@vzn/vx 0.0.652 → 0.0.653

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 (167) hide show
  1. package/README.md +2 -2
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +1 -0
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +25 -3
  8. package/src/cache/archive.ts +196 -31
  9. package/src/cache/cache.ts +863 -225
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +22 -5
  16. package/src/cache/index.ts +10 -1
  17. package/src/cache/inputs.ts +573 -152
  18. package/src/cache/key-fold.ts +1 -1
  19. package/src/cache/layer.ts +23 -10
  20. package/src/cache/layered-cache.ts +28 -7
  21. package/src/cache/output-index.ts +106 -44
  22. package/src/cache/plugin-cache.ts +2 -2
  23. package/src/cache/policy.ts +6 -2
  24. package/src/cache/remote-lane.ts +3 -1
  25. package/src/cache/run-history.ts +50 -10
  26. package/src/cache/schema.ts +42 -9
  27. package/src/cli/cache.ts +147 -34
  28. package/src/cli/completions.ts +2 -2
  29. package/src/cli/core-alias.ts +9 -2
  30. package/src/cli/docs.ts +4 -2
  31. package/src/cli/help.ts +25 -2
  32. package/src/cli/index.ts +38 -7
  33. package/src/cli/info.ts +4 -3
  34. package/src/cli/init.ts +55 -20
  35. package/src/cli/last.ts +184 -41
  36. package/src/cli/lock.ts +187 -24
  37. package/src/cli/plan-format.ts +24 -21
  38. package/src/cli/plugin-commands.ts +6 -1
  39. package/src/cli/plugin-templates.ts +1 -1
  40. package/src/cli/run.ts +68 -34
  41. package/src/cli/select.ts +160 -64
  42. package/src/cli/show.ts +63 -18
  43. package/src/cli/task-verb.ts +12 -7
  44. package/src/cli/upgrade.ts +210 -76
  45. package/src/cli/watch-filter.ts +88 -15
  46. package/src/cli/watch-fs.ts +34 -47
  47. package/src/cli/watch-judge.ts +72 -8
  48. package/src/cli/watch-set.ts +6 -4
  49. package/src/cli/watch.ts +179 -25
  50. package/src/cli/why.ts +107 -33
  51. package/src/cli/workspace-config.ts +11 -1
  52. package/src/config.ts +9 -0
  53. package/src/exec/env.ts +3 -1
  54. package/src/exec/executor.ts +76 -15
  55. package/src/exec/kill-tree.ts +7 -7
  56. package/src/exec/runner.ts +270 -108
  57. package/src/exec/sandbox-binds.ts +109 -18
  58. package/src/exec/sandbox-runtime.ts +134 -37
  59. package/src/exec/sandbox-violations.ts +250 -46
  60. package/src/exec/spawn-off.ts +10 -5
  61. package/src/exec/warm-shell.ts +29 -7
  62. package/src/graph/folds.ts +89 -16
  63. package/src/graph/index.ts +8 -0
  64. package/src/graph/priorities.ts +8 -5
  65. package/src/graph/scheduler.ts +173 -38
  66. package/src/graph/stand-ins.ts +36 -0
  67. package/src/graph/task-graph.ts +327 -141
  68. package/src/graph/task-status.ts +5 -1
  69. package/src/handoff.ts +45 -10
  70. package/src/history/failure-mode.ts +15 -5
  71. package/src/history/history.ts +103 -30
  72. package/src/history/index.ts +1 -0
  73. package/src/history/metrics.ts +189 -69
  74. package/src/index.ts +2 -1
  75. package/src/orchestrator/admission.ts +13 -9
  76. package/src/orchestrator/affected-tasks.ts +35 -13
  77. package/src/orchestrator/command-windows.ts +8 -5
  78. package/src/orchestrator/deferred-outputs.ts +41 -12
  79. package/src/orchestrator/doctor.ts +21 -9
  80. package/src/orchestrator/download-policy.ts +3 -3
  81. package/src/orchestrator/excluded-keys.ts +14 -2
  82. package/src/orchestrator/execute-args.ts +9 -2
  83. package/src/orchestrator/execute-task.ts +564 -123
  84. package/src/orchestrator/graph-memo.ts +26 -4
  85. package/src/orchestrator/graph-stage.ts +27 -4
  86. package/src/orchestrator/held-servers.ts +36 -14
  87. package/src/orchestrator/hit-restore.ts +136 -87
  88. package/src/orchestrator/index.ts +4 -1
  89. package/src/orchestrator/keyed-projects.ts +25 -0
  90. package/src/orchestrator/local-floor.ts +21 -3
  91. package/src/orchestrator/lockfile-claim.ts +30 -4
  92. package/src/orchestrator/miss-save.ts +33 -53
  93. package/src/orchestrator/options.ts +13 -0
  94. package/src/orchestrator/output-host.ts +16 -4
  95. package/src/orchestrator/output-log.ts +1 -54
  96. package/src/orchestrator/persistent-task.ts +31 -17
  97. package/src/orchestrator/persistent.ts +152 -15
  98. package/src/orchestrator/placement.ts +81 -15
  99. package/src/orchestrator/plan.ts +37 -15
  100. package/src/orchestrator/plugin-host.ts +232 -108
  101. package/src/orchestrator/plugin.ts +53 -27
  102. package/src/orchestrator/prepare.ts +194 -38
  103. package/src/orchestrator/projects.ts +89 -10
  104. package/src/orchestrator/read-only.ts +115 -0
  105. package/src/orchestrator/run-context.ts +6 -2
  106. package/src/orchestrator/run-failures.ts +43 -14
  107. package/src/orchestrator/run-id.ts +13 -2
  108. package/src/orchestrator/run-keys.ts +87 -28
  109. package/src/orchestrator/run-live.ts +16 -13
  110. package/src/orchestrator/run-lock.ts +27 -4
  111. package/src/orchestrator/run-records.ts +12 -10
  112. package/src/orchestrator/run-report.ts +2 -1
  113. package/src/orchestrator/run-stop.ts +17 -9
  114. package/src/orchestrator/run.ts +443 -107
  115. package/src/orchestrator/sandbox-fix.ts +40 -12
  116. package/src/orchestrator/sandbox-request.ts +286 -59
  117. package/src/orchestrator/save-lane.ts +24 -13
  118. package/src/orchestrator/shell-verdict.ts +20 -1
  119. package/src/orchestrator/signals.ts +103 -8
  120. package/src/orchestrator/stable-keys.ts +17 -9
  121. package/src/orchestrator/task-artifacts.ts +15 -2
  122. package/src/orchestrator/task-hash.ts +187 -30
  123. package/src/orchestrator/telemetry-host.ts +25 -6
  124. package/src/orchestrator/telemetry.ts +80 -12
  125. package/src/orchestrator/upstream.ts +3 -1
  126. package/src/report/events.ts +20 -3
  127. package/src/report/forecast.ts +103 -37
  128. package/src/report/framed-output.ts +165 -157
  129. package/src/report/index.ts +2 -1
  130. package/src/report/logger.ts +107 -30
  131. package/src/report/plain-output.ts +13 -9
  132. package/src/report/status-line.ts +28 -55
  133. package/src/report/summary.ts +57 -14
  134. package/src/settings.ts +42 -6
  135. package/src/ui/components.ts +36 -42
  136. package/src/ui/index.ts +3 -1
  137. package/src/ui/live-region.ts +67 -8
  138. package/src/ui/terminal.ts +12 -0
  139. package/src/ui/theme.ts +16 -1
  140. package/src/util/config-frame.ts +17 -3
  141. package/src/util/edit-distance.ts +17 -1
  142. package/src/util/errors.ts +23 -7
  143. package/src/util/foreign-cache-dir.ts +60 -0
  144. package/src/util/git-ignored.ts +59 -5
  145. package/src/util/index.ts +15 -1
  146. package/src/util/paths.ts +50 -6
  147. package/src/util/plain-text.ts +19 -0
  148. package/src/util/secret-mask.ts +57 -1
  149. package/src/util/settle.ts +16 -0
  150. package/src/util/size.ts +12 -2
  151. package/src/util/write-file.ts +86 -9
  152. package/src/workspace/affected.ts +225 -27
  153. package/src/workspace/config-bytecode.ts +23 -4
  154. package/src/workspace/config-eval.ts +90 -16
  155. package/src/workspace/config-imports.ts +96 -41
  156. package/src/workspace/config-schema.ts +126 -25
  157. package/src/workspace/dependencies-lock.ts +7 -14
  158. package/src/workspace/filter.ts +10 -1
  159. package/src/workspace/index.ts +3 -1
  160. package/src/workspace/json-data.ts +34 -8
  161. package/src/workspace/lockfile.ts +86 -37
  162. package/src/workspace/migration.ts +46 -25
  163. package/src/workspace/module-state.ts +62 -0
  164. package/src/workspace/nested-dirs.ts +8 -0
  165. package/src/workspace/project-loader.ts +276 -32
  166. package/src/workspace/repo-id.ts +29 -6
  167. package/src/workspace/workspace.ts +190 -30
@@ -46,6 +46,7 @@ import type {
46
46
  } from './plugin.js'
47
47
  import type { ProjectMeta } from '../workspace/index.js'
48
48
  import { hashExtra } from './lockfile-claim.js'
49
+ import { readOnly, readOnlyNodes } from './read-only.js'
49
50
 
50
51
  /** `a string`, `an array`, `null`, `a number` — for a refusal that names what came back. */
51
52
  function describeValue(v: unknown): string {
@@ -99,18 +100,61 @@ async function safe<T>(plugin: VxPlugin, hook: string, fn: () => T | Promise<T>)
99
100
  try {
100
101
  return await fn()
101
102
  } catch (err) {
102
- // Masked here, not by whoever prints it: an embedder of `run()` and
103
- // a plugin's verb print it as they get it, and the throw quotes what the
104
- // plugin was told (a remote's reply, a header) (L-11).
105
- // A plugin's own code survives (cli.md § Error codes): an agent
106
- // branches on it.
107
- throw new UserError(
108
- maskedLine(`plugin '${plugin.name}' failed in ${hook}: ${thrownMessage(err)}`),
109
- errorCode(err) === 'VX_E_INTERNAL' ? 'VX_E_REFUSED' : errorCode(err),
110
- )
103
+ throw pluginFailure(plugin, hook, err)
111
104
  }
112
105
  }
113
106
 
107
+ /**
108
+ * Reads and checks what a hook answered, in the plugin's name as `safe`
109
+ * runs the hook: a getter or Proxy trap in the answer that threw crashed
110
+ * vx with a raw stack. Core's own refusals pass as they are. What `read`
111
+ * returns is what the caller uses, so a getter cannot answer the check one
112
+ * value and the run another.
113
+ */
114
+ function reading<T>(plugin: VxPlugin, hook: string, read: () => T): T {
115
+ try {
116
+ return read()
117
+ } catch (err) {
118
+ if (err instanceof UserError) throw err
119
+ throw pluginFailure(plugin, hook, err)
120
+ }
121
+ }
122
+
123
+ function pluginFailure(plugin: VxPlugin, hook: string, err: unknown): UserError {
124
+ // Masked here, not by whoever prints it: an embedder of `run()` and
125
+ // a plugin's verb print it as they get it, and the throw quotes what the
126
+ // plugin was told (a remote's reply, a header) (L-11).
127
+ // A plugin's own code survives (cli.md § Error codes): an agent
128
+ // branches on it.
129
+ // A plugin's bug (a TypeError, not a message it chose to throw) names
130
+ // where it threw: the message alone left its author no file or line.
131
+ const at = isBug(err) ? pluginThrowSite(err) : undefined
132
+ return new UserError(
133
+ maskedLine(
134
+ `plugin '${plugin.name}' failed in ${hook}: ${thrownMessage(err)}${at === undefined ? '' : ` (at ${at})`}`,
135
+ ),
136
+ errorCode(err) === 'VX_E_INTERNAL' ? 'VX_E_REFUSED' : errorCode(err),
137
+ )
138
+ }
139
+
140
+ const VX_SRC = `${path.resolve(import.meta.dir, '..')}${path.sep}`
141
+
142
+ const isBug = (err: unknown): err is Error =>
143
+ err instanceof TypeError ||
144
+ err instanceof ReferenceError ||
145
+ err instanceof RangeError ||
146
+ err instanceof SyntaxError
147
+
148
+ /** The first stack frame outside vx's own source, relative to the cwd when under it. */
149
+ function pluginThrowSite(err: Error): string | undefined {
150
+ const site = [...String(err.stack ?? '').matchAll(/^\s+at (?:.*\()?(\/[^()\n]+:\d+:\d+)\)?$/gm)]
151
+ .map((m) => m[1]!)
152
+ .find((s) => !s.startsWith(VX_SRC))
153
+ if (site === undefined) return undefined
154
+ const rel = path.relative(process.cwd(), site)
155
+ return rel.startsWith('..') ? site : rel
156
+ }
157
+
114
158
  // --- Pipeline stages ---------------------------------------------------------
115
159
  //
116
160
  // Each stage hands plugins the object core is about to use, in declaration
@@ -156,7 +200,9 @@ export async function applyConfigHooks(
156
200
  for (const plugin of plugins) {
157
201
  if (plugin.config === undefined) continue
158
202
  const returned = await safe(plugin, 'config', () => plugin.config!(workspace, ctx))
159
- assertEditedInPlace(plugin, 'config', returned, workspace, 'the workspace config')
203
+ reading(plugin, 'config', () =>
204
+ assertEditedInPlace(plugin, 'config', returned, workspace, 'the workspace config'),
205
+ )
160
206
  afterEach?.(plugin)
161
207
  }
162
208
  }
@@ -180,13 +226,18 @@ export async function applyDiscoverHooks(
180
226
  const edges: DiscoveredEdges[] = []
181
227
  for (const plugin of plugins) {
182
228
  if (plugin.discover === undefined) continue
183
- const named = await safe(plugin, 'discover', () => plugin.discover!({ ...ctx, projects }))
184
- if (!Array.isArray(named)) {
185
- throw new UserError(
186
- `plugin '${plugin.name}' failed in discover: expected an array, got ${describeValue(named)}`,
187
- )
188
- }
189
- for (const entry of named as readonly DiscoveredProject[]) {
229
+ const answer: unknown = await safe(plugin, 'discover', () =>
230
+ plugin.discover!({ ...ctx, projects: readOnly(projects) }),
231
+ )
232
+ const named = reading(plugin, 'discover', () => {
233
+ if (!Array.isArray(answer)) {
234
+ throw new UserError(
235
+ `plugin '${plugin.name}' failed in discover: expected an array, got ${describeValue(answer)}`,
236
+ )
237
+ }
238
+ return Array.from(answer as unknown[], plainEntry)
239
+ })
240
+ for (const entry of named) {
190
241
  const { meta, added } = await add(entry, plugin)
191
242
  if (added) projects.push(meta)
192
243
  if (entry.deps !== undefined || entry.reach !== undefined) {
@@ -197,6 +248,22 @@ export async function applyDiscoverHooks(
197
248
  return edges
198
249
  }
199
250
 
251
+ /**
252
+ * A `discover` entry's fields, each read once: deps that read as a list
253
+ * to the check and as 5 after it crashed the edge link. `add` checks the
254
+ * copy's shape.
255
+ */
256
+ function plainEntry(entry: unknown): DiscoveredProject {
257
+ if (typeof entry !== 'object' || entry === null) return entry as DiscoveredProject
258
+ const { dir, name, deps, reach } = entry as Record<string, unknown>
259
+ return {
260
+ dir,
261
+ name,
262
+ deps: Array.isArray(deps) ? [...(deps as unknown[])] : deps,
263
+ reach: Array.isArray(reach) ? [...(reach as unknown[])] : reach,
264
+ } as DiscoveredProject
265
+ }
266
+
200
267
  export interface DiscoveredEdges {
201
268
  readonly by: string
202
269
  readonly meta: ProjectMeta
@@ -216,7 +283,9 @@ export async function applyProjectHooks(
216
283
  for (const plugin of plugins) {
217
284
  if (plugin.project === undefined) continue
218
285
  const returned = await safe(plugin, 'project', () => plugin.project!(config, ctx))
219
- assertEditedInPlace(plugin, 'project', returned, config, "the project's config")
286
+ reading(plugin, 'project', () =>
287
+ assertEditedInPlace(plugin, 'project', returned, config, "the project's config"),
288
+ )
220
289
  afterEach?.(plugin)
221
290
  }
222
291
  }
@@ -244,7 +313,9 @@ export async function applyGraphHooks(
244
313
  for (const plugin of plugins) {
245
314
  if (plugin.graph === undefined) continue
246
315
  const returned = await safe(plugin, 'graph', () => plugin.graph!(nodes, ctx))
247
- assertEditedInPlace(plugin, 'graph', returned, nodes, 'the task graph')
316
+ reading(plugin, 'graph', () =>
317
+ assertEditedInPlace(plugin, 'graph', returned, nodes, 'the task graph'),
318
+ )
248
319
  await safe(plugin, 'graph', () => checkNodeShapes(nodes))
249
320
  afterEach?.(plugin)
250
321
  last = plugin
@@ -310,21 +381,25 @@ export async function applyKeyHooks(
310
381
  ): Promise<void> {
311
382
  for (const node of nodes.values()) {
312
383
  const parts: Array<readonly [string, string]> = []
384
+ const task = readOnly(node)
313
385
  for (const plugin of plugins) {
314
386
  if (plugin.key === undefined) continue
315
- const material = await safe(plugin, 'key', () => plugin.key!(node, ctx))
387
+ const material = await safe(plugin, 'key', () => plugin.key!(task, ctx))
316
388
  if (material === undefined) continue
317
- // `Object.entries` over a string yields its characters as string
318
- // values, so a plugin returning `'v22'` used to fold parts named
319
- // '0', '1', '2' into every key — silently, and permanently. A Map or a
320
- // class instance keeps its data off own enumerable keys, so
321
- // `Object.entries` read it as no material at all: a silent miskey.
322
- if (!isRecord(material)) {
323
- throw new UserError(
324
- `plugin '${plugin.name}' failed in key: returned ${describeValue(material)}, not a record of string values`,
325
- )
326
- }
327
- for (const [name, value] of Object.entries(material)) {
389
+ const entries = reading(plugin, 'key', () => {
390
+ // `Object.entries` over a string yields its characters as string
391
+ // values, so a plugin returning `'v22'` used to fold parts named
392
+ // '0', '1', '2' into every key — silently, and permanently. A Map or a
393
+ // class instance keeps its data off own enumerable keys, so
394
+ // `Object.entries` read it as no material at all: a silent miskey.
395
+ if (!isRecord(material)) {
396
+ throw new UserError(
397
+ `plugin '${plugin.name}' failed in key: returned ${describeValue(material)}, not a record of string values`,
398
+ )
399
+ }
400
+ return Object.entries(material)
401
+ })
402
+ for (const [name, value] of entries) {
328
403
  if (typeof value !== 'string') {
329
404
  throw new UserError(
330
405
  `plugin '${plugin.name}' failed in key: value for '${name}' on ${node.id} is not a string`,
@@ -421,19 +496,30 @@ export async function claimedDigests(
421
496
  digests.call(plugin.fingerprint, file, ctx),
422
497
  )
423
498
  if (answer === undefined) return undefined
424
- const { hash, projects, extras } = (answer ?? {}) as Partial<FingerprintDigests>
425
- if (
426
- typeof hash !== 'string' ||
427
- typeof projects !== 'object' ||
428
- projects === null ||
429
- Object.values(projects).some((d) => typeof d !== 'string') ||
430
- (extras !== undefined &&
431
- (!Array.isArray(extras) || extras.some((rel) => typeof rel !== 'string')))
432
- ) {
433
- throw new UserError(
434
- `plugin '${plugin.name}' failed in fingerprint: digests returned ${describeValue(answer)}, not { hash, projects: { name: digest }, extras?: [path] }`,
435
- )
436
- }
499
+ const { hash, projects, extras } = reading(plugin, 'fingerprint', () => {
500
+ const read = (answer ?? {}) as Partial<Record<keyof FingerprintDigests, unknown>>
501
+ const hash = read.hash
502
+ const projects = read.projects
503
+ const digests =
504
+ typeof projects === 'object' && projects !== null ? Object.entries(projects) : undefined
505
+ const extras = Array.isArray(read.extras) ? [...(read.extras as unknown[])] : read.extras
506
+ if (
507
+ typeof hash !== 'string' ||
508
+ digests === undefined ||
509
+ digests.some(([, d]) => typeof d !== 'string') ||
510
+ (extras !== undefined &&
511
+ (!Array.isArray(extras) || extras.some((rel) => typeof rel !== 'string')))
512
+ ) {
513
+ throw new UserError(
514
+ `plugin '${plugin.name}' failed in fingerprint: digests returned ${describeValue(answer)}, not { hash, projects: { name: digest }, extras?: [path] }`,
515
+ )
516
+ }
517
+ return {
518
+ hash,
519
+ projects: Object.fromEntries(digests) as FingerprintDigests['projects'],
520
+ extras: extras as string[] | undefined,
521
+ }
522
+ })
437
523
  const known = new Set(ctx.projects.map((p) => p.name))
438
524
  const stray = Object.keys(projects).find((n) => !known.has(n))
439
525
  if (stray !== undefined) {
@@ -454,24 +540,26 @@ async function projectNames(
454
540
  ctx: FingerprintContext,
455
541
  ): Promise<Set<string> | undefined> {
456
542
  if (answer === undefined) return undefined
457
- // A plain object is no list: `for…of` threw "is not iterable", naming
458
- // neither the plugin nor the hook.
459
- if (
460
- typeof answer === 'string' ||
461
- typeof answer !== 'object' ||
462
- answer === null ||
463
- typeof (answer as Partial<Iterable<unknown>>)[Symbol.iterator] !== 'function'
464
- ) {
465
- throw new UserError(
466
- `plugin '${plugin.name}' failed in fingerprint: returned ${describeValue(answer)}, not a list of project names`,
467
- )
468
- }
543
+ // A lazy list (a generator) runs the plugin's code as it is read.
544
+ const read = reading(plugin, 'fingerprint', () => {
545
+ // A plain object is no list: `for…of` threw "is not iterable", naming
546
+ // neither the plugin nor the hook.
547
+ if (
548
+ typeof answer === 'string' ||
549
+ typeof answer !== 'object' ||
550
+ answer === null ||
551
+ typeof (answer as Partial<Iterable<unknown>>)[Symbol.iterator] !== 'function'
552
+ ) {
553
+ throw new UserError(
554
+ `plugin '${plugin.name}' failed in fingerprint: returned ${describeValue(answer)}, not a list of project names`,
555
+ )
556
+ }
557
+ return [...(answer as Iterable<unknown>)]
558
+ })
469
559
  const names = new Set<string>()
470
560
  // A name no project carries selected nothing, and the tasks it meant to
471
561
  // select did not run, with no word said.
472
562
  const known = new Set(ctx.projects.map((p) => p.name))
473
- // A lazy list (a generator) runs the plugin's code as it is read.
474
- const read = await safe(plugin, 'fingerprint', () => [...(answer as Iterable<unknown>)])
475
563
  for (const name of read) {
476
564
  if (typeof name !== 'string') {
477
565
  throw new UserError(
@@ -545,6 +633,9 @@ export async function applyScheduleHooks(
545
633
  const refining = async (): Promise<void> => {
546
634
  while (promised.size > 0) await Promise.all(promised)
547
635
  }
636
+ // The keys are derived by now: a node a hook edited ran under the key of
637
+ // the one declared.
638
+ const viewed = readOnlyNodes(nodes)
548
639
  const merged = (): ReadonlyMap<string, number> => {
549
640
  const out = new Map<string, number>()
550
641
  for (const set of sets) if (set !== undefined) for (const [id, w] of set) out.set(id, w)
@@ -591,12 +682,13 @@ export async function applyScheduleHooks(
591
682
  const flaky = (taskIds: Iterable<string>): void => run.flaky?.(taskIds)
592
683
  const own = { ...ctx, cache: cacheFor(plugin.name), refine, onRefine, refining, flaky }
593
684
  const call = (): Promise<ReadonlyMap<string, number> | undefined> =>
594
- safe(plugin, 'schedule', () => plugin.schedule!(nodes, own))
685
+ safe(plugin, 'schedule', () => plugin.schedule!(viewed, own))
595
686
  const weights = await (run.step === undefined
596
687
  ? call()
597
688
  : run.step(`${plugin.name}:schedule`, call))
598
689
  // A refine that landed while the call ran is the newer set.
599
- if (weights !== undefined) sets[i] ??= scheduleWeights(plugin, weights, nodes)
690
+ if (weights !== undefined)
691
+ sets[i] ??= reading(plugin, 'schedule', () => scheduleWeights(plugin, weights, nodes))
600
692
  }
601
693
  return merged()
602
694
  }
@@ -653,9 +745,13 @@ export function buildAdmission(
653
745
  // The scheduler asks only about ids in this same map, so the lookup
654
746
  // always lands: an unknown-id arm here was deleted with the whole core
655
747
  // suite green (item 651).
656
- const task = nodes.get(id)!
748
+ // Read-only: the key is derived, and a hook's edit ran under it.
749
+ const task = readOnly(nodes.get(id)!)
657
750
  const ctx = {
658
- running: [...running].flatMap((r) => nodes.get(r) ?? []),
751
+ running: [...running].flatMap((r) => {
752
+ const node = nodes.get(r)
753
+ return node === undefined ? [] : [readOnly(node)]
754
+ }),
659
755
  concurrency,
660
756
  }
661
757
  for (const plugin of answering) {
@@ -726,18 +822,20 @@ export async function resolveCache(
726
822
  lentBy === undefined ? base : { ...base, lent: () => lentBy()?.[plugin.name] }
727
823
  const remote = await safe(plugin, 'cache', () => plugin.cache!(ctx))
728
824
  if (remote === undefined) continue
729
- assertShape(plugin, 'cache', remote, REMOTE_CACHE_METHODS, 'a remote cache layer')
730
- // `hasMany: true` failed every batch probe, and `missing: true` every
731
- // upload, each said per call as the remote's own fault.
732
- const odd = OPTIONAL_REMOTE_METHODS.filter(
733
- (m) => remote[m] !== undefined && typeof remote[m] !== 'function',
734
- )
735
- if (odd.length > 0) {
736
- throw new UserError(
737
- `plugin '${plugin.name}' returned from cache something that is not a remote cache layer: ` +
738
- `${odd.join(', ')} ${odd.length === 1 ? 'not a function' : 'not functions'}`,
825
+ reading(plugin, 'cache', () => {
826
+ assertShape(plugin, 'cache', remote, REMOTE_CACHE_METHODS, 'a remote cache layer')
827
+ // `hasMany: true` failed every batch probe, and `missing: true` every
828
+ // upload, each said per call as the remote's own fault.
829
+ const odd = OPTIONAL_REMOTE_METHODS.filter(
830
+ (m) => remote[m] !== undefined && typeof remote[m] !== 'function',
739
831
  )
740
- }
832
+ if (odd.length > 0) {
833
+ throw new UserError(
834
+ `plugin '${plugin.name}' returned from cache something that is not a remote cache layer: ` +
835
+ `${odd.join(', ')} ${odd.length === 1 ? 'not a function' : 'not functions'}`,
836
+ )
837
+ }
838
+ })
741
839
  const entry: ResolvedRemote = {
742
840
  remote,
743
841
  plugin: plugin.name,
@@ -894,6 +992,7 @@ const EXECUTOR_HOOKS = [
894
992
  'advice',
895
993
  'profile',
896
994
  'running',
995
+ 'unneeded',
897
996
  ] as const
898
997
 
899
998
  /**
@@ -911,46 +1010,71 @@ export async function resolveExecutors(
911
1010
  if (plugin.executor === undefined) continue
912
1011
  const executor = await safe(plugin, 'executor', () => plugin.executor!(ctx))
913
1012
  if (executor === undefined) continue
914
- assertShape(plugin, 'executor', executor, ['execute'], 'an executor')
915
- if (typeof executor.name !== 'string' || executor.name.length === 0) {
916
- throw new UserError(`plugin '${plugin.name}' returned an executor with no name`)
917
- }
918
- // The pool's room is `active < capacity`: 0 or NaN parks its tasks for good.
919
- const capacity: unknown = executor.capacity
920
- if (capacity !== undefined && !(Number.isInteger(capacity) && (capacity as number) > 0)) {
921
- throw new UserError(
922
- `plugin '${plugin.name}' returned executor '${executor.name}' with capacity ${typeof capacity === 'number' ? String(capacity) : JSON.stringify(capacity)}: it must be a positive integer`,
923
- )
924
- }
925
- // Read as `=== true`: `remote: 'yes'` ran a remote executor as a local one.
926
- for (const flag of EXECUTOR_FLAGS) {
927
- const v: unknown = executor[flag]
928
- if (v !== undefined && typeof v !== 'boolean') {
1013
+ reading(plugin, 'executor', () => {
1014
+ assertShape(plugin, 'executor', executor, ['execute'], 'an executor')
1015
+ if (typeof executor.name !== 'string' || executor.name.length === 0) {
1016
+ throw new UserError(`plugin '${plugin.name}' returned an executor with no name`)
1017
+ }
1018
+ // The pool's room is `active < capacity`: 0 or NaN parks its tasks for good.
1019
+ const capacity: unknown = executor.capacity
1020
+ if (capacity !== undefined && !(Number.isInteger(capacity) && (capacity as number) > 0)) {
929
1021
  throw new UserError(
930
- `plugin '${plugin.name}' returned executor '${executor.name}' with ${flag} ${typeof v === 'string' ? JSON.stringify(v) : typeof v === 'object' && v !== null ? describeValue(v) : String(v as number)}: it must be true or false`,
1022
+ `plugin '${plugin.name}' returned executor '${executor.name}' with capacity ${typeof capacity === 'number' ? String(capacity) : JSON.stringify(capacity)}: it must be a positive integer`,
931
1023
  )
932
1024
  }
933
- }
934
- const platforms: unknown = executor.platforms
935
- if (
936
- platforms !== undefined &&
937
- !(
938
- Array.isArray(platforms) &&
939
- platforms.length > 0 &&
940
- platforms.every((p) => (PLATFORMS as readonly unknown[]).includes(p))
941
- )
942
- ) {
943
- throw new UserError(
944
- `plugin '${plugin.name}' returned executor '${executor.name}' with platforms ${JSON.stringify(platforms)}: it must be a non-empty array of ${PLATFORMS.join(', ')}`,
945
- )
946
- }
947
- for (const hook of EXECUTOR_HOOKS) {
948
- const v: unknown = executor[hook]
949
- if (v !== undefined && typeof v !== 'function')
1025
+ // Read as `=== true`: `remote: 'yes'` ran a remote executor as a local one.
1026
+ for (const flag of EXECUTOR_FLAGS) {
1027
+ const v: unknown = executor[flag]
1028
+ if (v !== undefined && typeof v !== 'boolean') {
1029
+ throw new UserError(
1030
+ `plugin '${plugin.name}' returned executor '${executor.name}' with ${flag} ${typeof v === 'string' ? JSON.stringify(v) : typeof v === 'object' && v !== null ? describeValue(v) : String(v as number)}: it must be true or false`,
1031
+ )
1032
+ }
1033
+ }
1034
+ const platforms: unknown = executor.platforms
1035
+ if (
1036
+ platforms !== undefined &&
1037
+ !(
1038
+ Array.isArray(platforms) &&
1039
+ platforms.length > 0 &&
1040
+ platforms.every((p) => (PLATFORMS as readonly unknown[]).includes(p))
1041
+ )
1042
+ ) {
950
1043
  throw new UserError(
951
- `plugin '${plugin.name}' returned executor '${executor.name}' with ${hook} that is not a function`,
1044
+ `plugin '${plugin.name}' returned executor '${executor.name}' with platforms ${JSON.stringify(platforms)}: it must be a non-empty array of ${PLATFORMS.join(', ')}`,
952
1045
  )
953
- }
1046
+ }
1047
+ const workers: unknown = executor.workers
1048
+ if (workers !== undefined) {
1049
+ const offered = (platforms ?? []) as readonly unknown[]
1050
+ const names = new Set<unknown>()
1051
+ const ok =
1052
+ Array.isArray(workers) &&
1053
+ workers.length > 0 &&
1054
+ workers.every(
1055
+ (w: unknown) =>
1056
+ Array.isArray(w) &&
1057
+ typeof w[0] === 'string' &&
1058
+ w[0].length > 0 &&
1059
+ !names.has(w[0]) &&
1060
+ names.add(w[0]) !== undefined &&
1061
+ Array.isArray(w[1]) &&
1062
+ w[1].length > 0 &&
1063
+ (w[1] as unknown[]).every((p) => offered.includes(p)),
1064
+ )
1065
+ if (!ok)
1066
+ throw new UserError(
1067
+ `plugin '${plugin.name}' returned executor '${executor.name}' with workers ${JSON.stringify(workers)}: it must be a non-empty array of distinct [name, platforms] pairs, each platform one of its platforms`,
1068
+ )
1069
+ }
1070
+ for (const hook of EXECUTOR_HOOKS) {
1071
+ const v: unknown = executor[hook]
1072
+ if (v !== undefined && typeof v !== 'function')
1073
+ throw new UserError(
1074
+ `plugin '${plugin.name}' returned executor '${executor.name}' with ${hook} that is not a function`,
1075
+ )
1076
+ }
1077
+ })
954
1078
  executorPlugin.set(executor, plugin.name)
955
1079
  executors.push(executor)
956
1080
  }