@vzn/vx 0.0.631 → 0.0.633
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 +21 -18
- package/docs/caching.md +2247 -0
- package/docs/cli.md +3214 -0
- package/docs/execution.md +708 -0
- package/docs/features.md +172 -0
- package/docs/patterns.md +219 -0
- package/docs/schema.md +2137 -0
- package/docs/security.md +209 -0
- package/package.json +14 -5
- package/schemas/cache.json +24 -0
- package/schemas/docs.json +28 -0
- package/schemas/error.json +23 -0
- package/schemas/info.json +147 -0
- package/schemas/init.json +57 -0
- package/schemas/last.json +188 -0
- package/schemas/lock.json +21 -0
- package/schemas/plan.json +88 -0
- package/schemas/show.json +124 -0
- package/schemas/summary.json +133 -0
- package/schemas/why.json +168 -0
- package/skills/vx/SKILL.md +89 -0
- package/src/bin.ts +9 -0
- package/src/cache/archive.ts +153 -51
- package/src/cache/cache.ts +542 -137
- package/src/cache/chained-cache.ts +9 -0
- package/src/cache/inputs.ts +5 -0
- package/src/cache/layer.ts +10 -5
- package/src/cache/layered-cache.ts +4 -0
- package/src/cache/schema.ts +44 -17
- package/src/cache/tar-stream.ts +204 -66
- package/src/cache/zstd.ts +1 -1
- package/src/cli/cache.ts +14 -14
- package/src/cli/docs-corpus.ts +21 -0
- package/src/cli/docs-text.d.ts +5 -0
- package/src/cli/docs.ts +167 -0
- package/src/cli/foreign-flags.ts +1 -1
- package/src/cli/help.ts +42 -7
- package/src/cli/index.ts +25 -11
- package/src/cli/info.ts +38 -9
- package/src/cli/init.ts +131 -24
- package/src/cli/last.ts +54 -3
- package/src/cli/lock.ts +43 -15
- package/src/cli/plan-format.ts +23 -1
- package/src/cli/plugin-commands.ts +3 -0
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +40 -21
- package/src/cli/show.ts +10 -7
- package/src/cli/upgrade.ts +19 -7
- package/src/cli/watch.ts +7 -2
- package/src/cli/why.ts +14 -40
- package/src/exec/runner.ts +27 -10
- package/src/graph/scheduler.ts +36 -0
- package/src/graph/task-graph.ts +4 -4
- package/src/index.ts +22 -5
- package/src/orchestrator/admission.ts +29 -1
- package/src/orchestrator/affected-tasks.ts +87 -18
- package/src/orchestrator/doctor.ts +14 -1
- package/src/orchestrator/execute-task.ts +149 -64
- package/src/orchestrator/failure-mode.ts +5 -3
- package/src/orchestrator/framed-output.ts +17 -3
- package/src/orchestrator/index.ts +11 -0
- package/src/orchestrator/logger.ts +57 -6
- package/src/orchestrator/metrics.ts +76 -4
- package/src/orchestrator/miss-reason.ts +3 -5
- package/src/orchestrator/miss-save.ts +6 -5
- package/src/orchestrator/options.ts +12 -0
- package/src/orchestrator/output-log.ts +14 -1
- package/src/orchestrator/path-links.ts +79 -0
- package/src/orchestrator/plan.ts +20 -3
- package/src/orchestrator/plugin.ts +7 -0
- package/src/orchestrator/prepare.ts +14 -4
- package/src/orchestrator/projects.ts +2 -2
- package/src/orchestrator/run-artifacts.ts +18 -2
- package/src/orchestrator/run-failures.ts +192 -0
- package/src/orchestrator/run-records.ts +10 -8
- package/src/orchestrator/run.ts +87 -22
- package/src/orchestrator/task-hash.ts +4 -4
- package/src/orchestrator/terminal-signals.ts +16 -0
- package/src/orchestrator/upstream.ts +65 -10
- package/src/util/errors.ts +55 -1
- package/src/util/index.ts +3 -0
- package/src/util/secret-mask.ts +13 -1
- package/src/util/verbs.ts +1 -0
- package/src/workspace/config-schema.ts +10 -8
- package/src/workspace/migration.ts +30 -3
- package/src/workspace/workspace.ts +1 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// A failed run's task output and the files it names, so an agent reads why
|
|
2
|
+
// a task failed without the terminal (`vx last --format json`, vx-mcp's
|
|
3
|
+
// getFailures). One file per failed run, `<cacheDir>/failures/<runId>.json`,
|
|
4
|
+
// the newest 50 kept: files, not cache.db, so the index layout (and every
|
|
5
|
+
// shared store under it) stays as it was.
|
|
6
|
+
|
|
7
|
+
import type { Database } from 'bun:sqlite'
|
|
8
|
+
import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
import { isGroupTask, type TaskOutcome } from '../graph/index.js'
|
|
11
|
+
import { decodeOutputLog } from './output-log.js'
|
|
12
|
+
import { splitTaskId } from '../util/index.js'
|
|
13
|
+
import { fileLocations, fileMemo, type OutputLocation } from './path-links.js'
|
|
14
|
+
|
|
15
|
+
/** Failed runs whose output is kept; run ids are UUIDv7, so names sort by age. */
|
|
16
|
+
const KEPT = 50
|
|
17
|
+
|
|
18
|
+
/** A failed task of a run and what it said. */
|
|
19
|
+
export interface TaskFailure {
|
|
20
|
+
taskId: string
|
|
21
|
+
exitCode: number
|
|
22
|
+
timedOut?: true
|
|
23
|
+
/** Plain text, bounded, secrets masked; '' when the run kept none. */
|
|
24
|
+
output: string
|
|
25
|
+
locations: OutputLocation[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RunFailures {
|
|
29
|
+
runId: string
|
|
30
|
+
tasks: TaskFailure[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface StoredFailure extends TaskFailure {
|
|
34
|
+
project: string
|
|
35
|
+
task: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const dirOf = (cacheDir: string): string => path.join(cacheDir, 'failures')
|
|
39
|
+
|
|
40
|
+
/** Each failed outcome's output as plain text and the files it names. */
|
|
41
|
+
function failuresOf(outcomes: readonly TaskOutcome[]): StoredFailure[] {
|
|
42
|
+
const isFile = fileMemo()
|
|
43
|
+
const out: StoredFailure[] = []
|
|
44
|
+
for (const o of outcomes) {
|
|
45
|
+
if (o.failedOutput === undefined || isGroupTask(o.node)) continue
|
|
46
|
+
const output = Bun.stripANSI(
|
|
47
|
+
decodeOutputLog(o.failedOutput)
|
|
48
|
+
.map((c) => c.text)
|
|
49
|
+
.join(''),
|
|
50
|
+
)
|
|
51
|
+
out.push({
|
|
52
|
+
taskId: o.node.id,
|
|
53
|
+
project: o.node.projectName,
|
|
54
|
+
task: o.node.taskName,
|
|
55
|
+
exitCode: o.exitCode,
|
|
56
|
+
...(o.timedOut === true ? { timedOut: true as const } : {}),
|
|
57
|
+
output,
|
|
58
|
+
locations: fileLocations(output, o.node.projectDir, isFile),
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
return out
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Write the run's failures, when any outcome carries output, and drop all
|
|
66
|
+
* but the newest `KEPT` files. Atomic: a reader sees the whole file or none.
|
|
67
|
+
*/
|
|
68
|
+
export function writeRunFailures(
|
|
69
|
+
cacheDir: string,
|
|
70
|
+
runId: string,
|
|
71
|
+
outcomes: readonly TaskOutcome[],
|
|
72
|
+
): void {
|
|
73
|
+
const tasks = failuresOf(outcomes)
|
|
74
|
+
if (tasks.length === 0) return
|
|
75
|
+
const dir = dirOf(cacheDir)
|
|
76
|
+
mkdirSync(dir, { recursive: true })
|
|
77
|
+
const file = path.join(dir, `${runId}.json`)
|
|
78
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
79
|
+
writeFileSync(tmp, JSON.stringify({ runId, tasks }))
|
|
80
|
+
renameSync(tmp, file)
|
|
81
|
+
const names = readdirSync(dir)
|
|
82
|
+
.filter((n) => n.endsWith('.json'))
|
|
83
|
+
.sort()
|
|
84
|
+
for (const n of names.slice(0, -KEPT)) rmSync(path.join(dir, n), { force: true })
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The failed tasks of `runId`, or of the latest failed run when omitted;
|
|
89
|
+
* null when there is no such run. A failed task the file lacks (an older
|
|
90
|
+
* run, a write the disk refused) reads `output: ''`, `locations: []`.
|
|
91
|
+
*/
|
|
92
|
+
export function runFailures(cacheDir: string, db: Database, runId?: string): RunFailures | null {
|
|
93
|
+
const id =
|
|
94
|
+
runId ??
|
|
95
|
+
(
|
|
96
|
+
db
|
|
97
|
+
.query('SELECT run_id FROM invocations WHERE exit_ok = 0 ORDER BY rowid DESC LIMIT 1')
|
|
98
|
+
.get() as { run_id: string } | null
|
|
99
|
+
)?.run_id
|
|
100
|
+
if (id === undefined) return null
|
|
101
|
+
const kept = new Map<string, StoredFailure>()
|
|
102
|
+
try {
|
|
103
|
+
const stored = JSON.parse(
|
|
104
|
+
readFileSync(path.join(dirOf(cacheDir), `${id}.json`), 'utf8'),
|
|
105
|
+
) as RunFailures & { tasks: StoredFailure[] }
|
|
106
|
+
for (const t of stored.tasks) kept.set(t.taskId, t)
|
|
107
|
+
} catch (err) {
|
|
108
|
+
// No file: the run kept no output.
|
|
109
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
|
|
110
|
+
}
|
|
111
|
+
const rows = db
|
|
112
|
+
.query(
|
|
113
|
+
`SELECT project, task, exit_code AS exitCode, timed_out AS timedOut FROM runs
|
|
114
|
+
WHERE run_id = ? AND status = 'failed' ORDER BY id`,
|
|
115
|
+
)
|
|
116
|
+
.all(id) as { project: string; task: string; exitCode: number; timedOut: number | null }[]
|
|
117
|
+
return {
|
|
118
|
+
runId: id,
|
|
119
|
+
tasks: rows.map((r) => {
|
|
120
|
+
const taskId = `${r.project}#${r.task}`
|
|
121
|
+
const k = kept.get(taskId)
|
|
122
|
+
return {
|
|
123
|
+
taskId,
|
|
124
|
+
exitCode: r.exitCode,
|
|
125
|
+
...(r.timedOut === 1 ? { timedOut: true as const } : {}),
|
|
126
|
+
output: k?.output ?? '',
|
|
127
|
+
locations: k?.locations ?? [],
|
|
128
|
+
}
|
|
129
|
+
}),
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** One task's output in a recorded run, as `vx last --log` and getTaskLog read it. */
|
|
134
|
+
export interface TaskLog {
|
|
135
|
+
runId: string
|
|
136
|
+
taskId: string
|
|
137
|
+
status: string
|
|
138
|
+
/**
|
|
139
|
+
* Where the output came from: the run's kept failure output, the cache
|
|
140
|
+
* entry the task saved or hit (masked as it was replayed), or null when
|
|
141
|
+
* vx kept none (an uncached task that passed, a skip).
|
|
142
|
+
*/
|
|
143
|
+
source: 'failure' | 'cache' | null
|
|
144
|
+
/** Plain text; '' when `source` is null. The files it names: `runFailures`. */
|
|
145
|
+
output: string
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* `taskId`'s output in `runId`, or in the latest run that recorded the
|
|
150
|
+
* task when omitted; null when no such run recorded it.
|
|
151
|
+
*/
|
|
152
|
+
export function taskLog(
|
|
153
|
+
cacheDir: string,
|
|
154
|
+
db: Database,
|
|
155
|
+
taskId: string,
|
|
156
|
+
runId?: string,
|
|
157
|
+
): TaskLog | null {
|
|
158
|
+
const [project, task] = splitTaskId(taskId)
|
|
159
|
+
if (project === '' || task === '') return null
|
|
160
|
+
const row = db
|
|
161
|
+
.query(
|
|
162
|
+
`SELECT run_id AS runId, hash, status FROM runs
|
|
163
|
+
WHERE project = ? AND task = ? AND run_id IS NOT NULL ${runId === undefined ? '' : 'AND run_id = ?'}
|
|
164
|
+
ORDER BY id DESC LIMIT 1`,
|
|
165
|
+
)
|
|
166
|
+
.get(...(runId === undefined ? [project, task] : [project, task, runId])) as {
|
|
167
|
+
runId: string
|
|
168
|
+
hash: string
|
|
169
|
+
status: string
|
|
170
|
+
} | null
|
|
171
|
+
if (row === null) return null
|
|
172
|
+
const base = { runId: row.runId, taskId, status: row.status }
|
|
173
|
+
if (row.status === 'failed') {
|
|
174
|
+
const kept = runFailures(cacheDir, db, row.runId)?.tasks.find((t) => t.taskId === taskId)
|
|
175
|
+
if (kept !== undefined && kept.output !== '') {
|
|
176
|
+
return { ...base, source: 'failure', output: kept.output }
|
|
177
|
+
}
|
|
178
|
+
} else if (row.hash !== '') {
|
|
179
|
+
const stored = db.query('SELECT stdout FROM entry_stdout WHERE hash = ?').get(row.hash) as {
|
|
180
|
+
stdout: string
|
|
181
|
+
} | null
|
|
182
|
+
if (stored !== null) {
|
|
183
|
+
const output = Bun.stripANSI(
|
|
184
|
+
decodeOutputLog(stored.stdout)
|
|
185
|
+
.map((c) => c.text)
|
|
186
|
+
.join(''),
|
|
187
|
+
)
|
|
188
|
+
return { ...base, source: 'cache', output }
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return { ...base, source: null, output: '' }
|
|
192
|
+
}
|
|
@@ -14,8 +14,11 @@ import { isCacheHit, taskTelemetryOf, type TaskTelemetry } from './telemetry.js'
|
|
|
14
14
|
|
|
15
15
|
export interface RunRecordsInput {
|
|
16
16
|
outcomes: readonly TaskOutcome[]
|
|
17
|
-
/**
|
|
18
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Tasks that ran behind a failed dependency (`--continue=always`) or one
|
|
19
|
+
* `--exclude-dependencies` dropped, each with the task at the root of it.
|
|
20
|
+
*/
|
|
21
|
+
tainted?: ReadonlyMap<string, string>
|
|
19
22
|
runId: string
|
|
20
23
|
/** Wall-clock ms at run start; every per-task ns offset is anchored to it. */
|
|
21
24
|
startedAtMs: number
|
|
@@ -67,12 +70,9 @@ export function assembleRunRecords(input: RunRecordsInput): RunRecords {
|
|
|
67
70
|
// aborted (killed by a shutdown signal) isn't a real run.
|
|
68
71
|
if (o.status === 'aborted') continue
|
|
69
72
|
if (input.withTelemetry) telemetryTasks.push(taskTelemetryOf(o))
|
|
73
|
+
const blockedBy = o.blockedBy ?? input.tainted?.get(o.node.id)
|
|
70
74
|
runs.push({
|
|
71
|
-
|
|
72
|
-
// the key readers skip): the flaky list asks a key whether it failed.
|
|
73
|
-
...(o.hash !== undefined && !(o.status === 'failed' && input.tainted?.has(o.node.id) === true)
|
|
74
|
-
? { hash: o.hash }
|
|
75
|
-
: {}),
|
|
75
|
+
...(o.hash !== undefined ? { hash: o.hash } : {}),
|
|
76
76
|
project: o.node.projectName,
|
|
77
77
|
task: o.node.taskName,
|
|
78
78
|
status: o.status,
|
|
@@ -107,7 +107,9 @@ export function assembleRunRecords(input: RunRecordsInput): RunRecords {
|
|
|
107
107
|
...(isCacheHit(o.status) ? { restored: o.restored === true } : {}),
|
|
108
108
|
...(o.attempts !== undefined ? { attempts: o.attempts } : {}),
|
|
109
109
|
cached: o.node.config.cache !== undefined,
|
|
110
|
-
|
|
110
|
+
// A task that ran behind a failure names it too, so the flakiness
|
|
111
|
+
// projection counts its outcome on no key (failure-mode.ts).
|
|
112
|
+
...(blockedBy !== undefined ? { blockedBy } : {}),
|
|
111
113
|
...(o.timedOut === true ? { timedOut: true as const } : {}),
|
|
112
114
|
...(o.sandboxViolations !== undefined ? { sandboxViolations: o.sandboxViolations } : {}),
|
|
113
115
|
...(o.notReady !== undefined ? { notReady: o.notReady } : {}),
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -48,13 +48,14 @@ import {
|
|
|
48
48
|
machineParallelism,
|
|
49
49
|
teardownTimeoutMs,
|
|
50
50
|
secretMask,
|
|
51
|
+
isFsRefusal,
|
|
51
52
|
} from '../util/index.js'
|
|
52
53
|
import { keyedProjects } from './keyed-projects.js'
|
|
53
54
|
import { isDefaultBuild } from './projects.js'
|
|
54
55
|
import { prepareSandbox } from './sandbox-request.js'
|
|
55
56
|
import type { OutputDirSnapshot, SaveFacts } from './miss-save.js'
|
|
56
57
|
import { admitTasks, taintTracker } from './admission.js'
|
|
57
|
-
import type
|
|
58
|
+
import { unkeyedGroupOutcome, type ExecuteArgs } from './execute-task.js'
|
|
58
59
|
import { excludedTaint } from './excluded-keys.js'
|
|
59
60
|
import { keyUpstream } from './upstream.js'
|
|
60
61
|
import { busLogger, createEventBus, terminalSubscriber, type EventBus } from './events.js'
|
|
@@ -70,6 +71,7 @@ import { formatPersistentList } from './framed-output.js'
|
|
|
70
71
|
import { createForecast } from './forecast.js'
|
|
71
72
|
import { LocalHistoryProvider } from './history.js'
|
|
72
73
|
import { plan, type RunPlan } from './plan.js'
|
|
74
|
+
import type { AffectedReason } from './affected-tasks.js'
|
|
73
75
|
import { prepareRun, type PreparedRun } from './prepare.js'
|
|
74
76
|
import { acquireRunLock } from './run-lock.js'
|
|
75
77
|
import {
|
|
@@ -103,6 +105,7 @@ import {
|
|
|
103
105
|
} from './local-shortcircuit.js'
|
|
104
106
|
import { deriveStableKeys, probesAfterWrites } from './stable-keys.js'
|
|
105
107
|
|
|
108
|
+
import { writeRunFailures } from './run-failures.js'
|
|
106
109
|
import { assembleRunRecords } from './run-records.js'
|
|
107
110
|
import {
|
|
108
111
|
hasEnded,
|
|
@@ -111,7 +114,12 @@ import {
|
|
|
111
114
|
shutdownPersistent,
|
|
112
115
|
takeHeldServers,
|
|
113
116
|
} from './persistent.js'
|
|
114
|
-
import {
|
|
117
|
+
import {
|
|
118
|
+
runSummaryJson,
|
|
119
|
+
writeRunProfile,
|
|
120
|
+
writeRunSummary,
|
|
121
|
+
type RunSummaryJson,
|
|
122
|
+
} from './run-artifacts.js'
|
|
115
123
|
import { createSaveLane } from './save-lane.js'
|
|
116
124
|
import { formatOutcomeTable, formatRunSummary } from './summary.js'
|
|
117
125
|
import { detectFlaky, type FlakyCandidate, type FlakyFinding } from './failure-mode.js'
|
|
@@ -316,11 +324,16 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
316
324
|
// See docs/design/event-stream-2026-06.md.
|
|
317
325
|
const terminal =
|
|
318
326
|
options.log === undefined
|
|
319
|
-
? defaultLogger(
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
327
|
+
? defaultLogger(
|
|
328
|
+
colors,
|
|
329
|
+
resolveOutputView(options),
|
|
330
|
+
options.json === true ? process.stderr : process.stdout,
|
|
331
|
+
{
|
|
332
|
+
coalesce: true,
|
|
333
|
+
...(options.tty === true ? { tty: true } : {}),
|
|
334
|
+
...(options.forwardArgs !== undefined ? { forwardArgs: options.forwardArgs } : {}),
|
|
335
|
+
},
|
|
336
|
+
)
|
|
324
337
|
: null
|
|
325
338
|
const sink = options.log ?? terminal!
|
|
326
339
|
// An injected bus already has surfaces subscribed; we add the terminal
|
|
@@ -970,7 +983,8 @@ async function runOnBus(
|
|
|
970
983
|
// dispatch, and the tracker carries it to what is built on it.
|
|
971
984
|
const taintSeeds = new Set(excluded.seeds)
|
|
972
985
|
const taint = taintTracker(options.continueMode === 'always', taintSeeds, nodes)
|
|
973
|
-
|
|
986
|
+
// Each task that ran tainted, and the task at the root of it.
|
|
987
|
+
const taintedRan = new Map<string, string>()
|
|
974
988
|
const dependedOn = new Set<string>()
|
|
975
989
|
for (const n of nodes.values()) for (const d of n.deps) dependedOn.add(d)
|
|
976
990
|
|
|
@@ -995,13 +1009,16 @@ async function runOnBus(
|
|
|
995
1009
|
const probe = reuseProbe ? shortCircuit.preProbed.get(node.id) : undefined
|
|
996
1010
|
const upfrontKey = shortCircuit.uncachedKeys.get(node.id)
|
|
997
1011
|
const upfrontGroupKey = shortCircuit.groupKeys.get(node.id)
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1012
|
+
let deadServer: string | undefined
|
|
1013
|
+
if (options.continueMode === 'always') {
|
|
1014
|
+
for (const d of node.deps) {
|
|
1015
|
+
deadServer = deadServerBehind(nodes, serverDied, d)
|
|
1016
|
+
if (deadServer !== undefined) break
|
|
1017
|
+
}
|
|
1018
|
+
if (deadServer !== undefined) taintSeeds.add(node.id)
|
|
1019
|
+
}
|
|
1003
1020
|
const tainted = taint.judge(node, upstream)
|
|
1004
|
-
if (tainted) taintedRan.
|
|
1021
|
+
if (tainted) taintedRan.set(node.id, deadServer ?? taint.cause(node, upstream))
|
|
1005
1022
|
const a: ExecuteArgs = {
|
|
1006
1023
|
node,
|
|
1007
1024
|
upstream,
|
|
@@ -1130,6 +1147,21 @@ async function runOnBus(
|
|
|
1130
1147
|
? Promise.resolve(kept)
|
|
1131
1148
|
: executeWithDedup(node, keyUpstream(node, upstream))
|
|
1132
1149
|
},
|
|
1150
|
+
// An unkeyed group runs nothing: settled in place, unless a taint
|
|
1151
|
+
// may ride it, which `buildExecuteArgs` records on dispatch.
|
|
1152
|
+
...(options.continueMode !== 'always' && taintSeeds.size === 0
|
|
1153
|
+
? {
|
|
1154
|
+
settleNow: (node: TaskNode, upstream: TaskOutcome[]) =>
|
|
1155
|
+
isGroupTask(node) && node.config.cache === undefined
|
|
1156
|
+
? unkeyedGroupOutcome(
|
|
1157
|
+
node,
|
|
1158
|
+
keyUpstream(node, upstream),
|
|
1159
|
+
shortCircuit.groupKeys.get(node.id),
|
|
1160
|
+
runStartHrTimeNs,
|
|
1161
|
+
)
|
|
1162
|
+
: undefined,
|
|
1163
|
+
}
|
|
1164
|
+
: {}),
|
|
1133
1165
|
// A `schedule` plugin's weights; the scheduler keeps its structural
|
|
1134
1166
|
// baseline as the tie-break. Empty map → baseline only.
|
|
1135
1167
|
...(prepared.priorities.size > 0 ? { priorities: prepared.priorities } : {}),
|
|
@@ -1232,7 +1264,20 @@ async function runOnBus(
|
|
|
1232
1264
|
// change the run's exit code — the run already happened.
|
|
1233
1265
|
// Written again after the keep-alive wait: a kept server's crash or a
|
|
1234
1266
|
// Ctrl-C there is the process's exit, and the first write said ok.
|
|
1267
|
+
let json: RunSummaryJson | undefined
|
|
1235
1268
|
const summarize = async (runOk: boolean, final = list): Promise<void> => {
|
|
1269
|
+
if (options.json === true) {
|
|
1270
|
+
json = runSummaryJson({
|
|
1271
|
+
runId,
|
|
1272
|
+
startedAtMs: endedAtMsAtStart,
|
|
1273
|
+
endedAtMs,
|
|
1274
|
+
totalMs,
|
|
1275
|
+
ok: runOk,
|
|
1276
|
+
...(stoppedBy !== undefined && { exitCode: signalExitCode(stoppedBy) }),
|
|
1277
|
+
outcomes: final,
|
|
1278
|
+
flaky,
|
|
1279
|
+
})
|
|
1280
|
+
}
|
|
1236
1281
|
if (options.summarize === undefined) return
|
|
1237
1282
|
// The rewrite after the keep-alive wait says nothing: the footer is
|
|
1238
1283
|
// the run's last word.
|
|
@@ -1319,8 +1364,21 @@ async function runOnBus(
|
|
|
1319
1364
|
// cache closes: its history is written once the wait has decided, or it
|
|
1320
1365
|
// read `ok` over the exit 1 and fed the flaky list a pass (X-23).
|
|
1321
1366
|
const historyAfterWait = keepAlive.children.length > 0 && !hold && stoppedBy === undefined
|
|
1322
|
-
|
|
1367
|
+
// A failed task's output, for `vx last` and an agent: files beside the
|
|
1368
|
+
// index, so a refused write (a full disk, a read-only cache dir) costs
|
|
1369
|
+
// only that output.
|
|
1370
|
+
const recordFailures = (final: readonly TaskOutcome[]): void => {
|
|
1371
|
+
try {
|
|
1372
|
+
writeRunFailures(prepared.cacheDir, runId, final)
|
|
1373
|
+
} catch (err) {
|
|
1374
|
+
if (!isFsRefusal(err)) throw err
|
|
1375
|
+
log.status(`[vx] failed tasks' output not kept: ${err.message} — the verdict above stands`)
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
if (stoppedBy === undefined && !historyAfterWait) {
|
|
1323
1379
|
recordHistory(() => cache.recordRunBundle(records))
|
|
1380
|
+
recordFailures(list)
|
|
1381
|
+
}
|
|
1324
1382
|
mark('record history')
|
|
1325
1383
|
// Drain any still-in-flight background prefetches before closing the
|
|
1326
1384
|
// cache handle — a prefetch ingesting into a closed SQLite DB would
|
|
@@ -1533,11 +1591,12 @@ async function runOnBus(
|
|
|
1533
1591
|
local.close()
|
|
1534
1592
|
}
|
|
1535
1593
|
})
|
|
1594
|
+
recordFailures(final)
|
|
1536
1595
|
}
|
|
1537
|
-
return { ok: ok && first.code === 0, outcomes: final }
|
|
1596
|
+
return { ok: ok && first.code === 0, outcomes: final, ...(json && { json }) }
|
|
1538
1597
|
}
|
|
1539
1598
|
|
|
1540
|
-
return { ok, outcomes: list }
|
|
1599
|
+
return { ok, outcomes: list, ...(json && { json }) }
|
|
1541
1600
|
} finally {
|
|
1542
1601
|
// Idempotent. The status-line ticker starts in runStart, and every
|
|
1543
1602
|
// call between it and the success path's runEnd is crash-isolated
|
|
@@ -1627,7 +1686,8 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
|
|
|
1627
1686
|
// The plan is the product and goes to stdout (`--dry=json` is parsed):
|
|
1628
1687
|
// what a stage says on the way goes to stderr (C-6).
|
|
1629
1688
|
const log = options.log ?? defaultLogger(undefined, undefined, process.stderr)
|
|
1630
|
-
const
|
|
1689
|
+
const reasons = new Map<string, AffectedReason>()
|
|
1690
|
+
const prepared = await prepareRun({ ...options, affectedReasons: reasons }, log)
|
|
1631
1691
|
try {
|
|
1632
1692
|
if (prepared.unresolvedTasks.length > 0) {
|
|
1633
1693
|
return {
|
|
@@ -1675,6 +1735,10 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
|
|
|
1675
1735
|
...(await planRestorable(prepared, policy, options.forwardArgs)),
|
|
1676
1736
|
})
|
|
1677
1737
|
mark('plan')
|
|
1738
|
+
for (const t of planned.tasks) {
|
|
1739
|
+
const why = reasons.get(t.node.id)
|
|
1740
|
+
if (why !== undefined) t.affected = why
|
|
1741
|
+
}
|
|
1678
1742
|
return planned
|
|
1679
1743
|
} finally {
|
|
1680
1744
|
// The plan called the cache and executor factories; teardown releases
|
|
@@ -1861,7 +1925,7 @@ export function projectNamed(
|
|
|
1861
1925
|
/** The executed, keyed outcomes of a run — what flakiness is judged on. */
|
|
1862
1926
|
function flakyCandidates(
|
|
1863
1927
|
outcomes: readonly TaskOutcome[],
|
|
1864
|
-
tainted:
|
|
1928
|
+
tainted: ReadonlyMap<string, string>,
|
|
1865
1929
|
): FlakyCandidate[] {
|
|
1866
1930
|
const out: FlakyCandidate[] = []
|
|
1867
1931
|
for (const o of outcomes) {
|
|
@@ -1871,9 +1935,10 @@ function flakyCandidates(
|
|
|
1871
1935
|
// and runs every time, so one bad network day would read as a flake for
|
|
1872
1936
|
// thirty days. Groups do no work.
|
|
1873
1937
|
if (o.hash === undefined || o.node.config.cache === undefined || isGroupTask(o.node)) continue
|
|
1874
|
-
// Behind a failed dependency the
|
|
1875
|
-
//
|
|
1876
|
-
|
|
1938
|
+
// Behind a failed dependency the outcome is not the key's: a failure is
|
|
1939
|
+
// the dependency's, and a pass built on bytes the key never named, so a
|
|
1940
|
+
// later failure relapsed from it and read as a flake for thirty days.
|
|
1941
|
+
if (tainted.has(o.node.id)) continue
|
|
1877
1942
|
out.push({
|
|
1878
1943
|
project: o.node.projectName,
|
|
1879
1944
|
task: o.node.taskName,
|
|
@@ -411,8 +411,8 @@ function hashTaskConfig(cfg: TaskConfig, hashCache?: HashCache): string {
|
|
|
411
411
|
* in place. Its one behavioural case is a config with NO `exec`, where
|
|
412
412
|
* the spread would add `exec: {}` — and nothing reaches here that way,
|
|
413
413
|
* proven by throwing on a no-exec config and running the whole suite:
|
|
414
|
-
* a group takes `computeGroupKey`, and
|
|
415
|
-
* `
|
|
414
|
+
* a group takes `computeGroupKey`, and a keyed group has no `exec`, so no
|
|
415
|
+
* `remote`. `timeout`/`retries` stay folded — their keys are
|
|
416
416
|
* distinct by design (see the decision log); stripping them retroactively
|
|
417
417
|
* would bump CACHE_VERSION.
|
|
418
418
|
*/
|
|
@@ -423,8 +423,8 @@ function hashableConfig(cfg: TaskConfig): unknown {
|
|
|
423
423
|
}
|
|
424
424
|
|
|
425
425
|
/**
|
|
426
|
-
* A group's key: its upstream, and for a keyed group —
|
|
427
|
-
* `build` (projects.ts)
|
|
426
|
+
* A group's key: its upstream, and for a keyed group — one with `cache`,
|
|
427
|
+
* the default `build` (projects.ts) or a config's — its own inputs too,
|
|
428
428
|
* the way a task's key folds them.
|
|
429
429
|
*/
|
|
430
430
|
export async function computeGroupKey(args: ComputeHashArgs): Promise<string> {
|
|
@@ -43,3 +43,19 @@ export function notifyEscape(text: string): string {
|
|
|
43
43
|
// eslint-disable-next-line no-control-regex -- the control range is the point
|
|
44
44
|
return `\x1b]9;${text.replace(/[\x00-\x1f\x7f]/g, ' ')}\x07`
|
|
45
45
|
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether the terminal `env` names opens OSC 8 links. A terminal that does
|
|
49
|
+
* not know the sequence may print it, so only ones known to read it get it;
|
|
50
|
+
* tmux and screen pass it through only when configured to.
|
|
51
|
+
*/
|
|
52
|
+
export function terminalLinks(env: Readonly<Record<string, string | undefined>>): boolean {
|
|
53
|
+
if (env['TMUX'] !== undefined || env['STY'] !== undefined) return false
|
|
54
|
+
const program = env['TERM_PROGRAM']
|
|
55
|
+
if (program === 'tmux' || program === 'screen') return false
|
|
56
|
+
if (program === 'ghostty' || program === 'WezTerm' || program === 'vscode') return true
|
|
57
|
+
if (program === 'iTerm.app') return atLeast(env['TERM_PROGRAM_VERSION'], 3, 1)
|
|
58
|
+
if (env['WT_SESSION'] !== undefined || env['KITTY_WINDOW_ID'] !== undefined) return true
|
|
59
|
+
if (env['KONSOLE_VERSION'] !== undefined) return true
|
|
60
|
+
return Number(env['VTE_VERSION'] ?? 0) >= 5000
|
|
61
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
DependencySpecError,
|
|
3
3
|
compileTaskPattern,
|
|
4
|
+
isGroupTask,
|
|
4
5
|
isTaskPattern,
|
|
5
6
|
parseDependencySpec,
|
|
6
7
|
type DependencySpec,
|
|
@@ -76,6 +77,69 @@ export function filterUpstreamHashes(
|
|
|
76
77
|
])
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
/**
|
|
81
|
+
* A filter selects among the task's own dependencies, never through a
|
|
82
|
+
* group: `dependsOn: ['build']` over a group `build` of `build.a` and
|
|
83
|
+
* `build.b` offers the filter `build`, not its members. A pattern that
|
|
84
|
+
* matches only a member (`build.*`) therefore selected nothing, and the key stopped moving
|
|
85
|
+
* with that member's inputs: a stale hit under a green run. Such a spec
|
|
86
|
+
* is refused, once, over the graph every key site reads. One that matches
|
|
87
|
+
* nothing anywhere stays legal (a preset's pattern needn't match in every
|
|
88
|
+
* project). An exact name is refused earlier, at load, unless `dependsOn`
|
|
89
|
+
* names it (`assertFilterNamesDeclaredDeps`).
|
|
90
|
+
*/
|
|
91
|
+
export function refuseFiltersThroughGroups(nodes: ReadonlyMap<string, TaskNode>): void {
|
|
92
|
+
for (const node of nodes.values()) {
|
|
93
|
+
const filter = node.config.cache?.inputs?.tasks
|
|
94
|
+
if (filter === undefined || filter.length === 0) continue
|
|
95
|
+
const deps = keyedDeps(node).flatMap((id) => nodes.get(id) ?? [])
|
|
96
|
+
const groups = deps.filter(isGroupTask)
|
|
97
|
+
if (groups.length === 0) continue
|
|
98
|
+
for (const raw of filter) {
|
|
99
|
+
const spec = parseSpec(raw, node.id)
|
|
100
|
+
if (spec.negated) continue
|
|
101
|
+
const matches = specMatcher(spec)
|
|
102
|
+
const hit = (n: TaskNode): boolean => matches(n, n.projectName === node.projectName)
|
|
103
|
+
if (deps.some(hit)) continue
|
|
104
|
+
for (const group of groups) {
|
|
105
|
+
const member = groupMembers(group, nodes).find(hit)
|
|
106
|
+
if (member === undefined) continue
|
|
107
|
+
throw new UserError(
|
|
108
|
+
`${node.id}: cache.inputs.tasks: '${raw}' matches ${member.id} only through the group ${group.id}, so it selects nothing; name '${group.taskName}' or depend on ${member.id} directly`,
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function parseSpec(raw: string, selfTaskId: string): DependencySpec {
|
|
116
|
+
try {
|
|
117
|
+
return parseDependencySpec(raw)
|
|
118
|
+
} catch (err) {
|
|
119
|
+
if (err instanceof DependencySpecError) {
|
|
120
|
+
throw new UserError(`${selfTaskId}: cache.inputs.tasks: ${err.message}`)
|
|
121
|
+
}
|
|
122
|
+
throw err
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Every task a group stands for, through nested groups, each once. */
|
|
127
|
+
function groupMembers(group: TaskNode, nodes: ReadonlyMap<string, TaskNode>): TaskNode[] {
|
|
128
|
+
const out: TaskNode[] = []
|
|
129
|
+
const seen = new Set<string>([group.id])
|
|
130
|
+
const stack = group.deps.toReversed()
|
|
131
|
+
while (stack.length > 0) {
|
|
132
|
+
const id = stack.pop()!
|
|
133
|
+
if (seen.has(id)) continue
|
|
134
|
+
seen.add(id)
|
|
135
|
+
const n = nodes.get(id)
|
|
136
|
+
if (n === undefined) continue
|
|
137
|
+
out.push(n)
|
|
138
|
+
if (isGroupTask(n)) stack.push(...n.deps.toReversed())
|
|
139
|
+
}
|
|
140
|
+
return out
|
|
141
|
+
}
|
|
142
|
+
|
|
79
143
|
/**
|
|
80
144
|
* One dependency as the key fold sees it: the task, and the value the
|
|
81
145
|
* fold dedups by — its hash on the hash path, a structural stand-in for
|
|
@@ -123,16 +187,7 @@ export function selectFoldedDeps(
|
|
|
123
187
|
): FoldCandidate[] {
|
|
124
188
|
if (filter === undefined) return [...deps]
|
|
125
189
|
|
|
126
|
-
const specs: DependencySpec[] = filter.map((raw) =>
|
|
127
|
-
try {
|
|
128
|
-
return parseDependencySpec(raw)
|
|
129
|
-
} catch (err) {
|
|
130
|
-
if (err instanceof DependencySpecError) {
|
|
131
|
-
throw new UserError(`${selfTaskId}: cache.inputs.tasks: ${err.message}`)
|
|
132
|
-
}
|
|
133
|
-
throw err
|
|
134
|
-
}
|
|
135
|
-
})
|
|
190
|
+
const specs: DependencySpec[] = filter.map((raw) => parseSpec(raw, selfTaskId))
|
|
136
191
|
|
|
137
192
|
// Per-spec predicate, compiled once (exact compares + patterns).
|
|
138
193
|
const matchers = specs.map((spec) => specMatcher(spec))
|
package/src/util/errors.ts
CHANGED
|
@@ -7,12 +7,66 @@ import { realpathSync } from 'node:fs'
|
|
|
7
7
|
import os from 'node:os'
|
|
8
8
|
|
|
9
9
|
export class UserError extends Error {
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* What an agent branches on: `VX_E_…`, printed by a verb that answers
|
|
12
|
+
* JSON (`errorDocument`). The message is prose and may change; a code
|
|
13
|
+
* does not. `VX_E_REFUSED` is any refusal without a code of its own.
|
|
14
|
+
*/
|
|
15
|
+
readonly code: string
|
|
16
|
+
// Two signatures, not an optional parameter: the package-api record
|
|
17
|
+
// keeps the one-argument form every plugin already calls.
|
|
18
|
+
constructor(message: string)
|
|
19
|
+
constructor(message: string, code: string)
|
|
20
|
+
constructor(message: string, code = 'VX_E_REFUSED') {
|
|
11
21
|
super(message)
|
|
12
22
|
this.name = 'UserError'
|
|
23
|
+
this.code = code
|
|
13
24
|
}
|
|
14
25
|
}
|
|
15
26
|
|
|
27
|
+
// A refusal that opens with the config that caused it: the schema's
|
|
28
|
+
// `<file>: tasks.<name> …` and the loader's `<file> has unknown field …`.
|
|
29
|
+
const CONFIG_REFUSAL = /^\/\S*\/vx\.(?:config|workspace)\.[cm]?[jt]s\b/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The stable code of anything `bin.ts` catches. A UserError's own, but
|
|
33
|
+
* `VX_E_CONFIG` for a codeless one that names its config file; then the
|
|
34
|
+
* environment's refusals; anything else is a defect, `VX_E_INTERNAL`. A
|
|
35
|
+
* UserError from another copy of core has no `code` when that copy
|
|
36
|
+
* predates it.
|
|
37
|
+
*/
|
|
38
|
+
export function errorCode(err: unknown): string {
|
|
39
|
+
if (isUserError(err)) {
|
|
40
|
+
const code = (err as { code?: unknown }).code
|
|
41
|
+
if (typeof code === 'string' && code !== 'VX_E_REFUSED') return code
|
|
42
|
+
return CONFIG_REFUSAL.test(err.message) ? 'VX_E_CONFIG' : 'VX_E_REFUSED'
|
|
43
|
+
}
|
|
44
|
+
if (isOutOfFds(err)) return 'VX_E_FDS'
|
|
45
|
+
if (isFsRefusal(err)) return 'VX_E_FS'
|
|
46
|
+
return 'VX_E_INTERNAL'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Does this argv ask for JSON (`--format json`, `--format=json`,
|
|
51
|
+
* `--dry=json`)? Then a refusal is a JSON document on stdout too, so a
|
|
52
|
+
* reader of stdout gets an answer, not nothing. Args after `--` are the
|
|
53
|
+
* task's, not vx's.
|
|
54
|
+
*/
|
|
55
|
+
export function wantsJson(argv: readonly string[]): boolean {
|
|
56
|
+
for (let i = 0; i < argv.length; i++) {
|
|
57
|
+
const a = argv[i]!
|
|
58
|
+
if (a === '--') return false
|
|
59
|
+
if (a === '--format=json' || a === '--dry=json') return true
|
|
60
|
+
if (a === '--format' && argv[i + 1] === 'json') return true
|
|
61
|
+
}
|
|
62
|
+
return false
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The JSON a verb that answers JSON prints for a refusal: one line. */
|
|
66
|
+
export function errorDocument(code: string, message: string): string {
|
|
67
|
+
return `${JSON.stringify({ ok: false, error: { code, message } })}\n`
|
|
68
|
+
}
|
|
69
|
+
|
|
16
70
|
/**
|
|
17
71
|
* `instanceof UserError`, plus the same class arriving from ANOTHER COPY of
|
|
18
72
|
* core. A compiled `vx` binary carries core inside it while a plugin in the
|
package/src/util/index.ts
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
export {
|
|
5
5
|
DISK_FULL_HINT,
|
|
6
|
+
errorCode,
|
|
7
|
+
errorDocument,
|
|
6
8
|
fsRefusalHint,
|
|
7
9
|
isDiskFull,
|
|
8
10
|
isFsRefusal,
|
|
@@ -17,6 +19,7 @@ export {
|
|
|
17
19
|
isOutOfFds,
|
|
18
20
|
notAWorkTree,
|
|
19
21
|
OUT_OF_FDS_HINT,
|
|
22
|
+
wantsJson,
|
|
20
23
|
} from './errors.js'
|
|
21
24
|
export { configErrorFrame } from './config-frame.js'
|
|
22
25
|
export { xxh3, xxh3hex } from './hash.js'
|