@vzn/vx 0.0.18 → 0.0.22

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 (149) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -70
  3. package/package.json +10 -20
  4. package/src/bin.ts +64 -5
  5. package/src/cache/archive.ts +279 -28
  6. package/src/cache/cache.ts +952 -1584
  7. package/src/cache/chained-cache.ts +14 -10
  8. package/src/cache/config-evals.ts +112 -0
  9. package/src/cache/file-hashes.ts +290 -0
  10. package/src/cache/git-inputs.ts +966 -0
  11. package/src/cache/index.ts +23 -7
  12. package/src/cache/inputs.ts +553 -739
  13. package/src/cache/key-fold.ts +232 -0
  14. package/src/cache/layer.ts +674 -0
  15. package/src/cache/layered-cache.ts +211 -49
  16. package/src/cache/output-index.ts +323 -0
  17. package/src/cache/policy.ts +84 -0
  18. package/src/cache/run-history.ts +152 -0
  19. package/src/cache/zstd.ts +183 -0
  20. package/src/cli/cache.ts +88 -30
  21. package/src/cli/completions.ts +104 -0
  22. package/src/cli/core-alias.ts +25 -0
  23. package/src/cli/format.ts +4 -15
  24. package/src/cli/help.ts +76 -38
  25. package/src/cli/index.ts +82 -17
  26. package/src/cli/info.ts +162 -91
  27. package/src/cli/init.ts +87 -0
  28. package/src/cli/last.ts +130 -23
  29. package/src/cli/lock.ts +36 -18
  30. package/src/cli/plan-format.ts +18 -7
  31. package/src/cli/plugin-commands.ts +34 -12
  32. package/src/cli/run.ts +138 -313
  33. package/src/cli/select.ts +342 -0
  34. package/src/cli/show.ts +151 -71
  35. package/src/cli/upgrade.ts +136 -15
  36. package/src/cli/watch.ts +1175 -116
  37. package/src/cli/why.ts +51 -29
  38. package/src/cli/workspace-config.ts +138 -0
  39. package/src/config.ts +183 -126
  40. package/src/exec/env.ts +11 -0
  41. package/src/exec/executor.ts +62 -31
  42. package/src/exec/index.ts +16 -2
  43. package/src/exec/kill-tree.ts +281 -0
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +361 -77
  46. package/src/exec/sandbox-binds.ts +211 -0
  47. package/src/exec/sandbox-paths.ts +70 -0
  48. package/src/exec/sandbox-runtime.ts +1152 -415
  49. package/src/exec/sandbox-violations.ts +293 -0
  50. package/src/graph/dependency-spec.ts +14 -14
  51. package/src/graph/index.ts +6 -4
  52. package/src/graph/priorities.ts +130 -0
  53. package/src/graph/scheduler.ts +332 -329
  54. package/src/graph/task-graph.ts +552 -188
  55. package/src/index.ts +111 -102
  56. package/src/orchestrator/admission.ts +161 -0
  57. package/src/orchestrator/colors.ts +8 -3
  58. package/src/orchestrator/deferred-outputs.ts +25 -7
  59. package/src/orchestrator/doctor.ts +363 -0
  60. package/src/orchestrator/events.ts +111 -8
  61. package/src/orchestrator/excluded-keys.ts +155 -0
  62. package/src/orchestrator/execute-task.ts +450 -565
  63. package/src/orchestrator/failure-mode.ts +169 -28
  64. package/src/orchestrator/failure-recap.ts +121 -0
  65. package/src/orchestrator/fingerprint-watch.ts +89 -0
  66. package/src/orchestrator/framed-output.ts +121 -49
  67. package/src/orchestrator/history.ts +123 -104
  68. package/src/orchestrator/hit-restore.ts +285 -0
  69. package/src/orchestrator/index.ts +42 -5
  70. package/src/orchestrator/keyed-projects.ts +89 -0
  71. package/src/orchestrator/local-shortcircuit.ts +83 -23
  72. package/src/orchestrator/lockfile-claim.ts +401 -0
  73. package/src/orchestrator/logger.ts +128 -41
  74. package/src/orchestrator/metrics.ts +201 -72
  75. package/src/orchestrator/miss-save.ts +232 -0
  76. package/src/orchestrator/options.ts +63 -34
  77. package/src/orchestrator/persistent.ts +99 -0
  78. package/src/orchestrator/placement.ts +212 -0
  79. package/src/orchestrator/plan.ts +24 -11
  80. package/src/orchestrator/plugin-host.ts +200 -49
  81. package/src/orchestrator/plugin.ts +190 -14
  82. package/src/orchestrator/prepare.ts +168 -137
  83. package/src/orchestrator/projects.ts +270 -0
  84. package/src/orchestrator/remote-prefetch.ts +22 -2
  85. package/src/orchestrator/run-artifacts.ts +34 -3
  86. package/src/orchestrator/run-context.ts +20 -5
  87. package/src/orchestrator/run-lock.ts +349 -0
  88. package/src/orchestrator/run-records.ts +132 -0
  89. package/src/orchestrator/run-report.ts +6 -4
  90. package/src/orchestrator/run.ts +467 -611
  91. package/src/orchestrator/sandbox-request.ts +569 -0
  92. package/src/orchestrator/save-lane.ts +44 -0
  93. package/src/orchestrator/shell-verdict.ts +117 -0
  94. package/src/orchestrator/signals.ts +167 -0
  95. package/src/orchestrator/stable-keys.ts +259 -10
  96. package/src/orchestrator/status-line.ts +35 -2
  97. package/src/orchestrator/summary.ts +115 -25
  98. package/src/orchestrator/task-hash.ts +103 -21
  99. package/src/orchestrator/task-log-buffer.ts +15 -18
  100. package/src/orchestrator/telemetry-host.ts +27 -8
  101. package/src/orchestrator/telemetry.ts +71 -44
  102. package/src/orchestrator/upstream.ts +91 -45
  103. package/src/util/bun-version.ts +43 -0
  104. package/src/util/cgroup.ts +160 -0
  105. package/src/util/edit-distance.ts +49 -0
  106. package/src/util/errors.ts +86 -0
  107. package/src/util/hash.ts +10 -1
  108. package/src/util/index.ts +42 -5
  109. package/src/util/paths.ts +168 -5
  110. package/src/util/procfs.ts +20 -0
  111. package/src/util/settle.ts +23 -2
  112. package/src/util/size.ts +33 -0
  113. package/src/util/task-id.ts +20 -0
  114. package/src/util/timing.ts +11 -1
  115. package/src/util/verbs.ts +39 -0
  116. package/src/util/which.ts +48 -0
  117. package/src/workspace/affected.ts +368 -60
  118. package/src/workspace/config-cache.ts +271 -32
  119. package/src/workspace/config-eval.ts +60 -16
  120. package/src/workspace/config-imports.ts +131 -9
  121. package/src/workspace/config-schema.ts +1033 -0
  122. package/src/workspace/filter.ts +69 -17
  123. package/src/workspace/fingerprint.ts +59 -7
  124. package/src/workspace/index.ts +37 -3
  125. package/src/workspace/json-data.ts +90 -0
  126. package/src/workspace/load-reads.ts +32 -0
  127. package/src/workspace/lockfile.ts +41 -14
  128. package/src/workspace/migrate-scripts.ts +187 -0
  129. package/src/workspace/migration.ts +353 -0
  130. package/src/workspace/package-graph.ts +239 -31
  131. package/src/workspace/project-loader.ts +295 -815
  132. package/src/workspace/workspace.ts +266 -31
  133. package/plugins/local-cache/index.ts +0 -2
  134. package/plugins/local-executor/index.ts +0 -2
  135. package/plugins/schedule-history/index.ts +0 -2
  136. package/src/cache/cas-backend.ts +0 -115
  137. package/src/cache/digest.ts +0 -32
  138. package/src/cli/migrate-emit.ts +0 -21
  139. package/src/cli/migrate-nx.ts +0 -473
  140. package/src/cli/migrate-scripts.ts +0 -127
  141. package/src/cli/migrate-turbo.ts +0 -401
  142. package/src/cli/migrate.ts +0 -305
  143. package/src/cli/prune.ts +0 -291
  144. package/src/orchestrator/missing-plugin.ts +0 -9
  145. package/src/orchestrator/resources.ts +0 -45
  146. package/src/orchestrator/verify.ts +0 -174
  147. package/src/plugins/local-cache/index.ts +0 -11
  148. package/src/plugins/local-executor/index.ts +0 -45
  149. package/src/plugins/schedule-history/index.ts +0 -109
@@ -15,6 +15,8 @@ import type { Database } from 'bun:sqlite'
15
15
  import { KEYED_RUNS_SQL } from '../cache/index.js'
16
16
  import { splitTaskId } from '../graph/index.js'
17
17
  import { clampInt } from '../util/index.js'
18
+ import type { TaskInputComponent } from './task-hash.js'
19
+ import { isPassStatus } from './telemetry.js'
18
20
 
19
21
  // ---------------------------------------------------------------------------
20
22
  // Run listing + detail
@@ -30,6 +32,8 @@ export interface RunSummaryRow {
30
32
  startedAt: number
31
33
  endedAt: number
32
34
  cacheHit: boolean | null
35
+ /** Whether the task declared a `cache` block; null on rows older than the column. */
36
+ cached: boolean | null
33
37
  hash: string
34
38
  cpuMs: number | null
35
39
  peakRssBytes: number | null
@@ -37,6 +41,11 @@ export interface RunSummaryRow {
37
41
  // (bigints aren't JSON-safe); use BigInt or Number on the client.
38
42
  wallclockStartNs: string | null
39
43
  wallclockEndNs: string | null
44
+ /** Why the task failed or was skipped (v27); null where the reason does not apply. */
45
+ blockedBy: string | null
46
+ timedOut: boolean | null
47
+ sandboxViolations: number | null
48
+ notReady: 'timeout' | 'exited' | 'spawn' | null
40
49
  }
41
50
 
42
51
  export interface ListRunsArgs {
@@ -65,24 +74,38 @@ export function listRuns(db: Database, args: ListRunsArgs = {}): RunSummaryRow[]
65
74
  params.push(args.runId)
66
75
  }
67
76
  const clause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''
68
- type RawRow = Omit<RunSummaryRow, 'cacheHit' | 'wallclockStartNs' | 'wallclockEndNs'> & {
77
+ type RawRow = Omit<
78
+ RunSummaryRow,
79
+ 'cacheHit' | 'cached' | 'wallclockStartNs' | 'wallclockEndNs' | 'timedOut'
80
+ > & {
69
81
  cacheHit: number | null
70
- wallclockStartNs: bigint | null
71
- wallclockEndNs: bigint | null
82
+ cached: number | null
83
+ // NOT bigint: `bun:sqlite` hands these back as JS numbers (the handle
84
+ // does not set `safeIntegers`), so the annotation claimed a precision
85
+ // the read never had. Harmless in practice — these are ns RELATIVE to
86
+ // run t=0, and 2^53 ns is 104 days of run time — but a `bigint` here
87
+ // was simply false. `.toString()` reads the same on either.
88
+ wallclockStartNs: number | null
89
+ wallclockEndNs: number | null
90
+ timedOut: number | null
72
91
  }
73
92
  const rows = db
74
93
  .query(
75
94
  `SELECT run_id AS runId, project, task, status, exit_code AS exitCode,
76
95
  duration_ms AS durationMs, started_at AS startedAt, ended_at AS endedAt,
77
- cache_hit AS cacheHit, hash,
96
+ cache_hit AS cacheHit, cached, hash,
78
97
  cpu_ms AS cpuMs, peak_rss_bytes AS peakRssBytes,
79
- wallclock_start_ns AS wallclockStartNs, wallclock_end_ns AS wallclockEndNs
80
- FROM runs ${clause} ORDER BY started_at DESC LIMIT ?`,
98
+ wallclock_start_ns AS wallclockStartNs, wallclock_end_ns AS wallclockEndNs,
99
+ blocked_by AS blockedBy, timed_out AS timedOut,
100
+ sandbox_violations AS sandboxViolations, not_ready AS notReady
101
+ FROM runs ${clause} ORDER BY id DESC LIMIT ?`,
81
102
  )
82
103
  .all(...params, limit) as RawRow[]
83
104
  return rows.map((r) => ({
84
105
  ...r,
85
106
  cacheHit: r.cacheHit === null ? null : Boolean(r.cacheHit),
107
+ cached: r.cached === null ? null : Boolean(r.cached),
108
+ timedOut: r.timedOut === null ? null : Boolean(r.timedOut),
86
109
  wallclockStartNs: r.wallclockStartNs === null ? null : r.wallclockStartNs.toString(),
87
110
  wallclockEndNs: r.wallclockEndNs === null ? null : r.wallclockEndNs.toString(),
88
111
  }))
@@ -257,7 +280,7 @@ export function listInvocations(
257
280
  const rows = db
258
281
  .query(
259
282
  `SELECT ${INVOCATION_COLUMNS} FROM invocations ${clause}
260
- ORDER BY started_at DESC LIMIT ?`,
283
+ ORDER BY rowid DESC LIMIT ?`,
261
284
  )
262
285
  .all(...params, limit) as InvocationRawRow[]
263
286
  return rows.map(mapInvocation)
@@ -350,31 +373,91 @@ export interface WhyDidThisRerun {
350
373
  runId: string
351
374
  taskId: string
352
375
  found: boolean
353
- thisRun?: { hash: string; status: string; cacheHit: boolean | null; startedAt: number }
376
+ thisRun?: {
377
+ hash: string
378
+ status: string
379
+ cacheHit: boolean | null
380
+ /** Whether the task declared a `cache` block; null on rows older than the column. */
381
+ cached: boolean | null
382
+ startedAt: number
383
+ }
354
384
  previousRun?: { hash: string; status: string; cacheHit: boolean | null; startedAt: number } | null
355
385
  hashChanged?: boolean | null
356
386
  note: string
357
387
  }
358
388
 
359
- /** The three endings an UNCHANGED cache key can have. */
360
- function unchangedKeyNote(cacheHit: number | null): string {
361
- if (cacheHit === null) {
389
+ /**
390
+ * The endings an UNCHANGED cache key can have. A re-execution is told apart
391
+ * by the evidence the index holds: every one used to blame `--no-cache` /
392
+ * `--force`, when the previous run on the key had failed (a failure saves
393
+ * nothing) or the entry had been pruned, and the invocation recorded that
394
+ * no flag was passed (item 1009).
395
+ */
396
+ function unchangedKeyNote(
397
+ db: Database,
398
+ runId: string,
399
+ this_: { hash: string; cacheHit: number | null; startedAt: number },
400
+ prev: { status: string },
401
+ ): string {
402
+ if (this_.cacheHit === null) {
362
403
  return 'cache key unchanged — this run recorded no cache outcome, so whether it re-ran is unknown'
363
404
  }
364
- return cacheHit
365
- ? 'cache key unchanged — this run was served from cache, nothing re-ran'
366
- : 'cache key unchanged — re-executed on the same key (--no-cache / --force, or unrelated)'
405
+ if (this_.cacheHit) return 'cache key unchanged — this run was served from cache, nothing re-ran'
406
+ if (prev.status === 'failed') {
407
+ return 'cache key unchanged — the previous run on this key failed and saved nothing, so there was nothing to hit'
408
+ }
409
+ const policy = (
410
+ db.query('SELECT cache_policy AS p FROM invocations WHERE run_id = ?').get(runId) as {
411
+ p: string
412
+ } | null
413
+ )?.p
414
+ if (policy !== undefined && !policy.split(',').some((axis) => axis === 'lR' || axis === 'rR')) {
415
+ return 'cache key unchanged — re-executed because this run did not read the cache (--force, or a --cache without read)'
416
+ }
417
+ // `get` answers null, not undefined, for no row.
418
+ const createdAt = (
419
+ db.query('SELECT created_at AS at FROM entries WHERE hash = ?').get(this_.hash) as {
420
+ at: number
421
+ } | null
422
+ )?.at
423
+ if (createdAt !== undefined && createdAt >= this_.startedAt) {
424
+ return 'cache key unchanged — no entry for this key was in the cache when it ran (pruned or evicted), so it executed and saved one'
425
+ }
426
+ return 'cache key unchanged — re-executed on the same key (--no-cache / --force, or unrelated)'
427
+ }
428
+
429
+ /**
430
+ * The latest recorded run of a task (run_id may be NULL on very old rows).
431
+ * One query for `vx why` and `vx mcp`: the CLI defaulted to it while the
432
+ * tool demanded a run id an agent had to fetch first (2026-09-16).
433
+ */
434
+ export function latestRunId(db: Database, taskId: string): string | null {
435
+ const [project, task] = splitTaskId(taskId)
436
+ const row = db
437
+ .query(
438
+ `SELECT run_id AS runId FROM runs WHERE project = ? AND task = ?
439
+ ORDER BY id DESC LIMIT 1`,
440
+ )
441
+ .get(project, task) as { runId: string | null } | undefined
442
+ return row?.runId ?? null
367
443
  }
368
444
 
369
445
  export function whyDidThisRerun(db: Database, runId: string, taskId: string): WhyDidThisRerun {
370
446
  const [project, task] = splitTaskId(taskId)
371
447
  const this_ = db
372
448
  .query(
373
- `SELECT hash, status, cache_hit AS cacheHit, started_at AS startedAt
449
+ `SELECT id, hash, status, cache_hit AS cacheHit, cached, started_at AS startedAt
374
450
  FROM runs WHERE run_id = ? AND project = ? AND task = ?`,
375
451
  )
376
452
  .get(runId, project, task) as
377
- | { hash: string; status: string; cacheHit: number | null; startedAt: number }
453
+ | {
454
+ id: number
455
+ hash: string
456
+ status: string
457
+ cacheHit: number | null
458
+ cached: number | null
459
+ startedAt: number
460
+ }
378
461
  | undefined
379
462
  if (!this_) {
380
463
  return {
@@ -391,10 +474,10 @@ export function whyDidThisRerun(db: Database, runId: string, taskId: string): Wh
391
474
  const prev = db
392
475
  .query(
393
476
  `SELECT hash, status, cache_hit AS cacheHit, started_at AS startedAt
394
- FROM runs WHERE project = ? AND task = ? AND started_at < ? AND ${KEYED_RUNS_SQL}
395
- ORDER BY started_at DESC LIMIT 1`,
477
+ FROM runs WHERE project = ? AND task = ? AND id < ? AND ${KEYED_RUNS_SQL}
478
+ ORDER BY id DESC LIMIT 1`,
396
479
  )
397
- .get(project, task, this_.startedAt) as
480
+ .get(project, task, this_.id) as
398
481
  | { hash: string; status: string; cacheHit: number | null; startedAt: number }
399
482
  | undefined
400
483
  // …and this run must have one too, or there is nothing to compare.
@@ -403,24 +486,34 @@ export function whyDidThisRerun(db: Database, runId: string, taskId: string): Wh
403
486
  runId,
404
487
  taskId,
405
488
  found: true,
406
- thisRun: { ...this_, cacheHit: this_.cacheHit === null ? null : Boolean(this_.cacheHit) },
489
+ thisRun: {
490
+ hash: this_.hash,
491
+ status: this_.status,
492
+ cacheHit: this_.cacheHit === null ? null : Boolean(this_.cacheHit),
493
+ cached: this_.cached === null ? null : Boolean(this_.cached),
494
+ startedAt: this_.startedAt,
495
+ },
407
496
  previousRun: prev
408
497
  ? { ...prev, cacheHit: prev.cacheHit === null ? null : Boolean(prev.cacheHit) }
409
498
  : null,
410
499
  hashChanged: prev && !noKey ? prev.hash !== this_.hash : null,
411
500
  note: noKey
412
501
  ? 'this task recorded no cache key (skipped, or a persistent task) — nothing to compare'
413
- : prev && prev.hash !== this_.hash
414
- ? 'cache key changed between the previous run and this one (inputs differ)'
415
- : prev
416
- ? // An unchanged key has two very different endings, and calling
417
- // both a "re-run" answered the question wrong: a cache HIT did not
418
- // re-run at all, so blaming `--no-cache` named a cause that cannot
419
- // have applied. Only a run that EXECUTED on an unchanged key is
420
- // the case this verb exists to explain. A row with no recorded
421
- // cacheHit (older rows) is neither — say that, do not guess.
422
- unchangedKeyNote(this_.cacheHit)
423
- : 'no prior run for this (project, task)',
502
+ : this_.cached === 0
503
+ ? // Not a cache decision at all: the task runs every time by design,
504
+ // and its key is derived only so dependents can fold it.
505
+ 'this task declares no `cache` block — it runs on every invocation; its key is folded by dependents only'
506
+ : prev && prev.hash !== this_.hash
507
+ ? 'cache key changed between the previous run and this one (inputs differ)'
508
+ : prev
509
+ ? // An unchanged key has two very different endings, and calling
510
+ // both a "re-run" answered the question wrong: a cache HIT did not
511
+ // re-run at all, so blaming `--no-cache` named a cause that cannot
512
+ // have applied. Only a run that EXECUTED on an unchanged key is
513
+ // the case this verb exists to explain. A row with no recorded
514
+ // cacheHit (older rows) is neither — say that, do not guess.
515
+ unchangedKeyNote(db, runId, this_, prev)
516
+ : 'no prior run for this (project, task)',
424
517
  }
425
518
  }
426
519
 
@@ -450,17 +543,55 @@ export interface CacheKeyDiff {
450
543
  note: string
451
544
  }
452
545
 
453
- interface EntryInputRow {
454
- kind: string
455
- name: string
456
- hash: string
546
+ function loadEntryInputs(db: Database, entryHash: string): TaskInputComponent[] {
547
+ return db
548
+ .query('SELECT kind, name, hash FROM entry_inputs WHERE entry_hash = ?')
549
+ .all(entryHash) as TaskInputComponent[]
457
550
  }
458
551
 
459
- function loadEntryInputs(db: Database, entryHash: string): Map<string, EntryInputRow> {
460
- const rows = db
461
- .query('SELECT kind, name, hash FROM entry_inputs WHERE entry_hash = ?')
462
- .all(entryHash) as EntryInputRow[]
463
- return new Map(rows.map((r) => [`${r.kind}\0${r.name}`, r]))
552
+ /**
553
+ * The rule `vx why` and the site's playground both name a moved key by: join
554
+ * two keys' components over `(kind, name)`. A component in both with a
555
+ * different hash is `changed`, one only in `after` is `added`, one only in
556
+ * `before` is `removed`, and an equal one is counted. Entries are ordered by
557
+ * kind, then name.
558
+ */
559
+ export function diffKeyComponents(
560
+ before: readonly TaskInputComponent[],
561
+ after: readonly TaskInputComponent[],
562
+ ): { entries: InputDiffEntry[]; unchangedCount: number } {
563
+ const id = (c: TaskInputComponent): string => `${c.kind}\0${c.name}`
564
+ const cur = new Map(after.map((c) => [id(c), c]))
565
+ const old = new Map(before.map((c) => [id(c), c]))
566
+ const entries: InputDiffEntry[] = []
567
+ let unchangedCount = 0
568
+ const keys = new Set<string>([...cur.keys(), ...old.keys()])
569
+ for (const key of keys) {
570
+ const a = cur.get(key)
571
+ const b = old.get(key)
572
+ if (a && b) {
573
+ if (a.hash === b.hash) unchangedCount++
574
+ else
575
+ entries.push({
576
+ kind: a.kind,
577
+ name: a.name,
578
+ change: 'changed',
579
+ before: b.hash,
580
+ after: a.hash,
581
+ })
582
+ } else if (a) {
583
+ entries.push({ kind: a.kind, name: a.name, change: 'added', before: null, after: a.hash })
584
+ } else if (b) {
585
+ entries.push({ kind: b.kind, name: b.name, change: 'removed', before: b.hash, after: null })
586
+ }
587
+ }
588
+ // Code-unit order, not `localeCompare`: collation follows the machine's
589
+ // locale, and the playground runs this same join in the reader's browser,
590
+ // so the CLI and the page could list one diff in two orders. Code units
591
+ // are also SQLite's BINARY order, the `entry_inputs` scan's own.
592
+ const byCodeUnit = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0)
593
+ entries.sort((x, y) => byCodeUnit(x.kind, y.kind) || byCodeUnit(x.name, y.name))
594
+ return { entries, unchangedCount }
464
595
  }
465
596
 
466
597
  /**
@@ -475,7 +606,8 @@ function loadEntryInputs(db: Database, entryHash: string): Map<string, EntryInpu
475
606
  * - only in the previous run → `removed`
476
607
  * - equal → counted as unchanged
477
608
  *
478
- * Pure SQL + an app-side set join — no config re-evaluation, no re-hash.
609
+ * Pure SQL + an app-side set join (`diffKeyComponents`) — no config
610
+ * re-evaluation, no re-hash.
479
611
  * Always returns a value (never throws); `found:false` when the run/task pair
480
612
  * has no row, `entries:[]` for the first run of a task.
481
613
  */
@@ -483,9 +615,11 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
483
615
  const [project, task] = splitTaskId(taskId)
484
616
  const this_ = db
485
617
  .query(
486
- 'SELECT hash, started_at AS startedAt FROM runs WHERE run_id = ? AND project = ? AND task = ?',
618
+ 'SELECT id, hash, cached, status, started_at AS startedAt FROM runs WHERE run_id = ? AND project = ? AND task = ?',
487
619
  )
488
- .get(runId, project, task) as { hash: string; startedAt: number } | undefined
620
+ .get(runId, project, task) as
621
+ | { id: number; hash: string; cached: number | null; status: string; startedAt: number }
622
+ | undefined
489
623
  if (!this_) {
490
624
  return {
491
625
  runId,
@@ -515,11 +649,13 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
515
649
 
516
650
  const prev = db
517
651
  .query(
518
- `SELECT run_id AS runId, hash FROM runs
519
- WHERE project = ? AND task = ? AND started_at < ? AND ${KEYED_RUNS_SQL}
520
- ORDER BY started_at DESC LIMIT 1`,
652
+ `SELECT run_id AS runId, hash, status FROM runs
653
+ WHERE project = ? AND task = ? AND id < ? AND ${KEYED_RUNS_SQL}
654
+ ORDER BY id DESC LIMIT 1`,
521
655
  )
522
- .get(project, task, this_.startedAt) as { runId: string | null; hash: string } | undefined
656
+ .get(project, task, this_.id) as
657
+ | { runId: string | null; hash: string; status: string }
658
+ | undefined
523
659
 
524
660
  if (!prev) {
525
661
  return {
@@ -551,7 +687,15 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
551
687
  // Input fingerprints are pruned with their entry (ON DELETE CASCADE); if
552
688
  // either side's rows are gone, we can name the hash change but not the
553
689
  // component-level diff.
554
- if (cur.size === 0 || old.size === 0) {
690
+ if (cur.length === 0 || old.length === 0) {
691
+ // A run that did not pass saved no entry, so it kept no fingerprints:
692
+ // that is the cause to name, not a prune (item 898).
693
+ const unsaved =
694
+ cur.length === 0 && !isPassStatus(this_.status)
695
+ ? `this run ended ${this_.status}`
696
+ : old.length === 0 && !isPassStatus(prev.status)
697
+ ? `the previous run ended ${prev.status}`
698
+ : null
555
699
  return {
556
700
  runId,
557
701
  taskId,
@@ -559,33 +703,18 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
559
703
  previousRunId: prev.runId,
560
704
  entries: [],
561
705
  unchangedCount: 0,
562
- note: 'cache key changed but input fingerprints are unavailable (entry pruned); only the hash change is known',
706
+ note:
707
+ this_.cached === 0
708
+ ? // The verdict already says the task is uncached; this line says
709
+ // what moved a key that declares nothing.
710
+ 'no `cache` block declares its inputs, so its key folds every file in its project — a file it wrote, or any untracked file, moves it — and no fingerprints are kept to say which'
711
+ : unsaved !== null
712
+ ? `cache key changed but input fingerprints are unavailable — ${unsaved} and saved no entry; only the key change is known`
713
+ : 'cache key changed but input fingerprints are unavailable — the entry was pruned (or the row predates the `cached` column); only the key change is known',
563
714
  }
564
715
  }
565
716
 
566
- const entries: InputDiffEntry[] = []
567
- let unchangedCount = 0
568
- const keys = new Set<string>([...cur.keys(), ...old.keys()])
569
- for (const key of keys) {
570
- const a = cur.get(key)
571
- const b = old.get(key)
572
- if (a && b) {
573
- if (a.hash === b.hash) unchangedCount++
574
- else
575
- entries.push({
576
- kind: a.kind,
577
- name: a.name,
578
- change: 'changed',
579
- before: b.hash,
580
- after: a.hash,
581
- })
582
- } else if (a) {
583
- entries.push({ kind: a.kind, name: a.name, change: 'added', before: null, after: a.hash })
584
- } else if (b) {
585
- entries.push({ kind: b.kind, name: b.name, change: 'removed', before: b.hash, after: null })
586
- }
587
- }
588
- entries.sort((x, y) => x.kind.localeCompare(y.kind) || x.name.localeCompare(y.name))
717
+ const { entries, unchangedCount } = diffKeyComponents(old, cur)
589
718
 
590
719
  return {
591
720
  runId,
@@ -0,0 +1,232 @@
1
+ // What a MISS leaves behind once the command exited 0 and the task will
2
+ // save: the declared outputs resolved, the artifact and its rows saved in
3
+ // one transaction, the whole-subtree output prefixes recorded for the
4
+ // next hit's skip-restore, and the exact written paths marked against the
5
+ // git snapshot so a same-project consumer re-spawns git only when its
6
+ // globs can see them. Stale-hit-critical: a line moved here changes what
7
+ // a later run replays. Moved out of execute-task.ts 2026-09-09 as pure
8
+ // code motion.
9
+
10
+ import path from 'node:path'
11
+ import {
12
+ type CacheLayer,
13
+ type GitFilesCache,
14
+ resolveOutputs,
15
+ resolveWorkspaceOutputs,
16
+ } from '../cache/index.js'
17
+ import type { TaskNode } from '../graph/index.js'
18
+ import { span, wholeSubtreePrefixes } from '../util/index.js'
19
+ import type { Logger } from './logger.js'
20
+ import type { TaskInputComponent } from './task-hash.js'
21
+
22
+ /** A snapshot request for `recordOutputDirs`, taken at run end. */
23
+ export interface OutputDirSnapshot {
24
+ hash: string
25
+ projectDir: string
26
+ prefixes: readonly string[]
27
+ }
28
+
29
+ export interface SaveMissArgs {
30
+ node: TaskNode
31
+ hash: string
32
+ cache: CacheLayer
33
+ log: Logger
34
+ workspaceRoot: string
35
+ nestedProjectDirs: string[]
36
+ gitFilesCache?: GitFilesCache | undefined
37
+ /** Declared `cache.outputs.files` / `.workspaceFiles`. */
38
+ outputs: string[]
39
+ wsOutputs: string[]
40
+ /**
41
+ * An ADDITIVE task's own set (item 588): the files its run added or
42
+ * changed under its declared outputs, resolved by execute-task against
43
+ * the stamp it took before the run. Replaces the glob walk below, which
44
+ * would also take the upstream's files the task adds beside.
45
+ */
46
+ ownOutputFiles?: string[] | undefined
47
+ /** The Tier-3 input fingerprint rows captured by the pre-exec describe. */
48
+ captured: readonly TaskInputComponent[]
49
+ command: string
50
+ durationMs: number
51
+ stdout: string
52
+ /** The execution's usage, when the runner reported it — rides the artifact's sidecar. */
53
+ cpuMs?: number | undefined
54
+ peakRssBytes?: number | undefined
55
+ /** When present, the directory snapshot is queued here instead of taken now. */
56
+ outputDirSnapshots?: OutputDirSnapshot[] | undefined
57
+ /**
58
+ * When present, the cache save itself runs off the execution slot: the
59
+ * outputs are resolved and the git snapshot marked here, in the slot
60
+ * (a same-project downstream task reads both), and the pack + write +
61
+ * index go to the run's save lane (save-lane.ts).
62
+ */
63
+ deferSave?: ((save: () => Promise<void>) => Promise<void>) | undefined
64
+ }
65
+
66
+ /** `landed` settles when the entry is in the cache — at once without a lane. */
67
+ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<void> }> {
68
+ const { node, cache, log } = a
69
+ const endResolve = span('miss: resolve outputs')
70
+ const outputFiles =
71
+ a.ownOutputFiles ??
72
+ (await resolveOutputs({
73
+ projectDir: node.projectDir,
74
+ outputs: a.outputs,
75
+ nestedProjectDirs: a.nestedProjectDirs,
76
+ }))
77
+ const wsOutputFiles = await resolveWorkspaceOutputs({
78
+ workspaceRoot: a.workspaceRoot,
79
+ outputs: a.wsOutputs,
80
+ })
81
+ endResolve()
82
+ // The mirror of the input warning: declared outputs that resolve to
83
+ // nothing save an empty artifact, and the next hit "restores" it —
84
+ // the build that ran nowhere looks like a build that ran. `files: []`
85
+ // is a deliberate cached no-op and says nothing; a glob that matched
86
+ // nothing is a glob against the wrong directory.
87
+ if (
88
+ a.outputs.length + a.wsOutputs.length > 0 &&
89
+ outputFiles.length + wsOutputFiles.length === 0
90
+ ) {
91
+ // One cause is common enough to name, because the task shows no other
92
+ // symptom: a sandboxed task with no write grant. Its writes land in the
93
+ // sandbox's own scratch and never reach disk, and whether the shell
94
+ // even notices depends on the layout — in a single-package workspace
95
+ // (project dir === workspace root) the write SUCCEEDS into that
96
+ // scratch and the task exits 0, so this warning is the only thing that
97
+ // says the build produced nothing (item 444). A declared
98
+ // `cache.outputs` is not a write grant; `exec.sandbox.allow.write` is.
99
+ const sandboxed = node.config.exec?.sandbox !== undefined
100
+ const grantsWrite = (node.config.exec?.sandbox?.allow?.write?.length ?? 0) > 0
101
+ log.status(
102
+ `[vx] ${node.id}: cache.outputs matched no files (${[...a.outputs, ...a.wsOutputs].join(', ')}) — ` +
103
+ `an empty artifact is saved; a later hit restores nothing` +
104
+ (sandboxed && !grantsWrite
105
+ ? ` — the task is sandboxed and declares no exec.sandbox.allow.write, so its writes never reached disk`
106
+ : ''),
107
+ )
108
+ }
109
+ const save = async (): Promise<void> => {
110
+ const endSave = span('miss: save')
111
+ // Tier-3 input fingerprint: the digest rows captured by the pre-exec
112
+ // describe above, persisted with the entry inside `cache.save`'s
113
+ // transaction. Miss path only — the warm/hit path never reaches here.
114
+ await cache.save({
115
+ hash: a.hash,
116
+ projectDir: node.projectDir,
117
+ outputFiles,
118
+ ...(wsOutputFiles.length > 0
119
+ ? { workspaceOutputFiles: wsOutputFiles, workspaceRoot: a.workspaceRoot }
120
+ : {}),
121
+ inputComponents: a.captured.map((c) => ({ entryHash: a.hash, ...c })),
122
+ // No `exitCode`: the save only runs under `effectiveExitCode === 0`, and
123
+ // the contract no longer accepts one — so the invariant is enforced by
124
+ // the type rather than by every call site remembering the gate.
125
+ entry: {
126
+ taskId: node.id,
127
+ command: a.command,
128
+ durationMs: a.durationMs,
129
+ stdout: a.stdout,
130
+ ...(a.cpuMs !== undefined ? { cpuMs: a.cpuMs } : {}),
131
+ ...(a.peakRssBytes !== undefined ? { peakRssBytes: a.peakRssBytes } : {}),
132
+ },
133
+ })
134
+ endSave()
135
+ // The files the task just wrote are the entry's bytes: stamp them,
136
+ // so the next hit can tell them from another entry's with the same
137
+ // size and mtime (item 886).
138
+ cache.recordOutputStamps?.(a.hash, node.projectDir, a.workspaceRoot)
139
+ // The directory snapshot behind the next hit's skip-restore, taken at
140
+ // run end from the run's list: the task wrote these directories
141
+ // milliseconds ago, inside the snapshot's racy window, so a snapshot
142
+ // taken here was refused and the next hit walked every output tree —
143
+ // 1,000 walks, 296 ms accumulated, on the first warm run after a cold
144
+ // build of the 1,000-project bench (2026-09-10). A caller with no
145
+ // list gets no snapshot: recording here was that same refusal (item
146
+ // 637 deleted the fallback and nothing reddened), and the next hit
147
+ // walks, as it did.
148
+ const savedDirPrefixes = wholeSubtreePrefixes(a.outputs)
149
+ if (savedDirPrefixes !== null && a.outputDirSnapshots !== undefined) {
150
+ a.outputDirSnapshots.push({
151
+ hash: a.hash,
152
+ projectDir: node.projectDir,
153
+ prefixes: savedDirPrefixes,
154
+ })
155
+ }
156
+ }
157
+ markWritten(a, outputFiles, wsOutputFiles)
158
+ // Off the slot when the run keeps a lane (the save lane bounds and
159
+ // drains it); in the slot otherwise — an embedder without a lane gets
160
+ // the entry before the outcome.
161
+ if (a.deferSave !== undefined) return { landed: a.deferSave(save) }
162
+ await save()
163
+ return { landed: Promise.resolve() }
164
+ }
165
+
166
+ /** What `markUnsaved` reads of a miss: where its declared outputs are. */
167
+ export type UnsavedArgs = Pick<
168
+ SaveMissArgs,
169
+ 'node' | 'workspaceRoot' | 'nestedProjectDirs' | 'gitFilesCache' | 'outputs' | 'wsOutputs'
170
+ >
171
+
172
+ /**
173
+ * A miss that ran here and saves nothing (it failed, the policy writes
174
+ * nothing, an upstream failed, its key no longer held) still wrote its
175
+ * outputs, and a same-project reader after it must see them as it sees a
176
+ * save's: resolved and marked the same way.
177
+ */
178
+ export async function markUnsaved(a: UnsavedArgs): Promise<void> {
179
+ const endResolve = span('miss: resolve outputs')
180
+ const outputFiles = await resolveOutputs({
181
+ projectDir: a.node.projectDir,
182
+ outputs: a.outputs,
183
+ nestedProjectDirs: a.nestedProjectDirs,
184
+ })
185
+ const wsOutputFiles = await resolveWorkspaceOutputs({
186
+ workspaceRoot: a.workspaceRoot,
187
+ outputs: a.wsOutputs,
188
+ })
189
+ endResolve()
190
+ markWritten(a, outputFiles, wsOutputFiles)
191
+ }
192
+
193
+ /**
194
+ * The task just wrote outputs to the project's tree. Record the exact
195
+ * declared-output paths as changed (same as the cache-hit restore path)
196
+ * instead of dropping the whole snapshot: a downstream same-project task
197
+ * then re-spawns git ONLY when its input globs can actually see one of
198
+ * these paths. On a 1000-package cold run this removes ~one synchronous
199
+ * `git ls-files` spawn per project (the single largest cold-run cost —
200
+ * 22% of CPU in profiling). Contract: outputs must be declared — an
201
+ * executed task that writes files outside `cache.outputs.files` which a
202
+ * same-project downstream task reads is undeclared behavior (the restore
203
+ * path already assumes it).
204
+ */
205
+ function markWritten(
206
+ a: Pick<SaveMissArgs, 'node' | 'workspaceRoot' | 'gitFilesCache'>,
207
+ outputFiles: readonly string[],
208
+ wsOutputFiles: readonly string[],
209
+ ): void {
210
+ const { projectDir } = a.node
211
+ if (outputFiles.length > 0) {
212
+ a.gitFilesCache?.markOutputsChanged(
213
+ projectDir,
214
+ outputFiles.map((p) => path.relative(projectDir, p).split(path.sep).join('/')),
215
+ )
216
+ }
217
+ // Declared workspace outputs may have landed inside OTHER projects' dirs
218
+ // (no-boundary escape hatch) — mark the exact paths against every
219
+ // partition that can see them.
220
+ if (wsOutputFiles.length > 0) {
221
+ a.gitFilesCache?.markWorkspaceOutputsChanged(
222
+ a.workspaceRoot,
223
+ wsOutputFiles.map((f) => path.relative(a.workspaceRoot, f).split(path.sep).join('/')),
224
+ )
225
+ }
226
+ // The workspace-wide partition (when one exists) spans this project's
227
+ // subtree, so it inherits the same "undeclared writes are only visible
228
+ // to git" rule as the project drop above.
229
+ if (outputFiles.length + wsOutputFiles.length > 0) {
230
+ a.gitFilesCache?.invalidateWorkspacePartition()
231
+ }
232
+ }