@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
@@ -14,7 +14,7 @@
14
14
  // HARD SCOPE: this runs ONLY when a remote layer is configured. It
15
15
  // asks the local index only which keys it holds (no local get / no
16
16
  // isOutputsCurrent), and probes the remote for the rest: a run the local
17
- // cache answers makes no remote call (M, 2026-10-15). Local-only
17
+ // cache answers makes no remote call (2026-10-15). Local-only
18
18
  // runs are byte-identical to before; the caller gates on the layer's
19
19
  // own `hasRemote`, so a third-party remote layer gets the overlap too.
20
20
  // The two batch-probe hooks are OPTIONAL on the contract: a remote layer
@@ -24,7 +24,7 @@ import type { TaskNode } from '../graph/index.js'
24
24
  import { maskedCommand } from '../util/index.js'
25
25
  import type { CacheLayer, GitFilesCache } from '../cache/index.js'
26
26
  import type { HashCache } from './task-hash.js'
27
- import { deriveStableKeys } from './stable-keys.js'
27
+ import { deriveStableKeys, type StableKey } from './stable-keys.js'
28
28
 
29
29
  export interface PrefetchArgs {
30
30
  nodes: Map<string, TaskNode>
@@ -46,6 +46,27 @@ export interface PrefetchArgs {
46
46
  * `probeBeforeUpload` instead, so a key a remote holds is not re-sent.
47
47
  */
48
48
  remoteWrite: boolean
49
+ /**
50
+ * Told once, after the probe, the stable tasks a store holds, local or
51
+ * remote, and those every store answered it does not: they run.
52
+ */
53
+ held?: ((ids: readonly string[], missed: readonly string[]) => void) | undefined
54
+ /** `--profile`'s run events: the keys' derivation and the batch probe, `ts` on `performance.now()`. */
55
+ spans?: Record<string, unknown>[] | undefined
56
+ }
57
+
58
+ function span(spans: PrefetchArgs['spans'], name: string, start: number, args: object): void {
59
+ const end = performance.now()
60
+ spans?.push({
61
+ name,
62
+ cat: 'cache',
63
+ ph: 'X',
64
+ ts: start * 1000,
65
+ dur: (end - start) * 1000,
66
+ pid: 1,
67
+ tid: 0,
68
+ args,
69
+ })
49
70
  }
50
71
 
51
72
  /**
@@ -75,16 +96,24 @@ export function startRemotePrefetch(args: PrefetchArgs): Promise<void> {
75
96
  // a still-in-flight prefetch can never ingest into a closed DB. Errors
76
97
  // degrade inside LayeredCache.prefetch; the catch keeps the handle
77
98
  // from ever rejecting.
78
- const started = Promise.withResolvers<void>()
79
- args.cache.probeFirst?.(started.promise)
80
- return runPrefetch(args, started.resolve)
81
- .catch(() => {})
82
- .finally(started.resolve)
99
+ return runPrefetch(args).catch(() => {})
83
100
  }
84
101
 
85
- async function runPrefetch(args: PrefetchArgs, probing: () => void): Promise<void> {
102
+ async function runPrefetch(args: PrefetchArgs): Promise<void> {
103
+ const keysAt = performance.now()
86
104
  const stableKeys = await deriveStableKeys(args)
87
- if (stableKeys.length === 0) return
105
+ span(args.spans, 'stable keys', keysAt, { keys: stableKeys.length })
106
+ // `complete`: every store asked answered for every key, so one it does
107
+ // not hold is a miss; else only its hits are known.
108
+ const tell = (holds: (hash: string) => boolean, complete: boolean): void =>
109
+ args.held?.(
110
+ stableKeys.filter((k) => holds(k.hash)).map((k) => k.node.id),
111
+ complete ? stableKeys.filter((k) => !holds(k.hash)).map((k) => k.node.id) : [],
112
+ )
113
+ if (stableKeys.length === 0) {
114
+ tell(() => false, true)
115
+ return
116
+ }
88
117
 
89
118
  // Batch existence probe FIRST (when the remote supports it): one round-trip
90
119
  // tells us which of the N stable hashes exist remotely, so we GET only the
@@ -96,12 +125,58 @@ async function runPrefetch(args: PrefetchArgs, probing: () => void): Promise<voi
96
125
  const held =
97
126
  (await args.cache.localHasMany?.([...new Set(stableKeys.map((k) => k.hash))])) ?? null
98
127
  let toPrefetch = held === null ? stableKeys : stableKeys.filter((k) => !held.has(k.hash))
99
- if (toPrefetch.length === 0) return
128
+ if (toPrefetch.length === 0) {
129
+ tell(() => true, true)
130
+ return
131
+ }
100
132
  const uniqueHashes = [...new Set(toPrefetch.map((k) => k.hash))]
133
+ const pool = fetchPool(args.concurrency, (k) =>
134
+ args.cache.prefetch(k.hash, getContext(k.node, k.node.config.exec?.command ?? '')),
135
+ )
136
+ // Trusted at once: a key the index says a remote holds is a hit and its
137
+ // pull starts now, before any remote answers (2026-10-23). The probe
138
+ // below still asks for all of them.
139
+ // One that knows none of them says nothing: a fresh CI runner's empty
140
+ // index called every key a miss, and main asked 15 workers for 16 misses
141
+ // (75f58f9, 2026-10-23). The probe answers them.
142
+ const kept = args.cache.remoteKnown?.(uniqueHashes)
143
+ const known = kept === undefined || kept.size === 0 ? undefined : kept
144
+ if (known !== undefined) pool.push(toPrefetch.filter((k) => known.has(k.hash)))
145
+ const probeAt = performance.now()
101
146
  const asked = args.cache.remoteHasMany?.(uniqueHashes)
102
- // Every lookup that waited on the probe now finds its hash in it.
103
- probing()
104
- const present = (await asked) ?? null
147
+ // The first keys in graph order are pulled now too, the probe unanswered:
148
+ // on CI it answered ~490 ms in, after a GET did (~250 ms), and a hit's
149
+ // dependant waited on its own GET once its upstream hit landed
150
+ // (2026-10-24). At most one pool's worth of these can 404.
151
+ const eager = toPrefetch.filter((k) => known?.has(k.hash) !== true).slice(0, args.concurrency)
152
+ if (asked !== undefined) pool.push(eager)
153
+ // Demand hears the index only when no probe will answer: a key the
154
+ // index lacks may still be held (a PR's CI stored main's next tree), and
155
+ // calling it a miss asked 14 workers for 71 tasks, 17 of which ran
156
+ // (main 7391c93, 2026-10-23). The probe is one round-trip.
157
+ const early = known !== undefined && asked === undefined
158
+ if (early) tell((h) => held?.has(h) === true || known.has(h), true)
159
+
160
+ // Batch existence probe (when the remote supports it): one round-trip
161
+ // tells us which of the N stable hashes exist remotely, so we GET only the
162
+ // hits and pre-mark the misses — their lazy `get` then short-circuits with
163
+ // no network. This collapses N probe waves into 1 and skips every GET that
164
+ // would 404. When the remote can't batch (`null` — an older remote, reads
165
+ // disabled, or a layer that doesn't implement the hook at all), fall back to
166
+ // prefetching every stable key, exactly as before.
167
+ // A remote that answered no batch query is asked key by key when demand
168
+ // plans on the answer: main f71b377 asked 15 workers for 116 tasks, 107
169
+ // restored, and small#1 joined to nothing (2026-10-23). A run that plans
170
+ // nothing pulls instead, as before.
171
+ let present = (await asked) ?? null
172
+ if (asked !== undefined)
173
+ span(args.spans, 'remote probe', probeAt, {
174
+ keys: uniqueHashes.length,
175
+ held: present?.size ?? null,
176
+ })
177
+ if (present === null && args.held !== undefined && asked !== undefined)
178
+ present = (await args.cache.remoteHasEach?.(uniqueHashes, args.concurrency)) ?? null
179
+ if (!early) tell((h) => held?.has(h) === true || present?.has(h) === true, present !== null)
105
180
  if (present !== null) {
106
181
  args.cache.markRemoteAbsent?.(uniqueHashes.filter((h) => !present.has(h)))
107
182
  // The pre-mark above already answers a pull for an absent hash from
@@ -110,24 +185,43 @@ async function runPrefetch(args: PrefetchArgs, probing: () => void): Promise<voi
110
185
  // every absent key. What it decides is which hashes the pump asks
111
186
  // the layer for, and that is what the row pins (item 643, 645).
112
187
  toPrefetch = toPrefetch.filter((k) => present.has(k.hash))
113
- if (toPrefetch.length === 0) return
114
188
  }
189
+ const pulled = new Set(asked === undefined ? [] : eager)
190
+ pool.push(toPrefetch.filter((k) => known?.has(k.hash) !== true && !pulled.has(k)))
191
+ await pool.settled()
192
+ }
115
193
 
116
- // Bounded worker pool over the keys to fetch. Each prefetch is
117
- // self-contained (LayeredCache owns the in-flight map + ingest); we
118
- // cap concurrency so a 1000-task run doesn't open 1000 sockets at
119
- // once. The pumps race alongside execution.
194
+ /**
195
+ * Bounded worker pool over the keys to fetch, fed as they are known. Each
196
+ * prefetch is self-contained (LayeredCache owns the in-flight map +
197
+ * ingest); the cap keeps a 1000-task run from opening 1000 sockets at
198
+ * once. The pumps race alongside execution.
199
+ */
200
+ function fetchPool(
201
+ concurrency: number,
202
+ fetch: (k: StableKey) => Promise<boolean>,
203
+ ): { push(keys: readonly StableKey[]): void; settled(): Promise<unknown> } {
204
+ const cap = Math.max(1, concurrency)
205
+ const queue: StableKey[] = []
206
+ const pumps: Promise<void>[] = []
120
207
  let next = 0
121
- const workers = Math.max(1, Math.min(args.concurrency, toPrefetch.length))
208
+ let active = 0
122
209
  const pump = async (): Promise<void> => {
123
- while (next < toPrefetch.length) {
124
- const { hash, node } = toPrefetch[next++]!
125
- await args.cache
126
- .prefetch(hash, getContext(node, node.config.exec?.command ?? ''))
127
- .catch(() => false)
210
+ active++
211
+ try {
212
+ while (next < queue.length) await fetch(queue[next++]!).catch(() => false)
213
+ } finally {
214
+ active--
128
215
  }
129
216
  }
130
- await Promise.all(Array.from({ length: workers }, () => pump()))
217
+ return {
218
+ push(keys) {
219
+ queue.push(...keys)
220
+ const room = Math.min(cap - active, queue.length - next)
221
+ for (let i = 0; i < room; i++) pumps.push(pump())
222
+ },
223
+ settled: () => Promise.all(pumps),
224
+ }
131
225
  }
132
226
 
133
227
  /**
@@ -10,6 +10,42 @@ export interface ProfileArgs {
10
10
  target: string
11
11
  cwd: string
12
12
  outcomes: readonly TaskOutcome[]
13
+ /** `process.hrtime.bigint()` at the run's start: a task's span is taken from it. */
14
+ runStartNs: bigint
15
+ /** Each executor's own events (`TaskExecutor.profile`), `ts` on `performance.now()`. */
16
+ executors?: readonly (readonly Record<string, unknown>[])[]
17
+ /** The run's own events beside its tasks (the cache probe), `ts` on `performance.now()`. */
18
+ spans?: readonly Record<string, unknown>[]
19
+ }
20
+
21
+ // ns → us; bigint arithmetic to avoid precision loss before converting to
22
+ // a JSON-safe number.
23
+ function spanUs(o: TaskOutcome): { startUs: number; durUs: number } {
24
+ return {
25
+ startUs: Number(o.wallclockStartNs! / 1000n),
26
+ durUs: Number((o.wallclockEndNs! - o.wallclockStartNs!) / 1000n),
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Each task's `tid`: its project's lane, or the project's next lane when it
32
+ * overlaps a task already there. Slices on one thread must nest, and two
33
+ * tasks of one project running side by side do not: Perfetto drops one.
34
+ */
35
+ function lanes(timed: readonly TaskOutcome[]): Map<TaskOutcome, number> {
36
+ const ends = new Map<string, { tid: number; endUs: number }[]>()
37
+ const tidOf = new Map<TaskOutcome, number>()
38
+ let tids = 0
39
+ const byStart = timed.map((o) => ({ o, ...spanUs(o) })).sort((a, b) => a.startUs - b.startUs)
40
+ for (const { o, startUs, durUs } of byStart) {
41
+ let own = ends.get(o.node.projectName)
42
+ if (own === undefined) ends.set(o.node.projectName, (own = []))
43
+ let lane = own.find((l) => l.endUs <= startUs)
44
+ if (lane === undefined) own.push((lane = { tid: ++tids, endUs: 0 }))
45
+ lane.endUs = startUs + durUs
46
+ tidOf.set(o, lane.tid)
47
+ }
48
+ return tidOf
13
49
  }
14
50
 
15
51
  /**
@@ -18,40 +54,67 @@ export interface ProfileArgs {
18
54
  *
19
55
  * One complete event (`ph: 'X'`) per task with `ts` and `dur` in
20
56
  * microseconds (Chrome's unit; we have ns from hrtime, divide by 1000).
21
- * Each project gets its own `tid` so concurrent tasks render on
22
- * distinct rows in chrome://tracing or perfetto.
57
+ * Each project gets its own `tid`, and one more for each task it runs
58
+ * beside another, so concurrent tasks render on distinct rows.
23
59
  */
24
60
  export async function writeRunProfile(args: ProfileArgs): Promise<string> {
25
61
  const outPath = path.resolve(args.cwd, args.target)
26
- const tidByProject = new Map<string, number>()
27
- const traceEvents = args.outcomes
28
- .filter((o) => o.wallclockStartNs !== undefined && o.wallclockEndNs !== undefined)
29
- .map((o) => {
30
- let tid = tidByProject.get(o.node.projectName)
31
- if (tid === undefined) {
32
- tid = tidByProject.size + 1
33
- tidByProject.set(o.node.projectName, tid)
34
- }
35
- // ns → us; bigint arithmetic to avoid precision loss before
36
- // converting to a JSON-safe number.
37
- const startUs = Number((o.wallclockStartNs ?? 0n) / 1000n)
38
- const durUs = Number(((o.wallclockEndNs ?? 0n) - (o.wallclockStartNs ?? 0n)) / 1000n)
39
- return {
40
- name: o.node.id,
41
- cat: o.status,
42
- ph: 'X',
43
- ts: startUs,
44
- dur: durUs,
45
- pid: 1,
46
- tid,
47
- args: {
48
- exitCode: o.exitCode,
49
- hash: o.hash ?? null,
50
- ...(o.cpuMs !== undefined ? { cpuMs: o.cpuMs } : {}),
51
- ...(o.peakRssBytes !== undefined ? { peakRssBytes: o.peakRssBytes } : {}),
52
- },
53
- }
54
- })
55
- await Bun.write(outPath, JSON.stringify({ traceEvents }, null, 2))
62
+ const timed = args.outcomes.filter(
63
+ (o) => o.wallclockStartNs !== undefined && o.wallclockEndNs !== undefined,
64
+ )
65
+ const tidOf = lanes(timed)
66
+ const traceEvents = timed.map((o) => {
67
+ const tid = tidOf.get(o)!
68
+ const { startUs, durUs } = spanUs(o)
69
+ return {
70
+ name: o.node.id,
71
+ cat: o.status,
72
+ ph: 'X',
73
+ ts: startUs,
74
+ dur: durUs,
75
+ pid: 1,
76
+ tid,
77
+ args: {
78
+ exitCode: o.exitCode,
79
+ hash: o.hash ?? null,
80
+ ...(o.cpuMs !== undefined ? { cpuMs: o.cpuMs } : {}),
81
+ ...(o.peakRssBytes !== undefined ? { peakRssBytes: o.peakRssBytes } : {}),
82
+ // A remote hit's download and ingest: the rest of its span is vx's.
83
+ ...(o.fetchMs !== undefined ? { fetchMs: o.fetchMs } : {}),
84
+ },
85
+ }
86
+ })
87
+ // An executor's processes follow the run's own, each executor's after
88
+ // the last; its times move onto the tasks' clock, the run's start on
89
+ // hrtime. Without the start, a DTE mark sat ~the run's setup late.
90
+ const shiftUs =
91
+ Number((process.hrtime.bigint() - args.runStartNs) / 1000n) - performance.now() * 1000
92
+ let next = 2
93
+ const theirs: Record<string, unknown>[] = []
94
+ for (const events of args.executors ?? []) {
95
+ let top = 0
96
+ for (const e of events) {
97
+ const pid = typeof e['pid'] === 'number' ? e['pid'] : 1
98
+ top = Math.max(top, pid)
99
+ theirs.push({
100
+ ...e,
101
+ pid: next - 1 + pid,
102
+ ...(typeof e['ts'] === 'number' ? { ts: Math.round(e['ts'] + shiftUs) } : {}),
103
+ })
104
+ }
105
+ next += top
106
+ }
107
+ const own = (args.spans ?? []).map((e) => ({
108
+ ...e,
109
+ ...(typeof e['ts'] === 'number' ? { ts: Math.round(e['ts'] + shiftUs) } : {}),
110
+ }))
111
+ const named =
112
+ theirs.length === 0
113
+ ? []
114
+ : [{ ph: 'M', name: 'process_name', pid: 1, tid: 0, args: { name: 'run' } }]
115
+ await Bun.write(
116
+ outPath,
117
+ JSON.stringify({ traceEvents: [...named, ...traceEvents, ...own, ...theirs] }, null, 2),
118
+ )
56
119
  return outPath
57
120
  }
@@ -13,7 +13,15 @@
13
13
  import fs from 'node:fs'
14
14
  import os from 'node:os'
15
15
  import path from 'node:path'
16
- import { executablePath, gitEnv, relPosix, ulid, xxh3hex } from '../util/index.js'
16
+ import {
17
+ envFlagOn,
18
+ executablePath,
19
+ gitEnv,
20
+ ignoreDir,
21
+ relPosix,
22
+ ulid,
23
+ xxh3hex,
24
+ } from '../util/index.js'
17
25
  import { resolveSetting } from '../settings.js'
18
26
  import { branchCacheScope, cacheScopeEnvError, isCacheScope } from '../workspace/index.js'
19
27
  import { VERSION } from '../version.js'
@@ -234,7 +242,7 @@ function ciBranch(env: NodeJS.ProcessEnv | Record<string, string | undefined>):
234
242
  * The repository's DEFAULT (trunk) branch — the axis that separates a
235
243
  * mainline run from a PR / feature-branch experiment. A run whose `branch`
236
244
  * equals this is a trunk run; everything else is branch work whose timings
237
- * must NOT pollute the shared scheduling baseline (owner 2026-07-14: "I can
245
+ * must NOT pollute the shared scheduling baseline (2026-07-14: "I can
238
246
  * be experimenting on a branch increasing task time for all later on… don't
239
247
  * count their times into main"). Resolution ladder, most-reliable first:
240
248
  * 1. GitLab: `CI_DEFAULT_BRANCH` (the project's configured default).
@@ -312,10 +320,6 @@ const CI_PROVIDERS: ReadonlyArray<[envVar: string, provider: string]> = [
312
320
  ['CI', 'generic'],
313
321
  ]
314
322
 
315
- function isTruthy(v: string | undefined): boolean {
316
- return v !== undefined && v !== '' && v !== '0' && v.toLowerCase() !== 'false'
317
- }
318
-
319
323
  /**
320
324
  * Detect a CI environment + which provider. `ci=true` if any recognized
321
325
  * var is truthy; `provider` names the most-specific match (a specific
@@ -323,7 +327,7 @@ function isTruthy(v: string | undefined): boolean {
323
327
  */
324
328
  export function detectCi(env: NodeJS.ProcessEnv | Record<string, string | undefined>): CiContext {
325
329
  for (const [varName, provider] of CI_PROVIDERS) {
326
- if (isTruthy(env[varName])) return { ci: true, provider, ...ciLinks(provider, env) }
330
+ if (envFlagOn(env[varName])) return { ci: true, provider, ...ciLinks(provider, env) }
327
331
  }
328
332
  return { ci: false, provider: null }
329
333
  }
@@ -345,8 +349,12 @@ function ciLinks(provider: string, env: Record<string, string | undefined>): CiL
345
349
  const id = v('GITHUB_RUN_ID')
346
350
  links = {
347
351
  runUrl: repo && id ? `${server}/${repo}/actions/runs/${id}` : undefined,
348
- // `refs/pull/<n>/merge` on a pull_request event.
349
- change: /^refs\/pull\/(\d+)\//.exec(v('GITHUB_REF') ?? '')?.[1],
352
+ // `refs/pull/<n>/merge` on a pull_request event; a merge queue's
353
+ // run, `refs/heads/gh-readonly-queue/<base>/pr-<n>-<sha>`.
354
+ change: /^refs\/(?:pull\/(\d+)\/|heads\/gh-readonly-queue\/.+\/pr-(\d+)-[0-9a-f]+$)/
355
+ .exec(v('GITHUB_REF') ?? '')
356
+ ?.slice(1)
357
+ .find((x) => x !== undefined),
350
358
  pipeline: v('GITHUB_WORKFLOW'),
351
359
  job: v('GITHUB_JOB'),
352
360
  attempt: n(v('GITHUB_RUN_ATTEMPT')),
@@ -505,6 +513,12 @@ export function captureWorkspaceIdentity(
505
513
  fs.mkdirSync(path.dirname(saltPath), { recursive: true })
506
514
  fs.writeFileSync(saltPath, salt + '\n')
507
515
  }
516
+ try {
517
+ // On every capture: a salt an older vx wrote may sit unignored.
518
+ ignoreDir(path.dirname(saltPath))
519
+ } catch {
520
+ // read-only: the salt is still the identity
521
+ }
508
522
  return { id: xxh3hex(salt), name: base, ...where }
509
523
  } catch {
510
524
  return { id: xxh3hex(workspaceRoot), name: base, ...where }
@@ -528,7 +542,7 @@ export function currentBranch(
528
542
 
529
543
  /**
530
544
  * The cache scope a checkout on a branch reads, from the `.git` files
531
- * alone (owner, 2026-10-17): `<owner>/<repo>:<branch>`, the branch it
545
+ * alone (2026-10-17): `<owner>/<repo>:<branch>`, the branch it
532
546
  * tracks (`branch.<name>.merge` on `branch.<name>.remote`), else its own
533
547
  * name on `origin`, the repository named by that remote's URL. None on
534
548
  * the default branch, on a detached HEAD, or with no remote URL. CI writes
@@ -554,41 +568,25 @@ export function localBranchScope(
554
568
  }
555
569
 
556
570
  /**
557
- * The refs a remote reads, in order (2026-10-20): the run's own branch,
558
- * then the branch it merges into, then the default branch, each once. On
559
- * CI from its env (a GitHub pull request's head branch, a merge queue's
560
- * queue ref, a GitLab pipeline's ref), else from git: the checked-out
561
- * branch, then the default (`origin/HEAD`). A remote plugin keys or scopes
562
- * its entries by them (`scope` on turboCache/nxCache); core keys nothing
563
- * by them.
571
+ * The refs a remote reads, in order: a plugin's (`setBranch`, `github()`
572
+ * and `gitlab()` on CI, 2026-10-25), else git's: the checked-out branch,
573
+ * then the default (`origin/HEAD`). A remote plugin keys or scopes its
574
+ * entries by them (`scope` on turboCache/nxCache); core keys nothing by them.
564
575
  */
565
576
  export function cacheRefs(
566
577
  workspaceRoot: string,
567
578
  baseBranch: string | undefined,
579
+ pluginRefs: readonly string[] | undefined,
568
580
  env: NodeJS.ProcessEnv = process.env,
569
581
  ): string[] {
570
- const set = (v: string | undefined): string | undefined =>
571
- v !== undefined && v.trim() !== '' ? v.trim() : undefined
572
- const head = (b: string | undefined) => (b === undefined ? undefined : `refs/heads/${b}`)
573
- let own: string | undefined
574
- let base: string | undefined
575
- if (env['GITHUB_ACTIONS'] === 'true') {
576
- own = head(set(env['GITHUB_HEAD_REF'])) ?? set(env['GITHUB_REF'])
577
- base = head(set(env['GITHUB_BASE_REF']))
578
- const queue = /^refs\/heads\/gh-readonly-queue\/(.+)\/pr-\d+-[0-9a-f]+$/.exec(own ?? '')
579
- if (queue !== null) base = head(queue[1])
580
- } else if (env['GITLAB_CI'] === 'true') {
581
- const tag = set(env['CI_COMMIT_TAG'])
582
- own = tag !== undefined ? `refs/tags/${tag}` : head(set(env['CI_COMMIT_REF_NAME']))
583
- base = head(set(env['CI_MERGE_REQUEST_TARGET_BRANCH_NAME']))
584
- } else {
585
- own = head(
586
- (readHeadDirect(workspaceRoot) ?? captureGitContext(workspaceRoot, null, env)).branch ??
587
- undefined,
588
- )
589
- }
590
- const trunk = head(baseBranch ?? captureDefaultBranch(env, workspaceRoot) ?? undefined)
591
- return [...new Set([own, base, trunk].filter((r): r is string => r !== undefined))]
582
+ if (pluginRefs !== undefined) return [...pluginRefs]
583
+ const head = (b: string | null | undefined) => (b == null ? undefined : `refs/heads/${b}`)
584
+ // `{}`: a detached HEAD names no branch here, not CI's (a plugin's job).
585
+ const own = head(
586
+ (readHeadDirect(workspaceRoot) ?? captureGitContext(workspaceRoot, null, {})).branch,
587
+ )
588
+ const trunk = head(baseBranch ?? captureDefaultBranch(env, workspaceRoot))
589
+ return [...new Set([own, trunk].filter((r): r is string => r !== undefined))]
592
590
  }
593
591
 
594
592
  /**
@@ -626,7 +624,7 @@ function gitConfigSections(workspaceRoot: string): Map<string, Map<string, strin
626
624
  }
627
625
 
628
626
  /**
629
- * The run's cache scope (owner, 2026-10-17): `VX_CACHE_SCOPE` (empty: none),
627
+ * The run's cache scope (2026-10-17): `VX_CACHE_SCOPE` (empty: none),
630
628
  * else a plugin's (`setBranch`, `github()` on Actions; null: the plugin
631
629
  * named the run trusted), else, with a cache plugin to share it, the
632
630
  * branch's from git. Undefined: no scope. It keys plugin entries only.
@@ -641,7 +639,7 @@ export function resolveCacheScope(
641
639
  const named = env['VX_CACHE_SCOPE']
642
640
  if (named !== undefined) {
643
641
  if (named === '') return undefined
644
- if (!isCacheScope(named)) throw cacheScopeEnvError()
642
+ if (!isCacheScope(named)) throw cacheScopeEnvError(named)
645
643
  return named
646
644
  }
647
645
  if (pluginScope === null) return undefined
@@ -9,7 +9,7 @@ import path from 'node:path'
9
9
  import { isGroupTask, type TaskOutcome } from '../graph/index.js'
10
10
  import { decodeOutputLog } from './output-log.js'
11
11
  import { PASS_STATUSES } from '../history/index.js'
12
- import { isFsRefusal, splitTaskId } from '../util/index.js'
12
+ import { isFsRefusal, maskedLine, splitTaskId } from '../util/index.js'
13
13
  import { fileLocations, fileMemo, type OutputLocation } from '../report/index.js'
14
14
 
15
15
  /** Failed runs whose output is kept; run ids are UUIDv7, so names sort by age. */
@@ -104,10 +104,24 @@ function keep(cacheDir: string, runId: string, tasks: TaskFailure[]): void {
104
104
  const tmp = `${file}.${process.pid}.tmp`
105
105
  writeFileSync(tmp, JSON.stringify({ runId, tasks }))
106
106
  renameSync(tmp, file)
107
- const names = readdirSync(dir)
108
- .filter((n) => n.endsWith('.json'))
109
- .sort()
110
- for (const n of names.slice(0, -KEPT)) rmSync(path.join(dir, n), { force: true })
107
+ trimKept(dir, KEPT)
108
+ }
109
+
110
+ /**
111
+ * Keep the newest `kept` runs' files in `dir`, and the temp files of runs
112
+ * no older: a writer that crashed between its write and its rename left
113
+ * `<runId>.json.<pid>.tmp`, which the trim of `.json` names never took. A
114
+ * live writer's temp is of a run newer than every kept one.
115
+ */
116
+ export function trimKept(dir: string, kept: number): void {
117
+ const all = readdirSync(dir)
118
+ const names = all.filter((n) => n.endsWith('.json')).sort()
119
+ for (const n of names.slice(0, -kept)) rmSync(path.join(dir, n), { force: true })
120
+ if (names.length < kept) return
121
+ const oldest = names[names.length - kept]!
122
+ for (const n of all)
123
+ if (n.endsWith('.tmp') && n.slice(0, n.lastIndexOf('.json.') + 5) < oldest)
124
+ rmSync(path.join(dir, n), { force: true })
111
125
  }
112
126
 
113
127
  /**
@@ -124,16 +138,7 @@ export function runFailures(cacheDir: string, db: Database, runId?: string): Run
124
138
  .get() as { run_id: string } | null
125
139
  )?.run_id
126
140
  if (id === undefined) return null
127
- const kept = new Map<string, StoredFailure>()
128
- try {
129
- const stored = JSON.parse(
130
- readFileSync(path.join(dirOf(cacheDir), `${id}.json`), 'utf8'),
131
- ) as RunFailures & { tasks: StoredFailure[] }
132
- for (const t of stored.tasks) kept.set(t.taskId, t)
133
- } catch (err) {
134
- // No file: the run kept no output.
135
- if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
136
- }
141
+ const kept = storedFailures(cacheDir, id)
137
142
  const rows = db
138
143
  .query(
139
144
  `SELECT r.project, r.task, r.exit_code AS exitCode, r.timed_out AS timedOut,
@@ -168,6 +173,33 @@ export function runFailures(cacheDir: string, db: Database, runId?: string): Run
168
173
  }
169
174
  }
170
175
 
176
+ /** What `runId`'s failures file holds, by task id; empty when the run kept none. */
177
+ function storedFailures(cacheDir: string, runId: string): Map<string, StoredFailure> {
178
+ const kept = new Map<string, StoredFailure>()
179
+ // A run id the caller typed names a file in failures/ only.
180
+ if (path.basename(runId) !== runId || runId === '..') return kept
181
+ let text: string
182
+ try {
183
+ text = readFileSync(path.join(dirOf(cacheDir), `${runId}.json`), 'utf8')
184
+ } catch (err) {
185
+ // No file: the run kept no output.
186
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
187
+ return kept
188
+ }
189
+ // A file the disk left torn (a rename that landed before its data, on
190
+ // power loss) is output the run did not keep: it threw on every `vx last`
191
+ // until 50 newer runs rotated it out.
192
+ let stored: unknown
193
+ try {
194
+ stored = JSON.parse(text)
195
+ } catch {
196
+ return kept
197
+ }
198
+ const tasks = (stored as { tasks?: unknown } | null)?.tasks
199
+ if (Array.isArray(tasks)) for (const t of tasks as StoredFailure[]) kept.set(t.taskId, t)
200
+ return kept
201
+ }
202
+
171
203
  /** One task's output in a recorded run, as `vx last --log` and getTaskLog read it. */
172
204
  export interface TaskLog {
173
205
  runId: string
@@ -209,22 +241,32 @@ export function taskLog(
209
241
  if (row === null) return null
210
242
  const base = { runId: row.runId, taskId, status: row.status }
211
243
  if (row.status === 'failed') {
212
- const kept = runFailures(cacheDir, db, row.runId)?.tasks.find((t) => t.taskId === taskId)
213
- if (kept !== undefined && kept.output !== '') {
214
- return { ...base, source: 'failure', output: kept.output }
215
- }
244
+ // Only the file: runFailures reads `output: ''` for a task it lacks, as
245
+ // if the task printed nothing, when the run is past the newest 50.
246
+ const kept = storedFailures(cacheDir, row.runId).get(taskId)
247
+ if (kept !== undefined) return { ...base, source: 'failure', output: kept.output }
216
248
  } else if (row.hash !== '') {
217
249
  const stored = db.query('SELECT stdout FROM entry_stdout WHERE hash = ?').get(row.hash) as {
218
250
  stdout: string
219
251
  } | null
220
252
  if (stored !== null) {
221
- const output = Bun.stripANSI(
222
- decodeOutputLog(stored.stdout)
223
- .map((c) => c.text)
224
- .join(''),
253
+ // An entry saved before masking, or by another machine, can hold a
254
+ // secret-named variable's value: masked as a hit's replay masks it.
255
+ // This process's env only: `vx last` loads no config, so a task's
256
+ // `env.define` / `env.secret` values are not known here.
257
+ const output = maskedLine(
258
+ Bun.stripANSI(
259
+ decodeOutputLog(stored.stdout)
260
+ .map((c) => c.text)
261
+ .join(''),
262
+ ),
225
263
  )
226
264
  return { ...base, source: 'cache', output }
227
265
  }
266
+ // An entry that printed nothing stores no stdout row: kept, and empty.
267
+ if (db.query('SELECT 1 FROM entries WHERE hash = ?').get(row.hash) !== null) {
268
+ return { ...base, source: 'cache', output: '' }
269
+ }
228
270
  }
229
271
  return { ...base, source: null, output: '' }
230
272
  }
@@ -10,18 +10,21 @@ import { UserError } from '../util/index.js'
10
10
  * is refused with those runs, since picking one would replay the wrong run.
11
11
  */
12
12
  export function resolveRunId(db: Database, raw: string, verb: string): string | null {
13
- const exact = db.query('SELECT 1 FROM invocations WHERE run_id = ?').get(raw)
14
- if (exact !== null) return raw
13
+ // Run ids are lower-case UUIDs; one typed or pasted in upper case is the same id.
14
+ const id = raw.toLowerCase()
15
+ const exact = db.query('SELECT 1 FROM invocations WHERE run_id = ?').get(id)
16
+ if (exact !== null) return id
15
17
  const rows = db
16
18
  .query(
17
19
  'SELECT run_id FROM invocations WHERE substr(run_id, 1, ?) = ? ORDER BY run_id DESC LIMIT 6',
18
20
  )
19
- .all(raw.length, raw) as Array<{ run_id: string }>
21
+ .all(id.length, id) as Array<{ run_id: string }>
20
22
  if (rows.length === 0) return null
21
23
  if (rows.length === 1) return rows[0]!.run_id
22
24
  const shown = rows.slice(0, 5).map((r) => ` ${r.run_id}`)
23
25
  throw new UserError(
24
26
  `${verb}: run id ${raw} is the start of ${rows.length > 5 ? 'more than 5' : rows.length} runs — type more of it:\n${shown.join('\n')}`,
27
+ 'VX_E_AMBIGUOUS',
25
28
  )
26
29
  }
27
30