@vzn/vx 0.0.636 → 0.0.638
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 +39 -11
- package/docs/caching.md +246 -213
- package/docs/cli.md +791 -705
- package/docs/execution.md +68 -76
- package/docs/features.md +47 -42
- package/docs/patterns.md +26 -25
- package/docs/schema.md +392 -205
- package/docs/security.md +76 -39
- package/launcher.sh +43 -0
- package/package.json +5 -5
- package/schemas/cache.json +41 -19
- package/schemas/info.json +23 -11
- package/schemas/last.json +48 -2
- package/schemas/lock.json +1 -1
- package/schemas/plan.json +4 -4
- package/schemas/show.json +5 -3
- package/schemas/why.json +15 -1
- package/skills/vx/SKILL.md +28 -13
- package/src/bin.ts +30 -9
- package/src/cache/archive.ts +11 -0
- package/src/cache/cache.ts +124 -53
- package/src/cache/chained-cache.ts +4 -0
- package/src/cache/git-inputs.ts +277 -63
- package/src/cache/index.ts +15 -1
- package/src/cache/inputs.ts +102 -36
- package/src/cache/key-fold.ts +8 -7
- package/src/cache/layer.ts +15 -18
- package/src/cache/layered-cache.ts +20 -102
- package/src/cache/plugin-cache.ts +248 -0
- package/src/cache/policy.ts +32 -27
- package/src/cache/run-history.ts +104 -14
- package/src/cache/run-store.ts +77 -0
- package/src/cache/schema.ts +58 -31
- package/src/cache/scoped-cache.ts +237 -0
- package/src/cli/cache.ts +173 -34
- package/src/cli/completions.ts +11 -7
- package/src/cli/core-alias.ts +1 -1
- package/src/cli/foreign-flags.ts +13 -12
- package/src/cli/help.ts +110 -70
- package/src/cli/index.ts +67 -24
- package/src/cli/info.ts +43 -27
- package/src/cli/init.ts +115 -407
- package/src/cli/last.ts +157 -9
- package/src/cli/lock.ts +356 -122
- package/src/cli/plan-format.ts +7 -5
- package/src/cli/plugin-commands.ts +81 -14
- package/src/cli/plugin-templates.ts +11 -11
- package/src/cli/run.ts +116 -160
- package/src/cli/select.ts +50 -47
- package/src/cli/show.ts +83 -59
- package/src/cli/task-verb.ts +6 -13
- package/src/cli/upgrade.ts +415 -149
- package/src/cli/watch-cycle.ts +9 -2
- package/src/cli/watch-filter.ts +39 -61
- package/src/cli/watch-fs.ts +29 -16
- package/src/cli/watch-judge.ts +18 -4
- package/src/cli/watch-set.ts +16 -42
- package/src/cli/watch.ts +227 -230
- package/src/cli/why.ts +38 -18
- package/src/cli/workspace-config.ts +33 -70
- package/src/config.ts +169 -70
- package/src/exec/env.ts +1 -31
- package/src/exec/executor.ts +1 -5
- package/src/exec/kill-tree.ts +2 -1
- package/src/exec/sandbox-runtime.ts +12 -1
- package/src/exec/sandbox-violations.ts +9 -1
- package/src/graph/dependency-spec.ts +1 -1
- package/src/graph/index.ts +1 -0
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +27 -7
- package/src/graph/task-graph.ts +76 -24
- package/src/handoff.ts +169 -0
- package/src/index.ts +35 -58
- package/src/orchestrator/affected-tasks.ts +15 -8
- package/src/orchestrator/command-windows.ts +44 -0
- package/src/orchestrator/deferred-outputs.ts +12 -10
- package/src/orchestrator/doctor.ts +98 -110
- package/src/orchestrator/download-policy.ts +3 -3
- package/src/orchestrator/events.ts +19 -3
- package/src/orchestrator/excluded-keys.ts +0 -2
- package/src/orchestrator/execute-task.ts +147 -69
- package/src/orchestrator/failure-mode.ts +19 -10
- package/src/orchestrator/fingerprint-watch.ts +18 -15
- package/src/orchestrator/forecast.ts +10 -2
- package/src/orchestrator/framed-output.ts +9 -5
- package/src/orchestrator/graph-stage.ts +5 -5
- package/src/orchestrator/history.ts +126 -30
- package/src/orchestrator/hit-restore.ts +15 -3
- package/src/orchestrator/index.ts +20 -5
- package/src/orchestrator/local-shortcircuit.ts +2 -1
- package/src/orchestrator/lockfile-claim.ts +79 -20
- package/src/orchestrator/logger.ts +80 -41
- package/src/orchestrator/metrics.ts +78 -9
- package/src/orchestrator/miss-save.ts +3 -0
- package/src/orchestrator/options.ts +42 -29
- package/src/orchestrator/output-host.ts +72 -0
- package/src/orchestrator/plan.ts +6 -8
- package/src/orchestrator/plugin-host.ts +100 -66
- package/src/orchestrator/plugin.ts +173 -67
- package/src/orchestrator/prepare.ts +163 -78
- package/src/orchestrator/projects.ts +105 -99
- package/src/orchestrator/remote-prefetch.ts +11 -6
- package/src/orchestrator/run-context.ts +25 -6
- package/src/orchestrator/run-failures.ts +17 -8
- package/src/orchestrator/run-lock.ts +1 -1
- package/src/orchestrator/run-records.ts +1 -1
- package/src/orchestrator/run-report.ts +9 -9
- package/src/orchestrator/run-settings.ts +48 -0
- package/src/orchestrator/run.ts +244 -119
- package/src/orchestrator/sandbox-fix.ts +39 -20
- package/src/orchestrator/sandbox-request.ts +84 -13
- package/src/orchestrator/signals.ts +1 -1
- package/src/orchestrator/stable-keys.ts +13 -11
- package/src/orchestrator/status-line.ts +49 -7
- package/src/orchestrator/summary.ts +115 -106
- package/src/orchestrator/task-hash.ts +12 -83
- package/src/orchestrator/telemetry.ts +4 -4
- package/src/settings.ts +428 -0
- package/src/{orchestrator → util}/colors.ts +5 -0
- package/src/util/config-frame.ts +31 -2
- package/src/util/errors.ts +33 -1
- package/src/util/essential-env.ts +31 -0
- package/src/util/hide-env.ts +107 -0
- package/src/util/index.ts +16 -3
- package/src/util/paths.ts +10 -0
- package/src/util/refusal-notice.ts +111 -0
- package/src/util/report.ts +31 -0
- package/src/util/settle.ts +49 -30
- package/src/util/size.ts +23 -4
- package/src/util/timing.ts +3 -2
- package/src/util/ui.ts +186 -0
- package/src/util/ulid.ts +1 -1
- package/src/util/verbs.ts +0 -15
- package/src/workspace/affected.ts +84 -232
- package/src/workspace/config-eval.ts +15 -20
- package/src/workspace/config-imports.ts +78 -3
- package/src/workspace/config-schema.ts +209 -268
- package/src/workspace/dependency-rules.ts +65 -0
- package/src/workspace/filter.ts +2 -2
- package/src/workspace/fingerprint.ts +11 -121
- package/src/workspace/index.ts +20 -27
- package/src/workspace/load-reads.ts +4 -4
- package/src/workspace/lockfile.ts +96 -140
- package/src/workspace/migration.ts +307 -215
- package/src/workspace/package-graph.ts +30 -216
- package/src/workspace/project-loader.ts +80 -285
- package/src/workspace/repo-id.ts +2 -1
- package/src/workspace/workspace.ts +363 -831
- package/src/cache/config-evals.ts +0 -130
- package/src/workspace/config-cache.ts +0 -661
- package/src/workspace/migrate-scripts.ts +0 -1121
|
@@ -1,18 +1,17 @@
|
|
|
1
|
-
// The
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// files — `pnpm install` without `--frozen-lockfile` — and every key taken
|
|
1
|
+
// The files the plugins claim (`VxPlugin.fingerprint`) are read once per
|
|
2
|
+
// run, before any task, and every key folds what their claimant takes
|
|
3
|
+
// from them (a lockfile plugin's per-project parts). A task in the root
|
|
4
|
+
// project may rewrite one of those files — `pnpm install` without `--frozen-lockfile` — and every key taken
|
|
5
5
|
// before it then names a lockfile the tree no longer holds: a save under
|
|
6
6
|
// one filed bytes built against the new install under the old lockfile's
|
|
7
7
|
// key, replayed whenever the tree went back (item 750). The watch answers
|
|
8
|
-
// "has a
|
|
8
|
+
// "has a claimed file moved since the run read it", re-learning the
|
|
9
9
|
// fact only after a task that may have written one ran (`wrote`), so a run
|
|
10
10
|
// with no such task never touches the files again.
|
|
11
11
|
|
|
12
12
|
import { readFileSync, statSync } from 'node:fs'
|
|
13
13
|
import path from 'node:path'
|
|
14
14
|
import { FILE_HASH_RACY_MS, racyWindowMs } from '../cache/index.js'
|
|
15
|
-
import { WORKSPACE_FINGERPRINT_FILES, type WorkspaceFingerprints } from '../workspace/index.js'
|
|
16
15
|
import type { Logger } from './logger.js'
|
|
17
16
|
|
|
18
17
|
export class FingerprintWatch {
|
|
@@ -20,22 +19,26 @@ export class FingerprintWatch {
|
|
|
20
19
|
private movedFiles: string[] | undefined
|
|
21
20
|
private said = false
|
|
22
21
|
|
|
23
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* `names`: the claimed files; `read`: the bytes of those that existed;
|
|
24
|
+
* `at`: when the run started reading them (ms since the epoch).
|
|
25
|
+
*/
|
|
24
26
|
constructor(
|
|
25
27
|
private readonly workspaceRoot: string,
|
|
26
|
-
|
|
28
|
+
readonly names: readonly string[],
|
|
29
|
+
private readonly read: ReadonlyMap<string, Uint8Array>,
|
|
27
30
|
private readonly at: number,
|
|
28
31
|
) {}
|
|
29
32
|
|
|
30
|
-
/** A task that may rewrite a
|
|
33
|
+
/** A task that may rewrite a claimed file (`mayWriteFingerprint`) ran a command. */
|
|
31
34
|
wrote(): void {
|
|
32
35
|
this.pending = true
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
/**
|
|
36
|
-
* The
|
|
39
|
+
* The claimed files that no longer hold what the run read, or
|
|
37
40
|
* undefined. Once one has, the run stays moved: every key already taken
|
|
38
|
-
* folded the old bytes. One `stat` per
|
|
41
|
+
* folded the old bytes. One `stat` per claimed file after each writer; a
|
|
39
42
|
* file written since the read (by ctime, with git's racy window) is read
|
|
40
43
|
* and compared. The stat follows a link as both reads do: a lockfile
|
|
41
44
|
* that is a symlink, rewritten through it, moves its target's ctime and
|
|
@@ -46,9 +49,9 @@ export class FingerprintWatch {
|
|
|
46
49
|
if (this.movedFiles !== undefined || !this.pending) return this.movedFiles
|
|
47
50
|
this.pending = false
|
|
48
51
|
const moved: string[] = []
|
|
49
|
-
for (const f of
|
|
52
|
+
for (const f of this.names) {
|
|
50
53
|
const abs = path.join(this.workspaceRoot, f)
|
|
51
|
-
const before = this.read.
|
|
54
|
+
const before = this.read.get(f)
|
|
52
55
|
let ctimeMs: number
|
|
53
56
|
try {
|
|
54
57
|
ctimeMs = statSync(abs).ctimeMs
|
|
@@ -81,8 +84,8 @@ export class FingerprintWatch {
|
|
|
81
84
|
if (this.said || this.movedFiles === undefined) return
|
|
82
85
|
this.said = true
|
|
83
86
|
log.status(
|
|
84
|
-
`[vx] a task rewrote \`${this.movedFiles.join('`, `')}\` during the run, which
|
|
85
|
-
`folds
|
|
87
|
+
`[vx] a task rewrote \`${this.movedFiles.join('`, `')}\` during the run, which a plugin ` +
|
|
88
|
+
`folds into every key: nothing keyed before it is restored or saved from here on`,
|
|
86
89
|
)
|
|
87
90
|
}
|
|
88
91
|
}
|
|
@@ -12,6 +12,8 @@ export interface LiveForecast {
|
|
|
12
12
|
critical?: string
|
|
13
13
|
/** Each running task's unfinished dependents, direct and transitive. */
|
|
14
14
|
blocks: ReadonlyMap<string, number>
|
|
15
|
+
/** Each running task's p50, where history knows it: the row's expected duration. */
|
|
16
|
+
expected?: ReadonlyMap<string, number>
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
export type Forecast = (
|
|
@@ -90,7 +92,12 @@ export function createForecast(
|
|
|
90
92
|
|
|
91
93
|
return (finished, running, nowMs) => {
|
|
92
94
|
const blocks = new Map<string, number>()
|
|
93
|
-
|
|
95
|
+
const expected = new Map<string, number>()
|
|
96
|
+
for (const id of running.keys()) {
|
|
97
|
+
blocks.set(id, blocked(id, finished))
|
|
98
|
+
const p = cost.get(id)
|
|
99
|
+
if (p !== undefined) expected.set(id, p)
|
|
100
|
+
}
|
|
94
101
|
const end = new Map<string, number>()
|
|
95
102
|
// The running task each node's longest chain waits on.
|
|
96
103
|
const head = new Map<string, string>()
|
|
@@ -128,12 +135,13 @@ export function createForecast(
|
|
|
128
135
|
}
|
|
129
136
|
work += own
|
|
130
137
|
}
|
|
131
|
-
if (!known || overrun) return { blocks }
|
|
138
|
+
if (!known || overrun) return { blocks, expected }
|
|
132
139
|
const critical = chainEnd === undefined ? undefined : head.get(chainEnd)
|
|
133
140
|
return {
|
|
134
141
|
etaMs: Math.max(chain, work / workers),
|
|
135
142
|
...(critical !== undefined ? { critical } : {}),
|
|
136
143
|
blocks,
|
|
144
|
+
expected,
|
|
137
145
|
}
|
|
138
146
|
}
|
|
139
147
|
}
|
|
@@ -27,8 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
import { withForwardArgs } from '../exec/index.js'
|
|
29
29
|
import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
30
|
-
import { formatElapsed, maskedCommand } from '../util/index.js'
|
|
31
|
-
import { paint, type ColorSupport } from './colors.js'
|
|
30
|
+
import { formatElapsed, maskedCommand, paint, type ColorSupport } from '../util/index.js'
|
|
32
31
|
import { neverStarted } from './summary.js'
|
|
33
32
|
import { outcomeLabel, ranNoCache, skippedReason } from './events.js'
|
|
34
33
|
import { fileMemo, linkPaths } from './path-links.js'
|
|
@@ -532,7 +531,8 @@ function violationSection(outcome: TaskOutcome, colors: ColorSupport): string[]
|
|
|
532
531
|
// Red, not amber: a violation fails the task, so it reads as a failure.
|
|
533
532
|
// vx's note, after the evidence: the grants that would have allowed it.
|
|
534
533
|
// A read the output depends on is a key input too; the sandbox never
|
|
535
|
-
// adds one (owner, 2026-10-09)
|
|
534
|
+
// adds one (owner, 2026-10-09), so the note names the reads no input
|
|
535
|
+
// glob matches and the user decides.
|
|
536
536
|
const fix = outcome.sandboxFix
|
|
537
537
|
const line = fix === undefined ? undefined : sandboxFixLine(fix)
|
|
538
538
|
const dim = (text: string): string => paint('', text, colors, { dim: true })
|
|
@@ -541,8 +541,12 @@ function violationSection(outcome: TaskOutcome, colors: ColorSupport): string[]
|
|
|
541
541
|
? []
|
|
542
542
|
: [
|
|
543
543
|
...(line !== undefined ? [dim(`fix: ${line}`)] : []),
|
|
544
|
-
...(fix.
|
|
545
|
-
? [
|
|
544
|
+
...(fix.inputs !== undefined
|
|
545
|
+
? [
|
|
546
|
+
dim(
|
|
547
|
+
`and, if the output depends on them, cache.inputs.files: [${fix.inputs.map((p) => `'${p}'`).join(', ')}]`,
|
|
548
|
+
),
|
|
549
|
+
]
|
|
546
550
|
: []),
|
|
547
551
|
...(fix.withheld !== undefined
|
|
548
552
|
? [dim(`credential path, grant it only on purpose: ${fix.withheld.join(', ')}`)]
|
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
// CLI's `--affected` task selection (`vx show <task> --affected`), so both
|
|
3
3
|
// select over the graph the hooks leave.
|
|
4
4
|
|
|
5
|
-
import type {
|
|
6
|
-
import type { TaskNode } from '../graph/index.js'
|
|
5
|
+
import type { GraphRules, TaskNode } from '../graph/index.js'
|
|
7
6
|
import { type ProjectMeta, validateProjectConfig } from '../workspace/index.js'
|
|
8
7
|
import { applyGraphHooks } from './plugin-host.js'
|
|
9
8
|
import type { VxPlugin } from './plugin.js'
|
|
@@ -17,7 +16,7 @@ export async function applyGraphStage(
|
|
|
17
16
|
cacheDir: string
|
|
18
17
|
projectMetas: readonly ProjectMeta[]
|
|
19
18
|
warn: (message: string) => void
|
|
20
|
-
rules?:
|
|
19
|
+
rules?: GraphRules | undefined
|
|
21
20
|
},
|
|
22
21
|
): Promise<void> {
|
|
23
22
|
// A hook edits each node's config in place, as `project` does, so the
|
|
@@ -36,10 +35,11 @@ export async function applyGraphStage(
|
|
|
36
35
|
(plugin) => {
|
|
37
36
|
for (const n of nodes.values()) {
|
|
38
37
|
if (isDefaultBuild(n.config)) continue
|
|
39
|
-
const
|
|
38
|
+
const own = configPaths.get(n.projectName) ?? null
|
|
40
39
|
validateProjectConfig(
|
|
41
40
|
{ tasks: { [n.taskName]: n.config } },
|
|
42
|
-
`${
|
|
41
|
+
`${own ?? `${n.projectName} (no config file)`} (after plugin '${plugin.name}')`,
|
|
42
|
+
own,
|
|
43
43
|
)
|
|
44
44
|
}
|
|
45
45
|
},
|
|
@@ -12,13 +12,17 @@
|
|
|
12
12
|
// (zero-config); the interface is the seam a plugin fills for any other.
|
|
13
13
|
|
|
14
14
|
import type { Database } from 'bun:sqlite'
|
|
15
|
-
import { EXECUTED_RUNS_SQL } from '../cache/index.js'
|
|
15
|
+
import { branchSource, EXECUTED_RUNS_SQL } from '../cache/index.js'
|
|
16
|
+
import { splitTaskId } from '../util/index.js'
|
|
16
17
|
import { failureModeOf, mixedOutcomeKeysSql } from './failure-mode.js'
|
|
17
18
|
import type { FailureMode } from './failure-mode.js'
|
|
18
19
|
import { isPassStatus, TASK_STATUSES } from './telemetry.js'
|
|
19
20
|
|
|
20
21
|
const DEFAULT_RECENT = 50
|
|
21
22
|
|
|
23
|
+
/** A task's forecast is the median of this many of its newest timings. */
|
|
24
|
+
const FORECAST_RUNS = 5
|
|
25
|
+
|
|
22
26
|
// A hit is recorded as `cache-hit` / `cache-hit-remote` and replays a
|
|
23
27
|
// success; the list is derived so a new status cannot miss it.
|
|
24
28
|
/** The pass set as SQL, `(…)`: one definition, `isPassStatus`. */
|
|
@@ -86,7 +90,9 @@ export class EmptyHistoryProvider implements HistoryProvider {
|
|
|
86
90
|
export class LocalHistoryProvider implements HistoryProvider {
|
|
87
91
|
constructor(
|
|
88
92
|
private readonly db: Database,
|
|
89
|
-
|
|
93
|
+
readonly recent: number = DEFAULT_RECENT,
|
|
94
|
+
/** The run's branch (`currentBranch`); undefined: a forecast reads every branch's timings. */
|
|
95
|
+
private readonly branch?: string | null,
|
|
90
96
|
) {}
|
|
91
97
|
|
|
92
98
|
/**
|
|
@@ -107,8 +113,17 @@ export class LocalHistoryProvider implements HistoryProvider {
|
|
|
107
113
|
* read by `run_id` and stay exact.
|
|
108
114
|
*/
|
|
109
115
|
async loadFor(taskIds: readonly string[]): Promise<HistoryTable> {
|
|
116
|
+
if (taskIds.length === 0) return new Map()
|
|
117
|
+
return this.load(new Set(taskIds))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Every task the window holds, as `loadFor` answers each (`vx last --stats`). */
|
|
121
|
+
async loadAll(): Promise<HistoryTable> {
|
|
122
|
+
return this.load(null)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private load(wanted: ReadonlySet<string> | null): HistoryTable {
|
|
110
126
|
const out = new Map<string, TaskHistory>()
|
|
111
|
-
if (taskIds.length === 0) return out
|
|
112
127
|
const floor = this.floor()
|
|
113
128
|
|
|
114
129
|
// `skipped` rows are excluded: a skip is a task the run never executed,
|
|
@@ -160,15 +175,15 @@ export class LocalHistoryProvider implements HistoryProvider {
|
|
|
160
175
|
// Every pair in the slice is grouped (the slice is bounded, so this is
|
|
161
176
|
// cheap) and the asked-for ones are picked out here — a per-row IN-list
|
|
162
177
|
// probe in SQL cost more than the grouping it would have saved.
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
wanted.has(`${r.project}#${r.task}`),
|
|
178
|
+
const rows = (this.db.query(sql).all(floor) as Row[]).filter(
|
|
179
|
+
(r) => wanted === null || wanted.has(`${r.project}#${r.task}`),
|
|
166
180
|
)
|
|
167
181
|
|
|
168
182
|
// The flakiness signal needs per-key outcomes, which the aggregate above
|
|
169
183
|
// folded away — one more pass over the slice, only for the pairs whose
|
|
170
184
|
// verdict actually depends on it (failed, and no retry already proving
|
|
171
185
|
// nondeterminism); a green history never pays for it.
|
|
186
|
+
const p50s = this.forecasts(new Set(rows.map((r) => `${r.project}#${r.task}`)))
|
|
172
187
|
const mixed = this.mixedOutcomeKeys(
|
|
173
188
|
floor,
|
|
174
189
|
rows.filter((r) => r.failures > 0 && r.retried === 0),
|
|
@@ -182,7 +197,7 @@ export class LocalHistoryProvider implements HistoryProvider {
|
|
|
182
197
|
if (durations !== undefined) durations.sort((a, b) => a - b)
|
|
183
198
|
out.set(key, {
|
|
184
199
|
runs: total,
|
|
185
|
-
p50DurationMs:
|
|
200
|
+
p50DurationMs: p50s.get(key),
|
|
186
201
|
p99DurationMs: durations ? pickPercentile(durations, 0.99) : undefined,
|
|
187
202
|
successRate: row.successes / total,
|
|
188
203
|
hitRate: row.hits / total,
|
|
@@ -195,28 +210,109 @@ export class LocalHistoryProvider implements HistoryProvider {
|
|
|
195
210
|
}
|
|
196
211
|
|
|
197
212
|
/**
|
|
198
|
-
* The
|
|
199
|
-
*
|
|
200
|
-
*
|
|
213
|
+
* The forecasts alone. `loadFor`'s rates and resource maxima read every
|
|
214
|
+
* row of the window and join each hit to its entry: 38 ms at 27,000 rows
|
|
215
|
+
* against 4 for the executed successes' durations alone.
|
|
201
216
|
*/
|
|
202
217
|
async p50sFor(taskIds: readonly string[]): Promise<ReadonlyMap<string, number>> {
|
|
218
|
+
if (taskIds.length === 0) return new Map()
|
|
219
|
+
return this.forecasts(new Set(taskIds))
|
|
220
|
+
}
|
|
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
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Each task's duration forecast (owner, 2026-10-14): the median of its
|
|
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
|
|
265
|
+
* worktree of the repo reads them and a new one forecasts on its first
|
|
266
|
+
* run. A point read per task on `(project, task, source, id)`: exact
|
|
267
|
+
* however old the rows, where the runs window holds only the newest
|
|
268
|
+
* invocations.
|
|
269
|
+
*/
|
|
270
|
+
private forecasts(wanted: ReadonlySet<string>): Map<string, number> {
|
|
203
271
|
const out = new Map<string, number>()
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
272
|
+
this.lackedBranchHistory = false
|
|
273
|
+
if (wanted.size === 0) return out
|
|
274
|
+
// Tiers, first answer wins.
|
|
275
|
+
type Tier = (project: string, task: string) => Array<{ ms: number }>
|
|
276
|
+
const tiers: Tier[] = []
|
|
277
|
+
const newest = (where: string) =>
|
|
278
|
+
this.db.query(
|
|
279
|
+
`SELECT duration_ms AS ms FROM task_durations WHERE project = ? AND task = ? AND ${where}
|
|
280
|
+
ORDER BY id DESC LIMIT ${FORECAST_RUNS}`,
|
|
210
281
|
)
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
282
|
+
if (this.branch !== undefined) {
|
|
283
|
+
const source = branchSource(this.branch)
|
|
284
|
+
const q = newest('source = ?')
|
|
285
|
+
tiers.push((p, t) => q.all(p, t, source) as Array<{ ms: number }>)
|
|
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
|
+
}
|
|
298
|
+
const any = newest(`source LIKE 'branch:%'`)
|
|
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
|
|
302
|
+
for (const id of wanted) {
|
|
303
|
+
const [project, task] = splitTaskId(id)
|
|
304
|
+
let answered: Tier | undefined
|
|
305
|
+
for (const tier of tiers) {
|
|
306
|
+
const rows = tier(project, task)
|
|
307
|
+
if (rows.length === 0) continue
|
|
308
|
+
const durations = rows.map((r) => r.ms).sort((x, y) => x - y)
|
|
309
|
+
out.set(id, pickPercentile(durations, 0.5))
|
|
310
|
+
answered = tier
|
|
311
|
+
break
|
|
312
|
+
}
|
|
313
|
+
if (answered !== own) lacked = true
|
|
219
314
|
}
|
|
315
|
+
this.lackedBranchHistory = lacked
|
|
220
316
|
return out
|
|
221
317
|
}
|
|
222
318
|
|
|
@@ -236,13 +332,12 @@ export class LocalHistoryProvider implements HistoryProvider {
|
|
|
236
332
|
type Executed = {
|
|
237
333
|
project: string
|
|
238
334
|
task: string
|
|
239
|
-
ds: string
|
|
240
335
|
rss: number | null
|
|
241
336
|
cpu: number | null
|
|
242
337
|
}
|
|
243
338
|
const executed = this.db
|
|
244
339
|
.query(
|
|
245
|
-
`SELECT project, task,
|
|
340
|
+
`SELECT project, task, MAX(peak_rss_bytes) AS rss,
|
|
246
341
|
MAX(CASE WHEN cpu_ms IS NOT NULL AND duration_ms > 0
|
|
247
342
|
THEN cpu_ms * 1.0 / duration_ms END) AS cpu
|
|
248
343
|
FROM runs
|
|
@@ -250,13 +345,12 @@ export class LocalHistoryProvider implements HistoryProvider {
|
|
|
250
345
|
GROUP BY project, task`,
|
|
251
346
|
)
|
|
252
347
|
.all(floor) as Executed[]
|
|
348
|
+
const p50s = this.forecasts(wanted)
|
|
253
349
|
for (const row of executed) {
|
|
254
350
|
const key = `${row.project}#${row.task}`
|
|
255
351
|
if (!wanted.has(key)) continue
|
|
256
|
-
const durations = row.ds.split(',').map(Number)
|
|
257
|
-
durations.sort((a, b) => a - b)
|
|
258
352
|
out.set(key, {
|
|
259
|
-
p50DurationMs:
|
|
353
|
+
p50DurationMs: p50s.get(key),
|
|
260
354
|
...(row.rss !== null ? { maxPeakRssBytes: row.rss } : {}),
|
|
261
355
|
...(row.cpu !== null ? { maxCpuParallelism: row.cpu } : {}),
|
|
262
356
|
})
|
|
@@ -279,11 +373,13 @@ export class LocalHistoryProvider implements HistoryProvider {
|
|
|
279
373
|
const rss = Math.max(had?.maxPeakRssBytes ?? -Infinity, row.rss ?? -Infinity)
|
|
280
374
|
const cpu = Math.max(had?.maxCpuParallelism ?? -Infinity, row.cpu ?? -Infinity)
|
|
281
375
|
out.set(key, {
|
|
282
|
-
p50DurationMs:
|
|
376
|
+
p50DurationMs: p50s.get(key),
|
|
283
377
|
...(rss !== -Infinity ? { maxPeakRssBytes: rss } : {}),
|
|
284
378
|
...(cpu !== -Infinity ? { maxCpuParallelism: cpu } : {}),
|
|
285
379
|
})
|
|
286
380
|
}
|
|
381
|
+
// A forecast reads past the window: a task with none in it still has one.
|
|
382
|
+
for (const [key, p50] of p50s) if (!out.has(key)) out.set(key, { p50DurationMs: p50 })
|
|
287
383
|
return out
|
|
288
384
|
}
|
|
289
385
|
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
cleanOutputPaths,
|
|
15
15
|
cleanOutputs,
|
|
16
16
|
cleanWorkspaceOutputs,
|
|
17
|
+
committedFiles,
|
|
17
18
|
resolveOutputs,
|
|
18
19
|
resolveWorkspaceOutputs,
|
|
19
20
|
WORKSPACE_OUTPUT_PREFIX,
|
|
@@ -67,8 +68,13 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
|
|
|
67
68
|
projectDir: node.projectDir,
|
|
68
69
|
outputs,
|
|
69
70
|
nestedProjectDirs: args.nestedProjectDirs,
|
|
71
|
+
committed: committedFiles(args.gitFilesCache, node.projectDir),
|
|
72
|
+
}
|
|
73
|
+
const wsCleanArgs = {
|
|
74
|
+
workspaceRoot: args.workspaceRoot,
|
|
75
|
+
outputs: wsOutputs,
|
|
76
|
+
committed: committedFiles(args.gitFilesCache, args.workspaceRoot),
|
|
70
77
|
}
|
|
71
|
-
const wsCleanArgs = { workspaceRoot: args.workspaceRoot, outputs: wsOutputs }
|
|
72
78
|
// The two sides of an overlapping pair (item 588). An ADDITIVE task owns
|
|
73
79
|
// only its recorded rows: its glob also selects the upstream's files it
|
|
74
80
|
// adds beside, so "no strays" is not a claim it can make, and its clean
|
|
@@ -101,7 +107,7 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
|
|
|
101
107
|
// output replays from the row (`hit.stdout`), and its artifact holds
|
|
102
108
|
// only logs. Extracting it anyway cost an `exists` + a tar read per hit
|
|
103
109
|
// — 1.7 ms each on a four-task real repo whose `link` and `element`
|
|
104
|
-
// tasks declare `outputs: []` (2026-09-10,
|
|
110
|
+
// tasks declare `outputs: []` (2026-09-10, `VX_DEBUG_TIMING`).
|
|
105
111
|
let skipRestore = !anyOutputs
|
|
106
112
|
// The walk that found the tree stale, handed to the clean when nothing
|
|
107
113
|
// was awaited between them: the clean's own walk would only repeat it
|
|
@@ -230,7 +236,12 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
|
|
|
230
236
|
let cleanedWsRels: string[] = []
|
|
231
237
|
if (additive) {
|
|
232
238
|
cleanedRels = hit.outputFiles.filter((p) => !p.startsWith(WORKSPACE_OUTPUT_PREFIX))
|
|
233
|
-
await cleanOutputPaths({
|
|
239
|
+
await cleanOutputPaths({
|
|
240
|
+
projectDir: node.projectDir,
|
|
241
|
+
rels: cleanedRels,
|
|
242
|
+
outputs,
|
|
243
|
+
committed: cleanArgs.committed,
|
|
244
|
+
})
|
|
234
245
|
cleanedWsRels = hit.outputFiles
|
|
235
246
|
.filter((p) => p.startsWith(WORKSPACE_OUTPUT_PREFIX))
|
|
236
247
|
.map((p) => p.slice(WORKSPACE_OUTPUT_PREFIX.length))
|
|
@@ -238,6 +249,7 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
|
|
|
238
249
|
projectDir: args.workspaceRoot,
|
|
239
250
|
rels: cleanedWsRels,
|
|
240
251
|
outputs: wsOutputs,
|
|
252
|
+
committed: wsCleanArgs.committed,
|
|
241
253
|
})
|
|
242
254
|
} else {
|
|
243
255
|
if (outputs.length > 0) {
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
export { run, planRun, nxProjectTarget, projectNamed } from './run.js'
|
|
5
5
|
export { prepareRun, type PreparedRun } from './prepare.js'
|
|
6
6
|
export { affectedTaskProjects } from './affected-tasks.js'
|
|
7
|
+
export { applyGraphStage } from './graph-stage.js'
|
|
7
8
|
export type { AffectedReason } from './affected-tasks.js'
|
|
8
9
|
export {
|
|
9
10
|
discoverProjects,
|
|
@@ -13,6 +14,8 @@ export {
|
|
|
13
14
|
loadProjects,
|
|
14
15
|
loadResolvedProjects,
|
|
15
16
|
loadWorkspacePlugins,
|
|
17
|
+
lockOfDiscovery,
|
|
18
|
+
lockReader,
|
|
16
19
|
type LoadedProjects,
|
|
17
20
|
type LoadProjectsArgs,
|
|
18
21
|
} from './projects.js'
|
|
@@ -21,15 +24,19 @@ export type { HeldPersistent, RunOptions, RunSummary } from './options.js'
|
|
|
21
24
|
// Re-surface the cache policy contract (defined in the cache module) so
|
|
22
25
|
// embedders constructing RunOptions.cache and the package façade can
|
|
23
26
|
// reach it without importing the cache module directly.
|
|
24
|
-
export {
|
|
27
|
+
export {
|
|
28
|
+
type CacheFlags,
|
|
29
|
+
type CachePolicy,
|
|
30
|
+
FULL_CACHE_POLICY,
|
|
31
|
+
parseCachePolicy,
|
|
32
|
+
} from '../cache/index.js'
|
|
25
33
|
// The CLI reads a recorded exit code; the runner's convention decodes it.
|
|
26
34
|
// It records its own argv quoted as the runner quotes forwarded args.
|
|
27
35
|
export { exitSignal, shellQuote } from '../exec/index.js'
|
|
28
36
|
export { forwardedSignal } from './signals.js'
|
|
29
|
-
// `vx cache
|
|
37
|
+
// `vx cache clean` waits for a run on the workspace before it evicts.
|
|
30
38
|
export { acquireRunLock } from './run-lock.js'
|
|
31
39
|
export { defaultLogger, resolveOutputView } from './logger.js'
|
|
32
|
-
export { detectColors, paint, type ColorSupport } from './colors.js'
|
|
33
40
|
export type { Logger, OutputView } from './logger.js'
|
|
34
41
|
export type { RunPlan, PlannedTask, PlanPrediction, CacheStatus } from './plan.js'
|
|
35
42
|
export {
|
|
@@ -70,6 +77,7 @@ export {
|
|
|
70
77
|
installPlugins,
|
|
71
78
|
type CacheContext,
|
|
72
79
|
type ExecutorContext,
|
|
80
|
+
type OutputContext,
|
|
73
81
|
type CommandContext,
|
|
74
82
|
type GraphHookContext,
|
|
75
83
|
type KeyHookContext,
|
|
@@ -79,13 +87,15 @@ export {
|
|
|
79
87
|
type AdmitContext,
|
|
80
88
|
type WorkspaceHookContext,
|
|
81
89
|
type DiscoverContext,
|
|
82
|
-
type
|
|
90
|
+
type DiscoveredProject,
|
|
83
91
|
type InstallPluginsArgs,
|
|
84
92
|
type Plugin,
|
|
85
93
|
type PluginContext,
|
|
86
94
|
type PluginHookHandlers,
|
|
87
95
|
type PluginHookName,
|
|
88
96
|
type PluginSetupContext,
|
|
97
|
+
type RunReport,
|
|
98
|
+
type RunReportTask,
|
|
89
99
|
type FingerprintChange,
|
|
90
100
|
type FingerprintClaim,
|
|
91
101
|
type FingerprintContext,
|
|
@@ -105,8 +115,9 @@ export {
|
|
|
105
115
|
claimedAffected,
|
|
106
116
|
fingerprintClaims,
|
|
107
117
|
hasHook,
|
|
108
|
-
|
|
118
|
+
REMOTE_CACHE_METHODS,
|
|
109
119
|
resolveCache,
|
|
120
|
+
type CacheHost,
|
|
110
121
|
resolveExecutors,
|
|
111
122
|
} from './plugin-host.js'
|
|
112
123
|
export {
|
|
@@ -114,6 +125,7 @@ export {
|
|
|
114
125
|
reachDigests,
|
|
115
126
|
type LockfileClaimHooks,
|
|
116
127
|
type LockfileClaimOptions,
|
|
128
|
+
type LockfileImporter,
|
|
117
129
|
type ReachGraph,
|
|
118
130
|
} from './lockfile-claim.js'
|
|
119
131
|
export { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
|
|
@@ -147,6 +159,7 @@ export {
|
|
|
147
159
|
captureGitContext,
|
|
148
160
|
captureHostContext,
|
|
149
161
|
captureWorkspaceIdentity,
|
|
162
|
+
currentBranch,
|
|
150
163
|
detectCi,
|
|
151
164
|
} from './run-context.js'
|
|
152
165
|
export type { CiContext, GitContext, HostContext, WorkspaceIdentity } from './run-context.js'
|
|
@@ -158,6 +171,7 @@ export {
|
|
|
158
171
|
latestRunId,
|
|
159
172
|
listInvocations,
|
|
160
173
|
listRuns,
|
|
174
|
+
inputWriters,
|
|
161
175
|
rootCauses,
|
|
162
176
|
whyDidThisRerun as whyDidThisRerunQuery,
|
|
163
177
|
} from './metrics.js'
|
|
@@ -173,6 +187,7 @@ export { resolveRunId, shortRunId } from './run-id.js'
|
|
|
173
187
|
export type {
|
|
174
188
|
CacheEntryRow,
|
|
175
189
|
CacheKeyDiff,
|
|
190
|
+
InputWriter,
|
|
176
191
|
RootCause,
|
|
177
192
|
CacheKeyExplanation,
|
|
178
193
|
InputDiffEntry,
|
|
@@ -52,7 +52,8 @@ export interface ShortCircuitArgs {
|
|
|
52
52
|
nodes: Map<string, TaskNode>
|
|
53
53
|
cache: CacheLayer
|
|
54
54
|
workspaceRoot: string
|
|
55
|
-
|
|
55
|
+
/** The root files the plugins claim (`DeriveStableKeysArgs.claimedFiles`). */
|
|
56
|
+
claimedFiles: readonly string[]
|
|
56
57
|
forwardArgs?: readonly string[] | undefined
|
|
57
58
|
nestedDirsByProject: Map<string, string[]>
|
|
58
59
|
gitFilesCache: GitFilesCache
|