@vzn/vx 0.0.16 → 0.0.18
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/README.md +66 -89
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +19 -5
- package/plugins/local-cache/index.ts +2 -0
- package/plugins/local-executor/index.ts +2 -0
- package/plugins/schedule-history/index.ts +2 -0
- package/src/bin.ts +2 -2
- package/src/cache/archive.ts +557 -0
- package/src/cache/cache.ts +929 -250
- package/src/cache/cas-backend.ts +20 -2
- package/src/cache/chained-cache.ts +202 -0
- package/src/cache/digest.ts +0 -15
- package/src/cache/index.ts +13 -3
- package/src/cache/inputs.ts +445 -41
- package/src/cache/layered-cache.ts +88 -30
- package/src/cache/tar-stream.ts +358 -0
- package/src/cli/cache.ts +36 -10
- package/src/cli/help.ts +167 -128
- package/src/cli/index.ts +74 -43
- package/src/cli/info.ts +30 -0
- package/src/cli/last.ts +140 -0
- package/src/cli/migrate-emit.ts +21 -0
- package/src/cli/migrate-nx.ts +23 -0
- package/src/cli/migrate-scripts.ts +127 -0
- package/src/cli/migrate-turbo.ts +43 -6
- package/src/cli/migrate.ts +119 -33
- package/src/cli/plan-format.ts +58 -3
- package/src/cli/plugin-commands.ts +96 -0
- package/src/cli/prune.ts +291 -0
- package/src/cli/run.ts +272 -80
- package/src/cli/upgrade.ts +7 -6
- package/src/cli/watch.ts +259 -61
- package/src/cli/why.ts +193 -0
- package/src/config.ts +40 -23
- package/src/exec/env.ts +7 -1
- package/src/exec/executor.ts +239 -0
- package/src/exec/index.ts +19 -1
- package/src/exec/runner.ts +116 -29
- package/src/exec/sandbox-runtime.ts +194 -24
- package/src/graph/index.ts +3 -1
- package/src/graph/scheduler.ts +92 -18
- package/src/graph/task-graph.ts +220 -6
- package/src/index.ts +82 -84
- package/src/orchestrator/deferred-outputs.ts +144 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +30 -0
- package/src/orchestrator/execute-task.ts +328 -94
- package/src/orchestrator/failure-mode.ts +61 -0
- package/src/orchestrator/framed-output.ts +87 -35
- package/src/orchestrator/history.ts +78 -31
- package/src/orchestrator/index.ts +43 -90
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/logger.ts +196 -14
- package/src/orchestrator/metrics.ts +57 -1376
- package/src/orchestrator/missing-plugin.ts +9 -0
- package/src/orchestrator/options.ts +22 -4
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +199 -119
- package/src/orchestrator/plugin.ts +144 -57
- package/src/orchestrator/prepare.ts +220 -69
- package/src/orchestrator/remote-prefetch.ts +12 -8
- package/src/orchestrator/resources.ts +20 -34
- package/src/orchestrator/run-artifacts.ts +41 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-report.ts +73 -46
- package/src/orchestrator/run.ts +590 -122
- package/src/orchestrator/stable-keys.ts +74 -33
- package/src/orchestrator/status-line.ts +47 -8
- package/src/orchestrator/summary.ts +22 -1
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +105 -11
- package/src/orchestrator/task-log-buffer.ts +291 -0
- package/src/orchestrator/telemetry-host.ts +24 -6
- package/src/orchestrator/telemetry.ts +181 -21
- package/src/orchestrator/upstream.ts +72 -31
- package/src/orchestrator/verify.ts +12 -2
- package/src/plugins/local-cache/index.ts +11 -0
- package/src/plugins/local-executor/index.ts +45 -0
- package/src/plugins/schedule-history/index.ts +109 -0
- package/src/util/edit-distance.ts +21 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +7 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +43 -0
- package/src/util/settle.ts +48 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/workspace/affected.ts +142 -15
- package/src/workspace/config-cache.ts +304 -0
- package/src/workspace/config-eval.ts +210 -0
- package/src/workspace/config-imports.ts +161 -0
- package/src/workspace/filter.ts +22 -4
- package/src/workspace/fingerprint.ts +16 -1
- package/src/workspace/index.ts +15 -3
- package/src/workspace/lockfile.ts +13 -2
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +23 -20
- package/src/workspace/project-loader.ts +503 -59
- package/src/workspace/workspace.ts +213 -72
- 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/orchestrator/devframe-surface.ts +0 -74
- package/src/orchestrator/predict.ts +0 -106
- package/src/orchestrator/protocol.ts +0 -156
- package/src/orchestrator/run-state.ts +0 -145
- package/src/orchestrator/wire-render.ts +0 -92
- package/src/orchestrator/wire.ts +0 -212
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// The flakiness verdict, in ONE place. Two surfaces classify the same
|
|
2
|
+
// `runs` rows — the run-history readers and `LocalHistoryProvider` (what
|
|
3
|
+
// `--dry` predictions read) — and they used
|
|
4
|
+
// to encode the rule independently, which let them drift into opposite
|
|
5
|
+
// verdicts on identical data. Both now call `classifyFailureMode`, so the
|
|
6
|
+
// rule cannot fork again.
|
|
7
|
+
|
|
8
|
+
import type { Database } from 'bun:sqlite'
|
|
9
|
+
import { KEYED_RUNS_SQL } from '../cache/index.js'
|
|
10
|
+
import { isCacheHit, TASK_STATUSES } from './telemetry.js'
|
|
11
|
+
|
|
12
|
+
// SQL hit set derived from the predicate — never a hand-typed list and never
|
|
13
|
+
// a prefix LIKE (a prefix counts any status merely NAMED cache-hit-*). The
|
|
14
|
+
// status-vocabulary tripwire greps both wrong forms.
|
|
15
|
+
const HIT_STATUSES = `(${TASK_STATUSES.filter(isCacheHit)
|
|
16
|
+
.map((s) => `'${s}'`)
|
|
17
|
+
.join(', ')})`
|
|
18
|
+
|
|
19
|
+
export type FailureMode = 'stable' | 'flaky-recoverable' | 'flaky-fatal'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Distinct cache keys that produced BOTH a failure and a success — the
|
|
23
|
+
* definitional flake: identical inputs, different outcomes. A failure whose
|
|
24
|
+
* key never succeeded is a legitimate break (a changed input that fails),
|
|
25
|
+
* which belongs to the regressions surface, not flakiness.
|
|
26
|
+
*/
|
|
27
|
+
export function mixedOutcomeKeyCount(db: Database, project: string, task: string): number {
|
|
28
|
+
const row = db
|
|
29
|
+
.query(
|
|
30
|
+
`SELECT COUNT(*) AS n FROM (
|
|
31
|
+
SELECT hash FROM runs
|
|
32
|
+
WHERE project = ? AND task = ? AND ${KEYED_RUNS_SQL}
|
|
33
|
+
GROUP BY hash
|
|
34
|
+
HAVING SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) > 0
|
|
35
|
+
AND SUM(CASE WHEN status = 'success' OR status IN ${HIT_STATUSES} OR cache_hit = 1
|
|
36
|
+
THEN 1 ELSE 0 END) > 0
|
|
37
|
+
)`,
|
|
38
|
+
)
|
|
39
|
+
.get(project, task) as { n: number }
|
|
40
|
+
return row.n
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Flaky requires a NONDETERMINISM signal: a within-run retry, or a cache key
|
|
45
|
+
* that both failed and succeeded. Failures alone — each on its own key — are
|
|
46
|
+
* legitimate breaks, however many there are.
|
|
47
|
+
*
|
|
48
|
+
* The `failures > 0` short-circuit keeps the key-count query off the common
|
|
49
|
+
* path: a task that never failed is stable without touching the DB.
|
|
50
|
+
*/
|
|
51
|
+
export function classifyFailureMode(
|
|
52
|
+
db: Database,
|
|
53
|
+
project: string,
|
|
54
|
+
task: string,
|
|
55
|
+
counts: { total: number; failures: number; retried: number },
|
|
56
|
+
): FailureMode {
|
|
57
|
+
const flakySignal =
|
|
58
|
+
counts.retried > 0 || (counts.failures > 0 && mixedOutcomeKeyCount(db, project, task) > 0)
|
|
59
|
+
if (!flakySignal) return 'stable'
|
|
60
|
+
return counts.failures < counts.total / 5 ? 'flaky-recoverable' : 'flaky-fatal'
|
|
61
|
+
}
|
|
@@ -60,6 +60,14 @@ export interface TaskBlockBody {
|
|
|
60
60
|
stdout?: string
|
|
61
61
|
/** stderr chunks. Renders under `├─ stderr`. */
|
|
62
62
|
stderr?: string
|
|
63
|
+
/**
|
|
64
|
+
* Characters the capture dropped from the head of each stream. Non-zero
|
|
65
|
+
* only for a task whose output is bounded (a persistent one — nothing
|
|
66
|
+
* else can outrun its own exit), and rendered so a capped log can never
|
|
67
|
+
* read as a complete one.
|
|
68
|
+
*/
|
|
69
|
+
droppedStdout?: number
|
|
70
|
+
droppedStderr?: number
|
|
63
71
|
}
|
|
64
72
|
|
|
65
73
|
export function formatTaskBlock(
|
|
@@ -84,16 +92,7 @@ export function formatTaskBlock(
|
|
|
84
92
|
|
|
85
93
|
const idPainted = paintTaskId(node, colors, { bold: true })
|
|
86
94
|
const corner = (s: string) => paint('', s, colors, { dim: true })
|
|
87
|
-
|
|
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
|
-
}
|
|
95
|
+
const section = (title: string, color?: string) => sectionLine(title, color ?? '', colors)
|
|
97
96
|
const header = formatBlockHeader(outcome, colors)
|
|
98
97
|
const lines: string[] = [`${corner('┌─')} ${idPainted} ${corner('>')} ${header}`]
|
|
99
98
|
|
|
@@ -107,17 +106,8 @@ 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
112
|
// Sandbox violations get a dedicated section inside the frame so the
|
|
123
113
|
// user sees them in context with the failing task, not as loose
|
|
@@ -133,6 +123,42 @@ export function formatTaskBlock(
|
|
|
133
123
|
return lines.join('\n') + '\n'
|
|
134
124
|
}
|
|
135
125
|
|
|
126
|
+
/**
|
|
127
|
+
* One `├─ <LABEL>` section of captured output, with the head-dropped notice
|
|
128
|
+
* when the capture was bounded. Shared by the task frame and the persistent
|
|
129
|
+
* tail block so the two renderings of the same bytes cannot drift.
|
|
130
|
+
*/
|
|
131
|
+
function pushStreamSection(
|
|
132
|
+
lines: string[],
|
|
133
|
+
text: string,
|
|
134
|
+
title: string,
|
|
135
|
+
color: string,
|
|
136
|
+
lost: number,
|
|
137
|
+
colors: ColorSupport,
|
|
138
|
+
): void {
|
|
139
|
+
if (text.trim().length === 0) return
|
|
140
|
+
lines.push(sectionLine(title, color, colors), '')
|
|
141
|
+
if (lost > 0) {
|
|
142
|
+
lines.push(paint('', `… ${lost} earlier characters dropped`, colors, { dim: true }))
|
|
143
|
+
}
|
|
144
|
+
pushBodyLines(lines, text)
|
|
145
|
+
lines.push('')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Section labels are bold + state-colored (owner design): stdout green,
|
|
150
|
+
* stderr red, sandbox yellow, command plain white — only the command
|
|
151
|
+
* CONTENT is greyed (context, not signal). A dim rule trails each label to
|
|
152
|
+
* the frame width, and sections get vertical margins so content stands
|
|
153
|
+
* clear of the furniture.
|
|
154
|
+
*/
|
|
155
|
+
function sectionLine(title: string, color: string, colors: ColorSupport): string {
|
|
156
|
+
const corner = (s: string) => paint('', s, colors, { dim: true })
|
|
157
|
+
const label = paint(color, title, colors, { bold: true })
|
|
158
|
+
const rule = corner('─'.repeat(Math.max(1, FRAME_WIDTH - 4 - title.length)))
|
|
159
|
+
return `${corner('├─')} ${label} ${rule}`
|
|
160
|
+
}
|
|
161
|
+
|
|
136
162
|
/**
|
|
137
163
|
* Append each line of `text` raw. Content carries no border/indent so
|
|
138
164
|
* terminal wrapping never collides with frame glyphs and copy/paste
|
|
@@ -143,7 +169,7 @@ function pushBodyLines(lines: string[], text: string): void {
|
|
|
143
169
|
}
|
|
144
170
|
|
|
145
171
|
/** Stable per-project hue: FNV-style fold of the name into the palette. */
|
|
146
|
-
|
|
172
|
+
function projectColor(projectName: string): string {
|
|
147
173
|
let h = 0
|
|
148
174
|
for (let i = 0; i < projectName.length; i++) {
|
|
149
175
|
h = (h * 31 + projectName.charCodeAt(i)) >>> 0
|
|
@@ -172,7 +198,7 @@ export function paintIdParts(
|
|
|
172
198
|
)
|
|
173
199
|
}
|
|
174
200
|
|
|
175
|
-
|
|
201
|
+
function paintId(
|
|
176
202
|
projectName: string,
|
|
177
203
|
taskName: string,
|
|
178
204
|
colors: ColorSupport,
|
|
@@ -181,11 +207,7 @@ export function paintId(
|
|
|
181
207
|
return paintIdParts(projectName, projectName, taskName, colors, opts)
|
|
182
208
|
}
|
|
183
209
|
|
|
184
|
-
|
|
185
|
-
node: TaskNode,
|
|
186
|
-
colors: ColorSupport,
|
|
187
|
-
opts: { bold?: boolean } = {},
|
|
188
|
-
): string {
|
|
210
|
+
function paintTaskId(node: TaskNode, colors: ColorSupport, opts: { bold?: boolean } = {}): string {
|
|
189
211
|
return paintId(node.projectName, node.taskName, colors, opts)
|
|
190
212
|
}
|
|
191
213
|
|
|
@@ -197,8 +219,8 @@ export function paintTaskId(
|
|
|
197
219
|
// fixed cell so durations line up and a ticking elapsed never shifts
|
|
198
220
|
// the row. All detail (exit code, output) lives in the framed block.
|
|
199
221
|
export const TIME_COL = 7 // "0ms" … "999.99s"
|
|
200
|
-
|
|
201
|
-
|
|
222
|
+
const STATUS_COL = 7 // "success" / "running" / "skipped"
|
|
223
|
+
const CACHE_COL = 6 // "remote" / "local" / "fresh" / "miss"
|
|
202
224
|
|
|
203
225
|
/** Right-align the duration in a TIME_COL cell (pad on the left). */
|
|
204
226
|
function timeCell(ms: number | null, colors: ColorSupport): string {
|
|
@@ -271,7 +293,7 @@ function cacheOf(o: TaskOutcome): { word: string; color: string } {
|
|
|
271
293
|
}
|
|
272
294
|
|
|
273
295
|
/** The painted status glyph (shape = cache axis, color = task axis). */
|
|
274
|
-
|
|
296
|
+
function taskGlyph(o: TaskOutcome, colors: ColorSupport): string {
|
|
275
297
|
return paint(statusOf(o).color, glyphShape(o), colors)
|
|
276
298
|
}
|
|
277
299
|
|
|
@@ -385,6 +407,33 @@ function isPersistentNode(node: TaskNode): boolean {
|
|
|
385
407
|
return node.config.exec?.persistent !== undefined
|
|
386
408
|
}
|
|
387
409
|
|
|
410
|
+
/**
|
|
411
|
+
* Everything a persistent task wrote AFTER it signalled ready, as one
|
|
412
|
+
* trailing block. Its outcome landed at ready while the child kept
|
|
413
|
+
* running, so this uses the live frame's `▸ … running` vocabulary rather
|
|
414
|
+
* than the success frame `formatTaskBlock` renders — the block is a tail,
|
|
415
|
+
* not a completed task's log. Section titles say `(since ready)` because
|
|
416
|
+
* the pre-ready output already went out with the outcome, and a
|
|
417
|
+
* head-dropped tail names how much it lost so a truncated log can never
|
|
418
|
+
* read as complete. Empty body → '' (the caller writes nothing).
|
|
419
|
+
*/
|
|
420
|
+
export function formatPersistentTailBlock(
|
|
421
|
+
node: TaskNode,
|
|
422
|
+
outcome: TaskOutcome,
|
|
423
|
+
body: TaskBlockBody,
|
|
424
|
+
dropped: { stdout?: number; stderr?: number } = {},
|
|
425
|
+
colors: ColorSupport = NO_COLOR,
|
|
426
|
+
): string {
|
|
427
|
+
const stdout = body.stdout ?? ''
|
|
428
|
+
const stderr = body.stderr ?? ''
|
|
429
|
+
if (stdout.trim().length === 0 && stderr.trim().length === 0) return ''
|
|
430
|
+
const lines: string[] = [formatFrameOpen(node, colors)]
|
|
431
|
+
pushStreamSection(lines, stdout, 'STDOUT (since ready)', SUCCESS, dropped.stdout ?? 0, colors)
|
|
432
|
+
pushStreamSection(lines, stderr, 'STDERR (since ready)', ERROR, dropped.stderr ?? 0, colors)
|
|
433
|
+
lines.push(formatFrameClose(node, outcome, colors))
|
|
434
|
+
return lines.join('\n') + '\n'
|
|
435
|
+
}
|
|
436
|
+
|
|
388
437
|
/**
|
|
389
438
|
* Between the requested task's frame and the summary footer, list the
|
|
390
439
|
* persistent tasks (dev servers / watchers) the run is keeping alive in
|
|
@@ -430,10 +479,13 @@ function formatBlockHeader(o: TaskOutcome, colors: ColorSupport): string {
|
|
|
430
479
|
}
|
|
431
480
|
|
|
432
481
|
function formatBlockFooter(o: TaskOutcome, colors: ColorSupport): string {
|
|
433
|
-
// Footer pattern: ` (<dur>) <status>`. Duration is always shown
|
|
434
|
-
//
|
|
435
|
-
// stored with (
|
|
436
|
-
//
|
|
482
|
+
// Footer pattern: ` (<dur>) <status>`. Duration is always shown, and it is
|
|
483
|
+
// always what THIS run spent — for a cache hit, the probe + restore, not
|
|
484
|
+
// the exec time the entry was stored with. (The comment here used to claim
|
|
485
|
+
// the opposite; the code never did, and `--report` summed these as "time
|
|
486
|
+
// saved" on the strength of it. The stored time lives on
|
|
487
|
+
// `TaskOutcome.storedDurationMs`.) Status differs by outcome — see
|
|
488
|
+
// formatStatusTag.
|
|
437
489
|
const dur = paint('', `(${formatDuration(o.durationMs)})`, colors, { dim: true })
|
|
438
490
|
const tag = formatStatusTag(o, colors)
|
|
439
491
|
return ` ${dur} ${tag}`
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
// when such an RPC actually exists.
|
|
13
13
|
|
|
14
14
|
import type { Database } from 'bun:sqlite'
|
|
15
|
+
import { EXECUTED_RUNS_SQL } from '../cache/index.js'
|
|
16
|
+
import { classifyFailureMode } from './failure-mode.js'
|
|
17
|
+
import type { FailureMode } from './failure-mode.js'
|
|
15
18
|
|
|
16
19
|
const DEFAULT_RECENT = 50
|
|
17
20
|
|
|
@@ -27,8 +30,9 @@ export interface TaskHistory {
|
|
|
27
30
|
successRate: number
|
|
28
31
|
/** Cache hit rate over the recent window ([0, 1]). */
|
|
29
32
|
hitRate: number
|
|
30
|
-
/** Failure mode classification.
|
|
31
|
-
|
|
33
|
+
/** Failure mode classification. Shares `classifyFailureMode` with
|
|
34
|
+
* the run-history readers so the two surfaces cannot disagree. */
|
|
35
|
+
failureMode: FailureMode
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
/** Map keyed by `project#task`. */
|
|
@@ -56,11 +60,26 @@ export class LocalHistoryProvider implements HistoryProvider {
|
|
|
56
60
|
const out = new Map<string, TaskHistory>()
|
|
57
61
|
if (taskIds.length === 0) return out
|
|
58
62
|
|
|
63
|
+
// Split `project#task` into (project, task) pairs so the WHERE filters on a
|
|
64
|
+
// ROW-VALUE `IN (VALUES …)` the `runs(project, task)` index can SEARCH
|
|
65
|
+
// (seek) — filtering on the concatenated `project || '#' || task`
|
|
66
|
+
// expression forced a full index SCAN instead. Split on the FIRST `#`:
|
|
67
|
+
// task names never contain `#`, and a project name (a package name) never
|
|
68
|
+
// does either.
|
|
69
|
+
const pairs = (taskIds as string[]).map(splitTaskId)
|
|
70
|
+
const tupleParams = pairs.flat()
|
|
71
|
+
const tuplePlaceholders = pairs.map(() => '(?,?)').join(',')
|
|
72
|
+
|
|
59
73
|
// One CTE per call: rank rows per (project, task) descending by
|
|
60
74
|
// started_at, keep the top `recent`, aggregate. Cache-hit rows
|
|
61
75
|
// (cache_hit = 1) are excluded from the duration percentiles so
|
|
62
76
|
// p50/p99 reflect work the runner actually did. successRate +
|
|
63
77
|
// hitRate are computed over ALL recent rows.
|
|
78
|
+
//
|
|
79
|
+
// `skipped` rows are excluded from the window entirely: a skip is a task
|
|
80
|
+
// the run never executed, so it neither belongs in a success/hit RATE nor
|
|
81
|
+
// deserves to occupy one of the `recent` slots — a task whose upstream
|
|
82
|
+
// keeps breaking would otherwise push its own real history out of view.
|
|
64
83
|
const sql = `
|
|
65
84
|
WITH recent AS (
|
|
66
85
|
SELECT
|
|
@@ -69,9 +88,11 @@ export class LocalHistoryProvider implements HistoryProvider {
|
|
|
69
88
|
status,
|
|
70
89
|
duration_ms,
|
|
71
90
|
cache_hit,
|
|
91
|
+
attempts,
|
|
72
92
|
ROW_NUMBER() OVER (PARTITION BY project, task ORDER BY started_at DESC) AS rn
|
|
73
93
|
FROM runs
|
|
74
|
-
WHERE (project
|
|
94
|
+
WHERE (project, task) IN (VALUES ${tuplePlaceholders})
|
|
95
|
+
AND ${EXECUTED_RUNS_SQL}
|
|
75
96
|
)
|
|
76
97
|
SELECT
|
|
77
98
|
project,
|
|
@@ -80,7 +101,8 @@ export class LocalHistoryProvider implements HistoryProvider {
|
|
|
80
101
|
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) AS successes,
|
|
81
102
|
SUM(CASE WHEN cache_hit = 1 THEN 1 ELSE 0 END) AS hits,
|
|
82
103
|
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
|
|
104
|
+
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failures,
|
|
105
|
+
SUM(CASE WHEN attempts > 1 THEN 1 ELSE 0 END) AS retried
|
|
84
106
|
FROM recent
|
|
85
107
|
WHERE rn <= ${this.recent}
|
|
86
108
|
GROUP BY project, task
|
|
@@ -94,20 +116,30 @@ export class LocalHistoryProvider implements HistoryProvider {
|
|
|
94
116
|
hits: number
|
|
95
117
|
executed: number
|
|
96
118
|
failures: number
|
|
119
|
+
retried: number
|
|
97
120
|
}
|
|
98
|
-
const rows = this.db.query(sql).all(...
|
|
121
|
+
const rows = this.db.query(sql).all(...tupleParams) as Row[]
|
|
122
|
+
|
|
123
|
+
// Percentiles need a row-wise read (percentile_cont isn't in SQLite), but
|
|
124
|
+
// ONE windowed query pulls the last `recent` EXECUTED+SUCCESS durations for
|
|
125
|
+
// EVERY task at once — the old per-task `percentilesFor` was an N+1 (one
|
|
126
|
+
// query per task on top of the counts CTE). Same per-task window (last
|
|
127
|
+
// `recent` executed-success rows by started_at DESC), grouped in JS.
|
|
128
|
+
const durationsByTask = this.durationsFor(taskIds)
|
|
99
129
|
|
|
100
130
|
for (const row of rows) {
|
|
101
131
|
const key = `${row.project}#${row.task}`
|
|
102
132
|
const total = row.total || 0
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
failures
|
|
106
|
-
|
|
133
|
+
const failureMode = classifyFailureMode(this.db, row.project, row.task, {
|
|
134
|
+
total,
|
|
135
|
+
failures: row.failures || 0,
|
|
136
|
+
retried: row.retried || 0,
|
|
137
|
+
})
|
|
138
|
+
const durations = durationsByTask.get(key)
|
|
107
139
|
out.set(key, {
|
|
108
140
|
runs: total,
|
|
109
|
-
p50DurationMs:
|
|
110
|
-
p99DurationMs:
|
|
141
|
+
p50DurationMs: durations ? pickPercentile(durations, 0.5) : undefined,
|
|
142
|
+
p99DurationMs: durations ? pickPercentile(durations, 0.99) : undefined,
|
|
111
143
|
successRate: total > 0 ? (row.successes || 0) / total : 0,
|
|
112
144
|
hitRate: total > 0 ? (row.hits || 0) / total : 0,
|
|
113
145
|
failureMode,
|
|
@@ -116,31 +148,46 @@ export class LocalHistoryProvider implements HistoryProvider {
|
|
|
116
148
|
return out
|
|
117
149
|
}
|
|
118
150
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
151
|
+
/**
|
|
152
|
+
* The last `recent` executed-success `duration_ms` values per `project#task`,
|
|
153
|
+
* ASCENDING (ready for percentile picking), in ONE windowed query for the
|
|
154
|
+
* whole task set. A `project#task` with no executed-success rows is absent.
|
|
155
|
+
*/
|
|
156
|
+
private durationsFor(taskIds: readonly string[]): Map<string, number[]> {
|
|
157
|
+
const pairs = (taskIds as string[]).map(splitTaskId)
|
|
158
|
+
const sql = `
|
|
159
|
+
SELECT project, task, duration_ms FROM (
|
|
160
|
+
SELECT project, task, duration_ms,
|
|
161
|
+
ROW_NUMBER() OVER (PARTITION BY project, task ORDER BY started_at DESC) AS rn
|
|
162
|
+
FROM runs
|
|
163
|
+
WHERE (project, task) IN (VALUES ${pairs.map(() => '(?,?)').join(',')})
|
|
164
|
+
AND (cache_hit IS NULL OR cache_hit = 0)
|
|
165
|
+
AND status = 'success'
|
|
133
166
|
)
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
167
|
+
WHERE rn <= ${this.recent}`
|
|
168
|
+
const rows = this.db.query(sql).all(...pairs.flat()) as {
|
|
169
|
+
project: string
|
|
170
|
+
task: string
|
|
171
|
+
duration_ms: number
|
|
172
|
+
}[]
|
|
173
|
+
const out = new Map<string, number[]>()
|
|
174
|
+
for (const r of rows) {
|
|
175
|
+
const key = `${r.project}#${r.task}`
|
|
176
|
+
const list = out.get(key)
|
|
177
|
+
if (list) list.push(r.duration_ms)
|
|
178
|
+
else out.set(key, [r.duration_ms])
|
|
140
179
|
}
|
|
180
|
+
for (const list of out.values()) list.sort((a, b) => a - b)
|
|
181
|
+
return out
|
|
141
182
|
}
|
|
142
183
|
}
|
|
143
184
|
|
|
185
|
+
/** Split a `project#task` id on its FIRST `#` (task names contain no `#`). */
|
|
186
|
+
function splitTaskId(id: string): [string, string] {
|
|
187
|
+
const i = id.indexOf('#')
|
|
188
|
+
return i < 0 ? [id, ''] : [id.slice(0, i), id.slice(i + 1)]
|
|
189
|
+
}
|
|
190
|
+
|
|
144
191
|
function pickPercentile(sorted: number[], q: number): number {
|
|
145
192
|
if (sorted.length === 0) return 0
|
|
146
193
|
const idx = Math.min(sorted.length - 1, Math.floor(q * sorted.length))
|
|
@@ -11,7 +11,8 @@ export type { RunOptions, RunSummary } from './options.js'
|
|
|
11
11
|
export { type CachePolicy, FULL_CACHE_POLICY, parseCachePolicy } from '../cache/index.js'
|
|
12
12
|
export { defaultLogger, resolveOutputView } from './logger.js'
|
|
13
13
|
export type { Logger, OutputView } from './logger.js'
|
|
14
|
-
export type { RunPlan, PlannedTask, CacheStatus } from './plan.js'
|
|
14
|
+
export type { RunPlan, PlannedTask, PlanPrediction, CacheStatus } from './plan.js'
|
|
15
|
+
export { formatDuration } from './summary.js'
|
|
15
16
|
export {
|
|
16
17
|
createEventBus,
|
|
17
18
|
wireForwarder,
|
|
@@ -27,39 +28,7 @@ export type {
|
|
|
27
28
|
TaskView,
|
|
28
29
|
OutcomeView,
|
|
29
30
|
} from './events.js'
|
|
30
|
-
export {
|
|
31
|
-
export { createWireRenderer } from './wire-render.js'
|
|
32
|
-
export { optionsToRequest, requestToOptions } from './protocol.js'
|
|
33
|
-
export type { ClientMessage, RunBackend, RunRequest, RunResult, ServerMessage } from './protocol.js'
|
|
34
|
-
export { formatRunReportMarkdown } from './run-report.js'
|
|
35
|
-
export {
|
|
36
|
-
clientMessageToEnvelope,
|
|
37
|
-
decodeEnvelope,
|
|
38
|
-
encodeForNDJSON,
|
|
39
|
-
encodeForSSE,
|
|
40
|
-
encodeForWS,
|
|
41
|
-
ENVELOPE_ERRORS,
|
|
42
|
-
envelopeToClientMessage,
|
|
43
|
-
envelopeToServerMessage,
|
|
44
|
-
isEnvelope,
|
|
45
|
-
isNotification,
|
|
46
|
-
isRequest,
|
|
47
|
-
makeError,
|
|
48
|
-
makeNotification,
|
|
49
|
-
makeRequest,
|
|
50
|
-
makeResponse,
|
|
51
|
-
serverMessageToEnvelope,
|
|
52
|
-
WIRE_CHANNELS,
|
|
53
|
-
WIRE_PROTOCOL_VERSION,
|
|
54
|
-
} from './wire.js'
|
|
55
|
-
export type {
|
|
56
|
-
Envelope,
|
|
57
|
-
ErrorResponse,
|
|
58
|
-
Notification,
|
|
59
|
-
Request as WireRequest,
|
|
60
|
-
Response as WireResponse,
|
|
61
|
-
WireChannel,
|
|
62
|
-
} from './wire.js'
|
|
31
|
+
export { escapeMarkdownCell, formatRunReportMarkdown, type RunResult } from './run-report.js'
|
|
63
32
|
export {
|
|
64
33
|
EmptyHistoryProvider,
|
|
65
34
|
type HistoryProvider,
|
|
@@ -67,13 +36,17 @@ export {
|
|
|
67
36
|
LocalHistoryProvider,
|
|
68
37
|
type TaskHistory,
|
|
69
38
|
} from './history.js'
|
|
70
|
-
export { computePredictedPriorities } from './predict.js'
|
|
71
39
|
export {
|
|
72
40
|
installPlugins,
|
|
73
|
-
type BackendContext,
|
|
74
41
|
type CacheContext,
|
|
75
|
-
type
|
|
76
|
-
type
|
|
42
|
+
type ExecutorContext,
|
|
43
|
+
type CommandContext,
|
|
44
|
+
type GraphHookContext,
|
|
45
|
+
type KeyHookContext,
|
|
46
|
+
type PluginCommand,
|
|
47
|
+
type ProjectHookContext,
|
|
48
|
+
type ScheduleHookContext,
|
|
49
|
+
type WorkspaceHookContext,
|
|
77
50
|
type InstallPluginsArgs,
|
|
78
51
|
type Plugin,
|
|
79
52
|
type PluginContext,
|
|
@@ -82,9 +55,31 @@ export {
|
|
|
82
55
|
type PluginSetupContext,
|
|
83
56
|
type VxPlugin,
|
|
84
57
|
} from './plugin.js'
|
|
85
|
-
export {
|
|
58
|
+
export {
|
|
59
|
+
applyConfigHooks,
|
|
60
|
+
applyGraphHooks,
|
|
61
|
+
applyKeyHooks,
|
|
62
|
+
applyProjectHooks,
|
|
63
|
+
applyScheduleHooks,
|
|
64
|
+
hasHook,
|
|
65
|
+
resolveCache,
|
|
66
|
+
resolveExecutors,
|
|
67
|
+
} from './plugin-host.js'
|
|
68
|
+
export { MISSING_PLUGIN_HINT } from './missing-plugin.js'
|
|
86
69
|
export { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
|
|
87
|
-
|
|
70
|
+
// The bounded log-capture buffer every telemetry sink shares — see the
|
|
71
|
+
// module header for why one implementation, not one per sink.
|
|
72
|
+
export { LOG_WIRE_VERSION, TaskLogBuffer } from './task-log-buffer.js'
|
|
73
|
+
export type { TaskLogBundle, TaskLogEntry } from './task-log-buffer.js'
|
|
74
|
+
export {
|
|
75
|
+
assembleRunSummary,
|
|
76
|
+
createTelemetrySource,
|
|
77
|
+
deriveCacheSource,
|
|
78
|
+
isCacheHit,
|
|
79
|
+
isPassStatus,
|
|
80
|
+
TASK_STATUSES,
|
|
81
|
+
TELEMETRY_SCHEMA_VERSION,
|
|
82
|
+
} from './telemetry.js'
|
|
88
83
|
export type {
|
|
89
84
|
CacheSource,
|
|
90
85
|
RunContextRecord,
|
|
@@ -100,75 +95,33 @@ export type {
|
|
|
100
95
|
export { diffOutputTrees } from './verify.js'
|
|
101
96
|
export { deriveStableKeys } from './stable-keys.js'
|
|
102
97
|
export type { DeriveStableKeysArgs, StableKey } from './stable-keys.js'
|
|
103
|
-
export {
|
|
104
|
-
|
|
98
|
+
export {
|
|
99
|
+
captureDefaultBranch,
|
|
100
|
+
captureGitContext,
|
|
101
|
+
captureHostContext,
|
|
102
|
+
captureWorkspaceIdentity,
|
|
103
|
+
detectCi,
|
|
104
|
+
} from './run-context.js'
|
|
105
|
+
export type { CiContext, GitContext, HostContext, WorkspaceIdentity } from './run-context.js'
|
|
105
106
|
export {
|
|
106
107
|
cacheKeyDiff,
|
|
107
|
-
compareRuns,
|
|
108
108
|
explainCacheKey as explainCacheKeyQuery,
|
|
109
|
-
getBottlenecks,
|
|
110
|
-
getCacheBreakdown,
|
|
111
|
-
getCacheSavings,
|
|
112
|
-
getCacheStatsSql,
|
|
113
|
-
getFlakiestTasks,
|
|
114
|
-
getHistory,
|
|
115
|
-
getHitRateSplit,
|
|
116
109
|
getInvocation,
|
|
117
|
-
getParallelismHistory,
|
|
118
|
-
getPeriodComparison,
|
|
119
|
-
getPrunableEntries,
|
|
120
|
-
getRecentFailures,
|
|
121
|
-
getRegressions,
|
|
122
110
|
getRun,
|
|
123
|
-
getRunHeatmap,
|
|
124
|
-
getRunTrends,
|
|
125
|
-
getStorageGrowth,
|
|
126
|
-
getTaskDetail,
|
|
127
|
-
getTopTimeBurners,
|
|
128
|
-
listCacheEntries,
|
|
129
111
|
listInvocations,
|
|
130
|
-
listProjects,
|
|
131
112
|
listRuns,
|
|
132
113
|
whyDidThisRerun as whyDidThisRerunQuery,
|
|
133
114
|
} from './metrics.js'
|
|
134
115
|
export type {
|
|
135
|
-
BottleneckRow,
|
|
136
116
|
CacheEntryRow,
|
|
137
117
|
CacheKeyDiff,
|
|
138
118
|
CacheKeyExplanation,
|
|
139
|
-
CacheProjectRow,
|
|
140
|
-
CacheSavings,
|
|
141
|
-
CacheStatsResult,
|
|
142
|
-
CompareRuns,
|
|
143
|
-
CompareTaskRow,
|
|
144
|
-
CompareTaskSide,
|
|
145
|
-
FailureRow,
|
|
146
|
-
FlakyTask,
|
|
147
|
-
GetHistoryArgs,
|
|
148
|
-
HeatmapCell,
|
|
149
|
-
HitRateSplit,
|
|
150
119
|
InputDiffEntry,
|
|
151
120
|
InvocationDetail,
|
|
152
121
|
InvocationRow,
|
|
153
|
-
ListCacheEntriesArgs,
|
|
154
122
|
ListInvocationsArgs,
|
|
155
123
|
ListRunsArgs,
|
|
156
|
-
ParallelismPoint,
|
|
157
|
-
PeriodComparison,
|
|
158
|
-
PeriodComparisonArgs,
|
|
159
|
-
PeriodStats,
|
|
160
|
-
PrunableEntry,
|
|
161
|
-
ProjectRollup,
|
|
162
|
-
RegressedTask,
|
|
163
|
-
RegressionArgs,
|
|
164
|
-
TaskMover,
|
|
165
124
|
RunDetail,
|
|
166
125
|
RunSummaryRow,
|
|
167
|
-
StoragePoint,
|
|
168
|
-
TaskDetail,
|
|
169
|
-
TaskHistoryRow,
|
|
170
|
-
TopTaskRow,
|
|
171
|
-
TrendBucket,
|
|
172
|
-
TrendPoint,
|
|
173
126
|
WhyDidThisRerun,
|
|
174
127
|
} from './metrics.js'
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
// conservative exclusion). Those tasks still get an exec-tier probe
|
|
39
39
|
// reuse entry so there's no double work.
|
|
40
40
|
|
|
41
|
+
import { span } from '../util/index.js'
|
|
41
42
|
import type { CacheEntry, CacheLayer, GitFilesCache } from '../cache/index.js'
|
|
42
43
|
import type { TaskNode } from '../graph/index.js'
|
|
43
44
|
import { deriveStableKeys } from './stable-keys.js'
|
|
@@ -95,10 +96,13 @@ const EMPTY: ShortCircuit = { preProbed: new Map(), restoreTier: new Set() }
|
|
|
95
96
|
*/
|
|
96
97
|
export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<ShortCircuit> {
|
|
97
98
|
let stableKeys
|
|
99
|
+
const endKeys = span('stable keys')
|
|
98
100
|
try {
|
|
99
101
|
stableKeys = await deriveStableKeys(args)
|
|
100
102
|
} catch {
|
|
101
103
|
return EMPTY
|
|
104
|
+
} finally {
|
|
105
|
+
endKeys()
|
|
102
106
|
}
|
|
103
107
|
const candidates = stableKeys.filter(({ node }) => node.config.cache !== undefined)
|
|
104
108
|
if (candidates.length === 0) return EMPTY
|
|
@@ -125,6 +129,24 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
|
|
|
125
129
|
// it); a miss stays exec-tier with a known-miss reuse entry. The pool
|
|
126
130
|
// keeps the up-front pass from being a serial pre-phase on a wide warm
|
|
127
131
|
// graph — the probes parallelize at the run's concurrency.
|
|
132
|
+
// A layer with a batched `get` answers every probe in a couple of
|
|
133
|
+
// queries; the pool below is for layers without one.
|
|
134
|
+
if (args.cache.getMany !== undefined) {
|
|
135
|
+
try {
|
|
136
|
+
const endProbe = span('probe')
|
|
137
|
+
const hits = await args.cache.getMany(candidates.map((c) => c.hash))
|
|
138
|
+
endProbe()
|
|
139
|
+
for (const { hash, node } of candidates) {
|
|
140
|
+
const hit = hits.get(hash) ?? null
|
|
141
|
+
preProbed.set(node.id, { hash, hit })
|
|
142
|
+
if (hit !== null && !anyWorkspaceOutputs) restoreTier.add(node.id)
|
|
143
|
+
}
|
|
144
|
+
return { preProbed, restoreTier }
|
|
145
|
+
} catch {
|
|
146
|
+
// Fall through to the per-hash pool, which isolates a failing probe
|
|
147
|
+
// to its own task.
|
|
148
|
+
}
|
|
149
|
+
}
|
|
128
150
|
let next = 0
|
|
129
151
|
const workers = Math.max(1, Math.min(args.concurrency, candidates.length))
|
|
130
152
|
const pump = async (): Promise<void> => {
|