@vzn/vx 0.0.18 → 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/LICENSE +1 -1
- package/README.md +107 -70
- package/package.json +10 -20
- package/src/bin.ts +64 -5
- package/src/cache/archive.ts +279 -28
- package/src/cache/cache.ts +952 -1584
- package/src/cache/chained-cache.ts +14 -10
- package/src/cache/config-evals.ts +112 -0
- package/src/cache/file-hashes.ts +290 -0
- package/src/cache/git-inputs.ts +966 -0
- package/src/cache/index.ts +23 -7
- package/src/cache/inputs.ts +553 -739
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +674 -0
- package/src/cache/layered-cache.ts +211 -49
- package/src/cache/output-index.ts +323 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +152 -0
- package/src/cache/zstd.ts +183 -0
- package/src/cli/cache.ts +88 -30
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +76 -38
- package/src/cli/index.ts +82 -17
- package/src/cli/info.ts +162 -91
- package/src/cli/init.ts +87 -0
- package/src/cli/last.ts +130 -23
- package/src/cli/lock.ts +36 -18
- package/src/cli/plan-format.ts +18 -7
- package/src/cli/plugin-commands.ts +34 -12
- package/src/cli/run.ts +138 -313
- package/src/cli/select.ts +342 -0
- package/src/cli/show.ts +151 -71
- package/src/cli/upgrade.ts +136 -15
- package/src/cli/watch.ts +1175 -116
- package/src/cli/why.ts +51 -29
- package/src/cli/workspace-config.ts +138 -0
- package/src/config.ts +183 -126
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +62 -31
- package/src/exec/index.ts +16 -2
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +361 -77
- package/src/exec/sandbox-binds.ts +211 -0
- package/src/exec/sandbox-paths.ts +70 -0
- package/src/exec/sandbox-runtime.ts +1152 -415
- package/src/exec/sandbox-violations.ts +293 -0
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +6 -4
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +332 -329
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +111 -102
- package/src/orchestrator/admission.ts +161 -0
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +25 -7
- package/src/orchestrator/doctor.ts +363 -0
- package/src/orchestrator/events.ts +111 -8
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +450 -565
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +121 -49
- package/src/orchestrator/history.ts +123 -104
- package/src/orchestrator/hit-restore.ts +285 -0
- package/src/orchestrator/index.ts +42 -5
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +401 -0
- package/src/orchestrator/logger.ts +128 -41
- package/src/orchestrator/metrics.ts +201 -72
- package/src/orchestrator/miss-save.ts +232 -0
- package/src/orchestrator/options.ts +63 -34
- package/src/orchestrator/persistent.ts +99 -0
- package/src/orchestrator/placement.ts +212 -0
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +200 -49
- package/src/orchestrator/plugin.ts +190 -14
- package/src/orchestrator/prepare.ts +168 -137
- package/src/orchestrator/projects.ts +270 -0
- package/src/orchestrator/remote-prefetch.ts +22 -2
- package/src/orchestrator/run-artifacts.ts +34 -3
- package/src/orchestrator/run-context.ts +20 -5
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +132 -0
- package/src/orchestrator/run-report.ts +6 -4
- package/src/orchestrator/run.ts +467 -611
- package/src/orchestrator/sandbox-request.ts +569 -0
- package/src/orchestrator/save-lane.ts +44 -0
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +167 -0
- package/src/orchestrator/stable-keys.ts +259 -10
- package/src/orchestrator/status-line.ts +35 -2
- package/src/orchestrator/summary.ts +115 -25
- package/src/orchestrator/task-hash.ts +103 -21
- package/src/orchestrator/task-log-buffer.ts +15 -18
- package/src/orchestrator/telemetry-host.ts +27 -8
- package/src/orchestrator/telemetry.ts +71 -44
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +42 -5
- package/src/util/paths.ts +168 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +23 -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/verbs.ts +39 -0
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +368 -60
- package/src/workspace/config-cache.ts +271 -32
- package/src/workspace/config-eval.ts +60 -16
- package/src/workspace/config-imports.ts +131 -9
- package/src/workspace/config-schema.ts +1033 -0
- package/src/workspace/filter.ts +69 -17
- package/src/workspace/fingerprint.ts +59 -7
- package/src/workspace/index.ts +37 -3
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +41 -14
- package/src/workspace/migrate-scripts.ts +187 -0
- package/src/workspace/migration.ts +353 -0
- package/src/workspace/package-graph.ts +239 -31
- package/src/workspace/project-loader.ts +295 -815
- package/src/workspace/workspace.ts +266 -31
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-scripts.ts +0 -127
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/migrate.ts +0 -305
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/local-executor/index.ts +0 -45
- package/src/plugins/schedule-history/index.ts +0 -109
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
29
29
|
import { paint, type ColorSupport } from './colors.js'
|
|
30
30
|
import { formatDuration } from './summary.js'
|
|
31
|
+
import { outcomeLabel, skippedReason } from './events.js'
|
|
32
|
+
import type { RecapTail } from './failure-recap.js'
|
|
31
33
|
|
|
32
34
|
const NO_COLOR: ColorSupport = { enabled: false }
|
|
33
35
|
|
|
@@ -92,7 +94,6 @@ export function formatTaskBlock(
|
|
|
92
94
|
|
|
93
95
|
const idPainted = paintTaskId(node, colors, { bold: true })
|
|
94
96
|
const corner = (s: string) => paint('', s, colors, { dim: true })
|
|
95
|
-
const section = (title: string, color?: string) => sectionLine(title, color ?? '', colors)
|
|
96
97
|
const header = formatBlockHeader(outcome, colors)
|
|
97
98
|
const lines: string[] = [`${corner('┌─')} ${idPainted} ${corner('>')} ${header}`]
|
|
98
99
|
|
|
@@ -109,15 +110,7 @@ export function formatTaskBlock(
|
|
|
109
110
|
pushStreamSection(lines, stdout, 'STDOUT', SUCCESS, body.droppedStdout ?? 0, colors)
|
|
110
111
|
pushStreamSection(lines, stderr, 'STDERR', ERROR, body.droppedStderr ?? 0, colors)
|
|
111
112
|
|
|
112
|
-
|
|
113
|
-
// user sees them in context with the failing task, not as loose
|
|
114
|
-
// status output above the box.
|
|
115
|
-
const vlines = outcome.sandboxViolationLines
|
|
116
|
-
if (vlines && vlines.length > 0) {
|
|
117
|
-
lines.push(section(`SANDBOX VIOLATIONS (${vlines.length})`, WARN), '')
|
|
118
|
-
for (const v of vlines) lines.push(v)
|
|
119
|
-
lines.push('')
|
|
120
|
-
}
|
|
113
|
+
lines.push(...violationSection(outcome, colors))
|
|
121
114
|
|
|
122
115
|
lines.push(`${corner('└─')} ${idPainted} ${corner('──')}${formatBlockFooter(outcome, colors)}`)
|
|
123
116
|
return lines.join('\n') + '\n'
|
|
@@ -213,14 +206,14 @@ function paintTaskId(node: TaskNode, colors: ColorSupport, opts: { bold?: boolea
|
|
|
213
206
|
|
|
214
207
|
// ── Reported-line grid: glyph · time · status · cache · name ────────
|
|
215
208
|
// glyph SHAPE encodes the cache axis (⏺ miss · ► fresh · ⇢ local · ⇣
|
|
216
|
-
// remote; ◼ failed · ⊘ skipped
|
|
209
|
+
// remote; ◼ failed · ⊘ skipped), glyph COLOR encodes the
|
|
217
210
|
// task axis (green/red/yellow/cyan). The status and cache WORDS spell
|
|
218
211
|
// the two axes out, each in its own color. Time is right-aligned in a
|
|
219
212
|
// fixed cell so durations line up and a ticking elapsed never shifts
|
|
220
213
|
// the row. All detail (exit code, output) lives in the framed block.
|
|
221
214
|
export const TIME_COL = 7 // "0ms" … "999.99s"
|
|
222
215
|
const STATUS_COL = 7 // "success" / "running" / "skipped"
|
|
223
|
-
const CACHE_COL =
|
|
216
|
+
const CACHE_COL = 8 // "no-cache" / "remote" / "local" / "fresh" / "miss"
|
|
224
217
|
|
|
225
218
|
/** Right-align the duration in a TIME_COL cell (pad on the left). */
|
|
226
219
|
function timeCell(ms: number | null, colors: ColorSupport): string {
|
|
@@ -278,7 +271,8 @@ function cacheOf(o: TaskOutcome): { word: string; color: string } {
|
|
|
278
271
|
switch (o.status) {
|
|
279
272
|
case 'success':
|
|
280
273
|
case 'failed':
|
|
281
|
-
|
|
274
|
+
// A task with no `cache` block never consulted the cache: not a miss.
|
|
275
|
+
return { word: o.node.config.cache === undefined ? 'no-cache' : 'miss', color: '' } // dim
|
|
282
276
|
case 'cache-hit':
|
|
283
277
|
return o.restored === false
|
|
284
278
|
? { word: 'fresh', color: SUCCESS }
|
|
@@ -357,17 +351,29 @@ function formatOutcomeRow(
|
|
|
357
351
|
)
|
|
358
352
|
}
|
|
359
353
|
|
|
360
|
-
/**
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
354
|
+
/**
|
|
355
|
+
* Compact one-liner for a skipped task — it never ran (blank time). The
|
|
356
|
+
* blocker rides the row (`• blocked by lib#build`): the footer's Skipped
|
|
357
|
+
* section groups the same fact, but the row is where the reader's eye is.
|
|
358
|
+
*/
|
|
359
|
+
export function formatTaskSkippedLine(
|
|
360
|
+
node: TaskNode,
|
|
361
|
+
colors: ColorSupport = NO_COLOR,
|
|
362
|
+
blockedBy?: string,
|
|
363
|
+
): string {
|
|
364
|
+
const why = skippedReason(blockedBy)
|
|
365
|
+
const suffix = why === '' ? '' : ` ${paint('', `• ${why}`, colors, { dim: true })}`
|
|
366
|
+
return (
|
|
367
|
+
formatTaskRow(
|
|
368
|
+
paint(WARN, '\u2298', colors),
|
|
369
|
+
null,
|
|
370
|
+
'skipped',
|
|
371
|
+
WARN,
|
|
372
|
+
'',
|
|
373
|
+
'',
|
|
374
|
+
paintTaskId(node, colors),
|
|
375
|
+
colors,
|
|
376
|
+
) + suffix
|
|
371
377
|
)
|
|
372
378
|
}
|
|
373
379
|
|
|
@@ -385,6 +391,27 @@ export function formatFrameOpen(node: TaskNode, colors: ColorSupport = NO_COLOR)
|
|
|
385
391
|
return `${corner('┌─')} ${mark}${paintTaskId(node, colors, { bold: true })} ${corner('>')} $ ${cmd}`
|
|
386
392
|
}
|
|
387
393
|
|
|
394
|
+
/**
|
|
395
|
+
* Sandbox violations get a dedicated section inside the frame so the user
|
|
396
|
+
* sees them in context with the failing task, not as loose status output
|
|
397
|
+
* above the box. Shared by BOTH renderers: the focused/live frame used to
|
|
398
|
+
* omit it, so a single-task run — exactly how someone debugging one task
|
|
399
|
+
* invokes vx — showed a failure with no violations at all (owner,
|
|
400
|
+
* 2026-09-05).
|
|
401
|
+
*/
|
|
402
|
+
function violationSection(outcome: TaskOutcome, colors: ColorSupport): string[] {
|
|
403
|
+
const vlines = outcome.sandboxViolationLines
|
|
404
|
+
if (!vlines || vlines.length === 0) return []
|
|
405
|
+
// Unique lines only — a task's children trip the same denial many times
|
|
406
|
+
// and the repeats bury everything else. The lines themselves are printed
|
|
407
|
+
// VERBATIM: a violation is evidence, and evidence that has been rewritten
|
|
408
|
+
// for display is not the thing the sandbox reported. The outcome keeps
|
|
409
|
+
// every record for telemetry.
|
|
410
|
+
const body = [...new Set(vlines)]
|
|
411
|
+
// Red, not amber: a violation fails the task, so it reads as a failure.
|
|
412
|
+
return [sectionLine(`SANDBOX VIOLATIONS (${body.length})`, ERROR, colors), '', ...body, '']
|
|
413
|
+
}
|
|
414
|
+
|
|
388
415
|
export function formatFrameClose(
|
|
389
416
|
node: TaskNode,
|
|
390
417
|
outcome: TaskOutcome,
|
|
@@ -400,7 +427,9 @@ export function formatFrameClose(
|
|
|
400
427
|
persistent && outcome.status === 'success'
|
|
401
428
|
? ` ${paint('', `(${formatDuration(outcome.durationMs)})`, colors, { dim: true })} ${paint(ACCENT, 'running', colors)}`
|
|
402
429
|
: formatBlockFooter(outcome, colors)
|
|
403
|
-
|
|
430
|
+
const close = `${corner('└─')} ${mark}${paintTaskId(node, colors, { bold: true })} ${corner('──')}${tail}`
|
|
431
|
+
const violations = violationSection(outcome, colors)
|
|
432
|
+
return violations.length === 0 ? close : `${violations.join('\n')}\n${close}`
|
|
404
433
|
}
|
|
405
434
|
|
|
406
435
|
function isPersistentNode(node: TaskNode): boolean {
|
|
@@ -451,30 +480,78 @@ export function formatPersistentList(
|
|
|
451
480
|
)
|
|
452
481
|
}
|
|
453
482
|
|
|
483
|
+
/** One failed task's section of the end-of-run recap. */
|
|
484
|
+
export interface RecapEntry {
|
|
485
|
+
node: TaskNode
|
|
486
|
+
outcome: TaskOutcome
|
|
487
|
+
tail: RecapTail
|
|
488
|
+
/** Characters a bounded capture (a persistent task's) dropped before the tail saw them. */
|
|
489
|
+
droppedChars: number
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* The run's last block: each failed task's last lines, under the failure's
|
|
494
|
+
* label, then the ids of the failures past the tail limit. The lines are
|
|
495
|
+
* raw, like a frame's, and `fence` wraps them where a task's text could be
|
|
496
|
+
* read as something other than text (GitHub Actions' workflow commands).
|
|
497
|
+
*/
|
|
498
|
+
export function formatFailureRecap(
|
|
499
|
+
entries: readonly RecapEntry[],
|
|
500
|
+
more: readonly string[],
|
|
501
|
+
colors: ColorSupport = NO_COLOR,
|
|
502
|
+
fence: (lines: string[]) => string[] = (lines) => lines,
|
|
503
|
+
): string[] {
|
|
504
|
+
const total = entries.length + more.length
|
|
505
|
+
const dim = (s: string) => paint('', s, colors, { dim: true })
|
|
506
|
+
const lines = [
|
|
507
|
+
'',
|
|
508
|
+
` Failed: ${total} task${total === 1 ? '' : 's'} — the last lines ${total === 1 ? 'it' : 'each one'} printed`,
|
|
509
|
+
]
|
|
510
|
+
for (const e of entries) {
|
|
511
|
+
lines.push(
|
|
512
|
+
'',
|
|
513
|
+
` ${paint(ERROR, '◼︎', colors)} ${paintTaskId(e.node, colors, { bold: true })} ${dim('—')} ${paint(ERROR, outcomeLabel(e.outcome), colors, { bold: true })}`,
|
|
514
|
+
)
|
|
515
|
+
const cut: string[] = []
|
|
516
|
+
const { earlierLines, cutBytes } = e.tail
|
|
517
|
+
if (earlierLines > 0)
|
|
518
|
+
cut.push(`${count(earlierLines)} earlier line${earlierLines === 1 ? '' : 's'}`)
|
|
519
|
+
if (cutBytes > 0) cut.push(`${count(cutBytes)} bytes cut from the start of the line below`)
|
|
520
|
+
if (e.droppedChars > 0) {
|
|
521
|
+
cut.push(`the capture dropped ${count(e.droppedChars)} characters of the task's output`)
|
|
522
|
+
}
|
|
523
|
+
if (cut.length > 0) lines.push(dim(` … ${cut.join(', and ')}`))
|
|
524
|
+
if (e.tail.text.length === 0) lines.push(dim(' (no output)'))
|
|
525
|
+
else lines.push(...fence(e.tail.text.split('\n')))
|
|
526
|
+
}
|
|
527
|
+
if (more.length > 0) {
|
|
528
|
+
lines.push('', ` … and ${more.length} more failed: ${more.join(', ')}`)
|
|
529
|
+
}
|
|
530
|
+
return lines
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function count(n: number): string {
|
|
534
|
+
return n.toLocaleString('en-US')
|
|
535
|
+
}
|
|
536
|
+
|
|
454
537
|
function formatBlockHeader(o: TaskOutcome, colors: ColorSupport): string {
|
|
455
538
|
const shortHash = o.hash ? o.hash.slice(0, 8) : ''
|
|
456
539
|
const dim = (s: string) => paint('', s, colors, { dim: true })
|
|
540
|
+
const word = outcomeLabel(o)
|
|
457
541
|
switch (o.status) {
|
|
458
542
|
case 'cache-hit':
|
|
459
|
-
|
|
460
|
-
return `${paint(SUCCESS, 'up-to-date', colors)} ${dim(`• ${shortHash}`)}`
|
|
461
|
-
}
|
|
462
|
-
return `${paint(SUCCESS, 'restored-local', colors)} ${dim(`• ${shortHash}`)}`
|
|
543
|
+
return `${paint(SUCCESS, word, colors)} ${dim(`• ${shortHash}`)}`
|
|
463
544
|
case 'cache-hit-remote':
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
}
|
|
467
|
-
return `${paint(ACCENT, 'restored-remote', colors)} ${dim(`• ${shortHash}`)}`
|
|
545
|
+
// A remote restore is the one hit painted accent: bytes came off the wire.
|
|
546
|
+
return `${paint(o.restored === false ? SUCCESS : ACCENT, word, colors)} ${dim(`• ${shortHash}`)}`
|
|
468
547
|
case 'failed':
|
|
469
548
|
// The command lives in its own `├─ command` section; the header
|
|
470
549
|
// carries the outcome like every other status.
|
|
471
|
-
return paint(ERROR,
|
|
550
|
+
return paint(ERROR, word, colors, { bold: true })
|
|
472
551
|
case 'skipped':
|
|
473
|
-
return paint(WARN,
|
|
474
|
-
case 'success':
|
|
475
|
-
return dim('success')
|
|
552
|
+
return paint(WARN, word, colors)
|
|
476
553
|
default:
|
|
477
|
-
return
|
|
554
|
+
return dim(word)
|
|
478
555
|
}
|
|
479
556
|
}
|
|
480
557
|
|
|
@@ -492,21 +569,16 @@ function formatBlockFooter(o: TaskOutcome, colors: ColorSupport): string {
|
|
|
492
569
|
}
|
|
493
570
|
|
|
494
571
|
function formatStatusTag(o: TaskOutcome, colors: ColorSupport): string {
|
|
572
|
+
const word = outcomeLabel(o)
|
|
495
573
|
switch (o.status) {
|
|
574
|
+
case 'failed':
|
|
575
|
+
return paint(ERROR, word, colors, { bold: true })
|
|
576
|
+
case 'skipped':
|
|
577
|
+
return paint(WARN, word, colors)
|
|
496
578
|
case 'cache-hit':
|
|
497
|
-
return paint('', o.restored === false ? 'up-to-date' : 'restored-local', colors, {
|
|
498
|
-
dim: true,
|
|
499
|
-
})
|
|
500
579
|
case 'cache-hit-remote':
|
|
501
|
-
return paint('', o.restored === false ? 'up-to-date' : 'restored-remote', colors, {
|
|
502
|
-
dim: true,
|
|
503
|
-
})
|
|
504
580
|
case 'success':
|
|
505
|
-
return paint('',
|
|
506
|
-
case 'failed':
|
|
507
|
-
return paint(ERROR, `failed (exit ${o.exitCode})`, colors, { bold: true })
|
|
508
|
-
case 'skipped':
|
|
509
|
-
return paint(WARN, 'skipped', colors)
|
|
581
|
+
return paint('', word, colors, { dim: true })
|
|
510
582
|
default:
|
|
511
583
|
return o.status
|
|
512
584
|
}
|
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
// HistoryTable + HistoryProvider — historical run data the
|
|
2
|
-
//
|
|
1
|
+
// HistoryTable + HistoryProvider — historical run data the `schedule`
|
|
2
|
+
// plugin (`@vzn/vx-schedule-history`, and its `vx history` verb) and
|
|
3
|
+
// `--dry`'s time prediction read.
|
|
3
4
|
//
|
|
4
5
|
// The data has been in cache.db.runs since schema v11; what's new is
|
|
5
|
-
// surfacing it.
|
|
6
|
-
// (project, task) pair
|
|
7
|
-
// the run's lifetime. Loaded once
|
|
6
|
+
// surfacing it. One SQL statement per call pulls the last N executed rows
|
|
7
|
+
// per (project, task) pair out of a rowid-bounded slice of the table; the
|
|
8
|
+
// result becomes a read-only snapshot for the run's lifetime. Loaded once
|
|
9
|
+
// by the `schedule` stage (or `--dry`); never mutated mid-run.
|
|
8
10
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// RemoteHistoryProvider — would call a service RPC; deferred to
|
|
12
|
-
// when such an RPC actually exists.
|
|
11
|
+
// One provider ships, `LocalHistoryProvider`, reading cache.db directly
|
|
12
|
+
// (zero-config); the interface is the seam a plugin fills for any other.
|
|
13
13
|
|
|
14
14
|
import type { Database } from 'bun:sqlite'
|
|
15
15
|
import { EXECUTED_RUNS_SQL } from '../cache/index.js'
|
|
16
|
-
import {
|
|
16
|
+
import { failureModeOf, mixedOutcomeKeysSql } from './failure-mode.js'
|
|
17
17
|
import type { FailureMode } from './failure-mode.js'
|
|
18
18
|
|
|
19
19
|
const DEFAULT_RECENT = 50
|
|
@@ -30,9 +30,22 @@ export interface TaskHistory {
|
|
|
30
30
|
successRate: number
|
|
31
31
|
/** Cache hit rate over the recent window ([0, 1]). */
|
|
32
32
|
hitRate: number
|
|
33
|
-
/** Failure mode classification
|
|
34
|
-
* the
|
|
33
|
+
/** Failure mode classification over the same window (`failureModeOf`,
|
|
34
|
+
* the one rule `vx why`'s all-time query also applies). */
|
|
35
35
|
failureMode: FailureMode
|
|
36
|
+
/**
|
|
37
|
+
* Largest peak RSS (bytes) any successful execution in the window
|
|
38
|
+
* reported; undefined when none did (hits report nothing, and a
|
|
39
|
+
* platform may not expose rusage). The floor a memory reservation
|
|
40
|
+
* learned from history packs on.
|
|
41
|
+
*/
|
|
42
|
+
maxPeakRssBytes?: number
|
|
43
|
+
/**
|
|
44
|
+
* The most CPU parallelism any successful execution in the window
|
|
45
|
+
* showed: cpu time over wall time, so 2.0 means two cores busy for the
|
|
46
|
+
* whole task. Undefined when no row carried both numbers.
|
|
47
|
+
*/
|
|
48
|
+
maxCpuParallelism?: number
|
|
36
49
|
}
|
|
37
50
|
|
|
38
51
|
/** Map keyed by `project#task`. */
|
|
@@ -56,138 +69,144 @@ export class LocalHistoryProvider implements HistoryProvider {
|
|
|
56
69
|
private readonly recent: number = DEFAULT_RECENT,
|
|
57
70
|
) {}
|
|
58
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The window is the last `recent` INVOCATIONS, read as one rowid slice.
|
|
74
|
+
*
|
|
75
|
+
* `runs` has no (project, task) index: it cost every run a scattered
|
|
76
|
+
* B-tree write per task (2026-09-09, 1,000 warm hits: the record stage
|
|
77
|
+
* 57–79 ms with it, 14–19 ms without) and bought this reader nothing —
|
|
78
|
+
* ranking every pair's rows newest-first (`ROW_NUMBER` over a partition)
|
|
79
|
+
* sorted the whole table either way, 230 ms at 116k rows. Rows are
|
|
80
|
+
* appended in time order, one contiguous block per invocation, so the
|
|
81
|
+
* rows of the newest `recent` invocations are exactly `id >= MIN(id)` of
|
|
82
|
+
* the `recent`-th newest header — a primary-key range, no sort, plain
|
|
83
|
+
* aggregates, and at most one row per pair per invocation, so "the last
|
|
84
|
+
* `recent` invocations" IS the last `recent` runs for a pair that runs
|
|
85
|
+
* every time and whatever fewer it has for one that runs less often. A
|
|
86
|
+
* hint for ordering and prediction, not a ledger; `vx why` / `vx last`
|
|
87
|
+
* read by `run_id` and stay exact.
|
|
88
|
+
*/
|
|
59
89
|
async loadFor(taskIds: readonly string[]): Promise<HistoryTable> {
|
|
60
90
|
const out = new Map<string, TaskHistory>()
|
|
61
91
|
if (taskIds.length === 0) return out
|
|
62
92
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
// task names never contain `#`, and a project name (a package name) never
|
|
68
|
-
// does either.
|
|
69
|
-
const pairs = (taskIds as string[]).map(splitTaskId)
|
|
70
|
-
const tupleParams = pairs.flat()
|
|
71
|
-
const tuplePlaceholders = pairs.map(() => '(?,?)').join(',')
|
|
72
|
-
|
|
73
|
-
// One CTE per call: rank rows per (project, task) descending by
|
|
74
|
-
// started_at, keep the top `recent`, aggregate. Cache-hit rows
|
|
75
|
-
// (cache_hit = 1) are excluded from the duration percentiles so
|
|
76
|
-
// p50/p99 reflect work the runner actually did. successRate +
|
|
77
|
-
// hitRate are computed over ALL recent rows.
|
|
78
|
-
//
|
|
79
|
-
// `skipped` rows are excluded from the window entirely: a skip is a task
|
|
80
|
-
// the run never executed, so it neither belongs in a success/hit RATE nor
|
|
81
|
-
// deserves to occupy one of the `recent` slots — a task whose upstream
|
|
82
|
-
// keeps breaking would otherwise push its own real history out of view.
|
|
83
|
-
const sql = `
|
|
84
|
-
WITH recent AS (
|
|
85
|
-
SELECT
|
|
86
|
-
project,
|
|
87
|
-
task,
|
|
88
|
-
status,
|
|
89
|
-
duration_ms,
|
|
90
|
-
cache_hit,
|
|
91
|
-
attempts,
|
|
92
|
-
ROW_NUMBER() OVER (PARTITION BY project, task ORDER BY started_at DESC) AS rn
|
|
93
|
-
FROM runs
|
|
94
|
-
WHERE (project, task) IN (VALUES ${tuplePlaceholders})
|
|
95
|
-
AND ${EXECUTED_RUNS_SQL}
|
|
93
|
+
const floorRow = this.db
|
|
94
|
+
.query(
|
|
95
|
+
`SELECT MIN(id) AS id FROM runs WHERE run_id =
|
|
96
|
+
(SELECT run_id FROM invocations ORDER BY rowid DESC LIMIT 1 OFFSET ?)`,
|
|
96
97
|
)
|
|
98
|
+
.get(this.recent - 1) as { id: number | null }
|
|
99
|
+
// Fewer invocations than the window (or none): the whole table is the window.
|
|
100
|
+
const floor = floorRow.id ?? 0
|
|
101
|
+
|
|
102
|
+
// `skipped` rows are excluded: a skip is a task the run never executed,
|
|
103
|
+
// so it belongs in no success/hit RATE and no duration. Percentiles are
|
|
104
|
+
// over the executed-success rows of the slice — work the runner actually
|
|
105
|
+
// did — while the rates count every executed row.
|
|
106
|
+
const sql = `
|
|
97
107
|
SELECT
|
|
98
|
-
project,
|
|
99
|
-
task,
|
|
108
|
+
r.project,
|
|
109
|
+
r.task,
|
|
100
110
|
COUNT(*) AS total,
|
|
101
|
-
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) AS successes,
|
|
102
|
-
SUM(CASE WHEN cache_hit = 1 THEN 1 ELSE 0 END) AS hits,
|
|
103
|
-
SUM(CASE WHEN
|
|
104
|
-
SUM(CASE WHEN
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
111
|
+
SUM(CASE WHEN r.status = 'success' THEN 1 ELSE 0 END) AS successes,
|
|
112
|
+
SUM(CASE WHEN r.cache_hit = 1 THEN 1 ELSE 0 END) AS hits,
|
|
113
|
+
SUM(CASE WHEN r.status = 'failed' THEN 1 ELSE 0 END) AS failures,
|
|
114
|
+
SUM(CASE WHEN r.attempts > 1 THEN 1 ELSE 0 END) AS retried,
|
|
115
|
+
GROUP_CONCAT(CASE WHEN (r.cache_hit IS NULL OR r.cache_hit = 0) AND r.status = 'success'
|
|
116
|
+
THEN r.duration_ms END) AS ds,
|
|
117
|
+
MAX(CASE WHEN (r.cache_hit IS NULL OR r.cache_hit = 0) AND r.status = 'success'
|
|
118
|
+
THEN r.peak_rss_bytes
|
|
119
|
+
WHEN r.cache_hit = 1 THEN e.peak_rss_bytes END) AS rss,
|
|
120
|
+
MAX(CASE WHEN (r.cache_hit IS NULL OR r.cache_hit = 0) AND r.status = 'success'
|
|
121
|
+
AND r.cpu_ms IS NOT NULL AND r.duration_ms > 0
|
|
122
|
+
THEN r.cpu_ms * 1.0 / r.duration_ms
|
|
123
|
+
WHEN r.cache_hit = 1 AND e.cpu_ms IS NOT NULL AND e.duration_ms > 0
|
|
124
|
+
THEN e.cpu_ms * 1.0 / e.duration_ms END) AS cpu
|
|
125
|
+
FROM runs r
|
|
126
|
+
-- A hit executed nothing here, but its entry carries what the
|
|
127
|
+
-- PRODUCING execution used (the artifact's sidecar, indexed at save
|
|
128
|
+
-- and ingest alike), so a task this machine has only ever restored
|
|
129
|
+
-- — a fresh runner behind a remote cache — still has a number. The
|
|
130
|
+
-- join is by primary key and only for hit rows; an entry pruned
|
|
131
|
+
-- since contributes nothing.
|
|
132
|
+
LEFT JOIN entries e ON r.cache_hit = 1 AND e.hash = r.hash
|
|
133
|
+
WHERE r.id >= ? AND ${EXECUTED_RUNS_SQL}
|
|
134
|
+
GROUP BY r.project, r.task
|
|
109
135
|
`
|
|
110
|
-
|
|
111
136
|
type Row = {
|
|
112
137
|
project: string
|
|
113
138
|
task: string
|
|
114
139
|
total: number
|
|
115
140
|
successes: number
|
|
116
141
|
hits: number
|
|
117
|
-
executed: number
|
|
118
142
|
failures: number
|
|
119
143
|
retried: number
|
|
144
|
+
ds: string | null
|
|
145
|
+
rss: number | null
|
|
146
|
+
cpu: number | null
|
|
120
147
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
148
|
+
// Every pair in the slice is grouped (the slice is bounded, so this is
|
|
149
|
+
// cheap) and the asked-for ones are picked out here — a per-row IN-list
|
|
150
|
+
// probe in SQL cost more than the grouping it would have saved.
|
|
151
|
+
const wanted = new Set(taskIds)
|
|
152
|
+
const rows = (this.db.query(sql).all(floor) as Row[]).filter((r) =>
|
|
153
|
+
wanted.has(`${r.project}#${r.task}`),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
// The flakiness signal needs per-key outcomes, which the aggregate above
|
|
157
|
+
// folded away — one more pass over the slice, only for the pairs whose
|
|
158
|
+
// verdict actually depends on it (failed, and no retry already proving
|
|
159
|
+
// nondeterminism); a green history never pays for it.
|
|
160
|
+
const mixed = this.mixedOutcomeKeys(
|
|
161
|
+
floor,
|
|
162
|
+
rows.filter((r) => (r.failures || 0) > 0 && (r.retried || 0) === 0),
|
|
163
|
+
)
|
|
129
164
|
|
|
130
165
|
for (const row of rows) {
|
|
131
166
|
const key = `${row.project}#${row.task}`
|
|
132
167
|
const total = row.total || 0
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
retried: row.retried || 0,
|
|
137
|
-
})
|
|
138
|
-
const durations = durationsByTask.get(key)
|
|
168
|
+
const counts = { total, failures: row.failures || 0, retried: row.retried || 0 }
|
|
169
|
+
const durations = row.ds === null ? undefined : row.ds.split(',').map(Number)
|
|
170
|
+
if (durations !== undefined) durations.sort((a, b) => a - b)
|
|
139
171
|
out.set(key, {
|
|
140
172
|
runs: total,
|
|
141
173
|
p50DurationMs: durations ? pickPercentile(durations, 0.5) : undefined,
|
|
142
174
|
p99DurationMs: durations ? pickPercentile(durations, 0.99) : undefined,
|
|
143
175
|
successRate: total > 0 ? (row.successes || 0) / total : 0,
|
|
144
176
|
hitRate: total > 0 ? (row.hits || 0) / total : 0,
|
|
145
|
-
failureMode,
|
|
177
|
+
failureMode: failureModeOf(counts, () => mixed.get(key) ?? 0),
|
|
178
|
+
...(row.rss !== null ? { maxPeakRssBytes: row.rss } : {}),
|
|
179
|
+
...(row.cpu !== null ? { maxCpuParallelism: row.cpu } : {}),
|
|
146
180
|
})
|
|
147
181
|
}
|
|
148
182
|
return out
|
|
149
183
|
}
|
|
150
184
|
|
|
151
|
-
/**
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
AND status = 'success'
|
|
185
|
+
/** Mixed-outcome key count per `project#task`, over the slice, for `pairs` only. */
|
|
186
|
+
private mixedOutcomeKeys(
|
|
187
|
+
floor: number,
|
|
188
|
+
pairs: readonly { project: string; task: string }[],
|
|
189
|
+
): Map<string, number> {
|
|
190
|
+
const out = new Map<string, number>()
|
|
191
|
+
if (pairs.length === 0) return out
|
|
192
|
+
const placeholders = pairs.map(() => '(?,?)').join(',')
|
|
193
|
+
const rows = this.db
|
|
194
|
+
.query(
|
|
195
|
+
`SELECT project, task, COUNT(*) AS n FROM (${mixedOutcomeKeysSql(
|
|
196
|
+
'runs',
|
|
197
|
+
` AND id >= ? AND (project, task) IN (VALUES ${placeholders})`,
|
|
198
|
+
)}) GROUP BY project, task`,
|
|
166
199
|
)
|
|
167
|
-
|
|
168
|
-
const rows = this.db.query(sql).all(...pairs.flat()) as {
|
|
200
|
+
.all(floor, ...pairs.flatMap((p) => [p.project, p.task])) as {
|
|
169
201
|
project: string
|
|
170
202
|
task: string
|
|
171
|
-
|
|
203
|
+
n: number
|
|
172
204
|
}[]
|
|
173
|
-
const
|
|
174
|
-
for (const r of rows) {
|
|
175
|
-
const key = `${r.project}#${r.task}`
|
|
176
|
-
const list = out.get(key)
|
|
177
|
-
if (list) list.push(r.duration_ms)
|
|
178
|
-
else out.set(key, [r.duration_ms])
|
|
179
|
-
}
|
|
180
|
-
for (const list of out.values()) list.sort((a, b) => a - b)
|
|
205
|
+
for (const r of rows) out.set(`${r.project}#${r.task}`, r.n)
|
|
181
206
|
return out
|
|
182
207
|
}
|
|
183
208
|
}
|
|
184
209
|
|
|
185
|
-
/** Split a `project#task` id on its FIRST `#` (task names contain no `#`). */
|
|
186
|
-
function splitTaskId(id: string): [string, string] {
|
|
187
|
-
const i = id.indexOf('#')
|
|
188
|
-
return i < 0 ? [id, ''] : [id.slice(0, i), id.slice(i + 1)]
|
|
189
|
-
}
|
|
190
|
-
|
|
191
210
|
function pickPercentile(sorted: number[], q: number): number {
|
|
192
211
|
if (sorted.length === 0) return 0
|
|
193
212
|
const idx = Math.min(sorted.length - 1, Math.floor(q * sorted.length))
|