@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
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import type { Actor } from '../events/envelope'
11
11
  import type { AbEvent } from '../events/catalog'
12
- import { normalizeVerifyCompletion, type EventPayload } from '../events/payloads'
12
+ import { normalizeVerifyCompletion, type EventPayload, type PinnedAsset } from '../events/payloads'
13
13
  import type {
14
14
  ArtifactRef,
15
15
  BuildOutcome,
@@ -52,6 +52,8 @@ export interface AnsweredEscalation extends OpenEscalation {
52
52
  resolution: EscalationResolution
53
53
  /** Exact replacement spec revision authorized by a `revise-spec` answer. */
54
54
  artifact?: ArtifactRef
55
+ /** Ticket assets sampled with a revise-from-ticket authorization. */
56
+ assets?: PinnedAsset[]
55
57
  /** Absolute review budget accepted with a review-round-limit answer. */
56
58
  reviewRoundCeiling?: number
57
59
  /** seq of the `escalation.answered` event. */
@@ -130,6 +132,11 @@ export interface AutoMergeProjection {
130
132
  /** Latest human command. False with no commandSeq means the default: off. */
131
133
  requested: boolean
132
134
  commandSeq?: number
135
+ /** The repository seq of the `dispatcher.auto-merge-default-set` fact the
136
+ * latest answering command (claim-time seed or fan-out) sampled, when one
137
+ * did. A per-build command with no `defaultSeq` preserves the previous
138
+ * value, so the default fan-out can tell an override from staleness. */
139
+ defaultSeq?: number
133
140
  /** Latest recorded external application. It may acknowledge an older
134
141
  * command; such a stale fact never changes `requested`/`commandSeq`. */
135
142
  applied?: { enabled: boolean; commandSeq: number }
@@ -194,6 +201,9 @@ export interface BuildState {
194
201
  lastEvent?: AbEvent
195
202
  /** 0 for an empty log; `runner.attached {resumedFromSeq}` cites this. */
196
203
  lastSeq: number
204
+ /** Ticket assets the build froze: set by `build.created`, replaced by a
205
+ * `spec.revised` that carries `assets` (§6.3). Empty for historical logs. */
206
+ pinnedAssets: PinnedAsset[]
197
207
  /** Latest spec artifact rev — `spec.imported`/`spec.authored` set it,
198
208
  * `spec.revised` bumps it (§6.3: rev N+1 restarts the build from plan). */
199
209
  specRev?: number
@@ -279,6 +289,16 @@ export interface BuildState {
279
289
  failures: Record<string, number>
280
290
  }
281
291
 
292
+ /** Whether the build carries an outstanding `discardRequest`. Present exactly
293
+ * while the build is non-terminal and its discard is unsettled; the reducer
294
+ * settles it only by terminal completion. This is the single predicate shared
295
+ * by the auto-merge default fan-out (`autoMergeDefaultEligible`, AUT-418) and
296
+ * the per-build consent guard (`controlBuild`'s `discard-pending` refusal), so
297
+ * the two controls' discard exclusions cannot diverge silently. */
298
+ export function discardInFlight(state: Pick<BuildState, 'discardRequest'>): boolean {
299
+ return state.discardRequest !== undefined
300
+ }
301
+
282
302
  export function reduceBuild(events: AbEvent[]): BuildState {
283
303
  let attached = false
284
304
  let pausedFlag = false
@@ -295,6 +315,7 @@ export function reduceBuild(events: AbEvent[]): BuildState {
295
315
  const autoMerge: AutoMergeProjection = { requested: false }
296
316
  let specRev: number | undefined
297
317
  let restartSince = 0
318
+ let pinnedAssets: PinnedAsset[] = []
298
319
  let finalizeCompletedSeq = 0
299
320
  const finalizeSteps: BuildState['finalizeSteps'] = []
300
321
  const plan: BuildState['plan'] = { round: 0, approved: false }
@@ -347,6 +368,8 @@ export function reduceBuild(events: AbEvent[]): BuildState {
347
368
  // Facts the projection does not need (workspace liveness is the
348
369
  // dispatcher's concern; finalize post-steps are failure-tolerant §5).
349
370
  case 'build.created':
371
+ pinnedAssets = event.payload.assets ?? []
372
+ break
350
373
  case 'workspace.provisioned':
351
374
  case 'workspace.released':
352
375
  case 'publication.requested':
@@ -443,10 +466,23 @@ export function reduceBuild(events: AbEvent[]): BuildState {
443
466
  case 'build.auto-merge-requested':
444
467
  autoMerge.requested = true
445
468
  autoMerge.commandSeq = event.seq
469
+ // A fan-out (or seeded) command carries the default fact it answers; a
470
+ // bare per-build command must preserve the previous provenance.
471
+ if (event.payload.defaultSeq !== undefined) autoMerge.defaultSeq = event.payload.defaultSeq
446
472
  break
447
473
  case 'build.auto-merge-cancelled':
448
474
  autoMerge.requested = false
449
475
  autoMerge.commandSeq = event.seq
476
+ if (event.payload.defaultSeq !== undefined) autoMerge.defaultSeq = event.payload.defaultSeq
477
+ break
478
+ case 'build.auto-merge-default-observed':
479
+ // A no-op fan-out still advances the build's provenance. Without it, a
480
+ // matching default fact would stay strictly ahead of a bare per-build
481
+ // command and be re-applied on the next tick, reverting the operator's
482
+ // explicit choice (f_28b3fba1).
483
+ if (event.payload.defaultSeq > (autoMerge.defaultSeq ?? 0)) {
484
+ autoMerge.defaultSeq = event.payload.defaultSeq
485
+ }
450
486
  break
451
487
  case 'build.paused':
452
488
  pausedFlag = true
@@ -467,6 +503,7 @@ export function reduceBuild(events: AbEvent[]): BuildState {
467
503
  break
468
504
  case 'spec.revised':
469
505
  specRev = event.payload.artifact.rev
506
+ if (event.payload.assets !== undefined) pinnedAssets = event.payload.assets
470
507
  // §6.3: rev N+1 restarts the build from plan. The restart boundary
471
508
  // invalidates every approval and result at or before it, so it is also
472
509
  // a verify cycle boundary. Finalize post-steps remain in full-log
@@ -661,6 +698,7 @@ export function reduceBuild(events: AbEvent[]): BuildState {
661
698
  answer: event.payload.answer,
662
699
  resolution: event.payload.resolution,
663
700
  ...(event.payload.artifact !== undefined ? { artifact: event.payload.artifact } : {}),
701
+ ...(event.payload.assets !== undefined ? { assets: event.payload.assets } : {}),
664
702
  ...(event.payload.reviewRoundCeiling !== undefined
665
703
  ? { reviewRoundCeiling: event.payload.reviewRoundCeiling }
666
704
  : {}),
@@ -725,6 +763,7 @@ export function reduceBuild(events: AbEvent[]): BuildState {
725
763
  finalizeSteps,
726
764
  lastEvent,
727
765
  lastSeq: lastEvent?.seq ?? 0,
766
+ pinnedAssets,
728
767
  specRev,
729
768
  restartSince,
730
769
  plan,
@@ -182,6 +182,12 @@ export const OPERATOR_TOOL_ANNOTATIONS = {
182
182
  idempotentHint: false,
183
183
  approval: 'default',
184
184
  },
185
+ 'sandbox.publish': {
186
+ readOnlyHint: false,
187
+ destructiveHint: true,
188
+ idempotentHint: false,
189
+ approval: 'default',
190
+ },
185
191
  } as const satisfies Record<string, ToolAnnotationsTableEntry>
186
192
 
187
193
  export type OperatorToolName = keyof typeof OPERATOR_TOOL_ANNOTATIONS
@@ -29,19 +29,27 @@ export async function setRepositorySetting(opts: {
29
29
  enabled: boolean
30
30
  /** Delegated-write attribution marker threaded onto the event's actor. */
31
31
  via?: Via
32
+ /** Dispatcher invocation that performed the write. Present on the
33
+ * dashboard/launch-flag write paths so the two are distinguishable in the
34
+ * journal; optional so the operator API (which has no run) is unchanged. */
35
+ run?: string
32
36
  }): Promise<{ enabled: boolean; event: RepositoryEventEnvelope }> {
33
37
  await opts.store.ensureRepo(opts.repo)
38
+ const payload = {
39
+ enabled: opts.enabled,
40
+ ...(opts.run !== undefined ? { run: opts.run } : {}),
41
+ }
34
42
  const event =
35
43
  opts.setting === 'intake'
36
44
  ? await opts.store.appendRepo(opts.repo, {
37
45
  actor: humanActor(opts.user, opts.via),
38
46
  type: 'dispatcher.intake-set',
39
- payload: { enabled: opts.enabled },
47
+ payload,
40
48
  })
41
49
  : await opts.store.appendRepo(opts.repo, {
42
50
  actor: humanActor(opts.user, opts.via),
43
51
  type: 'dispatcher.auto-merge-default-set',
44
- payload: { enabled: opts.enabled },
52
+ payload,
45
53
  })
46
54
  return { enabled: opts.enabled, event }
47
55
  }
@@ -52,9 +60,12 @@ export async function toggleRepositorySetting(opts: {
52
60
  user: string
53
61
  setting: RepositorySetting
54
62
  via?: Via
63
+ /** Dispatcher invocation that performed the write (see setRepositorySetting). */
64
+ run?: string
55
65
  }): Promise<{ enabled: boolean; event: RepositoryEventEnvelope }> {
56
66
  await opts.store.ensureRepo(opts.repo)
57
- const settings = reduceDispatchSettings(await opts.store.getRepoEvents(opts.repo))
67
+ // Bounded read (AUT-489): the settings reducer consumes durable types only.
68
+ const settings = reduceDispatchSettings(await opts.store.getRepoStateEvents(opts.repo))
58
69
  const enabled = opts.setting === 'intake' ? !settings.intake : !settings.defaultAutoMerge
59
70
  return setRepositorySetting({ ...opts, enabled })
60
71
  }
@@ -66,7 +77,8 @@ export async function toggleHarvestGate(opts: {
66
77
  via?: Via
67
78
  }): Promise<{ command: 'pause' | 'resume'; event: RepositoryEventEnvelope }> {
68
79
  await opts.store.ensureRepo(opts.repo)
69
- const state = reduceHarvest(await opts.store.getRepoEvents(opts.repo))
80
+ // Bounded read (AUT-489): the harvest reducer consumes durable types only.
81
+ const state = reduceHarvest(await opts.store.getRepoStateEvents(opts.repo))
70
82
  const pending = state.pendingCommands.at(-1)
71
83
  const requestedPaused = pending === undefined ? state.paused : pending.command === 'pause'
72
84
  const command = requestedPaused ? 'resume' : 'pause'
@@ -87,7 +99,9 @@ export async function controlHarvestRun(opts: {
87
99
  via?: Via
88
100
  }): Promise<{ action: HarvestRunAction; event: RepositoryEventEnvelope }> {
89
101
  await opts.store.ensureRepo(opts.repo)
90
- const events = await opts.store.getRepoEvents(opts.repo)
102
+ // Bounded read (AUT-489): the harvest reducer and the dashboard harvest
103
+ // projection consume durable types only.
104
+ const events = await opts.store.getRepoStateEvents(opts.repo)
91
105
  const state = reduceHarvest(events)
92
106
  const projected = projectHarvest(events)
93
107
  if (projected === undefined || projected.run !== opts.run) {
@@ -1,7 +1,7 @@
1
- export { OperatorApiClient, OperatorApiError } from './client'
2
- export type { DownloadedArtifact, OperatorApiClientOptions } from './client'
3
- export { createOperatorServer } from './server'
4
- export type { OperatorServerOptions } from './server'
1
+ // Core's operator surface: the tool registry behind `ab mcp`, the CLI-facing
2
+ // control/query/tickets/requests primitives, and the operator wire protocol.
3
+ // The operator HTTP server and client ship in
4
+ // `@defrex/autobuild-hosted-store-service` (see its `operator-api` subpath).
5
5
  export {
6
6
  answerRequestSchema,
7
7
  buildControlRequestSchema,
@@ -9,6 +9,10 @@ export {
9
9
  bulkControlRequestSchema,
10
10
  harvestControlRequestSchema,
11
11
  operatorErrorSchema,
12
+ sessionApprovalRequestSchema,
13
+ sessionCreateRequestSchema,
14
+ sessionMessageRequestSchema,
15
+ sessionWakeRequestSchema,
12
16
  settingRequestSchema,
13
17
  ticketBlockerRequestSchema,
14
18
  ticketCreateRequestSchema,
@@ -19,6 +23,10 @@ export type {
19
23
  OperatorAnswerRequest,
20
24
  OperatorBuildControlRequest,
21
25
  OperatorErrorBody,
26
+ OperatorSessionApprovalRequest,
27
+ OperatorSessionCreateRequest,
28
+ OperatorSessionMessageRequest,
29
+ OperatorSessionWakeRequest,
22
30
  OperatorTicketBlockerRequest,
23
31
  OperatorTicketCreateRequest,
24
32
  OperatorTicketMoveRequest,
@@ -29,8 +37,32 @@ export type {
29
37
  OperatorBuildView,
30
38
  OperatorDashboardSnapshot,
31
39
  } from './query'
32
- export { OperatorControlError } from './control'
33
- export { OperatorQueryError } from './query'
40
+ export {
41
+ controlHarvestRun,
42
+ OperatorControlError,
43
+ setRepositorySetting,
44
+ toggleHarvestGate,
45
+ toggleRepositorySetting,
46
+ } from './control'
47
+ export {
48
+ getHarvestStatus,
49
+ getOperatorBuild,
50
+ getOperatorDashboard,
51
+ getRepositoryStatus,
52
+ listOperatorBuilds,
53
+ OperatorQueryError,
54
+ OrchestratorConfigError,
55
+ orchestratorConfig,
56
+ } from './query'
57
+ export {
58
+ orchestratorWakeGlobs,
59
+ ORCHESTRATOR_ROUTE_LIMIT_SECONDS,
60
+ type OrchestratorConfig,
61
+ } from '../config/schema'
62
+ export type { OrchestratorTurnRunner } from '../orchestrator/turn-runner'
63
+ export { createOrchestratorTurnRunner } from '../orchestrator/turn-runner'
64
+ export { randomIds } from '../ids'
65
+ export type { Config } from '../config/schema'
34
66
  export {
35
67
  OPERATOR_NOTES_ARTIFACT,
36
68
  TOOLS,
@@ -59,4 +91,37 @@ export type {
59
91
  OperatorTicketDetail,
60
92
  OperatorTicketQueue,
61
93
  } from './tickets'
94
+ export {
95
+ getOperatorTicket,
96
+ listOperatorTickets,
97
+ mutateOperatorTicket,
98
+ } from './tickets'
99
+ export type { OperatorSandboxService } from './sandbox'
62
100
  export * from './presentation'
101
+
102
+ /**
103
+ * Hosted operator-server composition. The moved server (in
104
+ * `@defrex/autobuild-hosted-store-service`) builds its routes from these core
105
+ * primitives; they are re-exported here so the hosted package needs no core
106
+ * internals. Deliberately not a general public API — do not grow it for
107
+ * core-internal convenience.
108
+ */
109
+ export {
110
+ BuildControlError,
111
+ controlBuild,
112
+ type BuildControlAction,
113
+ type BuildControlResult,
114
+ } from '../cli/build-control'
115
+ export {
116
+ BulkWalkError,
117
+ bulkControlRepository,
118
+ type BulkControlSummary,
119
+ } from '../cli/bulk-control'
120
+ export { effectiveStatus } from '../cli/dashboard/model'
121
+ export { reduceBuild } from '../kernel/reducer'
122
+ export { TicketOperationError } from '../ports/tickets/operations'
123
+ export { humanActor, type Via } from '../events/envelope'
124
+ export type { HarvestStatusView } from '../cli/harvest'
125
+ export type { RepositoryStatus } from '../cli/repository-status'
126
+ export type { BuildSummary } from '../cli/status'
127
+ export type { SessionRecord } from '../store/types'
@@ -1,16 +1,27 @@
1
+ import { composeBuildConfig } from '../config/live'
2
+ import type { PipelineSourceMeta } from '../config/pipeline-source'
1
3
  import { configSchema, type Config } from '../config/schema'
4
+ import {
5
+ BUILD_EFFECTIVE_CONFIG_ARTIFACT,
6
+ parseBuildConfigMetadata,
7
+ parseEffectiveBuildConfig,
8
+ } from '../processes/build-execution-state'
9
+ import type { Artifact } from '../store/types'
2
10
  import { detail, statusFilter, summarize, type BuildDetail, type BuildSummary } from '../cli/status'
3
11
  import { projectRepositoryStatus, type RepositoryStatus } from '../cli/repository-status'
4
12
  import { projectHarvestStatus, type HarvestStatusView } from '../cli/harvest'
5
13
  import {
6
14
  buildDashboardFromProjected,
15
+ effectiveStatus,
7
16
  projectBuild,
8
17
  type DashboardBuild,
9
18
  type DashboardModel,
10
19
  } from '../cli/dashboard/model'
11
- import { reduceBuild } from '../kernel/reducer'
20
+ import { reduceBuild, type BuildState } from '../kernel/reducer'
21
+ import { DISPATCHER_EFFECTIVE_CONFIG_ARTIFACT } from '../store/retention'
12
22
  import { reduceDispatchStatus } from '../kernel/dispatch-status'
13
- import { scanUnclaimedObservations } from '../processes/harvest'
23
+ import { readRepoEventsIfRecorded, unclaimedObservationCount } from '../processes/harvest'
24
+ import type { RepositoryEvent } from '../events/repository'
14
25
  import type { BuildStore, Clock } from '../store/types'
15
26
 
16
27
  export type BuildListScope = 'active' | 'queued' | 'all'
@@ -25,10 +36,6 @@ export class OperatorQueryError extends Error {
25
36
  }
26
37
  }
27
38
 
28
- async function repoEvents(store: BuildStore, repo: string) {
29
- return (await store.getRepo(repo)) === null ? [] : store.getRepoEvents(repo)
30
- }
31
-
32
39
  export async function listOperatorBuilds(opts: {
33
40
  store: BuildStore
34
41
  repo: string
@@ -36,9 +43,40 @@ export async function listOperatorBuilds(opts: {
36
43
  now: Date
37
44
  }): Promise<BuildSummary[]> {
38
45
  const statuses = new Set(statusFilter(opts.scope === 'all', opts.scope === 'queued'))
46
+ // Records are read and held before the digest read: builds are never
47
+ // deleted, so every record iterated below is guaranteed an entry in the
48
+ // digest map fetched after it — a build created between the two reads
49
+ // appears only in the digest map, which is harmless — and the completeness
50
+ // check can only ever catch a genuine adapter bug, never a concurrent
51
+ // dispatch. The reverse order would let a concurrent creation abort the
52
+ // whole listing (AUT-488 finding f_3cb67aba).
53
+ const records = await opts.store.listBuilds()
54
+ // One digest read gates the per-build history reads (AUT-488): the active
55
+ // and queued scopes never show a terminal build, so a build whose digest
56
+ // already carries a terminal fact skips its `getEvents` round trip and the
57
+ // listing's store cost stays flat as finished builds accumulate. The `--all`
58
+ // scope includes terminal statuses, so it takes no digest read and skips
59
+ // nothing — byte-for-byte the old loop.
60
+ const digests = opts.scope === 'all' ? undefined : await opts.store.getRepoBuildDigests(opts.repo)
39
61
  const output: BuildSummary[] = []
40
- for (const record of await opts.store.listBuilds()) {
62
+ for (const record of records) {
41
63
  if (record.repo !== opts.repo) continue
64
+ if (digests !== undefined) {
65
+ // Completeness is contractual (one entry per repo build); a missing
66
+ // entry is an adapter bug and must fail loudly rather than silently
67
+ // drop the build from the listing.
68
+ const digest = digests.get(record.slug)
69
+ if (digest === undefined) {
70
+ throw new Error(`getRepoBuildDigests is missing an entry for build "${record.slug}"`)
71
+ }
72
+ // The digest's `terminal` follows `reduceBuild`'s terminal rule exactly
73
+ // (in-order overwrite of `build.completed`/`build.aborted`, never
74
+ // cleared — pinned against `reduceBuild` in store/digest.test.ts), so
75
+ // `terminal !== undefined` is exactly reduced status `done`/`aborted`,
76
+ // which the active and queued status sets never include. Skipping the
77
+ // history read therefore cannot change the emitted summaries.
78
+ if (digest.terminal !== undefined) continue
79
+ }
42
80
  const projected = summarize(record, await opts.store.getEvents(record.slug), opts.now)
43
81
  if (statuses.has(projected.status)) output.push(projected)
44
82
  }
@@ -59,10 +97,10 @@ export async function effectiveConfig(
59
97
  repo: string,
60
98
  ): Promise<{
61
99
  config: Config
62
- repositoryEvents: Awaited<ReturnType<typeof repoEvents>>
100
+ repositoryEvents: RepositoryEvent[]
63
101
  status: ReturnType<typeof reduceDispatchStatus>
64
102
  }> {
65
- const repositoryEvents = await repoEvents(store, repo)
103
+ const repositoryEvents = await readRepoEventsIfRecorded(store, repo)
66
104
  let latestRun: string | undefined
67
105
  for (const event of repositoryEvents) {
68
106
  if (event.type === 'dispatcher.run-started') latestRun = event.payload.run
@@ -112,6 +150,65 @@ export interface OperatorBuildView {
112
150
  dashboardRow: DashboardBuild | null
113
151
  }
114
152
 
153
+ interface PinnedProjection {
154
+ config: Config
155
+ revision?: number
156
+ pipelineSource?: PipelineSourceMeta
157
+ }
158
+
159
+ /** Read one build's pinned effective-config artifact (SPEC §16.1): its parsed
160
+ * build-owned sections composed over the live snapshot's deployment-owned
161
+ * ones, plus the artifact's metadata projection. Every row must be projected
162
+ * against the pipeline the build actually runs — the artifact's build-owned
163
+ * sections — never the dispatcher's live base-branch snapshot, which a pinned
164
+ * build may never execute. All read failures are display-only: an absent or
165
+ * malformed artifact degrades to the live config, the pre-pin behavior. */
166
+ async function readPinnedConfig(
167
+ store: BuildStore,
168
+ slug: string,
169
+ live: Config,
170
+ ): Promise<PinnedProjection> {
171
+ let artifact: Artifact | null
172
+ try {
173
+ artifact = await store.getArtifact(slug, BUILD_EFFECTIVE_CONFIG_ARTIFACT)
174
+ } catch {
175
+ artifact = null
176
+ }
177
+ if (artifact === null) return { config: live }
178
+ let config: Config
179
+ try {
180
+ config = composeBuildConfig(parseEffectiveBuildConfig(artifact), live)
181
+ } catch {
182
+ config = live
183
+ }
184
+ return { config, ...parseBuildConfigMetadata(artifact) }
185
+ }
186
+
187
+ /** The shared null-row gate (AUT-486 snapshot, AUT-496 getOperatorBuild,
188
+ * AUT-487 digest): `effectiveStatus` maps only `done` outside the visible set
189
+ * (cli/dashboard/model.ts `isVisible`), so `projectBuild` returns null
190
+ * exactly for this state, and a pinned effective-config read for such a
191
+ * build could never be surfaced. One predicate so every consumer cannot
192
+ * diverge from each other or from the row projection: the digest's `terminal`
193
+ * is a third consumer of the same gate — `terminal === 'done'` is exactly
194
+ * `reduceBuild(...).status === 'done'` — and the snapshot uses it to skip a
195
+ * finished build's history and pinned-config reads without weakening this
196
+ * predicate as the authority. If cli/dashboard/model.ts ever adds a
197
+ * non-visible status, this predicate must follow — the snapshot
198
+ * byte-identical differential test and the done/aborted query tests pin the
199
+ * coupling. */
200
+ function projectsNoDashboardRow(state: BuildState): boolean {
201
+ return effectiveStatus(state) === 'done'
202
+ }
203
+
204
+ /** Attach the effective-config metadata (SPEC §16.1) to a projected row so an
205
+ * operator can see which autobuild.toml the build runs under. */
206
+ function decorateWithPinnedMeta(row: DashboardBuild, pinned: PinnedProjection): DashboardBuild {
207
+ if (pinned.revision !== undefined) row.effectiveConfigRev = pinned.revision
208
+ if (pinned.pipelineSource !== undefined) row.pipelineSource = pinned.pipelineSource
209
+ return row
210
+ }
211
+
115
212
  export async function getOperatorBuild(opts: {
116
213
  store: BuildStore
117
214
  repo: string
@@ -122,22 +219,32 @@ export async function getOperatorBuild(opts: {
122
219
  const events = await opts.store.getEvents(opts.slug)
123
220
  const state = reduceBuild(events)
124
221
  const { config } = await effectiveConfig(opts.store, opts.repo)
125
- const dashboardRow = projectBuild(record, state, config, events)
126
- return { detail: detail(record, events, opts.now), dashboardRow }
222
+ let dashboardRow: DashboardBuild | null = null
223
+ if (!projectsNoDashboardRow(state)) {
224
+ const pinned = await readPinnedConfig(opts.store, opts.slug, config)
225
+ const row = projectBuild(record, state, config, events, undefined, pinned.config)
226
+ // Implied by the gate; kept as an explicit check (not an assertion) so the
227
+ // code never lies if `effectiveStatus` and `projectBuild` ever drift.
228
+ if (row !== null) dashboardRow = decorateWithPinnedMeta(row, pinned)
229
+ }
230
+ return {
231
+ detail: detail(record, events, opts.now),
232
+ dashboardRow,
233
+ }
127
234
  }
128
235
 
129
236
  export async function getRepositoryStatus(
130
237
  store: BuildStore,
131
238
  repo: string,
132
239
  ): Promise<RepositoryStatus> {
133
- return projectRepositoryStatus(repo, await repoEvents(store, repo))
240
+ return projectRepositoryStatus(repo, await readRepoEventsIfRecorded(store, repo))
134
241
  }
135
242
 
136
243
  export async function getHarvestStatus(
137
244
  store: BuildStore,
138
245
  repo: string,
139
246
  ): Promise<HarvestStatusView> {
140
- return projectHarvestStatus(repo, await repoEvents(store, repo))
247
+ return projectHarvestStatus(repo, await readRepoEventsIfRecorded(store, repo))
141
248
  }
142
249
 
143
250
  export interface OperatorDashboardSnapshot {
@@ -157,17 +264,50 @@ export async function getOperatorDashboard(opts: {
157
264
  clock: Clock
158
265
  }): Promise<OperatorDashboardSnapshot> {
159
266
  const { config, repositoryEvents, status } = await effectiveConfig(opts.store, opts.repo)
267
+ const records = await opts.store.listBuilds()
268
+ // One digest read covers every build of the repository (AUT-487): the
269
+ // row gate (a `done` digest means no row) and the observation count no
270
+ // longer need per-build histories, so the snapshot's store traffic stays
271
+ // flat as finished builds accumulate. Row-rendering builds still get their
272
+ // full history below, and the digest itself is derived from the event log
273
+ // on every call — nothing is persisted or cached between snapshots.
274
+ const digests = await opts.store.getRepoBuildDigests(opts.repo)
160
275
  const projected: DashboardBuild[] = []
161
276
  let activeCount = 0
162
- for (const record of await opts.store.listBuilds()) {
277
+ for (const record of records) {
163
278
  if (record.repo !== opts.repo) continue
279
+ const digest = digests.get(record.slug)
280
+ // Completeness is contractual (one entry per repo build); a missing entry
281
+ // is an adapter bug and must fail loudly rather than silently drop the
282
+ // build from the active count or the row list.
283
+ if (digest === undefined) {
284
+ throw new Error(`getRepoBuildDigests is missing an entry for build "${record.slug}"`)
285
+ }
286
+ // Reduced status is `done`/`aborted` exactly when a terminal fact exists,
287
+ // so `terminal === undefined` is exactly the old active test — no history
288
+ // read needed for the count.
289
+ if (digest.terminal === undefined) activeCount += 1
290
+ if (digest.terminal === 'done') continue
291
+ // Both query surfaces share the `projectsNoDashboardRow` gate (AUT-486
292
+ // snapshot, AUT-496 detail query): a build that projects no row —
293
+ // `effectiveStatus` maps only `done` outside the visible set — never
294
+ // reads its pinned effective-config artifact, because the read result
295
+ // could never be surfaced. The digest already excluded `done` builds
296
+ // above; the gate stays as the authority for the row projection so the
297
+ // digest and the reducer cannot silently diverge. Every row-rendering
298
+ // build still gets its full history and pinned pipeline.
164
299
  const events = await opts.store.getEvents(record.slug)
165
300
  const state = reduceBuild(events)
166
- if (state.status !== 'done' && state.status !== 'aborted') activeCount += 1
167
- const row = projectBuild(record, state, config, events)
168
- if (row !== null) projected.push(row)
301
+ if (projectsNoDashboardRow(state)) continue
302
+ const pinned = await readPinnedConfig(opts.store, record.slug, config)
303
+ const row = projectBuild(record, state, config, events, undefined, pinned.config)
304
+ if (row !== null) projected.push(decorateWithPinnedMeta(row, pinned))
169
305
  }
170
- const scan = await scanUnclaimedObservations(opts.store, opts.repo)
306
+ // The unclaimed-observation count comes from the digests and the journal
307
+ // `effectiveConfig` already returned — no per-build history reads (AUT-487).
308
+ // The pure core writes nothing — a snapshot performs no store writes and
309
+ // never creates or locks the repository record.
310
+ const observationCount = unclaimedObservationCount({ digests, harvestEvents: repositoryEvents })
171
311
  const warningLines = [
172
312
  ...status.roleWarnings,
173
313
  ...(status.warningNotice !== undefined ? [status.warningNotice] : []),
@@ -179,7 +319,7 @@ export async function getOperatorDashboard(opts: {
179
319
  queued: status.queued ?? 0,
180
320
  activeCount,
181
321
  capacity: config.capacity,
182
- observationCount: scan.observations.length,
322
+ observationCount,
183
323
  observationLimit: config.policy.harvestThreshold,
184
324
  ...(status.availableUpgrade !== undefined
185
325
  ? { availableUpgrade: status.availableUpgrade }
@@ -199,3 +339,52 @@ export async function getOperatorDashboard(opts: {
199
339
  },
200
340
  }
201
341
  }
342
+
343
+ /** Thrown when a deposited `dispatcher-effective-config` artifact exists but
344
+ * cannot be parsed or validated: the hosted message route maps this to a 500
345
+ * `internal` — a broken config must never silently disable the orchestrator
346
+ * (AUT-342). */
347
+ export class OrchestratorConfigError extends Error {
348
+ constructor(message: string) {
349
+ super(message)
350
+ this.name = 'OrchestratorConfigError'
351
+ }
352
+ }
353
+
354
+ /** The repository's orchestrator configuration, resolved from the latest
355
+ * deposited `dispatcher-effective-config` artifact (AUT-342). Returns null
356
+ * when no artifact exists — the orchestrator is disabled, and message
357
+ * posting behaves exactly as it did before this feature. Throws
358
+ * `OperatorQueryError` (code `effective-config-unavailable`) when an
359
+ * artifact exists but is unreadable or invalid: a deployment that deposited
360
+ * a broken config must fail loudly (a 500 on the message route), never
361
+ * silently disable the orchestrator. */
362
+ export async function orchestratorConfig(store: BuildStore, repo: string): Promise<Config | null> {
363
+ // A repository the store has no record for has no deposited artifact and
364
+ // no dispatcher history: the orchestrator is disabled there.
365
+ if ((await store.getRepo(repo)) === null) return null
366
+ const artifacts = await store.listRepoArtifacts(repo, DISPATCHER_EFFECTIVE_CONFIG_ARTIFACT)
367
+ if (artifacts.length === 0) return null
368
+ const latest = [...artifacts].sort((a, b) => b.revision - a.revision)[0]!
369
+ const artifact = await store.getRepoArtifact(repo, latest.kind, latest.revision)
370
+ if (artifact === null) {
371
+ throw new OrchestratorConfigError(
372
+ `effective config ${latest.kind}@${latest.revision} is not retrievable`,
373
+ )
374
+ }
375
+ let raw: unknown
376
+ try {
377
+ raw = JSON.parse(new TextDecoder().decode(artifact.content))
378
+ } catch {
379
+ throw new OrchestratorConfigError(
380
+ `effective config ${latest.kind}@${latest.revision} is not valid JSON`,
381
+ )
382
+ }
383
+ const parsed = configSchema.safeParse(raw)
384
+ if (!parsed.success) {
385
+ throw new OrchestratorConfigError(
386
+ `effective config ${latest.kind}@${latest.revision} is invalid: ${parsed.error.message}`,
387
+ )
388
+ }
389
+ return parsed.data.orchestrator.enabled ? parsed.data : null
390
+ }