@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
|
@@ -10,7 +10,8 @@ import type { InvocationRecord, RunRecord } from '../cache/index.js'
|
|
|
10
10
|
import { isGroupTask, type TaskOutcome } from '../graph/index.js'
|
|
11
11
|
import { VERSION } from '../version.js'
|
|
12
12
|
import type { CiContext, GitContext, HostContext } from './run-context.js'
|
|
13
|
-
import {
|
|
13
|
+
import { taskTelemetryOf, type TaskTelemetry } from './telemetry.js'
|
|
14
|
+
import { isCacheHit } from '../graph/index.js'
|
|
14
15
|
|
|
15
16
|
export interface RunRecordsInput {
|
|
16
17
|
outcomes: readonly TaskOutcome[]
|
|
@@ -27,17 +28,18 @@ export interface RunRecordsInput {
|
|
|
27
28
|
ok: boolean
|
|
28
29
|
command: string
|
|
29
30
|
requestedTasks: readonly string[]
|
|
30
|
-
/** Already
|
|
31
|
+
/** Already formatted (`formatCachePolicy`), the form the header row stores. */
|
|
31
32
|
cachePolicy: string
|
|
32
33
|
concurrency: number
|
|
33
34
|
flow: InvocationRecord['flow']
|
|
34
35
|
forwardArgs?: readonly string[] | undefined
|
|
35
|
-
tags: Record<string, string>
|
|
36
36
|
git: GitContext
|
|
37
37
|
ci: CiContext
|
|
38
38
|
host: HostContext
|
|
39
39
|
/** Build the telemetry mirror; a run with no sink allocates none. */
|
|
40
40
|
withTelemetry: boolean
|
|
41
|
+
/** The run's remote failures by key, so far (`Cache.traffic`). */
|
|
42
|
+
remoteTraffic?: ReadonlyMap<string, { failures: readonly string[] }>
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
export interface RunRecords {
|
|
@@ -46,6 +48,15 @@ export interface RunRecords {
|
|
|
46
48
|
telemetryTasks: TaskTelemetry[]
|
|
47
49
|
}
|
|
48
50
|
|
|
51
|
+
/** A key's remote failures as its `runs` row stores them; none when it met none. */
|
|
52
|
+
function remoteErrorOf(
|
|
53
|
+
traffic: ReadonlyMap<string, { failures: readonly string[] }> | undefined,
|
|
54
|
+
hash: string | undefined,
|
|
55
|
+
): string | undefined {
|
|
56
|
+
const failures = hash === undefined ? undefined : traffic?.get(hash)?.failures
|
|
57
|
+
return failures === undefined || failures.length === 0 ? undefined : failures.join('; ')
|
|
58
|
+
}
|
|
59
|
+
|
|
49
60
|
/**
|
|
50
61
|
* Every non-group, non-aborted outcome gets a row. An outcome with NO hash
|
|
51
62
|
* (a `skipped` task never probed the cache; a `persistent` one is never
|
|
@@ -71,6 +82,7 @@ export function assembleRunRecords(input: RunRecordsInput): RunRecords {
|
|
|
71
82
|
if (o.status === 'aborted') continue
|
|
72
83
|
if (input.withTelemetry) telemetryTasks.push(taskTelemetryOf(o))
|
|
73
84
|
const blockedBy = o.blockedBy ?? input.tainted?.get(o.node.id)
|
|
85
|
+
const remoteError = remoteErrorOf(input.remoteTraffic, o.hash)
|
|
74
86
|
runs.push({
|
|
75
87
|
...(o.hash !== undefined ? { hash: o.hash } : {}),
|
|
76
88
|
project: o.node.projectName,
|
|
@@ -113,6 +125,7 @@ export function assembleRunRecords(input: RunRecordsInput): RunRecords {
|
|
|
113
125
|
...(o.timedOut === true ? { timedOut: true as const } : {}),
|
|
114
126
|
...(o.sandboxViolations !== undefined ? { sandboxViolations: o.sandboxViolations } : {}),
|
|
115
127
|
...(o.notReady !== undefined ? { notReady: o.notReady } : {}),
|
|
128
|
+
...(remoteError !== undefined ? { remoteError } : {}),
|
|
116
129
|
})
|
|
117
130
|
if (o.status === 'failed') failedCount++
|
|
118
131
|
if (o.status === 'cache-hit') hitLocalCount++
|
|
@@ -151,7 +164,26 @@ export function assembleRunRecords(input: RunRecordsInput): RunRecords {
|
|
|
151
164
|
os: input.host.os,
|
|
152
165
|
arch: input.host.arch,
|
|
153
166
|
vxVersion: VERSION,
|
|
154
|
-
tags: JSON.stringify(input.tags),
|
|
155
167
|
}
|
|
156
168
|
return { runs, invocation, telemetryTasks }
|
|
157
169
|
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The remote failures uploads met after the run's rows went in (the uploads
|
|
173
|
+
* drain later): each row whose text is now short of its key's is rewritten.
|
|
174
|
+
*/
|
|
175
|
+
export function recordLateRemoteErrors(
|
|
176
|
+
cache: {
|
|
177
|
+
readonly traffic: { readonly keys: ReadonlyMap<string, { failures: readonly string[] }> }
|
|
178
|
+
recordRemoteErrors(runId: string, errors: readonly (readonly [string, string])[]): void
|
|
179
|
+
},
|
|
180
|
+
runs: readonly RunRecord[],
|
|
181
|
+
runId: string,
|
|
182
|
+
): void {
|
|
183
|
+
const late: [string, string][] = []
|
|
184
|
+
for (const r of runs) {
|
|
185
|
+
const now = remoteErrorOf(cache.traffic.keys, r.hash)
|
|
186
|
+
if (now !== undefined && now !== r.remoteError) late.push([r.hash!, now])
|
|
187
|
+
}
|
|
188
|
+
if (late.length > 0) cache.recordRemoteErrors(runId, late)
|
|
189
|
+
}
|
|
@@ -1,189 +1,91 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// (`vx run ci --report-file="$GITHUB_STEP_SUMMARY"`). Pure: the run's
|
|
5
|
-
// finished outcomes in, a markdown string out. No ANSI, no live region —
|
|
6
|
-
// keep it machine-clean and diffable.
|
|
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, an uncached task's log.
|
|
11
4
|
|
|
12
|
-
import {
|
|
13
|
-
|
|
5
|
+
import {
|
|
6
|
+
packRunOutputs,
|
|
7
|
+
resolveOutputs,
|
|
8
|
+
resolveWorkspaceOutputs,
|
|
9
|
+
runEntryName,
|
|
10
|
+
} from '../cache/index.js'
|
|
11
|
+
import type { CiContext } from './run-context.js'
|
|
12
|
+
import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
13
|
+
import { relPosix } from '../util/index.js'
|
|
14
|
+
import { decodeOutputLog } from './output-log.js'
|
|
15
|
+
import type { RunReport, RunReportTask } from './plugin.js'
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
17
|
+
export interface RunReportArgs {
|
|
18
|
+
readonly runId: string
|
|
19
|
+
readonly commit: string | null
|
|
20
|
+
readonly ciRun: CiContext
|
|
21
|
+
readonly ok: boolean
|
|
22
|
+
readonly workspaceRoot: string
|
|
23
|
+
readonly nestedDirsByProject: ReadonlyMap<string, string[]>
|
|
24
|
+
/** Stores one run entry; undefined when no remote takes the run's writes. */
|
|
25
|
+
readonly keep: ((name: string, bytes: Uint8Array) => Promise<boolean>) | undefined
|
|
24
26
|
}
|
|
25
|
-
import { tallyViews, type Tally } from './tally.js'
|
|
26
27
|
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
28
|
+
/** The files `node`'s declared outputs match on disk now, workspace-relative and sorted. */
|
|
29
|
+
async function outputsLeft(
|
|
30
|
+
node: TaskNode,
|
|
31
|
+
workspaceRoot: string,
|
|
32
|
+
nestedProjectDirs: string[],
|
|
33
|
+
): Promise<string[]> {
|
|
34
|
+
const declared = node.config.cache?.outputs
|
|
35
|
+
if (declared === undefined) return []
|
|
36
|
+
const [own, ws] = await Promise.all([
|
|
37
|
+
resolveOutputs({ projectDir: node.projectDir, outputs: declared.files, nestedProjectDirs }),
|
|
38
|
+
resolveWorkspaceOutputs({ workspaceRoot, outputs: declared.workspaceFiles ?? [] }),
|
|
39
|
+
])
|
|
40
|
+
return [...new Set([...own, ...ws].map((p) => relPosix(workspaceRoot, p)))].sort()
|
|
49
41
|
}
|
|
50
42
|
|
|
51
|
-
function
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
43
|
+
export async function keepRunBytes(
|
|
44
|
+
final: readonly TaskOutcome[],
|
|
45
|
+
args: RunReportArgs,
|
|
46
|
+
): Promise<RunReport> {
|
|
47
|
+
const { keep, runId, workspaceRoot } = args
|
|
48
|
+
const tasks: RunReportTask[] = []
|
|
49
|
+
const work: (() => Promise<void>)[] = []
|
|
50
|
+
for (const o of final) {
|
|
51
|
+
if (isGroupTask(o.node)) continue
|
|
52
|
+
const task: { -readonly [K in keyof RunReportTask]: RunReportTask[K] } = {
|
|
53
|
+
id: o.node.id,
|
|
54
|
+
status: o.status,
|
|
55
|
+
durationMs: Math.round(o.durationMs),
|
|
56
|
+
...(o.hash !== undefined ? { key: o.hash } : {}),
|
|
57
|
+
}
|
|
58
|
+
tasks.push(task)
|
|
59
|
+
if (keep === undefined) continue
|
|
60
|
+
const runLog = o.runLog
|
|
61
|
+
if (runLog !== undefined) {
|
|
62
|
+
work.push(async () => {
|
|
63
|
+
const text = decodeOutputLog(runLog)
|
|
64
|
+
.map((c) => c.text)
|
|
65
|
+
.join('')
|
|
66
|
+
if (text.length === 0) return
|
|
67
|
+
const name = runEntryName(runId, o.node.id, 'log')
|
|
68
|
+
if (await keep(name, new TextEncoder().encode(text))) task.log = name
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
if (o.status === 'failed' && o.where === undefined) {
|
|
72
|
+
work.push(async () => {
|
|
73
|
+
const bytes = await packRunOutputs(
|
|
74
|
+
workspaceRoot,
|
|
75
|
+
await outputsLeft(
|
|
76
|
+
o.node,
|
|
77
|
+
workspaceRoot,
|
|
78
|
+
args.nestedDirsByProject.get(o.node.projectName) ?? [],
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
if (bytes === null) return
|
|
82
|
+
const name = runEntryName(runId, o.node.id, 'outputs')
|
|
83
|
+
if (await keep(name, bytes)) task.outputs = name
|
|
84
|
+
})
|
|
72
85
|
}
|
|
73
86
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
function statusWord(o: OutcomeView): string {
|
|
78
|
-
switch (o.status) {
|
|
79
|
-
case 'success':
|
|
80
|
-
return 'success'
|
|
81
|
-
case 'cache-hit':
|
|
82
|
-
case 'cache-hit-remote':
|
|
83
|
-
return 'success'
|
|
84
|
-
case 'failed':
|
|
85
|
-
return failedLabel(o.exitCode, o.timedOut, o.sandboxViolations, o.notReady, o.inputMoved)
|
|
86
|
-
case 'skipped':
|
|
87
|
-
return skippedLabel(o.blockedBy)
|
|
88
|
-
case 'aborted':
|
|
89
|
-
return neverStarted(o) ? 'not run' : 'aborted'
|
|
90
|
-
default:
|
|
91
|
-
return o.status
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function cacheWord(o: OutcomeView): string {
|
|
96
|
-
switch (o.status) {
|
|
97
|
-
case 'cache-hit':
|
|
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 '—'
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function fmtDuration(ms: number): string {
|
|
110
|
-
if (ms < 1000) return `${Math.round(ms)}ms`
|
|
111
|
-
return `${(ms / 1000).toFixed(2)}s`
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Make a value safe inside a GFM table cell. Task names are arbitrary TS
|
|
116
|
-
* object keys and the loader accepts `|` and newlines, either of which
|
|
117
|
-
* silently breaks the table on the consumer (`>> $GITHUB_STEP_SUMMARY`):
|
|
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
|
-
}
|
|
136
|
-
|
|
137
|
-
const cell = escapeMarkdownCell
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Render the finished run as a markdown report. A header line of totals
|
|
141
|
-
* followed by a per-task table. Self-contained — everything is derived
|
|
142
|
-
* from the result's outcomes.
|
|
143
|
-
*/
|
|
144
|
-
export function formatRunReportMarkdown(result: RunResult): string {
|
|
145
|
-
const t = tally(result.outcomes)
|
|
146
|
-
const cached = t.cachedLocal + t.cachedRemote
|
|
147
|
-
// The terminal summary's words and counts: every task in the run counts
|
|
148
|
-
// (M, 2026-10-14), and a task the stop reached before it ran was never
|
|
149
|
-
// aborted.
|
|
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`)
|
|
166
|
-
|
|
167
|
-
// Only a stop (a signal, `RunOptions.signal`) makes an `aborted` outcome,
|
|
168
|
-
// so one is how an interrupted run tells itself from a failed one.
|
|
169
|
-
const heading = t.aborted > 0 ? 'interrupted' : result.ok ? 'passed' : 'failed'
|
|
170
|
-
const lines: string[] = []
|
|
171
|
-
lines.push(`## vx run — ${heading}`)
|
|
172
|
-
lines.push('')
|
|
173
|
-
lines.push(parts.join(' · '))
|
|
174
|
-
lines.push('')
|
|
175
|
-
lines.push('| Task | Status | Cache | Duration |')
|
|
176
|
-
lines.push('| --- | --- | --- | --- |')
|
|
177
|
-
for (const o of result.outcomes) {
|
|
178
|
-
// A group node did no work: no command, no cache decision, 0ms. Rendering
|
|
179
|
-
// one as `success | miss | 0ms` invents a task the user never wrote a
|
|
180
|
-
// command for. Same exclusion the header's tally makes.
|
|
181
|
-
if (o.isGroup === true) continue
|
|
182
|
-
lines.push(
|
|
183
|
-
`| ${cell(o.taskId)} | ${cell(statusWord(o))} | ${cell(cacheWord(o))} | ${cell(
|
|
184
|
-
neverStarted(o) ? '—' : fmtDuration(o.durationMs),
|
|
185
|
-
)} |`,
|
|
186
|
-
)
|
|
87
|
+
for (let i = 0; i < work.length; i += 8) {
|
|
88
|
+
await Promise.all(work.slice(i, i + 8).map((w) => w()))
|
|
187
89
|
}
|
|
188
|
-
return
|
|
90
|
+
return { runId, commit: args.commit, ciRun: args.ciRun, ok: args.ok, tasks }
|
|
189
91
|
}
|
|
@@ -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,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
|
+
}
|