@defrex/autobuild 0.8.0 → 0.9.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 (147) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +313 -26
  3. package/bin/ab-build-runner.ts +8 -2
  4. package/docs/architecture.md +57 -14
  5. package/docs/assets/headline-wide.png +0 -0
  6. package/docs/configuration.md +151 -25
  7. package/docs/hosted-dispatcher.md +69 -13
  8. package/docs/operator-api.md +2 -2
  9. package/docs/remote-store-protocol.md +169 -16
  10. package/docs/remote-ticket-protocol.md +1 -1
  11. package/docs/setup.md +12 -9
  12. package/docs/website.md +10 -0
  13. package/package.json +43 -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/model.ts +57 -7
  19. package/packages/core/src/cli/dashboard/poll.ts +69 -10
  20. package/packages/core/src/cli/dashboard/render.ts +34 -5
  21. package/packages/core/src/cli/dispatch-frontend.ts +54 -5
  22. package/packages/core/src/cli/dispatch.ts +320 -46
  23. package/packages/core/src/cli/harvest.ts +122 -8
  24. package/packages/core/src/cli/help.ts +47 -8
  25. package/packages/core/src/cli/init-readiness-remote.ts +110 -0
  26. package/packages/core/src/cli/init-readiness-shared.ts +72 -0
  27. package/packages/core/src/cli/init-validation.ts +248 -222
  28. package/packages/core/src/cli/main.ts +70 -11
  29. package/packages/core/src/cli/mcp.ts +23 -1
  30. package/packages/core/src/cli/plugin.ts +7 -4
  31. package/packages/core/src/cli/remote-poll.ts +277 -0
  32. package/packages/core/src/cli/repo-state.ts +22 -0
  33. package/packages/core/src/cli/repository-status.ts +39 -1
  34. package/packages/core/src/cli/status.ts +149 -27
  35. package/packages/core/src/cli/terminals.ts +2 -1
  36. package/packages/core/src/cli/ticket-assets.ts +247 -0
  37. package/packages/core/src/cli/ticket.ts +307 -7
  38. package/packages/core/src/cli/wait.ts +147 -39
  39. package/packages/core/src/cli/watch.ts +128 -166
  40. package/packages/core/src/config/live.ts +22 -0
  41. package/packages/core/src/config/pipeline-source.ts +376 -0
  42. package/packages/core/src/config/schema.ts +230 -19
  43. package/packages/core/src/events/catalog.ts +1 -0
  44. package/packages/core/src/events/globs.ts +47 -0
  45. package/packages/core/src/events/payloads.ts +52 -2
  46. package/packages/core/src/events/repository.ts +52 -1
  47. package/packages/core/src/events/sessions.ts +69 -10
  48. package/packages/core/src/events/workspace-remote.ts +12 -0
  49. package/packages/core/src/integration/harness.ts +2 -1
  50. package/packages/core/src/kernel/auto-merge-default.ts +123 -0
  51. package/packages/core/src/kernel/phases.ts +16 -2
  52. package/packages/core/src/kernel/reducer.ts +40 -1
  53. package/packages/core/src/operator/annotations.ts +6 -0
  54. package/packages/core/src/operator/control.ts +19 -5
  55. package/packages/core/src/operator/index.ts +71 -6
  56. package/packages/core/src/operator/query.ts +208 -19
  57. package/packages/core/src/operator/registry.ts +55 -10
  58. package/packages/core/src/operator/requests.ts +9 -6
  59. package/packages/core/src/operator/sandbox-host.ts +140 -0
  60. package/packages/core/src/operator/sandbox.ts +247 -14
  61. package/packages/core/src/operator/ticket-source-backend.ts +47 -0
  62. package/packages/core/src/orchestrator/approvals.ts +115 -0
  63. package/packages/core/src/orchestrator/conversation.ts +167 -0
  64. package/packages/core/src/orchestrator/model.ts +86 -0
  65. package/packages/core/src/orchestrator/prompt.ts +61 -0
  66. package/packages/core/src/orchestrator/tools.ts +117 -0
  67. package/packages/core/src/orchestrator/turn-runner.ts +457 -0
  68. package/packages/core/src/plugin-sdk/index.ts +51 -1
  69. package/packages/core/src/plugins/load.ts +135 -7
  70. package/packages/core/src/plugins/manifest.ts +183 -10
  71. package/packages/core/src/plugins/registry.ts +73 -4
  72. package/packages/core/src/ports/forge/create.ts +1 -1
  73. package/packages/core/src/ports/forge/fake.ts +31 -0
  74. package/packages/core/src/ports/forge/github-transport.ts +13 -2
  75. package/packages/core/src/ports/forge/github.ts +13 -2
  76. package/packages/core/src/ports/forge/local-git.ts +31 -1
  77. package/packages/core/src/ports/runner/claude.ts +2 -2
  78. package/packages/core/src/ports/runner/codex.ts +6 -2
  79. package/packages/core/src/ports/runner/fake.ts +7 -1
  80. package/packages/core/src/ports/runner/pi.ts +8 -6
  81. package/packages/core/src/ports/runner/provider-error.ts +13 -3
  82. package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
  83. package/packages/core/src/ports/types.ts +17 -2
  84. package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
  85. package/packages/core/src/ports/workspace/create.ts +74 -39
  86. package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
  87. package/packages/core/src/ports/workspace/fake.ts +169 -4
  88. package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
  89. package/packages/core/src/ports/workspace/github-origin.ts +36 -0
  90. package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
  91. package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
  92. package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
  93. package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
  94. package/packages/core/src/processes/build-child.ts +17 -2
  95. package/packages/core/src/processes/build-execution-state.ts +37 -0
  96. package/packages/core/src/processes/build-runner.ts +54 -3
  97. package/packages/core/src/processes/dispatcher.ts +247 -41
  98. package/packages/core/src/processes/harvest-child.ts +5 -0
  99. package/packages/core/src/processes/harvest-runner.ts +66 -19
  100. package/packages/core/src/processes/harvest.ts +196 -24
  101. package/packages/core/src/processes/orchestrator-tick.ts +319 -0
  102. package/packages/core/src/processes/sandbox-state.ts +10 -0
  103. package/packages/core/src/store/adapter.ts +32 -0
  104. package/packages/core/src/store/build-scope.ts +64 -0
  105. package/packages/core/src/store/contract.ts +868 -0
  106. package/packages/core/src/store/digest.ts +49 -0
  107. package/packages/core/src/store/local/schema.ts +29 -2
  108. package/packages/core/src/store/local/store.ts +338 -4
  109. package/packages/core/src/store/memory.ts +179 -0
  110. package/packages/core/src/store/new-build.ts +17 -0
  111. package/packages/core/src/store/phase-session.ts +71 -0
  112. package/packages/core/src/store/remote/client.ts +260 -1
  113. package/packages/core/src/store/remote/index.ts +12 -2
  114. package/packages/core/src/store/remote/protocol.ts +100 -12
  115. package/packages/core/src/store/remote/version.ts +6 -2
  116. package/packages/core/src/store/repo-state-events.ts +140 -0
  117. package/packages/core/src/store/retention.ts +7 -0
  118. package/packages/core/src/store/session-handle.ts +64 -0
  119. package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +28 -9
  120. package/packages/core/src/store/streams/session-writer.ts +42 -2
  121. package/packages/core/src/store/streams/wait.ts +4 -5
  122. package/packages/core/src/store/ticket-assets.ts +425 -0
  123. package/packages/core/src/store/types.ts +122 -1
  124. package/packages/core/src/testing/index.ts +59 -0
  125. package/skills/code-review/SKILL.md +5 -1
  126. package/skills/guide/SKILL.md +126 -33
  127. package/skills/guide/references/operator-api.md +2 -2
  128. package/skills/guide/references/plugin-authoring.md +17 -0
  129. package/skills/guide/references/remote-store-protocol.md +169 -16
  130. package/skills/guide/references/remote-ticket-protocol.md +1 -1
  131. package/skills/guide/references/setup.md +12 -9
  132. package/skills/implement/SKILL.md +3 -0
  133. package/skills/operate/SKILL.md +126 -0
  134. package/skills/plan/SKILL.md +4 -0
  135. package/skills/plan-review/SKILL.md +5 -1
  136. package/skills/spec/SKILL.md +46 -3
  137. package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
  138. package/packages/core/src/cli/store-opening.contract.ts +0 -289
  139. package/packages/core/src/cli/testkit.ts +0 -194
  140. package/packages/core/src/markdown.ts +0 -67
  141. package/packages/core/src/operator/client.ts +0 -311
  142. package/packages/core/src/operator/server.ts +0 -616
  143. package/packages/core/src/operator/sessions.ts +0 -223
  144. package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
  145. package/packages/core/src/ports/tickets/remote-server.ts +0 -185
  146. package/packages/core/src/store/remote/server.ts +0 -923
  147. package/packages/core/src/testing/store-failures.ts +0 -32
@@ -7,11 +7,18 @@
7
7
  * normal runner and dispatcher consume them.
8
8
  */
9
9
  import type { AbEvent } from '../events/catalog'
10
+ import type { PinnedAsset } from '../events/payloads'
10
11
  import { humanActor, KERNEL, type Via } from '../events/envelope'
11
- import { reduceBuild, type BuildState, type OpenEscalation } from '../kernel/reducer'
12
+ import {
13
+ reduceBuild,
14
+ discardInFlight,
15
+ type BuildState,
16
+ type OpenEscalation,
17
+ } from '../kernel/reducer'
12
18
  import type { ArtifactRef, BuildOutcome, BuildStatus, TicketRef } from '../ontology'
13
19
  import type { Exec } from '../ports/workspace/git-worktree'
14
20
  import { specConformance } from '../spec-standard'
21
+ import { samplePinnedAssets } from '../store/ticket-assets'
15
22
  import type { BuildStore } from '../store/types'
16
23
  import { withSessionlessStore, type StoreOpener } from './store-opening'
17
24
 
@@ -107,6 +114,7 @@ export type BuildControlErrorCode =
107
114
  | 'missing-authorization'
108
115
  | 'no-longer-active'
109
116
  | 'abort-pending'
117
+ | 'discard-pending'
110
118
  | 'review-round-ceiling-unavailable'
111
119
  | 'incompatible-answer-options'
112
120
 
@@ -226,6 +234,78 @@ function hasPendingAbort(state: BuildState): boolean {
226
234
  return state.pendingCommands.some((command) => command.command === 'abort')
227
235
  }
228
236
 
237
+ function discardPending(slug: string): BuildControlError {
238
+ return new BuildControlError(
239
+ 'discard-pending',
240
+ `build "${slug}" has an outstanding discard request; auto-merge consent was not recorded. ` +
241
+ 'The discard settles only when the build terminalizes — nothing was appended. ' +
242
+ 'If the discard raced with runner attachment and you still want the change, ' +
243
+ 'merge the PR manually.',
244
+ )
245
+ }
246
+
247
+ /** Record or withdraw per-build auto-merge consent — the write path for
248
+ * `auto-merge-on`, `auto-merge-off`, and `toggle-auto-merge`.
249
+ *
250
+ * Ruling (AUT-445): recording consent on a build with an outstanding discard
251
+ * request is rejected — the fan-out's discard exclusion
252
+ * (`autoMergeDefaultEligible`'s discard clause, AUT-418) applied to explicit
253
+ * consent. Both controls call the same `discardInFlight` predicate from
254
+ * kernel/reducer.ts, so they cannot diverge silently. Withdrawing consent (OFF)
255
+ * is deliberately still allowed: `checkPr` honors revocation before anything
256
+ * else, so an OFF strictly shrinks the merge set, and it is the only way to
257
+ * withdraw consent recorded before the discard (a fan-out or claim-time seed) —
258
+ * refusing it would leave that discard-contradicting consent driving the merge
259
+ * anyway. The fan-out is direction-blind because it is a queue cleaner that
260
+ * withdraws nothing that survives the settlement; this guard is directional on
261
+ * purpose, and the divergence is stated here rather than left accidental.
262
+ *
263
+ * The guard runs inside a compare-and-append loop (the `appendIfCurrent` idiom
264
+ * the fan-out's dispatcher loop and `bulkControlRepository` already use), so a
265
+ * discard request that lands between the guard's read and this append cannot
266
+ * slip past it: the CAS misses, the loop re-reduces the log, and the guard
267
+ * fires on the fresh state. The toggle's direction is re-resolved from the
268
+ * re-read state each attempt. `activeState` runs before the guard on every
269
+ * attempt: a queued build (where the discard normally sits) still reports the
270
+ * existing `inactive` error. */
271
+ async function appendConsentCommand(
272
+ store: BuildStore,
273
+ slug: string,
274
+ user: string,
275
+ action: 'auto-merge-on' | 'auto-merge-off' | 'toggle-auto-merge',
276
+ via?: Via,
277
+ ): Promise<BuildControlResult> {
278
+ while (true) {
279
+ const state = reduceBuild(await store.getEvents(slug))
280
+ activeState(slug, state)
281
+ const command =
282
+ action === 'toggle-auto-merge'
283
+ ? state.autoMerge.requested
284
+ ? 'auto-merge-off'
285
+ : 'auto-merge-on'
286
+ : action
287
+ if (command === 'auto-merge-on' && discardInFlight(state)) throw discardPending(slug)
288
+ const actor = humanActor(user, via)
289
+ // Written out rather than selected into a variable so `appendIfCurrent`'s
290
+ // `T extends EventType` infers a concrete payload type in each branch.
291
+ const appended =
292
+ command === 'auto-merge-on'
293
+ ? await store.appendIfCurrent(slug, state.lastSeq, {
294
+ actor,
295
+ type: 'build.auto-merge-requested',
296
+ payload: {},
297
+ })
298
+ : await store.appendIfCurrent(slug, state.lastSeq, {
299
+ actor,
300
+ type: 'build.auto-merge-cancelled',
301
+ payload: {},
302
+ })
303
+ if (appended !== null) return { kind: 'command', slug, command, event: appended }
304
+ // The stream advanced under us (a racing discard, a fan-out write, a
305
+ // runner acknowledgement): loop to re-read and re-check before writing.
306
+ }
307
+ }
308
+
229
309
  function abortPending(slug: string): BuildControlError {
230
310
  return new BuildControlError(
231
311
  'abort-pending',
@@ -254,6 +334,9 @@ export type ReviseDecision =
254
334
  | {
255
335
  kind: 'complete'
256
336
  artifact: ArtifactRef
337
+ /** The assets sampled with the authorization; absent for a supplied body
338
+ * and for authorizations recorded before assets were pinned. */
339
+ assets?: PinnedAsset[]
257
340
  open: OpenEscalation[]
258
341
  escalationSeq: number
259
342
  }
@@ -289,7 +372,13 @@ export function decideRevise(
289
372
  }
290
373
  }
291
374
  const escalationSeq = Math.max(...[...open, ...pending].map((item) => item.seq))
292
- return { kind: 'complete', artifact: authorized.artifact, open, escalationSeq }
375
+ return {
376
+ kind: 'complete',
377
+ artifact: authorized.artifact,
378
+ ...(authorized.assets !== undefined ? { assets: authorized.assets } : {}),
379
+ open,
380
+ escalationSeq,
381
+ }
293
382
  }
294
383
  if (open.length === 0) {
295
384
  const paused =
@@ -425,9 +514,14 @@ export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControl
425
514
  switch (opts.action.kind) {
426
515
  case 'pause':
427
516
  case 'resume':
517
+ return appendCommand(opts.store, opts.slug, user, opts.action.kind, opts.via)
518
+
428
519
  case 'auto-merge-on':
429
520
  case 'auto-merge-off':
430
- return appendCommand(opts.store, opts.slug, user, opts.action.kind, opts.via)
521
+ case 'toggle-auto-merge':
522
+ // Consent has its own guarded, compare-and-append write path; the
523
+ // outer `activeState` above only short-circuits the common inactive case.
524
+ return appendConsentCommand(opts.store, opts.slug, user, opts.action.kind, opts.via)
431
525
 
432
526
  case 'dashboard-pause': {
433
527
  const pendingPause = state.pendingCommands.some((command) => command.command === 'pause')
@@ -464,15 +558,6 @@ export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControl
464
558
  return appendCommand(opts.store, opts.slug, user, 'resume', opts.via)
465
559
  }
466
560
 
467
- case 'toggle-auto-merge':
468
- return appendCommand(
469
- opts.store,
470
- opts.slug,
471
- user,
472
- state.autoMerge.requested ? 'auto-merge-off' : 'auto-merge-on',
473
- opts.via,
474
- )
475
-
476
561
  case 'answer': {
477
562
  const captured =
478
563
  opts.action.escalationIds === undefined ? undefined : new Set(opts.action.escalationIds)
@@ -510,11 +595,16 @@ export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControl
510
595
  if (initial.kind === 'refuse') throwRefusal(initial)
511
596
 
512
597
  let artifact: ArtifactRef
598
+ // From-ticket revisions re-pin the ticket's assets; a supplied body
599
+ // leaves the pinned set alone (`undefined`). Sampled once, with the
600
+ // body, and persisted on the answers so a retry reuses it.
601
+ let assets: PinnedAsset[] | undefined
513
602
  let authorizedEarlier = initial.kind === 'complete'
514
603
  if (initial.kind === 'complete') {
515
604
  // Completion rule: recorded authorization wins before the caller's
516
605
  // lazy body source is opened, regardless of remaining blockers.
517
606
  artifact = initial.artifact
607
+ assets = initial.assets
518
608
  } else {
519
609
  let body: string
520
610
  const source = opts.action.resolve.body
@@ -534,6 +624,7 @@ export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControl
534
624
  )
535
625
  }
536
626
  body = await opts.readTicketBody(record.ticket)
627
+ assets = await samplePinnedAssets(opts.store, record.repo, record.ticket.id)
537
628
  }
538
629
  const conformance = specConformance(body)
539
630
  if (!conformance.conforms) {
@@ -564,6 +655,7 @@ export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControl
564
655
  if (decision.kind === 'refuse') throwRefusal(decision)
565
656
  if (decision.kind === 'complete') {
566
657
  artifact = decision.artifact
658
+ assets = decision.assets
567
659
  authorizedEarlier = true
568
660
  }
569
661
  const answer = guidance === '' ? SPEC_REVISION_ANSWER : guidance
@@ -580,13 +672,18 @@ export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControl
580
672
  answer,
581
673
  resolution: 'revise-spec',
582
674
  artifact,
675
+ ...(assets !== undefined ? { assets } : {}),
583
676
  },
584
677
  })
585
678
  }
586
679
  await opts.store.append(opts.slug, {
587
680
  actor: KERNEL,
588
681
  type: 'spec.revised',
589
- payload: { artifact, escalation: decision.escalationSeq },
682
+ payload: {
683
+ artifact,
684
+ escalation: decision.escalationSeq,
685
+ ...(assets !== undefined ? { assets } : {}),
686
+ },
590
687
  })
591
688
  if (alsoPaused) {
592
689
  await opts.store.append(opts.slug, {
@@ -44,6 +44,7 @@ import {
44
44
  } from '../ports/workspace/phase-scratch'
45
45
  import type { Exec } from '../ports/workspace/git-worktree'
46
46
  import type { BuildStore } from '../store/types'
47
+ import { writeAssetTo } from './ticket-assets'
47
48
  import type { CliEnv } from './env'
48
49
 
49
50
  export interface ContextDeps {
@@ -81,6 +82,16 @@ export interface ContextManifest {
81
82
  notesPath?: string
82
83
  /** relPath (under .ab/) → source artifact ref, or 'derived'. */
83
84
  materialized: Record<string, MaterializedEntry>
85
+ /** The ticket assets this build froze (SPEC §6.3), each written to `path`
86
+ * (relative to the workspace). Omitted when the build pinned none. */
87
+ assets?: {
88
+ kind: string
89
+ name: string
90
+ revision: number
91
+ layout: 'file' | 'tree'
92
+ size: number
93
+ path: string
94
+ }[]
84
95
  /** From the latest `implement.completed` (§8.3 code-review/verify inputs). */
85
96
  commitRange?: CommitRange
86
97
  /** Reconcile: `{baseSha}` freshly recorded by this attempt's phase start (§15.7). */
@@ -276,6 +287,41 @@ export async function buildContext(deps: ContextDeps): Promise<ContextManifest>
276
287
  }
277
288
  }
278
289
 
290
+ if (inputs.assets === true && state.pinnedAssets.length > 0) {
291
+ // The pin, not the ticket's current state: the revisions `build.created`
292
+ // (or the latest re-pinning `spec.revised`) recorded, fetched by explicit
293
+ // revision so a later attach/replace/remove cannot change what a phase sees.
294
+ const root = locate('assets')
295
+ manifest.assets = []
296
+ for (const pinned of state.pinnedAssets) {
297
+ const asset = await store.getPinnedTicketAsset(
298
+ build,
299
+ pinned.kind,
300
+ pinned.name,
301
+ pinned.revision,
302
+ )
303
+ if (asset === null) {
304
+ throw new Error(
305
+ `pinned ticket asset ${pinned.kind}/${pinned.name} revision ${pinned.revision} is missing from the store`,
306
+ )
307
+ }
308
+ const relPath = `${root}/${pinned.kind}/${pinned.name}`
309
+ const target = join(abDir, relPath)
310
+ // A file asset lands inside its directory as `<filename>`; a tree asset
311
+ // keeps its relative tree, empty directories included.
312
+ await mkdir(target, { recursive: true })
313
+ await writeAssetTo(asset, target)
314
+ manifest.assets.push({
315
+ kind: pinned.kind,
316
+ name: pinned.name,
317
+ revision: pinned.revision,
318
+ layout: pinned.layout,
319
+ size: pinned.size,
320
+ path: `.ab/${relPath}`,
321
+ })
322
+ }
323
+ }
324
+
279
325
  if (inputs.spec === true) {
280
326
  // §6.3 immutability: the spec a phase sees is the rev the spec.* events
281
327
  // anchor (spec.imported/authored/revised → state.specRev), never the
@@ -38,6 +38,7 @@
38
38
  import type { AbEvent } from '../../events/catalog'
39
39
  import type { RepositoryEvent } from '../../events/repository'
40
40
  import type { Config } from '../../config/schema'
41
+ import type { PipelineSourceMeta } from '../../config/pipeline-source'
41
42
  import type { BuildState, PhaseContext, PrLifecycle } from '../../kernel/reducer'
42
43
  import { currentAutoMergeDeferral } from '../../kernel/auto-merge'
43
44
  import { decideNext } from '../../kernel/engine'
@@ -104,6 +105,12 @@ export interface PipelineStep {
104
105
  /** Absent ⇒ the step has never run in the current spec scope ⇒ no time is
105
106
  * shown (AC 6). The renderer composes the elapsed segment from this. */
106
107
  timing?: StepTiming
108
+ /** Display-only, like `qualifier`, and never consulted for routing: the
109
+ * parked merge step's no-consent explanation — why nothing will happen
110
+ * without operator action, and which control unblocks it. Attached when the
111
+ * build is current at merge with effective auto-merge off; the terminal
112
+ * row/detail and the web row/detail render the same words. */
113
+ reason?: string
107
114
  }
108
115
 
109
116
  export type DashboardSelection =
@@ -155,6 +162,11 @@ export interface DashboardBuild {
155
162
  pr?: { url: string; state: PrLifecycle }
156
163
  /** Chronological session history projected from the retained raw log. */
157
164
  sessions?: DashboardSession[]
165
+ /** Which `autobuild.toml` the dispatcher pinned this build's pipeline from,
166
+ * and the effective-config revision it was deposited under (SPEC §16.1).
167
+ * Present once an effective-config artifact exists. */
168
+ pipelineSource?: PipelineSourceMeta
169
+ effectiveConfigRev?: number
158
170
  }
159
171
 
160
172
  export type DashboardView =
@@ -329,6 +341,18 @@ export function autoMergeDisplay(state: BuildState): AutoMergeDisplay {
329
341
  return 'cancelling'
330
342
  }
331
343
 
344
+ /** The shared no-consent wording for a build parked on an open PR. One string
345
+ * so the terminal row/detail and the web row/detail say the same words, and
346
+ * so it names the per-build control that unblocks the parked build. */
347
+ export function autoMergeConsentReason(slug: string): string {
348
+ return (
349
+ `no auto-merge consent has been requested — request it with m on the build row or ` +
350
+ '`ab auto-merge ' +
351
+ slug +
352
+ ' on`'
353
+ )
354
+ }
355
+
332
356
  /**
333
357
  * One step, built through one helper so the precedence rule cannot be applied
334
358
  * inconsistently: **`current > done > provisional > pending`**. In particular,
@@ -342,6 +366,7 @@ interface StepExtra {
342
366
  qualifier?: 'failed' | 'skipped' | 'waiting'
343
367
  count?: number
344
368
  timing?: StepTiming
369
+ reason?: string
345
370
  }
346
371
 
347
372
  function step(label: string, done: boolean, current: boolean, extra: StepExtra = {}): PipelineStep {
@@ -357,6 +382,7 @@ function step(label: string, done: boolean, current: boolean, extra: StepExtra =
357
382
  ...(extra.qualifier !== undefined ? { qualifier: extra.qualifier } : {}),
358
383
  ...(extra.count !== undefined ? { count: extra.count } : {}),
359
384
  ...(extra.timing !== undefined ? { timing: extra.timing } : {}),
385
+ ...(extra.reason !== undefined ? { reason: extra.reason } : {}),
360
386
  }
361
387
  }
362
388
 
@@ -490,6 +516,14 @@ function timingFor(
490
516
 
491
517
  /**
492
518
  * `record` + `state` → one dashboard row, or `null` only for terminal builds.
519
+ *
520
+ * `config` is the dispatcher's live snapshot; `pinnedConfig` (SPEC §16.1) is
521
+ * the build's own pipeline — its deposited effective-config artifact's
522
+ * build-owned sections composed over `config`'s deployment-owned ones. A
523
+ * build's verify/finalize universe and next-action are decided from the
524
+ * pinned pipeline: the live snapshot describes the base branch, which a
525
+ * pinned build may never execute (the AUT-366 class). Absent artifact ⇒ the
526
+ * live config, the pre-pin behavior.
493
527
  */
494
528
  export function projectBuild(
495
529
  record: BuildRecord,
@@ -497,7 +531,9 @@ export function projectBuild(
497
531
  config: Config,
498
532
  events: AbEvent[],
499
533
  streams?: readonly StreamRecord[],
534
+ pinnedConfig?: Config,
500
535
  ): DashboardBuild | null {
536
+ const pipeline = pinnedConfig ?? config
501
537
  const status = effectiveStatus(state)
502
538
  if (!isVisible(status)) return null
503
539
  const abortProgress =
@@ -663,7 +699,7 @@ export function projectBuild(
663
699
  const verifySkipped = (s: string): boolean =>
664
700
  cycle.some((r) => r.step === s && r.outcome === 'skipped')
665
701
  const verifySatisfied = (s: string): boolean => verifyPassed(s) || verifySkipped(s)
666
- const verifyDrained = !cycleFailed && config.verify.steps.every(verifySatisfied)
702
+ const verifyDrained = !cycleFailed && pipeline.verify.steps.every(verifySatisfied)
667
703
 
668
704
  // A loop is settled only if its standing approval survived the last restart
669
705
  // AND no later producer round reopened it. `approved` alone is a full-log
@@ -713,7 +749,7 @@ export function projectBuild(
713
749
  // Finalize ran for the CURRENT spec — NOT `prState !== undefined`, which a
714
750
  // spec restart never resets while the engine re-runs finalize from scratch.
715
751
  const finalizeDone = state.finalizeCompletedSeq > restartSince
716
- const postStepsDrained = config.finalize.steps.every((s) =>
752
+ const postStepsDrained = pipeline.finalize.steps.every((s) =>
717
753
  finalizeSteps.some((f) => f.step === s),
718
754
  )
719
755
 
@@ -746,7 +782,7 @@ export function projectBuild(
746
782
  }),
747
783
  ]
748
784
 
749
- for (const s of config.verify.steps) {
785
+ for (const s of pipeline.verify.steps) {
750
786
  const phase = verifyPhase(s)
751
787
  const current = at(phase)
752
788
  const stepResults = cycle.filter((r) => r.step === s)
@@ -787,7 +823,7 @@ export function projectBuild(
787
823
  timing: timingFor(intervals, 'finalize', restartSince, frozenNow),
788
824
  }),
789
825
  )
790
- for (const s of config.finalize.steps) {
826
+ for (const s of pipeline.finalize.steps) {
791
827
  // Post-steps have no `.started` event, so they carry no timing.
792
828
  const done = finalizeSteps.find((f) => f.step === s)
793
829
  steps.push(
@@ -839,6 +875,17 @@ export function projectBuild(
839
875
  ? { accumulatedMs: Math.max(0, frozenNow - lastMs) }
840
876
  : { accumulatedMs: 0, runningSince: lastMs }
841
877
  steps.push(step('merge', false, mergeCurrent, { qualifier: 'waiting', timing: mergeTiming }))
878
+ const autoMerge = autoMergeDisplay(state)
879
+ // The parked no-consent reason: a merge-current build whose effective
880
+ // auto-merge state is off will never merge without operator action, and the
881
+ // step must say so (and name the control) wherever the pipeline renders.
882
+ // Display-only; the deferral `mergeWaitReason` stays in `blockers`.
883
+ if (mergeCurrent && autoMerge === 'off') {
884
+ steps[steps.length - 1] = {
885
+ ...steps[steps.length - 1]!,
886
+ reason: autoMergeConsentReason(record.slug),
887
+ }
888
+ }
842
889
 
843
890
  const setupError =
844
891
  state.infrastructureFailure !== undefined
@@ -855,7 +902,7 @@ export function projectBuild(
855
902
  : `exit status ${state.setupFailure.exitStatus}`
856
903
  }): ${state.setupFailure.output || '(no output)'}`
857
904
 
858
- const decision = decideNext(events, config)
905
+ const decision = decideNext(events, pipeline)
859
906
  const mergeWaitReason =
860
907
  decision.kind === 'wait' && decision.reason === 'awaiting-pr'
861
908
  ? currentAutoMergeDeferral(events, state)
@@ -875,7 +922,7 @@ export function projectBuild(
875
922
  ...(state.reviewRoundCeilings.plan !== undefined || state.reviewRoundCeilings.code !== undefined
876
923
  ? { reviewRoundCeilings: { ...state.reviewRoundCeilings } }
877
924
  : {}),
878
- autoMerge: autoMergeDisplay(state),
925
+ autoMerge,
879
926
  ...(state.pr !== undefined && state.prState !== undefined
880
927
  ? { pr: { url: state.pr.url, state: state.prState } }
881
928
  : {}),
@@ -1084,7 +1131,10 @@ interface RepositoryHarvestProjection {
1084
1131
  harvest?: DashboardHarvest
1085
1132
  }
1086
1133
 
1087
- function projectRepositoryHarvest(events: RepositoryEvent[]): RepositoryHarvestProjection {
1134
+ /** The repository-journal half of a legacy dashboard frame (AUT-545): the
1135
+ * harvest projection and the pause flag exactly as `buildDashboardFromProjected`
1136
+ * derives them, exposed for tests that pin the bounded read's equivalence. */
1137
+ export function projectRepositoryHarvest(events: RepositoryEvent[]): RepositoryHarvestProjection {
1088
1138
  const state = reduceHarvest(events)
1089
1139
  const harvest = projectHarvestRun(events, state)
1090
1140
  return {
@@ -1,16 +1,27 @@
1
1
  import type { Config } from '../../config/schema'
2
+ import { composeBuildConfig } from '../../config/live'
3
+ import type { PipelineSourceMeta } from '../../config/pipeline-source'
2
4
  import type { AbEvent } from '../../events/catalog'
3
5
  import { reduceBuild, type BuildState } from '../../kernel/reducer'
4
- import type { BuildRecord, StreamRecord, StreamScope } from '../../store/types'
6
+ import {
7
+ BUILD_EFFECTIVE_CONFIG_ARTIFACT,
8
+ parseBuildConfigMetadata,
9
+ parseEffectiveBuildConfig,
10
+ } from '../../processes/build-execution-state'
11
+ import type { Artifact, BuildRecord, StreamRecord, StreamScope } from '../../store/types'
5
12
  import { projectBuild, type DashboardBuild } from './model'
6
13
 
7
14
  /** The read-only BuildStore surface needed to construct dashboard build rows.
8
15
  * `listStreams` is optional: when present, build-scope records enrich each
9
- * row's session history with authoritative stream ids and statuses (SPEC §9). */
16
+ * row's session history with authoritative stream ids and statuses (SPEC §9).
17
+ * `getArtifact` is optional: when present, the effective-config artifact's
18
+ * metadata decorates each row with its pinned pipeline source and revision
19
+ * (SPEC §16.1). */
10
20
  export interface DashboardBuildReader {
11
21
  listBuilds(): Promise<BuildRecord[]>
12
22
  getEvents(slug: string, sinceSeq?: number): Promise<AbEvent[]>
13
23
  listStreams?(scope: StreamScope): Promise<StreamRecord[]>
24
+ getArtifact?(slug: string, kind: string): Promise<Artifact | null>
14
25
  }
15
26
 
16
27
  export interface DashboardPollSnapshot {
@@ -109,6 +120,60 @@ export class DashboardBuildPollCache {
109
120
  return snapshot.revision === this.committedRevision
110
121
  }
111
122
 
123
+ /** Effective-config artifact facts for one build (SPEC §16.1), when the
124
+ * reader exposes the artifact primitive: the metadata projection plus the
125
+ * artifact's parsed content. A malformed artifact still yields its metadata
126
+ * (config absent); all failures are display-only. */
127
+ private async readEffectiveConfig(
128
+ slug: string,
129
+ ): Promise<
130
+ { revision?: number; pipelineSource?: PipelineSourceMeta; config?: Config } | undefined
131
+ > {
132
+ const getArtifact = this.reader.getArtifact
133
+ if (getArtifact === undefined) return undefined
134
+ try {
135
+ const artifact = await getArtifact.call(this.reader, slug, BUILD_EFFECTIVE_CONFIG_ARTIFACT)
136
+ if (artifact === null) return undefined
137
+ let config: Config | undefined
138
+ try {
139
+ config = parseEffectiveBuildConfig(artifact)
140
+ } catch {
141
+ config = undefined
142
+ }
143
+ return { ...parseBuildConfigMetadata(artifact), config }
144
+ } catch {
145
+ return undefined
146
+ }
147
+ }
148
+
149
+ /** Project one build row against its PINNED pipeline (SPEC §16.1) and
150
+ * attach the effective-config metadata. The row's verify/finalize steps and
151
+ * next-action must describe the pipeline the build actually runs — the
152
+ * artifact's build-owned sections — never the dispatcher's live base-branch
153
+ * snapshot, which a pinned build may never execute. Deployment-owned
154
+ * sections (roles/policy) still come from the live snapshot, so a reload
155
+ * that reaches the build is reflected. A build with no artifact yet (queued
156
+ * or pre-pin) projects from the live config, the pre-pin behavior. */
157
+ private async projectRow(
158
+ record: BuildRecord,
159
+ state: BuildState,
160
+ events: AbEvent[],
161
+ config: Config,
162
+ ): Promise<DashboardBuild | null> {
163
+ const [streams, artifact] = await Promise.all([
164
+ this.readStreams(record.slug),
165
+ this.readEffectiveConfig(record.slug),
166
+ ])
167
+ const pinnedConfig =
168
+ artifact?.config !== undefined ? composeBuildConfig(artifact.config, config) : undefined
169
+ const build = projectBuild(record, state, config, events, streams, pinnedConfig)
170
+ if (build !== null && artifact !== undefined) {
171
+ if (artifact.revision !== undefined) build.effectiveConfigRev = artifact.revision
172
+ if (artifact.pipelineSource !== undefined) build.pipelineSource = artifact.pipelineSource
173
+ }
174
+ return build
175
+ }
176
+
112
177
  refresh(
113
178
  config: Config = this.config,
114
179
  configRevision = this.configRevision,
@@ -146,13 +211,7 @@ export class DashboardBuildPollCache {
146
211
  configChanged
147
212
  ? {
148
213
  ...current,
149
- build: projectBuild(
150
- record,
151
- current.state,
152
- config,
153
- current.events,
154
- await this.readStreams(record.slug),
155
- ),
214
+ build: await this.projectRow(record, current.state, current.events, config),
156
215
  }
157
216
  : current,
158
217
  )
@@ -169,7 +228,7 @@ export class DashboardBuildPollCache {
169
228
  kind: 'live',
170
229
  events,
171
230
  state,
172
- build: projectBuild(record, state, config, events, await this.readStreams(record.slug)),
231
+ build: await this.projectRow(record, state, events, config),
173
232
  })
174
233
  }
175
234
 
@@ -250,7 +250,8 @@ function renderStep(step: PipelineStep, color: boolean, now: number): string {
250
250
  parts.push(`${formatDuration(elapsedMs)}${count}`)
251
251
  }
252
252
  const note = parts.length > 0 ? `(${parts.join(', ')})` : ''
253
- const text = `${GLYPH[step.state]} ${step.label}${note}`
253
+ const reason = step.reason !== undefined ? ` — ${step.reason}` : ''
254
+ const text = `${GLYPH[step.state]} ${step.label}${note}${reason}`
254
255
  const painted = paint(text, STEP_COLOR[step.state], color)
255
256
  return step.state === 'current' ? paint(painted, 'bold', color) : painted
256
257
  }
@@ -307,6 +308,7 @@ function renderBuild(
307
308
  widths: Widths,
308
309
  selected: boolean,
309
310
  selecting: boolean,
311
+ defaultAutoMerge: boolean,
310
312
  ): string[] {
311
313
  const { color, width, now } = opts
312
314
 
@@ -329,10 +331,22 @@ function renderBuild(
329
331
  // right-justifies the status word so it is never truncated and always ends at
330
332
  // the frame's right edge (AC 3, AC 5).
331
333
  const rightTokens: string[] = []
332
- if (build.autoMerge !== 'off') {
334
+ // The state is ALWAYS drawn — including `off` — so consent is never conveyed
335
+ // by the absence of a token alone. `off` turns red while the repository
336
+ // default reads ON: the default-on/build-off mismatch an operator must see
337
+ // without opening the detail view.
338
+ {
333
339
  const autoColor: ColorName =
334
- build.autoMerge === 'enabled' ? 'green' : build.autoMerge === 'requested' ? 'cyan' : 'yellow'
335
- rightTokens.push(paint('auto merge', autoColor, color))
340
+ build.autoMerge === 'enabled'
341
+ ? 'green'
342
+ : build.autoMerge === 'requested'
343
+ ? 'cyan'
344
+ : build.autoMerge === 'cancelling'
345
+ ? 'yellow'
346
+ : defaultAutoMerge
347
+ ? 'red'
348
+ : 'dim'
349
+ rightTokens.push(paint(`auto merge ${build.autoMerge}`, autoColor, color))
336
350
  }
337
351
  if (build.pr !== undefined) rightTokens.push(link(build.pr.url, `PR ${build.pr.state}`, color))
338
352
  // A repository hold changes no build lifecycle state. Keep QUEUED literal
@@ -578,7 +592,8 @@ function detailStep(step: PipelineStep, opts: RenderOpts): string {
578
592
  notes.push(formatDuration(elapsed))
579
593
  }
580
594
  const note = notes.length > 0 ? ` (${notes.join(', ')})` : ''
581
- return paint(`${GLYPH[step.state]} ${step.label}${note}`, STEP_COLOR[step.state], color)
595
+ const reason = step.reason !== undefined ? ` — ${step.reason}` : ''
596
+ return paint(`${GLYPH[step.state]} ${step.label}${note}${reason}`, STEP_COLOR[step.state], color)
582
597
  }
583
598
 
584
599
  function sessionLines(session: DashboardSession, selected: boolean, opts: RenderOpts): string[] {
@@ -639,6 +654,19 @@ function detailBody(
639
654
  if (ceiling !== undefined) body.push(` ${ceiling}`)
640
655
  if (build.pr !== undefined)
641
656
  body.push(` PR ${build.pr.state} ${link(build.pr.url, build.pr.url, color)}`)
657
+ if (build.pipelineSource !== undefined || build.effectiveConfigRev !== undefined) {
658
+ const commit =
659
+ build.pipelineSource?.commit !== undefined
660
+ ? build.pipelineSource.commit.slice(0, 7)
661
+ : 'unknown'
662
+ const source =
663
+ build.pipelineSource !== undefined
664
+ ? `autobuild.toml@${commit} (${build.pipelineSource.ref})`
665
+ : 'autobuild.toml@unknown (pre-pin)'
666
+ const rev =
667
+ build.effectiveConfigRev !== undefined ? ` config rev ${build.effectiveConfigRev}` : ''
668
+ body.push(` pipeline ${source}${rev}`)
669
+ }
642
670
  if (build.abortProgress !== undefined) {
643
671
  body.push('', paint('Abort progress', 'bold', color))
644
672
  body.push(...wrappedText(build.abortProgress, width, ' '))
@@ -1449,6 +1477,7 @@ function renderDashboardContent(model: DashboardModel, opts: RenderOpts): string
1449
1477
  widths,
1450
1478
  sameSelection(selection, model.selection),
1451
1479
  selecting,
1480
+ model.defaultAutoMerge,
1452
1481
  ),
1453
1482
  },
1454
1483
  ]