@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,259 @@
1
+ /** Seeded generated build logs for the incremental reducer property tests. */
2
+ import { DISPATCHER, KERNEL, humanActor, type Actor } from '../../events/envelope'
3
+ import {
4
+ validateEventWrite,
5
+ allowedActorKinds,
6
+ type AbEvent,
7
+ type EventWrite,
8
+ } from '../../events/catalog'
9
+ import type { EventType } from '../../events/payloads'
10
+ import { steppingClock } from '../../testing/fixed'
11
+ import { pick, seededRandom } from '../incremental-contract'
12
+
13
+ function actorFor(type: EventType): Actor {
14
+ switch (allowedActorKinds[type][0]) {
15
+ case 'kernel':
16
+ return KERNEL
17
+ case 'dispatcher':
18
+ return DISPATCHER
19
+ case 'human':
20
+ return humanActor('aron')
21
+ case 'agent':
22
+ return { kind: 'agent', role: 'test-role', session: 's_test' }
23
+ default:
24
+ return { kind: 'ingester', source: 'test' }
25
+ }
26
+ }
27
+
28
+ const w = (type: EventType, payload: Record<string, unknown>): EventWrite =>
29
+ validateEventWrite({ actor: actorFor(type), type, payload } as EventWrite)
30
+
31
+ const SHA = 'a'.repeat(40)
32
+ const ART = (kind: string, rev: number) => ({ kind, rev })
33
+
34
+ /** Plausible payload templates drawn from the catalog; ids and rounds come
35
+ * from small pools so correlations (escalations, sessions, rounds) happen. */
36
+ function randomWrite(rand: () => number): EventWrite {
37
+ const round = 1 + Math.floor(rand() * 3)
38
+ const attempt = 1 + Math.floor(rand() * 3)
39
+ const id = pick(rand, ['e_1', 'e_2', 'e_3'])
40
+ const session = pick(rand, ['s_a', 's_b'])
41
+ const step = pick(rand, ['types', 'unit'])
42
+ const templates: Array<() => EventWrite> = [
43
+ () => w('runner.attached', { instance: 'runner-1', host: 'local' }),
44
+ () => w('spec.imported', { artifact: ART('spec', 0), ticket: { source: 'linear', id: 'E-1' } }),
45
+ () => w('spec.revised', { artifact: ART('spec', round), escalation: 1 }),
46
+ () => w('plan.started', { round }),
47
+ () => w('plan.completed', { round, artifact: ART('plan', round) }),
48
+ () => w('plan-review.started', { round }),
49
+ () =>
50
+ w('plan-review.verdict', {
51
+ round,
52
+ verdict: pick(rand, ['approve', 'revise'] as const),
53
+ findings: [],
54
+ artifact: ART('plan-review', round),
55
+ }),
56
+ () => w('implement.started', { round }),
57
+ () =>
58
+ w('implement.completed', {
59
+ round,
60
+ commits: { base: 'b', head: `h${round}` },
61
+ artifact: ART('implement-notes', round),
62
+ }),
63
+ () => w('code-review.started', { round }),
64
+ () =>
65
+ w('code-review.verdict', {
66
+ round,
67
+ verdict: pick(rand, ['approve', 'revise'] as const),
68
+ findings: [],
69
+ artifact: ART('code-review', round),
70
+ }),
71
+ () => w('verify.started', { step, attempt }),
72
+ () => w('verify.completed', { step, attempt, pass: rand() < 0.5 }),
73
+ () => w('finalize.started', {}),
74
+ () =>
75
+ w('finalize.completed', {
76
+ pr: { number: 7, url: 'https://github.com/o/r/pull/7', headSha: 'h' },
77
+ }),
78
+ () => w('finalize.step-completed', { step: 'notes', ok: rand() < 0.5 }),
79
+ () => w('pr.conflicted', { baseSha: 'm' }),
80
+ () => w('reconcile.started', { attempt, baseSha: 'm' }),
81
+ () =>
82
+ w('reconcile.completed', { mergeCommit: 'mc', artifact: ART('reconcile-notes', attempt) }),
83
+ () => w('pr.merged', { sha: 's' }),
84
+ () => w('build.completed', { outcome: 'merged' }),
85
+ () => w('build.pause-requested', {}),
86
+ () => w('build.resume-requested', {}),
87
+ () => w('build.paused', {}),
88
+ () => w('build.resumed', {}),
89
+ () => w('build.abort-requested', {}),
90
+ () => w('build.aborted', {}),
91
+ () => w('build.auto-merge-requested', {}),
92
+ () => w('build.auto-merge-cancelled', {}),
93
+ () => w('pr.auto-merge-enabled', { commandSeq: 1 }),
94
+ () =>
95
+ w('escalation.raised', {
96
+ id,
97
+ phase: pick(rand, ['plan', 'implement', 'plan-review'] as const),
98
+ source: 'agent',
99
+ question: 'q',
100
+ }),
101
+ () =>
102
+ w('escalation.answered', {
103
+ id,
104
+ answer: 'a',
105
+ resolution: pick(rand, ['guidance', 'abort', 'retry'] as const),
106
+ }),
107
+ () =>
108
+ w('session.started', {
109
+ session,
110
+ role: 'plan',
111
+ runner: 'claude',
112
+ phase: 'plan',
113
+ round,
114
+ }),
115
+ () =>
116
+ w('session.ended', {
117
+ session,
118
+ transcript: ART('transcript', 0),
119
+ usage: { inputTokens: 1, outputTokens: 1, turns: 1 },
120
+ }),
121
+ () =>
122
+ w('observation.recorded', { id: `o_${round}`, kind: 'refactor', summary: 's', files: [] }),
123
+ () => w('phase.failed', { phase: 'plan', round, attempt, error: 'x', willRetry: true }),
124
+ () => w('dispatch.failed', { stage: 'workspace', attempt, error: 'offline' }),
125
+ () => w('execution.started', { provider: 'p', workspaceRef: 'w', instance: 'i' }),
126
+ () => w('execution.ended', { instance: 'i', workspaceRef: 'w', outcome: 'completed' }),
127
+ () =>
128
+ w('infrastructure.failed', {
129
+ provider: 'p',
130
+ workspaceRef: 'w',
131
+ instance: 'i',
132
+ operation: 'wait',
133
+ cause: 'timeout',
134
+ attempt,
135
+ retryable: true,
136
+ cleanupPending: false,
137
+ error: 'x',
138
+ }),
139
+ () =>
140
+ w('infrastructure.cleanup-attempted', {
141
+ provider: 'p',
142
+ workspaceRef: 'w',
143
+ operation: 'stop',
144
+ attempt,
145
+ outcome: 'confirmed',
146
+ }),
147
+ () => w('runner.setup-failed', { command: 'bun install', attempt, exitStatus: 1, output: 'x' }),
148
+ () => w('build.discard-requested', {}),
149
+ () =>
150
+ w('escalation.raised', {
151
+ id,
152
+ phase: 'plan',
153
+ source: 'policy',
154
+ policyCause: 'infrastructure-failure-limit',
155
+ question: 'q',
156
+ }),
157
+ () =>
158
+ w('workspace.provision-started', { provider: 'worktree', branch: 'ab/x', generation: round }),
159
+ () =>
160
+ w('workspace.provisioned', {
161
+ provider: 'worktree',
162
+ ref: `/ws/${round}`,
163
+ branch: 'ab/x',
164
+ base: { source: 'remote', sha: `base${round}` },
165
+ ...(rand() < 0.5 ? { remote: true } : {}),
166
+ }),
167
+ () => w('workspace.released', rand() < 0.5 ? {} : { ref: '/ws/1', reason: 'completion' }),
168
+ () =>
169
+ w('publication.requested', {
170
+ operation: 'implement',
171
+ branch: 'ab/x',
172
+ sha: SHA,
173
+ round,
174
+ base: SHA,
175
+ artifact: ART('implement-notes', round),
176
+ }),
177
+ () =>
178
+ w('publication.requested', {
179
+ operation: 'reconcile',
180
+ branch: 'ab/x',
181
+ sha: SHA,
182
+ artifact: ART('reconcile-notes', round),
183
+ }),
184
+ () =>
185
+ w('publication.lost', {
186
+ request: round,
187
+ operation: 'implement',
188
+ branch: 'ab/x',
189
+ sha: SHA,
190
+ reason: 'released',
191
+ }),
192
+ () =>
193
+ w('pr-attachment.designated', {
194
+ artifact: ART('screenshot', round),
195
+ filename: `shot${round}.png`,
196
+ mediaType: 'image/png',
197
+ }),
198
+ () =>
199
+ w('pr-attachment.hosted', {
200
+ designationSeq: 1 + Math.floor(rand() * 20),
201
+ asset: {
202
+ provider: 'github-release',
203
+ repository: 'o/r',
204
+ releaseId: round,
205
+ assetId: attempt,
206
+ url: 'https://example.com/a.png',
207
+ },
208
+ }),
209
+ () => w('pr-attachment.reclaimed', { hostedSeq: 1 + Math.floor(rand() * 20) }),
210
+ () => w('build.auto-merge-default-observed', { defaultSeq: round }),
211
+ () =>
212
+ w('observation.recorded', {
213
+ id: `o_d${round}`,
214
+ kind: 'followup',
215
+ summary: 'deferred',
216
+ files: [],
217
+ refs: [`auto-merge-gate:pr:7:${round}`],
218
+ }),
219
+ () =>
220
+ w('spec.revised', {
221
+ artifact: ART('spec', round),
222
+ escalation: 1,
223
+ assets: [
224
+ {
225
+ kind: 'design',
226
+ name: 'a',
227
+ revision: round,
228
+ layout: 'file',
229
+ size: 1,
230
+ fileCount: 1,
231
+ dirCount: 0,
232
+ },
233
+ ],
234
+ }),
235
+ ]
236
+ return pick(rand, templates)()
237
+ }
238
+
239
+ export function randomBuildLog(seed: number, length: number): AbEvent[] {
240
+ const rand = seededRandom(seed)
241
+ const clock = steppingClock()
242
+ const created = w('build.created', {
243
+ ticket: { source: 'linear', id: 'E-1', title: 't' },
244
+ repo: 'o/r',
245
+ baseBranch: 'main',
246
+ })
247
+ const writes = [created, ...Array.from({ length }, () => randomWrite(rand))]
248
+ return writes.map(
249
+ (write, index) =>
250
+ ({
251
+ build: 'gen',
252
+ seq: index + 1,
253
+ ts: clock().toISOString(),
254
+ actor: write.actor,
255
+ type: write.type,
256
+ payload: write.payload,
257
+ }) as AbEvent,
258
+ )
259
+ }
@@ -0,0 +1,206 @@
1
+ /** Seeded generated repository journals for the incremental reducer property tests. */
2
+ import { DISPATCHER, KERNEL, humanActor } from '../../events/envelope'
3
+ import type { RepositoryEvent } from '../../events/repository'
4
+ import { pick, seededRandom } from '../incremental-contract'
5
+
6
+ type Spec = { type: string; payload: Record<string, unknown>; actor?: RepositoryEvent['actor'] }
7
+
8
+ function toJournal(specs: Spec[], shuffleTs = false): RepositoryEvent[] {
9
+ return specs.map(
10
+ (spec, index) =>
11
+ ({
12
+ repo: 'acme/repo',
13
+ seq: index + 1,
14
+ ts: new Date(
15
+ Date.UTC(2026, 0, 1, 0, 0, shuffleTs ? (index * 7) % 13 : index),
16
+ ).toISOString(),
17
+ actor: spec.actor ?? KERNEL,
18
+ type: spec.type,
19
+ payload: spec.payload,
20
+ }) as RepositoryEvent,
21
+ )
22
+ }
23
+
24
+ /** Valid harvest lifecycles only (the reducer throws on dangling references):
25
+ * runs start one at a time with fresh observations; pause/resume facts and
26
+ * failures interleave while a run is open. */
27
+ export function randomHarvestJournal(seed: number, length: number): RepositoryEvent[] {
28
+ const rand = seededRandom(seed)
29
+ const specs: Spec[] = []
30
+ let runNo = 0
31
+ let open: { run: string; obs: Array<{ build: string; seq: number }> } | undefined
32
+ let parked = false
33
+ for (let i = 0; i < length; i++) {
34
+ const r = rand()
35
+ if (open === undefined) {
36
+ runNo += 1
37
+ const obs = [{ build: `b${runNo}`, seq: 1 }]
38
+ open = { run: `h_${runNo}`, obs }
39
+ parked = false
40
+ specs.push({
41
+ type: 'harvest.started',
42
+ payload: { run: open.run, observations: obs, scan: { kind: 'harvest-scan', rev: runNo } },
43
+ })
44
+ continue
45
+ }
46
+ if (r < 0.15) {
47
+ specs.push({ type: 'harvest.pause-requested', payload: {}, actor: humanActor('aron') })
48
+ } else if (r < 0.25) {
49
+ specs.push({ type: 'harvest.resume-requested', payload: {}, actor: humanActor('aron') })
50
+ } else if (r < 0.35) {
51
+ specs.push({ type: 'harvest.paused', payload: {} })
52
+ } else if (r < 0.5) {
53
+ specs.push({ type: 'harvest.resumed', payload: {} })
54
+ parked = false
55
+ } else if (r < 0.7 && !parked) {
56
+ const willRetry = rand() < 0.5
57
+ specs.push({
58
+ type: 'harvest.failed',
59
+ payload: { run: open.run, step: 'file', attempt: 1, error: 'boom', willRetry },
60
+ })
61
+ if (!willRetry) parked = true
62
+ } else if (r < 0.8 && !parked) {
63
+ specs.push({
64
+ type: 'harvest.completed',
65
+ payload: {
66
+ run: open.run,
67
+ dispositions: [
68
+ { occurrence: open.obs[0], action: 'suppressed', proposalKey: `k${runNo}` },
69
+ ],
70
+ report: { kind: 'harvest-report', rev: runNo },
71
+ },
72
+ })
73
+ open = undefined
74
+ } else if (r < 0.9 && !parked) {
75
+ specs.push({
76
+ type: 'harvest.escalated',
77
+ payload: { run: open.run, source: 'agent', reason: 'judgment', observations: open.obs },
78
+ })
79
+ open = undefined
80
+ } else if (r < 0.95) {
81
+ const execution = `x${i % 3}`
82
+ specs.push(
83
+ rand() < 0.6
84
+ ? {
85
+ type: 'harvest.execution.started',
86
+ payload: { execution, provider: 'vercel', environmentId: 'env', sessionId: 's' },
87
+ actor: DISPATCHER,
88
+ }
89
+ : {
90
+ type: 'harvest.execution.released',
91
+ payload: { execution, environmentId: 'env', snapshots: { outcome: 'confirmed' } },
92
+ actor: DISPATCHER,
93
+ },
94
+ )
95
+ } else {
96
+ specs.push({
97
+ type: 'dispatcher.intake-set',
98
+ payload: { enabled: rand() < 0.5 },
99
+ actor: DISPATCHER,
100
+ })
101
+ }
102
+ }
103
+ return toJournal(specs)
104
+ }
105
+
106
+ export function randomSettingsJournal(seed: number, length: number): RepositoryEvent[] {
107
+ const rand = seededRandom(seed)
108
+ const specs: Spec[] = Array.from({ length }, () => ({
109
+ type: pick(rand, [
110
+ 'dispatcher.intake-set',
111
+ 'dispatcher.pause-set',
112
+ 'dispatcher.auto-merge-default-set',
113
+ 'harvest.paused',
114
+ ]),
115
+ payload: { enabled: rand() < 0.5 },
116
+ actor: humanActor('aron'),
117
+ }))
118
+ return toJournal(specs)
119
+ }
120
+
121
+ const counters = {
122
+ claimed: 0,
123
+ launched: 0,
124
+ merged: 0,
125
+ closed: 0,
126
+ conflicted: 0,
127
+ reconciled: 0,
128
+ harvestStarted: 0,
129
+ harvestCompleted: 0,
130
+ harvestEscalated: 0,
131
+ harvestFailed: 0,
132
+ }
133
+
134
+ export function randomStatusJournal(seed: number, length: number): RepositoryEvent[] {
135
+ const rand = seededRandom(seed)
136
+ const specs: Spec[] = []
137
+ for (let i = 0; i < length; i++) {
138
+ const run = pick(rand, ['run-a', 'run-b'])
139
+ const templates: Array<() => Spec> = [
140
+ () => ({
141
+ type: 'dispatcher.run-started',
142
+ payload: {
143
+ run,
144
+ pid: 100,
145
+ effectiveConfig: { kind: 'dispatcher-effective-config', rev: i },
146
+ roleWarnings: ['w'],
147
+ },
148
+ }),
149
+ () => ({
150
+ type: 'dispatcher.tick-completed',
151
+ payload: {
152
+ run,
153
+ queued: i,
154
+ counters: { ...counters, creationWithheld: 1 },
155
+ janitorDiagnostics: ['j'],
156
+ ticketDiagnostics: [],
157
+ dependencyDiagnostics: [],
158
+ },
159
+ }),
160
+ () => ({ type: 'dispatcher.tick-failed', payload: { run, error: 'e' } }),
161
+ () => ({ type: 'dispatcher.tick-started', payload: { run } }),
162
+ () => ({ type: 'dispatcher.config-rejected', payload: { run, error: 'bad' } }),
163
+ () => ({
164
+ type: 'dispatcher.operator-reported',
165
+ payload: { run, level: pick(rand, ['info', 'warning']), message: 'm' },
166
+ }),
167
+ () => ({ type: 'dispatcher.upgrade-available', payload: { run, version: '1.0.0' } }),
168
+ () => ({
169
+ type: 'dispatcher.run-stopped',
170
+ payload: { run, outcome: pick(rand, ['normal', 'abnormal']) },
171
+ }),
172
+ ]
173
+ specs.push({ ...pick(rand, templates)(), actor: DISPATCHER })
174
+ }
175
+ return toJournal(specs)
176
+ }
177
+
178
+ export function randomSandboxJournal(seed: number, length: number): RepositoryEvent[] {
179
+ const rand = seededRandom(seed)
180
+ const specs: Spec[] = []
181
+ for (let i = 0; i < length; i++) {
182
+ const base = { operator: 'op', environmentId: pick(rand, ['env_1', 'env_2']) }
183
+ const templates: Array<() => Spec> = [
184
+ () => ({
185
+ type: 'orchestrator.sandbox.provisioned',
186
+ payload: {
187
+ ...base,
188
+ provider: 'vercel',
189
+ workspacePath: '/w',
190
+ ...(rand() < 0.5 ? { sessionId: 's' } : {}),
191
+ },
192
+ }),
193
+ () => ({ type: 'orchestrator.sandbox.resumed', payload: { ...base, provider: 'vercel' } }),
194
+ () => ({ type: 'orchestrator.sandbox.activity', payload: base }),
195
+ () => ({ type: 'orchestrator.sandbox.stopped', payload: { ...base, reason: 'idle' } }),
196
+ () => ({ type: 'orchestrator.sandbox.reset', payload: base }),
197
+ () => ({
198
+ type: 'orchestrator.sandbox.released',
199
+ payload: { ...base, snapshots: { outcome: 'confirmed' } },
200
+ }),
201
+ ]
202
+ specs.push({ ...pick(rand, templates)(), actor: DISPATCHER })
203
+ }
204
+ // Non-monotonic timestamps exercise the `lastEvidenceTs <= ts` guards.
205
+ return toJournal(specs, true)
206
+ }
@@ -0,0 +1,72 @@
1
+ /** Seeded generated session logs, with `session.archived` optionally mid-log. */
2
+ import { agentActor, humanActor } from '../../events/envelope'
3
+ import type { SessionEvent } from '../../events/sessions'
4
+ import { pick, seededRandom } from '../incremental-contract'
5
+
6
+ type Spec = { type: SessionEvent['type']; payload: Record<string, unknown>; human?: boolean }
7
+
8
+ export function randomSessionLog(seed: number, length: number, archive: boolean): SessionEvent[] {
9
+ const rand = seededRandom(seed)
10
+ const specs: Spec[] = [{ type: 'session.created', payload: {}, human: true }]
11
+ for (let i = 0; i < length; i++) {
12
+ const turn = pick(rand, ['t1', 't2', 't3'])
13
+ const toolCallId = pick(rand, ['c1', 'c2'])
14
+ const templates: Spec[] = [
15
+ { type: 'message.posted', payload: { text: 'hi' }, human: true },
16
+ {
17
+ type: 'session.wake-set',
18
+ payload: { globs: [pick(rand, ['a*', 'b*', 'c*'])] },
19
+ human: true,
20
+ },
21
+ {
22
+ type: 'turn.started',
23
+ payload: {
24
+ turn,
25
+ stream: `st_${turn}`,
26
+ trigger: pick(rand, [
27
+ { kind: 'message', messageSeq: 1 },
28
+ {
29
+ kind: 'wake',
30
+ build: pick(rand, ['b1', 'b2']),
31
+ seq: 1 + Math.floor(rand() * 9),
32
+ type: 'x',
33
+ },
34
+ { kind: 'wake', journal: true, seq: 1 + Math.floor(rand() * 9), type: 'x' },
35
+ ]),
36
+ },
37
+ },
38
+ { type: 'turn.suspended', payload: { turn, cause: pick(rand, ['budget', 'approval']) } },
39
+ { type: 'turn.resumed', payload: { turn } },
40
+ {
41
+ type: 'approval.requested',
42
+ payload: { turn, toolCallId, toolName: 'tool', input: { a: 1 } },
43
+ },
44
+ {
45
+ type: 'approval.answered',
46
+ payload: { turn, toolCallId, decision: 'approve' },
47
+ human: true,
48
+ },
49
+ {
50
+ type: 'turn.completed',
51
+ payload: { turn, usage: { inputTokens: 1, outputTokens: 2, steps: 3 } },
52
+ },
53
+ { type: 'turn.failed', payload: { turn, kind: 'internal', error: 'boom' } },
54
+ ]
55
+ specs.push(pick(rand, templates))
56
+ }
57
+ if (archive) {
58
+ const at = 1 + Math.floor(rand() * specs.length)
59
+ specs.splice(at, 0, { type: 'session.archived', payload: {}, human: true })
60
+ }
61
+ return specs.map(
62
+ (spec, index) =>
63
+ ({
64
+ session: 's_1',
65
+ seq: index + 1,
66
+ ts: new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString(),
67
+ actor: spec.human ? humanActor('aron') : agentActor('orchestrator', 's_a'),
68
+ type: spec.type,
69
+ payload: spec.payload,
70
+ }) as unknown as SessionEvent,
71
+ )
72
+ }
@@ -15,6 +15,7 @@ import {
15
15
  type HarvestTrigger,
16
16
  type OccurrenceKey,
17
17
  } from '../harvest/schema'
18
+ import { defineReducer, type IncrementalReducer } from './incremental'
18
19
 
19
20
  export interface HarvestStepOccurrence {
20
21
  step: HarvestStep
@@ -184,21 +185,35 @@ function requireRun(
184
185
  return state
185
186
  }
186
187
 
187
- /** Total for valid journals; malformed cross-event references throw loudly so
188
- * storage corruption cannot masquerade as an idle harvester. */
189
- export function reduceHarvest(events: readonly RepositoryEvent[]): HarvestState {
190
- const runs = new Map<string, HarvestRunState>()
191
- const order: HarvestRunState[] = []
192
- const claimed = new Map<string, { occurrence: OccurrenceKey; run: string }>()
193
- const ledger: HarvestLedgerEntry[] = []
194
- const pending: Record<HarvestPendingCommand['command'], HarvestPendingCommand[]> = {
195
- pause: [],
196
- resume: [],
197
- }
198
- let paused = false
199
- let pausedSeq: number | undefined
200
- let pausedAt: string | undefined
201
- let lastSeq = 0
188
+ /** The carried accumulator for `reduceHarvest`: `runs` in start order, and
189
+ * `claimed` as `Map`-insertion-ordered entries keyed by occurrence id. The
190
+ * public `latest` aliases the last run and is rebuilt by `finish`. */
191
+ export interface HarvestAcc {
192
+ runs: HarvestRunState[]
193
+ claimed: Array<{ key: string; occurrence: OccurrenceKey; run: string }>
194
+ ledger: HarvestLedgerEntry[]
195
+ pending: Record<HarvestPendingCommand['command'], HarvestPendingCommand[]>
196
+ paused: boolean
197
+ pausedSeq?: number
198
+ pausedAt?: string
199
+ lastSeq: number
200
+ }
201
+
202
+ /** Bump when `HarvestAcc`'s shape or fold semantics change. */
203
+ export const HARVEST_REDUCER_VERSION = 1
204
+
205
+ function foldHarvest(acc: HarvestAcc, events: readonly RepositoryEvent[]): void {
206
+ const order = acc.runs
207
+ const runs = new Map(order.map((run) => [run.run, run]))
208
+ const claimed = new Map(
209
+ acc.claimed.map((entry) => [entry.key, { occurrence: entry.occurrence, run: entry.run }]),
210
+ )
211
+ const ledger = acc.ledger
212
+ const pending = acc.pending
213
+ let paused = acc.paused
214
+ let pausedSeq = acc.pausedSeq
215
+ let pausedAt = acc.pausedAt
216
+ let lastSeq = acc.lastSeq
202
217
 
203
218
  for (const event of events) {
204
219
  lastSeq = Math.max(lastSeq, event.seq)
@@ -638,9 +653,17 @@ export function reduceHarvest(events: readonly RepositoryEvent[]): HarvestState
638
653
  break
639
654
  }
640
655
  }
656
+ acc.claimed = [...claimed].map(([key, { occurrence, run }]) => ({ key, occurrence, run }))
657
+ acc.paused = paused
658
+ acc.pausedSeq = pausedSeq
659
+ acc.pausedAt = pausedAt
660
+ acc.lastSeq = lastSeq
661
+ }
641
662
 
663
+ function finishHarvest(acc: HarvestAcc): HarvestState {
664
+ const { runs: order, paused, pausedSeq, pausedAt, pending } = acc
642
665
  const state: HarvestState = {
643
- lastSeq,
666
+ lastSeq: acc.lastSeq,
644
667
  runs: order,
645
668
  paused,
646
669
  ...(pausedSeq !== undefined ? { pausedSeq } : {}),
@@ -648,14 +671,35 @@ export function reduceHarvest(events: readonly RepositoryEvent[]): HarvestState
648
671
  pendingCommands: [...pending.pause, ...pending.resume].sort(
649
672
  (left, right) => left.seq - right.seq,
650
673
  ),
651
- claimed: [...claimed.values()].map(({ occurrence }) => occurrence),
652
- ledger,
674
+ claimed: acc.claimed.map(({ occurrence }) => occurrence),
675
+ ledger: acc.ledger,
653
676
  }
654
677
  const latest = order.at(-1)
655
678
  if (latest !== undefined) state.latest = latest
656
679
  return state
657
680
  }
658
681
 
682
+ export const harvestReducer: IncrementalReducer<HarvestAcc, RepositoryEvent, HarvestState> =
683
+ defineReducer({
684
+ version: HARVEST_REDUCER_VERSION,
685
+ initial: (): HarvestAcc => ({
686
+ runs: [],
687
+ claimed: [],
688
+ ledger: [],
689
+ pending: { pause: [], resume: [] },
690
+ paused: false,
691
+ lastSeq: 0,
692
+ }),
693
+ fold: foldHarvest,
694
+ finish: finishHarvest,
695
+ })
696
+
697
+ /** Total for valid journals; malformed cross-event references throw loudly so
698
+ * storage corruption cannot masquerade as an idle harvester. */
699
+ export function reduceHarvest(events: readonly RepositoryEvent[]): HarvestState {
700
+ return harvestReducer.reduce(events)
701
+ }
702
+
659
703
  export function claimedOccurrenceKeys(state: HarvestState): Set<string> {
660
704
  return new Set(state.claimed.map(occurrenceKey))
661
705
  }