@vzn/vx 0.0.645 → 0.0.648

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 (186) hide show
  1. package/README.md +39 -50
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +127 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +19 -3
  12. package/src/bin.ts +54 -13
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +330 -65
  15. package/src/cache/git-inputs.ts +274 -97
  16. package/src/cache/index.ts +7 -2
  17. package/src/cache/inputs.ts +515 -83
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +74 -11
  20. package/src/cache/layered-cache.ts +154 -59
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +44 -14
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +100 -15
  26. package/src/cache/run-history.ts +77 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +42 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/core-alias.ts +5 -2
  34. package/src/cli/docs.ts +42 -16
  35. package/src/cli/help.ts +62 -25
  36. package/src/cli/index.ts +79 -45
  37. package/src/cli/info.ts +12 -6
  38. package/src/cli/init.ts +222 -34
  39. package/src/cli/last.ts +443 -47
  40. package/src/cli/lock.ts +197 -52
  41. package/src/cli/plan-format.ts +91 -49
  42. package/src/cli/plugin-commands.ts +160 -11
  43. package/src/cli/plugin-templates.ts +13 -13
  44. package/src/cli/registry.ts +209 -0
  45. package/src/cli/run.ts +111 -41
  46. package/src/cli/select.ts +148 -49
  47. package/src/cli/show.ts +59 -16
  48. package/src/cli/task-verb.ts +16 -6
  49. package/src/cli/upgrade.ts +155 -54
  50. package/src/cli/watch-cycle.ts +10 -5
  51. package/src/cli/watch-filter.ts +71 -3
  52. package/src/cli/watch-fs.ts +22 -6
  53. package/src/cli/watch-judge.ts +48 -6
  54. package/src/cli/watch.ts +163 -42
  55. package/src/cli/why.ts +211 -73
  56. package/src/cli/workspace-config.ts +54 -15
  57. package/src/config.ts +13 -1
  58. package/src/exec/env.ts +7 -2
  59. package/src/exec/executor.ts +262 -15
  60. package/src/exec/index.ts +8 -2
  61. package/src/exec/kill-tree.ts +15 -0
  62. package/src/exec/local-executor.ts +1 -1
  63. package/src/exec/proc-sample.ts +70 -6
  64. package/src/exec/runner.ts +256 -53
  65. package/src/exec/sandbox-binds.ts +7 -3
  66. package/src/exec/sandbox-paths.ts +1 -1
  67. package/src/exec/sandbox-runtime.ts +222 -28
  68. package/src/exec/sandbox-violations.ts +52 -20
  69. package/src/graph/critical-path.ts +79 -0
  70. package/src/graph/default-build.ts +19 -0
  71. package/src/graph/definition.ts +38 -0
  72. package/src/graph/folds.ts +38 -5
  73. package/src/graph/index.ts +4 -1
  74. package/src/graph/priorities.ts +74 -8
  75. package/src/graph/scheduler.ts +326 -65
  76. package/src/graph/task-graph.ts +228 -35
  77. package/src/handoff.ts +97 -9
  78. package/src/history/failure-mode.ts +6 -3
  79. package/src/history/history.ts +268 -50
  80. package/src/history/index.ts +2 -0
  81. package/src/history/metrics.ts +142 -45
  82. package/src/index.ts +42 -10
  83. package/src/orchestrator/admission.ts +10 -6
  84. package/src/orchestrator/affected-tasks.ts +11 -6
  85. package/src/orchestrator/deferred-outputs.ts +2 -0
  86. package/src/orchestrator/doctor.ts +134 -42
  87. package/src/orchestrator/execute-args.ts +6 -0
  88. package/src/orchestrator/execute-task.ts +155 -42
  89. package/src/orchestrator/fingerprint-watch.ts +1 -1
  90. package/src/orchestrator/graph-memo.ts +140 -0
  91. package/src/orchestrator/graph-stage.ts +18 -1
  92. package/src/orchestrator/held-servers.ts +86 -0
  93. package/src/orchestrator/hit-restore.ts +227 -122
  94. package/src/orchestrator/index.ts +20 -3
  95. package/src/orchestrator/local-floor.ts +198 -0
  96. package/src/orchestrator/local-shortcircuit.ts +51 -8
  97. package/src/orchestrator/lockfile-claim.ts +96 -16
  98. package/src/orchestrator/miss-save.ts +52 -12
  99. package/src/orchestrator/options.ts +8 -6
  100. package/src/orchestrator/output-host.ts +43 -14
  101. package/src/orchestrator/output-log.ts +90 -10
  102. package/src/orchestrator/persistent-task.ts +97 -28
  103. package/src/orchestrator/persistent.ts +16 -4
  104. package/src/orchestrator/placement.ts +257 -14
  105. package/src/orchestrator/plan.ts +64 -7
  106. package/src/orchestrator/plugin-host.ts +230 -40
  107. package/src/orchestrator/plugin.ts +150 -37
  108. package/src/orchestrator/prepare.ts +178 -32
  109. package/src/orchestrator/projects.ts +65 -40
  110. package/src/orchestrator/remote-prefetch.ts +119 -25
  111. package/src/orchestrator/run-artifacts.ts +95 -32
  112. package/src/orchestrator/run-context.ts +39 -41
  113. package/src/orchestrator/run-failures.ts +65 -23
  114. package/src/orchestrator/run-id.ts +6 -3
  115. package/src/orchestrator/run-keys.ts +300 -0
  116. package/src/orchestrator/run-live.ts +294 -0
  117. package/src/orchestrator/run-lock.ts +13 -7
  118. package/src/orchestrator/run-records.ts +35 -23
  119. package/src/orchestrator/run-report.ts +3 -3
  120. package/src/orchestrator/run-steps.ts +58 -22
  121. package/src/orchestrator/run-stop.ts +22 -5
  122. package/src/orchestrator/run.ts +409 -79
  123. package/src/orchestrator/sandbox-fix.ts +7 -5
  124. package/src/orchestrator/sandbox-request.ts +78 -22
  125. package/src/orchestrator/shell-verdict.ts +5 -5
  126. package/src/orchestrator/signals.ts +26 -1
  127. package/src/orchestrator/stable-keys.ts +92 -8
  128. package/src/orchestrator/steps.ts +2 -2
  129. package/src/orchestrator/task-artifacts.ts +206 -0
  130. package/src/orchestrator/task-hash.ts +89 -14
  131. package/src/orchestrator/task-log-buffer.ts +72 -36
  132. package/src/orchestrator/task-process.ts +22 -0
  133. package/src/orchestrator/telemetry-host.ts +24 -3
  134. package/src/orchestrator/telemetry.ts +113 -38
  135. package/src/orchestrator/upstream.ts +15 -0
  136. package/src/report/events.ts +32 -13
  137. package/src/report/forecast.ts +36 -35
  138. package/src/report/framed-output.ts +173 -46
  139. package/src/report/index.ts +1 -0
  140. package/src/report/logger.ts +217 -186
  141. package/src/report/path-links.ts +12 -4
  142. package/src/report/plain-output.ts +13 -5
  143. package/src/report/status-line.ts +94 -46
  144. package/src/report/summary.ts +174 -38
  145. package/src/report/terminal-signals.ts +13 -2
  146. package/src/settings.ts +14 -6
  147. package/src/util/bun-version.ts +4 -1
  148. package/src/util/colors.ts +20 -3
  149. package/src/util/config-frame.ts +41 -4
  150. package/src/{graph → util}/dependency-spec.ts +22 -13
  151. package/src/util/edit-distance.ts +13 -1
  152. package/src/util/env-flag.ts +9 -0
  153. package/src/util/errors.ts +59 -3
  154. package/src/util/essential-env.ts +57 -0
  155. package/src/util/git-ignored.ts +1 -1
  156. package/src/util/hash.ts +35 -0
  157. package/src/util/hide-env.ts +45 -5
  158. package/src/util/index.ts +22 -5
  159. package/src/util/num.ts +8 -0
  160. package/src/util/paths.ts +121 -21
  161. package/src/util/report.ts +6 -1
  162. package/src/util/secret-mask.ts +62 -1
  163. package/src/util/secrets.ts +7 -2
  164. package/src/util/size.ts +11 -3
  165. package/src/util/tail.ts +6 -1
  166. package/src/util/timing.ts +13 -1
  167. package/src/util/ui.ts +65 -4
  168. package/src/util/which.ts +1 -1
  169. package/src/util/write-file.ts +67 -0
  170. package/src/workspace/affected.ts +710 -107
  171. package/src/workspace/bracket-literal.ts +84 -0
  172. package/src/workspace/canonical-dir.ts +37 -0
  173. package/src/workspace/config-bytecode.ts +282 -0
  174. package/src/workspace/config-eval.ts +29 -8
  175. package/src/workspace/config-imports.ts +199 -58
  176. package/src/workspace/config-schema.ts +242 -39
  177. package/src/workspace/filter.ts +37 -8
  178. package/src/workspace/index.ts +11 -2
  179. package/src/workspace/json-data.ts +3 -4
  180. package/src/workspace/lockfile.ts +57 -8
  181. package/src/workspace/migration.ts +72 -49
  182. package/src/workspace/package-graph.ts +5 -4
  183. package/src/workspace/project-loader.ts +314 -47
  184. package/src/workspace/repo-id.ts +1 -1
  185. package/src/workspace/workspace.ts +176 -33
  186. package/src/cli/foreign-flags.ts +0 -727
@@ -13,8 +13,10 @@ import {
13
13
  formatBytes,
14
14
  formatElapsed,
15
15
  gradientRule,
16
+ identityColor,
16
17
  paint,
17
18
  SUCCESS,
19
+ ui,
18
20
  WARN,
19
21
  type ColorSupport,
20
22
  } from '../util/index.js'
@@ -27,6 +29,7 @@ const LOCAL = '#38bdf8' // sky-400
27
29
  const REMOTE = '#2563eb' // blue-600
28
30
  const ABORTED = '#f97316' // orange-500: red's neighbour, not a failure
29
31
  const NOT_RUN = '#a855f7' // purple-500: stopped before it began, apart from every other state
32
+ const RUNNING = '#06b6d4' // cyan-500: ui's info
30
33
  const ACCENT = '#06b6d4' // cyan-500: the live region's running word
31
34
 
32
35
  /**
@@ -85,6 +88,11 @@ export interface SummaryStats {
85
88
  aborted?: number
86
89
  /** Reached by the stop before they ran. Not in `total`. */
87
90
  notRun?: number
91
+ /** Live: running now, and handed to an executor whose machine has not begun them. */
92
+ running?: number
93
+ scheduled?: number
94
+ /** Live: not started because a dependency has not finished. */
95
+ blocked?: number
88
96
  total: number
89
97
  upToDate: number
90
98
  restoredLocal: number
@@ -106,10 +114,12 @@ export interface SummaryStats {
106
114
  held?: { count: number; sumMs: number }
107
115
  /** Live section only: the forecast of what is left, from history. */
108
116
  etaMs?: number
117
+ /** Live section only: how long the run has gone past that forecast. */
118
+ overMs?: number
109
119
  /** A run of groups only: their names, so `0 tasks` says why. */
110
120
  emptyGroups?: readonly string[]
111
- /** Each finished task's cache bucket by its key and its id (its run bytes), so its remote traffic can move it. */
112
- buckets?: ReadonlyMap<string, CacheBucket>
121
+ /** Each finished task's id and cache bucket by its key and its id (its run bytes), so its remote traffic can move it. */
122
+ buckets?: ReadonlyMap<string, TaskBucket>
113
123
  }
114
124
 
115
125
  /** The cache bar segment a finished task fills before its remote traffic is weighed. */
@@ -120,13 +130,26 @@ export type CacheBucket =
120
130
  | 'restoredLocal'
121
131
  | 'restoredRemote'
122
132
  | 'saved'
133
+ | 'notSaved'
134
+
135
+ /** A finished task's id and cache bucket, as `SummaryStats.buckets` holds them. */
136
+ export interface TaskBucket {
137
+ task: string
138
+ bucket: CacheBucket
139
+ }
123
140
 
124
141
  /** A finished task's own cache bucket; none for a group, a skip or an abort. */
125
142
  export function cacheBucketOf(o: TaskOutcome): CacheBucket | undefined {
126
143
  if (isGroupTask(o.node)) return undefined
127
144
  switch (o.status) {
128
145
  case 'success':
129
- return ranNoCache(o) ? 'noCache' : o.saved === true ? 'saved' : 'miss'
146
+ return ranNoCache(o)
147
+ ? 'noCache'
148
+ : o.saved === true
149
+ ? 'saved'
150
+ : ranUnsaved(o)
151
+ ? 'notSaved'
152
+ : 'miss'
130
153
  case 'failed':
131
154
  return ranNoCache(o) ? 'noCache' : 'miss'
132
155
  case 'cache-hit':
@@ -138,10 +161,12 @@ export function cacheBucketOf(o: TaskOutcome): CacheBucket | undefined {
138
161
  }
139
162
  }
140
163
 
141
- type Buckets = Record<CacheBucket | 'uploaded' | 'uploading' | 'remoteFailed', number>
164
+ type RemoteState = 'uploaded' | 'uploading' | 'remoteFailed'
165
+ const REMOTE_RANK: Record<RemoteState, number> = { uploaded: 0, uploading: 1, remoteFailed: 2 }
166
+ type Buckets = Record<CacheBucket | RemoteState, number>
142
167
 
143
168
  /**
144
- * The cache bar's segments, each task in one (M, 2026-10-15). With layers
169
+ * The cache bar's segments, each task in one (2026-10-15). With layers
145
170
  * named, a saved task leaves the misses, and a task with remote traffic
146
171
  * leaves its own bucket for its strongest remote state: a failed read or
147
172
  * write, then an upload still running, then one that landed. Without, the
@@ -149,8 +174,12 @@ type Buckets = Record<CacheBucket | 'uploaded' | 'uploading' | 'remoteFailed', n
149
174
  */
150
175
  function cacheBuckets(stats: SummaryStats, layers: RunContext['cache']): Buckets {
151
176
  const saved = layers === undefined ? 0 : (stats.saved ?? 0)
177
+ // Its own yellow segment, as its legend count is: in the misses' red it
178
+ // was a cell with no count under it (2026-10-21).
179
+ const notSaved = layers === undefined ? 0 : (stats.notSaved ?? 0)
152
180
  const b: Buckets = {
153
- miss: stats.miss - saved,
181
+ miss: stats.miss - saved - notSaved,
182
+ notSaved,
154
183
  noCache: stats.noCache ?? 0,
155
184
  upToDate: stats.upToDate,
156
185
  restoredLocal: stats.restoredLocal,
@@ -161,13 +190,20 @@ function cacheBuckets(stats: SummaryStats, layers: RunContext['cache']): Buckets
161
190
  remoteFailed: 0,
162
191
  }
163
192
  if (layers?.remote !== true || stats.buckets === undefined) return b
164
- for (const [hash, k] of layers.traffic.keys) {
165
- const from = stats.buckets.get(hash)
193
+ // A task's traffic may sit under its key (its entry) and its id (its run
194
+ // bytes): it moves once, to the strongest state of the two.
195
+ const moved = new Map<string, RemoteState>()
196
+ for (const [key, k] of layers.traffic.keys) {
197
+ const from = stats.buckets.get(key)
166
198
  // Still running: it counts once it ends.
167
199
  if (from === undefined) continue
168
- b[from]--
169
- b[k.failures.length > 0 ? 'remoteFailed' : k.uploading > 0 ? 'uploading' : 'uploaded']++
200
+ const state: RemoteState =
201
+ k.failures.length > 0 ? 'remoteFailed' : k.uploading > 0 ? 'uploading' : 'uploaded'
202
+ const prior = moved.get(from.task)
203
+ if (prior === undefined) b[from.bucket]--
204
+ if (prior === undefined || REMOTE_RANK[state] > REMOTE_RANK[prior]) moved.set(from.task, state)
170
205
  }
206
+ for (const state of moved.values()) b[state]++
171
207
  return b
172
208
  }
173
209
 
@@ -183,6 +219,10 @@ export interface RunContext {
183
219
  packageCount: number
184
220
  /** The run's whole id, as `vx last` takes it; shown on the `info` row. */
185
221
  runId?: string | undefined
222
+ /** The executors' own footer rows (`TaskExecutor.footer`), above the `info` row. */
223
+ executorRows?: () => readonly (readonly [string, ui.Inline])[]
224
+ /** Where a running task runs, `null` while it waits for a machine (`TaskExecutor.running`): its live row. */
225
+ whereRunning?: (taskId: string) => string | null | undefined
186
226
  /** Worker-pool size for this run; shown on the `info` row. */
187
227
  concurrency?: number
188
228
  remoteCacheEnabled: boolean
@@ -212,6 +252,8 @@ export interface RunContext {
212
252
  }
213
253
  /** Total projects discovery found — the bar's denominator. */
214
254
  workspaceProjectCount?: number
255
+ /** The run's warnings no task owns, as they were said: the footer's `warn` row. */
256
+ warnings?: () => readonly string[]
215
257
  }
216
258
 
217
259
  export function formatSummarySection(
@@ -226,7 +268,15 @@ export function formatSummarySection(
226
268
  const left = stats.left ?? 0
227
269
  const aborted = stats.aborted ?? 0
228
270
  const notRun = stats.notRun ?? 0
229
- const remainder = { n: left, color: '', glyph: '\u25b1', dim: true }
271
+ const running = stats.running ?? 0
272
+ const scheduled = stats.scheduled ?? 0
273
+ const blocked = stats.blocked ?? 0
274
+ const remainder = {
275
+ n: Math.max(0, left - running - scheduled - blocked),
276
+ color: '',
277
+ glyph: '\u25b1',
278
+ dim: true,
279
+ }
230
280
 
231
281
  const dim = (txt: string) => paint('', txt, colors, { dim: true })
232
282
  const row = (label: string, value: string): string => ` ${dim(label.padEnd(8))} ${value}`
@@ -234,7 +284,7 @@ export function formatSummarySection(
234
284
 
235
285
  // Tasks meter + numbers, fixed order: failed · success · skipped ·
236
286
  // aborted · not run, then a dim total, last on its line. Every task in
237
- // the run counts, each state its own segment (M, 2026-10-14).
287
+ // the run counts, each state its own segment (2026-10-14).
238
288
  const runTotal = stats.total + aborted + notRun
239
289
  const taskParts: string[] = []
240
290
  if (stats.failed > 0)
@@ -243,6 +293,11 @@ export function formatSummarySection(
243
293
  if (stats.skipped > 0) taskParts.push(paint(WARN, `${stats.skipped} skipped`, colors))
244
294
  if (aborted > 0) taskParts.push(paint(ABORTED, `${aborted} aborted`, colors))
245
295
  if (notRun > 0) taskParts.push(paint(NOT_RUN, `${notRun} not run`, colors))
296
+ if (running > 0) taskParts.push(paint(RUNNING, `${running} running`, colors))
297
+ if (scheduled > 0) taskParts.push(dim(`${scheduled} scheduled`))
298
+ if (blocked > 0) taskParts.push(dim(`${blocked} blocked`))
299
+ // What is not done yet, running included: the count the bar's unfilled part shows.
300
+ if (left > 0) taskParts.push(dim(`${left} left`))
246
301
  if (runTotal > 0) taskParts.push(dim(`${runTotal} total`))
247
302
  const taskBar = segmentBar(
248
303
  [
@@ -251,6 +306,9 @@ export function formatSummarySection(
251
306
  { n: stats.skipped, color: WARN },
252
307
  { n: aborted, color: ABORTED },
253
308
  { n: notRun, color: NOT_RUN },
309
+ { n: running, color: RUNNING },
310
+ { n: scheduled, color: '', dim: true },
311
+ { n: blocked, color: '', dim: true },
254
312
  remainder,
255
313
  ],
256
314
  colors,
@@ -271,10 +329,10 @@ export function formatSummarySection(
271
329
  const wsTotal = context.workspaceProjectCount
272
330
  if (wsTotal !== undefined && wsTotal > 0) {
273
331
  const width = barWidth(colors)
274
- const cells = Math.min(
275
- width,
276
- Math.max(1, Math.round((context.packageCount / wsTotal) * width)),
277
- )
332
+ const cells =
333
+ context.packageCount === 0
334
+ ? 0
335
+ : Math.min(width, Math.max(1, Math.round((context.packageCount / wsTotal) * width)))
278
336
  const bar =
279
337
  paint(WARN, '▰'.repeat(cells), colors) +
280
338
  paint('', '▱'.repeat(width - cells), colors, { dim: true })
@@ -310,6 +368,7 @@ export function formatSummarySection(
310
368
  const cacheBar = segmentBar(
311
369
  [
312
370
  { n: b.miss, color: ERROR },
371
+ { n: b.notSaved, color: WARN },
313
372
  { n: b.noCache, color: '', dim: true },
314
373
  { n: b.upToDate, color: SUCCESS },
315
374
  { n: b.restoredLocal, color: LOCAL },
@@ -325,7 +384,7 @@ export function formatSummarySection(
325
384
  )
326
385
  const cacheParts: string[] = []
327
386
  if (layers !== undefined) {
328
- // Per layer (M, 2026-10-15): `✓` fresh (outputs already on disk, from
387
+ // Per layer (2026-10-15): `✓` fresh (outputs already on disk, from
329
388
  // either layer) and `∅` no cache, both local; `↓` restored from a
330
389
  // layer, `↑` saved to it; `✗` a remote read or write failed, `⇡` still
331
390
  // uploading. Each count is its bar segment's, glyph and number in its colour.
@@ -337,7 +396,7 @@ export function formatSummarySection(
337
396
  ...(layers.local ? n(b.restoredLocal, '\u2193', LOCAL) : []),
338
397
  ...(layers.localWrite ? n(b.saved, '\u2191', LOCAL) : []),
339
398
  ]
340
- // The layer words are dim; only the counts carry colour (M, 2026-10-15).
399
+ // The layer words are dim; only the counts carry colour (2026-10-15).
341
400
  if (local.length > 0) cacheParts.push(`${dim('local')} ${local.join(' ')}`)
342
401
  // Each transfer count is followed by its bytes, dim: what the
343
402
  // remote moved is the cost a hit or an upload paid.
@@ -355,8 +414,8 @@ export function formatSummarySection(
355
414
  ]
356
415
  if (layers.remote && remote.length > 0)
357
416
  cacheParts.push(`${dim('remote')} ${remote.join(' ')}`)
358
- if ((stats.notSaved ?? 0) > 0)
359
- cacheParts.push(paint(WARN, `${stats.notSaved} not saved`, colors))
417
+ if (b.miss > 0) cacheParts.push(paint(ERROR, `${b.miss} miss`, colors))
418
+ if (b.notSaved > 0) cacheParts.push(paint(WARN, `${b.notSaved} not saved`, colors))
360
419
  const notDownloaded = layers.notDownloaded()
361
420
  if (notDownloaded > 0) cacheParts.push(paint(WARN, `${notDownloaded} not downloaded`, colors))
362
421
  } else {
@@ -371,7 +430,7 @@ export function formatSummarySection(
371
430
  }
372
431
  // Skipped tasks ride the bar, yellow like the tasks meter, so the two
373
432
  // bars fill alike; their count is the tasks legend's alone (the footer
374
- // never says a fact twice, M, 2026-10-14).
433
+ // never says a fact twice, 2026-10-14).
375
434
  lines.push(row('cache', cacheBar))
376
435
  if (cacheParts.length > 0) lines.push(legend(cacheParts))
377
436
  }
@@ -379,7 +438,7 @@ export function formatSummarySection(
379
438
  // Cache-miss duration spread (owner: hits' restore times polluted
380
439
  // it — only tasks that actually executed count). Dim: context.
381
440
  // A value already on the row is not said again: one executed task read
382
- // as its own max, avg and min (M, 2026-10-14). Tasks that all took the
441
+ // as its own max, avg and min (2026-10-14). Tasks that all took the
383
442
  // same time have no spread to show.
384
443
  const total = formatElapsed(totalMs)
385
444
  const said = new Set([total])
@@ -405,15 +464,34 @@ export function formatSummarySection(
405
464
  // that is about the future.
406
465
  if (stats.etaMs !== undefined)
407
466
  timeParts.push(paint(ACCENT, `${formatEta(stats.etaMs)} left`, colors))
467
+ else if (stats.overMs !== undefined)
468
+ timeParts.push(paint(ACCENT, `${formatOver(stats.overMs)} over`, colors))
408
469
  for (const p of spreadParts) timeParts.push(dim(p))
409
470
  const timeRow = row('time', join(timeParts))
410
471
  // Run-shape footer (final summary only): worker pool + cache mode,
411
472
  // grouped with time under a blank line below the meters.
412
473
  if (context !== undefined) {
474
+ const own = context.executorRows?.() ?? []
475
+ if (own.length > 0)
476
+ lines.push('', ...own.map(([label, text]) => row(label, ui.inline(text, colors))))
413
477
  const info: string[] = []
414
- if (context.concurrency !== undefined)
478
+ // An executor that says where tasks ran says it better than the pool.
479
+ if (context.concurrency !== undefined && own.length === 0)
415
480
  info.push(`${context.concurrency} worker${context.concurrency === 1 ? '' : 's'}`)
416
- info.push(context.remoteCacheEnabled ? 'local + remote cache' : 'local cache')
481
+ const layers = context.cache
482
+ info.push(
483
+ layers === undefined
484
+ ? context.remoteCacheEnabled
485
+ ? 'local + remote cache'
486
+ : 'local cache'
487
+ : layers.local && layers.remote
488
+ ? 'local + remote cache'
489
+ : layers.local
490
+ ? 'local cache'
491
+ : layers.remote
492
+ ? 'remote cache'
493
+ : 'no cache',
494
+ )
417
495
  if (context.cacheScope !== undefined) info.push(`scope ${context.cacheScope}`)
418
496
  // The plugin's hand on the run: a task a policy held with a worker
419
497
  // free would otherwise have started; without the line nothing says
@@ -426,13 +504,33 @@ export function formatSummarySection(
426
504
  )
427
505
  }
428
506
  if (context.runId !== undefined) info.push(`run ${context.runId}`)
429
- lines.push('', row('info', join(info)), timeRow)
507
+ lines.push('', ...warnRows(context.warnings?.() ?? [], row, colors))
508
+ lines.push(row('info', join(info)), timeRow)
430
509
  } else {
431
510
  lines.push('', timeRow)
432
511
  }
433
512
  return lines
434
513
  }
435
514
 
515
+ /**
516
+ * A long run scrolls its warnings (a remote cache's 403, a plugin's
517
+ * failed flush) out of sight, and the footer read as a clean run
518
+ * (2026-10-27). The footer counts the distinct ones; it does not repeat
519
+ * them, so a warning stays one line in a log (a refused token costs
520
+ * ONE line, tests/remote-cache-degrade.test.ts in @vzn/vx-migrate).
521
+ */
522
+ function warnRows(
523
+ warnings: readonly string[],
524
+ row: (label: string, value: string) => string,
525
+ colors: ColorSupport,
526
+ ): string[] {
527
+ const n = new Set(
528
+ warnings.map((w) => Bun.stripANSI(w).split('\n')[0]!.trim()).filter((w) => w !== ''),
529
+ ).size
530
+ if (n === 0) return []
531
+ return [row('warn', paint(WARN, `${n} ${n === 1 ? 'warning' : 'warnings'} above`, colors))]
532
+ }
533
+
436
534
  /**
437
535
  * A forecast, said as coarsely as it is known: `~4s`, `~2m 05s`, `<1s`
438
536
  * once the history says the end is due.
@@ -444,16 +542,27 @@ function formatEta(ms: number): string {
444
542
  return `~${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
445
543
  }
446
544
 
545
+ function formatOver(ms: number): string {
546
+ const s = Math.max(1, Math.ceil(ms / 1000))
547
+ return s < 60 ? `+${s}s` : `+${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
548
+ }
549
+
447
550
  /**
448
551
  * The `--verbosity 1` per-task table (`RunOptions.summaryTable`). Groups are
449
- * left out as the footer and `--report` leave them: a group ran no command.
552
+ * left out as the footer leaves them: a group ran no command. Painted in the
553
+ * list's vocabulary: header bold violet, ids in their hues, status in its
554
+ * state colour, rule and durations dim.
450
555
  */
451
- export function formatOutcomeTable(outcomes: readonly TaskOutcome[]): string[] {
556
+ export function formatOutcomeTable(
557
+ outcomes: readonly TaskOutcome[],
558
+ colors: ColorSupport = NO_COLOR,
559
+ ): string[] {
452
560
  const rows = outcomes
453
561
  .filter((o) => !isGroupTask(o.node))
454
562
  .map((o) => {
455
563
  const v = projectOutcome(o)
456
- return { task: v.taskId, status: outcomeLabel(v), duration: `${v.durationMs}ms` }
564
+ const status = neverStarted(o) ? 'not run' : outcomeLabel(v)
565
+ return { o, task: v.taskId, status, duration: `${v.durationMs}ms` }
457
566
  })
458
567
  if (rows.length === 0) return []
459
568
  const widths = {
@@ -461,17 +570,43 @@ export function formatOutcomeTable(outcomes: readonly TaskOutcome[]): string[] {
461
570
  status: Math.max(6, ...rows.map((r) => r.status.length)),
462
571
  duration: Math.max(8, ...rows.map((r) => r.duration.length)),
463
572
  }
464
- const line = (task: string, status: string, duration: string) =>
465
- `${task.padEnd(widths.task)} ${status.padEnd(widths.status)} ${duration.padStart(widths.duration)}`
466
- const header = line('TASK', 'STATUS', 'DURATION')
573
+ const pad = (text: string, width: number) => ' '.repeat(Math.max(0, width - text.length))
574
+ const header = `${'TASK'.padEnd(widths.task)} ${'STATUS'.padEnd(widths.status)} ${'DURATION'.padStart(widths.duration)}`
575
+ const dim = (t: string) => paint('', t, colors, { dim: true })
467
576
  return [
468
577
  '',
469
- header,
470
- '-'.repeat(header.length),
471
- ...rows.map((r) => line(r.task, r.status, r.duration)),
578
+ paint(TABLE_HEADER, header, colors, { bold: true }),
579
+ dim('-'.repeat(header.length)),
580
+ ...rows.map((r) => {
581
+ const { node } = r.o
582
+ const id =
583
+ node.projectName !== undefined && node.taskName !== undefined
584
+ ? paint(identityColor(node.projectName), node.projectName, colors) +
585
+ dim('#') +
586
+ paint(identityColor(node.taskName), node.taskName, colors)
587
+ : r.task
588
+ const status = paint(tableStatusColor(r.o), r.status, colors)
589
+ return `${id}${pad(r.task, widths.task)} ${status}${pad(r.status, widths.status)} ${pad(r.duration, widths.duration)}${dim(r.duration)}`
590
+ }),
472
591
  ]
473
592
  }
474
593
 
594
+ /** violet-400, as the task list's header. */
595
+ const TABLE_HEADER = '#a78bfa'
596
+
597
+ function tableStatusColor(o: TaskOutcome): string {
598
+ switch (o.status) {
599
+ case 'failed':
600
+ return ERROR
601
+ case 'skipped':
602
+ return WARN
603
+ case 'aborted':
604
+ return neverStarted(o) ? NOT_RUN : ABORTED
605
+ default:
606
+ return SUCCESS
607
+ }
608
+ }
609
+
475
610
  // Local byte formatter — the orchestrator can't import cli/format.ts
476
611
  // (module boundary), and the footer only needs one compact form.
477
612
  /** Ran here with a `cache` block, succeeded, and its result was not saved. */
@@ -544,13 +679,14 @@ export function formatRunSummary(
544
679
  }
545
680
 
546
681
  /** Each finished task's cache bucket by its key and by its id. */
547
- function bucketsOf(outcomes: readonly TaskOutcome[]): Map<string, CacheBucket> {
548
- const out = new Map<string, CacheBucket>()
682
+ function bucketsOf(outcomes: readonly TaskOutcome[]): Map<string, TaskBucket> {
683
+ const out = new Map<string, TaskBucket>()
549
684
  for (const o of outcomes) {
550
685
  const bucket = cacheBucketOf(o)
551
686
  if (bucket === undefined) continue
552
- if (o.hash !== undefined) out.set(o.hash, bucket)
553
- out.set(o.node.id, bucket)
687
+ const entry = { task: o.node.id, bucket }
688
+ if (o.hash !== undefined) out.set(o.hash, entry)
689
+ out.set(o.node.id, entry)
554
690
  }
555
691
  return out
556
692
  }
@@ -12,6 +12,7 @@ export interface TerminalSignals {
12
12
  export function terminalSignals(
13
13
  env: Readonly<Record<string, string | undefined>>,
14
14
  ): TerminalSignals {
15
+ if (inMultiplexer(env)) return { progress: false, notify: false }
15
16
  const program = env['TERM_PROGRAM']
16
17
  if (program === 'ghostty') return { progress: true, notify: true }
17
18
  // iTerm2 posts OSC 9 since 3.0 and draws OSC 9;4 since 3.6.
@@ -24,6 +25,17 @@ export function terminalSignals(
24
25
  return { progress: false, notify: false }
25
26
  }
26
27
 
28
+ /** Inside tmux or screen, which keep the outer terminal's variables (`WT_SESSION`, its `TERM_PROGRAM`). */
29
+ function inMultiplexer(env: Readonly<Record<string, string | undefined>>): boolean {
30
+ const program = env['TERM_PROGRAM']
31
+ return (
32
+ env['TMUX'] !== undefined ||
33
+ env['STY'] !== undefined ||
34
+ program === 'tmux' ||
35
+ program === 'screen'
36
+ )
37
+ }
38
+
27
39
  function atLeast(version: string | undefined, major: number, minor: number): boolean {
28
40
  const m = /^(\d+)\.(\d+)/.exec(version ?? '')
29
41
  if (m === null) return false
@@ -50,9 +62,8 @@ export function notifyEscape(text: string): string {
50
62
  * tmux and screen pass it through only when configured to.
51
63
  */
52
64
  export function terminalLinks(env: Readonly<Record<string, string | undefined>>): boolean {
53
- if (env['TMUX'] !== undefined || env['STY'] !== undefined) return false
65
+ if (inMultiplexer(env)) return false
54
66
  const program = env['TERM_PROGRAM']
55
- if (program === 'tmux' || program === 'screen') return false
56
67
  if (program === 'ghostty' || program === 'WezTerm' || program === 'vscode') return true
57
68
  if (program === 'iTerm.app') return atLeast(env['TERM_PROGRAM_VERSION'], 3, 1)
58
69
  if (env['WT_SESSION'] !== undefined || env['KITTY_WINDOW_ID'] !== undefined) return true
package/src/settings.ts CHANGED
@@ -15,8 +15,7 @@ import {
15
15
  export type SettingKind =
16
16
  | { readonly type: 'int'; readonly min: number }
17
17
  | { readonly type: 'concurrency' }
18
- // `zero`: 0 is a value ("wait for nothing not already done"), not a typo.
19
- | { readonly type: 'duration'; readonly timer?: true; readonly zero?: true }
18
+ | { readonly type: 'duration'; readonly timer?: true }
20
19
  // `off`: `false` is a value, 0 bytes (the thing the size bounds is off).
21
20
  | { readonly type: 'size'; readonly off?: true }
22
21
  | { readonly type: 'bool' }
@@ -48,7 +47,14 @@ export const SETTINGS: readonly Setting[] = [
48
47
  path: 'baseBranch',
49
48
  kind: { type: 'string' },
50
49
  default: "origin/HEAD's branch, then main, then master",
51
- doc: 'The default branch: a run on it has no cache scope and a bare `--affected` no base; elsewhere both use the merge base with `origin/<baseBranch>`.',
50
+ doc: 'The default branch: a run on it has no cache scope and a bare `--affected` no base; elsewhere both use the merge base with `origin/<baseBranch>`. Named alone: `main`, not `origin/main`.',
51
+ },
52
+ {
53
+ path: 'affectedBase',
54
+ kind: { type: 'string' },
55
+ default: "a plugin's base commit, else the merge base with `baseBranch`",
56
+ doc: "The commit a bare `--affected` compares with, on the base branch too. `--affected=<base>` beats it; it beats a plugin's base commit. Env only: a committed base would be stale on the next push.",
57
+ config: false,
52
58
  },
53
59
  {
54
60
  path: 'run.concurrency',
@@ -316,7 +322,7 @@ export function parseSettingValue(kind: SettingKind, raw: unknown): unknown {
316
322
  return parseConcurrency(typeof raw === 'number' ? String(raw) : raw)
317
323
  case 'duration': {
318
324
  const n = typeof raw === 'string' ? parseDuration(raw) : null
319
- if (n === null || (n === 0 && kind.zero !== true)) return null
325
+ if (n === null || n === 0) return null
320
326
  return kind.timer === true && n > MAX_TIMEOUT_MS ? null : n
321
327
  }
322
328
  case 'size':
@@ -352,10 +358,12 @@ export function settingRefusal(
352
358
  : typeof raw === 'string' && /^\d+$/.test(raw)
353
359
  ? Number(raw)
354
360
  : null
355
- if (n !== null && Number.isSafeInteger(n) && n > 0) {
361
+ const bare = n !== null && Number.isSafeInteger(n) && n > 0
362
+ // A bare number past the ceiling is past it in either unit it could mean.
363
+ const ms = bare ? n : typeof raw === 'string' ? parseDuration(raw) : null
364
+ if (bare && !(kind.timer === true && n > MAX_TIMEOUT_MS)) {
356
365
  return `${where} takes a duration with its unit, like '${n}ms' or '${Math.max(1, Math.round(n / 1000))}s' (got ${got})`
357
366
  }
358
- const ms = typeof raw === 'string' ? parseDuration(raw) : null
359
367
  if (kind.timer === true && ms !== null && ms > MAX_TIMEOUT_MS) {
360
368
  return (
361
369
  `${where} of ${got} exceeds the maximum timer delay (${MAX_TIMEOUT_MS} ms, ~24.8 days): ` +
@@ -20,8 +20,11 @@
20
20
  * refusing would be heavier than the measurement supports. What the
21
21
  * measurement supports is that a green exit can hide a truncated answer, and
22
22
  * that is what the warning says.
23
+ *
24
+ * 1.4.4: vx expands a brace range (`{1..3}`) as `Bun.Glob` does from 1.4.4
25
+ * on; an older `Bun.Glob` reads it as text, so a scan and a match disagree.
23
26
  */
24
- export const MIN_BUN = [1, 4, 0] as const
27
+ export const MIN_BUN = [1, 4, 4] as const
25
28
 
26
29
  /** True when `version` is older than {@link MIN_BUN}. */
27
30
  export function isUnsupportedBun(version: string): boolean {
@@ -34,6 +34,7 @@ export interface PaintOptions {
34
34
  * NO_COLOR non-empty → off (overrides FORCE_COLOR)
35
35
  * FORCE_COLOR=0 or =false → off, even on a TTY
36
36
  * FORCE_COLOR any other value → on (the empty string too)
37
+ * a CI whose log renders ANSI (`ciPaints`) → on
37
38
  * else: on iff stdout is a TTY that is not `TERM=dumb`.
38
39
  * `0` and `false` are the convention's "off" (supports-color, chalk,
39
40
  * Node); reading every non-empty value as on painted a piped CI log that
@@ -45,7 +46,7 @@ export function detectColors(stream: NodeJS.WriteStream = process.stdout): Color
45
46
  ? false
46
47
  : force !== undefined
47
48
  ? force !== '0' && force !== 'false'
48
- : stream.isTTY === true && !dumbTerminal()
49
+ : ciPaints() || (stream.isTTY === true && !dumbTerminal())
49
50
  return {
50
51
  enabled,
51
52
  get columns() {
@@ -54,6 +55,20 @@ export function detectColors(stream: NodeJS.WriteStream = process.stdout): Color
54
55
  }
55
56
  }
56
57
 
58
+ /**
59
+ * A CI whose log viewer renders ANSI colour though the job's output is a
60
+ * pipe (supports-color reads the same variables): its log was plain.
61
+ */
62
+ function ciPaints(): boolean {
63
+ const env = process.env
64
+ return (
65
+ env['GITHUB_ACTIONS'] === 'true' ||
66
+ env['GITLAB_CI'] === 'true' ||
67
+ env['BUILDKITE'] === 'true' ||
68
+ env['CIRCLECI'] === 'true'
69
+ )
70
+ }
71
+
57
72
  /**
58
73
  * `TERM=dumb` (Emacs `M-x shell`, some CI log viewers) can neither paint
59
74
  * nor move the cursor: vx prints to it as off a terminal.
@@ -81,7 +96,9 @@ export function paint(
81
96
  colors: ColorSupport,
82
97
  opts: PaintOptions = {},
83
98
  ): string {
84
- if (!colors.enabled) return text
99
+ // Empty text paints nothing: an escape pair around no text is bytes and no
100
+ // mark (a full bar's empty remainder).
101
+ if (!colors.enabled || text === '') return text
85
102
  let prefix = ''
86
103
  if (opts.bold) prefix += BOLD
87
104
  if (opts.dim) prefix += DIM
@@ -92,7 +109,7 @@ export function paint(
92
109
 
93
110
  /**
94
111
  * Identity hues for the two halves of a `project#task` id, never a status
95
- * colour: one name, one hue, in every run, list and row (M, 2026-10-15:
112
+ * colour: one name, one hue, in every run, list and row (2026-10-15:
96
113
  * every `#build` matches every other `#build`).
97
114
  */
98
115
  const IDENTITY_PALETTE = [
@@ -18,7 +18,7 @@ const HEAD = /^(\/[^\n]*?\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s)(?:: (task
18
18
  */
19
19
  export function configErrorFrame(message: string, cwd: string): string | undefined {
20
20
  const head = HEAD.exec(message)
21
- if (head === null) return undefined
21
+ if (head === null) return positionFrame(message, cwd)
22
22
  const [, file, fieldPath, colon] = head as unknown as [string, string, string?, string?]
23
23
  const rest = message.slice(head[0].length)
24
24
  // The field the refusal is about, past the path that leads to it:
@@ -35,6 +35,37 @@ export function configErrorFrame(message: string, cwd: string): string | undefin
35
35
  }
36
36
  const at = locate(source, segments)
37
37
  if (at === undefined) return undefined
38
+ return framed(file, source, at, message.slice(file.length), cwd)
39
+ }
40
+
41
+ // The loader's `Project config <file>:<line>:<col>: …`: a syntax error,
42
+ // placed by Bun, in the config itself.
43
+ const POSITION =
44
+ /^(?:Project|Workspace) config (\/[^\n]*?\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s):(\d+):(\d+)(: [^]*)$/
45
+
46
+ function positionFrame(message: string, cwd: string): string | undefined {
47
+ const pos = POSITION.exec(message)
48
+ if (pos === null) return undefined
49
+ const [, file, line, col, rest] = pos as unknown as [string, string, string, string, string]
50
+ let source: string
51
+ try {
52
+ source = readFileSync(file, 'utf8')
53
+ } catch {
54
+ return undefined
55
+ }
56
+ const at = { line: Number(line), col: Math.max(1, Number(col)) }
57
+ if (at.line < 1 || at.line > source.split('\n').length) return undefined
58
+ return framed(file, source, at, rest, cwd)
59
+ }
60
+
61
+ /** `<file>:<line>:<col><rest>` relative to `cwd`, over the source lines up to `at`. */
62
+ function framed(
63
+ file: string,
64
+ source: string,
65
+ at: { line: number; col: number },
66
+ rest: string,
67
+ cwd: string,
68
+ ): string {
38
69
  const lines = source.split('\n')
39
70
  // Relative to where the user stands, unless that means climbing out.
40
71
  const rel = path.relative(cwd, file)
@@ -50,7 +81,7 @@ export function configErrorFrame(message: string, cwd: string): string | undefin
50
81
  .replace(/\t/g, ' ')
51
82
  .replace(/\S/g, ' ')
52
83
  frame.push(` ${' '.repeat(width)} | ${lead}^`)
53
- return `${shown}:${at.line}:${at.col}${message.slice(file.length)}\n\n${frame.join('\n')}`
84
+ return `${shown}:${at.line}:${at.col}${rest}\n\n${frame.join('\n')}`
54
85
  }
55
86
 
56
87
  /**
@@ -106,14 +137,20 @@ export function configRefusal(message: string, cwd: string): ConfigRefusal | und
106
137
  const cut = framed.indexOf('\n\n')
107
138
  const line = framed.slice(0, cut)
108
139
  const frame = framed.slice(cut + 2)
109
- const at = /^(\S+?:\d+:\d+):? (.*)$/s.exec(line)
140
+ // A path may hold spaces (`packages/my app/`); the first `:line:col` ends it.
141
+ const at = /^([^\n]+?:\d+:\d+):? (.*)$/s.exec(line)
110
142
  if (at === null) return { where: line, problem: '', frame }
111
- const said = /^(.*?)(?: \(allowed: ([^)]*)\))?(?: — did you mean ([^?]+)\?)?$/s.exec(at[2]!)!
143
+ // After ` — `: the field meant, or how vx spells another runner's field.
144
+ const said =
145
+ /^(.*?)(?: \(allowed: ([^)]*)\))?(?: — (?:did you mean ([^?]+)\?|vx spells it (.+)))?$/s.exec(
146
+ at[2]!,
147
+ )!
112
148
  return {
113
149
  where: at[1]!,
114
150
  problem: said[1]!.replace(/^has /, ''),
115
151
  ...(said[2] !== undefined ? { allowed: said[2] } : {}),
116
152
  ...(said[3] !== undefined ? { fix: `use ${said[3]}` } : {}),
153
+ ...(said[4] !== undefined ? { fix: `use ${said[4]}` } : {}),
117
154
  frame,
118
155
  }
119
156
  }