@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
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// p50 in local history. A display aid for the live region only: it never
|
|
3
3
|
// steers the scheduler and never reaches the final summary.
|
|
4
4
|
|
|
5
|
-
import { isGroupTask, type TaskNode } from '../graph/index.js'
|
|
5
|
+
import { computeReverseDepCount, isGroupTask, type TaskNode } from '../graph/index.js'
|
|
6
6
|
|
|
7
7
|
/** What the live region says about the rest of a run. */
|
|
8
8
|
export interface LiveForecast {
|
|
@@ -14,6 +14,18 @@ export interface LiveForecast {
|
|
|
14
14
|
blocks: ReadonlyMap<string, number>
|
|
15
15
|
/** Each running task's p50, where history knows it: the row's expected duration. */
|
|
16
16
|
expected?: ReadonlyMap<string, number>
|
|
17
|
+
/**
|
|
18
|
+
* Tasks not started because a dependency has not finished, in the order
|
|
19
|
+
* the scheduler would start them, at most one per worker: what an idle
|
|
20
|
+
* worker row holds (M, 2026-10-15).
|
|
21
|
+
*/
|
|
22
|
+
blocked?: readonly BlockedTask[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A task an unfinished dependency holds back, and what holds it: the running task its longest chain waits on, else that dependency. */
|
|
26
|
+
export interface BlockedTask {
|
|
27
|
+
id: string
|
|
28
|
+
by: string
|
|
17
29
|
}
|
|
18
30
|
|
|
19
31
|
export type Forecast = (
|
|
@@ -68,6 +80,14 @@ export function createForecast(
|
|
|
68
80
|
if (p !== undefined) cost.set(n.id, p)
|
|
69
81
|
}
|
|
70
82
|
const workers = Math.max(1, concurrency)
|
|
83
|
+
// The scheduler's own baseline ranking, most dependents first, ties in
|
|
84
|
+
// graph order: once per run, as the scheduler computes it.
|
|
85
|
+
const rank = computeReverseDepCount(nodes)
|
|
86
|
+
const position = new Map(order.map((n, i) => [n.id, i]))
|
|
87
|
+
const before = (a: string, b: string): boolean => {
|
|
88
|
+
const d = (rank.get(a) ?? 0) - (rank.get(b) ?? 0)
|
|
89
|
+
return d !== 0 ? d > 0 : position.get(a)! < position.get(b)!
|
|
90
|
+
}
|
|
71
91
|
|
|
72
92
|
const dependents = new Map<string, string[]>()
|
|
73
93
|
for (const n of order)
|
|
@@ -106,6 +126,8 @@ export function createForecast(
|
|
|
106
126
|
let work = 0
|
|
107
127
|
let known = false
|
|
108
128
|
let overrun = false
|
|
129
|
+
// The best `workers` blocked tasks, kept sorted as the pass meets them.
|
|
130
|
+
const held: BlockedTask[] = []
|
|
109
131
|
for (const n of order) {
|
|
110
132
|
if (finished.has(n.id)) continue
|
|
111
133
|
let start = 0
|
|
@@ -120,6 +142,16 @@ export function createForecast(
|
|
|
120
142
|
const since = running.get(n.id)
|
|
121
143
|
if (since !== undefined) head.set(n.id, n.id)
|
|
122
144
|
else if (via !== undefined && head.has(via)) head.set(n.id, head.get(via)!)
|
|
145
|
+
const by =
|
|
146
|
+
since === undefined ? (head.get(n.id) ?? n.deps.find((d) => end.has(d))) : undefined
|
|
147
|
+
if (by !== undefined && !isGroupTask(n)) {
|
|
148
|
+
let at = held.length
|
|
149
|
+
while (at > 0 && before(n.id, held[at - 1]!.id)) at--
|
|
150
|
+
if (at < workers) {
|
|
151
|
+
held.splice(at, 0, { id: n.id, by })
|
|
152
|
+
if (held.length > workers) held.pop()
|
|
153
|
+
}
|
|
154
|
+
}
|
|
123
155
|
const p = cost.get(n.id)
|
|
124
156
|
let own = 0
|
|
125
157
|
if (p !== undefined) {
|
|
@@ -135,13 +167,14 @@ export function createForecast(
|
|
|
135
167
|
}
|
|
136
168
|
work += own
|
|
137
169
|
}
|
|
138
|
-
if (!known || overrun) return { blocks, expected }
|
|
170
|
+
if (!known || overrun) return { blocks, expected, blocked: held }
|
|
139
171
|
const critical = chainEnd === undefined ? undefined : head.get(chainEnd)
|
|
140
172
|
return {
|
|
141
173
|
etaMs: Math.max(chain, work / workers),
|
|
142
174
|
...(critical !== undefined ? { critical } : {}),
|
|
143
175
|
blocks,
|
|
144
176
|
expected,
|
|
177
|
+
blocked: held,
|
|
145
178
|
}
|
|
146
179
|
}
|
|
147
180
|
}
|
|
@@ -27,33 +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 {
|
|
31
|
-
|
|
30
|
+
import {
|
|
31
|
+
formatElapsed,
|
|
32
|
+
identityColor,
|
|
33
|
+
maskedCommand,
|
|
34
|
+
paint,
|
|
35
|
+
type ColorSupport,
|
|
36
|
+
} from '../util/index.js'
|
|
32
37
|
import { neverStarted } from './summary.js'
|
|
33
38
|
import { outcomeLabel, ranNoCache, skippedReason } from './events.js'
|
|
34
39
|
import { fileMemo, linkPaths } from './path-links.js'
|
|
35
|
-
import { sandboxFixLine } from './sandbox-fix.js'
|
|
36
40
|
|
|
37
41
|
const NO_COLOR: ColorSupport = { enabled: false }
|
|
38
42
|
|
|
39
43
|
const ACCENT = '#06b6d4' // cyan-500 — bullets, remote-hit hint
|
|
40
|
-
// Identity hues
|
|
41
|
-
// red / yellow / cyan) so a task id can never read as an outcome.
|
|
42
|
-
//
|
|
43
|
-
// run, list
|
|
44
|
-
// 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.
|
|
45
48
|
// Section rules + the summary rule share one frame width.
|
|
46
49
|
const FRAME_WIDTH = 60
|
|
47
50
|
|
|
48
|
-
const PROJECT_PALETTE = [
|
|
49
|
-
'#a78bfa', // violet-400
|
|
50
|
-
'#60a5fa', // blue-400
|
|
51
|
-
'#e879f9', // fuchsia-400
|
|
52
|
-
'#818cf8', // indigo-400
|
|
53
|
-
'#c084fc', // purple-400
|
|
54
|
-
'#93c5fd', // blue-300
|
|
55
|
-
] as const
|
|
56
|
-
const TASK = '#f472b6' // pink-400 — task part of an id
|
|
57
51
|
const SUCCESS = '#22c55e' // green-500 — success / fresh
|
|
58
52
|
const WARN = '#eab308' // yellow-500 — skipped
|
|
59
53
|
const ERROR = '#ef4444' // red-500 — failed
|
|
@@ -210,7 +204,8 @@ function pushOutputSection(
|
|
|
210
204
|
function sectionLine(title: string, color: string, colors: ColorSupport): string {
|
|
211
205
|
const corner = (s: string) => paint('', s, colors, { dim: true })
|
|
212
206
|
const label = paint(color, title, colors, { bold: true })
|
|
213
|
-
const
|
|
207
|
+
const width = Math.min(FRAME_WIDTH, colors.columns ?? FRAME_WIDTH)
|
|
208
|
+
const rule = corner('─'.repeat(Math.max(1, width - 4 - title.length)))
|
|
214
209
|
return `${corner('├─')} ${label} ${rule}`
|
|
215
210
|
}
|
|
216
211
|
|
|
@@ -223,18 +218,9 @@ function pushBodyLines(lines: string[], text: string): void {
|
|
|
223
218
|
for (const line of text.replace(/\n$/, '').split('\n')) lines.push(line)
|
|
224
219
|
}
|
|
225
220
|
|
|
226
|
-
/** Stable per-project hue: FNV-style fold of the name into the palette. */
|
|
227
|
-
function projectColor(projectName: string): string {
|
|
228
|
-
let h = 0
|
|
229
|
-
for (let i = 0; i < projectName.length; i++) {
|
|
230
|
-
h = (h * 31 + projectName.charCodeAt(i)) >>> 0
|
|
231
|
-
}
|
|
232
|
-
return PROJECT_PALETTE[h % PROJECT_PALETTE.length]!
|
|
233
|
-
}
|
|
234
|
-
|
|
235
221
|
/**
|
|
236
|
-
* `project#task` halves in
|
|
237
|
-
*
|
|
222
|
+
* `project#task` halves, each in its name's identity hue (never a status
|
|
223
|
+
* color); the dim separator keeps them reading apart. `hueSource`
|
|
238
224
|
* exists for truncated display strings: the hue must hash from the
|
|
239
225
|
* FULL project name so it survives truncation.
|
|
240
226
|
*/
|
|
@@ -243,13 +229,14 @@ export function paintIdParts(
|
|
|
243
229
|
projectText: string,
|
|
244
230
|
taskText: string,
|
|
245
231
|
colors: ColorSupport,
|
|
246
|
-
opts: { bold?: boolean } = {},
|
|
232
|
+
opts: { bold?: boolean; sep?: '#' | ':' } = {},
|
|
247
233
|
): string {
|
|
248
234
|
const dim = (t: string) => paint('', t, colors, { dim: true })
|
|
235
|
+
const { sep = '#', ...style } = opts
|
|
249
236
|
return (
|
|
250
|
-
paint(
|
|
251
|
-
dim(
|
|
252
|
-
paint(
|
|
237
|
+
paint(identityColor(hueSource), projectText, colors, style) +
|
|
238
|
+
dim(sep) +
|
|
239
|
+
paint(identityColor(taskText), taskText, colors, style)
|
|
253
240
|
)
|
|
254
241
|
}
|
|
255
242
|
|
|
@@ -554,8 +541,10 @@ function violationSection(outcome: TaskOutcome, colors: ColorSupport): string[]
|
|
|
554
541
|
: []),
|
|
555
542
|
'',
|
|
556
543
|
]
|
|
544
|
+
// vx's own notes (`vx: …`) explain a failure; they are not denials.
|
|
545
|
+
const denials = body.filter((l) => !l.startsWith('vx: ')).length
|
|
557
546
|
return [
|
|
558
|
-
sectionLine(`SANDBOX VIOLATIONS (${
|
|
547
|
+
sectionLine(denials > 0 ? `SANDBOX VIOLATIONS (${denials})` : 'SANDBOX', ERROR, colors),
|
|
559
548
|
'',
|
|
560
549
|
...body,
|
|
561
550
|
'',
|
|
@@ -705,3 +694,19 @@ function formatStatusTag(o: TaskOutcome, colors: ColorSupport): string {
|
|
|
705
694
|
return o.status
|
|
706
695
|
}
|
|
707
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'
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { TaskNode, TaskOutcome } from '../graph/index.js'
|
|
2
2
|
import { settingEnv } from '../settings.js'
|
|
3
|
-
import { detectColors, type ColorSupport } from './colors.js'
|
|
4
3
|
import {
|
|
5
4
|
formatFrameClose,
|
|
6
5
|
formatFrameOpen,
|
|
@@ -18,10 +17,18 @@ import {
|
|
|
18
17
|
createOutputWriter,
|
|
19
18
|
formatFailureLine,
|
|
20
19
|
formatStatusRegion,
|
|
20
|
+
formatStepLine,
|
|
21
21
|
type StatusStream,
|
|
22
22
|
type WorkerSlot,
|
|
23
23
|
} from './status-line.js'
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
cacheBucketOf,
|
|
26
|
+
formatSummarySection,
|
|
27
|
+
neverStarted,
|
|
28
|
+
ranUnsaved,
|
|
29
|
+
type CacheBucket,
|
|
30
|
+
type RunContext,
|
|
31
|
+
} from './summary.js'
|
|
25
32
|
import { isGroupTask } from '../graph/index.js'
|
|
26
33
|
import {
|
|
27
34
|
appendTail,
|
|
@@ -30,10 +37,12 @@ import {
|
|
|
30
37
|
resetTail,
|
|
31
38
|
tailText,
|
|
32
39
|
type Tail,
|
|
40
|
+
detectColors,
|
|
41
|
+
type ColorSupport,
|
|
33
42
|
} from '../util/index.js'
|
|
34
|
-
import { isCacheHit } from '
|
|
43
|
+
import { isCacheHit } from '../graph/index.js'
|
|
35
44
|
import { failedLabel, outcomeWord, ranNoCache } from './events.js'
|
|
36
|
-
import type { Forecast } from './forecast.js'
|
|
45
|
+
import type { BlockedTask, Forecast } from './forecast.js'
|
|
37
46
|
import {
|
|
38
47
|
notifyEscape,
|
|
39
48
|
PROGRESS_CLEAR,
|
|
@@ -45,6 +54,12 @@ import {
|
|
|
45
54
|
export interface Logger {
|
|
46
55
|
/** Header / footer / status text. Written verbatim, one trailing \n added. */
|
|
47
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
|
|
48
63
|
/** Streamed stdout chunk for a task. Buffered until `taskComplete`. */
|
|
49
64
|
taskStdout(node: TaskNode, chunk: string): void
|
|
50
65
|
/** Streamed stderr chunk for a task. Buffered until `taskComplete`. */
|
|
@@ -69,8 +84,21 @@ export interface Logger {
|
|
|
69
84
|
startedAtMs?: number
|
|
70
85
|
}): void
|
|
71
86
|
taskStart?(node: TaskNode): void
|
|
72
|
-
/**
|
|
87
|
+
/** The task graph finished (any outcome); vx's own closing steps follow. Idempotent. */
|
|
73
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)
|
|
74
102
|
}
|
|
75
103
|
|
|
76
104
|
/** The logger `run()` builds when none is passed. */
|
|
@@ -81,6 +109,12 @@ export interface DefaultLogger extends Logger {
|
|
|
81
109
|
* throw that never reached `runEnd`.
|
|
82
110
|
*/
|
|
83
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
|
|
84
118
|
/**
|
|
85
119
|
* Hand the live region a way to predict the run's end. Called once at
|
|
86
120
|
* run start; `load` runs only if the region is still live a second in,
|
|
@@ -191,6 +225,8 @@ export function resolveOutputView(
|
|
|
191
225
|
const FORECAST_AFTER_MS = 1000
|
|
192
226
|
/** A run shorter than this ends without a desktop notification. */
|
|
193
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
|
|
194
230
|
|
|
195
231
|
export function defaultLogger(
|
|
196
232
|
colors: ColorSupport = detectColors(),
|
|
@@ -206,9 +242,12 @@ export function defaultLogger(
|
|
|
206
242
|
footer?: boolean
|
|
207
243
|
/** The environment that names the terminal (`TERM_PROGRAM`, …); `process.env` by default. */
|
|
208
244
|
env?: Readonly<Record<string, string | undefined>>
|
|
245
|
+
/** Where warnings go; `process.stderr` by default. */
|
|
246
|
+
err?: StatusStream
|
|
209
247
|
} = {},
|
|
210
248
|
): DefaultLogger {
|
|
211
249
|
const forwardArgs = opts.forwardArgs ?? []
|
|
250
|
+
const errOut = opts.err ?? process.stderr
|
|
212
251
|
// Per-task buffers, split by stream. Splitting lets the framed-output
|
|
213
252
|
// renderer put stdout under `├─ stdout` and stderr under `├─ stderr`.
|
|
214
253
|
// The price: chunks that interleaved at runtime get re-ordered (all
|
|
@@ -302,6 +341,10 @@ export function defaultLogger(
|
|
|
302
341
|
let critical: string | undefined
|
|
303
342
|
let blocks: ReadonlyMap<string, number> | undefined
|
|
304
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)[] = []
|
|
305
348
|
let etaAtMs = 0
|
|
306
349
|
const signals = writer.enabled
|
|
307
350
|
? terminalSignals(opts.env ?? process.env)
|
|
@@ -329,8 +372,19 @@ export function defaultLogger(
|
|
|
329
372
|
let upToDate = 0
|
|
330
373
|
let restoredLocal = 0
|
|
331
374
|
let restoredRemote = 0
|
|
375
|
+
let saved = 0
|
|
376
|
+
let notSaved = 0
|
|
332
377
|
let skippedCount = 0
|
|
333
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>()
|
|
334
388
|
// Cache-miss duration spread, accumulated incrementally (the same
|
|
335
389
|
// numbers the final summary computes from the outcome list).
|
|
336
390
|
let spreadMax = 0
|
|
@@ -406,7 +460,7 @@ export function defaultLogger(
|
|
|
406
460
|
if (statusDead) return
|
|
407
461
|
const now = Date.now()
|
|
408
462
|
// Twice a second at most: one pass over the unfinished graph.
|
|
409
|
-
if (forecastFn !== undefined && (force || now - etaAtMs >= 500)) {
|
|
463
|
+
if (forecastFn !== undefined && !tail && (force || now - etaAtMs >= 500)) {
|
|
410
464
|
etaAtMs = now
|
|
411
465
|
const running = new Map<string, number>()
|
|
412
466
|
for (const s of slots) if (s !== null) running.set(s.id, s.startedMs)
|
|
@@ -416,6 +470,7 @@ export function defaultLogger(
|
|
|
416
470
|
critical = f.critical
|
|
417
471
|
blocks = f.blocks
|
|
418
472
|
expected = f.expected
|
|
473
|
+
blocked = f.blocked
|
|
419
474
|
}
|
|
420
475
|
if (signals.progress && total > 0) {
|
|
421
476
|
const elapsed = now - startedAtMs
|
|
@@ -434,7 +489,7 @@ export function defaultLogger(
|
|
|
434
489
|
// The live summary IS the final summary's section, built from the
|
|
435
490
|
// same formatter so the region visually becomes the printout.
|
|
436
491
|
const summaryLines =
|
|
437
|
-
opts.footer === false
|
|
492
|
+
opts.footer === false || !started
|
|
438
493
|
? []
|
|
439
494
|
: formatSummarySection(
|
|
440
495
|
{
|
|
@@ -445,11 +500,14 @@ export function defaultLogger(
|
|
|
445
500
|
upToDate,
|
|
446
501
|
restoredLocal,
|
|
447
502
|
restoredRemote,
|
|
503
|
+
saved,
|
|
504
|
+
notSaved,
|
|
448
505
|
miss: succeeded + failed - noCache,
|
|
449
506
|
noCache,
|
|
450
507
|
savedMs,
|
|
451
508
|
left: total - done,
|
|
452
|
-
...(etaMs !== undefined ? { etaMs } : {}),
|
|
509
|
+
...(etaMs !== undefined && !tail ? { etaMs } : {}),
|
|
510
|
+
buckets,
|
|
453
511
|
spread:
|
|
454
512
|
spreadCount > 0
|
|
455
513
|
? { maxMs: spreadMax, minMs: spreadMin, sumMs: spreadSum, count: spreadCount }
|
|
@@ -459,12 +517,18 @@ export function defaultLogger(
|
|
|
459
517
|
colors,
|
|
460
518
|
runContext,
|
|
461
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 })
|
|
462
524
|
writer.setRegion(
|
|
463
525
|
formatStatusRegion(
|
|
464
526
|
{
|
|
465
527
|
pinnedPersistent,
|
|
466
528
|
slots: slots.slice(0, shownSlots),
|
|
529
|
+
...(held !== undefined ? { blocked: held } : {}),
|
|
467
530
|
overflow: slotQueue.length,
|
|
531
|
+
...(shownSteps.length > 0 ? { steps: shownSteps } : {}),
|
|
468
532
|
nowMs: Date.now(),
|
|
469
533
|
summaryLines,
|
|
470
534
|
...(blocks !== undefined ? { blocks } : {}),
|
|
@@ -476,6 +540,46 @@ export function defaultLogger(
|
|
|
476
540
|
{ force },
|
|
477
541
|
)
|
|
478
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
|
+
}
|
|
479
583
|
const killStatus = (): void => {
|
|
480
584
|
if (ticker !== null) {
|
|
481
585
|
clearInterval(ticker)
|
|
@@ -499,7 +603,11 @@ export function defaultLogger(
|
|
|
499
603
|
// no task row (`status`). Without a footer, the caller frames the log
|
|
500
604
|
// (`vx watch` opens each cycle with its own line).
|
|
501
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
|
|
502
609
|
const taskWrite = (chunk: string): void => {
|
|
610
|
+
if (chunk.length > 0) endsBlank = chunk.endsWith('\n\n')
|
|
503
611
|
if (!logOpened && chunk.length > 0) {
|
|
504
612
|
logOpened = true
|
|
505
613
|
writer.write(`\n${chunk}`)
|
|
@@ -554,6 +662,7 @@ export function defaultLogger(
|
|
|
554
662
|
flakyNote(outcome, colors)
|
|
555
663
|
|
|
556
664
|
return {
|
|
665
|
+
showsSteps: writer.enabled,
|
|
557
666
|
settle() {
|
|
558
667
|
// The bus delivers one `run:end`, so the CLI's last word reaches here.
|
|
559
668
|
flushKept()
|
|
@@ -569,28 +678,46 @@ export function defaultLogger(
|
|
|
569
678
|
writer.write('\n')
|
|
570
679
|
streamMidLine = false
|
|
571
680
|
}
|
|
681
|
+
if (line === '' && endsBlank) return
|
|
682
|
+
endsBlank = line === ''
|
|
572
683
|
writer.write(`${line}\n`)
|
|
573
684
|
},
|
|
685
|
+
warn(line) {
|
|
686
|
+
flushKept()
|
|
687
|
+
if (streamMidLine) {
|
|
688
|
+
writer.write('\n')
|
|
689
|
+
streamMidLine = false
|
|
690
|
+
}
|
|
691
|
+
writer.writeTo(errOut, `${line}\n`)
|
|
692
|
+
},
|
|
574
693
|
runStart(info) {
|
|
575
694
|
total = info.total
|
|
576
695
|
requestedCount = info.requestedCount ?? requestedCount
|
|
577
696
|
runContext = info.context
|
|
578
697
|
startedAtMs = Date.now()
|
|
698
|
+
started = true
|
|
579
699
|
const cap = Math.max(1, Math.min(info.concurrency ?? 10, 10))
|
|
580
700
|
slots = Array.from({ length: cap }, () => null)
|
|
581
701
|
shownSlots = 0
|
|
582
|
-
|
|
583
|
-
// Keeps the per-worker elapsed time ticking between task events
|
|
584
|
-
// (the time IS the motion — there's no spinner). The writer
|
|
585
|
-
// throttles unforced redraws, and unref means a stray ticker
|
|
586
|
-
// can never hold the process open.
|
|
587
|
-
ticker = setInterval(() => {
|
|
588
|
-
refresh(false)
|
|
589
|
-
}, 100)
|
|
590
|
-
ticker.unref?.()
|
|
591
|
-
}
|
|
702
|
+
tick()
|
|
592
703
|
refresh(true)
|
|
593
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
|
+
},
|
|
594
721
|
taskStart(node) {
|
|
595
722
|
if (isGroupTask(node)) return
|
|
596
723
|
if (holdsTerminal(node)) {
|
|
@@ -614,7 +741,14 @@ export function defaultLogger(
|
|
|
614
741
|
persistentTails.set(node.id, { node, out: createTail(), err: createTail() })
|
|
615
742
|
}
|
|
616
743
|
const slot: WorkerSlot = { id: node.id, startedMs: Date.now() }
|
|
617
|
-
|
|
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)
|
|
618
752
|
if (free >= 0) {
|
|
619
753
|
slots[free] = slot
|
|
620
754
|
shownSlots = Math.max(shownSlots, free + 1)
|
|
@@ -640,7 +774,7 @@ export function defaultLogger(
|
|
|
640
774
|
runEnd() {
|
|
641
775
|
// A long run says it ended where the user is not looking. Once:
|
|
642
776
|
// the status is dead after the first runEnd.
|
|
643
|
-
if (signals.notify && !statusDead && Date.now() - startedAtMs >= NOTIFY_AFTER_MS) {
|
|
777
|
+
if (signals.notify && !statusDead && !tail && Date.now() - startedAtMs >= NOTIFY_AFTER_MS) {
|
|
644
778
|
out.write(
|
|
645
779
|
notifyEscape(
|
|
646
780
|
failed > 0
|
|
@@ -649,7 +783,14 @@ export function defaultLogger(
|
|
|
649
783
|
),
|
|
650
784
|
)
|
|
651
785
|
}
|
|
652
|
-
|
|
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()
|
|
653
794
|
// Persistent tails first: they are context for whatever ran against
|
|
654
795
|
// the server, so they read above the failures they explain. `none`
|
|
655
796
|
// prints no per-task output and `errors-only` only failed tasks: a
|
|
@@ -832,10 +973,14 @@ export function defaultLogger(
|
|
|
832
973
|
const qi = slotQueue.findIndex((s) => s.id === node.id)
|
|
833
974
|
if (qi >= 0) slotQueue.splice(qi, 1)
|
|
834
975
|
}
|
|
976
|
+
const bucket = outcome.hash === undefined ? undefined : cacheBucketOf(outcome)
|
|
977
|
+
if (bucket !== undefined) buckets.set(outcome.hash!, bucket)
|
|
835
978
|
switch (outcome.status) {
|
|
836
979
|
case 'success':
|
|
837
980
|
succeeded++
|
|
838
981
|
if (ranNoCache(outcome)) noCache++
|
|
982
|
+
if (outcome.saved === true) saved++
|
|
983
|
+
else if (ranUnsaved(outcome)) notSaved++
|
|
839
984
|
break
|
|
840
985
|
case 'cache-hit':
|
|
841
986
|
if (outcome.restored === false) upToDate++
|