@vzn/vx 0.0.636 → 0.0.637

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 (141) hide show
  1. package/README.md +39 -11
  2. package/docs/caching.md +213 -207
  3. package/docs/cli.md +699 -655
  4. package/docs/execution.md +67 -75
  5. package/docs/features.md +44 -40
  6. package/docs/patterns.md +26 -25
  7. package/docs/schema.md +376 -198
  8. package/docs/security.md +76 -39
  9. package/launcher.sh +43 -0
  10. package/package.json +5 -5
  11. package/schemas/cache.json +41 -19
  12. package/schemas/info.json +23 -11
  13. package/schemas/last.json +48 -2
  14. package/schemas/lock.json +1 -1
  15. package/schemas/plan.json +2 -2
  16. package/schemas/show.json +5 -3
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +16 -12
  19. package/src/bin.ts +17 -2
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +105 -53
  22. package/src/cache/git-inputs.ts +277 -63
  23. package/src/cache/index.ts +7 -1
  24. package/src/cache/inputs.ts +125 -35
  25. package/src/cache/key-fold.ts +8 -7
  26. package/src/cache/layer.ts +8 -17
  27. package/src/cache/layered-cache.ts +16 -102
  28. package/src/cache/policy.ts +25 -29
  29. package/src/cache/run-history.ts +36 -14
  30. package/src/cache/schema.ts +21 -23
  31. package/src/cache/scoped-cache.ts +224 -0
  32. package/src/cli/cache.ts +150 -28
  33. package/src/cli/completions.ts +2 -2
  34. package/src/cli/core-alias.ts +1 -1
  35. package/src/cli/foreign-flags.ts +13 -12
  36. package/src/cli/help.ts +94 -63
  37. package/src/cli/index.ts +42 -13
  38. package/src/cli/info.ts +38 -23
  39. package/src/cli/init.ts +115 -406
  40. package/src/cli/last.ts +145 -4
  41. package/src/cli/lock.ts +333 -111
  42. package/src/cli/plan-format.ts +2 -2
  43. package/src/cli/plugin-commands.ts +5 -9
  44. package/src/cli/plugin-templates.ts +10 -10
  45. package/src/cli/run.ts +103 -149
  46. package/src/cli/select.ts +33 -39
  47. package/src/cli/show.ts +17 -19
  48. package/src/cli/task-verb.ts +2 -9
  49. package/src/cli/upgrade.ts +402 -145
  50. package/src/cli/watch-cycle.ts +9 -2
  51. package/src/cli/watch-filter.ts +33 -60
  52. package/src/cli/watch-fs.ts +29 -16
  53. package/src/cli/watch-judge.ts +12 -3
  54. package/src/cli/watch-set.ts +16 -42
  55. package/src/cli/watch.ts +188 -215
  56. package/src/cli/why.ts +28 -17
  57. package/src/cli/workspace-config.ts +31 -68
  58. package/src/config.ts +169 -70
  59. package/src/exec/env.ts +1 -31
  60. package/src/exec/executor.ts +1 -5
  61. package/src/exec/kill-tree.ts +2 -1
  62. package/src/exec/sandbox-runtime.ts +12 -1
  63. package/src/exec/sandbox-violations.ts +9 -1
  64. package/src/graph/dependency-spec.ts +1 -1
  65. package/src/graph/index.ts +1 -0
  66. package/src/graph/priorities.ts +1 -1
  67. package/src/graph/scheduler.ts +19 -7
  68. package/src/graph/task-graph.ts +44 -20
  69. package/src/handoff.ts +169 -0
  70. package/src/index.ts +24 -57
  71. package/src/orchestrator/affected-tasks.ts +7 -5
  72. package/src/orchestrator/command-windows.ts +44 -0
  73. package/src/orchestrator/deferred-outputs.ts +12 -10
  74. package/src/orchestrator/doctor.ts +98 -110
  75. package/src/orchestrator/download-policy.ts +3 -3
  76. package/src/orchestrator/events.ts +19 -3
  77. package/src/orchestrator/excluded-keys.ts +0 -2
  78. package/src/orchestrator/execute-task.ts +158 -63
  79. package/src/orchestrator/fingerprint-watch.ts +18 -15
  80. package/src/orchestrator/forecast.ts +10 -2
  81. package/src/orchestrator/framed-output.ts +8 -3
  82. package/src/orchestrator/graph-stage.ts +5 -5
  83. package/src/orchestrator/history.ts +70 -30
  84. package/src/orchestrator/hit-restore.ts +15 -3
  85. package/src/orchestrator/index.ts +18 -4
  86. package/src/orchestrator/local-shortcircuit.ts +2 -1
  87. package/src/orchestrator/lockfile-claim.ts +79 -20
  88. package/src/orchestrator/logger.ts +71 -39
  89. package/src/orchestrator/metrics.ts +71 -6
  90. package/src/orchestrator/miss-save.ts +3 -0
  91. package/src/orchestrator/options.ts +42 -29
  92. package/src/orchestrator/output-host.ts +72 -0
  93. package/src/orchestrator/plan.ts +6 -8
  94. package/src/orchestrator/plugin-host.ts +87 -63
  95. package/src/orchestrator/plugin.ts +94 -60
  96. package/src/orchestrator/prepare.ts +113 -72
  97. package/src/orchestrator/projects.ts +102 -99
  98. package/src/orchestrator/remote-prefetch.ts +2 -1
  99. package/src/orchestrator/run-context.ts +25 -6
  100. package/src/orchestrator/run-failures.ts +17 -8
  101. package/src/orchestrator/run-lock.ts +1 -1
  102. package/src/orchestrator/run-report.ts +9 -9
  103. package/src/orchestrator/run-settings.ts +48 -0
  104. package/src/orchestrator/run.ts +121 -95
  105. package/src/orchestrator/sandbox-fix.ts +39 -20
  106. package/src/orchestrator/sandbox-request.ts +84 -13
  107. package/src/orchestrator/signals.ts +1 -1
  108. package/src/orchestrator/stable-keys.ts +13 -11
  109. package/src/orchestrator/status-line.ts +48 -6
  110. package/src/orchestrator/summary.ts +46 -62
  111. package/src/orchestrator/task-hash.ts +12 -83
  112. package/src/orchestrator/telemetry.ts +3 -3
  113. package/src/settings.ts +417 -0
  114. package/src/util/errors.ts +1 -1
  115. package/src/util/essential-env.ts +31 -0
  116. package/src/util/hide-env.ts +107 -0
  117. package/src/util/index.ts +8 -2
  118. package/src/util/paths.ts +10 -0
  119. package/src/util/settle.ts +49 -30
  120. package/src/util/size.ts +23 -4
  121. package/src/util/timing.ts +3 -2
  122. package/src/util/ulid.ts +1 -1
  123. package/src/util/verbs.ts +0 -15
  124. package/src/workspace/affected.ts +77 -222
  125. package/src/workspace/config-eval.ts +15 -20
  126. package/src/workspace/config-imports.ts +78 -3
  127. package/src/workspace/config-schema.ts +209 -268
  128. package/src/workspace/dependency-rules.ts +57 -0
  129. package/src/workspace/filter.ts +2 -2
  130. package/src/workspace/fingerprint.ts +11 -121
  131. package/src/workspace/index.ts +20 -27
  132. package/src/workspace/load-reads.ts +4 -4
  133. package/src/workspace/lockfile.ts +96 -140
  134. package/src/workspace/migration.ts +242 -146
  135. package/src/workspace/package-graph.ts +30 -216
  136. package/src/workspace/project-loader.ts +80 -285
  137. package/src/workspace/repo-id.ts +2 -1
  138. package/src/workspace/workspace.ts +362 -831
  139. package/src/cache/config-evals.ts +0 -130
  140. package/src/workspace/config-cache.ts +0 -661
  141. package/src/workspace/migrate-scripts.ts +0 -1121
@@ -7,7 +7,7 @@ import { existsSync } from 'node:fs'
7
7
  import { lstat, mkdir, readdir, realpath, rm, writeFile } from 'node:fs/promises'
8
8
  import { homedir } from 'node:os'
9
9
  import path from 'node:path'
10
- import type { ExecConfig } from '../config.js'
10
+ import type { ExecConfig, SandboxConfig } from '../config.js'
11
11
  import {
12
12
  bindableWrites,
13
13
  initSandbox,
@@ -25,7 +25,6 @@ import {
25
25
  } from '../exec/index.js'
26
26
  import type { TaskNode } from '../graph/index.js'
27
27
  import { grantPrefix, relPosix, UserError } from '../util/index.js'
28
- import { WORKSPACE_FINGERPRINT_FILES } from '../workspace/index.js'
29
28
 
30
29
  /**
31
30
  * Arm the sandbox runtime for a run, lazily: only when at least one task
@@ -188,7 +187,7 @@ export async function sandboxRequestFor(
188
187
  /** The projects nested in this one (their dirs): a wall its grants do not reach. */
189
188
  nested: readonly string[] = [],
190
189
  /**
191
- * The run's cache directory. `.vx` is walled wherever it is; a
190
+ * The run's cache directory. `.vx/cache` is walled wherever it is; a
192
191
  * `cacheDir` configured inside a project was not, and a task whose write
193
192
  * grant covered it could plant artifacts and index rows (L-24).
194
193
  */
@@ -234,7 +233,7 @@ export async function sandboxRequestFor(
234
233
  const walls = [
235
234
  ...nested,
236
235
  path.join(workspaceRoot, '.git'),
237
- path.join(workspaceRoot, '.vx'),
236
+ path.join(workspaceRoot, '.vx', 'cache'),
238
237
  ...(cacheDir !== undefined &&
239
238
  toRealPath(cacheDir).startsWith(toRealPath(workspaceRoot) + path.sep)
240
239
  ? [cacheDir]
@@ -290,6 +289,53 @@ export async function sandboxRequestFor(
290
289
  return { sandbox: request, placeholders, withheld: links.withheld }
291
290
  }
292
291
 
292
+ /**
293
+ * Whether a sandboxed task could read `real` (a canonical path inside its
294
+ * project): under one of its read or write grants or its `node_modules`,
295
+ * and not behind a wall. vx packs a symlinked output as its target's
296
+ * bytes, outside the sandbox, so a link the task planted to a file it was
297
+ * denied (`dist/x -> ../.env` under `read: ['src/']`) packed that file
298
+ * into the cache wherever creating the link went unseen: seatbelt, or
299
+ * Linux untraced. The grants are matched as written, not expanded: a
300
+ * glob's hit made by the task itself counts.
301
+ */
302
+ export function sandboxReadable(
303
+ node: TaskNode,
304
+ sandbox: SandboxConfig,
305
+ workspaceRoot: string,
306
+ nested: readonly string[] = [],
307
+ ): (real: string) => boolean {
308
+ const projectReal = toRealPath(node.projectDir)
309
+ const at = (p: string): string =>
310
+ p.startsWith('~')
311
+ ? path.join(homedir(), p.slice(1))
312
+ : path.isAbsolute(p)
313
+ ? p
314
+ : path.join(projectReal, p)
315
+ const grants = [...(sandbox.allow?.read ?? []), ...(sandbox.allow?.write ?? [])].map(at)
316
+ const literals = [
317
+ ...grants.filter(isMountableLiteral).map((g) => toRealPath(g.replace(/\/+$/, '') || '/')),
318
+ toRealPath(path.join(node.projectDir, 'node_modules')),
319
+ toRealPath(path.join(workspaceRoot, 'node_modules')),
320
+ ]
321
+ const globs = grants.filter((g) => !isMountableLiteral(g)).map((g) => new Bun.Glob(g))
322
+ const walls = [
323
+ ...nested,
324
+ path.join(workspaceRoot, '.git'),
325
+ path.join(workspaceRoot, '.vx', 'cache'),
326
+ ].map(toRealPath)
327
+ return (real) => {
328
+ if (walls.some((w) => atOrUnder(real, w))) return false
329
+ if (literals.some((l) => atOrUnder(real, l))) return true
330
+ // A directory a glob matches is granted whole.
331
+ for (let p = real; atOrUnder(p, projectReal); p = path.dirname(p)) {
332
+ if (globs.some((g) => g.match(p))) return true
333
+ if (p === projectReal) break
334
+ }
335
+ return false
336
+ }
337
+ }
338
+
293
339
  /**
294
340
  * The sandbox half of the request, plus the empty files vx created so a
295
341
  * literal write grant had something to bind (`placeholders`). They are
@@ -619,9 +665,9 @@ export function untouchedPlaceholderLine(projectDir: string, placeholder: string
619
665
  /**
620
666
  * Where tools keep credentials under the user's home. A sandboxed task
621
667
  * reads none of them unless its `allow.read` names one (a publish task's
622
- * `~/.npmrc`).
668
+ * `~/.npmrc`), and `sandboxFix` never suggests one as a grant.
623
669
  */
624
- const CREDENTIAL_STORES = [
670
+ export const CREDENTIAL_STORES = [
625
671
  '.ssh',
626
672
  '.gnupg',
627
673
  '.aws',
@@ -647,6 +693,26 @@ const CREDENTIAL_STORES = [
647
693
  '.terraform.d/credentials.tfrc.json',
648
694
  '.vault-token',
649
695
  '.pgpass',
696
+ // Deploy and service CLIs a JS build runs beside, each at its home on
697
+ // Linux and macOS, and the agent CLIs that may run vx itself.
698
+ '.config/configstore/firebase-tools.json',
699
+ '.local/share/com.vercel.cli/auth.json',
700
+ 'Library/Application Support/com.vercel.cli/auth.json',
701
+ '.netlify/config.json',
702
+ '.config/netlify/config.json',
703
+ 'Library/Preferences/netlify/config.json',
704
+ '.wrangler/config',
705
+ '.config/.wrangler/config',
706
+ 'Library/Preferences/.wrangler/config',
707
+ '.fly/config.yml',
708
+ '.supabase/access-token',
709
+ '.config/doctl',
710
+ 'Library/Application Support/doctl',
711
+ '.config/stripe/config.toml',
712
+ '.config/op',
713
+ '.expo/state.json',
714
+ '.claude/.credentials.json',
715
+ '.codex/auth.json',
650
716
  ]
651
717
 
652
718
  /** The stores present on this host, learned once per home. */
@@ -697,7 +763,7 @@ export type WriteReach = 'none' | 'project' | 'workspace'
697
763
  /**
698
764
  * Where a task that ran a command may have written files no declaration
699
765
  * names — the files whose run-start facts (the git snapshot, its index
700
- * OIDs, the `package.json` digest) a later key must not reuse (item 743).
766
+ * OIDs) a later key must not reuse (item 743).
701
767
  *
702
768
  * A cached task declares its outputs and is held to them. A task with no
703
769
  * `cache` block can declare none, so it may have written anywhere
@@ -732,20 +798,25 @@ export function commandWriteReach(node: TaskNode, workspaceRoot: string): WriteR
732
798
  }
733
799
 
734
800
  /**
735
- * May this task's command rewrite a file the workspace fingerprint folds
736
- * (a lockfile, `pnpm-workspace.yaml`)? Those sit at the workspace root, so
801
+ * May this task's command rewrite a root file a plugin claims (`claimed`:
802
+ * a lockfile, `pnpm-workspace.yaml`)? Those sit at the workspace root, so
737
803
  * an unsandboxed task may only when the root is its own project (`pnpm
738
804
  * install` without `--frozen-lockfile` in a root task); a sandboxed one
739
805
  * when a write grant covers one. Anyone else writing there crosses a
740
- * project boundary, out of contract.
806
+ * project boundary, out of contract. With nothing claimed, no key folds a
807
+ * root file this way.
741
808
  */
742
- export function mayWriteFingerprint(node: TaskNode, workspaceRoot: string): boolean {
809
+ export function mayWriteFingerprint(
810
+ node: TaskNode,
811
+ workspaceRoot: string,
812
+ claimed: readonly string[],
813
+ ): boolean {
743
814
  const exec = node.config.exec
744
- if (exec === undefined) return false
815
+ if (exec === undefined || claimed.length === 0) return false
745
816
  if (exec.sandbox === undefined) return node.projectDir === workspaceRoot
746
817
  for (const grant of exec.sandbox.allow?.write ?? []) {
747
818
  const abs = path.resolve(node.projectDir, expandHome(grantPrefix(grant)))
748
- for (const f of WORKSPACE_FINGERPRINT_FILES) {
819
+ for (const f of claimed) {
749
820
  if (atOrUnder(path.join(workspaceRoot, f), abs)) return true
750
821
  }
751
822
  }
@@ -21,7 +21,7 @@ import type { Logger } from './logger.js'
21
21
  /**
22
22
  * How long a SIGTERMed child gets before SIGKILL on the way out. The
23
23
  * end-of-run persistent shutdown grants the same two seconds; both read
24
- * `VX_KILL_GRACE_MS` (util/settle.ts) so the suites that prove the
24
+ * `VX_RUN_KILL_GRACE` (util/settle.ts) so the suites that prove the
25
25
  * escalation do not wait the full grace.
26
26
  */
27
27
  export const SIGNAL_SHUTDOWN_GRACE_MS = 2000
@@ -28,7 +28,8 @@ export interface DeriveStableKeysArgs {
28
28
  nodes: Map<string, TaskNode>
29
29
  cache: CacheLayer
30
30
  workspaceRoot: string
31
- workspaceFingerprint: string
31
+ /** The root files the plugins claim (`VxPlugin.fingerprint`), for `mayWriteFingerprint`. */
32
+ claimedFiles: readonly string[]
32
33
  forwardArgs?: readonly string[] | undefined
33
34
  nestedDirsByProject: Map<string, string[]>
34
35
  gitFilesCache: GitFilesCache
@@ -58,7 +59,7 @@ export interface StableKey {
58
59
  * cache-enabled, non-group tasks paired with their keys.
59
60
  *
60
61
  * Reuses the run's `hashCache`, so execute-task's later computeTaskHash
61
- * for the same node hits the memo (package.json bytes + task config) —
62
+ * for the same node hits the memo (the task config hash, file lists) —
62
63
  * the key bytes are derived here, the heavy per-project hashing is
63
64
  * shared. No cache layer is touched: keys only.
64
65
  */
@@ -82,8 +83,8 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
82
83
  // - wsOutputUpstream: any upstream declares cache.outputs.workspaceFiles
83
84
  // (root-anchored, boundary-ignoring outputs), or has no cache block
84
85
  // and a sandbox write grant elsewhere in the workspace, or may rewrite
85
- // a file the workspace fingerprint folds (a root `pnpm install`): every
86
- // key folds that digest, taken once per run.
86
+ // a root file a plugin claims (a root `pnpm install`): every key folds
87
+ // what the claimant took from it, once per run.
87
88
  // - rewritersById / unfoldedById: the projects of every upstream CACHED
88
89
  // task that may rewrite its own inputs in place (a formatter declaring
89
90
  // no outputs; `commandWriteReach`), and the subset this task's key does
@@ -93,7 +94,7 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
93
94
  // unfolded dependency covered is still uncovered here: its key is not
94
95
  // in this one.
95
96
  // - wsRewriters: the same for a cached rewriter whose reach is every
96
- // project (a write grant elsewhere in the workspace, or a fingerprinted
97
+ // project (a write grant elsewhere in the workspace, or a claimed root
97
98
  // file, `mayWriteFingerprint`); one this key does not fold makes it
98
99
  // preliminary. A dependency this key folds needs no pass-through here:
99
100
  // a keyed task with such a rewriter unfolded is unstable itself, and
@@ -175,14 +176,17 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
175
176
  ;(wide ??= projects.empty()).add(depNode.projectName)
176
177
  } else if (reach === 'workspace') wsOutputUpstream = true
177
178
  const cached = depNode.config.cache !== undefined
178
- if (!cached && mayWriteFingerprint(depNode, args.workspaceRoot)) wsOutputUpstream = true
179
+ if (!cached && mayWriteFingerprint(depNode, args.workspaceRoot, args.claimedFiles))
180
+ wsOutputUpstream = true
179
181
  if (!anyUnfolded) continue
180
182
  // The cached twin: a formatter rewriting its own input ahead of a
181
183
  // reader that folds no key of it replayed seed B's build under seed A
182
184
  // on the fourth run, the same way (item 750).
183
185
  const rewrites = cached ? commandWriteReach(depNode, args.workspaceRoot) : 'none'
184
186
  const rewritesWs =
185
- cached && (rewrites === 'workspace' || mayWriteFingerprint(depNode, args.workspaceRoot))
187
+ cached &&
188
+ (rewrites === 'workspace' ||
189
+ mayWriteFingerprint(depNode, args.workspaceRoot, args.claimedFiles))
186
190
  const rewritersOfDep = rewritersById.get(dep)
187
191
  const wsRewriterOfDep = wsRewriters.has(dep)
188
192
  if (
@@ -239,7 +243,6 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
239
243
  node,
240
244
  upstream,
241
245
  workspaceRoot: args.workspaceRoot,
242
- workspaceFingerprint: args.workspaceFingerprint,
243
246
  cache: args.cache,
244
247
  forwardArgs: args.forwardArgs,
245
248
  nestedProjectDirs: args.nestedDirsByProject.get(node.projectName) ?? [],
@@ -256,7 +259,6 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
256
259
  node,
257
260
  upstream,
258
261
  workspaceRoot: args.workspaceRoot,
259
- workspaceFingerprint: args.workspaceFingerprint,
260
262
  cache: args.cache,
261
263
  forwardArgs: args.forwardArgs,
262
264
  nestedProjectDirs: args.nestedDirsByProject.get(node.projectName) ?? [],
@@ -339,7 +341,7 @@ export function probesAfterWrites(
339
341
  }
340
342
  if (!anyProbe) return late
341
343
  // A sandbox with no write grant still writes the declared outputs. A
342
- // fingerprinted file is only writable where `commandWriteReach` is not
344
+ // claimed root file is only writable where `commandWriteReach` is not
343
345
  // 'none' already (`mayWriteFingerprint`).
344
346
  const writers = new Set<string>()
345
347
  for (const n of nodes.values()) {
@@ -469,7 +471,7 @@ export function dependsOnSiblingOutputs(
469
471
  if (inputs === undefined || outputs.length === 0) return true
470
472
  // An output the reader's own `!` inputs take back whole is not in its
471
473
  // key: `['**/*', '!dist/**']` beside a `dist/**` producer keys the same
472
- // before and after it, which is what `rules.upfrontKeys` asks a config
474
+ // before and after it, which is what `run.rules.upfrontKeys` asks a config
473
475
  // to write (X-54).
474
476
  const back = inputs.filter((g) => g.startsWith('!')).map((g) => g.slice(1))
475
477
  const reach = back.length === 0 ? outputs : outputs.filter((o) => !outputTakenBack(o, back))
@@ -26,6 +26,8 @@ export interface StatusStream {
26
26
  * TTY) means the region is never drawn anyway.
27
27
  */
28
28
  columns?: number
29
+ /** Terminal height in rows, read per draw as `columns` is. */
30
+ rows?: number
29
31
  }
30
32
 
31
33
  export interface OutputWriter {
@@ -137,14 +139,37 @@ export function createOutputWriter(
137
139
  * it. Reachable only on a TTY whose winsize ioctl fails, since a non-TTY
138
140
  * disables the region outright.
139
141
  */
140
- const regionRows = (lines: readonly string[]): number => {
142
+ const lineRows = (line: string): number => {
141
143
  const cols = stream.columns
142
- if (cols === undefined || cols <= 0) return lines.length
144
+ if (cols === undefined || cols <= 0) return 1
145
+ return Math.max(1, Math.ceil(Bun.stringWidth(line) / cols))
146
+ }
147
+ const regionRows = (lines: readonly string[]): number => {
143
148
  let rows = 0
144
- for (const line of lines) rows += Math.max(1, Math.ceil(Bun.stringWidth(line) / cols))
149
+ for (const line of lines) rows += lineRows(line)
145
150
  return rows
146
151
  }
147
152
 
153
+ /**
154
+ * The leading lines that fit the terminal's height, one row spared.
155
+ * Cursor-up stops at the top of the screen, and a row that scrolled into
156
+ * the scrollback is out of the erase's reach: a region taller than the
157
+ * terminal left its top behind on every redraw, one stacked block per tick
158
+ * (VS Code's short panel, 2026-10-14). The first line always shows.
159
+ */
160
+ const fitted = (lines: readonly string[]): readonly string[] => {
161
+ const height = stream.rows
162
+ if (height === undefined || height <= 0) return lines
163
+ let rows = 0
164
+ let n = 0
165
+ for (const line of lines) {
166
+ rows += lineRows(line)
167
+ if (n > 0 && rows > height - 1) break
168
+ n++
169
+ }
170
+ return n === lines.length ? lines : lines.slice(0, n)
171
+ }
172
+
148
173
  // Erase sequence for whatever is currently shown. A single row keeps
149
174
  // the exact legacy bytes (ESC[2K\r); anything taller moves to its
150
175
  // top row and clears to end of screen.
@@ -155,9 +180,10 @@ export function createOutputWriter(
155
180
  // only repeat these bytes.
156
181
  cancelTrailing()
157
182
  const erase = shown ? eraseSeq() : CLEAR
158
- stream.write(erase + current!.join('\n'))
183
+ const lines = fitted(current!)
184
+ stream.write(erase + lines.join('\n'))
159
185
  shown = true
160
- shownRows = regionRows(current!)
186
+ shownRows = regionRows(lines)
161
187
  lastDraw = now()
162
188
  }
163
189
  const set = (lines: readonly string[], o: { force?: boolean }): void => {
@@ -246,6 +272,8 @@ export interface StatusRegionState {
246
272
  blocks?: ReadonlyMap<string, number>
247
273
  /** The running task at the head of the longest unfinished chain. */
248
274
  critical?: string
275
+ /** A running task's usual duration from history; its row shows it. */
276
+ expected?: ReadonlyMap<string, number>
249
277
  /**
250
278
  * Ready persistent tasks (dev servers …) whose children keep
251
279
  * running after their outcome lands. Pinned until runEnd — the
@@ -349,13 +377,14 @@ export function formatStatusRegion(
349
377
  const notes: string[] = []
350
378
  if (s.critical === slot.id && busy > 1) notes.push(paint(WARN, 'critical path', colors))
351
379
  if (waiting > 0) notes.push(dim(`blocks ${waiting}`))
380
+ const usual = s.expected?.get(slot.id)
352
381
  lines.push(
353
382
  formatTaskRow(
354
383
  ' ',
355
384
  Math.max(0, s.nowMs - slot.startedMs),
356
385
  'running',
357
386
  ACCENT,
358
- '',
387
+ usual === undefined ? '' : formatExpected(usual),
359
388
  '',
360
389
  paintPinnedId(slot.id, colors),
361
390
  colors,
@@ -370,3 +399,16 @@ export function formatStatusRegion(
370
399
  lines.push(...s.summaryLines)
371
400
  return lines
372
401
  }
402
+
403
+ /**
404
+ * A running task's usual duration, fixed while the elapsed time counts up
405
+ * toward it (M, 2026-10-14): `~5.2s`, `~42s`, `~2m 05s`. Fits the cache
406
+ * column below 100 minutes.
407
+ */
408
+ function formatExpected(ms: number): string {
409
+ if (ms < 1000) return `~${Math.round(ms)}ms`
410
+ if (ms < 9950) return `~${(ms / 1000).toFixed(1)}s`
411
+ const s = Math.round(ms / 1000)
412
+ if (s < 60) return `~${s}s`
413
+ return `~${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
414
+ }
@@ -21,6 +21,7 @@ const LOCAL = '#38bdf8' // sky-400
21
21
  const REMOTE = '#2563eb' // blue-600
22
22
  const ERROR = '#ef4444'
23
23
  const ABORTED = '#f97316' // orange-500: red's neighbour, not a failure
24
+ const NOT_RUN = '#a855f7' // purple-500: stopped before it began, apart from every other state
24
25
  const ACCENT = '#06b6d4' // cyan-500: the live region's running word
25
26
 
26
27
  const BAR_WIDTH = 50
@@ -137,7 +138,7 @@ export interface SummaryStats {
137
138
  */
138
139
  export interface RunContext {
139
140
  version: string
140
- /** Projects covered by the graph — the "in run" half of the bar. */
141
+ /** Projects covered by the graph — the "affected" half of the bar. */
141
142
  packageCount: number
142
143
  /** Worker-pool size for this run; shown on the `info` row. */
143
144
  concurrency?: number
@@ -164,25 +165,25 @@ export function formatSummarySection(
164
165
  const row = (label: string, value: string): string => ` ${dim(label.padEnd(8))} ${value}`
165
166
  const join = (parts: string[]): string => parts.join(` ${dim('\u00b7')} `)
166
167
 
167
- // Tasks meter + numbers, fixed order: failed · success · skipped,
168
- // then a dim total so the legend reads against the whole graph.
168
+ // Tasks meter + numbers, fixed order: failed · success · skipped ·
169
+ // aborted · not run, then a dim total, last on its line. Every task in
170
+ // the run counts, each state its own segment (M, 2026-10-14).
171
+ const runTotal = stats.total + aborted + notRun
169
172
  const taskParts: string[] = []
170
173
  if (stats.failed > 0)
171
174
  taskParts.push(paint(ERROR, `${stats.failed} failed`, colors, { bold: true }))
172
175
  if (stats.successful > 0) taskParts.push(paint(SUCCESS, `${stats.successful} success`, colors))
173
176
  if (stats.skipped > 0) taskParts.push(paint(WARN, `${stats.skipped} skipped`, colors))
174
- if (stats.total > 0) taskParts.push(dim(`${stats.total} total`))
175
- // Rows in the task list, but outside `total`: history and telemetry count
176
- // the same tasks the total does, and an aborted task did no work.
177
- const uncounted: string[] = []
178
- if (aborted > 0) uncounted.push(paint(ABORTED, `${aborted} aborted`, colors))
179
- if (notRun > 0) uncounted.push(dim(`${notRun} not run`))
180
- if (uncounted.length > 0) taskParts.push(`${dim('not counted:')} ${uncounted.join(dim(', '))}`)
177
+ if (aborted > 0) taskParts.push(paint(ABORTED, `${aborted} aborted`, colors))
178
+ if (notRun > 0) taskParts.push(paint(NOT_RUN, `${notRun} not run`, colors))
179
+ if (runTotal > 0) taskParts.push(dim(`${runTotal} total`))
181
180
  const taskBar = segmentBar(
182
181
  [
183
182
  { n: stats.failed, color: ERROR },
184
183
  { n: stats.successful, color: SUCCESS },
185
184
  { n: stats.skipped, color: WARN },
185
+ { n: aborted, color: ABORTED },
186
+ { n: notRun, color: NOT_RUN },
186
187
  remainder,
187
188
  ],
188
189
  colors,
@@ -211,7 +212,7 @@ export function formatSummarySection(
211
212
  paint('', '▱'.repeat(BAR_WIDTH - cells), colors, { dim: true })
212
213
  lines.push(
213
214
  row('projects', bar),
214
- legend([paint(WARN, `${context.packageCount} in run`, colors), dim(`${wsTotal} total`)]),
215
+ legend([paint(WARN, `${context.packageCount} affected`, colors), dim(`${wsTotal} total`)]),
215
216
  )
216
217
  }
217
218
  }
@@ -256,27 +257,48 @@ export function formatSummarySection(
256
257
  cacheParts.push(paint(LOCAL, `${stats.restoredLocal} local`, colors))
257
258
  if (stats.restoredRemote > 0)
258
259
  cacheParts.push(paint(REMOTE, `${stats.restoredRemote} remote`, colors))
259
- // Skipped tasks have no cache provenance, but omitting them made
260
- // the cache legend sum below the tasks legend (owner-reported
261
- // confusion) — on the bar and in the legend, yellow like the
262
- // tasks meter.
263
- if (stats.skipped > 0) cacheParts.push(paint(WARN, `${stats.skipped} skipped`, colors))
260
+ // Skipped tasks ride the bar, yellow like the tasks meter, so the two
261
+ // bars fill alike; their count is the tasks legend's alone (the footer
262
+ // never says a fact twice, M, 2026-10-14).
264
263
  lines.push(row('cache', cacheBar))
265
264
  if (cacheParts.length > 0) lines.push(legend(cacheParts))
266
265
  }
267
266
 
268
267
  // Cache-miss duration spread (owner: hits' restore times polluted
269
268
  // it — only tasks that actually executed count). Dim: context.
270
- let spread = ''
271
- if (stats.spread !== null && stats.spread.count > 0) {
269
+ // A value already on the row is not said again: one executed task read
270
+ // as its own max, avg and min (M, 2026-10-14). Tasks that all took the
271
+ // same time have no spread to show.
272
+ const total = formatElapsed(totalMs)
273
+ const said = new Set([total])
274
+ const spreadParts: string[] = []
275
+ if (
276
+ stats.spread !== null &&
277
+ formatElapsed(stats.spread.maxMs) !== formatElapsed(stats.spread.minMs)
278
+ ) {
272
279
  const { maxMs, minMs, sumMs, count } = stats.spread
273
- spread = ` ${dim(`\u00b7 max ${formatElapsed(maxMs)} \u00b7 avg ${formatElapsed(sumMs / count)} \u00b7 min ${formatElapsed(minMs)}`)}`
280
+ for (const [label, ms] of [
281
+ ['max', maxMs],
282
+ ['avg', sumMs / count],
283
+ ['min', minMs],
284
+ ] as const) {
285
+ const v = formatElapsed(ms)
286
+ if (said.has(v)) continue
287
+ said.add(v)
288
+ spreadParts.push(`${label} ${v}`)
289
+ }
274
290
  }
291
+ const timeParts = [total]
292
+ // What the cache paid back, beside what the run cost: the stored exec
293
+ // time of every hit, never the restore this run spent on it.
294
+ if ((stats.savedMs ?? 0) >= 1)
295
+ timeParts.push(paint(SUCCESS, `${formatElapsed(stats.savedMs!)} saved`, colors))
275
296
  // The live run's forecast leads the spread: the one number on the row
276
297
  // that is about the future.
277
- if (stats.etaMs !== undefined) {
278
- spread = ` ${dim('\u00b7')} ${paint(ACCENT, `${formatEta(stats.etaMs)} left`, colors)}${spread}`
279
- }
298
+ if (stats.etaMs !== undefined)
299
+ timeParts.push(paint(ACCENT, `${formatEta(stats.etaMs)} left`, colors))
300
+ for (const p of spreadParts) timeParts.push(dim(p))
301
+ const timeRow = row('time', join(timeParts))
280
302
  // Run-shape footer (final summary only): worker pool + cache mode,
281
303
  // grouped with time under a blank line below the meters.
282
304
  if (context !== undefined) {
@@ -294,12 +316,9 @@ export function formatSummarySection(
294
316
  `admit held ${stats.held.count} task${stats.held.count === 1 ? '' : 's'}, ${formatElapsed(stats.held.sumMs)} in all`,
295
317
  )
296
318
  }
297
- lines.push('', row('info', join(info)), row('time', `${formatElapsed(totalMs)}${spread}`))
298
- // The run in one line, last, where the eye lands: what a Turbo user
299
- // reads first in its own summary (tasks, cached, time), in vx's words.
300
- if (stats.total > 0) lines.push(row('result', resultLine(stats, hits, totalMs, colors)))
319
+ lines.push('', row('info', join(info)), timeRow)
301
320
  } else {
302
- lines.push('', row('time', `${formatElapsed(totalMs)}${spread}`))
321
+ lines.push('', timeRow)
303
322
  }
304
323
  return lines
305
324
  }
@@ -315,41 +334,6 @@ function formatEta(ms: number): string {
315
334
  return `~${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
316
335
  }
317
336
 
318
- /**
319
- * `42 tasks · 38 cached (90%) · 3.2s`, `2 tasks · all cached · 23ms`,
320
- * with the failures first when there are any. Cached is every hit (up to
321
- * date, restored locally or remotely) over every task that consulted a
322
- * cache: a skipped or not-yet-run task never did.
323
- */
324
- function resultLine(
325
- stats: SummaryStats,
326
- hits: number,
327
- totalMs: number,
328
- colors: ColorSupport,
329
- ): string {
330
- const parts = [`${stats.total} task${stats.total === 1 ? '' : 's'}`]
331
- if (stats.failed > 0) parts.push(paint(ERROR, `${stats.failed} failed`, colors, { bold: true }))
332
- // A task with no `cache` block could never hit: it is counted apart, not
333
- // as a miss, or `vx run dev` would read "0 cached (0%)". A skipped task
334
- // never asked either, and counted as a miss it put "0 cached (0%)" on a
335
- // run in which no task has a cache.
336
- const noCache = stats.noCache ?? 0
337
- const cacheable = stats.total - noCache - stats.skipped - (stats.left ?? 0)
338
- if (cacheable > 0)
339
- parts.push(
340
- hits === cacheable
341
- ? paint(SUCCESS, 'all cached', colors, { bold: true })
342
- : `${hits} cached (${Math.floor((hits / cacheable) * 100)}%)`,
343
- )
344
- if (noCache > 0) parts.push(paint('', `${noCache} no-cache`, colors, { dim: true }))
345
- // What the cache paid back, beside what the run cost: the stored exec
346
- // time of every hit, never the restore this run spent on it.
347
- if ((stats.savedMs ?? 0) >= 1)
348
- parts.push(paint(SUCCESS, `${formatElapsed(stats.savedMs!)} saved`, colors))
349
- parts.push(formatElapsed(totalMs))
350
- return parts.join(` ${paint('', '\u00b7', colors, { dim: true })} `)
351
- }
352
-
353
337
  /**
354
338
  * The `--verbosity 1` per-task table (`RunOptions.summaryTable`). Groups are
355
339
  * left out as the footer and `--report` leave them: a group ran no command.