@vzn/vx 0.0.646 → 0.0.649
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 +29 -22
- package/launcher.cjs +9 -2
- package/launcher.sh +2 -2
- package/package.json +5 -5
- package/schemas/cache.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/info.json +16 -2
- package/schemas/last.json +112 -5
- package/schemas/plan.json +28 -2
- package/schemas/show.json +10 -1
- package/skills/vx/SKILL.md +18 -2
- package/src/bin.ts +53 -12
- package/src/cache/archive.ts +324 -23
- package/src/cache/cache.ts +268 -53
- package/src/cache/git-inputs.ts +219 -87
- package/src/cache/index.ts +3 -2
- package/src/cache/inputs.ts +460 -78
- package/src/cache/key-fold.ts +15 -6
- package/src/cache/layer.ts +49 -9
- package/src/cache/layered-cache.ts +123 -58
- package/src/cache/linked-inputs.ts +1 -1
- package/src/cache/output-index.ts +19 -3
- package/src/cache/plugin-cache.ts +49 -23
- package/src/cache/remote-hits.ts +72 -0
- package/src/cache/remote-lane.ts +90 -14
- package/src/cache/run-history.ts +70 -23
- package/src/cache/run-store.ts +11 -10
- package/src/cache/schema.ts +25 -6
- package/src/cache/tar-stream.ts +5 -0
- package/src/cache/umask.ts +1 -2
- package/src/cli/cache.ts +58 -27
- package/src/cli/completions.ts +12 -5
- package/src/cli/docs.ts +42 -16
- package/src/cli/help.ts +58 -19
- package/src/cli/index.ts +79 -33
- package/src/cli/info.ts +12 -6
- package/src/cli/init.ts +186 -31
- package/src/cli/last.ts +439 -45
- package/src/cli/lock.ts +197 -52
- package/src/cli/plan-format.ts +91 -49
- package/src/cli/plugin-commands.ts +70 -14
- package/src/cli/plugin-templates.ts +13 -13
- package/src/cli/registry.ts +209 -0
- package/src/cli/run.ts +109 -34
- package/src/cli/select.ts +143 -46
- package/src/cli/show.ts +59 -16
- package/src/cli/task-verb.ts +16 -6
- package/src/cli/upgrade.ts +151 -49
- package/src/cli/watch-cycle.ts +10 -5
- package/src/cli/watch-filter.ts +71 -3
- package/src/cli/watch-fs.ts +22 -6
- package/src/cli/watch-judge.ts +48 -6
- package/src/cli/watch.ts +163 -42
- package/src/cli/why.ts +211 -73
- package/src/cli/workspace-config.ts +54 -15
- package/src/config.ts +13 -1
- package/src/exec/env.ts +7 -2
- package/src/exec/executor.ts +154 -10
- package/src/exec/index.ts +6 -2
- package/src/exec/kill-tree.ts +15 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/proc-sample.ts +3 -1
- package/src/exec/runner.ts +253 -54
- package/src/exec/sandbox-binds.ts +7 -3
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +211 -24
- package/src/exec/sandbox-violations.ts +52 -20
- package/src/graph/critical-path.ts +79 -0
- package/src/graph/default-build.ts +19 -0
- package/src/graph/definition.ts +38 -0
- package/src/graph/folds.ts +38 -5
- package/src/graph/index.ts +4 -1
- package/src/graph/priorities.ts +74 -8
- package/src/graph/scheduler.ts +270 -60
- package/src/graph/task-graph.ts +228 -35
- package/src/handoff.ts +97 -9
- package/src/history/failure-mode.ts +6 -3
- package/src/history/history.ts +167 -61
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +90 -30
- package/src/index.ts +30 -13
- package/src/orchestrator/admission.ts +10 -6
- package/src/orchestrator/affected-tasks.ts +11 -6
- package/src/orchestrator/deferred-outputs.ts +2 -0
- package/src/orchestrator/doctor.ts +134 -42
- package/src/orchestrator/execute-args.ts +4 -1
- package/src/orchestrator/execute-task.ts +130 -38
- package/src/orchestrator/fingerprint-watch.ts +1 -1
- package/src/orchestrator/graph-memo.ts +140 -0
- package/src/orchestrator/graph-stage.ts +18 -1
- package/src/orchestrator/held-servers.ts +1 -1
- package/src/orchestrator/hit-restore.ts +217 -119
- package/src/orchestrator/index.ts +4 -3
- package/src/orchestrator/local-floor.ts +119 -25
- package/src/orchestrator/local-shortcircuit.ts +28 -4
- package/src/orchestrator/lockfile-claim.ts +96 -16
- package/src/orchestrator/miss-save.ts +42 -8
- package/src/orchestrator/options.ts +8 -6
- package/src/orchestrator/output-host.ts +43 -14
- package/src/orchestrator/output-log.ts +90 -10
- package/src/orchestrator/persistent-task.ts +84 -29
- package/src/orchestrator/persistent.ts +16 -4
- package/src/orchestrator/placement.ts +185 -17
- package/src/orchestrator/plan.ts +64 -7
- package/src/orchestrator/plugin-host.ts +214 -42
- package/src/orchestrator/plugin.ts +113 -37
- package/src/orchestrator/prepare.ts +168 -30
- package/src/orchestrator/projects.ts +65 -40
- package/src/orchestrator/remote-prefetch.ts +119 -25
- package/src/orchestrator/run-artifacts.ts +95 -32
- package/src/orchestrator/run-context.ts +39 -41
- package/src/orchestrator/run-failures.ts +65 -23
- package/src/orchestrator/run-id.ts +6 -3
- package/src/orchestrator/run-keys.ts +149 -13
- package/src/orchestrator/run-live.ts +294 -0
- package/src/orchestrator/run-lock.ts +13 -7
- package/src/orchestrator/run-records.ts +35 -29
- package/src/orchestrator/run-report.ts +3 -3
- package/src/orchestrator/run-steps.ts +58 -22
- package/src/orchestrator/run-stop.ts +22 -5
- package/src/orchestrator/run.ts +331 -88
- package/src/orchestrator/sandbox-fix.ts +7 -5
- package/src/orchestrator/sandbox-request.ts +70 -20
- package/src/orchestrator/shell-verdict.ts +5 -5
- package/src/orchestrator/signals.ts +26 -1
- package/src/orchestrator/stable-keys.ts +92 -8
- package/src/orchestrator/steps.ts +2 -2
- package/src/orchestrator/task-artifacts.ts +90 -26
- package/src/orchestrator/task-hash.ts +78 -13
- package/src/orchestrator/task-log-buffer.ts +72 -36
- package/src/orchestrator/task-process.ts +7 -1
- package/src/orchestrator/telemetry-host.ts +22 -2
- package/src/orchestrator/telemetry.ts +53 -21
- package/src/orchestrator/upstream.ts +15 -0
- package/src/report/events.ts +32 -13
- package/src/report/forecast.ts +31 -9
- package/src/report/framed-output.ts +141 -50
- package/src/report/index.ts +1 -0
- package/src/report/logger.ts +170 -179
- package/src/report/path-links.ts +12 -4
- package/src/report/plain-output.ts +13 -5
- package/src/report/status-line.ts +81 -53
- package/src/report/summary.ts +114 -32
- package/src/report/terminal-signals.ts +13 -2
- package/src/settings.ts +7 -6
- package/src/util/bun-version.ts +4 -1
- package/src/util/colors.ts +4 -2
- package/src/util/config-frame.ts +41 -4
- package/src/{graph → util}/dependency-spec.ts +22 -13
- package/src/util/edit-distance.ts +13 -1
- package/src/util/env-flag.ts +9 -0
- package/src/util/errors.ts +59 -3
- package/src/util/essential-env.ts +57 -0
- package/src/util/git-ignored.ts +1 -1
- package/src/util/hash.ts +35 -0
- package/src/util/hide-env.ts +45 -5
- package/src/util/index.ts +21 -5
- package/src/util/num.ts +8 -0
- package/src/util/paths.ts +91 -21
- package/src/util/report.ts +6 -1
- package/src/util/secret-mask.ts +62 -1
- package/src/util/secrets.ts +7 -2
- package/src/util/size.ts +11 -3
- package/src/util/tail.ts +6 -1
- package/src/util/timing.ts +13 -1
- package/src/util/ui.ts +61 -3
- package/src/util/which.ts +1 -1
- package/src/util/write-file.ts +67 -0
- package/src/workspace/affected.ts +710 -107
- package/src/workspace/bracket-literal.ts +84 -0
- package/src/workspace/canonical-dir.ts +37 -0
- package/src/workspace/config-bytecode.ts +17 -11
- package/src/workspace/config-eval.ts +29 -8
- package/src/workspace/config-imports.ts +199 -58
- package/src/workspace/config-schema.ts +242 -39
- package/src/workspace/filter.ts +37 -8
- package/src/workspace/index.ts +11 -1
- package/src/workspace/json-data.ts +3 -4
- package/src/workspace/lockfile.ts +57 -8
- package/src/workspace/migration.ts +69 -18
- package/src/workspace/package-graph.ts +5 -4
- package/src/workspace/project-loader.ts +226 -37
- package/src/workspace/repo-id.ts +1 -1
- package/src/workspace/workspace.ts +176 -33
package/src/cache/key-fold.ts
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
// The cache key, derived: `foldKey` is the whole of what `Cache.key` computes,
|
|
2
2
|
// with the two things it read off the class (the file hasher and the
|
|
3
3
|
// workspace-relative path) passed in. Nothing here touches a store, so the
|
|
4
|
-
// fold runs wherever xxh3 does: the CLI
|
|
5
|
-
// playground, which bundles this file to derive the keys the CLI would
|
|
6
|
-
// (design/playground-spike-2026-09.md, P1; item 691).
|
|
4
|
+
// fold runs wherever xxh3 does: the CLI and a remote layer (item 691).
|
|
7
5
|
|
|
8
6
|
import { xxh3, xxh3hex } from '../util/index.js'
|
|
9
7
|
import type { CacheKeyInput } from './layer.js'
|
|
@@ -113,14 +111,21 @@ import type { CacheKeyInput } from './layer.js'
|
|
|
113
111
|
// v44: the sidecar lists the empty directories under the outputs; a v43
|
|
114
112
|
// artifact holds none and a hit left them out (Growth's parity, 2026-10-16).
|
|
115
113
|
// v45: the sidecar lists the symlinks under the outputs and a hit makes
|
|
116
|
-
// them; a v44 artifact holds a link as a copy of its target (
|
|
114
|
+
// them; a v44 artifact holds a link as a copy of its target (2026-10-16).
|
|
117
115
|
// v46: vx's names are reserved only at the project root; a v45 artifact
|
|
118
116
|
// left out a build's own `dist/.git/**`, `dist/.vx/**` and nested
|
|
119
117
|
// `vx.lock.json` (Growth's parity, 2026-10-17).
|
|
120
118
|
// v47: the local store keeps plain files (`cache.store`).
|
|
121
|
-
// v48: a task glob is standard again (
|
|
119
|
+
// v48: a task glob is standard again (2026-10-18): `app/[id]/page.js` is
|
|
122
120
|
// a class, and a v47 entry under its unchanged key holds the literal route.
|
|
123
|
-
|
|
121
|
+
// v49: each brace alternative is read alone (2026-10-28): a v48 entry for
|
|
122
|
+
// `outputs: ['{dist,lib}']` under its unchanged key holds nothing.
|
|
123
|
+
// v49, on another branch at once: the sidecar keeps the mode of an output
|
|
124
|
+
// directory that withholds a permission 0755 grants, and a hit makes it so;
|
|
125
|
+
// a v48 artifact keeps none, and a hit gave `chmod 700 dist/keys` back as 0755.
|
|
126
|
+
// v50: both v49 changes in one build (2026-10-29): a v49 entry was written
|
|
127
|
+
// by a vx that had only one of them.
|
|
128
|
+
export const CACHE_VERSION = 'vx-cache-v51'
|
|
124
129
|
|
|
125
130
|
/**
|
|
126
131
|
* The digest a key folds for an input gone between its enumeration and its
|
|
@@ -321,5 +326,9 @@ export function foldHead(
|
|
|
321
326
|
for (const [n, v] of pluginParts) cap.push({ kind: 'plugin', name: n, hash: digestValue(v) })
|
|
322
327
|
}
|
|
323
328
|
}
|
|
329
|
+
if (input.platform !== undefined) {
|
|
330
|
+
h = xxh3(`platform:${input.platform}`, h)
|
|
331
|
+
if (cap) cap.push({ kind: 'platform', name: 'platform', hash: input.platform })
|
|
332
|
+
}
|
|
324
333
|
return h
|
|
325
334
|
}
|
package/src/cache/layer.ts
CHANGED
|
@@ -89,6 +89,12 @@ export interface CacheKeyInput {
|
|
|
89
89
|
* derives exactly the key it always did.
|
|
90
90
|
*/
|
|
91
91
|
pluginParts?: ReadonlyArray<readonly [name: string, value: string]>
|
|
92
|
+
/**
|
|
93
|
+
* The platform a task that declares `exec.platforms` runs on: its
|
|
94
|
+
* outputs are that platform's. Folded only when set, so a task that
|
|
95
|
+
* declares none derives the key it always did.
|
|
96
|
+
*/
|
|
97
|
+
platform?: string
|
|
92
98
|
/**
|
|
93
99
|
* When set, `key()` pushes each component (kind, name, hash) it folds
|
|
94
100
|
* — at the same fold sites, in fold order. Pure SIDE-CHANNEL: it does
|
|
@@ -219,9 +225,13 @@ export interface RunRecord {
|
|
|
219
225
|
placement?: string
|
|
220
226
|
/** The task with no `inputs` its key folded, so it never read or saved (v46). */
|
|
221
227
|
keylessBy?: string
|
|
228
|
+
/** The task's definition (`taskDefinition`): what its history is kept under (v48). */
|
|
229
|
+
def?: string
|
|
222
230
|
/** That machine's cores and memory (v44). */
|
|
223
231
|
placementCores?: number
|
|
224
232
|
placementMemoryBytes?: number
|
|
233
|
+
/** The share of a core each runnable thread got there (`ExecuteResult.machine.cpuShare`, v49). */
|
|
234
|
+
cpuShare?: number
|
|
225
235
|
}
|
|
226
236
|
|
|
227
237
|
/**
|
|
@@ -317,7 +327,7 @@ export interface PruneOptions {
|
|
|
317
327
|
* Pick the victims and count the orphans, delete nothing: the result
|
|
318
328
|
* says what a real prune with the same policy would reap right now.
|
|
319
329
|
*/
|
|
320
|
-
|
|
330
|
+
dry?: boolean
|
|
321
331
|
}
|
|
322
332
|
|
|
323
333
|
export interface PruneResult {
|
|
@@ -574,12 +584,28 @@ export interface CacheLayer {
|
|
|
574
584
|
* batch omits it and the prefetch pass falls back to per-hash GETs.
|
|
575
585
|
*/
|
|
576
586
|
remoteHasMany?(hashes: readonly string[]): Promise<Map<string, ArtifactInfo> | null>
|
|
587
|
+
/**
|
|
588
|
+
* `remoteHasMany`, with a remote that answers no batch query asked each
|
|
589
|
+
* key, `concurrency` at a time: a complete answer or `null`. For a run
|
|
590
|
+
* that plans on the answer (an executor's demand); others pull instead.
|
|
591
|
+
*/
|
|
592
|
+
remoteHasEach?(
|
|
593
|
+
hashes: readonly string[],
|
|
594
|
+
concurrency: number,
|
|
595
|
+
): Promise<Map<string, ArtifactInfo> | null>
|
|
577
596
|
/**
|
|
578
597
|
* The subset of `hashes` the local store already holds. The prefetch
|
|
579
598
|
* pass probes the remote only for the rest, so a run the local cache
|
|
580
599
|
* answers makes no remote call. Absent reads as "none held".
|
|
581
600
|
*/
|
|
582
601
|
localHasMany?(hashes: readonly string[]): Promise<Set<string>>
|
|
602
|
+
/**
|
|
603
|
+
* The subset of `hashes` a remote said it holds, as the local index
|
|
604
|
+
* keeps it across runs and processes: trusted now, with no round-trip.
|
|
605
|
+
* A pull of one starts at once, waiting on no batch probe. Absent, a
|
|
606
|
+
* run learns its remote hits from `remoteHasMany` alone.
|
|
607
|
+
*/
|
|
608
|
+
remoteKnown?(hashes: readonly string[]): Map<string, ArtifactInfo>
|
|
583
609
|
/**
|
|
584
610
|
* Record that the remote has NO artifact for each of `hashes` (from a
|
|
585
611
|
* batch probe), so a later `get`/`prefetch` short-circuits to a miss with
|
|
@@ -593,12 +619,6 @@ export interface CacheLayer {
|
|
|
593
619
|
* `hasRemote`.
|
|
594
620
|
*/
|
|
595
621
|
probeBeforeUpload?(hashes: Promise<readonly string[]>): void
|
|
596
|
-
/**
|
|
597
|
-
* The run's batch probe starts when `started` settles: until then a pull
|
|
598
|
-
* waits, so a lookup that lands first does not GET a key the probe is
|
|
599
|
-
* about to call absent. Only meaningful with `hasRemote`.
|
|
600
|
-
*/
|
|
601
|
-
probeFirst?(started: Promise<void>): void
|
|
602
622
|
/**
|
|
603
623
|
* Resolve once every background write-through upload settles. The
|
|
604
624
|
* orchestrator awaits it before `close()` — an upload reading layer state
|
|
@@ -616,6 +636,22 @@ export interface CacheLayer {
|
|
|
616
636
|
* A layer without it is read through `get`.
|
|
617
637
|
*/
|
|
618
638
|
getSaved?(hash: string, ctx?: CacheGetContext): Promise<CacheEntry | null>
|
|
639
|
+
/**
|
|
640
|
+
* True once a remote the run reads holds entry `hash`, the local entry
|
|
641
|
+
* uploaded to those it writes when none does (`TaskArtifacts.share`). A
|
|
642
|
+
* layer without it shares nothing.
|
|
643
|
+
*/
|
|
644
|
+
share?(hash: string): Promise<boolean>
|
|
645
|
+
/**
|
|
646
|
+
* The local entry uploaded over each written remote's copy, which another
|
|
647
|
+
* machine found corrupt (`TaskArtifacts.resend`). True when one took it.
|
|
648
|
+
*/
|
|
649
|
+
resend?(hash: string): Promise<boolean>
|
|
650
|
+
/**
|
|
651
|
+
* Whether a remote this run writes took entry `hash` when `save` uploaded
|
|
652
|
+
* it, or already held it; undefined when this run sent it to none.
|
|
653
|
+
*/
|
|
654
|
+
uploaded?(hash: string): Promise<boolean | undefined>
|
|
619
655
|
/**
|
|
620
656
|
* Optional batched `get` (same answers, fewer round trips). The
|
|
621
657
|
* short-circuit probe uses it when a layer offers one; a layer without it
|
|
@@ -670,7 +706,7 @@ export interface CacheLayer {
|
|
|
670
706
|
* out-of-band file edits or deletions and falls through to a real
|
|
671
707
|
* restore.
|
|
672
708
|
*/
|
|
673
|
-
isOutputsCurrent(projectDir: string, expected: readonly OutputFileRow[]):
|
|
709
|
+
isOutputsCurrent(projectDir: string, expected: readonly OutputFileRow[]): boolean
|
|
674
710
|
/**
|
|
675
711
|
* The directory-mtime short-circuit behind a warm hit (optional — a layer
|
|
676
712
|
* without it keeps the output walk). `recordOutputDirs` snapshots every
|
|
@@ -699,7 +735,7 @@ export interface CacheLayer {
|
|
|
699
735
|
*/
|
|
700
736
|
recordOutputStamps?(hash: string, projectDir: string, workspaceRoot: string): void
|
|
701
737
|
loadOutputDirsBatch?(hashes: readonly string[]): Map<string, OutputDirRow[]>
|
|
702
|
-
outputDirsCurrent?(projectDir: string, rows: readonly OutputDirRow[]):
|
|
738
|
+
outputDirsCurrent?(projectDir: string, rows: readonly OutputDirRow[]): boolean
|
|
703
739
|
/**
|
|
704
740
|
* Extract the artifact's `outputs/` entries into `projectDir` and —
|
|
705
741
|
* when `workspaceRoot` is given — its `workspace-outputs/` entries
|
|
@@ -736,6 +772,8 @@ export interface CacheLayer {
|
|
|
736
772
|
* format.
|
|
737
773
|
*/
|
|
738
774
|
workspaceOutputFiles?: string[]
|
|
775
|
+
/** The empty directories under `workspaceOutputFiles`' globs (absolute). */
|
|
776
|
+
workspaceOutputDirs?: string[]
|
|
739
777
|
workspaceRoot?: string
|
|
740
778
|
/**
|
|
741
779
|
* Cache-key components for this entry (the Tier-3 input
|
|
@@ -745,6 +783,8 @@ export interface CacheLayer {
|
|
|
745
783
|
* Only computed on the miss/save path — never on a hit.
|
|
746
784
|
*/
|
|
747
785
|
inputComponents?: readonly TaskInputRow[]
|
|
786
|
+
/** No remote held `hash` when the run asked: an upload asks none first. */
|
|
787
|
+
absent?: boolean
|
|
748
788
|
}): Promise<void>
|
|
749
789
|
/**
|
|
750
790
|
* Adopt an artifact produced elsewhere — the remote-hit path. Streams
|
|
@@ -35,6 +35,7 @@ import type {
|
|
|
35
35
|
SaveArgs,
|
|
36
36
|
} from './cache.js'
|
|
37
37
|
import type { OutputDirRow } from './cache.js'
|
|
38
|
+
import { CorruptArtifactError } from './layer.js'
|
|
38
39
|
import { RemoteLane, type ResolvedRemote } from './remote-lane.js'
|
|
39
40
|
import type { PluginSecrets } from '../util/index.js'
|
|
40
41
|
|
|
@@ -57,6 +58,7 @@ function endsOnStop(
|
|
|
57
58
|
// which the flag turns into an error rather than a short body.
|
|
58
59
|
void stopped.then(() => {
|
|
59
60
|
stop = true
|
|
61
|
+
// quiet: stopping: a reader already done has nothing to cancel
|
|
60
62
|
void reader.cancel().catch(() => undefined)
|
|
61
63
|
})
|
|
62
64
|
const stream = new ReadableStream<Uint8Array>({
|
|
@@ -80,6 +82,15 @@ const judged = (i: ArtifactInfo): i is { size: number; durationMs: number } =>
|
|
|
80
82
|
/** How often a task waiting on its download weighs running instead. */
|
|
81
83
|
const JUDGE_EVERY_MS = 25
|
|
82
84
|
|
|
85
|
+
/**
|
|
86
|
+
* The least a download is given against running its task. A stored
|
|
87
|
+
* duration is the command's alone, in whole ms (Turbo's wire sends 0 for
|
|
88
|
+
* a sub-ms task), and a budget under one weighing is none: a duration of
|
|
89
|
+
* 0 gave up every body not in by the first, 99% in or not, and ran the
|
|
90
|
+
* task beside the bytes already here.
|
|
91
|
+
*/
|
|
92
|
+
const LEAST_BUDGET_MS = JUDGE_EVERY_MS
|
|
93
|
+
|
|
83
94
|
interface Download {
|
|
84
95
|
readonly start: number
|
|
85
96
|
received: number
|
|
@@ -186,9 +197,16 @@ export interface RemoteCacheLayer {
|
|
|
186
197
|
* artifact, so a plugin that streams it never holds it whole, and every
|
|
187
198
|
* read of it sees the same bytes. An entry is immutable: a `put` of a
|
|
188
199
|
* key the remote holds keeps the stored bytes. Core never asks `has`
|
|
189
|
-
* first; a wire that would overwrite checks inside
|
|
200
|
+
* first; a wire that would overwrite checks inside, unless told `missing`.
|
|
201
|
+
* `false`: nothing was sent and nothing failed (a layer that writes
|
|
202
|
+
* nothing this run, such as one with no token), so no upload is counted.
|
|
203
|
+
*/
|
|
204
|
+
put(hash: string, body: Blob, meta: { durationMs: number }): Promise<void | false>
|
|
205
|
+
/**
|
|
206
|
+
* The run found `hash` in no remote, and a `put` of it follows: a wire
|
|
207
|
+
* that checks before it overwrites can skip that request.
|
|
190
208
|
*/
|
|
191
|
-
|
|
209
|
+
missing?(hash: string): void
|
|
192
210
|
}
|
|
193
211
|
|
|
194
212
|
export class LayeredCache implements CacheLayer {
|
|
@@ -196,6 +214,8 @@ export class LayeredCache implements CacheLayer {
|
|
|
196
214
|
readonly hasRemote = true
|
|
197
215
|
|
|
198
216
|
private readonly lanes: readonly RemoteLane[]
|
|
217
|
+
/** Each key `save` uploaded: whether a remote took it (`uploaded`). */
|
|
218
|
+
private readonly sent = new Map<string, Promise<boolean>>()
|
|
199
219
|
|
|
200
220
|
/**
|
|
201
221
|
* In-flight remote pulls keyed by hash. `prefetch` and `get` both go
|
|
@@ -209,17 +229,6 @@ export class LayeredCache implements CacheLayer {
|
|
|
209
229
|
*/
|
|
210
230
|
private readonly inflight = new Map<string, Promise<boolean>>()
|
|
211
231
|
|
|
212
|
-
/**
|
|
213
|
-
* Hashes a batch probe is asking about, each resolving whether a pull may
|
|
214
|
-
* still find it (false: no remote holds it). A `get` that lands while the
|
|
215
|
-
* probe is out waits for its answer instead of spending a GET on a key
|
|
216
|
-
* the probe is about to call absent.
|
|
217
|
-
*/
|
|
218
|
-
private readonly probing = new Map<string, Promise<boolean>>()
|
|
219
|
-
|
|
220
|
-
/** Settles once the run's batch probe has started (`probeFirst`); a pull waits on it. */
|
|
221
|
-
private probeStarted: Promise<void> | null = null
|
|
222
|
-
|
|
223
232
|
/**
|
|
224
233
|
* Hashes whose local artifact was materialized FROM a remote this run
|
|
225
234
|
* (by `prefetch` or `get`'s read-through). A later `get` finds them as
|
|
@@ -267,6 +276,13 @@ export class LayeredCache implements CacheLayer {
|
|
|
267
276
|
return this.local.localHasMany(hashes)
|
|
268
277
|
}
|
|
269
278
|
|
|
279
|
+
remoteKnown(hashes: readonly string[]): Map<string, ArtifactInfo> {
|
|
280
|
+
if (this.readable.length === 0) return new Map()
|
|
281
|
+
const known = this.local.remoteHits.get(hashes)
|
|
282
|
+
for (const [h, info] of known) if (!this.infos.has(h)) this.infos.set(h, info)
|
|
283
|
+
return known
|
|
284
|
+
}
|
|
285
|
+
|
|
270
286
|
/**
|
|
271
287
|
* Batch existence probe over the readable remotes — the subset of
|
|
272
288
|
* `hashes` some remote holds, one round-trip each, or `null` when any
|
|
@@ -280,27 +296,34 @@ export class LayeredCache implements CacheLayer {
|
|
|
280
296
|
*/
|
|
281
297
|
async remoteHasMany(hashes: readonly string[]): Promise<Map<string, ArtifactInfo> | null> {
|
|
282
298
|
if (this.readable.length === 0) return null
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
299
|
+
const found = await this.probeMany(hashes)
|
|
300
|
+
if (found !== null) {
|
|
301
|
+
for (const [h, info] of found) this.infos.set(h, info)
|
|
302
|
+
// The whole answer, kept for the next run and the next process.
|
|
303
|
+
this.local.remoteHits.record(hashes, found)
|
|
304
|
+
}
|
|
305
|
+
return found
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Each readable remote asked key by key (`RemoteLane.hasEach`), a key
|
|
310
|
+
* its batch answer already covered not asked again: a complete answer,
|
|
311
|
+
* for a run that plans on it. Null when a probe failed or none may be read.
|
|
312
|
+
*/
|
|
313
|
+
async remoteHasEach(
|
|
314
|
+
hashes: readonly string[],
|
|
315
|
+
concurrency: number,
|
|
316
|
+
): Promise<Map<string, ArtifactInfo> | null> {
|
|
317
|
+
if (this.readable.length === 0) return null
|
|
318
|
+
const out = new Map<string, ArtifactInfo>()
|
|
319
|
+
for (const lane of this.readable) {
|
|
320
|
+
const found = await lane.hasEach(hashes, concurrency)
|
|
321
|
+
if (found === null) return null
|
|
322
|
+
for (const [h, info] of found) if (!out.has(h)) out.set(h, info)
|
|
302
323
|
}
|
|
303
|
-
|
|
324
|
+
for (const [h, info] of out) this.infos.set(h, info)
|
|
325
|
+
this.local.remoteHits.record(hashes, out)
|
|
326
|
+
return out
|
|
304
327
|
}
|
|
305
328
|
|
|
306
329
|
private async probeMany(hashes: readonly string[]): Promise<Map<string, ArtifactInfo> | null> {
|
|
@@ -327,17 +350,6 @@ export class LayeredCache implements CacheLayer {
|
|
|
327
350
|
}
|
|
328
351
|
}
|
|
329
352
|
|
|
330
|
-
probeFirst(started: Promise<void>): void {
|
|
331
|
-
const gate = started.then(
|
|
332
|
-
() => {},
|
|
333
|
-
() => {},
|
|
334
|
-
)
|
|
335
|
-
this.probeStarted = gate
|
|
336
|
-
void gate.then(() => {
|
|
337
|
-
if (this.probeStarted === gate) this.probeStarted = null
|
|
338
|
-
})
|
|
339
|
-
}
|
|
340
|
-
|
|
341
353
|
probeBeforeUpload(hashes: Promise<readonly string[]>): void {
|
|
342
354
|
for (const lane of this.lanes) lane.probeBeforeUpload(hashes)
|
|
343
355
|
}
|
|
@@ -347,7 +359,9 @@ export class LayeredCache implements CacheLayer {
|
|
|
347
359
|
if (localHit) {
|
|
348
360
|
// A prefetch this run may have materialized this entry FROM
|
|
349
361
|
// remote; the local row exists now, but the work was saved by
|
|
350
|
-
// the remote cache, so the provenance stays 'remote'.
|
|
362
|
+
// the remote cache, so the provenance stays 'remote'. Its row lands
|
|
363
|
+
// a tick before the pull settles and says so: wait for that.
|
|
364
|
+
await this.inflight.get(hash)
|
|
351
365
|
return this.asRemote(hash, localHit)
|
|
352
366
|
}
|
|
353
367
|
|
|
@@ -360,6 +374,9 @@ export class LayeredCache implements CacheLayer {
|
|
|
360
374
|
// remote walk per key: a settled `false` means no remote had it
|
|
361
375
|
// (no second probe), and a settled/in-flight `true` means the
|
|
362
376
|
// artifact was/will-be ingested locally — re-read below.
|
|
377
|
+
// A lookup never waits on the run's batch probe: a hit then paid two
|
|
378
|
+
// round-trips before its first byte (the probe, then the GET), ~300 ms
|
|
379
|
+
// an up-to-date task on CI. A GET answers a miss as fast as the probe.
|
|
363
380
|
const ingested = await this.awaitOrRun(hash, this.pullFromRemote(hash, ctx))
|
|
364
381
|
if (!ingested) return null
|
|
365
382
|
|
|
@@ -407,7 +424,7 @@ export class LayeredCache implements CacheLayer {
|
|
|
407
424
|
const info = this.infos.get(hash)
|
|
408
425
|
if (info === undefined) continue
|
|
409
426
|
if (!judged(info)) return pull
|
|
410
|
-
const budget = info.durationMs * 2
|
|
427
|
+
const budget = Math.max(info.durationMs * 2, LEAST_BUDGET_MS)
|
|
411
428
|
const dl = this.downloads.get(hash)
|
|
412
429
|
if (dl === undefined) continue
|
|
413
430
|
const elapsed = performance.now() - dl.start
|
|
@@ -451,11 +468,6 @@ export class LayeredCache implements CacheLayer {
|
|
|
451
468
|
private pullFromRemote(hash: string, ctx?: CacheGetContext): Promise<boolean> {
|
|
452
469
|
const existing = this.inflight.get(hash)
|
|
453
470
|
if (existing) return existing
|
|
454
|
-
const probed = this.probing.get(hash)
|
|
455
|
-
if (probed !== undefined)
|
|
456
|
-
return probed.then((found) => (found ? this.pullFromRemote(hash, ctx) : false))
|
|
457
|
-
if (this.probeStarted !== null)
|
|
458
|
-
return this.probeStarted.then(() => this.pullFromRemote(hash, ctx))
|
|
459
471
|
const p = this.doPullFromRemote(hash, ctx)
|
|
460
472
|
this.inflight.set(hash, p)
|
|
461
473
|
return p
|
|
@@ -471,9 +483,14 @@ export class LayeredCache implements CacheLayer {
|
|
|
471
483
|
// caller re-reads it, keeping whatever provenance it already had (local,
|
|
472
484
|
// or remote if a concurrent prefetch set it).
|
|
473
485
|
if ((await this.local.has(hash)) === 'local') return true
|
|
486
|
+
let absent = true
|
|
474
487
|
for (const lane of this.readable) {
|
|
475
|
-
|
|
488
|
+
const got = await this.pullFrom(lane, hash, ctx)
|
|
489
|
+
if (got === true) return true
|
|
490
|
+
if (got === false) absent = false
|
|
476
491
|
}
|
|
492
|
+
// Every remote answered it holds none: a hit the index kept is gone.
|
|
493
|
+
if (absent) this.local.remoteHits.forget(hash)
|
|
477
494
|
return false
|
|
478
495
|
}
|
|
479
496
|
|
|
@@ -482,10 +499,10 @@ export class LayeredCache implements CacheLayer {
|
|
|
482
499
|
hash: string,
|
|
483
500
|
ctx?: CacheGetContext,
|
|
484
501
|
fresh = false,
|
|
485
|
-
): Promise<boolean> {
|
|
502
|
+
): Promise<boolean | null> {
|
|
486
503
|
const fetchStart = performance.now()
|
|
487
504
|
// A stopped run waits on no read, as on no upload: a lookup or download
|
|
488
|
-
// in flight held a Ctrl-C until its last byte (
|
|
505
|
+
// in flight held a Ctrl-C until its last byte (2026-10-17). The
|
|
489
506
|
// plugin's request is left to the exit, and its body is cancelled.
|
|
490
507
|
const stopped = lane.whenStopped.then((): typeof STOPPED => STOPPED)
|
|
491
508
|
const pending = lane.get(hash, fresh)
|
|
@@ -495,7 +512,9 @@ export class LayeredCache implements CacheLayer {
|
|
|
495
512
|
void pending.then((r) => (r?.body instanceof Response ? r.body.body?.cancel() : undefined))
|
|
496
513
|
return false
|
|
497
514
|
}
|
|
498
|
-
|
|
515
|
+
// The remote holds none: the one answer that says the key is gone.
|
|
516
|
+
if (remoteBody === null) return null
|
|
517
|
+
if (remoteBody === undefined) return false
|
|
499
518
|
|
|
500
519
|
// Ingest the remote body into local using the caller-supplied
|
|
501
520
|
// taskId/command plus the remote-reported durationMs, which `get`
|
|
@@ -525,6 +544,7 @@ export class LayeredCache implements CacheLayer {
|
|
|
525
544
|
endsOnStop(remoteBody.body, ends, (n) => (dl.received += n)),
|
|
526
545
|
meta,
|
|
527
546
|
)
|
|
547
|
+
// quiet: raced and awaited below, where its error is reported
|
|
528
548
|
ingest.catch(() => undefined)
|
|
529
549
|
if ((await Promise.race([ingest, ends])) === STOPPED) return false
|
|
530
550
|
} catch (err) {
|
|
@@ -533,6 +553,9 @@ export class LayeredCache implements CacheLayer {
|
|
|
533
553
|
// crash the run. A corrupt LOCAL artifact is a miss too, one level
|
|
534
554
|
// down: `Cache.restoreOutputs` drops it (A-52).
|
|
535
555
|
lane.report('download', hash, err)
|
|
556
|
+
// Bad bytes there: the re-run's save sends good ones over them, or
|
|
557
|
+
// every machine rebuilt the task and none repaired it (2026-10-24).
|
|
558
|
+
if (err instanceof CorruptArtifactError) lane.distrust(hash)
|
|
536
559
|
return false
|
|
537
560
|
} finally {
|
|
538
561
|
this.downloads.delete(hash)
|
|
@@ -598,6 +621,7 @@ export class LayeredCache implements CacheLayer {
|
|
|
598
621
|
packed = await this.local.packArtifactBytes(args)
|
|
599
622
|
} catch (err) {
|
|
600
623
|
for (const lane of writable) lane.report('upload', hash, err)
|
|
624
|
+
this.sent.set(hash, Promise.resolve(false))
|
|
601
625
|
return
|
|
602
626
|
}
|
|
603
627
|
}
|
|
@@ -605,7 +629,48 @@ export class LayeredCache implements CacheLayer {
|
|
|
605
629
|
packed !== undefined
|
|
606
630
|
? Promise.resolve({ body: new Blob([packed]) })
|
|
607
631
|
: this.local.pinArtifact(hash)
|
|
608
|
-
|
|
632
|
+
const sent = writable.map((lane) =>
|
|
633
|
+
lane.upload(hash, durationMs, body, this.local.uploads, args.absent === true),
|
|
634
|
+
)
|
|
635
|
+
this.sent.set(
|
|
636
|
+
hash,
|
|
637
|
+
Promise.all(sent).then((held) => held.includes(true)),
|
|
638
|
+
)
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
uploaded(hash: string): Promise<boolean | undefined> {
|
|
642
|
+
return this.sent.get(hash) ?? Promise.resolve(undefined)
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* True once a remote this run reads holds entry `hash`, uploading the
|
|
647
|
+
* local entry to each one it also writes when none does. Another machine
|
|
648
|
+
* of the run (a DTE worker) restores from the remotes alone: a hit only
|
|
649
|
+
* this cache held, the remote having evicted it, failed its dependant
|
|
650
|
+
* there.
|
|
651
|
+
*/
|
|
652
|
+
async share(hash: string): Promise<boolean> {
|
|
653
|
+
for (const lane of this.readable) if ((await lane.has(hash)) !== null) return true
|
|
654
|
+
return this.upload(hash, false)
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** `share`, over a remote copy another machine of the run found corrupt. */
|
|
658
|
+
resend(hash: string): Promise<boolean> {
|
|
659
|
+
return this.upload(hash, true)
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
private async upload(hash: string, over: boolean): Promise<boolean> {
|
|
663
|
+
const lanes = this.readable.filter((l) => l.write)
|
|
664
|
+
if (lanes.length === 0) return false
|
|
665
|
+
const entry = await this.local.getIngested(hash)
|
|
666
|
+
if (entry === null) return false
|
|
667
|
+
if (over) for (const lane of lanes) lane.distrust(hash)
|
|
668
|
+
const held = await Promise.all(
|
|
669
|
+
lanes.map((l) =>
|
|
670
|
+
l.upload(hash, entry.durationMs, () => this.local.pinArtifact(hash), this.local.uploads),
|
|
671
|
+
),
|
|
672
|
+
)
|
|
673
|
+
return held.includes(true)
|
|
609
674
|
}
|
|
610
675
|
|
|
611
676
|
/**
|
|
@@ -632,7 +697,7 @@ export class LayeredCache implements CacheLayer {
|
|
|
632
697
|
return this.local.loadOutputFilesBatch(hashes)
|
|
633
698
|
}
|
|
634
699
|
|
|
635
|
-
|
|
700
|
+
isOutputsCurrent(projectDir: string, expected: readonly OutputFileRow[]): boolean {
|
|
636
701
|
return this.local.isOutputsCurrent(projectDir, expected)
|
|
637
702
|
}
|
|
638
703
|
|
|
@@ -652,7 +717,7 @@ export class LayeredCache implements CacheLayer {
|
|
|
652
717
|
return this.local.loadOutputDirsBatch(hashes)
|
|
653
718
|
}
|
|
654
719
|
|
|
655
|
-
outputDirsCurrent(projectDir: string, rows: readonly OutputDirRow[]):
|
|
720
|
+
outputDirsCurrent(projectDir: string, rows: readonly OutputDirRow[]): boolean {
|
|
656
721
|
return this.local.outputDirsCurrent(projectDir, rows)
|
|
657
722
|
}
|
|
658
723
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// A symlinked input keys the content behind it, as Nx reads a linked file
|
|
2
|
-
// where it points (
|
|
2
|
+
// where it points (2026-10-16: "Do like nx"). Its own fold stays the
|
|
3
3
|
// blob of its target STRING (file-hashes.ts), so a retarget to equal bytes
|
|
4
4
|
// moves the key; this is what follows it. The target string alone let an
|
|
5
5
|
// edit behind the link replay the old output (Growth, 2026-10-16), wherever
|
|
@@ -37,6 +37,12 @@ import {
|
|
|
37
37
|
*/
|
|
38
38
|
export const LINK_MODE = 0o120000
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* The type bits a directory row carries beside a mode the restore applies
|
|
42
|
+
* (`MetaFile.dirModes`); an empty directory's row without one is mode 0.
|
|
43
|
+
*/
|
|
44
|
+
export const DIR_MODE = 0o040000
|
|
45
|
+
|
|
40
46
|
/** A link row is current while a link with that target stands there; never followed. */
|
|
41
47
|
function linkCurrent(abs: string, e: { size: number; mtimeMs: number }): boolean {
|
|
42
48
|
if (!lstatSync(abs).isSymbolicLink()) return false
|
|
@@ -75,7 +81,15 @@ export class OutputIndex {
|
|
|
75
81
|
*/
|
|
76
82
|
private readonly held = new Map<string, readonly OutputFileRow[]>()
|
|
77
83
|
|
|
78
|
-
|
|
84
|
+
/**
|
|
85
|
+
* `memo` false: a cache directory this run cannot write. A restore still
|
|
86
|
+
* serves the hit, but its stamps and directory snapshots are not queued:
|
|
87
|
+
* the next lookup flushed them and failed its task (SQLITE_READONLY).
|
|
88
|
+
*/
|
|
89
|
+
constructor(
|
|
90
|
+
private readonly db: Database,
|
|
91
|
+
private readonly memo = true,
|
|
92
|
+
) {
|
|
79
93
|
this.insertOutputFile = lazyStatement(
|
|
80
94
|
this.db,
|
|
81
95
|
`
|
|
@@ -206,7 +220,7 @@ export class OutputIndex {
|
|
|
206
220
|
return out
|
|
207
221
|
}
|
|
208
222
|
|
|
209
|
-
|
|
223
|
+
isOutputsCurrent(projectDir: string, expected: readonly OutputFileRow[]): boolean {
|
|
210
224
|
// Empty manifest case (a task produced no outputs) → trivially
|
|
211
225
|
// current; the on-disk tree under projectDir is whatever it was,
|
|
212
226
|
// and nothing was supposed to land there.
|
|
@@ -271,6 +285,7 @@ export class OutputIndex {
|
|
|
271
285
|
prefixes: readonly string[],
|
|
272
286
|
holds?: (files: readonly string[]) => boolean,
|
|
273
287
|
): Promise<void> {
|
|
288
|
+
if (!this.memo) return
|
|
274
289
|
const rows: Array<[string, number]> = []
|
|
275
290
|
const files: string[] = []
|
|
276
291
|
let overCap = false
|
|
@@ -352,6 +367,7 @@ export class OutputIndex {
|
|
|
352
367
|
* Written with the directory snapshots, in one transaction.
|
|
353
368
|
*/
|
|
354
369
|
recordOutputStamps(hash: string, projectDir: string, workspaceRoot: string): void {
|
|
370
|
+
if (!this.memo) return
|
|
355
371
|
const rows = this.rowsOf(hash)
|
|
356
372
|
const stamps: Array<[string, number, number]> = []
|
|
357
373
|
for (const e of rows) {
|
|
@@ -442,7 +458,7 @@ export class OutputIndex {
|
|
|
442
458
|
}
|
|
443
459
|
|
|
444
460
|
/** True iff every recorded directory exists with its recorded mtime (ms). Same forged-mtime trade as the file check. */
|
|
445
|
-
|
|
461
|
+
outputDirsCurrent(projectDir: string, rows: readonly OutputDirRow[]): boolean {
|
|
446
462
|
if (rows.length === 0) return false
|
|
447
463
|
const results = rows.map((r) => {
|
|
448
464
|
let st
|