@vzn/vx 0.0.646 → 0.0.648

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 (184) hide show
  1. package/README.md +29 -22
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +112 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +18 -2
  12. package/src/bin.ts +53 -12
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +268 -53
  15. package/src/cache/git-inputs.ts +219 -87
  16. package/src/cache/index.ts +3 -2
  17. package/src/cache/inputs.ts +460 -78
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +49 -9
  20. package/src/cache/layered-cache.ts +123 -58
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +19 -3
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +90 -14
  26. package/src/cache/run-history.ts +70 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +25 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/docs.ts +42 -16
  34. package/src/cli/help.ts +58 -19
  35. package/src/cli/index.ts +79 -33
  36. package/src/cli/info.ts +12 -6
  37. package/src/cli/init.ts +186 -31
  38. package/src/cli/last.ts +439 -45
  39. package/src/cli/lock.ts +197 -52
  40. package/src/cli/plan-format.ts +91 -49
  41. package/src/cli/plugin-commands.ts +70 -14
  42. package/src/cli/plugin-templates.ts +13 -13
  43. package/src/cli/registry.ts +209 -0
  44. package/src/cli/run.ts +109 -34
  45. package/src/cli/select.ts +143 -46
  46. package/src/cli/show.ts +59 -16
  47. package/src/cli/task-verb.ts +16 -6
  48. package/src/cli/upgrade.ts +151 -49
  49. package/src/cli/watch-cycle.ts +10 -5
  50. package/src/cli/watch-filter.ts +71 -3
  51. package/src/cli/watch-fs.ts +22 -6
  52. package/src/cli/watch-judge.ts +48 -6
  53. package/src/cli/watch.ts +163 -42
  54. package/src/cli/why.ts +211 -73
  55. package/src/cli/workspace-config.ts +54 -15
  56. package/src/config.ts +13 -1
  57. package/src/exec/env.ts +7 -2
  58. package/src/exec/executor.ts +154 -10
  59. package/src/exec/index.ts +6 -2
  60. package/src/exec/kill-tree.ts +15 -0
  61. package/src/exec/local-executor.ts +1 -1
  62. package/src/exec/proc-sample.ts +3 -1
  63. package/src/exec/runner.ts +253 -54
  64. package/src/exec/sandbox-binds.ts +7 -3
  65. package/src/exec/sandbox-paths.ts +1 -1
  66. package/src/exec/sandbox-runtime.ts +211 -24
  67. package/src/exec/sandbox-violations.ts +52 -20
  68. package/src/graph/critical-path.ts +79 -0
  69. package/src/graph/default-build.ts +19 -0
  70. package/src/graph/definition.ts +38 -0
  71. package/src/graph/folds.ts +38 -5
  72. package/src/graph/index.ts +4 -1
  73. package/src/graph/priorities.ts +74 -8
  74. package/src/graph/scheduler.ts +270 -60
  75. package/src/graph/task-graph.ts +228 -35
  76. package/src/handoff.ts +97 -9
  77. package/src/history/failure-mode.ts +6 -3
  78. package/src/history/history.ts +167 -61
  79. package/src/history/index.ts +2 -0
  80. package/src/history/metrics.ts +90 -30
  81. package/src/index.ts +30 -13
  82. package/src/orchestrator/admission.ts +10 -6
  83. package/src/orchestrator/affected-tasks.ts +11 -6
  84. package/src/orchestrator/deferred-outputs.ts +2 -0
  85. package/src/orchestrator/doctor.ts +134 -42
  86. package/src/orchestrator/execute-args.ts +4 -1
  87. package/src/orchestrator/execute-task.ts +130 -38
  88. package/src/orchestrator/fingerprint-watch.ts +1 -1
  89. package/src/orchestrator/graph-memo.ts +140 -0
  90. package/src/orchestrator/graph-stage.ts +18 -1
  91. package/src/orchestrator/held-servers.ts +1 -1
  92. package/src/orchestrator/hit-restore.ts +217 -119
  93. package/src/orchestrator/index.ts +4 -3
  94. package/src/orchestrator/local-floor.ts +119 -25
  95. package/src/orchestrator/local-shortcircuit.ts +28 -4
  96. package/src/orchestrator/lockfile-claim.ts +96 -16
  97. package/src/orchestrator/miss-save.ts +42 -8
  98. package/src/orchestrator/options.ts +8 -6
  99. package/src/orchestrator/output-host.ts +43 -14
  100. package/src/orchestrator/output-log.ts +90 -10
  101. package/src/orchestrator/persistent-task.ts +84 -29
  102. package/src/orchestrator/persistent.ts +16 -4
  103. package/src/orchestrator/placement.ts +185 -17
  104. package/src/orchestrator/plan.ts +64 -7
  105. package/src/orchestrator/plugin-host.ts +214 -42
  106. package/src/orchestrator/plugin.ts +113 -37
  107. package/src/orchestrator/prepare.ts +168 -30
  108. package/src/orchestrator/projects.ts +65 -40
  109. package/src/orchestrator/remote-prefetch.ts +119 -25
  110. package/src/orchestrator/run-artifacts.ts +95 -32
  111. package/src/orchestrator/run-context.ts +39 -41
  112. package/src/orchestrator/run-failures.ts +65 -23
  113. package/src/orchestrator/run-id.ts +6 -3
  114. package/src/orchestrator/run-keys.ts +149 -13
  115. package/src/orchestrator/run-live.ts +294 -0
  116. package/src/orchestrator/run-lock.ts +13 -7
  117. package/src/orchestrator/run-records.ts +35 -29
  118. package/src/orchestrator/run-report.ts +3 -3
  119. package/src/orchestrator/run-steps.ts +58 -22
  120. package/src/orchestrator/run-stop.ts +22 -5
  121. package/src/orchestrator/run.ts +331 -88
  122. package/src/orchestrator/sandbox-fix.ts +7 -5
  123. package/src/orchestrator/sandbox-request.ts +70 -20
  124. package/src/orchestrator/shell-verdict.ts +5 -5
  125. package/src/orchestrator/signals.ts +26 -1
  126. package/src/orchestrator/stable-keys.ts +92 -8
  127. package/src/orchestrator/steps.ts +2 -2
  128. package/src/orchestrator/task-artifacts.ts +90 -26
  129. package/src/orchestrator/task-hash.ts +78 -13
  130. package/src/orchestrator/task-log-buffer.ts +72 -36
  131. package/src/orchestrator/task-process.ts +7 -1
  132. package/src/orchestrator/telemetry-host.ts +22 -2
  133. package/src/orchestrator/telemetry.ts +53 -21
  134. package/src/orchestrator/upstream.ts +15 -0
  135. package/src/report/events.ts +32 -13
  136. package/src/report/forecast.ts +31 -9
  137. package/src/report/framed-output.ts +141 -50
  138. package/src/report/index.ts +1 -0
  139. package/src/report/logger.ts +170 -179
  140. package/src/report/path-links.ts +12 -4
  141. package/src/report/plain-output.ts +13 -5
  142. package/src/report/status-line.ts +81 -53
  143. package/src/report/summary.ts +114 -32
  144. package/src/report/terminal-signals.ts +13 -2
  145. package/src/settings.ts +7 -6
  146. package/src/util/bun-version.ts +4 -1
  147. package/src/util/colors.ts +4 -2
  148. package/src/util/config-frame.ts +41 -4
  149. package/src/{graph → util}/dependency-spec.ts +22 -13
  150. package/src/util/edit-distance.ts +13 -1
  151. package/src/util/env-flag.ts +9 -0
  152. package/src/util/errors.ts +59 -3
  153. package/src/util/essential-env.ts +57 -0
  154. package/src/util/git-ignored.ts +1 -1
  155. package/src/util/hash.ts +35 -0
  156. package/src/util/hide-env.ts +45 -5
  157. package/src/util/index.ts +21 -5
  158. package/src/util/num.ts +8 -0
  159. package/src/util/paths.ts +91 -21
  160. package/src/util/report.ts +6 -1
  161. package/src/util/secret-mask.ts +62 -1
  162. package/src/util/secrets.ts +7 -2
  163. package/src/util/size.ts +11 -3
  164. package/src/util/tail.ts +6 -1
  165. package/src/util/timing.ts +13 -1
  166. package/src/util/ui.ts +61 -3
  167. package/src/util/which.ts +1 -1
  168. package/src/util/write-file.ts +67 -0
  169. package/src/workspace/affected.ts +710 -107
  170. package/src/workspace/bracket-literal.ts +84 -0
  171. package/src/workspace/canonical-dir.ts +37 -0
  172. package/src/workspace/config-bytecode.ts +17 -11
  173. package/src/workspace/config-eval.ts +29 -8
  174. package/src/workspace/config-imports.ts +199 -58
  175. package/src/workspace/config-schema.ts +242 -39
  176. package/src/workspace/filter.ts +37 -8
  177. package/src/workspace/index.ts +11 -1
  178. package/src/workspace/json-data.ts +3 -4
  179. package/src/workspace/lockfile.ts +57 -8
  180. package/src/workspace/migration.ts +69 -18
  181. package/src/workspace/package-graph.ts +5 -4
  182. package/src/workspace/project-loader.ts +226 -37
  183. package/src/workspace/repo-id.ts +1 -1
  184. package/src/workspace/workspace.ts +176 -33
@@ -16,8 +16,8 @@ import {
16
16
  committedFiles,
17
17
  OUTPUT_DIRS_OVER_CAP,
18
18
  outputWalkTakes,
19
- resolveOutputs,
20
- resolveWorkspaceOutputs,
19
+ resolveOutputTree,
20
+ resolveWorkspaceOutputTree,
21
21
  sweepRestoreTemps,
22
22
  WORKSPACE_OUTPUT_PREFIX,
23
23
  } from '../cache/index.js'
@@ -33,7 +33,7 @@ import {
33
33
  } from '../util/index.js'
34
34
  import type { ExecuteArgs } from './execute-task.js'
35
35
  import { entryHolds } from './miss-save.js'
36
- import { decodeOutputLog } from './output-log.js'
36
+ import { decodeOutputLog, maskStreams } from './output-log.js'
37
37
 
38
38
  export interface RestoreHitArgs {
39
39
  args: ExecuteArgs
@@ -55,28 +55,33 @@ export interface RestoreHitArgs {
55
55
  * schedule using the IDENTICAL logic — there is one restore path, not
56
56
  * two that could drift.
57
57
  */
58
- export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome> {
59
- const { args, hash, hit, cacheOpStart, taskStartNs } = restore
60
- const { node, log } = args
58
+ export async function restoreHit(given: RestoreHitArgs): Promise<TaskOutcome> {
59
+ const { args, hash } = given
60
+ const { node } = args
61
61
  const cacheCfg: CacheConfig | undefined = node.config.cache
62
62
  const outputs = cacheCfg?.outputs.files ?? []
63
63
  const wsOutputs = cacheCfg?.outputs.workspaceFiles ?? []
64
64
  const anyOutputs = outputs.length > 0 || wsOutputs.length > 0
65
+ // A layer that handed no rows: one SELECT, read by both paths below.
66
+ const restore =
67
+ anyOutputs && given.hit.outputRows === undefined
68
+ ? {
69
+ ...given,
70
+ hit: {
71
+ ...given.hit,
72
+ outputRows: args.cache.loadOutputFilesBatch([hash]).get(hash) ?? [],
73
+ },
74
+ }
75
+ : given
76
+ const { hit } = restore
77
+ const now = currentHit(restore)
78
+ if (now !== undefined) return now()
65
79
  // The directories the positive globs cover whole. A `!` entry inside one
66
80
  // leaves the snapshot sound: a file added or removed there still bumps a
67
81
  // recorded directory, and the walk that follows applies the `!` (A-46).
68
- const dirPrefixes = dirPrefixesOf(outputs)
69
- const cleanArgs = {
70
- projectDir: node.projectDir,
71
- outputs,
72
- nestedProjectDirs: args.nestedProjectDirs,
73
- committed: committedFiles(args.gitFilesCache, node.projectDir),
74
- }
75
- const wsCleanArgs = {
76
- workspaceRoot: args.workspaceRoot,
77
- outputs: wsOutputs,
78
- committed: committedFiles(args.gitFilesCache, args.workspaceRoot),
79
- }
82
+ // Asked only of a task that declares outputs: most hits declare none,
83
+ // and a warm run of 1,000 such hits paid this set-up for each.
84
+ const dirPrefixes = anyOutputs ? dirPrefixesOf(outputs) : null
80
85
  // The two sides of an overlapping pair (item 588). An ADDITIVE task owns
81
86
  // only its recorded rows: its glob also selects the upstream's files it
82
87
  // adds beside, so "no strays" is not a claim it can make, and its clean
@@ -85,9 +90,10 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
85
90
  // addition below is not a stray — while its glob clean still takes them:
86
91
  // the dependant restores or runs after it, by the edge.
87
92
  const additive = (node.addsToOutputsOf?.length ?? 0) > 0
88
- const addedGlobs = (node.outputsAddedToBy ?? [])
89
- .flatMap((g) => asTrees([g]))
90
- .map((g) => taskGlob(g))
93
+ const addedGlobs =
94
+ anyOutputs && node.outputsAddedToBy !== undefined
95
+ ? node.outputsAddedToBy.flatMap((g) => asTrees([g])).map((g) => taskGlob(g))
96
+ : []
91
97
  const isAddition = (rel: string): boolean => addedGlobs.some((g) => g.match(rel))
92
98
 
93
99
  // "Tree is already current" short-circuit — skip cleanOutputs
@@ -114,11 +120,9 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
114
120
  // The walk that found the tree stale, handed to the clean when nothing
115
121
  // was awaited between them: the clean's own walk would only repeat it
116
122
  // (X-161).
117
- let walked: { proj: string[]; ws: string[] } | undefined
123
+ let walked: { proj: Tree; ws: Tree } | undefined
118
124
  if (anyOutputs) {
119
- const endRows = span('output rows')
120
- const expected = hit.outputRows ?? args.cache.loadOutputFilesBatch([hash]).get(hash) ?? []
121
- endRows()
125
+ const expected = hit.outputRows ?? []
122
126
  if (expected.length > 0) {
123
127
  // Two namespaces in the rows: bare rels are project outputs,
124
128
  // `workspace-outputs/<rel>` rows anchor at the workspace root.
@@ -126,91 +130,92 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
126
130
  const wsExpected = expected
127
131
  .filter((e) => e.path.startsWith(WORKSPACE_OUTPUT_PREFIX))
128
132
  .map((e) => ({ ...e, path: e.path.slice(WORKSPACE_OUTPUT_PREFIX.length) }))
129
- // The directory short-circuit: for whole-subtree globs, unchanged
130
- // mtimes on every directory recorded at the last save/restore prove
131
- // the output SET is unchanged (a file added or removed anywhere the
132
- // glob could see bumps a recorded directory), so the walk that cost
133
- // 0.36 ms per warm hit is replaced by a few stats. The per-file
134
- // fingerprint check below still runs; only the enumeration is skipped.
135
- let setKnown = false
136
- if (dirPrefixes !== null && wsOutputs.length === 0 && args.cache.outputDirsCurrent) {
137
- const endDirs = span('output dirs')
138
- // Loaded with the entry (batched by getMany, or with the lazy get).
139
- const dirRows = hit.outputDirRows ?? []
140
- // Rows are recorded for these same prefixes under this same key
141
- // (the outputs are folded into it), so `covers` cannot be false
142
- // today — deleting it reddens nothing (item 638). Kept: a row
143
- // set that shrank would otherwise vouch for a tree it never saw,
144
- // and the failure it would let through is a skipped restore.
145
- const covers = dirPrefixes.every((pre) => dirRows.some((r) => r.path === pre))
146
- setKnown = covers && (await args.cache.outputDirsCurrent(node.projectDir, dirRows))
147
- endDirs()
148
- }
149
- let treeMatches: boolean
150
- if (setKnown) {
151
- // The snapshot proved the tree holds exactly the entry's project
152
- // rows (one per path, the table's key) and no workspace output is
153
- // declared, so the comparisons below hold by construction: mapping
154
- // each row to a path and back was ~5 ms per 1,000 warm hits.
155
- treeMatches = wsExpected.length === 0
156
- } else {
157
- const endGlob = span('output glob')
158
- const actualAbs = await resolveOutputs({
159
- projectDir: node.projectDir,
160
- outputs,
161
- nestedProjectDirs: args.nestedProjectDirs,
162
- })
163
- const actualWsAbs = await resolveWorkspaceOutputs({
164
- workspaceRoot: args.workspaceRoot,
165
- outputs: wsOutputs,
166
- })
167
- endGlob()
168
- const setsMatch = (
169
- actual: readonly string[],
170
- exp: ReadonlyArray<{ path: string }>,
171
- ): boolean => {
172
- const expSet = new Set(exp.map((e) => e.path).filter((p) => !p.endsWith('/')))
173
- return actual.length === expSet.size && actual.every((r) => expSet.has(r))
174
- }
175
- // A stray a dependant's glob could have added is not a stray; a path
176
- // this entry recorded is never dropped, even where that glob covers
177
- // the whole tree (strapi's shape: both on `dist`).
178
- const expectedRels = new Set(projExpected.map((e) => e.path))
179
- const expectedWsRels = new Set(wsExpected.map((e) => e.path))
180
- const actualRels = actualAbs
181
- .map((p) => relPosix(node.projectDir, p))
182
- .filter((rel) => expectedRels.has(rel) || !isAddition(rel))
183
- const actualWsRels = actualWsAbs
184
- .map((p) => relPosix(args.workspaceRoot, p))
185
- .filter((rel) => expectedWsRels.has(rel) || !isAddition(rel))
186
- const rowsPresent = (
187
- actual: readonly string[],
188
- exp: ReadonlyArray<{ path: string }>,
189
- ): boolean => {
190
- const have = new Set(actual)
191
- return exp.every((e) => e.path.endsWith('/') || have.has(e.path))
133
+ const endGlob = span('output glob')
134
+ const projTree = await resolveOutputTree({
135
+ projectDir: node.projectDir,
136
+ outputs,
137
+ nestedProjectDirs: args.nestedProjectDirs,
138
+ })
139
+ const wsTree = await resolveWorkspaceOutputTree({
140
+ workspaceRoot: args.workspaceRoot,
141
+ outputs: wsOutputs,
142
+ })
143
+ endGlob()
144
+ const actualAbs = projTree.files
145
+ const actualWsAbs = wsTree.files
146
+ // An empty directory is an entry row (`dist/empty/`): one the entry
147
+ // does not hold is a stray, as a file is. A directory row with a row
148
+ // under it is one kept for its mode (`DIR_MODE`), and the walk lists
149
+ // no such directory: only the files and empty directories it holds.
150
+ const setsMatch = (
151
+ actual: readonly string[],
152
+ exp: ReadonlyArray<{ path: string }>,
153
+ ): boolean => {
154
+ const held = new Set<string>()
155
+ for (const { path: p } of exp) {
156
+ for (let i = p.lastIndexOf('/', p.length - 2); i > 0; i = p.lastIndexOf('/', i - 1))
157
+ held.add(p.slice(0, i + 1))
192
158
  }
193
- treeMatches = additive
194
- ? rowsPresent(actualRels, projExpected) && rowsPresent(actualWsRels, wsExpected)
195
- : setsMatch(actualRels, projExpected) && setsMatch(actualWsRels, wsExpected)
196
- if (!treeMatches) walked = { proj: actualAbs, ws: actualWsAbs }
159
+ const expSet = new Set(
160
+ exp.map((e) => e.path).filter((p) => !p.endsWith('/') || !held.has(p)),
161
+ )
162
+ return actual.length === expSet.size && actual.every((r) => expSet.has(r))
163
+ }
164
+ const dirRels = (root: string, dirs: readonly string[]): string[] =>
165
+ dirs.map((d) => `${relPosix(root, d)}/`)
166
+ // A stray a dependant's glob could have added is not a stray; a path
167
+ // this entry recorded is never dropped, even where that glob covers
168
+ // the whole tree (strapi's shape: both on `dist`).
169
+ const expectedRels = new Set(projExpected.map((e) => e.path))
170
+ const expectedWsRels = new Set(wsExpected.map((e) => e.path))
171
+ const actualRels = actualAbs
172
+ .map((p) => relPosix(node.projectDir, p))
173
+ .filter((rel) => expectedRels.has(rel) || !isAddition(rel))
174
+ const actualWsRels = actualWsAbs
175
+ .map((p) => relPosix(args.workspaceRoot, p))
176
+ .filter((rel) => expectedWsRels.has(rel) || !isAddition(rel))
177
+ const actualDirRels = dirRels(node.projectDir, projTree.emptyDirs).filter(
178
+ (rel) => expectedRels.has(rel) || !isAddition(rel.slice(0, -1)),
179
+ )
180
+ const actualWsDirRels = dirRels(args.workspaceRoot, wsTree.emptyDirs).filter(
181
+ (rel) => expectedWsRels.has(rel) || !isAddition(rel.slice(0, -1)),
182
+ )
183
+ const rowsPresent = (
184
+ actual: readonly string[],
185
+ exp: ReadonlyArray<{ path: string }>,
186
+ ): boolean => {
187
+ const have = new Set(actual)
188
+ return exp.every((e) => e.path.endsWith('/') || have.has(e.path))
197
189
  }
190
+ const treeMatches = additive
191
+ ? rowsPresent(actualRels, projExpected) && rowsPresent(actualWsRels, wsExpected)
192
+ : setsMatch([...actualRels, ...actualDirRels], projExpected) &&
193
+ setsMatch([...actualWsRels, ...actualWsDirRels], wsExpected)
194
+ if (!treeMatches) walked = { proj: projTree, ws: wsTree }
198
195
  if (treeMatches) {
199
196
  const endStat = span('output stat')
200
197
  skipRestore =
201
- (await args.cache.isOutputsCurrent(node.projectDir, projExpected)) &&
202
- (await args.cache.isOutputsCurrent(args.workspaceRoot, wsExpected))
198
+ args.cache.isOutputsCurrent(node.projectDir, projExpected) &&
199
+ args.cache.isOutputsCurrent(args.workspaceRoot, wsExpected)
203
200
  endStat()
204
201
  // The walk ran and proved the tree current (rows absent, or a
205
202
  // directory had moved — a benign touch): snapshot the directories
206
203
  // so the next hit skips the walk.
207
204
  if (
208
205
  skipRestore &&
209
- !setKnown &&
210
206
  dirPrefixes !== null &&
211
207
  hit.outputDirRows?.some((r) => r.mtimeMs === OUTPUT_DIRS_OVER_CAP) !== true
212
208
  ) {
213
- await args.cache.recordOutputDirs?.(hash, node.projectDir, dirPrefixes)
209
+ // Held to the rows like the run-end snapshot (item 1087): a stray
210
+ // written since the walk listed its directory, more than a racy
211
+ // window before this, is not the entry's.
212
+ const holds = entryHolds(
213
+ node,
214
+ outputWalkTakes(node.projectDir, outputs, args.nestedProjectDirs),
215
+ )
216
+ await args.cache.recordOutputDirs?.(hash, node.projectDir, dirPrefixes, (files) =>
217
+ holds(files, expected),
218
+ )
214
219
  }
215
220
  }
216
221
  }
@@ -221,24 +226,37 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
221
226
  // of the tar is spared. A stray under the glob still falls through to
222
227
  // the clean, as strict ownership requires (item 589).
223
228
  if (anyOutputs && !skipRestore) {
224
- const expected = hit.outputRows ?? args.cache.loadOutputFilesBatch([hash]).get(hash) ?? []
229
+ const expected = hit.outputRows ?? []
225
230
  if (expected.length === 0) {
226
231
  const endGlob = span('output glob')
227
- const actual = await resolveOutputs({
232
+ const actual = await resolveOutputTree({
228
233
  projectDir: node.projectDir,
229
234
  outputs,
230
235
  nestedProjectDirs: args.nestedProjectDirs,
231
236
  })
232
- const actualWs = await resolveWorkspaceOutputs({
237
+ const actualWs = await resolveWorkspaceOutputTree({
233
238
  workspaceRoot: args.workspaceRoot,
234
239
  outputs: wsOutputs,
235
240
  })
236
241
  endGlob()
237
- skipRestore = actual.length === 0 && actualWs.length === 0
242
+ skipRestore =
243
+ actual.files.length + actual.emptyDirs.length + actualWs.files.length === 0 &&
244
+ actualWs.emptyDirs.length === 0
238
245
  walked = { proj: actual, ws: actualWs }
239
246
  }
240
247
  }
241
248
  if (!skipRestore) {
249
+ const cleanArgs = {
250
+ projectDir: node.projectDir,
251
+ outputs,
252
+ nestedProjectDirs: args.nestedProjectDirs,
253
+ committed: committedFiles(args.gitFilesCache, node.projectDir),
254
+ }
255
+ const wsCleanArgs = {
256
+ workspaceRoot: args.workspaceRoot,
257
+ outputs: wsOutputs,
258
+ committed: committedFiles(args.gitFilesCache, args.workspaceRoot),
259
+ }
242
260
  const heldRels = hit.outputFiles.filter((p) => !p.startsWith(WORKSPACE_OUTPUT_PREFIX))
243
261
  const heldWsRels = hit.outputFiles
244
262
  .filter((p) => p.startsWith(WORKSPACE_OUTPUT_PREFIX))
@@ -255,34 +273,42 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
255
273
  let foundWsRels: string[] = []
256
274
  let staleRels: string[] = []
257
275
  let staleWsRels: string[] = []
276
+ let staleDirs: string[] = []
277
+ let staleWsDirs: string[] = []
258
278
  if (!additive) {
259
- sweepRestoreTemps(node.projectDir, outputs)
260
- sweepRestoreTemps(args.workspaceRoot, wsOutputs)
279
+ sweepRestoreTemps(node.projectDir, outputs, cleanArgs.committed)
280
+ sweepRestoreTemps(args.workspaceRoot, wsOutputs, wsCleanArgs.committed)
261
281
  if (outputs.length > 0) {
262
- const abs = walked?.proj ?? (await resolveOutputs(cleanArgs))
263
- foundRels = abs.map((f) => relPosix(node.projectDir, f))
282
+ const tree = walked?.proj ?? (await resolveOutputTree(cleanArgs))
283
+ foundRels = tree.files.map((f) => relPosix(node.projectDir, f))
264
284
  const blocking = inTheWay(foundRels, heldRels)
265
285
  staleRels = notHeld(foundRels, heldRels).filter((f) => !blocking.has(f))
266
- if (blocking.size > 0) {
286
+ const dirs = splitDirs(node.projectDir, tree.emptyDirs, heldRels)
287
+ staleDirs = dirs.stale
288
+ if (blocking.size > 0 || dirs.blocking.length > 0) {
267
289
  await cleanOutputPaths({
268
290
  projectDir: node.projectDir,
269
291
  rels: [...blocking],
270
292
  outputs,
271
293
  committed: cleanArgs.committed,
294
+ emptyDirs: dirs.blocking,
272
295
  })
273
296
  }
274
297
  }
275
298
  if (wsOutputs.length > 0) {
276
- const abs = walked?.ws ?? (await resolveWorkspaceOutputs(wsCleanArgs))
277
- foundWsRels = abs.map((f) => relPosix(args.workspaceRoot, f))
299
+ const tree = walked?.ws ?? (await resolveWorkspaceOutputTree(wsCleanArgs))
300
+ foundWsRels = tree.files.map((f) => relPosix(args.workspaceRoot, f))
278
301
  const blocking = inTheWay(foundWsRels, heldWsRels)
279
302
  staleWsRels = notHeld(foundWsRels, heldWsRels).filter((f) => !blocking.has(f))
280
- if (blocking.size > 0) {
303
+ const dirs = splitDirs(args.workspaceRoot, tree.emptyDirs, heldWsRels)
304
+ staleWsDirs = dirs.stale
305
+ if (blocking.size > 0 || dirs.blocking.length > 0) {
281
306
  await cleanOutputPaths({
282
307
  projectDir: args.workspaceRoot,
283
308
  rels: [...blocking],
284
309
  outputs: wsOutputs,
285
310
  committed: wsCleanArgs.committed,
311
+ emptyDirs: dirs.blocking,
286
312
  })
287
313
  }
288
314
  }
@@ -291,10 +317,11 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
291
317
  files: outputs,
292
318
  workspaceFiles: wsOutputs,
293
319
  })
294
- if (staleRels.length > 0) {
320
+ if (staleRels.length > 0 || staleDirs.length > 0) {
295
321
  await cleanOutputPaths({
296
322
  projectDir: node.projectDir,
297
323
  rels: staleRels,
324
+ emptyDirs: staleDirs,
298
325
  outputs,
299
326
  committed: cleanArgs.committed,
300
327
  // A stale file in an output directory the entry holds empty takes
@@ -306,12 +333,18 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
306
333
  ),
307
334
  })
308
335
  }
309
- if (staleWsRels.length > 0) {
336
+ if (staleWsRels.length > 0 || staleWsDirs.length > 0) {
310
337
  await cleanOutputPaths({
311
338
  projectDir: args.workspaceRoot,
312
339
  rels: staleWsRels,
340
+ emptyDirs: staleWsDirs,
313
341
  outputs: wsOutputs,
314
342
  committed: wsCleanArgs.committed,
343
+ keep: new Set(
344
+ heldWsRels
345
+ .filter((r) => r.endsWith('/'))
346
+ .map((r) => path.join(args.workspaceRoot, r.slice(0, -1))),
347
+ ),
315
348
  })
316
349
  }
317
350
  // The tree now holds this entry's bytes: stamp them for the next hit
@@ -351,6 +384,47 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
351
384
  ])
352
385
  }
353
386
  }
387
+ return hitOutcome(restore, !skipRestore && anyOutputs)
388
+ }
389
+
390
+ /**
391
+ * The hit's outcome now, when its tree is provably current without a walk
392
+ * or an await: it declares no outputs, or every directory the entry
393
+ * recorded and every file it holds are as the entry left them (the
394
+ * directory snapshot proves the set, the stamps the bytes). Undefined
395
+ * sends it down `restoreHit`'s full path. Nothing happens until the thunk
396
+ * runs, which replays the stored output: the scheduler starts a hit it
397
+ * settles in place first, so its output follows its start.
398
+ */
399
+ export function currentHit(restore: RestoreHitArgs): (() => TaskOutcome) | undefined {
400
+ const { args, hash, hit } = restore
401
+ const { node } = args
402
+ const outputs = node.config.cache?.outputs.files ?? []
403
+ if ((node.config.cache?.outputs.workspaceFiles?.length ?? 0) > 0) return undefined
404
+ if (outputs.length > 0) {
405
+ const dirPrefixes = dirPrefixesOf(outputs)
406
+ if (dirPrefixes === null || args.cache.outputDirsCurrent === undefined) return undefined
407
+ const expected = hit.outputRows ?? args.cache.loadOutputFilesBatch([hash]).get(hash) ?? []
408
+ if (expected.length === 0) return undefined
409
+ // Loaded with the entry (batched by getMany, or with the lazy get).
410
+ const dirRows = hit.outputDirRows ?? []
411
+ // Rows are recorded for these same prefixes under this same key (the
412
+ // outputs are folded into it), so this cannot fail today (item 638).
413
+ // Kept: a row set that shrank would otherwise vouch for a tree it never
414
+ // saw, and the failure it would let through is a skipped restore.
415
+ if (!dirPrefixes.every((pre) => dirRows.some((r) => r.path === pre))) return undefined
416
+ if (!args.cache.outputDirsCurrent(node.projectDir, dirRows)) return undefined
417
+ // No workspace output is declared, so every row is the project's.
418
+ if (expected.some((e) => e.path.startsWith(WORKSPACE_OUTPUT_PREFIX))) return undefined
419
+ if (!args.cache.isOutputsCurrent(node.projectDir, expected)) return undefined
420
+ }
421
+ return () => hitOutcome(restore, false)
422
+ }
423
+
424
+ /** Replay the stored output and build the hit's outcome. */
425
+ function hitOutcome(restore: RestoreHitArgs, restored: boolean): TaskOutcome {
426
+ const { args, hash, hit, cacheOpStart, taskStartNs } = restore
427
+ const { node, log } = args
354
428
  if (hit.stdout) {
355
429
  // An entry saved before masking, or by another machine, can hold a
356
430
  // secret-named variable's value (L-11). Asked only of a hit that prints.
@@ -359,10 +433,9 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
359
433
  node.config.exec?.env?.secret,
360
434
  )
361
435
  // The stored log holds both streams in the order the run printed them.
362
- for (const c of decodeOutputLog(hit.stdout)) {
363
- const text = secrets === null ? c.text : secrets.mask(c.text)
364
- if (c.err) log.taskStderr(node, text)
365
- else log.taskStdout(node, text)
436
+ for (const c of maskStreams(decodeOutputLog(hit.stdout), secrets ?? undefined)) {
437
+ if (c.err) log.taskStderr(node, c.text)
438
+ else log.taskStdout(node, c.text)
366
439
  }
367
440
  }
368
441
  const status =
@@ -370,10 +443,10 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
370
443
  // `restored` distinguishes "we just wrote files to disk" from
371
444
  // "disk already matched the cached snapshot". Drives the
372
445
  // "up-to-date" vs "local-cache" / "remote-cache" label in the
373
- // framed block. Only meaningful when at least one output was
374
- // declared — no-outputs tasks never materialize anything, so
375
- // they're vacuously up-to-date.
376
- const restored = !skipRestore && anyOutputs
446
+ // framed block. A hit the remote answered is never up to date: up to
447
+ // date is fresh here, and a remote hit waited on the network and
448
+ // downloaded its entry, outputs or none (2026-10-24: "Up to date is
449
+ // a fresh. It should not require any cache").
377
450
  return {
378
451
  node,
379
452
  status,
@@ -387,7 +460,7 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
387
460
  ...(hit.sizeBytes !== undefined ? { artifactBytes: hit.sizeBytes } : {}),
388
461
  ...(hit.fetchMs !== undefined ? { fetchMs: hit.fetchMs } : {}),
389
462
  hash,
390
- restored,
463
+ restored: restored || hit.source === 'remote',
391
464
  wallclockStartNs: taskStartNs,
392
465
  wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
393
466
  }
@@ -446,6 +519,31 @@ function inTheWay(found: readonly string[], held: readonly string[]): Set<string
446
519
  }
447
520
 
448
521
  /** The found outputs the entry does not hold: the stale ones, once it has restored. */
522
+ type Tree = { files: string[]; emptyDirs: string[] }
523
+
524
+ /**
525
+ * The empty directories found under the outputs (absolute) that the entry
526
+ * does not hold, as rels: one where it holds a file blocks that file's
527
+ * rename and goes first, the rest go after the restore, as a miss's clean
528
+ * takes them.
529
+ */
530
+ function splitDirs(
531
+ root: string,
532
+ emptyDirs: readonly string[],
533
+ held: readonly string[],
534
+ ): { blocking: string[]; stale: string[] } {
535
+ const heldKeys = new Set(held.map(fold))
536
+ const blocking: string[] = []
537
+ const stale: string[] = []
538
+ for (const d of emptyDirs) {
539
+ const rel = relPosix(root, d)
540
+ if (heldKeys.has(fold(`${rel}/`))) continue
541
+ if (heldKeys.has(fold(rel))) blocking.push(rel)
542
+ else stale.push(rel)
543
+ }
544
+ return { blocking, stale }
545
+ }
546
+
449
547
  function notHeld(found: readonly string[], held: readonly string[]): string[] {
450
548
  const keep = new Set(held.map(fold))
451
549
  return found.filter((f) => !keep.has(fold(f)))
@@ -2,6 +2,7 @@
2
2
  // through here (enforced by tests/module-boundaries.test.ts).
3
3
 
4
4
  export { run, planRun, nxProjectTarget, projectNamed } from './run.js'
5
+ export { CYCLE_INTERRUPT } from './run-stop.js'
5
6
  export {
6
7
  namesRemote,
7
8
  openRemotes,
@@ -15,7 +16,6 @@ export type { AffectedReason } from './affected-tasks.js'
15
16
  export {
16
17
  discoverProjects,
17
18
  gitOfDiscovery,
18
- isDefaultBuild,
19
19
  keepDiscoveryGraph,
20
20
  loadProjects,
21
21
  loadResolvedProjects,
@@ -42,6 +42,7 @@ export { exitSignal, shellQuote } from '../exec/index.js'
42
42
  export { forwardedSignal } from './signals.js'
43
43
  // `vx cache clean` waits for a run on the workspace before it evicts.
44
44
  export { acquireRunLock } from './run-lock.js'
45
+ export { type LiveSnapshot, liveSocketPath } from './run-live.js'
45
46
  export type { RunPlan, PlannedTask, PlanPrediction, CacheStatus } from './plan.js'
46
47
  export {
47
48
  cacheFacts,
@@ -145,8 +146,8 @@ export {
145
146
  type TaskLog,
146
147
  taskLog,
147
148
  } from './run-failures.js'
148
- export { type RunStep, runSteps } from './run-steps.js'
149
+ export { type RunStep, runSteps, runWarnings } from './run-steps.js'
149
150
  export { resolveRunId, shortRunId } from './run-id.js'
150
151
  export { collectInfo, type CollectInfoOptions, type InfoFacts } from './doctor.js'
151
152
  export { localFloor, type LocalFloor } from './local-floor.js'
152
- export { sandboxRequestFor, type SandboxRequest } from './sandbox-request.js'
153
+ export { sandboxRequestFor, sweepPlaceholders, type SandboxRequest } from './sandbox-request.js'