@vzn/vx 0.0.649 → 0.0.651

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 (122) hide show
  1. package/README.md +5 -20
  2. package/package.json +5 -5
  3. package/schemas/last.json +15 -0
  4. package/src/bin.ts +9 -31
  5. package/src/cache/cache.ts +225 -51
  6. package/src/cache/file-hashes.ts +3 -0
  7. package/src/cache/fold-memo.ts +8 -1
  8. package/src/cache/git-inputs.ts +188 -39
  9. package/src/cache/index.ts +12 -0
  10. package/src/cache/inputs.ts +3 -0
  11. package/src/cache/layer.ts +11 -0
  12. package/src/cache/layered-cache.ts +4 -0
  13. package/src/cache/linked-inputs.ts +3 -0
  14. package/src/cache/lockfile-memo.ts +120 -0
  15. package/src/cache/output-index.ts +5 -2
  16. package/src/cache/remote-hits.ts +9 -1
  17. package/src/cache/remote-lane.ts +2 -1
  18. package/src/cache/run-history.ts +11 -1
  19. package/src/cache/run-notes.ts +123 -0
  20. package/src/cache/schema.ts +54 -1
  21. package/src/cli/cache.ts +26 -16
  22. package/src/cli/completions.ts +4 -2
  23. package/src/cli/docs.ts +10 -11
  24. package/src/cli/help.ts +12 -23
  25. package/src/cli/index.ts +4 -9
  26. package/src/cli/info.ts +15 -7
  27. package/src/cli/init.ts +23 -15
  28. package/src/cli/last.ts +224 -223
  29. package/src/cli/lock.ts +55 -5
  30. package/src/cli/plan-format.ts +100 -118
  31. package/src/cli/plugin-commands.ts +10 -3
  32. package/src/cli/plugin-templates.ts +2 -2
  33. package/src/cli/run.ts +46 -27
  34. package/src/cli/select.ts +43 -5
  35. package/src/cli/show.ts +38 -42
  36. package/src/cli/task-verb.ts +22 -0
  37. package/src/cli/upgrade.ts +3 -1
  38. package/src/cli/watch-fs.ts +6 -1
  39. package/src/cli/watch-judge.ts +2 -1
  40. package/src/cli/watch.ts +83 -44
  41. package/src/cli/why.ts +126 -92
  42. package/src/cli/workspace-config.ts +2 -1
  43. package/src/exec/executor.ts +2 -1
  44. package/src/exec/kill-tree.ts +58 -5
  45. package/src/exec/local-executor.ts +1 -1
  46. package/src/exec/pipes.ts +152 -0
  47. package/src/exec/runner.ts +199 -182
  48. package/src/exec/sandbox-binds.ts +1 -1
  49. package/src/exec/sandbox-runtime.ts +11 -2
  50. package/src/exec/spawn-off.ts +154 -0
  51. package/src/exec/warm-shell.ts +146 -0
  52. package/src/graph/critical-path.ts +40 -28
  53. package/src/graph/priorities.ts +98 -44
  54. package/src/graph/scheduler.ts +12 -2
  55. package/src/graph/task-graph.ts +27 -1
  56. package/src/handoff.ts +10 -7
  57. package/src/history/history.ts +39 -2
  58. package/src/history/index.ts +3 -0
  59. package/src/history/metrics.ts +9 -1
  60. package/src/index.ts +12 -3
  61. package/src/orchestrator/doctor.ts +3 -3
  62. package/src/orchestrator/execute-task.ts +16 -2
  63. package/src/orchestrator/graph-memo.ts +50 -1
  64. package/src/orchestrator/graph-stage.ts +6 -0
  65. package/src/orchestrator/hit-restore.ts +3 -0
  66. package/src/orchestrator/index.ts +3 -1
  67. package/src/orchestrator/live-usage.ts +98 -0
  68. package/src/orchestrator/locked-digests.ts +63 -0
  69. package/src/orchestrator/lockfile-claim.ts +176 -102
  70. package/src/orchestrator/miss-save.ts +31 -3
  71. package/src/orchestrator/persistent-task.ts +3 -1
  72. package/src/orchestrator/plan.ts +5 -1
  73. package/src/orchestrator/plugin-host.ts +61 -0
  74. package/src/orchestrator/plugin.ts +49 -4
  75. package/src/orchestrator/prepare.ts +18 -2
  76. package/src/orchestrator/projects.ts +4 -1
  77. package/src/orchestrator/run-context.ts +29 -10
  78. package/src/orchestrator/run-failures.ts +35 -114
  79. package/src/orchestrator/run-records.ts +10 -0
  80. package/src/orchestrator/run.ts +89 -26
  81. package/src/orchestrator/sandbox-request.ts +2 -2
  82. package/src/orchestrator/signals.ts +8 -5
  83. package/src/orchestrator/steps.ts +2 -2
  84. package/src/orchestrator/task-hash.ts +17 -3
  85. package/src/report/forecast.ts +12 -33
  86. package/src/report/framed-output.ts +388 -161
  87. package/src/report/index.ts +8 -2
  88. package/src/report/logger.ts +100 -36
  89. package/src/report/status-line.ts +81 -365
  90. package/src/report/summary.ts +104 -131
  91. package/src/ui/ansi.ts +48 -0
  92. package/src/ui/components.ts +506 -0
  93. package/src/ui/index.ts +79 -0
  94. package/src/ui/kit.ts +37 -0
  95. package/src/ui/live-region.ts +363 -0
  96. package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
  97. package/src/ui/terminal.ts +51 -0
  98. package/src/ui/theme.ts +224 -0
  99. package/src/ui/version-line.ts +28 -0
  100. package/src/util/config-frame.ts +65 -10
  101. package/src/util/edit-distance.ts +6 -1
  102. package/src/util/hidden-prompt.ts +8 -2
  103. package/src/util/index.ts +2 -15
  104. package/src/util/paths.ts +20 -2
  105. package/src/util/secret-mask.ts +39 -3
  106. package/src/util/secrets.ts +3 -0
  107. package/src/util/settle.ts +31 -0
  108. package/src/util/timing.ts +4 -16
  109. package/src/workspace/affected.ts +89 -30
  110. package/src/workspace/config-schema.ts +4 -2
  111. package/src/workspace/dependencies-lock.ts +191 -0
  112. package/src/workspace/filter.ts +28 -0
  113. package/src/workspace/index.ts +9 -0
  114. package/src/workspace/lockfile.ts +159 -6
  115. package/src/workspace/migration.ts +4 -3
  116. package/src/workspace/workspace.ts +3 -0
  117. package/src/orchestrator/run-steps.ts +0 -93
  118. package/src/util/colors.ts +0 -130
  119. package/src/util/report.ts +0 -44
  120. package/src/util/ui.ts +0 -258
  121. /package/src/{report/path-links.ts → ui/links.ts} +0 -0
  122. /package/src/{report → ui}/terminal-signals.ts +0 -0
@@ -20,6 +20,7 @@ import {
20
20
  UserError,
21
21
  } from '../util/index.js'
22
22
  import {
23
+ indexDb,
23
24
  Cache,
24
25
  type CacheLayer,
25
26
  applyCacheFlags,
@@ -38,6 +39,7 @@ import {
38
39
  gitPathspecs,
39
40
  MAX_SCOPED_PATHSPECS,
40
41
  startGitEnumeration,
42
+ checkBlobSizes,
41
43
  lazyGitEnumeration,
42
44
  measureFsClockLag,
43
45
  DEFAULT_STORE,
@@ -51,6 +53,7 @@ import {
51
53
  lockDigest,
52
54
  PROJECT_CONFIG_FILENAMES,
53
55
  readClaimedFiles,
56
+ readDependenciesLock,
54
57
  resolveCacheDir,
55
58
  resolveStoreRoot,
56
59
  type ProjectEntry,
@@ -75,6 +78,7 @@ import {
75
78
  teardownPlugins,
76
79
  type ScheduleRun,
77
80
  } from './plugin-host.js'
81
+ import { lockedFiles } from './locked-digests.js'
78
82
  import {
79
83
  discoverProjects,
80
84
  gitOfDiscovery,
@@ -381,6 +385,16 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
381
385
  const fingerprintsAt = Date.now()
382
386
  const claimed = [...fingerprintClaims(plugins).keys()]
383
387
  const claimedBytes = await readClaimedFiles(workspaceRoot, claimed, reads)
388
+ // What `vx lock` recorded of them: a run keys from it (locked-digests.ts).
389
+ const dependenciesLock = claimed.length > 0 ? readDependenciesLock(workspaceRoot, reads) : null
390
+ dependenciesLock?.catch(() => {})
391
+ // A whole-tree run's blob-size check starts with the cache it reads its
392
+ // verdict from, beside the config load and graph build: after them, a cold
393
+ // 100-package run waited 40-50 ms on it before its first task (2026-11-03).
394
+ if (git.started !== undefined) {
395
+ localCache.holdBlobSizes()
396
+ git.started.then((e) => checkBlobSizes(e, localCache)).catch(() => {})
397
+ }
384
398
  mark('open cache')
385
399
 
386
400
  // Configs load FROM .vx/projects.lock.json with no evaluation and no check of
@@ -775,6 +789,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
775
789
  // 1,000 projects when it ran before the spawn.
776
790
  const settleIgnored = askIgnoredOutputs(gatedOutputs(nodes, workspaceRoot), workspaceRoot)
777
791
  const enumeration = await enumerating
792
+ localCache.holdBlobSizes()
778
793
  await applyGitEnumeration(
779
794
  enumeration,
780
795
  workspaceRoot,
@@ -799,7 +814,8 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
799
814
  }
800
815
  }
801
816
  if (hasHook(plugins, 'key')) {
802
- await applyKeyHooks(plugins, nodes, { workspaceRoot, cacheDir, warn: warnOf(log) })
817
+ const locked = lockedFiles(workspaceRoot, claimed, claimedBytes, await dependenciesLock)
818
+ await applyKeyHooks(plugins, nodes, { workspaceRoot, cacheDir, warn: warnOf(log), locked })
803
819
  }
804
820
  refuseFiltersThroughGroups(nodes)
805
821
  const exclude = options.excludeDependencies
@@ -834,7 +850,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
834
850
  let own = pluginCaches.get(plugin)
835
851
  if (own === undefined) {
836
852
  own = pluginCache(plugin, {
837
- db: localCache.dbHandle(),
853
+ db: indexDb(localCache),
838
854
  policy,
839
855
  remotes,
840
856
  scope: cacheScope,
@@ -370,8 +370,11 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
370
370
  }
371
371
  continue
372
372
  }
373
- const config: ProjectConfig =
373
+ const frozen: ProjectConfig =
374
374
  meta.configPath === null ? { tasks: {} } : (loaded[next++] as ProjectConfig)
375
+ // The lock shares a task two projects declare alike: an edit in place
376
+ // works on this project's own copy.
377
+ const config = projectStage ? structuredClone(frozen) : frozen
375
378
  if (projectStage) {
376
379
  // The `project` stage edits the validated object in place; core
377
380
  // re-validates after EACH plugin so a plugin can only produce what
@@ -30,7 +30,7 @@ import type { RunContextRecord } from './telemetry.js'
30
30
  export interface GitContext {
31
31
  /** `git rev-parse HEAD`, or null outside a repo / on failure. */
32
32
  commitSha: string | null
33
- /** `git rev-parse --abbrev-ref HEAD`, or null. */
33
+ /** The branch HEAD names (`refs/heads/` stripped), with or without a commit; null when detached. */
34
34
  branch: string | null
35
35
  /** True if `git status --porcelain` was non-empty; null on failure. */
36
36
  dirty: boolean | null
@@ -61,9 +61,11 @@ export interface HostContext {
61
61
 
62
62
  /**
63
63
  * One short `git` spawn per run (not per task), behind try/catch.
64
- * `git rev-parse HEAD --abbrev-ref HEAD` returns the commit on line 1
65
- * and the branch on line 2 in a SINGLE invocation — half the spawns of
66
- * two separate `rev-parse` calls. `dirty` is NOT probed here: the run's
64
+ * `git rev-parse HEAD --symbolic-full-name HEAD` returns the commit on
65
+ * line 1 and the ref on line 2 in a SINGLE invocation — half the spawns
66
+ * of two separate `rev-parse` calls. The full name, not `--abbrev-ref`:
67
+ * a tag named like the branch made that answer `heads/main`
68
+ * (vercel/turborepo#13882). `dirty` is NOT probed here: the run's
67
69
  * `GitFilesCache` populate already ran `git status --porcelain` for
68
70
  * input enumeration, so the orchestrator passes that aggregate in
69
71
  * (`dirty`) rather than paying for a second status spawn. Pass
@@ -95,7 +97,7 @@ export function captureGitContext(
95
97
  workspaceRoot,
96
98
  'rev-parse',
97
99
  'HEAD',
98
- '--abbrev-ref',
100
+ '--symbolic-full-name',
99
101
  'HEAD',
100
102
  ],
101
103
  env: gitEnv(),
@@ -107,15 +109,19 @@ export function captureGitContext(
107
109
  const sha = lines[0]?.trim()
108
110
  const br = lines[1]?.trim()
109
111
  if (sha) commitSha = sha
110
- // `--abbrev-ref HEAD` answers the literal string `HEAD` when nothing is
111
- // checked out. Recording that verbatim INVENTS a branch: every detached
112
+ // A detached HEAD answers the literal string `HEAD`, never `refs/heads/…`.
113
+ // Recording that verbatim INVENTS a branch: every detached
112
114
  // run shares a pseudo-branch "HEAD", which is how `actions/checkout`
113
115
  // leaves a pull_request build. The trust boundary (`branch ===
114
116
  // defaultBranch`) means it is at least never mistaken for trunk, but
115
117
  // every PR collapses into one scope — so one PR's timings feed another's
116
118
  // baseline, cross-branch regression detection counts them as a single
117
119
  // branch, and a consumer grouping runs by branch reads "HEAD".
118
- if (br && br !== 'HEAD') branch = br
120
+ if (br?.startsWith('refs/heads/')) branch = br.slice('refs/heads/'.length)
121
+ } else {
122
+ // No commit yet: `rev-parse HEAD` fails, yet HEAD already names the
123
+ // branch the first commit lands on (`git branch --show-current`).
124
+ branch = unbornBranch(workspaceRoot)
119
125
  }
120
126
  } catch {
121
127
  // git unavailable / non-repo: both fields stay null.
@@ -129,6 +135,18 @@ export function captureGitContext(
129
135
  return { commitSha, branch, dirty }
130
136
  }
131
137
 
138
+ /** The branch HEAD names, asked of git only after `rev-parse HEAD` failed. */
139
+ function unbornBranch(workspaceRoot: string): string | null {
140
+ const proc = Bun.spawnSync({
141
+ cmd: [executablePath('git'), '-C', workspaceRoot, 'symbolic-ref', '--quiet', 'HEAD'],
142
+ env: gitEnv(),
143
+ stdout: 'pipe',
144
+ stderr: 'ignore',
145
+ })
146
+ const ref = proc.exitCode === 0 ? new TextDecoder().decode(proc.stdout).trim() : ''
147
+ return ref.startsWith('refs/heads/') ? ref.slice('refs/heads/'.length) : null
148
+ }
149
+
132
150
  /**
133
151
  * `HEAD`'s commit and branch from the `.git` files, or null when anything
134
152
  * about the layout is unfamiliar (the caller then spawns git). Handles a
@@ -138,7 +156,7 @@ export function captureGitContext(
138
156
  */
139
157
  function readHeadDirect(
140
158
  workspaceRoot: string,
141
- ): { commitSha: string; branch: string | null } | null {
159
+ ): { commitSha: string | null; branch: string | null } | null {
142
160
  try {
143
161
  const dirs = refDirsOf(workspaceRoot)
144
162
  if (dirs === null) return null
@@ -166,7 +184,8 @@ function readHeadDirect(
166
184
  }
167
185
  }
168
186
  }
169
- return null
187
+ // Neither a loose nor a packed ref: the branch has no commit yet.
188
+ return { commitSha: null, branch }
170
189
  } catch {
171
190
  return null
172
191
  }
@@ -1,19 +1,14 @@
1
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`). One file per failed run, `<cacheDir>/failures/<runId>.json`,
3
- // the newest 50 kept: files, not cache.db, so the index layout (and every
4
- // shared store under it) stays as it was.
2
+ // a task failed without the terminal (`vx last --format json`). Rows of
3
+ // cache.db's `failures` (cache/run-notes.ts), the newest 50 failed runs kept.
5
4
 
6
5
  import type { Database } from 'bun:sqlite'
7
- import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
8
- import path from 'node:path'
6
+ import { indexDb, type Cache, keepRunNotes, keptFailures } from '../cache/index.js'
9
7
  import { isGroupTask, type TaskOutcome } from '../graph/index.js'
10
8
  import { decodeOutputLog } from './output-log.js'
11
9
  import { PASS_STATUSES } from '../history/index.js'
12
- import { isFsRefusal, maskedLine, splitTaskId } from '../util/index.js'
13
- import { fileLocations, fileMemo, type OutputLocation } from '../report/index.js'
14
-
15
- /** Failed runs whose output is kept; run ids are UUIDv7, so names sort by age. */
16
- const KEPT = 50
10
+ import { maskedLine, splitTaskId } from '../util/index.js'
11
+ import { fileLocations, fileMemo, type OutputLocation } from '../ui/index.js'
17
12
 
18
13
  /** A failed task of a run and what it said. */
19
14
  export interface TaskFailure {
@@ -36,17 +31,19 @@ export interface RunFailures {
36
31
  tasks: TaskFailure[]
37
32
  }
38
33
 
39
- interface StoredFailure extends TaskFailure {
40
- project: string
41
- task: string
42
- }
43
-
44
- const dirOf = (cacheDir: string): string => path.join(cacheDir, 'failures')
45
-
46
- /** Each failed outcome's output as plain text and the files it names. */
47
- function failuresOf(outcomes: readonly TaskOutcome[]): StoredFailure[] {
34
+ /**
35
+ * Keep the run's failures, when any outcome carries output. A refused write
36
+ * (a full disk) costs only this output: `warn` says so and the run's
37
+ * verdict stands.
38
+ */
39
+ export function writeRunFailures(
40
+ cache: Cache,
41
+ runId: string,
42
+ outcomes: readonly TaskOutcome[],
43
+ warn: (message: string) => void,
44
+ ): void {
48
45
  const isFile = fileMemo()
49
- const out: StoredFailure[] = []
46
+ const failures = []
50
47
  for (const o of outcomes) {
51
48
  // A task failed by a moved input may have printed nothing: the input
52
49
  // is still why, and an agent reads it here.
@@ -60,76 +57,30 @@ function failuresOf(outcomes: readonly TaskOutcome[]): StoredFailure[] {
60
57
  .map((c) => c.text)
61
58
  .join(''),
62
59
  )
63
- out.push({
60
+ failures.push({
64
61
  taskId: o.node.id,
65
- project: o.node.projectName,
66
- task: o.node.taskName,
67
- exitCode: o.exitCode,
68
- ...(o.timedOut === true ? { timedOut: true as const } : {}),
69
62
  ...(o.inputMoved !== undefined ? { inputMoved: o.inputMoved } : {}),
70
63
  output,
71
64
  locations: fileLocations(output, o.node.projectDir, isFile),
72
65
  ...(o.sandboxFix !== undefined ? { sandboxFix: o.sandboxFix } : {}),
73
66
  })
74
67
  }
75
- return out
76
- }
77
-
78
- /**
79
- * Write the run's failures, when any outcome carries output, and drop all
80
- * but the newest `KEPT` files. Atomic: a reader sees the whole file or none.
81
- * A refused write (a full disk, a read-only cache dir) costs only this
82
- * output: `warn` says so and the run's verdict stands.
83
- */
84
- export function writeRunFailures(
85
- cacheDir: string,
86
- runId: string,
87
- outcomes: readonly TaskOutcome[],
88
- warn: (message: string) => void,
89
- ): void {
90
- const tasks = failuresOf(outcomes)
91
- if (tasks.length === 0) return
68
+ if (failures.length === 0) return
92
69
  try {
93
- keep(cacheDir, runId, tasks)
70
+ keepRunNotes(indexDb(cache), runId, { failures })
94
71
  } catch (err) {
95
- if (!isFsRefusal(err)) throw err
96
- warn(`[vx] failed tasks' output not kept: ${err.message} — the verdict above stands`)
72
+ const message = err instanceof Error ? err.message : String(err)
73
+ warn(`[vx] failed tasks' output not kept: ${message} — the verdict above stands`)
97
74
  }
98
75
  }
99
76
 
100
- function keep(cacheDir: string, runId: string, tasks: TaskFailure[]): void {
101
- const dir = dirOf(cacheDir)
102
- mkdirSync(dir, { recursive: true })
103
- const file = path.join(dir, `${runId}.json`)
104
- const tmp = `${file}.${process.pid}.tmp`
105
- writeFileSync(tmp, JSON.stringify({ runId, tasks }))
106
- renameSync(tmp, file)
107
- trimKept(dir, KEPT)
108
- }
109
-
110
- /**
111
- * Keep the newest `kept` runs' files in `dir`, and the temp files of runs
112
- * no older: a writer that crashed between its write and its rename left
113
- * `<runId>.json.<pid>.tmp`, which the trim of `.json` names never took. A
114
- * live writer's temp is of a run newer than every kept one.
115
- */
116
- export function trimKept(dir: string, kept: number): void {
117
- const all = readdirSync(dir)
118
- const names = all.filter((n) => n.endsWith('.json')).sort()
119
- for (const n of names.slice(0, -kept)) rmSync(path.join(dir, n), { force: true })
120
- if (names.length < kept) return
121
- const oldest = names[names.length - kept]!
122
- for (const n of all)
123
- if (n.endsWith('.tmp') && n.slice(0, n.lastIndexOf('.json.') + 5) < oldest)
124
- rmSync(path.join(dir, n), { force: true })
125
- }
126
-
127
77
  /**
128
78
  * The failed tasks of `runId`, or of the latest failed run when omitted;
129
- * null when there is no such run. A failed task the file lacks (an older
130
- * run, a write the disk refused) reads `output: ''`, `locations: []`.
79
+ * null when there is no such run. A failed task with no kept row (a run
80
+ * past the newest 50, a write the disk refused) reads `output: ''`,
81
+ * `locations: []`.
131
82
  */
132
- export function runFailures(cacheDir: string, db: Database, runId?: string): RunFailures | null {
83
+ export function runFailures(db: Database, runId?: string): RunFailures | null {
133
84
  const id =
134
85
  runId ??
135
86
  (
@@ -138,7 +89,7 @@ export function runFailures(cacheDir: string, db: Database, runId?: string): Run
138
89
  .get() as { run_id: string } | null
139
90
  )?.run_id
140
91
  if (id === undefined) return null
141
- const kept = storedFailures(cacheDir, id)
92
+ const kept = keptFailures(db, id)
142
93
  const rows = db
143
94
  .query(
144
95
  `SELECT r.project, r.task, r.exit_code AS exitCode, r.timed_out AS timedOut,
@@ -165,41 +116,16 @@ export function runFailures(cacheDir: string, db: Database, runId?: string): Run
165
116
  ...(r.timedOut === 1 ? { timedOut: true as const } : {}),
166
117
  ...(k?.inputMoved !== undefined ? { inputMoved: k.inputMoved } : {}),
167
118
  output: k?.output ?? '',
168
- locations: k?.locations ?? [],
169
- ...(k?.sandboxFix !== undefined ? { sandboxFix: k.sandboxFix } : {}),
119
+ locations: (k?.locations ?? []) as OutputLocation[],
120
+ ...(k?.sandboxFix !== undefined
121
+ ? { sandboxFix: k.sandboxFix as NonNullable<TaskOutcome['sandboxFix']> }
122
+ : {}),
170
123
  ...(r.fixedIn !== null ? { fixedIn: r.fixedIn } : {}),
171
124
  }
172
125
  }),
173
126
  }
174
127
  }
175
128
 
176
- /** What `runId`'s failures file holds, by task id; empty when the run kept none. */
177
- function storedFailures(cacheDir: string, runId: string): Map<string, StoredFailure> {
178
- const kept = new Map<string, StoredFailure>()
179
- // A run id the caller typed names a file in failures/ only.
180
- if (path.basename(runId) !== runId || runId === '..') return kept
181
- let text: string
182
- try {
183
- text = readFileSync(path.join(dirOf(cacheDir), `${runId}.json`), 'utf8')
184
- } catch (err) {
185
- // No file: the run kept no output.
186
- if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
187
- return kept
188
- }
189
- // A file the disk left torn (a rename that landed before its data, on
190
- // power loss) is output the run did not keep: it threw on every `vx last`
191
- // until 50 newer runs rotated it out.
192
- let stored: unknown
193
- try {
194
- stored = JSON.parse(text)
195
- } catch {
196
- return kept
197
- }
198
- const tasks = (stored as { tasks?: unknown } | null)?.tasks
199
- if (Array.isArray(tasks)) for (const t of tasks as StoredFailure[]) kept.set(t.taskId, t)
200
- return kept
201
- }
202
-
203
129
  /** One task's output in a recorded run, as `vx last --log` and getTaskLog read it. */
204
130
  export interface TaskLog {
205
131
  runId: string
@@ -219,12 +145,7 @@ export interface TaskLog {
219
145
  * `taskId`'s output in `runId`, or in the latest run that recorded the
220
146
  * task when omitted; null when no such run recorded it.
221
147
  */
222
- export function taskLog(
223
- cacheDir: string,
224
- db: Database,
225
- taskId: string,
226
- runId?: string,
227
- ): TaskLog | null {
148
+ export function taskLog(db: Database, taskId: string, runId?: string): TaskLog | null {
228
149
  const [project, task] = splitTaskId(taskId)
229
150
  if (project === '' || task === '') return null
230
151
  const row = db
@@ -241,9 +162,9 @@ export function taskLog(
241
162
  if (row === null) return null
242
163
  const base = { runId: row.runId, taskId, status: row.status }
243
164
  if (row.status === 'failed') {
244
- // Only the file: runFailures reads `output: ''` for a task it lacks, as
245
- // if the task printed nothing, when the run is past the newest 50.
246
- const kept = storedFailures(cacheDir, row.runId).get(taskId)
165
+ // Only the kept row: runFailures reads `output: ''` for a task it lacks,
166
+ // as if the task printed nothing, when the run is past the newest 50.
167
+ const kept = keptFailures(db, row.runId).get(taskId)
247
168
  if (kept !== undefined) return { ...base, source: 'failure', output: kept.output }
248
169
  } else if (row.hash !== '') {
249
170
  const stored = db.query('SELECT stdout FROM entry_stdout WHERE hash = ?').get(row.hash) as {
@@ -38,6 +38,8 @@ export interface RunRecordsInput {
38
38
  host: HostContext
39
39
  /** Build the telemetry mirror; a run with no sink allocates none. */
40
40
  withTelemetry: boolean
41
+ /** Each task's count of the tasks and groups downstream of it (the scheduler's): the rows' `blocks`. */
42
+ blocks?: ReadonlyMap<string, number>
41
43
  /** The run's remote failures by key, so far (`Cache.traffic`). */
42
44
  remoteTraffic?: ReadonlyMap<string, { failures: readonly string[] }>
43
45
  }
@@ -124,6 +126,14 @@ export function assembleRunRecords(input: RunRecordsInput): RunRecords {
124
126
  if (o.whereCores !== undefined) r.placementCores = o.whereCores
125
127
  if (o.whereMemoryBytes !== undefined) r.placementMemoryBytes = o.whereMemoryBytes
126
128
  if (o.cpuShare !== undefined) r.cpuShare = o.cpuShare
129
+ if (o.queuedMs !== undefined) r.queuedMs = o.queuedMs
130
+ if (o.outputBytes !== undefined) r.outputBytes = o.outputBytes
131
+ const blocks = input.blocks?.get(o.node.id)
132
+ if (blocks !== undefined) r.blocks = blocks
133
+ if (o.flaky !== undefined) {
134
+ r.flakyPasses = o.flaky.passes
135
+ r.flakyFailures = o.flaky.failures
136
+ }
127
137
  if (o.wallclockStartNs !== undefined) r.wallclockStartNs = o.wallclockStartNs
128
138
  if (o.wallclockEndNs !== undefined) r.wallclockEndNs = o.wallclockEndNs
129
139
  if (hit) r.restored = o.restored === true