@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
@@ -0,0 +1,157 @@
1
+ /**
2
+ * The repository auto-merge default as a bulk action over in-flight builds.
3
+ *
4
+ * The durable `dispatcher.auto-merge-default-set` fact has always been the
5
+ * claim-time seed; it now also fans out onto every current non-terminal build
6
+ * of the repository, attributed to the operator who toggled it. The fan-out is
7
+ * a dispatcher-tick reconciliation driven by the durable fact — never a
8
+ * write-time walk — so a build claimed concurrently with the toggle and a
9
+ * build observed by a second dispatcher polling the same repository converge
10
+ * on the same state.
11
+ *
12
+ * Idempotency vs. per-build overrides: each answering command records the
13
+ * repository `seq` of the default fact that caused it (`defaultSeq`). A tick
14
+ * applies the default to a build only when the newest fact's seq is strictly
15
+ * newer than the build's recorded `defaultSeq`. When the build's requested
16
+ * state already matches the fact, the tick records a provenance-only
17
+ * `build.auto-merge-default-observed` marker instead of appending a duplicate
18
+ * command, so a later per-build toggle is not mistaken for staleness and
19
+ * reverted. A global toggle therefore re-applies over a per-build choice, a
20
+ * per-build toggle in between is stable, and pressing the toggle twice appends
21
+ * no duplicate request.
22
+ *
23
+ * Eligibility is expressed against `BuildState`, not `effectiveStatus`
24
+ * (dashboard/model.ts), whose contract is "DISPLAY-ONLY — nothing consults
25
+ * this"; a durable write path must not become its first consumer — the same
26
+ * precedent `bulk-control.ts` set for the pause/resume walk.
27
+ */
28
+ import type { RepositoryEvent } from '../events/repository'
29
+ import { defineReducer } from './incremental'
30
+ import { discardInFlight, type BuildState } from './reducer'
31
+
32
+ /** One `dispatcher.auto-merge-default-set` fact, resolved. `actor` is the
33
+ * event's actor — the human who toggled, `via` marker included — and is the
34
+ * attribution every fan-out command carries. */
35
+ export interface AutoMergeDefaultFact {
36
+ enabled: boolean
37
+ /** The repository event's seq — the fan-out's comparison marker. */
38
+ seq: number
39
+ actor: RepositoryEvent['actor']
40
+ }
41
+
42
+ /** The newest `dispatcher.auto-merge-default-set` fact in the repository
43
+ * journal, or undefined when no default was ever set (the scan runs from the
44
+ * tail because seqs are monotonic, so the first hit is the newest). With
45
+ * `enabled`, the newest fact *matching that value* — the fact a claim-time
46
+ * seed built on a stale pre-tick sample must cite, so a newer opposite fact
47
+ * stays strictly newer than the seed's provenance and the next tick's
48
+ * fan-out can reconcile it (f_b851c0e8). */
49
+ export function latestAutoMergeDefault(
50
+ events: RepositoryEvent[],
51
+ enabled?: boolean,
52
+ ): AutoMergeDefaultFact | undefined {
53
+ return pickAutoMergeDefault(autoMergeDefaultReducer.reduce(events), enabled)
54
+ }
55
+
56
+ export const AUTO_MERGE_DEFAULT_REDUCER_VERSION = 1
57
+
58
+ /** The newest `dispatcher.auto-merge-default-set` fact in array order, overall
59
+ * and per value — `latestAutoMergeDefault(events, enabled)` is a lookup in
60
+ * this state, exact for any array order. */
61
+ export interface AutoMergeDefaultFacts {
62
+ latest?: AutoMergeDefaultFact
63
+ latestEnabled?: AutoMergeDefaultFact
64
+ latestDisabled?: AutoMergeDefaultFact
65
+ }
66
+
67
+ export const autoMergeDefaultReducer = defineReducer<
68
+ AutoMergeDefaultFacts,
69
+ RepositoryEvent,
70
+ AutoMergeDefaultFacts
71
+ >({
72
+ version: AUTO_MERGE_DEFAULT_REDUCER_VERSION,
73
+ initial: () => ({}),
74
+ fold(acc, events) {
75
+ for (const event of events) {
76
+ if (event.type !== 'dispatcher.auto-merge-default-set') continue
77
+ const fact: AutoMergeDefaultFact = {
78
+ enabled: event.payload.enabled,
79
+ seq: event.seq,
80
+ actor: event.actor,
81
+ }
82
+ acc.latest = fact
83
+ if (fact.enabled) acc.latestEnabled = fact
84
+ else acc.latestDisabled = fact
85
+ }
86
+ },
87
+ finish: (acc) => ({ ...acc }),
88
+ })
89
+
90
+ export function pickAutoMergeDefault(
91
+ facts: AutoMergeDefaultFacts,
92
+ enabled?: boolean,
93
+ ): AutoMergeDefaultFact | undefined {
94
+ if (enabled === undefined) return facts.latest
95
+ return enabled ? facts.latestEnabled : facts.latestDisabled
96
+ }
97
+
98
+ /**
99
+ * A build the default may still act on: not terminal, and not on its way out.
100
+ * A pending abort (an explicit request or an accepted escalation answer) must
101
+ * never receive consent — a merge landing mid-abort is exactly the race this
102
+ * clause exists to close. The same holds for an outstanding discard request:
103
+ * `discardRequest` is present exactly while the build is non-terminal and its
104
+ * discard is unsettled, and the reducer settles it only by terminal completion
105
+ * — consent recorded inside that window can merge a build the operator already
106
+ * asked to discard (in the raced-runner-attachment case the discard is inert
107
+ * for a running build, and the recorded consent later drives the merge in
108
+ * `checkPr`). Direction-blind like the abort clause: the build terminalizes
109
+ * imminently either way. If discard ever grows a mid-flight settlement event,
110
+ * this predicate must be revisited.
111
+ */
112
+ export function autoMergeDefaultEligible(state: BuildState): boolean {
113
+ if (state.status === 'done' || state.status === 'aborted') return false
114
+ // An empty log is a record whose `build.created` has not landed yet (the
115
+ // crash or one-await window between `createBuild` and the first append):
116
+ // writing an auto-merge command ahead of the immutable facts would leave the
117
+ // log unrecoverable — dispatch recovery rejects a log that does not start
118
+ // with `build.created` (f_647d40be). `lastSeq === 0` is exactly that log.
119
+ if (state.lastSeq === 0) return false
120
+ // An outstanding discard request is the same "on its way out" shape as a
121
+ // pending abort: it settles only by terminal completion (reducer.ts), so
122
+ // consent recorded inside that window can merge a build the operator
123
+ // already asked to discard. Direction-blind, like the abort clause — an
124
+ // OFF fan-out skipping it withdraws nothing that survives the settlement.
125
+ // Shared predicate: the per-build consent guard (build-control.ts) applies
126
+ // the same exclusion to consent-recording writes. A per-build OFF withdrawal
127
+ // stays available there — revocation only shrinks the merge set — which is a
128
+ // stated, directional divergence, not a silent one.
129
+ if (discardInFlight(state)) return false
130
+ return !state.pendingCommands.some((command) => command.command === 'abort')
131
+ }
132
+
133
+ /** What one default fact asks of one build: a real command, a pure provenance
134
+ * advance, or `undefined` for "nothing". */
135
+ export type AutoMergeDefaultTarget = 'request' | 'cancel' | 'observed'
136
+
137
+ /** What one default fact asks of one build, or undefined for "nothing".
138
+ *
139
+ * Seq-first: a build whose recorded provenance is already this fact (or newer)
140
+ * has had its day in court — a per-build toggle made after the fan-out stands
141
+ * until the default moves again. A build that sampled the default at claim time
142
+ * carries the fact's seq too, so a fresh claim is not re-fanned on the next
143
+ * tick.
144
+ *
145
+ * When the requested state already matches the fact, the fact still has to be
146
+ * *recorded* as observed (`'observed'`) so the next tick does not mistake a
147
+ * later per-build command for staleness: the marker advances `defaultSeq`
148
+ * without a duplicate request/cancel (f_28b3fba1). */
149
+ export function autoMergeDefaultTarget(
150
+ state: BuildState,
151
+ fact: AutoMergeDefaultFact,
152
+ ): AutoMergeDefaultTarget | undefined {
153
+ if (!autoMergeDefaultEligible(state)) return undefined
154
+ if ((state.autoMerge.defaultSeq ?? 0) >= fact.seq) return undefined
155
+ if (state.autoMerge.requested === fact.enabled) return 'observed'
156
+ return fact.enabled ? 'request' : 'cancel'
157
+ }
@@ -2,6 +2,7 @@ import type { AbEvent, EventEnvelope, EventWrite } from '../events/catalog'
2
2
  import { KERNEL } from '../events/envelope'
3
3
  import { autoMergeDeferralClasses, type AutoMergeDeferralReason } from '../ports/types'
4
4
  import type { BuildStore } from '../store/types'
5
+ import { defineReducer } from './incremental'
5
6
  import type { BuildState } from './reducer'
6
7
 
7
8
  /** The complete GitHub `mergeStateStatus` enum. Keeping this list closed is a
@@ -133,36 +134,104 @@ export function currentDeferralObservation(
133
134
  events: AbEvent[],
134
135
  prNumber: number,
135
136
  commandSeq: number,
136
- ): Extract<AbEvent, { type: 'observation.recorded' }> | undefined {
137
+ ): DeferralObservation | undefined {
138
+ return deferralObservationFrom(
139
+ currentDeferralObservationReducer.reduce(events),
140
+ prNumber,
141
+ commandSeq,
142
+ )
143
+ }
144
+
145
+ type DeferralObservation = Extract<AbEvent, { type: 'observation.recorded' }>
146
+
147
+ const DEFERRAL_REF_PREFIX = 'auto-merge-gate:pr:'
148
+
149
+ /** One array-order ledger entry. Only the facts the currency predicate reads
150
+ * are kept, with every maximal run of other events collapsed to a barrier
151
+ * carrying the run's max seq: the head scan stops at the first event (of ANY
152
+ * type) with `seq >= observation.seq`, so an unrelated event must still be able
153
+ * to end it. */
154
+ type DeferralLedgerEntry =
155
+ | { t: 'barrier'; seq: number }
156
+ | { t: 'observation'; event: DeferralObservation }
157
+ | { t: 'finalized'; seq: number; headSha: string }
158
+ | { t: 'terminal'; seq: number }
159
+ | { t: 'reconciled'; seq: number }
160
+
161
+ export const CURRENT_DEFERRAL_OBSERVATION_REDUCER_VERSION = 1
162
+
163
+ export interface DeferralLedger {
164
+ entries: DeferralLedgerEntry[]
165
+ }
166
+
167
+ export const currentDeferralObservationReducer = defineReducer<
168
+ DeferralLedger,
169
+ AbEvent,
170
+ DeferralLedger
171
+ >({
172
+ version: CURRENT_DEFERRAL_OBSERVATION_REDUCER_VERSION,
173
+ initial: () => ({ entries: [] }),
174
+ fold(acc, events) {
175
+ for (const event of events) {
176
+ let entry: DeferralLedgerEntry | undefined
177
+ if (
178
+ event.type === 'observation.recorded' &&
179
+ event.payload.refs?.some((ref) => ref.startsWith(DEFERRAL_REF_PREFIX)) === true
180
+ ) {
181
+ entry = { t: 'observation', event }
182
+ } else if (event.type === 'finalize.completed') {
183
+ entry = { t: 'finalized', seq: event.seq, headSha: event.payload.pr.headSha }
184
+ } else if (event.type === 'pr.merged' || event.type === 'pr.closed') {
185
+ entry = { t: 'terminal', seq: event.seq }
186
+ } else if (event.type === 'reconcile.completed') {
187
+ entry = { t: 'reconciled', seq: event.seq }
188
+ }
189
+ if (entry !== undefined) {
190
+ acc.entries.push(entry)
191
+ continue
192
+ }
193
+ const last = acc.entries.at(-1)
194
+ if (last?.t === 'barrier') last.seq = Math.max(last.seq, event.seq)
195
+ else acc.entries.push({ t: 'barrier', seq: event.seq })
196
+ }
197
+ },
198
+ finish: (acc) => ({ entries: acc.entries.slice() }),
199
+ })
200
+
201
+ function deferralEntrySeq(entry: DeferralLedgerEntry): number {
202
+ return entry.t === 'observation' ? entry.event.seq : entry.seq
203
+ }
204
+
205
+ /** The current deferral observation for a PR/command over a reduced ledger. */
206
+ export function deferralObservationFrom(
207
+ ledger: DeferralLedger,
208
+ prNumber: number,
209
+ commandSeq: number,
210
+ ): DeferralObservation | undefined {
137
211
  const marker = autoMergeDeferralRef(prNumber, commandSeq)
138
- let observation: Extract<AbEvent, { type: 'observation.recorded' }> | undefined
139
- for (let index = events.length - 1; index >= 0; index -= 1) {
140
- const event = events[index]
141
- if (
142
- event !== undefined &&
143
- event.type === 'observation.recorded' &&
144
- event.payload.refs?.includes(marker) === true
145
- ) {
146
- observation = event
212
+ const { entries } = ledger
213
+ let observation: DeferralObservation | undefined
214
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
215
+ const entry = entries[index]
216
+ if (entry?.t === 'observation' && entry.event.payload.refs?.includes(marker) === true) {
217
+ observation = entry.event
147
218
  break
148
219
  }
149
220
  }
150
221
  if (observation === undefined) return undefined
151
222
 
152
223
  let headAtObservation: string | undefined
153
- for (const event of events) {
154
- if (event.seq >= observation.seq) break
155
- if (event.type === 'finalize.completed') headAtObservation = event.payload.pr.headSha
224
+ for (const entry of entries) {
225
+ if (deferralEntrySeq(entry) >= observation.seq) break
226
+ if (entry.t === 'finalized') headAtObservation = entry.headSha
156
227
  }
157
228
 
158
- for (const event of events) {
159
- if (event.seq <= observation.seq) continue
160
- if (event.type === 'pr.merged' || event.type === 'pr.closed') return undefined
161
- if (event.type === 'reconcile.completed') return undefined
162
- if (event.type === 'finalize.completed') {
163
- if (headAtObservation === undefined) headAtObservation = event.payload.pr.headSha
164
- else if (event.payload.pr.headSha !== headAtObservation) return undefined
165
- }
229
+ for (const entry of entries) {
230
+ if (entry.t === 'observation' || entry.t === 'barrier') continue
231
+ if (entry.seq <= observation.seq) continue
232
+ if (entry.t === 'terminal' || entry.t === 'reconciled') return undefined
233
+ if (headAtObservation === undefined) headAtObservation = entry.headSha
234
+ else if (entry.headSha !== headAtObservation) return undefined
166
235
  }
167
236
  return observation
168
237
  }
@@ -186,10 +255,18 @@ export function hasAutoMergeDeferralObservation(
186
255
  export function currentAutoMergeDeferral(
187
256
  events: AbEvent[],
188
257
  state: Pick<BuildState, 'autoMerge' | 'pr'>,
258
+ ): string | undefined {
259
+ return currentAutoMergeDeferralFrom(currentDeferralObservationReducer.reduce(events), state)
260
+ }
261
+
262
+ /** `currentAutoMergeDeferral` over an already-reduced ledger. */
263
+ export function currentAutoMergeDeferralFrom(
264
+ ledger: DeferralLedger,
265
+ state: Pick<BuildState, 'autoMerge' | 'pr'>,
189
266
  ): string | undefined {
190
267
  const pending = pendingAutoMerge(state)
191
268
  if (pending?.enabled !== true || state.pr === undefined) return undefined
192
- return currentDeferralObservation(events, state.pr.number, pending.commandSeq)?.payload.summary
269
+ return deferralObservationFrom(ledger, state.pr.number, pending.commandSeq)?.payload.summary
193
270
  }
194
271
 
195
272
  const DEFERRAL_SUMMARIES = {
@@ -4,6 +4,7 @@
4
4
  * is authoritative; fresh repositories retain the historical process defaults.
5
5
  */
6
6
  import type { RepositoryEvent } from '../events/repository'
7
+ import { defineReducer, type IncrementalReducer } from './incremental'
7
8
 
8
9
  export const DEFAULT_DISPATCH_INTAKE = true
9
10
  export const DEFAULT_DISPATCH_PAUSED = false
@@ -21,32 +22,36 @@ export interface DispatchSettings {
21
22
  defaultAutoMerge: boolean
22
23
  }
23
24
 
24
- export function reduceDispatchSettings(events: RepositoryEvent[]): DispatchSettings {
25
- let intake = DEFAULT_DISPATCH_INTAKE
26
- let paused = DEFAULT_DISPATCH_PAUSED
27
- let defaultAutoMerge = DEFAULT_DISPATCH_AUTO_MERGE
28
- let intakeSeq = 0
29
- let pausedSeq = 0
30
- let autoMergeSeq = 0
25
+ /** The carried accumulator: the settings plus the seq of the fact that set
26
+ * each, which decides last-writer-wins independent of array order. */
27
+ export interface DispatchSettingsAcc extends DispatchSettings {
28
+ intakeSeq: number
29
+ pausedSeq: number
30
+ autoMergeSeq: number
31
+ }
32
+
33
+ /** Bump when `DispatchSettingsAcc`'s shape or fold semantics change. */
34
+ export const DISPATCH_SETTINGS_REDUCER_VERSION = 1
31
35
 
36
+ function foldDispatchSettings(acc: DispatchSettingsAcc, events: readonly RepositoryEvent[]): void {
32
37
  for (const event of events) {
33
38
  switch (event.type) {
34
39
  case 'dispatcher.intake-set':
35
- if (event.seq > intakeSeq) {
36
- intake = event.payload.enabled
37
- intakeSeq = event.seq
40
+ if (event.seq > acc.intakeSeq) {
41
+ acc.intake = event.payload.enabled
42
+ acc.intakeSeq = event.seq
38
43
  }
39
44
  break
40
45
  case 'dispatcher.pause-set':
41
- if (event.seq > pausedSeq) {
42
- paused = event.payload.enabled
43
- pausedSeq = event.seq
46
+ if (event.seq > acc.pausedSeq) {
47
+ acc.paused = event.payload.enabled
48
+ acc.pausedSeq = event.seq
44
49
  }
45
50
  break
46
51
  case 'dispatcher.auto-merge-default-set':
47
- if (event.seq > autoMergeSeq) {
48
- defaultAutoMerge = event.payload.enabled
49
- autoMergeSeq = event.seq
52
+ if (event.seq > acc.autoMergeSeq) {
53
+ acc.defaultAutoMerge = event.payload.enabled
54
+ acc.autoMergeSeq = event.seq
50
55
  }
51
56
  break
52
57
  default:
@@ -55,6 +60,26 @@ export function reduceDispatchSettings(events: RepositoryEvent[]): DispatchSetti
55
60
  break
56
61
  }
57
62
  }
63
+ }
64
+
65
+ export const dispatchSettingsReducer: IncrementalReducer<
66
+ DispatchSettingsAcc,
67
+ RepositoryEvent,
68
+ DispatchSettings
69
+ > = defineReducer({
70
+ version: DISPATCH_SETTINGS_REDUCER_VERSION,
71
+ initial: () => ({
72
+ intake: DEFAULT_DISPATCH_INTAKE,
73
+ paused: DEFAULT_DISPATCH_PAUSED,
74
+ defaultAutoMerge: DEFAULT_DISPATCH_AUTO_MERGE,
75
+ intakeSeq: 0,
76
+ pausedSeq: 0,
77
+ autoMergeSeq: 0,
78
+ }),
79
+ fold: foldDispatchSettings,
80
+ finish: ({ intake, paused, defaultAutoMerge }) => ({ intake, paused, defaultAutoMerge }),
81
+ })
58
82
 
59
- return { intake, paused, defaultAutoMerge }
83
+ export function reduceDispatchSettings(events: RepositoryEvent[]): DispatchSettings {
84
+ return dispatchSettingsReducer.reduce(events)
60
85
  }
@@ -1,5 +1,6 @@
1
1
  import type { RepositoryEvent } from '../events/repository'
2
2
  import type { ArtifactRef } from '../ontology'
3
+ import type { IncrementalReducer } from './incremental'
3
4
 
4
5
  export type DispatchHealth = 'starting' | 'running' | 'stopped' | 'failed'
5
6
 
@@ -139,3 +140,25 @@ export function reduceDispatchStatus(
139
140
  }
140
141
  return state
141
142
  }
143
+
144
+ /** Bump when `DispatchStatus`'s shape or fold semantics change (it is its own
145
+ * accumulator: `lastSeq` makes the fold skip already-seen events). */
146
+ export const DISPATCH_STATUS_REDUCER_VERSION = 1
147
+
148
+ /** The incremental form of `reduceDispatchStatus` for one run. The run is a
149
+ * parameter, so this is a factory. */
150
+ export function dispatchStatusReducer(
151
+ run: string,
152
+ ): IncrementalReducer<DispatchStatus, RepositoryEvent, DispatchStatus> {
153
+ return {
154
+ version: DISPATCH_STATUS_REDUCER_VERSION,
155
+ initial: () => ({ run, health: 'starting', roleWarnings: [], diagnostics: [], lastSeq: 0 }),
156
+ advance: (acc, events) => reduceDispatchStatus(events, run, acc),
157
+ finish: (acc) => ({
158
+ ...acc,
159
+ roleWarnings: [...acc.roleWarnings],
160
+ diagnostics: [...acc.diagnostics],
161
+ }),
162
+ reduce: (events) => reduceDispatchStatus(events, run),
163
+ }
164
+ }