@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/cache.ts
CHANGED
|
@@ -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
|
|
@@ -851,6 +856,7 @@ export class Cache implements CacheLayer {
|
|
|
851
856
|
/** The per-file (mtime, size) → blob-OID memo behind `hashFile`. */
|
|
852
857
|
private readonly files: FileHashStore
|
|
853
858
|
private readonly outputs: OutputIndex
|
|
859
|
+
private readonly folds: FoldMemo
|
|
854
860
|
private readonly history: RunHistory
|
|
855
861
|
|
|
856
862
|
/**
|
|
@@ -866,6 +872,8 @@ export class Cache implements CacheLayer {
|
|
|
866
872
|
private readonly inspecting: boolean
|
|
867
873
|
/** Why this process cannot write into `cacheDir`, or `null`; decided at open. */
|
|
868
874
|
private readonly writeBlocked: string | null
|
|
875
|
+
/** The index is in memory: nothing on disk to read. */
|
|
876
|
+
private readonly empty: boolean
|
|
869
877
|
private readonly dbFile: string
|
|
870
878
|
/** Where the artifacts live: the shared store, or `cacheDir` itself. */
|
|
871
879
|
private readonly artifactDir: string
|
|
@@ -952,8 +960,15 @@ export class Cache implements CacheLayer {
|
|
|
952
960
|
(!existsSync(dbFile) || (mode === 'preview' && indexOfAnotherSchema(dbFile)))
|
|
953
961
|
this.writeBlocked = absent ? 'no index there yet' : openCacheDir(cacheDir, repoDir)
|
|
954
962
|
this.write = localPolicy.write && this.writeBlocked === null
|
|
963
|
+
// A directory this user cannot write with no index in it yet: SQLite
|
|
964
|
+
// cannot make one, and its `unable to open database file` stack reached
|
|
965
|
+
// the user before `assertWritable` could name the directory (a
|
|
966
|
+
// read-only mount, Growth's parity run 2026-10-16). Nothing there to
|
|
967
|
+
// read, so an empty index in memory, as a reader's typo gets.
|
|
968
|
+
const memory = absent || (this.writeBlocked !== null && !existsSync(dbFile))
|
|
969
|
+
this.empty = memory
|
|
955
970
|
try {
|
|
956
|
-
this.db = new Database(
|
|
971
|
+
this.db = new Database(memory ? ':memory:' : dbFile, { create: true })
|
|
957
972
|
} catch (err) {
|
|
958
973
|
throw outOfFdsAtOpen(dbFile, err) ?? err
|
|
959
974
|
}
|
|
@@ -1062,7 +1077,7 @@ export class Cache implements CacheLayer {
|
|
|
1062
1077
|
// keeps it so).
|
|
1063
1078
|
if (found !== undefined) {
|
|
1064
1079
|
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;',
|
|
1080
|
+
'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
1081
|
)
|
|
1067
1082
|
}
|
|
1068
1083
|
// The tables land under the same write lock as the stamp: made
|
|
@@ -1224,6 +1239,7 @@ export class Cache implements CacheLayer {
|
|
|
1224
1239
|
// the schema above is the one place every table is declared.
|
|
1225
1240
|
this.files = new FileHashStore(this.db, cacheDir, this.write, repoDir)
|
|
1226
1241
|
this.outputs = new OutputIndex(this.db)
|
|
1242
|
+
this.folds = new FoldMemo(this.db, this.write && !this.inspecting)
|
|
1227
1243
|
this.history = new RunHistory(this.db, (v) => this.digestValue(v))
|
|
1228
1244
|
|
|
1229
1245
|
// A CACHE_VERSION bump keeps the index but moves every key, so the run
|
|
@@ -1493,6 +1509,11 @@ export class Cache implements CacheLayer {
|
|
|
1493
1509
|
return rel
|
|
1494
1510
|
}
|
|
1495
1511
|
|
|
1512
|
+
/** Each task's last key by its probe (`computeTaskHash`). */
|
|
1513
|
+
foldMemo(): FoldMemo {
|
|
1514
|
+
return this.folds
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1496
1517
|
key(input: CacheKeyInput): Promise<string> {
|
|
1497
1518
|
const hashFile = (f: string): Promise<string> => this.hashFile(f)
|
|
1498
1519
|
return foldKey(
|
|
@@ -2091,17 +2112,31 @@ export class Cache implements CacheLayer {
|
|
|
2091
2112
|
)
|
|
2092
2113
|
}
|
|
2093
2114
|
|
|
2115
|
+
/**
|
|
2116
|
+
* A run over a cache directory it cannot write: one holding entries
|
|
2117
|
+
* serves its hits and saves nothing (M, 2026-10-16), and the line this
|
|
2118
|
+
* returns says so; an empty one has nothing to serve and refuses.
|
|
2119
|
+
*/
|
|
2120
|
+
readOnlyNotice(): string | null {
|
|
2121
|
+
if (this.writeBlocked === null) return null
|
|
2122
|
+
if (this.empty) this.assertWritable()
|
|
2123
|
+
return (
|
|
2124
|
+
`cache directory ${this.cacheDir} is not writable (${this.writeBlocked}) — serving hits ` +
|
|
2125
|
+
`from it; this run saves nothing and records no history`
|
|
2126
|
+
)
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2094
2129
|
async save(args: {
|
|
2095
2130
|
hash: string
|
|
2096
2131
|
/** See `CacheLayer.save` — `exitCode` is not a caller's to supply. */
|
|
2097
2132
|
entry: Omit<CacheEntry, 'hash' | 'storedAt' | 'outputFiles' | 'exitCode'>
|
|
2098
2133
|
projectDir: string
|
|
2099
2134
|
outputFiles: string[]
|
|
2135
|
+
outputDirs?: string[]
|
|
2100
2136
|
workspaceOutputFiles?: string[]
|
|
2101
2137
|
workspaceRoot?: string
|
|
2102
2138
|
inputComponents?: readonly TaskInputRow[]
|
|
2103
2139
|
/** See `CacheLayer.save`. */
|
|
2104
|
-
readable?: ((real: string) => boolean) | undefined
|
|
2105
2140
|
}): Promise<void> {
|
|
2106
2141
|
// Layout (v17, extended additively for workspaceFiles): one
|
|
2107
2142
|
// `<hash>.tar.zst` per entry. Tar carries ONLY the things you'd
|
|
@@ -2267,6 +2302,7 @@ export class Cache implements CacheLayer {
|
|
|
2267
2302
|
private outputsOf(args: {
|
|
2268
2303
|
projectDir: string
|
|
2269
2304
|
outputFiles: string[]
|
|
2305
|
+
outputDirs?: string[]
|
|
2270
2306
|
workspaceOutputFiles?: string[]
|
|
2271
2307
|
workspaceRoot?: string
|
|
2272
2308
|
}): Map<string, string> {
|
|
@@ -2299,9 +2335,9 @@ export class Cache implements CacheLayer {
|
|
|
2299
2335
|
entry: Omit<CacheEntry, 'hash' | 'storedAt' | 'outputFiles' | 'exitCode'>
|
|
2300
2336
|
projectDir: string
|
|
2301
2337
|
outputFiles: string[]
|
|
2338
|
+
outputDirs?: string[]
|
|
2302
2339
|
workspaceOutputFiles?: string[]
|
|
2303
2340
|
workspaceRoot?: string
|
|
2304
|
-
readable?: ((real: string) => boolean) | undefined
|
|
2305
2341
|
}): Promise<Uint8Array> {
|
|
2306
2342
|
// stdout is ALWAYS present in the artifact, even if empty, so the
|
|
2307
2343
|
// layout is predictable: a successful read finds `stdout` and
|
|
@@ -2323,9 +2359,9 @@ export class Cache implements CacheLayer {
|
|
|
2323
2359
|
key: args.hash,
|
|
2324
2360
|
stdout: args.entry.stdout ?? '',
|
|
2325
2361
|
outputs: this.outputsOf(args),
|
|
2362
|
+
dirs: args.outputDirs?.map((d) => `outputs/${relPosix(args.projectDir, d)}`),
|
|
2326
2363
|
exec: usageOfEntry(args.entry),
|
|
2327
|
-
|
|
2328
|
-
readable: args.readable,
|
|
2364
|
+
root: args.workspaceRoot ?? args.projectDir,
|
|
2329
2365
|
})
|
|
2330
2366
|
if (plan.size > this.artifactCeiling) {
|
|
2331
2367
|
throw new Error(
|
|
@@ -2463,7 +2499,10 @@ export class Cache implements CacheLayer {
|
|
|
2463
2499
|
: `artifact was stored under another key (${scanned.key})`,
|
|
2464
2500
|
)
|
|
2465
2501
|
}
|
|
2466
|
-
assertArtifactNames(hash, scanned.entries, meta.outputs
|
|
2502
|
+
assertArtifactNames(hash, scanned.entries, meta.outputs, [
|
|
2503
|
+
...scanned.dirs,
|
|
2504
|
+
...scanned.links.map(([name]) => name),
|
|
2505
|
+
])
|
|
2467
2506
|
} catch (err) {
|
|
2468
2507
|
if (tmpPath !== undefined) await unlink(tmpPath).catch(() => undefined)
|
|
2469
2508
|
if (err instanceof ArchiveSecurityError || err instanceof CorruptArtifactError) throw err
|
|
@@ -2496,6 +2535,28 @@ export class Cache implements CacheLayer {
|
|
|
2496
2535
|
if (rowPath === null) continue
|
|
2497
2536
|
outputFileRows.push([rowPath, e.size, e.mode & 0o777, Math.floor(e.mtimeMs)])
|
|
2498
2537
|
}
|
|
2538
|
+
// An empty directory's row is its rel with a trailing slash: the hit's
|
|
2539
|
+
// check asks only that a directory stands there.
|
|
2540
|
+
for (const d of scanned.dirs) {
|
|
2541
|
+
outputFileRows.push([
|
|
2542
|
+
`${d.startsWith('outputs/') ? d.slice('outputs/'.length) : d}/`,
|
|
2543
|
+
0,
|
|
2544
|
+
0,
|
|
2545
|
+
0,
|
|
2546
|
+
])
|
|
2547
|
+
}
|
|
2548
|
+
// A link's row: its rel, the target's length and CRC-32 where a file
|
|
2549
|
+
// has its size and mtime, and the link type in the mode, so the hit's
|
|
2550
|
+
// check compares the target and never follows it.
|
|
2551
|
+
for (const [name, to] of scanned.links) {
|
|
2552
|
+
const bytes = new TextEncoder().encode(to)
|
|
2553
|
+
outputFileRows.push([
|
|
2554
|
+
name.startsWith('outputs/') ? name.slice('outputs/'.length) : name,
|
|
2555
|
+
bytes.byteLength,
|
|
2556
|
+
LINK_MODE,
|
|
2557
|
+
Bun.hash.crc32(bytes),
|
|
2558
|
+
])
|
|
2559
|
+
}
|
|
2499
2560
|
const stdoutText = scanned.stdout
|
|
2500
2561
|
|
|
2501
2562
|
const [project, task] = splitTaskId(meta.taskId)
|
|
@@ -2665,13 +2726,18 @@ export class Cache implements CacheLayer {
|
|
|
2665
2726
|
}
|
|
2666
2727
|
|
|
2667
2728
|
// --- run history: delegated to `RunHistory` (see run-history.ts) ---
|
|
2729
|
+
// A run over a directory it cannot write records no history:
|
|
2730
|
+
// `readOnlyNotice` said so once.
|
|
2668
2731
|
recordRun(run: RunRecord): void {
|
|
2732
|
+
if (this.writeBlocked !== null) return
|
|
2669
2733
|
return this.guard(() => this.history.recordRun(run))
|
|
2670
2734
|
}
|
|
2671
2735
|
recordRuns(runs: readonly RunRecord[]): void {
|
|
2736
|
+
if (this.writeBlocked !== null) return
|
|
2672
2737
|
return this.guard(() => this.history.recordRuns(runs))
|
|
2673
2738
|
}
|
|
2674
2739
|
recordRunBundle(bundle: { runs: readonly RunRecord[]; invocation: InvocationRecord }): void {
|
|
2740
|
+
if (this.writeBlocked !== null) return
|
|
2675
2741
|
return this.guard(() => this.history.recordRunBundle(bundle))
|
|
2676
2742
|
}
|
|
2677
2743
|
recordRemoteErrors(runId: string, errors: readonly (readonly [string, string])[]): void {
|
|
@@ -3026,6 +3092,7 @@ export class Cache implements CacheLayer {
|
|
|
3026
3092
|
try {
|
|
3027
3093
|
this.flushAccessed()
|
|
3028
3094
|
this.outputs.flushOutputDirs()
|
|
3095
|
+
this.folds.flush(Date.now() - 30 * 24 * 60 * 60 * 1000)
|
|
3029
3096
|
} catch {
|
|
3030
3097
|
// Same contract as the retention prune above, which was already
|
|
3031
3098
|
// guarded while this sibling was not: `accessed_at` is LRU
|
|
@@ -3083,8 +3150,15 @@ function assertArtifactNames(
|
|
|
3083
3150
|
hash: string,
|
|
3084
3151
|
entries: ReadonlyArray<{ name: string }>,
|
|
3085
3152
|
declared: DeclaredOutputs | undefined,
|
|
3153
|
+
dirs: readonly string[] = [],
|
|
3086
3154
|
): void {
|
|
3087
3155
|
const names = new Set(entries.map((e) => e.name))
|
|
3156
|
+
for (const d of dirs) {
|
|
3157
|
+
if (names.has(d)) {
|
|
3158
|
+
throw new CorruptArtifactError(hash, `artifact holds ${d} as a file and as a directory`)
|
|
3159
|
+
}
|
|
3160
|
+
names.add(d)
|
|
3161
|
+
}
|
|
3088
3162
|
for (const name of names) {
|
|
3089
3163
|
for (let sep = name.indexOf('/'); sep !== -1; sep = name.indexOf('/', sep + 1)) {
|
|
3090
3164
|
if (names.has(name.slice(0, sep))) {
|
|
@@ -3116,7 +3190,7 @@ function assertArtifactNames(
|
|
|
3116
3190
|
*/
|
|
3117
3191
|
function declaredName(declared: DeclaredOutputs): (name: string) => boolean {
|
|
3118
3192
|
const isFile = restorableOutput(declared.files)
|
|
3119
|
-
const isWsFile = restorableOutput(declared.workspaceFiles)
|
|
3193
|
+
const isWsFile = restorableOutput(declared.workspaceFiles, 'workspace')
|
|
3120
3194
|
return (name) =>
|
|
3121
3195
|
name.startsWith('outputs/')
|
|
3122
3196
|
? isFile(name.slice('outputs/'.length))
|
|
@@ -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
|
+
}
|