@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.445",
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.445",
25
- "@vzn/vx-linux-arm64": "0.0.445",
26
- "@vzn/vx-darwin-x64": "0.0.445",
27
- "@vzn/vx-darwin-arm64": "0.0.445"
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"
@@ -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]) rather than `import.meta.path`: with
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(args.config, customConfig!.filesystem!, baselines.denyRead)
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 yet, and a read grant mounts its ` +
2435
- `directory read-only, so a file the task creates under it will fail with "Read-only file ` +
2436
- `system". A bind mount ` +
2437
- `covers what exists when the task starts — grant the directory instead: ` +
2438
- `${grantPrefix(grant)}/**\n`,
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,
@@ -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 table = await args.history.loadFor(realIds)
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 = table.get(t.node.id)?.p50DurationMs
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". Added when
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