@vzn/vx 0.0.636 → 0.0.637
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 +213 -207
- package/docs/cli.md +699 -655
- package/docs/execution.md +67 -75
- package/docs/features.md +44 -40
- package/docs/patterns.md +26 -25
- package/docs/schema.md +376 -198
- 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 +2 -2
- package/schemas/show.json +5 -3
- package/schemas/why.json +15 -1
- package/skills/vx/SKILL.md +16 -12
- package/src/bin.ts +17 -2
- package/src/cache/archive.ts +11 -0
- package/src/cache/cache.ts +105 -53
- package/src/cache/git-inputs.ts +277 -63
- package/src/cache/index.ts +7 -1
- package/src/cache/inputs.ts +125 -35
- package/src/cache/key-fold.ts +8 -7
- package/src/cache/layer.ts +8 -17
- package/src/cache/layered-cache.ts +16 -102
- package/src/cache/policy.ts +25 -29
- package/src/cache/run-history.ts +36 -14
- package/src/cache/schema.ts +21 -23
- package/src/cache/scoped-cache.ts +224 -0
- package/src/cli/cache.ts +150 -28
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +1 -1
- package/src/cli/foreign-flags.ts +13 -12
- package/src/cli/help.ts +94 -63
- package/src/cli/index.ts +42 -13
- package/src/cli/info.ts +38 -23
- package/src/cli/init.ts +115 -406
- package/src/cli/last.ts +145 -4
- package/src/cli/lock.ts +333 -111
- package/src/cli/plan-format.ts +2 -2
- package/src/cli/plugin-commands.ts +5 -9
- package/src/cli/plugin-templates.ts +10 -10
- package/src/cli/run.ts +103 -149
- package/src/cli/select.ts +33 -39
- package/src/cli/show.ts +17 -19
- package/src/cli/task-verb.ts +2 -9
- package/src/cli/upgrade.ts +402 -145
- package/src/cli/watch-cycle.ts +9 -2
- package/src/cli/watch-filter.ts +33 -60
- package/src/cli/watch-fs.ts +29 -16
- package/src/cli/watch-judge.ts +12 -3
- package/src/cli/watch-set.ts +16 -42
- package/src/cli/watch.ts +188 -215
- package/src/cli/why.ts +28 -17
- package/src/cli/workspace-config.ts +31 -68
- 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 +19 -7
- package/src/graph/task-graph.ts +44 -20
- package/src/handoff.ts +169 -0
- package/src/index.ts +24 -57
- package/src/orchestrator/affected-tasks.ts +7 -5
- 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 +158 -63
- package/src/orchestrator/fingerprint-watch.ts +18 -15
- package/src/orchestrator/forecast.ts +10 -2
- package/src/orchestrator/framed-output.ts +8 -3
- package/src/orchestrator/graph-stage.ts +5 -5
- package/src/orchestrator/history.ts +70 -30
- package/src/orchestrator/hit-restore.ts +15 -3
- package/src/orchestrator/index.ts +18 -4
- package/src/orchestrator/local-shortcircuit.ts +2 -1
- package/src/orchestrator/lockfile-claim.ts +79 -20
- package/src/orchestrator/logger.ts +71 -39
- package/src/orchestrator/metrics.ts +71 -6
- 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 +87 -63
- package/src/orchestrator/plugin.ts +94 -60
- package/src/orchestrator/prepare.ts +113 -72
- package/src/orchestrator/projects.ts +102 -99
- package/src/orchestrator/remote-prefetch.ts +2 -1
- 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-report.ts +9 -9
- package/src/orchestrator/run-settings.ts +48 -0
- package/src/orchestrator/run.ts +121 -95
- 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 +48 -6
- package/src/orchestrator/summary.ts +46 -62
- package/src/orchestrator/task-hash.ts +12 -83
- package/src/orchestrator/telemetry.ts +3 -3
- package/src/settings.ts +417 -0
- package/src/util/errors.ts +1 -1
- package/src/util/essential-env.ts +31 -0
- package/src/util/hide-env.ts +107 -0
- package/src/util/index.ts +8 -2
- package/src/util/paths.ts +10 -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/ulid.ts +1 -1
- package/src/util/verbs.ts +0 -15
- package/src/workspace/affected.ts +77 -222
- 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 +57 -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 +242 -146
- 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 +362 -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
package/src/cache/policy.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The run-level cache policy: four axes (local/remote × read/write) and
|
|
2
|
-
// the `--cache=<spec>` grammar that sets them. Pure data + parsing; no I/O.
|
|
2
|
+
// the `--cache-policy=<spec>` grammar that sets them. Pure data + parsing; no I/O.
|
|
3
3
|
|
|
4
4
|
import { UserError } from '../util/index.js'
|
|
5
5
|
|
|
@@ -10,8 +10,7 @@ import { UserError } from '../util/index.js'
|
|
|
10
10
|
* cache) is distinct from `--no-cache` (disable everything).
|
|
11
11
|
*
|
|
12
12
|
* The task-artifact get/save path is gated, and the local axes also gate
|
|
13
|
-
* the local store's
|
|
14
|
-
* writes (`cache.ts`). `recordRun`, `stats`, `prune`, key derivation, and
|
|
13
|
+
* the local store's file-hash writes (`cache.ts`). `recordRun`, `stats`, `prune`, key derivation, and
|
|
15
14
|
* prefetch-ingest are never affected — they are bookkeeping/analytics
|
|
16
15
|
* that a run policy has no business disabling.
|
|
17
16
|
*/
|
|
@@ -20,12 +19,6 @@ export interface CachePolicy {
|
|
|
20
19
|
localWrite: boolean
|
|
21
20
|
remoteRead: boolean
|
|
22
21
|
remoteWrite: boolean
|
|
23
|
-
/**
|
|
24
|
-
* An untrusted scope (`cacheScope: 'pr-123'`): remote reads try this
|
|
25
|
-
* scope's keys, then the trusted ones; remote writes go to this scope's
|
|
26
|
-
* keys only. Absent = trusted. See {@link scopeCachePolicy}.
|
|
27
|
-
*/
|
|
28
|
-
remoteScope?: string
|
|
29
22
|
}
|
|
30
23
|
|
|
31
24
|
/** All four axes on — the default when no cache flag is passed. */
|
|
@@ -36,13 +29,13 @@ export const FULL_CACHE_POLICY: CachePolicy = {
|
|
|
36
29
|
remoteWrite: true,
|
|
37
30
|
}
|
|
38
31
|
|
|
39
|
-
/** No axis on (`--no-cache`, `--cache=local:`): no cache answers for any task. */
|
|
32
|
+
/** No axis on (`--no-cache`, `--cache-policy=local:`): no cache answers for any task. */
|
|
40
33
|
export function cachesNothing(p: CachePolicy): boolean {
|
|
41
34
|
return !(p.localRead || p.localWrite || p.remoteRead || p.remoteWrite)
|
|
42
35
|
}
|
|
43
36
|
|
|
44
37
|
/**
|
|
45
|
-
* Parse a `--cache=<spec>` value into a `CachePolicy`, starting from a
|
|
38
|
+
* Parse a `--cache-policy=<spec>` value into a `CachePolicy`, starting from a
|
|
46
39
|
* base (defaults to {@link FULL_CACHE_POLICY}). The spec is a
|
|
47
40
|
* comma-separated list of `layer:flags` segments where `layer` is
|
|
48
41
|
* `local` or `remote` and `flags` is any subset of `r` (read) and `w`
|
|
@@ -53,7 +46,8 @@ export function cachesNothing(p: CachePolicy): boolean {
|
|
|
53
46
|
* So `local:rw,remote:r` = remote read-only, `remote:` = remote fully
|
|
54
47
|
* off, `local:r` = local read-only with remote untouched.
|
|
55
48
|
*
|
|
56
|
-
* Throws a {@link UserError}
|
|
49
|
+
* Throws a {@link UserError}, whose message the caller prefixes with the
|
|
50
|
+
* spec's source (`--cache-policy`, `VX_CACHE_POLICY`, `cache.policy`), on an unknown layer, an unknown flag, a
|
|
57
51
|
* duplicated flag, a missing colon, or a repeated layer.
|
|
58
52
|
*/
|
|
59
53
|
export function parseCachePolicy(spec: string, base: CachePolicy = FULL_CACHE_POLICY): CachePolicy {
|
|
@@ -64,24 +58,24 @@ export function parseCachePolicy(spec: string, base: CachePolicy = FULL_CACHE_PO
|
|
|
64
58
|
if (seg.length === 0) continue
|
|
65
59
|
const colon = seg.indexOf(':')
|
|
66
60
|
if (colon < 0) {
|
|
67
|
-
throw new UserError(`invalid
|
|
61
|
+
throw new UserError(`invalid segment '${seg}': expected '<layer>:<flags>'`)
|
|
68
62
|
}
|
|
69
63
|
const layer = seg.slice(0, colon)
|
|
70
64
|
const flags = seg.slice(colon + 1)
|
|
71
65
|
if (layer !== 'local' && layer !== 'remote') {
|
|
72
|
-
throw new UserError(`invalid
|
|
66
|
+
throw new UserError(`invalid layer '${layer}': expected 'local' or 'remote'`)
|
|
73
67
|
}
|
|
74
68
|
if (seen.has(layer)) {
|
|
75
|
-
throw new UserError(
|
|
69
|
+
throw new UserError(`layer '${layer}' specified twice`)
|
|
76
70
|
}
|
|
77
71
|
seen.add(layer)
|
|
78
72
|
const flagSet = new Set<string>()
|
|
79
73
|
for (const ch of flags) {
|
|
80
74
|
if (ch !== 'r' && ch !== 'w') {
|
|
81
|
-
throw new UserError(`invalid
|
|
75
|
+
throw new UserError(`invalid flag '${ch}' for '${layer}': expected 'r' and/or 'w'`)
|
|
82
76
|
}
|
|
83
77
|
if (flagSet.has(ch)) {
|
|
84
|
-
throw new UserError(
|
|
78
|
+
throw new UserError(`flag '${ch}' repeated for '${layer}'`)
|
|
85
79
|
}
|
|
86
80
|
flagSet.add(ch)
|
|
87
81
|
}
|
|
@@ -96,16 +90,18 @@ export function parseCachePolicy(spec: string, base: CachePolicy = FULL_CACHE_PO
|
|
|
96
90
|
return out
|
|
97
91
|
}
|
|
98
92
|
|
|
99
|
-
/**
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
export function
|
|
108
|
-
if (
|
|
109
|
-
|
|
110
|
-
|
|
93
|
+
/** `--no-cache` and `--force`, which still apply over whichever spec set the base. */
|
|
94
|
+
export interface CacheFlags {
|
|
95
|
+
/** `--no-cache`: every axis off. Beats `force`. */
|
|
96
|
+
noCache?: boolean
|
|
97
|
+
/** `--force`: both reads off, writes kept. */
|
|
98
|
+
force?: boolean
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function applyCacheFlags(policy: CachePolicy, flags: CacheFlags | undefined): CachePolicy {
|
|
102
|
+
if (flags?.noCache === true) {
|
|
103
|
+
return { localRead: false, localWrite: false, remoteRead: false, remoteWrite: false }
|
|
104
|
+
}
|
|
105
|
+
if (flags?.force === true) return { ...policy, localRead: false, remoteRead: false }
|
|
106
|
+
return policy
|
|
111
107
|
}
|
package/src/cache/run-history.ts
CHANGED
|
@@ -31,8 +31,13 @@ function insertRunsSql(n: number): string {
|
|
|
31
31
|
*/
|
|
32
32
|
const RUNS_PER_INSERT = 40
|
|
33
33
|
|
|
34
|
+
/** Rows per `task_durations` INSERT: 6 variables each, under 999. */
|
|
35
|
+
const DURATIONS_PER_INSERT = 160
|
|
36
|
+
|
|
37
|
+
/** A local run's `task_durations.source`: its branch, `''` when it had none. */
|
|
38
|
+
export const branchSource = (branch: string | null): string => `branch:${branch ?? ''}`
|
|
39
|
+
|
|
34
40
|
export class RunHistory {
|
|
35
|
-
private readonly insertRun: ReturnType<Database['prepare']>
|
|
36
41
|
private readonly insertInvocation: ReturnType<Database['prepare']>
|
|
37
42
|
|
|
38
43
|
constructor(
|
|
@@ -40,7 +45,6 @@ export class RunHistory {
|
|
|
40
45
|
/** The store's salted value digest (L-4). */
|
|
41
46
|
private readonly digestValue: (value: string) => string,
|
|
42
47
|
) {
|
|
43
|
-
this.insertRun = lazyStatement(this.db, insertRunsSql(1))
|
|
44
48
|
this.insertInvocation = lazyStatement(
|
|
45
49
|
this.db,
|
|
46
50
|
`
|
|
@@ -60,18 +64,34 @@ export class RunHistory {
|
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
recordRun(run: RunRecord): void {
|
|
63
|
-
this.
|
|
67
|
+
this.recordRuns([run])
|
|
64
68
|
}
|
|
65
69
|
|
|
66
70
|
recordRuns(runs: readonly RunRecord[]): void {
|
|
67
71
|
if (runs.length === 0) return
|
|
68
|
-
if (runs.length === 1) {
|
|
69
|
-
this.insertRun.run(...bindRun(runs[0]!, this.digestValue))
|
|
70
|
-
return
|
|
71
|
-
}
|
|
72
72
|
// One transaction, one fsync: for a 200-task run, one instead of 200.
|
|
73
73
|
// IMMEDIATE, as `recordRunBundle` explains.
|
|
74
|
-
this.db
|
|
74
|
+
this.db
|
|
75
|
+
.transaction(() => {
|
|
76
|
+
this.insertRuns(runs)
|
|
77
|
+
this.insertDurations(runs, branchSource(null), null)
|
|
78
|
+
})
|
|
79
|
+
.immediate()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One `task_durations` row per executed success: what a forecast reads. */
|
|
83
|
+
private insertDurations(runs: readonly RunRecord[], source: string, sha: string | null): void {
|
|
84
|
+
const done = runs.filter((r) => r.status === 'success' && r.cacheHit !== true)
|
|
85
|
+
for (let i = 0; i < done.length; i += DURATIONS_PER_INSERT) {
|
|
86
|
+
const block = done.slice(i, i + DURATIONS_PER_INSERT)
|
|
87
|
+
const params: SQLQueryBindings[] = []
|
|
88
|
+
for (const r of block) params.push(r.project, r.task, source, sha, r.durationMs, r.endedAt)
|
|
89
|
+
this.db
|
|
90
|
+
.query(
|
|
91
|
+
`INSERT INTO task_durations(project, task, source, sha, duration_ms, recorded_at) VALUES ${Array(block.length).fill('(?, ?, ?, ?, ?, ?)').join(', ')}`,
|
|
92
|
+
)
|
|
93
|
+
.run(...params)
|
|
94
|
+
}
|
|
75
95
|
}
|
|
76
96
|
|
|
77
97
|
/** `runs` as few INSERTs; each distinct forward-args list digested once. */
|
|
@@ -109,6 +129,11 @@ export class RunHistory {
|
|
|
109
129
|
.transaction(() => {
|
|
110
130
|
this.insertRuns(bundle.runs)
|
|
111
131
|
this.insertInvocation.run(...bindInvocation(bundle.invocation))
|
|
132
|
+
this.insertDurations(
|
|
133
|
+
bundle.runs,
|
|
134
|
+
branchSource(bundle.invocation.branch),
|
|
135
|
+
bundle.invocation.commitSha,
|
|
136
|
+
)
|
|
112
137
|
})
|
|
113
138
|
.immediate()
|
|
114
139
|
}
|
|
@@ -116,21 +141,18 @@ export class RunHistory {
|
|
|
116
141
|
/**
|
|
117
142
|
* Retention: the runs table grows by one row per executed task per
|
|
118
143
|
* invocation — a 2000-task repo accretes ~20k rows in days. 30 days
|
|
119
|
-
* covers `vx
|
|
144
|
+
* covers `vx info` (24 h windows) and CI-side consumers. The
|
|
120
145
|
* `invocations` header is pruned on the SAME window, or a header would
|
|
121
146
|
* outlive its rows and `vx last` would list a run whose detail is gone.
|
|
122
147
|
*/
|
|
123
148
|
pruneOlderThan(cutoff: number): void {
|
|
124
149
|
this.db.prepare('DELETE FROM runs WHERE started_at < ?').run(cutoff)
|
|
125
150
|
this.db.prepare('DELETE FROM invocations WHERE started_at < ?').run(cutoff)
|
|
151
|
+
this.db.prepare('DELETE FROM task_durations WHERE recorded_at < ?').run(cutoff)
|
|
126
152
|
}
|
|
127
153
|
}
|
|
128
154
|
|
|
129
|
-
/**
|
|
130
|
-
* Bind a RunRecord to the positional parameters expected by the
|
|
131
|
-
* `insertRun` prepared statement (22 columns). Shared between the
|
|
132
|
-
* single and batched record paths.
|
|
133
|
-
*/
|
|
155
|
+
/** Bind a RunRecord to one `RUN_TUPLE`'s 22 positional parameters. */
|
|
134
156
|
function bindRun(run: RunRecord, digestValue: (v: string) => string): SQLQueryBindings[] {
|
|
135
157
|
return [
|
|
136
158
|
// The ONE place the no-key sentinel is applied, so the column's
|
package/src/cache/schema.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The index's tables, one concern: what a cache.db holds. `Cache` opens the
|
|
2
2
|
// file, checks `SCHEMA_VERSION` and creates these; every statement against
|
|
3
|
-
// them lives in the store that owns the table (file-hashes,
|
|
4
|
-
//
|
|
3
|
+
// them lives in the store that owns the table (file-hashes, output-index,
|
|
4
|
+
// history) or in cache.ts for `entries`, `entry_inputs` and
|
|
5
5
|
// `artifacts`.
|
|
6
6
|
|
|
7
7
|
import type { Database, SQLQueryBindings } from 'bun:sqlite'
|
|
@@ -22,7 +22,8 @@ export function lazyStatement(db: Database, sql: string): ReturnType<Database['p
|
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* Create every table. `store` names the schema holding the ENTRY tables
|
|
25
|
-
* (`entries`, `entry_stdout`, `output_files`, `entry_inputs`, `store_meta`)
|
|
25
|
+
* (`entries`, `entry_stdout`, `output_files`, `entry_inputs`, `store_meta`)
|
|
26
|
+
* and the task timings (`task_durations`):
|
|
26
27
|
* `main` when one `cache.db` holds everything (a `cacheDir`), `store` when
|
|
27
28
|
* they live in the shared store attached beside it (v32). The rest is the
|
|
28
29
|
* workspace's own: its history, its memos, and what its disk looked like
|
|
@@ -30,17 +31,6 @@ export function lazyStatement(db: Database, sql: string): ReturnType<Database['p
|
|
|
30
31
|
* a statement; SQLite finds it in `main` or in `store`, whichever has it.
|
|
31
32
|
*/
|
|
32
33
|
export function createTables(db: Database, store: 'main' | 'store' = 'main'): void {
|
|
33
|
-
// Cached config evaluations (workspace/config-cache.ts): the validated
|
|
34
|
-
// config as JSON, keyed by everything the evaluation could observe. A
|
|
35
|
-
// separate exec so the artifact schema stays byte-identical.
|
|
36
|
-
db.exec(`
|
|
37
|
-
CREATE TABLE IF NOT EXISTS main.config_evals (
|
|
38
|
-
key TEXT PRIMARY KEY,
|
|
39
|
-
json TEXT NOT NULL,
|
|
40
|
-
created_at INTEGER NOT NULL
|
|
41
|
-
);
|
|
42
|
-
`)
|
|
43
|
-
|
|
44
34
|
db.exec(`
|
|
45
35
|
-- The queryable index: command, exit_code, duration, size and
|
|
46
36
|
-- timestamps; stdout in entry_stdout below.
|
|
@@ -197,15 +187,6 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
|
|
|
197
187
|
ctime_ms INTEGER NOT NULL,
|
|
198
188
|
PRIMARY KEY (entry_hash, path)
|
|
199
189
|
);
|
|
200
|
-
-- Each config's ORDERED import closure (the config first), so a warm
|
|
201
|
-
-- load keys it by stat-hashing the list (the file_hashes memo) instead
|
|
202
|
-
-- of reading and scanning every file. Machine-local; pruned with
|
|
203
|
-
-- config_evals (2026-09-03).
|
|
204
|
-
CREATE TABLE IF NOT EXISTS main.config_closures (
|
|
205
|
-
config_path TEXT PRIMARY KEY,
|
|
206
|
-
files_json TEXT NOT NULL,
|
|
207
|
-
created_at INTEGER NOT NULL
|
|
208
|
-
);
|
|
209
190
|
-- Every directory under a whole-subtree output glob, with its mtime
|
|
210
191
|
-- as of the last save/restore on THIS machine (2026-09-03). On a warm
|
|
211
192
|
-- hit, unchanged mtimes prove the output SET is unchanged, replacing
|
|
@@ -277,6 +258,23 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
|
|
|
277
258
|
key TEXT PRIMARY KEY,
|
|
278
259
|
value TEXT NOT NULL
|
|
279
260
|
);
|
|
261
|
+
-- What a duration forecast reads (orchestrator/history.ts), one row per
|
|
262
|
+
-- executed success, shared by every worktree of the repo so a new one
|
|
263
|
+
-- forecasts on its first run (owner, 2026-10-14). Append-only: never
|
|
264
|
+
-- updated, pruned by age with the run history. \`source\` tags a local
|
|
265
|
+
-- run 'branch:<name>' ('branch:' detached or outside git); \`sha\` is its
|
|
266
|
+
-- commit. A forecast reads a task's newest rows of one source.
|
|
267
|
+
CREATE TABLE IF NOT EXISTS ${store}.task_durations (
|
|
268
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
269
|
+
project TEXT NOT NULL,
|
|
270
|
+
task TEXT NOT NULL,
|
|
271
|
+
source TEXT NOT NULL,
|
|
272
|
+
sha TEXT,
|
|
273
|
+
duration_ms INTEGER NOT NULL,
|
|
274
|
+
recorded_at INTEGER NOT NULL
|
|
275
|
+
);
|
|
276
|
+
CREATE INDEX IF NOT EXISTS ${store}.task_durations_task
|
|
277
|
+
ON task_durations(project, task, source, id);
|
|
280
278
|
${artifactTables(store)}
|
|
281
279
|
`)
|
|
282
280
|
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// The run's `cache.scope` over every layer at once (owner, 2026-10-14): a
|
|
2
|
+
// scoped run reads the default key, then the scope's, and writes only the
|
|
3
|
+
// scope's, in the local store and every remote alike. Below this wrapper no
|
|
4
|
+
// layer knows a scope exists: the scope's key is a key like any other.
|
|
5
|
+
|
|
6
|
+
import { xxh3hex } from '../util/index.js'
|
|
7
|
+
import type {
|
|
8
|
+
CacheEntry,
|
|
9
|
+
CacheGetContext,
|
|
10
|
+
CacheLayer,
|
|
11
|
+
IngestMeta,
|
|
12
|
+
InvocationRecord,
|
|
13
|
+
OutputDirRow,
|
|
14
|
+
RunRecord,
|
|
15
|
+
SaveArgs,
|
|
16
|
+
} from './layer.js'
|
|
17
|
+
|
|
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 {
|
|
20
|
+
return xxh3hex(`vx-cache-scope\0${scope}\0${hash}`)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A layer as a scoped run sees it. Callers speak task keys; each answer is
|
|
25
|
+
* remembered under the key that served it (the default key on a default hit,
|
|
26
|
+
* the scope's on a scope hit or a save), so the restore, the output index and
|
|
27
|
+
* the run's history all reach the entry that answered. An artifact records
|
|
28
|
+
* the key it was packed under and ingest refuses another, so a derived-key
|
|
29
|
+
* collision degrades to a miss. A default artifact a remote ingest refuses is
|
|
30
|
+
* a miss there, and the scope's key is asked next (Security F6).
|
|
31
|
+
*/
|
|
32
|
+
export class ScopedCache implements CacheLayer {
|
|
33
|
+
readonly local?: CacheLayer['local']
|
|
34
|
+
readonly hasRemote?: boolean
|
|
35
|
+
readonly drainUploads?: () => Promise<void>
|
|
36
|
+
readonly remoteHasMany?: (hashes: readonly string[]) => Promise<Set<string> | null>
|
|
37
|
+
readonly getMany?: (
|
|
38
|
+
hashes: readonly string[],
|
|
39
|
+
ctx?: (hash: string) => CacheGetContext,
|
|
40
|
+
) => Promise<Map<string, CacheEntry>>
|
|
41
|
+
/** Task key → the key that answers for it this run. */
|
|
42
|
+
private readonly served = new Map<string, string>()
|
|
43
|
+
|
|
44
|
+
constructor(
|
|
45
|
+
private readonly inner: CacheLayer,
|
|
46
|
+
private readonly scope: string,
|
|
47
|
+
) {
|
|
48
|
+
if (inner.local !== undefined) this.local = inner.local
|
|
49
|
+
if (inner.hasRemote !== undefined) this.hasRemote = inner.hasRemote
|
|
50
|
+
if (inner.drainUploads !== undefined) this.drainUploads = () => inner.drainUploads!()
|
|
51
|
+
if (inner.remoteHasMany !== undefined) this.remoteHasMany = (h) => this.scopedHasMany(h)
|
|
52
|
+
if (inner.getMany !== undefined) this.getMany = (h, ctx) => this.scopedGetMany(h, ctx)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private own(hash: string): string {
|
|
56
|
+
return scopedKey(this.scope, hash)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private keyOf(hash: string): string {
|
|
60
|
+
return this.served.get(hash) ?? hash
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A batch keyed by served keys, keyed back by task key. */
|
|
64
|
+
private batch<T>(
|
|
65
|
+
hashes: readonly string[],
|
|
66
|
+
read: (keys: string[]) => Map<string, T>,
|
|
67
|
+
): Map<string, T> {
|
|
68
|
+
const keys = hashes.map((h) => this.keyOf(h))
|
|
69
|
+
const found = read(keys)
|
|
70
|
+
const out = new Map<string, T>()
|
|
71
|
+
for (let i = 0; i < hashes.length; i++) {
|
|
72
|
+
const v = found.get(keys[i]!)
|
|
73
|
+
if (v !== undefined) out.set(hashes[i]!, v)
|
|
74
|
+
}
|
|
75
|
+
return out
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private async scopedHasMany(hashes: readonly string[]): Promise<Set<string> | null> {
|
|
79
|
+
const scoped = hashes.map((h) => this.own(h))
|
|
80
|
+
const [plain, own] = await Promise.all([
|
|
81
|
+
this.inner.remoteHasMany!(hashes),
|
|
82
|
+
this.inner.remoteHasMany!(scoped),
|
|
83
|
+
])
|
|
84
|
+
if (plain == null || own == null) return null
|
|
85
|
+
const found = new Set(plain)
|
|
86
|
+
const scopeOnly: string[] = []
|
|
87
|
+
for (let i = 0; i < hashes.length; i++) {
|
|
88
|
+
if (plain.has(hashes[i]!) || !own.has(scoped[i]!)) continue
|
|
89
|
+
found.add(hashes[i]!)
|
|
90
|
+
scopeOnly.push(hashes[i]!)
|
|
91
|
+
}
|
|
92
|
+
// Their default key is known absent: its GET is skipped.
|
|
93
|
+
this.inner.markRemoteAbsent?.(scopeOnly)
|
|
94
|
+
return found
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
markRemoteAbsent(hashes: Iterable<string>): void {
|
|
98
|
+
const all: string[] = []
|
|
99
|
+
for (const h of hashes) all.push(h, this.own(h))
|
|
100
|
+
this.inner.markRemoteAbsent?.(all)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
key(input: Parameters<CacheLayer['key']>[0]): Promise<string> {
|
|
104
|
+
return this.inner.key(input)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async get(hash: string, ctx?: CacheGetContext): Promise<CacheEntry | null> {
|
|
108
|
+
for (const key of [hash, this.own(hash)]) {
|
|
109
|
+
const entry = await this.inner.get(key, ctx)
|
|
110
|
+
if (entry === null) continue
|
|
111
|
+
this.served.set(hash, key)
|
|
112
|
+
return key === hash ? entry : { ...entry, hash }
|
|
113
|
+
}
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private async scopedGetMany(
|
|
118
|
+
hashes: readonly string[],
|
|
119
|
+
ctx?: (hash: string) => CacheGetContext,
|
|
120
|
+
): Promise<Map<string, CacheEntry>> {
|
|
121
|
+
const out = await this.inner.getMany!(hashes, ctx)
|
|
122
|
+
for (const h of out.keys()) this.served.set(h, h)
|
|
123
|
+
const rest = hashes.filter((h) => !out.has(h))
|
|
124
|
+
if (rest.length === 0) return out
|
|
125
|
+
const byOwn = new Map(rest.map((h) => [this.own(h), h]))
|
|
126
|
+
const scoped = await this.inner.getMany!(
|
|
127
|
+
[...byOwn.keys()],
|
|
128
|
+
ctx === undefined ? undefined : (key) => ctx(byOwn.get(key)!),
|
|
129
|
+
)
|
|
130
|
+
for (const [key, entry] of scoped) {
|
|
131
|
+
const hash = byOwn.get(key)!
|
|
132
|
+
this.served.set(hash, key)
|
|
133
|
+
out.set(hash, { ...entry, hash })
|
|
134
|
+
}
|
|
135
|
+
return out
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async has(hash: string): Promise<'local' | 'remote' | null> {
|
|
139
|
+
return (await this.inner.has(hash)) ?? (await this.inner.has(this.own(hash)))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async prefetch(hash: string, ctx?: CacheGetContext): Promise<boolean> {
|
|
143
|
+
return (await this.inner.prefetch(hash, ctx)) || this.inner.prefetch(this.own(hash), ctx)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
loadOutputFilesBatch(hashes: readonly string[]): ReturnType<CacheLayer['loadOutputFilesBatch']> {
|
|
147
|
+
return this.batch(hashes, (keys) => this.inner.loadOutputFilesBatch(keys))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
isOutputsCurrent(
|
|
151
|
+
...args: Parameters<CacheLayer['isOutputsCurrent']>
|
|
152
|
+
): ReturnType<CacheLayer['isOutputsCurrent']> {
|
|
153
|
+
return this.inner.isOutputsCurrent(...args)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async recordOutputDirs(
|
|
157
|
+
hash: string,
|
|
158
|
+
projectDir: string,
|
|
159
|
+
prefixes: readonly string[],
|
|
160
|
+
holds?: (files: readonly string[]) => boolean,
|
|
161
|
+
): Promise<void> {
|
|
162
|
+
await this.inner.recordOutputDirs?.(this.keyOf(hash), projectDir, prefixes, holds)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
recordOutputStamps(hash: string, projectDir: string, workspaceRoot: string): void {
|
|
166
|
+
this.inner.recordOutputStamps?.(this.keyOf(hash), projectDir, workspaceRoot)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
loadOutputDirsBatch(hashes: readonly string[]): Map<string, OutputDirRow[]> {
|
|
170
|
+
return this.batch(hashes, (keys) => this.inner.loadOutputDirsBatch?.(keys) ?? new Map())
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async outputDirsCurrent(projectDir: string, rows: readonly OutputDirRow[]): Promise<boolean> {
|
|
174
|
+
return (await this.inner.outputDirsCurrent?.(projectDir, rows)) ?? false
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
restoreOutputs(hash: string, projectDir: string, workspaceRoot?: string): Promise<void> {
|
|
178
|
+
return this.inner.restoreOutputs(this.keyOf(hash), projectDir, workspaceRoot)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
save(args: SaveArgs): Promise<void> {
|
|
182
|
+
const key = this.own(args.hash)
|
|
183
|
+
this.served.set(args.hash, key)
|
|
184
|
+
return this.inner.save({ ...args, hash: key })
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
ingest(hash: string, body: Blob | Response, meta: IngestMeta): Promise<void> {
|
|
188
|
+
const key = this.own(hash)
|
|
189
|
+
this.served.set(hash, key)
|
|
190
|
+
return this.inner.ingest(key, body, meta)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** History names the key that answered, so `vx why` reads that entry's components. */
|
|
194
|
+
recordRunBundle(bundle: { runs: readonly RunRecord[]; invocation: InvocationRecord }): void {
|
|
195
|
+
this.inner.recordRunBundle({
|
|
196
|
+
...bundle,
|
|
197
|
+
runs: bundle.runs.map((r) => (r.hash === undefined ? r : { ...r, hash: this.keyOf(r.hash) })),
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
stats(...args: Parameters<CacheLayer['stats']>): ReturnType<CacheLayer['stats']> {
|
|
202
|
+
return this.inner.stats(...args)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
hashFile(filePath: string): Promise<string> {
|
|
206
|
+
return this.inner.hashFile(filePath)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
outputsPath(hash: string): string {
|
|
210
|
+
return this.inner.outputsPath(this.keyOf(hash))
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
artifactSize(hash: string): number | undefined {
|
|
214
|
+
return this.inner.artifactSize?.(this.keyOf(hash))
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
prune(...args: Parameters<CacheLayer['prune']>): ReturnType<CacheLayer['prune']> {
|
|
218
|
+
return this.inner.prune(...args)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
close(): void {
|
|
222
|
+
this.inner.close()
|
|
223
|
+
}
|
|
224
|
+
}
|