@vzn/vx 0.0.650 → 0.0.652

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 (146) hide show
  1. package/README.md +7 -23
  2. package/package.json +5 -5
  3. package/schemas/last.json +15 -0
  4. package/schemas/show.json +14 -20
  5. package/src/bin.ts +21 -31
  6. package/src/cache/archive.ts +1 -1
  7. package/src/cache/cache.ts +221 -30
  8. package/src/cache/file-hashes.ts +3 -0
  9. package/src/cache/git-inputs.ts +188 -39
  10. package/src/cache/history-stream.ts +134 -0
  11. package/src/cache/history-writer.ts +76 -0
  12. package/src/cache/index.ts +3 -0
  13. package/src/cache/inputs.ts +77 -14
  14. package/src/cache/layer.ts +17 -6
  15. package/src/cache/layered-cache.ts +4 -0
  16. package/src/cache/linked-inputs.ts +3 -0
  17. package/src/cache/lockfile-memo.ts +5 -3
  18. package/src/cache/output-index.ts +5 -2
  19. package/src/cache/remote-lane.ts +2 -1
  20. package/src/cache/run-history.ts +56 -17
  21. package/src/cache/schema.ts +15 -2
  22. package/src/cli/cache.ts +28 -18
  23. package/src/cli/completions.ts +4 -2
  24. package/src/cli/docs.ts +10 -11
  25. package/src/cli/help.ts +14 -25
  26. package/src/cli/index.ts +28 -9
  27. package/src/cli/info.ts +16 -8
  28. package/src/cli/init.ts +23 -15
  29. package/src/cli/last.ts +246 -204
  30. package/src/cli/lock.ts +55 -5
  31. package/src/cli/plan-format.ts +100 -118
  32. package/src/cli/plugin-commands.ts +8 -2
  33. package/src/cli/plugin-templates.ts +36 -45
  34. package/src/cli/run.ts +46 -27
  35. package/src/cli/select.ts +43 -5
  36. package/src/cli/show.ts +45 -52
  37. package/src/cli/task-verb.ts +22 -0
  38. package/src/cli/upgrade.ts +3 -1
  39. package/src/cli/watch-fs.ts +6 -1
  40. package/src/cli/watch-judge.ts +3 -2
  41. package/src/cli/watch-set.ts +4 -4
  42. package/src/cli/watch.ts +94 -52
  43. package/src/cli/why.ts +123 -89
  44. package/src/cli/workspace-config.ts +2 -1
  45. package/src/config.ts +47 -30
  46. package/src/exec/executor.ts +42 -2
  47. package/src/exec/index.ts +1 -0
  48. package/src/exec/kill-tree.ts +58 -5
  49. package/src/exec/local-executor.ts +1 -1
  50. package/src/exec/pipes.ts +152 -0
  51. package/src/exec/runner.ts +199 -182
  52. package/src/exec/sandbox-binds.ts +1 -1
  53. package/src/exec/sandbox-paths.ts +3 -1
  54. package/src/exec/sandbox-runtime.ts +317 -23
  55. package/src/exec/spawn-off.ts +154 -0
  56. package/src/exec/task-proxy.ts +93 -0
  57. package/src/exec/warm-shell.ts +146 -0
  58. package/src/graph/critical-path.ts +40 -28
  59. package/src/graph/definition.ts +16 -3
  60. package/src/graph/priorities.ts +98 -44
  61. package/src/graph/scheduler.ts +17 -11
  62. package/src/graph/task-graph.ts +51 -24
  63. package/src/handoff.ts +10 -7
  64. package/src/history/failure-mode.ts +24 -1
  65. package/src/history/history.ts +5 -0
  66. package/src/history/index.ts +1 -0
  67. package/src/history/metrics.ts +14 -6
  68. package/src/index.ts +7 -3
  69. package/src/orchestrator/admission.ts +1 -1
  70. package/src/orchestrator/command-windows.ts +1 -1
  71. package/src/orchestrator/deferred-outputs.ts +1 -1
  72. package/src/orchestrator/doctor.ts +1 -1
  73. package/src/orchestrator/download-policy.ts +5 -7
  74. package/src/orchestrator/excluded-keys.ts +1 -1
  75. package/src/orchestrator/execute-task.ts +67 -20
  76. package/src/orchestrator/graph-memo.ts +50 -1
  77. package/src/orchestrator/graph-stage.ts +6 -0
  78. package/src/orchestrator/hit-restore.ts +5 -6
  79. package/src/orchestrator/index.ts +3 -0
  80. package/src/orchestrator/keyed-projects.ts +28 -0
  81. package/src/orchestrator/live-usage.ts +16 -11
  82. package/src/orchestrator/local-shortcircuit.ts +2 -2
  83. package/src/orchestrator/locked-digests.ts +63 -0
  84. package/src/orchestrator/lockfile-claim.ts +108 -29
  85. package/src/orchestrator/miss-save.ts +58 -11
  86. package/src/orchestrator/persistent-task.ts +5 -1
  87. package/src/orchestrator/placement.ts +1 -1
  88. package/src/orchestrator/plan.ts +7 -3
  89. package/src/orchestrator/plugin-host.ts +67 -2
  90. package/src/orchestrator/plugin.ts +53 -2
  91. package/src/orchestrator/prepare.ts +16 -1
  92. package/src/orchestrator/projects.ts +4 -1
  93. package/src/orchestrator/remote-prefetch.ts +1 -1
  94. package/src/orchestrator/run-context.ts +29 -10
  95. package/src/orchestrator/run-failures.ts +62 -13
  96. package/src/orchestrator/run-keys.ts +1 -1
  97. package/src/orchestrator/run-records.ts +88 -59
  98. package/src/orchestrator/run-report.ts +1 -1
  99. package/src/orchestrator/run.ts +145 -41
  100. package/src/orchestrator/sandbox-request.ts +52 -9
  101. package/src/orchestrator/shell-verdict.ts +9 -0
  102. package/src/orchestrator/signals.ts +8 -5
  103. package/src/orchestrator/stable-keys.ts +17 -18
  104. package/src/orchestrator/task-artifacts.ts +1 -1
  105. package/src/orchestrator/task-hash.ts +21 -7
  106. package/src/orchestrator/telemetry.ts +5 -1
  107. package/src/report/events.ts +6 -14
  108. package/src/report/forecast.ts +10 -32
  109. package/src/report/framed-output.ts +283 -175
  110. package/src/report/index.ts +8 -3
  111. package/src/report/logger.ts +74 -39
  112. package/src/report/plain-output.ts +3 -1
  113. package/src/report/status-line.ts +49 -357
  114. package/src/report/summary.ts +105 -136
  115. package/src/settings.ts +8 -8
  116. package/src/ui/ansi.ts +48 -0
  117. package/src/ui/components.ts +506 -0
  118. package/src/ui/index.ts +79 -0
  119. package/src/ui/kit.ts +37 -0
  120. package/src/ui/live-region.ts +363 -0
  121. package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
  122. package/src/ui/terminal.ts +51 -0
  123. package/src/ui/theme.ts +224 -0
  124. package/src/ui/version-line.ts +28 -0
  125. package/src/util/config-frame.ts +191 -14
  126. package/src/util/edit-distance.ts +6 -1
  127. package/src/util/index.ts +2 -15
  128. package/src/util/paths.ts +21 -3
  129. package/src/util/secret-mask.ts +39 -3
  130. package/src/util/settle.ts +33 -1
  131. package/src/util/tail.ts +4 -1
  132. package/src/util/timing.ts +4 -16
  133. package/src/workspace/affected.ts +89 -30
  134. package/src/workspace/bracket-literal.ts +2 -2
  135. package/src/workspace/config-schema.ts +70 -63
  136. package/src/workspace/dependencies-lock.ts +191 -0
  137. package/src/workspace/filter.ts +36 -1
  138. package/src/workspace/index.ts +9 -0
  139. package/src/workspace/lockfile.ts +159 -6
  140. package/src/workspace/migration.ts +16 -9
  141. package/src/workspace/workspace.ts +3 -0
  142. package/src/util/colors.ts +0 -133
  143. package/src/util/report.ts +0 -44
  144. package/src/util/ui.ts +0 -258
  145. /package/src/{report/path-links.ts → ui/links.ts} +0 -0
  146. /package/src/{report → ui}/terminal-signals.ts +0 -0
package/src/cli/why.ts CHANGED
@@ -18,19 +18,9 @@ import {
18
18
  whyDidThisRerun,
19
19
  } from '../history/index.js'
20
20
  import { resolveRunId } from '../orchestrator/index.js'
21
- import {
22
- detectColors,
23
- ERROR,
24
- MASKED,
25
- nearMatches,
26
- paint,
27
- printable,
28
- refusal,
29
- SUCCESS,
30
- ui,
31
- UserError,
32
- WARN,
33
- } from '../util/index.js'
21
+ import { MASKED, nearMatches, printable, refusal, UserError } from '../util/index.js'
22
+ import * as ui from '../ui/index.js'
23
+ import type { Style } from '../ui/index.js'
34
24
  import { runRules } from '../settings.js'
35
25
  import { buildPackageGraph, findWorkspaceRoot } from '../workspace/index.js'
36
26
  import { findCwdProject } from './select.js'
@@ -165,6 +155,7 @@ async function resolveTarget(
165
155
  cache: Cache,
166
156
  target: string,
167
157
  cwdProject: () => Promise<string | null>,
158
+ declared: () => Promise<boolean>,
168
159
  ): Promise<string> {
169
160
  const db = indexDb(cache)
170
161
  if (target.includes('#')) {
@@ -202,11 +193,21 @@ async function resolveTarget(
202
193
  const ids = pairs.map((p) => `${p.project}#${p.task}`)
203
194
  const invoked = ids.length === 0 && db.query('SELECT 1 FROM invocations LIMIT 1').get() !== null
204
195
  if (ids.length === 0 && !invoked) {
196
+ // `vx run` of a task no project declares is refused: its advice was that.
197
+ const known = await declared()
205
198
  throw refusal(
206
- { tone: 'info', headline: `no runs of ${target} yet`, fix: `vx run ${target}` },
199
+ known
200
+ ? { tone: 'info', headline: `no runs of ${target} yet`, fix: `vx run ${target}` }
201
+ : {
202
+ tone: 'info',
203
+ headline: `no project declares ${target}`,
204
+ fix: 'vx show lists the tasks',
205
+ },
207
206
  {
208
207
  code: 'VX_E_NO_HISTORY',
209
- message: `vx why: no recorded runs for "${target}"${suggest(target, ids, invoked)}`,
208
+ message: known
209
+ ? `vx why: no recorded runs for "${target}"${suggest(target, ids, invoked)}`
210
+ : `vx why: no project declares "${target}" (vx show lists the tasks)`,
210
211
  },
211
212
  )
212
213
  }
@@ -264,8 +265,12 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
264
265
  const cache = Cache.inspect(await cliCacheDir(root, parsed.cacheDir))
265
266
  try {
266
267
  const db = indexDb(cache)
267
- const taskId = await resolveTarget(cache, await rootSpelled(root, parsed.target), () =>
268
- findCwdProject(process.cwd()),
268
+ const target = await rootSpelled(root, parsed.target)
269
+ const taskId = await resolveTarget(
270
+ cache,
271
+ target,
272
+ () => findCwdProject(process.cwd()),
273
+ () => declares(root, target),
269
274
  )
270
275
  let runId: string | null
271
276
  if (parsed.runId !== undefined) {
@@ -290,14 +295,23 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
290
295
  // the persisted entry metadata so the verb still says something useful.
291
296
  const explanation = explainCacheKey(db, taskId)
292
297
  if (parsed.format === 'json') {
293
- process.stdout.write(`${JSON.stringify({ taskId, why: null, diff: null, explanation })}\n`)
298
+ ui.data('out', `${JSON.stringify({ taskId, why: null, diff: null, explanation })}\n`)
294
299
  } else {
295
- const c = detectColors(process.stdout)
296
- process.stdout.write(
297
- `${ui.inline(ui.id(taskId), c)}: recorded runs carry no run id — showing the latest cache entry instead\n` +
298
- (explanation.latestEntry !== null
299
- ? ` hash ${explanation.latestEntry.hash} · $ ${explanation.latestEntry.command}\n`
300
- : ' (no cache entry either)\n'),
300
+ ui.print(
301
+ 'out',
302
+ ui.section(
303
+ 'Why',
304
+ taskId,
305
+ ui.text('recorded runs carry no run id: the latest cache entry instead'),
306
+ ui.fields(
307
+ explanation.latestEntry !== null
308
+ ? [
309
+ ['key', explanation.latestEntry.hash],
310
+ ['command', ui.cmd(explanation.latestEntry.command)],
311
+ ]
312
+ : [['key', 'no cache entry either']],
313
+ ),
314
+ ),
301
315
  )
302
316
  }
303
317
  return 0
@@ -326,27 +340,22 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
326
340
  const writers = inputWriters(db, root, taskId, diff.entries, roots)
327
341
 
328
342
  if (parsed.format === 'json') {
329
- process.stdout.write(`${JSON.stringify({ taskId, runId, why, diff, roots, writers })}\n`)
343
+ ui.data('out', `${JSON.stringify({ taskId, runId, why, diff, roots, writers })}\n`)
330
344
  return 0
331
345
  }
332
346
 
333
- const lines: string[] = []
334
- const c = detectColors(process.stdout)
335
- const dim = (text: string): string => paint('', text, c, { dim: true })
336
- const head = (text: string): string => paint('', text, c, { bold: true })
337
347
  const t = why.thisRun!
338
- lines.push(`${ui.inline(ui.id(taskId), c)}${dim(` — run ${runId}`)}`)
339
- lines.push(` ${dim('this run')} ${runFacts(t)}`)
348
+ const head: ui.FieldRow[] = [
349
+ ['run', runId],
350
+ ['this run', runFacts(t)],
351
+ ]
340
352
  if (why.previousRun == null && t.hash === '') {
341
353
  // Only keyed runs are compared: a persistent task's tenth run read
342
354
  // as its first, and the note saying why went unprinted.
343
- lines.push(` ${dim('previous')} (none with a cache key)`)
344
- lines.push(` ${dim('verdict')} ${why.note}`)
355
+ head.push(['previous', 'none with a cache key'], ['verdict', why.note])
345
356
  } else if (why.previousRun == null) {
346
- lines.push(` ${dim('previous')} (none — first recorded run of this task)`)
357
+ head.push(['previous', 'none: first recorded run of this task'])
347
358
  } else {
348
- const p = why.previousRun
349
- lines.push(` ${dim('previous')} ${runFacts(p)}`)
350
359
  // The key moved and the diff says by what: the verdict names it, so
351
360
  // an env-only change reads as one line, not "inputs differ" above a
352
361
  // table to scan.
@@ -354,56 +363,71 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
354
363
  const moved = diff.entries.map((e) =>
355
364
  e.name === e.kind ? e.kind : `${e.kind} ${printable(e.name)}`,
356
365
  )
357
- lines.push(
358
- ` ${dim('verdict')} ${
366
+ head.push(
367
+ ['previous', runFacts(why.previousRun)],
368
+ [
369
+ 'verdict',
359
370
  why.hashChanged === true && moved.length > 0
360
- ? paint(
361
- WARN,
371
+ ? ui.tone(
372
+ 'warn',
362
373
  `cache key changed: ${moved.slice(0, 3).join(', ')}${moved.length > 3 ? ` and ${moved.length - 3} more` : ''}`,
363
- c,
364
374
  )
365
- : why.note
366
- }`,
375
+ : why.note,
376
+ ],
367
377
  )
368
378
  }
379
+ if (diff.entries.length === 0 && why.hashChanged === true) head.push(['detail', diff.note])
380
+ const blocks: ui.Block[] = [ui.section('Why', taskId, ui.fields(head))]
369
381
 
370
382
  if (diff.entries.length > 0) {
371
- lines.push('')
372
- lines.push(
373
- ` ${head('what changed')}${dim(` (${diff.entries.length} component${diff.entries.length === 1 ? '' : 's'}, ${diff.unchangedCount} unchanged)`)}:`,
383
+ blocks.push(
384
+ ui.section(
385
+ 'What changed',
386
+ `${diff.entries.length} of ${diff.entries.length + diff.unchangedCount}`,
387
+ ui.table(
388
+ ['change', 'kind', 'name', 'before → after'],
389
+ diff.entries.map((e) => [
390
+ ui.tone(CHANGE_HUE[e.change] ?? 'warn', e.change),
391
+ e.kind,
392
+ ui.strong(printable(e.name)),
393
+ // A secret-named env input shows its name and its change, no hash.
394
+ e.before === MASKED || e.after === MASKED
395
+ ? ''
396
+ : e.change === 'added'
397
+ ? `+ ${e.after}`
398
+ : e.change === 'removed'
399
+ ? `- ${e.before}`
400
+ : `${e.before} → ${e.after}`,
401
+ ]),
402
+ ),
403
+ ),
374
404
  )
375
- const kindW = Math.max(...diff.entries.map((e) => e.kind.length), 4)
376
- for (const e of diff.entries) {
377
- // A secret-named env input shows its name and its change, no hash.
378
- const beforeAfter =
379
- e.before === MASKED || e.after === MASKED
380
- ? ''
381
- : e.change === 'added'
382
- ? `+ ${e.after}`
383
- : e.change === 'removed'
384
- ? `- ${e.before}`
385
- : `${e.before} → ${e.after}`
386
- lines.push(
387
- ` ${paint(CHANGE_HUE[e.change] ?? WARN, e.change.padEnd(7), c)} ${dim(e.kind.padEnd(kindW))} ${printable(e.name)} ${dim(beforeAfter)}`.trimEnd(),
388
- )
389
- }
390
405
  if (roots.length > 0) {
391
- lines.push('', ` ${head('root cause')}:`)
392
- for (const r of roots.slice(0, ROOTS_SHOWN)) {
406
+ const lines: ui.Inline[] = roots.slice(0, ROOTS_SHOWN).map((r) => {
393
407
  const what = r.entries.map((e) =>
394
408
  e.name === e.kind ? e.kind : `${e.kind} ${printable(e.name)}`,
395
409
  )
396
- lines.push(
397
- ` ${r.chain.join(' \u2190 ')} \u2190 ${what.slice(0, 3).join(', ')}${what.length > 3 ? ` and ${what.length - 3} more` : ''}`,
398
- )
399
- }
400
- if (roots.length > ROOTS_SHOWN) lines.push(` and ${roots.length - ROOTS_SHOWN} more`)
410
+ return [
411
+ ...r.chain.flatMap((id, i) => [...(i > 0 ? [ui.dim(' \u2190 ')] : []), ui.id(id)]),
412
+ ui.dim(' \u2190 '),
413
+ ui.strong(
414
+ `${what.slice(0, 3).join(', ')}${what.length > 3 ? ` and ${what.length - 3} more` : ''}`,
415
+ ),
416
+ ]
417
+ })
418
+ if (roots.length > ROOTS_SHOWN) lines.push(`and ${roots.length - ROOTS_SHOWN} more`)
419
+ blocks.push(ui.section('Root cause', ui.text(...lines)))
401
420
  }
402
421
  if (writers.length > 0) {
403
- lines.push('', ` ${head('changed during a run')}:`)
404
- for (const w of writers) {
405
- lines.push(` ${printable(w.file)} ${dim(`while ${w.writers.join(', ')} ran`)}`)
406
- }
422
+ blocks.push(
423
+ ui.section(
424
+ 'Changed during a run',
425
+ ui.table(
426
+ ['file', 'written while'],
427
+ writers.map((w) => [ui.path(printable(w.file)), w.writers.join(', ')]),
428
+ ),
429
+ ),
430
+ )
407
431
  }
408
432
  const rootKinds = roots.flatMap((r) => r.entries.map((e) => e.kind))
409
433
  // An upstream row names the task to ask next: the command, not a
@@ -418,31 +442,26 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
418
442
  k in WHAT_TO_DO && !(k === 'upstream' && (roots.length > 0 || upstream.length === 0)),
419
443
  )
420
444
  if (kinds.length > 0) {
421
- lines.push('', ` ${head('what to do')}:`)
422
- // Its own column: a root cause's kind and the `written` row are
423
- // not in the table above, and overflowed its width.
424
- const todoW = Math.max(kindW, ...kinds.map((k) => k.length), writers.length > 0 ? 7 : 0)
445
+ const todo: ui.FieldRow[] = []
425
446
  for (const k of kinds) {
426
447
  if (k === 'upstream') {
427
- for (const todo of upstream) lines.push(` ${dim(k.padEnd(todoW))} ${todo}`)
448
+ todo.push([k, ...upstream])
428
449
  continue
429
450
  }
430
- const todo =
451
+ todo.push([
452
+ k,
431
453
  k === 'config'
432
454
  ? WHAT_TO_DO[k]!.replace('`vx show <that task>`', `\`vx show ${taskId}\``)
433
- : WHAT_TO_DO[k]
434
- lines.push(` ${dim(k.padEnd(todoW))} ${todo}`)
435
- }
436
- if (writers.length > 0) {
437
- // A task re-writing a file a key reads moves that key every run:
438
- // the miss is right, and only moving the write ends it.
439
- lines.push(` ${dim('written'.padEnd(todoW))} ${WRITTEN_TODO}`)
455
+ : WHAT_TO_DO[k]!,
456
+ ])
440
457
  }
458
+ // A task re-writing a file a key reads moves that key every run:
459
+ // the miss is right, and only moving the write ends it.
460
+ if (writers.length > 0) todo.push(['written', WRITTEN_TODO])
461
+ blocks.push(ui.section('What to do', ui.fields(todo)))
441
462
  }
442
- } else if (why.hashChanged === true) {
443
- lines.push(` ${dim('detail')} ${diff.note}`)
444
463
  }
445
- process.stdout.write(`${lines.join('\n')}\n`)
464
+ ui.print('out', ...blocks)
446
465
  return 0
447
466
  } finally {
448
467
  cache.close()
@@ -451,7 +470,7 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
451
470
 
452
471
  const ROOTS_SHOWN = 5
453
472
 
454
- const CHANGE_HUE: Record<string, string> = { added: SUCCESS, removed: ERROR, changed: WARN }
473
+ const CHANGE_HUE: Record<string, Style> = { added: 'ok', removed: 'error', changed: 'warn' }
455
474
 
456
475
  type DiffEntry = ReturnType<typeof cacheKeyDiff>['entries'][number]
457
476
 
@@ -489,6 +508,21 @@ function upstreamTodo(upstream: readonly DiffEntry[], unrecorded: readonly DiffE
489
508
  return out
490
509
  }
491
510
 
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> {
513
+ try {
514
+ const projects = await loadCliProjects(root, await discoverCliProjects(root), 'all')
515
+ 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
+ )
520
+ } catch {
521
+ // quiet: with no lock or a broken config, the run says why itself
522
+ return true
523
+ }
524
+ }
525
+
492
526
  /**
493
527
  * Each group in `taskId`'s graph with the tasks it gathers. Empty when a
494
528
  * config no longer loads: the advice then names the group to `vx show`.
@@ -7,7 +7,8 @@
7
7
  import { existsSync } from 'node:fs'
8
8
  import path from 'node:path'
9
9
  import type { WorkspaceConfig } from '../config.js'
10
- import { UserError, sayLine } from '../util/index.js'
10
+ import { UserError } from '../util/index.js'
11
+ import { sayLine } from '../ui/index.js'
11
12
  import {
12
13
  discoverProjects,
13
14
  loadProjects,
package/src/config.ts CHANGED
@@ -28,6 +28,10 @@ export interface WorkspaceConfig {
28
28
  baseBranch?: string
29
29
  run?: RunSettings
30
30
  cache?: CacheSettings
31
+ /** Input defaults for every task. */
32
+ inputs?: WorkspaceInputs
33
+ /** Output defaults for every task. */
34
+ outputs?: WorkspaceOutputs
31
35
  lock?: LockSettings
32
36
  watch?: WatchSettings
33
37
  /**
@@ -76,6 +80,18 @@ export interface RunSettings {
76
80
  rules?: WorkspaceRules
77
81
  }
78
82
 
83
+ /** `WorkspaceConfig.inputs`. */
84
+ export interface WorkspaceInputs {
85
+ /** The `inputs.stability` of every task that does not set its own. */
86
+ stability?: InputStability
87
+ }
88
+
89
+ /** `WorkspaceConfig.outputs`. */
90
+ export interface WorkspaceOutputs {
91
+ /** The `outputs.clean` of every task that does not set its own. */
92
+ clean?: boolean
93
+ }
94
+
79
95
  /** `WorkspaceConfig.cache`: where the cache lives and what a run does with it. */
80
96
  export interface CacheSettings {
81
97
  /**
@@ -93,10 +109,6 @@ export interface CacheSettings {
93
109
  policy?: string
94
110
  /** Which remote hits land on disk: `all` (the default), only the requested tasks' (`toplevel`), or `none`. */
95
111
  download?: 'all' | 'toplevel' | 'none'
96
- /** The `cache.inputStability` of every task that does not set its own. */
97
- inputStability?: InputStability
98
- /** The `cache.outputs.clean` of every task that does not set its own. */
99
- outputs?: { clean?: boolean }
100
112
  /**
101
113
  * Which runs keep a failed task's log and the files its outputs left in
102
114
  * the remote: CI runs (`'ci'`, the default), runs off CI (`'local'`), or
@@ -305,13 +317,15 @@ export interface TaskConfig {
305
317
  * keyed group: its inputs are a key its dependents fold, with no process.
306
318
  */
307
319
  inputs?: InputsConfig
320
+ /** What the task produces: saved on a miss, restored on a hit. Needs `cache: true`. */
321
+ outputs?: TaskOutputs
308
322
  /**
309
- * Caching configuration. **Caching is opt-in.** If this field is omitted,
310
- * the task always runs and nothing is read from or written to the cache.
311
- * Requires `exec` and `inputs`: the key is the task's inputs, and a group
312
- * runs nothing to cache.
323
+ * `true` caches the task. **Caching is opt-in.** Omitted or `false`, the
324
+ * task always runs and nothing is read from or written to the cache.
325
+ * Requires `exec`, `inputs` and `outputs`: the key is the task's inputs,
326
+ * and a group runs nothing to cache.
313
327
  */
314
- cache?: CacheConfig
328
+ cache?: boolean
315
329
  }
316
330
 
317
331
  /**
@@ -335,7 +349,7 @@ export interface TaskConfig {
335
349
  * from the other coupled them both ways — a declaration added for
336
350
  * caching silently widened the sandbox, and a path the task needed had
337
351
  * to be laundered through the cache key to get it (2026-09-05).
338
- * A declared `cache.outputs` is NOT a write grant; the request
352
+ * A declared `outputs` is NOT a write grant; the request
339
353
  * `sandbox-request.ts` builds carries no write of its own and binds
340
354
  * `allow.write` alone. This comment claimed the opposite — here and on
341
355
  * both grant fields — while the file beside it and `schema.md` both said
@@ -391,6 +405,13 @@ export interface SandboxConfig {
391
405
  weakerWhenNested?: boolean
392
406
  /** Accept weaker network isolation (host proxy) for lower overhead. */
393
407
  weakerNetworkIsolation?: boolean
408
+ /**
409
+ * Refuse every read outside the workspace too (`/usr`, `~/.cache`, the
410
+ * system toolchain), not only the workspace's other projects. A path
411
+ * outside it is readable only when `allow.read` names it; the shell a
412
+ * task runs in stays readable, so a task can start.
413
+ */
414
+ hermetic?: boolean
394
415
  }
395
416
 
396
417
  /** The denials `sandbox.ignore` can leave out of the report: patterns per class. */
@@ -410,9 +431,9 @@ export interface SandboxGrants {
410
431
  read?: readonly string[]
411
432
  /**
412
433
  * Writable paths or globs. The task can write NOTHING without these:
413
- * declaring `cache.outputs.files` grants no write, and a task whose
434
+ * declaring `outputs.files` grants no write, and a task whose
414
435
  * only declaration is its outputs writes into a tmpfs that evaporates
415
- * (vx then warns that `cache.outputs` matched no files).
436
+ * (vx then warns that `outputs` matched no files).
416
437
  */
417
438
  write?: readonly string[]
418
439
  /**
@@ -585,17 +606,6 @@ export interface ExecEnv {
585
606
  secret?: readonly string[]
586
607
  }
587
608
 
588
- export interface CacheConfig {
589
- /** What this task produces. Captured for restore on a cache hit. */
590
- outputs: CacheOutputs
591
- /**
592
- * What a write to an input while the command runs means. Each field,
593
- * highest first: its env var, this, the workspace's `inputStability`,
594
- * the default (`'immutable'`, `'warn'`).
595
- */
596
- inputStability?: InputStability
597
- }
598
-
599
609
  export interface InputStability {
600
610
  /**
601
611
  * `'immutable'` (the default): any write to an input during the command
@@ -615,7 +625,7 @@ export interface InputStability {
615
625
  severity?: 'warn' | 'error'
616
626
  }
617
627
 
618
- export interface CacheOutputs {
628
+ export interface TaskOutputs {
619
629
  /**
620
630
  * Files produced by this task, as project-relative globs. Pass `[]`
621
631
  * when the task has no files to capture (e.g. `lint`, `typecheck`).
@@ -640,7 +650,7 @@ export interface CacheOutputs {
640
650
  * (tsc's `.tsbuildinfo`) instead of removing them first. A run that
641
651
  * leaves a file it found untouched saves nothing, so an entry holds only
642
652
  * what a clean run would leave. A retry still starts clean. Omitted →
643
- * the workspace's `cache.outputs.clean`, else `true`.
653
+ * the workspace's `outputs.clean`, else `true`.
644
654
  */
645
655
  clean?: boolean
646
656
  }
@@ -716,6 +726,13 @@ export interface InputsConfig {
716
726
  * tool versions, OS info, etc.
717
727
  */
718
728
  workspaceRuntime?: readonly string[]
729
+ /**
730
+ * What a write to an input while the command runs means for the save.
731
+ * Each field, highest first: its env var, this, the workspace's
732
+ * `inputs.stability`, the default (`'immutable'`, `'warn'`). Needs
733
+ * `cache: true`.
734
+ */
735
+ stability?: InputStability
719
736
  }
720
737
 
721
738
  /**
@@ -785,9 +802,7 @@ export function defineProject<const T extends ProjectConfig>(
785
802
  dependsOn?: readonly DependsOnEntry<Extract<keyof NonNullable<T['tasks']>, string>>[]
786
803
  exec?: Known<At<NonNullable<T['tasks']>[K], 'exec'>, ExecConfig>
787
804
  inputs?: Known<At<NonNullable<T['tasks']>[K], 'inputs'>, InputsConfig>
788
- cache?: Known<At<NonNullable<T['tasks']>[K], 'cache'>, CacheConfig> & {
789
- outputs?: Known<At<At<NonNullable<T['tasks']>[K], 'cache'>, 'outputs'>, CacheOutputs>
790
- }
805
+ outputs?: Known<At<NonNullable<T['tasks']>[K], 'outputs'>, TaskOutputs>
791
806
  }
792
807
  }
793
808
  },
@@ -801,9 +816,11 @@ export function defineWorkspace<T extends WorkspaceConfig>(
801
816
  run?: Known<At<T, 'run'>, RunSettings> & {
802
817
  rules?: Known<At<At<T, 'run'>, 'rules'>, WorkspaceRules>
803
818
  }
804
- cache?: Known<At<T, 'cache'>, CacheSettings> & {
805
- inputStability?: Known<At<At<T, 'cache'>, 'inputStability'>, InputStability>
819
+ cache?: Known<At<T, 'cache'>, CacheSettings>
820
+ inputs?: Known<At<T, 'inputs'>, WorkspaceInputs> & {
821
+ stability?: Known<At<At<T, 'inputs'>, 'stability'>, InputStability>
806
822
  }
823
+ outputs?: Known<At<T, 'outputs'>, WorkspaceOutputs>
807
824
  lock?: Known<At<T, 'lock'>, LockSettings>
808
825
  watch?: Known<At<T, 'watch'>, WatchSettings>
809
826
  },
@@ -16,7 +16,8 @@
16
16
  import type { SandboxConfig } from '../config.js'
17
17
  import type { CaptureConfig, RunResult } from './runner.js'
18
18
  import type { ResolvedSandboxConfig, SandboxViolation } from './sandbox-runtime.js'
19
- import { thrownMessage, UserError, type ui } from '../util/index.js'
19
+ import { thrownMessage, UserError } from '../util/index.js'
20
+ import * as ui from '../ui/index.js'
20
21
 
21
22
  /** Sandbox baselines + the user's resolved sandbox block, when the task is sandboxed. */
22
23
  export interface ExecuteSandbox {
@@ -42,6 +43,11 @@ export interface ExecuteSandbox {
42
43
  * machine's paths and platform, hands `sandboxRequestFor`.
43
44
  */
44
45
  readonly keyed?: readonly string[]
46
+ /**
47
+ * The dependencies' outputs it may read (absolute), which an executor
48
+ * that builds the request again hands `sandboxRequestFor` too.
49
+ */
50
+ readonly dependencyReads?: readonly string[]
45
51
  }
46
52
 
47
53
  /** One declared input file, as the cache key saw it. */
@@ -107,7 +113,7 @@ export interface ExecuteRequest {
107
113
  /** `${project}#${task}` — for executors that route or log by task. */
108
114
  readonly taskId: string
109
115
  readonly workspaceRoot: string
110
- /** Present for cacheable tasks (the miss path); absent when the task declares no `cache`. */
116
+ /** Present for cacheable tasks (the miss path); absent for a task without `cache: true`. */
111
117
  readonly inputs?: TaskInputs
112
118
  /**
113
119
  * The task's cache key (present when the task is cacheable). An executor
@@ -144,6 +150,12 @@ export interface ExecuteRequest {
144
150
  * executor's `platforms`: its key includes it. Absent when the task names none.
145
151
  */
146
152
  readonly platform?: string
153
+ /**
154
+ * Which attempt of the task this is, from 2 when it retries; absent on
155
+ * the first. An executor that can, runs a retry somewhere other than
156
+ * the attempt before it, so the machine is not the flake.
157
+ */
158
+ readonly attempt?: number
147
159
  /**
148
160
  * Whether this task's outputs must land on the submitter's disk now.
149
161
  * 'eager' (or absent) = materialise as today. 'deferred' = leave them
@@ -376,6 +388,13 @@ export interface TaskExecutor {
376
388
  * anything but a finite number, reports none.
377
389
  */
378
390
  idealMs?(): number | undefined
391
+ /**
392
+ * The workers that would have run the run best, judged after it from
393
+ * what each task took: for the run summary, so its reader can change
394
+ * how many workers the executor may start and of what size. Asked once
395
+ * at the end, only when a sink is on. A throw reports none.
396
+ */
397
+ advice?(): readonly WorkerAdvice[]
379
398
  /**
380
399
  * Its own Chrome-trace events for `vx run --profile` (the machines it ran
381
400
  * tasks on, in time), asked once at the end, only when the flag is set.
@@ -422,6 +441,27 @@ export interface MachineReport {
422
441
  startMs?: number
423
442
  }
424
443
 
444
+ /**
445
+ * Workers that would have run the run best (`TaskExecutor.advice`): `of`
446
+ * says on what. `declared`: as many of each size the executor may start
447
+ * as end it soonest; `more`: past each size's limit, when that ends it
448
+ * sooner; `any`: one shape a platform, of any cores, ending as soon for
449
+ * the least core time.
450
+ */
451
+ export interface WorkerAdvice {
452
+ of: 'declared' | 'more' | 'any'
453
+ /** The run's end on them, from its start. */
454
+ endMs: number
455
+ workers: readonly {
456
+ count: number
457
+ cores: number
458
+ memoryBytes: number
459
+ platform?: string
460
+ /** The size it is, for `declared` and `more`. */
461
+ size?: string
462
+ }[]
463
+ }
464
+
425
465
  /**
426
466
  * What a remote executor rejects with when it gives a task back: core runs
427
467
  * the same request on the local floor and says `reason` once (a remote that
package/src/exec/index.ts CHANGED
@@ -64,6 +64,7 @@ export {
64
64
  type ExecuteSandbox,
65
65
  type InputFile,
66
66
  type MachineReport,
67
+ type WorkerAdvice,
67
68
  type ServerSpec,
68
69
  type TaskExecutor,
69
70
  type TaskInputs,