@defrex/autobuild 0.8.0

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 (251) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +192 -0
  3. package/SPEC.md +2269 -0
  4. package/bin/ab-build-runner.ts +47 -0
  5. package/bin/ab-dev.ts +27 -0
  6. package/bin/ab-dispatch-kernel.ts +92 -0
  7. package/bin/ab-harvest-runner.ts +29 -0
  8. package/bin/ab-init-probe.ts +19 -0
  9. package/bin/ab-process-group-reaper.ts +32 -0
  10. package/bin/ab.ts +6 -0
  11. package/bin/agent/ab +3 -0
  12. package/docs/answering-blocked-builds.md +89 -0
  13. package/docs/architecture.md +726 -0
  14. package/docs/assets/headline-wide.png +0 -0
  15. package/docs/configuration.md +1623 -0
  16. package/docs/hosted-dispatcher-cutover-validation.md +178 -0
  17. package/docs/hosted-dispatcher.md +204 -0
  18. package/docs/mcp.md +119 -0
  19. package/docs/operator-api.md +186 -0
  20. package/docs/remote-store-protocol.md +972 -0
  21. package/docs/remote-ticket-protocol.md +79 -0
  22. package/docs/setup.md +289 -0
  23. package/docs/spec-standard.md +63 -0
  24. package/docs/ticket-dependencies.md +59 -0
  25. package/docs/vercel-sandbox-rollout-probe.md +7 -0
  26. package/package.json +135 -0
  27. package/packages/core/src/cli/args.ts +74 -0
  28. package/packages/core/src/cli/artifact.ts +182 -0
  29. package/packages/core/src/cli/binary.ts +198 -0
  30. package/packages/core/src/cli/build-control.ts +789 -0
  31. package/packages/core/src/cli/build-progress.ts +55 -0
  32. package/packages/core/src/cli/bulk-control.ts +385 -0
  33. package/packages/core/src/cli/context.ts +470 -0
  34. package/packages/core/src/cli/dashboard/actions.ts +59 -0
  35. package/packages/core/src/cli/dashboard/cells.ts +92 -0
  36. package/packages/core/src/cli/dashboard/composer.ts +230 -0
  37. package/packages/core/src/cli/dashboard/detail.ts +100 -0
  38. package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
  39. package/packages/core/src/cli/dashboard/live.ts +152 -0
  40. package/packages/core/src/cli/dashboard/model.ts +1173 -0
  41. package/packages/core/src/cli/dashboard/poll.ts +196 -0
  42. package/packages/core/src/cli/dashboard/render.ts +1568 -0
  43. package/packages/core/src/cli/dashboard/selection.ts +66 -0
  44. package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
  45. package/packages/core/src/cli/dashboard/session-view.ts +506 -0
  46. package/packages/core/src/cli/dashboard/transcript.ts +108 -0
  47. package/packages/core/src/cli/dev.ts +46 -0
  48. package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
  49. package/packages/core/src/cli/dispatch-process.ts +259 -0
  50. package/packages/core/src/cli/dispatch.ts +3692 -0
  51. package/packages/core/src/cli/dotenv.ts +48 -0
  52. package/packages/core/src/cli/env.ts +206 -0
  53. package/packages/core/src/cli/harvest.ts +698 -0
  54. package/packages/core/src/cli/help.ts +713 -0
  55. package/packages/core/src/cli/init-agent.ts +106 -0
  56. package/packages/core/src/cli/init-validation.ts +583 -0
  57. package/packages/core/src/cli/init.ts +756 -0
  58. package/packages/core/src/cli/installation.ts +333 -0
  59. package/packages/core/src/cli/keyboard.ts +261 -0
  60. package/packages/core/src/cli/main.ts +1399 -0
  61. package/packages/core/src/cli/mcp.ts +279 -0
  62. package/packages/core/src/cli/models.ts +65 -0
  63. package/packages/core/src/cli/observe.ts +55 -0
  64. package/packages/core/src/cli/plugin.ts +227 -0
  65. package/packages/core/src/cli/pr-attachments.ts +138 -0
  66. package/packages/core/src/cli/pr-summary.ts +124 -0
  67. package/packages/core/src/cli/publication-settlement.ts +188 -0
  68. package/packages/core/src/cli/repo-state.ts +213 -0
  69. package/packages/core/src/cli/repository-status.ts +80 -0
  70. package/packages/core/src/cli/self-update.ts +443 -0
  71. package/packages/core/src/cli/status.ts +895 -0
  72. package/packages/core/src/cli/store-opening.contract.ts +289 -0
  73. package/packages/core/src/cli/store-opening.ts +167 -0
  74. package/packages/core/src/cli/store-ref.ts +33 -0
  75. package/packages/core/src/cli/terminal-restore.ts +174 -0
  76. package/packages/core/src/cli/terminal.ts +361 -0
  77. package/packages/core/src/cli/terminals.ts +995 -0
  78. package/packages/core/src/cli/testkit.ts +194 -0
  79. package/packages/core/src/cli/ticket.ts +484 -0
  80. package/packages/core/src/cli/upgrade-agent.ts +180 -0
  81. package/packages/core/src/cli/upgrade-commit.ts +468 -0
  82. package/packages/core/src/cli/upgrade-notice.ts +91 -0
  83. package/packages/core/src/cli/upgrade-progress.ts +110 -0
  84. package/packages/core/src/cli/upgrade.ts +888 -0
  85. package/packages/core/src/cli/wait.ts +587 -0
  86. package/packages/core/src/cli/watch.ts +932 -0
  87. package/packages/core/src/config/live.ts +255 -0
  88. package/packages/core/src/config/load.ts +98 -0
  89. package/packages/core/src/config/roles.ts +438 -0
  90. package/packages/core/src/config/schema.ts +774 -0
  91. package/packages/core/src/distribution.ts +24 -0
  92. package/packages/core/src/events/catalog.ts +201 -0
  93. package/packages/core/src/events/envelope.ts +59 -0
  94. package/packages/core/src/events/payloads.ts +618 -0
  95. package/packages/core/src/events/repository.ts +559 -0
  96. package/packages/core/src/events/sessions.ts +180 -0
  97. package/packages/core/src/harvest/schema.ts +147 -0
  98. package/packages/core/src/ids.ts +45 -0
  99. package/packages/core/src/integration/harness.ts +681 -0
  100. package/packages/core/src/kernel/auto-merge.ts +259 -0
  101. package/packages/core/src/kernel/converge.ts +117 -0
  102. package/packages/core/src/kernel/dispatch-settings.ts +60 -0
  103. package/packages/core/src/kernel/dispatch-status.ts +141 -0
  104. package/packages/core/src/kernel/engine.ts +1047 -0
  105. package/packages/core/src/kernel/harvest.ts +823 -0
  106. package/packages/core/src/kernel/origin.ts +69 -0
  107. package/packages/core/src/kernel/phases.ts +196 -0
  108. package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
  109. package/packages/core/src/kernel/pr-attachments.ts +82 -0
  110. package/packages/core/src/kernel/reducer.ts +752 -0
  111. package/packages/core/src/kernel/stall.ts +155 -0
  112. package/packages/core/src/kernel/verify-applicability.ts +52 -0
  113. package/packages/core/src/markdown.ts +67 -0
  114. package/packages/core/src/ontology.ts +288 -0
  115. package/packages/core/src/open-map.ts +203 -0
  116. package/packages/core/src/operator/annotations.ts +187 -0
  117. package/packages/core/src/operator/client.ts +311 -0
  118. package/packages/core/src/operator/control.ts +117 -0
  119. package/packages/core/src/operator/index.ts +62 -0
  120. package/packages/core/src/operator/presentation.ts +22 -0
  121. package/packages/core/src/operator/protocol.ts +98 -0
  122. package/packages/core/src/operator/query.ts +201 -0
  123. package/packages/core/src/operator/registry.ts +1043 -0
  124. package/packages/core/src/operator/requests.ts +138 -0
  125. package/packages/core/src/operator/sandbox.ts +458 -0
  126. package/packages/core/src/operator/server.ts +616 -0
  127. package/packages/core/src/operator/sessions.ts +223 -0
  128. package/packages/core/src/operator/tickets.ts +177 -0
  129. package/packages/core/src/plugin-sdk/index.ts +174 -0
  130. package/packages/core/src/plugins/contract-entry.ts +74 -0
  131. package/packages/core/src/plugins/contract-env.ts +3 -0
  132. package/packages/core/src/plugins/load.ts +259 -0
  133. package/packages/core/src/plugins/manifest.ts +236 -0
  134. package/packages/core/src/plugins/registry.ts +274 -0
  135. package/packages/core/src/plugins/runtimes.ts +75 -0
  136. package/packages/core/src/ports/forge/contract.ts +258 -0
  137. package/packages/core/src/ports/forge/create.ts +74 -0
  138. package/packages/core/src/ports/forge/fake.ts +488 -0
  139. package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
  140. package/packages/core/src/ports/forge/github-transport.ts +352 -0
  141. package/packages/core/src/ports/forge/github.ts +1162 -0
  142. package/packages/core/src/ports/forge/local-git.ts +568 -0
  143. package/packages/core/src/ports/runner/claude.ts +873 -0
  144. package/packages/core/src/ports/runner/cli-stream.ts +90 -0
  145. package/packages/core/src/ports/runner/codex.ts +873 -0
  146. package/packages/core/src/ports/runner/contract.ts +492 -0
  147. package/packages/core/src/ports/runner/fake.ts +213 -0
  148. package/packages/core/src/ports/runner/json-record.ts +2 -0
  149. package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
  150. package/packages/core/src/ports/runner/one-shot.ts +29 -0
  151. package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
  152. package/packages/core/src/ports/runner/pi-bridge.js +6 -0
  153. package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
  154. package/packages/core/src/ports/runner/pi.ts +617 -0
  155. package/packages/core/src/ports/runner/production.ts +74 -0
  156. package/packages/core/src/ports/runner/provider-error.ts +121 -0
  157. package/packages/core/src/ports/runner/routing.ts +234 -0
  158. package/packages/core/src/ports/runner/runtime.ts +214 -0
  159. package/packages/core/src/ports/runner/session-env.ts +34 -0
  160. package/packages/core/src/ports/runner/stream-parts.ts +103 -0
  161. package/packages/core/src/ports/tickets/contract.ts +570 -0
  162. package/packages/core/src/ports/tickets/create.ts +135 -0
  163. package/packages/core/src/ports/tickets/fake.ts +215 -0
  164. package/packages/core/src/ports/tickets/file.ts +559 -0
  165. package/packages/core/src/ports/tickets/linear.ts +811 -0
  166. package/packages/core/src/ports/tickets/operations.ts +161 -0
  167. package/packages/core/src/ports/tickets/remote-index.ts +13 -0
  168. package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
  169. package/packages/core/src/ports/tickets/remote-server.ts +185 -0
  170. package/packages/core/src/ports/tickets/remote.ts +130 -0
  171. package/packages/core/src/ports/tickets/update.ts +51 -0
  172. package/packages/core/src/ports/types.ts +528 -0
  173. package/packages/core/src/ports/workspace/build-execution.ts +123 -0
  174. package/packages/core/src/ports/workspace/contract.ts +136 -0
  175. package/packages/core/src/ports/workspace/create.ts +132 -0
  176. package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
  177. package/packages/core/src/ports/workspace/fake.ts +432 -0
  178. package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
  179. package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
  180. package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
  181. package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
  182. package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
  183. package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
  184. package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
  185. package/packages/core/src/processes/build-child-exit.ts +78 -0
  186. package/packages/core/src/processes/build-child.ts +119 -0
  187. package/packages/core/src/processes/build-execution-state.ts +95 -0
  188. package/packages/core/src/processes/build-parent-watch.ts +35 -0
  189. package/packages/core/src/processes/build-runner.ts +2614 -0
  190. package/packages/core/src/processes/dispatcher.ts +2825 -0
  191. package/packages/core/src/processes/execution-settlement.ts +171 -0
  192. package/packages/core/src/processes/harvest-child.ts +115 -0
  193. package/packages/core/src/processes/harvest-execution-state.ts +121 -0
  194. package/packages/core/src/processes/harvest-runner.ts +1656 -0
  195. package/packages/core/src/processes/harvest.ts +595 -0
  196. package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
  197. package/packages/core/src/processes/phase-failure-budget.ts +52 -0
  198. package/packages/core/src/processes/process-group-reaper.ts +35 -0
  199. package/packages/core/src/processes/process-group.ts +65 -0
  200. package/packages/core/src/processes/publication-loss.ts +62 -0
  201. package/packages/core/src/processes/publication-state.ts +93 -0
  202. package/packages/core/src/processes/sandbox-state.ts +101 -0
  203. package/packages/core/src/registry.ts +24 -0
  204. package/packages/core/src/sessions/reducer.ts +185 -0
  205. package/packages/core/src/skills.ts +7 -0
  206. package/packages/core/src/spec-standard.ts +47 -0
  207. package/packages/core/src/store/adapter.ts +80 -0
  208. package/packages/core/src/store/build-scope.ts +272 -0
  209. package/packages/core/src/store/contract.ts +2581 -0
  210. package/packages/core/src/store/local/blobs.ts +38 -0
  211. package/packages/core/src/store/local/schema.ts +180 -0
  212. package/packages/core/src/store/local/store.ts +1713 -0
  213. package/packages/core/src/store/memory.ts +1138 -0
  214. package/packages/core/src/store/phase-session.ts +425 -0
  215. package/packages/core/src/store/remote/client.ts +780 -0
  216. package/packages/core/src/store/remote/index.ts +12 -0
  217. package/packages/core/src/store/remote/protocol.ts +348 -0
  218. package/packages/core/src/store/remote/server.ts +923 -0
  219. package/packages/core/src/store/remote/token.ts +137 -0
  220. package/packages/core/src/store/remote/version.ts +10 -0
  221. package/packages/core/src/store/retention.ts +91 -0
  222. package/packages/core/src/store/session-handle.ts +265 -0
  223. package/packages/core/src/store/streams/assemble.ts +79 -0
  224. package/packages/core/src/store/streams/lock.ts +42 -0
  225. package/packages/core/src/store/streams/session-writer.ts +306 -0
  226. package/packages/core/src/store/streams/types.ts +171 -0
  227. package/packages/core/src/store/streams/wait.ts +127 -0
  228. package/packages/core/src/store/subscribe.ts +105 -0
  229. package/packages/core/src/store/types.ts +466 -0
  230. package/packages/core/src/testing/fixed.ts +44 -0
  231. package/packages/core/src/testing/store-failures.ts +32 -0
  232. package/packages/core/src/zod-issues.ts +91 -0
  233. package/skills/code-review/SKILL.md +100 -0
  234. package/skills/finalize/SKILL.md +44 -0
  235. package/skills/guide/SKILL.md +1781 -0
  236. package/skills/guide/references/operator-api.md +164 -0
  237. package/skills/guide/references/plugin-authoring.md +354 -0
  238. package/skills/guide/references/remote-store-protocol.md +972 -0
  239. package/skills/guide/references/remote-ticket-protocol.md +79 -0
  240. package/skills/guide/references/setup.md +289 -0
  241. package/skills/guide/references/spec-standard.md +63 -0
  242. package/skills/guide/references/ticket-dependencies.md +59 -0
  243. package/skills/harvest/SKILL.md +66 -0
  244. package/skills/harvest-review/SKILL.md +63 -0
  245. package/skills/implement/SKILL.md +82 -0
  246. package/skills/plan/SKILL.md +123 -0
  247. package/skills/plan-review/SKILL.md +121 -0
  248. package/skills/reconcile/SKILL.md +65 -0
  249. package/skills/spec/SKILL.md +132 -0
  250. package/skills/tickets/SKILL.md +139 -0
  251. package/templates/autobuild.toml +36 -0
@@ -0,0 +1,1173 @@
1
+ /**
2
+ * The dashboard's pure projection: `BuildState` → what the operator sees.
3
+ *
4
+ * DISPLAY-ONLY — nothing consults this. The engine decides transitions from
5
+ * `decideNext` (§2.1's determinism half); a progress row is not a transition
6
+ * table and must never become one.
7
+ *
8
+ * But display-only is NOT a licence to disagree with the engine. The display
9
+ * IS the deliverable: a row that says `verify:lint [x]` for code the engine is
10
+ * about to re-verify is a wrong answer, not a cosmetic one. So every row obeys
11
+ * one rule:
12
+ *
13
+ * >>> A step is `done` iff the engine will not re-run it. <<<
14
+ *
15
+ * Which means every `done` predicate is expressed against a fact the engine
16
+ * ALSO routes on — a seq or a round — never a full-log "latest wins" boolean.
17
+ * `plan.approved`, `codeReviewApproved` and `prState !== undefined` all look
18
+ * like the right answer and all lie across a restart, because `spec.revised`
19
+ * never touches them.
20
+ *
21
+ * `provisional` is a separate, display-only fact: this occurrence produced a
22
+ * terminal output in the current round/cycle, but the engine may still re-run
23
+ * it. It never participates in routing and never changes the `done` rule. The
24
+ * rules this file mirrors, and the code that owns them:
25
+ *
26
+ * engine.ts:275 the verify cycle boundary (§15.6-A)
27
+ * engine.ts:347-355 a verify failure reopens the CODE loop
28
+ * engine.ts:466 a spec restart re-runs both loops (§6.3)
29
+ * engine.ts:707-708 a spec restart re-runs finalize and its post-steps
30
+ * engine.ts:221-227 an unlanded revise-spec answer parks the whole pipeline
31
+ *
32
+ * Display-only inferences live here rather than in the kernel when the spec
33
+ * asks for pixels without changing lifecycle semantics: build effective status
34
+ * (blocked overrides paused), the pending-restart boundary, and harvest-row
35
+ * attention visibility. All derive from retained facts; neither reducer nor
36
+ * engine is touched.
37
+ */
38
+ import type { AbEvent } from '../../events/catalog'
39
+ import type { RepositoryEvent } from '../../events/repository'
40
+ import type { Config } from '../../config/schema'
41
+ import type { BuildState, PhaseContext, PrLifecycle } from '../../kernel/reducer'
42
+ import { currentAutoMergeDeferral } from '../../kernel/auto-merge'
43
+ import { decideNext } from '../../kernel/engine'
44
+ import { verifyPhase } from '../../ontology'
45
+ import type { BuildRecord, StreamRecord } from '../../store/types'
46
+ import { reduceDispatchSettings } from '../../kernel/dispatch-settings'
47
+ import { projectSessions, type DashboardSession } from './detail'
48
+ import type { TranscriptPresentation } from './transcript'
49
+ import type { UIMessage } from 'ai'
50
+ import type { StreamOutcome, StreamPart } from '../../store/streams/types'
51
+ export { dashboardBuildControl } from './actions'
52
+ export type { DashboardBuildControl } from './actions'
53
+ import {
54
+ DEFAULT_MAX_HARVEST_RECOVERY_ATTEMPTS,
55
+ openHarvestRun,
56
+ reduceHarvest,
57
+ type HarvestRunState,
58
+ type HarvestState,
59
+ } from '../../kernel/harvest'
60
+
61
+ /** Every capacity-bearing build plus acknowledged abort cleanup is listed.
62
+ * `aborting` and `cleaning` are display-only projections over durable abort
63
+ * facts; neither extends the reducer's lifecycle vocabulary. */
64
+ export type EffectiveStatus =
65
+ | 'queued'
66
+ | 'running'
67
+ | 'pausing'
68
+ | 'paused'
69
+ | 'resuming'
70
+ | 'blocked'
71
+ | 'aborting'
72
+ | 'cleaning'
73
+
74
+ export type StepState = 'done' | 'current' | 'provisional' | 'pending'
75
+
76
+ /** Human intent versus correlated forge application, rendered non-color-only. */
77
+ export type AutoMergeDisplay = 'off' | 'requested' | 'enabled' | 'cancelling'
78
+
79
+ /**
80
+ * A step's wall-clock timing, now-INDEPENDENT so the model can be cached and
81
+ * repainted against a moving clock (the elapsed ticks in the renderer, not
82
+ * here). `accumulatedMs` is the cumulative duration of every CLOSED occurrence
83
+ * in scope; `runningSince` is the start epoch-ms of an open occurrence — the
84
+ * renderer adds `now - runningSince` on top of `accumulatedMs`. A frozen build
85
+ * (paused/blocked) has its open occurrence closed into `accumulatedMs`, so
86
+ * `runningSince` is absent and its timer never advances (AC 10).
87
+ */
88
+ export interface StepTiming {
89
+ accumulatedMs: number
90
+ runningSince?: number
91
+ }
92
+
93
+ export interface PipelineStep {
94
+ label: string
95
+ state: StepState
96
+ /** Display-only and never consulted for routing. `failed` or `skipped` on
97
+ * a verify/finalize step preserves its outcome alongside lifecycle `state`;
98
+ * `waiting` annotates merge. Rendered inside the `(…)` note, so outcome
99
+ * distinctions never rely on color alone. */
100
+ qualifier?: 'failed' | 'skipped' | 'waiting'
101
+ /** Round (plan/implement loops) or attempt (verify/reconcile). Rendered as
102
+ * `/n` riding the elapsed time when > 1 — supersedes the old `r2`/`a2`. */
103
+ count?: number
104
+ /** Absent ⇒ the step has never run in the current spec scope ⇒ no time is
105
+ * shown (AC 6). The renderer composes the elapsed segment from this. */
106
+ timing?: StepTiming
107
+ }
108
+
109
+ export type DashboardSelection =
110
+ | { kind: 'global' }
111
+ | { kind: 'harvest' }
112
+ | { kind: 'build'; slug: string }
113
+
114
+ export type HarvestRunAction = 'resume' | 'acknowledge'
115
+
116
+ export interface DashboardHarvest {
117
+ kind: 'harvest'
118
+ /** A row always represents this concrete run; the repository gate lives in
119
+ * the header even when no run exists. */
120
+ run: string
121
+ status: 'running' | 'paused' | 'escalated' | 'failed'
122
+ steps: PipelineStep[]
123
+ observations: number
124
+ rounds: number
125
+ detail?: string
126
+ /** Display-only action currently safe for `p`. Absent for running rows,
127
+ * acknowledged pauses, and while a resume request awaits the kernel. */
128
+ action?: HarvestRunAction
129
+ }
130
+
131
+ export interface DashboardBuild {
132
+ slug: string
133
+ status: EffectiveStatus
134
+ /** True when the build is BOTH paused and blocked: blocked wins the status
135
+ * (the spec's visual override), and this keeps the pause visible so no
136
+ * information is lost. */
137
+ alsoPaused: boolean
138
+ ticketId?: string
139
+ steps: PipelineStep[]
140
+ /** Queued-only explanation of the dispatcher boundary currently pending. */
141
+ dispatch?: string
142
+ /** Abort-stage explanation. Present instead of pipeline progress so the two
143
+ * destructive-action stages remain explicit without manufacturing work. */
144
+ abortProgress?: string
145
+ /** Current durable setup error, attributed to this build and retained until
146
+ * a later successful attachment proves recovery. */
147
+ setupError?: string
148
+ /** Every unresolved blocker's question. Resolved ones drop out by
149
+ * construction — the reducer moves them to answeredEscalations. */
150
+ blockers: string[]
151
+ /** Current-spec per-loop review budget overrides. */
152
+ reviewRoundCeilings?: { plan?: number; code?: number }
153
+ /** Native auto-merge desired/applied state. */
154
+ autoMerge: AutoMergeDisplay
155
+ pr?: { url: string; state: PrLifecycle }
156
+ /** Chronological session history projected from the retained raw log. */
157
+ sessions?: DashboardSession[]
158
+ }
159
+
160
+ export type DashboardView =
161
+ | {
162
+ kind: 'detail'
163
+ slug: string
164
+ /** Process-local offset into the fully wrapped detail body. */
165
+ scroll: number
166
+ sessionId?: string
167
+ /** Retained display content. Raising it is a one-shot controller reveal;
168
+ * its continued presence never implies a standing follow mode. */
169
+ message?: string
170
+ /** Message validity fence for facts that can change on the next poll. */
171
+ messageWhileSessionOpen?: string
172
+ }
173
+ | {
174
+ kind: 'transcript'
175
+ slug: string
176
+ sessionId: string
177
+ transcript: TranscriptPresentation
178
+ scroll: number
179
+ }
180
+ | {
181
+ /** The read-only live/closed stream view. Pure display state: the feed
182
+ * only appends freshly read chunks to the accumulated array, and stored
183
+ * parts are never altered. */
184
+ kind: 'session'
185
+ slug: string
186
+ sessionId: string
187
+ stream: string
188
+ status: 'open' | 'closed'
189
+ outcome?: StreamOutcome
190
+ /** The content source: the accumulated part sequence while chunks are
191
+ * readable, or the finalized `UIMessage[]` document once chunks are
192
+ * pruned (or the closed session never had a chunk log). */
193
+ source:
194
+ | { kind: 'parts'; parts: StreamPart[]; lastSeq: number }
195
+ | { kind: 'document'; document: UIMessage[] }
196
+ /** The last failed read's message, shown in place of content until a
197
+ * retry succeeds. */
198
+ error?: string
199
+ /** Tail-follow mode. True while the operator keeps the viewport at the
200
+ * bottom; Up away from the bottom pauses it, back to the bottom
201
+ * resumes. While true the render pins `scroll` to the limit. */
202
+ follow: boolean
203
+ scroll: number
204
+ }
205
+
206
+ export type SessionDashboardView = Extract<DashboardView, { kind: 'session' }>
207
+
208
+ export interface ResumeInputView {
209
+ /** The prompt stays bound to this build even while polling re-sorts rows. */
210
+ slug: string
211
+ /** Full operator input, line structure included. Rendering may scroll it,
212
+ * submission never truncates it. */
213
+ value: string
214
+ /** Caret position as a UTF-16 grapheme-boundary offset into `value` — the
215
+ * composer's one string coordinate the controller owns. */
216
+ cursor: number
217
+ }
218
+
219
+ export interface AbortConfirmationView {
220
+ /** Captured build identity; confirmation never retargets after re-sorting. */
221
+ slug: string
222
+ }
223
+ export interface DashboardCounter {
224
+ current: number
225
+ limit: number
226
+ }
227
+
228
+ export interface DashboardModel {
229
+ repo: string
230
+ /** Ready tickets waiting for a slot — the dispatcher's standing queue depth. */
231
+ queued: number
232
+ /** Every nonterminal build consumes one configured dispatch capacity slot. */
233
+ active: DashboardCounter
234
+ /** Recorded observation occurrences not claimed by a Harvest snapshot,
235
+ * against the configured count-pressure trigger. */
236
+ observations: DashboardCounter
237
+ /** Durable repository intake state (`true` means claims are disabled). */
238
+ drained: boolean
239
+ /** Durable repository-wide hold (`true` means queued builds cannot launch). */
240
+ repositoryPaused: boolean
241
+ /** Durable repository claim-time default for newly claimed builds. */
242
+ defaultAutoMerge: boolean
243
+ /** Durable repository gate reduced from acknowledged journal facts. Pending
244
+ * commands deliberately do not change this field optimistically. */
245
+ harvestPaused: boolean
246
+ /** Stable row identity, never a row index. */
247
+ selection?: DashboardSelection
248
+ /** Release courtesy, separate from transient and startup warnings so those
249
+ * notices can never overwrite one another. Store-only frontends project it
250
+ * from their correlated dispatcher run. */
251
+ availableUpgrade?: string
252
+ /** Process-local warnings/errors, in render order. Routine dispatcher
253
+ * notices never enter the interactive model; absence (or an empty array)
254
+ * means the warning chrome is omitted. */
255
+ warningLines?: readonly string[]
256
+ /** Ephemeral blocked-resume field; never derived from or stored in events. */
257
+ resumeInput?: ResumeInputView
258
+ /** Ephemeral destructive-action confirmation; the first `a` writes nothing. */
259
+ abortConfirmation?: AbortConfirmationView
260
+ /** Nested read-only dashboard mode. Omission is the byte-stable list view. */
261
+ view?: DashboardView
262
+ builds: DashboardBuild[]
263
+ /** Present only for an open run or unresolved run-level attention. It is
264
+ * selectable display state, never a synthetic build. */
265
+ harvest?: DashboardHarvest
266
+ }
267
+
268
+ /**
269
+ * §15.5 sets the reducer's precedence to paused > blocked and documents why:
270
+ * pausing is the operator's explicit instruction, and the escalation is not
271
+ * lost — it stays in `openEscalations`, so resuming reports blocked again. The
272
+ * spec asks for the opposite VISUALLY ("a build with unresolved blockers is
273
+ * treated as blocked even when it is also manually paused"). Both hold at once
274
+ * because the reducer retains both facts: the display overrides, the lifecycle
275
+ * does not. A paused+blocked build still parks on `paused` in `decideNext`,
276
+ * exactly as today.
277
+ */
278
+ export function effectiveStatus(state: BuildState): EffectiveStatus | BuildState['status'] {
279
+ // Terminal outcomes outrank every pending command and visual override. An
280
+ // acknowledged abort remains in its cleanup display stage, while `done`
281
+ // stays filterable as terminal. Only nonterminal abort intent projects as
282
+ // `aborting`; blocked and pause/resume overrides follow it.
283
+ if (state.status === 'done') return 'done'
284
+ if (state.status === 'aborted') return 'cleaning'
285
+ if (state.pendingCommands.some((command) => command.command === 'abort')) return 'aborting'
286
+ if (
287
+ (state.status === 'paused' || state.status === 'blocked') &&
288
+ state.openEscalations.length > 0
289
+ ) {
290
+ return 'blocked'
291
+ }
292
+ if (state.status === 'running') {
293
+ return state.pendingCommands.some((command) => command.command === 'pause')
294
+ ? 'pausing'
295
+ : 'running'
296
+ }
297
+ if (state.status === 'paused') {
298
+ return state.pendingCommands.some((command) => command.command === 'resume')
299
+ ? 'resuming'
300
+ : 'paused'
301
+ }
302
+ return state.status
303
+ }
304
+
305
+ function isVisible(status: EffectiveStatus | BuildState['status']): status is EffectiveStatus {
306
+ return (
307
+ status === 'queued' ||
308
+ status === 'running' ||
309
+ status === 'pausing' ||
310
+ status === 'paused' ||
311
+ status === 'resuming' ||
312
+ status === 'blocked' ||
313
+ status === 'aborting' ||
314
+ status === 'cleaning'
315
+ )
316
+ }
317
+
318
+ export function autoMergeDisplay(state: BuildState): AutoMergeDisplay {
319
+ const { requested, commandSeq, applied } = state.autoMerge
320
+ if (requested) {
321
+ return applied?.enabled === true && applied.commandSeq === commandSeq ? 'enabled' : 'requested'
322
+ }
323
+ if (commandSeq === undefined) return 'off'
324
+ if (applied?.enabled === false && applied.commandSeq === commandSeq) return 'off'
325
+ // Before a PR exists, cancellation clears the durable desired flag and there
326
+ // is no remote state to wait on. Once a PR (or prior enabled fact) exists,
327
+ // keep the pending disable visible until its correlated fact lands.
328
+ if (state.pr === undefined && applied?.enabled !== true) return 'off'
329
+ return 'cancelling'
330
+ }
331
+
332
+ /**
333
+ * One step, built through one helper so the precedence rule cannot be applied
334
+ * inconsistently: **`current > done > provisional > pending`**. In particular,
335
+ * an active occurrence stays current even when an earlier terminal output for
336
+ * that same occurrence exists. `producedOutput` is display-only and means a
337
+ * terminal event exists in the current round/cycle — never merely a start or
338
+ * an open timing interval.
339
+ */
340
+ interface StepExtra {
341
+ producedOutput?: boolean
342
+ qualifier?: 'failed' | 'skipped' | 'waiting'
343
+ count?: number
344
+ timing?: StepTiming
345
+ }
346
+
347
+ function step(label: string, done: boolean, current: boolean, extra: StepExtra = {}): PipelineStep {
348
+ return {
349
+ label,
350
+ state: current
351
+ ? 'current'
352
+ : done
353
+ ? 'done'
354
+ : extra.producedOutput === true
355
+ ? 'provisional'
356
+ : 'pending',
357
+ ...(extra.qualifier !== undefined ? { qualifier: extra.qualifier } : {}),
358
+ ...(extra.count !== undefined ? { count: extra.count } : {}),
359
+ ...(extra.timing !== undefined ? { timing: extra.timing } : {}),
360
+ }
361
+ }
362
+
363
+ /** One phase occurrence's wall-clock span, keyed by phase. `startSeq` is the
364
+ * seq of the `*.started` event, so callers can scope by the same seq
365
+ * boundaries (`restartSince`/`cycleSince`) the step STATES already use. */
366
+ interface PhaseInterval {
367
+ start: number
368
+ end: number
369
+ startSeq: number
370
+ }
371
+ interface PhaseTiming {
372
+ closed: PhaseInterval[]
373
+ open?: { start: number; startSeq: number }
374
+ }
375
+
376
+ /**
377
+ * Walk the raw event log into per-phase `[start, end]` intervals in epoch-ms
378
+ * (`Date.parse(ev.ts)`), the durations the reducer collapses away. Each
379
+ * `*.started` opens an interval for its phase key and its terminal event
380
+ * (`.completed`/`.verdict`) closes it. A second `*.started` while one is still
381
+ * open REPLACES the open start — a §15.6-C cross-sandbox re-run starts the
382
+ * phase afresh, so the crashed attempt contributes nothing. Finalize post-steps
383
+ * (`finalize.step-completed`) have no `.started` and so no interval.
384
+ */
385
+ function phaseIntervals(events: AbEvent[]): Map<string, PhaseTiming> {
386
+ const timings = new Map<string, PhaseTiming>()
387
+ const get = (key: string): PhaseTiming => {
388
+ let t = timings.get(key)
389
+ if (t === undefined) {
390
+ t = { closed: [] }
391
+ timings.set(key, t)
392
+ }
393
+ return t
394
+ }
395
+ for (const ev of events) {
396
+ const ms = Date.parse(ev.ts)
397
+ const open = (key: string): void => {
398
+ get(key).open = { start: ms, startSeq: ev.seq }
399
+ }
400
+ const close = (key: string): void => {
401
+ const t = get(key)
402
+ if (t.open === undefined) return
403
+ t.closed.push({ start: t.open.start, end: ms, startSeq: t.open.startSeq })
404
+ t.open = undefined
405
+ }
406
+ switch (ev.type) {
407
+ case 'plan.started':
408
+ open('plan')
409
+ break
410
+ case 'plan.completed':
411
+ close('plan')
412
+ break
413
+ case 'plan-review.started':
414
+ open('plan-review')
415
+ break
416
+ case 'plan-review.verdict':
417
+ close('plan-review')
418
+ break
419
+ case 'implement.started':
420
+ open('implement')
421
+ break
422
+ case 'implement.completed':
423
+ close('implement')
424
+ break
425
+ case 'code-review.started':
426
+ open('code-review')
427
+ break
428
+ case 'code-review.verdict':
429
+ close('code-review')
430
+ break
431
+ case 'verify.started':
432
+ open(verifyPhase(ev.payload.step))
433
+ break
434
+ case 'verify.completed':
435
+ close(verifyPhase(ev.payload.step))
436
+ break
437
+ case 'finalize.started':
438
+ open('finalize')
439
+ break
440
+ case 'finalize.completed':
441
+ close('finalize')
442
+ break
443
+ case 'reconcile.started':
444
+ open('reconcile')
445
+ break
446
+ case 'reconcile.completed':
447
+ close('reconcile')
448
+ break
449
+ default:
450
+ break
451
+ }
452
+ }
453
+ return timings
454
+ }
455
+
456
+ /**
457
+ * Sum the in-scope occurrences of one phase into a `StepTiming`, or `undefined`
458
+ * when the phase never ran in scope (the step shows no time, AC 6). Scope is
459
+ * `startSeq > sinceSeq` — the SAME boundary the step's `done`/`current` state
460
+ * uses, so durations restart in lockstep with states (AC 12). The open
461
+ * occurrence stays open (`runningSince`) only when the build is running; a
462
+ * frozen build (`frozenNow` given) closes it at that instant so its timer does
463
+ * not advance (AC 10).
464
+ */
465
+ function timingFor(
466
+ intervals: Map<string, PhaseTiming>,
467
+ key: string,
468
+ sinceSeq: number,
469
+ frozenNow: number | undefined,
470
+ ): StepTiming | undefined {
471
+ const t = intervals.get(key)
472
+ if (t === undefined) return undefined
473
+ let accumulatedMs = 0
474
+ let inScope = false
475
+ for (const iv of t.closed) {
476
+ if (iv.startSeq > sinceSeq) {
477
+ accumulatedMs += iv.end - iv.start
478
+ inScope = true
479
+ }
480
+ }
481
+ let runningSince: number | undefined
482
+ if (t.open !== undefined && t.open.startSeq > sinceSeq) {
483
+ inScope = true
484
+ if (frozenNow !== undefined) accumulatedMs += Math.max(0, frozenNow - t.open.start)
485
+ else runningSince = t.open.start
486
+ }
487
+ if (!inScope) return undefined
488
+ return { accumulatedMs, ...(runningSince !== undefined ? { runningSince } : {}) }
489
+ }
490
+
491
+ /**
492
+ * `record` + `state` → one dashboard row, or `null` only for terminal builds.
493
+ */
494
+ export function projectBuild(
495
+ record: BuildRecord,
496
+ state: BuildState,
497
+ config: Config,
498
+ events: AbEvent[],
499
+ streams?: readonly StreamRecord[],
500
+ ): DashboardBuild | null {
501
+ const status = effectiveStatus(state)
502
+ if (!isVisible(status)) return null
503
+ const abortProgress =
504
+ status === 'aborting'
505
+ ? state.status === 'queued'
506
+ ? 'abort requested; waiting for dispatcher acknowledgement'
507
+ : 'abort requested; waiting for running work to stop'
508
+ : status === 'cleaning'
509
+ ? 'running work stopped; abort cleanup pending or in progress'
510
+ : undefined
511
+
512
+ if (status === 'queued') {
513
+ const created = events.findLast((event) => event.type === 'build.created')
514
+ const workspace = events.findLast((event) => event.type === 'workspace.provisioned')
515
+ const released = events.findLast((event) => event.type === 'workspace.released')
516
+ const openWorkspace =
517
+ workspace !== undefined && (released === undefined || workspace.seq > released.seq)
518
+ const spec = events.findLast(
519
+ (event) => event.type === 'spec.imported' || event.type === 'spec.authored',
520
+ )
521
+ const commentPosted = events.findLast((event) => event.type === 'dispatch.comment-posted')
522
+ const latestFailure = events.findLast((event) => event.type === 'dispatch.failed')
523
+ const failureSuperseded =
524
+ latestFailure !== undefined &&
525
+ ((latestFailure.payload.stage === 'create' &&
526
+ created !== undefined &&
527
+ created.seq > latestFailure.seq) ||
528
+ (latestFailure.payload.stage === 'workspace' &&
529
+ workspace !== undefined &&
530
+ workspace.seq > latestFailure.seq) ||
531
+ (latestFailure.payload.stage === 'spec' &&
532
+ spec !== undefined &&
533
+ spec.seq > latestFailure.seq) ||
534
+ (latestFailure.payload.stage === 'comment' &&
535
+ commentPosted !== undefined &&
536
+ commentPosted.seq > latestFailure.seq))
537
+ const dispatch =
538
+ state.discardRequest !== undefined
539
+ ? 'discard requested; cleanup and Ready handback pending'
540
+ : latestFailure !== undefined && !failureSuperseded
541
+ ? `dispatch ${latestFailure.payload.stage} failed (attempt ${latestFailure.payload.attempt}): ${latestFailure.payload.error}`
542
+ : created === undefined
543
+ ? 'dispatch initialization pending'
544
+ : !openWorkspace
545
+ ? 'workspace provisioning pending'
546
+ : spec === undefined
547
+ ? 'spec import pending'
548
+ : 'runner attachment pending'
549
+ return {
550
+ slug: record.slug,
551
+ status,
552
+ alsoPaused: false,
553
+ ...(record.ticket?.id !== undefined ? { ticketId: record.ticket.id } : {}),
554
+ steps: [],
555
+ dispatch,
556
+ blockers: [],
557
+ autoMerge: autoMergeDisplay(state),
558
+ sessions: projectSessions(events, streams),
559
+ }
560
+ }
561
+
562
+ if (abortProgress !== undefined) {
563
+ return {
564
+ slug: record.slug,
565
+ status,
566
+ alsoPaused: false,
567
+ ...(record.ticket?.id !== undefined ? { ticketId: record.ticket.id } : {}),
568
+ steps: [],
569
+ abortProgress,
570
+ blockers: state.openEscalations.map((escalation) => escalation.question),
571
+ ...(state.reviewRoundCeilings.plan !== undefined ||
572
+ state.reviewRoundCeilings.code !== undefined
573
+ ? { reviewRoundCeilings: { ...state.reviewRoundCeilings } }
574
+ : {}),
575
+ autoMerge: autoMergeDisplay(state),
576
+ ...(state.pr !== undefined && state.prState !== undefined
577
+ ? { pr: { url: state.pr.url, state: state.prState } }
578
+ : {}),
579
+ sessions: projectSessions(events, streams),
580
+ }
581
+ }
582
+
583
+ // ── Boundaries ────────────────────────────────────────────────────────────
584
+ //
585
+ // A revise-spec answer whose `spec.revised` has not landed yet (§6.3,
586
+ // engine.ts:221-227): the engine is parked on `wait{awaiting-spec}` and will
587
+ // restart the build from plan the instant the revision arrives. This window
588
+ // is HUMAN-paced — the operator is off rewriting the spec — so it is exactly
589
+ // the durable state this dashboard exists to render, and rendering
590
+ // `finalize [x] merge [>] waiting` in it would be a wrong answer: every one
591
+ // of those steps is about to re-run. A pending restart therefore raises the
592
+ // boundary the same way the landed one will. (`decideNext` returns `wait`
593
+ // here, naming no phase, so the engine oracle in the tests is blind to this
594
+ // state — it is asserted by hand.)
595
+ const pendingRestart = state.answeredEscalations.some(
596
+ (e) => e.resolution === 'revise-spec' && e.answeredSeq > state.restartSince,
597
+ )
598
+ const restartSince = pendingRestart ? state.lastSeq : state.restartSince
599
+ const cycleSince = Math.max(state.verify.cycleSince, restartSince)
600
+ // Post-steps re-run after a restart too (engine.ts:710-712). Their reducer
601
+ // projection is full-log history, so the same effective boundary handles
602
+ // both a landed revision and the pending human-paced restart window.
603
+ const finalizeSteps = state.finalizeSteps.filter((completion) => completion.seq > restartSince)
604
+
605
+ /**
606
+ * The current phase, scoped to the current spec.
607
+ *
608
+ * `currentPhase` is latest-wins over the FULL log — the same shape as
609
+ * `plan.approved` and `prState`, and stale for the same reason. `start()`
610
+ * sets `currentPhase` and only that phase's OWN terminal event clears it
611
+ * (`reducer.ts`); `escalation.raised` deliberately does not
612
+ * (the phase still needs re-running), and `spec.revised` advances
613
+ * `restartSince` and `cycleSince` but does not clear these. So a phase that
614
+ * was in flight when a restart landed still reads as running.
615
+ *
616
+ * That is the plan's own defect class in the one predicate its rule never
617
+ * quantified over — `current`, not `done` — so it gets the same treatment:
618
+ * across a restart (landed OR pending) nothing is current until a new
619
+ * `*.started` lands. That is exactly what the engine does; it re-runs the
620
+ * loops from plan (engine.ts:466) rather than resuming the interrupted
621
+ * phase, and during a pending restart it runs nothing at all
622
+ * (engine.ts:221-227).
623
+ *
624
+ * `reconcile` is included deliberately. The epilogue is restart-orthogonal
625
+ * in the facts it routes on (engine.ts:161-162) — but rule 3 preempts it
626
+ * while a restart is pending, and once one lands the engine goes to plan and
627
+ * only re-reaches the epilogue after a full rebuild re-opens the PR. A
628
+ * pre-restart `reconcile.started` is not "reconcile is running now".
629
+ */
630
+ const scoped = (ctx: PhaseContext | undefined): PhaseContext | undefined =>
631
+ ctx !== undefined && ctx.seq > restartSince ? ctx : undefined
632
+ const activePhase = scoped(state.currentPhase)
633
+
634
+ // ── Timing ────────────────────────────────────────────────────────────────
635
+ // Per-occurrence durations from the raw log — the reducer collapses them
636
+ // away, so they are derived here (display-only, at the one call site that
637
+ // already has the log in hand). A running build keeps its open interval live
638
+ // (the renderer ticks it against `now`); a paused build freezes every open
639
+ // interval at its acknowledgement (including while RESUMING), while a
640
+ // blocked build freezes at the latest durable event. Timers therefore do not
641
+ // advance behind either gate (AC 10). Each step scopes by the SAME seq boundary its state uses, so
642
+ // durations and states restart in lockstep (AC 12).
643
+ const intervals = phaseIntervals(events)
644
+ const pausedAt =
645
+ state.status === 'paused'
646
+ ? events.findLast((event) => event.type === 'build.paused')?.ts
647
+ : undefined
648
+ const frozenNow =
649
+ state.status === 'running'
650
+ ? undefined
651
+ : pausedAt !== undefined
652
+ ? Date.parse(pausedAt)
653
+ : state.lastEvent !== undefined
654
+ ? Date.parse(state.lastEvent.ts)
655
+ : undefined
656
+
657
+ // ── Shared derivations ────────────────────────────────────────────────────
658
+ // ORDER MATTERS: `cycleFailed` must be defined before `codeDone` reads it.
659
+ const cycle = state.verify.results.filter((r) => r.seq > cycleSince)
660
+ const cycleFailed = cycle.some((r) => r.outcome === 'fail')
661
+ const verifyPassed = (s: string): boolean =>
662
+ cycle.some((r) => r.step === s && r.outcome === 'pass')
663
+ const verifySkipped = (s: string): boolean =>
664
+ cycle.some((r) => r.step === s && r.outcome === 'skipped')
665
+ const verifySatisfied = (s: string): boolean => verifyPassed(s) || verifySkipped(s)
666
+ const verifyDrained = !cycleFailed && config.verify.steps.every(verifySatisfied)
667
+
668
+ // A loop is settled only if its standing approval survived the last restart
669
+ // AND no later producer round reopened it. `approved` alone is a full-log
670
+ // boolean and lies in both cases.
671
+ const loopDone = (
672
+ approval: { seq: number; round: number } | undefined,
673
+ producerRound: number,
674
+ ): boolean =>
675
+ approval !== undefined && approval.seq > restartSince && approval.round === producerRound
676
+
677
+ const planDone = loopDone(state.plan.approval, state.plan.round)
678
+ // `&& !cycleFailed`: a verify failure reopens the CODE loop (§15.6-A) the
679
+ // MOMENT it lands — long before `implement.started` moves `implement.round`,
680
+ // and under default policy that start may never come (see below).
681
+ const codeDone = loopDone(state.codeReviewApproval, state.implement.round) && !cycleFailed
682
+
683
+ // A loop row is provisional only when ITS terminal output belongs to the
684
+ // reducer's currently tracked round and landed after the effective restart
685
+ // boundary. Starts and timing intervals are deliberately insufficient: a
686
+ // crashed phase has run, but has produced no output. Matching the round also
687
+ // drops a review verdict as soon as the next producer round starts.
688
+ const planProduced = events.some(
689
+ (ev) =>
690
+ ev.seq > restartSince &&
691
+ ev.type === 'plan.completed' &&
692
+ ev.payload.round === state.plan.round,
693
+ )
694
+ const planReviewProduced = events.some(
695
+ (ev) =>
696
+ ev.seq > restartSince &&
697
+ ev.type === 'plan-review.verdict' &&
698
+ ev.payload.round === state.plan.round,
699
+ )
700
+ const implementProduced = events.some(
701
+ (ev) =>
702
+ ev.seq > restartSince &&
703
+ ev.type === 'implement.completed' &&
704
+ ev.payload.round === state.implement.round,
705
+ )
706
+ const codeReviewProduced = events.some(
707
+ (ev) =>
708
+ ev.seq > restartSince &&
709
+ ev.type === 'code-review.verdict' &&
710
+ ev.payload.round === state.implement.round,
711
+ )
712
+
713
+ // Finalize ran for the CURRENT spec — NOT `prState !== undefined`, which a
714
+ // spec restart never resets while the engine re-runs finalize from scratch.
715
+ const finalizeDone = state.finalizeCompletedSeq > restartSince
716
+ const postStepsDrained = config.finalize.steps.every((s) =>
717
+ finalizeSteps.some((f) => f.step === s),
718
+ )
719
+
720
+ const at = (phase: string): boolean => activePhase?.phase === phase
721
+ // Retain first-round counts for detail rendering. The compact renderer still
722
+ // suppresses `/1`, preserving its existing density and output.
723
+ const planCount = state.plan.round > 0 ? state.plan.round : undefined
724
+ const codeCount = state.implement.round > 0 ? state.implement.round : undefined
725
+
726
+ const steps: PipelineStep[] = [
727
+ step('plan', planDone, at('plan'), {
728
+ producedOutput: planProduced,
729
+ count: planCount,
730
+ timing: timingFor(intervals, 'plan', restartSince, frozenNow),
731
+ }),
732
+ step('plan-review', planDone, at('plan-review'), {
733
+ producedOutput: planReviewProduced,
734
+ count: planCount,
735
+ timing: timingFor(intervals, 'plan-review', restartSince, frozenNow),
736
+ }),
737
+ step('implement', codeDone, at('implement'), {
738
+ producedOutput: implementProduced,
739
+ count: codeCount,
740
+ timing: timingFor(intervals, 'implement', restartSince, frozenNow),
741
+ }),
742
+ step('code-review', codeDone, at('code-review'), {
743
+ producedOutput: codeReviewProduced,
744
+ count: codeCount,
745
+ timing: timingFor(intervals, 'code-review', restartSince, frozenNow),
746
+ }),
747
+ ]
748
+
749
+ for (const s of config.verify.steps) {
750
+ const phase = verifyPhase(s)
751
+ const current = at(phase)
752
+ const stepResults = cycle.filter((r) => r.step === s)
753
+ // The attempt COUNT comes from `currentPhase.attempt` for the running step
754
+ // — the attempt ACTUALLY running (`verify.started` populates it directly),
755
+ // never `verify.maxAttemptSeen`, which is a full-log high-water and names
756
+ // the previous cycle in the window after a boundary move. A non-running
757
+ // step takes the max attempt among its results IN THE CURRENT CYCLE, so a
758
+ // past cycle's attempt can never leak. Rendered as `/n` only when > 1.
759
+ const maxAttempt =
760
+ current && activePhase?.attempt !== undefined
761
+ ? activePhase.attempt
762
+ : stepResults.length > 0
763
+ ? Math.max(...stepResults.map((r) => r.attempt))
764
+ : undefined
765
+ const count = maxAttempt
766
+ // `!cycleFailed &&`: §15.6-A re-runs the cycle FROM THE FIRST STEP, so
767
+ // once any step in the cycle has failed, an earlier step's pass in that
768
+ // same cycle is not durably done either. The failing step keeps a `failed`
769
+ // qualifier so the operator does not lose the information; completed rows
770
+ // are provisional because they produced output but will genuinely re-run.
771
+ steps.push(
772
+ step(phase, !cycleFailed && verifySatisfied(s), current, {
773
+ producedOutput: stepResults.length > 0,
774
+ qualifier: stepResults.some((r) => r.outcome === 'fail')
775
+ ? 'failed'
776
+ : stepResults.some((r) => r.outcome === 'skipped')
777
+ ? 'skipped'
778
+ : undefined,
779
+ count,
780
+ timing: timingFor(intervals, phase, cycleSince, frozenNow),
781
+ }),
782
+ )
783
+ }
784
+
785
+ steps.push(
786
+ step('finalize', finalizeDone, at('finalize'), {
787
+ timing: timingFor(intervals, 'finalize', restartSince, frozenNow),
788
+ }),
789
+ )
790
+ for (const s of config.finalize.steps) {
791
+ // Post-steps have no `.started` event, so they carry no timing.
792
+ const done = finalizeSteps.find((f) => f.step === s)
793
+ steps.push(
794
+ step(s, done !== undefined, false, { qualifier: done?.ok === false ? 'failed' : undefined }),
795
+ )
796
+ }
797
+
798
+ // Conditional (§15.7): the epilogue is not guaranteed future work, so the
799
+ // row appears only once a conflict activates it. `reconcileAttempts` is
800
+ // full-log — legitimately, per the rule above: the engine routes the
801
+ // epilogue on the same full-log values (engine.ts:402,:416), so a restart
802
+ // genuinely does not re-run reconcile and the display agrees.
803
+ if (state.prState === 'conflicted' || state.reconcileAttempts > 0) {
804
+ steps.push(
805
+ step(
806
+ 'reconcile',
807
+ state.reconcileAttempts > 0 && state.prState !== 'conflicted',
808
+ at('reconcile'),
809
+ {
810
+ count: state.reconcileAttempts > 0 ? state.reconcileAttempts : undefined,
811
+ // Full-log scope (sinceSeq 0), matching reconcile's full-log
812
+ // done/current predicate: the epilogue is restart-orthogonal.
813
+ timing: timingFor(intervals, 'reconcile', 0, frozenNow),
814
+ },
815
+ ),
816
+ )
817
+ }
818
+
819
+ // Merge is never `done` for a LISTED build — a merged build is `done` and
820
+ // filtered out above. It is `current` exactly when the engine would return
821
+ // `wait{awaiting-pr}`: the PR is open and every piece of work behind it has
822
+ // drained. `prState === 'open'` alone is not enough — it is set at
823
+ // `finalize.completed` AND again at `reconcile.completed`, both with real
824
+ // work outstanding.
825
+ const mergeCurrent =
826
+ finalizeDone &&
827
+ state.prState === 'open' &&
828
+ verifyDrained &&
829
+ postStepsDrained &&
830
+ activePhase === undefined
831
+ // Merge-ready start = the log's last-event ts: once the build drains the
832
+ // engine parks on `wait{awaiting-pr}` and appends nothing, so `lastEvent.ts`
833
+ // is exactly when it became merge-ready, ticking like a running phase (AC 9).
834
+ // Frozen at that same ts when the build is not running.
835
+ const lastMs = state.lastEvent !== undefined ? Date.parse(state.lastEvent.ts) : 0
836
+ const mergeTiming: StepTiming | undefined = !mergeCurrent
837
+ ? undefined
838
+ : frozenNow !== undefined
839
+ ? { accumulatedMs: Math.max(0, frozenNow - lastMs) }
840
+ : { accumulatedMs: 0, runningSince: lastMs }
841
+ steps.push(step('merge', false, mergeCurrent, { qualifier: 'waiting', timing: mergeTiming }))
842
+
843
+ const setupError =
844
+ state.infrastructureFailure !== undefined
845
+ ? `${state.infrastructureFailure.provider} ${state.infrastructureFailure.operation} failed ` +
846
+ `(attempt ${state.infrastructureFailure.attempt}, ${state.infrastructureFailure.cause}${
847
+ state.infrastructureFailure.cleanupPending ? ', cleanup pending' : ''
848
+ }): ${state.infrastructureFailure.error}`
849
+ : state.setupFailure === undefined
850
+ ? undefined
851
+ : `[commands].setup ${JSON.stringify(state.setupFailure.command)} failed ` +
852
+ `(attempt ${state.setupFailure.attempt}, ${
853
+ state.setupFailure.exitStatus === null
854
+ ? 'exit status unavailable'
855
+ : `exit status ${state.setupFailure.exitStatus}`
856
+ }): ${state.setupFailure.output || '(no output)'}`
857
+
858
+ const decision = decideNext(events, config)
859
+ const mergeWaitReason =
860
+ decision.kind === 'wait' && decision.reason === 'awaiting-pr'
861
+ ? currentAutoMergeDeferral(events, state)
862
+ : undefined
863
+
864
+ return {
865
+ slug: record.slug,
866
+ status,
867
+ alsoPaused: state.status === 'paused' && status === 'blocked',
868
+ ...(record.ticket?.id !== undefined ? { ticketId: record.ticket.id } : {}),
869
+ steps,
870
+ ...(setupError !== undefined ? { setupError } : {}),
871
+ blockers: [
872
+ ...state.openEscalations.map((e) => e.question),
873
+ ...(mergeWaitReason !== undefined ? [mergeWaitReason] : []),
874
+ ],
875
+ ...(state.reviewRoundCeilings.plan !== undefined || state.reviewRoundCeilings.code !== undefined
876
+ ? { reviewRoundCeilings: { ...state.reviewRoundCeilings } }
877
+ : {}),
878
+ autoMerge: autoMergeDisplay(state),
879
+ ...(state.pr !== undefined && state.prState !== undefined
880
+ ? { pr: { url: state.pr.url, state: state.prState } }
881
+ : {}),
882
+ sessions: projectSessions(events, streams),
883
+ }
884
+ }
885
+
886
+ function harvestStepTiming(
887
+ occurrences: Array<{
888
+ step: string
889
+ startedAt: string
890
+ completedAt?: string
891
+ }>,
892
+ name: string,
893
+ frozenAt?: number,
894
+ ): StepTiming | undefined {
895
+ const matching = occurrences.filter((occurrence) => occurrence.step === name)
896
+ if (matching.length === 0) return undefined
897
+ let accumulatedMs = 0
898
+ let runningSince: number | undefined
899
+ for (const occurrence of matching) {
900
+ const start = Date.parse(occurrence.startedAt)
901
+ if (occurrence.completedAt !== undefined) {
902
+ accumulatedMs += Math.max(0, Date.parse(occurrence.completedAt) - start)
903
+ } else if (frozenAt !== undefined) {
904
+ accumulatedMs += Math.max(0, frozenAt - start)
905
+ } else {
906
+ runningSince = start
907
+ }
908
+ }
909
+ return {
910
+ accumulatedMs,
911
+ ...(runningSince !== undefined ? { runningSince } : {}),
912
+ }
913
+ }
914
+
915
+ /** A deliberate escalation has no dedicated acknowledgement event. For display
916
+ * only, pair a post-terminal human resume request with the later kernel resume
917
+ * that consumed it. A countermanding pause invalidates the pending request,
918
+ * matching the repository reducer's command semantics. */
919
+ function escalationAttentionDismissed(events: RepositoryEvent[], terminalSeq: number): boolean {
920
+ let pendingHumanResume = false
921
+ for (const event of events) {
922
+ if (event.seq <= terminalSeq) continue
923
+ if (event.type === 'harvest.resume-requested' && event.actor.kind === 'human') {
924
+ pendingHumanResume = true
925
+ continue
926
+ }
927
+ if (event.type === 'harvest.pause-requested') {
928
+ pendingHumanResume = false
929
+ continue
930
+ }
931
+ if (event.type === 'harvest.resumed' && pendingHumanResume) return true
932
+ }
933
+ return false
934
+ }
935
+
936
+ function selectDashboardHarvestRun(
937
+ events: RepositoryEvent[],
938
+ state: HarvestState,
939
+ ): HarvestRunState | undefined {
940
+ const attention = state.runs.find((run) => {
941
+ if (run.status === 'failed') {
942
+ return run.recoveryExhaustion?.attentionAcknowledgedSeq === undefined
943
+ }
944
+ if (run.status !== 'escalated') return false
945
+ return run.terminalSeq === undefined || !escalationAttentionDismissed(events, run.terminalSeq)
946
+ })
947
+ return attention ?? openHarvestRun(state)
948
+ }
949
+
950
+ /** Project one deterministic concrete run. The oldest unresolved failed,
951
+ * exhausted, or escalated attention outranks merely open work; completed and
952
+ * acknowledged terminal history never hides an older actionable run. */
953
+ function projectHarvestRun(
954
+ events: RepositoryEvent[],
955
+ state: HarvestState,
956
+ ): DashboardHarvest | undefined {
957
+ const run = selectDashboardHarvestRun(events, state)
958
+ if (run === undefined || run.status === 'completed') return undefined
959
+ const exhaustion = run.recoveryExhaustion
960
+
961
+ const occurrences = run.steps
962
+ const stopped = run.status !== 'running'
963
+ const current = (name: string): boolean => {
964
+ if (stopped || state.paused) return false
965
+ const latest = [...occurrences].reverse().find((occurrence) => occurrence.step === name)
966
+ return latest !== undefined && latest.completedSeq === undefined
967
+ }
968
+ const hasCompleted = (name: string): boolean =>
969
+ occurrences.some(
970
+ (occurrence) => occurrence.step === name && occurrence.completedSeq !== undefined,
971
+ )
972
+ const hasOutcome = (name: string, outcome: string): boolean =>
973
+ occurrences.some((occurrence) => occurrence.step === name && occurrence.outcome === outcome)
974
+ const failedAt = (name: string): boolean => run.status === 'failed' && run.failure?.step === name
975
+ const rounds = Math.max(
976
+ 0,
977
+ ...run.proposals.map((proposal) => proposal.round),
978
+ ...run.reviews.map((review) => review.round),
979
+ ...occurrences.map((occurrence) => occurrence.round ?? 0),
980
+ )
981
+ const count = rounds > 1 ? rounds : undefined
982
+ const approved = run.reviews.some((review) => review.verdict === 'approve')
983
+ const synthOutput = run.proposals.length > 0
984
+ const reviewOutput = run.reviews.length > 0
985
+ const failedEvent =
986
+ run.status === 'failed'
987
+ ? [...events]
988
+ .reverse()
989
+ .find(
990
+ (event) =>
991
+ event.type === 'harvest.failed' &&
992
+ event.payload.run === run.run &&
993
+ event.payload.willRetry === false,
994
+ )
995
+ : undefined
996
+ // Both durable stop states freeze any open occurrence. A failure normally
997
+ // closes its step immediately afterward, but projecting the boundary itself
998
+ // must never make an errored row look like work is still accumulating time.
999
+ const frozenAt =
1000
+ state.pausedAt !== undefined
1001
+ ? Date.parse(state.pausedAt)
1002
+ : failedEvent !== undefined
1003
+ ? Date.parse(failedEvent.ts)
1004
+ : undefined
1005
+ const steps: PipelineStep[] = [
1006
+ step('scan', hasOutcome('scan', 'completed'), current('scan'), {
1007
+ qualifier: failedAt('scan') ? 'failed' : undefined,
1008
+ producedOutput: hasCompleted('scan'),
1009
+ timing: harvestStepTiming(occurrences, 'scan', frozenAt),
1010
+ }),
1011
+ step(
1012
+ 'synthesize',
1013
+ synthOutput && (approved || stopped) && !failedAt('synthesize'),
1014
+ current('synthesize'),
1015
+ {
1016
+ producedOutput: synthOutput || hasCompleted('synthesize'),
1017
+ qualifier: failedAt('synthesize') ? 'failed' : undefined,
1018
+ count,
1019
+ timing: harvestStepTiming(occurrences, 'synthesize', frozenAt),
1020
+ },
1021
+ ),
1022
+ step(
1023
+ 'review',
1024
+ reviewOutput && (approved || stopped) && !failedAt('review'),
1025
+ current('review'),
1026
+ {
1027
+ producedOutput: reviewOutput || hasCompleted('review'),
1028
+ qualifier: failedAt('review') ? 'failed' : undefined,
1029
+ count,
1030
+ timing: harvestStepTiming(occurrences, 'review', frozenAt),
1031
+ },
1032
+ ),
1033
+ step('file', false, current('file'), {
1034
+ producedOutput: hasCompleted('file'),
1035
+ qualifier: failedAt('file') ? 'failed' : undefined,
1036
+ timing: harvestStepTiming(occurrences, 'file', frozenAt),
1037
+ }),
1038
+ ]
1039
+ const recoveryAttempts = run.recoveryRequests.length
1040
+ const recoveryLimit =
1041
+ exhaustion?.limit ?? run.recoveryRequests.at(-1)?.limit ?? DEFAULT_MAX_HARVEST_RECOVERY_ATTEMPTS
1042
+ const stoppedAt = run.failure
1043
+ ? `${run.failure.step}${run.failure.round !== undefined ? ` r${run.failure.round}` : ''}`
1044
+ : undefined
1045
+ const detail =
1046
+ run.escalation !== undefined
1047
+ ? run.escalation.reason
1048
+ : exhaustion !== undefined
1049
+ ? `recovery exhausted - human attention required; stopped at ${exhaustion.step}${
1050
+ exhaustion.round !== undefined ? ` r${exhaustion.round}` : ''
1051
+ }; pending ${exhaustion.releasedObservations.length}`
1052
+ : run.failure !== undefined
1053
+ ? `stopped at ${stoppedAt} - automatic recovery ${recoveryAttempts}/${recoveryLimit}; ${run.failure.error}`
1054
+ : recoveryAttempts > 0
1055
+ ? `automatic recovery ${recoveryAttempts}/${recoveryLimit} resumed`
1056
+ : undefined
1057
+ const hasPendingResume = state.pendingCommands.some((command) => command.command === 'resume')
1058
+ const action: HarvestRunAction | undefined =
1059
+ state.paused || hasPendingResume
1060
+ ? undefined
1061
+ : run.status === 'failed'
1062
+ ? exhaustion === undefined
1063
+ ? 'resume'
1064
+ : 'acknowledge'
1065
+ : run.status === 'escalated'
1066
+ ? 'acknowledge'
1067
+ : undefined
1068
+ return {
1069
+ kind: 'harvest',
1070
+ run: run.run,
1071
+ // A paused gate freezes an open run. Attention statuses remain visible as
1072
+ // FAILED/ESCALATED because the header now carries the gate state.
1073
+ status: state.paused && run.status === 'running' ? 'paused' : run.status,
1074
+ steps,
1075
+ observations: run.observations.length,
1076
+ rounds,
1077
+ ...(detail !== undefined ? { detail } : {}),
1078
+ ...(action !== undefined ? { action } : {}),
1079
+ }
1080
+ }
1081
+
1082
+ interface RepositoryHarvestProjection {
1083
+ harvestPaused: boolean
1084
+ harvest?: DashboardHarvest
1085
+ }
1086
+
1087
+ function projectRepositoryHarvest(events: RepositoryEvent[]): RepositoryHarvestProjection {
1088
+ const state = reduceHarvest(events)
1089
+ const harvest = projectHarvestRun(events, state)
1090
+ return {
1091
+ harvestPaused: state.paused,
1092
+ ...(harvest !== undefined ? { harvest } : {}),
1093
+ }
1094
+ }
1095
+
1096
+ export function projectHarvest(events: RepositoryEvent[]): DashboardHarvest | undefined {
1097
+ return projectRepositoryHarvest(events).harvest
1098
+ }
1099
+
1100
+ interface DashboardFrameHeader {
1101
+ repo: string
1102
+ queued: number
1103
+ observationCount: number
1104
+ selection?: DashboardSelection
1105
+ availableUpgrade?: string
1106
+ warningLines?: readonly string[]
1107
+ resumeInput?: ResumeInputView
1108
+ }
1109
+
1110
+ /** Exact dynamic and configured facts needed by the preprojected live path. */
1111
+ export interface DashboardHeader extends DashboardFrameHeader {
1112
+ activeCount: number
1113
+ capacity: number
1114
+ observationLimit: number
1115
+ }
1116
+
1117
+ /**
1118
+ * Compose a frame from already projected active rows. Keeping repository
1119
+ * projection here means build rows (including their phase intervals) may be
1120
+ * cached without caching dispatcher settings or Harvest state.
1121
+ */
1122
+ export function buildDashboardFromProjected(
1123
+ projectedBuilds: readonly DashboardBuild[],
1124
+ header: DashboardHeader,
1125
+ repositoryEvents: RepositoryEvent[] = [],
1126
+ ): DashboardModel {
1127
+ const builds = [...projectedBuilds].sort((a, b) => a.slug.localeCompare(b.slug))
1128
+ const harvestProjection = projectRepositoryHarvest(repositoryEvents)
1129
+ const settings = reduceDispatchSettings(repositoryEvents)
1130
+ return {
1131
+ repo: header.repo,
1132
+ queued: header.queued,
1133
+ active: { current: header.activeCount, limit: header.capacity },
1134
+ observations: { current: header.observationCount, limit: header.observationLimit },
1135
+ drained: !settings.intake,
1136
+ repositoryPaused: settings.paused,
1137
+ defaultAutoMerge: settings.defaultAutoMerge,
1138
+ harvestPaused: harvestProjection.harvestPaused,
1139
+ ...(header.selection !== undefined ? { selection: header.selection } : {}),
1140
+ ...(header.availableUpgrade !== undefined ? { availableUpgrade: header.availableUpgrade } : {}),
1141
+ ...(header.warningLines !== undefined && header.warningLines.length > 0
1142
+ ? { warningLines: header.warningLines }
1143
+ : {}),
1144
+ ...(header.resumeInput !== undefined ? { resumeInput: header.resumeInput } : {}),
1145
+ builds,
1146
+ ...(harvestProjection.harvest !== undefined ? { harvest: harvestProjection.harvest } : {}),
1147
+ }
1148
+ }
1149
+
1150
+ /** Existing entry-based convenience for uncached callers and projection tests. */
1151
+ export function buildDashboard(
1152
+ entries: { record: BuildRecord; state: BuildState; events: AbEvent[] }[],
1153
+ config: Config,
1154
+ header: DashboardFrameHeader,
1155
+ repositoryEvents: RepositoryEvent[] = [],
1156
+ ): DashboardModel {
1157
+ const builds = entries
1158
+ .map(({ record, state, events }) => projectBuild(record, state, config, events))
1159
+ .filter((build): build is DashboardBuild => build !== null)
1160
+ const activeCount = entries.filter(
1161
+ ({ state }) => state.status !== 'done' && state.status !== 'aborted',
1162
+ ).length
1163
+ return buildDashboardFromProjected(
1164
+ builds,
1165
+ {
1166
+ ...header,
1167
+ activeCount,
1168
+ capacity: config.capacity,
1169
+ observationLimit: config.policy.harvestThreshold,
1170
+ },
1171
+ repositoryEvents,
1172
+ )
1173
+ }