@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,206 @@
1
+ // Task entries by key for an executor that runs a task on another machine
2
+ // (`ExecutorContext.artifacts`, `CommandContext.artifacts`): the machine
3
+ // that ran it saves its outputs and output under the key the order named,
4
+ // and the one that needs them restores them, through the workspace's own
5
+ // cache (design packages/vx/docs/design/dte-2026-10.md, "Files").
6
+
7
+ import {
8
+ type CacheLayer,
9
+ type ResolvedRemote,
10
+ resolveOutputTree,
11
+ resolveWorkspaceOutputTree,
12
+ sayRemoteFault,
13
+ } from '../cache/index.js'
14
+ import type { CapturedChunk } from '../exec/index.js'
15
+ import { secretMask, thrownMessage } from '../util/index.js'
16
+ import { decodeOutputLog, encodeOutputLog, maskedOutputLog } from './output-log.js'
17
+
18
+ /** The task a `TaskArtifacts` call names: where its outputs are and what ran. */
19
+ export interface ArtifactTask {
20
+ /** `project#task`. */
21
+ readonly id: string
22
+ /** Absolute. */
23
+ readonly projectDir: string
24
+ readonly command: string
25
+ /** Its `cache.outputs`: project-relative `files`, root-relative `workspaceFiles`. */
26
+ readonly outputs: {
27
+ readonly files: readonly string[]
28
+ readonly workspaceFiles: readonly string[]
29
+ }
30
+ /** The projects inside `projectDir` (absolute), whose files are not its outputs. */
31
+ readonly nestedProjectDirs?: readonly string[]
32
+ /**
33
+ * Files under its outputs it found and left as they were (absolute): an
34
+ * upstream's, which an additive task builds on, not its entry's.
35
+ */
36
+ readonly others?: ReadonlySet<string>
37
+ }
38
+
39
+ /** What a task's run left besides its files: its output, and its time and usage. */
40
+ export interface ArtifactRun {
41
+ /** Both streams in the order they were written, as a `BoundedCapture` keeps them. */
42
+ readonly output: readonly CapturedChunk[]
43
+ readonly durationMs: number
44
+ readonly cpuMs?: number
45
+ readonly peakRssBytes?: number
46
+ /** Its environment: a secret value in it is masked out of the stored output. */
47
+ readonly env?: Readonly<Record<string, string | undefined>>
48
+ /** Its `exec.env.secret`: names in `env` whose values are masked whatever the name. */
49
+ readonly secret?: readonly string[]
50
+ /** No remote held the key when the run asked: its upload asks none first. */
51
+ readonly absent?: boolean
52
+ }
53
+
54
+ export interface TaskArtifacts {
55
+ /**
56
+ * Keeps the files `task`'s outputs match, and its output, as entry
57
+ * `key`: here, and on each remote the workspace writes. Resolves once
58
+ * every upload has settled; a remote that fails degrades as a run's
59
+ * upload does.
60
+ */
61
+ save(key: string, task: ArtifactTask, run: ArtifactRun): Promise<void>
62
+ /**
63
+ * Lays entry `key`'s files into the workspace, from the local store or
64
+ * else the first remote holding it, and resolves its output, both streams
65
+ * in the order written; null when
66
+ * none holds it. A key restored here and then saved is not uploaded
67
+ * again.
68
+ */
69
+ restore(key: string, task: ArtifactTask): Promise<CapturedChunk[] | null>
70
+ /**
71
+ * Entry `key`'s output alone, laying no file; null when no cache holds
72
+ * it. An `exec.remote: 'only'` task's files stay off this disk.
73
+ */
74
+ output(key: string): Promise<CapturedChunk[] | null>
75
+ /**
76
+ * True once a remote the run reads holds entry `key`, this machine's
77
+ * entry uploaded to those it writes when none does: another machine of
78
+ * the run reads the remotes alone. False when none holds it and none took it.
79
+ */
80
+ share(key: string): Promise<boolean>
81
+ /**
82
+ * This machine's entry `key` uploaded over each written remote's copy,
83
+ * which another machine of the run found corrupt. True when one took it.
84
+ */
85
+ resend(key: string): Promise<boolean>
86
+ /**
87
+ * Whether a remote the workspace writes took entry `key` when `save`
88
+ * uploaded it; undefined when it writes none. Another machine of the
89
+ * run reads the remotes alone, so false there means only this one has it.
90
+ */
91
+ uploaded?(key: string): Promise<boolean | undefined>
92
+ }
93
+
94
+ export function taskArtifacts(cache: CacheLayer, workspaceRoot: string): TaskArtifacts {
95
+ return {
96
+ async save(key, task, run) {
97
+ const [tree, wsTree] = await Promise.all([
98
+ resolveOutputTree({
99
+ projectDir: task.projectDir,
100
+ outputs: task.outputs.files,
101
+ nestedProjectDirs: [...(task.nestedProjectDirs ?? [])],
102
+ }),
103
+ resolveWorkspaceOutputTree({ workspaceRoot, outputs: task.outputs.workspaceFiles }),
104
+ ])
105
+ const mine = (f: string) => task.others?.has(f) !== true
106
+ const files = tree.files.filter(mine)
107
+ const ws = wsTree.files.filter(mine)
108
+ const mask = secretMask([process.env, run.env ?? {}], run.secret)
109
+ const log = mask === null ? encodeOutputLog(run.output) : maskedOutputLog(run.output, mask)
110
+ await cache.save({
111
+ hash: key,
112
+ projectDir: task.projectDir,
113
+ outputFiles: files,
114
+ ...(tree.emptyDirs.length > 0 ? { outputDirs: tree.emptyDirs } : {}),
115
+ workspaceRoot,
116
+ ...(ws.length > 0 ? { workspaceOutputFiles: ws } : {}),
117
+ ...(wsTree.emptyDirs.length > 0 ? { workspaceOutputDirs: wsTree.emptyDirs } : {}),
118
+ ...(run.absent === true ? { absent: true } : {}),
119
+ entry: {
120
+ taskId: task.id,
121
+ command: mask?.mask(task.command) ?? task.command,
122
+ durationMs: run.durationMs,
123
+ stdout: log,
124
+ ...(run.cpuMs !== undefined ? { cpuMs: run.cpuMs } : {}),
125
+ ...(run.peakRssBytes !== undefined ? { peakRssBytes: run.peakRssBytes } : {}),
126
+ },
127
+ })
128
+ await cache.drainUploads?.()
129
+ },
130
+
131
+ async restore(key, task) {
132
+ const entry = await (cache.getSaved ? cache.getSaved(key) : cache.get(key))
133
+ if (entry === null) return null
134
+ await cache.restoreOutputs(key, task.projectDir, workspaceRoot, task.outputs)
135
+ return decodeOutputLog(entry.stdout)
136
+ },
137
+
138
+ async output(key) {
139
+ const entry = await (cache.getSaved ? cache.getSaved(key) : cache.get(key))
140
+ return entry === null ? null : decodeOutputLog(entry.stdout)
141
+ },
142
+
143
+ share: (key) => cache.share?.(key) ?? Promise.resolve(false),
144
+ resend: (key) => cache.resend?.(key) ?? Promise.resolve(false),
145
+ uploaded: (key) => cache.uploaded?.(key) ?? Promise.resolve(undefined),
146
+ }
147
+ }
148
+
149
+ /**
150
+ * What a run's cache is read under, for a machine that runs its tasks
151
+ * (`ExecutorContext.cacheFacts`, `CommandContext.artifactsFor`): the
152
+ * refs the run worked out, so that machine reads as the run would, and
153
+ * the access each remote lent, by plugin.
154
+ */
155
+ export interface CacheFacts {
156
+ /** `CacheContext.refs`. */
157
+ readonly refs: readonly string[]
158
+ /** Plugin name → the token its remote's `lend` gave. */
159
+ readonly lent: Readonly<Record<string, string>>
160
+ /** Epoch ms: the first lent token's end, before which to ask again. */
161
+ readonly expiresAt?: number
162
+ }
163
+
164
+ /** The run's `CacheFacts`: each readable remote's `lend`, asked now; one that throws lends nothing, said. */
165
+ export async function cacheFacts(
166
+ remotes: readonly ResolvedRemote[],
167
+ refs: readonly string[],
168
+ ): Promise<CacheFacts> {
169
+ const lent: Record<string, string> = {}
170
+ let expiresAt: number | undefined
171
+ await Promise.all(
172
+ remotes.map(async (r) => {
173
+ if (r.plugin === undefined || r.remote.lend === undefined || !r.read) return
174
+ // Through a promise, and checked: a plugin's synchronous throw skipped
175
+ // the catch and rejected every remote's answer, and a `null` or a
176
+ // numeric token met `.token` as a TypeError.
177
+ const got = await Promise.resolve()
178
+ .then(() => r.remote.lend!())
179
+ .then((answer: unknown) => {
180
+ if (answer === undefined) return undefined
181
+ if (typeof answer !== 'object' || answer === null)
182
+ throw new Error(
183
+ `it answered ${answer === null ? 'null' : typeof answer}, not { token, expiresAt? }`,
184
+ )
185
+ const { token, expiresAt: ends } = answer as { token?: unknown; expiresAt?: unknown }
186
+ if (typeof token !== 'string') throw new Error('it answered a token that is not a string')
187
+ if (ends !== undefined && !(typeof ends === 'number' && Number.isFinite(ends)))
188
+ throw new Error('it answered an expiresAt that is not a time in ms')
189
+ return { token, ...(ends === undefined ? {} : { expiresAt: ends }) }
190
+ })
191
+ .catch((err: unknown) => {
192
+ // Said: unsaid, the run's workers went without this remote and no line said why.
193
+ const message = thrownMessage(err)
194
+ sayRemoteFault(
195
+ r,
196
+ new Error(`lend of ${r.plugin}'s access failed: ${message}`, { cause: err }),
197
+ )
198
+ return undefined
199
+ })
200
+ if (got === undefined) return
201
+ lent[r.plugin] = got.token
202
+ if (got.expiresAt !== undefined) expiresAt = Math.min(expiresAt ?? Infinity, got.expiresAt)
203
+ }),
204
+ )
205
+ return { refs, lent, ...(expiresAt === undefined ? {} : { expiresAt }) }
206
+ }
@@ -6,6 +6,7 @@ import {
6
6
  ABSENT_INPUT,
7
7
  absentOr,
8
8
  addedInput,
9
+ behindLink,
9
10
  filesToken,
10
11
  foldHead,
11
12
  type InputListing,
@@ -21,7 +22,16 @@ import {
21
22
  } from '../cache/index.js'
22
23
  import type { InputFile, TaskInputs } from '../exec/index.js'
23
24
  import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
24
- import { isUserError, relPosix, secretMask, span, UserError, xxh3hex } from '../util/index.js'
25
+ import { GROUP_VIA } from '../history/index.js'
26
+ import {
27
+ isUserError,
28
+ keysSorted,
29
+ relPosix,
30
+ secretMask,
31
+ span,
32
+ UserError,
33
+ xxh3hex,
34
+ } from '../util/index.js'
25
35
  import { expandGroupUpstream, filterUpstreamHashes } from './upstream.js'
26
36
 
27
37
  /**
@@ -46,6 +56,11 @@ export interface HashCache {
46
56
  projectFiles: ProjectFilesCache
47
57
  /** Each task's last key by its probe, across runs (the local cache's `FoldMemo`). */
48
58
  folds?: FoldMemo | undefined
59
+ /**
60
+ * The token of the remembered graph the run's nodes are (graph-memo.ts),
61
+ * while no plugin or flag has edited them since.
62
+ */
63
+ graph?: string | undefined
49
64
  }
50
65
 
51
66
  export function createHashCache(folds?: FoldMemo): HashCache {
@@ -131,15 +146,15 @@ const FOLD_MEMO_VERSION = 'fold-memo-v1'
131
146
  /**
132
147
  * A task that runs a command and declares no `inputs`: nothing says what
133
148
  * it read, so no key can stand for its outputs, and a key that folds it
134
- * would replay a stale entry (owner, 2026-10-16).
149
+ * would replay a stale entry (2026-10-16).
135
150
  */
136
151
  export function isKeyless(task: TaskConfig): boolean {
137
152
  return task.exec !== undefined && task.inputs === undefined
138
153
  }
139
154
 
140
155
  /**
141
- * Drawn once per process, so no run derives another run's keyless keys;
142
- * on first use, as the browser playground loads this module with no `crypto`.
156
+ * Drawn once per process, on first use, so no run derives another run's
157
+ * keyless keys.
143
158
  */
144
159
  let keylessSalt: string | undefined
145
160
 
@@ -159,6 +174,12 @@ export interface InputFact {
159
174
  path: string
160
175
  digest: string
161
176
  since: number
177
+ /**
178
+ * For a link, the content behind it (`behindLink`), which the key folds
179
+ * after the link's own target string. An edit there leaves the link
180
+ * untouched, so the re-check compares this rather than the link's ctime.
181
+ */
182
+ behind?: string
162
183
  }
163
184
 
164
185
  /**
@@ -180,11 +201,21 @@ export async function describeTaskInputs(args: ComputeHashArgs): Promise<{
180
201
  }> {
181
202
  const hashedAt = Date.now()
182
203
  const { input, listings } = await resolveKeyInput(args)
183
- const hash = await args.cache.key(input)
184
204
  const sorted = [...input.inputFiles].sort()
185
205
  const digests = await Promise.all(
186
206
  sorted.map((f) => input.fileHashes?.get(f) ?? args.cache.hashFile(f).catch(absentOr)),
187
207
  )
208
+ // Learned BEFORE the key folds it: an edit in between then reads as a
209
+ // move at the re-check (a withheld save), never as content the key
210
+ // did not fold matching it (a stale entry).
211
+ const hashFile = (f: string): Promise<string> => args.cache.hashFile(f)
212
+ const behind = new Map<string, string>()
213
+ await Promise.all(
214
+ sorted.map(async (f, i) => {
215
+ if (digests[i]!.startsWith('120000:')) behind.set(f, await behindLink(f, hashFile))
216
+ }),
217
+ )
218
+ const hash = await args.cache.key(input)
188
219
  // A file gone since the enumeration is no input: the task will not read it.
189
220
  const files: InputFile[] = sorted.flatMap((f, i) =>
190
221
  digests[i] === ABSENT_INPUT
@@ -192,11 +223,16 @@ export async function describeTaskInputs(args: ComputeHashArgs): Promise<{
192
223
  : [{ path: relPosix(input.workspaceRoot, f), digest: digests[i]! }],
193
224
  )
194
225
  const indexedAt = args.gitFilesCache?.enumeratedAtMs ?? 0
195
- const facts: InputFact[] = sorted.map((f, i) => ({
196
- path: f,
197
- digest: digests[i]!,
198
- since: input.fileHashes?.has(f) === true ? indexedAt : hashedAt,
199
- }))
226
+ const facts: InputFact[] = sorted.map((f, i) => {
227
+ const fact: InputFact = {
228
+ path: f,
229
+ digest: digests[i]!,
230
+ since: input.fileHashes?.has(f) === true ? indexedAt : hashedAt,
231
+ }
232
+ const linked = behind.get(f)
233
+ if (linked !== undefined) fact.behind = linked
234
+ return fact
235
+ })
200
236
  // Groups expanded: this list describes the INPUT ROOT, not the key, and a
201
237
  // group produces nothing of its own (see `CacheKeyInput.upstreamGraft`).
202
238
  // Sorted by hash so the list — and any action digest derived from it — is
@@ -260,7 +296,8 @@ export async function describeTaskInputs(args: ComputeHashArgs): Promise<{
260
296
  * the command by content too, when it answers true for that file and its
261
297
  * ctime: the task says the same bytes are its own identical rewrite
262
298
  * (tsdown's package.json), not an edit undone, unless another command that
263
- * may write the file ran until then (command-windows.ts).
299
+ * may write the file ran until then (command-windows.ts). A link's content
300
+ * behind it (`InputFact.behind`) is followed again and compared.
264
301
  */
265
302
  export async function movedInput(
266
303
  facts: readonly InputFact[],
@@ -294,9 +331,25 @@ export async function movedInput(
294
331
  const now = await cache.hashFile(f.path).catch(() => undefined)
295
332
  if (now !== f.digest) return f.path
296
333
  }
334
+ // What a link points to changes without touching the link: each is
335
+ // followed again (links are rare, so a task without one pays nothing).
336
+ const hashFile = (file: string): Promise<string> => cache.hashFile(file)
337
+ for (const f of facts) {
338
+ if (f.behind !== undefined && (await behindLink(f.path, hashFile)) !== f.behind) return f.path
339
+ }
297
340
  return undefined
298
341
  }
299
342
 
343
+ /** A task's `filesToken`, as its key's fold memo probe takes it. */
344
+ export function taskFilesToken(
345
+ args: Pick<
346
+ ComputeHashArgs,
347
+ 'node' | 'workspaceRoot' | 'nestedProjectDirs' | 'gitFilesCache' | 'hashCache'
348
+ >,
349
+ ): string | undefined {
350
+ return filesToken(inputsArgs({ ...args, upstream: [], cache: undefined as never }))
351
+ }
352
+
300
353
  /** What `resolveInputs` is asked for a task's key. */
301
354
  function inputsArgs(args: ComputeHashArgs): ResolveInputsArgs {
302
355
  const cfg = args.node.config
@@ -348,6 +401,24 @@ async function resolveKeyInput(
348
401
  const upstreamHashes = upstreamPairs.map(([, hash]) => hash)
349
402
  // hash → upstream task id, for capture-row naming only (not folded).
350
403
  const upstreamIds = new Map(upstreamPairs.map(([id, hash]) => [hash, id]))
404
+ // A group records no run and keeps no fingerprints, so `vx why` could not
405
+ // see past one: each folded group's members are captured as rows of their
406
+ // own, named through it. Rows only, never folded: the group's hash already
407
+ // folds theirs.
408
+ if (args.captureInto !== undefined) {
409
+ const folded = new Set(upstreamPairs.map(([id]) => id))
410
+ for (const g of args.upstream) {
411
+ if (g.groupUpstream === undefined || !folded.has(g.node.id)) continue
412
+ for (const m of expandGroupUpstream(g.groupUpstream)) {
413
+ if (m.hash === undefined) continue
414
+ args.captureInto.push({
415
+ kind: 'upstream',
416
+ name: `${g.node.id}${GROUP_VIA}${m.node.id}`,
417
+ hash: m.hash,
418
+ })
419
+ }
420
+ }
421
+ }
351
422
  // The DEPENDENCY closure, groups expanded — for the executor's input root.
352
423
  // Deliberately NOT the filtered set above: `inputs.tasks` says which
353
424
  // upstream KEYS this task's key folds, and what a task may READ is
@@ -393,6 +464,7 @@ async function resolveKeyInput(
393
464
  forwardArgs: effectiveForwardArgs,
394
465
  ...(fileHashes !== undefined ? { fileHashes } : {}),
395
466
  ...(args.node.keyParts !== undefined ? { pluginParts: args.node.keyParts } : {}),
467
+ ...(args.node.platform !== undefined ? { platform: args.node.platform } : {}),
396
468
  ...(args.captureInto !== undefined ? { captureInto: args.captureInto } : {}),
397
469
  }
398
470
  return { input, listings: resolved.listings }
@@ -411,7 +483,7 @@ async function resolveKeyInput(
411
483
  * each task's config object is created once per run, so a hit there
412
484
  * skips the JSON.stringify + xxh3 entirely.
413
485
  */
414
- function hashTaskConfig(cfg: TaskConfig, hashCache?: HashCache): string {
486
+ export function hashTaskConfig(cfg: TaskConfig, hashCache?: HashCache): string {
415
487
  if (hashCache) {
416
488
  const cached = hashCache.taskConfig.get(cfg)
417
489
  if (cached !== undefined) return cached
@@ -444,9 +516,12 @@ function hashTaskConfig(cfg: TaskConfig, hashCache?: HashCache): string {
444
516
  * would bump CACHE_VERSION.
445
517
  */
446
518
  function hashableConfig(cfg: TaskConfig): unknown {
447
- if (cfg.exec?.remote === undefined) return cfg
519
+ // Keys sorted: moving `cache` above `exec`, or a `define` in another
520
+ // order, missed every key of the task, and a `project` or `graph` plugin
521
+ // writes a config the lock never sorted (2026-10-25).
522
+ if (cfg.exec?.remote === undefined) return keysSorted(cfg)
448
523
  const { remote: _remote, ...execRest } = cfg.exec
449
- return { ...cfg, exec: execRest }
524
+ return keysSorted({ ...cfg, exec: execRest })
450
525
  }
451
526
 
452
527
  /**
@@ -105,7 +105,7 @@ function budgetCost(chars: number, chunkCount: number): number {
105
105
 
106
106
  /**
107
107
  * Bounded per-run capture. `append` keeps a chunk LIST + running char count per
108
- * task, evicting whole chunks from the head past `TASK_LOG_TAIL_CHARS` — no
108
+ * task, evicting from the head past `TASK_LOG_TAIL_CHARS` — no
109
109
  * string concatenation until `drain`, so a cache-hit replay (one big chunk) is
110
110
  * one array push, zero copies. `finish` decides retention; `drain` emits the
111
111
  * bundle, failures first.
@@ -115,6 +115,16 @@ export class TaskLogBuffer {
115
115
  private readonly retained = new Map<string, Retained>()
116
116
  private retainedChars = 0
117
117
  private seq = 0
118
+ /**
119
+ * Retained entries in eviction order, kept as they arrive: successes are
120
+ * stubbed from `successHead` on (oldest first), failures from the end
121
+ * (newest first). An entry since stubbed, taken or replaced is skipped
122
+ * where it lies. A sort of every entry per finish made a run past the
123
+ * budget quadratic: 40,000 tasks took 17 s in a sink's `onRecord`.
124
+ */
125
+ private readonly successes: Retained[] = []
126
+ private successHead = 0
127
+ private failures: Retained[] = []
118
128
 
119
129
  append(taskId: string, chunk: string): void {
120
130
  if (chunk.length === 0) return
@@ -126,24 +136,27 @@ export class TaskLogBuffer {
126
136
  acc.chunks.push(chunk)
127
137
  acc.chars += chunk.length
128
138
  acc.charsFull += chunk.length
129
- // Evict whole chunks from the head until under the per-task tail cap. A
130
- // single over-cap chunk is sliced to its tail (the only place we copy).
131
- while (acc.chars > TASK_LOG_TAIL_CHARS && acc.chunks.length > 1) {
132
- const dropped = acc.chunks.shift()!
133
- acc.chars -= dropped.length
134
- acc.truncatedHeadChars += dropped.length
135
- }
136
- if (acc.chars > TASK_LOG_TAIL_CHARS && acc.chunks.length === 1) {
137
- const only = acc.chunks[0]!
138
- let start = only.length - TASK_LOG_TAIL_CHARS
139
+ // Evict whole chunks from the head until under the per-task tail cap;
140
+ // the one that straddles the cap is sliced to what still fits (the only
141
+ // place we copy). Dropped whole, a 128 KiB chunk and a 7-char one after
142
+ // it kept the 7 chars alone.
143
+ while (acc.chars > TASK_LOG_TAIL_CHARS) {
144
+ const head = acc.chunks[0]!
145
+ const excess = acc.chars - TASK_LOG_TAIL_CHARS
146
+ if (head.length <= excess) {
147
+ acc.chunks.shift()
148
+ acc.chars -= head.length
149
+ acc.truncatedHeadChars += head.length
150
+ continue
151
+ }
152
+ let start = excess
139
153
  // A cut between a surrogate pair would open the tail on a lone low
140
154
  // surrogate, which a sink's UTF-8 encode turns into U+FFFD.
141
- const first = only.charCodeAt(start)
155
+ const first = head.charCodeAt(start)
142
156
  if (first >= 0xdc00 && first <= 0xdfff) start++
143
- const keep = only.slice(start)
144
- acc.truncatedHeadChars += only.length - keep.length
145
- acc.chunks[0] = keep
146
- acc.chars = keep.length
157
+ acc.chunks[0] = head.slice(start)
158
+ acc.chars -= start
159
+ acc.truncatedHeadChars += start
147
160
  }
148
161
  }
149
162
 
@@ -180,9 +193,32 @@ export class TaskLogBuffer {
180
193
  if (prior !== undefined) this.retainedChars -= budgetCost(prior.chars, prior.chunks.length)
181
194
  this.retained.set(taskId, entry)
182
195
  this.retainedChars += budgetCost(entry.chars, entry.chunks.length)
196
+ ;(status === 'success' ? this.successes : this.failures).push(entry)
197
+ this.compact()
183
198
  this.evictToBudget()
184
199
  }
185
200
 
201
+ /** Whether `e` may still be stubbed: retained as it is, content and all. */
202
+ private evictable(e: Retained): boolean {
203
+ return e.chars > 0 && this.retained.get(e.taskId) === e
204
+ }
205
+
206
+ /**
207
+ * Drop what eviction would only skip, once the queues hold twice what is
208
+ * retained: a sink that takes every entry (`takeEntry`) never evicts, and
209
+ * its queues would grow by a task each. Amortized constant per finish.
210
+ */
211
+ private compact(): void {
212
+ const room = 2 * this.retained.size + 1024
213
+ if (this.successes.length - this.successHead > room || this.successHead > room) {
214
+ const live = this.successes.slice(this.successHead).filter((e) => this.evictable(e))
215
+ this.successes.length = 0
216
+ this.successes.push(...live)
217
+ this.successHead = 0
218
+ }
219
+ if (this.failures.length > room) this.failures = this.failures.filter((e) => this.evictable(e))
220
+ }
221
+
186
222
  /**
187
223
  * Take ONE finished task's retained tail as a wire entry, removing it from
188
224
  * the buffer (so a later `drain` won't re-ship it). Used by per-task
@@ -249,7 +285,7 @@ export class TaskLogBuffer {
249
285
  /**
250
286
  * Evict until `retainedChars <= RUN_LOG_BUDGET_CHARS`. Successes go first
251
287
  * (oldest by seq); only when failures ALONE still exceed the budget do the
252
- * oldest failures go — a failure is never dropped to keep a success.
288
+ * newest failures go — a failure is never dropped to keep a success.
253
289
  *
254
290
  * An evicted task is degraded to a STUB (no content, `truncatedHeadChars`
255
291
  * = everything it emitted) rather than removed. Dropping the entry makes a
@@ -269,25 +305,25 @@ export class TaskLogBuffer {
269
305
  * sees it.
270
306
  */
271
307
  private evictToBudget(): void {
272
- if (this.retainedChars <= RUN_LOG_BUDGET_CHARS) return
273
- const order = [...this.retained.values()].sort((a, b) => {
274
- const sa = a.status === 'success' ? 0 : 1
275
- const sb = b.status === 'success' ? 0 : 1
276
- if (sa !== sb) return sa - sb // successes are dropped first, always
277
- // Within a tier the tiebreak DIFFERS by status, and the failed one is
278
- // the point: when failures alone blow the budget, the FIRST failure is
279
- // usually the root cause and the later ones its cascade, so it must be
280
- // the last thing stubbed. Successes keep oldest-first — none of them
281
- // is more interesting than another, and recency is the better guess.
282
- return a.status === 'failed' ? b.seq - a.seq : a.seq - b.seq
283
- })
284
- for (const e of order) {
285
- if (this.retainedChars <= RUN_LOG_BUDGET_CHARS) break
286
- if (e.chars === 0) continue // already a stub — frees nothing
287
- this.retainedChars -= budgetCost(e.chars, e.chunks.length)
288
- e.truncatedHeadChars += e.chars
289
- e.chunks = []
290
- e.chars = 0
308
+ // Successes are dropped first, always, and keep oldest-first: none of
309
+ // them is more interesting than another, and recency is the better
310
+ // guess. Failures go newest-first: when failures alone blow the budget,
311
+ // the FIRST failure is usually the root cause and the later ones its
312
+ // cascade, so it must be the last thing stubbed.
313
+ while (this.retainedChars > RUN_LOG_BUDGET_CHARS && this.successHead < this.successes.length) {
314
+ const e = this.successes[this.successHead++]!
315
+ if (this.evictable(e)) this.stub(e)
316
+ }
317
+ while (this.retainedChars > RUN_LOG_BUDGET_CHARS && this.failures.length > 0) {
318
+ const e = this.failures.pop()!
319
+ if (this.evictable(e)) this.stub(e)
291
320
  }
292
321
  }
322
+
323
+ private stub(e: Retained): void {
324
+ this.retainedChars -= budgetCost(e.chars, e.chunks.length)
325
+ e.truncatedHeadChars += e.chars
326
+ e.chunks = []
327
+ e.chars = 0
328
+ }
293
329
  }
@@ -11,6 +11,7 @@ import {
11
11
  PM_EXEC_ENV,
12
12
  VX_RUN_TASK_ENV,
13
13
  VX_RUN_WORKSPACE_ENV,
14
+ type ServerSpec,
14
15
  } from '../exec/index.js'
15
16
  import type { TaskNode } from '../graph/index.js'
16
17
  import { parseDuration } from '../util/index.js'
@@ -73,6 +74,7 @@ export function taskEnv(
73
74
  define: step.env?.define ?? {},
74
75
  source: process.env,
75
76
  binPaths: bins,
77
+ workspaceRoot,
76
78
  })
77
79
  env[VX_RUN_WORKSPACE_ENV] = workspaceRoot
78
80
  env[VX_RUN_TASK_ENV] = node.id
@@ -89,6 +91,26 @@ export function taskTimeoutMs(step: ExecConfig): number | undefined {
89
91
  return step.timeout === undefined ? undefined : parseDuration(step.timeout)!
90
92
  }
91
93
 
94
+ /** Persistent task `node` as a `servers` executor starts it: no forwarded args, which a dependency never gets. */
95
+ export function serverSpec(
96
+ node: TaskNode,
97
+ workspaceRoot: string,
98
+ nested: readonly string[],
99
+ ): ServerSpec {
100
+ const step = node.config.exec!
101
+ const timeoutMs = taskTimeoutMs(step)
102
+ const readyWhen = step.persistent?.readyWhen
103
+ return {
104
+ taskId: node.id,
105
+ command: step.command,
106
+ cwd: node.projectDir,
107
+ env: taskEnv(node, step, workspaceRoot),
108
+ ...(readyWhen === undefined ? {} : { readyWhen }),
109
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
110
+ ...(step.sandbox === undefined ? {} : { sandbox: { config: step.sandbox, nested } }),
111
+ }
112
+ }
113
+
92
114
  export function isAborted(signal: AbortSignal | undefined): boolean {
93
115
  return signal?.aborted === true
94
116
  }
@@ -17,9 +17,11 @@ import type {
17
17
  RunContextRecord,
18
18
  RunSummaryRecord,
19
19
  TelemetryContext,
20
+ TelemetryRecord,
20
21
  TelemetrySink,
22
+ TreeLoad,
21
23
  } from './telemetry.js'
22
- import { settleWithin, teardownTimeoutMs } from '../util/index.js'
24
+ import { settleWithin, teardownTimeoutMs, thrownMessage } from '../util/index.js'
23
25
  import { createTelemetrySource } from './telemetry.js'
24
26
  import type { VxPlugin } from './plugin.js'
25
27
 
@@ -32,7 +34,17 @@ export interface TelemetryHandle {
32
34
  /** Remove the bus subscription. Idempotent. */
33
35
  dispose(): void
34
36
  /** Sample a running task's process tree; see `TelemetrySource.track`. */
35
- readonly track?: (taskId: string, pid: number) => () => void
37
+ readonly track?: (taskId: string, pid: number) => () => TreeLoad | undefined
38
+ }
39
+
40
+ // Every kind once: a kind added to `TelemetryRecord` fails to compile here.
41
+ const RECORD_KINDS: Record<TelemetryRecord['kind'], true> = {
42
+ 'run.start': true,
43
+ 'task.start': true,
44
+ 'task.log': true,
45
+ 'task.sample': true,
46
+ 'task.end': true,
47
+ 'run.end': true,
36
48
  }
37
49
 
38
50
  /** Reject anything that is not sink-shaped, naming what arrived. */
@@ -44,6 +56,15 @@ function checkSink(sink: unknown): TelemetrySink {
44
56
  if (wants !== undefined && !Array.isArray(wants)) {
45
57
  throw new Error(`telemetry sink 'wants' must be an array, got ${typeof wants}`)
46
58
  }
59
+ // A misspelt kind (`task.logs`) was a sink that never heard it, with no word.
60
+ const unknown = (wants as readonly unknown[] | undefined)?.find(
61
+ (k) => typeof k !== 'string' || !Object.hasOwn(RECORD_KINDS, k),
62
+ )
63
+ if (unknown !== undefined) {
64
+ throw new Error(
65
+ `telemetry sink 'wants' names ${typeof unknown === 'string' ? JSON.stringify(unknown) : typeof unknown}, which is no record kind (${Object.keys(RECORD_KINDS).join(', ')})`,
66
+ )
67
+ }
47
68
  // Every handler is optional, so `{ nope: true }` was a valid sink that
48
69
  // subscribed to the bus and heard nothing — the plugin was "on" and did
49
70
  // nothing. Refused here, which the caller turns into the same warning
@@ -101,7 +122,7 @@ export async function subscribeTelemetry(
101
122
  accepted = list.map((sink) => checkSink(sink))
102
123
  } catch (err) {
103
124
  ctx.warn(
104
- `[vx] plugin '${plugin.name}' telemetry failed to initialize; disabled for this run: ${err instanceof Error ? err.message : String(err)}`,
125
+ `[vx] plugin '${plugin.name}' telemetry failed to initialize; disabled for this run: ${thrownMessage(err)}`,
105
126
  )
106
127
  continue
107
128
  }