@vzn/vx 0.0.652 → 0.0.653
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/launcher.sh +2 -1
- package/package.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/show.json +1 -0
- package/skills/vx/SKILL.md +3 -3
- package/src/bin.ts +25 -3
- package/src/cache/archive.ts +196 -31
- package/src/cache/cache.ts +863 -225
- package/src/cache/checkpointer.ts +102 -0
- package/src/cache/file-hashes.ts +2 -1
- package/src/cache/fold-memo.ts +24 -4
- package/src/cache/fs-clock.ts +26 -0
- package/src/cache/git-inputs.ts +188 -22
- package/src/cache/history-stream.ts +22 -5
- package/src/cache/index.ts +10 -1
- package/src/cache/inputs.ts +573 -152
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +23 -10
- package/src/cache/layered-cache.ts +28 -7
- package/src/cache/output-index.ts +106 -44
- package/src/cache/plugin-cache.ts +2 -2
- package/src/cache/policy.ts +6 -2
- package/src/cache/remote-lane.ts +3 -1
- package/src/cache/run-history.ts +50 -10
- package/src/cache/schema.ts +42 -9
- package/src/cli/cache.ts +147 -34
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +9 -2
- package/src/cli/docs.ts +4 -2
- package/src/cli/help.ts +25 -2
- package/src/cli/index.ts +38 -7
- package/src/cli/info.ts +4 -3
- package/src/cli/init.ts +55 -20
- package/src/cli/last.ts +184 -41
- package/src/cli/lock.ts +187 -24
- package/src/cli/plan-format.ts +24 -21
- package/src/cli/plugin-commands.ts +6 -1
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +68 -34
- package/src/cli/select.ts +160 -64
- package/src/cli/show.ts +63 -18
- package/src/cli/task-verb.ts +12 -7
- package/src/cli/upgrade.ts +210 -76
- package/src/cli/watch-filter.ts +88 -15
- package/src/cli/watch-fs.ts +34 -47
- package/src/cli/watch-judge.ts +72 -8
- package/src/cli/watch-set.ts +6 -4
- package/src/cli/watch.ts +179 -25
- package/src/cli/why.ts +107 -33
- package/src/cli/workspace-config.ts +11 -1
- package/src/config.ts +9 -0
- package/src/exec/env.ts +3 -1
- package/src/exec/executor.ts +76 -15
- package/src/exec/kill-tree.ts +7 -7
- package/src/exec/runner.ts +270 -108
- package/src/exec/sandbox-binds.ts +109 -18
- package/src/exec/sandbox-runtime.ts +134 -37
- package/src/exec/sandbox-violations.ts +250 -46
- package/src/exec/spawn-off.ts +10 -5
- package/src/exec/warm-shell.ts +29 -7
- package/src/graph/folds.ts +89 -16
- package/src/graph/index.ts +8 -0
- package/src/graph/priorities.ts +8 -5
- package/src/graph/scheduler.ts +173 -38
- package/src/graph/stand-ins.ts +36 -0
- package/src/graph/task-graph.ts +327 -141
- package/src/graph/task-status.ts +5 -1
- package/src/handoff.ts +45 -10
- package/src/history/failure-mode.ts +15 -5
- package/src/history/history.ts +103 -30
- package/src/history/index.ts +1 -0
- package/src/history/metrics.ts +189 -69
- package/src/index.ts +2 -1
- package/src/orchestrator/admission.ts +13 -9
- package/src/orchestrator/affected-tasks.ts +35 -13
- package/src/orchestrator/command-windows.ts +8 -5
- package/src/orchestrator/deferred-outputs.ts +41 -12
- package/src/orchestrator/doctor.ts +21 -9
- package/src/orchestrator/download-policy.ts +3 -3
- package/src/orchestrator/excluded-keys.ts +14 -2
- package/src/orchestrator/execute-args.ts +9 -2
- package/src/orchestrator/execute-task.ts +564 -123
- package/src/orchestrator/graph-memo.ts +26 -4
- package/src/orchestrator/graph-stage.ts +27 -4
- package/src/orchestrator/held-servers.ts +36 -14
- package/src/orchestrator/hit-restore.ts +136 -87
- package/src/orchestrator/index.ts +4 -1
- package/src/orchestrator/keyed-projects.ts +25 -0
- package/src/orchestrator/local-floor.ts +21 -3
- package/src/orchestrator/lockfile-claim.ts +30 -4
- package/src/orchestrator/miss-save.ts +33 -53
- package/src/orchestrator/options.ts +13 -0
- package/src/orchestrator/output-host.ts +16 -4
- package/src/orchestrator/output-log.ts +1 -54
- package/src/orchestrator/persistent-task.ts +31 -17
- package/src/orchestrator/persistent.ts +152 -15
- package/src/orchestrator/placement.ts +81 -15
- package/src/orchestrator/plan.ts +37 -15
- package/src/orchestrator/plugin-host.ts +232 -108
- package/src/orchestrator/plugin.ts +53 -27
- package/src/orchestrator/prepare.ts +194 -38
- package/src/orchestrator/projects.ts +89 -10
- package/src/orchestrator/read-only.ts +115 -0
- package/src/orchestrator/run-context.ts +6 -2
- package/src/orchestrator/run-failures.ts +43 -14
- package/src/orchestrator/run-id.ts +13 -2
- package/src/orchestrator/run-keys.ts +87 -28
- package/src/orchestrator/run-live.ts +16 -13
- package/src/orchestrator/run-lock.ts +27 -4
- package/src/orchestrator/run-records.ts +12 -10
- package/src/orchestrator/run-report.ts +2 -1
- package/src/orchestrator/run-stop.ts +17 -9
- package/src/orchestrator/run.ts +443 -107
- package/src/orchestrator/sandbox-fix.ts +40 -12
- package/src/orchestrator/sandbox-request.ts +286 -59
- package/src/orchestrator/save-lane.ts +24 -13
- package/src/orchestrator/shell-verdict.ts +20 -1
- package/src/orchestrator/signals.ts +103 -8
- package/src/orchestrator/stable-keys.ts +17 -9
- package/src/orchestrator/task-artifacts.ts +15 -2
- package/src/orchestrator/task-hash.ts +187 -30
- package/src/orchestrator/telemetry-host.ts +25 -6
- package/src/orchestrator/telemetry.ts +80 -12
- package/src/orchestrator/upstream.ts +3 -1
- package/src/report/events.ts +20 -3
- package/src/report/forecast.ts +103 -37
- package/src/report/framed-output.ts +165 -157
- package/src/report/index.ts +2 -1
- package/src/report/logger.ts +107 -30
- package/src/report/plain-output.ts +13 -9
- package/src/report/status-line.ts +28 -55
- package/src/report/summary.ts +57 -14
- package/src/settings.ts +42 -6
- package/src/ui/components.ts +36 -42
- package/src/ui/index.ts +3 -1
- package/src/ui/live-region.ts +67 -8
- package/src/ui/terminal.ts +12 -0
- package/src/ui/theme.ts +16 -1
- package/src/util/config-frame.ts +17 -3
- package/src/util/edit-distance.ts +17 -1
- package/src/util/errors.ts +23 -7
- package/src/util/foreign-cache-dir.ts +60 -0
- package/src/util/git-ignored.ts +59 -5
- package/src/util/index.ts +15 -1
- package/src/util/paths.ts +50 -6
- package/src/util/plain-text.ts +19 -0
- package/src/util/secret-mask.ts +57 -1
- package/src/util/settle.ts +16 -0
- package/src/util/size.ts +12 -2
- package/src/util/write-file.ts +86 -9
- package/src/workspace/affected.ts +225 -27
- package/src/workspace/config-bytecode.ts +23 -4
- package/src/workspace/config-eval.ts +90 -16
- package/src/workspace/config-imports.ts +96 -41
- package/src/workspace/config-schema.ts +126 -25
- package/src/workspace/dependencies-lock.ts +7 -14
- package/src/workspace/filter.ts +10 -1
- package/src/workspace/index.ts +3 -1
- package/src/workspace/json-data.ts +34 -8
- package/src/workspace/lockfile.ts +86 -37
- package/src/workspace/migration.ts +46 -25
- package/src/workspace/module-state.ts +62 -0
- package/src/workspace/nested-dirs.ts +8 -0
- package/src/workspace/project-loader.ts +276 -32
- package/src/workspace/repo-id.ts +29 -6
- package/src/workspace/workspace.ts +190 -30
package/src/cache/key-fold.ts
CHANGED
|
@@ -125,7 +125,7 @@ import type { CacheKeyInput } from './layer.js'
|
|
|
125
125
|
// a v48 artifact keeps none, and a hit gave `chmod 700 dist/keys` back as 0755.
|
|
126
126
|
// v50: both v49 changes in one build (2026-10-29): a v49 entry was written
|
|
127
127
|
// by a vx that had only one of them.
|
|
128
|
-
export const CACHE_VERSION = 'vx-cache-
|
|
128
|
+
export const CACHE_VERSION = 'vx-cache-v52'
|
|
129
129
|
|
|
130
130
|
/**
|
|
131
131
|
* The digest a key folds for an input gone between its enumeration and its
|
package/src/cache/layer.ts
CHANGED
|
@@ -142,7 +142,8 @@ export interface CacheEntry {
|
|
|
142
142
|
outputDirRows?: OutputDirRow[]
|
|
143
143
|
/**
|
|
144
144
|
* The task's output log, always present (may be empty): both streams in
|
|
145
|
-
* the order the run printed them (`orchestrator/output-log.ts`).
|
|
145
|
+
* the order the run printed them (`orchestrator/output-log.ts`). A local
|
|
146
|
+
* entry's is a getter that reads it on each access: read it once.
|
|
146
147
|
*/
|
|
147
148
|
stdout: string
|
|
148
149
|
storedAt: string
|
|
@@ -511,11 +512,12 @@ export function isIndexFull(err: unknown): boolean {
|
|
|
511
512
|
}
|
|
512
513
|
|
|
513
514
|
/**
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
515
|
+
* Bytes that are not the artifact their key names. Core's ingest throws
|
|
516
|
+
* it for bytes that do not decompress or parse, before anything is
|
|
517
|
+
* stored; a remote layer that checks its bytes as they pass (a digest, a
|
|
518
|
+
* signature) errors its `get` body with it to say they are wrong there,
|
|
519
|
+
* not cut short. Either way the hit is a miss, and the re-run's save is
|
|
520
|
+
* sent over that remote's copy.
|
|
519
521
|
*/
|
|
520
522
|
export class CorruptArtifactError extends Error {
|
|
521
523
|
constructor(
|
|
@@ -528,10 +530,15 @@ export class CorruptArtifactError extends Error {
|
|
|
528
530
|
}
|
|
529
531
|
}
|
|
530
532
|
|
|
533
|
+
/** A `CorruptArtifactError` by name: a plugin may load its own copy of core. */
|
|
534
|
+
export function isCorruptArtifact(err: unknown): boolean {
|
|
535
|
+
return err instanceof Error && err.name === 'CorruptArtifactError'
|
|
536
|
+
}
|
|
537
|
+
|
|
531
538
|
/**
|
|
532
|
-
* `restoreOutputs` found no artifact where the probe found one:
|
|
533
|
-
* removed in between — a
|
|
534
|
-
*
|
|
539
|
+
* `restoreOutputs` found no artifact where the probe found one: another
|
|
540
|
+
* process removed or replaced it in between — a prune or a clean for a
|
|
541
|
+
* workspace sharing the cache directory, a re-save of the key. The
|
|
535
542
|
* bytes are not wrong, they are gone, so the entry is a MISS and the task
|
|
536
543
|
* runs (execute-task.ts); nothing about the cache needs throwing away.
|
|
537
544
|
* Also thrown, with its `reason`, for an artifact whose bytes ARE wrong
|
|
@@ -547,7 +554,8 @@ export class ArtifactVanishedError extends Error {
|
|
|
547
554
|
cause?: unknown,
|
|
548
555
|
) {
|
|
549
556
|
super(
|
|
550
|
-
reason ??
|
|
557
|
+
reason ??
|
|
558
|
+
`its cache artifact ${hash} vanished before the restore (another process removed or replaced it)`,
|
|
551
559
|
cause === undefined ? undefined : { cause },
|
|
552
560
|
)
|
|
553
561
|
this.name = 'ArtifactVanishedError'
|
|
@@ -834,6 +842,11 @@ export interface CacheLayer {
|
|
|
834
842
|
artifactSize?(hash: string): number | undefined
|
|
835
843
|
/** The output files' bytes, uncompressed, of the entry this process just saved; asked once. */
|
|
836
844
|
outputBytes?(hash: string): number | undefined
|
|
845
|
+
/**
|
|
846
|
+
* True when the last `save` of `hash` found the key stored and kept that
|
|
847
|
+
* entry (a `--force` run): nothing of this run's was saved; asked once.
|
|
848
|
+
*/
|
|
849
|
+
kept?(hash: string): boolean
|
|
837
850
|
prune(options: PruneOptions): Promise<PruneResult>
|
|
838
851
|
close(): void
|
|
839
852
|
}
|
|
@@ -35,7 +35,8 @@ import type {
|
|
|
35
35
|
SaveArgs,
|
|
36
36
|
} from './cache.js'
|
|
37
37
|
import type { OutputDirRow } from './cache.js'
|
|
38
|
-
import {
|
|
38
|
+
import { shareSeenHasher } from './cache.js'
|
|
39
|
+
import { isCorruptArtifact } from './layer.js'
|
|
39
40
|
import { RemoteLane, type ResolvedRemote } from './remote-lane.js'
|
|
40
41
|
import type { PluginSecrets } from '../util/index.js'
|
|
41
42
|
|
|
@@ -182,7 +183,9 @@ export interface RemoteCacheLayer {
|
|
|
182
183
|
*/
|
|
183
184
|
hasMany?(hashes: readonly string[]): Promise<Map<string, ArtifactInfo> | null>
|
|
184
185
|
/**
|
|
185
|
-
* Fetch an artifact; `null` = miss
|
|
186
|
+
* Fetch an artifact; `null` = miss, `undefined` = no answer (the layer
|
|
187
|
+
* is off, its refusal already said): a miss that keeps the hits the
|
|
188
|
+
* index holds. `body` is read once, by core,
|
|
186
189
|
* straight into the local cache — an HTTP wire returns its `fetch`
|
|
187
190
|
* `Response`, a chunked wire `new Response(stream)`, bytes in hand
|
|
188
191
|
* `new Blob([bytes])`. `durationMs` is the producing task's duration
|
|
@@ -190,7 +193,9 @@ export interface RemoteCacheLayer {
|
|
|
190
193
|
* nothing and the read is a miss, so a layer can check bytes as they pass
|
|
191
194
|
* (a digest, a signature) and refuse at the end.
|
|
192
195
|
*/
|
|
193
|
-
get(
|
|
196
|
+
get(
|
|
197
|
+
hash: string,
|
|
198
|
+
): Promise<{ body: Blob | Response; durationMs: number | undefined } | null | undefined>
|
|
194
199
|
/**
|
|
195
200
|
* Store an artifact (fire-and-forget from LayeredCache's perspective).
|
|
196
201
|
* `body` is file-backed (`Bun.file`) when the local store holds the
|
|
@@ -198,10 +203,14 @@ export interface RemoteCacheLayer {
|
|
|
198
203
|
* read of it sees the same bytes. An entry is immutable: a `put` of a
|
|
199
204
|
* key the remote holds keeps the stored bytes. Core never asks `has`
|
|
200
205
|
* first; a wire that would overwrite checks inside, unless told `missing`.
|
|
206
|
+
* `replace`: the remote's copy of `hash` did not read back (a
|
|
207
|
+
* `CorruptArtifactError`, here or on another machine of the run), so
|
|
208
|
+
* these bytes go over it; a wire that cannot write over a held key
|
|
209
|
+
* returns `false`, or the run counts the bad copy fixed.
|
|
201
210
|
* `false`: nothing was sent and nothing failed (a layer that writes
|
|
202
211
|
* nothing this run, such as one with no token), so no upload is counted.
|
|
203
212
|
*/
|
|
204
|
-
put(hash: string, body: Blob, meta: { durationMs: number }): Promise<void | false>
|
|
213
|
+
put(hash: string, body: Blob, meta: { durationMs: number; replace?: true }): Promise<void | false>
|
|
205
214
|
/**
|
|
206
215
|
* The run found `hash` in no remote, and a `put` of it follows: a wire
|
|
207
216
|
* that checks before it overwrites can skip that request.
|
|
@@ -216,6 +225,8 @@ export class LayeredCache implements CacheLayer {
|
|
|
216
225
|
private readonly lanes: readonly RemoteLane[]
|
|
217
226
|
/** Each key `save` uploaded: whether a remote took it (`uploaded`). */
|
|
218
227
|
private readonly sent = new Map<string, Promise<boolean>>()
|
|
228
|
+
/** Each key whose stored entry `save` kept, until `kept` answers for it. */
|
|
229
|
+
private readonly keptHere = new Set<string>()
|
|
219
230
|
|
|
220
231
|
/**
|
|
221
232
|
* In-flight remote pulls keyed by hash. `prefetch` and `get` both go
|
|
@@ -251,6 +262,7 @@ export class LayeredCache implements CacheLayer {
|
|
|
251
262
|
uploadConcurrency?: number,
|
|
252
263
|
) {
|
|
253
264
|
this.lanes = remotes.map((r) => new RemoteLane(r, local.traffic, uploadConcurrency))
|
|
265
|
+
shareSeenHasher(local, this)
|
|
254
266
|
}
|
|
255
267
|
|
|
256
268
|
// Asked, not kept: a remote whose `open` failed leaves the run after this is built.
|
|
@@ -555,7 +567,8 @@ export class LayeredCache implements CacheLayer {
|
|
|
555
567
|
lane.report('download', hash, err)
|
|
556
568
|
// Bad bytes there: the re-run's save sends good ones over them, or
|
|
557
569
|
// every machine rebuilt the task and none repaired it (2026-10-24).
|
|
558
|
-
|
|
570
|
+
// Core's ingest says so, or the layer's own check of its body does.
|
|
571
|
+
if (isCorruptArtifact(err)) lane.distrust(hash)
|
|
559
572
|
return false
|
|
560
573
|
} finally {
|
|
561
574
|
this.downloads.delete(hash)
|
|
@@ -579,6 +592,10 @@ export class LayeredCache implements CacheLayer {
|
|
|
579
592
|
return this.local.outputBytes(hash)
|
|
580
593
|
}
|
|
581
594
|
|
|
595
|
+
kept(hash: string): boolean {
|
|
596
|
+
return this.keptHere.delete(hash) || this.local.kept(hash)
|
|
597
|
+
}
|
|
598
|
+
|
|
582
599
|
hashFile(filePath: string): Promise<string> {
|
|
583
600
|
return this.local.hashFile(filePath)
|
|
584
601
|
}
|
|
@@ -596,6 +613,8 @@ export class LayeredCache implements CacheLayer {
|
|
|
596
613
|
// Local write honors its own gates inside Cache.save (no-op when local
|
|
597
614
|
// writes are disabled).
|
|
598
615
|
await this.local.save(args)
|
|
616
|
+
const kept = this.local.kept(args.hash)
|
|
617
|
+
if (kept) this.keptHere.add(args.hash)
|
|
599
618
|
const writable = this.lanes.filter((l) => l.write)
|
|
600
619
|
if (writable.length === 0) return
|
|
601
620
|
// Write-through upload, OFF the task's critical path: each PUT runs in
|
|
@@ -618,9 +637,11 @@ export class LayeredCache implements CacheLayer {
|
|
|
618
637
|
// while this task's output files are still on disk. Deferring THIS
|
|
619
638
|
// read would pack whatever the tree happens to hold when the job
|
|
620
639
|
// runs. Such a run keeps the old memory profile by necessity — the
|
|
621
|
-
// bytes exist nowhere else.
|
|
640
|
+
// bytes exist nowhere else. So with an entry the save kept (`--force`):
|
|
641
|
+
// its artifact is an older run's, maybe the corrupt one `--force` was
|
|
642
|
+
// run to replace, and a remote holding none took it for good.
|
|
622
643
|
let packed: Uint8Array | undefined
|
|
623
|
-
if (!this.local.localWritesEnabled) {
|
|
644
|
+
if (!this.local.localWritesEnabled || kept) {
|
|
624
645
|
try {
|
|
625
646
|
packed = await this.local.packArtifactBytes(args)
|
|
626
647
|
} catch (err) {
|
|
@@ -18,6 +18,7 @@ const FILE_PREFIX_MTIME = -2
|
|
|
18
18
|
import type { Database } from 'bun:sqlite'
|
|
19
19
|
import { inHashes, lazyStatement } from './schema.js'
|
|
20
20
|
import { modeMatches } from './umask.js'
|
|
21
|
+
import { stampGrainMs } from './fs-clock.js'
|
|
21
22
|
import { lstatSync, readdirSync, readlinkSync, statSync } from 'node:fs'
|
|
22
23
|
import path from 'node:path'
|
|
23
24
|
import {
|
|
@@ -43,6 +44,15 @@ export const LINK_MODE = 0o120000
|
|
|
43
44
|
*/
|
|
44
45
|
export const DIR_MODE = 0o040000
|
|
45
46
|
|
|
47
|
+
/**
|
|
48
|
+
* A file system that keeps whole (or FAT's even) seconds stored a restore's
|
|
49
|
+
* millisecond "now" as the second it falls in: that stamp, not a write.
|
|
50
|
+
*/
|
|
51
|
+
function secondOf(statMs: number, datedMs: number): boolean {
|
|
52
|
+
const grain = stampGrainMs(statMs)
|
|
53
|
+
return grain > 0 && datedMs >= statMs && datedMs - statMs < grain
|
|
54
|
+
}
|
|
55
|
+
|
|
46
56
|
/** A link row is current while a link with that target stands there; never followed. */
|
|
47
57
|
function linkCurrent(abs: string, e: { size: number; mtimeMs: number }): boolean {
|
|
48
58
|
if (!lstatSync(abs).isSymbolicLink()) return false
|
|
@@ -50,6 +60,22 @@ function linkCurrent(abs: string, e: { size: number; mtimeMs: number }): boolean
|
|
|
50
60
|
return to.byteLength === e.size && Bun.hash.crc32(to) === e.mtimeMs
|
|
51
61
|
}
|
|
52
62
|
|
|
63
|
+
const FILES_SELECT =
|
|
64
|
+
'SELECT f.entry_hash, f.path, f.size_bytes, f.mode, f.mtime_ms, s.ino, s.ctime_ms, s.mtime_ms AS stamp_mtime_ms'
|
|
65
|
+
const FILES_STAMPS = 'LEFT JOIN output_stamps s ON s.entry_hash = f.entry_hash AND s.path = f.path'
|
|
66
|
+
const FILES_JOINED = `${FILES_SELECT} FROM json_each(?) q JOIN output_files f ON f.entry_hash = q.value ${FILES_STAMPS}`
|
|
67
|
+
/**
|
|
68
|
+
* A row as this index loads it: with its stamp, the mtime the file carried
|
|
69
|
+
* when stamped, which a restore that wrote the file dated now and so is
|
|
70
|
+
* not the row's saved one.
|
|
71
|
+
*/
|
|
72
|
+
interface StampedRow extends OutputFileRow {
|
|
73
|
+
stampMtimeMs?: number
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const DIRS_JOINED =
|
|
77
|
+
'SELECT d.entry_hash, d.path, d.mtime_ms FROM json_each(?) q JOIN output_dirs d ON d.entry_hash = q.value'
|
|
78
|
+
|
|
53
79
|
export class OutputIndex {
|
|
54
80
|
private readonly insertOutputFile: ReturnType<Database['prepare']>
|
|
55
81
|
private readonly deleteOutputFiles: ReturnType<Database['prepare']>
|
|
@@ -67,8 +93,12 @@ export class OutputIndex {
|
|
|
67
93
|
* stage, 47–72 ms at run end (item 622).
|
|
68
94
|
*/
|
|
69
95
|
private readonly pendingDirs = new Map<string, Array<[string, number]> | null>()
|
|
70
|
-
/**
|
|
71
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Stamps taken and not yet written, flushed with the directory snapshots:
|
|
98
|
+
* inode and ctime by path, so a batch read finds a row's stamp without
|
|
99
|
+
* scanning them all (rows × stamps was ~7 s for a 40,000-file output).
|
|
100
|
+
*/
|
|
101
|
+
private readonly pendingStamps = new Map<string, Map<string, [number, number, number]>>()
|
|
72
102
|
/**
|
|
73
103
|
* The rows the last batched probe loaded, by hash: a hit's restore and
|
|
74
104
|
* the stamps after it read their rows here instead of the index, twice
|
|
@@ -110,7 +140,7 @@ export class OutputIndex {
|
|
|
110
140
|
this.entryExists = lazyStatement(this.db, 'SELECT 1 FROM entries WHERE hash = ?')
|
|
111
141
|
this.stampOutputFile = lazyStatement(
|
|
112
142
|
this.db,
|
|
113
|
-
'INSERT INTO output_stamps(entry_hash, path, ino, ctime_ms) VALUES (?, ?, ?, ?) ON CONFLICT(entry_hash, path) DO UPDATE SET ino = excluded.ino, ctime_ms = excluded.ctime_ms',
|
|
143
|
+
'INSERT INTO output_stamps(entry_hash, path, ino, ctime_ms, mtime_ms) VALUES (?, ?, ?, ?, ?) ON CONFLICT(entry_hash, path) DO UPDATE SET ino = excluded.ino, ctime_ms = excluded.ctime_ms, mtime_ms = excluded.mtime_ms',
|
|
114
144
|
)
|
|
115
145
|
this.deleteStamps = lazyStatement(this.db, 'DELETE FROM output_stamps WHERE entry_hash = ?')
|
|
116
146
|
}
|
|
@@ -165,12 +195,24 @@ export class OutputIndex {
|
|
|
165
195
|
for (const [hash, list] of rows) this.held.set(hash, list)
|
|
166
196
|
}
|
|
167
197
|
|
|
198
|
+
/** The probe's rows of `hash` when held: no read. */
|
|
199
|
+
heldRowsOf(hash: string): readonly OutputFileRow[] | undefined {
|
|
200
|
+
return this.held.get(hash)
|
|
201
|
+
}
|
|
202
|
+
|
|
168
203
|
/** `hash`'s rows: the probe's when held, else read. Path, size, mode and mtime only. */
|
|
169
204
|
rowsOf(hash: string): readonly OutputFileRow[] {
|
|
170
205
|
return this.held.get(hash) ?? this.loadOutputFilesBatch([hash]).get(hash) ?? []
|
|
171
206
|
}
|
|
172
207
|
|
|
173
|
-
|
|
208
|
+
/**
|
|
209
|
+
* `distinct`: `hashes` as a JSON array with no hash twice, which the probe
|
|
210
|
+
* has to hand. The rows are then joined from it, not filtered by an `IN`
|
|
211
|
+
* that builds a lookup of every hash first: ~1.5 ms of 8,002 hits, and as
|
|
212
|
+
* much again for `loadOutputDirsBatch` (2026-11-07). A repeated hash
|
|
213
|
+
* would repeat its rows, hence the caller's promise.
|
|
214
|
+
*/
|
|
215
|
+
loadOutputFilesBatch(hashes: readonly string[], distinct?: string): Map<string, OutputFileRow[]> {
|
|
174
216
|
const out = new Map<string, OutputFileRow[]>()
|
|
175
217
|
if (hashes.length === 0) return out
|
|
176
218
|
// `db.query` (not `db.prepare`) caches the compiled statement keyed by
|
|
@@ -180,11 +222,14 @@ export class OutputIndex {
|
|
|
180
222
|
// the stamps taken, not only the ones flushed: overlaid below from
|
|
181
223
|
// memory. A flush here committed a transaction per read, and a restore
|
|
182
224
|
// reads its rows twice.
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
225
|
+
let sql = FILES_JOINED
|
|
226
|
+
let params = [distinct!]
|
|
227
|
+
if (distinct === undefined || hashes.length === 1) {
|
|
228
|
+
const where = inHashes(hashes)
|
|
229
|
+
sql = `${FILES_SELECT} FROM output_files f ${FILES_STAMPS} WHERE f.entry_hash ${where.test}`
|
|
230
|
+
params = where.params
|
|
231
|
+
}
|
|
232
|
+
const rows = this.db.query(sql).all(...params) as Array<{
|
|
188
233
|
entry_hash: string
|
|
189
234
|
path: string
|
|
190
235
|
size_bytes: number
|
|
@@ -192,6 +237,7 @@ export class OutputIndex {
|
|
|
192
237
|
mtime_ms: number
|
|
193
238
|
ino: number | null
|
|
194
239
|
ctime_ms: number | null
|
|
240
|
+
stamp_mtime_ms: number | null
|
|
195
241
|
}>
|
|
196
242
|
for (const r of rows) {
|
|
197
243
|
let list = out.get(r.entry_hash)
|
|
@@ -199,31 +245,29 @@ export class OutputIndex {
|
|
|
199
245
|
list = []
|
|
200
246
|
out.set(r.entry_hash, list)
|
|
201
247
|
}
|
|
202
|
-
const row:
|
|
248
|
+
const row: StampedRow = {
|
|
203
249
|
path: r.path,
|
|
204
250
|
size: r.size_bytes,
|
|
205
251
|
mode: r.mode,
|
|
206
252
|
mtimeMs: r.mtime_ms,
|
|
207
253
|
}
|
|
208
|
-
if (r.ino !== null && r.ctime_ms !== null) {
|
|
254
|
+
if (r.ino !== null && r.ctime_ms !== null && r.stamp_mtime_ms !== null) {
|
|
209
255
|
row.ino = r.ino
|
|
210
256
|
row.ctimeMs = r.ctime_ms
|
|
257
|
+
row.stampMtimeMs = r.stamp_mtime_ms
|
|
211
258
|
}
|
|
212
|
-
const
|
|
213
|
-
if (
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
row.ctimeMs = ctime
|
|
218
|
-
break
|
|
219
|
-
}
|
|
259
|
+
const stamp = this.pendingStamps.get(r.entry_hash)?.get(r.path)
|
|
260
|
+
if (stamp !== undefined) {
|
|
261
|
+
row.ino = stamp[0]
|
|
262
|
+
row.ctimeMs = stamp[1]
|
|
263
|
+
row.stampMtimeMs = stamp[2]
|
|
220
264
|
}
|
|
221
265
|
list.push(row)
|
|
222
266
|
}
|
|
223
267
|
return out
|
|
224
268
|
}
|
|
225
269
|
|
|
226
|
-
isOutputsCurrent(projectDir: string, expected: readonly
|
|
270
|
+
isOutputsCurrent(projectDir: string, expected: readonly StampedRow[]): boolean {
|
|
227
271
|
// Empty manifest case (a task produced no outputs) → trivially
|
|
228
272
|
// current; the on-disk tree under projectDir is whatever it was,
|
|
229
273
|
// and nothing was supposed to land there.
|
|
@@ -239,17 +283,20 @@ export class OutputIndex {
|
|
|
239
283
|
try {
|
|
240
284
|
if (e.mode === LINK_MODE) return linkCurrent(path.join(projectDir, e.path), e)
|
|
241
285
|
const s = statSync(path.join(projectDir, e.path))
|
|
242
|
-
// An empty output directory: current while a directory stands there
|
|
243
|
-
if
|
|
286
|
+
// An empty output directory: current while a directory stands there,
|
|
287
|
+
// with the mode its row records if it records one.
|
|
288
|
+
if (e.path.endsWith('/'))
|
|
289
|
+
return (
|
|
290
|
+
s.isDirectory() && ((e.mode & 0o170000) !== DIR_MODE || modeMatches(s.mode, e.mode))
|
|
291
|
+
)
|
|
244
292
|
return (
|
|
245
293
|
s.size === e.size &&
|
|
246
294
|
modeMatches(s.mode, e.mode) &&
|
|
247
295
|
// MILLISECOND comparison (sub-ms tolerance for the float
|
|
248
|
-
// round-trip through utimes)
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
|
|
252
|
-
Math.abs(s.mtimeMs - e.mtimeMs) < 1 &&
|
|
296
|
+
// round-trip through utimes), against the mtime the file carried
|
|
297
|
+
// when stamped: the row's after a save; after a restore, now for
|
|
298
|
+
// a file it wrote and the file's own where its bytes stood.
|
|
299
|
+
Math.abs(s.mtimeMs - (e.stampMtimeMs ?? e.mtimeMs)) < 1 &&
|
|
253
300
|
// And the file is the one THIS machine last saw under this
|
|
254
301
|
// entry: two entries whose outputs carry one fixed mtime (a
|
|
255
302
|
// `tar -x`, `cp -p`, SOURCE_DATE_EPOCH) and one size matched
|
|
@@ -260,7 +307,8 @@ export class OutputIndex {
|
|
|
260
307
|
// (an ingest, a changed file at stamping) is never current. The
|
|
261
308
|
// residual: a same-size rewrite, in place or as a new file on the
|
|
262
309
|
// same inode, inside the coarse clock tick of the stamp, with the
|
|
263
|
-
// mtime forged back
|
|
310
|
+
// mtime forged back or written in that tick (a save's or a
|
|
311
|
+
// restore's file is dated in it).
|
|
264
312
|
e.ino !== undefined &&
|
|
265
313
|
s.ino === e.ino &&
|
|
266
314
|
Math.floor(s.ctimeMs) === e.ctimeMs
|
|
@@ -363,16 +411,23 @@ export class OutputIndex {
|
|
|
363
411
|
}
|
|
364
412
|
|
|
365
413
|
/**
|
|
366
|
-
* Stamp `hash`'s rows with each file's inode and
|
|
367
|
-
* a restore has left the tree equal to the entry.
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
414
|
+
* Stamp `hash`'s rows with each file's inode, ctime and mtime, once a
|
|
415
|
+
* save or a restore has left the tree equal to the entry. `dated` holds
|
|
416
|
+
* the mtime a restore left each file with, by row path (now for one it
|
|
417
|
+
* wrote); any other file is held to its row's. A file whose size, mode or mtime no
|
|
418
|
+
* longer matches is left unstamped, so a write that landed since is
|
|
419
|
+
* never vouched for; the next hit restores. Written with the directory
|
|
420
|
+
* snapshots, in one transaction.
|
|
371
421
|
*/
|
|
372
|
-
recordOutputStamps(
|
|
422
|
+
recordOutputStamps(
|
|
423
|
+
hash: string,
|
|
424
|
+
projectDir: string,
|
|
425
|
+
workspaceRoot: string,
|
|
426
|
+
dated?: ReadonlyMap<string, number>,
|
|
427
|
+
): void {
|
|
373
428
|
if (!this.memo) return
|
|
374
429
|
const rows = this.rowsOf(hash)
|
|
375
|
-
const stamps
|
|
430
|
+
const stamps = new Map<string, [number, number, number]>()
|
|
376
431
|
for (const e of rows) {
|
|
377
432
|
if (e.path.endsWith('/') || e.mode === LINK_MODE) continue
|
|
378
433
|
const ws = e.path.startsWith(WORKSPACE_OUTPUT_PREFIX)
|
|
@@ -381,18 +436,19 @@ export class OutputIndex {
|
|
|
381
436
|
: path.join(projectDir, e.path)
|
|
382
437
|
try {
|
|
383
438
|
const s = statSync(abs)
|
|
439
|
+
const at = dated?.get(e.path) ?? e.mtimeMs
|
|
384
440
|
if (
|
|
385
441
|
s.size === e.size &&
|
|
386
442
|
modeMatches(s.mode, e.mode) &&
|
|
387
|
-
Math.abs(s.mtimeMs -
|
|
443
|
+
(Math.abs(s.mtimeMs - at) < 1 || secondOf(s.mtimeMs, at))
|
|
388
444
|
) {
|
|
389
|
-
stamps.
|
|
445
|
+
stamps.set(e.path, [s.ino, Math.floor(s.ctimeMs), Math.floor(s.mtimeMs)])
|
|
390
446
|
}
|
|
391
447
|
} catch {
|
|
392
448
|
// gone: left unstamped
|
|
393
449
|
}
|
|
394
450
|
}
|
|
395
|
-
if (stamps.
|
|
451
|
+
if (stamps.size > 0) this.pendingStamps.set(hash, stamps)
|
|
396
452
|
}
|
|
397
453
|
|
|
398
454
|
/** Land every pending stamp and snapshot in one transaction each; a null snapshot clears its rows. */
|
|
@@ -418,7 +474,8 @@ export class OutputIndex {
|
|
|
418
474
|
for (const [hash, rows] of stamps) {
|
|
419
475
|
// A stamp outlives nothing: an entry pruned since has no rows to vouch for.
|
|
420
476
|
if (this.entryExists.get(hash) === null) continue
|
|
421
|
-
for (const [rel, ino, ctime] of rows)
|
|
477
|
+
for (const [rel, [ino, ctime, mtime]] of rows)
|
|
478
|
+
this.stampOutputFile.run(hash, rel, ino, ctime, mtime)
|
|
422
479
|
}
|
|
423
480
|
})
|
|
424
481
|
.immediate()
|
|
@@ -437,16 +494,21 @@ export class OutputIndex {
|
|
|
437
494
|
})()
|
|
438
495
|
}
|
|
439
496
|
|
|
440
|
-
|
|
497
|
+
/** `distinct` as `loadOutputFilesBatch` takes it. */
|
|
498
|
+
loadOutputDirsBatch(hashes: readonly string[], distinct?: string): Map<string, OutputDirRow[]> {
|
|
441
499
|
const out = new Map<string, OutputDirRow[]>()
|
|
442
500
|
if (hashes.length === 0) return out
|
|
443
501
|
// A reader in the same process (`vx watch`'s next cycle, a test) sees
|
|
444
502
|
// what was snapshotted, not what was flushed.
|
|
445
503
|
this.flushOutputDirs()
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
504
|
+
let sql = DIRS_JOINED
|
|
505
|
+
let params = [distinct!]
|
|
506
|
+
if (distinct === undefined || hashes.length === 1) {
|
|
507
|
+
const where = inHashes(hashes)
|
|
508
|
+
sql = `SELECT entry_hash, path, mtime_ms FROM output_dirs WHERE entry_hash ${where.test}`
|
|
509
|
+
params = where.params
|
|
510
|
+
}
|
|
511
|
+
const rows = this.db.query(sql).all(...params) as Array<{
|
|
450
512
|
entry_hash: string
|
|
451
513
|
path: string
|
|
452
514
|
mtime_ms: number
|
|
@@ -172,7 +172,7 @@ export function pluginCache(plugin: string, host: PluginCacheHost): PluginCache
|
|
|
172
172
|
for (const r of readers) {
|
|
173
173
|
try {
|
|
174
174
|
const got = await r.remote.get(key)
|
|
175
|
-
if (got
|
|
175
|
+
if (got == null) continue
|
|
176
176
|
const bytes = await capped(got.body)
|
|
177
177
|
if (bytes === null) continue
|
|
178
178
|
if (policy.localWrite) localPut(local, bytes)
|
|
@@ -245,7 +245,7 @@ export function pluginCache(plugin: string, host: PluginCacheHost): PluginCache
|
|
|
245
245
|
for (const key of runReadKeys(name, scope)) {
|
|
246
246
|
try {
|
|
247
247
|
const got = await r.remote.get(key)
|
|
248
|
-
if (got
|
|
248
|
+
if (got == null) continue
|
|
249
249
|
const bytes = await capped(got.body, Infinity)
|
|
250
250
|
if (bytes !== null) return bytes
|
|
251
251
|
} catch (err) {
|
package/src/cache/policy.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The run-level cache policy: four axes (local/remote × read/write) and
|
|
2
2
|
// the `--cache-policy=<spec>` grammar that sets them. Pure data + parsing; no I/O.
|
|
3
3
|
|
|
4
|
-
import { UserError } from '../util/index.js'
|
|
4
|
+
import { nearest, UserError } from '../util/index.js'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Independent read/write control over the two cache layers (local +
|
|
@@ -63,7 +63,11 @@ export function parseCachePolicy(spec: string, base: CachePolicy = FULL_CACHE_PO
|
|
|
63
63
|
const layer = seg.slice(0, colon)
|
|
64
64
|
const flags = seg.slice(colon + 1)
|
|
65
65
|
if (layer !== 'local' && layer !== 'remote') {
|
|
66
|
-
|
|
66
|
+
const near = nearest(layer, ['local', 'remote'])
|
|
67
|
+
throw new UserError(
|
|
68
|
+
`invalid layer '${layer}': expected 'local' or 'remote'` +
|
|
69
|
+
(near === undefined ? '' : ` — did you mean '${near}'?`),
|
|
70
|
+
)
|
|
67
71
|
}
|
|
68
72
|
if (seen.has(layer)) {
|
|
69
73
|
throw new UserError(`layer '${layer}' specified twice`)
|
package/src/cache/remote-lane.ts
CHANGED
|
@@ -296,6 +296,7 @@ export class RemoteLane {
|
|
|
296
296
|
this.report('download', hash, err)
|
|
297
297
|
return undefined
|
|
298
298
|
}
|
|
299
|
+
if (result === undefined) return undefined
|
|
299
300
|
if (!result) {
|
|
300
301
|
// Gone, whatever a probe said: the re-run's save uploads it.
|
|
301
302
|
this.held.delete(hash)
|
|
@@ -387,7 +388,8 @@ export class RemoteLane {
|
|
|
387
388
|
const got = await body()
|
|
388
389
|
release = got.release
|
|
389
390
|
if (known) this.remote.missing?.(hash)
|
|
390
|
-
|
|
391
|
+
const meta = distrusted ? { durationMs, replace: true as const } : { durationMs }
|
|
392
|
+
if ((await this.remote.put(hash, got.body, meta)) === false) return
|
|
391
393
|
holds = true
|
|
392
394
|
tally.count++
|
|
393
395
|
tally.bytes += got.body.size
|