@vzn/vx 0.0.635 → 0.0.637
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 +47 -19
- package/docs/caching.md +222 -210
- package/docs/cli.md +699 -664
- package/docs/execution.md +67 -75
- package/docs/features.md +44 -40
- package/docs/patterns.md +31 -32
- package/docs/schema.md +418 -202
- package/docs/security.md +76 -39
- package/launcher.sh +43 -0
- package/package.json +5 -5
- package/schemas/cache.json +41 -19
- package/schemas/info.json +23 -11
- package/schemas/last.json +48 -2
- package/schemas/lock.json +1 -1
- package/schemas/plan.json +2 -2
- package/schemas/show.json +13 -4
- package/schemas/why.json +15 -1
- package/skills/vx/SKILL.md +16 -12
- package/src/bin.ts +17 -2
- package/src/cache/archive.ts +11 -0
- package/src/cache/cache.ts +105 -53
- package/src/cache/git-inputs.ts +277 -63
- package/src/cache/index.ts +7 -1
- package/src/cache/inputs.ts +125 -35
- package/src/cache/key-fold.ts +8 -7
- package/src/cache/layer.ts +8 -17
- package/src/cache/layered-cache.ts +16 -102
- package/src/cache/policy.ts +25 -29
- package/src/cache/run-history.ts +36 -14
- package/src/cache/schema.ts +21 -23
- package/src/cache/scoped-cache.ts +224 -0
- package/src/cli/cache.ts +150 -28
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +1 -1
- package/src/cli/foreign-flags.ts +13 -12
- package/src/cli/help.ts +94 -61
- package/src/cli/index.ts +42 -13
- package/src/cli/info.ts +38 -23
- package/src/cli/init.ts +115 -406
- package/src/cli/last.ts +145 -4
- package/src/cli/lock.ts +333 -111
- package/src/cli/plan-format.ts +2 -2
- package/src/cli/plugin-commands.ts +5 -9
- package/src/cli/plugin-templates.ts +10 -10
- package/src/cli/run.ts +123 -144
- package/src/cli/select.ts +33 -39
- package/src/cli/show.ts +17 -19
- package/src/cli/task-verb.ts +2 -9
- package/src/cli/upgrade.ts +402 -145
- package/src/cli/watch-cycle.ts +9 -2
- package/src/cli/watch-filter.ts +33 -60
- package/src/cli/watch-fs.ts +29 -16
- package/src/cli/watch-judge.ts +12 -3
- package/src/cli/watch-set.ts +16 -42
- package/src/cli/watch.ts +188 -215
- package/src/cli/why.ts +28 -17
- package/src/cli/workspace-config.ts +31 -68
- package/src/config.ts +191 -71
- package/src/exec/env.ts +1 -31
- package/src/exec/executor.ts +1 -5
- package/src/exec/kill-tree.ts +2 -1
- package/src/exec/sandbox-runtime.ts +12 -1
- package/src/exec/sandbox-violations.ts +9 -1
- package/src/graph/dependency-spec.ts +1 -1
- package/src/graph/index.ts +1 -0
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +19 -7
- package/src/graph/task-graph.ts +44 -20
- package/src/handoff.ts +169 -0
- package/src/index.ts +25 -57
- package/src/orchestrator/affected-tasks.ts +7 -5
- package/src/orchestrator/command-windows.ts +44 -0
- package/src/orchestrator/deferred-outputs.ts +12 -10
- package/src/orchestrator/doctor.ts +98 -110
- package/src/orchestrator/download-policy.ts +3 -3
- package/src/orchestrator/events.ts +19 -3
- package/src/orchestrator/excluded-keys.ts +0 -2
- package/src/orchestrator/execute-task.ts +192 -70
- package/src/orchestrator/fingerprint-watch.ts +18 -15
- package/src/orchestrator/forecast.ts +10 -2
- package/src/orchestrator/framed-output.ts +8 -3
- package/src/orchestrator/graph-stage.ts +5 -5
- package/src/orchestrator/history.ts +70 -30
- package/src/orchestrator/hit-restore.ts +15 -3
- package/src/orchestrator/index.ts +18 -4
- package/src/orchestrator/local-shortcircuit.ts +2 -1
- package/src/orchestrator/lockfile-claim.ts +79 -20
- package/src/orchestrator/logger.ts +71 -39
- package/src/orchestrator/metrics.ts +71 -6
- package/src/orchestrator/miss-save.ts +3 -0
- package/src/orchestrator/options.ts +43 -28
- package/src/orchestrator/output-host.ts +72 -0
- package/src/orchestrator/plan.ts +6 -8
- package/src/orchestrator/plugin-host.ts +87 -63
- package/src/orchestrator/plugin.ts +94 -60
- package/src/orchestrator/prepare.ts +114 -73
- package/src/orchestrator/projects.ts +102 -99
- package/src/orchestrator/remote-prefetch.ts +2 -1
- package/src/orchestrator/run-context.ts +25 -6
- package/src/orchestrator/run-failures.ts +17 -8
- package/src/orchestrator/run-lock.ts +1 -1
- package/src/orchestrator/run-report.ts +9 -9
- package/src/orchestrator/run-settings.ts +48 -0
- package/src/orchestrator/run.ts +127 -88
- package/src/orchestrator/sandbox-fix.ts +39 -20
- package/src/orchestrator/sandbox-request.ts +84 -13
- package/src/orchestrator/signals.ts +1 -1
- package/src/orchestrator/stable-keys.ts +13 -11
- package/src/orchestrator/status-line.ts +48 -6
- package/src/orchestrator/summary.ts +46 -62
- package/src/orchestrator/task-hash.ts +12 -83
- package/src/orchestrator/telemetry.ts +3 -3
- package/src/settings.ts +417 -0
- package/src/util/errors.ts +1 -1
- package/src/util/essential-env.ts +31 -0
- package/src/util/hide-env.ts +107 -0
- package/src/util/index.ts +8 -2
- package/src/util/paths.ts +10 -0
- package/src/util/settle.ts +49 -30
- package/src/util/size.ts +23 -4
- package/src/util/timing.ts +3 -2
- package/src/util/ulid.ts +1 -1
- package/src/util/verbs.ts +0 -15
- package/src/workspace/affected.ts +77 -222
- package/src/workspace/config-eval.ts +15 -20
- package/src/workspace/config-imports.ts +78 -3
- package/src/workspace/config-schema.ts +251 -241
- package/src/workspace/dependency-rules.ts +57 -0
- package/src/workspace/filter.ts +2 -2
- package/src/workspace/fingerprint.ts +11 -121
- package/src/workspace/index.ts +22 -27
- package/src/workspace/load-reads.ts +4 -4
- package/src/workspace/lockfile.ts +96 -140
- package/src/workspace/migration.ts +242 -146
- package/src/workspace/package-graph.ts +30 -216
- package/src/workspace/project-loader.ts +80 -285
- package/src/workspace/repo-id.ts +2 -1
- package/src/workspace/workspace.ts +362 -831
- package/src/cache/config-evals.ts +0 -130
- package/src/workspace/config-cache.ts +0 -661
- package/src/workspace/migrate-scripts.ts +0 -1121
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { TaskNode, TaskOutcome } from '../graph/index.js'
|
|
2
|
+
import { settingEnv } from '../settings.js'
|
|
2
3
|
import { detectColors, type ColorSupport } from './colors.js'
|
|
3
4
|
import {
|
|
4
5
|
formatFrameClose,
|
|
@@ -177,7 +178,10 @@ export function resolveOutputView(
|
|
|
177
178
|
...(gha ? { gha: true } : {}),
|
|
178
179
|
...(ci ? { ci: true } : {}),
|
|
179
180
|
})
|
|
180
|
-
|
|
181
|
+
// `run.outputLogs`: chosen before the workspace loads, so flag, then variable.
|
|
182
|
+
const outputLogs =
|
|
183
|
+
options.outputLogs ?? (settingEnv('run.outputLogs', env) as OutputView['mode'] | undefined)
|
|
184
|
+
if (outputLogs !== undefined) return mk(outputLogs)
|
|
181
185
|
if (ci) return mk('full')
|
|
182
186
|
if (options.flow !== undefined) return mk(options.flow)
|
|
183
187
|
return mk('full')
|
|
@@ -198,6 +202,8 @@ export function defaultLogger(
|
|
|
198
202
|
tty?: boolean
|
|
199
203
|
/** The args after `--`: a requested task's `$ <command>` line shows them, as it ran. */
|
|
200
204
|
forwardArgs?: readonly string[]
|
|
205
|
+
/** `false`: no summary in the live region and no blank line opening the log (`RunOptions.footer`). */
|
|
206
|
+
footer?: boolean
|
|
201
207
|
/** The environment that names the terminal (`TERM_PROGRAM`, …); `process.env` by default. */
|
|
202
208
|
env?: Readonly<Record<string, string | undefined>>
|
|
203
209
|
} = {},
|
|
@@ -295,6 +301,7 @@ export function defaultLogger(
|
|
|
295
301
|
let etaMs: number | undefined
|
|
296
302
|
let critical: string | undefined
|
|
297
303
|
let blocks: ReadonlyMap<string, number> | undefined
|
|
304
|
+
let expected: ReadonlyMap<string, number> | undefined
|
|
298
305
|
let etaAtMs = 0
|
|
299
306
|
const signals = writer.enabled
|
|
300
307
|
? terminalSignals(opts.env ?? process.env)
|
|
@@ -311,6 +318,10 @@ export function defaultLogger(
|
|
|
311
318
|
// as `+k more` on the stats line. Focused flow keeps its lifecycle:
|
|
312
319
|
// the region dies the moment a requested node starts streaming.
|
|
313
320
|
let slots: (WorkerSlot | null)[] = []
|
|
321
|
+
// Rows shown: the most workers busy at once so far. An idle row past it
|
|
322
|
+
// was noise (a 10-core machine drew 9 on a one-chain run); it never
|
|
323
|
+
// shrinks, so a row never jumps.
|
|
324
|
+
let shownSlots = 0
|
|
314
325
|
const slotQueue: WorkerSlot[] = []
|
|
315
326
|
let succeeded = 0
|
|
316
327
|
// Executed with no `cache` block: never a miss, counted apart.
|
|
@@ -375,16 +386,16 @@ export function defaultLogger(
|
|
|
375
386
|
if (text.length < KEPT_LINE_CAP) held.rest = text
|
|
376
387
|
else {
|
|
377
388
|
held.rest = ''
|
|
378
|
-
|
|
389
|
+
taskWrite(fenced(formatKeptLines(node, text, colors)))
|
|
379
390
|
}
|
|
380
391
|
return
|
|
381
392
|
}
|
|
382
393
|
held.rest = text.slice(cut + 1)
|
|
383
|
-
|
|
394
|
+
taskWrite(fenced(formatKeptLines(node, text.slice(0, cut), colors)))
|
|
384
395
|
}
|
|
385
396
|
const flushKept = (): void => {
|
|
386
397
|
for (const held of keptLines.values()) {
|
|
387
|
-
if (held.rest.length > 0)
|
|
398
|
+
if (held.rest.length > 0) taskWrite(fenced(formatKeptLines(held.node, held.rest, colors)))
|
|
388
399
|
held.rest = ''
|
|
389
400
|
}
|
|
390
401
|
}
|
|
@@ -404,6 +415,7 @@ export function defaultLogger(
|
|
|
404
415
|
etaMs = f.etaMs
|
|
405
416
|
critical = f.critical
|
|
406
417
|
blocks = f.blocks
|
|
418
|
+
expected = f.expected
|
|
407
419
|
}
|
|
408
420
|
if (signals.progress && total > 0) {
|
|
409
421
|
const elapsed = now - startedAtMs
|
|
@@ -421,38 +433,42 @@ export function defaultLogger(
|
|
|
421
433
|
}
|
|
422
434
|
// The live summary IS the final summary's section, built from the
|
|
423
435
|
// same formatter so the region visually becomes the printout.
|
|
424
|
-
const summaryLines =
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
436
|
+
const summaryLines =
|
|
437
|
+
opts.footer === false
|
|
438
|
+
? []
|
|
439
|
+
: formatSummarySection(
|
|
440
|
+
{
|
|
441
|
+
failed,
|
|
442
|
+
successful: succeeded + upToDate + restoredLocal + restoredRemote,
|
|
443
|
+
skipped: skippedCount,
|
|
444
|
+
total,
|
|
445
|
+
upToDate,
|
|
446
|
+
restoredLocal,
|
|
447
|
+
restoredRemote,
|
|
448
|
+
miss: succeeded + failed - noCache,
|
|
449
|
+
noCache,
|
|
450
|
+
savedMs,
|
|
451
|
+
left: total - done,
|
|
452
|
+
...(etaMs !== undefined ? { etaMs } : {}),
|
|
453
|
+
spread:
|
|
454
|
+
spreadCount > 0
|
|
455
|
+
? { maxMs: spreadMax, minMs: spreadMin, sumMs: spreadSum, count: spreadCount }
|
|
456
|
+
: null,
|
|
457
|
+
},
|
|
458
|
+
Date.now() - startedAtMs,
|
|
459
|
+
colors,
|
|
460
|
+
runContext,
|
|
461
|
+
)
|
|
447
462
|
writer.setRegion(
|
|
448
463
|
formatStatusRegion(
|
|
449
464
|
{
|
|
450
465
|
pinnedPersistent,
|
|
451
|
-
slots,
|
|
466
|
+
slots: slots.slice(0, shownSlots),
|
|
452
467
|
overflow: slotQueue.length,
|
|
453
468
|
nowMs: Date.now(),
|
|
454
469
|
summaryLines,
|
|
455
470
|
...(blocks !== undefined ? { blocks } : {}),
|
|
471
|
+
...(expected !== undefined ? { expected } : {}),
|
|
456
472
|
...(critical !== undefined ? { critical } : {}),
|
|
457
473
|
},
|
|
458
474
|
colors,
|
|
@@ -478,8 +494,21 @@ export function defaultLogger(
|
|
|
478
494
|
writer.clearStatus()
|
|
479
495
|
}
|
|
480
496
|
|
|
497
|
+
// The task log opens with one blank line before its first row, on a
|
|
498
|
+
// terminal and off one (M, 2026-10-14). A note vx prints before it is
|
|
499
|
+
// no task row (`status`). Without a footer, the caller frames the log
|
|
500
|
+
// (`vx watch` opens each cycle with its own line).
|
|
501
|
+
let logOpened = opts.footer === false
|
|
502
|
+
const taskWrite = (chunk: string): void => {
|
|
503
|
+
if (!logOpened && chunk.length > 0) {
|
|
504
|
+
logOpened = true
|
|
505
|
+
writer.write(`\n${chunk}`)
|
|
506
|
+
return
|
|
507
|
+
}
|
|
508
|
+
writer.write(chunk)
|
|
509
|
+
}
|
|
481
510
|
const emitLine = (line: string): void => {
|
|
482
|
-
|
|
511
|
+
taskWrite(`${line}\n`)
|
|
483
512
|
lineEmitted = true
|
|
484
513
|
}
|
|
485
514
|
// formatTaskBlock returns '' for group tasks (no exec) — skip the
|
|
@@ -487,14 +516,14 @@ export function defaultLogger(
|
|
|
487
516
|
const emitBlock = (block: string): void => {
|
|
488
517
|
if (block.length === 0) return
|
|
489
518
|
const lead = lineEmitted || streamedSinceBlock ? '\n' : ''
|
|
490
|
-
|
|
519
|
+
taskWrite(`${lead}${block}\n`)
|
|
491
520
|
lineEmitted = false
|
|
492
521
|
streamedSinceBlock = false
|
|
493
522
|
}
|
|
494
523
|
// Live-frame close lines end a frame the same way emitBlock does:
|
|
495
524
|
// with a trailing blank line, resetting the separator state.
|
|
496
525
|
const emitFrameClose = (line: string): void => {
|
|
497
|
-
|
|
526
|
+
taskWrite(`${line}\n\n`)
|
|
498
527
|
lineEmitted = false
|
|
499
528
|
streamedSinceBlock = false
|
|
500
529
|
}
|
|
@@ -549,6 +578,7 @@ export function defaultLogger(
|
|
|
549
578
|
startedAtMs = Date.now()
|
|
550
579
|
const cap = Math.max(1, Math.min(info.concurrency ?? 10, 10))
|
|
551
580
|
slots = Array.from({ length: cap }, () => null)
|
|
581
|
+
shownSlots = 0
|
|
552
582
|
if (writer.enabled && !statusDead && ticker === null) {
|
|
553
583
|
// Keeps the per-worker elapsed time ticking between task events
|
|
554
584
|
// (the time IS the motion — there's no spinner). The writer
|
|
@@ -585,8 +615,10 @@ export function defaultLogger(
|
|
|
585
615
|
}
|
|
586
616
|
const slot: WorkerSlot = { id: node.id, startedMs: Date.now() }
|
|
587
617
|
const free = slots.indexOf(null)
|
|
588
|
-
if (free >= 0)
|
|
589
|
-
|
|
618
|
+
if (free >= 0) {
|
|
619
|
+
slots[free] = slot
|
|
620
|
+
shownSlots = Math.max(shownSlots, free + 1)
|
|
621
|
+
} else slotQueue.push(slot)
|
|
590
622
|
refresh(true)
|
|
591
623
|
},
|
|
592
624
|
forecast(load) {
|
|
@@ -675,7 +707,7 @@ export function defaultLogger(
|
|
|
675
707
|
if (streamsLive(node)) {
|
|
676
708
|
streamedSinceBlock = true
|
|
677
709
|
if (chunk.length > 0) streamMidLine = !chunk.endsWith('\n')
|
|
678
|
-
|
|
710
|
+
taskWrite(chunk)
|
|
679
711
|
return
|
|
680
712
|
}
|
|
681
713
|
const tail = persistentTails.get(node.id)
|
|
@@ -691,7 +723,7 @@ export function defaultLogger(
|
|
|
691
723
|
if (streamsLive(node)) {
|
|
692
724
|
streamedSinceBlock = true
|
|
693
725
|
if (chunk.length > 0) streamMidLine = !chunk.endsWith('\n')
|
|
694
|
-
|
|
726
|
+
taskWrite(chunk)
|
|
695
727
|
return
|
|
696
728
|
}
|
|
697
729
|
const tail = persistentTails.get(node.id)
|
|
@@ -747,7 +779,7 @@ export function defaultLogger(
|
|
|
747
779
|
(framesTerminal && holdsTerminal(node)))
|
|
748
780
|
if (framed && streamsLive(node)) {
|
|
749
781
|
if (streamMidLine) {
|
|
750
|
-
|
|
782
|
+
taskWrite('\n')
|
|
751
783
|
streamMidLine = false
|
|
752
784
|
}
|
|
753
785
|
emitFrameClose(formatFrameClose(node, outcome, colors))
|
|
@@ -830,7 +862,7 @@ export function defaultLogger(
|
|
|
830
862
|
if (isGroupTask(node)) return
|
|
831
863
|
if (holdsTerminal(node) && streamsLive(node) && outcome.status !== 'skipped') {
|
|
832
864
|
if (streamMidLine) {
|
|
833
|
-
|
|
865
|
+
taskWrite('\n')
|
|
834
866
|
streamMidLine = false
|
|
835
867
|
}
|
|
836
868
|
emitFrameClose(formatFrameClose(node, outcome, colors))
|
|
@@ -908,7 +940,7 @@ export function defaultLogger(
|
|
|
908
940
|
// and up-to-date included (owner: "always full frame
|
|
909
941
|
// for a single task").
|
|
910
942
|
if (streamMidLine) {
|
|
911
|
-
|
|
943
|
+
taskWrite('\n')
|
|
912
944
|
streamMidLine = false
|
|
913
945
|
}
|
|
914
946
|
emitFrameClose(formatFrameClose(node, outcome, colors))
|
|
@@ -1003,7 +1035,7 @@ export function defaultLogger(
|
|
|
1003
1035
|
// The message is ours (a literal, an integer and a signal
|
|
1004
1036
|
// name); only the title carries user input.
|
|
1005
1037
|
emitBlock(
|
|
1006
|
-
`::error title=${ghaProperty(node.id)}::${failedLabel(outcome.exitCode, outcome.timedOut, outcome.sandboxViolations, outcome.notReady)}\n${body}`,
|
|
1038
|
+
`::error title=${ghaProperty(node.id)}::${failedLabel(outcome.exitCode, outcome.timedOut, outcome.sandboxViolations, outcome.notReady, outcome.inputMoved)}\n${body}`,
|
|
1007
1039
|
)
|
|
1008
1040
|
} else {
|
|
1009
1041
|
emitBlock(
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
// Run-history queries — pure functions over a `bun:sqlite` Database.
|
|
2
2
|
//
|
|
3
3
|
// `vx why` and `vx last` read through them, and so can any out-of-process
|
|
4
|
-
// surface
|
|
4
|
+
// surface. One canonical home for every
|
|
5
5
|
// aggregate over the runs / entries tables. The schema itself is owned by
|
|
6
6
|
// src/cache/cache.ts.
|
|
7
7
|
//
|
|
8
|
-
// Pure SQL + JSON-safe return shapes
|
|
8
|
+
// Pure SQL + JSON-safe return shapes (`inputWriters` also stats the
|
|
9
|
+
// files it names). No Cache lifecycle here; the
|
|
9
10
|
// caller opens and closes. bigints are serialized as decimal strings
|
|
10
11
|
// for JSON compatibility (matches the WireEvent timeUnixNano rule).
|
|
11
12
|
|
|
12
13
|
import type { Database } from 'bun:sqlite'
|
|
14
|
+
import { statSync } from 'node:fs'
|
|
15
|
+
import path from 'node:path'
|
|
13
16
|
// The keyed-run predicate lives beside the schema in cache/cache.ts, so a
|
|
14
17
|
// rule written once cannot drift.
|
|
15
18
|
import { KEYED_RUNS_SQL } from '../cache/index.js'
|
|
@@ -410,7 +413,7 @@ function unchangedKeyNote(
|
|
|
410
413
|
} | null
|
|
411
414
|
)?.p
|
|
412
415
|
if (policy !== undefined && !policy.split(',').some((axis) => axis === 'lR' || axis === 'rR')) {
|
|
413
|
-
return 'cache key unchanged — re-executed because this run did not read the cache (--force, or a --cache without read)'
|
|
416
|
+
return 'cache key unchanged — re-executed because this run did not read the cache (--force, or a --cache-policy without read)'
|
|
414
417
|
}
|
|
415
418
|
// `get` answers null, not undefined, for no row.
|
|
416
419
|
const createdAt = (
|
|
@@ -470,7 +473,7 @@ function unchangedKeyNote(
|
|
|
470
473
|
} | null
|
|
471
474
|
)?.p
|
|
472
475
|
if (prevPolicy !== undefined && !prevPolicy.split(',').some((a) => a === 'lW' || a === 'rW')) {
|
|
473
|
-
return 'cache key unchanged — the previous run on this key did not write the cache (--no-cache, or a --cache without write), so there was nothing to hit'
|
|
476
|
+
return 'cache key unchanged — the previous run on this key did not write the cache (--no-cache, or a --cache-policy without write), so there was nothing to hit'
|
|
474
477
|
}
|
|
475
478
|
return 'cache key unchanged — the previous run on this key executed but no entry for it is in the cache (its save failed, or it was pruned since), so there was nothing to hit'
|
|
476
479
|
}
|
|
@@ -479,8 +482,7 @@ function unchangedKeyNote(
|
|
|
479
482
|
|
|
480
483
|
/**
|
|
481
484
|
* The latest recorded run of a task (run_id may be NULL on very old rows).
|
|
482
|
-
*
|
|
483
|
-
* tool demanded a run id an agent had to fetch first (2026-09-16).
|
|
485
|
+
* `vx why`'s default when no run id is given.
|
|
484
486
|
*/
|
|
485
487
|
export function latestRunId(db: Database, taskId: string): string | null {
|
|
486
488
|
const [project, task] = splitTaskId(taskId)
|
|
@@ -875,6 +877,69 @@ export function rootCauses(
|
|
|
875
877
|
return roots
|
|
876
878
|
}
|
|
877
879
|
|
|
880
|
+
export interface InputWriter {
|
|
881
|
+
/** The task whose key read the file. */
|
|
882
|
+
taskId: string
|
|
883
|
+
/** Workspace-relative, as the key names it. */
|
|
884
|
+
file: string
|
|
885
|
+
/** The tasks that were running when the file last changed. */
|
|
886
|
+
writers: string[]
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Slack around a task's recorded window: its edges are rounded to the
|
|
891
|
+
* millisecond and a write lands just before the process exits.
|
|
892
|
+
*/
|
|
893
|
+
const WRITE_SLACK_MS = 5
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* For each moved input file of `taskId` and of each root cause, the
|
|
897
|
+
* recorded tasks that were running when the file last changed: a task
|
|
898
|
+
* that writes a file another key reads re-keys that task on every run.
|
|
899
|
+
* The file's mtime against the recorded windows, so
|
|
900
|
+
* a writer that runs each time is named from its latest run, and an
|
|
901
|
+
* edit made outside a run names none.
|
|
902
|
+
*/
|
|
903
|
+
export function inputWriters(
|
|
904
|
+
db: Database,
|
|
905
|
+
workspaceRoot: string,
|
|
906
|
+
taskId: string,
|
|
907
|
+
entries: CacheKeyDiff['entries'],
|
|
908
|
+
roots: readonly RootCause[],
|
|
909
|
+
): InputWriter[] {
|
|
910
|
+
const during = db.query(
|
|
911
|
+
`SELECT project, task FROM runs
|
|
912
|
+
WHERE started_at <= ?1 + ${WRITE_SLACK_MS} AND ended_at >= ?1 - ${WRITE_SLACK_MS}
|
|
913
|
+
AND status != 'skipped'
|
|
914
|
+
GROUP BY project, task ORDER BY MIN(started_at) LIMIT 3`,
|
|
915
|
+
)
|
|
916
|
+
const out: InputWriter[] = []
|
|
917
|
+
const owners = [
|
|
918
|
+
{ taskId, entries },
|
|
919
|
+
...roots.map((r) => ({ taskId: r.chain.at(-1)!, entries: r.entries })),
|
|
920
|
+
]
|
|
921
|
+
for (const owner of owners) {
|
|
922
|
+
for (const e of owner.entries) {
|
|
923
|
+
if (e.kind !== 'file' || e.change === 'removed') continue
|
|
924
|
+
let mtime: number
|
|
925
|
+
try {
|
|
926
|
+
mtime = statSync(path.join(workspaceRoot, e.name)).mtimeMs
|
|
927
|
+
} catch {
|
|
928
|
+
continue
|
|
929
|
+
}
|
|
930
|
+
const rows = during.all(mtime) as { project: string; task: string }[]
|
|
931
|
+
if (rows.length > 0) {
|
|
932
|
+
out.push({
|
|
933
|
+
taskId: owner.taskId,
|
|
934
|
+
file: e.name,
|
|
935
|
+
writers: rows.map((r) => `${r.project}#${r.task}`),
|
|
936
|
+
})
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
return out
|
|
941
|
+
}
|
|
942
|
+
|
|
878
943
|
// ---------------------------------------------------------------------------
|
|
879
944
|
// Run comparison — diff a run against the immediately-previous invocation
|
|
880
945
|
// ---------------------------------------------------------------------------
|
|
@@ -81,6 +81,8 @@ export interface SaveMissArgs {
|
|
|
81
81
|
ownOutputFiles?: string[] | undefined
|
|
82
82
|
/** `ownOutputFiles` for `cache.outputs.workspaceFiles` (A-43). */
|
|
83
83
|
ownWsOutputFiles?: string[] | undefined
|
|
84
|
+
/** A sandboxed task's reach: a symlinked output it could not read is refused (`sandboxReadable`). */
|
|
85
|
+
readable?: ((real: string) => boolean) | undefined
|
|
84
86
|
/** The Tier-3 input fingerprint rows captured by the pre-exec describe. */
|
|
85
87
|
captured: readonly TaskInputComponent[]
|
|
86
88
|
command: string
|
|
@@ -176,6 +178,7 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
|
|
|
176
178
|
? { workspaceOutputFiles: wsOutputFiles, workspaceRoot: a.workspaceRoot }
|
|
177
179
|
: {}),
|
|
178
180
|
inputComponents: a.captured.map((c) => ({ entryHash: a.hash, ...c })),
|
|
181
|
+
readable: a.readable,
|
|
179
182
|
// No `exitCode`: the save only runs under `effectiveExitCode === 0`, and
|
|
180
183
|
// the contract no longer accepts one — so the invariant is enforced by
|
|
181
184
|
// the type rather than by every call site remembering the gate.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// so internals like prepare.ts can import them without an upward
|
|
3
3
|
// import of index.ts — the module entry must stay cycle-free.
|
|
4
4
|
|
|
5
|
-
import type { CachePolicy, RemoteCacheLayer } from '../cache/index.js'
|
|
5
|
+
import type { CacheFlags, CachePolicy, RemoteCacheLayer } from '../cache/index.js'
|
|
6
6
|
import type { ContinueMode } from '../graph/index.js'
|
|
7
7
|
import type { TaskOutcome } from '../graph/index.js'
|
|
8
8
|
import type { AffectedReason } from './affected-tasks.js'
|
|
@@ -11,6 +11,7 @@ import type { TelemetrySink } from './telemetry.js'
|
|
|
11
11
|
import type { Logger } from './logger.js'
|
|
12
12
|
import type { RunSummaryJson } from './run-artifacts.js'
|
|
13
13
|
import type { AffectedChanges, ProjectEntry, ProjectMeta } from '../workspace/index.js'
|
|
14
|
+
import type { RunSettings } from '../config.js'
|
|
14
15
|
|
|
15
16
|
export interface RunOptions {
|
|
16
17
|
cwd: string
|
|
@@ -47,7 +48,7 @@ export interface RunOptions {
|
|
|
47
48
|
affectedReasons?: Map<string, AffectedReason>
|
|
48
49
|
/**
|
|
49
50
|
* Configs a selection pass in this same process already loaded and
|
|
50
|
-
* staged with the same `cacheDir`
|
|
51
|
+
* staged with the same `cacheDir` (the CLI walks every
|
|
51
52
|
* `pkg#task` edge for `--filter 'app...'` and `--affected`). The run
|
|
52
53
|
* seeds and scopes as it always does and reuses these entries instead
|
|
53
54
|
* of evaluating and staging them again. One run only — never carry a
|
|
@@ -67,40 +68,39 @@ export interface RunOptions {
|
|
|
67
68
|
concurrency?: number
|
|
68
69
|
/**
|
|
69
70
|
* Cache directory override (`--cache-dir <path>`). Absolute, or relative
|
|
70
|
-
* to `cwd`. Takes precedence over the workspace `
|
|
71
|
+
* to `cwd`. Takes precedence over the workspace `cache.dir` field and the
|
|
71
72
|
* `.vx/cache` default. A per-run knob — never folded into a cache key.
|
|
72
73
|
*/
|
|
73
74
|
cacheDir?: string
|
|
74
75
|
/**
|
|
75
76
|
* Granular cache read/write control across the local + remote layers.
|
|
76
77
|
* Each of the four axes (localRead / localWrite / remoteRead /
|
|
77
|
-
* remoteWrite) is independent. Undefined →
|
|
78
|
-
*
|
|
79
|
-
*
|
|
78
|
+
* remoteWrite) is independent. Undefined → the workspace's
|
|
79
|
+
* `cache.policy` with `cacheFlags` applied, else {@link FULL_CACHE_POLICY}
|
|
80
|
+
* (everything on).
|
|
80
81
|
*/
|
|
81
82
|
cache?: CachePolicy
|
|
82
83
|
/**
|
|
83
|
-
*
|
|
84
|
+
* With no `cache`: `--no-cache` (all four off) and `--force` (both reads
|
|
85
|
+
* off), applied over the workspace's `cache.policy`.
|
|
86
|
+
*/
|
|
87
|
+
cacheFlags?: CacheFlags
|
|
88
|
+
/**
|
|
89
|
+
* The caller asked for a remote axis by name (`--cache-policy=remote:rw`). With
|
|
84
90
|
* no cache plugin supplying a remote layer the axes are inert, and a CI
|
|
85
91
|
* job that believes it is filling a shared cache deserves one line
|
|
86
92
|
* saying it is not.
|
|
87
93
|
*/
|
|
88
94
|
remoteRequested?: boolean
|
|
95
|
+
/** This run's `cache.scope` (the CLI's `--cache-scope`), above `VX_CACHE_SCOPE` and `.vx/workspace`. */
|
|
96
|
+
cacheScope?: string
|
|
89
97
|
/**
|
|
90
|
-
* The
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
|
|
94
|
-
defaultCacheScope?: string
|
|
95
|
-
/**
|
|
96
|
-
* CI mode: load configs FROM the committed vx-lock.json instead of
|
|
97
|
-
* evaluating them (frozen-env reproducibility). Requires the lock
|
|
98
|
-
* to exist and pass its content-hash tripwire. Local runs default
|
|
99
|
-
* to live evaluation — byte hashes can't see a config's import
|
|
100
|
-
* closure, so a strict local mode would give false confidence.
|
|
101
|
-
* Pair with `vx lock --check` in CI for the full evaluation audit.
|
|
98
|
+
* The default policy's remote write is off (`remote:r`): the CLI passes it
|
|
99
|
+
* off CI (`CI` unset, `0` or `false`) unless `--cache-policy` or
|
|
100
|
+
* `VX_CACHE_POLICY` names the remote. Dropped when the workspace's
|
|
101
|
+
* `cache.policy` names the remote.
|
|
102
102
|
*/
|
|
103
|
-
|
|
103
|
+
remoteReadOnlyByDefault?: boolean
|
|
104
104
|
/**
|
|
105
105
|
* Explicit per-task output volume for the default logger. ALWAYS
|
|
106
106
|
* overrides the flow default. 'full': frames for work + one-liners
|
|
@@ -133,19 +133,18 @@ export interface RunOptions {
|
|
|
133
133
|
*/
|
|
134
134
|
flow?: 'focused' | 'broad'
|
|
135
135
|
/**
|
|
136
|
-
*
|
|
136
|
+
* `run.retries` from its flag (`--retries <n>`): max additional attempts
|
|
137
137
|
* after a failed attempt, for tasks that don't declare their own
|
|
138
|
-
* `exec.retries` (explicit config wins, including `retries: 0`)
|
|
139
|
-
*
|
|
140
|
-
* the same run with and without `--
|
|
138
|
+
* `exec.retries` (explicit config wins, including `retries: 0`); unset,
|
|
139
|
+
* `VX_RUN_RETRIES`, then the workspace's. Never folded into any cache
|
|
140
|
+
* key, so the same run with and without `--retries` derives identical keys.
|
|
141
141
|
* A non-negative integer; run() refuses anything else (C-61).
|
|
142
142
|
*/
|
|
143
143
|
retries?: number
|
|
144
144
|
/**
|
|
145
|
-
*
|
|
146
|
-
* `exec.timeout
|
|
147
|
-
*
|
|
148
|
-
* `exec.timeout` always wins. Threaded as an option only — never
|
|
145
|
+
* `run.timeout` in ms from its flag (`--timeout 30m`), for tasks that
|
|
146
|
+
* declare no `exec.timeout`; unset, `VX_RUN_TIMEOUT`, then the
|
|
147
|
+
* workspace's. Per-task `exec.timeout` always wins. Never
|
|
149
148
|
* folded into any cache key (a timed-out task fails and is never
|
|
150
149
|
* cached), so a `--timeout` run cache-hits a plain run's entry.
|
|
151
150
|
* A positive integer of ms up to `2 ** 31 - 1`; run() refuses anything
|
|
@@ -161,6 +160,16 @@ export interface RunOptions {
|
|
|
161
160
|
* `--continue[=never|deps-ok|always]`; bare `--continue` = 'always'.
|
|
162
161
|
*/
|
|
163
162
|
continueMode?: ContinueMode
|
|
163
|
+
/** `--branch`; unset, `VX_RUN_BRANCH`, then git's, then the CI provider's. */
|
|
164
|
+
branch?: string
|
|
165
|
+
/** `run.schedule` from its flag; unset, `VX_RUN_SCHEDULE`, then the workspace's. */
|
|
166
|
+
schedule?: NonNullable<RunSettings['schedule']>
|
|
167
|
+
/** `run.killGrace` in ms from its flag; unset, `VX_RUN_KILL_GRACE`, then the workspace's. */
|
|
168
|
+
killGrace?: number
|
|
169
|
+
/** `run.teardownTimeout` in ms from its flag; unset, `VX_RUN_TEARDOWN_TIMEOUT`, then the workspace's. */
|
|
170
|
+
teardownTimeout?: number
|
|
171
|
+
/** `run.readyNotice` in ms from its flag; unset, `VX_RUN_READY_NOTICE`, then the workspace's. */
|
|
172
|
+
readyNotice?: number
|
|
164
173
|
/**
|
|
165
174
|
* Filter `dependsOn` expansion. `'all'` drops every edge (just the
|
|
166
175
|
* requested task runs). A string array drops only those task names
|
|
@@ -188,6 +197,12 @@ export interface RunOptions {
|
|
|
188
197
|
* here. Not called on a refused run.
|
|
189
198
|
*/
|
|
190
199
|
beforeFooter?: (outcomes: readonly TaskOutcome[], ok: boolean) => string
|
|
200
|
+
/**
|
|
201
|
+
* `false`: no footer, live or printed, and no blank line opening the
|
|
202
|
+
* task log; the run is its task rows (`vx watch`, which frames each
|
|
203
|
+
* cycle with its own line).
|
|
204
|
+
*/
|
|
205
|
+
footer?: boolean
|
|
191
206
|
/**
|
|
192
207
|
* If set, write a Chrome-trace JSON profile of the run's wallclock
|
|
193
208
|
* spans. Path is cwd-relative. Default `profile.json` is selected
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The `output` capability: a plugin may take the terminal for a run. Sibling
|
|
2
|
+
// to telemetry-host.ts. With no plugin declaring the hook nothing here runs,
|
|
3
|
+
// and core's renderer is the run's, byte for byte.
|
|
4
|
+
|
|
5
|
+
import { terminalSubscriber, type RunEventSubscriber } from './events.js'
|
|
6
|
+
import type { Logger } from './logger.js'
|
|
7
|
+
import type { OutputContext, VxPlugin } from './plugin.js'
|
|
8
|
+
|
|
9
|
+
const REQUIRED = ['status', 'taskStdout', 'taskStderr', 'taskComplete'] as const
|
|
10
|
+
const OPTIONAL = ['runStart', 'taskStart', 'runEnd'] as const
|
|
11
|
+
|
|
12
|
+
/** Reject anything that is not Logger-shaped, naming what is missing. */
|
|
13
|
+
function checkLogger(value: unknown): Logger {
|
|
14
|
+
if (typeof value !== 'object' || value === null) {
|
|
15
|
+
throw new Error(`output must be a Logger object, got ${value === null ? 'null' : typeof value}`)
|
|
16
|
+
}
|
|
17
|
+
const o = value as Record<string, unknown>
|
|
18
|
+
const missing = REQUIRED.filter((k) => typeof o[k] !== 'function')
|
|
19
|
+
if (missing.length > 0) throw new Error(`output Logger has no ${missing.join(', ')}`)
|
|
20
|
+
const wrong = OPTIONAL.filter((k) => o[k] !== undefined && typeof o[k] !== 'function')
|
|
21
|
+
if (wrong.length > 0) throw new Error(`output Logger's ${wrong.join(', ')} must be functions`)
|
|
22
|
+
return value as Logger
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The first plugin, in declaration order, whose `output` returns a Logger.
|
|
27
|
+
* A hook that throws or returns something else is warned about and passed
|
|
28
|
+
* over: a renderer must never fail a run.
|
|
29
|
+
*/
|
|
30
|
+
export async function claimOutput(
|
|
31
|
+
plugins: readonly VxPlugin[],
|
|
32
|
+
ctx: OutputContext,
|
|
33
|
+
): Promise<{ logger: Logger; owner: string } | undefined> {
|
|
34
|
+
for (const plugin of plugins) {
|
|
35
|
+
if (plugin.output === undefined) continue
|
|
36
|
+
try {
|
|
37
|
+
const logger = await plugin.output(ctx)
|
|
38
|
+
if (logger !== undefined) return { logger: checkLogger(logger), owner: plugin.name }
|
|
39
|
+
} catch (err) {
|
|
40
|
+
ctx.warn(
|
|
41
|
+
`[vx] plugin '${plugin.name}' output: ${err instanceof Error ? err.message : String(err)}; vx renders the run`,
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return undefined
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The bus subscriber for a plugin's Logger. The first throw hands that
|
|
50
|
+
* event and every later one to `fallback` (core's renderer), after one
|
|
51
|
+
* status line naming the plugin.
|
|
52
|
+
*/
|
|
53
|
+
export function guardedOutput(
|
|
54
|
+
claimed: { logger: Logger; owner: string },
|
|
55
|
+
fallback: RunEventSubscriber,
|
|
56
|
+
): RunEventSubscriber {
|
|
57
|
+
const render = terminalSubscriber(claimed.logger)
|
|
58
|
+
let failed = false
|
|
59
|
+
return (event) => {
|
|
60
|
+
if (failed) return fallback(event)
|
|
61
|
+
try {
|
|
62
|
+
render(event)
|
|
63
|
+
} catch (err) {
|
|
64
|
+
failed = true
|
|
65
|
+
fallback({
|
|
66
|
+
kind: 'run:status',
|
|
67
|
+
line: `[vx] plugin '${claimed.owner}' output failed: ${err instanceof Error ? err.message : String(err)}; vx renders the rest of the run`,
|
|
68
|
+
})
|
|
69
|
+
fallback(event)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
package/src/orchestrator/plan.ts
CHANGED
|
@@ -50,7 +50,7 @@ export interface PlannedTask {
|
|
|
50
50
|
executor?: string
|
|
51
51
|
/**
|
|
52
52
|
* `'deferred'` when this task's outputs would stay in the remote store
|
|
53
|
-
* (`--download=none` / `toplevel`). Attached only when it is TRUE — an
|
|
53
|
+
* (`--cache-download=none` / `toplevel`). Attached only when it is TRUE — an
|
|
54
54
|
* eager task is the default and saying so on every line says nothing.
|
|
55
55
|
*/
|
|
56
56
|
download?: 'deferred'
|
|
@@ -91,22 +91,21 @@ export interface RunPlan {
|
|
|
91
91
|
/** Set when `--affected` left nothing to run: the line the run would print, and why. */
|
|
92
92
|
noneAffected?: string
|
|
93
93
|
/**
|
|
94
|
-
* Producers `--download` WANTED to defer but could not, mapped to why.
|
|
95
|
-
* Without this the gate is invisible: a user asks for `--download=none`,
|
|
94
|
+
* Producers `--cache-download` WANTED to defer but could not, mapped to why.
|
|
95
|
+
* Without this the gate is invisible: a user asks for `--cache-download=none`,
|
|
96
96
|
* gets no deferral, and has nothing to read. Empty/absent under
|
|
97
|
-
* `--download=all`, which defers nothing by definition.
|
|
97
|
+
* `--cache-download=all`, which defers nothing by definition.
|
|
98
98
|
*/
|
|
99
99
|
downloadDowngrades?: ReadonlyArray<{ taskId: string; reason: string }>
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
export interface PlanArgs {
|
|
103
103
|
nodes: Map<string, TaskNode>
|
|
104
|
-
/** Per-task `--download` decision, when the policy is not `all`. */
|
|
104
|
+
/** Per-task `--cache-download` decision, when the policy is not `all`. */
|
|
105
105
|
downloadOf?: (id: string) => 'eager' | 'deferred' | 'never' | undefined
|
|
106
106
|
/** See `RunPlan.downloadDowngrades`. */
|
|
107
107
|
downloadDowngrades?: ReadonlyArray<{ taskId: string; reason: string }>
|
|
108
108
|
workspaceRoot: string
|
|
109
|
-
workspaceFingerprint: string
|
|
110
109
|
cache: CacheLayer
|
|
111
110
|
cachePolicy?: CachePolicy
|
|
112
111
|
forwardArgs?: readonly string[] | undefined
|
|
@@ -150,7 +149,6 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
|
|
|
150
149
|
node,
|
|
151
150
|
upstream,
|
|
152
151
|
workspaceRoot: args.workspaceRoot,
|
|
153
|
-
workspaceFingerprint: args.workspaceFingerprint,
|
|
154
152
|
cache: args.cache,
|
|
155
153
|
forwardArgs: args.forwardArgs,
|
|
156
154
|
nestedProjectDirs: args.nestedDirsByProject.get(node.projectName) ?? [],
|
|
@@ -166,7 +164,7 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
|
|
|
166
164
|
|
|
167
165
|
// Prediction keys off READS: a no-read policy re-executes every task,
|
|
168
166
|
// so nothing is probed. Whether that run is a miss or no cache at all
|
|
169
|
-
// is the WRITE axes': `--force` and `--cache=local:w` save what they
|
|
167
|
+
// is the WRITE axes': `--force` and `--cache-policy=local:w` save what they
|
|
170
168
|
// run, and the plan called them `no-cache`, the label a task with no
|
|
171
169
|
// `cache` block gets (item 991). The probe below routes through the
|
|
172
170
|
// policy-aware cache layer, which itself respects read gating.
|