@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.
- package/README.md +29 -22
- 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 +112 -5
- package/schemas/plan.json +28 -2
- package/schemas/show.json +10 -1
- package/skills/vx/SKILL.md +18 -2
- package/src/bin.ts +53 -12
- package/src/cache/archive.ts +324 -23
- package/src/cache/cache.ts +268 -53
- package/src/cache/git-inputs.ts +219 -87
- package/src/cache/index.ts +3 -2
- package/src/cache/inputs.ts +460 -78
- package/src/cache/key-fold.ts +15 -6
- package/src/cache/layer.ts +49 -9
- package/src/cache/layered-cache.ts +123 -58
- package/src/cache/linked-inputs.ts +1 -1
- package/src/cache/output-index.ts +19 -3
- package/src/cache/plugin-cache.ts +49 -23
- package/src/cache/remote-hits.ts +72 -0
- package/src/cache/remote-lane.ts +90 -14
- package/src/cache/run-history.ts +70 -23
- package/src/cache/run-store.ts +11 -10
- package/src/cache/schema.ts +25 -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/docs.ts +42 -16
- package/src/cli/help.ts +58 -19
- package/src/cli/index.ts +79 -33
- package/src/cli/info.ts +12 -6
- package/src/cli/init.ts +186 -31
- package/src/cli/last.ts +439 -45
- package/src/cli/lock.ts +197 -52
- package/src/cli/plan-format.ts +91 -49
- package/src/cli/plugin-commands.ts +70 -14
- package/src/cli/plugin-templates.ts +13 -13
- package/src/cli/registry.ts +209 -0
- package/src/cli/run.ts +109 -34
- package/src/cli/select.ts +143 -46
- package/src/cli/show.ts +59 -16
- package/src/cli/task-verb.ts +16 -6
- package/src/cli/upgrade.ts +151 -49
- 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 +154 -10
- package/src/exec/index.ts +6 -2
- package/src/exec/kill-tree.ts +15 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/proc-sample.ts +3 -1
- package/src/exec/runner.ts +253 -54
- package/src/exec/sandbox-binds.ts +7 -3
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +211 -24
- 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 +270 -60
- 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 +167 -61
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +90 -30
- package/src/index.ts +30 -13
- 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 +4 -1
- package/src/orchestrator/execute-task.ts +130 -38
- 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 +1 -1
- package/src/orchestrator/hit-restore.ts +217 -119
- package/src/orchestrator/index.ts +4 -3
- package/src/orchestrator/local-floor.ts +119 -25
- package/src/orchestrator/local-shortcircuit.ts +28 -4
- package/src/orchestrator/lockfile-claim.ts +96 -16
- package/src/orchestrator/miss-save.ts +42 -8
- 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 +84 -29
- package/src/orchestrator/persistent.ts +16 -4
- package/src/orchestrator/placement.ts +185 -17
- package/src/orchestrator/plan.ts +64 -7
- package/src/orchestrator/plugin-host.ts +214 -42
- package/src/orchestrator/plugin.ts +113 -37
- package/src/orchestrator/prepare.ts +168 -30
- 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 +149 -13
- package/src/orchestrator/run-live.ts +294 -0
- package/src/orchestrator/run-lock.ts +13 -7
- package/src/orchestrator/run-records.ts +35 -29
- 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 +331 -88
- package/src/orchestrator/sandbox-fix.ts +7 -5
- package/src/orchestrator/sandbox-request.ts +70 -20
- 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 +90 -26
- package/src/orchestrator/task-hash.ts +78 -13
- package/src/orchestrator/task-log-buffer.ts +72 -36
- package/src/orchestrator/task-process.ts +7 -1
- package/src/orchestrator/telemetry-host.ts +22 -2
- package/src/orchestrator/telemetry.ts +53 -21
- package/src/orchestrator/upstream.ts +15 -0
- package/src/report/events.ts +32 -13
- package/src/report/forecast.ts +31 -9
- package/src/report/framed-output.ts +141 -50
- package/src/report/index.ts +1 -0
- package/src/report/logger.ts +170 -179
- package/src/report/path-links.ts +12 -4
- package/src/report/plain-output.ts +13 -5
- package/src/report/status-line.ts +81 -53
- package/src/report/summary.ts +114 -32
- package/src/report/terminal-signals.ts +13 -2
- package/src/settings.ts +7 -6
- package/src/util/bun-version.ts +4 -1
- package/src/util/colors.ts +4 -2
- 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 +21 -5
- package/src/util/num.ts +8 -0
- package/src/util/paths.ts +91 -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 +61 -3
- 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 +17 -11
- 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 -1
- package/src/workspace/json-data.ts +3 -4
- package/src/workspace/lockfile.ts +57 -8
- package/src/workspace/migration.ts +69 -18
- package/src/workspace/package-graph.ts +5 -4
- package/src/workspace/project-loader.ts +226 -37
- package/src/workspace/repo-id.ts +1 -1
- package/src/workspace/workspace.ts +176 -33
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
ABSENT_INPUT,
|
|
7
7
|
absentOr,
|
|
8
8
|
addedInput,
|
|
9
|
+
behindLink,
|
|
9
10
|
filesToken,
|
|
10
11
|
foldHead,
|
|
11
12
|
type InputListing,
|
|
@@ -21,7 +22,16 @@ import {
|
|
|
21
22
|
} from '../cache/index.js'
|
|
22
23
|
import type { InputFile, TaskInputs } from '../exec/index.js'
|
|
23
24
|
import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
24
|
-
import {
|
|
25
|
+
import { GROUP_VIA } from '../history/index.js'
|
|
26
|
+
import {
|
|
27
|
+
isUserError,
|
|
28
|
+
keysSorted,
|
|
29
|
+
relPosix,
|
|
30
|
+
secretMask,
|
|
31
|
+
span,
|
|
32
|
+
UserError,
|
|
33
|
+
xxh3hex,
|
|
34
|
+
} from '../util/index.js'
|
|
25
35
|
import { expandGroupUpstream, filterUpstreamHashes } from './upstream.js'
|
|
26
36
|
|
|
27
37
|
/**
|
|
@@ -46,6 +56,11 @@ export interface HashCache {
|
|
|
46
56
|
projectFiles: ProjectFilesCache
|
|
47
57
|
/** Each task's last key by its probe, across runs (the local cache's `FoldMemo`). */
|
|
48
58
|
folds?: FoldMemo | undefined
|
|
59
|
+
/**
|
|
60
|
+
* The token of the remembered graph the run's nodes are (graph-memo.ts),
|
|
61
|
+
* while no plugin or flag has edited them since.
|
|
62
|
+
*/
|
|
63
|
+
graph?: string | undefined
|
|
49
64
|
}
|
|
50
65
|
|
|
51
66
|
export function createHashCache(folds?: FoldMemo): HashCache {
|
|
@@ -131,15 +146,15 @@ const FOLD_MEMO_VERSION = 'fold-memo-v1'
|
|
|
131
146
|
/**
|
|
132
147
|
* A task that runs a command and declares no `inputs`: nothing says what
|
|
133
148
|
* it read, so no key can stand for its outputs, and a key that folds it
|
|
134
|
-
* would replay a stale entry (
|
|
149
|
+
* would replay a stale entry (2026-10-16).
|
|
135
150
|
*/
|
|
136
151
|
export function isKeyless(task: TaskConfig): boolean {
|
|
137
152
|
return task.exec !== undefined && task.inputs === undefined
|
|
138
153
|
}
|
|
139
154
|
|
|
140
155
|
/**
|
|
141
|
-
* Drawn once per process, so no run derives another run's
|
|
142
|
-
*
|
|
156
|
+
* Drawn once per process, on first use, so no run derives another run's
|
|
157
|
+
* keyless keys.
|
|
143
158
|
*/
|
|
144
159
|
let keylessSalt: string | undefined
|
|
145
160
|
|
|
@@ -159,6 +174,12 @@ export interface InputFact {
|
|
|
159
174
|
path: string
|
|
160
175
|
digest: string
|
|
161
176
|
since: number
|
|
177
|
+
/**
|
|
178
|
+
* For a link, the content behind it (`behindLink`), which the key folds
|
|
179
|
+
* after the link's own target string. An edit there leaves the link
|
|
180
|
+
* untouched, so the re-check compares this rather than the link's ctime.
|
|
181
|
+
*/
|
|
182
|
+
behind?: string
|
|
162
183
|
}
|
|
163
184
|
|
|
164
185
|
/**
|
|
@@ -180,11 +201,21 @@ export async function describeTaskInputs(args: ComputeHashArgs): Promise<{
|
|
|
180
201
|
}> {
|
|
181
202
|
const hashedAt = Date.now()
|
|
182
203
|
const { input, listings } = await resolveKeyInput(args)
|
|
183
|
-
const hash = await args.cache.key(input)
|
|
184
204
|
const sorted = [...input.inputFiles].sort()
|
|
185
205
|
const digests = await Promise.all(
|
|
186
206
|
sorted.map((f) => input.fileHashes?.get(f) ?? args.cache.hashFile(f).catch(absentOr)),
|
|
187
207
|
)
|
|
208
|
+
// Learned BEFORE the key folds it: an edit in between then reads as a
|
|
209
|
+
// move at the re-check (a withheld save), never as content the key
|
|
210
|
+
// did not fold matching it (a stale entry).
|
|
211
|
+
const hashFile = (f: string): Promise<string> => args.cache.hashFile(f)
|
|
212
|
+
const behind = new Map<string, string>()
|
|
213
|
+
await Promise.all(
|
|
214
|
+
sorted.map(async (f, i) => {
|
|
215
|
+
if (digests[i]!.startsWith('120000:')) behind.set(f, await behindLink(f, hashFile))
|
|
216
|
+
}),
|
|
217
|
+
)
|
|
218
|
+
const hash = await args.cache.key(input)
|
|
188
219
|
// A file gone since the enumeration is no input: the task will not read it.
|
|
189
220
|
const files: InputFile[] = sorted.flatMap((f, i) =>
|
|
190
221
|
digests[i] === ABSENT_INPUT
|
|
@@ -192,11 +223,16 @@ export async function describeTaskInputs(args: ComputeHashArgs): Promise<{
|
|
|
192
223
|
: [{ path: relPosix(input.workspaceRoot, f), digest: digests[i]! }],
|
|
193
224
|
)
|
|
194
225
|
const indexedAt = args.gitFilesCache?.enumeratedAtMs ?? 0
|
|
195
|
-
const facts: InputFact[] = sorted.map((f, i) =>
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
226
|
+
const facts: InputFact[] = sorted.map((f, i) => {
|
|
227
|
+
const fact: InputFact = {
|
|
228
|
+
path: f,
|
|
229
|
+
digest: digests[i]!,
|
|
230
|
+
since: input.fileHashes?.has(f) === true ? indexedAt : hashedAt,
|
|
231
|
+
}
|
|
232
|
+
const linked = behind.get(f)
|
|
233
|
+
if (linked !== undefined) fact.behind = linked
|
|
234
|
+
return fact
|
|
235
|
+
})
|
|
200
236
|
// Groups expanded: this list describes the INPUT ROOT, not the key, and a
|
|
201
237
|
// group produces nothing of its own (see `CacheKeyInput.upstreamGraft`).
|
|
202
238
|
// Sorted by hash so the list — and any action digest derived from it — is
|
|
@@ -260,7 +296,8 @@ export async function describeTaskInputs(args: ComputeHashArgs): Promise<{
|
|
|
260
296
|
* the command by content too, when it answers true for that file and its
|
|
261
297
|
* ctime: the task says the same bytes are its own identical rewrite
|
|
262
298
|
* (tsdown's package.json), not an edit undone, unless another command that
|
|
263
|
-
* may write the file ran until then (command-windows.ts).
|
|
299
|
+
* may write the file ran until then (command-windows.ts). A link's content
|
|
300
|
+
* behind it (`InputFact.behind`) is followed again and compared.
|
|
264
301
|
*/
|
|
265
302
|
export async function movedInput(
|
|
266
303
|
facts: readonly InputFact[],
|
|
@@ -294,6 +331,12 @@ export async function movedInput(
|
|
|
294
331
|
const now = await cache.hashFile(f.path).catch(() => undefined)
|
|
295
332
|
if (now !== f.digest) return f.path
|
|
296
333
|
}
|
|
334
|
+
// What a link points to changes without touching the link: each is
|
|
335
|
+
// followed again (links are rare, so a task without one pays nothing).
|
|
336
|
+
const hashFile = (file: string): Promise<string> => cache.hashFile(file)
|
|
337
|
+
for (const f of facts) {
|
|
338
|
+
if (f.behind !== undefined && (await behindLink(f.path, hashFile)) !== f.behind) return f.path
|
|
339
|
+
}
|
|
297
340
|
return undefined
|
|
298
341
|
}
|
|
299
342
|
|
|
@@ -358,6 +401,24 @@ async function resolveKeyInput(
|
|
|
358
401
|
const upstreamHashes = upstreamPairs.map(([, hash]) => hash)
|
|
359
402
|
// hash → upstream task id, for capture-row naming only (not folded).
|
|
360
403
|
const upstreamIds = new Map(upstreamPairs.map(([id, hash]) => [hash, id]))
|
|
404
|
+
// A group records no run and keeps no fingerprints, so `vx why` could not
|
|
405
|
+
// see past one: each folded group's members are captured as rows of their
|
|
406
|
+
// own, named through it. Rows only, never folded: the group's hash already
|
|
407
|
+
// folds theirs.
|
|
408
|
+
if (args.captureInto !== undefined) {
|
|
409
|
+
const folded = new Set(upstreamPairs.map(([id]) => id))
|
|
410
|
+
for (const g of args.upstream) {
|
|
411
|
+
if (g.groupUpstream === undefined || !folded.has(g.node.id)) continue
|
|
412
|
+
for (const m of expandGroupUpstream(g.groupUpstream)) {
|
|
413
|
+
if (m.hash === undefined) continue
|
|
414
|
+
args.captureInto.push({
|
|
415
|
+
kind: 'upstream',
|
|
416
|
+
name: `${g.node.id}${GROUP_VIA}${m.node.id}`,
|
|
417
|
+
hash: m.hash,
|
|
418
|
+
})
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
361
422
|
// The DEPENDENCY closure, groups expanded — for the executor's input root.
|
|
362
423
|
// Deliberately NOT the filtered set above: `inputs.tasks` says which
|
|
363
424
|
// upstream KEYS this task's key folds, and what a task may READ is
|
|
@@ -403,6 +464,7 @@ async function resolveKeyInput(
|
|
|
403
464
|
forwardArgs: effectiveForwardArgs,
|
|
404
465
|
...(fileHashes !== undefined ? { fileHashes } : {}),
|
|
405
466
|
...(args.node.keyParts !== undefined ? { pluginParts: args.node.keyParts } : {}),
|
|
467
|
+
...(args.node.platform !== undefined ? { platform: args.node.platform } : {}),
|
|
406
468
|
...(args.captureInto !== undefined ? { captureInto: args.captureInto } : {}),
|
|
407
469
|
}
|
|
408
470
|
return { input, listings: resolved.listings }
|
|
@@ -454,9 +516,12 @@ export function hashTaskConfig(cfg: TaskConfig, hashCache?: HashCache): string {
|
|
|
454
516
|
* would bump CACHE_VERSION.
|
|
455
517
|
*/
|
|
456
518
|
function hashableConfig(cfg: TaskConfig): unknown {
|
|
457
|
-
|
|
519
|
+
// Keys sorted: moving `cache` above `exec`, or a `define` in another
|
|
520
|
+
// order, missed every key of the task, and a `project` or `graph` plugin
|
|
521
|
+
// writes a config the lock never sorted (2026-10-25).
|
|
522
|
+
if (cfg.exec?.remote === undefined) return keysSorted(cfg)
|
|
458
523
|
const { remote: _remote, ...execRest } = cfg.exec
|
|
459
|
-
return { ...cfg, exec: execRest }
|
|
524
|
+
return keysSorted({ ...cfg, exec: execRest })
|
|
460
525
|
}
|
|
461
526
|
|
|
462
527
|
/**
|
|
@@ -105,7 +105,7 @@ function budgetCost(chars: number, chunkCount: number): number {
|
|
|
105
105
|
|
|
106
106
|
/**
|
|
107
107
|
* Bounded per-run capture. `append` keeps a chunk LIST + running char count per
|
|
108
|
-
* task, evicting
|
|
108
|
+
* task, evicting from the head past `TASK_LOG_TAIL_CHARS` — no
|
|
109
109
|
* string concatenation until `drain`, so a cache-hit replay (one big chunk) is
|
|
110
110
|
* one array push, zero copies. `finish` decides retention; `drain` emits the
|
|
111
111
|
* bundle, failures first.
|
|
@@ -115,6 +115,16 @@ export class TaskLogBuffer {
|
|
|
115
115
|
private readonly retained = new Map<string, Retained>()
|
|
116
116
|
private retainedChars = 0
|
|
117
117
|
private seq = 0
|
|
118
|
+
/**
|
|
119
|
+
* Retained entries in eviction order, kept as they arrive: successes are
|
|
120
|
+
* stubbed from `successHead` on (oldest first), failures from the end
|
|
121
|
+
* (newest first). An entry since stubbed, taken or replaced is skipped
|
|
122
|
+
* where it lies. A sort of every entry per finish made a run past the
|
|
123
|
+
* budget quadratic: 40,000 tasks took 17 s in a sink's `onRecord`.
|
|
124
|
+
*/
|
|
125
|
+
private readonly successes: Retained[] = []
|
|
126
|
+
private successHead = 0
|
|
127
|
+
private failures: Retained[] = []
|
|
118
128
|
|
|
119
129
|
append(taskId: string, chunk: string): void {
|
|
120
130
|
if (chunk.length === 0) return
|
|
@@ -126,24 +136,27 @@ export class TaskLogBuffer {
|
|
|
126
136
|
acc.chunks.push(chunk)
|
|
127
137
|
acc.chars += chunk.length
|
|
128
138
|
acc.charsFull += chunk.length
|
|
129
|
-
// Evict whole chunks from the head until under the per-task tail cap
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
+
// Evict whole chunks from the head until under the per-task tail cap;
|
|
140
|
+
// the one that straddles the cap is sliced to what still fits (the only
|
|
141
|
+
// place we copy). Dropped whole, a 128 KiB chunk and a 7-char one after
|
|
142
|
+
// it kept the 7 chars alone.
|
|
143
|
+
while (acc.chars > TASK_LOG_TAIL_CHARS) {
|
|
144
|
+
const head = acc.chunks[0]!
|
|
145
|
+
const excess = acc.chars - TASK_LOG_TAIL_CHARS
|
|
146
|
+
if (head.length <= excess) {
|
|
147
|
+
acc.chunks.shift()
|
|
148
|
+
acc.chars -= head.length
|
|
149
|
+
acc.truncatedHeadChars += head.length
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
let start = excess
|
|
139
153
|
// A cut between a surrogate pair would open the tail on a lone low
|
|
140
154
|
// surrogate, which a sink's UTF-8 encode turns into U+FFFD.
|
|
141
|
-
const first =
|
|
155
|
+
const first = head.charCodeAt(start)
|
|
142
156
|
if (first >= 0xdc00 && first <= 0xdfff) start++
|
|
143
|
-
|
|
144
|
-
acc.
|
|
145
|
-
acc.
|
|
146
|
-
acc.chars = keep.length
|
|
157
|
+
acc.chunks[0] = head.slice(start)
|
|
158
|
+
acc.chars -= start
|
|
159
|
+
acc.truncatedHeadChars += start
|
|
147
160
|
}
|
|
148
161
|
}
|
|
149
162
|
|
|
@@ -180,9 +193,32 @@ export class TaskLogBuffer {
|
|
|
180
193
|
if (prior !== undefined) this.retainedChars -= budgetCost(prior.chars, prior.chunks.length)
|
|
181
194
|
this.retained.set(taskId, entry)
|
|
182
195
|
this.retainedChars += budgetCost(entry.chars, entry.chunks.length)
|
|
196
|
+
;(status === 'success' ? this.successes : this.failures).push(entry)
|
|
197
|
+
this.compact()
|
|
183
198
|
this.evictToBudget()
|
|
184
199
|
}
|
|
185
200
|
|
|
201
|
+
/** Whether `e` may still be stubbed: retained as it is, content and all. */
|
|
202
|
+
private evictable(e: Retained): boolean {
|
|
203
|
+
return e.chars > 0 && this.retained.get(e.taskId) === e
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Drop what eviction would only skip, once the queues hold twice what is
|
|
208
|
+
* retained: a sink that takes every entry (`takeEntry`) never evicts, and
|
|
209
|
+
* its queues would grow by a task each. Amortized constant per finish.
|
|
210
|
+
*/
|
|
211
|
+
private compact(): void {
|
|
212
|
+
const room = 2 * this.retained.size + 1024
|
|
213
|
+
if (this.successes.length - this.successHead > room || this.successHead > room) {
|
|
214
|
+
const live = this.successes.slice(this.successHead).filter((e) => this.evictable(e))
|
|
215
|
+
this.successes.length = 0
|
|
216
|
+
this.successes.push(...live)
|
|
217
|
+
this.successHead = 0
|
|
218
|
+
}
|
|
219
|
+
if (this.failures.length > room) this.failures = this.failures.filter((e) => this.evictable(e))
|
|
220
|
+
}
|
|
221
|
+
|
|
186
222
|
/**
|
|
187
223
|
* Take ONE finished task's retained tail as a wire entry, removing it from
|
|
188
224
|
* the buffer (so a later `drain` won't re-ship it). Used by per-task
|
|
@@ -249,7 +285,7 @@ export class TaskLogBuffer {
|
|
|
249
285
|
/**
|
|
250
286
|
* Evict until `retainedChars <= RUN_LOG_BUDGET_CHARS`. Successes go first
|
|
251
287
|
* (oldest by seq); only when failures ALONE still exceed the budget do the
|
|
252
|
-
*
|
|
288
|
+
* newest failures go — a failure is never dropped to keep a success.
|
|
253
289
|
*
|
|
254
290
|
* An evicted task is degraded to a STUB (no content, `truncatedHeadChars`
|
|
255
291
|
* = everything it emitted) rather than removed. Dropping the entry makes a
|
|
@@ -269,25 +305,25 @@ export class TaskLogBuffer {
|
|
|
269
305
|
* sees it.
|
|
270
306
|
*/
|
|
271
307
|
private evictToBudget(): void {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
for (const e of order) {
|
|
285
|
-
if (this.retainedChars <= RUN_LOG_BUDGET_CHARS) break
|
|
286
|
-
if (e.chars === 0) continue // already a stub — frees nothing
|
|
287
|
-
this.retainedChars -= budgetCost(e.chars, e.chunks.length)
|
|
288
|
-
e.truncatedHeadChars += e.chars
|
|
289
|
-
e.chunks = []
|
|
290
|
-
e.chars = 0
|
|
308
|
+
// Successes are dropped first, always, and keep oldest-first: none of
|
|
309
|
+
// them is more interesting than another, and recency is the better
|
|
310
|
+
// guess. Failures go newest-first: when failures alone blow the budget,
|
|
311
|
+
// the FIRST failure is usually the root cause and the later ones its
|
|
312
|
+
// cascade, so it must be the last thing stubbed.
|
|
313
|
+
while (this.retainedChars > RUN_LOG_BUDGET_CHARS && this.successHead < this.successes.length) {
|
|
314
|
+
const e = this.successes[this.successHead++]!
|
|
315
|
+
if (this.evictable(e)) this.stub(e)
|
|
316
|
+
}
|
|
317
|
+
while (this.retainedChars > RUN_LOG_BUDGET_CHARS && this.failures.length > 0) {
|
|
318
|
+
const e = this.failures.pop()!
|
|
319
|
+
if (this.evictable(e)) this.stub(e)
|
|
291
320
|
}
|
|
292
321
|
}
|
|
322
|
+
|
|
323
|
+
private stub(e: Retained): void {
|
|
324
|
+
this.retainedChars -= budgetCost(e.chars, e.chunks.length)
|
|
325
|
+
e.truncatedHeadChars += e.chars
|
|
326
|
+
e.chunks = []
|
|
327
|
+
e.chars = 0
|
|
328
|
+
}
|
|
293
329
|
}
|
|
@@ -74,6 +74,7 @@ export function taskEnv(
|
|
|
74
74
|
define: step.env?.define ?? {},
|
|
75
75
|
source: process.env,
|
|
76
76
|
binPaths: bins,
|
|
77
|
+
workspaceRoot,
|
|
77
78
|
})
|
|
78
79
|
env[VX_RUN_WORKSPACE_ENV] = workspaceRoot
|
|
79
80
|
env[VX_RUN_TASK_ENV] = node.id
|
|
@@ -91,7 +92,11 @@ export function taskTimeoutMs(step: ExecConfig): number | undefined {
|
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
/** Persistent task `node` as a `servers` executor starts it: no forwarded args, which a dependency never gets. */
|
|
94
|
-
export function serverSpec(
|
|
95
|
+
export function serverSpec(
|
|
96
|
+
node: TaskNode,
|
|
97
|
+
workspaceRoot: string,
|
|
98
|
+
nested: readonly string[],
|
|
99
|
+
): ServerSpec {
|
|
95
100
|
const step = node.config.exec!
|
|
96
101
|
const timeoutMs = taskTimeoutMs(step)
|
|
97
102
|
const readyWhen = step.persistent?.readyWhen
|
|
@@ -102,6 +107,7 @@ export function serverSpec(node: TaskNode, workspaceRoot: string): ServerSpec {
|
|
|
102
107
|
env: taskEnv(node, step, workspaceRoot),
|
|
103
108
|
...(readyWhen === undefined ? {} : { readyWhen }),
|
|
104
109
|
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
110
|
+
...(step.sandbox === undefined ? {} : { sandbox: { config: step.sandbox, nested } }),
|
|
105
111
|
}
|
|
106
112
|
}
|
|
107
113
|
|
|
@@ -17,10 +17,11 @@ import type {
|
|
|
17
17
|
RunContextRecord,
|
|
18
18
|
RunSummaryRecord,
|
|
19
19
|
TelemetryContext,
|
|
20
|
+
TelemetryRecord,
|
|
20
21
|
TelemetrySink,
|
|
21
22
|
TreeLoad,
|
|
22
23
|
} from './telemetry.js'
|
|
23
|
-
import { settleWithin, teardownTimeoutMs } from '../util/index.js'
|
|
24
|
+
import { settleWithin, teardownTimeoutMs, thrownMessage } from '../util/index.js'
|
|
24
25
|
import { createTelemetrySource } from './telemetry.js'
|
|
25
26
|
import type { VxPlugin } from './plugin.js'
|
|
26
27
|
|
|
@@ -36,6 +37,16 @@ export interface TelemetryHandle {
|
|
|
36
37
|
readonly track?: (taskId: string, pid: number) => () => TreeLoad | undefined
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
// Every kind once: a kind added to `TelemetryRecord` fails to compile here.
|
|
41
|
+
const RECORD_KINDS: Record<TelemetryRecord['kind'], true> = {
|
|
42
|
+
'run.start': true,
|
|
43
|
+
'task.start': true,
|
|
44
|
+
'task.log': true,
|
|
45
|
+
'task.sample': true,
|
|
46
|
+
'task.end': true,
|
|
47
|
+
'run.end': true,
|
|
48
|
+
}
|
|
49
|
+
|
|
39
50
|
/** Reject anything that is not sink-shaped, naming what arrived. */
|
|
40
51
|
function checkSink(sink: unknown): TelemetrySink {
|
|
41
52
|
if (typeof sink !== 'object' || sink === null) {
|
|
@@ -45,6 +56,15 @@ function checkSink(sink: unknown): TelemetrySink {
|
|
|
45
56
|
if (wants !== undefined && !Array.isArray(wants)) {
|
|
46
57
|
throw new Error(`telemetry sink 'wants' must be an array, got ${typeof wants}`)
|
|
47
58
|
}
|
|
59
|
+
// A misspelt kind (`task.logs`) was a sink that never heard it, with no word.
|
|
60
|
+
const unknown = (wants as readonly unknown[] | undefined)?.find(
|
|
61
|
+
(k) => typeof k !== 'string' || !Object.hasOwn(RECORD_KINDS, k),
|
|
62
|
+
)
|
|
63
|
+
if (unknown !== undefined) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`telemetry sink 'wants' names ${typeof unknown === 'string' ? JSON.stringify(unknown) : typeof unknown}, which is no record kind (${Object.keys(RECORD_KINDS).join(', ')})`,
|
|
66
|
+
)
|
|
67
|
+
}
|
|
48
68
|
// Every handler is optional, so `{ nope: true }` was a valid sink that
|
|
49
69
|
// subscribed to the bus and heard nothing — the plugin was "on" and did
|
|
50
70
|
// nothing. Refused here, which the caller turns into the same warning
|
|
@@ -102,7 +122,7 @@ export async function subscribeTelemetry(
|
|
|
102
122
|
accepted = list.map((sink) => checkSink(sink))
|
|
103
123
|
} catch (err) {
|
|
104
124
|
ctx.warn(
|
|
105
|
-
`[vx] plugin '${plugin.name}' telemetry failed to initialize; disabled for this run: ${
|
|
125
|
+
`[vx] plugin '${plugin.name}' telemetry failed to initialize; disabled for this run: ${thrownMessage(err)}`,
|
|
106
126
|
)
|
|
107
127
|
continue
|
|
108
128
|
}
|
|
@@ -27,7 +27,13 @@ import {
|
|
|
27
27
|
type TaskOutcome,
|
|
28
28
|
type TaskStatus,
|
|
29
29
|
} from '../graph/index.js'
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
maskedCommand,
|
|
32
|
+
settleWithin,
|
|
33
|
+
stageTimes,
|
|
34
|
+
teardownTimeoutMs,
|
|
35
|
+
thrownMessage,
|
|
36
|
+
} from '../util/index.js'
|
|
31
37
|
import type { RunEvent, RunEventSubscriber } from '../report/index.js'
|
|
32
38
|
|
|
33
39
|
/** Bumped when the record shape changes. Readers MUST check `v`. */
|
|
@@ -114,6 +120,10 @@ export interface TaskTelemetry {
|
|
|
114
120
|
/** Executor-reported placement (a worker id) — absent for local runs.
|
|
115
121
|
* Additive-optional, no schema bump (the `attempts` precedent). */
|
|
116
122
|
where?: string
|
|
123
|
+
/** A persistent task each dependant's executor started where that ran:
|
|
124
|
+
* part of those runs, never an execution of its own (2026-10-23:
|
|
125
|
+
* nothing runs on a distributed run's orchestrator). Additive-optional. */
|
|
126
|
+
carried?: true
|
|
117
127
|
/** `'deferred'` when the outputs stayed remote (`--cache-download=none`).
|
|
118
128
|
* Additive-optional, same no-bump precedent as `where`. */
|
|
119
129
|
outputs?: 'deferred'
|
|
@@ -127,6 +137,8 @@ export interface TaskTelemetry {
|
|
|
127
137
|
* fail-fast skip and on every other status. Additive (schema stays 2).
|
|
128
138
|
*/
|
|
129
139
|
blockedBy?: string
|
|
140
|
+
/** On a `skipped` task: its `exec.platforms` leave out the machine that ran the run. Additive. */
|
|
141
|
+
otherPlatform?: { readonly needs: readonly string[]; readonly host: string }
|
|
130
142
|
/** On a `failed` task: vx's own `timeout` killed it (exit 143 is the deadline, not a signal). Additive. */
|
|
131
143
|
timedOut?: true
|
|
132
144
|
/** On a sandboxed task: how many declared-boundary violations the sandbox recorded (a failure on its own). Additive. */
|
|
@@ -188,6 +200,10 @@ export type TelemetryRecord =
|
|
|
188
200
|
/** The run's canonical start (epoch ms) — equals the summary's
|
|
189
201
|
* `startedAt`; a sink derives per-task timing from it during the run. */
|
|
190
202
|
startedAt: number
|
|
203
|
+
/** Where the run's stage table begins (epoch ms), the first stage's
|
|
204
|
+
* start: at or before `startedAt`. A sink that draws the stages
|
|
205
|
+
* starts the run here. Additive. */
|
|
206
|
+
originAt?: number
|
|
191
207
|
}
|
|
192
208
|
| {
|
|
193
209
|
v: number
|
|
@@ -280,6 +296,11 @@ export interface RunSummaryRecord {
|
|
|
280
296
|
* executor reports any. Additive.
|
|
281
297
|
*/
|
|
282
298
|
machines?: readonly MachineReport[]
|
|
299
|
+
/**
|
|
300
|
+
* The run's end had an executor placed its tasks perfectly
|
|
301
|
+
* (`TaskExecutor.idealMs`), beside `totalDurationMs`. Additive.
|
|
302
|
+
*/
|
|
303
|
+
idealMs?: number
|
|
283
304
|
}
|
|
284
305
|
|
|
285
306
|
/** One stage of a run, a wall window in epoch ms. */
|
|
@@ -312,6 +333,7 @@ export function assembleRunSummary(
|
|
|
312
333
|
stages?: readonly RunStage[]
|
|
313
334
|
uploads?: { count: number; bytes: number; ms: number; failed: number }
|
|
314
335
|
machines?: readonly MachineReport[]
|
|
336
|
+
idealMs?: number | undefined
|
|
315
337
|
},
|
|
316
338
|
): RunSummaryRecord {
|
|
317
339
|
let failedCount = 0
|
|
@@ -353,6 +375,7 @@ export function assembleRunSummary(
|
|
|
353
375
|
...(timing.machines !== undefined && timing.machines.length > 0
|
|
354
376
|
? { machines: timing.machines }
|
|
355
377
|
: {}),
|
|
378
|
+
...(timing.idealMs !== undefined ? { idealMs: timing.idealMs } : {}),
|
|
356
379
|
}
|
|
357
380
|
}
|
|
358
381
|
|
|
@@ -427,6 +450,8 @@ export interface TreeLoad {
|
|
|
427
450
|
|
|
428
451
|
/** How often a running task's process tree is sampled. */
|
|
429
452
|
const SAMPLE_MS = 1000
|
|
453
|
+
/** How long an aborted flush may take to end its own requests. */
|
|
454
|
+
const ABORT_GRACE_MS = 250
|
|
430
455
|
|
|
431
456
|
/** Call `fn` every `ms` until the returned stop is called. */
|
|
432
457
|
type Every = (ms: number, fn: () => Promise<void>) => () => void
|
|
@@ -489,7 +514,7 @@ export function createTelemetrySource(args: {
|
|
|
489
514
|
if (disabled.has(sink)) return
|
|
490
515
|
disabled.add(sink)
|
|
491
516
|
warn?.(
|
|
492
|
-
`[vx] telemetry sink '${label(sink)}' threw in ${hook}; disabled for this run: ${
|
|
517
|
+
`[vx] telemetry sink '${label(sink)}' threw in ${hook}; disabled for this run: ${thrownMessage(err)}`,
|
|
493
518
|
)
|
|
494
519
|
}
|
|
495
520
|
// Precompute which sinks want each kind, so per-event fan-out is a plain
|
|
@@ -599,6 +624,7 @@ export function createTelemetrySource(args: {
|
|
|
599
624
|
total: event.info.total,
|
|
600
625
|
ts,
|
|
601
626
|
startedAt: event.info.startedAtMs ?? ts,
|
|
627
|
+
...originOf(),
|
|
602
628
|
})
|
|
603
629
|
return
|
|
604
630
|
case 'task:start': {
|
|
@@ -682,28 +708,26 @@ export function createTelemetrySource(args: {
|
|
|
682
708
|
// accessed_at bumps and every later plugin's teardown lost with it.
|
|
683
709
|
// Sinks race concurrently, so each still gets the whole budget.
|
|
684
710
|
const deadline = new AbortController()
|
|
685
|
-
const
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
)
|
|
699
|
-
}
|
|
700
|
-
}),
|
|
701
|
-
),
|
|
702
|
-
ms,
|
|
711
|
+
const all = Promise.all(
|
|
712
|
+
sinks.map(async (sink) => {
|
|
713
|
+
if (disabled.has(sink) || sink.flush === undefined) return
|
|
714
|
+
try {
|
|
715
|
+
await step(`${args.owners?.get(sink) ?? label(sink)}:telemetry`, () =>
|
|
716
|
+
sink.flush!(deadline.signal),
|
|
717
|
+
)
|
|
718
|
+
} catch (err) {
|
|
719
|
+
// A flush failure can never break the run — but it still costs
|
|
720
|
+
// this sink its export, so say so rather than dropping it.
|
|
721
|
+
warn?.(`[vx] telemetry sink '${label(sink)}' failed to flush: ${thrownMessage(err)}`)
|
|
722
|
+
}
|
|
723
|
+
}),
|
|
703
724
|
)
|
|
704
|
-
if (!
|
|
725
|
+
if (!(await settleWithin(all, ms))) {
|
|
705
726
|
deadline.abort(new Error(`telemetry flush deadline (${ms}ms)`))
|
|
706
727
|
warn?.(`[vx] telemetry flush timed out after ${ms}ms; buffered records lost`)
|
|
728
|
+
// A sink told of the abort says what it lost as its request ends; the
|
|
729
|
+
// run ended first and that line was lost (vx-otel, on CI). Briefly.
|
|
730
|
+
await settleWithin(all, ABORT_GRACE_MS)
|
|
707
731
|
}
|
|
708
732
|
},
|
|
709
733
|
}
|
|
@@ -747,9 +771,11 @@ export function taskTelemetryOf(o: TaskOutcome): TaskTelemetry {
|
|
|
747
771
|
if (o.cpuMs !== undefined) t.cpuMs = o.cpuMs
|
|
748
772
|
if (o.peakRssBytes !== undefined) t.peakRssBytes = o.peakRssBytes
|
|
749
773
|
if (o.where !== undefined) t.where = o.where
|
|
774
|
+
if (o.carried === true) t.carried = true
|
|
750
775
|
if (o.outputs !== undefined) t.outputs = o.outputs
|
|
751
776
|
if (o.attempts !== undefined) t.attempts = o.attempts
|
|
752
777
|
if (o.blockedBy !== undefined) t.blockedBy = o.blockedBy
|
|
778
|
+
if (o.otherPlatform !== undefined) t.otherPlatform = o.otherPlatform
|
|
753
779
|
if (o.timedOut === true) t.timedOut = true
|
|
754
780
|
if (o.sandboxViolations !== undefined) t.sandboxViolations = o.sandboxViolations
|
|
755
781
|
if (o.notReady !== undefined) t.notReady = o.notReady
|
|
@@ -773,3 +799,9 @@ export function taskTelemetryOf(o: TaskOutcome): TaskTelemetry {
|
|
|
773
799
|
if (o.wallclockEndNs !== undefined) t.wallclockEndNs = o.wallclockEndNs.toString()
|
|
774
800
|
return t
|
|
775
801
|
}
|
|
802
|
+
|
|
803
|
+
/** The stage table's start, when a stage was marked. */
|
|
804
|
+
function originOf(): { originAt?: number } {
|
|
805
|
+
const first = stageTimes()[0]
|
|
806
|
+
return first === undefined ? {} : { originAt: first.startedAt }
|
|
807
|
+
}
|
|
@@ -24,6 +24,21 @@ export function keyUpstream(node: TaskNode, upstream: TaskOutcome[]): TaskOutcom
|
|
|
24
24
|
return node.excludedUpstream === undefined ? keyed : [...keyed, ...node.excludedUpstream]
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/** The ids some task of the run depends on; the rest run with `noDependants`. */
|
|
28
|
+
export function dependedOn(nodes: Iterable<TaskNode>): Set<string> {
|
|
29
|
+
const ids = new Set<string>()
|
|
30
|
+
for (const n of nodes) for (const d of n.deps) ids.add(d)
|
|
31
|
+
return ids
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A server's key is read only by a dependant (A-17), so with none in the
|
|
36
|
+
* run it derives none (`noDependants`), and the plan must not show one.
|
|
37
|
+
*/
|
|
38
|
+
export function keylessServer(node: TaskNode, depended: ReadonlySet<string>): boolean {
|
|
39
|
+
return node.config.exec?.persistent !== undefined && !depended.has(node.id)
|
|
40
|
+
}
|
|
41
|
+
|
|
27
42
|
/** A dependency as a key reads it: only `node` and `hash` matter. */
|
|
28
43
|
export function keyedOutcome(node: TaskNode, hash: string | undefined): TaskOutcome {
|
|
29
44
|
const o: TaskOutcome = { node, status: 'success', exitCode: 0, durationMs: 0 }
|