@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.
- package/LICENSE +1 -1
- package/README.md +135 -124
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +11 -7
- package/src/bin.ts +14 -4
- package/src/cache/archive.ts +659 -0
- package/src/cache/cache.ts +769 -1101
- package/src/cache/chained-cache.ts +194 -0
- package/src/cache/config-evals.ts +89 -0
- package/src/cache/file-hashes.ts +262 -0
- package/src/cache/git-inputs.ts +637 -0
- package/src/cache/index.ts +24 -5
- package/src/cache/inputs.ts +373 -456
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +96 -43
- package/src/cache/output-index.ts +224 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +147 -0
- package/src/cache/tar-stream.ts +358 -0
- package/src/cache/zstd.ts +160 -0
- package/src/cli/cache.ts +75 -21
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +195 -128
- package/src/cli/index.ts +116 -46
- package/src/cli/info.ts +122 -63
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +172 -0
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +72 -6
- package/src/cli/plugin-commands.ts +103 -0
- package/src/cli/run.ts +245 -271
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +9 -7
- package/src/cli/watch.ts +858 -110
- package/src/cli/why.ts +223 -0
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +187 -150
- package/src/exec/env.ts +18 -1
- package/src/exec/executor.ts +224 -0
- package/src/exec/index.ts +24 -2
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +166 -40
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +642 -260
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +4 -10
- package/src/graph/scheduler.ts +232 -203
- package/src/graph/task-graph.ts +222 -8
- package/src/index.ts +146 -153
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +147 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +72 -8
- package/src/orchestrator/execute-task.ts +374 -549
- package/src/orchestrator/failure-mode.ts +202 -0
- package/src/orchestrator/framed-output.ts +126 -69
- package/src/orchestrator/history.ts +126 -59
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +77 -93
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +208 -35
- package/src/orchestrator/metrics.ts +102 -1388
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +55 -34
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +357 -120
- package/src/orchestrator/plugin.ts +298 -58
- package/src/orchestrator/prepare.ts +202 -106
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +12 -8
- package/src/orchestrator/run-artifacts.ts +63 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +74 -47
- package/src/orchestrator/run.ts +534 -417
- package/src/orchestrator/sandbox-request.ts +243 -0
- package/src/orchestrator/save-lane.ts +47 -0
- package/src/orchestrator/signals.ts +111 -0
- package/src/orchestrator/stable-keys.ts +129 -31
- package/src/orchestrator/status-line.ts +49 -9
- package/src/orchestrator/summary.ts +90 -26
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +111 -15
- package/src/orchestrator/task-log-buffer.ts +288 -0
- package/src/orchestrator/telemetry-host.ts +34 -6
- package/src/orchestrator/telemetry.ts +182 -36
- package/src/orchestrator/upstream.ts +72 -31
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +70 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +15 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +64 -0
- package/src/util/settle.ts +67 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +250 -23
- package/src/workspace/config-cache.ts +313 -0
- package/src/workspace/config-eval.ts +217 -0
- package/src/workspace/config-imports.ts +162 -0
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +65 -10
- package/src/workspace/fingerprint.ts +53 -4
- package/src/workspace/index.ts +42 -3
- package/src/workspace/lockfile.ts +25 -9
- package/src/workspace/migrate-scripts.ts +134 -0
- package/src/workspace/migration.ts +314 -0
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +114 -41
- package/src/workspace/project-loader.ts +224 -450
- package/src/workspace/workspace.ts +298 -72
- package/src/cache/cas-backend.ts +0 -97
- package/src/cache/digest.ts +0 -47
- package/src/cache/tar.ts +0 -343
- package/src/cli/backend.ts +0 -23
- package/src/cli/mcp-rpc.ts +0 -292
- package/src/cli/mcp.ts +0 -92
- package/src/cli/migrate-nx.ts +0 -450
- package/src/cli/migrate-turbo.ts +0 -364
- package/src/cli/migrate.ts +0 -219
- package/src/orchestrator/devframe-surface.ts +0 -74
- package/src/orchestrator/predict.ts +0 -106
- package/src/orchestrator/protocol.ts +0 -156
- package/src/orchestrator/resources.ts +0 -59
- package/src/orchestrator/run-state.ts +0 -145
- package/src/orchestrator/verify.ts +0 -164
- package/src/orchestrator/wire-render.ts +0 -92
- 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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
416
|
-
|
|
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,
|
|
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
|
|
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
|
-
//
|
|
435
|
-
// stored with (
|
|
436
|
-
//
|
|
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('',
|
|
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
|
}
|