@vzn/vx 0.0.639 → 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 (74) hide show
  1. package/README.md +1 -1
  2. package/docs/caching.md +111 -83
  3. package/docs/cli.md +70 -40
  4. package/docs/execution.md +4 -4
  5. package/docs/features.md +9 -8
  6. package/docs/patterns.md +1 -1
  7. package/docs/schema.md +235 -180
  8. package/docs/security.md +1 -1
  9. package/package.json +5 -5
  10. package/schemas/show.json +14 -14
  11. package/skills/vx/SKILL.md +4 -3
  12. package/src/cache/cache.ts +4 -1
  13. package/src/cache/file-hashes.ts +7 -12
  14. package/src/cache/fs-clock.ts +62 -0
  15. package/src/cache/git-inputs.ts +2 -2
  16. package/src/cache/index.ts +3 -0
  17. package/src/cache/inputs.ts +154 -83
  18. package/src/cache/key-fold.ts +23 -3
  19. package/src/cache/layer.ts +8 -7
  20. package/src/cache/linked-inputs.ts +87 -0
  21. package/src/cli/foreign-flags.ts +1 -1
  22. package/src/cli/init.ts +23 -11
  23. package/src/cli/last.ts +24 -1
  24. package/src/cli/plugin-templates.ts +2 -2
  25. package/src/cli/select.ts +16 -6
  26. package/src/cli/show.ts +9 -6
  27. package/src/cli/upgrade.ts +95 -19
  28. package/src/cli/watch-filter.ts +22 -6
  29. package/src/cli/watch-judge.ts +49 -5
  30. package/src/cli/watch-set.ts +18 -8
  31. package/src/cli/watch.ts +74 -2
  32. package/src/cli/why.ts +5 -3
  33. package/src/config.ts +19 -15
  34. package/src/exec/executor.ts +4 -4
  35. package/src/graph/dependency-spec.ts +2 -2
  36. package/src/graph/folds.ts +236 -0
  37. package/src/graph/index.ts +8 -1
  38. package/src/graph/scheduler.ts +41 -15
  39. package/src/graph/task-graph.ts +135 -73
  40. package/src/index.ts +1 -1
  41. package/src/orchestrator/affected-tasks.ts +20 -19
  42. package/src/orchestrator/download-policy.ts +16 -22
  43. package/src/orchestrator/excluded-keys.ts +4 -5
  44. package/src/orchestrator/execute-task.ts +29 -25
  45. package/src/orchestrator/index.ts +1 -0
  46. package/src/orchestrator/keyed-projects.ts +12 -51
  47. package/src/orchestrator/local-shortcircuit.ts +1 -1
  48. package/src/orchestrator/persistent-task.ts +3 -3
  49. package/src/orchestrator/placement.ts +2 -2
  50. package/src/orchestrator/plan.ts +1 -1
  51. package/src/orchestrator/plugin-host.ts +62 -22
  52. package/src/orchestrator/plugin.ts +12 -2
  53. package/src/orchestrator/prepare.ts +12 -5
  54. package/src/orchestrator/projects.ts +7 -10
  55. package/src/orchestrator/run-report.ts +124 -46
  56. package/src/orchestrator/run-steps.ts +57 -0
  57. package/src/orchestrator/run.ts +51 -21
  58. package/src/orchestrator/sandbox-fix.ts +1 -1
  59. package/src/orchestrator/sandbox-request.ts +2 -2
  60. package/src/orchestrator/stable-keys.ts +59 -26
  61. package/src/orchestrator/steps.ts +26 -6
  62. package/src/orchestrator/task-hash.ts +31 -9
  63. package/src/orchestrator/telemetry-host.ts +3 -3
  64. package/src/orchestrator/telemetry.ts +9 -4
  65. package/src/orchestrator/upstream.ts +4 -181
  66. package/src/report/framed-output.ts +1 -1
  67. package/src/report/status-line.ts +7 -3
  68. package/src/settings.ts +11 -17
  69. package/src/util/paths.ts +1 -1
  70. package/src/workspace/affected.ts +81 -2
  71. package/src/workspace/config-imports.ts +1 -1
  72. package/src/workspace/config-schema.ts +142 -143
  73. package/src/workspace/lockfile.ts +1 -1
  74. package/src/workspace/migration.ts +19 -3
@@ -3,25 +3,45 @@
3
3
  // `:teardown`. `:` is no task id's separator, so a step never collides with
4
4
  // a project named `vx` or a plugin package's own `#setup` task. A step is
5
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).
6
10
  import type { Logger } from '../report/index.js'
11
+ import type { RunStep } from './run-steps.js'
7
12
 
8
13
  export interface Steps {
9
14
  /** Time `work` as step `id` when steps show; else run it as it is. */
10
15
  step<T>(id: string, work: () => Promise<T>): Promise<T>
11
16
  stepSync<T>(id: string, work: () => T): T
12
- }
13
-
14
- const unseen: Steps = {
15
- step: (_id, work) => work(),
16
- stepSync: (_id, work) => work(),
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[]
17
21
  }
18
22
 
19
23
  /** Steps that tell `log`, or none when nothing renders them (zero cost). */
20
24
  export function createSteps(log: Logger, shown: boolean): Steps {
21
- if (!shown) return unseen
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 }
22
40
  const end = (id: string, t0: number, ok: boolean): void =>
23
41
  log.stepEnd?.(id, { durationMs: performance.now() - t0, ok })
24
42
  return {
43
+ background,
44
+ ended,
25
45
  async step(id, work) {
26
46
  const t0 = performance.now()
27
47
  log.stepStart?.(id)
@@ -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
  : []),
@@ -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,
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/paths.ts CHANGED
@@ -163,7 +163,7 @@ export function isLiteralPattern(glob: string): boolean {
163
163
  }
164
164
 
165
165
  /**
166
- * The alphabet of a TASK glob (`cache.inputs.files`, `cache.outputs.files`,
166
+ * The alphabet of a TASK glob (`inputs.files`, `cache.outputs.files`,
167
167
  * `workspaceFiles`), behind {@link isLiteralPattern}, for the callers that
168
168
  * need the POSITION of the first wildcard rather than a verdict. Every site
169
169
  * that asks "must this declaration be matched?" reads this one regex: item
@@ -6,7 +6,7 @@
6
6
  // index + unstaged — so it captures everything you touched and nothing
7
7
  // the base branch moved on with. Matches Turbo's `[<since>]` semantics.
8
8
 
9
- import { realpath } from 'node:fs/promises'
9
+ import { realpath, stat } from 'node:fs/promises'
10
10
  import path from 'node:path'
11
11
  import {
12
12
  executablePath,
@@ -75,7 +75,7 @@ export interface AffectedArgs {
75
75
  since: string
76
76
  projects: readonly ProjectMeta[]
77
77
  /**
78
- * Which projects declare a `cache.inputs.workspaceFiles` glob matching one of
78
+ * Which projects declare a `inputs.workspaceFiles` glob matching one of
79
79
  * these workspace-relative paths.
80
80
  *
81
81
  * A workspace-anchored glob is the documented escape hatch for inputs
@@ -107,6 +107,12 @@ export interface AffectedArgs {
107
107
  * run then paid again (I-26).
108
108
  */
109
109
  untracked?: () => Promise<readonly string[] | null>
110
+ /**
111
+ * The tracked symlinks, workspace-relative, from the run's enumeration
112
+ * (its index OIDs spell one `120000:`); null or absent spawns `git ls-files
113
+ * -s` here. A changed or untracked link is a changed path already.
114
+ */
115
+ links?: () => Promise<readonly string[] | null>
110
116
  }
111
117
 
112
118
  export interface FingerprintClaims {
@@ -253,6 +259,16 @@ export async function affectedChanges(
253
259
  const changed = [...diffed.paths, ...untracked.filter((s) => !isInstalledPath(s))].filter(
254
260
  (s) => s !== LOCKFILE_NAME,
255
261
  )
262
+ // A symlinked input keys the content behind it (cache/linked-inputs.ts),
263
+ // and the diff names that content's path, never the link's: a link whose
264
+ // target changed is a changed path itself.
265
+ changed.push(
266
+ ...(await linkedChanges(
267
+ args.workspaceRoot,
268
+ (await args.links?.().catch(() => null)) ?? (await trackedLinks(args.workspaceRoot)),
269
+ changed,
270
+ )),
271
+ )
256
272
  // The nested repositories git reports as one path: a gitlink, or an
257
273
  // untracked embedded repository (`dir/`, the only path git prints so).
258
274
  const nested = new Set(diffed.gitlinks)
@@ -503,6 +519,69 @@ async function bytesOrNull(file: string): Promise<Uint8Array | null> {
503
519
  return (await f.exists()) ? await f.bytes() : null
504
520
  }
505
521
 
522
+ /** The symlinks the index lists, workspace-relative. */
523
+ async function trackedLinks(workspaceRoot: string): Promise<string[]> {
524
+ const out: string[] = []
525
+ for (const record of await gitPaths(workspaceRoot, ['ls-files', '-s', '-z'])) {
526
+ if (record.startsWith('120000 ')) out.push(record.slice(record.indexOf('\t') + 1))
527
+ }
528
+ return out
529
+ }
530
+
531
+ /**
532
+ * The links in `links` whose target changed: a file `changed` names, or a
533
+ * directory holding one. A target git cannot diff is changed on every run:
534
+ * one outside the workspace, or git-ignored (or ignored files under a linked
535
+ * directory), since the key reads it whatever git says. A dangling link
536
+ * reads nothing; retargeting it is a change to the link itself.
537
+ */
538
+ async function linkedChanges(
539
+ workspaceRoot: string,
540
+ links: readonly string[],
541
+ changed: readonly string[],
542
+ ): Promise<string[]> {
543
+ if (links.length === 0) return []
544
+ const realRoot = await realpath(workspaceRoot).catch(() => workspaceRoot)
545
+ const out: string[] = []
546
+ const inside: Array<{ link: string; target: string; dir: boolean }> = []
547
+ for (const link of links) {
548
+ let real: string
549
+ let dir: boolean
550
+ try {
551
+ real = await realpath(path.join(workspaceRoot, link))
552
+ dir = (await stat(real)).isDirectory()
553
+ } catch {
554
+ continue
555
+ }
556
+ const rel = path.relative(realRoot, real)
557
+ if (rel.startsWith('..') || path.isAbsolute(rel)) out.push(link)
558
+ else inside.push({ link, target: rel.split(path.sep).join('/'), dir })
559
+ }
560
+ if (inside.length === 0) return out
561
+ const named = inside.map((l) => l.target).filter((t) => t !== '')
562
+ const ignored = await gitPaths(workspaceRoot, [
563
+ 'ls-files',
564
+ '--others',
565
+ '--ignored',
566
+ '--exclude-standard',
567
+ '--directory',
568
+ '-z',
569
+ '--',
570
+ ...(named.length === inside.length ? named : ['.']),
571
+ ])
572
+ const changedSet = new Set(changed)
573
+ const under = (p: string, dir: string): boolean => dir === '' || p.startsWith(`${dir}/`)
574
+ for (const { link, target, dir } of inside) {
575
+ const unseen = ignored.some((i) => {
576
+ const at = i.replace(/\/$/, '')
577
+ return at === target || under(target, at) || (dir && under(at, target))
578
+ })
579
+ const hit = dir ? changed.some((c) => under(c, target)) : changedSet.has(target)
580
+ if (unseen || hit) out.push(link)
581
+ }
582
+ return out
583
+ }
584
+
506
585
  /** Run a NUL-separated path-listing git command from the workspace root. */
507
586
  async function gitPaths(workspaceRoot: string, cmd: string[]): Promise<string[]> {
508
587
  const proc = spawnGit([...cmd], workspaceRoot)
@@ -1,7 +1,7 @@
1
1
  // The third `changed file → project` channel for `--affected`.
2
2
  //
3
3
  // Directory containment answers "which project owns this file", and
4
- // `cache.inputs.workspaceFiles` answers "which project declared it". Neither
4
+ // `inputs.workspaceFiles` answers "which project declared it". Neither
5
5
  // can see the one remaining way a file reaches a task: a project's
6
6
  // `vx.config.*` IMPORTS it. Resolved-config hashing folds the imported values
7
7
  // into the cache key (architecture principle #4), so editing such a file