@defrex/autobuild 0.12.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 (151) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +138 -16
  3. package/bin/ab-dispatch-kernel.ts +4 -6
  4. package/docs/architecture.md +155 -73
  5. package/docs/configuration.md +41 -9
  6. package/docs/hosted-service.md +31 -5
  7. package/docs/operator-api.md +8 -1
  8. package/docs/remote-store-protocol.md +94 -2
  9. package/docs/remote-ticket-protocol.md +151 -9
  10. package/docs/setup.md +33 -7
  11. package/docs/ticket-migration.md +64 -0
  12. package/package.json +4 -3
  13. package/packages/core/src/cli/attention.ts +18 -164
  14. package/packages/core/src/cli/build-control.ts +23 -16
  15. package/packages/core/src/cli/bulk-control.ts +3 -2
  16. package/packages/core/src/cli/connect.ts +141 -38
  17. package/packages/core/src/cli/context.ts +23 -79
  18. package/packages/core/src/cli/dashboard/model.ts +36 -22
  19. package/packages/core/src/cli/dashboard/poll.ts +1 -1
  20. package/packages/core/src/cli/dispatch-frontend.ts +24 -67
  21. package/packages/core/src/cli/dispatch-process.ts +14 -7
  22. package/packages/core/src/cli/dispatch-repository-feed.ts +196 -0
  23. package/packages/core/src/cli/dispatch.ts +304 -82
  24. package/packages/core/src/cli/dispatcher-liveness.ts +26 -19
  25. package/packages/core/src/cli/harvest.ts +84 -42
  26. package/packages/core/src/cli/help.ts +14 -8
  27. package/packages/core/src/cli/historical-build-state.ts +27 -0
  28. package/packages/core/src/cli/installation.ts +67 -13
  29. package/packages/core/src/cli/main.ts +23 -5
  30. package/packages/core/src/cli/mcp.ts +2 -1
  31. package/packages/core/src/cli/pr-attachments.ts +10 -13
  32. package/packages/core/src/cli/pr-summary-publication.ts +27 -60
  33. package/packages/core/src/cli/pr-summary.ts +43 -13
  34. package/packages/core/src/cli/publication-settlement.ts +10 -20
  35. package/packages/core/src/cli/repository-status.ts +38 -2
  36. package/packages/core/src/cli/self-update.ts +5 -1
  37. package/packages/core/src/cli/status.ts +62 -32
  38. package/packages/core/src/cli/terminals.ts +25 -142
  39. package/packages/core/src/cli/ticket-assets.ts +7 -2
  40. package/packages/core/src/cli/ticket-editor.ts +82 -0
  41. package/packages/core/src/cli/ticket.ts +166 -36
  42. package/packages/core/src/cli/wait.ts +91 -84
  43. package/packages/core/src/cli/watch.ts +60 -97
  44. package/packages/core/src/config/pipeline-source.ts +4 -3
  45. package/packages/core/src/config/schema.ts +35 -3
  46. package/packages/core/src/connection.ts +3 -3
  47. package/packages/core/src/events/catalog.ts +3 -2
  48. package/packages/core/src/events/payloads.ts +6 -0
  49. package/packages/core/src/events/repository.ts +7 -3
  50. package/packages/core/src/integration/harness.ts +38 -4
  51. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +2444 -9
  52. package/packages/core/src/kernel/auto-merge.ts +19 -3
  53. package/packages/core/src/kernel/dispatcher-liveness.ts +10 -4
  54. package/packages/core/src/kernel/generators/build-log.ts +1 -1
  55. package/packages/core/src/kernel/generators/repository-journals.ts +14 -3
  56. package/packages/core/src/kernel/harvest.ts +25 -23
  57. package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
  58. package/packages/core/src/kernel/log-index-value.ts +38 -0
  59. package/packages/core/src/kernel/read-facts.ts +637 -0
  60. package/packages/core/src/kernel/reducer-registry.ts +139 -3
  61. package/packages/core/src/kernel/repository-attention.ts +179 -0
  62. package/packages/core/src/kernel/service-facts.ts +135 -0
  63. package/packages/core/src/kernel/terminal-guard.ts +133 -0
  64. package/packages/core/src/ontology.ts +1 -0
  65. package/packages/core/src/operator/annotations.ts +12 -0
  66. package/packages/core/src/operator/control.ts +5 -6
  67. package/packages/core/src/operator/index.ts +7 -0
  68. package/packages/core/src/operator/protocol.ts +19 -2
  69. package/packages/core/src/operator/query.ts +37 -20
  70. package/packages/core/src/operator/read-view.ts +4 -13
  71. package/packages/core/src/operator/registry.ts +61 -8
  72. package/packages/core/src/operator/requests.ts +2 -14
  73. package/packages/core/src/operator/sandbox.ts +10 -9
  74. package/packages/core/src/operator/ticket-source-backend.ts +17 -21
  75. package/packages/core/src/operator/tickets.ts +132 -22
  76. package/packages/core/src/plugin-sdk/index.ts +19 -0
  77. package/packages/core/src/plugins/runtimes.ts +5 -1
  78. package/packages/core/src/ports/runner/session-env.ts +2 -0
  79. package/packages/core/src/ports/tickets/contract.ts +66 -1
  80. package/packages/core/src/ports/tickets/create.ts +48 -12
  81. package/packages/core/src/ports/tickets/fake.ts +30 -1
  82. package/packages/core/src/ports/tickets/file-assets.ts +159 -0
  83. package/packages/core/src/ports/tickets/file-codec.ts +90 -0
  84. package/packages/core/src/ports/tickets/file.ts +114 -120
  85. package/packages/core/src/ports/tickets/grants.ts +47 -0
  86. package/packages/core/src/ports/tickets/hosted-index.ts +17 -0
  87. package/packages/core/src/ports/tickets/identity.ts +20 -0
  88. package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
  89. package/packages/core/src/ports/tickets/linear.ts +74 -9
  90. package/packages/core/src/ports/tickets/migration.ts +336 -0
  91. package/packages/core/src/ports/tickets/operations.ts +38 -7
  92. package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
  93. package/packages/core/src/ports/tickets/remote-protocol.ts +95 -0
  94. package/packages/core/src/ports/tickets/remote.ts +60 -3
  95. package/packages/core/src/ports/tickets/requested-id.ts +46 -0
  96. package/packages/core/src/ports/tickets/revision.ts +62 -0
  97. package/packages/core/src/ports/tickets/states.ts +61 -0
  98. package/packages/core/src/ports/types.ts +40 -3
  99. package/packages/core/src/ports/workspace/build-execution.ts +1 -0
  100. package/packages/core/src/ports/workspace/create.ts +5 -1
  101. package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
  102. package/packages/core/src/ports/workspace/vercel-sandbox.ts +8 -4
  103. package/packages/core/src/processes/build-child.ts +6 -6
  104. package/packages/core/src/processes/build-runner.ts +62 -70
  105. package/packages/core/src/processes/dispatch-failure-budget.ts +5 -4
  106. package/packages/core/src/processes/dispatcher.ts +223 -205
  107. package/packages/core/src/processes/execution-no-progress.ts +176 -0
  108. package/packages/core/src/processes/harvest-child.ts +30 -4
  109. package/packages/core/src/processes/harvest-execution-state.ts +15 -9
  110. package/packages/core/src/processes/harvest-runner.ts +58 -74
  111. package/packages/core/src/processes/harvest.ts +72 -29
  112. package/packages/core/src/processes/infrastructure-failure-budget.ts +13 -15
  113. package/packages/core/src/processes/orchestrator-tick.ts +2 -2
  114. package/packages/core/src/processes/phase-failure-budget.ts +1 -0
  115. package/packages/core/src/processes/publication-loss.ts +35 -21
  116. package/packages/core/src/processes/repo-view.ts +229 -1099
  117. package/packages/core/src/processes/tick-publication.ts +110 -0
  118. package/packages/core/src/store/adapter.ts +23 -0
  119. package/packages/core/src/store/build-scope.ts +17 -0
  120. package/packages/core/src/store/contract.ts +283 -1
  121. package/packages/core/src/store/local/schema.ts +1 -0
  122. package/packages/core/src/store/local/store.ts +180 -55
  123. package/packages/core/src/store/memory.ts +119 -29
  124. package/packages/core/src/store/phase-session.ts +17 -0
  125. package/packages/core/src/store/reduced-reads.ts +239 -0
  126. package/packages/core/src/store/reducer-value-shapes.json +14056 -0
  127. package/packages/core/src/store/reducer-value-validation.ts +96 -0
  128. package/packages/core/src/store/reducer-values.ts +145 -0
  129. package/packages/core/src/store/remote/client.ts +41 -0
  130. package/packages/core/src/store/remote/compatibility.ts +2 -0
  131. package/packages/core/src/store/remote/index.ts +19 -0
  132. package/packages/core/src/store/remote/protocol.ts +16 -0
  133. package/packages/core/src/store/remote/version.ts +3 -1
  134. package/packages/core/src/store/repo-state-events.ts +1 -1
  135. package/packages/core/src/store/repo-tick.ts +72 -0
  136. package/packages/core/src/store/session-handle.ts +19 -0
  137. package/packages/core/src/store/snapshots.ts +10 -0
  138. package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
  139. package/packages/core/src/store/ticket-assets.ts +86 -8
  140. package/packages/core/src/store/types.ts +40 -3
  141. package/packages/core/src/testing/dispatch-read-budget.ts +215 -0
  142. package/packages/core/src/testing/index.ts +8 -0
  143. package/packages/core/src/testing/reduced-read-budget.ts +462 -0
  144. package/skills/guide/SKILL.md +97 -12
  145. package/skills/guide/references/operator-api.md +8 -1
  146. package/skills/guide/references/remote-store-protocol.md +94 -2
  147. package/skills/guide/references/remote-ticket-protocol.md +151 -9
  148. package/skills/guide/references/setup.md +33 -7
  149. package/skills/guide/references/ticket-migration.md +64 -0
  150. package/skills/tickets/SKILL.md +71 -0
  151. package/templates/hosted-service/package.json +2 -2
@@ -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 ?? '')}`
@@ -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,4 @@
1
+ import { join } from 'node:path'
1
2
  import { checkUpgradeCompatibility, type CompatibilityFetch } from './upgrade-compatibility'
2
3
  import { readFile, writeFile } from 'node:fs/promises'
3
4
  import semver from 'semver'
@@ -363,6 +364,7 @@ export async function selfUpdate(options: SelfUpdateOptions): Promise<SelfUpdate
363
364
  const inspection = await inspectInstallation({
364
365
  distRoot,
365
366
  globalBin: globalBinResult.stdout.trim(),
367
+ cwd: options.targetRepo,
366
368
  })
367
369
  if (inspection.kind !== 'bun-forge' && inspection.kind !== 'npm-registry') {
368
370
  return explicit ? fail(options, inspection.reason) : warn(options, inspection.reason)
@@ -469,7 +471,9 @@ export async function selfUpdate(options: SelfUpdateOptions): Promise<SelfUpdate
469
471
 
470
472
  let updated: DistributionIdentity
471
473
  try {
472
- updated = await readDistributionIdentity(distRoot)
474
+ updated = await readDistributionIdentity(
475
+ join(install.ownerRoot, 'node_modules', ...install.packageName.split('/')),
476
+ )
473
477
  // Injected command fixtures do not rewrite disk; a real successful Bun
474
478
  // operation must. Keep the check injectable by accepting the old identity
475
479
  // only when the command seam itself was supplied.
@@ -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(