@vzn/vx 0.0.641 → 0.0.642

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 (67) hide show
  1. package/package.json +5 -12
  2. package/skills/vx/SKILL.md +4 -4
  3. package/src/cache/archive.ts +525 -104
  4. package/src/cache/cache.ts +92 -12
  5. package/src/cache/fold-memo.ts +58 -0
  6. package/src/cache/git-inputs.ts +213 -31
  7. package/src/cache/index.ts +6 -2
  8. package/src/cache/inputs.ts +169 -79
  9. package/src/cache/key-fold.ts +90 -69
  10. package/src/cache/layer.ts +2 -6
  11. package/src/cache/layered-cache.ts +68 -4
  12. package/src/cache/output-index.ts +25 -4
  13. package/src/cache/remote-lane.ts +27 -10
  14. package/src/cache/schema.ts +13 -3
  15. package/src/cache/scoped-cache.ts +3 -1
  16. package/src/cache/umask.ts +29 -0
  17. package/src/cli/docs.ts +71 -7
  18. package/src/cli/help.ts +4 -4
  19. package/src/cli/plan-format.ts +4 -1
  20. package/src/cli/run.ts +10 -3
  21. package/src/cli/select.ts +2 -0
  22. package/src/exec/runner.ts +5 -0
  23. package/src/exec/sandbox-runtime.ts +81 -1
  24. package/src/graph/priorities.ts +14 -1
  25. package/src/history/history.ts +43 -33
  26. package/src/index.ts +10 -0
  27. package/src/orchestrator/deferred-outputs.ts +3 -2
  28. package/src/orchestrator/execute-task.ts +20 -14
  29. package/src/orchestrator/hit-restore.ts +10 -2
  30. package/src/orchestrator/index.ts +1 -0
  31. package/src/orchestrator/lockfile-claim.ts +54 -8
  32. package/src/orchestrator/miss-save.ts +27 -17
  33. package/src/orchestrator/output-host.ts +9 -1
  34. package/src/orchestrator/plan.ts +4 -0
  35. package/src/orchestrator/plugin-host.ts +31 -0
  36. package/src/orchestrator/plugin.ts +29 -3
  37. package/src/orchestrator/prepare.ts +74 -5
  38. package/src/orchestrator/projects.ts +6 -1
  39. package/src/orchestrator/run.ts +37 -15
  40. package/src/orchestrator/sandbox-request.ts +1 -48
  41. package/src/orchestrator/save-lane.ts +17 -10
  42. package/src/orchestrator/signals.ts +7 -10
  43. package/src/orchestrator/stable-keys.ts +83 -25
  44. package/src/orchestrator/task-hash.ts +46 -12
  45. package/src/report/events.ts +12 -0
  46. package/src/report/framed-output.ts +2 -1
  47. package/src/report/logger.ts +53 -13
  48. package/src/report/status-line.ts +45 -4
  49. package/src/report/summary.ts +5 -2
  50. package/src/util/errors.ts +1 -1
  51. package/src/util/hidden-prompt.ts +74 -0
  52. package/src/util/index.ts +12 -2
  53. package/src/util/paths.ts +33 -14
  54. package/src/util/secrets.ts +150 -0
  55. package/src/workspace/affected.ts +63 -13
  56. package/src/workspace/config-schema.ts +37 -3
  57. package/src/workspace/project-loader.ts +1 -0
  58. package/src/workspace/workspace.ts +17 -11
  59. package/docs/caching.md +0 -2376
  60. package/docs/cli.md +0 -3285
  61. package/docs/execution.md +0 -676
  62. package/docs/features.md +0 -178
  63. package/docs/patterns.md +0 -219
  64. package/docs/schema.md +0 -2468
  65. package/docs/security.md +0 -253
  66. package/src/cli/docs-corpus.ts +0 -21
  67. package/src/cli/docs-text.d.ts +0 -5
@@ -381,6 +381,36 @@ export async function claimedAffected(
381
381
  return names
382
382
  }
383
383
 
384
+ /**
385
+ * The files a claimed file names that its plugin keys too
386
+ * (`FingerprintClaim.extraFiles`); `undefined` when it names none it can
387
+ * answer for, and a plugin that throws is refused by its name.
388
+ */
389
+ export function claimedExtraFiles(
390
+ plugin: VxPlugin,
391
+ file: string,
392
+ bytes: Uint8Array,
393
+ ): readonly string[] | undefined {
394
+ const read = plugin.fingerprint?.extraFiles
395
+ if (read === undefined) return undefined
396
+ let names: unknown
397
+ try {
398
+ names = read.call(plugin.fingerprint, file, bytes)
399
+ } catch (err) {
400
+ throw new UserError(
401
+ maskedLine(
402
+ `plugin '${plugin.name}' failed in fingerprint: ${err instanceof Error ? err.message : String(err)}`,
403
+ ),
404
+ )
405
+ }
406
+ if (!Array.isArray(names) || names.some((n) => typeof n !== 'string')) {
407
+ throw new UserError(
408
+ `plugin '${plugin.name}' failed in fingerprint: extraFiles returned ${describeValue(names)}, not a list of paths`,
409
+ )
410
+ }
411
+ return names as string[]
412
+ }
413
+
384
414
  /** What a run hands the `schedule` stage: a step per plugin, and where refined weights go. */
385
415
  export interface ScheduleRun {
386
416
  readonly step?: <T>(id: string, work: () => Promise<T>) => Promise<T>
@@ -548,6 +578,7 @@ export async function resolveCache(
548
578
  assertShape(plugin, 'cache', remote, REMOTE_CACHE_METHODS, 'a remote cache layer')
549
579
  const entry: ResolvedRemote = {
550
580
  remote,
581
+ plugin: plugin.name,
551
582
  read: policy.remoteRead,
552
583
  write: policy.remoteWrite,
553
584
  onError: (err) => ctx.warn(`[vx] plugin '${plugin.name}': ${err.message}`),
@@ -20,7 +20,7 @@ import {
20
20
  import { PLUGIN_PACKAGE, type ProjectConfig, type WorkspaceConfig } from '../config.js'
21
21
  import type { TaskExecutor } from '../exec/index.js'
22
22
  import type { TaskNode, TaskOutcome, TaskStatus } from '../graph/index.js'
23
- import { maskedLine, nearest, UserError } from '../util/index.js'
23
+ import { maskedLine, nearest, type PluginSecrets, SecretBroker, UserError } from '../util/index.js'
24
24
  import type { ProjectMeta } from '../workspace/index.js'
25
25
  import type { EventBus, Logger, RunStartInfo } from '../report/index.js'
26
26
  import type { CiContext } from './run-context.js'
@@ -285,8 +285,12 @@ interface BaseContext {
285
285
  warn(message: string): void
286
286
  }
287
287
 
288
- /** What `setup` receives: the run's lifecycle, observe-only. */
289
- export interface PluginSetupContext extends BaseContext {
288
+ /**
289
+ * What `setup` receives: the run's lifecycle, observe-only, and the
290
+ * plugin's keychain (`secret`, `forgetSecret`): setup runs before the
291
+ * status line, so a prompt there is seen.
292
+ */
293
+ export interface PluginSetupContext extends BaseContext, PluginSecrets {
290
294
  /** This plugin's entries in the workspace's cache (`PluginCache`). */
291
295
  readonly cache: PluginCache
292
296
  /** The run is on CI (`CI` set, or a provider core detects). */
@@ -419,6 +423,15 @@ export interface FingerprintClaim {
419
423
  change: FingerprintChange,
420
424
  ctx: FingerprintContext,
421
425
  ): Iterable<string> | undefined | Promise<Iterable<string> | undefined>
426
+ /**
427
+ * The workspace-relative files a claimed file's bytes name that the
428
+ * plugin's key reads too: `bun.lock`'s patch files, which it names by
429
+ * path and hashes nowhere, or a `file:` dependency's directory. A change
430
+ * to one, or under one, asks `affected` about the file naming it, with
431
+ * `change.extras`. Without it, a `bun.lock` patch edit selects every
432
+ * project.
433
+ */
434
+ extraFiles?(file: string, bytes: Uint8Array): readonly string[]
422
435
  }
423
436
 
424
437
  export interface FingerprintChange {
@@ -426,6 +439,15 @@ export interface FingerprintChange {
426
439
  readonly file: string
427
440
  readonly before: Uint8Array | null
428
441
  readonly after: Uint8Array | null
442
+ /**
443
+ * Each changed `extraFiles` entry, as the claimant names it, at the base
444
+ * ref and in the working tree. A directory's sides are not its bytes:
445
+ * they differ because a file under it changed.
446
+ */
447
+ readonly extras?: ReadonlyMap<
448
+ string,
449
+ { readonly before: Uint8Array | null; readonly after: Uint8Array | null }
450
+ >
429
451
  }
430
452
 
431
453
  export interface FingerprintContext extends BaseContext {
@@ -695,6 +717,8 @@ export interface InstallPluginsArgs {
695
717
  ci?: boolean
696
718
  /** Default null. */
697
719
  branch?: string | null
720
+ /** The run's secrets; default: the OS keychain and this terminal. */
721
+ secrets?: SecretBroker
698
722
  /** Each setup as the step `<plugin>:setup`; default: untimed. */
699
723
  step?: <T>(id: string, work: () => Promise<T>) => Promise<T>
700
724
  }
@@ -726,6 +750,7 @@ export class PluginSetupError extends UserError {
726
750
  export async function installPlugins(args: InstallPluginsArgs): Promise<() => void> {
727
751
  const { plugins, bus, workspaceRoot, cacheDir } = args
728
752
  const warn = args.warn ?? ((m) => console.error(m))
753
+ const secrets = args.secrets ?? new SecretBroker(args.ci ?? false, warn)
729
754
  const disposers: Array<() => void> = []
730
755
  // By object, not name: one package can export two plugins.
731
756
  const disabled = new Set<Plugin>()
@@ -754,6 +779,7 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
754
779
  cacheDir,
755
780
  warn,
756
781
  cache: args.cache?.(plugin.name) ?? absentPluginCache,
782
+ ...secrets.for(plugin.name),
757
783
  ci: args.ci ?? false,
758
784
  branch: args.branch ?? null,
759
785
  // A subscription made past the hooks leaves with the run like a
@@ -7,9 +7,10 @@
7
7
  // closes at the bottom of execution, `planRun()` does so via
8
8
  // try/finally around its plan() call.
9
9
 
10
+ import { existsSync } from 'node:fs'
10
11
  import path from 'node:path'
11
12
  import type { WorkspaceConfig } from '../config.js'
12
- import { beginRun, mark, nearest, UserError } from '../util/index.js'
13
+ import { beginRun, mark, nearest, relPosix, type SecretBroker, UserError } from '../util/index.js'
13
14
  import {
14
15
  Cache,
15
16
  type CacheLayer,
@@ -40,6 +41,7 @@ import {
40
41
  cacheScopeEnvError,
41
42
  isCacheScope,
42
43
  type LoadReads,
44
+ PROJECT_CONFIG_FILENAMES,
43
45
  readClaimedFiles,
44
46
  resolveCacheDir,
45
47
  resolveStoreRoot,
@@ -111,6 +113,8 @@ export interface PreparedRun {
111
113
  hasRemoteLayer: boolean
112
114
  /** `--cache-policy` with the workspace's `cacheScope` applied: what the layers were built under. */
113
115
  cachePolicy: CachePolicy
116
+ /** The run's remotes in declared order, as the policy gates each; `openRemotes` turns one off. */
117
+ remotes: ResolvedRemote[]
114
118
  /**
115
119
  * Scheduling priorities from plugins' `schedule` stage (task id → weight,
116
120
  * merged over the structural baseline by the scheduler). Empty when no
@@ -325,16 +329,18 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
325
329
  const readOnly =
326
330
  options.remoteReadOnlyByDefault === true &&
327
331
  !(options.cache === undefined && namesRemote(workspaceConfig?.cache?.policy))
328
- const policy: CachePolicy = readOnly ? { ...asked, remoteWrite: false } : asked
332
+ const wanted: CachePolicy = readOnly ? { ...asked, remoteWrite: false } : asked
329
333
  const localCache = new Cache(
330
334
  cacheDir,
331
- { read: policy.localRead, write: policy.localWrite },
335
+ { read: wanted.localRead, write: wanted.localWrite },
332
336
  workspaceRoot,
333
337
  options.artifactCeiling,
334
338
  'open',
335
339
  await storeRoot,
336
340
  )
337
- localCache.assertWritable()
341
+ const unwritable = localCache.readOnlyNotice()
342
+ if (unwritable !== null) warnOf(log)(`[vx] ${unwritable}`)
343
+ const policy: CachePolicy = unwritable === null ? wanted : { ...wanted, localWrite: false }
338
344
  // The root files the plugins claim (`VxPlugin.fingerprint`), whose bytes
339
345
  // their claimants fold: read now so a task that rewrites one is caught.
340
346
  const fingerprintsAt = Date.now()
@@ -405,6 +411,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
405
411
  // A dependant's `^build` still reaches it, and `lib#build` names it.
406
412
  const requested = expandRequested(tasks, candidateProjects, projects, isDefaultBuild)
407
413
  let unresolvedTasks = unresolvedRequests(tasks, candidateProjects, projects, isDefaultBuild)
414
+ refuseUnlockedConfig(tasks, projects, projectMetas, workspaceRoot)
408
415
  if (options.selectedByDiff === true && unresolvedTasks.some((t) => !t.includes('#'))) {
409
416
  // A name a loaded project declares is no typo, whether or not the load
410
417
  // was whole: an unaffected dependency loaded for the closure declared
@@ -508,7 +515,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
508
515
  // cycle under `vx watch` (item 1029). run() owns both only once this
509
516
  // returns.
510
517
  try {
511
- const hashCache = createHashCache()
518
+ const hashCache = createHashCache(localCache.foldMemo())
512
519
  const fingerprintWatch = new FingerprintWatch(
513
520
  workspaceRoot,
514
521
  claimed,
@@ -527,6 +534,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
527
534
  localCache,
528
535
  hasRemoteLayer,
529
536
  cachePolicy: policy,
537
+ remotes,
530
538
  keepRunEntry: undefined,
531
539
  priorities: () => Promise.resolve(new Map()),
532
540
  branch,
@@ -775,6 +783,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
775
783
  localCache,
776
784
  hasRemoteLayer,
777
785
  cachePolicy: policy,
786
+ remotes,
778
787
  priorities,
779
788
  branch,
780
789
  cacheFor,
@@ -906,3 +915,63 @@ function workspaceCachePolicy(config: WorkspaceConfig | null | undefined): Cache
906
915
  )
907
916
  }
908
917
  }
918
+
919
+ /**
920
+ * A `project#build` naming the default `build` of a project the lock holds
921
+ * no config for, while its directory holds one, asks for that config's task, not
922
+ * one that runs nothing: ran, `c#build` was 0 tasks, exit 0, where a
923
+ * renamed task gets "run `vx lock`" (Growth, lock drift).
924
+ */
925
+ function refuseUnlockedConfig(
926
+ tasks: readonly string[],
927
+ projects: ReadonlyMap<string, ProjectEntry>,
928
+ metas: ReadonlyArray<{ name: string; configPath?: string | null }>,
929
+ workspaceRoot: string,
930
+ ): void {
931
+ for (const spec of tasks) {
932
+ const at = spec.indexOf('#')
933
+ if (at < 0) continue
934
+ const name = spec.slice(0, at)
935
+ const entry = projects.get(name)
936
+ if (entry === undefined || !isDefaultBuild(entry.config.tasks?.[spec.slice(at + 1)])) continue
937
+ const locked = metas.find((m) => m.name === name)?.configPath
938
+ if (typeof locked === 'string' && locked !== '') continue
939
+ const config = PROJECT_CONFIG_FILENAMES.find((n) => existsSync(path.join(entry.dir, n)))
940
+ if (config === undefined) continue
941
+ throw new UserError(
942
+ `${spec}: ${relPosix(workspaceRoot, path.join(entry.dir, config))} is not in the lock yet — run \`vx lock\``,
943
+ )
944
+ }
945
+ }
946
+
947
+ /**
948
+ * Each remote's `open`, in declared order, for the ones the policy lets read
949
+ * or write. One at a time: two that ask on the terminal must not interleave.
950
+ * A throw warns through the remote's `onError` (naming its plugin) and
951
+ * turns that remote off; the run goes on, as after any remote failure.
952
+ * True when that left the run no remote at all.
953
+ */
954
+ export async function openRemotes(
955
+ remotes: readonly ResolvedRemote[],
956
+ ci: boolean,
957
+ secrets: SecretBroker,
958
+ ): Promise<boolean> {
959
+ let closed = false
960
+ for (const entry of remotes) {
961
+ if (entry.remote.open === undefined || !(entry.read || entry.write)) continue
962
+ try {
963
+ await entry.remote.open({
964
+ ci,
965
+ warn: (message) => entry.onError?.(new Error(message)),
966
+ ...secrets.for(entry.plugin ?? 'vx'),
967
+ })
968
+ } catch (err) {
969
+ entry.read = false
970
+ entry.write = false
971
+ const e = err instanceof Error ? err : new Error(String(err))
972
+ entry.onError?.(e)
973
+ closed = true
974
+ }
975
+ }
976
+ return closed && !remotes.some((r) => r.read || r.write)
977
+ }
@@ -127,11 +127,16 @@ export async function discoverProjects(
127
127
  (e) => e.changed,
128
128
  () => null,
129
129
  )
130
+ const byDir = new Map(projects.map((p) => [p.dir, p]))
130
131
  const edges = await applyDiscoverHooks(
131
132
  plugins,
132
133
  projects,
133
134
  { workspaceRoot, cacheDir, warn, worktreeChanges },
134
- (entry, plugin) => discoveredProject(workspaceRoot, projects, entry, plugin.name),
135
+ async (entry, plugin) => {
136
+ const found = await discoveredProject(workspaceRoot, byDir, entry, plugin.name)
137
+ if (found.added) byDir.set(found.meta.dir, found.meta)
138
+ return found
139
+ },
135
140
  )
136
141
  checkProjectNames(workspaceRoot, projects)
137
142
  linkDiscoveredEdges(workspaceRoot, projects, edges)
@@ -49,6 +49,7 @@ import {
49
49
  isUserError,
50
50
  detectColors,
51
51
  type ColorSupport,
52
+ SecretBroker,
52
53
  } from '../util/index.js'
53
54
  import { keyedProjects } from './keyed-projects.js'
54
55
  import { isDefaultBuild } from './projects.js'
@@ -87,7 +88,7 @@ import { isPassStatus } from '../graph/index.js'
87
88
  import type { RunContextRecord } from './telemetry.js'
88
89
  import { plan, type RunPlan } from './plan.js'
89
90
  import type { AffectedReason } from './affected-tasks.js'
90
- import { prepareRun, type PreparedRun } from './prepare.js'
91
+ import { openRemotes, prepareRun, type PreparedRun } from './prepare.js'
91
92
  import { acquireRunLock } from './run-lock.js'
92
93
  import { terminateChildren } from './signals.js'
93
94
  import { runStop } from './run-stop.js'
@@ -466,6 +467,15 @@ async function runOnBus(
466
467
  failures: keepRun.failures,
467
468
  traffic: prepared.localCache.traffic,
468
469
  })
470
+ // Before setup and the status line: a remote's `open` may ask on the terminal.
471
+ const secrets = new SecretBroker(
472
+ ciContext.ci,
473
+ warnOf(log),
474
+ undefined,
475
+ undefined,
476
+ terminal === null ? undefined : () => terminal.holdStatus(),
477
+ )
478
+ const remotesClosed = await openRemotes(prepared.remotes, ciContext.ci, secrets)
469
479
  try {
470
480
  disposePlugins = await installPlugins({
471
481
  plugins: prepared.plugins,
@@ -476,6 +486,7 @@ async function runOnBus(
476
486
  cache: prepared.cacheFor,
477
487
  ci: ciContext.ci,
478
488
  branch: prepared.branch(),
489
+ secrets,
479
490
  step,
480
491
  })
481
492
  } catch (err) {
@@ -592,6 +603,7 @@ async function runOnBus(
592
603
  handsTerminal: holders.size > 0,
593
604
  teardowns: prepared.plugins.filter((p) => p.teardown).length,
594
605
  onStop: () => {
606
+ log.runStop?.()
595
607
  stopRuntimeProbes(hashCache.runtime, hashCache.workspaceRuntime)
596
608
  cache.stopUploads?.()
597
609
  keeper?.stop()
@@ -634,13 +646,15 @@ async function runOnBus(
634
646
  try {
635
647
  const runStartHrTimeNs = process.hrtime.bigint()
636
648
  const endedAtMsAtStart = Date.now()
637
- const remoteCacheEnabled = prepared.hasRemoteLayer
649
+ // Every remote turned off by its `open` is no remote: a task must not clean
650
+ // its outputs for a save that goes nowhere.
651
+ const remoteCacheEnabled = prepared.hasRemoteLayer && !remotesClosed
638
652
  // Normalised ONCE so every consumer — execute-task, the dedup
639
653
  // predicate, the recorded invocation row — reads the policy that
640
654
  // actually governed the run. Reading the raw request here made tasks
641
655
  // clean their outputs before every exec for a save that never
642
656
  // happened.
643
- const policy: CachePolicy = effectiveCachePolicy(prepared.cachePolicy, prepared.hasRemoteLayer)
657
+ const policy: CachePolicy = effectiveCachePolicy(prepared.cachePolicy, remoteCacheEnabled)
644
658
  if (options.remoteRequested === true && !prepared.hasRemoteLayer) {
645
659
  warnOf(log)(
646
660
  'vx: --cache-policy named the remote cache, but no cache plugin in .vx/workspace.* supplies one — the remote axes are off for this run',
@@ -794,6 +808,7 @@ async function runOnBus(
794
808
  },
795
809
  concurrency,
796
810
  workspaceProjectCount,
811
+ runId: undefined as string | undefined,
797
812
  }
798
813
 
799
814
  // Asked only when a plugin declares `output`: a plain run pays nothing.
@@ -810,6 +825,8 @@ async function runOnBus(
810
825
 
811
826
  // Lifecycle hooks drive the default logger's dynamic status line
812
827
  // (TTY-only); custom loggers may ignore them.
828
+ // The status line starts here: a prompt now would be drawn over.
829
+ secrets.closePrompts()
813
830
  log.runStart?.({
814
831
  total: taskCount,
815
832
  concurrency,
@@ -817,18 +834,22 @@ async function runOnBus(
817
834
  context: runContext,
818
835
  startedAtMs: endedAtMsAtStart,
819
836
  })
820
- // The live region's end-of-run forecast. The logger asks only if its
821
- // region is still up a second in, so a warm run never reads history.
822
- terminal?.forecast(async () => {
823
- const ids: string[] = []
824
- for (const n of nodes.values()) if (!isGroupTask(n)) ids.push(n.id)
825
- const p50s = await new LocalHistoryProvider(
826
- prepared.localCache.dbHandle(),
827
- undefined,
828
- prepared.branch(),
829
- ).p50sFor(ids)
830
- return createForecast(nodes, p50s, concurrency)
831
- })
837
+ // The live region's forecast: the graph's from the first free row, and
838
+ // history's only if the region is still up a second in, so a warm run
839
+ // never reads history.
840
+ terminal?.forecast(
841
+ () => createForecast(nodes, new Map(), concurrency),
842
+ async () => {
843
+ const ids: string[] = []
844
+ for (const n of nodes.values()) if (!isGroupTask(n)) ids.push(n.id)
845
+ const p50s = await new LocalHistoryProvider(
846
+ prepared.localCache.dbHandle(),
847
+ undefined,
848
+ prepared.branch(),
849
+ ).p50sFor(ids)
850
+ return createForecast(nodes, p50s, concurrency)
851
+ },
852
+ )
832
853
 
833
854
  // Remote-only: kick off background prefetches so remote-GET latency
834
855
  // overlaps execution. Fire-and-forget — execution starts on the next
@@ -1269,6 +1290,7 @@ async function runOnBus(
1269
1290
  })
1270
1291
  }
1271
1292
  mark('record history')
1293
+ runContext.runId = runId
1272
1294
  // Drain any still-in-flight background prefetches before closing the
1273
1295
  // cache handle — a prefetch ingesting into a closed SQLite DB would
1274
1296
  // throw. Tasks that resolved as local hits never awaited their
@@ -7,7 +7,7 @@ import { existsSync } from 'node:fs'
7
7
  import { lstat, mkdir, readdir, realpath, rm, writeFile } from 'node:fs/promises'
8
8
  import { homedir } from 'node:os'
9
9
  import path from 'node:path'
10
- import type { ExecConfig, SandboxConfig } from '../config.js'
10
+ import type { ExecConfig } from '../config.js'
11
11
  import {
12
12
  bindableWrites,
13
13
  initSandbox,
@@ -289,53 +289,6 @@ export async function sandboxRequestFor(
289
289
  return { sandbox: request, placeholders, withheld: links.withheld }
290
290
  }
291
291
 
292
- /**
293
- * Whether a sandboxed task could read `real` (a canonical path inside its
294
- * project): under one of its read or write grants or its `node_modules`,
295
- * and not behind a wall. vx packs a symlinked output as its target's
296
- * bytes, outside the sandbox, so a link the task planted to a file it was
297
- * denied (`dist/x -> ../.env` under `read: ['src/']`) packed that file
298
- * into the cache wherever creating the link went unseen: seatbelt, or
299
- * Linux untraced. The grants are matched as written, not expanded: a
300
- * glob's hit made by the task itself counts.
301
- */
302
- export function sandboxReadable(
303
- node: TaskNode,
304
- sandbox: SandboxConfig,
305
- workspaceRoot: string,
306
- nested: readonly string[] = [],
307
- ): (real: string) => boolean {
308
- const projectReal = toRealPath(node.projectDir)
309
- const at = (p: string): string =>
310
- p.startsWith('~')
311
- ? path.join(homedir(), p.slice(1))
312
- : path.isAbsolute(p)
313
- ? p
314
- : path.join(projectReal, p)
315
- const grants = [...(sandbox.allow?.read ?? []), ...(sandbox.allow?.write ?? [])].map(at)
316
- const literals = [
317
- ...grants.filter(isMountableLiteral).map((g) => toRealPath(g.replace(/\/+$/, '') || '/')),
318
- toRealPath(path.join(node.projectDir, 'node_modules')),
319
- toRealPath(path.join(workspaceRoot, 'node_modules')),
320
- ]
321
- const globs = grants.filter((g) => !isMountableLiteral(g)).map((g) => new Bun.Glob(g))
322
- const walls = [
323
- ...nested,
324
- path.join(workspaceRoot, '.git'),
325
- path.join(workspaceRoot, '.vx', 'cache'),
326
- ].map(toRealPath)
327
- return (real) => {
328
- if (walls.some((w) => atOrUnder(real, w))) return false
329
- if (literals.some((l) => atOrUnder(real, l))) return true
330
- // A directory a glob matches is granted whole.
331
- for (let p = real; atOrUnder(p, projectReal); p = path.dirname(p)) {
332
- if (globs.some((g) => g.match(p))) return true
333
- if (p === projectReal) break
334
- }
335
- return false
336
- }
337
- }
338
-
339
292
  /**
340
293
  * The sandbox half of the request, plus the empty files vx created so a
341
294
  * literal write grant had something to bind (`placeholders`). They are
@@ -12,22 +12,29 @@
12
12
  export interface SaveLane {
13
13
  /**
14
14
  * Start `save` now if the lane has room, else when one finishes. Resolves
15
- * once the save has settled — a failure is reported and resolves too —
16
- * for the one reader that must wait for the entry: a duplicate of the
17
- * same task in another run (admission's in-flight join).
15
+ * once the save has settled, true when the entry landed — a failure is
16
+ * reported and resolves false — for the readers that must know: the
17
+ * task's outcome, and a duplicate of the same task in another run
18
+ * (admission's in-flight join).
18
19
  */
19
- defer(save: () => Promise<void>): Promise<void>
20
+ defer(save: () => Promise<void>): Promise<boolean>
20
21
  }
21
22
 
22
23
  export function createSaveLane(cap: number, onError: (err: unknown) => void): SaveLane {
23
- const queue: Array<{ save: () => Promise<void>; settle: () => void }> = []
24
+ const queue: Array<{ save: () => Promise<void>; settle: (stored: boolean) => void }> = []
24
25
  let running = 0
25
- const start = (save: () => Promise<void>, settle: () => void): void => {
26
+ const start = (save: () => Promise<void>, settle: (stored: boolean) => void): void => {
26
27
  void save()
27
- .catch(onError)
28
- .then(() => {
28
+ .then(
29
+ () => true,
30
+ (err: unknown) => {
31
+ onError(err)
32
+ return false
33
+ },
34
+ )
35
+ .then((stored) => {
29
36
  running--
30
- settle()
37
+ settle(stored)
31
38
  const next = queue.shift()
32
39
  if (next !== undefined) start(next.save, next.settle)
33
40
  })
@@ -35,7 +42,7 @@ export function createSaveLane(cap: number, onError: (err: unknown) => void): Sa
35
42
  }
36
43
  return {
37
44
  defer(save) {
38
- return new Promise<void>((settle) => {
45
+ return new Promise<boolean>((settle) => {
39
46
  if (running < cap) start(save, settle)
40
47
  else queue.push({ save, settle })
41
48
  })
@@ -126,9 +126,11 @@ export function forwardSignals(args: {
126
126
  const everyChild = (): Child[] => [...args.liveChildren, ...args.persistentRegistry.values()]
127
127
  const exit = (signal: StopSignal): never => {
128
128
  // The run's own end already flushed the log; a second signal cut it
129
- // short, so the failure frames it holds print now (idempotent).
129
+ // short, so the failure frames it holds print now (idempotent), and
130
+ // the live region goes so a terminal keeps no frozen one.
130
131
  try {
131
132
  args.log.runEnd?.()
133
+ args.log.runDone?.()
132
134
  } catch {
133
135
  // teardown must not throw on the way out
134
136
  }
@@ -158,15 +160,10 @@ export function forwardSignals(args: {
158
160
  if (stopping !== undefined) exit(stopping)
159
161
  stopping = signal
160
162
  claimExitForSignal()
161
- // Clear the live worker/status region BEFORE exiting so a TTY isn't
162
- // left with a frozen region in the scrollback. Only the region: the
163
- // tasks this stop aborts still list as rows, above the failure frames
164
- // the run's own end prints.
165
- try {
166
- args.log.runDone?.()
167
- } catch {
168
- // teardown must not throw on the way out
169
- }
163
+ // The live region stays: the stop marks its rows `stopping` (run.ts's
164
+ // onStop), and the run's own end replaces it with the footer. Cleared
165
+ // at once, it left a blank screen for as long as the stop took (M,
166
+ // 2026-10-17).
170
167
  args.stop(signal)
171
168
  void settleWithin(args.done, args.boundMs)
172
169
  // The run's output may still be in the pipe: `process.exit` drops