@defrex/autobuild 0.8.0 → 0.10.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 (182) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +323 -26
  3. package/bin/ab-build-runner.ts +8 -2
  4. package/docs/architecture.md +166 -14
  5. package/docs/assets/headline-wide.png +0 -0
  6. package/docs/configuration.md +175 -28
  7. package/docs/hosted-dispatcher.md +69 -13
  8. package/docs/operator-api.md +2 -2
  9. package/docs/remote-store-protocol.md +204 -16
  10. package/docs/remote-ticket-protocol.md +1 -1
  11. package/docs/setup.md +12 -9
  12. package/docs/website.md +14 -0
  13. package/package.json +45 -23
  14. package/packages/core/src/cli/artifact.ts +130 -6
  15. package/packages/core/src/cli/binary.ts +9 -1
  16. package/packages/core/src/cli/build-control.ts +110 -13
  17. package/packages/core/src/cli/context.ts +46 -0
  18. package/packages/core/src/cli/dashboard/facts.ts +215 -0
  19. package/packages/core/src/cli/dashboard/frame-parse.ts +223 -0
  20. package/packages/core/src/cli/dashboard/model.ts +141 -153
  21. package/packages/core/src/cli/dashboard/poll.ts +76 -12
  22. package/packages/core/src/cli/dashboard/render.ts +34 -5
  23. package/packages/core/src/cli/dispatch-frontend.ts +54 -5
  24. package/packages/core/src/cli/dispatch-process.ts +1 -13
  25. package/packages/core/src/cli/dispatch.ts +392 -75
  26. package/packages/core/src/cli/harvest.ts +122 -8
  27. package/packages/core/src/cli/help.ts +62 -9
  28. package/packages/core/src/cli/init-readiness-remote.ts +110 -0
  29. package/packages/core/src/cli/init-readiness-shared.ts +72 -0
  30. package/packages/core/src/cli/init-validation.ts +266 -222
  31. package/packages/core/src/cli/installation.ts +50 -8
  32. package/packages/core/src/cli/main.ts +70 -11
  33. package/packages/core/src/cli/mcp.ts +23 -1
  34. package/packages/core/src/cli/plugin.ts +7 -4
  35. package/packages/core/src/cli/remote-poll.ts +277 -0
  36. package/packages/core/src/cli/repo-state.ts +22 -0
  37. package/packages/core/src/cli/repository-status.ts +39 -1
  38. package/packages/core/src/cli/self-update.ts +77 -2
  39. package/packages/core/src/cli/status.ts +150 -28
  40. package/packages/core/src/cli/terminals.ts +2 -1
  41. package/packages/core/src/cli/ticket-assets.ts +247 -0
  42. package/packages/core/src/cli/ticket.ts +453 -8
  43. package/packages/core/src/cli/wait.ts +157 -41
  44. package/packages/core/src/cli/watch.ts +138 -168
  45. package/packages/core/src/config/live.ts +23 -0
  46. package/packages/core/src/config/pipeline-source.ts +388 -0
  47. package/packages/core/src/config/schema.ts +251 -19
  48. package/packages/core/src/events/catalog.ts +1 -0
  49. package/packages/core/src/events/globs.ts +47 -0
  50. package/packages/core/src/events/payloads.ts +52 -2
  51. package/packages/core/src/events/repository.ts +52 -1
  52. package/packages/core/src/events/sessions.ts +69 -10
  53. package/packages/core/src/events/workspace-remote.ts +12 -0
  54. package/packages/core/src/integration/harness.ts +115 -2
  55. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +1369 -0
  56. package/packages/core/src/kernel/auto-merge-default.ts +157 -0
  57. package/packages/core/src/kernel/auto-merge.ts +99 -22
  58. package/packages/core/src/kernel/dispatch-settings.ts +42 -17
  59. package/packages/core/src/kernel/dispatch-status.ts +23 -0
  60. package/packages/core/src/kernel/engine.ts +9 -438
  61. package/packages/core/src/kernel/generators/build-log.ts +259 -0
  62. package/packages/core/src/kernel/generators/repository-journals.ts +206 -0
  63. package/packages/core/src/kernel/generators/session-log.ts +72 -0
  64. package/packages/core/src/kernel/harvest.ts +62 -18
  65. package/packages/core/src/kernel/incremental-contract.ts +75 -0
  66. package/packages/core/src/kernel/incremental-fixtures.ts +211 -0
  67. package/packages/core/src/kernel/incremental.ts +53 -0
  68. package/packages/core/src/kernel/log-index.ts +620 -0
  69. package/packages/core/src/kernel/phases.ts +16 -2
  70. package/packages/core/src/kernel/pr-attachments.ts +164 -46
  71. package/packages/core/src/kernel/reducer-registry.ts +164 -0
  72. package/packages/core/src/kernel/reducer.ts +254 -73
  73. package/packages/core/src/operator/annotations.ts +6 -0
  74. package/packages/core/src/operator/control.ts +19 -5
  75. package/packages/core/src/operator/index.ts +71 -6
  76. package/packages/core/src/operator/query.ts +253 -24
  77. package/packages/core/src/operator/read-view.ts +98 -0
  78. package/packages/core/src/operator/registry.ts +55 -10
  79. package/packages/core/src/operator/requests.ts +9 -6
  80. package/packages/core/src/operator/sandbox-host.ts +140 -0
  81. package/packages/core/src/operator/sandbox.ts +247 -14
  82. package/packages/core/src/operator/ticket-source-backend.ts +47 -0
  83. package/packages/core/src/orchestrator/approvals.ts +115 -0
  84. package/packages/core/src/orchestrator/conversation.ts +167 -0
  85. package/packages/core/src/orchestrator/model.ts +86 -0
  86. package/packages/core/src/orchestrator/prompt.ts +61 -0
  87. package/packages/core/src/orchestrator/tools.ts +117 -0
  88. package/packages/core/src/orchestrator/turn-runner.ts +457 -0
  89. package/packages/core/src/plugin-sdk/index.ts +51 -1
  90. package/packages/core/src/plugins/load.ts +135 -7
  91. package/packages/core/src/plugins/manifest.ts +183 -10
  92. package/packages/core/src/plugins/registry.ts +73 -4
  93. package/packages/core/src/ports/forge/create.ts +1 -1
  94. package/packages/core/src/ports/forge/fake.ts +31 -0
  95. package/packages/core/src/ports/forge/github-transport.ts +13 -2
  96. package/packages/core/src/ports/forge/github.ts +13 -2
  97. package/packages/core/src/ports/forge/local-git.ts +31 -1
  98. package/packages/core/src/ports/runner/claude.ts +2 -2
  99. package/packages/core/src/ports/runner/codex.ts +6 -2
  100. package/packages/core/src/ports/runner/fake.ts +7 -1
  101. package/packages/core/src/ports/runner/pi.ts +8 -6
  102. package/packages/core/src/ports/runner/provider-error.ts +13 -3
  103. package/packages/core/src/ports/tickets/fake.ts +5 -1
  104. package/packages/core/src/ports/tickets/file.ts +169 -48
  105. package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
  106. package/packages/core/src/ports/tickets/linear.ts +33 -1
  107. package/packages/core/src/ports/tickets/operations.ts +1 -1
  108. package/packages/core/src/ports/tickets/remote.ts +8 -1
  109. package/packages/core/src/ports/types.ts +49 -3
  110. package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
  111. package/packages/core/src/ports/workspace/create.ts +74 -39
  112. package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
  113. package/packages/core/src/ports/workspace/fake.ts +169 -4
  114. package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
  115. package/packages/core/src/ports/workspace/github-origin.ts +36 -0
  116. package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
  117. package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
  118. package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
  119. package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
  120. package/packages/core/src/processes/build-child.ts +17 -2
  121. package/packages/core/src/processes/build-execution-state.ts +68 -15
  122. package/packages/core/src/processes/build-runner.ts +238 -93
  123. package/packages/core/src/processes/dispatcher-selectors.ts +123 -0
  124. package/packages/core/src/processes/dispatcher.ts +501 -201
  125. package/packages/core/src/processes/execution-settlement.ts +72 -39
  126. package/packages/core/src/processes/harvest-child.ts +5 -0
  127. package/packages/core/src/processes/harvest-execution-state.ts +41 -29
  128. package/packages/core/src/processes/harvest-runner.ts +66 -19
  129. package/packages/core/src/processes/harvest.ts +196 -24
  130. package/packages/core/src/processes/infrastructure-failure-budget.ts +31 -18
  131. package/packages/core/src/processes/orchestrator-tick.ts +360 -0
  132. package/packages/core/src/processes/publication-state.ts +164 -52
  133. package/packages/core/src/processes/repo-view.ts +1436 -0
  134. package/packages/core/src/processes/sandbox-state.ts +36 -2
  135. package/packages/core/src/processes/tick-state.ts +33 -0
  136. package/packages/core/src/store/adapter.ts +41 -0
  137. package/packages/core/src/store/build-scope.ts +90 -0
  138. package/packages/core/src/store/contract.ts +1205 -0
  139. package/packages/core/src/store/digest.ts +102 -0
  140. package/packages/core/src/store/local/schema.ts +46 -2
  141. package/packages/core/src/store/local/store.ts +445 -4
  142. package/packages/core/src/store/memory.ts +246 -0
  143. package/packages/core/src/store/new-build.ts +17 -0
  144. package/packages/core/src/store/phase-session.ts +94 -0
  145. package/packages/core/src/store/remote/client.ts +310 -2
  146. package/packages/core/src/store/remote/index.ts +14 -2
  147. package/packages/core/src/store/remote/protocol.ts +115 -12
  148. package/packages/core/src/store/remote/version.ts +6 -2
  149. package/packages/core/src/store/repo-state-events.ts +205 -0
  150. package/packages/core/src/store/retention.ts +7 -0
  151. package/packages/core/src/store/session-handle.ts +94 -0
  152. package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +79 -19
  153. package/packages/core/src/store/snapshots.ts +88 -0
  154. package/packages/core/src/store/streams/session-writer.ts +42 -2
  155. package/packages/core/src/store/streams/wait.ts +4 -5
  156. package/packages/core/src/store/ticket-assets.ts +458 -0
  157. package/packages/core/src/store/types.ts +160 -1
  158. package/packages/core/src/testing/index.ts +59 -0
  159. package/skills/code-review/SKILL.md +5 -1
  160. package/skills/guide/SKILL.md +152 -40
  161. package/skills/guide/references/operator-api.md +2 -2
  162. package/skills/guide/references/plugin-authoring.md +17 -0
  163. package/skills/guide/references/remote-store-protocol.md +204 -16
  164. package/skills/guide/references/remote-ticket-protocol.md +1 -1
  165. package/skills/guide/references/setup.md +12 -9
  166. package/skills/implement/SKILL.md +3 -0
  167. package/skills/operate/SKILL.md +126 -0
  168. package/skills/plan/SKILL.md +4 -0
  169. package/skills/plan-review/SKILL.md +5 -1
  170. package/skills/spec/SKILL.md +46 -3
  171. package/skills/tickets/SKILL.md +57 -5
  172. package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
  173. package/packages/core/src/cli/store-opening.contract.ts +0 -289
  174. package/packages/core/src/cli/testkit.ts +0 -194
  175. package/packages/core/src/markdown.ts +0 -67
  176. package/packages/core/src/operator/client.ts +0 -311
  177. package/packages/core/src/operator/server.ts +0 -616
  178. package/packages/core/src/operator/sessions.ts +0 -223
  179. package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
  180. package/packages/core/src/ports/tickets/remote-server.ts +0 -185
  181. package/packages/core/src/store/remote/server.ts +0 -923
  182. package/packages/core/src/testing/store-failures.ts +0 -32
@@ -15,6 +15,17 @@ export interface RepositoryStatus extends DispatchSettings {
15
15
  state: 'live' | 'stopped'
16
16
  lastEvidenceAt: string
17
17
  }>
18
+ /** PR-only publications from operator sandboxes (AUT-343), newest first,
19
+ * latest per (operator, branch). */
20
+ publications: Array<{
21
+ operator: string
22
+ branch: string
23
+ sha: string
24
+ prNumber: number
25
+ prUrl: string
26
+ session?: string
27
+ at: string
28
+ }>
18
29
  }
19
30
 
20
31
  export interface RepositoryStatusOpts {
@@ -31,6 +42,27 @@ export interface RepositoryStatusOpts {
31
42
  }
32
43
 
33
44
  export function projectRepositoryStatus(repo: string, events: RepositoryEvent[]): RepositoryStatus {
45
+ const publications: RepositoryStatus['publications'] = []
46
+ for (const event of events) {
47
+ if (event.type !== 'orchestrator.sandbox.published') continue
48
+ const payload = event.payload
49
+ // Latest per (operator, branch): a later fact for the same pair
50
+ // supersedes the earlier one.
51
+ const index = publications.findIndex(
52
+ (entry) => entry.operator === payload.operator && entry.branch === payload.branch,
53
+ )
54
+ if (index !== -1) publications.splice(index, 1)
55
+ publications.push({
56
+ operator: payload.operator,
57
+ branch: payload.branch,
58
+ sha: payload.sha,
59
+ prNumber: payload.pr.number,
60
+ prUrl: payload.pr.url,
61
+ ...(payload.session !== undefined ? { session: payload.session } : {}),
62
+ at: event.ts,
63
+ })
64
+ }
65
+ publications.reverse()
34
66
  return {
35
67
  repo,
36
68
  ...reduceDispatchSettings(events),
@@ -43,6 +75,7 @@ export function projectRepositoryStatus(repo: string, events: RepositoryEvent[])
43
75
  state: state.state === 'stopped' ? ('stopped' as const) : ('live' as const),
44
76
  lastEvidenceAt: state.lastEvidenceTs,
45
77
  })),
78
+ publications,
46
79
  }
47
80
  }
48
81
 
@@ -57,6 +90,10 @@ export function renderRepositoryStatus(status: RepositoryStatus): string[] {
57
90
  (sandbox) =>
58
91
  `operator sandbox ${sandbox.environmentId} (${sandbox.provider}) — ${sandbox.state}, idle since ${sandbox.lastEvidenceAt}`,
59
92
  ),
93
+ ...status.publications.map(
94
+ (publication) =>
95
+ `publication by ${publication.operator} — ${publication.branch} at ${publication.sha.slice(0, 8)} → PR #${publication.prNumber} (${publication.prUrl})`,
96
+ ),
60
97
  ]
61
98
  }
62
99
 
@@ -69,7 +106,8 @@ export async function abRepositoryStatus(opts: RepositoryStatusOpts): Promise<vo
69
106
  // A fresh store has no repository row. Do not call ensureRepo: this query's
70
107
  // empty-stream defaults must remain a genuinely read-only operation.
71
108
  const record = await store.getRepo(repo)
72
- const events = record === null ? [] : await store.getRepoEvents(repo)
109
+ // Bounded read (AUT-489): projectRepositoryStatus reduces durable types only.
110
+ const events = record === null ? [] : await store.getRepoStateEvents(repo)
73
111
  const status = projectRepositoryStatus(repo, events)
74
112
  if (opts.json === true) {
75
113
  opts.stdout(JSON.stringify(status, null, 2))
@@ -1,3 +1,4 @@
1
+ import { readFile, writeFile } from 'node:fs/promises'
1
2
  import semver from 'semver'
2
3
  import type { ExecResult } from '../ports/workspace/git-worktree'
3
4
  import { registryBaseUrl, registryVersionUrl } from '../registry'
@@ -230,6 +231,36 @@ export function updateDependency(install: ManagedInstallation, version: string):
230
231
  : `${install.packageName}@${version}`
231
232
  }
232
233
 
234
+ /** The specifier written into package.json for a resolved version: what
235
+ * `bun add <pkg>@<version>` writes (the exact version) for the registry, the tag for a forge. */
236
+ function manifestSpecifier(install: ManagedInstallation, version: string): string {
237
+ return install.channel === 'github'
238
+ ? `github:${install.owner}/${install.repository}#v${version}`
239
+ : version
240
+ }
241
+
242
+ /** Set the package's specifier in every section that declares it, preserving
243
+ * key order, indentation, and the trailing newline. Returns the new bytes. */
244
+ export function rewriteDeclaredSpecifiers(
245
+ manifestText: string,
246
+ packageName: string,
247
+ specifier: string,
248
+ ): string {
249
+ const manifest = JSON.parse(manifestText) as Record<string, unknown>
250
+ for (const section of ['dependencies', 'devDependencies']) {
251
+ const entries = manifest[section]
252
+ if (
253
+ typeof entries === 'object' &&
254
+ entries !== null &&
255
+ typeof (entries as Record<string, unknown>)[packageName] === 'string'
256
+ ) {
257
+ ;(entries as Record<string, unknown>)[packageName] = specifier
258
+ }
259
+ }
260
+ const indent = /^([ \t]+)"/m.exec(manifestText)?.[1] ?? 2
261
+ return JSON.stringify(manifest, null, indent) + (manifestText.endsWith('\n') ? '\n' : '')
262
+ }
263
+
233
264
  /** Silently determine whether this installation has a newer published release.
234
265
  * This read-only courtesy intentionally collapses every unsupported install,
235
266
  * command failure, malformed response, and cancellation to no result. */
@@ -244,7 +275,9 @@ export async function availableRelease(
244
275
  const identity = await readDistributionIdentity(distRoot)
245
276
  if (identity.sourceCheckout || signalAborted(options.signal)) return undefined
246
277
 
247
- const globalBin = await command(['bun', 'pm', 'bin', '-g'], { signal: options.signal })
278
+ const globalBin = await command(['bun', 'pm', 'bin', '-g'], {
279
+ signal: options.signal,
280
+ })
248
281
  if (
249
282
  globalBin.exitCode !== 0 ||
250
283
  globalBin.stdout.trim() === '' ||
@@ -369,11 +402,53 @@ export async function selfUpdate(options: SelfUpdateOptions): Promise<SelfUpdate
369
402
  options.stdout(
370
403
  `Updating Autobuild to v${resolved} in ${install.ownerRoot}; Bun will rewrite ${install.ownerManifest} and ${install.ownerLock}.`,
371
404
  )
372
- updateCommand = ['bun', 'add', '--cwd', install.ownerRoot, dependency]
405
+ if (install.dual) {
406
+ // Bun cannot reconcile two differing specifiers through `bun add`: it
407
+ // exits 0 yet keeps the old version. Bring both entries to the new
408
+ // specifier and let `bun install` apply it.
409
+ options.stdout(
410
+ `${install.packageName} is declared in both dependencies and devDependencies; updating both entries.`,
411
+ )
412
+ updateCommand = ['bun', 'install', '--cwd', install.ownerRoot]
413
+ } else {
414
+ updateCommand = [
415
+ 'bun',
416
+ 'add',
417
+ '--cwd',
418
+ install.ownerRoot,
419
+ ...(install.section === 'devDependencies' ? ['-d'] : []),
420
+ dependency,
421
+ ]
422
+ }
423
+ }
424
+
425
+ let originalManifest: string | undefined
426
+ if (install.scope === 'local' && install.dual) {
427
+ try {
428
+ originalManifest = await readFile(install.ownerManifest, 'utf8')
429
+ await writeFile(
430
+ install.ownerManifest,
431
+ rewriteDeclaredSpecifiers(
432
+ originalManifest,
433
+ install.packageName,
434
+ manifestSpecifier(install, resolved),
435
+ ),
436
+ )
437
+ } catch (error) {
438
+ const reason = `rewriting ${install.ownerManifest}: ${error instanceof Error ? error.message : String(error)}`
439
+ if (explicit) {
440
+ options.stderr(`self-update failed: ${reason}`)
441
+ return { kind: 'failed' }
442
+ }
443
+ return warn(options, reason)
444
+ }
373
445
  }
374
446
 
375
447
  const update = await invoke(updateCommand, {})
376
448
  if (update.exitCode !== 0) {
449
+ if (originalManifest !== undefined) {
450
+ await writeFile(install.ownerManifest, originalManifest).catch(() => undefined)
451
+ }
377
452
  const reason = commandFailure(`installing v${resolved}`, update)
378
453
  if (explicit) {
379
454
  options.stderr(`self-update failed: ${reason}`)
@@ -39,10 +39,13 @@ import { loadConfig } from '../config/load'
39
39
  import type { Config } from '../config/schema'
40
40
  import {
41
41
  BUILD_EFFECTIVE_CONFIG_ARTIFACT,
42
+ parseBuildConfigMetadata,
42
43
  parseEffectiveBuildConfig,
43
44
  } from '../processes/build-execution-state'
45
+ import type { PipelineSourceMeta } from '../config/pipeline-source'
44
46
  import { openExecution } from '../processes/execution-settlement'
45
47
  import { currentAutoMergeDeferral } from '../kernel/auto-merge'
48
+ import { autoMergeDisplay, type AutoMergeDisplay } from './dashboard/model'
46
49
  import { decideNext } from '../kernel/engine'
47
50
  import {
48
51
  reduceBuild,
@@ -64,6 +67,7 @@ import type { BuildRecord, BuildStore, StreamRecord, StreamScope } from '../stor
64
67
  import { buildProgress, isDiverged, type BuildProgress } from './build-progress'
65
68
  import { normalizeGitRemoteUrl } from '../kernel/origin'
66
69
  import { buildInRepository, resolveMainRepo } from './repo-state'
70
+ import { writeAssetTo } from './ticket-assets'
67
71
  import { withAmbientReadStore, type StoreOpener } from './store-opening'
68
72
 
69
73
  /** Backward-compatible name for callers/tests; repository resolution is shared. */
@@ -135,6 +139,10 @@ export interface BuildDetail extends BuildSummary {
135
139
  /** Current work-owner decision. Open PRs use the build-owned config; ended
136
140
  * PRs project the repository completion path directly from durable state. */
137
141
  decision?: BuildDecisionProjection
142
+ /** The build's effective auto-merge display state (off, requested, enabled,
143
+ * cancelling) — the same projection the dashboards render, so `--json` and
144
+ * the text agree and `waiting: on PR` can say why it is waiting. */
145
+ autoMerge: AutoMergeDisplay
138
146
  openEscalations: OpenEscalation[]
139
147
  /** Per-loop review overrides for the current spec; omitted when none are set. */
140
148
  reviewRoundCeilings?: { plan?: number; code?: number }
@@ -155,6 +163,11 @@ export interface BuildDetail extends BuildSummary {
155
163
  lastEvent?: { type: string; seq: number; ts: string; actor: Actor }
156
164
  /** Present only with `--events <n>`: the newest n, chronological. */
157
165
  events?: AbEvent[]
166
+ /** Which `autobuild.toml` the build's pinned pipeline was read from, and the
167
+ * effective-config artifact revision it was deposited under (SPEC §16.1).
168
+ * Absent when no effective-config artifact has been deposited. */
169
+ pipelineSource?: PipelineSourceMeta
170
+ effectiveConfigRev?: number
158
171
  outcome?: BuildOutcome
159
172
  }
160
173
 
@@ -192,7 +205,7 @@ function leaseInfo(record: BuildRecord, now: Date): LeaseInfo {
192
205
  * The summary over an ALREADY-reduced state — so `detail` reduces the log
193
206
  * once and builds both projections from it, rather than reducing twice.
194
207
  */
195
- function summarizeFrom(record: BuildRecord, state: BuildState, now: Date): BuildSummary {
208
+ export function summarizeFrom(record: BuildRecord, state: BuildState, now: Date): BuildSummary {
196
209
  // `phase`, `round`, and `attempt` describe ONE phase occurrence, so they come
197
210
  // from ONE source: the in-flight phase, else the last completed one — the
198
211
  // same `currentPhase ?? lastCompletedPhase` the reducer uses to derive
@@ -287,6 +300,7 @@ export function detail(
287
300
  }))
288
301
  return {
289
302
  ...summary,
303
+ autoMerge: autoMergeDisplay(state),
290
304
  ...(decision !== undefined ? { decision } : {}),
291
305
  openEscalations: state.openEscalations,
292
306
  ...(state.reviewRoundCeilings.plan !== undefined || state.reviewRoundCeilings.code !== undefined
@@ -493,6 +507,16 @@ export function renderDetail(d: BuildDetail, now: Date): string[] {
493
507
  lines.push(` review round ceiling: ${ceilings.join(', ')}`)
494
508
  }
495
509
  lines.push(` updated: ${d.updatedAt} (${relativeTime(d.updatedAt, now)})`)
510
+ if (d.pipelineSource !== undefined || d.effectiveConfigRev !== undefined) {
511
+ const source =
512
+ d.pipelineSource !== undefined
513
+ ? `autobuild.toml@${
514
+ d.pipelineSource.commit !== undefined ? d.pipelineSource.commit.slice(0, 7) : 'unknown'
515
+ } (${d.pipelineSource.ref})`
516
+ : 'autobuild.toml@unknown (pre-pin)'
517
+ const rev = d.effectiveConfigRev !== undefined ? ` config rev ${d.effectiveConfigRev}` : ''
518
+ lines.push(` pipeline: ${source}${rev}`)
519
+ }
496
520
 
497
521
  // Its own line, never folded into status: a running build with an expired
498
522
  // lease is the case this exists to make visible.
@@ -548,9 +572,11 @@ export function renderDetail(d: BuildDetail, now: Date): string[] {
548
572
  )
549
573
  }
550
574
  if (d.decision?.kind === 'awaiting-pr') {
551
- lines.push(
552
- ` waiting: on PR${d.decision.reason !== undefined ? ` — ${d.decision.reason}` : ''}`,
553
- )
575
+ // The deferral reason (a durable observation about WHY consent could not
576
+ // be applied) takes precedence; otherwise the auto-merge state itself is
577
+ // what the parked operator needs — off names the missing consent.
578
+ const consent = d.decision.reason ?? `auto merge ${d.autoMerge}`
579
+ lines.push(` waiting: on PR — ${consent}`)
554
580
  } else if (d.decision?.kind === 'repository-completion') {
555
581
  lines.push(
556
582
  ` waiting: repository-level completion after the PR was ${d.decision.prState}; no runner re-attachment is pending`,
@@ -703,9 +729,43 @@ export async function abBuilds(opts: AbBuildsOpts): Promise<void> {
703
729
  const wanted = new Set(statusFilter(opts.all, opts.queued))
704
730
  // Cross-repo aggregation is out of scope (§12: one dispatcher per repo,
705
731
  // one repo's builds per answer).
732
+ // Records are read and held before the digest read: builds are never
733
+ // deleted, so every identity-keyed record iterated below is guaranteed an
734
+ // entry in the digest map fetched after it — a build created between the
735
+ // two reads appears only in the digest map, which is harmless — and the
736
+ // completeness check can only ever catch a genuine adapter bug, never a
737
+ // concurrent dispatch (AUT-488 finding f_3cb67aba).
706
738
  const records = (await store.listBuilds()).filter(mine)
739
+ // One digest read gates the per-build history reads (AUT-488): the active
740
+ // and queued scopes never show a terminal build, so an identity-keyed
741
+ // build whose digest already carries a terminal fact skips its `getEvents`
742
+ // round trip and the listing's store cost stays flat as finished builds
743
+ // accumulate. The `--all` scope includes terminal statuses, so it takes no
744
+ // digest read and skips nothing — byte-for-byte the old loop.
745
+ const digests = opts.all === true ? undefined : await store.getRepoBuildDigests(identity)
707
746
  const summaries: BuildSummary[] = []
708
747
  for (const record of records) {
748
+ if (digests !== undefined && record.repo === identity) {
749
+ // Completeness is contractual (one entry per repo build); a missing
750
+ // entry is an adapter bug and must fail loudly rather than silently
751
+ // drop the build from the listing.
752
+ const digest = digests.get(record.slug)
753
+ if (digest === undefined) {
754
+ throw new Error(`getRepoBuildDigests is missing an entry for build "${record.slug}"`)
755
+ }
756
+ // The digest's `terminal` follows `reduceBuild`'s terminal rule
757
+ // exactly (in-order overwrite of `build.completed`/`build.aborted`,
758
+ // never cleared — pinned against `reduceBuild` in
759
+ // store/digest.test.ts), so `terminal !== undefined` is exactly
760
+ // reduced status `done`/`aborted`, which the active and queued status
761
+ // sets never include. Skipping the history read therefore cannot
762
+ // change the emitted summaries.
763
+ if (digest.terminal !== undefined) continue
764
+ }
765
+ // A record matched only through the legacy `repoOrigin` path has no
766
+ // entry under `identity` — `getRepoBuildDigests` groups by `record.repo`
767
+ // exactly — so it keeps its per-build history read (correct, just not
768
+ // accelerated).
709
769
  const summary = summarize(record, await store.getEvents(record.slug), now)
710
770
  if (wanted.has(summary.status)) summaries.push(summary)
711
771
  }
@@ -830,6 +890,40 @@ async function projectBuildDecision(
830
890
  }
831
891
  }
832
892
 
893
+ type AmbientReadContext = Parameters<Parameters<typeof withAmbientReadStore>[1]>[0]
894
+
895
+ /** The build record, after the repository gate every single-build read shares
896
+ * (see `abBuildStatus`). */
897
+ async function requireReadableBuild(
898
+ opts: AbBuildStatusOpts,
899
+ context: AmbientReadContext,
900
+ ): Promise<BuildRecord> {
901
+ const record = await context.store.getBuild(opts.slug)
902
+ if (record === null) {
903
+ throw new Error(
904
+ `no build "${opts.slug}" in this store — run 'ab builds --all' to list ` +
905
+ "this repo's builds, or pass --store <ref> if it lives in another store",
906
+ )
907
+ }
908
+ // Own-session authority: slug equality bounds the read to the session's
909
+ // own build, and an explicit --store must name the session's own store for
910
+ // the bypass to apply. Every other combination falls through to the
911
+ // ordinary repository gate, unchanged — which now keys on the checkout's
912
+ // normalized origin identity (`context.checkout` is the physical path;
913
+ // `buildInRepository` derives the identity from it).
914
+ const ownSession =
915
+ context.ambient !== undefined &&
916
+ 'build' in context.ambient &&
917
+ context.ambient.build === opts.slug &&
918
+ (opts.storeRef === undefined || opts.storeRef === context.ambient.store)
919
+ if (!(ownSession || (await buildInRepository(record, context.checkout, opts.exec)))) {
920
+ throw new Error(
921
+ `build "${opts.slug}" belongs to repository "${record.repo}", not "${context.repo}"`,
922
+ )
923
+ }
924
+ return record
925
+ }
926
+
833
927
  /** `ab build status <slug>` — one build in detail. Read-only.
834
928
  *
835
929
  * Repository authority mirrors the other ambient reads: an operator invocation
@@ -849,29 +943,7 @@ async function projectBuildDecision(
849
943
  export async function abBuildStatus(opts: AbBuildStatusOpts): Promise<void> {
850
944
  const now = (opts.now ?? (() => new Date()))()
851
945
  await withAmbientReadStore(opts, async (context) => {
852
- const record = await context.store.getBuild(opts.slug)
853
- if (record === null) {
854
- throw new Error(
855
- `no build "${opts.slug}" in this store — run 'ab builds --all' to list ` +
856
- "this repo's builds, or pass --store <ref> if it lives in another store",
857
- )
858
- }
859
- // Own-session authority: slug equality bounds the read to the session's
860
- // own build, and an explicit --store must name the session's own store for
861
- // the bypass to apply. Every other combination falls through to the
862
- // ordinary repository gate, unchanged — which now keys on the checkout's
863
- // normalized origin identity (`context.checkout` is the physical path;
864
- // `buildInRepository` derives the identity from it).
865
- const ownSession =
866
- context.ambient !== undefined &&
867
- 'build' in context.ambient &&
868
- context.ambient.build === opts.slug &&
869
- (opts.storeRef === undefined || opts.storeRef === context.ambient.store)
870
- if (!(ownSession || (await buildInRepository(record, context.checkout, opts.exec)))) {
871
- throw new Error(
872
- `build "${opts.slug}" belongs to repository "${record.repo}", not "${context.repo}"`,
873
- )
874
- }
946
+ const record = await requireReadableBuild(opts, context)
875
947
  const events = await context.store.getEvents(opts.slug)
876
948
  const decision = await projectBuildDecision(opts.slug, events, context.store)
877
949
  // Stream enrichment is presentation (SPEC §9): an unavailable read
@@ -885,7 +957,22 @@ export async function abBuildStatus(opts: AbBuildStatusOpts): Promise<void> {
885
957
  } catch {
886
958
  streams = undefined
887
959
  }
888
- const d = detail(record, events, now, opts.events, decision, streams)
960
+ const projected = detail(record, events, now, opts.events, decision, streams)
961
+ // Operator visibility (SPEC §16.1): which autobuild.toml the build's
962
+ // pinned pipeline came from, alongside the effective-config revision.
963
+ const configArtifact = await context.store.getArtifact(
964
+ opts.slug,
965
+ BUILD_EFFECTIVE_CONFIG_ARTIFACT,
966
+ )
967
+ const configMeta =
968
+ configArtifact !== null ? parseBuildConfigMetadata(configArtifact) : undefined
969
+ const d: BuildDetail = {
970
+ ...projected,
971
+ ...(configMeta?.revision !== undefined ? { effectiveConfigRev: configMeta.revision } : {}),
972
+ ...(configMeta?.pipelineSource !== undefined
973
+ ? { pipelineSource: configMeta.pipelineSource }
974
+ : {}),
975
+ }
889
976
  if (opts.json === true) {
890
977
  opts.stdout(JSON.stringify(d, null, 2))
891
978
  return
@@ -893,3 +980,38 @@ export async function abBuildStatus(opts: AbBuildStatusOpts): Promise<void> {
893
980
  for (const line of renderDetail(d, now)) opts.stdout(line)
894
981
  })
895
982
  }
983
+
984
+ export interface AbBuildAssetGetOpts extends AbBuildStatusOpts {
985
+ kind: string
986
+ name: string
987
+ dest: string
988
+ rev?: number
989
+ }
990
+
991
+ /** `ab build asset get <slug> <kind> <name> <dest>` — the exact bytes a build
992
+ * froze at its claim (SPEC §6.3), available after the build has finished. */
993
+ export async function abBuildAssetGet(opts: AbBuildAssetGetOpts): Promise<void> {
994
+ await withAmbientReadStore(opts, async (context) => {
995
+ await requireReadableBuild(opts, context)
996
+ const asset = await context.store.getPinnedTicketAsset(
997
+ opts.slug,
998
+ opts.kind,
999
+ opts.name,
1000
+ opts.rev,
1001
+ )
1002
+ if (asset === null) {
1003
+ const revision = opts.rev === undefined ? '' : ` revision ${opts.rev}`
1004
+ throw new Error(
1005
+ `build "${opts.slug}" pinned no ticket asset ${opts.kind}/${opts.name}${revision}`,
1006
+ )
1007
+ }
1008
+ const written = await writeAssetTo(asset, opts.dest)
1009
+ if (opts.json === true) {
1010
+ opts.stdout(JSON.stringify({ ...asset.meta, path: written }, null, 2))
1011
+ return
1012
+ }
1013
+ opts.stdout(
1014
+ `build asset downloaded: ${opts.slug} ${opts.kind}/${opts.name} revision ${asset.meta.revision} → ${written}`,
1015
+ )
1016
+ })
1017
+ }
@@ -20,6 +20,7 @@ import { z } from 'zod'
20
20
  import { loadConfig } from '../config/load'
21
21
  import type { AbEvent, EventEnvelope } from '../events/catalog'
22
22
  import { agentActor, KERNEL } from '../events/envelope'
23
+ import { isRemoteWorkspace } from '../events/workspace-remote'
23
24
  import type { IdSource } from '../ids'
24
25
  import {
25
26
  autoMergeApplicationType,
@@ -258,7 +259,7 @@ function requireImplementationProvisioning(events: AbEvent[]): void {
258
259
  function remotePublication(events: readonly AbEvent[]): boolean {
259
260
  let remote = false
260
261
  for (const event of events) {
261
- if (event.type === 'workspace.provisioned') remote = event.payload.provider === 'vercel-sandbox'
262
+ if (event.type === 'workspace.provisioned') remote = isRemoteWorkspace(event.payload)
262
263
  else if (event.type === 'workspace.released') remote = false
263
264
  }
264
265
  return remote