@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,11 +1,10 @@
1
+ import { readBuildDigests } from '../store/reduced-reads'
2
+ import { readBuildFacts, type CurrentBuildFacts } from '../store/reduced-reads'
1
3
  import { ensureExecutionProgress } from './execution-no-progress'
2
4
  import { ticketIdentity } from '../ports/tickets/identity'
3
5
  import { ticketLifecycle } from '../ports/tickets/lifecycle'
4
6
  import { ensureDispatchBudget } from './dispatch-failure-budget'
5
- import {
6
- reduceTicketSourceHealth,
7
- type TicketSourceDegradation,
8
- } from '../kernel/ticket-source-health'
7
+ import type { TicketSourceDegradation } from '../kernel/ticket-source-health'
9
8
  /**
10
9
  * The dispatcher process (SPEC §3.3, §12): watches the TicketSource for
11
10
  * Ready tickets, claims, provisions a workspace, and launches build-runners
@@ -60,21 +59,18 @@ import {
60
59
  } from '../kernel/auto-merge'
61
60
  import {
62
61
  autoMergeDefaultTarget,
63
- latestAutoMergeDefault,
62
+ pickAutoMergeDefault,
64
63
  type AutoMergeDefaultFact,
65
64
  } from '../kernel/auto-merge-default'
66
- import { reduceDispatchSettings } from '../kernel/dispatch-settings'
67
65
  import { decideNextFromFacts } from '../kernel/engine'
68
66
  import { composeBuildConfig } from '../config/live'
69
67
  import { BUILD_EFFECTIVE_CONFIG_ARTIFACT, parseEffectiveBuildConfig } from './build-execution-state'
70
68
  import {
71
69
  DEFAULT_MAX_HARVEST_RECOVERY_ATTEMPTS,
72
70
  decideHarvestControl,
73
- harvestCreationsDuringReadyScan,
74
- reduceHarvest,
71
+ harvestCreationsFromStates,
75
72
  } from '../kernel/harvest'
76
- import { pendingPrAttachmentReclaims } from '../kernel/pr-attachments'
77
- import { reduceBuild, type BuildState } from '../kernel/reducer'
73
+ import type { BuildState } from '../kernel/reducer'
78
74
  import { createOperatorSandboxService, type OperatorSandboxService } from '../operator/sandbox'
79
75
  import type { ArtifactRef, TicketRef } from '../ontology'
80
76
  import { BUILD_EXECUTION_LEASE_TTL_MS } from '../ports/workspace/build-execution'
@@ -94,19 +90,9 @@ import { specConformance } from '../spec-standard'
94
90
  export { specConformance, type SpecConformance } from '../spec-standard'
95
91
  import { recordInfrastructureFailure as appendInfrastructureFailure } from './infrastructure-failure-budget'
96
92
  import { settleOpenExecution } from './execution-settlement'
97
- import { openHarvestExecutions } from './harvest-execution-state'
98
93
  import { isWorkDigest, RepoViewStore } from './repo-view'
99
- import {
100
- baseBranchReducer,
101
- openBuildWorkspace,
102
- provisionMarkerReducer,
103
- recoveryCheckpointReducer,
104
- type OpenBuildWorkspace,
105
- type ProvisionMarker,
106
- } from './dispatcher-selectors'
107
- import type { RepositoryEvent } from '../events/repository'
108
- import { sandboxStates } from './sandbox-state'
109
- import { settlePublicationBeforeRelease } from './publication-loss'
94
+ import type { OpenBuildWorkspace, ProvisionMarker } from './dispatcher-selectors'
95
+ import { settlePublicationFactsBeforeRelease } from './publication-loss'
110
96
 
111
97
  // ── Readiness resolution (SPEC §3.3) ─────────────────────────────────────────
112
98
 
@@ -611,14 +597,14 @@ export interface DispatcherDeps {
611
597
  /** Latest `workspace.provisioned` not followed by a `workspace.released` —
612
598
  * the reducer deliberately ignores workspace events (liveness is the
613
599
  * dispatcher's concern), so the janitor scans the raw log. */
614
- function openWorkspace(events: AbEvent[]): OpenBuildWorkspace | null {
615
- return openBuildWorkspace(events)
600
+ function openWorkspace(events: CurrentBuildFacts): OpenBuildWorkspace | null {
601
+ return events.workspace
616
602
  }
617
603
 
618
604
  /** The build's base branch, from its own `build.created` fact (§15.3);
619
605
  * falls back to the repo config for logs missing one. */
620
- function baseBranchOf(events: AbEvent[], config: Config): string {
621
- return baseBranchReducer.reduce(events) ?? config.baseBranch
606
+ function baseBranchOf(events: CurrentBuildFacts, config: Config): string {
607
+ return events.baseBranch ?? config.baseBranch
622
608
  }
623
609
 
624
610
  function artifactRefOf(deposited: ArtifactMeta[]): ArtifactRef {
@@ -759,14 +745,7 @@ export class Dispatcher {
759
745
  }
760
746
 
761
747
  async tick(opts: TickOpts = {}): Promise<TickReport> {
762
- try {
763
- return await this.runTick(opts)
764
- } finally {
765
- // Leave this tick's reduced state behind for the next process (a cache;
766
- // a failure to write it is never a tick failure). Throttled in a
767
- // resident dispatcher, always run by a cold single-tick process.
768
- await this.store.persistSnapshots()
769
- }
748
+ return this.runTick(opts)
770
749
  }
771
750
 
772
751
  private async runTick(opts: TickOpts): Promise<TickReport> {
@@ -791,9 +770,9 @@ export class Dispatcher {
791
770
  // likewise samples before calling `tick`); the auto-merge default fact is
792
771
  // the durable driver of this tick's fan-out below. Neither control
793
772
  // pretends to a serialization the store does not offer.
794
- const repoEvents = this.store.recordedJournal()
795
- const paused = reduceDispatchSettings(repoEvents).paused
796
- const autoMergeDefault = latestAutoMergeDefault(repoEvents)
773
+ const repoFacts = this.store.repositoryFacts()
774
+ const paused = repoFacts.settings.paused
775
+ const autoMergeDefault = pickAutoMergeDefault(repoFacts.autoMerge)
797
776
  // The claim-time seed cites the newest fact whose enabled state matches
798
777
  // the launch sample, not blindly the newest fact: the CLI samples
799
778
  // `defaultAutoMerge` from its own earlier read, so an interleaved toggle
@@ -802,7 +781,7 @@ export class Dispatcher {
802
781
  // so the fan-out reconciles the build on the next tick instead of the
803
782
  // stale sample out-voting the durable default forever (f_b851c0e8).
804
783
  const autoMergeSeedFact =
805
- opts.defaultAutoMerge === true ? latestAutoMergeDefault(repoEvents, true) : undefined
784
+ opts.defaultAutoMerge === true ? pickAutoMergeDefault(repoFacts.autoMerge, true) : undefined
806
785
  // Stage 0 — SETTLEMENT: observe foreign executions from durable facts plus
807
786
  // provider liveness and settle their completion, lease, and publication.
808
787
  // Runs before the janitor so its executionLeaseLive checks see freshly
@@ -916,7 +895,7 @@ export class Dispatcher {
916
895
  const store = this.store
917
896
  const { repo } = this.deps
918
897
  const records = (await store.listBuilds()).filter((record) => record.repo === repo)
919
- const digests = await store.getRepoBuildDigests(repo)
898
+ const digests = await readBuildDigests(store, repo)
920
899
  return records.map((record) => {
921
900
  const digest = digests.get(record.slug)
922
901
  if (digest === undefined) {
@@ -952,7 +931,7 @@ export class Dispatcher {
952
931
  successfulOperation?: 'ready-scan' | 'dependency-check',
953
932
  ): Promise<void> {
954
933
  await this.store.refreshJournal()
955
- const current = reduceTicketSourceHealth(this.store.recordedJournal())
934
+ const current = this.store.repositoryFacts().health
956
935
  if (next !== undefined && current?.cause === next.cause && current.source === next.source) {
957
936
  if (
958
937
  next.cause === 'rate-limited' &&
@@ -1082,12 +1061,7 @@ export class Dispatcher {
1082
1061
  }
1083
1062
 
1084
1063
  private latestScan() {
1085
- return this.store
1086
- .recordedJournal()
1087
- .findLast(
1088
- (event): event is Extract<RepositoryEvent, { type: 'dispatcher.ticket-scan-completed' }> =>
1089
- event.type === 'dispatcher.ticket-scan-completed',
1090
- )?.payload
1064
+ return this.store.repositoryFacts().scan
1091
1065
  }
1092
1066
 
1093
1067
  private restoreScan(report: TickReport): void {
@@ -1219,7 +1193,7 @@ export class Dispatcher {
1219
1193
  return
1220
1194
  }
1221
1195
  // Bounded subset (AUT-489): the harvest reducer consumes durable types only.
1222
- const state = reduceHarvest(this.store.recordedJournal())
1196
+ const state = this.store.repositoryFacts().harvest
1223
1197
  if (decideHarvestControl(state, this.maxHarvestRecoveryAttempts).kind !== 'park') {
1224
1198
  start()
1225
1199
  }
@@ -1305,13 +1279,13 @@ export class Dispatcher {
1305
1279
  * repository lease. */
1306
1280
  private async settleHarvestExecutions(opts: TickOpts = {}): Promise<void> {
1307
1281
  const active = this.deps.activeHarvestExecutions?.() ?? new Set<string>()
1308
- let events: RepositoryEvent[]
1282
+ let facts: ReturnType<RepoViewStore['repositoryFacts']>
1309
1283
  try {
1310
1284
  // The earlier stages awaited providers; take the journal delta so a
1311
1285
  // foreign append (operator activity, harvest facts) made meanwhile is
1312
1286
  // seen before any destructive decision below.
1313
1287
  await this.store.refreshJournal()
1314
- events = this.store.recordedJournal()
1288
+ facts = this.store.repositoryFacts()
1315
1289
  } catch {
1316
1290
  // The read itself failed (a transient store error — a missing record
1317
1291
  // answers `[]` inside the helper, so it never reaches this arm):
@@ -1319,7 +1293,7 @@ export class Dispatcher {
1319
1293
  // tick proceeds, and the next tick retries what remains.
1320
1294
  return
1321
1295
  }
1322
- for (const execution of openHarvestExecutions(events)) {
1296
+ for (const execution of facts.openHarvest) {
1323
1297
  if (active.has(execution.execution)) continue
1324
1298
  // Budget gate: once spent, stop settling further harvest executions;
1325
1299
  // the next tick retries what remains.
@@ -1408,13 +1382,13 @@ export class Dispatcher {
1408
1382
  const capability = this.deps.workspaces.orchestratorSandbox
1409
1383
  if (capability === undefined) return
1410
1384
  const providerName = this.deps.workspaces.name
1411
- let events: RepositoryEvent[]
1385
+ let facts: ReturnType<RepoViewStore['repositoryFacts']>
1412
1386
  try {
1413
1387
  // The earlier stages awaited providers; take the journal delta so a
1414
1388
  // foreign append (operator activity, harvest facts) made meanwhile is
1415
1389
  // seen before any destructive decision below.
1416
1390
  await this.store.refreshJournal()
1417
- events = this.store.recordedJournal()
1391
+ facts = this.store.repositoryFacts()
1418
1392
  } catch {
1419
1393
  // The read itself failed (a transient store error — a missing record
1420
1394
  // answers `[]` inside the helper, so it never reaches this arm):
@@ -1423,7 +1397,7 @@ export class Dispatcher {
1423
1397
  return
1424
1398
  }
1425
1399
  const idleMs = config.orchestrator.sandbox.idleMinutes * 60_000
1426
- for (const environment of sandboxStates(events)) {
1400
+ for (const environment of facts.sandboxes) {
1427
1401
  if (environment.state !== 'live') continue
1428
1402
  // Budget gate: once spent, stop settling further environments; the
1429
1403
  // next tick retries what remains.
@@ -1505,8 +1479,8 @@ export class Dispatcher {
1505
1479
 
1506
1480
  /** The latest `workspace.provision-started` not yet followed by a
1507
1481
  * provisioned/released fact — an open provisioning marker. */
1508
- private openProvisionMarker(events: AbEvent[]): ProvisionMarker | undefined {
1509
- return provisionMarkerReducer.reduce(events)
1482
+ private openProvisionMarker(events: CurrentBuildFacts): ProvisionMarker | undefined {
1483
+ return events.provisionMarker
1510
1484
  }
1511
1485
 
1512
1486
  /** Liveness of the lease behind an open provision marker: `live` means a
@@ -1527,7 +1501,7 @@ export class Dispatcher {
1527
1501
  * report progress without awaiting any of it. */
1528
1502
  private async ensureProvisionContinuation(
1529
1503
  record: BuildRecord,
1530
- events: AbEvent[],
1504
+ events: CurrentBuildFacts,
1531
1505
  tail: 'dispatch' | 'launch',
1532
1506
  seed?: {
1533
1507
  ticket: Ticket
@@ -1543,12 +1517,7 @@ export class Dispatcher {
1543
1517
  const marker = this.openProvisionMarker(events)
1544
1518
  if (marker !== undefined) {
1545
1519
  const liveness = await this.provisionMarkerLiveness(slug)
1546
- const failedAttempt = events.some(
1547
- (event) =>
1548
- event.type === 'dispatch.failed' &&
1549
- event.payload.stage === 'workspace' &&
1550
- event.seq > marker.seq,
1551
- )
1520
+ const failedAttempt = events.latestWorkspaceFailureSeq > marker.seq
1552
1521
  if (liveness === 'live') return 'foreign-live'
1553
1522
  if (
1554
1523
  liveness === 'absent' &&
@@ -1563,7 +1532,7 @@ export class Dispatcher {
1563
1532
  // Expired lease, failed attempt, or an old lease-less marker: adopt.
1564
1533
  }
1565
1534
  const branch = record.branch ?? `ab/${slug}`
1566
- const generation = events.filter((event) => event.type === 'workspace.provisioned').length
1535
+ const generation = events.workspaceGenerations
1567
1536
  await this.store.append(slug, {
1568
1537
  actor: DISPATCHER,
1569
1538
  type: 'workspace.provision-started',
@@ -1643,10 +1612,8 @@ export class Dispatcher {
1643
1612
  // so the next supervisor adopts the marker immediately.
1644
1613
  return
1645
1614
  }
1646
- const events = await store.getEvents(slug)
1647
- const priorGenerations = events.filter(
1648
- (event) => event.type === 'workspace.provisioned',
1649
- ).length
1615
+ const events = (await readBuildFacts(store, slug)).value
1616
+ const priorGenerations = events.workspaceGenerations
1650
1617
  const handle = await workspaces.provision({
1651
1618
  // Local providers provision FROM the physical checkout; remote
1652
1619
  // providers ignore the value (their host seams are injected).
@@ -1689,7 +1656,7 @@ export class Dispatcher {
1689
1656
  boundary = 'read'
1690
1657
  const record = await store.getBuild(slug)
1691
1658
  if (record === null) return
1692
- const queued = reduceBuild(await store.getEvents(slug)).status === 'queued'
1659
+ const queued = (await readBuildFacts(store, slug)).value.state.status === 'queued'
1693
1660
  boundary = input.tail === 'dispatch' || queued ? 'dispatch' : 'launch'
1694
1661
  if (boundary === 'dispatch') await this.completeDispatchBoundaries(record, input.seed)
1695
1662
  else await this.deps.launchRunner(slug)
@@ -1699,10 +1666,10 @@ export class Dispatcher {
1699
1666
  // tick; it is not another provider workspace failure. Replacement
1700
1667
  // runner launch errors retain the existing infrastructure-budget path.
1701
1668
  if (boundary === 'read' || boundary === 'dispatch') return
1702
- const events = await store.getEvents(slug)
1669
+ const events = (await readBuildFacts(store, slug)).value
1703
1670
  if (workspaces.recovery !== undefined) {
1704
1671
  const open = openWorkspace(events)
1705
- const latestRef = [...events].reverse().find((event) => event.type === 'workspace.released')
1672
+ const latestRef = events.latestRelease
1706
1673
  await this.recordInfrastructureFailure(slug, events, {
1707
1674
  provider: workspaces.name,
1708
1675
  workspaceRef:
@@ -1715,7 +1682,7 @@ export class Dispatcher {
1715
1682
  cleanupPending: true,
1716
1683
  })
1717
1684
  }
1718
- const attempt = events.filter((event) => event.type === 'dispatch.failed').length + 1
1685
+ const attempt = events.dispatchFailureCount + 1
1719
1686
  const message = (error instanceof Error ? error.message : String(error)).trim()
1720
1687
  await store.append(slug, {
1721
1688
  actor: DISPATCHER,
@@ -1764,9 +1731,9 @@ export class Dispatcher {
1764
1731
  for (const entry of entries) entry.controller.abort()
1765
1732
  for (const entry of entries) {
1766
1733
  try {
1767
- const events = await this.store.getEvents(entry.slug)
1734
+ const events = (await readBuildFacts(this.store, entry.slug)).value
1768
1735
  if (this.openProvisionMarker(events) !== undefined) {
1769
- const attempt = events.filter((event) => event.type === 'dispatch.failed').length + 1
1736
+ const attempt = events.dispatchFailureCount + 1
1770
1737
  await this.store.append(entry.slug, {
1771
1738
  actor: DISPATCHER,
1772
1739
  type: 'dispatch.failed',
@@ -1816,9 +1783,9 @@ export class Dispatcher {
1816
1783
  const executionLeaseLive = this.hasLiveExecutionLease(record)
1817
1784
  // Raw history, read at most once and only by a branch that acts on it:
1818
1785
  // the facts above answer every quiet-path question without it.
1819
- let loaded: AbEvent[] | undefined
1820
- const history = async (): Promise<AbEvent[]> => {
1821
- loaded ??= await this.store.getEvents(record.slug)
1786
+ let loaded: CurrentBuildFacts | undefined
1787
+ const history = async (): Promise<CurrentBuildFacts> => {
1788
+ loaded ??= (await readBuildFacts(this.store, record.slug)).value
1822
1789
  return loaded
1823
1790
  }
1824
1791
  // Pipeline/ticket/workspace state is settled, but release-asset cleanup
@@ -1881,7 +1848,7 @@ export class Dispatcher {
1881
1848
  }
1882
1849
  }
1883
1850
 
1884
- private forgeWorkspacePath(events: AbEvent[]): string {
1851
+ private forgeWorkspacePath(events: CurrentBuildFacts): string {
1885
1852
  return this.forgeWorkspacePathOf(openWorkspace(events))
1886
1853
  }
1887
1854
 
@@ -1905,7 +1872,7 @@ export class Dispatcher {
1905
1872
  * repeating on later ticks. `build.completed` is deliberately last. */
1906
1873
  private async cleanupAborted(
1907
1874
  record: BuildRecord,
1908
- events: AbEvent[],
1875
+ events: CurrentBuildFacts,
1909
1876
  report: TickReport,
1910
1877
  ): Promise<void> {
1911
1878
  const store = this.store
@@ -1919,13 +1886,11 @@ export class Dispatcher {
1919
1886
  // in the main checkout, and origin mode has no checkout at all: the
1920
1887
  // local-branch git step is skipped, and the saga's later steps tolerate
1921
1888
  // its absence.
1922
- const hadLocalWorkspace = events.some(
1923
- (event) => event.type === 'workspace.provisioned' && !isRemoteWorkspace(event.payload),
1924
- )
1925
- const has = (type: AbEvent['type']): boolean => events.some((event) => event.type === type)
1889
+ const hadLocalWorkspace = events.localWorkspace
1890
+ const has = (type: AbEvent['type']): boolean => events.seenTypes.includes(type)
1926
1891
  const append = async <T extends EventWrite['type']>(write: EventWrite<T>): Promise<void> => {
1927
- const event = await store.append(record.slug, write)
1928
- events.push(event as AbEvent)
1892
+ const _event = await store.append(record.slug, write)
1893
+ Object.assign(events, (await readBuildFacts(store, record.slug)).value)
1929
1894
  }
1930
1895
  const fail = (operation: string, identity: string, error: unknown): never => {
1931
1896
  const detail = error instanceof Error ? error.message : String(error)
@@ -1942,7 +1907,7 @@ export class Dispatcher {
1942
1907
  }
1943
1908
  }
1944
1909
 
1945
- const reduced = reduceBuild(events)
1910
+ const reduced = events.state
1946
1911
  if (reduced.pr !== undefined && !has('pr.closed') && !has('pr.merged')) {
1947
1912
  const closePr = forge.closePr
1948
1913
  if (closePr === undefined) {
@@ -1972,7 +1937,8 @@ export class Dispatcher {
1972
1937
 
1973
1938
  try {
1974
1939
  const released = await this.releaseWorkspace(record.slug, events, 'abort')
1975
- if (released !== undefined) events.push(released)
1940
+ if (released !== undefined)
1941
+ Object.assign(events, (await readBuildFacts(store, record.slug)).value)
1976
1942
  } catch (error) {
1977
1943
  fail('workspace release', `build ${record.slug}`, error)
1978
1944
  }
@@ -2069,7 +2035,7 @@ export class Dispatcher {
2069
2035
  * every partial cleanup is retried after a crash. */
2070
2036
  private async cleanupDiscarded(
2071
2037
  record: BuildRecord,
2072
- events: AbEvent[],
2038
+ events: CurrentBuildFacts,
2073
2039
  report: TickReport,
2074
2040
  ): Promise<void> {
2075
2041
  const store = this.store
@@ -2099,7 +2065,7 @@ export class Dispatcher {
2099
2065
  record: BuildRecord,
2100
2066
  // `workspace` is the open workspace fact; `history` loads the raw log, which
2101
2067
  // only the branches that act on a PR transition need.
2102
- source: { history: () => Promise<AbEvent[]>; workspace: OpenBuildWorkspace | null },
2068
+ source: { history: () => Promise<CurrentBuildFacts>; workspace: OpenBuildWorkspace | null },
2103
2069
  state: BuildState,
2104
2070
  report: TickReport,
2105
2071
  launched: Set<string>,
@@ -2139,12 +2105,12 @@ export class Dispatcher {
2139
2105
  if (state.prState === 'conflicted') return
2140
2106
  const events = await source.history()
2141
2107
  const baseSha = await this.baseSha(baseBranchOf(events, this.deps.config))
2142
- const conflicted = await store.append(record.slug, {
2108
+ const _conflicted = await store.append(record.slug, {
2143
2109
  actor: DISPATCHER,
2144
2110
  type: 'pr.conflicted',
2145
2111
  payload: { baseSha },
2146
2112
  } satisfies EventWrite<'pr.conflicted'>)
2147
- events.push(conflicted)
2113
+ Object.assign(events, (await readBuildFacts(this.store, record.slug)).value)
2148
2114
  if (openWorkspace(events) === null && this.deps.workspaces.recovery !== undefined) {
2149
2115
  // The reconcile workspace is provisioned by the tracked background
2150
2116
  // continuation, whose tail re-attaches the build-runner; this tick
@@ -2270,12 +2236,9 @@ export class Dispatcher {
2270
2236
  * is deliberately post-terminal and best-effort: a provider, timeout, or
2271
2237
  * store failure cannot roll back build.completed/ticket/workspace work.
2272
2238
  * Pending handles remain derivable and are retried on every later tick. */
2273
- private async reclaimPrAttachments(slug: string, events: AbEvent[]): Promise<void> {
2274
- for (const hosted of pendingPrAttachmentReclaims(events)) {
2275
- const priorAttempts = events.filter(
2276
- (event) =>
2277
- event.type === 'pr-attachment.reclaim-failed' && event.payload.hostedSeq === hosted.seq,
2278
- ).length
2239
+ private async reclaimPrAttachments(slug: string, events: CurrentBuildFacts): Promise<void> {
2240
+ for (const hosted of events.pendingAttachments) {
2241
+ const priorAttempts = events.reclaimCounts[String(hosted.seq)] ?? 0
2279
2242
  try {
2280
2243
  const capability = this.deps.forge.prAttachments
2281
2244
  if (capability === undefined) {
@@ -2315,13 +2278,13 @@ export class Dispatcher {
2315
2278
  }
2316
2279
  }
2317
2280
 
2318
- private recoveryCheckpoint(events: AbEvent[]): string | undefined {
2319
- return recoveryCheckpointReducer.reduce(events)
2281
+ private recoveryCheckpoint(events: CurrentBuildFacts): string | undefined {
2282
+ return events.recoveryCheckpoint
2320
2283
  }
2321
2284
 
2322
2285
  private async recordInfrastructureFailure(
2323
2286
  slug: string,
2324
- events: AbEvent[],
2287
+ events: CurrentBuildFacts,
2325
2288
  input: {
2326
2289
  provider: string
2327
2290
  workspaceRef: string
@@ -2330,8 +2293,8 @@ export class Dispatcher {
2330
2293
  cleanupPending: boolean
2331
2294
  },
2332
2295
  ): Promise<void> {
2333
- const execution = [...events].reverse().find((event) => event.type === 'execution.started')
2334
- const appended = await appendInfrastructureFailure(
2296
+ const execution = events.state.executions.at(-1)
2297
+ const _appended = await appendInfrastructureFailure(
2335
2298
  { store: this.store, ids: this.deps.ids },
2336
2299
  {
2337
2300
  slug,
@@ -2339,22 +2302,19 @@ export class Dispatcher {
2339
2302
  maxAttempts: this.deps.config.policy.maxInfrastructureAttempts,
2340
2303
  provider: input.provider,
2341
2304
  workspaceRef: input.workspaceRef,
2342
- instance:
2343
- execution?.type === 'execution.started'
2344
- ? execution.payload.instance
2345
- : 'dispatcher-recovery',
2346
- ...(execution?.type === 'execution.started' && execution.payload.environmentId !== undefined
2347
- ? { environmentId: execution.payload.environmentId }
2305
+ instance: execution !== undefined ? execution.instance : 'dispatcher-recovery',
2306
+ ...(execution !== undefined && execution.environmentId !== undefined
2307
+ ? { environmentId: execution.environmentId }
2348
2308
  : {}),
2349
- ...(execution?.type === 'execution.started' && execution.payload.sessionId !== undefined
2350
- ? { sessionId: execution.payload.sessionId }
2309
+ ...(execution !== undefined && execution.sessionId !== undefined
2310
+ ? { sessionId: execution.sessionId }
2351
2311
  : {}),
2352
2312
  operation: input.operation,
2353
2313
  error: input.error,
2354
2314
  cleanupPending: input.cleanupPending,
2355
2315
  },
2356
2316
  )
2357
- events.push(...appended)
2317
+ Object.assign(events, (await readBuildFacts(this.store, slug)).value)
2358
2318
  }
2359
2319
 
2360
2320
  /** The provider that owns a recorded workspace: the one named on its
@@ -2370,7 +2330,7 @@ export class Dispatcher {
2370
2330
  * only after exact-name cleanup is durably acknowledged. */
2371
2331
  private async reapStaleWorkspace(
2372
2332
  slug: string,
2373
- events: AbEvent[],
2333
+ events: CurrentBuildFacts,
2374
2334
  reason: 'pause' | 'blocked' | 'replacement',
2375
2335
  ): Promise<boolean> {
2376
2336
  const open = openWorkspace(events)
@@ -2381,18 +2341,13 @@ export class Dispatcher {
2381
2341
  // publication's fate BEFORE the destructive reap. The guard's append
2382
2342
  // failure propagates — a stale workspace with an unrecorded pending
2383
2343
  // request is never reaped (the next tick retries).
2384
- events = await settlePublicationBeforeRelease(
2344
+ events = await settlePublicationFactsBeforeRelease(
2385
2345
  { store: this.store, settlePublication: this.settlePublication },
2386
2346
  slug,
2387
2347
  events,
2388
2348
  reason,
2389
2349
  )
2390
- const attempt =
2391
- events.filter(
2392
- (event) =>
2393
- event.type === 'infrastructure.cleanup-attempted' &&
2394
- event.payload.workspaceRef === open.ref,
2395
- ).length + 1
2350
+ const attempt = (events.cleanupCounts[JSON.stringify(open.ref)] ?? 0) + 1
2396
2351
  try {
2397
2352
  const outcome = await recovery.reap({
2398
2353
  provider: open.provider,
@@ -2401,7 +2356,7 @@ export class Dispatcher {
2401
2356
  ...(open.localPath !== undefined ? { localPath: open.localPath } : {}),
2402
2357
  branch: open.branch,
2403
2358
  })
2404
- const cleaned = await this.store.append(slug, {
2359
+ const _cleaned = await this.store.append(slug, {
2405
2360
  actor: DISPATCHER,
2406
2361
  type: 'infrastructure.cleanup-attempted',
2407
2362
  payload: {
@@ -2413,17 +2368,17 @@ export class Dispatcher {
2413
2368
  snapshots: outcome.snapshots,
2414
2369
  },
2415
2370
  })
2416
- events.push(cleaned)
2417
- const released = await this.store.append(slug, {
2371
+ Object.assign(events, (await readBuildFacts(this.store, slug)).value)
2372
+ const _released = await this.store.append(slug, {
2418
2373
  actor: DISPATCHER,
2419
2374
  type: 'workspace.released',
2420
2375
  payload: { ref: open.ref, reason },
2421
2376
  })
2422
- events.push(released)
2377
+ Object.assign(events, (await readBuildFacts(this.store, slug)).value)
2423
2378
  return true
2424
2379
  } catch (error) {
2425
2380
  const message = error instanceof Error ? error.message : String(error)
2426
- const failed = await this.store.append(slug, {
2381
+ const _failed = await this.store.append(slug, {
2427
2382
  actor: DISPATCHER,
2428
2383
  type: 'infrastructure.cleanup-attempted',
2429
2384
  payload: {
@@ -2435,7 +2390,7 @@ export class Dispatcher {
2435
2390
  error: message || 'cleanup failed without an error message',
2436
2391
  },
2437
2392
  })
2438
- events.push(failed)
2393
+ Object.assign(events, (await readBuildFacts(this.store, slug)).value)
2439
2394
  await this.recordInfrastructureFailure(slug, events, {
2440
2395
  provider: open.provider,
2441
2396
  workspaceRef: open.ref,
@@ -2451,7 +2406,7 @@ export class Dispatcher {
2451
2406
  * append `workspace.released`. Log-deduped, so re-runs are no-ops. */
2452
2407
  private async releaseWorkspace(
2453
2408
  slug: string,
2454
- events: AbEvent[],
2409
+ events: CurrentBuildFacts,
2455
2410
  reason: 'completion' | 'abort' | 'pause' | 'blocked' | 'replacement' | 'discard' = 'completion',
2456
2411
  ): Promise<AbEvent | undefined> {
2457
2412
  const open = openWorkspace(events)
@@ -2470,7 +2425,7 @@ export class Dispatcher {
2470
2425
  // pending publication BEFORE any provider cleanup call, while the
2471
2426
  // workspace still exists. The guard's append failure propagates — the
2472
2427
  // release never proceeds past an unrecorded pending request.
2473
- events = await settlePublicationBeforeRelease(
2428
+ events = await settlePublicationFactsBeforeRelease(
2474
2429
  { store: this.store, settlePublication: this.settlePublication },
2475
2430
  slug,
2476
2431
  events,
@@ -2478,15 +2433,10 @@ export class Dispatcher {
2478
2433
  )
2479
2434
  const recovery = owner.recovery
2480
2435
  if (recovery !== undefined) {
2481
- const attempt =
2482
- events.filter(
2483
- (event) =>
2484
- event.type === 'infrastructure.cleanup-attempted' &&
2485
- event.payload.workspaceRef === open.ref,
2486
- ).length + 1
2436
+ const attempt = (events.cleanupCounts[JSON.stringify(open.ref)] ?? 0) + 1
2487
2437
  try {
2488
2438
  const outcome = await recovery.reap(handle)
2489
- const cleaned = await this.store.append(slug, {
2439
+ const _cleaned = await this.store.append(slug, {
2490
2440
  actor: DISPATCHER,
2491
2441
  type: 'infrastructure.cleanup-attempted',
2492
2442
  payload: {
@@ -2498,10 +2448,10 @@ export class Dispatcher {
2498
2448
  snapshots: outcome.snapshots,
2499
2449
  },
2500
2450
  })
2501
- events.push(cleaned)
2451
+ Object.assign(events, (await readBuildFacts(this.store, slug)).value)
2502
2452
  } catch (error) {
2503
2453
  const message = error instanceof Error ? error.message : String(error)
2504
- const failed = await this.store.append(slug, {
2454
+ const _failed = await this.store.append(slug, {
2505
2455
  actor: DISPATCHER,
2506
2456
  type: 'infrastructure.cleanup-attempted',
2507
2457
  payload: {
@@ -2513,7 +2463,7 @@ export class Dispatcher {
2513
2463
  error: message || 'cleanup failed without an error message',
2514
2464
  },
2515
2465
  })
2516
- events.push(failed)
2466
+ Object.assign(events, (await readBuildFacts(this.store, slug)).value)
2517
2467
  await this.recordInfrastructureFailure(slug, events, {
2518
2468
  provider: open.provider,
2519
2469
  workspaceRef: open.ref,
@@ -2639,9 +2589,9 @@ export class Dispatcher {
2639
2589
  if (!(await this.dispatchBudget(record.slug))) return 'parked'
2640
2590
  let stage: EventPayload<'dispatch.failed'>['stage'] = 'create'
2641
2591
  try {
2642
- let events = await store.getEvents(record.slug)
2643
- if (!events.some((event) => event.type === 'build.created')) {
2644
- if (events.length !== 0 || record.ticket === undefined) {
2592
+ let events = (await readBuildFacts(store, record.slug)).value
2593
+ if (events.created === undefined) {
2594
+ if (events.state.lastSeq !== 0 || record.ticket === undefined) {
2645
2595
  throw new Error(
2646
2596
  'build record lacks the immutable ticket facts needed to reconstruct build.created',
2647
2597
  )
@@ -2664,22 +2614,19 @@ export class Dispatcher {
2664
2614
  type: 'build.created',
2665
2615
  payload: this.buildCreatedPayload(record.ticket, record.repo, { ...seed, assets }),
2666
2616
  })
2667
- events = await store.getEvents(record.slug)
2668
- if (!events.some((event) => event.type === 'build.created')) {
2617
+ events = (await readBuildFacts(store, record.slug)).value
2618
+ if (events.created === undefined) {
2669
2619
  throw new Error('build.created reconstruction lost its conditional append race')
2670
2620
  }
2671
2621
  }
2672
2622
 
2673
- const created = events.find((event) => event.type === 'build.created')
2623
+ const created = events.created
2674
2624
  const autoMergeUser = seed?.autoMergeUser ?? created?.payload.autoMergeRequestedBy
2675
2625
  // The claim's sampled default fact, for the request's fan-out provenance:
2676
2626
  // the seed's own value first (this dispatch created the build), else the
2677
2627
  // durable `build.created` fact a recovery pass resumes from.
2678
2628
  const autoMergeDefaultSeq = seed?.autoMergeDefaultSeq ?? created?.payload.autoMergeDefaultSeq
2679
- if (
2680
- autoMergeUser !== undefined &&
2681
- !events.some((event) => event.type === 'build.auto-merge-requested')
2682
- ) {
2629
+ if (autoMergeUser !== undefined && !events.autoMergeEverRequested) {
2683
2630
  await store.append(record.slug, {
2684
2631
  actor: humanActor(autoMergeUser),
2685
2632
  type: 'build.auto-merge-requested',
@@ -2687,7 +2634,7 @@ export class Dispatcher {
2687
2634
  ...(autoMergeDefaultSeq !== undefined ? { defaultSeq: autoMergeDefaultSeq } : {}),
2688
2635
  },
2689
2636
  })
2690
- events = await store.getEvents(record.slug)
2637
+ events = (await readBuildFacts(store, record.slug)).value
2691
2638
  }
2692
2639
 
2693
2640
  stage = 'workspace'
@@ -2702,9 +2649,7 @@ export class Dispatcher {
2702
2649
  launched.add(record.slug)
2703
2650
  return 'deferred'
2704
2651
  }
2705
- const priorGenerations = events.filter(
2706
- (event) => event.type === 'workspace.provisioned',
2707
- ).length
2652
+ const priorGenerations = events.workspaceGenerations
2708
2653
  const handle = await this.deps.workspaces.provision({
2709
2654
  // Local providers provision FROM the physical checkout; remote
2710
2655
  // providers ignore the value (their host seams are injected).
@@ -2731,10 +2676,10 @@ export class Dispatcher {
2731
2676
  base: handle.base,
2732
2677
  },
2733
2678
  } satisfies EventWrite<'workspace.provisioned'>)
2734
- events = await store.getEvents(record.slug)
2679
+ events = (await readBuildFacts(store, record.slug)).value
2735
2680
  }
2736
2681
  } catch (error) {
2737
- const events = await store.getEvents(record.slug)
2682
+ const events = (await readBuildFacts(store, record.slug)).value
2738
2683
  if (stage === 'workspace' && this.deps.workspaces.recovery !== undefined) {
2739
2684
  const open = openWorkspace(events)
2740
2685
  await this.recordInfrastructureFailure(record.slug, events, {
@@ -2745,7 +2690,7 @@ export class Dispatcher {
2745
2690
  cleanupPending: true,
2746
2691
  })
2747
2692
  }
2748
- const attempt = events.filter((event) => event.type === 'dispatch.failed').length + 1
2693
+ const attempt = events.dispatchFailureCount + 1
2749
2694
  const message = (error instanceof Error ? error.message : String(error)).trim()
2750
2695
  await store.append(record.slug, {
2751
2696
  actor: DISPATCHER,
@@ -2785,12 +2730,10 @@ export class Dispatcher {
2785
2730
  let stage: EventPayload<'dispatch.failed'>['stage'] = 'spec'
2786
2731
  let authored = false
2787
2732
  try {
2788
- const events = await store.getEvents(record.slug)
2789
- const state = reduceBuild(events)
2733
+ const events = (await readBuildFacts(store, record.slug)).value
2734
+ const state = events.state
2790
2735
  if (state.status !== 'queued' || state.discardRequest !== undefined) return 'parked'
2791
- if (
2792
- !events.some((event) => event.type === 'spec.imported' || event.type === 'spec.authored')
2793
- ) {
2736
+ if (!events.specRecorded) {
2794
2737
  let ticket: Ticket
2795
2738
  if (seed?.ticket !== undefined) {
2796
2739
  ticket = seed.ticket
@@ -2848,10 +2791,7 @@ export class Dispatcher {
2848
2791
  }
2849
2792
 
2850
2793
  stage = 'comment'
2851
- if (
2852
- record.ticket !== undefined &&
2853
- !events.some((event) => event.type === 'dispatch.comment-posted')
2854
- ) {
2794
+ if (record.ticket !== undefined && !events.commentPosted) {
2855
2795
  await this.deps.tickets.comment(record.ticket.id, `build ${record.slug} dispatched`)
2856
2796
  await store.append(record.slug, {
2857
2797
  actor: DISPATCHER,
@@ -2865,7 +2805,7 @@ export class Dispatcher {
2865
2805
  // provider awaits. Re-read immediately before launch so a request made
2866
2806
  // during those boundaries parks cleanly for the next janitor pass.
2867
2807
  await store.refreshBuild(record.slug)
2868
- const launchState = reduceBuild(await store.getEvents(record.slug))
2808
+ const launchState = (await readBuildFacts(store, record.slug)).value.state
2869
2809
  if (launchState.status !== 'queued' || launchState.discardRequest !== undefined) {
2870
2810
  return 'parked'
2871
2811
  }
@@ -2874,8 +2814,8 @@ export class Dispatcher {
2874
2814
  if (authored && report !== undefined) report.authored += 1
2875
2815
  return 'completed'
2876
2816
  } catch (error) {
2877
- const events = await store.getEvents(record.slug)
2878
- const attempt = events.filter((event) => event.type === 'dispatch.failed').length + 1
2817
+ const events = (await readBuildFacts(store, record.slug)).value
2818
+ const attempt = events.dispatchFailureCount + 1
2879
2819
  const message = (error instanceof Error ? error.message : String(error)).trim()
2880
2820
  await store.append(record.slug, {
2881
2821
  actor: DISPATCHER,
@@ -3050,9 +2990,9 @@ export class Dispatcher {
3050
2990
  if (decision === undefined) continue
3051
2991
  if (state.status === 'done' || state.status === 'aborted') continue
3052
2992
  // Raw history, read at most once and only by a branch that acts on it.
3053
- let loaded: AbEvent[] | undefined
3054
- const history = async (): Promise<AbEvent[]> => {
3055
- loaded ??= await this.store.getEvents(record.slug)
2993
+ let loaded: CurrentBuildFacts | undefined
2994
+ const history = async (): Promise<CurrentBuildFacts> => {
2995
+ loaded ??= (await readBuildFacts(this.store, record.slug)).value
3056
2996
  return loaded
3057
2997
  }
3058
2998
  // Liveness gate (durable supervision): a recorded execution the provider
@@ -3136,7 +3076,7 @@ export class Dispatcher {
3136
3076
  if (
3137
3077
  facts.workspace === null &&
3138
3078
  this.deps.workspaces.recovery !== undefined &&
3139
- (await history()).some((event) => event.type === 'workspace.provisioned')
3079
+ (await history()).workspaceGenerations > 0
3140
3080
  ) {
3141
3081
  const kicked = await this.ensureProvisionContinuation(record, await history(), 'launch')
3142
3082
  if (kicked === 'kicked') {
@@ -3190,16 +3130,16 @@ export class Dispatcher {
3190
3130
  // dispatcher is saturated. Sources that keep a claimed ticket in the
3191
3131
  // ready state are deduped against the active builds embodying them.
3192
3132
  await this.store.refreshJournal()
3193
- const journalBeforeReady = this.store.recordedJournal()
3133
+ const journalBeforeReady = this.store.repositoryFacts().harvest
3194
3134
  const listing = await this.listReady()
3195
3135
  const activeTicketIds = await this.activeTicketIdentities(activeRefs, listing.tickets)
3196
3136
  await this.store.refreshJournal()
3197
- const journalAfterReady = this.store.recordedJournal()
3198
- if (reduceTicketSourceHealth(journalAfterReady)?.cause === 'ready-scan-failed') {
3137
+ const journalAfterReady = this.store.repositoryFacts()
3138
+ if (journalAfterReady.health?.cause === 'ready-scan-failed') {
3199
3139
  await this.sourceCondition(undefined, 'ready-scan')
3200
3140
  }
3201
3141
  const creationHolds = new Map(
3202
- harvestCreationsDuringReadyScan(journalBeforeReady, journalAfterReady).map((creation) => [
3142
+ harvestCreationsFromStates(journalBeforeReady, journalAfterReady.harvest).map((creation) => [
3203
3143
  creation.creationKey.toLowerCase(),
3204
3144
  creation,
3205
3145
  ]),