@vzn/vx 0.0.16 → 0.0.21
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/LICENSE +1 -1
- package/README.md +135 -124
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +11 -7
- package/src/bin.ts +14 -4
- package/src/cache/archive.ts +659 -0
- package/src/cache/cache.ts +769 -1101
- package/src/cache/chained-cache.ts +194 -0
- package/src/cache/config-evals.ts +89 -0
- package/src/cache/file-hashes.ts +262 -0
- package/src/cache/git-inputs.ts +637 -0
- package/src/cache/index.ts +24 -5
- package/src/cache/inputs.ts +373 -456
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +96 -43
- package/src/cache/output-index.ts +224 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +147 -0
- package/src/cache/tar-stream.ts +358 -0
- package/src/cache/zstd.ts +160 -0
- package/src/cli/cache.ts +75 -21
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +195 -128
- package/src/cli/index.ts +116 -46
- package/src/cli/info.ts +122 -63
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +172 -0
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +72 -6
- package/src/cli/plugin-commands.ts +103 -0
- package/src/cli/run.ts +245 -271
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +9 -7
- package/src/cli/watch.ts +858 -110
- package/src/cli/why.ts +223 -0
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +187 -150
- package/src/exec/env.ts +18 -1
- package/src/exec/executor.ts +224 -0
- package/src/exec/index.ts +24 -2
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +166 -40
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +642 -260
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +4 -10
- package/src/graph/scheduler.ts +232 -203
- package/src/graph/task-graph.ts +222 -8
- package/src/index.ts +146 -153
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +147 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +72 -8
- package/src/orchestrator/execute-task.ts +374 -549
- package/src/orchestrator/failure-mode.ts +202 -0
- package/src/orchestrator/framed-output.ts +126 -69
- package/src/orchestrator/history.ts +126 -59
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +77 -93
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +208 -35
- package/src/orchestrator/metrics.ts +102 -1388
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +55 -34
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +357 -120
- package/src/orchestrator/plugin.ts +298 -58
- package/src/orchestrator/prepare.ts +202 -106
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +12 -8
- package/src/orchestrator/run-artifacts.ts +63 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +74 -47
- package/src/orchestrator/run.ts +534 -417
- package/src/orchestrator/sandbox-request.ts +243 -0
- package/src/orchestrator/save-lane.ts +47 -0
- package/src/orchestrator/signals.ts +111 -0
- package/src/orchestrator/stable-keys.ts +129 -31
- package/src/orchestrator/status-line.ts +49 -9
- package/src/orchestrator/summary.ts +90 -26
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +111 -15
- package/src/orchestrator/task-log-buffer.ts +288 -0
- package/src/orchestrator/telemetry-host.ts +34 -6
- package/src/orchestrator/telemetry.ts +182 -36
- package/src/orchestrator/upstream.ts +72 -31
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +70 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +15 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +64 -0
- package/src/util/settle.ts +67 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +250 -23
- package/src/workspace/config-cache.ts +313 -0
- package/src/workspace/config-eval.ts +217 -0
- package/src/workspace/config-imports.ts +162 -0
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +65 -10
- package/src/workspace/fingerprint.ts +53 -4
- package/src/workspace/index.ts +42 -3
- package/src/workspace/lockfile.ts +25 -9
- package/src/workspace/migrate-scripts.ts +134 -0
- package/src/workspace/migration.ts +314 -0
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +114 -41
- package/src/workspace/project-loader.ts +224 -450
- package/src/workspace/workspace.ts +298 -72
- package/src/cache/cas-backend.ts +0 -97
- package/src/cache/digest.ts +0 -47
- package/src/cache/tar.ts +0 -343
- package/src/cli/backend.ts +0 -23
- package/src/cli/mcp-rpc.ts +0 -292
- package/src/cli/mcp.ts +0 -92
- package/src/cli/migrate-nx.ts +0 -450
- package/src/cli/migrate-turbo.ts +0 -364
- package/src/cli/migrate.ts +0 -219
- package/src/orchestrator/devframe-surface.ts +0 -74
- package/src/orchestrator/predict.ts +0 -106
- package/src/orchestrator/protocol.ts +0 -156
- package/src/orchestrator/resources.ts +0 -59
- package/src/orchestrator/run-state.ts +0 -145
- package/src/orchestrator/verify.ts +0 -164
- package/src/orchestrator/wire-render.ts +0 -92
- package/src/orchestrator/wire.ts +0 -212
package/src/cache/cache.ts
CHANGED
|
@@ -20,23 +20,48 @@
|
|
|
20
20
|
// for the user to see — but on a cache hit there's nothing to replay
|
|
21
21
|
// (the original run was successful and stderr typically empty).
|
|
22
22
|
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
// save : persist outputs + stdout under a hash
|
|
28
|
-
// ingest : adopt an artifact produced elsewhere (remote-hit path)
|
|
29
|
-
// recordRun : append a row to the run history table (for stats)
|
|
30
|
-
// close : release the SQLite handle
|
|
23
|
+
// This is core's FLOOR, not a module to replace: remote storage is a
|
|
24
|
+
// `RemoteCacheLayer` (has / get / put) that `LayeredCache` wraps around
|
|
25
|
+
// this handle, declared by a plugin's `cache` hook. The contract every
|
|
26
|
+
// layer speaks is `CacheLayer` in layer.ts; `plugin-host.ts` enforces it.
|
|
31
27
|
|
|
32
28
|
import { Database, type SQLQueryBindings } from 'bun:sqlite'
|
|
33
|
-
import { mkdirSync,
|
|
34
|
-
import { mkdir,
|
|
35
|
-
import os from 'node:os'
|
|
29
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
30
|
+
import { mkdir, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises'
|
|
36
31
|
import path from 'node:path'
|
|
37
|
-
import { relPosix, UserError, xxh3, xxh3hex } from '../util/index.js'
|
|
38
|
-
import {
|
|
39
|
-
|
|
32
|
+
import { relPosix, UserError, xxh3, xxh3hex, span } from '../util/index.js'
|
|
33
|
+
import {
|
|
34
|
+
ArchiveSecurityError,
|
|
35
|
+
type ExecUsage,
|
|
36
|
+
extractArtifactStream,
|
|
37
|
+
scanArtifact,
|
|
38
|
+
packArtifactBytes,
|
|
39
|
+
packArtifactStream,
|
|
40
|
+
planArtifact,
|
|
41
|
+
} from './archive.js'
|
|
42
|
+
import {
|
|
43
|
+
type CacheEntry,
|
|
44
|
+
type CacheGetContext,
|
|
45
|
+
type CacheKeyInput,
|
|
46
|
+
type CacheLayer,
|
|
47
|
+
type CacheStats,
|
|
48
|
+
type CacheStatsOptions,
|
|
49
|
+
CorruptArtifactError,
|
|
50
|
+
type IngestMeta,
|
|
51
|
+
type InvocationRecord,
|
|
52
|
+
type OutputDirRow,
|
|
53
|
+
type OutputFileRow,
|
|
54
|
+
type PruneOptions,
|
|
55
|
+
type PruneResult,
|
|
56
|
+
type RunRecord,
|
|
57
|
+
type SaveArgs,
|
|
58
|
+
type TaskInputRow,
|
|
59
|
+
} from './layer.js'
|
|
60
|
+
import { bytesOf, decodedTar, STREAM_DECODE_FROM, zstdEncoder } from './zstd.js'
|
|
61
|
+
import { ConfigEvalTable } from './config-evals.js'
|
|
62
|
+
import { FileHashStore } from './file-hashes.js'
|
|
63
|
+
import { OutputIndex } from './output-index.js'
|
|
64
|
+
import { RunHistory } from './run-history.js'
|
|
40
65
|
|
|
41
66
|
// v17: artifact carries only logs + outputs (stdout + outputs/<rel>).
|
|
42
67
|
// Local and remote layers transport the SAME tar.zst bytes — no
|
|
@@ -75,7 +100,51 @@ import { extractOutputs, parseTarHeaders, readTarText } from './tar.js'
|
|
|
75
100
|
// inside the key fold (the new `captureInto` sink is a pure
|
|
76
101
|
// side-channel). A task's hash is byte-identical before and after, so
|
|
77
102
|
// existing artifacts stay valid and there is no cache-version bump.
|
|
78
|
-
|
|
103
|
+
// v25: the ARTIFACT BYTES are wrong in every existing entry, while the
|
|
104
|
+
// key that addresses them is unchanged — the one situation a version
|
|
105
|
+
// bump exists for. Two defects: (a) `packArtifact` staged outputs with
|
|
106
|
+
// `Bun.write`, which does not carry the source mode, so every artifact
|
|
107
|
+
// records 0644 and a cache hit strips the executable bit off any binary
|
|
108
|
+
// or generated script; (b) it packed `--format=ustar`, which splits a
|
|
109
|
+
// name over 100 bytes into prefix+name, and the reader read only `name`
|
|
110
|
+
// — so those entries lost their `outputs/` prefix and were silently
|
|
111
|
+
// dropped from both the restore and the output_files index. Neither is
|
|
112
|
+
// self-healing: the stored bytes/headers are already wrong, so without a
|
|
113
|
+
// new namespace the fixed code would keep replaying them forever.
|
|
114
|
+
// v26: same situation, different producer. A task whose child was killed by
|
|
115
|
+
// a shutdown signal reported `aborted`, but `aborted` did not propagate to
|
|
116
|
+
// dependents — so a dependent ran against PARTIAL outputs, succeeded, and
|
|
117
|
+
// cached what it built. The fold takes the upstream's INPUT key, which a
|
|
118
|
+
// signal does not change, so that entry sits under exactly the key a healthy
|
|
119
|
+
// run derives and replays forever as a green hit. Fixing the propagation
|
|
120
|
+
// stops new poison but cannot reach entries already written, and a
|
|
121
|
+
// `LayeredCache` uploads them, so the reach is a whole team's shared cache
|
|
122
|
+
// rather than one developer's disk. Pre-alpha, so one cold rebuild is the
|
|
123
|
+
// cheap side of that trade.
|
|
124
|
+
export const CACHE_VERSION = 'vx-cache-v27'
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* An artifact or temp file without an `entries` row is reaped by
|
|
128
|
+
* `prune()` only once it is this old. A save renames the artifact into
|
|
129
|
+
* place and commits its row in the same tick; a crashed save's temp is
|
|
130
|
+
* stale long before this. Generous on purpose — a false orphan costs a
|
|
131
|
+
* re-run, a leaked temp costs disk.
|
|
132
|
+
*/
|
|
133
|
+
const ORPHAN_GRACE_MS = 60 * 60 * 1000
|
|
134
|
+
|
|
135
|
+
export interface SchemaReset {
|
|
136
|
+
from: string
|
|
137
|
+
to: string
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Say once, on the channel the opener has, that an upgrade emptied the index. */
|
|
141
|
+
export function noteSchemaReset(cache: Cache, warn: (message: string) => void): void {
|
|
142
|
+
if (cache.schemaReset === null) return
|
|
143
|
+
const { from, to } = cache.schemaReset
|
|
144
|
+
warn(
|
|
145
|
+
`[vx] cache index reset: schema ${from} → ${to} (vx upgraded); every cached task misses once and re-saves, and \`vx cache prune\` reclaims the old artifacts`,
|
|
146
|
+
)
|
|
147
|
+
}
|
|
79
148
|
// SCHEMA history (drop+recreate on mismatch; pre-alpha, no migrations):
|
|
80
149
|
// v20: file_hashes.content_hash (git blob OIDs).
|
|
81
150
|
// v21: dropped the unused outputs_hash column (pure-input hashing).
|
|
@@ -94,578 +163,74 @@ const CACHE_VERSION = 'vx-cache-v24'
|
|
|
94
163
|
// passed under identical inputs is nondeterministic by definition;
|
|
95
164
|
// no cross-run inference needed). Nullable; NULL for a once-run
|
|
96
165
|
// task. Analytics-only — the cache KEY is unchanged.
|
|
97
|
-
|
|
166
|
+
// v24: file_hashes.ctime_ms + .ino — the stat memo's guard against a
|
|
167
|
+
// content change that PRESERVES mtime (`tar -x`, `cp -p`,
|
|
168
|
+
// `rsync --times`, SOURCE_DATE_EPOCH). (mtime, size) alone
|
|
169
|
+
// returned the previous run's digest for different bytes, i.e. a
|
|
170
|
+
// stale cache hit. `utimes` cannot suppress ctime unprivileged
|
|
171
|
+
// and an atomic write-then-rename changes the inode, so the two
|
|
172
|
+
// together close it; git's index keys on ctime+ino+dev for the
|
|
173
|
+
// same reason. Both come free from the stat already taken. The
|
|
174
|
+
// cache KEY derivation is unchanged (CACHE_VERSION not bumped) —
|
|
175
|
+
// the memo simply stops answering wrongly, so an affected task's
|
|
176
|
+
// key moves from a WRONG value to the right one: it misses once,
|
|
177
|
+
// re-runs, re-caches. Self-healing, never a wrong hit.
|
|
178
|
+
// v25: runs.cached — whether the task declared a `cache` block. An
|
|
179
|
+
// uncached task's row looked like any executed miss, so `vx why`
|
|
180
|
+
// headlined "cache key changed (inputs differ)" for a task that
|
|
181
|
+
// runs every time by design, and `vx last` could not mark it the
|
|
182
|
+
// way the terminal summary does (`no-cache`). Analytics-only —
|
|
183
|
+
// the cache KEY is unchanged.
|
|
184
|
+
// v26: entries.cpu_ms / peak_rss_bytes — what the producing execution
|
|
185
|
+
// used, read out of the artifact's sidecar at save and ingest
|
|
186
|
+
// alike, so a hit (a remote one on a fresh runner above all) can
|
|
187
|
+
// tell the history what the task needs. The cache KEY and the
|
|
188
|
+
// artifact container are unchanged (no CACHE_VERSION bump: an
|
|
189
|
+
// artifact without the field reads as before).
|
|
190
|
+
export const SCHEMA_VERSION = 'v26'
|
|
98
191
|
|
|
99
192
|
/**
|
|
100
|
-
*
|
|
101
|
-
* anchored outputs (`cache.outputs.workspaceFiles`). Project outputs
|
|
102
|
-
* keep their bare project-relative `path` rows; workspace rows store
|
|
103
|
-
* the full `workspace-outputs/<rel-to-root>` tar entry name as the
|
|
104
|
-
* discriminator — least-invasive row format, no schema change. A
|
|
105
|
-
* project output dir literally named `workspace-outputs/` would
|
|
106
|
-
* collide with the namespace; the name is reserved.
|
|
107
|
-
*/
|
|
108
|
-
export const WORKSPACE_OUTPUT_PREFIX = 'workspace-outputs/'
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Independent read/write control over the two cache layers (local +
|
|
112
|
-
* remote). Replaces the old single `noCache` boolean: each axis can be
|
|
113
|
-
* toggled on its own so `--force` (re-execute but still refresh the
|
|
114
|
-
* cache) is distinct from `--no-cache` (disable everything).
|
|
193
|
+
* SQL predicate selecting `runs` rows that record an EXECUTION.
|
|
115
194
|
*
|
|
116
|
-
*
|
|
117
|
-
* `
|
|
118
|
-
*
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
remoteRead: boolean
|
|
124
|
-
remoteWrite: boolean
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/** All four axes on — the default when no cache flag is passed. */
|
|
128
|
-
export const FULL_CACHE_POLICY: CachePolicy = {
|
|
129
|
-
localRead: true,
|
|
130
|
-
localWrite: true,
|
|
131
|
-
remoteRead: true,
|
|
132
|
-
remoteWrite: true,
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Parse a `--cache=<spec>` value into a `CachePolicy`, starting from a
|
|
137
|
-
* base (defaults to {@link FULL_CACHE_POLICY}). The spec is a
|
|
138
|
-
* comma-separated list of `layer:flags` segments where `layer` is
|
|
139
|
-
* `local` or `remote` and `flags` is any subset of `r` (read) and `w`
|
|
140
|
-
* (write), order-independent and possibly empty.
|
|
141
|
-
*
|
|
142
|
-
* A mentioned layer is set EXACTLY to its flags (read = includes `r`,
|
|
143
|
-
* write = includes `w`); an unmentioned layer keeps its base value.
|
|
144
|
-
* So `local:rw,remote:r` = remote read-only, `remote:` = remote fully
|
|
145
|
-
* off, `local:r` = local read-only with remote untouched.
|
|
195
|
+
* Every non-group, non-aborted outcome of a run gets a row, so the header's
|
|
196
|
+
* `task_count` matches `COUNT(*)` and the run-detail timeline is complete.
|
|
197
|
+
* But a `skipped` row is a task the run never executed — its upstream failed,
|
|
198
|
+
* so it has no exit of its own, no duration, and made no cache decision.
|
|
199
|
+
* Counting it in a RATE or a MEAN dilutes that figure with a non-event: a
|
|
200
|
+
* task skipped as often as it succeeds reads 50% success, and a zero-duration
|
|
201
|
+
* row drags every average toward zero.
|
|
146
202
|
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
203
|
+
* Lives here, beside the schema, because the same figure is computed in more
|
|
204
|
+
* than one place (`Cache.stats` and the run-history queries both answer
|
|
205
|
+
* "runs in the last 24h") and two copies of a rule are how they drift apart.
|
|
149
206
|
*/
|
|
150
|
-
export
|
|
151
|
-
const out: CachePolicy = { ...base }
|
|
152
|
-
const seen = new Set<string>()
|
|
153
|
-
for (const rawSeg of spec.split(',')) {
|
|
154
|
-
const seg = rawSeg.trim()
|
|
155
|
-
if (seg.length === 0) continue
|
|
156
|
-
const colon = seg.indexOf(':')
|
|
157
|
-
if (colon < 0) {
|
|
158
|
-
throw new UserError(`invalid --cache segment '${seg}': expected '<layer>:<flags>'`)
|
|
159
|
-
}
|
|
160
|
-
const layer = seg.slice(0, colon)
|
|
161
|
-
const flags = seg.slice(colon + 1)
|
|
162
|
-
if (layer !== 'local' && layer !== 'remote') {
|
|
163
|
-
throw new UserError(`invalid --cache layer '${layer}': expected 'local' or 'remote'`)
|
|
164
|
-
}
|
|
165
|
-
if (seen.has(layer)) {
|
|
166
|
-
throw new UserError(`--cache layer '${layer}' specified twice`)
|
|
167
|
-
}
|
|
168
|
-
seen.add(layer)
|
|
169
|
-
const flagSet = new Set<string>()
|
|
170
|
-
for (const ch of flags) {
|
|
171
|
-
if (ch !== 'r' && ch !== 'w') {
|
|
172
|
-
throw new UserError(`invalid --cache flag '${ch}' for '${layer}': expected 'r' and/or 'w'`)
|
|
173
|
-
}
|
|
174
|
-
if (flagSet.has(ch)) {
|
|
175
|
-
throw new UserError(`--cache flag '${ch}' repeated for '${layer}'`)
|
|
176
|
-
}
|
|
177
|
-
flagSet.add(ch)
|
|
178
|
-
}
|
|
179
|
-
if (layer === 'local') {
|
|
180
|
-
out.localRead = flagSet.has('r')
|
|
181
|
-
out.localWrite = flagSet.has('w')
|
|
182
|
-
} else {
|
|
183
|
-
out.remoteRead = flagSet.has('r')
|
|
184
|
-
out.remoteWrite = flagSet.has('w')
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
return out
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
export interface CacheKeyInput {
|
|
191
|
-
taskId: string
|
|
192
|
-
/**
|
|
193
|
-
* Hash of the resolved task config (post-evaluation). Folds in everything
|
|
194
|
-
* the user wrote — command, env declarations (passThrough names + define
|
|
195
|
-
* key/value pairs), dependsOn, cache.inputs declarations, outputs — including
|
|
196
|
-
* values that arrived via `import` at config-load time.
|
|
197
|
-
*/
|
|
198
|
-
taskConfigHash: string
|
|
199
|
-
/**
|
|
200
|
-
* Runtime values of declared cache-input env names (from parent at hash
|
|
201
|
-
* time). Independent of `exec.env`; lives here for cache identity.
|
|
202
|
-
*/
|
|
203
|
-
envValues: Array<[name: string, value: string]>
|
|
204
|
-
/**
|
|
205
|
-
* Resolved `cache.inputs.runtime` commands as [command, output] pairs
|
|
206
|
-
* (output = trimmed stdout+stderr, resolved live at hash time). Folded
|
|
207
|
-
* into the key in a namespace distinct from workspaceRuntimeValues.
|
|
208
|
-
*/
|
|
209
|
-
runtimeValues?: Array<[command: string, output: string]>
|
|
210
|
-
/** Resolved `cache.inputs.workspaceRuntime` pairs (root-cwd commands). */
|
|
211
|
-
workspaceRuntimeValues?: Array<[command: string, output: string]>
|
|
212
|
-
/** Absolute paths to input files. */
|
|
213
|
-
inputFiles: string[]
|
|
214
|
-
workspaceRoot: string
|
|
215
|
-
/** Cache keys of upstream tasks this one depends on, sorted. */
|
|
216
|
-
upstreamHashes: string[]
|
|
217
|
-
/**
|
|
218
|
-
* Upstream hash → upstream task id, for `captureInto` row NAMING only.
|
|
219
|
-
* Never folded into the digest — the key already folds the sorted
|
|
220
|
-
* hashes. Lets the persisted `entry_inputs` row name which upstream
|
|
221
|
-
* task a hash came from (the diff reads better than a bare hash). When
|
|
222
|
-
* absent, the captured `upstream` row falls back to `name = hash`.
|
|
223
|
-
*/
|
|
224
|
-
upstreamIds?: ReadonlyMap<string, string>
|
|
225
|
-
/**
|
|
226
|
-
* Workspace-level fingerprint — typically a hash of `pnpm-lock.yaml` +
|
|
227
|
-
* `pnpm-workspace.yaml`. Folds resolved dep versions and workspace shape
|
|
228
|
-
* into every task's key, so a lockfile bump invalidates everything.
|
|
229
|
-
*/
|
|
230
|
-
workspaceFingerprint: string
|
|
231
|
-
/**
|
|
232
|
-
* CLI args forwarded to the task (after `--`). Folded into the key so that
|
|
233
|
-
* the same command with different forwarded args is treated as a distinct
|
|
234
|
-
* run, never a spurious cache hit.
|
|
235
|
-
*/
|
|
236
|
-
forwardArgs?: readonly string[]
|
|
237
|
-
/**
|
|
238
|
-
* Hash of the project's `package.json` bytes. Folded into the key
|
|
239
|
-
* implicitly (Turbo / Nx parity) so dep changes invalidate every
|
|
240
|
-
* task in that project, even when `cache.inputs.files` doesn't
|
|
241
|
-
* cover package.json. Empty string when the project has no
|
|
242
|
-
* package.json (impossible in practice — workspace discovery
|
|
243
|
-
* requires one — but we don't fail-loud here).
|
|
244
|
-
*/
|
|
245
|
-
projectPackageJsonHash: string
|
|
246
|
-
/**
|
|
247
|
-
* Precomputed content hashes (git blob OIDs) keyed by absolute
|
|
248
|
-
* path — typically the trusted-index OID map harvested by the
|
|
249
|
-
* run's bulk `git ls-files -s`. Paths present here skip `hashFile`
|
|
250
|
-
* entirely (no stat, no SQLite, no read); missing paths fall back
|
|
251
|
-
* to `hashFile`, which computes the byte-identical blob OID from
|
|
252
|
-
* disk. Pure fast path: the derived key never depends on whether a
|
|
253
|
-
* hash arrived via the map or the fallback.
|
|
254
|
-
*/
|
|
255
|
-
fileHashes?: ReadonlyMap<string, string>
|
|
256
|
-
/**
|
|
257
|
-
* When set, `key()` pushes each component (kind, name, hash) it folds
|
|
258
|
-
* — at the same fold sites, in fold order. Pure SIDE-CHANNEL: it does
|
|
259
|
-
* not change the returned digest in any way. On a cache MISS the
|
|
260
|
-
* orchestrator allocates an array, passes it here, and persists the
|
|
261
|
-
* rows to `entry_inputs` (inside the entry-save transaction) so a
|
|
262
|
-
* later run can diff its inputs against this one (the Tier-3 "why did
|
|
263
|
-
* this re-run?" moat). Capturing at the fold sites keeps the
|
|
264
|
-
* recorded set in lockstep with the key by construction: a future
|
|
265
|
-
* component that forgets to capture is a one-line miss, not silent
|
|
266
|
-
* drift. The per-file OIDs are already awaited here, so file rows cost
|
|
267
|
-
* zero extra I/O — just array pushes.
|
|
268
|
-
*/
|
|
269
|
-
captureInto?: Array<{ kind: string; name: string; hash: string }>
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
export interface CacheEntry {
|
|
273
|
-
hash: string
|
|
274
|
-
taskId: string
|
|
275
|
-
command: string
|
|
276
|
-
exitCode: number
|
|
277
|
-
durationMs: number
|
|
278
|
-
outputFiles: string[]
|
|
279
|
-
/** Captured stdout, always present (may be empty). stderr is not cached. */
|
|
280
|
-
stdout: string
|
|
281
|
-
storedAt: string
|
|
282
|
-
/**
|
|
283
|
-
* Where this hit was resolved from. `'local'` for a SQLite-backed
|
|
284
|
-
* Cache; `'remote'` when LayeredCache pulled the artifact from the
|
|
285
|
-
* remote layer this lookup (even though it's been materialized into
|
|
286
|
-
* local for next time). Lets the orchestrator surface
|
|
287
|
-
* `cache-hit-remote` so users see when remote caching actually saved
|
|
288
|
-
* them work vs. a stale-local replay.
|
|
289
|
-
*/
|
|
290
|
-
source?: 'local' | 'remote'
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
export interface RunRecord {
|
|
294
|
-
hash: string
|
|
295
|
-
project: string
|
|
296
|
-
task: string
|
|
297
|
-
status: 'success' | 'failed' | 'cache-hit' | 'cache-hit-remote' | 'skipped'
|
|
298
|
-
exitCode: number
|
|
299
|
-
durationMs: number
|
|
300
|
-
forwardArgs?: readonly string[]
|
|
301
|
-
startedAt: number // ms-epoch wall clock
|
|
302
|
-
endedAt: number // ms-epoch wall clock
|
|
303
|
-
/**
|
|
304
|
-
* Optional analytics columns. Populated by the orchestrator/runner;
|
|
305
|
-
* stored as NULL on rows from older runs. Surfaced via `vx stats`
|
|
306
|
-
* and consumable from CI by reading cache.db directly.
|
|
307
|
-
*/
|
|
308
|
-
runId?: string // ULID shared across every task in one `vx run` invocation
|
|
309
|
-
cpuMs?: number // sum of user + system CPU time for the child process
|
|
310
|
-
peakRssBytes?: number // peak resident set size of the child process
|
|
311
|
-
wallclockStartNs?: bigint // hrtime span relative to run t=0
|
|
312
|
-
wallclockEndNs?: bigint
|
|
313
|
-
cacheHit?: boolean // convenience for flamegraph color; derivable from status
|
|
314
|
-
attempts?: number // >1 when the task retried; the direct within-run flaky signal
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* One header row per `vx run` invocation (the `invocations` table). All
|
|
319
|
-
* fields mirror the columns; nullable VCS/host columns are `null` when
|
|
320
|
-
* the probe failed (not a git repo, hostname unavailable). Recorded
|
|
321
|
-
* once per run inside the same transaction as the per-task `runs` rows.
|
|
322
|
-
*/
|
|
323
|
-
export interface InvocationRecord {
|
|
324
|
-
runId: string
|
|
325
|
-
command: string
|
|
326
|
-
/** JSON-serialized `string[]` of the requested task names. */
|
|
327
|
-
requestedTasks: string
|
|
328
|
-
/** Compact policy flags, e.g. `'lR,lW,rR,rW'` (an axis omitted = off). */
|
|
329
|
-
cachePolicy: string
|
|
330
|
-
concurrency: number
|
|
331
|
-
flow: 'focused' | 'broad' | null
|
|
332
|
-
startedAt: number
|
|
333
|
-
endedAt: number
|
|
334
|
-
totalDurationMs: number
|
|
335
|
-
taskCount: number
|
|
336
|
-
failedCount: number
|
|
337
|
-
hitCount: number
|
|
338
|
-
hitLocalCount: number
|
|
339
|
-
hitRemoteCount: number
|
|
340
|
-
exitOk: boolean
|
|
341
|
-
commitSha: string | null
|
|
342
|
-
branch: string | null
|
|
343
|
-
dirty: boolean | null
|
|
344
|
-
ci: boolean
|
|
345
|
-
ciProvider: string | null
|
|
346
|
-
host: string | null
|
|
347
|
-
os: string | null
|
|
348
|
-
arch: string | null
|
|
349
|
-
vxVersion: string
|
|
350
|
-
/** JSON object `{k:v}` of `--tag` pairs. */
|
|
351
|
-
tags: string
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
/**
|
|
355
|
-
* One cache-key component row for the `entry_inputs` table — keyed by
|
|
356
|
-
* the cache-entry HASH it belongs to, not a run. Written inside the
|
|
357
|
-
* entry-save transaction (`writeArtifactAndIndex`) on a miss/save; a
|
|
358
|
-
* cache HIT does not save, so it persists nothing (warm runs are free).
|
|
359
|
-
* The diff (Phase B/B1) reads these by the run's task hash
|
|
360
|
-
* (`runs.hash → entry_inputs[entry_hash]`).
|
|
361
|
-
*/
|
|
362
|
-
export interface TaskInputRow {
|
|
363
|
-
entryHash: string
|
|
364
|
-
kind: string
|
|
365
|
-
name: string
|
|
366
|
-
hash: string
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
export interface CacheStats {
|
|
370
|
-
entryCount: number
|
|
371
|
-
totalBytes: number
|
|
372
|
-
runCountLast24h: number
|
|
373
|
-
hitCountLast24h: number
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
export interface PruneOptions {
|
|
377
|
-
/** Drop entries last accessed before this ms-epoch threshold. */
|
|
378
|
-
olderThanMs?: number
|
|
379
|
-
/**
|
|
380
|
-
* After applying olderThanMs, if the cache still exceeds this size in
|
|
381
|
-
* bytes, evict LRU (smallest `accessed_at` first) until under it.
|
|
382
|
-
*/
|
|
383
|
-
maxBytes?: number
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
export interface PruneResult {
|
|
387
|
-
evicted: number
|
|
388
|
-
bytesFreed: number
|
|
389
|
-
}
|
|
207
|
+
export const EXECUTED_RUNS_SQL = "status <> 'skipped'"
|
|
390
208
|
|
|
391
209
|
/**
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
* `
|
|
395
|
-
*
|
|
396
|
-
* plus N parallel stat calls.
|
|
397
|
-
*
|
|
398
|
-
* `path` is project-relative (e.g. `dist/index.js`), matching how
|
|
399
|
-
* outputs are addressed under `<projectDir>/` — except workspace
|
|
400
|
-
* outputs, which carry the full `workspace-outputs/<rel-to-root>`
|
|
401
|
-
* tar entry name (see `WORKSPACE_OUTPUT_PREFIX`); callers split on
|
|
402
|
-
* the prefix and anchor those at the workspace root.
|
|
210
|
+
* SQL predicate selecting `runs` rows that recorded a cache key. `hash` is
|
|
211
|
+
* `''` for an outcome that never derived one (see {@link RunRecord.hash}),
|
|
212
|
+
* and `''` is not a key: it can neither corroborate flakiness nor say the
|
|
213
|
+
* inputs changed. Mirrors the `hash <> ''` guards in the cloud analytics copy.
|
|
403
214
|
*/
|
|
404
|
-
export
|
|
405
|
-
path: string
|
|
406
|
-
size: number
|
|
407
|
-
mode: number
|
|
408
|
-
mtimeMs: number
|
|
409
|
-
}
|
|
215
|
+
export const KEYED_RUNS_SQL = "hash <> ''"
|
|
410
216
|
|
|
411
217
|
/**
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
*
|
|
419
|
-
* may resolve through the remote layer — the local SQL row inserted on
|
|
420
|
-
* remote-hit needs `taskId` + `command` to be queryable later (the
|
|
421
|
-
* artifact itself doesn't carry them). `Cache` (local) ignores this
|
|
422
|
-
* field; `LayeredCache` forwards it to `Cache.ingest`.
|
|
423
|
-
*/
|
|
424
|
-
export interface CacheGetContext {
|
|
425
|
-
taskId: string
|
|
426
|
-
command: string
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
/** Metadata supplied at ingest time — values the artifact does not carry. */
|
|
430
|
-
export interface IngestMeta {
|
|
431
|
-
taskId: string
|
|
432
|
-
command: string
|
|
433
|
-
/** Wall-clock time of the original task execution. */
|
|
434
|
-
durationMs: number
|
|
435
|
-
/**
|
|
436
|
-
* Cache-key components (Tier-3 input fingerprint) to persist into
|
|
437
|
-
* `entry_inputs` in the same transaction as the entry row. Omitted on
|
|
438
|
-
* the remote-hit ingest path (the artifact doesn't carry them — the
|
|
439
|
-
* fingerprint is the saving machine's local moat). Only computed on a
|
|
440
|
-
* miss/save, so a cache hit never reaches here.
|
|
441
|
-
*/
|
|
442
|
-
inputComponents?: readonly TaskInputRow[]
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
/**
|
|
446
|
-
* The supplied artifact bytes don't decompress/parse as a vx artifact.
|
|
447
|
-
* Thrown by `save`/`ingest` BEFORE anything reaches the final cache
|
|
448
|
-
* path — a rejected artifact leaves no `<hash>.tar.zst` and no SQL row.
|
|
449
|
-
* The LayeredCache treats this as a remote fault on the remote-hit
|
|
450
|
-
* path (degrades to a cache miss).
|
|
451
|
-
*/
|
|
452
|
-
export class CorruptArtifactError extends Error {
|
|
453
|
-
constructor(
|
|
454
|
-
public readonly hash: string,
|
|
455
|
-
reason: string,
|
|
456
|
-
public override readonly cause?: unknown,
|
|
457
|
-
) {
|
|
458
|
-
super(`cache: corrupt artifact for ${hash}: ${reason}`)
|
|
459
|
-
this.name = 'CorruptArtifactError'
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
/**
|
|
464
|
-
* A decompressed artifact above this is refused as a zstd bomb rather than
|
|
465
|
-
* expanded into memory. 2 GiB comfortably exceeds any real build output while
|
|
466
|
-
* bounding a malicious/compromised remote's ability to OOM a victim who takes
|
|
467
|
-
* a cache hit.
|
|
468
|
-
*/
|
|
469
|
-
export const MAX_DECOMPRESSED_ARTIFACT_BYTES = 2 * 1024 * 1024 * 1024
|
|
470
|
-
|
|
471
|
-
/**
|
|
472
|
-
* Read a zstd frame's declared Frame_Content_Size (RFC 8878 §3.1.1) WITHOUT
|
|
473
|
-
* decompressing. Returns null when the frame omits it (streaming frames) or
|
|
474
|
-
* the header is too short to parse. vx's own producer (single-shot
|
|
475
|
-
* `Bun.zstdCompress` of a known buffer) always writes it, so an artifact that
|
|
476
|
-
* declares an enormous size can be rejected before a byte is allocated.
|
|
477
|
-
*
|
|
478
|
-
* Exported for tests: pins the per-`fcsFlag` byte layouts (incl. the 2-byte
|
|
479
|
-
* `+256` adjustment and the `dictIdFlag` offset) that a bomb-refusal e2e can't
|
|
480
|
-
* discriminate (their max declarable sizes sit below the ceiling).
|
|
481
|
-
*/
|
|
482
|
-
export function zstdContentSize(b: Uint8Array): bigint | null {
|
|
483
|
-
if (b.length < 5) return null
|
|
484
|
-
// Magic_Number 0xFD2FB528, little-endian.
|
|
485
|
-
if (b[0] !== 0x28 || b[1] !== 0xb5 || b[2] !== 0x2f || b[3] !== 0xfd) return null
|
|
486
|
-
const desc = b[4]!
|
|
487
|
-
const fcsFlag = desc >> 6
|
|
488
|
-
const singleSegment = (desc >> 5) & 1
|
|
489
|
-
const dictIdFlag = desc & 3
|
|
490
|
-
let off = 5
|
|
491
|
-
if (singleSegment === 0) off += 1 // Window_Descriptor byte
|
|
492
|
-
off += dictIdFlag === 3 ? 4 : dictIdFlag // Dictionary_ID: 0,1,2,4 bytes
|
|
493
|
-
let fcsSize: number
|
|
494
|
-
if (fcsFlag === 0) fcsSize = singleSegment === 1 ? 1 : 0
|
|
495
|
-
else if (fcsFlag === 1) fcsSize = 2
|
|
496
|
-
else if (fcsFlag === 2) fcsSize = 4
|
|
497
|
-
else fcsSize = 8
|
|
498
|
-
if (fcsSize === 0 || b.length < off + fcsSize) return null
|
|
499
|
-
let v = 0n
|
|
500
|
-
for (let i = 0; i < fcsSize; i++) v |= BigInt(b[off + i]!) << BigInt(8 * i)
|
|
501
|
-
if (fcsSize === 2) v += 256n // per spec, the 2-byte field stores value − 256
|
|
502
|
-
return v
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
/**
|
|
506
|
-
* Decompress a zstd artifact with a hard output ceiling. `trusted` marks a
|
|
507
|
-
* locally-produced artifact (always carries a declared content size); an
|
|
508
|
-
* UNTRUSTED (remote-sourced) frame that declares no content size is refused,
|
|
509
|
-
* since our producer and the Turbo servers we interop with always write one —
|
|
510
|
-
* a sizeless frame from the network is the shape a bomb takes to dodge the
|
|
511
|
-
* pre-decompress check.
|
|
218
|
+
* Artifact + `output_files` namespace prefix for workspace-root-
|
|
219
|
+
* anchored outputs (`cache.outputs.workspaceFiles`). Project outputs
|
|
220
|
+
* keep their bare project-relative `path` rows; workspace rows store
|
|
221
|
+
* the full `workspace-outputs/<rel-to-root>` tar entry name as the
|
|
222
|
+
* discriminator — least-invasive row format, no schema change. A
|
|
223
|
+
* project output dir literally named `workspace-outputs/` would
|
|
224
|
+
* collide with the namespace; the name is reserved.
|
|
512
225
|
*/
|
|
513
|
-
|
|
514
|
-
compressed: Uint8Array,
|
|
515
|
-
hash: string,
|
|
516
|
-
trusted: boolean,
|
|
517
|
-
): Promise<Uint8Array> {
|
|
518
|
-
const declared = zstdContentSize(compressed)
|
|
519
|
-
if (declared !== null && declared > BigInt(MAX_DECOMPRESSED_ARTIFACT_BYTES)) {
|
|
520
|
-
throw new CorruptArtifactError(
|
|
521
|
-
hash,
|
|
522
|
-
`declares ${declared} decompressed bytes (> ${MAX_DECOMPRESSED_ARTIFACT_BYTES} cap)`,
|
|
523
|
-
)
|
|
524
|
-
}
|
|
525
|
-
if (declared === null && !trusted) {
|
|
526
|
-
throw new CorruptArtifactError(hash, 'remote zstd frame carries no declared content size')
|
|
527
|
-
}
|
|
528
|
-
const out = await Bun.zstdDecompress(compressed)
|
|
529
|
-
if (out.length > MAX_DECOMPRESSED_ARTIFACT_BYTES) {
|
|
530
|
-
throw new CorruptArtifactError(
|
|
531
|
-
hash,
|
|
532
|
-
`decompressed to ${out.length} bytes (> ${MAX_DECOMPRESSED_ARTIFACT_BYTES} cap)`,
|
|
533
|
-
)
|
|
534
|
-
}
|
|
535
|
-
return out
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
export interface CacheLayer {
|
|
539
|
-
key(input: CacheKeyInput): Promise<string>
|
|
540
|
-
get(hash: string, ctx?: CacheGetContext): Promise<CacheEntry | null>
|
|
541
|
-
/**
|
|
542
|
-
* Lightweight existence probe. `'local'` / `'remote'` names the layer
|
|
543
|
-
* that holds the artifact; `null` is a miss. NEVER moves bytes: no
|
|
544
|
-
* artifact read, no remote download, no local ingest, no accessed_at
|
|
545
|
-
* bump (the LayeredCache's remote side is an HTTP HEAD). Planning
|
|
546
|
-
* (`--dry` / `--graph`) predicts hits with this instead of `get` so a
|
|
547
|
-
* dry run can't pull N artifacts over the network. Remote errors
|
|
548
|
-
* degrade to `null` — an existence probe never fails anything.
|
|
549
|
-
*/
|
|
550
|
-
has(hash: string): Promise<'local' | 'remote' | null>
|
|
551
|
-
/**
|
|
552
|
-
* Best-effort warm of `hash` from a slower layer (the remote cache)
|
|
553
|
-
* into this one, so a later `get(hash)` resolves locally without a
|
|
554
|
-
* round-trip on the task's critical path. Returns `true` if the
|
|
555
|
-
* artifact is now present locally, `false` on a miss / error
|
|
556
|
-
* (degrades, never throws). The local `Cache` is a no-op (nothing
|
|
557
|
-
* slower to warm from); `LayeredCache` owns the real implementation
|
|
558
|
-
* plus the in-flight de-dup so prefetch + get share ONE remote GET.
|
|
559
|
-
*/
|
|
560
|
-
prefetch(hash: string, ctx?: CacheGetContext): Promise<boolean>
|
|
561
|
-
/**
|
|
562
|
-
* Batched lookup of per-output-file fingerprints for many cache
|
|
563
|
-
* entries in one SQL round-trip. Returns a Map keyed by entry hash.
|
|
564
|
-
*
|
|
565
|
-
* Orchestrator pattern: call this once at `prepareRun` for every
|
|
566
|
-
* task whose hash is known up-front, then per-task `executeCachedTask`
|
|
567
|
-
* does a Map.get (O(1)) + parallel stat checks to decide whether the
|
|
568
|
-
* on-disk tree is already current.
|
|
569
|
-
*
|
|
570
|
-
* Hashes with no rows (cache misses) are absent from the result.
|
|
571
|
-
*/
|
|
572
|
-
loadOutputFilesBatch(hashes: readonly string[]): Map<string, OutputFileRow[]>
|
|
573
|
-
/**
|
|
574
|
-
* Stat each `expected` row's target under `projectDir` and return
|
|
575
|
-
* `true` iff every (size, mode, mtime) matches. Missing files,
|
|
576
|
-
* stat errors, or any mismatch → `false`.
|
|
577
|
-
*
|
|
578
|
-
* Pure FS check — no DB access. Caller batches the expected rows
|
|
579
|
-
* via `loadOutputFilesBatch`. Lets the orchestrator skip
|
|
580
|
-
* `cleanOutputs + restoreOutputs` entirely when the cached
|
|
581
|
-
* snapshot is already in place. Integrity-preserving: detects
|
|
582
|
-
* out-of-band file edits or deletions and falls through to a real
|
|
583
|
-
* restore.
|
|
584
|
-
*/
|
|
585
|
-
isOutputsCurrent(projectDir: string, expected: readonly OutputFileRow[]): Promise<boolean>
|
|
586
|
-
/**
|
|
587
|
-
* Extract the artifact's `outputs/` entries into `projectDir` and —
|
|
588
|
-
* when `workspaceRoot` is given — its `workspace-outputs/` entries
|
|
589
|
-
* into the workspace root. Callers restoring entries that may carry
|
|
590
|
-
* workspace outputs must pass `workspaceRoot`.
|
|
591
|
-
*/
|
|
592
|
-
restoreOutputs(hash: string, projectDir: string, workspaceRoot?: string): Promise<void>
|
|
593
|
-
save(args: {
|
|
594
|
-
hash: string
|
|
595
|
-
entry: Omit<CacheEntry, 'hash' | 'storedAt' | 'outputFiles'>
|
|
596
|
-
projectDir: string
|
|
597
|
-
outputFiles: string[]
|
|
598
|
-
/**
|
|
599
|
-
* Resolved `outputs.workspaceFiles` (absolute paths) + the root
|
|
600
|
-
* they're relative to. Packed under `workspace-outputs/<rel>`.
|
|
601
|
-
* Omitted → artifact bytes identical to the pre-workspaceFiles
|
|
602
|
-
* format.
|
|
603
|
-
*/
|
|
604
|
-
workspaceOutputFiles?: string[]
|
|
605
|
-
workspaceRoot?: string
|
|
606
|
-
/**
|
|
607
|
-
* Cache-key components for this entry (the Tier-3 input
|
|
608
|
-
* fingerprint). Persisted to `entry_inputs` inside the same
|
|
609
|
-
* transaction as the entry row, via `INSERT OR IGNORE` (a re-save
|
|
610
|
-
* of the same hash is a no-op). Omitted/empty → nothing written.
|
|
611
|
-
* Only computed on the miss/save path — never on a hit.
|
|
612
|
-
*/
|
|
613
|
-
inputComponents?: readonly TaskInputRow[]
|
|
614
|
-
}): Promise<void>
|
|
615
|
-
/**
|
|
616
|
-
* Adopt an artifact produced elsewhere — the remote-hit path. Writes
|
|
617
|
-
* the compressed bytes to `<cacheDir>/<hash>.tar.zst`, parses the
|
|
618
|
-
* tar headers to populate the `output_files` rows, and inserts the
|
|
619
|
-
* `entries` row using the caller-supplied `meta`. After this returns,
|
|
620
|
-
* the next `get(hash)` resolves locally.
|
|
621
|
-
*/
|
|
622
|
-
ingest(hash: string, compressed: Uint8Array, meta: IngestMeta): Promise<void>
|
|
623
|
-
recordRun(run: RunRecord): void
|
|
624
|
-
/**
|
|
625
|
-
* Append every run in `runs` to the history in a single SQLite
|
|
626
|
-
* transaction. ~10× faster than calling `recordRun` in a loop when
|
|
627
|
-
* `runs.length > ~50` (one fsync vs. N).
|
|
628
|
-
*/
|
|
629
|
-
recordRuns(runs: readonly RunRecord[]): void
|
|
630
|
-
/**
|
|
631
|
-
* Record a whole `vx run` atomically: the per-task `runs` rows and
|
|
632
|
-
* the one `invocations` header row — in a SINGLE transaction (one
|
|
633
|
-
* fsync). Replaces the bare `recordRuns` call in the orchestrator's
|
|
634
|
-
* end-of-run path. Input-fingerprint rows are NOT written here — they
|
|
635
|
-
* ride the entry-save transaction (`save`/`ingest`) so a warm
|
|
636
|
-
* all-cache-hit run writes nothing.
|
|
637
|
-
*/
|
|
638
|
-
recordRunBundle(bundle: { runs: readonly RunRecord[]; invocation: InvocationRecord }): void
|
|
639
|
-
stats(): CacheStats
|
|
640
|
-
/**
|
|
641
|
-
* Content-hash a file with an mtime+size fast path. If the
|
|
642
|
-
* `(mtime_ms, size_bytes)` of `filePath` match a previously seen
|
|
643
|
-
* row, return the stored xxh3 digest instead of re-reading the
|
|
644
|
-
* bytes. Otherwise read + hash + upsert. The hash is byte-for-byte
|
|
645
|
-
* identical to what a fresh content-hash would produce — pure
|
|
646
|
-
* optimization, no cache-key change.
|
|
647
|
-
*/
|
|
648
|
-
hashFile(filePath: string): Promise<string>
|
|
649
|
-
/**
|
|
650
|
-
* Absolute path to the on-disk outputs artifact for a hash —
|
|
651
|
-
* `<cacheDir>/<hash>.tar.zst`. Returns the path whether or
|
|
652
|
-
* not the artifact exists. Exposed for telemetry / dashboards;
|
|
653
|
-
* `restoreOutputs` is the canonical way to materialize the bytes.
|
|
654
|
-
*/
|
|
655
|
-
outputsPath(hash: string): string
|
|
656
|
-
prune(options: PruneOptions): Promise<PruneResult>
|
|
657
|
-
close(): void
|
|
658
|
-
}
|
|
226
|
+
export const WORKSPACE_OUTPUT_PREFIX = 'workspace-outputs/'
|
|
659
227
|
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
*
|
|
664
|
-
*
|
|
665
|
-
|
|
666
|
-
* @internal
|
|
667
|
-
*/
|
|
668
|
-
export type SaveArgs = Parameters<CacheLayer['save']>[0]
|
|
228
|
+
// The contract, the policy grammar and the zstd framing live beside this
|
|
229
|
+
// file; they are re-exported here so an importer of `./cache.js` — the
|
|
230
|
+
// module index, the sibling layers, the tests — keeps one path.
|
|
231
|
+
export * from './layer.js'
|
|
232
|
+
export * from './policy.js'
|
|
233
|
+
export { zstdContentSize } from './zstd.js'
|
|
669
234
|
|
|
670
235
|
interface EntryRow {
|
|
671
236
|
hash: string
|
|
@@ -678,23 +243,51 @@ interface EntryRow {
|
|
|
678
243
|
stdout: string
|
|
679
244
|
created_at: number
|
|
680
245
|
accessed_at: number
|
|
246
|
+
cpu_ms: number | null
|
|
247
|
+
peak_rss_bytes: number | null
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function entryOf(row: EntryRow, fileRows: OutputFileRow[]): CacheEntry {
|
|
251
|
+
return {
|
|
252
|
+
hash: row.hash,
|
|
253
|
+
taskId: `${row.project}#${row.task}`,
|
|
254
|
+
command: row.command,
|
|
255
|
+
exitCode: row.exit_code,
|
|
256
|
+
durationMs: row.duration_ms,
|
|
257
|
+
...(row.cpu_ms !== null ? { cpuMs: row.cpu_ms } : {}),
|
|
258
|
+
...(row.peak_rss_bytes !== null ? { peakRssBytes: row.peak_rss_bytes } : {}),
|
|
259
|
+
outputFiles: fileRows.map((r) => r.path),
|
|
260
|
+
outputRows: fileRows,
|
|
261
|
+
stdout: row.stdout,
|
|
262
|
+
storedAt: new Date(row.created_at).toISOString(),
|
|
263
|
+
source: 'local',
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The sidecar's `exec` from a save's entry: only the axes the runner reported. */
|
|
268
|
+
function usageOfEntry(entry: { cpuMs?: number; peakRssBytes?: number }): ExecUsage | undefined {
|
|
269
|
+
if (entry.cpuMs === undefined && entry.peakRssBytes === undefined) return undefined
|
|
270
|
+
return {
|
|
271
|
+
...(entry.cpuMs !== undefined ? { cpuMs: entry.cpuMs } : {}),
|
|
272
|
+
...(entry.peakRssBytes !== undefined ? { peakRssBytes: entry.peakRssBytes } : {}),
|
|
273
|
+
}
|
|
681
274
|
}
|
|
682
275
|
|
|
683
276
|
export class Cache implements CacheLayer {
|
|
277
|
+
/** This IS the local layer — there is nothing slower behind it. */
|
|
278
|
+
readonly hasRemote = false
|
|
279
|
+
|
|
684
280
|
private readonly db: Database
|
|
685
281
|
private readonly insertEntry: ReturnType<Database['prepare']>
|
|
686
282
|
private readonly selectEntry: ReturnType<Database['prepare']>
|
|
687
283
|
private readonly bumpAccessed: ReturnType<Database['prepare']>
|
|
688
284
|
private readonly touched = new Set<string>()
|
|
689
|
-
private readonly insertRun: ReturnType<Database['prepare']>
|
|
690
|
-
private readonly insertInvocation: ReturnType<Database['prepare']>
|
|
691
285
|
private readonly insertEntryInput: ReturnType<Database['prepare']>
|
|
692
|
-
|
|
693
|
-
private readonly
|
|
694
|
-
private readonly
|
|
695
|
-
private readonly
|
|
696
|
-
|
|
697
|
-
private objectFormat: 'sha1' | 'sha256' | null = null
|
|
286
|
+
/** The per-file (mtime, size) → blob-OID memo behind `hashFile`. */
|
|
287
|
+
private readonly files: FileHashStore
|
|
288
|
+
private readonly configEvals: ConfigEvalTable
|
|
289
|
+
private readonly outputs: OutputIndex
|
|
290
|
+
private readonly history: RunHistory
|
|
698
291
|
|
|
699
292
|
/**
|
|
700
293
|
* Local-layer read/write gates for the task ARTIFACT path only.
|
|
@@ -706,6 +299,16 @@ export class Cache implements CacheLayer {
|
|
|
706
299
|
private readonly read: boolean
|
|
707
300
|
private readonly write: boolean
|
|
708
301
|
|
|
302
|
+
/**
|
|
303
|
+
* Set when THIS open found an index written by another `SCHEMA_VERSION`
|
|
304
|
+
* and dropped every table. The next open sees the current version and
|
|
305
|
+
* reports null, so whoever opened first is the one that can say so —
|
|
306
|
+
* `noteSchemaReset` is that one line, at every opener that has a
|
|
307
|
+
* channel. Without it an upgrade empties the cache and the history in
|
|
308
|
+
* silence, and the all-miss run that follows looks like a bug.
|
|
309
|
+
*/
|
|
310
|
+
readonly schemaReset: SchemaReset | null = null
|
|
311
|
+
|
|
709
312
|
constructor(
|
|
710
313
|
private readonly cacheDir: string,
|
|
711
314
|
localPolicy: { read: boolean; write: boolean } = { read: true, write: true },
|
|
@@ -717,6 +320,15 @@ export class Cache implements CacheLayer {
|
|
|
717
320
|
// because callers use `new Cache(...)` directly; `mkdirSync` keeps
|
|
718
321
|
// that property without a subprocess fork.
|
|
719
322
|
mkdirSync(cacheDir, { recursive: true })
|
|
323
|
+
// Make the cache dir invisible to git, every time it is created: a
|
|
324
|
+
// `*` .gitignore inside it (the Cargo / Nx convention). Two reasons,
|
|
325
|
+
// both measured. A cache nobody ignored gets COMMITTED by the next
|
|
326
|
+
// `git add -A`; and vx's own `git status -uall` walks it — 1000
|
|
327
|
+
// artifacts doubled the enumeration on the bench workspace before
|
|
328
|
+
// its generator ignored `.vx`. An ignored directory is skipped by the
|
|
329
|
+
// walk entirely. Only written when absent, so a user's own file wins.
|
|
330
|
+
const ignore = path.join(cacheDir, '.gitignore')
|
|
331
|
+
if (!existsSync(ignore)) writeFileSync(ignore, '*\n')
|
|
720
332
|
this.db = new Database(path.join(cacheDir, 'cache.db'), { create: true })
|
|
721
333
|
this.db.exec('PRAGMA journal_mode = WAL')
|
|
722
334
|
this.db.exec('PRAGMA synchronous = NORMAL')
|
|
@@ -737,14 +349,17 @@ export class Cache implements CacheLayer {
|
|
|
737
349
|
// Schema-version gate runs BEFORE the rest of the schema lands so
|
|
738
350
|
// a column rename (e.g. v15's `sha256` → `content_hash`) actually
|
|
739
351
|
// takes effect on stale DBs. Pre-alpha: no migrations, just drop
|
|
740
|
-
// and recreate.
|
|
741
|
-
//
|
|
352
|
+
// and recreate. Artifacts on disk become orphans: a lookup never
|
|
353
|
+
// sees them (rows first, then the file), the next save of the same
|
|
354
|
+
// key renames over them, and `prune()`'s orphan sweep unlinks the
|
|
355
|
+
// rest.
|
|
742
356
|
const meta = this.db.prepare("SELECT value FROM schema_meta WHERE key = 'version'").get() as
|
|
743
357
|
| { value: string }
|
|
744
358
|
| undefined
|
|
745
359
|
if (meta && meta.value !== SCHEMA_VERSION) {
|
|
360
|
+
this.schemaReset = { from: meta.value, to: SCHEMA_VERSION }
|
|
746
361
|
this.db.exec(
|
|
747
|
-
'DROP TABLE IF EXISTS entries; DROP TABLE IF EXISTS runs; DROP TABLE IF EXISTS file_hashes; DROP TABLE IF EXISTS output_files; DROP TABLE IF EXISTS invocations; DROP TABLE IF EXISTS run_task_inputs; DROP TABLE IF EXISTS entry_inputs;',
|
|
362
|
+
'DROP TABLE IF EXISTS entries; DROP TABLE IF EXISTS runs; DROP TABLE IF EXISTS file_hashes; DROP TABLE IF EXISTS output_files; DROP TABLE IF EXISTS invocations; DROP TABLE IF EXISTS run_task_inputs; DROP TABLE IF EXISTS entry_inputs; DROP TABLE IF EXISTS config_evals;',
|
|
748
363
|
)
|
|
749
364
|
this.db.prepare("UPDATE schema_meta SET value = ? WHERE key = 'version'").run(SCHEMA_VERSION)
|
|
750
365
|
} else if (!meta) {
|
|
@@ -753,6 +368,17 @@ export class Cache implements CacheLayer {
|
|
|
753
368
|
.run(SCHEMA_VERSION)
|
|
754
369
|
}
|
|
755
370
|
|
|
371
|
+
// Cached config evaluations (workspace/config-cache.ts): the validated
|
|
372
|
+
// config as JSON, keyed by everything the evaluation could observe. A
|
|
373
|
+
// separate exec so the artifact schema above stays byte-identical.
|
|
374
|
+
this.db.exec(`
|
|
375
|
+
CREATE TABLE IF NOT EXISTS config_evals (
|
|
376
|
+
key TEXT PRIMARY KEY,
|
|
377
|
+
json TEXT NOT NULL,
|
|
378
|
+
created_at INTEGER NOT NULL
|
|
379
|
+
);
|
|
380
|
+
`)
|
|
381
|
+
|
|
756
382
|
this.db.exec(`
|
|
757
383
|
-- stdout/stderr live in the <hash>.tar.zst artifact, not here
|
|
758
384
|
-- (v14+) — so they survive remote round-trips. The entries
|
|
@@ -768,10 +394,15 @@ export class Cache implements CacheLayer {
|
|
|
768
394
|
size_bytes INTEGER NOT NULL,
|
|
769
395
|
stdout TEXT NOT NULL DEFAULT '',
|
|
770
396
|
created_at INTEGER NOT NULL,
|
|
771
|
-
accessed_at INTEGER NOT NULL
|
|
397
|
+
accessed_at INTEGER NOT NULL,
|
|
398
|
+
-- v26: the producing execution's usage, from the artifact's sidecar.
|
|
399
|
+
cpu_ms INTEGER,
|
|
400
|
+
peak_rss_bytes INTEGER
|
|
772
401
|
);
|
|
773
402
|
CREATE TABLE IF NOT EXISTS runs (
|
|
774
403
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
404
|
+
-- '' when the outcome derived no cache key (skipped / persistent).
|
|
405
|
+
-- Every reader that must not mistake it for a key guards hash != ''.
|
|
775
406
|
hash TEXT NOT NULL,
|
|
776
407
|
project TEXT NOT NULL,
|
|
777
408
|
task TEXT NOT NULL,
|
|
@@ -792,13 +423,37 @@ export class Cache implements CacheLayer {
|
|
|
792
423
|
cache_hit INTEGER,
|
|
793
424
|
-- v23: attempts a retried task took (>1); NULL for a once-run task.
|
|
794
425
|
-- The direct within-run flaky signal.
|
|
795
|
-
attempts INTEGER
|
|
426
|
+
attempts INTEGER,
|
|
427
|
+
-- v25: 1 when the task declared a cache block, 0 when it runs every
|
|
428
|
+
-- time by design; NULL on rows older than the column.
|
|
429
|
+
cached INTEGER
|
|
796
430
|
);
|
|
797
|
-
|
|
431
|
+
-- Two whole-table indexes only, and both APPEND: every row of a run carries the
|
|
432
|
+
-- same run_id and a started_at newer than everything before it, so
|
|
433
|
+
-- 1,000 inserts touch a handful of leaf pages. The dropped ones did
|
|
434
|
+
-- not — the DROPs shed them from existing databases (a schema-meta
|
|
435
|
+
-- bump is for stored shapes, and an index is not one):
|
|
436
|
+
-- runs_hash (2026-09-03) had no reader; 11.5 → 3.9 ms per 1,000.
|
|
437
|
+
-- runs_project (project, task) (2026-09-09) scattered every run's
|
|
438
|
+
-- rows over one leaf per pair: the record stage of a 1,000-hit
|
|
439
|
+
-- warm run was 57–79 ms with it and 14–19 ms without, at 166k
|
|
440
|
+
-- rows, and it bought its readers nothing — the history reader
|
|
441
|
+
-- now scans a rowid slice (history.ts), vx why walks
|
|
442
|
+
-- started_at newest-first and stops at the first match.
|
|
443
|
+
-- runs_ended (2026-09-09) served only the retention DELETE, which
|
|
444
|
+
-- prunes on started_at now (a row ends after it starts, so the
|
|
445
|
+
-- 30-day window moves by at most one task's duration).
|
|
446
|
+
DROP INDEX IF EXISTS runs_hash;
|
|
447
|
+
DROP INDEX IF EXISTS runs_project;
|
|
448
|
+
DROP INDEX IF EXISTS runs_ended;
|
|
798
449
|
CREATE INDEX IF NOT EXISTS runs_started_at ON runs(started_at);
|
|
799
|
-
CREATE INDEX IF NOT EXISTS runs_project ON runs(project, task);
|
|
800
|
-
CREATE INDEX IF NOT EXISTS runs_ended ON runs(ended_at);
|
|
801
450
|
CREATE INDEX IF NOT EXISTS runs_run_id ON runs(run_id);
|
|
451
|
+
-- The one keyed index, PARTIAL over failed rows: a green run's
|
|
452
|
+
-- inserts only evaluate its predicate, so the append-only cost above
|
|
453
|
+
-- holds, and the flakiness probe after a miss (failure-mode.ts,
|
|
454
|
+
-- "did this key ever fail?") reads a handful of leaves instead of
|
|
455
|
+
-- scanning the table (2026-09-10: 10–95 ms at 170k rows without it).
|
|
456
|
+
CREATE INDEX IF NOT EXISTS runs_failed ON runs(hash) WHERE status = 'failed';
|
|
802
457
|
-- Per-file (mtime, size, content_hash) cache. Lets Cache.key()
|
|
803
458
|
-- skip the content-hash on inputs whose stat hasn't changed
|
|
804
459
|
-- since the last run. Pure performance optimization; the stored
|
|
@@ -808,6 +463,8 @@ export class Cache implements CacheLayer {
|
|
|
808
463
|
path TEXT PRIMARY KEY,
|
|
809
464
|
mtime_ms INTEGER NOT NULL,
|
|
810
465
|
size_bytes INTEGER NOT NULL,
|
|
466
|
+
ctime_ms INTEGER NOT NULL,
|
|
467
|
+
ino INTEGER NOT NULL,
|
|
811
468
|
content_hash TEXT NOT NULL,
|
|
812
469
|
seen_at INTEGER NOT NULL
|
|
813
470
|
);
|
|
@@ -829,6 +486,27 @@ export class Cache implements CacheLayer {
|
|
|
829
486
|
PRIMARY KEY (entry_hash, path),
|
|
830
487
|
FOREIGN KEY (entry_hash) REFERENCES entries(hash) ON DELETE CASCADE
|
|
831
488
|
);
|
|
489
|
+
-- Every directory under a whole-subtree output glob, with its mtime
|
|
490
|
+
-- as of the last save/restore on THIS machine (2026-09-03). On a warm
|
|
491
|
+
-- hit, unchanged mtimes prove the output SET is unchanged, replacing
|
|
492
|
+
-- the glob walk that cost 0.36 ms per hit. Machine-local: a remote
|
|
493
|
+
-- ingest writes none, and the first hit after it walks and records.
|
|
494
|
+
-- Each config's ORDERED import closure (the config first), so a warm
|
|
495
|
+
-- load keys it by stat-hashing the list (the file_hashes memo) instead
|
|
496
|
+
-- of reading and scanning every file. Machine-local; pruned with
|
|
497
|
+
-- config_evals (2026-09-03).
|
|
498
|
+
CREATE TABLE IF NOT EXISTS config_closures (
|
|
499
|
+
config_path TEXT PRIMARY KEY,
|
|
500
|
+
files_json TEXT NOT NULL,
|
|
501
|
+
created_at INTEGER NOT NULL
|
|
502
|
+
);
|
|
503
|
+
CREATE TABLE IF NOT EXISTS output_dirs (
|
|
504
|
+
entry_hash TEXT NOT NULL,
|
|
505
|
+
path TEXT NOT NULL,
|
|
506
|
+
mtime_ms INTEGER NOT NULL,
|
|
507
|
+
PRIMARY KEY (entry_hash, path),
|
|
508
|
+
FOREIGN KEY (entry_hash) REFERENCES entries(hash) ON DELETE CASCADE
|
|
509
|
+
);
|
|
832
510
|
-- v22 (Tier 3): one header row per vx-run invocation. The runs
|
|
833
511
|
-- table is per-task; this is the per-invocation record that
|
|
834
512
|
-- carries git/CI/host context, the command, tags, and run-level
|
|
@@ -885,41 +563,22 @@ export class Cache implements CacheLayer {
|
|
|
885
563
|
`)
|
|
886
564
|
|
|
887
565
|
this.insertEntry = this.db.prepare(`
|
|
888
|
-
INSERT INTO entries(hash, project, task, command, exit_code, duration_ms, size_bytes, stdout, created_at, accessed_at)
|
|
889
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
566
|
+
INSERT INTO entries(hash, project, task, command, exit_code, duration_ms, size_bytes, stdout, created_at, accessed_at, cpu_ms, peak_rss_bytes)
|
|
567
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
890
568
|
ON CONFLICT(hash) DO UPDATE SET
|
|
891
|
-
stdout
|
|
892
|
-
project
|
|
893
|
-
task
|
|
894
|
-
command
|
|
895
|
-
exit_code
|
|
896
|
-
duration_ms
|
|
897
|
-
size_bytes
|
|
898
|
-
accessed_at
|
|
569
|
+
stdout = excluded.stdout,
|
|
570
|
+
project = excluded.project,
|
|
571
|
+
task = excluded.task,
|
|
572
|
+
command = excluded.command,
|
|
573
|
+
exit_code = excluded.exit_code,
|
|
574
|
+
duration_ms = excluded.duration_ms,
|
|
575
|
+
size_bytes = excluded.size_bytes,
|
|
576
|
+
accessed_at = excluded.accessed_at,
|
|
577
|
+
cpu_ms = excluded.cpu_ms,
|
|
578
|
+
peak_rss_bytes = excluded.peak_rss_bytes
|
|
899
579
|
`)
|
|
900
580
|
this.selectEntry = this.db.prepare('SELECT * FROM entries WHERE hash = ?')
|
|
901
581
|
this.bumpAccessed = this.db.prepare('UPDATE entries SET accessed_at = ? WHERE hash = ?')
|
|
902
|
-
this.insertRun = this.db.prepare(`
|
|
903
|
-
INSERT INTO runs(
|
|
904
|
-
hash, project, task, status, exit_code, duration_ms, forward_args,
|
|
905
|
-
started_at, ended_at,
|
|
906
|
-
run_id, cpu_ms, peak_rss_bytes, wallclock_start_ns, wallclock_end_ns,
|
|
907
|
-
cache_hit, attempts
|
|
908
|
-
)
|
|
909
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
910
|
-
`)
|
|
911
|
-
this.insertInvocation = this.db.prepare(`
|
|
912
|
-
INSERT INTO invocations(
|
|
913
|
-
run_id, command, requested_tasks, cache_policy, concurrency, flow,
|
|
914
|
-
started_at, ended_at, total_duration_ms,
|
|
915
|
-
task_count, failed_count, hit_count, hit_local_count, hit_remote_count,
|
|
916
|
-
exit_ok,
|
|
917
|
-
commit_sha, branch, dirty, ci, ci_provider,
|
|
918
|
-
host, os, arch, vx_version, tags
|
|
919
|
-
)
|
|
920
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
921
|
-
ON CONFLICT(run_id) DO NOTHING
|
|
922
|
-
`)
|
|
923
582
|
// INSERT OR IGNORE: re-saving the same hash (idempotent ingest /
|
|
924
583
|
// overlapping concurrent saves) leaves the existing rows untouched —
|
|
925
584
|
// identical inputs derive the identical hash, so the rows are too.
|
|
@@ -927,111 +586,37 @@ export class Cache implements CacheLayer {
|
|
|
927
586
|
INSERT OR IGNORE INTO entry_inputs(entry_hash, kind, name, hash)
|
|
928
587
|
VALUES (?, ?, ?, ?)
|
|
929
588
|
`)
|
|
930
|
-
this
|
|
931
|
-
|
|
932
|
-
)
|
|
933
|
-
this.
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
ON CONFLICT(path) DO UPDATE SET
|
|
937
|
-
mtime_ms = excluded.mtime_ms,
|
|
938
|
-
size_bytes = excluded.size_bytes,
|
|
939
|
-
content_hash = excluded.content_hash,
|
|
940
|
-
seen_at = excluded.seen_at
|
|
941
|
-
`)
|
|
942
|
-
this.insertOutputFile = this.db.prepare(`
|
|
943
|
-
INSERT INTO output_files(entry_hash, path, size_bytes, mode, mtime_ms)
|
|
944
|
-
VALUES (?, ?, ?, ?, ?)
|
|
945
|
-
ON CONFLICT(entry_hash, path) DO UPDATE SET
|
|
946
|
-
size_bytes = excluded.size_bytes,
|
|
947
|
-
mode = excluded.mode,
|
|
948
|
-
mtime_ms = excluded.mtime_ms
|
|
949
|
-
`)
|
|
950
|
-
this.deleteOutputFiles = this.db.prepare('DELETE FROM output_files WHERE entry_hash = ?')
|
|
589
|
+
// The slices: each owns its statements over this handle and its table(s);
|
|
590
|
+
// the schema above is the one place every table is declared.
|
|
591
|
+
this.files = new FileHashStore(this.db, cacheDir)
|
|
592
|
+
this.configEvals = new ConfigEvalTable(this.db, localPolicy)
|
|
593
|
+
this.outputs = new OutputIndex(this.db)
|
|
594
|
+
this.history = new RunHistory(this.db)
|
|
951
595
|
}
|
|
952
596
|
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
* `(mtime_ms, size_bytes)` match the current stat, we reuse the
|
|
957
|
-
* stored content_hash (a memory + SQLite lookup, no disk read).
|
|
958
|
-
* Otherwise we read + hash + upsert.
|
|
959
|
-
*
|
|
960
|
-
* The OID is byte-identical to what `git hash-object` (and the git
|
|
961
|
-
* index) computes for the same content, so this fallback and the
|
|
962
|
-
* `CacheKeyInput.fileHashes` index-OID fast path never diverge —
|
|
963
|
-
* a file's key contribution can't flip across dirty↔clean
|
|
964
|
-
* transitions.
|
|
965
|
-
*/
|
|
966
|
-
async hashFile(filePath: string): Promise<string> {
|
|
967
|
-
// statSync intentional: a single stat is ~1.6µs (Bun 1.3); the
|
|
968
|
-
// async-stat equivalent adds ~75µs of Promise machinery per call.
|
|
969
|
-
// Promise.all over the batched callers (key derivation) gives no
|
|
970
|
-
// I/O parallelism benefit because the stat is faster than the
|
|
971
|
-
// threadpool dispatch overhead.
|
|
972
|
-
let st
|
|
973
|
-
try {
|
|
974
|
-
st = statSync(filePath)
|
|
975
|
-
} catch {
|
|
976
|
-
// Caller is responsible for skipping files that don't exist;
|
|
977
|
-
// fall through to the content-hash path which will throw with
|
|
978
|
-
// a more useful error.
|
|
979
|
-
return await this.hashFileFromDisk(filePath)
|
|
980
|
-
}
|
|
981
|
-
const mtimeMs = Math.floor(st.mtimeMs)
|
|
982
|
-
const size = st.size
|
|
983
|
-
const row = this.selectFileHash.get(filePath) as
|
|
984
|
-
| { mtime_ms: number; size_bytes: number; content_hash: string }
|
|
985
|
-
| undefined
|
|
986
|
-
if (row && row.mtime_ms === mtimeMs && row.size_bytes === size) {
|
|
987
|
-
return row.content_hash
|
|
988
|
-
}
|
|
989
|
-
const ch = await this.hashFileFromDisk(filePath)
|
|
990
|
-
this.upsertFileHash.run(filePath, mtimeMs, size, ch, Date.now())
|
|
991
|
-
return ch
|
|
597
|
+
// --- config evaluations: `ConfigEvalStore`, delegated to `ConfigEvalTable` ---
|
|
598
|
+
getConfigEval(key: string): string | null {
|
|
599
|
+
return this.configEvals.getConfigEval(key)
|
|
992
600
|
}
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
* Git blob OID of the file's bytes:
|
|
996
|
-
* `hex(HASH("blob " + byteLength + "\0" + content))`, where HASH is
|
|
997
|
-
* the repo's object format. Same value `git hash-object` prints and
|
|
998
|
-
* the same value the index stores. Computed in-process — no git
|
|
999
|
-
* spawn per file.
|
|
1000
|
-
*/
|
|
1001
|
-
private async hashFileFromDisk(filePath: string): Promise<string> {
|
|
1002
|
-
const bytes = await Bun.file(filePath).bytes()
|
|
1003
|
-
const hasher = new Bun.CryptoHasher(this.objectFormat ?? this.detectObjectFormat(filePath))
|
|
1004
|
-
hasher.update(`blob ${bytes.byteLength}\0`)
|
|
1005
|
-
hasher.update(bytes)
|
|
1006
|
-
return hasher.digest('hex')
|
|
601
|
+
getConfigClosures(configPaths: readonly string[]): Map<string, string[]> {
|
|
602
|
+
return this.configEvals.getConfigClosures(configPaths)
|
|
1007
603
|
}
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
stderr: 'pipe',
|
|
1024
|
-
})
|
|
1025
|
-
if (proc.exitCode === 0 && new TextDecoder().decode(proc.stdout).trim() === 'sha256') {
|
|
1026
|
-
detected = 'sha256'
|
|
1027
|
-
}
|
|
1028
|
-
} catch {
|
|
1029
|
-
// git unavailable → sha1 default keeps hashing deterministic.
|
|
1030
|
-
}
|
|
1031
|
-
this.objectFormat = detected
|
|
1032
|
-
return detected
|
|
604
|
+
putConfigClosure(configPath: string, files: readonly string[]): void {
|
|
605
|
+
this.configEvals.putConfigClosure(configPath, files)
|
|
606
|
+
}
|
|
607
|
+
getConfigEvals(keys: readonly string[]): Map<string, string> {
|
|
608
|
+
return this.configEvals.getConfigEvals(keys)
|
|
609
|
+
}
|
|
610
|
+
putConfigEval(key: string, json: string): void {
|
|
611
|
+
this.configEvals.putConfigEval(key, json)
|
|
612
|
+
}
|
|
613
|
+
// --- input file hashes: delegated to `FileHashStore` (see file-hashes.ts) ---
|
|
614
|
+
hashFile(filePath: string): Promise<string> {
|
|
615
|
+
return this.files.hashFile(filePath)
|
|
616
|
+
}
|
|
617
|
+
hashFiles(paths: readonly string[]): Promise<Map<string, string>> {
|
|
618
|
+
return this.files.hashFiles(paths)
|
|
1033
619
|
}
|
|
1034
|
-
|
|
1035
620
|
async key(input: CacheKeyInput): Promise<string> {
|
|
1036
621
|
// Seed-chained xxHash3: each step folds one field into the
|
|
1037
622
|
// running digest via `xxh3(part, prevDigest)`. Equivalent to the
|
|
@@ -1098,14 +683,27 @@ export class Cache implements CacheLayer {
|
|
|
1098
683
|
}
|
|
1099
684
|
}
|
|
1100
685
|
|
|
686
|
+
const pluginParts = input.pluginParts ?? []
|
|
687
|
+
if (pluginParts.length > 0) {
|
|
688
|
+
h = xxh3(`plugin:${pluginParts.length}`, h)
|
|
689
|
+
for (const [n, v] of pluginParts) h = xxh3(`${n}\0${v}`, h)
|
|
690
|
+
if (cap) {
|
|
691
|
+
for (const [n, v] of pluginParts) cap.push({ kind: 'plugin', name: n, hash: xxh3hex(v) })
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
1101
695
|
const sortedInputs = [...input.inputFiles].sort()
|
|
1102
696
|
h = xxh3(`inputs:${sortedInputs.length}`, h)
|
|
1103
697
|
// Per-file hash source, in preference order: the caller-supplied
|
|
1104
698
|
// index-OID map (clean tracked files — zero I/O), then hashFile's
|
|
1105
|
-
//
|
|
1106
|
-
//
|
|
1107
|
-
//
|
|
1108
|
-
//
|
|
699
|
+
// stat memo (no read), then a full in-process blob-OID computation.
|
|
700
|
+
// All three describe the WORKTREE bytes, which is what the task
|
|
701
|
+
// reads — the index-OID map is only populated for paths whose
|
|
702
|
+
// worktree form matches their index form, because a clean filter
|
|
703
|
+
// (`text`/`eol`/`ident`) makes the index blob a DIFFERENT sequence
|
|
704
|
+
// of bytes and folding it would let two distinct worktree contents
|
|
705
|
+
// share a key. The fold order is locked to `sortedInputs` so
|
|
706
|
+
// results are stable across runs.
|
|
1109
707
|
const fileHashes = await Promise.all(
|
|
1110
708
|
sortedInputs.map((f) => input.fileHashes?.get(f) ?? this.hashFile(f)),
|
|
1111
709
|
)
|
|
@@ -1128,12 +726,29 @@ export class Cache implements CacheLayer {
|
|
|
1128
726
|
// Local reads disabled (e.g. `--force` / `--cache=local:w`): report a
|
|
1129
727
|
// miss so the task re-executes. The artifact + index are untouched.
|
|
1130
728
|
if (!this.read) return null
|
|
729
|
+
return this.readEntry(hash)
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Read an entry BYPASSING the local read gate. The gate means "don't
|
|
734
|
+
* serve hits out of the PRE-EXISTING local cache" — it must not throw
|
|
735
|
+
* away bytes this run just downloaded. `LayeredCache` calls this to
|
|
736
|
+
* deliver an artifact it ingested from the remote; under
|
|
737
|
+
* `--cache=local:,remote:rw` the gated `get` returned null for the row
|
|
738
|
+
* `ingest` had just written, so the task re-executed and re-uploaded on
|
|
739
|
+
* every single run.
|
|
740
|
+
*/
|
|
741
|
+
getIngested(hash: string): Promise<CacheEntry | null> {
|
|
742
|
+
return this.readEntry(hash)
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
private async readEntry(hash: string): Promise<CacheEntry | null> {
|
|
1131
746
|
const row = this.selectEntry.get(hash) as EntryRow | undefined
|
|
1132
747
|
if (!row) return null
|
|
1133
748
|
|
|
1134
749
|
// Verify the tar artifact actually exists. The DB and the
|
|
1135
750
|
// filesystem can drift if someone manually deletes the cache dir.
|
|
1136
|
-
if (!(
|
|
751
|
+
if (!existsSync(this.tarPath(hash))) return null
|
|
1137
752
|
|
|
1138
753
|
// Deferred: per-hit UPDATEs cost ~60 ms across 2000+ probes on a
|
|
1139
754
|
// full-cache run. Hashes are collected and flushed as ONE batched
|
|
@@ -1147,18 +762,45 @@ export class Cache implements CacheLayer {
|
|
|
1147
762
|
// for four up-to-date hits on ~70 MB binaries). restoreOutputs
|
|
1148
763
|
// reads the artifact itself, only when extraction actually runs.
|
|
1149
764
|
const fileRows = this.loadOutputFilesBatch([hash]).get(hash) ?? []
|
|
765
|
+
const entry = entryOf(row, fileRows)
|
|
766
|
+
entry.outputDirRows = this.loadOutputDirsBatch([hash]).get(hash) ?? []
|
|
767
|
+
return entry
|
|
768
|
+
}
|
|
1150
769
|
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
770
|
+
/**
|
|
771
|
+
* `get` for many hashes at once: one `entries` query and one
|
|
772
|
+
* `output_files` query per chunk instead of two per hash, with the
|
|
773
|
+
* artifact-existence stats in flight together. Same answers as N calls
|
|
774
|
+
* to `get` — a hash whose artifact is gone is simply absent from the map.
|
|
775
|
+
* The up-front short-circuit probe is the caller; per-hit `get` stays for
|
|
776
|
+
* the lazy path.
|
|
777
|
+
*/
|
|
778
|
+
async getMany(hashes: readonly string[]): Promise<Map<string, CacheEntry>> {
|
|
779
|
+
const out = new Map<string, CacheEntry>()
|
|
780
|
+
if (!this.read || hashes.length === 0) return out
|
|
781
|
+
const rows: EntryRow[] = []
|
|
782
|
+
for (let i = 0; i < hashes.length; i += 900) {
|
|
783
|
+
const chunk = hashes.slice(i, i + 900)
|
|
784
|
+
const placeholders = chunk.map(() => '?').join(',')
|
|
785
|
+
rows.push(
|
|
786
|
+
...(this.db
|
|
787
|
+
.query(`SELECT * FROM entries WHERE hash IN (${placeholders})`)
|
|
788
|
+
.all(...(chunk as readonly SQLQueryBindings[])) as EntryRow[]),
|
|
789
|
+
)
|
|
1161
790
|
}
|
|
791
|
+
if (rows.length === 0) return out
|
|
792
|
+
const present = rows.map((r) => existsSync(this.tarPath(r.hash)))
|
|
793
|
+
const live = rows.filter((_r, i) => present[i])
|
|
794
|
+
const liveHashes = live.map((r) => r.hash)
|
|
795
|
+
const fileRows = this.loadOutputFilesBatch(liveHashes)
|
|
796
|
+
const dirRows = this.loadOutputDirsBatch(liveHashes)
|
|
797
|
+
for (const row of live) {
|
|
798
|
+
this.touched.add(row.hash)
|
|
799
|
+
const entry = entryOf(row, fileRows.get(row.hash) ?? [])
|
|
800
|
+
entry.outputDirRows = dirRows.get(row.hash) ?? []
|
|
801
|
+
out.set(row.hash, entry)
|
|
802
|
+
}
|
|
803
|
+
return out
|
|
1162
804
|
}
|
|
1163
805
|
|
|
1164
806
|
// Existence probe: SQL row + artifact-on-disk check, no byte reads
|
|
@@ -1167,7 +809,7 @@ export class Cache implements CacheLayer {
|
|
|
1167
809
|
if (!this.read) return null
|
|
1168
810
|
const row = this.selectEntry.get(hash) as EntryRow | undefined
|
|
1169
811
|
if (!row) return null
|
|
1170
|
-
return (
|
|
812
|
+
return existsSync(this.tarPath(hash)) ? 'local' : null
|
|
1171
813
|
}
|
|
1172
814
|
|
|
1173
815
|
// Local cache has no slower layer to warm from — prefetch is a no-op.
|
|
@@ -1177,74 +819,36 @@ export class Cache implements CacheLayer {
|
|
|
1177
819
|
return false
|
|
1178
820
|
}
|
|
1179
821
|
|
|
822
|
+
// --- output fingerprints: delegated to `OutputIndex` (see output-index.ts) ---
|
|
823
|
+
// The slices' proofs and hashes are returned as the slice's own promise —
|
|
824
|
+
// no `async` wrapper, no `return await`: each of those added a promise and
|
|
825
|
+
// a microtask hop per call, and a 1,000-hit warm run pays the proofs
|
|
826
|
+
// 2,000 times (measured 2026-09-10: +3 ms on `classify + probe`).
|
|
1180
827
|
loadOutputFilesBatch(hashes: readonly string[]): Map<string, OutputFileRow[]> {
|
|
1181
|
-
|
|
1182
|
-
if (hashes.length === 0) return out
|
|
1183
|
-
// Inline placeholders for an IN-list — bun:sqlite doesn't ship
|
|
1184
|
-
// rarray, but `IN (?, ?, …)` with N≤~999 is fast and avoids per-
|
|
1185
|
-
// hash select.get() overhead. `db.query` (not `db.prepare`) caches the
|
|
1186
|
-
// compiled statement keyed by the SQL text — so the dominant single-hash
|
|
1187
|
-
// warm-hit path (called up to 3× per hit) reuses one statement instead of
|
|
1188
|
-
// recompiling on every call.
|
|
1189
|
-
const placeholders = hashes.map(() => '?').join(',')
|
|
1190
|
-
const stmt = this.db.query(
|
|
1191
|
-
`SELECT entry_hash, path, size_bytes, mode, mtime_ms FROM output_files WHERE entry_hash IN (${placeholders})`,
|
|
1192
|
-
)
|
|
1193
|
-
const rows = stmt.all(...(hashes as readonly SQLQueryBindings[])) as Array<{
|
|
1194
|
-
entry_hash: string
|
|
1195
|
-
path: string
|
|
1196
|
-
size_bytes: number
|
|
1197
|
-
mode: number
|
|
1198
|
-
mtime_ms: number
|
|
1199
|
-
}>
|
|
1200
|
-
for (const r of rows) {
|
|
1201
|
-
let list = out.get(r.entry_hash)
|
|
1202
|
-
if (!list) {
|
|
1203
|
-
list = []
|
|
1204
|
-
out.set(r.entry_hash, list)
|
|
1205
|
-
}
|
|
1206
|
-
list.push({ path: r.path, size: r.size_bytes, mode: r.mode, mtimeMs: r.mtime_ms })
|
|
1207
|
-
}
|
|
1208
|
-
return out
|
|
828
|
+
return this.outputs.loadOutputFilesBatch(hashes)
|
|
1209
829
|
}
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
// Empty manifest case (a task produced no outputs) → trivially
|
|
1213
|
-
// current; the on-disk tree under projectDir is whatever it was,
|
|
1214
|
-
// and nothing was supposed to land there.
|
|
1215
|
-
if (expected.length === 0) return true
|
|
1216
|
-
const results = await Promise.all(
|
|
1217
|
-
expected.map(async (e) => {
|
|
1218
|
-
try {
|
|
1219
|
-
const s = await stat(path.join(projectDir, e.path))
|
|
1220
|
-
return (
|
|
1221
|
-
s.size === e.size &&
|
|
1222
|
-
(s.mode & 0o777) === (e.mode & 0o777) &&
|
|
1223
|
-
// MILLISECOND comparison (sub-ms tolerance for the float
|
|
1224
|
-
// round-trip through utimes). Save rows carry stat-ms and
|
|
1225
|
-
// restoreOutputs re-syncs restored files to the row value,
|
|
1226
|
-
// so equality holds exactly in steady state; legacy
|
|
1227
|
-
// second-precision rows converge on their first restore.
|
|
1228
|
-
// Residual blind spot: a same-size edit landing in the SAME
|
|
1229
|
-
// millisecond as the recorded write, or a deliberately
|
|
1230
|
-
// forged mtime (touch -r) — the trade every mtime-based
|
|
1231
|
-
// skip check accepts.
|
|
1232
|
-
Math.abs(s.mtimeMs - e.mtimeMs) < 1
|
|
1233
|
-
)
|
|
1234
|
-
} catch {
|
|
1235
|
-
return false
|
|
1236
|
-
}
|
|
1237
|
-
}),
|
|
1238
|
-
)
|
|
1239
|
-
return results.every(Boolean)
|
|
830
|
+
isOutputsCurrent(projectDir: string, expected: readonly OutputFileRow[]): Promise<boolean> {
|
|
831
|
+
return this.outputs.isOutputsCurrent(projectDir, expected)
|
|
1240
832
|
}
|
|
1241
|
-
|
|
1242
833
|
outputsPath(hash: string): string {
|
|
1243
834
|
return this.tarPath(hash)
|
|
1244
835
|
}
|
|
1245
836
|
|
|
837
|
+
async recordOutputDirs(
|
|
838
|
+
hash: string,
|
|
839
|
+
projectDir: string,
|
|
840
|
+
prefixes: readonly string[],
|
|
841
|
+
): Promise<void> {
|
|
842
|
+
await this.outputs.recordOutputDirs(hash, projectDir, prefixes)
|
|
843
|
+
}
|
|
844
|
+
loadOutputDirsBatch(hashes: readonly string[]): Map<string, OutputDirRow[]> {
|
|
845
|
+
return this.outputs.loadOutputDirsBatch(hashes)
|
|
846
|
+
}
|
|
847
|
+
outputDirsCurrent(projectDir: string, rows: readonly OutputDirRow[]): Promise<boolean> {
|
|
848
|
+
return this.outputs.outputDirsCurrent(projectDir, rows)
|
|
849
|
+
}
|
|
1246
850
|
async restoreOutputs(hash: string, projectDir: string, workspaceRoot?: string): Promise<void> {
|
|
1247
|
-
// In-process
|
|
851
|
+
// In-process extraction — no fork+exec on the hot path
|
|
1248
852
|
// (~5-10ms reclaimed per hit vs the prior subprocess `tar -xf`).
|
|
1249
853
|
//
|
|
1250
854
|
// The "tree is already current" skip-everything check happens at
|
|
@@ -1256,52 +860,84 @@ export class Cache implements CacheLayer {
|
|
|
1256
860
|
// path — they're surfaced via `get()` for the orchestrator to
|
|
1257
861
|
// replay through the logger.
|
|
1258
862
|
const src = this.tarPath(hash)
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
//
|
|
1262
|
-
//
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
const
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
)
|
|
1271
|
-
|
|
863
|
+
// The caller already committed to this hit and WIPED the declared
|
|
864
|
+
// outputs, so returning quietly here would report a green cache hit over
|
|
865
|
+
// an emptied output tree. The artifact existed when `get()` probed it, so
|
|
866
|
+
// its absence now means something removed it underneath us (a concurrent
|
|
867
|
+
// `vx cache prune` is the documented way) — fail loud; the task re-runs.
|
|
868
|
+
const endExists = span('restore: exists')
|
|
869
|
+
const exists = await Bun.file(src).exists()
|
|
870
|
+
endExists()
|
|
871
|
+
if (!exists) {
|
|
872
|
+
throw new CorruptArtifactError(hash, 'artifact file vanished before restore')
|
|
873
|
+
}
|
|
874
|
+
const endRows = span('restore: rows')
|
|
875
|
+
// The index says exactly which files this entry materializes. If the
|
|
876
|
+
// archive cannot produce one of them, restoring "successfully" leaves a
|
|
877
|
+
// hole that no later run detects: the skip-restore check compares the
|
|
878
|
+
// same truncated expectation against the same truncated tree and agrees
|
|
879
|
+
// forever. Refuse instead of silently under-restoring — checked before
|
|
880
|
+
// anything is renamed into place.
|
|
881
|
+
const rows = this.loadOutputFilesBatch([hash]).get(hash) ?? []
|
|
882
|
+
const expected = rows
|
|
883
|
+
.filter((r) => workspaceRoot !== undefined || !r.path.startsWith(WORKSPACE_OUTPUT_PREFIX))
|
|
884
|
+
.map((r) => (r.path.startsWith(WORKSPACE_OUTPUT_PREFIX) ? r.path : `outputs/${r.path}`))
|
|
885
|
+
endRows()
|
|
886
|
+
const verify = (provided: ReadonlySet<string>): void => {
|
|
887
|
+
const missing = expected.filter((name) => !provided.has(name))
|
|
888
|
+
if (missing.length > 0) {
|
|
889
|
+
throw new CorruptArtifactError(
|
|
890
|
+
hash,
|
|
891
|
+
`artifact is missing ${missing.length} recorded output(s): ${missing.slice(0, 3).join(', ')}`,
|
|
892
|
+
)
|
|
893
|
+
}
|
|
1272
894
|
}
|
|
1273
895
|
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
//
|
|
1277
|
-
//
|
|
1278
|
-
//
|
|
1279
|
-
//
|
|
1280
|
-
//
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
896
|
+
// One tar reader and one extractor either way; only the decode differs
|
|
897
|
+
// by size. A large artifact is decoded and read as it streams, so the
|
|
898
|
+
// restore costs a chunk of memory, not 4× the artifact (measured
|
|
899
|
+
// 2026-09-03 on 150 MiB: +644 MiB in memory, +49 MiB streamed, same
|
|
900
|
+
// wall time). A small one is decoded in one call: the stream setup
|
|
901
|
+
// costs ~35 µs per artifact, which is 4% of the headline restore row
|
|
902
|
+
// when every artifact is a one-file `dist/` (measured: 390 vs 355 ms
|
|
903
|
+
// per 1 000). The local artifact was validated at ingest, so a missing
|
|
904
|
+
// declared size is allowed; the output ceiling applies to both.
|
|
905
|
+
const endExtract = span('restore: extract')
|
|
906
|
+
try {
|
|
907
|
+
// Every step here is an async filesystem call on purpose: a
|
|
908
|
+
// synchronous restore of a small artifact is 2× faster ALONE (1.25 →
|
|
909
|
+
// 0.62 ms sequential, 2026-09-10) and 30% slower in the run, where
|
|
910
|
+
// four workers overlap their round trips and a blocking one stalls
|
|
911
|
+
// the other three (1,000-project restore row 1.1–1.2 s → 1.5 s).
|
|
912
|
+
const tar = await decodedTar(Bun.file(src), hash)
|
|
913
|
+
await extractArtifactStream(tar, projectDir, workspaceRoot, verify)
|
|
914
|
+
} catch (err) {
|
|
915
|
+
if (err instanceof ArchiveSecurityError || err instanceof CorruptArtifactError) throw err
|
|
916
|
+
// What is on disk, not what is in the archive: a directory standing
|
|
917
|
+
// where the entry holds a file, or a file where it needs a directory.
|
|
918
|
+
// The clean removes everything the output globs cover, so this is a
|
|
919
|
+
// stray they do not — name it as such, not as a corrupt artifact.
|
|
920
|
+
const code = (err as NodeJS.ErrnoException).code
|
|
921
|
+
if (code === 'EISDIR' || code === 'ENOTDIR' || code === 'EEXIST' || code === 'ENOTEMPTY') {
|
|
922
|
+
throw new UserError(
|
|
923
|
+
`restore of ${hash} into ${projectDir} was blocked by what is on disk (${code}: ${(err as Error).message}). ` +
|
|
924
|
+
`Declared outputs are wiped before a restore, so this is a path the output globs do not cover — remove it and re-run.`,
|
|
925
|
+
)
|
|
926
|
+
}
|
|
927
|
+
throw new CorruptArtifactError(hash, 'artifact is not a readable archive', err)
|
|
928
|
+
} finally {
|
|
929
|
+
endExtract()
|
|
1297
930
|
}
|
|
1298
931
|
}
|
|
1299
932
|
|
|
1300
933
|
async save(args: {
|
|
1301
934
|
hash: string
|
|
1302
|
-
|
|
935
|
+
/** See `CacheLayer.save` — `exitCode` is not a caller's to supply. */
|
|
936
|
+
entry: Omit<CacheEntry, 'hash' | 'storedAt' | 'outputFiles' | 'exitCode'>
|
|
1303
937
|
projectDir: string
|
|
1304
938
|
outputFiles: string[]
|
|
939
|
+
/** See `CacheLayer.save`. */
|
|
940
|
+
skipLocalWrite?: boolean
|
|
1305
941
|
workspaceOutputFiles?: string[]
|
|
1306
942
|
workspaceRoot?: string
|
|
1307
943
|
inputComponents?: readonly TaskInputRow[]
|
|
@@ -1324,23 +960,17 @@ export class Cache implements CacheLayer {
|
|
|
1324
960
|
// still uploads to remote — it calls `packArtifact` itself to get
|
|
1325
961
|
// the bytes, since there's no local artifact to read off disk.
|
|
1326
962
|
if (!this.write) return
|
|
1327
|
-
|
|
1328
|
-
await this.
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
},
|
|
1337
|
-
|
|
1338
|
-
// headers carry only seconds) — see the row loop for the guard.
|
|
1339
|
-
{
|
|
1340
|
-
projectDir: args.projectDir,
|
|
1341
|
-
...(args.workspaceRoot !== undefined ? { workspaceRoot: args.workspaceRoot } : {}),
|
|
1342
|
-
},
|
|
1343
|
-
)
|
|
963
|
+
if (args.skipLocalWrite === true) return
|
|
964
|
+
await mkdir(this.cacheDir, { recursive: true })
|
|
965
|
+
const endPack = span('save: pack')
|
|
966
|
+
const compressed = await this.packArtifactToTemp(this.tempPath(args.hash), args)
|
|
967
|
+
endPack()
|
|
968
|
+
await this.writeArtifactAndIndex(args.hash, compressed, {
|
|
969
|
+
taskId: args.entry.taskId,
|
|
970
|
+
command: args.entry.command,
|
|
971
|
+
durationMs: args.entry.durationMs,
|
|
972
|
+
...(args.inputComponents !== undefined ? { inputComponents: args.inputComponents } : {}),
|
|
973
|
+
})
|
|
1344
974
|
}
|
|
1345
975
|
|
|
1346
976
|
/**
|
|
@@ -1357,8 +987,8 @@ export class Cache implements CacheLayer {
|
|
|
1357
987
|
* index. Used by the LayeredCache when local writes are disabled but
|
|
1358
988
|
* a remote upload still needs the artifact bytes.
|
|
1359
989
|
*/
|
|
1360
|
-
|
|
1361
|
-
return
|
|
990
|
+
packArtifactBytes(args: SaveArgs): Promise<Uint8Array> {
|
|
991
|
+
return this.packArtifact(args)
|
|
1362
992
|
}
|
|
1363
993
|
|
|
1364
994
|
async ingest(hash: string, compressed: Uint8Array, meta: IngestMeta): Promise<void> {
|
|
@@ -1366,179 +996,201 @@ export class Cache implements CacheLayer {
|
|
|
1366
996
|
}
|
|
1367
997
|
|
|
1368
998
|
/**
|
|
1369
|
-
*
|
|
1370
|
-
* No disk write to the final cache path — that's the index
|
|
1371
|
-
* job. Pure transform, so `ingest()` can skip this and
|
|
1372
|
-
* remote-supplied bytes straight to `writeArtifactAndIndex`.
|
|
999
|
+
* Collect stdout + outputs into artifact bytes, zstd-compress, return
|
|
1000
|
+
* them. No disk write to the final cache path — that's the index
|
|
1001
|
+
* step's job. Pure transform, so `ingest()` can skip this and hand
|
|
1002
|
+
* its remote-supplied bytes straight to `writeArtifactAndIndex`.
|
|
1003
|
+
*
|
|
1004
|
+
* Entries are named directly into the archive, so there is no staging
|
|
1005
|
+
* copy of every output byte and no `tar` subprocess (see
|
|
1006
|
+
* `archive.ts`).
|
|
1007
|
+
*/
|
|
1008
|
+
/** Archive name → absolute source path for every declared output. */
|
|
1009
|
+
private outputsOf(args: {
|
|
1010
|
+
projectDir: string
|
|
1011
|
+
outputFiles: string[]
|
|
1012
|
+
workspaceOutputFiles?: string[]
|
|
1013
|
+
workspaceRoot?: string
|
|
1014
|
+
}): Map<string, string> {
|
|
1015
|
+
const outputs = new Map<string, string>()
|
|
1016
|
+
for (const f of args.outputFiles) {
|
|
1017
|
+
outputs.set(`outputs/${path.relative(args.projectDir, f)}`, f)
|
|
1018
|
+
}
|
|
1019
|
+
// Caller passes workspaceRoot whenever workspaceOutputFiles is
|
|
1020
|
+
// non-empty; the rels are root-anchored by construction.
|
|
1021
|
+
for (const f of args.workspaceOutputFiles ?? []) {
|
|
1022
|
+
outputs.set(`${WORKSPACE_OUTPUT_PREFIX}${path.relative(args.workspaceRoot!, f)}`, f)
|
|
1023
|
+
}
|
|
1024
|
+
return outputs
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* The compressed artifact, in memory: the remote upload when local
|
|
1029
|
+
* writes are off. A large artifact is packed and compressed as a
|
|
1030
|
+
* stream and collected — the seam takes bytes.
|
|
1373
1031
|
*/
|
|
1374
1032
|
private async packArtifact(args: {
|
|
1375
|
-
|
|
1376
|
-
entry: Omit<CacheEntry, 'hash' | 'storedAt' | 'outputFiles'>
|
|
1033
|
+
entry: Omit<CacheEntry, 'hash' | 'storedAt' | 'outputFiles' | 'exitCode'>
|
|
1377
1034
|
projectDir: string
|
|
1378
1035
|
outputFiles: string[]
|
|
1379
1036
|
workspaceOutputFiles?: string[]
|
|
1380
1037
|
workspaceRoot?: string
|
|
1381
1038
|
}): Promise<Uint8Array> {
|
|
1382
|
-
|
|
1383
|
-
|
|
1039
|
+
// stdout is ALWAYS present in the artifact, even if empty, so the
|
|
1040
|
+
// layout is predictable: a successful read finds `stdout` and
|
|
1041
|
+
// zero-or-more `outputs/<rel>` / `workspace-outputs/<rel>` entries.
|
|
1042
|
+
const plan = await planArtifact({
|
|
1043
|
+
stdout: args.entry.stdout ?? '',
|
|
1044
|
+
outputs: this.outputsOf(args),
|
|
1045
|
+
exec: usageOfEntry(args.entry),
|
|
1046
|
+
})
|
|
1047
|
+
if (plan.size <= STREAM_DECODE_FROM)
|
|
1048
|
+
return await Bun.zstdCompress(await packArtifactBytes(plan))
|
|
1049
|
+
return bytesOf(packArtifactStream(plan).pipeThrough(zstdEncoder()))
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/**
|
|
1053
|
+
* Pack for the local save. A large artifact is read from disk as it is
|
|
1054
|
+
* written and compressed as a stream straight into the temp, so it
|
|
1055
|
+
* never sits in memory (measured 2026-09-03 on a 150 MiB output: +705
|
|
1056
|
+
* MiB before). The streamed compressor is ~2.4× the one-call cost per
|
|
1057
|
+
* byte and a stream costs ~35 µs to set up, so a small artifact is
|
|
1058
|
+
* packed in memory and compressed in one call — returned as bytes,
|
|
1059
|
+
* nothing written yet.
|
|
1060
|
+
*/
|
|
1061
|
+
private async packArtifactToTemp(
|
|
1062
|
+
tmpPath: string,
|
|
1063
|
+
args: Parameters<Cache['packArtifact']>[0],
|
|
1064
|
+
): Promise<Uint8Array | { tmpPath: string }> {
|
|
1065
|
+
const plan = await planArtifact({
|
|
1066
|
+
stdout: args.entry.stdout ?? '',
|
|
1067
|
+
outputs: this.outputsOf(args),
|
|
1068
|
+
exec: usageOfEntry(args.entry),
|
|
1069
|
+
})
|
|
1070
|
+
if (plan.size <= STREAM_DECODE_FROM)
|
|
1071
|
+
return await Bun.zstdCompress(await packArtifactBytes(plan))
|
|
1072
|
+
const sink = Bun.file(tmpPath).writer()
|
|
1384
1073
|
try {
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
await Promise.all(
|
|
1388
|
-
args.outputFiles.map(async (f) => {
|
|
1389
|
-
const rel = path.relative(args.projectDir, f)
|
|
1390
|
-
const dest = path.join(stageOutputs, rel)
|
|
1391
|
-
// Bun.write creates parent dirs as needed.
|
|
1392
|
-
await Bun.write(dest, Bun.file(f))
|
|
1393
|
-
}),
|
|
1394
|
-
)
|
|
1074
|
+
for await (const chunk of packArtifactStream(plan).pipeThrough(zstdEncoder())) {
|
|
1075
|
+
await sink.write(chunk)
|
|
1395
1076
|
}
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
const rel = path.relative(args.workspaceRoot!, f)
|
|
1403
|
-
await Bun.write(path.join(stageWs, rel), Bun.file(f))
|
|
1404
|
-
}),
|
|
1405
|
-
)
|
|
1406
|
-
}
|
|
1407
|
-
// stdout is ALWAYS present in the artifact, even if empty, so the
|
|
1408
|
-
// archive layout is predictable: a successful read finds `stdout`
|
|
1409
|
-
// and zero-or-more `outputs/<rel>` / `workspace-outputs/<rel>`
|
|
1410
|
-
// entries.
|
|
1411
|
-
await Bun.write(path.join(stage, 'stdout'), args.entry.stdout ?? '')
|
|
1412
|
-
|
|
1413
|
-
const topLevel: string[] = ['stdout']
|
|
1414
|
-
if (wsOutputFiles.length > 0) topLevel.unshift('workspace-outputs')
|
|
1415
|
-
if (args.outputFiles.length > 0) topLevel.unshift('outputs')
|
|
1416
|
-
|
|
1417
|
-
// `--format=ustar` forces strict POSIX ustar — no PAX extended-
|
|
1418
|
-
// header records. BSD tar (macOS default) emits PAX per entry
|
|
1419
|
-
// by default for xattrs / mtime-nanos; those records would
|
|
1420
|
-
// otherwise show up as junk `PaxHeaders/<name>` entries in our
|
|
1421
|
-
// restored trees. GNU tar also accepts the flag (no-op on its
|
|
1422
|
-
// side). Names > 100 chars still work via ustar's prefix+name
|
|
1423
|
-
// (255 chars) or GNU longname fallback if the tar binary
|
|
1424
|
-
// chooses to emit one.
|
|
1425
|
-
const proc = Bun.spawn(['tar', '--format=ustar', '-cf', '-', '-C', stage, ...topLevel], {
|
|
1426
|
-
stdout: 'pipe',
|
|
1427
|
-
stderr: 'pipe',
|
|
1428
|
-
// COPYFILE_DISABLE blocks Apple's copyfile() from attaching
|
|
1429
|
-
// xattrs to staged files via inherited child copies; tar then
|
|
1430
|
-
// has nothing to emit AppleDouble for.
|
|
1431
|
-
env: { ...process.env, COPYFILE_DISABLE: '1' },
|
|
1432
|
-
})
|
|
1433
|
-
const [tarBytes, stderrText] = await Promise.all([
|
|
1434
|
-
new Response(proc.stdout).bytes(),
|
|
1435
|
-
new Response(proc.stderr).text(),
|
|
1436
|
-
])
|
|
1437
|
-
await proc.exited
|
|
1438
|
-
if (proc.exitCode !== 0) {
|
|
1439
|
-
throw new Error(`save: tar exited ${proc.exitCode}: ${stderrText.trim()}`)
|
|
1440
|
-
}
|
|
1441
|
-
return await Bun.zstdCompress(tarBytes)
|
|
1442
|
-
} finally {
|
|
1443
|
-
await rm(stage, { recursive: true, force: true })
|
|
1077
|
+
} catch (err) {
|
|
1078
|
+
// An output that vanished or changed shape between the plan's stat
|
|
1079
|
+
// and its read: the partial temp must not outlive the failure.
|
|
1080
|
+
await sink.end()
|
|
1081
|
+
await unlink(tmpPath).catch(() => undefined)
|
|
1082
|
+
throw err
|
|
1444
1083
|
}
|
|
1084
|
+
await sink.end()
|
|
1085
|
+
return { tmpPath }
|
|
1445
1086
|
}
|
|
1446
1087
|
|
|
1447
1088
|
/**
|
|
1448
|
-
* Atomically write `compressed` to `<hash>.tar.zst` and (re)build
|
|
1449
|
-
*
|
|
1450
|
-
*
|
|
1451
|
-
*
|
|
1089
|
+
* Atomically write `compressed` to `<hash>.tar.zst` and (re)build the
|
|
1090
|
+
* entries + output_files SQL rows from the archive itself. Shared by
|
|
1091
|
+
* `save()` (we just packed the bytes) and `ingest()` (we got them from
|
|
1092
|
+
* the remote layer) — both index the identical values, because both
|
|
1093
|
+
* read them out of the artifact.
|
|
1452
1094
|
*/
|
|
1095
|
+
/** tmp suffix mixes pid + hrtime + a random hex chunk so two saves of
|
|
1096
|
+
* the same hash from the same process (or from two forked workers that
|
|
1097
|
+
* happen to share a wall-clock ms) don't pick the same tmp filename and
|
|
1098
|
+
* race on the rename. */
|
|
1099
|
+
private tempPath(hash: string): string {
|
|
1100
|
+
return `${this.tarPath(hash)}.tmp-${process.pid}-${process.hrtime.bigint()}-${Math.random().toString(36).slice(2, 10)}`
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1453
1103
|
private async writeArtifactAndIndex(
|
|
1454
1104
|
hash: string,
|
|
1455
|
-
compressed: Uint8Array,
|
|
1105
|
+
compressed: Uint8Array | { tmpPath: string },
|
|
1456
1106
|
meta: IngestMeta,
|
|
1457
|
-
statBase?: { projectDir: string; workspaceRoot?: string },
|
|
1458
1107
|
): Promise<void> {
|
|
1459
1108
|
// Validate BEFORE anything touches the final path. `ingest()` feeds
|
|
1460
1109
|
// us network bytes; a truncated/garbage body that went live first
|
|
1461
1110
|
// would leave a corrupt `<hash>.tar.zst` behind (with no SQL row,
|
|
1462
1111
|
// since the decompress throw aborted indexing) for every later
|
|
1463
1112
|
// reader to trip over. Decompress + parse also produce the
|
|
1464
|
-
// `output_files` rows: same headers
|
|
1113
|
+
// `output_files` rows: same headers the restore will see on
|
|
1465
1114
|
// restore, so the size/mode/mtime fingerprint we store matches
|
|
1466
1115
|
// what isOutputsCurrent will compare against post-restore.
|
|
1467
|
-
|
|
1116
|
+
const finalPath = this.tarPath(hash)
|
|
1117
|
+
let tmpPath: string
|
|
1118
|
+
if (compressed instanceof Uint8Array) {
|
|
1119
|
+
tmpPath = this.tempPath(hash)
|
|
1120
|
+
await mkdir(this.cacheDir, { recursive: true })
|
|
1121
|
+
// The temp is written BEFORE validation so a large artifact can be
|
|
1122
|
+
// scanned from the file as it decodes — a file stream reads in
|
|
1123
|
+
// bounded pieces; the bytes in memory would not (see `decodedTar`).
|
|
1124
|
+
// The final path is still untouched until the archive has passed.
|
|
1125
|
+
// node's writeFile, not Bun.write: the latter copies the buffer first
|
|
1126
|
+
// (measured on 150 MiB: +151 MiB and 33 ms against +0 and 21 ms).
|
|
1127
|
+
const endWrite = span('save: write temp')
|
|
1128
|
+
await writeFile(tmpPath, compressed)
|
|
1129
|
+
endWrite()
|
|
1130
|
+
} else {
|
|
1131
|
+
// `save` already streamed the artifact into its temp.
|
|
1132
|
+
tmpPath = compressed.tmpPath
|
|
1133
|
+
}
|
|
1134
|
+
let scanned: Awaited<ReturnType<typeof scanArtifact>>
|
|
1468
1135
|
try {
|
|
1469
1136
|
// ingest() is the UNTRUSTED boundary — `compressed` is bytes just
|
|
1470
1137
|
// pulled from a remote. Refuse a bomb (declared or sizeless) before it
|
|
1471
1138
|
// can expand into memory.
|
|
1472
|
-
|
|
1139
|
+
const source =
|
|
1140
|
+
compressed instanceof Uint8Array && compressed.byteLength <= STREAM_DECODE_FROM
|
|
1141
|
+
? compressed
|
|
1142
|
+
: Bun.file(tmpPath)
|
|
1143
|
+
const endScan = span('save: scan')
|
|
1144
|
+
scanned = await scanArtifact(await decodedTar(source, hash))
|
|
1145
|
+
endScan()
|
|
1146
|
+
// v17 invariant: every artifact carries a `stdout` entry. Its
|
|
1147
|
+
// absence means the bytes decompressed but aren't a vx artifact.
|
|
1148
|
+
if (scanned.stdout === null) {
|
|
1149
|
+
throw new CorruptArtifactError(hash, 'missing stdout entry')
|
|
1150
|
+
}
|
|
1473
1151
|
} catch (err) {
|
|
1474
|
-
|
|
1475
|
-
|
|
1152
|
+
await unlink(tmpPath).catch(() => undefined)
|
|
1153
|
+
if (err instanceof ArchiveSecurityError || err instanceof CorruptArtifactError) throw err
|
|
1154
|
+
throw new CorruptArtifactError(hash, 'artifact is not a readable archive', err)
|
|
1476
1155
|
}
|
|
1477
|
-
const
|
|
1478
|
-
// v17 invariant: every artifact carries a `stdout` entry. Its
|
|
1479
|
-
// absence means the bytes decompressed but aren't a vx artifact.
|
|
1480
|
-
if (!headers.some((h) => h.name === 'stdout' && !h.isDir)) {
|
|
1481
|
-
throw new CorruptArtifactError(hash, 'missing stdout entry')
|
|
1482
|
-
}
|
|
1483
|
-
|
|
1484
|
-
const finalPath = this.tarPath(hash)
|
|
1485
|
-
// tmp suffix mixes pid + hrtime + a random hex chunk so two saves
|
|
1486
|
-
// of the same hash from the same process (or from two forked
|
|
1487
|
-
// workers that happen to share a wall-clock ms) don't pick the
|
|
1488
|
-
// same tmp filename and race on the rename.
|
|
1489
|
-
const tmpPath = `${finalPath}.tmp-${process.pid}-${process.hrtime.bigint()}-${Math.random().toString(36).slice(2, 10)}`
|
|
1490
|
-
await mkdir(this.cacheDir, { recursive: true })
|
|
1491
|
-
await Bun.write(tmpPath, compressed)
|
|
1156
|
+
const { entries } = scanned
|
|
1492
1157
|
// POSIX rename atomically REPLACES the destination if it exists,
|
|
1493
1158
|
// so we don't need a pre-rm. The pre-rm was actively harmful —
|
|
1494
1159
|
// it opened a race window where writer B could delete writer A's
|
|
1495
1160
|
// just-renamed file BEFORE A's subsequent stat, producing a
|
|
1496
1161
|
// spurious ENOENT. The rename itself preserves the "either-or"
|
|
1497
1162
|
// semantics for concurrent readers.
|
|
1163
|
+
const endRename = span('save: rename')
|
|
1498
1164
|
await rename(tmpPath, finalPath)
|
|
1165
|
+
endRename()
|
|
1499
1166
|
|
|
1500
|
-
const totalBytes =
|
|
1167
|
+
const totalBytes =
|
|
1168
|
+
compressed instanceof Uint8Array ? compressed.byteLength : Bun.file(finalPath).size
|
|
1501
1169
|
const outputFileRows: Array<[string, number, number, number]> = []
|
|
1502
1170
|
// Per-output-file fingerprint rows feed the skip-restore check.
|
|
1503
1171
|
// Row paths: project entries store the bare rel (`outputs/`
|
|
1504
1172
|
// stripped); workspace entries keep the full
|
|
1505
1173
|
// `workspace-outputs/<rel>` name as the namespace discriminator.
|
|
1506
|
-
|
|
1507
|
-
|
|
1174
|
+
//
|
|
1175
|
+
// Mode and MILLISECOND mtime come from the artifact's own sidecar
|
|
1176
|
+
// (`.vx-meta.json`), written from a stat taken while packing. Both
|
|
1177
|
+
// paths — save and remote ingest — therefore index the same values,
|
|
1178
|
+
// and `restoreOutputs` materialises exactly them, so
|
|
1179
|
+
// `isOutputsCurrent` compares equal at ms precision straight after a
|
|
1180
|
+
// restore. (Tar headers carry seconds, which is why the save path
|
|
1181
|
+
// used to need a second stat pass the ingest path could not have.)
|
|
1182
|
+
for (const e of entries) {
|
|
1508
1183
|
let rowPath: string | null = null
|
|
1509
|
-
if (
|
|
1510
|
-
const rel =
|
|
1184
|
+
if (e.name.startsWith('outputs/')) {
|
|
1185
|
+
const rel = e.name.slice('outputs/'.length)
|
|
1511
1186
|
if (rel.length > 0) rowPath = rel
|
|
1512
|
-
} else if (
|
|
1513
|
-
if (
|
|
1187
|
+
} else if (e.name.startsWith(WORKSPACE_OUTPUT_PREFIX)) {
|
|
1188
|
+
if (e.name.length > WORKSPACE_OUTPUT_PREFIX.length) rowPath = e.name
|
|
1514
1189
|
}
|
|
1515
1190
|
if (rowPath === null) continue
|
|
1516
|
-
|
|
1517
|
-
// Save path only (ingest has no filesystem to consult): tar
|
|
1518
|
-
// headers truncate mtimes to SECONDS, which is what made the
|
|
1519
|
-
// skip-restore check blind to same-second same-size edits. A
|
|
1520
|
-
// fresh stat refines the row to millisecond precision — but only
|
|
1521
|
-
// when the size still matches the packed header, so the row
|
|
1522
|
-
// always describes the ARTIFACT's bytes; a file mutated between
|
|
1523
|
-
// pack and stat keeps the coarse header time (degraded, not
|
|
1524
|
-
// wrong). restoreOutputs re-syncs disk mtimes to these rows.
|
|
1525
|
-
if (statBase !== undefined) {
|
|
1526
|
-
try {
|
|
1527
|
-
const abs = rowPath.startsWith(WORKSPACE_OUTPUT_PREFIX)
|
|
1528
|
-
? path.join(
|
|
1529
|
-
statBase.workspaceRoot ?? statBase.projectDir,
|
|
1530
|
-
rowPath.slice(WORKSPACE_OUTPUT_PREFIX.length),
|
|
1531
|
-
)
|
|
1532
|
-
: path.join(statBase.projectDir, rowPath)
|
|
1533
|
-
const st = await stat(abs)
|
|
1534
|
-
if (st.size === h.size) mtimeMs = Math.floor(st.mtimeMs)
|
|
1535
|
-
} catch {
|
|
1536
|
-
// stat failed — keep the header-seconds fallback.
|
|
1537
|
-
}
|
|
1538
|
-
}
|
|
1539
|
-
outputFileRows.push([rowPath, h.size, h.mode & 0o777, mtimeMs])
|
|
1191
|
+
outputFileRows.push([rowPath, e.size, e.mode & 0o777, Math.floor(e.mtimeMs)])
|
|
1540
1192
|
}
|
|
1541
|
-
const stdoutText =
|
|
1193
|
+
const stdoutText = scanned.stdout
|
|
1542
1194
|
|
|
1543
1195
|
const [project, task] = splitTaskId(meta.taskId)
|
|
1544
1196
|
const now = Date.now()
|
|
@@ -1549,8 +1201,7 @@ export class Cache implements CacheLayer {
|
|
|
1549
1201
|
// (not the per-run path) so a warm all-cache-hit run — which never
|
|
1550
1202
|
// saves — writes none of them.
|
|
1551
1203
|
const insertEntry = this.insertEntry
|
|
1552
|
-
const
|
|
1553
|
-
const deleteOutputFiles = this.deleteOutputFiles
|
|
1204
|
+
const outputs = this.outputs
|
|
1554
1205
|
const insertEntryInput = this.insertEntryInput
|
|
1555
1206
|
const inputComponents = meta.inputComponents
|
|
1556
1207
|
const tx = this.db.transaction(() => {
|
|
@@ -1559,19 +1210,23 @@ export class Cache implements CacheLayer {
|
|
|
1559
1210
|
project,
|
|
1560
1211
|
task,
|
|
1561
1212
|
meta.command,
|
|
1562
|
-
|
|
1213
|
+
// exitCode. Pinned, not supplied: vx caches only successes, and neither
|
|
1214
|
+
// `save` nor `ingest` accepts one (see their arg types). The column
|
|
1215
|
+
// stays because the READ side still defends against a non-zero value —
|
|
1216
|
+
// a row from a foreign or hand-edited cache.db classifies the hit
|
|
1217
|
+
// `failed` rather than laundering a broken build into a green run.
|
|
1218
|
+
0,
|
|
1563
1219
|
meta.durationMs,
|
|
1564
1220
|
totalBytes,
|
|
1565
1221
|
stdoutText,
|
|
1566
1222
|
now,
|
|
1567
1223
|
now,
|
|
1224
|
+
// From the artifact, on both paths: a save indexes what it just
|
|
1225
|
+
// packed, an ingest what the producing machine packed.
|
|
1226
|
+
scanned.exec?.cpuMs ?? null,
|
|
1227
|
+
scanned.exec?.peakRssBytes ?? null,
|
|
1568
1228
|
)
|
|
1569
|
-
|
|
1570
|
-
// the same hash should refresh, not append).
|
|
1571
|
-
deleteOutputFiles.run(hash)
|
|
1572
|
-
for (const [rel, size, mode, mtime] of outputFileRows) {
|
|
1573
|
-
insertOutputFile.run(hash, rel, size, mode, mtime)
|
|
1574
|
-
}
|
|
1229
|
+
outputs.replaceFileRows(hash, outputFileRows)
|
|
1575
1230
|
// INSERT OR IGNORE: identical inputs derive this same hash, so a
|
|
1576
1231
|
// re-save's rows are identical — keep the first set, skip the rest.
|
|
1577
1232
|
if (inputComponents !== undefined) {
|
|
@@ -1580,7 +1235,9 @@ export class Cache implements CacheLayer {
|
|
|
1580
1235
|
}
|
|
1581
1236
|
}
|
|
1582
1237
|
})
|
|
1238
|
+
const endTx = span('save: index tx')
|
|
1583
1239
|
tx()
|
|
1240
|
+
endTx()
|
|
1584
1241
|
}
|
|
1585
1242
|
|
|
1586
1243
|
/** Apply the deferred accessed_at bumps in one statement. */
|
|
@@ -1611,64 +1268,36 @@ export class Cache implements CacheLayer {
|
|
|
1611
1268
|
return this.db
|
|
1612
1269
|
}
|
|
1613
1270
|
|
|
1614
|
-
|
|
1615
|
-
* Content-addressed storage view over the same artifacts directory.
|
|
1616
|
-
* Returns an `FsCASBackend` pointing at `cacheDir`, so external
|
|
1617
|
-
* subsystems (R2 mirror, REAPI CAS bridge, analytics scanners) can
|
|
1618
|
-
* read raw bytes with a `Digest`-keyed API without coupling to
|
|
1619
|
-
* Cache's internal save path. Read/write semantics match what
|
|
1620
|
-
* Cache.save writes (`<cacheDir>/<hash>.tar.zst`).
|
|
1621
|
-
*/
|
|
1622
|
-
contentBackend(): FsCASBackend {
|
|
1623
|
-
return new FsCASBackend(this.cacheDir)
|
|
1624
|
-
}
|
|
1625
|
-
|
|
1271
|
+
// --- run history: delegated to `RunHistory` (see run-history.ts) ---
|
|
1626
1272
|
recordRun(run: RunRecord): void {
|
|
1627
|
-
this.
|
|
1273
|
+
this.history.recordRun(run)
|
|
1628
1274
|
}
|
|
1629
|
-
|
|
1630
1275
|
recordRuns(runs: readonly RunRecord[]): void {
|
|
1631
|
-
|
|
1632
|
-
if (runs.length === 1) {
|
|
1633
|
-
this.insertRun.run(...bindRun(runs[0]!))
|
|
1634
|
-
return
|
|
1635
|
-
}
|
|
1636
|
-
// `bun:sqlite`'s `transaction()` returns a callable that wraps the
|
|
1637
|
-
// body in BEGIN/COMMIT, fsyncing once at the end. For a 200-task
|
|
1638
|
-
// run that's one fsync instead of 200.
|
|
1639
|
-
const insert = this.insertRun
|
|
1640
|
-
const tx = this.db.transaction((batch: readonly RunRecord[]) => {
|
|
1641
|
-
for (const r of batch) insert.run(...bindRun(r))
|
|
1642
|
-
})
|
|
1643
|
-
tx(runs)
|
|
1276
|
+
this.history.recordRuns(runs)
|
|
1644
1277
|
}
|
|
1645
|
-
|
|
1646
1278
|
recordRunBundle(bundle: { runs: readonly RunRecord[]; invocation: InvocationRecord }): void {
|
|
1647
|
-
|
|
1648
|
-
// per-task `runs` rows and the one `invocations` header. The
|
|
1649
|
-
// input-fingerprint rows do NOT live here: they're written inside
|
|
1650
|
-
// the entry-save transaction (`save`/`ingest`) so a warm
|
|
1651
|
-
// all-cache-hit run — which writes no `runs`-vs-`entry_inputs`
|
|
1652
|
-
// mismatch — pays nothing for the moat it isn't refreshing.
|
|
1653
|
-
const insertRun = this.insertRun
|
|
1654
|
-
const insertInvocation = this.insertInvocation
|
|
1655
|
-
this.db.transaction(() => {
|
|
1656
|
-
for (const r of bundle.runs) insertRun.run(...bindRun(r))
|
|
1657
|
-
insertInvocation.run(...bindInvocation(bundle.invocation))
|
|
1658
|
-
})()
|
|
1279
|
+
this.history.recordRunBundle(bundle)
|
|
1659
1280
|
}
|
|
1660
|
-
|
|
1661
|
-
stats(): CacheStats {
|
|
1281
|
+
stats(opts: CacheStatsOptions = {}): CacheStats {
|
|
1662
1282
|
this.flushAccessed()
|
|
1283
|
+
const project = opts.project
|
|
1284
|
+
const scoped = project !== undefined
|
|
1285
|
+
const scopeParams: string[] = project === undefined ? [] : [project]
|
|
1663
1286
|
const aggregate = this.db
|
|
1664
|
-
.prepare(
|
|
1665
|
-
|
|
1287
|
+
.prepare(
|
|
1288
|
+
`SELECT COUNT(*) AS n, COALESCE(SUM(size_bytes), 0) AS bytes FROM entries${
|
|
1289
|
+
scoped ? ' WHERE project = ?' : ''
|
|
1290
|
+
}`,
|
|
1291
|
+
)
|
|
1292
|
+
.get(...scopeParams) as { n: number; bytes: number }
|
|
1666
1293
|
const since = Date.now() - 24 * 60 * 60 * 1000
|
|
1667
1294
|
const runs = this.db
|
|
1668
1295
|
.prepare(
|
|
1669
|
-
|
|
1296
|
+
`SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN status IN ('cache-hit', 'cache-hit-remote') THEN 1 ELSE 0 END), 0) AS hits FROM runs WHERE started_at >= ? AND ${EXECUTED_RUNS_SQL}${
|
|
1297
|
+
scoped ? ' AND project = ?' : ''
|
|
1298
|
+
}`,
|
|
1670
1299
|
)
|
|
1671
|
-
.get(since) as { total: number; hits: number }
|
|
1300
|
+
.get(since, ...scopeParams) as { total: number; hits: number }
|
|
1672
1301
|
return {
|
|
1673
1302
|
entryCount: aggregate.n,
|
|
1674
1303
|
totalBytes: aggregate.bytes,
|
|
@@ -1679,7 +1308,7 @@ export class Cache implements CacheLayer {
|
|
|
1679
1308
|
|
|
1680
1309
|
async prune(options: PruneOptions): Promise<PruneResult> {
|
|
1681
1310
|
this.flushAccessed()
|
|
1682
|
-
const { olderThanMs, maxBytes } = options
|
|
1311
|
+
const { olderThanMs, maxBytes, dryRun = false } = options
|
|
1683
1312
|
if (olderThanMs === undefined && maxBytes === undefined) {
|
|
1684
1313
|
throw new Error('prune: pass at least one of `olderThanMs` or `maxBytes`')
|
|
1685
1314
|
}
|
|
@@ -1726,6 +1355,15 @@ export class Cache implements CacheLayer {
|
|
|
1726
1355
|
// Promise.all over the unlinks. The IN-list is chunked at 900 like
|
|
1727
1356
|
// flushAccessed so a huge eviction stays under any build's
|
|
1728
1357
|
// bound-parameter ceiling.
|
|
1358
|
+
if (dryRun) {
|
|
1359
|
+
const orphans = await this.orphanStats()
|
|
1360
|
+
return {
|
|
1361
|
+
evicted: victims.size,
|
|
1362
|
+
bytesFreed,
|
|
1363
|
+
orphans: orphans.orphans,
|
|
1364
|
+
orphanBytes: orphans.orphanBytes,
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1729
1367
|
if (victims.size > 0) {
|
|
1730
1368
|
const hashes = [...victims]
|
|
1731
1369
|
this.db.transaction(() => {
|
|
@@ -1739,7 +1377,83 @@ export class Cache implements CacheLayer {
|
|
|
1739
1377
|
await Promise.all(hashes.map((h) => rm(this.tarPath(h), { force: true })))
|
|
1740
1378
|
}
|
|
1741
1379
|
|
|
1742
|
-
|
|
1380
|
+
const orphans = await this.reapOrphans()
|
|
1381
|
+
return { evicted: victims.size, bytesFreed, ...orphans }
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
/**
|
|
1385
|
+
* Unlink artifacts the index does not know: a `<hash>.tar.zst` with no
|
|
1386
|
+
* `entries` row (a `SCHEMA_VERSION` drop, a `cache.db` deleted by hand)
|
|
1387
|
+
* and a `<hash>.tar.zst.tmp-*` a crashed save never renamed. Nothing
|
|
1388
|
+
* else reaps them — a lookup starts at the row, and a save of the same
|
|
1389
|
+
* key renames over the file, so a key that never recurs leaks its bytes
|
|
1390
|
+
* forever. Files younger than the grace window are left alone: a save
|
|
1391
|
+
* renames the artifact into place BEFORE its row commits, and its temp
|
|
1392
|
+
* exists while the bytes are still being written, so a fresh file
|
|
1393
|
+
* without a row is a save in flight, not an orphan.
|
|
1394
|
+
*/
|
|
1395
|
+
private async reapOrphans(): Promise<{ orphans: number; orphanBytes: number }> {
|
|
1396
|
+
let orphans = 0
|
|
1397
|
+
let orphanBytes = 0
|
|
1398
|
+
await Promise.all(
|
|
1399
|
+
(await this.scanOrphans()).map(async (o) => {
|
|
1400
|
+
try {
|
|
1401
|
+
// unlink, not `rm({ force })`: force swallows ENOENT, and a file a
|
|
1402
|
+
// concurrent prune took first must not be counted as ours.
|
|
1403
|
+
await unlink(o.file)
|
|
1404
|
+
orphans += 1
|
|
1405
|
+
orphanBytes += o.size
|
|
1406
|
+
} catch {
|
|
1407
|
+
// Gone under us (a concurrent prune, a save's own cleanup): not ours.
|
|
1408
|
+
}
|
|
1409
|
+
}),
|
|
1410
|
+
)
|
|
1411
|
+
return { orphans, orphanBytes }
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
/** What `prune()` would reap right now, for `vx info` to say before anyone prunes. */
|
|
1415
|
+
async orphanStats(): Promise<{ orphans: number; orphanBytes: number }> {
|
|
1416
|
+
const found = await this.scanOrphans()
|
|
1417
|
+
let orphanBytes = 0
|
|
1418
|
+
for (const o of found) orphanBytes += o.size
|
|
1419
|
+
return { orphans: found.length, orphanBytes }
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
/** Row-less artifacts and temps past the in-flight grace window: one readdir, one stat per candidate. */
|
|
1423
|
+
private async scanOrphans(): Promise<Array<{ file: string; size: number }>> {
|
|
1424
|
+
let names: string[]
|
|
1425
|
+
try {
|
|
1426
|
+
names = await readdir(this.cacheDir)
|
|
1427
|
+
} catch {
|
|
1428
|
+
return []
|
|
1429
|
+
}
|
|
1430
|
+
const indexed = new Set(
|
|
1431
|
+
(this.db.prepare('SELECT hash FROM entries').all() as Array<{ hash: string }>).map(
|
|
1432
|
+
(r) => r.hash,
|
|
1433
|
+
),
|
|
1434
|
+
)
|
|
1435
|
+
const cutoff = Date.now() - ORPHAN_GRACE_MS
|
|
1436
|
+
const candidates: string[] = []
|
|
1437
|
+
for (const name of names) {
|
|
1438
|
+
if (name.indexOf('.tar.zst.tmp-') > 0) {
|
|
1439
|
+
candidates.push(name)
|
|
1440
|
+
} else if (name.endsWith('.tar.zst') && !indexed.has(name.slice(0, -'.tar.zst'.length))) {
|
|
1441
|
+
candidates.push(name)
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
const found: Array<{ file: string; size: number }> = []
|
|
1445
|
+
await Promise.all(
|
|
1446
|
+
candidates.map(async (name) => {
|
|
1447
|
+
const file = path.join(this.cacheDir, name)
|
|
1448
|
+
try {
|
|
1449
|
+
const st = await stat(file)
|
|
1450
|
+
if (st.isFile() && st.mtimeMs <= cutoff) found.push({ file, size: st.size })
|
|
1451
|
+
} catch {
|
|
1452
|
+
// Gone between readdir and stat: not an orphan any more.
|
|
1453
|
+
}
|
|
1454
|
+
}),
|
|
1455
|
+
)
|
|
1456
|
+
return found
|
|
1743
1457
|
}
|
|
1744
1458
|
|
|
1745
1459
|
close(): void {
|
|
@@ -1747,15 +1461,30 @@ export class Cache implements CacheLayer {
|
|
|
1747
1461
|
// executed task per invocation — a 2000-task repo accretes ~20k
|
|
1748
1462
|
// rows in days, inflating insert and checkpoint cost forever.
|
|
1749
1463
|
// 30 days comfortably covers `vx stats` (24 h windows) and any
|
|
1750
|
-
// CI-side analytics consumers.
|
|
1464
|
+
// CI-side analytics consumers. The `invocations` header table (one
|
|
1465
|
+
// row per `vx run`) is pruned on the SAME window — otherwise a
|
|
1466
|
+
// header would outlive its `runs` rows, so `vx info`/`vx last` would
|
|
1467
|
+
// list an invocation whose task detail is already gone, and the
|
|
1468
|
+
// table would grow unbounded on a long-lived checkout.
|
|
1751
1469
|
try {
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1470
|
+
const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000
|
|
1471
|
+
this.history.pruneOlderThan(cutoff)
|
|
1472
|
+
this.configEvals.pruneOlderThan(cutoff)
|
|
1755
1473
|
} catch {
|
|
1756
1474
|
// Retention is best-effort; never block closing the handle.
|
|
1757
1475
|
}
|
|
1758
|
-
|
|
1476
|
+
try {
|
|
1477
|
+
this.flushAccessed()
|
|
1478
|
+
} catch {
|
|
1479
|
+
// Same contract as the retention prune above, which was already
|
|
1480
|
+
// guarded while this sibling was not: `accessed_at` is LRU
|
|
1481
|
+
// bookkeeping, never correctness, and the run's results are
|
|
1482
|
+
// already recorded by the time we get here. Letting it throw also
|
|
1483
|
+
// SKIPPED `db.close()` below, leaking the handle. Reachable when
|
|
1484
|
+
// the cache dir is removed under a live handle (a concurrent
|
|
1485
|
+
// `rm -rf .vx/cache`): macOS answers SQLITE_IOERR_VNODE on a
|
|
1486
|
+
// write to an unlinked file where Linux happily writes on.
|
|
1487
|
+
}
|
|
1759
1488
|
this.db.close()
|
|
1760
1489
|
}
|
|
1761
1490
|
|
|
@@ -1764,67 +1493,6 @@ export class Cache implements CacheLayer {
|
|
|
1764
1493
|
}
|
|
1765
1494
|
}
|
|
1766
1495
|
|
|
1767
|
-
/**
|
|
1768
|
-
* Bind a RunRecord to the positional parameters expected by the
|
|
1769
|
-
* `insertRun` prepared statement (17 columns). Shared between the
|
|
1770
|
-
* single and batched record paths.
|
|
1771
|
-
*/
|
|
1772
|
-
function bindRun(run: RunRecord): SQLQueryBindings[] {
|
|
1773
|
-
return [
|
|
1774
|
-
run.hash,
|
|
1775
|
-
run.project,
|
|
1776
|
-
run.task,
|
|
1777
|
-
run.status,
|
|
1778
|
-
run.exitCode,
|
|
1779
|
-
run.durationMs,
|
|
1780
|
-
run.forwardArgs ? JSON.stringify(run.forwardArgs) : null,
|
|
1781
|
-
run.startedAt,
|
|
1782
|
-
run.endedAt,
|
|
1783
|
-
run.runId ?? null,
|
|
1784
|
-
run.cpuMs ?? null,
|
|
1785
|
-
run.peakRssBytes ?? null,
|
|
1786
|
-
run.wallclockStartNs !== undefined ? run.wallclockStartNs : null,
|
|
1787
|
-
run.wallclockEndNs !== undefined ? run.wallclockEndNs : null,
|
|
1788
|
-
run.cacheHit === undefined ? null : run.cacheHit ? 1 : 0,
|
|
1789
|
-
run.attempts ?? null,
|
|
1790
|
-
]
|
|
1791
|
-
}
|
|
1792
|
-
|
|
1793
|
-
/**
|
|
1794
|
-
* Bind an InvocationRecord to the positional parameters of the
|
|
1795
|
-
* `insertInvocation` prepared statement (25 columns). Booleans map to
|
|
1796
|
-
* 0/1; null-or-bool columns (`dirty`) keep null distinct from 0.
|
|
1797
|
-
*/
|
|
1798
|
-
function bindInvocation(inv: InvocationRecord): SQLQueryBindings[] {
|
|
1799
|
-
return [
|
|
1800
|
-
inv.runId,
|
|
1801
|
-
inv.command,
|
|
1802
|
-
inv.requestedTasks,
|
|
1803
|
-
inv.cachePolicy,
|
|
1804
|
-
inv.concurrency,
|
|
1805
|
-
inv.flow,
|
|
1806
|
-
inv.startedAt,
|
|
1807
|
-
inv.endedAt,
|
|
1808
|
-
inv.totalDurationMs,
|
|
1809
|
-
inv.taskCount,
|
|
1810
|
-
inv.failedCount,
|
|
1811
|
-
inv.hitCount,
|
|
1812
|
-
inv.hitLocalCount,
|
|
1813
|
-
inv.hitRemoteCount,
|
|
1814
|
-
inv.exitOk ? 1 : 0,
|
|
1815
|
-
inv.commitSha,
|
|
1816
|
-
inv.branch,
|
|
1817
|
-
inv.dirty === null ? null : inv.dirty ? 1 : 0,
|
|
1818
|
-
inv.ci ? 1 : 0,
|
|
1819
|
-
inv.ciProvider,
|
|
1820
|
-
inv.host,
|
|
1821
|
-
inv.os,
|
|
1822
|
-
inv.arch,
|
|
1823
|
-
inv.vxVersion,
|
|
1824
|
-
inv.tags,
|
|
1825
|
-
]
|
|
1826
|
-
}
|
|
1827
|
-
|
|
1828
1496
|
function splitTaskId(id: string): [string, string] {
|
|
1829
1497
|
const i = id.indexOf('#')
|
|
1830
1498
|
if (i < 0) return [id, '']
|