@vzn/vx 0.0.445 → 0.0.446

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.446",
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.446",
25
+ "@vzn/vx-linux-arm64": "0.0.446",
26
+ "@vzn/vx-darwin-x64": "0.0.446",
27
+ "@vzn/vx-darwin-arm64": "0.0.446"
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
  }
@@ -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