@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
@@ -1,16 +1,36 @@
1
+ import { composeBuildConfig } from '../config/live'
2
+ import type { PipelineSourceMeta } from '../config/pipeline-source'
1
3
  import { configSchema, type Config } from '../config/schema'
2
- import { detail, statusFilter, summarize, type BuildDetail, type BuildSummary } from '../cli/status'
4
+ import {
5
+ BUILD_EFFECTIVE_CONFIG_ARTIFACT,
6
+ parseBuildConfigMetadata,
7
+ parseEffectiveBuildConfig,
8
+ } from '../processes/build-execution-state'
9
+ import type { Artifact } from '../store/types'
10
+ import {
11
+ detail,
12
+ statusFilter,
13
+ summarizeFrom,
14
+ type BuildDetail,
15
+ type BuildSummary,
16
+ } from '../cli/status'
3
17
  import { projectRepositoryStatus, type RepositoryStatus } from '../cli/repository-status'
4
18
  import { projectHarvestStatus, type HarvestStatusView } from '../cli/harvest'
5
19
  import {
6
20
  buildDashboardFromProjected,
21
+ effectiveStatus,
7
22
  projectBuild,
23
+ projectBuildFromFacts,
8
24
  type DashboardBuild,
9
25
  type DashboardModel,
10
26
  } from '../cli/dashboard/model'
11
- import { reduceBuild } from '../kernel/reducer'
27
+ import { reduceBuild, type BuildState } from '../kernel/reducer'
28
+ import { DISPATCHER_EFFECTIVE_CONFIG_ARTIFACT } from '../store/retention'
12
29
  import { reduceDispatchStatus } from '../kernel/dispatch-status'
13
- import { scanUnclaimedObservations } from '../processes/harvest'
30
+ import { readRepoEventsIfRecorded, unclaimedObservationCount } from '../processes/harvest'
31
+ import type { RepoViewStore } from '../processes/repo-view'
32
+ import { withOperatorView } from './read-view'
33
+ import type { RepositoryEvent } from '../events/repository'
14
34
  import type { BuildStore, Clock } from '../store/types'
15
35
 
16
36
  export type BuildListScope = 'active' | 'queued' | 'all'
@@ -25,21 +45,55 @@ export class OperatorQueryError extends Error {
25
45
  }
26
46
  }
27
47
 
28
- async function repoEvents(store: BuildStore, repo: string) {
29
- return (await store.getRepo(repo)) === null ? [] : store.getRepoEvents(repo)
30
- }
31
-
32
48
  export async function listOperatorBuilds(opts: {
33
49
  store: BuildStore
34
50
  repo: string
35
51
  scope: BuildListScope
36
52
  now: Date
37
53
  }): Promise<BuildSummary[]> {
54
+ return withOperatorView(opts.store, opts.repo, 'builds', (view) => listFromView(view, opts))
55
+ }
56
+
57
+ async function listFromView(
58
+ view: RepoViewStore,
59
+ opts: { repo: string; scope: BuildListScope; now: Date },
60
+ ): Promise<BuildSummary[]> {
38
61
  const statuses = new Set(statusFilter(opts.scope === 'all', opts.scope === 'queued'))
62
+ // Records are read and held before the digest read: builds are never
63
+ // deleted, so every record iterated below is guaranteed an entry in the
64
+ // digest map fetched after it — a build created between the two reads
65
+ // appears only in the digest map, which is harmless — and the completeness
66
+ // check can only ever catch a genuine adapter bug, never a concurrent
67
+ // dispatch. The reverse order would let a concurrent creation abort the
68
+ // whole listing (AUT-488 finding f_3cb67aba).
69
+ const records = await view.listBuilds()
70
+ // One digest read gates the per-build reads (AUT-488): the active and queued
71
+ // scopes never show a terminal build, so a build whose digest already
72
+ // carries a terminal fact is skipped and the listing's cost stays flat as
73
+ // finished builds accumulate. The `--all` scope includes terminal statuses,
74
+ // so it takes no digest read and skips nothing; its terminal builds are
75
+ // restored from the reducer snapshots the dispatcher leaves at settlement.
76
+ const digests = opts.scope === 'all' ? undefined : await view.getRepoBuildDigests(opts.repo)
39
77
  const output: BuildSummary[] = []
40
- for (const record of await opts.store.listBuilds()) {
78
+ for (const record of records) {
41
79
  if (record.repo !== opts.repo) continue
42
- const projected = summarize(record, await opts.store.getEvents(record.slug), opts.now)
80
+ if (digests !== undefined) {
81
+ // Completeness is contractual (one entry per repo build); a missing
82
+ // entry is an adapter bug and must fail loudly rather than silently
83
+ // drop the build from the listing.
84
+ const digest = digests.get(record.slug)
85
+ if (digest === undefined) {
86
+ throw new Error(`getRepoBuildDigests is missing an entry for build "${record.slug}"`)
87
+ }
88
+ // The digest's `terminal` follows `reduceBuild`'s terminal rule exactly
89
+ // (in-order overwrite of `build.completed`/`build.aborted`, never
90
+ // cleared — pinned against `reduceBuild` in store/digest.test.ts), so
91
+ // `terminal !== undefined` is exactly reduced status `done`/`aborted`,
92
+ // which the active and queued status sets never include. Skipping the
93
+ // build therefore cannot change the emitted summaries.
94
+ if (digest.terminal !== undefined) continue
95
+ }
96
+ const projected = summarizeFrom(record, (await view.buildFacts(record.slug)).state, opts.now)
43
97
  if (statuses.has(projected.status)) output.push(projected)
44
98
  }
45
99
  return output.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
@@ -57,12 +111,15 @@ async function requireBuild(store: BuildStore, repo: string, slug: string) {
57
111
  export async function effectiveConfig(
58
112
  store: BuildStore,
59
113
  repo: string,
114
+ /** The bounded journal, when the caller already holds it (a repository
115
+ * view); otherwise it is read from the store. */
116
+ journal?: RepositoryEvent[],
60
117
  ): Promise<{
61
118
  config: Config
62
- repositoryEvents: Awaited<ReturnType<typeof repoEvents>>
119
+ repositoryEvents: RepositoryEvent[]
63
120
  status: ReturnType<typeof reduceDispatchStatus>
64
121
  }> {
65
- const repositoryEvents = await repoEvents(store, repo)
122
+ const repositoryEvents = journal ?? (await readRepoEventsIfRecorded(store, repo))
66
123
  let latestRun: string | undefined
67
124
  for (const event of repositoryEvents) {
68
125
  if (event.type === 'dispatcher.run-started') latestRun = event.payload.run
@@ -112,6 +169,65 @@ export interface OperatorBuildView {
112
169
  dashboardRow: DashboardBuild | null
113
170
  }
114
171
 
172
+ interface PinnedProjection {
173
+ config: Config
174
+ revision?: number
175
+ pipelineSource?: PipelineSourceMeta
176
+ }
177
+
178
+ /** Read one build's pinned effective-config artifact (SPEC §16.1): its parsed
179
+ * build-owned sections composed over the live snapshot's deployment-owned
180
+ * ones, plus the artifact's metadata projection. Every row must be projected
181
+ * against the pipeline the build actually runs — the artifact's build-owned
182
+ * sections — never the dispatcher's live base-branch snapshot, which a pinned
183
+ * build may never execute. All read failures are display-only: an absent or
184
+ * malformed artifact degrades to the live config, the pre-pin behavior. */
185
+ async function readPinnedConfig(
186
+ store: BuildStore,
187
+ slug: string,
188
+ live: Config,
189
+ ): Promise<PinnedProjection> {
190
+ let artifact: Artifact | null
191
+ try {
192
+ artifact = await store.getArtifact(slug, BUILD_EFFECTIVE_CONFIG_ARTIFACT)
193
+ } catch {
194
+ artifact = null
195
+ }
196
+ if (artifact === null) return { config: live }
197
+ let config: Config
198
+ try {
199
+ config = composeBuildConfig(parseEffectiveBuildConfig(artifact), live)
200
+ } catch {
201
+ config = live
202
+ }
203
+ return { config, ...parseBuildConfigMetadata(artifact) }
204
+ }
205
+
206
+ /** The shared null-row gate (AUT-486 snapshot, AUT-496 getOperatorBuild,
207
+ * AUT-487 digest): `effectiveStatus` maps only `done` outside the visible set
208
+ * (cli/dashboard/model.ts `isVisible`), so `projectBuild` returns null
209
+ * exactly for this state, and a pinned effective-config read for such a
210
+ * build could never be surfaced. One predicate so every consumer cannot
211
+ * diverge from each other or from the row projection: the digest's `terminal`
212
+ * is a third consumer of the same gate — `terminal === 'done'` is exactly
213
+ * `reduceBuild(...).status === 'done'` — and the snapshot uses it to skip a
214
+ * finished build's history and pinned-config reads without weakening this
215
+ * predicate as the authority. If cli/dashboard/model.ts ever adds a
216
+ * non-visible status, this predicate must follow — the snapshot
217
+ * byte-identical differential test and the done/aborted query tests pin the
218
+ * coupling. */
219
+ function projectsNoDashboardRow(state: BuildState): boolean {
220
+ return effectiveStatus(state) === 'done'
221
+ }
222
+
223
+ /** Attach the effective-config metadata (SPEC §16.1) to a projected row so an
224
+ * operator can see which autobuild.toml the build runs under. */
225
+ function decorateWithPinnedMeta(row: DashboardBuild, pinned: PinnedProjection): DashboardBuild {
226
+ if (pinned.revision !== undefined) row.effectiveConfigRev = pinned.revision
227
+ if (pinned.pipelineSource !== undefined) row.pipelineSource = pinned.pipelineSource
228
+ return row
229
+ }
230
+
115
231
  export async function getOperatorBuild(opts: {
116
232
  store: BuildStore
117
233
  repo: string
@@ -122,22 +238,36 @@ export async function getOperatorBuild(opts: {
122
238
  const events = await opts.store.getEvents(opts.slug)
123
239
  const state = reduceBuild(events)
124
240
  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 }
241
+ let dashboardRow: DashboardBuild | null = null
242
+ if (!projectsNoDashboardRow(state)) {
243
+ const pinned = await readPinnedConfig(opts.store, opts.slug, config)
244
+ const row = projectBuild(record, state, config, events, undefined, pinned.config)
245
+ // Implied by the gate; kept as an explicit check (not an assertion) so the
246
+ // code never lies if `effectiveStatus` and `projectBuild` ever drift.
247
+ if (row !== null) dashboardRow = decorateWithPinnedMeta(row, pinned)
248
+ }
249
+ return {
250
+ detail: detail(record, events, opts.now),
251
+ dashboardRow,
252
+ }
127
253
  }
128
254
 
129
255
  export async function getRepositoryStatus(
130
256
  store: BuildStore,
131
257
  repo: string,
132
258
  ): Promise<RepositoryStatus> {
133
- return projectRepositoryStatus(repo, await repoEvents(store, repo))
259
+ return withOperatorView(store, repo, 'journal', async (view) =>
260
+ projectRepositoryStatus(repo, view.recordedJournal()),
261
+ )
134
262
  }
135
263
 
136
264
  export async function getHarvestStatus(
137
265
  store: BuildStore,
138
266
  repo: string,
139
267
  ): Promise<HarvestStatusView> {
140
- return projectHarvestStatus(repo, await repoEvents(store, repo))
268
+ return withOperatorView(store, repo, 'journal', async (view) =>
269
+ projectHarvestStatus(repo, view.recordedJournal()),
270
+ )
141
271
  }
142
272
 
143
273
  export interface OperatorDashboardSnapshot {
@@ -156,18 +286,68 @@ export async function getOperatorDashboard(opts: {
156
286
  repo: string
157
287
  clock: Clock
158
288
  }): Promise<OperatorDashboardSnapshot> {
159
- const { config, repositoryEvents, status } = await effectiveConfig(opts.store, opts.repo)
289
+ return withOperatorView(opts.store, opts.repo, 'builds', (view) => dashboardFromView(view, opts))
290
+ }
291
+
292
+ async function dashboardFromView(
293
+ view: RepoViewStore,
294
+ opts: { repo: string; clock: Clock },
295
+ ): Promise<OperatorDashboardSnapshot> {
296
+ const { config, repositoryEvents, status } = await effectiveConfig(
297
+ view,
298
+ opts.repo,
299
+ view.recordedJournal(),
300
+ )
301
+ const records = await view.listBuilds()
302
+ // One digest read covers every build of the repository (AUT-487): the
303
+ // row gate (a `done` digest means no row) and the observation count need no
304
+ // per-build histories, so the snapshot's traffic stays flat as finished
305
+ // builds accumulate. Row-rendering builds derive from the repository view's
306
+ // reducer snapshots plus a delta read (AUT-650); every snapshot is a cache
307
+ // the event log regenerates, so the response equals a full replay's.
308
+ const digests = await view.getRepoBuildDigests(opts.repo)
160
309
  const projected: DashboardBuild[] = []
161
310
  let activeCount = 0
162
- for (const record of await opts.store.listBuilds()) {
311
+ for (const record of records) {
163
312
  if (record.repo !== opts.repo) continue
164
- const events = await opts.store.getEvents(record.slug)
165
- 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)
313
+ const digest = digests.get(record.slug)
314
+ // Completeness is contractual (one entry per repo build); a missing entry
315
+ // is an adapter bug and must fail loudly rather than silently drop the
316
+ // build from the active count or the row list.
317
+ if (digest === undefined) {
318
+ throw new Error(`getRepoBuildDigests is missing an entry for build "${record.slug}"`)
319
+ }
320
+ // Reduced status is `done`/`aborted` exactly when a terminal fact exists,
321
+ // so `terminal === undefined` is exactly the old active test — no history
322
+ // read needed for the count.
323
+ if (digest.terminal === undefined) activeCount += 1
324
+ if (digest.terminal === 'done') continue
325
+ // Both query surfaces share the `projectsNoDashboardRow` gate (AUT-486
326
+ // snapshot, AUT-496 detail query): a build that projects no row —
327
+ // `effectiveStatus` maps only `done` outside the visible set — never
328
+ // reads its pinned effective-config artifact, because the read result
329
+ // could never be surfaced. The digest already excluded `done` builds
330
+ // above; the gate stays as the authority for the row projection so the
331
+ // digest and the reducer cannot silently diverge. Every row-rendering
332
+ // build still gets its facts and pinned pipeline.
333
+ const facts = await view.buildFacts(record.slug)
334
+ if (projectsNoDashboardRow(facts.state)) continue
335
+ const pinned = await readPinnedConfig(view, record.slug, config)
336
+ const row = projectBuildFromFacts(
337
+ record,
338
+ facts.state,
339
+ { dashboard: facts.dashboard, log: facts.log, deferral: facts.deferral },
340
+ config,
341
+ undefined,
342
+ pinned.config,
343
+ )
344
+ if (row !== null) projected.push(decorateWithPinnedMeta(row, pinned))
169
345
  }
170
- const scan = await scanUnclaimedObservations(opts.store, opts.repo)
346
+ // The unclaimed-observation count comes from the digests and the journal
347
+ // `effectiveConfig` already returned — no per-build history reads (AUT-487).
348
+ // The pure core writes nothing — a snapshot performs no store writes and
349
+ // never creates or locks the repository record.
350
+ const observationCount = unclaimedObservationCount({ digests, harvestEvents: repositoryEvents })
171
351
  const warningLines = [
172
352
  ...status.roleWarnings,
173
353
  ...(status.warningNotice !== undefined ? [status.warningNotice] : []),
@@ -179,7 +359,7 @@ export async function getOperatorDashboard(opts: {
179
359
  queued: status.queued ?? 0,
180
360
  activeCount,
181
361
  capacity: config.capacity,
182
- observationCount: scan.observations.length,
362
+ observationCount,
183
363
  observationLimit: config.policy.harvestThreshold,
184
364
  ...(status.availableUpgrade !== undefined
185
365
  ? { availableUpgrade: status.availableUpgrade }
@@ -199,3 +379,52 @@ export async function getOperatorDashboard(opts: {
199
379
  },
200
380
  }
201
381
  }
382
+
383
+ /** Thrown when a deposited `dispatcher-effective-config` artifact exists but
384
+ * cannot be parsed or validated: the hosted message route maps this to a 500
385
+ * `internal` — a broken config must never silently disable the orchestrator
386
+ * (AUT-342). */
387
+ export class OrchestratorConfigError extends Error {
388
+ constructor(message: string) {
389
+ super(message)
390
+ this.name = 'OrchestratorConfigError'
391
+ }
392
+ }
393
+
394
+ /** The repository's orchestrator configuration, resolved from the latest
395
+ * deposited `dispatcher-effective-config` artifact (AUT-342). Returns null
396
+ * when no artifact exists — the orchestrator is disabled, and message
397
+ * posting behaves exactly as it did before this feature. Throws
398
+ * `OperatorQueryError` (code `effective-config-unavailable`) when an
399
+ * artifact exists but is unreadable or invalid: a deployment that deposited
400
+ * a broken config must fail loudly (a 500 on the message route), never
401
+ * silently disable the orchestrator. */
402
+ export async function orchestratorConfig(store: BuildStore, repo: string): Promise<Config | null> {
403
+ // A repository the store has no record for has no deposited artifact and
404
+ // no dispatcher history: the orchestrator is disabled there.
405
+ if ((await store.getRepo(repo)) === null) return null
406
+ const artifacts = await store.listRepoArtifacts(repo, DISPATCHER_EFFECTIVE_CONFIG_ARTIFACT)
407
+ if (artifacts.length === 0) return null
408
+ const latest = [...artifacts].sort((a, b) => b.revision - a.revision)[0]!
409
+ const artifact = await store.getRepoArtifact(repo, latest.kind, latest.revision)
410
+ if (artifact === null) {
411
+ throw new OrchestratorConfigError(
412
+ `effective config ${latest.kind}@${latest.revision} is not retrievable`,
413
+ )
414
+ }
415
+ let raw: unknown
416
+ try {
417
+ raw = JSON.parse(new TextDecoder().decode(artifact.content))
418
+ } catch {
419
+ throw new OrchestratorConfigError(
420
+ `effective config ${latest.kind}@${latest.revision} is not valid JSON`,
421
+ )
422
+ }
423
+ const parsed = configSchema.safeParse(raw)
424
+ if (!parsed.success) {
425
+ throw new OrchestratorConfigError(
426
+ `effective config ${latest.kind}@${latest.revision} is invalid: ${parsed.error.message}`,
427
+ )
428
+ }
429
+ return parsed.data.orchestrator.enabled ? parsed.data : null
430
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The operator read paths' repository view: every poll-driven route (dashboard,
3
+ * repository and harvest status, build listings) derives its state from a
4
+ * `RepoViewStore` cold-started from stored reducer snapshots and advanced with
5
+ * delta reads, so a poll's event-row cost follows the events appended since the
6
+ * last one, never the history, the open tabs, or the watchers.
7
+ *
8
+ * Passes are single-flight per `(store, repo)` within one serving process: at
9
+ * most one pass runs at a time, and requests arriving while it runs join the
10
+ * one trailing pass (each needs state at least as new as its arrival, which the
11
+ * running pass cannot promise). That trailing pass restores from the snapshot
12
+ * the previous pass persisted, so N concurrent pollers cost one delta read per
13
+ * pass, not N. The coordination is scheduling inside this process; every datum
14
+ * still comes from durable state, and a second serving process simply reads the
15
+ * same delta once itself (duplicate reads are bounded by the process count).
16
+ */
17
+ import { RepoViewStore } from '../processes/repo-view'
18
+ import type { BuildStore } from '../store/types'
19
+
20
+ /** What a request reads: the journal alone, or the builds beside it. */
21
+ export type OperatorViewNeed = 'journal' | 'builds'
22
+
23
+ interface Waiter {
24
+ need: OperatorViewNeed
25
+ run: (view: RepoViewStore) => Promise<unknown>
26
+ resolve: (value: unknown) => void
27
+ reject: (error: unknown) => void
28
+ }
29
+
30
+ interface Flight {
31
+ running: boolean
32
+ queue: Waiter[]
33
+ }
34
+
35
+ const flights = new WeakMap<BuildStore, Map<string, Flight>>()
36
+
37
+ function flightFor(store: BuildStore, repo: string): Flight {
38
+ let byRepo = flights.get(store)
39
+ if (byRepo === undefined) {
40
+ byRepo = new Map()
41
+ flights.set(store, byRepo)
42
+ }
43
+ let flight = byRepo.get(repo)
44
+ if (flight === undefined) {
45
+ flight = { running: false, queue: [] }
46
+ byRepo.set(repo, flight)
47
+ }
48
+ return flight
49
+ }
50
+
51
+ async function pass(store: BuildStore, repo: string, waiters: Waiter[]): Promise<void> {
52
+ const view = new RepoViewStore(store, { repo })
53
+ try {
54
+ if (waiters.some((waiter) => waiter.need === 'builds')) await view.refresh()
55
+ else await view.startJournal()
56
+ } catch (error) {
57
+ for (const waiter of waiters) waiter.reject(error)
58
+ return
59
+ }
60
+ await Promise.all(waiters.map((waiter) => waiter.run(view).then(waiter.resolve, waiter.reject)))
61
+ // Leave the advanced state behind for the next pass (this process's or
62
+ // another's). `held` also writes a settled build read for a listing. The
63
+ // cache never fails a read.
64
+ try {
65
+ await view.persistSnapshots({ force: true, held: true })
66
+ } catch {
67
+ // A cache: the next pass retries.
68
+ }
69
+ }
70
+
71
+ async function drain(store: BuildStore, repo: string, flight: Flight): Promise<void> {
72
+ flight.running = true
73
+ try {
74
+ while (flight.queue.length > 0) {
75
+ const waiters = flight.queue
76
+ flight.queue = []
77
+ await pass(store, repo, waiters)
78
+ }
79
+ } finally {
80
+ flight.running = false
81
+ }
82
+ }
83
+
84
+ /** Run `run` against a freshly advanced repository view. The view is only valid
85
+ * inside `run`; the response must not hold it. */
86
+ export function withOperatorView<T>(
87
+ store: BuildStore,
88
+ repo: string,
89
+ need: OperatorViewNeed,
90
+ run: (view: RepoViewStore) => Promise<T>,
91
+ ): Promise<T> {
92
+ const flight = flightFor(store, repo)
93
+ const result = new Promise<T>((resolve, reject) => {
94
+ flight.queue.push({ need, run, resolve: resolve as (value: unknown) => void, reject })
95
+ })
96
+ if (!flight.running) void drain(store, repo, flight)
97
+ return result
98
+ }
@@ -15,7 +15,8 @@
15
15
  * carried as base64 strings at the entry level (`builds.artifact`'s
16
16
  * `contentBase64` is the base64 of the exact artifact bytes), so a binding's
17
17
  * `JSON.stringify` can never silently corrupt bytes. The contract suite
18
- * (registry.contract.test.ts) asserts the round-trip for every tool.
18
+ * (packages/hosted-store-service/src/registry.contract.test.ts) asserts the
19
+ * round-trip for every tool.
19
20
  *
20
21
  * Risk classes live in `operator/annotations.ts` (checked in, enforced by the
21
22
  * contract suite). The registry itself enforces only what every binding needs
@@ -428,6 +429,25 @@ const sandboxWriteFileInput = z.strictObject({
428
429
 
429
430
  const sandboxResetInput = z.strictObject({ repo: repoField })
430
431
 
432
+ const sandboxPublishInput = z.strictObject({
433
+ repo: repoField,
434
+ title: z.string().min(1).max(200).describe('Pull-request title, 1-200 characters.'),
435
+ body: z
436
+ .string()
437
+ .max(65_536)
438
+ .optional()
439
+ .describe(
440
+ 'Optional pull-request body, at most 65,536 characters; an attribution block naming the operator and session is appended automatically.',
441
+ ),
442
+ commit: z
443
+ .string()
444
+ .min(1)
445
+ .optional()
446
+ .describe(
447
+ 'Optional commit-ish to publish instead of the checkout HEAD; must resolve to a commit that is a descendant of the base head recorded at provision or reset time.',
448
+ ),
449
+ })
450
+
431
451
  // ── The closed version-one table ─────────────────────────────────────────────
432
452
 
433
453
  function defineTool(
@@ -455,7 +475,7 @@ function defineTool(
455
475
 
456
476
  /** The closed version-one tool table. Adding a tool means adding an entry
457
477
  * here and a row in `operator/annotations.ts`; the contract suite proves the
458
- * two stay in lockstep. Sandbox and publish tools are later tickets. */
478
+ * two stay in lockstep. */
459
479
  export const TOOLS: readonly ToolEntry[] = [
460
480
  defineTool(
461
481
  'builds.list',
@@ -893,6 +913,22 @@ export const TOOLS: readonly ToolEntry[] = [
893
913
  return { ok: true as const }
894
914
  },
895
915
  ),
916
+ defineTool(
917
+ 'sandbox.publish',
918
+ "Publish your operator sandbox's work as a pull request: push the checkout's current head commit (or an explicit commit) to your operator's dedicated branch and open or update a PR against the base branch. Refuses when the checkout has uncommitted changes (tracked or untracked; the sandbox's own provisioning marker is excluded), when the commit is not a descendant of the base head recorded at provision or reset time, or when the base branch head itself would be published. The sandbox itself never pushes and holds no credential — the push and PR creation are kernel plumbing; a publication is a durable repository-journal fact. PR only: the base branch is never pushed to.",
919
+ sandboxPublishInput,
920
+ '{branch, sha, pr: {number, url, headSha}}: the publication branch, the published commit, and the opened or updated pull request.',
921
+ async (raw, ctx) => {
922
+ const input = raw as z.infer<typeof sandboxPublishInput>
923
+ return sandboxOf(ctx).publish(attributed(ctx), {
924
+ repo: input.repo,
925
+ title: input.title,
926
+ ...(input.body !== undefined ? { body: input.body } : {}),
927
+ ...(input.commit !== undefined ? { commit: input.commit } : {}),
928
+ ...(ctx.via !== undefined ? { via: ctx.via } : {}),
929
+ })
930
+ },
931
+ ),
896
932
  ]
897
933
 
898
934
  // ── The registry constructor ─────────────────────────────────────────────────
@@ -900,8 +936,9 @@ export const TOOLS: readonly ToolEntry[] = [
900
936
  export interface RegistryOptions {
901
937
  store: BuildStore
902
938
  tickets?: OperatorTicketBackend
903
- /** The operator-sandbox backend; when absent the six sandbox tools are
904
- * neither advertised nor callable (the compatibility case). */
939
+ /** The operator-sandbox backend; when absent the sandbox tools are
940
+ * neither advertised nor callable (the compatibility case). When present
941
+ * but `canPublish` is false, only `sandbox.publish` is filtered. */
905
942
  sandbox?: OperatorSandboxService
906
943
  clock?: Clock
907
944
  /** Constrain every call to this repository identity (the `ab mcp --repo` flag). */
@@ -917,7 +954,8 @@ export interface OperatorToolRegistry {
917
954
  }
918
955
 
919
956
  /** Map a service error to the registry's operator-API-shaped failure body —
920
- * the same kind mapping as the catch block in operator/server.ts. */
957
+ * the same kind mapping as the catch block in the hosted package's
958
+ * operator-server.ts. */
921
959
  function mapDomainError(error: unknown): RegistryError {
922
960
  if (error instanceof RegistryError) return error
923
961
  if (error instanceof RouteRefusalError) {
@@ -976,15 +1014,22 @@ function mapDomainError(error: unknown): RegistryError {
976
1014
  /** Bind the closed table to an opened store (and optional ticket and sandbox
977
1015
  * backends). The returned registry is the in-process binding; every other
978
1016
  * binding is generated from `entries`. Advertisement and dispatch share ONE
979
- * table: without a sandbox backend the six sandbox tools are filtered from
980
- * `entries` AND from `call()` dispatch, so a binding that cannot provision
981
- * sandboxes never advertises or serves them. */
1017
+ * table: a filtered tool is neither advertised nor served. */
982
1018
  export function buildRegistry(options: RegistryOptions): OperatorToolRegistry {
983
1019
  const sandboxToolNames = new Set(
984
1020
  Object.keys(OPERATOR_TOOL_ANNOTATIONS).filter((name) => name.startsWith('sandbox.')),
985
1021
  )
986
- const table =
987
- options.sandbox === undefined ? TOOLS.filter((tool) => !sandboxToolNames.has(tool.name)) : TOOLS
1022
+ // Without a sandbox backend every sandbox tool is filtered; with a backend
1023
+ // that cannot publish (no forge or no provider publication capability),
1024
+ // only `sandbox.publish` is filtered. Filtered tools are absent from
1025
+ // `entries` AND from `call()` dispatch.
1026
+ const filtered = new Set<string>()
1027
+ if (options.sandbox === undefined) {
1028
+ for (const name of sandboxToolNames) filtered.add(name)
1029
+ } else if (options.sandbox.canPublish === false) {
1030
+ filtered.add('sandbox.publish')
1031
+ }
1032
+ const table = filtered.size === 0 ? TOOLS : TOOLS.filter((tool) => !filtered.has(tool.name))
988
1033
  return {
989
1034
  entries: table,
990
1035
  async call(name, input, partial = {}) {
@@ -4,9 +4,10 @@
4
4
  *
5
5
  * This module is registry-owned on purpose: the operator API routes were
6
6
  * declared out of scope for the registry ticket, so the parity between a
7
- * tool and its route is *proven* by the contract suite (registry.contract
8
- * .test.ts) rather than obtained by extraction. Every refusal text here is
9
- * load-bearing — it must stay byte-identical to `operator/server.ts` so the
7
+ * tool and its route is *proven* by the contract suite
8
+ * (packages/hosted-store-service/src/registry.contract.test.ts) rather than
9
+ * obtained by extraction. Every refusal text here is load-bearing — it must
10
+ * stay byte-identical to the hosted package's `operator-server.ts` so the
10
11
  * suite's full-body comparisons hold.
11
12
  */
12
13
  import { reduceBuild } from '../kernel/reducer'
@@ -34,7 +35,8 @@ export class RouteRefusalError extends Error {
34
35
  }
35
36
  }
36
37
 
37
- /** `requireRouteBuild` (operator/server.ts): 404 unless the slug exists and
38
+ /** `requireRouteBuild` (the hosted package's operator-server.ts): 404 unless
39
+ * the slug exists and
38
40
  * belongs to the named repository. */
39
41
  export async function requireRouteBuild(
40
42
  store: BuildStore,
@@ -61,7 +63,7 @@ export function parseArtifactRevision(raw: string): number {
61
63
  /**
62
64
  * The route's control-request prechecks: build existence/repo, then the
63
65
  * pending-state checks for pause and cancel-pause. Byte-identical to the
64
- * `POST …/control` branch in operator/server.ts.
66
+ * `POST …/control` branch in the hosted package's operator-server.ts.
65
67
  */
66
68
  export async function controlPrechecks(
67
69
  store: BuildStore,
@@ -97,7 +99,8 @@ export async function controlPrechecks(
97
99
 
98
100
  /**
99
101
  * The route's answer request → `BuildControlAction` translation, byte-identical
100
- * to the `POST …/answer` branch in operator/server.ts. The ticket-origin body
102
+ * to the `POST …/answer` branch in the hosted package's operator-server.ts.
103
+ * The ticket-origin body
101
104
  * reader travels separately, exactly as the route passes `readTicketBody`.
102
105
  */
103
106
  export function answerAction(request: OperatorAnswerRequest): {