@vzn/vx 0.0.636 → 0.0.638

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 (151) hide show
  1. package/README.md +39 -11
  2. package/docs/caching.md +246 -213
  3. package/docs/cli.md +791 -705
  4. package/docs/execution.md +68 -76
  5. package/docs/features.md +47 -42
  6. package/docs/patterns.md +26 -25
  7. package/docs/schema.md +392 -205
  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 +4 -4
  16. package/schemas/show.json +5 -3
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +28 -13
  19. package/src/bin.ts +30 -9
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +124 -53
  22. package/src/cache/chained-cache.ts +4 -0
  23. package/src/cache/git-inputs.ts +277 -63
  24. package/src/cache/index.ts +15 -1
  25. package/src/cache/inputs.ts +102 -36
  26. package/src/cache/key-fold.ts +8 -7
  27. package/src/cache/layer.ts +15 -18
  28. package/src/cache/layered-cache.ts +20 -102
  29. package/src/cache/plugin-cache.ts +248 -0
  30. package/src/cache/policy.ts +32 -27
  31. package/src/cache/run-history.ts +104 -14
  32. package/src/cache/run-store.ts +77 -0
  33. package/src/cache/schema.ts +58 -31
  34. package/src/cache/scoped-cache.ts +237 -0
  35. package/src/cli/cache.ts +173 -34
  36. package/src/cli/completions.ts +11 -7
  37. package/src/cli/core-alias.ts +1 -1
  38. package/src/cli/foreign-flags.ts +13 -12
  39. package/src/cli/help.ts +110 -70
  40. package/src/cli/index.ts +67 -24
  41. package/src/cli/info.ts +43 -27
  42. package/src/cli/init.ts +115 -407
  43. package/src/cli/last.ts +157 -9
  44. package/src/cli/lock.ts +356 -122
  45. package/src/cli/plan-format.ts +7 -5
  46. package/src/cli/plugin-commands.ts +81 -14
  47. package/src/cli/plugin-templates.ts +11 -11
  48. package/src/cli/run.ts +116 -160
  49. package/src/cli/select.ts +50 -47
  50. package/src/cli/show.ts +83 -59
  51. package/src/cli/task-verb.ts +6 -13
  52. package/src/cli/upgrade.ts +415 -149
  53. package/src/cli/watch-cycle.ts +9 -2
  54. package/src/cli/watch-filter.ts +39 -61
  55. package/src/cli/watch-fs.ts +29 -16
  56. package/src/cli/watch-judge.ts +18 -4
  57. package/src/cli/watch-set.ts +16 -42
  58. package/src/cli/watch.ts +227 -230
  59. package/src/cli/why.ts +38 -18
  60. package/src/cli/workspace-config.ts +33 -70
  61. package/src/config.ts +169 -70
  62. package/src/exec/env.ts +1 -31
  63. package/src/exec/executor.ts +1 -5
  64. package/src/exec/kill-tree.ts +2 -1
  65. package/src/exec/sandbox-runtime.ts +12 -1
  66. package/src/exec/sandbox-violations.ts +9 -1
  67. package/src/graph/dependency-spec.ts +1 -1
  68. package/src/graph/index.ts +1 -0
  69. package/src/graph/priorities.ts +1 -1
  70. package/src/graph/scheduler.ts +27 -7
  71. package/src/graph/task-graph.ts +76 -24
  72. package/src/handoff.ts +169 -0
  73. package/src/index.ts +35 -58
  74. package/src/orchestrator/affected-tasks.ts +15 -8
  75. package/src/orchestrator/command-windows.ts +44 -0
  76. package/src/orchestrator/deferred-outputs.ts +12 -10
  77. package/src/orchestrator/doctor.ts +98 -110
  78. package/src/orchestrator/download-policy.ts +3 -3
  79. package/src/orchestrator/events.ts +19 -3
  80. package/src/orchestrator/excluded-keys.ts +0 -2
  81. package/src/orchestrator/execute-task.ts +147 -69
  82. package/src/orchestrator/failure-mode.ts +19 -10
  83. package/src/orchestrator/fingerprint-watch.ts +18 -15
  84. package/src/orchestrator/forecast.ts +10 -2
  85. package/src/orchestrator/framed-output.ts +9 -5
  86. package/src/orchestrator/graph-stage.ts +5 -5
  87. package/src/orchestrator/history.ts +126 -30
  88. package/src/orchestrator/hit-restore.ts +15 -3
  89. package/src/orchestrator/index.ts +20 -5
  90. package/src/orchestrator/local-shortcircuit.ts +2 -1
  91. package/src/orchestrator/lockfile-claim.ts +79 -20
  92. package/src/orchestrator/logger.ts +80 -41
  93. package/src/orchestrator/metrics.ts +78 -9
  94. package/src/orchestrator/miss-save.ts +3 -0
  95. package/src/orchestrator/options.ts +42 -29
  96. package/src/orchestrator/output-host.ts +72 -0
  97. package/src/orchestrator/plan.ts +6 -8
  98. package/src/orchestrator/plugin-host.ts +100 -66
  99. package/src/orchestrator/plugin.ts +173 -67
  100. package/src/orchestrator/prepare.ts +163 -78
  101. package/src/orchestrator/projects.ts +105 -99
  102. package/src/orchestrator/remote-prefetch.ts +11 -6
  103. package/src/orchestrator/run-context.ts +25 -6
  104. package/src/orchestrator/run-failures.ts +17 -8
  105. package/src/orchestrator/run-lock.ts +1 -1
  106. package/src/orchestrator/run-records.ts +1 -1
  107. package/src/orchestrator/run-report.ts +9 -9
  108. package/src/orchestrator/run-settings.ts +48 -0
  109. package/src/orchestrator/run.ts +244 -119
  110. package/src/orchestrator/sandbox-fix.ts +39 -20
  111. package/src/orchestrator/sandbox-request.ts +84 -13
  112. package/src/orchestrator/signals.ts +1 -1
  113. package/src/orchestrator/stable-keys.ts +13 -11
  114. package/src/orchestrator/status-line.ts +49 -7
  115. package/src/orchestrator/summary.ts +115 -106
  116. package/src/orchestrator/task-hash.ts +12 -83
  117. package/src/orchestrator/telemetry.ts +4 -4
  118. package/src/settings.ts +428 -0
  119. package/src/{orchestrator → util}/colors.ts +5 -0
  120. package/src/util/config-frame.ts +31 -2
  121. package/src/util/errors.ts +33 -1
  122. package/src/util/essential-env.ts +31 -0
  123. package/src/util/hide-env.ts +107 -0
  124. package/src/util/index.ts +16 -3
  125. package/src/util/paths.ts +10 -0
  126. package/src/util/refusal-notice.ts +111 -0
  127. package/src/util/report.ts +31 -0
  128. package/src/util/settle.ts +49 -30
  129. package/src/util/size.ts +23 -4
  130. package/src/util/timing.ts +3 -2
  131. package/src/util/ui.ts +186 -0
  132. package/src/util/ulid.ts +1 -1
  133. package/src/util/verbs.ts +0 -15
  134. package/src/workspace/affected.ts +84 -232
  135. package/src/workspace/config-eval.ts +15 -20
  136. package/src/workspace/config-imports.ts +78 -3
  137. package/src/workspace/config-schema.ts +209 -268
  138. package/src/workspace/dependency-rules.ts +65 -0
  139. package/src/workspace/filter.ts +2 -2
  140. package/src/workspace/fingerprint.ts +11 -121
  141. package/src/workspace/index.ts +20 -27
  142. package/src/workspace/load-reads.ts +4 -4
  143. package/src/workspace/lockfile.ts +96 -140
  144. package/src/workspace/migration.ts +307 -215
  145. package/src/workspace/package-graph.ts +30 -216
  146. package/src/workspace/project-loader.ts +80 -285
  147. package/src/workspace/repo-id.ts +2 -1
  148. package/src/workspace/workspace.ts +363 -831
  149. package/src/cache/config-evals.ts +0 -130
  150. package/src/workspace/config-cache.ts +0 -661
  151. package/src/workspace/migrate-scripts.ts +0 -1121
@@ -25,7 +25,7 @@ import type {
25
25
  } from './plugin.js'
26
26
 
27
27
  export interface LockfileClaimOptions {
28
- /** The root-relative lockfile name, one of `WORKSPACE_FINGERPRINT_FILES`. */
28
+ /** The lockfile's name at the workspace root. */
29
29
  readonly file: string
30
30
  /**
31
31
  * Every project's digest from the file's text: importer directory
@@ -46,8 +46,7 @@ export interface LockfileClaimOptions {
46
46
  readonly version: number
47
47
  /**
48
48
  * `'project'` (default): each task folds its own project's digest.
49
- * `'workspace'`: the whole file's hash into every task — the coarse key
50
- * core would fold, through the plugin.
49
+ * `'workspace'`: the whole file's hash into every task — the coarse key.
51
50
  */
52
51
  readonly scope?: 'project' | 'workspace'
53
52
  /**
@@ -55,18 +54,46 @@ export interface LockfileClaimOptions {
55
54
  * (`@vzn/vx-lockfile/pnpm`). Default `'deps'`.
56
55
  */
57
56
  readonly part?: string
57
+ /**
58
+ * Each importer's links to other importers, from the same text, kept in
59
+ * the memo beside the digests so a `discover` hook reads them without a
60
+ * parse. Called once per distinct content, under either scope.
61
+ */
62
+ readonly importers?: (text: string) => ReadonlyMap<string, LockfileImporter>
58
63
  }
59
64
 
60
- /** The two hooks a lockfile plugin spreads into `definePlugin`. */
65
+ /**
66
+ * What a lockfile records of one importer's links to the workspace's other
67
+ * importers: dependency name → importer directory (root-relative POSIX,
68
+ * `.` for the root).
69
+ */
70
+ export interface LockfileImporter {
71
+ /** The entries it installs that resolve to an importer. */
72
+ readonly deps: Readonly<Record<string, string>>
73
+ /** Its peers that resolve to an importer, each with no installed entry of the same name. */
74
+ readonly peers: Readonly<Record<string, string>>
75
+ }
76
+
77
+ /** What a lockfile plugin spreads into `definePlugin` (`fingerprint`, `key`), and `importers` for its own stages. */
61
78
  export interface LockfileClaimHooks {
62
79
  readonly fingerprint: FingerprintClaim
63
80
  key(task: TaskNode, ctx: KeyHookContext): Promise<Readonly<Record<string, string>> | undefined>
81
+ /**
82
+ * `importers`' answer for the file as it is now, through the same memo
83
+ * and gates as `key`; undefined when the file is absent. Not a hook: a
84
+ * plugin calls it from its own `discover` or `project`.
85
+ */
86
+ importers(ctx: {
87
+ readonly workspaceRoot: string
88
+ readonly cacheDir: string
89
+ }): Promise<ReadonlyMap<string, LockfileImporter> | undefined>
64
90
  }
65
91
 
66
92
  interface Digests {
67
93
  /** xxh3 of the lockfile bytes; '' when the file is absent. */
68
94
  readonly lock: string
69
95
  readonly importers: ReadonlyMap<string, string>
96
+ readonly edges: ReadonlyMap<string, LockfileImporter>
70
97
  }
71
98
 
72
99
  interface Memo {
@@ -76,6 +103,7 @@ interface Memo {
76
103
  /** `extraFiles` → content hash when the digests were computed. */
77
104
  extras?: Record<string, string>
78
105
  importers: Record<string, string>
106
+ edges?: Record<string, LockfileImporter>
79
107
  }
80
108
 
81
109
  /** A root-relative extra file's content hash, '' when absent; a path leaving the root is never read. */
@@ -125,7 +153,16 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
125
153
  // at the same number read this one's memo: one that named no extra file
126
154
  // left the other's patch edits out of its key (X-130). The functions'
127
155
  // source stands for the claimant; a rebuild that changes it is a miss.
128
- const claimant = xxh3hex([part, String(digest), String(options.extraFiles ?? '')].join('\0'))
156
+ // The scope too: a workspace-scope memo holds no digests.
157
+ const claimant = xxh3hex(
158
+ [
159
+ part,
160
+ scope,
161
+ String(digest),
162
+ String(options.extraFiles ?? ''),
163
+ String(options.importers ?? ''),
164
+ ].join('\0'),
165
+ )
129
166
  if (scope !== 'project' && scope !== 'workspace') {
130
167
  throw new Error(`scope must be 'project' or 'workspace', not ${JSON.stringify(scope)}`)
131
168
  }
@@ -149,7 +186,7 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
149
186
  try {
150
187
  identity = identityOf(await stat(full))
151
188
  } catch {
152
- const digests: Digests = { lock: '', importers: new Map() }
189
+ const digests: Digests = { lock: '', importers: new Map(), edges: new Map() }
153
190
  seen.set(workspaceRoot, { identity: 'gone', extras: [], extraStats: {}, digests })
154
191
  return digests
155
192
  }
@@ -169,12 +206,14 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
169
206
  let extras: Record<string, string> = {}
170
207
  let extraStats: Record<string, string> = {}
171
208
  let importers: ReadonlyMap<string, string> | undefined
209
+ let edges: ReadonlyMap<string, LockfileImporter> = new Map()
172
210
  if (memo !== undefined) {
173
211
  const now = await hashExtras(workspaceRoot, Object.keys(memo.extras))
174
212
  if (Object.entries(memo.extras).every(([rel, h]) => now.hashes[rel] === h)) {
175
213
  extras = memo.extras
176
214
  extraStats = now.stats
177
215
  importers = memo.importers
216
+ edges = memo.edges
178
217
  }
179
218
  }
180
219
  if (importers === undefined) {
@@ -186,19 +225,19 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
186
225
  importers =
187
226
  scope === 'workspace'
188
227
  ? new Map<string, string>()
189
- : await computeAndMemo(
190
- memoFile,
191
- { version, claimant, lock: lockHash },
192
- extras,
193
- digest(text, new Map(Object.entries(extras))),
194
- )
228
+ : digest(text, new Map(Object.entries(extras)))
229
+ edges = options.importers?.(text) ?? new Map()
230
+ // Workspace scope with no edges to keep has nothing a memo saves.
231
+ if (scope === 'project' || options.importers !== undefined) {
232
+ await writeMemo(memoFile, { version, claimant, lock: lockHash }, extras, importers, edges)
233
+ }
195
234
  }
196
235
  const rels = Object.keys(extras)
197
236
  const lock =
198
237
  rels.length === 0
199
238
  ? lockHash
200
239
  : xxh3hex([lockHash, ...rels.map((r) => `${r}\0${extras[r]}`)].join('\0'))
201
- const digests: Digests = { lock, importers }
240
+ const digests: Digests = { lock, importers, edges }
202
241
  seen.set(workspaceRoot, { identity, extras: rels, extraStats, digests })
203
242
  return digests
204
243
  }
@@ -216,7 +255,7 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
216
255
  files: [file],
217
256
  affected(change: FingerprintChange, ctx: FingerprintContext) {
218
257
  // One side absent: the file appeared or went — every project's
219
- // installed tree is in question, and core's answer (all) is right.
258
+ // installed tree is in question.
220
259
  if (change.before === null || change.after === null) return undefined
221
260
  if (scope === 'workspace') return undefined
222
261
  // WHICH side failed is the whole difference between "your
@@ -248,6 +287,10 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
248
287
  [part]: digestFor(digests.importers, importerOf(ctx.workspaceRoot, task.projectDir)),
249
288
  }
250
289
  },
290
+ async importers(ctx) {
291
+ const digests = await load(ctx.workspaceRoot, ctx.cacheDir)
292
+ return digests.lock === '' ? undefined : digests.edges
293
+ },
251
294
  }
252
295
  }
253
296
 
@@ -280,25 +323,42 @@ async function readMemo(
280
323
  version: number,
281
324
  claimant: string,
282
325
  lock: string,
283
- ): Promise<{ extras: Record<string, string>; importers: Map<string, string> } | undefined> {
326
+ ): Promise<
327
+ | {
328
+ extras: Record<string, string>
329
+ importers: Map<string, string>
330
+ edges: Map<string, LockfileImporter>
331
+ }
332
+ | undefined
333
+ > {
284
334
  try {
285
335
  const memo = (await Bun.file(memoFile).json()) as Memo
286
336
  if (memo.version !== version || memo.claimant !== claimant || memo.lock !== lock) {
287
337
  return undefined
288
338
  }
289
- return { extras: memo.extras ?? {}, importers: new Map(Object.entries(memo.importers)) }
339
+ return {
340
+ extras: memo.extras ?? {},
341
+ importers: new Map(Object.entries(memo.importers)),
342
+ edges: new Map(Object.entries(memo.edges ?? {})),
343
+ }
290
344
  } catch {
291
345
  return undefined
292
346
  }
293
347
  }
294
348
 
295
- async function computeAndMemo(
349
+ async function writeMemo(
296
350
  memoFile: string,
297
351
  identity: Pick<Memo, 'version' | 'claimant' | 'lock'>,
298
352
  extras: Record<string, string>,
299
353
  importers: ReadonlyMap<string, string>,
300
- ): Promise<ReadonlyMap<string, string>> {
301
- const memo: Memo = { ...identity, extras, importers: Object.fromEntries(importers) }
354
+ edges: ReadonlyMap<string, LockfileImporter>,
355
+ ): Promise<void> {
356
+ const memo: Memo = {
357
+ ...identity,
358
+ extras,
359
+ importers: Object.fromEntries(importers),
360
+ ...(edges.size > 0 ? { edges: Object.fromEntries(edges) } : {}),
361
+ }
302
362
  // Write-then-rename: a reader never sees a half-written memo, and two
303
363
  // concurrent runs each land a whole one.
304
364
  const tmp = `${memoFile}.tmp-${process.pid}-${Date.now()}`
@@ -309,7 +369,6 @@ async function computeAndMemo(
309
369
  // The memo is a speed-up, never a requirement: the digests are
310
370
  // computed either way, and the next run computes them again.
311
371
  }
312
- return importers
313
372
  }
314
373
 
315
374
  function decode(bytes: Uint8Array): string {
@@ -1,5 +1,5 @@
1
1
  import type { TaskNode, TaskOutcome } from '../graph/index.js'
2
- import { detectColors, type ColorSupport } from './colors.js'
2
+ import { settingEnv } from '../settings.js'
3
3
  import {
4
4
  formatFrameClose,
5
5
  formatFrameOpen,
@@ -20,7 +20,7 @@ import {
20
20
  type StatusStream,
21
21
  type WorkerSlot,
22
22
  } from './status-line.js'
23
- import { formatSummarySection, neverStarted, type RunContext } from './summary.js'
23
+ import { formatSummarySection, neverStarted, ranUnsaved, type RunContext } from './summary.js'
24
24
  import { isGroupTask } from '../graph/index.js'
25
25
  import {
26
26
  appendTail,
@@ -29,6 +29,8 @@ import {
29
29
  resetTail,
30
30
  tailText,
31
31
  type Tail,
32
+ detectColors,
33
+ type ColorSupport,
32
34
  } from '../util/index.js'
33
35
  import { isCacheHit } from './telemetry.js'
34
36
  import { failedLabel, outcomeWord, ranNoCache } from './events.js'
@@ -177,7 +179,10 @@ export function resolveOutputView(
177
179
  ...(gha ? { gha: true } : {}),
178
180
  ...(ci ? { ci: true } : {}),
179
181
  })
180
- if (options.outputLogs !== undefined) return mk(options.outputLogs)
182
+ // `run.outputLogs`: chosen before the workspace loads, so flag, then variable.
183
+ const outputLogs =
184
+ options.outputLogs ?? (settingEnv('run.outputLogs', env) as OutputView['mode'] | undefined)
185
+ if (outputLogs !== undefined) return mk(outputLogs)
181
186
  if (ci) return mk('full')
182
187
  if (options.flow !== undefined) return mk(options.flow)
183
188
  return mk('full')
@@ -198,6 +203,8 @@ export function defaultLogger(
198
203
  tty?: boolean
199
204
  /** The args after `--`: a requested task's `$ <command>` line shows them, as it ran. */
200
205
  forwardArgs?: readonly string[]
206
+ /** `false`: no summary in the live region and no blank line opening the log (`RunOptions.footer`). */
207
+ footer?: boolean
201
208
  /** The environment that names the terminal (`TERM_PROGRAM`, …); `process.env` by default. */
202
209
  env?: Readonly<Record<string, string | undefined>>
203
210
  } = {},
@@ -295,6 +302,7 @@ export function defaultLogger(
295
302
  let etaMs: number | undefined
296
303
  let critical: string | undefined
297
304
  let blocks: ReadonlyMap<string, number> | undefined
305
+ let expected: ReadonlyMap<string, number> | undefined
298
306
  let etaAtMs = 0
299
307
  const signals = writer.enabled
300
308
  ? terminalSignals(opts.env ?? process.env)
@@ -311,6 +319,10 @@ export function defaultLogger(
311
319
  // as `+k more` on the stats line. Focused flow keeps its lifecycle:
312
320
  // the region dies the moment a requested node starts streaming.
313
321
  let slots: (WorkerSlot | null)[] = []
322
+ // Rows shown: the most workers busy at once so far. An idle row past it
323
+ // was noise (a 10-core machine drew 9 on a one-chain run); it never
324
+ // shrinks, so a row never jumps.
325
+ let shownSlots = 0
314
326
  const slotQueue: WorkerSlot[] = []
315
327
  let succeeded = 0
316
328
  // Executed with no `cache` block: never a miss, counted apart.
@@ -318,6 +330,8 @@ export function defaultLogger(
318
330
  let upToDate = 0
319
331
  let restoredLocal = 0
320
332
  let restoredRemote = 0
333
+ let saved = 0
334
+ let notSaved = 0
321
335
  let skippedCount = 0
322
336
  let savedMs = 0
323
337
  // Cache-miss duration spread, accumulated incrementally (the same
@@ -375,16 +389,16 @@ export function defaultLogger(
375
389
  if (text.length < KEPT_LINE_CAP) held.rest = text
376
390
  else {
377
391
  held.rest = ''
378
- writer.write(fenced(formatKeptLines(node, text, colors)))
392
+ taskWrite(fenced(formatKeptLines(node, text, colors)))
379
393
  }
380
394
  return
381
395
  }
382
396
  held.rest = text.slice(cut + 1)
383
- writer.write(fenced(formatKeptLines(node, text.slice(0, cut), colors)))
397
+ taskWrite(fenced(formatKeptLines(node, text.slice(0, cut), colors)))
384
398
  }
385
399
  const flushKept = (): void => {
386
400
  for (const held of keptLines.values()) {
387
- if (held.rest.length > 0) writer.write(fenced(formatKeptLines(held.node, held.rest, colors)))
401
+ if (held.rest.length > 0) taskWrite(fenced(formatKeptLines(held.node, held.rest, colors)))
388
402
  held.rest = ''
389
403
  }
390
404
  }
@@ -404,6 +418,7 @@ export function defaultLogger(
404
418
  etaMs = f.etaMs
405
419
  critical = f.critical
406
420
  blocks = f.blocks
421
+ expected = f.expected
407
422
  }
408
423
  if (signals.progress && total > 0) {
409
424
  const elapsed = now - startedAtMs
@@ -421,38 +436,44 @@ export function defaultLogger(
421
436
  }
422
437
  // The live summary IS the final summary's section, built from the
423
438
  // same formatter so the region visually becomes the printout.
424
- const summaryLines = formatSummarySection(
425
- {
426
- failed,
427
- successful: succeeded + upToDate + restoredLocal + restoredRemote,
428
- skipped: skippedCount,
429
- total,
430
- upToDate,
431
- restoredLocal,
432
- restoredRemote,
433
- miss: succeeded + failed - noCache,
434
- noCache,
435
- savedMs,
436
- left: total - done,
437
- ...(etaMs !== undefined ? { etaMs } : {}),
438
- spread:
439
- spreadCount > 0
440
- ? { maxMs: spreadMax, minMs: spreadMin, sumMs: spreadSum, count: spreadCount }
441
- : null,
442
- },
443
- Date.now() - startedAtMs,
444
- colors,
445
- runContext,
446
- )
439
+ const summaryLines =
440
+ opts.footer === false
441
+ ? []
442
+ : formatSummarySection(
443
+ {
444
+ failed,
445
+ successful: succeeded + upToDate + restoredLocal + restoredRemote,
446
+ skipped: skippedCount,
447
+ total,
448
+ upToDate,
449
+ restoredLocal,
450
+ restoredRemote,
451
+ saved,
452
+ notSaved,
453
+ miss: succeeded + failed - noCache,
454
+ noCache,
455
+ savedMs,
456
+ left: total - done,
457
+ ...(etaMs !== undefined ? { etaMs } : {}),
458
+ spread:
459
+ spreadCount > 0
460
+ ? { maxMs: spreadMax, minMs: spreadMin, sumMs: spreadSum, count: spreadCount }
461
+ : null,
462
+ },
463
+ Date.now() - startedAtMs,
464
+ colors,
465
+ runContext,
466
+ )
447
467
  writer.setRegion(
448
468
  formatStatusRegion(
449
469
  {
450
470
  pinnedPersistent,
451
- slots,
471
+ slots: slots.slice(0, shownSlots),
452
472
  overflow: slotQueue.length,
453
473
  nowMs: Date.now(),
454
474
  summaryLines,
455
475
  ...(blocks !== undefined ? { blocks } : {}),
476
+ ...(expected !== undefined ? { expected } : {}),
456
477
  ...(critical !== undefined ? { critical } : {}),
457
478
  },
458
479
  colors,
@@ -478,8 +499,21 @@ export function defaultLogger(
478
499
  writer.clearStatus()
479
500
  }
480
501
 
502
+ // The task log opens with one blank line before its first row, on a
503
+ // terminal and off one (M, 2026-10-14). A note vx prints before it is
504
+ // no task row (`status`). Without a footer, the caller frames the log
505
+ // (`vx watch` opens each cycle with its own line).
506
+ let logOpened = opts.footer === false
507
+ const taskWrite = (chunk: string): void => {
508
+ if (!logOpened && chunk.length > 0) {
509
+ logOpened = true
510
+ writer.write(`\n${chunk}`)
511
+ return
512
+ }
513
+ writer.write(chunk)
514
+ }
481
515
  const emitLine = (line: string): void => {
482
- writer.write(`${line}\n`)
516
+ taskWrite(`${line}\n`)
483
517
  lineEmitted = true
484
518
  }
485
519
  // formatTaskBlock returns '' for group tasks (no exec) — skip the
@@ -487,14 +521,14 @@ export function defaultLogger(
487
521
  const emitBlock = (block: string): void => {
488
522
  if (block.length === 0) return
489
523
  const lead = lineEmitted || streamedSinceBlock ? '\n' : ''
490
- writer.write(`${lead}${block}\n`)
524
+ taskWrite(`${lead}${block}\n`)
491
525
  lineEmitted = false
492
526
  streamedSinceBlock = false
493
527
  }
494
528
  // Live-frame close lines end a frame the same way emitBlock does:
495
529
  // with a trailing blank line, resetting the separator state.
496
530
  const emitFrameClose = (line: string): void => {
497
- writer.write(`${line}\n\n`)
531
+ taskWrite(`${line}\n\n`)
498
532
  lineEmitted = false
499
533
  streamedSinceBlock = false
500
534
  }
@@ -549,6 +583,7 @@ export function defaultLogger(
549
583
  startedAtMs = Date.now()
550
584
  const cap = Math.max(1, Math.min(info.concurrency ?? 10, 10))
551
585
  slots = Array.from({ length: cap }, () => null)
586
+ shownSlots = 0
552
587
  if (writer.enabled && !statusDead && ticker === null) {
553
588
  // Keeps the per-worker elapsed time ticking between task events
554
589
  // (the time IS the motion — there's no spinner). The writer
@@ -585,8 +620,10 @@ export function defaultLogger(
585
620
  }
586
621
  const slot: WorkerSlot = { id: node.id, startedMs: Date.now() }
587
622
  const free = slots.indexOf(null)
588
- if (free >= 0) slots[free] = slot
589
- else slotQueue.push(slot)
623
+ if (free >= 0) {
624
+ slots[free] = slot
625
+ shownSlots = Math.max(shownSlots, free + 1)
626
+ } else slotQueue.push(slot)
590
627
  refresh(true)
591
628
  },
592
629
  forecast(load) {
@@ -675,7 +712,7 @@ export function defaultLogger(
675
712
  if (streamsLive(node)) {
676
713
  streamedSinceBlock = true
677
714
  if (chunk.length > 0) streamMidLine = !chunk.endsWith('\n')
678
- writer.write(chunk)
715
+ taskWrite(chunk)
679
716
  return
680
717
  }
681
718
  const tail = persistentTails.get(node.id)
@@ -691,7 +728,7 @@ export function defaultLogger(
691
728
  if (streamsLive(node)) {
692
729
  streamedSinceBlock = true
693
730
  if (chunk.length > 0) streamMidLine = !chunk.endsWith('\n')
694
- writer.write(chunk)
731
+ taskWrite(chunk)
695
732
  return
696
733
  }
697
734
  const tail = persistentTails.get(node.id)
@@ -747,7 +784,7 @@ export function defaultLogger(
747
784
  (framesTerminal && holdsTerminal(node)))
748
785
  if (framed && streamsLive(node)) {
749
786
  if (streamMidLine) {
750
- writer.write('\n')
787
+ taskWrite('\n')
751
788
  streamMidLine = false
752
789
  }
753
790
  emitFrameClose(formatFrameClose(node, outcome, colors))
@@ -804,6 +841,8 @@ export function defaultLogger(
804
841
  case 'success':
805
842
  succeeded++
806
843
  if (ranNoCache(outcome)) noCache++
844
+ if (outcome.saved === true) saved++
845
+ else if (ranUnsaved(outcome)) notSaved++
807
846
  break
808
847
  case 'cache-hit':
809
848
  if (outcome.restored === false) upToDate++
@@ -830,7 +869,7 @@ export function defaultLogger(
830
869
  if (isGroupTask(node)) return
831
870
  if (holdsTerminal(node) && streamsLive(node) && outcome.status !== 'skipped') {
832
871
  if (streamMidLine) {
833
- writer.write('\n')
872
+ taskWrite('\n')
834
873
  streamMidLine = false
835
874
  }
836
875
  emitFrameClose(formatFrameClose(node, outcome, colors))
@@ -908,7 +947,7 @@ export function defaultLogger(
908
947
  // and up-to-date included (owner: "always full frame
909
948
  // for a single task").
910
949
  if (streamMidLine) {
911
- writer.write('\n')
950
+ taskWrite('\n')
912
951
  streamMidLine = false
913
952
  }
914
953
  emitFrameClose(formatFrameClose(node, outcome, colors))
@@ -1003,7 +1042,7 @@ export function defaultLogger(
1003
1042
  // The message is ours (a literal, an integer and a signal
1004
1043
  // name); only the title carries user input.
1005
1044
  emitBlock(
1006
- `::error title=${ghaProperty(node.id)}::${failedLabel(outcome.exitCode, outcome.timedOut, outcome.sandboxViolations, outcome.notReady)}\n${body}`,
1045
+ `::error title=${ghaProperty(node.id)}::${failedLabel(outcome.exitCode, outcome.timedOut, outcome.sandboxViolations, outcome.notReady, outcome.inputMoved)}\n${body}`,
1007
1046
  )
1008
1047
  } else {
1009
1048
  emitBlock(
@@ -1,15 +1,18 @@
1
1
  // Run-history queries — pure functions over a `bun:sqlite` Database.
2
2
  //
3
3
  // `vx why` and `vx last` read through them, and so can any out-of-process
4
- // surface (an MCP server plugin, a dashboard). One canonical home for every
4
+ // surface. One canonical home for every
5
5
  // aggregate over the runs / entries tables. The schema itself is owned by
6
6
  // src/cache/cache.ts.
7
7
  //
8
- // Pure SQL + JSON-safe return shapes. No Cache lifecycle here; the
8
+ // Pure SQL + JSON-safe return shapes (`inputWriters` also stats the
9
+ // files it names). No Cache lifecycle here; the
9
10
  // caller opens and closes. bigints are serialized as decimal strings
10
11
  // for JSON compatibility (matches the WireEvent timeUnixNano rule).
11
12
 
12
13
  import type { Database } from 'bun:sqlite'
14
+ import { statSync } from 'node:fs'
15
+ import path from 'node:path'
13
16
  // The keyed-run predicate lives beside the schema in cache/cache.ts, so a
14
17
  // rule written once cannot drift.
15
18
  import { KEYED_RUNS_SQL } from '../cache/index.js'
@@ -18,6 +21,10 @@ import { clampInt, MASKED, secretNamed } from '../util/index.js'
18
21
  import type { TaskInputComponent } from './task-hash.js'
19
22
  import { isPassStatus } from './telemetry.js'
20
23
 
24
+ // A recorded `cache_policy` is the `--cache-policy` spec (`local:rw,remote:r`).
25
+ const readsCache = (spec: string): boolean => /(?:^|,)(?:local|remote):w?r/.test(spec)
26
+ const writesCache = (spec: string): boolean => /(?:^|,)(?:local|remote):r?w/.test(spec)
27
+
21
28
  // ---------------------------------------------------------------------------
22
29
  // Run listing + detail
23
30
  // ---------------------------------------------------------------------------
@@ -409,8 +416,8 @@ function unchangedKeyNote(
409
416
  p: string
410
417
  } | null
411
418
  )?.p
412
- if (policy !== undefined && !policy.split(',').some((axis) => axis === 'lR' || axis === 'rR')) {
413
- return 'cache key unchanged — re-executed because this run did not read the cache (--force, or a --cache without read)'
419
+ if (policy !== undefined && !readsCache(policy)) {
420
+ return 'cache key unchanged — re-executed because this run did not read the cache (--force, or a --cache-policy without read)'
414
421
  }
415
422
  // `get` answers null, not undefined, for no row.
416
423
  const createdAt = (
@@ -435,7 +442,7 @@ function unchangedKeyNote(
435
442
  const row = db
436
443
  .query('SELECT cache_policy AS p, failed_count AS f FROM invocations WHERE run_id = ?')
437
444
  .get(id) as { p: string; f: number } | null
438
- const writes = row?.p.split(',').some((axis) => axis === 'lW' || axis === 'rW') === true
445
+ const writes = row !== null && writesCache(row.p)
439
446
  return writes ? (row?.f ?? 0) : 0
440
447
  }
441
448
  const before = failedWithWrites(prev.runId)
@@ -469,8 +476,8 @@ function unchangedKeyNote(
469
476
  p: string
470
477
  } | null
471
478
  )?.p
472
- if (prevPolicy !== undefined && !prevPolicy.split(',').some((a) => a === 'lW' || a === 'rW')) {
473
- return 'cache key unchanged — the previous run on this key did not write the cache (--no-cache, or a --cache without write), so there was nothing to hit'
479
+ if (prevPolicy !== undefined && !writesCache(prevPolicy)) {
480
+ return 'cache key unchanged — the previous run on this key did not write the cache (--no-cache, or a --cache-policy without write), so there was nothing to hit'
474
481
  }
475
482
  return 'cache key unchanged — the previous run on this key executed but no entry for it is in the cache (its save failed, or it was pruned since), so there was nothing to hit'
476
483
  }
@@ -479,8 +486,7 @@ function unchangedKeyNote(
479
486
 
480
487
  /**
481
488
  * The latest recorded run of a task (run_id may be NULL on very old rows).
482
- * One query for `vx why` and `vx mcp`: the CLI defaulted to it while the
483
- * tool demanded a run id an agent had to fetch first (2026-09-16).
489
+ * `vx why`'s default when no run id is given.
484
490
  */
485
491
  export function latestRunId(db: Database, taskId: string): string | null {
486
492
  const [project, task] = splitTaskId(taskId)
@@ -875,6 +881,69 @@ export function rootCauses(
875
881
  return roots
876
882
  }
877
883
 
884
+ export interface InputWriter {
885
+ /** The task whose key read the file. */
886
+ taskId: string
887
+ /** Workspace-relative, as the key names it. */
888
+ file: string
889
+ /** The tasks that were running when the file last changed. */
890
+ writers: string[]
891
+ }
892
+
893
+ /**
894
+ * Slack around a task's recorded window: its edges are rounded to the
895
+ * millisecond and a write lands just before the process exits.
896
+ */
897
+ const WRITE_SLACK_MS = 5
898
+
899
+ /**
900
+ * For each moved input file of `taskId` and of each root cause, the
901
+ * recorded tasks that were running when the file last changed: a task
902
+ * that writes a file another key reads re-keys that task on every run.
903
+ * The file's mtime against the recorded windows, so
904
+ * a writer that runs each time is named from its latest run, and an
905
+ * edit made outside a run names none.
906
+ */
907
+ export function inputWriters(
908
+ db: Database,
909
+ workspaceRoot: string,
910
+ taskId: string,
911
+ entries: CacheKeyDiff['entries'],
912
+ roots: readonly RootCause[],
913
+ ): InputWriter[] {
914
+ const during = db.query(
915
+ `SELECT project, task FROM runs
916
+ WHERE started_at <= ?1 + ${WRITE_SLACK_MS} AND ended_at >= ?1 - ${WRITE_SLACK_MS}
917
+ AND status != 'skipped'
918
+ GROUP BY project, task ORDER BY MIN(started_at) LIMIT 3`,
919
+ )
920
+ const out: InputWriter[] = []
921
+ const owners = [
922
+ { taskId, entries },
923
+ ...roots.map((r) => ({ taskId: r.chain.at(-1)!, entries: r.entries })),
924
+ ]
925
+ for (const owner of owners) {
926
+ for (const e of owner.entries) {
927
+ if (e.kind !== 'file' || e.change === 'removed') continue
928
+ let mtime: number
929
+ try {
930
+ mtime = statSync(path.join(workspaceRoot, e.name)).mtimeMs
931
+ } catch {
932
+ continue
933
+ }
934
+ const rows = during.all(mtime) as { project: string; task: string }[]
935
+ if (rows.length > 0) {
936
+ out.push({
937
+ taskId: owner.taskId,
938
+ file: e.name,
939
+ writers: rows.map((r) => `${r.project}#${r.task}`),
940
+ })
941
+ }
942
+ }
943
+ }
944
+ return out
945
+ }
946
+
878
947
  // ---------------------------------------------------------------------------
879
948
  // Run comparison — diff a run against the immediately-previous invocation
880
949
  // ---------------------------------------------------------------------------
@@ -81,6 +81,8 @@ export interface SaveMissArgs {
81
81
  ownOutputFiles?: string[] | undefined
82
82
  /** `ownOutputFiles` for `cache.outputs.workspaceFiles` (A-43). */
83
83
  ownWsOutputFiles?: string[] | undefined
84
+ /** A sandboxed task's reach: a symlinked output it could not read is refused (`sandboxReadable`). */
85
+ readable?: ((real: string) => boolean) | undefined
84
86
  /** The Tier-3 input fingerprint rows captured by the pre-exec describe. */
85
87
  captured: readonly TaskInputComponent[]
86
88
  command: string
@@ -176,6 +178,7 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
176
178
  ? { workspaceOutputFiles: wsOutputFiles, workspaceRoot: a.workspaceRoot }
177
179
  : {}),
178
180
  inputComponents: a.captured.map((c) => ({ entryHash: a.hash, ...c })),
181
+ readable: a.readable,
179
182
  // No `exitCode`: the save only runs under `effectiveExitCode === 0`, and
180
183
  // the contract no longer accepts one — so the invariant is enforced by
181
184
  // the type rather than by every call site remembering the gate.