@vzn/vx 0.0.645 → 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 (186) hide show
  1. package/README.md +39 -50
  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 +127 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +19 -3
  12. package/src/bin.ts +54 -13
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +330 -65
  15. package/src/cache/git-inputs.ts +274 -97
  16. package/src/cache/index.ts +7 -2
  17. package/src/cache/inputs.ts +515 -83
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +74 -11
  20. package/src/cache/layered-cache.ts +154 -59
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +44 -14
  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 +100 -15
  26. package/src/cache/run-history.ts +77 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +42 -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/core-alias.ts +5 -2
  34. package/src/cli/docs.ts +42 -16
  35. package/src/cli/help.ts +62 -25
  36. package/src/cli/index.ts +79 -45
  37. package/src/cli/info.ts +12 -6
  38. package/src/cli/init.ts +222 -34
  39. package/src/cli/last.ts +443 -47
  40. package/src/cli/lock.ts +197 -52
  41. package/src/cli/plan-format.ts +91 -49
  42. package/src/cli/plugin-commands.ts +160 -11
  43. package/src/cli/plugin-templates.ts +13 -13
  44. package/src/cli/registry.ts +209 -0
  45. package/src/cli/run.ts +111 -41
  46. package/src/cli/select.ts +148 -49
  47. package/src/cli/show.ts +59 -16
  48. package/src/cli/task-verb.ts +16 -6
  49. package/src/cli/upgrade.ts +155 -54
  50. package/src/cli/watch-cycle.ts +10 -5
  51. package/src/cli/watch-filter.ts +71 -3
  52. package/src/cli/watch-fs.ts +22 -6
  53. package/src/cli/watch-judge.ts +48 -6
  54. package/src/cli/watch.ts +163 -42
  55. package/src/cli/why.ts +211 -73
  56. package/src/cli/workspace-config.ts +54 -15
  57. package/src/config.ts +13 -1
  58. package/src/exec/env.ts +7 -2
  59. package/src/exec/executor.ts +262 -15
  60. package/src/exec/index.ts +8 -2
  61. package/src/exec/kill-tree.ts +15 -0
  62. package/src/exec/local-executor.ts +1 -1
  63. package/src/exec/proc-sample.ts +70 -6
  64. package/src/exec/runner.ts +256 -53
  65. package/src/exec/sandbox-binds.ts +7 -3
  66. package/src/exec/sandbox-paths.ts +1 -1
  67. package/src/exec/sandbox-runtime.ts +222 -28
  68. package/src/exec/sandbox-violations.ts +52 -20
  69. package/src/graph/critical-path.ts +79 -0
  70. package/src/graph/default-build.ts +19 -0
  71. package/src/graph/definition.ts +38 -0
  72. package/src/graph/folds.ts +38 -5
  73. package/src/graph/index.ts +4 -1
  74. package/src/graph/priorities.ts +74 -8
  75. package/src/graph/scheduler.ts +326 -65
  76. package/src/graph/task-graph.ts +228 -35
  77. package/src/handoff.ts +97 -9
  78. package/src/history/failure-mode.ts +6 -3
  79. package/src/history/history.ts +268 -50
  80. package/src/history/index.ts +2 -0
  81. package/src/history/metrics.ts +142 -45
  82. package/src/index.ts +42 -10
  83. package/src/orchestrator/admission.ts +10 -6
  84. package/src/orchestrator/affected-tasks.ts +11 -6
  85. package/src/orchestrator/deferred-outputs.ts +2 -0
  86. package/src/orchestrator/doctor.ts +134 -42
  87. package/src/orchestrator/execute-args.ts +6 -0
  88. package/src/orchestrator/execute-task.ts +155 -42
  89. package/src/orchestrator/fingerprint-watch.ts +1 -1
  90. package/src/orchestrator/graph-memo.ts +140 -0
  91. package/src/orchestrator/graph-stage.ts +18 -1
  92. package/src/orchestrator/held-servers.ts +86 -0
  93. package/src/orchestrator/hit-restore.ts +227 -122
  94. package/src/orchestrator/index.ts +20 -3
  95. package/src/orchestrator/local-floor.ts +198 -0
  96. package/src/orchestrator/local-shortcircuit.ts +51 -8
  97. package/src/orchestrator/lockfile-claim.ts +96 -16
  98. package/src/orchestrator/miss-save.ts +52 -12
  99. package/src/orchestrator/options.ts +8 -6
  100. package/src/orchestrator/output-host.ts +43 -14
  101. package/src/orchestrator/output-log.ts +90 -10
  102. package/src/orchestrator/persistent-task.ts +97 -28
  103. package/src/orchestrator/persistent.ts +16 -4
  104. package/src/orchestrator/placement.ts +257 -14
  105. package/src/orchestrator/plan.ts +64 -7
  106. package/src/orchestrator/plugin-host.ts +230 -40
  107. package/src/orchestrator/plugin.ts +150 -37
  108. package/src/orchestrator/prepare.ts +178 -32
  109. package/src/orchestrator/projects.ts +65 -40
  110. package/src/orchestrator/remote-prefetch.ts +119 -25
  111. package/src/orchestrator/run-artifacts.ts +95 -32
  112. package/src/orchestrator/run-context.ts +39 -41
  113. package/src/orchestrator/run-failures.ts +65 -23
  114. package/src/orchestrator/run-id.ts +6 -3
  115. package/src/orchestrator/run-keys.ts +300 -0
  116. package/src/orchestrator/run-live.ts +294 -0
  117. package/src/orchestrator/run-lock.ts +13 -7
  118. package/src/orchestrator/run-records.ts +35 -23
  119. package/src/orchestrator/run-report.ts +3 -3
  120. package/src/orchestrator/run-steps.ts +58 -22
  121. package/src/orchestrator/run-stop.ts +22 -5
  122. package/src/orchestrator/run.ts +409 -79
  123. package/src/orchestrator/sandbox-fix.ts +7 -5
  124. package/src/orchestrator/sandbox-request.ts +78 -22
  125. package/src/orchestrator/shell-verdict.ts +5 -5
  126. package/src/orchestrator/signals.ts +26 -1
  127. package/src/orchestrator/stable-keys.ts +92 -8
  128. package/src/orchestrator/steps.ts +2 -2
  129. package/src/orchestrator/task-artifacts.ts +206 -0
  130. package/src/orchestrator/task-hash.ts +89 -14
  131. package/src/orchestrator/task-log-buffer.ts +72 -36
  132. package/src/orchestrator/task-process.ts +22 -0
  133. package/src/orchestrator/telemetry-host.ts +24 -3
  134. package/src/orchestrator/telemetry.ts +113 -38
  135. package/src/orchestrator/upstream.ts +15 -0
  136. package/src/report/events.ts +32 -13
  137. package/src/report/forecast.ts +36 -35
  138. package/src/report/framed-output.ts +173 -46
  139. package/src/report/index.ts +1 -0
  140. package/src/report/logger.ts +217 -186
  141. package/src/report/path-links.ts +12 -4
  142. package/src/report/plain-output.ts +13 -5
  143. package/src/report/status-line.ts +94 -46
  144. package/src/report/summary.ts +174 -38
  145. package/src/report/terminal-signals.ts +13 -2
  146. package/src/settings.ts +14 -6
  147. package/src/util/bun-version.ts +4 -1
  148. package/src/util/colors.ts +20 -3
  149. package/src/util/config-frame.ts +41 -4
  150. package/src/{graph → util}/dependency-spec.ts +22 -13
  151. package/src/util/edit-distance.ts +13 -1
  152. package/src/util/env-flag.ts +9 -0
  153. package/src/util/errors.ts +59 -3
  154. package/src/util/essential-env.ts +57 -0
  155. package/src/util/git-ignored.ts +1 -1
  156. package/src/util/hash.ts +35 -0
  157. package/src/util/hide-env.ts +45 -5
  158. package/src/util/index.ts +22 -5
  159. package/src/util/num.ts +8 -0
  160. package/src/util/paths.ts +121 -21
  161. package/src/util/report.ts +6 -1
  162. package/src/util/secret-mask.ts +62 -1
  163. package/src/util/secrets.ts +7 -2
  164. package/src/util/size.ts +11 -3
  165. package/src/util/tail.ts +6 -1
  166. package/src/util/timing.ts +13 -1
  167. package/src/util/ui.ts +65 -4
  168. package/src/util/which.ts +1 -1
  169. package/src/util/write-file.ts +67 -0
  170. package/src/workspace/affected.ts +710 -107
  171. package/src/workspace/bracket-literal.ts +84 -0
  172. package/src/workspace/canonical-dir.ts +37 -0
  173. package/src/workspace/config-bytecode.ts +282 -0
  174. package/src/workspace/config-eval.ts +29 -8
  175. package/src/workspace/config-imports.ts +199 -58
  176. package/src/workspace/config-schema.ts +242 -39
  177. package/src/workspace/filter.ts +37 -8
  178. package/src/workspace/index.ts +11 -2
  179. package/src/workspace/json-data.ts +3 -4
  180. package/src/workspace/lockfile.ts +57 -8
  181. package/src/workspace/migration.ts +72 -49
  182. package/src/workspace/package-graph.ts +5 -4
  183. package/src/workspace/project-loader.ts +314 -47
  184. package/src/workspace/repo-id.ts +1 -1
  185. package/src/workspace/workspace.ts +176 -33
  186. package/src/cli/foreign-flags.ts +0 -727
@@ -0,0 +1,86 @@
1
+ // A server the run's local store cannot settle: some dependant missed here
2
+ // but may be a hit the remote holds. The scheduler holds such a server
3
+ // until the remote answers; when every dependant hit, the server never
4
+ // starts (2026-10-22), as it never starts behind local hits.
5
+
6
+ import type { TaskNode } from '../graph/index.js'
7
+ import type { CacheEntry, CacheLayer } from '../cache/index.js'
8
+ import type { ShortCircuit } from './local-shortcircuit.js'
9
+ import { getContext } from './remote-prefetch.js'
10
+
11
+ /** The persistent tasks nobody asked for whose every dependant is a local hit or a key the remote may hold. */
12
+ export function serversToHold(
13
+ nodes: ReadonlyMap<string, TaskNode>,
14
+ restoreTier: ReadonlySet<string>,
15
+ remoteAsk: ReadonlyMap<string, string>,
16
+ ): Map<string, string[]> {
17
+ const out = new Map<string, string[]>()
18
+ if (remoteAsk.size === 0) return out
19
+ const dependents = new Map<string, string[]>()
20
+ for (const n of nodes.values())
21
+ for (const d of n.deps) {
22
+ const list = dependents.get(d)
23
+ if (list === undefined) dependents.set(d, [n.id])
24
+ else list.push(n.id)
25
+ }
26
+ for (const n of nodes.values()) {
27
+ if (n.config.exec?.persistent === undefined || n.requested || n.surfaced === true) continue
28
+ const ds = dependents.get(n.id)
29
+ if (
30
+ ds !== undefined &&
31
+ ds.every((d) => restoreTier.has(d) || remoteAsk.has(d)) &&
32
+ ds.some((d) => remoteAsk.has(d))
33
+ )
34
+ out.set(n.id, ds)
35
+ }
36
+ return out
37
+ }
38
+
39
+ /**
40
+ * For each held server, whether the remote holds every dependant: one
41
+ * `get` per dependant through the layered cache, which shares the
42
+ * prefetch's request. All hits join the restore tier at once, so the
43
+ * scheduler finds the server idle; the first miss answers at once, with
44
+ * nothing promoted, so the server starts without waiting on the rest.
45
+ */
46
+ export function askRemote(
47
+ held: ReadonlyMap<string, readonly string[]>,
48
+ nodes: ReadonlyMap<string, TaskNode>,
49
+ shortCircuit: Pick<ShortCircuit, 'preProbed' | 'restoreTier' | 'remoteAsk'>,
50
+ cache: CacheLayer,
51
+ ): Map<string, Promise<readonly string[]>> {
52
+ const asked = new Map<string, Promise<CacheEntry | null>>()
53
+ const ask = (id: string): Promise<CacheEntry | null> => {
54
+ let p = asked.get(id)
55
+ if (p === undefined) {
56
+ const node = nodes.get(id)!
57
+ p = cache
58
+ .get(shortCircuit.remoteAsk.get(id)!, getContext(node, node.config.exec?.command ?? ''))
59
+ .catch(() => null)
60
+ asked.set(id, p)
61
+ }
62
+ return p
63
+ }
64
+ const out = new Map<string, Promise<readonly string[]>>()
65
+ for (const [server, ds] of held) {
66
+ const remote = ds.filter((d) => shortCircuit.remoteAsk.has(d))
67
+ out.set(
68
+ server,
69
+ new Promise((resolve) => {
70
+ const hits = new Map<string, CacheEntry>()
71
+ for (const d of remote)
72
+ void ask(d).then((hit) => {
73
+ if (hit === null) return resolve([])
74
+ hits.set(d, hit)
75
+ if (hits.size < remote.length) return
76
+ for (const [id, entry] of hits) {
77
+ shortCircuit.preProbed.set(id, { hash: shortCircuit.remoteAsk.get(id)!, hit: entry })
78
+ shortCircuit.restoreTier.add(id)
79
+ }
80
+ resolve(remote)
81
+ })
82
+ }),
83
+ )
84
+ }
85
+ return out
86
+ }
@@ -14,8 +14,10 @@ import {
14
14
  type CacheEntry,
15
15
  cleanOutputPaths,
16
16
  committedFiles,
17
- resolveOutputs,
18
- resolveWorkspaceOutputs,
17
+ OUTPUT_DIRS_OVER_CAP,
18
+ outputWalkTakes,
19
+ resolveOutputTree,
20
+ resolveWorkspaceOutputTree,
19
21
  sweepRestoreTemps,
20
22
  WORKSPACE_OUTPUT_PREFIX,
21
23
  } from '../cache/index.js'
@@ -27,11 +29,11 @@ import {
27
29
  span,
28
30
  splitNegations,
29
31
  taskGlob,
30
- wholeSubtreePrefixes,
32
+ outputDirPrefixes,
31
33
  } from '../util/index.js'
32
34
  import type { ExecuteArgs } from './execute-task.js'
33
35
  import { entryHolds } from './miss-save.js'
34
- import { decodeOutputLog } from './output-log.js'
36
+ import { decodeOutputLog, maskStreams } from './output-log.js'
35
37
 
36
38
  export interface RestoreHitArgs {
37
39
  args: ExecuteArgs
@@ -53,28 +55,33 @@ export interface RestoreHitArgs {
53
55
  * schedule using the IDENTICAL logic — there is one restore path, not
54
56
  * two that could drift.
55
57
  */
56
- export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome> {
57
- const { args, hash, hit, cacheOpStart, taskStartNs } = restore
58
- const { node, log } = args
58
+ export async function restoreHit(given: RestoreHitArgs): Promise<TaskOutcome> {
59
+ const { args, hash } = given
60
+ const { node } = args
59
61
  const cacheCfg: CacheConfig | undefined = node.config.cache
60
62
  const outputs = cacheCfg?.outputs.files ?? []
61
63
  const wsOutputs = cacheCfg?.outputs.workspaceFiles ?? []
62
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()
63
79
  // The directories the positive globs cover whole. A `!` entry inside one
64
80
  // leaves the snapshot sound: a file added or removed there still bumps a
65
81
  // recorded directory, and the walk that follows applies the `!` (A-46).
66
- const dirPrefixes = dirPrefixesOf(outputs)
67
- const cleanArgs = {
68
- projectDir: node.projectDir,
69
- outputs,
70
- nestedProjectDirs: args.nestedProjectDirs,
71
- committed: committedFiles(args.gitFilesCache, node.projectDir),
72
- }
73
- const wsCleanArgs = {
74
- workspaceRoot: args.workspaceRoot,
75
- outputs: wsOutputs,
76
- committed: committedFiles(args.gitFilesCache, args.workspaceRoot),
77
- }
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
78
85
  // The two sides of an overlapping pair (item 588). An ADDITIVE task owns
79
86
  // only its recorded rows: its glob also selects the upstream's files it
80
87
  // adds beside, so "no strays" is not a claim it can make, and its clean
@@ -83,9 +90,10 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
83
90
  // addition below is not a stray — while its glob clean still takes them:
84
91
  // the dependant restores or runs after it, by the edge.
85
92
  const additive = (node.addsToOutputsOf?.length ?? 0) > 0
86
- const addedGlobs = (node.outputsAddedToBy ?? [])
87
- .flatMap((g) => asTrees([g]))
88
- .map((g) => taskGlob(g))
93
+ const addedGlobs =
94
+ anyOutputs && node.outputsAddedToBy !== undefined
95
+ ? node.outputsAddedToBy.flatMap((g) => asTrees([g])).map((g) => taskGlob(g))
96
+ : []
89
97
  const isAddition = (rel: string): boolean => addedGlobs.some((g) => g.match(rel))
90
98
 
91
99
  // "Tree is already current" short-circuit — skip cleanOutputs
@@ -112,11 +120,9 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
112
120
  // The walk that found the tree stale, handed to the clean when nothing
113
121
  // was awaited between them: the clean's own walk would only repeat it
114
122
  // (X-161).
115
- let walked: { proj: string[]; ws: string[] } | undefined
123
+ let walked: { proj: Tree; ws: Tree } | undefined
116
124
  if (anyOutputs) {
117
- const endRows = span('output rows')
118
- const expected = hit.outputRows ?? args.cache.loadOutputFilesBatch([hash]).get(hash) ?? []
119
- endRows()
125
+ const expected = hit.outputRows ?? []
120
126
  if (expected.length > 0) {
121
127
  // Two namespaces in the rows: bare rels are project outputs,
122
128
  // `workspace-outputs/<rel>` rows anchor at the workspace root.
@@ -124,86 +130,92 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
124
130
  const wsExpected = expected
125
131
  .filter((e) => e.path.startsWith(WORKSPACE_OUTPUT_PREFIX))
126
132
  .map((e) => ({ ...e, path: e.path.slice(WORKSPACE_OUTPUT_PREFIX.length) }))
127
- // The directory short-circuit: for whole-subtree globs, unchanged
128
- // mtimes on every directory recorded at the last save/restore prove
129
- // the output SET is unchanged (a file added or removed anywhere the
130
- // glob could see bumps a recorded directory), so the walk that cost
131
- // 0.36 ms per warm hit is replaced by a few stats. The per-file
132
- // fingerprint check below still runs; only the enumeration is skipped.
133
- let setKnown = false
134
- if (dirPrefixes !== null && wsOutputs.length === 0 && args.cache.outputDirsCurrent) {
135
- const endDirs = span('output dirs')
136
- // Loaded with the entry (batched by getMany, or with the lazy get).
137
- const dirRows = hit.outputDirRows ?? []
138
- // Rows are recorded for these same prefixes under this same key
139
- // (the outputs are folded into it), so `covers` cannot be false
140
- // today — deleting it reddens nothing (item 638). Kept: a row
141
- // set that shrank would otherwise vouch for a tree it never saw,
142
- // and the failure it would let through is a skipped restore.
143
- const covers = dirPrefixes.every((pre) => dirRows.some((r) => r.path === pre))
144
- setKnown = covers && (await args.cache.outputDirsCurrent(node.projectDir, dirRows))
145
- endDirs()
146
- }
147
- let treeMatches: boolean
148
- if (setKnown) {
149
- // The snapshot proved the tree holds exactly the entry's project
150
- // rows (one per path, the table's key) and no workspace output is
151
- // declared, so the comparisons below hold by construction: mapping
152
- // each row to a path and back was ~5 ms per 1,000 warm hits.
153
- treeMatches = wsExpected.length === 0
154
- } else {
155
- const endGlob = span('output glob')
156
- const actualAbs = await resolveOutputs({
157
- projectDir: node.projectDir,
158
- outputs,
159
- nestedProjectDirs: args.nestedProjectDirs,
160
- })
161
- const actualWsAbs = await resolveWorkspaceOutputs({
162
- workspaceRoot: args.workspaceRoot,
163
- outputs: wsOutputs,
164
- })
165
- endGlob()
166
- const setsMatch = (
167
- actual: readonly string[],
168
- exp: ReadonlyArray<{ path: string }>,
169
- ): boolean => {
170
- const expSet = new Set(exp.map((e) => e.path).filter((p) => !p.endsWith('/')))
171
- return actual.length === expSet.size && actual.every((r) => expSet.has(r))
172
- }
173
- // A stray a dependant's glob could have added is not a stray; a path
174
- // this entry recorded is never dropped, even where that glob covers
175
- // the whole tree (strapi's shape: both on `dist`).
176
- const expectedRels = new Set(projExpected.map((e) => e.path))
177
- const expectedWsRels = new Set(wsExpected.map((e) => e.path))
178
- const actualRels = actualAbs
179
- .map((p) => relPosix(node.projectDir, p))
180
- .filter((rel) => expectedRels.has(rel) || !isAddition(rel))
181
- const actualWsRels = actualWsAbs
182
- .map((p) => relPosix(args.workspaceRoot, p))
183
- .filter((rel) => expectedWsRels.has(rel) || !isAddition(rel))
184
- const rowsPresent = (
185
- actual: readonly string[],
186
- exp: ReadonlyArray<{ path: string }>,
187
- ): boolean => {
188
- const have = new Set(actual)
189
- 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))
190
158
  }
191
- treeMatches = additive
192
- ? rowsPresent(actualRels, projExpected) && rowsPresent(actualWsRels, wsExpected)
193
- : setsMatch(actualRels, projExpected) && setsMatch(actualWsRels, wsExpected)
194
- 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))
195
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 }
196
195
  if (treeMatches) {
197
196
  const endStat = span('output stat')
198
197
  skipRestore =
199
- (await args.cache.isOutputsCurrent(node.projectDir, projExpected)) &&
200
- (await args.cache.isOutputsCurrent(args.workspaceRoot, wsExpected))
198
+ args.cache.isOutputsCurrent(node.projectDir, projExpected) &&
199
+ args.cache.isOutputsCurrent(args.workspaceRoot, wsExpected)
201
200
  endStat()
202
201
  // The walk ran and proved the tree current (rows absent, or a
203
202
  // directory had moved — a benign touch): snapshot the directories
204
203
  // so the next hit skips the walk.
205
- if (skipRestore && !setKnown && dirPrefixes !== null) {
206
- await args.cache.recordOutputDirs?.(hash, node.projectDir, dirPrefixes)
204
+ if (
205
+ skipRestore &&
206
+ dirPrefixes !== null &&
207
+ hit.outputDirRows?.some((r) => r.mtimeMs === OUTPUT_DIRS_OVER_CAP) !== true
208
+ ) {
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
+ )
207
219
  }
208
220
  }
209
221
  }
@@ -214,24 +226,37 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
214
226
  // of the tar is spared. A stray under the glob still falls through to
215
227
  // the clean, as strict ownership requires (item 589).
216
228
  if (anyOutputs && !skipRestore) {
217
- const expected = hit.outputRows ?? args.cache.loadOutputFilesBatch([hash]).get(hash) ?? []
229
+ const expected = hit.outputRows ?? []
218
230
  if (expected.length === 0) {
219
231
  const endGlob = span('output glob')
220
- const actual = await resolveOutputs({
232
+ const actual = await resolveOutputTree({
221
233
  projectDir: node.projectDir,
222
234
  outputs,
223
235
  nestedProjectDirs: args.nestedProjectDirs,
224
236
  })
225
- const actualWs = await resolveWorkspaceOutputs({
237
+ const actualWs = await resolveWorkspaceOutputTree({
226
238
  workspaceRoot: args.workspaceRoot,
227
239
  outputs: wsOutputs,
228
240
  })
229
241
  endGlob()
230
- 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
231
245
  walked = { proj: actual, ws: actualWs }
232
246
  }
233
247
  }
234
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
+ }
235
260
  const heldRels = hit.outputFiles.filter((p) => !p.startsWith(WORKSPACE_OUTPUT_PREFIX))
236
261
  const heldWsRels = hit.outputFiles
237
262
  .filter((p) => p.startsWith(WORKSPACE_OUTPUT_PREFIX))
@@ -248,34 +273,42 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
248
273
  let foundWsRels: string[] = []
249
274
  let staleRels: string[] = []
250
275
  let staleWsRels: string[] = []
276
+ let staleDirs: string[] = []
277
+ let staleWsDirs: string[] = []
251
278
  if (!additive) {
252
- sweepRestoreTemps(node.projectDir, outputs)
253
- sweepRestoreTemps(args.workspaceRoot, wsOutputs)
279
+ sweepRestoreTemps(node.projectDir, outputs, cleanArgs.committed)
280
+ sweepRestoreTemps(args.workspaceRoot, wsOutputs, wsCleanArgs.committed)
254
281
  if (outputs.length > 0) {
255
- const abs = walked?.proj ?? (await resolveOutputs(cleanArgs))
256
- 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))
257
284
  const blocking = inTheWay(foundRels, heldRels)
258
285
  staleRels = notHeld(foundRels, heldRels).filter((f) => !blocking.has(f))
259
- 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) {
260
289
  await cleanOutputPaths({
261
290
  projectDir: node.projectDir,
262
291
  rels: [...blocking],
263
292
  outputs,
264
293
  committed: cleanArgs.committed,
294
+ emptyDirs: dirs.blocking,
265
295
  })
266
296
  }
267
297
  }
268
298
  if (wsOutputs.length > 0) {
269
- const abs = walked?.ws ?? (await resolveWorkspaceOutputs(wsCleanArgs))
270
- 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))
271
301
  const blocking = inTheWay(foundWsRels, heldWsRels)
272
302
  staleWsRels = notHeld(foundWsRels, heldWsRels).filter((f) => !blocking.has(f))
273
- 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) {
274
306
  await cleanOutputPaths({
275
307
  projectDir: args.workspaceRoot,
276
308
  rels: [...blocking],
277
309
  outputs: wsOutputs,
278
310
  committed: wsCleanArgs.committed,
311
+ emptyDirs: dirs.blocking,
279
312
  })
280
313
  }
281
314
  }
@@ -284,10 +317,11 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
284
317
  files: outputs,
285
318
  workspaceFiles: wsOutputs,
286
319
  })
287
- if (staleRels.length > 0) {
320
+ if (staleRels.length > 0 || staleDirs.length > 0) {
288
321
  await cleanOutputPaths({
289
322
  projectDir: node.projectDir,
290
323
  rels: staleRels,
324
+ emptyDirs: staleDirs,
291
325
  outputs,
292
326
  committed: cleanArgs.committed,
293
327
  // A stale file in an output directory the entry holds empty takes
@@ -299,12 +333,18 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
299
333
  ),
300
334
  })
301
335
  }
302
- if (staleWsRels.length > 0) {
336
+ if (staleWsRels.length > 0 || staleWsDirs.length > 0) {
303
337
  await cleanOutputPaths({
304
338
  projectDir: args.workspaceRoot,
305
339
  rels: staleWsRels,
340
+ emptyDirs: staleWsDirs,
306
341
  outputs: wsOutputs,
307
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
+ ),
308
348
  })
309
349
  }
310
350
  // The tree now holds this entry's bytes: stamp them for the next hit
@@ -323,7 +363,7 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
323
363
  hash,
324
364
  projectDir: node.projectDir,
325
365
  prefixes: dirPrefixes,
326
- holds: entryHolds(node),
366
+ holds: entryHolds(node, outputWalkTakes(node.projectDir, outputs, args.nestedProjectDirs)),
327
367
  })
328
368
  }
329
369
  // Restored outputs changed the project's tree — but on this
@@ -344,6 +384,47 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
344
384
  ])
345
385
  }
346
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
347
428
  if (hit.stdout) {
348
429
  // An entry saved before masking, or by another machine, can hold a
349
430
  // secret-named variable's value (L-11). Asked only of a hit that prints.
@@ -352,10 +433,9 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
352
433
  node.config.exec?.env?.secret,
353
434
  )
354
435
  // The stored log holds both streams in the order the run printed them.
355
- for (const c of decodeOutputLog(hit.stdout)) {
356
- const text = secrets === null ? c.text : secrets.mask(c.text)
357
- if (c.err) log.taskStderr(node, text)
358
- 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)
359
439
  }
360
440
  }
361
441
  const status =
@@ -363,10 +443,10 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
363
443
  // `restored` distinguishes "we just wrote files to disk" from
364
444
  // "disk already matched the cached snapshot". Drives the
365
445
  // "up-to-date" vs "local-cache" / "remote-cache" label in the
366
- // framed block. Only meaningful when at least one output was
367
- // declared — no-outputs tasks never materialize anything, so
368
- // they're vacuously up-to-date.
369
- 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").
370
450
  return {
371
451
  node,
372
452
  status,
@@ -380,7 +460,7 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
380
460
  ...(hit.sizeBytes !== undefined ? { artifactBytes: hit.sizeBytes } : {}),
381
461
  ...(hit.fetchMs !== undefined ? { fetchMs: hit.fetchMs } : {}),
382
462
  hash,
383
- restored,
463
+ restored: restored || hit.source === 'remote',
384
464
  wallclockStartNs: taskStartNs,
385
465
  wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
386
466
  }
@@ -396,7 +476,7 @@ function dirPrefixesOf(outputs: readonly string[]): readonly string[] | null {
396
476
  const key = JSON.stringify(outputs)
397
477
  let prefixes = prefixMemo.get(key)
398
478
  if (prefixes === undefined) {
399
- prefixes = wholeSubtreePrefixes(splitNegations(outputs).positive)
479
+ prefixes = outputDirPrefixes(splitNegations(outputs).positive)
400
480
  prefixMemo.set(key, prefixes)
401
481
  }
402
482
  return prefixes
@@ -439,6 +519,31 @@ function inTheWay(found: readonly string[], held: readonly string[]): Set<string
439
519
  }
440
520
 
441
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
+
442
547
  function notHeld(found: readonly string[], held: readonly string[]): string[] {
443
548
  const keep = new Set(held.map(fold))
444
549
  return found.filter((f) => !keep.has(fold(f)))
@@ -2,14 +2,20 @@
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 { prepareRun, type PreparedRun } from './prepare.js'
5
+ export { CYCLE_INTERRUPT } from './run-stop.js'
6
+ export {
7
+ namesRemote,
8
+ openRemotes,
9
+ prepareRun,
10
+ workspaceCachePolicy,
11
+ type PreparedRun,
12
+ } from './prepare.js'
6
13
  export { affectedTaskProjects } from './affected-tasks.js'
7
14
  export { applyGraphStage } from './graph-stage.js'
8
15
  export type { AffectedReason } from './affected-tasks.js'
9
16
  export {
10
17
  discoverProjects,
11
18
  gitOfDiscovery,
12
- isDefaultBuild,
13
19
  keepDiscoveryGraph,
14
20
  loadProjects,
15
21
  loadResolvedProjects,
@@ -36,7 +42,16 @@ export { exitSignal, shellQuote } from '../exec/index.js'
36
42
  export { forwardedSignal } from './signals.js'
37
43
  // `vx cache clean` waits for a run on the workspace before it evicts.
38
44
  export { acquireRunLock } from './run-lock.js'
45
+ export { type LiveSnapshot, liveSocketPath } from './run-live.js'
39
46
  export type { RunPlan, PlannedTask, PlanPrediction, CacheStatus } from './plan.js'
47
+ export {
48
+ cacheFacts,
49
+ taskArtifacts,
50
+ type ArtifactRun,
51
+ type ArtifactTask,
52
+ type CacheFacts,
53
+ type TaskArtifacts,
54
+ } from './task-artifacts.js'
40
55
  export {
41
56
  installPlugins,
42
57
  type CacheContext,
@@ -131,6 +146,8 @@ export {
131
146
  type TaskLog,
132
147
  taskLog,
133
148
  } from './run-failures.js'
134
- export { type RunStep, runSteps } from './run-steps.js'
149
+ export { type RunStep, runSteps, runWarnings } from './run-steps.js'
135
150
  export { resolveRunId, shortRunId } from './run-id.js'
136
151
  export { collectInfo, type CollectInfoOptions, type InfoFacts } from './doctor.js'
152
+ export { localFloor, type LocalFloor } from './local-floor.js'
153
+ export { sandboxRequestFor, sweepPlaceholders, type SandboxRequest } from './sandbox-request.js'