@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,895 @@
1
+ /**
2
+ * `ab builds` and `ab build status <slug>` — the read-only query surface on
3
+ * build state (SPEC §8.2, §15.5). Their invocation forms require no session,
4
+ * while a complete ambient phase identity narrows Store authority; `ab build
5
+ * status` additionally extends that identity to authorize reading the
6
+ * session's OWN build from a differently located checkout (see the gate
7
+ * comment on `abBuildStatus`).
8
+ *
9
+ * The shape is one decision: **the projection is pure, the IO is a thin
10
+ * shell.** The functions below map build facts plus an optional build-config
11
+ * decision to plain data (`BuildSummary`, `BuildDetail`), and the renderers map
12
+ * that data to `string[]`. The command entry points only resolve a store, fetch
13
+ * the build-owned config when needed, project, and print. So `--json` IS the
14
+ * projection object and the human text is a second renderer over the same
15
+ * value — there is no "compute for text" path that can disagree with the JSON.
16
+ *
17
+ * Effective status comes from `reduceBuild` — the authoritative event-derived
18
+ * projection (§15.5, §3.4). This is NOT a second read model: it reduces the
19
+ * log and reads the projection's fields, never re-deriving status from
20
+ * records, worktrees, or OS processes.
21
+ *
22
+ * **Status, durable progress, and lease health are separate axes.**
23
+ * `reduceBuild` reports both effective status and the latest durable event;
24
+ * mutable `lease`/`heartbeatAt` columns report runner liveness (§15.2.6).
25
+ * A runner can therefore stay live while its event log is silent. The shared
26
+ * presentation helper exposes that divergence without folding it into status
27
+ * or lease health.
28
+ *
29
+ * Output carries no ANSI, ever: §16 puts themes and colors out of scope, and
30
+ * "understandable without relying solely on color" plus "--json without ANSI
31
+ * formatting" are both satisfied by emitting none rather than by gating on a
32
+ * TTY check.
33
+ */
34
+ import { join } from 'node:path'
35
+ import type { AbEvent } from '../events/catalog'
36
+ import type { EventPayload } from '../events/payloads'
37
+ import type { Actor } from '../events/envelope'
38
+ import { loadConfig } from '../config/load'
39
+ import type { Config } from '../config/schema'
40
+ import {
41
+ BUILD_EFFECTIVE_CONFIG_ARTIFACT,
42
+ parseEffectiveBuildConfig,
43
+ } from '../processes/build-execution-state'
44
+ import { openExecution } from '../processes/execution-settlement'
45
+ import { currentAutoMergeDeferral } from '../kernel/auto-merge'
46
+ import { decideNext } from '../kernel/engine'
47
+ import {
48
+ reduceBuild,
49
+ type BuildState,
50
+ type OpenEscalation,
51
+ type OpenSession,
52
+ type PrLifecycle,
53
+ } from '../kernel/reducer'
54
+ import type {
55
+ BuildOutcome,
56
+ BuildStatus,
57
+ ObservationKind,
58
+ Phase,
59
+ TicketRef,
60
+ VerifyOutcome,
61
+ } from '../ontology'
62
+ import type { Exec } from '../ports/workspace/git-worktree'
63
+ import type { BuildRecord, BuildStore, StreamRecord, StreamScope } from '../store/types'
64
+ import { buildProgress, isDiverged, type BuildProgress } from './build-progress'
65
+ import { normalizeGitRemoteUrl } from '../kernel/origin'
66
+ import { buildInRepository, resolveMainRepo } from './repo-state'
67
+ import { withAmbientReadStore, type StoreOpener } from './store-opening'
68
+
69
+ /** Backward-compatible name for callers/tests; repository resolution is shared. */
70
+ export const currentRepo = resolveMainRepo
71
+
72
+ // ── The projection ───────────────────────────────────────────────────────────
73
+
74
+ /**
75
+ * Runner liveness, from the mutable lease columns (§15.2.6) — NOT a status.
76
+ * `none` is deliberately its own value rather than a weaker `expired`: a build
77
+ * that has not yet claimed its first lease reads this way, and it is the
78
+ * common case for a freshly launched runner.
79
+ */
80
+ export type LeaseHealth = 'held' | 'expired' | 'none'
81
+
82
+ export interface LeaseInfo {
83
+ health: LeaseHealth
84
+ holder?: string
85
+ expiresAt?: string
86
+ heartbeatAt?: string
87
+ }
88
+
89
+ export interface BuildSummary {
90
+ slug: string
91
+ ticket?: TicketRef
92
+ /** The reducer's effective status (§15.5) — never mixed with lease health. */
93
+ status: BuildStatus
94
+ phase?: Phase
95
+ round?: number
96
+ attempt?: number
97
+ pr?: { number: number; url: string; state?: PrLifecycle }
98
+ updatedAt: string
99
+ lease: LeaseInfo
100
+ /** Durable-event progress, separate from mutable lease renewal. */
101
+ progress: BuildProgress
102
+ }
103
+
104
+ export interface VerifyProgress {
105
+ /** Full-log attempt high-water retained for status-output compatibility. */
106
+ attempt: number
107
+ /** Current cycle only — selected by sequence boundary, never attempt. */
108
+ steps: { step: string; outcome: VerifyOutcome; reason?: string }[]
109
+ currentStep?: string
110
+ }
111
+
112
+ export interface SetupFailureDetail {
113
+ attempt: number
114
+ command: string
115
+ exitStatus: number | null
116
+ output: string
117
+ }
118
+
119
+ export interface BuildObservation {
120
+ id: string
121
+ kind: ObservationKind
122
+ summary: string
123
+ files?: string[]
124
+ refs?: string[]
125
+ }
126
+
127
+ export type BuildDecisionProjection =
128
+ | { kind: 'awaiting-pr'; reason?: string }
129
+ | { kind: 'repository-completion'; prState: Extract<PrLifecycle, 'merged' | 'closed'> }
130
+ | { kind: 'parked'; reason: string }
131
+ | { kind: 'runner-work' }
132
+ | { kind: 'unavailable'; diagnostic: string }
133
+
134
+ export interface BuildDetail extends BuildSummary {
135
+ /** Current work-owner decision. Open PRs use the build-owned config; ended
136
+ * PRs project the repository completion path directly from durable state. */
137
+ decision?: BuildDecisionProjection
138
+ openEscalations: OpenEscalation[]
139
+ /** Per-loop review overrides for the current spec; omitted when none are set. */
140
+ reviewRoundCeilings?: { plan?: number; code?: number }
141
+ openSessions: StatusOpenSession[]
142
+ observations: BuildObservation[]
143
+ /** Current setup failure only; a later successful attachment clears it. */
144
+ setupFailure?: SetupFailureDetail
145
+ /** True when the latest recorded execution has no following
146
+ * `execution.ended` — the guest may still be running under durable
147
+ * supervision even with no dispatcher process alive. */
148
+ executionOpen?: boolean
149
+ /** Latest provider execution and current lifecycle failure. Full history is
150
+ * available with --events. */
151
+ execution?: EventPayload<'execution.started'> & { seq: number }
152
+ infrastructureFailure?: EventPayload<'infrastructure.failed'> & { seq: number }
153
+ cleanupPending: boolean
154
+ verify: VerifyProgress
155
+ lastEvent?: { type: string; seq: number; ts: string; actor: Actor }
156
+ /** Present only with `--events <n>`: the newest n, chronological. */
157
+ events?: AbEvent[]
158
+ outcome?: BuildOutcome
159
+ }
160
+
161
+ /**
162
+ * `none` when there is no lease; `expired` when it has run out; else `held`.
163
+ *
164
+ * The boundary matches `Dispatcher.leaseSweep` (src/processes/dispatcher.ts),
165
+ * whose has-lease branch is `if (expiresAt > now) continue // healthy` — so
166
+ * `expiresAt === now` is expired in both places and the sweep and this report
167
+ * agree.
168
+ *
169
+ * That agreement is on the has-lease branch only. `leaseSweep` also grants an
170
+ * ABSENT lease a first-claim grace window (`updatedAt` within `leaseTtlMs`)
171
+ * before it acts, while this reports `none` regardless. The divergence is
172
+ * intended: `none` is a distinct word from `expired`, and teaching this
173
+ * function the grace window would put a dispatcher policy knob (`leaseTtlMs`)
174
+ * into a read-only reporter. It is documented in the guide instead.
175
+ */
176
+ export function leaseHealth(record: BuildRecord, now: Date): LeaseHealth {
177
+ if (record.lease === undefined) return 'none'
178
+ return new Date(record.lease.expiresAt).getTime() <= now.getTime() ? 'expired' : 'held'
179
+ }
180
+
181
+ function leaseInfo(record: BuildRecord, now: Date): LeaseInfo {
182
+ return {
183
+ health: leaseHealth(record, now),
184
+ ...(record.lease !== undefined
185
+ ? { holder: record.lease.holder, expiresAt: record.lease.expiresAt }
186
+ : {}),
187
+ ...(record.heartbeatAt !== undefined ? { heartbeatAt: record.heartbeatAt } : {}),
188
+ }
189
+ }
190
+
191
+ /**
192
+ * The summary over an ALREADY-reduced state — so `detail` reduces the log
193
+ * once and builds both projections from it, rather than reducing twice.
194
+ */
195
+ function summarizeFrom(record: BuildRecord, state: BuildState, now: Date): BuildSummary {
196
+ // `phase`, `round`, and `attempt` describe ONE phase occurrence, so they come
197
+ // from ONE source: the in-flight phase, else the last completed one — the
198
+ // same `currentPhase ?? lastCompletedPhase` the reducer uses to derive
199
+ // `state.phase`. Reading `phase` from that pair while reading round/attempt
200
+ // from `currentPhase` alone made the three disagree the moment a phase
201
+ // completed: `verify.completed` clears `currentPhase`, so between two verify
202
+ // steps a build on attempt 3 reported `verify:unit r1` — the attempt gone and
203
+ // the loop round stamped onto a phase that carries none. That reads as "first
204
+ // pass through verify" while verify is actually thrashing on its third.
205
+ //
206
+ // PhaseContext is authoritative about which axis a phase HAS (§15.3): loop
207
+ // phases carry `round`, verify and reconcile carry `attempt`, finalize
208
+ // carries neither. Taking both straight from it is what makes "current round
209
+ // or attempt WHEN APPLICABLE" fall out, instead of being re-decided here.
210
+ const active = state.currentPhase ?? state.lastCompletedPhase
211
+ const round = active?.round
212
+ const attempt = active?.attempt
213
+ return {
214
+ slug: record.slug,
215
+ // record.ticket is a TicketRef already carrying id/title/url — no artifact
216
+ // read and no ticket-provider call (live ticket state is out of scope).
217
+ ...(record.ticket !== undefined ? { ticket: record.ticket } : {}),
218
+ status: state.status,
219
+ ...(active?.phase !== undefined ? { phase: active.phase } : {}),
220
+ ...(round !== undefined ? { round } : {}),
221
+ ...(attempt !== undefined ? { attempt } : {}),
222
+ ...(state.pr !== undefined
223
+ ? {
224
+ pr: {
225
+ number: state.pr.number,
226
+ url: state.pr.url,
227
+ ...(state.prState !== undefined ? { state: state.prState } : {}),
228
+ },
229
+ }
230
+ : {}),
231
+ updatedAt: record.updatedAt,
232
+ lease: leaseInfo(record, now),
233
+ progress: buildProgress(record, state),
234
+ }
235
+ }
236
+
237
+ /**
238
+ * One build's summary. `round`/`attempt` come from the in-flight phase when
239
+ * there is one, so "current round or attempt when applicable" reflects what is
240
+ * happening now rather than the last thing that finished.
241
+ */
242
+ export function summarize(record: BuildRecord, events: AbEvent[], now: Date): BuildSummary {
243
+ return summarizeFrom(record, reduceBuild(events), now)
244
+ }
245
+
246
+ /** Summary plus the detail fields; `eventCount` appends the newest n events. */
247
+ /** One open session enriched with its live-view stream status (SPEC §9). */
248
+ export interface StatusOpenSession extends OpenSession {
249
+ /** Open until the stream closes; the store's records are authoritative. */
250
+ streamStatus?: 'open' | 'closed'
251
+ }
252
+
253
+ export function detail(
254
+ record: BuildRecord,
255
+ events: AbEvent[],
256
+ now: Date,
257
+ eventCount?: number,
258
+ decision?: BuildDecisionProjection,
259
+ streams?: readonly StreamRecord[],
260
+ ): BuildDetail {
261
+ const state = reduceBuild(events)
262
+ const streamStatus = new Map<string, { stream?: string; status: 'open' | 'closed' }>()
263
+ if (streams !== undefined) {
264
+ for (const record of streams) {
265
+ if (record.scope.kind !== 'build') continue
266
+ streamStatus.set(record.label, { stream: record.id, status: record.status })
267
+ }
268
+ }
269
+ const summary = summarizeFrom(record, state, now)
270
+ // §15.6-A: only the CURRENT cycle's results describe the current code.
271
+ // Without this filter an earlier cycle's passes read as current — wrong, and
272
+ // wrong in the reassuring direction.
273
+ //
274
+ // The cycle test is `seq > verify.cycleSince`, never result attempt equality:
275
+ // `maxAttemptSeen` is the full-log high-water and still names the failed
276
+ // cycle in the window before the next `verify.started`. `cycleSince` is the
277
+ // authoritative boundary the reducer and engine.ts already agree on (max of
278
+ // restart, code-review approve, and reconcile) — reading it here is what
279
+ // keeps this command's report equal to the projection used elsewhere rather
280
+ // than a second, drifting opinion.
281
+ const steps = state.verify.results
282
+ .filter((result) => result.seq > state.verify.cycleSince)
283
+ .map((result) => ({
284
+ step: result.step,
285
+ outcome: result.outcome,
286
+ ...(result.reason !== undefined ? { reason: result.reason } : {}),
287
+ }))
288
+ return {
289
+ ...summary,
290
+ ...(decision !== undefined ? { decision } : {}),
291
+ openEscalations: state.openEscalations,
292
+ ...(state.reviewRoundCeilings.plan !== undefined || state.reviewRoundCeilings.code !== undefined
293
+ ? { reviewRoundCeilings: { ...state.reviewRoundCeilings } }
294
+ : {}),
295
+ openSessions: state.sessions.open.map((session) => {
296
+ const record = streamStatus.get(`session:${session.session}`)
297
+ return {
298
+ ...session,
299
+ ...(record?.stream !== undefined ? { stream: record.stream } : {}),
300
+ ...(record !== undefined ? { streamStatus: record.status } : {}),
301
+ }
302
+ }),
303
+ // Observations are historical facts rather than a derived current-state
304
+ // opinion. Preserve event order and exact optional evidence fields.
305
+ observations: events
306
+ .filter((event) => event.type === 'observation.recorded')
307
+ .map((event) => ({
308
+ id: event.payload.id,
309
+ kind: event.payload.kind,
310
+ summary: event.payload.summary,
311
+ ...(event.payload.files !== undefined ? { files: event.payload.files } : {}),
312
+ ...(event.payload.refs !== undefined ? { refs: event.payload.refs } : {}),
313
+ })),
314
+ ...(state.setupFailure !== undefined
315
+ ? {
316
+ setupFailure: {
317
+ attempt: state.setupFailure.attempt,
318
+ command: state.setupFailure.command,
319
+ exitStatus: state.setupFailure.exitStatus,
320
+ output: state.setupFailure.output,
321
+ },
322
+ }
323
+ : {}),
324
+ ...(state.executions.at(-1) !== undefined ? { execution: state.executions.at(-1)! } : {}),
325
+ // Durable supervision: a recorded execution with no following end is the
326
+ // source of truth for whether a guest may still be running.
327
+ executionOpen: openExecution(events) !== null,
328
+ ...(state.infrastructureFailure !== undefined
329
+ ? { infrastructureFailure: state.infrastructureFailure }
330
+ : {}),
331
+ cleanupPending:
332
+ state.infrastructureFailure?.cleanupPending === true ||
333
+ state.cleanupAttempts.at(-1)?.outcome === 'unknown',
334
+ verify: {
335
+ // Preserve the status API's display counter while sourcing it explicitly
336
+ // from the reducer's full-log high-water, never from cycle membership.
337
+ attempt: state.verify.maxAttemptSeen,
338
+ steps,
339
+ ...(state.verify.currentStep !== undefined ? { currentStep: state.verify.currentStep } : {}),
340
+ },
341
+ ...(state.lastEvent !== undefined
342
+ ? {
343
+ lastEvent: {
344
+ type: state.lastEvent.type,
345
+ seq: state.lastEvent.seq,
346
+ ts: state.lastEvent.ts,
347
+ actor: state.lastEvent.actor,
348
+ },
349
+ }
350
+ : {}),
351
+ // getEvents returns seq-ordered events, so the newest n slice out already
352
+ // chronological — which is what the AC asks for.
353
+ ...(eventCount !== undefined ? { events: events.slice(-eventCount) } : {}),
354
+ ...(state.outcome !== undefined ? { outcome: state.outcome } : {}),
355
+ }
356
+ }
357
+
358
+ // ── Renderers ────────────────────────────────────────────────────────────────
359
+
360
+ /** Lease health as a literal word — never a color (§16: colors out of scope). */
361
+ function healthWord(health: LeaseHealth): string {
362
+ return health === 'none' ? 'no-lease' : health
363
+ }
364
+
365
+ /** Preserve printable text while making terminal control bytes inert. Newlines
366
+ * are split by the caller so multiline setup output remains readable. */
367
+ function safeStatusText(value: string): string {
368
+ let displayed = ''
369
+ for (const char of value) {
370
+ const code = char.codePointAt(0)!
371
+ displayed += code >= 0x20 && code !== 0x7f ? char : `\\u{${code.toString(16)}}`
372
+ }
373
+ return displayed
374
+ }
375
+
376
+ /** `r<round>` / `a<attempt>` suffixed onto the phase — the loop vs verify axis. */
377
+ function phaseCell(summary: BuildSummary): string {
378
+ if (summary.phase === undefined) return '—'
379
+ if (summary.attempt !== undefined) return `${summary.phase} a${summary.attempt}`
380
+ if (summary.round !== undefined) return `${summary.phase} r${summary.round}`
381
+ return summary.phase
382
+ }
383
+
384
+ /** `#7 open https://…` — state AND link, per the summary's required fields. */
385
+ function prCell(summary: BuildSummary): string {
386
+ if (summary.pr === undefined) return '—'
387
+ const state = summary.pr.state !== undefined ? ` ${summary.pr.state}` : ''
388
+ return `#${summary.pr.number}${state} ${summary.pr.url}`
389
+ }
390
+
391
+ /** Long enough to recognize the ticket, short enough to keep rows scannable. */
392
+ const TITLE_WIDTH = 40
393
+
394
+ /** `AUT-6 Interactive build dashboard…` — id AND title, per the same list. */
395
+ function ticketCell(summary: BuildSummary): string {
396
+ const ticket = summary.ticket
397
+ if (ticket === undefined) return '—'
398
+ if (ticket.title === undefined || ticket.title === '') return ticket.id
399
+ const title =
400
+ ticket.title.length > TITLE_WIDTH ? `${ticket.title.slice(0, TITLE_WIDTH - 1)}…` : ticket.title
401
+ return `${ticket.id} ${title}`
402
+ }
403
+
404
+ /** Coarse relative age — a status read wants "how stale", not a timestamp. */
405
+ export function relativeTime(iso: string, now: Date): string {
406
+ const then = new Date(iso).getTime()
407
+ if (Number.isNaN(then)) return iso
408
+ const seconds = Math.round((now.getTime() - then) / 1000)
409
+ if (seconds < 0) return 'just now'
410
+ if (seconds < 60) return `${seconds}s ago`
411
+ const minutes = Math.floor(seconds / 60)
412
+ if (minutes < 60) return `${minutes}m ago`
413
+ const hours = Math.floor(minutes / 60)
414
+ if (hours < 24) return `${hours}h ago`
415
+ return `${Math.floor(hours / 24)}d ago`
416
+ }
417
+
418
+ /**
419
+ * Delegated-write attribution (§15.1): a human actor carrying a via marker
420
+ * renders as `human (via session os_…)` / `human (via mcp client)`; plain
421
+ * actors render unchanged. The JSON output and the operator API's build
422
+ * detail serialize the actor object verbatim, so the marker reaches those
423
+ * surfaces without transformation.
424
+ */
425
+ function viaMarker(actor: Actor): string {
426
+ if (actor.kind !== 'human' || actor.via === undefined) return actor.kind
427
+ return `human (via ${actor.via.kind === 'session' ? `session ${actor.via.id}` : `mcp ${actor.via.client}`})`
428
+ }
429
+
430
+ function padColumns(rows: string[][]): string[] {
431
+ const widths: number[] = []
432
+ for (const row of rows) {
433
+ row.forEach((cell, i) => {
434
+ widths[i] = Math.max(widths[i] ?? 0, cell.length)
435
+ })
436
+ }
437
+ return rows.map((row) =>
438
+ row
439
+ .map((cell, i) => (i === row.length - 1 ? cell : cell.padEnd(widths[i] ?? 0)))
440
+ .join(' ')
441
+ .trimEnd(),
442
+ )
443
+ }
444
+
445
+ /**
446
+ * One aligned row per build. `emptyNote` names the filter in effect when there
447
+ * is nothing to show, so an empty result is an honest answer rather than a
448
+ * blank that reads like a bug.
449
+ */
450
+ export function renderSummaries(summaries: BuildSummary[], now: Date, emptyNote: string): string[] {
451
+ if (summaries.length === 0) return [emptyNote]
452
+ // The two free-text columns (ticket title, PR link) go last, where their
453
+ // width costs no alignment on the scannable columns to their left.
454
+ const rows: string[][] = [
455
+ ['BUILD', 'STATUS', 'PHASE', 'LEASE', 'HEARTBEAT', 'PROGRESS', 'TICKET', 'PR'],
456
+ ]
457
+ for (const summary of summaries) {
458
+ const progressAge =
459
+ summary.progress.lastEventAt === undefined
460
+ ? '—'
461
+ : relativeTime(summary.progress.lastEventAt, now)
462
+ rows.push([
463
+ summary.slug,
464
+ summary.status,
465
+ phaseCell(summary),
466
+ healthWord(summary.lease.health),
467
+ summary.progress.heartbeatAt === undefined
468
+ ? '—'
469
+ : relativeTime(summary.progress.heartbeatAt, now),
470
+ `${progressAge}${isDiverged(summary.progress, now) ? ' diverged' : ''}`,
471
+ ticketCell(summary),
472
+ prCell(summary),
473
+ ])
474
+ }
475
+ return padColumns(rows)
476
+ }
477
+
478
+ /** Labeled sections, each omitted when absent ("when each is available"). */
479
+ export function renderDetail(d: BuildDetail, now: Date): string[] {
480
+ const lines: string[] = [`build ${d.slug}`]
481
+ if (d.ticket !== undefined) {
482
+ const title = d.ticket.title !== undefined ? ` — ${d.ticket.title}` : ''
483
+ lines.push(` ticket: ${d.ticket.id}${title}`)
484
+ if (d.ticket.url !== undefined) lines.push(` ticket url: ${d.ticket.url}`)
485
+ }
486
+ lines.push(` status: ${d.status}${d.outcome !== undefined ? ` (${d.outcome})` : ''}`)
487
+ lines.push(` phase: ${phaseCell(d)}`)
488
+ if (d.reviewRoundCeilings !== undefined) {
489
+ const ceilings = [
490
+ ...(d.reviewRoundCeilings.plan !== undefined ? [`plan ${d.reviewRoundCeilings.plan}`] : []),
491
+ ...(d.reviewRoundCeilings.code !== undefined ? [`code ${d.reviewRoundCeilings.code}`] : []),
492
+ ]
493
+ lines.push(` review round ceiling: ${ceilings.join(', ')}`)
494
+ }
495
+ lines.push(` updated: ${d.updatedAt} (${relativeTime(d.updatedAt, now)})`)
496
+
497
+ // Its own line, never folded into status: a running build with an expired
498
+ // lease is the case this exists to make visible.
499
+ const lease = [` lease: ${healthWord(d.lease.health)}`]
500
+ if (d.lease.holder !== undefined) lease.push(`holder ${d.lease.holder}`)
501
+ if (d.lease.expiresAt !== undefined) lease.push(`expires ${d.lease.expiresAt}`)
502
+ lines.push(lease.join(' '))
503
+ lines.push(
504
+ d.progress.heartbeatAt === undefined
505
+ ? ' heartbeat: —'
506
+ : ` heartbeat: ${d.progress.heartbeatAt} (${relativeTime(d.progress.heartbeatAt, now)})`,
507
+ )
508
+ lines.push(
509
+ d.progress.lastEventAt === undefined
510
+ ? ' progress: —'
511
+ : ` progress: ${d.progress.lastEventAt} (${relativeTime(d.progress.lastEventAt, now)})${
512
+ isDiverged(d.progress, now) ? ' — diverged' : ''
513
+ }`,
514
+ )
515
+ if (d.status === 'running' && d.lease.health === 'expired') {
516
+ if (d.decision?.kind === 'awaiting-pr') {
517
+ lines.push(
518
+ ' note: running with an expired lease — parked waiting on the PR; no runner re-attachment is pending',
519
+ )
520
+ } else if (d.decision?.kind === 'repository-completion') {
521
+ lines.push(
522
+ ` note: running with an expired lease — the PR is ${d.decision.prState}; repository-level completion is pending and no runner re-attachment is pending`,
523
+ )
524
+ } else if (d.decision?.kind === 'parked') {
525
+ lines.push(
526
+ ' note: running with an expired lease — the current decision is parked; no runner re-attachment is pending',
527
+ )
528
+ } else if (d.decision?.kind === 'unavailable') {
529
+ lines.push(
530
+ ' note: running with an expired lease — runner actionability could not be determined from the unavailable build config',
531
+ )
532
+ } else if (d.executionOpen === true) {
533
+ lines.push(
534
+ ' note: running with an expired lease — the recorded execution is still the\n' +
535
+ ' source of truth: the guest may keep running and the lease sweep\n' +
536
+ ' re-attaches only when the guest is actually gone',
537
+ )
538
+ } else {
539
+ lines.push(
540
+ ' note: running with an expired lease — the runner is gone; the lease sweep will re-attach it',
541
+ )
542
+ }
543
+ }
544
+
545
+ if (d.pr !== undefined) {
546
+ lines.push(
547
+ ` pr: #${d.pr.number}${d.pr.state !== undefined ? ` (${d.pr.state})` : ''} ${d.pr.url}`,
548
+ )
549
+ }
550
+ if (d.decision?.kind === 'awaiting-pr') {
551
+ lines.push(
552
+ ` waiting: on PR${d.decision.reason !== undefined ? ` — ${d.decision.reason}` : ''}`,
553
+ )
554
+ } else if (d.decision?.kind === 'repository-completion') {
555
+ lines.push(
556
+ ` waiting: repository-level completion after the PR was ${d.decision.prState}; no runner re-attachment is pending`,
557
+ )
558
+ } else if (d.decision?.kind === 'unavailable') {
559
+ lines.push(` decision: unavailable — ${d.decision.diagnostic}`)
560
+ }
561
+
562
+ if (d.execution !== undefined) {
563
+ lines.push(
564
+ ` execution: ${safeStatusText(d.execution.provider)} workspace=${safeStatusText(d.execution.workspaceRef)} instance=${safeStatusText(d.execution.instance)}`,
565
+ )
566
+ if (d.execution.environmentId !== undefined)
567
+ lines.push(` environment: ${safeStatusText(d.execution.environmentId)}`)
568
+ if (d.execution.sessionId !== undefined)
569
+ lines.push(` session: ${safeStatusText(d.execution.sessionId)}`)
570
+ }
571
+ if (d.infrastructureFailure !== undefined) {
572
+ lines.push(
573
+ ` infrastructure failure: ${d.infrastructureFailure.operation} attempt ${d.infrastructureFailure.attempt} (${d.infrastructureFailure.cause})`,
574
+ )
575
+ lines.push(` error: ${safeStatusText(d.infrastructureFailure.error)}`)
576
+ if (d.cleanupPending) lines.push(' cleanup: pending provider reconciliation')
577
+ }
578
+
579
+ if (d.setupFailure !== undefined) {
580
+ const status =
581
+ d.setupFailure.exitStatus === null
582
+ ? 'exit status unavailable'
583
+ : `exit status ${d.setupFailure.exitStatus}`
584
+ lines.push(` setup failure: attempt ${d.setupFailure.attempt}`)
585
+ lines.push(` command: ${safeStatusText(d.setupFailure.command)}`)
586
+ lines.push(` status: ${status}`)
587
+ lines.push(' output:')
588
+ const output = d.setupFailure.output || '(no output)'
589
+ for (const line of output.split('\n')) lines.push(` ${safeStatusText(line)}`)
590
+ }
591
+
592
+ if (d.openEscalations.length > 0) {
593
+ lines.push(` escalations (${d.openEscalations.length} unresolved):`)
594
+ for (const escalation of d.openEscalations) {
595
+ lines.push(` [${escalation.id}] ${escalation.phase}: ${escalation.question}`)
596
+ }
597
+ }
598
+
599
+ if (d.openSessions.length > 0) {
600
+ lines.push(` open sessions (${d.openSessions.length}):`)
601
+ for (const session of d.openSessions) {
602
+ const stream =
603
+ session.stream !== undefined
604
+ ? ` — stream ${session.stream} (${session.streamStatus ?? 'open'})`
605
+ : ''
606
+ lines.push(
607
+ ` ${session.session} — ${session.role} on ${session.phase} (runner ${session.runner})${stream}`,
608
+ )
609
+ }
610
+ }
611
+
612
+ if (d.observations.length > 0) {
613
+ lines.push(` observations (${d.observations.length}):`)
614
+ for (const observation of d.observations) {
615
+ lines.push(` [${observation.id}] ${observation.kind}: ${observation.summary}`)
616
+ if (observation.files !== undefined) {
617
+ lines.push(` files: ${observation.files.join(', ')}`)
618
+ }
619
+ if (observation.refs !== undefined) {
620
+ lines.push(` refs: ${observation.refs.join(', ')}`)
621
+ }
622
+ }
623
+ }
624
+
625
+ if (d.verify.attempt > 0) {
626
+ lines.push(` verify: attempt ${d.verify.attempt}`)
627
+ for (const step of d.verify.steps) {
628
+ if (step.outcome === 'skipped') {
629
+ lines.push(` SKIP ${step.step} — ${step.reason ?? '(reason unavailable)'}`)
630
+ } else {
631
+ lines.push(` ${step.outcome === 'pass' ? 'pass' : 'FAIL'} ${step.step}`)
632
+ }
633
+ }
634
+ if (d.verify.currentStep !== undefined) {
635
+ lines.push(` running ${d.verify.currentStep}`)
636
+ }
637
+ }
638
+
639
+ if (d.lastEvent !== undefined) {
640
+ lines.push(
641
+ ` last event: ${d.lastEvent.type} (seq ${d.lastEvent.seq}) ${d.lastEvent.ts} by ${viaMarker(d.lastEvent.actor)}`,
642
+ )
643
+ }
644
+
645
+ if (d.events !== undefined) {
646
+ lines.push(` events (newest ${d.events.length}, chronological):`)
647
+ const rows = d.events.map((event) => [
648
+ ` ${event.seq}`,
649
+ event.ts,
650
+ event.type,
651
+ viaMarker(event.actor),
652
+ ])
653
+ for (const line of padColumns(rows)) lines.push(line)
654
+ }
655
+
656
+ return lines
657
+ }
658
+
659
+ // ── The command shells ───────────────────────────────────────────────────────
660
+
661
+ export interface StatusOpts {
662
+ /** The cwd — resolved to the main repo root before matching (see currentRepo). */
663
+ targetRepo: string
664
+ /** Process environment: AB_STORE / AB_TOKEN (D8). */
665
+ env: Record<string, string | undefined>
666
+ exec: Exec
667
+ stdout: (line: string) => void
668
+ json?: boolean
669
+ /** `--store <ref>`; same reference behavior dispatch has (§7.2). */
670
+ storeRef?: string
671
+ /** Injectable store seam — mirrors dispatch.ts's `wire`, keeps tests off the
672
+ * real filesystem. */
673
+ openStore?: StoreOpener
674
+ /** Injectable clock; defaults to the wall clock. */
675
+ now?: () => Date
676
+ }
677
+
678
+ /** Statuses a bare `ab builds` shows: the ones an operator can still act on. */
679
+ const ACTIVE_STATUSES: BuildStatus[] = ['running', 'paused', 'blocked']
680
+
681
+ export function statusFilter(all?: boolean, queued?: boolean): BuildStatus[] {
682
+ if (all === true) return ['queued', 'running', 'paused', 'blocked', 'done', 'aborted']
683
+ return queued === true ? [...ACTIVE_STATUSES, 'queued'] : ACTIVE_STATUSES
684
+ }
685
+
686
+ export interface AbBuildsOpts extends StatusOpts {
687
+ queued?: boolean
688
+ all?: boolean
689
+ }
690
+
691
+ /** `ab builds` — this repo's builds, active by default. Read-only. */
692
+ export async function abBuilds(opts: AbBuildsOpts): Promise<void> {
693
+ const now = (opts.now ?? (() => new Date()))()
694
+ await withAmbientReadStore(opts, async ({ store, repo }) => {
695
+ // Repository identity is the checkout's normalized origin. Old records
696
+ // are keyed by checkout path and are not migrated; they stay visible
697
+ // from any checkout whose origin matches the recorded `repoOrigin`
698
+ // (read compatibility, not migration). Foreign-origin records are hidden.
699
+ const identity = normalizeGitRemoteUrl(repo)
700
+ const mine = (record: BuildRecord): boolean =>
701
+ record.repo === identity ||
702
+ (record.repoOrigin !== undefined && normalizeGitRemoteUrl(record.repoOrigin) === identity)
703
+ const wanted = new Set(statusFilter(opts.all, opts.queued))
704
+ // Cross-repo aggregation is out of scope (§12: one dispatcher per repo,
705
+ // one repo's builds per answer).
706
+ const records = (await store.listBuilds()).filter(mine)
707
+ const summaries: BuildSummary[] = []
708
+ for (const record of records) {
709
+ const summary = summarize(record, await store.getEvents(record.slug), now)
710
+ if (wanted.has(summary.status)) summaries.push(summary)
711
+ }
712
+ summaries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
713
+
714
+ if (opts.json === true) {
715
+ opts.stdout(JSON.stringify(summaries, null, 2))
716
+ return
717
+ }
718
+ // The empty line names the filter that matched nothing and suggests only
719
+ // the flags that would actually widen it — telling a caller who passed
720
+ // --queued to "try --queued" reads as a broken command.
721
+ const scope =
722
+ opts.all === true
723
+ ? 'builds'
724
+ : opts.queued === true
725
+ ? 'active or queued builds'
726
+ : 'active builds'
727
+ const hint =
728
+ opts.all === true ? '' : opts.queued === true ? ' — try --all' : ' — try --queued or --all'
729
+ for (const line of renderSummaries(summaries, now, `no ${scope} for ${repo}${hint}`)) {
730
+ opts.stdout(line)
731
+ }
732
+ })
733
+ }
734
+
735
+ export interface AbBuildStatusOpts extends StatusOpts {
736
+ slug: string
737
+ events?: number
738
+ }
739
+
740
+ function openWorkspacePath(events: AbEvent[]): string | undefined {
741
+ let path: string | undefined
742
+ for (const event of events) {
743
+ if (event.type === 'workspace.provisioned') {
744
+ path = event.payload.path ?? event.payload.ref
745
+ } else if (event.type === 'workspace.released') {
746
+ path = undefined
747
+ }
748
+ }
749
+ return path
750
+ }
751
+
752
+ /** The kernel tail both config sources share, so the deposited artifact and
753
+ * the workspace file cannot drift in what they compute — the sources differ
754
+ * only in where the config comes from, never in what is decided with it. */
755
+ function decideFromConfig(
756
+ events: AbEvent[],
757
+ state: BuildState,
758
+ config: Config,
759
+ ): BuildDecisionProjection {
760
+ const decision = decideNext(events, config)
761
+ if (decision.kind !== 'wait') return { kind: 'runner-work' }
762
+ if (decision.reason !== 'awaiting-pr') return { kind: 'parked', reason: decision.reason }
763
+ const reason = currentAutoMergeDeferral(events, state)
764
+ return { kind: 'awaiting-pr', ...(reason !== undefined ? { reason } : {}) }
765
+ }
766
+
767
+ /** Load the build-owned configuration for the current work-owner decision.
768
+ *
769
+ * Source order: the store's deposited `build-runner-effective-config` artifact
770
+ * first — the exact config the dispatcher froze at the last runner launch and
771
+ * the one the build child actually runs on — then the local workspace's
772
+ * `autobuild.toml` when no deposit exists (older records predate per-launch
773
+ * deposits). For a remote build the recorded workspace path is a guest path
774
+ * that does not exist on the operator's machine; the deposit is what makes the
775
+ * decision available there. The decision is reported unavailable only when
776
+ * neither source can supply a configuration, and the diagnostic then names the
777
+ * missing source rather than a filesystem path: paths embed guest locations
778
+ * the operator cannot access. An unavailable decision is a reportable
779
+ * degradation, never a reason to guess from the current main branch or to make
780
+ * the read-only command fail. */
781
+ async function projectBuildDecision(
782
+ slug: string,
783
+ events: AbEvent[],
784
+ store: Pick<BuildStore, 'getArtifact'>,
785
+ ): Promise<BuildDecisionProjection | undefined> {
786
+ const state = reduceBuild(events)
787
+ const active =
788
+ state.status === 'running' || state.status === 'paused' || state.status === 'blocked'
789
+ if (!active) return undefined
790
+ if (state.prState === 'merged' || state.prState === 'closed') {
791
+ return { kind: 'repository-completion', prState: state.prState }
792
+ }
793
+ if (state.prState !== 'open') return undefined
794
+
795
+ // Source 1 — the store deposit. A deposit that fails to parse is itself
796
+ // reportable state: it is surfaced as unavailable naming the artifact rather
797
+ // than silently falling back to a possibly stale workspace file.
798
+ const artifact = await store.getArtifact(slug, BUILD_EFFECTIVE_CONFIG_ARTIFACT)
799
+ if (artifact !== null) {
800
+ try {
801
+ return decideFromConfig(events, state, parseEffectiveBuildConfig(artifact))
802
+ } catch (error) {
803
+ const message = error instanceof Error ? error.message : String(error)
804
+ return {
805
+ kind: 'unavailable',
806
+ diagnostic: `the deposited ${BUILD_EFFECTIVE_CONFIG_ARTIFACT} artifact could not be read: ${message}`,
807
+ }
808
+ }
809
+ }
810
+
811
+ // Source 2 — the local workspace, exactly as before, only when no artifact
812
+ // is deposited.
813
+ const workspace = openWorkspacePath(events)
814
+ if (workspace === undefined) {
815
+ return {
816
+ kind: 'unavailable',
817
+ diagnostic: `no ${BUILD_EFFECTIVE_CONFIG_ARTIFACT} artifact is deposited and no unreleased build workspace is recorded, so the build config cannot be read`,
818
+ }
819
+ }
820
+
821
+ try {
822
+ return decideFromConfig(events, state, await loadConfig(join(workspace, 'autobuild.toml')))
823
+ } catch {
824
+ // The raw error deliberately stays here: ENOENT/ENOTDIR messages embed the
825
+ // recorded workspace path, which for a remote build is a guest location.
826
+ return {
827
+ kind: 'unavailable',
828
+ diagnostic: `no ${BUILD_EFFECTIVE_CONFIG_ARTIFACT} artifact is deposited and the build config in the recorded workspace could not be read`,
829
+ }
830
+ }
831
+ }
832
+
833
+ /** `ab build status <slug>` — one build in detail. Read-only.
834
+ *
835
+ * Repository authority mirrors the other ambient reads: an operator invocation
836
+ * (no ambient identity) must match the store-recorded repository by path, or
837
+ * by normalized origin for a differently located checkout of the same
838
+ * repository. A validated ambient *build* session additionally authorizes
839
+ * reading its OWN build — `AB_BUILD` equal to the requested slug, from the
840
+ * session's own store — the same trust basis as the phase-scoped
841
+ * `ab artifact get` (AUT-312): the runner-injected tuple validated by
842
+ * `resolveCliEnv` is the authority, so a guest checkout of the same
843
+ * repository at a different path can read its build's status projection even
844
+ * when the record predates origin recording. The bypass is bounded by slug
845
+ * equality and store-ref equality, never a general relaxation; a sessionless
846
+ * relocated-checkout read of a record without `repoOrigin` remains foreign by
847
+ * design (the documented AUT-314 limitation).
848
+ */
849
+ export async function abBuildStatus(opts: AbBuildStatusOpts): Promise<void> {
850
+ const now = (opts.now ?? (() => new Date()))()
851
+ await withAmbientReadStore(opts, async (context) => {
852
+ const record = await context.store.getBuild(opts.slug)
853
+ if (record === null) {
854
+ throw new Error(
855
+ `no build "${opts.slug}" in this store — run 'ab builds --all' to list ` +
856
+ "this repo's builds, or pass --store <ref> if it lives in another store",
857
+ )
858
+ }
859
+ // Own-session authority: slug equality bounds the read to the session's
860
+ // own build, and an explicit --store must name the session's own store for
861
+ // the bypass to apply. Every other combination falls through to the
862
+ // ordinary repository gate, unchanged — which now keys on the checkout's
863
+ // normalized origin identity (`context.checkout` is the physical path;
864
+ // `buildInRepository` derives the identity from it).
865
+ const ownSession =
866
+ context.ambient !== undefined &&
867
+ 'build' in context.ambient &&
868
+ context.ambient.build === opts.slug &&
869
+ (opts.storeRef === undefined || opts.storeRef === context.ambient.store)
870
+ if (!(ownSession || (await buildInRepository(record, context.checkout, opts.exec)))) {
871
+ throw new Error(
872
+ `build "${opts.slug}" belongs to repository "${record.repo}", not "${context.repo}"`,
873
+ )
874
+ }
875
+ const events = await context.store.getEvents(opts.slug)
876
+ const decision = await projectBuildDecision(opts.slug, events, context.store)
877
+ // Stream enrichment is presentation (SPEC §9): an unavailable read
878
+ // degrades to payload-carried ids without a status, never a failure.
879
+ let streams: StreamRecord[] | undefined
880
+ try {
881
+ streams = await context.store.listStreams({
882
+ kind: 'build',
883
+ build: opts.slug,
884
+ } satisfies StreamScope)
885
+ } catch {
886
+ streams = undefined
887
+ }
888
+ const d = detail(record, events, now, opts.events, decision, streams)
889
+ if (opts.json === true) {
890
+ opts.stdout(JSON.stringify(d, null, 2))
891
+ return
892
+ }
893
+ for (const line of renderDetail(d, now)) opts.stdout(line)
894
+ })
895
+ }