@vzn/vx 0.0.595 → 0.0.597

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.
Files changed (82) hide show
  1. package/README.md +1 -1
  2. package/launcher.cjs +7 -3
  3. package/package.json +5 -5
  4. package/src/cache/archive.ts +35 -6
  5. package/src/cache/cache.ts +170 -99
  6. package/src/cache/chained-cache.ts +25 -7
  7. package/src/cache/git-inputs.ts +23 -0
  8. package/src/cache/index.ts +3 -0
  9. package/src/cache/inputs.ts +99 -44
  10. package/src/cache/key-fold.ts +1 -1
  11. package/src/cache/layer.ts +9 -6
  12. package/src/cache/policy.ts +5 -0
  13. package/src/cache/zstd.ts +10 -4
  14. package/src/cli/cache.ts +12 -6
  15. package/src/cli/foreign-flags.ts +57 -3
  16. package/src/cli/info.ts +1 -1
  17. package/src/cli/init.ts +2 -2
  18. package/src/cli/last.ts +10 -4
  19. package/src/cli/lock.ts +5 -3
  20. package/src/cli/plan-format.ts +4 -4
  21. package/src/cli/run.ts +73 -30
  22. package/src/cli/select.ts +6 -3
  23. package/src/cli/show.ts +8 -2
  24. package/src/cli/upgrade.ts +1 -1
  25. package/src/cli/watch-filter.ts +63 -12
  26. package/src/cli/watch-fs.ts +11 -3
  27. package/src/cli/watch-judge.ts +15 -8
  28. package/src/cli/watch.ts +18 -11
  29. package/src/cli/workspace-config.ts +3 -1
  30. package/src/config.ts +42 -21
  31. package/src/exec/executor.ts +1 -1
  32. package/src/exec/kill-tree.ts +89 -18
  33. package/src/exec/proc-sample.ts +1 -1
  34. package/src/exec/runner.ts +74 -36
  35. package/src/exec/sandbox-paths.ts +12 -1
  36. package/src/exec/sandbox-runtime.ts +283 -88
  37. package/src/exec/sandbox-violations.ts +115 -15
  38. package/src/graph/scheduler.ts +30 -14
  39. package/src/graph/task-graph.ts +265 -44
  40. package/src/orchestrator/admission.ts +12 -3
  41. package/src/orchestrator/affected-tasks.ts +6 -2
  42. package/src/orchestrator/deferred-outputs.ts +3 -2
  43. package/src/orchestrator/doctor.ts +2 -2
  44. package/src/orchestrator/events.ts +10 -1
  45. package/src/orchestrator/excluded-keys.ts +2 -13
  46. package/src/orchestrator/execute-task.ts +85 -39
  47. package/src/orchestrator/failure-mode.ts +43 -28
  48. package/src/orchestrator/framed-output.ts +8 -8
  49. package/src/orchestrator/index.ts +0 -1
  50. package/src/orchestrator/keyed-projects.ts +6 -3
  51. package/src/orchestrator/logger.ts +21 -9
  52. package/src/orchestrator/metrics.ts +19 -8
  53. package/src/orchestrator/miss-save.ts +9 -13
  54. package/src/orchestrator/options.ts +7 -0
  55. package/src/orchestrator/plan.ts +7 -23
  56. package/src/orchestrator/plugin.ts +37 -16
  57. package/src/orchestrator/prepare.ts +13 -4
  58. package/src/orchestrator/run-artifacts.ts +4 -3
  59. package/src/orchestrator/run-lock.ts +25 -24
  60. package/src/orchestrator/run.ts +22 -5
  61. package/src/orchestrator/sandbox-request.ts +96 -18
  62. package/src/orchestrator/save-lane.ts +5 -5
  63. package/src/orchestrator/signals.ts +22 -4
  64. package/src/orchestrator/stable-keys.ts +3 -11
  65. package/src/orchestrator/summary.ts +7 -13
  66. package/src/orchestrator/task-hash.ts +13 -3
  67. package/src/orchestrator/telemetry.ts +16 -7
  68. package/src/orchestrator/upstream.ts +11 -0
  69. package/src/util/hangup.ts +42 -0
  70. package/src/util/index.ts +2 -0
  71. package/src/util/paths.ts +24 -4
  72. package/src/workspace/affected.ts +79 -42
  73. package/src/workspace/config-eval.ts +27 -6
  74. package/src/workspace/config-imports.ts +8 -53
  75. package/src/workspace/config-schema.ts +56 -9
  76. package/src/workspace/filter.ts +19 -0
  77. package/src/workspace/lockfile.ts +2 -1
  78. package/src/workspace/migrate-scripts.ts +5 -3
  79. package/src/workspace/migration.ts +1 -1
  80. package/src/workspace/project-loader.ts +19 -4
  81. package/src/workspace/repo-id.ts +42 -4
  82. package/src/workspace/workspace.ts +53 -24
@@ -4,7 +4,7 @@
4
4
  // task's owner can act on — inside the project, minus the loopback denial
5
5
  // no grant can avoid, minus what the task chose to ignore.
6
6
 
7
- import { statSync } from 'node:fs'
7
+ import { existsSync, lstatSync, statSync } from 'node:fs'
8
8
  import path from 'node:path'
9
9
  import { absolutize, atOrUnder, isUnderAny, localBindingOn, toRealPath } from './sandbox-paths.js'
10
10
  import { bindableReads, bindableWrites } from './sandbox-binds.js'
@@ -24,6 +24,7 @@ import type {
24
24
  * <pid> openat(AT_FDCWD, "<path>", <flags>) = -1 ENOENT (...)
25
25
  * <pid> access("<path>", <mode>) = -1 EACCES (...)
26
26
  * <pid> statx(AT_FDCWD, "<path>", <flags>, <mask>, ...) = -1 ENOENT (...)
27
+ * <pid> execve("<path>", ["<argv0>", ...], <envp>) = -1 ENOENT (...)
27
28
  *
28
29
  * …but ONLY when the syscall completes without another traced process
29
30
  * interleaving. Under `-f` strace splits an interrupted call across two
@@ -40,21 +41,26 @@ import type {
40
41
  * (`cStringPath` decodes it). Paths that are relative resolve against the
41
42
  * task's cwd (set by Bun.spawn).
42
43
  */
43
- const SYSCALLS = 'openat|access|statx|newfstatat'
44
+ const SYSCALLS = 'openat|execve|faccessat2|faccessat|access|statx|newfstatat'
44
45
  const QUOTED = '"((?:[^"\\\\]|\\\\.)+)"'
45
46
  // The directory descriptor a call names, `-y`'s path included: taken up to
46
47
  // the `, "` that opens the path argument, since a directory's name may hold
47
48
  // a quote (`4</ws/q"d>`). Captured: a relative path is in that directory.
48
49
  const DIRFD = '(?:AT_FDCWD|\\d+)(?:<(.*?)>)?, '
50
+ // The rest of the call: an `execve`'s argv strings may hold a `)`
51
+ // (`sh -c "f()"`), so it reads to the result at the line's end.
52
+ const ARGS = '.*'
49
53
  const STRACE_DONE_RE = new RegExp(
50
- `^(\\d+)\\s+(${SYSCALLS})\\((?:${DIRFD})?${QUOTED}[^)]*\\)\\s*=\\s*-1\\s+(ENOENT|EACCES|EPERM)`,
54
+ `^(\\d+)\\s+(${SYSCALLS})\\((?:${DIRFD})?${QUOTED}${ARGS}\\)\\s*=\\s*-1\\s+(ENOENT|EACCES|EPERM)`,
51
55
  )
52
56
  const STRACE_UNFINISHED_RE = new RegExp(
53
- `^(\\d+)\\s+(${SYSCALLS})\\((?:${DIRFD})?${QUOTED}[^)]*<unfinished`,
57
+ `^(\\d+)\\s+(${SYSCALLS})\\((?:${DIRFD})?${QUOTED}${ARGS}<unfinished`,
54
58
  )
55
59
  const STRACE_RESUMED_RE = new RegExp(
56
60
  `^(\\d+)\\s+<\\.\\.\\. (${SYSCALLS}) resumed>.*?=\\s*-1\\s+(ENOENT|EACCES|EPERM)`,
57
61
  )
62
+ /** The calls that ask whether a path is there rather than read it. */
63
+ const PROBES = new Set(['execve', 'faccessat2', 'faccessat', 'access', 'statx', 'newfstatat'])
58
64
  /** A resumed call that SUCCEEDED — clears the pending entry, emits nothing. */
59
65
  const STRACE_RESUMED_OK_RE = new RegExp(`^(\\d+)\\s+<\\.\\.\\. (${SYSCALLS}) resumed>`)
60
66
  /**
@@ -352,7 +358,16 @@ export async function parseStraceViolations(
352
358
  ...args.config.allowWrite.filter((w) => bindableWrites([w]).length > 0),
353
359
  ].map((p) => toRealPath(absolutize(p))),
354
360
  )
361
+ // A write glob no mount holds is a write grant too (`refusedWrites`
362
+ // counts its scratch): cp's probe of its destination there failed a task
363
+ // on its own output. One a read bind holds is readable; one outside the
364
+ // workspace, never reported.
365
+ const pendingWrites = (args.config.pendingWrites ?? []).map((g) => new Bun.Glob(g))
355
366
  const denyAnchors = baselines.denyRead.map((p) => toRealPath(absolutize(p)))
367
+ const mounted = new Set([
368
+ ...allowAbs,
369
+ ...bindableWrites(args.config.allowWrite).map((p) => toRealPath(absolutize(p))),
370
+ ])
356
371
  // A read under a widened write grant's directory is never refused, so it
357
372
  // is reported when it succeeds: an entry that was there at the start and
358
373
  // no grant covers is an input the key never saw, and so is the
@@ -377,13 +392,20 @@ export async function parseStraceViolations(
377
392
  baselines.cwd,
378
393
  widened.size > 0,
379
394
  )) {
380
- const abs = toRealPath(absolutize(rawPath, dir ?? baselines.cwd))
395
+ const lexical = absolutize(rawPath, dir ?? baselines.cwd)
396
+ const abs = toRealPath(lexical)
381
397
  // Only report paths under the workspace-root deny anchor — system
382
398
  // libs / /proc / /sys / etc. probes are not interesting violations.
383
399
  if (!denyAnchors.some((root) => atOrUnder(abs, root))) continue
384
400
  // Skip paths the user explicitly allowed (and their descendants).
385
- if (isUnderAny(abs, allowAbs)) continue
401
+ if (isUnderAny(abs, allowAbs) && !hiddenByLink(lexical, abs, mounted, denyAnchors)) continue
402
+ if (underGlob(abs, pendingWrites)) continue
386
403
  if (read === true && !undeclared(abs)) continue
404
+ // A hidden path answers ENOENT, as a missing one does: a probe
405
+ // (`./gen.sh || …`, `test -x`, a PATH walk) is a refusal only where
406
+ // the host has the file.
407
+ if (errno === 'ENOENT' && PROBES.has(syscall) && !lstatSync(abs, { throwIfNoEntry: false }))
408
+ continue
387
409
  const key = `${syscall}|${abs}`
388
410
  if (seen.has(key)) continue
389
411
  seen.add(key)
@@ -401,9 +423,9 @@ export async function parseStraceViolations(
401
423
  timestamp: new Date(),
402
424
  target: abs,
403
425
  path: abs,
404
- // The trace is `-e trace=openat`, and an openat is a read or a
405
- // write depending on flags the trace does not carry — so either
406
- // list can silence it.
426
+ // An openat is a read or a write depending on flags the parse
427
+ // does not keep, and `access` may ask either — so either list
428
+ // can silence it.
407
429
  ignorable: ['read', 'write'],
408
430
  },
409
431
  )
@@ -411,6 +433,39 @@ export async function parseStraceViolations(
411
433
  return out
412
434
  }
413
435
 
436
+ /**
437
+ * A granted file the task could not open because a link on the path it
438
+ * opened is not in the sandbox. A grant is mounted at its real path and
439
+ * bwrap mounts no link, so `read: ['config.json']` over `config.json ->
440
+ * conf/real.json` bound the target and left the name the task opens out;
441
+ * judged by its real path, the ENOENT was granted and dropped, and a tool
442
+ * that fell back on the missing file was cached green. The link is there
443
+ * when the directory holding it is mounted; a target missing on the host
444
+ * is missing outside the sandbox too, and outside the deny anchor the
445
+ * host is mounted, links and all.
446
+ */
447
+ function hiddenByLink(
448
+ lexical: string,
449
+ real: string,
450
+ mounted: Set<string>,
451
+ anchors: readonly string[],
452
+ ): boolean {
453
+ if (lexical === real || !existsSync(real)) return false
454
+ let at = lexical
455
+ const links: string[] = []
456
+ for (let up = path.dirname(at); up !== at; at = up, up = path.dirname(at)) {
457
+ try {
458
+ if (lstatSync(at).isSymbolicLink()) links.push(at)
459
+ } catch {
460
+ // Not on the host by this spelling: no link to judge here.
461
+ }
462
+ }
463
+ return links.some((l) => {
464
+ const holder = toRealPath(path.dirname(l))
465
+ return anchors.some((a) => atOrUnder(holder, a)) && !isUnderAny(holder, mounted)
466
+ })
467
+ }
468
+
414
469
  /**
415
470
  * Does a violation line match something the task said to ignore?
416
471
  *
@@ -418,17 +473,23 @@ export async function parseStraceViolations(
418
473
  * /path/x`, `deny(1) system-info vfs.disk-space` — so the operation picks
419
474
  * the list and the target is matched against its patterns. Anything
420
475
  * unparseable is NOT ignored: a record we cannot classify is exactly the
421
- * one worth seeing.
476
+ * one worth seeing. A file list matches the record's `path`, where it
477
+ * lands, as `read` and `write` patterns are anchored and as the report's
478
+ * own boundary judges it: a seatbelt record named through a link was
479
+ * reported and its pattern never matched.
422
480
  */
423
481
  function matchesIgnore(
424
482
  v: SandboxViolation,
425
483
  ignore: NonNullable<ResolvedSandboxConfig['ignore']>,
426
484
  ): boolean {
427
485
  if (v.target === undefined || v.ignorable === undefined) return false
486
+ const proxy = PROXY_DENY_RE.test(v.line)
428
487
  for (const which of v.ignorable) {
429
- const patterns = ignore[which]
430
- if (patterns === undefined) continue
431
- if (patterns.some((pat) => pat === v.target || new Bun.Glob(pat).match(v.target!))) return true
488
+ const listed = ignore[which]
489
+ if (listed === undefined) continue
490
+ const patterns = proxy ? listed.map(hostKey) : listed
491
+ const subject = which === 'read' || which === 'write' ? (v.path ?? v.target) : v.target
492
+ if (patterns.some((pat) => pat === subject || new Bun.Glob(pat).match(subject))) return true
432
493
  }
433
494
  return false
434
495
  }
@@ -440,7 +501,7 @@ function matchesIgnore(
440
501
  */
441
502
  function describeMacViolation(line: string): Partial<SandboxViolation> {
442
503
  const proxy = PROXY_DENY_RE.exec(line)
443
- if (proxy !== null) return { target: proxy[1]!, ignorable: ['network'] }
504
+ if (proxy !== null) return { target: hostKey(proxy[1]!), ignorable: ['network'] }
444
505
  const m = /deny\(\d+\)\s+(\S+)\s+(.+?)\s*$/.exec(line)
445
506
  if (m === null) return {}
446
507
  const [op, target] = [m[1]!, m[2]!]
@@ -657,6 +718,18 @@ function filterIgnored(
657
718
  */
658
719
  const PROXY_DENY_RE = /^deny network-outbound (\S+) \([^)]*\)$/
659
720
 
721
+ /**
722
+ * `<host>[:<port>]` as the proxy matches it: case-blind, no trailing dot.
723
+ * The record keeps the client's spelling (curl sends `EXAMPLE.Com`, curl
724
+ * and Bun keep `example.com.`), so an exact match against the ignore list
725
+ * left a refusal the task named in its report.
726
+ */
727
+ function hostKey(target: string): string {
728
+ const colon = target.lastIndexOf(':')
729
+ const host = colon === -1 ? target : target.slice(0, colon)
730
+ return host.toLowerCase().replace(/\.$/, '') + (colon === -1 ? '' : target.slice(colon))
731
+ }
732
+
660
733
  /**
661
734
  * Linux: the connections the proxy refused, from the store records. Only
662
735
  * the write observer's records were read there, so a task denied a host
@@ -685,6 +758,8 @@ export function refusedWrites(
685
758
  writable: readonly string[],
686
759
  /** Write globs whose writes land in the sandbox's scratch (`scratchWrites`): granted. */
687
760
  scratch: readonly string[] = [],
761
+ /** What the sandbox mounts readable, canonical: a mkdir of what is there is no write. */
762
+ readable: readonly string[] = [],
688
763
  ): SandboxViolation[] {
689
764
  const binds = new Set(writable.map((w) => toRealPath(absolutize(w))))
690
765
  const globs = scratch.map((g) => new Bun.Glob(g))
@@ -705,7 +780,9 @@ export function refusedWrites(
705
780
  // the bind: `mkdir -p node_modules/.cache/tool` under a grant of
706
781
  // `node_modules/.cache/` met EEXIST on `node_modules`, wrote nothing,
707
782
  // and the attempt failed a clean task (2026-10-03).
708
- if (syscall.startsWith('mkdir') && [...binds].some((b) => atOrUnder(b, abs))) continue
783
+ // So does one a read grant shows, or holds: `mkdir -p src` under
784
+ // `read: ['src']` failed a clean task the same way (X-82).
785
+ if (syscall.startsWith('mkdir') && inSandbox(abs, binds, readable)) continue
709
786
  const key = `${syscall}|${abs}`
710
787
  if (seen.has(key)) continue
711
788
  seen.add(key)
@@ -720,6 +797,29 @@ export function refusedWrites(
720
797
  return out
721
798
  }
722
799
 
800
+ /**
801
+ * `abs` exists inside the sandbox: a write bind lies at or under it (vx
802
+ * made each before the spawn), a read grant that exists does, or a read
803
+ * grant holds it and it exists. A read grant naming nothing is not
804
+ * mounted, so neither it nor its ancestors are there.
805
+ */
806
+ function inSandbox(abs: string, binds: Set<string>, readable: readonly string[]): boolean {
807
+ return (
808
+ [...binds].some((b) => atOrUnder(b, abs)) ||
809
+ readable.some((r) => atOrUnder(r, abs) && exists(r)) ||
810
+ (readable.some((r) => atOrUnder(abs, r)) && exists(abs))
811
+ )
812
+ }
813
+
814
+ function exists(p: string): boolean {
815
+ try {
816
+ lstatSync(p)
817
+ return true
818
+ } catch {
819
+ return false
820
+ }
821
+ }
822
+
723
823
  /** `p`, or a directory holding it, matches one of `globs`. */
724
824
  function underGlob(p: string, globs: readonly InstanceType<typeof Bun.Glob>[]): boolean {
725
825
  if (globs.length === 0) return false
@@ -120,6 +120,12 @@ export interface TaskOutcome {
120
120
  * so a dependant that folds `hash` saves nothing either (A-12).
121
121
  */
122
122
  unkeyed?: true
123
+ /**
124
+ * The run's cache policy read and wrote nothing (`--no-cache`,
125
+ * `--cache=local:`): no cache answered for this task, so a run of it is
126
+ * `no-cache`, not a miss. Set by `run()`; `ranNoCache` reads it.
127
+ */
128
+ cacheOff?: true
123
129
  /**
124
130
  * On a `skipped` outcome: the task at the ROOT of what blocked it — the
125
131
  * failed (or aborted) upstream, followed through any chain of skips
@@ -173,8 +179,8 @@ export interface TaskOutcome {
173
179
  /** The attempts that failed and were run again, in order: when each ended (epoch ms) and how. */
174
180
  failedAttempts?: readonly { endedAt: number; exitCode: number; timedOut?: true }[]
175
181
  /**
176
- * Set when this run proved the task flaky: its key holds both outcomes on
177
- * record (this run's included) or it needed a retry. Its row carries it.
182
+ * Set when this run proved the task flaky: its key failed after it had
183
+ * passed (this run's included) or it needed a retry. Its row carries it.
178
184
  */
179
185
  flaky?: { passes: number; failures: number }
180
186
  /**
@@ -292,9 +298,10 @@ export interface ScheduleOptions {
292
298
  * Admission over the worker count: asked for every exec-tier task about
293
299
  * to start on this machine, with the ids of the exec-tier tasks running
294
300
  * here right now (added on dispatch, so two asks in one tick see each
295
- * other). `false` parks the task until something finishes. Undefined →
296
- * count-only, the legacy path byte for byte. Restore-tier hits and
297
- * pooled tasks hold no local resources and are never asked.
301
+ * other; removed when `execute` resolves, which for a persistent task is
302
+ * at ready). `false` parks the task until something finishes. Undefined →
303
+ * count-only, the legacy path byte for byte. Restore-tier hits, pooled
304
+ * tasks and groups hold no local resources: never asked, never listed.
298
305
  */
299
306
  admit?: (id: string, running: ReadonlySet<string>) => boolean
300
307
  /**
@@ -394,19 +401,26 @@ class ReadyHeap {
394
401
  /**
395
402
  * The dead server a dependency stands for: itself, or one a group reaches.
396
403
  * A group is a name for its deps, and it finished the moment the server
397
- * was ready, so a task behind it ran against the dead one.
404
+ * was ready, so a task behind it ran against the dead one. Walked with a
405
+ * stack and a seen set: recursion threw `RangeError` on a deep chain of
406
+ * groups, and re-walked a group each path reached it by.
398
407
  */
399
408
  export function deadServerBehind(
400
409
  nodes: ReadonlyMap<string, TaskNode>,
401
410
  serverDied: (id: string) => boolean,
402
411
  id: string,
403
412
  ): string | undefined {
404
- if (serverDied(id)) return id
405
- const n = nodes.get(id)
406
- if (n === undefined || !isGroupTask(n)) return undefined
407
- for (const d of n.deps) {
408
- const dead = deadServerBehind(nodes, serverDied, d)
409
- if (dead !== undefined) return dead
413
+ const stack = [id]
414
+ const seen = new Set<string>()
415
+ while (stack.length > 0) {
416
+ const at = stack.pop()!
417
+ if (seen.has(at)) continue
418
+ seen.add(at)
419
+ if (serverDied(at)) return at
420
+ const n = nodes.get(at)
421
+ if (n === undefined || !isGroupTask(n)) continue
422
+ // Reversed, so the first dep is walked first, as the order it names them.
423
+ for (let i = n.deps.length - 1; i >= 0; i--) stack.push(n.deps[i]!)
410
424
  }
411
425
  return undefined
412
426
  }
@@ -582,8 +596,10 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
582
596
  }
583
597
  }
584
598
  // The policy sees exec-tier local tasks only; a restore is a tar
585
- // extract and a pooled task runs on someone else's capacity.
586
- const local = (id: string): boolean => !inRestoreTier(id) && poolOf?.(id) === undefined
599
+ // extract, a pooled task runs on someone else's capacity, and a group
600
+ // runs nothing, so a packing policy that counted one parked work for it.
601
+ const local = (id: string): boolean =>
602
+ !inRestoreTier(id) && poolOf?.(id) === undefined && !isGroupTask(nodes.get(id) as TaskNode)
587
603
  const admits = (id: string): boolean => !local(id) || admitPolicy!(id, running)
588
604
  // With no policy nothing reads `running`, so nothing is tracked: the
589
605
  // count-only dispatch allocates no closure and touches no set per task.