@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.
- package/LICENSE +21 -0
- package/README.md +349 -1
- package/launcher.mjs +92 -0
- package/package.json +44 -3
- package/src/bin.ts +25 -0
- package/src/cache/cache.ts +1832 -0
- package/src/cache/cas-backend.ts +97 -0
- package/src/cache/digest.ts +47 -0
- package/src/cache/index.ts +28 -0
- package/src/cache/inputs.ts +839 -0
- package/src/cache/layered-cache.ts +355 -0
- package/src/cache/tar.ts +343 -0
- package/src/cli/backend.ts +23 -0
- package/src/cli/cache.ts +96 -0
- package/src/cli/format.ts +15 -0
- package/src/cli/help.ts +129 -0
- package/src/cli/index.ts +85 -0
- package/src/cli/info.ts +80 -0
- package/src/cli/lock.ts +135 -0
- package/src/cli/mcp-rpc.ts +292 -0
- package/src/cli/mcp.ts +92 -0
- package/src/cli/migrate-nx.ts +450 -0
- package/src/cli/migrate-turbo.ts +364 -0
- package/src/cli/migrate.ts +219 -0
- package/src/cli/plan-format.ts +148 -0
- package/src/cli/run.ts +683 -0
- package/src/cli/show.ts +201 -0
- package/src/cli/upgrade.ts +103 -0
- package/src/cli/watch.ts +287 -0
- package/src/config.ts +460 -0
- package/src/exec/env.ts +74 -0
- package/src/exec/index.ts +13 -0
- package/src/exec/runner.ts +470 -0
- package/src/exec/sandbox-runtime.ts +578 -0
- package/src/graph/dependency-spec.ts +85 -0
- package/src/graph/index.ts +27 -0
- package/src/graph/scheduler.ts +677 -0
- package/src/graph/task-graph.ts +377 -0
- package/src/index.ts +222 -0
- package/src/orchestrator/colors.ts +63 -0
- package/src/orchestrator/devframe-surface.ts +74 -0
- package/src/orchestrator/events.ts +277 -0
- package/src/orchestrator/execute-task.ts +939 -0
- package/src/orchestrator/framed-output.ts +461 -0
- package/src/orchestrator/history.ts +148 -0
- package/src/orchestrator/index.ts +174 -0
- package/src/orchestrator/local-shortcircuit.ts +147 -0
- package/src/orchestrator/logger.ts +526 -0
- package/src/orchestrator/metrics.ts +1927 -0
- package/src/orchestrator/options.ts +197 -0
- package/src/orchestrator/plan.ts +129 -0
- package/src/orchestrator/plugin-host.ts +185 -0
- package/src/orchestrator/plugin.ts +283 -0
- package/src/orchestrator/predict.ts +106 -0
- package/src/orchestrator/prepare.ts +306 -0
- package/src/orchestrator/protocol.ts +156 -0
- package/src/orchestrator/remote-prefetch.ts +81 -0
- package/src/orchestrator/resources.ts +59 -0
- package/src/orchestrator/run-artifacts.ts +111 -0
- package/src/orchestrator/run-context.ts +205 -0
- package/src/orchestrator/run-report.ts +132 -0
- package/src/orchestrator/run-state.ts +145 -0
- package/src/orchestrator/run.ts +887 -0
- package/src/orchestrator/stable-keys.ts +177 -0
- package/src/orchestrator/status-line.ts +287 -0
- package/src/orchestrator/summary.ts +315 -0
- package/src/orchestrator/tally.ts +69 -0
- package/src/orchestrator/task-hash.ts +253 -0
- package/src/orchestrator/telemetry-host.ts +80 -0
- package/src/orchestrator/telemetry.ts +358 -0
- package/src/orchestrator/upstream.ts +121 -0
- package/src/orchestrator/verify.ts +164 -0
- package/src/orchestrator/wire-render.ts +92 -0
- package/src/orchestrator/wire.ts +212 -0
- package/src/util/errors.ts +11 -0
- package/src/util/hash.ts +25 -0
- package/src/util/index.ts +8 -0
- package/src/util/paths.ts +16 -0
- package/src/util/size.ts +16 -0
- package/src/util/ulid.ts +15 -0
- package/src/version.ts +10 -0
- package/src/workspace/affected.ts +118 -0
- package/src/workspace/filter.ts +155 -0
- package/src/workspace/fingerprint.ts +41 -0
- package/src/workspace/index.ts +27 -0
- package/src/workspace/lockfile.ts +118 -0
- package/src/workspace/nested-dirs.ts +34 -0
- package/src/workspace/package-graph.ts +148 -0
- package/src/workspace/project-loader.ts +512 -0
- 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
|
+
}
|