@vzn/vx 0.0.650 → 0.0.652

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 (146) hide show
  1. package/README.md +7 -23
  2. package/package.json +5 -5
  3. package/schemas/last.json +15 -0
  4. package/schemas/show.json +14 -20
  5. package/src/bin.ts +21 -31
  6. package/src/cache/archive.ts +1 -1
  7. package/src/cache/cache.ts +221 -30
  8. package/src/cache/file-hashes.ts +3 -0
  9. package/src/cache/git-inputs.ts +188 -39
  10. package/src/cache/history-stream.ts +134 -0
  11. package/src/cache/history-writer.ts +76 -0
  12. package/src/cache/index.ts +3 -0
  13. package/src/cache/inputs.ts +77 -14
  14. package/src/cache/layer.ts +17 -6
  15. package/src/cache/layered-cache.ts +4 -0
  16. package/src/cache/linked-inputs.ts +3 -0
  17. package/src/cache/lockfile-memo.ts +5 -3
  18. package/src/cache/output-index.ts +5 -2
  19. package/src/cache/remote-lane.ts +2 -1
  20. package/src/cache/run-history.ts +56 -17
  21. package/src/cache/schema.ts +15 -2
  22. package/src/cli/cache.ts +28 -18
  23. package/src/cli/completions.ts +4 -2
  24. package/src/cli/docs.ts +10 -11
  25. package/src/cli/help.ts +14 -25
  26. package/src/cli/index.ts +28 -9
  27. package/src/cli/info.ts +16 -8
  28. package/src/cli/init.ts +23 -15
  29. package/src/cli/last.ts +246 -204
  30. package/src/cli/lock.ts +55 -5
  31. package/src/cli/plan-format.ts +100 -118
  32. package/src/cli/plugin-commands.ts +8 -2
  33. package/src/cli/plugin-templates.ts +36 -45
  34. package/src/cli/run.ts +46 -27
  35. package/src/cli/select.ts +43 -5
  36. package/src/cli/show.ts +45 -52
  37. package/src/cli/task-verb.ts +22 -0
  38. package/src/cli/upgrade.ts +3 -1
  39. package/src/cli/watch-fs.ts +6 -1
  40. package/src/cli/watch-judge.ts +3 -2
  41. package/src/cli/watch-set.ts +4 -4
  42. package/src/cli/watch.ts +94 -52
  43. package/src/cli/why.ts +123 -89
  44. package/src/cli/workspace-config.ts +2 -1
  45. package/src/config.ts +47 -30
  46. package/src/exec/executor.ts +42 -2
  47. package/src/exec/index.ts +1 -0
  48. package/src/exec/kill-tree.ts +58 -5
  49. package/src/exec/local-executor.ts +1 -1
  50. package/src/exec/pipes.ts +152 -0
  51. package/src/exec/runner.ts +199 -182
  52. package/src/exec/sandbox-binds.ts +1 -1
  53. package/src/exec/sandbox-paths.ts +3 -1
  54. package/src/exec/sandbox-runtime.ts +317 -23
  55. package/src/exec/spawn-off.ts +154 -0
  56. package/src/exec/task-proxy.ts +93 -0
  57. package/src/exec/warm-shell.ts +146 -0
  58. package/src/graph/critical-path.ts +40 -28
  59. package/src/graph/definition.ts +16 -3
  60. package/src/graph/priorities.ts +98 -44
  61. package/src/graph/scheduler.ts +17 -11
  62. package/src/graph/task-graph.ts +51 -24
  63. package/src/handoff.ts +10 -7
  64. package/src/history/failure-mode.ts +24 -1
  65. package/src/history/history.ts +5 -0
  66. package/src/history/index.ts +1 -0
  67. package/src/history/metrics.ts +14 -6
  68. package/src/index.ts +7 -3
  69. package/src/orchestrator/admission.ts +1 -1
  70. package/src/orchestrator/command-windows.ts +1 -1
  71. package/src/orchestrator/deferred-outputs.ts +1 -1
  72. package/src/orchestrator/doctor.ts +1 -1
  73. package/src/orchestrator/download-policy.ts +5 -7
  74. package/src/orchestrator/excluded-keys.ts +1 -1
  75. package/src/orchestrator/execute-task.ts +67 -20
  76. package/src/orchestrator/graph-memo.ts +50 -1
  77. package/src/orchestrator/graph-stage.ts +6 -0
  78. package/src/orchestrator/hit-restore.ts +5 -6
  79. package/src/orchestrator/index.ts +3 -0
  80. package/src/orchestrator/keyed-projects.ts +28 -0
  81. package/src/orchestrator/live-usage.ts +16 -11
  82. package/src/orchestrator/local-shortcircuit.ts +2 -2
  83. package/src/orchestrator/locked-digests.ts +63 -0
  84. package/src/orchestrator/lockfile-claim.ts +108 -29
  85. package/src/orchestrator/miss-save.ts +58 -11
  86. package/src/orchestrator/persistent-task.ts +5 -1
  87. package/src/orchestrator/placement.ts +1 -1
  88. package/src/orchestrator/plan.ts +7 -3
  89. package/src/orchestrator/plugin-host.ts +67 -2
  90. package/src/orchestrator/plugin.ts +53 -2
  91. package/src/orchestrator/prepare.ts +16 -1
  92. package/src/orchestrator/projects.ts +4 -1
  93. package/src/orchestrator/remote-prefetch.ts +1 -1
  94. package/src/orchestrator/run-context.ts +29 -10
  95. package/src/orchestrator/run-failures.ts +62 -13
  96. package/src/orchestrator/run-keys.ts +1 -1
  97. package/src/orchestrator/run-records.ts +88 -59
  98. package/src/orchestrator/run-report.ts +1 -1
  99. package/src/orchestrator/run.ts +145 -41
  100. package/src/orchestrator/sandbox-request.ts +52 -9
  101. package/src/orchestrator/shell-verdict.ts +9 -0
  102. package/src/orchestrator/signals.ts +8 -5
  103. package/src/orchestrator/stable-keys.ts +17 -18
  104. package/src/orchestrator/task-artifacts.ts +1 -1
  105. package/src/orchestrator/task-hash.ts +21 -7
  106. package/src/orchestrator/telemetry.ts +5 -1
  107. package/src/report/events.ts +6 -14
  108. package/src/report/forecast.ts +10 -32
  109. package/src/report/framed-output.ts +283 -175
  110. package/src/report/index.ts +8 -3
  111. package/src/report/logger.ts +74 -39
  112. package/src/report/plain-output.ts +3 -1
  113. package/src/report/status-line.ts +49 -357
  114. package/src/report/summary.ts +105 -136
  115. package/src/settings.ts +8 -8
  116. package/src/ui/ansi.ts +48 -0
  117. package/src/ui/components.ts +506 -0
  118. package/src/ui/index.ts +79 -0
  119. package/src/ui/kit.ts +37 -0
  120. package/src/ui/live-region.ts +363 -0
  121. package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
  122. package/src/ui/terminal.ts +51 -0
  123. package/src/ui/theme.ts +224 -0
  124. package/src/ui/version-line.ts +28 -0
  125. package/src/util/config-frame.ts +191 -14
  126. package/src/util/edit-distance.ts +6 -1
  127. package/src/util/index.ts +2 -15
  128. package/src/util/paths.ts +21 -3
  129. package/src/util/secret-mask.ts +39 -3
  130. package/src/util/settle.ts +33 -1
  131. package/src/util/tail.ts +4 -1
  132. package/src/util/timing.ts +4 -16
  133. package/src/workspace/affected.ts +89 -30
  134. package/src/workspace/bracket-literal.ts +2 -2
  135. package/src/workspace/config-schema.ts +70 -63
  136. package/src/workspace/dependencies-lock.ts +191 -0
  137. package/src/workspace/filter.ts +36 -1
  138. package/src/workspace/index.ts +9 -0
  139. package/src/workspace/lockfile.ts +159 -6
  140. package/src/workspace/migration.ts +16 -9
  141. package/src/workspace/workspace.ts +3 -0
  142. package/src/util/colors.ts +0 -133
  143. package/src/util/report.ts +0 -44
  144. package/src/util/ui.ts +0 -258
  145. /package/src/{report/path-links.ts → ui/links.ts} +0 -0
  146. /package/src/{report → ui}/terminal-signals.ts +0 -0
package/src/cli/lock.ts CHANGED
@@ -8,10 +8,16 @@ import path from 'node:path'
8
8
  import type { ProjectConfig } from '../config.js'
9
9
  import { flagHint, formatValue, refuse, refusedWord, seeHelp } from './help.js'
10
10
  import { buildTaskGraph, expandRequested, isDefaultBuild } from '../graph/index.js'
11
- import { applyGraphStage, discoverProjects, hasHook, loadProjects } from '../orchestrator/index.js'
11
+ import {
12
+ applyGraphStage,
13
+ claimedDigests,
14
+ discoverProjects,
15
+ fingerprintClaims,
16
+ hasHook,
17
+ loadProjects,
18
+ } from '../orchestrator/index.js'
12
19
  import { resolveSetting, runRules } from '../settings.js'
13
20
  import {
14
- refusalNotice,
15
21
  DOT_VX_GITATTRIBUTES,
16
22
  fsRefusalHint,
17
23
  isFsRefusal,
@@ -23,11 +29,11 @@ import {
23
29
  type UserError,
24
30
  secretMask,
25
31
  type SecretMask,
26
- ui,
27
- sayNotice,
28
32
  refuseSymlink,
29
33
  writeNoFollow,
30
34
  } from '../util/index.js'
35
+ import * as ui from '../ui/index.js'
36
+ import { refusalNotice, sayNotice } from '../ui/index.js'
31
37
  import {
32
38
  ConfigsRefused,
33
39
  findProjectConfigs,
@@ -43,6 +49,11 @@ import {
43
49
  checkDependencyRules,
44
50
  computeNestedProjectDirs,
45
51
  writeLockfile,
52
+ DEPENDENCIES_LOCK_NAME,
53
+ dependenciesLock,
54
+ parseDependenciesLock,
55
+ writeDependenciesLock,
56
+ type DependenciesLock,
46
57
  type Lockfile,
47
58
  type ProjectMeta,
48
59
  } from '../workspace/index.js'
@@ -140,6 +151,7 @@ async function audit(root: string, warn: (message: string) => void): Promise<Gra
140
151
  reviewFiles: true,
141
152
  strayReviews: [],
142
153
  shadowed: [],
154
+ dependencies: null,
143
155
  }
144
156
  try {
145
157
  const { workspaceConfig, plugins, cacheDir } = await loadCliWorkspace(root)
@@ -178,6 +190,7 @@ async function audit(root: string, warn: (message: string) => void): Promise<Gra
178
190
  })
179
191
  for (const entry of projects.values()) resolved.set(entry.name, entry.config)
180
192
  out.loaded = true
193
+ out.dependencies = await dependenciesOf(root, out.all, plugins, cacheDir, warn)
181
194
  checkBracketLiterals(root, projects.values())
182
195
  const names = new Set<string>()
183
196
  for (const entry of projects.values()) {
@@ -252,6 +265,30 @@ interface GraphCheck {
252
265
  strayReviews: string[]
253
266
  /** Config files a higher-precedence one beside them hides (`findProjectConfigs`). */
254
267
  shadowed: { file: string; read: string }[]
268
+ /** What the dependencies resolve to here; null when the configs did not load. */
269
+ dependencies: DependenciesLock | null
270
+ }
271
+
272
+ /** Each project's edges and each claimed file's digests, as `--affected` reads them back. */
273
+ async function dependenciesOf(
274
+ root: string,
275
+ all: readonly ProjectMeta[],
276
+ plugins: Parameters<typeof fingerprintClaims>[0],
277
+ cacheDir: string,
278
+ warn: (message: string) => void,
279
+ ): Promise<DependenciesLock> {
280
+ const ctx = {
281
+ workspaceRoot: root,
282
+ cacheDir,
283
+ warn,
284
+ projects: all.map((p) => ({ name: p.name, dir: p.dir })),
285
+ }
286
+ const files = new Map<string, NonNullable<Awaited<ReturnType<typeof claimedDigests>>>>()
287
+ for (const [file, plugin] of fingerprintClaims(plugins)) {
288
+ const answer = await claimedDigests(plugin, file, ctx)
289
+ if (answer !== undefined) files.set(file, answer)
290
+ }
291
+ return dependenciesLock(root, all, files)
255
292
  }
256
293
 
257
294
  function configured(all: readonly ProjectMeta[]): ConfiguredMeta[] {
@@ -415,6 +452,7 @@ async function writeLock(
415
452
  return 1
416
453
  }
417
454
  await writeLockfile(root, check.fresh)
455
+ if (check.dependencies !== null) await writeDependenciesLock(root, check.dependencies)
418
456
  await writeIfMissing(path.join(root, '.vx', '.gitattributes'), DOT_VX_GITATTRIBUTES)
419
457
  const n = metas.length
420
458
  say(`${n} project config${n === 1 ? '' : 's'} → ${LOCKFILE_NAME}${bareNote(bare)}`)
@@ -517,7 +555,7 @@ async function checkLock(root: string, json: boolean): Promise<number> {
517
555
  notAudited: bare.length,
518
556
  problems,
519
557
  }
520
- process.stdout.write(`${JSON.stringify(out)}\n`)
558
+ ui.data('out', `${JSON.stringify(out)}\n`)
521
559
  }
522
560
  return problems.length === 0 ? 0 : 1
523
561
  }
@@ -600,6 +638,18 @@ async function checkLock(root: string, json: boolean): Promise<number> {
600
638
  failures.push(`the lock holds ${what}, which has no vx.config now — run 'vx lock'`)
601
639
  }
602
640
  }
641
+ // Named only over a lock that holds: a config drift moves the record too.
642
+ if (check.dependencies !== null && failures.length === 0) {
643
+ const text = await Bun.file(path.join(root, DEPENDENCIES_LOCK_NAME))
644
+ .bytes()
645
+ .catch(() => null)
646
+ // Compared as JSON: a formatter may lay the file out its own way.
647
+ const read = parseDependenciesLock(text)
648
+ if (read === null || !Bun.deepEquals(read, check.dependencies, true))
649
+ failures.push(
650
+ `${DEPENDENCIES_LOCK_NAME} ${text === null ? 'is missing' : 'is not what the dependencies resolve to here'} — run 'vx lock'`,
651
+ )
652
+ }
603
653
  if (json)
604
654
  // Root-relative, as every other problem: the loader names the absolute path.
605
655
  return answer([
@@ -1,45 +1,37 @@
1
1
  // Formatters for `--dry` (human / JSON).
2
2
 
3
3
  import type { AffectedReason, CacheStatus, RunPlan } from '../orchestrator/index.js'
4
- import { skippedReason } from '../report/index.js'
5
- import {
6
- formatBytes,
7
- formatElapsed,
8
- maskedCommand,
9
- paint,
10
- SUCCESS,
11
- ui,
12
- WARN,
13
- type ColorSupport,
14
- } from '../util/index.js'
4
+ import { computeReverseDepCount } from '../graph/index.js'
5
+ import { planList, type PlannedRow, skippedReason } from '../report/index.js'
6
+ import { formatBytes, formatElapsed, maskedCommand } from '../util/index.js'
7
+ import * as ui from '../ui/index.js'
8
+ import type { ColorSupport } from '../ui/index.js'
15
9
 
16
10
  /**
17
- * Human-readable preview. One line per real task (groups hidden, same
18
- * as the live runner), tagged with predicted cache outcome. A would-run
19
- * task with recorded history also shows its typical duration (~p50).
11
+ * `vx run --dry`'s screen: the `Plan` panel, each task on the running
12
+ * list's grid (what the run would do with it, its usual time, where it
13
+ * would run), then the counts and the predicted time.
20
14
  *
21
- * would run:
22
- * ✓ @vzn/vx#lint cache hit (local) abc12345
23
- * ↓ @vzn/vx#test cache hit (remote) def67890
24
- * ▶ @vzn/vx#build cache miss — would exec fedcba98 ~1.20s
15
+ * ─ Plan 3 ─────────────────────────────────────────────────────
16
+ * time usual status cache cpu mem outputs blocks worker task
17
+ * ⇢ cached local local @vzn/vx#lint
18
+ * ⇣ cached remote 12MB local @vzn/vx#test
19
+ * ⏺︎ ~1.2s run miss local @vzn/vx#build
25
20
  *
26
- * 3 task(s) planned, 2 cache hits (1 local, 1 remote), 1 would run.
27
- * predicted: ~1.20s wall · ~1.20s total execution
21
+ * tasks 3 planned · 2 cache hits (1 local, 1 remote, 12MB) · 1 would run
22
+ * predicted ~1.20s wall · ~1.20s total execution
28
23
  */
29
- export function formatPlanText(plan: RunPlan, c: ColorSupport = { enabled: false }): string {
24
+ export function planReport(plan: RunPlan): ui.Block {
30
25
  // A group the platform skip reaches is still a group: it runs nothing.
31
26
  const real = plan.tasks.filter(
32
27
  (t) =>
33
28
  t.cacheStatus !== 'group' &&
34
29
  (t.cacheStatus !== 'skipped' || t.node.config.exec !== undefined),
35
30
  )
36
- const dim = (s: string): string => paint('', s, c, { dim: true })
37
- if (real.length === 0) return `${dim('No tasks planned.')}\n`
38
-
39
- const idWidth = Math.max(...real.map((t) => t.node.id.length))
40
- const tagWidth = 24
31
+ if (real.length === 0) return ui.section('Plan', ui.text('No tasks planned.'))
41
32
 
42
- const lines: string[] = [paint('', 'would run:', c, { bold: true })]
33
+ const blocks = downstream(plan)
34
+ const rows: PlannedRow[] = []
43
35
  let local = 0
44
36
  let remote = 0
45
37
  let miss = 0
@@ -50,33 +42,28 @@ export function formatPlanText(plan: RunPlan, c: ColorSupport = { enabled: false
50
42
  for (const t of real) {
51
43
  // `@noop`: the run succeeds it without running, whatever its key says.
52
44
  const skipped = t.executor === 'noop' && t.cacheStatus !== 'skipped'
53
- const sym = skipped ? '·' : symbolFor(t.cacheStatus)
54
- const desc = skipped
55
- ? 'noop — would not run'
56
- : t.cacheStatus === 'skipped'
57
- ? `skipped — ${skippedReason(t)}`
58
- : describe(t.cacheStatus)
59
- const shortHash = t.hash ? t.hash.slice(0, 8) : ''
45
+ const notes: string[] = []
46
+ if (t.cacheStatus === 'skipped') notes.push(skippedReason(t))
47
+ const desc = shownDescription(t)
48
+ if (desc !== undefined && desc !== '') notes.push(desc)
49
+ if (t.affected !== undefined) notes.push(`affected: ${describeReason(t.affected)}`)
60
50
  const executes = !skipped && (t.cacheStatus === 'miss' || t.cacheStatus === 'no-cache')
61
- const eta = executes && t.p50Ms !== undefined ? ` ~${formatElapsed(t.p50Ms)}` : ''
62
- // Placement, by EXECUTOR NAME rather than a local/remote word: the
63
- // summary line below already spends "local" and "remote" on the cache
64
- // tier, and a task placed on a named executor is what the reader can
65
- // act on. Present only when the workspace declared a choice.
66
- const where = t.executor !== undefined ? ` @${t.executor}` : ''
67
- const hue = skipped ? '' : HUE[t.cacheStatus]
68
- lines.push(
69
- ` ${paint(hue, sym, c, { dim: hue === '' })} ${ui.inline(ui.id(t.node.id), c)}${' '.repeat(idWidth - t.node.id.length)} ${paint(hue, desc.padEnd(tagWidth), c, { dim: hue === '' })} ${dim(shortHash)}${dim(eta)}${where}`,
70
- )
71
- // Optional one-line description from the task config, indented
72
- // under the id so the eye picks up the task → blurb mapping.
73
- const taskDesc = shownDescription(t)
74
- if (taskDesc) {
75
- lines.push(` ${' '.repeat(idWidth)} ${dim(taskDesc)}`)
76
- }
77
- if (t.affected !== undefined) {
78
- lines.push(` ${' '.repeat(idWidth)} ${dim(`affected: ${describeReason(t.affected)}`)}`)
79
- }
51
+ rows.push({
52
+ node: t.node,
53
+ ...(skipped ? NOOP : LOOK[t.cacheStatus]),
54
+ ...(executes && t.p50Ms !== undefined ? { usualMs: t.p50Ms } : {}),
55
+ ...(t.cacheStatus === 'hit-remote' && t.remoteBytes !== undefined
56
+ ? { outputBytes: t.remoteBytes }
57
+ : {}),
58
+ blocks: blocks.get(t.node.id) ?? 0,
59
+ // Placement by executor name; a task that will not run reaches no
60
+ // machine, as a skip's row in the run has none.
61
+ worker:
62
+ skipped || t.cacheStatus === 'skipped' || t.cacheStatus === 'not-started'
63
+ ? ''
64
+ : (t.executor ?? 'local'),
65
+ note: notes.join(' · '),
66
+ })
80
67
  if (skipped) noop++
81
68
  else if (t.cacheStatus === 'hit-local') local++
82
69
  else if (t.cacheStatus === 'hit-remote') remote++
@@ -94,15 +81,14 @@ export function formatPlanText(plan: RunPlan, c: ColorSupport = { enabled: false
94
81
  hitParts.push(bytes > 0 ? `${remote} remote, ${formatBytes(bytes)}` : `${remote} remote`)
95
82
  }
96
83
  const hits = local + remote
97
- const summary: string[] = [`${real.length} task(s) planned`]
84
+ const summary: string[] = [`${real.length} planned`]
98
85
  if (hits > 0) summary.push(`${hits} cache hit${hits === 1 ? '' : 's'} (${hitParts.join(', ')})`)
99
86
  if (miss > 0) summary.push(`${miss} would run`)
100
87
  if (nocache > 0) summary.push(`${nocache} no-cache`)
101
88
  if (noop > 0) summary.push(`${noop} noop`)
102
89
  if (idle > 0) summary.push(`${idle} not started`)
103
90
  if (platform > 0) summary.push(`${platform} skipped`)
104
- lines.push('')
105
- lines.push(dim(summary.join(', ') + '.'))
91
+ const facts: ui.FieldRow[] = [['tasks', summary.join(' · ')]]
106
92
 
107
93
  // `--cache-download` is invisible without this: the eligibility gate silently
108
94
  // keeps producers eager, so a user who asked for `none` and got no
@@ -111,16 +97,13 @@ export function formatPlanText(plan: RunPlan, c: ColorSupport = { enabled: false
111
97
  const deferred = real.filter((t) => t.download === 'deferred')
112
98
  const downgrades = plan.downloadDowngrades ?? []
113
99
  if (deferred.length > 0 || downgrades.length > 0) {
114
- lines.push('')
115
- lines.push(
116
- dim(
117
- `download: ${deferred.length} task(s) would keep outputs remote` +
118
- (downgrades.length > 0 ? `, ${downgrades.length} kept eager:` : '.'),
119
- ),
120
- )
121
- for (const d of downgrades.slice(0, 3))
122
- lines.push(` ${ui.inline(ui.id(d.taskId), c)} ${dim(`— ${d.reason}`)}`)
123
- if (downgrades.length > 3) lines.push(dim(` …and ${downgrades.length - 3} more`))
100
+ facts.push([
101
+ 'download',
102
+ `${deferred.length} task(s) would keep outputs remote` +
103
+ (downgrades.length > 0 ? `, ${downgrades.length} kept eager:` : '.'),
104
+ ...downgrades.slice(0, 3).map((d) => [ui.id(d.taskId), ui.dim(` — ${d.reason}`)]),
105
+ ...(downgrades.length > 3 ? [`…and ${downgrades.length - 3} more`] : []),
106
+ ])
124
107
  }
125
108
 
126
109
  // Time prediction — only when history gave us something to say: at least
@@ -128,15 +111,63 @@ export function formatPlanText(plan: RunPlan, c: ColorSupport = { enabled: false
128
111
  const p = plan.predicted
129
112
  const executes = miss + nocache
130
113
  if (p !== undefined && executes > 0 && executes > p.unknownCount) {
131
- const parts = [`predicted: ~${formatElapsed(p.wallMs)} wall`]
114
+ const parts = [`~${formatElapsed(p.wallMs)} wall`]
132
115
  parts.push(`~${formatElapsed(p.workMs)} total execution`)
133
116
  if (p.unknownCount > 0) {
134
117
  parts.push(`${p.unknownCount} task${p.unknownCount === 1 ? '' : 's'} without history (+?)`)
135
118
  }
136
- lines.push(dim(parts.join(' · ')))
119
+ facts.push(['predicted', parts.join(' · ')])
137
120
  }
138
- return lines.join('\n') + '\n'
121
+ return ui.section('Plan', String(real.length), planList(rows), ui.fields(facts))
122
+ }
123
+
124
+ /**
125
+ * How many tasks are downstream of each, all of them (groups too), by the
126
+ * run's own count: a closure walked per task was quadratic, and a 10,000
127
+ * task chain's `--dry` spent 7 s here.
128
+ */
129
+ function downstream(plan: RunPlan): Map<string, number> {
130
+ return computeReverseDepCount(
131
+ new Map(plan.tasks.map((t) => [t.node.id, { ...t.node, deps: [...t.deps] }])),
132
+ )
133
+ }
134
+
135
+ /** The plan as plain text: what a pipe, a test or `grep` reads. */
136
+ export function formatPlanText(plan: RunPlan, c: ColorSupport = { enabled: false }): string {
137
+ return ui.render([planReport(plan)], c)
138
+ }
139
+
140
+ type Look = Pick<PlannedRow, 'glyph' | 'status' | 'tone' | 'cache' | 'cacheTone'>
141
+
142
+ /** Each outcome on the run's grid: its glyph and words, as the run would print them. */
143
+ const LOOK: Record<CacheStatus, Look> = {
144
+ 'hit-local': {
145
+ glyph: '\u21e2',
146
+ status: 'cached',
147
+ tone: 'ok',
148
+ cache: 'local',
149
+ cacheTone: 'local',
150
+ },
151
+ 'hit-remote': {
152
+ glyph: '\u21e3',
153
+ status: 'cached',
154
+ tone: 'ok',
155
+ cache: 'remote',
156
+ cacheTone: 'remote',
157
+ },
158
+ miss: { glyph: '\u23fa\ufe0e', status: 'run', tone: 'info', cache: 'miss', cacheTone: 'muted' },
159
+ 'no-cache': {
160
+ glyph: '\u23fa\ufe0e',
161
+ status: 'run',
162
+ tone: 'info',
163
+ cache: 'no-cache',
164
+ cacheTone: 'muted',
165
+ },
166
+ group: { glyph: '\u25cb', status: 'group', tone: 'muted', cache: '', cacheTone: '' },
167
+ 'not-started': { glyph: '\u25cc', status: 'not run', tone: 'muted', cache: '', cacheTone: '' },
168
+ skipped: { glyph: '\u2298', status: 'skipped', tone: 'warn', cache: '', cacheTone: '' },
139
169
  }
170
+ const NOOP: Look = { glyph: '\u00b7', status: 'noop', tone: 'muted', cache: '', cacheTone: '' }
140
171
 
141
172
  /** One `--affected --dry` reason line: the cause, then the edge chain that carried it. */
142
173
  function describeReason(r: AffectedReason): string {
@@ -219,52 +250,3 @@ export function formatPlanJson(
219
250
  }) + '\n'
220
251
  )
221
252
  }
222
-
223
- /** A hit is green, a task that would run is yellow, the rest dim. */
224
- const HUE: Record<CacheStatus, string> = {
225
- 'hit-local': SUCCESS,
226
- 'hit-remote': SUCCESS,
227
- miss: WARN,
228
- 'no-cache': WARN,
229
- group: '',
230
- 'not-started': '',
231
- skipped: '',
232
- }
233
-
234
- function symbolFor(s: CacheStatus): string {
235
- switch (s) {
236
- case 'hit-local':
237
- return '✓'
238
- case 'hit-remote':
239
- return '↓'
240
- case 'miss':
241
- return '▶'
242
- case 'no-cache':
243
- return '∅'
244
- case 'group':
245
- return '○'
246
- case 'not-started':
247
- return '−'
248
- case 'skipped':
249
- return '·'
250
- }
251
- }
252
-
253
- function describe(s: CacheStatus): string {
254
- switch (s) {
255
- case 'hit-local':
256
- return 'cache hit (local)'
257
- case 'hit-remote':
258
- return 'cache hit (remote)'
259
- case 'miss':
260
- return 'cache miss — would exec'
261
- case 'no-cache':
262
- return 'no-cache (would exec)'
263
- case 'group':
264
- return 'group task'
265
- case 'not-started':
266
- return 'not started (unneeded)'
267
- case 'skipped':
268
- return 'skipped'
269
- }
270
- }
@@ -24,9 +24,9 @@ import {
24
24
  maskedLine,
25
25
  SecretBroker,
26
26
  thrownMessage,
27
- ui,
28
27
  UserError,
29
28
  } from '../util/index.js'
29
+ import * as ui from '../ui/index.js'
30
30
  import { resolveSetting } from '../settings.js'
31
31
 
32
32
  export interface ResolvedPluginCommand {
@@ -284,7 +284,12 @@ async function openVerbRemotes(
284
284
  await open(remotes, ci, new SecretBroker(ci, warn))
285
285
  }
286
286
 
287
- function lazyArtifacts(open: () => Promise<TaskArtifacts>): TaskArtifacts {
287
+ /**
288
+ * `Required`, so a member `TaskArtifacts` gains cannot be left out here:
289
+ * this wrapper dropped `uploaded`, and `vx dte worker` took every upload a
290
+ * remote reset as landed (CI, 2026-10-29).
291
+ */
292
+ function lazyArtifacts(open: () => Promise<TaskArtifacts>): Required<TaskArtifacts> {
288
293
  let opened: Promise<TaskArtifacts> | undefined
289
294
  const artifacts = (): Promise<TaskArtifacts> => (opened ??= open())
290
295
  return {
@@ -293,6 +298,7 @@ function lazyArtifacts(open: () => Promise<TaskArtifacts>): TaskArtifacts {
293
298
  output: async (key) => (await artifacts()).output(key),
294
299
  share: async (key) => (await artifacts()).share(key),
295
300
  resend: async (key) => (await artifacts()).resend(key),
301
+ uploaded: async (key) => (await artifacts()).uploaded?.(key),
296
302
  }
297
303
  }
298
304