@vzn/vx 0.0.640 → 0.0.642
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/launcher.sh +9 -2
- package/package.json +5 -12
- package/skills/vx/SKILL.md +4 -4
- package/src/bin.ts +8 -6
- package/src/cache/archive.ts +735 -107
- package/src/cache/cache.ts +133 -29
- package/src/cache/fold-memo.ts +58 -0
- package/src/cache/git-inputs.ts +213 -31
- package/src/cache/index.ts +9 -2
- package/src/cache/inputs.ts +244 -108
- package/src/cache/key-fold.ts +90 -69
- package/src/cache/layer.ts +9 -7
- package/src/cache/layered-cache.ts +74 -9
- package/src/cache/output-index.ts +25 -4
- package/src/cache/remote-lane.ts +27 -10
- package/src/cache/schema.ts +13 -3
- package/src/cache/scoped-cache.ts +11 -3
- package/src/cache/umask.ts +29 -0
- package/src/cli/docs.ts +71 -7
- package/src/cli/help.ts +11 -5
- package/src/cli/plan-format.ts +4 -1
- package/src/cli/run.ts +10 -3
- package/src/cli/select.ts +2 -0
- package/src/cli/upgrade.ts +37 -2
- package/src/cli/watch-filter.ts +1 -6
- package/src/cli/watch-fs.ts +2 -1
- package/src/exec/runner.ts +5 -0
- package/src/exec/sandbox-runtime.ts +81 -1
- package/src/graph/priorities.ts +14 -1
- package/src/handoff.ts +5 -0
- package/src/history/history.ts +43 -33
- package/src/index.ts +11 -1
- package/src/orchestrator/deferred-outputs.ts +3 -2
- package/src/orchestrator/execute-task.ts +20 -14
- package/src/orchestrator/hit-restore.ts +111 -29
- package/src/orchestrator/index.ts +1 -0
- package/src/orchestrator/local-shortcircuit.ts +8 -0
- package/src/orchestrator/lockfile-claim.ts +54 -8
- package/src/orchestrator/miss-save.ts +27 -17
- package/src/orchestrator/output-host.ts +9 -1
- package/src/orchestrator/plan.ts +4 -0
- package/src/orchestrator/plugin-host.ts +35 -5
- package/src/orchestrator/plugin.ts +29 -3
- package/src/orchestrator/prepare.ts +74 -5
- package/src/orchestrator/projects.ts +6 -1
- package/src/orchestrator/run-report.ts +30 -9
- package/src/orchestrator/run.ts +62 -38
- 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 +46 -12
- package/src/report/events.ts +12 -0
- package/src/report/framed-output.ts +2 -1
- package/src/report/logger.ts +58 -15
- package/src/report/status-line.ts +45 -4
- package/src/report/summary.ts +11 -6
- package/src/util/errors.ts +1 -1
- package/src/util/hidden-prompt.ts +74 -0
- package/src/util/index.ts +13 -0
- package/src/util/paths.ts +58 -5
- package/src/util/secrets.ts +150 -0
- package/src/workspace/affected.ts +63 -13
- package/src/workspace/config-eval.ts +2 -2
- package/src/workspace/config-schema.ts +37 -3
- package/src/workspace/project-loader.ts +6 -4
- package/src/workspace/workspace.ts +17 -11
- package/docs/caching.md +0 -2352
- package/docs/cli.md +0 -3280
- package/docs/execution.md +0 -675
- package/docs/features.md +0 -178
- package/docs/patterns.md +0 -219
- package/docs/schema.md +0 -2461
- package/docs/security.md +0 -252
- package/src/cli/docs-corpus.ts +0 -21
- package/src/cli/docs-text.d.ts +0 -5
package/src/cache/cache.ts
CHANGED
|
@@ -60,7 +60,7 @@ import {
|
|
|
60
60
|
isFsRefusal,
|
|
61
61
|
isOutOfFds,
|
|
62
62
|
OUT_OF_FDS_HINT,
|
|
63
|
-
|
|
63
|
+
restorableOutput,
|
|
64
64
|
relPosix,
|
|
65
65
|
span,
|
|
66
66
|
splitTaskId,
|
|
@@ -109,9 +109,10 @@ import {
|
|
|
109
109
|
zstdEncoder,
|
|
110
110
|
} from './zstd.js'
|
|
111
111
|
import { FileHashStore } from './file-hashes.js'
|
|
112
|
-
import { OutputIndex } from './output-index.js'
|
|
112
|
+
import { LINK_MODE, OutputIndex } from './output-index.js'
|
|
113
113
|
import { RunHistory } from './run-history.js'
|
|
114
114
|
import { CACHE_VERSION, foldKey } from './key-fold.js'
|
|
115
|
+
import { FoldMemo } from './fold-memo.js'
|
|
115
116
|
import { behindLink } from './linked-inputs.js'
|
|
116
117
|
|
|
117
118
|
/**
|
|
@@ -315,7 +316,11 @@ function outOfFdsAtOpen(dbFile: string, err: unknown): UserError | undefined {
|
|
|
315
316
|
// unchanged.
|
|
316
317
|
// v40: runs.remote_error — each remote read or write of a task's key that
|
|
317
318
|
// failed, for `vx last`. The cache KEY is unchanged.
|
|
318
|
-
|
|
319
|
+
// v41: the artifacts table is a rowid table (layout a2). The cache KEY
|
|
320
|
+
// is unchanged.
|
|
321
|
+
// v42: fold_memo — each task's last key by its fold up to the files and
|
|
322
|
+
// a token of their git records. The cache KEY is unchanged.
|
|
323
|
+
export const SCHEMA_VERSION = 'v42'
|
|
319
324
|
|
|
320
325
|
/**
|
|
321
326
|
* An entry's key components as its one `entry_inputs` row (v34): JSON
|
|
@@ -363,7 +368,7 @@ const ARTIFACT_TABLES = ['artifacts', 'artifacts_meta']
|
|
|
363
368
|
* reset, a new index or store, a store moved), so a change to it bumps
|
|
364
369
|
* `SCHEMA_VERSION` too: a warm open reads no statement for it.
|
|
365
370
|
*/
|
|
366
|
-
const ARTIFACT_LAYOUT = '
|
|
371
|
+
const ARTIFACT_LAYOUT = 'a2'
|
|
367
372
|
|
|
368
373
|
/**
|
|
369
374
|
* The largest compressed artifact kept in the index rather than in a file
|
|
@@ -807,6 +812,12 @@ export interface RemoteTraffic {
|
|
|
807
812
|
/** Uploads queued or running across every remote. */
|
|
808
813
|
inFlight: number
|
|
809
814
|
readonly keys: Map<string, RemoteKeyTraffic>
|
|
815
|
+
/**
|
|
816
|
+
* A key a remote is asked under that is not the task's own (a scope's)
|
|
817
|
+
* → the task key it answers for: its traffic counts under the task key,
|
|
818
|
+
* which the footer and the run's rows look up.
|
|
819
|
+
*/
|
|
820
|
+
readonly aliases: Map<string, string>
|
|
810
821
|
}
|
|
811
822
|
|
|
812
823
|
export class Cache implements CacheLayer {
|
|
@@ -819,7 +830,7 @@ export class Cache implements CacheLayer {
|
|
|
819
830
|
*/
|
|
820
831
|
readonly uploads: UploadTally = { count: 0, bytes: 0, ms: 0, failed: 0 }
|
|
821
832
|
/** The same remotes' traffic per key, filled by the same `LayeredCache`. */
|
|
822
|
-
readonly traffic: RemoteTraffic = { inFlight: 0, keys: new Map() }
|
|
833
|
+
readonly traffic: RemoteTraffic = { inFlight: 0, keys: new Map(), aliases: new Map() }
|
|
823
834
|
|
|
824
835
|
private readonly db: Database
|
|
825
836
|
private readonly insertEntry: ReturnType<Database['prepare']>
|
|
@@ -851,6 +862,7 @@ export class Cache implements CacheLayer {
|
|
|
851
862
|
/** The per-file (mtime, size) → blob-OID memo behind `hashFile`. */
|
|
852
863
|
private readonly files: FileHashStore
|
|
853
864
|
private readonly outputs: OutputIndex
|
|
865
|
+
private readonly folds: FoldMemo
|
|
854
866
|
private readonly history: RunHistory
|
|
855
867
|
|
|
856
868
|
/**
|
|
@@ -866,6 +878,8 @@ export class Cache implements CacheLayer {
|
|
|
866
878
|
private readonly inspecting: boolean
|
|
867
879
|
/** Why this process cannot write into `cacheDir`, or `null`; decided at open. */
|
|
868
880
|
private readonly writeBlocked: string | null
|
|
881
|
+
/** The index is in memory: nothing on disk to read. */
|
|
882
|
+
private readonly empty: boolean
|
|
869
883
|
private readonly dbFile: string
|
|
870
884
|
/** Where the artifacts live: the shared store, or `cacheDir` itself. */
|
|
871
885
|
private readonly artifactDir: string
|
|
@@ -952,8 +966,15 @@ export class Cache implements CacheLayer {
|
|
|
952
966
|
(!existsSync(dbFile) || (mode === 'preview' && indexOfAnotherSchema(dbFile)))
|
|
953
967
|
this.writeBlocked = absent ? 'no index there yet' : openCacheDir(cacheDir, repoDir)
|
|
954
968
|
this.write = localPolicy.write && this.writeBlocked === null
|
|
969
|
+
// A directory this user cannot write with no index in it yet: SQLite
|
|
970
|
+
// cannot make one, and its `unable to open database file` stack reached
|
|
971
|
+
// the user before `assertWritable` could name the directory (a
|
|
972
|
+
// read-only mount, Growth's parity run 2026-10-16). Nothing there to
|
|
973
|
+
// read, so an empty index in memory, as a reader's typo gets.
|
|
974
|
+
const memory = absent || (this.writeBlocked !== null && !existsSync(dbFile))
|
|
975
|
+
this.empty = memory
|
|
955
976
|
try {
|
|
956
|
-
this.db = new Database(
|
|
977
|
+
this.db = new Database(memory ? ':memory:' : dbFile, { create: true })
|
|
957
978
|
} catch (err) {
|
|
958
979
|
throw outOfFdsAtOpen(dbFile, err) ?? err
|
|
959
980
|
}
|
|
@@ -1062,7 +1083,7 @@ export class Cache implements CacheLayer {
|
|
|
1062
1083
|
// keeps it so).
|
|
1063
1084
|
if (found !== undefined) {
|
|
1064
1085
|
this.db.exec(
|
|
1065
|
-
'DROP TABLE IF EXISTS main.entries; DROP TABLE IF EXISTS main.runs; DROP TABLE IF EXISTS main.file_hashes; DROP TABLE IF EXISTS main.blob_sizes; DROP TABLE IF EXISTS main.blob_verdicts; DROP TABLE IF EXISTS main.output_files; DROP TABLE IF EXISTS main.output_stamps; DROP TABLE IF EXISTS main.invocations; DROP TABLE IF EXISTS main.run_task_inputs; DROP TABLE IF EXISTS main.entry_inputs; DROP TABLE IF EXISTS main.config_evals; DROP TABLE IF EXISTS main.config_closures; DROP TABLE IF EXISTS main.output_dirs; DROP TABLE IF EXISTS main.entry_stdout; DROP TABLE IF EXISTS main.task_durations; DROP TABLE IF EXISTS main.plugin_blobs; DROP TABLE IF EXISTS main.task_outcomes; DROP TABLE IF EXISTS main.store_meta;',
|
|
1086
|
+
'DROP TABLE IF EXISTS main.entries; DROP TABLE IF EXISTS main.runs; DROP TABLE IF EXISTS main.file_hashes; DROP TABLE IF EXISTS main.blob_sizes; DROP TABLE IF EXISTS main.blob_verdicts; DROP TABLE IF EXISTS main.output_files; DROP TABLE IF EXISTS main.output_stamps; DROP TABLE IF EXISTS main.invocations; DROP TABLE IF EXISTS main.run_task_inputs; DROP TABLE IF EXISTS main.entry_inputs; DROP TABLE IF EXISTS main.config_evals; DROP TABLE IF EXISTS main.config_closures; DROP TABLE IF EXISTS main.output_dirs; DROP TABLE IF EXISTS main.entry_stdout; DROP TABLE IF EXISTS main.task_durations; DROP TABLE IF EXISTS main.plugin_blobs; DROP TABLE IF EXISTS main.task_outcomes; DROP TABLE IF EXISTS main.store_meta; DROP TABLE IF EXISTS main.fold_memo;',
|
|
1066
1087
|
)
|
|
1067
1088
|
}
|
|
1068
1089
|
// The tables land under the same write lock as the stamp: made
|
|
@@ -1224,6 +1245,7 @@ export class Cache implements CacheLayer {
|
|
|
1224
1245
|
// the schema above is the one place every table is declared.
|
|
1225
1246
|
this.files = new FileHashStore(this.db, cacheDir, this.write, repoDir)
|
|
1226
1247
|
this.outputs = new OutputIndex(this.db)
|
|
1248
|
+
this.folds = new FoldMemo(this.db, this.write && !this.inspecting)
|
|
1227
1249
|
this.history = new RunHistory(this.db, (v) => this.digestValue(v))
|
|
1228
1250
|
|
|
1229
1251
|
// A CACHE_VERSION bump keeps the index but moves every key, so the run
|
|
@@ -1493,6 +1515,11 @@ export class Cache implements CacheLayer {
|
|
|
1493
1515
|
return rel
|
|
1494
1516
|
}
|
|
1495
1517
|
|
|
1518
|
+
/** Each task's last key by its probe (`computeTaskHash`). */
|
|
1519
|
+
foldMemo(): FoldMemo {
|
|
1520
|
+
return this.folds
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1496
1523
|
key(input: CacheKeyInput): Promise<string> {
|
|
1497
1524
|
const hashFile = (f: string): Promise<string> => this.hashFile(f)
|
|
1498
1525
|
return foldKey(
|
|
@@ -1842,9 +1869,14 @@ export class Cache implements CacheLayer {
|
|
|
1842
1869
|
outputDirsCurrent(projectDir: string, rows: readonly OutputDirRow[]): Promise<boolean> {
|
|
1843
1870
|
return this.outputs.outputDirsCurrent(projectDir, rows)
|
|
1844
1871
|
}
|
|
1845
|
-
async restoreOutputs(
|
|
1872
|
+
async restoreOutputs(
|
|
1873
|
+
hash: string,
|
|
1874
|
+
projectDir: string,
|
|
1875
|
+
workspaceRoot?: string,
|
|
1876
|
+
outputs?: DeclaredOutputs,
|
|
1877
|
+
): Promise<void> {
|
|
1846
1878
|
try {
|
|
1847
|
-
await this.restoreOutputsOnce(hash, projectDir, workspaceRoot)
|
|
1879
|
+
await this.restoreOutputsOnce(hash, projectDir, workspaceRoot, outputs)
|
|
1848
1880
|
} catch (err) {
|
|
1849
1881
|
if (!(err instanceof CorruptArtifactError)) throw err
|
|
1850
1882
|
// Bad bytes under a good key failed the task on every run until
|
|
@@ -1879,7 +1911,8 @@ export class Cache implements CacheLayer {
|
|
|
1879
1911
|
private async restoreOutputsOnce(
|
|
1880
1912
|
hash: string,
|
|
1881
1913
|
projectDir: string,
|
|
1882
|
-
workspaceRoot
|
|
1914
|
+
workspaceRoot: string | undefined,
|
|
1915
|
+
outputs: DeclaredOutputs | undefined,
|
|
1883
1916
|
): Promise<void> {
|
|
1884
1917
|
// In-process extraction — no fork+exec on the hot path
|
|
1885
1918
|
// (~5-10ms reclaimed per hit vs the prior subprocess `tar -xf`).
|
|
@@ -1967,7 +2000,14 @@ export class Cache implements CacheLayer {
|
|
|
1967
2000
|
// four workers overlap their round trips and a blocking one stalls
|
|
1968
2001
|
// the other three (1,000-project restore row 1.1–1.2 s → 1.5 s).
|
|
1969
2002
|
const tar = await decodedTar(inline ?? Bun.file(src), hash, this.artifactCeiling)
|
|
1970
|
-
await extractArtifactStream(
|
|
2003
|
+
await extractArtifactStream(
|
|
2004
|
+
tar,
|
|
2005
|
+
projectDir,
|
|
2006
|
+
workspaceRoot,
|
|
2007
|
+
verify,
|
|
2008
|
+
outputs === undefined ? undefined : declaredName(outputs),
|
|
2009
|
+
workspaceRoot,
|
|
2010
|
+
)
|
|
1971
2011
|
} catch (err) {
|
|
1972
2012
|
// A UserError here is the extractor naming the tree's fault (an
|
|
1973
2013
|
// output directory that links out of the project).
|
|
@@ -1981,8 +2021,8 @@ export class Cache implements CacheLayer {
|
|
|
1981
2021
|
throw new CorruptArtifactError(hash, err.message, err)
|
|
1982
2022
|
// What is on disk, not what is in the archive: a directory standing
|
|
1983
2023
|
// where the entry holds a file, or a file where it needs a directory.
|
|
1984
|
-
// The
|
|
1985
|
-
// directories that removal emptied, so this is a path it left: one
|
|
2024
|
+
// The restore removes the FILES the output globs select that block it
|
|
2025
|
+
// and the directories that removal emptied, so this is a path it left: one
|
|
1986
2026
|
// the globs do not cover, or an empty directory where a file goes,
|
|
1987
2027
|
// which `dist/**` covers and the clean does not take (item 1094).
|
|
1988
2028
|
// Named as such, not as a corrupt artifact. A
|
|
@@ -2001,7 +2041,7 @@ export class Cache implements CacheLayer {
|
|
|
2001
2041
|
: 'a file stands where the entry needs a directory'
|
|
2002
2042
|
throw new UserError(
|
|
2003
2043
|
`restore of ${hash} into ${projectDir} was blocked by what is on disk (${msg.startsWith(code) ? msg : `${code}: ${msg}`}): ${what}. ` +
|
|
2004
|
-
`
|
|
2044
|
+
`A restore replaces what the output globs select, not this — remove it and re-run.`,
|
|
2005
2045
|
)
|
|
2006
2046
|
}
|
|
2007
2047
|
// A legal name under a destination deep enough that the two together
|
|
@@ -2049,7 +2089,7 @@ export class Cache implements CacheLayer {
|
|
|
2049
2089
|
// on one workspace, a `vx watch` beside a `vx run` — a clean landing
|
|
2050
2090
|
// 4–32 ms into a 2,000-file restore reproduces it, 2026-09-16). The
|
|
2051
2091
|
// artifact is intact; the tree was not ours alone.
|
|
2052
|
-
if (code === 'ENOENT' && (err as Error).message
|
|
2092
|
+
if (code === 'ENOENT' && /\.vx-tmp-|[\\/]\.vx[\\/]tmp[\\/]/.test((err as Error).message)) {
|
|
2053
2093
|
throw new UserError(
|
|
2054
2094
|
`restore of ${hash} into ${projectDir} was interrupted: a file it had just written vanished (${(err as Error).message}). ` +
|
|
2055
2095
|
`Another vx run is using this workspace — re-run once it is done.`,
|
|
@@ -2078,17 +2118,31 @@ export class Cache implements CacheLayer {
|
|
|
2078
2118
|
)
|
|
2079
2119
|
}
|
|
2080
2120
|
|
|
2121
|
+
/**
|
|
2122
|
+
* A run over a cache directory it cannot write: one holding entries
|
|
2123
|
+
* serves its hits and saves nothing (M, 2026-10-16), and the line this
|
|
2124
|
+
* returns says so; an empty one has nothing to serve and refuses.
|
|
2125
|
+
*/
|
|
2126
|
+
readOnlyNotice(): string | null {
|
|
2127
|
+
if (this.writeBlocked === null) return null
|
|
2128
|
+
if (this.empty) this.assertWritable()
|
|
2129
|
+
return (
|
|
2130
|
+
`cache directory ${this.cacheDir} is not writable (${this.writeBlocked}) — serving hits ` +
|
|
2131
|
+
`from it; this run saves nothing and records no history`
|
|
2132
|
+
)
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2081
2135
|
async save(args: {
|
|
2082
2136
|
hash: string
|
|
2083
2137
|
/** See `CacheLayer.save` — `exitCode` is not a caller's to supply. */
|
|
2084
2138
|
entry: Omit<CacheEntry, 'hash' | 'storedAt' | 'outputFiles' | 'exitCode'>
|
|
2085
2139
|
projectDir: string
|
|
2086
2140
|
outputFiles: string[]
|
|
2141
|
+
outputDirs?: string[]
|
|
2087
2142
|
workspaceOutputFiles?: string[]
|
|
2088
2143
|
workspaceRoot?: string
|
|
2089
2144
|
inputComponents?: readonly TaskInputRow[]
|
|
2090
2145
|
/** See `CacheLayer.save`. */
|
|
2091
|
-
readable?: ((real: string) => boolean) | undefined
|
|
2092
2146
|
}): Promise<void> {
|
|
2093
2147
|
// Layout (v17, extended additively for workspaceFiles): one
|
|
2094
2148
|
// `<hash>.tar.zst` per entry. Tar carries ONLY the things you'd
|
|
@@ -2254,6 +2308,7 @@ export class Cache implements CacheLayer {
|
|
|
2254
2308
|
private outputsOf(args: {
|
|
2255
2309
|
projectDir: string
|
|
2256
2310
|
outputFiles: string[]
|
|
2311
|
+
outputDirs?: string[]
|
|
2257
2312
|
workspaceOutputFiles?: string[]
|
|
2258
2313
|
workspaceRoot?: string
|
|
2259
2314
|
}): Map<string, string> {
|
|
@@ -2286,9 +2341,9 @@ export class Cache implements CacheLayer {
|
|
|
2286
2341
|
entry: Omit<CacheEntry, 'hash' | 'storedAt' | 'outputFiles' | 'exitCode'>
|
|
2287
2342
|
projectDir: string
|
|
2288
2343
|
outputFiles: string[]
|
|
2344
|
+
outputDirs?: string[]
|
|
2289
2345
|
workspaceOutputFiles?: string[]
|
|
2290
2346
|
workspaceRoot?: string
|
|
2291
|
-
readable?: ((real: string) => boolean) | undefined
|
|
2292
2347
|
}): Promise<Uint8Array> {
|
|
2293
2348
|
// stdout is ALWAYS present in the artifact, even if empty, so the
|
|
2294
2349
|
// layout is predictable: a successful read finds `stdout` and
|
|
@@ -2310,9 +2365,9 @@ export class Cache implements CacheLayer {
|
|
|
2310
2365
|
key: args.hash,
|
|
2311
2366
|
stdout: args.entry.stdout ?? '',
|
|
2312
2367
|
outputs: this.outputsOf(args),
|
|
2368
|
+
dirs: args.outputDirs?.map((d) => `outputs/${relPosix(args.projectDir, d)}`),
|
|
2313
2369
|
exec: usageOfEntry(args.entry),
|
|
2314
|
-
|
|
2315
|
-
readable: args.readable,
|
|
2370
|
+
root: args.workspaceRoot ?? args.projectDir,
|
|
2316
2371
|
})
|
|
2317
2372
|
if (plan.size > this.artifactCeiling) {
|
|
2318
2373
|
throw new Error(
|
|
@@ -2450,7 +2505,10 @@ export class Cache implements CacheLayer {
|
|
|
2450
2505
|
: `artifact was stored under another key (${scanned.key})`,
|
|
2451
2506
|
)
|
|
2452
2507
|
}
|
|
2453
|
-
assertArtifactNames(hash, scanned.entries, meta.outputs
|
|
2508
|
+
assertArtifactNames(hash, scanned.entries, meta.outputs, [
|
|
2509
|
+
...scanned.dirs,
|
|
2510
|
+
...scanned.links.map(([name]) => name),
|
|
2511
|
+
])
|
|
2454
2512
|
} catch (err) {
|
|
2455
2513
|
if (tmpPath !== undefined) await unlink(tmpPath).catch(() => undefined)
|
|
2456
2514
|
if (err instanceof ArchiveSecurityError || err instanceof CorruptArtifactError) throw err
|
|
@@ -2483,6 +2541,28 @@ export class Cache implements CacheLayer {
|
|
|
2483
2541
|
if (rowPath === null) continue
|
|
2484
2542
|
outputFileRows.push([rowPath, e.size, e.mode & 0o777, Math.floor(e.mtimeMs)])
|
|
2485
2543
|
}
|
|
2544
|
+
// An empty directory's row is its rel with a trailing slash: the hit's
|
|
2545
|
+
// check asks only that a directory stands there.
|
|
2546
|
+
for (const d of scanned.dirs) {
|
|
2547
|
+
outputFileRows.push([
|
|
2548
|
+
`${d.startsWith('outputs/') ? d.slice('outputs/'.length) : d}/`,
|
|
2549
|
+
0,
|
|
2550
|
+
0,
|
|
2551
|
+
0,
|
|
2552
|
+
])
|
|
2553
|
+
}
|
|
2554
|
+
// A link's row: its rel, the target's length and CRC-32 where a file
|
|
2555
|
+
// has its size and mtime, and the link type in the mode, so the hit's
|
|
2556
|
+
// check compares the target and never follows it.
|
|
2557
|
+
for (const [name, to] of scanned.links) {
|
|
2558
|
+
const bytes = new TextEncoder().encode(to)
|
|
2559
|
+
outputFileRows.push([
|
|
2560
|
+
name.startsWith('outputs/') ? name.slice('outputs/'.length) : name,
|
|
2561
|
+
bytes.byteLength,
|
|
2562
|
+
LINK_MODE,
|
|
2563
|
+
Bun.hash.crc32(bytes),
|
|
2564
|
+
])
|
|
2565
|
+
}
|
|
2486
2566
|
const stdoutText = scanned.stdout
|
|
2487
2567
|
|
|
2488
2568
|
const [project, task] = splitTaskId(meta.taskId)
|
|
@@ -2652,13 +2732,18 @@ export class Cache implements CacheLayer {
|
|
|
2652
2732
|
}
|
|
2653
2733
|
|
|
2654
2734
|
// --- run history: delegated to `RunHistory` (see run-history.ts) ---
|
|
2735
|
+
// A run over a directory it cannot write records no history:
|
|
2736
|
+
// `readOnlyNotice` said so once.
|
|
2655
2737
|
recordRun(run: RunRecord): void {
|
|
2738
|
+
if (this.writeBlocked !== null) return
|
|
2656
2739
|
return this.guard(() => this.history.recordRun(run))
|
|
2657
2740
|
}
|
|
2658
2741
|
recordRuns(runs: readonly RunRecord[]): void {
|
|
2742
|
+
if (this.writeBlocked !== null) return
|
|
2659
2743
|
return this.guard(() => this.history.recordRuns(runs))
|
|
2660
2744
|
}
|
|
2661
2745
|
recordRunBundle(bundle: { runs: readonly RunRecord[]; invocation: InvocationRecord }): void {
|
|
2746
|
+
if (this.writeBlocked !== null) return
|
|
2662
2747
|
return this.guard(() => this.history.recordRunBundle(bundle))
|
|
2663
2748
|
}
|
|
2664
2749
|
recordRemoteErrors(runId: string, errors: readonly (readonly [string, string])[]): void {
|
|
@@ -3013,6 +3098,7 @@ export class Cache implements CacheLayer {
|
|
|
3013
3098
|
try {
|
|
3014
3099
|
this.flushAccessed()
|
|
3015
3100
|
this.outputs.flushOutputDirs()
|
|
3101
|
+
this.folds.flush(Date.now() - 30 * 24 * 60 * 60 * 1000)
|
|
3016
3102
|
} catch {
|
|
3017
3103
|
// Same contract as the retention prune above, which was already
|
|
3018
3104
|
// guarded while this sibling was not: `accessed_at` is LRU
|
|
@@ -3070,8 +3156,15 @@ function assertArtifactNames(
|
|
|
3070
3156
|
hash: string,
|
|
3071
3157
|
entries: ReadonlyArray<{ name: string }>,
|
|
3072
3158
|
declared: DeclaredOutputs | undefined,
|
|
3159
|
+
dirs: readonly string[] = [],
|
|
3073
3160
|
): void {
|
|
3074
3161
|
const names = new Set(entries.map((e) => e.name))
|
|
3162
|
+
for (const d of dirs) {
|
|
3163
|
+
if (names.has(d)) {
|
|
3164
|
+
throw new CorruptArtifactError(hash, `artifact holds ${d} as a file and as a directory`)
|
|
3165
|
+
}
|
|
3166
|
+
names.add(d)
|
|
3167
|
+
}
|
|
3075
3168
|
for (const name of names) {
|
|
3076
3169
|
for (let sep = name.indexOf('/'); sep !== -1; sep = name.indexOf('/', sep + 1)) {
|
|
3077
3170
|
if (names.has(name.slice(0, sep))) {
|
|
@@ -3083,16 +3176,9 @@ function assertArtifactNames(
|
|
|
3083
3176
|
}
|
|
3084
3177
|
}
|
|
3085
3178
|
if (declared === undefined) return
|
|
3086
|
-
|
|
3087
|
-
const isFile = outputMatcher(declared.files)
|
|
3088
|
-
const isWsFile = outputMatcher(declared.workspaceFiles)
|
|
3179
|
+
const ok = declaredName(declared)
|
|
3089
3180
|
for (const name of names) {
|
|
3090
|
-
|
|
3091
|
-
? isFile(name.slice('outputs/'.length))
|
|
3092
|
-
: name.startsWith(WORKSPACE_OUTPUT_PREFIX)
|
|
3093
|
-
? isWsFile(name.slice(WORKSPACE_OUTPUT_PREFIX.length))
|
|
3094
|
-
: true
|
|
3095
|
-
if (!ok) {
|
|
3181
|
+
if (!ok(name)) {
|
|
3096
3182
|
throw new CorruptArtifactError(
|
|
3097
3183
|
hash,
|
|
3098
3184
|
`artifact carries ${name}, which is not one of the task's declared outputs`,
|
|
@@ -3101,6 +3187,24 @@ function assertArtifactNames(
|
|
|
3101
3187
|
}
|
|
3102
3188
|
}
|
|
3103
3189
|
|
|
3190
|
+
/**
|
|
3191
|
+
* Whether an artifact entry names one of the task's declared outputs, as
|
|
3192
|
+
* the save could have packed it (`restorableOutput`: a `!` take-back, vx's
|
|
3193
|
+
* own files and an unnamed `node_modules` are none). Asked at ingest and
|
|
3194
|
+
* again at every restore, so an entry that reached the local store by any
|
|
3195
|
+
* route still writes nothing it should not (2026-10-16).
|
|
3196
|
+
*/
|
|
3197
|
+
function declaredName(declared: DeclaredOutputs): (name: string) => boolean {
|
|
3198
|
+
const isFile = restorableOutput(declared.files)
|
|
3199
|
+
const isWsFile = restorableOutput(declared.workspaceFiles, 'workspace')
|
|
3200
|
+
return (name) =>
|
|
3201
|
+
name.startsWith('outputs/')
|
|
3202
|
+
? isFile(name.slice('outputs/'.length))
|
|
3203
|
+
: name.startsWith(WORKSPACE_OUTPUT_PREFIX)
|
|
3204
|
+
? isWsFile(name.slice(WORKSPACE_OUTPUT_PREFIX.length))
|
|
3205
|
+
: true
|
|
3206
|
+
}
|
|
3207
|
+
|
|
3104
3208
|
/** What `ingest` reads a remote body through. */
|
|
3105
3209
|
interface ChunkReader {
|
|
3106
3210
|
read(): Promise<{ done: true; value?: undefined } | { done: false; value: Uint8Array }>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Each task's last key, by what its input files were: a `probe` that is the
|
|
2
|
+
// fold up to the files (`foldHead`) seeded into a token of the files' git
|
|
3
|
+
// records and the declaration that picks among them (`filesToken`). Equal
|
|
4
|
+
// probes are equal folds, so a warm task's key is read back instead of
|
|
5
|
+
// resolving and folding its files again: ~300 ms of a 5,000-project warm
|
|
6
|
+
// run (2026-10-17). One row per task, rewritten when its probe moves; a
|
|
7
|
+
// pure memo, so a lost or swept row is one fold more.
|
|
8
|
+
|
|
9
|
+
import type { Database } from 'bun:sqlite'
|
|
10
|
+
|
|
11
|
+
export interface FoldMemoRow {
|
|
12
|
+
probe: string
|
|
13
|
+
key: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class FoldMemo {
|
|
17
|
+
private rows: Map<string, FoldMemoRow> | undefined
|
|
18
|
+
private readonly dirty = new Map<string, FoldMemoRow>()
|
|
19
|
+
|
|
20
|
+
constructor(
|
|
21
|
+
private readonly db: Database,
|
|
22
|
+
/** The local WRITE axis: off, and a fold is answered but not remembered. */
|
|
23
|
+
private readonly write: boolean,
|
|
24
|
+
) {}
|
|
25
|
+
|
|
26
|
+
/** The key remembered for `taskId` under `probe`, if that is the probe it was folded under. */
|
|
27
|
+
get(taskId: string, probe: string): string | undefined {
|
|
28
|
+
// One read of every row: a warm run asks for nearly all of them.
|
|
29
|
+
this.rows ??= new Map(
|
|
30
|
+
(
|
|
31
|
+
this.db.query('SELECT task_id, probe, key FROM fold_memo').all() as Array<{
|
|
32
|
+
task_id: string
|
|
33
|
+
probe: string
|
|
34
|
+
key: string
|
|
35
|
+
}>
|
|
36
|
+
).map((r) => [r.task_id, { probe: r.probe, key: r.key }]),
|
|
37
|
+
)
|
|
38
|
+
const row = this.dirty.get(taskId) ?? this.rows.get(taskId)
|
|
39
|
+
return row?.probe === probe ? row.key : undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
set(taskId: string, probe: string, key: string): void {
|
|
43
|
+
if (this.write) this.dirty.set(taskId, { probe, key })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Write the folds this run made; rows a run has not rewritten for `cutoff` go. */
|
|
47
|
+
flush(cutoff: number, now: number = Date.now()): void {
|
|
48
|
+
if (this.dirty.size === 0) return
|
|
49
|
+
const upsert = this.db.prepare(
|
|
50
|
+
'INSERT OR REPLACE INTO fold_memo(task_id, probe, key, seen_at) VALUES (?, ?, ?, ?)',
|
|
51
|
+
)
|
|
52
|
+
this.db.transaction(() => {
|
|
53
|
+
for (const [taskId, r] of this.dirty) upsert.run(taskId, r.probe, r.key, now)
|
|
54
|
+
this.db.prepare('DELETE FROM fold_memo WHERE seen_at < ?').run(cutoff)
|
|
55
|
+
})()
|
|
56
|
+
this.dirty.clear()
|
|
57
|
+
}
|
|
58
|
+
}
|