@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
|
@@ -1,189 +1,169 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// Machine-clean is a property of this STRING, not of stdout: the status
|
|
9
|
-
// logger shares that stream, so redirecting it captures the whole run log
|
|
10
|
-
// too. `--report-file` is the sink that carries the property end to end.
|
|
1
|
+
// The report every plugin's teardown reads, and the run's own bytes kept
|
|
2
|
+
// in a remote that takes its writes (owner 2026-10-15): a failed task's
|
|
3
|
+
// log and the files its outputs left (`cache.upload.failures`), and the
|
|
4
|
+
// log of a task that saved no entry. Each task's bytes leave when it ends,
|
|
5
|
+
// off its path, like its entry's upload (owner 2026-10-16); the run only
|
|
6
|
+
// waits for them where it waits for every upload.
|
|
11
7
|
|
|
12
|
-
import {
|
|
13
|
-
|
|
8
|
+
import {
|
|
9
|
+
packRunOutputs,
|
|
10
|
+
resolveOutputs,
|
|
11
|
+
resolveWorkspaceOutputs,
|
|
12
|
+
runEntryName,
|
|
13
|
+
} from '../cache/index.js'
|
|
14
|
+
import type { CiContext } from './run-context.js'
|
|
15
|
+
import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
16
|
+
import { relPosix } from '../util/index.js'
|
|
17
|
+
import { decodeOutputLog } from './output-log.js'
|
|
18
|
+
import type { RunReport, RunReportTask } from './plugin.js'
|
|
14
19
|
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
/** Stores one run entry; resolves whether a remote kept it. */
|
|
21
|
+
export type KeepRunEntry = (name: string, bytes: Uint8Array) => Promise<boolean>
|
|
22
|
+
|
|
23
|
+
export interface RunKeeperArgs {
|
|
24
|
+
readonly runId: string
|
|
25
|
+
readonly workspaceRoot: string
|
|
26
|
+
readonly nestedDirsByProject: ReadonlyMap<string, string[]>
|
|
27
|
+
readonly keep: KeepRunEntry
|
|
28
|
+
/** Whether a failed task's log and outputs are kept (`cache.upload.failures`). */
|
|
29
|
+
readonly failures: boolean
|
|
24
30
|
}
|
|
25
|
-
import { tallyViews, type Tally } from './tally.js'
|
|
26
31
|
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
* durations are report-specific.
|
|
32
|
-
*
|
|
33
|
-
* This used to be a private copy of the partition, and it drifted: it had no
|
|
34
|
-
* group filter (it could not have one — `OutcomeView` had no `isGroup`), so
|
|
35
|
-
* every organizational node was counted as a successful task and rendered as
|
|
36
|
-
* a row claiming `success | miss | 0ms`.
|
|
37
|
-
*/
|
|
38
|
-
interface ReportTally extends Tally {
|
|
39
|
-
/** Wall-clock ms summed over tasks that ran a command, an aborted one's time so far included. */
|
|
40
|
-
executedMs: number
|
|
41
|
-
/** The aborted tasks the stop reached before they ran, as the terminal splits them. */
|
|
42
|
-
notRun: number
|
|
43
|
-
/**
|
|
44
|
-
* Ms of work the cache SKIPPED — the sum of the hits' STORED exec times.
|
|
45
|
-
* Deliberately not `durationMs`, which for a hit is the restore this run
|
|
46
|
-
* paid: summing that reported a 2.01 s task as "6ms saved".
|
|
47
|
-
*/
|
|
48
|
-
savedMs: number
|
|
32
|
+
/** Whether a run on CI or off it keeps failures, by `cache.upload.failures` (default `'ci'`). */
|
|
33
|
+
export function keepsFailures(where: unknown, ci: boolean): boolean {
|
|
34
|
+
const at = where ?? 'ci'
|
|
35
|
+
return at === 'always' || (at === 'ci') === ci
|
|
49
36
|
}
|
|
50
37
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
// better to omit the claim than to substantiate it with the restore
|
|
68
|
-
// cost. (Only reachable across a version skew — every hit this
|
|
69
|
-
// binary produces carries the stored duration.)
|
|
70
|
-
t.savedMs += o.storedDurationMs ?? 0
|
|
71
|
-
break
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
return t
|
|
38
|
+
/** Concurrent run-entry writes; more queue. */
|
|
39
|
+
const KEEP_CONCURRENCY = 8
|
|
40
|
+
|
|
41
|
+
/** The files `node`'s declared outputs match on disk now, workspace-relative and sorted. */
|
|
42
|
+
async function outputsLeft(
|
|
43
|
+
node: TaskNode,
|
|
44
|
+
workspaceRoot: string,
|
|
45
|
+
nestedProjectDirs: string[],
|
|
46
|
+
): Promise<string[]> {
|
|
47
|
+
const declared = node.config.cache?.outputs
|
|
48
|
+
if (declared === undefined) return []
|
|
49
|
+
const [own, ws] = await Promise.all([
|
|
50
|
+
resolveOutputs({ projectDir: node.projectDir, outputs: declared.files, nestedProjectDirs }),
|
|
51
|
+
resolveWorkspaceOutputs({ workspaceRoot, outputs: declared.workspaceFiles ?? [] }),
|
|
52
|
+
])
|
|
53
|
+
return [...new Set([...own, ...ws].map((p) => relPosix(workspaceRoot, p)))].sort()
|
|
75
54
|
}
|
|
76
55
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
56
|
+
/** Keeps each task's run bytes as it ends, and names them in the run's report. */
|
|
57
|
+
export class RunKeeper {
|
|
58
|
+
private readonly kept = new Map<string, { log?: string; outputs?: string }>()
|
|
59
|
+
private readonly queue: (() => Promise<void>)[] = []
|
|
60
|
+
private readonly pending = new Set<Promise<void>>()
|
|
61
|
+
private active = 0
|
|
62
|
+
private stopped = false
|
|
63
|
+
|
|
64
|
+
constructor(private readonly args: RunKeeperArgs) {}
|
|
65
|
+
|
|
66
|
+
/** A task ended: queue what of it the run keeps. */
|
|
67
|
+
taskDone(o: TaskOutcome): void {
|
|
68
|
+
if (this.stopped || isGroupTask(o.node)) return
|
|
69
|
+
const { runId, workspaceRoot } = this.args
|
|
70
|
+
const id = o.node.id
|
|
71
|
+
const slot = (): { log?: string; outputs?: string } => {
|
|
72
|
+
let s = this.kept.get(id)
|
|
73
|
+
if (s === undefined) this.kept.set(id, (s = {}))
|
|
74
|
+
return s
|
|
75
|
+
}
|
|
76
|
+
const runLog = o.runLog
|
|
77
|
+
if (runLog !== undefined) {
|
|
78
|
+
const text = decodeOutputLog(runLog)
|
|
79
|
+
.map((c) => c.text)
|
|
80
|
+
.join('')
|
|
81
|
+
if (text.length > 0) {
|
|
82
|
+
const name = runEntryName(runId, id, 'log')
|
|
83
|
+
const bytes = new TextEncoder().encode(text)
|
|
84
|
+
this.enqueue(async () => {
|
|
85
|
+
if (await this.args.keep(name, bytes)) slot().log = name
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (this.args.failures && o.status === 'failed' && o.where === undefined) {
|
|
90
|
+
// Read now: a later task may write where this one's outputs lie.
|
|
91
|
+
const packed = outputsLeft(
|
|
92
|
+
o.node,
|
|
93
|
+
workspaceRoot,
|
|
94
|
+
this.args.nestedDirsByProject.get(o.node.projectName) ?? [],
|
|
95
|
+
).then((paths) => packRunOutputs(workspaceRoot, paths))
|
|
96
|
+
const name = runEntryName(runId, id, 'outputs')
|
|
97
|
+
this.enqueue(async () => {
|
|
98
|
+
const bytes = await packed
|
|
99
|
+
if (bytes !== null && (await this.args.keep(name, bytes))) slot().outputs = name
|
|
100
|
+
})
|
|
101
|
+
}
|
|
92
102
|
}
|
|
93
|
-
}
|
|
94
103
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
return o.restored === false ? 'up-to-date' : 'local'
|
|
99
|
-
case 'cache-hit-remote':
|
|
100
|
-
return o.restored === false ? 'up-to-date' : 'remote'
|
|
101
|
-
case 'success':
|
|
102
|
-
case 'failed':
|
|
103
|
-
return o.noCache === true ? 'no-cache' : 'miss'
|
|
104
|
-
default:
|
|
105
|
-
return '—'
|
|
104
|
+
/** Resolves once every queued write has settled, or the run was stopped. */
|
|
105
|
+
async settled(): Promise<void> {
|
|
106
|
+
while (!this.stopped && this.pending.size > 0) await Promise.all(this.pending)
|
|
106
107
|
}
|
|
107
|
-
}
|
|
108
108
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
109
|
+
/** A stopped run starts no more writes and stops waiting on those in flight. */
|
|
110
|
+
stop(): void {
|
|
111
|
+
this.stopped = true
|
|
112
|
+
this.queue.length = 0
|
|
113
|
+
}
|
|
113
114
|
|
|
114
|
-
/**
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
* a bare pipe adds a column, a newline splits the row.
|
|
119
|
-
*
|
|
120
|
-
* Exported because this file is not the only markdown table describing a run:
|
|
121
|
-
* the cloud plugin's GitHub job summary renders the same data from the same
|
|
122
|
-
* unvalidated names and shipped WITHOUT this escape, so a `|` in a task name
|
|
123
|
-
* or an output path shifted its columns. One definition, so the two
|
|
124
|
-
* cannot disagree about what a cell may contain.
|
|
125
|
-
*
|
|
126
|
-
* A pipe splits the row unless an ODD run of backslashes precedes it (GFM
|
|
127
|
-
* reads `\\` as one escaped backslash), so a pipe after an odd run is already
|
|
128
|
-
* escaped and one more backslash would free it: `a\|b` became `a\\|b`, two
|
|
129
|
-
* cells. A lone `\r` ends a line too.
|
|
130
|
-
*/
|
|
131
|
-
export function escapeMarkdownCell(value: string): string {
|
|
132
|
-
return value
|
|
133
|
-
.replace(/(\\*)\|/g, (pipe, run: string) => (run.length % 2 === 0 ? `${run}\\|` : pipe))
|
|
134
|
-
.replace(/\r\n|\r|\n/g, ' ')
|
|
135
|
-
}
|
|
115
|
+
/** What was kept of each task, by id, for `reportOf`. */
|
|
116
|
+
keptOf(id: string): { log?: string; outputs?: string } | undefined {
|
|
117
|
+
return this.kept.get(id)
|
|
118
|
+
}
|
|
136
119
|
|
|
137
|
-
|
|
120
|
+
private enqueue(job: () => Promise<void>): void {
|
|
121
|
+
let done!: () => void
|
|
122
|
+
const p = new Promise<void>((resolve) => (done = resolve))
|
|
123
|
+
this.pending.add(p)
|
|
124
|
+
this.queue.push(async () => {
|
|
125
|
+
try {
|
|
126
|
+
if (!this.stopped) await job()
|
|
127
|
+
} catch {
|
|
128
|
+
// a record, never the run's verdict
|
|
129
|
+
} finally {
|
|
130
|
+
this.pending.delete(p)
|
|
131
|
+
done()
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
this.pump()
|
|
135
|
+
}
|
|
138
136
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const aborted = t.aborted - t.notRun
|
|
151
|
-
const count = t.total + aborted + t.notRun
|
|
152
|
-
const parts = [
|
|
153
|
-
`**${count} task${count === 1 ? '' : 's'}**`,
|
|
154
|
-
`${t.successful} success`,
|
|
155
|
-
`${t.failed} failed`,
|
|
156
|
-
// What the hits did to the disk, as the run's summary splits them.
|
|
157
|
-
cached > 0
|
|
158
|
-
? `${cached} cached (${t.upToDate} up-to-date, ${t.restoredLocal + t.restoredRemote} restored)`
|
|
159
|
-
: '0 cached',
|
|
160
|
-
]
|
|
161
|
-
if (t.skipped > 0) parts.push(`${t.skipped} skipped`)
|
|
162
|
-
if (aborted > 0) parts.push(`${aborted} aborted`)
|
|
163
|
-
if (t.notRun > 0) parts.push(`${t.notRun} not run`)
|
|
164
|
-
parts.push(`${fmtDuration(t.executedMs)} total`)
|
|
165
|
-
if (t.savedMs > 0) parts.push(`${fmtDuration(t.savedMs)} saved`)
|
|
137
|
+
private pump(): void {
|
|
138
|
+
while (this.active < KEEP_CONCURRENCY && this.queue.length > 0) {
|
|
139
|
+
const job = this.queue.shift()!
|
|
140
|
+
this.active++
|
|
141
|
+
void job().finally(() => {
|
|
142
|
+
this.active--
|
|
143
|
+
this.pump()
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
166
148
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
)} |`,
|
|
186
|
-
)
|
|
149
|
+
/** The report: every task, with the names of what `keeper` kept of it. */
|
|
150
|
+
export function reportOf(
|
|
151
|
+
final: readonly TaskOutcome[],
|
|
152
|
+
run: { runId: string; commit: string | null; ciRun: CiContext; ok: boolean },
|
|
153
|
+
keeper: RunKeeper | undefined,
|
|
154
|
+
): RunReport {
|
|
155
|
+
const tasks: RunReportTask[] = []
|
|
156
|
+
for (const o of final) {
|
|
157
|
+
if (isGroupTask(o.node)) continue
|
|
158
|
+
const kept = keeper?.keptOf(o.node.id)
|
|
159
|
+
tasks.push({
|
|
160
|
+
id: o.node.id,
|
|
161
|
+
status: o.status,
|
|
162
|
+
durationMs: Math.round(o.durationMs),
|
|
163
|
+
...(o.hash !== undefined ? { key: o.hash } : {}),
|
|
164
|
+
...(kept?.log !== undefined ? { log: kept.log } : {}),
|
|
165
|
+
...(kept?.outputs !== undefined ? { outputs: kept.outputs } : {}),
|
|
166
|
+
})
|
|
187
167
|
}
|
|
188
|
-
return
|
|
168
|
+
return { runId: run.runId, commit: run.commit, ciRun: run.ciRun, ok: run.ok, tasks }
|
|
189
169
|
}
|
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
|
|
6
6
|
import type { WorkspaceConfig } from '../config.js'
|
|
7
7
|
import { resolveSetting } from '../settings.js'
|
|
8
|
-
import { setSettleBounds, type SettleBound } from '../util/index.js'
|
|
9
8
|
import type { RunOptions } from './options.js'
|
|
10
9
|
|
|
11
10
|
const FROM_OPTIONS = {
|
|
@@ -14,15 +13,12 @@ const FROM_OPTIONS = {
|
|
|
14
13
|
timeout: 'run.timeout',
|
|
15
14
|
retries: 'run.retries',
|
|
16
15
|
continueMode: 'run.continue',
|
|
17
|
-
readyNotice: 'run.readyNotice',
|
|
18
16
|
download: 'cache.download',
|
|
19
17
|
} as const satisfies Partial<Record<keyof RunOptions, string>>
|
|
20
18
|
|
|
21
19
|
/**
|
|
22
20
|
* `options` with each run-level setting it leaves unset filled from the
|
|
23
|
-
* environment, then the workspace.
|
|
24
|
-
* below settings) with the flag's or workspace's `run.killGrace` and
|
|
25
|
-
* `run.teardownTimeout`: the variable sits between them, read per call.
|
|
21
|
+
* environment, then the workspace.
|
|
26
22
|
*/
|
|
27
23
|
export function withRunSettings(
|
|
28
24
|
options: RunOptions,
|
|
@@ -33,16 +29,5 @@ export function withRunSettings(
|
|
|
33
29
|
const { value } = resolveSetting(path, workspace, options[field as keyof RunOptions])
|
|
34
30
|
if (value !== undefined) out[field] = value
|
|
35
31
|
}
|
|
36
|
-
const bound = (flag: number | undefined, path: string): SettleBound | undefined => {
|
|
37
|
-
if (flag !== undefined) return { ms: flag, from: 'flag' }
|
|
38
|
-
const { value } = resolveSetting(path, workspace, undefined, {})
|
|
39
|
-
return value === undefined ? undefined : { ms: value as number, from: 'workspace' }
|
|
40
|
-
}
|
|
41
|
-
const killGrace = bound(options.killGrace, 'run.killGrace')
|
|
42
|
-
const teardown = bound(options.teardownTimeout, 'run.teardownTimeout')
|
|
43
|
-
setSettleBounds({
|
|
44
|
-
...(killGrace !== undefined ? { killGrace } : {}),
|
|
45
|
-
...(teardown !== undefined ? { teardown } : {}),
|
|
46
|
-
})
|
|
47
32
|
return out as unknown as RunOptions
|
|
48
33
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The steps a run did not wait on (`vx:prefetch`), for `vx last` (M,
|
|
2
|
+
// 2026-10-16): they never show in a run's output. One file per run that
|
|
3
|
+
// had one, `<cacheDir>/steps/<runId>.json`, the newest 50 kept, as
|
|
4
|
+
// run-failures.ts keeps failed output: files, not cache.db, so the index
|
|
5
|
+
// layout stays as it was. A run with none writes nothing.
|
|
6
|
+
|
|
7
|
+
import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import { isFsRefusal } from '../util/index.js'
|
|
10
|
+
|
|
11
|
+
/** Runs whose background steps are kept; run ids are UUIDv7, so names sort by age. */
|
|
12
|
+
const KEPT = 50
|
|
13
|
+
|
|
14
|
+
/** One of vx's own steps that ran beside the tasks. */
|
|
15
|
+
export interface RunStep {
|
|
16
|
+
id: string
|
|
17
|
+
durationMs: number
|
|
18
|
+
ok: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const dirOf = (cacheDir: string): string => path.join(cacheDir, 'steps')
|
|
22
|
+
|
|
23
|
+
/** Keep `steps` for `runId`; a refused write costs only them. */
|
|
24
|
+
export function writeRunSteps(
|
|
25
|
+
cacheDir: string,
|
|
26
|
+
runId: string,
|
|
27
|
+
steps: readonly RunStep[],
|
|
28
|
+
warn: (message: string) => void,
|
|
29
|
+
): void {
|
|
30
|
+
if (steps.length === 0) return
|
|
31
|
+
try {
|
|
32
|
+
const dir = dirOf(cacheDir)
|
|
33
|
+
mkdirSync(dir, { recursive: true })
|
|
34
|
+
const file = path.join(dir, `${runId}.json`)
|
|
35
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
36
|
+
writeFileSync(tmp, JSON.stringify(steps))
|
|
37
|
+
renameSync(tmp, file)
|
|
38
|
+
const names = readdirSync(dir)
|
|
39
|
+
.filter((n) => n.endsWith('.json'))
|
|
40
|
+
.sort()
|
|
41
|
+
for (const n of names.slice(0, -KEPT)) rmSync(path.join(dir, n), { force: true })
|
|
42
|
+
} catch (err) {
|
|
43
|
+
if (!isFsRefusal(err)) throw err
|
|
44
|
+
warn(`[vx] background steps not kept: ${err.message} — the verdict above stands`)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The background steps `runId` kept; none when it had none or they are gone. */
|
|
49
|
+
export function runSteps(cacheDir: string, runId: string): RunStep[] {
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(
|
|
52
|
+
readFileSync(path.join(dirOf(cacheDir), `${runId}.json`), 'utf8'),
|
|
53
|
+
) as RunStep[]
|
|
54
|
+
} catch {
|
|
55
|
+
return []
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// One stop for the run: an embedder's `RunOptions.signal` and a process
|
|
2
|
+
// signal both abort it. The scheduler stops dispatching (it reads the
|
|
3
|
+
// signal), the children are torn down, and run() returns through its own
|
|
4
|
+
// end-of-run path. Owns the run-scoped registries of live subprocesses:
|
|
5
|
+
// - `liveChildren`: in-flight children. The runner adds/removes each
|
|
6
|
+
// child around its spawn (persistent children stay until they exit).
|
|
7
|
+
// - `persistentRegistry`: ready persistent tasks (dev servers,
|
|
8
|
+
// watchers). executeTask spawns them but does NOT await their exit;
|
|
9
|
+
// ownership moves here so the orchestrator can SIGTERM them once the
|
|
10
|
+
// rest of the graph finishes.
|
|
11
|
+
// A SIGINT/SIGTERM mid-run forwards that signal to everything in both
|
|
12
|
+
// (`signals.ts`); `detach` removes the handlers so repeated run() calls
|
|
13
|
+
// (test suites) never stack listeners.
|
|
14
|
+
|
|
15
|
+
import { killGraceMs, teardownTimeoutMs } from '../util/index.js'
|
|
16
|
+
import { type Logger, warnOf } from '../report/index.js'
|
|
17
|
+
import { hasEnded } from './persistent.js'
|
|
18
|
+
import {
|
|
19
|
+
forwardedSignal,
|
|
20
|
+
forwardSignals,
|
|
21
|
+
SIGNAL_SHUTDOWN_GRACE_MS,
|
|
22
|
+
type StopSignal,
|
|
23
|
+
terminateChildren,
|
|
24
|
+
} from './signals.js'
|
|
25
|
+
|
|
26
|
+
type Child = ReturnType<typeof Bun.spawn>
|
|
27
|
+
|
|
28
|
+
export interface RunStop {
|
|
29
|
+
readonly signal: AbortSignal
|
|
30
|
+
readonly liveChildren: Set<Child>
|
|
31
|
+
readonly persistentRegistry: Map<string, Child>
|
|
32
|
+
/**
|
|
33
|
+
* The process signal that stopped the run. A run a process signal
|
|
34
|
+
* stopped records no history: until item 849 the handler exited before
|
|
35
|
+
* any outcome landed, and a Ctrl-C'd run then read in `vx last` as
|
|
36
|
+
* FAILED with 0 tasks (item 854). An embedder's abort is its own call
|
|
37
|
+
* and keeps its record.
|
|
38
|
+
*/
|
|
39
|
+
readonly stoppedBy: StopSignal | undefined
|
|
40
|
+
/**
|
|
41
|
+
* The stop's teardown of every child, awaited before run() leaves: a
|
|
42
|
+
* task's shell can die of the signal at once while a child it
|
|
43
|
+
* backgrounded ignores it and dies only to the SIGKILL after the grace,
|
|
44
|
+
* and a run that returned first left that child running past vx's exit
|
|
45
|
+
* (turborepo#14043's class).
|
|
46
|
+
*/
|
|
47
|
+
readonly aborting: Promise<void> | undefined
|
|
48
|
+
/**
|
|
49
|
+
* The servers that had ended when the stop landed: those crashed. One
|
|
50
|
+
* that ends after it is the stop's own kill, and was named a crash
|
|
51
|
+
* (item 1061).
|
|
52
|
+
*/
|
|
53
|
+
readonly endedBeforeStop: ReadonlySet<Child> | undefined
|
|
54
|
+
/** The graph finished: a server ending from here on is said by the end of the run. */
|
|
55
|
+
graphDone(): void
|
|
56
|
+
/** Removes the signal handlers and abort listeners. */
|
|
57
|
+
detach(): void
|
|
58
|
+
/** run() has left its `finally`: a process signal waits for this before it exits. */
|
|
59
|
+
left(): void
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function runStop(args: {
|
|
63
|
+
log: Logger
|
|
64
|
+
cache: { close(): void }
|
|
65
|
+
/** `RunOptions.signal`. */
|
|
66
|
+
external: AbortSignal | undefined
|
|
67
|
+
/** `RunOptions.handleSignals`. */
|
|
68
|
+
handleSignals: boolean
|
|
69
|
+
handsTerminal: boolean
|
|
70
|
+
/** How many plugins have a teardown: each gets its own bound before a signal exits. */
|
|
71
|
+
teardowns: number
|
|
72
|
+
/** Called first when the run is stopped. */
|
|
73
|
+
onStop: () => void
|
|
74
|
+
}): RunStop {
|
|
75
|
+
const { log, external } = args
|
|
76
|
+
const liveChildren = new Set<Child>()
|
|
77
|
+
// A server that ends on its own while the graph still runs is said at
|
|
78
|
+
// once: its dependants were failing against it, and the end of the run
|
|
79
|
+
// was the first word of it (C-69). Not once the graph is done (the end
|
|
80
|
+
// of the run and the keep-alive wait say it) nor under a stop.
|
|
81
|
+
let graphDone = false
|
|
82
|
+
const persistentRegistry = new (class extends Map<string, Child> {
|
|
83
|
+
override set(id: string, child: Child): this {
|
|
84
|
+
super.set(id, child)
|
|
85
|
+
void child.exited.then((code) => {
|
|
86
|
+
if (code !== 0 && !graphDone && !controller.signal.aborted)
|
|
87
|
+
warnOf(log)(`vx: ${id} exited with code ${code} while the run went on`)
|
|
88
|
+
})
|
|
89
|
+
return this
|
|
90
|
+
}
|
|
91
|
+
})()
|
|
92
|
+
const controller = new AbortController()
|
|
93
|
+
const forwardAbort = (): void => controller.abort(external?.reason)
|
|
94
|
+
if (external?.aborted === true) forwardAbort()
|
|
95
|
+
else external?.addEventListener('abort', forwardAbort, { once: true })
|
|
96
|
+
let stoppedBy: StopSignal | undefined
|
|
97
|
+
let left = (): void => {}
|
|
98
|
+
const runLeft = new Promise<void>((resolve) => {
|
|
99
|
+
left = resolve
|
|
100
|
+
})
|
|
101
|
+
const signals = forwardSignals({
|
|
102
|
+
enabled: args.handleSignals,
|
|
103
|
+
log,
|
|
104
|
+
cache: args.cache,
|
|
105
|
+
liveChildren,
|
|
106
|
+
persistentRegistry,
|
|
107
|
+
stop: (signal) => {
|
|
108
|
+
stoppedBy = signal
|
|
109
|
+
controller.abort(signal)
|
|
110
|
+
},
|
|
111
|
+
done: runLeft,
|
|
112
|
+
handsTerminal: args.handsTerminal,
|
|
113
|
+
// The grace, then one flush and each teardown at their own bound, and
|
|
114
|
+
// slack for the summary and the cache close.
|
|
115
|
+
boundMs:
|
|
116
|
+
killGraceMs(SIGNAL_SHUTDOWN_GRACE_MS) + teardownTimeoutMs() * (1 + args.teardowns) + 2_000,
|
|
117
|
+
})
|
|
118
|
+
let aborting: Promise<void> | undefined
|
|
119
|
+
let endedBeforeStop: ReadonlySet<Child> | undefined
|
|
120
|
+
const onAbort = (): void => {
|
|
121
|
+
args.onStop()
|
|
122
|
+
endedBeforeStop = new Set([...persistentRegistry.values()].filter(hasEnded))
|
|
123
|
+
aborting = terminateChildren(
|
|
124
|
+
() => [...liveChildren, ...persistentRegistry.values()],
|
|
125
|
+
forwardedSignal(controller.signal.reason),
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
if (controller.signal.aborted) onAbort()
|
|
129
|
+
else controller.signal.addEventListener('abort', onAbort, { once: true })
|
|
130
|
+
return {
|
|
131
|
+
signal: controller.signal,
|
|
132
|
+
liveChildren,
|
|
133
|
+
persistentRegistry,
|
|
134
|
+
get stoppedBy() {
|
|
135
|
+
return stoppedBy
|
|
136
|
+
},
|
|
137
|
+
get aborting() {
|
|
138
|
+
return aborting
|
|
139
|
+
},
|
|
140
|
+
get endedBeforeStop() {
|
|
141
|
+
return endedBeforeStop
|
|
142
|
+
},
|
|
143
|
+
graphDone: () => {
|
|
144
|
+
graphDone = true
|
|
145
|
+
},
|
|
146
|
+
detach: () => {
|
|
147
|
+
signals.remove()
|
|
148
|
+
external?.removeEventListener('abort', forwardAbort)
|
|
149
|
+
controller.signal.removeEventListener('abort', onAbort)
|
|
150
|
+
},
|
|
151
|
+
left: () => left(),
|
|
152
|
+
}
|
|
153
|
+
}
|