@vzn/vx 0.0.646 → 0.0.649
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -22
- package/launcher.cjs +9 -2
- package/launcher.sh +2 -2
- package/package.json +5 -5
- package/schemas/cache.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/info.json +16 -2
- package/schemas/last.json +112 -5
- package/schemas/plan.json +28 -2
- package/schemas/show.json +10 -1
- package/skills/vx/SKILL.md +18 -2
- package/src/bin.ts +53 -12
- package/src/cache/archive.ts +324 -23
- package/src/cache/cache.ts +268 -53
- package/src/cache/git-inputs.ts +219 -87
- package/src/cache/index.ts +3 -2
- package/src/cache/inputs.ts +460 -78
- package/src/cache/key-fold.ts +15 -6
- package/src/cache/layer.ts +49 -9
- package/src/cache/layered-cache.ts +123 -58
- package/src/cache/linked-inputs.ts +1 -1
- package/src/cache/output-index.ts +19 -3
- package/src/cache/plugin-cache.ts +49 -23
- package/src/cache/remote-hits.ts +72 -0
- package/src/cache/remote-lane.ts +90 -14
- package/src/cache/run-history.ts +70 -23
- package/src/cache/run-store.ts +11 -10
- package/src/cache/schema.ts +25 -6
- package/src/cache/tar-stream.ts +5 -0
- package/src/cache/umask.ts +1 -2
- package/src/cli/cache.ts +58 -27
- package/src/cli/completions.ts +12 -5
- package/src/cli/docs.ts +42 -16
- package/src/cli/help.ts +58 -19
- package/src/cli/index.ts +79 -33
- package/src/cli/info.ts +12 -6
- package/src/cli/init.ts +186 -31
- package/src/cli/last.ts +439 -45
- package/src/cli/lock.ts +197 -52
- package/src/cli/plan-format.ts +91 -49
- package/src/cli/plugin-commands.ts +70 -14
- package/src/cli/plugin-templates.ts +13 -13
- package/src/cli/registry.ts +209 -0
- package/src/cli/run.ts +109 -34
- package/src/cli/select.ts +143 -46
- package/src/cli/show.ts +59 -16
- package/src/cli/task-verb.ts +16 -6
- package/src/cli/upgrade.ts +151 -49
- package/src/cli/watch-cycle.ts +10 -5
- package/src/cli/watch-filter.ts +71 -3
- package/src/cli/watch-fs.ts +22 -6
- package/src/cli/watch-judge.ts +48 -6
- package/src/cli/watch.ts +163 -42
- package/src/cli/why.ts +211 -73
- package/src/cli/workspace-config.ts +54 -15
- package/src/config.ts +13 -1
- package/src/exec/env.ts +7 -2
- package/src/exec/executor.ts +154 -10
- package/src/exec/index.ts +6 -2
- package/src/exec/kill-tree.ts +15 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/proc-sample.ts +3 -1
- package/src/exec/runner.ts +253 -54
- package/src/exec/sandbox-binds.ts +7 -3
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +211 -24
- package/src/exec/sandbox-violations.ts +52 -20
- package/src/graph/critical-path.ts +79 -0
- package/src/graph/default-build.ts +19 -0
- package/src/graph/definition.ts +38 -0
- package/src/graph/folds.ts +38 -5
- package/src/graph/index.ts +4 -1
- package/src/graph/priorities.ts +74 -8
- package/src/graph/scheduler.ts +270 -60
- package/src/graph/task-graph.ts +228 -35
- package/src/handoff.ts +97 -9
- package/src/history/failure-mode.ts +6 -3
- package/src/history/history.ts +167 -61
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +90 -30
- package/src/index.ts +30 -13
- package/src/orchestrator/admission.ts +10 -6
- package/src/orchestrator/affected-tasks.ts +11 -6
- package/src/orchestrator/deferred-outputs.ts +2 -0
- package/src/orchestrator/doctor.ts +134 -42
- package/src/orchestrator/execute-args.ts +4 -1
- package/src/orchestrator/execute-task.ts +130 -38
- package/src/orchestrator/fingerprint-watch.ts +1 -1
- package/src/orchestrator/graph-memo.ts +140 -0
- package/src/orchestrator/graph-stage.ts +18 -1
- package/src/orchestrator/held-servers.ts +1 -1
- package/src/orchestrator/hit-restore.ts +217 -119
- package/src/orchestrator/index.ts +4 -3
- package/src/orchestrator/local-floor.ts +119 -25
- package/src/orchestrator/local-shortcircuit.ts +28 -4
- package/src/orchestrator/lockfile-claim.ts +96 -16
- package/src/orchestrator/miss-save.ts +42 -8
- package/src/orchestrator/options.ts +8 -6
- package/src/orchestrator/output-host.ts +43 -14
- package/src/orchestrator/output-log.ts +90 -10
- package/src/orchestrator/persistent-task.ts +84 -29
- package/src/orchestrator/persistent.ts +16 -4
- package/src/orchestrator/placement.ts +185 -17
- package/src/orchestrator/plan.ts +64 -7
- package/src/orchestrator/plugin-host.ts +214 -42
- package/src/orchestrator/plugin.ts +113 -37
- package/src/orchestrator/prepare.ts +168 -30
- package/src/orchestrator/projects.ts +65 -40
- package/src/orchestrator/remote-prefetch.ts +119 -25
- package/src/orchestrator/run-artifacts.ts +95 -32
- package/src/orchestrator/run-context.ts +39 -41
- package/src/orchestrator/run-failures.ts +65 -23
- package/src/orchestrator/run-id.ts +6 -3
- package/src/orchestrator/run-keys.ts +149 -13
- package/src/orchestrator/run-live.ts +294 -0
- package/src/orchestrator/run-lock.ts +13 -7
- package/src/orchestrator/run-records.ts +35 -29
- package/src/orchestrator/run-report.ts +3 -3
- package/src/orchestrator/run-steps.ts +58 -22
- package/src/orchestrator/run-stop.ts +22 -5
- package/src/orchestrator/run.ts +331 -88
- package/src/orchestrator/sandbox-fix.ts +7 -5
- package/src/orchestrator/sandbox-request.ts +70 -20
- package/src/orchestrator/shell-verdict.ts +5 -5
- package/src/orchestrator/signals.ts +26 -1
- package/src/orchestrator/stable-keys.ts +92 -8
- package/src/orchestrator/steps.ts +2 -2
- package/src/orchestrator/task-artifacts.ts +90 -26
- package/src/orchestrator/task-hash.ts +78 -13
- package/src/orchestrator/task-log-buffer.ts +72 -36
- package/src/orchestrator/task-process.ts +7 -1
- package/src/orchestrator/telemetry-host.ts +22 -2
- package/src/orchestrator/telemetry.ts +53 -21
- package/src/orchestrator/upstream.ts +15 -0
- package/src/report/events.ts +32 -13
- package/src/report/forecast.ts +31 -9
- package/src/report/framed-output.ts +141 -50
- package/src/report/index.ts +1 -0
- package/src/report/logger.ts +170 -179
- package/src/report/path-links.ts +12 -4
- package/src/report/plain-output.ts +13 -5
- package/src/report/status-line.ts +81 -53
- package/src/report/summary.ts +114 -32
- package/src/report/terminal-signals.ts +13 -2
- package/src/settings.ts +7 -6
- package/src/util/bun-version.ts +4 -1
- package/src/util/colors.ts +4 -2
- package/src/util/config-frame.ts +41 -4
- package/src/{graph → util}/dependency-spec.ts +22 -13
- package/src/util/edit-distance.ts +13 -1
- package/src/util/env-flag.ts +9 -0
- package/src/util/errors.ts +59 -3
- package/src/util/essential-env.ts +57 -0
- package/src/util/git-ignored.ts +1 -1
- package/src/util/hash.ts +35 -0
- package/src/util/hide-env.ts +45 -5
- package/src/util/index.ts +21 -5
- package/src/util/num.ts +8 -0
- package/src/util/paths.ts +91 -21
- package/src/util/report.ts +6 -1
- package/src/util/secret-mask.ts +62 -1
- package/src/util/secrets.ts +7 -2
- package/src/util/size.ts +11 -3
- package/src/util/tail.ts +6 -1
- package/src/util/timing.ts +13 -1
- package/src/util/ui.ts +61 -3
- package/src/util/which.ts +1 -1
- package/src/util/write-file.ts +67 -0
- package/src/workspace/affected.ts +710 -107
- package/src/workspace/bracket-literal.ts +84 -0
- package/src/workspace/canonical-dir.ts +37 -0
- package/src/workspace/config-bytecode.ts +17 -11
- package/src/workspace/config-eval.ts +29 -8
- package/src/workspace/config-imports.ts +199 -58
- package/src/workspace/config-schema.ts +242 -39
- package/src/workspace/filter.ts +37 -8
- package/src/workspace/index.ts +11 -1
- package/src/workspace/json-data.ts +3 -4
- package/src/workspace/lockfile.ts +57 -8
- package/src/workspace/migration.ts +69 -18
- package/src/workspace/package-graph.ts +5 -4
- package/src/workspace/project-loader.ts +226 -37
- package/src/workspace/repo-id.ts +1 -1
- package/src/workspace/workspace.ts +176 -33
package/src/report/path-links.ts
CHANGED
|
@@ -6,12 +6,19 @@
|
|
|
6
6
|
|
|
7
7
|
import { statSync } from 'node:fs'
|
|
8
8
|
import path from 'node:path'
|
|
9
|
+
import { pathToFileURL } from 'node:url'
|
|
9
10
|
|
|
10
11
|
// `src/a.ts`, `./a.ts:12`, `/abs/a.ts:12:5`, tsc's `src/a.ts(12,5)`. Not
|
|
11
12
|
// after a word, dot, slash or `@`, so a URL's path or a scoped name's tail
|
|
12
|
-
// never starts a match.
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
// never starts a match. Colour codes (`\x1b[4m`) are invisible: a path one
|
|
14
|
+
// starts is linked (tasks run with FORCE_COLOR), one it splits from its
|
|
15
|
+
// directory is not. Groups: file, then line and column of either form.
|
|
16
|
+
const SGR = String.raw`\x1b\[[\d;]*m`
|
|
17
|
+
const PATH = new RegExp(
|
|
18
|
+
String.raw`(?<!\x1b\[[\d;]*)(?:(?<![\w./@+-])|(?<=${SGR}))(?<(?:${SGR})+)` +
|
|
19
|
+
String.raw`((?:\/|\.{1,2}\/)?(?:[\w@.+-]+\/)*[\w@+-][\w@.+-]*\.[A-Za-z][A-Za-z0-9]{0,9})(?::(\d+)(?::(\d+))?|\((\d+),(\d+)\))?`,
|
|
20
|
+
'g',
|
|
21
|
+
)
|
|
15
22
|
|
|
16
23
|
/** A file a task's output names; `file` absolute. */
|
|
17
24
|
export interface OutputLocation {
|
|
@@ -29,7 +36,8 @@ export function linkPaths(text: string, dir: string, isFile: (abs: string) => bo
|
|
|
29
36
|
return text.replace(PATH, (match: string, file: string) => {
|
|
30
37
|
const abs = path.resolve(dir, file)
|
|
31
38
|
if (!isFile(abs)) return match
|
|
32
|
-
|
|
39
|
+
// Not `encodeURI`: it keeps a `#` or `?`, which the URL reads as its fragment or query.
|
|
40
|
+
return `\x1b]8;;${pathToFileURL(abs).href}\x1b\\${match}\x1b]8;;\x1b\\`
|
|
33
41
|
})
|
|
34
42
|
}
|
|
35
43
|
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { TaskNode } from '../graph/index.js'
|
|
2
2
|
import type { Logger } from './logger.js'
|
|
3
3
|
|
|
4
|
-
// A prefix that may yet complete as a CSI or
|
|
5
|
-
// at a chunk's end is held for
|
|
6
|
-
// remainder (`1m`) reached the log
|
|
4
|
+
// A prefix that may yet complete as a CSI, OSC or charset sequence
|
|
5
|
+
// (`tput sgr0` writes `\x1b(B`). An escape cut at a chunk's end is held for
|
|
6
|
+
// the next chunk: stripped alone, its remainder (`1m`, `B`) reached the log
|
|
7
|
+
// as text.
|
|
7
8
|
// eslint-disable-next-line no-control-regex -- ESC is the point
|
|
8
|
-
const PARTIAL_ESCAPE = /^\x1b(?:\[[0-?]*[ -/]*|\][^\x07\x1b]*\x1b
|
|
9
|
+
const PARTIAL_ESCAPE = /^\x1b(?:\[[0-?]*[ -/]*|\][^\x07\x1b]*\x1b?|[ -/]+)?$/
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* `sink` with terminal escapes stripped from task output. Tasks run with
|
|
@@ -34,12 +35,19 @@ export function plainOutput(sink: Logger): Logger {
|
|
|
34
35
|
if (text !== '') write(node, text)
|
|
35
36
|
}
|
|
36
37
|
// Delegated one by one, never spread: an embedder's logger may be a class
|
|
37
|
-
// whose methods live on its prototype and read `this`.
|
|
38
|
+
// whose methods live on its prototype and read `this`. Every hook the sink
|
|
39
|
+
// has: a dropped `warn` put warnings on stdout, a dropped `runDone` left
|
|
40
|
+
// the live region under the footer.
|
|
38
41
|
return {
|
|
39
42
|
status: (line) => sink.status(line),
|
|
43
|
+
...(sink.warn && { warn: (line) => sink.warn!(line) }),
|
|
40
44
|
...(sink.runStart && { runStart: (info) => sink.runStart!(info) }),
|
|
41
45
|
...(sink.taskStart && { taskStart: (node) => sink.taskStart!(node) }),
|
|
42
46
|
...(sink.runEnd && { runEnd: () => sink.runEnd!() }),
|
|
47
|
+
...(sink.stepStart && { stepStart: (id) => sink.stepStart!(id) }),
|
|
48
|
+
...(sink.stepEnd && { stepEnd: (id, info) => sink.stepEnd!(id, info) }),
|
|
49
|
+
...(sink.runStop && { runStop: () => sink.runStop!() }),
|
|
50
|
+
...(sink.runDone && { runDone: () => sink.runDone!() }),
|
|
43
51
|
taskStdout(node, chunk) {
|
|
44
52
|
const text = strip(`${node.id}\0out`, chunk)
|
|
45
53
|
if (text !== '' || chunk === '') sink.taskStdout(node, text)
|
|
@@ -5,14 +5,13 @@
|
|
|
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 { dumbTerminal, paint,
|
|
8
|
+
import { dumbTerminal, paint, type ColorSupport } from '../util/index.js'
|
|
9
9
|
import {
|
|
10
10
|
formatListHeader,
|
|
11
11
|
formatTaskRow,
|
|
12
12
|
paintIdParts,
|
|
13
13
|
pathNote,
|
|
14
14
|
placeNote,
|
|
15
|
-
TIME_COL,
|
|
16
15
|
} from './framed-output.js'
|
|
17
16
|
import type { BlockedTask } from './forecast.js'
|
|
18
17
|
|
|
@@ -39,6 +38,9 @@ export interface StatusStream {
|
|
|
39
38
|
rows?: number
|
|
40
39
|
}
|
|
41
40
|
|
|
41
|
+
/** A region's lines, or what builds them when it is drawn. */
|
|
42
|
+
export type Region = readonly string[] | (() => readonly string[])
|
|
43
|
+
|
|
42
44
|
export interface OutputWriter {
|
|
43
45
|
/** True only when the stream is a TTY and the run isn't CI. */
|
|
44
46
|
readonly enabled: boolean
|
|
@@ -56,9 +58,11 @@ export interface OutputWriter {
|
|
|
56
58
|
setStatus(line: string, opts?: { force?: boolean }): void
|
|
57
59
|
/**
|
|
58
60
|
* Replace the multi-line status region. Same throttle contract as
|
|
59
|
-
* `setStatus`; the two share one display slot.
|
|
61
|
+
* `setStatus`; the two share one display slot. A function is called
|
|
62
|
+
* when the region is next drawn, once: a set the throttle coalesces
|
|
63
|
+
* builds nothing.
|
|
60
64
|
*/
|
|
61
|
-
setRegion(lines:
|
|
65
|
+
setRegion(lines: Region, opts?: { force?: boolean }): void
|
|
62
66
|
/** Hand over what `coalesce` holds and write straight through from now on. */
|
|
63
67
|
settle(): void
|
|
64
68
|
/** Permanently remove the status display; later set* is a no-op. */
|
|
@@ -114,7 +118,7 @@ export function createOutputWriter(
|
|
|
114
118
|
stream.write(text)
|
|
115
119
|
}
|
|
116
120
|
|
|
117
|
-
let current:
|
|
121
|
+
let current: Region | null = null
|
|
118
122
|
let shown = false
|
|
119
123
|
// PHYSICAL rows the shown region occupies, not logical lines — see
|
|
120
124
|
// `regionRows`.
|
|
@@ -200,13 +204,18 @@ export function createOutputWriter(
|
|
|
200
204
|
// only repeat these bytes.
|
|
201
205
|
cancelTrailing()
|
|
202
206
|
const erase = shown ? eraseSeq() : CLEAR
|
|
207
|
+
// Built here, not at the set: a warm run's task events set the
|
|
208
|
+
// region hundreds of times inside one floor, and each build was a
|
|
209
|
+
// forecast pass and a summary render no frame showed (200 hits on a
|
|
210
|
+
// terminal: run graph 62 → 9 ms). Kept built: a write redraws it.
|
|
211
|
+
if (typeof current === 'function') current = current()
|
|
203
212
|
const lines = fitted(current!)
|
|
204
213
|
stream.write(erase + lines.join('\n'))
|
|
205
214
|
shown = true
|
|
206
215
|
shownRows = regionRows(lines)
|
|
207
216
|
lastDraw = now()
|
|
208
217
|
}
|
|
209
|
-
const set = (lines:
|
|
218
|
+
const set = (lines: Region, o: { force?: boolean }): void => {
|
|
210
219
|
if (!enabled || dead) return
|
|
211
220
|
current = lines
|
|
212
221
|
if (!atLineStart || holds > 0) return
|
|
@@ -313,8 +322,6 @@ export function createOutputWriter(
|
|
|
313
322
|
}
|
|
314
323
|
}
|
|
315
324
|
|
|
316
|
-
const IDLE = '#6b7280'
|
|
317
|
-
|
|
318
325
|
export interface WorkerSlot {
|
|
319
326
|
id: string
|
|
320
327
|
startedMs: number
|
|
@@ -323,8 +330,6 @@ export interface WorkerSlot {
|
|
|
323
330
|
}
|
|
324
331
|
|
|
325
332
|
export interface StatusRegionState {
|
|
326
|
-
/** The terminal's height: a region taller folds its rows, never its summary. */
|
|
327
|
-
rows?: number
|
|
328
333
|
/** The running task at the head of the longest unfinished chain. */
|
|
329
334
|
critical?: string
|
|
330
335
|
/** Where a running task runs, when not here (`TaskExecutor.running`); its row shows it. */
|
|
@@ -338,15 +343,23 @@ export interface StatusRegionState {
|
|
|
338
343
|
* the honest end of their life.
|
|
339
344
|
*/
|
|
340
345
|
pinnedPersistent: readonly string[]
|
|
341
|
-
/**
|
|
346
|
+
/** The running tasks by slot, null for a free one; a task past the slots follows them. */
|
|
342
347
|
slots: readonly (WorkerSlot | null)[]
|
|
343
|
-
/** The
|
|
344
|
-
|
|
348
|
+
/** The list's height: one row per worker, whatever runs, so it never jumps. */
|
|
349
|
+
size?: number
|
|
350
|
+
/**
|
|
351
|
+
* The tasks the scheduler has picked for the next free workers, each
|
|
352
|
+
* nested under the running task it waits on, in the rows no running task
|
|
353
|
+
* takes; nothing further ahead.
|
|
354
|
+
*/
|
|
355
|
+
next?: readonly BlockedTask[]
|
|
356
|
+
/** Each running task's count of the tasks it holds back: its `blocked` cell. */
|
|
357
|
+
blockedBy?: ReadonlyMap<string, number>
|
|
345
358
|
/** The run was stopped: running rows say `aborting`, idle rows leave. */
|
|
346
359
|
stopping?: boolean
|
|
347
360
|
/**
|
|
348
361
|
* vx's own steps still running, shown below the worker rows: no worker
|
|
349
|
-
* slot or `--concurrency` bounds them (
|
|
362
|
+
* slot or `--concurrency` bounds them (2026-10-15).
|
|
350
363
|
*/
|
|
351
364
|
steps?: readonly WorkerSlot[]
|
|
352
365
|
/** Clock value used for per-slot elapsed (injectable for tests). */
|
|
@@ -401,7 +414,7 @@ const INTERNAL = 'internal'
|
|
|
401
414
|
/**
|
|
402
415
|
* The list row of one of vx's own steps (`vx:uploads`), on the task grid
|
|
403
416
|
* with `internal` in the cache column: a step is no task and caches
|
|
404
|
-
* nothing (
|
|
417
|
+
* nothing (2026-10-16: `4.60s success internal vx:keep-run`).
|
|
405
418
|
*/
|
|
406
419
|
export function formatStepLine(
|
|
407
420
|
id: string,
|
|
@@ -450,31 +463,39 @@ export function formatStatusRegion(
|
|
|
450
463
|
'',
|
|
451
464
|
paintPinnedId(id, colors),
|
|
452
465
|
colors,
|
|
466
|
+
// A blank `blocked` cell, so the id sits under the header's `task`.
|
|
467
|
+
'',
|
|
453
468
|
),
|
|
454
469
|
)
|
|
455
470
|
}
|
|
456
471
|
|
|
457
|
-
//
|
|
458
|
-
// the ticking time IS the motion), then `running`,
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
const
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
)
|
|
476
|
-
|
|
472
|
+
// Running rows: NO glyph — the live elapsed time leads (no spinner;
|
|
473
|
+
// the ticking time IS the motion), then `running`, the usual duration,
|
|
474
|
+
// how many tasks wait on it, and the full id (never truncated — name is
|
|
475
|
+
// the last column). The rows no task runs in hold the tasks planned
|
|
476
|
+
// next, each nested under what it waits on; the rest stay blank, so the
|
|
477
|
+
// list keeps its height.
|
|
478
|
+
const running = s.slots.filter((x): x is WorkerSlot => x !== null && x.waiting !== true)
|
|
479
|
+
// Placed by an executor (DTE), the list is what runs: blocked tasks are
|
|
480
|
+
// the tasks line's count alone, and its size is no machine's.
|
|
481
|
+
const placed = s.where !== undefined
|
|
482
|
+
const free = Math.max(0, (s.size ?? 0) - running.length)
|
|
483
|
+
const nested = new Map<string, BlockedTask[]>()
|
|
484
|
+
if (!placed && s.stopping !== true) {
|
|
485
|
+
const ids = new Set(running.map((x) => x.id))
|
|
486
|
+
let n = 0
|
|
487
|
+
for (const b of s.next ?? []) {
|
|
488
|
+
if (n === free) break
|
|
489
|
+
if (!ids.has(b.by)) continue
|
|
490
|
+
let list = nested.get(b.by)
|
|
491
|
+
if (list === undefined) nested.set(b.by, (list = []))
|
|
492
|
+
list.push(b)
|
|
493
|
+
n++
|
|
477
494
|
}
|
|
495
|
+
}
|
|
496
|
+
let rows = 0
|
|
497
|
+
for (const slot of running) {
|
|
498
|
+
rows++
|
|
478
499
|
if (s.stopping === true) {
|
|
479
500
|
lines.push(
|
|
480
501
|
formatTaskRow(
|
|
@@ -486,15 +507,12 @@ export function formatStatusRegion(
|
|
|
486
507
|
'',
|
|
487
508
|
paintPinnedId(slot.id, colors),
|
|
488
509
|
colors,
|
|
510
|
+
'',
|
|
489
511
|
),
|
|
490
512
|
)
|
|
491
513
|
continue
|
|
492
514
|
}
|
|
493
|
-
// Handed to an executor that has no machine on it yet: no row, only
|
|
494
|
-
// the tasks line's `scheduled` (M, 2026-10-21).
|
|
495
|
-
if (slot.waiting === true) continue
|
|
496
515
|
const at = s.where?.(slot.id)
|
|
497
|
-
const critical = s.critical === slot.id && busy > 1
|
|
498
516
|
const usual = s.expected?.get(slot.id)
|
|
499
517
|
lines.push(
|
|
500
518
|
formatTaskRow(
|
|
@@ -506,11 +524,29 @@ export function formatStatusRegion(
|
|
|
506
524
|
'',
|
|
507
525
|
paintPinnedId(slot.id, colors),
|
|
508
526
|
colors,
|
|
527
|
+
String(s.blockedBy?.get(slot.id) ?? 0),
|
|
509
528
|
) +
|
|
510
|
-
(critical ? pathNote(colors) : '') +
|
|
529
|
+
(s.critical === slot.id ? pathNote(colors) : '') +
|
|
511
530
|
(at != null ? placeNote(at, colors) : ''),
|
|
512
531
|
)
|
|
532
|
+
for (const b of nested.get(slot.id) ?? []) {
|
|
533
|
+
rows++
|
|
534
|
+
lines.push(
|
|
535
|
+
formatTaskRow(
|
|
536
|
+
' ',
|
|
537
|
+
null,
|
|
538
|
+
'blocked',
|
|
539
|
+
'',
|
|
540
|
+
'',
|
|
541
|
+
'',
|
|
542
|
+
`${dim('\u2514')} ${paintPinnedId(b.id, colors)}`,
|
|
543
|
+
colors,
|
|
544
|
+
'',
|
|
545
|
+
),
|
|
546
|
+
)
|
|
547
|
+
}
|
|
513
548
|
}
|
|
549
|
+
if (!placed) for (; rows < (s.size ?? 0); rows++) lines.push('')
|
|
514
550
|
for (const step of s.steps ?? []) {
|
|
515
551
|
lines.push(
|
|
516
552
|
formatTaskRow(
|
|
@@ -522,20 +558,11 @@ export function formatStatusRegion(
|
|
|
522
558
|
'',
|
|
523
559
|
paintPinnedId(step.id, colors),
|
|
524
560
|
colors,
|
|
561
|
+
'',
|
|
525
562
|
),
|
|
526
563
|
)
|
|
527
564
|
}
|
|
528
|
-
if (lines.length > 1) lines.splice(1, 0, formatListHeader(
|
|
529
|
-
// A region taller than the terminal (a row spared) folds the rows past
|
|
530
|
-
// what fits into one line: the writer would cut the summary at its
|
|
531
|
-
// foot, and lines never printed cannot be scrolled to (M, 2026-10-21).
|
|
532
|
-
const room = s.rows === undefined ? Infinity : s.rows - 1 - s.summaryLines.length
|
|
533
|
-
if (lines.length > room && room >= 3) {
|
|
534
|
-
const kept = room - 1
|
|
535
|
-
const more = lines.length - kept
|
|
536
|
-
lines.length = kept
|
|
537
|
-
lines.push(`${' '.repeat(3 + TIME_COL + 1)}${dim(`+${more} more`)}`)
|
|
538
|
-
}
|
|
565
|
+
if (lines.length > 1) lines.splice(1, 0, formatListHeader(placed, colors, true))
|
|
539
566
|
// The live summary section — identical shape to the final printout,
|
|
540
567
|
// so the region visually BECOMES the summary when the run ends.
|
|
541
568
|
lines.push(...s.summaryLines)
|
|
@@ -544,11 +571,12 @@ export function formatStatusRegion(
|
|
|
544
571
|
|
|
545
572
|
/**
|
|
546
573
|
* A running task's usual duration, fixed while the elapsed time counts up
|
|
547
|
-
* toward it (
|
|
574
|
+
* toward it (2026-10-14): `~5.2s`, `~42s`, `~2m 05s`. Fits the cache
|
|
548
575
|
* column below 100 minutes.
|
|
549
576
|
*/
|
|
550
577
|
function formatExpected(ms: number): string {
|
|
551
|
-
|
|
578
|
+
// The tier by the rounded value: 999.6 ms printed `~1000ms` (item 1034's class).
|
|
579
|
+
if (Math.round(ms) < 1000) return `~${Math.round(ms)}ms`
|
|
552
580
|
if (ms < 9950) return `~${(ms / 1000).toFixed(1)}s`
|
|
553
581
|
const s = Math.round(ms / 1000)
|
|
554
582
|
if (s < 60) return `~${s}s`
|
package/src/report/summary.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
formatBytes,
|
|
14
14
|
formatElapsed,
|
|
15
15
|
gradientRule,
|
|
16
|
+
identityColor,
|
|
16
17
|
paint,
|
|
17
18
|
SUCCESS,
|
|
18
19
|
ui,
|
|
@@ -113,10 +114,12 @@ export interface SummaryStats {
|
|
|
113
114
|
held?: { count: number; sumMs: number }
|
|
114
115
|
/** Live section only: the forecast of what is left, from history. */
|
|
115
116
|
etaMs?: number
|
|
117
|
+
/** Live section only: how long the run has gone past that forecast. */
|
|
118
|
+
overMs?: number
|
|
116
119
|
/** A run of groups only: their names, so `0 tasks` says why. */
|
|
117
120
|
emptyGroups?: readonly string[]
|
|
118
|
-
/** Each finished task's cache bucket by its key and its id (its run bytes), so its remote traffic can move it. */
|
|
119
|
-
buckets?: ReadonlyMap<string,
|
|
121
|
+
/** Each finished task's id and cache bucket by its key and its id (its run bytes), so its remote traffic can move it. */
|
|
122
|
+
buckets?: ReadonlyMap<string, TaskBucket>
|
|
120
123
|
}
|
|
121
124
|
|
|
122
125
|
/** The cache bar segment a finished task fills before its remote traffic is weighed. */
|
|
@@ -129,6 +132,12 @@ export type CacheBucket =
|
|
|
129
132
|
| 'saved'
|
|
130
133
|
| 'notSaved'
|
|
131
134
|
|
|
135
|
+
/** A finished task's id and cache bucket, as `SummaryStats.buckets` holds them. */
|
|
136
|
+
export interface TaskBucket {
|
|
137
|
+
task: string
|
|
138
|
+
bucket: CacheBucket
|
|
139
|
+
}
|
|
140
|
+
|
|
132
141
|
/** A finished task's own cache bucket; none for a group, a skip or an abort. */
|
|
133
142
|
export function cacheBucketOf(o: TaskOutcome): CacheBucket | undefined {
|
|
134
143
|
if (isGroupTask(o.node)) return undefined
|
|
@@ -152,10 +161,12 @@ export function cacheBucketOf(o: TaskOutcome): CacheBucket | undefined {
|
|
|
152
161
|
}
|
|
153
162
|
}
|
|
154
163
|
|
|
155
|
-
type
|
|
164
|
+
type RemoteState = 'uploaded' | 'uploading' | 'remoteFailed'
|
|
165
|
+
const REMOTE_RANK: Record<RemoteState, number> = { uploaded: 0, uploading: 1, remoteFailed: 2 }
|
|
166
|
+
type Buckets = Record<CacheBucket | RemoteState, number>
|
|
156
167
|
|
|
157
168
|
/**
|
|
158
|
-
* The cache bar's segments, each task in one (
|
|
169
|
+
* The cache bar's segments, each task in one (2026-10-15). With layers
|
|
159
170
|
* named, a saved task leaves the misses, and a task with remote traffic
|
|
160
171
|
* leaves its own bucket for its strongest remote state: a failed read or
|
|
161
172
|
* write, then an upload still running, then one that landed. Without, the
|
|
@@ -164,7 +175,7 @@ type Buckets = Record<CacheBucket | 'uploaded' | 'uploading' | 'remoteFailed', n
|
|
|
164
175
|
function cacheBuckets(stats: SummaryStats, layers: RunContext['cache']): Buckets {
|
|
165
176
|
const saved = layers === undefined ? 0 : (stats.saved ?? 0)
|
|
166
177
|
// Its own yellow segment, as its legend count is: in the misses' red it
|
|
167
|
-
// was a cell with no count under it (
|
|
178
|
+
// was a cell with no count under it (2026-10-21).
|
|
168
179
|
const notSaved = layers === undefined ? 0 : (stats.notSaved ?? 0)
|
|
169
180
|
const b: Buckets = {
|
|
170
181
|
miss: stats.miss - saved - notSaved,
|
|
@@ -179,13 +190,20 @@ function cacheBuckets(stats: SummaryStats, layers: RunContext['cache']): Buckets
|
|
|
179
190
|
remoteFailed: 0,
|
|
180
191
|
}
|
|
181
192
|
if (layers?.remote !== true || stats.buckets === undefined) return b
|
|
182
|
-
|
|
183
|
-
|
|
193
|
+
// A task's traffic may sit under its key (its entry) and its id (its run
|
|
194
|
+
// bytes): it moves once, to the strongest state of the two.
|
|
195
|
+
const moved = new Map<string, RemoteState>()
|
|
196
|
+
for (const [key, k] of layers.traffic.keys) {
|
|
197
|
+
const from = stats.buckets.get(key)
|
|
184
198
|
// Still running: it counts once it ends.
|
|
185
199
|
if (from === undefined) continue
|
|
186
|
-
|
|
187
|
-
|
|
200
|
+
const state: RemoteState =
|
|
201
|
+
k.failures.length > 0 ? 'remoteFailed' : k.uploading > 0 ? 'uploading' : 'uploaded'
|
|
202
|
+
const prior = moved.get(from.task)
|
|
203
|
+
if (prior === undefined) b[from.bucket]--
|
|
204
|
+
if (prior === undefined || REMOTE_RANK[state] > REMOTE_RANK[prior]) moved.set(from.task, state)
|
|
188
205
|
}
|
|
206
|
+
for (const state of moved.values()) b[state]++
|
|
189
207
|
return b
|
|
190
208
|
}
|
|
191
209
|
|
|
@@ -234,6 +252,8 @@ export interface RunContext {
|
|
|
234
252
|
}
|
|
235
253
|
/** Total projects discovery found — the bar's denominator. */
|
|
236
254
|
workspaceProjectCount?: number
|
|
255
|
+
/** The run's warnings no task owns, as they were said: the footer's `warn` row. */
|
|
256
|
+
warnings?: () => readonly string[]
|
|
237
257
|
}
|
|
238
258
|
|
|
239
259
|
export function formatSummarySection(
|
|
@@ -264,7 +284,7 @@ export function formatSummarySection(
|
|
|
264
284
|
|
|
265
285
|
// Tasks meter + numbers, fixed order: failed · success · skipped ·
|
|
266
286
|
// aborted · not run, then a dim total, last on its line. Every task in
|
|
267
|
-
// the run counts, each state its own segment (
|
|
287
|
+
// the run counts, each state its own segment (2026-10-14).
|
|
268
288
|
const runTotal = stats.total + aborted + notRun
|
|
269
289
|
const taskParts: string[] = []
|
|
270
290
|
if (stats.failed > 0)
|
|
@@ -276,6 +296,8 @@ export function formatSummarySection(
|
|
|
276
296
|
if (running > 0) taskParts.push(paint(RUNNING, `${running} running`, colors))
|
|
277
297
|
if (scheduled > 0) taskParts.push(dim(`${scheduled} scheduled`))
|
|
278
298
|
if (blocked > 0) taskParts.push(dim(`${blocked} blocked`))
|
|
299
|
+
// What is not done yet, running included: the count the bar's unfilled part shows.
|
|
300
|
+
if (left > 0) taskParts.push(dim(`${left} left`))
|
|
279
301
|
if (runTotal > 0) taskParts.push(dim(`${runTotal} total`))
|
|
280
302
|
const taskBar = segmentBar(
|
|
281
303
|
[
|
|
@@ -307,10 +329,10 @@ export function formatSummarySection(
|
|
|
307
329
|
const wsTotal = context.workspaceProjectCount
|
|
308
330
|
if (wsTotal !== undefined && wsTotal > 0) {
|
|
309
331
|
const width = barWidth(colors)
|
|
310
|
-
const cells =
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
332
|
+
const cells =
|
|
333
|
+
context.packageCount === 0
|
|
334
|
+
? 0
|
|
335
|
+
: Math.min(width, Math.max(1, Math.round((context.packageCount / wsTotal) * width)))
|
|
314
336
|
const bar =
|
|
315
337
|
paint(WARN, '▰'.repeat(cells), colors) +
|
|
316
338
|
paint('', '▱'.repeat(width - cells), colors, { dim: true })
|
|
@@ -362,7 +384,7 @@ export function formatSummarySection(
|
|
|
362
384
|
)
|
|
363
385
|
const cacheParts: string[] = []
|
|
364
386
|
if (layers !== undefined) {
|
|
365
|
-
// Per layer (
|
|
387
|
+
// Per layer (2026-10-15): `✓` fresh (outputs already on disk, from
|
|
366
388
|
// either layer) and `∅` no cache, both local; `↓` restored from a
|
|
367
389
|
// layer, `↑` saved to it; `✗` a remote read or write failed, `⇡` still
|
|
368
390
|
// uploading. Each count is its bar segment's, glyph and number in its colour.
|
|
@@ -374,7 +396,7 @@ export function formatSummarySection(
|
|
|
374
396
|
...(layers.local ? n(b.restoredLocal, '\u2193', LOCAL) : []),
|
|
375
397
|
...(layers.localWrite ? n(b.saved, '\u2191', LOCAL) : []),
|
|
376
398
|
]
|
|
377
|
-
// The layer words are dim; only the counts carry colour (
|
|
399
|
+
// The layer words are dim; only the counts carry colour (2026-10-15).
|
|
378
400
|
if (local.length > 0) cacheParts.push(`${dim('local')} ${local.join(' ')}`)
|
|
379
401
|
// Each transfer count is followed by its bytes, dim: what the
|
|
380
402
|
// remote moved is the cost a hit or an upload paid.
|
|
@@ -408,7 +430,7 @@ export function formatSummarySection(
|
|
|
408
430
|
}
|
|
409
431
|
// Skipped tasks ride the bar, yellow like the tasks meter, so the two
|
|
410
432
|
// bars fill alike; their count is the tasks legend's alone (the footer
|
|
411
|
-
// never says a fact twice,
|
|
433
|
+
// never says a fact twice, 2026-10-14).
|
|
412
434
|
lines.push(row('cache', cacheBar))
|
|
413
435
|
if (cacheParts.length > 0) lines.push(legend(cacheParts))
|
|
414
436
|
}
|
|
@@ -416,7 +438,7 @@ export function formatSummarySection(
|
|
|
416
438
|
// Cache-miss duration spread (owner: hits' restore times polluted
|
|
417
439
|
// it — only tasks that actually executed count). Dim: context.
|
|
418
440
|
// A value already on the row is not said again: one executed task read
|
|
419
|
-
// as its own max, avg and min (
|
|
441
|
+
// as its own max, avg and min (2026-10-14). Tasks that all took the
|
|
420
442
|
// same time have no spread to show.
|
|
421
443
|
const total = formatElapsed(totalMs)
|
|
422
444
|
const said = new Set([total])
|
|
@@ -442,6 +464,8 @@ export function formatSummarySection(
|
|
|
442
464
|
// that is about the future.
|
|
443
465
|
if (stats.etaMs !== undefined)
|
|
444
466
|
timeParts.push(paint(ACCENT, `${formatEta(stats.etaMs)} left`, colors))
|
|
467
|
+
else if (stats.overMs !== undefined)
|
|
468
|
+
timeParts.push(paint(ACCENT, `${formatOver(stats.overMs)} over`, colors))
|
|
445
469
|
for (const p of spreadParts) timeParts.push(dim(p))
|
|
446
470
|
const timeRow = row('time', join(timeParts))
|
|
447
471
|
// Run-shape footer (final summary only): worker pool + cache mode,
|
|
@@ -480,13 +504,33 @@ export function formatSummarySection(
|
|
|
480
504
|
)
|
|
481
505
|
}
|
|
482
506
|
if (context.runId !== undefined) info.push(`run ${context.runId}`)
|
|
483
|
-
lines.push('',
|
|
507
|
+
lines.push('', ...warnRows(context.warnings?.() ?? [], row, colors))
|
|
508
|
+
lines.push(row('info', join(info)), timeRow)
|
|
484
509
|
} else {
|
|
485
510
|
lines.push('', timeRow)
|
|
486
511
|
}
|
|
487
512
|
return lines
|
|
488
513
|
}
|
|
489
514
|
|
|
515
|
+
/**
|
|
516
|
+
* A long run scrolls its warnings (a remote cache's 403, a plugin's
|
|
517
|
+
* failed flush) out of sight, and the footer read as a clean run
|
|
518
|
+
* (2026-10-27). The footer counts the distinct ones; it does not repeat
|
|
519
|
+
* them, so a warning stays one line in a log (a refused token costs
|
|
520
|
+
* ONE line, tests/remote-cache-degrade.test.ts in @vzn/vx-migrate).
|
|
521
|
+
*/
|
|
522
|
+
function warnRows(
|
|
523
|
+
warnings: readonly string[],
|
|
524
|
+
row: (label: string, value: string) => string,
|
|
525
|
+
colors: ColorSupport,
|
|
526
|
+
): string[] {
|
|
527
|
+
const n = new Set(
|
|
528
|
+
warnings.map((w) => Bun.stripANSI(w).split('\n')[0]!.trim()).filter((w) => w !== ''),
|
|
529
|
+
).size
|
|
530
|
+
if (n === 0) return []
|
|
531
|
+
return [row('warn', paint(WARN, `${n} ${n === 1 ? 'warning' : 'warnings'} above`, colors))]
|
|
532
|
+
}
|
|
533
|
+
|
|
490
534
|
/**
|
|
491
535
|
* A forecast, said as coarsely as it is known: `~4s`, `~2m 05s`, `<1s`
|
|
492
536
|
* once the history says the end is due.
|
|
@@ -498,16 +542,27 @@ function formatEta(ms: number): string {
|
|
|
498
542
|
return `~${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
|
|
499
543
|
}
|
|
500
544
|
|
|
545
|
+
function formatOver(ms: number): string {
|
|
546
|
+
const s = Math.max(1, Math.ceil(ms / 1000))
|
|
547
|
+
return s < 60 ? `+${s}s` : `+${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
|
|
548
|
+
}
|
|
549
|
+
|
|
501
550
|
/**
|
|
502
551
|
* The `--verbosity 1` per-task table (`RunOptions.summaryTable`). Groups are
|
|
503
|
-
* left out as the footer
|
|
552
|
+
* left out as the footer leaves them: a group ran no command. Painted in the
|
|
553
|
+
* list's vocabulary: header bold violet, ids in their hues, status in its
|
|
554
|
+
* state colour, rule and durations dim.
|
|
504
555
|
*/
|
|
505
|
-
export function formatOutcomeTable(
|
|
556
|
+
export function formatOutcomeTable(
|
|
557
|
+
outcomes: readonly TaskOutcome[],
|
|
558
|
+
colors: ColorSupport = NO_COLOR,
|
|
559
|
+
): string[] {
|
|
506
560
|
const rows = outcomes
|
|
507
561
|
.filter((o) => !isGroupTask(o.node))
|
|
508
562
|
.map((o) => {
|
|
509
563
|
const v = projectOutcome(o)
|
|
510
|
-
|
|
564
|
+
const status = neverStarted(o) ? 'not run' : outcomeLabel(v)
|
|
565
|
+
return { o, task: v.taskId, status, duration: `${v.durationMs}ms` }
|
|
511
566
|
})
|
|
512
567
|
if (rows.length === 0) return []
|
|
513
568
|
const widths = {
|
|
@@ -515,17 +570,43 @@ export function formatOutcomeTable(outcomes: readonly TaskOutcome[]): string[] {
|
|
|
515
570
|
status: Math.max(6, ...rows.map((r) => r.status.length)),
|
|
516
571
|
duration: Math.max(8, ...rows.map((r) => r.duration.length)),
|
|
517
572
|
}
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
const
|
|
573
|
+
const pad = (text: string, width: number) => ' '.repeat(Math.max(0, width - text.length))
|
|
574
|
+
const header = `${'TASK'.padEnd(widths.task)} ${'STATUS'.padEnd(widths.status)} ${'DURATION'.padStart(widths.duration)}`
|
|
575
|
+
const dim = (t: string) => paint('', t, colors, { dim: true })
|
|
521
576
|
return [
|
|
522
577
|
'',
|
|
523
|
-
header,
|
|
524
|
-
'-'.repeat(header.length),
|
|
525
|
-
...rows.map((r) =>
|
|
578
|
+
paint(TABLE_HEADER, header, colors, { bold: true }),
|
|
579
|
+
dim('-'.repeat(header.length)),
|
|
580
|
+
...rows.map((r) => {
|
|
581
|
+
const { node } = r.o
|
|
582
|
+
const id =
|
|
583
|
+
node.projectName !== undefined && node.taskName !== undefined
|
|
584
|
+
? paint(identityColor(node.projectName), node.projectName, colors) +
|
|
585
|
+
dim('#') +
|
|
586
|
+
paint(identityColor(node.taskName), node.taskName, colors)
|
|
587
|
+
: r.task
|
|
588
|
+
const status = paint(tableStatusColor(r.o), r.status, colors)
|
|
589
|
+
return `${id}${pad(r.task, widths.task)} ${status}${pad(r.status, widths.status)} ${pad(r.duration, widths.duration)}${dim(r.duration)}`
|
|
590
|
+
}),
|
|
526
591
|
]
|
|
527
592
|
}
|
|
528
593
|
|
|
594
|
+
/** violet-400, as the task list's header. */
|
|
595
|
+
const TABLE_HEADER = '#a78bfa'
|
|
596
|
+
|
|
597
|
+
function tableStatusColor(o: TaskOutcome): string {
|
|
598
|
+
switch (o.status) {
|
|
599
|
+
case 'failed':
|
|
600
|
+
return ERROR
|
|
601
|
+
case 'skipped':
|
|
602
|
+
return WARN
|
|
603
|
+
case 'aborted':
|
|
604
|
+
return neverStarted(o) ? NOT_RUN : ABORTED
|
|
605
|
+
default:
|
|
606
|
+
return SUCCESS
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
529
610
|
// Local byte formatter — the orchestrator can't import cli/format.ts
|
|
530
611
|
// (module boundary), and the footer only needs one compact form.
|
|
531
612
|
/** Ran here with a `cache` block, succeeded, and its result was not saved. */
|
|
@@ -598,13 +679,14 @@ export function formatRunSummary(
|
|
|
598
679
|
}
|
|
599
680
|
|
|
600
681
|
/** Each finished task's cache bucket by its key and by its id. */
|
|
601
|
-
function bucketsOf(outcomes: readonly TaskOutcome[]): Map<string,
|
|
602
|
-
const out = new Map<string,
|
|
682
|
+
function bucketsOf(outcomes: readonly TaskOutcome[]): Map<string, TaskBucket> {
|
|
683
|
+
const out = new Map<string, TaskBucket>()
|
|
603
684
|
for (const o of outcomes) {
|
|
604
685
|
const bucket = cacheBucketOf(o)
|
|
605
686
|
if (bucket === undefined) continue
|
|
606
|
-
|
|
607
|
-
out.set(o.
|
|
687
|
+
const entry = { task: o.node.id, bucket }
|
|
688
|
+
if (o.hash !== undefined) out.set(o.hash, entry)
|
|
689
|
+
out.set(o.node.id, entry)
|
|
608
690
|
}
|
|
609
691
|
return out
|
|
610
692
|
}
|