@vzn/vx 0.0.637 → 0.0.639

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/docs/caching.md +84 -28
  2. package/docs/cli.md +273 -408
  3. package/docs/execution.md +19 -44
  4. package/docs/features.md +13 -15
  5. package/docs/patterns.md +5 -7
  6. package/docs/schema.md +68 -61
  7. package/package.json +5 -5
  8. package/schemas/last.json +7 -4
  9. package/schemas/plan.json +2 -2
  10. package/skills/vx/SKILL.md +19 -6
  11. package/src/bin.ts +13 -7
  12. package/src/cache/cache.ts +60 -11
  13. package/src/cache/git-inputs.ts +6 -0
  14. package/src/cache/index.ts +10 -2
  15. package/src/cache/inputs.ts +0 -24
  16. package/src/cache/layer.ts +32 -12
  17. package/src/cache/layered-cache.ts +112 -315
  18. package/src/cache/plugin-cache.ts +240 -0
  19. package/src/cache/policy.ts +9 -0
  20. package/src/cache/remote-lane.ts +383 -0
  21. package/src/cache/run-history.ts +92 -8
  22. package/src/cache/run-store.ts +77 -0
  23. package/src/cache/schema.ts +44 -13
  24. package/src/cache/scoped-cache.ts +22 -2
  25. package/src/cli/cache.ts +33 -16
  26. package/src/cli/completions.ts +11 -7
  27. package/src/cli/foreign-flags.ts +10 -22
  28. package/src/cli/help.ts +22 -38
  29. package/src/cli/index.ts +25 -11
  30. package/src/cli/info.ts +8 -6
  31. package/src/cli/init.ts +14 -15
  32. package/src/cli/last.ts +20 -10
  33. package/src/cli/lock.ts +39 -27
  34. package/src/cli/plan-format.ts +10 -61
  35. package/src/cli/plugin-commands.ts +77 -6
  36. package/src/cli/plugin-templates.ts +1 -1
  37. package/src/cli/run.ts +31 -191
  38. package/src/cli/select.ts +18 -9
  39. package/src/cli/show.ts +72 -40
  40. package/src/cli/task-verb.ts +4 -4
  41. package/src/cli/upgrade.ts +132 -39
  42. package/src/cli/watch-cycle.ts +2 -1
  43. package/src/cli/watch-filter.ts +7 -2
  44. package/src/cli/watch-judge.ts +6 -1
  45. package/src/cli/watch.ts +73 -34
  46. package/src/cli/why.ts +14 -5
  47. package/src/cli/workspace-config.ts +2 -2
  48. package/src/config.ts +0 -6
  49. package/src/exec/executor.ts +1 -1
  50. package/src/exec/sandbox-runtime.ts +1 -1
  51. package/src/exec/sandbox-violations.ts +3 -1
  52. package/src/graph/index.ts +9 -1
  53. package/src/graph/priorities.ts +1 -1
  54. package/src/graph/scheduler.ts +12 -16
  55. package/src/graph/task-graph.ts +119 -21
  56. package/src/graph/task-status.ts +95 -0
  57. package/src/{orchestrator → history}/failure-mode.ts +20 -11
  58. package/src/{orchestrator → history}/history.ts +61 -5
  59. package/src/history/index.ts +48 -0
  60. package/src/{orchestrator → history}/metrics.ts +14 -23
  61. package/src/{orchestrator → history}/miss-reason.ts +1 -1
  62. package/src/index.ts +21 -29
  63. package/src/orchestrator/affected-tasks.ts +8 -3
  64. package/src/orchestrator/doctor.ts +2 -2
  65. package/src/orchestrator/execute-args.ts +80 -0
  66. package/src/orchestrator/execute-task.ts +47 -429
  67. package/src/orchestrator/fingerprint-watch.ts +2 -2
  68. package/src/orchestrator/index.ts +3 -74
  69. package/src/orchestrator/miss-save.ts +33 -3
  70. package/src/orchestrator/options.ts +1 -36
  71. package/src/orchestrator/output-host.ts +4 -3
  72. package/src/orchestrator/persistent-task.ts +321 -0
  73. package/src/orchestrator/persistent.ts +130 -3
  74. package/src/orchestrator/placement.ts +3 -3
  75. package/src/orchestrator/plan.ts +18 -5
  76. package/src/orchestrator/plugin-host.ts +77 -47
  77. package/src/orchestrator/plugin.ts +86 -14
  78. package/src/orchestrator/prepare.ts +74 -31
  79. package/src/orchestrator/projects.ts +3 -0
  80. package/src/orchestrator/remote-prefetch.ts +23 -6
  81. package/src/orchestrator/run-artifacts.ts +5 -177
  82. package/src/orchestrator/run-context.ts +55 -0
  83. package/src/orchestrator/run-failures.ts +15 -3
  84. package/src/orchestrator/run-records.ts +36 -4
  85. package/src/orchestrator/run-report.ts +81 -179
  86. package/src/orchestrator/run-settings.ts +1 -16
  87. package/src/orchestrator/run-stop.ts +153 -0
  88. package/src/orchestrator/run.ts +313 -555
  89. package/src/orchestrator/sandbox-fix.ts +0 -16
  90. package/src/orchestrator/signals.ts +14 -6
  91. package/src/orchestrator/stable-keys.ts +107 -3
  92. package/src/orchestrator/steps.ts +50 -0
  93. package/src/orchestrator/task-hash.ts +3 -16
  94. package/src/orchestrator/task-log-buffer.ts +1 -1
  95. package/src/orchestrator/task-process.ts +94 -0
  96. package/src/orchestrator/telemetry-host.ts +1 -1
  97. package/src/orchestrator/telemetry.ts +6 -83
  98. package/src/{orchestrator → report}/events.ts +41 -8
  99. package/src/{orchestrator → report}/forecast.ts +35 -2
  100. package/src/{orchestrator → report}/framed-output.ts +39 -34
  101. package/src/report/index.ts +36 -0
  102. package/src/{orchestrator → report}/logger.ts +166 -21
  103. package/src/{orchestrator → report}/status-line.ts +84 -7
  104. package/src/{orchestrator → report}/summary.ts +179 -63
  105. package/src/report/tally.ts +83 -0
  106. package/src/settings.ts +13 -23
  107. package/src/{orchestrator → util}/colors.ts +49 -4
  108. package/src/util/config-frame.ts +31 -2
  109. package/src/util/errors.ts +32 -0
  110. package/src/util/git-ignored.ts +143 -0
  111. package/src/util/index.ts +25 -3
  112. package/src/util/refusal-notice.ts +111 -0
  113. package/src/util/report.ts +39 -0
  114. package/src/util/settle.ts +14 -39
  115. package/src/util/tail.ts +1 -1
  116. package/src/util/ui.ts +197 -0
  117. package/src/workspace/affected.ts +20 -23
  118. package/src/workspace/dependency-rules.ts +12 -4
  119. package/src/workspace/migration.ts +87 -91
  120. package/src/workspace/workspace.ts +5 -4
  121. package/schemas/summary.json +0 -133
  122. package/src/cache/chained-cache.ts +0 -276
  123. package/src/orchestrator/tally.ts +0 -112
  124. /package/src/{orchestrator → report}/path-links.ts +0 -0
  125. /package/src/{orchestrator → report}/plain-output.ts +0 -0
  126. /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.637",
3
+ "version": "0.0.639",
4
4
  "description": "Task orchestration you own: correct, sandboxed, extensible, with a content-addressed build cache.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,10 +21,10 @@
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.637",
25
- "@vzn/vx-linux-arm64": "0.0.637",
26
- "@vzn/vx-darwin-x64": "0.0.637",
27
- "@vzn/vx-darwin-arm64": "0.0.637"
24
+ "@vzn/vx-linux-x64": "0.0.639",
25
+ "@vzn/vx-linux-arm64": "0.0.639",
26
+ "@vzn/vx-darwin-x64": "0.0.639",
27
+ "@vzn/vx-darwin-arm64": "0.0.639"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
package/schemas/last.json CHANGED
@@ -107,8 +107,7 @@
107
107
  "host",
108
108
  "os",
109
109
  "arch",
110
- "vxVersion",
111
- "tags"
110
+ "vxVersion"
112
111
  ],
113
112
  "properties": {
114
113
  "runId": { "type": "string" },
@@ -146,8 +145,7 @@
146
145
  "host": { "type": ["string", "null"] },
147
146
  "os": { "type": ["string", "null"] },
148
147
  "arch": { "type": ["string", "null"] },
149
- "vxVersion": { "type": "string" },
150
- "tags": { "type": "object", "additionalProperties": { "type": "string" } }
148
+ "vxVersion": { "type": "string" }
151
149
  }
152
150
  },
153
151
  "task": {
@@ -174,6 +172,7 @@
174
172
  "timedOut",
175
173
  "sandboxViolations",
176
174
  "notReady",
175
+ "remoteError",
177
176
  "forwarded"
178
177
  ],
179
178
  "properties": {
@@ -209,6 +208,10 @@
209
208
  "timedOut": { "type": ["boolean", "null"] },
210
209
  "sandboxViolations": { "type": ["integer", "null"] },
211
210
  "notReady": { "enum": ["timeout", "exited", "spawn", null] },
211
+ "remoteError": {
212
+ "type": ["string", "null"],
213
+ "description": "each failed remote read or write of the key, `; `-joined"
214
+ },
212
215
  "forwarded": { "type": "boolean" },
213
216
  "output": {
214
217
  "type": "string",
package/schemas/plan.json CHANGED
@@ -45,8 +45,8 @@
45
45
  "required": ["kind"],
46
46
  "properties": {
47
47
  "kind": {
48
- "enum": ["input", "project", "package", "named", "selected"],
49
- "description": "input: a changed file is a declared input; project: its project changed and the task reads it whole; package: a ^ edge passes a changed package; named: asked as pkg#task; selected: another filter selected its project"
48
+ "enum": ["input", "project", "workspace", "package", "named", "selected"],
49
+ "description": "input: a changed file is a declared input; project: its project changed and the task reads it whole; workspace: a workspace-wide file (the workspace config, a root file a plugin claims) changed; package: a ^ edge passes a changed package; named: asked as pkg#task; selected: another filter selected its project"
50
50
  },
51
51
  "file": { "type": "string", "description": "the changed file, workspace-relative" },
52
52
  "project": { "type": "string", "description": "the changed package (kind package)" },
@@ -15,22 +15,33 @@ dependencies.
15
15
  vx run build --all # a task in every project that declares it
16
16
  vx run test --affected=origin/main # only what the diff since origin/main reaches
17
17
  vx run app#build # one project's task (and what it depends on)
18
- vx run build --all --format json # the result as JSON on stdout; logs go to stderr
18
+ vx last --format json # the last run's result as JSON
19
19
  ```
20
20
 
21
21
  The exit code is the verdict: `0` every task passed or hit the cache, `1` a
22
- task failed or was skipped, or the command was refused. `--format json`
23
- prints `schemas/summary.json` (`runId`, `ok`, `exitCode`, a row per task).
24
- A refusal under `--format json` is one line on stdout,
22
+ task failed or was skipped, or the command was refused. `vx last --format
23
+ json` prints the run as JSON afterwards, a row per task. A refusal under
24
+ `--format json` (or `--dry=json`) is one line on stdout,
25
25
  `{"ok":false,"error":{"code","message","docs"}}`: branch on `code`;
26
26
  `docs` (or `vx docs <code>` offline) says what to do.
27
27
  Without a TTY a missing task name never prompts: vx exits 1 and lists the
28
28
  tasks it found.
29
29
 
30
+ ## After editing a config
31
+
32
+ ```sh
33
+ vx lock # after any edit to a vx.config.ts or .vx/workspace.ts
34
+ ```
35
+
36
+ A run reads every config from `.vx/projects.lock.json`, never the files:
37
+ a task you add, or an edit to one, does nothing until `vx lock`. Commit
38
+ the lock with the config.
39
+
30
40
  ## Look before running
31
41
 
32
42
  ```sh
33
- vx show --format json # projects, their tasks and dependencies
43
+ vx show --format json # projects and their tasks
44
+ vx show app --format json # one project's tasks as a run reads them
34
45
  vx run test --affected=origin/main --dry=json # what would run, cache hit or miss, and why
35
46
  ```
36
47
 
@@ -94,6 +105,8 @@ vx info --format json # vx and Bun versions, projects, config errors, plugins,
94
105
 
95
106
  ## Install
96
107
 
97
- `npx skills add vznjs/vx/packages/vx/skills/vx` installs this skill. To match the vx a workspace
108
+ `npx skills add vznjs/vx` installs this skill. To match the vx a workspace
98
109
  has installed, copy it from there instead:
99
110
  `cp node_modules/@vzn/vx/skills/vx/SKILL.md .claude/skills/vx/`.
111
+ `vx upgrade` inside a workspace moves the project's `@vzn/vx` and the
112
+ global vx that ran it to the same version.
package/src/bin.ts CHANGED
@@ -46,7 +46,7 @@ async function main(): Promise<void> {
46
46
  process.exitCode = code
47
47
  } catch (err) {
48
48
  const {
49
- configErrorFrame,
49
+ detectColors,
50
50
  errorCode,
51
51
  errorDocument,
52
52
  fsRefusalHint,
@@ -55,6 +55,8 @@ async function main(): Promise<void> {
55
55
  isUserError,
56
56
  maskedLine,
57
57
  OUT_OF_FDS_HINT,
58
+ refusalNotice,
59
+ ui,
58
60
  wantsJson,
59
61
  } = await import('./util/index.js')
60
62
  // A plugin's failure quotes what it was told (a remote's reply, a
@@ -66,12 +68,16 @@ async function main(): Promise<void> {
66
68
  // on. Everything else gets the full stack so internal bugs are
67
69
  // debuggable.
68
70
  if (isUserError(err)) {
69
- // A message that already names the tool (`vx why: …`, thrown by a verb
70
- // that wants its own name in the line) is printed as it is; prefixing
71
- // it produced `vx: vx why: …` (walkthrough, 2026-09-04).
72
- // A config refusal names its line and shows it.
73
- const m = configErrorFrame(err.message, process.cwd()) ?? err.message
74
- say(m.startsWith('vx ') ? `${m}\n` : `vx: ${m}\n`)
71
+ // A message that names its verb (`vx why: …`) is that verb's; any
72
+ // other is the verb typed. A config refusal shows its line.
73
+ const named = /^(vx [a-z][\w-]*): /.exec(err.message)
74
+ const typed = /^[a-z][\w-]*$/.test(argv[0] ?? '') ? `vx ${argv[0]}` : 'vx'
75
+ const block = refusalNotice(
76
+ named?.[1] ?? typed,
77
+ named === null ? err.message : err.message.slice(named[0].length),
78
+ err.parts,
79
+ )
80
+ process.stderr.write(maskedLine(ui.render([block], detectColors(process.stderr))))
75
81
  } else if (isOutOfFds(err)) {
76
82
  say(`vx: ${err.message} — ${OUT_OF_FDS_HINT}\n`)
77
83
  } else if (isFsRefusal(err)) {
@@ -96,6 +96,7 @@ import {
96
96
  type PruneResult,
97
97
  type RunRecord,
98
98
  type SaveArgs,
99
+ type TaskInputComponent,
99
100
  type TaskInputRow,
100
101
  WORKSPACE_OUTPUT_PREFIX,
101
102
  } from './layer.js'
@@ -303,17 +304,25 @@ function outOfFdsAtOpen(dbFile: string, err: unknown): UserError | undefined {
303
304
  // unchanged.
304
305
  // v36: the store's task_durations: each executed success's time, tagged
305
306
  // by branch, read by the forecast. The cache KEY is unchanged.
306
- export const SCHEMA_VERSION = 'v36'
307
+ // v37: the store's plugin_blobs (a plugin's `ctx.cache`) and one
308
+ // 'remote' task_durations row per (sha, task). The cache KEY is
309
+ // unchanged.
310
+ // v38: blob_sizes moved to the store, and the store's task_outcomes
311
+ // holds the flaky record, so worktrees share both. The cache KEY is
312
+ // unchanged.
313
+ // v39: invocations drops `tags` (`--tag` is gone). The cache KEY is
314
+ // unchanged.
315
+ // v40: runs.remote_error — each remote read or write of a task's key that
316
+ // failed, for `vx last`. The cache KEY is unchanged.
317
+ export const SCHEMA_VERSION = 'v40'
307
318
 
308
319
  /**
309
320
  * An entry's key components as its one `entry_inputs` row (v34): JSON
310
321
  * `[kind, name, hash]` triples in (kind, name) code-unit order, the first
311
322
  * of a repeated (kind, name) kept, as the per-component primary key did.
312
323
  */
313
- function encodeEntryInputs(
314
- components: readonly Pick<TaskInputRow, 'kind' | 'name' | 'hash'>[],
315
- ): string {
316
- const byId = new Map<string, Pick<TaskInputRow, 'kind' | 'name' | 'hash'>>()
324
+ function encodeEntryInputs(components: readonly TaskInputComponent[]): string {
325
+ const byId = new Map<string, TaskInputComponent>()
317
326
  for (const c of components) {
318
327
  const id = `${c.kind}\0${c.name}`
319
328
  if (!byId.has(id)) byId.set(id, c)
@@ -333,6 +342,9 @@ const STORE_TABLES = [
333
342
  'output_files',
334
343
  'entry_stdout',
335
344
  'task_durations',
345
+ 'task_outcomes',
346
+ 'blob_sizes',
347
+ 'plugin_blobs',
336
348
  'store_meta',
337
349
  'entries',
338
350
  ]
@@ -779,6 +791,23 @@ export interface UploadTally {
779
791
  failed: number
780
792
  }
781
793
 
794
+ /** One task's traffic with the remotes this run, by its key. */
795
+ export interface RemoteKeyTraffic {
796
+ /** Its uploads queued or running. */
797
+ uploading: number
798
+ /** An upload of it landed on a remote. */
799
+ landed: boolean
800
+ /** Each distinct remote read or write of it that failed: `upload: <message>`. */
801
+ failures: string[]
802
+ }
803
+
804
+ /** The run's remote traffic per key: what the footer's remote part counts, task by task. */
805
+ export interface RemoteTraffic {
806
+ /** Uploads queued or running across every remote. */
807
+ inFlight: number
808
+ readonly keys: Map<string, RemoteKeyTraffic>
809
+ }
810
+
782
811
  export class Cache implements CacheLayer {
783
812
  /** This IS the local layer — there is nothing slower behind it. */
784
813
  readonly hasRemote = false
@@ -788,6 +817,8 @@ export class Cache implements CacheLayer {
788
817
  * so the run can report them without a method on the layer seam.
789
818
  */
790
819
  readonly uploads: UploadTally = { count: 0, bytes: 0, ms: 0, failed: 0 }
820
+ /** The same remotes' traffic per key, filled by the same `LayeredCache`. */
821
+ readonly traffic: RemoteTraffic = { inFlight: 0, keys: new Map() }
791
822
 
792
823
  private readonly db: Database
793
824
  private readonly insertEntry: ReturnType<Database['prepare']>
@@ -1030,7 +1061,7 @@ export class Cache implements CacheLayer {
1030
1061
  // keeps it so).
1031
1062
  if (found !== undefined) {
1032
1063
  this.db.exec(
1033
- 'DROP TABLE IF EXISTS main.entries; DROP TABLE IF EXISTS main.runs; DROP TABLE IF EXISTS main.file_hashes; DROP TABLE IF EXISTS main.blob_sizes; DROP TABLE IF EXISTS main.blob_verdicts; DROP TABLE IF EXISTS main.output_files; DROP TABLE IF EXISTS main.output_stamps; DROP TABLE IF EXISTS main.invocations; DROP TABLE IF EXISTS main.run_task_inputs; DROP TABLE IF EXISTS main.entry_inputs; DROP TABLE IF EXISTS main.config_evals; DROP TABLE IF EXISTS main.config_closures; DROP TABLE IF EXISTS main.output_dirs; DROP TABLE IF EXISTS main.entry_stdout; DROP TABLE IF EXISTS main.task_durations; DROP TABLE IF EXISTS main.store_meta;',
1064
+ 'DROP TABLE IF EXISTS main.entries; DROP TABLE IF EXISTS main.runs; DROP TABLE IF EXISTS main.file_hashes; DROP TABLE IF EXISTS main.blob_sizes; DROP TABLE IF EXISTS main.blob_verdicts; DROP TABLE IF EXISTS main.output_files; DROP TABLE IF EXISTS main.output_stamps; DROP TABLE IF EXISTS main.invocations; DROP TABLE IF EXISTS main.run_task_inputs; DROP TABLE IF EXISTS main.entry_inputs; DROP TABLE IF EXISTS main.config_evals; DROP TABLE IF EXISTS main.config_closures; DROP TABLE IF EXISTS main.output_dirs; DROP TABLE IF EXISTS main.entry_stdout; DROP TABLE IF EXISTS main.task_durations; DROP TABLE IF EXISTS main.plugin_blobs; DROP TABLE IF EXISTS main.task_outcomes; DROP TABLE IF EXISTS main.store_meta;',
1034
1065
  )
1035
1066
  }
1036
1067
  // The tables land under the same write lock as the stamp: made
@@ -1737,11 +1768,26 @@ export class Cache implements CacheLayer {
1737
1768
  return this.guard(() => this.hasEntry(hash))
1738
1769
  }
1739
1770
 
1771
+ /**
1772
+ * The subset of `hashes` this store holds, `has` for each: the prefetch
1773
+ * pass asks the remote only about the rest (M, 2026-10-15).
1774
+ */
1775
+ async localHasMany(hashes: readonly string[]): Promise<Set<string>> {
1776
+ const out = new Set<string>()
1777
+ for (const hash of hashes) if ((await this.has(hash)) === 'local') out.add(hash)
1778
+ return out
1779
+ }
1780
+
1740
1781
  private async hasEntry(hash: string): Promise<'local' | 'remote' | null> {
1741
1782
  if (!this.read) return null
1783
+ return this.storedHere(hash) ? 'local' : null
1784
+ }
1785
+
1786
+ /** The index row and its artifact are both here, whatever the run may read. */
1787
+ private storedHere(hash: string): boolean {
1742
1788
  const row = this.entryExists.get(hash) as { inline_at: number | null } | null
1743
- if (row === null) return null
1744
- return row.inline_at !== null || existsSync(this.tarPath(hash)) ? 'local' : null
1789
+ if (row === null) return false
1790
+ return row.inline_at !== null || existsSync(this.tarPath(hash))
1745
1791
  }
1746
1792
 
1747
1793
  // Local cache has no slower layer to warm from — prefetch is a no-op.
@@ -2035,8 +2081,6 @@ export class Cache implements CacheLayer {
2035
2081
  entry: Omit<CacheEntry, 'hash' | 'storedAt' | 'outputFiles' | 'exitCode'>
2036
2082
  projectDir: string
2037
2083
  outputFiles: string[]
2038
- /** See `CacheLayer.save`. */
2039
- skipLocalWrite?: boolean
2040
2084
  workspaceOutputFiles?: string[]
2041
2085
  workspaceRoot?: string
2042
2086
  inputComponents?: readonly TaskInputRow[]
@@ -2061,7 +2105,9 @@ export class Cache implements CacheLayer {
2061
2105
  // still uploads to remote — it calls `packArtifact` itself to get
2062
2106
  // the bytes, since there's no local artifact to read off disk.
2063
2107
  if (!this.write) return
2064
- if (args.skipLocalWrite === true) return
2108
+ // A cache that reads saves only on a miss. One that does not (`--force`)
2109
+ // re-executes a key it may hold: that entry is kept as it is.
2110
+ if (!this.read && this.guard(() => this.storedHere(args.hash))) return
2065
2111
  // The cache directory is the constructor's (`mkdirSync`, with a
2066
2112
  // refusal recorded for `assertWritable`); re-creating it here and
2067
2113
  // again in `writeArtifactAndIndex` cost every save two thread-pool
@@ -2612,6 +2658,9 @@ export class Cache implements CacheLayer {
2612
2658
  recordRunBundle(bundle: { runs: readonly RunRecord[]; invocation: InvocationRecord }): void {
2613
2659
  return this.guard(() => this.history.recordRunBundle(bundle))
2614
2660
  }
2661
+ recordRemoteErrors(runId: string, errors: readonly (readonly [string, string])[]): void {
2662
+ return this.guard(() => this.history.recordRemoteErrors(runId, errors))
2663
+ }
2615
2664
  stats(opts: CacheStatsOptions = {}): CacheStats {
2616
2665
  return this.guard(() => this.statsOf(opts))
2617
2666
  }
@@ -289,6 +289,12 @@ export class GitFilesCache extends Map<string, readonly string[]> {
289
289
  * a cache no enumeration filled; its OIDs are then re-checked by content.
290
290
  */
291
291
  enumeratedAtMs: number | undefined
292
+ /**
293
+ * The declared outputs (root-relative globs) git ignores whole, asked
294
+ * once per run beside the enumeration: no key reads them, so a reader
295
+ * beside their producer keys up front (`dependsOnSiblingOutputs`).
296
+ */
297
+ ignoredOutputs: ReadonlySet<string> = new Set()
292
298
 
293
299
  get undecodableNames(): ReadonlySet<string> {
294
300
  return this.undecodable
@@ -15,11 +15,13 @@ export {
15
15
  type CachePolicy,
16
16
  cachesNothing,
17
17
  EXECUTED_RUNS_SQL,
18
+ formatCachePolicy,
18
19
  FULL_CACHE_POLICY,
19
20
  type InvocationRecord,
20
21
  KEYED_RUNS_SQL,
21
22
  parseCachePolicy,
22
23
  type RunRecord,
24
+ type TaskInputComponent,
23
25
  type TaskInputRow,
24
26
  WORKSPACE_OUTPUT_PREFIX,
25
27
  type OutputDirRow,
@@ -47,7 +49,6 @@ export {
47
49
  type ProjectFilesCache,
48
50
  type WorkspaceFilesCache,
49
51
  cleanOutputPaths,
50
- anyUntouched,
51
52
  ownOutputsSince,
52
53
  ownWorkspaceOutputsSince,
53
54
  stampOutputs,
@@ -69,6 +70,13 @@ export {
69
70
  type BlobSizeMemo,
70
71
  } from './git-inputs.js'
71
72
  export { LayeredCache, type RemoteCacheLayer } from './layered-cache.js'
73
+ export type { ResolvedRemote } from './remote-lane.js'
72
74
  export { ScopedCache } from './scoped-cache.js'
73
- export { ChainedCache } from './chained-cache.js'
75
+ export {
76
+ absentPluginCache,
77
+ pluginCache,
78
+ PLUGIN_ENTRY_MAX_BYTES,
79
+ type PluginCache,
80
+ } from './plugin-cache.js'
81
+ export { packRunOutputs, putRunEntry, runEntryName, writesRunStore } from './run-store.js'
74
82
  export { branchSource } from './run-history.js'
@@ -81,7 +81,6 @@ const ALWAYS_IGNORE = [
81
81
  '**/.git/**',
82
82
  // vx's machine-local state; the workspace config beside it is a source.
83
83
  '**/.vx/cache/**',
84
- '**/*.tsbuildinfo',
85
84
  '**/.vx/projects.lock.json',
86
85
  // `vx lock`'s review copy of a project's resolved config: the config is
87
86
  // keyed already, and a re-lock must not move every `**` reader's key.
@@ -1018,29 +1017,6 @@ function changedSince(
1018
1017
  return kept < before.size ? undefined : own
1019
1018
  }
1020
1019
 
1021
- /**
1022
- * Is any file `before` stamped still there, unchanged? Under
1023
- * `cache.outputs.clean: false` such a file is one the run found and never
1024
- * wrote, so no entry may claim it as the run's output.
1025
- */
1026
- export function anyUntouched(before: ReadonlyMap<string, OutputStamp>): boolean {
1027
- for (const [f, was] of before) {
1028
- try {
1029
- const st = lstatSync(f)
1030
- if (
1031
- st.size === was.size &&
1032
- st.mtimeMs === was.mtimeMs &&
1033
- st.ino === was.ino &&
1034
- st.ctimeMs === was.ctimeMs
1035
- )
1036
- return true
1037
- } catch {
1038
- // Removed by the run: nothing of it is left to claim.
1039
- }
1040
- }
1041
- return false
1042
- }
1043
-
1044
1020
  /**
1045
1021
  * Up to this many paths, a clean removes synchronously: the threadpool round
1046
1022
  * trip per `rm`/`rmdir` cost more than the unlink itself, 0.30 ms against
@@ -1,5 +1,5 @@
1
1
  // The cache layer CONTRACT and its records — what every cache implementation
2
- // honours (`Cache`, `LayeredCache`, `ChainedCache`, a plugin's remote layer) and
2
+ // honours (`Cache`, `LayeredCache`, a plugin's remote layer) and
3
3
  // the shapes that cross it: the key input, entries, run-history rows, output
4
4
  // fingerprints, stats, prune options. No implementation lives here; the one
5
5
  // reference to `Cache` below is a type (the local handle a layer may wrap).
@@ -208,6 +208,8 @@ export interface RunRecord {
208
208
  timedOut?: true
209
209
  sandboxViolations?: number
210
210
  notReady?: 'timeout' | 'exited' | 'spawn'
211
+ /** Each remote read or write of its key that failed, `upload: <message>`, `; `-joined (v40). */
212
+ remoteError?: string
211
213
  }
212
214
 
213
215
  /**
@@ -221,7 +223,7 @@ export interface InvocationRecord {
221
223
  command: string
222
224
  /** JSON-serialized `string[]` of the requested task names. */
223
225
  requestedTasks: string
224
- /** Compact policy flags, e.g. `'lR,lW,rR,rW'` (an axis omitted = off). */
226
+ /** The `--cache-policy` spec, e.g. `'local:rw,remote:r'` (a flag omitted = off). */
225
227
  cachePolicy: string
226
228
  concurrency: number
227
229
  flow: 'focused' | 'broad' | null
@@ -247,8 +249,18 @@ export interface InvocationRecord {
247
249
  os: string | null
248
250
  arch: string | null
249
251
  vxVersion: string
250
- /** JSON object `{k:v}` of `--tag` pairs. */
251
- tags: string
252
+ }
253
+
254
+ /**
255
+ * One cache-key component as `key()` folded it, the `captureInto` row.
256
+ * Persisted on a miss (`TaskInputRow`) so a later run can diff its inputs
257
+ * against this one (the Tier-3 "why did this re-run?" moat); a hit saves
258
+ * nothing, so the warm path is free.
259
+ */
260
+ export interface TaskInputComponent {
261
+ kind: string
262
+ name: string
263
+ hash: string
252
264
  }
253
265
 
254
266
  /**
@@ -544,12 +556,25 @@ export interface CacheLayer {
544
556
  * batch omits it and the prefetch pass falls back to per-hash GETs.
545
557
  */
546
558
  remoteHasMany?(hashes: readonly string[]): Promise<Set<string> | null>
559
+ /**
560
+ * The subset of `hashes` the local store already holds. The prefetch
561
+ * pass probes the remote only for the rest, so a run the local cache
562
+ * answers makes no remote call. Absent reads as "none held".
563
+ */
564
+ localHasMany?(hashes: readonly string[]): Promise<Set<string>>
547
565
  /**
548
566
  * Record that the remote has NO artifact for each of `hashes` (from a
549
567
  * batch probe), so a later `get`/`prefetch` short-circuits to a miss with
550
568
  * no round-trip. Only meaningful with `hasRemote`.
551
569
  */
552
570
  markRemoteAbsent?(hashes: Iterable<string>): void
571
+ /**
572
+ * The run's up-front keys, for each remote the run writes but does not
573
+ * read (`--force`): one batch probe in the background, and an upload of
574
+ * a key the remote already holds is skipped. Only meaningful with
575
+ * `hasRemote`.
576
+ */
577
+ probeBeforeUpload?(hashes: Promise<readonly string[]>): void
553
578
  /**
554
579
  * Resolve once every background write-through upload settles. The
555
580
  * orchestrator awaits it before `close()` — an upload reading layer state
@@ -557,6 +582,8 @@ export interface CacheLayer {
557
582
  * uploads synchronously (or not at all) omits it.
558
583
  */
559
584
  drainUploads?(): Promise<void>
585
+ /** The run was stopped: start no more uploads, and let `drainUploads` resolve now. */
586
+ stopUploads?(): void
560
587
  key(input: CacheKeyInput): Promise<string>
561
588
  get(hash: string, ctx?: CacheGetContext): Promise<CacheEntry | null>
562
589
  /**
@@ -574,7 +601,7 @@ export interface CacheLayer {
574
601
  * artifact read, no remote download, no local ingest, no accessed_at
575
602
  * bump (on the remote side the layer decides the call: an HTTP HEAD, or
576
603
  * REAPI's `getActionResult` + `findMissingBlobs`). Planning
577
- * (`--dry` / `--graph`) predicts hits with this instead of `get` so a
604
+ * (`--dry`) predicts hits with this instead of `get` so a
578
605
  * dry run can't pull N artifacts over the network. Remote errors
579
606
  * degrade to `null` — an existence probe never fails anything.
580
607
  */
@@ -664,13 +691,6 @@ export interface CacheLayer {
664
691
  entry: Omit<CacheEntry, 'hash' | 'storedAt' | 'outputFiles' | 'exitCode'>
665
692
  projectDir: string
666
693
  outputFiles: string[]
667
- /**
668
- * Set only by ChainedCache when an EARLIER layer in the chain already
669
- * wrote this artifact to the same local handle: the local pack + write
670
- * is skipped and only the layer's remote side acts. A layer with no
671
- * remote side treats it as a full no-op.
672
- */
673
- skipLocalWrite?: boolean
674
694
  /**
675
695
  * Resolved `outputs.workspaceFiles` (absolute paths) + the root
676
696
  * they're relative to. Packed under `workspace-outputs/<rel>`.