@vzn/vx 0.0.641 → 0.0.643
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/package.json +5 -12
- package/skills/vx/SKILL.md +4 -4
- package/src/cache/archive.ts +525 -104
- package/src/cache/cache.ts +85 -11
- package/src/cache/fold-memo.ts +58 -0
- package/src/cache/git-inputs.ts +213 -31
- package/src/cache/index.ts +12 -3
- package/src/cache/inputs.ts +169 -79
- package/src/cache/key-fold.ts +90 -69
- package/src/cache/layer.ts +4 -6
- package/src/cache/layered-cache.ts +96 -10
- package/src/cache/output-index.ts +25 -4
- package/src/cache/plugin-cache.ts +23 -16
- package/src/cache/remote-lane.ts +25 -8
- package/src/cache/run-store.ts +4 -1
- package/src/cache/schema.ts +13 -3
- package/src/cache/scoped-cache.ts +74 -237
- package/src/cache/umask.ts +29 -0
- package/src/cli/docs.ts +71 -7
- package/src/cli/help.ts +6 -8
- package/src/cli/plan-format.ts +4 -1
- package/src/cli/plugin-commands.ts +11 -6
- package/src/cli/run.ts +14 -15
- package/src/cli/select.ts +9 -9
- package/src/cli/workspace-config.ts +8 -2
- package/src/config.ts +7 -16
- package/src/exec/runner.ts +5 -0
- package/src/exec/sandbox-runtime.ts +81 -1
- package/src/graph/priorities.ts +14 -1
- package/src/history/history.ts +43 -33
- package/src/index.ts +14 -0
- package/src/orchestrator/affected-tasks.ts +23 -2
- package/src/orchestrator/deferred-outputs.ts +3 -2
- package/src/orchestrator/execute-task.ts +20 -14
- package/src/orchestrator/hit-restore.ts +10 -2
- package/src/orchestrator/index.ts +5 -0
- package/src/orchestrator/lockfile-claim.ts +54 -8
- package/src/orchestrator/miss-save.ts +27 -17
- package/src/orchestrator/options.ts +9 -1
- package/src/orchestrator/output-host.ts +9 -1
- package/src/orchestrator/plan.ts +4 -0
- package/src/orchestrator/plugin-host.ts +38 -4
- package/src/orchestrator/plugin.ts +49 -5
- package/src/orchestrator/prepare.ts +141 -22
- package/src/orchestrator/projects.ts +25 -6
- package/src/orchestrator/run-context.ts +80 -0
- package/src/orchestrator/run.ts +38 -15
- package/src/orchestrator/sandbox-request.ts +1 -48
- package/src/orchestrator/save-lane.ts +17 -10
- package/src/orchestrator/signals.ts +7 -10
- package/src/orchestrator/stable-keys.ts +83 -25
- package/src/orchestrator/task-hash.ts +52 -12
- package/src/report/events.ts +12 -0
- package/src/report/framed-output.ts +2 -1
- package/src/report/logger.ts +53 -13
- package/src/report/status-line.ts +45 -4
- package/src/report/summary.ts +8 -2
- package/src/settings.ts +11 -11
- package/src/util/errors.ts +1 -1
- package/src/util/hidden-prompt.ts +74 -0
- package/src/util/index.ts +12 -2
- package/src/util/paths.ts +33 -14
- package/src/util/secrets.ts +150 -0
- package/src/workspace/affected.ts +182 -75
- package/src/workspace/config-schema.ts +81 -19
- package/src/workspace/index.ts +3 -0
- package/src/workspace/project-loader.ts +1 -0
- package/src/workspace/workspace.ts +17 -11
- package/docs/caching.md +0 -2376
- package/docs/cli.md +0 -3285
- package/docs/execution.md +0 -676
- package/docs/features.md +0 -178
- package/docs/patterns.md +0 -219
- package/docs/schema.md +0 -2468
- package/docs/security.md +0 -253
- package/src/cli/docs-corpus.ts +0 -21
- package/src/cli/docs-text.d.ts +0 -5
package/src/cache/layer.ts
CHANGED
|
@@ -576,6 +576,8 @@ export interface CacheLayer {
|
|
|
576
576
|
* `hasRemote`.
|
|
577
577
|
*/
|
|
578
578
|
probeBeforeUpload?(hashes: Promise<readonly string[]>): void
|
|
579
|
+
/** `taskId`'s key is `hash`: a scoped run picks each remote key by its task (`BranchScope`). */
|
|
580
|
+
noteKey?(taskId: string, hash: string): void
|
|
579
581
|
/**
|
|
580
582
|
* Resolve once every background write-through upload settles. The
|
|
581
583
|
* orchestrator awaits it before `close()` — an upload reading layer state
|
|
@@ -698,6 +700,8 @@ export interface CacheLayer {
|
|
|
698
700
|
entry: Omit<CacheEntry, 'hash' | 'storedAt' | 'outputFiles' | 'exitCode'>
|
|
699
701
|
projectDir: string
|
|
700
702
|
outputFiles: string[]
|
|
703
|
+
/** The empty directories under the outputs (absolute), kept so a hit makes them. */
|
|
704
|
+
outputDirs?: string[]
|
|
701
705
|
/**
|
|
702
706
|
* Resolved `outputs.workspaceFiles` (absolute paths) + the root
|
|
703
707
|
* they're relative to. Packed under `workspace-outputs/<rel>`.
|
|
@@ -714,12 +718,6 @@ export interface CacheLayer {
|
|
|
714
718
|
* Only computed on the miss/save path — never on a hit.
|
|
715
719
|
*/
|
|
716
720
|
inputComponents?: readonly TaskInputRow[]
|
|
717
|
-
/**
|
|
718
|
-
* For a sandboxed task: whether it could read a canonical path. A
|
|
719
|
-
* symlinked output is packed as its target's bytes, outside the
|
|
720
|
-
* sandbox, so one whose target the task could not read is refused.
|
|
721
|
-
*/
|
|
722
|
-
readable?: ((real: string) => boolean) | undefined
|
|
723
721
|
}): Promise<void>
|
|
724
722
|
/**
|
|
725
723
|
* Adopt an artifact produced elsewhere — the remote-hit path. Streams
|
|
@@ -36,6 +36,55 @@ import type {
|
|
|
36
36
|
} from './cache.js'
|
|
37
37
|
import type { OutputDirRow } from './cache.js'
|
|
38
38
|
import { RemoteLane, type ResolvedRemote } from './remote-lane.js'
|
|
39
|
+
import type { BranchScope } from './scoped-cache.js'
|
|
40
|
+
import type { PluginSecrets } from '../util/index.js'
|
|
41
|
+
|
|
42
|
+
const STOPPED: unique symbol = Symbol('stopped')
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A response body that errors once the run stops, so the ingest reading it
|
|
46
|
+
* fails at once, removes its temp, and releases the connection; the cache
|
|
47
|
+
* then closes under no write.
|
|
48
|
+
*/
|
|
49
|
+
function endsOnStop(body: Blob | Response, stopped: Promise<typeof STOPPED>): Blob | Response {
|
|
50
|
+
if (body instanceof Blob || body.body === null) return body
|
|
51
|
+
const reader = body.body.getReader()
|
|
52
|
+
let stop = false
|
|
53
|
+
// Once, not raced per chunk: a cancel ends the pending read as `done`,
|
|
54
|
+
// which the flag turns into an error rather than a short body.
|
|
55
|
+
void stopped.then(() => {
|
|
56
|
+
stop = true
|
|
57
|
+
void reader.cancel().catch(() => undefined)
|
|
58
|
+
})
|
|
59
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
60
|
+
async pull(controller) {
|
|
61
|
+
const next = await reader.read()
|
|
62
|
+
if (stop) controller.error(new Error('the run was stopped'))
|
|
63
|
+
else if (next.done) controller.close()
|
|
64
|
+
else controller.enqueue(next.value)
|
|
65
|
+
},
|
|
66
|
+
cancel: (reason) => reader.cancel(reason),
|
|
67
|
+
})
|
|
68
|
+
return new Response(stream, { headers: body.headers })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** What a remote's `open` is handed: the run's CI verdict and its plugin's keychain. */
|
|
72
|
+
export interface RemoteOpenContext extends PluginSecrets {
|
|
73
|
+
/** The run is on CI: nobody may be asked. */
|
|
74
|
+
readonly ci: boolean
|
|
75
|
+
/** One line naming the plugin, for a fact that leaves the remote on. */
|
|
76
|
+
warn(message: string): void
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Where a remote call's key lives: `scope` names the branch scope a scoped
|
|
81
|
+
* key was derived in (`scopedKey`), absent for a task's own key. The key
|
|
82
|
+
* alone is enough; a backend that keeps one store per scope may use it to
|
|
83
|
+
* go straight to that store. turboCache(), nxCache() and vx-reapi ignore it.
|
|
84
|
+
*/
|
|
85
|
+
export interface RemoteKey {
|
|
86
|
+
readonly scope?: string
|
|
87
|
+
}
|
|
39
88
|
|
|
40
89
|
/**
|
|
41
90
|
* What a remote cache layer must provide — THE plugin seam for remote
|
|
@@ -62,17 +111,28 @@ export interface RemoteCacheLayer {
|
|
|
62
111
|
* dropped before it is printed.
|
|
63
112
|
*/
|
|
64
113
|
readonly endpoint?: string
|
|
114
|
+
/**
|
|
115
|
+
* Optional: make the layer ready (read a credential, ask for one).
|
|
116
|
+
* Called once per run, in declared order, before the status line and the
|
|
117
|
+
* first task, and only when the run's policy lets this remote read or
|
|
118
|
+
* write: a `--cache-policy=remote:` run, `--no-cache` and a plan
|
|
119
|
+
* (`--dry`, `vx show`) never call it, so the other methods may be asked
|
|
120
|
+
* without it. A throw is a warning naming the plugin and turns the
|
|
121
|
+
* remote off for the run; the run goes on. `ctx.secret` reads the
|
|
122
|
+
* plugin's keychain and may ask on the terminal.
|
|
123
|
+
*/
|
|
124
|
+
open?(ctx: RemoteOpenContext): Promise<void>
|
|
65
125
|
/** Existence probe: `--dry` and `--force` ask it per key when the remote cannot batch. */
|
|
66
|
-
has(hash: string): Promise<boolean>
|
|
126
|
+
has(hash: string, at?: RemoteKey): Promise<boolean>
|
|
67
127
|
/**
|
|
68
128
|
* Optional batch existence probe: given N hashes, return the subset stored
|
|
69
129
|
* remotely in ONE round-trip. Its presence says the wire can batch: a run
|
|
70
130
|
* asks it once for its keys, `--dry` before its per-key answers, and
|
|
71
131
|
* `--force` in the background to skip uploads of held keys. Without it a
|
|
72
132
|
* run GETs each key directly (no `has`). Never throws for control flow:
|
|
73
|
-
* `null` means "no batch info; use per-hash".
|
|
133
|
+
* `null` means "no batch info; use per-hash". `at[i]` is `hashes[i]`'s.
|
|
74
134
|
*/
|
|
75
|
-
hasMany?(hashes: readonly string[]): Promise<Set<string> | null>
|
|
135
|
+
hasMany?(hashes: readonly string[], at?: readonly RemoteKey[]): Promise<Set<string> | null>
|
|
76
136
|
/**
|
|
77
137
|
* Fetch an artifact; `null` = miss. `body` is read once, by core,
|
|
78
138
|
* straight into the local cache — an HTTP wire returns its `fetch`
|
|
@@ -82,7 +142,10 @@ export interface RemoteCacheLayer {
|
|
|
82
142
|
* nothing and the read is a miss, so a layer can check bytes as they pass
|
|
83
143
|
* (a digest, a signature) and refuse at the end.
|
|
84
144
|
*/
|
|
85
|
-
get(
|
|
145
|
+
get(
|
|
146
|
+
hash: string,
|
|
147
|
+
at?: RemoteKey,
|
|
148
|
+
): Promise<{ body: Blob | Response; durationMs: number | undefined } | null>
|
|
86
149
|
/**
|
|
87
150
|
* Store an artifact (fire-and-forget from LayeredCache's perspective).
|
|
88
151
|
* `body` is file-backed (`Bun.file`) when the local store holds the
|
|
@@ -91,7 +154,7 @@ export interface RemoteCacheLayer {
|
|
|
91
154
|
* key the remote holds keeps the stored bytes. Core never asks `has`
|
|
92
155
|
* first; a wire that would overwrite checks inside.
|
|
93
156
|
*/
|
|
94
|
-
put(hash: string, body: Blob, meta: { durationMs: number }): Promise<void>
|
|
157
|
+
put(hash: string, body: Blob, meta: { durationMs: number } & RemoteKey): Promise<void>
|
|
95
158
|
}
|
|
96
159
|
|
|
97
160
|
export class LayeredCache implements CacheLayer {
|
|
@@ -99,7 +162,6 @@ export class LayeredCache implements CacheLayer {
|
|
|
99
162
|
readonly hasRemote = true
|
|
100
163
|
|
|
101
164
|
private readonly lanes: readonly RemoteLane[]
|
|
102
|
-
private readonly readable: readonly RemoteLane[]
|
|
103
165
|
|
|
104
166
|
/**
|
|
105
167
|
* In-flight remote pulls keyed by hash. `prefetch` and `get` both go
|
|
@@ -128,9 +190,21 @@ export class LayeredCache implements CacheLayer {
|
|
|
128
190
|
constructor(
|
|
129
191
|
readonly local: Cache,
|
|
130
192
|
remotes: readonly ResolvedRemote[],
|
|
193
|
+
/** The run's branch scope: it picks each remote key; the local store never sees it. */
|
|
194
|
+
private readonly branch?: BranchScope,
|
|
131
195
|
) {
|
|
132
|
-
this.lanes = remotes.map(
|
|
133
|
-
|
|
196
|
+
this.lanes = remotes.map(
|
|
197
|
+
(r) => new RemoteLane(r, local.traffic, branch?.remote(r.remote) ?? r.remote),
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
noteKey(taskId: string, hash: string): void {
|
|
202
|
+
this.branch?.noteKey(taskId, hash)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Asked, not kept: a remote whose `open` failed leaves the run after this is built.
|
|
206
|
+
private get readable(): RemoteLane[] {
|
|
207
|
+
return this.lanes.filter((l) => l.read)
|
|
134
208
|
}
|
|
135
209
|
|
|
136
210
|
key(input: CacheKeyInput): Promise<string> {
|
|
@@ -280,7 +354,17 @@ export class LayeredCache implements CacheLayer {
|
|
|
280
354
|
|
|
281
355
|
private async pullFrom(lane: RemoteLane, hash: string, ctx?: CacheGetContext): Promise<boolean> {
|
|
282
356
|
const fetchStart = performance.now()
|
|
283
|
-
|
|
357
|
+
// A stopped run waits on no read, as on no upload: a lookup or download
|
|
358
|
+
// in flight held a Ctrl-C until its last byte (M, 2026-10-17). The
|
|
359
|
+
// plugin's request is left to the exit, and its body is cancelled.
|
|
360
|
+
const stopped = lane.whenStopped.then((): typeof STOPPED => STOPPED)
|
|
361
|
+
const pending = lane.get(hash)
|
|
362
|
+
const remoteBody = await Promise.race([pending, stopped])
|
|
363
|
+
if (remoteBody === STOPPED) {
|
|
364
|
+
// A late answer's body would hold its connection to the exit.
|
|
365
|
+
void pending.then((r) => (r?.body instanceof Response ? r.body.body?.cancel() : undefined))
|
|
366
|
+
return false
|
|
367
|
+
}
|
|
284
368
|
if (remoteBody === null) return false
|
|
285
369
|
|
|
286
370
|
// Ingest the remote body into local using the caller-supplied
|
|
@@ -301,7 +385,9 @@ export class LayeredCache implements CacheLayer {
|
|
|
301
385
|
...(ctx?.outputs !== undefined ? { outputs: ctx.outputs } : {}),
|
|
302
386
|
}
|
|
303
387
|
try {
|
|
304
|
-
|
|
388
|
+
const ingest = this.local.ingest(hash, endsOnStop(remoteBody.body, stopped), meta)
|
|
389
|
+
ingest.catch(() => undefined)
|
|
390
|
+
if ((await Promise.race([ingest, stopped])) === STOPPED) return false
|
|
305
391
|
} catch (err) {
|
|
306
392
|
// The bytes came off the network — a corrupt/truncated remote
|
|
307
393
|
// artifact must degrade to a cache miss (task re-executes), not
|
|
@@ -17,7 +17,8 @@ const FILE_PREFIX_MTIME = -2
|
|
|
17
17
|
|
|
18
18
|
import type { Database } from 'bun:sqlite'
|
|
19
19
|
import { inHashes, lazyStatement } from './schema.js'
|
|
20
|
-
import {
|
|
20
|
+
import { modeMatches } from './umask.js'
|
|
21
|
+
import { lstatSync, readlinkSync, statSync } from 'node:fs'
|
|
21
22
|
import { lstat, readdir } from 'node:fs/promises'
|
|
22
23
|
import path from 'node:path'
|
|
23
24
|
import {
|
|
@@ -30,6 +31,19 @@ import {
|
|
|
30
31
|
WORKSPACE_OUTPUT_PREFIX,
|
|
31
32
|
} from './layer.js'
|
|
32
33
|
|
|
34
|
+
/**
|
|
35
|
+
* The mode a symlink output's row carries (S_IFLNK): its size is the
|
|
36
|
+
* target's byte length and its mtime the target's CRC-32.
|
|
37
|
+
*/
|
|
38
|
+
export const LINK_MODE = 0o120000
|
|
39
|
+
|
|
40
|
+
/** A link row is current while a link with that target stands there; never followed. */
|
|
41
|
+
function linkCurrent(abs: string, e: { size: number; mtimeMs: number }): boolean {
|
|
42
|
+
if (!lstatSync(abs).isSymbolicLink()) return false
|
|
43
|
+
const to = readlinkSync(abs, 'buffer')
|
|
44
|
+
return to.byteLength === e.size && Bun.hash.crc32(to) === e.mtimeMs
|
|
45
|
+
}
|
|
46
|
+
|
|
33
47
|
export class OutputIndex {
|
|
34
48
|
private readonly insertOutputFile: ReturnType<Database['prepare']>
|
|
35
49
|
private readonly deleteOutputFiles: ReturnType<Database['prepare']>
|
|
@@ -206,10 +220,13 @@ export class OutputIndex {
|
|
|
206
220
|
// graph stage 100 → 54 ms, the whole process 422 → 368 ms.
|
|
207
221
|
const results = expected.map((e) => {
|
|
208
222
|
try {
|
|
223
|
+
if (e.mode === LINK_MODE) return linkCurrent(path.join(projectDir, e.path), e)
|
|
209
224
|
const s = statSync(path.join(projectDir, e.path))
|
|
225
|
+
// An empty output directory: current while a directory stands there.
|
|
226
|
+
if (e.path.endsWith('/')) return s.isDirectory()
|
|
210
227
|
return (
|
|
211
228
|
s.size === e.size &&
|
|
212
|
-
(s.mode
|
|
229
|
+
modeMatches(s.mode, e.mode) &&
|
|
213
230
|
// MILLISECOND comparison (sub-ms tolerance for the float
|
|
214
231
|
// round-trip through utimes). Save rows carry stat-ms and
|
|
215
232
|
// restoreOutputs re-syncs restored files to the row value,
|
|
@@ -324,6 +341,7 @@ export class OutputIndex {
|
|
|
324
341
|
const rows = this.rowsOf(hash)
|
|
325
342
|
const stamps: Array<[string, number, number]> = []
|
|
326
343
|
for (const e of rows) {
|
|
344
|
+
if (e.path.endsWith('/') || e.mode === LINK_MODE) continue
|
|
327
345
|
const ws = e.path.startsWith(WORKSPACE_OUTPUT_PREFIX)
|
|
328
346
|
const abs = ws
|
|
329
347
|
? path.join(workspaceRoot, e.path.slice(WORKSPACE_OUTPUT_PREFIX.length))
|
|
@@ -332,7 +350,7 @@ export class OutputIndex {
|
|
|
332
350
|
const s = statSync(abs)
|
|
333
351
|
if (
|
|
334
352
|
s.size === e.size &&
|
|
335
|
-
(s.mode
|
|
353
|
+
modeMatches(s.mode, e.mode) &&
|
|
336
354
|
Math.abs(s.mtimeMs - e.mtimeMs) < 1
|
|
337
355
|
) {
|
|
338
356
|
stamps.push([e.path, s.ino, Math.floor(s.ctimeMs)])
|
|
@@ -415,10 +433,13 @@ export class OutputIndex {
|
|
|
415
433
|
const results = rows.map((r) => {
|
|
416
434
|
let st
|
|
417
435
|
try {
|
|
418
|
-
|
|
436
|
+
// No throw for a missing dir: every restore after a clean asks
|
|
437
|
+
// about one, and an exception per hit cost more than the stat.
|
|
438
|
+
st = lstatSync(path.join(projectDir, r.path), { throwIfNoEntry: false })
|
|
419
439
|
} catch {
|
|
420
440
|
return r.mtimeMs === ABSENT_DIR_MTIME
|
|
421
441
|
}
|
|
442
|
+
if (st === undefined) return r.mtimeMs === ABSENT_DIR_MTIME
|
|
422
443
|
if (r.mtimeMs === ABSENT_DIR_MTIME) return false
|
|
423
444
|
if (r.mtimeMs === FILE_PREFIX_MTIME) return st.isFile()
|
|
424
445
|
return st.isDirectory() && Math.abs(st.mtimeMs - r.mtimeMs) < 1
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type { Database } from 'bun:sqlite'
|
|
10
10
|
import { xxh3hex } from '../util/index.js'
|
|
11
|
+
import type { RemoteKey } from './layered-cache.js'
|
|
11
12
|
import type { CachePolicy } from './policy.js'
|
|
12
13
|
import type { ResolvedRemote } from './remote-lane.js'
|
|
13
14
|
import { scopedKey } from './scoped-cache.js'
|
|
@@ -85,13 +86,17 @@ async function capped(body: unknown, max = PLUGIN_ENTRY_MAX_BYTES): Promise<Uint
|
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
/**
|
|
88
|
-
* `plugin`'s view of the cache.
|
|
89
|
-
*
|
|
89
|
+
* `plugin`'s view of the cache. The local store never knows a scope
|
|
90
|
+
* (owner, 2026-10-17): it keeps every entry under its name's key. A scoped
|
|
91
|
+
* run reads a remote under the default key, then the scope's, and writes
|
|
92
|
+
* a remote only under the scope's.
|
|
90
93
|
*/
|
|
91
94
|
export function pluginCache(plugin: string, host: PluginCacheHost): PluginCache {
|
|
92
95
|
const { db, policy, scope } = host
|
|
93
96
|
const readers = host.remotes.filter((r) => r.read)
|
|
94
97
|
const writers = host.remotes.filter((r) => r.write)
|
|
98
|
+
const at: RemoteKey = scope === undefined ? {} : { scope }
|
|
99
|
+
const atOf = (key: string, name: string): RemoteKey => (key === entryKey(plugin, name) ? {} : at)
|
|
95
100
|
const reads = (name: string): string[] => {
|
|
96
101
|
const key = entryKey(plugin, name)
|
|
97
102
|
return scope === undefined ? [key] : [key, scopedKey(scope, key)]
|
|
@@ -139,19 +144,18 @@ export function pluginCache(plugin: string, host: PluginCacheHost): PluginCache
|
|
|
139
144
|
|
|
140
145
|
return {
|
|
141
146
|
async get(name) {
|
|
147
|
+
const local = entryKey(plugin, name)
|
|
148
|
+
const hit = localGet(local)
|
|
149
|
+
if (hit !== null) return hit
|
|
142
150
|
const keys = reads(name)
|
|
143
|
-
for (const key of keys) {
|
|
144
|
-
const hit = localGet(key)
|
|
145
|
-
if (hit !== null) return hit
|
|
146
|
-
}
|
|
147
151
|
for (const { remote } of readers) {
|
|
148
152
|
for (const key of keys) {
|
|
149
153
|
try {
|
|
150
|
-
const got = await remote.get(key)
|
|
154
|
+
const got = await remote.get(key, atOf(key, name))
|
|
151
155
|
if (got === null) continue
|
|
152
156
|
const bytes = await capped(got.body)
|
|
153
157
|
if (bytes === null) continue
|
|
154
|
-
if (policy.localWrite) localPut(
|
|
158
|
+
if (policy.localWrite) localPut(local, bytes)
|
|
155
159
|
return bytes
|
|
156
160
|
} catch {
|
|
157
161
|
// a failing remote is a miss there
|
|
@@ -164,10 +168,10 @@ export function pluginCache(plugin: string, host: PluginCacheHost): PluginCache
|
|
|
164
168
|
async put(name, bytes) {
|
|
165
169
|
if (bytes.byteLength === 0 || bytes.byteLength > PLUGIN_ENTRY_MAX_BYTES) return
|
|
166
170
|
const key = writeKey(name)
|
|
167
|
-
if (policy.localWrite) localPut(
|
|
171
|
+
if (policy.localWrite) localPut(entryKey(plugin, name), bytes)
|
|
168
172
|
for (const { remote } of writers) {
|
|
169
173
|
try {
|
|
170
|
-
await remote.put(key, new Blob([bytes]), { durationMs: 0 })
|
|
174
|
+
await remote.put(key, new Blob([bytes]), { durationMs: 0, ...at })
|
|
171
175
|
} catch {
|
|
172
176
|
// the other layers still keep it
|
|
173
177
|
}
|
|
@@ -175,13 +179,13 @@ export function pluginCache(plugin: string, host: PluginCacheHost): PluginCache
|
|
|
175
179
|
},
|
|
176
180
|
|
|
177
181
|
async has(name) {
|
|
182
|
+
if (localHas([entryKey(plugin, name)]).size > 0) return true
|
|
178
183
|
const keys = reads(name)
|
|
179
|
-
if (localHas(keys).size > 0) return true
|
|
180
184
|
let answered = policy.localRead
|
|
181
185
|
for (const { remote } of readers) {
|
|
182
186
|
for (const key of keys) {
|
|
183
187
|
try {
|
|
184
|
-
if (await remote.has(key)) return true
|
|
188
|
+
if (await remote.has(key, atOf(key, name))) return true
|
|
185
189
|
answered = true
|
|
186
190
|
} catch {
|
|
187
191
|
// no answer from this remote
|
|
@@ -196,13 +200,16 @@ export function pluginCache(plugin: string, host: PluginCacheHost): PluginCache
|
|
|
196
200
|
for (const name of names) for (const key of reads(name)) byKey.set(key, name)
|
|
197
201
|
const keys = [...byKey.keys()]
|
|
198
202
|
const found = new Set<string>()
|
|
199
|
-
for (const key of localHas(
|
|
203
|
+
for (const key of localHas(names.map((n) => entryKey(plugin, n)))) found.add(byKey.get(key)!)
|
|
200
204
|
for (const { remote } of readers) {
|
|
201
205
|
const ask = keys.filter((k) => !found.has(byKey.get(k)!))
|
|
202
206
|
if (ask.length === 0) break
|
|
203
207
|
if (remote.hasMany === undefined) return null
|
|
204
208
|
try {
|
|
205
|
-
const got: unknown = await remote.hasMany(
|
|
209
|
+
const got: unknown = await remote.hasMany(
|
|
210
|
+
ask,
|
|
211
|
+
ask.map((k) => atOf(k, byKey.get(k)!)),
|
|
212
|
+
)
|
|
206
213
|
if (!(got instanceof Set)) return null
|
|
207
214
|
for (const key of got) if (byKey.has(key as string)) found.add(byKey.get(key as string)!)
|
|
208
215
|
} catch {
|
|
@@ -214,9 +221,9 @@ export function pluginCache(plugin: string, host: PluginCacheHost): PluginCache
|
|
|
214
221
|
|
|
215
222
|
async getRun(name) {
|
|
216
223
|
for (const { remote } of readers) {
|
|
217
|
-
for (const key of runReadKeys(name, scope)) {
|
|
224
|
+
for (const [i, key] of runReadKeys(name, scope).entries()) {
|
|
218
225
|
try {
|
|
219
|
-
const got = await remote.get(key)
|
|
226
|
+
const got = await remote.get(key, i === 0 ? {} : at)
|
|
220
227
|
if (got === null) continue
|
|
221
228
|
const bytes = await capped(got.body, Infinity)
|
|
222
229
|
if (bytes !== null) return bytes
|
package/src/cache/remote-lane.ts
CHANGED
|
@@ -10,8 +10,11 @@ import type { RemoteCacheLayer } from './layered-cache.js'
|
|
|
10
10
|
/** One remote, resolved: its wire and what the run's policy and its `access` let it do. */
|
|
11
11
|
export interface ResolvedRemote {
|
|
12
12
|
readonly remote: RemoteCacheLayer
|
|
13
|
-
|
|
14
|
-
readonly
|
|
13
|
+
/** The plugin that supplied it, whose keychain its `open` reads; none for an injected layer. */
|
|
14
|
+
readonly plugin?: string
|
|
15
|
+
/** Cleared, with `write`, when the remote's `open` fails: off for the rest of the run. */
|
|
16
|
+
read: boolean
|
|
17
|
+
write: boolean
|
|
15
18
|
/** Told of each failure this remote degrades to a miss; the default writes stderr. */
|
|
16
19
|
readonly onError?: (err: Error) => void
|
|
17
20
|
}
|
|
@@ -89,8 +92,7 @@ function batchOf(hashes: readonly string[]): { batch: string } {
|
|
|
89
92
|
}
|
|
90
93
|
|
|
91
94
|
export class RemoteLane {
|
|
92
|
-
readonly
|
|
93
|
-
readonly write: boolean
|
|
95
|
+
private readonly resolved: ResolvedRemote
|
|
94
96
|
private readonly remote: RemoteCacheLayer
|
|
95
97
|
private readonly endpoint: string | undefined
|
|
96
98
|
private readonly onError: ((err: Error) => void) | undefined
|
|
@@ -131,14 +133,23 @@ export class RemoteLane {
|
|
|
131
133
|
constructor(
|
|
132
134
|
resolved: ResolvedRemote,
|
|
133
135
|
private readonly traffic: RemoteTraffic,
|
|
136
|
+
/** The wire as this run asks it: a branch scope's view of `resolved.remote`. */
|
|
137
|
+
remote: RemoteCacheLayer = resolved.remote,
|
|
134
138
|
) {
|
|
135
|
-
this.
|
|
136
|
-
this.
|
|
137
|
-
this.write = resolved.write
|
|
139
|
+
this.resolved = resolved
|
|
140
|
+
this.remote = remote
|
|
138
141
|
this.onError = resolved.onError
|
|
139
142
|
this.endpoint = printableEndpoint(resolved.remote.endpoint)
|
|
140
143
|
}
|
|
141
144
|
|
|
145
|
+
get read(): boolean {
|
|
146
|
+
return this.resolved.read
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
get write(): boolean {
|
|
150
|
+
return this.resolved.write
|
|
151
|
+
}
|
|
152
|
+
|
|
142
153
|
/**
|
|
143
154
|
* Existence probe; a failure is a miss. A key a `hasMany` this run
|
|
144
155
|
* answered for is not asked again: `--dry` batches its keys first.
|
|
@@ -280,6 +291,11 @@ export class RemoteLane {
|
|
|
280
291
|
this.pump()
|
|
281
292
|
}
|
|
282
293
|
|
|
294
|
+
/** Settles when the run is stopped. */
|
|
295
|
+
get whenStopped(): Promise<void> {
|
|
296
|
+
return this.stopping
|
|
297
|
+
}
|
|
298
|
+
|
|
283
299
|
/** Resolves once every queued and in-flight upload has settled, or the run was stopped. */
|
|
284
300
|
async drain(): Promise<void> {
|
|
285
301
|
// A probe no upload waited on still runs: settled before the run tears
|
|
@@ -292,7 +308,8 @@ export class RemoteLane {
|
|
|
292
308
|
/**
|
|
293
309
|
* The run was stopped (Ctrl-C, SIGTERM, an embedder's abort): queued
|
|
294
310
|
* uploads never start and the drain stops waiting on the ones in flight,
|
|
295
|
-
* which a plugin cannot be asked to cancel.
|
|
311
|
+
* which a plugin cannot be asked to cancel. Reads in flight are given up
|
|
312
|
+
* the same way (`LayeredCache.pullFrom`). A slow remote held a stopped
|
|
296
313
|
* run past the signal's exit bound, which then exited with no footer.
|
|
297
314
|
*/
|
|
298
315
|
stop(): void {
|
package/src/cache/run-store.ts
CHANGED
|
@@ -44,7 +44,10 @@ export async function putRunEntry(
|
|
|
44
44
|
for (const { remote, write } of remotes) {
|
|
45
45
|
if (!write) continue
|
|
46
46
|
try {
|
|
47
|
-
await remote.put(key, new Blob([bytes]), {
|
|
47
|
+
await remote.put(key, new Blob([bytes]), {
|
|
48
|
+
durationMs: 0,
|
|
49
|
+
...(scope === undefined ? {} : { scope }),
|
|
50
|
+
})
|
|
48
51
|
kept = true
|
|
49
52
|
} catch {
|
|
50
53
|
// the other remotes still keep it
|
package/src/cache/schema.ts
CHANGED
|
@@ -140,6 +140,14 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
|
|
|
140
140
|
content_hash TEXT NOT NULL,
|
|
141
141
|
seen_at INTEGER NOT NULL
|
|
142
142
|
);
|
|
143
|
+
-- v42: each task's last key by its fold up to the files and a token of
|
|
144
|
+
-- the files' git records (fold-memo.ts). A memo: the KEY is unchanged.
|
|
145
|
+
CREATE TABLE IF NOT EXISTS main.fold_memo (
|
|
146
|
+
task_id TEXT PRIMARY KEY,
|
|
147
|
+
probe TEXT NOT NULL,
|
|
148
|
+
key TEXT NOT NULL,
|
|
149
|
+
seen_at INTEGER NOT NULL
|
|
150
|
+
);
|
|
143
151
|
-- The size of each index blob the enumeration checked against the
|
|
144
152
|
-- worktree size git recorded (A-60). Fixed for its OID, so a warm
|
|
145
153
|
-- run asks git for none; swept with file_hashes by seen_at, the
|
|
@@ -317,8 +325,10 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
|
|
|
317
325
|
* the artifact is the record and the entries only its inventory, so a reset
|
|
318
326
|
* drops the rows and keeps these, as it keeps the files. `artifacts_meta`
|
|
319
327
|
* holds the table's own layout version (`'layout'`), the one thing that may
|
|
320
|
-
* drop it. `at` is the last use a row-less sweep judges, as a file's mtime
|
|
321
|
-
*
|
|
328
|
+
* drop it. `at` is the last use a row-less sweep judges, as a file's mtime.
|
|
329
|
+
* A rowid table, not WITHOUT ROWID: there the blob sits in the key's b-tree,
|
|
330
|
+
* so every probe of a hash reads overflow pages; 10 000 lookups took 312 ms
|
|
331
|
+
* against 8.5 (a2, 2026-10-17).
|
|
322
332
|
*/
|
|
323
333
|
export function createArtifactTables(db: Database, store: 'main' | 'store'): void {
|
|
324
334
|
db.exec(artifactTables(store))
|
|
@@ -331,7 +341,7 @@ function artifactTables(store: 'main' | 'store'): string {
|
|
|
331
341
|
hash TEXT PRIMARY KEY,
|
|
332
342
|
at INTEGER NOT NULL,
|
|
333
343
|
bytes BLOB NOT NULL
|
|
334
|
-
)
|
|
344
|
+
);
|
|
335
345
|
CREATE TABLE IF NOT EXISTS ${store}.artifacts_meta (
|
|
336
346
|
key TEXT PRIMARY KEY,
|
|
337
347
|
value TEXT NOT NULL
|