@defrex/autobuild 0.8.0 → 0.10.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 (182) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +323 -26
  3. package/bin/ab-build-runner.ts +8 -2
  4. package/docs/architecture.md +166 -14
  5. package/docs/assets/headline-wide.png +0 -0
  6. package/docs/configuration.md +175 -28
  7. package/docs/hosted-dispatcher.md +69 -13
  8. package/docs/operator-api.md +2 -2
  9. package/docs/remote-store-protocol.md +204 -16
  10. package/docs/remote-ticket-protocol.md +1 -1
  11. package/docs/setup.md +12 -9
  12. package/docs/website.md +14 -0
  13. package/package.json +45 -23
  14. package/packages/core/src/cli/artifact.ts +130 -6
  15. package/packages/core/src/cli/binary.ts +9 -1
  16. package/packages/core/src/cli/build-control.ts +110 -13
  17. package/packages/core/src/cli/context.ts +46 -0
  18. package/packages/core/src/cli/dashboard/facts.ts +215 -0
  19. package/packages/core/src/cli/dashboard/frame-parse.ts +223 -0
  20. package/packages/core/src/cli/dashboard/model.ts +141 -153
  21. package/packages/core/src/cli/dashboard/poll.ts +76 -12
  22. package/packages/core/src/cli/dashboard/render.ts +34 -5
  23. package/packages/core/src/cli/dispatch-frontend.ts +54 -5
  24. package/packages/core/src/cli/dispatch-process.ts +1 -13
  25. package/packages/core/src/cli/dispatch.ts +392 -75
  26. package/packages/core/src/cli/harvest.ts +122 -8
  27. package/packages/core/src/cli/help.ts +62 -9
  28. package/packages/core/src/cli/init-readiness-remote.ts +110 -0
  29. package/packages/core/src/cli/init-readiness-shared.ts +72 -0
  30. package/packages/core/src/cli/init-validation.ts +266 -222
  31. package/packages/core/src/cli/installation.ts +50 -8
  32. package/packages/core/src/cli/main.ts +70 -11
  33. package/packages/core/src/cli/mcp.ts +23 -1
  34. package/packages/core/src/cli/plugin.ts +7 -4
  35. package/packages/core/src/cli/remote-poll.ts +277 -0
  36. package/packages/core/src/cli/repo-state.ts +22 -0
  37. package/packages/core/src/cli/repository-status.ts +39 -1
  38. package/packages/core/src/cli/self-update.ts +77 -2
  39. package/packages/core/src/cli/status.ts +150 -28
  40. package/packages/core/src/cli/terminals.ts +2 -1
  41. package/packages/core/src/cli/ticket-assets.ts +247 -0
  42. package/packages/core/src/cli/ticket.ts +453 -8
  43. package/packages/core/src/cli/wait.ts +157 -41
  44. package/packages/core/src/cli/watch.ts +138 -168
  45. package/packages/core/src/config/live.ts +23 -0
  46. package/packages/core/src/config/pipeline-source.ts +388 -0
  47. package/packages/core/src/config/schema.ts +251 -19
  48. package/packages/core/src/events/catalog.ts +1 -0
  49. package/packages/core/src/events/globs.ts +47 -0
  50. package/packages/core/src/events/payloads.ts +52 -2
  51. package/packages/core/src/events/repository.ts +52 -1
  52. package/packages/core/src/events/sessions.ts +69 -10
  53. package/packages/core/src/events/workspace-remote.ts +12 -0
  54. package/packages/core/src/integration/harness.ts +115 -2
  55. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +1369 -0
  56. package/packages/core/src/kernel/auto-merge-default.ts +157 -0
  57. package/packages/core/src/kernel/auto-merge.ts +99 -22
  58. package/packages/core/src/kernel/dispatch-settings.ts +42 -17
  59. package/packages/core/src/kernel/dispatch-status.ts +23 -0
  60. package/packages/core/src/kernel/engine.ts +9 -438
  61. package/packages/core/src/kernel/generators/build-log.ts +259 -0
  62. package/packages/core/src/kernel/generators/repository-journals.ts +206 -0
  63. package/packages/core/src/kernel/generators/session-log.ts +72 -0
  64. package/packages/core/src/kernel/harvest.ts +62 -18
  65. package/packages/core/src/kernel/incremental-contract.ts +75 -0
  66. package/packages/core/src/kernel/incremental-fixtures.ts +211 -0
  67. package/packages/core/src/kernel/incremental.ts +53 -0
  68. package/packages/core/src/kernel/log-index.ts +620 -0
  69. package/packages/core/src/kernel/phases.ts +16 -2
  70. package/packages/core/src/kernel/pr-attachments.ts +164 -46
  71. package/packages/core/src/kernel/reducer-registry.ts +164 -0
  72. package/packages/core/src/kernel/reducer.ts +254 -73
  73. package/packages/core/src/operator/annotations.ts +6 -0
  74. package/packages/core/src/operator/control.ts +19 -5
  75. package/packages/core/src/operator/index.ts +71 -6
  76. package/packages/core/src/operator/query.ts +253 -24
  77. package/packages/core/src/operator/read-view.ts +98 -0
  78. package/packages/core/src/operator/registry.ts +55 -10
  79. package/packages/core/src/operator/requests.ts +9 -6
  80. package/packages/core/src/operator/sandbox-host.ts +140 -0
  81. package/packages/core/src/operator/sandbox.ts +247 -14
  82. package/packages/core/src/operator/ticket-source-backend.ts +47 -0
  83. package/packages/core/src/orchestrator/approvals.ts +115 -0
  84. package/packages/core/src/orchestrator/conversation.ts +167 -0
  85. package/packages/core/src/orchestrator/model.ts +86 -0
  86. package/packages/core/src/orchestrator/prompt.ts +61 -0
  87. package/packages/core/src/orchestrator/tools.ts +117 -0
  88. package/packages/core/src/orchestrator/turn-runner.ts +457 -0
  89. package/packages/core/src/plugin-sdk/index.ts +51 -1
  90. package/packages/core/src/plugins/load.ts +135 -7
  91. package/packages/core/src/plugins/manifest.ts +183 -10
  92. package/packages/core/src/plugins/registry.ts +73 -4
  93. package/packages/core/src/ports/forge/create.ts +1 -1
  94. package/packages/core/src/ports/forge/fake.ts +31 -0
  95. package/packages/core/src/ports/forge/github-transport.ts +13 -2
  96. package/packages/core/src/ports/forge/github.ts +13 -2
  97. package/packages/core/src/ports/forge/local-git.ts +31 -1
  98. package/packages/core/src/ports/runner/claude.ts +2 -2
  99. package/packages/core/src/ports/runner/codex.ts +6 -2
  100. package/packages/core/src/ports/runner/fake.ts +7 -1
  101. package/packages/core/src/ports/runner/pi.ts +8 -6
  102. package/packages/core/src/ports/runner/provider-error.ts +13 -3
  103. package/packages/core/src/ports/tickets/fake.ts +5 -1
  104. package/packages/core/src/ports/tickets/file.ts +169 -48
  105. package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
  106. package/packages/core/src/ports/tickets/linear.ts +33 -1
  107. package/packages/core/src/ports/tickets/operations.ts +1 -1
  108. package/packages/core/src/ports/tickets/remote.ts +8 -1
  109. package/packages/core/src/ports/types.ts +49 -3
  110. package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
  111. package/packages/core/src/ports/workspace/create.ts +74 -39
  112. package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
  113. package/packages/core/src/ports/workspace/fake.ts +169 -4
  114. package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
  115. package/packages/core/src/ports/workspace/github-origin.ts +36 -0
  116. package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
  117. package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
  118. package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
  119. package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
  120. package/packages/core/src/processes/build-child.ts +17 -2
  121. package/packages/core/src/processes/build-execution-state.ts +68 -15
  122. package/packages/core/src/processes/build-runner.ts +238 -93
  123. package/packages/core/src/processes/dispatcher-selectors.ts +123 -0
  124. package/packages/core/src/processes/dispatcher.ts +501 -201
  125. package/packages/core/src/processes/execution-settlement.ts +72 -39
  126. package/packages/core/src/processes/harvest-child.ts +5 -0
  127. package/packages/core/src/processes/harvest-execution-state.ts +41 -29
  128. package/packages/core/src/processes/harvest-runner.ts +66 -19
  129. package/packages/core/src/processes/harvest.ts +196 -24
  130. package/packages/core/src/processes/infrastructure-failure-budget.ts +31 -18
  131. package/packages/core/src/processes/orchestrator-tick.ts +360 -0
  132. package/packages/core/src/processes/publication-state.ts +164 -52
  133. package/packages/core/src/processes/repo-view.ts +1436 -0
  134. package/packages/core/src/processes/sandbox-state.ts +36 -2
  135. package/packages/core/src/processes/tick-state.ts +33 -0
  136. package/packages/core/src/store/adapter.ts +41 -0
  137. package/packages/core/src/store/build-scope.ts +90 -0
  138. package/packages/core/src/store/contract.ts +1205 -0
  139. package/packages/core/src/store/digest.ts +102 -0
  140. package/packages/core/src/store/local/schema.ts +46 -2
  141. package/packages/core/src/store/local/store.ts +445 -4
  142. package/packages/core/src/store/memory.ts +246 -0
  143. package/packages/core/src/store/new-build.ts +17 -0
  144. package/packages/core/src/store/phase-session.ts +94 -0
  145. package/packages/core/src/store/remote/client.ts +310 -2
  146. package/packages/core/src/store/remote/index.ts +14 -2
  147. package/packages/core/src/store/remote/protocol.ts +115 -12
  148. package/packages/core/src/store/remote/version.ts +6 -2
  149. package/packages/core/src/store/repo-state-events.ts +205 -0
  150. package/packages/core/src/store/retention.ts +7 -0
  151. package/packages/core/src/store/session-handle.ts +94 -0
  152. package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +79 -19
  153. package/packages/core/src/store/snapshots.ts +88 -0
  154. package/packages/core/src/store/streams/session-writer.ts +42 -2
  155. package/packages/core/src/store/streams/wait.ts +4 -5
  156. package/packages/core/src/store/ticket-assets.ts +458 -0
  157. package/packages/core/src/store/types.ts +160 -1
  158. package/packages/core/src/testing/index.ts +59 -0
  159. package/skills/code-review/SKILL.md +5 -1
  160. package/skills/guide/SKILL.md +152 -40
  161. package/skills/guide/references/operator-api.md +2 -2
  162. package/skills/guide/references/plugin-authoring.md +17 -0
  163. package/skills/guide/references/remote-store-protocol.md +204 -16
  164. package/skills/guide/references/remote-ticket-protocol.md +1 -1
  165. package/skills/guide/references/setup.md +12 -9
  166. package/skills/implement/SKILL.md +3 -0
  167. package/skills/operate/SKILL.md +126 -0
  168. package/skills/plan/SKILL.md +4 -0
  169. package/skills/plan-review/SKILL.md +5 -1
  170. package/skills/spec/SKILL.md +46 -3
  171. package/skills/tickets/SKILL.md +57 -5
  172. package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
  173. package/packages/core/src/cli/store-opening.contract.ts +0 -289
  174. package/packages/core/src/cli/testkit.ts +0 -194
  175. package/packages/core/src/markdown.ts +0 -67
  176. package/packages/core/src/operator/client.ts +0 -311
  177. package/packages/core/src/operator/server.ts +0 -616
  178. package/packages/core/src/operator/sessions.ts +0 -223
  179. package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
  180. package/packages/core/src/ports/tickets/remote-server.ts +0 -185
  181. package/packages/core/src/store/remote/server.ts +0 -923
  182. package/packages/core/src/testing/store-failures.ts +0 -32
@@ -1,5 +1,6 @@
1
1
  /** Deterministic halves of observation harvest: scan, dedup, validation, and
2
2
  * proposal rendering. Agent judgment is deliberately absent from this file. */
3
+ import type { AbEvent } from '../events/catalog'
3
4
  import type { ArtifactRef } from '../ontology'
4
5
  import {
5
6
  harvestProposalSetSchema,
@@ -26,7 +27,14 @@ import {
26
27
  } from '../kernel/harvest'
27
28
  import type { TicketSource } from '../ports/types'
28
29
  import { specConformance } from '../spec-standard'
29
- import { contentHash, toBytes, type BuildStore } from '../store/types'
30
+ import type { RepositoryEvent } from '../events/repository'
31
+ import {
32
+ contentHash,
33
+ toBytes,
34
+ type BuildDigest,
35
+ type BuildRecord,
36
+ type BuildStore,
37
+ } from '../store/types'
30
38
 
31
39
  export const HARVEST_SCAN_ARTIFACT = 'harvest-scan'
32
40
  export const HARVEST_PROPOSALS_ARTIFACT = 'harvest-proposals'
@@ -52,21 +60,33 @@ export interface HarvestPressure {
52
60
  trigger?: HarvestTrigger
53
61
  }
54
62
 
55
- /** Pure two-dimensional Harvest gate. Drift is measured from the oldest
56
- * unclaimed observation and excludes that observation's own build. */
57
- export function evaluateHarvestPressure(
58
- scan: Pick<HarvestScanResult, 'observations' | 'merges'>,
63
+ /** The build- and occurrence-shaped facts both pressure evaluations project
64
+ * onto: the unclaimed occurrences with their event timestamps, and the merge
65
+ * facts. Keeping the oldest-observation selection and drift arithmetic in one
66
+ * place makes the scan-based and digest-based evaluations equal by
67
+ * construction rather than by parallel maintenance (AUT-521). */
68
+ interface HarvestPressureFacts {
69
+ unclaimed: { build: string; seq: number; ts: string }[]
70
+ merges: { build: string; ts: string }[]
71
+ }
72
+
73
+ function evaluateHarvestPressureFacts(
74
+ facts: HarvestPressureFacts,
59
75
  policy: { harvestThreshold: number; harvestMaxDrift: number },
60
76
  ): HarvestPressure {
61
- const observationCount = scan.observations.length
77
+ const observationCount = facts.unclaimed.length
62
78
  if (observationCount === 0) return { observationCount, drift: 0 }
63
79
 
64
- const oldest = scan.observations.reduce((candidate, observation) =>
65
- observation.ts < candidate.ts ? observation : candidate,
66
- )
80
+ // The oldest unclaimed occurrence: min ts, then build ascending, then seq
81
+ // ascending — exactly what the scan projection's sort-by-(build,seq) then
82
+ // strict-`<`-reduce produced, now stated as one comparator.
83
+ const oldest = [...facts.unclaimed].sort(
84
+ (a, b) =>
85
+ (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0) || a.build.localeCompare(b.build) || a.seq - b.seq,
86
+ )[0]!
67
87
  const mergedBuilds = new Set<string>()
68
- for (const merge of scan.merges) {
69
- if (merge.build === oldest.occurrence.build || merge.ts <= oldest.ts) continue
88
+ for (const merge of facts.merges) {
89
+ if (merge.build === oldest.build || merge.ts <= oldest.ts) continue
70
90
  mergedBuilds.add(merge.build)
71
91
  }
72
92
  const drift = mergedBuilds.size
@@ -87,23 +107,94 @@ export function evaluateHarvestPressure(
87
107
  }
88
108
  }
89
109
 
90
- /** Raw structured `observation.recorded` envelopes across this repository.
91
- * The pair `{build, seq}` is the occurrence key; payload ids are not assumed
92
- * globally unique. */
93
- export async function scanUnclaimedObservations(
94
- store: BuildStore,
95
- repo: string,
96
- ): Promise<HarvestScanResult> {
97
- await store.ensureRepo(repo)
98
- const harvestEvents = await store.getRepoEvents(repo)
99
- const state = reduceHarvest(harvestEvents)
110
+ /** Pure two-dimensional Harvest gate. Drift is measured from the oldest
111
+ * unclaimed observation and excludes that observation's own build. */
112
+ export function evaluateHarvestPressure(
113
+ scan: Pick<HarvestScanResult, 'observations' | 'merges'>,
114
+ policy: { harvestThreshold: number; harvestMaxDrift: number },
115
+ ): HarvestPressure {
116
+ return evaluateHarvestPressureFacts(
117
+ {
118
+ unclaimed: scan.observations.map((observation) => ({
119
+ build: observation.occurrence.build,
120
+ seq: observation.occurrence.seq,
121
+ ts: observation.ts,
122
+ })),
123
+ merges: scan.merges,
124
+ },
125
+ policy,
126
+ )
127
+ }
128
+
129
+ /** The same gate from build digests and the repository journal alone
130
+ * (AUT-521): claimedness comes from the journal, unclaimed occurrences and
131
+ * merge facts from the digests — no per-build history reads. The merge facts
132
+ * use each digest's latest `pr.merged` ts; drift counts a build when any of
133
+ * its merges is strictly after the oldest unclaimed observation, and
134
+ * `max(merge.ts) > oldest.ts` is equivalent to `any(merge.ts) > oldest.ts`,
135
+ * so latest-wins carries exactly the drift fact the scan collects. */
136
+ export function evaluateHarvestPressureFromDigests(input: {
137
+ digests: Map<string, BuildDigest>
138
+ harvestEvents: RepositoryEvent[]
139
+ policy: { harvestThreshold: number; harvestMaxDrift: number }
140
+ }): HarvestPressure {
141
+ const claimed = claimedOccurrenceKeys(reduceHarvest(input.harvestEvents))
142
+ const unclaimed: HarvestPressureFacts['unclaimed'] = []
143
+ const merges: HarvestPressureFacts['merges'] = []
144
+ for (const digest of input.digests.values()) {
145
+ for (const observation of digest.observations) {
146
+ if (claimed.has(occurrenceKey({ build: digest.slug, seq: observation.seq }))) continue
147
+ unclaimed.push({ build: digest.slug, seq: observation.seq, ts: observation.ts })
148
+ }
149
+ if (digest.merged !== undefined) merges.push({ build: digest.slug, ts: digest.merged })
150
+ }
151
+ return evaluateHarvestPressureFacts({ unclaimed, merges }, input.policy)
152
+ }
153
+
154
+ /** The dispatcher harvest-launch gate's read sequence as one named seam
155
+ * (AUT-521): `ensureRepo`, one repo-scoped digest batch read, and the pure
156
+ * digest evaluation with the caller's policy. Per-evaluation cost is one
157
+ * journal read (passed in by the caller, which already holds it) plus one
158
+ * bounded digest read — flat in the number of accumulated finished builds,
159
+ * where the previous scan read every build's full history per evaluation. */
160
+ export async function evaluateHarvestPressureFromStore(input: {
161
+ store: BuildStore
162
+ repo: string
163
+ harvestEvents: RepositoryEvent[]
164
+ policy: { harvestThreshold: number; harvestMaxDrift: number }
165
+ }): Promise<HarvestPressure> {
166
+ await input.store.ensureRepo(input.repo)
167
+ const digests = await input.store.getRepoBuildDigests(input.repo)
168
+ return evaluateHarvestPressureFromDigests({
169
+ digests,
170
+ harvestEvents: input.harvestEvents,
171
+ policy: input.policy,
172
+ })
173
+ }
174
+
175
+ /** The deterministic core of the unclaimed-observation scan, over
176
+ * already-loaded streams: exactly the reduce/claim/collect/sort the
177
+ * store-reading scan performs, with no store calls. The dashboard consumes
178
+ * this directly with the journal and per-build event arrays it has already
179
+ * fetched (AUT-486), while `scanUnclaimedObservations` keeps the
180
+ * ensure/read/delegate shape its other callers depend on. */
181
+ export function collectUnclaimedObservations(input: {
182
+ repo: string
183
+ records: BuildRecord[]
184
+ eventsByBuild: Map<string, AbEvent[]>
185
+ harvestEvents: RepositoryEvent[]
186
+ }): HarvestScanResult {
187
+ const state = reduceHarvest(input.harvestEvents)
100
188
  const claimed = claimedOccurrenceKeys(state)
101
189
  const observations: HarvestObservation[] = []
102
190
  const merges: HarvestMerge[] = []
103
191
 
104
- for (const record of await store.listBuilds()) {
105
- if (record.repo !== repo) continue
106
- const events = await store.getEvents(record.slug)
192
+ for (const record of input.records) {
193
+ if (record.repo !== input.repo) continue
194
+ const events = input.eventsByBuild.get(record.slug)
195
+ if (events === undefined) {
196
+ throw new Error(`eventsByBuild is missing an entry for build "${record.slug}"`)
197
+ }
107
198
  for (const event of events) {
108
199
  if (event.type === 'pr.merged') {
109
200
  merges.push({ build: record.slug, ts: event.ts })
@@ -132,6 +223,87 @@ export async function scanUnclaimedObservations(
132
223
  return { observations, merges, state }
133
224
  }
134
225
 
226
+ /** The unclaimed-observation count from build digests and the repository
227
+ * journal alone (AUT-487): the same reduce/claim/count the store-reading scan
228
+ * performs, with no per-build history reads. The operator query consumes this
229
+ * directly, and the terminal dashboards consume it through
230
+ * `sampleUnclaimedObservationCount` (AUT-524), while `scanUnclaimedObservations`
231
+ * keeps the full-scan shape its remaining callers depend on (the harvest
232
+ * runner's packet scan — the only source of observation payloads — and its
233
+ * gate re-confirmation; the dispatcher's harvest-launch gate itself evaluates
234
+ * from digests, AUT-521). Occurrences are keyed `{build, seq}`; payload ids
235
+ * are not assumed globally unique. */
236
+ export function unclaimedObservationCount(input: {
237
+ digests: Map<string, BuildDigest>
238
+ harvestEvents: RepositoryEvent[]
239
+ }): number {
240
+ const claimed = claimedOccurrenceKeys(reduceHarvest(input.harvestEvents))
241
+ let count = 0
242
+ for (const digest of input.digests.values()) {
243
+ for (const observation of digest.observations) {
244
+ if (!claimed.has(occurrenceKey({ build: digest.slug, seq: observation.seq }))) count += 1
245
+ }
246
+ }
247
+ return count
248
+ }
249
+
250
+ /** The shared missing-record journal read (AUT-524): a repository whose
251
+ * journal record does not yet exist has an empty journal by definition, so
252
+ * the probe answers `[]` instead of letting `getRepoStateEvents` reject. The
253
+ * read writes nothing — a display path must not `ensureRepo` — while
254
+ * `scanUnclaimedObservations` keeps its materializing `ensureRepo` for its
255
+ * remaining callers.
256
+ *
257
+ * The journal read is bounded (AUT-489): every consumer — the terminal
258
+ * dashboards' observation-pressure sample, the operator query
259
+ * (operator/query.ts), the dispatcher's repository-wide controls and ready
260
+ * scans, and the operator sandbox service's journal reads — reduces durable
261
+ * event types plus the latest-run tail only, so the subset is
262
+ * replay-equivalent to a full journal replay. */
263
+ export async function readRepoEventsIfRecorded(
264
+ store: BuildStore,
265
+ repo: string,
266
+ ): Promise<RepositoryEvent[]> {
267
+ return (await store.getRepo(repo)) === null ? [] : store.getRepoStateEvents(repo)
268
+ }
269
+
270
+ /** The terminal dashboards' observation-pressure sample (AUT-487): build
271
+ * digests plus the repository journal, reduced to the unclaimed-observation
272
+ * count. Store traffic is flat in the finished-build count — one journal read
273
+ * plus one repo-scoped digest read, plus the journal-record probe inside
274
+ * `readRepoEventsIfRecorded`. Its missing-record treatment (AUT-524) is the
275
+ * shared helper's, above. */
276
+ export async function sampleUnclaimedObservationCount(
277
+ store: BuildStore,
278
+ repo: string,
279
+ ): Promise<number> {
280
+ const [digests, harvestEvents] = await Promise.all([
281
+ store.getRepoBuildDigests(repo),
282
+ readRepoEventsIfRecorded(store, repo),
283
+ ])
284
+ return unclaimedObservationCount({ digests, harvestEvents })
285
+ }
286
+
287
+ /** Raw structured `observation.recorded` envelopes across this repository.
288
+ * The pair `{build, seq}` is the occurrence key; payload ids are not assumed
289
+ * globally unique. */
290
+ export async function scanUnclaimedObservations(
291
+ store: BuildStore,
292
+ repo: string,
293
+ ): Promise<HarvestScanResult> {
294
+ await store.ensureRepo(repo)
295
+ // Bounded read (AUT-489): only harvest facts are consumed here; the
296
+ // per-build history reads stay as AUT-487 left them.
297
+ const harvestEvents = await store.getRepoStateEvents(repo)
298
+ const records = await store.listBuilds()
299
+ const eventsByBuild = new Map<string, AbEvent[]>()
300
+ for (const record of records) {
301
+ if (record.repo !== repo) continue
302
+ eventsByBuild.set(record.slug, await store.getEvents(record.slug))
303
+ }
304
+ return collectUnclaimedObservations({ repo, records, eventsByBuild, harvestEvents })
305
+ }
306
+
135
307
  /** Distinct previously filed/joined proposal tickets, reconciled through the
136
308
  * TicketSource's native lifecycle semantics. Resolved and missing entries stay
137
309
  * in the packet as tombstones rather than disappearing and being re-filed. */
@@ -2,6 +2,7 @@ import type { AbEvent } from '../events/catalog'
2
2
  import { DISPATCHER } from '../events/envelope'
3
3
  import type { EventPayload } from '../events/payloads'
4
4
  import type { IdSource } from '../ids'
5
+ import { defineReducer } from '../kernel/incremental'
5
6
  import type { BuildStore } from '../store/types'
6
7
 
7
8
  export type InfrastructureOperation = EventPayload<'infrastructure.failed'>['operation']
@@ -26,27 +27,39 @@ export interface InfrastructureFailureDependencies {
26
27
  ids: IdSource
27
28
  }
28
29
 
30
+ export const INFRASTRUCTURE_FAILURE_RESET_REDUCER_VERSION = 1
31
+
29
32
  /** Infrastructure retries are re-armed only by a confirmed execution boundary
30
33
  * or a retry answer to this policy's own previously-raised exhaustion escalation. */
31
- export function infrastructureFailureResetSeq(events: readonly AbEvent[]): number {
32
- const infrastructureEscalations = new Set<string>()
33
- let resetSeq = 0
34
- for (const event of events) {
35
- if (
36
- event.type === 'escalation.raised' &&
37
- event.payload.policyCause === 'infrastructure-failure-limit'
38
- ) {
39
- infrastructureEscalations.add(event.payload.id)
40
- } else if (
41
- event.type === 'execution.ended' ||
42
- (event.type === 'escalation.answered' &&
43
- event.payload.resolution === 'retry' &&
44
- infrastructureEscalations.has(event.payload.id))
45
- ) {
46
- resetSeq = event.seq
34
+ export const infrastructureFailureResetReducer = defineReducer<
35
+ { escalations: string[]; resetSeq: number },
36
+ AbEvent,
37
+ number
38
+ >({
39
+ version: INFRASTRUCTURE_FAILURE_RESET_REDUCER_VERSION,
40
+ initial: () => ({ escalations: [], resetSeq: 0 }),
41
+ fold(acc, events) {
42
+ for (const event of events) {
43
+ if (
44
+ event.type === 'escalation.raised' &&
45
+ event.payload.policyCause === 'infrastructure-failure-limit'
46
+ ) {
47
+ if (!acc.escalations.includes(event.payload.id)) acc.escalations.push(event.payload.id)
48
+ } else if (
49
+ event.type === 'execution.ended' ||
50
+ (event.type === 'escalation.answered' &&
51
+ event.payload.resolution === 'retry' &&
52
+ acc.escalations.includes(event.payload.id))
53
+ ) {
54
+ acc.resetSeq = event.seq
55
+ }
47
56
  }
48
- }
49
- return resetSeq
57
+ },
58
+ finish: (acc) => acc.resetSeq,
59
+ })
60
+
61
+ export function infrastructureFailureResetSeq(events: readonly AbEvent[]): number {
62
+ return infrastructureFailureResetReducer.reduce(events)
50
63
  }
51
64
 
52
65
  export function normalizeInfrastructureError(error: unknown): string {