@vzn/vx 0.0.631 → 0.0.632
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 +7 -8
- package/package.json +7 -5
- package/schemas/cache.json +24 -0
- package/schemas/error.json +23 -0
- package/schemas/info.json +147 -0
- package/schemas/last.json +188 -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 +85 -0
- package/src/bin.ts +9 -0
- package/src/cache/archive.ts +153 -51
- package/src/cache/cache.ts +45 -19
- package/src/cache/layer.ts +2 -1
- package/src/cache/schema.ts +2 -1
- 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/foreign-flags.ts +1 -1
- package/src/cli/help.ts +30 -5
- package/src/cli/index.ts +23 -11
- package/src/cli/info.ts +38 -9
- package/src/cli/init.ts +97 -19
- package/src/cli/last.ts +54 -3
- 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 +1 -1
- 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 +1 -1
- 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 +65 -0
- 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/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/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/workspace/workspace.ts +1 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// A failed task's output with each file it names made a terminal link
|
|
2
|
+
// (OSC 8). A tool prints paths relative to where it ran, the task's
|
|
3
|
+
// project, so the terminal's own path detection, which resolves against
|
|
4
|
+
// the shell's directory, opens the wrong file or none in a monorepo. The
|
|
5
|
+
// visible text stays the task's bytes; only the link target is vx's.
|
|
6
|
+
|
|
7
|
+
import { statSync } from 'node:fs'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
|
|
10
|
+
// `src/a.ts`, `./a.ts:12`, `/abs/a.ts:12:5`, tsc's `src/a.ts(12,5)`. Not
|
|
11
|
+
// after a word, dot, slash or `@`, so a URL's path or a scoped name's tail
|
|
12
|
+
// never starts a match. Groups: file, then line and column of either form.
|
|
13
|
+
const PATH =
|
|
14
|
+
/(?<![\w./@+-])((?:\/|\.{1,2}\/)?(?:[\w@.+-]+\/)*[\w@+-][\w@.+-]*\.[A-Za-z][A-Za-z0-9]{0,9})(?::(\d+)(?::(\d+))?|\((\d+),(\d+)\))?/g
|
|
15
|
+
|
|
16
|
+
/** A file a task's output names; `file` absolute. */
|
|
17
|
+
export interface OutputLocation {
|
|
18
|
+
file: string
|
|
19
|
+
line?: number
|
|
20
|
+
col?: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Most locations one output reports: past it, the first ones are the cause. */
|
|
24
|
+
const LOCATIONS_CAP = 50
|
|
25
|
+
|
|
26
|
+
/** `text` with every path that names a file under `dir` (or an absolute one) linked. */
|
|
27
|
+
export function linkPaths(text: string, dir: string, isFile: (abs: string) => boolean): string {
|
|
28
|
+
if (text.includes('\x1b]8;')) return text
|
|
29
|
+
return text.replace(PATH, (match: string, file: string) => {
|
|
30
|
+
const abs = path.resolve(dir, file)
|
|
31
|
+
if (!isFile(abs)) return match
|
|
32
|
+
return `\x1b]8;;file://${encodeURI(abs)}\x1b\\${match}\x1b]8;;\x1b\\`
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The files `text` names that exist (resolved against `dir`), each with the
|
|
38
|
+
* line and column it gave, unique, in order of first appearance.
|
|
39
|
+
*/
|
|
40
|
+
export function fileLocations(
|
|
41
|
+
text: string,
|
|
42
|
+
dir: string,
|
|
43
|
+
isFile: (abs: string) => boolean,
|
|
44
|
+
): OutputLocation[] {
|
|
45
|
+
const out: OutputLocation[] = []
|
|
46
|
+
const seen = new Set<string>()
|
|
47
|
+
for (const m of text.matchAll(PATH)) {
|
|
48
|
+
const file = path.resolve(dir, m[1]!)
|
|
49
|
+
const line = m[2] ?? m[4]
|
|
50
|
+
const col = m[3] ?? m[5]
|
|
51
|
+
const id = `${file}:${line}:${col}`
|
|
52
|
+
if (seen.has(id) || !isFile(file)) continue
|
|
53
|
+
seen.add(id)
|
|
54
|
+
out.push({
|
|
55
|
+
file,
|
|
56
|
+
...(line !== undefined ? { line: Number(line) } : {}),
|
|
57
|
+
...(col !== undefined ? { col: Number(col) } : {}),
|
|
58
|
+
})
|
|
59
|
+
if (out.length === LOCATIONS_CAP) break
|
|
60
|
+
}
|
|
61
|
+
return out
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A file test that asks the file system once per path. */
|
|
65
|
+
export function fileMemo(): (abs: string) => boolean {
|
|
66
|
+
const seen = new Map<string, boolean>()
|
|
67
|
+
return (abs) => {
|
|
68
|
+
let known = seen.get(abs)
|
|
69
|
+
if (known === undefined) {
|
|
70
|
+
try {
|
|
71
|
+
known = statSync(abs).isFile()
|
|
72
|
+
} catch {
|
|
73
|
+
known = false
|
|
74
|
+
}
|
|
75
|
+
seen.set(abs, known)
|
|
76
|
+
}
|
|
77
|
+
return known
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/orchestrator/plan.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type { CacheLayer, CachePolicy, GitFilesCache } from '../cache/index.js'
|
|
|
15
15
|
import { cachesNothing, FULL_CACHE_POLICY } from '../cache/index.js'
|
|
16
16
|
import { ranNoCache } from './events.js'
|
|
17
17
|
import { idleServers, isGroupTask, runGraph, type TaskNode } from '../graph/index.js'
|
|
18
|
+
import type { AffectedReason } from './affected-tasks.js'
|
|
18
19
|
import type { HistoryProvider } from './history.js'
|
|
19
20
|
import { computeGroupKey, computeTaskHash } from './task-hash.js'
|
|
20
21
|
import { keyedOutcome, keyUpstream } from './upstream.js'
|
|
@@ -53,6 +54,8 @@ export interface PlannedTask {
|
|
|
53
54
|
* eager task is the default and saying so on every line says nothing.
|
|
54
55
|
*/
|
|
55
56
|
download?: 'deferred'
|
|
57
|
+
/** Why `--affected` kept this requested task; absent without `--affected` and on a dependency it pulled in. */
|
|
58
|
+
affected?: AffectedReason
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
/**
|
|
@@ -68,6 +71,9 @@ export interface PlanPrediction {
|
|
|
68
71
|
/** Would-run tasks with NO history — their cost is unknown and counted as
|
|
69
72
|
* 0, so wallMs/workMs are lower bounds when this is > 0. */
|
|
70
73
|
unknownCount: number
|
|
74
|
+
/** The would-run tasks with history along the chain `wallMs` measures,
|
|
75
|
+
* dependencies first; empty when nothing with history would run. */
|
|
76
|
+
criticalPath: string[]
|
|
71
77
|
}
|
|
72
78
|
|
|
73
79
|
export interface RunPlan {
|
|
@@ -271,19 +277,27 @@ function predictPlan(tasks: PlannedTask[]): PlanPrediction {
|
|
|
271
277
|
const dist = new Map<string, number>()
|
|
272
278
|
const queue: string[] = []
|
|
273
279
|
for (const [id, n] of indeg) if (n === 0) queue.push(id)
|
|
280
|
+
const via = new Map<string, string>()
|
|
274
281
|
let head = 0
|
|
275
282
|
let wallMs = 0
|
|
283
|
+
let end: string | undefined
|
|
276
284
|
while (head < queue.length) {
|
|
277
285
|
const id = queue[head++]!
|
|
278
286
|
const t = byId.get(id)!
|
|
279
287
|
let best = 0
|
|
280
288
|
for (const d of t.deps) {
|
|
281
289
|
const v = dist.get(d)
|
|
282
|
-
if (v !== undefined && v > best)
|
|
290
|
+
if (v !== undefined && v > best) {
|
|
291
|
+
best = v
|
|
292
|
+
via.set(id, d)
|
|
293
|
+
}
|
|
283
294
|
}
|
|
284
295
|
const v = best + cost(t)
|
|
285
296
|
dist.set(id, v)
|
|
286
|
-
if (v > wallMs)
|
|
297
|
+
if (v > wallMs) {
|
|
298
|
+
wallMs = v
|
|
299
|
+
end = id
|
|
300
|
+
}
|
|
287
301
|
for (const dep of dependents.get(id) ?? []) {
|
|
288
302
|
const n = indeg.get(dep)! - 1
|
|
289
303
|
indeg.set(dep, n)
|
|
@@ -297,5 +311,8 @@ function predictPlan(tasks: PlannedTask[]): PlanPrediction {
|
|
|
297
311
|
if (t.p50Ms === undefined) unknownCount++
|
|
298
312
|
else workMs += t.p50Ms
|
|
299
313
|
}
|
|
300
|
-
|
|
314
|
+
const criticalPath: string[] = []
|
|
315
|
+
for (let id = end; id !== undefined; id = via.get(id))
|
|
316
|
+
if (cost(byId.get(id)!) > 0) criticalPath.push(id)
|
|
317
|
+
return { wallMs, workMs, unknownCount, criticalPath: criticalPath.reverse() }
|
|
301
318
|
}
|
|
@@ -251,6 +251,13 @@ export interface CommandContext extends BaseContext {
|
|
|
251
251
|
* `machineParallelism()` said 4 cores beside `concurrency: 1` (item 1068).
|
|
252
252
|
*/
|
|
253
253
|
readonly concurrency: number
|
|
254
|
+
/**
|
|
255
|
+
* The argv that runs this vx again: the binary alone when compiled, the
|
|
256
|
+
* runtime and its entry script from source. A verb that runs tasks
|
|
257
|
+
* (`vx mcp`'s `runTasks`) spawns `[...vx, 'run', …]` rather than guess
|
|
258
|
+
* which `vx` a PATH holds.
|
|
259
|
+
*/
|
|
260
|
+
readonly vx: readonly string[]
|
|
254
261
|
}
|
|
255
262
|
|
|
256
263
|
/** `config` runs before the cache dir is known — it may be what the hook changes. */
|
|
@@ -74,6 +74,7 @@ import type { VxPlugin } from './plugin.js'
|
|
|
74
74
|
import { createHashCache, type HashCache } from './task-hash.js'
|
|
75
75
|
import type { Logger } from './logger.js'
|
|
76
76
|
import type { RunOptions } from './options.js'
|
|
77
|
+
import { refuseFiltersThroughGroups } from './upstream.js'
|
|
77
78
|
|
|
78
79
|
export interface PreparedRun {
|
|
79
80
|
workspaceRoot: string
|
|
@@ -549,10 +550,18 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
549
550
|
if (options.affected !== undefined) {
|
|
550
551
|
const before = hooksFirst ? new Set(nodes.keys()) : null
|
|
551
552
|
if (hooksFirst) await graphStage()
|
|
552
|
-
const kept = keptByAffected(
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
553
|
+
const kept = keptByAffected(
|
|
554
|
+
nodes,
|
|
555
|
+
requested,
|
|
556
|
+
options.affected,
|
|
557
|
+
projects,
|
|
558
|
+
packageGraph,
|
|
559
|
+
{
|
|
560
|
+
named: new Set(tasks.filter((t) => t.includes('#'))),
|
|
561
|
+
outright: new Set(options.selectedOutright),
|
|
562
|
+
},
|
|
563
|
+
options.affectedReasons && { workspaceRoot, reasons: options.affectedReasons },
|
|
564
|
+
)
|
|
556
565
|
if (kept.length === 0) return emptyRun('none-affected')
|
|
557
566
|
if (before !== null) {
|
|
558
567
|
const asked = new Set(requested.map((r) => `${r.project}#${r.task}`))
|
|
@@ -656,6 +665,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
656
665
|
if (hasHook(plugins, 'key')) {
|
|
657
666
|
await applyKeyHooks(plugins, nodes, { workspaceRoot, cacheDir, warn: (m) => log.status(m) })
|
|
658
667
|
}
|
|
668
|
+
refuseFiltersThroughGroups(nodes)
|
|
659
669
|
const exclude = options.excludeDependencies
|
|
660
670
|
let keyOnly: ReadonlyMap<string, TaskNode> = new Map()
|
|
661
671
|
if (exclude !== undefined && (exclude === 'all' || exclude.length > 0)) {
|
|
@@ -52,6 +52,7 @@ export interface SummaryTaskJson {
|
|
|
52
52
|
flaky?: { passes: number; failures: number; attempts: number }
|
|
53
53
|
cpuMs?: number
|
|
54
54
|
peakRssBytes?: number
|
|
55
|
+
storedDurationMs?: number
|
|
55
56
|
storedCpuMs?: number
|
|
56
57
|
storedPeakRssBytes?: number
|
|
57
58
|
admissionHeldMs?: number
|
|
@@ -71,6 +72,8 @@ export interface RunSummaryJson {
|
|
|
71
72
|
startedAt: string
|
|
72
73
|
endedAt: string
|
|
73
74
|
totalMs: number
|
|
75
|
+
/** What the hits skipped: the sum of their `storedDurationMs`. */
|
|
76
|
+
savedMs: number
|
|
74
77
|
tasks: SummaryTaskJson[]
|
|
75
78
|
aborted: SummaryTaskJson[]
|
|
76
79
|
summary: Tally
|
|
@@ -105,6 +108,7 @@ function taskEntry(o: TaskOutcome, flaky?: FlakyFinding): SummaryTaskJson {
|
|
|
105
108
|
...(o.peakRssBytes !== undefined ? { peakRssBytes: o.peakRssBytes } : {}),
|
|
106
109
|
// A hit's row names what the PRODUCING execution used under its own
|
|
107
110
|
// keys, so a consumer never reads a remote worker's peak as this run's.
|
|
111
|
+
...(o.storedDurationMs !== undefined ? { storedDurationMs: o.storedDurationMs } : {}),
|
|
108
112
|
...(o.storedCpuMs !== undefined ? { storedCpuMs: o.storedCpuMs } : {}),
|
|
109
113
|
...(o.storedPeakRssBytes !== undefined ? { storedPeakRssBytes: o.storedPeakRssBytes } : {}),
|
|
110
114
|
// Present only when an `admit` policy held the task with a worker free.
|
|
@@ -130,6 +134,14 @@ export async function writeRunSummary(args: SummarizeArgs): Promise<string> {
|
|
|
130
134
|
args.target === ''
|
|
131
135
|
? path.join(args.cacheDir, 'runs', `${args.runId}.json`)
|
|
132
136
|
: path.resolve(args.cwd, args.target)
|
|
137
|
+
await Bun.write(outPath, JSON.stringify(runSummaryJson(args), null, 2))
|
|
138
|
+
return outPath
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The `--summarize` document, which `vx run --format json` prints. */
|
|
142
|
+
export function runSummaryJson(
|
|
143
|
+
args: Omit<SummarizeArgs, 'target' | 'cacheDir' | 'cwd'>,
|
|
144
|
+
): RunSummaryJson {
|
|
133
145
|
// `tasks` and `summary` must describe the same population, or one artifact
|
|
134
146
|
// contradicts itself (a group task listed in `tasks` but absent from
|
|
135
147
|
// `summary.total`). `tallyOutcomes` owns the rule; mirror its filter here.
|
|
@@ -151,6 +163,11 @@ export async function writeRunSummary(args: SummarizeArgs): Promise<string> {
|
|
|
151
163
|
startedAt: new Date(args.startedAtMs).toISOString(),
|
|
152
164
|
endedAt: new Date(args.endedAtMs).toISOString(),
|
|
153
165
|
totalMs: args.totalMs,
|
|
166
|
+
// The summary row's `saved`, by the same rule: hits only, groups aside.
|
|
167
|
+
savedMs: counted.reduce(
|
|
168
|
+
(ms, o) => (isCacheHit(o.status) ? ms + (o.storedDurationMs ?? 0) : ms),
|
|
169
|
+
0,
|
|
170
|
+
),
|
|
154
171
|
tasks: counted.map((o) => taskEntry(o, flakyById.get(o.node.id))),
|
|
155
172
|
aborted: aborted.map((o) => taskEntry(o)),
|
|
156
173
|
// The FULL list: `tallyOutcomes` applies the same group/aborted
|
|
@@ -158,8 +175,7 @@ export async function writeRunSummary(args: SummarizeArgs): Promise<string> {
|
|
|
158
175
|
// it — but `summary.aborted` is only non-zero if it sees them.
|
|
159
176
|
summary: tallyOutcomes(args.outcomes),
|
|
160
177
|
}
|
|
161
|
-
|
|
162
|
-
return outPath
|
|
178
|
+
return payload
|
|
163
179
|
}
|
|
164
180
|
|
|
165
181
|
export interface ProfileArgs {
|
|
@@ -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 } : {}),
|