@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,932 @@
1
+ /**
2
+ * `ab watch` — the sessionless, read-only streaming half of the status family
3
+ * (§8.2, §7.2). It follows the event logs of one, several, or all active
4
+ * builds in the repository — optionally the repository journal — and emits one
5
+ * record per matching event as it lands, each carrying the full durable
6
+ * envelope, the build's reduced state after that event, and an opaque cursor
7
+ * so a later `--since` invocation resumes exactly where this one stopped.
8
+ *
9
+ * Like `ab builds` / `ab build status`, the projection is pure and the IO is a
10
+ * thin shell. The command composes the existing read surface — `listBuilds`,
11
+ * `getEvents(since)`, `getRepoEvents(since)`, `getBuild` — and never widens
12
+ * `BuildStore`: no event is appended, no lease claimed, no record created. It
13
+ * deliberately does not reuse `pollingSubscribe` (store/subscribe.ts): that
14
+ * helper is single-stream, fixed-membership, and build-only, while `watch`
15
+ * needs dynamic membership (builds created mid-watch join), the repository
16
+ * journal, per-stream error reporting, and a resumable cursor. Delivery
17
+ * semantics (in-order, exactly once per stream) are reimplemented locally for
18
+ * the same reason.
19
+ *
20
+ * Cadence follows the store kind (AUT-334). Against a local store the loop is
21
+ * the sequential interval tick. Against an `http(s)` store every tracked
22
+ * stream long-polls instead: one held `getEvents`/`getRepoEvents` request per
23
+ * stream is always in flight (tasks run concurrently), an appended event is
24
+ * seen within about a second of its append, and gap-fill keeps request starts
25
+ * at least `--interval` apart, so a quiet stream costs one request per wait
26
+ * window — not one per interval. Discovery keeps its interval cadence. Every
27
+ * stop is prompt: an abort, a tripped --count, an all-terminal named set, or
28
+ * an elapsed --timeout cancels the in-flight held reads instead of waiting
29
+ * out a full hold, and a hold never outlives the --timeout deadline.
30
+ *
31
+ * The cursor is `"v1." + base64url(JSON)` of the per-stream sequence position
32
+ * plus the resolved store reference and repository identity. It is opaque to
33
+ * callers — the format is not part of the contract — and a cursor from a
34
+ * different store or repository is rejected before any read. Non-matching
35
+ * events still advance their stream's position, so a resume never rescans or
36
+ * redelivers them.
37
+ *
38
+ * Output carries no ANSI, ever, in either form (§16).
39
+ */
40
+ import type { AbEvent } from '../events/catalog'
41
+ import { EVENT_TYPES, type EventType } from '../events/payloads'
42
+ import {
43
+ REPOSITORY_EVENT_TYPES,
44
+ type RepositoryEvent,
45
+ type RepositoryEventType,
46
+ } from '../events/repository'
47
+ import type { BuildState, PrLifecycle } from '../kernel/reducer'
48
+ import { reduceBuild } from '../kernel/reducer'
49
+ import type { BuildOutcome, BuildStatus, Phase } from '../ontology'
50
+ import type { Exec } from '../ports/workspace/git-worktree'
51
+ import { PhaseSessionError } from '../store/phase-session'
52
+ import { REMOTE_EVENT_WAIT_SECONDS } from '../store/remote/client'
53
+ import type { BuildRecord } from '../store/types'
54
+ import { resolveAmbientReadSession } from './env'
55
+ import { buildInRepository, isRemoteStoreRef, normalizeGitRemoteUrl } from './repo-state'
56
+ import { withAmbientReadStore, type StoreOpener } from './store-opening'
57
+
58
+ export const WATCH_USAGE =
59
+ 'usage: ab watch [<slug>...] [--repository] [--event <glob>]... [--since <cursor>] ' +
60
+ '[--timeout <duration>] [--interval <duration>] [--count <n>] [--json] [--store <ref>]'
61
+
62
+ /** Default poll cadence and watch horizon. */
63
+ export const DEFAULT_REMOTE_INTERVAL_MS = 5_000
64
+ export const DEFAULT_LOCAL_INTERVAL_MS = 1_000
65
+ const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000
66
+
67
+ /** The repository-journal stream's key in the cursor's per-stream map. */
68
+ const REPO_STREAM_KEY = '#repo'
69
+
70
+ /** Statuses a build can still leave; the others are terminal (§15.5). */
71
+ const NONTERMINAL_STATUSES: readonly BuildStatus[] = ['queued', 'running', 'paused', 'blocked']
72
+ const TERMINAL_STATUSES: readonly BuildStatus[] = ['done', 'aborted']
73
+
74
+ // ── The default filter: the attention set ────────────────────────────────────
75
+
76
+ /**
77
+ * The build events an operator or agent must wake up for. With no `--event`,
78
+ * a watch emits exactly these — deciding what to do about a blocker or a
79
+ * merge stays with the caller's skill.
80
+ */
81
+ export const BUILD_ATTENTION_EVENTS = [
82
+ 'escalation.raised',
83
+ 'phase.failed',
84
+ 'infrastructure.failed',
85
+ 'runner.setup-failed',
86
+ 'dispatch.failed',
87
+ 'publication.lost',
88
+ 'finalize.completed',
89
+ 'pr.conflicted',
90
+ 'pr.merged',
91
+ 'pr.closed',
92
+ 'build.completed',
93
+ 'build.aborted',
94
+ ] as const satisfies readonly EventType[]
95
+
96
+ /** The repository-journal attention set, followed only under `--repository`. */
97
+ export const REPOSITORY_ATTENTION_EVENTS = [
98
+ 'harvest.escalated',
99
+ 'harvest.failed',
100
+ 'dispatcher.tick-failed',
101
+ 'dispatcher.config-rejected',
102
+ 'dispatcher.harvest-runner-failed',
103
+ ] as const satisfies readonly RepositoryEventType[]
104
+
105
+ // ── Glob compilation and matching ────────────────────────────────────────────
106
+
107
+ /**
108
+ * Compile `--event` globs to anchored matchers: `*` matches any run of
109
+ * characters, `?` exactly one, everything else verbatim (so the dots inside
110
+ * event type names are escaped). Each glob must match at least one type in
111
+ * the build catalog — or, when `--repository` is given, in the repository
112
+ * catalog — otherwise a usage error naming the glob is thrown before any
113
+ * store read. Supplying any glob replaces the attention set entirely.
114
+ */
115
+ export function compileEventGlobs(
116
+ globs: readonly string[],
117
+ opts: { repository: boolean; usage: string },
118
+ ): RegExp[] {
119
+ return globs.map((glob) => {
120
+ const source =
121
+ '^' +
122
+ [...glob]
123
+ .map((char) => {
124
+ if (char === '*') return '.*'
125
+ if (char === '?') return '.'
126
+ return char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
127
+ })
128
+ .join('') +
129
+ '$'
130
+ const regex = new RegExp(source)
131
+ const inBuildCatalog = EVENT_TYPES.some((type) => regex.test(type))
132
+ const inRepositoryCatalog =
133
+ opts.repository && REPOSITORY_EVENT_TYPES.some((type) => regex.test(type))
134
+ if (!inBuildCatalog && !inRepositoryCatalog) {
135
+ throw new Error(
136
+ `--event "${glob}" matches no known event type${
137
+ opts.repository ? ' in the build or repository catalogs' : ' in the build event catalog'
138
+ } — ${opts.usage}`,
139
+ )
140
+ }
141
+ return regex
142
+ })
143
+ }
144
+
145
+ function matchesBuildFilter(type: EventType, filters: readonly RegExp[]): boolean {
146
+ return filters.length > 0
147
+ ? filters.some((regex) => regex.test(type))
148
+ : (BUILD_ATTENTION_EVENTS as readonly string[]).includes(type)
149
+ }
150
+
151
+ function matchesRepositoryFilter(type: RepositoryEventType, filters: readonly RegExp[]): boolean {
152
+ return filters.length > 0
153
+ ? filters.some((regex) => regex.test(type))
154
+ : (REPOSITORY_ATTENTION_EVENTS as readonly string[]).includes(type)
155
+ }
156
+
157
+ // ── The cursor ───────────────────────────────────────────────────────────────
158
+
159
+ interface CursorPayload {
160
+ v: 1
161
+ /** The resolved store reference this cursor's positions belong to. */
162
+ store: string
163
+ /** The repository identity (normalized origin or checkout path). */
164
+ repo: string
165
+ /** Per-stream watch position: build slug → seq, `#repo` → seq. */
166
+ streams: Record<string, number>
167
+ }
168
+
169
+ const CURSOR_PREFIX = 'v1.'
170
+
171
+ /** Opaque to callers; the format is internal, not contract. */
172
+ export function encodeCursor(payload: CursorPayload): string {
173
+ return CURSOR_PREFIX + Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')
174
+ }
175
+
176
+ /**
177
+ * Decode and authorize a `--since` cursor against this watch's resolved store
178
+ * and repository — before any store read. Malformed input, and cursors from a
179
+ * different store or repository, each get a distinct rejection naming what
180
+ * mismatched.
181
+ */
182
+ export function decodeCursor(
183
+ cursor: string,
184
+ expected: { store: string; repo: string },
185
+ opts: { verb: string; usage: string } = { verb: 'watch', usage: WATCH_USAGE },
186
+ ): CursorPayload {
187
+ const malformed = (): Error =>
188
+ new Error(
189
+ 'invalid --since cursor: malformed — a cursor is an opaque string emitted by a ' +
190
+ `previous 'ab ${opts.verb}' run — ${opts.usage}`,
191
+ )
192
+ if (!cursor.startsWith(CURSOR_PREFIX)) throw malformed()
193
+ let payload: unknown
194
+ try {
195
+ payload = JSON.parse(
196
+ Buffer.from(cursor.slice(CURSOR_PREFIX.length), 'base64url').toString('utf8'),
197
+ )
198
+ } catch {
199
+ throw malformed()
200
+ }
201
+ if (typeof payload !== 'object' || payload === null) throw malformed()
202
+ const { v, store, repo, streams } = payload as Record<string, unknown>
203
+ if (v !== 1 || typeof store !== 'string' || typeof repo !== 'string') throw malformed()
204
+ if (typeof streams !== 'object' || streams === null || Array.isArray(streams)) throw malformed()
205
+ for (const seq of Object.values(streams)) {
206
+ if (typeof seq !== 'number' || !Number.isInteger(seq) || seq < 0) throw malformed()
207
+ }
208
+ if (store !== expected.store) {
209
+ throw new Error(
210
+ `invalid --since cursor: it was created against store "${store}", but this watch ` +
211
+ `reads store "${expected.store}" — resume from the same --store the cursor came from`,
212
+ )
213
+ }
214
+ if (repo !== expected.repo) {
215
+ throw new Error(
216
+ `invalid --since cursor: it was created for repository "${repo}", but this watch reads ` +
217
+ `repository "${expected.repo}" — run from the repository the cursor came from`,
218
+ )
219
+ }
220
+ return { v: 1, store, repo, streams: streams as Record<string, number> }
221
+ }
222
+
223
+ // ── Durations and cadence ────────────────────────────────────────────────────
224
+
225
+ /**
226
+ * `30`, `45s`, `5m`, `1h` — a positive number of seconds with an optional
227
+ * unit suffix; a bare number means seconds (the `ab dispatch --interval`
228
+ * convention). `0` is accepted only where the caller says so (`--timeout 0`
229
+ * means unbounded; `--interval 0` is a usage error).
230
+ */
231
+ export function parseDurationMs(
232
+ text: string,
233
+ opts: { flag: string; usage: string; allowZero?: boolean },
234
+ ): number {
235
+ const match = /^(\d+)([smh]?)$/.exec(text)
236
+ const invalid = (): Error =>
237
+ new Error(
238
+ `${opts.flag} requires a duration — a positive number of seconds, optionally suffixed ` +
239
+ `s, m, or h (for example 30, 45s, 5m, 1h)${
240
+ opts.allowZero === true ? '; 0 means unbounded' : ''
241
+ } — ${opts.usage}`,
242
+ )
243
+ if (match === null) throw invalid()
244
+ const amount = Number(match[1])
245
+ if (amount === 0 && opts.allowZero !== true) throw invalid()
246
+ const unitMs = match[2] === 'm' ? 60_000 : match[2] === 'h' ? 3_600_000 : 1_000 // '' and 's' both mean seconds
247
+ return amount * unitMs
248
+ }
249
+
250
+ /** Default poll interval by store kind: patient remotely, prompt locally. */
251
+ export function watchSelection(storeRef: string): { intervalMs: number } {
252
+ return {
253
+ intervalMs: isRemoteStoreRef(storeRef) ? DEFAULT_REMOTE_INTERVAL_MS : DEFAULT_LOCAL_INTERVAL_MS,
254
+ }
255
+ }
256
+
257
+ // ── The record projection ────────────────────────────────────────────────────
258
+
259
+ /** The reduction after one event — the exact fields the spec names. */
260
+ export interface WatchRecordState {
261
+ status: BuildStatus
262
+ phase: Phase | null
263
+ round: number
264
+ openEscalations: { id: string; question: string }[]
265
+ pr: { number: number; url: string; headSha: string } | null
266
+ prState: PrLifecycle | null
267
+ outcome: BuildOutcome | null
268
+ }
269
+
270
+ export interface WatchRecord {
271
+ /** The build slug, or null for a repository-journal event. */
272
+ build: string | null
273
+ /** The repository identity — present only for repository events. */
274
+ repo?: string
275
+ /** The complete durable envelope, passed through unchanged. */
276
+ event: AbEvent | RepositoryEvent
277
+ /** For build events, the reduction after this event; null for repository events. */
278
+ state: WatchRecordState | null
279
+ /** The watch position after this event — resume here with --since. */
280
+ cursor: string
281
+ }
282
+
283
+ function projectState(state: BuildState): WatchRecordState {
284
+ return {
285
+ status: state.status,
286
+ phase: state.phase ?? null,
287
+ round: state.round,
288
+ openEscalations: state.openEscalations.map((escalation) => ({
289
+ id: escalation.id,
290
+ question: escalation.question,
291
+ })),
292
+ pr: state.pr ?? null,
293
+ prState: state.prState ?? null,
294
+ outcome: state.outcome ?? null,
295
+ }
296
+ }
297
+
298
+ /** `--json` form: exactly one object per matching event. */
299
+ export function projectRecord(
300
+ event: AbEvent | RepositoryEvent,
301
+ state: BuildState | null,
302
+ cursor: string,
303
+ ): WatchRecord {
304
+ if ('build' in event) {
305
+ return {
306
+ build: event.build,
307
+ event,
308
+ state: state === null ? null : projectState(state),
309
+ cursor,
310
+ }
311
+ }
312
+ return { build: null, repo: event.repo, event, state: null, cursor }
313
+ }
314
+
315
+ /** Preserve printable text while making control bytes (and ANSI) inert. */
316
+ function singleLine(value: string): string {
317
+ let displayed = ''
318
+ for (const char of value) {
319
+ const code = char.codePointAt(0)!
320
+ displayed += code >= 0x20 && code !== 0x7f ? char : `\\u{${code.toString(16)}}`
321
+ }
322
+ return displayed
323
+ }
324
+
325
+ const SUMMARY_MAX = 200
326
+
327
+ /**
328
+ * A payload-derived one-liner per attention type; unknown types (custom
329
+ * `--event` globs) get a compact single-line payload.
330
+ */
331
+ export function summarizeEvent(event: AbEvent | RepositoryEvent, state: BuildState | null): string {
332
+ switch (event.type) {
333
+ case 'escalation.raised':
334
+ return `${event.payload.id}: ${singleLine(event.payload.question)}`
335
+ case 'phase.failed':
336
+ return `${event.payload.phase}${
337
+ event.payload.round !== undefined ? ` r${event.payload.round}` : ''
338
+ } attempt ${event.payload.attempt} failed${
339
+ event.payload.willRetry ? ' (will retry)' : ''
340
+ }: ${singleLine(event.payload.error)}`
341
+ case 'infrastructure.failed':
342
+ return `${event.payload.operation} failed on ${event.payload.provider} (attempt ${
343
+ event.payload.attempt
344
+ }, ${event.payload.retryable ? 'retryable' : 'not retryable'}): ${singleLine(
345
+ event.payload.error,
346
+ )}`
347
+ case 'runner.setup-failed':
348
+ return `setup attempt ${event.payload.attempt} failed: ${singleLine(event.payload.command)}`
349
+ case 'dispatch.failed':
350
+ return `${event.payload.stage} attempt ${event.payload.attempt} failed: ${singleLine(
351
+ event.payload.error,
352
+ )}`
353
+ case 'publication.lost':
354
+ return `${event.payload.operation} publication lost: ${singleLine(event.payload.reason)}`
355
+ case 'finalize.completed':
356
+ return `PR #${event.payload.pr.number} opened ${event.payload.pr.url}`
357
+ case 'pr.conflicted':
358
+ return state?.pr !== undefined
359
+ ? `PR #${state.pr.number} conflicts with its base`
360
+ : 'PR conflicts with its base'
361
+ case 'pr.merged':
362
+ return state?.pr !== undefined ? `PR #${state.pr.number} merged` : 'PR merged'
363
+ case 'pr.closed':
364
+ return state?.pr !== undefined ? `PR #${state.pr.number} closed` : 'PR closed'
365
+ case 'build.completed':
366
+ return `build completed (${event.payload.outcome})`
367
+ case 'build.aborted':
368
+ return 'build aborted'
369
+ case 'harvest.escalated':
370
+ return `harvest run ${event.payload.run} escalated: ${singleLine(event.payload.reason)}`
371
+ case 'harvest.failed':
372
+ return `harvest run ${event.payload.run} failed at ${event.payload.step} (attempt ${event.payload.attempt}): ${singleLine(event.payload.error)}`
373
+ case 'dispatcher.tick-failed':
374
+ return `dispatcher tick failed: ${singleLine(event.payload.error)}`
375
+ case 'dispatcher.config-rejected':
376
+ return `dispatcher config rejected: ${singleLine(event.payload.error)}`
377
+ case 'dispatcher.harvest-runner-failed':
378
+ return `harvest runner failed: ${singleLine(event.payload.error)}`
379
+ default: {
380
+ const compact = singleLine(JSON.stringify(event.payload))
381
+ return compact.length > SUMMARY_MAX ? `${compact.slice(0, SUMMARY_MAX - 1)}…` : compact
382
+ }
383
+ }
384
+ }
385
+
386
+ /** The human form: one line, timestamp first, no ANSI anywhere. */
387
+ export function renderWatchLine(
388
+ event: AbEvent | RepositoryEvent,
389
+ state: BuildState | null,
390
+ ): string {
391
+ const label = 'build' in event ? event.build : '(repository)'
392
+ return `${event.ts} ${label} ${event.type} ${summarizeEvent(event, state)}`
393
+ }
394
+
395
+ // ── The ambient-scope preflight ─────────────────────────────────────────────
396
+
397
+ /**
398
+ * Ambient scope, before any store access: inside a phase with a complete
399
+ * build identity, only the ambient build may be read. The errors are the same
400
+ * PhaseSessionError shapes the scoped store handle produces for `ab builds` /
401
+ * `ab build status`, so the message matches exactly. Shared by `ab watch` and
402
+ * `ab wait`; `repository` is true only for watch's `--repository` form.
403
+ */
404
+ export function assertReadScope(
405
+ slugs: readonly string[],
406
+ opts: { repository: boolean; env: Record<string, string | undefined> },
407
+ ): void {
408
+ const ambient = resolveAmbientReadSession(opts.env)
409
+ if (ambient !== undefined && 'build' in ambient) {
410
+ const scope = { kind: 'build' as const, id: ambient.build, session: ambient.session }
411
+ if (slugs.length === 0 || opts.repository) {
412
+ throw new PhaseSessionError(scope, 'listBuilds', { kind: 'admin' })
413
+ }
414
+ for (const slug of slugs) {
415
+ if (slug !== ambient.build) {
416
+ throw new PhaseSessionError(scope, 'getEvents', { kind: 'build', id: slug })
417
+ }
418
+ }
419
+ }
420
+ }
421
+
422
+ // ── The command shell ────────────────────────────────────────────────────────
423
+
424
+ export interface AbWatchOpts {
425
+ targetRepo: string
426
+ /** Raw process environment: --store > AB_STORE > repository default (D8). */
427
+ env: Record<string, string | undefined>
428
+ exec: Exec
429
+ stdout: (line: string) => void
430
+ stderr: (line: string) => void
431
+ /** Positional build slugs; none means every nonterminal build in the repo. */
432
+ slugs?: readonly string[]
433
+ /** Also follow the repository journal (dispatcher and harvest events). */
434
+ repository?: boolean
435
+ /** Repeatable `--event` globs; replaces the attention set. */
436
+ events?: readonly string[]
437
+ /** A cursor from an earlier record: resume exactly where that watch stopped. */
438
+ since?: string
439
+ /** `--timeout` duration text; default 30 minutes, `0` unbounded. */
440
+ timeout?: string
441
+ /** `--interval` duration text; default 5s remote, 1s local. */
442
+ interval?: string
443
+ /** `--count`, validated as a positive integer by the route. */
444
+ count?: number
445
+ json?: boolean
446
+ storeRef?: string
447
+ openStore?: StoreOpener
448
+ /** Injectable clock; defaults to the wall clock. */
449
+ now?: () => Date
450
+ /** Injectable tick-gap sleep so tests drive ticks deterministically. */
451
+ delay?: (ms: number, signal?: AbortSignal) => Promise<void>
452
+ /** Stop signal; the generic sessionless SIGINT handler aborts it. */
453
+ signal?: AbortSignal
454
+ }
455
+
456
+ interface BuildStream {
457
+ kind: 'build'
458
+ slug: string
459
+ named: boolean
460
+ /** The full prefix, appended in seq order — what `reduceBuild` runs over. */
461
+ events: AbEvent[]
462
+ lastSeq: number
463
+ status: BuildStatus
464
+ }
465
+
466
+ interface RepositoryStream {
467
+ kind: 'repo'
468
+ lastSeq: number
469
+ }
470
+
471
+ type Stream = BuildStream | RepositoryStream
472
+
473
+ function defaultDelay(ms: number, signal?: AbortSignal): Promise<void> {
474
+ return new Promise((resolve) => {
475
+ if (signal?.aborted === true) {
476
+ resolve()
477
+ return
478
+ }
479
+ const finish = (): void => {
480
+ clearTimeout(timer)
481
+ signal?.removeEventListener('abort', onAbort)
482
+ resolve()
483
+ }
484
+ const timer = setTimeout(finish, ms)
485
+ const onAbort = (): void => finish()
486
+ signal?.addEventListener('abort', onAbort, { once: true })
487
+ })
488
+ }
489
+
490
+ /**
491
+ * `ab watch` — stream matching build and repository events until a timeout, a
492
+ * record count, or (only when slugs were named) terminal status ends the
493
+ * watch. Read-only: it appends no event, takes no lease, and creates no
494
+ * record.
495
+ */
496
+ export async function abWatch(opts: AbWatchOpts): Promise<void> {
497
+ const now = opts.now ?? (() => new Date())
498
+ const sleep = opts.delay ?? defaultDelay
499
+ const slugs = [...(opts.slugs ?? [])]
500
+ const repository = opts.repository === true
501
+
502
+ const timeoutMs =
503
+ opts.timeout !== undefined
504
+ ? parseDurationMs(opts.timeout, { flag: '--timeout', usage: WATCH_USAGE, allowZero: true })
505
+ : DEFAULT_TIMEOUT_MS
506
+ const intervalMs =
507
+ opts.interval !== undefined
508
+ ? parseDurationMs(opts.interval, { flag: '--interval', usage: WATCH_USAGE })
509
+ : undefined
510
+
511
+ // Ambient scope, before any store access.
512
+ assertReadScope(slugs, { repository, env: opts.env })
513
+
514
+ // Glob validation needs only the catalogs — before any store access.
515
+ const filters = compileEventGlobs(opts.events ?? [], { repository, usage: WATCH_USAGE })
516
+
517
+ await withAmbientReadStore(
518
+ {
519
+ targetRepo: opts.targetRepo,
520
+ env: opts.env,
521
+ exec: opts.exec,
522
+ ...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
523
+ ...(opts.openStore !== undefined ? { openStore: opts.openStore } : {}),
524
+ },
525
+ async (context) => {
526
+ const { store, repo, storeRef, checkout } = context
527
+ const { exec } = opts
528
+ const interval = intervalMs ?? watchSelection(storeRef).intervalMs
529
+
530
+ // Decode --since before any read: a foreign cursor is rejected outright.
531
+ const sinceStreams =
532
+ opts.since !== undefined ? decodeCursor(opts.since, { store: storeRef, repo }).streams : {}
533
+
534
+ const identity = normalizeGitRemoteUrl(repo)
535
+ const mine = (record: BuildRecord): boolean =>
536
+ record.repo === identity ||
537
+ (record.repoOrigin !== undefined && normalizeGitRemoteUrl(record.repoOrigin) === identity)
538
+
539
+ const streams = new Map<string, Stream>()
540
+ // Every stream's position — matching or not, delivered or baselined —
541
+ // lives here, so a resume never rescans or redelivers a filtered event.
542
+ const positions: Record<string, number> = { ...sinceStreams }
543
+ let emitted = 0
544
+ let countCursor: string | undefined
545
+ let stop = false
546
+ // Held reads in flight right now, each with its own controller. When
547
+ // the watch stops for any reason, every in-flight read is cancelled —
548
+ // otherwise a stop during a quiet window would wait out a full wait
549
+ // window (up to the remote hold) before the watch could exit.
550
+ const inFlightReads = new Set<AbortController>()
551
+ // Wakes stop-aware gap sleeps the moment the watch stops.
552
+ const stopController = new AbortController()
553
+ const requestStop = (): void => {
554
+ stop = true
555
+ stopController.abort()
556
+ for (const controller of inFlightReads) controller.abort()
557
+ }
558
+
559
+ const encodeCurrent = (): string =>
560
+ encodeCursor({ v: 1, store: storeRef, repo, streams: { ...positions } })
561
+
562
+ /** One record, flushed the moment it is projected. `cursor` is the watch
563
+ * position AFTER this event. */
564
+ const emit = (event: AbEvent | RepositoryEvent, state: BuildState | null): void => {
565
+ const cursor = encodeCurrent()
566
+ if (opts.json === true) {
567
+ opts.stdout(JSON.stringify(projectRecord(event, state, cursor)))
568
+ } else {
569
+ opts.stdout(renderWatchLine(event, state))
570
+ }
571
+ emitted += 1
572
+ if (opts.count !== undefined && emitted >= opts.count) {
573
+ // Events read but not delivered because the count tripped stay
574
+ // unprocessed: the next run resumes from THIS record's cursor.
575
+ countCursor = cursor
576
+ requestStop()
577
+ }
578
+ }
579
+
580
+ /** Record one event. `prefix` is the event prefix ENDING at `event` —
581
+ * the record's state is the reduction after THIS event, never a
582
+ * reduction over events that landed after it. The poll path passes the
583
+ * stream's just-extended array; the resume path replays the backlog
584
+ * into a scratch prefix so a resumed record never leaks state from
585
+ * events that landed after it. */
586
+ const processBuildEvent = (stream: BuildStream, event: AbEvent, prefix: AbEvent[]): void => {
587
+ stream.lastSeq = event.seq
588
+ positions[stream.slug] = event.seq
589
+ if (!matchesBuildFilter(event.type, filters)) return
590
+ emit(event, reduceBuild(prefix))
591
+ }
592
+
593
+ const processRepositoryEvent = (stream: RepositoryStream, event: RepositoryEvent): void => {
594
+ stream.lastSeq = event.seq
595
+ positions[REPO_STREAM_KEY] = event.seq
596
+ if (!matchesRepositoryFilter(event.type, filters)) return
597
+ emit(event, null)
598
+ }
599
+
600
+ /**
601
+ * Register a build stream over the full event prefix. `resumeSeq` (a
602
+ * cursor position) additionally emits every matching event after it —
603
+ * the backlog that landed while no watch was running. A baseline (the
604
+ * default) replays nothing: the position starts at the current maximum.
605
+ */
606
+ const trackBuild = async (slug: string, resumeSeq?: number): Promise<boolean> => {
607
+ if (streams.has(slug)) return true
608
+ const record = await store.getBuild(slug)
609
+ if (record === null) return false
610
+ const events = await store.getEvents(slug)
611
+ const stream: BuildStream = {
612
+ kind: 'build',
613
+ slug,
614
+ named: slugs.includes(slug),
615
+ events,
616
+ lastSeq: events.at(-1)?.seq ?? 0,
617
+ status: reduceBuild(events).status,
618
+ }
619
+ streams.set(slug, stream)
620
+ positions[slug] = stream.lastSeq
621
+ if (resumeSeq === undefined) return true
622
+ stream.lastSeq = resumeSeq
623
+ // Replay the backlog into a scratch prefix: each record's state is
624
+ // the reduction of the prefix ENDING at that event, never the
625
+ // reduction of the full loaded prefix, which would leak state from
626
+ // events that landed after the one being recorded.
627
+ const replay: AbEvent[] = events.filter((event) => event.seq <= resumeSeq)
628
+ for (const event of events) {
629
+ if (event.seq <= resumeSeq) continue
630
+ replay.push(event)
631
+ processBuildEvent(stream, event, replay)
632
+ if (stop) break
633
+ }
634
+ return true
635
+ }
636
+
637
+ /**
638
+ * No-slug membership: every nonterminal build of this repository joins
639
+ * the watch, baselined at its current maximum so no history replays. A
640
+ * build discovered while nonterminal stays tracked even after it turns
641
+ * terminal. Cursor-resumed and named streams are never re-baselined.
642
+ *
643
+ * Throws on a failed read — never swallows it: the poll loop's tick
644
+ * catches and reports the failure once on stderr, and the initial scan
645
+ * turns it into the friendly startup error. Streams discovered before
646
+ * the throw stay tracked, so the retry neither duplicates nor skips.
647
+ */
648
+ const discoverBuilds = async (): Promise<void> => {
649
+ for (const record of (await store.listBuilds()).filter(mine)) {
650
+ if (streams.has(record.slug)) continue
651
+ const events = await store.getEvents(record.slug)
652
+ const status = reduceBuild(events).status
653
+ if (!NONTERMINAL_STATUSES.includes(status)) continue
654
+ const stream: BuildStream = {
655
+ kind: 'build',
656
+ slug: record.slug,
657
+ named: false,
658
+ events,
659
+ lastSeq: events.at(-1)?.seq ?? 0,
660
+ status,
661
+ }
662
+ streams.set(record.slug, stream)
663
+ positions[record.slug] = stream.lastSeq
664
+ }
665
+ }
666
+
667
+ const trackRepository = async (): Promise<boolean> => {
668
+ const resumeSeq = sinceStreams[REPO_STREAM_KEY]
669
+ const stream: RepositoryStream = { kind: 'repo', lastSeq: resumeSeq ?? 0 }
670
+ streams.set(REPO_STREAM_KEY, stream)
671
+ positions[REPO_STREAM_KEY] = stream.lastSeq
672
+ try {
673
+ // A fresh store has no repository row; do not create one.
674
+ if (resumeSeq === undefined && (await store.getRepo(repo)) !== null) {
675
+ const events = await store.getRepoEvents(repo)
676
+ stream.lastSeq = events.at(-1)?.seq ?? 0
677
+ positions[REPO_STREAM_KEY] = stream.lastSeq
678
+ }
679
+ return true
680
+ } catch {
681
+ return false
682
+ }
683
+ }
684
+
685
+ const pollBuild = async (
686
+ stream: BuildStream,
687
+ readOpts?: { waitSeconds?: number; signal?: AbortSignal },
688
+ ): Promise<boolean> => {
689
+ const fresh = await store.getEvents(stream.slug, stream.lastSeq, readOpts)
690
+ for (const event of fresh) {
691
+ stream.events.push(event)
692
+ processBuildEvent(stream, event, stream.events)
693
+ if (stop) break
694
+ }
695
+ if (fresh.length > 0 && !stop) {
696
+ stream.status = reduceBuild(stream.events).status
697
+ }
698
+ return true
699
+ }
700
+
701
+ const pollRepository = async (
702
+ stream: RepositoryStream,
703
+ readOpts?: { waitSeconds?: number; signal?: AbortSignal },
704
+ ): Promise<boolean> => {
705
+ // A repository row may be created mid-watch by the first dispatch.
706
+ if ((await store.getRepo(repo)) === null) return true
707
+ const fresh = await store.getRepoEvents(repo, stream.lastSeq, readOpts)
708
+ for (const event of fresh) {
709
+ processRepositoryEvent(stream, event)
710
+ if (stop) break
711
+ }
712
+ return true
713
+ }
714
+
715
+ const namedAllTerminal = (): boolean => {
716
+ if (slugs.length === 0) return false
717
+ for (const slug of slugs) {
718
+ const stream = streams.get(slug)
719
+ if (stream === undefined || stream.kind !== 'build') return false
720
+ if (!TERMINAL_STATUSES.includes(stream.status)) return false
721
+ }
722
+ return true
723
+ }
724
+
725
+ /** A failed read is reported once per failure streak, advances nothing,
726
+ * and is retried at the next interval (local) or next request (remote);
727
+ * a success on the same source re-arms the report. Each read source —
728
+ * per stream task, plus discovery — carries its own streak, so a
729
+ * persistent failure on one stream interleaved with successes elsewhere
730
+ * is still reported only once. */
731
+ const makeFailureStreak = (): {
732
+ onFailure: (error: unknown) => void
733
+ onSuccess: () => void
734
+ } => {
735
+ let reported = false
736
+ return {
737
+ onFailure: (error: unknown): void => {
738
+ if (reported) return
739
+ reported = true
740
+ const message = error instanceof Error ? error.message : String(error)
741
+ opts.stderr(`ab watch: a store read failed (${message}); retrying at the next interval`)
742
+ },
743
+ onSuccess: (): void => {
744
+ reported = false
745
+ },
746
+ }
747
+ }
748
+
749
+ // One streak for the whole local loop — the same lifetime the shared
750
+ // reported flag had before per-source streaks: a persistent read
751
+ // failure is reported once, and a fully-successful cycle re-arms it.
752
+ const tickStreak = makeFailureStreak()
753
+
754
+ const tick = async (): Promise<void> => {
755
+ let allReadsOk = true
756
+ if (slugs.length === 0) {
757
+ try {
758
+ await discoverBuilds()
759
+ } catch (error) {
760
+ allReadsOk = false
761
+ tickStreak.onFailure(error)
762
+ }
763
+ }
764
+ for (const stream of [...streams.values()]) {
765
+ if (stop) return
766
+ try {
767
+ if (stream.kind === 'build') await pollBuild(stream)
768
+ else await pollRepository(stream)
769
+ } catch (error) {
770
+ allReadsOk = false
771
+ tickStreak.onFailure(error)
772
+ }
773
+ }
774
+ if (allReadsOk) tickStreak.onSuccess()
775
+ }
776
+
777
+ // ── Initial scan ──
778
+ // Named slugs are validated (and must belong to this repository) before
779
+ // any record is emitted: an unknown slug is an error, full stop.
780
+ for (const slug of slugs) {
781
+ const record = await store.getBuild(slug)
782
+ if (record === null) {
783
+ throw new Error(
784
+ `no build "${slug}" in this store — run 'ab builds --all' to list ` +
785
+ "this repo's builds, or pass --store <ref> if it lives in another store",
786
+ )
787
+ }
788
+ if (!(await buildInRepository(record, checkout, exec))) {
789
+ throw new Error(`build "${slug}" belongs to repository "${record.repo}", not "${repo}"`)
790
+ }
791
+ }
792
+ for (const slug of slugs) await trackBuild(slug, sinceStreams[slug])
793
+ // Cursor streams that are no longer named resume anyway: finishing
794
+ // their backlog is what makes a resumed watch lossless.
795
+ for (const key of Object.keys(sinceStreams)) {
796
+ if (key === REPO_STREAM_KEY) continue
797
+ await trackBuild(key, sinceStreams[key])
798
+ if (stop) break
799
+ }
800
+ if (repository) {
801
+ if (!(await trackRepository())) {
802
+ makeFailureStreak().onFailure(
803
+ new Error(`could not read the repository journal for "${repo}"`),
804
+ )
805
+ }
806
+ } else if (slugs.length === 0) {
807
+ // Membership discovery also baselines the default watch; a failed
808
+ // discovery here is fatal — nothing has started yet.
809
+ try {
810
+ await discoverBuilds()
811
+ } catch (error) {
812
+ const message = error instanceof Error ? error.message : String(error)
813
+ throw new Error(`could not list builds in this store (${message})`)
814
+ }
815
+ }
816
+
817
+ // ── The poll loop ──
818
+ const deadline = timeoutMs === 0 ? Number.POSITIVE_INFINITY : now().getTime() + timeoutMs
819
+ const aborted = (): boolean => opts.signal?.aborted === true
820
+
821
+ if (!isRemoteStoreRef(storeRef)) {
822
+ // Local cadence: one sequential tick per interval.
823
+ while (!stop) {
824
+ if (aborted() || now().getTime() >= deadline || namedAllTerminal()) break
825
+ await sleep(interval, opts.signal)
826
+ if (aborted()) break
827
+ await tick()
828
+ }
829
+ } else {
830
+ // Remote cadence (AUT-334): one long-poll task per tracked stream,
831
+ // running concurrently — a quiet stream keeps one held request in
832
+ // flight, so an appended event arrives within about a second instead
833
+ // of at the next interval tick. Gap-fill spaces each stream's request
834
+ // starts at least `interval` apart (elapsed request time counts
835
+ // toward the gap), so a server that answers immediately still sees
836
+ // today's request rate. Discovery keeps its interval cadence.
837
+ // Pre-launch guard mirrors the local loop's top-of-cycle checks: an
838
+ // already-terminal named set, an elapsed deadline, or an abort ends
839
+ // the watch before any request.
840
+ // Every stop is prompt: an external abort, a tripped --count, an
841
+ // all-terminal named set, or an elapsed --timeout cancels the
842
+ // in-flight held reads (via `requestStop`) and wakes the gap sleeps
843
+ // (via `stopController`) instead of waiting out a full hold. The
844
+ // held read's own bound is additionally capped at the watch's
845
+ // remaining time budget, so a hold can never outlive the deadline.
846
+ const shouldStop = stop || aborted() || now().getTime() >= deadline || namedAllTerminal()
847
+ const tasks: Promise<void>[] = []
848
+ const launched = new Set<string>()
849
+
850
+ /** The wait bound for one held read: the remote default, capped at
851
+ * the watch's remaining time budget. */
852
+ const readWaitSeconds = (atMs: number): number => {
853
+ if (timeoutMs === 0) return REMOTE_EVENT_WAIT_SECONDS
854
+ const remaining = Math.floor((deadline - atMs) / 1000)
855
+ return Math.max(0, Math.min(REMOTE_EVENT_WAIT_SECONDS, remaining))
856
+ }
857
+
858
+ const runStreamTask = async (
859
+ poll: (readOpts: { waitSeconds?: number; signal?: AbortSignal }) => Promise<unknown>,
860
+ ): Promise<void> => {
861
+ const streak = makeFailureStreak()
862
+ while (!stop && !aborted() && now().getTime() < deadline) {
863
+ const started = now().getTime()
864
+ const controller = new AbortController()
865
+ inFlightReads.add(controller)
866
+ try {
867
+ await poll({ waitSeconds: readWaitSeconds(started), signal: controller.signal })
868
+ streak.onSuccess()
869
+ if (namedAllTerminal()) {
870
+ requestStop()
871
+ return
872
+ }
873
+ } catch (error) {
874
+ // A cancelled held read is the watch stopping, not a store
875
+ // failure — never report it.
876
+ if (!stop && !aborted() && !controller.signal.aborted) streak.onFailure(error)
877
+ } finally {
878
+ inFlightReads.delete(controller)
879
+ }
880
+ if (stop || aborted()) return
881
+ const elapsed = now().getTime() - started
882
+ if (elapsed < interval) await sleep(interval - elapsed, stopController.signal)
883
+ }
884
+ }
885
+
886
+ const launch = (stream: Stream): void => {
887
+ const key = stream.kind === 'build' ? stream.slug : REPO_STREAM_KEY
888
+ if (launched.has(key)) return
889
+ launched.add(key)
890
+ tasks.push(
891
+ stream.kind === 'build'
892
+ ? runStreamTask((readOpts) => pollBuild(stream, readOpts))
893
+ : runStreamTask((readOpts) => pollRepository(stream, readOpts)),
894
+ )
895
+ }
896
+
897
+ // An external abort (SIGINT) is a stop like any other: it cancels
898
+ // the in-flight held reads and wakes the gap sleeps.
899
+ opts.signal?.addEventListener('abort', requestStop, { once: true })
900
+
901
+ for (const stream of streams.values()) if (!shouldStop) launch(stream)
902
+
903
+ if (slugs.length === 0 && !shouldStop) {
904
+ tasks.push(
905
+ (async (): Promise<void> => {
906
+ const streak = makeFailureStreak()
907
+ while (!stop && !aborted() && now().getTime() < deadline) {
908
+ try {
909
+ await discoverBuilds()
910
+ streak.onSuccess()
911
+ } catch (error) {
912
+ streak.onFailure(error)
913
+ }
914
+ for (const stream of streams.values()) launch(stream)
915
+ if (stop || aborted()) return
916
+ await sleep(interval, stopController.signal)
917
+ }
918
+ })(),
919
+ )
920
+ }
921
+
922
+ await Promise.all(tasks)
923
+ opts.signal?.removeEventListener('abort', requestStop)
924
+ }
925
+
926
+ const finalCursor = countCursor ?? encodeCurrent()
927
+ if (opts.json === true) {
928
+ opts.stdout(JSON.stringify({ cursor: finalCursor }))
929
+ }
930
+ },
931
+ )
932
+ }