@vzn/vx 0.0.21 → 0.0.22

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 (123) hide show
  1. package/README.md +21 -18
  2. package/package.json +6 -6
  3. package/src/bin.ts +52 -3
  4. package/src/cache/archive.ts +170 -21
  5. package/src/cache/cache.ts +667 -288
  6. package/src/cache/chained-cache.ts +14 -2
  7. package/src/cache/config-evals.ts +29 -6
  8. package/src/cache/file-hashes.ts +63 -35
  9. package/src/cache/git-inputs.ts +398 -69
  10. package/src/cache/index.ts +8 -1
  11. package/src/cache/inputs.ts +403 -102
  12. package/src/cache/key-fold.ts +232 -0
  13. package/src/cache/layer.ts +81 -11
  14. package/src/cache/layered-cache.ts +199 -32
  15. package/src/cache/output-index.ts +109 -10
  16. package/src/cache/run-history.ts +8 -3
  17. package/src/cache/zstd.ts +53 -30
  18. package/src/cli/cache.ts +51 -21
  19. package/src/cli/format.ts +4 -15
  20. package/src/cli/help.ts +13 -3
  21. package/src/cli/index.ts +35 -9
  22. package/src/cli/info.ts +49 -7
  23. package/src/cli/init.ts +17 -5
  24. package/src/cli/last.ts +96 -21
  25. package/src/cli/lock.ts +34 -17
  26. package/src/cli/plan-format.ts +4 -4
  27. package/src/cli/plugin-commands.ts +20 -5
  28. package/src/cli/run.ts +55 -12
  29. package/src/cli/select.ts +23 -13
  30. package/src/cli/show.ts +9 -4
  31. package/src/cli/upgrade.ts +134 -14
  32. package/src/cli/watch.ts +611 -102
  33. package/src/cli/why.ts +8 -16
  34. package/src/cli/workspace-config.ts +21 -9
  35. package/src/config.ts +46 -9
  36. package/src/exec/executor.ts +51 -5
  37. package/src/exec/index.ts +10 -0
  38. package/src/exec/kill-tree.ts +281 -0
  39. package/src/exec/local-executor.ts +1 -1
  40. package/src/exec/runner.ts +327 -82
  41. package/src/exec/sandbox-binds.ts +34 -5
  42. package/src/exec/sandbox-paths.ts +22 -2
  43. package/src/exec/sandbox-runtime.ts +627 -102
  44. package/src/exec/sandbox-violations.ts +36 -23
  45. package/src/graph/dependency-spec.ts +14 -14
  46. package/src/graph/index.ts +12 -2
  47. package/src/graph/priorities.ts +130 -0
  48. package/src/graph/scheduler.ts +182 -134
  49. package/src/graph/task-graph.ts +552 -188
  50. package/src/index.ts +16 -2
  51. package/src/orchestrator/admission.ts +41 -17
  52. package/src/orchestrator/colors.ts +8 -3
  53. package/src/orchestrator/deferred-outputs.ts +22 -7
  54. package/src/orchestrator/doctor.ts +122 -14
  55. package/src/orchestrator/events.ts +71 -2
  56. package/src/orchestrator/excluded-keys.ts +155 -0
  57. package/src/orchestrator/execute-task.ts +331 -37
  58. package/src/orchestrator/failure-recap.ts +121 -0
  59. package/src/orchestrator/fingerprint-watch.ts +89 -0
  60. package/src/orchestrator/framed-output.ts +81 -14
  61. package/src/orchestrator/history.ts +6 -7
  62. package/src/orchestrator/hit-restore.ts +82 -18
  63. package/src/orchestrator/index.ts +7 -1
  64. package/src/orchestrator/keyed-projects.ts +89 -0
  65. package/src/orchestrator/local-shortcircuit.ts +83 -23
  66. package/src/orchestrator/lockfile-claim.ts +152 -32
  67. package/src/orchestrator/logger.ts +116 -20
  68. package/src/orchestrator/metrics.ts +156 -60
  69. package/src/orchestrator/miss-save.ts +106 -43
  70. package/src/orchestrator/options.ts +30 -4
  71. package/src/orchestrator/persistent.ts +41 -21
  72. package/src/orchestrator/placement.ts +55 -25
  73. package/src/orchestrator/plan.ts +24 -11
  74. package/src/orchestrator/plugin-host.ts +17 -23
  75. package/src/orchestrator/plugin.ts +31 -8
  76. package/src/orchestrator/prepare.ts +99 -13
  77. package/src/orchestrator/projects.ts +19 -8
  78. package/src/orchestrator/remote-prefetch.ts +21 -1
  79. package/src/orchestrator/run-artifacts.ts +9 -0
  80. package/src/orchestrator/run-context.ts +19 -4
  81. package/src/orchestrator/run-lock.ts +349 -0
  82. package/src/orchestrator/run-records.ts +6 -23
  83. package/src/orchestrator/run-report.ts +4 -2
  84. package/src/orchestrator/run.ts +264 -57
  85. package/src/orchestrator/sandbox-request.ts +388 -62
  86. package/src/orchestrator/save-lane.ts +4 -7
  87. package/src/orchestrator/shell-verdict.ts +117 -0
  88. package/src/orchestrator/signals.ts +86 -30
  89. package/src/orchestrator/stable-keys.ts +202 -10
  90. package/src/orchestrator/status-line.ts +33 -1
  91. package/src/orchestrator/summary.ts +49 -2
  92. package/src/orchestrator/task-hash.ts +87 -7
  93. package/src/orchestrator/task-log-buffer.ts +7 -7
  94. package/src/orchestrator/telemetry-host.ts +16 -7
  95. package/src/orchestrator/telemetry.ts +69 -28
  96. package/src/orchestrator/upstream.ts +91 -45
  97. package/src/util/bun-version.ts +43 -0
  98. package/src/util/errors.ts +86 -0
  99. package/src/util/hash.ts +10 -1
  100. package/src/util/index.ts +32 -3
  101. package/src/util/paths.ts +147 -5
  102. package/src/util/procfs.ts +20 -0
  103. package/src/util/settle.ts +4 -2
  104. package/src/util/size.ts +33 -0
  105. package/src/util/task-id.ts +20 -0
  106. package/src/util/timing.ts +11 -1
  107. package/src/util/which.ts +48 -0
  108. package/src/workspace/affected.ts +272 -64
  109. package/src/workspace/config-cache.ts +262 -32
  110. package/src/workspace/config-eval.ts +49 -12
  111. package/src/workspace/config-imports.ts +126 -5
  112. package/src/workspace/config-schema.ts +237 -41
  113. package/src/workspace/filter.ts +33 -18
  114. package/src/workspace/fingerprint.ts +29 -11
  115. package/src/workspace/index.ts +7 -0
  116. package/src/workspace/json-data.ts +90 -0
  117. package/src/workspace/load-reads.ts +32 -0
  118. package/src/workspace/lockfile.ts +33 -11
  119. package/src/workspace/migrate-scripts.ts +60 -7
  120. package/src/workspace/migration.ts +40 -1
  121. package/src/workspace/package-graph.ts +154 -16
  122. package/src/workspace/project-loader.ts +227 -77
  123. package/src/workspace/workspace.ts +173 -23
@@ -0,0 +1,232 @@
1
+ // The cache key, derived: `foldKey` is the whole of what `Cache.key` computes,
2
+ // with the two things it read off the class (the file hasher and the
3
+ // workspace-relative path) passed in. Nothing here touches a store, so the
4
+ // fold runs wherever xxh3 does: the CLI, a remote layer, and the site's
5
+ // playground, which bundles this file to derive the keys the CLI would
6
+ // (design/playground-spike-2026-09.md, P1; item 691).
7
+
8
+ import { xxh3, xxh3hex } from '../util/index.js'
9
+ import type { CacheKeyInput } from './layer.js'
10
+
11
+ // v17: artifact carries only logs + outputs (stdout + outputs/<rel>).
12
+ // Local and remote layers transport the SAME tar.zst bytes — no
13
+ // separate stage/meta.json/tar.gz dance for remote, no
14
+ // `cache-archive.ts`. stderr is no longer cached: we only cache
15
+ // successful runs and stderr is rarely meaningful on success.
16
+ // v19: '^task' dependsOn expansion switched from transitive-deps to
17
+ // nearest-holder frontier — upstream-hash sets shrink, so keys change.
18
+ // v20: input-file content hashes switched from xxh3 to git blob OIDs
19
+ // (Turbo parity). Clean tracked files take their OID straight from
20
+ // the index (harvested by the bulk `git ls-files -s`); dirty /
21
+ // untracked files get the identical OID computed in-process. Every
22
+ // file's hash bytes change → bump. SCHEMA_VERSION moves with it:
23
+ // pre-v20 `file_hashes.content_hash` rows hold xxh3 digests that
24
+ // must not leak into the OID domain via the mtime+size memo.
25
+ // v22: reverted the v21 output-fold "early cutoff". Downstream keys
26
+ // fold the upstream's INPUT key (its task hash) again — pure-input
27
+ // transitive hashing (Turbo/Nx model). No output content participates
28
+ // in any cache key. SCHEMA v21 drops the now-unused outputs_hash
29
+ // column. Early cutoff removed (an upstream that re-emits identical
30
+ // output still re-runs dependents) — rare, not worth the cascade.
31
+ // v23: fold cache.inputs.runtime / workspaceRuntime command output into
32
+ // the key (two namespaced sections after env-values). Command strings
33
+ // live in the resolved config (frozen by `vx lock`); the OUTPUT is
34
+ // resolved live every run, so it stays correct under --frozen. No
35
+ // SCHEMA bump — only Cache.key derivation changed.
36
+ // v24: vx-lock.json added to the always-ignored input set (cache/
37
+ // inputs.ts ALWAYS_IGNORE). A task whose globs matched the root
38
+ // lockfile (broad `**/*` on the root project) drops it from the hashed
39
+ // file set, so those keys change; tasks that never matched it are
40
+ // byte-identical. No SCHEMA bump — only the input file set changed.
41
+ //
42
+ // CACHE_VERSION stays v24 through the Tier-3 schema roll (SCHEMA v22).
43
+ // Tier 3 persists the very components already fed to `Cache.key()` —
44
+ // it adds nothing to, reorders nothing in, and reweights nothing
45
+ // inside the key fold (the new `captureInto` sink is a pure
46
+ // side-channel). A task's hash is byte-identical before and after, so
47
+ // existing artifacts stay valid and there is no cache-version bump.
48
+ // v25: the ARTIFACT BYTES are wrong in every existing entry, while the
49
+ // key that addresses them is unchanged — the one situation a version
50
+ // bump exists for. Two defects: (a) `packArtifact` staged outputs with
51
+ // `Bun.write`, which does not carry the source mode, so every artifact
52
+ // records 0644 and a cache hit strips the executable bit off any binary
53
+ // or generated script; (b) it packed `--format=ustar`, which splits a
54
+ // name over 100 bytes into prefix+name, and the reader read only `name`
55
+ // — so those entries lost their `outputs/` prefix and were silently
56
+ // dropped from both the restore and the output_files index. Neither is
57
+ // self-healing: the stored bytes/headers are already wrong, so without a
58
+ // new namespace the fixed code would keep replaying them forever.
59
+ // v26: same situation, different producer. A task whose child was killed by
60
+ // a shutdown signal reported `aborted`, but `aborted` did not propagate to
61
+ // dependents — so a dependent ran against PARTIAL outputs, succeeded, and
62
+ // cached what it built. The fold takes the upstream's INPUT key, which a
63
+ // signal does not change, so that entry sits under exactly the key a healthy
64
+ // run derives and replays forever as a green hit. Fixing the propagation
65
+ // stops new poison but cannot reach entries already written, and a
66
+ // `LayeredCache` uploads them, so the reach is a whole team's shared cache
67
+ // rather than one developer's disk. Pre-alpha, so one cold rebuild is the
68
+ // cheap side of that trade.
69
+ // v28: the same shape again (item 667). An output glob over a bracket route
70
+ // directory (`app/[id]/page.js`) was read as a character class, so its
71
+ // entries saved nothing (or the class's namesakes) under a key the fix
72
+ // leaves unchanged: the glob text is what folds. Read literally, the first
73
+ // hit on such an entry cleaned the route and restored nothing, green.
74
+ // v29: every key moves (item 682). The seed-chained fold carried only 32
75
+ // bits of state (Bun's xxHash3 reads the low half of its seed), so two
76
+ // input sets could share a key; `xxh3` now feeds the seed forward. The
77
+ // old keys were wrong, not their bytes, so this is self-healing; the bump
78
+ // is for the first run's notice, since every entry misses once.
79
+ // v30: stored bytes wrong under an unchanged key (item 720). npm and Yarn
80
+ // link every workspace package at the root, the task's own included, and
81
+ // the sandbox granted that link's target whole: a sandboxed task read an
82
+ // undeclared file of its own project unreported and saved what it built.
83
+ // The fix withholds the self-link, but the key never saw the file, so an
84
+ // entry saved before it hits forever when only that file changes.
85
+ // v31: the same shape for a sibling (item 726). A cached sandboxed task was
86
+ // granted every linked workspace package, so it read a sibling its key
87
+ // never folded and saved what it built. The fix withholds that link unless
88
+ // the key answers for the package, but an entry saved before it still hits
89
+ // after the sibling changes (probed: the old output replayed).
90
+ export const CACHE_VERSION = 'vx-cache-v35'
91
+
92
+ /**
93
+ * Fold one task's key inputs into its 16-hex cache key. `hashFile` answers
94
+ * for an input file the caller's `fileHashes` map does not cover (the git
95
+ * blob OID of its worktree bytes); `relOf` renders an input file relative to
96
+ * `input.workspaceRoot`, POSIX-separated.
97
+ */
98
+ export async function foldKey(
99
+ input: CacheKeyInput,
100
+ hashFile: (file: string) => Promise<string>,
101
+ relOf: (file: string) => string,
102
+ ): Promise<string> {
103
+ // Seed-chained xxHash3: each step folds one field into the
104
+ // running digest via `xxh3(part, prevDigest)`. Equivalent to the
105
+ // old CryptoHasher.update() pattern, no intermediate buffer.
106
+ // Field-order matters; each line is prefixed with its label so
107
+ // adjacent fields can't collide via concat.
108
+ // Optional capture sink — see CacheKeyInput.captureInto. Pure
109
+ // side-channel; every push below mirrors the fold immediately
110
+ // above/beside it so the recorded set can't drift from the key.
111
+ const cap = input.captureInto
112
+ let h = xxh3(CACHE_VERSION)
113
+ h = xxh3(`task:${input.taskId}`, h)
114
+ h = xxh3(`workspace:${input.workspaceFingerprint}`, h)
115
+ h = xxh3(`pkg:${input.projectPackageJsonHash}`, h)
116
+ h = xxh3(`config:${input.taskConfigHash}`, h)
117
+ if (cap) {
118
+ cap.push({ kind: 'workspace', name: 'fingerprint', hash: input.workspaceFingerprint })
119
+ cap.push({ kind: 'package', name: 'package.json', hash: input.projectPackageJsonHash })
120
+ cap.push({ kind: 'config', name: 'config', hash: input.taskConfigHash })
121
+ }
122
+
123
+ // NOTE on `cap` (entry_inputs capture): the `hash` field stores a
124
+ // DIGEST of each component, never the raw value. For value-bearing kinds
125
+ // (env, runtime, ws-runtime, forward) the payload is a secret or
126
+ // sensitive string (API keys via cache.inputs.env, runtime-command
127
+ // output, args after `--`), so we push `xxh3hex(v)` — the diff consumer
128
+ // only needs to know whether a component CHANGED, which a digest
129
+ // preserves losslessly. cache.db must never hold plaintext secrets at
130
+ // rest. The cache KEY (`h`) folds the plaintext separately below and is
131
+ // unaffected.
132
+ const forwarded = input.forwardArgs ?? []
133
+ h = xxh3(`forward-args:${forwarded.length}`, h)
134
+ for (const a of forwarded) h = xxh3(a, h)
135
+ if (cap && forwarded.length > 0) {
136
+ cap.push({ kind: 'forward', name: 'argv', hash: xxh3hex(JSON.stringify(forwarded)) })
137
+ }
138
+
139
+ h = xxh3(`env-values:${input.envValues.length}`, h)
140
+ // \0 delimiter, not `=`: names and values may themselves contain
141
+ // `=`, and `A` + `B=C` must never fold the same bytes as `A=B` + `C`.
142
+ // An unset name folds its bare name: a name holds no \0 (the schema
143
+ // refuses one), so that is no `name\0value` for any value, the empty
144
+ // one included, and a set value's bytes stay what they always were.
145
+ for (const [n, v] of input.envValues) h = xxh3(v === undefined ? n : `${n}\0${v}`, h)
146
+ if (cap)
147
+ for (const [n, v] of input.envValues)
148
+ cap.push({ kind: 'env', name: n, hash: v === undefined ? 'unset' : xxh3hex(v) })
149
+
150
+ const runtimeValues = input.runtimeValues ?? []
151
+ h = xxh3(`runtime-values:${runtimeValues.length}`, h)
152
+ for (const [c, o] of runtimeValues) h = xxh3(`${c}\0${o}`, h)
153
+ if (cap)
154
+ for (const [c, o] of runtimeValues) cap.push({ kind: 'runtime', name: c, hash: xxh3hex(o) })
155
+
156
+ const wsRuntimeValues = input.workspaceRuntimeValues ?? []
157
+ h = xxh3(`ws-runtime-values:${wsRuntimeValues.length}`, h)
158
+ for (const [c, o] of wsRuntimeValues) h = xxh3(`${c}\0${o}`, h)
159
+ if (cap)
160
+ for (const [c, o] of wsRuntimeValues)
161
+ cap.push({ kind: 'ws-runtime', name: c, hash: xxh3hex(o) })
162
+
163
+ const upstream = [...input.upstreamHashes].sort()
164
+ h = xxh3(`upstream:${upstream.length}`, h)
165
+ for (const u of upstream) h = xxh3(u, h)
166
+ if (cap) {
167
+ for (const u of upstream) {
168
+ cap.push({ kind: 'upstream', name: input.upstreamIds?.get(u) ?? u, hash: u })
169
+ }
170
+ }
171
+
172
+ const pluginParts = input.pluginParts ?? []
173
+ if (pluginParts.length > 0) {
174
+ h = xxh3(`plugin:${pluginParts.length}`, h)
175
+ for (const [n, v] of pluginParts) h = xxh3(`${n}\0${v}`, h)
176
+ if (cap) {
177
+ for (const [n, v] of pluginParts) cap.push({ kind: 'plugin', name: n, hash: xxh3hex(v) })
178
+ }
179
+ }
180
+
181
+ // `resolveFiles` already returns its paths sorted, and a copy-and-sort
182
+ // of three thousand strings per task is not free: 7.4 ms of a 44-task
183
+ // run here (2026-09-20). A linear order check costs one comparison per
184
+ // file instead of n log n, and a caller that hands over an unsorted set
185
+ // — a plugin's own input list — still gets one. `>` on strings compares
186
+ // UTF-16 code units, which is what a comparator-less `sort` does.
187
+ let sortedInputs: readonly string[] = input.inputFiles
188
+ for (let i = 1; i < sortedInputs.length; i++) {
189
+ if (sortedInputs[i - 1]! > sortedInputs[i]!) {
190
+ sortedInputs = [...input.inputFiles].sort()
191
+ break
192
+ }
193
+ }
194
+ h = xxh3(`inputs:${sortedInputs.length}`, h)
195
+ // Per-file hash source, in preference order: the caller-supplied
196
+ // index-OID map (clean tracked files — zero I/O), then hashFile's
197
+ // stat memo (no read), then a full in-process blob-OID computation.
198
+ // All three describe the WORKTREE bytes, which is what the task
199
+ // reads — the index-OID map is only populated for paths whose
200
+ // worktree form matches their index form, because a clean filter
201
+ // (`text`/`eol`/`ident`/`filter`/`working-tree-encoding`) makes the index blob a DIFFERENT sequence
202
+ // of bytes and folding it would let two distinct worktree contents
203
+ // share a key. The fold order is locked to `sortedInputs` so
204
+ // results are stable across runs.
205
+ // The common warm shape is that the caller's OID map covers every file,
206
+ // and then there is nothing to await: building three thousand promises
207
+ // per task to resolve values already in hand cost 8.4 ms of that same
208
+ // run. One pass fills the array, and the first gap falls back to the
209
+ // awaited form for the whole list.
210
+ let fileHashes: readonly string[] | undefined
211
+ const provided = input.fileHashes
212
+ if (provided !== undefined) {
213
+ const out: string[] = []
214
+ for (const file of sortedInputs) {
215
+ const oid = provided.get(file)
216
+ if (oid === undefined) break
217
+ out.push(oid)
218
+ }
219
+ if (out.length === sortedInputs.length) fileHashes = out
220
+ }
221
+ fileHashes ??= await Promise.all(sortedInputs.map((f) => provided?.get(f) ?? hashFile(f)))
222
+ for (let i = 0; i < sortedInputs.length; i++) {
223
+ const file = sortedInputs[i]!
224
+ const rel = relOf(file)
225
+ const oid = fileHashes[i]!
226
+ h = xxh3(`${rel}\0${oid}`, h)
227
+ // The OID is already awaited — file capture is zero extra I/O.
228
+ if (cap) cap.push({ kind: 'file', name: rel, hash: oid })
229
+ }
230
+
231
+ return h.toString(16).padStart(16, '0')
232
+ }
@@ -17,9 +17,10 @@ export interface CacheKeyInput {
17
17
  taskConfigHash: string
18
18
  /**
19
19
  * Runtime values of declared cache-input env names (from parent at hash
20
- * time). Independent of `exec.env`; lives here for cache identity.
20
+ * time), `undefined` for a name that is unset. Independent of `exec.env`;
21
+ * lives here for cache identity.
21
22
  */
22
- envValues: Array<[name: string, value: string]>
23
+ envValues: Array<[name: string, value: string | undefined]>
23
24
  /**
24
25
  * Resolved `cache.inputs.runtime` commands as [command, output] pairs
25
26
  * (output = trimmed stdout+stderr, resolved live at hash time). Folded
@@ -199,6 +200,16 @@ export interface RunRecord {
199
200
  * decision. Absent on rows written before the column existed.
200
201
  */
201
202
  cached?: boolean
203
+ /**
204
+ * Why the task failed or was skipped, as the run's own footer said it
205
+ * (v27 columns; NULL on older rows and where the reason does not apply):
206
+ * a skip's root blocker, vx's own timeout, the sandbox's violation count,
207
+ * and why a persistent task never became ready.
208
+ */
209
+ blockedBy?: string
210
+ timedOut?: true
211
+ sandboxViolations?: number
212
+ notReady?: 'timeout' | 'exited' | 'spawn'
202
213
  }
203
214
 
204
215
  /**
@@ -296,6 +307,17 @@ export interface PruneResult {
296
307
  orphanBytes: number
297
308
  }
298
309
 
310
+ /**
311
+ * Artifact + `output_files` namespace prefix for workspace-root-
312
+ * anchored outputs (`cache.outputs.workspaceFiles`). Project outputs
313
+ * keep their bare project-relative `path` rows; workspace rows store
314
+ * the full `workspace-outputs/<rel-to-root>` tar entry name as the
315
+ * discriminator — least-invasive row format, no schema change. A
316
+ * project output dir literally named `workspace-outputs/` would
317
+ * collide with the namespace; the name is reserved.
318
+ */
319
+ export const WORKSPACE_OUTPUT_PREFIX = 'workspace-outputs/'
320
+
299
321
  /**
300
322
  * Per-output-file fingerprint, scoped by the cache entry that
301
323
  * produced it. Batch-loaded once at the top of a run via
@@ -314,6 +336,14 @@ export interface OutputFileRow {
314
336
  size: number
315
337
  mode: number
316
338
  mtimeMs: number
339
+ /**
340
+ * The inode and ctime THIS machine saw the file carry after the save or
341
+ * restore that left it equal to the entry (`recordOutputStamps`).
342
+ * Absent until then, and from any ingest: a row without them is never
343
+ * current.
344
+ */
345
+ ino?: number
346
+ ctimeMs?: number
317
347
  }
318
348
 
319
349
  /** One directory under a whole-subtree output glob, as it stood after the last save or restore on THIS machine. */
@@ -347,12 +377,6 @@ export const OUTPUT_DIRS_RACY_MS = 50
347
377
  /** The stat memo's twin of `OUTPUT_DIRS_RACY_MS`: a file changed within this window of the stat is not memoised. */
348
378
  export const FILE_HASH_RACY_MS = 50
349
379
 
350
- /**
351
- * The shape every cache implementation honors. `Cache` (the local v10
352
- * implementation) and `LayeredCache` both `implements` this so the
353
- * orchestrator's `executeTask` can take either without a discriminated
354
- * union and we get a compile-time guarantee the surfaces stay congruent.
355
- */
356
380
  /**
357
381
  * Context passed to `get()`. Optional, but required when the lookup
358
382
  * may resolve through the remote layer — the local SQL row inserted on
@@ -363,6 +387,20 @@ export const FILE_HASH_RACY_MS = 50
363
387
  export interface CacheGetContext {
364
388
  taskId: string
365
389
  command: string
390
+ /**
391
+ * The task's declared `cache.outputs`. A remote artifact is ingested
392
+ * only when every file it carries is one of them: bytes off the network
393
+ * named `outputs/src/in.txt` or `workspace-outputs/.git/hooks/…` wrote
394
+ * an input and a git hook on a green `cache-hit-remote` (item 942).
395
+ * Absent, the names are not checked.
396
+ */
397
+ outputs?: DeclaredOutputs
398
+ }
399
+
400
+ /** A task's declared outputs, as its config spells them. */
401
+ export interface DeclaredOutputs {
402
+ files: readonly string[]
403
+ workspaceFiles: readonly string[]
366
404
  }
367
405
 
368
406
  /** Metadata supplied at ingest time — values the artifact does not carry. */
@@ -379,6 +417,8 @@ export interface IngestMeta {
379
417
  * miss/save, so a cache hit never reaches here.
380
418
  */
381
419
  inputComponents?: readonly TaskInputRow[]
420
+ /** See `CacheGetContext.outputs`: the names the artifact may carry. */
421
+ outputs?: DeclaredOutputs
382
422
  }
383
423
 
384
424
  /**
@@ -399,6 +439,28 @@ export class CorruptArtifactError extends Error {
399
439
  }
400
440
  }
401
441
 
442
+ /**
443
+ * `restoreOutputs` found no artifact where the probe found one: it was
444
+ * removed in between — a `vx cache prune` in another shell, or the
445
+ * `cacheRetention` of another workspace sharing the cache directory. The
446
+ * bytes are not wrong, they are gone, so the entry is a MISS and the task
447
+ * runs (execute-task.ts); nothing about the cache needs throwing away.
448
+ */
449
+ export class ArtifactVanishedError extends Error {
450
+ constructor(public readonly hash: string) {
451
+ super(
452
+ `its cache artifact ${hash} vanished before the restore (a prune or another run's retention)`,
453
+ )
454
+ this.name = 'ArtifactVanishedError'
455
+ }
456
+ }
457
+
458
+ /**
459
+ * The shape every cache implementation honors. `Cache` (the local v10
460
+ * implementation) and `LayeredCache` both `implements` this so the
461
+ * orchestrator's `executeTask` can take either without a discriminated
462
+ * union and we get a compile-time guarantee the surfaces stay congruent.
463
+ */
402
464
  export interface CacheLayer {
403
465
  /**
404
466
  * The local handle this layer wraps, when it wraps one (`LayeredCache`).
@@ -509,6 +571,14 @@ export interface CacheLayer {
509
571
  * state, like the output rows: a remote ingest records none.
510
572
  */
511
573
  recordOutputDirs?(hash: string, projectDir: string, prefixes: readonly string[]): Promise<void>
574
+ /**
575
+ * Stamp `hash`'s output rows with each file's inode and ctime once a
576
+ * save or restore has left the tree equal to the entry (optional — a
577
+ * layer without it has rows `isOutputsCurrent` never trusts, so every
578
+ * hit restores). Machine-local; skips a file that no longer matches its
579
+ * row.
580
+ */
581
+ recordOutputStamps?(hash: string, projectDir: string, workspaceRoot: string): void
512
582
  loadOutputDirsBatch?(hashes: readonly string[]): Map<string, OutputDirRow[]>
513
583
  outputDirsCurrent?(projectDir: string, rows: readonly OutputDirRow[]): Promise<boolean>
514
584
  /**
@@ -557,13 +627,13 @@ export interface CacheLayer {
557
627
  inputComponents?: readonly TaskInputRow[]
558
628
  }): Promise<void>
559
629
  /**
560
- * Adopt an artifact produced elsewhere — the remote-hit path. Writes
561
- * the compressed bytes to `<cacheDir>/<hash>.tar.zst`, parses the
630
+ * Adopt an artifact produced elsewhere — the remote-hit path. Streams
631
+ * the compressed body to `<cacheDir>/<hash>.tar.zst`, parses the
562
632
  * tar headers to populate the `output_files` rows, and inserts the
563
633
  * `entries` row using the caller-supplied `meta`. After this returns,
564
634
  * the next `get(hash)` resolves locally.
565
635
  */
566
- ingest(hash: string, compressed: Uint8Array, meta: IngestMeta): Promise<void>
636
+ ingest(hash: string, body: Blob | Response, meta: IngestMeta): Promise<void>
567
637
  /**
568
638
  * The ONE run-history write: a whole `vx run` atomically, the per-task
569
639
  * `runs` rows and the one `invocations` header row — in a SINGLE