@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
@@ -0,0 +1,202 @@
1
+ // The flakiness verdict, in ONE place. Two readers classify `runs` rows —
2
+ // the all-time per-task query here and `LocalHistoryProvider`'s bounded
3
+ // window — and they used to encode the rule independently, which let them
4
+ // drift into opposite verdicts on identical data. Both build on
5
+ // `mixedOutcomeKeysSql` + `failureModeOf`, so the rule cannot fork again.
6
+ // The per-run detector (`detectFlaky`) and the doctor's list (`flakyTasks`)
7
+ // read the same per-key projection (`keyOutcomesSql`).
8
+
9
+ import type { Database } from 'bun:sqlite'
10
+ import { KEYED_RUNS_SQL } from '../cache/index.js'
11
+ import { isCacheHit, TASK_STATUSES } from './telemetry.js'
12
+
13
+ // SQL hit set derived from the predicate — never a hand-typed list and never
14
+ // a prefix LIKE (a prefix counts any status merely NAMED cache-hit-*). The
15
+ // status-vocabulary tripwire greps both wrong forms.
16
+ const HIT_STATUSES = `(${TASK_STATUSES.filter(isCacheHit)
17
+ .map((s) => `'${s}'`)
18
+ .join(', ')})`
19
+
20
+ export type FailureMode = 'stable' | 'flaky-recoverable' | 'flaky-fatal'
21
+
22
+ /**
23
+ * Per-key outcome projection: every keyed (project, task, hash) under
24
+ * `source` (a relation with `project`, `task`, `hash`, `status`, `cache_hit`
25
+ * columns) with how often it failed and how often it passed — a pass being
26
+ * an executed success or a cache hit, which replays a success. Every
27
+ * flakiness reader is a filter over this one projection.
28
+ */
29
+ function keyOutcomesSql(source: string, where = ''): string {
30
+ return `SELECT project, task, hash,
31
+ SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failures,
32
+ SUM(CASE WHEN status = 'success' OR status IN ${HIT_STATUSES} OR cache_hit = 1
33
+ THEN 1 ELSE 0 END) AS passes
34
+ FROM ${source}
35
+ WHERE ${KEYED_RUNS_SQL}${where}
36
+ GROUP BY project, task, hash`
37
+ }
38
+
39
+ /**
40
+ * Per-key outcome subquery: the distinct cache keys under `source` that
41
+ * produced BOTH a failure and a success — the definitional flake: identical
42
+ * inputs, different outcomes. A failure whose key never succeeded is a
43
+ * legitimate break (a changed input that fails), which belongs to the
44
+ * regressions surface, not flakiness. One projection of the rule serves the
45
+ * all-time query below and the windowed one in `history.ts`.
46
+ */
47
+ export function mixedOutcomeKeysSql(source: string, where = ''): string {
48
+ return `SELECT project, task FROM (${keyOutcomesSql(source, where)})
49
+ WHERE failures > 0 AND passes > 0`
50
+ }
51
+
52
+ /** Mixed-outcome keys of one (project, task) over its WHOLE recorded history. */
53
+ export function mixedOutcomeKeyCount(db: Database, project: string, task: string): number {
54
+ const row = db
55
+ .query(
56
+ `SELECT COUNT(*) AS n FROM (${mixedOutcomeKeysSql('runs', ' AND project = ? AND task = ?')})`,
57
+ )
58
+ .get(project, task) as { n: number }
59
+ return row.n
60
+ }
61
+
62
+ /**
63
+ * The verdict. Flaky requires a NONDETERMINISM signal: a within-run retry,
64
+ * or a cache key that both failed and succeeded (`mixedKeys`). Failures
65
+ * alone — each on its own key — are legitimate breaks, however many there
66
+ * are. `mixedKeys` is a thunk so a task that never failed is stable without
67
+ * the key count being computed at all.
68
+ */
69
+ export function failureModeOf(
70
+ counts: { total: number; failures: number; retried: number },
71
+ mixedKeys: () => number,
72
+ ): FailureMode {
73
+ const flakySignal = counts.retried > 0 || (counts.failures > 0 && mixedKeys() > 0)
74
+ if (!flakySignal) return 'stable'
75
+ return counts.failures < counts.total / 5 ? 'flaky-recoverable' : 'flaky-fatal'
76
+ }
77
+
78
+ /** `failureModeOf` over a task's whole recorded history (the DB is not touched unless it failed). */
79
+ export function classifyFailureMode(
80
+ db: Database,
81
+ project: string,
82
+ task: string,
83
+ counts: { total: number; failures: number; retried: number },
84
+ ): FailureMode {
85
+ return failureModeOf(counts, () => mixedOutcomeKeyCount(db, project, task))
86
+ }
87
+
88
+ /** An executed, keyed outcome of the run in progress — what `detectFlaky` judges. */
89
+ export interface FlakyCandidate {
90
+ project: string
91
+ task: string
92
+ hash: string
93
+ status: 'success' | 'failed'
94
+ /** Attempts this run took; > 1 is a within-run retry. */
95
+ attempts: number
96
+ }
97
+
98
+ /** One task the run just proved nondeterministic. */
99
+ export interface FlakyFinding extends FlakyCandidate {
100
+ /** `project#task`. */
101
+ taskId: string
102
+ /** Outcomes on record for this exact key, this run's included. */
103
+ passes: number
104
+ failures: number
105
+ }
106
+
107
+ // Bound parameters per statement: SQLite's compile-time cap is 32 766 in
108
+ // every build Bun ships, but a run can execute more tasks than that only in
109
+ // theory — chunking keeps the query shape identical at any size.
110
+ const IN_CHUNK = 500
111
+
112
+ function chunked<T>(items: readonly T[], each: (chunk: readonly T[]) => void): void {
113
+ for (let i = 0; i < items.length; i += IN_CHUNK) each(items.slice(i, i + IN_CHUNK))
114
+ }
115
+
116
+ const keyOf = (c: { project: string; task: string; hash: string }): string =>
117
+ `${c.project}\0${c.task}\0${c.hash}`
118
+
119
+ /**
120
+ * Which of this run's executed tasks are flaky, judged BEFORE the run's own
121
+ * rows are recorded: a pass on a key that has failed before, a failure on
122
+ * a key that has passed before, or a within-run retry. The counts fold this
123
+ * run in.
124
+ *
125
+ * The cost follows the run's colour. No candidate (every task a hit or a
126
+ * skip): no query. A green miss: one probe of the `runs_failed` partial
127
+ * index for the keys that ever failed — failures are the rare rows, so the
128
+ * probe is microseconds however long the history. Only a key that DID fail
129
+ * before, or a task failing now, pays the projection scan over its own
130
+ * keys, and a run that is failing has more to look at than a scan.
131
+ */
132
+ export function detectFlaky(db: Database, candidates: readonly FlakyCandidate[]): FlakyFinding[] {
133
+ if (candidates.length === 0) return []
134
+ const everFailed = new Set<string>()
135
+ const passedNow = candidates.filter((c) => c.status === 'success' && c.attempts <= 1)
136
+ chunked(passedNow, (chunk) => {
137
+ const rows = db
138
+ .query(
139
+ `SELECT DISTINCT project, task, hash FROM runs
140
+ WHERE status = 'failed' AND hash IN (${chunk.map(() => '?').join(', ')})`,
141
+ )
142
+ .all(...chunk.map((c) => c.hash)) as { project: string; task: string; hash: string }[]
143
+ for (const r of rows) everFailed.add(keyOf(r))
144
+ })
145
+ const suspects = candidates.filter(
146
+ (c) => c.status === 'failed' || c.attempts > 1 || everFailed.has(keyOf(c)),
147
+ )
148
+ if (suspects.length === 0) return []
149
+ const counts = new Map<string, { passes: number; failures: number }>()
150
+ chunked(suspects, (chunk) => {
151
+ const rows = db
152
+ .query(keyOutcomesSql('runs', ` AND hash IN (${chunk.map(() => '?').join(', ')})`))
153
+ .all(...chunk.map((c) => c.hash)) as {
154
+ project: string
155
+ task: string
156
+ hash: string
157
+ passes: number
158
+ failures: number
159
+ }[]
160
+ for (const r of rows) counts.set(keyOf(r), { passes: r.passes, failures: r.failures })
161
+ })
162
+ const out: FlakyFinding[] = []
163
+ for (const c of suspects) {
164
+ const before = counts.get(keyOf(c)) ?? { passes: 0, failures: 0 }
165
+ const passes = before.passes + (c.status === 'success' ? 1 : 0)
166
+ const failures = before.failures + (c.status === 'failed' ? 1 : 0)
167
+ if (!(passes > 0 && failures > 0) && c.attempts <= 1) continue
168
+ out.push({ ...c, taskId: `${c.project}#${c.task}`, passes, failures })
169
+ }
170
+ return out
171
+ }
172
+
173
+ /** One task the recorded history shows both passing and failing on unchanged inputs. */
174
+ export interface FlakyTask {
175
+ /** `project#task`. */
176
+ taskId: string
177
+ project: string
178
+ task: string
179
+ /** Distinct cache keys that both passed and failed. */
180
+ keys: number
181
+ /** Outcomes over those keys. */
182
+ passes: number
183
+ failures: number
184
+ }
185
+
186
+ /**
187
+ * Every task with a mixed-outcome key anywhere in the retained history
188
+ * (30 days, `RunHistory.pruneOlderThan`), most failures first: the
189
+ * doctor's list. A full scan of `runs`, which an inspection verb affords.
190
+ */
191
+ export function flakyTasks(db: Database): FlakyTask[] {
192
+ const rows = db
193
+ .query(
194
+ `SELECT project, task, COUNT(*) AS keys, SUM(passes) AS passes, SUM(failures) AS failures
195
+ FROM (${keyOutcomesSql('runs')})
196
+ WHERE failures > 0 AND passes > 0
197
+ GROUP BY project, task
198
+ ORDER BY failures DESC, passes DESC, project, task`,
199
+ )
200
+ .all() as { project: string; task: string; keys: number; passes: number; failures: number }[]
201
+ return rows.map((r) => ({ ...r, taskId: `${r.project}#${r.task}` }))
202
+ }
@@ -28,6 +28,7 @@
28
28
  import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
29
29
  import { paint, type ColorSupport } from './colors.js'
30
30
  import { formatDuration } from './summary.js'
31
+ import { outcomeLabel } from './events.js'
31
32
 
32
33
  const NO_COLOR: ColorSupport = { enabled: false }
33
34
 
@@ -60,6 +61,14 @@ export interface TaskBlockBody {
60
61
  stdout?: string
61
62
  /** stderr chunks. Renders under `├─ stderr`. */
62
63
  stderr?: string
64
+ /**
65
+ * Characters the capture dropped from the head of each stream. Non-zero
66
+ * only for a task whose output is bounded (a persistent one — nothing
67
+ * else can outrun its own exit), and rendered so a capped log can never
68
+ * read as a complete one.
69
+ */
70
+ droppedStdout?: number
71
+ droppedStderr?: number
63
72
  }
64
73
 
65
74
  export function formatTaskBlock(
@@ -84,16 +93,6 @@ export function formatTaskBlock(
84
93
 
85
94
  const idPainted = paintTaskId(node, colors, { bold: true })
86
95
  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
96
  const header = formatBlockHeader(outcome, colors)
98
97
  const lines: string[] = [`${corner('┌─')} ${idPainted} ${corner('>')} ${header}`]
99
98
 
@@ -107,32 +106,51 @@ export function formatTaskBlock(
107
106
  lines.push('', corner(`$ ${node.config.exec?.command ?? ''}`), '')
108
107
  }
109
108
 
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
- }
109
+ pushStreamSection(lines, stdout, 'STDOUT', SUCCESS, body.droppedStdout ?? 0, colors)
110
+ pushStreamSection(lines, stderr, 'STDERR', ERROR, body.droppedStderr ?? 0, colors)
121
111
 
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
- }
112
+ lines.push(...violationSection(outcome, colors))
131
113
 
132
114
  lines.push(`${corner('└─')} ${idPainted} ${corner('──')}${formatBlockFooter(outcome, colors)}`)
133
115
  return lines.join('\n') + '\n'
134
116
  }
135
117
 
118
+ /**
119
+ * One `├─ <LABEL>` section of captured output, with the head-dropped notice
120
+ * when the capture was bounded. Shared by the task frame and the persistent
121
+ * tail block so the two renderings of the same bytes cannot drift.
122
+ */
123
+ function pushStreamSection(
124
+ lines: string[],
125
+ text: string,
126
+ title: string,
127
+ color: string,
128
+ lost: number,
129
+ colors: ColorSupport,
130
+ ): void {
131
+ if (text.trim().length === 0) return
132
+ lines.push(sectionLine(title, color, colors), '')
133
+ if (lost > 0) {
134
+ lines.push(paint('', `… ${lost} earlier characters dropped`, colors, { dim: true }))
135
+ }
136
+ pushBodyLines(lines, text)
137
+ lines.push('')
138
+ }
139
+
140
+ /**
141
+ * Section labels are bold + state-colored (owner design): stdout green,
142
+ * stderr red, sandbox yellow, command plain white — only the command
143
+ * CONTENT is greyed (context, not signal). A dim rule trails each label to
144
+ * the frame width, and sections get vertical margins so content stands
145
+ * clear of the furniture.
146
+ */
147
+ function sectionLine(title: string, color: string, colors: ColorSupport): string {
148
+ const corner = (s: string) => paint('', s, colors, { dim: true })
149
+ const label = paint(color, title, colors, { bold: true })
150
+ const rule = corner('─'.repeat(Math.max(1, FRAME_WIDTH - 4 - title.length)))
151
+ return `${corner('├─')} ${label} ${rule}`
152
+ }
153
+
136
154
  /**
137
155
  * Append each line of `text` raw. Content carries no border/indent so
138
156
  * terminal wrapping never collides with frame glyphs and copy/paste
@@ -143,7 +161,7 @@ function pushBodyLines(lines: string[], text: string): void {
143
161
  }
144
162
 
145
163
  /** Stable per-project hue: FNV-style fold of the name into the palette. */
146
- export function projectColor(projectName: string): string {
164
+ function projectColor(projectName: string): string {
147
165
  let h = 0
148
166
  for (let i = 0; i < projectName.length; i++) {
149
167
  h = (h * 31 + projectName.charCodeAt(i)) >>> 0
@@ -172,7 +190,7 @@ export function paintIdParts(
172
190
  )
173
191
  }
174
192
 
175
- export function paintId(
193
+ function paintId(
176
194
  projectName: string,
177
195
  taskName: string,
178
196
  colors: ColorSupport,
@@ -181,11 +199,7 @@ export function paintId(
181
199
  return paintIdParts(projectName, projectName, taskName, colors, opts)
182
200
  }
183
201
 
184
- export function paintTaskId(
185
- node: TaskNode,
186
- colors: ColorSupport,
187
- opts: { bold?: boolean } = {},
188
- ): string {
202
+ function paintTaskId(node: TaskNode, colors: ColorSupport, opts: { bold?: boolean } = {}): string {
189
203
  return paintId(node.projectName, node.taskName, colors, opts)
190
204
  }
191
205
 
@@ -197,8 +211,8 @@ export function paintTaskId(
197
211
  // fixed cell so durations line up and a ticking elapsed never shifts
198
212
  // the row. All detail (exit code, output) lives in the framed block.
199
213
  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"
214
+ const STATUS_COL = 7 // "success" / "running" / "skipped"
215
+ const CACHE_COL = 8 // "no-cache" / "remote" / "local" / "fresh" / "miss"
202
216
 
203
217
  /** Right-align the duration in a TIME_COL cell (pad on the left). */
204
218
  function timeCell(ms: number | null, colors: ColorSupport): string {
@@ -256,7 +270,8 @@ function cacheOf(o: TaskOutcome): { word: string; color: string } {
256
270
  switch (o.status) {
257
271
  case 'success':
258
272
  case 'failed':
259
- return { word: 'miss', color: '' } // dim
273
+ // A task with no `cache` block never consulted the cache: not a miss.
274
+ return { word: o.node.config.cache === undefined ? 'no-cache' : 'miss', color: '' } // dim
260
275
  case 'cache-hit':
261
276
  return o.restored === false
262
277
  ? { word: 'fresh', color: SUCCESS }
@@ -271,7 +286,7 @@ function cacheOf(o: TaskOutcome): { word: string; color: string } {
271
286
  }
272
287
 
273
288
  /** The painted status glyph (shape = cache axis, color = task axis). */
274
- export function taskGlyph(o: TaskOutcome, colors: ColorSupport): string {
289
+ function taskGlyph(o: TaskOutcome, colors: ColorSupport): string {
275
290
  return paint(statusOf(o).color, glyphShape(o), colors)
276
291
  }
277
292
 
@@ -363,6 +378,27 @@ export function formatFrameOpen(node: TaskNode, colors: ColorSupport = NO_COLOR)
363
378
  return `${corner('┌─')} ${mark}${paintTaskId(node, colors, { bold: true })} ${corner('>')} $ ${cmd}`
364
379
  }
365
380
 
381
+ /**
382
+ * Sandbox violations get a dedicated section inside the frame so the user
383
+ * sees them in context with the failing task, not as loose status output
384
+ * above the box. Shared by BOTH renderers: the focused/live frame used to
385
+ * omit it, so a single-task run — exactly how someone debugging one task
386
+ * invokes vx — showed a failure with no violations at all (owner,
387
+ * 2026-09-05).
388
+ */
389
+ function violationSection(outcome: TaskOutcome, colors: ColorSupport): string[] {
390
+ const vlines = outcome.sandboxViolationLines
391
+ if (!vlines || vlines.length === 0) return []
392
+ // Unique lines only — a task's children trip the same denial many times
393
+ // and the repeats bury everything else. The lines themselves are printed
394
+ // VERBATIM: a violation is evidence, and evidence that has been rewritten
395
+ // for display is not the thing the sandbox reported. The outcome keeps
396
+ // every record for telemetry.
397
+ const body = [...new Set(vlines)]
398
+ // Red, not amber: a violation fails the task, so it reads as a failure.
399
+ return [sectionLine(`SANDBOX VIOLATIONS (${body.length})`, ERROR, colors), '', ...body, '']
400
+ }
401
+
366
402
  export function formatFrameClose(
367
403
  node: TaskNode,
368
404
  outcome: TaskOutcome,
@@ -378,13 +414,42 @@ export function formatFrameClose(
378
414
  persistent && outcome.status === 'success'
379
415
  ? ` ${paint('', `(${formatDuration(outcome.durationMs)})`, colors, { dim: true })} ${paint(ACCENT, 'running', colors)}`
380
416
  : formatBlockFooter(outcome, colors)
381
- return `${corner('└─')} ${mark}${paintTaskId(node, colors, { bold: true })} ${corner('──')}${tail}`
417
+ const close = `${corner('└─')} ${mark}${paintTaskId(node, colors, { bold: true })} ${corner('──')}${tail}`
418
+ const violations = violationSection(outcome, colors)
419
+ return violations.length === 0 ? close : `${violations.join('\n')}\n${close}`
382
420
  }
383
421
 
384
422
  function isPersistentNode(node: TaskNode): boolean {
385
423
  return node.config.exec?.persistent !== undefined
386
424
  }
387
425
 
426
+ /**
427
+ * Everything a persistent task wrote AFTER it signalled ready, as one
428
+ * trailing block. Its outcome landed at ready while the child kept
429
+ * running, so this uses the live frame's `▸ … running` vocabulary rather
430
+ * than the success frame `formatTaskBlock` renders — the block is a tail,
431
+ * not a completed task's log. Section titles say `(since ready)` because
432
+ * the pre-ready output already went out with the outcome, and a
433
+ * head-dropped tail names how much it lost so a truncated log can never
434
+ * read as complete. Empty body → '' (the caller writes nothing).
435
+ */
436
+ export function formatPersistentTailBlock(
437
+ node: TaskNode,
438
+ outcome: TaskOutcome,
439
+ body: TaskBlockBody,
440
+ dropped: { stdout?: number; stderr?: number } = {},
441
+ colors: ColorSupport = NO_COLOR,
442
+ ): string {
443
+ const stdout = body.stdout ?? ''
444
+ const stderr = body.stderr ?? ''
445
+ if (stdout.trim().length === 0 && stderr.trim().length === 0) return ''
446
+ const lines: string[] = [formatFrameOpen(node, colors)]
447
+ pushStreamSection(lines, stdout, 'STDOUT (since ready)', SUCCESS, dropped.stdout ?? 0, colors)
448
+ pushStreamSection(lines, stderr, 'STDERR (since ready)', ERROR, dropped.stderr ?? 0, colors)
449
+ lines.push(formatFrameClose(node, outcome, colors))
450
+ return lines.join('\n') + '\n'
451
+ }
452
+
388
453
  /**
389
454
  * Between the requested task's frame and the summary footer, list the
390
455
  * persistent tasks (dev servers / watchers) the run is keeping alive in
@@ -405,56 +470,48 @@ export function formatPersistentList(
405
470
  function formatBlockHeader(o: TaskOutcome, colors: ColorSupport): string {
406
471
  const shortHash = o.hash ? o.hash.slice(0, 8) : ''
407
472
  const dim = (s: string) => paint('', s, colors, { dim: true })
473
+ const word = outcomeLabel(o)
408
474
  switch (o.status) {
409
475
  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}`)}`
476
+ return `${paint(SUCCESS, word, colors)} ${dim(`• ${shortHash}`)}`
414
477
  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}`)}`
478
+ // A remote restore is the one hit painted accent: bytes came off the wire.
479
+ return `${paint(o.restored === false ? SUCCESS : ACCENT, word, colors)} ${dim(`• ${shortHash}`)}`
419
480
  case 'failed':
420
481
  // The command lives in its own `├─ command` section; the header
421
482
  // carries the outcome like every other status.
422
- return paint(ERROR, `failed (exit ${o.exitCode})`, colors, { bold: true })
483
+ return paint(ERROR, word, colors, { bold: true })
423
484
  case 'skipped':
424
485
  return paint(WARN, 'skipped (upstream failed)', colors)
425
- case 'success':
426
- return dim('success')
427
486
  default:
428
- return o.status
487
+ return dim(word)
429
488
  }
430
489
  }
431
490
 
432
491
  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.
492
+ // Footer pattern: ` (<dur>) <status>`. Duration is always shown, and it is
493
+ // always what THIS run spent — for a cache hit, the probe + restore, not
494
+ // the exec time the entry was stored with. (The comment here used to claim
495
+ // the opposite; the code never did, and `--report` summed these as "time
496
+ // saved" on the strength of it. The stored time lives on
497
+ // `TaskOutcome.storedDurationMs`.) Status differs by outcome — see
498
+ // formatStatusTag.
437
499
  const dur = paint('', `(${formatDuration(o.durationMs)})`, colors, { dim: true })
438
500
  const tag = formatStatusTag(o, colors)
439
501
  return ` ${dur} ${tag}`
440
502
  }
441
503
 
442
504
  function formatStatusTag(o: TaskOutcome, colors: ColorSupport): string {
505
+ const word = outcomeLabel(o)
443
506
  switch (o.status) {
507
+ case 'failed':
508
+ return paint(ERROR, word, colors, { bold: true })
509
+ case 'skipped':
510
+ return paint(WARN, word, colors)
444
511
  case 'cache-hit':
445
- return paint('', o.restored === false ? 'up-to-date' : 'restored-local', colors, {
446
- dim: true,
447
- })
448
512
  case 'cache-hit-remote':
449
- return paint('', o.restored === false ? 'up-to-date' : 'restored-remote', colors, {
450
- dim: true,
451
- })
452
513
  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)
514
+ return paint('', word, colors, { dim: true })
458
515
  default:
459
516
  return o.status
460
517
  }