@defrex/autobuild 0.13.0 → 0.14.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 (94) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +19 -1
  3. package/bin/ab-dispatch-kernel.ts +4 -6
  4. package/docs/architecture.md +96 -70
  5. package/docs/hosted-service.md +2 -2
  6. package/docs/operator-api.md +1 -1
  7. package/docs/remote-store-protocol.md +84 -2
  8. package/package.json +4 -3
  9. package/packages/core/src/cli/attention.ts +18 -164
  10. package/packages/core/src/cli/build-control.ts +11 -15
  11. package/packages/core/src/cli/bulk-control.ts +3 -2
  12. package/packages/core/src/cli/context.ts +16 -78
  13. package/packages/core/src/cli/dashboard/model.ts +36 -22
  14. package/packages/core/src/cli/dispatch-frontend.ts +24 -67
  15. package/packages/core/src/cli/dispatch-process.ts +14 -7
  16. package/packages/core/src/cli/dispatch-repository-feed.ts +196 -0
  17. package/packages/core/src/cli/dispatch.ts +148 -79
  18. package/packages/core/src/cli/dispatcher-liveness.ts +26 -19
  19. package/packages/core/src/cli/harvest.ts +71 -39
  20. package/packages/core/src/cli/historical-build-state.ts +27 -0
  21. package/packages/core/src/cli/main.ts +3 -3
  22. package/packages/core/src/cli/pr-attachments.ts +10 -13
  23. package/packages/core/src/cli/pr-summary-publication.ts +27 -60
  24. package/packages/core/src/cli/pr-summary.ts +43 -13
  25. package/packages/core/src/cli/publication-settlement.ts +10 -20
  26. package/packages/core/src/cli/repository-status.ts +38 -2
  27. package/packages/core/src/cli/status.ts +62 -32
  28. package/packages/core/src/cli/terminals.ts +25 -142
  29. package/packages/core/src/cli/ticket.ts +2 -2
  30. package/packages/core/src/cli/wait.ts +91 -84
  31. package/packages/core/src/cli/watch.ts +60 -97
  32. package/packages/core/src/config/pipeline-source.ts +4 -3
  33. package/packages/core/src/events/repository.ts +7 -3
  34. package/packages/core/src/integration/harness.ts +1 -2
  35. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +2429 -9
  36. package/packages/core/src/kernel/auto-merge.ts +19 -3
  37. package/packages/core/src/kernel/dispatcher-liveness.ts +10 -4
  38. package/packages/core/src/kernel/generators/repository-journals.ts +14 -3
  39. package/packages/core/src/kernel/harvest.ts +25 -23
  40. package/packages/core/src/kernel/log-index-value.ts +38 -0
  41. package/packages/core/src/kernel/read-facts.ts +637 -0
  42. package/packages/core/src/kernel/reducer-registry.ts +137 -3
  43. package/packages/core/src/kernel/repository-attention.ts +179 -0
  44. package/packages/core/src/kernel/service-facts.ts +135 -0
  45. package/packages/core/src/kernel/terminal-guard.ts +133 -0
  46. package/packages/core/src/operator/control.ts +5 -6
  47. package/packages/core/src/operator/index.ts +2 -0
  48. package/packages/core/src/operator/query.ts +37 -20
  49. package/packages/core/src/operator/read-view.ts +4 -13
  50. package/packages/core/src/operator/requests.ts +2 -14
  51. package/packages/core/src/operator/sandbox.ts +10 -9
  52. package/packages/core/src/operator/tickets.ts +2 -2
  53. package/packages/core/src/plugin-sdk/index.ts +13 -0
  54. package/packages/core/src/processes/build-child.ts +6 -6
  55. package/packages/core/src/processes/build-runner.ts +62 -70
  56. package/packages/core/src/processes/dispatch-failure-budget.ts +5 -4
  57. package/packages/core/src/processes/dispatcher.ts +113 -173
  58. package/packages/core/src/processes/harvest-execution-state.ts +15 -9
  59. package/packages/core/src/processes/harvest-runner.ts +34 -70
  60. package/packages/core/src/processes/harvest.ts +38 -15
  61. package/packages/core/src/processes/infrastructure-failure-budget.ts +13 -15
  62. package/packages/core/src/processes/orchestrator-tick.ts +2 -2
  63. package/packages/core/src/processes/publication-loss.ts +35 -21
  64. package/packages/core/src/processes/repo-view.ts +229 -1134
  65. package/packages/core/src/processes/tick-publication.ts +110 -0
  66. package/packages/core/src/store/adapter.ts +23 -0
  67. package/packages/core/src/store/build-scope.ts +17 -0
  68. package/packages/core/src/store/contract.ts +251 -0
  69. package/packages/core/src/store/local/schema.ts +1 -0
  70. package/packages/core/src/store/local/store.ts +179 -54
  71. package/packages/core/src/store/memory.ts +119 -29
  72. package/packages/core/src/store/phase-session.ts +17 -0
  73. package/packages/core/src/store/reduced-reads.ts +239 -0
  74. package/packages/core/src/store/reducer-value-shapes.json +14056 -0
  75. package/packages/core/src/store/reducer-value-validation.ts +96 -0
  76. package/packages/core/src/store/reducer-values.ts +145 -0
  77. package/packages/core/src/store/remote/client.ts +41 -0
  78. package/packages/core/src/store/remote/compatibility.ts +2 -0
  79. package/packages/core/src/store/remote/index.ts +19 -0
  80. package/packages/core/src/store/remote/protocol.ts +16 -0
  81. package/packages/core/src/store/remote/version.ts +3 -1
  82. package/packages/core/src/store/repo-state-events.ts +1 -1
  83. package/packages/core/src/store/repo-tick.ts +72 -0
  84. package/packages/core/src/store/session-handle.ts +19 -0
  85. package/packages/core/src/store/snapshots.ts +10 -0
  86. package/packages/core/src/store/ticket-assets.ts +4 -4
  87. package/packages/core/src/store/types.ts +39 -2
  88. package/packages/core/src/testing/dispatch-read-budget.ts +215 -0
  89. package/packages/core/src/testing/index.ts +8 -0
  90. package/packages/core/src/testing/reduced-read-budget.ts +462 -0
  91. package/skills/guide/SKILL.md +6 -1
  92. package/skills/guide/references/operator-api.md +1 -1
  93. package/skills/guide/references/remote-store-protocol.md +84 -2
  94. package/templates/hosted-service/package.json +2 -2
@@ -1,3 +1,4 @@
1
+ import type { RepositoryReadFacts } from '../store/reduced-reads'
1
2
  /**
2
3
  * Durable harvest execution state: the repository-journal projection that
3
4
  * hosted-harvest supervision (AUT-305) reads instead of process memory. The
@@ -88,22 +89,27 @@ export interface HarvestExecutionClassificationInput {
88
89
  * `held`; anything else is `idle`. The result feeds the same counters a
89
90
  * locally run harvest reports. */
90
91
  export function classifyHarvestOutcome(
91
- events: readonly RepositoryEvent[],
92
+ events: readonly RepositoryEvent[] | RepositoryReadFacts,
92
93
  input: HarvestExecutionClassificationInput,
93
94
  ): HarvestRunnerResult {
94
- const state = reduceHarvest(events)
95
- const started = events.find(
95
+ const facts = !Array.isArray(events) ? (events as RepositoryReadFacts) : undefined
96
+ const raw = Array.isArray(events) ? events : []
97
+ const state = facts?.harvest ?? reduceHarvest(raw)
98
+ const started = raw.find(
96
99
  (event) =>
97
100
  event.type === 'harvest.execution.started' && event.seq === input.executionStartedSeq,
98
101
  )
102
+ const execution =
103
+ facts?.harvestExecutionStarts[String(input.executionStartedSeq)] ??
104
+ (started?.type === 'harvest.execution.started' ? started.payload.execution : undefined)
99
105
  const authorization =
100
- started?.type === 'harvest.execution.started'
101
- ? events.find(
106
+ execution === undefined
107
+ ? undefined
108
+ : (facts?.harvestAuthorizations[JSON.stringify(execution)] ??
109
+ raw.find(
102
110
  (event) =>
103
- event.type === 'harvest.execution.authorized' &&
104
- event.payload.execution === started.payload.execution,
105
- )
106
- : undefined
111
+ event.type === 'harvest.execution.authorized' && event.payload.execution === execution,
112
+ ))
107
113
  const selected =
108
114
  authorization?.type === 'harvest.execution.authorized' ? authorization.payload : undefined
109
115
  const latestRun = selected
@@ -1,3 +1,4 @@
1
+ import { readRepositoryFacts } from '../store/reduced-reads'
1
2
  import { ticketIdentity } from '../ports/tickets/identity'
2
3
  import { issueTicketGrant } from '../ports/tickets/grants'
3
4
  import { delegateCredential } from '../store/remote/credentials'
@@ -22,7 +23,6 @@ import {
22
23
  decideHarvestControl,
23
24
  openHarvestRun,
24
25
  proposalArtifactForRound,
25
- reduceHarvest,
26
26
  type HarvestRunState,
27
27
  } from '../kernel/harvest'
28
28
  import type { ArtifactRef, Feedback, Verdict } from '../ontology'
@@ -239,28 +239,23 @@ export class HarvestRunner {
239
239
  // Bounded read (AUT-489): reduceHarvest and the verdict/failure filters
240
240
  // below consume durable types only, so the subset is replay-equivalent.
241
241
  if (this.deps.preparedRun) {
242
- const events = await store.getRepoStateEvents(repo)
243
- const authorization = events.find(
244
- (event) =>
245
- event.type === 'harvest.execution.authorized' &&
246
- event.payload.execution === instance &&
247
- event.payload.run === this.deps.preparedRun!.run,
248
- )
242
+ const events = (await readRepositoryFacts(store, repo)).value
243
+ const authorization = events.harvestAuthorizations[JSON.stringify(instance)]
249
244
  if (authorization?.type !== 'harvest.execution.authorized')
250
245
  throw new Error('prepared harvest execution unavailable')
251
- run = reduceHarvest(events).runs.find((run) => run.run === this.deps.preparedRun!.run)
246
+ run = events.harvest.runs.find((run) => run.run === this.deps.preparedRun!.run)
252
247
  initial = authorization.payload.launch
253
248
  } else {
254
- let events = await store.getRepoStateEvents(repo)
255
- let state = reduceHarvest(events)
249
+ let events = (await readRepositoryFacts(store, repo)).value
250
+ let state = events.harvest
256
251
  run = actionableHarvestRun(state)
257
252
 
258
253
  // Settle durable operator control before scanning or resuming work. The
259
254
  // selected run comes from the full journal, so a shadowed failure or
260
255
  // exhaustion barrier is recovered/reported before any later open run.
261
256
  await this.controlBoundary(run?.run)
262
- events = await store.getRepoStateEvents(repo)
263
- state = reduceHarvest(events)
257
+ events = (await readRepositoryFacts(store, repo)).value
258
+ state = events.harvest
264
259
  run = openHarvestRun(state)
265
260
 
266
261
  if (!run) {
@@ -274,7 +269,7 @@ export class HarvestRunner {
274
269
  // Bounded read (AUT-489): the gate's journal input is consumed only
275
270
  // through reduceHarvest, so the subset is replay-equivalent here too.
276
271
  const [gateEvents, digests] = await Promise.all([
277
- store.getRepoStateEvents(repo),
272
+ readRepositoryFacts(store, repo).then((result) => result.value.harvest),
278
273
  store.getRepoBuildDigests(repo),
279
274
  ])
280
275
  const pressure = evaluateHarvestPressureFromDigests({
@@ -338,8 +333,8 @@ export class HarvestRunner {
338
333
  }),
339
334
  )
340
335
  // Bounded read (AUT-489): reduceHarvest consumes durable types only.
341
- events = await store.getRepoStateEvents(repo)
342
- run = reduceHarvest(events).runs.find((candidate) => candidate.run === runId)
336
+ events = (await readRepositoryFacts(store, repo)).value
337
+ run = events.harvest.runs.find((candidate) => candidate.run === runId)
343
338
  if (!run) throw new Error(`harvest.started did not reduce run "${runId}"`)
344
339
  // A request racing the claim parks this exact run; its snapshot stays
345
340
  // open and is resumed rather than scanned again.
@@ -503,14 +498,8 @@ export class HarvestRunner {
503
498
  )
504
499
  // Bounded read (AUT-489): only harvest.review.verdict facts are
505
500
  // filtered here, and those are durable.
506
- const event = (await this.deps.store.getRepoStateEvents(this.deps.repo)).find(
507
- (
508
- candidate,
509
- ): candidate is Extract<RepositoryEvent, { type: 'harvest.review.verdict' }> =>
510
- candidate.type === 'harvest.review.verdict' &&
511
- candidate.payload.run === run.run &&
512
- candidate.payload.round === round,
513
- )
501
+ const event = (await readRepositoryFacts(this.deps.store, this.deps.repo)).value
502
+ .harvestVerdicts[JSON.stringify([run.run, round])]?.first
514
503
  if (!event) throw new Error('review reducer/event mismatch')
515
504
  await this.controlBoundary(run.run)
516
505
  return verdictFromEvent(event)
@@ -572,15 +561,8 @@ export class HarvestRunner {
572
561
  })
573
562
  // Bounded read (AUT-489): only harvest.review.verdict facts are filtered
574
563
  // here, and those are durable.
575
- const events = await this.deps.store.getRepoStateEvents(this.deps.repo)
576
- const verdict = [...events]
577
- .reverse()
578
- .find(
579
- (event): event is Extract<RepositoryEvent, { type: 'harvest.review.verdict' }> =>
580
- event.type === 'harvest.review.verdict' &&
581
- event.payload.run === run.run &&
582
- event.payload.round === round,
583
- )
564
+ const events = (await readRepositoryFacts(this.deps.store, this.deps.repo)).value
565
+ const verdict = events.harvestVerdicts[JSON.stringify([run.run, round])]?.latest
584
566
  if (!verdict) throw new Error(`review@${round} terminal is missing`)
585
567
  await this.completeStep(
586
568
  run.run,
@@ -624,22 +606,16 @@ export class HarvestRunner {
624
606
  await this.ensureLease()
625
607
  // Bounded read (AUT-489): reduceHarvest and the harvest.failed filter
626
608
  // consume durable types only.
627
- const events = await store.getRepoStateEvents(repo)
628
- const matchingFailures = events.filter(
629
- (event): event is Extract<RepositoryEvent, { type: 'harvest.failed' }> =>
630
- event.type === 'harvest.failed' &&
631
- event.payload.run === spec.run &&
632
- event.payload.step === spec.step &&
633
- event.payload.round === spec.round,
634
- )
635
- const failures = matchingFailures.length
636
- const latestFailure = matchingFailures.at(-1)
637
- const current = reduceHarvest(events).runs.find((candidate) => candidate.run === spec.run)
609
+ const events = (await readRepositoryFacts(store, repo)).value
610
+ const failureFacts = events.harvestFailures[JSON.stringify([spec.run, spec.step, spec.round])]
611
+ const failures = failureFacts?.count ?? 0
612
+ const latestFailure = failureFacts?.latest
613
+ const current = events.harvest.runs.find((candidate) => candidate.run === spec.run)
638
614
  const resumedAfterTerminalFailure =
639
615
  latestFailure?.payload.willRetry === false &&
640
616
  current?.status === 'running' &&
641
617
  current.failure === undefined &&
642
- events.some((event) => event.type === 'harvest.resumed' && event.seq > latestFailure.seq)
618
+ events.latestHarvestResumedSeq > latestFailure.seq
643
619
  if (failures >= this.maxSessionAttempts && !resumedAfterTerminalFailure) {
644
620
  throw new SessionFailure(`${spec.step}@${spec.round} exhausted retries`)
645
621
  }
@@ -884,17 +860,11 @@ export class HarvestRunner {
884
860
  await this.ensureLease()
885
861
  // Bounded read (AUT-489): reduceHarvest and the harvest.failed filter
886
862
  // consume durable types only.
887
- const events = await store.getRepoStateEvents(repo)
888
- const matchingFailures = events.filter(
889
- (event): event is Extract<RepositoryEvent, { type: 'harvest.failed' }> =>
890
- event.type === 'harvest.failed' &&
891
- event.payload.run === spec.run &&
892
- event.payload.step === spec.step &&
893
- event.payload.round === spec.round,
894
- )
895
- const failures = matchingFailures.length
896
- const latestFailure = matchingFailures.at(-1)
897
- const current = reduceHarvest(events).runs.find((candidate) => candidate.run === spec.run)
863
+ const events = (await readRepositoryFacts(store, repo)).value
864
+ const failureFacts = events.harvestFailures[JSON.stringify([spec.run, spec.step, spec.round])]
865
+ const failures = failureFacts?.count ?? 0
866
+ const latestFailure = failureFacts?.latest
867
+ const current = events.harvest.runs.find((candidate) => candidate.run === spec.run)
898
868
  // A stopping failure may already have consumed the ordinary retry budget.
899
869
  // harvest.resumed deliberately clears that reduced stop and grants one real
900
870
  // re-entry without deleting history or resetting attempt numbers. The next
@@ -903,7 +873,7 @@ export class HarvestRunner {
903
873
  latestFailure?.payload.willRetry === false &&
904
874
  current?.status === 'running' &&
905
875
  current.failure === undefined &&
906
- events.some((event) => event.type === 'harvest.resumed' && event.seq > latestFailure.seq)
876
+ events.latestHarvestResumedSeq > latestFailure.seq
907
877
  if (failures >= this.maxSessionAttempts && !resumedAfterTerminalFailure) {
908
878
  throw new SessionFailure(`${spec.step}@${spec.round} exhausted retries`)
909
879
  }
@@ -1366,8 +1336,8 @@ export class HarvestRunner {
1366
1336
  await this.ensureLease()
1367
1337
  // Bounded read (AUT-489): reduceHarvest and the harvest.failed filter
1368
1338
  // consume durable types only.
1369
- const events = await this.deps.store.getRepoStateEvents(this.deps.repo)
1370
- const state = reduceHarvest(events)
1339
+ const events = (await readRepositoryFacts(this.deps.store, this.deps.repo)).value
1340
+ const state = events.harvest
1371
1341
  const current = state.runs.find((candidate) => candidate.run === run)
1372
1342
  if (current?.status !== 'running') return
1373
1343
  const open = [...current.steps]
@@ -1375,13 +1345,7 @@ export class HarvestRunner {
1375
1345
  .find((occurrence) => occurrence.completedSeq === undefined)
1376
1346
  const step = open?.step ?? 'file'
1377
1347
  const round = open?.round
1378
- const prior = events.filter(
1379
- (event) =>
1380
- event.type === 'harvest.failed' &&
1381
- event.payload.run === run &&
1382
- event.payload.step === step &&
1383
- event.payload.round === round,
1384
- ).length
1348
+ const prior = events.harvestFailures[JSON.stringify([run, step, round])]?.count ?? 0
1385
1349
  const attempt = prior + 1
1386
1350
  const willRetry = attempt < this.maxSessionAttempts
1387
1351
  const message = errorMessage(error)
@@ -1421,7 +1385,7 @@ export class HarvestRunner {
1421
1385
  if (this.deps.preparedRun) return
1422
1386
  await this.ensureLease()
1423
1387
  // Bounded read (AUT-489): reduceHarvest consumes durable types only.
1424
- const state = reduceHarvest(await this.deps.store.getRepoStateEvents(this.deps.repo))
1388
+ const state = (await readRepositoryFacts(this.deps.store, this.deps.repo)).value.harvest
1425
1389
  const decision = decideHarvestControl(state, this.maxRecoveryAttempts)
1426
1390
  if (decision.kind !== 'exhaust-recovery' || decision.run !== run) return
1427
1391
  await this.finalizeRecoveryExhaustion(decision.run, decision.attempts, decision.limit)
@@ -1447,7 +1411,7 @@ export class HarvestRunner {
1447
1411
 
1448
1412
  await this.ensureLease()
1449
1413
  // Bounded read (AUT-489): reduceHarvest consumes durable types only.
1450
- const state = reduceHarvest(await this.deps.store.getRepoStateEvents(this.deps.repo))
1414
+ const state = (await readRepositoryFacts(this.deps.store, this.deps.repo)).value.harvest
1451
1415
  const decision = decideHarvestControl(state, this.maxRecoveryAttempts)
1452
1416
  if (
1453
1417
  decision.kind !== 'exhaust-recovery' ||
@@ -1484,7 +1448,7 @@ export class HarvestRunner {
1484
1448
  while (true) {
1485
1449
  await this.ensureLease()
1486
1450
  // Bounded read (AUT-489): reduceHarvest consumes durable types only.
1487
- const state = reduceHarvest(await this.deps.store.getRepoStateEvents(this.deps.repo))
1451
+ const state = (await readRepositoryFacts(this.deps.store, this.deps.repo)).value.harvest
1488
1452
  const decision = decideHarvestControl(state, this.maxRecoveryAttempts)
1489
1453
  if (decision.kind === 'proceed') return
1490
1454
  if (this.deps.preparedRun) throw new HarvestParkedSignal(run)
@@ -1529,7 +1493,7 @@ export class HarvestRunner {
1529
1493
 
1530
1494
  private async refreshRun(run: string): Promise<HarvestRunState> {
1531
1495
  // Bounded read (AUT-489): reduceHarvest consumes durable types only.
1532
- const state = reduceHarvest(await this.deps.store.getRepoStateEvents(this.deps.repo))
1496
+ const state = (await readRepositoryFacts(this.deps.store, this.deps.repo)).value.harvest
1533
1497
  const found = state.runs.find((candidate) => candidate.run === run)
1534
1498
  if (!found) throw new Error(`unknown harvest run "${run}"`)
1535
1499
  return found
@@ -1,3 +1,5 @@
1
+ import { readBuildDigests } from '../store/reduced-reads'
2
+ import { readRepositoryFacts, readBuildFacts } from '../store/reduced-reads'
1
3
  /** Deterministic halves of observation harvest: scan, dedup, validation, and
2
4
  * proposal rendering. Agent judgment is deliberately absent from this file. */
3
5
  import type { AbEvent } from '../events/catalog'
@@ -135,10 +137,12 @@ export function evaluateHarvestPressure(
135
137
  * so latest-wins carries exactly the drift fact the scan collects. */
136
138
  export function evaluateHarvestPressureFromDigests(input: {
137
139
  digests: Map<string, BuildDigest>
138
- harvestEvents: RepositoryEvent[]
140
+ harvestEvents: RepositoryEvent[] | HarvestState
139
141
  policy: { harvestThreshold: number; harvestMaxDrift: number }
140
142
  }): HarvestPressure {
141
- const claimed = claimedOccurrenceKeys(reduceHarvest(input.harvestEvents))
143
+ const claimed = claimedOccurrenceKeys(
144
+ Array.isArray(input.harvestEvents) ? reduceHarvest(input.harvestEvents) : input.harvestEvents,
145
+ )
142
146
  const unclaimed: HarvestPressureFacts['unclaimed'] = []
143
147
  const merges: HarvestPressureFacts['merges'] = []
144
148
  for (const digest of input.digests.values()) {
@@ -160,11 +164,11 @@ export function evaluateHarvestPressureFromDigests(input: {
160
164
  export async function evaluateHarvestPressureFromStore(input: {
161
165
  store: BuildStore
162
166
  repo: string
163
- harvestEvents: RepositoryEvent[]
167
+ harvestEvents: RepositoryEvent[] | HarvestState
164
168
  policy: { harvestThreshold: number; harvestMaxDrift: number }
165
169
  }): Promise<HarvestPressure> {
166
170
  await input.store.ensureRepo(input.repo)
167
- const digests = await input.store.getRepoBuildDigests(input.repo)
171
+ const digests = await readBuildDigests(input.store, input.repo)
168
172
  return evaluateHarvestPressureFromDigests({
169
173
  digests,
170
174
  harvestEvents: input.harvestEvents,
@@ -182,9 +186,11 @@ export function collectUnclaimedObservations(input: {
182
186
  repo: string
183
187
  records: BuildRecord[]
184
188
  eventsByBuild: Map<string, AbEvent[]>
185
- harvestEvents: RepositoryEvent[]
189
+ harvestEvents: RepositoryEvent[] | HarvestState
186
190
  }): HarvestScanResult {
187
- const state = reduceHarvest(input.harvestEvents)
191
+ const state = Array.isArray(input.harvestEvents)
192
+ ? reduceHarvest(input.harvestEvents)
193
+ : input.harvestEvents
188
194
  const claimed = claimedOccurrenceKeys(state)
189
195
  const observations: HarvestObservation[] = []
190
196
  const merges: HarvestMerge[] = []
@@ -235,9 +241,11 @@ export function collectUnclaimedObservations(input: {
235
241
  * are not assumed globally unique. */
236
242
  export function unclaimedObservationCount(input: {
237
243
  digests: Map<string, BuildDigest>
238
- harvestEvents: RepositoryEvent[]
244
+ harvestEvents: RepositoryEvent[] | HarvestState
239
245
  }): number {
240
- const claimed = claimedOccurrenceKeys(reduceHarvest(input.harvestEvents))
246
+ const claimed = claimedOccurrenceKeys(
247
+ Array.isArray(input.harvestEvents) ? reduceHarvest(input.harvestEvents) : input.harvestEvents,
248
+ )
241
249
  let count = 0
242
250
  for (const digest of input.digests.values()) {
243
251
  for (const observation of digest.observations) {
@@ -279,7 +287,7 @@ export async function sampleUnclaimedObservationCount(
279
287
  ): Promise<number> {
280
288
  const [digests, harvestEvents] = await Promise.all([
281
289
  store.getRepoBuildDigests(repo),
282
- readRepoEventsIfRecorded(store, repo),
290
+ readRepositoryFacts(store, repo).then((result) => result.value.harvest),
283
291
  ])
284
292
  return unclaimedObservationCount({ digests, harvestEvents })
285
293
  }
@@ -292,16 +300,31 @@ export async function scanUnclaimedObservations(
292
300
  repo: string,
293
301
  ): Promise<HarvestScanResult> {
294
302
  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)
303
+ const state = (await readRepositoryFacts(store, repo)).value.harvest
304
+ const claimed = claimedOccurrenceKeys(state)
298
305
  const records = await store.listBuilds()
299
- const eventsByBuild = new Map<string, AbEvent[]>()
306
+ const observations: HarvestObservation[] = []
307
+ const merges: HarvestMerge[] = []
300
308
  for (const record of records) {
301
309
  if (record.repo !== repo) continue
302
- eventsByBuild.set(record.slug, await store.getEvents(record.slug))
310
+ const facts = (await readBuildFacts(store, record.slug)).value
311
+ for (const ts of facts.mergeTimes) merges.push({ build: record.slug, ts })
312
+ for (const observation of facts.observationFacts) {
313
+ const occurrence = { build: record.slug, seq: observation.seq }
314
+ if (claimed.has(occurrenceKey(occurrence))) continue
315
+ observations.push({
316
+ occurrence,
317
+ ...observation.payload,
318
+ ts: observation.ts,
319
+ ...(record.ticket ? { ticket: structuredClone(record.ticket) } : {}),
320
+ })
321
+ }
303
322
  }
304
- return collectUnclaimedObservations({ repo, records, eventsByBuild, harvestEvents })
323
+ observations.sort(
324
+ (a, b) =>
325
+ a.occurrence.build.localeCompare(b.occurrence.build) || a.occurrence.seq - b.occurrence.seq,
326
+ )
327
+ return { state, observations, merges }
305
328
  }
306
329
 
307
330
  /** Distinct previously filed/joined proposal tickets, reconciled through the
@@ -1,3 +1,4 @@
1
+ import { readBuildFacts, type CurrentBuildFacts } from '../store/reduced-reads'
1
2
  import type { AbEvent } from '../events/catalog'
2
3
  import { DISPATCHER } from '../events/envelope'
3
4
  import type { EventPayload } from '../events/payloads'
@@ -10,7 +11,7 @@ export type InfrastructureCause = EventPayload<'infrastructure.failed'>['cause']
10
11
 
11
12
  export interface InfrastructureFailureInput {
12
13
  slug: string
13
- events: readonly AbEvent[]
14
+ events: readonly AbEvent[] | CurrentBuildFacts
14
15
  maxAttempts: number
15
16
  provider: string
16
17
  workspaceRef: string
@@ -23,7 +24,7 @@ export interface InfrastructureFailureInput {
23
24
  }
24
25
 
25
26
  export interface InfrastructureFailureDependencies {
26
- store: Pick<BuildStore, 'append' | 'getEvents'>
27
+ store: Pick<BuildStore, 'append' | 'getReducerValue'>
27
28
  ids: IdSource
28
29
  }
29
30
 
@@ -104,10 +105,14 @@ export async function recordInfrastructureFailure(
104
105
  deps: InfrastructureFailureDependencies,
105
106
  input: InfrastructureFailureInput,
106
107
  ): Promise<AbEvent[]> {
107
- const lastReset = infrastructureFailureResetSeq(input.events)
108
- const attempt =
109
- input.events.filter((event) => event.type === 'infrastructure.failed' && event.seq > lastReset)
110
- .length + 1
108
+ const lastReset = Array.isArray(input.events)
109
+ ? infrastructureFailureResetSeq(input.events)
110
+ : (input.events as CurrentBuildFacts).infrastructureReset
111
+ const attempt = Array.isArray(input.events)
112
+ ? input.events.filter(
113
+ (event) => event.type === 'infrastructure.failed' && event.seq > lastReset,
114
+ ).length + 1
115
+ : (input.events as CurrentBuildFacts).infrastructureCount + 1
111
116
  const message = normalizeInfrastructureError(input.error)
112
117
  const failure = await deps.store.append(input.slug, {
113
118
  actor: DISPATCHER,
@@ -130,15 +135,8 @@ export async function recordInfrastructureFailure(
130
135
 
131
136
  if (attempt < input.maxAttempts) return appended
132
137
 
133
- const currentEvents = await deps.store.getEvents(input.slug)
134
- if (
135
- !currentEvents.some(
136
- (event) =>
137
- event.seq > lastReset &&
138
- event.type === 'escalation.raised' &&
139
- event.payload.policyCause === 'infrastructure-failure-limit',
140
- )
141
- ) {
138
+ const current = (await readBuildFacts(deps.store, input.slug)).value
139
+ if (current.infrastructureRaisedSeq <= lastReset) {
142
140
  appended.push(
143
141
  await deps.store.append(input.slug, {
144
142
  actor: DISPATCHER,
@@ -1,3 +1,4 @@
1
+ import { readBuildFacts } from '../store/reduced-reads'
1
2
  /**
2
3
  * The dispatcher tick's orchestrator step (AUT-342): resume suspended turns,
3
4
  * reap crashed ones, and wake idle sessions from build attention events.
@@ -28,7 +29,6 @@ import { buildRegistry, type OperatorToolRegistry } from '../operator/registry'
28
29
  import type { OperatorSandboxService } from '../operator/sandbox'
29
30
  import { ticketBackendFromSource } from '../operator/ticket-source-backend'
30
31
  import { reduceSession, type SessionTurnTrigger } from '../store/session-reducer'
31
- import { reduceBuild } from '../kernel/reducer'
32
32
  import type { AbEvent } from '../events/catalog'
33
33
  import type { BuildStore, Clock } from '../store/types'
34
34
  import type { RepoViewStore } from './repo-view'
@@ -315,7 +315,7 @@ export async function runOrchestratorTickStep(
315
315
  event,
316
316
  buildState: (options.view !== undefined
317
317
  ? await options.view.buildState(newest.build)
318
- : reduceBuild(await store.getEvents(newest.build))) as unknown as Record<
318
+ : (await readBuildFacts(store, newest.build)).value.state) as unknown as Record<
319
319
  string,
320
320
  unknown
321
321
  >,
@@ -1,4 +1,3 @@
1
- import { summaryBoundary } from '../kernel/pr-summary'
2
1
  import { isRemoteWorkspace } from '../events/workspace-remote'
3
2
  /**
4
3
  * Last-chance publication settlement at the workspace-release boundary
@@ -12,11 +11,6 @@ import { isRemoteWorkspace } from '../events/workspace-remote'
12
11
  */
13
12
  import { DISPATCHER } from '../events/envelope'
14
13
  import type { AbEvent } from '../events/catalog'
15
- import {
16
- latestUncompletedPublicationRequest,
17
- publicationLostRecorded,
18
- publicationRequestCompleted,
19
- } from './publication-state'
20
14
  import type { BuildStore } from '../store/types'
21
15
 
22
16
  export interface PublicationLossDeps {
@@ -35,36 +29,56 @@ export interface PublicationLossDeps {
35
29
  export async function settlePublicationBeforeRelease(
36
30
  deps: PublicationLossDeps,
37
31
  slug: string,
38
- events: AbEvent[],
32
+ _events: AbEvent[],
39
33
  reason: string,
40
34
  ): Promise<AbEvent[]> {
41
- const request = latestUncompletedPublicationRequest(events)
35
+ const { readBuildFacts } = await import('../store/reduced-reads')
36
+ await settlePublicationFactsBeforeRelease(
37
+ deps,
38
+ slug,
39
+ (await readBuildFacts(deps.store, slug)).value,
40
+ reason,
41
+ )
42
+ // Legacy callers explicitly requested the raw event output.
43
+ return deps.store.getEvents(slug)
44
+ }
45
+
46
+ /** Current-state release guard; the returned facts follow every settlement write. */
47
+ export async function settlePublicationFactsBeforeRelease(
48
+ deps: PublicationLossDeps,
49
+ slug: string,
50
+ facts: import('../store/reduced-reads').CurrentBuildFacts,
51
+ reason: string,
52
+ ) {
53
+ const { readBuildFacts } = await import('../store/reduced-reads')
54
+ const request = facts.publication.latestUncompletedRequest()
42
55
  if (request === undefined) {
43
- const workspace = events.findLast((event) => event.type === 'workspace.provisioned')
44
56
  if (
45
- workspace?.type === 'workspace.provisioned' &&
46
- isRemoteWorkspace(workspace.payload) &&
47
- summaryBoundary(events) !== undefined
57
+ facts.workspace &&
58
+ isRemoteWorkspace(facts.workspace) &&
59
+ facts.summaryBoundary !== undefined
48
60
  ) {
49
61
  try {
50
62
  await deps.settlePublication?.(slug)
51
63
  } catch {
52
64
  /* Summary is best effort. */
53
65
  }
54
- return deps.store.getEvents(slug)
66
+ return (await readBuildFacts(deps.store, slug)).value
55
67
  }
56
- return events
68
+ return facts
57
69
  }
58
70
  try {
59
71
  await deps.settlePublication?.(slug)
60
72
  } catch {
61
- // A throw means the request is still uncompleted (or settlement failed);
62
- // either way the loss record below is what makes the release safe.
73
+ /* Record loss before release. */
63
74
  }
64
- const refreshed = await deps.store.getEvents(slug)
65
- if (publicationRequestCompleted(refreshed, request)) return refreshed
66
- if (publicationLostRecorded(refreshed, request)) return refreshed
67
- const appended = await deps.store.append(slug, {
75
+ const refreshed = (await readBuildFacts(deps.store, slug)).value
76
+ if (
77
+ refreshed.publication.requestCompleted(request) ||
78
+ refreshed.publication.lostRecorded(request)
79
+ )
80
+ return refreshed
81
+ await deps.store.append(slug, {
68
82
  actor: DISPATCHER,
69
83
  type: 'publication.lost',
70
84
  payload: {
@@ -75,5 +89,5 @@ export async function settlePublicationBeforeRelease(
75
89
  reason,
76
90
  },
77
91
  })
78
- return [...refreshed, appended]
92
+ return (await readBuildFacts(deps.store, slug)).value
79
93
  }