@vzn/vx 0.0.630 → 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.
Files changed (62) hide show
  1. package/README.md +7 -8
  2. package/package.json +7 -5
  3. package/schemas/cache.json +24 -0
  4. package/schemas/error.json +23 -0
  5. package/schemas/info.json +147 -0
  6. package/schemas/last.json +188 -0
  7. package/schemas/plan.json +88 -0
  8. package/schemas/show.json +124 -0
  9. package/schemas/summary.json +133 -0
  10. package/schemas/why.json +168 -0
  11. package/skills/vx/SKILL.md +85 -0
  12. package/src/bin.ts +9 -0
  13. package/src/cache/archive.ts +153 -51
  14. package/src/cache/cache.ts +45 -19
  15. package/src/cache/layer.ts +2 -1
  16. package/src/cache/schema.ts +2 -1
  17. package/src/cache/tar-stream.ts +204 -66
  18. package/src/cache/zstd.ts +1 -1
  19. package/src/cli/cache.ts +14 -14
  20. package/src/cli/foreign-flags.ts +1 -1
  21. package/src/cli/help.ts +30 -5
  22. package/src/cli/index.ts +23 -11
  23. package/src/cli/info.ts +38 -9
  24. package/src/cli/init.ts +97 -19
  25. package/src/cli/last.ts +54 -3
  26. package/src/cli/plan-format.ts +23 -1
  27. package/src/cli/plugin-commands.ts +3 -0
  28. package/src/cli/plugin-templates.ts +1 -1
  29. package/src/cli/run.ts +40 -21
  30. package/src/cli/show.ts +10 -7
  31. package/src/cli/upgrade.ts +1 -1
  32. package/src/cli/watch.ts +7 -2
  33. package/src/cli/why.ts +14 -40
  34. package/src/exec/runner.ts +27 -10
  35. package/src/graph/scheduler.ts +36 -0
  36. package/src/graph/task-graph.ts +1 -1
  37. package/src/index.ts +22 -5
  38. package/src/orchestrator/admission.ts +29 -1
  39. package/src/orchestrator/affected-tasks.ts +87 -18
  40. package/src/orchestrator/doctor.ts +14 -1
  41. package/src/orchestrator/execute-task.ts +149 -64
  42. package/src/orchestrator/failure-mode.ts +5 -3
  43. package/src/orchestrator/framed-output.ts +17 -3
  44. package/src/orchestrator/index.ts +11 -0
  45. package/src/orchestrator/logger.ts +57 -6
  46. package/src/orchestrator/metrics.ts +65 -0
  47. package/src/orchestrator/options.ts +12 -0
  48. package/src/orchestrator/output-log.ts +14 -1
  49. package/src/orchestrator/path-links.ts +79 -0
  50. package/src/orchestrator/plan.ts +20 -3
  51. package/src/orchestrator/plugin.ts +7 -0
  52. package/src/orchestrator/prepare.ts +14 -4
  53. package/src/orchestrator/run-artifacts.ts +18 -2
  54. package/src/orchestrator/run-failures.ts +192 -0
  55. package/src/orchestrator/run-records.ts +10 -8
  56. package/src/orchestrator/run.ts +87 -22
  57. package/src/orchestrator/terminal-signals.ts +16 -0
  58. package/src/orchestrator/upstream.ts +65 -10
  59. package/src/util/errors.ts +55 -1
  60. package/src/util/index.ts +3 -0
  61. package/src/util/secret-mask.ts +13 -1
  62. 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
+ }
@@ -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) best = v
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) wallMs = v
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
- return { wallMs, workMs, unknownCount }
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(nodes, requested, options.affected, projects, packageGraph, {
553
- named: new Set(tasks.filter((t) => t.includes('#'))),
554
- outright: new Set(options.selectedOutright),
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
- await Bun.write(outPath, JSON.stringify(payload, null, 2))
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
- /** Tasks that ran behind a failed dependency (`--continue=always`). */
18
- tainted?: ReadonlySet<string>
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
- // A failure behind a failed dependency is recorded keyless (`''`, which
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
- ...(o.blockedBy !== undefined ? { blockedBy: o.blockedBy } : {}),
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 } : {}),