@vzn/vx 0.0.18 → 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 (149) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -70
  3. package/package.json +10 -20
  4. package/src/bin.ts +64 -5
  5. package/src/cache/archive.ts +279 -28
  6. package/src/cache/cache.ts +952 -1584
  7. package/src/cache/chained-cache.ts +14 -10
  8. package/src/cache/config-evals.ts +112 -0
  9. package/src/cache/file-hashes.ts +290 -0
  10. package/src/cache/git-inputs.ts +966 -0
  11. package/src/cache/index.ts +23 -7
  12. package/src/cache/inputs.ts +553 -739
  13. package/src/cache/key-fold.ts +232 -0
  14. package/src/cache/layer.ts +674 -0
  15. package/src/cache/layered-cache.ts +211 -49
  16. package/src/cache/output-index.ts +323 -0
  17. package/src/cache/policy.ts +84 -0
  18. package/src/cache/run-history.ts +152 -0
  19. package/src/cache/zstd.ts +183 -0
  20. package/src/cli/cache.ts +88 -30
  21. package/src/cli/completions.ts +104 -0
  22. package/src/cli/core-alias.ts +25 -0
  23. package/src/cli/format.ts +4 -15
  24. package/src/cli/help.ts +76 -38
  25. package/src/cli/index.ts +82 -17
  26. package/src/cli/info.ts +162 -91
  27. package/src/cli/init.ts +87 -0
  28. package/src/cli/last.ts +130 -23
  29. package/src/cli/lock.ts +36 -18
  30. package/src/cli/plan-format.ts +18 -7
  31. package/src/cli/plugin-commands.ts +34 -12
  32. package/src/cli/run.ts +138 -313
  33. package/src/cli/select.ts +342 -0
  34. package/src/cli/show.ts +151 -71
  35. package/src/cli/upgrade.ts +136 -15
  36. package/src/cli/watch.ts +1175 -116
  37. package/src/cli/why.ts +51 -29
  38. package/src/cli/workspace-config.ts +138 -0
  39. package/src/config.ts +183 -126
  40. package/src/exec/env.ts +11 -0
  41. package/src/exec/executor.ts +62 -31
  42. package/src/exec/index.ts +16 -2
  43. package/src/exec/kill-tree.ts +281 -0
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +361 -77
  46. package/src/exec/sandbox-binds.ts +211 -0
  47. package/src/exec/sandbox-paths.ts +70 -0
  48. package/src/exec/sandbox-runtime.ts +1152 -415
  49. package/src/exec/sandbox-violations.ts +293 -0
  50. package/src/graph/dependency-spec.ts +14 -14
  51. package/src/graph/index.ts +6 -4
  52. package/src/graph/priorities.ts +130 -0
  53. package/src/graph/scheduler.ts +332 -329
  54. package/src/graph/task-graph.ts +552 -188
  55. package/src/index.ts +111 -102
  56. package/src/orchestrator/admission.ts +161 -0
  57. package/src/orchestrator/colors.ts +8 -3
  58. package/src/orchestrator/deferred-outputs.ts +25 -7
  59. package/src/orchestrator/doctor.ts +363 -0
  60. package/src/orchestrator/events.ts +111 -8
  61. package/src/orchestrator/excluded-keys.ts +155 -0
  62. package/src/orchestrator/execute-task.ts +450 -565
  63. package/src/orchestrator/failure-mode.ts +169 -28
  64. package/src/orchestrator/failure-recap.ts +121 -0
  65. package/src/orchestrator/fingerprint-watch.ts +89 -0
  66. package/src/orchestrator/framed-output.ts +121 -49
  67. package/src/orchestrator/history.ts +123 -104
  68. package/src/orchestrator/hit-restore.ts +285 -0
  69. package/src/orchestrator/index.ts +42 -5
  70. package/src/orchestrator/keyed-projects.ts +89 -0
  71. package/src/orchestrator/local-shortcircuit.ts +83 -23
  72. package/src/orchestrator/lockfile-claim.ts +401 -0
  73. package/src/orchestrator/logger.ts +128 -41
  74. package/src/orchestrator/metrics.ts +201 -72
  75. package/src/orchestrator/miss-save.ts +232 -0
  76. package/src/orchestrator/options.ts +63 -34
  77. package/src/orchestrator/persistent.ts +99 -0
  78. package/src/orchestrator/placement.ts +212 -0
  79. package/src/orchestrator/plan.ts +24 -11
  80. package/src/orchestrator/plugin-host.ts +200 -49
  81. package/src/orchestrator/plugin.ts +190 -14
  82. package/src/orchestrator/prepare.ts +168 -137
  83. package/src/orchestrator/projects.ts +270 -0
  84. package/src/orchestrator/remote-prefetch.ts +22 -2
  85. package/src/orchestrator/run-artifacts.ts +34 -3
  86. package/src/orchestrator/run-context.ts +20 -5
  87. package/src/orchestrator/run-lock.ts +349 -0
  88. package/src/orchestrator/run-records.ts +132 -0
  89. package/src/orchestrator/run-report.ts +6 -4
  90. package/src/orchestrator/run.ts +467 -611
  91. package/src/orchestrator/sandbox-request.ts +569 -0
  92. package/src/orchestrator/save-lane.ts +44 -0
  93. package/src/orchestrator/shell-verdict.ts +117 -0
  94. package/src/orchestrator/signals.ts +167 -0
  95. package/src/orchestrator/stable-keys.ts +259 -10
  96. package/src/orchestrator/status-line.ts +35 -2
  97. package/src/orchestrator/summary.ts +115 -25
  98. package/src/orchestrator/task-hash.ts +103 -21
  99. package/src/orchestrator/task-log-buffer.ts +15 -18
  100. package/src/orchestrator/telemetry-host.ts +27 -8
  101. package/src/orchestrator/telemetry.ts +71 -44
  102. package/src/orchestrator/upstream.ts +91 -45
  103. package/src/util/bun-version.ts +43 -0
  104. package/src/util/cgroup.ts +160 -0
  105. package/src/util/edit-distance.ts +49 -0
  106. package/src/util/errors.ts +86 -0
  107. package/src/util/hash.ts +10 -1
  108. package/src/util/index.ts +42 -5
  109. package/src/util/paths.ts +168 -5
  110. package/src/util/procfs.ts +20 -0
  111. package/src/util/settle.ts +23 -2
  112. package/src/util/size.ts +33 -0
  113. package/src/util/task-id.ts +20 -0
  114. package/src/util/timing.ts +11 -1
  115. package/src/util/verbs.ts +39 -0
  116. package/src/util/which.ts +48 -0
  117. package/src/workspace/affected.ts +368 -60
  118. package/src/workspace/config-cache.ts +271 -32
  119. package/src/workspace/config-eval.ts +60 -16
  120. package/src/workspace/config-imports.ts +131 -9
  121. package/src/workspace/config-schema.ts +1033 -0
  122. package/src/workspace/filter.ts +69 -17
  123. package/src/workspace/fingerprint.ts +59 -7
  124. package/src/workspace/index.ts +37 -3
  125. package/src/workspace/json-data.ts +90 -0
  126. package/src/workspace/load-reads.ts +32 -0
  127. package/src/workspace/lockfile.ts +41 -14
  128. package/src/workspace/migrate-scripts.ts +187 -0
  129. package/src/workspace/migration.ts +353 -0
  130. package/src/workspace/package-graph.ts +239 -31
  131. package/src/workspace/project-loader.ts +295 -815
  132. package/src/workspace/workspace.ts +266 -31
  133. package/plugins/local-cache/index.ts +0 -2
  134. package/plugins/local-executor/index.ts +0 -2
  135. package/plugins/schedule-history/index.ts +0 -2
  136. package/src/cache/cas-backend.ts +0 -115
  137. package/src/cache/digest.ts +0 -32
  138. package/src/cli/migrate-emit.ts +0 -21
  139. package/src/cli/migrate-nx.ts +0 -473
  140. package/src/cli/migrate-scripts.ts +0 -127
  141. package/src/cli/migrate-turbo.ts +0 -401
  142. package/src/cli/migrate.ts +0 -305
  143. package/src/cli/prune.ts +0 -291
  144. package/src/orchestrator/missing-plugin.ts +0 -9
  145. package/src/orchestrator/resources.ts +0 -45
  146. package/src/orchestrator/verify.ts +0 -174
  147. package/src/plugins/local-cache/index.ts +0 -11
  148. package/src/plugins/local-executor/index.ts +0 -45
  149. package/src/plugins/schedule-history/index.ts +0 -109
@@ -0,0 +1,285 @@
1
+ // What a HIT leaves behind: the outputs of a confirmed cache entry
2
+ // materialised in the project — or proved already current and left alone —
3
+ // the exact changed paths marked so a same-project consumer need not
4
+ // re-spawn git, the stored stdout replayed, and the cache-hit outcome
5
+ // built. There is ONE restore path: `executeCachedTask` and the local
6
+ // short-circuit (a stable hit restored ahead of its deps) both come here,
7
+ // so the two cannot drift. Stale-hit-critical: a line moved here changes
8
+ // which bytes a green run replays. Moved out of execute-task.ts
9
+ // 2026-09-10 as pure code motion, the mirror of miss-save.ts.
10
+
11
+ import path from 'node:path'
12
+ import type { CacheConfig } from '../config.js'
13
+ import {
14
+ type CacheEntry,
15
+ cleanOutputPaths,
16
+ cleanOutputs,
17
+ cleanWorkspaceOutputs,
18
+ resolveOutputs,
19
+ resolveWorkspaceOutputs,
20
+ WORKSPACE_OUTPUT_PREFIX,
21
+ } from '../cache/index.js'
22
+ import type { TaskOutcome } from '../graph/index.js'
23
+ import { asTrees, span, taskGlob, wholeSubtreePrefixes } from '../util/index.js'
24
+ import type { ExecuteArgs } from './execute-task.js'
25
+
26
+ export interface RestoreHitArgs {
27
+ args: ExecuteArgs
28
+ hash: string
29
+ hit: CacheEntry
30
+ /** `performance.now()` when the cache op (probe) started — the
31
+ * user-perceived restore duration is measured from here. */
32
+ cacheOpStart: number
33
+ /** ns offset from run start for the outcome's wallclock window. */
34
+ taskStartNs: bigint
35
+ }
36
+
37
+ /**
38
+ * Materialize a confirmed cache hit: decide skip-restore, clean +
39
+ * restore declared outputs, mark the exact changed paths so downstream
40
+ * same-project tasks needn't re-spawn git, replay stored stdout, and
41
+ * build the cache-hit `TaskOutcome`. Extracted from `executeCachedTask`
42
+ * so the local short-circuit can restore a stable-key hit ahead of the
43
+ * schedule using the IDENTICAL logic — there is one restore path, not
44
+ * two that could drift.
45
+ */
46
+ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome> {
47
+ const { args, hash, hit, cacheOpStart, taskStartNs } = restore
48
+ const { node, log } = args
49
+ const cacheCfg: CacheConfig | undefined = node.config.cache
50
+ const outputs = cacheCfg?.outputs.files ?? []
51
+ const wsOutputs = cacheCfg?.outputs.workspaceFiles ?? []
52
+ const anyOutputs = outputs.length > 0 || wsOutputs.length > 0
53
+ const dirPrefixes = wholeSubtreePrefixes(outputs)
54
+ const cleanArgs = {
55
+ projectDir: node.projectDir,
56
+ outputs,
57
+ nestedProjectDirs: args.nestedProjectDirs,
58
+ }
59
+ const wsCleanArgs = { workspaceRoot: args.workspaceRoot, outputs: wsOutputs }
60
+ // The two sides of an overlapping pair (item 588). An ADDITIVE task owns
61
+ // only its recorded rows: its glob also selects the upstream's files it
62
+ // adds beside, so "no strays" is not a claim it can make, and its clean
63
+ // takes the rows, never the glob. The UPSTREAM ignores what its
64
+ // dependants declare they add when it judges its own tree current — an
65
+ // addition below is not a stray — while its glob clean still takes them:
66
+ // the dependant restores or runs after it, by the edge.
67
+ const additive = (node.addsToOutputsOf?.length ?? 0) > 0
68
+ const addedGlobs = (node.outputsAddedToBy ?? [])
69
+ .flatMap((g) => asTrees([g]))
70
+ .map((g) => taskGlob(g))
71
+ const isAddition = (rel: string): boolean => addedGlobs.some((g) => g.match(rel))
72
+
73
+ // "Tree is already current" short-circuit — skip cleanOutputs
74
+ // + restoreOutputs when the on-disk state matches what this
75
+ // entry recorded at save time. Integrity-preserving: we
76
+ // require BOTH that the output-glob walk yields exactly the
77
+ // expected paths (no strays, no missing) AND that every file's
78
+ // (size, mode, mtime) matches the stored fingerprint. Any
79
+ // divergence falls through to a real clean + restore.
80
+ //
81
+ // The output-file fingerprints can't be batch-loaded at
82
+ // prepareRun time — non-leaf task hashes depend on upstream
83
+ // outputs that haven't been written yet, so hashes are
84
+ // necessarily computed mid-run. The short-circuit's batched probe
85
+ // loads the rows with the entry (`hit.outputRows`); only the lazy
86
+ // path pays one SELECT here. Either beats reading the manifest from
87
+ // the tar (decompress + parse) at the same point.
88
+ // A task with no declared outputs has nothing to clean or restore: its
89
+ // stdout replays from the row (`hit.stdout`), and its artifact holds
90
+ // only logs. Extracting it anyway cost an `exists` + a tar read per hit
91
+ // — 1.7 ms each on a four-task real repo whose `link` and `element`
92
+ // tasks declare `outputs: []` (2026-09-10, VX_TIMING).
93
+ let skipRestore = !anyOutputs
94
+ if (anyOutputs) {
95
+ const endRows = span('output rows')
96
+ const expected = hit.outputRows ?? args.cache.loadOutputFilesBatch([hash]).get(hash) ?? []
97
+ endRows()
98
+ if (expected.length > 0) {
99
+ // Two namespaces in the rows: bare rels are project outputs,
100
+ // `workspace-outputs/<rel>` rows anchor at the workspace root.
101
+ const projExpected = expected.filter((e) => !e.path.startsWith(WORKSPACE_OUTPUT_PREFIX))
102
+ const wsExpected = expected
103
+ .filter((e) => e.path.startsWith(WORKSPACE_OUTPUT_PREFIX))
104
+ .map((e) => ({ ...e, path: e.path.slice(WORKSPACE_OUTPUT_PREFIX.length) }))
105
+ // The directory short-circuit: for whole-subtree globs, unchanged
106
+ // mtimes on every directory recorded at the last save/restore prove
107
+ // the output SET is unchanged (a file added or removed anywhere the
108
+ // glob could see bumps a recorded directory), so the walk that cost
109
+ // 0.36 ms per warm hit is replaced by a few stats. The per-file
110
+ // fingerprint check below still runs; only the enumeration is skipped.
111
+ let setKnown = false
112
+ if (dirPrefixes !== null && wsOutputs.length === 0 && args.cache.outputDirsCurrent) {
113
+ const endDirs = span('output dirs')
114
+ // Loaded with the entry (batched by getMany, or with the lazy get).
115
+ const dirRows = hit.outputDirRows ?? []
116
+ // Rows are recorded for these same prefixes under this same key
117
+ // (the outputs are folded into it), so `covers` cannot be false
118
+ // today — deleting it reddens nothing (item 638). Kept: a row
119
+ // set that shrank would otherwise vouch for a tree it never saw,
120
+ // and the failure it would let through is a skipped restore.
121
+ const covers = dirPrefixes.every((pre) => dirRows.some((r) => r.path === pre))
122
+ setKnown = covers && (await args.cache.outputDirsCurrent!(node.projectDir, dirRows))
123
+ endDirs()
124
+ }
125
+ let actualAbs: string[]
126
+ let actualWsAbs: string[]
127
+ if (setKnown) {
128
+ actualAbs = projExpected.map((e) => path.join(node.projectDir, e.path))
129
+ actualWsAbs = []
130
+ } else {
131
+ const endGlob = span('output glob')
132
+ actualAbs = await resolveOutputs({
133
+ projectDir: node.projectDir,
134
+ outputs,
135
+ nestedProjectDirs: args.nestedProjectDirs,
136
+ })
137
+ actualWsAbs = await resolveWorkspaceOutputs({
138
+ workspaceRoot: args.workspaceRoot,
139
+ outputs: wsOutputs,
140
+ })
141
+ endGlob()
142
+ }
143
+ const setsMatch = (
144
+ actual: readonly string[],
145
+ exp: ReadonlyArray<{ path: string }>,
146
+ ): boolean => {
147
+ const expSet = new Set(exp.map((e) => e.path))
148
+ return actual.length === expSet.size && actual.every((r) => expSet.has(r))
149
+ }
150
+ // A stray a dependant's glob could have added is not a stray; a path
151
+ // this entry recorded is never dropped, even where that glob covers
152
+ // the whole tree (strapi's shape: both on `dist`).
153
+ const expectedRels = new Set(projExpected.map((e) => e.path))
154
+ const expectedWsRels = new Set(wsExpected.map((e) => e.path))
155
+ const actualRels = actualAbs
156
+ .map((p) => path.relative(node.projectDir, p).split(path.sep).join('/'))
157
+ .filter((rel) => expectedRels.has(rel) || !isAddition(rel))
158
+ const actualWsRels = actualWsAbs
159
+ .map((p) => path.relative(args.workspaceRoot, p).split(path.sep).join('/'))
160
+ .filter((rel) => expectedWsRels.has(rel) || !isAddition(rel))
161
+ const rowsPresent = (
162
+ actual: readonly string[],
163
+ exp: ReadonlyArray<{ path: string }>,
164
+ ): boolean => {
165
+ const have = new Set(actual)
166
+ return exp.every((e) => have.has(e.path))
167
+ }
168
+ const treeMatches = additive
169
+ ? rowsPresent(actualRels, projExpected) && rowsPresent(actualWsRels, wsExpected)
170
+ : setsMatch(actualRels, projExpected) && setsMatch(actualWsRels, wsExpected)
171
+ if (treeMatches) {
172
+ const endStat = span('output stat')
173
+ skipRestore =
174
+ (await args.cache.isOutputsCurrent(node.projectDir, projExpected)) &&
175
+ (await args.cache.isOutputsCurrent(args.workspaceRoot, wsExpected))
176
+ endStat()
177
+ // The walk ran and proved the tree current (rows absent, or a
178
+ // directory had moved — a benign touch): snapshot the directories
179
+ // so the next hit skips the walk.
180
+ if (skipRestore && !setKnown && dirPrefixes !== null) {
181
+ await args.cache.recordOutputDirs?.(hash, node.projectDir, dirPrefixes)
182
+ }
183
+ }
184
+ }
185
+ }
186
+ // An entry with NO rows (its declared outputs matched nothing at save
187
+ // time, the warned case) is current exactly when the globs still match
188
+ // nothing: then the empty artifact has nothing to put back and the read
189
+ // of the tar is spared. A stray under the glob still falls through to
190
+ // the clean, as strict ownership requires (item 589).
191
+ if (anyOutputs && !skipRestore) {
192
+ const expected = hit.outputRows ?? args.cache.loadOutputFilesBatch([hash]).get(hash) ?? []
193
+ if (expected.length === 0) {
194
+ const endGlob = span('output glob')
195
+ const actual = await resolveOutputs({
196
+ projectDir: node.projectDir,
197
+ outputs,
198
+ nestedProjectDirs: args.nestedProjectDirs,
199
+ })
200
+ const actualWs = await resolveWorkspaceOutputs({
201
+ workspaceRoot: args.workspaceRoot,
202
+ outputs: wsOutputs,
203
+ })
204
+ endGlob()
205
+ skipRestore = actual.length === 0 && actualWs.length === 0
206
+ }
207
+ }
208
+ if (!skipRestore) {
209
+ let cleanedRels: string[] = []
210
+ let cleanedWsRels: string[] = []
211
+ if (additive) {
212
+ cleanedRels = hit.outputFiles.filter((p) => !p.startsWith(WORKSPACE_OUTPUT_PREFIX))
213
+ await cleanOutputPaths({ projectDir: node.projectDir, rels: cleanedRels })
214
+ cleanedWsRels = hit.outputFiles
215
+ .filter((p) => p.startsWith(WORKSPACE_OUTPUT_PREFIX))
216
+ .map((p) => p.slice(WORKSPACE_OUTPUT_PREFIX.length))
217
+ await cleanOutputPaths({ projectDir: args.workspaceRoot, rels: cleanedWsRels })
218
+ } else {
219
+ if (outputs.length > 0) cleanedRels = await cleanOutputs(cleanArgs)
220
+ if (wsOutputs.length > 0) cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
221
+ }
222
+ await args.cache.restoreOutputs(hash, node.projectDir, args.workspaceRoot)
223
+ // The tree now holds this entry's bytes: stamp them for the next hit
224
+ // (item 886).
225
+ args.cache.recordOutputStamps?.(hash, node.projectDir, args.workspaceRoot)
226
+ // The directory snapshot behind the NEXT hit's skip-restore, taken at
227
+ // run end from the run's list (as the miss path does, see
228
+ // miss-save.ts): the restore renamed into these directories
229
+ // microseconds ago, inside the snapshot's racy window, so a snapshot
230
+ // taken here was refused and the first warm run after every restore
231
+ // walked its output trees — 41 of payload's 45 tasks, 14,430 files,
232
+ // ~75 ms of the no-op's CPU (2026-09-11). A caller with no list gets
233
+ // no snapshot (item 637: recording here was that same refusal).
234
+ if (dirPrefixes !== null && args.outputDirSnapshots !== undefined) {
235
+ args.outputDirSnapshots.push({ hash, projectDir: node.projectDir, prefixes: dirPrefixes })
236
+ }
237
+ // Restored outputs changed the project's tree — but on this
238
+ // path we know the EXACT changed paths (wiped declared
239
+ // outputs + the artifact's files). Record them instead of
240
+ // dropping the snapshot; downstream same-project tasks only
241
+ // re-spawn git when their input globs can actually see one of
242
+ // these paths. The cache-miss save path keeps the
243
+ // unconditional drop (an executed task may write undeclared
244
+ // files only git can see).
245
+ if (outputs.length > 0) {
246
+ args.gitFilesCache?.markOutputsChanged(node.projectDir, [
247
+ ...cleanedRels,
248
+ ...hit.outputFiles.filter((p) => !p.startsWith(WORKSPACE_OUTPUT_PREFIX)),
249
+ ])
250
+ }
251
+ if (wsOutputs.length > 0) {
252
+ args.gitFilesCache?.markWorkspaceOutputsChanged(args.workspaceRoot, [
253
+ ...cleanedWsRels,
254
+ ...hit.outputFiles
255
+ .filter((p) => p.startsWith(WORKSPACE_OUTPUT_PREFIX))
256
+ .map((p) => p.slice(WORKSPACE_OUTPUT_PREFIX.length)),
257
+ ])
258
+ }
259
+ }
260
+ if (hit.stdout) log.taskStdout(node, hit.stdout)
261
+ const status =
262
+ hit.exitCode !== 0 ? 'failed' : hit.source === 'remote' ? 'cache-hit-remote' : 'cache-hit'
263
+ // `restored` distinguishes "we just wrote files to disk" from
264
+ // "disk already matched the cached snapshot". Drives the
265
+ // "up-to-date" vs "local-cache" / "remote-cache" label in the
266
+ // framed block. Only meaningful when at least one output was
267
+ // declared — no-outputs tasks never materialize anything, so
268
+ // they're vacuously up-to-date.
269
+ const restored = !skipRestore && anyOutputs
270
+ return {
271
+ node,
272
+ status,
273
+ exitCode: hit.exitCode,
274
+ // What THIS run spent: probe + restore. NOT the stored exec time — see
275
+ // `storedDurationMs`, which is what the hit skipped.
276
+ durationMs: Math.round(performance.now() - cacheOpStart),
277
+ storedDurationMs: hit.durationMs,
278
+ ...(hit.cpuMs !== undefined ? { storedCpuMs: hit.cpuMs } : {}),
279
+ ...(hit.peakRssBytes !== undefined ? { storedPeakRssBytes: hit.peakRssBytes } : {}),
280
+ hash,
281
+ restored,
282
+ wallclockStartNs: taskStartNs,
283
+ wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
284
+ }
285
+ }
@@ -3,12 +3,24 @@
3
3
 
4
4
  export { run, planRun } from './run.js'
5
5
  export { prepareRun, type PreparedRun } from './prepare.js'
6
+ export {
7
+ loadProjects,
8
+ loadResolvedProjects,
9
+ loadWorkspacePlugins,
10
+ type LoadedProjects,
11
+ type LoadProjectsArgs,
12
+ } from './projects.js'
6
13
  export { computeTaskHash, createHashCache, type HashCache } from './task-hash.js'
7
- export type { RunOptions, RunSummary } from './options.js'
14
+ export type { HeldPersistent, RunOptions, RunSummary } from './options.js'
8
15
  // Re-surface the cache policy contract (defined in the cache module) so
9
16
  // embedders constructing RunOptions.cache and the package façade can
10
17
  // reach it without importing the cache module directly.
11
18
  export { type CachePolicy, FULL_CACHE_POLICY, parseCachePolicy } from '../cache/index.js'
19
+ // The CLI reads a recorded exit code; the runner's convention decodes it.
20
+ export { exitSignal } from '../exec/index.js'
21
+ export { forwardedSignal } from './signals.js'
22
+ // `vx cache prune` waits for a run on the workspace before it evicts.
23
+ export { acquireRunLock } from './run-lock.js'
12
24
  export { defaultLogger, resolveOutputView } from './logger.js'
13
25
  export type { Logger, OutputView } from './logger.js'
14
26
  export type { RunPlan, PlannedTask, PlanPrediction, CacheStatus } from './plan.js'
@@ -19,6 +31,8 @@ export {
19
31
  toWireEvent,
20
32
  projectNode,
21
33
  projectOutcome,
34
+ outcomeWord,
35
+ outcomeLabel,
22
36
  } from './events.js'
23
37
  export type {
24
38
  EventBus,
@@ -36,6 +50,14 @@ export {
36
50
  LocalHistoryProvider,
37
51
  type TaskHistory,
38
52
  } from './history.js'
53
+ export {
54
+ detectFlaky,
55
+ type FailureMode,
56
+ type FlakyCandidate,
57
+ type FlakyFinding,
58
+ type FlakyTask,
59
+ flakyTasks,
60
+ } from './failure-mode.js'
39
61
  export {
40
62
  installPlugins,
41
63
  type CacheContext,
@@ -46,6 +68,7 @@ export {
46
68
  type PluginCommand,
47
69
  type ProjectHookContext,
48
70
  type ScheduleHookContext,
71
+ type AdmitContext,
49
72
  type WorkspaceHookContext,
50
73
  type InstallPluginsArgs,
51
74
  type Plugin,
@@ -53,7 +76,13 @@ export {
53
76
  type PluginHookHandlers,
54
77
  type PluginHookName,
55
78
  type PluginSetupContext,
79
+ type FingerprintChange,
80
+ type FingerprintClaim,
81
+ type FingerprintContext,
56
82
  type VxPlugin,
83
+ definePlugin,
84
+ type PluginHooks,
85
+ type PluginOrigin,
57
86
  } from './plugin.js'
58
87
  export {
59
88
  applyConfigHooks,
@@ -61,11 +90,20 @@ export {
61
90
  applyKeyHooks,
62
91
  applyProjectHooks,
63
92
  applyScheduleHooks,
93
+ claimedAffected,
94
+ fingerprintClaims,
64
95
  hasHook,
96
+ CACHE_LAYER_METHODS,
65
97
  resolveCache,
66
98
  resolveExecutors,
67
99
  } from './plugin-host.js'
68
- export { MISSING_PLUGIN_HINT } from './missing-plugin.js'
100
+ export {
101
+ lockfileClaim,
102
+ reachDigests,
103
+ type LockfileClaimHooks,
104
+ type LockfileClaimOptions,
105
+ type ReachGraph,
106
+ } from './lockfile-claim.js'
69
107
  export { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
70
108
  // The bounded log-capture buffer every telemetry sink shares — see the
71
109
  // module header for why one implementation, not one per sink.
@@ -90,9 +128,6 @@ export type {
90
128
  TelemetrySink,
91
129
  TelemetrySource,
92
130
  } from './telemetry.js'
93
- // `diffOutputTrees` is the ONE tree-diff implementation — the verify verdict
94
- // and a serve's cross-machine fingerprint diff must never drift on it.
95
- export { diffOutputTrees } from './verify.js'
96
131
  export { deriveStableKeys } from './stable-keys.js'
97
132
  export type { DeriveStableKeysArgs, StableKey } from './stable-keys.js'
98
133
  export {
@@ -108,6 +143,7 @@ export {
108
143
  explainCacheKey as explainCacheKeyQuery,
109
144
  getInvocation,
110
145
  getRun,
146
+ latestRunId,
111
147
  listInvocations,
112
148
  listRuns,
113
149
  whyDidThisRerun as whyDidThisRerunQuery,
@@ -125,3 +161,4 @@ export type {
125
161
  RunSummaryRow,
126
162
  WhyDidThisRerun,
127
163
  } from './metrics.js'
164
+ export { collectInfo, type CollectInfoOptions, type InfoFacts } from './doctor.js'
@@ -0,0 +1,89 @@
1
+ // The projects a task's cache key answers for: K(T) in
2
+ // docs/design/linked-sibling-reads-2026-09.md. A sandboxed task that
3
+ // declares `cache` is granted a sibling's `node_modules` link only when its
4
+ // key already moves with that sibling's files, and this is where "already"
5
+ // is decided — on the graph, before any hash exists, through the same
6
+ // selection the hash path applies (`selectFoldedDeps`).
7
+
8
+ import { isGroupTask, type TaskNode } from '../graph/index.js'
9
+ import { selectFoldedDeps, type FoldCandidate } from './upstream.js'
10
+
11
+ /**
12
+ * A run's keyed-set lookup: for a task, the directories of every project
13
+ * holding an exec task whose key the task's key folds, transitively. The
14
+ * task itself is not counted.
15
+ *
16
+ * The fold relation is the hash path's, computed on the graph:
17
+ * - an exec task folds its dependencies as its own `cache.inputs.tasks`
18
+ * selects them (absent, or no `cache` at all, means all);
19
+ * - a group folds every dependency (`computeGroupHash`) and folds no
20
+ * files of its own;
21
+ * - a persistent task has no hash on the live path, so it is folded by
22
+ * no one and its own dependencies reach no key through it. (The local
23
+ * classify pass does give it one — stable-keys.ts — so a key there can
24
+ * move with more than this set names; never with less.)
25
+ *
26
+ * Every exec task reached contributes its project, cached or not: a key
27
+ * folds the task's own project files, and a task with no `cache` declares
28
+ * no `inputs.files`, so it folds every file of its project (inputs.ts's
29
+ * `DEFAULT_FILE_GLOBS`). `tests/keyed-projects.test.ts` holds that to the
30
+ * key itself.
31
+ *
32
+ * Lazy and memoized by task id: only a sandboxed task that executes asks,
33
+ * so a run of hits walks nothing, and a shared subgraph is walked once.
34
+ */
35
+ export function keyedProjects(
36
+ nodes: ReadonlyMap<string, TaskNode>,
37
+ ): (node: TaskNode) => ReadonlySet<string> {
38
+ const below = new Map<string, ReadonlySet<string>>()
39
+ // Post-order on an explicit stack: a fold is as deep as the graph, and a
40
+ // recursion per edge threw `RangeError` at the 50,000 the builder takes
41
+ // (item 737). A frame's folded dependencies are listed when it is first
42
+ // reached and combined once each has its set.
43
+ return (root) => {
44
+ const stack: Array<[node: TaskNode, deps: FoldCandidate[] | undefined]> = [[root, undefined]]
45
+ while (stack.length > 0) {
46
+ const frame = stack[stack.length - 1]!
47
+ const [node, deps] = frame
48
+ if (below.has(node.id)) {
49
+ stack.pop()
50
+ continue
51
+ }
52
+ if (deps === undefined) {
53
+ frame[1] = folded(node, nodes)
54
+ for (const { node: dep } of frame[1]) if (!below.has(dep.id)) stack.push([dep, undefined])
55
+ continue
56
+ }
57
+ stack.pop()
58
+ const out = new Set<string>()
59
+ for (const { node: dep } of deps) {
60
+ if (!isGroupTask(dep)) out.add(dep.projectDir)
61
+ for (const dir of below.get(dep.id)!) out.add(dir)
62
+ }
63
+ below.set(node.id, out)
64
+ }
65
+ return below.get(root.id)!
66
+ }
67
+ }
68
+
69
+ /** The dependencies `node`'s key folds, per the hash path's rules above. */
70
+ function folded(node: TaskNode, nodes: ReadonlyMap<string, TaskNode>): FoldCandidate[] {
71
+ const candidates: FoldCandidate[] = []
72
+ for (const id of node.deps) {
73
+ const dep = nodes.get(id)!
74
+ if (dep.config.exec?.persistent !== undefined) continue
75
+ candidates.push({ node: dep, unit: foldUnit(dep) })
76
+ }
77
+ if (isGroupTask(node)) return candidates
78
+ return selectFoldedDeps(candidates, node.config.cache?.inputs?.tasks, node.projectName, node.id)
79
+ }
80
+
81
+ /**
82
+ * What stands in for a dependency's hash in the dedup: equal exactly when
83
+ * the hashes are. An exec task's key folds its own id (`task:<id>`), so its
84
+ * id is enough; a group's hash is a roll-up of its members' ids and hashes
85
+ * with no id of its own, so two groups over the same members hash alike.
86
+ */
87
+ function foldUnit(node: TaskNode): string {
88
+ return isGroupTask(node) ? `group|${[...node.deps].sort().join('|')}` : `task|${node.id}`
89
+ }
@@ -32,16 +32,19 @@
32
32
  // - Only STABLE-key, cacheable, local-read tasks are classified. A
33
33
  // stable miss stays in the normal (dep-gated) schedule; an unstable
34
34
  // task is never probed here.
35
- // - If ANY task in the graph declares `cache.outputs.workspaceFiles`,
36
- // no task is restore-tiered (the boundary-ignoring escape hatch
37
- // could let a task write where a restore touches — a blanket
38
- // conservative exclusion). Those tasks still get an exec-tier probe
39
- // reuse entry so there's no double work.
35
+ // - A task that declares `cache.outputs.workspaceFiles` (the
36
+ // boundary-ignoring escape hatch) can write where a restore touches,
37
+ // so every task whose project directory a workspace-output glob's
38
+ // static prefix REACHES stays out of the restore tier, and so does
39
+ // every transitive dependant of one (its up-front key folds a key
40
+ // that is preliminary). A glob with no literal prefix reaches every
41
+ // project, which is the old graph-wide rule. Excluded tasks still get
42
+ // an exec-tier probe reuse entry so there's no double work.
40
43
 
41
- import { span } from '../util/index.js'
44
+ import { normalizeGlob, relPosix, span, staticPrefix } from '../util/index.js'
42
45
  import type { CacheEntry, CacheLayer, GitFilesCache } from '../cache/index.js'
43
46
  import type { TaskNode } from '../graph/index.js'
44
- import { deriveStableKeys } from './stable-keys.js'
47
+ import { deriveStableKeys, workspaceInputsReach } from './stable-keys.js'
45
48
  import type { HashCache } from './task-hash.js'
46
49
 
47
50
  export interface ShortCircuitArgs {
@@ -104,22 +107,13 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
104
107
  } finally {
105
108
  endKeys()
106
109
  }
107
- const candidates = stableKeys.filter(({ node }) => node.config.cache !== undefined)
110
+ // Every stable key is a cacheable task's: deriveStableKeys pushes only
111
+ // `cacheEnabled && !unstable` (item 640 deleted a second filter here and
112
+ // nothing reddened).
113
+ const candidates = stableKeys
108
114
  if (candidates.length === 0) return EMPTY
109
115
 
110
- // Belt-and-suspenders for the boundary-ignoring escape hatch: a
111
- // root-anchored output can land anywhere, so if ANY task declares
112
- // workspace outputs a task could in principle write where a
113
- // restore-tier task restores. Disable the RESTORE tier graph-wide —
114
- // workspace outputs are rare bad-practice. (Probe reuse still applies,
115
- // so there's no double work; those tasks just stay dep-gated.)
116
- let anyWorkspaceOutputs = false
117
- for (const node of args.nodes.values()) {
118
- if ((node.config.cache?.outputs.workspaceFiles?.length ?? 0) > 0) {
119
- anyWorkspaceOutputs = true
120
- break
121
- }
122
- }
116
+ const keptOut = restoreTierExclusions(args.nodes, args.workspaceRoot)
123
117
 
124
118
  const preProbed = new Map<string, ProbedEntry>()
125
119
  const restoreTier = new Set<string>()
@@ -139,7 +133,7 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
139
133
  for (const { hash, node } of candidates) {
140
134
  const hit = hits.get(hash) ?? null
141
135
  preProbed.set(node.id, { hash, hit })
142
- if (hit !== null && !anyWorkspaceOutputs) restoreTier.add(node.id)
136
+ if (hit !== null && !keptOut.has(node.id)) restoreTier.add(node.id)
143
137
  }
144
138
  return { preProbed, restoreTier }
145
139
  } catch {
@@ -156,7 +150,7 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
156
150
  const command = node.config.exec?.command ?? ''
157
151
  const hit = await args.cache.get(hash, { taskId: node.id, command })
158
152
  preProbed.set(node.id, { hash, hit })
159
- if (hit !== null && !anyWorkspaceOutputs) restoreTier.add(node.id)
153
+ if (hit !== null && !keptOut.has(node.id)) restoreTier.add(node.id)
160
154
  } catch {
161
155
  // Leave this task out of preProbed → it probes lazily in
162
156
  // execute(), exactly as today.
@@ -167,3 +161,69 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
167
161
 
168
162
  return { preProbed, restoreTier }
169
163
  }
164
+
165
+ /**
166
+ * The tasks a workspace-output declaration keeps out of the restore tier.
167
+ *
168
+ * A root-anchored output can land in any project's directory, edge or no
169
+ * edge (`tests/local-shortcircuit.test.ts` § "a workspace-output writer"
170
+ * rows, item 425), so the question is not who depends on the writer but
171
+ * WHERE it can write: each declared glob's static prefix is tested for
172
+ * reach against every project directory, and against every task's own
173
+ * `workspaceFiles` inputs, with the ancestor-or-equal relation
174
+ * `workspaceInputsReach` already uses. A prefix that is the root reaches
175
+ * everything — the graph-wide rule this replaced (item 584). Exclusion then
176
+ * follows the edges DOWN: a dependant's up-front key folds an excluded
177
+ * task's key, which is preliminary, so it cannot restore early either.
178
+ * Cost: one pass over the nodes, no filesystem.
179
+ */
180
+ function restoreTierExclusions(nodes: Map<string, TaskNode>, workspaceRoot: string): Set<string> {
181
+ const prefixes: string[] = []
182
+ let everything = false
183
+ for (const node of nodes.values()) {
184
+ for (const raw of node.config.cache?.outputs.workspaceFiles ?? []) {
185
+ // No negation to skip: the schema refuses '!' in output globs
186
+ // (`validateWorkspaceGlobs`), so the `continue` that stood here
187
+ // guarded a shape no config can carry (item 640).
188
+ const glob = normalizeGlob(raw)
189
+ const prefix = staticPrefix(glob)
190
+ if (prefix === '.' || prefix === '' || prefix === '/') everything = true
191
+ else prefixes.push(prefix.replace(/^\.\//, ''))
192
+ }
193
+ }
194
+ const out = new Set<string>()
195
+ if (everything) {
196
+ for (const id of nodes.keys()) out.add(id)
197
+ return out
198
+ }
199
+ if (prefixes.length === 0) return out
200
+ const reaches = (dir: string): boolean =>
201
+ dir === '' ||
202
+ dir === '.' ||
203
+ prefixes.some((p) => p === dir || p.startsWith(`${dir}/`) || dir.startsWith(`${p}/`))
204
+ const dependants = new Map<string, string[]>()
205
+ for (const node of nodes.values()) {
206
+ const dir = relPosix(workspaceRoot, node.projectDir)
207
+ const wsInputs = node.config.cache?.inputs?.workspaceFiles ?? []
208
+ if (reaches(dir) || (wsInputs.length > 0 && workspaceInputsReach(wsInputs, prefixes))) {
209
+ out.add(node.id)
210
+ }
211
+ for (const dep of node.deps) {
212
+ const list = dependants.get(dep)
213
+ if (list === undefined) dependants.set(dep, [node.id])
214
+ else list.push(node.id)
215
+ }
216
+ }
217
+ // Up the dependant edges on an explicit stack: a chain is as deep as the
218
+ // graph, and a recursion per edge threw `RangeError` at the 50,000 the
219
+ // builder takes (item 737).
220
+ const stack = [...out]
221
+ while (stack.length > 0) {
222
+ for (const up of dependants.get(stack.pop()!) ?? []) {
223
+ if (out.has(up)) continue
224
+ out.add(up)
225
+ stack.push(up)
226
+ }
227
+ }
228
+ return out
229
+ }