@vzn/vx 0.0.445 → 0.0.447
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/cli/upgrade.ts +1 -1
- package/src/cli/watch.ts +66 -2
- package/src/exec/sandbox-runtime.ts +26 -9
- package/src/orchestrator/history.ts +45 -9
- package/src/orchestrator/plan.ts +9 -2
- package/src/orchestrator/sandbox-request.ts +3 -2
- package/src/workspace/workspace.ts +9 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.447",
|
|
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.447",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.447",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.447",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.447"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cli/upgrade.ts
CHANGED
|
@@ -29,7 +29,7 @@ export function isBunfsPath(p: string): boolean {
|
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
31
|
* True when running as a `bun build --compile` binary. Keys off
|
|
32
|
-
* `Bun.main` (and argv[1])
|
|
32
|
+
* `Bun.main` (and argv[1]), `import.meta.path` only last: with
|
|
33
33
|
* `--minify --bytecode` — vx's release build flags — `import.meta.path`
|
|
34
34
|
* reports the ORIGINAL SOURCE path, not the bunfs path, so the old
|
|
35
35
|
* check silently failed for every installed binary and `vx
|
package/src/cli/watch.ts
CHANGED
|
@@ -432,6 +432,23 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
432
432
|
})
|
|
433
433
|
/** Per-project arms by directory, so `rearm` can add and drop them. */
|
|
434
434
|
const perProject = new Map<string, WatchHandle>()
|
|
435
|
+
// An OS watch holds the directory's inode, not its path: one removed and
|
|
436
|
+
// made again (`rm -rf packages && git checkout packages`) keeps reporting
|
|
437
|
+
// for the deleted one. Each arm notes the directory it holds, and a
|
|
438
|
+
// re-arm replaces any whose path now names another. The birth time is
|
|
439
|
+
// part of the name: a freed inode number is handed straight to the next
|
|
440
|
+
// directory made (measured on this box's /tmp), so the number alone
|
|
441
|
+
// said "the same one" of a new directory.
|
|
442
|
+
const armedAs = new Map<string, string>()
|
|
443
|
+
const inodeOf = (dir: string): string | undefined => {
|
|
444
|
+
try {
|
|
445
|
+
const st = fs.statSync(dir)
|
|
446
|
+
return `${st.dev}:${st.ino}:${st.birthtimeMs}`
|
|
447
|
+
} catch {
|
|
448
|
+
return undefined
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
const stale = (dir: string): boolean => armedAs.get(dir) !== inodeOf(dir)
|
|
435
452
|
/** The root arm: recursive when workspace-wide, else the root's own files only. */
|
|
436
453
|
let rootArm: WatchHandle = CLOSED
|
|
437
454
|
const armProject = (proj: ProjectMeta): void => {
|
|
@@ -442,6 +459,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
442
459
|
trigger(`${proj.name} ${filename}`, path.join(proj.dir, filename))
|
|
443
460
|
})
|
|
444
461
|
perProject.set(proj.dir, handle)
|
|
462
|
+
armedAs.set(proj.dir, inodeOf(proj.dir) ?? '')
|
|
445
463
|
} catch (err) {
|
|
446
464
|
sayCannot(`cannot watch ${proj.dir}`, err)
|
|
447
465
|
}
|
|
@@ -629,7 +647,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
629
647
|
} else if (!workspaceWide) {
|
|
630
648
|
const keep = new Set(projectDirs)
|
|
631
649
|
for (const [dir, handle] of perProject) {
|
|
632
|
-
if (keep.has(dir)) continue
|
|
650
|
+
if (keep.has(dir) && !stale(dir)) continue
|
|
633
651
|
handle.close()
|
|
634
652
|
perProject.delete(dir)
|
|
635
653
|
}
|
|
@@ -655,15 +673,60 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
655
673
|
// re-read with the set: a glob added to the list watched nothing new
|
|
656
674
|
// until a restart (item 1018).
|
|
657
675
|
const baseArms = new Map<string, WatchHandle>()
|
|
676
|
+
// A base removed and made again is no event to its own watch (it holds
|
|
677
|
+
// the deleted directory) nor to the root arm, which drops every name but
|
|
678
|
+
// its own files: `watching 0 project(s)` and silence until a restart. So
|
|
679
|
+
// each base is watched from its nearest directory that exists, for the
|
|
680
|
+
// next name on the way down: its parent while it is there, an ancestor
|
|
681
|
+
// when the parent went too (`apps/` removed under `apps/web/*`).
|
|
682
|
+
const baseParents = new Map<string, WatchHandle>()
|
|
683
|
+
let baseNames = new Map<string, Set<string>>()
|
|
684
|
+
const armBaseParents = (): void => {
|
|
685
|
+
baseNames = new Map()
|
|
686
|
+
for (const base of memberBases) {
|
|
687
|
+
let dir = path.dirname(base)
|
|
688
|
+
let name = path.basename(base)
|
|
689
|
+
while (inodeOf(dir) === undefined && dir !== workspaceRoot && dir !== path.dirname(dir)) {
|
|
690
|
+
name = path.basename(dir)
|
|
691
|
+
dir = path.dirname(dir)
|
|
692
|
+
}
|
|
693
|
+
baseNames.set(dir, (baseNames.get(dir) ?? new Set()).add(name))
|
|
694
|
+
}
|
|
695
|
+
for (const [dir, handle] of baseParents) {
|
|
696
|
+
if (baseNames.has(dir) && !stale(dir)) continue
|
|
697
|
+
handle.close()
|
|
698
|
+
baseParents.delete(dir)
|
|
699
|
+
}
|
|
700
|
+
for (const dir of baseNames.keys()) {
|
|
701
|
+
if (baseParents.has(dir)) continue
|
|
702
|
+
try {
|
|
703
|
+
baseParents.set(
|
|
704
|
+
dir,
|
|
705
|
+
arm(dir, false, (filename) => {
|
|
706
|
+
if (!baseNames.get(dir)?.has(filename)) return
|
|
707
|
+
reread = true
|
|
708
|
+
const abs = path.join(dir, filename)
|
|
709
|
+
trigger(path.relative(workspaceRoot, abs), abs)
|
|
710
|
+
}),
|
|
711
|
+
)
|
|
712
|
+
armedAs.set(dir, inodeOf(dir) ?? '')
|
|
713
|
+
} catch (err) {
|
|
714
|
+
sayCannot(`cannot watch ${dir}`, err)
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
658
718
|
const armBases = (): void => {
|
|
719
|
+
armBaseParents()
|
|
659
720
|
const want = new Set(memberBases)
|
|
660
721
|
for (const [base, handle] of baseArms) {
|
|
661
|
-
if (want.has(base)) continue
|
|
722
|
+
if (want.has(base) && !stale(base)) continue
|
|
662
723
|
handle.close()
|
|
663
724
|
baseArms.delete(base)
|
|
664
725
|
}
|
|
665
726
|
for (const base of want) {
|
|
666
727
|
if (baseArms.has(base)) continue
|
|
728
|
+
// Gone for now: its parent's arm hears it come back.
|
|
729
|
+
if (inodeOf(base) === undefined) continue
|
|
667
730
|
let members = memberEntries(base)
|
|
668
731
|
try {
|
|
669
732
|
baseArms.set(
|
|
@@ -683,6 +746,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
683
746
|
trigger(`${path.relative(workspaceRoot, base)}/${filename}`, path.join(base, filename))
|
|
684
747
|
}),
|
|
685
748
|
)
|
|
749
|
+
armedAs.set(base, inodeOf(base) ?? '')
|
|
686
750
|
} catch (err) {
|
|
687
751
|
sayCannot(`cannot watch ${base}`, err)
|
|
688
752
|
}
|
|
@@ -1148,7 +1148,12 @@ export async function wrapSandboxedCommand(
|
|
|
1148
1148
|
|
|
1149
1149
|
const baselines = canonicalBaselines(args)
|
|
1150
1150
|
const customConfig = buildCustomConfig(args, baselines)
|
|
1151
|
-
const scratch = pendingWriteGrants(
|
|
1151
|
+
const scratch = pendingWriteGrants(
|
|
1152
|
+
args.config,
|
|
1153
|
+
customConfig!.filesystem!,
|
|
1154
|
+
baselines.denyRead,
|
|
1155
|
+
baselines.cwd,
|
|
1156
|
+
)
|
|
1152
1157
|
customConfig!.filesystem!.denyRead!.push(toRealPath(taskTmpRoot()))
|
|
1153
1158
|
if (process.platform === 'linux' && !cwdMounted(baselines.cwd, customConfig!.filesystem!)) {
|
|
1154
1159
|
customConfig!.filesystem!.denyRead!.push(baselines.cwd)
|
|
@@ -2400,7 +2405,7 @@ function scanOrNothing(pattern: string, base: string): Iterable<string> {
|
|
|
2400
2405
|
* write may still land under, in the sandbox's scratch, returned; the ones
|
|
2401
2406
|
* a read-only mount or the host's root holds, reported once each
|
|
2402
2407
|
* (`writeGrantMatchedNothing`). `anchors` are the deny anchors the scratch
|
|
2403
|
-
* is made of.
|
|
2408
|
+
* is made of; `within`, the task's directory, which the report spells from.
|
|
2404
2409
|
*/
|
|
2405
2410
|
export function pendingWriteGrants(
|
|
2406
2411
|
config: Pick<ResolvedSandboxConfig, 'pendingWrites'>,
|
|
@@ -2409,9 +2414,10 @@ export function pendingWriteGrants(
|
|
|
2409
2414
|
readonly allowWrite?: readonly string[] | undefined
|
|
2410
2415
|
},
|
|
2411
2416
|
anchors: readonly string[],
|
|
2417
|
+
within: string,
|
|
2412
2418
|
): string[] {
|
|
2413
2419
|
const { scratch, mountless } = scratchWrites(config.pendingWrites ?? [], fs, anchors)
|
|
2414
|
-
for (const grant of mountless) writeGrantMatchedNothing(grant)
|
|
2420
|
+
for (const grant of mountless) writeGrantMatchedNothing(grant, within)
|
|
2415
2421
|
return scratch
|
|
2416
2422
|
}
|
|
2417
2423
|
|
|
@@ -2427,18 +2433,29 @@ const warnedEmptyWriteGrant = new Set<string>()
|
|
|
2427
2433
|
* the relative pattern keeps its wildcard component), and printing it
|
|
2428
2434
|
* would tell the user to grant the PARENT of the directory they meant.
|
|
2429
2435
|
*/
|
|
2430
|
-
function writeGrantMatchedNothing(grant: string): void {
|
|
2436
|
+
function writeGrantMatchedNothing(grant: string, within: string): void {
|
|
2431
2437
|
if (warnedEmptyWriteGrant.has(grant)) return
|
|
2432
2438
|
warnedEmptyWriteGrant.add(grant)
|
|
2439
|
+
// As a committed config spells them (B-97): the absolute path held only
|
|
2440
|
+
// on the machine that printed it, as in `outsideWritesHint`.
|
|
2441
|
+
const dir = grantSpelled(grantPrefix(grant), within)
|
|
2433
2442
|
process.stderr.write(
|
|
2434
|
-
`[vx] sandbox: the write grant ${grant} matches nothing
|
|
2435
|
-
`directory read-only, so a file the task creates under
|
|
2436
|
-
`system". A bind mount ` +
|
|
2437
|
-
`
|
|
2438
|
-
`${
|
|
2443
|
+
`[vx] sandbox: the write grant ${jsString(grantSpelled(grant, within))} matches nothing ` +
|
|
2444
|
+
`yet, and a read grant mounts its directory read-only, so a file the task creates under ` +
|
|
2445
|
+
`it will fail with "Read-only file system". A bind mount covers what exists when the ` +
|
|
2446
|
+
`task starts — grant the directory instead: ` +
|
|
2447
|
+
`\`allow: { write: [${jsString(dir === '.' ? '.' : `${dir}/`)}] }\`\n`,
|
|
2439
2448
|
)
|
|
2440
2449
|
}
|
|
2441
2450
|
|
|
2451
|
+
/** An absolute grant path as a config spells it: from `within`, from `~`, or whole. */
|
|
2452
|
+
function grantSpelled(p: string, within: string): string {
|
|
2453
|
+
const real = toRealPath(within)
|
|
2454
|
+
if (atOrUnder(p, real)) return path.relative(real, p) || '.'
|
|
2455
|
+
const home = toRealPath(os.homedir())
|
|
2456
|
+
return atOrUnder(p, home) ? `~${p.slice(home.length)}` : p
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2442
2459
|
/**
|
|
2443
2460
|
* Memoized check: is `strace` on PATH on a Linux host, and does it know
|
|
2444
2461
|
* `--seccomp-bpf` (5.3+)? `'seccomp'` is the fast form; `'plain'` traces
|
|
@@ -60,6 +60,12 @@ export type HistoryTable = ReadonlyMap<string, TaskHistory>
|
|
|
60
60
|
|
|
61
61
|
export interface HistoryProvider {
|
|
62
62
|
loadFor(taskIds: readonly string[]): Promise<HistoryTable>
|
|
63
|
+
/**
|
|
64
|
+
* Only `p50DurationMs` of each task `loadFor` would answer, for a reader
|
|
65
|
+
* that needs nothing else (`--dry`'s prediction). Optional: without it
|
|
66
|
+
* the reader takes them from `loadFor`.
|
|
67
|
+
*/
|
|
68
|
+
p50sFor?(taskIds: readonly string[]): Promise<ReadonlyMap<string, number>>
|
|
63
69
|
}
|
|
64
70
|
|
|
65
71
|
/** A no-op provider — every lookup returns an empty table. */
|
|
@@ -96,15 +102,7 @@ export class LocalHistoryProvider implements HistoryProvider {
|
|
|
96
102
|
async loadFor(taskIds: readonly string[]): Promise<HistoryTable> {
|
|
97
103
|
const out = new Map<string, TaskHistory>()
|
|
98
104
|
if (taskIds.length === 0) return out
|
|
99
|
-
|
|
100
|
-
const floorRow = this.db
|
|
101
|
-
.query(
|
|
102
|
-
`SELECT MIN(id) AS id FROM runs WHERE run_id =
|
|
103
|
-
(SELECT run_id FROM invocations ORDER BY rowid DESC LIMIT 1 OFFSET ?)`,
|
|
104
|
-
)
|
|
105
|
-
.get(this.recent - 1) as { id: number | null }
|
|
106
|
-
// Fewer invocations than the window (or none): the whole table is the window.
|
|
107
|
-
const floor = floorRow.id ?? 0
|
|
105
|
+
const floor = this.floor()
|
|
108
106
|
|
|
109
107
|
// `skipped` rows are excluded: a skip is a task the run never executed,
|
|
110
108
|
// so it belongs in no success/hit RATE and no duration. Percentiles are
|
|
@@ -189,6 +187,44 @@ export class LocalHistoryProvider implements HistoryProvider {
|
|
|
189
187
|
return out
|
|
190
188
|
}
|
|
191
189
|
|
|
190
|
+
/**
|
|
191
|
+
* The same window's p50s alone. `loadFor`'s rates and resource maxima
|
|
192
|
+
* read every row of the window and join each hit to its entry: 38 ms at
|
|
193
|
+
* 27,000 rows against 4 for the executed successes' durations alone.
|
|
194
|
+
*/
|
|
195
|
+
async p50sFor(taskIds: readonly string[]): Promise<ReadonlyMap<string, number>> {
|
|
196
|
+
const out = new Map<string, number>()
|
|
197
|
+
if (taskIds.length === 0) return out
|
|
198
|
+
const rows = this.db
|
|
199
|
+
.query(
|
|
200
|
+
`SELECT project, task, GROUP_CONCAT(duration_ms) AS ds FROM runs
|
|
201
|
+
WHERE id >= ? AND status = 'success' AND (cache_hit IS NULL OR cache_hit = 0)
|
|
202
|
+
GROUP BY project, task`,
|
|
203
|
+
)
|
|
204
|
+
.all(this.floor()) as Array<{ project: string; task: string; ds: string }>
|
|
205
|
+
const wanted = new Set(taskIds)
|
|
206
|
+
for (const row of rows) {
|
|
207
|
+
const key = `${row.project}#${row.task}`
|
|
208
|
+
if (!wanted.has(key)) continue
|
|
209
|
+
const durations = row.ds.split(',').map(Number)
|
|
210
|
+
durations.sort((a, b) => a - b)
|
|
211
|
+
out.set(key, pickPercentile(durations, 0.5))
|
|
212
|
+
}
|
|
213
|
+
return out
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The first `runs` id of the newest `recent` invocations (see `loadFor`). */
|
|
217
|
+
private floor(): number {
|
|
218
|
+
const row = this.db
|
|
219
|
+
.query(
|
|
220
|
+
`SELECT MIN(id) AS id FROM runs WHERE run_id =
|
|
221
|
+
(SELECT run_id FROM invocations ORDER BY rowid DESC LIMIT 1 OFFSET ?)`,
|
|
222
|
+
)
|
|
223
|
+
.get(this.recent - 1) as { id: number | null }
|
|
224
|
+
// Fewer invocations than the window (or none): the whole table is the window.
|
|
225
|
+
return row.id ?? 0
|
|
226
|
+
}
|
|
227
|
+
|
|
192
228
|
/** Mixed-outcome key count per `project#task`, over the slice, for `pairs` only. */
|
|
193
229
|
private mixedOutcomeKeys(
|
|
194
230
|
floor: number,
|
package/src/orchestrator/plan.ts
CHANGED
|
@@ -189,9 +189,16 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
|
|
|
189
189
|
if (args.history !== undefined) {
|
|
190
190
|
try {
|
|
191
191
|
const realIds = tasks.filter((t) => t.cacheStatus !== 'group').map((t) => t.node.id)
|
|
192
|
-
const
|
|
192
|
+
const p50s =
|
|
193
|
+
args.history.p50sFor !== undefined
|
|
194
|
+
? await args.history.p50sFor(realIds)
|
|
195
|
+
: new Map(
|
|
196
|
+
[...(await args.history.loadFor(realIds))].flatMap(([id, h]) =>
|
|
197
|
+
h.p50DurationMs === undefined ? [] : [[id, h.p50DurationMs] as const],
|
|
198
|
+
),
|
|
199
|
+
)
|
|
193
200
|
for (const t of tasks) {
|
|
194
|
-
const p50 =
|
|
201
|
+
const p50 = p50s.get(t.node.id)
|
|
195
202
|
if (p50 !== undefined) t.p50Ms = p50
|
|
196
203
|
}
|
|
197
204
|
return { tasks, predicted: predictPlan(tasks), ...downgradeField(args) }
|
|
@@ -553,7 +553,8 @@ export function placeholderSweeper(placeholders: readonly Placeholder[]): () =>
|
|
|
553
553
|
* The line a failed task gets for a placeholder it never wrote. Not a
|
|
554
554
|
* diagnosis — the task may have died before its first write — but the
|
|
555
555
|
* one clue to the trap: a grant that meant a directory is bound as a
|
|
556
|
-
* file, and the task's own `mkdir` says only "File exists"
|
|
556
|
+
* file, and the task's own `mkdir` says only "File exists" (or "Not a
|
|
557
|
+
* directory" for a path inside it, B-96). Added when
|
|
557
558
|
* the task already failed and the sandbox reported nothing else, so it
|
|
558
559
|
* never reddens a pass and never buries a real denial.
|
|
559
560
|
*/
|
|
@@ -562,7 +563,7 @@ export function untouchedPlaceholderLine(projectDir: string, placeholder: string
|
|
|
562
563
|
return (
|
|
563
564
|
`vx: the sandbox write grant \`${rel}\` named nothing on disk, so vx bound it as an empty ` +
|
|
564
565
|
`file, which the task never wrote (removed again). If the task creates a directory there ` +
|
|
565
|
-
`("File exists" from its own mkdir), spell the grant \`${rel}/\` — a literal without the ` +
|
|
566
|
+
`("File exists" or "Not a directory" from its own mkdir), spell the grant \`${rel}/\` — a literal without the ` +
|
|
566
567
|
`slash is a file.`
|
|
567
568
|
)
|
|
568
569
|
}
|
|
@@ -86,6 +86,9 @@ export async function findWorkspaceRoot(
|
|
|
86
86
|
let dir = path.resolve(start)
|
|
87
87
|
const below: string[] = []
|
|
88
88
|
let nearest: string | null = null
|
|
89
|
+
// The nearest manifest with workspace globs of its own, once passed: an
|
|
90
|
+
// outer root claims the tree only by listing this directory itself.
|
|
91
|
+
let inner: string | null = null
|
|
89
92
|
while (true) {
|
|
90
93
|
let globs: string[] | null
|
|
91
94
|
try {
|
|
@@ -99,7 +102,7 @@ export async function findWorkspaceRoot(
|
|
|
99
102
|
}
|
|
100
103
|
if (globs !== null) {
|
|
101
104
|
nearest ??= dir
|
|
102
|
-
if (claimsMember(dir, below, globs)) return dir
|
|
105
|
+
if (claimsMember(dir, inner === null ? below : [inner], globs)) return dir
|
|
103
106
|
// pnpm takes the nearest `pnpm-workspace.yaml` as the root, listed or
|
|
104
107
|
// not. Walking past it, `apps/inner` resolved to the outer workspace
|
|
105
108
|
// while `apps/inner/pkgs/x` resolved to the inner one: two roots and
|
|
@@ -113,6 +116,11 @@ export async function findWorkspaceRoot(
|
|
|
113
116
|
// package below it, and `packages/tools/standalone` ran as a stranger
|
|
114
117
|
// in a workspace that does not list it (item 989).
|
|
115
118
|
if (globs !== null) below.push(dir)
|
|
119
|
+
// `apps/tool/workspace` with its own `workspaces`, under a root listing
|
|
120
|
+
// `apps/*`, resolved to the outer root through `apps/tool`, and its
|
|
121
|
+
// members ran in a workspace that does not list them (D-137). npm makes
|
|
122
|
+
// an outer root own a nested one only when it lists that directory.
|
|
123
|
+
if (inner === null && globs !== null && globs.length > 0) inner = dir
|
|
116
124
|
dir = parent
|
|
117
125
|
}
|
|
118
126
|
if (nearest !== null) return nearest
|