@vzn/vx 0.0.638 → 0.0.640
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 +1 -1
- package/docs/caching.md +152 -95
- package/docs/cli.md +225 -372
- package/docs/execution.md +22 -47
- package/docs/features.md +14 -16
- package/docs/patterns.md +6 -8
- package/docs/schema.md +268 -215
- package/docs/security.md +1 -1
- package/package.json +5 -5
- package/schemas/last.json +7 -4
- package/schemas/show.json +14 -14
- package/skills/vx/SKILL.md +10 -7
- package/src/cache/cache.ts +44 -11
- package/src/cache/file-hashes.ts +7 -12
- package/src/cache/fs-clock.ts +62 -0
- package/src/cache/git-inputs.ts +8 -2
- package/src/cache/index.ts +5 -2
- package/src/cache/inputs.ts +154 -83
- package/src/cache/key-fold.ts +23 -3
- package/src/cache/layer.ts +33 -18
- package/src/cache/layered-cache.ts +108 -315
- package/src/cache/linked-inputs.ts +87 -0
- package/src/cache/plugin-cache.ts +2 -10
- package/src/cache/remote-lane.ts +383 -0
- package/src/cache/run-history.ts +24 -8
- package/src/cache/run-store.ts +4 -4
- package/src/cache/schema.ts +6 -4
- package/src/cache/scoped-cache.ts +7 -0
- package/src/cli/completions.ts +2 -2
- package/src/cli/foreign-flags.ts +11 -23
- package/src/cli/help.ts +6 -31
- package/src/cli/info.ts +2 -1
- package/src/cli/init.ts +23 -11
- package/src/cli/last.ts +30 -4
- package/src/cli/plan-format.ts +5 -58
- package/src/cli/plugin-commands.ts +2 -2
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +23 -185
- package/src/cli/select.ts +16 -6
- package/src/cli/show.ts +15 -6
- package/src/cli/upgrade.ts +215 -55
- package/src/cli/watch-cycle.ts +2 -1
- package/src/cli/watch-filter.ts +22 -6
- package/src/cli/watch-judge.ts +49 -5
- package/src/cli/watch-set.ts +18 -8
- package/src/cli/watch.ts +103 -16
- package/src/cli/why.ts +9 -7
- package/src/config.ts +19 -21
- package/src/exec/executor.ts +5 -5
- package/src/exec/sandbox-runtime.ts +1 -1
- package/src/exec/sandbox-violations.ts +3 -1
- package/src/graph/dependency-spec.ts +2 -2
- package/src/graph/folds.ts +236 -0
- package/src/graph/index.ts +17 -2
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +45 -31
- package/src/graph/task-graph.ts +190 -58
- package/src/graph/task-status.ts +95 -0
- package/src/{orchestrator → history}/failure-mode.ts +1 -1
- package/src/{orchestrator → history}/history.ts +1 -1
- package/src/history/index.ts +48 -0
- package/src/{orchestrator → history}/metrics.ts +7 -20
- package/src/{orchestrator → history}/miss-reason.ts +1 -1
- package/src/index.ts +11 -29
- package/src/orchestrator/affected-tasks.ts +20 -19
- package/src/orchestrator/doctor.ts +1 -1
- package/src/orchestrator/download-policy.ts +16 -22
- package/src/orchestrator/excluded-keys.ts +4 -5
- package/src/orchestrator/execute-args.ts +80 -0
- package/src/orchestrator/execute-task.ts +50 -411
- package/src/orchestrator/fingerprint-watch.ts +2 -2
- package/src/orchestrator/index.ts +2 -73
- package/src/orchestrator/keyed-projects.ts +12 -51
- package/src/orchestrator/local-shortcircuit.ts +1 -1
- 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 +5 -5
- package/src/orchestrator/plan.ts +19 -6
- package/src/orchestrator/plugin-host.ts +129 -69
- package/src/orchestrator/plugin.ts +18 -8
- package/src/orchestrator/prepare.ts +40 -34
- package/src/orchestrator/projects.ts +7 -10
- package/src/orchestrator/remote-prefetch.ts +14 -1
- 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 +35 -3
- package/src/orchestrator/run-report.ts +151 -171
- package/src/orchestrator/run-settings.ts +1 -16
- package/src/orchestrator/run-steps.ts +57 -0
- package/src/orchestrator/run-stop.ts +153 -0
- package/src/orchestrator/run.ts +293 -604
- package/src/orchestrator/sandbox-fix.ts +1 -17
- package/src/orchestrator/sandbox-request.ts +2 -2
- package/src/orchestrator/signals.ts +14 -6
- package/src/orchestrator/stable-keys.ts +164 -27
- package/src/orchestrator/steps.ts +70 -0
- package/src/orchestrator/task-hash.ts +34 -25
- package/src/orchestrator/task-log-buffer.ts +1 -1
- package/src/orchestrator/task-process.ts +94 -0
- package/src/orchestrator/telemetry-host.ts +4 -4
- package/src/orchestrator/telemetry.ts +14 -86
- package/src/orchestrator/upstream.ts +4 -181
- package/src/{orchestrator → report}/events.ts +41 -8
- package/src/{orchestrator → report}/forecast.ts +35 -2
- package/src/{orchestrator → report}/framed-output.ts +40 -34
- package/src/report/index.ts +36 -0
- package/src/{orchestrator → report}/logger.ts +158 -20
- package/src/{orchestrator → report}/status-line.ts +88 -7
- package/src/{orchestrator → report}/summary.ts +126 -35
- package/src/report/tally.ts +83 -0
- package/src/settings.ts +12 -39
- package/src/util/colors.ts +44 -4
- package/src/util/git-ignored.ts +143 -0
- package/src/util/index.ts +19 -4
- package/src/util/paths.ts +1 -1
- package/src/util/report.ts +13 -5
- package/src/util/settle.ts +14 -39
- package/src/util/tail.ts +1 -1
- package/src/util/ui.ts +17 -6
- package/src/workspace/affected.ts +81 -2
- package/src/workspace/config-imports.ts +1 -1
- package/src/workspace/config-schema.ts +142 -143
- package/src/workspace/lockfile.ts +1 -1
- package/src/workspace/migration.ts +19 -3
- package/schemas/summary.json +0 -133
- package/src/cache/chained-cache.ts +0 -280
- 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
|
@@ -27,32 +27,27 @@
|
|
|
27
27
|
|
|
28
28
|
import { withForwardArgs } from '../exec/index.js'
|
|
29
29
|
import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
formatElapsed,
|
|
32
|
+
identityColor,
|
|
33
|
+
maskedCommand,
|
|
34
|
+
paint,
|
|
35
|
+
type ColorSupport,
|
|
36
|
+
} from '../util/index.js'
|
|
31
37
|
import { neverStarted } from './summary.js'
|
|
32
38
|
import { outcomeLabel, ranNoCache, skippedReason } from './events.js'
|
|
33
39
|
import { fileMemo, linkPaths } from './path-links.js'
|
|
34
|
-
import { sandboxFixLine } from './sandbox-fix.js'
|
|
35
40
|
|
|
36
41
|
const NO_COLOR: ColorSupport = { enabled: false }
|
|
37
42
|
|
|
38
43
|
const ACCENT = '#06b6d4' // cyan-500 — bullets, remote-hit hint
|
|
39
|
-
// Identity hues
|
|
40
|
-
// red / yellow / cyan) so a task id can never read as an outcome.
|
|
41
|
-
//
|
|
42
|
-
// run, list
|
|
43
|
-
// the project palette so the two halves always read apart.
|
|
44
|
+
// Identity hues (`identityColor`) are outside the status palette (green /
|
|
45
|
+
// red / yellow / cyan) so a task id can never read as an outcome. Each
|
|
46
|
+
// half of an id hashes its own name: same project, same hue; same target,
|
|
47
|
+
// same hue, in every run, list and region row.
|
|
44
48
|
// Section rules + the summary rule share one frame width.
|
|
45
49
|
const FRAME_WIDTH = 60
|
|
46
50
|
|
|
47
|
-
const PROJECT_PALETTE = [
|
|
48
|
-
'#a78bfa', // violet-400
|
|
49
|
-
'#60a5fa', // blue-400
|
|
50
|
-
'#e879f9', // fuchsia-400
|
|
51
|
-
'#818cf8', // indigo-400
|
|
52
|
-
'#c084fc', // purple-400
|
|
53
|
-
'#93c5fd', // blue-300
|
|
54
|
-
] as const
|
|
55
|
-
const TASK = '#f472b6' // pink-400 — task part of an id
|
|
56
51
|
const SUCCESS = '#22c55e' // green-500 — success / fresh
|
|
57
52
|
const WARN = '#eab308' // yellow-500 — skipped
|
|
58
53
|
const ERROR = '#ef4444' // red-500 — failed
|
|
@@ -209,7 +204,8 @@ function pushOutputSection(
|
|
|
209
204
|
function sectionLine(title: string, color: string, colors: ColorSupport): string {
|
|
210
205
|
const corner = (s: string) => paint('', s, colors, { dim: true })
|
|
211
206
|
const label = paint(color, title, colors, { bold: true })
|
|
212
|
-
const
|
|
207
|
+
const width = Math.min(FRAME_WIDTH, colors.columns ?? FRAME_WIDTH)
|
|
208
|
+
const rule = corner('─'.repeat(Math.max(1, width - 4 - title.length)))
|
|
213
209
|
return `${corner('├─')} ${label} ${rule}`
|
|
214
210
|
}
|
|
215
211
|
|
|
@@ -222,18 +218,9 @@ function pushBodyLines(lines: string[], text: string): void {
|
|
|
222
218
|
for (const line of text.replace(/\n$/, '').split('\n')) lines.push(line)
|
|
223
219
|
}
|
|
224
220
|
|
|
225
|
-
/** Stable per-project hue: FNV-style fold of the name into the palette. */
|
|
226
|
-
function projectColor(projectName: string): string {
|
|
227
|
-
let h = 0
|
|
228
|
-
for (let i = 0; i < projectName.length; i++) {
|
|
229
|
-
h = (h * 31 + projectName.charCodeAt(i)) >>> 0
|
|
230
|
-
}
|
|
231
|
-
return PROJECT_PALETTE[h % PROJECT_PALETTE.length]!
|
|
232
|
-
}
|
|
233
|
-
|
|
234
221
|
/**
|
|
235
|
-
* `project#task` halves in
|
|
236
|
-
*
|
|
222
|
+
* `project#task` halves, each in its name's identity hue (never a status
|
|
223
|
+
* color); the dim separator keeps them reading apart. `hueSource`
|
|
237
224
|
* exists for truncated display strings: the hue must hash from the
|
|
238
225
|
* FULL project name so it survives truncation.
|
|
239
226
|
*/
|
|
@@ -242,13 +229,14 @@ export function paintIdParts(
|
|
|
242
229
|
projectText: string,
|
|
243
230
|
taskText: string,
|
|
244
231
|
colors: ColorSupport,
|
|
245
|
-
opts: { bold?: boolean } = {},
|
|
232
|
+
opts: { bold?: boolean; sep?: '#' | ':' } = {},
|
|
246
233
|
): string {
|
|
247
234
|
const dim = (t: string) => paint('', t, colors, { dim: true })
|
|
235
|
+
const { sep = '#', ...style } = opts
|
|
248
236
|
return (
|
|
249
|
-
paint(
|
|
250
|
-
dim(
|
|
251
|
-
paint(
|
|
237
|
+
paint(identityColor(hueSource), projectText, colors, style) +
|
|
238
|
+
dim(sep) +
|
|
239
|
+
paint(identityColor(taskText), taskText, colors, style)
|
|
252
240
|
)
|
|
253
241
|
}
|
|
254
242
|
|
|
@@ -544,7 +532,7 @@ function violationSection(outcome: TaskOutcome, colors: ColorSupport): string[]
|
|
|
544
532
|
...(fix.inputs !== undefined
|
|
545
533
|
? [
|
|
546
534
|
dim(
|
|
547
|
-
`and, if the output depends on them,
|
|
535
|
+
`and, if the output depends on them, inputs.files: [${fix.inputs.map((p) => `'${p}'`).join(', ')}]`,
|
|
548
536
|
),
|
|
549
537
|
]
|
|
550
538
|
: []),
|
|
@@ -553,8 +541,10 @@ function violationSection(outcome: TaskOutcome, colors: ColorSupport): string[]
|
|
|
553
541
|
: []),
|
|
554
542
|
'',
|
|
555
543
|
]
|
|
544
|
+
// vx's own notes (`vx: …`) explain a failure; they are not denials.
|
|
545
|
+
const denials = body.filter((l) => !l.startsWith('vx: ')).length
|
|
556
546
|
return [
|
|
557
|
-
sectionLine(`SANDBOX VIOLATIONS (${
|
|
547
|
+
sectionLine(denials > 0 ? `SANDBOX VIOLATIONS (${denials})` : 'SANDBOX', ERROR, colors),
|
|
558
548
|
'',
|
|
559
549
|
...body,
|
|
560
550
|
'',
|
|
@@ -704,3 +694,19 @@ function formatStatusTag(o: TaskOutcome, colors: ColorSupport): string {
|
|
|
704
694
|
return o.status
|
|
705
695
|
}
|
|
706
696
|
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* The fix as the config line it is: `exec.sandbox.allow: { read: ['a'] }`,
|
|
700
|
+
* or undefined when every denial was a withheld credential path.
|
|
701
|
+
*/
|
|
702
|
+
function sandboxFixLine({
|
|
703
|
+
withheld: _,
|
|
704
|
+
inputs: __,
|
|
705
|
+
...fix
|
|
706
|
+
}: NonNullable<TaskOutcome['sandboxFix']>): string | undefined {
|
|
707
|
+
if (Object.keys(fix).length === 0) return undefined
|
|
708
|
+
const parts = Object.entries(fix).map(
|
|
709
|
+
([k, list]: [string, string[]]) => `${k}: [${list.map((p) => `'${p}'`).join(', ')}]`,
|
|
710
|
+
)
|
|
711
|
+
return `exec.sandbox.allow: { ${parts.join(', ')} }`
|
|
712
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// What a run tells the people and programs watching it: the event stream
|
|
2
|
+
// every output surface subscribes to, the terminal renderer (framed task
|
|
3
|
+
// output, the status line, the summary, the forecast) and the plain
|
|
4
|
+
// output for non-TTY runs. It renders outcomes it is handed; it never
|
|
5
|
+
// runs a task or reads the cache.
|
|
6
|
+
export {
|
|
7
|
+
busLogger,
|
|
8
|
+
createEventBus,
|
|
9
|
+
type EventBus,
|
|
10
|
+
type OutcomeView,
|
|
11
|
+
outcomeWord,
|
|
12
|
+
projectNode,
|
|
13
|
+
projectOutcome,
|
|
14
|
+
ranNoCache,
|
|
15
|
+
type RunEvent,
|
|
16
|
+
type RunEventSubscriber,
|
|
17
|
+
type RunStartInfo,
|
|
18
|
+
type TaskView,
|
|
19
|
+
terminalSubscriber,
|
|
20
|
+
toWireEvent,
|
|
21
|
+
type WireEvent,
|
|
22
|
+
wireForwarder,
|
|
23
|
+
} from './events.js'
|
|
24
|
+
export {
|
|
25
|
+
type DefaultLogger,
|
|
26
|
+
defaultLogger,
|
|
27
|
+
type Logger,
|
|
28
|
+
type OutputView,
|
|
29
|
+
resolveOutputView,
|
|
30
|
+
warnOf,
|
|
31
|
+
} from './logger.js'
|
|
32
|
+
export { formatPersistentList } from './framed-output.js'
|
|
33
|
+
export { createForecast } from './forecast.js'
|
|
34
|
+
export { fileLocations, fileMemo, type OutputLocation } from './path-links.js'
|
|
35
|
+
export { plainOutput } from './plain-output.js'
|
|
36
|
+
export { formatOutcomeTable, formatRunSummary } from './summary.js'
|
|
@@ -17,10 +17,18 @@ import {
|
|
|
17
17
|
createOutputWriter,
|
|
18
18
|
formatFailureLine,
|
|
19
19
|
formatStatusRegion,
|
|
20
|
+
formatStepLine,
|
|
20
21
|
type StatusStream,
|
|
21
22
|
type WorkerSlot,
|
|
22
23
|
} from './status-line.js'
|
|
23
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
cacheBucketOf,
|
|
26
|
+
formatSummarySection,
|
|
27
|
+
neverStarted,
|
|
28
|
+
ranUnsaved,
|
|
29
|
+
type CacheBucket,
|
|
30
|
+
type RunContext,
|
|
31
|
+
} from './summary.js'
|
|
24
32
|
import { isGroupTask } from '../graph/index.js'
|
|
25
33
|
import {
|
|
26
34
|
appendTail,
|
|
@@ -32,9 +40,9 @@ import {
|
|
|
32
40
|
detectColors,
|
|
33
41
|
type ColorSupport,
|
|
34
42
|
} from '../util/index.js'
|
|
35
|
-
import { isCacheHit } from '
|
|
43
|
+
import { isCacheHit } from '../graph/index.js'
|
|
36
44
|
import { failedLabel, outcomeWord, ranNoCache } from './events.js'
|
|
37
|
-
import type { Forecast } from './forecast.js'
|
|
45
|
+
import type { BlockedTask, Forecast } from './forecast.js'
|
|
38
46
|
import {
|
|
39
47
|
notifyEscape,
|
|
40
48
|
PROGRESS_CLEAR,
|
|
@@ -46,6 +54,12 @@ import {
|
|
|
46
54
|
export interface Logger {
|
|
47
55
|
/** Header / footer / status text. Written verbatim, one trailing \n added. */
|
|
48
56
|
status(line: string): void
|
|
57
|
+
/**
|
|
58
|
+
* A warning: something worked, but look at it. The default logger writes
|
|
59
|
+
* it to stderr, so stdout carries only the rows and the footer. A logger
|
|
60
|
+
* without it gets warnings through `status`.
|
|
61
|
+
*/
|
|
62
|
+
warn?(line: string): void
|
|
49
63
|
/** Streamed stdout chunk for a task. Buffered until `taskComplete`. */
|
|
50
64
|
taskStdout(node: TaskNode, chunk: string): void
|
|
51
65
|
/** Streamed stderr chunk for a task. Buffered until `taskComplete`. */
|
|
@@ -70,8 +84,21 @@ export interface Logger {
|
|
|
70
84
|
startedAtMs?: number
|
|
71
85
|
}): void
|
|
72
86
|
taskStart?(node: TaskNode): void
|
|
73
|
-
/**
|
|
87
|
+
/** The task graph finished (any outcome); vx's own closing steps follow. Idempotent. */
|
|
74
88
|
runEnd?(): void
|
|
89
|
+
/**
|
|
90
|
+
* One of vx's own steps (`vx:keys`, `@vzn/vx-otel:setup`) began or ended.
|
|
91
|
+
* Not a task: no stat, history row or telemetry record counts it.
|
|
92
|
+
*/
|
|
93
|
+
stepStart?(id: string): void
|
|
94
|
+
stepEnd?(id: string, info: { durationMs: number; ok: boolean }): void
|
|
95
|
+
/** The run's work is done; the footer follows. Idempotent. */
|
|
96
|
+
runDone?(): void
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** `log.warn`, or `log.status` for a logger without it. */
|
|
100
|
+
export function warnOf(log: Logger): (line: string) => void {
|
|
101
|
+
return log.warn !== undefined ? (line) => log.warn!(line) : (line) => log.status(line)
|
|
75
102
|
}
|
|
76
103
|
|
|
77
104
|
/** The logger `run()` builds when none is passed. */
|
|
@@ -82,6 +109,12 @@ export interface DefaultLogger extends Logger {
|
|
|
82
109
|
* throw that never reached `runEnd`.
|
|
83
110
|
*/
|
|
84
111
|
settle(): void
|
|
112
|
+
/**
|
|
113
|
+
* Whether vx's own steps show: only beside a live region, on a terminal.
|
|
114
|
+
* A log (CI, a pipe) stays free of rows whose presence is a timing.
|
|
115
|
+
* When false, run() never times a step (zero cost).
|
|
116
|
+
*/
|
|
117
|
+
readonly showsSteps: boolean
|
|
85
118
|
/**
|
|
86
119
|
* Hand the live region a way to predict the run's end. Called once at
|
|
87
120
|
* run start; `load` runs only if the region is still live a second in,
|
|
@@ -192,6 +225,8 @@ export function resolveOutputView(
|
|
|
192
225
|
const FORECAST_AFTER_MS = 1000
|
|
193
226
|
/** A run shorter than this ends without a desktop notification. */
|
|
194
227
|
const NOTIFY_AFTER_MS = 10_000
|
|
228
|
+
// A step of vx's own quicker than this shows nowhere (M, 2026-10-15).
|
|
229
|
+
const STEP_SHOWN_MS = 150
|
|
195
230
|
|
|
196
231
|
export function defaultLogger(
|
|
197
232
|
colors: ColorSupport = detectColors(),
|
|
@@ -207,9 +242,12 @@ export function defaultLogger(
|
|
|
207
242
|
footer?: boolean
|
|
208
243
|
/** The environment that names the terminal (`TERM_PROGRAM`, …); `process.env` by default. */
|
|
209
244
|
env?: Readonly<Record<string, string | undefined>>
|
|
245
|
+
/** Where warnings go; `process.stderr` by default. */
|
|
246
|
+
err?: StatusStream
|
|
210
247
|
} = {},
|
|
211
248
|
): DefaultLogger {
|
|
212
249
|
const forwardArgs = opts.forwardArgs ?? []
|
|
250
|
+
const errOut = opts.err ?? process.stderr
|
|
213
251
|
// Per-task buffers, split by stream. Splitting lets the framed-output
|
|
214
252
|
// renderer put stdout under `├─ stdout` and stderr under `├─ stderr`.
|
|
215
253
|
// The price: chunks that interleaved at runtime get re-ordered (all
|
|
@@ -303,6 +341,10 @@ export function defaultLogger(
|
|
|
303
341
|
let critical: string | undefined
|
|
304
342
|
let blocks: ReadonlyMap<string, number> | undefined
|
|
305
343
|
let expected: ReadonlyMap<string, number> | undefined
|
|
344
|
+
let blocked: readonly BlockedTask[] | undefined
|
|
345
|
+
// The blocked task each idle worker row holds, by slot: a row keeps its
|
|
346
|
+
// task until it starts, in that row, or leaves the list, so nothing hops.
|
|
347
|
+
const heldRows: (string | null)[] = []
|
|
306
348
|
let etaAtMs = 0
|
|
307
349
|
const signals = writer.enabled
|
|
308
350
|
? terminalSignals(opts.env ?? process.env)
|
|
@@ -334,6 +376,15 @@ export function defaultLogger(
|
|
|
334
376
|
let notSaved = 0
|
|
335
377
|
let skippedCount = 0
|
|
336
378
|
let savedMs = 0
|
|
379
|
+
// Each finished task's cache bucket by its key: its remote traffic moves it.
|
|
380
|
+
const buckets = new Map<string, CacheBucket>()
|
|
381
|
+
// The graph is done and vx's own closing steps run: the region stays,
|
|
382
|
+
// footer and step rows, until the footer replaces it (M, 2026-10-15).
|
|
383
|
+
let tail = false
|
|
384
|
+
// No footer before the run starts: vx's opening steps show alone.
|
|
385
|
+
let started = false
|
|
386
|
+
// vx's own steps running now, by id: a row once one passes STEP_SHOWN_MS.
|
|
387
|
+
const steps = new Map<string, number>()
|
|
337
388
|
// Cache-miss duration spread, accumulated incrementally (the same
|
|
338
389
|
// numbers the final summary computes from the outcome list).
|
|
339
390
|
let spreadMax = 0
|
|
@@ -409,7 +460,7 @@ export function defaultLogger(
|
|
|
409
460
|
if (statusDead) return
|
|
410
461
|
const now = Date.now()
|
|
411
462
|
// Twice a second at most: one pass over the unfinished graph.
|
|
412
|
-
if (forecastFn !== undefined && (force || now - etaAtMs >= 500)) {
|
|
463
|
+
if (forecastFn !== undefined && !tail && (force || now - etaAtMs >= 500)) {
|
|
413
464
|
etaAtMs = now
|
|
414
465
|
const running = new Map<string, number>()
|
|
415
466
|
for (const s of slots) if (s !== null) running.set(s.id, s.startedMs)
|
|
@@ -419,6 +470,7 @@ export function defaultLogger(
|
|
|
419
470
|
critical = f.critical
|
|
420
471
|
blocks = f.blocks
|
|
421
472
|
expected = f.expected
|
|
473
|
+
blocked = f.blocked
|
|
422
474
|
}
|
|
423
475
|
if (signals.progress && total > 0) {
|
|
424
476
|
const elapsed = now - startedAtMs
|
|
@@ -437,7 +489,7 @@ export function defaultLogger(
|
|
|
437
489
|
// The live summary IS the final summary's section, built from the
|
|
438
490
|
// same formatter so the region visually becomes the printout.
|
|
439
491
|
const summaryLines =
|
|
440
|
-
opts.footer === false
|
|
492
|
+
opts.footer === false || !started
|
|
441
493
|
? []
|
|
442
494
|
: formatSummarySection(
|
|
443
495
|
{
|
|
@@ -454,7 +506,8 @@ export function defaultLogger(
|
|
|
454
506
|
noCache,
|
|
455
507
|
savedMs,
|
|
456
508
|
left: total - done,
|
|
457
|
-
...(etaMs !== undefined ? { etaMs } : {}),
|
|
509
|
+
...(etaMs !== undefined && !tail ? { etaMs } : {}),
|
|
510
|
+
buckets,
|
|
458
511
|
spread:
|
|
459
512
|
spreadCount > 0
|
|
460
513
|
? { maxMs: spreadMax, minMs: spreadMin, sumMs: spreadSum, count: spreadCount }
|
|
@@ -464,12 +517,18 @@ export function defaultLogger(
|
|
|
464
517
|
colors,
|
|
465
518
|
runContext,
|
|
466
519
|
)
|
|
520
|
+
const held = blocked === undefined ? undefined : holdRows(blocked)
|
|
521
|
+
const shownSteps: WorkerSlot[] = []
|
|
522
|
+
for (const [id, startedMs] of steps)
|
|
523
|
+
if (now - startedMs >= STEP_SHOWN_MS) shownSteps.push({ id, startedMs })
|
|
467
524
|
writer.setRegion(
|
|
468
525
|
formatStatusRegion(
|
|
469
526
|
{
|
|
470
527
|
pinnedPersistent,
|
|
471
528
|
slots: slots.slice(0, shownSlots),
|
|
529
|
+
...(held !== undefined ? { blocked: held } : {}),
|
|
472
530
|
overflow: slotQueue.length,
|
|
531
|
+
...(shownSteps.length > 0 ? { steps: shownSteps } : {}),
|
|
473
532
|
nowMs: Date.now(),
|
|
474
533
|
summaryLines,
|
|
475
534
|
...(blocks !== undefined ? { blocks } : {}),
|
|
@@ -481,6 +540,46 @@ export function defaultLogger(
|
|
|
481
540
|
{ force },
|
|
482
541
|
)
|
|
483
542
|
}
|
|
543
|
+
// Each idle worker row holds a blocked task, in scheduler order. A row
|
|
544
|
+
// with none left to hold leaves the region (M, 2026-10-15): blocked tasks
|
|
545
|
+
// only dwindle, so it is never needed again, and the rows below close up.
|
|
546
|
+
const holdRows = (list: readonly BlockedTask[]): (BlockedTask | null)[] => {
|
|
547
|
+
const started = new Set<string>()
|
|
548
|
+
for (const s of slots) if (s !== null) started.add(s.id)
|
|
549
|
+
for (const s of slotQueue) started.add(s.id)
|
|
550
|
+
const open = new Map<string, BlockedTask>()
|
|
551
|
+
for (const b of list) if (!started.has(b.id)) open.set(b.id, b)
|
|
552
|
+
const rows: (BlockedTask | null)[] = []
|
|
553
|
+
for (let i = 0; i < shownSlots; i++) {
|
|
554
|
+
const kept = slots[i] === null ? open.get(heldRows[i] ?? '') : undefined
|
|
555
|
+
rows.push(kept ?? null)
|
|
556
|
+
if (kept !== undefined) open.delete(kept.id)
|
|
557
|
+
}
|
|
558
|
+
const rest = open.values()
|
|
559
|
+
for (let i = 0; i < shownSlots; i++)
|
|
560
|
+
if (slots[i] === null && rows[i] === null) rows[i] = rest.next().value ?? null
|
|
561
|
+
for (let i = shownSlots - 1; i >= 0; i--) {
|
|
562
|
+
if (slots[i] !== null || rows[i] !== null) continue
|
|
563
|
+
rows.splice(i, 1)
|
|
564
|
+
slots.splice(i, 1)
|
|
565
|
+
slots.push(null)
|
|
566
|
+
shownSlots--
|
|
567
|
+
}
|
|
568
|
+
heldRows.length = 0
|
|
569
|
+
for (const r of rows) heldRows.push(r?.id ?? null)
|
|
570
|
+
return rows
|
|
571
|
+
}
|
|
572
|
+
const tick = (): void => {
|
|
573
|
+
if (!writer.enabled || statusDead || ticker !== null) return
|
|
574
|
+
// Keeps the per-worker elapsed time ticking between task events
|
|
575
|
+
// (the time IS the motion — there's no spinner). The writer
|
|
576
|
+
// throttles unforced redraws, and unref means a stray ticker
|
|
577
|
+
// can never hold the process open.
|
|
578
|
+
ticker = setInterval(() => {
|
|
579
|
+
refresh(false)
|
|
580
|
+
}, 100)
|
|
581
|
+
ticker.unref?.()
|
|
582
|
+
}
|
|
484
583
|
const killStatus = (): void => {
|
|
485
584
|
if (ticker !== null) {
|
|
486
585
|
clearInterval(ticker)
|
|
@@ -504,7 +603,11 @@ export function defaultLogger(
|
|
|
504
603
|
// no task row (`status`). Without a footer, the caller frames the log
|
|
505
604
|
// (`vx watch` opens each cycle with its own line).
|
|
506
605
|
let logOpened = opts.footer === false
|
|
606
|
+
// The log ends on a blank line (a block's own): a blank status line
|
|
607
|
+
// after it (the footer's) would make two; one blank line apart is the rule.
|
|
608
|
+
let endsBlank = false
|
|
507
609
|
const taskWrite = (chunk: string): void => {
|
|
610
|
+
if (chunk.length > 0) endsBlank = chunk.endsWith('\n\n')
|
|
508
611
|
if (!logOpened && chunk.length > 0) {
|
|
509
612
|
logOpened = true
|
|
510
613
|
writer.write(`\n${chunk}`)
|
|
@@ -559,6 +662,7 @@ export function defaultLogger(
|
|
|
559
662
|
flakyNote(outcome, colors)
|
|
560
663
|
|
|
561
664
|
return {
|
|
665
|
+
showsSteps: writer.enabled,
|
|
562
666
|
settle() {
|
|
563
667
|
// The bus delivers one `run:end`, so the CLI's last word reaches here.
|
|
564
668
|
flushKept()
|
|
@@ -574,28 +678,46 @@ export function defaultLogger(
|
|
|
574
678
|
writer.write('\n')
|
|
575
679
|
streamMidLine = false
|
|
576
680
|
}
|
|
681
|
+
if (line === '' && endsBlank) return
|
|
682
|
+
endsBlank = line === ''
|
|
577
683
|
writer.write(`${line}\n`)
|
|
578
684
|
},
|
|
685
|
+
warn(line) {
|
|
686
|
+
flushKept()
|
|
687
|
+
if (streamMidLine) {
|
|
688
|
+
writer.write('\n')
|
|
689
|
+
streamMidLine = false
|
|
690
|
+
}
|
|
691
|
+
writer.writeTo(errOut, `${line}\n`)
|
|
692
|
+
},
|
|
579
693
|
runStart(info) {
|
|
580
694
|
total = info.total
|
|
581
695
|
requestedCount = info.requestedCount ?? requestedCount
|
|
582
696
|
runContext = info.context
|
|
583
697
|
startedAtMs = Date.now()
|
|
698
|
+
started = true
|
|
584
699
|
const cap = Math.max(1, Math.min(info.concurrency ?? 10, 10))
|
|
585
700
|
slots = Array.from({ length: cap }, () => null)
|
|
586
701
|
shownSlots = 0
|
|
587
|
-
|
|
588
|
-
// Keeps the per-worker elapsed time ticking between task events
|
|
589
|
-
// (the time IS the motion — there's no spinner). The writer
|
|
590
|
-
// throttles unforced redraws, and unref means a stray ticker
|
|
591
|
-
// can never hold the process open.
|
|
592
|
-
ticker = setInterval(() => {
|
|
593
|
-
refresh(false)
|
|
594
|
-
}, 100)
|
|
595
|
-
ticker.unref?.()
|
|
596
|
-
}
|
|
702
|
+
tick()
|
|
597
703
|
refresh(true)
|
|
598
704
|
},
|
|
705
|
+
stepStart(id) {
|
|
706
|
+
steps.set(id, Date.now())
|
|
707
|
+
// Drawn by the ticker once it passes STEP_SHOWN_MS; a quicker one never is.
|
|
708
|
+
tick()
|
|
709
|
+
},
|
|
710
|
+
stepEnd(id, { durationMs, ok }) {
|
|
711
|
+
steps.delete(id)
|
|
712
|
+
if (durationMs < STEP_SHOWN_MS) return
|
|
713
|
+
if (ok ? view.mode !== 'none' && view.mode !== 'errors-only' : view.mode !== 'none')
|
|
714
|
+
emitLine(formatStepLine(id, durationMs, ok, colors))
|
|
715
|
+
refresh(true)
|
|
716
|
+
},
|
|
717
|
+
runDone() {
|
|
718
|
+
tail = false
|
|
719
|
+
killStatus()
|
|
720
|
+
},
|
|
599
721
|
taskStart(node) {
|
|
600
722
|
if (isGroupTask(node)) return
|
|
601
723
|
if (holdsTerminal(node)) {
|
|
@@ -619,7 +741,14 @@ export function defaultLogger(
|
|
|
619
741
|
persistentTails.set(node.id, { node, out: createTail(), err: createTail() })
|
|
620
742
|
}
|
|
621
743
|
const slot: WorkerSlot = { id: node.id, startedMs: Date.now() }
|
|
622
|
-
|
|
744
|
+
// A blocked task starts in the row that held it; another takes a free
|
|
745
|
+
// row, else the last held one, whose task moves to the next free row.
|
|
746
|
+
const own = heldRows.indexOf(node.id)
|
|
747
|
+
let free = own >= 0 && slots[own] === null ? own : -1
|
|
748
|
+
if (free < 0)
|
|
749
|
+
free = slots.findIndex((s, i) => s === null && i < shownSlots && heldRows[i] == null)
|
|
750
|
+
if (free < 0) free = slots.findLastIndex((s, i) => s === null && i < shownSlots)
|
|
751
|
+
if (free < 0) free = slots.indexOf(null)
|
|
623
752
|
if (free >= 0) {
|
|
624
753
|
slots[free] = slot
|
|
625
754
|
shownSlots = Math.max(shownSlots, free + 1)
|
|
@@ -645,7 +774,7 @@ export function defaultLogger(
|
|
|
645
774
|
runEnd() {
|
|
646
775
|
// A long run says it ended where the user is not looking. Once:
|
|
647
776
|
// the status is dead after the first runEnd.
|
|
648
|
-
if (signals.notify && !statusDead && Date.now() - startedAtMs >= NOTIFY_AFTER_MS) {
|
|
777
|
+
if (signals.notify && !statusDead && !tail && Date.now() - startedAtMs >= NOTIFY_AFTER_MS) {
|
|
649
778
|
out.write(
|
|
650
779
|
notifyEscape(
|
|
651
780
|
failed > 0
|
|
@@ -654,7 +783,14 @@ export function defaultLogger(
|
|
|
654
783
|
),
|
|
655
784
|
)
|
|
656
785
|
}
|
|
657
|
-
|
|
786
|
+
if (!tail && !statusDead) {
|
|
787
|
+
tail = true
|
|
788
|
+
slots = []
|
|
789
|
+
blocked = undefined
|
|
790
|
+
shownSlots = 0
|
|
791
|
+
slotQueue.length = 0
|
|
792
|
+
refresh(true)
|
|
793
|
+
} else if (!tail) killStatus()
|
|
658
794
|
// Persistent tails first: they are context for whatever ran against
|
|
659
795
|
// the server, so they read above the failures they explain. `none`
|
|
660
796
|
// prints no per-task output and `errors-only` only failed tasks: a
|
|
@@ -837,6 +973,8 @@ export function defaultLogger(
|
|
|
837
973
|
const qi = slotQueue.findIndex((s) => s.id === node.id)
|
|
838
974
|
if (qi >= 0) slotQueue.splice(qi, 1)
|
|
839
975
|
}
|
|
976
|
+
const bucket = outcome.hash === undefined ? undefined : cacheBucketOf(outcome)
|
|
977
|
+
if (bucket !== undefined) buckets.set(outcome.hash!, bucket)
|
|
840
978
|
switch (outcome.status) {
|
|
841
979
|
case 'success':
|
|
842
980
|
succeeded++
|
|
@@ -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 '../util/index.js'
|
|
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,32 @@ export function formatFailureLine(
|
|
|
327
361
|
)
|
|
328
362
|
}
|
|
329
363
|
|
|
364
|
+
/** A step's cache column, dim as `no-cache` is. */
|
|
365
|
+
const INTERNAL = 'internal'
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* The list row of one of vx's own steps (`vx:uploads`), on the task grid
|
|
369
|
+
* with `internal` in the cache column: a step is no task and caches
|
|
370
|
+
* nothing (M, 2026-10-16: `4.60s success internal vx:keep-run`).
|
|
371
|
+
*/
|
|
372
|
+
export function formatStepLine(
|
|
373
|
+
id: string,
|
|
374
|
+
durationMs: number,
|
|
375
|
+
ok: boolean,
|
|
376
|
+
colors: ColorSupport = NO_COLOR,
|
|
377
|
+
): string {
|
|
378
|
+
return formatTaskRow(
|
|
379
|
+
ok ? paint(SUCCESS, '\u23fa\uFE0E', colors) : paint(ERROR, '\u25fc\uFE0E', colors),
|
|
380
|
+
durationMs,
|
|
381
|
+
ok ? 'success' : 'failed',
|
|
382
|
+
ok ? SUCCESS : ERROR,
|
|
383
|
+
INTERNAL,
|
|
384
|
+
'',
|
|
385
|
+
paintPinnedId(id, colors),
|
|
386
|
+
colors,
|
|
387
|
+
)
|
|
388
|
+
}
|
|
389
|
+
|
|
330
390
|
/**
|
|
331
391
|
* Worker region: pinned ready persistent tasks, one row per worker
|
|
332
392
|
* slot — a task stays in its slot for its whole life so names never
|
|
@@ -363,12 +423,19 @@ export function formatStatusRegion(
|
|
|
363
423
|
// Worker rows: NO glyph — the live elapsed time leads (no spinner;
|
|
364
424
|
// the ticking time IS the motion), then `running`, full id (never
|
|
365
425
|
// truncated — name is the last column). Idle rows hold their slot's
|
|
366
|
-
// place, dim, aligned under the status column
|
|
426
|
+
// place, dim, aligned under the status column: the blocked task the row
|
|
427
|
+
// holds and what holds it back, else `idle`.
|
|
367
428
|
// One task running has no path to compare against.
|
|
368
429
|
const busy = s.slots.filter((x) => x !== null).length
|
|
369
|
-
for (const slot of s.slots) {
|
|
430
|
+
for (const [row, slot] of s.slots.entries()) {
|
|
370
431
|
if (slot === null) {
|
|
371
|
-
|
|
432
|
+
const w = s.blocked?.[row]
|
|
433
|
+
lines.push(
|
|
434
|
+
w == null
|
|
435
|
+
? `${' '.repeat(3 + TIME_COL + 1)}${paint(IDLE, 'idle', colors, { dim: true })}`
|
|
436
|
+
: formatTaskRow(' ', null, 'blocked', '', '', '', paintPinnedId(w.id, colors), colors) +
|
|
437
|
+
` ${dim(`by ${w.by}`)}`,
|
|
438
|
+
)
|
|
372
439
|
continue
|
|
373
440
|
}
|
|
374
441
|
// What waits on it, after the id: the row to watch is the one the
|
|
@@ -394,6 +461,20 @@ export function formatStatusRegion(
|
|
|
394
461
|
if (s.overflow > 0) {
|
|
395
462
|
lines.push(dim(`\u2026 +${s.overflow} more running`))
|
|
396
463
|
}
|
|
464
|
+
for (const step of s.steps ?? []) {
|
|
465
|
+
lines.push(
|
|
466
|
+
formatTaskRow(
|
|
467
|
+
' ',
|
|
468
|
+
Math.max(0, s.nowMs - step.startedMs),
|
|
469
|
+
'running',
|
|
470
|
+
ACCENT,
|
|
471
|
+
INTERNAL,
|
|
472
|
+
'',
|
|
473
|
+
paintPinnedId(step.id, colors),
|
|
474
|
+
colors,
|
|
475
|
+
),
|
|
476
|
+
)
|
|
477
|
+
}
|
|
397
478
|
// The live summary section — identical shape to the final printout,
|
|
398
479
|
// so the region visually BECOMES the summary when the run ends.
|
|
399
480
|
lines.push(...s.summaryLines)
|