@vzn/vx 0.0.637 → 0.0.639
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/docs/caching.md +84 -28
- package/docs/cli.md +273 -408
- package/docs/execution.md +19 -44
- package/docs/features.md +13 -15
- package/docs/patterns.md +5 -7
- package/docs/schema.md +68 -61
- package/package.json +5 -5
- package/schemas/last.json +7 -4
- package/schemas/plan.json +2 -2
- package/skills/vx/SKILL.md +19 -6
- package/src/bin.ts +13 -7
- package/src/cache/cache.ts +60 -11
- package/src/cache/git-inputs.ts +6 -0
- package/src/cache/index.ts +10 -2
- package/src/cache/inputs.ts +0 -24
- package/src/cache/layer.ts +32 -12
- package/src/cache/layered-cache.ts +112 -315
- package/src/cache/plugin-cache.ts +240 -0
- package/src/cache/policy.ts +9 -0
- package/src/cache/remote-lane.ts +383 -0
- package/src/cache/run-history.ts +92 -8
- package/src/cache/run-store.ts +77 -0
- package/src/cache/schema.ts +44 -13
- package/src/cache/scoped-cache.ts +22 -2
- package/src/cli/cache.ts +33 -16
- package/src/cli/completions.ts +11 -7
- package/src/cli/foreign-flags.ts +10 -22
- package/src/cli/help.ts +22 -38
- package/src/cli/index.ts +25 -11
- package/src/cli/info.ts +8 -6
- package/src/cli/init.ts +14 -15
- package/src/cli/last.ts +20 -10
- package/src/cli/lock.ts +39 -27
- package/src/cli/plan-format.ts +10 -61
- package/src/cli/plugin-commands.ts +77 -6
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +31 -191
- package/src/cli/select.ts +18 -9
- package/src/cli/show.ts +72 -40
- package/src/cli/task-verb.ts +4 -4
- package/src/cli/upgrade.ts +132 -39
- package/src/cli/watch-cycle.ts +2 -1
- package/src/cli/watch-filter.ts +7 -2
- package/src/cli/watch-judge.ts +6 -1
- package/src/cli/watch.ts +73 -34
- package/src/cli/why.ts +14 -5
- package/src/cli/workspace-config.ts +2 -2
- package/src/config.ts +0 -6
- package/src/exec/executor.ts +1 -1
- package/src/exec/sandbox-runtime.ts +1 -1
- package/src/exec/sandbox-violations.ts +3 -1
- package/src/graph/index.ts +9 -1
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +12 -16
- package/src/graph/task-graph.ts +119 -21
- package/src/graph/task-status.ts +95 -0
- package/src/{orchestrator → history}/failure-mode.ts +20 -11
- package/src/{orchestrator → history}/history.ts +61 -5
- package/src/history/index.ts +48 -0
- package/src/{orchestrator → history}/metrics.ts +14 -23
- package/src/{orchestrator → history}/miss-reason.ts +1 -1
- package/src/index.ts +21 -29
- package/src/orchestrator/affected-tasks.ts +8 -3
- package/src/orchestrator/doctor.ts +2 -2
- package/src/orchestrator/execute-args.ts +80 -0
- package/src/orchestrator/execute-task.ts +47 -429
- package/src/orchestrator/fingerprint-watch.ts +2 -2
- package/src/orchestrator/index.ts +3 -74
- package/src/orchestrator/miss-save.ts +33 -3
- package/src/orchestrator/options.ts +1 -36
- package/src/orchestrator/output-host.ts +4 -3
- package/src/orchestrator/persistent-task.ts +321 -0
- package/src/orchestrator/persistent.ts +130 -3
- package/src/orchestrator/placement.ts +3 -3
- package/src/orchestrator/plan.ts +18 -5
- package/src/orchestrator/plugin-host.ts +77 -47
- package/src/orchestrator/plugin.ts +86 -14
- package/src/orchestrator/prepare.ts +74 -31
- package/src/orchestrator/projects.ts +3 -0
- package/src/orchestrator/remote-prefetch.ts +23 -6
- package/src/orchestrator/run-artifacts.ts +5 -177
- package/src/orchestrator/run-context.ts +55 -0
- package/src/orchestrator/run-failures.ts +15 -3
- package/src/orchestrator/run-records.ts +36 -4
- package/src/orchestrator/run-report.ts +81 -179
- package/src/orchestrator/run-settings.ts +1 -16
- package/src/orchestrator/run-stop.ts +153 -0
- package/src/orchestrator/run.ts +313 -555
- package/src/orchestrator/sandbox-fix.ts +0 -16
- package/src/orchestrator/signals.ts +14 -6
- package/src/orchestrator/stable-keys.ts +107 -3
- package/src/orchestrator/steps.ts +50 -0
- package/src/orchestrator/task-hash.ts +3 -16
- package/src/orchestrator/task-log-buffer.ts +1 -1
- package/src/orchestrator/task-process.ts +94 -0
- package/src/orchestrator/telemetry-host.ts +1 -1
- package/src/orchestrator/telemetry.ts +6 -83
- package/src/{orchestrator → report}/events.ts +41 -8
- package/src/{orchestrator → report}/forecast.ts +35 -2
- package/src/{orchestrator → report}/framed-output.ts +39 -34
- package/src/report/index.ts +36 -0
- package/src/{orchestrator → report}/logger.ts +166 -21
- package/src/{orchestrator → report}/status-line.ts +84 -7
- package/src/{orchestrator → report}/summary.ts +179 -63
- package/src/report/tally.ts +83 -0
- package/src/settings.ts +13 -23
- package/src/{orchestrator → util}/colors.ts +49 -4
- package/src/util/config-frame.ts +31 -2
- package/src/util/errors.ts +32 -0
- package/src/util/git-ignored.ts +143 -0
- package/src/util/index.ts +25 -3
- package/src/util/refusal-notice.ts +111 -0
- package/src/util/report.ts +39 -0
- package/src/util/settle.ts +14 -39
- package/src/util/tail.ts +1 -1
- package/src/util/ui.ts +197 -0
- package/src/workspace/affected.ts +20 -23
- package/src/workspace/dependency-rules.ts +12 -4
- package/src/workspace/migration.ts +87 -91
- package/src/workspace/workspace.ts +5 -4
- package/schemas/summary.json +0 -133
- package/src/cache/chained-cache.ts +0 -276
- package/src/orchestrator/tally.ts +0 -112
- /package/src/{orchestrator → report}/path-links.ts +0 -0
- /package/src/{orchestrator → report}/plain-output.ts +0 -0
- /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
|
@@ -5,14 +5,16 @@
|
|
|
5
5
|
// ordinary write erases the status display first, writes its content,
|
|
6
6
|
// then redraws, so the display can never interleave with task output.
|
|
7
7
|
|
|
8
|
-
import { paint, type ColorSupport } from '
|
|
8
|
+
import { dumbTerminal, paint, type ColorSupport } from '../util/index.js'
|
|
9
9
|
import { formatTaskRow, paintIdParts, TIME_COL } from './framed-output.js'
|
|
10
|
+
import type { BlockedTask } from './forecast.js'
|
|
10
11
|
|
|
11
12
|
const NO_COLOR: ColorSupport = { enabled: false }
|
|
12
13
|
// Same palette as framed-output.ts so glyphs and stats agree.
|
|
13
14
|
const ACCENT = '#06b6d4'
|
|
14
15
|
const ERROR = '#ef4444'
|
|
15
16
|
const WARN = '#eab308' // yellow-500, as the summary's
|
|
17
|
+
const SUCCESS = '#22c55e'
|
|
16
18
|
|
|
17
19
|
/** Erase the current line and return the cursor to column 0. */
|
|
18
20
|
const CLEAR = '\x1b[2K\r'
|
|
@@ -35,6 +37,11 @@ export interface OutputWriter {
|
|
|
35
37
|
readonly enabled: boolean
|
|
36
38
|
/** Serialized content write: erase status → content → redraw. */
|
|
37
39
|
write(chunk: string): void
|
|
40
|
+
/**
|
|
41
|
+
* The same, to another stream on the same terminal (stderr): the region
|
|
42
|
+
* on this one is erased first and redrawn after.
|
|
43
|
+
*/
|
|
44
|
+
writeTo(target: StatusStream, chunk: string): void
|
|
38
45
|
/**
|
|
39
46
|
* Replace the status line's content. Redraws are throttled to
|
|
40
47
|
* `minRedrawMs` unless `force` (task start/finish) is set.
|
|
@@ -79,7 +86,7 @@ export function createOutputWriter(
|
|
|
79
86
|
stream: StatusStream,
|
|
80
87
|
opts: OutputWriterOptions = {},
|
|
81
88
|
): OutputWriter {
|
|
82
|
-
const enabled = (opts.enabled ?? true) && stream.isTTY === true
|
|
89
|
+
const enabled = (opts.enabled ?? true) && stream.isTTY === true && !dumbTerminal()
|
|
83
90
|
const minRedrawMs = opts.minRedrawMs ?? 100
|
|
84
91
|
const forceFloorMs = opts.forceFloorMs ?? 30
|
|
85
92
|
const now = opts.now ?? Date.now
|
|
@@ -236,6 +243,22 @@ export function createOutputWriter(
|
|
|
236
243
|
if (chunk.length > 0) atLineStart = chunk.endsWith('\n')
|
|
237
244
|
if (!dead && current !== null && atLineStart) draw()
|
|
238
245
|
},
|
|
246
|
+
writeTo(target, chunk) {
|
|
247
|
+
if (target === stream) return this.write(chunk)
|
|
248
|
+
// Held stdout is older than this chunk.
|
|
249
|
+
flushHeld()
|
|
250
|
+
if (!enabled) {
|
|
251
|
+
target.write(chunk)
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
if (shown) {
|
|
255
|
+
stream.write(eraseSeq())
|
|
256
|
+
shown = false
|
|
257
|
+
shownRows = 0
|
|
258
|
+
}
|
|
259
|
+
target.write(chunk)
|
|
260
|
+
if (!dead && current !== null && atLineStart) draw()
|
|
261
|
+
},
|
|
239
262
|
setStatus(line, o = {}) {
|
|
240
263
|
set([line], o)
|
|
241
264
|
},
|
|
@@ -283,8 +306,15 @@ export interface StatusRegionState {
|
|
|
283
306
|
pinnedPersistent: readonly string[]
|
|
284
307
|
/** Fixed-size slot array; null = idle worker. Index = display row. */
|
|
285
308
|
slots: readonly (WorkerSlot | null)[]
|
|
309
|
+
/** The blocked task an idle slot's row holds instead of `idle`, by the same index. */
|
|
310
|
+
blocked?: readonly (BlockedTask | null)[]
|
|
286
311
|
/** Running tasks beyond the displayed slots. */
|
|
287
312
|
overflow: number
|
|
313
|
+
/**
|
|
314
|
+
* vx's own steps still running, shown below the worker rows: no worker
|
|
315
|
+
* slot or `--concurrency` bounds them (M, 2026-10-15).
|
|
316
|
+
*/
|
|
317
|
+
steps?: readonly WorkerSlot[]
|
|
288
318
|
/** Clock value used for per-slot elapsed (injectable for tests). */
|
|
289
319
|
nowMs: number
|
|
290
320
|
/**
|
|
@@ -299,8 +329,12 @@ export interface StatusRegionState {
|
|
|
299
329
|
/** Identity-colored id, full (never truncated) — name is the last column. */
|
|
300
330
|
function paintPinnedId(id: string, colors: ColorSupport): string {
|
|
301
331
|
const sep = id.indexOf('#')
|
|
302
|
-
if (sep
|
|
303
|
-
|
|
332
|
+
if (sep >= 0) return paintIdParts(id.slice(0, sep), id.slice(0, sep), id.slice(sep + 1), colors)
|
|
333
|
+
// One of vx's own steps: `<owner>:<step>`, painted as a task id is.
|
|
334
|
+
const step = id.lastIndexOf(':')
|
|
335
|
+
if (step < 0) return id
|
|
336
|
+
const owner = id.slice(0, step)
|
|
337
|
+
return paintIdParts(owner, owner, id.slice(step + 1), colors, { sep: ':' })
|
|
304
338
|
}
|
|
305
339
|
|
|
306
340
|
/**
|
|
@@ -327,6 +361,28 @@ export function formatFailureLine(
|
|
|
327
361
|
)
|
|
328
362
|
}
|
|
329
363
|
|
|
364
|
+
/**
|
|
365
|
+
* The list row of one of vx's own steps (`vx:uploads`), on the task grid
|
|
366
|
+
* with no cache column: a step is no task and caches nothing.
|
|
367
|
+
*/
|
|
368
|
+
export function formatStepLine(
|
|
369
|
+
id: string,
|
|
370
|
+
durationMs: number,
|
|
371
|
+
ok: boolean,
|
|
372
|
+
colors: ColorSupport = NO_COLOR,
|
|
373
|
+
): string {
|
|
374
|
+
return formatTaskRow(
|
|
375
|
+
ok ? paint(SUCCESS, '\u23fa\uFE0E', colors) : paint(ERROR, '\u25fc\uFE0E', colors),
|
|
376
|
+
durationMs,
|
|
377
|
+
ok ? 'success' : 'failed',
|
|
378
|
+
ok ? SUCCESS : ERROR,
|
|
379
|
+
'',
|
|
380
|
+
'',
|
|
381
|
+
paintPinnedId(id, colors),
|
|
382
|
+
colors,
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
|
|
330
386
|
/**
|
|
331
387
|
* Worker region: pinned ready persistent tasks, one row per worker
|
|
332
388
|
* slot — a task stays in its slot for its whole life so names never
|
|
@@ -363,12 +419,19 @@ export function formatStatusRegion(
|
|
|
363
419
|
// Worker rows: NO glyph — the live elapsed time leads (no spinner;
|
|
364
420
|
// the ticking time IS the motion), then `running`, full id (never
|
|
365
421
|
// truncated — name is the last column). Idle rows hold their slot's
|
|
366
|
-
// place, dim, aligned under the status column
|
|
422
|
+
// place, dim, aligned under the status column: the blocked task the row
|
|
423
|
+
// holds and what holds it back, else `idle`.
|
|
367
424
|
// One task running has no path to compare against.
|
|
368
425
|
const busy = s.slots.filter((x) => x !== null).length
|
|
369
|
-
for (const slot of s.slots) {
|
|
426
|
+
for (const [row, slot] of s.slots.entries()) {
|
|
370
427
|
if (slot === null) {
|
|
371
|
-
|
|
428
|
+
const w = s.blocked?.[row]
|
|
429
|
+
lines.push(
|
|
430
|
+
w == null
|
|
431
|
+
? `${' '.repeat(3 + TIME_COL + 1)}${paint(IDLE, 'idle', colors, { dim: true })}`
|
|
432
|
+
: formatTaskRow(' ', null, 'blocked', '', '', '', paintPinnedId(w.id, colors), colors) +
|
|
433
|
+
` ${dim(`by ${w.by}`)}`,
|
|
434
|
+
)
|
|
372
435
|
continue
|
|
373
436
|
}
|
|
374
437
|
// What waits on it, after the id: the row to watch is the one the
|
|
@@ -394,6 +457,20 @@ export function formatStatusRegion(
|
|
|
394
457
|
if (s.overflow > 0) {
|
|
395
458
|
lines.push(dim(`\u2026 +${s.overflow} more running`))
|
|
396
459
|
}
|
|
460
|
+
for (const step of s.steps ?? []) {
|
|
461
|
+
lines.push(
|
|
462
|
+
formatTaskRow(
|
|
463
|
+
' ',
|
|
464
|
+
Math.max(0, s.nowMs - step.startedMs),
|
|
465
|
+
'running',
|
|
466
|
+
ACCENT,
|
|
467
|
+
'',
|
|
468
|
+
'',
|
|
469
|
+
paintPinnedId(step.id, colors),
|
|
470
|
+
colors,
|
|
471
|
+
),
|
|
472
|
+
)
|
|
473
|
+
}
|
|
397
474
|
// The live summary section — identical shape to the final printout,
|
|
398
475
|
// so the region visually BECOMES the summary when the run ends.
|
|
399
476
|
lines.push(...s.summaryLines)
|
|
@@ -4,57 +4,31 @@
|
|
|
4
4
|
// aren't real work and would inflate "N total" misleadingly.
|
|
5
5
|
|
|
6
6
|
import type { TaskOutcome } from '../graph/index.js'
|
|
7
|
-
import { paint, type ColorSupport } from './colors.js'
|
|
8
7
|
import { tallyOutcomes } from './tally.js'
|
|
9
|
-
import { isCacheHit } from '
|
|
8
|
+
import { isCacheHit } from '../graph/index.js'
|
|
10
9
|
import { outcomeLabel, projectOutcome, ranNoCache } from './events.js'
|
|
11
10
|
import { isGroupTask } from '../graph/index.js'
|
|
12
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
barWidth,
|
|
13
|
+
ERROR,
|
|
14
|
+
formatElapsed,
|
|
15
|
+
gradientRule,
|
|
16
|
+
paint,
|
|
17
|
+
SUCCESS,
|
|
18
|
+
WARN,
|
|
19
|
+
type ColorSupport,
|
|
20
|
+
} from '../util/index.js'
|
|
13
21
|
|
|
14
22
|
const NO_COLOR: ColorSupport = { enabled: false }
|
|
15
23
|
|
|
16
|
-
const SUCCESS = '#22c55e'
|
|
17
|
-
const WARN = '#eab308'
|
|
18
24
|
// Cache provenance hues (owner): local light blue, remote dark blue —
|
|
19
25
|
// yellow is skipped's color on both meters.
|
|
20
26
|
const LOCAL = '#38bdf8' // sky-400
|
|
21
27
|
const REMOTE = '#2563eb' // blue-600
|
|
22
|
-
const ERROR = '#ef4444'
|
|
23
28
|
const ABORTED = '#f97316' // orange-500: red's neighbour, not a failure
|
|
24
29
|
const NOT_RUN = '#a855f7' // purple-500: stopped before it began, apart from every other state
|
|
25
30
|
const ACCENT = '#06b6d4' // cyan-500: the live region's running word
|
|
26
31
|
|
|
27
|
-
const BAR_WIDTH = 50
|
|
28
|
-
// Brand gradient for the rule: identity violet → pink (the project /
|
|
29
|
-
// task hues), faded across the dash run. Plain dashes when colors
|
|
30
|
-
// are off.
|
|
31
|
-
const GRADIENT_FROM = [0xa7, 0x8b, 0xfa] as const
|
|
32
|
-
const GRADIENT_TO = [0xf4, 0x72, 0xb6] as const
|
|
33
|
-
// Rule ends flush with the bars: 2 indent + 8 label + 2 gap + 50 cells.
|
|
34
|
-
const RULE_DASHES = 57
|
|
35
|
-
|
|
36
|
-
/** Total visible rule width: `\u2500 <mark> ` + dashes. Shared by summary + header. */
|
|
37
|
-
function gradientRule(colors: ColorSupport, mark = 'vx'): string {
|
|
38
|
-
const head = `\u2500 `
|
|
39
|
-
const dashes = '\u2500'.repeat(Math.max(8, RULE_DASHES + 2 - mark.length))
|
|
40
|
-
if (!colors.enabled) return `${head}${mark} ${dashes}`
|
|
41
|
-
const chunks = 8
|
|
42
|
-
const per = Math.ceil(RULE_DASHES / chunks)
|
|
43
|
-
let out = ''
|
|
44
|
-
for (let i = 0; i < chunks; i++) {
|
|
45
|
-
const seg = dashes.slice(i * per, (i + 1) * per)
|
|
46
|
-
if (seg.length === 0) break
|
|
47
|
-
const f = i / (chunks - 1)
|
|
48
|
-
const hex = `#${GRADIENT_FROM.map((c, j) =>
|
|
49
|
-
Math.round(c + (GRADIENT_TO[j]! - c) * f)
|
|
50
|
-
.toString(16)
|
|
51
|
-
.padStart(2, '0'),
|
|
52
|
-
).join('')}`
|
|
53
|
-
out += paint(hex, seg, colors)
|
|
54
|
-
}
|
|
55
|
-
return `${paint(`#${GRADIENT_FROM.map((c) => c.toString(16).padStart(2, '0')).join('')}`, head, colors)}${paint('', mark, colors, { bold: true })} ${out}`
|
|
56
|
-
}
|
|
57
|
-
|
|
58
32
|
/**
|
|
59
33
|
* Stacked state meter: one ▰ run per bucket, proportional cells via
|
|
60
34
|
* largest-remainder, every non-zero bucket guaranteed at least one
|
|
@@ -67,9 +41,10 @@ function segmentBar(
|
|
|
67
41
|
): string {
|
|
68
42
|
const total = segments.reduce((sum, seg) => sum + seg.n, 0)
|
|
69
43
|
if (total === 0) return ''
|
|
70
|
-
const
|
|
44
|
+
const width = barWidth(colors)
|
|
45
|
+
const exact = segments.map((seg) => (seg.n / total) * width)
|
|
71
46
|
const cells = exact.map(Math.floor)
|
|
72
|
-
let leftover =
|
|
47
|
+
let leftover = width - cells.reduce((sum, c) => sum + c, 0)
|
|
73
48
|
const order = exact
|
|
74
49
|
.map((e, i) => ({ i, frac: e - Math.floor(e) }))
|
|
75
50
|
.sort((x, y) => y.frac - x.frac)
|
|
@@ -87,9 +62,12 @@ function segmentBar(
|
|
|
87
62
|
}
|
|
88
63
|
return segments
|
|
89
64
|
.map((seg, i) =>
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
65
|
+
// An empty segment paints nothing: no escape pair around no cell.
|
|
66
|
+
cells[i] === 0
|
|
67
|
+
? ''
|
|
68
|
+
: paint(seg.color, (seg.glyph ?? '\u25b0').repeat(cells[i]!), colors, {
|
|
69
|
+
...(seg.dim === true ? { dim: true } : {}),
|
|
70
|
+
}),
|
|
93
71
|
)
|
|
94
72
|
.join('')
|
|
95
73
|
}
|
|
@@ -111,6 +89,10 @@ export interface SummaryStats {
|
|
|
111
89
|
upToDate: number
|
|
112
90
|
restoredLocal: number
|
|
113
91
|
restoredRemote: number
|
|
92
|
+
/** Ran here and saved to the cache: the legend's `↑`. */
|
|
93
|
+
saved?: number
|
|
94
|
+
/** Ran here with a `cache` block, succeeded, and saved nothing. */
|
|
95
|
+
notSaved?: number
|
|
114
96
|
/** Tasks that executed (success + failed) with a `cache` block — the misses. */
|
|
115
97
|
miss: number
|
|
116
98
|
/** Executed with no `cache` block: never consulted the cache, not a miss. */
|
|
@@ -128,6 +110,67 @@ export interface SummaryStats {
|
|
|
128
110
|
savedMs?: number
|
|
129
111
|
/** A run of groups only: their names, so `0 tasks` says why. */
|
|
130
112
|
emptyGroups?: readonly string[]
|
|
113
|
+
/** Each finished task's cache bucket by its key, so its remote traffic can move it. */
|
|
114
|
+
buckets?: ReadonlyMap<string, CacheBucket>
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The cache bar segment a finished task fills before its remote traffic is weighed. */
|
|
118
|
+
export type CacheBucket =
|
|
119
|
+
| 'miss'
|
|
120
|
+
| 'noCache'
|
|
121
|
+
| 'upToDate'
|
|
122
|
+
| 'restoredLocal'
|
|
123
|
+
| 'restoredRemote'
|
|
124
|
+
| 'saved'
|
|
125
|
+
|
|
126
|
+
/** A finished task's own cache bucket; none for a group, a skip or an abort. */
|
|
127
|
+
export function cacheBucketOf(o: TaskOutcome): CacheBucket | undefined {
|
|
128
|
+
if (isGroupTask(o.node)) return undefined
|
|
129
|
+
switch (o.status) {
|
|
130
|
+
case 'success':
|
|
131
|
+
return ranNoCache(o) ? 'noCache' : o.saved === true ? 'saved' : 'miss'
|
|
132
|
+
case 'failed':
|
|
133
|
+
return ranNoCache(o) ? 'noCache' : 'miss'
|
|
134
|
+
case 'cache-hit':
|
|
135
|
+
return o.restored === false ? 'upToDate' : 'restoredLocal'
|
|
136
|
+
case 'cache-hit-remote':
|
|
137
|
+
return o.restored === false ? 'upToDate' : 'restoredRemote'
|
|
138
|
+
default:
|
|
139
|
+
return undefined
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
type Buckets = Record<CacheBucket | 'uploaded' | 'uploading' | 'remoteFailed', number>
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The cache bar's segments, each task in one (M, 2026-10-15). With layers
|
|
147
|
+
* named, a saved task leaves the misses, and a task with remote traffic
|
|
148
|
+
* leaves its own bucket for its strongest remote state: a failed read or
|
|
149
|
+
* write, then an upload still running, then one that landed. Without, the
|
|
150
|
+
* plain legend's buckets.
|
|
151
|
+
*/
|
|
152
|
+
function cacheBuckets(stats: SummaryStats, layers: RunContext['cache']): Buckets {
|
|
153
|
+
const saved = layers === undefined ? 0 : (stats.saved ?? 0)
|
|
154
|
+
const b: Buckets = {
|
|
155
|
+
miss: stats.miss - saved,
|
|
156
|
+
noCache: stats.noCache ?? 0,
|
|
157
|
+
upToDate: stats.upToDate,
|
|
158
|
+
restoredLocal: stats.restoredLocal,
|
|
159
|
+
restoredRemote: stats.restoredRemote,
|
|
160
|
+
saved,
|
|
161
|
+
uploaded: 0,
|
|
162
|
+
uploading: 0,
|
|
163
|
+
remoteFailed: 0,
|
|
164
|
+
}
|
|
165
|
+
if (layers?.remote !== true || stats.buckets === undefined) return b
|
|
166
|
+
for (const [hash, k] of layers.traffic.keys) {
|
|
167
|
+
const from = stats.buckets.get(hash)
|
|
168
|
+
// Still running: it counts once it ends.
|
|
169
|
+
if (from === undefined) continue
|
|
170
|
+
b[from]--
|
|
171
|
+
b[k.failures.length > 0 ? 'remoteFailed' : k.uploading > 0 ? 'uploading' : 'uploaded']++
|
|
172
|
+
}
|
|
173
|
+
return b
|
|
131
174
|
}
|
|
132
175
|
|
|
133
176
|
/**
|
|
@@ -138,11 +181,30 @@ export interface SummaryStats {
|
|
|
138
181
|
*/
|
|
139
182
|
export interface RunContext {
|
|
140
183
|
version: string
|
|
141
|
-
/** Projects
|
|
184
|
+
/** Projects with a real task in the graph — the "affected" half of the bar. */
|
|
142
185
|
packageCount: number
|
|
143
186
|
/** Worker-pool size for this run; shown on the `info` row. */
|
|
144
187
|
concurrency?: number
|
|
145
188
|
remoteCacheEnabled: boolean
|
|
189
|
+
/**
|
|
190
|
+
* The cache layers the run used, so the legend reads `local 1= 2↓ 4↑ ·
|
|
191
|
+
* remote 3↓ 4↑`. Absent: the plain `N miss` legend.
|
|
192
|
+
*/
|
|
193
|
+
cache?: {
|
|
194
|
+
/** The run's policy reads or writes the local layer. */
|
|
195
|
+
local: boolean
|
|
196
|
+
localWrite: boolean
|
|
197
|
+
/** A remote layer exists and the policy reads or writes it. */
|
|
198
|
+
remote: boolean
|
|
199
|
+
/** The run's remote traffic per key, filled as it happens (a live ref). */
|
|
200
|
+
traffic: {
|
|
201
|
+
/** Uploads queued or running. */
|
|
202
|
+
readonly inFlight: number
|
|
203
|
+
readonly keys: ReadonlyMap<string, { uploading: number; failures: readonly string[] }>
|
|
204
|
+
}
|
|
205
|
+
/** Tasks whose outputs are still in the remote store. */
|
|
206
|
+
notDownloaded: () => number
|
|
207
|
+
}
|
|
146
208
|
/** Total projects discovery found — the bar's denominator. */
|
|
147
209
|
workspaceProjectCount?: number
|
|
148
210
|
}
|
|
@@ -203,13 +265,14 @@ export function formatSummarySection(
|
|
|
203
265
|
if (context !== undefined) {
|
|
204
266
|
const wsTotal = context.workspaceProjectCount
|
|
205
267
|
if (wsTotal !== undefined && wsTotal > 0) {
|
|
268
|
+
const width = barWidth(colors)
|
|
206
269
|
const cells = Math.min(
|
|
207
|
-
|
|
208
|
-
Math.max(1, Math.round((context.packageCount / wsTotal) *
|
|
270
|
+
width,
|
|
271
|
+
Math.max(1, Math.round((context.packageCount / wsTotal) * width)),
|
|
209
272
|
)
|
|
210
273
|
const bar =
|
|
211
274
|
paint(WARN, '▰'.repeat(cells), colors) +
|
|
212
|
-
paint('', '▱'.repeat(
|
|
275
|
+
paint('', '▱'.repeat(width - cells), colors, { dim: true })
|
|
213
276
|
lines.push(
|
|
214
277
|
row('projects', bar),
|
|
215
278
|
legend([paint(WARN, `${context.packageCount} affected`, colors), dim(`${wsTotal} total`)]),
|
|
@@ -234,29 +297,65 @@ export function formatSummarySection(
|
|
|
234
297
|
if (taskParts.length > 0) lines.push(legend(taskParts))
|
|
235
298
|
}
|
|
236
299
|
|
|
237
|
-
// Cache meter + numbers
|
|
238
|
-
// remote.
|
|
300
|
+
// Cache meter + numbers: per layer when the run context names its
|
|
301
|
+
// layers, else miss · no-cache · up-to-date · local · remote.
|
|
239
302
|
if (miss + noCache + hits + left + stats.skipped > 0) {
|
|
303
|
+
const layers = context?.cache
|
|
304
|
+
const b = cacheBuckets(stats, layers)
|
|
240
305
|
const cacheBar = segmentBar(
|
|
241
306
|
[
|
|
242
|
-
{ n: miss, color: ERROR },
|
|
243
|
-
{ n: noCache, color: '', dim: true },
|
|
244
|
-
{ n:
|
|
245
|
-
{ n:
|
|
246
|
-
{ n:
|
|
307
|
+
{ n: b.miss, color: ERROR },
|
|
308
|
+
{ n: b.noCache, color: '', dim: true },
|
|
309
|
+
{ n: b.upToDate, color: SUCCESS },
|
|
310
|
+
{ n: b.restoredLocal, color: LOCAL },
|
|
311
|
+
{ n: b.saved, color: LOCAL },
|
|
312
|
+
{ n: b.restoredRemote, color: REMOTE },
|
|
313
|
+
{ n: b.uploaded, color: REMOTE },
|
|
314
|
+
{ n: b.uploading, color: ACCENT },
|
|
315
|
+
{ n: b.remoteFailed, color: ERROR },
|
|
247
316
|
{ n: stats.skipped, color: WARN },
|
|
248
317
|
remainder,
|
|
249
318
|
],
|
|
250
319
|
colors,
|
|
251
320
|
)
|
|
252
321
|
const cacheParts: string[] = []
|
|
253
|
-
if (
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
322
|
+
if (layers !== undefined) {
|
|
323
|
+
// Per layer (M, 2026-10-15): `✓` fresh (outputs already on disk, from
|
|
324
|
+
// either layer) and `∅` no cache, both local; `↓` restored from a
|
|
325
|
+
// layer, `↑` saved to it; `✗` a remote read or write failed, `⇡` still
|
|
326
|
+
// uploading. Each count is its bar segment's, glyph and number in its colour.
|
|
327
|
+
const n = (count: number, glyph: string, color: string, dim = false): string[] =>
|
|
328
|
+
count > 0 ? [paint(color, `${count}${glyph}`, colors, dim ? { dim: true } : {})] : []
|
|
329
|
+
const local = [
|
|
330
|
+
...(layers.local ? n(b.upToDate, '\u2713', SUCCESS) : []),
|
|
331
|
+
...n(b.noCache, '\u2205', '', true),
|
|
332
|
+
...(layers.local ? n(b.restoredLocal, '\u2193', LOCAL) : []),
|
|
333
|
+
...(layers.localWrite ? n(b.saved, '\u2191', LOCAL) : []),
|
|
334
|
+
]
|
|
335
|
+
// The layer words are dim; only the counts carry colour (M, 2026-10-15).
|
|
336
|
+
if (local.length > 0) cacheParts.push(`${dim('local')} ${local.join(' ')}`)
|
|
337
|
+
const remote = [
|
|
338
|
+
...n(b.restoredRemote, '\u2193', REMOTE),
|
|
339
|
+
...n(b.uploaded, '\u2191', REMOTE),
|
|
340
|
+
...n(b.remoteFailed, '\u2717', ERROR),
|
|
341
|
+
...n(b.uploading, '\u21e1', ACCENT),
|
|
342
|
+
]
|
|
343
|
+
if (layers.remote && remote.length > 0)
|
|
344
|
+
cacheParts.push(`${dim('remote')} ${remote.join(' ')}`)
|
|
345
|
+
if ((stats.notSaved ?? 0) > 0)
|
|
346
|
+
cacheParts.push(paint(WARN, `${stats.notSaved} not saved`, colors))
|
|
347
|
+
const notDownloaded = layers.notDownloaded()
|
|
348
|
+
if (notDownloaded > 0) cacheParts.push(paint(WARN, `${notDownloaded} not downloaded`, colors))
|
|
349
|
+
} else {
|
|
350
|
+
if (miss > 0) cacheParts.push(paint(ERROR, `${miss} miss`, colors))
|
|
351
|
+
if (noCache > 0) cacheParts.push(paint('', `${noCache} no-cache`, colors, { dim: true }))
|
|
352
|
+
if (stats.upToDate > 0)
|
|
353
|
+
cacheParts.push(paint(SUCCESS, `${stats.upToDate} up-to-date`, colors))
|
|
354
|
+
if (stats.restoredLocal > 0)
|
|
355
|
+
cacheParts.push(paint(LOCAL, `${stats.restoredLocal} local`, colors))
|
|
356
|
+
if (stats.restoredRemote > 0)
|
|
357
|
+
cacheParts.push(paint(REMOTE, `${stats.restoredRemote} remote`, colors))
|
|
358
|
+
}
|
|
260
359
|
// Skipped tasks ride the bar, yellow like the tasks meter, so the two
|
|
261
360
|
// bars fill alike; their count is the tasks legend's alone (the footer
|
|
262
361
|
// never says a fact twice, M, 2026-10-14).
|
|
@@ -364,15 +463,19 @@ export function formatOutcomeTable(outcomes: readonly TaskOutcome[]): string[] {
|
|
|
364
463
|
|
|
365
464
|
// Local byte formatter — the orchestrator can't import cli/format.ts
|
|
366
465
|
// (module boundary), and the footer only needs one compact form.
|
|
466
|
+
/** Ran here with a `cache` block, succeeded, and its result was not saved. */
|
|
467
|
+
export function ranUnsaved(o: TaskOutcome): boolean {
|
|
468
|
+
return o.status === 'success' && !ranNoCache(o) && o.saved !== true && o.where === undefined
|
|
469
|
+
}
|
|
470
|
+
|
|
367
471
|
export function formatRunSummary(
|
|
368
472
|
outcomes: readonly TaskOutcome[],
|
|
369
473
|
totalMs: number,
|
|
370
474
|
colors: ColorSupport = NO_COLOR,
|
|
371
475
|
context?: RunContext,
|
|
372
476
|
): string[] {
|
|
373
|
-
// Counted here, not in the Tally:
|
|
374
|
-
//
|
|
375
|
-
// distinction — it never consulted the cache, so it is not a miss.
|
|
477
|
+
// Counted here, not in the Tally: a task with no `cache` block never
|
|
478
|
+
// consulted the cache, so it is not a miss.
|
|
376
479
|
const noCache = outcomes.filter(
|
|
377
480
|
(o) =>
|
|
378
481
|
!isGroupTask(o.node) && (o.status === 'success' || o.status === 'failed') && ranNoCache(o),
|
|
@@ -402,6 +505,8 @@ export function formatRunSummary(
|
|
|
402
505
|
upToDate: t.upToDate,
|
|
403
506
|
restoredLocal: t.restoredLocal,
|
|
404
507
|
restoredRemote: t.restoredRemote,
|
|
508
|
+
saved: outcomes.filter((o) => o.saved === true && !isGroupTask(o.node)).length,
|
|
509
|
+
notSaved: outcomes.filter((o) => !isGroupTask(o.node) && ranUnsaved(o)).length,
|
|
405
510
|
miss: t.total - t.skipped - (t.cachedLocal + t.cachedRemote) - noCache,
|
|
406
511
|
noCache,
|
|
407
512
|
savedMs,
|
|
@@ -423,6 +528,7 @@ export function formatRunSummary(
|
|
|
423
528
|
}
|
|
424
529
|
: {}),
|
|
425
530
|
...(emptyGroups.length > 0 ? { emptyGroups } : {}),
|
|
531
|
+
buckets: bucketsOf(outcomes),
|
|
426
532
|
},
|
|
427
533
|
totalMs,
|
|
428
534
|
colors,
|
|
@@ -430,6 +536,16 @@ export function formatRunSummary(
|
|
|
430
536
|
)
|
|
431
537
|
}
|
|
432
538
|
|
|
539
|
+
/** Each finished task's cache bucket by its key. */
|
|
540
|
+
function bucketsOf(outcomes: readonly TaskOutcome[]): Map<string, CacheBucket> {
|
|
541
|
+
const out = new Map<string, CacheBucket>()
|
|
542
|
+
for (const o of outcomes) {
|
|
543
|
+
const bucket = o.hash === undefined ? undefined : cacheBucketOf(o)
|
|
544
|
+
if (bucket !== undefined) out.set(o.hash!, bucket)
|
|
545
|
+
}
|
|
546
|
+
return out
|
|
547
|
+
}
|
|
548
|
+
|
|
433
549
|
/**
|
|
434
550
|
* An `aborted` outcome the stop reached before dispatch. The scheduler gives
|
|
435
551
|
* it an invented exit 1; only a started task carries a wall-clock start, so
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Outcome tally for the end-of-run footer (`summary.ts`): the bucket rules,
|
|
2
|
+
// group tasks excluded.
|
|
3
|
+
|
|
4
|
+
import { isGroupTask, type TaskOutcome } from '../graph/index.js'
|
|
5
|
+
|
|
6
|
+
export interface Tally {
|
|
7
|
+
successful: number
|
|
8
|
+
failed: number
|
|
9
|
+
skipped: number
|
|
10
|
+
cachedLocal: number
|
|
11
|
+
/** Hits that materialized files from the local artifact. */
|
|
12
|
+
restoredLocal: number
|
|
13
|
+
/** Hits that materialized files pulled from the remote layer. */
|
|
14
|
+
restoredRemote: number
|
|
15
|
+
/** Cache hits whose tree already matched — nothing was written. */
|
|
16
|
+
upToDate: number
|
|
17
|
+
cachedRemote: number
|
|
18
|
+
/**
|
|
19
|
+
* Killed by a shutdown signal. No work, so it joins no outcome bucket and
|
|
20
|
+
* not `total` — but it is counted, because it makes the run red: a red run
|
|
21
|
+
* whose every counted task succeeded is undiagnosable without this.
|
|
22
|
+
*/
|
|
23
|
+
aborted: number
|
|
24
|
+
/** Tasks counted toward `total`. Group tasks are excluded by default. */
|
|
25
|
+
total: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function emptyTally(): Tally {
|
|
29
|
+
return {
|
|
30
|
+
successful: 0,
|
|
31
|
+
failed: 0,
|
|
32
|
+
skipped: 0,
|
|
33
|
+
cachedLocal: 0,
|
|
34
|
+
restoredLocal: 0,
|
|
35
|
+
restoredRemote: 0,
|
|
36
|
+
upToDate: 0,
|
|
37
|
+
cachedRemote: 0,
|
|
38
|
+
aborted: 0,
|
|
39
|
+
total: 0,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Walk live outcomes and return a `Tally` — the single owner of the bucket
|
|
45
|
+
* rules.
|
|
46
|
+
*
|
|
47
|
+
* Group tasks (no `exec`) aren't real work; including them inflates the
|
|
48
|
+
* per-run "N total" count in ways that mislead ("3 of 4 cached" when one was
|
|
49
|
+
* a group that ran nothing).
|
|
50
|
+
*
|
|
51
|
+
* Status bucket rules:
|
|
52
|
+
* `success` → +successful
|
|
53
|
+
* `cache-hit` → +successful, +cachedLocal
|
|
54
|
+
* `cache-hit-remote` → +successful, +cachedRemote
|
|
55
|
+
* `failed` → +failed
|
|
56
|
+
* `skipped` → +skipped
|
|
57
|
+
* `aborted` → +aborted only (no bucket, no total)
|
|
58
|
+
*/
|
|
59
|
+
export function tallyOutcomes(outcomes: readonly TaskOutcome[]): Tally {
|
|
60
|
+
const t = emptyTally()
|
|
61
|
+
for (const o of outcomes) {
|
|
62
|
+
if (isGroupTask(o.node)) continue
|
|
63
|
+
if (o.status === 'aborted') {
|
|
64
|
+
t.aborted++
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
t.total++
|
|
68
|
+
if (o.status === 'success') t.successful++
|
|
69
|
+
else if (o.status === 'cache-hit') {
|
|
70
|
+
t.successful++
|
|
71
|
+
t.cachedLocal++
|
|
72
|
+
if (o.restored === true) t.restoredLocal++
|
|
73
|
+
else t.upToDate++
|
|
74
|
+
} else if (o.status === 'cache-hit-remote') {
|
|
75
|
+
t.successful++
|
|
76
|
+
t.cachedRemote++
|
|
77
|
+
if (o.restored === true) t.restoredRemote++
|
|
78
|
+
else t.upToDate++
|
|
79
|
+
} else if (o.status === 'failed') t.failed++
|
|
80
|
+
else if (o.status === 'skipped') t.skipped++
|
|
81
|
+
}
|
|
82
|
+
return t
|
|
83
|
+
}
|
package/src/settings.ts
CHANGED
|
@@ -36,6 +36,8 @@ export interface Setting {
|
|
|
36
36
|
readonly task?: string
|
|
37
37
|
/** False: read before `.vx/workspace` loads, so flag and env only. */
|
|
38
38
|
readonly config?: false
|
|
39
|
+
/** False: `.vx/workspace` only, no variable. */
|
|
40
|
+
readonly env?: false
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
const SCHEDULES = ['most-work', 'critical-path', 'direct-dependents', 'ready-order'] as const
|
|
@@ -100,27 +102,6 @@ export const SETTINGS: readonly Setting[] = [
|
|
|
100
102
|
flag: { verb: 'run', name: '--output-logs' },
|
|
101
103
|
config: false,
|
|
102
104
|
},
|
|
103
|
-
{
|
|
104
|
-
path: 'run.killGrace',
|
|
105
|
-
kind: { type: 'duration', timer: true },
|
|
106
|
-
default: '2s',
|
|
107
|
-
doc: 'How long a task that ignores SIGTERM gets before SIGKILL.',
|
|
108
|
-
flag: { verb: 'run', name: '--kill-grace' },
|
|
109
|
-
},
|
|
110
|
-
{
|
|
111
|
-
path: 'run.teardownTimeout',
|
|
112
|
-
kind: { type: 'duration', timer: true, zero: true },
|
|
113
|
-
default: '3s',
|
|
114
|
-
doc: "The bound on each plugin's end-of-run flush and teardown.",
|
|
115
|
-
flag: { verb: 'run', name: '--teardown-timeout' },
|
|
116
|
-
},
|
|
117
|
-
{
|
|
118
|
-
path: 'run.readyNotice',
|
|
119
|
-
kind: { type: 'duration', timer: true },
|
|
120
|
-
default: '10s',
|
|
121
|
-
doc: 'How long a persistent task may take to match `readyWhen` before vx says it waits.',
|
|
122
|
-
flag: { verb: 'run', name: '--ready-notice' },
|
|
123
|
-
},
|
|
124
105
|
{
|
|
125
106
|
path: 'run.rules.exclusiveOutputs',
|
|
126
107
|
kind: { type: 'bool' },
|
|
@@ -193,6 +174,7 @@ export const SETTINGS: readonly Setting[] = [
|
|
|
193
174
|
default: 'true',
|
|
194
175
|
doc: "Remove a task's declared outputs before it runs on a miss, for tasks without their own.",
|
|
195
176
|
task: 'cache.outputs.clean',
|
|
177
|
+
env: false,
|
|
196
178
|
},
|
|
197
179
|
{
|
|
198
180
|
path: 'lock.reviewFiles',
|
|
@@ -221,6 +203,13 @@ export const SETTINGS: readonly Setting[] = [
|
|
|
221
203
|
doc: "Print the run's stage timings to stderr.",
|
|
222
204
|
config: false,
|
|
223
205
|
},
|
|
206
|
+
{
|
|
207
|
+
path: 'debug.watch',
|
|
208
|
+
kind: { type: 'bool' },
|
|
209
|
+
default: 'false',
|
|
210
|
+
doc: '`vx watch`: say on stderr each time the loop arms, with the projects it watches.',
|
|
211
|
+
config: false,
|
|
212
|
+
},
|
|
224
213
|
{
|
|
225
214
|
path: 'debug.dumpable',
|
|
226
215
|
kind: { type: 'bool' },
|
|
@@ -239,7 +228,7 @@ export function setting(path: string): Setting {
|
|
|
239
228
|
return BY_PATH.get(path)!
|
|
240
229
|
}
|
|
241
230
|
|
|
242
|
-
/** `run.
|
|
231
|
+
/** `run.affectedBase` → `VX_RUN_AFFECTED_BASE`. */
|
|
243
232
|
export function envName(path: string): string {
|
|
244
233
|
return `VX_${path
|
|
245
234
|
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
@@ -356,7 +345,8 @@ export function settingRefusal(
|
|
|
356
345
|
|
|
357
346
|
/** A setting's environment value, parsed; undefined when unset or empty. */
|
|
358
347
|
export function settingEnv(path: string, env: NodeJS.ProcessEnv = process.env): unknown {
|
|
359
|
-
const { kind } = setting(path)
|
|
348
|
+
const { kind, env: read } = setting(path)
|
|
349
|
+
if (read === false) return undefined
|
|
360
350
|
const name = envName(path)
|
|
361
351
|
const raw = env[name]
|
|
362
352
|
if (raw === undefined || raw === '') return undefined
|