@vzn/vx 0.0.638 → 0.0.640

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 (134) hide show
  1. package/README.md +1 -1
  2. package/docs/caching.md +152 -95
  3. package/docs/cli.md +225 -372
  4. package/docs/execution.md +22 -47
  5. package/docs/features.md +14 -16
  6. package/docs/patterns.md +6 -8
  7. package/docs/schema.md +268 -215
  8. package/docs/security.md +1 -1
  9. package/package.json +5 -5
  10. package/schemas/last.json +7 -4
  11. package/schemas/show.json +14 -14
  12. package/skills/vx/SKILL.md +10 -7
  13. package/src/cache/cache.ts +44 -11
  14. package/src/cache/file-hashes.ts +7 -12
  15. package/src/cache/fs-clock.ts +62 -0
  16. package/src/cache/git-inputs.ts +8 -2
  17. package/src/cache/index.ts +5 -2
  18. package/src/cache/inputs.ts +154 -83
  19. package/src/cache/key-fold.ts +23 -3
  20. package/src/cache/layer.ts +33 -18
  21. package/src/cache/layered-cache.ts +108 -315
  22. package/src/cache/linked-inputs.ts +87 -0
  23. package/src/cache/plugin-cache.ts +2 -10
  24. package/src/cache/remote-lane.ts +383 -0
  25. package/src/cache/run-history.ts +24 -8
  26. package/src/cache/run-store.ts +4 -4
  27. package/src/cache/schema.ts +6 -4
  28. package/src/cache/scoped-cache.ts +7 -0
  29. package/src/cli/completions.ts +2 -2
  30. package/src/cli/foreign-flags.ts +11 -23
  31. package/src/cli/help.ts +6 -31
  32. package/src/cli/info.ts +2 -1
  33. package/src/cli/init.ts +23 -11
  34. package/src/cli/last.ts +30 -4
  35. package/src/cli/plan-format.ts +5 -58
  36. package/src/cli/plugin-commands.ts +2 -2
  37. package/src/cli/plugin-templates.ts +2 -2
  38. package/src/cli/run.ts +23 -185
  39. package/src/cli/select.ts +16 -6
  40. package/src/cli/show.ts +15 -6
  41. package/src/cli/upgrade.ts +215 -55
  42. package/src/cli/watch-cycle.ts +2 -1
  43. package/src/cli/watch-filter.ts +22 -6
  44. package/src/cli/watch-judge.ts +49 -5
  45. package/src/cli/watch-set.ts +18 -8
  46. package/src/cli/watch.ts +103 -16
  47. package/src/cli/why.ts +9 -7
  48. package/src/config.ts +19 -21
  49. package/src/exec/executor.ts +5 -5
  50. package/src/exec/sandbox-runtime.ts +1 -1
  51. package/src/exec/sandbox-violations.ts +3 -1
  52. package/src/graph/dependency-spec.ts +2 -2
  53. package/src/graph/folds.ts +236 -0
  54. package/src/graph/index.ts +17 -2
  55. package/src/graph/priorities.ts +1 -1
  56. package/src/graph/scheduler.ts +45 -31
  57. package/src/graph/task-graph.ts +190 -58
  58. package/src/graph/task-status.ts +95 -0
  59. package/src/{orchestrator → history}/failure-mode.ts +1 -1
  60. package/src/{orchestrator → history}/history.ts +1 -1
  61. package/src/history/index.ts +48 -0
  62. package/src/{orchestrator → history}/metrics.ts +7 -20
  63. package/src/{orchestrator → history}/miss-reason.ts +1 -1
  64. package/src/index.ts +11 -29
  65. package/src/orchestrator/affected-tasks.ts +20 -19
  66. package/src/orchestrator/doctor.ts +1 -1
  67. package/src/orchestrator/download-policy.ts +16 -22
  68. package/src/orchestrator/excluded-keys.ts +4 -5
  69. package/src/orchestrator/execute-args.ts +80 -0
  70. package/src/orchestrator/execute-task.ts +50 -411
  71. package/src/orchestrator/fingerprint-watch.ts +2 -2
  72. package/src/orchestrator/index.ts +2 -73
  73. package/src/orchestrator/keyed-projects.ts +12 -51
  74. package/src/orchestrator/local-shortcircuit.ts +1 -1
  75. package/src/orchestrator/miss-save.ts +33 -3
  76. package/src/orchestrator/options.ts +1 -36
  77. package/src/orchestrator/output-host.ts +4 -3
  78. package/src/orchestrator/persistent-task.ts +321 -0
  79. package/src/orchestrator/persistent.ts +130 -3
  80. package/src/orchestrator/placement.ts +5 -5
  81. package/src/orchestrator/plan.ts +19 -6
  82. package/src/orchestrator/plugin-host.ts +129 -69
  83. package/src/orchestrator/plugin.ts +18 -8
  84. package/src/orchestrator/prepare.ts +40 -34
  85. package/src/orchestrator/projects.ts +7 -10
  86. package/src/orchestrator/remote-prefetch.ts +14 -1
  87. package/src/orchestrator/run-artifacts.ts +5 -177
  88. package/src/orchestrator/run-context.ts +55 -0
  89. package/src/orchestrator/run-failures.ts +15 -3
  90. package/src/orchestrator/run-records.ts +35 -3
  91. package/src/orchestrator/run-report.ts +151 -171
  92. package/src/orchestrator/run-settings.ts +1 -16
  93. package/src/orchestrator/run-steps.ts +57 -0
  94. package/src/orchestrator/run-stop.ts +153 -0
  95. package/src/orchestrator/run.ts +293 -604
  96. package/src/orchestrator/sandbox-fix.ts +1 -17
  97. package/src/orchestrator/sandbox-request.ts +2 -2
  98. package/src/orchestrator/signals.ts +14 -6
  99. package/src/orchestrator/stable-keys.ts +164 -27
  100. package/src/orchestrator/steps.ts +70 -0
  101. package/src/orchestrator/task-hash.ts +34 -25
  102. package/src/orchestrator/task-log-buffer.ts +1 -1
  103. package/src/orchestrator/task-process.ts +94 -0
  104. package/src/orchestrator/telemetry-host.ts +4 -4
  105. package/src/orchestrator/telemetry.ts +14 -86
  106. package/src/orchestrator/upstream.ts +4 -181
  107. package/src/{orchestrator → report}/events.ts +41 -8
  108. package/src/{orchestrator → report}/forecast.ts +35 -2
  109. package/src/{orchestrator → report}/framed-output.ts +40 -34
  110. package/src/report/index.ts +36 -0
  111. package/src/{orchestrator → report}/logger.ts +158 -20
  112. package/src/{orchestrator → report}/status-line.ts +88 -7
  113. package/src/{orchestrator → report}/summary.ts +126 -35
  114. package/src/report/tally.ts +83 -0
  115. package/src/settings.ts +12 -39
  116. package/src/util/colors.ts +44 -4
  117. package/src/util/git-ignored.ts +143 -0
  118. package/src/util/index.ts +19 -4
  119. package/src/util/paths.ts +1 -1
  120. package/src/util/report.ts +13 -5
  121. package/src/util/settle.ts +14 -39
  122. package/src/util/tail.ts +1 -1
  123. package/src/util/ui.ts +17 -6
  124. package/src/workspace/affected.ts +81 -2
  125. package/src/workspace/config-imports.ts +1 -1
  126. package/src/workspace/config-schema.ts +142 -143
  127. package/src/workspace/lockfile.ts +1 -1
  128. package/src/workspace/migration.ts +19 -3
  129. package/schemas/summary.json +0 -133
  130. package/src/cache/chained-cache.ts +0 -280
  131. package/src/orchestrator/tally.ts +0 -112
  132. /package/src/{orchestrator → report}/path-links.ts +0 -0
  133. /package/src/{orchestrator → report}/plain-output.ts +0 -0
  134. /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
@@ -85,7 +85,7 @@ export function sandboxFix(
85
85
  projectDir: string,
86
86
  mask: (text: string) => string = (t) => t,
87
87
  /**
88
- * The task's `cache.inputs.files`, undefined when it does not cache: a
88
+ * The task's `inputs.files`, undefined when it does not cache: a
89
89
  * write under one is granted exactly, and a read of the project no glob
90
90
  * matches is named for the key too.
91
91
  */
@@ -124,19 +124,3 @@ export function sandboxFix(
124
124
  if (fix[k].size > 0) out[k] = [...new Set([...fix[k]].map(mask))].sort()
125
125
  return Object.keys(out).length > 0 ? out : undefined
126
126
  }
127
-
128
- /**
129
- * The fix as the config line it is: `exec.sandbox.allow: { read: ['a'] }`,
130
- * or undefined when every denial was a withheld credential path.
131
- */
132
- export function sandboxFixLine({
133
- withheld: _,
134
- inputs: __,
135
- ...fix
136
- }: SandboxFix): string | undefined {
137
- if (Object.keys(fix).length === 0) return undefined
138
- const parts = Object.entries(fix).map(
139
- ([k, list]: [string, string[]]) => `${k}: [${list.map((p) => `'${p}'`).join(', ')}]`,
140
- )
141
- return `exec.sandbox.allow: { ${parts.join(', ')} }`
142
- }
@@ -163,7 +163,7 @@ export function prepareSandbox(nodes: Iterable<TaskNode>): SandboxArmer | null {
163
163
  * cached path (through the executor) and the persistent path (spawned here).
164
164
  *
165
165
  * The user's grants derive NOTHING from `cache` (owner, 2026-09-05). Those
166
- * are two different questions: `cache.inputs` says what INVALIDATES the
166
+ * are two different questions: `inputs` says what INVALIDATES the
167
167
  * task, `sandbox.allow` says what it may TOUCH. Deriving one from the other
168
168
  * coupled them in both directions — a declaration added for caching
169
169
  * silently widened the sandbox, and a path the task needed had to be
@@ -476,7 +476,7 @@ export function withheldLinkLine(taskId: string, w: WithheldLink): string {
476
476
  `${w.name}, so an edit there would not re-run it. Add a \`dependsOn\` edge that ` +
477
477
  `reaches one (\`^build\` where ${w.name}#build keys its sources, or a \`source\` task: ` +
478
478
  `\`${w.name}#source\`), or grant and key the files yourself (\`allow.read\` plus ` +
479
- `\`cache.inputs.workspaceFiles\`).`
479
+ `\`inputs.workspaceFiles\`).`
480
480
  )
481
481
  }
482
482
 
@@ -16,13 +16,13 @@
16
16
  import { signalExitCode } from '../exec/index.js'
17
17
  import { claimExitForSignal, hangupIgnored, killGraceMs, settleWithin } from '../util/index.js'
18
18
  import { holdGroups, killTree, untilGroupsGone } from '../exec/index.js'
19
- import type { Logger } from './logger.js'
19
+ import type { Logger } from '../report/index.js'
20
20
 
21
21
  /**
22
22
  * How long a SIGTERMed child gets before SIGKILL on the way out. The
23
23
  * end-of-run persistent shutdown grants the same two seconds; both read
24
- * `VX_RUN_KILL_GRACE` (util/settle.ts) so the suites that prove the
25
- * escalation do not wait the full grace.
24
+ * `killGraceMs` (util/settle.ts) so the suites that prove the escalation
25
+ * do not wait the full grace.
26
26
  */
27
27
  export const SIGNAL_SHUTDOWN_GRACE_MS = 2000
28
28
 
@@ -125,6 +125,13 @@ export function forwardSignals(args: {
125
125
  }): SignalForwarding {
126
126
  const everyChild = (): Child[] => [...args.liveChildren, ...args.persistentRegistry.values()]
127
127
  const exit = (signal: StopSignal): never => {
128
+ // The run's own end already flushed the log; a second signal cut it
129
+ // short, so the failure frames it holds print now (idempotent).
130
+ try {
131
+ args.log.runEnd?.()
132
+ } catch {
133
+ // teardown must not throw on the way out
134
+ }
128
135
  for (const child of everyChild()) killTree(child, 'SIGKILL')
129
136
  try {
130
137
  args.cache.close()
@@ -152,10 +159,11 @@ export function forwardSignals(args: {
152
159
  stopping = signal
153
160
  claimExitForSignal()
154
161
  // Clear the live worker/status region BEFORE exiting so a TTY isn't
155
- // left with a frozen region in the scrollback. runEnd is idempotent
156
- // and a no-op for non-TTY loggers.
162
+ // left with a frozen region in the scrollback. Only the region: the
163
+ // tasks this stop aborts still list as rows, above the failure frames
164
+ // the run's own end prints.
157
165
  try {
158
- args.log.runEnd?.()
166
+ args.log.runDone?.()
159
167
  } catch {
160
168
  // teardown must not throw on the way out
161
169
  }
@@ -5,17 +5,30 @@
5
5
  // out so the two callers can never drift on the stability gate.
6
6
  //
7
7
  // A task's key is "stable" only if its inputs can't be altered by an
8
- // upstream's outputs (a `cache.inputs.files` glob that could match a
8
+ // upstream's outputs (a `inputs.files` glob that could match a
9
9
  // sibling's declared output has a PRELIMINARY key until that upstream
10
10
  // runs — probing it would target the wrong artifact). Unstable tasks
11
11
  // fall back to lazy read-through in execute-task, which is always
12
12
  // correct. The check is conservative: when unsure, treat as unstable.
13
13
 
14
- import { isGroupTask, outputTakenBack, type TaskNode, type TaskOutcome } from '../graph/index.js'
14
+ import {
15
+ foldedDeps,
16
+ isGroupTask,
17
+ outputTakenBack,
18
+ type TaskNode,
19
+ type TaskOutcome,
20
+ } from '../graph/index.js'
15
21
  import type { CacheLayer, GitFilesCache } from '../cache/index.js'
16
- import { isLiteralPattern, normalizeGlob, relPosix } from '../util/index.js'
22
+ import {
23
+ checkIgnore,
24
+ ignoreProbes,
25
+ isLiteralPattern,
26
+ normalizeGlob,
27
+ relPosix,
28
+ taskGlob,
29
+ wholeIgnored,
30
+ } from '../util/index.js'
17
31
  import { commandWriteReach, mayWriteFingerprint, undeclaredWriteReach } from './sandbox-request.js'
18
- import { foldedDeps } from './keyed-projects.js'
19
32
  import {
20
33
  computeGroupHash,
21
34
  computeTaskHash,
@@ -116,16 +129,17 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
116
129
  if (list === undefined) outputsOf.set(n.projectName, [...files])
117
130
  else list.push(...files)
118
131
  }
132
+ const readableOf = readableOutputs(outputsOf, args.nodes, args.workspaceRoot, args.gitFilesCache)
119
133
  const rewritersById = new Map<string, ProjectSet>()
120
134
  const unfoldedById = new Map<string, ProjectSet>()
121
135
  const wsRewriters = new Set<string>()
122
136
  // A cached rewriter matters only to a key that leaves a dependency's key
123
- // out: a `cache.inputs.tasks` filter. With none in the graph every edge
137
+ // out: a `inputs.tasks` filter. With none in the graph every edge
124
138
  // folds and the sets above stay empty, so they are not built (about 2 ms,
125
139
  // median, of a 27 ms memoised walk over the 3,000-task bench; item 750).
126
140
  let anyUnfolded = false
127
141
  for (const n of args.nodes.values()) {
128
- if (n.config.cache?.inputs?.tasks !== undefined) {
142
+ if (n.config.inputs?.tasks !== undefined) {
129
143
  anyUnfolded = true
130
144
  break
131
145
  }
@@ -232,7 +246,7 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
232
246
  // cascade (and forward their producers, above). They inherit
233
247
  // instability from any unstable member.
234
248
  // `computeGroupKey`, with the unkeyed branch kept synchronous.
235
- if (node.config.cache === undefined) {
249
+ if (node.config.inputs === undefined) {
236
250
  const key = computeGroupHash(upstream)
237
251
  keyById.set(id, key)
238
252
  args.groupKeys?.set(id, { key, upstream })
@@ -277,15 +291,15 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
277
291
  {
278
292
  wide: unfolded === undefined ? wide : wide === undefined ? unfolded : wide.or(unfolded),
279
293
  outputsOf,
294
+ readableOf,
280
295
  },
281
296
  )
282
297
  if (unstable) unstableById.add(id)
283
298
 
284
299
  const cacheEnabled = node.config.cache !== undefined
285
300
  if (cacheEnabled && !unstable) stableKeys.push({ hash, node })
286
- // An uncached key reads its whole project, so `dependsOnSiblingOutputs`
287
- // (which answers false for it) is not the gate: any upstream that may
288
- // write into this project, or anywhere, leaves the key preliminary.
301
+ // An uncached key stays preliminary behind any upstream that may write
302
+ // into this project, or anywhere: none of its writes is declared.
289
303
  else if (
290
304
  !cacheEnabled &&
291
305
  !unstable &&
@@ -299,10 +313,94 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
299
313
  return stableKeys
300
314
  }
301
315
 
316
+ /**
317
+ * The same-project outputs a reader's inputs reach, root-relative, each with
318
+ * its project dir and project-relative glob: the outputs the gate would key
319
+ * a reader late over, unless git ignores them (`askIgnoredOutputs`).
320
+ */
321
+ export function gatedOutputs(
322
+ nodes: ReadonlyMap<string, TaskNode>,
323
+ workspaceRoot: string,
324
+ ): Map<string, { projectDir: string; glob: string }> {
325
+ const out = new Map<string, { projectDir: string; glob: string }>()
326
+ // Each project's positive outputs with their literal heads, read once:
327
+ // 1,000 projects' tasks spent 5-8 ms here re-splitting the same globs.
328
+ const outputsOf = new Map<string, { glob: string; head: string }[]>()
329
+ for (const n of nodes.values()) {
330
+ const files = n.config.cache?.outputs.files
331
+ if (files === undefined || files.length === 0) continue
332
+ let list = outputsOf.get(n.projectName)
333
+ if (list === undefined) outputsOf.set(n.projectName, (list = []))
334
+ for (const glob of files)
335
+ if (!glob.startsWith('!')) list.push({ glob, head: literalPrefix(glob) })
336
+ }
337
+ if (outputsOf.size === 0) return out
338
+ for (const n of nodes.values()) {
339
+ const inputs = n.config.inputs?.files
340
+ const outputs = outputsOf.get(n.projectName)
341
+ if (inputs === undefined || outputs === undefined || isGroupTask(n)) continue
342
+ let back: string[] | undefined
343
+ let rel: string | undefined
344
+ for (const { glob: o, head } of outputs) {
345
+ if (!workspaceInputsReach(inputs, [head])) continue
346
+ rel ??= relPosix(workspaceRoot, n.projectDir)
347
+ back ??= inputs.filter((g) => g.startsWith('!')).map((g) => g.slice(1))
348
+ const rooted = rel === '' ? o : `${rel}/${o}`
349
+ if (out.has(rooted) || (back.length > 0 && outputTakenBack(o, back))) continue
350
+ out.set(rooted, { projectDir: n.projectDir, glob: o })
351
+ }
352
+ }
353
+ return out
354
+ }
355
+
356
+ /**
357
+ * Asks git which of `gated` it ignores, started beside the run's
358
+ * enumeration; the call it returns settles `files.ignoredOutputs` once that
359
+ * enumeration is in, whose listing says whether a tracked or re-included
360
+ * file lies under a glob. Nothing gated: no spawn.
361
+ */
362
+ export function askIgnoredOutputs(
363
+ gated: ReadonlyMap<string, { projectDir: string; glob: string }>,
364
+ workspaceRoot: string,
365
+ ): (files: GitFilesCache) => Promise<void> {
366
+ if (gated.size === 0) return async () => {}
367
+ const paths = [...gated.keys()].flatMap((g) => ignoreProbes(g)?.paths ?? [])
368
+ const asked = paths.length === 0 ? Promise.resolve(null) : checkIgnore(workspaceRoot, paths)
369
+ return async (files) => {
370
+ files.ignoredOutputs = wholeIgnored(gated.keys(), await asked, (g) => {
371
+ const { projectDir, glob } = gated.get(g)!
372
+ const listed = files.get(projectDir)
373
+ return listed === undefined || listed.some((f) => taskGlob(glob).match(f))
374
+ })
375
+ }
376
+ }
377
+
378
+ /** `outputsOf` less what git ignores whole; the same map when it ignores none. */
379
+ function readableOutputs(
380
+ outputsOf: ReadonlyMap<string, string[]>,
381
+ nodes: ReadonlyMap<string, TaskNode>,
382
+ workspaceRoot: string,
383
+ files: GitFilesCache,
384
+ ): ReadonlyMap<string, readonly string[]> {
385
+ const ignored = files.ignoredOutputs
386
+ if (ignored.size === 0) return outputsOf
387
+ const dirOf = new Map<string, string>()
388
+ for (const n of nodes.values()) dirOf.set(n.projectName, relPosix(workspaceRoot, n.projectDir))
389
+ const out = new Map<string, readonly string[]>()
390
+ for (const [project, outputs] of outputsOf) {
391
+ const rel = dirOf.get(project)!
392
+ out.set(
393
+ project,
394
+ outputs.filter((o) => !ignored.has(rel === '' ? o : `${rel}/${o}`)),
395
+ )
396
+ }
397
+ return out
398
+ }
399
+
302
400
  const probesAfterWritesOf = new WeakMap<ReadonlyMap<string, TaskNode>, ReadonlySet<string>>()
303
401
 
304
402
  /**
305
- * The cached tasks whose `cache.inputs.runtime` / `workspaceRuntime` probes
403
+ * The cached tasks whose `inputs.runtime` / `workspaceRuntime` probes
306
404
  * may read what an upstream writes this run while the key folds nothing of
307
405
  * that upstream (X-34). A probe is a shell command and may read anywhere,
308
406
  * so no glob bounds it, and its answer taken before the writer ran is the
@@ -330,8 +428,8 @@ export function probesAfterWrites(
330
428
  const late = new Set<string>()
331
429
  probesAfterWritesOf.set(nodes, late)
332
430
  const probes = (n: TaskNode): boolean =>
333
- (n.config.cache?.inputs?.runtime?.length ?? 0) > 0 ||
334
- (n.config.cache?.inputs?.workspaceRuntime?.length ?? 0) > 0
431
+ (n.config.inputs?.runtime?.length ?? 0) > 0 ||
432
+ (n.config.inputs?.workspaceRuntime?.length ?? 0) > 0
335
433
  let anyProbe = false
336
434
  for (const n of nodes.values()) {
337
435
  if (probes(n)) {
@@ -361,7 +459,7 @@ export function probesAfterWrites(
361
459
  let writer = false
362
460
  let unfolded = false
363
461
  // No filter and no order-only edge: the key folds every dependency.
364
- const foldsAll = node.orderOnly === undefined && node.config.cache?.inputs?.tasks === undefined
462
+ const foldsAll = node.orderOnly === undefined && node.config.inputs?.tasks === undefined
365
463
  let folds: Set<string> | undefined
366
464
  for (const dep of node.deps) {
367
465
  if (!nodes.has(dep)) continue
@@ -389,7 +487,7 @@ function foldedBy(
389
487
  upstream: TaskOutcome[],
390
488
  keyById: Map<string, string>,
391
489
  ): (dep: string) => boolean {
392
- const filter = node.config.cache?.inputs?.tasks
490
+ const filter = node.config.inputs?.tasks
393
491
  if (isGroupTask(node) || filter === undefined) return (dep) => keyById.has(dep)
394
492
  const hashes = new Set(
395
493
  filterUpstreamHashes(upstream, filter, node.projectName, node.id).map(([, h]) => h),
@@ -431,7 +529,7 @@ function synthUpstream(
431
529
  * `outputs.files` — direct or transitive — makes the key
432
530
  * preliminary. Project boundaries are hard, so only SAME-project
433
531
  * outputs can reach a project-relative input.
434
- * - `cache.inputs.workspaceFiles` is boundary-free: it can read ANY
532
+ * - `inputs.workspaceFiles` is boundary-free: it can read ANY
435
533
  * project's dir (so any upstream `outputs.files`, in any project,
436
534
  * matters) or a root-anchored location (so any upstream
437
535
  * `outputs.workspaceFiles` matters). Either → preliminary key. This
@@ -452,12 +550,14 @@ export function dependsOnSiblingOutputs(
452
550
  wide: ProjectNames | undefined
453
551
  /** Each project's declared `outputs.files`, over all its tasks. */
454
552
  outputsOf: ReadonlyMap<string, readonly string[]>
553
+ /** The same less what git ignores whole: no key reads those. */
554
+ readableOf?: ReadonlyMap<string, readonly string[]>
455
555
  },
456
556
  ): boolean {
457
557
  const cache = node.config.cache
458
- // A cache-disabled task has no key to prefetch anyway; treat as
459
- // unstable-irrelevant (caller filters on cacheEnabled).
460
- if (cache === undefined) return false
558
+ const declared = node.config.inputs
559
+ // A task with no inputs has no key to take up front.
560
+ if (declared === undefined) return false
461
561
  if (upstreamOutputProjects.has(node.projectName)) {
462
562
  // A same-project producer whose writes are all declared reaches this
463
563
  // task's inputs only where the globs can meet: a `test` reading `src/**`
@@ -466,21 +566,29 @@ export function dependsOnSiblingOutputs(
466
566
  // projects (A-20). Undeclared writes, and a rewriter this key does not
467
567
  // fold, may land anywhere: those stay project-wide.
468
568
  if (sameProject === undefined || sameProject.wide?.has(node.projectName) === true) return true
469
- const inputs = cache.inputs?.files
569
+ const inputs = declared.files
470
570
  const outputs = sameProject.outputsOf.get(node.projectName) ?? []
471
- if (inputs === undefined || outputs.length === 0) return true
571
+ if (outputs.length === 0) return true
472
572
  // An output the reader's own `!` inputs take back whole is not in its
473
573
  // key: `['**/*', '!dist/**']` beside a `dist/**` producer keys the same
474
- // before and after it, which is what `run.rules.upfrontKeys` asks a config
475
- // to write (X-54).
574
+ // before and after it, which is what `vx lock` asks a config to write
575
+ // (X-54).
576
+ // Nor is an output git ignores whole: a key reads only what git lists.
577
+ // Unless an entry starts inside it (`dist/**`): naming an ignored path
578
+ // makes it an input (`ignoredReach` in cache/inputs.ts).
579
+ const readable = withNamedIgnored(
580
+ inputs,
581
+ outputs,
582
+ sameProject.readableOf?.get(node.projectName) ?? outputs,
583
+ )
476
584
  const back = inputs.filter((g) => g.startsWith('!')).map((g) => g.slice(1))
477
- const reach = back.length === 0 ? outputs : outputs.filter((o) => !outputTakenBack(o, back))
585
+ const reach = back.length === 0 ? readable : readable.filter((o) => !outputTakenBack(o, back))
478
586
  if (reach.length > 0 && workspaceInputsReach(inputs, reach.map(literalPrefix))) return true
479
587
  // Nor where it WRITES inside another task's tree: restored ahead of
480
588
  // that producer, its restore raced the producer's clean and extract
481
589
  // (M-5; invariant 2 of docs/design/overlapping-outputs-2026-09.md).
482
590
  // `outputsOf` holds this task's own globs too; set them aside once.
483
- const own = cache.outputs.files
591
+ const own = cache?.outputs.files ?? []
484
592
  if (own.length > 0) {
485
593
  const others = [...outputs]
486
594
  for (const g of own) {
@@ -499,7 +607,7 @@ export function dependsOnSiblingOutputs(
499
607
  // `app` read `gen/**` project-relative), so the mere presence of a
500
608
  // workspace-output producer upstream makes the key preliminary.
501
609
  if (hasWsOutputUpstream) return true
502
- const wsInputs = cache.inputs?.workspaceFiles ?? []
610
+ const wsInputs = declared.workspaceFiles ?? []
503
611
  if (wsInputs.length > 0 && upstreamOutputProjects.size > 0) {
504
612
  // Without the dirs the old answer stands: a workspace reader over any
505
613
  // producer is preliminary. With them, only a reader whose globs can
@@ -519,6 +627,29 @@ export function dependsOnSiblingOutputs(
519
627
  return false
520
628
  }
521
629
 
630
+ /**
631
+ * `readable`, plus each output git ignores whole that a positive entry of
632
+ * `inputs` starts inside: its literal prefix at or below the output's.
633
+ */
634
+ function withNamedIgnored(
635
+ inputs: readonly string[],
636
+ outputs: readonly string[],
637
+ readable: readonly string[],
638
+ ): readonly string[] {
639
+ if (readable.length === outputs.length) return readable
640
+ const named = outputs.filter((o) => {
641
+ if (readable.includes(o)) return false
642
+ const at = literalPrefix(o)
643
+ if (at === '') return false
644
+ return inputs.some((g) => {
645
+ if (g.startsWith('!')) return false
646
+ const p = literalPrefix(g)
647
+ return p === at || p.startsWith(`${at}/`)
648
+ })
649
+ })
650
+ return named.length === 0 ? readable : [...readable, ...named]
651
+ }
652
+
522
653
  /**
523
654
  * Can any positive `workspaceFiles` entry match a path inside one of
524
655
  * `dirs` (workspace-relative project dirs)? An entry's reach is its
@@ -546,14 +677,20 @@ export function workspaceInputsReach(
546
677
 
547
678
  /** A glob's leading literal segments, `''` when its first holds a wildcard. */
548
679
  function literalPrefix(glob: string): string {
680
+ let prefix = literalPrefixes.get(glob)
681
+ if (prefix !== undefined) return prefix
549
682
  const literal: string[] = []
550
683
  for (const seg of normalizeGlob(glob).split('/')) {
551
684
  if (!isLiteralPattern(seg)) break
552
685
  literal.push(seg)
553
686
  }
554
- return literal.join('/')
687
+ literalPrefixes.set(glob, (prefix = literal.join('/')))
688
+ return prefix
555
689
  }
556
690
 
691
+ /** Per glob string, which the configs bound: every task repeats a few. */
692
+ const literalPrefixes = new Map<string, string>()
693
+
557
694
  /** What the stability gate reads of a set of project names. */
558
695
  interface ProjectNames extends Iterable<string> {
559
696
  has(name: string): boolean
@@ -0,0 +1,70 @@
1
+ // vx's own steps (reading the lock, keying, uploads, teardown) rendered as
2
+ // tasks (M, 2026-10-15): `vx:<step>`, and `<plugin package>:setup` /
3
+ // `:teardown`. `:` is no task id's separator, so a step never collides with
4
+ // a project named `vx` or a plugin package's own `#setup` task. A step is
5
+ // not a task: no stat, history row or telemetry record counts it.
6
+ //
7
+ // A run shows only the steps it waits on, before its first task or its end
8
+ // (M, 2026-10-16). One that runs beside the tasks (`vx:prefetch`) is a
9
+ // `background` step: never shown, and kept for `vx last` (run-steps.ts).
10
+ import type { Logger } from '../report/index.js'
11
+ import type { RunStep } from './run-steps.js'
12
+
13
+ export interface Steps {
14
+ /** Time `work` as step `id` when steps show; else run it as it is. */
15
+ step<T>(id: string, work: () => Promise<T>): Promise<T>
16
+ stepSync<T>(id: string, work: () => T): T
17
+ /** Time `work`, which the run does not wait on, as step `id` into `background`. */
18
+ background<T>(id: string, work: () => Promise<T>): Promise<T>
19
+ /** The background steps that have ended. */
20
+ readonly ended: readonly RunStep[]
21
+ }
22
+
23
+ /** Steps that tell `log`, or none when nothing renders them (zero cost). */
24
+ export function createSteps(log: Logger, shown: boolean): Steps {
25
+ const ended: RunStep[] = []
26
+ // Timed whether or not anything shows: only a run with a remote starts one.
27
+ const background = async <T>(id: string, work: () => Promise<T>): Promise<T> => {
28
+ const t0 = performance.now()
29
+ let ok = false
30
+ try {
31
+ const value = await work()
32
+ ok = true
33
+ return value
34
+ } finally {
35
+ ended.push({ id, durationMs: Math.round(performance.now() - t0), ok })
36
+ }
37
+ }
38
+ if (!shown)
39
+ return { step: (_id, work) => work(), stepSync: (_id, work) => work(), background, ended }
40
+ const end = (id: string, t0: number, ok: boolean): void =>
41
+ log.stepEnd?.(id, { durationMs: performance.now() - t0, ok })
42
+ return {
43
+ background,
44
+ ended,
45
+ async step(id, work) {
46
+ const t0 = performance.now()
47
+ log.stepStart?.(id)
48
+ let ok = false
49
+ try {
50
+ const value = await work()
51
+ ok = true
52
+ return value
53
+ } finally {
54
+ end(id, t0, ok)
55
+ }
56
+ },
57
+ stepSync(id, work) {
58
+ const t0 = performance.now()
59
+ log.stepStart?.(id)
60
+ let ok = false
61
+ try {
62
+ const value = work()
63
+ ok = true
64
+ return value
65
+ } finally {
66
+ end(id, t0, ok)
67
+ }
68
+ },
69
+ }
70
+ }
@@ -1,6 +1,6 @@
1
1
  import { lstatSync } from 'node:fs'
2
2
  import path from 'node:path'
3
- import type { TaskConfig, CacheConfig } from '../config.js'
3
+ import type { TaskConfig } from '../config.js'
4
4
  import type { ProjectFilesCache, WorkspaceFilesCache } from '../cache/index.js'
5
5
  import {
6
6
  ABSENT_INPUT,
@@ -13,6 +13,7 @@ import {
13
13
  racyWindowMs,
14
14
  resolveInputs,
15
15
  type GitFilesCache,
16
+ type TaskInputComponent,
16
17
  WORKSPACE_OUTPUT_PREFIX,
17
18
  } from '../cache/index.js'
18
19
  import type { InputFile, TaskInputs } from '../exec/index.js'
@@ -52,20 +53,6 @@ export function createHashCache(): HashCache {
52
53
  }
53
54
  }
54
55
 
55
- /**
56
- * One cache-key component captured at hash time. On a cache MISS the
57
- * orchestrator persists these to `entry_inputs` (keyed by the entry
58
- * hash, inside the save transaction) so a later run can diff its inputs
59
- * against this one (the Tier-3 "why did this re-run?" moat). A cache
60
- * HIT never saves, so it captures nothing — the warm path is free.
61
- * Mirrors `Cache.key()`'s fold-site rows one-for-one.
62
- */
63
- export interface TaskInputComponent {
64
- kind: string
65
- name: string
66
- hash: string
67
- }
68
-
69
56
  export interface ComputeHashArgs {
70
57
  node: TaskNode
71
58
  upstream: TaskOutcome[]
@@ -94,15 +81,16 @@ export interface ComputeHashArgs {
94
81
  /**
95
82
  * Resolve every input the cache key depends on (file content, env
96
83
  * values, upstream hashes, config bytes, forwardArgs) and ask the cache layer to combine them. Used by both
97
- * `executeTask` for the real run and `plan()` for `--dry-run` /
98
- * `--graph` previews.
84
+ * `executeTask` for the real run and `plan()` for `--dry`
85
+ * previews.
99
86
  *
100
87
  * Caller is responsible for handling the group-task case before
101
- * calling this — groups have no `exec` and no `cache.inputs`, so the
88
+ * calling this — an unkeyed group has no `exec` and no `inputs`, so the
102
89
  * key-derivation steps below would all fall back to defaults.
103
90
  * `computeGroupKey` is exported for that purpose.
104
91
  */
105
92
  export async function computeTaskHash(args: ComputeHashArgs): Promise<string> {
93
+ if (isKeyless(args.node.config)) return keylessHash(args.node.id)
106
94
  const end = span('task hash')
107
95
  try {
108
96
  return await args.cache.key((await resolveKeyInput(args)).input)
@@ -111,6 +99,27 @@ export async function computeTaskHash(args: ComputeHashArgs): Promise<string> {
111
99
  }
112
100
  }
113
101
 
102
+ /**
103
+ * A task that runs a command and declares no `inputs`: nothing says what
104
+ * it read, so no key can stand for its outputs, and a key that folds it
105
+ * would replay a stale entry (owner, 2026-10-16).
106
+ */
107
+ export function isKeyless(task: TaskConfig): boolean {
108
+ return task.exec !== undefined && task.inputs === undefined
109
+ }
110
+
111
+ /**
112
+ * Drawn once per process, so no run derives another run's keyless keys;
113
+ * on first use, as the browser playground loads this module with no `crypto`.
114
+ */
115
+ let keylessSalt: string | undefined
116
+
117
+ /** A keyless task's key: this run's alone, so every key folding it misses. */
118
+ function keylessHash(taskId: string): string {
119
+ keylessSalt ??= crypto.randomUUID()
120
+ return xxh3hex(`keyless\0${keylessSalt}\0${taskId}`)
121
+ }
122
+
114
123
  /**
115
124
  * One file the key folded: the digest it folded, and since when (ms since
116
125
  * the epoch) that digest is known to be the file's content — the
@@ -263,14 +272,14 @@ async function resolveKeyInput(
263
272
  args: ComputeHashArgs,
264
273
  ): Promise<{ input: CacheKeyInput; listings: InputListing[] }> {
265
274
  const cfg = args.node.config
266
- const cacheCfg: CacheConfig | undefined = cfg.cache
275
+ const cacheCfg = cfg.cache
267
276
  const outputs = cacheCfg?.outputs.files ?? []
268
277
 
269
278
  const resolved = await resolveInputs({
270
279
  projectDir: args.node.projectDir,
271
280
  workspaceRoot: args.workspaceRoot,
272
281
  envSource: process.env,
273
- inputs: cacheCfg?.inputs,
282
+ inputs: cfg.inputs,
274
283
  ownOutputs: outputs,
275
284
  ownWorkspaceOutputs: cacheCfg?.outputs.workspaceFiles ?? [],
276
285
  nestedProjectDirs: args.nestedProjectDirs,
@@ -295,7 +304,7 @@ async function resolveKeyInput(
295
304
 
296
305
  const upstreamPairs = filterUpstreamHashes(
297
306
  args.upstream,
298
- cacheCfg?.inputs?.tasks,
307
+ cfg.inputs?.tasks,
299
308
  args.node.projectName,
300
309
  args.node.id,
301
310
  )
@@ -303,7 +312,7 @@ async function resolveKeyInput(
303
312
  // hash → upstream task id, for capture-row naming only (not folded).
304
313
  const upstreamIds = new Map(upstreamPairs.map(([id, hash]) => [hash, id]))
305
314
  // The DEPENDENCY closure, groups expanded — for the executor's input root.
306
- // Deliberately NOT the filtered set above: `cache.inputs.tasks` says which
315
+ // Deliberately NOT the filtered set above: `inputs.tasks` says which
307
316
  // upstream KEYS this task's key folds, and what a task may READ is
308
317
  // `dependsOn`. Locally every dependency's outputs are on disk before the
309
318
  // command runs however the filter is written, so an executor that ships the
@@ -319,7 +328,7 @@ async function resolveKeyInput(
319
328
  // also merge the workspace-wide partition's OIDs (keyed by abs
320
329
  // path, so the two maps agree wherever they overlap).
321
330
  let fileHashes = args.gitFilesCache?.oidsFor(args.node.projectDir)
322
- if ((cacheCfg?.inputs.workspaceFiles?.length ?? 0) > 0) {
331
+ if ((cfg.inputs?.workspaceFiles?.length ?? 0) > 0) {
323
332
  const wsOids = args.gitFilesCache?.oidsFor(args.workspaceRoot)
324
333
  if (wsOids !== undefined && wsOids !== fileHashes) {
325
334
  fileHashes = fileHashes === undefined ? wsOids : new Map([...wsOids, ...fileHashes])
@@ -403,12 +412,12 @@ function hashableConfig(cfg: TaskConfig): unknown {
403
412
  }
404
413
 
405
414
  /**
406
- * A group's key: its upstream, and for a keyed group — one with `cache`,
415
+ * A group's key: its upstream, and for a keyed group — one with `inputs`,
407
416
  * the default `build` (projects.ts) or a config's — its own inputs too,
408
417
  * the way a task's key folds them.
409
418
  */
410
419
  export async function computeGroupKey(args: ComputeHashArgs): Promise<string> {
411
- if (args.node.config.cache === undefined) return computeGroupHash(args.upstream)
420
+ if (args.node.config.inputs === undefined) return computeGroupHash(args.upstream)
412
421
  return computeTaskHash(args)
413
422
  }
414
423
 
@@ -21,7 +21,7 @@
21
21
  // task printed nothing".
22
22
 
23
23
  import type { TaskStatus } from '../graph/index.js'
24
- import type { CacheSource } from './telemetry.js'
24
+ import type { CacheSource } from '../graph/index.js'
25
25
 
26
26
  /** Version of the drained-bundle shape below. It is the canonical drained
27
27
  * logs format, not one transport's: every sink ships the same object. */