@vzn/vx 0.0.640 → 0.0.642
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/launcher.sh +9 -2
- package/package.json +5 -12
- package/skills/vx/SKILL.md +4 -4
- package/src/bin.ts +8 -6
- package/src/cache/archive.ts +735 -107
- package/src/cache/cache.ts +133 -29
- package/src/cache/fold-memo.ts +58 -0
- package/src/cache/git-inputs.ts +213 -31
- package/src/cache/index.ts +9 -2
- package/src/cache/inputs.ts +244 -108
- package/src/cache/key-fold.ts +90 -69
- package/src/cache/layer.ts +9 -7
- package/src/cache/layered-cache.ts +74 -9
- package/src/cache/output-index.ts +25 -4
- package/src/cache/remote-lane.ts +27 -10
- package/src/cache/schema.ts +13 -3
- package/src/cache/scoped-cache.ts +11 -3
- package/src/cache/umask.ts +29 -0
- package/src/cli/docs.ts +71 -7
- package/src/cli/help.ts +11 -5
- package/src/cli/plan-format.ts +4 -1
- package/src/cli/run.ts +10 -3
- package/src/cli/select.ts +2 -0
- package/src/cli/upgrade.ts +37 -2
- package/src/cli/watch-filter.ts +1 -6
- package/src/cli/watch-fs.ts +2 -1
- package/src/exec/runner.ts +5 -0
- package/src/exec/sandbox-runtime.ts +81 -1
- package/src/graph/priorities.ts +14 -1
- package/src/handoff.ts +5 -0
- package/src/history/history.ts +43 -33
- package/src/index.ts +11 -1
- package/src/orchestrator/deferred-outputs.ts +3 -2
- package/src/orchestrator/execute-task.ts +20 -14
- package/src/orchestrator/hit-restore.ts +111 -29
- package/src/orchestrator/index.ts +1 -0
- package/src/orchestrator/local-shortcircuit.ts +8 -0
- package/src/orchestrator/lockfile-claim.ts +54 -8
- package/src/orchestrator/miss-save.ts +27 -17
- package/src/orchestrator/output-host.ts +9 -1
- package/src/orchestrator/plan.ts +4 -0
- package/src/orchestrator/plugin-host.ts +35 -5
- package/src/orchestrator/plugin.ts +29 -3
- package/src/orchestrator/prepare.ts +74 -5
- package/src/orchestrator/projects.ts +6 -1
- package/src/orchestrator/run-report.ts +30 -9
- package/src/orchestrator/run.ts +62 -38
- package/src/orchestrator/sandbox-request.ts +1 -48
- package/src/orchestrator/save-lane.ts +17 -10
- package/src/orchestrator/signals.ts +7 -10
- package/src/orchestrator/stable-keys.ts +83 -25
- package/src/orchestrator/task-hash.ts +46 -12
- package/src/report/events.ts +12 -0
- package/src/report/framed-output.ts +2 -1
- package/src/report/logger.ts +58 -15
- package/src/report/status-line.ts +45 -4
- package/src/report/summary.ts +11 -6
- package/src/util/errors.ts +1 -1
- package/src/util/hidden-prompt.ts +74 -0
- package/src/util/index.ts +13 -0
- package/src/util/paths.ts +58 -5
- package/src/util/secrets.ts +150 -0
- package/src/workspace/affected.ts +63 -13
- package/src/workspace/config-eval.ts +2 -2
- package/src/workspace/config-schema.ts +37 -3
- package/src/workspace/project-loader.ts +6 -4
- package/src/workspace/workspace.ts +17 -11
- package/docs/caching.md +0 -2352
- package/docs/cli.md +0 -3280
- package/docs/execution.md +0 -675
- package/docs/features.md +0 -178
- package/docs/patterns.md +0 -219
- package/docs/schema.md +0 -2461
- package/docs/security.md +0 -252
- package/src/cli/docs-corpus.ts +0 -21
- package/src/cli/docs-text.d.ts +0 -5
|
@@ -126,9 +126,11 @@ export function forwardSignals(args: {
|
|
|
126
126
|
const everyChild = (): Child[] => [...args.liveChildren, ...args.persistentRegistry.values()]
|
|
127
127
|
const exit = (signal: StopSignal): never => {
|
|
128
128
|
// The run's own end already flushed the log; a second signal cut it
|
|
129
|
-
// short, so the failure frames it holds print now (idempotent)
|
|
129
|
+
// short, so the failure frames it holds print now (idempotent), and
|
|
130
|
+
// the live region goes so a terminal keeps no frozen one.
|
|
130
131
|
try {
|
|
131
132
|
args.log.runEnd?.()
|
|
133
|
+
args.log.runDone?.()
|
|
132
134
|
} catch {
|
|
133
135
|
// teardown must not throw on the way out
|
|
134
136
|
}
|
|
@@ -158,15 +160,10 @@ export function forwardSignals(args: {
|
|
|
158
160
|
if (stopping !== undefined) exit(stopping)
|
|
159
161
|
stopping = signal
|
|
160
162
|
claimExitForSignal()
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
try {
|
|
166
|
-
args.log.runDone?.()
|
|
167
|
-
} catch {
|
|
168
|
-
// teardown must not throw on the way out
|
|
169
|
-
}
|
|
163
|
+
// The live region stays: the stop marks its rows `stopping` (run.ts's
|
|
164
|
+
// onStop), and the run's own end replaces it with the footer. Cleared
|
|
165
|
+
// at once, it left a blank screen for as long as the stop took (M,
|
|
166
|
+
// 2026-10-17).
|
|
170
167
|
args.stop(signal)
|
|
171
168
|
void settleWithin(args.done, args.boundMs)
|
|
172
169
|
// The run's output may still be in the pipe: `process.exit` drops
|
|
@@ -145,6 +145,11 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
147
|
const wsOutputUpstreamById = new Map<string, boolean>()
|
|
148
|
+
const upstreamByDeps = new Map<
|
|
149
|
+
string,
|
|
150
|
+
{ outputProjects: ProjectSet; wide: ProjectSet | undefined; wsOutputUpstream: boolean }
|
|
151
|
+
>()
|
|
152
|
+
const noProjects = projects.empty()
|
|
148
153
|
const stableKeys: StableKey[] = []
|
|
149
154
|
// Workspace-relative project dirs, for the reach test of a
|
|
150
155
|
// `workspaceFiles` reader against its upstream producers.
|
|
@@ -160,39 +165,59 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
160
165
|
const upstream = synthUpstream(node, args.nodes, keyById)
|
|
161
166
|
|
|
162
167
|
// Fold every dep's accumulated producers + the dep's own declared
|
|
163
|
-
// outputs into this node's transitive-upstream producer sets.
|
|
164
|
-
|
|
165
|
-
|
|
168
|
+
// outputs into this node's transitive-upstream producer sets. They
|
|
169
|
+
// depend on the deps alone, and at 5,000 projects a `^build` reader
|
|
170
|
+
// shares its list with its project's group: 61,600 unions of 157-word
|
|
171
|
+
// sets were 52 ms of a warm run, half of them repeats.
|
|
172
|
+
const depsKey = node.deps.length === 1 ? node.deps[0]! : node.deps.join('\0')
|
|
173
|
+
let up = upstreamByDeps.get(depsKey)
|
|
174
|
+
if (up === undefined) {
|
|
175
|
+
const outputs = new Union(projects)
|
|
176
|
+
const wideUnion = new Union(projects)
|
|
177
|
+
let wsOutputUpstream = false
|
|
178
|
+
for (const dep of node.deps) {
|
|
179
|
+
const depNode = args.nodes.get(dep)
|
|
180
|
+
if (!depNode) continue
|
|
181
|
+
const upstreamOfDep = outputProjectsById.get(dep)
|
|
182
|
+
if (upstreamOfDep !== undefined) outputs.addAll(upstreamOfDep)
|
|
183
|
+
const wideOfDep = wideById.get(dep)
|
|
184
|
+
if (wideOfDep !== undefined) wideUnion.addAll(wideOfDep)
|
|
185
|
+
if (wsOutputUpstreamById.get(dep) === true) wsOutputUpstream = true
|
|
186
|
+
const depOut = depNode.config.cache?.outputs
|
|
187
|
+
if ((depOut?.files?.length ?? 0) > 0) outputs.add(depNode.projectName)
|
|
188
|
+
if ((depOut?.workspaceFiles?.length ?? 0) > 0) wsOutputUpstream = true
|
|
189
|
+
// An uncached producer declares no outputs, yet a same-project reader
|
|
190
|
+
// after it (`tasks: []` decouples the key, not the order) read the
|
|
191
|
+
// bytes it wrote: its key taken up front was the pre-producer one, and
|
|
192
|
+
// the fourth run of seeds A,B,B,A replayed B over A (turborepo#13788).
|
|
193
|
+
const reach = undeclaredWriteReach(depNode, args.workspaceRoot)
|
|
194
|
+
if (reach === 'project') {
|
|
195
|
+
outputs.add(depNode.projectName)
|
|
196
|
+
wideUnion.add(depNode.projectName)
|
|
197
|
+
} else if (reach === 'workspace') wsOutputUpstream = true
|
|
198
|
+
if (
|
|
199
|
+
depNode.config.cache === undefined &&
|
|
200
|
+
mayWriteFingerprint(depNode, args.workspaceRoot, args.claimedFiles)
|
|
201
|
+
)
|
|
202
|
+
wsOutputUpstream = true
|
|
203
|
+
}
|
|
204
|
+
up = {
|
|
205
|
+
outputProjects: outputs.result() ?? noProjects,
|
|
206
|
+
wide: wideUnion.result(),
|
|
207
|
+
wsOutputUpstream,
|
|
208
|
+
}
|
|
209
|
+
upstreamByDeps.set(depsKey, up)
|
|
210
|
+
}
|
|
211
|
+
const { outputProjects, wide, wsOutputUpstream } = up
|
|
166
212
|
let rewriters: ProjectSet | undefined
|
|
167
213
|
let unfolded: ProjectSet | undefined
|
|
168
214
|
let folds: ((dep: string) => boolean) | undefined
|
|
169
215
|
let wsRewriter = false
|
|
170
216
|
let wsUnfoldedHere = false
|
|
171
|
-
|
|
172
|
-
for (const dep of node.deps) {
|
|
217
|
+
for (const dep of anyUnfolded ? node.deps : []) {
|
|
173
218
|
const depNode = args.nodes.get(dep)
|
|
174
219
|
if (!depNode) continue
|
|
175
|
-
const upstreamOfDep = outputProjectsById.get(dep)
|
|
176
|
-
if (upstreamOfDep !== undefined) outputProjects.addAll(upstreamOfDep)
|
|
177
|
-
const wideOfDep = wideById.get(dep)
|
|
178
|
-
if (wideOfDep !== undefined) (wide ??= projects.empty()).addAll(wideOfDep)
|
|
179
|
-
if (wsOutputUpstreamById.get(dep) === true) wsOutputUpstream = true
|
|
180
|
-
const depOut = depNode.config.cache?.outputs
|
|
181
|
-
if ((depOut?.files?.length ?? 0) > 0) outputProjects.add(depNode.projectName)
|
|
182
|
-
if ((depOut?.workspaceFiles?.length ?? 0) > 0) wsOutputUpstream = true
|
|
183
|
-
// An uncached producer declares no outputs, yet a same-project reader
|
|
184
|
-
// after it (`tasks: []` decouples the key, not the order) read the
|
|
185
|
-
// bytes it wrote: its key taken up front was the pre-producer one, and
|
|
186
|
-
// the fourth run of seeds A,B,B,A replayed B over A (turborepo#13788).
|
|
187
|
-
const reach = undeclaredWriteReach(depNode, args.workspaceRoot)
|
|
188
|
-
if (reach === 'project') {
|
|
189
|
-
outputProjects.add(depNode.projectName)
|
|
190
|
-
;(wide ??= projects.empty()).add(depNode.projectName)
|
|
191
|
-
} else if (reach === 'workspace') wsOutputUpstream = true
|
|
192
220
|
const cached = depNode.config.cache !== undefined
|
|
193
|
-
if (!cached && mayWriteFingerprint(depNode, args.workspaceRoot, args.claimedFiles))
|
|
194
|
-
wsOutputUpstream = true
|
|
195
|
-
if (!anyUnfolded) continue
|
|
196
221
|
// The cached twin: a formatter rewriting its own input ahead of a
|
|
197
222
|
// reader that folds no key of it replayed seed B's build under seed A
|
|
198
223
|
// on the fourth run, the same way (item 750).
|
|
@@ -732,6 +757,10 @@ class ProjectSet implements ProjectNames {
|
|
|
732
757
|
for (let w = 0; w < this.bits.length; w++) this.bits[w]! |= other.bits[w]!
|
|
733
758
|
}
|
|
734
759
|
|
|
760
|
+
copy(): ProjectSet {
|
|
761
|
+
return new ProjectSet(this.index, this.bits.slice())
|
|
762
|
+
}
|
|
763
|
+
|
|
735
764
|
or(other: ProjectSet): ProjectSet {
|
|
736
765
|
const bits = this.bits.slice()
|
|
737
766
|
for (let w = 0; w < bits.length; w++) bits[w]! |= other.bits[w]!
|
|
@@ -762,6 +791,35 @@ class ProjectSet implements ProjectNames {
|
|
|
762
791
|
}
|
|
763
792
|
}
|
|
764
793
|
|
|
794
|
+
/** A union that shares its first set until another one would change it. */
|
|
795
|
+
class Union {
|
|
796
|
+
private set: ProjectSet | undefined
|
|
797
|
+
private owned = false
|
|
798
|
+
|
|
799
|
+
constructor(private readonly index: ProjectIndex) {}
|
|
800
|
+
|
|
801
|
+
addAll(other: ProjectSet): void {
|
|
802
|
+
if (this.set === undefined) this.set = other
|
|
803
|
+
else if (this.set !== other) this.own().addAll(other)
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
add(name: string): void {
|
|
807
|
+
if (this.set?.has(name) !== true) this.own().add(name)
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
result(): ProjectSet | undefined {
|
|
811
|
+
return this.set
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
private own(): ProjectSet {
|
|
815
|
+
if (!this.owned) {
|
|
816
|
+
this.set = this.set === undefined ? this.index.empty() : this.set.copy()
|
|
817
|
+
this.owned = true
|
|
818
|
+
}
|
|
819
|
+
return this.set!
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
765
823
|
function topoOrder(nodes: ReadonlyMap<string, TaskNode>): string[] {
|
|
766
824
|
const indegree = new Map<string, number>()
|
|
767
825
|
const dependents = new Map<string, string[]>()
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import { lstatSync } from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import type { TaskConfig } from '../config.js'
|
|
4
|
-
import type { ProjectFilesCache, WorkspaceFilesCache } from '../cache/index.js'
|
|
4
|
+
import type { FoldMemo, ProjectFilesCache, WorkspaceFilesCache } from '../cache/index.js'
|
|
5
5
|
import {
|
|
6
6
|
ABSENT_INPUT,
|
|
7
7
|
absentOr,
|
|
8
8
|
addedInput,
|
|
9
|
+
filesToken,
|
|
10
|
+
foldHead,
|
|
9
11
|
type InputListing,
|
|
10
12
|
type CacheKeyInput,
|
|
11
13
|
type CacheLayer,
|
|
12
14
|
FILE_HASH_RACY_MS,
|
|
13
15
|
racyWindowMs,
|
|
14
16
|
resolveInputs,
|
|
17
|
+
type ResolveInputsArgs,
|
|
15
18
|
type GitFilesCache,
|
|
16
19
|
type TaskInputComponent,
|
|
17
20
|
WORKSPACE_OUTPUT_PREFIX,
|
|
@@ -41,15 +44,18 @@ export interface HashCache {
|
|
|
41
44
|
workspaceRuntime: Map<string, Promise<string>>
|
|
42
45
|
workspaceFiles: WorkspaceFilesCache
|
|
43
46
|
projectFiles: ProjectFilesCache
|
|
47
|
+
/** Each task's last key by its probe, across runs (the local cache's `FoldMemo`). */
|
|
48
|
+
folds?: FoldMemo | undefined
|
|
44
49
|
}
|
|
45
50
|
|
|
46
|
-
export function createHashCache(): HashCache {
|
|
51
|
+
export function createHashCache(folds?: FoldMemo): HashCache {
|
|
47
52
|
return {
|
|
48
53
|
taskConfig: new WeakMap(),
|
|
49
54
|
runtime: new Map(),
|
|
50
55
|
workspaceRuntime: new Map(),
|
|
51
56
|
workspaceFiles: new Map(),
|
|
52
57
|
projectFiles: new Map(),
|
|
58
|
+
folds,
|
|
53
59
|
}
|
|
54
60
|
}
|
|
55
61
|
|
|
@@ -93,12 +99,31 @@ export async function computeTaskHash(args: ComputeHashArgs): Promise<string> {
|
|
|
93
99
|
if (isKeyless(args.node.config)) return keylessHash(args.node.id)
|
|
94
100
|
const end = span('task hash')
|
|
95
101
|
try {
|
|
96
|
-
|
|
102
|
+
const folds = args.captureInto === undefined ? args.hashCache?.folds : undefined
|
|
103
|
+
const token = folds === undefined ? undefined : filesToken(inputsArgs(args))
|
|
104
|
+
if (folds === undefined || token === undefined) {
|
|
105
|
+
return await args.cache.key((await resolveKeyInput(args)).input)
|
|
106
|
+
}
|
|
107
|
+
// The files fold last, so a fold up to them seeded into their token
|
|
108
|
+
// names the key the whole fold would: the files go unresolved.
|
|
109
|
+
const head = foldHead((await resolveKeyInput(args, true)).input)
|
|
110
|
+
const probe = xxh3hex(`${FOLD_MEMO_VERSION}\0${token}`, head)
|
|
111
|
+
const known = folds.get(args.node.id, probe)
|
|
112
|
+
if (known !== undefined) return known
|
|
113
|
+
const key = await args.cache.key((await resolveKeyInput(args)).input)
|
|
114
|
+
folds.set(args.node.id, probe, key)
|
|
115
|
+
return key
|
|
97
116
|
} finally {
|
|
98
117
|
end()
|
|
99
118
|
}
|
|
100
119
|
}
|
|
101
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Moves every remembered fold: bump it when what a token stands for
|
|
123
|
+
* changes, as when `resolveFiles` picks other files from the same records.
|
|
124
|
+
*/
|
|
125
|
+
const FOLD_MEMO_VERSION = 'fold-memo-v1'
|
|
126
|
+
|
|
102
127
|
/**
|
|
103
128
|
* A task that runs a command and declares no `inputs`: nothing says what
|
|
104
129
|
* it read, so no key can stand for its outputs, and a key that folds it
|
|
@@ -268,19 +293,16 @@ export async function movedInput(
|
|
|
268
293
|
return undefined
|
|
269
294
|
}
|
|
270
295
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
): Promise<{ input: CacheKeyInput; listings: InputListing[] }> {
|
|
296
|
+
/** What `resolveInputs` is asked for a task's key. */
|
|
297
|
+
function inputsArgs(args: ComputeHashArgs): ResolveInputsArgs {
|
|
274
298
|
const cfg = args.node.config
|
|
275
299
|
const cacheCfg = cfg.cache
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
const resolved = await resolveInputs({
|
|
300
|
+
return {
|
|
279
301
|
projectDir: args.node.projectDir,
|
|
280
302
|
workspaceRoot: args.workspaceRoot,
|
|
281
303
|
envSource: process.env,
|
|
282
304
|
inputs: cfg.inputs,
|
|
283
|
-
ownOutputs: outputs,
|
|
305
|
+
ownOutputs: cacheCfg?.outputs.files ?? [],
|
|
284
306
|
ownWorkspaceOutputs: cacheCfg?.outputs.workspaceFiles ?? [],
|
|
285
307
|
nestedProjectDirs: args.nestedProjectDirs,
|
|
286
308
|
...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
|
|
@@ -293,6 +315,17 @@ async function resolveKeyInput(
|
|
|
293
315
|
projectFilesCache: args.hashCache.projectFiles,
|
|
294
316
|
}
|
|
295
317
|
: {}),
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function resolveKeyInput(
|
|
322
|
+
args: ComputeHashArgs,
|
|
323
|
+
skipFiles = false,
|
|
324
|
+
): Promise<{ input: CacheKeyInput; listings: InputListing[] }> {
|
|
325
|
+
const cfg = args.node.config
|
|
326
|
+
const resolved = await resolveInputs({
|
|
327
|
+
...inputsArgs(args),
|
|
328
|
+
...(skipFiles ? { skipFiles } : {}),
|
|
296
329
|
}).catch((err: unknown) => {
|
|
297
330
|
// A failed probe's message quotes its command and output, and the probe
|
|
298
331
|
// runs in vx's own environment: `echo $NPM_TOKEN` printed the token in
|
|
@@ -327,8 +360,9 @@ async function resolveKeyInput(
|
|
|
327
360
|
// in-process blob-OID computation. Tasks declaring workspaceFiles
|
|
328
361
|
// also merge the workspace-wide partition's OIDs (keyed by abs
|
|
329
362
|
// path, so the two maps agree wherever they overlap).
|
|
330
|
-
|
|
331
|
-
|
|
363
|
+
// A fold that stops short of the files reads no OID: asking built the map.
|
|
364
|
+
let fileHashes = skipFiles ? undefined : args.gitFilesCache?.oidsFor(args.node.projectDir)
|
|
365
|
+
if (!skipFiles && (cfg.inputs?.workspaceFiles?.length ?? 0) > 0) {
|
|
332
366
|
const wsOids = args.gitFilesCache?.oidsFor(args.workspaceRoot)
|
|
333
367
|
if (wsOids !== undefined && wsOids !== fileHashes) {
|
|
334
368
|
fileHashes = fileHashes === undefined ? wsOids : new Map([...wsOids, ...fileHashes])
|
package/src/report/events.ts
CHANGED
|
@@ -55,6 +55,7 @@ export type RunEvent =
|
|
|
55
55
|
| { kind: 'step:start'; id: string }
|
|
56
56
|
| { kind: 'step:end'; id: string; durationMs: number; ok: boolean }
|
|
57
57
|
| { kind: 'run:end' }
|
|
58
|
+
| { kind: 'run:stop' }
|
|
58
59
|
| { kind: 'run:done' }
|
|
59
60
|
|
|
60
61
|
export type RunEventSubscriber = (event: RunEvent) => void
|
|
@@ -117,6 +118,7 @@ export function busLogger(bus: EventBus): Logger {
|
|
|
117
118
|
// run() reaches runEnd on its success path, in its finally and from a
|
|
118
119
|
// signal; a plugin's onRunEnd hears the first only.
|
|
119
120
|
let ended = false
|
|
121
|
+
let stopped = false
|
|
120
122
|
let done = false
|
|
121
123
|
// A status line carries plugin warnings, and a remote layer's warning
|
|
122
124
|
// quotes the server's reply, which may echo what it was sent (L-40).
|
|
@@ -136,6 +138,11 @@ export function busLogger(bus: EventBus): Logger {
|
|
|
136
138
|
},
|
|
137
139
|
stepStart: (id) => bus.emit({ kind: 'step:start', id }),
|
|
138
140
|
stepEnd: (id, info) => bus.emit({ kind: 'step:end', id, ...info }),
|
|
141
|
+
runStop: () => {
|
|
142
|
+
if (stopped) return
|
|
143
|
+
stopped = true
|
|
144
|
+
bus.emit({ kind: 'run:stop' })
|
|
145
|
+
},
|
|
139
146
|
runDone: () => {
|
|
140
147
|
if (done) return
|
|
141
148
|
done = true
|
|
@@ -182,6 +189,9 @@ export function terminalSubscriber(sink: Logger): RunEventSubscriber {
|
|
|
182
189
|
case 'run:end':
|
|
183
190
|
sink.runEnd?.()
|
|
184
191
|
return
|
|
192
|
+
case 'run:stop':
|
|
193
|
+
sink.runStop?.()
|
|
194
|
+
return
|
|
185
195
|
case 'run:done':
|
|
186
196
|
sink.runDone?.()
|
|
187
197
|
return
|
|
@@ -467,6 +477,7 @@ export type WireEvent =
|
|
|
467
477
|
| { kind: 'step:start'; id: string }
|
|
468
478
|
| { kind: 'step:end'; id: string; durationMs: number; ok: boolean }
|
|
469
479
|
| { kind: 'run:end' }
|
|
480
|
+
| { kind: 'run:stop' }
|
|
470
481
|
| { kind: 'run:done' }
|
|
471
482
|
|
|
472
483
|
/** Map an in-process event to its serializable wire form. */
|
|
@@ -489,6 +500,7 @@ export function toWireEvent(event: RunEvent): WireEvent {
|
|
|
489
500
|
case 'step:start':
|
|
490
501
|
case 'step:end':
|
|
491
502
|
case 'run:end':
|
|
503
|
+
case 'run:stop':
|
|
492
504
|
case 'run:done':
|
|
493
505
|
return event
|
|
494
506
|
}
|
|
@@ -357,7 +357,8 @@ export function formatTaskRow(
|
|
|
357
357
|
colors: ColorSupport = NO_COLOR,
|
|
358
358
|
): string {
|
|
359
359
|
const st = cell(status, statusColor, STATUS_COL, colors)
|
|
360
|
-
|
|
360
|
+
// `aborting` runs one into the cache cell, which is blank beside it.
|
|
361
|
+
const ca = cell(cache, cacheColor, CACHE_COL - Math.max(0, status.length - STATUS_COL), colors)
|
|
361
362
|
return ` ${glyph} ${timeCell(ms, colors)} ${st} ${ca} ${paintedId}`
|
|
362
363
|
}
|
|
363
364
|
|
package/src/report/logger.ts
CHANGED
|
@@ -92,6 +92,8 @@ export interface Logger {
|
|
|
92
92
|
*/
|
|
93
93
|
stepStart?(id: string): void
|
|
94
94
|
stepEnd?(id: string, info: { durationMs: number; ok: boolean }): void
|
|
95
|
+
/** The run was stopped (a signal, an embedder's abort); its tasks are going down. Idempotent. */
|
|
96
|
+
runStop?(): void
|
|
95
97
|
/** The run's work is done; the footer follows. Idempotent. */
|
|
96
98
|
runDone?(): void
|
|
97
99
|
}
|
|
@@ -116,11 +118,15 @@ export interface DefaultLogger extends Logger {
|
|
|
116
118
|
*/
|
|
117
119
|
readonly showsSteps: boolean
|
|
118
120
|
/**
|
|
119
|
-
* Hand the live region
|
|
120
|
-
*
|
|
121
|
-
*
|
|
121
|
+
* Hand the live region the run's forecast. Called once at run start:
|
|
122
|
+
* `graph` (the graph alone: what is blocked, and by what) is built the
|
|
123
|
+
* first time a worker row is free; `load` adds history's timings, and
|
|
124
|
+
* runs only if the region is still live a second in, so a run that ends
|
|
125
|
+
* sooner never reads history for it.
|
|
122
126
|
*/
|
|
123
|
-
forecast(load: () => Promise<Forecast>): void
|
|
127
|
+
forecast(graph: () => Forecast, load: () => Promise<Forecast>): void
|
|
128
|
+
/** Draw no live region until the returned release: a prompt waits on the terminal. */
|
|
129
|
+
holdStatus(): () => void
|
|
124
130
|
}
|
|
125
131
|
|
|
126
132
|
/**
|
|
@@ -336,6 +342,7 @@ export function defaultLogger(
|
|
|
336
342
|
// set is kept only where a region can show the answer.
|
|
337
343
|
const finished = new Set<string>()
|
|
338
344
|
let forecastFn: Forecast | undefined
|
|
345
|
+
let graphForecast: (() => Forecast) | undefined
|
|
339
346
|
let forecastTimer: ReturnType<typeof setTimeout> | null = null
|
|
340
347
|
let etaMs: number | undefined
|
|
341
348
|
let critical: string | undefined
|
|
@@ -381,6 +388,12 @@ export function defaultLogger(
|
|
|
381
388
|
// The graph is done and vx's own closing steps run: the region stays,
|
|
382
389
|
// footer and step rows, until the footer replaces it (M, 2026-10-15).
|
|
383
390
|
let tail = false
|
|
391
|
+
// The run was stopped: the region stays, its running rows say
|
|
392
|
+
// `aborting`, and nothing is forecast or held (M, 2026-10-17).
|
|
393
|
+
let stopping = false
|
|
394
|
+
// Counted live once the run is stopping, as the footer counts them.
|
|
395
|
+
let stopped = 0
|
|
396
|
+
let notRun = 0
|
|
384
397
|
// No footer before the run starts: vx's opening steps show alone.
|
|
385
398
|
let started = false
|
|
386
399
|
// vx's own steps running now, by id: a row once one passes STEP_SHOWN_MS.
|
|
@@ -456,11 +469,22 @@ export function defaultLogger(
|
|
|
456
469
|
const printsKept = (tail: { outcome?: TaskOutcome }): boolean =>
|
|
457
470
|
flushedPersistent && tail.outcome !== undefined && view.mode !== 'errors-only'
|
|
458
471
|
|
|
459
|
-
const refresh = (
|
|
472
|
+
const refresh = (forced: boolean): void => {
|
|
460
473
|
if (statusDead) return
|
|
474
|
+
let force = forced
|
|
461
475
|
const now = Date.now()
|
|
476
|
+
// A free row shows what it holds from the first frame: the graph
|
|
477
|
+
// knows, without history (M, 2026-10-17).
|
|
478
|
+
if (forecastFn === undefined && graphForecast !== undefined && !tail && !stopping)
|
|
479
|
+
for (let i = 0; i < shownSlots; i++)
|
|
480
|
+
if (slots[i] === null) {
|
|
481
|
+
forecastFn = graphForecast()
|
|
482
|
+
graphForecast = undefined
|
|
483
|
+
force = true
|
|
484
|
+
break
|
|
485
|
+
}
|
|
462
486
|
// Twice a second at most: one pass over the unfinished graph.
|
|
463
|
-
if (forecastFn !== undefined && !tail && (force || now - etaAtMs >= 500)) {
|
|
487
|
+
if (forecastFn !== undefined && !tail && !stopping && (force || now - etaAtMs >= 500)) {
|
|
464
488
|
etaAtMs = now
|
|
465
489
|
const running = new Map<string, number>()
|
|
466
490
|
for (const s of slots) if (s !== null) running.set(s.id, s.startedMs)
|
|
@@ -496,7 +520,9 @@ export function defaultLogger(
|
|
|
496
520
|
failed,
|
|
497
521
|
successful: succeeded + upToDate + restoredLocal + restoredRemote,
|
|
498
522
|
skipped: skippedCount,
|
|
499
|
-
total,
|
|
523
|
+
total: total - stopped - notRun,
|
|
524
|
+
...(stopped > 0 ? { aborted: stopped } : {}),
|
|
525
|
+
...(notRun > 0 ? { notRun } : {}),
|
|
500
526
|
upToDate,
|
|
501
527
|
restoredLocal,
|
|
502
528
|
restoredRemote,
|
|
@@ -505,8 +531,8 @@ export function defaultLogger(
|
|
|
505
531
|
miss: succeeded + failed - noCache,
|
|
506
532
|
noCache,
|
|
507
533
|
savedMs,
|
|
508
|
-
left: total - done,
|
|
509
|
-
...(etaMs !== undefined && !tail ? { etaMs } : {}),
|
|
534
|
+
left: total - done - stopped - notRun,
|
|
535
|
+
...(etaMs !== undefined && !tail && !stopping ? { etaMs } : {}),
|
|
510
536
|
buckets,
|
|
511
537
|
spread:
|
|
512
538
|
spreadCount > 0
|
|
@@ -517,7 +543,7 @@ export function defaultLogger(
|
|
|
517
543
|
colors,
|
|
518
544
|
runContext,
|
|
519
545
|
)
|
|
520
|
-
const held = blocked === undefined ? undefined : holdRows(blocked)
|
|
546
|
+
const held = blocked === undefined || stopping ? undefined : holdRows(blocked)
|
|
521
547
|
const shownSteps: WorkerSlot[] = []
|
|
522
548
|
for (const [id, startedMs] of steps)
|
|
523
549
|
if (now - startedMs >= STEP_SHOWN_MS) shownSteps.push({ id, startedMs })
|
|
@@ -528,6 +554,7 @@ export function defaultLogger(
|
|
|
528
554
|
slots: slots.slice(0, shownSlots),
|
|
529
555
|
...(held !== undefined ? { blocked: held } : {}),
|
|
530
556
|
overflow: slotQueue.length,
|
|
557
|
+
...(stopping ? { stopping } : {}),
|
|
531
558
|
...(shownSteps.length > 0 ? { steps: shownSteps } : {}),
|
|
532
559
|
nowMs: Date.now(),
|
|
533
560
|
summaryLines,
|
|
@@ -714,6 +741,13 @@ export function defaultLogger(
|
|
|
714
741
|
emitLine(formatStepLine(id, durationMs, ok, colors))
|
|
715
742
|
refresh(true)
|
|
716
743
|
},
|
|
744
|
+
runStop() {
|
|
745
|
+
if (stopping) return
|
|
746
|
+
stopping = true
|
|
747
|
+
blocked = undefined
|
|
748
|
+
heldRows.length = 0
|
|
749
|
+
refresh(true)
|
|
750
|
+
},
|
|
717
751
|
runDone() {
|
|
718
752
|
tail = false
|
|
719
753
|
killStatus()
|
|
@@ -755,8 +789,10 @@ export function defaultLogger(
|
|
|
755
789
|
} else slotQueue.push(slot)
|
|
756
790
|
refresh(true)
|
|
757
791
|
},
|
|
758
|
-
|
|
792
|
+
holdStatus: () => writer.hold(),
|
|
793
|
+
forecast(graph, load) {
|
|
759
794
|
if (statusDead || forecastTimer !== null) return
|
|
795
|
+
graphForecast = graph
|
|
760
796
|
forecastTimer = setTimeout(() => {
|
|
761
797
|
forecastTimer = null
|
|
762
798
|
if (statusDead) return
|
|
@@ -898,9 +934,13 @@ export function defaultLogger(
|
|
|
898
934
|
// a ready one starts a fresh post-ready tail below.
|
|
899
935
|
persistentTails.delete(node.id)
|
|
900
936
|
// An aborted task (killed by a shutdown signal, or reached by the stop
|
|
901
|
-
// before it ran) frees its worker slot
|
|
902
|
-
//
|
|
937
|
+
// before it ran) frees its worker slot and counts as the footer will
|
|
938
|
+
// count it: `aborted` or `not run`, beside no total.
|
|
903
939
|
if (outcome.status === 'aborted') {
|
|
940
|
+
if (!isGroupTask(node)) {
|
|
941
|
+
if (neverStarted(outcome)) notRun++
|
|
942
|
+
else stopped++
|
|
943
|
+
}
|
|
904
944
|
const si = slots.findIndex((s) => s !== null && s.id === node.id)
|
|
905
945
|
if (si >= 0) slots[si] = slotQueue.shift() ?? null
|
|
906
946
|
else {
|
|
@@ -973,8 +1013,11 @@ export function defaultLogger(
|
|
|
973
1013
|
const qi = slotQueue.findIndex((s) => s.id === node.id)
|
|
974
1014
|
if (qi >= 0) slotQueue.splice(qi, 1)
|
|
975
1015
|
}
|
|
976
|
-
const bucket =
|
|
977
|
-
if (bucket !== undefined)
|
|
1016
|
+
const bucket = cacheBucketOf(outcome)
|
|
1017
|
+
if (bucket !== undefined) {
|
|
1018
|
+
if (outcome.hash !== undefined) buckets.set(outcome.hash, bucket)
|
|
1019
|
+
buckets.set(node.id, bucket)
|
|
1020
|
+
}
|
|
978
1021
|
switch (outcome.status) {
|
|
979
1022
|
case 'success':
|
|
980
1023
|
succeeded++
|
|
@@ -56,6 +56,11 @@ export interface OutputWriter {
|
|
|
56
56
|
settle(): void
|
|
57
57
|
/** Permanently remove the status display; later set* is a no-op. */
|
|
58
58
|
clearStatus(): void
|
|
59
|
+
/**
|
|
60
|
+
* Erase the status display and draw none until the returned release:
|
|
61
|
+
* a prompt on the terminal (a secret's) waits for its answer there.
|
|
62
|
+
*/
|
|
63
|
+
hold(): () => void
|
|
59
64
|
}
|
|
60
65
|
|
|
61
66
|
export interface OutputWriterOptions {
|
|
@@ -108,6 +113,7 @@ export function createOutputWriter(
|
|
|
108
113
|
// `regionRows`.
|
|
109
114
|
let shownRows = 0
|
|
110
115
|
let dead = false
|
|
116
|
+
let holds = 0
|
|
111
117
|
let lastDraw = -Infinity
|
|
112
118
|
// Trailing draw scheduled when a forced set lands inside the floor.
|
|
113
119
|
let trailing: ReturnType<typeof setTimeout> | null = null
|
|
@@ -196,7 +202,7 @@ export function createOutputWriter(
|
|
|
196
202
|
const set = (lines: readonly string[], o: { force?: boolean }): void => {
|
|
197
203
|
if (!enabled || dead) return
|
|
198
204
|
current = lines
|
|
199
|
-
if (!atLineStart) return
|
|
205
|
+
if (!atLineStart || holds > 0) return
|
|
200
206
|
const since = now() - lastDraw
|
|
201
207
|
if (o.force) {
|
|
202
208
|
if (since >= forceFloorMs) {
|
|
@@ -209,7 +215,7 @@ export function createOutputWriter(
|
|
|
209
215
|
if (trailing === null) {
|
|
210
216
|
trailing = setTimeout(() => {
|
|
211
217
|
trailing = null
|
|
212
|
-
if (dead || current === null || !atLineStart) return
|
|
218
|
+
if (dead || current === null || !atLineStart || holds > 0) return
|
|
213
219
|
draw()
|
|
214
220
|
}, forceFloorMs - since)
|
|
215
221
|
trailing.unref?.()
|
|
@@ -241,7 +247,7 @@ export function createOutputWriter(
|
|
|
241
247
|
}
|
|
242
248
|
stream.write(chunk)
|
|
243
249
|
if (chunk.length > 0) atLineStart = chunk.endsWith('\n')
|
|
244
|
-
if (!dead && current !== null && atLineStart) draw()
|
|
250
|
+
if (!dead && current !== null && atLineStart && holds === 0) draw()
|
|
245
251
|
},
|
|
246
252
|
writeTo(target, chunk) {
|
|
247
253
|
if (target === stream) return this.write(chunk)
|
|
@@ -257,7 +263,7 @@ export function createOutputWriter(
|
|
|
257
263
|
shownRows = 0
|
|
258
264
|
}
|
|
259
265
|
target.write(chunk)
|
|
260
|
-
if (!dead && current !== null && atLineStart) draw()
|
|
266
|
+
if (!dead && current !== null && atLineStart && holds === 0) draw()
|
|
261
267
|
},
|
|
262
268
|
setStatus(line, o = {}) {
|
|
263
269
|
set([line], o)
|
|
@@ -280,6 +286,23 @@ export function createOutputWriter(
|
|
|
280
286
|
shownRows = 0
|
|
281
287
|
}
|
|
282
288
|
},
|
|
289
|
+
hold() {
|
|
290
|
+
if (!enabled || dead) return () => undefined
|
|
291
|
+
holds++
|
|
292
|
+
cancelTrailing()
|
|
293
|
+
if (shown) {
|
|
294
|
+
stream.write(eraseSeq())
|
|
295
|
+
shown = false
|
|
296
|
+
shownRows = 0
|
|
297
|
+
}
|
|
298
|
+
let released = false
|
|
299
|
+
return () => {
|
|
300
|
+
if (released) return
|
|
301
|
+
released = true
|
|
302
|
+
holds--
|
|
303
|
+
if (holds === 0 && !dead && current !== null && atLineStart) draw()
|
|
304
|
+
}
|
|
305
|
+
},
|
|
283
306
|
}
|
|
284
307
|
}
|
|
285
308
|
|
|
@@ -310,6 +333,8 @@ export interface StatusRegionState {
|
|
|
310
333
|
blocked?: readonly (BlockedTask | null)[]
|
|
311
334
|
/** Running tasks beyond the displayed slots. */
|
|
312
335
|
overflow: number
|
|
336
|
+
/** The run was stopped: running rows say `aborting`, idle rows leave. */
|
|
337
|
+
stopping?: boolean
|
|
313
338
|
/**
|
|
314
339
|
* vx's own steps still running, shown below the worker rows: no worker
|
|
315
340
|
* slot or `--concurrency` bounds them (M, 2026-10-15).
|
|
@@ -428,6 +453,7 @@ export function formatStatusRegion(
|
|
|
428
453
|
// One task running has no path to compare against.
|
|
429
454
|
const busy = s.slots.filter((x) => x !== null).length
|
|
430
455
|
for (const [row, slot] of s.slots.entries()) {
|
|
456
|
+
if (slot === null && s.stopping === true) continue
|
|
431
457
|
if (slot === null) {
|
|
432
458
|
const w = s.blocked?.[row]
|
|
433
459
|
lines.push(
|
|
@@ -440,6 +466,21 @@ export function formatStatusRegion(
|
|
|
440
466
|
}
|
|
441
467
|
// What waits on it, after the id: the row to watch is the one the
|
|
442
468
|
// longest chain waits on, and the one the most tasks wait on.
|
|
469
|
+
if (s.stopping === true) {
|
|
470
|
+
lines.push(
|
|
471
|
+
formatTaskRow(
|
|
472
|
+
' ',
|
|
473
|
+
Math.max(0, s.nowMs - slot.startedMs),
|
|
474
|
+
'aborting',
|
|
475
|
+
WARN,
|
|
476
|
+
'',
|
|
477
|
+
'',
|
|
478
|
+
paintPinnedId(slot.id, colors),
|
|
479
|
+
colors,
|
|
480
|
+
),
|
|
481
|
+
)
|
|
482
|
+
continue
|
|
483
|
+
}
|
|
443
484
|
const waiting = s.blocks?.get(slot.id) ?? 0
|
|
444
485
|
const notes: string[] = []
|
|
445
486
|
if (s.critical === slot.id && busy > 1) notes.push(paint(WARN, 'critical path', colors))
|