@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,14 +1,8 @@
1
+ import { readBuildFacts } from '../store/reduced-reads'
1
2
  import type { IdSource } from '../ids'
2
3
  import { DISPATCHER, KERNEL } from '../events/envelope'
3
4
  import type { Forge, WorkspacePublication } from '../ports/types'
4
5
  import type { Exec } from '../ports/workspace/git-worktree'
5
- import { openExecution } from '../processes/execution-settlement'
6
- import {
7
- latestUncompletedPublicationRequest,
8
- publicationLostRecorded,
9
- publicationRequestCompleted,
10
- publicationRequestSettled,
11
- } from '../processes/publication-state'
12
6
  import { systemClock, type BuildStore, type Clock } from '../store/types'
13
7
  import { completeFinalizePr } from './terminals'
14
8
 
@@ -41,11 +35,11 @@ export async function settlePendingPublication(
41
35
  ): Promise<void> {
42
36
  const publication = deps.publication
43
37
  if (publication === undefined) return
44
- let events = await deps.store.getEvents(slug)
45
- const request = latestUncompletedPublicationRequest(events)
38
+ let events = (await readBuildFacts(deps.store, slug)).value
39
+ const request = events.publication.latestUncompletedRequest()
46
40
  if (request === undefined) return
47
41
 
48
- const abandoned = publicationRequestSettled(events, request)
42
+ const abandoned = events.publication.requestSettled(request)
49
43
  // Consulted for every uncompleted request, not only abandoned ones: a guest
50
44
  // that pushed its branch before dying is owed its completion fact, not a
51
45
  // re-push or a loss record.
@@ -59,7 +53,7 @@ export async function settlePendingPublication(
59
53
  // returning silently — the exact drop that lost the validate-hosted-
60
54
  // dispatcher generation-1 work. The finalize-step publish-failure path
61
55
  // below is unchanged: it already settles loudly via its failed outcome.
62
- if (!publicationLostRecorded(events, request)) {
56
+ if (!events.publication.lostRecorded(request)) {
63
57
  await deps.store.append(slug, {
64
58
  actor: DISPATCHER,
65
59
  type: 'publication.lost',
@@ -85,12 +79,8 @@ export async function settlePendingPublication(
85
79
  const leaseLive =
86
80
  record?.lease !== undefined &&
87
81
  new Date(record.lease.expiresAt).getTime() > clock().getTime()
88
- if (openExecution(events) !== null && leaseLive) return
89
- let ref: string | undefined
90
- for (const event of events) {
91
- if (event.type === 'workspace.provisioned') ref = event.payload.ref
92
- else if (event.type === 'workspace.released') ref = undefined
93
- }
82
+ if (events.open !== null && leaseLive) return
83
+ const ref = events.workspace?.ref
94
84
  if (ref === undefined)
95
85
  throw new Error(`build ${slug} has a publication request but no open workspace`)
96
86
  await publication.publish({ ref, sha: request.payload.sha, branch: request.payload.branch })
@@ -115,8 +105,8 @@ export async function settlePendingPublication(
115
105
  })
116
106
  return
117
107
  }
118
- events = await deps.store.getEvents(slug)
119
- if (publicationRequestCompleted(events, request)) return
108
+ events = (await readBuildFacts(deps.store, slug)).value
109
+ if (events.publication.requestCompleted(request)) return
120
110
 
121
111
  if (request.payload.operation === 'implement') {
122
112
  await deps.store.append(slug, {
@@ -157,7 +147,7 @@ export async function settlePendingPublication(
157
147
  const newline = text.indexOf('\n')
158
148
  const title = (newline === -1 ? text : text.slice(0, newline)).replace(/^#+\s*/, '').trim()
159
149
  if (title === '') throw new Error(`empty PR title for ${slug}`)
160
- const created = events.findLast((event) => event.type === 'build.created')
150
+ const created = events.lastCreated
161
151
  if (created?.type !== 'build.created') throw new Error(`missing build.created for ${slug}`)
162
152
 
163
153
  await completeFinalizePr(
@@ -1,3 +1,4 @@
1
+ import { readRepositoryFacts, type RepositoryReadFacts } from '../store/reduced-reads'
1
2
  import {
2
3
  formatDispatcherIdentity,
3
4
  projectDispatcherLiveness,
@@ -99,6 +100,37 @@ export function projectRepositoryStatus(
99
100
  }
100
101
  }
101
102
 
103
+ export function repositoryStatusFromFacts(
104
+ repo: string,
105
+ facts: RepositoryReadFacts,
106
+ dispatcher: DispatcherLiveness,
107
+ ): RepositoryStatus {
108
+ return {
109
+ repo,
110
+ dispatcher,
111
+ ...facts.settings,
112
+ ...(facts.health === undefined ? {} : { ticketSourceDegradation: facts.health }),
113
+ sandboxes: facts.sandboxes
114
+ .filter((state) => state.state !== 'released')
115
+ .map((state) => ({
116
+ operator: state.operator,
117
+ environmentId: state.environmentId,
118
+ provider: state.provider,
119
+ state: state.state === 'stopped' ? ('stopped' as const) : ('live' as const),
120
+ lastEvidenceAt: state.lastEvidenceTs,
121
+ })),
122
+ publications: [...facts.publications].reverse().map((event) => ({
123
+ operator: event.payload.operator,
124
+ branch: event.payload.branch,
125
+ sha: event.payload.sha,
126
+ prNumber: event.payload.pr.number,
127
+ prUrl: event.payload.pr.url,
128
+ ...(event.payload.session !== undefined ? { session: event.payload.session } : {}),
129
+ at: event.ts,
130
+ })),
131
+ }
132
+ }
133
+
102
134
  export function renderRepositoryStatus(status: RepositoryStatus): string[] {
103
135
  const setting = (enabled: boolean): string => (enabled ? 'ON' : 'OFF')
104
136
  const active = status.ticketSourceDegradation
@@ -147,8 +179,12 @@ export async function abRepositoryStatus(opts: RepositoryStatusOpts): Promise<vo
147
179
  await withSessionlessStore(opts, async ({ store, repo }) => {
148
180
  // A fresh store has no repository row. Do not call ensureRepo: this query's
149
181
  // empty-stream defaults must remain a genuinely read-only operation.
150
- const { events, dispatcher } = await readDispatcherLiveness(store, repo, opts.now?.())
151
- const status = projectRepositoryStatus(repo, events, dispatcher)
182
+ const { dispatcher } = await readDispatcherLiveness(store, repo, opts.now?.())
183
+ const status = repositoryStatusFromFacts(
184
+ repo,
185
+ (await readRepositoryFacts(store, repo)).value,
186
+ dispatcher,
187
+ )
152
188
  if (opts.json === true) {
153
189
  opts.stdout(JSON.stringify(status, null, 2))
154
190
  return
@@ -1,3 +1,6 @@
1
+ import { readBuildDigests } from '../store/reduced-reads'
2
+ import { readBuildFacts } from '../store/reduced-reads'
3
+ import type { BuildFacts } from '../processes/repo-view'
1
4
  /**
2
5
  * `ab builds` and `ab build status <slug>` — the read-only query surface on
3
6
  * build state (SPEC §8.2, §15.5). Their invocation forms require no session,
@@ -44,9 +47,9 @@ import {
44
47
  } from '../processes/build-execution-state'
45
48
  import type { PipelineSourceMeta } from '../config/pipeline-source'
46
49
  import { openExecution } from '../processes/execution-settlement'
47
- import { currentAutoMergeDeferral } from '../kernel/auto-merge'
50
+ import { currentAutoMergeDeferralFrom } from '../kernel/auto-merge'
48
51
  import { autoMergeDisplay, type AutoMergeDisplay } from './dashboard/model'
49
- import { decideNext } from '../kernel/engine'
52
+ import { decideNextFromFacts } from '../kernel/engine'
50
53
  import {
51
54
  reduceBuild,
52
55
  type BuildState,
@@ -271,7 +274,31 @@ export function detail(
271
274
  decision?: BuildDecisionProjection,
272
275
  streams?: readonly StreamRecord[],
273
276
  ): BuildDetail {
274
- const state = reduceBuild(events)
277
+ return detailFromFacts(
278
+ record,
279
+ {
280
+ state: reduceBuild(events),
281
+ observations: events
282
+ .filter((event) => event.type === 'observation.recorded')
283
+ .map((event) => event.payload),
284
+ open: openExecution(events),
285
+ },
286
+ now,
287
+ eventCount === undefined ? undefined : events.slice(-eventCount),
288
+ decision,
289
+ streams,
290
+ )
291
+ }
292
+
293
+ export function detailFromFacts(
294
+ record: BuildRecord,
295
+ facts: Pick<BuildFacts, 'state' | 'open'> & { observations: BuildState['observations'] },
296
+ now: Date,
297
+ events?: AbEvent[],
298
+ decision?: BuildDecisionProjection,
299
+ streams?: readonly StreamRecord[],
300
+ ): BuildDetail {
301
+ const state = facts.state
275
302
  const streamStatus = new Map<string, { stream?: string; status: 'open' | 'closed' }>()
276
303
  if (streams !== undefined) {
277
304
  for (const record of streams) {
@@ -316,15 +343,13 @@ export function detail(
316
343
  }),
317
344
  // Observations are historical facts rather than a derived current-state
318
345
  // opinion. Preserve event order and exact optional evidence fields.
319
- observations: events
320
- .filter((event) => event.type === 'observation.recorded')
321
- .map((event) => ({
322
- id: event.payload.id,
323
- kind: event.payload.kind,
324
- summary: event.payload.summary,
325
- ...(event.payload.files !== undefined ? { files: event.payload.files } : {}),
326
- ...(event.payload.refs !== undefined ? { refs: event.payload.refs } : {}),
327
- })),
346
+ observations: facts.observations.map((observation) => ({
347
+ id: observation.id,
348
+ kind: observation.kind,
349
+ summary: observation.summary,
350
+ ...(observation.files !== undefined ? { files: observation.files } : {}),
351
+ ...(observation.refs !== undefined ? { refs: observation.refs } : {}),
352
+ })),
328
353
  ...(state.setupFailure !== undefined
329
354
  ? {
330
355
  setupFailure: {
@@ -338,7 +363,7 @@ export function detail(
338
363
  ...(state.executions.at(-1) !== undefined ? { execution: state.executions.at(-1)! } : {}),
339
364
  // Durable supervision: a recorded execution with no following end is the
340
365
  // source of truth for whether a guest may still be running.
341
- executionOpen: openExecution(events) !== null,
366
+ executionOpen: facts.open !== null,
342
367
  ...(state.infrastructureFailure !== undefined
343
368
  ? { infrastructureFailure: state.infrastructureFailure }
344
369
  : {}),
@@ -364,7 +389,7 @@ export function detail(
364
389
  : {}),
365
390
  // getEvents returns seq-ordered events, so the newest n slice out already
366
391
  // chronological — which is what the AC asks for.
367
- ...(eventCount !== undefined ? { events: events.slice(-eventCount) } : {}),
392
+ ...(events !== undefined ? { events } : {}),
368
393
  ...(state.outcome !== undefined ? { outcome: state.outcome } : {}),
369
394
  }
370
395
  }
@@ -742,7 +767,7 @@ export async function abBuilds(opts: AbBuildsOpts): Promise<void> {
742
767
  // round trip and the listing's store cost stays flat as finished builds
743
768
  // accumulate. The `--all` scope includes terminal statuses, so it takes no
744
769
  // digest read and skips nothing — byte-for-byte the old loop.
745
- const digests = opts.all === true ? undefined : await store.getRepoBuildDigests(identity)
770
+ const digests = opts.all === true ? undefined : await readBuildDigests(store, identity)
746
771
  const summaries: BuildSummary[] = []
747
772
  for (const record of records) {
748
773
  if (digests !== undefined && record.repo === identity) {
@@ -766,7 +791,11 @@ export async function abBuilds(opts: AbBuildsOpts): Promise<void> {
766
791
  // entry under `identity` — `getRepoBuildDigests` groups by `record.repo`
767
792
  // exactly — so it keeps its per-build history read (correct, just not
768
793
  // accelerated).
769
- const summary = summarize(record, await store.getEvents(record.slug), now)
794
+ const summary = summarizeFrom(
795
+ record,
796
+ (await readBuildFacts(store, record.slug)).value.state,
797
+ now,
798
+ )
770
799
  if (wanted.has(summary.status)) summaries.push(summary)
771
800
  }
772
801
  summaries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
@@ -797,7 +826,7 @@ export interface AbBuildStatusOpts extends StatusOpts {
797
826
  events?: number
798
827
  }
799
828
 
800
- function openWorkspacePath(events: AbEvent[]): string | undefined {
829
+ function _openWorkspacePath(events: AbEvent[]): string | undefined {
801
830
  let path: string | undefined
802
831
  for (const event of events) {
803
832
  if (event.type === 'workspace.provisioned') {
@@ -812,15 +841,11 @@ function openWorkspacePath(events: AbEvent[]): string | undefined {
812
841
  /** The kernel tail both config sources share, so the deposited artifact and
813
842
  * the workspace file cannot drift in what they compute — the sources differ
814
843
  * only in where the config comes from, never in what is decided with it. */
815
- function decideFromConfig(
816
- events: AbEvent[],
817
- state: BuildState,
818
- config: Config,
819
- ): BuildDecisionProjection {
820
- const decision = decideNext(events, config)
844
+ function decideFromConfig(facts: BuildFacts, config: Config): BuildDecisionProjection {
845
+ const decision = decideNextFromFacts(facts.state, facts.log, config)
821
846
  if (decision.kind !== 'wait') return { kind: 'runner-work' }
822
847
  if (decision.reason !== 'awaiting-pr') return { kind: 'parked', reason: decision.reason }
823
- const reason = currentAutoMergeDeferral(events, state)
848
+ const reason = currentAutoMergeDeferralFrom(facts.deferral, facts.state)
824
849
  return { kind: 'awaiting-pr', ...(reason !== undefined ? { reason } : {}) }
825
850
  }
826
851
 
@@ -840,10 +865,10 @@ function decideFromConfig(
840
865
  * the read-only command fail. */
841
866
  async function projectBuildDecision(
842
867
  slug: string,
843
- events: AbEvent[],
868
+ facts: BuildFacts,
844
869
  store: Pick<BuildStore, 'getArtifact'>,
845
870
  ): Promise<BuildDecisionProjection | undefined> {
846
- const state = reduceBuild(events)
871
+ const state = facts.state
847
872
  const active =
848
873
  state.status === 'running' || state.status === 'paused' || state.status === 'blocked'
849
874
  if (!active) return undefined
@@ -858,7 +883,7 @@ async function projectBuildDecision(
858
883
  const artifact = await store.getArtifact(slug, BUILD_EFFECTIVE_CONFIG_ARTIFACT)
859
884
  if (artifact !== null) {
860
885
  try {
861
- return decideFromConfig(events, state, parseEffectiveBuildConfig(artifact))
886
+ return decideFromConfig(facts, parseEffectiveBuildConfig(artifact))
862
887
  } catch (error) {
863
888
  const message = error instanceof Error ? error.message : String(error)
864
889
  return {
@@ -870,7 +895,7 @@ async function projectBuildDecision(
870
895
 
871
896
  // Source 2 — the local workspace, exactly as before, only when no artifact
872
897
  // is deposited.
873
- const workspace = openWorkspacePath(events)
898
+ const workspace = facts.workspace?.path ?? facts.workspace?.ref
874
899
  if (workspace === undefined) {
875
900
  return {
876
901
  kind: 'unavailable',
@@ -879,7 +904,7 @@ async function projectBuildDecision(
879
904
  }
880
905
 
881
906
  try {
882
- return decideFromConfig(events, state, await loadConfig(join(workspace, 'autobuild.toml')))
907
+ return decideFromConfig(facts, await loadConfig(join(workspace, 'autobuild.toml')))
883
908
  } catch {
884
909
  // The raw error deliberately stays here: ENOENT/ENOTDIR messages embed the
885
910
  // recorded workspace path, which for a remote build is a guest location.
@@ -944,8 +969,13 @@ export async function abBuildStatus(opts: AbBuildStatusOpts): Promise<void> {
944
969
  const now = (opts.now ?? (() => new Date()))()
945
970
  await withAmbientReadStore(opts, async (context) => {
946
971
  const record = await requireReadableBuild(opts, context)
947
- const events = await context.store.getEvents(opts.slug)
948
- const decision = await projectBuildDecision(opts.slug, events, context.store)
972
+ const current = await readBuildFacts(context.store, opts.slug)
973
+ const facts = current.value
974
+ const events =
975
+ opts.events === undefined
976
+ ? undefined
977
+ : await context.store.getEvents(opts.slug, Math.max(0, current.cursor - opts.events))
978
+ const decision = await projectBuildDecision(opts.slug, facts, context.store)
949
979
  // Stream enrichment is presentation (SPEC §9): an unavailable read
950
980
  // degrades to payload-carried ids without a status, never a failure.
951
981
  let streams: StreamRecord[] | undefined
@@ -957,7 +987,7 @@ export async function abBuildStatus(opts: AbBuildStatusOpts): Promise<void> {
957
987
  } catch {
958
988
  streams = undefined
959
989
  }
960
- const projected = detail(record, events, now, opts.events, decision, streams)
990
+ const projected = detailFromFacts(record, facts, now, events, decision, streams)
961
991
  // Operator visibility (SPEC §16.1): which autobuild.toml the build's
962
992
  // pinned pipeline came from, alongside the effective-config revision.
963
993
  const configArtifact = await context.store.getArtifact(
@@ -1,3 +1,4 @@
1
+ import { readBuildFacts, readTerminalGuard, type CurrentBuildFacts } from '../store/reduced-reads'
1
2
  /**
2
3
  * The three terminal commands — `ab done`, `ab verdict`, `ab escalate`
3
4
  * (SPEC §8.4 D5, §8.5 D6, §8.6 D7).
@@ -18,7 +19,7 @@
18
19
  import { join } from 'node:path'
19
20
  import { z } from 'zod'
20
21
  import { loadConfig } from '../config/load'
21
- import type { AbEvent, EventEnvelope } from '../events/catalog'
22
+ import type { EventEnvelope } from '../events/catalog'
22
23
  import { agentActor, KERNEL } from '../events/envelope'
23
24
  import { isRemoteWorkspace } from '../events/workspace-remote'
24
25
  import type { IdSource } from '../ids'
@@ -29,7 +30,6 @@ import {
29
30
  } from '../kernel/auto-merge'
30
31
  import { phaseSpecFor } from '../kernel/phases'
31
32
  import { resolvePlanVerifySteps } from '../kernel/plan-verify-selection'
32
- import { reduceBuild } from '../kernel/reducer'
33
33
  import {
34
34
  findingDraftSchema,
35
35
  isVerifyPhase,
@@ -75,114 +75,9 @@ export interface TerminalDeps {
75
75
  * round, so a still-in-flight CLI process from the failed session must not
76
76
  * land a terminal the runner will misattribute to the retry).
77
77
  */
78
- function assertNoPriorTerminal(events: AbEvent[], env: CliEnv): void {
79
- const spec = phaseSpecFor(env.phase)
80
- // §6.3 restart boundary: verify attempts and finalize completion RESET
81
- // across a spec.revised (decideNext ignores pre-restart results and
82
- // re-runs the pipeline from plan), so a pre-restart verify.completed or
83
- // finalize.completed must not shadow the rebuilt pipeline's terminal.
84
- // Loop-phase rounds continue monotonically across restarts (engine
85
- // LoopIndex.maxRoundEver) and never collide, so they match over the full
86
- // log; the reconcile epilogue is restart-orthogonal (§15.7).
87
- let restartSeq = 0
88
- for (const event of events) {
89
- if (event.type === 'spec.revised') restartSeq = event.seq
90
- }
91
- // reconcile.completed carries no attempt (§15.3); pair it with the latest
92
- // reconcile.started seen before it.
93
- let reconcileAttempt = 0
94
- let sessionStartedSeq: number | undefined
95
- for (const event of events) {
96
- if (event.type === 'reconcile.started') reconcileAttempt = event.payload.attempt
97
- if (event.type === 'session.started' && event.payload.session === env.session) {
98
- sessionStartedSeq = event.seq
99
- }
100
- if (event.type === 'session.ended' && event.payload.session === env.session) {
101
- throw new Error(
102
- `terminal rejected (D5): session "${env.session}" already ended ` +
103
- `(session.ended at seq ${event.seq}) — a session that ended without ` +
104
- 'a terminal was failed and retried; only the live retry may complete ' +
105
- 'this phase round (§8.4).',
106
- )
107
- }
108
- if (
109
- sessionStartedSeq !== undefined &&
110
- event.type === 'phase.failed' &&
111
- event.payload.phase === env.phase &&
112
- event.payload.round === env.round &&
113
- event.seq > sessionStartedSeq
114
- ) {
115
- throw new Error(
116
- `terminal rejected (D5): ${env.phase}@${env.round} already failed after ` +
117
- `this session started (phase.failed at seq ${event.seq}) — the retry ` +
118
- 'session owns this round now (§8.4).',
119
- )
120
- }
121
- if (
122
- event.type === 'escalation.raised' &&
123
- event.actor.kind === 'agent' &&
124
- event.actor.session === env.session
125
- ) {
126
- throw new Error(
127
- `second terminal call rejected (D5): this session already escalated — ` +
128
- `escalation.raised "${event.payload.id}" at seq ${event.seq}. ` +
129
- 'Every phase ends with exactly one terminal command (§8.4).',
130
- )
131
- }
132
- if (
133
- event.type === 'publication.requested' &&
134
- event.actor.kind === 'agent' &&
135
- event.actor.session === env.session &&
136
- ((env.phase === 'implement' &&
137
- event.payload.operation === 'implement' &&
138
- event.payload.round === env.round) ||
139
- (env.phase === 'reconcile' && event.payload.operation === 'reconcile') ||
140
- (env.phase === 'finalize' && event.payload.operation === 'finalize'))
141
- ) {
142
- throw new Error(
143
- `second terminal call rejected (D5): ${event.type} for ` +
144
- `${env.phase}@${env.round} already recorded at seq ${event.seq}. ` +
145
- 'Remote publication is pending; do not run ab done again (§8.4).',
146
- )
147
- }
148
- if (event.type !== spec.terminalEvent) continue
149
- if (
150
- (event.type === 'verify.completed' || event.type === 'finalize.completed') &&
151
- event.seq <= restartSeq
152
- ) {
153
- continue
154
- }
155
- if (!terminalMatchesRound(event, env, reconcileAttempt)) continue
156
- throw new Error(
157
- `second terminal call rejected (D5): ${event.type} for ` +
158
- `${env.phase}@${env.round} already recorded at seq ${event.seq}. ` +
159
- 'Every phase ends with exactly one terminal command (§8.4).',
160
- )
161
- }
162
- }
163
-
164
- function terminalMatchesRound(event: AbEvent, env: CliEnv, reconcileAttempt: number): boolean {
165
- switch (event.type) {
166
- case 'plan.completed':
167
- case 'plan-review.verdict':
168
- case 'implement.completed':
169
- case 'code-review.verdict':
170
- return event.payload.round === env.round
171
- case 'verify.completed':
172
- return (
173
- isVerifyPhase(env.phase) &&
174
- event.payload.step === verifyStep(env.phase) &&
175
- event.payload.attempt === env.round
176
- )
177
- case 'finalize.completed':
178
- // finalize runs once per build (§5) — any prior completion counts.
179
- return true
180
- case 'reconcile.completed':
181
- // No started event before it → conservative match (total over any log).
182
- return reconcileAttempt === env.round || reconcileAttempt === 0
183
- default:
184
- return false
185
- }
78
+ async function assertNoPriorTerminal(store: BuildStore, env: CliEnv): Promise<void> {
79
+ const result = await readTerminalGuard(store, env.build, env.phase, env.round, env.session)
80
+ if (result.diagnostic) throw new Error(result.diagnostic)
186
81
  }
187
82
 
188
83
  // ── Small helpers ────────────────────────────────────────────────────────────
@@ -240,28 +135,21 @@ async function assertCleanWorktree(deps: TerminalDeps): Promise<void> {
240
135
  }
241
136
  }
242
137
 
243
- function baseBranchOf(events: AbEvent[]): string {
244
- for (const event of events) {
245
- if (event.type === 'build.created') return event.payload.baseBranch
246
- }
138
+ function baseBranchOf(events: CurrentBuildFacts): string {
139
+ if (events.created) return events.created.payload.baseBranch
247
140
  throw new Error('this build has no build.created event — cannot resolve its baseBranch (§15.3)')
248
141
  }
249
142
 
250
- function requireImplementationProvisioning(events: AbEvent[]): void {
251
- if (events.some((event) => event.type === 'workspace.provisioned')) return
143
+ function requireImplementationProvisioning(events: CurrentBuildFacts): void {
144
+ if (events.workspaceGenerations > 0) return
252
145
  throw new Error(
253
146
  "implement's 'ab done' requires workspace.provisioned provenance — " +
254
147
  'this build has no workspace.provisioned event (§7.4, §15.3)',
255
148
  )
256
149
  }
257
150
 
258
- function remotePublication(events: readonly AbEvent[]): boolean {
259
- let remote = false
260
- for (const event of events) {
261
- if (event.type === 'workspace.provisioned') remote = isRemoteWorkspace(event.payload)
262
- else if (event.type === 'workspace.released') remote = false
263
- }
264
- return remote
151
+ function remotePublication(events: CurrentBuildFacts): boolean {
152
+ return events.workspace !== null && isRemoteWorkspace(events.workspace)
265
153
  }
266
154
 
267
155
  async function buildBranch(deps: TerminalDeps): Promise<string> {
@@ -281,7 +169,7 @@ async function buildBranch(deps: TerminalDeps): Promise<string> {
281
169
  */
282
170
  async function implementationReviewBoundary(
283
171
  deps: TerminalDeps,
284
- events: AbEvent[],
172
+ events: CurrentBuildFacts,
285
173
  ): Promise<{ branch: string; base: string; head: string }> {
286
174
  const diagnostic = 'cannot establish implementation review boundary'
287
175
  try {
@@ -384,7 +272,7 @@ export interface FinalizePrInput {
384
272
  * requested head before entering this idempotent PR-side sequence. */
385
273
  export async function completeFinalizePr(
386
274
  deps: TerminalDeps,
387
- events: AbEvent[],
275
+ events: CurrentBuildFacts,
388
276
  input: FinalizePrInput,
389
277
  ): Promise<EventEnvelope> {
390
278
  const { env, store } = deps
@@ -404,8 +292,8 @@ export async function completeFinalizePr(
404
292
 
405
293
  await preparePrAttachments(deps, events, pr.url)
406
294
 
407
- const latest = await store.getEvents(env.build)
408
- const autoMerge = pendingAutoMerge(reduceBuild(latest))
295
+ const latest = (await readBuildFacts(store, env.build)).value
296
+ const autoMerge = pendingAutoMerge(latest.state)
409
297
  const autoMergeResult =
410
298
  autoMerge === undefined
411
299
  ? undefined
@@ -452,8 +340,8 @@ export async function done(deps: TerminalDeps, opts: DoneOpts = {}): Promise<Eve
452
340
  `${spec.kind} phase; use 'ab verdict' (§8.2)`,
453
341
  )
454
342
  }
455
- const events = await store.getEvents(env.build)
456
- assertNoPriorTerminal(events, env)
343
+ const events = (await readBuildFacts(store, env.build)).value
344
+ await assertNoPriorTerminal(store, env)
457
345
  const actor = agentActor(env.phase, env.session)
458
346
 
459
347
  switch (spec.name) {
@@ -466,12 +354,7 @@ export async function done(deps: TerminalDeps, opts: DoneOpts = {}): Promise<Eve
466
354
  // still the already-reviewed plan, re-entering review as if revised.
467
355
  const revs = await store.listArtifacts(env.build, 'plan')
468
356
  const latest = revs[revs.length - 1]
469
- let lastCited = -1
470
- for (const event of events) {
471
- if (event.type === 'plan.completed') {
472
- lastCited = Math.max(lastCited, event.payload.artifact.rev)
473
- }
474
- }
357
+ const lastCited = events.maxPlanRevision
475
358
  if (latest === undefined || latest.revision <= lastCited) {
476
359
  throw new Error(
477
360
  `plan@${env.round} 'ab done' requires this round's plan deposit: found ` +
@@ -704,8 +587,8 @@ export async function verdict(deps: TerminalDeps, opts: VerdictOpts): Promise<Ev
704
587
  `accept pass|fail|skip. ${env.phase} accepts: ${vocabulary.join('|')}`,
705
588
  )
706
589
  }
707
- const events = await store.getEvents(env.build)
708
- assertNoPriorTerminal(events, env)
590
+ const events = (await readBuildFacts(store, env.build)).value
591
+ await assertNoPriorTerminal(store, env)
709
592
  const actor = agentActor(env.phase, env.session)
710
593
 
711
594
  if (spec.kind === 'agent-verify') {
@@ -717,7 +600,7 @@ export async function verdict(deps: TerminalDeps, opts: VerdictOpts): Promise<Ev
717
600
  async function reviewVerdict(
718
601
  deps: TerminalDeps,
719
602
  opts: VerdictOpts,
720
- events: AbEvent[],
603
+ events: CurrentBuildFacts,
721
604
  actor: ReturnType<typeof agentActor>,
722
605
  ): Promise<EventEnvelope[]> {
723
606
  const { env, store } = deps
@@ -816,7 +699,7 @@ export function renderFindingsIssues(issues: readonly z.core.$ZodIssue[]): strin
816
699
  async function parseFindings(
817
700
  deps: TerminalDeps,
818
701
  path: string,
819
- events: AbEvent[],
702
+ events: CurrentBuildFacts,
820
703
  ): Promise<Finding[]> {
821
704
  const raw = await readTextFile(path, '--findings')
822
705
  let json: unknown
@@ -841,7 +724,7 @@ async function parseFindings(
841
724
  // `persists` may only reference ids from prior rounds of THIS loop (§15.4;
842
725
  // ids are deposit-stamped, so a reviewer can only ever see rounds it was
843
726
  // shown — §8.3).
844
- const state = reduceBuild(events)
727
+ const state = events.state
845
728
  const loop =
846
729
  deps.env.phase === 'plan-review'
847
730
  ? state.reviewFindings.planReview
@@ -969,8 +852,8 @@ export async function escalate(
969
852
  if (opts.question.trim() === '') {
970
853
  throw new Error("'ab escalate' requires a question — it is what the human answers (§11)")
971
854
  }
972
- const events = await store.getEvents(env.build)
973
- assertNoPriorTerminal(events, env)
855
+ const _events = (await readBuildFacts(store, env.build)).value
856
+ await assertNoPriorTerminal(store, env)
974
857
  return store.append(env.build, {
975
858
  actor: agentActor(env.phase, env.session),
976
859
  type: 'escalation.raised',
@@ -1,3 +1,4 @@
1
+ import { readBuildFacts } from '../store/reduced-reads'
1
2
  import { exportTickets, importTickets } from '../ports/tickets/migration'
2
3
  import { composeTicketAssets } from '../store/ticket-assets'
3
4
  import { editTicket, type TicketEditorRunner } from './ticket-editor'
@@ -29,7 +30,6 @@ import type {
29
30
  TicketStateInfo,
30
31
  TicketUpdate,
31
32
  } from '../ports/types'
32
- import { reduceBuild } from '../kernel/reducer'
33
33
  import type { BuildStore } from '../store/types'
34
34
  import { spawnExec, type Exec } from '../ports/workspace/git-worktree'
35
35
  import type { TicketAssetSummary } from '../store/ticket-assets'
@@ -478,7 +478,7 @@ async function activeBuildsFor(store: BuildStore, repo: string, id: string): Pro
478
478
  const active: string[] = []
479
479
  for (const record of await store.listBuilds()) {
480
480
  if (record.repo !== repo || record.ticket?.id !== id) continue
481
- const { status } = reduceBuild(await store.getEvents(record.slug))
481
+ const { status } = (await readBuildFacts(store, record.slug)).value.state
482
482
  if (status !== 'done' && status !== 'aborted') active.push(record.slug)
483
483
  }
484
484
  return active