@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.
- package/README.md +39 -50
- package/launcher.cjs +9 -2
- package/launcher.sh +2 -2
- package/package.json +5 -5
- package/schemas/cache.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/info.json +16 -2
- package/schemas/last.json +127 -5
- package/schemas/plan.json +28 -2
- package/schemas/show.json +10 -1
- package/skills/vx/SKILL.md +19 -3
- package/src/bin.ts +54 -13
- package/src/cache/archive.ts +324 -23
- package/src/cache/cache.ts +330 -65
- package/src/cache/git-inputs.ts +274 -97
- package/src/cache/index.ts +7 -2
- package/src/cache/inputs.ts +515 -83
- package/src/cache/key-fold.ts +15 -6
- package/src/cache/layer.ts +74 -11
- package/src/cache/layered-cache.ts +154 -59
- package/src/cache/linked-inputs.ts +1 -1
- package/src/cache/output-index.ts +44 -14
- package/src/cache/plugin-cache.ts +49 -23
- package/src/cache/remote-hits.ts +72 -0
- package/src/cache/remote-lane.ts +100 -15
- package/src/cache/run-history.ts +77 -23
- package/src/cache/run-store.ts +11 -10
- package/src/cache/schema.ts +42 -6
- package/src/cache/tar-stream.ts +5 -0
- package/src/cache/umask.ts +1 -2
- package/src/cli/cache.ts +58 -27
- package/src/cli/completions.ts +12 -5
- package/src/cli/core-alias.ts +5 -2
- package/src/cli/docs.ts +42 -16
- package/src/cli/help.ts +62 -25
- package/src/cli/index.ts +79 -45
- package/src/cli/info.ts +12 -6
- package/src/cli/init.ts +222 -34
- package/src/cli/last.ts +443 -47
- package/src/cli/lock.ts +197 -52
- package/src/cli/plan-format.ts +91 -49
- package/src/cli/plugin-commands.ts +160 -11
- package/src/cli/plugin-templates.ts +13 -13
- package/src/cli/registry.ts +209 -0
- package/src/cli/run.ts +111 -41
- package/src/cli/select.ts +148 -49
- package/src/cli/show.ts +59 -16
- package/src/cli/task-verb.ts +16 -6
- package/src/cli/upgrade.ts +155 -54
- package/src/cli/watch-cycle.ts +10 -5
- package/src/cli/watch-filter.ts +71 -3
- package/src/cli/watch-fs.ts +22 -6
- package/src/cli/watch-judge.ts +48 -6
- package/src/cli/watch.ts +163 -42
- package/src/cli/why.ts +211 -73
- package/src/cli/workspace-config.ts +54 -15
- package/src/config.ts +13 -1
- package/src/exec/env.ts +7 -2
- package/src/exec/executor.ts +262 -15
- package/src/exec/index.ts +8 -2
- package/src/exec/kill-tree.ts +15 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/proc-sample.ts +70 -6
- package/src/exec/runner.ts +256 -53
- package/src/exec/sandbox-binds.ts +7 -3
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +222 -28
- package/src/exec/sandbox-violations.ts +52 -20
- package/src/graph/critical-path.ts +79 -0
- package/src/graph/default-build.ts +19 -0
- package/src/graph/definition.ts +38 -0
- package/src/graph/folds.ts +38 -5
- package/src/graph/index.ts +4 -1
- package/src/graph/priorities.ts +74 -8
- package/src/graph/scheduler.ts +326 -65
- package/src/graph/task-graph.ts +228 -35
- package/src/handoff.ts +97 -9
- package/src/history/failure-mode.ts +6 -3
- package/src/history/history.ts +268 -50
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +142 -45
- package/src/index.ts +42 -10
- package/src/orchestrator/admission.ts +10 -6
- package/src/orchestrator/affected-tasks.ts +11 -6
- package/src/orchestrator/deferred-outputs.ts +2 -0
- package/src/orchestrator/doctor.ts +134 -42
- package/src/orchestrator/execute-args.ts +6 -0
- package/src/orchestrator/execute-task.ts +155 -42
- package/src/orchestrator/fingerprint-watch.ts +1 -1
- package/src/orchestrator/graph-memo.ts +140 -0
- package/src/orchestrator/graph-stage.ts +18 -1
- package/src/orchestrator/held-servers.ts +86 -0
- package/src/orchestrator/hit-restore.ts +227 -122
- package/src/orchestrator/index.ts +20 -3
- package/src/orchestrator/local-floor.ts +198 -0
- package/src/orchestrator/local-shortcircuit.ts +51 -8
- package/src/orchestrator/lockfile-claim.ts +96 -16
- package/src/orchestrator/miss-save.ts +52 -12
- package/src/orchestrator/options.ts +8 -6
- package/src/orchestrator/output-host.ts +43 -14
- package/src/orchestrator/output-log.ts +90 -10
- package/src/orchestrator/persistent-task.ts +97 -28
- package/src/orchestrator/persistent.ts +16 -4
- package/src/orchestrator/placement.ts +257 -14
- package/src/orchestrator/plan.ts +64 -7
- package/src/orchestrator/plugin-host.ts +230 -40
- package/src/orchestrator/plugin.ts +150 -37
- package/src/orchestrator/prepare.ts +178 -32
- package/src/orchestrator/projects.ts +65 -40
- package/src/orchestrator/remote-prefetch.ts +119 -25
- package/src/orchestrator/run-artifacts.ts +95 -32
- package/src/orchestrator/run-context.ts +39 -41
- package/src/orchestrator/run-failures.ts +65 -23
- package/src/orchestrator/run-id.ts +6 -3
- package/src/orchestrator/run-keys.ts +300 -0
- package/src/orchestrator/run-live.ts +294 -0
- package/src/orchestrator/run-lock.ts +13 -7
- package/src/orchestrator/run-records.ts +35 -23
- package/src/orchestrator/run-report.ts +3 -3
- package/src/orchestrator/run-steps.ts +58 -22
- package/src/orchestrator/run-stop.ts +22 -5
- package/src/orchestrator/run.ts +409 -79
- package/src/orchestrator/sandbox-fix.ts +7 -5
- package/src/orchestrator/sandbox-request.ts +78 -22
- package/src/orchestrator/shell-verdict.ts +5 -5
- package/src/orchestrator/signals.ts +26 -1
- package/src/orchestrator/stable-keys.ts +92 -8
- package/src/orchestrator/steps.ts +2 -2
- package/src/orchestrator/task-artifacts.ts +206 -0
- package/src/orchestrator/task-hash.ts +89 -14
- package/src/orchestrator/task-log-buffer.ts +72 -36
- package/src/orchestrator/task-process.ts +22 -0
- package/src/orchestrator/telemetry-host.ts +24 -3
- package/src/orchestrator/telemetry.ts +113 -38
- package/src/orchestrator/upstream.ts +15 -0
- package/src/report/events.ts +32 -13
- package/src/report/forecast.ts +36 -35
- package/src/report/framed-output.ts +173 -46
- package/src/report/index.ts +1 -0
- package/src/report/logger.ts +217 -186
- package/src/report/path-links.ts +12 -4
- package/src/report/plain-output.ts +13 -5
- package/src/report/status-line.ts +94 -46
- package/src/report/summary.ts +174 -38
- package/src/report/terminal-signals.ts +13 -2
- package/src/settings.ts +14 -6
- package/src/util/bun-version.ts +4 -1
- package/src/util/colors.ts +20 -3
- package/src/util/config-frame.ts +41 -4
- package/src/{graph → util}/dependency-spec.ts +22 -13
- package/src/util/edit-distance.ts +13 -1
- package/src/util/env-flag.ts +9 -0
- package/src/util/errors.ts +59 -3
- package/src/util/essential-env.ts +57 -0
- package/src/util/git-ignored.ts +1 -1
- package/src/util/hash.ts +35 -0
- package/src/util/hide-env.ts +45 -5
- package/src/util/index.ts +22 -5
- package/src/util/num.ts +8 -0
- package/src/util/paths.ts +121 -21
- package/src/util/report.ts +6 -1
- package/src/util/secret-mask.ts +62 -1
- package/src/util/secrets.ts +7 -2
- package/src/util/size.ts +11 -3
- package/src/util/tail.ts +6 -1
- package/src/util/timing.ts +13 -1
- package/src/util/ui.ts +65 -4
- package/src/util/which.ts +1 -1
- package/src/util/write-file.ts +67 -0
- package/src/workspace/affected.ts +710 -107
- package/src/workspace/bracket-literal.ts +84 -0
- package/src/workspace/canonical-dir.ts +37 -0
- package/src/workspace/config-bytecode.ts +282 -0
- package/src/workspace/config-eval.ts +29 -8
- package/src/workspace/config-imports.ts +199 -58
- package/src/workspace/config-schema.ts +242 -39
- package/src/workspace/filter.ts +37 -8
- package/src/workspace/index.ts +11 -2
- package/src/workspace/json-data.ts +3 -4
- package/src/workspace/lockfile.ts +57 -8
- package/src/workspace/migration.ts +72 -49
- package/src/workspace/package-graph.ts +5 -4
- package/src/workspace/project-loader.ts +314 -47
- package/src/workspace/repo-id.ts +1 -1
- package/src/workspace/workspace.ts +176 -33
- 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<
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|