@vzn/vx 0.0.16 → 0.0.21

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 (138) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +135 -124
  3. package/index.ts +10 -0
  4. package/launcher.mjs +10 -11
  5. package/package.json +11 -7
  6. package/src/bin.ts +14 -4
  7. package/src/cache/archive.ts +659 -0
  8. package/src/cache/cache.ts +769 -1101
  9. package/src/cache/chained-cache.ts +194 -0
  10. package/src/cache/config-evals.ts +89 -0
  11. package/src/cache/file-hashes.ts +262 -0
  12. package/src/cache/git-inputs.ts +637 -0
  13. package/src/cache/index.ts +24 -5
  14. package/src/cache/inputs.ts +373 -456
  15. package/src/cache/layer.ts +604 -0
  16. package/src/cache/layered-cache.ts +96 -43
  17. package/src/cache/output-index.ts +224 -0
  18. package/src/cache/policy.ts +84 -0
  19. package/src/cache/run-history.ts +147 -0
  20. package/src/cache/tar-stream.ts +358 -0
  21. package/src/cache/zstd.ts +160 -0
  22. package/src/cli/cache.ts +75 -21
  23. package/src/cli/completions.ts +104 -0
  24. package/src/cli/core-alias.ts +25 -0
  25. package/src/cli/help.ts +195 -128
  26. package/src/cli/index.ts +116 -46
  27. package/src/cli/info.ts +122 -63
  28. package/src/cli/init.ts +75 -0
  29. package/src/cli/last.ts +172 -0
  30. package/src/cli/lock.ts +2 -1
  31. package/src/cli/plan-format.ts +72 -6
  32. package/src/cli/plugin-commands.ts +103 -0
  33. package/src/cli/run.ts +245 -271
  34. package/src/cli/select.ts +332 -0
  35. package/src/cli/show.ts +144 -69
  36. package/src/cli/upgrade.ts +9 -7
  37. package/src/cli/watch.ts +858 -110
  38. package/src/cli/why.ts +223 -0
  39. package/src/cli/workspace-config.ts +126 -0
  40. package/src/config.ts +187 -150
  41. package/src/exec/env.ts +18 -1
  42. package/src/exec/executor.ts +224 -0
  43. package/src/exec/index.ts +24 -2
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +166 -40
  46. package/src/exec/sandbox-binds.ts +182 -0
  47. package/src/exec/sandbox-paths.ts +50 -0
  48. package/src/exec/sandbox-runtime.ts +642 -260
  49. package/src/exec/sandbox-violations.ts +280 -0
  50. package/src/graph/index.ts +4 -10
  51. package/src/graph/scheduler.ts +232 -203
  52. package/src/graph/task-graph.ts +222 -8
  53. package/src/index.ts +146 -153
  54. package/src/orchestrator/admission.ts +137 -0
  55. package/src/orchestrator/deferred-outputs.ts +147 -0
  56. package/src/orchestrator/doctor.ts +255 -0
  57. package/src/orchestrator/download-policy.ts +171 -0
  58. package/src/orchestrator/events.ts +72 -8
  59. package/src/orchestrator/execute-task.ts +374 -549
  60. package/src/orchestrator/failure-mode.ts +202 -0
  61. package/src/orchestrator/framed-output.ts +126 -69
  62. package/src/orchestrator/history.ts +126 -59
  63. package/src/orchestrator/hit-restore.ts +221 -0
  64. package/src/orchestrator/index.ts +77 -93
  65. package/src/orchestrator/local-shortcircuit.ts +22 -0
  66. package/src/orchestrator/lockfile-claim.ts +281 -0
  67. package/src/orchestrator/logger.ts +208 -35
  68. package/src/orchestrator/metrics.ts +102 -1388
  69. package/src/orchestrator/miss-save.ts +169 -0
  70. package/src/orchestrator/options.ts +55 -34
  71. package/src/orchestrator/persistent.ts +79 -0
  72. package/src/orchestrator/placement.ts +182 -0
  73. package/src/orchestrator/plan.ts +144 -1
  74. package/src/orchestrator/plugin-host.ts +357 -120
  75. package/src/orchestrator/plugin.ts +298 -58
  76. package/src/orchestrator/prepare.ts +202 -106
  77. package/src/orchestrator/projects.ts +259 -0
  78. package/src/orchestrator/remote-prefetch.ts +12 -8
  79. package/src/orchestrator/run-artifacts.ts +63 -16
  80. package/src/orchestrator/run-context.ts +167 -29
  81. package/src/orchestrator/run-records.ts +149 -0
  82. package/src/orchestrator/run-report.ts +74 -47
  83. package/src/orchestrator/run.ts +534 -417
  84. package/src/orchestrator/sandbox-request.ts +243 -0
  85. package/src/orchestrator/save-lane.ts +47 -0
  86. package/src/orchestrator/signals.ts +111 -0
  87. package/src/orchestrator/stable-keys.ts +129 -31
  88. package/src/orchestrator/status-line.ts +49 -9
  89. package/src/orchestrator/summary.ts +90 -26
  90. package/src/orchestrator/tally.ts +78 -35
  91. package/src/orchestrator/task-hash.ts +111 -15
  92. package/src/orchestrator/task-log-buffer.ts +288 -0
  93. package/src/orchestrator/telemetry-host.ts +34 -6
  94. package/src/orchestrator/telemetry.ts +182 -36
  95. package/src/orchestrator/upstream.ts +72 -31
  96. package/src/util/cgroup.ts +160 -0
  97. package/src/util/edit-distance.ts +70 -0
  98. package/src/util/errors.ts +15 -1
  99. package/src/util/index.ts +15 -2
  100. package/src/util/num.ts +37 -0
  101. package/src/util/paths.ts +64 -0
  102. package/src/util/settle.ts +67 -0
  103. package/src/util/size.ts +5 -1
  104. package/src/util/tail.ts +66 -0
  105. package/src/util/timing.ts +63 -0
  106. package/src/util/verbs.ts +39 -0
  107. package/src/workspace/affected.ts +250 -23
  108. package/src/workspace/config-cache.ts +313 -0
  109. package/src/workspace/config-eval.ts +217 -0
  110. package/src/workspace/config-imports.ts +162 -0
  111. package/src/workspace/config-schema.ts +837 -0
  112. package/src/workspace/filter.ts +65 -10
  113. package/src/workspace/fingerprint.ts +53 -4
  114. package/src/workspace/index.ts +42 -3
  115. package/src/workspace/lockfile.ts +25 -9
  116. package/src/workspace/migrate-scripts.ts +134 -0
  117. package/src/workspace/migration.ts +314 -0
  118. package/src/workspace/nested-dirs.ts +21 -7
  119. package/src/workspace/package-graph.ts +114 -41
  120. package/src/workspace/project-loader.ts +224 -450
  121. package/src/workspace/workspace.ts +298 -72
  122. package/src/cache/cas-backend.ts +0 -97
  123. package/src/cache/digest.ts +0 -47
  124. package/src/cache/tar.ts +0 -343
  125. package/src/cli/backend.ts +0 -23
  126. package/src/cli/mcp-rpc.ts +0 -292
  127. package/src/cli/mcp.ts +0 -92
  128. package/src/cli/migrate-nx.ts +0 -450
  129. package/src/cli/migrate-turbo.ts +0 -364
  130. package/src/cli/migrate.ts +0 -219
  131. package/src/orchestrator/devframe-surface.ts +0 -74
  132. package/src/orchestrator/predict.ts +0 -106
  133. package/src/orchestrator/protocol.ts +0 -156
  134. package/src/orchestrator/resources.ts +0 -59
  135. package/src/orchestrator/run-state.ts +0 -145
  136. package/src/orchestrator/verify.ts +0 -164
  137. package/src/orchestrator/wire-render.ts +0 -92
  138. package/src/orchestrator/wire.ts +0 -212
@@ -19,6 +19,12 @@ const CLEAR = '\x1b[2K\r'
19
19
  export interface StatusStream {
20
20
  write(chunk: string): unknown
21
21
  isTTY?: boolean
22
+ /**
23
+ * Terminal width in columns. Read fresh on every draw — `process.stdout`
24
+ * updates it on SIGWINCH, so a mid-run resize is picked up. Absent (not a
25
+ * TTY) means the region is never drawn anyway.
26
+ */
27
+ columns?: number
22
28
  }
23
29
 
24
30
  export interface OutputWriter {
@@ -68,7 +74,9 @@ export function createOutputWriter(
68
74
 
69
75
  let current: readonly string[] | null = null
70
76
  let shown = false
71
- let shownHeight = 0
77
+ // PHYSICAL rows the shown region occupies, not logical lines — see
78
+ // `regionRows`.
79
+ let shownRows = 0
72
80
  let dead = false
73
81
  let lastDraw = -Infinity
74
82
  // Trailing draw scheduled when a forced set lands inside the floor.
@@ -85,10 +93,41 @@ export function createOutputWriter(
85
93
  }
86
94
  }
87
95
 
88
- // Erase sequence for whatever is currently shown. Single line keeps
89
- // the exact legacy bytes (ESC[2K\r); a taller region moves to its
90
- // top line and clears to end of screen.
91
- const eraseSeq = (): string => (shownHeight > 1 ? `\r\x1b[${shownHeight - 1}A\x1b[J` : CLEAR)
96
+ /**
97
+ * How many PHYSICAL terminal rows `lines` occupies. The terminal wraps
98
+ * any line wider than the viewport, so a region of N logical lines can
99
+ * cover more than N rows — and erasing by the logical count moves the
100
+ * cursor up too few rows, leaving the top of the region on screen. It
101
+ * accumulates, because every redraw is short by the same amount.
102
+ *
103
+ * Both triggers are ordinary: the summary section is a fixed 62 visible
104
+ * columns (narrower terminal ⇒ every bar row wraps), and a task id is
105
+ * deliberately never truncated (long id ⇒ the worker row wraps at any
106
+ * width). Width is read per draw so a mid-run resize is handled. Width
107
+ * must be VISIBLE width — these lines carry ANSI, which occupies no
108
+ * column.
109
+ *
110
+ * Unknown width falls back to the logical count, which is a KNOWN
111
+ * residual rather than a guarantee: a wrapped line then under-erases
112
+ * exactly as it did before this function existed. It is deliberate,
113
+ * because the alternatives are worse — guessing a width over-erases on a
114
+ * wider terminal, and `ESC[J` clears to end of SCREEN, so over-erasing
115
+ * destroys output above the region instead of merely leaving junk below
116
+ * it. Reachable only on a TTY whose winsize ioctl fails, since a non-TTY
117
+ * disables the region outright.
118
+ */
119
+ const regionRows = (lines: readonly string[]): number => {
120
+ const cols = stream.columns
121
+ if (cols === undefined || cols <= 0) return lines.length
122
+ let rows = 0
123
+ for (const line of lines) rows += Math.max(1, Math.ceil(Bun.stringWidth(line) / cols))
124
+ return rows
125
+ }
126
+
127
+ // Erase sequence for whatever is currently shown. A single row keeps
128
+ // the exact legacy bytes (ESC[2K\r); anything taller moves to its
129
+ // top row and clears to end of screen.
130
+ const eraseSeq = (): string => (shownRows > 1 ? `\r\x1b[${shownRows - 1}A\x1b[J` : CLEAR)
92
131
 
93
132
  const draw = (): void => {
94
133
  // Whatever was pending is now painted — the trailing draw would
@@ -97,7 +136,7 @@ export function createOutputWriter(
97
136
  const erase = shown ? eraseSeq() : CLEAR
98
137
  stream.write(erase + current!.join('\n'))
99
138
  shown = true
100
- shownHeight = current!.length
139
+ shownRows = regionRows(current!)
101
140
  lastDraw = now()
102
141
  }
103
142
  const set = (lines: readonly string[], o: { force?: boolean }): void => {
@@ -136,7 +175,7 @@ export function createOutputWriter(
136
175
  if (shown) {
137
176
  stream.write(eraseSeq())
138
177
  shown = false
139
- shownHeight = 0
178
+ shownRows = 0
140
179
  }
141
180
  stream.write(chunk)
142
181
  if (chunk.length > 0) atLineStart = chunk.endsWith('\n')
@@ -156,7 +195,7 @@ export function createOutputWriter(
156
195
  if (shown) {
157
196
  stream.write(eraseSeq())
158
197
  shown = false
159
- shownHeight = 0
198
+ shownRows = 0
160
199
  }
161
200
  },
162
201
  }
@@ -209,13 +248,14 @@ export function formatFailureLine(
209
248
  id: string,
210
249
  durationMs: number | null,
211
250
  colors: ColorSupport = NO_COLOR,
251
+ cacheWord: 'miss' | 'no-cache' = 'miss',
212
252
  ): string {
213
253
  return formatTaskRow(
214
254
  paint(ERROR, '◼\uFE0E', colors),
215
255
  durationMs,
216
256
  'failed',
217
257
  ERROR,
218
- 'miss',
258
+ cacheWord,
219
259
  '',
220
260
  paintPinnedId(id, colors),
221
261
  colors,
@@ -4,6 +4,7 @@
4
4
  // aren't real work and would inflate "N total" misleadingly.
5
5
 
6
6
  import type { TaskOutcome } from '../graph/index.js'
7
+ import type { FlakyFinding } from './failure-mode.js'
7
8
  import { paint, type ColorSupport } from './colors.js'
8
9
  import { tallyOutcomes } from './tally.js'
9
10
  import { isGroupTask } from '../graph/index.js'
@@ -28,7 +29,7 @@ const GRADIENT_TO = [0xf4, 0x72, 0xb6] as const
28
29
  const RULE_DASHES = 57
29
30
 
30
31
  /** Total visible rule width: `\u2500 <mark> ` + dashes. Shared by summary + header. */
31
- export function gradientRule(colors: ColorSupport, mark = 'vx'): string {
32
+ function gradientRule(colors: ColorSupport, mark = 'vx'): string {
32
33
  const head = `\u2500 `
33
34
  const dashes = '\u2500'.repeat(Math.max(8, RULE_DASHES + 2 - mark.length))
34
35
  if (!colors.enabled) return `${head}${mark} ${dashes}`
@@ -101,13 +102,17 @@ export interface SummaryStats {
101
102
  upToDate: number
102
103
  restoredLocal: number
103
104
  restoredRemote: number
104
- /** Tasks that executed (success + failed) — the cache misses. */
105
+ /** Tasks that executed (success + failed) with a `cache` block — the misses. */
105
106
  miss: number
107
+ /** Executed with no `cache` block: never consulted the cache, not a miss. */
108
+ noCache?: number
106
109
  /** Still to run (live section only; 0 in the final summary) — renders
107
110
  * as a gray ▱ remainder so the live meters FILL toward the final. */
108
111
  left?: number
109
112
  /** Cache-miss duration spread (only tasks that actually executed). */
110
113
  spread: { maxMs: number; minMs: number; sumMs: number; count: number } | null
114
+ /** Tasks an `admit` policy held with a worker free, and the waits summed. Absent with no hold. */
115
+ held?: { count: number; sumMs: number }
111
116
  }
112
117
 
113
118
  /**
@@ -125,14 +130,6 @@ export interface RunContext {
125
130
  remoteCacheEnabled: boolean
126
131
  /** Total projects discovery found — the bar's denominator. */
127
132
  workspaceProjectCount?: number
128
- /**
129
- * Resource-admission budgets (`exec.resources`), set ONLY when at
130
- * least one task declared a reservation — a plain run's footer is
131
- * byte-identical. Shown on the `info` row so a packed run says what
132
- * it packed against. memBudget in bytes.
133
- */
134
- cpuBudget?: number
135
- memBudget?: number
136
133
  }
137
134
 
138
135
  export function formatSummarySection(
@@ -143,6 +140,7 @@ export function formatSummarySection(
143
140
  ): string[] {
144
141
  const hits = stats.upToDate + stats.restoredLocal + stats.restoredRemote
145
142
  const miss = stats.miss
143
+ const noCache = stats.noCache ?? 0
146
144
  const left = stats.left ?? 0
147
145
  const remainder = { n: left, color: '', glyph: '\u25b1', dim: true }
148
146
 
@@ -206,10 +204,11 @@ export function formatSummarySection(
206
204
 
207
205
  // Cache meter + numbers, fixed order: miss · up-to-date · local ·
208
206
  // remote.
209
- if (miss + hits + left + stats.skipped > 0) {
207
+ if (miss + noCache + hits + left + stats.skipped > 0) {
210
208
  const cacheBar = segmentBar(
211
209
  [
212
210
  { n: miss, color: ERROR },
211
+ { n: noCache, color: '', dim: true },
213
212
  { n: stats.upToDate, color: SUCCESS },
214
213
  { n: stats.restoredLocal, color: LOCAL },
215
214
  { n: stats.restoredRemote, color: REMOTE },
@@ -220,6 +219,7 @@ export function formatSummarySection(
220
219
  )
221
220
  const cacheParts: string[] = []
222
221
  if (miss > 0) cacheParts.push(paint(ERROR, `${miss} miss`, colors))
222
+ if (noCache > 0) cacheParts.push(paint('', `${noCache} no-cache`, colors, { dim: true }))
223
223
  if (stats.upToDate > 0) cacheParts.push(paint(SUCCESS, `${stats.upToDate} up-to-date`, colors))
224
224
  if (stats.restoredLocal > 0)
225
225
  cacheParts.push(paint(LOCAL, `${stats.restoredLocal} local`, colors))
@@ -248,9 +248,14 @@ export function formatSummarySection(
248
248
  if (context.concurrency !== undefined)
249
249
  info.push(`${context.concurrency} worker${context.concurrency === 1 ? '' : 's'}`)
250
250
  info.push(context.remoteCacheEnabled ? 'local + remote cache' : 'local cache')
251
- if (context.cpuBudget !== undefined) info.push(`cpu budget ${context.cpuBudget}`)
252
- if (context.memBudget !== undefined && Number.isFinite(context.memBudget))
253
- info.push(`mem budget ${formatBudgetBytes(context.memBudget)}`)
251
+ // The plugin's hand on the run: a task a policy held with a worker
252
+ // free would otherwise have started; without the line nothing says
253
+ // the policy acted.
254
+ if (stats.held !== undefined && stats.held.count > 0) {
255
+ info.push(
256
+ `admit held ${stats.held.count} task${stats.held.count === 1 ? '' : 's'} ${formatDuration(stats.held.sumMs)}`,
257
+ )
258
+ }
254
259
  lines.push('', row('info', join(info)), row('time', `${formatDuration(totalMs)}${spread}`))
255
260
  } else {
256
261
  lines.push('', row('time', `${formatDuration(totalMs)}${spread}`))
@@ -260,29 +265,28 @@ export function formatSummarySection(
260
265
 
261
266
  // Local byte formatter — the orchestrator can't import cli/format.ts
262
267
  // (module boundary), and the footer only needs one compact form.
263
- function formatBudgetBytes(bytes: number): string {
264
- const units = ['B', 'KB', 'MB', 'GB', 'TB']
265
- let v = bytes
266
- let u = 0
267
- while (v >= 1024 && u < units.length - 1) {
268
- v /= 1024
269
- u++
270
- }
271
- return `${u === 0 ? v : v.toFixed(1)} ${units[u]}`
272
- }
273
-
274
268
  export function formatRunSummary(
275
269
  outcomes: readonly TaskOutcome[],
276
270
  totalMs: number,
277
271
  colors: ColorSupport = NO_COLOR,
278
272
  context?: RunContext,
279
273
  ): string[] {
274
+ // Counted here, not in the Tally: that object is the `--summarize` payload
275
+ // (ten documented fields) and a task with no `cache` block is a terminal
276
+ // distinction — it never consulted the cache, so it is not a miss.
277
+ const noCache = outcomes.filter(
278
+ (o) =>
279
+ !isGroupTask(o.node) &&
280
+ (o.status === 'success' || o.status === 'failed') &&
281
+ o.node.config.cache === undefined,
282
+ ).length
280
283
  const t = tallyOutcomes(outcomes)
281
284
  // Spread over executed tasks only — `success`/`failed` statuses ran
282
285
  // a command; cache-hit statuses replayed one.
283
286
  const durations = outcomes
284
287
  .filter((o) => (o.status === 'success' || o.status === 'failed') && !isGroupTask(o.node))
285
288
  .map((o) => o.durationMs)
289
+ const heldOutcomes = outcomes.filter((o) => o.admissionHeldMs !== undefined)
286
290
  return formatSummarySection(
287
291
  {
288
292
  failed: t.failed,
@@ -292,7 +296,8 @@ export function formatRunSummary(
292
296
  upToDate: t.upToDate,
293
297
  restoredLocal: t.restoredLocal,
294
298
  restoredRemote: t.restoredRemote,
295
- miss: t.total - t.skipped - (t.cachedLocal + t.cachedRemote),
299
+ miss: t.total - t.skipped - (t.cachedLocal + t.cachedRemote) - noCache,
300
+ noCache,
296
301
  spread:
297
302
  durations.length > 0
298
303
  ? {
@@ -302,6 +307,14 @@ export function formatRunSummary(
302
307
  count: durations.length,
303
308
  }
304
309
  : null,
310
+ ...(heldOutcomes.length > 0
311
+ ? {
312
+ held: {
313
+ count: heldOutcomes.length,
314
+ sumMs: heldOutcomes.reduce((sum, o) => sum + o.admissionHeldMs!, 0),
315
+ },
316
+ }
317
+ : {}),
305
318
  },
306
319
  totalMs,
307
320
  colors,
@@ -309,6 +322,57 @@ export function formatRunSummary(
309
322
  )
310
323
  }
311
324
 
325
+ /**
326
+ * Post-summary section naming the tasks a shutdown signal killed. An aborted
327
+ * task did no work, so it is in no tally bucket and no history row — but the
328
+ * run still exits non-zero, and without this the user reads a red exit over a
329
+ * fully green summary that names nothing. The interactive Ctrl-C path never
330
+ * gets here (the signal handler exits before any outcome lands), so this only
331
+ * ever prints for a run that reached its summary WITH a task killed by some
332
+ * other signal — an external `kill`, a supervisor, a self-terminating child.
333
+ * Empty when nothing aborted.
334
+ */
335
+ export function formatAbortedSection(outcomes: readonly TaskOutcome[]): string[] {
336
+ const aborted = outcomes.filter((o) => o.status === 'aborted')
337
+ if (aborted.length === 0) return []
338
+ const lines = [
339
+ '',
340
+ ` Aborted: ${aborted.length} task${aborted.length === 1 ? '' : 's'} killed by a shutdown signal — not counted above`,
341
+ ]
342
+ for (const o of aborted) lines.push(` ✗ ${o.node.id} — exit ${o.exitCode}, nothing cached`)
343
+ return lines
344
+ }
345
+
346
+ /**
347
+ * Post-summary section naming the tasks this run proved flaky: identical
348
+ * inputs, both outcomes on record (or a retry within the run). A red run
349
+ * whose failure has passed on these exact inputs before is not a break to
350
+ * bisect, and a green run that only passed on the second attempt is not
351
+ * green — either way the footer's counts alone would say nothing. Empty
352
+ * when nothing was.
353
+ */
354
+ export function formatFlakySection(findings: readonly FlakyFinding[]): string[] {
355
+ if (findings.length === 0) return []
356
+ const lines = [
357
+ '',
358
+ ` Flaky: ${findings.length} task${findings.length === 1 ? '' : 's'} with the same inputs both passing and failing on record`,
359
+ ]
360
+ for (const f of findings) {
361
+ const times = (n: number): string => `${n}\u00d7`
362
+ const history =
363
+ f.status === 'failed'
364
+ ? `failed on inputs that passed ${times(f.passes)} before`
365
+ : f.failures > 0
366
+ ? `passed on inputs that failed ${times(f.failures)} before`
367
+ : 'passed'
368
+ const retried = f.attempts > 1 ? ` \u00b7 ${f.attempts} attempts this run` : ''
369
+ lines.push(
370
+ ` ${f.status === 'failed' ? '\u2717' : '\u2713'} ${f.taskId} \u2014 ${history}${retried}`,
371
+ )
372
+ }
373
+ return lines
374
+ }
375
+
312
376
  export function formatDuration(ms: number): string {
313
377
  if (ms < 1000) return `${Math.round(ms)}ms`
314
378
  return `${(ms / 1000).toFixed(2)}s`
@@ -1,6 +1,7 @@
1
- // Shared outcome-tally helper. Both `summary.ts` (end-of-run terminal
2
- // block) and `run-artifacts.ts` (`--summarize` JSON) count the same
3
- // numbers; centralising it keeps the two surfaces consistent.
1
+ // Shared outcome-tally helper. `summary.ts` (end-of-run terminal block),
2
+ // `run-artifacts.ts` (`--summarize` JSON) and `run-report.ts`
3
+ // (`--report=markdown`) all count the same numbers; centralising the bucket
4
+ // rules keeps the three surfaces from disagreeing about one run.
4
5
 
5
6
  import { isGroupTask, type TaskOutcome } from '../graph/index.js'
6
7
 
@@ -16,25 +17,31 @@ export interface Tally {
16
17
  /** Cache hits whose tree already matched — nothing was written. */
17
18
  upToDate: number
18
19
  cachedRemote: number
20
+ /**
21
+ * Killed by a shutdown signal. No work, so it joins no outcome bucket and
22
+ * not `total` — but it is counted, because it makes the run red: a red run
23
+ * whose every counted task succeeded is undiagnosable without this.
24
+ */
25
+ aborted: number
19
26
  /** Tasks counted toward `total`. Group tasks are excluded by default. */
20
27
  total: number
21
28
  }
22
29
 
23
30
  /**
24
- * Walk the outcome list and return a `Tally`. Group tasks (no `exec`)
25
- * are filtered out — they aren't real work, and including them
26
- * inflates the per-run "N total" count in ways that mislead the user
27
- * ("3 of 4 cached" when one was a group that ran nothing).
28
- *
29
- * Status bucket rules:
30
- * `success` → +successful
31
- * `cache-hit` → +successful, +cachedLocal
32
- * `cache-hit-remote` → +successful, +cachedRemote
33
- * `failed` → +failed
34
- * `skipped` → +skipped
31
+ * The minimum an outcome must expose to be bucketed. The in-process
32
+ * `TaskOutcome` and the serializable `OutcomeView` both satisfy it; they
33
+ * differ only in how they carry group-ness (a live node ref vs. a projected
34
+ * boolean), so each entry point supplies that and the RULE lives in one
35
+ * place. Declared structurally here rather than importing `OutcomeView`,
36
+ * which would close a module cycle (events → summary → tally).
35
37
  */
36
- export function tallyOutcomes(outcomes: readonly TaskOutcome[]): Tally {
37
- const t: Tally = {
38
+ export interface TallyItem {
39
+ status: TaskOutcome['status']
40
+ restored?: boolean
41
+ }
42
+
43
+ function emptyTally(): Tally {
44
+ return {
38
45
  successful: 0,
39
46
  failed: 0,
40
47
  skipped: 0,
@@ -43,27 +50,63 @@ export function tallyOutcomes(outcomes: readonly TaskOutcome[]): Tally {
43
50
  restoredRemote: 0,
44
51
  upToDate: 0,
45
52
  cachedRemote: 0,
53
+ aborted: 0,
46
54
  total: 0,
47
55
  }
48
- for (const o of outcomes) {
49
- if (isGroupTask(o.node)) continue
50
- // aborted (killed by a shutdown signal) is no work — excluded
51
- // from totals and every bucket, exactly like a group task.
52
- if (o.status === 'aborted') continue
53
- t.total++
54
- if (o.status === 'success') t.successful++
55
- else if (o.status === 'cache-hit') {
56
- t.successful++
57
- t.cachedLocal++
58
- if (o.restored === true) t.restoredLocal++
59
- else t.upToDate++
60
- } else if (o.status === 'cache-hit-remote') {
61
- t.successful++
62
- t.cachedRemote++
63
- if (o.restored === true) t.restoredRemote++
64
- else t.upToDate++
65
- } else if (o.status === 'failed') t.failed++
66
- else if (o.status === 'skipped') t.skipped++
56
+ }
57
+
58
+ /**
59
+ * Fold ONE outcome into the tally — the single owner of the bucket rules.
60
+ *
61
+ * Group tasks (no `exec`) aren't real work; including them inflates the
62
+ * per-run "N total" count in ways that mislead ("3 of 4 cached" when one was
63
+ * a group that ran nothing).
64
+ *
65
+ * Status bucket rules:
66
+ * `success` → +successful
67
+ * `cache-hit` → +successful, +cachedLocal
68
+ * `cache-hit-remote` → +successful, +cachedRemote
69
+ * `failed` → +failed
70
+ * `skipped` → +skipped
71
+ * `aborted` → +aborted only (no bucket, no total)
72
+ */
73
+ function fold(t: Tally, o: TallyItem, isGroup: boolean): void {
74
+ if (isGroup) return
75
+ if (o.status === 'aborted') {
76
+ t.aborted++
77
+ return
67
78
  }
79
+ t.total++
80
+ if (o.status === 'success') t.successful++
81
+ else if (o.status === 'cache-hit') {
82
+ t.successful++
83
+ t.cachedLocal++
84
+ if (o.restored === true) t.restoredLocal++
85
+ else t.upToDate++
86
+ } else if (o.status === 'cache-hit-remote') {
87
+ t.successful++
88
+ t.cachedRemote++
89
+ if (o.restored === true) t.restoredRemote++
90
+ else t.upToDate++
91
+ } else if (o.status === 'failed') t.failed++
92
+ else if (o.status === 'skipped') t.skipped++
93
+ }
94
+
95
+ /** Walk live outcomes and return a `Tally`. */
96
+ export function tallyOutcomes(outcomes: readonly TaskOutcome[]): Tally {
97
+ const t = emptyTally()
98
+ for (const o of outcomes) fold(t, o, isGroupTask(o.node))
99
+ return t
100
+ }
101
+
102
+ /**
103
+ * The same tally over the serializable projection (`OutcomeView`), which
104
+ * carries group-ness as a flag because it has no node back-ref. An absent
105
+ * flag reads as "not a group" — the only outcomes without one predate the
106
+ * field and none of them are groups.
107
+ */
108
+ export function tallyViews(views: readonly (TallyItem & { isGroup?: boolean })[]): Tally {
109
+ const t = emptyTally()
110
+ for (const v of views) fold(t, v, v.isGroup === true)
68
111
  return t
69
112
  }
@@ -1,9 +1,17 @@
1
1
  import path from 'node:path'
2
2
  import type { TaskConfig, CacheConfig } from '../config.js'
3
- import { type CacheLayer, resolveInputs, type GitFilesCache } from '../cache/index.js'
3
+ import type { WorkspaceFilesCache } from '../cache/index.js'
4
+ import {
5
+ type CacheKeyInput,
6
+ type CacheLayer,
7
+ resolveInputs,
8
+ type GitFilesCache,
9
+ WORKSPACE_OUTPUT_PREFIX,
10
+ } from '../cache/index.js'
11
+ import type { InputFile, TaskInputs } from '../exec/index.js'
4
12
  import type { TaskNode, TaskOutcome } from '../graph/index.js'
5
- import { xxh3hex } from '../util/index.js'
6
- import { filterUpstreamHashes } from './upstream.js'
13
+ import { span, relPosix, xxh3hex } from '../util/index.js'
14
+ import { expandGroupUpstream, filterUpstreamHashes } from './upstream.js'
7
15
 
8
16
  /**
9
17
  * Per-run memoization caches for the two derived hashes that don't
@@ -25,6 +33,7 @@ export interface HashCache {
25
33
  taskConfig: WeakMap<TaskConfig, string>
26
34
  runtime: Map<string, Promise<string>>
27
35
  workspaceRuntime: Map<string, Promise<string>>
36
+ workspaceFiles: WorkspaceFilesCache
28
37
  }
29
38
 
30
39
  export function createHashCache(): HashCache {
@@ -33,6 +42,7 @@ export function createHashCache(): HashCache {
33
42
  taskConfig: new WeakMap(),
34
43
  runtime: new Map(),
35
44
  workspaceRuntime: new Map(),
45
+ workspaceFiles: new Map(),
36
46
  }
37
47
  }
38
48
 
@@ -83,6 +93,73 @@ export interface ComputeHashArgs {
83
93
  * `computeGroupHash` is exported for that purpose.
84
94
  */
85
95
  export async function computeTaskHash(args: ComputeHashArgs): Promise<string> {
96
+ const end = span('task hash')
97
+ try {
98
+ return await args.cache.key(await resolveKeyInput(args))
99
+ } finally {
100
+ end()
101
+ }
102
+ }
103
+
104
+ /**
105
+ * The key AND the structured input set behind it, for the executor seam.
106
+ * Miss path only: it re-runs the (memoized) resolution `computeTaskHash`
107
+ * did for the probe and retains the VALUES the key folded — env, runtime
108
+ * output, per-file digests — which `captureInto` deliberately reduces to
109
+ * digests because its rows are persisted. Per-file digests come from the
110
+ * same sources as the fold: the index-OID map for clean tracked files,
111
+ * else `hashFile`'s stat memo (already warm after `key()`).
112
+ */
113
+ export async function describeTaskInputs(
114
+ args: ComputeHashArgs,
115
+ ): Promise<{ hash: string; inputs: TaskInputs }> {
116
+ const input = await resolveKeyInput(args)
117
+ const hash = await args.cache.key(input)
118
+ const sorted = [...input.inputFiles].sort()
119
+ const digests = await Promise.all(
120
+ sorted.map((f) => input.fileHashes?.get(f) ?? args.cache.hashFile(f)),
121
+ )
122
+ const files: InputFile[] = sorted.map((f, i) => ({
123
+ path: relPosix(input.workspaceRoot, f),
124
+ digest: digests[i]!,
125
+ }))
126
+ // Groups expanded: this list describes the INPUT ROOT, not the key, and a
127
+ // group produces nothing of its own (see `CacheKeyInput.upstreamGraft`).
128
+ // Sorted by hash so the list — and any action digest derived from it — is
129
+ // independent of dependency declaration order.
130
+ const graft = [...(input.upstreamGraft ?? [])].sort((a, b) =>
131
+ a.hash < b.hash ? -1 : a.hash > b.hash ? 1 : 0,
132
+ )
133
+ // Output lists come from the local index — one SELECT for every upstream
134
+ // entry. An upstream with no entry (non-cacheable) contributes an empty list.
135
+ const rows = args.cache.loadOutputFilesBatch(graft.map((g) => g.hash))
136
+ const upstream = graft.map((g) => {
137
+ const outputs = (rows.get(g.hash) ?? []).map((r) =>
138
+ r.path.startsWith(WORKSPACE_OUTPUT_PREFIX)
139
+ ? r.path.slice(WORKSPACE_OUTPUT_PREFIX.length)
140
+ : relPosix(input.workspaceRoot, path.join(g.projectDir, r.path)),
141
+ )
142
+ return { taskId: g.taskId, hash: g.hash, outputs }
143
+ })
144
+ return {
145
+ hash,
146
+ inputs: {
147
+ files,
148
+ env: input.envValues.map(([name, value]) => ({ name, value })),
149
+ runtime: (input.runtimeValues ?? []).map(([command, output]) => ({ command, output })),
150
+ workspaceRuntime: (input.workspaceRuntimeValues ?? []).map(([command, output]) => ({
151
+ command,
152
+ output,
153
+ })),
154
+ upstream,
155
+ packageJsonDigest: input.projectPackageJsonHash,
156
+ configDigest: input.taskConfigHash,
157
+ workspaceFingerprint: input.workspaceFingerprint,
158
+ },
159
+ }
160
+ }
161
+
162
+ async function resolveKeyInput(args: ComputeHashArgs): Promise<CacheKeyInput> {
86
163
  const cfg = args.node.config
87
164
  const cacheCfg: CacheConfig | undefined = cfg.cache
88
165
  const outputs = cacheCfg?.outputs.files ?? []
@@ -100,6 +177,7 @@ export async function computeTaskHash(args: ComputeHashArgs): Promise<string> {
100
177
  ? {
101
178
  runtimeCache: args.hashCache.runtime,
102
179
  workspaceRuntimeCache: args.hashCache.workspaceRuntime,
180
+ workspaceFilesCache: args.hashCache.workspaceFiles,
103
181
  }
104
182
  : {}),
105
183
  })
@@ -113,6 +191,16 @@ export async function computeTaskHash(args: ComputeHashArgs): Promise<string> {
113
191
  const upstreamHashes = upstreamPairs.map(([, hash]) => hash)
114
192
  // hash → upstream task id, for capture-row naming only (not folded).
115
193
  const upstreamIds = new Map(upstreamPairs.map(([id, hash]) => [hash, id]))
194
+ // The DEPENDENCY closure, groups expanded — for the executor's input root.
195
+ // Deliberately NOT the filtered set above: `cache.inputs.tasks` says which
196
+ // upstream KEYS this task's key folds, and what a task may READ is
197
+ // `dependsOn`. Locally every dependency's outputs are on disk before the
198
+ // command runs however the filter is written, so an executor that ships the
199
+ // input closure has to ship the same thing or the task sees less remotely
200
+ // than it does here. Not folded — see `upstreamGraft`.
201
+ const upstreamGraft = expandGroupUpstream(args.upstream)
202
+ .filter((u) => u.hash !== undefined)
203
+ .map((u) => ({ taskId: u.node.id, hash: u.hash!, projectDir: u.node.projectDir }))
116
204
  // Trusted index-OID map for this project (populated by the run's
117
205
  // bulk `git ls-files -s` + `git status` pass). Mapped files skip
118
206
  // hashFile entirely; everything else falls back to the identical
@@ -136,7 +224,7 @@ export async function computeTaskHash(args: ComputeHashArgs): Promise<string> {
136
224
 
137
225
  const effectiveForwardArgs = args.node.requested ? (args.forwardArgs ?? []) : []
138
226
 
139
- return await args.cache.key({
227
+ return {
140
228
  taskId: args.node.id,
141
229
  taskConfigHash,
142
230
  projectPackageJsonHash,
@@ -147,11 +235,13 @@ export async function computeTaskHash(args: ComputeHashArgs): Promise<string> {
147
235
  workspaceRoot: args.workspaceRoot,
148
236
  upstreamHashes,
149
237
  upstreamIds,
238
+ upstreamGraft,
150
239
  workspaceFingerprint: args.workspaceFingerprint,
151
240
  forwardArgs: effectiveForwardArgs,
152
241
  ...(fileHashes !== undefined ? { fileHashes } : {}),
242
+ ...(args.node.keyParts !== undefined ? { pluginParts: args.node.keyParts } : {}),
153
243
  ...(args.captureInto !== undefined ? { captureInto: args.captureInto } : {}),
154
- })
244
+ }
155
245
  }
156
246
 
157
247
  /**
@@ -178,17 +268,23 @@ function hashTaskConfig(cfg: TaskConfig, hashCache?: HashCache): string {
178
268
  }
179
269
 
180
270
  /**
181
- * Project the config for hashing: `exec.resources` is a pure scheduling
182
- * hint with zero effect on outputs, so it's stripped — tuning a
183
- * reservation never busts a cache. A config that declares none takes the
184
- * fast path and stringifies byte-identically to before the field existed
185
- * (why this needs no CACHE_VERSION bump). `timeout`/`retries` stay folded
186
- * — their keys are distinct by design (see the decision log); stripping
187
- * them retroactively would bump CACHE_VERSION.
271
+ * Project the config for hashing: `exec.remote` is a pure PLACEMENT hint
272
+ * with zero effect on outputs, so it is stripped — pinning a task to this
273
+ * machine never busts a cache. It must not: the whole contract of a remote
274
+ * executor is that the same command over the same inputs produces the
275
+ * same outputs, so a key that moved when placement changed would split a
276
+ * laptop from a worker pool over nothing. (`exec.resources` rode the same
277
+ * rule until 2026-09-12, when reservations left the config for the
278
+ * schedule plugin that learns them; a config never declares them now.) A
279
+ * config that declares no `remote` takes the fast path and stringifies
280
+ * byte-identically to before the field existed (why this needs no
281
+ * CACHE_VERSION bump). `timeout`/`retries` stay folded — their keys are
282
+ * distinct by design (see the decision log); stripping them retroactively
283
+ * would bump CACHE_VERSION.
188
284
  */
189
285
  function hashableConfig(cfg: TaskConfig): unknown {
190
- if (cfg.exec?.resources === undefined) return cfg
191
- const { resources: _resources, ...execRest } = cfg.exec
286
+ if (cfg.exec?.remote === undefined) return cfg
287
+ const { remote: _remote, ...execRest } = cfg.exec
192
288
  return { ...cfg, exec: execRest }
193
289
  }
194
290
 
@@ -249,5 +345,5 @@ async function doHashProjectPackageJson(
249
345
  if (!(await Bun.file(filePath).exists())) return ''
250
346
  // Route through the cache layer's mtime+size fast path so the
251
347
  // typical re-run sees a stat + SQLite lookup instead of a file read.
252
- return await cache.hashFile(filePath)
348
+ return cache.hashFile(filePath)
253
349
  }