@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
package/src/cli/watch.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  //
8
8
  // The cache does the heavy lifting: re-runs are typically cache hits
9
9
  // (input hash matches), so spurious events from edits outside any
10
- // task's `cache.inputs.files` cost ~tens of ms of orchestrator
10
+ // task's `inputs.files` cost ~tens of ms of orchestrator
11
11
  // overhead. We deliberately don't try to filter events through the
12
12
  // per-task input globs — the cache key is the source of truth, and
13
13
  // filtering would mean re-doing the glob/boundary work on every event.
@@ -61,22 +61,17 @@ const DEBOUNCE_MAX_MS = 1_000
61
61
  * the help line and the completions, and a test holds the two together.
62
62
  */
63
63
  export function watchRefusal(parsed: RunArgs): string | null {
64
- if (parsed.dry !== undefined || parsed.graph !== undefined) {
65
- return 'vx watch: --dry / --graph are not supported in watch mode'
64
+ if (parsed.dry !== undefined) {
65
+ return 'vx watch: --dry is not supported in watch mode'
66
66
  }
67
- if (parsed.summarize !== undefined || parsed.profile !== undefined) {
68
- return 'vx watch: --summarize / --profile are not supported in watch mode (would overwrite per cycle)'
67
+ if (parsed.profile !== undefined) {
68
+ return 'vx watch: --profile is not supported in watch mode (would overwrite per cycle)'
69
69
  }
70
- // All three format ONE run's result and are consumed by `runCmd` alone, so
71
- // a watch loop silently ignored them. `--verbosity 0` is not rejected: it
70
+ // It formats ONE run's result and is consumed by `runCmd` alone, so a
71
+ // watch loop silently ignored it. `--verbosity 0` is not rejected: it
72
72
  // asks for the output watch already gives.
73
- if (
74
- parsed.report !== undefined ||
75
- parsed.reportFile !== undefined ||
76
- parsed.verbosity > 0 ||
77
- parsed.format !== 'pretty'
78
- ) {
79
- return 'vx watch: --report / --report-file / --verbosity / --format are not supported in watch mode (they report a single run)'
73
+ if (parsed.verbosity > 0) {
74
+ return 'vx watch: --verbosity is not supported in watch mode (it reports a single run)'
80
75
  }
81
76
  return null
82
77
  }
@@ -284,6 +279,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
284
279
  outputs: swept.outputs,
285
280
  inputs: swept.inputs,
286
281
  uncached: swept.uncached,
282
+ namedIgnored: swept.namedIgnored,
287
283
  configImports: swept.configImports,
288
284
  workspaceConfigImports: swept.workspaceConfigImports,
289
285
  fenceDirs: fenceDirs(allProjects),
@@ -306,6 +302,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
306
302
  outputs: sweep.outputs,
307
303
  inputs: sweep.inputs,
308
304
  uncached: sweep.uncached,
305
+ namedIgnored: sweep.namedIgnored,
309
306
  configImports: sweep.configImports,
310
307
  workspaceConfigImports: sweep.workspaceConfigImports,
311
308
  fenceDirs: fenceDirs(all),
@@ -356,6 +353,8 @@ interface WatchLoopArgs {
356
353
  inputs: ReadonlyMap<string, ReadonlyArray<readonly string[]>>
357
354
  /** Projects whose uncached task may read a git-ignored file: such a path there is still an edit. */
358
355
  uncached: ReadonlySet<string>
356
+ /** Paths a task's inputs name outright: a git-ignored one there is still an edit. */
357
+ namedIgnored: readonly string[]
359
358
  /** Files the configs import: an edit to one outside the watched projects is a cycle that re-reads them. */
360
359
  configImports: readonly string[]
361
360
  /** Files the workspace config imports: loaded once per process, so an edit is named, not run. */
@@ -384,6 +383,7 @@ interface Rediscovered {
384
383
  outputs: ReadonlyMap<string, readonly string[]>
385
384
  inputs: ReadonlyMap<string, ReadonlyArray<readonly string[]>>
386
385
  uncached: ReadonlySet<string>
386
+ namedIgnored: readonly string[]
387
387
  configImports: readonly string[]
388
388
  workspaceConfigImports: readonly string[]
389
389
  fenceDirs: readonly string[]
@@ -399,6 +399,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
399
399
  let outputs = args.outputs
400
400
  let inputs = args.inputs
401
401
  let uncached = args.uncached
402
+ let namedIgnored = args.namedIgnored
402
403
  let claimedRootFiles = args.claimedRootFiles
403
404
  let configImportFiles = args.configImports
404
405
  let wsConfigImportFiles = args.workspaceConfigImports
@@ -572,6 +573,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
572
573
  armedAt,
573
574
  held: () => held !== undefined,
574
575
  uncached: () => uncached,
576
+ namedIgnored: () => namedIgnored,
575
577
  fenced: (ownDir, abs) => fenced(ownDir, abs),
576
578
  ...(atArm !== undefined ? { existedAtArm: atArm.listed, trackedAtArm: atArm.tracked } : {}),
577
579
  })
@@ -626,6 +628,19 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
626
628
  }
627
629
  }
628
630
  const stale = (dir: string): boolean => armedAs.get(dir) !== inodeOf(dir)
631
+ /** The projects the workspace-wide arm names an event by. */
632
+ let wide: readonly ProjectMeta[] = []
633
+ // The deepest, as a nested project's file is its own.
634
+ const ownerOf = (abs: string): ProjectMeta | undefined => {
635
+ let best: ProjectMeta | undefined
636
+ for (const proj of wide)
637
+ if (
638
+ abs.startsWith(proj.dir + path.sep) &&
639
+ (best === undefined || proj.dir.length > best.dir.length)
640
+ )
641
+ best = proj
642
+ return best
643
+ }
629
644
  /** The root arm: recursive when workspace-wide or the one stream, else the root's own files only. */
630
645
  let rootArm: WatchHandle = CLOSED
631
646
  let dotVxArm: WatchHandle = CLOSED
@@ -634,7 +649,8 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
634
649
  if (isIgnoredPath(proj.dir, filename) || fenced(proj.dir, abs)) return
635
650
  // A module the config imports from inside the project is the config
636
651
  // too: its edit can add an output or a `workspaceFiles` input.
637
- if (shapesWatchedSet(filename) || configImportFiles.includes(abs)) reread = true
652
+ if (shapesWatchedSet(filename) || configImportFiles.includes(abs) || linkChanged(abs))
653
+ reread = true
638
654
  trigger(`${proj.name} ${filename}`, abs)
639
655
  }
640
656
  const armProject = (proj: ProjectMeta): void => {
@@ -700,7 +716,13 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
700
716
  configImportFiles.includes(abs)
701
717
  )
702
718
  reread = true
703
- trigger(`root ${filename}`, abs)
719
+ const owner = ownerOf(abs)
720
+ trigger(
721
+ owner === undefined
722
+ ? `root ${filename}`
723
+ : `${owner.name} ${path.relative(owner.dir, abs)}`,
724
+ abs,
725
+ )
704
726
  })
705
727
  : oneStream
706
728
  ? arm(workspaceRoot, true, streamEvent)
@@ -725,6 +747,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
725
747
  // (it would cover every project + node_modules + caches). On macOS one
726
748
  // recursive stream at the root stands for all of them (`oneStream`).
727
749
  const armMode = (watched: readonly ProjectMeta[]): void => {
750
+ wide = workspaceWide ? watched : []
728
751
  if (!workspaceWide) {
729
752
  streamed = oneStream ? toStream(watched) : []
730
753
  const inStream = new Set(streamed.map((s) => s.proj))
@@ -801,6 +824,65 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
801
824
  armAncestors()
802
825
  }
803
826
 
827
+ /**
828
+ * What each symlink under a watched project points to: the key reads it,
829
+ * wherever it lies (cache/linked-inputs.ts), and the project's arm hears
830
+ * only the link. A file target's directory, non-recursive, or a directory
831
+ * target, recursive; each event names the link. Links are read again when
832
+ * one comes or goes (`projectEvent`).
833
+ */
834
+ const linkArms = new Map<string, { target: string; handle: WatchHandle }>()
835
+ let links = atArm?.links ?? new Set<string>()
836
+ let watched = projects
837
+ /** A link came, went or moved: the set `armLinks` reads is stale. */
838
+ const linkChanged = (abs: string): boolean => {
839
+ if (links.has(abs)) return true
840
+ try {
841
+ return fs.lstatSync(abs).isSymbolicLink()
842
+ } catch {
843
+ return false
844
+ }
845
+ }
846
+ const armLinks = (): void => {
847
+ const want = new Map<string, { proj: ProjectMeta; real: string; dir: boolean }>()
848
+ for (const link of links) {
849
+ let proj: ProjectMeta | undefined
850
+ for (const p of watched)
851
+ if (
852
+ link.startsWith(p.dir + path.sep) &&
853
+ (proj === undefined || p.dir.length > proj.dir.length)
854
+ )
855
+ proj = p
856
+ if (proj === undefined) continue
857
+ try {
858
+ const real = fs.realpathSync(link)
859
+ want.set(link, { proj, real, dir: fs.statSync(real).isDirectory() })
860
+ } catch {
861
+ // Dangling: its target string is all the key reads, and the link's own arm hears that.
862
+ }
863
+ }
864
+ for (const [link, armed] of linkArms) {
865
+ if (want.get(link)?.real === armed.target) continue
866
+ armed.handle.close()
867
+ linkArms.delete(link)
868
+ }
869
+ for (const [link, { proj, real, dir }] of want) {
870
+ if (linkArms.has(link)) continue
871
+ const name = path.basename(real)
872
+ const label = `${proj.name} ${path.relative(proj.dir, link)}`
873
+ try {
874
+ const handle = arm(dir ? real : path.dirname(real), dir, (filename) => {
875
+ if (!dir && filename !== name) return
876
+ trigger(label, link)
877
+ })
878
+ linkArms.set(link, { target: real, handle })
879
+ changes.seed(link)
880
+ } catch (err) {
881
+ sayCannot(`cannot watch ${real}`, err)
882
+ }
883
+ }
884
+ }
885
+
804
886
  // Said once, when the loop first arms: a re-arm repeated it after every
805
887
  // cycle that re-read the watched set, between task rows. `debug.watch`
806
888
  // says each arm on stderr, for whoever needs to see the set change.
@@ -828,6 +910,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
828
910
  outputs = next.outputs
829
911
  inputs = next.inputs
830
912
  uncached = next.uncached
913
+ namedIgnored = next.namedIgnored
831
914
  claimedRootFiles = next.claimedRootFiles
832
915
  configImportFiles = next.configImports
833
916
  wsConfigImportFiles = next.workspaceConfigImports
@@ -861,6 +944,9 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
861
944
  for (const proj of own) if (!perProject.has(proj.dir)) armProject(proj)
862
945
  }
863
946
  armImports()
947
+ watched = next.projects
948
+ links = gitFiles(workspaceRoot)?.links ?? links
949
+ armLinks()
864
950
  // A new arm proves delivery like the first ones: an edit in the new
865
951
  // project right after this cycle is seen, not lost in the gap.
866
952
  await pool.proved()
@@ -943,6 +1029,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
943
1029
  }
944
1030
  armAncestors()
945
1031
  armImports()
1032
+ armLinks()
946
1033
 
947
1034
  // The orchestrator's own writes never kick the loop: `makeWatchIgnore`
948
1035
  // closes over the RESOLVED cache dir (relocated or not) and the tasks'
package/src/cli/why.ts CHANGED
@@ -9,13 +9,13 @@ import { flagHint, formatValue, seeHelp } from './help.js'
9
9
  import { splitTaskId } from '../graph/index.js'
10
10
  import {
11
11
  cacheKeyDiff,
12
- explainCacheKeyQuery as explainCacheKey,
12
+ explainCacheKey,
13
13
  inputWriters,
14
14
  latestRunId,
15
15
  rootCauses,
16
- whyDidThisRerunQuery as whyDidThisRerun,
17
- resolveRunId,
18
- } from '../orchestrator/index.js'
16
+ whyDidThisRerun,
17
+ } from '../history/index.js'
18
+ import { resolveRunId } from '../orchestrator/index.js'
19
19
  import { MASKED, nearMatches, printable, refusal, UserError } from '../util/index.js'
20
20
  import { findWorkspaceRoot } from '../workspace/index.js'
21
21
  import { findCwdProject } from './select.js'
@@ -36,8 +36,10 @@ interface WhyArgs {
36
36
  * `tests/why.test.ts`.
37
37
  */
38
38
  export const WHAT_TO_DO: Readonly<Record<string, string>> = {
39
- file: 'an edit re-runs by design; a file the task does not read belongs out of cache.inputs.files',
40
- env: "the variable's value moved; one the task does not read belongs out of cache.inputs.env",
39
+ file: 'an edit re-runs by design; a file the task does not read belongs out of inputs.files',
40
+ linked:
41
+ 'what the symlink points to changed (the file, or a file under the directory); an edit there re-runs by design',
42
+ env: "the variable's value moved; one the task does not read belongs out of inputs.env",
41
43
  runtime: 'the command printed something else; drop timestamps and paths from its output',
42
44
  'ws-runtime':
43
45
  'the workspace command printed something else; drop timestamps and paths from its output',
@@ -372,4 +374,4 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
372
374
  const ROOTS_SHOWN = 5
373
375
 
374
376
  const WRITTEN_TODO =
375
- 'a task that writes a file a key reads re-keys it every run; give that write its own task with the file as an output, or keep the file out of cache.inputs.files'
377
+ 'a task that writes a file a key reads re-keys it every run; give that write its own task with the file as an output, or keep the file out of inputs.files'
package/src/config.ts CHANGED
@@ -62,12 +62,6 @@ export interface RunSettings {
62
62
  * default) skips its dependents, `always` runs them too.
63
63
  */
64
64
  continue?: 'never' | 'deps-ok' | 'always'
65
- /** How long a task that ignores SIGTERM gets before SIGKILL. Omitted → `'2s'`. */
66
- killGrace?: Duration
67
- /** The bound on each plugin's end-of-run flush and teardown. Omitted → `'3s'`. */
68
- teardownTimeout?: Duration
69
- /** How long a persistent task may take to match `readyWhen` before vx says it waits. Omitted → `'10s'`. */
70
- readyNotice?: Duration
71
65
  /**
72
66
  * Graph rules checked by `vx lock` and `vx lock --check`, never by a
73
67
  * run. Each is on
@@ -116,6 +110,12 @@ export interface CacheSettings {
116
110
  inputStability?: InputStability
117
111
  /** The `cache.outputs.clean` of every task that does not set its own. */
118
112
  outputs?: { clean?: boolean }
113
+ /**
114
+ * Which runs keep a failed task's log and the files its outputs left in
115
+ * the remote: CI runs (`'ci'`, the default), runs off CI (`'local'`), or
116
+ * both (`'always'`). Only a run whose policy writes a remote keeps any.
117
+ */
118
+ upload?: { failures?: 'ci' | 'local' | 'always' }
119
119
  }
120
120
 
121
121
  /** `WorkspaceConfig.lock`. */
@@ -143,12 +143,6 @@ export interface WorkspaceRules {
143
143
  * overlapping tasks with no edge are refused either way.
144
144
  */
145
145
  exclusiveOutputs?: boolean
146
- /**
147
- * Refuse a task whose input globs can match another task's declared
148
- * outputs, so every key is derived before any task runs. Off, such a key
149
- * waits for its producer (`caching.md` § Local restore tier).
150
- */
151
- upfrontKeys?: boolean
152
146
  /**
153
147
  * Which workspace projects a project may depend on, checked by `vx lock`
154
148
  * over each project's direct dependencies (its manifest's and its tasks'
@@ -304,12 +298,18 @@ export interface TaskConfig {
304
298
  * - `'pkg#name'` — specific package's `name` task; not key-checked.
305
299
  */
306
300
  dependsOn?: readonly string[]
301
+ /**
302
+ * What the task's key folds, whether or not it caches: its dependents
303
+ * fold that key. A task with `exec` and no `inputs` has no key, so every
304
+ * task that folds it runs and saves nothing. A group with `inputs` is a
305
+ * keyed group: its inputs are a key its dependents fold, with no process.
306
+ */
307
+ inputs?: InputsConfig
307
308
  /**
308
309
  * Caching configuration. **Caching is opt-in.** If this field is omitted,
309
310
  * the task always runs and nothing is read from or written to the cache.
310
- * Provide a `cache` block (with at least `outputs`) to enable caching.
311
- * Requires `exec` — a group task can't cache anything because nothing
312
- * runs.
311
+ * Requires `exec` and `inputs`: the key is the task's inputs, and a group
312
+ * runs nothing to cache.
313
313
  */
314
314
  cache?: CacheConfig
315
315
  }
@@ -330,7 +330,7 @@ export interface TaskConfig {
330
330
  * `node_modules`, and through it the real path of every workspace
331
331
  * package linked there.
332
332
  *
333
- * `cache` grants no access in either direction: `cache.inputs` says what
333
+ * `cache` grants no access in either direction: `inputs` says what
334
334
  * INVALIDATES a task, `allow` says what it may TOUCH, and deriving one
335
335
  * from the other coupled them both ways — a declaration added for
336
336
  * caching silently widened the sandbox, and a path the task needed had
@@ -404,7 +404,7 @@ export interface SandboxIgnore {
404
404
  export interface SandboxGrants {
405
405
  /**
406
406
  * Readable paths or globs, added to the baseline — which is
407
- * `node_modules`, NOT `cache.inputs.files`. A task that reads its own
407
+ * `node_modules`, NOT `inputs.files`. A task that reads its own
408
408
  * source in the sandbox says so here.
409
409
  */
410
410
  read?: readonly string[]
@@ -579,8 +579,6 @@ export interface ExecEnv {
579
579
  }
580
580
 
581
581
  export interface CacheConfig {
582
- /** What participates in the cache key. */
583
- inputs: CacheInputs
584
582
  /** What this task produces. Captured for restore on a cache hit. */
585
583
  outputs: CacheOutputs
586
584
  /**
@@ -640,7 +638,7 @@ export interface CacheOutputs {
640
638
  clean?: boolean
641
639
  }
642
640
 
643
- export interface CacheInputs {
641
+ export interface InputsConfig {
644
642
  /**
645
643
  * Project-relative globs. Each entry is a positive glob, or a negation
646
644
  * prefixed with `!`. Required: pass a recursive glob to mean "all
@@ -779,8 +777,8 @@ export function defineProject<const T extends ProjectConfig>(
779
777
  [K in keyof NonNullable<T['tasks']>]?: Known<NonNullable<T['tasks']>[K], TaskConfig> & {
780
778
  dependsOn?: readonly DependsOnEntry<Extract<keyof NonNullable<T['tasks']>, string>>[]
781
779
  exec?: Known<At<NonNullable<T['tasks']>[K], 'exec'>, ExecConfig>
780
+ inputs?: Known<At<NonNullable<T['tasks']>[K], 'inputs'>, InputsConfig>
782
781
  cache?: Known<At<NonNullable<T['tasks']>[K], 'cache'>, CacheConfig> & {
783
- inputs?: Known<At<At<NonNullable<T['tasks']>[K], 'cache'>, 'inputs'>, CacheInputs>
784
782
  outputs?: Known<At<At<NonNullable<T['tasks']>[K], 'cache'>, 'outputs'>, CacheOutputs>
785
783
  }
786
784
  }
@@ -53,13 +53,13 @@ export interface InputFile {
53
53
  * secrets (cache.db stores only their digests).
54
54
  */
55
55
  export interface TaskInputs {
56
- /** Declared `cache.inputs.files` + `workspaceFiles` (own outputs excluded), sorted by path. */
56
+ /** Declared `inputs.files` + `workspaceFiles` (own outputs excluded), sorted by path. */
57
57
  readonly files: readonly InputFile[]
58
- /** Declared `cache.inputs.env` names with their resolved values; `undefined` when unset. */
58
+ /** Declared `inputs.env` names with their resolved values; `undefined` when unset. */
59
59
  readonly env: ReadonlyArray<{ readonly name: string; readonly value: string | undefined }>
60
- /** `cache.inputs.runtime` commands and the output that was folded — a toolchain expectation. */
60
+ /** `inputs.runtime` commands and the output that was folded — a toolchain expectation. */
61
61
  readonly runtime: ReadonlyArray<{ readonly command: string; readonly output: string }>
62
- /** `cache.inputs.workspaceRuntime`, same shape. */
62
+ /** `inputs.workspaceRuntime`, same shape. */
63
63
  readonly workspaceRuntime: ReadonlyArray<{ readonly command: string; readonly output: string }>
64
64
  /**
65
65
  * Dependencies whose cache keys are folded. `outputs` lists each one's
@@ -142,7 +142,7 @@ export interface ExecuteRequest {
142
142
  * work and returns: a child an executor spawned is not one core can
143
143
  * reach (H-10, H-12). One already aborted is work not to start (B-55).
144
144
  * Any exit after the timeout's abort, a 0 included, is a timeout. Core
145
- * waits the kill grace (`VX_RUN_KILL_GRACE`, 2 s) after the abort, then
145
+ * waits the kill grace (2 s) after the abort, then
146
146
  * settles the attempt without `execute` and abandons it (H-14).
147
147
  */
148
148
  readonly signal?: AbortSignal
@@ -2332,7 +2332,7 @@ function outsideWritesHint(
2332
2332
  ? `${refused} The task has its own temp directory, empty at its start: write under ` +
2333
2333
  `$TMPDIR (os.tmpdir() in Node and Bun) instead of a fixed path.`
2334
2334
  : `${refused} If the task needs one, grant its directory, e.g. ` +
2335
- `\`allow: { write: [${jsString(`${spelled}/`)}] }\`.`
2335
+ `\`allow: { write: [${jsString(spelled.endsWith('/') ? spelled : `${spelled}/`)}] }\`.`
2336
2336
  return { timestamp: new Date(), hint: true, line }
2337
2337
  }
2338
2338
 
@@ -578,7 +578,9 @@ export function refusedWritesOutside(
578
578
  if (v.path === undefined || !path.isAbsolute(v.path) || !write || reported(v)) continue
579
579
  if (!skip.some((s) => atOrUnder(v.path!, s))) paths.add(v.path)
580
580
  }
581
- return [...paths]
581
+ // `mkdir -p ~/.cache/tool` is refused at each directory on its way;
582
+ // the one it wanted is the deepest.
583
+ return [...paths].filter((p) => ![...paths].some((q) => q !== p && atOrUnder(q, p)))
582
584
  }
583
585
 
584
586
  /**
@@ -1,5 +1,5 @@
1
1
  // Parser for the Turbo/Nx-style task-edge micro-syntax used in
2
- // `dependsOn` and `cache.inputs.tasks`. Returns a small discriminated
2
+ // `dependsOn` and `inputs.tasks`. Returns a small discriminated
3
3
  // union the graph builder + cache filter both consume.
4
4
 
5
5
  export type DependencySpec =
@@ -32,7 +32,7 @@ export function compileTaskPattern(pattern: string): RegExp {
32
32
  }
33
33
 
34
34
  /**
35
- * Parse one `dependsOn` / `cache.inputs.tasks` entry.
35
+ * Parse one `dependsOn` / `inputs.tasks` entry.
36
36
  *
37
37
  * `name` → self.name
38
38
  * `^name` → every dep workspace's `name` task
@@ -0,0 +1,236 @@
1
+ // Which dependencies a task's key folds (`inputs.tasks`), on the graph
2
+ // alone: the hash path, the sandbox's keyed set and `vx lock`'s
3
+ // input-overlap rule read this one selection, so they cannot drift.
4
+
5
+ import { UserError } from '../util/index.js'
6
+ import {
7
+ DependencySpecError,
8
+ compileTaskPattern,
9
+ isTaskPattern,
10
+ parseDependencySpec,
11
+ type DependencySpec,
12
+ } from './dependency-spec.js'
13
+ import type { TaskNode } from './task-graph.js'
14
+
15
+ /**
16
+ * A task is a "group" if it has no `exec` — it exists only to chain
17
+ * `dependsOn` (an umbrella for `vx run ci`). Group tasks:
18
+ * - never spawn a process,
19
+ * - never read/write the cache (it's a config error to declare one),
20
+ * - never appear in the run summary or `runs` analytics table,
21
+ * - render no framed block in the live output.
22
+ * Six call sites used to repeat `node.config.exec === undefined`;
23
+ * centralising the predicate prevents that check from drifting.
24
+ */
25
+ export function isGroupTask(node: TaskNode): boolean {
26
+ return node.config.exec === undefined
27
+ }
28
+
29
+ /** `node.deps` less its ordering-only edges: the dependencies a key may fold. */
30
+ export function keyedDeps(node: TaskNode): readonly string[] {
31
+ const order = node.orderOnly
32
+ return order === undefined ? node.deps : node.deps.filter((d) => !order.includes(d))
33
+ }
34
+
35
+ /**
36
+ * A filter selects among the task's own dependencies, never through a
37
+ * group: `dependsOn: ['build']` over a group `build` of `build.a` and
38
+ * `build.b` offers the filter `build`, not its members. A pattern that
39
+ * matches only a member (`build.*`) therefore selected nothing, and the key stopped moving
40
+ * with that member's inputs: a stale hit under a green run. Such a spec
41
+ * is refused, once, over the graph every key site reads. One that matches
42
+ * nothing anywhere stays legal (a preset's pattern needn't match in every
43
+ * project). An exact name is refused earlier, at load, unless `dependsOn`
44
+ * names it (`assertFilterNamesDeclaredDeps`).
45
+ */
46
+ export function refuseFiltersThroughGroups(nodes: ReadonlyMap<string, TaskNode>): void {
47
+ for (const node of nodes.values()) {
48
+ const filter = node.config.inputs?.tasks
49
+ if (filter === undefined || filter.length === 0) continue
50
+ const deps = keyedDeps(node).flatMap((id) => nodes.get(id) ?? [])
51
+ const groups = deps.filter(isGroupTask)
52
+ if (groups.length === 0) continue
53
+ for (const raw of filter) {
54
+ const spec = parseSpec(raw, node.id)
55
+ if (spec.negated) continue
56
+ const matches = specMatcher(spec)
57
+ const hit = (n: TaskNode): boolean => matches(n, n.projectName === node.projectName)
58
+ if (deps.some(hit)) continue
59
+ for (const group of groups) {
60
+ const member = groupMembers(group, nodes).find(hit)
61
+ if (member === undefined) continue
62
+ throw new UserError(
63
+ `${node.id}: 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`,
64
+ )
65
+ }
66
+ }
67
+ }
68
+ }
69
+
70
+ function parseSpec(raw: string, selfTaskId: string): DependencySpec {
71
+ try {
72
+ return parseDependencySpec(raw)
73
+ } catch (err) {
74
+ if (err instanceof DependencySpecError) {
75
+ throw new UserError(`${selfTaskId}: inputs.tasks: ${err.message}`)
76
+ }
77
+ throw err
78
+ }
79
+ }
80
+
81
+ /** Every task a group stands for, through nested groups, each once. */
82
+ function groupMembers(group: TaskNode, nodes: ReadonlyMap<string, TaskNode>): TaskNode[] {
83
+ const out: TaskNode[] = []
84
+ const seen = new Set<string>([group.id])
85
+ const stack = group.deps.toReversed()
86
+ while (stack.length > 0) {
87
+ const id = stack.pop()!
88
+ if (seen.has(id)) continue
89
+ seen.add(id)
90
+ const n = nodes.get(id)
91
+ if (n === undefined) continue
92
+ out.push(n)
93
+ if (isGroupTask(n)) stack.push(...n.deps.toReversed())
94
+ }
95
+ return out
96
+ }
97
+
98
+ /**
99
+ * One dependency as the key fold sees it: the task, and the value the
100
+ * fold dedups by — its hash on the hash path, a structural stand-in for
101
+ * the hash on the graph (`foldUnit` in keyed-projects.ts).
102
+ */
103
+ export interface FoldCandidate {
104
+ node: TaskNode
105
+ unit: string
106
+ }
107
+
108
+ /**
109
+ * The dependencies a task's key folds, per its `inputs.tasks`.
110
+ *
111
+ * Patterns (Turbo/Nx micro-syntax + filter extensions):
112
+ * '*' all same-project upstream
113
+ * '^*' all dep-workspace upstream
114
+ * 'name' same-project task `name`
115
+ * '^name' `name` task in every dep workspace
116
+ * 'pkg#name' specific package's `name` task
117
+ * 'name.*' patterns — EITHER half of any form above may contain `*`
118
+ * (same glob as dependsOn patterns), including the project
119
+ * half of `pkg#name`. A filter that matched literally here
120
+ * would silently select ZERO upstream hashes and decouple the
121
+ * task from its dependencies — a stale-hit trap, so every
122
+ * name is matched through the shared glob.
123
+ * '!<form>' exclude — any of the above with a leading `!`
124
+ *
125
+ * Patterns are applied in order; last write wins, so
126
+ * `['*', '^*', '!^noisy']` reads as "all minus deps' noisy".
127
+ *
128
+ * Defaults:
129
+ * - `filter === undefined` → every candidate, as given.
130
+ * - `filter === []` → none (fully decoupled).
131
+ *
132
+ * Otherwise deduped by `unit`, first candidate kept. The unit is the
133
+ * fold's own identity, not the task's: two groups over the same members
134
+ * share one hash, so excluding either excludes both, and a selection by
135
+ * task id would keep the other while the key folded neither.
136
+ */
137
+ export function selectFoldedDeps(
138
+ deps: readonly FoldCandidate[],
139
+ filter: readonly string[] | undefined,
140
+ selfProjectName: string,
141
+ selfTaskId: string,
142
+ ): FoldCandidate[] {
143
+ if (filter === undefined) return [...deps]
144
+
145
+ const specs: DependencySpec[] = filter.map((raw) => parseSpec(raw, selfTaskId))
146
+
147
+ // Per-spec predicate, compiled once (exact compares + patterns).
148
+ const matchers = specs.map((spec) => specMatcher(spec))
149
+
150
+ const selected = new Map<string, FoldCandidate>()
151
+ for (let i = 0; i < specs.length; i++) {
152
+ const spec = specs[i]!
153
+ const matches = matchers[i]!
154
+ for (const d of deps) {
155
+ const isSelf = d.node.projectName === selfProjectName
156
+ if (!matches(d.node, isSelf)) continue
157
+ if (spec.negated) selected.delete(d.unit)
158
+ else if (!selected.has(d.unit)) selected.set(d.unit, d)
159
+ }
160
+ }
161
+ return [...selected.values()]
162
+ }
163
+
164
+ /** Exact-name compare, or the shared `*`-glob when the name is a pattern. */
165
+ function nameMatcher(name: string): (candidate: string) => boolean {
166
+ if (isTaskPattern(name)) {
167
+ const re = compileTaskPattern(name)
168
+ return (candidate) => re.test(candidate)
169
+ }
170
+ return (candidate) => candidate === name
171
+ }
172
+
173
+ /**
174
+ * Compile one spec into an upstream predicate. BOTH halves of a `pkg#task`
175
+ * form glob: a filter only ever selects from upstreams that already exist,
176
+ * so a package pattern is unambiguous here — unlike dependsOn, which must
177
+ * materialize concrete edges and therefore rejects it.
178
+ */
179
+ function specMatcher(spec: DependencySpec): (n: TaskNode, isSelf: boolean) => boolean {
180
+ switch (spec.kind) {
181
+ case 'wildcardSelf':
182
+ return (_n, isSelf) => isSelf
183
+ case 'wildcardDeps':
184
+ return (_n, isSelf) => !isSelf
185
+ case 'self': {
186
+ const task = nameMatcher(spec.task)
187
+ return (n, isSelf) => isSelf && task(n.taskName)
188
+ }
189
+ case 'deps': {
190
+ const task = nameMatcher(spec.task)
191
+ return (n, isSelf) => !isSelf && task(n.taskName)
192
+ }
193
+ case 'cross': {
194
+ const project = nameMatcher(spec.project)
195
+ const task = nameMatcher(spec.task)
196
+ return (n) => project(n.projectName) && task(n.taskName)
197
+ }
198
+ }
199
+ }
200
+
201
+ /**
202
+ * The dependencies `node`'s key folds, per the hash path's rules above, as
203
+ * `keyUpstream` hands them to the key: the scheduled ones less the edges
204
+ * `--exclude-dependencies` left for order alone, plus the dropped ones it
205
+ * keys all the same (`excludedUpstream`), whose own dependencies the walk
206
+ * reads from the run's `keyOnly` map. `node.deps` held the order-only
207
+ * edges and lacked the dropped ones: the sandbox granted a sibling the key
208
+ * does not answer for.
209
+ */
210
+ export function foldedDeps(node: TaskNode, nodeOf: (id: string) => TaskNode): FoldCandidate[] {
211
+ const candidates: FoldCandidate[] = []
212
+ for (const id of keyedDeps(node)) {
213
+ const dep = nodeOf(id)
214
+ candidates.push({ node: dep, unit: foldUnit(dep) })
215
+ }
216
+ for (const { node: dep } of node.excludedUpstream ?? []) {
217
+ candidates.push({ node: dep, unit: foldUnit(dep) })
218
+ }
219
+ if (isGroupTask(node) && node.config.inputs === undefined) return candidates
220
+ return selectFoldedDeps(candidates, node.config.inputs?.tasks, node.projectName, node.id)
221
+ }
222
+
223
+ /**
224
+ * What stands in for a dependency's hash in the dedup: equal exactly when
225
+ * the hashes are. An exec task's key folds its own id (`task:<id>`), so its
226
+ * id is enough; a group's hash is a roll-up of its members' ids and hashes
227
+ * with no id of its own, so two groups over the same members hash alike.
228
+ * The members are `keyUpstream`'s, which `computeGroupHash` is handed: under
229
+ * `--exclude-dependencies` `node.deps` holds order-only edges and lacks the
230
+ * dropped ones, and two groups the key told apart shared a unit.
231
+ */
232
+ function foldUnit(node: TaskNode): string {
233
+ if (!isGroupTask(node) || node.config.inputs !== undefined) return `task|${node.id}`
234
+ const members = [...keyedDeps(node), ...(node.excludedUpstream ?? []).map((u) => u.node.id)]
235
+ return `group|${members.sort().join('|')}`
236
+ }