@vzn/vx 0.0.596 → 0.0.598
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/launcher.cjs +7 -3
- package/package.json +5 -5
- package/src/bin.ts +9 -5
- package/src/cache/archive.ts +35 -6
- package/src/cache/cache.ts +60 -31
- package/src/cache/git-inputs.ts +23 -0
- package/src/cache/index.ts +2 -0
- package/src/cache/inputs.ts +87 -32
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +2 -1
- package/src/cache/layered-cache.ts +14 -9
- package/src/cache/output-index.ts +12 -7
- package/src/cache/run-history.ts +16 -8
- package/src/cli/cache.ts +2 -2
- package/src/cli/foreign-flags.ts +56 -2
- package/src/cli/info.ts +1 -1
- package/src/cli/init.ts +2 -2
- package/src/cli/last.ts +6 -3
- package/src/cli/lock.ts +5 -3
- package/src/cli/plan-format.ts +26 -20
- package/src/cli/run.ts +42 -15
- package/src/cli/select.ts +19 -5
- package/src/cli/show.ts +21 -27
- package/src/cli/upgrade.ts +1 -1
- package/src/cli/watch-cycle.ts +43 -0
- package/src/cli/watch-fs.ts +11 -3
- package/src/cli/watch.ts +50 -11
- package/src/cli/workspace-config.ts +3 -1
- package/src/config.ts +21 -0
- package/src/exec/executor.ts +1 -1
- package/src/exec/kill-tree.ts +121 -20
- package/src/exec/runner.ts +224 -48
- package/src/exec/sandbox-paths.ts +11 -0
- package/src/exec/sandbox-runtime.ts +187 -54
- package/src/exec/sandbox-violations.ts +59 -6
- package/src/graph/priorities.ts +1 -2
- package/src/graph/scheduler.ts +2 -2
- package/src/graph/task-graph.ts +145 -33
- package/src/index.ts +3 -1
- package/src/orchestrator/affected-tasks.ts +6 -2
- package/src/orchestrator/deferred-outputs.ts +3 -2
- package/src/orchestrator/doctor.ts +5 -3
- package/src/orchestrator/excluded-keys.ts +2 -13
- package/src/orchestrator/execute-task.ts +70 -28
- package/src/orchestrator/failure-mode.ts +38 -29
- package/src/orchestrator/framed-output.ts +5 -5
- package/src/orchestrator/hit-restore.ts +1 -1
- package/src/orchestrator/index.ts +0 -1
- package/src/orchestrator/keyed-projects.ts +6 -3
- package/src/orchestrator/lockfile-claim.ts +14 -7
- package/src/orchestrator/logger.ts +17 -7
- package/src/orchestrator/miss-save.ts +7 -11
- package/src/orchestrator/plan.ts +4 -20
- package/src/orchestrator/plugin-host.ts +7 -2
- package/src/orchestrator/plugin.ts +5 -2
- package/src/orchestrator/prepare.ts +4 -2
- package/src/orchestrator/projects.ts +1 -1
- package/src/orchestrator/run-artifacts.ts +2 -2
- package/src/orchestrator/run-context.ts +1 -1
- package/src/orchestrator/run-lock.ts +35 -25
- package/src/orchestrator/run.ts +2 -1
- package/src/orchestrator/sandbox-request.ts +31 -9
- package/src/orchestrator/signals.ts +22 -4
- package/src/orchestrator/stable-keys.ts +3 -11
- package/src/orchestrator/summary.ts +5 -9
- package/src/orchestrator/task-hash.ts +13 -3
- package/src/orchestrator/upstream.ts +11 -0
- package/src/util/hangup.ts +42 -0
- package/src/util/index.ts +2 -0
- package/src/util/paths.ts +1 -1
- package/src/workspace/affected.ts +79 -42
- package/src/workspace/config-cache.ts +15 -1
- package/src/workspace/config-eval.ts +47 -7
- package/src/workspace/config-imports.ts +8 -55
- package/src/workspace/config-schema.ts +30 -6
- package/src/workspace/lockfile.ts +2 -1
- package/src/workspace/migrate-scripts.ts +1 -1
- package/src/workspace/project-loader.ts +33 -6
- package/src/workspace/workspace.ts +36 -14
package/launcher.cjs
CHANGED
|
@@ -18,12 +18,16 @@
|
|
|
18
18
|
// CommonJS, not ESM: Node's ESM loader cost every `vx` call ~8 ms of a
|
|
19
19
|
// ~65 ms launch (min of 21, interleaved, Node 22, 2026-10-01).
|
|
20
20
|
const { execFileSync, spawn, spawnSync } = require('node:child_process')
|
|
21
|
-
const { existsSync, readFileSync } = require('node:fs')
|
|
21
|
+
const { existsSync, readFileSync, realpathSync } = require('node:fs')
|
|
22
22
|
const { constants: osConstants } = require('node:os')
|
|
23
23
|
const { dirname, join } = require('node:path')
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
// The real directory, not `__dirname`: npm's `node_modules/.bin/vx` is a
|
|
26
|
+
// symlink to this file, and under `--preserve-symlinks-main` (NODE_OPTIONS)
|
|
27
|
+
// `__dirname` is `.bin`, where `./package.json` does not resolve and
|
|
28
|
+
// the launcher died MODULE_NOT_FOUND before vx started.
|
|
29
|
+
const here = dirname(realpathSync(__filename))
|
|
30
|
+
const pkg = require(join(here, 'package.json'))
|
|
27
31
|
|
|
28
32
|
// Derive the platform-package prefix + binary basename from this package's
|
|
29
33
|
// name. `base` is the unscoped name — the command AND the binary filename
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.598",
|
|
4
4
|
"description": "The fastest task runner and build cache for JavaScript monorepos.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
"node": ">=18"
|
|
22
22
|
},
|
|
23
23
|
"optionalDependencies": {
|
|
24
|
-
"@vzn/vx-linux-x64": "0.0.
|
|
25
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
26
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
27
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
24
|
+
"@vzn/vx-linux-x64": "0.0.598",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.598",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.598",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.598"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/bin.ts
CHANGED
|
@@ -30,8 +30,12 @@ async function main(): Promise<void> {
|
|
|
30
30
|
// is set, and `exitOnceFlushed` exits after both streams have ended.
|
|
31
31
|
process.exitCode = code
|
|
32
32
|
} catch (err) {
|
|
33
|
-
const { fsRefusalHint, isFsRefusal, isOutOfFds, isUserError, OUT_OF_FDS_HINT } =
|
|
33
|
+
const { fsRefusalHint, isFsRefusal, isOutOfFds, isUserError, maskedLine, OUT_OF_FDS_HINT } =
|
|
34
34
|
await import('./util/index.js')
|
|
35
|
+
// A plugin's failure quotes what it was told (a remote's reply, a
|
|
36
|
+
// header), and a config's own throw is its author's text: a secret in
|
|
37
|
+
// either reached stderr whole (L-11).
|
|
38
|
+
const say = (line: string): void => void process.stderr.write(maskedLine(line))
|
|
35
39
|
// UserError (workspace not found, cycle, config invalid, ...) —
|
|
36
40
|
// print the message only; the stack is noise the user can't act
|
|
37
41
|
// on. Everything else gets the full stack so internal bugs are
|
|
@@ -41,16 +45,16 @@ async function main(): Promise<void> {
|
|
|
41
45
|
// that wants its own name in the line) is printed as it is; prefixing
|
|
42
46
|
// it produced `vx: vx why: …` (walkthrough, 2026-09-04).
|
|
43
47
|
const m = err.message
|
|
44
|
-
|
|
48
|
+
say(m.startsWith('vx ') ? `${m}\n` : `vx: ${m}\n`)
|
|
45
49
|
} else if (isOutOfFds(err)) {
|
|
46
|
-
|
|
50
|
+
say(`vx: ${err.message} — ${OUT_OF_FDS_HINT}\n`)
|
|
47
51
|
} else if (isFsRefusal(err)) {
|
|
48
52
|
// The file system's refusal names the path; the stack would name
|
|
49
53
|
// the verb's write, which the reader cannot act on either.
|
|
50
|
-
|
|
54
|
+
say(`vx: ${err.message} — ${fsRefusalHint(err)}\n`)
|
|
51
55
|
} else {
|
|
52
56
|
const message = err instanceof Error ? (err.stack ?? err.message) : String(err)
|
|
53
|
-
|
|
57
|
+
say(`vx: ${message}\n`)
|
|
54
58
|
}
|
|
55
59
|
// Same reason as the success path above: a large stderr is truncated by
|
|
56
60
|
// `process.exit` too, and an error message cut in half is the one a
|
package/src/cache/archive.ts
CHANGED
|
@@ -233,7 +233,7 @@ export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
|
|
|
233
233
|
const few = args.outputs.size <= ON_THREAD_STATS
|
|
234
234
|
const lstatOf = few ? async (p: string) => lstatSync(p) : lstat
|
|
235
235
|
const statOf = few ? async (p: string) => statSync(p) : stat
|
|
236
|
-
const realpathOf =
|
|
236
|
+
const realpathOf = (p: string) => realOf(p, few)
|
|
237
237
|
let withinReal: Promise<string> | undefined
|
|
238
238
|
// Where this artifact's own non-link outputs really are: a link to one of
|
|
239
239
|
// them packs bytes the task wrote itself. A root-anchored output tree is
|
|
@@ -262,6 +262,16 @@ export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
|
|
|
262
262
|
: name.startsWith(WORKSPACE_OUTPUT_PREFIX)
|
|
263
263
|
? `workspaceFiles output ${name.slice(WORKSPACE_OUTPUT_PREFIX.length)}`
|
|
264
264
|
: `output ${name}`
|
|
265
|
+
// The index stores a project row as its bare rel and reads one under
|
|
266
|
+
// `workspace-outputs/` as a workspace output, so every hit of this
|
|
267
|
+
// entry was "missing a recorded output", dropped and run again. The
|
|
268
|
+
// loader refuses a glob written under the name; one that reaches it
|
|
269
|
+
// otherwise (`**/*.js`, `w*/**`) is refused here, where names are known.
|
|
270
|
+
if (name.startsWith(`outputs/${WORKSPACE_OUTPUT_PREFIX}`)) {
|
|
271
|
+
throw new UserError(
|
|
272
|
+
`${shown} is under workspace-outputs/, a name vx's artifacts reserve for outputs.workspaceFiles — write the task's files to another directory, or take them back with a '!' entry`,
|
|
273
|
+
)
|
|
274
|
+
}
|
|
265
275
|
const dangling = (err: NodeJS.ErrnoException): never => {
|
|
266
276
|
if (err.code === 'ENOENT') {
|
|
267
277
|
throw new UserError(
|
|
@@ -293,12 +303,12 @@ export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
|
|
|
293
303
|
)
|
|
294
304
|
}
|
|
295
305
|
const mode = st.mode & 0o777
|
|
296
|
-
meta.files[name] = [mode, Math.floor(st.mtimeMs)]
|
|
297
306
|
return {
|
|
298
307
|
name,
|
|
299
308
|
abs: src,
|
|
300
309
|
size: st.size,
|
|
301
310
|
mode,
|
|
311
|
+
mtimeMs: Math.floor(st.mtimeMs),
|
|
302
312
|
// ustar's octal field holds no sign: an mtime before 1970 made the
|
|
303
313
|
// header unreadable and every save of it a "corrupt artifact". The
|
|
304
314
|
// sidecar above carries the real value.
|
|
@@ -306,6 +316,8 @@ export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
|
|
|
306
316
|
}
|
|
307
317
|
}),
|
|
308
318
|
)
|
|
319
|
+
// In the outputs' order, not as each stat settled: one tree, one artifact.
|
|
320
|
+
for (const f of files) meta.files[f.name] = [f.mode, f.mtimeMs]
|
|
309
321
|
const stdout = new TextEncoder().encode(args.stdout)
|
|
310
322
|
const metaBytes = new TextEncoder().encode(JSON.stringify(meta))
|
|
311
323
|
const inputs: TarInput[] = [
|
|
@@ -624,11 +636,11 @@ class Extractor {
|
|
|
624
636
|
// deepest EXISTING ancestor and re-append the rest: the answer is the
|
|
625
637
|
// same before and after the base is created, which is what makes the
|
|
626
638
|
// memo safe.
|
|
627
|
-
r = await
|
|
639
|
+
r = await realOf(key).catch(async () => {
|
|
628
640
|
let probe = path.dirname(key)
|
|
629
641
|
const tail: string[] = [path.basename(key)]
|
|
630
642
|
while (probe !== path.dirname(probe)) {
|
|
631
|
-
const real = await
|
|
643
|
+
const real = await realOf(probe).then(
|
|
632
644
|
(v) => v,
|
|
633
645
|
() => null,
|
|
634
646
|
)
|
|
@@ -674,7 +686,7 @@ class Extractor {
|
|
|
674
686
|
// resolve was a round trip spent on a comparison never made (item 627).
|
|
675
687
|
let probe = path.dirname(targetResolved)
|
|
676
688
|
while (probe.startsWith(baseResolved + path.sep)) {
|
|
677
|
-
const real = await
|
|
689
|
+
const real = await realOf(probe).then(
|
|
678
690
|
(r) => r,
|
|
679
691
|
() => null,
|
|
680
692
|
)
|
|
@@ -854,7 +866,7 @@ class Extractor {
|
|
|
854
866
|
const top = path.resolve(s.created)
|
|
855
867
|
// Created at a dangling link's resolved target (`mkdirThroughLink`),
|
|
856
868
|
// so the chain is pruned from where the link leads.
|
|
857
|
-
if (!dir.startsWith(top)) dir = await
|
|
869
|
+
if (!dir.startsWith(top)) dir = await realOf(dir).catch(() => dir)
|
|
858
870
|
while (dir.startsWith(top)) {
|
|
859
871
|
if (
|
|
860
872
|
!(await rmdir(dir).then(
|
|
@@ -903,6 +915,23 @@ async function linkOutError(base: string, probe: string, realBase: string): Prom
|
|
|
903
915
|
)
|
|
904
916
|
}
|
|
905
917
|
|
|
918
|
+
/**
|
|
919
|
+
* `realpath`, for a path holding a backslash too: Bun's answers ENOENT for
|
|
920
|
+
* any such path (1.4.2), so the containment walk took a link named
|
|
921
|
+
* `dist\x` for absent and wrote through it, and a save called a linked
|
|
922
|
+
* output under one dangling. Such a path's links are read one by one.
|
|
923
|
+
*/
|
|
924
|
+
async function realOf(p: string, sync = false): Promise<string> {
|
|
925
|
+
try {
|
|
926
|
+
return sync ? realpathSync(p) : await realpath(p)
|
|
927
|
+
} catch (err) {
|
|
928
|
+
if (!p.includes('\\')) throw err
|
|
929
|
+
const real = await resolveThrough(p)
|
|
930
|
+
await stat(real)
|
|
931
|
+
return real
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
906
935
|
/**
|
|
907
936
|
* Where `p` leads, every link on the way followed, a dangling one included:
|
|
908
937
|
* `realpath` for the part that exists, and past the first missing name the
|
package/src/cache/cache.ts
CHANGED
|
@@ -660,15 +660,6 @@ export class Cache implements CacheLayer {
|
|
|
660
660
|
/** The shared store this index attached, or undefined when it holds its entries itself. */
|
|
661
661
|
readonly storeDir: string | undefined
|
|
662
662
|
|
|
663
|
-
/**
|
|
664
|
-
* The store this open was asked for and could not use, with why: the
|
|
665
|
-
* entries went to a store inside `cacheDir` instead.
|
|
666
|
-
*/
|
|
667
|
-
readonly storeFallback: string | null = null
|
|
668
|
-
|
|
669
|
-
/** Set when this open moved a workspace index's own entries out for a shared store. */
|
|
670
|
-
readonly storeMoved: { from: string; to: string } | null = null
|
|
671
|
-
|
|
672
663
|
/**
|
|
673
664
|
* Set when THIS open found an index written by another `SCHEMA_VERSION`
|
|
674
665
|
* and dropped every table. The next open sees the current version and
|
|
@@ -849,24 +840,33 @@ export class Cache implements CacheLayer {
|
|
|
849
840
|
this.schemaReset = this.db
|
|
850
841
|
.transaction((): SchemaReset | null => {
|
|
851
842
|
const found = readVersion()
|
|
852
|
-
if (found !== undefined && found !== SCHEMA_VERSION) refuseUnreadable(found)
|
|
853
|
-
if (found === undefined) {
|
|
854
|
-
this.db
|
|
855
|
-
.prepare("INSERT INTO schema_meta(key, value) VALUES ('version', ?)")
|
|
856
|
-
.run(SCHEMA_VERSION)
|
|
857
|
-
return null
|
|
858
|
-
}
|
|
859
843
|
if (found === SCHEMA_VERSION) return null
|
|
844
|
+
if (found !== undefined) refuseUnreadable(found)
|
|
860
845
|
// `main.` on every name: unqualified, a table the index lacks
|
|
861
846
|
// resolves to an attached store's (none is attached yet; this
|
|
862
847
|
// keeps it so).
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
848
|
+
if (found !== undefined) {
|
|
849
|
+
this.db.exec(
|
|
850
|
+
'DROP TABLE IF EXISTS main.entries; DROP TABLE IF EXISTS main.runs; DROP TABLE IF EXISTS main.file_hashes; DROP TABLE IF EXISTS main.blob_sizes; DROP TABLE IF EXISTS main.blob_verdicts; DROP TABLE IF EXISTS main.output_files; DROP TABLE IF EXISTS main.output_stamps; DROP TABLE IF EXISTS main.invocations; DROP TABLE IF EXISTS main.run_task_inputs; DROP TABLE IF EXISTS main.entry_inputs; DROP TABLE IF EXISTS main.config_evals; DROP TABLE IF EXISTS main.config_closures; DROP TABLE IF EXISTS main.output_dirs; DROP TABLE IF EXISTS main.entry_stdout; DROP TABLE IF EXISTS main.store_meta;',
|
|
851
|
+
)
|
|
852
|
+
}
|
|
853
|
+
// The tables land under the same write lock as the stamp: made
|
|
854
|
+
// after it, another vx's reset could stamp its own schema in
|
|
855
|
+
// between and keep these tables under it. An index beside a
|
|
856
|
+
// store keeps no entry tables (they would shadow the store's).
|
|
857
|
+
createTables(this.db, 'main')
|
|
858
|
+
const recordedStore = this.db
|
|
859
|
+
.prepare("SELECT 1 FROM schema_meta WHERE key = 'store_dir'")
|
|
860
|
+
.get()
|
|
861
|
+
if (storeRoot === undefined ? recordedStore != null : storeRoot !== null) {
|
|
862
|
+
for (const t of STORE_TABLES) this.db.exec(`DROP TABLE main.${t}`)
|
|
863
|
+
}
|
|
866
864
|
this.db
|
|
867
|
-
.prepare(
|
|
865
|
+
.prepare(
|
|
866
|
+
"INSERT INTO schema_meta(key, value) VALUES ('version', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
867
|
+
)
|
|
868
868
|
.run(SCHEMA_VERSION)
|
|
869
|
-
return { from: found, to: SCHEMA_VERSION }
|
|
869
|
+
return found === undefined ? null : { from: found, to: SCHEMA_VERSION }
|
|
870
870
|
})
|
|
871
871
|
.immediate()
|
|
872
872
|
} catch (err) {
|
|
@@ -904,8 +904,6 @@ export class Cache implements CacheLayer {
|
|
|
904
904
|
// the entries stay in this workspace rather than fail the run.
|
|
905
905
|
const fallback = cacheDir
|
|
906
906
|
if (fallback !== storeDir) {
|
|
907
|
-
// Said by the open that falls back; the ones after it find it recorded.
|
|
908
|
-
if (recorded !== fallback) this.storeFallback = `${storeDir} (${blocked})`
|
|
909
907
|
storeDir = fallback
|
|
910
908
|
openCacheDir(fallback)
|
|
911
909
|
}
|
|
@@ -922,13 +920,11 @@ export class Cache implements CacheLayer {
|
|
|
922
920
|
.get() != null,
|
|
923
921
|
)
|
|
924
922
|
if (holds) {
|
|
925
|
-
const had = this.db.prepare('SELECT 1 FROM main.entries LIMIT 1').get() != null
|
|
926
923
|
this.db
|
|
927
924
|
.transaction(() => {
|
|
928
925
|
for (const t of STORE_TABLES) this.db.exec(`DROP TABLE IF EXISTS main.${t}`)
|
|
929
926
|
})
|
|
930
927
|
.immediate()
|
|
931
|
-
if (had) this.storeMoved = { from: cacheDir, to: storeDir }
|
|
932
928
|
}
|
|
933
929
|
}
|
|
934
930
|
this.storeDir = storeDir
|
|
@@ -1040,13 +1036,25 @@ export class Cache implements CacheLayer {
|
|
|
1040
1036
|
* Attach the shared store as `store`, with the pragmas the index takes:
|
|
1041
1037
|
* WAL for both, so a workspace's run and another's share the store as
|
|
1042
1038
|
* two runs on one `--cache-dir` share an index. A reading verb over a
|
|
1043
|
-
* store with no file yet reads an empty one in memory.
|
|
1039
|
+
* store with no file or no tables yet reads an empty one in memory.
|
|
1044
1040
|
*/
|
|
1045
1041
|
private attachStore(storeDir: string, inspecting: boolean): void {
|
|
1046
1042
|
const storeFile = path.join(storeDir, 'store.db')
|
|
1047
|
-
|
|
1043
|
+
let absent = inspecting && !existsSync(storeFile)
|
|
1048
1044
|
try {
|
|
1049
1045
|
this.db.prepare('ATTACH DATABASE ? AS store').run(absent ? ':memory:' : storeFile)
|
|
1046
|
+
// One with no tables yet is read empty in memory too: the journal
|
|
1047
|
+
// switch and the table creation below are writes, under no write
|
|
1048
|
+
// lock, racing an opener making that store.
|
|
1049
|
+
if (
|
|
1050
|
+
inspecting &&
|
|
1051
|
+
!absent &&
|
|
1052
|
+
this.db.prepare("SELECT 1 FROM store.sqlite_master WHERE name = 'entries'").get() == null
|
|
1053
|
+
) {
|
|
1054
|
+
this.db.exec('DETACH DATABASE store')
|
|
1055
|
+
this.db.prepare('ATTACH DATABASE ? AS store').run(':memory:')
|
|
1056
|
+
absent = true
|
|
1057
|
+
}
|
|
1050
1058
|
this.db.exec('PRAGMA store.journal_mode = WAL')
|
|
1051
1059
|
if (!absent) this.db.fileControl('store', SQLITE_FCNTL_PERSIST_WAL, 1)
|
|
1052
1060
|
this.db.exec('PRAGMA store.journal_size_limit = 67108864')
|
|
@@ -1750,6 +1758,21 @@ export class Cache implements CacheLayer {
|
|
|
1750
1758
|
return this.packArtifact(args)
|
|
1751
1759
|
}
|
|
1752
1760
|
|
|
1761
|
+
/**
|
|
1762
|
+
* The artifact under a private second name, for a body read more than
|
|
1763
|
+
* once (a digest pass then an upload, a retry). The live name is not
|
|
1764
|
+
* stable: a re-save of the key renames other bytes over it, and a body
|
|
1765
|
+
* opened by path read those mid-upload (a digest of one artifact over
|
|
1766
|
+
* the bytes of another). A `Bun.file` over an fd is no answer — its
|
|
1767
|
+
* second read starts where the first ended. Throws when the artifact is
|
|
1768
|
+
* gone; `release` unlinks the name.
|
|
1769
|
+
*/
|
|
1770
|
+
pinArtifact(hash: string): { body: Blob; release: () => Promise<void> } {
|
|
1771
|
+
const pinned = this.tempPath(hash)
|
|
1772
|
+
linkSync(this.tarPath(hash), pinned)
|
|
1773
|
+
return { body: Bun.file(pinned), release: () => unlink(pinned).catch(() => undefined) }
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1753
1776
|
/**
|
|
1754
1777
|
* The remote body goes to the temp by `Bun.write`, which streams a
|
|
1755
1778
|
* `Response` and copies a file `Blob` without collecting either, so a
|
|
@@ -1769,6 +1792,9 @@ export class Cache implements CacheLayer {
|
|
|
1769
1792
|
hash,
|
|
1770
1793
|
`remote body runs past ${cap} bytes (the artifact ceiling's bound)`,
|
|
1771
1794
|
)
|
|
1795
|
+
// Every refusal releases the remote body: a held response pins its
|
|
1796
|
+
// connection, and a layer that settles on the body's end never hears.
|
|
1797
|
+
let counted: ReadableStream<Uint8Array> | undefined
|
|
1772
1798
|
try {
|
|
1773
1799
|
if (body instanceof Blob) {
|
|
1774
1800
|
if (body.size > cap) throw past()
|
|
@@ -1777,7 +1803,7 @@ export class Cache implements CacheLayer {
|
|
|
1777
1803
|
throw past()
|
|
1778
1804
|
} else {
|
|
1779
1805
|
let n = 0
|
|
1780
|
-
|
|
1806
|
+
counted = body.body?.pipeThrough(
|
|
1781
1807
|
new TransformStream<Uint8Array, Uint8Array>({
|
|
1782
1808
|
transform(chunk, controller) {
|
|
1783
1809
|
n += chunk.byteLength
|
|
@@ -1789,6 +1815,9 @@ export class Cache implements CacheLayer {
|
|
|
1789
1815
|
await Bun.write(tmpPath, new Response(counted ?? null))
|
|
1790
1816
|
}
|
|
1791
1817
|
} catch (err) {
|
|
1818
|
+
// A failed write leaves the pipe's end unlocked; cancelling it
|
|
1819
|
+
// cancels the source. A body refused by its length is cancelled itself.
|
|
1820
|
+
if (!(body instanceof Blob)) void (counted ?? body.body)?.cancel(err).catch(() => undefined)
|
|
1792
1821
|
await unlink(tmpPath).catch(() => undefined)
|
|
1793
1822
|
throw err
|
|
1794
1823
|
}
|
|
@@ -2294,9 +2323,9 @@ export class Cache implements CacheLayer {
|
|
|
2294
2323
|
// Delete DB rows in a single transaction (one fsync; ON DELETE
|
|
2295
2324
|
// CASCADE clears `output_files`) and unlink artifacts in parallel.
|
|
2296
2325
|
// Replaces N round-trips + serialized rm with one transaction + a
|
|
2297
|
-
// Promise.all over the unlinks. The
|
|
2298
|
-
//
|
|
2299
|
-
//
|
|
2326
|
+
// Promise.all over the unlinks. The hash list binds as one `json_each`
|
|
2327
|
+
// parameter, so a huge eviction stays under any build's bound-parameter
|
|
2328
|
+
// ceiling.
|
|
2300
2329
|
if (dryRun) {
|
|
2301
2330
|
return { evicted: victims.size, bytesFreed, orphans: picked.files.length, orphanBytes }
|
|
2302
2331
|
}
|
package/src/cache/git-inputs.ts
CHANGED
|
@@ -87,6 +87,29 @@ export function repoFacts(dir: string): RepoFacts | null {
|
|
|
87
87
|
return facts
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* The refusal a run would meet at its enumeration, asked up front by a
|
|
92
|
+
* verb that waits on the user first (the picker); undefined when git
|
|
93
|
+
* tracks `dir`. Free when it does: `repoFacts` is the answer the run
|
|
94
|
+
* reuses.
|
|
95
|
+
*/
|
|
96
|
+
export function gitRefusal(dir: string): UserError | undefined {
|
|
97
|
+
if (repoFacts(dir) !== null) return undefined
|
|
98
|
+
let proc
|
|
99
|
+
try {
|
|
100
|
+
proc = Bun.spawnSync({
|
|
101
|
+
cmd: [executablePath('git'), 'rev-parse', '--is-inside-work-tree'],
|
|
102
|
+
cwd: dir,
|
|
103
|
+
stdout: 'ignore',
|
|
104
|
+
stderr: 'pipe',
|
|
105
|
+
})
|
|
106
|
+
} catch {
|
|
107
|
+
return gitSpawnRefusal(dir)
|
|
108
|
+
}
|
|
109
|
+
if (proc.exitCode === 0) return undefined
|
|
110
|
+
return notAWorkTree(dir, new TextDecoder().decode(proc.stderr).trim())
|
|
111
|
+
}
|
|
112
|
+
|
|
90
113
|
/** Variables that move where git finds the repository or its index: with any set, git is asked. */
|
|
91
114
|
const GIT_LOCATION_ENV = [
|
|
92
115
|
'GIT_INDEX_FILE',
|
package/src/cache/index.ts
CHANGED
|
@@ -36,6 +36,7 @@ export {
|
|
|
36
36
|
cleanOutputs,
|
|
37
37
|
cleanWorkspaceOutputs,
|
|
38
38
|
declaresInput,
|
|
39
|
+
workspaceFilesReachInto,
|
|
39
40
|
resolveInputs,
|
|
40
41
|
resolveOutputs,
|
|
41
42
|
resolveWorkspaceOutputs,
|
|
@@ -52,6 +53,7 @@ export {
|
|
|
52
53
|
} from './inputs.js'
|
|
53
54
|
export {
|
|
54
55
|
GitFilesCache,
|
|
56
|
+
gitRefusal,
|
|
55
57
|
applyGitEnumeration,
|
|
56
58
|
gitPathspecs,
|
|
57
59
|
MAX_SCOPED_PATHSPECS,
|
package/src/cache/inputs.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// be a git work tree; non-git environments are not supported.
|
|
18
18
|
|
|
19
19
|
import path from 'node:path'
|
|
20
|
-
import { lstatSync, readdirSync, realpathSync, rmdirSync, rmSync } from 'node:fs'
|
|
20
|
+
import { lstatSync, readdirSync, realpathSync, rmdirSync, rmSync, statSync } from 'node:fs'
|
|
21
21
|
import { rm, rmdir } from 'node:fs/promises'
|
|
22
22
|
import type { CacheConfig, CacheInputs } from '../config.js'
|
|
23
23
|
import {
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
slashBraceExpansions,
|
|
34
34
|
splitNegations,
|
|
35
35
|
staticPrefix,
|
|
36
|
+
stripTrailingSlash,
|
|
36
37
|
taskGlob,
|
|
37
38
|
UserError,
|
|
38
39
|
} from '../util/index.js'
|
|
@@ -643,7 +644,11 @@ function outputExcludes(outputs: readonly string[]): Bun.Glob[] {
|
|
|
643
644
|
return (namesNodeModules ? OUTPUT_NEVER : [...OUTPUT_NEVER, '**/node_modules/**']).map(globFor)
|
|
644
645
|
}
|
|
645
646
|
|
|
646
|
-
/**
|
|
647
|
+
/**
|
|
648
|
+
* Resolve declared output globs (project-relative) to actual produced files.
|
|
649
|
+
* An output directory linked out of the project is refused by name (X-88):
|
|
650
|
+
* dropping its files silently saved an empty entry under a green run.
|
|
651
|
+
*/
|
|
647
652
|
export async function resolveOutputs(args: {
|
|
648
653
|
projectDir: string
|
|
649
654
|
outputs: readonly string[]
|
|
@@ -652,9 +657,11 @@ export async function resolveOutputs(args: {
|
|
|
652
657
|
const { positive, negative } = splitNegations(args.outputs)
|
|
653
658
|
if (positive.length === 0) return []
|
|
654
659
|
const excludeGlobs = [...outputExcludes(positive), ...asTrees(negative).map(globFor)]
|
|
660
|
+
const trees = asTrees(positive)
|
|
661
|
+
refuseDirLinkedOut(args.projectDir, trees)
|
|
655
662
|
const scanned = [
|
|
656
663
|
...(await scanUnion(
|
|
657
|
-
|
|
664
|
+
trees,
|
|
658
665
|
excludeGlobs,
|
|
659
666
|
args.projectDir,
|
|
660
667
|
inNestedProject(args.projectDir, args.nestedProjectDirs),
|
|
@@ -670,6 +677,53 @@ export async function resolveOutputs(args: {
|
|
|
670
677
|
return containedIn(args.projectDir, scanned).sort()
|
|
671
678
|
}
|
|
672
679
|
|
|
680
|
+
/**
|
|
681
|
+
* The literal head of each output glob, walked: the scan follows a link
|
|
682
|
+
* there (`dist -> ../elsewhere` for `dist/**`) and only there. A link to a
|
|
683
|
+
* FILE is the archive's to judge (L-23); a dangling one the restore's.
|
|
684
|
+
*/
|
|
685
|
+
function refuseDirLinkedOut(projectDir: string, trees: readonly string[]): void {
|
|
686
|
+
let realRoot: string | undefined
|
|
687
|
+
for (const tree of trees) {
|
|
688
|
+
let at = projectDir
|
|
689
|
+
for (const part of staticPrefix(tree).split('/')) {
|
|
690
|
+
if (part === '' || part === '.') continue
|
|
691
|
+
at = path.join(at, part)
|
|
692
|
+
if (!isInside(projectDir, at)) break
|
|
693
|
+
let st
|
|
694
|
+
try {
|
|
695
|
+
st = lstatSync(at)
|
|
696
|
+
} catch {
|
|
697
|
+
break
|
|
698
|
+
}
|
|
699
|
+
if (!st.isSymbolicLink()) continue
|
|
700
|
+
let real: string
|
|
701
|
+
try {
|
|
702
|
+
real = realpathSync(at)
|
|
703
|
+
if (!statSync(real).isDirectory()) break
|
|
704
|
+
} catch {
|
|
705
|
+
break
|
|
706
|
+
}
|
|
707
|
+
realRoot ??= realOr(projectDir)
|
|
708
|
+
if (!isInside(realRoot, real)) {
|
|
709
|
+
throw new UserError(
|
|
710
|
+
`${at} is a symbolic link to ${real}, outside ${projectDir} — vx never cleans, saves ` +
|
|
711
|
+
'or restores declared outputs through a link that leaves the project. Remove the ' +
|
|
712
|
+
'link and re-run, or stop declaring outputs under it.',
|
|
713
|
+
)
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function realOr(p: string): string {
|
|
720
|
+
try {
|
|
721
|
+
return realpathSync(p)
|
|
722
|
+
} catch {
|
|
723
|
+
return p
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
673
727
|
/**
|
|
674
728
|
* Keep only the paths that are REALLY inside `root` — lexically, and after
|
|
675
729
|
* resolving symlinks.
|
|
@@ -708,16 +762,9 @@ function containedIn(root: string, paths: readonly string[]): string[] {
|
|
|
708
762
|
// Sync, as `hashFile`'s lstat: a realpath is microseconds, and the
|
|
709
763
|
// promise round trip per call was most of this function's cost on every
|
|
710
764
|
// miss (B, 2026-09-30).
|
|
711
|
-
const
|
|
712
|
-
try {
|
|
713
|
-
return realpathSync(p)
|
|
714
|
-
} catch {
|
|
715
|
-
return null
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
const realRoot = real(root) ?? root
|
|
765
|
+
const realRoot = realOrNull(root) ?? root
|
|
719
766
|
const uniqueDirs = [...new Set(lexDirs)]
|
|
720
|
-
const resolved = uniqueDirs.map(
|
|
767
|
+
const resolved = uniqueDirs.map(realOrNull)
|
|
721
768
|
const contained = new Set<string>()
|
|
722
769
|
for (const [i, dir] of uniqueDirs.entries()) {
|
|
723
770
|
const real = resolved[i]
|
|
@@ -899,7 +946,7 @@ const SYNC_CLEAN_MAX = 128
|
|
|
899
946
|
* sends the reader to file a bug against a permission bit.
|
|
900
947
|
*/
|
|
901
948
|
async function removeAll(all: readonly string[], root: string): Promise<string[]> {
|
|
902
|
-
const files =
|
|
949
|
+
const files = insideRoot(all, root)
|
|
903
950
|
const refused = (f: string, err: NodeJS.ErrnoException): UserError => {
|
|
904
951
|
const rel = relPosix(root, f)
|
|
905
952
|
return new UserError(
|
|
@@ -929,21 +976,14 @@ async function removeAll(all: readonly string[], root: string): Promise<string[]
|
|
|
929
976
|
}
|
|
930
977
|
|
|
931
978
|
/**
|
|
932
|
-
* The paths whose directory
|
|
933
|
-
*
|
|
934
|
-
*
|
|
935
|
-
*
|
|
936
|
-
*
|
|
937
|
-
*
|
|
979
|
+
* The paths whose directory resolves inside `root`. A link inside the
|
|
980
|
+
* project is followed, as the save follows it (turborepo#13042): a clean
|
|
981
|
+
* that skipped `dist -> real-out` left the last run's files for the next
|
|
982
|
+
* entry to save (X-88). One that leaves the project is never deleted
|
|
983
|
+
* through (X-5); `cleanOutputPaths` takes recorded rows, not the resolver's
|
|
984
|
+
* contained set, so the check is here too.
|
|
938
985
|
*/
|
|
939
|
-
function
|
|
940
|
-
const real = (p: string): string | null => {
|
|
941
|
-
try {
|
|
942
|
-
return realpathSync(p)
|
|
943
|
-
} catch {
|
|
944
|
-
return null
|
|
945
|
-
}
|
|
946
|
-
}
|
|
986
|
+
function insideRoot(files: readonly string[], root: string): string[] {
|
|
947
987
|
// Resolved only once a directory exists to compare: after a clean pruned
|
|
948
988
|
// the outputs (the common restore) nothing does, and the call was most
|
|
949
989
|
// of an empty clean.
|
|
@@ -955,9 +995,8 @@ function notThroughLink(files: readonly string[], root: string): string[] {
|
|
|
955
995
|
if (ok === undefined) {
|
|
956
996
|
// A directory already gone has nothing to delete through; its path
|
|
957
997
|
// stays so the prune still reaches the parents it emptied.
|
|
958
|
-
const r =
|
|
959
|
-
ok =
|
|
960
|
-
r === null || r === path.join((realRoot ??= real(root) ?? root), path.relative(root, dir))
|
|
998
|
+
const r = realOrNull(dir)
|
|
999
|
+
ok = r === null || isInside((realRoot ??= realOrNull(root) ?? root), r)
|
|
961
1000
|
own.set(dir, ok)
|
|
962
1001
|
}
|
|
963
1002
|
return ok
|
|
@@ -1101,8 +1140,12 @@ export async function cleanWorkspaceOutputs(args: {
|
|
|
1101
1140
|
return files.map((f) => relPosix(args.workspaceRoot, f))
|
|
1102
1141
|
}
|
|
1103
1142
|
|
|
1104
|
-
function
|
|
1105
|
-
|
|
1143
|
+
function realOrNull(p: string): string | null {
|
|
1144
|
+
try {
|
|
1145
|
+
return realpathSync(p)
|
|
1146
|
+
} catch {
|
|
1147
|
+
return null
|
|
1148
|
+
}
|
|
1106
1149
|
}
|
|
1107
1150
|
|
|
1108
1151
|
// The literal-is-a-tree rule moved to `util/paths.ts` (item 442): it
|
|
@@ -1356,6 +1399,18 @@ export function declaresInput(
|
|
|
1356
1399
|
return matcher(workspaceRel)
|
|
1357
1400
|
}
|
|
1358
1401
|
|
|
1402
|
+
/**
|
|
1403
|
+
* Whether a cached task's `workspaceFiles` may name a file under `dir`
|
|
1404
|
+
* (workspace-relative): a nested repository git reports as that one path,
|
|
1405
|
+
* whose files the key folds and a match against the path alone misses.
|
|
1406
|
+
* By the entries' static prefixes, so a glob that may descend counts.
|
|
1407
|
+
*/
|
|
1408
|
+
export function workspaceFilesReachInto(cache: CacheConfig, dir: string): boolean {
|
|
1409
|
+
const ws = cache.inputs.workspaceFiles
|
|
1410
|
+
if (ws === undefined || ws.length === 0) return false
|
|
1411
|
+
return reaches(reachOf(splitNegations(ws).positive).prefixes, dir)
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1359
1414
|
function inPlan(plan: FilesPlan, rel: string): boolean {
|
|
1360
1415
|
let input = plan.verdicts.get(rel)
|
|
1361
1416
|
if (input === undefined) {
|
package/src/cache/key-fold.ts
CHANGED
|
@@ -103,7 +103,7 @@ import type { CacheKeyInput } from './layer.js'
|
|
|
103
103
|
// bytes on disk, the filter since removed, holds the CRLF build (A-60).
|
|
104
104
|
// v39: an entry saved while a gitlink without `.git` held files the key
|
|
105
105
|
// never listed sits under the key of that directory empty (A-61).
|
|
106
|
-
export const CACHE_VERSION = 'vx-cache-
|
|
106
|
+
export const CACHE_VERSION = 'vx-cache-v41'
|
|
107
107
|
|
|
108
108
|
/**
|
|
109
109
|
* The digest a key folds for an input gone between its enumeration and its
|
package/src/cache/layer.ts
CHANGED
|
@@ -332,7 +332,8 @@ export interface PruneResult {
|
|
|
332
332
|
* discriminator — least-invasive row format, no schema change. A
|
|
333
333
|
* project output dir literally named `workspace-outputs/` would
|
|
334
334
|
* collide with the namespace; the name is reserved, and a project
|
|
335
|
-
* output glob under it is refused at load (config-schema.ts)
|
|
335
|
+
* output glob under it is refused at load (config-schema.ts), a file
|
|
336
|
+
* another glob reaches there at save (`planArtifact`).
|
|
336
337
|
*/
|
|
337
338
|
export const WORKSPACE_OUTPUT_PREFIX = 'workspace-outputs/'
|
|
338
339
|
|