@vzn/vx 0.0.639 → 0.0.641

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 (88) hide show
  1. package/README.md +1 -1
  2. package/docs/caching.md +158 -106
  3. package/docs/cli.md +80 -45
  4. package/docs/execution.md +11 -10
  5. package/docs/features.md +9 -8
  6. package/docs/patterns.md +1 -1
  7. package/docs/schema.md +252 -190
  8. package/docs/security.md +5 -4
  9. package/launcher.sh +9 -2
  10. package/package.json +5 -5
  11. package/schemas/show.json +14 -14
  12. package/skills/vx/SKILL.md +4 -3
  13. package/src/bin.ts +8 -6
  14. package/src/cache/archive.ts +248 -41
  15. package/src/cache/cache.ts +46 -19
  16. package/src/cache/file-hashes.ts +7 -12
  17. package/src/cache/fs-clock.ts +62 -0
  18. package/src/cache/git-inputs.ts +2 -2
  19. package/src/cache/index.ts +6 -0
  20. package/src/cache/inputs.ts +228 -111
  21. package/src/cache/key-fold.ts +23 -3
  22. package/src/cache/layer.ts +15 -8
  23. package/src/cache/layered-cache.ts +8 -7
  24. package/src/cache/linked-inputs.ts +87 -0
  25. package/src/cache/scoped-cache.ts +8 -2
  26. package/src/cli/foreign-flags.ts +1 -1
  27. package/src/cli/help.ts +7 -1
  28. package/src/cli/init.ts +23 -11
  29. package/src/cli/last.ts +24 -1
  30. package/src/cli/plugin-templates.ts +2 -2
  31. package/src/cli/select.ts +16 -6
  32. package/src/cli/show.ts +9 -6
  33. package/src/cli/upgrade.ts +132 -21
  34. package/src/cli/watch-filter.ts +23 -12
  35. package/src/cli/watch-fs.ts +2 -1
  36. package/src/cli/watch-judge.ts +49 -5
  37. package/src/cli/watch-set.ts +18 -8
  38. package/src/cli/watch.ts +74 -2
  39. package/src/cli/why.ts +5 -3
  40. package/src/config.ts +19 -15
  41. package/src/exec/executor.ts +4 -4
  42. package/src/graph/dependency-spec.ts +2 -2
  43. package/src/graph/folds.ts +236 -0
  44. package/src/graph/index.ts +8 -1
  45. package/src/graph/scheduler.ts +41 -15
  46. package/src/graph/task-graph.ts +135 -73
  47. package/src/handoff.ts +5 -0
  48. package/src/index.ts +2 -2
  49. package/src/orchestrator/affected-tasks.ts +20 -19
  50. package/src/orchestrator/download-policy.ts +16 -22
  51. package/src/orchestrator/excluded-keys.ts +4 -5
  52. package/src/orchestrator/execute-task.ts +29 -25
  53. package/src/orchestrator/hit-restore.ts +101 -27
  54. package/src/orchestrator/index.ts +1 -0
  55. package/src/orchestrator/keyed-projects.ts +12 -51
  56. package/src/orchestrator/local-shortcircuit.ts +9 -1
  57. package/src/orchestrator/persistent-task.ts +3 -3
  58. package/src/orchestrator/placement.ts +2 -2
  59. package/src/orchestrator/plan.ts +1 -1
  60. package/src/orchestrator/plugin-host.ts +66 -27
  61. package/src/orchestrator/plugin.ts +12 -2
  62. package/src/orchestrator/prepare.ts +12 -5
  63. package/src/orchestrator/projects.ts +7 -10
  64. package/src/orchestrator/run-report.ts +145 -46
  65. package/src/orchestrator/run-steps.ts +57 -0
  66. package/src/orchestrator/run.ts +76 -44
  67. package/src/orchestrator/sandbox-fix.ts +1 -1
  68. package/src/orchestrator/sandbox-request.ts +2 -2
  69. package/src/orchestrator/stable-keys.ts +59 -26
  70. package/src/orchestrator/steps.ts +26 -6
  71. package/src/orchestrator/task-hash.ts +31 -9
  72. package/src/orchestrator/telemetry-host.ts +3 -3
  73. package/src/orchestrator/telemetry.ts +9 -4
  74. package/src/orchestrator/upstream.ts +4 -181
  75. package/src/report/framed-output.ts +1 -1
  76. package/src/report/logger.ts +5 -2
  77. package/src/report/status-line.ts +7 -3
  78. package/src/report/summary.ts +6 -4
  79. package/src/settings.ts +11 -17
  80. package/src/util/index.ts +3 -0
  81. package/src/util/paths.ts +39 -5
  82. package/src/workspace/affected.ts +81 -2
  83. package/src/workspace/config-eval.ts +2 -2
  84. package/src/workspace/config-imports.ts +1 -1
  85. package/src/workspace/config-schema.ts +142 -143
  86. package/src/workspace/lockfile.ts +1 -1
  87. package/src/workspace/migration.ts +19 -3
  88. package/src/workspace/project-loader.ts +5 -4
@@ -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,
@@ -85,11 +85,12 @@ export interface ComputeHashArgs {
85
85
  * previews.
86
86
  *
87
87
  * Caller is responsible for handling the group-task case before
88
- * 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
89
89
  * key-derivation steps below would all fall back to defaults.
90
90
  * `computeGroupKey` is exported for that purpose.
91
91
  */
92
92
  export async function computeTaskHash(args: ComputeHashArgs): Promise<string> {
93
+ if (isKeyless(args.node.config)) return keylessHash(args.node.id)
93
94
  const end = span('task hash')
94
95
  try {
95
96
  return await args.cache.key((await resolveKeyInput(args)).input)
@@ -98,6 +99,27 @@ export async function computeTaskHash(args: ComputeHashArgs): Promise<string> {
98
99
  }
99
100
  }
100
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
+
101
123
  /**
102
124
  * One file the key folded: the digest it folded, and since when (ms since
103
125
  * the epoch) that digest is known to be the file's content — the
@@ -250,14 +272,14 @@ async function resolveKeyInput(
250
272
  args: ComputeHashArgs,
251
273
  ): Promise<{ input: CacheKeyInput; listings: InputListing[] }> {
252
274
  const cfg = args.node.config
253
- const cacheCfg: CacheConfig | undefined = cfg.cache
275
+ const cacheCfg = cfg.cache
254
276
  const outputs = cacheCfg?.outputs.files ?? []
255
277
 
256
278
  const resolved = await resolveInputs({
257
279
  projectDir: args.node.projectDir,
258
280
  workspaceRoot: args.workspaceRoot,
259
281
  envSource: process.env,
260
- inputs: cacheCfg?.inputs,
282
+ inputs: cfg.inputs,
261
283
  ownOutputs: outputs,
262
284
  ownWorkspaceOutputs: cacheCfg?.outputs.workspaceFiles ?? [],
263
285
  nestedProjectDirs: args.nestedProjectDirs,
@@ -282,7 +304,7 @@ async function resolveKeyInput(
282
304
 
283
305
  const upstreamPairs = filterUpstreamHashes(
284
306
  args.upstream,
285
- cacheCfg?.inputs?.tasks,
307
+ cfg.inputs?.tasks,
286
308
  args.node.projectName,
287
309
  args.node.id,
288
310
  )
@@ -290,7 +312,7 @@ async function resolveKeyInput(
290
312
  // hash → upstream task id, for capture-row naming only (not folded).
291
313
  const upstreamIds = new Map(upstreamPairs.map(([id, hash]) => [hash, id]))
292
314
  // The DEPENDENCY closure, groups expanded — for the executor's input root.
293
- // Deliberately NOT the filtered set above: `cache.inputs.tasks` says which
315
+ // Deliberately NOT the filtered set above: `inputs.tasks` says which
294
316
  // upstream KEYS this task's key folds, and what a task may READ is
295
317
  // `dependsOn`. Locally every dependency's outputs are on disk before the
296
318
  // command runs however the filter is written, so an executor that ships the
@@ -306,7 +328,7 @@ async function resolveKeyInput(
306
328
  // also merge the workspace-wide partition's OIDs (keyed by abs
307
329
  // path, so the two maps agree wherever they overlap).
308
330
  let fileHashes = args.gitFilesCache?.oidsFor(args.node.projectDir)
309
- if ((cacheCfg?.inputs.workspaceFiles?.length ?? 0) > 0) {
331
+ if ((cfg.inputs?.workspaceFiles?.length ?? 0) > 0) {
310
332
  const wsOids = args.gitFilesCache?.oidsFor(args.workspaceRoot)
311
333
  if (wsOids !== undefined && wsOids !== fileHashes) {
312
334
  fileHashes = fileHashes === undefined ? wsOids : new Map([...wsOids, ...fileHashes])
@@ -390,12 +412,12 @@ function hashableConfig(cfg: TaskConfig): unknown {
390
412
  }
391
413
 
392
414
  /**
393
- * 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`,
394
416
  * the default `build` (projects.ts) or a config's — its own inputs too,
395
417
  * the way a task's key folds them.
396
418
  */
397
419
  export async function computeGroupKey(args: ComputeHashArgs): Promise<string> {
398
- if (args.node.config.cache === undefined) return computeGroupHash(args.upstream)
420
+ if (args.node.config.inputs === undefined) return computeGroupHash(args.upstream)
399
421
  return computeTaskHash(args)
400
422
  }
401
423
 
@@ -27,8 +27,8 @@ import type { VxPlugin } from './plugin.js'
27
27
  export interface TelemetryHandle {
28
28
  /** Emit the per-run summary to every sink (crash-isolated). */
29
29
  emitSummary(summary: RunSummaryRecord): void
30
- /** Await every sink's flush (crash-isolated, time-bounded by the sink). */
31
- flush(): Promise<void>
30
+ /** Await every sink's flush (crash-isolated, time-bounded by the sink), each as a `<plugin>:telemetry` step. */
31
+ flush(step?: <T>(id: string, work: () => Promise<T>) => Promise<T>): Promise<void>
32
32
  /** Remove the bus subscription. Idempotent. */
33
33
  dispose(): void
34
34
  /** Sample a running task's process tree; see `TelemetrySource.track`. */
@@ -125,7 +125,7 @@ export async function subscribeTelemetry(
125
125
  const dispose = bus.subscribe(source.subscriber)
126
126
  return {
127
127
  emitSummary: (summary) => source.emitSummary(summary),
128
- flush: () => source.flush(),
128
+ flush: (step) => source.flush(step),
129
129
  dispose,
130
130
  ...(source.track !== undefined ? { track: source.track } : {}),
131
131
  }
@@ -391,8 +391,11 @@ export interface TelemetrySource {
391
391
  readonly subscriber: RunEventSubscriber
392
392
  /** Fan the per-run summary to every sink's `onRunSummary` (crash-isolated). */
393
393
  emitSummary(summary: RunSummaryRecord): void
394
- /** Await every sink's `flush()` (each crash-isolated, all time-bounded). */
395
- flush(): Promise<void>
394
+ /**
395
+ * Await every sink's `flush()` (each crash-isolated, all time-bounded),
396
+ * each as the step `<plugin>:telemetry` through `step`.
397
+ */
398
+ flush(step?: <T>(id: string, work: () => Promise<T>) => Promise<T>): Promise<void>
396
399
  /**
397
400
  * Sample this task's process tree every `SAMPLE_MS` until the returned
398
401
  * function is called, the root exits, or the run ends. Present only when
@@ -625,7 +628,7 @@ export function createTelemetrySource(args: {
625
628
  }
626
629
  }
627
630
  },
628
- async flush(): Promise<void> {
631
+ async flush(step = <T>(_id: string, work: () => Promise<T>): Promise<T> => work()) {
629
632
  const ms = teardownTimeoutMs()
630
633
  // Bounded, for the same reason plugin teardown is bounded in
631
634
  // plugin-host.ts: run() awaits this BEFORE closeCache() and before it
@@ -641,7 +644,9 @@ export function createTelemetrySource(args: {
641
644
  sinks.map(async (sink) => {
642
645
  if (disabled.has(sink) || sink.flush === undefined) return
643
646
  try {
644
- await sink.flush(deadline.signal)
647
+ await step(`${args.owners?.get(sink) ?? label(sink)}:telemetry`, () =>
648
+ sink.flush!(deadline.signal),
649
+ )
645
650
  } catch (err) {
646
651
  // A flush failure can never break the run — but it still costs
647
652
  // this sink its export, so say so rather than dropping it.
@@ -1,14 +1,9 @@
1
1
  import {
2
- DependencySpecError,
3
- compileTaskPattern,
4
- isGroupTask,
5
- isTaskPattern,
6
- parseDependencySpec,
7
- type DependencySpec,
2
+ selectFoldedDeps,
3
+ type FoldCandidate,
8
4
  type TaskNode,
9
5
  type TaskOutcome,
10
6
  } from '../graph/index.js'
11
- import { UserError } from '../util/index.js'
12
7
 
13
8
  /**
14
9
  * A task's upstream as its KEY reads it: the live outcomes of its scheduled
@@ -36,15 +31,9 @@ export function keyedOutcome(node: TaskNode, hash: string | undefined): TaskOutc
36
31
  return o
37
32
  }
38
33
 
39
- /** `node.deps` less its ordering-only edges: the dependencies a key may fold. */
40
- export function keyedDeps(node: TaskNode): readonly string[] {
41
- const order = node.orderOnly
42
- return order === undefined ? node.deps : node.deps.filter((d) => !order.includes(d))
43
- }
44
-
45
34
  /**
46
35
  * Pick which upstream task hashes participate in the current task's
47
- * cache key, filtered by `cache.inputs.tasks`. The folded value is the
36
+ * cache key, filtered by `inputs.tasks`. The folded value is the
48
37
  * upstream's own cache key (its input-based task hash) — pure-input
49
38
  * transitive hashing, like Turbo/Nx. An upstream change propagates
50
39
  * through its key into every dependent's key. There is deliberately NO
@@ -77,135 +66,6 @@ export function filterUpstreamHashes(
77
66
  ])
78
67
  }
79
68
 
80
- /**
81
- * A filter selects among the task's own dependencies, never through a
82
- * group: `dependsOn: ['build']` over a group `build` of `build.a` and
83
- * `build.b` offers the filter `build`, not its members. A pattern that
84
- * matches only a member (`build.*`) therefore selected nothing, and the key stopped moving
85
- * with that member's inputs: a stale hit under a green run. Such a spec
86
- * is refused, once, over the graph every key site reads. One that matches
87
- * nothing anywhere stays legal (a preset's pattern needn't match in every
88
- * project). An exact name is refused earlier, at load, unless `dependsOn`
89
- * names it (`assertFilterNamesDeclaredDeps`).
90
- */
91
- export function refuseFiltersThroughGroups(nodes: ReadonlyMap<string, TaskNode>): void {
92
- for (const node of nodes.values()) {
93
- const filter = node.config.cache?.inputs?.tasks
94
- if (filter === undefined || filter.length === 0) continue
95
- const deps = keyedDeps(node).flatMap((id) => nodes.get(id) ?? [])
96
- const groups = deps.filter(isGroupTask)
97
- if (groups.length === 0) continue
98
- for (const raw of filter) {
99
- const spec = parseSpec(raw, node.id)
100
- if (spec.negated) continue
101
- const matches = specMatcher(spec)
102
- const hit = (n: TaskNode): boolean => matches(n, n.projectName === node.projectName)
103
- if (deps.some(hit)) continue
104
- for (const group of groups) {
105
- const member = groupMembers(group, nodes).find(hit)
106
- if (member === undefined) continue
107
- throw new UserError(
108
- `${node.id}: cache.inputs.tasks: '${raw}' matches ${member.id} only through the group ${group.id}, so it selects nothing; name '${group.taskName}' or depend on ${member.id} directly`,
109
- )
110
- }
111
- }
112
- }
113
- }
114
-
115
- function parseSpec(raw: string, selfTaskId: string): DependencySpec {
116
- try {
117
- return parseDependencySpec(raw)
118
- } catch (err) {
119
- if (err instanceof DependencySpecError) {
120
- throw new UserError(`${selfTaskId}: cache.inputs.tasks: ${err.message}`)
121
- }
122
- throw err
123
- }
124
- }
125
-
126
- /** Every task a group stands for, through nested groups, each once. */
127
- function groupMembers(group: TaskNode, nodes: ReadonlyMap<string, TaskNode>): TaskNode[] {
128
- const out: TaskNode[] = []
129
- const seen = new Set<string>([group.id])
130
- const stack = group.deps.toReversed()
131
- while (stack.length > 0) {
132
- const id = stack.pop()!
133
- if (seen.has(id)) continue
134
- seen.add(id)
135
- const n = nodes.get(id)
136
- if (n === undefined) continue
137
- out.push(n)
138
- if (isGroupTask(n)) stack.push(...n.deps.toReversed())
139
- }
140
- return out
141
- }
142
-
143
- /**
144
- * One dependency as the key fold sees it: the task, and the value the
145
- * fold dedups by — its hash on the hash path, a structural stand-in for
146
- * the hash on the graph (`foldUnit` in keyed-projects.ts).
147
- */
148
- export interface FoldCandidate {
149
- node: TaskNode
150
- unit: string
151
- }
152
-
153
- /**
154
- * The dependencies a task's key folds, per its `cache.inputs.tasks`.
155
- *
156
- * Patterns (Turbo/Nx micro-syntax + filter extensions):
157
- * '*' all same-project upstream
158
- * '^*' all dep-workspace upstream
159
- * 'name' same-project task `name`
160
- * '^name' `name` task in every dep workspace
161
- * 'pkg#name' specific package's `name` task
162
- * 'name.*' patterns — EITHER half of any form above may contain `*`
163
- * (same glob as dependsOn patterns), including the project
164
- * half of `pkg#name`. A filter that matched literally here
165
- * would silently select ZERO upstream hashes and decouple the
166
- * task from its dependencies — a stale-hit trap, so every
167
- * name is matched through the shared glob.
168
- * '!<form>' exclude — any of the above with a leading `!`
169
- *
170
- * Patterns are applied in order; last write wins, so
171
- * `['*', '^*', '!^noisy']` reads as "all minus deps' noisy".
172
- *
173
- * Defaults:
174
- * - `filter === undefined` → every candidate, as given.
175
- * - `filter === []` → none (fully decoupled).
176
- *
177
- * Otherwise deduped by `unit`, first candidate kept. The unit is the
178
- * fold's own identity, not the task's: two groups over the same members
179
- * share one hash, so excluding either excludes both, and a selection by
180
- * task id would keep the other while the key folded neither.
181
- */
182
- export function selectFoldedDeps(
183
- deps: readonly FoldCandidate[],
184
- filter: readonly string[] | undefined,
185
- selfProjectName: string,
186
- selfTaskId: string,
187
- ): FoldCandidate[] {
188
- if (filter === undefined) return [...deps]
189
-
190
- const specs: DependencySpec[] = filter.map((raw) => parseSpec(raw, selfTaskId))
191
-
192
- // Per-spec predicate, compiled once (exact compares + patterns).
193
- const matchers = specs.map((spec) => specMatcher(spec))
194
-
195
- const selected = new Map<string, FoldCandidate>()
196
- for (let i = 0; i < specs.length; i++) {
197
- const spec = specs[i]!
198
- const matches = matchers[i]!
199
- for (const d of deps) {
200
- const isSelf = d.node.projectName === selfProjectName
201
- if (!matches(d.node, isSelf)) continue
202
- if (spec.negated) selected.delete(d.unit)
203
- else if (!selected.has(d.unit)) selected.set(d.unit, d)
204
- }
205
- }
206
- return [...selected.values()]
207
- }
208
-
209
69
  /**
210
70
  * Expand any GROUP among these outcomes into the real tasks it stands for,
211
71
  * transitively, preserving order and de-duplicating by task id.
@@ -223,7 +83,7 @@ export function selectFoldedDeps(
223
83
  * along two paths (a group `build` whose `^build` meets a package diamond)
224
84
  * is expanded once, or the walk doubles per layer. Only what the FILTER
225
85
  * already selected is passed in, so a group excluded from a task's
226
- * `cache.inputs.tasks` brings no members with it.
86
+ * `inputs.tasks` brings no members with it.
227
87
  */
228
88
  export function expandGroupUpstream(upstream: readonly TaskOutcome[]): TaskOutcome[] {
229
89
  const out: TaskOutcome[] = []
@@ -245,40 +105,3 @@ export function expandGroupUpstream(upstream: readonly TaskOutcome[]): TaskOutco
245
105
  }
246
106
  return out
247
107
  }
248
-
249
- /** Exact-name compare, or the shared `*`-glob when the name is a pattern. */
250
- function nameMatcher(name: string): (candidate: string) => boolean {
251
- if (isTaskPattern(name)) {
252
- const re = compileTaskPattern(name)
253
- return (candidate) => re.test(candidate)
254
- }
255
- return (candidate) => candidate === name
256
- }
257
-
258
- /**
259
- * Compile one spec into an upstream predicate. BOTH halves of a `pkg#task`
260
- * form glob: a filter only ever selects from upstreams that already exist,
261
- * so a package pattern is unambiguous here — unlike dependsOn, which must
262
- * materialize concrete edges and therefore rejects it.
263
- */
264
- function specMatcher(spec: DependencySpec): (n: TaskNode, isSelf: boolean) => boolean {
265
- switch (spec.kind) {
266
- case 'wildcardSelf':
267
- return (_n, isSelf) => isSelf
268
- case 'wildcardDeps':
269
- return (_n, isSelf) => !isSelf
270
- case 'self': {
271
- const task = nameMatcher(spec.task)
272
- return (n, isSelf) => isSelf && task(n.taskName)
273
- }
274
- case 'deps': {
275
- const task = nameMatcher(spec.task)
276
- return (n, isSelf) => !isSelf && task(n.taskName)
277
- }
278
- case 'cross': {
279
- const project = nameMatcher(spec.project)
280
- const task = nameMatcher(spec.task)
281
- return (n) => project(n.projectName) && task(n.taskName)
282
- }
283
- }
284
- }
@@ -532,7 +532,7 @@ function violationSection(outcome: TaskOutcome, colors: ColorSupport): string[]
532
532
  ...(fix.inputs !== undefined
533
533
  ? [
534
534
  dim(
535
- `and, if the output depends on them, cache.inputs.files: [${fix.inputs.map((p) => `'${p}'`).join(', ')}]`,
535
+ `and, if the output depends on them, inputs.files: [${fix.inputs.map((p) => `'${p}'`).join(', ')}]`,
536
536
  ),
537
537
  ]
538
538
  : []),
@@ -973,8 +973,11 @@ export function defaultLogger(
973
973
  const qi = slotQueue.findIndex((s) => s.id === node.id)
974
974
  if (qi >= 0) slotQueue.splice(qi, 1)
975
975
  }
976
- const bucket = outcome.hash === undefined ? undefined : cacheBucketOf(outcome)
977
- if (bucket !== undefined) buckets.set(outcome.hash!, bucket)
976
+ const bucket = cacheBucketOf(outcome)
977
+ if (bucket !== undefined) {
978
+ if (outcome.hash !== undefined) buckets.set(outcome.hash, bucket)
979
+ buckets.set(node.id, bucket)
980
+ }
978
981
  switch (outcome.status) {
979
982
  case 'success':
980
983
  succeeded++
@@ -361,9 +361,13 @@ export function formatFailureLine(
361
361
  )
362
362
  }
363
363
 
364
+ /** A step's cache column, dim as `no-cache` is. */
365
+ const INTERNAL = 'internal'
366
+
364
367
  /**
365
368
  * The list row of one of vx's own steps (`vx:uploads`), on the task grid
366
- * with no cache column: a step is no task and caches nothing.
369
+ * with `internal` in the cache column: a step is no task and caches
370
+ * nothing (M, 2026-10-16: `4.60s success internal vx:keep-run`).
367
371
  */
368
372
  export function formatStepLine(
369
373
  id: string,
@@ -376,7 +380,7 @@ export function formatStepLine(
376
380
  durationMs,
377
381
  ok ? 'success' : 'failed',
378
382
  ok ? SUCCESS : ERROR,
379
- '',
383
+ INTERNAL,
380
384
  '',
381
385
  paintPinnedId(id, colors),
382
386
  colors,
@@ -464,7 +468,7 @@ export function formatStatusRegion(
464
468
  Math.max(0, s.nowMs - step.startedMs),
465
469
  'running',
466
470
  ACCENT,
467
- '',
471
+ INTERNAL,
468
472
  '',
469
473
  paintPinnedId(step.id, colors),
470
474
  colors,
@@ -110,7 +110,7 @@ export interface SummaryStats {
110
110
  savedMs?: number
111
111
  /** A run of groups only: their names, so `0 tasks` says why. */
112
112
  emptyGroups?: readonly string[]
113
- /** Each finished task's cache bucket by its key, so its remote traffic can move it. */
113
+ /** Each finished task's cache bucket by its key and its id (its run bytes), so its remote traffic can move it. */
114
114
  buckets?: ReadonlyMap<string, CacheBucket>
115
115
  }
116
116
 
@@ -536,12 +536,14 @@ export function formatRunSummary(
536
536
  )
537
537
  }
538
538
 
539
- /** Each finished task's cache bucket by its key. */
539
+ /** Each finished task's cache bucket by its key and by its id. */
540
540
  function bucketsOf(outcomes: readonly TaskOutcome[]): Map<string, CacheBucket> {
541
541
  const out = new Map<string, CacheBucket>()
542
542
  for (const o of outcomes) {
543
- const bucket = o.hash === undefined ? undefined : cacheBucketOf(o)
544
- if (bucket !== undefined) out.set(o.hash!, bucket)
543
+ const bucket = cacheBucketOf(o)
544
+ if (bucket === undefined) continue
545
+ if (o.hash !== undefined) out.set(o.hash, bucket)
546
+ out.set(o.node.id, bucket)
545
547
  }
546
548
  return out
547
549
  }
package/src/settings.ts CHANGED
@@ -108,12 +108,6 @@ export const SETTINGS: readonly Setting[] = [
108
108
  default: 'true',
109
109
  doc: 'Refuse two tasks whose outputs overlap even when an edge orders them.',
110
110
  },
111
- {
112
- path: 'run.rules.upfrontKeys',
113
- kind: { type: 'bool' },
114
- default: 'true',
115
- doc: "Refuse a task whose inputs can match another task's outputs.",
116
- },
117
111
  {
118
112
  path: 'cache.dir',
119
113
  kind: { type: 'string' },
@@ -176,6 +170,13 @@ export const SETTINGS: readonly Setting[] = [
176
170
  task: 'cache.outputs.clean',
177
171
  env: false,
178
172
  },
173
+ {
174
+ path: 'cache.upload.failures',
175
+ kind: { type: 'enum', values: ['ci', 'local', 'always'] },
176
+ default: 'ci',
177
+ doc: "Where a failed task's log and outputs are kept in the remote: CI runs, local runs, or both.",
178
+ env: false,
179
+ },
179
180
  {
180
181
  path: 'lock.reviewFiles',
181
182
  kind: { type: 'bool' },
@@ -393,15 +394,8 @@ export function resolveSetting(
393
394
  return { value: undefined, source: 'default' }
394
395
  }
395
396
 
396
- /** `run.rules`, each from its variable, else the workspace; unset ones stay on. */
397
- export function runRules(workspace: unknown): {
398
- exclusiveOutputs?: boolean
399
- upfrontKeys?: boolean
400
- } {
401
- const out: { exclusiveOutputs?: boolean; upfrontKeys?: boolean } = {}
402
- for (const rule of ['exclusiveOutputs', 'upfrontKeys'] as const) {
403
- const { value } = resolveSetting(`run.rules.${rule}`, workspace)
404
- if (value !== undefined) out[rule] = value as boolean
405
- }
406
- return out
397
+ /** `run.rules`, from its variable, else the workspace; unset stays on. */
398
+ export function runRules(workspace: unknown): { exclusiveOutputs?: boolean } {
399
+ const { value } = resolveSetting('run.rules.exclusiveOutputs', workspace)
400
+ return value === undefined ? {} : { exclusiveOutputs: value as boolean }
407
401
  }
package/src/util/index.ts CHANGED
@@ -58,7 +58,10 @@ export {
58
58
  GLOB_WILDCARDS,
59
59
  normalizeBunGlob,
60
60
  normalizeGlob,
61
+ namesNodeModules,
62
+ OUTPUT_NEVER,
61
63
  outputMatcher,
64
+ restorableOutput,
62
65
  printable,
63
66
  relPosix,
64
67
  DOT_VX_GITATTRIBUTES,
package/src/util/paths.ts CHANGED
@@ -12,11 +12,12 @@ export function toPosix(p: string): string {
12
12
  }
13
13
 
14
14
  /**
15
- * `.vx/.gitignore`: git ignores the machine-local `.vx/cache` and nothing
16
- * else in `.vx`, so the workspace config and the lock beside it are
17
- * committed. `vx init` and the cache write it when missing.
15
+ * `.vx/.gitignore`: git ignores the machine-local `.vx/cache`, `.vx/tmp`
16
+ * (where restores stage) and nothing else in `.vx`, so the workspace
17
+ * config and the lock beside it are committed. `vx init` and the cache
18
+ * write it when missing.
18
19
  */
19
- export const DOT_VX_GITIGNORE = '/cache\n'
20
+ export const DOT_VX_GITIGNORE = '/cache\n/tmp\n'
20
21
 
21
22
  /** `.vx/.gitattributes`' text: GitHub collapses the machine lock in a diff. */
22
23
  export const DOT_VX_GITATTRIBUTES = 'projects.lock.json linguist-generated=true\n'
@@ -163,7 +164,7 @@ export function isLiteralPattern(glob: string): boolean {
163
164
  }
164
165
 
165
166
  /**
166
- * The alphabet of a TASK glob (`cache.inputs.files`, `cache.outputs.files`,
167
+ * The alphabet of a TASK glob (`inputs.files`, `cache.outputs.files`,
167
168
  * `workspaceFiles`), behind {@link isLiteralPattern}, for the callers that
168
169
  * need the POSITION of the first wildcard rather than a verdict. Every site
169
170
  * that asks "must this declaration be matched?" reads this one regex: item
@@ -385,6 +386,39 @@ export function outputMatcher(globs: readonly string[]): (rel: string) => boolea
385
386
  return (rel) => pos(rel) && !neg(rel)
386
387
  }
387
388
 
389
+ /**
390
+ * What no output glob reaches. An output is otherwise taken as written —
391
+ * `node_modules/**` IS an install task's output — but no task produces
392
+ * these, and a root project declaring `**` would otherwise wipe its
393
+ * repository and the cache it is restoring from (2026-09-16). `.vx` whole
394
+ * and each `vx.lock.json` are vx's own: as outputs, the clean deleted an
395
+ * uncommitted workspace config and a hit restored a lock or ignore file the
396
+ * key does not see over the current one.
397
+ */
398
+ export const OUTPUT_NEVER = ['**/.git/**', '**/.vx/**', '**/vx.lock.json']
399
+
400
+ /** Whether a positive glob in `globs` names a `node_modules` directory. */
401
+ export function namesNodeModules(globs: readonly string[]): boolean {
402
+ return globs.some(
403
+ (g) => !g.startsWith('!') && normalizeGlob(g).split('/').includes('node_modules'),
404
+ )
405
+ }
406
+
407
+ /**
408
+ * Whether a restore may write `rel`: one of the outputs `globs` declare,
409
+ * never under `OUTPUT_NEVER`, and under `node_modules` only where a glob
410
+ * names it — what the save side could have packed (`resolveOutputs`). An
411
+ * artifact naming anything else was not written by this task's save, and
412
+ * none of it is restored (2026-10-16).
413
+ */
414
+ export function restorableOutput(globs: readonly string[]): (rel: string) => boolean {
415
+ const isOutput = outputMatcher(globs)
416
+ const never = anyTaskGlob(
417
+ namesNodeModules(globs) ? OUTPUT_NEVER : [...OUTPUT_NEVER, '**/node_modules/**'],
418
+ )
419
+ return (rel) => isOutput(rel) && !never(rel)
420
+ }
421
+
388
422
  /**
389
423
  * `pattern` with its first brace group expanded, recursively, while that
390
424
  * group holds a `/`; any other pattern as itself, since `Bun.Glob`