@vzn/vx 0.0.21 → 0.0.22
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 +21 -18
- package/package.json +6 -6
- package/src/bin.ts +52 -3
- package/src/cache/archive.ts +170 -21
- package/src/cache/cache.ts +667 -288
- package/src/cache/chained-cache.ts +14 -2
- package/src/cache/config-evals.ts +29 -6
- package/src/cache/file-hashes.ts +63 -35
- package/src/cache/git-inputs.ts +398 -69
- package/src/cache/index.ts +8 -1
- package/src/cache/inputs.ts +403 -102
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +81 -11
- package/src/cache/layered-cache.ts +199 -32
- package/src/cache/output-index.ts +109 -10
- package/src/cache/run-history.ts +8 -3
- package/src/cache/zstd.ts +53 -30
- package/src/cli/cache.ts +51 -21
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +13 -3
- package/src/cli/index.ts +35 -9
- package/src/cli/info.ts +49 -7
- package/src/cli/init.ts +17 -5
- package/src/cli/last.ts +96 -21
- package/src/cli/lock.ts +34 -17
- package/src/cli/plan-format.ts +4 -4
- package/src/cli/plugin-commands.ts +20 -5
- package/src/cli/run.ts +55 -12
- package/src/cli/select.ts +23 -13
- package/src/cli/show.ts +9 -4
- package/src/cli/upgrade.ts +134 -14
- package/src/cli/watch.ts +611 -102
- package/src/cli/why.ts +8 -16
- package/src/cli/workspace-config.ts +21 -9
- package/src/config.ts +46 -9
- package/src/exec/executor.ts +51 -5
- package/src/exec/index.ts +10 -0
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/runner.ts +327 -82
- package/src/exec/sandbox-binds.ts +34 -5
- package/src/exec/sandbox-paths.ts +22 -2
- package/src/exec/sandbox-runtime.ts +627 -102
- package/src/exec/sandbox-violations.ts +36 -23
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +12 -2
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +182 -134
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +16 -2
- package/src/orchestrator/admission.ts +41 -17
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +22 -7
- package/src/orchestrator/doctor.ts +122 -14
- package/src/orchestrator/events.ts +71 -2
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +331 -37
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +81 -14
- package/src/orchestrator/history.ts +6 -7
- package/src/orchestrator/hit-restore.ts +82 -18
- package/src/orchestrator/index.ts +7 -1
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +152 -32
- package/src/orchestrator/logger.ts +116 -20
- package/src/orchestrator/metrics.ts +156 -60
- package/src/orchestrator/miss-save.ts +106 -43
- package/src/orchestrator/options.ts +30 -4
- package/src/orchestrator/persistent.ts +41 -21
- package/src/orchestrator/placement.ts +55 -25
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +17 -23
- package/src/orchestrator/plugin.ts +31 -8
- package/src/orchestrator/prepare.ts +99 -13
- package/src/orchestrator/projects.ts +19 -8
- package/src/orchestrator/remote-prefetch.ts +21 -1
- package/src/orchestrator/run-artifacts.ts +9 -0
- package/src/orchestrator/run-context.ts +19 -4
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +6 -23
- package/src/orchestrator/run-report.ts +4 -2
- package/src/orchestrator/run.ts +264 -57
- package/src/orchestrator/sandbox-request.ts +388 -62
- package/src/orchestrator/save-lane.ts +4 -7
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +86 -30
- package/src/orchestrator/stable-keys.ts +202 -10
- package/src/orchestrator/status-line.ts +33 -1
- package/src/orchestrator/summary.ts +49 -2
- package/src/orchestrator/task-hash.ts +87 -7
- package/src/orchestrator/task-log-buffer.ts +7 -7
- package/src/orchestrator/telemetry-host.ts +16 -7
- package/src/orchestrator/telemetry.ts +69 -28
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +32 -3
- package/src/util/paths.ts +147 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +4 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +272 -64
- package/src/workspace/config-cache.ts +262 -32
- package/src/workspace/config-eval.ts +49 -12
- package/src/workspace/config-imports.ts +126 -5
- package/src/workspace/config-schema.ts +237 -41
- package/src/workspace/filter.ts +33 -18
- package/src/workspace/fingerprint.ts +29 -11
- package/src/workspace/index.ts +7 -0
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +33 -11
- package/src/workspace/migrate-scripts.ts +60 -7
- package/src/workspace/migration.ts +40 -1
- package/src/workspace/package-graph.ts +154 -16
- package/src/workspace/project-loader.ts +227 -77
- package/src/workspace/workspace.ts +173 -23
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { TaskNode, TaskOutcome } from '../graph/index.js'
|
|
2
2
|
import { detectColors, type ColorSupport } from './colors.js'
|
|
3
3
|
import {
|
|
4
|
+
formatFailureRecap,
|
|
4
5
|
formatFrameClose,
|
|
5
6
|
formatFrameOpen,
|
|
6
7
|
formatPersistentTailBlock,
|
|
@@ -8,7 +9,15 @@ import {
|
|
|
8
9
|
formatTaskExecutedLine,
|
|
9
10
|
formatTaskHitLine,
|
|
10
11
|
formatTaskSkippedLine,
|
|
12
|
+
type RecapEntry,
|
|
11
13
|
} from './framed-output.js'
|
|
14
|
+
import {
|
|
15
|
+
appendRecapRing,
|
|
16
|
+
createRecapRing,
|
|
17
|
+
RECAP_TASKS,
|
|
18
|
+
recapTail,
|
|
19
|
+
type RecapRing,
|
|
20
|
+
} from './failure-recap.js'
|
|
12
21
|
import {
|
|
13
22
|
createOutputWriter,
|
|
14
23
|
formatFailureLine,
|
|
@@ -20,7 +29,7 @@ import { formatDuration, formatSummarySection, type RunContext } from './summary
|
|
|
20
29
|
import { isGroupTask } from '../graph/index.js'
|
|
21
30
|
import { appendTail, createTail, resetTail, tailText, type Tail } from '../util/index.js'
|
|
22
31
|
import { isCacheHit } from './telemetry.js'
|
|
23
|
-
import { outcomeWord } from './events.js'
|
|
32
|
+
import { failedLabel, outcomeWord } from './events.js'
|
|
24
33
|
|
|
25
34
|
export interface Logger {
|
|
26
35
|
/** Header / footer / status text. Written verbatim, one trailing \n added. */
|
|
@@ -53,6 +62,21 @@ export interface Logger {
|
|
|
53
62
|
runEnd?(): void
|
|
54
63
|
}
|
|
55
64
|
|
|
65
|
+
/** The logger `run()` builds when none is passed, and the one that owns the recap. */
|
|
66
|
+
export interface DefaultLogger extends Logger {
|
|
67
|
+
/**
|
|
68
|
+
* The run's last block: each failed task's last lines (item 706). Empty
|
|
69
|
+
* when nothing failed, and in the modes that promise no task output.
|
|
70
|
+
*/
|
|
71
|
+
failureRecap(): string[]
|
|
72
|
+
/**
|
|
73
|
+
* Hand over any output still coalesced, and write straight through from
|
|
74
|
+
* now on. `runEnd` does it; the run's own `finally` does it again for a
|
|
75
|
+
* throw that never reached `runEnd`.
|
|
76
|
+
*/
|
|
77
|
+
settle(): void
|
|
78
|
+
}
|
|
79
|
+
|
|
56
80
|
/**
|
|
57
81
|
* The default logger's per-task output policy. Resolved once per run
|
|
58
82
|
* from (in priority order) the explicit `--output-logs` override, a
|
|
@@ -152,8 +176,8 @@ export function defaultLogger(
|
|
|
152
176
|
colors: ColorSupport = detectColors(),
|
|
153
177
|
view: OutputView = { mode: 'full' },
|
|
154
178
|
out: StatusStream = process.stdout,
|
|
155
|
-
opts: { forceFloorMs?: number } = {},
|
|
156
|
-
):
|
|
179
|
+
opts: { forceFloorMs?: number; coalesce?: boolean } = {},
|
|
180
|
+
): DefaultLogger {
|
|
157
181
|
// Per-task buffers, split by stream. Splitting lets the framed-output
|
|
158
182
|
// renderer put stdout under `├─ stdout` and stderr under `├─ stderr`.
|
|
159
183
|
// The price: chunks that interleaved at runtime get re-ordered (all
|
|
@@ -215,6 +239,7 @@ export function defaultLogger(
|
|
|
215
239
|
const writer = createOutputWriter(out, {
|
|
216
240
|
enabled: view.ci !== true,
|
|
217
241
|
...(opts.forceFloorMs !== undefined ? { forceFloorMs: opts.forceFloorMs } : {}),
|
|
242
|
+
...(opts.coalesce === true ? { coalesce: true } : {}),
|
|
218
243
|
})
|
|
219
244
|
|
|
220
245
|
// Status-display state, driven by the optional lifecycle hooks.
|
|
@@ -257,11 +282,12 @@ export function defaultLogger(
|
|
|
257
282
|
let spreadMin = Infinity
|
|
258
283
|
let spreadSum = 0
|
|
259
284
|
let spreadCount = 0
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
285
|
+
// The pinned zone above the worker rows: persistent tasks pin at
|
|
286
|
+
// ready (their outcome lands while the child keeps running) and live
|
|
287
|
+
// until runEnd kills the region. A failure is not pinned: it is a
|
|
288
|
+
// permanent ◼ row the moment it happens, and its full frame is
|
|
289
|
+
// deferred to runEnd (owner: '✗ line, continue, all full frames at
|
|
290
|
+
// the end'). 'full'/CI keeps frames inline.
|
|
265
291
|
const deferredFailures: string[] = []
|
|
266
292
|
const pinnedPersistent: string[] = []
|
|
267
293
|
let flushedFailures = false
|
|
@@ -287,6 +313,13 @@ export function defaultLogger(
|
|
|
287
313
|
{ node: TaskNode; outcome?: TaskOutcome; out: Tail; err: Tail }
|
|
288
314
|
>()
|
|
289
315
|
let flushedPersistent = false
|
|
316
|
+
// The recap's material, decided at each failure so nothing is held for a
|
|
317
|
+
// task that passed. A buffered task's tail is read off the buffers the
|
|
318
|
+
// frame drains; a live-streamed one keeps no buffer, so a bounded ring
|
|
319
|
+
// follows it from its frame-open until its outcome.
|
|
320
|
+
const recapEntries: RecapEntry[] = []
|
|
321
|
+
const recapMore: string[] = []
|
|
322
|
+
const liveRings = new Map<string, RecapRing>()
|
|
290
323
|
|
|
291
324
|
const refresh = (force: boolean): void => {
|
|
292
325
|
if (statusDead) return
|
|
@@ -372,6 +405,23 @@ export function defaultLogger(
|
|
|
372
405
|
view.mode === 'focused' && isPrimary(node) && !isGroupTask(node) && requestedCount <= 1
|
|
373
406
|
|
|
374
407
|
return {
|
|
408
|
+
settle() {
|
|
409
|
+
writer.settle()
|
|
410
|
+
},
|
|
411
|
+
failureRecap() {
|
|
412
|
+
if (recapEntries.length === 0) return []
|
|
413
|
+
// Fenced like a frame: a tail is the task's own text, and a line of it
|
|
414
|
+
// must not become a workflow command. Never inside a group, so it reads
|
|
415
|
+
// with every group collapsed.
|
|
416
|
+
const fence =
|
|
417
|
+
view.gha === true
|
|
418
|
+
? (lines: string[]) =>
|
|
419
|
+
ghaFence(`${lines.join('\n')}\n`, ghaToken)
|
|
420
|
+
.slice(0, -1)
|
|
421
|
+
.split('\n')
|
|
422
|
+
: undefined
|
|
423
|
+
return formatFailureRecap(recapEntries, recapMore, colors, fence)
|
|
424
|
+
},
|
|
375
425
|
status(line) {
|
|
376
426
|
writer.write(`${line}\n`)
|
|
377
427
|
},
|
|
@@ -404,6 +454,7 @@ export function defaultLogger(
|
|
|
404
454
|
// Open the live frame: full task info even when the command
|
|
405
455
|
// streams nothing (or the hit replays nothing).
|
|
406
456
|
emitLine(formatFrameOpen(node, colors))
|
|
457
|
+
liveRings.set(node.id, createRecapRing())
|
|
407
458
|
return
|
|
408
459
|
}
|
|
409
460
|
// Bound a persistent task's output from its FIRST chunk. Waiting for
|
|
@@ -452,13 +503,16 @@ export function defaultLogger(
|
|
|
452
503
|
}
|
|
453
504
|
}
|
|
454
505
|
// Failures end the log: every deferred frame replays here, right
|
|
455
|
-
// above the summary — the
|
|
456
|
-
//
|
|
506
|
+
// above the summary — the ◼ rows marked them in the stream,
|
|
507
|
+
// the full diagnostics read last where eyes land.
|
|
457
508
|
// Guarded for repeat runEnd calls.
|
|
458
509
|
if (!flushedFailures && deferredFailures.length > 0) {
|
|
459
510
|
flushedFailures = true
|
|
460
511
|
for (const block of deferredFailures) emitBlock(block)
|
|
461
512
|
}
|
|
513
|
+
// The summary and the CLI's own lines follow on the stream directly:
|
|
514
|
+
// nothing may still be held, and nothing after this is.
|
|
515
|
+
writer.settle()
|
|
462
516
|
},
|
|
463
517
|
taskStdout(node, chunk) {
|
|
464
518
|
if (discardsOutput) return
|
|
@@ -466,6 +520,8 @@ export function defaultLogger(
|
|
|
466
520
|
streamedSinceBlock = true
|
|
467
521
|
if (chunk.length > 0) streamMidLine = !chunk.endsWith('\n')
|
|
468
522
|
writer.write(chunk)
|
|
523
|
+
const ring = liveRings.get(node.id)
|
|
524
|
+
if (ring !== undefined) appendRecapRing(ring, chunk)
|
|
469
525
|
return
|
|
470
526
|
}
|
|
471
527
|
const tail = persistentTails.get(node.id)
|
|
@@ -481,6 +537,8 @@ export function defaultLogger(
|
|
|
481
537
|
streamedSinceBlock = true
|
|
482
538
|
if (chunk.length > 0) streamMidLine = !chunk.endsWith('\n')
|
|
483
539
|
writer.write(chunk)
|
|
540
|
+
const ring = liveRings.get(node.id)
|
|
541
|
+
if (ring !== undefined) appendRecapRing(ring, chunk)
|
|
484
542
|
return
|
|
485
543
|
}
|
|
486
544
|
const tail = persistentTails.get(node.id)
|
|
@@ -505,6 +563,28 @@ export function defaultLogger(
|
|
|
505
563
|
// The pre-ready window closed either way: a failed task is over, and
|
|
506
564
|
// a ready one starts a fresh post-ready tail below.
|
|
507
565
|
persistentTails.delete(node.id)
|
|
566
|
+
const liveRing = liveRings.get(node.id)
|
|
567
|
+
liveRings.delete(node.id)
|
|
568
|
+
if (outcome.status === 'failed' && !discardsOutput) {
|
|
569
|
+
if (recapEntries.length === RECAP_TASKS) recapMore.push(node.id)
|
|
570
|
+
else {
|
|
571
|
+
let ring = liveRing
|
|
572
|
+
if (ring === undefined) {
|
|
573
|
+
ring = createRecapRing()
|
|
574
|
+
appendRecapRing(ring, stdout)
|
|
575
|
+
if (stdout.length > 0 && stderr.length > 0 && !stdout.endsWith('\n')) {
|
|
576
|
+
appendRecapRing(ring, '\n')
|
|
577
|
+
}
|
|
578
|
+
appendRecapRing(ring, stderr)
|
|
579
|
+
}
|
|
580
|
+
recapEntries.push({
|
|
581
|
+
node,
|
|
582
|
+
outcome,
|
|
583
|
+
tail: recapTail(ring),
|
|
584
|
+
droppedChars: (preReady?.out.dropped ?? 0) + (preReady?.err.dropped ?? 0),
|
|
585
|
+
})
|
|
586
|
+
}
|
|
587
|
+
}
|
|
508
588
|
// An aborted task (child killed by a shutdown signal) reverts
|
|
509
589
|
// to pending: free its worker slot, but never count or render it
|
|
510
590
|
// — the run is tearing down and it has no honest outcome.
|
|
@@ -516,6 +596,22 @@ export function defaultLogger(
|
|
|
516
596
|
if (qi >= 0) slotQueue.splice(qi, 1)
|
|
517
597
|
}
|
|
518
598
|
refresh(true)
|
|
599
|
+
// What it printed while it stopped (a trap's cleanup) still reaches
|
|
600
|
+
// the terminal where a frame would have: a task that traps Ctrl-C
|
|
601
|
+
// and exits 0 is aborted since item 962, and its frame was dropped
|
|
602
|
+
// with the count.
|
|
603
|
+
const framed = view.mode === 'full' || (view.mode === 'focused' && isPrimary(node))
|
|
604
|
+
if (framed && !isGroupTask(node)) {
|
|
605
|
+
if (streamsLive(node)) {
|
|
606
|
+
if (streamMidLine) {
|
|
607
|
+
writer.write('\n')
|
|
608
|
+
streamMidLine = false
|
|
609
|
+
}
|
|
610
|
+
emitFrameClose(formatFrameClose(node, outcome, colors))
|
|
611
|
+
} else if (stdout.length > 0 || stderr.length > 0) {
|
|
612
|
+
emitBlock(formatTaskBlock(node, outcome, { stdout, stderr, ...dropped }, colors, true))
|
|
613
|
+
}
|
|
614
|
+
}
|
|
519
615
|
return
|
|
520
616
|
}
|
|
521
617
|
// Group tasks (no exec) do no work — no surface prints them.
|
|
@@ -595,7 +691,7 @@ export function defaultLogger(
|
|
|
595
691
|
// their replay buffers are deliberately dropped; the counts
|
|
596
692
|
// surface in the end-of-run summary.
|
|
597
693
|
if (outcome.status === 'failed') {
|
|
598
|
-
//
|
|
694
|
+
// ◼ row now; the full frame replays at runEnd.
|
|
599
695
|
emitLine(formatFailureLine(node.id, outcome.durationMs, colors, cacheWordOf(node)))
|
|
600
696
|
deferredFailures.push(
|
|
601
697
|
formatTaskBlock(node, outcome, { stdout, stderr, ...dropped }, colors),
|
|
@@ -606,11 +702,11 @@ export function defaultLogger(
|
|
|
606
702
|
return
|
|
607
703
|
case 'focused':
|
|
608
704
|
if (isPrimary(node)) {
|
|
609
|
-
// Skipped tasks never started (upstream
|
|
610
|
-
// frame-open fired — and a skip has no output,
|
|
611
|
-
// one-liner carries everything a frame would.
|
|
705
|
+
// Skipped tasks never started (blocked upstream, or the run
|
|
706
|
+
// stopped), so no frame-open fired — and a skip has no output,
|
|
707
|
+
// so a one-liner carries everything a frame would.
|
|
612
708
|
if (outcome.status === 'skipped') {
|
|
613
|
-
emitLine(formatTaskSkippedLine(node, colors))
|
|
709
|
+
emitLine(formatTaskSkippedLine(node, colors, outcome.blockedBy))
|
|
614
710
|
return
|
|
615
711
|
}
|
|
616
712
|
if (streamsLive(node)) {
|
|
@@ -643,7 +739,7 @@ export function defaultLogger(
|
|
|
643
739
|
return
|
|
644
740
|
}
|
|
645
741
|
// Dependency-pulled nodes: silent on success; failures get
|
|
646
|
-
// the
|
|
742
|
+
// the ◼ row now and their frame replayed at runEnd.
|
|
647
743
|
if (outcome.status === 'failed') {
|
|
648
744
|
emitLine(formatFailureLine(node.id, outcome.durationMs, colors, cacheWordOf(node)))
|
|
649
745
|
deferredFailures.push(
|
|
@@ -663,7 +759,7 @@ export function defaultLogger(
|
|
|
663
759
|
return
|
|
664
760
|
}
|
|
665
761
|
if (outcome.status === 'skipped') {
|
|
666
|
-
emitLine(formatTaskSkippedLine(node, colors))
|
|
762
|
+
emitLine(formatTaskSkippedLine(node, colors, outcome.blockedBy))
|
|
667
763
|
return
|
|
668
764
|
}
|
|
669
765
|
const block = formatTaskBlock(node, outcome, { stdout, stderr, ...dropped }, colors)
|
|
@@ -676,10 +772,10 @@ export function defaultLogger(
|
|
|
676
772
|
// Failed tasks stay pre-expanded in the Actions viewer:
|
|
677
773
|
// an ::error annotation instead of a collapsed group.
|
|
678
774
|
if (outcome.status === 'failed') {
|
|
679
|
-
// The message is ours (a literal
|
|
680
|
-
// carries user input.
|
|
775
|
+
// The message is ours (a literal, an integer and a signal
|
|
776
|
+
// name); only the title carries user input.
|
|
681
777
|
emitBlock(
|
|
682
|
-
`::error title=${ghaProperty(node.id)}
|
|
778
|
+
`::error title=${ghaProperty(node.id)}::${failedLabel(outcome.exitCode, outcome.timedOut, outcome.sandboxViolations, outcome.notReady)}\n${body}`,
|
|
683
779
|
)
|
|
684
780
|
} else {
|
|
685
781
|
emitBlock(
|
|
@@ -15,6 +15,8 @@ import type { Database } from 'bun:sqlite'
|
|
|
15
15
|
import { KEYED_RUNS_SQL } from '../cache/index.js'
|
|
16
16
|
import { splitTaskId } from '../graph/index.js'
|
|
17
17
|
import { clampInt } from '../util/index.js'
|
|
18
|
+
import type { TaskInputComponent } from './task-hash.js'
|
|
19
|
+
import { isPassStatus } from './telemetry.js'
|
|
18
20
|
|
|
19
21
|
// ---------------------------------------------------------------------------
|
|
20
22
|
// Run listing + detail
|
|
@@ -39,6 +41,11 @@ export interface RunSummaryRow {
|
|
|
39
41
|
// (bigints aren't JSON-safe); use BigInt or Number on the client.
|
|
40
42
|
wallclockStartNs: string | null
|
|
41
43
|
wallclockEndNs: string | null
|
|
44
|
+
/** Why the task failed or was skipped (v27); null where the reason does not apply. */
|
|
45
|
+
blockedBy: string | null
|
|
46
|
+
timedOut: boolean | null
|
|
47
|
+
sandboxViolations: number | null
|
|
48
|
+
notReady: 'timeout' | 'exited' | 'spawn' | null
|
|
42
49
|
}
|
|
43
50
|
|
|
44
51
|
export interface ListRunsArgs {
|
|
@@ -69,12 +76,18 @@ export function listRuns(db: Database, args: ListRunsArgs = {}): RunSummaryRow[]
|
|
|
69
76
|
const clause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''
|
|
70
77
|
type RawRow = Omit<
|
|
71
78
|
RunSummaryRow,
|
|
72
|
-
'cacheHit' | 'cached' | 'wallclockStartNs' | 'wallclockEndNs'
|
|
79
|
+
'cacheHit' | 'cached' | 'wallclockStartNs' | 'wallclockEndNs' | 'timedOut'
|
|
73
80
|
> & {
|
|
74
81
|
cacheHit: number | null
|
|
75
82
|
cached: number | null
|
|
76
|
-
|
|
77
|
-
|
|
83
|
+
// NOT bigint: `bun:sqlite` hands these back as JS numbers (the handle
|
|
84
|
+
// does not set `safeIntegers`), so the annotation claimed a precision
|
|
85
|
+
// the read never had. Harmless in practice — these are ns RELATIVE to
|
|
86
|
+
// run t=0, and 2^53 ns is 104 days of run time — but a `bigint` here
|
|
87
|
+
// was simply false. `.toString()` reads the same on either.
|
|
88
|
+
wallclockStartNs: number | null
|
|
89
|
+
wallclockEndNs: number | null
|
|
90
|
+
timedOut: number | null
|
|
78
91
|
}
|
|
79
92
|
const rows = db
|
|
80
93
|
.query(
|
|
@@ -82,14 +95,17 @@ export function listRuns(db: Database, args: ListRunsArgs = {}): RunSummaryRow[]
|
|
|
82
95
|
duration_ms AS durationMs, started_at AS startedAt, ended_at AS endedAt,
|
|
83
96
|
cache_hit AS cacheHit, cached, hash,
|
|
84
97
|
cpu_ms AS cpuMs, peak_rss_bytes AS peakRssBytes,
|
|
85
|
-
wallclock_start_ns AS wallclockStartNs, wallclock_end_ns AS wallclockEndNs
|
|
86
|
-
|
|
98
|
+
wallclock_start_ns AS wallclockStartNs, wallclock_end_ns AS wallclockEndNs,
|
|
99
|
+
blocked_by AS blockedBy, timed_out AS timedOut,
|
|
100
|
+
sandbox_violations AS sandboxViolations, not_ready AS notReady
|
|
101
|
+
FROM runs ${clause} ORDER BY id DESC LIMIT ?`,
|
|
87
102
|
)
|
|
88
103
|
.all(...params, limit) as RawRow[]
|
|
89
104
|
return rows.map((r) => ({
|
|
90
105
|
...r,
|
|
91
106
|
cacheHit: r.cacheHit === null ? null : Boolean(r.cacheHit),
|
|
92
107
|
cached: r.cached === null ? null : Boolean(r.cached),
|
|
108
|
+
timedOut: r.timedOut === null ? null : Boolean(r.timedOut),
|
|
93
109
|
wallclockStartNs: r.wallclockStartNs === null ? null : r.wallclockStartNs.toString(),
|
|
94
110
|
wallclockEndNs: r.wallclockEndNs === null ? null : r.wallclockEndNs.toString(),
|
|
95
111
|
}))
|
|
@@ -264,7 +280,7 @@ export function listInvocations(
|
|
|
264
280
|
const rows = db
|
|
265
281
|
.query(
|
|
266
282
|
`SELECT ${INVOCATION_COLUMNS} FROM invocations ${clause}
|
|
267
|
-
ORDER BY
|
|
283
|
+
ORDER BY rowid DESC LIMIT ?`,
|
|
268
284
|
)
|
|
269
285
|
.all(...params, limit) as InvocationRawRow[]
|
|
270
286
|
return rows.map(mapInvocation)
|
|
@@ -370,25 +386,72 @@ export interface WhyDidThisRerun {
|
|
|
370
386
|
note: string
|
|
371
387
|
}
|
|
372
388
|
|
|
373
|
-
/**
|
|
374
|
-
|
|
375
|
-
|
|
389
|
+
/**
|
|
390
|
+
* The endings an UNCHANGED cache key can have. A re-execution is told apart
|
|
391
|
+
* by the evidence the index holds: every one used to blame `--no-cache` /
|
|
392
|
+
* `--force`, when the previous run on the key had failed (a failure saves
|
|
393
|
+
* nothing) or the entry had been pruned, and the invocation recorded that
|
|
394
|
+
* no flag was passed (item 1009).
|
|
395
|
+
*/
|
|
396
|
+
function unchangedKeyNote(
|
|
397
|
+
db: Database,
|
|
398
|
+
runId: string,
|
|
399
|
+
this_: { hash: string; cacheHit: number | null; startedAt: number },
|
|
400
|
+
prev: { status: string },
|
|
401
|
+
): string {
|
|
402
|
+
if (this_.cacheHit === null) {
|
|
376
403
|
return 'cache key unchanged — this run recorded no cache outcome, so whether it re-ran is unknown'
|
|
377
404
|
}
|
|
378
|
-
return
|
|
379
|
-
|
|
380
|
-
|
|
405
|
+
if (this_.cacheHit) return 'cache key unchanged — this run was served from cache, nothing re-ran'
|
|
406
|
+
if (prev.status === 'failed') {
|
|
407
|
+
return 'cache key unchanged — the previous run on this key failed and saved nothing, so there was nothing to hit'
|
|
408
|
+
}
|
|
409
|
+
const policy = (
|
|
410
|
+
db.query('SELECT cache_policy AS p FROM invocations WHERE run_id = ?').get(runId) as {
|
|
411
|
+
p: string
|
|
412
|
+
} | null
|
|
413
|
+
)?.p
|
|
414
|
+
if (policy !== undefined && !policy.split(',').some((axis) => axis === 'lR' || axis === 'rR')) {
|
|
415
|
+
return 'cache key unchanged — re-executed because this run did not read the cache (--force, or a --cache without read)'
|
|
416
|
+
}
|
|
417
|
+
// `get` answers null, not undefined, for no row.
|
|
418
|
+
const createdAt = (
|
|
419
|
+
db.query('SELECT created_at AS at FROM entries WHERE hash = ?').get(this_.hash) as {
|
|
420
|
+
at: number
|
|
421
|
+
} | null
|
|
422
|
+
)?.at
|
|
423
|
+
if (createdAt !== undefined && createdAt >= this_.startedAt) {
|
|
424
|
+
return 'cache key unchanged — no entry for this key was in the cache when it ran (pruned or evicted), so it executed and saved one'
|
|
425
|
+
}
|
|
426
|
+
return 'cache key unchanged — re-executed on the same key (--no-cache / --force, or unrelated)'
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* The latest recorded run of a task (run_id may be NULL on very old rows).
|
|
431
|
+
* One query for `vx why` and `vx mcp`: the CLI defaulted to it while the
|
|
432
|
+
* tool demanded a run id an agent had to fetch first (2026-09-16).
|
|
433
|
+
*/
|
|
434
|
+
export function latestRunId(db: Database, taskId: string): string | null {
|
|
435
|
+
const [project, task] = splitTaskId(taskId)
|
|
436
|
+
const row = db
|
|
437
|
+
.query(
|
|
438
|
+
`SELECT run_id AS runId FROM runs WHERE project = ? AND task = ?
|
|
439
|
+
ORDER BY id DESC LIMIT 1`,
|
|
440
|
+
)
|
|
441
|
+
.get(project, task) as { runId: string | null } | undefined
|
|
442
|
+
return row?.runId ?? null
|
|
381
443
|
}
|
|
382
444
|
|
|
383
445
|
export function whyDidThisRerun(db: Database, runId: string, taskId: string): WhyDidThisRerun {
|
|
384
446
|
const [project, task] = splitTaskId(taskId)
|
|
385
447
|
const this_ = db
|
|
386
448
|
.query(
|
|
387
|
-
`SELECT hash, status, cache_hit AS cacheHit, cached, started_at AS startedAt
|
|
449
|
+
`SELECT id, hash, status, cache_hit AS cacheHit, cached, started_at AS startedAt
|
|
388
450
|
FROM runs WHERE run_id = ? AND project = ? AND task = ?`,
|
|
389
451
|
)
|
|
390
452
|
.get(runId, project, task) as
|
|
391
453
|
| {
|
|
454
|
+
id: number
|
|
392
455
|
hash: string
|
|
393
456
|
status: string
|
|
394
457
|
cacheHit: number | null
|
|
@@ -411,10 +474,10 @@ export function whyDidThisRerun(db: Database, runId: string, taskId: string): Wh
|
|
|
411
474
|
const prev = db
|
|
412
475
|
.query(
|
|
413
476
|
`SELECT hash, status, cache_hit AS cacheHit, started_at AS startedAt
|
|
414
|
-
FROM runs WHERE project = ? AND task = ? AND
|
|
415
|
-
ORDER BY
|
|
477
|
+
FROM runs WHERE project = ? AND task = ? AND id < ? AND ${KEYED_RUNS_SQL}
|
|
478
|
+
ORDER BY id DESC LIMIT 1`,
|
|
416
479
|
)
|
|
417
|
-
.get(project, task, this_.
|
|
480
|
+
.get(project, task, this_.id) as
|
|
418
481
|
| { hash: string; status: string; cacheHit: number | null; startedAt: number }
|
|
419
482
|
| undefined
|
|
420
483
|
// …and this run must have one too, or there is nothing to compare.
|
|
@@ -424,9 +487,11 @@ export function whyDidThisRerun(db: Database, runId: string, taskId: string): Wh
|
|
|
424
487
|
taskId,
|
|
425
488
|
found: true,
|
|
426
489
|
thisRun: {
|
|
427
|
-
|
|
490
|
+
hash: this_.hash,
|
|
491
|
+
status: this_.status,
|
|
428
492
|
cacheHit: this_.cacheHit === null ? null : Boolean(this_.cacheHit),
|
|
429
493
|
cached: this_.cached === null ? null : Boolean(this_.cached),
|
|
494
|
+
startedAt: this_.startedAt,
|
|
430
495
|
},
|
|
431
496
|
previousRun: prev
|
|
432
497
|
? { ...prev, cacheHit: prev.cacheHit === null ? null : Boolean(prev.cacheHit) }
|
|
@@ -447,7 +512,7 @@ export function whyDidThisRerun(db: Database, runId: string, taskId: string): Wh
|
|
|
447
512
|
// have applied. Only a run that EXECUTED on an unchanged key is
|
|
448
513
|
// the case this verb exists to explain. A row with no recorded
|
|
449
514
|
// cacheHit (older rows) is neither — say that, do not guess.
|
|
450
|
-
unchangedKeyNote(this_
|
|
515
|
+
unchangedKeyNote(db, runId, this_, prev)
|
|
451
516
|
: 'no prior run for this (project, task)',
|
|
452
517
|
}
|
|
453
518
|
}
|
|
@@ -478,17 +543,55 @@ export interface CacheKeyDiff {
|
|
|
478
543
|
note: string
|
|
479
544
|
}
|
|
480
545
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
546
|
+
function loadEntryInputs(db: Database, entryHash: string): TaskInputComponent[] {
|
|
547
|
+
return db
|
|
548
|
+
.query('SELECT kind, name, hash FROM entry_inputs WHERE entry_hash = ?')
|
|
549
|
+
.all(entryHash) as TaskInputComponent[]
|
|
485
550
|
}
|
|
486
551
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
552
|
+
/**
|
|
553
|
+
* The rule `vx why` and the site's playground both name a moved key by: join
|
|
554
|
+
* two keys' components over `(kind, name)`. A component in both with a
|
|
555
|
+
* different hash is `changed`, one only in `after` is `added`, one only in
|
|
556
|
+
* `before` is `removed`, and an equal one is counted. Entries are ordered by
|
|
557
|
+
* kind, then name.
|
|
558
|
+
*/
|
|
559
|
+
export function diffKeyComponents(
|
|
560
|
+
before: readonly TaskInputComponent[],
|
|
561
|
+
after: readonly TaskInputComponent[],
|
|
562
|
+
): { entries: InputDiffEntry[]; unchangedCount: number } {
|
|
563
|
+
const id = (c: TaskInputComponent): string => `${c.kind}\0${c.name}`
|
|
564
|
+
const cur = new Map(after.map((c) => [id(c), c]))
|
|
565
|
+
const old = new Map(before.map((c) => [id(c), c]))
|
|
566
|
+
const entries: InputDiffEntry[] = []
|
|
567
|
+
let unchangedCount = 0
|
|
568
|
+
const keys = new Set<string>([...cur.keys(), ...old.keys()])
|
|
569
|
+
for (const key of keys) {
|
|
570
|
+
const a = cur.get(key)
|
|
571
|
+
const b = old.get(key)
|
|
572
|
+
if (a && b) {
|
|
573
|
+
if (a.hash === b.hash) unchangedCount++
|
|
574
|
+
else
|
|
575
|
+
entries.push({
|
|
576
|
+
kind: a.kind,
|
|
577
|
+
name: a.name,
|
|
578
|
+
change: 'changed',
|
|
579
|
+
before: b.hash,
|
|
580
|
+
after: a.hash,
|
|
581
|
+
})
|
|
582
|
+
} else if (a) {
|
|
583
|
+
entries.push({ kind: a.kind, name: a.name, change: 'added', before: null, after: a.hash })
|
|
584
|
+
} else if (b) {
|
|
585
|
+
entries.push({ kind: b.kind, name: b.name, change: 'removed', before: b.hash, after: null })
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
// Code-unit order, not `localeCompare`: collation follows the machine's
|
|
589
|
+
// locale, and the playground runs this same join in the reader's browser,
|
|
590
|
+
// so the CLI and the page could list one diff in two orders. Code units
|
|
591
|
+
// are also SQLite's BINARY order, the `entry_inputs` scan's own.
|
|
592
|
+
const byCodeUnit = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0)
|
|
593
|
+
entries.sort((x, y) => byCodeUnit(x.kind, y.kind) || byCodeUnit(x.name, y.name))
|
|
594
|
+
return { entries, unchangedCount }
|
|
492
595
|
}
|
|
493
596
|
|
|
494
597
|
/**
|
|
@@ -503,7 +606,8 @@ function loadEntryInputs(db: Database, entryHash: string): Map<string, EntryInpu
|
|
|
503
606
|
* - only in the previous run → `removed`
|
|
504
607
|
* - equal → counted as unchanged
|
|
505
608
|
*
|
|
506
|
-
* Pure SQL + an app-side set join — no config
|
|
609
|
+
* Pure SQL + an app-side set join (`diffKeyComponents`) — no config
|
|
610
|
+
* re-evaluation, no re-hash.
|
|
507
611
|
* Always returns a value (never throws); `found:false` when the run/task pair
|
|
508
612
|
* has no row, `entries:[]` for the first run of a task.
|
|
509
613
|
*/
|
|
@@ -511,10 +615,10 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
|
|
|
511
615
|
const [project, task] = splitTaskId(taskId)
|
|
512
616
|
const this_ = db
|
|
513
617
|
.query(
|
|
514
|
-
'SELECT hash, cached, started_at AS startedAt FROM runs WHERE run_id = ? AND project = ? AND task = ?',
|
|
618
|
+
'SELECT id, hash, cached, status, started_at AS startedAt FROM runs WHERE run_id = ? AND project = ? AND task = ?',
|
|
515
619
|
)
|
|
516
620
|
.get(runId, project, task) as
|
|
517
|
-
| { hash: string; cached: number | null; startedAt: number }
|
|
621
|
+
| { id: number; hash: string; cached: number | null; status: string; startedAt: number }
|
|
518
622
|
| undefined
|
|
519
623
|
if (!this_) {
|
|
520
624
|
return {
|
|
@@ -545,11 +649,13 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
|
|
|
545
649
|
|
|
546
650
|
const prev = db
|
|
547
651
|
.query(
|
|
548
|
-
`SELECT run_id AS runId, hash FROM runs
|
|
549
|
-
WHERE project = ? AND task = ? AND
|
|
550
|
-
ORDER BY
|
|
652
|
+
`SELECT run_id AS runId, hash, status FROM runs
|
|
653
|
+
WHERE project = ? AND task = ? AND id < ? AND ${KEYED_RUNS_SQL}
|
|
654
|
+
ORDER BY id DESC LIMIT 1`,
|
|
551
655
|
)
|
|
552
|
-
.get(project, task, this_.
|
|
656
|
+
.get(project, task, this_.id) as
|
|
657
|
+
| { runId: string | null; hash: string; status: string }
|
|
658
|
+
| undefined
|
|
553
659
|
|
|
554
660
|
if (!prev) {
|
|
555
661
|
return {
|
|
@@ -581,7 +687,15 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
|
|
|
581
687
|
// Input fingerprints are pruned with their entry (ON DELETE CASCADE); if
|
|
582
688
|
// either side's rows are gone, we can name the hash change but not the
|
|
583
689
|
// component-level diff.
|
|
584
|
-
if (cur.
|
|
690
|
+
if (cur.length === 0 || old.length === 0) {
|
|
691
|
+
// A run that did not pass saved no entry, so it kept no fingerprints:
|
|
692
|
+
// that is the cause to name, not a prune (item 898).
|
|
693
|
+
const unsaved =
|
|
694
|
+
cur.length === 0 && !isPassStatus(this_.status)
|
|
695
|
+
? `this run ended ${this_.status}`
|
|
696
|
+
: old.length === 0 && !isPassStatus(prev.status)
|
|
697
|
+
? `the previous run ended ${prev.status}`
|
|
698
|
+
: null
|
|
585
699
|
return {
|
|
586
700
|
runId,
|
|
587
701
|
taskId,
|
|
@@ -591,34 +705,16 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
|
|
|
591
705
|
unchangedCount: 0,
|
|
592
706
|
note:
|
|
593
707
|
this_.cached === 0
|
|
594
|
-
?
|
|
595
|
-
|
|
708
|
+
? // The verdict already says the task is uncached; this line says
|
|
709
|
+
// what moved a key that declares nothing.
|
|
710
|
+
'no `cache` block declares its inputs, so its key folds every file in its project — a file it wrote, or any untracked file, moves it — and no fingerprints are kept to say which'
|
|
711
|
+
: unsaved !== null
|
|
712
|
+
? `cache key changed but input fingerprints are unavailable — ${unsaved} and saved no entry; only the key change is known`
|
|
713
|
+
: 'cache key changed but input fingerprints are unavailable — the entry was pruned (or the row predates the `cached` column); only the key change is known',
|
|
596
714
|
}
|
|
597
715
|
}
|
|
598
716
|
|
|
599
|
-
const entries
|
|
600
|
-
let unchangedCount = 0
|
|
601
|
-
const keys = new Set<string>([...cur.keys(), ...old.keys()])
|
|
602
|
-
for (const key of keys) {
|
|
603
|
-
const a = cur.get(key)
|
|
604
|
-
const b = old.get(key)
|
|
605
|
-
if (a && b) {
|
|
606
|
-
if (a.hash === b.hash) unchangedCount++
|
|
607
|
-
else
|
|
608
|
-
entries.push({
|
|
609
|
-
kind: a.kind,
|
|
610
|
-
name: a.name,
|
|
611
|
-
change: 'changed',
|
|
612
|
-
before: b.hash,
|
|
613
|
-
after: a.hash,
|
|
614
|
-
})
|
|
615
|
-
} else if (a) {
|
|
616
|
-
entries.push({ kind: a.kind, name: a.name, change: 'added', before: null, after: a.hash })
|
|
617
|
-
} else if (b) {
|
|
618
|
-
entries.push({ kind: b.kind, name: b.name, change: 'removed', before: b.hash, after: null })
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
entries.sort((x, y) => x.kind.localeCompare(y.kind) || x.name.localeCompare(y.name))
|
|
717
|
+
const { entries, unchangedCount } = diffKeyComponents(old, cur)
|
|
622
718
|
|
|
623
719
|
return {
|
|
624
720
|
runId,
|