@vzn/vx 0.0.646 → 0.0.649

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
@@ -36,11 +36,24 @@ export interface RunKeys {
36
36
  uncachedKeys: Map<string, string>
37
37
  }
38
38
 
39
+ /** Where a run's keys are remembered: the selection's row, under its token. */
40
+ export interface RunKeysAt {
41
+ id: string
42
+ token: string
43
+ /**
44
+ * The token less each node's files, and each node's files token, when a
45
+ * remembered graph fixes every edge and config (`graph`): an edit then
46
+ * moves only the files of the projects it touched, and the keys it
47
+ * reaches are all that derive again (`readRunDelta`).
48
+ */
49
+ delta?: () => { global: string; files: Map<string, string> }
50
+ }
51
+
39
52
  /**
40
53
  * The memo row's name (the selection) and the token its keys stand for, or
41
54
  * undefined when a node's key reads something the token cannot name.
42
55
  */
43
- export function runKeysToken(args: RunKeysArgs): { id: string; token: string } | undefined {
56
+ export function runKeysToken(args: RunKeysArgs): RunKeysAt | undefined {
44
57
  if (args.hashCache.folds === undefined) return undefined
45
58
  const parts: string[] = [
46
59
  RUN_MEMO_VERSION,
@@ -52,8 +65,18 @@ export function runKeysToken(args: RunKeysArgs): { id: string; token: string } |
52
65
  // Which outputs git ignores decides which a reader may see (`readableOutputs`).
53
66
  [...args.gitFilesCache.ignoredOutputs].sort().join('\0'),
54
67
  ]
68
+ // A remembered graph's token names every node's edges and config, so
69
+ // each node adds only what may differ under it (4 of 8 ms at 1,206
70
+ // tasks, 2026-10-23).
71
+ const graph = args.hashCache.graph
72
+ parts.push(graph ?? '')
55
73
  const ids: string[] = []
56
74
  const envNames = new Set<string>()
75
+ // Tasks of one project mostly declare the same files and outputs, and the
76
+ // token is a function of that declaration (`filesToken`): one walk each,
77
+ // not one per task (5 of 12 ms at 1,206 tasks, 2026-10-23).
78
+ const tokens = new Map<string, string>()
79
+ const filesAt: number[] = []
57
80
  for (const node of args.nodes.values()) {
58
81
  const cfg = node.config
59
82
  if (isKeyless(cfg) || node.excludedUpstream !== undefined) return undefined
@@ -66,19 +89,30 @@ export function runKeysToken(args: RunKeysArgs): { id: string; token: string } |
66
89
  if ((inputs.workspaceFiles?.length ?? 0) > 0) return undefined
67
90
  // No positive entry resolves no files (`filesPlan`).
68
91
  if (inputs.files === undefined || inputs.files.some((e) => !e.startsWith('!'))) {
69
- const token = taskFilesToken({
70
- node,
71
- workspaceRoot: args.workspaceRoot,
72
- nestedProjectDirs: args.nestedDirsByProject.get(node.projectName) ?? [],
73
- gitFilesCache: args.gitFilesCache,
74
- hashCache: args.hashCache,
75
- })
76
- if (token === undefined) return undefined
92
+ const outputs = cfg.cache?.outputs
93
+ const declared = `${node.projectName}\0${JSON.stringify([inputs.files, outputs?.files, outputs?.workspaceFiles])}`
94
+ let token = tokens.get(declared)
95
+ if (token === undefined) {
96
+ token = taskFilesToken({
97
+ node,
98
+ workspaceRoot: args.workspaceRoot,
99
+ nestedProjectDirs: args.nestedDirsByProject.get(node.projectName) ?? [],
100
+ gitFilesCache: args.gitFilesCache,
101
+ hashCache: args.hashCache,
102
+ })
103
+ if (token === undefined) return undefined
104
+ tokens.set(declared, token)
105
+ }
77
106
  files = token
78
107
  }
79
108
  for (const name of inputs.env ?? []) envNames.add(name)
80
109
  }
81
110
  ids.push(node.id)
111
+ if (graph !== undefined) {
112
+ parts.push(node.id, node.requested ? '1' : '0', node.platform ?? '')
113
+ filesAt.push(parts.push(files) - 1)
114
+ continue
115
+ }
82
116
  parts.push(
83
117
  node.id,
84
118
  node.projectName,
@@ -88,6 +122,7 @@ export function runKeysToken(args: RunKeysArgs): { id: string; token: string } |
88
122
  node.requested ? '1' : '0',
89
123
  hashTaskConfig(cfg, args.hashCache),
90
124
  JSON.stringify(node.keyParts ?? []),
125
+ node.platform ?? '',
91
126
  JSON.stringify(node.addsToOutputsOf ?? []),
92
127
  JSON.stringify(node.outputsAddedToBy ?? []),
93
128
  files,
@@ -97,7 +132,26 @@ export function runKeysToken(args: RunKeysArgs): { id: string; token: string } |
97
132
  const value = process.env[name]
98
133
  parts.push(value === undefined ? name : `${name}=${value}`)
99
134
  }
100
- return { id: `\0run\0${xxh3hex(ids.join('\0'))}`, token: xxh3hex(parts.join('\0')) }
135
+ const selection = xxh3hex(ids.join('\0'))
136
+ const at: RunKeysAt = { id: `\0run\0${selection}`, token: xxh3hex(parts.join('\0')) }
137
+ if (graph !== undefined) {
138
+ let delta: { global: string; files: Map<string, string> } | undefined
139
+ at.delta = () => {
140
+ if (delta !== undefined) return delta
141
+ const files = new Map<string, string>()
142
+ const hashed = new Map<string, string>()
143
+ const rest = [...parts]
144
+ filesAt.forEach((i, n) => {
145
+ const token = parts[i]!
146
+ let h = hashed.get(token)
147
+ if (h === undefined) hashed.set(token, (h = xxh3hex(token)))
148
+ files.set(ids[n]!, h)
149
+ rest[i] = ''
150
+ })
151
+ return (delta = { global: `${selection}\0${xxh3hex(rest.join('\0'))}`, files })
152
+ }
153
+ }
154
+ return at
101
155
  }
102
156
 
103
157
  /** The keys remembered under `token`, mapped back onto this run's nodes. */
@@ -122,6 +176,84 @@ export function readRunKeys(
122
176
  return { stableKeys, uncachedKeys: new Map(parsed.u) }
123
177
  }
124
178
 
179
+ /**
180
+ * What a run's derivation leaves for the next one to start from: every key
181
+ * it made, in the order it made them, and which became stable or uncached
182
+ * keys. Those verdicts are a function of the graph, the configs and what
183
+ * git ignores, all in the global token, so a run under the same global
184
+ * token keeps them and derives only the keys an edit reaches.
185
+ */
186
+ export interface RunDerivation {
187
+ keys: Map<string, string>
188
+ stable: string[]
189
+ uncached: string[]
190
+ }
191
+
192
+ interface DeltaRow {
193
+ k: [string, string][]
194
+ f: [string, string][]
195
+ s: string[]
196
+ u: string[]
197
+ }
198
+
199
+ const deltaId = (at: RunKeysAt): string => `\0rund\0${at.id.slice('\0run\0'.length)}`
200
+
201
+ /**
202
+ * The last derivation under this run's global token, and the tasks whose
203
+ * keys may have moved since: each one whose files token did, and every task
204
+ * folding one of theirs. Undefined when no such derivation is remembered.
205
+ */
206
+ export function readRunDelta(
207
+ args: RunKeysArgs,
208
+ at: RunKeysAt,
209
+ ): { derivation: RunDerivation; dirty: Set<string> } | undefined {
210
+ if (at.delta === undefined) return undefined
211
+ const { global, files } = at.delta()
212
+ const row = args.hashCache.folds?.get(deltaId(at), global)
213
+ if (row === undefined) return undefined
214
+ let parsed: DeltaRow
215
+ try {
216
+ parsed = JSON.parse(row)
217
+ } catch {
218
+ return undefined
219
+ }
220
+ const was = new Map(parsed.f)
221
+ const dirty = new Set<string>()
222
+ for (const [id, token] of files) if (was.get(id) !== token) dirty.add(id)
223
+ const dependants = new Map<string, string[]>()
224
+ for (const node of args.nodes.values()) {
225
+ for (const dep of node.deps) {
226
+ const list = dependants.get(dep)
227
+ if (list === undefined) dependants.set(dep, [node.id])
228
+ else list.push(node.id)
229
+ }
230
+ }
231
+ const stack = [...dirty]
232
+ while (stack.length > 0) {
233
+ for (const up of dependants.get(stack.pop()!) ?? []) {
234
+ if (dirty.has(up)) continue
235
+ dirty.add(up)
236
+ stack.push(up)
237
+ }
238
+ }
239
+ return {
240
+ derivation: { keys: new Map(parsed.k), stable: parsed.s, uncached: parsed.u },
241
+ dirty,
242
+ }
243
+ }
244
+
245
+ export function writeRunDelta(args: RunKeysArgs, at: RunKeysAt, derivation: RunDerivation): void {
246
+ if (at.delta === undefined || args.hashCache.folds === undefined) return
247
+ const { global, files } = at.delta()
248
+ const row: DeltaRow = {
249
+ k: [...derivation.keys],
250
+ f: [...files],
251
+ s: derivation.stable,
252
+ u: derivation.uncached,
253
+ }
254
+ args.hashCache.folds.set(deltaId(at), global, JSON.stringify(row))
255
+ }
256
+
125
257
  export function writeRunKeys(
126
258
  args: RunKeysArgs,
127
259
  at: { id: string; token: string },
@@ -144,21 +276,25 @@ let identity: string | undefined
144
276
  * checkout is every module under `src/`, so an edit to the derivation from
145
277
  * source never reads back a key the old code made.
146
278
  */
147
- function codeIdentity(): string {
279
+ export function codeIdentity(): string {
148
280
  if (identity !== undefined) return identity
149
281
  // `Bun.main`, not `import.meta.dir`: `--minify` inlines the build
150
282
  // machine's source path, so an installed binary walked a `src/` that is
151
283
  // not there, threw, and lost every warm run's short circuit (2026-10-22).
284
+ // ctime and inode with the mtime, as the file hash memo keys: an npm
285
+ // tarball stamps every file 1985-10-26 and extraction keeps that mtime,
286
+ // so an upgrade that kept a file's size left its stamp alone, and `cp
287
+ // -p` does the same to a binary. No write can set ctime back.
152
288
  if (Bun.main.startsWith('/$bunfs/')) {
153
289
  const st = statSync(process.execPath)
154
- return (identity = `${process.execPath}:${st.size}:${st.mtimeMs}:${st.ino}`)
290
+ return (identity = `${process.execPath}:${st.size}:${st.mtimeMs}:${st.ctimeMs}:${st.ino}`)
155
291
  }
156
292
  const src = path.resolve(import.meta.dir, '..')
157
293
  const stamps: string[] = []
158
294
  for (const rel of readdirSync(src, { recursive: true, encoding: 'utf8' }).sort()) {
159
295
  if (!rel.endsWith('.ts')) continue
160
296
  const st = statSync(path.join(src, rel))
161
- stamps.push(`${rel}:${st.size}:${st.mtimeMs}`)
297
+ stamps.push(`${rel}:${st.size}:${st.mtimeMs}:${st.ctimeMs}:${st.ino}`)
162
298
  }
163
299
  return (identity = xxh3hex(stamps.join('\0')))
164
300
  }
@@ -0,0 +1,294 @@
1
+ // The run in progress, for `vx last --live` (2026-10-28): an agent that
2
+ // started `vx run` in the background asks what runs, what already failed
3
+ // and what it was warned of while the run goes on; the history holds a
4
+ // run only once it ended.
5
+ //
6
+ // The run answers on a unix socket beside its run lock (run-lock.ts),
7
+ // `<lock>.sock`: in this user's own lock directory, so no other user
8
+ // reaches it, and taken only while the lock is held, so a socket a killed
9
+ // run left is this run's to replace. Nothing is written for it: the run
10
+ // keeps which task started when (one Map entry per task) and builds the
11
+ // snapshot only when asked. Output is not kept; a reader that wants it
12
+ // follows (`--follow`), and a follower is fed only while it is attached
13
+ // and keeps up: one that falls a megabyte behind is let go, so a stuck
14
+ // reader never holds the run's memory or its event loop.
15
+ //
16
+ // The path is left with the lock, before the run's last steps, and a
17
+ // follower stays attached through `run:done`.
18
+ //
19
+ // The protocol is one request line, `snapshot` or `follow`, then the
20
+ // snapshot as one JSON line; a follower then gets each `WireEvent` as a
21
+ // line until `run:done`, and the run closes the connection.
22
+
23
+ import { unlinkSync } from 'node:fs'
24
+ import type { Socket, UnixSocketListener } from 'bun'
25
+ import { isGroupTask, type TaskNode } from '../graph/index.js'
26
+ import {
27
+ type EventBus,
28
+ type OutcomeView,
29
+ projectOutcome,
30
+ toWireEvent,
31
+ type WireEvent,
32
+ } from '../report/index.js'
33
+ import { runLockPath } from './run-lock.js'
34
+
35
+ /** One task as a live reader sees it. */
36
+ export interface LiveTask {
37
+ taskId: string
38
+ /** `waiting`, `running`, or the status it ended with. */
39
+ status: 'waiting' | 'running' | OutcomeView['status']
40
+ /** Epoch ms it started; null while it waits (and for a skip, which never starts). */
41
+ startedAt: number | null
42
+ /** Null until it ends. */
43
+ durationMs: number | null
44
+ }
45
+
46
+ /** What `vx last --live --format json` prints, the snapshot line. */
47
+ export interface LiveSnapshot {
48
+ live: { runId: string; command: string; pid: number; startedAt: number; elapsedMs: number }
49
+ tasks: LiveTask[]
50
+ warnings: string[]
51
+ }
52
+
53
+ /** Where the run of `workspaceRoot` answers: beside its run lock. */
54
+ export function liveSocketPath(workspaceRoot: string, lockDir?: string): string {
55
+ return liveSocketOf(runLockPath(workspaceRoot, lockDir))
56
+ }
57
+
58
+ /** The live socket beside the run lock at `lockPath`. */
59
+ export function liveSocketOf(lockPath: string): string {
60
+ return `${lockPath}.sock`
61
+ }
62
+
63
+ /** A follower this far behind is let go. */
64
+ const FOLLOW_BACKLOG = 1 << 20
65
+
66
+ interface Peer {
67
+ follow: boolean
68
+ /** Bytes the socket did not take yet. */
69
+ pending: Uint8Array[]
70
+ pendingBytes: number
71
+ /** End the connection once `pending` drains. */
72
+ ending: boolean
73
+ request: string
74
+ }
75
+
76
+ const encoder = new TextEncoder()
77
+
78
+ /**
79
+ * The sockets this process listens on. A signal exit is `process.exit`
80
+ * (signals.ts): `unlisten` never runs, and a second Ctrl-C left the
81
+ * socket beside the released lock. The `exit` event runs on every exit
82
+ * path, synchronously, as run-lock.ts's release does; this one runs
83
+ * first, so the path is left before the lock frees it for the next run.
84
+ */
85
+ const listening = new Set<string>()
86
+ let exitHooked = false
87
+ function unlinkOnExit(socketPath: string): void {
88
+ listening.add(socketPath)
89
+ if (exitHooked) return
90
+ exitHooked = true
91
+ process.prependListener('exit', () => {
92
+ for (const p of listening) {
93
+ try {
94
+ unlinkSync(p)
95
+ } catch {
96
+ // Gone already.
97
+ }
98
+ }
99
+ })
100
+ }
101
+
102
+ /**
103
+ * Serve the run's live state on `socketPath` until the returned close.
104
+ * Never fails the run: a socket that cannot be made (a path too long, a
105
+ * second run in this process holding it) leaves the run unanswered.
106
+ */
107
+ export function serveLive(opts: {
108
+ socketPath: string
109
+ bus: EventBus
110
+ runId: string
111
+ command: string
112
+ startedAt: number
113
+ nodes: () => Iterable<TaskNode>
114
+ warnings: () => readonly string[]
115
+ }): { unlisten(): void; dispose(): void } {
116
+ const started = new Map<string, number>()
117
+ const ended = new Map<
118
+ string,
119
+ OutcomeView['status'] | { status: OutcomeView['status']; durationMs: number }
120
+ >()
121
+ const peers = new Map<Socket<Peer>, Peer>()
122
+ let followers = 0
123
+
124
+ const flush = (sock: Socket<Peer>, peer: Peer): void => {
125
+ while (peer.pending.length > 0) {
126
+ const head = peer.pending[0]!
127
+ const n = sock.write(head)
128
+ if (n < 0) return
129
+ peer.pendingBytes -= n
130
+ if (n < head.length) {
131
+ peer.pending[0] = head.subarray(n)
132
+ return
133
+ }
134
+ peer.pending.shift()
135
+ }
136
+ if (peer.ending) sock.end()
137
+ }
138
+ const send = (sock: Socket<Peer>, peer: Peer, line: string): void => {
139
+ const bytes = encoder.encode(`${line}\n`)
140
+ peer.pending.push(bytes)
141
+ peer.pendingBytes += bytes.length
142
+ if (peer.follow && peer.pendingBytes > FOLLOW_BACKLOG) {
143
+ drop(sock, peer)
144
+ return
145
+ }
146
+ if (peer.pending.length === 1) flush(sock, peer)
147
+ }
148
+ const drop = (sock: Socket<Peer>, peer: Peer): void => {
149
+ if (peers.delete(sock) && peer.follow) followers--
150
+ sock.end()
151
+ }
152
+
153
+ const snapshot = (): LiveSnapshot => {
154
+ const now = Date.now()
155
+ const tasks: LiveTask[] = []
156
+ for (const node of opts.nodes()) {
157
+ if (isGroupTask(node)) continue
158
+ const at = started.get(node.id) ?? null
159
+ const end = ended.get(node.id)
160
+ tasks.push(
161
+ end !== undefined && typeof end === 'object'
162
+ ? { taskId: node.id, status: end.status, startedAt: at, durationMs: end.durationMs }
163
+ : at !== null
164
+ ? { taskId: node.id, status: 'running', startedAt: at, durationMs: null }
165
+ : { taskId: node.id, status: 'waiting', startedAt: null, durationMs: null },
166
+ )
167
+ }
168
+ const rank = (t: LiveTask): number =>
169
+ t.status === 'running' ? 0 : t.status === 'waiting' ? 2 : 1
170
+ tasks.sort(
171
+ (a, b) => rank(a) - rank(b) || (a.taskId < b.taskId ? -1 : a.taskId > b.taskId ? 1 : 0),
172
+ )
173
+ return {
174
+ live: {
175
+ runId: opts.runId,
176
+ command: opts.command,
177
+ pid: process.pid,
178
+ startedAt: opts.startedAt,
179
+ elapsedMs: now - opts.startedAt,
180
+ },
181
+ tasks,
182
+ warnings: opts.warnings().map((w) => Bun.stripANSI(w)),
183
+ }
184
+ }
185
+
186
+ const unsubscribe = opts.bus.subscribe((event) => {
187
+ if (event.kind === 'task:start') started.set(event.node.id, Date.now())
188
+ else if (event.kind === 'task:complete') {
189
+ ended.set(event.node.id, {
190
+ status: event.outcome.status,
191
+ durationMs: event.outcome.durationMs,
192
+ })
193
+ }
194
+ if (followers === 0) return
195
+ if (
196
+ (event.kind === 'task:start' || event.kind === 'task:complete') &&
197
+ isGroupTask(event.node)
198
+ ) {
199
+ return
200
+ }
201
+ const wire: WireEvent =
202
+ event.kind === 'task:complete'
203
+ ? { kind: 'task:complete', outcome: projectOutcome(event.outcome) }
204
+ : toWireEvent(event)
205
+ const line = JSON.stringify(wire)
206
+ for (const [sock, peer] of peers) {
207
+ if (!peer.follow) continue
208
+ send(sock, peer, line)
209
+ if (event.kind === 'run:done') peer.ending = true
210
+ if (peer.ending && peer.pending.length === 0) drop(sock, peer)
211
+ }
212
+ })
213
+
214
+ let listener: UnixSocketListener<Peer> | undefined
215
+ try {
216
+ unlinkSync(opts.socketPath)
217
+ } catch {
218
+ // None there: the usual case.
219
+ }
220
+ try {
221
+ const made = Bun.listen<Peer>({
222
+ unix: opts.socketPath,
223
+ socket: {
224
+ open(sock) {
225
+ const peer: Peer = {
226
+ follow: false,
227
+ pending: [],
228
+ pendingBytes: 0,
229
+ ending: false,
230
+ request: '',
231
+ }
232
+ sock.data = peer
233
+ peers.set(sock, peer)
234
+ },
235
+ data(sock, chunk) {
236
+ const peer = sock.data
237
+ if (peer.request.includes('\n')) return
238
+ peer.request += chunk.toString()
239
+ if (!peer.request.includes('\n')) return
240
+ const what = peer.request.trim()
241
+ send(sock, peer, JSON.stringify(snapshot()))
242
+ if (what === 'follow') {
243
+ peer.follow = true
244
+ followers++
245
+ } else {
246
+ peer.ending = true
247
+ if (peer.pending.length === 0) drop(sock, peer)
248
+ }
249
+ },
250
+ drain(sock) {
251
+ flush(sock, sock.data)
252
+ },
253
+ close(sock) {
254
+ const peer = peers.get(sock)
255
+ if (peer !== undefined) drop(sock, peer)
256
+ },
257
+ error(sock) {
258
+ const peer = peers.get(sock)
259
+ if (peer !== undefined) drop(sock, peer)
260
+ },
261
+ },
262
+ })
263
+ // The socket must never keep a run's process alive past its end.
264
+ made.unref()
265
+ listener = made
266
+ unlinkOnExit(opts.socketPath)
267
+ } catch {
268
+ listener = undefined
269
+ }
270
+
271
+ return {
272
+ // With the run lock: the next run may take the path the moment the lock
273
+ // is free, so this run stops taking connections and leaves the path
274
+ // first. Followers already attached stay, through `run:done`.
275
+ unlisten: () => {
276
+ if (listener === undefined) return
277
+ listener.stop()
278
+ listener = undefined
279
+ listening.delete(opts.socketPath)
280
+ try {
281
+ unlinkSync(opts.socketPath)
282
+ } catch {
283
+ // Gone already.
284
+ }
285
+ },
286
+ dispose: () => {
287
+ unsubscribe()
288
+ for (const [sock, peer] of [...peers]) {
289
+ if (peer.pending.length > 0) peer.ending = true
290
+ else drop(sock, peer)
291
+ }
292
+ },
293
+ }
294
+ }
@@ -126,6 +126,9 @@ export interface RunLockOptions {
126
126
  signal?: AbortSignal | undefined
127
127
  }
128
128
 
129
+ /** Releases a taken run lock; `path` is its directory (`runLockPath`). */
130
+ export type RunLockRelease = (() => Promise<void>) & { readonly path: string }
131
+
129
132
  /**
130
133
  * The lock directory for a workspace root: stable across this user's runs,
131
134
  * private to this machine. Keyed by the REAL path: a CLI's cwd comes back
@@ -323,16 +326,18 @@ async function leave(lockDir: string, entry: string): Promise<void> {
323
326
  /**
324
327
  * Take the workspace's run lock, waiting for a live holder in another
325
328
  * process to release it. Resolves to the release function; the caller
326
- * runs it on every exit path.
329
+ * runs it on every exit path. It carries the lock's `path`, so a run's
330
+ * live socket beside it costs no second realpath and hash.
327
331
  */
328
332
  export async function acquireRunLock(
329
333
  workspaceRoot: string,
330
334
  opts: RunLockOptions,
331
- ): Promise<() => Promise<void>> {
335
+ ): Promise<RunLockRelease> {
332
336
  const lockDir = runLockPath(workspaceRoot, opts.dir)
333
337
  // A task's own process (`vx cache clean` in a task) would wait for the
334
338
  // run that started it, which holds the lock until that task ends.
335
- const outer = process.env[VX_RUN_WORKSPACE_ENV]
339
+ // Empty is unset: resolved, '' named the cwd.
340
+ const outer = process.env[VX_RUN_WORKSPACE_ENV] || undefined
336
341
  if (outer !== undefined && runLockPath(outer, opts.dir) === lockDir) {
337
342
  throw new UserError(
338
343
  `this vx runs inside task ${process.env[VX_RUN_TASK_ENV] ?? '<unknown>'} of a vx run on this workspace, which holds its run lock until it ends — run it before or after that run, not from one of its tasks`,
@@ -363,12 +368,13 @@ export async function acquireRunLock(
363
368
  if (takers.get(lockDir) === taker) takers.delete(lockDir)
364
369
  }
365
370
  }
371
+ const held: RunLockRelease = Object.assign(release, { path: lockDir })
366
372
  for (;;) {
367
- if (opts.signal?.aborted === true) return async () => {}
373
+ if (opts.signal?.aborted === true) return Object.assign(async () => {}, { path: lockDir })
368
374
  const here = heldHere.get(lockDir) ?? 0
369
375
  if (here > 0) {
370
376
  heldHere.set(lockDir, here + 1)
371
- return release
377
+ return held
372
378
  }
373
379
  let h: Holder | 'free' | null
374
380
  try {
@@ -379,7 +385,7 @@ export async function acquireRunLock(
379
385
  heldHere.set(lockDir, 1)
380
386
  takers.set(lockDir, entry)
381
387
  releaseOnExit()
382
- return release
388
+ return held
383
389
  }
384
390
  h = await holder(lockDir)
385
391
  if (h === 'free') continue
@@ -402,7 +408,7 @@ export async function acquireRunLock(
402
408
  opts.log(
403
409
  `[vx] no run lock for this workspace (${(err as Error).message}${hint}) — another vx run on it at the same time may race this one`,
404
410
  )
405
- return async () => {}
411
+ return Object.assign(async () => {}, { path: lockDir })
406
412
  }
407
413
  if (h !== null && !said && Date.now() - started >= SAY_AFTER_MS) {
408
414
  said = true
@@ -7,7 +7,7 @@
7
7
  // tests/run-records.test.ts holds to this one.
8
8
 
9
9
  import type { InvocationRecord, RunRecord } from '../cache/index.js'
10
- import { isGroupTask, type TaskOutcome } from '../graph/index.js'
10
+ import { isGroupTask, taskDefinition, type TaskOutcome } from '../graph/index.js'
11
11
  import { VERSION } from '../version.js'
12
12
  import type { CiContext, GitContext, HostContext } from './run-context.js'
13
13
  import { taskTelemetryOf, type TaskTelemetry } from './telemetry.js'
@@ -83,18 +83,15 @@ export function assembleRunRecords(input: RunRecordsInput): RunRecords {
83
83
  if (input.withTelemetry) telemetryTasks.push(taskTelemetryOf(o))
84
84
  const blockedBy = o.blockedBy ?? input.tainted?.get(o.node.id)
85
85
  const remoteError = remoteErrorOf(input.remoteTraffic, o.hash)
86
- runs.push({
87
- ...(o.hash !== undefined ? { hash: o.hash } : {}),
86
+ // Built field by field: a spread per optional field was ~2 µs of each
87
+ // of a 1,200-hit warm run's rows.
88
+ const hit = isCacheHit(o.status)
89
+ const r: RunRecord = {
88
90
  project: o.node.projectName,
89
91
  task: o.node.taskName,
90
92
  status: o.status,
91
93
  exitCode: o.exitCode,
92
94
  durationMs: o.durationMs,
93
- // A run forwards its args to the tasks it was asked for, never to a
94
- // dependency: the row says which ran with them.
95
- ...(input.forwardArgs !== undefined && input.forwardArgs.length > 0 && o.node.requested
96
- ? { forwardArgs: input.forwardArgs }
97
- : {}),
98
95
  // Anchor to the REAL per-task wall-clock window: run-start wall time +
99
96
  // the task's ns offset (captured for hits and executed tasks alike).
100
97
  // The `end - duration` fallback applies to outcomes without an offset —
@@ -111,28 +108,37 @@ export function assembleRunRecords(input: RunRecordsInput): RunRecords {
111
108
  ? startedAtMs + Math.round(Number(o.wallclockEndNs) / 1e6)
112
109
  : endedAtMs,
113
110
  runId,
114
- ...(o.cpuMs !== undefined ? { cpuMs: o.cpuMs } : {}),
115
- ...(o.peakRssBytes !== undefined ? { peakRssBytes: o.peakRssBytes } : {}),
116
- ...(o.peakCores !== undefined ? { peakCores: o.peakCores } : {}),
117
- ...(o.avgRssBytes !== undefined ? { avgRssBytes: o.avgRssBytes } : {}),
118
- ...(o.where !== undefined ? { placement: o.where } : {}),
119
- ...(o.whereCores !== undefined ? { placementCores: o.whereCores } : {}),
120
- ...(o.whereMemoryBytes !== undefined ? { placementMemoryBytes: o.whereMemoryBytes } : {}),
121
- ...(o.wallclockStartNs !== undefined ? { wallclockStartNs: o.wallclockStartNs } : {}),
122
- ...(o.wallclockEndNs !== undefined ? { wallclockEndNs: o.wallclockEndNs } : {}),
123
- cacheHit: isCacheHit(o.status),
124
- ...(isCacheHit(o.status) ? { restored: o.restored === true } : {}),
125
- ...(o.attempts !== undefined ? { attempts: o.attempts } : {}),
111
+ cacheHit: hit,
126
112
  cached: o.node.config.cache !== undefined,
127
- // A task that ran behind a failure names it too, so the flakiness
128
- // projection counts its outcome on no key (failure-mode.ts).
129
- ...(blockedBy !== undefined ? { blockedBy } : {}),
130
- ...(o.timedOut === true ? { timedOut: true as const } : {}),
131
- ...(o.sandboxViolations !== undefined ? { sandboxViolations: o.sandboxViolations } : {}),
132
- ...(o.notReady !== undefined ? { notReady: o.notReady } : {}),
133
- ...(remoteError !== undefined ? { remoteError } : {}),
134
- ...(o.keylessBy !== undefined ? { keylessBy: o.keylessBy } : {}),
135
- })
113
+ }
114
+ if (o.hash !== undefined) r.hash = o.hash
115
+ // A run forwards its args to the tasks it was asked for, never to a
116
+ // dependency: the row says which ran with them.
117
+ if (input.forwardArgs !== undefined && input.forwardArgs.length > 0 && o.node.requested)
118
+ r.forwardArgs = input.forwardArgs
119
+ if (o.cpuMs !== undefined) r.cpuMs = o.cpuMs
120
+ if (o.peakRssBytes !== undefined) r.peakRssBytes = o.peakRssBytes
121
+ if (o.peakCores !== undefined) r.peakCores = o.peakCores
122
+ if (o.avgRssBytes !== undefined) r.avgRssBytes = o.avgRssBytes
123
+ if (o.where !== undefined) r.placement = o.where
124
+ if (o.whereCores !== undefined) r.placementCores = o.whereCores
125
+ if (o.whereMemoryBytes !== undefined) r.placementMemoryBytes = o.whereMemoryBytes
126
+ if (o.cpuShare !== undefined) r.cpuShare = o.cpuShare
127
+ if (o.wallclockStartNs !== undefined) r.wallclockStartNs = o.wallclockStartNs
128
+ if (o.wallclockEndNs !== undefined) r.wallclockEndNs = o.wallclockEndNs
129
+ if (hit) r.restored = o.restored === true
130
+ if (o.attempts !== undefined) r.attempts = o.attempts
131
+ // A task that ran behind a failure names it too, so the flakiness
132
+ // projection counts its outcome on no key (failure-mode.ts).
133
+ if (blockedBy !== undefined) r.blockedBy = blockedBy
134
+ if (o.timedOut === true) r.timedOut = true
135
+ if (o.sandboxViolations !== undefined) r.sandboxViolations = o.sandboxViolations
136
+ if (o.notReady !== undefined) r.notReady = o.notReady
137
+ if (remoteError !== undefined) r.remoteError = remoteError
138
+ if (o.keylessBy !== undefined) r.keylessBy = o.keylessBy
139
+ const def = taskDefinition(o.node)
140
+ if (def !== undefined) r.def = def
141
+ runs.push(r)
136
142
  if (o.status === 'failed') failedCount++
137
143
  if (o.status === 'cache-hit') hitLocalCount++
138
144
  if (o.status === 'cache-hit-remote') hitRemoteCount++