@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
@@ -1,22 +1,29 @@
1
1
  // Core's local floor for a plugin that runs tasks itself: `@vzn/vx-dte`'s
2
- // orchestrator for its own share, and its agents, which run tasks another
3
- // machine placed. A run arms the sandbox runtime before it builds a
4
- // sandboxed request; an agent is no run, so it arms here. It starts a
5
- // request's servers (`ExecuteRequest.servers`) once each, before the
6
- // task, and stops them when it closes.
2
+ // workers, which run tasks another machine placed. A run arms the sandbox
3
+ // runtime before it builds a sandboxed request; a worker is no run, so it
4
+ // arms here. It starts a request's servers (`ExecuteRequest.servers`) once
5
+ // each, before the task, each inside its own sandbox, and stops them when
6
+ // it closes.
7
7
 
8
8
  import type { ExecConfig } from '../config.js'
9
9
  import {
10
10
  localExecutor,
11
+ PersistentReadyError,
12
+ releaseBridges,
11
13
  resetSandbox,
12
14
  runPersistent,
15
+ signalExitCode,
13
16
  type ExecuteRequest,
14
17
  type ExecuteResult,
15
18
  type ServerSpec,
19
+ wrapSandboxedCommand,
16
20
  } from '../exec/index.js'
17
- import { prepareSandbox } from './sandbox-request.js'
21
+ import { placeholderSweeper, prepareSandbox, sandboxRequestFor } from './sandbox-request.js'
18
22
  import { forwardedSignal, terminateChildren } from './signals.js'
19
23
 
24
+ /** How much of a server's output before ready a failure to start quotes. */
25
+ const HEARD_BYTES = 8192
26
+
20
27
  export interface LocalFloor {
21
28
  execute(req: ExecuteRequest): Promise<ExecuteResult>
22
29
  /** Arms the sandbox runtime for `sandboxes` from the next sandboxed task on; its servers keep running. */
@@ -43,20 +50,84 @@ export function localFloor(
43
50
  // Each server, by task id: shared by every task here that depends on it.
44
51
  const servers = new Map<string, Promise<ReturnType<typeof Bun.spawn>>>()
45
52
  const children: ReturnType<typeof Bun.spawn>[] = []
46
- const serve = (spec: ServerSpec): Promise<unknown> => {
47
- let up = servers.get(spec.taskId)
48
- if (up === undefined) {
49
- const spawn = runPersistent({
50
- command: spec.command,
53
+ // A sandboxed one starts inside its sandbox, built here as a one-shot
54
+ // task's; its port bridges and untouched placeholders go when it exits.
55
+ const start = async (spec: ServerSpec, root: string): Promise<ReturnType<typeof Bun.spawn>> => {
56
+ let command = spec.command
57
+ let signalChannel = false
58
+ let gone = async (): Promise<unknown> => undefined
59
+ if (spec.sandbox !== undefined) {
60
+ await armer?.arm()
61
+ const sb = await sandboxRequestFor(
62
+ { projectDir: spec.cwd },
63
+ spec.sandbox.config,
64
+ root,
65
+ undefined,
66
+ spec.sandbox.nested,
67
+ )
68
+ const swept = placeholderSweeper(sb.placeholders)
69
+ const wrapped = await wrapSandboxedCommand({
70
+ command,
51
71
  cwd: spec.cwd,
52
72
  env: spec.env,
53
- onStdout: () => {},
54
- onStderr: () => {},
55
- ...(spec.readyWhen === undefined ? {} : { readyWhen: spec.readyWhen }),
56
- ...(spec.timeoutMs === undefined ? {} : { timeoutMs: spec.timeoutMs }),
73
+ ...sb.sandbox,
74
+ server: true,
75
+ }).catch(async (err: unknown) => {
76
+ await swept()
77
+ throw err
57
78
  })
79
+ command = wrapped.wrapped
80
+ signalChannel = wrapped.forwardsSignals
81
+ gone = async () => {
82
+ if (wrapped.tag !== undefined) releaseBridges(wrapped.tag)
83
+ return swept()
84
+ }
85
+ }
86
+ // Its output until ready, the tail kept: a server that dies first says
87
+ // why (`docker run` exiting 125 said nothing on CI, 2026-10-26).
88
+ let heard: string | undefined = ''
89
+ const hear = (t: string): void => {
90
+ if (heard !== undefined) heard = (heard + t).slice(-HEARD_BYTES)
91
+ }
92
+ const spawn = runPersistent({
93
+ command,
94
+ cwd: spec.cwd,
95
+ env: spec.env,
96
+ onStdout: hear,
97
+ onStderr: hear,
98
+ ...(spec.readyWhen === undefined ? {} : { readyWhen: spec.readyWhen }),
99
+ ...(spec.timeoutMs === undefined ? {} : { timeoutMs: spec.timeoutMs }),
100
+ ...(signalChannel ? { signalChannel } : {}),
101
+ })
102
+ // A spawn that threw hands back no child, only a rejected `ready`.
103
+ if (spawn.child === undefined) void gone()
104
+ else {
58
105
  children.push(spawn.child)
59
- up = spawn.ready.then(() => spawn.child)
106
+ void spawn.child.exited.then(gone, gone)
107
+ }
108
+ try {
109
+ await spawn.ready
110
+ } catch (err) {
111
+ // A shell that exited before ready can leave its group running
112
+ // (`server & …; exit`), holding the port of the one the next task starts.
113
+ if (
114
+ err instanceof PersistentReadyError &&
115
+ err.reason === 'exited' &&
116
+ spawn.child !== undefined
117
+ )
118
+ await terminateChildren(() => [spawn.child!])
119
+ const why = err instanceof Error ? err.message : String(err)
120
+ const said = (heard ?? '').trimEnd()
121
+ throw new Error(said === '' ? why : `${why}; its output:\n${said}`)
122
+ }
123
+ heard = undefined
124
+ // Ready, so it spawned: only a failed spawn has no child.
125
+ return spawn.child!
126
+ }
127
+ const serve = (spec: ServerSpec, root: string): Promise<ReturnType<typeof Bun.spawn>> => {
128
+ let up = servers.get(spec.taskId)
129
+ if (up === undefined) {
130
+ up = start(spec, root)
60
131
  servers.set(spec.taskId, up)
61
132
  // One that never became ready is asked again by the next task.
62
133
  up.catch(() => servers.delete(spec.taskId))
@@ -65,24 +136,47 @@ export function localFloor(
65
136
  }
66
137
  return {
67
138
  async execute(req) {
139
+ // A stop while a server is not yet ready ends the wait: one that never
140
+ // prints its `readyWhen` line held the task, and a worker's shutdown
141
+ // waits on its tasks before `close()` stops the servers. The aborted
142
+ // request then reads as one stopped before its spawn.
143
+ const signal = req.signal
144
+ const stopped =
145
+ signal === undefined
146
+ ? undefined
147
+ : new Promise<void>((resolve) => {
148
+ if (signal.aborted) resolve()
149
+ else signal.addEventListener('abort', () => resolve(), { once: true })
150
+ })
68
151
  for (const spec of req.servers ?? []) {
152
+ if (signal?.aborted === true) break
69
153
  try {
70
- await serve(spec)
154
+ const up = serve(spec, req.workspaceRoot)
155
+ const child = await (stopped === undefined ? up : Promise.race([up, stopped]))
156
+ // One that crashed after it was ready serves nobody: the task ran
157
+ // against nothing and failed on its own "connection refused". A
158
+ // clean exit is a setup step that did its work (runPersistent).
159
+ const died = child === undefined ? null : (child.exitCode ?? child.signalCode)
160
+ if (died !== null && died !== 0) {
161
+ const code = typeof died === 'number' ? died : signalExitCode(died)
162
+ const text = `${spec.taskId}, which ${req.taskId} depends on, exited with code ${code} after it was ready\n`
163
+ req.onStderr(text)
164
+ return { exitCode: 1, durationMs: 0, stdout: '', stderr: text, violations: [] }
165
+ }
71
166
  } catch (err) {
72
167
  const why = err instanceof Error ? err.message : String(err)
73
- return {
74
- exitCode: 1,
75
- durationMs: 0,
76
- stdout: '',
77
- stderr: `${spec.taskId}, which ${req.taskId} depends on, did not start: ${why}\n`,
78
- violations: [],
79
- }
168
+ const text = `${spec.taskId}, which ${req.taskId} depends on, did not start: ${why}\n`
169
+ // Through `onStderr` too, as a spawn failure's text goes: a worker
170
+ // ships only what its callbacks heard, and a dependant failed
171
+ // "exit 1, 0ms" with no word of why (CI, 2026-10-25).
172
+ req.onStderr(text)
173
+ return { exitCode: 1, durationMs: 0, stdout: '', stderr: text, violations: [] }
80
174
  }
81
175
  }
82
176
  if (req.sandbox !== undefined) await armer?.arm()
83
177
  const stop = req.signal
84
178
  if (stop === undefined || req.liveChildren !== undefined) return local.execute(req)
85
- // No run holds this task's children (an agent's): its stop is ours to carry out.
179
+ // No run holds this task's children (a worker's): its stop is ours to carry out.
86
180
  const live = new Set<ReturnType<typeof Bun.spawn>>()
87
181
  const end = (): void => void terminateChildren(() => [...live], forwardedSignal(stop.reason))
88
182
  stop.addEventListener('abort', end, { once: true })
@@ -44,8 +44,8 @@
44
44
  import { normalizeGlob, relPosix, span, staticPrefix } from '../util/index.js'
45
45
  import type { CacheEntry, CacheLayer, GitFilesCache } from '../cache/index.js'
46
46
  import type { TaskNode } from '../graph/index.js'
47
- import { deriveStableKeys, workspaceInputsReach } from './stable-keys.js'
48
- import { readRunKeys, runKeysToken, writeRunKeys } from './run-keys.js'
47
+ import { deriveStableKeys, rederiveKeys, workspaceInputsReach } from './stable-keys.js'
48
+ import { readRunDelta, readRunKeys, runKeysToken, writeRunDelta, writeRunKeys } from './run-keys.js'
49
49
  import type { HashCache, UpfrontGroupKey } from './task-hash.js'
50
50
  import { getContext } from './remote-prefetch.js'
51
51
 
@@ -126,8 +126,32 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
126
126
  stableKeys = remembered.stableKeys
127
127
  for (const [id, key] of remembered.uncachedKeys) uncachedKeys.set(id, key)
128
128
  } else {
129
- stableKeys = await deriveStableKeys({ ...args, uncachedKeys, groupKeys })
130
- if (at !== undefined) writeRunKeys(args, at, { stableKeys, uncachedKeys })
129
+ // An edit moved some files tokens: the last derivation under the same
130
+ // graph keys again only the tasks the edit reaches.
131
+ const delta = at === undefined ? undefined : readRunDelta(args, at)
132
+ let derivation
133
+ if (delta !== undefined) {
134
+ derivation = delta.derivation
135
+ await rederiveKeys(args, derivation.keys, delta.dirty)
136
+ const keys = derivation.keys
137
+ stableKeys = derivation.stable.map((id) => ({
138
+ hash: keys.get(id)!,
139
+ node: args.nodes.get(id)!,
140
+ }))
141
+ for (const id of derivation.uncached) uncachedKeys.set(id, keys.get(id)!)
142
+ } else {
143
+ const keys = new Map<string, string>()
144
+ stableKeys = await deriveStableKeys({ ...args, uncachedKeys, groupKeys, keys })
145
+ derivation = {
146
+ keys,
147
+ stable: stableKeys.map((k) => k.node.id),
148
+ uncached: [...uncachedKeys.keys()],
149
+ }
150
+ }
151
+ if (at !== undefined) {
152
+ writeRunKeys(args, at, { stableKeys, uncachedKeys })
153
+ writeRunDelta(args, at, derivation)
154
+ }
131
155
  }
132
156
  } catch {
133
157
  return {
@@ -15,7 +15,7 @@
15
15
 
16
16
  import path from 'node:path'
17
17
  import { rename, stat } from 'node:fs/promises'
18
- import { relPosix, xxh3, xxh3hex } from '../util/index.js'
18
+ import { relPosix, UserError, xxh3, xxh3hex } from '../util/index.js'
19
19
  import type { TaskNode } from '../graph/index.js'
20
20
  import type {
21
21
  FingerprintChange,
@@ -37,7 +37,7 @@ export interface LockfileClaimOptions {
37
37
  /**
38
38
  * Root-relative files the lockfile names whose BYTES the install uses but
39
39
  * the lockfile does not pin (bun.lock's `patchedDependencies` names a patch
40
- * file and records no hash of it). Their content hashes ('' when absent)
40
+ * file and records no hash of it). Their content hashes ('' when absent; unreadable refuses)
41
41
  * reach `digest` as `files` and join the memo's identity, so an edited
42
42
  * patch moves the key (item 1014). A directory (a `file:` dependency the
43
43
  * install copies) hashes every file under it but `node_modules` and
@@ -108,18 +108,30 @@ interface Memo {
108
108
  edges?: Record<string, LockfileImporter>
109
109
  }
110
110
 
111
- /** A root-relative extra file's content hash, '' when absent; a path leaving the root is never read. */
111
+ /**
112
+ * A root-relative extra file's content hash, '' when absent; a path leaving
113
+ * the root is never read. One there but unreadable (EACCES, a symlink loop)
114
+ * refuses, as a settings file does: folded as '', it keyed as no file at all.
115
+ */
112
116
  async function hashExtra(workspaceRoot: string, rel: string): Promise<string> {
113
117
  if (path.isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) return 'outside-root'
114
118
  const abs = path.join(workspaceRoot, rel)
115
119
  try {
116
120
  return xxh3hex(await Bun.file(abs).bytes())
117
121
  } catch (err) {
118
- if ((err as NodeJS.ErrnoException).code === 'EISDIR') return hashDir(abs)
119
- return ''
122
+ const code = (err as NodeJS.ErrnoException).code
123
+ if (code === 'EISDIR') return hashDir(abs)
124
+ if (code === 'ENOENT' || code === 'ENOTDIR') return ''
125
+ throw unreadable(rel, err)
120
126
  }
121
127
  }
122
128
 
129
+ function unreadable(rel: string, err: unknown): UserError {
130
+ return new UserError(
131
+ `${rel}: cannot be read (${err instanceof Error ? err.message : String(err)})`,
132
+ )
133
+ }
134
+
123
135
  /**
124
136
  * Every file under `dir` but its `node_modules` and `.git`, as `rel\0hash`
125
137
  * lines in sorted order. Links are followed: a linked file is what a task
@@ -322,7 +334,18 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
322
334
  return names
323
335
  },
324
336
  ...(options.extraFiles !== undefined
325
- ? { extraFiles: (_file: string, bytes: Uint8Array) => options.extraFiles!(decode(bytes)) }
337
+ ? {
338
+ // The memo records which files these exact bytes name, for this
339
+ // parser: `--affected` asks on every diff, and a cold parse of
340
+ // a large lockfile was most of its selection's cost.
341
+ async extraFiles(_file: string, bytes: Uint8Array, ctx: FingerprintContext) {
342
+ const memoFile = path.join(ctx.cacheDir, 'lockfile-claims', `${file}.json`)
343
+ const memo = await readMemo(memoFile, version, claimant, xxh3hex(bytes))
344
+ return memo !== undefined
345
+ ? Object.keys(memo.extras)
346
+ : options.extraFiles!(decode(bytes))
347
+ },
348
+ }
326
349
  : {}),
327
350
  },
328
351
  async key(task, ctx) {
@@ -347,15 +370,22 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
347
370
  * Node's resolution walking up to the root `node_modules` (`@types/*`,
348
371
  * a config's plugins) — so a root devDependency bump that moved only the
349
372
  * root's digest replayed the old tool's output (nx#36415 class,
350
- * 2026-09-24). A project the lockfile has no importer for (outside the
351
- * workspace's `packages`) folds the root's alone; a file with neither
352
- * folds a constant.
373
+ * 2026-09-24). A project the lockfile has no importer for folds its
374
+ * nearest listed ancestor's, as Node resolves from that ancestor's
375
+ * `node_modules` (`packages/a/e2e` from `packages/a`'s): the root's alone
376
+ * keyed it on no bump only `a` reached. Outside every importer it folds
377
+ * the root's alone; a file with neither folds a constant.
353
378
  */
354
379
  function digestFor(importers: ReadonlyMap<string, string>, importer: string): string {
355
- const own = importers.get(importer)
380
+ let dir = importer
381
+ while (dir !== '.' && !importers.has(dir)) {
382
+ const slash = dir.lastIndexOf('/')
383
+ dir = slash === -1 ? '.' : dir.slice(0, slash)
384
+ }
385
+ const own = importers.get(dir)
356
386
  const root = importers.get('.')
357
387
  if (own === undefined) return root ?? 'no-importer'
358
- if (root === undefined || importer === '.') return own
388
+ if (root === undefined || dir === '.') return own
359
389
  return xxh3hex(`${own}\0${root}`)
360
390
  }
361
391
 
@@ -423,6 +453,49 @@ function decode(bytes: Uint8Array): string {
423
453
 
424
454
  // --- Reach digests -----------------------------------------------------------
425
455
 
456
+ /**
457
+ * Each member of a cycle as what it is and where its edges land, by
458
+ * colour refinement: a member starts as its material and the child
459
+ * digests its edges leave the cycle for, then folds its in-cycle
460
+ * targets' labels until no two labels part further. npm and bun key two
461
+ * copies of one version alike (`a/node_modules/foo` → bar@1,
462
+ * `node_modules/foo` → bar@2), so by material alone two members swapping
463
+ * copies, in the cycle or out of it, moved nothing. Two members left with
464
+ * one label have the same material, reach the same children, and their
465
+ * in-cycle edges land on labels alike to every depth. Telling such a pair
466
+ * apart takes an isomorphism test; it needs a cycle where copies of one
467
+ * version reach alike at every step, which no swap of what one reaches
468
+ * produces.
469
+ */
470
+ function cycleLabels(
471
+ g: ReachGraph,
472
+ members: readonly number[],
473
+ childOf: (w: number) => string | undefined,
474
+ ): string[] {
475
+ let label = new Map<number, string>()
476
+ for (const m of members) {
477
+ const out = new Set<string>()
478
+ for (const w of g.edges[m]!) {
479
+ const child = w === m ? undefined : childOf(w)
480
+ if (child !== undefined) out.add(child)
481
+ }
482
+ label.set(m, xxh3hex(`${g.material[m]!}\0${[...out].sort().join('\0')}`))
483
+ }
484
+ let parts = new Set(label.values()).size
485
+ for (;;) {
486
+ const next = new Map<number, string>()
487
+ for (const m of members) {
488
+ const inner = new Set<string>()
489
+ for (const w of g.edges[m]!) if (w !== m && label.has(w)) inner.add(label.get(w)!)
490
+ next.set(m, xxh3hex(`${label.get(m)!}\0${[...inner].sort().join('\0')}`))
491
+ }
492
+ label = next
493
+ const now = new Set(label.values()).size
494
+ if (now === parts) return members.map((m) => label.get(m)!)
495
+ parts = now
496
+ }
497
+ }
498
+
426
499
  /** A dependency graph: one material string per node and its out-edges by index. */
427
500
  export interface ReachGraph {
428
501
  readonly material: readonly string[]
@@ -493,11 +566,18 @@ export function reachDigests(g: ReachGraph): string[] {
493
566
  // share one digest, so retargeting an edge between two of them (an
494
567
  // importer's `y@1.0.0` → `y@1.1.0` inside one cycle) moved nothing
495
568
  // and replayed a stale output (item 1013). A self-loop reaches
496
- // nothing new and is left out.
497
- const lines = members.map((m) => {
498
- const targets = [...new Set(g.edges[m]!.filter((w) => w !== m).map((w) => g.material[w]!))]
499
- return `${g.material[m]!}\0${targets.sort().join('\0')}`
500
- })
569
+ // nothing new and is left out. A singleton's children already say
570
+ // where its edges land, and its line stays as it was, so an acyclic
571
+ // digest keeps its value.
572
+ const lines =
573
+ members.length === 1
574
+ ? members.map((m) => {
575
+ const targets = [
576
+ ...new Set(g.edges[m]!.filter((w) => w !== m).map((w) => g.material[w]!)),
577
+ ]
578
+ return `${g.material[m]!}\0${targets.sort().join('\0')}`
579
+ })
580
+ : cycleLabels(g, members, (w) => (comp[w] === id ? undefined : compHash[comp[w]!]!))
501
581
  let h = xxh3(`members:${members.length}`)
502
582
  for (const line of lines.sort()) h = xxh3(`${line}\n`, h)
503
583
  h = xxh3(`children:${children.size}`, h)
@@ -7,7 +7,7 @@
7
7
  // a later run replays. Moved out of execute-task.ts 2026-09-09 as pure
8
8
  // code motion.
9
9
 
10
- import { lstatSync, realpathSync, statSync } from 'node:fs'
10
+ import { existsSync, lstatSync, realpathSync, statSync } from 'node:fs'
11
11
  import path from 'node:path'
12
12
  import {
13
13
  type CacheLayer,
@@ -16,6 +16,7 @@ import {
16
16
  outputWalkTakes,
17
17
  resolveOutputs,
18
18
  resolveWorkspaceOutputs,
19
+ resolveWorkspaceOutputTree,
19
20
  WORKSPACE_OUTPUT_PREFIX,
20
21
  } from '../cache/index.js'
21
22
  import type { TaskNode } from '../graph/index.js'
@@ -166,12 +167,11 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
166
167
  nestedProjectDirs: a.nestedProjectDirs,
167
168
  })
168
169
  const outputFiles = tree.files
169
- const wsOutputFiles =
170
- a.ownWsOutputFiles ??
171
- (await resolveWorkspaceOutputs({
172
- workspaceRoot: a.workspaceRoot,
173
- outputs: a.wsOutputs,
174
- }))
170
+ const wsTree =
171
+ a.ownWsOutputFiles !== undefined
172
+ ? { files: a.ownWsOutputFiles, emptyDirs: [] }
173
+ : await resolveWorkspaceOutputTree({ workspaceRoot: a.workspaceRoot, outputs: a.wsOutputs })
174
+ const wsOutputFiles = wsTree.files
175
175
  endResolve()
176
176
  // The mirror of the input warning: declared outputs that resolve to
177
177
  // nothing save an empty artifact, and the next hit "restores" it —
@@ -202,7 +202,11 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
202
202
  ? ` — ${linkedOut.dir} is a symlink to ${linkedOut.target}, outside ${linkedOut.base}, and vx keeps only outputs inside it: make ${linkedOut.dir} a directory`
203
203
  : sandboxed && !grantsWrite
204
204
  ? ` — the task is sandboxed and declares no exec.sandbox.allow.write, so its writes never reached disk`
205
- : ''),
205
+ : '') +
206
+ bracketLiteralHint([
207
+ ...a.outputs.map((g) => [node.projectDir, g] as const),
208
+ ...a.wsOutputs.map((g) => [a.workspaceRoot, g] as const),
209
+ ]),
206
210
  )
207
211
  }
208
212
  const facts: SaveFacts = {}
@@ -220,6 +224,7 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
220
224
  // The root an output symlink's target must stay inside, outputs.workspaceFiles or not.
221
225
  workspaceRoot: a.workspaceRoot,
222
226
  ...(wsOutputFiles.length > 0 ? { workspaceOutputFiles: wsOutputFiles } : {}),
227
+ ...(wsTree.emptyDirs.length > 0 ? { workspaceOutputDirs: wsTree.emptyDirs } : {}),
223
228
  inputComponents: a.captured.map((c) => ({ entryHash: a.hash, ...c })),
224
229
  // No `exitCode`: the save only runs under `effectiveExitCode === 0`, and
225
230
  // the contract no longer accepts one — so the invariant is enforced by
@@ -386,3 +391,32 @@ function realpathOrNull(p: string): string | null {
386
391
  return null
387
392
  }
388
393
  }
394
+
395
+ /**
396
+ * The clause a "matched no files" warning gains when a glob holds an
397
+ * unescaped `[` and, read as a literal path, names something on disk:
398
+ * `app/[slug]/page.tsx` is a character class (caching.md) that matches
399
+ * no route, so the key stops following the file and an edit replays the
400
+ * old output. Only on the already-empty path; one stat per such glob, on
401
+ * its literal prefix up to the first segment holding another glob token.
402
+ */
403
+ export function bracketLiteralHint(
404
+ globs: readonly (readonly [base: string, glob: string])[],
405
+ ): string {
406
+ let hint = ''
407
+ for (const [base, glob] of globs) {
408
+ if (glob.startsWith('!') || !/(^|[^\\])\[/.test(glob)) continue
409
+ const segments: string[] = []
410
+ for (const seg of glob.split('/')) {
411
+ if (/(^|[^\\])[*?{}]/.test(seg)) break
412
+ segments.push(seg)
413
+ }
414
+ const prefix = segments.join('/')
415
+ if (!/(^|[^\\])\[/.test(prefix)) continue
416
+ const literal = prefix.replace(/\\(.)/g, '$1')
417
+ if (!existsSync(path.join(base, literal))) continue
418
+ const escaped = glob.replace(/(^|[^\\])(?=[[\]])/g, '$1\\')
419
+ hint += ` — [ starts a character class and ${literal} exists: write ${escaped} to name it`
420
+ }
421
+ return hint
422
+ }
@@ -84,12 +84,12 @@ export interface RunOptions {
84
84
  */
85
85
  cacheFlags?: CacheFlags
86
86
  /**
87
- * The caller asked for a remote axis by name (`--cache-policy=remote:rw`). With
88
- * no cache plugin supplying a remote layer the axes are inert, and a CI
89
- * job that believes it is filling a shared cache deserves one line
90
- * saying it is not.
87
+ * The setting that asked for a remote axis by name (`'--cache-policy'` for
88
+ * `--cache-policy=remote:rw`), named in the warning. With no cache plugin
89
+ * supplying a remote layer the axes are inert, and a CI job that believes
90
+ * it is filling a shared cache deserves one line saying it is not.
91
91
  */
92
- remoteRequested?: boolean
92
+ remoteRequested?: string
93
93
  /** This run's cache scope, above `VX_CACHE_SCOPE`, a plugin's and the branch's (an embedder's). */
94
94
  cacheScope?: string
95
95
  /**
@@ -205,7 +205,9 @@ export interface RunOptions {
205
205
  * never-started ones `aborted`. The seam for an embedder that owns its
206
206
  * process's lifetime instead of `handleSignals` — the watch loop aborts
207
207
  * its in-flight cycle on SIGINT/SIGTERM this way, so a cancellation
208
- * never orphans a cycle's children.
208
+ * never orphans a cycle's children. A reason naming a signal
209
+ * (`'SIGINT'`, `'SIGTERM'`, `'SIGHUP'`) is a stop as vx's own handler
210
+ * makes one: the run records no history, only what finished.
209
211
  */
210
212
  signal?: AbortSignal
211
213
  /**
@@ -4,10 +4,12 @@
4
4
 
5
5
  import { terminalSubscriber, type RunEventSubscriber } from '../report/index.js'
6
6
  import type { Logger } from '../report/index.js'
7
+ import { settleWithin, teardownTimeoutMs, thrownMessage } from '../util/index.js'
7
8
  import type { OutputContext, VxPlugin } from './plugin.js'
8
9
 
9
10
  const REQUIRED = ['status', 'taskStdout', 'taskStderr', 'taskComplete'] as const
10
11
  const OPTIONAL = [
12
+ 'warn',
11
13
  'runStart',
12
14
  'taskStart',
13
15
  'runEnd',
@@ -26,14 +28,17 @@ function checkLogger(value: unknown): Logger {
26
28
  const missing = REQUIRED.filter((k) => typeof o[k] !== 'function')
27
29
  if (missing.length > 0) throw new Error(`output Logger has no ${missing.join(', ')}`)
28
30
  const wrong = OPTIONAL.filter((k) => o[k] !== undefined && typeof o[k] !== 'function')
29
- if (wrong.length > 0) throw new Error(`output Logger's ${wrong.join(', ')} must be functions`)
31
+ if (wrong.length > 0)
32
+ throw new Error(
33
+ `output Logger's ${wrong.join(', ')} must be ${wrong.length === 1 ? 'a function' : 'functions'}`,
34
+ )
30
35
  return value as Logger
31
36
  }
32
37
 
33
38
  /**
34
39
  * The first plugin, in declaration order, whose `output` returns a Logger.
35
- * A hook that throws or returns something else is warned about and passed
36
- * over: a renderer must never fail a run.
40
+ * A hook that throws, returns something else or does not answer in time
41
+ * is warned about and passed over: a renderer must never fail a run.
37
42
  */
38
43
  export async function claimOutput(
39
44
  plugins: readonly VxPlugin[],
@@ -42,12 +47,20 @@ export async function claimOutput(
42
47
  for (const plugin of plugins) {
43
48
  if (plugin.output === undefined) continue
44
49
  try {
45
- const logger = await plugin.output(ctx)
50
+ // Bounded as telemetry's hook is: one that never settled held the
51
+ // run before its first task until the loop drained, exit 1 (item 921).
52
+ let logger: Logger | undefined
53
+ const ms = teardownTimeoutMs()
54
+ const arrived = await settleWithin(
55
+ Promise.resolve(plugin.output(ctx)).then((l) => {
56
+ logger = l
57
+ }),
58
+ ms,
59
+ )
60
+ if (!arrived) throw new Error(`did not answer within ${ms} ms`)
46
61
  if (logger !== undefined) return { logger: checkLogger(logger), owner: plugin.name }
47
62
  } catch (err) {
48
- ctx.warn(
49
- `[vx] plugin '${plugin.name}' output: ${err instanceof Error ? err.message : String(err)}; vx renders the run`,
50
- )
63
+ ctx.warn(`[vx] plugin '${plugin.name}' output: ${thrownMessage(err)}; vx renders the run`)
51
64
  }
52
65
  }
53
66
  return undefined
@@ -62,19 +75,35 @@ export function guardedOutput(
62
75
  claimed: { logger: Logger; owner: string },
63
76
  fallback: RunEventSubscriber,
64
77
  ): RunEventSubscriber {
65
- const render = terminalSubscriber(claimed.logger)
66
78
  let failed = false
79
+ const fail = (err: unknown): void => {
80
+ if (failed) return
81
+ failed = true
82
+ fallback({
83
+ kind: 'run:status',
84
+ warn: true,
85
+ line: `[vx] plugin '${claimed.owner}' output failed: ${thrownMessage(err)}; vx renders the rest of the run`,
86
+ })
87
+ }
88
+ // An `async` method's rejection reached no one: an unhandled rejection
89
+ // ended the run with a stack, exit 1, after its tasks had passed. It
90
+ // fails the Logger as a throw does; the event it was handed stays its.
91
+ const logger = Object.create(claimed.logger) as Logger
92
+ for (const k of [...REQUIRED, ...OPTIONAL, 'warn'] as const) {
93
+ const method = claimed.logger[k] as ((...args: unknown[]) => unknown) | undefined
94
+ if (typeof method !== 'function') continue
95
+ ;(logger as unknown as Record<string, unknown>)[k] = (...args: unknown[]): void => {
96
+ const ret = method.apply(claimed.logger, args)
97
+ if (ret instanceof Promise) ret.catch(fail)
98
+ }
99
+ }
100
+ const render = terminalSubscriber(logger)
67
101
  return (event) => {
68
102
  if (failed) return fallback(event)
69
103
  try {
70
104
  render(event)
71
105
  } catch (err) {
72
- failed = true
73
- fallback({
74
- kind: 'run:status',
75
- warn: true,
76
- line: `[vx] plugin '${claimed.owner}' output failed: ${err instanceof Error ? err.message : String(err)}; vx renders the rest of the run`,
77
- })
106
+ fail(err)
78
107
  fallback(event)
79
108
  }
80
109
  }