@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
package/src/cli/why.ts CHANGED
@@ -7,12 +7,14 @@
7
7
 
8
8
  import { indexDb, Cache } from '../cache/index.js'
9
9
  import { anchoredSpecError, flagHint, formatValue, seeHelp } from './help.js'
10
- import { buildTaskGraph, isGroupTask, splitTaskId } from '../graph/index.js'
10
+ import { buildTaskGraph, isDefaultBuild, isGroupTask, splitTaskId } from '../graph/index.js'
11
11
  import {
12
12
  cacheKeyDiff,
13
13
  explainCacheKey,
14
14
  inputWriters,
15
15
  latestRunId,
16
+ LocalHistoryProvider,
17
+ recordedTasks,
16
18
  rootCauses,
17
19
  upstreamTask,
18
20
  whyDidThisRerun,
@@ -20,6 +22,7 @@ import {
20
22
  import { resolveRunId } from '../orchestrator/index.js'
21
23
  import { MASKED, nearMatches, printable, refusal, UserError } from '../util/index.js'
22
24
  import * as ui from '../ui/index.js'
25
+ import type { InputWriter, RootCause } from '../history/index.js'
23
26
  import type { Style } from '../ui/index.js'
24
27
  import { runRules } from '../settings.js'
25
28
  import { buildPackageGraph, findWorkspaceRoot } from '../workspace/index.js'
@@ -139,7 +142,7 @@ function suggest(query: string, ids: readonly string[], invoked: boolean): strin
139
142
  // No near name: say where to look, or that there is nothing yet.
140
143
  // A run of only group tasks records no task row: "nothing has run" was
141
144
  // false there, and its advice re-ran the group to the same answer.
142
- if (ids.length > 0) return '; `vx last --list` shows what has run'
145
+ if (ids.length > 0) return '; `vx last --stats` lists the tasks that ran'
143
146
  return invoked
144
147
  ? '; no recorded run executed a task (a group records none: ask about a task it depends on)'
145
148
  : `; nothing has run here yet (vx run ${query}, then vx why)`
@@ -155,7 +158,8 @@ async function resolveTarget(
155
158
  cache: Cache,
156
159
  target: string,
157
160
  cwdProject: () => Promise<string | null>,
158
- declared: () => Promise<boolean>,
161
+ declared: () => Promise<Declared>,
162
+ declaresId: (id: string) => Promise<Declared>,
159
163
  ): Promise<string> {
160
164
  const db = indexDb(cache)
161
165
  if (target.includes('#')) {
@@ -164,22 +168,40 @@ async function resolveTarget(
164
168
  // the error's suggestions and the bare-name form only.
165
169
  const [project, task] = splitTaskId(target)
166
170
  const hit = db
167
- .query('SELECT 1 FROM runs WHERE project = ? AND task = ? ORDER BY id DESC LIMIT 1')
171
+ .query('SELECT 1 FROM main.runs WHERE project = ? AND task = ? ORDER BY id DESC LIMIT 1')
168
172
  .get(project, task)
169
173
  if (hit !== null) return target
174
+ if ((await declaresId(target)) === 'group') throw groupRefusal(target)
170
175
  } else {
171
176
  // A bare name reads only its own task's rows: the DISTINCT over every
172
177
  // pair sorted the whole history (24 of 26 ms at 58k rows), and its list
173
178
  // serves only the miss's suggestions below.
174
179
  const matches = (
175
- db.query('SELECT DISTINCT project FROM runs WHERE task = ? ORDER BY project').all(target) as {
180
+ db
181
+ .query('SELECT DISTINCT project FROM main.runs WHERE task = ? ORDER BY project')
182
+ .all(target) as {
176
183
  project: string
177
184
  }[]
178
185
  ).map((r) => `${r.project}#${target}`)
186
+ // Asked even when no project ran it: the cwd project's own that never
187
+ // ran read as a name nobody declares.
188
+ const here = await cwdProject()
189
+ if (here !== null) {
190
+ const own = `${here}#${target}`
191
+ if (matches.includes(own)) return own
192
+ // `vx run build` here runs this project's own: another project's
193
+ // runs were explained in its place, unsaid.
194
+ const kind = await declaresId(own)
195
+ if (kind === 'group') throw groupRefusal(own)
196
+ if (kind !== false) {
197
+ throw refusal(
198
+ { tone: 'info', headline: `no runs of ${own} yet`, fix: `vx run ${target}` },
199
+ { code: 'VX_E_NO_HISTORY', message: `vx why: no recorded runs for "${own}"` },
200
+ )
201
+ }
202
+ }
179
203
  if (matches.length === 1) return matches[0]!
180
204
  if (matches.length > 1) {
181
- const here = await cwdProject()
182
- if (here !== null && matches.includes(`${here}#${target}`)) return `${here}#${target}`
183
205
  throw new UserError(
184
206
  `vx why: "${target}" ran in ${matches.length} projects — pick one:\n` +
185
207
  matches.map((m) => ` ${m}`).join('\n'),
@@ -187,17 +209,18 @@ async function resolveTarget(
187
209
  )
188
210
  }
189
211
  }
190
- const pairs = db
191
- .query('SELECT DISTINCT project, task FROM runs ORDER BY project, task')
192
- .all() as Array<{ project: string; task: string }>
193
- const ids = pairs.map((p) => `${p.project}#${p.task}`)
212
+ const ids = new LocalHistoryProvider(db).recentTaskIds()
194
213
  const invoked = ids.length === 0 && db.query('SELECT 1 FROM invocations LIMIT 1').get() !== null
195
214
  if (ids.length === 0 && !invoked) {
196
215
  // `vx run` of a task no project declares is refused: its advice was that.
197
216
  const known = await declared()
217
+ // Where the cwd's project does not declare it (a monorepo root), a bare
218
+ // `vx run build` is refused too: that advice sent the user in a circle.
219
+ const here = known && !target.includes('#') ? await cwdProject() : null
220
+ const all = here !== null && (await declaresId(`${here}#${target}`)) === false ? ' --all' : ''
198
221
  throw refusal(
199
222
  known
200
- ? { tone: 'info', headline: `no runs of ${target} yet`, fix: `vx run ${target}` }
223
+ ? { tone: 'info', headline: `no runs of ${target} yet`, fix: `vx run ${target}${all}` }
201
224
  : {
202
225
  tone: 'info',
203
226
  headline: `no project declares ${target}`,
@@ -271,16 +294,12 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
271
294
  target,
272
295
  () => findCwdProject(process.cwd()),
273
296
  () => declares(root, target),
297
+ // The cwd project's own: a bare `vx run` there skips its default build.
298
+ (id) => declares(root, id, true),
274
299
  )
275
300
  let runId: string | null
276
301
  if (parsed.runId !== undefined) {
277
- // A stopped run keeps rows but no header (item 854), and `vx last
278
- // --log` prints its id, so the whole id is taken from its rows too.
279
- runId =
280
- resolveRunId(db, parsed.runId, 'vx why') ??
281
- (db.query('SELECT 1 FROM runs WHERE run_id = ? LIMIT 1').get(parsed.runId) !== null
282
- ? parsed.runId
283
- : null)
302
+ runId = resolveRunId(db, parsed.runId, 'vx why')
284
303
  // An unknown id read as a known run missing the task (X-18).
285
304
  if (runId === null) {
286
305
  throw new UserError(
@@ -329,18 +348,16 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
329
348
  // each moved dependency in this run down to the tasks whose own
330
349
  // inputs moved, so one `vx why` names the file, not the next task.
331
350
  // A group records no run: the graph names what it gathers.
351
+ const recorded = recordedTasks(db, runId)
332
352
  const unrecorded = diff.entries.filter(
333
- (e) =>
334
- e.kind === 'upstream' &&
335
- e.change === 'changed' &&
336
- !cacheKeyDiff(db, runId, upstreamTask(e.name)).found,
353
+ (e) => e.kind === 'upstream' && e.change === 'changed' && !recorded.has(upstreamTask(e.name)),
337
354
  )
338
355
  const groups = unrecorded.length > 0 ? await groupMembers(root, taskId) : new Map()
339
356
  const roots = rootCauses(db, runId, taskId, diff.entries, groups)
340
- const writers = inputWriters(db, root, taskId, diff.entries, roots)
357
+ const writers = inputWriters(db, runId, root, taskId, diff.entries, roots)
341
358
 
342
359
  if (parsed.format === 'json') {
343
- ui.data('out', `${JSON.stringify({ taskId, runId, why, diff, roots, writers })}\n`)
360
+ await ui.dataPieces('out', jsonPieces({ taskId, runId, why, diff }, roots, writers))
344
361
  return 0
345
362
  }
346
363
 
@@ -407,8 +424,18 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
407
424
  const what = r.entries.map((e) =>
408
425
  e.name === e.kind ? e.kind : `${e.kind} ${printable(e.name)}`,
409
426
  )
427
+ // A 20,000-task chain printed whole is one 389 KB line.
428
+ const chain = r.chain
429
+ const ids =
430
+ chain.length > CHAIN_SHOWN
431
+ ? [
432
+ ...chain.slice(0, 2).map((id) => ui.id(id)),
433
+ ui.dim(`${chain.length - 5} more`),
434
+ ...chain.slice(-3).map((id) => ui.id(id)),
435
+ ]
436
+ : chain.map((id) => ui.id(id))
410
437
  return [
411
- ...r.chain.flatMap((id, i) => [...(i > 0 ? [ui.dim(' \u2190 ')] : []), ui.id(id)]),
438
+ ...ids.flatMap((id, i) => [...(i > 0 ? [ui.dim(' \u2190 ')] : []), id]),
412
439
  ui.dim(' \u2190 '),
413
440
  ui.strong(
414
441
  `${what.slice(0, 3).join(', ')}${what.length > 3 ? ` and ${what.length - 3} more` : ''}`,
@@ -470,6 +497,33 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
470
497
 
471
498
  const ROOTS_SHOWN = 5
472
499
 
500
+ /** Longer root-cause chains print their two ends around a count. */
501
+ const CHAIN_SHOWN = 6
502
+
503
+ /** Bytes of roots per piece written. */
504
+ const PIECE_BYTES = 1 << 20
505
+
506
+ /**
507
+ * `{ ...head, roots, writers }` as one `JSON.stringify` of it gives, in
508
+ * pieces: every root carries its whole chain, so a 20,000-task chain's
509
+ * roots hold 2×10⁸ ids, more than one string holds.
510
+ */
511
+ function* jsonPieces(
512
+ head: object,
513
+ roots: readonly RootCause[],
514
+ writers: readonly InputWriter[],
515
+ ): Generator<string> {
516
+ let piece = `${JSON.stringify(head).slice(0, -1)},"roots":[`
517
+ for (let i = 0; i < roots.length; i++) {
518
+ piece += `${i > 0 ? ',' : ''}${JSON.stringify(roots[i])}`
519
+ if (piece.length >= PIECE_BYTES) {
520
+ yield piece
521
+ piece = ''
522
+ }
523
+ }
524
+ yield `${piece}],"writers":${JSON.stringify(writers)}}\n`
525
+ }
526
+
473
527
  const CHANGE_HUE: Record<string, Style> = { added: 'ok', removed: 'error', changed: 'warn' }
474
528
 
475
529
  type DiffEntry = ReturnType<typeof cacheKeyDiff>['entries'][number]
@@ -508,21 +562,41 @@ function upstreamTodo(upstream: readonly DiffEntry[], unrecorded: readonly DiffE
508
562
  return out
509
563
  }
510
564
 
511
- /** Whether a project declares `target` (`pkg#task`, or a bare task); true when no config loads. */
512
- async function declares(root: string, target: string): Promise<boolean> {
565
+ /** A declared group (no `exec`) records no run: `group` when every declaration is one. */
566
+ type Declared = false | 'task' | 'group'
567
+
568
+ /** Whether a project declares `target` (`pkg#task`, or a bare task); `task` when no config loads. */
569
+ async function declares(root: string, target: string, own = false): Promise<Declared> {
513
570
  try {
514
571
  const projects = await loadCliProjects(root, await discoverCliProjects(root), 'all')
515
572
  const [project, task] = target.includes('#') ? splitTaskId(target) : [undefined, target]
516
- return [...projects.values()].some(
517
- (p) =>
518
- (project === undefined || p.name === project) && Object.hasOwn(p.config.tasks ?? {}, task),
519
- )
573
+ const found = [...projects.values()].flatMap((p) => {
574
+ const t = p.config.tasks?.[task]
575
+ return (project === undefined || p.name === project) &&
576
+ Object.hasOwn(p.config.tasks ?? {}, task) &&
577
+ !(own && isDefaultBuild(t))
578
+ ? [t]
579
+ : []
580
+ })
581
+ if (found.length === 0) return false
582
+ // The default build is a group too, but no one declared it.
583
+ return found.every((t) => t?.exec === undefined && !isDefaultBuild(t)) ? 'group' : 'task'
520
584
  } catch {
521
585
  // quiet: with no lock or a broken config, the run says why itself
522
- return true
586
+ return 'task'
523
587
  }
524
588
  }
525
589
 
590
+ function groupRefusal(id: string): UserError {
591
+ return refusal(
592
+ { tone: 'info', headline: `${id} is a group, which records no run`, fix: `vx show ${id}` },
593
+ {
594
+ code: 'VX_E_NO_HISTORY',
595
+ message: `vx why: ${id} is a group, which records no run (vx show ${id} names what it gathers)`,
596
+ },
597
+ )
598
+ }
599
+
526
600
  /**
527
601
  * Each group in `taskId`'s graph with the tasks it gathers. Empty when a
528
602
  * config no longer loads: the advice then names the group to `vx show`.
@@ -7,6 +7,7 @@
7
7
  import { existsSync } from 'node:fs'
8
8
  import path from 'node:path'
9
9
  import type { WorkspaceConfig } from '../config.js'
10
+ import type { LazyGitEnumeration } from '../cache/index.js'
10
11
  import { UserError } from '../util/index.js'
11
12
  import { sayLine } from '../ui/index.js'
12
13
  import {
@@ -65,10 +66,19 @@ export async function loadCliWorkspace(
65
66
  export async function discoverCliProjects(
66
67
  root: string,
67
68
  load: CliLoadOptions = {},
69
+ /** The whole-tree enumeration the caller started; the run reuses it with the discovery. */
70
+ git?: LazyGitEnumeration,
68
71
  ): Promise<ProjectMeta[]> {
69
72
  const warn = load.warn ?? warnToStderr
70
73
  const ws = await loadCliWorkspace(root, warn)
71
- return await discoverProjects(root, lockReader(root), ws.plugins, stageCacheDir(ws, load), warn)
74
+ return await discoverProjects(
75
+ root,
76
+ lockReader(root),
77
+ ws.plugins,
78
+ stageCacheDir(ws, load),
79
+ warn,
80
+ git,
81
+ )
72
82
  }
73
83
 
74
84
  /**
package/src/config.ts CHANGED
@@ -497,6 +497,15 @@ export interface ExecConfig {
497
497
  * it on a worker that is. Omitted → anywhere.
498
498
  */
499
499
  platforms?: readonly Platform[]
500
+ /**
501
+ * The worker type to run it on, by name (`'ubuntu'`), when an executor
502
+ * that names worker types (`TaskExecutor.workers`, `@vzn/vx-dte`'s
503
+ * `workers`) runs it: only that type's machines take it, and a run
504
+ * whose executor has no such type, or none on one of `platforms`, is
505
+ * refused before it starts. Placement only: not in the key, and a run
506
+ * on this machine ignores it. Omitted → the first type that fits.
507
+ */
508
+ worker?: string
500
509
  /** Environment exposed to the child process. */
501
510
  env?: ExecEnv
502
511
  /**
package/src/exec/env.ts CHANGED
@@ -39,7 +39,9 @@ export interface BuildEnvOptions {
39
39
  }
40
40
 
41
41
  export function buildIsolatedEnv(opts: BuildEnvOptions): NodeJS.ProcessEnv {
42
- const out: NodeJS.ProcessEnv = {}
42
+ // No prototype: on `{}`, a name `__proto__` hit the setter and reached
43
+ // no task, though the key folded it.
44
+ const out: NodeJS.ProcessEnv = Object.create(null)
43
45
 
44
46
  for (const name of ESSENTIAL_ENV) {
45
47
  const value = opts.source[name]
@@ -48,6 +48,11 @@ export interface ExecuteSandbox {
48
48
  * that builds the request again hands `sandboxRequestFor` too.
49
49
  */
50
50
  readonly dependencyReads?: readonly string[]
51
+ /**
52
+ * The heads of those outputs (absolute): a refused read under one is a
53
+ * file beside them, reported. Handed `sandboxRequestFor` with them.
54
+ */
55
+ readonly dependencyReported?: readonly string[]
51
56
  }
52
57
 
53
58
  /** One declared input file, as the cache key saw it. */
@@ -79,8 +84,9 @@ export interface TaskInputs {
79
84
  * Dependencies whose cache keys are folded. `outputs` lists each one's
80
85
  * declared output files (workspace-relative POSIX, as stored with its cache
81
86
  * entry) — restored on disk before this task runs, so an input-shipping
82
- * executor can include them in the input root. Empty when the upstream's
83
- * entry is not in the local index (a non-cacheable dependency).
87
+ * executor can include them in the input root. A non-cacheable
88
+ * dependency's are its declared outputs found on disk; a cacheable one
89
+ * with no entry in the local index lists none.
84
90
  */
85
91
  readonly upstream: ReadonlyArray<{
86
92
  readonly taskId: string
@@ -109,6 +115,12 @@ export interface ServerSpec {
109
115
  readonly sandbox?: { readonly config: SandboxConfig; readonly nested: readonly string[] }
110
116
  }
111
117
 
118
+ /**
119
+ * One task's command, as core hands it to an executor. A plugin
120
+ * executor's `outputs`, `envDefine` and `forwardArgs` are frozen copies:
121
+ * core saves the outputs after the command, and a config or the run's
122
+ * args are shared with other tasks.
123
+ */
112
124
  export interface ExecuteRequest {
113
125
  /** `${project}#${task}` — for executors that route or log by task. */
114
126
  readonly taskId: string
@@ -150,6 +162,12 @@ export interface ExecuteRequest {
150
162
  * executor's `platforms`: its key includes it. Absent when the task names none.
151
163
  */
152
164
  readonly platform?: string
165
+ /**
166
+ * The worker type to run it on, one of the executor's `workers`: the
167
+ * task's `exec.worker`, else the first type with one of its platforms.
168
+ * Absent when the executor names no types.
169
+ */
170
+ readonly worker?: string
153
171
  /**
154
172
  * Which attempt of the task this is, from 2 when it retries; absent on
155
173
  * the first. An executor that can, runs a retry somewhere other than
@@ -190,7 +208,8 @@ export interface ExecuteRequest {
190
208
  readonly onStderr: (chunk: string) => void
191
209
  /**
192
210
  * Aborted when the run stops (Ctrl-C, SIGTERM, SIGHUP or an embedder's
193
- * `RunOptions.signal`) or when `timeoutMs` elapses. An executor ends its
211
+ * `RunOptions.signal`), when `timeoutMs` elapses, or when fail-fast
212
+ * withdraws a task `running` says is not begun. An executor ends its
194
213
  * work and returns: a child an executor spawned is not one core can
195
214
  * reach (H-10, H-12). One already aborted is work not to start (B-55).
196
215
  * Any exit after the timeout's abort, a 0 included, is a timeout. Core
@@ -236,10 +255,11 @@ export interface ExecuteResult extends RunResult {
236
255
  */
237
256
  readonly outputs?: { kind: 'disk' } | { kind: 'deferred'; materialize: () => Promise<void> }
238
257
  /**
239
- * Tracked files the command changed where it ran, workspace-relative
240
- * POSIX: an executor that runs it elsewhere says so, as this disk shows
241
- * none of it. One the key read withholds the save under the key, as an
242
- * input changed here does.
258
+ * Tracked files the command changed where it ran, and untracked ones it
259
+ * found there, workspace-relative POSIX: an executor that runs it
260
+ * elsewhere says so, as this disk shows none of it. One the key read, or
261
+ * one its inputs declare, withholds the save under the key, as an input
262
+ * changed here does.
243
263
  */
244
264
  readonly changed?: readonly string[]
245
265
  /**
@@ -291,9 +311,12 @@ export interface TaskExecutor {
291
311
  /** Runs the command somewhere else. Never offered a `pinnedLocal` task. */
292
312
  readonly remote?: boolean
293
313
  /**
294
- * A `remote` executor that enforces `ExecuteRequest.sandbox` where it
295
- * runs the command, as the local one does. It is offered sandboxed tasks
296
- * and their dependants; the request's violations come back on the result.
314
+ * Enforces `ExecuteRequest.sandbox` where it runs the command, as the
315
+ * local one does; the request's violations come back on the result. Only
316
+ * such an executor is offered a task that declares `exec.sandbox`, here
317
+ * or remote: one that ran it unsandboxed passed a boundary it never
318
+ * checked and cached the output. A `remote` one is offered the
319
+ * dependants of a sandboxed task too.
297
320
  */
298
321
  readonly sandbox?: boolean
299
322
  /**
@@ -338,6 +361,14 @@ export interface TaskExecutor {
338
361
  * declares none is never offered such a task off this machine.
339
362
  */
340
363
  readonly platforms?: readonly string[]
364
+ /**
365
+ * Its worker types in order of preference, each with the platforms its
366
+ * machines run (`@vzn/vx-dte`'s `workers`). A task naming `exec.worker`
367
+ * is offered it only when it has that type on one of the task's
368
+ * platforms; any other task goes to the first type that fits, on
369
+ * `ExecuteRequest.worker`. Its `platforms` must list every one of them.
370
+ */
371
+ readonly workers?: ReadonlyArray<readonly [name: string, platforms: readonly string[]]>
341
372
  /**
342
373
  * How many tasks this executor runs at once. Its tasks then occupy a pool
343
374
  * of this size instead of the local worker slots, so a remote pool is not
@@ -345,7 +376,11 @@ export interface TaskExecutor {
345
376
  * is refused when the executor is resolved. Absent = the local pool.
346
377
  */
347
378
  readonly capacity?: number
348
- /** Per-task opt-out at placement time. Absent = accepts every task it is offered. */
379
+ /**
380
+ * Per-task opt-out at placement time. Absent = accepts every task it is
381
+ * offered. `false` or `undefined` declines; any other non-boolean is
382
+ * refused, naming the plugin.
383
+ */
349
384
  accepts?(task: TaskPlacement): boolean
350
385
  /**
351
386
  * The tasks placed here that will run and have NOT finished yet — called
@@ -406,8 +441,17 @@ export interface TaskExecutor {
406
441
  * Where a task it was handed runs (`small#2`), for the live row; asked
407
442
  * per frame. `null` while it holds the task for a machine to begin it:
408
443
  * the row says `waiting`, and its clock starts when a machine begins it.
444
+ * Under `--continue=never` a failure aborts the `signal` of each task it
445
+ * answers `null` for, and the task is skipped, never started.
409
446
  */
410
447
  running?(taskId: string): string | null | undefined
448
+ /**
449
+ * A `servers` executor's: no task left in the run uses persistent task
450
+ * `taskId`, so each copy it started beside a dependant stops now, not at
451
+ * teardown. Core asks once, as the run stops its own copy (`schema.md`,
452
+ * "Stopped once nothing needs it"). A throw is ignored.
453
+ */
454
+ unneeded?(taskId: string): void
411
455
  execute(req: ExecuteRequest): Promise<ExecuteResult>
412
456
  }
413
457
 
@@ -431,7 +475,11 @@ export interface MachineReport {
431
475
  /** Bytes in use at once, at the most and on average. */
432
476
  memoryPeakBytes: number
433
477
  memoryAvgBytes: number
434
- /** Core-time its tasks used over the core-time it had, 0 to 1. */
478
+ /**
479
+ * Its machine's busy core-time over the core-time it had, from its first
480
+ * task's send to its last one's end, 0 to 1 (its tasks' own where its
481
+ * machine said none): whether cores were left idle.
482
+ */
435
483
  utilization: number
436
484
  /** Cores the executor booked for the tasks it ran, on average over its time online. */
437
485
  bookedAvg?: number
@@ -574,8 +622,8 @@ export function assertExecuteResult(
574
622
 
575
623
  /**
576
624
  * Place one task: the first executor, in declaration order, that may take
577
- * it — a `remote` executor is skipped for a `pinnedLocal` task, then
578
- * `accepts()` decides. Decided once per task before scheduling. The local
625
+ * it — a `remote` executor is skipped for a `pinnedLocal` task, any one
626
+ * `barred` names is skipped, then `accepts()` decides. Decided once per task before scheduling. The local
579
627
  * executor accepts everything, so with it declared the only throw is an
580
628
  * `accepts()` that threw: refused as `label` names that executor, where it
581
629
  * surfaced as a bare stack naming no plugin (item 1022).
@@ -586,10 +634,12 @@ export function selectExecutor(
586
634
  label: (executor: TaskExecutor) => string = (e) => `executor '${e.name}'`,
587
635
  /** `task.pinnedLocal` as `executor` sees it: one that enforces the sandbox or carries servers lifts some pins. */
588
636
  pinnedFor: (executor: TaskExecutor) => boolean = () => task.pinnedLocal,
637
+ /** Never offered the task: a sandboxed one is barred from an executor that does not enforce it. */
638
+ barred: (executor: TaskExecutor) => boolean = () => false,
589
639
  ): TaskExecutor {
590
640
  for (const executor of executors) {
591
641
  const pinned = pinnedFor(executor)
592
- if (pinned && executor.remote === true) continue
642
+ if ((pinned && executor.remote === true) || barred(executor)) continue
593
643
  if (executor.accepts === undefined) return executor
594
644
  let taken: unknown
595
645
  try {
@@ -602,6 +652,17 @@ export function selectExecutor(
602
652
  taken.catch(() => {})
603
653
  throw new Error('returned a Promise; accepts is synchronous')
604
654
  }
655
+ // `'no'` was truthy and took the task; admit refuses the same answer.
656
+ if (taken !== undefined && typeof taken !== 'boolean') {
657
+ const t = typeof taken
658
+ const what =
659
+ taken === null
660
+ ? 'null'
661
+ : Array.isArray(taken)
662
+ ? 'an array'
663
+ : `${t === 'object' ? 'an' : 'a'} ${t}`
664
+ throw new Error(`returned ${what}, not true or false`)
665
+ }
605
666
  } catch (err) {
606
667
  const m = thrownMessage(err)
607
668
  throw new UserError(`${label(executor)} failed in accepts for ${task.taskId}: ${m}`)
@@ -151,7 +151,7 @@ function stopGuard(): void {
151
151
 
152
152
  function guardWrite(line: string): void {
153
153
  if (typeof guardFd !== 'number') return
154
- let deadline = Date.now() + GUARD_STALL_MS
154
+ let deadline = performance.now() + GUARD_STALL_MS
155
155
  // A turn's batch can be more than the queue takes at once: it goes in
156
156
  // pieces as the guard drains it, and only a guard that takes nothing for
157
157
  // the whole stall is gone. With one deadline for the batch, 2000 strikes
@@ -162,9 +162,9 @@ function guardWrite(line: string): void {
162
162
  const n = writeSync(guardFd, bytes, 0, Math.min(bytes.length, GUARD_PIECE))
163
163
  if (n === bytes.length) return
164
164
  bytes = bytes.subarray(n)
165
- deadline = Date.now() + GUARD_STALL_MS
165
+ deadline = performance.now() + GUARD_STALL_MS
166
166
  } catch (err) {
167
- if ((err as NodeJS.ErrnoException).code !== 'EAGAIN' || Date.now() >= deadline) break
167
+ if ((err as NodeJS.ErrnoException).code !== 'EAGAIN' || performance.now() >= deadline) break
168
168
  Bun.sleepSync(1)
169
169
  }
170
170
  }
@@ -414,7 +414,7 @@ export async function untilGroupsGone(
414
414
  children: readonly Child[],
415
415
  graceMs: number,
416
416
  ): Promise<Child[]> {
417
- const deadline = Date.now() + graceMs
417
+ const deadline = performance.now() + graceMs
418
418
  let timer: ReturnType<typeof setTimeout> | undefined
419
419
  // Not unref'd: it is what guarantees progress when every other handle
420
420
  // has drained, and the grace is bounded either way.
@@ -426,8 +426,8 @@ export async function untilGroupsGone(
426
426
  ])
427
427
  if (timer !== undefined) clearTimeout(timer)
428
428
  let left = children.filter(groupAlive)
429
- while (left.length > 0 && Date.now() < deadline) {
430
- await Bun.sleep(Math.min(GROUP_POLL_MS, deadline - Date.now()))
429
+ while (left.length > 0 && performance.now() < deadline) {
430
+ await Bun.sleep(Math.min(GROUP_POLL_MS, deadline - performance.now()))
431
431
  left = left.filter(groupAlive)
432
432
  }
433
433
  return left
@@ -443,7 +443,7 @@ export async function untilGroupsGone(
443
443
  * and a group that ignored the SIGTERM went un-killed; there the group
444
444
  * signal is the answer and the grace ends the wait.
445
445
  */
446
- function groupAlive(child: Child): boolean {
446
+ export function groupAlive(child: Child): boolean {
447
447
  if (!(child.pid > 0) || goneGroups.has(child)) return false
448
448
  try {
449
449
  process.kill(-child.pid, 0)