@vzn/vx 0.0.50 → 0.0.51
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 +38 -31
- package/package.json +5 -5
- package/src/bin.ts +1 -1
- package/src/cache/archive.ts +18 -6
- package/src/cache/cache.ts +150 -67
- package/src/cache/chained-cache.ts +9 -2
- package/src/cache/file-hashes.ts +32 -3
- package/src/cache/git-inputs.ts +96 -11
- package/src/cache/index.ts +1 -0
- package/src/cache/layer.ts +28 -3
- package/src/cache/layered-cache.ts +7 -2
- package/src/cache/output-index.ts +16 -7
- package/src/cache/run-history.ts +5 -1
- package/src/cache/zstd.ts +40 -8
- package/src/cli/cache.ts +35 -9
- package/src/cli/completions.ts +10 -7
- package/src/cli/help.ts +48 -4
- package/src/cli/index.ts +5 -3
- package/src/cli/info.ts +1 -1
- package/src/cli/plugin-commands.ts +10 -2
- package/src/cli/run.ts +1 -0
- package/src/cli/select.ts +29 -2
- package/src/cli/upgrade.ts +110 -22
- package/src/cli/watch.ts +35 -18
- package/src/config.ts +18 -5
- package/src/exec/executor.ts +3 -2
- package/src/exec/index.ts +1 -1
- package/src/exec/runner.ts +102 -24
- package/src/exec/sandbox-binds.ts +1 -1
- package/src/exec/sandbox-runtime.ts +149 -40
- package/src/exec/sandbox-violations.ts +42 -10
- package/src/graph/scheduler.ts +11 -5
- package/src/graph/task-graph.ts +96 -12
- package/src/orchestrator/admission.ts +72 -17
- package/src/orchestrator/execute-task.ts +39 -25
- package/src/orchestrator/hit-restore.ts +7 -1
- package/src/orchestrator/miss-save.ts +29 -1
- package/src/orchestrator/placement.ts +23 -3
- package/src/orchestrator/plugin-host.ts +1 -1
- package/src/orchestrator/plugin.ts +9 -1
- package/src/orchestrator/prepare.ts +1 -0
- package/src/orchestrator/run.ts +75 -12
- package/src/orchestrator/sandbox-request.ts +19 -10
- package/src/orchestrator/summary.ts +26 -15
- package/src/orchestrator/task-hash.ts +7 -2
- package/src/orchestrator/telemetry.ts +14 -4
- package/src/util/paths.ts +16 -9
- package/src/workspace/affected.ts +201 -38
- package/src/workspace/config-eval.ts +4 -1
- package/src/workspace/config-schema.ts +70 -10
- package/src/workspace/fingerprint.ts +6 -0
- package/src/workspace/migration.ts +1 -1
- package/src/workspace/project-loader.ts +53 -1
- package/src/workspace/workspace.ts +54 -14
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
drainOrAbort,
|
|
34
34
|
POST_EXIT_CUT_LINE,
|
|
35
35
|
shellQuote,
|
|
36
|
+
withForwardArgs,
|
|
36
37
|
signalExitCode,
|
|
37
38
|
spawnFailureText,
|
|
38
39
|
streamToString,
|
|
@@ -41,6 +42,7 @@ import {
|
|
|
41
42
|
type RunResult,
|
|
42
43
|
} from './runner.js'
|
|
43
44
|
import {
|
|
45
|
+
BUN_GLOB_WILDCARDS,
|
|
44
46
|
executablePath,
|
|
45
47
|
grantPrefix,
|
|
46
48
|
isTmpdirRefusal,
|
|
@@ -48,7 +50,7 @@ import {
|
|
|
48
50
|
UserError,
|
|
49
51
|
xxh3hex,
|
|
50
52
|
} from '../util/index.js'
|
|
51
|
-
import { buildCustomConfig } from './sandbox-binds.js'
|
|
53
|
+
import { bindableWrites, buildCustomConfig } from './sandbox-binds.js'
|
|
52
54
|
import {
|
|
53
55
|
isMountableLiteral,
|
|
54
56
|
localBindingOn,
|
|
@@ -56,7 +58,7 @@ import {
|
|
|
56
58
|
toRealPath,
|
|
57
59
|
unique,
|
|
58
60
|
} from './sandbox-paths.js'
|
|
59
|
-
import { parseStraceViolations, reportableViolations } from './sandbox-violations.js'
|
|
61
|
+
import { parseStraceViolations, refusedWrites, reportableViolations } from './sandbox-violations.js'
|
|
60
62
|
import {
|
|
61
63
|
closeSignalChannel,
|
|
62
64
|
killTree,
|
|
@@ -613,6 +615,8 @@ function throughLinks(p: string, hops = 0): string {
|
|
|
613
615
|
export function resolveSandboxConfig(
|
|
614
616
|
cfg: SandboxConfig,
|
|
615
617
|
projectDir: string,
|
|
618
|
+
/** Canonical directories a glob's hits stop at (sandbox-request.ts `wallOff`). */
|
|
619
|
+
walls: readonly string[] = [],
|
|
616
620
|
): ResolvedSandboxConfig {
|
|
617
621
|
const resolve = (p: string): string => {
|
|
618
622
|
if (p.startsWith('~')) return toRealPath(path.join(os.homedir(), p.slice(1)))
|
|
@@ -621,7 +625,7 @@ export function resolveSandboxConfig(
|
|
|
621
625
|
}
|
|
622
626
|
const a = cfg.allow ?? {}
|
|
623
627
|
const r: ResolvedSandboxConfig = {
|
|
624
|
-
allowRead: expandGrants((a.read ?? []).map(resolve), 'read'),
|
|
628
|
+
allowRead: expandGrants((a.read ?? []).map(resolve), 'read', walls),
|
|
625
629
|
allowWrite: expandGrants(
|
|
626
630
|
(a.write ?? []).map((p) => {
|
|
627
631
|
const real = resolve(p)
|
|
@@ -629,6 +633,7 @@ export function resolveSandboxConfig(
|
|
|
629
633
|
return real
|
|
630
634
|
}),
|
|
631
635
|
'write',
|
|
636
|
+
walls,
|
|
632
637
|
),
|
|
633
638
|
}
|
|
634
639
|
if (a.network !== undefined) r.network = a.network
|
|
@@ -644,10 +649,19 @@ export function resolveSandboxConfig(
|
|
|
644
649
|
r.weakerNetworkIsolation = cfg.weakerNetworkIsolation
|
|
645
650
|
}
|
|
646
651
|
if (cfg.ignore !== undefined) {
|
|
647
|
-
// Relative patterns anchor at the project dir;
|
|
648
|
-
//
|
|
649
|
-
|
|
650
|
-
|
|
652
|
+
// Relative patterns anchor at the project dir; `~` ones are taken as
|
|
653
|
+
// written. A pattern is not a path, but its literal head is: both
|
|
654
|
+
// producers record where a denial LANDS, so the head is canonicalized
|
|
655
|
+
// like every other side of the policy. Anchored at a project reached
|
|
656
|
+
// through a link (macOS's `/var`), no pattern matched and the denial it
|
|
657
|
+
// named failed the task (B-2).
|
|
658
|
+
const anchor = (pat: string): string => {
|
|
659
|
+
if (pat.startsWith('~')) return pat
|
|
660
|
+
const abs = path.isAbsolute(pat) ? pat : path.join(projectDir, pat)
|
|
661
|
+
const wild = abs.search(BUN_GLOB_WILDCARDS)
|
|
662
|
+
const head = wild === -1 ? abs : abs.slice(0, abs.lastIndexOf(path.sep, wild)) || path.sep
|
|
663
|
+
return toRealPath(head) + abs.slice(head.length)
|
|
664
|
+
}
|
|
651
665
|
r.ignore = {
|
|
652
666
|
...(cfg.ignore.read ? { read: cfg.ignore.read.map(anchor) } : {}),
|
|
653
667
|
...(cfg.ignore.write ? { write: cfg.ignore.write.map(anchor) } : {}),
|
|
@@ -726,15 +740,14 @@ export async function wrapSandboxedCommand(
|
|
|
726
740
|
wrapped: string
|
|
727
741
|
tag: string
|
|
728
742
|
taggedCommand: string
|
|
743
|
+
/** What SRT wrapped, and so what its store keys a record by (the group wrapper included on Linux). */
|
|
744
|
+
srtCommand: string
|
|
729
745
|
baselines: CanonicalBaselines
|
|
730
746
|
/** The command reads its polite signals off fd 3: spawn it with one and `signalThrough` it. */
|
|
731
747
|
forwardsSignals: boolean
|
|
732
748
|
}> {
|
|
733
749
|
const { SandboxManager } = await loadSrt()
|
|
734
|
-
const userCommand =
|
|
735
|
-
args.forwardArgs && args.forwardArgs.length > 0
|
|
736
|
-
? args.command + ' ' + args.forwardArgs.map(shellQuote).join(' ')
|
|
737
|
-
: args.command
|
|
750
|
+
const userCommand = withForwardArgs(args.command, args.forwardArgs)
|
|
738
751
|
|
|
739
752
|
const tag = xxh3hex(`${args.cwd}|${userCommand}|${process.hrtime.bigint()}`).slice(0, 16)
|
|
740
753
|
const tmp = taskTmpdir(tag)
|
|
@@ -775,7 +788,14 @@ export async function wrapSandboxedCommand(
|
|
|
775
788
|
if (process.platform === 'linux' && wrapped.startsWith('bwrap ')) wrapped = `exec ${wrapped}`
|
|
776
789
|
if (args.server === true) liveServers.add(tag)
|
|
777
790
|
if (ports.length > 0) spawnHostBridges(ports, tag)
|
|
778
|
-
return {
|
|
791
|
+
return {
|
|
792
|
+
wrapped,
|
|
793
|
+
tag,
|
|
794
|
+
taggedCommand,
|
|
795
|
+
srtCommand: inner,
|
|
796
|
+
baselines,
|
|
797
|
+
forwardsSignals: grouped.forwards,
|
|
798
|
+
}
|
|
779
799
|
}
|
|
780
800
|
|
|
781
801
|
/**
|
|
@@ -962,6 +982,45 @@ export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRun
|
|
|
962
982
|
}
|
|
963
983
|
}
|
|
964
984
|
|
|
985
|
+
/** The records collected for the commands running, by SRT's key for each. */
|
|
986
|
+
const collecting = new Map<string, SandboxViolation[]>()
|
|
987
|
+
let collectorOn = false
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* Start collecting SRT's store records for `command`; the returned function
|
|
991
|
+
* stops and hands them over. The store is a 100-record ring the whole run
|
|
992
|
+
* shares, and on Linux its write observer reports every write any task
|
|
993
|
+
* makes, so a refused write followed by 150 declared ones was gone before
|
|
994
|
+
* the task's exit read it (B-7). One subscription takes each record as it
|
|
995
|
+
* arrives and keeps it only for a command still running. SRT keys a record
|
|
996
|
+
* by the base64 of the command's first 100 characters
|
|
997
|
+
* (`encodeSandboxedCommand`, not exported).
|
|
998
|
+
*/
|
|
999
|
+
function collectRecords(
|
|
1000
|
+
store: ReturnType<SrtModule['SandboxManager']['getSandboxViolationStore']>,
|
|
1001
|
+
command: string,
|
|
1002
|
+
): () => SandboxViolation[] {
|
|
1003
|
+
if (!collectorOn) {
|
|
1004
|
+
collectorOn = true
|
|
1005
|
+
let seen = store.getTotalCount()
|
|
1006
|
+
store.subscribe((all) => {
|
|
1007
|
+
const total = store.getTotalCount()
|
|
1008
|
+
const fresh = Math.min(total - seen, all.length)
|
|
1009
|
+
seen = total
|
|
1010
|
+
for (const v of fresh > 0 ? all.slice(-fresh) : []) {
|
|
1011
|
+
collecting.get(v.encodedCommand ?? '')?.push({ line: v.line, timestamp: v.timestamp })
|
|
1012
|
+
}
|
|
1013
|
+
})
|
|
1014
|
+
}
|
|
1015
|
+
const key = Buffer.from(command.slice(0, 100)).toString('base64')
|
|
1016
|
+
const list: SandboxViolation[] = []
|
|
1017
|
+
collecting.set(key, list)
|
|
1018
|
+
return () => {
|
|
1019
|
+
collecting.delete(key)
|
|
1020
|
+
return list
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
965
1024
|
/** strace's own message, as the last line a failed traced task printed. */
|
|
966
1025
|
const STRACE_OWN_ERROR = /^strace: /
|
|
967
1026
|
|
|
@@ -979,12 +1038,11 @@ async function runSandboxedOnce(
|
|
|
979
1038
|
): Promise<SandboxedRunResult & { tracerFailed: boolean }> {
|
|
980
1039
|
const start = Date.now()
|
|
981
1040
|
const { SandboxManager } = await loadSrt()
|
|
982
|
-
const { wrapped, tag,
|
|
983
|
-
|
|
1041
|
+
const { wrapped, tag, srtCommand, baselines, forwardsSignals } = await wrapSandboxedCommand(args)
|
|
1042
|
+
const takeRecords = collectRecords(SandboxManager.getSandboxViolationStore(), srtCommand)
|
|
984
1043
|
|
|
985
|
-
// Linux: SRT's
|
|
986
|
-
//
|
|
987
|
-
// parse the trace for denied syscalls. The trace is per-task (unique
|
|
1044
|
+
// Linux: SRT's store sees only writes (below), so read denials need
|
|
1045
|
+
// the spawn wrapped with strace and the trace parsed for denied syscalls. The trace is per-task (unique
|
|
988
1046
|
// log path keyed by the command tag) so parallel tasks don't share
|
|
989
1047
|
// a stream. Skipped when strace isn't on PATH — bwrap still enforces
|
|
990
1048
|
// structurally; we just lose the structured violation list.
|
|
@@ -1044,6 +1102,7 @@ async function runSandboxedOnce(
|
|
|
1044
1102
|
const stderr = spawnFailureText(err, args.cwd, 'sandboxed task')
|
|
1045
1103
|
args.onStderr?.(stderr)
|
|
1046
1104
|
releaseBridges(tag)
|
|
1105
|
+
takeRecords()
|
|
1047
1106
|
return {
|
|
1048
1107
|
exitCode: 127,
|
|
1049
1108
|
durationMs: Date.now() - start,
|
|
@@ -1106,25 +1165,21 @@ async function runSandboxedOnce(
|
|
|
1106
1165
|
// DROPPED by the unified log under pressure, not delayed. Reporting on
|
|
1107
1166
|
// macOS is therefore lossy-by-OS under load; ENFORCEMENT is unaffected
|
|
1108
1167
|
// (every observed loss still denied the read and failed the child).
|
|
1109
|
-
|
|
1110
|
-
// EVERY record the store holds for this command, unfiltered (owner,
|
|
1168
|
+
// EVERY record SRT's store saw for this command, unfiltered (owner,
|
|
1111
1169
|
// 2026-09-05). A sandboxed task that fails must say what it was denied;
|
|
1112
1170
|
// deciding on the user's behalf that a record was "just traversal" is how
|
|
1113
1171
|
// a failure ends up with an empty violations section and no explanation.
|
|
1114
|
-
const
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
//
|
|
1120
|
-
//
|
|
1121
|
-
//
|
|
1122
|
-
//
|
|
1123
|
-
//
|
|
1124
|
-
|
|
1125
|
-
// Linux container: exit 1, empty stderr). Linux detection is the strace
|
|
1126
|
-
// pass below, judged against the task's own baselines.
|
|
1127
|
-
let macViolations = process.platform === 'darwin' ? readMacViolations() : []
|
|
1172
|
+
const records = takeRecords()
|
|
1173
|
+
// Read as is on darwin only. Since SRT 0.0.75 the store is fed on Linux
|
|
1174
|
+
// too, by the seccomp helper's write observer — but SRT judges those
|
|
1175
|
+
// reports against the GLOBAL `filesystem.allowWrite` from `initialize`
|
|
1176
|
+
// (empty here; the per-task list travels in `customConfig`, which the
|
|
1177
|
+
// monitor never sees), so every write a task makes to its own declared
|
|
1178
|
+
// output arrived as `deny openat <output>` and, read as is, failed the
|
|
1179
|
+
// task (reproduced 2026-09-04 in a Linux container: exit 1, empty
|
|
1180
|
+
// stderr). On Linux the records are judged against the task's own binds
|
|
1181
|
+
// below (`refusedWrites`).
|
|
1182
|
+
const macViolations = process.platform === 'darwin' ? records : []
|
|
1128
1183
|
// No settle window: the store is read once, right after the child exits
|
|
1129
1184
|
// (owner, 2026-09-05). It cost 300ms on EVERY clean sandboxed task — the
|
|
1130
1185
|
// full budget, since a task with nothing to report can only prove that by
|
|
@@ -1139,9 +1194,28 @@ async function runSandboxedOnce(
|
|
|
1139
1194
|
// syscall on a path inside denyRead that wasn't unconditionally
|
|
1140
1195
|
// allowed. Best-effort — if parsing fails we surface no Linux
|
|
1141
1196
|
// violations rather than fail the whole task.
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1197
|
+
//
|
|
1198
|
+
// strace never sees a write: SRT's seccomp step hands every write-intent
|
|
1199
|
+
// syscall to its observer (USER_NOTIF, which takes precedence over
|
|
1200
|
+
// strace's TRACE), and the observer feeds the store with every ATTEMPT,
|
|
1201
|
+
// judged against the run-wide config, which grants no write. Judged here
|
|
1202
|
+
// against this task's own binds, what is left is a write bwrap refused,
|
|
1203
|
+
// or one into the anchor's scratch that vanished with the sandbox: a
|
|
1204
|
+
// task that swallowed it exited 0 with nothing reported (B-5).
|
|
1205
|
+
const linuxViolations: SandboxViolation[] =
|
|
1206
|
+
process.platform === 'linux'
|
|
1207
|
+
? [
|
|
1208
|
+
...(straceLog
|
|
1209
|
+
? await parseStraceViolations(straceLog, args, baselines).catch(() => [])
|
|
1210
|
+
: []),
|
|
1211
|
+
...refusedWrites(
|
|
1212
|
+
// Keyed by what SRT wrapped, the in-sandbox group wrapper
|
|
1213
|
+
// included, not by the tagged command macOS is keyed by.
|
|
1214
|
+
records.map((v) => v.line),
|
|
1215
|
+
bindableWrites(args.config.allowWrite),
|
|
1216
|
+
),
|
|
1217
|
+
]
|
|
1218
|
+
: []
|
|
1145
1219
|
if (straceLog) {
|
|
1146
1220
|
// Listed until the unlink lands, as the run lock's taking (item 868).
|
|
1147
1221
|
await unlink(straceLog).catch(() => undefined)
|
|
@@ -1197,7 +1271,15 @@ async function runSandboxedOnce(
|
|
|
1197
1271
|
violations,
|
|
1198
1272
|
...(proc.signalCode ? { signal: proc.signalCode } : {}),
|
|
1199
1273
|
...(timeout.timedOut() ? { timedOut: true } : {}),
|
|
1200
|
-
|
|
1274
|
+
// Linux: the task's CPU and peak never reach this wait. bwrap runs it
|
|
1275
|
+
// in a pid namespace (`--unshare-pid`), and what the namespace's
|
|
1276
|
+
// processes used is not folded into bwrap's usage: a 500 ms busy loop
|
|
1277
|
+
// read 2 ms under it and 510 without the flag, and the peak read was
|
|
1278
|
+
// vx's own high-water mark, inherited at exec (a sandboxed `true` from a
|
|
1279
|
+
// 300 MB vx: 353 MB; B-3). Nothing is reported rather than a number
|
|
1280
|
+
// that is someone else's. macOS's `sandbox-exec` execs the command, so
|
|
1281
|
+
// its usage is the task's.
|
|
1282
|
+
...(process.platform === 'linux' ? {} : resourceUsageToCpuRss(proc.resourceUsage())),
|
|
1201
1283
|
tracerFailed:
|
|
1202
1284
|
straceLog !== undefined &&
|
|
1203
1285
|
exitCode !== 0 &&
|
|
@@ -1239,7 +1321,7 @@ function sbplToken(value: string, field: string): string {
|
|
|
1239
1321
|
* `systemInfo` has no SRT field at any level. The rest DO have fields,
|
|
1240
1322
|
* but SRT reads `network.allowLocalBinding` / `allowUnixSockets` /
|
|
1241
1323
|
* `allowMachLookup` off the config given to `initialize()` and never off
|
|
1242
|
-
* the per-call one (`sandbox-manager.js` getters, 0.0.75) — so a per-task
|
|
1324
|
+
* the per-call one (`sandbox-manager.js` getters, 0.0.75 and 0.0.76) — so a per-task
|
|
1243
1325
|
* grant passed there is silently dropped. vx is per-task by definition,
|
|
1244
1326
|
* so it emits them itself. The rule text mirrors SRT's own.
|
|
1245
1327
|
*/
|
|
@@ -1391,7 +1473,11 @@ function injectProfileRules(wrapped: string, rules: readonly string[]): string {
|
|
|
1391
1473
|
* NOT `isLiteralPattern`: its docblock says why the brace is not a wildcard
|
|
1392
1474
|
* to a grant.
|
|
1393
1475
|
*/
|
|
1394
|
-
function expandGrants(
|
|
1476
|
+
function expandGrants(
|
|
1477
|
+
paths: readonly string[],
|
|
1478
|
+
kind: 'read' | 'write',
|
|
1479
|
+
walls: readonly string[],
|
|
1480
|
+
): string[] {
|
|
1395
1481
|
// A pattern covering a directory WHOLE is that directory. `<d>/**/*` and
|
|
1396
1482
|
// `<d>/**` match everything UNDER `<d>` and never `<d>` itself, so a task
|
|
1397
1483
|
// granted `read: ['**/*']` still could not list its own cwd — the exact
|
|
@@ -1421,10 +1507,15 @@ function expandGrants(paths: readonly string[], kind: 'read' | 'write'): string[
|
|
|
1421
1507
|
const head = p.slice(0, p.search(MOUNT_WILDCARDS))
|
|
1422
1508
|
const home = toRealPath(head.endsWith('/') ? head.slice(0, -1) : path.dirname(head))
|
|
1423
1509
|
let hits = 0
|
|
1424
|
-
for (const hit of
|
|
1510
|
+
for (const hit of scanOrNothing(pattern, base)) {
|
|
1425
1511
|
const abs = path.join(base, hit)
|
|
1426
1512
|
const real = toRealPath(abs)
|
|
1427
1513
|
if (real !== home && !real.startsWith(home + path.sep)) continue
|
|
1514
|
+
// A hit that IS a wall, or lies inside one, was matched, not named:
|
|
1515
|
+
// `read: ['*']` in a root project bound `.git` and `.vx`, and
|
|
1516
|
+
// `packages/*` a nested project its key excludes (B-1). A grant that
|
|
1517
|
+
// names a wall literally never reaches this loop and stays.
|
|
1518
|
+
if (walls.some((w) => real === w || real.startsWith(w + path.sep))) continue
|
|
1428
1519
|
out.push(abs)
|
|
1429
1520
|
hits++
|
|
1430
1521
|
}
|
|
@@ -1433,6 +1524,24 @@ function expandGrants(paths: readonly string[], kind: 'read' | 'write'): string[
|
|
|
1433
1524
|
return out
|
|
1434
1525
|
}
|
|
1435
1526
|
|
|
1527
|
+
/**
|
|
1528
|
+
* A glob's hits under `base`, or none when `base` does not exist yet:
|
|
1529
|
+
* `Bun.Glob` throws ENOENT there, and a cache not yet populated on a fresh
|
|
1530
|
+
* runner (`~/.cache/x/y/*`) failed its task with the raw errno and no word
|
|
1531
|
+
* of the grant (B-6). A missing base matches nothing, as an empty one does.
|
|
1532
|
+
*/
|
|
1533
|
+
function scanOrNothing(pattern: string, base: string): Iterable<string> {
|
|
1534
|
+
const glob = new Bun.Glob(pattern)
|
|
1535
|
+
try {
|
|
1536
|
+
// Materialised here: the throw comes from the first step, not the call.
|
|
1537
|
+
return [...glob.scanSync({ cwd: base, onlyFiles: false, dot: true })]
|
|
1538
|
+
} catch (err) {
|
|
1539
|
+
const code = (err as NodeJS.ErrnoException).code
|
|
1540
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') return []
|
|
1541
|
+
throw err
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1436
1545
|
/** Grants already reported — once per process, not per spawn. */
|
|
1437
1546
|
const warnedEmptyWriteGrant = new Set<string>()
|
|
1438
1547
|
|
|
@@ -273,16 +273,12 @@ function withinReported(
|
|
|
273
273
|
}
|
|
274
274
|
|
|
275
275
|
/**
|
|
276
|
-
* Apply the task's
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
* the violation line
|
|
283
|
-
*
|
|
284
|
-
* The defaults installed in `initSandbox` already filter on the macOS
|
|
285
|
-
* side; this pass catches per-task additions + Linux strace results.
|
|
276
|
+
* Apply the task's own `sandbox.ignore` lists on top of whatever the
|
|
277
|
+
* macOS log monitor and the Linux strace pass produced: a record is
|
|
278
|
+
* dropped when a list its operation names (`matchesIgnore`) holds a
|
|
279
|
+
* pattern equal to its target or matching it as a glob. Not SRT's
|
|
280
|
+
* per-command substring match, which is what the defaults installed in
|
|
281
|
+
* `initSandbox` use on the macOS side.
|
|
286
282
|
*/
|
|
287
283
|
function filterIgnored(
|
|
288
284
|
violations: SandboxViolation[],
|
|
@@ -291,3 +287,39 @@ function filterIgnored(
|
|
|
291
287
|
if (ignore === undefined) return violations
|
|
292
288
|
return violations.filter((v) => !matchesIgnore(v, ignore))
|
|
293
289
|
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The writes SRT's Linux observer saw that this task's binds do not cover.
|
|
293
|
+
* The observer reports every write-intent syscall as `deny <syscall>
|
|
294
|
+
* <path>` (it cannot see the mount table), so a record under a path bwrap
|
|
295
|
+
* binds writable — `bindableWrites` of the task's grants — was a write
|
|
296
|
+
* that landed, and anything else was refused or fell into the deny
|
|
297
|
+
* anchor's scratch and vanished with the sandbox. Deduplicated by syscall
|
|
298
|
+
* and canonical path; only a `write` ignore list can silence one.
|
|
299
|
+
*/
|
|
300
|
+
export function refusedWrites(
|
|
301
|
+
records: readonly string[],
|
|
302
|
+
writable: readonly string[],
|
|
303
|
+
): SandboxViolation[] {
|
|
304
|
+
const binds = new Set(writable.map((w) => toRealPath(absolutize(w))))
|
|
305
|
+
const seen = new Set<string>()
|
|
306
|
+
const out: SandboxViolation[] = []
|
|
307
|
+
for (const record of records) {
|
|
308
|
+
const m = /^deny (\S+) (\/.*)$/.exec(record)
|
|
309
|
+
if (m === null) continue
|
|
310
|
+
const [, syscall, raw] = m as unknown as [string, string, string]
|
|
311
|
+
const abs = toRealPath(raw)
|
|
312
|
+
if (isUnderAny(abs, binds)) continue
|
|
313
|
+
const key = `${syscall}|${abs}`
|
|
314
|
+
if (seen.has(key)) continue
|
|
315
|
+
seen.add(key)
|
|
316
|
+
out.push({
|
|
317
|
+
line: `${syscall}(${raw}) = a write no grant covers [${abs}]`,
|
|
318
|
+
timestamp: new Date(),
|
|
319
|
+
target: abs,
|
|
320
|
+
path: abs,
|
|
321
|
+
ignorable: ['write'],
|
|
322
|
+
})
|
|
323
|
+
}
|
|
324
|
+
return out
|
|
325
|
+
}
|
package/src/graph/scheduler.ts
CHANGED
|
@@ -389,9 +389,15 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
389
389
|
// the exec cap: on the 1,000-project bench with every task a restore,
|
|
390
390
|
// 4 → 8 workers cut the run-graph stage 683–754 → 556–595 ms and 16 was
|
|
391
391
|
// no better (2026-09-10). Exec-tier work keeps the CPU-shaped cap; the
|
|
392
|
-
// two lanes never wait on each other. `--concurrency 1` stays serial
|
|
392
|
+
// two lanes never wait on each other. `--concurrency 1` stays serial:
|
|
393
|
+
// the two lanes share its one slot, where a lane each let an 80 MB
|
|
394
|
+
// restore run inside another task's execution (item 1103).
|
|
393
395
|
let activeRestore = 0
|
|
394
|
-
const restoreConcurrency =
|
|
396
|
+
const restoreConcurrency = 2 * concurrency
|
|
397
|
+
const serial = concurrency === 1
|
|
398
|
+
const execRoom = (): boolean => (serial ? active + activeRestore < 1 : active < concurrency)
|
|
399
|
+
const restoreRoom = (): boolean =>
|
|
400
|
+
serial ? active + activeRestore < 1 : activeRestore < restoreConcurrency
|
|
395
401
|
let resolved = false
|
|
396
402
|
|
|
397
403
|
// Admission policy over the count limit (a plugin's `admit`). Inactive
|
|
@@ -416,7 +422,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
416
422
|
const hasRoom = (id: string): boolean => {
|
|
417
423
|
const pool = poolOf?.(id)
|
|
418
424
|
if (pool === undefined) {
|
|
419
|
-
return inRestoreTier(id) ?
|
|
425
|
+
return inRestoreTier(id) ? restoreRoom() : execRoom()
|
|
420
426
|
}
|
|
421
427
|
return (poolActive.get(pool.name) ?? 0) < pool.capacity
|
|
422
428
|
}
|
|
@@ -579,7 +585,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
579
585
|
// 6,000-task scale pin went 0.5 s → 28 s when the restore lane's
|
|
580
586
|
// first cut let the scan run past a full exec lane, 2026-09-10).
|
|
581
587
|
// This is the legacy O(1) gate, kept per lane.
|
|
582
|
-
const execAdmissible = poolOf !== undefined || admitActive ||
|
|
588
|
+
const execAdmissible = poolOf !== undefined || admitActive || execRoom()
|
|
583
589
|
if (execAdmissible) {
|
|
584
590
|
while (execReady.size > 0) {
|
|
585
591
|
const seq = execReady.peekSeq()
|
|
@@ -594,7 +600,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
594
600
|
}
|
|
595
601
|
}
|
|
596
602
|
// Restore-tier tasks are local disk work, on their own lane.
|
|
597
|
-
return
|
|
603
|
+
return restoreRoom() ? restoreReady.pop() : undefined
|
|
598
604
|
}
|
|
599
605
|
|
|
600
606
|
for (;;) {
|
package/src/graph/task-graph.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
1
2
|
import type { ProjectConfig, TaskConfig } from '../config.js'
|
|
2
3
|
import {
|
|
3
4
|
asTrees,
|
|
@@ -258,6 +259,13 @@ export interface BuildGraphOptions {
|
|
|
258
259
|
* workspace and the builder refuses it itself.
|
|
259
260
|
*/
|
|
260
261
|
undeclaredDeps?: (taskId: string, name: string) => void
|
|
262
|
+
/**
|
|
263
|
+
* The workspace root, where `cache.outputs.workspaceFiles` is anchored.
|
|
264
|
+
* Given, a root-anchored output is also compared with every project's
|
|
265
|
+
* `files` outputs (item 1088); absent (a graph built for a unit row), only
|
|
266
|
+
* with other root-anchored ones.
|
|
267
|
+
*/
|
|
268
|
+
workspaceRoot?: string
|
|
261
269
|
}
|
|
262
270
|
|
|
263
271
|
/**
|
|
@@ -505,7 +513,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
505
513
|
}
|
|
506
514
|
}
|
|
507
515
|
|
|
508
|
-
checkGraph(nodes)
|
|
516
|
+
checkGraph(nodes, options.workspaceRoot)
|
|
509
517
|
return nodes
|
|
510
518
|
}
|
|
511
519
|
|
|
@@ -520,7 +528,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
520
528
|
* marks are derived here, so they are cleared first and follow the edges
|
|
521
529
|
* the graph has now.
|
|
522
530
|
*/
|
|
523
|
-
export function checkGraph(nodes: Map<string, TaskNode
|
|
531
|
+
export function checkGraph(nodes: Map<string, TaskNode>, workspaceRoot?: string): void {
|
|
524
532
|
for (const [key, node] of nodes) {
|
|
525
533
|
if (node.id !== key) {
|
|
526
534
|
throw new Error(`the task ${node.id} is stored under '${key}', not its own id`)
|
|
@@ -534,7 +542,7 @@ export function checkGraph(nodes: Map<string, TaskNode>): void {
|
|
|
534
542
|
delete node.outputsAddedToBy
|
|
535
543
|
}
|
|
536
544
|
detectCycle(nodes)
|
|
537
|
-
detectOutputCollisions(nodes)
|
|
545
|
+
detectOutputCollisions(nodes, workspaceRoot)
|
|
538
546
|
}
|
|
539
547
|
|
|
540
548
|
/**
|
|
@@ -718,7 +726,7 @@ function covers(tree: string, glob: string): boolean {
|
|
|
718
726
|
* project boundaries by design, so ANY two tasks can. No cache key changes —
|
|
719
727
|
* this only refuses a graph that was already destroying files.
|
|
720
728
|
*/
|
|
721
|
-
function detectOutputCollisions(nodes: Map<string, TaskNode
|
|
729
|
+
function detectOutputCollisions(nodes: Map<string, TaskNode>, workspaceRoot?: string): void {
|
|
722
730
|
// Does `from` reach `to` through deps? Asked only for a colliding pair,
|
|
723
731
|
// so the walk is rare; memoised per source across the detector's calls.
|
|
724
732
|
const reachMemo = new Map<string, Set<string>>()
|
|
@@ -778,11 +786,87 @@ function detectOutputCollisions(nodes: Map<string, TaskNode>): void {
|
|
|
778
786
|
collide(a, b, filesOf(a), filesOf(b), 'files', reaches)
|
|
779
787
|
}
|
|
780
788
|
}
|
|
781
|
-
if (wsDeclarers.length
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
789
|
+
if (wsDeclarers.length >= 2) {
|
|
790
|
+
for (const [i, j] of overlapCandidates(wsDeclarers, wsFilesOf)) {
|
|
791
|
+
const a = wsDeclarers[i]!
|
|
792
|
+
const b = wsDeclarers[j]!
|
|
793
|
+
collide(a, b, wsFilesOf(a), wsFilesOf(b), 'workspaceFiles', reaches)
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
// A root-anchored output that reaches into ANOTHER project's `files`
|
|
797
|
+
// outputs was compared with neither: `a` writing `packages/b/dist/a.txt`
|
|
798
|
+
// beside `b`'s `dist/**` had its file deleted by `b`'s clean and still
|
|
799
|
+
// replayed `up-to-date` (item 1088). Each `files` declarer joins the
|
|
800
|
+
// root-anchored domain with its globs rebased to the root, and only the
|
|
801
|
+
// mixed pairs are compared.
|
|
802
|
+
if (wsDeclarers.length === 0 || workspaceRoot === undefined) return
|
|
803
|
+
interface Entry {
|
|
804
|
+
node: TaskNode
|
|
805
|
+
globs: readonly string[]
|
|
806
|
+
/** The project's root-relative dir for a rebased `files` entry; undefined for a root-anchored one. */
|
|
807
|
+
rel?: string
|
|
808
|
+
}
|
|
809
|
+
const entries: Entry[] = wsDeclarers.map((node) => ({ node, globs: wsFilesOf(node) ?? [] }))
|
|
810
|
+
for (const bucket of byProject.values()) {
|
|
811
|
+
for (const node of bucket) {
|
|
812
|
+
const rel = path.relative(workspaceRoot, node.projectDir).split(path.sep).join('/')
|
|
813
|
+
const globs = (filesOf(node) ?? []).map((g) => (rel === '' ? g : `${rel}/${g}`))
|
|
814
|
+
entries.push({ node, globs, rel })
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
for (const [i, j] of overlapCandidates(entries, (e) => e.globs)) {
|
|
818
|
+
const x = entries[i]!
|
|
819
|
+
const y = entries[j]!
|
|
820
|
+
if ((x.rel === undefined) === (y.rel === undefined) || x.node === y.node) continue
|
|
821
|
+
const [ws, files] = x.rel === undefined ? [x, y] : [y, x]
|
|
822
|
+
collideAcross(ws.node, ws.globs, files.node, files.globs, files.rel!, reaches)
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* `collide` for a root-anchored output against another project's `files`
|
|
828
|
+
* output rebased to the root. With an edge the dependant adds to its
|
|
829
|
+
* upstream's tree, and the upstream is told the dependant's globs in its
|
|
830
|
+
* OWN namespace: root-relative for the root-anchored side, project-relative
|
|
831
|
+
* for the `files` side, where a glob that does not start in that project
|
|
832
|
+
* cannot be told apart and the pair is refused.
|
|
833
|
+
*/
|
|
834
|
+
function collideAcross(
|
|
835
|
+
ws: TaskNode,
|
|
836
|
+
wsGlobs: readonly string[],
|
|
837
|
+
files: TaskNode,
|
|
838
|
+
rebased: readonly string[],
|
|
839
|
+
rel: string,
|
|
840
|
+
reaches: (from: string, to: string) => boolean,
|
|
841
|
+
): void {
|
|
842
|
+
if (neverWritesLocally(ws) || neverWritesLocally(files)) return
|
|
843
|
+
for (const ga of wsGlobs) {
|
|
844
|
+
for (const gb of rebased) {
|
|
845
|
+
if (!outputsOverlap(ga, gb)) continue
|
|
846
|
+
if (reaches(files.id, ws.id)) {
|
|
847
|
+
;(files.addsToOutputsOf ??= []).push(ws.id)
|
|
848
|
+
;(ws.outputsAddedToBy ??= []).push(...rebased)
|
|
849
|
+
return
|
|
850
|
+
}
|
|
851
|
+
if (reaches(ws.id, files.id)) {
|
|
852
|
+
const own = wsGlobs.map((g) =>
|
|
853
|
+
rel === '' ? g : g.startsWith(`${rel}/`) ? g.slice(rel.length + 1) : null,
|
|
854
|
+
)
|
|
855
|
+
if (own.every((g): g is string => g !== null)) {
|
|
856
|
+
;(ws.addsToOutputsOf ??= []).push(files.id)
|
|
857
|
+
;(files.outputsAddedToBy ??= []).push(...own)
|
|
858
|
+
return
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
throw new UserError(
|
|
862
|
+
`${ws.id} declares the output ${JSON.stringify(ga)} in cache.outputs.workspaceFiles, ` +
|
|
863
|
+
`inside ${files.id}'s ${JSON.stringify(gb.slice(rel === '' ? 0 : rel.length + 1))} in ` +
|
|
864
|
+
`cache.outputs.files — vx cleans a task's declared outputs before it runs and before a ` +
|
|
865
|
+
`cache-hit restore, so whichever of these runs second DELETES the other's output and ` +
|
|
866
|
+
`the run still reports success. Give each task its own output path, or make one ` +
|
|
867
|
+
`depend on the other.`,
|
|
868
|
+
)
|
|
869
|
+
}
|
|
786
870
|
}
|
|
787
871
|
}
|
|
788
872
|
|
|
@@ -815,9 +899,9 @@ const GLOB_HEAD_END = /[*?{}\\!]/
|
|
|
815
899
|
* literal prefix lies at or under it, found the same way from the glob's
|
|
816
900
|
* side (item 941).
|
|
817
901
|
*/
|
|
818
|
-
function overlapCandidates(
|
|
819
|
-
tasks: readonly
|
|
820
|
-
globsOf: (n:
|
|
902
|
+
function overlapCandidates<T>(
|
|
903
|
+
tasks: readonly T[],
|
|
904
|
+
globsOf: (n: T) => readonly string[] | undefined,
|
|
821
905
|
): Array<[number, number]> {
|
|
822
906
|
const globs = new Map<string, number[]>()
|
|
823
907
|
const globsUnder = new Map<string, number[]>()
|