@vzn/vx 0.0.506 → 0.0.507
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/package.json +5 -5
- package/src/cache/archive.ts +32 -7
- package/src/cache/cache.ts +30 -13
- package/src/cache/zstd.ts +23 -3
- package/src/exec/kill-tree.ts +2 -2
- package/src/exec/sandbox-runtime.ts +11 -10
- package/src/orchestrator/deferred-outputs.ts +4 -0
- package/src/orchestrator/execute-task.ts +3 -0
- package/src/orchestrator/task-hash.ts +18 -10
- package/src/util/index.ts +1 -1
- package/src/util/which.ts +23 -1
- package/src/workspace/project-loader.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.507",
|
|
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.507",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.507",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.507",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.507"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cache/archive.ts
CHANGED
|
@@ -37,12 +37,21 @@
|
|
|
37
37
|
// skipped — vx's outputs are regular files, and an artifact that claims
|
|
38
38
|
// otherwise silently loses the claim rather than acting on it.
|
|
39
39
|
|
|
40
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
chmodSync,
|
|
42
|
+
lstatSync,
|
|
43
|
+
readFileSync,
|
|
44
|
+
realpathSync,
|
|
45
|
+
renameSync,
|
|
46
|
+
statSync,
|
|
47
|
+
utimesSync,
|
|
48
|
+
} from 'node:fs'
|
|
41
49
|
import { lstat, mkdir, readlink, realpath, rmdir, stat, unlink, writeFile } from 'node:fs/promises'
|
|
42
50
|
import path from 'node:path'
|
|
43
51
|
import { UserError } from '../util/index.js'
|
|
44
52
|
import { WORKSPACE_OUTPUT_PREFIX } from './layer.js'
|
|
45
53
|
import { TarFormatError, type TarInput, tarEntries, tarPack, tarSize } from './tar-stream.js'
|
|
54
|
+
import { ON_THREAD_MAX } from './zstd.js'
|
|
46
55
|
|
|
47
56
|
/** Archive entry name carrying the per-output mode/mtime sidecar. */
|
|
48
57
|
const META_ENTRY = '.vx-meta.json'
|
|
@@ -200,6 +209,9 @@ export interface PackArgs {
|
|
|
200
209
|
within?: string
|
|
201
210
|
}
|
|
202
211
|
|
|
212
|
+
/** At or below this many outputs, `planArtifact` stats them on the calling thread. */
|
|
213
|
+
const ON_THREAD_STATS = 32
|
|
214
|
+
|
|
203
215
|
/** A packed artifact's plan: its entries with their stats, and the tar's exact size. */
|
|
204
216
|
export interface ArtifactPlan {
|
|
205
217
|
inputs: TarInput[]
|
|
@@ -216,6 +228,12 @@ export interface ArtifactPlan {
|
|
|
216
228
|
*/
|
|
217
229
|
export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
|
|
218
230
|
const meta: MetaFile = { version: 1, files: {} }
|
|
231
|
+
// A few outputs are stat'ed on this thread: a pool round trip per stat
|
|
232
|
+
// cost more than the stat. Many go to the pool together, as before.
|
|
233
|
+
const few = args.outputs.size <= ON_THREAD_STATS
|
|
234
|
+
const lstatOf = few ? async (p: string) => lstatSync(p) : lstat
|
|
235
|
+
const statOf = few ? async (p: string) => statSync(p) : stat
|
|
236
|
+
const realpathOf = few ? async (p: string) => realpathSync(p) : realpath
|
|
219
237
|
let withinReal: Promise<string> | undefined
|
|
220
238
|
// Where this artifact's own non-link outputs really are: a link to one of
|
|
221
239
|
// them packs bytes the task wrote itself. A root-anchored output tree is
|
|
@@ -225,7 +243,7 @@ export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
|
|
|
225
243
|
const ownOutputs = (): Promise<Set<string>> =>
|
|
226
244
|
(ownReal ??= Promise.all(
|
|
227
245
|
[...args.outputs.values()].map(async (abs) =>
|
|
228
|
-
(await
|
|
246
|
+
(await lstatOf(abs)).isSymbolicLink() ? null : realpathOf(abs),
|
|
229
247
|
),
|
|
230
248
|
).then((all) => new Set(all.filter((p): p is string => p !== null))))
|
|
231
249
|
if (args.key !== undefined) meta.key = args.key
|
|
@@ -255,11 +273,11 @@ export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
|
|
|
255
273
|
// lstat, so a regular file costs no second call; only a link is
|
|
256
274
|
// resolved, and its body is read from the path this check resolved.
|
|
257
275
|
let src = abs
|
|
258
|
-
let st = await
|
|
276
|
+
let st = await lstatOf(abs)
|
|
259
277
|
if (st.isSymbolicLink()) {
|
|
260
|
-
src = await
|
|
278
|
+
src = await realpathOf(abs).catch(dangling)
|
|
261
279
|
if (args.within !== undefined) {
|
|
262
|
-
withinReal ??=
|
|
280
|
+
withinReal ??= realpathOf(args.within)
|
|
263
281
|
const root = await withinReal
|
|
264
282
|
if (!src.startsWith(root + path.sep) && !(await ownOutputs()).has(src)) {
|
|
265
283
|
throw new UserError(
|
|
@@ -267,7 +285,7 @@ export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
|
|
|
267
285
|
)
|
|
268
286
|
}
|
|
269
287
|
}
|
|
270
|
-
st = await
|
|
288
|
+
st = await statOf(src).catch(dangling)
|
|
271
289
|
}
|
|
272
290
|
if (!st.isFile()) {
|
|
273
291
|
throw new UserError(
|
|
@@ -340,9 +358,16 @@ async function* summed(inputs: readonly TarInput[]): AsyncGenerator<TarInput> {
|
|
|
340
358
|
* otherwise (measured 2026-09-03: 238 → 293 ms per 1 000 saves).
|
|
341
359
|
*/
|
|
342
360
|
export async function packArtifactBytes(plan: ArtifactPlan): Promise<Uint8Array> {
|
|
361
|
+
// A small artifact's files are read on this thread (`ON_THREAD_MAX`).
|
|
362
|
+
const onThread = plan.size <= ON_THREAD_MAX
|
|
343
363
|
const inputs = await Promise.all(
|
|
344
364
|
plan.inputs.map(async (i) =>
|
|
345
|
-
i.body instanceof Blob
|
|
365
|
+
i.body instanceof Blob
|
|
366
|
+
? {
|
|
367
|
+
...i,
|
|
368
|
+
body: onThread ? readFileSync((i.body as Bun.BunFile).name!) : await i.body.bytes(),
|
|
369
|
+
}
|
|
370
|
+
: i,
|
|
346
371
|
),
|
|
347
372
|
)
|
|
348
373
|
const out = new Uint8Array(plan.size)
|
package/src/cache/cache.ts
CHANGED
|
@@ -88,7 +88,9 @@ import {
|
|
|
88
88
|
bytesOf,
|
|
89
89
|
decodedTar,
|
|
90
90
|
MAX_DECOMPRESSED_ARTIFACT_BYTES,
|
|
91
|
+
oneChunk,
|
|
91
92
|
STREAM_DECODE_FROM,
|
|
93
|
+
zstdCompress,
|
|
92
94
|
zstdEncoder,
|
|
93
95
|
} from './zstd.js'
|
|
94
96
|
import { ConfigEvalTable } from './config-evals.js'
|
|
@@ -1296,7 +1298,7 @@ export class Cache implements CacheLayer {
|
|
|
1296
1298
|
// again in `writeArtifactAndIndex` cost every save two thread-pool
|
|
1297
1299
|
// round trips for an EEXIST and a stat (item 630).
|
|
1298
1300
|
const endPack = span('save: pack')
|
|
1299
|
-
const
|
|
1301
|
+
const packed = await this.packArtifactToTemp(this.tempPath(args.hash), args).catch(
|
|
1300
1302
|
(err: unknown) => {
|
|
1301
1303
|
// A save that could not open an output names the limit, not only
|
|
1302
1304
|
// the file, which is fine (A-39).
|
|
@@ -1310,12 +1312,17 @@ export class Cache implements CacheLayer {
|
|
|
1310
1312
|
)
|
|
1311
1313
|
endPack()
|
|
1312
1314
|
await this.guard(() =>
|
|
1313
|
-
this.writeArtifactAndIndex(
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1315
|
+
this.writeArtifactAndIndex(
|
|
1316
|
+
args.hash,
|
|
1317
|
+
'tar' in packed ? packed.compressed : packed,
|
|
1318
|
+
{
|
|
1319
|
+
taskId: args.entry.taskId,
|
|
1320
|
+
command: args.entry.command,
|
|
1321
|
+
durationMs: args.entry.durationMs,
|
|
1322
|
+
...(args.inputComponents !== undefined ? { inputComponents: args.inputComponents } : {}),
|
|
1323
|
+
},
|
|
1324
|
+
'tar' in packed ? packed.tar : undefined,
|
|
1325
|
+
),
|
|
1319
1326
|
)
|
|
1320
1327
|
}
|
|
1321
1328
|
|
|
@@ -1425,8 +1432,7 @@ export class Cache implements CacheLayer {
|
|
|
1425
1432
|
// layout is predictable: a successful read finds `stdout` and
|
|
1426
1433
|
// zero-or-more `outputs/<rel>` / `workspace-outputs/<rel>` entries.
|
|
1427
1434
|
const plan = await this.planWithin(args)
|
|
1428
|
-
if (plan.size <= STREAM_DECODE_FROM)
|
|
1429
|
-
return await Bun.zstdCompress(await packArtifactBytes(plan))
|
|
1435
|
+
if (plan.size <= STREAM_DECODE_FROM) return await zstdCompress(await packArtifactBytes(plan))
|
|
1430
1436
|
return bytesOf(packArtifactStream(plan).pipeThrough(zstdEncoder()))
|
|
1431
1437
|
}
|
|
1432
1438
|
|
|
@@ -1466,10 +1472,12 @@ export class Cache implements CacheLayer {
|
|
|
1466
1472
|
private async packArtifactToTemp(
|
|
1467
1473
|
tmpPath: string,
|
|
1468
1474
|
args: Parameters<Cache['packArtifact']>[0],
|
|
1469
|
-
): Promise<Uint8Array | { tmpPath: string }> {
|
|
1475
|
+
): Promise<{ compressed: Uint8Array; tar: Uint8Array } | { tmpPath: string }> {
|
|
1470
1476
|
const plan = await this.planWithin(args)
|
|
1471
|
-
if (plan.size <= STREAM_DECODE_FROM)
|
|
1472
|
-
|
|
1477
|
+
if (plan.size <= STREAM_DECODE_FROM) {
|
|
1478
|
+
const tar = await packArtifactBytes(plan)
|
|
1479
|
+
return { compressed: await zstdCompress(tar), tar }
|
|
1480
|
+
}
|
|
1473
1481
|
const sink = Bun.file(tmpPath).writer()
|
|
1474
1482
|
try {
|
|
1475
1483
|
for await (const chunk of packArtifactStream(plan).pipeThrough(zstdEncoder())) {
|
|
@@ -1505,6 +1513,11 @@ export class Cache implements CacheLayer {
|
|
|
1505
1513
|
hash: string,
|
|
1506
1514
|
compressed: Uint8Array | { tmpPath: string },
|
|
1507
1515
|
meta: IngestMeta,
|
|
1516
|
+
/**
|
|
1517
|
+
* The tar `compressed` was made from, when this process packed it: it is
|
|
1518
|
+
* scanned as is rather than decoded back out of the bytes just encoded.
|
|
1519
|
+
*/
|
|
1520
|
+
tar?: Uint8Array,
|
|
1508
1521
|
): Promise<void> {
|
|
1509
1522
|
// Validate BEFORE anything touches the final path. `ingest()` feeds
|
|
1510
1523
|
// us a temp of network bytes; a truncated/garbage body that went live first
|
|
@@ -1527,6 +1540,8 @@ export class Cache implements CacheLayer {
|
|
|
1527
1540
|
const endWrite = span('save: write temp')
|
|
1528
1541
|
// A write that fails part-way (a full disk) leaves the temp behind,
|
|
1529
1542
|
// and a fresh one is an orphan only an hour on (A-14).
|
|
1543
|
+
// Off this thread even when small: a file's creation on the main
|
|
1544
|
+
// thread cost more CPU and wall than the round trip (A/B, 2026-10-04).
|
|
1530
1545
|
await writeFile(tmpPath, compressed).catch(async (err: unknown) => {
|
|
1531
1546
|
await unlink(tmpPath).catch(() => undefined)
|
|
1532
1547
|
throw err
|
|
@@ -1546,7 +1561,9 @@ export class Cache implements CacheLayer {
|
|
|
1546
1561
|
? compressed
|
|
1547
1562
|
: Bun.file(tmpPath)
|
|
1548
1563
|
const endScan = span('save: scan')
|
|
1549
|
-
scanned = await scanArtifact(
|
|
1564
|
+
scanned = await scanArtifact(
|
|
1565
|
+
tar !== undefined ? oneChunk(tar) : await decodedTar(source, hash, this.artifactCeiling),
|
|
1566
|
+
)
|
|
1550
1567
|
endScan()
|
|
1551
1568
|
// v17 invariant: every artifact carries a `stdout` entry. Its
|
|
1552
1569
|
// absence means the bytes decompressed but aren't a vx artifact.
|
package/src/cache/zstd.ts
CHANGED
|
@@ -92,8 +92,11 @@ async function zstdDecompressBounded(
|
|
|
92
92
|
hash: string,
|
|
93
93
|
cap: number = MAX_DECOMPRESSED_ARTIFACT_BYTES,
|
|
94
94
|
): Promise<Uint8Array> {
|
|
95
|
-
assertDeclaredSize(compressed, hash, cap)
|
|
96
|
-
const out =
|
|
95
|
+
const declared = assertDeclaredSize(compressed, hash, cap)
|
|
96
|
+
const out =
|
|
97
|
+
declared !== null && declared <= BigInt(ON_THREAD_MAX)
|
|
98
|
+
? Bun.zstdDecompressSync(compressed)
|
|
99
|
+
: await Bun.zstdDecompress(compressed)
|
|
97
100
|
// UNREACHABLE as written, and kept deliberately. `assertDeclaredSize`
|
|
98
101
|
// above refuses any frame DECLARING more than the cap, Bun refuses a
|
|
99
102
|
// frame whose declaration disagrees with its body ("Decompression
|
|
@@ -121,6 +124,22 @@ function assertDeclaredSize(
|
|
|
121
124
|
return declared
|
|
122
125
|
}
|
|
123
126
|
|
|
127
|
+
/**
|
|
128
|
+
* At or below this many bytes an artifact's one-call codec and file I/O
|
|
129
|
+
* run on the calling thread. Each async call is a thread-pool round trip
|
|
130
|
+
* (a futex wake, an eventfd write, an epoll turn) that cost more CPU than
|
|
131
|
+
* the work itself for a one-file artifact, several times per save and
|
|
132
|
+
* per restore.
|
|
133
|
+
*/
|
|
134
|
+
export const ON_THREAD_MAX = 256 * 1024
|
|
135
|
+
|
|
136
|
+
/** `Bun.zstdCompress`, on this thread when the input is small (`ON_THREAD_MAX`). */
|
|
137
|
+
export async function zstdCompress(bytes: Uint8Array): Promise<Uint8Array> {
|
|
138
|
+
return bytes.byteLength <= ON_THREAD_MAX
|
|
139
|
+
? Bun.zstdCompressSync(bytes)
|
|
140
|
+
: await Bun.zstdCompress(bytes)
|
|
141
|
+
}
|
|
142
|
+
|
|
124
143
|
/** Compressed artifacts above this size are decoded as a stream, on restore and on ingest. */
|
|
125
144
|
export const STREAM_DECODE_FROM = 4 * 1024 * 1024
|
|
126
145
|
|
|
@@ -137,7 +156,8 @@ export async function bytesOf(stream: ReadableStream<Uint8Array>): Promise<Uint8
|
|
|
137
156
|
export const zstdEncoder = (): TransformStream<Uint8Array, Uint8Array> =>
|
|
138
157
|
new CompressionStream('zstd') as unknown as TransformStream<Uint8Array, Uint8Array>
|
|
139
158
|
|
|
140
|
-
|
|
159
|
+
/** Bytes in memory as a one-chunk stream, for a reader that takes a stream. */
|
|
160
|
+
export const oneChunk = (bytes: Uint8Array): ReadableStream<Uint8Array> =>
|
|
141
161
|
new ReadableStream({
|
|
142
162
|
start(c) {
|
|
143
163
|
c.enqueue(bytes)
|
package/src/exec/kill-tree.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// takes even that.
|
|
12
12
|
|
|
13
13
|
import { closeSync, readdirSync, readFileSync, writeSync } from 'node:fs'
|
|
14
|
-
import {
|
|
14
|
+
import { procfsIsOwn, taskShell } from '../util/index.js'
|
|
15
15
|
|
|
16
16
|
export type Child = ReturnType<typeof Bun.spawn>
|
|
17
17
|
|
|
@@ -79,7 +79,7 @@ function startGuard(): void {
|
|
|
79
79
|
if (guardFd !== undefined) return
|
|
80
80
|
guardFd = null
|
|
81
81
|
try {
|
|
82
|
-
const guard = Bun.spawn([
|
|
82
|
+
const guard = Bun.spawn([taskShell(), '-c', GUARD_SCRIPT], {
|
|
83
83
|
argv0: 'vx-group-guard',
|
|
84
84
|
stdio: ['ignore', 'ignore', 'ignore', 'pipe'],
|
|
85
85
|
// Out of vx's group, so a Ctrl-C at the terminal leaves it to the
|
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
import {
|
|
61
61
|
BUN_GLOB_WILDCARDS,
|
|
62
62
|
executablePath,
|
|
63
|
+
taskShell,
|
|
63
64
|
grantPrefix,
|
|
64
65
|
isTmpdirRefusal,
|
|
65
66
|
procfsIsOwn,
|
|
@@ -236,7 +237,7 @@ async function trySandboxedTrue(
|
|
|
236
237
|
undefined,
|
|
237
238
|
weakerNested ? { enableWeakerNestedSandbox: true } : undefined,
|
|
238
239
|
)
|
|
239
|
-
const proc = Bun.spawn([
|
|
240
|
+
const proc = Bun.spawn([taskShell(), '-c', wrapped], {
|
|
240
241
|
argv0: 'sh',
|
|
241
242
|
stdout: 'ignore',
|
|
242
243
|
stderr: 'pipe',
|
|
@@ -1299,7 +1300,7 @@ function ownGroupCommand(
|
|
|
1299
1300
|
// `/bin/sh` is dash (item 964).
|
|
1300
1301
|
let sh: string
|
|
1301
1302
|
try {
|
|
1302
|
-
sh =
|
|
1303
|
+
sh = taskShell()
|
|
1303
1304
|
} catch {
|
|
1304
1305
|
return { command: `: 'vx-${tag}'; ${userCommand}`, forwards: false, traced: false }
|
|
1305
1306
|
}
|
|
@@ -1433,15 +1434,19 @@ function wrapForTask(
|
|
|
1433
1434
|
sockets: boolean,
|
|
1434
1435
|
gitConfig: boolean,
|
|
1435
1436
|
): Promise<string> {
|
|
1437
|
+
// macOS: the runtime runs the command under bash unless told, and an
|
|
1438
|
+
// unsandboxed task runs under `taskShell()` (dash). Linux needs no word:
|
|
1439
|
+
// `ownGroupCommand` already puts the task shell inside.
|
|
1440
|
+
const shell = process.platform === 'darwin' ? taskShell() : undefined
|
|
1436
1441
|
const run = perTaskRun
|
|
1437
|
-
if (run === undefined) return SandboxManager.wrapWithSandbox(command,
|
|
1442
|
+
if (run === undefined) return SandboxManager.wrapWithSandbox(command, shell, customConfig)
|
|
1438
1443
|
const turn = wrapTurn.then(() => {
|
|
1439
1444
|
SandboxManager.updateConfig({
|
|
1440
1445
|
...run,
|
|
1441
1446
|
network: { ...run.network, allowAllUnixSockets: sockets },
|
|
1442
1447
|
filesystem: { ...run.filesystem, allowGitConfig: gitConfig },
|
|
1443
1448
|
})
|
|
1444
|
-
return SandboxManager.wrapWithSandbox(command,
|
|
1449
|
+
return SandboxManager.wrapWithSandbox(command, shell, customConfig)
|
|
1445
1450
|
})
|
|
1446
1451
|
wrapTurn = turn.catch(() => undefined)
|
|
1447
1452
|
return turn
|
|
@@ -1588,11 +1593,7 @@ function spawnHostBridges(ports: readonly number[], tag: string): void {
|
|
|
1588
1593
|
detached: true,
|
|
1589
1594
|
})
|
|
1590
1595
|
: Bun.spawn(
|
|
1591
|
-
[
|
|
1592
|
-
executablePath('sh'),
|
|
1593
|
-
'-c',
|
|
1594
|
-
`${guardLine(3)}exec ${argv.map(shellQuote).join(' ')}`,
|
|
1595
|
-
],
|
|
1596
|
+
[taskShell(), '-c', `${guardLine(3)}exec ${argv.map(shellQuote).join(' ')}`],
|
|
1596
1597
|
{ stdio: ['ignore', 'ignore', 'ignore', guard], detached: true },
|
|
1597
1598
|
),
|
|
1598
1599
|
),
|
|
@@ -1793,7 +1794,7 @@ async function runSandboxedOnce(
|
|
|
1793
1794
|
try {
|
|
1794
1795
|
// Resolved on vx's own PATH (util/which.ts), not the task's, where a
|
|
1795
1796
|
// project's node_modules/.bin comes first.
|
|
1796
|
-
const sh =
|
|
1797
|
+
const sh = taskShell()
|
|
1797
1798
|
if (straceLog) traceFd = openSync(straceLog, 'w')
|
|
1798
1799
|
// Descriptors: 3 the signal channel the in-sandbox watcher reads, 4 the
|
|
1799
1800
|
// guard's pipe (kill-tree.ts), TRACE_FD the trace log; a gap is 'ignore'.
|
|
@@ -71,6 +71,10 @@ export class DeferredOutputs {
|
|
|
71
71
|
* this throws and the caller fails the task naming the producer.
|
|
72
72
|
*/
|
|
73
73
|
async materializeFor(node: TaskNode): Promise<void> {
|
|
74
|
+
// Nothing deferred, nothing to find: the closure walk is the size of
|
|
75
|
+
// the graph, and every task of a run without `--download=none` paid it
|
|
76
|
+
// (1.4 s of a 3,270-task cold run's main thread).
|
|
77
|
+
if (this.entries.size === 0) return
|
|
74
78
|
const needed: string[] = []
|
|
75
79
|
const seen = new Set<string>()
|
|
76
80
|
// Pre-order on an explicit stack of dependency lists: a closure is as
|
|
@@ -801,6 +801,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
801
801
|
if (c.kind === 'env' && named.includes(c.name) && !secretNamed(c.name))
|
|
802
802
|
c.hash = MASKED + c.hash
|
|
803
803
|
const inputs: TaskInputs | undefined = described?.inputs
|
|
804
|
+
// A plugin may keep the request past the run (the cache closed): it gets
|
|
805
|
+
// the upstream outputs read now. Only the local floor leaves them unread.
|
|
806
|
+
if (inputs !== undefined && !isLocalExecutor(args.executor)) void inputs.upstream
|
|
804
807
|
// A declared input set that resolves to NOTHING is the quiet stale hit:
|
|
805
808
|
// the key stops moving with this project's source and every later run
|
|
806
809
|
// is a hit. Almost always a glob written against the wrong directory
|
|
@@ -173,15 +173,21 @@ export async function describeTaskInputs(
|
|
|
173
173
|
)
|
|
174
174
|
// Output lists come from the local index — one SELECT for every upstream
|
|
175
175
|
// entry. An upstream with no entry (non-cacheable) contributes an empty list.
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
176
|
+
// Read on first access: the local floor never reads it, and its every
|
|
177
|
+
// miss paid a query over the whole closure (execute-task reads it up
|
|
178
|
+
// front for a plugin executor).
|
|
179
|
+
const listUpstream = (): TaskInputs['upstream'] => {
|
|
180
|
+
const rows = args.cache.loadOutputFilesBatch(graft.map((g) => g.hash))
|
|
181
|
+
return graft.map((g) => {
|
|
182
|
+
const outputs = (rows.get(g.hash) ?? []).map((r) =>
|
|
183
|
+
r.path.startsWith(WORKSPACE_OUTPUT_PREFIX)
|
|
184
|
+
? r.path.slice(WORKSPACE_OUTPUT_PREFIX.length)
|
|
185
|
+
: relPosix(input.workspaceRoot, path.join(g.projectDir, r.path)),
|
|
186
|
+
)
|
|
187
|
+
return { taskId: g.taskId, hash: g.hash, outputs }
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
let upstream: TaskInputs['upstream'] | undefined
|
|
185
191
|
return {
|
|
186
192
|
hash,
|
|
187
193
|
facts,
|
|
@@ -194,7 +200,9 @@ export async function describeTaskInputs(
|
|
|
194
200
|
command,
|
|
195
201
|
output,
|
|
196
202
|
})),
|
|
197
|
-
upstream
|
|
203
|
+
get upstream() {
|
|
204
|
+
return (upstream ??= listUpstream())
|
|
205
|
+
},
|
|
198
206
|
packageJsonDigest: input.projectPackageJsonHash,
|
|
199
207
|
configDigest: input.taskConfigHash,
|
|
200
208
|
workspaceFingerprint: input.workspaceFingerprint,
|
package/src/util/index.ts
CHANGED
|
@@ -52,7 +52,7 @@ export { splitTaskId } from './task-id.js'
|
|
|
52
52
|
export { editDistance, listed, nearMatches, nearest } from './edit-distance.js'
|
|
53
53
|
export { CORE_VERBS, MOVED_VERBS } from './verbs.js'
|
|
54
54
|
export { isUnsupportedBun, MIN_BUN, unsupportedBunMessage } from './bun-version.js'
|
|
55
|
-
export { executablePath, shellArgv } from './which.js'
|
|
55
|
+
export { executablePath, shellArgv, taskShell } from './which.js'
|
|
56
56
|
export { procfsIsOwn } from './procfs.js'
|
|
57
57
|
export { realPath } from './real-path.js'
|
|
58
58
|
export {
|
package/src/util/which.ts
CHANGED
|
@@ -47,9 +47,31 @@ export function executablePath(name: string): string {
|
|
|
47
47
|
return at
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
let dashMissedOn: string | undefined
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The shell vx runs commands in: `dash` on macOS when vx's PATH has it,
|
|
54
|
+
* else `sh` on vx's PATH. macOS's `/bin/sh` is a stub that execs bash, two
|
|
55
|
+
* images for one shell, and bash where Linux runs dash, so a command read
|
|
56
|
+
* one way on a Mac and another on Linux CI (owner, 2026-10-04).
|
|
57
|
+
*/
|
|
58
|
+
export function taskShell(): string {
|
|
59
|
+
if (process.platform === 'darwin') {
|
|
60
|
+
const PATH = process.env['PATH'] ?? ''
|
|
61
|
+
if (dashMissedOn !== PATH) {
|
|
62
|
+
try {
|
|
63
|
+
return executablePath('dash')
|
|
64
|
+
} catch {
|
|
65
|
+
dashMissedOn = PATH
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return executablePath('sh')
|
|
70
|
+
}
|
|
71
|
+
|
|
50
72
|
/**
|
|
51
73
|
* The argv that runs `script` in the task shell: `sh -c`.
|
|
52
74
|
*/
|
|
53
75
|
export function shellArgv(script: string): string[] {
|
|
54
|
-
return [
|
|
76
|
+
return [taskShell(), '-c', script]
|
|
55
77
|
}
|