@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,587 @@
1
+ /**
2
+ * `ab wait` — the blocking sibling of `ab watch` (§8.2, §7.2). It follows the
3
+ * event logs of one, several, or all active builds in the repository and
4
+ * blocks until a targeted build satisfies a condition, then prints exactly one
5
+ * record — the `ab watch` record plus a `condition` field — and exits with a
6
+ * code that tells the caller what happened: 0 satisfied, 2 named builds all
7
+ * terminal unsatisfied, 3 timeout, 4 interrupted. Distinct exit codes let a
8
+ * wake-up be self-describing without parsing prose.
9
+ *
10
+ * The command shares `ab watch`'s machinery — target selection, ambient
11
+ * scoping (via the extracted `assertReadScope`), the cursor codec, the record
12
+ * projection, the poll cadence, and the read-failure policy — but ends on the
13
+ * first satisfied condition instead of streaming. Conditions are derived from
14
+ * the same reducer that produces `ab build status` (`status`, `prState`,
15
+ * `phase`), so "blocked" means an open escalation exactly as it does
16
+ * everywhere else; no new projection is defined. In the manner of `kubectl
17
+ * wait`, a state condition that already holds when the command starts returns
18
+ * immediately with the build's latest event.
19
+ *
20
+ * Read-only: it appends no event, claims or renews no lease, and creates no
21
+ * build or repository record. Output carries no ANSI, ever, in either form.
22
+ */
23
+ import type { AbEvent } from '../events/catalog'
24
+ import type { EventType } from '../events/payloads'
25
+ import type { BuildState, PrLifecycle } from '../kernel/reducer'
26
+ import { reduceBuild } from '../kernel/reducer'
27
+ import { isPhase, type BuildStatus, type Phase } from '../ontology'
28
+ import type { Exec } from '../ports/workspace/git-worktree'
29
+ import { buildInRepository, normalizeGitRemoteUrl } from './repo-state'
30
+ import type { BuildRecord } from '../store/types'
31
+ import { withAmbientReadStore, type StoreOpener } from './store-opening'
32
+ import {
33
+ assertReadScope,
34
+ BUILD_ATTENTION_EVENTS,
35
+ compileEventGlobs,
36
+ decodeCursor,
37
+ encodeCursor,
38
+ parseDurationMs,
39
+ projectRecord,
40
+ renderWatchLine,
41
+ watchSelection,
42
+ } from './watch'
43
+
44
+ export const WAIT_USAGE =
45
+ 'usage: ab wait [<slug>...] (--for <condition> | --event <glob>)... [--since <cursor>] ' +
46
+ '[--timeout <duration>] [--interval <duration>] [--json] [--store <ref>]'
47
+
48
+ /** Default poll cadence and wait horizon — identical to `ab watch`. */
49
+ const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000
50
+
51
+ const NONTERMINAL_STATUSES: readonly BuildStatus[] = ['queued', 'running', 'paused', 'blocked']
52
+ const TERMINAL_STATUSES: readonly BuildStatus[] = ['done', 'aborted']
53
+
54
+ /** The `--for` condition vocabulary, in the order the help documents it. */
55
+ const CONDITION_VOCABULARY =
56
+ 'blocked, paused, running, done, aborted, terminal, pr=open, pr=merged, pr=closed, ' +
57
+ 'pr=conflicted, phase=<phase> (a core phase or verify:<step>), attention'
58
+
59
+ // ── Conditions ───────────────────────────────────────────────────────────────
60
+
61
+ type ConditionBody =
62
+ | { kind: 'status'; status: BuildStatus }
63
+ | { kind: 'terminal' }
64
+ | { kind: 'pr'; prState: PrLifecycle }
65
+ | { kind: 'phase'; phase: Phase }
66
+ | { kind: 'attention' }
67
+
68
+ interface WaitCondition {
69
+ /** The original `--for` value — what a matching record's `condition` names. */
70
+ text: string
71
+ cond: ConditionBody
72
+ }
73
+
74
+ export interface EventGlob {
75
+ regex: RegExp
76
+ /** The original `--event` glob — what a matching record's `condition` names. */
77
+ text: string
78
+ }
79
+
80
+ function isAttentionEvent(type: EventType): boolean {
81
+ return (BUILD_ATTENTION_EVENTS as readonly string[]).includes(type)
82
+ }
83
+
84
+ /**
85
+ * Parse and validate `--for` values and `--event` globs into the closed
86
+ * discriminated union — before any store access. `phase=<p>` validity is
87
+ * `isPhase`: the core phases plus the open `verify:<step>` form (steps are
88
+ * config-defined, so no catalog check). An unknown value, or an empty
89
+ * `phase=`, is a usage error naming the value, the full vocabulary, and the
90
+ * usage line.
91
+ */
92
+ export function parseConditions(
93
+ forValues: readonly string[],
94
+ eventGlobs: readonly string[],
95
+ ): { conditions: WaitCondition[]; globs: EventGlob[] } {
96
+ const unknown = (value: string): Error =>
97
+ new Error(
98
+ `unknown --for condition "${value}" — the condition vocabulary is: ${CONDITION_VOCABULARY}` +
99
+ ` — ${WAIT_USAGE}`,
100
+ )
101
+ const conditions = forValues.map((value): WaitCondition => {
102
+ if (value === 'terminal') return { text: value, cond: { kind: 'terminal' } }
103
+ if (value === 'attention') return { text: value, cond: { kind: 'attention' } }
104
+ if (value.startsWith('pr=')) {
105
+ const prState = value.slice('pr='.length)
106
+ if (
107
+ prState === 'open' ||
108
+ prState === 'merged' ||
109
+ prState === 'closed' ||
110
+ prState === 'conflicted'
111
+ ) {
112
+ return { text: value, cond: { kind: 'pr', prState } }
113
+ }
114
+ throw unknown(value)
115
+ }
116
+ if (value.startsWith('phase=')) {
117
+ const phase = value.slice('phase='.length)
118
+ if (isPhase(phase)) return { text: value, cond: { kind: 'phase', phase } }
119
+ throw unknown(value)
120
+ }
121
+ const status = value as BuildStatus
122
+ if (
123
+ status === 'blocked' ||
124
+ status === 'paused' ||
125
+ status === 'running' ||
126
+ status === 'done' ||
127
+ status === 'aborted'
128
+ ) {
129
+ return { text: value, cond: { kind: 'status', status } }
130
+ }
131
+ throw unknown(value)
132
+ })
133
+ // Glob validation needs only the event catalog — before any store access.
134
+ const regexes = compileEventGlobs(eventGlobs, { repository: false, usage: WAIT_USAGE })
135
+ const globs = regexes.map((regex, index) => ({ regex, text: eventGlobs[index]! }))
136
+ return { conditions, globs }
137
+ }
138
+
139
+ /** Does one condition body hold over a reduction? Attention is event-only. */
140
+ function stateSatisfies(cond: ConditionBody, state: BuildState): boolean {
141
+ switch (cond.kind) {
142
+ case 'status':
143
+ return state.status === cond.status
144
+ case 'terminal':
145
+ return TERMINAL_STATUSES.includes(state.status)
146
+ case 'pr':
147
+ return state.prState === cond.prState
148
+ case 'phase':
149
+ return (state.phase ?? null) === cond.phase
150
+ case 'attention':
151
+ return false
152
+ }
153
+ }
154
+
155
+ /**
156
+ * The first state-satisfying `--for` value in supply order, or null. Used for
157
+ * the start-of-command and start-of-registration checks — never during a
158
+ * backlog replay, where only event conditions may fire.
159
+ */
160
+ export function matchState(conditions: readonly WaitCondition[], state: BuildState): string | null {
161
+ for (const { text, cond } of conditions) {
162
+ if (stateSatisfies(cond, state)) return text
163
+ }
164
+ return null
165
+ }
166
+
167
+ /** The first event-satisfying condition: attention per supply order, then
168
+ * globs in supply order. Used on a cursor-resumed backlog, where state
169
+ * conditions must not fire. */
170
+ export function matchEvent(
171
+ event: Pick<AbEvent, 'type'>,
172
+ conditions: readonly WaitCondition[],
173
+ globs: readonly EventGlob[],
174
+ ): string | null {
175
+ for (const { text, cond } of conditions) {
176
+ if (cond.kind === 'attention' && isAttentionEvent(event.type)) return text
177
+ }
178
+ for (const { regex, text } of globs) {
179
+ if (regex.test(event.type)) return text
180
+ }
181
+ return null
182
+ }
183
+
184
+ /**
185
+ * One event and the reduction after it: all `--for` values in supply order —
186
+ * each checked against the state first and the event's attention membership
187
+ * second — then all `--event` globs in supply order. The matched condition's
188
+ * original string becomes the record's `condition` field.
189
+ */
190
+ function matchFirst(
191
+ event: Pick<AbEvent, 'type'>,
192
+ state: BuildState,
193
+ conditions: readonly WaitCondition[],
194
+ globs: readonly EventGlob[],
195
+ ): string | null {
196
+ for (const { text, cond } of conditions) {
197
+ if (stateSatisfies(cond, state)) return text
198
+ if (cond.kind === 'attention' && isAttentionEvent(event.type)) return text
199
+ }
200
+ for (const { regex, text } of globs) {
201
+ if (regex.test(event.type)) return text
202
+ }
203
+ return null
204
+ }
205
+
206
+ // ── The command shell ────────────────────────────────────────────────────────
207
+
208
+ export interface AbWaitOpts {
209
+ targetRepo: string
210
+ /** Raw process environment: --store > AB_STORE > repository default (D8). */
211
+ env: Record<string, string | undefined>
212
+ exec: Exec
213
+ stdout: (line: string) => void
214
+ stderr: (line: string) => void
215
+ /** Positional build slugs; none means every nonterminal build in the repo. */
216
+ slugs?: readonly string[]
217
+ /** Repeatable `--for` conditions, in supply order. */
218
+ forValues?: readonly string[]
219
+ /** Repeatable `--event` globs, in supply order. */
220
+ events?: readonly string[]
221
+ /** A cursor from an earlier record: event conditions may fire on the
222
+ * backlog after it; state conditions never do. */
223
+ since?: string
224
+ /** `--timeout` duration text; default 30 minutes, `0` unbounded. */
225
+ timeout?: string
226
+ /** `--interval` duration text; default 5s remote, 1s local. */
227
+ interval?: string
228
+ json?: boolean
229
+ storeRef?: string
230
+ openStore?: StoreOpener
231
+ /** Injectable clock; defaults to the wall clock. */
232
+ now?: () => Date
233
+ /** Injectable tick-gap sleep so tests drive ticks deterministically. */
234
+ delay?: (ms: number, signal?: AbortSignal) => Promise<void>
235
+ /** Stop signal; the generic sessionless SIGINT handler aborts it. */
236
+ signal?: AbortSignal
237
+ }
238
+
239
+ interface WaitStream {
240
+ slug: string
241
+ named: boolean
242
+ /** The full prefix, appended in seq order — what `reduceBuild` runs over. */
243
+ events: AbEvent[]
244
+ lastSeq: number
245
+ status: BuildStatus
246
+ }
247
+
248
+ function defaultDelay(ms: number, signal?: AbortSignal): Promise<void> {
249
+ return new Promise((resolve) => {
250
+ if (signal?.aborted === true) {
251
+ resolve()
252
+ return
253
+ }
254
+ const finish = (): void => {
255
+ clearTimeout(timer)
256
+ signal?.removeEventListener('abort', onAbort)
257
+ resolve()
258
+ }
259
+ const timer = setTimeout(finish, ms)
260
+ const onAbort = (): void => finish()
261
+ signal?.addEventListener('abort', onAbort, { once: true })
262
+ })
263
+ }
264
+
265
+ /**
266
+ * `ab wait` — block until a targeted build satisfies a condition, then print
267
+ * one record and exit 0; exit 2 when every named build went terminal without
268
+ * satisfying anything, 3 on timeout, 4 when interrupted. Read-only: it
269
+ * appends no event, takes no lease, and creates no record.
270
+ */
271
+ export async function abWait(opts: AbWaitOpts): Promise<number> {
272
+ const now = opts.now ?? (() => new Date())
273
+ const sleep = opts.delay ?? defaultDelay
274
+ const slugs = [...(opts.slugs ?? [])]
275
+ const json = opts.json === true
276
+
277
+ const timeoutMs =
278
+ opts.timeout !== undefined
279
+ ? parseDurationMs(opts.timeout, { flag: '--timeout', usage: WAIT_USAGE, allowZero: true })
280
+ : DEFAULT_TIMEOUT_MS
281
+ const intervalMs =
282
+ opts.interval !== undefined
283
+ ? parseDurationMs(opts.interval, { flag: '--interval', usage: WAIT_USAGE })
284
+ : undefined
285
+
286
+ // Condition validation, before any store access.
287
+ const { conditions, globs } = parseConditions(opts.forValues ?? [], opts.events ?? [])
288
+ if (conditions.length === 0 && globs.length === 0) throw new Error(WAIT_USAGE)
289
+
290
+ // Ambient scope, before any store access — the same preflight as watch
291
+ // (wait never follows the repository journal, so `repository` is false).
292
+ assertReadScope(slugs, { repository: false, env: opts.env })
293
+
294
+ return await withAmbientReadStore(
295
+ {
296
+ targetRepo: opts.targetRepo,
297
+ env: opts.env,
298
+ exec: opts.exec,
299
+ ...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
300
+ ...(opts.openStore !== undefined ? { openStore: opts.openStore } : {}),
301
+ },
302
+ async (context) => {
303
+ const { store, repo, storeRef, checkout } = context
304
+ const { exec } = opts
305
+ const interval = intervalMs ?? watchSelection(storeRef).intervalMs
306
+
307
+ // Decode --since before any read: a foreign cursor is rejected outright.
308
+ const sinceStreams =
309
+ opts.since !== undefined
310
+ ? decodeCursor(opts.since, { store: storeRef, repo }, { verb: 'wait', usage: WAIT_USAGE })
311
+ .streams
312
+ : {}
313
+
314
+ const identity = normalizeGitRemoteUrl(repo)
315
+ const mine = (record: BuildRecord): boolean =>
316
+ record.repo === identity ||
317
+ (record.repoOrigin !== undefined && normalizeGitRemoteUrl(record.repoOrigin) === identity)
318
+
319
+ const streams = new Map<string, WaitStream>()
320
+ // Every stream's position — matching or not, delivered or baselined —
321
+ // lives here, so the returned cursor never rescans or redelivers.
322
+ const positions: Record<string, number> = { ...sinceStreams }
323
+
324
+ const encodeCurrent = (): string =>
325
+ encodeCursor({ v: 1, store: storeRef, repo, streams: { ...positions } })
326
+
327
+ /** The one success/terminal record. Exit 0 is contractually tied to
328
+ * exactly one condition-satisfying record; the exit-2 record carries
329
+ * `condition` null so the caller can see how the build ended. */
330
+ const finish = (condition: string | null, event: AbEvent, state: BuildState): number => {
331
+ const record = { ...projectRecord(event, state, encodeCurrent()), condition }
332
+ if (json) {
333
+ opts.stdout(JSON.stringify(record))
334
+ } else {
335
+ const line = renderWatchLine(event, state)
336
+ opts.stdout(condition === null ? line : `${line} (${condition})`)
337
+ }
338
+ return condition === null ? 2 : 0
339
+ }
340
+
341
+ let satisfied: { event: AbEvent; state: BuildState; condition: string } | null = null
342
+ // Read through these: `satisfied` is mutated inside closures (match,
343
+ // trackBuild, pollBuild, tick), and control-flow narrowing would
344
+ // otherwise collapse it to `never` at the loop exit.
345
+ const hasMatch = (): boolean => satisfied !== null
346
+ const theMatch = (): { event: AbEvent; state: BuildState; condition: string } =>
347
+ satisfied as { event: AbEvent; state: BuildState; condition: string }
348
+
349
+ /** Record the first match only: whichever condition wins, wins. */
350
+ const match = (event: AbEvent, state: BuildState, condition: string): void => {
351
+ if (satisfied === null) satisfied = { event, state, condition }
352
+ }
353
+
354
+ const registerStream = (slug: string, events: AbEvent[], named: boolean): WaitStream => {
355
+ const state = reduceBuild(events)
356
+ const stream: WaitStream = {
357
+ slug,
358
+ named,
359
+ events,
360
+ lastSeq: events.at(-1)?.seq ?? 0,
361
+ status: state.status,
362
+ }
363
+ streams.set(slug, stream)
364
+ positions[slug] = stream.lastSeq
365
+ return stream
366
+ }
367
+
368
+ /** The start-of-registration state check (decision 4): a build that is
369
+ * already satisfying when it becomes targeted ends the wait with its
370
+ * latest event. Not cursor-relative — `--since` does not suppress it. */
371
+ const checkRegistrationState = (stream: WaitStream): void => {
372
+ const state = reduceBuild(stream.events)
373
+ const condition = matchState(conditions, state)
374
+ if (condition !== null && stream.events.length > 0) {
375
+ match(stream.events.at(-1)!, state, condition)
376
+ }
377
+ }
378
+ /**
379
+ * Track one build over the full event prefix. `resumeSeq` (a cursor
380
+ * position) additionally replays every event after it, evaluating only
381
+ * event conditions (decision 3): state conditions are satisfied by the
382
+ * first post-start event, and the registration state check below runs
383
+ * over the full reduction either way. A baseline replays nothing.
384
+ */
385
+ const trackBuild = async (slug: string, resumeSeq?: number): Promise<void> => {
386
+ if (streams.has(slug)) return
387
+ const record = await store.getBuild(slug)
388
+ if (record === null) return
389
+ const events = await store.getEvents(slug)
390
+ const stream = registerStream(slug, events, slugs.includes(slug))
391
+ if (resumeSeq !== undefined) {
392
+ stream.lastSeq = resumeSeq
393
+ positions[slug] = resumeSeq
394
+ // Replay the backlog into a scratch prefix: each evaluated state is
395
+ // the reduction of the prefix ENDING at that event, never the
396
+ // reduction of the full loaded prefix.
397
+ const replay: AbEvent[] = events.filter((event) => event.seq <= resumeSeq)
398
+ for (const event of events) {
399
+ if (event.seq <= resumeSeq) continue
400
+ replay.push(event)
401
+ stream.lastSeq = event.seq
402
+ positions[slug] = event.seq
403
+ const condition = matchEvent(event, conditions, globs)
404
+ if (condition !== null) {
405
+ match(event, reduceBuild(replay), condition)
406
+ break
407
+ }
408
+ }
409
+ }
410
+ if (satisfied === null) checkRegistrationState(stream)
411
+ }
412
+
413
+ /**
414
+ * No-slug membership: every nonterminal build of this repository joins
415
+ * the wait, baselined at its current maximum so no history replays, and
416
+ * each gets the start-of-registration state check — a build discovered
417
+ * already satisfying ends the wait. Terminal builds are never targeted
418
+ * without being named, so an entirely terminal set keeps waiting for a
419
+ * build to appear.
420
+ */
421
+ const discoverBuilds = async (): Promise<void> => {
422
+ for (const record of (await store.listBuilds()).filter(mine)) {
423
+ if (streams.has(record.slug)) continue
424
+ const events = await store.getEvents(record.slug)
425
+ const status = reduceBuild(events).status
426
+ if (!NONTERMINAL_STATUSES.includes(status)) continue
427
+ const stream = registerStream(record.slug, events, false)
428
+ if (satisfied === null) checkRegistrationState(stream)
429
+ }
430
+ }
431
+
432
+ const pollBuild = async (stream: WaitStream): Promise<void> => {
433
+ const fresh = await store.getEvents(stream.slug, stream.lastSeq)
434
+ for (const event of fresh) {
435
+ stream.events.push(event)
436
+ stream.lastSeq = event.seq
437
+ positions[stream.slug] = event.seq
438
+ const state = reduceBuild(stream.events)
439
+ stream.status = state.status
440
+ const condition = matchFirst(event, state, conditions, globs)
441
+ if (condition !== null) {
442
+ match(event, state, condition)
443
+ return
444
+ }
445
+ }
446
+ }
447
+
448
+ const namedAllTerminal = (): boolean => {
449
+ if (slugs.length === 0) return false
450
+ for (const slug of slugs) {
451
+ const stream = streams.get(slug)
452
+ if (stream === undefined || !TERMINAL_STATUSES.includes(stream.status)) return false
453
+ }
454
+ return true
455
+ }
456
+
457
+ /** The exit-2 record: among the named builds' terminal events, the one
458
+ * with the latest `ts` (ties broken by slug order) — the event that made
459
+ * the final named build terminal. */
460
+ const pickTerminalRecord = (): { event: AbEvent; state: BuildState } => {
461
+ let best: { event: AbEvent; state: BuildState; slug: string } | null = null
462
+ for (const slug of slugs) {
463
+ const stream = streams.get(slug)
464
+ if (stream === undefined) continue
465
+ const terminalEvent = [...stream.events]
466
+ .reverse()
467
+ .find((event) => event.type === 'build.completed' || event.type === 'build.aborted')
468
+ if (terminalEvent === undefined) continue
469
+ if (
470
+ best === null ||
471
+ terminalEvent.ts > best.event.ts ||
472
+ (terminalEvent.ts === best.event.ts && slug < best.slug)
473
+ ) {
474
+ best = { event: terminalEvent, state: reduceBuild(stream.events), slug }
475
+ }
476
+ }
477
+ // A named build is terminal only through its terminal event, so
478
+ // `best` is always set by the time this runs.
479
+ return { event: best!.event, state: best!.state }
480
+ }
481
+
482
+ /** One poll cycle: discovery, then per-stream reads. A failed read is
483
+ * reported once per failure streak, advances nothing, and is retried at
484
+ * the next interval; a fully successful cycle re-arms the report. */
485
+ let readFailureReported = false
486
+ const reportReadFailure = (error: unknown): void => {
487
+ if (readFailureReported) return
488
+ readFailureReported = true
489
+ const message = error instanceof Error ? error.message : String(error)
490
+ opts.stderr(`ab wait: a store read failed (${message}); retrying at the next interval`)
491
+ }
492
+
493
+ const tick = async (): Promise<void> => {
494
+ let allReadsOk = true
495
+ if (slugs.length === 0 && satisfied === null) {
496
+ try {
497
+ await discoverBuilds()
498
+ } catch (error) {
499
+ allReadsOk = false
500
+ reportReadFailure(error)
501
+ }
502
+ }
503
+ for (const stream of [...streams.values()]) {
504
+ if (satisfied !== null) break
505
+ try {
506
+ await pollBuild(stream)
507
+ } catch (error) {
508
+ allReadsOk = false
509
+ reportReadFailure(error)
510
+ }
511
+ }
512
+ if (allReadsOk) readFailureReported = false
513
+ }
514
+
515
+ const finishSatisfied = (): number =>
516
+ finish(theMatch().condition, theMatch().event, theMatch().state)
517
+
518
+ // ── Initial scan ──
519
+ // Named slugs are validated (and must belong to this repository) before
520
+ // any record is emitted: an unknown slug is an error, full stop.
521
+ for (const slug of slugs) {
522
+ const record = await store.getBuild(slug)
523
+ if (record === null) {
524
+ throw new Error(
525
+ `no build "${slug}" in this store — run 'ab builds --all' to list ` +
526
+ "this repo's builds, or pass --store <ref> if it lives in another store",
527
+ )
528
+ }
529
+ if (!(await buildInRepository(record, checkout, exec))) {
530
+ throw new Error(`build "${slug}" belongs to repository "${record.repo}", not "${repo}"`)
531
+ }
532
+ }
533
+ for (const slug of slugs) {
534
+ await trackBuild(slug, sinceStreams[slug])
535
+ if (hasMatch()) return finishSatisfied()
536
+ }
537
+ // Cursor streams that are no longer named resume anyway: finishing
538
+ // their backlog is what makes a resumed wait lossless.
539
+ for (const key of Object.keys(sinceStreams)) {
540
+ await trackBuild(key, sinceStreams[key])
541
+ if (hasMatch()) return finishSatisfied()
542
+ }
543
+ if (slugs.length === 0) {
544
+ // Membership discovery also baselines the default wait; a failed
545
+ // discovery here is fatal — nothing has started yet.
546
+ try {
547
+ await discoverBuilds()
548
+ } catch (error) {
549
+ const message = error instanceof Error ? error.message : String(error)
550
+ throw new Error(`could not list builds in this store (${message})`)
551
+ }
552
+ if (hasMatch()) return finishSatisfied()
553
+ } else if (namedAllTerminal()) {
554
+ // Decision 5, start form: every named build is already terminal and
555
+ // no condition held — show how the build ended.
556
+ const terminal = pickTerminalRecord()
557
+ return finish(null, terminal.event, terminal.state)
558
+ }
559
+
560
+ // ── The poll loop ──
561
+ const timeoutText = opts.timeout ?? '30m'
562
+ const deadline = timeoutMs === 0 ? Number.POSITIVE_INFINITY : now().getTime() + timeoutMs
563
+ const aborted = (): boolean => opts.signal?.aborted === true
564
+ while (!hasMatch()) {
565
+ if (aborted() || now().getTime() >= deadline) break
566
+ await sleep(interval, opts.signal)
567
+ if (aborted()) break
568
+ await tick()
569
+ if (!hasMatch() && namedAllTerminal()) {
570
+ const terminal = pickTerminalRecord()
571
+ return finish(null, terminal.event, terminal.state)
572
+ }
573
+ }
574
+ if (hasMatch()) return finishSatisfied()
575
+ if (aborted()) {
576
+ opts.stderr('ab wait: interrupted — no condition was satisfied before the signal')
577
+ if (json) opts.stdout(JSON.stringify({ cursor: encodeCurrent() }))
578
+ return 4
579
+ }
580
+ opts.stderr(
581
+ `ab wait: timed out after ${timeoutText} — no targeted build satisfied any condition`,
582
+ )
583
+ if (json) opts.stdout(JSON.stringify({ cursor: encodeCurrent() }))
584
+ return 3
585
+ },
586
+ )
587
+ }