@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,3 +1,6 @@
1
+ import { parseConfig } from '../config/load'
2
+ import { HostedTicketSource } from '../ports/tickets/remote'
3
+ import { ticketLifecycle } from '../ports/tickets/lifecycle'
1
4
  import { createHash, randomBytes, randomUUID } from 'node:crypto'
2
5
  import { access, readFile, rename, unlink, writeFile } from 'node:fs/promises'
3
6
  import { constants } from 'node:fs'
@@ -14,6 +17,8 @@ import { resolveMainRepo } from './repo-state'
14
17
  import { ProjectCredentials } from './project-credentials'
15
18
 
16
19
  interface ConnectOptions {
20
+ tickets?: boolean
21
+ builds?: boolean
17
22
  cwd: string
18
23
  env: Record<string, string | undefined>
19
24
  exec: Exec
@@ -45,8 +50,15 @@ async function project(opts: ConnectOptions) {
45
50
  const store = table.store as { url?: unknown } | undefined
46
51
  if (store && (typeof store !== 'object' || Array.isArray(store)))
47
52
  throw new Error('Invalid [store] configuration')
48
- const service = store?.url === undefined ? undefined : normalizeServiceURL(String(store.url))
49
- return { checkout, repo, path, text, table, service }
53
+ const tickets = table.tickets as { source?: string; url?: string } | undefined
54
+ const buildService = opts.env.AB_STORE?.trim() || store?.url
55
+ const ticketService = tickets?.source === 'hosted' ? (tickets.url ?? buildService) : undefined
56
+ const selected = opts.tickets ? ticketService : buildService
57
+ const service =
58
+ typeof selected === 'string' && /^https?:/.test(selected)
59
+ ? normalizeServiceURL(selected)
60
+ : undefined
61
+ return { checkout, repo, path, text, table, service, buildService, ticketService }
50
62
  }
51
63
  async function request(
52
64
  opts: ConnectOptions,
@@ -84,45 +96,64 @@ async function atomicConfig(path: string, before: string, after: string) {
84
96
  }
85
97
  }
86
98
  export async function abConnect(raw: string | undefined, opts: ConnectOptions): Promise<void> {
99
+ if (opts.tickets && opts.builds) throw new Error('Choose --tickets or --builds')
87
100
  const p = await project(opts)
101
+ if (opts.tickets && (p.table.tickets as { source?: string } | undefined)?.source !== 'hosted')
102
+ throw new Error('ab connect --tickets requires [tickets] source = "hosted"')
88
103
  const credentials = new ProjectCredentials(opts.env)
89
104
  if (raw === undefined) {
90
- if (!p.service) {
91
- opts.print(
92
- `Project: ${p.repo}\nService: unconfigured\nCredential: missing; run ab connect <url>`,
93
- )
105
+ const connections = new Map<string, string[]>()
106
+ for (const [use, candidate] of [
107
+ ['builds', p.buildService],
108
+ ['tickets', p.ticketService],
109
+ ] as const) {
110
+ if (typeof candidate !== 'string' || !/^https?:/.test(candidate)) continue
111
+ const service = normalizeServiceURL(candidate)
112
+ connections.set(service, [...(connections.get(service) ?? []), use])
113
+ }
114
+ if (connections.size === 0) {
115
+ opts.print(`Project: ${p.repo}\nService: unconfigured`)
94
116
  return
95
117
  }
96
- const entry = credentials.load(p.service, p.repo)
97
- let status = 'missing; run ab connect'
98
- if (entry) {
99
- try {
100
- const response = await request(opts, p.service, 'status', undefined, entry.token)
101
- if (response.ok) {
102
- const body = (await response.json()) as {
103
- repo?: string
104
- credentialId?: string
105
- accepted?: boolean
118
+ for (const [service, uses] of connections) {
119
+ opts.print(`Uses: ${uses.join(', ')}`)
120
+ if (!service) {
121
+ opts.print(
122
+ `Project: ${p.repo}\nService: unconfigured\nCredential: missing; run ab connect <url>`,
123
+ )
124
+ return
125
+ }
126
+ const entry = credentials.load(service, p.repo)
127
+ let status = 'missing; run ab connect'
128
+ if (entry) {
129
+ try {
130
+ const response = await request(opts, service, 'status', undefined, entry.token)
131
+ if (response.ok) {
132
+ const body = (await response.json()) as {
133
+ repo?: string
134
+ credentialId?: string
135
+ accepted?: boolean
136
+ }
137
+ if (body.repo !== p.repo || body.credentialId !== entry.id || body.accepted !== true)
138
+ throw new Error('Invalid status response')
106
139
  }
107
- if (body.repo !== p.repo || body.credentialId !== entry.id || body.accepted !== true)
108
- throw new Error('Invalid status response')
140
+ status = response.ok
141
+ ? 'accepted'
142
+ : response.status === 401 || response.status === 403
143
+ ? 'rejected; run ab connect'
144
+ : 'unreachable'
145
+ } catch {
146
+ status = 'unreachable'
109
147
  }
110
- status = response.ok
111
- ? 'accepted'
112
- : response.status === 401 || response.status === 403
113
- ? 'rejected; run ab connect'
114
- : 'unreachable'
115
- } catch {
116
- status = 'unreachable'
117
148
  }
149
+ opts.print(`Service: ${service}\nProject: ${p.repo}\nCredential: ${status}`)
118
150
  }
119
- opts.print(`Service: ${p.service}\nProject: ${p.repo}\nCredential: ${status}`)
120
151
  return
121
152
  }
122
153
  const service = normalizeServiceURL(raw)
123
- if (p.service && p.service !== service)
154
+ if (!opts.tickets && p.service && p.service !== service)
124
155
  throw new Error(
125
- 'Configured [store] url differs; update it explicitly before connecting to another service',
156
+ `Configured [${opts.tickets ? 'tickets' : 'store'}] url differs; update it explicitly before connecting to another service`,
126
157
  )
127
158
  await access(p.path, constants.W_OK)
128
159
  await access(dirname(p.path), constants.W_OK)
@@ -169,6 +200,7 @@ export async function abConnect(raw: string | undefined, opts: ConnectOptions):
169
200
  let approved = false
170
201
  let configChanged = false
171
202
  let saved = false
203
+ let updatedText = p.text
172
204
  try {
173
205
  let outcome: ConnectionOutcome | undefined
174
206
  while (!opts.signal?.aborted && now() <= Date.parse(started.expiresAt) + interval) {
@@ -199,15 +231,42 @@ export async function abConnect(raw: string | undefined, opts: ConnectOptions):
199
231
  approved = true
200
232
  if (outcome.repo !== p.repo || outcome.credentialId !== id)
201
233
  throw new Error('Approval identity mismatch')
202
- if (!p.service) {
203
- // Dotted root key also supports an existing empty [store] table after it.
204
- const after = `store.url = ${JSON.stringify(service)}\n${p.text}`
205
- const parsed = parse(after)
206
- if ((parsed.store as { url?: string })?.url !== service)
207
- throw new Error('Cannot safely update [store] url; add it manually and retry')
208
- await atomicConfig(p.path, p.text, after)
234
+ if (!p.service || (opts.tickets && (p.table.tickets as { url?: string }).url !== service)) {
235
+ updatedText = setConnectionURL(p.text, opts.tickets ? 'tickets' : 'store', service)
236
+ if (opts.tickets) parseConfig(updatedText)
237
+ else parse(updatedText)
238
+ await atomicConfig(p.path, p.text, updatedText)
209
239
  configChanged = true
210
240
  }
241
+ const configuredTickets = p.table.tickets as { source?: string; url?: string } | undefined
242
+ if (
243
+ opts.tickets ||
244
+ (configuredTickets?.source === 'hosted' &&
245
+ (!configuredTickets.url || normalizeServiceURL(configuredTickets.url) === service))
246
+ ) {
247
+ const tickets = parseConfig(updatedText).tickets
248
+ const lifecycle = ticketLifecycle(tickets)
249
+ await new HostedTicketSource({
250
+ url: service,
251
+ token,
252
+ repo: p.repo,
253
+ teamKey: tickets.teamKey!,
254
+ ...(opts.fetch ? { fetchFn: (input, init) => opts.fetch!(String(input), init) } : {}),
255
+ }).synchronizePolicy({
256
+ version: 1,
257
+ tickets: {
258
+ source: 'hosted',
259
+ teamKey: tickets.teamKey!,
260
+ readyState: lifecycle.ready,
261
+ triageState: lifecycle.triage,
262
+ claimedState: lifecycle.doing,
263
+ doneState: lifecycle.done,
264
+ createState: lifecycle.create,
265
+ proposalState: lifecycle.proposal,
266
+ ...(tickets.readyLabels !== undefined ? { readyLabels: tickets.readyLabels } : {}),
267
+ },
268
+ })
269
+ }
211
270
  credentials.save({ service, repo: p.repo, id, token })
212
271
  saved = true
213
272
  if (previous) {
@@ -233,21 +292,35 @@ export async function abConnect(raw: string | undefined, opts: ConnectOptions):
233
292
  }
234
293
  if (configChanged) {
235
294
  try {
236
- await atomicConfig(p.path, `store.url = ${JSON.stringify(service)}\n${p.text}`, p.text)
295
+ await atomicConfig(p.path, updatedText, p.text)
237
296
  } catch {
238
- opts.print('Restore [store] url manually if needed; configuration changed concurrently.')
297
+ opts.print(
298
+ `Restore [${opts.tickets ? 'tickets' : 'store'}] url manually if needed; configuration changed concurrently.`,
299
+ )
239
300
  }
240
301
  }
241
302
  }
242
303
  throw new Error(
243
304
  approved
244
- ? 'Connection could not be saved; previous credential retained. Repair local storage and run ab connect again.'
305
+ ? `Connection could not be saved; previous credential retained. ${(error as Error).message}. Repair the reported error and run ab connect again.`
245
306
  : (error as Error).message,
246
307
  )
247
308
  }
248
309
  }
249
310
  export async function abDisconnect(opts: ConnectOptions): Promise<void> {
311
+ if (opts.tickets && opts.builds) throw new Error('Choose --tickets or --builds')
250
312
  const p = await project(opts)
313
+ if (
314
+ !opts.tickets &&
315
+ !opts.builds &&
316
+ p.ticketService &&
317
+ p.service &&
318
+ normalizeServiceURL(String(p.ticketService)) !== p.service
319
+ )
320
+ throw new Error(
321
+ 'Multiple services: use ab disconnect --tickets for tickets; set AB_STORE to the build service and select --builds for builds',
322
+ )
323
+ if (!p.service && !opts.builds && p.ticketService) return abDisconnect({ ...opts, tickets: true })
251
324
  if (!p.service) throw new Error('No configured service')
252
325
  const credentials = new ProjectCredentials(opts.env)
253
326
  const entry = credentials.load(p.service, p.repo)
@@ -255,7 +328,37 @@ export async function abDisconnect(opts: ConnectOptions): Promise<void> {
255
328
  opts.print('No stored credential; project configuration and service data retained.')
256
329
  return
257
330
  }
331
+ if (p.ticketService && p.buildService === p.ticketService)
332
+ opts.print('Builds and tickets share this credential; disconnecting affects both uses.')
258
333
  await revoke(opts, p.service, entry.token)
259
334
  credentials.remove(p.service, p.repo)
260
335
  opts.print('Disconnected; project registration, data, and [store] url retained.')
261
336
  }
337
+
338
+ /** Edit only the selected table; preserve unrelated configuration and comments. */
339
+ export function setConnectionURL(
340
+ text: string,
341
+ table: 'tickets' | 'store',
342
+ service: string,
343
+ ): string {
344
+ const lines = text.split('\n')
345
+ let inside = false
346
+ let start = -1
347
+ for (let i = 0; i < lines.length; i++) {
348
+ const line = lines[i]!
349
+ if (/^\s*\[/.test(line)) {
350
+ if (inside) {
351
+ lines.splice(i, 0, `url = ${JSON.stringify(service)}`)
352
+ return lines.join('\n')
353
+ }
354
+ inside = new RegExp(`^\\s*\\[${table}\\]\\s*(?:#.*)?$`).test(line)
355
+ if (inside) start = i
356
+ } else if (inside && /^\s*url\s*=/.test(line)) {
357
+ lines[i] = `url = ${JSON.stringify(service)}`
358
+ return lines.join('\n')
359
+ }
360
+ }
361
+ if (start >= 0) lines.splice(start + 1, 0, `url = ${JSON.stringify(service)}`)
362
+ else lines.push(`[${table}]`, `url = ${JSON.stringify(service)}`)
363
+ return lines.join('\n')
364
+ }
@@ -1,3 +1,4 @@
1
+ import { readBuildFacts, type CurrentBuildFacts } from '../store/reduced-reads'
1
2
  /**
2
3
  * The `ab context` materializer (SPEC §8.3): a phase-scoped hydration of the
3
4
  * gitignored `.ab/` scratch dir. Everything derives from
@@ -16,14 +17,13 @@ import { mkdir, rm, writeFile } from 'node:fs/promises'
16
17
  import { dirname, join } from 'node:path'
17
18
  import { parseConfig } from '../config/load'
18
19
  import type { VerifyStepConfig } from '../config/schema'
19
- import type { AbEvent } from '../events/catalog'
20
20
  import {
21
21
  allowedTerminals,
22
22
  phaseSpecFor,
23
23
  type PhaseSpec,
24
24
  type TerminalCommand,
25
25
  } from '../kernel/phases'
26
- import { reduceBuild, type BuildState } from '../kernel/reducer'
26
+ import type { BuildState } from '../kernel/reducer'
27
27
  import {
28
28
  isVerifyPhase,
29
29
  verifyReportKind,
@@ -113,69 +113,12 @@ function requiredKinds(spec: PhaseSpec, phase: Phase): string[] {
113
113
 
114
114
  /** All finding objects ever deposited, by id — verdict payloads carry them in
115
115
  * full (§15.3), so feedback-by-id resolves without touching artifacts. */
116
- function findingsById(events: AbEvent[]): Map<string, Finding> {
117
- const byId = new Map<string, Finding>()
118
- for (const event of events) {
119
- if (event.type === 'plan-review.verdict' || event.type === 'code-review.verdict') {
120
- for (const finding of event.payload.findings) byId.set(finding.id, finding)
121
- }
122
- }
123
- return byId
116
+ function findingsById(events: CurrentBuildFacts): Map<string, Finding> {
117
+ return new Map(Object.values(events.findings).map((finding) => [finding.id, finding]))
124
118
  }
125
-
126
- /**
127
- * The feedback carried by this occurrence's `*.started` event (§15.3).
128
- * `implement.started`, `plan.started`, and agent `verify.started` may carry
129
- * feedback — the carrier that gets answered guidance to a fresh session after
130
- * a parked build re-attaches (§15.6-B). A `plan.started` without one (logs
131
- * predating the field) falls
132
- * back to deriving round-N feedback from the round-(N-1)
133
- * `plan-review.verdict` — the plan loop's verdicts ARE its findings channel
134
- * (§8.3: "prior plan rev + findings").
135
- */
136
- function currentFeedback(events: AbEvent[], phase: Phase, round: number): Feedback | null {
137
- if (isVerifyPhase(phase)) {
138
- const step = verifyStep(phase)
139
- for (let i = events.length - 1; i >= 0; i -= 1) {
140
- const event = events[i]!
141
- if (
142
- event.type === 'verify.started' &&
143
- event.payload.step === step &&
144
- event.payload.attempt === round
145
- ) {
146
- return event.payload.feedback ?? null
147
- }
148
- }
149
- return null
150
- }
151
- if (phase === 'implement') {
152
- for (let i = events.length - 1; i >= 0; i -= 1) {
153
- const event = events[i]!
154
- if (event.type === 'implement.started' && event.payload.round === round) {
155
- return event.payload.feedback ?? null
156
- }
157
- }
158
- return null
159
- }
160
- if (phase === 'plan') {
161
- for (let i = events.length - 1; i >= 0; i -= 1) {
162
- const event = events[i]!
163
- if (event.type === 'plan.started' && event.payload.round === round) {
164
- if (event.payload.feedback !== undefined) return event.payload.feedback
165
- break // legacy start without the field — derive from the prior verdict
166
- }
167
- }
168
- if (round <= 1) return null
169
- for (let i = events.length - 1; i >= 0; i -= 1) {
170
- const event = events[i]!
171
- if (event.type === 'plan-review.verdict' && event.payload.round === round - 1) {
172
- return event.payload.verdict === 'revise'
173
- ? { findings: event.payload.findings.map((finding) => finding.id) }
174
- : null
175
- }
176
- }
177
- }
178
- return null
119
+ function currentFeedback(events: CurrentBuildFacts, phase: Phase, round: number): Feedback | null {
120
+ const explicit = events.feedback[JSON.stringify([phase, round])]
121
+ return explicit ?? (phase === 'plan' ? (events.planFeedback[String(round)] ?? null) : null)
179
122
  }
180
123
 
181
124
  /** Empty and recreate `.ab/`; callers restore any tracked legacy entries. */
@@ -184,7 +127,7 @@ async function wipeAbDir(abDir: string): Promise<void> {
184
127
  await mkdir(abDir, { recursive: true })
185
128
  }
186
129
 
187
- function dismissedIds(state: BuildState, events: AbEvent[]): string[] {
130
+ function dismissedIds(state: BuildState, events: CurrentBuildFacts): string[] {
188
131
  const known = findingsById(events)
189
132
  const ids = new Set<string>()
190
133
  for (const escalation of state.answeredEscalations) {
@@ -205,8 +148,8 @@ export async function buildContext(deps: ContextDeps): Promise<ContextManifest>
205
148
  const { build, phase, round } = env
206
149
  const spec = phaseSpecFor(phase)
207
150
  const inputs = spec.inputs
208
- const events = await store.getEvents(build)
209
- const state = reduceBuild(events)
151
+ const events = (await readBuildFacts(store, build)).value
152
+ const state = events.state
210
153
 
211
154
  // Preserve legacy tracked scratch exactly while replacing disposable files.
212
155
  // Some adapter contract fixtures intentionally use a non-Git workspace;
@@ -271,9 +214,15 @@ export async function buildContext(deps: ContextDeps): Promise<ContextManifest>
271
214
  }
272
215
 
273
216
  if (inputs.ticket === true) {
274
- const created = events.find((event) => event.type === 'build.created')
217
+ const created = events.created
275
218
  if (created !== undefined && created.type === 'build.created') {
276
- const ticket = created.payload.ticket
219
+ const original = created.payload.ticket
220
+ const current =
221
+ original.source === 'hosted' ? (await store.getBuild(build))?.ticket : undefined
222
+ const ticket = {
223
+ ...original,
224
+ ...(current?.source === original.source ? { id: current.id } : {}),
225
+ }
277
226
  const lines = [
278
227
  `# Ticket: ${ticket.title ?? ticket.id}`,
279
228
  '',
@@ -436,13 +385,8 @@ export async function buildContext(deps: ContextDeps): Promise<ContextManifest>
436
385
  // records the execution input in reconcile.started. A crashed same-attempt
437
386
  // rerun can therefore have multiple starts; the newest matching AB_PHASE
438
387
  // attempt is authoritative.
439
- for (let i = events.length - 1; i >= 0; i -= 1) {
440
- const event = events[i]!
441
- if (event.type === 'reconcile.started' && event.payload.attempt === round) {
442
- manifest.conflict = { baseSha: event.payload.baseSha }
443
- break
444
- }
445
- }
388
+ const baseSha = events.reconcileBases[String(round)]
389
+ if (baseSha !== undefined) manifest.conflict = { baseSha }
446
390
  if (manifest.conflict === undefined) {
447
391
  throw new Error(
448
392
  `reconcile@${round} context requires a matching reconcile.started event ` +
@@ -482,10 +426,10 @@ export async function buildContext(deps: ContextDeps): Promise<ContextManifest>
482
426
  // Legacy tracked scratch is repository debt, not content Autobuild rewrites.
483
427
  // Record it once per build from the provisioned base so harvest can groom it.
484
428
  if (gitWorkspace && deps.ids !== undefined) {
485
- const provisioned = events.find((event) => event.type === 'workspace.provisioned')
429
+ const provisioned = events.firstWorkspace
486
430
  const marker = 'phase-scratch:tracked-at-provisioned-base'
487
- const alreadyRecorded = events.some(
488
- (event) => event.type === 'observation.recorded' && event.payload.refs?.includes(marker),
431
+ const alreadyRecorded = events.observations.some((observation) =>
432
+ observation.refs?.includes(marker),
489
433
  )
490
434
  if (
491
435
  provisioned !== undefined &&
@@ -1,3 +1,4 @@
1
+ import type { RepositoryReadFacts } from '../../store/reduced-reads'
1
2
  /**
2
3
  * The dashboard's pure projection: `BuildState` → what the operator sees.
3
4
  *
@@ -920,7 +921,7 @@ function escalationAttentionDismissed(events: RepositoryEvent[], terminalSeq: nu
920
921
  }
921
922
 
922
923
  function selectDashboardHarvestRun(
923
- events: RepositoryEvent[],
924
+ events: RepositoryEvent[] | RepositoryReadFacts,
924
925
  state: HarvestState,
925
926
  ): HarvestRunState | undefined {
926
927
  const attention = state.runs.find((run) => {
@@ -928,7 +929,12 @@ function selectDashboardHarvestRun(
928
929
  return run.recoveryExhaustion?.attentionAcknowledgedSeq === undefined
929
930
  }
930
931
  if (run.status !== 'escalated') return false
931
- return run.terminalSeq === undefined || !escalationAttentionDismissed(events, run.terminalSeq)
932
+ return (
933
+ run.terminalSeq === undefined ||
934
+ !(Array.isArray(events)
935
+ ? escalationAttentionDismissed(events, run.terminalSeq)
936
+ : events.dismissedThrough > run.terminalSeq)
937
+ )
932
938
  })
933
939
  return attention ?? openHarvestRun(state)
934
940
  }
@@ -937,7 +943,7 @@ function selectDashboardHarvestRun(
937
943
  * exhausted, or escalated attention outranks merely open work; completed and
938
944
  * acknowledged terminal history never hides an older actionable run. */
939
945
  function projectHarvestRun(
940
- events: RepositoryEvent[],
946
+ events: RepositoryEvent[] | RepositoryReadFacts,
941
947
  state: HarvestState,
942
948
  ): DashboardHarvest | undefined {
943
949
  const run = selectDashboardHarvestRun(events, state)
@@ -968,25 +974,27 @@ function projectHarvestRun(
968
974
  const approved = run.reviews.some((review) => review.verdict === 'approve')
969
975
  const synthOutput = run.proposals.length > 0
970
976
  const reviewOutput = run.reviews.length > 0
971
- const failedEvent =
972
- run.status === 'failed'
973
- ? [...events]
974
- .reverse()
975
- .find(
976
- (event) =>
977
- event.type === 'harvest.failed' &&
978
- event.payload.run === run.run &&
979
- event.payload.willRetry === false,
980
- )
981
- : undefined
977
+ const failureStoppedAt =
978
+ run.status !== 'failed'
979
+ ? undefined
980
+ : Array.isArray(events)
981
+ ? [...events]
982
+ .reverse()
983
+ .find(
984
+ (event) =>
985
+ event.type === 'harvest.failed' &&
986
+ event.payload.run === run.run &&
987
+ !event.payload.willRetry,
988
+ )?.ts
989
+ : events.failedAt[JSON.stringify(run.run)]
982
990
  // Both durable stop states freeze any open occurrence. A failure normally
983
991
  // closes its step immediately afterward, but projecting the boundary itself
984
992
  // must never make an errored row look like work is still accumulating time.
985
993
  const frozenAt =
986
994
  state.pausedAt !== undefined
987
995
  ? Date.parse(state.pausedAt)
988
- : failedEvent !== undefined
989
- ? Date.parse(failedEvent.ts)
996
+ : failureStoppedAt !== undefined
997
+ ? Date.parse(failureStoppedAt)
990
998
  : undefined
991
999
  const steps: PipelineStep[] = [
992
1000
  step('scan', hasOutcome('scan', 'completed'), current('scan'), {
@@ -1073,8 +1081,10 @@ interface RepositoryHarvestProjection {
1073
1081
  /** The repository-journal half of a legacy dashboard frame (AUT-545): the
1074
1082
  * harvest projection and the pause flag exactly as `buildDashboardFromProjected`
1075
1083
  * derives them, exposed for tests that pin the bounded read's equivalence. */
1076
- export function projectRepositoryHarvest(events: RepositoryEvent[]): RepositoryHarvestProjection {
1077
- const state = reduceHarvest(events)
1084
+ export function projectRepositoryHarvest(
1085
+ events: RepositoryEvent[] | RepositoryReadFacts,
1086
+ ): RepositoryHarvestProjection {
1087
+ const state = Array.isArray(events) ? reduceHarvest(events) : events.harvest
1078
1088
  const harvest = projectHarvestRun(events, state)
1079
1089
  return {
1080
1090
  harvestPaused: state.paused,
@@ -1082,7 +1092,9 @@ export function projectRepositoryHarvest(events: RepositoryEvent[]): RepositoryH
1082
1092
  }
1083
1093
  }
1084
1094
 
1085
- export function projectHarvest(events: RepositoryEvent[]): DashboardHarvest | undefined {
1095
+ export function projectHarvest(
1096
+ events: RepositoryEvent[] | RepositoryReadFacts,
1097
+ ): DashboardHarvest | undefined {
1086
1098
  return projectRepositoryHarvest(events).harvest
1087
1099
  }
1088
1100
 
@@ -1112,11 +1124,13 @@ export interface DashboardHeader extends DashboardFrameHeader {
1112
1124
  export function buildDashboardFromProjected(
1113
1125
  projectedBuilds: readonly DashboardBuild[],
1114
1126
  header: DashboardHeader,
1115
- repositoryEvents: RepositoryEvent[] = [],
1127
+ repositoryEvents: RepositoryEvent[] | RepositoryReadFacts = [],
1116
1128
  ): DashboardModel {
1117
1129
  const builds = [...projectedBuilds].sort((a, b) => a.slug.localeCompare(b.slug))
1118
1130
  const harvestProjection = projectRepositoryHarvest(repositoryEvents)
1119
- const settings = reduceDispatchSettings(repositoryEvents)
1131
+ const settings = Array.isArray(repositoryEvents)
1132
+ ? reduceDispatchSettings(repositoryEvents)
1133
+ : repositoryEvents.settings
1120
1134
  return {
1121
1135
  repo: header.repo,
1122
1136
  queued: header.queued,
@@ -1143,7 +1157,7 @@ export function buildDashboard(
1143
1157
  entries: { record: BuildRecord; state: BuildState; events: AbEvent[] }[],
1144
1158
  config: Config,
1145
1159
  header: DashboardFrameHeader,
1146
- repositoryEvents: RepositoryEvent[] = [],
1160
+ repositoryEvents: RepositoryEvent[] | RepositoryReadFacts = [],
1147
1161
  ): DashboardModel {
1148
1162
  const builds = entries
1149
1163
  .map(({ record, state, events }) => projectBuild(record, state, config, events))
@@ -211,7 +211,7 @@ export class DashboardBuildPollCache {
211
211
  // both the cached reduction and projected row untouched.
212
212
  next.set(
213
213
  record.slug,
214
- configChanged
214
+ configChanged || current.build?.ticketId !== record.ticket?.id
215
215
  ? {
216
216
  ...current,
217
217
  build: await this.projectRow(record, current.state, current.events, config),