@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/cache/run-history.ts
CHANGED
|
@@ -13,11 +13,11 @@ const INSERT_RUNS = `
|
|
|
13
13
|
started_at, ended_at,
|
|
14
14
|
run_id, cpu_ms, peak_rss_bytes, wallclock_start_ns, wallclock_end_ns,
|
|
15
15
|
cache_hit, attempts, cached,
|
|
16
|
-
blocked_by, timed_out, sandbox_violations, not_ready, restored
|
|
16
|
+
blocked_by, timed_out, sandbox_violations, not_ready, restored, remote_error
|
|
17
17
|
)
|
|
18
18
|
VALUES `
|
|
19
|
-
/** One `runs` row's placeholders: the
|
|
20
|
-
const RUN_TUPLE = `(${Array(
|
|
19
|
+
/** One `runs` row's placeholders: the 23 columns `bindRun` fills. */
|
|
20
|
+
const RUN_TUPLE = `(${Array(23).fill('?').join(', ')})`
|
|
21
21
|
|
|
22
22
|
/** An INSERT of `n` `runs` rows. */
|
|
23
23
|
function insertRunsSql(n: number): string {
|
|
@@ -25,7 +25,7 @@ function insertRunsSql(n: number): string {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
* Rows per INSERT: 40 ×
|
|
28
|
+
* Rows per INSERT: 40 × 23 variables stays under 999, the oldest default
|
|
29
29
|
* ceiling. A statement per row was 8.7 ms of 1,000 rows, 40 at a time 6.4
|
|
30
30
|
* (2026-10-03).
|
|
31
31
|
*/
|
|
@@ -34,6 +34,31 @@ const RUNS_PER_INSERT = 40
|
|
|
34
34
|
/** Rows per `task_durations` INSERT: 6 variables each, under 999. */
|
|
35
35
|
const DURATIONS_PER_INSERT = 160
|
|
36
36
|
|
|
37
|
+
/** Rows per `task_outcomes` INSERT: 6 variables each, under 999. */
|
|
38
|
+
const OUTCOMES_PER_INSERT = 160
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Whether a run says anything of its key, so the flaky record may keep it:
|
|
42
|
+
* keyed, cached, and not run behind a failure (its bytes were not the key's).
|
|
43
|
+
*/
|
|
44
|
+
const keyed = (r: RunRecord): boolean =>
|
|
45
|
+
r.hash !== undefined && r.hash !== '' && r.cached !== false && r.blockedBy == null
|
|
46
|
+
|
|
47
|
+
/** A hit, as the orchestrator marks one (`isCacheHit`, run-records.ts). */
|
|
48
|
+
const isHit = (r: RunRecord): boolean => r.cacheHit === true
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Of a JSON array of hashes, those the record holds a pass of: one index
|
|
52
|
+
* probe each (0.6 ms for 1,000; a join reading every row of each key was
|
|
53
|
+
* 5.7 ms at 20 runs of history). A hit's row is kept only when its key has
|
|
54
|
+
* none: a hit replays a pass, and the first is what a later failure
|
|
55
|
+
* relapses from (an entry adopted from its artifact, or older than the
|
|
56
|
+
* record, has no executed row). By hash alone, as the entry it replays is.
|
|
57
|
+
*/
|
|
58
|
+
const PASSED_HASHES = `
|
|
59
|
+
SELECT j.value AS hash FROM json_each(?) AS j
|
|
60
|
+
WHERE EXISTS (SELECT 1 FROM task_outcomes AS o WHERE o.hash = j.value AND o.status <> 'failed')`
|
|
61
|
+
|
|
37
62
|
/** A local run's `task_durations.source`: its branch, `''` when it had none. */
|
|
38
63
|
export const branchSource = (branch: string | null): string => `branch:${branch ?? ''}`
|
|
39
64
|
|
|
@@ -55,9 +80,9 @@ export class RunHistory {
|
|
|
55
80
|
up_to_date_count, restored_local_count, restored_remote_count,
|
|
56
81
|
exit_ok,
|
|
57
82
|
commit_sha, branch, dirty, ci, ci_provider,
|
|
58
|
-
host, os, arch, vx_version
|
|
83
|
+
host, os, arch, vx_version
|
|
59
84
|
)
|
|
60
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
85
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
61
86
|
ON CONFLICT(run_id) DO NOTHING
|
|
62
87
|
`,
|
|
63
88
|
)
|
|
@@ -75,6 +100,7 @@ export class RunHistory {
|
|
|
75
100
|
.transaction(() => {
|
|
76
101
|
this.insertRuns(runs)
|
|
77
102
|
this.insertDurations(runs, branchSource(null), null)
|
|
103
|
+
this.insertOutcomes(runs)
|
|
78
104
|
})
|
|
79
105
|
.immediate()
|
|
80
106
|
}
|
|
@@ -94,6 +120,45 @@ export class RunHistory {
|
|
|
94
120
|
}
|
|
95
121
|
}
|
|
96
122
|
|
|
123
|
+
/**
|
|
124
|
+
* The flaky record's rows: every executed outcome, and a key's first hit,
|
|
125
|
+
* in the order given (a batch may hold one key's history).
|
|
126
|
+
*/
|
|
127
|
+
private insertOutcomes(runs: readonly RunRecord[]): void {
|
|
128
|
+
const rows = runs.filter(
|
|
129
|
+
(r) => keyed(r) && (isHit(r) || r.status === 'success' || r.status === 'failed'),
|
|
130
|
+
)
|
|
131
|
+
const hits = rows.filter(isHit)
|
|
132
|
+
let passed: Set<string> | undefined
|
|
133
|
+
if (hits.length > 0) {
|
|
134
|
+
const found = this.db.query(PASSED_HASHES).all(JSON.stringify(hits.map((r) => r.hash))) as {
|
|
135
|
+
hash: string
|
|
136
|
+
}[]
|
|
137
|
+
passed = new Set(found.map((f) => f.hash))
|
|
138
|
+
}
|
|
139
|
+
const kept: RunRecord[] = []
|
|
140
|
+
for (const r of rows) {
|
|
141
|
+
if (isHit(r)) {
|
|
142
|
+
if (passed!.has(r.hash!)) continue
|
|
143
|
+
passed!.add(r.hash!)
|
|
144
|
+
} else if (r.status === 'success') {
|
|
145
|
+
passed?.add(r.hash!)
|
|
146
|
+
}
|
|
147
|
+
kept.push(r)
|
|
148
|
+
}
|
|
149
|
+
for (let i = 0; i < kept.length; i += OUTCOMES_PER_INSERT) {
|
|
150
|
+
const block = kept.slice(i, i + OUTCOMES_PER_INSERT)
|
|
151
|
+
const params: SQLQueryBindings[] = []
|
|
152
|
+
for (const r of block)
|
|
153
|
+
params.push(r.project, r.task, r.hash!, r.status, r.attempts ?? null, r.startedAt)
|
|
154
|
+
this.db
|
|
155
|
+
.query(
|
|
156
|
+
`INSERT INTO task_outcomes(project, task, hash, status, attempts, recorded_at) VALUES ${Array(block.length).fill('(?, ?, ?, ?, ?, ?)').join(', ')}`,
|
|
157
|
+
)
|
|
158
|
+
.run(...params)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
97
162
|
/** `runs` as few INSERTs; each distinct forward-args list digested once. */
|
|
98
163
|
private insertRuns(runs: readonly RunRecord[]): void {
|
|
99
164
|
const digests = new Map<string, string>()
|
|
@@ -134,6 +199,23 @@ export class RunHistory {
|
|
|
134
199
|
branchSource(bundle.invocation.branch),
|
|
135
200
|
bundle.invocation.commitSha,
|
|
136
201
|
)
|
|
202
|
+
this.insertOutcomes(bundle.runs)
|
|
203
|
+
})
|
|
204
|
+
.immediate()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The remote failures a run's uploads met after its rows were written:
|
|
209
|
+
* the uploads drain once the history is in (run.ts).
|
|
210
|
+
*/
|
|
211
|
+
recordRemoteErrors(
|
|
212
|
+
runId: string,
|
|
213
|
+
errors: readonly (readonly [hash: string, text: string])[],
|
|
214
|
+
): void {
|
|
215
|
+
const update = this.db.prepare('UPDATE runs SET remote_error = ? WHERE run_id = ? AND hash = ?')
|
|
216
|
+
this.db
|
|
217
|
+
.transaction(() => {
|
|
218
|
+
for (const [hash, text] of errors) update.run(text, runId, hash)
|
|
137
219
|
})
|
|
138
220
|
.immediate()
|
|
139
221
|
}
|
|
@@ -149,10 +231,12 @@ export class RunHistory {
|
|
|
149
231
|
this.db.prepare('DELETE FROM runs WHERE started_at < ?').run(cutoff)
|
|
150
232
|
this.db.prepare('DELETE FROM invocations WHERE started_at < ?').run(cutoff)
|
|
151
233
|
this.db.prepare('DELETE FROM task_durations WHERE recorded_at < ?').run(cutoff)
|
|
234
|
+
this.db.prepare('DELETE FROM task_outcomes WHERE recorded_at < ?').run(cutoff)
|
|
235
|
+
this.db.prepare('DELETE FROM plugin_blobs WHERE at < ?').run(cutoff)
|
|
152
236
|
}
|
|
153
237
|
}
|
|
154
238
|
|
|
155
|
-
/** Bind a RunRecord to one `RUN_TUPLE`'s
|
|
239
|
+
/** Bind a RunRecord to one `RUN_TUPLE`'s 23 positional parameters. */
|
|
156
240
|
function bindRun(run: RunRecord, digestValue: (v: string) => string): SQLQueryBindings[] {
|
|
157
241
|
return [
|
|
158
242
|
// The ONE place the no-key sentinel is applied, so the column's
|
|
@@ -182,6 +266,7 @@ function bindRun(run: RunRecord, digestValue: (v: string) => string): SQLQueryBi
|
|
|
182
266
|
run.sandboxViolations ?? null,
|
|
183
267
|
run.notReady ?? null,
|
|
184
268
|
run.restored === undefined ? null : run.restored ? 1 : 0,
|
|
269
|
+
run.remoteError ?? null,
|
|
185
270
|
]
|
|
186
271
|
}
|
|
187
272
|
|
|
@@ -219,6 +304,5 @@ function bindInvocation(inv: InvocationRecord): SQLQueryBindings[] {
|
|
|
219
304
|
inv.os,
|
|
220
305
|
inv.arch,
|
|
221
306
|
inv.vxVersion,
|
|
222
|
-
inv.tags,
|
|
223
307
|
]
|
|
224
308
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// What core keeps of a run that writes a remote (owner 2026-10-15): a failed
|
|
2
|
+
// task's log and the files its outputs left, an uncached task's log. Names
|
|
3
|
+
// come from the run id and task (`run/<id>/<task>/log`); each key is derived
|
|
4
|
+
// in a namespace no task key or plugin entry shares. Remote only: locally
|
|
5
|
+
// `vx last --log` and the working tree already hold them. No size cap (M:
|
|
6
|
+
// the cache is free). Plugins read them by name (`ctx.cache.getRun`).
|
|
7
|
+
|
|
8
|
+
import { xxh3hex } from '../util/index.js'
|
|
9
|
+
import type { ResolvedRemote } from './remote-lane.js'
|
|
10
|
+
import { scopedKey } from './scoped-cache.js'
|
|
11
|
+
import { tarPackInto, tarSize } from './tar-stream.js'
|
|
12
|
+
|
|
13
|
+
/** The name core stores a task's `part` of run `runId` under. */
|
|
14
|
+
export function runEntryName(runId: string, taskId: string, part: 'log' | 'outputs'): string {
|
|
15
|
+
return `run/${runId}/${taskId}/${part}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const runKey = (name: string): string => xxh3hex(`vx-run\0${name}`)
|
|
19
|
+
|
|
20
|
+
/** The keys `name` is read under: the default, then the scope's, as tasks read. */
|
|
21
|
+
export function runReadKeys(name: string, scope: string | undefined): string[] {
|
|
22
|
+
const key = runKey(name)
|
|
23
|
+
return scope === undefined ? [key] : [key, scopedKey(scope, key)]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Whether some remote takes this run's writes: only then does core keep run bytes. */
|
|
27
|
+
export function writesRunStore(remotes: readonly ResolvedRemote[]): boolean {
|
|
28
|
+
return remotes.some((r) => r.write)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Store `bytes` under `name` in every remote the policy writes. An entry is
|
|
33
|
+
* written once; a remote's failure leaves the others theirs, silently: what
|
|
34
|
+
* a run keeps here is a record, never its verdict.
|
|
35
|
+
*/
|
|
36
|
+
export async function putRunEntry(
|
|
37
|
+
remotes: readonly ResolvedRemote[],
|
|
38
|
+
scope: string | undefined,
|
|
39
|
+
name: string,
|
|
40
|
+
bytes: Uint8Array,
|
|
41
|
+
): Promise<boolean> {
|
|
42
|
+
const key = scope === undefined ? runKey(name) : scopedKey(scope, runKey(name))
|
|
43
|
+
let kept = false
|
|
44
|
+
for (const { remote, write } of remotes) {
|
|
45
|
+
if (!write) continue
|
|
46
|
+
try {
|
|
47
|
+
await remote.put(key, new Blob([bytes]), { durationMs: 0 })
|
|
48
|
+
kept = true
|
|
49
|
+
} catch {
|
|
50
|
+
// the other remotes still keep it
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return kept
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The files at `paths` (workspace-relative) as a plain ustar archive, names
|
|
58
|
+
* as given, so any `tar` reads it; null when none is left to read.
|
|
59
|
+
*/
|
|
60
|
+
export async function packRunOutputs(
|
|
61
|
+
workspaceRoot: string,
|
|
62
|
+
paths: readonly string[],
|
|
63
|
+
): Promise<Uint8Array | null> {
|
|
64
|
+
const inputs: { name: string; size: number; body: Uint8Array }[] = []
|
|
65
|
+
for (const name of paths) {
|
|
66
|
+
try {
|
|
67
|
+
const body = await Bun.file(`${workspaceRoot}/${name}`).bytes()
|
|
68
|
+
inputs.push({ name, size: body.byteLength, body })
|
|
69
|
+
} catch {
|
|
70
|
+
// gone since the glob: nothing to keep
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (inputs.length === 0) return null
|
|
74
|
+
const out = new Uint8Array(tarSize(inputs))
|
|
75
|
+
tarPackInto(inputs, out)
|
|
76
|
+
return out
|
|
77
|
+
}
|
package/src/cache/schema.ts
CHANGED
|
@@ -23,7 +23,8 @@ export function lazyStatement(db: Database, sql: string): ReturnType<Database['p
|
|
|
23
23
|
/**
|
|
24
24
|
* Create every table. `store` names the schema holding the ENTRY tables
|
|
25
25
|
* (`entries`, `entry_stdout`, `output_files`, `entry_inputs`, `store_meta`)
|
|
26
|
-
*
|
|
26
|
+
* the task timings (`task_durations`), the flaky record (`task_outcomes`),
|
|
27
|
+
* the blob sizes (`blob_sizes`) and the plugins' blobs (`plugin_blobs`):
|
|
27
28
|
* `main` when one `cache.db` holds everything (a `cacheDir`), `store` when
|
|
28
29
|
* they live in the shared store attached beside it (v32). The rest is the
|
|
29
30
|
* workspace's own: its history, its memos, and what its disk looked like
|
|
@@ -97,7 +98,10 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
|
|
|
97
98
|
not_ready TEXT,
|
|
98
99
|
-- v31: on a hit, 1 when outputs were restored, 0 when the disk
|
|
99
100
|
-- already matched (up-to-date); NULL on every other status.
|
|
100
|
-
restored INTEGER
|
|
101
|
+
restored INTEGER,
|
|
102
|
+
-- v40: each remote read or write of the key that failed, as
|
|
103
|
+
-- 'upload: <message>', '; '-joined; NULL when none did.
|
|
104
|
+
remote_error TEXT
|
|
101
105
|
);
|
|
102
106
|
-- Two whole-table indexes only, and both APPEND: every row of a run carries the
|
|
103
107
|
-- same run_id and a started_at newer than everything before it, so
|
|
@@ -114,17 +118,14 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
|
|
|
114
118
|
-- runs_ended (2026-09-09) served only the retention DELETE, which
|
|
115
119
|
-- prunes on started_at now (a row ends after it starts, so the
|
|
116
120
|
-- 30-day window moves by at most one task's duration).
|
|
121
|
+
-- runs_failed (v38) served the flakiness probe, which reads the
|
|
122
|
+
-- store's task_outcomes now.
|
|
117
123
|
DROP INDEX IF EXISTS main.runs_hash;
|
|
118
124
|
DROP INDEX IF EXISTS main.runs_project;
|
|
119
125
|
DROP INDEX IF EXISTS main.runs_ended;
|
|
126
|
+
DROP INDEX IF EXISTS main.runs_failed;
|
|
120
127
|
CREATE INDEX IF NOT EXISTS main.runs_started_at ON runs(started_at);
|
|
121
128
|
CREATE INDEX IF NOT EXISTS main.runs_run_id ON runs(run_id);
|
|
122
|
-
-- The one keyed index, PARTIAL over failed rows: a green run's
|
|
123
|
-
-- inserts only evaluate its predicate, so the append-only cost above
|
|
124
|
-
-- holds, and the flakiness probe after a miss (failure-mode.ts,
|
|
125
|
-
-- "did this key ever fail?") reads a handful of leaves instead of
|
|
126
|
-
-- scanning the table (2026-09-10: 10–95 ms at 170k rows without it).
|
|
127
|
-
CREATE INDEX IF NOT EXISTS main.runs_failed ON runs(hash) WHERE status = 'failed';
|
|
128
129
|
-- Per-file (mtime, size, content_hash) cache. Lets Cache.key()
|
|
129
130
|
-- skip the content-hash on inputs whose stat hasn't changed
|
|
130
131
|
-- since the last run. Pure performance optimization; the stored
|
|
@@ -142,8 +143,9 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
|
|
|
142
143
|
-- The size of each index blob the enumeration checked against the
|
|
143
144
|
-- worktree size git recorded (A-60). Fixed for its OID, so a warm
|
|
144
145
|
-- run asks git for none; swept with file_hashes by seen_at, the
|
|
145
|
-
-- time the row was written.
|
|
146
|
-
|
|
146
|
+
-- time the row was written. v38: in the store, so a worktree reads
|
|
147
|
+
-- what another learned (owner, 2026-10-14).
|
|
148
|
+
CREATE TABLE IF NOT EXISTS ${store}.blob_sizes (
|
|
147
149
|
oid TEXT PRIMARY KEY,
|
|
148
150
|
size INTEGER NOT NULL,
|
|
149
151
|
seen_at INTEGER NOT NULL
|
|
@@ -200,7 +202,7 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
|
|
|
200
202
|
);
|
|
201
203
|
-- v22 (Tier 3): one header row per vx-run invocation. The runs
|
|
202
204
|
-- table is per-task; this is the per-invocation record that
|
|
203
|
-
-- carries git/CI/host context, the command,
|
|
205
|
+
-- carries git/CI/host context, the command, and run-level
|
|
204
206
|
-- counts so the dashboard never reconstructs a header with a
|
|
205
207
|
-- lossy GROUP BY over runs.
|
|
206
208
|
CREATE TABLE IF NOT EXISTS main.invocations (
|
|
@@ -231,8 +233,7 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
|
|
|
231
233
|
host TEXT,
|
|
232
234
|
os TEXT,
|
|
233
235
|
arch TEXT,
|
|
234
|
-
vx_version TEXT NOT NULL
|
|
235
|
-
tags TEXT NOT NULL DEFAULT '{}'
|
|
236
|
+
vx_version TEXT NOT NULL
|
|
236
237
|
);
|
|
237
238
|
CREATE INDEX IF NOT EXISTS main.invocations_started ON invocations(started_at);
|
|
238
239
|
CREATE INDEX IF NOT EXISTS main.invocations_branch ON invocations(branch);
|
|
@@ -275,6 +276,36 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
|
|
|
275
276
|
);
|
|
276
277
|
CREATE INDEX IF NOT EXISTS ${store}.task_durations_task
|
|
277
278
|
ON task_durations(project, task, source, id);
|
|
279
|
+
-- v37: a 'remote' row is a default-branch commit's timing a plugin
|
|
280
|
+
-- imported (\`sha\` that commit): one per task, so a repeat import is a
|
|
281
|
+
-- no-op under INSERT OR IGNORE.
|
|
282
|
+
CREATE UNIQUE INDEX IF NOT EXISTS ${store}.task_durations_remote
|
|
283
|
+
ON task_durations(sha, project, task) WHERE source = 'remote';
|
|
284
|
+
-- v38: the flaky record (orchestrator/failure-mode.ts): each keyed,
|
|
285
|
+
-- cached, unblocked outcome that observed its key, shared by every
|
|
286
|
+
-- worktree so a key that passed in one and failed in another is flaky
|
|
287
|
+
-- in both (owner, 2026-10-14). Every executed success or failure, and
|
|
288
|
+
-- a key's first hit (run-history.ts): a later hit replays a pass the
|
|
289
|
+
-- record holds, so a warm run on keys that passed writes no row.
|
|
290
|
+
-- Append-only, ordered by id, pruned by age with the run history.
|
|
291
|
+
CREATE TABLE IF NOT EXISTS ${store}.task_outcomes (
|
|
292
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
293
|
+
project TEXT NOT NULL,
|
|
294
|
+
task TEXT NOT NULL,
|
|
295
|
+
hash TEXT NOT NULL,
|
|
296
|
+
status TEXT NOT NULL,
|
|
297
|
+
attempts INTEGER,
|
|
298
|
+
recorded_at INTEGER NOT NULL
|
|
299
|
+
);
|
|
300
|
+
CREATE INDEX IF NOT EXISTS ${store}.task_outcomes_hash ON task_outcomes(hash, status);
|
|
301
|
+
-- v37: what a plugin keeps through \`ctx.cache\` (plugin-cache.ts), by
|
|
302
|
+
-- its namespaced key. Written once, never updated; pruned by age with
|
|
303
|
+
-- the run history.
|
|
304
|
+
CREATE TABLE IF NOT EXISTS ${store}.plugin_blobs (
|
|
305
|
+
key TEXT PRIMARY KEY,
|
|
306
|
+
bytes BLOB NOT NULL,
|
|
307
|
+
at INTEGER NOT NULL
|
|
308
|
+
);
|
|
278
309
|
${artifactTables(store)}
|
|
279
310
|
`)
|
|
280
311
|
}
|
|
@@ -16,7 +16,7 @@ import type {
|
|
|
16
16
|
} from './layer.js'
|
|
17
17
|
|
|
18
18
|
/** The key a scope stores `hash` under: task-key shaped, 16 hex, so every wire stores it unchanged. */
|
|
19
|
-
function scopedKey(scope: string, hash: string): string {
|
|
19
|
+
export function scopedKey(scope: string, hash: string): string {
|
|
20
20
|
return xxh3hex(`vx-cache-scope\0${scope}\0${hash}`)
|
|
21
21
|
}
|
|
22
22
|
|
|
@@ -33,7 +33,10 @@ export class ScopedCache implements CacheLayer {
|
|
|
33
33
|
readonly local?: CacheLayer['local']
|
|
34
34
|
readonly hasRemote?: boolean
|
|
35
35
|
readonly drainUploads?: () => Promise<void>
|
|
36
|
+
readonly stopUploads?: () => void
|
|
36
37
|
readonly remoteHasMany?: (hashes: readonly string[]) => Promise<Set<string> | null>
|
|
38
|
+
/** A scoped run saves under the scope's key, so that is the key asked. */
|
|
39
|
+
readonly probeBeforeUpload?: (hashes: Promise<readonly string[]>) => void
|
|
37
40
|
readonly getMany?: (
|
|
38
41
|
hashes: readonly string[],
|
|
39
42
|
ctx?: (hash: string) => CacheGetContext,
|
|
@@ -48,7 +51,11 @@ export class ScopedCache implements CacheLayer {
|
|
|
48
51
|
if (inner.local !== undefined) this.local = inner.local
|
|
49
52
|
if (inner.hasRemote !== undefined) this.hasRemote = inner.hasRemote
|
|
50
53
|
if (inner.drainUploads !== undefined) this.drainUploads = () => inner.drainUploads!()
|
|
54
|
+
if (inner.stopUploads !== undefined) this.stopUploads = () => inner.stopUploads!()
|
|
51
55
|
if (inner.remoteHasMany !== undefined) this.remoteHasMany = (h) => this.scopedHasMany(h)
|
|
56
|
+
if (inner.probeBeforeUpload !== undefined)
|
|
57
|
+
this.probeBeforeUpload = (h) =>
|
|
58
|
+
inner.probeBeforeUpload!(h.then((keys) => keys.map((k) => this.own(k))))
|
|
52
59
|
if (inner.getMany !== undefined) this.getMany = (h, ctx) => this.scopedGetMany(h, ctx)
|
|
53
60
|
}
|
|
54
61
|
|
|
@@ -75,6 +82,13 @@ export class ScopedCache implements CacheLayer {
|
|
|
75
82
|
return out
|
|
76
83
|
}
|
|
77
84
|
|
|
85
|
+
/** A task key the store holds under the default key or the scope's. */
|
|
86
|
+
async localHasMany(hashes: readonly string[]): Promise<Set<string>> {
|
|
87
|
+
const held = await this.inner.localHasMany?.([...hashes, ...hashes.map((h) => this.own(h))])
|
|
88
|
+
if (held === undefined) return new Set()
|
|
89
|
+
return new Set(hashes.filter((h) => held.has(h) || held.has(this.own(h))))
|
|
90
|
+
}
|
|
91
|
+
|
|
78
92
|
private async scopedHasMany(hashes: readonly string[]): Promise<Set<string> | null> {
|
|
79
93
|
const scoped = hashes.map((h) => this.own(h))
|
|
80
94
|
const [plain, own] = await Promise.all([
|
|
@@ -105,7 +119,13 @@ export class ScopedCache implements CacheLayer {
|
|
|
105
119
|
}
|
|
106
120
|
|
|
107
121
|
async get(hash: string, ctx?: CacheGetContext): Promise<CacheEntry | null> {
|
|
108
|
-
|
|
122
|
+
// The scope's key first when only it is local: the default key's
|
|
123
|
+
// remote GET would cost a round-trip for the same inputs.
|
|
124
|
+
const own = this.own(hash)
|
|
125
|
+
const keys = (await this.inner.localHasMany?.([hash, own]))?.has(own)
|
|
126
|
+
? [own, hash]
|
|
127
|
+
: [hash, own]
|
|
128
|
+
for (const key of keys) {
|
|
109
129
|
const entry = await this.inner.get(key, ctx)
|
|
110
130
|
if (entry === null) continue
|
|
111
131
|
this.served.set(hash, key)
|
package/src/cli/cache.ts
CHANGED
|
@@ -3,7 +3,15 @@ import { homedir } from 'node:os'
|
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { Cache, vxCacheEntries } from '../cache/index.js'
|
|
5
5
|
import { flagHint, formatValue, refuse, refusedWord, seeHelp } from './help.js'
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
errorCode,
|
|
8
|
+
nearest,
|
|
9
|
+
parseDuration,
|
|
10
|
+
parseSize,
|
|
11
|
+
relPosix,
|
|
12
|
+
treeBytes,
|
|
13
|
+
ui,
|
|
14
|
+
} from '../util/index.js'
|
|
7
15
|
import { acquireRunLock } from '../orchestrator/index.js'
|
|
8
16
|
import { findWorkspaceRoot } from '../workspace/index.js'
|
|
9
17
|
import {
|
|
@@ -254,15 +262,28 @@ function printPruned(
|
|
|
254
262
|
)
|
|
255
263
|
return
|
|
256
264
|
}
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
265
|
+
const plural = (n: number, one: string, many: string): string => `${n} ${n === 1 ? one : many}`
|
|
266
|
+
const facts = [
|
|
267
|
+
...(result.evicted > 0 ? [plural(result.evicted, 'entry', 'entries')] : []),
|
|
268
|
+
...(result.orphans > 0
|
|
269
|
+
? [plural(result.orphans, 'orphaned artifact', 'orphaned artifacts')]
|
|
270
|
+
: []),
|
|
271
|
+
]
|
|
272
|
+
ui.print(
|
|
273
|
+
'out',
|
|
274
|
+
freedStatus(dry, result.bytesFreed + result.orphanBytes, facts.length > 0),
|
|
275
|
+
...(facts.length > 0 ? [ui.text(facts.join(' · '))] : []),
|
|
263
276
|
)
|
|
264
277
|
}
|
|
265
278
|
|
|
279
|
+
/** `✓ vx cache clean freed 1.3 GB`, `› … would free 1.3 GB (dry run)`, or nothing to free. */
|
|
280
|
+
function freedStatus(dry: boolean, bytes: number, any: boolean): ui.Block {
|
|
281
|
+
if (!any) return ui.status('info', 'vx cache clean', `nothing to free${dry ? ' (dry run)' : ''}`)
|
|
282
|
+
return dry
|
|
283
|
+
? ui.status('info', 'vx cache clean', `would free ${formatBytes(bytes)} (dry run)`)
|
|
284
|
+
: ui.status('ok', 'vx cache clean', `freed ${formatBytes(bytes)}`)
|
|
285
|
+
}
|
|
286
|
+
|
|
266
287
|
/** `vx cache clean --format json` with no bound (schemas/cache.json `clear`). */
|
|
267
288
|
export interface ClearResult {
|
|
268
289
|
dryRun: boolean
|
|
@@ -341,16 +362,12 @@ async function clear(
|
|
|
341
362
|
if (parsed.format === 'json') {
|
|
342
363
|
const result: ClearResult = { dryRun: dry, cleared: shown, bytesFreed: bytes }
|
|
343
364
|
process.stdout.write(`${JSON.stringify(result)}\n`)
|
|
344
|
-
} else
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
: "this repository's store, shared by its checkouts, and this workspace's run history"
|
|
350
|
-
process.stdout.write(
|
|
351
|
-
`${dry ? 'Would clear' : 'Cleared'} ${scope} (${formatBytes(bytes)}${dry ? '' : ' freed'}):\n${shown.map((t) => ` ${t}\n`).join('')}`,
|
|
365
|
+
} else
|
|
366
|
+
ui.print(
|
|
367
|
+
'out',
|
|
368
|
+
freedStatus(dry, bytes, shown.length > 0),
|
|
369
|
+
...(shown.length > 0 ? [ui.text(...shown)] : []),
|
|
352
370
|
)
|
|
353
|
-
}
|
|
354
371
|
return 0
|
|
355
372
|
}
|
|
356
373
|
|
package/src/cli/completions.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// Tab is the wrong price).
|
|
7
7
|
|
|
8
8
|
import { acceptedFlags, CORE_VERBS, flagHint, seeHelp } from './help.js'
|
|
9
|
-
import { nearest } from '../util/index.js'
|
|
9
|
+
import { nearest, sayNotice } from '../util/index.js'
|
|
10
10
|
|
|
11
11
|
/** A flag whose value is one of a fixed set; the tests hold each to its parser. */
|
|
12
12
|
export const FLAG_VALUES: Readonly<Record<string, readonly string[]>> = {
|
|
@@ -21,8 +21,8 @@ const SHELLS: readonly CompletionShell[] = ['bash', 'zsh', 'fish']
|
|
|
21
21
|
/**
|
|
22
22
|
* `--help` and every flag the verb accepts. Not every `--flag` its help cut
|
|
23
23
|
* mentions: the cut's prose names other verbs' flags, and `vx watch`
|
|
24
|
-
* completed only
|
|
25
|
-
*
|
|
24
|
+
* completed only the flags its help line says it refuses, while `vx show`
|
|
25
|
+
* offered `--run` and `--list`.
|
|
26
26
|
*/
|
|
27
27
|
export function verbFlags(verb: string): string[] {
|
|
28
28
|
return [...new Set(['--help', ...acceptedFlags(verb)])]
|
|
@@ -139,8 +139,10 @@ export async function completionsCmd(
|
|
|
139
139
|
if (args.length !== 1 || !(SHELLS as readonly string[]).includes(shell ?? '')) {
|
|
140
140
|
const flag = args.find((a) => a.startsWith('-'))
|
|
141
141
|
if (flag !== undefined) {
|
|
142
|
-
|
|
143
|
-
|
|
142
|
+
sayNotice(
|
|
143
|
+
'error',
|
|
144
|
+
'vx completions',
|
|
145
|
+
`unknown flag: ${flag}${flagHint('completions', flag)}${seeHelp('completions')}`,
|
|
144
146
|
)
|
|
145
147
|
return 1
|
|
146
148
|
}
|
|
@@ -148,8 +150,10 @@ export async function completionsCmd(
|
|
|
148
150
|
// One edit: shell names are three or four letters, so two edits took
|
|
149
151
|
// `tcsh` to `bash`, a different shell.
|
|
150
152
|
const best = one ? nearest(shell, SHELLS, 1) : undefined
|
|
151
|
-
|
|
152
|
-
|
|
153
|
+
sayNotice(
|
|
154
|
+
'error',
|
|
155
|
+
'vx completions',
|
|
156
|
+
`expected one shell — bash, zsh or fish${one ? ` (got ${shell})` : ''}${best === undefined ? '' : `. Did you mean ${best}?`}${seeHelp('completions')}`,
|
|
153
157
|
)
|
|
154
158
|
return 1
|
|
155
159
|
}
|
package/src/cli/foreign-flags.ts
CHANGED
|
@@ -22,8 +22,7 @@ interface ForeignFlag {
|
|
|
22
22
|
/**
|
|
23
23
|
* The runner reads `=true` / `=false` on it (Turbo's `--force [FORCE]`,
|
|
24
24
|
* Nx's yargs booleans): `=false` is the flag left out, `=true` the flag
|
|
25
|
-
* bare
|
|
26
|
-
* wrote the summary to a file named `true`.
|
|
25
|
+
* bare: `--skip-nx-cache=false` forced every task.
|
|
27
26
|
*/
|
|
28
27
|
bool?: true
|
|
29
28
|
/** For an alias: the vx argv. For a refusal: none. */
|
|
@@ -47,8 +46,6 @@ const nxPattern = (p: string): string => {
|
|
|
47
46
|
return p
|
|
48
47
|
}
|
|
49
48
|
|
|
50
|
-
const GRAPH_IMAGE = /\.(svg|png|jpe?g|pdf|json|html|mermaid)$/i
|
|
51
|
-
|
|
52
49
|
export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
|
|
53
50
|
{
|
|
54
51
|
runner: 'turbo',
|
|
@@ -98,21 +95,12 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
|
|
|
98
95
|
vx: '`--dry[=text|json]`',
|
|
99
96
|
to: (v) => [v === undefined ? '--dry' : `--dry=${v}`],
|
|
100
97
|
},
|
|
101
|
-
{
|
|
102
|
-
runner: 'turbo',
|
|
103
|
-
names: ['--graph'],
|
|
104
|
-
value: true,
|
|
105
|
-
when: (v) => v !== undefined && GRAPH_IMAGE.test(v),
|
|
106
|
-
shown: '`--graph=<file>.svg|png|json|html|…`',
|
|
107
|
-
outcome: 'refuse',
|
|
108
|
-
vx: 'vx writes Graphviz DOT only: `--graph=<file>.dot`, then `dot -Tsvg`',
|
|
109
|
-
},
|
|
110
98
|
{
|
|
111
99
|
runner: 'turbo',
|
|
112
100
|
names: ['--graph'],
|
|
113
101
|
value: false,
|
|
114
|
-
outcome: '
|
|
115
|
-
vx: '`--graph
|
|
102
|
+
outcome: 'refuse',
|
|
103
|
+
vx: '`--dry=json` prints the planned task graph, each task with its deps',
|
|
116
104
|
},
|
|
117
105
|
{
|
|
118
106
|
runner: 'turbo',
|
|
@@ -133,9 +121,8 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
|
|
|
133
121
|
runner: 'turbo',
|
|
134
122
|
names: ['--summarize'],
|
|
135
123
|
value: false,
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
vx: '`--summarize[=<path>]`',
|
|
124
|
+
outcome: 'refuse',
|
|
125
|
+
vx: "use `vx last --format json` for the run's JSON record",
|
|
139
126
|
},
|
|
140
127
|
{
|
|
141
128
|
runner: 'turbo',
|
|
@@ -263,7 +250,8 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
|
|
|
263
250
|
runner: 'nx',
|
|
264
251
|
names: ['--parallel'],
|
|
265
252
|
value: true,
|
|
266
|
-
|
|
253
|
+
// Nx's value is a count or `false`; a word after it is a task name.
|
|
254
|
+
when: (v) => v === 'false' || (v !== undefined && /^\d+$/.test(v)),
|
|
267
255
|
shown: '`--parallel <n>`',
|
|
268
256
|
outcome: 'alias',
|
|
269
257
|
vx: '`--concurrency <n>` (`--parallel=false` is 1)',
|
|
@@ -362,14 +350,14 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
|
|
|
362
350
|
names: ['--json'],
|
|
363
351
|
value: false,
|
|
364
352
|
outcome: 'refuse',
|
|
365
|
-
vx: "use
|
|
353
|
+
vx: "use `vx last --format json` for the run's result, `--dry=json` for the plan",
|
|
366
354
|
},
|
|
367
355
|
{
|
|
368
356
|
runner: 'turbo',
|
|
369
357
|
names: ['--log-file'],
|
|
370
358
|
value: false,
|
|
371
359
|
outcome: 'refuse',
|
|
372
|
-
vx: "use
|
|
360
|
+
vx: "use `vx last --format json` for the run's JSON record",
|
|
373
361
|
},
|
|
374
362
|
{
|
|
375
363
|
runner: 'turbo',
|
|
@@ -664,7 +652,7 @@ export const FOREIGN_VERBS: Readonly<Record<string, string>> = {
|
|
|
664
652
|
'`nx run-many` is `vx run <task> --all` here; -t, -p, --exclude and --parallel work as they are',
|
|
665
653
|
affected:
|
|
666
654
|
'`nx affected` is `vx run <task> --affected` here; -t, --base and --exclude work as they are',
|
|
667
|
-
graph: '`nx graph` is `vx run <task> --
|
|
655
|
+
graph: '`nx graph` is `vx run <task> --dry=json` here: the planned task graph as JSON',
|
|
668
656
|
ls: '`turbo ls` is `vx show` here: every project, its directory and its task count',
|
|
669
657
|
query:
|
|
670
658
|
'`turbo query` has no vx form: `vx run <task> --dry=json` prints the planned graph as JSON',
|