@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,75 @@
1
+ /**
2
+ * Test support for the incremental reducer contract. Imported only by tests.
3
+ */
4
+ import { expect } from 'bun:test'
5
+ import type { IncrementalReducer } from './incremental'
6
+
7
+ export interface CheckOptions<State> {
8
+ /** Equality for the oracle comparison. Defaults to `toEqual`, which treats
9
+ * `undefined` and missing keys alike (the JSON round trip needs that). */
10
+ compare?: (actual: State, expected: State) => void
11
+ /** Also fold one event at a time. Default true. */
12
+ stepwise?: boolean
13
+ }
14
+
15
+ const roundTrip = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
16
+
17
+ /** Assert that for every split point `k`, advancing the accumulator of
18
+ * `log[0..k]` with `log[k..]` equals reducing the whole log — in memory and
19
+ * through a JSON round trip — and that `advance` never mutates its input. */
20
+ export function checkIncremental<Acc, Event, State>(
21
+ reducer: IncrementalReducer<Acc, Event, State>,
22
+ log: readonly Event[],
23
+ options: CheckOptions<State> = {},
24
+ ): void {
25
+ const compare = options.compare ?? ((a, b) => expect(a).toEqual(b))
26
+ const oracle = reducer.reduce(log)
27
+ for (let k = 0; k <= log.length; k++) {
28
+ const prefix = reducer.advance(reducer.initial(), log.slice(0, k))
29
+ const before = structuredClone(prefix)
30
+ const earlier = reducer.finish(prefix)
31
+ const earlierSnapshot = structuredClone(earlier)
32
+ const suffix = log.slice(k)
33
+
34
+ const advanced = reducer.advance(prefix, suffix)
35
+ expect(prefix).toEqual(before)
36
+ compare(reducer.finish(advanced), oracle)
37
+ // Advancing later must not disturb a state derived earlier.
38
+ expect(earlier).toEqual(earlierSnapshot)
39
+
40
+ const revived = reducer.advance(roundTrip(prefix), suffix)
41
+ compare(reducer.finish(revived), oracle)
42
+ // A serialized finished accumulator must also revive to the same state.
43
+ compare(reducer.finish(roundTrip(advanced)), oracle)
44
+ }
45
+ if (options.stepwise ?? true) {
46
+ let acc = reducer.initial()
47
+ for (const event of log) acc = reducer.advance(acc, [event])
48
+ compare(reducer.finish(acc), oracle)
49
+ }
50
+ }
51
+
52
+ /** A small seeded PRNG (mulberry32) so generated logs reproduce from a seed. */
53
+ export function seededRandom(seed: number): () => number {
54
+ let a = seed >>> 0
55
+ return () => {
56
+ a = (a + 0x6d2b79f5) >>> 0
57
+ let t = a
58
+ t = Math.imul(t ^ (t >>> 15), t | 1)
59
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
60
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
61
+ }
62
+ }
63
+
64
+ export function pick<T>(rand: () => number, items: readonly T[]): T {
65
+ return items[Math.floor(rand() * items.length)] as T
66
+ }
67
+
68
+ export function shuffled<T>(rand: () => number, items: readonly T[]): T[] {
69
+ const out = [...items]
70
+ for (let i = out.length - 1; i > 0; i--) {
71
+ const j = Math.floor(rand() * (i + 1))
72
+ ;[out[i], out[j]] = [out[j] as T, out[i] as T]
73
+ }
74
+ return out
75
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Test support for the incremental-reducer property tests of the small
3
+ * selector projections: build and repository event generators drawn from
4
+ * small value pools (so correlations happen), seeded, and shuffled to prove
5
+ * the carried state reproduces the whole-array result for any array order.
6
+ * Imported only by tests. Events are cast, not validated: the projections
7
+ * under test read only the fields the generators set.
8
+ */
9
+ import type { AbEvent } from '../events/catalog'
10
+ import { KERNEL } from '../events/envelope'
11
+ import type { RepositoryEvent } from '../events/repository'
12
+ import { expect } from 'bun:test'
13
+ import { checkIncremental, pick, seededRandom, shuffled } from './incremental-contract'
14
+ import type { IncrementalReducer } from './incremental'
15
+
16
+ export type Rand = () => number
17
+
18
+ export function buildLog(
19
+ items: ReadonlyArray<readonly [string, Record<string, unknown>]>,
20
+ ): AbEvent[] {
21
+ return items.map(
22
+ ([type, payload], index) =>
23
+ ({
24
+ build: 'b',
25
+ seq: index + 1,
26
+ ts: new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString(),
27
+ actor: KERNEL,
28
+ type,
29
+ payload,
30
+ }) as unknown as AbEvent,
31
+ )
32
+ }
33
+
34
+ export function repoLog(
35
+ items: ReadonlyArray<readonly [string, Record<string, unknown>]>,
36
+ ): RepositoryEvent[] {
37
+ return items.map(
38
+ ([type, payload], index) =>
39
+ ({
40
+ repo: 'r',
41
+ seq: index + 1,
42
+ ts: new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString(),
43
+ actor: KERNEL,
44
+ type,
45
+ payload,
46
+ }) as unknown as RepositoryEvent,
47
+ )
48
+ }
49
+
50
+ /** For each seed: an ordered log and two shuffled copies of it (seq is
51
+ * assigned before shuffling, so shuffled arrays carry out-of-order seqs). */
52
+ export function generatedLogs<E>(
53
+ make: (items: ReadonlyArray<readonly [string, Record<string, unknown>]>) => E[],
54
+ item: (rand: Rand) => readonly [string, Record<string, unknown>],
55
+ seeds: readonly number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
56
+ ): E[][] {
57
+ const out: E[][] = []
58
+ for (const seed of seeds) {
59
+ const rand = seededRandom(seed)
60
+ const length = 4 + Math.floor(rand() * 16)
61
+ const ordered = make(Array.from({ length }, () => item(rand)))
62
+ out.push(ordered, shuffled(rand, ordered), shuffled(rand, ordered))
63
+ }
64
+ return out
65
+ }
66
+
67
+ /** A random build event of the kinds the process-layer selectors read. */
68
+ export function randomBuildItem(rand: Rand): readonly [string, Record<string, unknown>] {
69
+ const instance = pick(rand, ['i1', 'i2'])
70
+ const attempt = 1 + Math.floor(rand() * 3)
71
+ const id = pick(rand, ['e1', 'e2', 'e3'])
72
+ const sha = pick(rand, ['s1', 's2', 's3', 's4'])
73
+ const ref = pick(rand, ['/ws/a', '/ws/b'])
74
+ const marker = pick(rand, [
75
+ 'auto-merge-gate:pr:7:command:1',
76
+ 'auto-merge-gate:pr:7:command:2',
77
+ 'other',
78
+ ])
79
+ const assets = () =>
80
+ rand() < 0.3
81
+ ? undefined
82
+ : [
83
+ { kind: 'doc', name: pick(rand, ['a', 'b']), revision: 1 + Math.floor(rand() * 3) },
84
+ ...(rand() < 0.5 ? [{ kind: 'img', name: 'c', revision: 1 }] : []),
85
+ ]
86
+ const templates: Array<() => readonly [string, Record<string, unknown>]> = [
87
+ () => ['build.created', { baseBranch: pick(rand, ['main', 'dev']), assets: assets() }],
88
+ () => ['spec.revised', { artifact: { kind: 'spec', rev: 1 }, escalation: 1, assets: assets() }],
89
+ () => [
90
+ 'execution.started',
91
+ {
92
+ provider: 'p',
93
+ workspaceRef: ref,
94
+ instance,
95
+ ...(rand() < 0.5 ? { environmentId: 'env' } : {}),
96
+ ...(rand() < 0.5 ? { sessionId: 'sess' } : {}),
97
+ ...(rand() < 0.5 ? { commandId: 'cmd' } : {}),
98
+ },
99
+ ],
100
+ () => [
101
+ 'execution.ended',
102
+ { instance, workspaceRef: ref, outcome: pick(rand, ['completed', 'stopped', 'lost']) },
103
+ ],
104
+ () => [
105
+ 'workspace.provisioned',
106
+ {
107
+ provider: 'p',
108
+ ref,
109
+ ...(rand() < 0.5 ? { path: '/p' } : {}),
110
+ branch: 'ab/x',
111
+ base: { source: 'remote', sha },
112
+ },
113
+ ],
114
+ () => ['workspace.released', {}],
115
+ () => ['workspace.provision-started', { provider: 'p', branch: 'ab/x', generation: attempt }],
116
+ () => ['implement.completed', { round: 1, commits: { base: 'b', head: sha }, artifact: {} }],
117
+ () => ['reconcile.started', { attempt, baseSha: sha }],
118
+ () => ['reconcile.completed', { mergeCommit: sha, artifact: {} }],
119
+ () => [
120
+ 'finalize.step-completed',
121
+ { step: 'n', ok: rand() < 0.7, ...(rand() < 0.7 ? { headSha: sha } : {}) },
122
+ ],
123
+ () => ['finalize.completed', { pr: { number: 7, url: 'u', headSha: sha } }],
124
+ () => ['pr.merged', { sha }],
125
+ () => ['pr.closed', {}],
126
+ () => ['runner.attached', { instance, host: 'h' }],
127
+ () => ['runner.setup-failed', { command: 'c', attempt, exitStatus: 1, output: '' }],
128
+ () => [
129
+ 'escalation.raised',
130
+ {
131
+ id,
132
+ phase: pick(rand, ['setup', 'plan', 'implement', 'verify:unit']),
133
+ source: pick(rand, ['policy', 'agent']),
134
+ ...(rand() < 0.7
135
+ ? { policyCause: pick(rand, ['infrastructure-failure-limit', 'setup-failure-limit']) }
136
+ : {}),
137
+ ...(rand() < 0.5 ? { round: 1 } : {}),
138
+ question: 'q',
139
+ },
140
+ ],
141
+ () => [
142
+ 'escalation.answered',
143
+ { id, answer: 'a', resolution: pick(rand, ['retry', 'guidance', 'abort']) },
144
+ ],
145
+ () => [
146
+ 'phase.failed',
147
+ {
148
+ phase: pick(rand, ['plan', 'implement']),
149
+ round: 1 + Math.floor(rand() * 2),
150
+ attempt,
151
+ error: `err${attempt}`,
152
+ willRetry: rand() < 0.5,
153
+ ...(rand() < 0.5 ? { providerAttempts: [{ index: 1, runner: 'r', error: 'e' }] } : {}),
154
+ },
155
+ ],
156
+ () => ['observation.recorded', { id: 'o', kind: 'followup', summary: 's', refs: [marker] }],
157
+ () => ['observation.recorded', { id: 'o2', kind: 'refactor', summary: 's' }],
158
+ () => ['session.started', { session: 's', role: 'r', runner: 'c', phase: 'plan' }],
159
+ ]
160
+ return pick(rand, templates)()
161
+ }
162
+
163
+ /** A random repository-journal event of the kinds the repo selectors read. */
164
+ export function randomRepoItem(rand: Rand): readonly [string, Record<string, unknown>] {
165
+ const execution = pick(rand, ['x1', 'x2', 'x3'])
166
+ const run = pick(rand, ['r1', 'r2'])
167
+ const templates: Array<() => readonly [string, Record<string, unknown>]> = [
168
+ () => [
169
+ 'harvest.execution.started',
170
+ {
171
+ execution,
172
+ provider: 'p',
173
+ environmentId: 'env',
174
+ ...(rand() < 0.5 ? { sessionId: 'sess' } : {}),
175
+ ...(rand() < 0.5 ? { commandId: 'cmd' } : {}),
176
+ },
177
+ ],
178
+ () => [
179
+ 'harvest.execution.released',
180
+ { execution, environmentId: 'env', snapshots: { outcome: 'confirmed' } },
181
+ ],
182
+ () => ['dispatcher.tick-started', { run }],
183
+ () => ['dispatcher.tick-completed', { run, queued: 0 }],
184
+ () => ['dispatcher.tick-failed', { run, error: 'x' }],
185
+ () => ['dispatcher.tick-yielded', { holder: 'h' }],
186
+ () => ['dispatcher.operator-reported', { run, level: 'info', message: 'm' }],
187
+ () => ['dispatcher.auto-merge-default-set', { enabled: rand() < 0.5 }],
188
+ () => ['dispatcher.intake-set', { enabled: rand() < 0.5 }],
189
+ ]
190
+ return pick(rand, templates)()
191
+ }
192
+
193
+ /** Run the incremental contract over every log, and hold the result to an
194
+ * independent reference implementation of the original whole-array function. */
195
+ export function checkAgainstReference<Acc, Event, State>(
196
+ reducer: IncrementalReducer<Acc, Event, State>,
197
+ logs: readonly (readonly Event[])[],
198
+ reference: (events: readonly Event[]) => State,
199
+ normalize: (state: State) => unknown = (state) => state,
200
+ ): void {
201
+ for (const log of logs) {
202
+ const expected = normalize(reference(log))
203
+ expect(normalize(reducer.reduce(log))).toEqual(expected)
204
+ checkIncremental(reducer, log, {
205
+ compare: (actual, oracle) => {
206
+ expect(normalize(actual)).toEqual(normalize(oracle))
207
+ expect(normalize(actual)).toEqual(expected)
208
+ },
209
+ })
210
+ }
211
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The incremental reducer contract: every projection of an event array is a
3
+ * fold plus a final derivation, so its state can be advanced from a previously
4
+ * computed accumulator using only the events newer than it (CLAUDE.md rule 2 —
5
+ * state is a reduction of the log, and any carried accumulator is a cache).
6
+ *
7
+ * The accumulator is plain JSON (no `Map`, `Set`, or class instances), so it
8
+ * survives `JSON.parse(JSON.stringify(acc))` and can be stored and resumed.
9
+ */
10
+
11
+ export interface IncrementalReducer<Acc, Event, State> {
12
+ /** Bump when `Acc`'s shape or the fold's semantics change: a cached
13
+ * accumulator is only valid for the version that wrote it. */
14
+ readonly version: number
15
+ /** The accumulator for an empty log. */
16
+ initial(): Acc
17
+ /** Fold events newer than `acc` into a copy of it. Never mutates `acc`. */
18
+ advance(acc: Acc, events: readonly Event[]): Acc
19
+ /** Derive the public state. Returns fresh top-level objects. */
20
+ finish(acc: Acc): State
21
+ /** The whole-array form: `finish(advance(initial(), events))` without the
22
+ * clone. */
23
+ reduce(events: readonly Event[]): State
24
+ }
25
+
26
+ export function cloneAcc<T>(acc: T): T {
27
+ return structuredClone(acc)
28
+ }
29
+
30
+ /** Derive `advance` (clone, then fold in place) and `reduce` from an in-place
31
+ * `fold`. */
32
+ export function defineReducer<Acc, Event, State>(def: {
33
+ version: number
34
+ initial: () => Acc
35
+ fold: (acc: Acc, events: readonly Event[]) => void
36
+ finish: (acc: Acc) => State
37
+ }): IncrementalReducer<Acc, Event, State> {
38
+ return {
39
+ version: def.version,
40
+ initial: def.initial,
41
+ advance(acc, events) {
42
+ const next = cloneAcc(acc)
43
+ def.fold(next, events)
44
+ return next
45
+ },
46
+ finish: def.finish,
47
+ reduce(events) {
48
+ const acc = def.initial()
49
+ def.fold(acc, events)
50
+ return def.finish(acc)
51
+ },
52
+ }
53
+ }