@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,30 +1,30 @@
1
+ import { readRepositoryFacts, readDispatcherRunFacts } from '../store/reduced-reads'
1
2
  import type { RepositoryEvent } from '../events/repository'
2
3
  import type { BuildStore, RepositoryRecord } from '../store/types'
3
4
  import { formatDispatcherIdentity, projectDispatcherLiveness } from '../kernel/dispatcher-liveness'
4
5
 
5
- /** Bounded first; a later yielding invocation can hide the owner's identity or
6
- * an earlier stop. Only this correlation query falls back to full history. */
6
+ /** Correlate only the latest, stopped and lease-holder runs through persisted values. */
7
7
  export async function readDispatcherLiveness(
8
8
  store: BuildStore,
9
9
  repo: string,
10
10
  now = new Date(),
11
- subset?: RepositoryEvent[],
11
+ _subset?: RepositoryEvent[],
12
12
  ) {
13
13
  const record = await store.getRepo(repo)
14
- let events = record === null ? [] : (subset ?? (await store.getRepoStateEvents(repo)))
15
- const bounded = projectDispatcherLiveness(record, events, now)
16
- if (
17
- record &&
18
- ((record.lease &&
19
- !events.some(
20
- (e) => e.type === 'dispatcher.run-started' && e.payload.run === record.lease!.holder,
21
- )) ||
22
- (bounded.state !== 'held' &&
23
- events.some((e) => e.type === 'dispatcher.run-started') &&
24
- !events.some((e) => e.type === 'dispatcher.run-stopped')))
25
- ) {
26
- events = await store.getRepoEvents(repo)
27
- }
14
+ const facts = record === null ? undefined : (await readRepositoryFacts(store, repo)).value
15
+ const runs = new Set(
16
+ [facts?.latestRun, facts?.latestStoppedRun, record?.lease?.holder].filter(
17
+ (run): run is string => run !== undefined,
18
+ ),
19
+ )
20
+ const correlations = await Promise.all(
21
+ [...runs].map(async (run) => (await readDispatcherRunFacts(store, repo, run)).value),
22
+ )
23
+ const events = correlations.flatMap((run) =>
24
+ [run.start, run.latestStart, run.stop, run.tick].filter(
25
+ (event): event is RepositoryEvent => event !== undefined,
26
+ ),
27
+ )
28
28
  return { record, events, dispatcher: projectDispatcherLiveness(record, events, now) }
29
29
  }
30
30
 
@@ -39,6 +39,10 @@ export class StandbyIdentityResolver {
39
39
  constructor(
40
40
  private readonly store: BuildStore,
41
41
  private readonly repo: string,
42
+ private readonly history?: {
43
+ allowLookup: () => boolean
44
+ remember: (events: RepositoryEvent[], complete: boolean) => void
45
+ },
42
46
  ) {}
43
47
 
44
48
  ingest(events: readonly RepositoryEvent[]): void {
@@ -51,11 +55,14 @@ export class StandbyIdentityResolver {
51
55
  const pending = this.pending.get(holder)
52
56
  if (pending !== undefined) return pending
53
57
  if (this.starts.has(holder) || this.attempted.has(holder)) return
58
+ if (this.history && !this.history.allowLookup()) return
54
59
  this.attempted.add(holder)
55
60
  const lookup = (async () => {
56
61
  try {
57
- this.ingest(await this.store.getRepoStateEvents(this.repo))
58
- if (!this.starts.has(holder)) this.ingest(await this.store.getRepoEvents(this.repo, 0))
62
+ const facts = (await readDispatcherRunFacts(this.store, this.repo, holder)).value
63
+ this.ingest(facts.start ? [facts.start] : [])
64
+ const complete = false // This lookup correlates one holder, not the complete journal.
65
+ this.history?.remember([...this.starts.values()], complete)
59
66
  } catch {
60
67
  // Identity is best effort; the sampled holder id remains sufficient.
61
68
  }
@@ -1,3 +1,4 @@
1
+ import { readRepositoryFacts, type RepositoryReadFacts } from '../store/reduced-reads'
1
2
  /** Typed agent/operator surface for repository-scoped harvest workflows. Agent
2
3
  * stdout is never interpreted: proposal sets and review verdicts land through
3
4
  * this module as validated repository events and versioned artifacts. */
@@ -67,8 +68,8 @@ async function writeJson(path: string, value: unknown): Promise<void> {
67
68
  await writeFile(path, `${JSON.stringify(value, null, 2)}\n`)
68
69
  }
69
70
 
70
- function runForEnv(events: RepositoryEvent[], env: HarvestCliEnv): HarvestRunState {
71
- const run = reduceHarvest(events).runs.find((candidate) => candidate.run === env.run)
71
+ function runForEnv(events: RepositoryReadFacts, env: HarvestCliEnv): HarvestRunState {
72
+ const run = events.harvest.runs.find((candidate) => candidate.run === env.run)
72
73
  if (!run) throw new Error(`unknown harvest run "${env.run}" in repo "${env.repo}"`)
73
74
  if (run.status !== 'running') {
74
75
  throw new Error(
@@ -78,34 +79,44 @@ function runForEnv(events: RepositoryEvent[], env: HarvestCliEnv): HarvestRunSta
78
79
  return run
79
80
  }
80
81
 
81
- function assertNoSessionTerminal(events: RepositoryEvent[], env: HarvestCliEnv): void {
82
- for (const event of events) {
83
- if (
84
- event.type === 'harvest.proposals.submitted' &&
85
- env.phase === 'synthesize' &&
86
- event.payload.run === env.run &&
87
- event.payload.round === env.round
88
- ) {
89
- throw new Error(
90
- `second harvest terminal rejected: proposals for ${env.run} synthesize@${env.round} already landed at repo seq ${event.seq}`,
91
- )
92
- }
93
- if (
94
- event.type === 'harvest.review.verdict' &&
95
- env.phase === 'review' &&
96
- event.payload.run === env.run &&
97
- event.payload.round === env.round
98
- ) {
99
- throw new Error(
100
- `second harvest terminal rejected: verdict for ${env.run} review@${env.round} already landed at repo seq ${event.seq}`,
101
- )
102
- }
103
- if (event.type === 'harvest.session.ended' && event.payload.session === env.session) {
104
- throw new Error(
105
- `harvest terminal rejected: session "${env.session}" already ended at repo seq ${event.seq}`,
106
- )
107
- }
108
- }
82
+ function assertNoSessionTerminal(events: RepositoryReadFacts, env: HarvestCliEnv): void {
83
+ const run = events.harvest.runs.find((run) => run.run === env.run)
84
+ const proposal =
85
+ env.phase === 'synthesize'
86
+ ? run?.proposals.find((proposal) => proposal.round === env.round)
87
+ : undefined
88
+ const review =
89
+ env.phase === 'review'
90
+ ? events.harvestVerdicts[JSON.stringify([env.run, env.round])]?.first
91
+ : undefined
92
+ const ended = events.harvestSessionEndings[JSON.stringify(env.session)]
93
+ const errors = [
94
+ ...(proposal
95
+ ? [
96
+ {
97
+ seq: proposal.seq,
98
+ message: `second harvest terminal rejected: proposals for ${env.run} synthesize@${env.round} already landed at repo seq ${proposal.seq}`,
99
+ },
100
+ ]
101
+ : []),
102
+ ...(review
103
+ ? [
104
+ {
105
+ seq: review.seq,
106
+ message: `second harvest terminal rejected: verdict for ${env.run} review@${env.round} already landed at repo seq ${review.seq}`,
107
+ },
108
+ ]
109
+ : []),
110
+ ...(ended
111
+ ? [
112
+ {
113
+ seq: ended,
114
+ message: `harvest terminal rejected: session "${env.session}" already ended at repo seq ${ended}`,
115
+ },
116
+ ]
117
+ : []),
118
+ ].sort((a, b) => a.seq - b.seq)
119
+ if (errors[0]) throw new Error(errors[0].message)
109
120
  }
110
121
 
111
122
  async function readArtifactJson(
@@ -122,7 +133,7 @@ async function readArtifactJson(
122
133
  export async function buildHarvestContext(deps: HarvestCliDeps): Promise<HarvestContextManifest> {
123
134
  const { store, env, workspacePath } = deps
124
135
  // Bounded read (AUT-489): runForEnv reduces harvest facts only.
125
- const events = await store.getRepoStateEvents(env.repo)
136
+ const events = (await readRepositoryFacts(store, env.repo)).value
126
137
  const run = runForEnv(events, env)
127
138
  const packet = await loadScanPacket(store, env.repo, run.scan)
128
139
  const abDir = join(workspacePath, '.ab')
@@ -230,7 +241,7 @@ export async function submitHarvestProposals(
230
241
  }
231
242
  // Bounded read (AUT-489): runForEnv and assertNoSessionTerminal consume
232
243
  // harvest facts only.
233
- const events = await store.getRepoStateEvents(env.repo)
244
+ const events = (await readRepositoryFacts(store, env.repo)).value
234
245
  const run = runForEnv(events, env)
235
246
  assertNoSessionTerminal(events, env)
236
247
 
@@ -322,7 +333,7 @@ export async function submitHarvestVerdict(
322
333
  }
323
334
  // Bounded read (AUT-489): runForEnv and assertNoSessionTerminal consume
324
335
  // harvest facts only.
325
- const events = await store.getRepoStateEvents(env.repo)
336
+ const events = (await readRepositoryFacts(store, env.repo)).value
326
337
  const run = runForEnv(events, env)
327
338
  assertNoSessionTerminal(events, env)
328
339
  if (proposalArtifactForRound(run, env.round) === undefined) {
@@ -602,19 +613,35 @@ function projectHarvestRunStatus(run: HarvestRunState): Omit<HarvestRunStatusVie
602
613
 
603
614
  export function projectHarvestStatus(
604
615
  repo: string,
605
- events: RepositoryEvent[],
616
+ events: RepositoryEvent[] | RepositoryReadFacts,
606
617
  newestEvents?: number,
607
618
  streams?: readonly StreamRecord[],
608
619
  ): HarvestStatusView {
609
- const state = reduceHarvest(events)
620
+ const state = Array.isArray(events) ? reduceHarvest(events) : events.harvest
610
621
  const history =
611
- newestEvents === undefined ? undefined : events.filter(isHarvestEvent).slice(-newestEvents)
622
+ newestEvents === undefined || !Array.isArray(events)
623
+ ? undefined
624
+ : events.filter(isHarvestEvent).slice(-newestEvents)
612
625
  const unresolved = [...parkedHarvestRuns(state), ...unresolvedHarvestAttentionRuns(state)]
613
626
  const open = openHarvestRuns(state)
614
627
  const included = new Set(
615
628
  [...unresolved, ...open, ...(state.latest ? [state.latest] : [])].map((run) => run.run),
616
629
  )
617
- const sessionsByRun = projectHarvestSessions(events, streams)
630
+ const sessionsByRun = Array.isArray(events)
631
+ ? projectHarvestSessions(events, streams)
632
+ : new Map<string, HarvestSessionStatusView[]>()
633
+ if (!Array.isArray(events))
634
+ for (const { run, ...session } of events.harvestSessions) {
635
+ const record = streams?.find(
636
+ (record) => record.scope.kind === 'repo' && record.label === `session:${session.session}`,
637
+ )
638
+ const list = sessionsByRun.get(run) ?? []
639
+ list.push({
640
+ ...session,
641
+ ...(record ? { stream: record.id, streamStatus: record.status } : {}),
642
+ })
643
+ sessionsByRun.set(run, list)
644
+ }
618
645
  const runs = state.runs
619
646
  .filter((run) => included.has(run.run))
620
647
  .map((run) => ({
@@ -802,7 +829,7 @@ export async function abHarvestStatus(opts: HarvestStatusOpts): Promise<void> {
802
829
  async ({ store, repo }) => {
803
830
  const record = await store.getRepo(repo)
804
831
  // Bounded read (AUT-489): projectHarvestStatus reduces harvest facts only.
805
- const events = record === null ? [] : await store.getRepoStateEvents(repo)
832
+ const events = (await readRepositoryFacts(store, repo)).value
806
833
  // Best-effort stream enrichment for the session rows, mirroring the
807
834
  // degrade-gracefully pattern of 'ab build status': a store that cannot
808
835
  // list streams keeps the payload-derived projection instead of failing.
@@ -814,7 +841,12 @@ export async function abHarvestStatus(opts: HarvestStatusOpts): Promise<void> {
814
841
  streams = undefined
815
842
  }
816
843
  }
817
- const view = projectHarvestStatus(repo, events, opts.events, streams)
844
+ const view = projectHarvestStatus(repo, events, undefined, streams)
845
+ if (opts.events !== undefined)
846
+ view.events =
847
+ record === null
848
+ ? []
849
+ : (await store.getRepoEvents(repo)).filter(isHarvestEvent).slice(-opts.events)
818
850
  if (opts.json === true) opts.stdout(JSON.stringify(view, null, 2))
819
851
  else for (const line of renderHarvestStatus(view)) opts.stdout(line)
820
852
  },
@@ -0,0 +1,27 @@
1
+ import type { BuildStore } from '../store/types'
2
+ import { buildStreamSeedReducer, type BuildStreamSeed } from '../kernel/read-facts'
3
+
4
+ /** Only explicit old --since cursors need historical reconstruction. Ordinary
5
+ * registration and the first event batch must use the persisted seed. */
6
+ export async function historicalBuildState(
7
+ store: BuildStore,
8
+ slug: string,
9
+ requested: number,
10
+ current: { cursor: number; value: BuildStreamSeed },
11
+ ) {
12
+ if (requested >= current.cursor) return current.value
13
+ const events = await store.getEvents(slug)
14
+ return buildStreamSeedReducer.reduce(events.filter((event) => event.seq <= requested))
15
+ }
16
+
17
+ import { repositoryAttentionReducer } from '../kernel/repository-attention'
18
+ export async function historicalRepositoryAttention(
19
+ store: BuildStore,
20
+ repo: string,
21
+ requested: number,
22
+ current: { cursor: number; value: ReturnType<typeof repositoryAttentionReducer.finish> },
23
+ ) {
24
+ if (requested >= current.cursor) return current.value
25
+ const events = await store.getRepoEvents(repo)
26
+ return repositoryAttentionReducer.reduce(events.filter((event) => event.seq <= requested))
27
+ }
@@ -1,3 +1,4 @@
1
+ import { readBuildFacts } from '../store/reduced-reads'
1
2
  import { preparePinnedAssets } from '../store/ticket-assets'
2
3
  import type { PinnedAsset } from '../events/payloads'
3
4
  import { abConnect, abDisconnect } from './connect'
@@ -21,7 +22,6 @@ import { textContent } from '../store/types'
21
22
  import type { Forge } from '../ports/types'
22
23
  import { spawnExec, type Exec } from '../ports/workspace/git-worktree'
23
24
  import type { IdSource } from '../ids'
24
- import { reduceBuild } from '../kernel/reducer'
25
25
  import { artifactDownload, artifactDownloadStream, artifactGet, artifactPut } from './artifact'
26
26
  import { multiFlag, parseArgs, stringFlag, type ParsedArgs } from './args'
27
27
  import { abBuildControl, type BuildControlAction, type BuildControlResult } from './build-control'
@@ -1372,8 +1372,8 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
1372
1372
  // external side effect is best-effort after the atomic designation.
1373
1373
  if (attach) {
1374
1374
  try {
1375
- const events = await session.store.getEvents(session.env.build)
1376
- const pr = reduceBuild(events).pr
1375
+ const events = (await readBuildFacts(session.store, session.env.build)).value
1376
+ const pr = events.state.pr
1377
1377
  if (pr !== undefined) {
1378
1378
  try {
1379
1379
  await preparePrAttachments(session, events, pr.url)
@@ -1,11 +1,8 @@
1
- import type { AbEvent, EventEnvelope } from '../events/catalog'
1
+ import type { CurrentBuildFacts } from '../store/reduced-reads'
2
+ import type { EventEnvelope } from '../events/catalog'
2
3
  import { KERNEL } from '../events/envelope'
3
4
  import type { IdSource } from '../ids'
4
- import {
5
- currentPrAttachments,
6
- frozenPrImageHost,
7
- hostedPrAttachments,
8
- } from '../kernel/pr-attachments'
5
+ import type { currentPrAttachments } from '../kernel/pr-attachments'
9
6
  import { hostedPrAttachmentAssetSchema, type HostedPrAttachmentAsset } from '../ontology'
10
7
  import type { Forge } from '../ports/types'
11
8
  import type { BuildStore } from '../store/types'
@@ -25,7 +22,7 @@ function errorMessage(error: unknown): string {
25
22
 
26
23
  async function recordHostingFailure(
27
24
  deps: PrAttachmentDeps,
28
- events: AbEvent[],
25
+ events: CurrentBuildFacts,
29
26
  designation: ReturnType<typeof currentPrAttachments>[number],
30
27
  error: unknown,
31
28
  extraRefs: string[] = [],
@@ -44,7 +41,7 @@ async function recordHostingFailure(
44
41
  refs: [`${artifact.kind}@${artifact.rev}`, ...extraRefs],
45
42
  },
46
43
  })
47
- events.push(observed)
44
+ events.observations.push(observed.payload)
48
45
  } catch {
49
46
  // Hosting is a best-effort review affordance. A secondary failure to
50
47
  // record its follow-up must not hide the complete text projection or turn
@@ -61,14 +58,14 @@ async function recordHostingFailure(
61
58
  */
62
59
  export async function preparePrAttachments(
63
60
  deps: PrAttachmentDeps,
64
- events: AbEvent[],
61
+ events: CurrentBuildFacts,
65
62
  prUrl: string,
66
63
  ): Promise<void> {
67
- const target = frozenPrImageHost(events)
64
+ const target = events.imageHost
68
65
  if (target === undefined) return
69
66
 
70
- const designations = currentPrAttachments(events)
71
- const hosted = hostedPrAttachments(events, designations)
67
+ const designations = events.designations
68
+ const hosted = new Map(events.hostedImages)
72
69
  for (const designation of designations) {
73
70
  if (!designation.payload.mediaType.startsWith('image/')) continue
74
71
  if (hosted.has(designation.seq)) continue
@@ -132,7 +129,7 @@ export async function preparePrAttachments(
132
129
  )
133
130
  throw error
134
131
  }
135
- events.push(event)
132
+ events.hostedImages.push([designation.seq, event])
136
133
  hosted.set(designation.seq, event)
137
134
  }
138
135
  }
@@ -1,11 +1,9 @@
1
- import { summaryBoundary, pendingSummaryRequests } from '../kernel/pr-summary'
1
+ import { readBuildFacts, type CurrentBuildFacts } from '../store/reduced-reads'
2
2
  export { summaryBoundary, pendingSummaryRequests } from '../kernel/pr-summary'
3
3
  import type { AbEvent } from '../events/catalog'
4
4
  import { KERNEL } from '../events/envelope'
5
5
  import { isRemoteWorkspace } from '../events/workspace-remote'
6
- import { reduceBuild } from '../kernel/reducer'
7
6
  import { currentPrAttachments } from '../kernel/pr-attachments'
8
- import { openExecution } from '../processes/execution-settlement'
9
7
  import type { Forge } from '../ports/types'
10
8
  import { githubRateLimitUntil } from '../ports/forge/github-transport'
11
9
  import { summaryMarker } from '../ports/forge/summary-marker'
@@ -27,7 +25,7 @@ interface SummaryDeps {
27
25
  * is deliberately not designated, avoiding recursive growth. */
28
26
  export async function boundedSummary(
29
27
  deps: SummaryDeps,
30
- events: readonly AbEvent[],
28
+ events: readonly AbEvent[] | CurrentBuildFacts,
31
29
  ): Promise<string> {
32
30
  const env = {
33
31
  build: deps.slug,
@@ -48,7 +46,7 @@ export async function boundedSummary(
48
46
  summaryMarker(deps.slug),
49
47
  `## Autobuild: ${html(deps.slug)}`,
50
48
  '',
51
- `The full summary exceeds the comment limit. It includes ${currentPrAttachments(events).length} PR attachments.`,
49
+ `The full summary exceeds the comment limit. It includes ${(Array.isArray(events) ? currentPrAttachments(events) : (events as CurrentBuildFacts).designations).length} PR attachments.`,
52
50
  `Store: <code>${html(deps.storeRef)}</code>`,
53
51
  '',
54
52
  'Download the complete summary, including all pinned artifact commands and hosted links:',
@@ -65,26 +63,16 @@ export async function publishSummary(
65
63
  boundary: { occurrence: number; boundary: number },
66
64
  ): Promise<'attempted' | 'skipped' | 'deferred'> {
67
65
  try {
68
- const events = await deps.store.getEvents(deps.slug)
69
- const pr = reduceBuild(events).pr
66
+ const events = (await readBuildFacts(deps.store, deps.slug)).value
67
+ const pr = events.state.pr
70
68
  if (pr === undefined) return 'skipped'
69
+ if (events.summaryAttempts.includes(boundary.occurrence)) return 'attempted'
71
70
  if (
72
- events.some(
73
- (event) =>
74
- event.type === 'pr-summary.attempted' && event.payload.occurrence === boundary.occurrence,
75
- )
76
- )
77
- return 'attempted'
78
- if (
79
- events.some(
80
- (event) =>
81
- event.type === 'pr-summary.result' &&
82
- event.payload.eligibleAfter !== undefined &&
83
- Date.parse(event.payload.eligibleAfter) > deps.clock().getTime(),
84
- )
71
+ events.summaryEligibleAfter !== undefined &&
72
+ Date.parse(events.summaryEligibleAfter) > deps.clock().getTime()
85
73
  )
86
74
  return 'deferred'
87
- const claim = await deps.store.appendIfCurrent(deps.slug, events.at(-1)?.seq ?? 0, {
75
+ const claim = await deps.store.appendIfCurrent(deps.slug, events.state.lastSeq, {
88
76
  actor: KERNEL,
89
77
  type: 'pr-summary.attempted',
90
78
  payload: { ...boundary, pr: pr.number },
@@ -93,7 +81,7 @@ export async function publishSummary(
93
81
  let outcome: 'success' | 'failure' | 'rate-limited' = 'success'
94
82
  let eligibleAfter: string | undefined
95
83
  try {
96
- const body = await boundedSummary(deps, await deps.store.getEvents(deps.slug))
84
+ const body = await boundedSummary(deps, (await readBuildFacts(deps.store, deps.slug)).value)
97
85
  await deps.forge.commentOnPr(deps.workspacePath, pr.number, body)
98
86
  } catch (error) {
99
87
  eligibleAfter = githubRateLimitUntil(error, deps.clock().getTime())
@@ -118,45 +106,24 @@ export async function publishSummary(
118
106
  * prevent pipeline progress, including a failed request deposit. */
119
107
  export async function refreshSummaryBoundary(deps: SummaryDeps): Promise<boolean> {
120
108
  try {
121
- const events = await deps.store.getEvents(deps.slug)
122
- const boundary = summaryBoundary(events)
109
+ const events = (await readBuildFacts(deps.store, deps.slug)).value
110
+ const boundary = events.summaryBoundary
123
111
  if (boundary === undefined) return false
124
- const workspace = events.findLast((event) => event.type === 'workspace.provisioned')
112
+ const workspace = events.latestWorkspace
125
113
  if (workspace?.type === 'workspace.provisioned' && isRemoteWorkspace(workspace.payload)) {
126
- const request = events.find(
127
- (event) =>
128
- event.type === 'pr-summary.requested' && event.payload.occurrence === boundary.occurrence,
129
- )
114
+ const request = events.summaryRequested[String(boundary.occurrence)]
130
115
  if (request !== undefined) {
131
116
  // A lost result/disposition append must not repeatedly park a guest.
117
+ if (events.summaryAttempts.includes(boundary.occurrence)) return false
132
118
  if (
133
- events.some(
134
- (event) =>
135
- event.type === 'pr-summary.attempted' &&
136
- event.payload.occurrence === boundary.occurrence,
137
- )
138
- )
139
- return false
140
- if (
141
- events.some(
142
- (event) =>
143
- event.type === 'pr-summary.result' &&
144
- event.payload.eligibleAfter !== undefined &&
145
- Date.parse(event.payload.eligibleAfter) > deps.clock().getTime(),
146
- )
119
+ events.summaryEligibleAfter !== undefined &&
120
+ Date.parse(events.summaryEligibleAfter) > deps.clock().getTime()
147
121
  )
148
122
  return false
149
- return pendingSummaryRequests(events).some((event) => event.seq === request.seq)
123
+ return events.summaryRequests.some((event) => event.seq === request.seq)
150
124
  }
151
- if (
152
- reduceBuild(events).pr === undefined &&
153
- !events.some(
154
- (event) =>
155
- event.type === 'publication.requested' && event.payload.operation === 'finalize',
156
- )
157
- )
158
- return false
159
- const written = await deps.store.appendIfCurrent(deps.slug, events.at(-1)?.seq ?? 0, {
125
+ if (events.state.pr === undefined && !events.finalizePublicationRequested) return false
126
+ const written = await deps.store.appendIfCurrent(deps.slug, events.state.lastSeq, {
160
127
  actor: KERNEL,
161
128
  type: 'pr-summary.requested',
162
129
  payload: boundary,
@@ -178,10 +145,10 @@ export async function settleSummaryRequests(
178
145
  let acquired = false
179
146
  let heartbeat: ReturnType<typeof setInterval> | undefined
180
147
  try {
181
- let events = await deps.store.getEvents(deps.slug)
182
- if (openExecution(events) !== null) return
148
+ let events = (await readBuildFacts(deps.store, deps.slug)).value
149
+ if (events.open !== null) return
183
150
  // Repair a crash after terminal/session completion but before guest handoff.
184
- const workspace = events.findLast((event) => event.type === 'workspace.provisioned')
151
+ const workspace = events.latestWorkspace
185
152
  if (workspace?.type !== 'workspace.provisioned' || !isRemoteWorkspace(workspace.payload)) return
186
153
  const holder = deps.reservation ?? deps.holder
187
154
  acquired = await deps.store.claimLease(deps.slug, holder, 60_000)
@@ -189,11 +156,11 @@ export async function settleSummaryRequests(
189
156
  heartbeat = setInterval(() => {
190
157
  void deps.store.heartbeat(deps.slug, holder).catch(() => {})
191
158
  }, 15_000)
192
- events = await deps.store.getEvents(deps.slug)
193
- if (openExecution(events) !== null) return
159
+ events = (await readBuildFacts(deps.store, deps.slug)).value
160
+ if (events.open !== null) return
194
161
  await refreshSummaryBoundary(deps)
195
- events = await deps.store.getEvents(deps.slug)
196
- const requests = pendingSummaryRequests(events)
162
+ events = (await readBuildFacts(deps.store, deps.slug)).value
163
+ const requests = events.summaryRequests
197
164
  const latest = requests.at(-1)
198
165
  for (const request of requests) {
199
166
  if (request.type !== 'pr-summary.requested') continue
@@ -1,3 +1,4 @@
1
+ import type { CurrentBuildFacts } from '../store/reduced-reads'
1
2
  import { summaryMarker } from '../ports/forge/summary-marker'
2
3
  import type { AbEvent } from '../events/catalog'
3
4
  import {
@@ -33,12 +34,21 @@ export function shellQuote(value: string): string {
33
34
  return `'${value.replaceAll("'", `'"'"'`)}'`
34
35
  }
35
36
 
36
- export function renderPrAttachmentSection(env: CliEnv, events: readonly AbEvent[]): string[] {
37
- const designations = currentPrAttachments(events)
37
+ export function renderPrAttachmentSection(
38
+ env: CliEnv,
39
+ events: readonly AbEvent[] | CurrentBuildFacts,
40
+ ): string[] {
41
+ const designations = Array.isArray(events)
42
+ ? currentPrAttachments(events)
43
+ : (events as CurrentBuildFacts).designations
38
44
  if (designations.length === 0) return []
39
45
 
40
- const target = frozenPrImageHost(events)
41
- const hosted = hostedPrAttachments(events, designations)
46
+ const target = Array.isArray(events)
47
+ ? frozenPrImageHost(events)
48
+ : (events as CurrentBuildFacts).imageHost
49
+ const hosted = Array.isArray(events)
50
+ ? hostedPrAttachments(events, designations)
51
+ : new Map((events as CurrentBuildFacts).hostedImages)
42
52
  const lines = [
43
53
  '',
44
54
  '### PR attachments',
@@ -81,19 +91,39 @@ export function renderPrAttachmentSection(env: CliEnv, events: readonly AbEvent[
81
91
  }
82
92
 
83
93
  /** The complete PR summary projection, rendered only from durable facts. */
84
- export function renderPrSummary(env: CliEnv, events: readonly AbEvent[]): string {
94
+ export function renderPrSummary(
95
+ env: CliEnv,
96
+ events: readonly AbEvent[] | CurrentBuildFacts,
97
+ ): string {
85
98
  const verdicts: string[] = []
86
99
  const verifies: string[] = []
87
- for (const event of events) {
88
- if (event.type === 'plan-review.verdict' || event.type === 'code-review.verdict') {
89
- const phase = event.type === 'plan-review.verdict' ? 'plan-review' : 'code-review'
90
- const count = event.payload.findings.length
100
+ const history = Array.isArray(events)
101
+ ? events.flatMap((event): CurrentBuildFacts['summaryHistory'] =>
102
+ event.type === 'plan-review.verdict' || event.type === 'code-review.verdict'
103
+ ? [
104
+ {
105
+ kind: 'review',
106
+ phase: event.type === 'plan-review.verdict' ? 'plan-review' : 'code-review',
107
+ round: event.payload.round,
108
+ verdict: event.payload.verdict,
109
+ count: event.payload.findings.length,
110
+ },
111
+ ]
112
+ : event.type === 'verify.completed'
113
+ ? [{ kind: 'verify', result: event.payload }]
114
+ : [],
115
+ )
116
+ : (events as CurrentBuildFacts).summaryHistory
117
+ for (const entry of history) {
118
+ if (entry.kind === 'review') {
119
+ const phase = entry.phase
120
+ const count = entry.count
91
121
  const detail =
92
- event.payload.verdict === 'revise' ? ` (${count} finding${count === 1 ? '' : 's'})` : ''
93
- verdicts.push(`- ${phase} r${event.payload.round}: ${event.payload.verdict}${detail}`)
122
+ entry.verdict === 'revise' ? ` (${count} finding${count === 1 ? '' : 's'})` : ''
123
+ verdicts.push(`- ${phase} r${entry.round}: ${entry.verdict}${detail}`)
94
124
  }
95
- if (event.type === 'verify.completed') {
96
- const result = normalizeVerifyCompletion(event.payload)
125
+ if (entry.kind === 'verify' && entry.result) {
126
+ const result = normalizeVerifyCompletion(entry.result)
97
127
  const detail =
98
128
  result.outcome === 'skipped'
99
129
  ? ` — ${html(result.reason ?? '')}`