@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,300 @@
1
+ // The run key memo: every up-front key a warm run derived, read back whole
2
+ // when nothing any of them folds has moved. Deriving them again cost 250 ms
3
+ // of a 1,070 ms warm run at 9,600 tasks even with every fold memo row
4
+ // answering (2026-10-22): the per-task walk itself, not the files.
5
+ //
6
+ // The token is everything `deriveStableKeys` reads: each node's id, edges,
7
+ // resolved config, plugin key parts and files token, the declared env
8
+ // values, the forward args and the code that derives. A run the token cannot
9
+ // vouch for (a keyless or runtime-probed task, a declaration no files token
10
+ // covers) derives as before.
11
+
12
+ import { readdirSync, statSync } from 'node:fs'
13
+ import path from 'node:path'
14
+ import type { GitFilesCache } from '../cache/index.js'
15
+ import type { TaskNode } from '../graph/index.js'
16
+ import { xxh3hex } from '../util/index.js'
17
+ import type { StableKey } from './stable-keys.js'
18
+ import { hashTaskConfig, isKeyless, taskFilesToken, type HashCache } from './task-hash.js'
19
+
20
+ /** Moves every remembered run: bump it when what a token stands for changes. */
21
+ const RUN_MEMO_VERSION = 'run-memo-v1'
22
+
23
+ export interface RunKeysArgs {
24
+ nodes: Map<string, TaskNode>
25
+ workspaceRoot: string
26
+ claimedFiles: readonly string[]
27
+ forwardArgs?: readonly string[] | undefined
28
+ nestedDirsByProject: Map<string, string[]>
29
+ gitFilesCache: GitFilesCache
30
+ hashCache: HashCache
31
+ }
32
+
33
+ /** What a run's up-front pass yields, as the memo holds it. */
34
+ export interface RunKeys {
35
+ stableKeys: StableKey[]
36
+ uncachedKeys: Map<string, string>
37
+ }
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
+
52
+ /**
53
+ * The memo row's name (the selection) and the token its keys stand for, or
54
+ * undefined when a node's key reads something the token cannot name.
55
+ */
56
+ export function runKeysToken(args: RunKeysArgs): RunKeysAt | undefined {
57
+ if (args.hashCache.folds === undefined) return undefined
58
+ const parts: string[] = [
59
+ RUN_MEMO_VERSION,
60
+ codeIdentity(),
61
+ args.workspaceRoot,
62
+ JSON.stringify(args.forwardArgs ?? []),
63
+ JSON.stringify([...args.claimedFiles].sort()),
64
+ JSON.stringify([...args.nestedDirsByProject]),
65
+ // Which outputs git ignores decides which a reader may see (`readableOutputs`).
66
+ [...args.gitFilesCache.ignoredOutputs].sort().join('\0'),
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 ?? '')
73
+ const ids: string[] = []
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[] = []
80
+ for (const node of args.nodes.values()) {
81
+ const cfg = node.config
82
+ if (isKeyless(cfg) || node.excludedUpstream !== undefined) return undefined
83
+ const inputs = cfg.inputs
84
+ if ((inputs?.runtime?.length ?? 0) > 0 || (inputs?.workspaceRuntime?.length ?? 0) > 0) {
85
+ return undefined
86
+ }
87
+ let files = ''
88
+ if (inputs !== undefined) {
89
+ if ((inputs.workspaceFiles?.length ?? 0) > 0) return undefined
90
+ // No positive entry resolves no files (`filesPlan`).
91
+ if (inputs.files === undefined || inputs.files.some((e) => !e.startsWith('!'))) {
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
+ }
106
+ files = token
107
+ }
108
+ for (const name of inputs.env ?? []) envNames.add(name)
109
+ }
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
+ }
116
+ parts.push(
117
+ node.id,
118
+ node.projectName,
119
+ node.projectDir,
120
+ node.deps.join('\u0001'),
121
+ (node.orderOnly ?? []).join('\u0001'),
122
+ node.requested ? '1' : '0',
123
+ hashTaskConfig(cfg, args.hashCache),
124
+ JSON.stringify(node.keyParts ?? []),
125
+ node.platform ?? '',
126
+ JSON.stringify(node.addsToOutputsOf ?? []),
127
+ JSON.stringify(node.outputsAddedToBy ?? []),
128
+ files,
129
+ )
130
+ }
131
+ for (const name of [...envNames].sort()) {
132
+ const value = process.env[name]
133
+ parts.push(value === undefined ? name : `${name}=${value}`)
134
+ }
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
155
+ }
156
+
157
+ /** The keys remembered under `token`, mapped back onto this run's nodes. */
158
+ export function readRunKeys(
159
+ args: RunKeysArgs,
160
+ at: { id: string; token: string },
161
+ ): RunKeys | undefined {
162
+ const row = args.hashCache.folds?.get(at.id, at.token)
163
+ if (row === undefined) return undefined
164
+ let parsed: { s: [string, string][]; u: [string, string][] }
165
+ try {
166
+ parsed = JSON.parse(row)
167
+ } catch {
168
+ return undefined
169
+ }
170
+ const stableKeys: StableKey[] = []
171
+ for (const [id, hash] of parsed.s) {
172
+ const node = args.nodes.get(id)
173
+ if (node === undefined) return undefined
174
+ stableKeys.push({ hash, node })
175
+ }
176
+ return { stableKeys, uncachedKeys: new Map(parsed.u) }
177
+ }
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
+
257
+ export function writeRunKeys(
258
+ args: RunKeysArgs,
259
+ at: { id: string; token: string },
260
+ keys: RunKeys,
261
+ ): void {
262
+ args.hashCache.folds?.set(
263
+ at.id,
264
+ at.token,
265
+ JSON.stringify({
266
+ s: keys.stableKeys.map((k) => [k.node.id, k.hash]),
267
+ u: [...keys.uncachedKeys],
268
+ }),
269
+ )
270
+ }
271
+
272
+ let identity: string | undefined
273
+
274
+ /**
275
+ * The code that derives the keys: a compiled binary is its file; a source
276
+ * checkout is every module under `src/`, so an edit to the derivation from
277
+ * source never reads back a key the old code made.
278
+ */
279
+ export function codeIdentity(): string {
280
+ if (identity !== undefined) return identity
281
+ // `Bun.main`, not `import.meta.dir`: `--minify` inlines the build
282
+ // machine's source path, so an installed binary walked a `src/` that is
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.
288
+ if (Bun.main.startsWith('/$bunfs/')) {
289
+ const st = statSync(process.execPath)
290
+ return (identity = `${process.execPath}:${st.size}:${st.mtimeMs}:${st.ctimeMs}:${st.ino}`)
291
+ }
292
+ const src = path.resolve(import.meta.dir, '..')
293
+ const stamps: string[] = []
294
+ for (const rel of readdirSync(src, { recursive: true, encoding: 'utf8' }).sort()) {
295
+ if (!rel.endsWith('.ts')) continue
296
+ const st = statSync(path.join(src, rel))
297
+ stamps.push(`${rel}:${st.size}:${st.mtimeMs}:${st.ctimeMs}:${st.ino}`)
298
+ }
299
+ return (identity = xxh3hex(stamps.join('\0')))
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