@vzn/vx 0.0.0 → 0.0.14

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 (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +349 -1
  3. package/launcher.mjs +92 -0
  4. package/package.json +44 -3
  5. package/src/bin.ts +25 -0
  6. package/src/cache/cache.ts +1832 -0
  7. package/src/cache/cas-backend.ts +97 -0
  8. package/src/cache/digest.ts +47 -0
  9. package/src/cache/index.ts +28 -0
  10. package/src/cache/inputs.ts +839 -0
  11. package/src/cache/layered-cache.ts +355 -0
  12. package/src/cache/tar.ts +343 -0
  13. package/src/cli/backend.ts +23 -0
  14. package/src/cli/cache.ts +96 -0
  15. package/src/cli/format.ts +15 -0
  16. package/src/cli/help.ts +129 -0
  17. package/src/cli/index.ts +85 -0
  18. package/src/cli/info.ts +80 -0
  19. package/src/cli/lock.ts +135 -0
  20. package/src/cli/mcp-rpc.ts +292 -0
  21. package/src/cli/mcp.ts +92 -0
  22. package/src/cli/migrate-nx.ts +450 -0
  23. package/src/cli/migrate-turbo.ts +364 -0
  24. package/src/cli/migrate.ts +219 -0
  25. package/src/cli/plan-format.ts +148 -0
  26. package/src/cli/run.ts +683 -0
  27. package/src/cli/show.ts +201 -0
  28. package/src/cli/upgrade.ts +103 -0
  29. package/src/cli/watch.ts +287 -0
  30. package/src/config.ts +460 -0
  31. package/src/exec/env.ts +74 -0
  32. package/src/exec/index.ts +13 -0
  33. package/src/exec/runner.ts +470 -0
  34. package/src/exec/sandbox-runtime.ts +578 -0
  35. package/src/graph/dependency-spec.ts +85 -0
  36. package/src/graph/index.ts +27 -0
  37. package/src/graph/scheduler.ts +677 -0
  38. package/src/graph/task-graph.ts +377 -0
  39. package/src/index.ts +222 -0
  40. package/src/orchestrator/colors.ts +63 -0
  41. package/src/orchestrator/devframe-surface.ts +74 -0
  42. package/src/orchestrator/events.ts +277 -0
  43. package/src/orchestrator/execute-task.ts +939 -0
  44. package/src/orchestrator/framed-output.ts +461 -0
  45. package/src/orchestrator/history.ts +148 -0
  46. package/src/orchestrator/index.ts +174 -0
  47. package/src/orchestrator/local-shortcircuit.ts +147 -0
  48. package/src/orchestrator/logger.ts +526 -0
  49. package/src/orchestrator/metrics.ts +1927 -0
  50. package/src/orchestrator/options.ts +197 -0
  51. package/src/orchestrator/plan.ts +129 -0
  52. package/src/orchestrator/plugin-host.ts +185 -0
  53. package/src/orchestrator/plugin.ts +283 -0
  54. package/src/orchestrator/predict.ts +106 -0
  55. package/src/orchestrator/prepare.ts +306 -0
  56. package/src/orchestrator/protocol.ts +156 -0
  57. package/src/orchestrator/remote-prefetch.ts +81 -0
  58. package/src/orchestrator/resources.ts +59 -0
  59. package/src/orchestrator/run-artifacts.ts +111 -0
  60. package/src/orchestrator/run-context.ts +205 -0
  61. package/src/orchestrator/run-report.ts +132 -0
  62. package/src/orchestrator/run-state.ts +145 -0
  63. package/src/orchestrator/run.ts +887 -0
  64. package/src/orchestrator/stable-keys.ts +177 -0
  65. package/src/orchestrator/status-line.ts +287 -0
  66. package/src/orchestrator/summary.ts +315 -0
  67. package/src/orchestrator/tally.ts +69 -0
  68. package/src/orchestrator/task-hash.ts +253 -0
  69. package/src/orchestrator/telemetry-host.ts +80 -0
  70. package/src/orchestrator/telemetry.ts +358 -0
  71. package/src/orchestrator/upstream.ts +121 -0
  72. package/src/orchestrator/verify.ts +164 -0
  73. package/src/orchestrator/wire-render.ts +92 -0
  74. package/src/orchestrator/wire.ts +212 -0
  75. package/src/util/errors.ts +11 -0
  76. package/src/util/hash.ts +25 -0
  77. package/src/util/index.ts +8 -0
  78. package/src/util/paths.ts +16 -0
  79. package/src/util/size.ts +16 -0
  80. package/src/util/ulid.ts +15 -0
  81. package/src/version.ts +10 -0
  82. package/src/workspace/affected.ts +118 -0
  83. package/src/workspace/filter.ts +155 -0
  84. package/src/workspace/fingerprint.ts +41 -0
  85. package/src/workspace/index.ts +27 -0
  86. package/src/workspace/lockfile.ts +118 -0
  87. package/src/workspace/nested-dirs.ts +34 -0
  88. package/src/workspace/package-graph.ts +148 -0
  89. package/src/workspace/project-loader.ts +512 -0
  90. package/src/workspace/workspace.ts +176 -0
@@ -0,0 +1,461 @@
1
+ // Turbo-style header + per-task framed output.
2
+ //
3
+ // Header:
4
+ // • vx 0.0.0
5
+ //
6
+ // • Packages in scope: @vzn/vx, @repo/ui
7
+ // • Running ci in 2 packages
8
+ // • Remote caching disabled
9
+ //
10
+ // Per-task block:
11
+ // ┌─ @vzn/vx#lint > success
12
+ // ├─ command
13
+ // oxlint --type-aware --type-check
14
+ // ├─ stdout
15
+ // Found 0 warnings and 0 errors.
16
+ // └─ @vzn/vx#lint ── (327ms) success
17
+ //
18
+ // Content lines are RAW — no left border, no indent. A border would
19
+ // collide with terminal wrapping on long lines and pollute
20
+ // copy/paste; the dim ├─ section headers carry the structure instead
21
+ // (owner feedback, 2026-06).
22
+ //
23
+ // Output is buffered per-task and the whole block is emitted at task
24
+ // completion — concurrent tasks don't interleave their lines, but the
25
+ // price is no live progress within a task. This matches Turbo's
26
+ // `--ui=stream` mode.
27
+
28
+ import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
29
+ import { paint, type ColorSupport } from './colors.js'
30
+ import { formatDuration } from './summary.js'
31
+
32
+ const NO_COLOR: ColorSupport = { enabled: false }
33
+
34
+ const ACCENT = '#06b6d4' // cyan-500 — bullets, remote-hit hint
35
+ // Identity hues — deliberately outside the status palette (green /
36
+ // red / yellow / cyan) so a task id can never read as an outcome.
37
+ // Projects hash to a stable hue (same project = same color in every
38
+ // run, list, and region row); tasks keep one fixed hue excluded from
39
+ // the project palette so the two halves always read apart.
40
+ // Section rules + the summary rule share one frame width.
41
+ const FRAME_WIDTH = 60
42
+
43
+ const PROJECT_PALETTE = [
44
+ '#a78bfa', // violet-400
45
+ '#60a5fa', // blue-400
46
+ '#e879f9', // fuchsia-400
47
+ '#818cf8', // indigo-400
48
+ '#c084fc', // purple-400
49
+ '#93c5fd', // blue-300
50
+ ] as const
51
+ const TASK = '#f472b6' // pink-400 — task part of an id
52
+ const SUCCESS = '#22c55e' // green-500 — success / fresh
53
+ const WARN = '#eab308' // yellow-500 — skipped
54
+ const ERROR = '#ef4444' // red-500 — failed
55
+ const LOCAL = '#38bdf8' // sky-400 — local cache hit
56
+ const REMOTE = '#2563eb' // blue-600 — remote cache hit
57
+
58
+ export interface TaskBlockBody {
59
+ /** stdout chunks accumulated during the task. Renders under `├─ stdout`. */
60
+ stdout?: string
61
+ /** stderr chunks. Renders under `├─ stderr`. */
62
+ stderr?: string
63
+ }
64
+
65
+ export function formatTaskBlock(
66
+ node: TaskNode,
67
+ outcome: TaskOutcome,
68
+ body: TaskBlockBody,
69
+ colors: ColorSupport = NO_COLOR,
70
+ // Force the `$ cmd` line even for cache hits. Focused requested
71
+ // tasks set this so a requested task's frame is identical whether it
72
+ // ran or was cached — you asked for it, you see what it would run.
73
+ forceCommand = false,
74
+ ): string {
75
+ // Group tasks (no `exec`) do no work and have no body — they're
76
+ // organizational nodes the user wrote so a `vx run ci` invocation
77
+ // has a single name to address. Showing an empty box for them is
78
+ // pure noise. Same exclusion the summary totals + analytics
79
+ // pass already make.
80
+ if (isGroupTask(node)) return ''
81
+
82
+ const stdout = body.stdout ?? ''
83
+ const stderr = body.stderr ?? ''
84
+
85
+ const idPainted = paintTaskId(node, colors, { bold: true })
86
+ const corner = (s: string) => paint('', s, colors, { dim: true })
87
+ // Section labels are bold + state-colored (owner design): stdout
88
+ // green, stderr red, sandbox yellow, command plain white — only
89
+ // the command CONTENT is greyed (context, not signal). A dim rule
90
+ // trails each label to the frame width, and sections get vertical
91
+ // margins so content stands clear of the furniture.
92
+ const section = (title: string, color?: string) => {
93
+ const label = paint(color ?? '', title, colors, { bold: true })
94
+ const rule = corner('\u2500'.repeat(Math.max(1, FRAME_WIDTH - 4 - title.length)))
95
+ return `${corner('\u251c\u2500')} ${label} ${rule}`
96
+ }
97
+ const header = formatBlockHeader(outcome, colors)
98
+ const lines: string[] = [`${corner('┌─')} ${idPainted} ${corner('>')} ${header}`]
99
+
100
+ // The command section shows what actually ran — executed tasks
101
+ // (success and failed). Cache hits normally skip it (the stored
102
+ // output is the interesting part), but `forceCommand` keeps it for
103
+ // focused requested tasks so their frame is hit/miss-identical.
104
+ if (forceCommand || outcome.status === 'success' || outcome.status === 'failed') {
105
+ // No section label for the command (owner cut it) — the dim `$ `
106
+ // line under the header reads as the command on its own.
107
+ lines.push('', corner(`$ ${node.config.exec?.command ?? ''}`), '')
108
+ }
109
+
110
+ if (stdout.trim().length > 0) {
111
+ lines.push(section('STDOUT', SUCCESS), '')
112
+ pushBodyLines(lines, stdout)
113
+ lines.push('')
114
+ }
115
+
116
+ if (stderr.trim().length > 0) {
117
+ lines.push(section('STDERR', ERROR), '')
118
+ pushBodyLines(lines, stderr)
119
+ lines.push('')
120
+ }
121
+
122
+ // Sandbox violations get a dedicated section inside the frame so the
123
+ // user sees them in context with the failing task, not as loose
124
+ // status output above the box.
125
+ const vlines = outcome.sandboxViolationLines
126
+ if (vlines && vlines.length > 0) {
127
+ lines.push(section(`SANDBOX VIOLATIONS (${vlines.length})`, WARN), '')
128
+ for (const v of vlines) lines.push(v)
129
+ lines.push('')
130
+ }
131
+
132
+ lines.push(`${corner('└─')} ${idPainted} ${corner('──')}${formatBlockFooter(outcome, colors)}`)
133
+ return lines.join('\n') + '\n'
134
+ }
135
+
136
+ /**
137
+ * Append each line of `text` raw. Content carries no border/indent so
138
+ * terminal wrapping never collides with frame glyphs and copy/paste
139
+ * stays clean; trailing newline is trimmed so the frame stays tight.
140
+ */
141
+ function pushBodyLines(lines: string[], text: string): void {
142
+ for (const line of text.replace(/\n$/, '').split('\n')) lines.push(line)
143
+ }
144
+
145
+ /** Stable per-project hue: FNV-style fold of the name into the palette. */
146
+ export function projectColor(projectName: string): string {
147
+ let h = 0
148
+ for (let i = 0; i < projectName.length; i++) {
149
+ h = (h * 31 + projectName.charCodeAt(i)) >>> 0
150
+ }
151
+ return PROJECT_PALETTE[h % PROJECT_PALETTE.length]!
152
+ }
153
+
154
+ /**
155
+ * `project#task` halves in distinct identity hues (never status
156
+ * colors); the dim separator keeps them reading apart. `hueSource`
157
+ * exists for truncated display strings: the hue must hash from the
158
+ * FULL project name so it survives truncation.
159
+ */
160
+ export function paintIdParts(
161
+ hueSource: string,
162
+ projectText: string,
163
+ taskText: string,
164
+ colors: ColorSupport,
165
+ opts: { bold?: boolean } = {},
166
+ ): string {
167
+ const dim = (t: string) => paint('', t, colors, { dim: true })
168
+ return (
169
+ paint(projectColor(hueSource), projectText, colors, opts) +
170
+ dim('#') +
171
+ paint(TASK, taskText, colors, opts)
172
+ )
173
+ }
174
+
175
+ export function paintId(
176
+ projectName: string,
177
+ taskName: string,
178
+ colors: ColorSupport,
179
+ opts: { bold?: boolean } = {},
180
+ ): string {
181
+ return paintIdParts(projectName, projectName, taskName, colors, opts)
182
+ }
183
+
184
+ export function paintTaskId(
185
+ node: TaskNode,
186
+ colors: ColorSupport,
187
+ opts: { bold?: boolean } = {},
188
+ ): string {
189
+ return paintId(node.projectName, node.taskName, colors, opts)
190
+ }
191
+
192
+ // ── Reported-line grid: glyph · time · status · cache · name ────────
193
+ // glyph SHAPE encodes the cache axis (⏺ miss · ► fresh · ⇢ local · ⇣
194
+ // remote; ◼ failed · ⊘ skipped · ⦿ running), glyph COLOR encodes the
195
+ // task axis (green/red/yellow/cyan). The status and cache WORDS spell
196
+ // the two axes out, each in its own color. Time is right-aligned in a
197
+ // fixed cell so durations line up and a ticking elapsed never shifts
198
+ // the row. All detail (exit code, output) lives in the framed block.
199
+ export const TIME_COL = 7 // "0ms" … "999.99s"
200
+ export const STATUS_COL = 7 // "success" / "running" / "skipped"
201
+ export const CACHE_COL = 6 // "remote" / "local" / "fresh" / "miss"
202
+
203
+ /** Right-align the duration in a TIME_COL cell (pad on the left). */
204
+ function timeCell(ms: number | null, colors: ColorSupport): string {
205
+ const raw = ms === null ? '' : formatDuration(ms)
206
+ return ' '.repeat(Math.max(0, TIME_COL - raw.length)) + paint('', raw, colors, { dim: true })
207
+ }
208
+
209
+ /**
210
+ * Paint `text` then pad to `width` by VISIBLE length (trailing spaces).
211
+ * An empty color string renders the text dim (used for the `miss` cache
212
+ * word); empty text is a blank cell.
213
+ */
214
+ function cell(text: string, color: string, width: number, colors: ColorSupport): string {
215
+ const painted =
216
+ text === ''
217
+ ? ''
218
+ : color === ''
219
+ ? paint('', text, colors, { dim: true })
220
+ : paint(color, text, colors)
221
+ return painted + ' '.repeat(Math.max(0, width - text.length))
222
+ }
223
+
224
+ /** The cache-shape glyph (task-shape for non-success). */
225
+ function glyphShape(o: TaskOutcome): string {
226
+ switch (o.status) {
227
+ case 'success':
228
+ return '⏺\uFE0E' // ⏺ miss (ran), text-presentation (narrow)
229
+ case 'cache-hit':
230
+ return o.restored === false ? '\u25ba' : '\u21e2' // ► fresh : ⇢ local
231
+ case 'cache-hit-remote':
232
+ return o.restored === false ? '\u25ba' : '\u21e3' // ► fresh : ⇣ remote
233
+ case 'failed':
234
+ return '\u25fc\uFE0E' // ◼ failed, text-presentation (narrow)
235
+ case 'skipped':
236
+ return '\u2298' // ⊘ skipped
237
+ default:
238
+ return '⏺\uFE0E'
239
+ }
240
+ }
241
+
242
+ /** Task-axis word + color (a cache hit is a success). */
243
+ function statusOf(o: TaskOutcome): { word: string; color: string } {
244
+ switch (o.status) {
245
+ case 'failed':
246
+ return { word: 'failed', color: ERROR }
247
+ case 'skipped':
248
+ return { word: 'skipped', color: WARN }
249
+ default:
250
+ return { word: 'success', color: SUCCESS }
251
+ }
252
+ }
253
+
254
+ /** Cache-axis word + color. Empty for states that never reached the cache. */
255
+ function cacheOf(o: TaskOutcome): { word: string; color: string } {
256
+ switch (o.status) {
257
+ case 'success':
258
+ case 'failed':
259
+ return { word: 'miss', color: '' } // dim
260
+ case 'cache-hit':
261
+ return o.restored === false
262
+ ? { word: 'fresh', color: SUCCESS }
263
+ : { word: 'local', color: LOCAL }
264
+ case 'cache-hit-remote':
265
+ return o.restored === false
266
+ ? { word: 'fresh', color: SUCCESS }
267
+ : { word: 'remote', color: REMOTE }
268
+ default:
269
+ return { word: '', color: '' }
270
+ }
271
+ }
272
+
273
+ /** The painted status glyph (shape = cache axis, color = task axis). */
274
+ export function taskGlyph(o: TaskOutcome, colors: ColorSupport): string {
275
+ return paint(statusOf(o).color, glyphShape(o), colors)
276
+ }
277
+
278
+ /**
279
+ * One reported task row: `<glyph> <time> <status> <cache> <name>`.
280
+ * `glyph` is pre-painted; status/cache are raw text + color, padded to
281
+ * their columns; `paintedId` carries identity coloring. `ms = null`
282
+ * blanks the time (e.g. a skip never ran); `cache = ''` blanks it.
283
+ */
284
+ export function formatTaskRow(
285
+ glyph: string,
286
+ ms: number | null,
287
+ status: string,
288
+ statusColor: string,
289
+ cache: string,
290
+ cacheColor: string,
291
+ paintedId: string,
292
+ colors: ColorSupport = NO_COLOR,
293
+ ): string {
294
+ const st = cell(status, statusColor, STATUS_COL, colors)
295
+ const ca = cell(cache, cacheColor, CACHE_COL, colors)
296
+ return ` ${glyph} ${timeCell(ms, colors)} ${st} ${ca} ${paintedId}`
297
+ }
298
+
299
+ /** Compact one-liner for a cache hit with nothing to replay. */
300
+ export function formatTaskHitLine(
301
+ node: TaskNode,
302
+ o: TaskOutcome,
303
+ colors: ColorSupport = NO_COLOR,
304
+ ): string {
305
+ return formatOutcomeRow(o, paintTaskId(node, colors), o.durationMs, colors)
306
+ }
307
+
308
+ /** Compact one-liner for an executed task in broad mode. */
309
+ export function formatTaskExecutedLine(
310
+ node: TaskNode,
311
+ o: TaskOutcome,
312
+ colors: ColorSupport = NO_COLOR,
313
+ ): string {
314
+ return formatOutcomeRow(o, paintTaskId(node, colors), o.durationMs, colors)
315
+ }
316
+
317
+ /** Shared: render any outcome on the grid. */
318
+ function formatOutcomeRow(
319
+ o: TaskOutcome,
320
+ paintedId: string,
321
+ ms: number | null,
322
+ colors: ColorSupport,
323
+ ): string {
324
+ const st = statusOf(o)
325
+ const ca = cacheOf(o)
326
+ return formatTaskRow(
327
+ taskGlyph(o, colors),
328
+ ms,
329
+ st.word,
330
+ st.color,
331
+ ca.word,
332
+ ca.color,
333
+ paintedId,
334
+ colors,
335
+ )
336
+ }
337
+
338
+ /** Compact one-liner for a skipped task — it never ran (blank time). */
339
+ export function formatTaskSkippedLine(node: TaskNode, colors: ColorSupport = NO_COLOR): string {
340
+ return formatTaskRow(
341
+ paint(WARN, '\u2298', colors),
342
+ null,
343
+ 'skipped',
344
+ WARN,
345
+ '',
346
+ '',
347
+ paintTaskId(node, colors),
348
+ colors,
349
+ )
350
+ }
351
+
352
+ /**
353
+ * Live-frame brackets for focused mode: the requested task's output
354
+ * streams raw between an opening line (id + command) and a closing
355
+ * line (id + duration + outcome). Same visual language as
356
+ * formatTaskBlock, but emitted in real time around the live stream
357
+ * instead of buffered.
358
+ */
359
+ export function formatFrameOpen(node: TaskNode, colors: ColorSupport = NO_COLOR): string {
360
+ const corner = (t: string) => paint('', t, colors, { dim: true })
361
+ const cmd = node.config.exec?.command ?? ''
362
+ const mark = isPersistentNode(node) ? `${paint(ACCENT, '▸', colors)} ` : ''
363
+ return `${corner('┌─')} ${mark}${paintTaskId(node, colors, { bold: true })} ${corner('>')} $ ${cmd}`
364
+ }
365
+
366
+ export function formatFrameClose(
367
+ node: TaskNode,
368
+ outcome: TaskOutcome,
369
+ colors: ColorSupport = NO_COLOR,
370
+ ): string {
371
+ const corner = (t: string) => paint('', t, colors, { dim: true })
372
+ const persistent = isPersistentNode(node)
373
+ const mark = persistent ? `${paint(ACCENT, '▸', colors)} ` : ''
374
+ // A persistent task's child keeps running past this "ready" outcome,
375
+ // so the close reads `running` (accent), not `success` — the ▸ marks
376
+ // both ends of the frame as a long-lived task.
377
+ const tail =
378
+ persistent && outcome.status === 'success'
379
+ ? ` ${paint('', `(${formatDuration(outcome.durationMs)})`, colors, { dim: true })} ${paint(ACCENT, 'running', colors)}`
380
+ : formatBlockFooter(outcome, colors)
381
+ return `${corner('└─')} ${mark}${paintTaskId(node, colors, { bold: true })} ${corner('──')}${tail}`
382
+ }
383
+
384
+ function isPersistentNode(node: TaskNode): boolean {
385
+ return node.config.exec?.persistent !== undefined
386
+ }
387
+
388
+ /**
389
+ * Between the requested task's frame and the summary footer, list the
390
+ * persistent tasks (dev servers / watchers) the run is keeping alive in
391
+ * the foreground — one `▸ <id> running` row each, so it's clear which
392
+ * children are still up and how many. Same ▸ vocabulary as the frame
393
+ * marks and the live region's persistent pins.
394
+ */
395
+ export function formatPersistentList(
396
+ nodes: readonly TaskNode[],
397
+ colors: ColorSupport = NO_COLOR,
398
+ ): string[] {
399
+ return nodes.map(
400
+ (n) =>
401
+ ` ${paint(ACCENT, '▸', colors)} ${paintTaskId(n, colors)} ${paint('', 'running', colors, { dim: true })}`,
402
+ )
403
+ }
404
+
405
+ function formatBlockHeader(o: TaskOutcome, colors: ColorSupport): string {
406
+ const shortHash = o.hash ? o.hash.slice(0, 8) : ''
407
+ const dim = (s: string) => paint('', s, colors, { dim: true })
408
+ switch (o.status) {
409
+ case 'cache-hit':
410
+ if (o.restored === false) {
411
+ return `${paint(SUCCESS, 'up-to-date', colors)} ${dim(`• ${shortHash}`)}`
412
+ }
413
+ return `${paint(SUCCESS, 'restored-local', colors)} ${dim(`• ${shortHash}`)}`
414
+ case 'cache-hit-remote':
415
+ if (o.restored === false) {
416
+ return `${paint(SUCCESS, 'up-to-date', colors)} ${dim(`• ${shortHash}`)}`
417
+ }
418
+ return `${paint(ACCENT, 'restored-remote', colors)} ${dim(`• ${shortHash}`)}`
419
+ case 'failed':
420
+ // The command lives in its own `├─ command` section; the header
421
+ // carries the outcome like every other status.
422
+ return paint(ERROR, `failed (exit ${o.exitCode})`, colors, { bold: true })
423
+ case 'skipped':
424
+ return paint(WARN, 'skipped (upstream failed)', colors)
425
+ case 'success':
426
+ return dim('success')
427
+ default:
428
+ return o.status
429
+ }
430
+ }
431
+
432
+ function formatBlockFooter(o: TaskOutcome, colors: ColorSupport): string {
433
+ // Footer pattern: ` (<dur>) <status>`. Duration is always shown.
434
+ // For cache hits it's the *original* exec time the entry was
435
+ // stored with (set by execute-task), not the ~0ms replay cost.
436
+ // Status differs by outcome — see formatStatusTag.
437
+ const dur = paint('', `(${formatDuration(o.durationMs)})`, colors, { dim: true })
438
+ const tag = formatStatusTag(o, colors)
439
+ return ` ${dur} ${tag}`
440
+ }
441
+
442
+ function formatStatusTag(o: TaskOutcome, colors: ColorSupport): string {
443
+ switch (o.status) {
444
+ case 'cache-hit':
445
+ return paint('', o.restored === false ? 'up-to-date' : 'restored-local', colors, {
446
+ dim: true,
447
+ })
448
+ case 'cache-hit-remote':
449
+ return paint('', o.restored === false ? 'up-to-date' : 'restored-remote', colors, {
450
+ dim: true,
451
+ })
452
+ case 'success':
453
+ return paint('', 'success', colors, { dim: true })
454
+ case 'failed':
455
+ return paint(ERROR, `failed (exit ${o.exitCode})`, colors, { bold: true })
456
+ case 'skipped':
457
+ return paint(WARN, 'skipped', colors)
458
+ default:
459
+ return o.status
460
+ }
461
+ }
@@ -0,0 +1,148 @@
1
+ // HistoryTable + HistoryProvider — historical run data the scheduler
2
+ // uses for predictive priority and `vx info --history` surfaces.
3
+ //
4
+ // The data has been in cache.db.runs since schema v11; what's new is
5
+ // surfacing it. A single SQL CTE per run pulls the last N rows per
6
+ // (project, task) pair; the result becomes a read-only snapshot for
7
+ // the run's lifetime. Loaded once at prepareRun; never mutated mid-run.
8
+ //
9
+ // Two providers:
10
+ // LocalHistoryProvider — reads cache.db directly (zero-config).
11
+ // RemoteHistoryProvider — would call a service RPC; deferred to
12
+ // when such an RPC actually exists.
13
+
14
+ import type { Database } from 'bun:sqlite'
15
+
16
+ const DEFAULT_RECENT = 50
17
+
18
+ /** Per (project#task) — last RECENT runs collapsed into a summary. */
19
+ export interface TaskHistory {
20
+ /** Total runs in the recent window. */
21
+ runs: number
22
+ /** Wall-clock p50 (ms). Cache-hit rows excluded so this reflects work actually done. */
23
+ p50DurationMs: number | undefined
24
+ /** Wall-clock p99 (ms). Same exclusion. */
25
+ p99DurationMs: number | undefined
26
+ /** Success rate over the recent window ([0, 1]). */
27
+ successRate: number
28
+ /** Cache hit rate over the recent window ([0, 1]). */
29
+ hitRate: number
30
+ /** Failure mode classification. */
31
+ failureMode: 'stable' | 'flaky-recoverable' | 'flaky-fatal'
32
+ }
33
+
34
+ /** Map keyed by `project#task`. */
35
+ export type HistoryTable = ReadonlyMap<string, TaskHistory>
36
+
37
+ export interface HistoryProvider {
38
+ loadFor(taskIds: readonly string[]): Promise<HistoryTable>
39
+ }
40
+
41
+ /** A no-op provider — every lookup returns an empty table. */
42
+ export class EmptyHistoryProvider implements HistoryProvider {
43
+ async loadFor(_taskIds: readonly string[]): Promise<HistoryTable> {
44
+ return new Map()
45
+ }
46
+ }
47
+
48
+ /** Reads from the orchestrator's local SQLite cache.db. */
49
+ export class LocalHistoryProvider implements HistoryProvider {
50
+ constructor(
51
+ private readonly db: Database,
52
+ private readonly recent: number = DEFAULT_RECENT,
53
+ ) {}
54
+
55
+ async loadFor(taskIds: readonly string[]): Promise<HistoryTable> {
56
+ const out = new Map<string, TaskHistory>()
57
+ if (taskIds.length === 0) return out
58
+
59
+ // One CTE per call: rank rows per (project, task) descending by
60
+ // started_at, keep the top `recent`, aggregate. Cache-hit rows
61
+ // (cache_hit = 1) are excluded from the duration percentiles so
62
+ // p50/p99 reflect work the runner actually did. successRate +
63
+ // hitRate are computed over ALL recent rows.
64
+ const sql = `
65
+ WITH recent AS (
66
+ SELECT
67
+ project,
68
+ task,
69
+ status,
70
+ duration_ms,
71
+ cache_hit,
72
+ ROW_NUMBER() OVER (PARTITION BY project, task ORDER BY started_at DESC) AS rn
73
+ FROM runs
74
+ WHERE (project || '#' || task) IN (${taskIds.map(() => '?').join(',')})
75
+ )
76
+ SELECT
77
+ project,
78
+ task,
79
+ COUNT(*) AS total,
80
+ SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) AS successes,
81
+ SUM(CASE WHEN cache_hit = 1 THEN 1 ELSE 0 END) AS hits,
82
+ SUM(CASE WHEN cache_hit IS NULL OR cache_hit = 0 THEN 1 ELSE 0 END) AS executed,
83
+ SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failures
84
+ FROM recent
85
+ WHERE rn <= ${this.recent}
86
+ GROUP BY project, task
87
+ `
88
+
89
+ type Row = {
90
+ project: string
91
+ task: string
92
+ total: number
93
+ successes: number
94
+ hits: number
95
+ executed: number
96
+ failures: number
97
+ }
98
+ const rows = this.db.query(sql).all(...(taskIds as string[])) as Row[]
99
+
100
+ for (const row of rows) {
101
+ const key = `${row.project}#${row.task}`
102
+ const total = row.total || 0
103
+ const failures = row.failures || 0
104
+ const failureMode: TaskHistory['failureMode'] =
105
+ failures === 0 ? 'stable' : failures < total / 5 ? 'flaky-recoverable' : 'flaky-fatal'
106
+ const { p50, p99 } = this.percentilesFor(row.project, row.task)
107
+ out.set(key, {
108
+ runs: total,
109
+ p50DurationMs: p50,
110
+ p99DurationMs: p99,
111
+ successRate: total > 0 ? (row.successes || 0) / total : 0,
112
+ hitRate: total > 0 ? (row.hits || 0) / total : 0,
113
+ failureMode,
114
+ })
115
+ }
116
+ return out
117
+ }
118
+
119
+ // Percentiles need a row-wise read; do it in JS rather than try to
120
+ // express the percentile_cont equivalent in SQLite. Tiny rows.
121
+ private percentilesFor(
122
+ project: string,
123
+ task: string,
124
+ ): { p50: number | undefined; p99: number | undefined } {
125
+ const rows = this.db
126
+ .query(
127
+ `SELECT duration_ms FROM runs
128
+ WHERE project = ? AND task = ?
129
+ AND (cache_hit IS NULL OR cache_hit = 0)
130
+ AND status = 'success'
131
+ ORDER BY started_at DESC
132
+ LIMIT ?`,
133
+ )
134
+ .all(project, task, this.recent) as { duration_ms: number }[]
135
+ if (rows.length === 0) return { p50: undefined, p99: undefined }
136
+ const durations = rows.map((r) => r.duration_ms).sort((a, b) => a - b)
137
+ return {
138
+ p50: pickPercentile(durations, 0.5),
139
+ p99: pickPercentile(durations, 0.99),
140
+ }
141
+ }
142
+ }
143
+
144
+ function pickPercentile(sorted: number[], q: number): number {
145
+ if (sorted.length === 0) return 0
146
+ const idx = Math.min(sorted.length - 1, Math.floor(q * sorted.length))
147
+ return sorted[idx]!
148
+ }