@vzn/vx 0.0.637 → 0.0.639
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/docs/caching.md +84 -28
- package/docs/cli.md +273 -408
- package/docs/execution.md +19 -44
- package/docs/features.md +13 -15
- package/docs/patterns.md +5 -7
- package/docs/schema.md +68 -61
- package/package.json +5 -5
- package/schemas/last.json +7 -4
- package/schemas/plan.json +2 -2
- package/skills/vx/SKILL.md +19 -6
- package/src/bin.ts +13 -7
- package/src/cache/cache.ts +60 -11
- package/src/cache/git-inputs.ts +6 -0
- package/src/cache/index.ts +10 -2
- package/src/cache/inputs.ts +0 -24
- package/src/cache/layer.ts +32 -12
- package/src/cache/layered-cache.ts +112 -315
- package/src/cache/plugin-cache.ts +240 -0
- package/src/cache/policy.ts +9 -0
- package/src/cache/remote-lane.ts +383 -0
- package/src/cache/run-history.ts +92 -8
- package/src/cache/run-store.ts +77 -0
- package/src/cache/schema.ts +44 -13
- package/src/cache/scoped-cache.ts +22 -2
- package/src/cli/cache.ts +33 -16
- package/src/cli/completions.ts +11 -7
- package/src/cli/foreign-flags.ts +10 -22
- package/src/cli/help.ts +22 -38
- package/src/cli/index.ts +25 -11
- package/src/cli/info.ts +8 -6
- package/src/cli/init.ts +14 -15
- package/src/cli/last.ts +20 -10
- package/src/cli/lock.ts +39 -27
- package/src/cli/plan-format.ts +10 -61
- package/src/cli/plugin-commands.ts +77 -6
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +31 -191
- package/src/cli/select.ts +18 -9
- package/src/cli/show.ts +72 -40
- package/src/cli/task-verb.ts +4 -4
- package/src/cli/upgrade.ts +132 -39
- package/src/cli/watch-cycle.ts +2 -1
- package/src/cli/watch-filter.ts +7 -2
- package/src/cli/watch-judge.ts +6 -1
- package/src/cli/watch.ts +73 -34
- package/src/cli/why.ts +14 -5
- package/src/cli/workspace-config.ts +2 -2
- package/src/config.ts +0 -6
- package/src/exec/executor.ts +1 -1
- package/src/exec/sandbox-runtime.ts +1 -1
- package/src/exec/sandbox-violations.ts +3 -1
- package/src/graph/index.ts +9 -1
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +12 -16
- package/src/graph/task-graph.ts +119 -21
- package/src/graph/task-status.ts +95 -0
- package/src/{orchestrator → history}/failure-mode.ts +20 -11
- package/src/{orchestrator → history}/history.ts +61 -5
- package/src/history/index.ts +48 -0
- package/src/{orchestrator → history}/metrics.ts +14 -23
- package/src/{orchestrator → history}/miss-reason.ts +1 -1
- package/src/index.ts +21 -29
- package/src/orchestrator/affected-tasks.ts +8 -3
- package/src/orchestrator/doctor.ts +2 -2
- package/src/orchestrator/execute-args.ts +80 -0
- package/src/orchestrator/execute-task.ts +47 -429
- package/src/orchestrator/fingerprint-watch.ts +2 -2
- package/src/orchestrator/index.ts +3 -74
- package/src/orchestrator/miss-save.ts +33 -3
- package/src/orchestrator/options.ts +1 -36
- package/src/orchestrator/output-host.ts +4 -3
- package/src/orchestrator/persistent-task.ts +321 -0
- package/src/orchestrator/persistent.ts +130 -3
- package/src/orchestrator/placement.ts +3 -3
- package/src/orchestrator/plan.ts +18 -5
- package/src/orchestrator/plugin-host.ts +77 -47
- package/src/orchestrator/plugin.ts +86 -14
- package/src/orchestrator/prepare.ts +74 -31
- package/src/orchestrator/projects.ts +3 -0
- package/src/orchestrator/remote-prefetch.ts +23 -6
- package/src/orchestrator/run-artifacts.ts +5 -177
- package/src/orchestrator/run-context.ts +55 -0
- package/src/orchestrator/run-failures.ts +15 -3
- package/src/orchestrator/run-records.ts +36 -4
- package/src/orchestrator/run-report.ts +81 -179
- package/src/orchestrator/run-settings.ts +1 -16
- package/src/orchestrator/run-stop.ts +153 -0
- package/src/orchestrator/run.ts +313 -555
- package/src/orchestrator/sandbox-fix.ts +0 -16
- package/src/orchestrator/signals.ts +14 -6
- package/src/orchestrator/stable-keys.ts +107 -3
- package/src/orchestrator/steps.ts +50 -0
- package/src/orchestrator/task-hash.ts +3 -16
- package/src/orchestrator/task-log-buffer.ts +1 -1
- package/src/orchestrator/task-process.ts +94 -0
- package/src/orchestrator/telemetry-host.ts +1 -1
- package/src/orchestrator/telemetry.ts +6 -83
- package/src/{orchestrator → report}/events.ts +41 -8
- package/src/{orchestrator → report}/forecast.ts +35 -2
- package/src/{orchestrator → report}/framed-output.ts +39 -34
- package/src/report/index.ts +36 -0
- package/src/{orchestrator → report}/logger.ts +166 -21
- package/src/{orchestrator → report}/status-line.ts +84 -7
- package/src/{orchestrator → report}/summary.ts +179 -63
- package/src/report/tally.ts +83 -0
- package/src/settings.ts +13 -23
- package/src/{orchestrator → util}/colors.ts +49 -4
- package/src/util/config-frame.ts +31 -2
- package/src/util/errors.ts +32 -0
- package/src/util/git-ignored.ts +143 -0
- package/src/util/index.ts +25 -3
- package/src/util/refusal-notice.ts +111 -0
- package/src/util/report.ts +39 -0
- package/src/util/settle.ts +14 -39
- package/src/util/tail.ts +1 -1
- package/src/util/ui.ts +197 -0
- package/src/workspace/affected.ts +20 -23
- package/src/workspace/dependency-rules.ts +12 -4
- package/src/workspace/migration.ts +87 -91
- package/src/workspace/workspace.ts +5 -4
- package/schemas/summary.json +0 -133
- package/src/cache/chained-cache.ts +0 -276
- package/src/orchestrator/tally.ts +0 -112
- /package/src/{orchestrator → report}/path-links.ts +0 -0
- /package/src/{orchestrator → report}/plain-output.ts +0 -0
- /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
package/src/graph/task-graph.ts
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import type { ProjectConfig, TaskConfig, WorkspaceRules } from '../config.js'
|
|
2
2
|
import {
|
|
3
3
|
asTrees,
|
|
4
|
+
checkIgnoreSync,
|
|
5
|
+
ignoreProbes,
|
|
4
6
|
isLiteralPattern,
|
|
7
|
+
listedSync,
|
|
5
8
|
normalizeGlob,
|
|
6
9
|
relPosix,
|
|
7
10
|
splitNegations,
|
|
8
11
|
staticPrefix,
|
|
9
12
|
taskGlob,
|
|
13
|
+
refusal,
|
|
10
14
|
UserError,
|
|
15
|
+
wholeIgnored,
|
|
11
16
|
wholeSubtreePrefixes,
|
|
12
17
|
} from '../util/index.js'
|
|
13
18
|
import type { PackageGraph, ProjectEntry } from '../workspace/index.js'
|
|
@@ -583,7 +588,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
583
588
|
// task behind it would fold no upstream either.
|
|
584
589
|
if (!packageGraph.edgeSource) {
|
|
585
590
|
throw new UserError(
|
|
586
|
-
`${id}: ^${spec.task} needs project edges; add pnpm()/bun()/npm()/yarn() or a plugin that returns \`deps\``,
|
|
591
|
+
`${id}: ^${spec.task} needs project edges; add pnpm()/bun()/npm()/yarn() and install so its lockfile exists, or a plugin that returns \`deps\``,
|
|
587
592
|
'VX_E_DEPENDS_ON',
|
|
588
593
|
)
|
|
589
594
|
}
|
|
@@ -969,6 +974,19 @@ function witnessed(a: string, b: string): boolean {
|
|
|
969
974
|
if (ga.match(candidate) && gb.match(candidate)) return true
|
|
970
975
|
}
|
|
971
976
|
}
|
|
977
|
+
// Each glob filled in whole, every wildcard a name and `**` one level or
|
|
978
|
+
// none: `**/dist/**` beside `**/*` meets at `x/dist/x`, a path neither
|
|
979
|
+
// last segment builds (solid, M 2026-10-15).
|
|
980
|
+
for (const glob of [a, b]) {
|
|
981
|
+
const segs = glob.split('/')
|
|
982
|
+
if (segs.some((s) => /[[\]{}()!+@]/.test(s))) continue
|
|
983
|
+
for (const drop of [false, true]) {
|
|
984
|
+
const candidate = segs
|
|
985
|
+
.flatMap((s) => (s === '**' ? (drop ? [] : ['x']) : [s.replace(/\*+|\?/g, 'x')]))
|
|
986
|
+
.join('/')
|
|
987
|
+
if (candidate !== '' && ga.match(candidate) && gb.match(candidate)) return true
|
|
988
|
+
}
|
|
989
|
+
}
|
|
972
990
|
return false
|
|
973
991
|
}
|
|
974
992
|
|
|
@@ -1114,6 +1132,15 @@ function detectOutputCollisions(
|
|
|
1114
1132
|
}
|
|
1115
1133
|
}
|
|
1116
1134
|
|
|
1135
|
+
/**
|
|
1136
|
+
* What a task's `!` output entries take back (A-44). A glob of the other
|
|
1137
|
+
* task's that one takes back whole is a path this task never cleans or
|
|
1138
|
+
* captures, so the pair cannot collide there.
|
|
1139
|
+
*/
|
|
1140
|
+
function takenBackOf(n: TaskNode, field: 'files' | 'workspaceFiles'): string[] {
|
|
1141
|
+
return splitNegations(n.config.cache?.outputs[field] ?? []).negative
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1117
1144
|
/**
|
|
1118
1145
|
* `collide` for a root-anchored output against another project's `files`
|
|
1119
1146
|
* output rebased to the root. With an edge the dependant adds to its
|
|
@@ -1132,9 +1159,12 @@ function collideAcross(
|
|
|
1132
1159
|
exclusive: boolean,
|
|
1133
1160
|
): void {
|
|
1134
1161
|
if (neverWritesLocally(ws) || neverWritesLocally(files)) return
|
|
1162
|
+
const wsBack = takenBackOf(ws, 'workspaceFiles')
|
|
1163
|
+
const filesBack = takenBackOf(files, 'files').map((g) => (rel === '' ? g : `${rel}/${g}`))
|
|
1135
1164
|
for (const ga of wsGlobs) {
|
|
1136
1165
|
for (const gb of rebased) {
|
|
1137
|
-
if (!outputsOverlap(ga, gb))
|
|
1166
|
+
if (!outputsOverlap(ga, gb) || outputTakenBack(gb, wsBack) || outputTakenBack(ga, filesBack))
|
|
1167
|
+
continue
|
|
1138
1168
|
const head =
|
|
1139
1169
|
`${ws.id} declares the output ${JSON.stringify(ga)} in cache.outputs.workspaceFiles, ` +
|
|
1140
1170
|
`inside ${files.id}'s ${JSON.stringify(gb.slice(rel === '' ? 0 : rel.length + 1))} in ` +
|
|
@@ -1391,9 +1421,12 @@ function collide(
|
|
|
1391
1421
|
exclusive: boolean,
|
|
1392
1422
|
): void {
|
|
1393
1423
|
if (neverWritesLocally(a) || neverWritesLocally(b)) return
|
|
1424
|
+
const aBack = takenBackOf(a, field)
|
|
1425
|
+
const bBack = takenBackOf(b, field)
|
|
1394
1426
|
for (const ga of aGlobs ?? []) {
|
|
1395
1427
|
for (const gb of bGlobs ?? []) {
|
|
1396
|
-
if (!outputsOverlap(ga, gb))
|
|
1428
|
+
if (!outputsOverlap(ga, gb) || outputTakenBack(gb, aBack) || outputTakenBack(ga, bBack))
|
|
1429
|
+
continue
|
|
1397
1430
|
const head =
|
|
1398
1431
|
`${a.id} and ${b.id} both declare the output ${JSON.stringify(ga)}` +
|
|
1399
1432
|
(ga === gb ? '' : ` / ${JSON.stringify(gb)}`) +
|
|
@@ -1407,8 +1440,19 @@ function collide(
|
|
|
1407
1440
|
// `run.rules.exclusiveOutputs` is off (X-53). Without an edge the two run
|
|
1408
1441
|
// in either order, and the refusal below stands.
|
|
1409
1442
|
const [up, down] = reaches(b.id, a.id) ? [a, b] : reaches(a.id, b.id) ? [b, a] : []
|
|
1443
|
+
const parts = {
|
|
1444
|
+
headline: `${a.id} and ${b.id} both write ${ga === gb ? ga : `${ga} / ${gb}`}`,
|
|
1445
|
+
...(field === 'files' ? {} : { what: ['in cache.outputs.workspaceFiles'] }),
|
|
1446
|
+
why: "vx clears a task's outputs before it runs, so one deletes the other's",
|
|
1447
|
+
}
|
|
1410
1448
|
if (up !== undefined && exclusive)
|
|
1411
|
-
throw
|
|
1449
|
+
throw refusal(
|
|
1450
|
+
{
|
|
1451
|
+
...parts,
|
|
1452
|
+
fix: 'give each task its own output path, or set run.rules.exclusiveOutputs: false in .vx/workspace.ts',
|
|
1453
|
+
},
|
|
1454
|
+
{ code: 'VX_E_OUTPUT_OVERLAP', message: `${head}. ${EXCLUSIVE_OUTPUTS_FIX}` },
|
|
1455
|
+
)
|
|
1412
1456
|
if (up !== undefined && down !== undefined) {
|
|
1413
1457
|
const downGlobs = splitNegations(
|
|
1414
1458
|
(field === 'files'
|
|
@@ -1419,9 +1463,12 @@ function collide(
|
|
|
1419
1463
|
;(up.outputsAddedToBy ??= []).push(...(downGlobs ?? []))
|
|
1420
1464
|
return
|
|
1421
1465
|
}
|
|
1422
|
-
throw
|
|
1423
|
-
|
|
1424
|
-
|
|
1466
|
+
throw refusal(
|
|
1467
|
+
{ ...parts, fix: 'give each task its own output path' },
|
|
1468
|
+
{
|
|
1469
|
+
code: 'VX_E_OUTPUT_OVERLAP',
|
|
1470
|
+
message: `${head} and the run still reports success. Give each task its own output path.`,
|
|
1471
|
+
},
|
|
1425
1472
|
)
|
|
1426
1473
|
}
|
|
1427
1474
|
}
|
|
@@ -1522,6 +1569,8 @@ interface Side {
|
|
|
1522
1569
|
reads: boolean
|
|
1523
1570
|
/** The project's root-relative dir for rebased `files` globs. */
|
|
1524
1571
|
rel?: string
|
|
1572
|
+
/** The root-relative dir project-relative `globs` sit in, for git. */
|
|
1573
|
+
anchor?: string | undefined
|
|
1525
1574
|
}
|
|
1526
1575
|
|
|
1527
1576
|
/**
|
|
@@ -1555,6 +1604,7 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
|
|
|
1555
1604
|
let projectReaders = false
|
|
1556
1605
|
let rootReaders = false
|
|
1557
1606
|
const memo: TakeBackMemo = { bySide: new Map(), byList: new Map() }
|
|
1607
|
+
const found: Found = new Map()
|
|
1558
1608
|
const bucket = (name: string): Side[] => {
|
|
1559
1609
|
let list = byProject.get(name)
|
|
1560
1610
|
if (list === undefined) byProject.set(name, (list = []))
|
|
@@ -1566,7 +1616,8 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
|
|
|
1566
1616
|
const outFiles = positives(cache.outputs.files)
|
|
1567
1617
|
const outWs = positives(cache.outputs.workspaceFiles ?? [])
|
|
1568
1618
|
if (outFiles.length > 0) {
|
|
1569
|
-
|
|
1619
|
+
const anchor = workspaceRoot === undefined ? undefined : relPosix(workspaceRoot, n.projectDir)
|
|
1620
|
+
bucket(n.projectName).push({ node: n, globs: outFiles, reads: false, anchor })
|
|
1570
1621
|
filesWriters.push(n)
|
|
1571
1622
|
}
|
|
1572
1623
|
if (outWs.length > 0) {
|
|
@@ -1595,7 +1646,7 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
|
|
|
1595
1646
|
(s) => s.globs,
|
|
1596
1647
|
(s) => s.reads,
|
|
1597
1648
|
)) {
|
|
1598
|
-
readsOutputs(memo, sides[i]!, sides[j]!, 'files')
|
|
1649
|
+
readsOutputs(memo, found, sides[i]!, sides[j]!, 'files')
|
|
1599
1650
|
}
|
|
1600
1651
|
}
|
|
1601
1652
|
}
|
|
@@ -1613,10 +1664,20 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
|
|
|
1613
1664
|
(s) => s.globs,
|
|
1614
1665
|
(s) => s.reads,
|
|
1615
1666
|
)) {
|
|
1616
|
-
readsOutputs(memo, sides[i]!, sides[j]!, 'files')
|
|
1667
|
+
readsOutputs(memo, found, sides[i]!, sides[j]!, 'files')
|
|
1617
1668
|
}
|
|
1618
1669
|
}
|
|
1619
|
-
if (
|
|
1670
|
+
if (rootReaders) readsRootedOutputs(memo, found, rooted, filesWriters, workspaceRoot)
|
|
1671
|
+
refuseUnlessIgnored(found, workspaceRoot)
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
function readsRootedOutputs(
|
|
1675
|
+
memo: TakeBackMemo,
|
|
1676
|
+
found: Found,
|
|
1677
|
+
rooted: Side[],
|
|
1678
|
+
filesWriters: readonly TaskNode[],
|
|
1679
|
+
workspaceRoot: string | undefined,
|
|
1680
|
+
): void {
|
|
1620
1681
|
if (workspaceRoot !== undefined) {
|
|
1621
1682
|
for (const node of filesWriters) {
|
|
1622
1683
|
const rel = relPosix(workspaceRoot, node.projectDir)
|
|
@@ -1631,8 +1692,37 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
|
|
|
1631
1692
|
(s) => s.globs,
|
|
1632
1693
|
(s) => s.reads,
|
|
1633
1694
|
)) {
|
|
1634
|
-
readsOutputs(memo, rooted[i]!, rooted[j]!, 'workspaceFiles')
|
|
1695
|
+
readsOutputs(memo, found, rooted[i]!, rooted[j]!, 'workspaceFiles')
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
/** Each overlapped output, root-relative, and the refusal it meets; the first pair found per output. */
|
|
1700
|
+
type Found = Map<string, () => UserError>
|
|
1701
|
+
|
|
1702
|
+
/**
|
|
1703
|
+
* An output git ignores is no overlap (M, 2026-10-15): a key reads only what
|
|
1704
|
+
* git lists, so such an output never reaches it. Asked of git once, for
|
|
1705
|
+
* every overlapped output together; an output git cannot speak for (no work
|
|
1706
|
+
* tree, a glob no few paths stand for) is refused as before. The answer
|
|
1707
|
+
* holds for the lock; a run's stability gate asks git again for itself.
|
|
1708
|
+
*/
|
|
1709
|
+
function refuseUnlessIgnored(found: Found, workspaceRoot: string | undefined): void {
|
|
1710
|
+
if (found.size === 0) return
|
|
1711
|
+
if (workspaceRoot !== undefined) {
|
|
1712
|
+
const asks = [...found.keys()].map(ignoreProbes)
|
|
1713
|
+
const paths = asks.flatMap((a) => a?.paths ?? [])
|
|
1714
|
+
const ignored = checkIgnoreSync(workspaceRoot, paths)
|
|
1715
|
+
let listed: string[] | null | undefined
|
|
1716
|
+
const pass = wholeIgnored(found.keys(), ignored, (g) => {
|
|
1717
|
+
listed ??= listedSync(workspaceRoot, [
|
|
1718
|
+
...new Set(asks.flatMap((a) => (a?.deep === true ? [a.dir] : []))),
|
|
1719
|
+
])
|
|
1720
|
+
return listed === null || listed.some((f) => taskGlob(g).match(f))
|
|
1721
|
+
})
|
|
1722
|
+
for (const [g, refuse] of found) if (!pass.has(g)) throw refuse()
|
|
1723
|
+
return
|
|
1635
1724
|
}
|
|
1725
|
+
throw found.values().next().value!()
|
|
1636
1726
|
}
|
|
1637
1727
|
|
|
1638
1728
|
/**
|
|
@@ -1679,6 +1769,7 @@ function takenBack(
|
|
|
1679
1769
|
|
|
1680
1770
|
function readsOutputs(
|
|
1681
1771
|
memo: TakeBackMemo,
|
|
1772
|
+
found: Found,
|
|
1682
1773
|
x: Side,
|
|
1683
1774
|
y: Side,
|
|
1684
1775
|
field: 'files' | 'workspaceFiles',
|
|
@@ -1687,21 +1778,28 @@ function readsOutputs(
|
|
|
1687
1778
|
const [reader, writer] = x.reads ? [x, y] : [y, x]
|
|
1688
1779
|
const at = reader.rel === undefined || reader.rel === '' ? '' : `${reader.rel}/`
|
|
1689
1780
|
for (const go of writer.globs) {
|
|
1690
|
-
|
|
1781
|
+
const rooted =
|
|
1782
|
+
writer.anchor === undefined || writer.anchor === '' ? go : `${writer.anchor}/${go}`
|
|
1783
|
+
if (found.has(rooted) || takenBack(memo, reader, field, go)) continue
|
|
1691
1784
|
for (const gi of reader.globs) {
|
|
1692
1785
|
if (!outputsOverlap(gi, go)) continue
|
|
1693
1786
|
const shown =
|
|
1694
1787
|
writer.rel === undefined || writer.rel === '' ? go : go.slice(writer.rel.length + 1)
|
|
1695
1788
|
const out = at !== '' && go.startsWith(at) ? go.slice(at.length) : go
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1789
|
+
found.set(
|
|
1790
|
+
rooted,
|
|
1791
|
+
() =>
|
|
1792
|
+
new UserError(
|
|
1793
|
+
`${reader.node.id} reads ${JSON.stringify(gi.slice(at.length))} in cache.inputs.${field}, which matches ` +
|
|
1794
|
+
`${writer.node.id}'s output ${JSON.stringify(shown)} — a task's key must not read ` +
|
|
1795
|
+
`another task's outputs (the dependency's key already cascades through dependsOn). ` +
|
|
1796
|
+
`Exclude it: add ${JSON.stringify(`!${out}`)} to ${reader.node.id}'s ` +
|
|
1797
|
+
`cache.inputs.${field}, or set run.rules.upfrontKeys: false in .vx/workspace.ts to ` +
|
|
1798
|
+
`let it wait for its producer.`,
|
|
1799
|
+
'VX_E_INPUT_IS_OUTPUT',
|
|
1800
|
+
),
|
|
1704
1801
|
)
|
|
1802
|
+
break
|
|
1705
1803
|
}
|
|
1706
1804
|
}
|
|
1707
1805
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// A task's terminal status and what each one means: a pass, a hit, where
|
|
2
|
+
// its result came from. One vocabulary every reader (the run verdict, the
|
|
3
|
+
// logger, the history queries, telemetry, plugins) asks, so none re-lists
|
|
4
|
+
// the statuses.
|
|
5
|
+
|
|
6
|
+
export type TaskStatus =
|
|
7
|
+
| 'success'
|
|
8
|
+
| 'cache-hit'
|
|
9
|
+
| 'cache-hit-remote'
|
|
10
|
+
| 'failed'
|
|
11
|
+
| 'skipped'
|
|
12
|
+
// A child killed by a shutdown signal (SIGINT/SIGTERM — e.g. Ctrl-C):
|
|
13
|
+
// the task never finished on its own terms, so it reverts to aborted —
|
|
14
|
+
// excluded from the tally and the run history. It still PROPAGATES like
|
|
15
|
+
// a failure (see `willSkip`): the child died mid-write, so its outputs
|
|
16
|
+
// are partial.
|
|
17
|
+
| 'aborted'
|
|
18
|
+
|
|
19
|
+
/** Where a task's result came from, derived ONCE in core from the status. */
|
|
20
|
+
export type CacheSource = 'miss' | 'local' | 'remote' | 'none'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Map a task outcome status to its cache source. `success`/`failed` ran
|
|
24
|
+
* (a miss as far as the cache read is concerned); the two cache-hit
|
|
25
|
+
* statuses restored from local/remote; `skipped`/`aborted` never engaged
|
|
26
|
+
* the cache. Pure function — the single place this mapping is decided;
|
|
27
|
+
* `isCacheHit` below is derived from it rather than re-listing the two
|
|
28
|
+
* hit statuses, so there is one decision, not two that can disagree.
|
|
29
|
+
*/
|
|
30
|
+
export function deriveCacheSource(status: TaskStatus): CacheSource {
|
|
31
|
+
switch (status) {
|
|
32
|
+
case 'cache-hit':
|
|
33
|
+
return 'local'
|
|
34
|
+
case 'cache-hit-remote':
|
|
35
|
+
return 'remote'
|
|
36
|
+
case 'success':
|
|
37
|
+
case 'failed':
|
|
38
|
+
return 'miss'
|
|
39
|
+
case 'skipped':
|
|
40
|
+
case 'aborted':
|
|
41
|
+
return 'none'
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Whether each status counts as a PASS. Written as a `Record<TaskStatus, …>`
|
|
47
|
+
* rather than a `new Set([...])` on purpose: a Record must name every member,
|
|
48
|
+
* so adding a status to the union is a COMPILE error HERE and the omission
|
|
49
|
+
* cannot ship. A Set of string literals has no such tripwire — it silently
|
|
50
|
+
* answers `false` for the new member, which is how ten hand-rolled copies of
|
|
51
|
+
* this vocabulary accumulated across core, cloud and the dashboard.
|
|
52
|
+
*/
|
|
53
|
+
const PASSES: Record<TaskStatus, boolean> = {
|
|
54
|
+
success: true,
|
|
55
|
+
'cache-hit': true,
|
|
56
|
+
'cache-hit-remote': true,
|
|
57
|
+
failed: false,
|
|
58
|
+
skipped: false,
|
|
59
|
+
aborted: false,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Every `TaskStatus`, at runtime. Read off `PASSES`'s keys, which the
|
|
64
|
+
* `Record<TaskStatus, …>` type guarantees is exactly the union — so this is
|
|
65
|
+
* derived, not a second list that can fall behind. Exported so a consumer that
|
|
66
|
+
* cannot import the type (a test asserting a copy of this vocabulary in
|
|
67
|
+
* another package agrees) can still enumerate the real set.
|
|
68
|
+
*/
|
|
69
|
+
export const TASK_STATUSES: readonly TaskStatus[] = Object.keys(PASSES) as TaskStatus[]
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Did the task pass? A cache hit counts — it produced the same result without
|
|
73
|
+
* spending the time, which is the whole point. `skipped` and `aborted` do NOT:
|
|
74
|
+
* neither finished on its own terms, so neither can vouch for anything.
|
|
75
|
+
*
|
|
76
|
+
* Takes `string`, not `TaskStatus`, because most callers hold a status that
|
|
77
|
+
* arrived over a wire or out of a database column. An unrecognised string
|
|
78
|
+
* reads as NOT passing — the safe direction, since the alternative is calling
|
|
79
|
+
* a run green on a status this build has never heard of.
|
|
80
|
+
*/
|
|
81
|
+
export function isPassStatus(status: string): boolean {
|
|
82
|
+
return PASSES[status as TaskStatus] === true
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Did the task's result come out of the cache (either layer)? Derived from
|
|
87
|
+
* `deriveCacheSource` rather than re-listing the two hit statuses, so the two
|
|
88
|
+
* cannot disagree about what a hit is. Unknown strings read as not-a-hit.
|
|
89
|
+
*/
|
|
90
|
+
export function isCacheHit(status: string): boolean {
|
|
91
|
+
// No known-status guard: an unknown string falls through the switch to
|
|
92
|
+
// undefined, which is neither hit source (the guard survived item 654).
|
|
93
|
+
const source = deriveCacheSource(status as TaskStatus)
|
|
94
|
+
return source === 'local' || source === 'remote'
|
|
95
|
+
}
|
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
// drift into opposite verdicts on identical data. Both build on
|
|
5
5
|
// `mixedOutcomeKeysSql` + `failureModeOf`, so the rule cannot fork again.
|
|
6
6
|
// The per-run detector (`detectFlaky`) and the doctor's list (`flakyTasks`)
|
|
7
|
-
// read the same per-key projection (`keyOutcomesSql`)
|
|
7
|
+
// read the same per-key projection (`keyOutcomesSql`) over the shared
|
|
8
|
+
// store's flaky record (`task_outcomes`, v38), so every worktree of a repo
|
|
9
|
+
// judges a key on what all of them saw.
|
|
8
10
|
|
|
9
11
|
import type { Database } from 'bun:sqlite'
|
|
10
12
|
import { KEYED_RUNS_SQL } from '../cache/index.js'
|
|
11
|
-
import { isCacheHit, TASK_STATUSES } from '
|
|
13
|
+
import { isCacheHit, TASK_STATUSES } from '../graph/index.js'
|
|
12
14
|
|
|
13
15
|
// SQL hit set derived from the predicate — never a hand-typed list and never
|
|
14
16
|
// a prefix LIKE (a prefix counts any status merely NAMED cache-hit-*). The
|
|
@@ -17,6 +19,9 @@ const HIT_STATUSES = `(${TASK_STATUSES.filter(isCacheHit)
|
|
|
17
19
|
.map((s) => `'${s}'`)
|
|
18
20
|
.join(', ')})`
|
|
19
21
|
|
|
22
|
+
/** The shared store's flaky record: what `detectFlaky` and `flakyTasks` read. */
|
|
23
|
+
const OUTCOMES = 'task_outcomes'
|
|
24
|
+
|
|
20
25
|
export type FailureMode = 'stable' | 'flaky-recoverable' | 'flaky-fatal'
|
|
21
26
|
|
|
22
27
|
/**
|
|
@@ -33,6 +38,10 @@ export type FailureMode = 'stable' | 'flaky-recoverable' | 'flaky-fatal'
|
|
|
33
38
|
* projection.
|
|
34
39
|
*/
|
|
35
40
|
function keyOutcomesSql(source: string, where = ''): string {
|
|
41
|
+
// The flaky record keeps only rows that observed their key (run-history.ts
|
|
42
|
+
// `observesKey`), so it carries none of the filter's columns.
|
|
43
|
+
const keyed =
|
|
44
|
+
source === OUTCOMES ? '1' : `${KEYED_RUNS_SQL} AND cached IS NOT 0 AND blocked_by IS NULL`
|
|
36
45
|
return `SELECT project, task, hash, SUM(failed) AS failures, SUM(passed) AS passes,
|
|
37
46
|
SUM(retried) AS retried_failures,
|
|
38
47
|
SUM(CASE WHEN failed = 1 AND id > first_pass THEN 1 ELSE 0 END) AS relapses
|
|
@@ -41,10 +50,10 @@ function keyOutcomesSql(source: string, where = ''): string {
|
|
|
41
50
|
FROM (SELECT project, task, hash, id,
|
|
42
51
|
CASE WHEN status = 'failed' THEN 1 ELSE 0 END AS failed,
|
|
43
52
|
CASE WHEN status = 'success' AND attempts > 1 THEN attempts - 1 ELSE 0 END AS retried,
|
|
44
|
-
CASE WHEN status = 'success' OR status IN ${HIT_STATUSES} OR cache_hit = 1
|
|
53
|
+
CASE WHEN status = 'success' OR status IN ${HIT_STATUSES}${source === OUTCOMES ? '' : ' OR cache_hit = 1'}
|
|
45
54
|
THEN 1 ELSE 0 END AS passed
|
|
46
55
|
FROM ${source}
|
|
47
|
-
WHERE ${
|
|
56
|
+
WHERE ${keyed}${where}))
|
|
48
57
|
GROUP BY project, task, hash`
|
|
49
58
|
}
|
|
50
59
|
|
|
@@ -136,9 +145,9 @@ const keyOf = (c: { project: string; task: string; hash: string }): string =>
|
|
|
136
145
|
* failures is a recovery, not a flake. The counts fold this run in.
|
|
137
146
|
*
|
|
138
147
|
* The cost follows the run's colour. No candidate (every task a hit or a
|
|
139
|
-
* skip): no query. A green miss: one probe of the `
|
|
140
|
-
* index for the keys that ever failed —
|
|
141
|
-
* probe is microseconds however long the history. Only a key that DID fail
|
|
148
|
+
* skip): no query. A green miss: one probe of the `task_outcomes_hash`
|
|
149
|
+
* index for the keys that ever failed — a key holds a handful of rows, so
|
|
150
|
+
* the probe is microseconds however long the history. Only a key that DID fail
|
|
142
151
|
* before, or a task failing now, pays the projection scan over its own
|
|
143
152
|
* keys, and a run that is failing has more to look at than a scan.
|
|
144
153
|
*/
|
|
@@ -149,8 +158,8 @@ export function detectFlaky(db: Database, candidates: readonly FlakyCandidate[])
|
|
|
149
158
|
chunked(passedNow, (chunk) => {
|
|
150
159
|
const rows = db
|
|
151
160
|
.query(
|
|
152
|
-
`SELECT DISTINCT project, task, hash FROM
|
|
153
|
-
WHERE status = 'failed' AND
|
|
161
|
+
`SELECT DISTINCT project, task, hash FROM ${OUTCOMES}
|
|
162
|
+
WHERE status = 'failed' AND hash IN (${chunk.map(() => '?').join(', ')})`,
|
|
154
163
|
)
|
|
155
164
|
.all(...chunk.map((c) => c.hash)) as { project: string; task: string; hash: string }[]
|
|
156
165
|
for (const r of rows) everFailed.add(keyOf(r))
|
|
@@ -162,7 +171,7 @@ export function detectFlaky(db: Database, candidates: readonly FlakyCandidate[])
|
|
|
162
171
|
const counts = new Map<string, { passes: number; failures: number; relapses: number }>()
|
|
163
172
|
chunked(suspects, (chunk) => {
|
|
164
173
|
const rows = db
|
|
165
|
-
.query(keyOutcomesSql(
|
|
174
|
+
.query(keyOutcomesSql(OUTCOMES, ` AND hash IN (${chunk.map(() => '?').join(', ')})`))
|
|
166
175
|
.all(...chunk.map((c) => c.hash)) as {
|
|
167
176
|
project: string
|
|
168
177
|
task: string
|
|
@@ -212,7 +221,7 @@ export function flakyTasks(db: Database): FlakyTask[] {
|
|
|
212
221
|
.query(
|
|
213
222
|
`SELECT project, task, COUNT(*) AS keys, SUM(passes) AS passes, SUM(failures) AS failures
|
|
214
223
|
FROM (SELECT project, task, passes, failures + retried_failures AS failures
|
|
215
|
-
FROM (${keyOutcomesSql(
|
|
224
|
+
FROM (${keyOutcomesSql(OUTCOMES)})
|
|
216
225
|
WHERE relapses > 0 OR retried_failures > 0)
|
|
217
226
|
GROUP BY project, task
|
|
218
227
|
ORDER BY failures DESC, passes DESC, project, task`,
|
|
@@ -16,7 +16,7 @@ import { branchSource, EXECUTED_RUNS_SQL } from '../cache/index.js'
|
|
|
16
16
|
import { splitTaskId } from '../util/index.js'
|
|
17
17
|
import { failureModeOf, mixedOutcomeKeysSql } from './failure-mode.js'
|
|
18
18
|
import type { FailureMode } from './failure-mode.js'
|
|
19
|
-
import { isPassStatus, TASK_STATUSES } from '
|
|
19
|
+
import { isPassStatus, TASK_STATUSES } from '../graph/index.js'
|
|
20
20
|
|
|
21
21
|
const DEFAULT_RECENT = 50
|
|
22
22
|
|
|
@@ -219,10 +219,49 @@ export class LocalHistoryProvider implements HistoryProvider {
|
|
|
219
219
|
return this.forecasts(new Set(taskIds))
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
+
/**
|
|
223
|
+
* Whether the last forecast read found some task with no timing on this
|
|
224
|
+
* branch (on any branch when the provider has none): what a
|
|
225
|
+
* default-branch import is for. Learned by the read itself, so asking
|
|
226
|
+
* costs nothing (a query per task was 70 ms at 8,002 tasks).
|
|
227
|
+
*/
|
|
228
|
+
lackedBranchHistory = false
|
|
229
|
+
|
|
230
|
+
/** Whether timings of default-branch commit `sha` were imported. */
|
|
231
|
+
hasImported(sha: string): boolean {
|
|
232
|
+
return (
|
|
233
|
+
this.db
|
|
234
|
+
.query(`SELECT 1 FROM task_durations WHERE source = 'remote' AND sha = ? LIMIT 1`)
|
|
235
|
+
.get(sha) !== null
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Keep default-branch commit `sha`'s timings (task id → ms) as forecast
|
|
241
|
+
* rows: one per task, so a repeat import, by this worktree or another
|
|
242
|
+
* at once, adds nothing.
|
|
243
|
+
*/
|
|
244
|
+
importTimings(sha: string, timings: ReadonlyMap<string, number>): void {
|
|
245
|
+
const insert = this.db.query(
|
|
246
|
+
`INSERT OR IGNORE INTO task_durations(project, task, source, sha, duration_ms, recorded_at)
|
|
247
|
+
VALUES (?, ?, 'remote', ?, ?, ?)`,
|
|
248
|
+
)
|
|
249
|
+
const now = Date.now()
|
|
250
|
+
this.db
|
|
251
|
+
.transaction(() => {
|
|
252
|
+
for (const [id, ms] of timings) {
|
|
253
|
+
const [project, task] = splitTaskId(id)
|
|
254
|
+
insert.run(project, task, sha, Math.round(ms), now)
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
.immediate()
|
|
258
|
+
}
|
|
259
|
+
|
|
222
260
|
/**
|
|
223
261
|
* Each task's duration forecast (owner, 2026-10-14): the median of its
|
|
224
|
-
* last `FORECAST_RUNS` timings on this branch, else
|
|
225
|
-
*
|
|
262
|
+
* last `FORECAST_RUNS` timings on this branch, else the default branch's
|
|
263
|
+
* newest imported timing (`importTimings`), else the median on any
|
|
264
|
+
* branch, else none. The timings are the shared store's `task_durations`, so every
|
|
226
265
|
* worktree of the repo reads them and a new one forecasts on its first
|
|
227
266
|
* run. A point read per task on `(project, task, source, id)`: exact
|
|
228
267
|
* however old the rows, where the runs window holds only the newest
|
|
@@ -230,9 +269,9 @@ export class LocalHistoryProvider implements HistoryProvider {
|
|
|
230
269
|
*/
|
|
231
270
|
private forecasts(wanted: ReadonlySet<string>): Map<string, number> {
|
|
232
271
|
const out = new Map<string, number>()
|
|
272
|
+
this.lackedBranchHistory = false
|
|
233
273
|
if (wanted.size === 0) return out
|
|
234
|
-
// Tiers, first answer wins.
|
|
235
|
-
// (#3563) slot in between the two.
|
|
274
|
+
// Tiers, first answer wins.
|
|
236
275
|
type Tier = (project: string, task: string) => Array<{ ms: number }>
|
|
237
276
|
const tiers: Tier[] = []
|
|
238
277
|
const newest = (where: string) =>
|
|
@@ -245,18 +284,35 @@ export class LocalHistoryProvider implements HistoryProvider {
|
|
|
245
284
|
const q = newest('source = ?')
|
|
246
285
|
tiers.push((p, t) => q.all(p, t, source) as Array<{ ms: number }>)
|
|
247
286
|
}
|
|
287
|
+
// One probe on the partial index first: a store with no import costs
|
|
288
|
+
// no query per task.
|
|
289
|
+
if (
|
|
290
|
+
this.db.query(`SELECT 1 FROM task_durations WHERE source = 'remote' LIMIT 1`).get() !== null
|
|
291
|
+
) {
|
|
292
|
+
const remote = this.db.query(
|
|
293
|
+
`SELECT duration_ms AS ms FROM task_durations WHERE project = ? AND task = ? AND source = 'remote'
|
|
294
|
+
ORDER BY id DESC LIMIT 1`,
|
|
295
|
+
)
|
|
296
|
+
tiers.push((p, t) => remote.all(p, t) as Array<{ ms: number }>)
|
|
297
|
+
}
|
|
248
298
|
const any = newest(`source LIKE 'branch:%'`)
|
|
249
299
|
tiers.push((p, t) => any.all(p, t) as Array<{ ms: number }>)
|
|
300
|
+
const own = this.branch !== undefined ? tiers[0] : tiers.at(-1)
|
|
301
|
+
let lacked = false
|
|
250
302
|
for (const id of wanted) {
|
|
251
303
|
const [project, task] = splitTaskId(id)
|
|
304
|
+
let answered: Tier | undefined
|
|
252
305
|
for (const tier of tiers) {
|
|
253
306
|
const rows = tier(project, task)
|
|
254
307
|
if (rows.length === 0) continue
|
|
255
308
|
const durations = rows.map((r) => r.ms).sort((x, y) => x - y)
|
|
256
309
|
out.set(id, pickPercentile(durations, 0.5))
|
|
310
|
+
answered = tier
|
|
257
311
|
break
|
|
258
312
|
}
|
|
313
|
+
if (answered !== own) lacked = true
|
|
259
314
|
}
|
|
315
|
+
this.lackedBranchHistory = lacked
|
|
260
316
|
return out
|
|
261
317
|
}
|
|
262
318
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// What runs leave behind, read back: the duration table a schedule learns
|
|
2
|
+
// from, the flakiness verdict, the queries behind `vx last` / `vx why`, and
|
|
3
|
+
// what a miss's key changed. Read-only SQL over the cache's database; it
|
|
4
|
+
// never runs, renders or writes a run.
|
|
5
|
+
export {
|
|
6
|
+
EmptyHistoryProvider,
|
|
7
|
+
type HistoryProvider,
|
|
8
|
+
type HistoryTable,
|
|
9
|
+
LocalHistoryProvider,
|
|
10
|
+
PASS_STATUSES,
|
|
11
|
+
type TaskHistory,
|
|
12
|
+
type TaskResources,
|
|
13
|
+
} from './history.js'
|
|
14
|
+
export {
|
|
15
|
+
detectFlaky,
|
|
16
|
+
type FailureMode,
|
|
17
|
+
type FlakyCandidate,
|
|
18
|
+
type FlakyFinding,
|
|
19
|
+
type FlakyTask,
|
|
20
|
+
flakyTasks,
|
|
21
|
+
} from './failure-mode.js'
|
|
22
|
+
export {
|
|
23
|
+
cacheKeyDiff,
|
|
24
|
+
explainCacheKey,
|
|
25
|
+
getInvocation,
|
|
26
|
+
getRun,
|
|
27
|
+
inputWriters,
|
|
28
|
+
latestRunId,
|
|
29
|
+
listInvocations,
|
|
30
|
+
listRuns,
|
|
31
|
+
rootCauses,
|
|
32
|
+
whyDidThisRerun,
|
|
33
|
+
} from './metrics.js'
|
|
34
|
+
export type {
|
|
35
|
+
CacheEntryRow,
|
|
36
|
+
CacheKeyDiff,
|
|
37
|
+
CacheKeyExplanation,
|
|
38
|
+
InputDiffEntry,
|
|
39
|
+
InputWriter,
|
|
40
|
+
InvocationDetail,
|
|
41
|
+
ListInvocationsArgs,
|
|
42
|
+
ListRunsArgs,
|
|
43
|
+
RootCause,
|
|
44
|
+
RunDetail,
|
|
45
|
+
RunSummaryRow,
|
|
46
|
+
WhyDidThisRerun,
|
|
47
|
+
} from './metrics.js'
|
|
48
|
+
export { createMissExplainer, type MissExplainer } from './miss-reason.js'
|