@vzn/vx 0.0.594 → 0.0.596

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 (80) hide show
  1. package/README.md +1 -1
  2. package/package.json +5 -5
  3. package/src/bin.ts +26 -11
  4. package/src/cache/archive.ts +2 -2
  5. package/src/cache/cache.ts +235 -174
  6. package/src/cache/chained-cache.ts +25 -7
  7. package/src/cache/config-evals.ts +19 -4
  8. package/src/cache/git-inputs.ts +19 -8
  9. package/src/cache/index.ts +1 -0
  10. package/src/cache/inputs.ts +122 -49
  11. package/src/cache/layer.ts +7 -5
  12. package/src/cache/layered-cache.ts +25 -2
  13. package/src/cache/policy.ts +5 -0
  14. package/src/cache/tar-stream.ts +1 -1
  15. package/src/cache/zstd.ts +10 -4
  16. package/src/cli/cache.ts +11 -15
  17. package/src/cli/completions.ts +42 -6
  18. package/src/cli/foreign-flags.ts +26 -5
  19. package/src/cli/help.ts +5 -3
  20. package/src/cli/init.ts +1 -1
  21. package/src/cli/last.ts +4 -1
  22. package/src/cli/plan-format.ts +9 -0
  23. package/src/cli/plugin-templates.ts +2 -2
  24. package/src/cli/run.ts +60 -42
  25. package/src/cli/select.ts +30 -3
  26. package/src/cli/show.ts +42 -3
  27. package/src/cli/watch-filter.ts +77 -13
  28. package/src/cli/watch-fs.ts +15 -7
  29. package/src/cli/watch-judge.ts +16 -9
  30. package/src/cli/watch.ts +22 -15
  31. package/src/config.ts +26 -26
  32. package/src/exec/env.ts +5 -0
  33. package/src/exec/index.ts +1 -0
  34. package/src/exec/proc-sample.ts +6 -2
  35. package/src/exec/runner.ts +98 -40
  36. package/src/exec/sandbox-paths.ts +12 -6
  37. package/src/exec/sandbox-runtime.ts +136 -44
  38. package/src/exec/sandbox-violations.ts +58 -11
  39. package/src/graph/index.ts +1 -0
  40. package/src/graph/scheduler.ts +29 -13
  41. package/src/graph/task-graph.ts +134 -25
  42. package/src/index.ts +6 -0
  43. package/src/orchestrator/admission.ts +12 -3
  44. package/src/orchestrator/events.ts +10 -1
  45. package/src/orchestrator/execute-task.ts +65 -20
  46. package/src/orchestrator/failure-mode.ts +8 -2
  47. package/src/orchestrator/framed-output.ts +3 -3
  48. package/src/orchestrator/hit-restore.ts +6 -3
  49. package/src/orchestrator/local-shortcircuit.ts +4 -1
  50. package/src/orchestrator/lockfile-claim.ts +43 -46
  51. package/src/orchestrator/logger.ts +11 -6
  52. package/src/orchestrator/metrics.ts +19 -8
  53. package/src/orchestrator/miss-save.ts +2 -2
  54. package/src/orchestrator/options.ts +7 -0
  55. package/src/orchestrator/plan.ts +28 -4
  56. package/src/orchestrator/plugin-host.ts +25 -3
  57. package/src/orchestrator/plugin.ts +39 -17
  58. package/src/orchestrator/prepare.ts +13 -4
  59. package/src/orchestrator/run-artifacts.ts +2 -1
  60. package/src/orchestrator/run.ts +72 -12
  61. package/src/orchestrator/sandbox-request.ts +65 -9
  62. package/src/orchestrator/save-lane.ts +5 -5
  63. package/src/orchestrator/signals.ts +5 -2
  64. package/src/orchestrator/summary.ts +16 -5
  65. package/src/orchestrator/telemetry.ts +16 -7
  66. package/src/util/index.ts +7 -1
  67. package/src/util/paths.ts +23 -3
  68. package/src/util/secret-mask.ts +36 -0
  69. package/src/util/settle.ts +15 -0
  70. package/src/workspace/affected.ts +30 -8
  71. package/src/workspace/config-cache.ts +25 -4
  72. package/src/workspace/config-imports.ts +8 -6
  73. package/src/workspace/config-schema.ts +74 -9
  74. package/src/workspace/filter.ts +19 -0
  75. package/src/workspace/migrate-scripts.ts +5 -3
  76. package/src/workspace/migration.ts +1 -1
  77. package/src/workspace/package-graph.ts +15 -3
  78. package/src/workspace/project-loader.ts +76 -2
  79. package/src/workspace/repo-id.ts +42 -4
  80. package/src/workspace/workspace.ts +98 -20
@@ -9,8 +9,8 @@
9
9
  //
10
10
  // The digests are memoised on disk under the cache dir by the file's
11
11
  // xxh3, so a warm run pays one read + hash + a small JSON read, never a
12
- // parse; within one process (a run, a watch cycle) the file's size and
13
- // mtime gate even the read; and per run the read happens once (a WeakMap
12
+ // parse; within one process (a run, a watch cycle) the file's size,
13
+ // mtime, ctime and inode gate even the read; and per run the read happens once (a WeakMap
14
14
  // on the context core hands every `key` call of a run).
15
15
 
16
16
  import path from 'node:path'
@@ -87,18 +87,33 @@ async function hashExtra(workspaceRoot: string, rel: string): Promise<string> {
87
87
  }
88
88
  }
89
89
 
90
- async function statsOf(
90
+ /**
91
+ * A file's identity for the in-process gate, as `Cache.hashFile`'s memo
92
+ * keys one: size and mtime alone kept the digests of a lockfile or patch
93
+ * replaced by another of the same size with its mtime kept (`cp -p`,
94
+ * `tar -x`), and no task sets a ctime.
95
+ */
96
+ function identityOf(st: { size: number; mtimeMs: number; ctimeMs: number; ino: number }): string {
97
+ return `${st.size}:${st.mtimeMs}:${st.ctimeMs}:${st.ino}`
98
+ }
99
+
100
+ async function statsOf(workspaceRoot: string, rels: readonly string[]): Promise<string[]> {
101
+ return Promise.all(
102
+ rels.map((rel) => stat(path.join(workspaceRoot, rel)).then(identityOf, () => 'gone')),
103
+ )
104
+ }
105
+
106
+ /** Each file's identity, taken BEFORE its hash, so a write between the two re-reads it. */
107
+ async function hashExtras(
91
108
  workspaceRoot: string,
92
109
  rels: readonly string[],
93
- ): Promise<Array<{ size: number; mtimeMs: number }>> {
94
- return Promise.all(
95
- rels.map((rel) =>
96
- stat(path.join(workspaceRoot, rel)).then(
97
- (st) => ({ size: st.size, mtimeMs: st.mtimeMs }),
98
- () => ({ size: -1, mtimeMs: -1 }),
99
- ),
100
- ),
110
+ ): Promise<{ hashes: Record<string, string>; stats: Record<string, string> }> {
111
+ const ids = await statsOf(workspaceRoot, rels)
112
+ const stats = Object.fromEntries(rels.map((rel, i) => [rel, ids[i]!] as const))
113
+ const hashes = Object.fromEntries(
114
+ await Promise.all(rels.map(async (rel) => [rel, await hashExtra(workspaceRoot, rel)] as const)),
101
115
  )
116
+ return { hashes, stats }
102
117
  }
103
118
 
104
119
  export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks {
@@ -108,15 +123,14 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
108
123
  if (scope !== 'project' && scope !== 'workspace') {
109
124
  throw new Error(`scope must be 'project' or 'workspace', not ${JSON.stringify(scope)}`)
110
125
  }
111
- // Per workspace root: what the last read saw. `size` + `mtimeMs` gate a
112
- // re-read; the content hash decides whether the digests are current.
126
+ // Per workspace root: what the last read saw. Each file's identity gates
127
+ // a re-read; the content hash decides whether the digests are current.
113
128
  const seen = new Map<
114
129
  string,
115
130
  {
116
- size: number
117
- mtimeMs: number
131
+ identity: string
118
132
  extras: readonly string[]
119
- extraStats: ReadonlyArray<{ size: number; mtimeMs: number }>
133
+ extraStats: Readonly<Record<string, string>>
120
134
  digests: Digests
121
135
  }
122
136
  >()
@@ -125,23 +139,18 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
125
139
 
126
140
  const load = async (workspaceRoot: string, cacheDir: string): Promise<Digests> => {
127
141
  const full = path.join(workspaceRoot, file)
128
- let st: { size: number; mtimeMs: number }
142
+ let identity: string
129
143
  try {
130
- st = await stat(full)
144
+ identity = identityOf(await stat(full))
131
145
  } catch {
132
146
  const digests: Digests = { lock: '', importers: new Map() }
133
- seen.set(workspaceRoot, { size: -1, mtimeMs: -1, extras: [], extraStats: [], digests })
147
+ seen.set(workspaceRoot, { identity: 'gone', extras: [], extraStats: {}, digests })
134
148
  return digests
135
149
  }
136
150
  const last = seen.get(workspaceRoot)
137
- if (last !== undefined && last.size === st.size && last.mtimeMs === st.mtimeMs) {
151
+ if (last !== undefined && last.identity === identity) {
138
152
  const now = await statsOf(workspaceRoot, last.extras)
139
- if (
140
- now.every(
141
- (s, i) =>
142
- s.size === last.extraStats[i]!.size && s.mtimeMs === last.extraStats[i]!.mtimeMs,
143
- )
144
- ) {
153
+ if (now.every((s, i) => s === last.extraStats[last.extras[i]!])) {
145
154
  return last.digests
146
155
  }
147
156
  }
@@ -152,28 +161,22 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
152
161
  // re-hashes the files the memo names and never parses to learn them.
153
162
  const memo = await readMemo(memoFile, version, lockHash)
154
163
  let extras: Record<string, string> = {}
164
+ let extraStats: Record<string, string> = {}
155
165
  let importers: ReadonlyMap<string, string> | undefined
156
166
  if (memo !== undefined) {
157
- const now = Object.fromEntries(
158
- await Promise.all(
159
- Object.keys(memo.extras).map(
160
- async (rel) => [rel, await hashExtra(workspaceRoot, rel)] as const,
161
- ),
162
- ),
163
- )
164
- if (Object.entries(memo.extras).every(([rel, h]) => now[rel] === h)) {
167
+ const now = await hashExtras(workspaceRoot, Object.keys(memo.extras))
168
+ if (Object.entries(memo.extras).every(([rel, h]) => now.hashes[rel] === h)) {
165
169
  extras = memo.extras
170
+ extraStats = now.stats
166
171
  importers = memo.importers
167
172
  }
168
173
  }
169
174
  if (importers === undefined) {
170
175
  const text = decode(bytes)
171
176
  const rels = [...new Set(options.extraFiles?.(text) ?? [])].sort()
172
- extras = Object.fromEntries(
173
- await Promise.all(
174
- rels.map(async (rel) => [rel, await hashExtra(workspaceRoot, rel)] as const),
175
- ),
176
- )
177
+ const now = await hashExtras(workspaceRoot, rels)
178
+ extras = now.hashes
179
+ extraStats = now.stats
177
180
  importers =
178
181
  scope === 'workspace'
179
182
  ? new Map<string, string>()
@@ -191,13 +194,7 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
191
194
  ? lockHash
192
195
  : xxh3hex([lockHash, ...rels.map((r) => `${r}\0${extras[r]}`)].join('\0'))
193
196
  const digests: Digests = { lock, importers }
194
- seen.set(workspaceRoot, {
195
- size: st.size,
196
- mtimeMs: st.mtimeMs,
197
- extras: rels,
198
- extraStats: await statsOf(workspaceRoot, rels),
199
- digests,
200
- })
197
+ seen.set(workspaceRoot, { identity, extras: rels, extraStats, digests })
201
198
  return digests
202
199
  }
203
200
  const loadOnce = (ctx: KeyHookContext): Promise<Digests> => {
@@ -23,7 +23,7 @@ import { formatDuration, formatSummarySection, neverStarted, type RunContext } f
23
23
  import { isGroupTask } from '../graph/index.js'
24
24
  import { appendTail, createTail, resetTail, tailText, type Tail } from '../util/index.js'
25
25
  import { isCacheHit } from './telemetry.js'
26
- import { failedLabel, outcomeWord } from './events.js'
26
+ import { failedLabel, outcomeWord, ranNoCache } from './events.js'
27
27
 
28
28
  export interface Logger {
29
29
  /** Header / footer / status text. Written verbatim, one trailing \n added. */
@@ -139,8 +139,7 @@ function ghaFence(body: string, token: string): string {
139
139
  return `::stop-commands::${token}\n${body}::${token}::\n`
140
140
  }
141
141
 
142
- const cacheWordOf = (n: TaskNode): 'miss' | 'no-cache' =>
143
- n.config.cache === undefined ? 'no-cache' : 'miss'
142
+ const cacheWordOf = (o: TaskOutcome): 'miss' | 'no-cache' => (ranNoCache(o) ? 'no-cache' : 'miss')
144
143
 
145
144
  export function resolveOutputView(
146
145
  options: {
@@ -440,7 +439,7 @@ export function defaultLogger(
440
439
  (view.mode === 'focused' && isPrimary(node) && !isGroupTask(node) && requestedCount <= 1)
441
440
 
442
441
  const failureRow = (node: TaskNode, outcome: TaskOutcome): string =>
443
- formatFailureLine(node.id, outcome.durationMs, colors, cacheWordOf(node)) +
442
+ formatFailureLine(node.id, outcome.durationMs, colors, cacheWordOf(outcome)) +
444
443
  flakyNote(outcome, colors)
445
444
 
446
445
  return {
@@ -453,6 +452,12 @@ export function defaultLogger(
453
452
  // A kept server's held partial line is older than this line: held
454
453
  // past it, a server's last words printed below its own exit notice.
455
454
  flushKept()
455
+ // A live-streamed server keeps writing after its frame closes, so a
456
+ // summary row or "exited with code" can follow its partial line.
457
+ if (streamMidLine) {
458
+ writer.write('\n')
459
+ streamMidLine = false
460
+ }
456
461
  writer.write(`${line}\n`)
457
462
  },
458
463
  runStart(info) {
@@ -654,7 +659,7 @@ export function defaultLogger(
654
659
  done++
655
660
  if (outcome.status === 'failed') {
656
661
  failed++
657
- if (node.config.cache === undefined) noCache++
662
+ if (ranNoCache(outcome)) noCache++
658
663
  } else if (node.config.exec?.persistent !== undefined && outcome.status === 'success') {
659
664
  // A persistent task's outcome arrives at READY; the child
660
665
  // keeps running until the orchestrator SIGTERMs it at run
@@ -678,7 +683,7 @@ export function defaultLogger(
678
683
  switch (outcome.status) {
679
684
  case 'success':
680
685
  succeeded++
681
- if (node.config.cache === undefined) noCache++
686
+ if (ranNoCache(outcome)) noCache++
682
687
  break
683
688
  case 'cache-hit':
684
689
  if (outcome.restored === false) upToDate++
@@ -407,11 +407,18 @@ function unchangedKeyNote(
407
407
  at: number
408
408
  } | null
409
409
  )?.at
410
- // Both runs executed the task and succeeded with the cache writable, no
411
- // entry holds the key, and each ran beside a failure: the continue-taint
412
- // (admission.ts), which runs a task downstream of a failure and never
413
- // saves it. Blaming `--no-cache` / `--force` named a flag nobody passed.
414
- if (createdAt === undefined && this_.status === 'success' && prev.status === 'success') {
410
+ // The previous run executed the task and succeeded with the cache
411
+ // writable beside a failure, and no entry older than this run holds the
412
+ // key: the continue-taint (admission.ts), which runs a task downstream of
413
+ // a failure and never saves it. Blaming `--no-cache` / `--force` named a
414
+ // flag nobody passed, and a prune when this run saved the key named an
415
+ // eviction that never happened.
416
+ if (
417
+ (createdAt === undefined || createdAt >= this_.startedAt) &&
418
+ this_.status === 'success' &&
419
+ prev.status === 'success' &&
420
+ prev.cacheHit === 0
421
+ ) {
415
422
  const failedWithWrites = (id: string | null): number => {
416
423
  if (id === null) return 0
417
424
  const row = db
@@ -420,9 +427,13 @@ function unchangedKeyNote(
420
427
  const writes = row?.p.split(',').some((axis) => axis === 'lW' || axis === 'rW') === true
421
428
  return writes ? (row?.f ?? 0) : 0
422
429
  }
423
- const now = failedWithWrites(runId)
424
- if (prev.cacheHit === 0 && now > 0 && failedWithWrites(prev.runId) > 0) {
425
- return `cache key unchanged — neither run saved it: each ran beside a failed task (${now} failed in this one), and a task run past a failed dependency (--continue) is never cached`
430
+ const before = failedWithWrites(prev.runId)
431
+ if (before > 0) {
432
+ const now = failedWithWrites(runId)
433
+ if (createdAt === undefined && now > 0) {
434
+ return `cache key unchanged — neither run saved it: each ran beside a failed task (${now} failed in this one), and a task run past a failed dependency (--continue) is never cached`
435
+ }
436
+ return `cache key unchanged — the previous run on this key ran beside a failed task (${before} failed in it) and was not saved: a task run past a failed dependency (--continue) is never cached, so there was nothing to hit`
426
437
  }
427
438
  }
428
439
  if (createdAt !== undefined && createdAt >= this_.startedAt) {
@@ -70,8 +70,8 @@ export interface SaveMissArgs {
70
70
  nestedProjectDirs: string[]
71
71
  gitFilesCache?: GitFilesCache | undefined
72
72
  /** Declared `cache.outputs.files` / `.workspaceFiles`. */
73
- outputs: string[]
74
- wsOutputs: string[]
73
+ outputs: readonly string[]
74
+ wsOutputs: readonly string[]
75
75
  /**
76
76
  * An ADDITIVE task's own set (item 588): the files its run added or
77
77
  * changed under its declared outputs, resolved by execute-task against
@@ -171,6 +171,13 @@ export interface RunOptions {
171
171
  * else is treated as the literal file path (cwd-relative).
172
172
  */
173
173
  summarize?: string
174
+ /**
175
+ * Text printed just above the run's footer, given the outcomes the
176
+ * footer counts, after the `summaryTable`: nothing prints below the
177
+ * footer (owner), so a caller's end-of-run report (`--report`) rides
178
+ * here. Not called on a refused run.
179
+ */
180
+ beforeFooter?: (outcomes: readonly TaskOutcome[], ok: boolean) => string
174
181
  /**
175
182
  * If set, write a Chrome-trace JSON profile of the run's wallclock
176
183
  * spans. Path is cwd-relative. Default `profile.json` is selected
@@ -12,8 +12,15 @@
12
12
  // side-effect-free pure probes.
13
13
 
14
14
  import type { CacheLayer, CachePolicy, GitFilesCache } from '../cache/index.js'
15
- import { FULL_CACHE_POLICY } from '../cache/index.js'
16
- import { isGroupTask, runGraph, type TaskNode, type TaskOutcome } from '../graph/index.js'
15
+ import { cachesNothing, FULL_CACHE_POLICY } from '../cache/index.js'
16
+ import { ranNoCache } from './events.js'
17
+ import {
18
+ idleServers,
19
+ isGroupTask,
20
+ runGraph,
21
+ type TaskNode,
22
+ type TaskOutcome,
23
+ } from '../graph/index.js'
17
24
  import type { HistoryProvider } from './history.js'
18
25
  import { computeGroupKey, computeTaskHash } from './task-hash.js'
19
26
  import { keyUpstream } from './upstream.js'
@@ -24,6 +31,7 @@ export type CacheStatus =
24
31
  | 'miss' // caching enabled but no entry — would execute
25
32
  | 'no-cache' // task opts out of caching (no `cache` block) or --no-cache
26
33
  | 'group' // no `exec`; just an aggregator
34
+ | 'not-started' // a server every dependant of which restores early: the run never spawns it
27
35
 
28
36
  export interface PlannedTask {
29
37
  node: TaskNode
@@ -108,6 +116,12 @@ export interface PlanArgs {
108
116
  history?: HistoryProvider
109
117
  /** Placement lookup (`placement.ts`): >1 executor declared, or a `noop` task. */
110
118
  executorOf?: (id: string) => string | undefined
119
+ /**
120
+ * The tasks the run's short-circuit would restore ahead of their deps on
121
+ * a local hit (stable key, no workspace-output reach). A server whose
122
+ * every dependant is one of those and a local hit is never spawned.
123
+ */
124
+ restorable?: ReadonlySet<string>
111
125
  }
112
126
 
113
127
  /**
@@ -156,9 +170,8 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
156
170
  // policy-aware cache layer, which itself respects read gating.
157
171
  const policy = args.cachePolicy ?? FULL_CACHE_POLICY
158
172
  const reads = policy.localRead || policy.remoteRead
159
- const writes = policy.localWrite || policy.remoteWrite
160
173
  let status: CacheStatus
161
- if (node.config.cache === undefined || (!reads && !writes)) {
174
+ if (ranNoCache({ node, cacheOff: cachesNothing(policy) })) {
162
175
  status = 'no-cache'
163
176
  } else if (!reads) {
164
177
  status = 'miss'
@@ -171,6 +184,17 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
171
184
  },
172
185
  })
173
186
 
187
+ if (args.restorable !== undefined) {
188
+ const restoreTier = new Set(
189
+ [...args.restorable].filter((id) => cacheStatusById.get(id) === 'hit-local'),
190
+ )
191
+ const dependents = new Map<string, string[]>()
192
+ for (const node of args.nodes.values())
193
+ for (const dep of node.deps) dependents.set(dep, [...(dependents.get(dep) ?? []), node.id])
194
+ for (const id of idleServers(args.nodes, dependents, restoreTier))
195
+ cacheStatusById.set(id, 'not-started')
196
+ }
197
+
174
198
  const tasks: PlannedTask[] = []
175
199
  for (const id of args.nodes.keys()) {
176
200
  const o = outcomes.get(id)
@@ -107,6 +107,25 @@ export function hasHook(
107
107
  return false
108
108
  }
109
109
 
110
+ /**
111
+ * An in-place stage reads nothing a hook returns: Vite's `config` hook
112
+ * returns a partial config, and one written that way here loaded, changed
113
+ * nothing and said nothing. Returning the object it was handed is harmless.
114
+ */
115
+ function assertEditedInPlace(
116
+ plugin: VxPlugin,
117
+ hook: 'config' | 'project' | 'graph',
118
+ returned: unknown,
119
+ edited: unknown,
120
+ what: string,
121
+ ): void {
122
+ if (returned === undefined || returned === edited) return
123
+ throw new UserError(
124
+ `plugin '${plugin.name}' failed in ${hook}: returned ${describeValue(returned)}, which core ` +
125
+ `ignores — edit ${what} in place`,
126
+ )
127
+ }
128
+
110
129
  /** `config` stage: every plugin edits the workspace config in place. */
111
130
  export async function applyConfigHooks(
112
131
  plugins: readonly VxPlugin[],
@@ -117,7 +136,8 @@ export async function applyConfigHooks(
117
136
  ): Promise<void> {
118
137
  for (const plugin of plugins) {
119
138
  if (plugin.config === undefined) continue
120
- await safe(plugin, 'config', () => plugin.config!(workspace, ctx))
139
+ const returned = await safe(plugin, 'config', () => plugin.config!(workspace, ctx))
140
+ assertEditedInPlace(plugin, 'config', returned, workspace, 'the workspace config')
121
141
  afterEach?.(plugin)
122
142
  }
123
143
  }
@@ -158,7 +178,8 @@ export async function applyProjectHooks(
158
178
  ): Promise<void> {
159
179
  for (const plugin of plugins) {
160
180
  if (plugin.project === undefined) continue
161
- await safe(plugin, 'project', () => plugin.project!(config, ctx))
181
+ const returned = await safe(plugin, 'project', () => plugin.project!(config, ctx))
182
+ assertEditedInPlace(plugin, 'project', returned, config, "the project's config")
162
183
  afterEach?.(plugin)
163
184
  }
164
185
  }
@@ -184,7 +205,8 @@ export async function applyGraphHooks(
184
205
  let last: VxPlugin | undefined
185
206
  for (const plugin of plugins) {
186
207
  if (plugin.graph === undefined) continue
187
- await safe(plugin, 'graph', () => plugin.graph!(nodes, ctx))
208
+ const returned = await safe(plugin, 'graph', () => plugin.graph!(nodes, ctx))
209
+ assertEditedInPlace(plugin, 'graph', returned, nodes, 'the task graph')
188
210
  await safe(plugin, 'graph', () => checkNodeShapes(nodes))
189
211
  afterEach?.(plugin)
190
212
  last = plugin
@@ -142,9 +142,9 @@ export interface VxPlugin {
142
142
  * many times per run, so it must be cheap and synchronous; a throw, or
143
143
  * an answer that is a Promise, is reported once and the plugin admits
144
144
  * from then on — a policy never
145
- * breaks a run. Restore-tier hits and tasks on an executor pool hold no
146
- * local resources and are never asked. When several plugins answer, all
147
- * must admit. Core keeps no notion of what a task needs: a plugin that
145
+ * breaks a run. Restore-tier hits, tasks on an executor pool and groups
146
+ * hold no local resources: never asked, never in `ctx.running`. When
147
+ * several plugins answer, all must admit. Core keeps no notion of what a task needs: a plugin that
148
148
  * packs memory or CPU learns or declares the numbers itself
149
149
  * (`@vzn/vx-schedule-history` packs what past executions used).
150
150
  */
@@ -341,7 +341,12 @@ export interface FingerprintContext extends BaseContext {
341
341
  }
342
342
 
343
343
  export interface AdmitContext {
344
- /** The tasks executing on this machine right now, in dispatch order. */
344
+ /**
345
+ * The tasks holding a worker on this machine right now, in dispatch
346
+ * order. A persistent task leaves at ready, when it gives its worker
347
+ * back: it runs until the graph ends, so a policy that counted it would
348
+ * hold its dependants with no completion left to ask again.
349
+ */
345
350
  readonly running: readonly TaskNode[]
346
351
  /** The run's worker count — the ceiling the count gate already applies. */
347
352
  readonly concurrency: number
@@ -440,7 +445,8 @@ function pluginPackageName(dir: string): string {
440
445
  for (let d = dir; ;) {
441
446
  let text: string | undefined
442
447
  try {
443
- text = readFileSync(path.join(d, 'package.json'), 'utf8')
448
+ // A byte-order mark is stripped, as npm, Node and discovery strip it.
449
+ text = readFileSync(path.join(d, 'package.json'), 'utf8').replace(/^\uFEFF/, '')
444
450
  } catch {
445
451
  /* not here; look one level up */
446
452
  }
@@ -600,7 +606,8 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
600
606
  const { plugins, bus, workspaceRoot, cacheDir } = args
601
607
  const warn = args.warn ?? ((m) => console.error(m))
602
608
  const disposers: Array<() => void> = []
603
- const disabled = new Set<string>()
609
+ // By object, not name: one package can export two plugins.
610
+ const disabled = new Set<Plugin>()
604
611
 
605
612
  for (const plugin of plugins) {
606
613
  if (typeof plugin.name !== 'string' || plugin.name.length === 0) {
@@ -614,6 +621,13 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
614
621
  throw new UserError(`plugin '${plugin.name}' setup is not a function`)
615
622
  }
616
623
 
624
+ const fail = (where: string, err: unknown): void => {
625
+ if (disabled.has(plugin)) return
626
+ disabled.add(plugin)
627
+ warn(
628
+ `[vx] plugin '${plugin.name}' threw in ${where}; disabled for this run: ${err instanceof Error ? err.message : String(err)}`,
629
+ )
630
+ }
617
631
  const ctx: PluginContext = {
618
632
  workspaceRoot,
619
633
  cacheDir,
@@ -624,7 +638,18 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
624
638
  bus: {
625
639
  emit: (event) => bus.emit(event),
626
640
  subscribe(subscriber) {
627
- const dispose = bus.subscribe(subscriber)
641
+ // Isolated like an `on` handler: the bus itself swallows a throw.
642
+ const dispose = bus.subscribe((event) => {
643
+ if (disabled.has(plugin)) return
644
+ let ret: unknown
645
+ try {
646
+ ret = subscriber(event)
647
+ } catch (err) {
648
+ fail('a bus subscriber', err)
649
+ return
650
+ }
651
+ if (ret instanceof Promise) ret.catch((err: unknown) => fail('a bus subscriber', err))
652
+ })
628
653
  disposers.push(dispose)
629
654
  return dispose
630
655
  },
@@ -637,15 +662,9 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
637
662
  `ctx.on: unknown hook '${String(hook)}' (one of ${Object.keys(HOOK_NAMES).join(', ')})`,
638
663
  )
639
664
  }
640
- const fail = (err: unknown): void => {
641
- if (disabled.has(plugin.name)) return
642
- disabled.add(plugin.name)
643
- warn(
644
- `[vx] plugin '${plugin.name}' threw in ${hook}; disabled for this run: ${err instanceof Error ? err.message : String(err)}`,
645
- )
646
- }
665
+ const onFail = (err: unknown): void => fail(hook, err)
647
666
  const dispose = bus.subscribe((event) => {
648
- if (disabled.has(plugin.name)) return
667
+ if (disabled.has(plugin)) return
649
668
  // Not awaited: the bus is synchronous and plugin work runs off the
650
669
  // critical path. A handler's rejection is caught like its throw.
651
670
  let ret: unknown
@@ -680,10 +699,10 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
680
699
  break
681
700
  }
682
701
  } catch (err) {
683
- fail(err)
702
+ onFail(err)
684
703
  return
685
704
  }
686
- if (ret instanceof Promise) ret.catch(fail)
705
+ if (ret instanceof Promise) ret.catch(onFail)
687
706
  })
688
707
  disposers.push(dispose)
689
708
  },
@@ -692,6 +711,9 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
692
711
  try {
693
712
  await plugin.setup(ctx)
694
713
  } catch (err) {
714
+ // Earlier plugins' subscriptions too: the caller never gets the
715
+ // disposer, and a bus that outlives the run would keep them.
716
+ for (const d of disposers) d()
695
717
  throw new PluginSetupError(
696
718
  plugin,
697
719
  `plugin '${plugin.name}' failed in setup: ${err instanceof Error ? err.message : String(err)}`,
@@ -368,8 +368,11 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
368
368
  ? options.projects.filter((p) => projects.has(p))
369
369
  : [...projects.keys()]
370
370
 
371
- const requested = expandRequested(tasks, candidateProjects, projects)
372
- let unresolvedTasks = unresolvedRequests(tasks, candidateProjects, projects)
371
+ // A bare name skips the default `build`: it runs nothing, and matched,
372
+ // `vx run build` in a project declaring none ran 0 tasks, exit 0 (X-102).
373
+ // A dependant's `^build` still reaches it, and `lib#build` names it.
374
+ const requested = expandRequested(tasks, candidateProjects, projects, isDefaultBuild)
375
+ let unresolvedTasks = unresolvedRequests(tasks, candidateProjects, projects, isDefaultBuild)
373
376
  if (options.selectedByDiff === true && unresolvedTasks.some((t) => !t.includes('#'))) {
374
377
  // A name a loaded project declares is no typo, whether or not the load
375
378
  // was whole: an unaffected dependency loaded for the closure declared
@@ -750,14 +753,20 @@ async function declaredNowhere(
750
753
  return undeclaredIn(unresolved, all.projects)
751
754
  }
752
755
 
753
- /** The bare names in `unresolved` no project in `projects` declares; `a#b` forms pass through. */
756
+ /**
757
+ * The bare names in `unresolved` no project in `projects` declares; `a#b`
758
+ * forms pass through. Every project has a `build`, its default one if
759
+ * nothing declares it, and a bare name does not select that (X-102).
760
+ */
754
761
  function undeclaredIn(
755
762
  unresolved: readonly string[],
756
763
  projects: LoadedProjects['projects'],
757
764
  ): string[] {
758
765
  const declared = new Set<string>()
759
766
  for (const p of projects.values()) {
760
- for (const t of Object.keys(p.config.tasks ?? {})) declared.add(t)
767
+ for (const [t, task] of Object.entries(p.config.tasks ?? {})) {
768
+ if (!isDefaultBuild(task)) declared.add(t)
769
+ }
761
770
  }
762
771
  return unresolved.filter((t) => t.includes('#') || !declared.has(t))
763
772
  }
@@ -18,6 +18,7 @@ import { isGroupTask, type TaskOutcome } from '../graph/index.js'
18
18
  import { tallyOutcomes, type Tally } from './tally.js'
19
19
  import type { FlakyFinding } from './failure-mode.js'
20
20
  import { isCacheHit } from './telemetry.js'
21
+ import { ranNoCache } from './events.js'
21
22
 
22
23
  export interface SummarizeArgs {
23
24
  /** Empty string → default path; otherwise the explicit file path. */
@@ -92,7 +93,7 @@ function taskEntry(o: TaskOutcome, flaky?: FlakyFinding): SummaryTaskJson {
92
93
  // the flag a consumer computing a hit rate cannot tell a miss from a
93
94
  // task that could never hit (its `hash` is still set — dependents fold
94
95
  // it). Present only when true, so every other row is byte-identical.
95
- ...(o.node.config.cache === undefined ? { noCache: true } : {}),
96
+ ...(ranNoCache(o) ? { noCache: true } : {}),
96
97
  // Present only when the run proved the task flaky: this exact key has
97
98
  // both passed and failed on record (this run counted), or the task
98
99
  // needed a retry. A consumer gating on `failed` can tell a break from a