@vzn/vx 0.0.651 → 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 (177) hide show
  1. package/README.md +3 -4
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +15 -20
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +37 -3
  8. package/src/cache/archive.ts +197 -32
  9. package/src/cache/cache.ts +882 -230
  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 +151 -0
  16. package/src/cache/history-writer.ts +76 -0
  17. package/src/cache/index.ts +12 -1
  18. package/src/cache/inputs.ts +616 -135
  19. package/src/cache/key-fold.ts +1 -1
  20. package/src/cache/layer.ts +29 -16
  21. package/src/cache/layered-cache.ts +28 -7
  22. package/src/cache/lockfile-memo.ts +2 -1
  23. package/src/cache/output-index.ts +106 -44
  24. package/src/cache/plugin-cache.ts +2 -2
  25. package/src/cache/policy.ts +6 -2
  26. package/src/cache/remote-lane.ts +3 -1
  27. package/src/cache/run-history.ts +94 -25
  28. package/src/cache/schema.ts +46 -10
  29. package/src/cli/cache.ts +149 -36
  30. package/src/cli/completions.ts +2 -2
  31. package/src/cli/core-alias.ts +9 -2
  32. package/src/cli/docs.ts +4 -2
  33. package/src/cli/help.ts +27 -4
  34. package/src/cli/index.ts +62 -7
  35. package/src/cli/info.ts +5 -4
  36. package/src/cli/init.ts +55 -20
  37. package/src/cli/last.ts +218 -43
  38. package/src/cli/lock.ts +187 -24
  39. package/src/cli/plan-format.ts +24 -21
  40. package/src/cli/plugin-commands.ts +6 -1
  41. package/src/cli/plugin-templates.ts +36 -45
  42. package/src/cli/run.ts +68 -34
  43. package/src/cli/select.ts +160 -64
  44. package/src/cli/show.ts +70 -28
  45. package/src/cli/task-verb.ts +12 -7
  46. package/src/cli/upgrade.ts +210 -76
  47. package/src/cli/watch-filter.ts +88 -15
  48. package/src/cli/watch-fs.ts +34 -47
  49. package/src/cli/watch-judge.ts +73 -9
  50. package/src/cli/watch-set.ts +10 -8
  51. package/src/cli/watch.ts +190 -33
  52. package/src/cli/why.ts +107 -33
  53. package/src/cli/workspace-config.ts +11 -1
  54. package/src/config.ts +56 -30
  55. package/src/exec/env.ts +3 -1
  56. package/src/exec/executor.ts +116 -16
  57. package/src/exec/index.ts +1 -0
  58. package/src/exec/kill-tree.ts +7 -7
  59. package/src/exec/runner.ts +270 -108
  60. package/src/exec/sandbox-binds.ts +109 -18
  61. package/src/exec/sandbox-paths.ts +3 -1
  62. package/src/exec/sandbox-runtime.ts +446 -64
  63. package/src/exec/sandbox-violations.ts +250 -46
  64. package/src/exec/spawn-off.ts +10 -5
  65. package/src/exec/task-proxy.ts +93 -0
  66. package/src/exec/warm-shell.ts +29 -7
  67. package/src/graph/definition.ts +16 -3
  68. package/src/graph/folds.ts +89 -16
  69. package/src/graph/index.ts +8 -0
  70. package/src/graph/priorities.ts +8 -5
  71. package/src/graph/scheduler.ts +176 -39
  72. package/src/graph/stand-ins.ts +36 -0
  73. package/src/graph/task-graph.ts +347 -160
  74. package/src/graph/task-status.ts +5 -1
  75. package/src/handoff.ts +45 -10
  76. package/src/history/failure-mode.ts +39 -6
  77. package/src/history/history.ts +103 -30
  78. package/src/history/index.ts +2 -0
  79. package/src/history/metrics.ts +193 -73
  80. package/src/index.ts +6 -3
  81. package/src/orchestrator/admission.ts +14 -10
  82. package/src/orchestrator/affected-tasks.ts +35 -13
  83. package/src/orchestrator/command-windows.ts +8 -5
  84. package/src/orchestrator/deferred-outputs.ts +42 -13
  85. package/src/orchestrator/doctor.ts +21 -9
  86. package/src/orchestrator/download-policy.ts +8 -10
  87. package/src/orchestrator/excluded-keys.ts +15 -3
  88. package/src/orchestrator/execute-args.ts +9 -2
  89. package/src/orchestrator/execute-task.ts +612 -138
  90. package/src/orchestrator/graph-memo.ts +26 -4
  91. package/src/orchestrator/graph-stage.ts +27 -4
  92. package/src/orchestrator/held-servers.ts +36 -14
  93. package/src/orchestrator/hit-restore.ts +141 -93
  94. package/src/orchestrator/index.ts +4 -1
  95. package/src/orchestrator/keyed-projects.ts +53 -0
  96. package/src/orchestrator/local-floor.ts +21 -3
  97. package/src/orchestrator/local-shortcircuit.ts +2 -2
  98. package/src/orchestrator/lockfile-claim.ts +30 -4
  99. package/src/orchestrator/miss-save.ts +60 -61
  100. package/src/orchestrator/options.ts +13 -0
  101. package/src/orchestrator/output-host.ts +16 -4
  102. package/src/orchestrator/output-log.ts +1 -54
  103. package/src/orchestrator/persistent-task.ts +32 -16
  104. package/src/orchestrator/persistent.ts +152 -15
  105. package/src/orchestrator/placement.ts +82 -16
  106. package/src/orchestrator/plan.ts +39 -17
  107. package/src/orchestrator/plugin-host.ts +238 -110
  108. package/src/orchestrator/plugin.ts +59 -27
  109. package/src/orchestrator/prepare.ts +194 -38
  110. package/src/orchestrator/projects.ts +89 -10
  111. package/src/orchestrator/read-only.ts +115 -0
  112. package/src/orchestrator/remote-prefetch.ts +1 -1
  113. package/src/orchestrator/run-context.ts +6 -2
  114. package/src/orchestrator/run-failures.ts +101 -23
  115. package/src/orchestrator/run-id.ts +13 -2
  116. package/src/orchestrator/run-keys.ts +87 -28
  117. package/src/orchestrator/run-live.ts +16 -13
  118. package/src/orchestrator/run-lock.ts +27 -4
  119. package/src/orchestrator/run-records.ts +95 -74
  120. package/src/orchestrator/run-report.ts +3 -2
  121. package/src/orchestrator/run-stop.ts +17 -9
  122. package/src/orchestrator/run.ts +531 -122
  123. package/src/orchestrator/sandbox-fix.ts +40 -12
  124. package/src/orchestrator/sandbox-request.ts +314 -44
  125. package/src/orchestrator/save-lane.ts +24 -13
  126. package/src/orchestrator/shell-verdict.ts +29 -1
  127. package/src/orchestrator/signals.ts +103 -8
  128. package/src/orchestrator/stable-keys.ts +34 -27
  129. package/src/orchestrator/task-artifacts.ts +16 -3
  130. package/src/orchestrator/task-hash.ts +191 -34
  131. package/src/orchestrator/telemetry-host.ts +25 -6
  132. package/src/orchestrator/telemetry.ts +85 -13
  133. package/src/orchestrator/upstream.ts +3 -1
  134. package/src/report/events.ts +23 -6
  135. package/src/report/forecast.ts +103 -37
  136. package/src/report/framed-output.ts +179 -162
  137. package/src/report/index.ts +2 -1
  138. package/src/report/logger.ts +108 -31
  139. package/src/report/plain-output.ts +15 -9
  140. package/src/report/status-line.ts +28 -55
  141. package/src/report/summary.ts +61 -18
  142. package/src/settings.ts +50 -14
  143. package/src/ui/components.ts +36 -42
  144. package/src/ui/index.ts +3 -1
  145. package/src/ui/live-region.ts +67 -8
  146. package/src/ui/terminal.ts +12 -0
  147. package/src/ui/theme.ts +16 -1
  148. package/src/util/config-frame.ts +142 -6
  149. package/src/util/edit-distance.ts +17 -1
  150. package/src/util/errors.ts +23 -7
  151. package/src/util/foreign-cache-dir.ts +60 -0
  152. package/src/util/git-ignored.ts +59 -5
  153. package/src/util/index.ts +15 -1
  154. package/src/util/paths.ts +51 -7
  155. package/src/util/plain-text.ts +19 -0
  156. package/src/util/secret-mask.ts +57 -1
  157. package/src/util/settle.ts +18 -1
  158. package/src/util/size.ts +12 -2
  159. package/src/util/tail.ts +4 -1
  160. package/src/util/write-file.ts +86 -9
  161. package/src/workspace/affected.ts +225 -27
  162. package/src/workspace/bracket-literal.ts +2 -2
  163. package/src/workspace/config-bytecode.ts +23 -4
  164. package/src/workspace/config-eval.ts +90 -16
  165. package/src/workspace/config-imports.ts +96 -41
  166. package/src/workspace/config-schema.ts +192 -86
  167. package/src/workspace/dependencies-lock.ts +7 -14
  168. package/src/workspace/filter.ts +18 -2
  169. package/src/workspace/index.ts +3 -1
  170. package/src/workspace/json-data.ts +34 -8
  171. package/src/workspace/lockfile.ts +86 -37
  172. package/src/workspace/migration.ts +58 -31
  173. package/src/workspace/module-state.ts +62 -0
  174. package/src/workspace/nested-dirs.ts +8 -0
  175. package/src/workspace/project-loader.ts +276 -32
  176. package/src/workspace/repo-id.ts +29 -6
  177. 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
@@ -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
  /**
@@ -476,6 +497,15 @@ export interface ExecConfig {
476
497
  * it on a worker that is. Omitted → anywhere.
477
498
  */
478
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
479
509
  /** Environment exposed to the child process. */
480
510
  env?: ExecEnv
481
511
  /**
@@ -585,17 +615,6 @@ export interface ExecEnv {
585
615
  secret?: readonly string[]
586
616
  }
587
617
 
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
618
  export interface InputStability {
600
619
  /**
601
620
  * `'immutable'` (the default): any write to an input during the command
@@ -615,7 +634,7 @@ export interface InputStability {
615
634
  severity?: 'warn' | 'error'
616
635
  }
617
636
 
618
- export interface CacheOutputs {
637
+ export interface TaskOutputs {
619
638
  /**
620
639
  * Files produced by this task, as project-relative globs. Pass `[]`
621
640
  * when the task has no files to capture (e.g. `lint`, `typecheck`).
@@ -640,7 +659,7 @@ export interface CacheOutputs {
640
659
  * (tsc's `.tsbuildinfo`) instead of removing them first. A run that
641
660
  * leaves a file it found untouched saves nothing, so an entry holds only
642
661
  * what a clean run would leave. A retry still starts clean. Omitted →
643
- * the workspace's `cache.outputs.clean`, else `true`.
662
+ * the workspace's `outputs.clean`, else `true`.
644
663
  */
645
664
  clean?: boolean
646
665
  }
@@ -716,6 +735,13 @@ export interface InputsConfig {
716
735
  * tool versions, OS info, etc.
717
736
  */
718
737
  workspaceRuntime?: readonly string[]
738
+ /**
739
+ * What a write to an input while the command runs means for the save.
740
+ * Each field, highest first: its env var, this, the workspace's
741
+ * `inputs.stability`, the default (`'immutable'`, `'warn'`). Needs
742
+ * `cache: true`.
743
+ */
744
+ stability?: InputStability
719
745
  }
720
746
 
721
747
  /**
@@ -785,9 +811,7 @@ export function defineProject<const T extends ProjectConfig>(
785
811
  dependsOn?: readonly DependsOnEntry<Extract<keyof NonNullable<T['tasks']>, string>>[]
786
812
  exec?: Known<At<NonNullable<T['tasks']>[K], 'exec'>, ExecConfig>
787
813
  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
- }
814
+ outputs?: Known<At<NonNullable<T['tasks']>[K], 'outputs'>, TaskOutputs>
791
815
  }
792
816
  }
793
817
  },
@@ -801,9 +825,11 @@ export function defineWorkspace<T extends WorkspaceConfig>(
801
825
  run?: Known<At<T, 'run'>, RunSettings> & {
802
826
  rules?: Known<At<At<T, 'run'>, 'rules'>, WorkspaceRules>
803
827
  }
804
- cache?: Known<At<T, 'cache'>, CacheSettings> & {
805
- inputStability?: Known<At<At<T, 'cache'>, 'inputStability'>, InputStability>
828
+ cache?: Known<At<T, 'cache'>, CacheSettings>
829
+ inputs?: Known<At<T, 'inputs'>, WorkspaceInputs> & {
830
+ stability?: Known<At<At<T, 'inputs'>, 'stability'>, InputStability>
806
831
  }
832
+ outputs?: Known<At<T, 'outputs'>, WorkspaceOutputs>
807
833
  lock?: Known<At<T, 'lock'>, LockSettings>
808
834
  watch?: Known<At<T, 'watch'>, WatchSettings>
809
835
  },
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]