@vzn/vx 0.0.636 → 0.0.638
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 +39 -11
- package/docs/caching.md +246 -213
- package/docs/cli.md +791 -705
- package/docs/execution.md +68 -76
- package/docs/features.md +47 -42
- package/docs/patterns.md +26 -25
- package/docs/schema.md +392 -205
- package/docs/security.md +76 -39
- package/launcher.sh +43 -0
- package/package.json +5 -5
- package/schemas/cache.json +41 -19
- package/schemas/info.json +23 -11
- package/schemas/last.json +48 -2
- package/schemas/lock.json +1 -1
- package/schemas/plan.json +4 -4
- package/schemas/show.json +5 -3
- package/schemas/why.json +15 -1
- package/skills/vx/SKILL.md +28 -13
- package/src/bin.ts +30 -9
- package/src/cache/archive.ts +11 -0
- package/src/cache/cache.ts +124 -53
- package/src/cache/chained-cache.ts +4 -0
- package/src/cache/git-inputs.ts +277 -63
- package/src/cache/index.ts +15 -1
- package/src/cache/inputs.ts +102 -36
- package/src/cache/key-fold.ts +8 -7
- package/src/cache/layer.ts +15 -18
- package/src/cache/layered-cache.ts +20 -102
- package/src/cache/plugin-cache.ts +248 -0
- package/src/cache/policy.ts +32 -27
- package/src/cache/run-history.ts +104 -14
- package/src/cache/run-store.ts +77 -0
- package/src/cache/schema.ts +58 -31
- package/src/cache/scoped-cache.ts +237 -0
- package/src/cli/cache.ts +173 -34
- package/src/cli/completions.ts +11 -7
- package/src/cli/core-alias.ts +1 -1
- package/src/cli/foreign-flags.ts +13 -12
- package/src/cli/help.ts +110 -70
- package/src/cli/index.ts +67 -24
- package/src/cli/info.ts +43 -27
- package/src/cli/init.ts +115 -407
- package/src/cli/last.ts +157 -9
- package/src/cli/lock.ts +356 -122
- package/src/cli/plan-format.ts +7 -5
- package/src/cli/plugin-commands.ts +81 -14
- package/src/cli/plugin-templates.ts +11 -11
- package/src/cli/run.ts +116 -160
- package/src/cli/select.ts +50 -47
- package/src/cli/show.ts +83 -59
- package/src/cli/task-verb.ts +6 -13
- package/src/cli/upgrade.ts +415 -149
- package/src/cli/watch-cycle.ts +9 -2
- package/src/cli/watch-filter.ts +39 -61
- package/src/cli/watch-fs.ts +29 -16
- package/src/cli/watch-judge.ts +18 -4
- package/src/cli/watch-set.ts +16 -42
- package/src/cli/watch.ts +227 -230
- package/src/cli/why.ts +38 -18
- package/src/cli/workspace-config.ts +33 -70
- package/src/config.ts +169 -70
- package/src/exec/env.ts +1 -31
- package/src/exec/executor.ts +1 -5
- package/src/exec/kill-tree.ts +2 -1
- package/src/exec/sandbox-runtime.ts +12 -1
- package/src/exec/sandbox-violations.ts +9 -1
- package/src/graph/dependency-spec.ts +1 -1
- package/src/graph/index.ts +1 -0
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +27 -7
- package/src/graph/task-graph.ts +76 -24
- package/src/handoff.ts +169 -0
- package/src/index.ts +35 -58
- package/src/orchestrator/affected-tasks.ts +15 -8
- package/src/orchestrator/command-windows.ts +44 -0
- package/src/orchestrator/deferred-outputs.ts +12 -10
- package/src/orchestrator/doctor.ts +98 -110
- package/src/orchestrator/download-policy.ts +3 -3
- package/src/orchestrator/events.ts +19 -3
- package/src/orchestrator/excluded-keys.ts +0 -2
- package/src/orchestrator/execute-task.ts +147 -69
- package/src/orchestrator/failure-mode.ts +19 -10
- package/src/orchestrator/fingerprint-watch.ts +18 -15
- package/src/orchestrator/forecast.ts +10 -2
- package/src/orchestrator/framed-output.ts +9 -5
- package/src/orchestrator/graph-stage.ts +5 -5
- package/src/orchestrator/history.ts +126 -30
- package/src/orchestrator/hit-restore.ts +15 -3
- package/src/orchestrator/index.ts +20 -5
- package/src/orchestrator/local-shortcircuit.ts +2 -1
- package/src/orchestrator/lockfile-claim.ts +79 -20
- package/src/orchestrator/logger.ts +80 -41
- package/src/orchestrator/metrics.ts +78 -9
- package/src/orchestrator/miss-save.ts +3 -0
- package/src/orchestrator/options.ts +42 -29
- package/src/orchestrator/output-host.ts +72 -0
- package/src/orchestrator/plan.ts +6 -8
- package/src/orchestrator/plugin-host.ts +100 -66
- package/src/orchestrator/plugin.ts +173 -67
- package/src/orchestrator/prepare.ts +163 -78
- package/src/orchestrator/projects.ts +105 -99
- package/src/orchestrator/remote-prefetch.ts +11 -6
- package/src/orchestrator/run-context.ts +25 -6
- package/src/orchestrator/run-failures.ts +17 -8
- package/src/orchestrator/run-lock.ts +1 -1
- package/src/orchestrator/run-records.ts +1 -1
- package/src/orchestrator/run-report.ts +9 -9
- package/src/orchestrator/run-settings.ts +48 -0
- package/src/orchestrator/run.ts +244 -119
- package/src/orchestrator/sandbox-fix.ts +39 -20
- package/src/orchestrator/sandbox-request.ts +84 -13
- package/src/orchestrator/signals.ts +1 -1
- package/src/orchestrator/stable-keys.ts +13 -11
- package/src/orchestrator/status-line.ts +49 -7
- package/src/orchestrator/summary.ts +115 -106
- package/src/orchestrator/task-hash.ts +12 -83
- package/src/orchestrator/telemetry.ts +4 -4
- package/src/settings.ts +428 -0
- package/src/{orchestrator → util}/colors.ts +5 -0
- package/src/util/config-frame.ts +31 -2
- package/src/util/errors.ts +33 -1
- package/src/util/essential-env.ts +31 -0
- package/src/util/hide-env.ts +107 -0
- package/src/util/index.ts +16 -3
- package/src/util/paths.ts +10 -0
- package/src/util/refusal-notice.ts +111 -0
- package/src/util/report.ts +31 -0
- package/src/util/settle.ts +49 -30
- package/src/util/size.ts +23 -4
- package/src/util/timing.ts +3 -2
- package/src/util/ui.ts +186 -0
- package/src/util/ulid.ts +1 -1
- package/src/util/verbs.ts +0 -15
- package/src/workspace/affected.ts +84 -232
- package/src/workspace/config-eval.ts +15 -20
- package/src/workspace/config-imports.ts +78 -3
- package/src/workspace/config-schema.ts +209 -268
- package/src/workspace/dependency-rules.ts +65 -0
- package/src/workspace/filter.ts +2 -2
- package/src/workspace/fingerprint.ts +11 -121
- package/src/workspace/index.ts +20 -27
- package/src/workspace/load-reads.ts +4 -4
- package/src/workspace/lockfile.ts +96 -140
- package/src/workspace/migration.ts +307 -215
- package/src/workspace/package-graph.ts +30 -216
- package/src/workspace/project-loader.ts +80 -285
- package/src/workspace/repo-id.ts +2 -1
- package/src/workspace/workspace.ts +363 -831
- package/src/cache/config-evals.ts +0 -130
- package/src/workspace/config-cache.ts +0 -661
- package/src/workspace/migrate-scripts.ts +0 -1121
package/src/cache/inputs.ts
CHANGED
|
@@ -58,7 +58,7 @@ export {
|
|
|
58
58
|
type GitEnumeration,
|
|
59
59
|
} from './git-inputs.js'
|
|
60
60
|
|
|
61
|
-
// vx
|
|
61
|
+
// .vx/projects.lock.json is committed (so git enumerates it) but it's vx's own
|
|
62
62
|
// frozen-config metadata — never a task input. Excluded globally so a
|
|
63
63
|
// re-lock can't bust every cache key the way a tracked source file
|
|
64
64
|
// would. (Literal pattern, not the workspace `LOCKFILE_NAME` constant:
|
|
@@ -79,9 +79,16 @@ const ALWAYS_IGNORE = [
|
|
|
79
79
|
// it. Kept: the day an enumeration stops going through git, this is
|
|
80
80
|
// what keeps a repository's object store out of a cache key.
|
|
81
81
|
'**/.git/**',
|
|
82
|
-
|
|
83
|
-
'
|
|
84
|
-
'
|
|
82
|
+
// vx's machine-local state; the workspace config beside it is a source.
|
|
83
|
+
'**/.vx/cache/**',
|
|
84
|
+
'**/.vx/projects.lock.json',
|
|
85
|
+
// `vx lock`'s review copy of a project's resolved config: the config is
|
|
86
|
+
// keyed already, and a re-lock must not move every `**` reader's key.
|
|
87
|
+
'**/vx.lock.json',
|
|
88
|
+
// What `vx lock` and `vx init` write beside the lock: a re-lock or init
|
|
89
|
+
// must not select every `**` reader under --affected.
|
|
90
|
+
'**/.vx/.gitattributes',
|
|
91
|
+
'**/.vx/.gitignore',
|
|
85
92
|
// `bun build --compile` writes a transient `.<hash>-<n>.bun-build` intermediate
|
|
86
93
|
// in the cwd. It never rests on disk, so it can't be a real input — but a
|
|
87
94
|
// broad `inputs.files: ['**/*']` on a compile task would try to hash the temp
|
|
@@ -313,7 +320,13 @@ function resolveWorkspaceFiles(args: {
|
|
|
313
320
|
let isPositive: ((rel: string) => boolean) | undefined
|
|
314
321
|
const excludedByDeclaration = (rel: string): boolean =>
|
|
315
322
|
(isExcluded ??= anyTaskGlob([...ALWAYS_IGNORE, ...asTrees(negative)]))(rel)
|
|
316
|
-
|
|
323
|
+
// A committed file stays an input whatever output reaches it (`committedFiles`).
|
|
324
|
+
let committed: ((abs: string) => boolean) | undefined
|
|
325
|
+
const own = (rel: string): boolean =>
|
|
326
|
+
(ownOutput(rel) || ownProject(rel)) &&
|
|
327
|
+
!(committed ??= committedFiles(args.gitFilesCache, args.workspaceRoot))(
|
|
328
|
+
path.join(args.workspaceRoot, rel),
|
|
329
|
+
)
|
|
317
330
|
const listing: InputListing = {
|
|
318
331
|
root: args.workspaceRoot,
|
|
319
332
|
listed: gitFiles,
|
|
@@ -667,11 +680,13 @@ async function resolveRuntimeValues(
|
|
|
667
680
|
/**
|
|
668
681
|
* What no output glob reaches. An output is otherwise taken as written —
|
|
669
682
|
* `node_modules/**` IS an install task's output, so `ALWAYS_IGNORE` does not
|
|
670
|
-
* apply here — but
|
|
671
|
-
*
|
|
672
|
-
*
|
|
683
|
+
* apply here — but no task produces these, and a root project declaring
|
|
684
|
+
* `**` would otherwise wipe its repository and the cache it is restoring
|
|
685
|
+
* from (2026-09-16). `.vx` whole and each `vx.lock.json` are vx's own: as
|
|
686
|
+
* outputs, the clean deleted an uncommitted workspace config and a hit
|
|
687
|
+
* restored a lock or ignore file the key does not see over the current one.
|
|
673
688
|
*/
|
|
674
|
-
const OUTPUT_NEVER = ['**/.git/**', '**/.vx/**']
|
|
689
|
+
const OUTPUT_NEVER = ['**/.git/**', '**/.vx/**', '**/vx.lock.json']
|
|
675
690
|
|
|
676
691
|
/**
|
|
677
692
|
* What a set of output globs may not reach: `OUTPUT_NEVER`, and every
|
|
@@ -719,6 +734,20 @@ export async function resolveOutputs(args: {
|
|
|
719
734
|
return containedIn(args.projectDir, scanned).sort()
|
|
720
735
|
}
|
|
721
736
|
|
|
737
|
+
/**
|
|
738
|
+
* Whether git tracks a file under `dir`. No clean deletes a committed (or
|
|
739
|
+
* `git add`ed) file an output glob reaches (owner, 2026-10-14: "never
|
|
740
|
+
* delete committed file"); the task may rewrite it and a restore writes the
|
|
741
|
+
* bytes it left. It stays in the task's key, so those bytes follow from
|
|
742
|
+
* the key. Turbo and Nx never clean an output at all.
|
|
743
|
+
*/
|
|
744
|
+
export function committedFiles(
|
|
745
|
+
cache: GitFilesCache | undefined,
|
|
746
|
+
dir: string,
|
|
747
|
+
): (abs: string) => boolean {
|
|
748
|
+
return (cache ?? new GitFilesCache()).committedIn(dir)
|
|
749
|
+
}
|
|
750
|
+
|
|
722
751
|
/**
|
|
723
752
|
* The literal head of each output glob, walked: the scan follows a link
|
|
724
753
|
* there (`dist -> ../elsewhere` for `dist/**`) and only there. A link to a
|
|
@@ -838,6 +867,7 @@ export async function cleanOutputs(args: {
|
|
|
838
867
|
projectDir: string
|
|
839
868
|
outputs: readonly string[]
|
|
840
869
|
nestedProjectDirs: string[]
|
|
870
|
+
committed?: (abs: string) => boolean
|
|
841
871
|
/**
|
|
842
872
|
* What `resolveOutputs` returned for these same arguments, when the
|
|
843
873
|
* caller walked the tree with no await since: a hit that found its tree
|
|
@@ -846,15 +876,20 @@ export async function cleanOutputs(args: {
|
|
|
846
876
|
*/
|
|
847
877
|
resolved?: readonly string[] | undefined
|
|
848
878
|
}): Promise<string[]> {
|
|
849
|
-
const
|
|
879
|
+
const committed = args.committed ?? committedFiles(undefined, args.projectDir)
|
|
880
|
+
const matched = args.resolved ?? (await resolveOutputs(args))
|
|
850
881
|
// `force: true` makes rm tolerate ENOENT (e.g. when two output
|
|
851
882
|
// globs overlap and a sibling already deleted a path mid-iteration).
|
|
852
883
|
// A symlink is unlinked, never followed.
|
|
853
|
-
const removed = await removeAll(
|
|
884
|
+
const removed = await removeAll(
|
|
885
|
+
matched.filter((f) => !committed(f)),
|
|
886
|
+
args.projectDir,
|
|
887
|
+
)
|
|
854
888
|
await pruneEmptiedDirs(args.projectDir, removed, pruneScope(args.projectDir, args.outputs))
|
|
855
|
-
// Project-relative posix paths
|
|
856
|
-
//
|
|
857
|
-
|
|
889
|
+
// Project-relative posix paths the outputs reach, for the caller's
|
|
890
|
+
// GitFilesCache.markOutputsChanged: a committed one kept here is the
|
|
891
|
+
// task's to rewrite or remove, so its index OID no longer vouches for it.
|
|
892
|
+
return matched.map((f) => relPosix(args.projectDir, f))
|
|
858
893
|
}
|
|
859
894
|
|
|
860
895
|
/**
|
|
@@ -868,8 +903,10 @@ export async function cleanOutputPaths(args: {
|
|
|
868
903
|
projectDir: string
|
|
869
904
|
rels: readonly string[]
|
|
870
905
|
outputs: readonly string[]
|
|
906
|
+
committed?: (abs: string) => boolean
|
|
871
907
|
}): Promise<void> {
|
|
872
|
-
const
|
|
908
|
+
const committed = args.committed ?? committedFiles(undefined, args.projectDir)
|
|
909
|
+
const files = args.rels.map((r) => path.resolve(args.projectDir, r)).filter((f) => !committed(f))
|
|
873
910
|
await pruneEmptiedDirs(
|
|
874
911
|
args.projectDir,
|
|
875
912
|
await removeAll(files, args.projectDir),
|
|
@@ -1179,16 +1216,21 @@ export async function resolveWorkspaceOutputs(args: {
|
|
|
1179
1216
|
export async function cleanWorkspaceOutputs(args: {
|
|
1180
1217
|
workspaceRoot: string
|
|
1181
1218
|
outputs: readonly string[]
|
|
1219
|
+
committed?: (abs: string) => boolean
|
|
1182
1220
|
/** `cleanOutputs`'s `resolved`, from `resolveWorkspaceOutputs`. */
|
|
1183
1221
|
resolved?: readonly string[] | undefined
|
|
1184
1222
|
}): Promise<string[]> {
|
|
1185
|
-
const
|
|
1223
|
+
const committed = args.committed ?? committedFiles(undefined, args.workspaceRoot)
|
|
1224
|
+
const matched = args.resolved ?? (await resolveWorkspaceOutputs(args))
|
|
1186
1225
|
await pruneEmptiedDirs(
|
|
1187
1226
|
args.workspaceRoot,
|
|
1188
|
-
await removeAll(
|
|
1227
|
+
await removeAll(
|
|
1228
|
+
matched.filter((f) => !committed(f)),
|
|
1229
|
+
args.workspaceRoot,
|
|
1230
|
+
),
|
|
1189
1231
|
pruneScope(args.workspaceRoot, args.outputs),
|
|
1190
1232
|
)
|
|
1191
|
-
return
|
|
1233
|
+
return matched.map((f) => relPosix(args.workspaceRoot, f))
|
|
1192
1234
|
}
|
|
1193
1235
|
|
|
1194
1236
|
function realOrNull(p: string): string | null {
|
|
@@ -1380,6 +1422,12 @@ interface FilesPlan {
|
|
|
1380
1422
|
* `package.json`), so each is matched once per plan.
|
|
1381
1423
|
*/
|
|
1382
1424
|
verdicts: Map<string, boolean>
|
|
1425
|
+
/**
|
|
1426
|
+
* Whether a path is an input but for the task's own outputs: one git
|
|
1427
|
+
* tracks stays an input (`committedFiles`), so the key sees an edit to a
|
|
1428
|
+
* committed file an output glob reaches.
|
|
1429
|
+
*/
|
|
1430
|
+
ownOnly: Map<string, boolean>
|
|
1383
1431
|
}
|
|
1384
1432
|
|
|
1385
1433
|
/**
|
|
@@ -1423,6 +1471,7 @@ function filesPlan(
|
|
|
1423
1471
|
isPositive: anyTaskGlob(asTrees(positive)),
|
|
1424
1472
|
...reachOf(positive),
|
|
1425
1473
|
verdicts: new Map<string, boolean>(),
|
|
1474
|
+
ownOnly: new Map<string, boolean>(),
|
|
1426
1475
|
}
|
|
1427
1476
|
filesPlans.set(key, plan)
|
|
1428
1477
|
return plan
|
|
@@ -1432,8 +1481,8 @@ function filesPlan(
|
|
|
1432
1481
|
* Whether a changed path is one of a cached task's declared file inputs:
|
|
1433
1482
|
* `projectRel` (the path relative to the project that owns it) against
|
|
1434
1483
|
* `files`, `workspaceRel` (relative to the workspace root) against
|
|
1435
|
-
* `workspaceFiles`; null skips that half, by the globs
|
|
1436
|
-
* the
|
|
1484
|
+
* `workspaceFiles`; null skips that half, by the globs and `!` exclusions (a path under
|
|
1485
|
+
* the task's own outputs counts: a committed one is keyed). `--affected` seeds a task with it (owner, 2026-10-04).
|
|
1437
1486
|
*/
|
|
1438
1487
|
export function declaresInput(
|
|
1439
1488
|
cache: CacheConfig,
|
|
@@ -1441,13 +1490,15 @@ export function declaresInput(
|
|
|
1441
1490
|
workspaceRel: string | null,
|
|
1442
1491
|
): boolean {
|
|
1443
1492
|
if (projectRel !== null) {
|
|
1493
|
+
// A changed path under the task's own outputs is a committed file
|
|
1494
|
+
// (`git diff` lists only what git tracks) or a stray `git status`
|
|
1495
|
+
// lists: either way it may be an input, so it counts.
|
|
1444
1496
|
const plan = filesPlan(cache.inputs.files, cache.outputs.files)
|
|
1445
|
-
if (plan !== null && inPlan(plan, projectRel)) return true
|
|
1497
|
+
if (plan !== null && (inPlan(plan, projectRel) || ownOnly(plan, projectRel))) return true
|
|
1446
1498
|
}
|
|
1447
1499
|
const ws = cache.inputs.workspaceFiles
|
|
1448
1500
|
if (workspaceRel === null || ws === undefined || ws.length === 0) return false
|
|
1449
|
-
|
|
1450
|
-
return matcher(workspaceRel)
|
|
1501
|
+
return workspaceMatcher(ws)(workspaceRel)
|
|
1451
1502
|
}
|
|
1452
1503
|
|
|
1453
1504
|
/**
|
|
@@ -1471,20 +1522,26 @@ function inPlan(plan: FilesPlan, rel: string): boolean {
|
|
|
1471
1522
|
return input
|
|
1472
1523
|
}
|
|
1473
1524
|
|
|
1525
|
+
/** `FilesPlan.ownOnly`, memoised: asked only of a path `inPlan` refused. */
|
|
1526
|
+
function ownOnly(plan: FilesPlan, rel: string): boolean {
|
|
1527
|
+
let own = plan.ownOnly.get(rel)
|
|
1528
|
+
if (own === undefined) {
|
|
1529
|
+
own = plan.ownOutput(rel) && plan.isPositive(rel) && !plan.isExcluded(rel)
|
|
1530
|
+
plan.ownOnly.set(rel, own)
|
|
1531
|
+
}
|
|
1532
|
+
return own
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1474
1535
|
const workspaceMatchers = new Map<string, (rel: string) => boolean>()
|
|
1475
1536
|
|
|
1476
|
-
function workspaceMatcher(
|
|
1477
|
-
|
|
1478
|
-
ownOutputs: readonly string[],
|
|
1479
|
-
): (rel: string) => boolean {
|
|
1480
|
-
const key = JSON.stringify([decl, ownOutputs])
|
|
1537
|
+
function workspaceMatcher(decl: readonly string[]): (rel: string) => boolean {
|
|
1538
|
+
const key = JSON.stringify(decl)
|
|
1481
1539
|
let m = workspaceMatchers.get(key)
|
|
1482
1540
|
if (m !== undefined) return m
|
|
1483
1541
|
const { positive, negative } = splitNegations(decl)
|
|
1484
1542
|
const isPositive = anyTaskGlob(asTrees(positive))
|
|
1485
1543
|
const isExcluded = anyTaskGlob([...ALWAYS_IGNORE, ...asTrees(negative)])
|
|
1486
|
-
|
|
1487
|
-
m = (rel) => isPositive(rel) && !isExcluded(rel) && !ownOutput(rel)
|
|
1544
|
+
m = (rel) => isPositive(rel) && !isExcluded(rel)
|
|
1488
1545
|
workspaceMatchers.set(key, m)
|
|
1489
1546
|
return m
|
|
1490
1547
|
}
|
|
@@ -1521,7 +1578,7 @@ async function resolveFiles(
|
|
|
1521
1578
|
if (memo !== undefined && memo.snapshot === gitFiles) {
|
|
1522
1579
|
return {
|
|
1523
1580
|
files: [...memo.result],
|
|
1524
|
-
listing: listingFor(args
|
|
1581
|
+
listing: listingFor(args, gitFiles, plan, nested, ownWsOutput),
|
|
1525
1582
|
}
|
|
1526
1583
|
}
|
|
1527
1584
|
}
|
|
@@ -1566,14 +1623,17 @@ async function resolveFiles(
|
|
|
1566
1623
|
: undefined
|
|
1567
1624
|
const candidates: string[] = []
|
|
1568
1625
|
const verdicts = plan.verdicts
|
|
1626
|
+
const committed = committedFiles(args.gitFilesCache, args.projectDir)
|
|
1569
1627
|
for (const rel of gitFiles) {
|
|
1570
1628
|
let input = verdicts.get(rel)
|
|
1571
1629
|
if (input === undefined) {
|
|
1572
1630
|
input = isPositive(rel) && !isExcluded(rel) && !ownOutput(rel)
|
|
1573
1631
|
verdicts.set(rel, input)
|
|
1574
1632
|
}
|
|
1575
|
-
if (!input || nested(rel) || ownWsOutput(rel)) continue
|
|
1576
|
-
|
|
1633
|
+
if ((!input && !ownOnly(plan, rel)) || nested(rel) || ownWsOutput(rel)) continue
|
|
1634
|
+
const abs = base === undefined ? path.resolve(args.projectDir, rel) : base + rel
|
|
1635
|
+
if (!input && !committed(abs)) continue
|
|
1636
|
+
candidates.push(abs)
|
|
1577
1637
|
}
|
|
1578
1638
|
const unmatchedLiterals = unanswered(plan.literals, gitFiles)
|
|
1579
1639
|
if (unmatchedLiterals.size > 0) {
|
|
@@ -1601,21 +1661,27 @@ async function resolveFiles(
|
|
|
1601
1661
|
args.projectFilesCache?.set(memoKey, { snapshot: gitFiles, result: resolved })
|
|
1602
1662
|
return {
|
|
1603
1663
|
files: [...resolved],
|
|
1604
|
-
listing: listingFor(args
|
|
1664
|
+
listing: listingFor(args, gitFiles, plan, nested, ownWsOutput),
|
|
1605
1665
|
}
|
|
1606
1666
|
}
|
|
1607
1667
|
|
|
1608
1668
|
function listingFor(
|
|
1609
|
-
|
|
1669
|
+
args: { projectDir: string; gitFilesCache?: GitFilesCache | undefined },
|
|
1610
1670
|
listed: readonly string[],
|
|
1611
1671
|
plan: FilesPlan,
|
|
1612
1672
|
nested: (rel: string) => boolean,
|
|
1613
1673
|
ownWsOutput: (rel: string) => boolean,
|
|
1614
1674
|
): InputListing {
|
|
1675
|
+
const root = args.projectDir
|
|
1676
|
+
let committed: ((abs: string) => boolean) | undefined
|
|
1615
1677
|
return {
|
|
1616
1678
|
root,
|
|
1617
1679
|
listed,
|
|
1618
|
-
isInput: (rel) =>
|
|
1680
|
+
isInput: (rel) =>
|
|
1681
|
+
(inPlan(plan, rel) ||
|
|
1682
|
+
(ownOnly(plan, rel) &&
|
|
1683
|
+
(committed ??= committedFiles(args.gitFilesCache, root))(path.join(root, rel)))) &&
|
|
1684
|
+
!ownWsOutput(rel),
|
|
1619
1685
|
nested,
|
|
1620
1686
|
prefixes: plan.prefixes,
|
|
1621
1687
|
literals: plan.literals,
|
package/src/cache/key-fold.ts
CHANGED
|
@@ -31,9 +31,9 @@ import type { CacheKeyInput } from './layer.js'
|
|
|
31
31
|
// v23: fold cache.inputs.runtime / workspaceRuntime command output into
|
|
32
32
|
// the key (two namespaced sections after env-values). Command strings
|
|
33
33
|
// live in the resolved config (frozen by `vx lock`); the OUTPUT is
|
|
34
|
-
// resolved live every run, so it stays correct under
|
|
34
|
+
// resolved live every run, so it stays correct under the lock. No
|
|
35
35
|
// SCHEMA bump — only Cache.key derivation changed.
|
|
36
|
-
// v24: vx
|
|
36
|
+
// v24: .vx/projects.lock.json added to the always-ignored input set (cache/
|
|
37
37
|
// inputs.ts ALWAYS_IGNORE). A task whose globs matched the root
|
|
38
38
|
// lockfile (broad `**/*` on the root project) drops it from the hashed
|
|
39
39
|
// file set, so those keys change; tasks that never matched it are
|
|
@@ -105,7 +105,12 @@ import type { CacheKeyInput } from './layer.js'
|
|
|
105
105
|
// never listed sits under the key of that directory empty (A-61).
|
|
106
106
|
// v42: the stored output is both streams in order (orchestrator/output-log.ts);
|
|
107
107
|
// a v41 entry holds stdout alone and replays no stderr.
|
|
108
|
-
|
|
108
|
+
// v43: a scoped run stores under the scope's key locally too (F4,
|
|
109
|
+
// 2026-10-14); a v42 entry a scoped run left locally sits under the default key.
|
|
110
|
+
// v43 stays when core stopped folding package files (2026-10-14): the
|
|
111
|
+
// `workspace:` and `pkg:` parts left the chain, so every key moved and no
|
|
112
|
+
// unchanged key holds wrong bytes.
|
|
113
|
+
export const CACHE_VERSION = 'vx-cache-v43'
|
|
109
114
|
|
|
110
115
|
/**
|
|
111
116
|
* The digest a key folds for an input gone between its enumeration and its
|
|
@@ -151,16 +156,12 @@ export async function foldKey(
|
|
|
151
156
|
const cap = input.captureInto
|
|
152
157
|
let h = xxh3(CACHE_VERSION)
|
|
153
158
|
h = xxh3(`task:${input.taskId}`, h)
|
|
154
|
-
h = xxh3(`workspace:${input.workspaceFingerprint}`, h)
|
|
155
|
-
h = xxh3(`pkg:${input.projectPackageJsonHash}`, h)
|
|
156
159
|
h = xxh3(`config:${input.taskConfigHash}`, h)
|
|
157
160
|
if (cap) {
|
|
158
161
|
// Recorded so `vx why` can name a key that moved only because vx
|
|
159
162
|
// keys differently now (an upgrade): unrecorded, every task's miss
|
|
160
163
|
// read as "inputs differ" with no input changed (X-142).
|
|
161
164
|
cap.push({ kind: 'format', name: 'cache-version', hash: CACHE_VERSION })
|
|
162
|
-
cap.push({ kind: 'workspace', name: 'fingerprint', hash: input.workspaceFingerprint })
|
|
163
|
-
cap.push({ kind: 'package', name: 'package.json', hash: input.projectPackageJsonHash })
|
|
164
165
|
cap.push({ kind: 'config', name: 'config', hash: input.taskConfigHash })
|
|
165
166
|
}
|
|
166
167
|
|
package/src/cache/layer.ts
CHANGED
|
@@ -65,27 +65,12 @@ export interface CacheKeyInput {
|
|
|
65
65
|
readonly hash: string
|
|
66
66
|
readonly projectDir: string
|
|
67
67
|
}>
|
|
68
|
-
/**
|
|
69
|
-
* Workspace-level fingerprint — typically a hash of `pnpm-lock.yaml` +
|
|
70
|
-
* `pnpm-workspace.yaml`. Folds resolved dep versions and workspace shape
|
|
71
|
-
* into every task's key, so a lockfile bump invalidates everything.
|
|
72
|
-
*/
|
|
73
|
-
workspaceFingerprint: string
|
|
74
68
|
/**
|
|
75
69
|
* CLI args forwarded to the task (after `--`). Folded into the key so that
|
|
76
70
|
* the same command with different forwarded args is treated as a distinct
|
|
77
71
|
* run, never a spurious cache hit.
|
|
78
72
|
*/
|
|
79
73
|
forwardArgs?: readonly string[]
|
|
80
|
-
/**
|
|
81
|
-
* Hash of the project's `package.json` bytes. Folded into the key
|
|
82
|
-
* implicitly (Turbo / Nx parity) so dep changes invalidate every
|
|
83
|
-
* task in that project, even when `cache.inputs.files` doesn't
|
|
84
|
-
* cover package.json. Empty string when the project has no
|
|
85
|
-
* package.json (impossible in practice — workspace discovery
|
|
86
|
-
* requires one — but we don't fail-loud here).
|
|
87
|
-
*/
|
|
88
|
-
projectPackageJsonHash: string
|
|
89
74
|
/**
|
|
90
75
|
* Precomputed content hashes (git blob OIDs) keyed by absolute
|
|
91
76
|
* path — typically the trusted-index OID map harvested by the
|
|
@@ -190,7 +175,7 @@ export interface RunRecord {
|
|
|
190
175
|
endedAt: number // ms-epoch wall clock
|
|
191
176
|
/**
|
|
192
177
|
* Optional analytics columns. Populated by the orchestrator/runner;
|
|
193
|
-
* stored as NULL on rows from older runs. Surfaced via `vx
|
|
178
|
+
* stored as NULL on rows from older runs. Surfaced via `vx info`
|
|
194
179
|
* and consumable from CI by reading cache.db directly.
|
|
195
180
|
*/
|
|
196
181
|
runId?: string // ULID shared across every task in one `vx run` invocation
|
|
@@ -236,7 +221,7 @@ export interface InvocationRecord {
|
|
|
236
221
|
command: string
|
|
237
222
|
/** JSON-serialized `string[]` of the requested task names. */
|
|
238
223
|
requestedTasks: string
|
|
239
|
-
/**
|
|
224
|
+
/** The `--cache-policy` spec, e.g. `'local:rw,remote:r'` (a flag omitted = off). */
|
|
240
225
|
cachePolicy: string
|
|
241
226
|
concurrency: number
|
|
242
227
|
flow: 'focused' | 'broad' | null
|
|
@@ -495,7 +480,7 @@ export class CorruptArtifactError extends Error {
|
|
|
495
480
|
|
|
496
481
|
/**
|
|
497
482
|
* `restoreOutputs` found no artifact where the probe found one: it was
|
|
498
|
-
* removed in between — a `vx cache
|
|
483
|
+
* removed in between — a `vx cache clean` in another shell, or the
|
|
499
484
|
* `cacheRetention` of another workspace sharing the cache directory. The
|
|
500
485
|
* bytes are not wrong, they are gone, so the entry is a MISS and the task
|
|
501
486
|
* runs (execute-task.ts); nothing about the cache needs throwing away.
|
|
@@ -559,6 +544,12 @@ export interface CacheLayer {
|
|
|
559
544
|
* batch omits it and the prefetch pass falls back to per-hash GETs.
|
|
560
545
|
*/
|
|
561
546
|
remoteHasMany?(hashes: readonly string[]): Promise<Set<string> | null>
|
|
547
|
+
/**
|
|
548
|
+
* The subset of `hashes` the local store already holds. The prefetch
|
|
549
|
+
* pass probes the remote only for the rest, so a run the local cache
|
|
550
|
+
* answers makes no remote call. Absent reads as "none held".
|
|
551
|
+
*/
|
|
552
|
+
localHasMany?(hashes: readonly string[]): Promise<Set<string>>
|
|
562
553
|
/**
|
|
563
554
|
* Record that the remote has NO artifact for each of `hashes` (from a
|
|
564
555
|
* batch probe), so a later `get`/`prefetch` short-circuits to a miss with
|
|
@@ -702,6 +693,12 @@ export interface CacheLayer {
|
|
|
702
693
|
* Only computed on the miss/save path — never on a hit.
|
|
703
694
|
*/
|
|
704
695
|
inputComponents?: readonly TaskInputRow[]
|
|
696
|
+
/**
|
|
697
|
+
* For a sandboxed task: whether it could read a canonical path. A
|
|
698
|
+
* symlinked output is packed as its target's bytes, outside the
|
|
699
|
+
* sandbox, so one whose target the task could not read is refused.
|
|
700
|
+
*/
|
|
701
|
+
readable?: ((real: string) => boolean) | undefined
|
|
705
702
|
}): Promise<void>
|
|
706
703
|
/**
|
|
707
704
|
* Adopt an artifact produced elsewhere — the remote-hit path. Streams
|
|
@@ -33,12 +33,11 @@ import type {
|
|
|
33
33
|
SaveArgs,
|
|
34
34
|
} from './cache.js'
|
|
35
35
|
import { FULL_CACHE_POLICY, type OutputDirRow } from './cache.js'
|
|
36
|
-
import { xxh3hex } from '../util/index.js'
|
|
37
36
|
|
|
38
37
|
/**
|
|
39
38
|
* What a remote cache layer must provide — THE plugin seam for remote
|
|
40
|
-
* caching. Core ships no wire client; a plugin's `cache`
|
|
41
|
-
* embedder
|
|
39
|
+
* caching. Core ships no wire client; a plugin's `cache` hook returns one
|
|
40
|
+
* (or an embedder passes one as `RunOptions.remoteCache`), speaking
|
|
42
41
|
* whatever protocol it wants, and `LayeredCache` owns everything else:
|
|
43
42
|
* policy gating, in-flight dedup, remote provenance, and the never-fail
|
|
44
43
|
* contract (implementations THROW on failure; LayeredCache degrades every
|
|
@@ -60,6 +59,11 @@ export interface RemoteCacheLayer {
|
|
|
60
59
|
* dropped before it is printed.
|
|
61
60
|
*/
|
|
62
61
|
readonly endpoint?: string
|
|
62
|
+
/**
|
|
63
|
+
* What the wire's credential allows, when less than both (a read-only
|
|
64
|
+
* token): core clears the run's remote axis it lacks. Omitted → both.
|
|
65
|
+
*/
|
|
66
|
+
readonly access?: { readonly read?: boolean; readonly write?: boolean }
|
|
63
67
|
/** Existence probe (drives the plan path's `--dry` remote prediction). */
|
|
64
68
|
has(hash: string): Promise<boolean>
|
|
65
69
|
/**
|
|
@@ -161,91 +165,6 @@ function batchOf(hashes: readonly string[]): { batch: string } {
|
|
|
161
165
|
return { batch: `of ${hashes.length} artifact${hashes.length === 1 ? '' : 's'}` }
|
|
162
166
|
}
|
|
163
167
|
|
|
164
|
-
/**
|
|
165
|
-
* The remote as an untrusted scope sees it: reads try the scope's key,
|
|
166
|
-
* where a PR's own earlier runs left it, then the trusted one; writes go to the scope's key only. A scope's key is
|
|
167
|
-
* derived from the task key, so every wire stores it unchanged and the
|
|
168
|
-
* trusted keyspace never holds a scoped write. What a scoped read
|
|
169
|
-
* returns is ingested under the TASK key, and ingest refuses an artifact
|
|
170
|
-
* recording another key, so a derived-key collision degrades to a miss.
|
|
171
|
-
*/
|
|
172
|
-
class ScopedRemote implements RemoteCacheLayer {
|
|
173
|
-
readonly endpoint?: string
|
|
174
|
-
readonly hasMany?: (hashes: readonly string[]) => Promise<Set<string> | null>
|
|
175
|
-
/** Task keys a batch probe found under the trusted key alone: their GET skips the scope's. */
|
|
176
|
-
private readonly trustedOnly = new Set<string>()
|
|
177
|
-
|
|
178
|
-
constructor(
|
|
179
|
-
private readonly remote: RemoteCacheLayer,
|
|
180
|
-
private readonly scope: string,
|
|
181
|
-
) {
|
|
182
|
-
if (remote.endpoint !== undefined) this.endpoint = remote.endpoint
|
|
183
|
-
if (remote.hasMany !== undefined) this.hasMany = (hashes) => this.scopedHasMany(hashes)
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
private scoped(hash: string): string {
|
|
187
|
-
return scopedKey(this.scope, hash)
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
async has(hash: string): Promise<boolean> {
|
|
191
|
-
return this.fallThrough(
|
|
192
|
-
() => this.remote.has(this.scoped(hash)),
|
|
193
|
-
() => this.remote.has(hash),
|
|
194
|
-
)
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* The scope's answer, else the trusted one. A scope lookup that fails
|
|
199
|
-
* still asks the trusted key, so a hit there is not lost; its error is
|
|
200
|
-
* rethrown (and warned) when the trusted key fails or has nothing too.
|
|
201
|
-
*/
|
|
202
|
-
private async fallThrough<T>(own: () => Promise<T>, trusted: () => Promise<T>): Promise<T> {
|
|
203
|
-
let found: T
|
|
204
|
-
try {
|
|
205
|
-
found = await own()
|
|
206
|
-
} catch (err) {
|
|
207
|
-
const fallback = await trusted().catch(() => undefined)
|
|
208
|
-
if (!fallback) throw err
|
|
209
|
-
return fallback
|
|
210
|
-
}
|
|
211
|
-
return found || trusted()
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
private async scopedHasMany(hashes: readonly string[]): Promise<Set<string> | null> {
|
|
215
|
-
const scoped = hashes.map((h) => this.scoped(h))
|
|
216
|
-
const [trusted, own] = await Promise.all([
|
|
217
|
-
this.remote.hasMany!(hashes),
|
|
218
|
-
this.remote.hasMany!(scoped),
|
|
219
|
-
])
|
|
220
|
-
if (trusted == null || own == null) return null
|
|
221
|
-
const found = new Set(trusted)
|
|
222
|
-
for (let i = 0; i < hashes.length; i++) {
|
|
223
|
-
if (own.has(scoped[i]!)) found.add(hashes[i]!)
|
|
224
|
-
else if (trusted.has(hashes[i]!)) this.trustedOnly.add(hashes[i]!)
|
|
225
|
-
}
|
|
226
|
-
return found
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
async get(
|
|
230
|
-
hash: string,
|
|
231
|
-
): Promise<{ body: Blob | Response; durationMs: number | undefined } | null> {
|
|
232
|
-
if (this.trustedOnly.has(hash)) return this.remote.get(hash)
|
|
233
|
-
return this.fallThrough(
|
|
234
|
-
() => this.remote.get(this.scoped(hash)),
|
|
235
|
-
() => this.remote.get(hash),
|
|
236
|
-
)
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
put(hash: string, body: Blob, meta: { durationMs: number }): Promise<void> {
|
|
240
|
-
return this.remote.put(this.scoped(hash), body, meta)
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/** The key an untrusted scope stores `hash` under: task-key shaped, 16 hex. */
|
|
245
|
-
function scopedKey(scope: string, hash: string): string {
|
|
246
|
-
return xxh3hex(`vx-cache-scope\0${scope}\0${hash}`)
|
|
247
|
-
}
|
|
248
|
-
|
|
249
168
|
export interface LayeredCacheOptions {
|
|
250
169
|
/**
|
|
251
170
|
* Called for the remote failures the layer degrades to a miss, once per
|
|
@@ -312,17 +231,12 @@ export class LayeredCache implements CacheLayer {
|
|
|
312
231
|
*/
|
|
313
232
|
private readonly reported = new Map<string, { cause: string; repeats: number }>()
|
|
314
233
|
|
|
315
|
-
/** The remote as this run's scope sees it: `remote` itself when trusted. */
|
|
316
|
-
private readonly wire: RemoteCacheLayer
|
|
317
|
-
|
|
318
234
|
constructor(
|
|
319
235
|
readonly local: Cache,
|
|
320
236
|
private readonly remote: RemoteCacheLayer,
|
|
321
237
|
private readonly options: LayeredCacheOptions = {},
|
|
322
238
|
) {
|
|
323
239
|
this.policy = options.policy ?? FULL_CACHE_POLICY
|
|
324
|
-
const scope = this.policy.remoteScope
|
|
325
|
-
this.wire = scope === undefined ? remote : new ScopedRemote(remote, scope)
|
|
326
240
|
this.endpoint = printableEndpoint(remote.endpoint)
|
|
327
241
|
}
|
|
328
242
|
|
|
@@ -340,6 +254,10 @@ export class LayeredCache implements CacheLayer {
|
|
|
340
254
|
return this.pullFromRemote(hash, ctx)
|
|
341
255
|
}
|
|
342
256
|
|
|
257
|
+
localHasMany(hashes: readonly string[]): Promise<Set<string>> {
|
|
258
|
+
return this.local.localHasMany(hashes)
|
|
259
|
+
}
|
|
260
|
+
|
|
343
261
|
/**
|
|
344
262
|
* Batch existence probe over the remote layer — the subset of `hashes`
|
|
345
263
|
* present remotely, in one round-trip, or `null` when the remote can't
|
|
@@ -349,11 +267,11 @@ export class LayeredCache implements CacheLayer {
|
|
|
349
267
|
* degrades to "no batch info" and the caller falls back to per-hash.
|
|
350
268
|
*/
|
|
351
269
|
async remoteHasMany(hashes: readonly string[]): Promise<Set<string> | null> {
|
|
352
|
-
if (!this.policy.remoteRead || this.
|
|
270
|
+
if (!this.policy.remoteRead || this.remote.hasMany === undefined) return null
|
|
353
271
|
try {
|
|
354
272
|
// `return await`, deliberately: the catch below is the never-fail
|
|
355
273
|
// contract, and a returned promise's rejection would sail past it.
|
|
356
|
-
const found: unknown = await this.
|
|
274
|
+
const found: unknown = await this.remote.hasMany(hashes)
|
|
357
275
|
if (found !== null && found !== undefined && !(found instanceof Set)) {
|
|
358
276
|
this.reportRemoteError(
|
|
359
277
|
'probe',
|
|
@@ -394,7 +312,7 @@ export class LayeredCache implements CacheLayer {
|
|
|
394
312
|
return this.asRemote(hash, localHit)
|
|
395
313
|
}
|
|
396
314
|
|
|
397
|
-
// Remote reads disabled (e.g. `--cache=remote:`): a local miss is a
|
|
315
|
+
// Remote reads disabled (e.g. `--cache-policy=remote:`): a local miss is a
|
|
398
316
|
// real miss; never touch the remote layer.
|
|
399
317
|
if (!this.policy.remoteRead) return null
|
|
400
318
|
|
|
@@ -410,7 +328,7 @@ export class LayeredCache implements CacheLayer {
|
|
|
410
328
|
// the artifact + index row this pull just wrote exist regardless of
|
|
411
329
|
// policy, and the gate ("don't serve hits from the pre-existing local
|
|
412
330
|
// cache") must not discard them. Going through the gated `get` here
|
|
413
|
-
// made `--cache=local:,remote:rw` download the artifact, throw the hit
|
|
331
|
+
// made `--cache-policy=local:,remote:rw` download the artifact, throw the hit
|
|
414
332
|
// away, re-execute and re-upload — on every run, forever.
|
|
415
333
|
const materialized = await this.local.getIngested(hash)
|
|
416
334
|
if (!materialized) return null
|
|
@@ -437,7 +355,7 @@ export class LayeredCache implements CacheLayer {
|
|
|
437
355
|
if ((await this.local.has(hash)) === 'local') return 'local'
|
|
438
356
|
if (!this.policy.remoteRead) return null
|
|
439
357
|
try {
|
|
440
|
-
return (await this.
|
|
358
|
+
return (await this.remote.has(hash)) ? 'remote' : null
|
|
441
359
|
} catch (err) {
|
|
442
360
|
this.reportRemoteError('probe', hash, err)
|
|
443
361
|
return null
|
|
@@ -474,7 +392,7 @@ export class LayeredCache implements CacheLayer {
|
|
|
474
392
|
const fetchStart = performance.now()
|
|
475
393
|
let remoteResult: unknown
|
|
476
394
|
try {
|
|
477
|
-
remoteResult = await this.
|
|
395
|
+
remoteResult = await this.remote.get(hash)
|
|
478
396
|
} catch (err) {
|
|
479
397
|
this.reportRemoteError('download', hash, err)
|
|
480
398
|
return false
|
|
@@ -560,11 +478,11 @@ export class LayeredCache implements CacheLayer {
|
|
|
560
478
|
// queued job holds a path, not a buffer, a plugin that streams the Blob
|
|
561
479
|
// never holds it whole, and every read of it sees the same bytes though
|
|
562
480
|
// a re-save of the key renames others over the live name. If a
|
|
563
|
-
// concurrent `vx cache
|
|
481
|
+
// concurrent `vx cache clean` removed the artifact first the pin throws
|
|
564
482
|
// and this upload is skipped — the never-fail contract.
|
|
565
483
|
const hash = args.hash
|
|
566
484
|
const durationMs = args.entry.durationMs
|
|
567
|
-
// Local writes disabled (`--cache=local:,remote:rw`): there is no
|
|
485
|
+
// Local writes disabled (`--cache-policy=local:,remote:rw`): there is no
|
|
568
486
|
// on-disk artifact to read later, so the bytes must be packed NOW,
|
|
569
487
|
// while this task's output files are still on disk. Deferring THIS
|
|
570
488
|
// read would pack whatever the tree happens to hold when the job
|
|
@@ -587,7 +505,7 @@ export class LayeredCache implements CacheLayer {
|
|
|
587
505
|
let body: Blob
|
|
588
506
|
if (packed !== undefined) body = new Blob([packed])
|
|
589
507
|
else ({ body, release } = this.local.pinArtifact(hash))
|
|
590
|
-
await this.
|
|
508
|
+
await this.remote.put(hash, body, { durationMs })
|
|
591
509
|
tally.count++
|
|
592
510
|
tally.bytes += body.size
|
|
593
511
|
tally.ms += Math.round(performance.now() - start)
|