@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
@@ -38,10 +38,22 @@
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
- import { currentAutoMergeDeferral } from '../../kernel/auto-merge'
43
- import { decideNext } from '../../kernel/engine'
43
+ import {
44
+ currentAutoMergeDeferralFrom,
45
+ currentDeferralObservationReducer,
46
+ type DeferralLedger,
47
+ } from '../../kernel/auto-merge'
48
+ import { decideNextFromFacts } from '../../kernel/engine'
44
49
  import { verifyPhase } from '../../ontology'
50
+ import { indexLog, type LogIndex } from '../../kernel/log-index'
51
+ import {
52
+ dashboardFactsReducer,
53
+ producedAfter,
54
+ type DashboardFacts,
55
+ type PhaseTiming,
56
+ } from './facts'
45
57
  import type { BuildRecord, StreamRecord } from '../../store/types'
46
58
  import { reduceDispatchSettings } from '../../kernel/dispatch-settings'
47
59
  import { projectSessions, type DashboardSession } from './detail'
@@ -104,6 +116,12 @@ export interface PipelineStep {
104
116
  /** Absent ⇒ the step has never run in the current spec scope ⇒ no time is
105
117
  * shown (AC 6). The renderer composes the elapsed segment from this. */
106
118
  timing?: StepTiming
119
+ /** Display-only, like `qualifier`, and never consulted for routing: the
120
+ * parked merge step's no-consent explanation — why nothing will happen
121
+ * without operator action, and which control unblocks it. Attached when the
122
+ * build is current at merge with effective auto-merge off; the terminal
123
+ * row/detail and the web row/detail render the same words. */
124
+ reason?: string
107
125
  }
108
126
 
109
127
  export type DashboardSelection =
@@ -155,6 +173,11 @@ export interface DashboardBuild {
155
173
  pr?: { url: string; state: PrLifecycle }
156
174
  /** Chronological session history projected from the retained raw log. */
157
175
  sessions?: DashboardSession[]
176
+ /** Which `autobuild.toml` the dispatcher pinned this build's pipeline from,
177
+ * and the effective-config revision it was deposited under (SPEC §16.1).
178
+ * Present once an effective-config artifact exists. */
179
+ pipelineSource?: PipelineSourceMeta
180
+ effectiveConfigRev?: number
158
181
  }
159
182
 
160
183
  export type DashboardView =
@@ -329,6 +352,18 @@ export function autoMergeDisplay(state: BuildState): AutoMergeDisplay {
329
352
  return 'cancelling'
330
353
  }
331
354
 
355
+ /** The shared no-consent wording for a build parked on an open PR. One string
356
+ * so the terminal row/detail and the web row/detail say the same words, and
357
+ * so it names the per-build control that unblocks the parked build. */
358
+ export function autoMergeConsentReason(slug: string): string {
359
+ return (
360
+ `no auto-merge consent has been requested — request it with m on the build row or ` +
361
+ '`ab auto-merge ' +
362
+ slug +
363
+ ' on`'
364
+ )
365
+ }
366
+
332
367
  /**
333
368
  * One step, built through one helper so the precedence rule cannot be applied
334
369
  * inconsistently: **`current > done > provisional > pending`**. In particular,
@@ -342,6 +377,7 @@ interface StepExtra {
342
377
  qualifier?: 'failed' | 'skipped' | 'waiting'
343
378
  count?: number
344
379
  timing?: StepTiming
380
+ reason?: string
345
381
  }
346
382
 
347
383
  function step(label: string, done: boolean, current: boolean, extra: StepExtra = {}): PipelineStep {
@@ -357,102 +393,10 @@ function step(label: string, done: boolean, current: boolean, extra: StepExtra =
357
393
  ...(extra.qualifier !== undefined ? { qualifier: extra.qualifier } : {}),
358
394
  ...(extra.count !== undefined ? { count: extra.count } : {}),
359
395
  ...(extra.timing !== undefined ? { timing: extra.timing } : {}),
396
+ ...(extra.reason !== undefined ? { reason: extra.reason } : {}),
360
397
  }
361
398
  }
362
399
 
363
- /** One phase occurrence's wall-clock span, keyed by phase. `startSeq` is the
364
- * seq of the `*.started` event, so callers can scope by the same seq
365
- * boundaries (`restartSince`/`cycleSince`) the step STATES already use. */
366
- interface PhaseInterval {
367
- start: number
368
- end: number
369
- startSeq: number
370
- }
371
- interface PhaseTiming {
372
- closed: PhaseInterval[]
373
- open?: { start: number; startSeq: number }
374
- }
375
-
376
- /**
377
- * Walk the raw event log into per-phase `[start, end]` intervals in epoch-ms
378
- * (`Date.parse(ev.ts)`), the durations the reducer collapses away. Each
379
- * `*.started` opens an interval for its phase key and its terminal event
380
- * (`.completed`/`.verdict`) closes it. A second `*.started` while one is still
381
- * open REPLACES the open start — a §15.6-C cross-sandbox re-run starts the
382
- * phase afresh, so the crashed attempt contributes nothing. Finalize post-steps
383
- * (`finalize.step-completed`) have no `.started` and so no interval.
384
- */
385
- function phaseIntervals(events: AbEvent[]): Map<string, PhaseTiming> {
386
- const timings = new Map<string, PhaseTiming>()
387
- const get = (key: string): PhaseTiming => {
388
- let t = timings.get(key)
389
- if (t === undefined) {
390
- t = { closed: [] }
391
- timings.set(key, t)
392
- }
393
- return t
394
- }
395
- for (const ev of events) {
396
- const ms = Date.parse(ev.ts)
397
- const open = (key: string): void => {
398
- get(key).open = { start: ms, startSeq: ev.seq }
399
- }
400
- const close = (key: string): void => {
401
- const t = get(key)
402
- if (t.open === undefined) return
403
- t.closed.push({ start: t.open.start, end: ms, startSeq: t.open.startSeq })
404
- t.open = undefined
405
- }
406
- switch (ev.type) {
407
- case 'plan.started':
408
- open('plan')
409
- break
410
- case 'plan.completed':
411
- close('plan')
412
- break
413
- case 'plan-review.started':
414
- open('plan-review')
415
- break
416
- case 'plan-review.verdict':
417
- close('plan-review')
418
- break
419
- case 'implement.started':
420
- open('implement')
421
- break
422
- case 'implement.completed':
423
- close('implement')
424
- break
425
- case 'code-review.started':
426
- open('code-review')
427
- break
428
- case 'code-review.verdict':
429
- close('code-review')
430
- break
431
- case 'verify.started':
432
- open(verifyPhase(ev.payload.step))
433
- break
434
- case 'verify.completed':
435
- close(verifyPhase(ev.payload.step))
436
- break
437
- case 'finalize.started':
438
- open('finalize')
439
- break
440
- case 'finalize.completed':
441
- close('finalize')
442
- break
443
- case 'reconcile.started':
444
- open('reconcile')
445
- break
446
- case 'reconcile.completed':
447
- close('reconcile')
448
- break
449
- default:
450
- break
451
- }
452
- }
453
- return timings
454
- }
455
-
456
400
  /**
457
401
  * Sum the in-scope occurrences of one phase into a `StepTiming`, or `undefined`
458
402
  * when the phase never ran in scope (the step shows no time, AC 6). Scope is
@@ -463,12 +407,12 @@ function phaseIntervals(events: AbEvent[]): Map<string, PhaseTiming> {
463
407
  * not advance (AC 10).
464
408
  */
465
409
  function timingFor(
466
- intervals: Map<string, PhaseTiming>,
410
+ intervals: Record<string, PhaseTiming>,
467
411
  key: string,
468
412
  sinceSeq: number,
469
413
  frozenNow: number | undefined,
470
414
  ): StepTiming | undefined {
471
- const t = intervals.get(key)
415
+ const t = intervals[key]
472
416
  if (t === undefined) return undefined
473
417
  let accumulatedMs = 0
474
418
  let inScope = false
@@ -490,6 +434,14 @@ function timingFor(
490
434
 
491
435
  /**
492
436
  * `record` + `state` → one dashboard row, or `null` only for terminal builds.
437
+ *
438
+ * `config` is the dispatcher's live snapshot; `pinnedConfig` (SPEC §16.1) is
439
+ * the build's own pipeline — its deposited effective-config artifact's
440
+ * build-owned sections composed over `config`'s deployment-owned ones. A
441
+ * build's verify/finalize universe and next-action are decided from the
442
+ * pinned pipeline: the live snapshot describes the base branch, which a
443
+ * pinned build may never execute (the AUT-366 class). Absent artifact ⇒ the
444
+ * live config, the pre-pin behavior.
493
445
  */
494
446
  export function projectBuild(
495
447
  record: BuildRecord,
@@ -497,7 +449,42 @@ export function projectBuild(
497
449
  config: Config,
498
450
  events: AbEvent[],
499
451
  streams?: readonly StreamRecord[],
452
+ pinnedConfig?: Config,
453
+ ): DashboardBuild | null {
454
+ return projectBuildFromFacts(
455
+ record,
456
+ state,
457
+ {
458
+ dashboard: dashboardFactsReducer.reduce(events),
459
+ log: indexLog(events),
460
+ deferral: currentDeferralObservationReducer.reduce(events),
461
+ },
462
+ config,
463
+ streams,
464
+ pinnedConfig,
465
+ )
466
+ }
467
+
468
+ /** The reduced facts a row renders from, beside the build state: what
469
+ * `projectBuild` derives from the event array, advanced incrementally. */
470
+ export interface DashboardRowFacts {
471
+ dashboard: DashboardFacts
472
+ log: LogIndex
473
+ deferral: DeferralLedger
474
+ }
475
+
476
+ /** `projectBuild` over already-reduced facts — the one implementation of the
477
+ * row projection, so a snapshot-backed row and a replayed row cannot differ. */
478
+ export function projectBuildFromFacts(
479
+ record: BuildRecord,
480
+ state: BuildState,
481
+ facts: DashboardRowFacts,
482
+ config: Config,
483
+ streams?: readonly StreamRecord[],
484
+ pinnedConfig?: Config,
500
485
  ): DashboardBuild | null {
486
+ const pipeline = pinnedConfig ?? config
487
+ const dashboard = facts.dashboard
501
488
  const status = effectiveStatus(state)
502
489
  if (!isVisible(status)) return null
503
490
  const abortProgress =
@@ -510,35 +497,29 @@ export function projectBuild(
510
497
  : undefined
511
498
 
512
499
  if (status === 'queued') {
513
- const created = events.findLast((event) => event.type === 'build.created')
514
- const workspace = events.findLast((event) => event.type === 'workspace.provisioned')
515
- const released = events.findLast((event) => event.type === 'workspace.released')
500
+ const created = dashboard.created
501
+ const workspace = dashboard.workspaceProvisioned
502
+ const released = dashboard.workspaceReleased
516
503
  const openWorkspace =
517
- workspace !== undefined && (released === undefined || workspace.seq > released.seq)
518
- const spec = events.findLast(
519
- (event) => event.type === 'spec.imported' || event.type === 'spec.authored',
520
- )
521
- const commentPosted = events.findLast((event) => event.type === 'dispatch.comment-posted')
522
- const latestFailure = events.findLast((event) => event.type === 'dispatch.failed')
504
+ workspace !== undefined && (released === undefined || workspace > released)
505
+ const spec = dashboard.specLanded
506
+ const commentPosted = dashboard.commentPosted
507
+ const latestFailure = dashboard.latestFailure
523
508
  const failureSuperseded =
524
509
  latestFailure !== undefined &&
525
- ((latestFailure.payload.stage === 'create' &&
526
- created !== undefined &&
527
- created.seq > latestFailure.seq) ||
528
- (latestFailure.payload.stage === 'workspace' &&
510
+ ((latestFailure.stage === 'create' && created !== undefined && created > latestFailure.seq) ||
511
+ (latestFailure.stage === 'workspace' &&
529
512
  workspace !== undefined &&
530
- workspace.seq > latestFailure.seq) ||
531
- (latestFailure.payload.stage === 'spec' &&
532
- spec !== undefined &&
533
- spec.seq > latestFailure.seq) ||
534
- (latestFailure.payload.stage === 'comment' &&
513
+ workspace > latestFailure.seq) ||
514
+ (latestFailure.stage === 'spec' && spec !== undefined && spec > latestFailure.seq) ||
515
+ (latestFailure.stage === 'comment' &&
535
516
  commentPosted !== undefined &&
536
- commentPosted.seq > latestFailure.seq))
517
+ commentPosted > latestFailure.seq))
537
518
  const dispatch =
538
519
  state.discardRequest !== undefined
539
520
  ? 'discard requested; cleanup and Ready handback pending'
540
521
  : latestFailure !== undefined && !failureSuperseded
541
- ? `dispatch ${latestFailure.payload.stage} failed (attempt ${latestFailure.payload.attempt}): ${latestFailure.payload.error}`
522
+ ? `dispatch ${latestFailure.stage} failed (attempt ${latestFailure.attempt}): ${latestFailure.error}`
542
523
  : created === undefined
543
524
  ? 'dispatch initialization pending'
544
525
  : !openWorkspace
@@ -555,7 +536,7 @@ export function projectBuild(
555
536
  dispatch,
556
537
  blockers: [],
557
538
  autoMerge: autoMergeDisplay(state),
558
- sessions: projectSessions(events, streams),
539
+ sessions: projectSessions(dashboard.sessionEvents, streams),
559
540
  }
560
541
  }
561
542
 
@@ -576,7 +557,7 @@ export function projectBuild(
576
557
  ...(state.pr !== undefined && state.prState !== undefined
577
558
  ? { pr: { url: state.pr.url, state: state.prState } }
578
559
  : {}),
579
- sessions: projectSessions(events, streams),
560
+ sessions: projectSessions(dashboard.sessionEvents, streams),
580
561
  }
581
562
  }
582
563
 
@@ -640,11 +621,8 @@ export function projectBuild(
640
621
  // blocked build freezes at the latest durable event. Timers therefore do not
641
622
  // advance behind either gate (AC 10). Each step scopes by the SAME seq boundary its state uses, so
642
623
  // durations and states restart in lockstep (AC 12).
643
- const intervals = phaseIntervals(events)
644
- const pausedAt =
645
- state.status === 'paused'
646
- ? events.findLast((event) => event.type === 'build.paused')?.ts
647
- : undefined
624
+ const intervals = dashboard.intervals
625
+ const pausedAt = state.status === 'paused' ? dashboard.pausedAt : undefined
648
626
  const frozenNow =
649
627
  state.status === 'running'
650
628
  ? undefined
@@ -663,7 +641,7 @@ export function projectBuild(
663
641
  const verifySkipped = (s: string): boolean =>
664
642
  cycle.some((r) => r.step === s && r.outcome === 'skipped')
665
643
  const verifySatisfied = (s: string): boolean => verifyPassed(s) || verifySkipped(s)
666
- const verifyDrained = !cycleFailed && config.verify.steps.every(verifySatisfied)
644
+ const verifyDrained = !cycleFailed && pipeline.verify.steps.every(verifySatisfied)
667
645
 
668
646
  // A loop is settled only if its standing approval survived the last restart
669
647
  // AND no later producer round reopened it. `approved` alone is a full-log
@@ -685,35 +663,31 @@ export function projectBuild(
685
663
  // boundary. Starts and timing intervals are deliberately insufficient: a
686
664
  // crashed phase has run, but has produced no output. Matching the round also
687
665
  // drops a review verdict as soon as the next producer round starts.
688
- const planProduced = events.some(
689
- (ev) =>
690
- ev.seq > restartSince &&
691
- ev.type === 'plan.completed' &&
692
- ev.payload.round === state.plan.round,
666
+ const planProduced = producedAfter(
667
+ dashboard.produced.planCompleted,
668
+ state.plan.round,
669
+ restartSince,
693
670
  )
694
- const planReviewProduced = events.some(
695
- (ev) =>
696
- ev.seq > restartSince &&
697
- ev.type === 'plan-review.verdict' &&
698
- ev.payload.round === state.plan.round,
671
+ const planReviewProduced = producedAfter(
672
+ dashboard.produced.planReviewVerdict,
673
+ state.plan.round,
674
+ restartSince,
699
675
  )
700
- const implementProduced = events.some(
701
- (ev) =>
702
- ev.seq > restartSince &&
703
- ev.type === 'implement.completed' &&
704
- ev.payload.round === state.implement.round,
676
+ const implementProduced = producedAfter(
677
+ dashboard.produced.implementCompleted,
678
+ state.implement.round,
679
+ restartSince,
705
680
  )
706
- const codeReviewProduced = events.some(
707
- (ev) =>
708
- ev.seq > restartSince &&
709
- ev.type === 'code-review.verdict' &&
710
- ev.payload.round === state.implement.round,
681
+ const codeReviewProduced = producedAfter(
682
+ dashboard.produced.codeReviewVerdict,
683
+ state.implement.round,
684
+ restartSince,
711
685
  )
712
686
 
713
687
  // Finalize ran for the CURRENT spec — NOT `prState !== undefined`, which a
714
688
  // spec restart never resets while the engine re-runs finalize from scratch.
715
689
  const finalizeDone = state.finalizeCompletedSeq > restartSince
716
- const postStepsDrained = config.finalize.steps.every((s) =>
690
+ const postStepsDrained = pipeline.finalize.steps.every((s) =>
717
691
  finalizeSteps.some((f) => f.step === s),
718
692
  )
719
693
 
@@ -746,7 +720,7 @@ export function projectBuild(
746
720
  }),
747
721
  ]
748
722
 
749
- for (const s of config.verify.steps) {
723
+ for (const s of pipeline.verify.steps) {
750
724
  const phase = verifyPhase(s)
751
725
  const current = at(phase)
752
726
  const stepResults = cycle.filter((r) => r.step === s)
@@ -787,7 +761,7 @@ export function projectBuild(
787
761
  timing: timingFor(intervals, 'finalize', restartSince, frozenNow),
788
762
  }),
789
763
  )
790
- for (const s of config.finalize.steps) {
764
+ for (const s of pipeline.finalize.steps) {
791
765
  // Post-steps have no `.started` event, so they carry no timing.
792
766
  const done = finalizeSteps.find((f) => f.step === s)
793
767
  steps.push(
@@ -839,6 +813,17 @@ export function projectBuild(
839
813
  ? { accumulatedMs: Math.max(0, frozenNow - lastMs) }
840
814
  : { accumulatedMs: 0, runningSince: lastMs }
841
815
  steps.push(step('merge', false, mergeCurrent, { qualifier: 'waiting', timing: mergeTiming }))
816
+ const autoMerge = autoMergeDisplay(state)
817
+ // The parked no-consent reason: a merge-current build whose effective
818
+ // auto-merge state is off will never merge without operator action, and the
819
+ // step must say so (and name the control) wherever the pipeline renders.
820
+ // Display-only; the deferral `mergeWaitReason` stays in `blockers`.
821
+ if (mergeCurrent && autoMerge === 'off') {
822
+ steps[steps.length - 1] = {
823
+ ...steps[steps.length - 1]!,
824
+ reason: autoMergeConsentReason(record.slug),
825
+ }
826
+ }
842
827
 
843
828
  const setupError =
844
829
  state.infrastructureFailure !== undefined
@@ -855,10 +840,10 @@ export function projectBuild(
855
840
  : `exit status ${state.setupFailure.exitStatus}`
856
841
  }): ${state.setupFailure.output || '(no output)'}`
857
842
 
858
- const decision = decideNext(events, config)
843
+ const decision = decideNextFromFacts(state, facts.log, pipeline)
859
844
  const mergeWaitReason =
860
845
  decision.kind === 'wait' && decision.reason === 'awaiting-pr'
861
- ? currentAutoMergeDeferral(events, state)
846
+ ? currentAutoMergeDeferralFrom(facts.deferral, state)
862
847
  : undefined
863
848
 
864
849
  return {
@@ -875,11 +860,11 @@ export function projectBuild(
875
860
  ...(state.reviewRoundCeilings.plan !== undefined || state.reviewRoundCeilings.code !== undefined
876
861
  ? { reviewRoundCeilings: { ...state.reviewRoundCeilings } }
877
862
  : {}),
878
- autoMerge: autoMergeDisplay(state),
863
+ autoMerge,
879
864
  ...(state.pr !== undefined && state.prState !== undefined
880
865
  ? { pr: { url: state.pr.url, state: state.prState } }
881
866
  : {}),
882
- sessions: projectSessions(events, streams),
867
+ sessions: projectSessions(dashboard.sessionEvents, streams),
883
868
  }
884
869
  }
885
870
 
@@ -1084,7 +1069,10 @@ interface RepositoryHarvestProjection {
1084
1069
  harvest?: DashboardHarvest
1085
1070
  }
1086
1071
 
1087
- function projectRepositoryHarvest(events: RepositoryEvent[]): RepositoryHarvestProjection {
1072
+ /** The repository-journal half of a legacy dashboard frame (AUT-545): the
1073
+ * harvest projection and the pause flag exactly as `buildDashboardFromProjected`
1074
+ * derives them, exposed for tests that pin the bounded read's equivalence. */
1075
+ export function projectRepositoryHarvest(events: RepositoryEvent[]): RepositoryHarvestProjection {
1088
1076
  const state = reduceHarvest(events)
1089
1077
  const harvest = projectHarvestRun(events, state)
1090
1078
  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
- import { reduceBuild, type BuildState } from '../../kernel/reducer'
4
- import type { BuildRecord, StreamRecord, StreamScope } from '../../store/types'
5
+ import { buildReducer, type BuildAcc, type BuildState } from '../../kernel/reducer'
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 {
@@ -27,6 +38,9 @@ export interface DashboardPollSnapshot {
27
38
  interface LiveEntry {
28
39
  kind: 'live'
29
40
  events: AbEvent[]
41
+ /** The reducer accumulator `state` was derived from; the next delta is
42
+ * folded into a copy of it instead of re-reducing the whole log. */
43
+ acc: BuildAcc
30
44
  state: BuildState
31
45
  build: DashboardBuild | null
32
46
  }
@@ -109,6 +123,60 @@ export class DashboardBuildPollCache {
109
123
  return snapshot.revision === this.committedRevision
110
124
  }
111
125
 
126
+ /** Effective-config artifact facts for one build (SPEC §16.1), when the
127
+ * reader exposes the artifact primitive: the metadata projection plus the
128
+ * artifact's parsed content. A malformed artifact still yields its metadata
129
+ * (config absent); all failures are display-only. */
130
+ private async readEffectiveConfig(
131
+ slug: string,
132
+ ): Promise<
133
+ { revision?: number; pipelineSource?: PipelineSourceMeta; config?: Config } | undefined
134
+ > {
135
+ const getArtifact = this.reader.getArtifact
136
+ if (getArtifact === undefined) return undefined
137
+ try {
138
+ const artifact = await getArtifact.call(this.reader, slug, BUILD_EFFECTIVE_CONFIG_ARTIFACT)
139
+ if (artifact === null) return undefined
140
+ let config: Config | undefined
141
+ try {
142
+ config = parseEffectiveBuildConfig(artifact)
143
+ } catch {
144
+ config = undefined
145
+ }
146
+ return { ...parseBuildConfigMetadata(artifact), config }
147
+ } catch {
148
+ return undefined
149
+ }
150
+ }
151
+
152
+ /** Project one build row against its PINNED pipeline (SPEC §16.1) and
153
+ * attach the effective-config metadata. The row's verify/finalize steps and
154
+ * next-action must describe the pipeline the build actually runs — the
155
+ * artifact's build-owned sections — never the dispatcher's live base-branch
156
+ * snapshot, which a pinned build may never execute. Deployment-owned
157
+ * sections (roles/policy) still come from the live snapshot, so a reload
158
+ * that reaches the build is reflected. A build with no artifact yet (queued
159
+ * or pre-pin) projects from the live config, the pre-pin behavior. */
160
+ private async projectRow(
161
+ record: BuildRecord,
162
+ state: BuildState,
163
+ events: AbEvent[],
164
+ config: Config,
165
+ ): Promise<DashboardBuild | null> {
166
+ const [streams, artifact] = await Promise.all([
167
+ this.readStreams(record.slug),
168
+ this.readEffectiveConfig(record.slug),
169
+ ])
170
+ const pinnedConfig =
171
+ artifact?.config !== undefined ? composeBuildConfig(artifact.config, config) : undefined
172
+ const build = projectBuild(record, state, config, events, streams, pinnedConfig)
173
+ if (build !== null && artifact !== undefined) {
174
+ if (artifact.revision !== undefined) build.effectiveConfigRev = artifact.revision
175
+ if (artifact.pipelineSource !== undefined) build.pipelineSource = artifact.pipelineSource
176
+ }
177
+ return build
178
+ }
179
+
112
180
  refresh(
113
181
  config: Config = this.config,
114
182
  configRevision = this.configRevision,
@@ -146,13 +214,7 @@ export class DashboardBuildPollCache {
146
214
  configChanged
147
215
  ? {
148
216
  ...current,
149
- build: projectBuild(
150
- record,
151
- current.state,
152
- config,
153
- current.events,
154
- await this.readStreams(record.slug),
155
- ),
217
+ build: await this.projectRow(record, current.state, current.events, config),
156
218
  }
157
219
  : current,
158
220
  )
@@ -160,7 +222,8 @@ export class DashboardBuildPollCache {
160
222
  }
161
223
 
162
224
  const events = current === undefined ? delta : [...current.events, ...delta]
163
- const state = reduceBuild(events)
225
+ const acc = buildReducer.advance(current?.acc ?? buildReducer.initial(), delta)
226
+ const state = buildReducer.finish(acc)
164
227
  if (isTerminal(state)) {
165
228
  next.set(record.slug, { kind: 'terminal' })
166
229
  continue
@@ -168,8 +231,9 @@ export class DashboardBuildPollCache {
168
231
  next.set(record.slug, {
169
232
  kind: 'live',
170
233
  events,
234
+ acc,
171
235
  state,
172
- build: projectBuild(record, state, config, events, await this.readStreams(record.slug)),
236
+ build: await this.projectRow(record, state, events, config),
173
237
  })
174
238
  }
175
239
 
@@ -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
  ]