@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
@@ -0,0 +1,95 @@
1
+ // A task's terminal status and what each one means: a pass, a hit, where
2
+ // its result came from. One vocabulary every reader (the run verdict, the
3
+ // logger, the history queries, telemetry, plugins) asks, so none re-lists
4
+ // the statuses.
5
+
6
+ export type TaskStatus =
7
+ | 'success'
8
+ | 'cache-hit'
9
+ | 'cache-hit-remote'
10
+ | 'failed'
11
+ | 'skipped'
12
+ // A child killed by a shutdown signal (SIGINT/SIGTERM — e.g. Ctrl-C):
13
+ // the task never finished on its own terms, so it reverts to aborted —
14
+ // excluded from the tally and the run history. It still PROPAGATES like
15
+ // a failure (see `willSkip`): the child died mid-write, so its outputs
16
+ // are partial.
17
+ | 'aborted'
18
+
19
+ /** Where a task's result came from, derived ONCE in core from the status. */
20
+ export type CacheSource = 'miss' | 'local' | 'remote' | 'none'
21
+
22
+ /**
23
+ * Map a task outcome status to its cache source. `success`/`failed` ran
24
+ * (a miss as far as the cache read is concerned); the two cache-hit
25
+ * statuses restored from local/remote; `skipped`/`aborted` never engaged
26
+ * the cache. Pure function — the single place this mapping is decided;
27
+ * `isCacheHit` below is derived from it rather than re-listing the two
28
+ * hit statuses, so there is one decision, not two that can disagree.
29
+ */
30
+ export function deriveCacheSource(status: TaskStatus): CacheSource {
31
+ switch (status) {
32
+ case 'cache-hit':
33
+ return 'local'
34
+ case 'cache-hit-remote':
35
+ return 'remote'
36
+ case 'success':
37
+ case 'failed':
38
+ return 'miss'
39
+ case 'skipped':
40
+ case 'aborted':
41
+ return 'none'
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Whether each status counts as a PASS. Written as a `Record<TaskStatus, …>`
47
+ * rather than a `new Set([...])` on purpose: a Record must name every member,
48
+ * so adding a status to the union is a COMPILE error HERE and the omission
49
+ * cannot ship. A Set of string literals has no such tripwire — it silently
50
+ * answers `false` for the new member, which is how ten hand-rolled copies of
51
+ * this vocabulary accumulated across core, cloud and the dashboard.
52
+ */
53
+ const PASSES: Record<TaskStatus, boolean> = {
54
+ success: true,
55
+ 'cache-hit': true,
56
+ 'cache-hit-remote': true,
57
+ failed: false,
58
+ skipped: false,
59
+ aborted: false,
60
+ }
61
+
62
+ /**
63
+ * Every `TaskStatus`, at runtime. Read off `PASSES`'s keys, which the
64
+ * `Record<TaskStatus, …>` type guarantees is exactly the union — so this is
65
+ * derived, not a second list that can fall behind. Exported so a consumer that
66
+ * cannot import the type (a test asserting a copy of this vocabulary in
67
+ * another package agrees) can still enumerate the real set.
68
+ */
69
+ export const TASK_STATUSES: readonly TaskStatus[] = Object.keys(PASSES) as TaskStatus[]
70
+
71
+ /**
72
+ * Did the task pass? A cache hit counts — it produced the same result without
73
+ * spending the time, which is the whole point. `skipped` and `aborted` do NOT:
74
+ * neither finished on its own terms, so neither can vouch for anything.
75
+ *
76
+ * Takes `string`, not `TaskStatus`, because most callers hold a status that
77
+ * arrived over a wire or out of a database column. An unrecognised string
78
+ * reads as NOT passing — the safe direction, since the alternative is calling
79
+ * a run green on a status this build has never heard of.
80
+ */
81
+ export function isPassStatus(status: string): boolean {
82
+ return PASSES[status as TaskStatus] === true
83
+ }
84
+
85
+ /**
86
+ * Did the task's result come out of the cache (either layer)? Derived from
87
+ * `deriveCacheSource` rather than re-listing the two hit statuses, so the two
88
+ * cannot disagree about what a hit is. Unknown strings read as not-a-hit.
89
+ */
90
+ export function isCacheHit(status: string): boolean {
91
+ // No known-status guard: an unknown string falls through the switch to
92
+ // undefined, which is neither hit source (the guard survived item 654).
93
+ const source = deriveCacheSource(status as TaskStatus)
94
+ return source === 'local' || source === 'remote'
95
+ }
@@ -10,7 +10,7 @@
10
10
 
11
11
  import type { Database } from 'bun:sqlite'
12
12
  import { KEYED_RUNS_SQL } from '../cache/index.js'
13
- import { isCacheHit, TASK_STATUSES } from './telemetry.js'
13
+ import { isCacheHit, TASK_STATUSES } from '../graph/index.js'
14
14
 
15
15
  // SQL hit set derived from the predicate — never a hand-typed list and never
16
16
  // a prefix LIKE (a prefix counts any status merely NAMED cache-hit-*). The
@@ -16,7 +16,7 @@ import { branchSource, EXECUTED_RUNS_SQL } from '../cache/index.js'
16
16
  import { splitTaskId } from '../util/index.js'
17
17
  import { failureModeOf, mixedOutcomeKeysSql } from './failure-mode.js'
18
18
  import type { FailureMode } from './failure-mode.js'
19
- import { isPassStatus, TASK_STATUSES } from './telemetry.js'
19
+ import { isPassStatus, TASK_STATUSES } from '../graph/index.js'
20
20
 
21
21
  const DEFAULT_RECENT = 50
22
22
 
@@ -0,0 +1,48 @@
1
+ // What runs leave behind, read back: the duration table a schedule learns
2
+ // from, the flakiness verdict, the queries behind `vx last` / `vx why`, and
3
+ // what a miss's key changed. Read-only SQL over the cache's database; it
4
+ // never runs, renders or writes a run.
5
+ export {
6
+ EmptyHistoryProvider,
7
+ type HistoryProvider,
8
+ type HistoryTable,
9
+ LocalHistoryProvider,
10
+ PASS_STATUSES,
11
+ type TaskHistory,
12
+ type TaskResources,
13
+ } from './history.js'
14
+ export {
15
+ detectFlaky,
16
+ type FailureMode,
17
+ type FlakyCandidate,
18
+ type FlakyFinding,
19
+ type FlakyTask,
20
+ flakyTasks,
21
+ } from './failure-mode.js'
22
+ export {
23
+ cacheKeyDiff,
24
+ explainCacheKey,
25
+ getInvocation,
26
+ getRun,
27
+ inputWriters,
28
+ latestRunId,
29
+ listInvocations,
30
+ listRuns,
31
+ rootCauses,
32
+ whyDidThisRerun,
33
+ } from './metrics.js'
34
+ export type {
35
+ CacheEntryRow,
36
+ CacheKeyDiff,
37
+ CacheKeyExplanation,
38
+ InputDiffEntry,
39
+ InputWriter,
40
+ InvocationDetail,
41
+ ListInvocationsArgs,
42
+ ListRunsArgs,
43
+ RootCause,
44
+ RunDetail,
45
+ RunSummaryRow,
46
+ WhyDidThisRerun,
47
+ } from './metrics.js'
48
+ export { createMissExplainer, type MissExplainer } from './miss-reason.js'
@@ -15,11 +15,9 @@ import { statSync } from 'node:fs'
15
15
  import path from 'node:path'
16
16
  // The keyed-run predicate lives beside the schema in cache/cache.ts, so a
17
17
  // rule written once cannot drift.
18
- import { KEYED_RUNS_SQL } from '../cache/index.js'
19
- import { splitTaskId } from '../graph/index.js'
18
+ import { KEYED_RUNS_SQL, type TaskInputComponent } from '../cache/index.js'
19
+ import { isPassStatus, splitTaskId } from '../graph/index.js'
20
20
  import { clampInt, MASKED, secretNamed } from '../util/index.js'
21
- import type { TaskInputComponent } from './task-hash.js'
22
- import { isPassStatus } from './telemetry.js'
23
21
 
24
22
  // A recorded `cache_policy` is the `--cache-policy` spec (`local:rw,remote:r`).
25
23
  const readsCache = (spec: string): boolean => /(?:^|,)(?:local|remote):w?r/.test(spec)
@@ -57,6 +55,8 @@ export interface RunSummaryRow {
57
55
  notReady: 'timeout' | 'exited' | 'spawn' | null
58
56
  /** The task ran with the run's forwarded args (`-- …`): a requested one under a run that had some. */
59
57
  forwarded: boolean
58
+ /** Each remote read or write of its key that failed, `upload: <message>`, `; `-joined (v40). */
59
+ remoteError: string | null
60
60
  }
61
61
 
62
62
  export interface ListRunsArgs {
@@ -117,7 +117,7 @@ export function listRuns(db: Database, args: ListRunsArgs = {}): RunSummaryRow[]
117
117
  wallclock_start_ns AS wallclockStartNs, wallclock_end_ns AS wallclockEndNs,
118
118
  blocked_by AS blockedBy, timed_out AS timedOut,
119
119
  sandbox_violations AS sandboxViolations, not_ready AS notReady,
120
- forward_args IS NOT NULL AS forwarded
120
+ forward_args IS NOT NULL AS forwarded, remote_error AS remoteError
121
121
  FROM runs ${clause} ORDER BY id DESC LIMIT ?`,
122
122
  )
123
123
  .all(...params, limit) as RawRow[]
@@ -135,8 +135,7 @@ export function listRuns(db: Database, args: ListRunsArgs = {}): RunSummaryRow[]
135
135
 
136
136
  /**
137
137
  * One `vx run` invocation header — every `invocations` column, camelCased,
138
- * `tags` parsed to a `Record<string,string>` and `dirty`/`ci`/`exitOk`
139
- * surfaced as booleans. The SPA's
138
+ * `dirty`/`ci`/`exitOk` surfaced as booleans. The SPA's
140
139
  * existing fields (runId / startedAt / endedAt / taskCount / failedCount /
141
140
  * hitCount / totalDurationMs) are all present, so existing views keep working.
142
141
  */
@@ -170,7 +169,6 @@ export interface InvocationDetail {
170
169
  os: string | null
171
170
  arch: string | null
172
171
  vxVersion: string
173
- tags: Record<string, string>
174
172
  }
175
173
 
176
174
  interface InvocationRawRow {
@@ -201,7 +199,6 @@ interface InvocationRawRow {
201
199
  os: string | null
202
200
  arch: string | null
203
201
  vxVersion: string
204
- tags: string
205
202
  }
206
203
 
207
204
  const INVOCATION_COLUMNS = `
@@ -214,7 +211,7 @@ const INVOCATION_COLUMNS = `
214
211
  restored_remote_count AS restoredRemoteCount,
215
212
  exit_ok AS exitOk,
216
213
  commit_sha AS commitSha, branch, dirty, ci, ci_provider AS ciProvider,
217
- host, os, arch, vx_version AS vxVersion, tags`
214
+ host, os, arch, vx_version AS vxVersion`
218
215
 
219
216
  function mapInvocation(r: InvocationRawRow): InvocationDetail {
220
217
  let requestedTasks: string[] = []
@@ -222,15 +219,6 @@ function mapInvocation(r: InvocationRawRow): InvocationDetail {
222
219
  const parsed = JSON.parse(r.requestedTasks) as unknown
223
220
  if (Array.isArray(parsed)) requestedTasks = parsed.map(String)
224
221
  } catch {}
225
- let tags: Record<string, string> = {}
226
- try {
227
- const parsed = JSON.parse(r.tags) as unknown
228
- if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
229
- tags = Object.fromEntries(
230
- Object.entries(parsed as Record<string, unknown>).map(([k, v]) => [k, String(v)]),
231
- )
232
- }
233
- } catch {}
234
222
  return {
235
223
  runId: r.runId,
236
224
  command: r.command,
@@ -259,7 +247,6 @@ function mapInvocation(r: InvocationRawRow): InvocationDetail {
259
247
  os: r.os,
260
248
  arch: r.arch,
261
249
  vxVersion: r.vxVersion,
262
- tags,
263
250
  }
264
251
  }
265
252
 
@@ -9,7 +9,7 @@
9
9
  import type { Database } from 'bun:sqlite'
10
10
  import type { InputChanges } from '../graph/index.js'
11
11
  import { diffKeyComponents, loadEntryInputs } from './metrics.js'
12
- import type { TaskInputComponent } from './task-hash.js'
12
+ import type { TaskInputComponent } from '../cache/index.js'
13
13
 
14
14
  /** How many changed components a miss names; `count` says how many there were. */
15
15
  const NAMED = 10
package/src/index.ts CHANGED
@@ -37,7 +37,7 @@ export type {
37
37
  ExecConfig,
38
38
  ExecEnv,
39
39
  CacheConfig,
40
- CacheInputs,
40
+ InputsConfig,
41
41
  CacheOutputs,
42
42
  InputStability,
43
43
  SandboxConfig,
@@ -56,13 +56,9 @@ export { run, planRun, prepareRun } from './orchestrator/index.js'
56
56
  // The per-task duration history a `schedule` plugin learns from
57
57
  // (`@vzn/vx-schedule-history` does; a plugin package reaches core only
58
58
  // through this façade, which is what put these here).
59
- export { currentBranch, LocalHistoryProvider } from './orchestrator/index.js'
60
- export type {
61
- HistoryProvider,
62
- HistoryTable,
63
- TaskHistory,
64
- TaskResources,
65
- } from './orchestrator/index.js'
59
+ export { currentBranch } from './orchestrator/index.js'
60
+ export { LocalHistoryProvider } from './history/index.js'
61
+ export type { HistoryProvider, HistoryTable, TaskHistory, TaskResources } from './history/index.js'
66
62
  export type { PreparedRun } from './orchestrator/index.js'
67
63
  // `RunOptions.affected`: what `--affected`'s diff changed, per project.
68
64
  export type { AffectedChanges } from './workspace/index.js'
@@ -72,14 +68,8 @@ export type { AffectedChanges } from './workspace/index.js'
72
68
  // docs/cli.md § Programmatic API listed all three (item 387, 2026-09-19).
73
69
  export type { RunPlan, PlannedTask } from './orchestrator/index.js'
74
70
  export type { CiContext, GitContext, HostContext, WorkspaceIdentity } from './orchestrator/index.js'
75
- export type {
76
- CacheFlags,
77
- CachePolicy,
78
- Logger,
79
- OutputView,
80
- RunOptions,
81
- RunSummary,
82
- } from './orchestrator/index.js'
71
+ export type { CacheFlags, CachePolicy, RunOptions, RunSummary } from './orchestrator/index.js'
72
+ export type { Logger, OutputView } from './report/index.js'
83
73
  // `splitTaskId` is on the façade because the alternative is what happened:
84
74
  // with only `taskId()` to JOIN an id and nothing exported to SPLIT one,
85
75
  // consumers roll their own `split('#', 2)` and drift from the graph, which
@@ -178,8 +168,8 @@ export type {
178
168
  RunReportTask,
179
169
  PluginHookName,
180
170
  PluginHookHandlers,
181
- RunStartInfo,
182
171
  } from './orchestrator/index.js'
172
+ export type { RunStartInfo } from './report/index.js'
183
173
  // The per-task execution contract a plugin's `executor` capability returns.
184
174
  // (`runCommand` / `runSandboxed`, the local executor's own primitives, left
185
175
  // the façade 2026-09-10: no executor plugin built on them — `@vzn/vx-reapi`
@@ -211,15 +201,8 @@ export type {
211
201
  // consumer rolls its own Set of status literals — and a Set has no
212
202
  // compile-time tripwire when the union gains a member, so it silently answers
213
203
  // "no" for the new one.
214
- // `escapeMarkdownCell` is on the façade for the same demonstrated need: a
215
- // plugin that renders a run as a markdown table takes the same unvalidated
216
- // task names core does, and the cloud job summary shipped without the escape.
217
- export {
218
- escapeMarkdownCell,
219
- TELEMETRY_SCHEMA_VERSION,
220
- isCacheHit,
221
- isPassStatus,
222
- } from './orchestrator/index.js'
204
+ export { type CacheSource, isCacheHit, isPassStatus } from './graph/index.js'
205
+ export { TELEMETRY_SCHEMA_VERSION } from './orchestrator/index.js'
223
206
  // `TaskLogBuffer` is on the façade on the same demonstrated need: EVERY
224
207
  // telemetry sink that ships build output has to bound it, and the retention
225
208
  // rules (per-task tail, per-run budget, failures never evicted by successes,
@@ -231,7 +214,6 @@ export { LOG_WIRE_VERSION, TaskLogBuffer } from './orchestrator/index.js'
231
214
  // say `128 + SIGKILL`; the decode is the runner's convention, one copy.
232
215
  export { exitSignal } from './orchestrator/index.js'
233
216
  export type {
234
- CacheSource,
235
217
  RunContextRecord,
236
218
  RunSummaryRecord,
237
219
  TaskLogBundle,
@@ -242,5 +224,5 @@ export type {
242
224
  TelemetrySink,
243
225
  } from './orchestrator/index.js'
244
226
 
245
- // The serializable run result and its per-task views (what `run()` returns).
246
- export type { RunResult, TaskView, OutcomeView } from './orchestrator/index.js'
227
+ // The serializable per-task views.
228
+ export type { TaskView, OutcomeView } from './report/index.js'
@@ -33,7 +33,7 @@ const KEYED_BY_EVERY_TASK: ReadonlySet<string> = new Set(PROJECT_CONFIG_FILENAME
33
33
 
34
34
  /**
35
35
  * The projects every task of which the change reaches: the whole ones the
36
- * diff named, and those holding a changed path no cached task of theirs
36
+ * diff named, and those holding a changed path no task of theirs
37
37
  * declares as an input. vx cannot prove such a path re-keys nothing (a
38
38
  * `project` plugin may read it), so it reaches the whole project, as it did
39
39
  * before selection was per task.
@@ -42,15 +42,15 @@ function wholeProjects(
42
42
  changes: AffectedChanges,
43
43
  projects: ReadonlyMap<string, ProjectEntry>,
44
44
  ): Map<string, string | undefined> {
45
- // Each whole project, with the path no cached task claims when one did it.
45
+ // Each whole project, with the path no task claims when one did it.
46
46
  const whole = new Map<string, string | undefined>([...changes.whole].map((n) => [n, undefined]))
47
47
  for (const [name, rels] of changes.paths) {
48
48
  if (whole.has(name)) continue
49
- const caches = Object.values(projects.get(name)?.config.tasks ?? {}).flatMap((t) =>
50
- t.exec !== undefined && t.cache !== undefined ? [t.cache] : [],
49
+ const keyed = Object.values(projects.get(name)?.config.tasks ?? {}).filter(
50
+ (t) => t.exec !== undefined && t.inputs !== undefined,
51
51
  )
52
52
  const unclaimed = (rel: string): boolean =>
53
- KEYED_BY_EVERY_TASK.has(rel) || !caches.some((c) => declaresInput(c, rel, null))
53
+ KEYED_BY_EVERY_TASK.has(rel) || !keyed.some((t) => declaresInput(t, rel, null))
54
54
  const rel = rels.find(unclaimed)
55
55
  if (rel !== undefined) whole.set(name, rel)
56
56
  }
@@ -60,8 +60,8 @@ function wholeProjects(
60
60
  /**
61
61
  * Why `--affected` kept a requested task (`vx run --affected --dry`):
62
62
  * `input`, a changed `file` is one of its declared inputs; `project`, its
63
- * project changed and the task reads it whole (an uncached task, a path no
64
- * cached task declares, or no single `file`: a lockfile claim, a manifest
63
+ * project changed and the task reads it whole (a task with no inputs, a
64
+ * path no task declares, or no single `file`: a lockfile claim, a manifest
65
65
  * edge, a config import); `workspace`, a workspace-wide `file` (the
66
66
  * workspace config, a root file a plugin claims) reached every task of its
67
67
  * project; `package`, a `^` edge passes through `project`, a
@@ -86,9 +86,9 @@ export interface AffectedExplain {
86
86
  /**
87
87
  * The task ids of `ids` whose closure the change reaches, in their order.
88
88
  * A group seeds nothing (it runs nothing) unless it is keyed, as the default
89
- * `build` is; an uncached task seeds when a changed path lies in its project
90
- * (or the whole project is reached); a cached one when a changed path is one
91
- * of its declared inputs. A `^name` edge the
89
+ * `build` is; a task with no inputs seeds when a changed path lies in its
90
+ * project (or the whole project is reached); one with inputs when a changed
91
+ * path is one of them. A `^name` edge the
92
92
  * graph passes through a package it loaded no config for reaches it as the
93
93
  * default `build` would: any change there reaches the task.
94
94
  */
@@ -139,14 +139,15 @@ export function affectedRoots(
139
139
  return false
140
140
  }
141
141
  const seeded = (n: TaskNode): boolean => {
142
- const cache = n.config.cache
142
+ const task = n.config
143
+ const keyed = task.inputs !== undefined
143
144
  // Asked of every node, not only the changed projects' (the
144
145
  // `workspaceFiles` owners): a `graph` hook may have given the glob.
145
- if (cache !== undefined) {
146
- const file = changes.changed.find((rel) => declaresInput(cache, null, rel))
146
+ if (keyed) {
147
+ const file = changes.changed.find((rel) => declaresInput(task, null, rel))
147
148
  const dir =
148
149
  file === undefined
149
- ? (changes.nested ?? []).find((d) => workspaceFilesReachInto(cache, d))
150
+ ? (changes.nested ?? []).find((d) => workspaceFilesReachInto(task, d))
150
151
  : undefined
151
152
  if (file !== undefined || dir !== undefined) {
152
153
  seeds?.set(n.id, { kind: 'input', file: (file ?? dir)! })
@@ -155,10 +156,10 @@ export function affectedRoots(
155
156
  }
156
157
  if (!changes.projects.has(n.projectName)) return false
157
158
  const rels = changes.paths.get(n.projectName) ?? []
158
- // A group runs nothing; only the default `build` (projects.ts) is keyed.
159
- // An uncached task reads its project: a root file another task of it
160
- // declares (`workspaceFiles`) is no change there.
161
- if (cache === undefined) {
159
+ // An unkeyed group runs nothing. A task with no inputs may read anything
160
+ // in its project: a root file another task of it declares
161
+ // (`workspaceFiles`) is no change there.
162
+ if (!keyed) {
162
163
  const hit = !isGroupTask(n) && (whole.has(n.projectName) || changes.paths.has(n.projectName))
163
164
  if (hit) seeds?.set(n.id, inProject(n.projectName, rels[0]))
164
165
  return hit
@@ -167,7 +168,7 @@ export function affectedRoots(
167
168
  seeds?.set(n.id, inProject(n.projectName, whole.get(n.projectName)))
168
169
  return true
169
170
  }
170
- const rel = rels.find((r) => declaresInput(cache, r, null))
171
+ const rel = rels.find((r) => declaresInput(task, r, null))
171
172
  if (rel === undefined) return false
172
173
  if (seeds !== null) seeds.set(n.id, { ...inProject(n.projectName, rel), kind: 'input' })
173
174
  return true
@@ -30,7 +30,7 @@ import {
30
30
  resolveCacheDir,
31
31
  resolveStoreRoot,
32
32
  } from '../workspace/index.js'
33
- import { flakyTasks, type FlakyTask } from './failure-mode.js'
33
+ import { flakyTasks, type FlakyTask } from '../history/index.js'
34
34
  import type { VxPlugin } from './plugin.js'
35
35
  import { discoverProjects, loadProjects, loadWorkspacePlugins, lockReader } from './projects.js'
36
36
  import { atPath, envName, resolveSetting, SETTINGS, type SettingSource } from '../settings.js'
@@ -19,7 +19,7 @@ export type DownloadMode = 'eager' | 'deferred' | 'never'
19
19
  * The channel this closes is narrow and specific. A dependent's key folds
20
20
  * an upstream's KEY, never its output content (pure-input transitive
21
21
  * hashing), so deferral cannot move a dependent's key that way. The one
22
- * real channel is a task whose `cache.inputs` globs can MATCH a producer's
22
+ * real channel is a task whose `inputs` globs can MATCH a producer's
23
23
  * declared outputs on disk: then its key differs by whether the bytes
24
24
  * arrived.
25
25
  *
@@ -30,7 +30,7 @@ export type DownloadMode = 'eager' | 'deferred' | 'never'
30
30
  * `--cache-download=none` with nothing to defer. Comparing the globs' static
31
31
  * prefixes answers the question actually being asked, and stays
32
32
  * conservative in every direction that matters: a leading wildcard yields
33
- * `.` and reaches everything, a cacheable task with no declared `files`
33
+ * `.` and reaches everything, a task with empty `inputs.files`
34
34
  * is treated as reading its whole project, and `workspaceFiles` on either
35
35
  * side ignores project boundaries — all three force ineligible.
36
36
  */
@@ -44,10 +44,10 @@ export function deferralEligibility(nodes: Map<string, TaskNode>): Map<string, s
44
44
  if (list) list.push({ taskId: n.id, prefixes, via })
45
45
  else readersByProject.set(n.projectName, [{ taskId: n.id, prefixes, via }])
46
46
  }
47
- // A task with no `cache` block keys on every file in its project, and a
48
- // cached task that depends on it folds that key: deferring a producer
49
- // there moved both keys with the transfer flag. Only such a task is a
50
- // reader — one nothing cached folds keys nothing that lasts.
47
+ // A task with no `cache` block keys on its inputs, and a cached task that
48
+ // depends on it folds that key: deferring a producer there moved both
49
+ // keys with the transfer flag. Only such a task is a reader — one nothing
50
+ // cached folds keys nothing that lasts.
51
51
  const folded = new Set<string>()
52
52
  const stack = [...nodes.values()]
53
53
  .filter((n) => n.config.cache !== undefined)
@@ -60,14 +60,11 @@ export function deferralEligibility(nodes: Map<string, TaskNode>): Map<string, s
60
60
  let workspaceReader: string | undefined
61
61
  let runtimeReader: string | undefined
62
62
  for (const n of nodes.values()) {
63
- const cache = n.config.cache
64
- if (cache === undefined) {
65
- if (folded.has(n.id) && !isGroupTask(n) && n.config.exec?.persistent === undefined) {
66
- addReader(n, ['.'], 'as a task with no cache block that a cached task folds')
67
- }
68
- continue
69
- }
70
- if ((cache.inputs?.workspaceFiles?.length ?? 0) > 0) workspaceReader ??= n.id
63
+ const inputs = n.config.inputs
64
+ // A task with no inputs has no key to read; an uncached one with inputs
65
+ // is read only for the cached tasks that fold its key.
66
+ if (inputs === undefined || (n.config.cache === undefined && !folded.has(n.id))) continue
67
+ if ((inputs?.workspaceFiles?.length ?? 0) > 0) workspaceReader ??= n.id
71
68
  // A `runtime` input is a SHELL COMMAND whose reads are unknowable —
72
69
  // the same reason vx refuses to infer inputs by tracing. It can `cat` a
73
70
  // producer's output (or path-escape its project to do it), and its
@@ -77,17 +74,14 @@ export function deferralEligibility(nodes: Map<string, TaskNode>): Map<string, s
77
74
  // is exactly what such a command would sample. Nothing defers in a run
78
75
  // that declares one — worse than nothing would be a key that moves with
79
76
  // a transfer flag.
80
- if (
81
- (cache.inputs?.runtime?.length ?? 0) > 0 ||
82
- (cache.inputs?.workspaceRuntime?.length ?? 0) > 0
83
- ) {
77
+ if ((inputs?.runtime?.length ?? 0) > 0 || (inputs?.workspaceRuntime?.length ?? 0) > 0) {
84
78
  runtimeReader ??= n.id
85
79
  }
86
- const files = cache.inputs?.files
80
+ const files = inputs?.files
87
81
  addReader(
88
82
  n,
89
83
  files === undefined || files.length === 0 ? ['.'] : files.map(staticPrefix),
90
- 'with cache.inputs.files',
84
+ 'with inputs.files',
91
85
  )
92
86
  }
93
87
 
@@ -105,14 +99,14 @@ export function deferralEligibility(nodes: Map<string, TaskNode>): Map<string, s
105
99
  if (runtimeReader !== undefined) {
106
100
  ineligible.set(
107
101
  n.id,
108
- `${runtimeReader} declares a cache.inputs.runtime command, whose reads cannot be bounded`,
102
+ `${runtimeReader} declares a inputs.runtime command, whose reads cannot be bounded`,
109
103
  )
110
104
  continue
111
105
  }
112
106
  if (workspaceReader !== undefined) {
113
107
  ineligible.set(
114
108
  n.id,
115
- `${workspaceReader} declares cache.inputs.workspaceFiles, which can read any project`,
109
+ `${workspaceReader} declares inputs.workspaceFiles, which can read any project`,
116
110
  )
117
111
  continue
118
112
  }
@@ -14,9 +14,9 @@
14
14
  // full run looks up.
15
15
 
16
16
  import type { CacheLayer, GitFilesCache } from '../cache/index.js'
17
- import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
17
+ import { isGroupTask, keyedDeps, type TaskNode, type TaskOutcome } from '../graph/index.js'
18
18
  import { computeGroupKey, computeTaskHash, type HashCache } from './task-hash.js'
19
- import { filterUpstreamHashes, keyedDeps, keyedOutcome } from './upstream.js'
19
+ import { filterUpstreamHashes, keyedOutcome } from './upstream.js'
20
20
 
21
21
  export interface KeyExcludedArgs {
22
22
  /** The scheduled graph, after `excludeDependencies`. */
@@ -124,7 +124,7 @@ export function excludedTaint(nodes: ReadonlyMap<string, TaskNode>): {
124
124
 
125
125
  /**
126
126
  * Whether `node`'s key folds a dropped dependency's key: a group folds all
127
- * of its dependencies, an exec task what its `cache.inputs.tasks` selects.
127
+ * of its dependencies, an exec task what its `inputs.tasks` selects.
128
128
  * A task with `tasks: []` folds none, so its entry claims nothing about a
129
129
  * dependency and it saves as usual.
130
130
  */
@@ -133,7 +133,6 @@ function foldsExcludedKey(node: TaskNode): boolean {
133
133
  if (excluded === undefined) return false
134
134
  if (isGroupTask(node)) return true
135
135
  return (
136
- filterUpstreamHashes(excluded, node.config.cache?.inputs?.tasks, node.projectName, node.id)
137
- .length > 0
136
+ filterUpstreamHashes(excluded, node.config.inputs?.tasks, node.projectName, node.id).length > 0
138
137
  )
139
138
  }
@@ -0,0 +1,80 @@
1
+ // What a dispatch hands executeTask: the run's half, built once, and the
2
+ // task's half, looked up per dispatch. The run's half is one fixed-shape
3
+ // object every task spreads; the task's optional fields are assigned only
4
+ // when present: twelve conditional spreads per task cost ~18 ms of object
5
+ // copying on a 1,090-package warm run (X-149).
6
+
7
+ import type { TaskNode, TaskOutcome } from '../graph/index.js'
8
+ import type { Logger } from '../report/index.js'
9
+ import type { DownloadMode } from './download-policy.js'
10
+ import type { ExecuteArgs } from './execute-task.js'
11
+ import type { ShortCircuit } from './local-shortcircuit.js'
12
+ import { UNPLACED_EXECUTOR, type Placements } from './placement.js'
13
+
14
+ type TaskField =
15
+ | 'node'
16
+ | 'upstream'
17
+ | 'log'
18
+ | 'executor'
19
+ | 'nestedProjectDirs'
20
+ | 'remoteOnlyNoop'
21
+ | 'remoteOnly'
22
+ | 'download'
23
+ | 'preProbed'
24
+ | 'upfrontKey'
25
+ | 'upfrontGroupKey'
26
+ | 'taintedUpstream'
27
+ | 'noDependants'
28
+ | 'terminal'
29
+
30
+ /** What every task of a run shares; an absent optional stays absent. */
31
+ export type RunScope = Omit<ExecuteArgs, TaskField>
32
+
33
+ /** Where the task's half is read from, per task id. */
34
+ export interface TaskFacts {
35
+ log: (node: TaskNode) => Logger
36
+ placements: Placements
37
+ nestedDirsByProject: ReadonlyMap<string, string[]>
38
+ downloadModeOf: ReadonlyMap<string, DownloadMode>
39
+ shortCircuit: Pick<ShortCircuit, 'preProbed' | 'uncachedKeys' | 'groupKeys'>
40
+ /** Tasks some other task depends on. */
41
+ dependedOn: ReadonlySet<string>
42
+ /** Tasks that hold the terminal (`terminalHolders`). */
43
+ holders: ReadonlySet<string>
44
+ }
45
+
46
+ /**
47
+ * The dispatch builder. `reuseProbe: false` takes the task's key again
48
+ * instead of the up-front probe (admission's retry after a dropped probe);
49
+ * `tainted` is the run's taint verdict for this dispatch.
50
+ */
51
+ export function executeArgsOf(
52
+ scope: RunScope,
53
+ facts: TaskFacts,
54
+ ): (node: TaskNode, upstream: TaskOutcome[], reuseProbe: boolean, tainted: boolean) => ExecuteArgs {
55
+ const { placements, shortCircuit } = facts
56
+ return (node, upstream, reuseProbe, tainted) => {
57
+ const id = node.id
58
+ const a: ExecuteArgs = {
59
+ ...scope,
60
+ node,
61
+ upstream,
62
+ log: facts.log(node),
63
+ executor: placements.executors.get(id) ?? UNPLACED_EXECUTOR,
64
+ nestedProjectDirs: facts.nestedDirsByProject.get(node.projectName) ?? [],
65
+ }
66
+ const probe = reuseProbe ? shortCircuit.preProbed.get(id) : undefined
67
+ const upfrontKey = shortCircuit.uncachedKeys.get(id)
68
+ const upfrontGroupKey = shortCircuit.groupKeys.get(id)
69
+ if (facts.downloadModeOf.get(id) === 'deferred') a.download = 'deferred'
70
+ if (placements.remoteOnlyNoop.has(id)) a.remoteOnlyNoop = true
71
+ if (placements.remoteOnly.has(id)) a.remoteOnly = true
72
+ if (probe !== undefined) a.preProbed = probe
73
+ if (upfrontKey !== undefined) a.upfrontKey = upfrontKey
74
+ if (upfrontGroupKey !== undefined) a.upfrontGroupKey = upfrontGroupKey
75
+ if (tainted) a.taintedUpstream = true
76
+ if (!facts.dependedOn.has(id)) a.noDependants = true
77
+ if (facts.holders.has(id)) a.terminal = true
78
+ return a
79
+ }
80
+ }