@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
@@ -0,0 +1,140 @@
1
+ /**
2
+ * The checkout-less operator-sandbox composition helper (AUT-584): builds the
3
+ * same `createOperatorSandboxService` backend `ab mcp` builds from a checkout,
4
+ * but from a deposited effective config plus injected seams — for hosted
5
+ * serving processes that hold no host checkout and never touch host git.
6
+ *
7
+ * The module is a SEPARATE subpath (`@defrex/autobuild/operator-sandbox-host`)
8
+ * on purpose: it pulls the workspace-provider/forge construction closure, and
9
+ * existing `./operator` consumers (the hosted operator server among them) must
10
+ * not grow that closure. The hosted store service imports only this subpath.
11
+ *
12
+ * Containment contract: every construction failure THROWS. The callers (the
13
+ * hosted store service's runner factory and the dispatcher tick) contain it —
14
+ * a broken or unsupported config degrades the sandbox tools of one binding,
15
+ * never the orchestrator itself.
16
+ */
17
+ import { mkdtemp } from 'node:fs/promises'
18
+ import { tmpdir } from 'node:os'
19
+ import { join } from 'node:path'
20
+ import type { Config } from '../config/schema'
21
+ import type { Forge } from '../ports/types'
22
+ import { createForge } from '../ports/forge/create'
23
+ import { GitHubApiError } from '../ports/forge/github-transport'
24
+ import { createWorkspaceProvider } from '../ports/workspace/create'
25
+ import { createPluginRegistry } from '../plugins/registry'
26
+ import type { BuildStore, Clock } from '../store/types'
27
+ import { createOperatorSandboxService, type OperatorSandboxService } from './sandbox'
28
+
29
+ export interface HostedOperatorSandboxOptions {
30
+ store: BuildStore
31
+ /** The repository identity — already the normalized HTTPS origin the
32
+ * sandbox providers parse with `cleanGithubOrigin`; passed through as the
33
+ * provider's `origin` seam so no host git is ever probed. */
34
+ repo: string
35
+ /** The repository's effective config (the deposited
36
+ * `dispatcher-effective-config` artifact, or the checkout's parsed config).
37
+ * `[orchestrator].enabled` is the CALLER's gate; this helper composes the
38
+ * backend for any config handed to it. */
39
+ config: Config
40
+ /** The serving process's environment. The Vercel SDK credential must be
41
+ * reachable here for a `vercel-sandbox` workspace (`VERCEL_OIDC_TOKEN`, or
42
+ * the durable `VERCEL_TOKEN`/`VERCEL_TEAM_ID`/`VERCEL_PROJECT_ID` triple);
43
+ * a hosted caller merges the request's `x-vercel-oidc-token` under
44
+ * `VERCEL_OIDC_TOKEN` before calling, with an explicit env token keeping
45
+ * precedence. */
46
+ env: Record<string, string | undefined>
47
+ /** The provider's store authority: an HTTPS store ref reachable from the
48
+ * sandbox plus a repo-scoped token. The guest environment is always built
49
+ * from an empty record plus the configured forwarded names, so neither
50
+ * value ever reaches the sandbox guest. */
51
+ storeRef: string
52
+ storeToken: string
53
+ /** Scratch root for the composition's filesystem seams (worktree root,
54
+ * repo root, sandbox root). Default: a fresh `mkdtemp` under the OS temp
55
+ * directory. The builtin providers only touch these paths through host-git
56
+ * or local-scratch paths the injected seams bypass; a plugin-provider config
57
+ * fails construction before any path use. */
58
+ scratchRoot?: string
59
+ clock?: Clock
60
+ /** Test seam: an already-constructed forge. Production passes nothing and
61
+ * the configured forge is built here. The forge is REQUIRED either way —
62
+ * unlike `ab mcp`, which degrades to `canPublish: false` because its
63
+ * provider can fall back to host git, a hosted host has no checkout, so a
64
+ * backend without a forge cannot resolve base heads at all. */
65
+ forge?: Forge
66
+ }
67
+
68
+ /** Map a forge onto the provider's checkout-less `remoteBranchHead` seam:
69
+ * a `GitHubApiError` with status 404 means the branch does not exist and
70
+ * reads as `undefined`; every other error is rethrown (a transient GitHub
71
+ * failure must never masquerade as an absent branch and refuse provision).
72
+ * The forge must expose `remoteBranchSha`; the caller enforces that before
73
+ * calling with a diagnostic that names the forge. */
74
+ export function remoteBranchHeadFromForge(
75
+ forge: Forge,
76
+ ): (branch: string) => Promise<string | undefined> {
77
+ const remoteBranchSha = forge.remoteBranchSha
78
+ if (remoteBranchSha === undefined) {
79
+ throw new Error('forge does not resolve remote branch heads')
80
+ }
81
+ return async (branch: string): Promise<string | undefined> => {
82
+ try {
83
+ return await remoteBranchSha.call(forge, branch)
84
+ } catch (error) {
85
+ if (error instanceof GitHubApiError && error.status === 404) return undefined
86
+ throw error
87
+ }
88
+ }
89
+ }
90
+
91
+ /** Build the hosted operator-sandbox backend from the effective config and
92
+ * injected seams. Throws on every construction failure (unknown provider or
93
+ * forge, missing Vercel SDK credential, forbidden forwarded env name,
94
+ * capability-less provider, forge without `remoteBranchSha`) — the caller
95
+ * contains and degrades to serving without the sandbox tools. */
96
+ export async function createHostedOperatorSandboxService(
97
+ options: HostedOperatorSandboxOptions,
98
+ ): Promise<OperatorSandboxService> {
99
+ const scratch = options.scratchRoot ?? (await mkdtemp(join(tmpdir(), 'autobuild-sandbox-host-')))
100
+ const forge =
101
+ options.forge ??
102
+ (await createForge({
103
+ name: options.config.forge,
104
+ registry: createPluginRegistry(),
105
+ env: options.env,
106
+ repoRoot: scratch,
107
+ repository: options.repo,
108
+ }))
109
+ if (forge.remoteBranchSha === undefined) {
110
+ throw new Error(
111
+ `the configured forge "${options.config.forge}" does not resolve remote branch heads; ` +
112
+ 'the hosted sandbox backend has no checkout to resolve base heads against',
113
+ )
114
+ }
115
+ // Builtin-only registry: a plugin-provider config fails construction here
116
+ // (unknown provider in the empty registry) and the caller contains it.
117
+ const provider = await createWorkspaceProvider(options.config.workspace, {
118
+ registry: createPluginRegistry(),
119
+ worktreeRoot: scratch,
120
+ repoRoot: scratch,
121
+ env: options.env,
122
+ storeRef: options.storeRef,
123
+ storeToken: options.storeToken,
124
+ origin: async () => options.repo,
125
+ remoteBranchHead: remoteBranchHeadFromForge(forge),
126
+ sandboxSetupCommand: options.config.commands.setup,
127
+ sandboxRoot: join(scratch, 'orchestrator-sandboxes'),
128
+ sandboxEnvironmentVariables: options.config.orchestrator.sandbox.environmentVariables,
129
+ orchestratorSandboxEnabled: true,
130
+ })
131
+ return createOperatorSandboxService({
132
+ store: options.store,
133
+ repo: options.repo,
134
+ provider,
135
+ sandbox: options.config.orchestrator.sandbox,
136
+ baseBranch: options.config.baseBranch,
137
+ forge,
138
+ ...(options.clock !== undefined ? { clock: options.clock } : {}),
139
+ })
140
+ }
@@ -10,7 +10,11 @@
10
10
  * environments from empty records); redaction here keeps credential VALUES
11
11
  * out of typed error messages that travel to tool callers.
12
12
  */
13
+ import { createHash } from 'node:crypto'
13
14
  import { humanActor } from '../events/envelope'
15
+ import type { Via } from '../events/envelope'
16
+ import type { RepositoryEvent } from '../events/repository'
17
+ import type { Forge } from '../ports/types'
14
18
  import {
15
19
  SANDBOX_FORBIDDEN_ENV,
16
20
  SandboxOperationError,
@@ -19,6 +23,7 @@ import {
19
23
  type SandboxWaitResult,
20
24
  } from '../ports/workspace/operator-sandbox'
21
25
  import type { WorkspaceProvider } from '../ports/types'
26
+ import { readRepoEventsIfRecorded } from '../processes/harvest'
22
27
  import { sandboxStates } from '../processes/sandbox-state'
23
28
  import { systemClock, type BuildStore, type Clock } from '../store/types'
24
29
 
@@ -107,12 +112,41 @@ function describeError(error: unknown): string {
107
112
  return redactSandboxMessage(error instanceof Error ? error.message : String(error))
108
113
  }
109
114
 
115
+ /** The canonical publication branch grammar every provider adapter enforces. */
116
+ const PUBLICATION_BRANCH_PATTERN = /^ab\/[a-z0-9][a-z0-9-]*$/
117
+
118
+ /** Deterministic PR branch name for one operator × repository:
119
+ * `ab/orch-<operator-slug>-<hash8>` where hash8 is the first 8 hex chars of
120
+ * sha256(`<repo>\0<operator>`) and the slug is the operator lowercased with
121
+ * non-`[a-z0-9]` runs collapsed to `-` and trimmed. One operator always maps
122
+ * to one branch per repository, so a later publish updates the same PR's
123
+ * head. An identity with no `[a-z0-9]` characters yields `ab/orch--<hash8>`,
124
+ * which still matches the canonical grammar — no fallback token. */
125
+ export function sandboxPublicationBranch(repo: string, operator: string): string {
126
+ const slug = operator
127
+ .toLowerCase()
128
+ .replace(/[^a-z0-9]+/g, '-')
129
+ .replace(/^-+|-+$/g, '')
130
+ const hash8 = createHash('sha256').update(`${repo}\0${operator}`).digest('hex').slice(0, 8)
131
+ const branch = `ab/orch-${slug}-${hash8}`
132
+ if (!PUBLICATION_BRANCH_PATTERN.test(branch)) {
133
+ throw new SandboxOperationError(
134
+ 'publish',
135
+ `derived publication branch ${JSON.stringify(branch)} is not a canonical branch name`,
136
+ )
137
+ }
138
+ return branch
139
+ }
140
+
110
141
  export interface OperatorSandboxConfig {
111
142
  idleMinutes: number
112
143
  environmentVariables: readonly string[]
113
144
  }
114
145
 
115
146
  export interface OperatorSandboxService {
147
+ /** True when both a forge and the provider's sandbox publication
148
+ * capability are present, i.e. `publish` is callable. */
149
+ readonly canPublish: boolean
116
150
  exec(
117
151
  identity: string,
118
152
  input: { repo: string; command: string; cwd?: string; timeoutSeconds?: number },
@@ -133,6 +167,20 @@ export interface OperatorSandboxService {
133
167
  /** Destructive: journal `reset`, full teardown + snapshot purge, then a
134
168
  * fresh provision from the CURRENT base head. */
135
169
  reset(identity: string, input: { repo: string }): Promise<void>
170
+ /** PR-only publication (AUT-343): push the sandbox checkout's head (or an
171
+ * explicit commit) to a deterministic operator branch and open or update
172
+ * the PR against the base branch. The base branch itself is never pushed
173
+ * to; the sandbox performs no push and receives no credential. */
174
+ publish(
175
+ identity: string,
176
+ input: {
177
+ repo: string
178
+ title: string
179
+ body?: string
180
+ commit?: string
181
+ via?: Via
182
+ },
183
+ ): Promise<{ branch: string; sha: string; pr: { number: number; url: string; headSha: string } }>
136
184
  /** Full teardown + snapshot purge of the operator's environment, without
137
185
  * provisioning. A no-op when the journal shows no environment. Used by
138
186
  * `reset` and the session-archive hook. */
@@ -148,6 +196,9 @@ export interface OperatorSandboxServiceOptions {
148
196
  * branch's current head. A hot baseBranch reload is picked up when the
149
197
  * `ab mcp` process restarts. */
150
198
  baseBranch: string
199
+ /** The forge used to open or update the PR. Absent = publication is
200
+ * unavailable and the service reports `canPublish: false`. */
201
+ forge?: Forge
151
202
  clock?: Clock
152
203
  capability?: WorkspaceProvider['orchestratorSandbox']
153
204
  }
@@ -165,6 +216,7 @@ export async function createOperatorSandboxService(
165
216
  )
166
217
  }
167
218
  const clock = options.clock ?? systemClock
219
+ const canPublish = options.forge !== undefined && provider.sandboxPublication !== undefined
168
220
  await store.ensureRepo(repo)
169
221
 
170
222
  /** Per-environment serialization: one in-process promise chain keyed by
@@ -188,7 +240,9 @@ export async function createOperatorSandboxService(
188
240
  | 'orchestrator.sandbox.resumed'
189
241
  | 'orchestrator.sandbox.activity'
190
242
  | 'orchestrator.sandbox.released'
191
- | 'orchestrator.sandbox.reset',
243
+ | 'orchestrator.sandbox.reset'
244
+ | 'orchestrator.sandbox.published'
245
+ | 'orchestrator.sandbox.publish-failed',
192
246
  payload: Record<string, unknown>,
193
247
  ): Promise<void> => {
194
248
  await store.appendRepo(repo, {
@@ -198,13 +252,8 @@ export async function createOperatorSandboxService(
198
252
  })
199
253
  }
200
254
 
201
- const journalEvents = async (): Promise<Awaited<ReturnType<typeof store.getRepoEvents>>> => {
202
- if ((await store.getRepo(repo)) === null) return []
203
- return store.getRepoEvents(repo)
204
- }
205
-
206
255
  /** The operator's journal state, or undefined when no environment exists. */
207
- const operatorState = (identity: string, events: Awaited<ReturnType<typeof journalEvents>>) =>
256
+ const operatorState = (identity: string, events: RepositoryEvent[]) =>
208
257
  sandboxStates(events)
209
258
  .filter((state) => state.operator === identity && state.state !== 'released')
210
259
  .at(-1)
@@ -227,6 +276,7 @@ export async function createOperatorSandboxService(
227
276
  provider: resolved.provider,
228
277
  ...(resolved.sessionId !== undefined ? { sessionId: resolved.sessionId } : {}),
229
278
  workspacePath: resolved.workspacePath,
279
+ ...(resolved.baseSha !== undefined ? { baseSha: resolved.baseSha } : {}),
230
280
  })
231
281
  } else if (state.state === 'stopped') {
232
282
  await append(identity, 'orchestrator.sandbox.resumed', {
@@ -255,10 +305,13 @@ export async function createOperatorSandboxService(
255
305
  })
256
306
  }
257
307
 
258
- const requireRepo = (inputRepo: string | undefined): void => {
308
+ const requireRepo = (
309
+ inputRepo: string | undefined,
310
+ stage: SandboxOperationError['stage'] = 'exec',
311
+ ): void => {
259
312
  if (inputRepo !== undefined && inputRepo !== repo) {
260
313
  throw new SandboxOperationError(
261
- 'exec',
314
+ stage,
262
315
  `sandbox tools serve repository "${repo}"; refusing "${inputRepo}"`,
263
316
  )
264
317
  }
@@ -266,7 +319,7 @@ export async function createOperatorSandboxService(
266
319
 
267
320
  const run = async <T>(
268
321
  identity: string,
269
- stage: 'provision' | 'resume' | 'exec',
322
+ stage: 'provision' | 'resume' | 'exec' | 'publish',
270
323
  operation: (
271
324
  resolved: SandboxEnvironmentIdentity,
272
325
  state: ReturnType<typeof operatorState>,
@@ -274,7 +327,7 @@ export async function createOperatorSandboxService(
274
327
  ): Promise<T> => {
275
328
  const key = `${repo}\0${identity}`
276
329
  return serialize(key, async () => {
277
- const events = await journalEvents()
330
+ const events = await readRepoEventsIfRecorded(store, repo)
278
331
  const state = operatorState(identity, events)
279
332
  const resolved = await ensureWithFacts(identity, state).catch((error: unknown) => {
280
333
  if (error instanceof SandboxOperationError) throw error
@@ -284,7 +337,7 @@ export async function createOperatorSandboxService(
284
337
  { cause: error },
285
338
  )
286
339
  })
287
- const freshEvents = await journalEvents()
340
+ const freshEvents = await readRepoEventsIfRecorded(store, repo)
288
341
  const freshState = operatorState(identity, freshEvents)
289
342
  try {
290
343
  const result = await operation(resolved, freshState)
@@ -298,6 +351,10 @@ export async function createOperatorSandboxService(
298
351
  }
299
352
 
300
353
  return {
354
+ /** True only when both a forge and a sandbox publication capability are
355
+ * wired; the registry filters `sandbox.publish` when false. */
356
+ canPublish,
357
+
301
358
  async exec(identity, input) {
302
359
  requireRepo(input.repo)
303
360
  const timeoutSeconds = validateBoundedInt(
@@ -393,11 +450,187 @@ export async function createOperatorSandboxService(
393
450
  })
394
451
  },
395
452
 
453
+ async publish(
454
+ identity,
455
+ input: {
456
+ repo: string
457
+ title: string
458
+ body?: string
459
+ commit?: string
460
+ via?: Via
461
+ },
462
+ ) {
463
+ requireRepo(input.repo, 'publish')
464
+ const title = input.title.trim()
465
+ if (title === '') {
466
+ throw new SandboxOperationError('publish', 'title must not be empty')
467
+ }
468
+ if (title.length > 200) {
469
+ throw new SandboxOperationError('publish', 'title must be at most 200 characters')
470
+ }
471
+ if (input.body !== undefined && input.body.length > 65_536) {
472
+ throw new SandboxOperationError('publish', 'body must be at most 65,536 characters')
473
+ }
474
+ if (input.commit !== undefined && input.commit.trim() === '') {
475
+ throw new SandboxOperationError('publish', 'commit must be a nonempty commit-ish')
476
+ }
477
+ if (!canPublish) {
478
+ throw new SandboxOperationError(
479
+ 'publish',
480
+ 'publication is unavailable: no forge or no sandbox publication capability is wired',
481
+ )
482
+ }
483
+ const forge = options.forge!
484
+ const publication = provider.sandboxPublication!
485
+ // run() already serializes per environment; wrapping it in another
486
+ // serialize on the same key would deadlock the chain.
487
+ return run(identity, 'publish', async (resolved, freshState) => {
488
+ let stage: 'checks' | 'push' | 'pr' = 'checks'
489
+ try {
490
+ // 1. Resolve the commit inside the sandbox checkout.
491
+ const commitExpr = input.commit?.trim() || 'HEAD'
492
+ const resolveResult = await capability.exec(resolved, {
493
+ // --quiet makes an unresolvable commit-ish exit 1 (the documented
494
+ // refusal signal below); without it git exits 128 and the refusal
495
+ // branch is unreachable.
496
+ command: `git rev-parse --verify --quiet ${commitExpr}^{commit}`,
497
+ timeoutSeconds: 30,
498
+ })
499
+ if (resolveResult.exitCode === 1) {
500
+ throw new SandboxOperationError(
501
+ 'publish',
502
+ `commit ${JSON.stringify(commitExpr)} does not resolve to a commit in the sandbox checkout`,
503
+ )
504
+ }
505
+ if (resolveResult.exitCode !== 0) {
506
+ throw new Error(
507
+ `git rev-parse --verify ${commitExpr}^{commit} exited ${resolveResult.exitCode}: ${resolveResult.stderr.trim() || resolveResult.stdout.trim() || '(no output)'}`,
508
+ )
509
+ }
510
+ const sha = resolveResult.stdout.trim()
511
+ // 2. Refuse a dirty checkout: staged or unstaged changes to
512
+ // tracked files, and untracked files — a new file the operator
513
+ // never committed would otherwise be silently left out of the
514
+ // published commit. The providers exclude their own
515
+ // provisioning marker in the checkout's info/exclude, so the
516
+ // marker itself never counts as dirt (f_c748dc6a).
517
+ const statusResult = await capability.exec(resolved, {
518
+ command: 'git status --porcelain',
519
+ timeoutSeconds: 30,
520
+ })
521
+ if (statusResult.exitCode !== 0) {
522
+ throw new Error(
523
+ `git status --porcelain exited ${statusResult.exitCode}: ${statusResult.stderr.trim() || statusResult.stdout.trim() || '(no output)'}`,
524
+ )
525
+ }
526
+ if (statusResult.stdout.trim() !== '') {
527
+ throw new SandboxOperationError(
528
+ 'publish',
529
+ 'the sandbox checkout has uncommitted changes; commit or discard them before publishing',
530
+ )
531
+ }
532
+ // 3. Refuse a commit that is not a descendant of the base head
533
+ // at provision or reset time.
534
+ const baseSha = freshState?.baseSha
535
+ if (baseSha === undefined) {
536
+ throw new SandboxOperationError(
537
+ 'publish',
538
+ 'this environment has no recorded base head; run sandbox.reset to re-provision before publishing',
539
+ )
540
+ }
541
+ if (sha === baseSha) {
542
+ throw new SandboxOperationError(
543
+ 'publish',
544
+ 'the checkout head is the base commit itself; there is nothing to publish',
545
+ )
546
+ }
547
+ const ancestorResult = await capability.exec(resolved, {
548
+ command: `git merge-base --is-ancestor ${baseSha} ${sha}`,
549
+ timeoutSeconds: 30,
550
+ })
551
+ if (ancestorResult.exitCode === 1) {
552
+ throw new SandboxOperationError(
553
+ 'publish',
554
+ `the commit is not a descendant of the base head ${baseSha} recorded at provision time; rebase onto the current base or run sandbox.reset`,
555
+ )
556
+ }
557
+ if (ancestorResult.exitCode !== 0) {
558
+ throw new Error(
559
+ `git merge-base --is-ancestor ${baseSha} ${sha} exited ${ancestorResult.exitCode}: ${ancestorResult.stderr.trim() || ancestorResult.stdout.trim() || '(no output)'}`,
560
+ )
561
+ }
562
+ // 4. Deterministic branch; the helper self-validates the grammar.
563
+ const branch = sandboxPublicationBranch(repo, identity)
564
+ if (branch === options.baseBranch) {
565
+ throw new SandboxOperationError(
566
+ 'publish',
567
+ 'the derived publication branch collides with the base branch; refusing',
568
+ )
569
+ }
570
+ // 5. Push-before-fact crash safety, mirroring
571
+ // publication-settlement: a crashed prior attempt is owed its
572
+ // completion, not a re-push.
573
+ const alreadyPublished =
574
+ (await publication.isPublished?.({
575
+ ref: resolved.environmentId,
576
+ sha,
577
+ branch,
578
+ })) === true
579
+ stage = 'push'
580
+ if (!alreadyPublished) {
581
+ await publication.publish({ ref: resolved.environmentId, sha, branch })
582
+ }
583
+ // 6. Open (or adopt) the PR against the base branch; a later
584
+ // publish to the same branch moves the same PR's head.
585
+ stage = 'pr'
586
+ const session =
587
+ input.via !== undefined && input.via.kind === 'session' ? input.via.id : undefined
588
+ const body =
589
+ (input.body ?? '') +
590
+ (input.body !== undefined && input.body !== '' ? '\n\n' : '') +
591
+ `Published from an Autobuild operator sandbox by ${identity}` +
592
+ (session !== undefined ? ` via orchestrator session ${session}` : '') +
593
+ ' — agent-authored, not a pipeline build.'
594
+ const pr = await forge.openPr({
595
+ workspacePath: resolved.workspacePath,
596
+ head: branch,
597
+ base: options.baseBranch,
598
+ title,
599
+ body,
600
+ })
601
+ // 7. The publication is a repository-journal fact.
602
+ await append(identity, 'orchestrator.sandbox.published', {
603
+ operator: identity,
604
+ environmentId: resolved.environmentId,
605
+ branch,
606
+ sha,
607
+ ...(session !== undefined ? { session } : {}),
608
+ pr: { number: pr.number, url: pr.url, headSha: pr.headSha },
609
+ })
610
+ return { branch, sha, pr: { number: pr.number, url: pr.url, headSha: pr.headSha } }
611
+ } catch (error) {
612
+ // 8. Every refusal or failure at steps 1-6 is a journal fact
613
+ // naming the stage, with credential values redacted from the
614
+ // message.
615
+ const message = describeError(error)
616
+ await append(identity, 'orchestrator.sandbox.publish-failed', {
617
+ operator: identity,
618
+ environmentId: resolved.environmentId,
619
+ stage,
620
+ message,
621
+ }).catch(() => {})
622
+ throw new SandboxOperationError('publish', message, {
623
+ cause: error instanceof SandboxOperationError ? error.cause : error,
624
+ })
625
+ }
626
+ })
627
+ },
628
+
396
629
  async reset(identity, input) {
397
630
  requireRepo(input.repo)
398
631
  const key = `${repo}\0${identity}`
399
632
  await serialize(key, async () => {
400
- const events = await journalEvents()
633
+ const events = await readRepoEventsIfRecorded(store, repo)
401
634
  const state = operatorState(identity, events)
402
635
  const resolved = await capability.describe({ repo, operator: identity })
403
636
  await append(identity, 'orchestrator.sandbox.reset', {
@@ -429,7 +662,7 @@ export async function createOperatorSandboxService(
429
662
  requireRepo(input.repo)
430
663
  const key = `${repo}\0${identity}`
431
664
  await serialize(key, async () => {
432
- const events = await journalEvents()
665
+ const events = await readRepoEventsIfRecorded(store, repo)
433
666
  const state = operatorState(identity, events)
434
667
  // No environment ever provisioned: a no-op, with no fact and no
435
668
  // provider traffic beyond the pure describe.
@@ -0,0 +1,47 @@
1
+ /**
2
+ * A dispatcher ticket source as the registry's ticket backend (AUT-342):
3
+ * the tick's turn runner takes an `OperatorToolRegistry`, and
4
+ * `buildRegistry`'s ticket tools require an `OperatorTicketBackend` — but
5
+ * the dispatcher's wiring holds a `TicketSource`, not a backend, and
6
+ * `buildMcpTicketBackend` reads a checkout's autobuild.toml, which cannot
7
+ * run in origin mode. This adapter hands the dispatcher's
8
+ * already-constructed source straight through (it was built by
9
+ * `createTicketSource` from the same effective `[tickets]` config the
10
+ * `OperatorTicketContext` would derive, for the same repository) and derives
11
+ * the workflow-state list from the effective config's lifecycle names.
12
+ */
13
+ import type { Config } from '../config/schema'
14
+ import type { OperatorTicketBackend, OperatorTicketContext } from './tickets'
15
+ import type { TicketSource } from '../ports/types'
16
+
17
+ /** The dispatcher's resolved triage state (the same default
18
+ * `processes/dispatcher.ts` applies); inlined rather than imported so this
19
+ * operator-seam module does not pull the dispatcher's module graph. */
20
+ function defaultTriageState(config: Config): string {
21
+ return (
22
+ config.tickets.triageState ??
23
+ (config.tickets.source === 'linear' || config.tickets.source === 'hosted'
24
+ ? 'Backlog'
25
+ : 'Triage')
26
+ )
27
+ }
28
+
29
+ export function ticketBackendFromSource(options: {
30
+ source: TicketSource
31
+ config: Config
32
+ }): OperatorTicketBackend {
33
+ return {
34
+ sourceFor: (_context: OperatorTicketContext) => options.source,
35
+ statesFor: (_context: OperatorTicketContext, _source: TicketSource): string[] => {
36
+ const tickets = options.config.tickets
37
+ const states = [
38
+ tickets.triageState ?? defaultTriageState(options.config),
39
+ tickets.readyState,
40
+ tickets.createState,
41
+ tickets.claimedState,
42
+ tickets.proposalState ?? defaultTriageState(options.config),
43
+ ].filter((state): state is string => typeof state === 'string' && state.trim().length > 0)
44
+ return [...new Set(states)]
45
+ },
46
+ }
47
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The turn approval list (AUT-342): which registry tool calls suspend a turn
3
+ * until the operator answers. Configured as `[orchestrator].approvals` — a
4
+ * list of registry tool names, optionally qualified by the accepted value of
5
+ * the tool's discriminator field (`tool:qualifier`).
6
+ *
7
+ * Semantics:
8
+ * - A bare entry (`builds.control`) suspends every call to that tool.
9
+ * - A qualified entry (`tickets.move:ready`) suspends only calls whose
10
+ * discriminator equals the qualifier; other values of the same tool run
11
+ * without pause.
12
+ * - An entry naming an absent or unknown tool is inert by design, never an
13
+ * error — the default list is stable while the registry grows.
14
+ * - A qualified entry for a tool without a declared discriminator never
15
+ * matches (there is nothing to compare).
16
+ *
17
+ * Nothing here encodes this repository's specifics.
18
+ */
19
+ import type { ToolApprovalStatus } from 'ai'
20
+
21
+ /** A per-tool approval configuration value: a static status or a function
22
+ * deciding from the call's input. (The SDK's per-tool map accepts both.) */
23
+ export type ApprovalDecision = ToolApprovalStatus | ((input: unknown) => ToolApprovalStatus)
24
+
25
+ /** The registry tools' discriminator fields, for qualified entries. A tool
26
+ * absent from this map has no discriminator; only bare entries match it. */
27
+ const DISCRIMINATOR_FIELDS: Record<string, string> = {
28
+ 'builds.control': 'action',
29
+ 'builds.answer': 'resolution',
30
+ 'tickets.move': 'state',
31
+ }
32
+
33
+ export interface ParsedApprovalEntry {
34
+ tool: string
35
+ qualifier?: string
36
+ }
37
+
38
+ /** Parse one configured entry into its tool and optional qualifier. The
39
+ * config schema already enforces the entry shape syntactically. */
40
+ export function parseApprovalEntry(entry: string): ParsedApprovalEntry {
41
+ const separator = entry.indexOf(':')
42
+ if (separator === -1) return { tool: entry }
43
+ return { tool: entry.slice(0, separator), qualifier: entry.slice(separator + 1) }
44
+ }
45
+
46
+ /** Parse the whole configured list. Duplicate entries collapse harmlessly:
47
+ * the resulting matcher is a predicate, not a table. */
48
+ export function parseApprovalEntries(entries: readonly string[]): ParsedApprovalEntry[] {
49
+ return entries.map(parseApprovalEntry)
50
+ }
51
+
52
+ /**
53
+ * The AI SDK `toolApproval` per-tool configuration for the parsed list: a
54
+ * bare entry maps the whole tool to `user-approval`; a qualified entry maps
55
+ * to a function that returns `user-approval` when the discriminator equals
56
+ * one of the tool's configured qualifiers, else `not-applicable`. Tools with
57
+ * no matching entry get no key at all — the SDK treats an absent key as
58
+ * not-applicable.
59
+ */
60
+ export function approvalConfiguration(
61
+ entries: readonly ParsedApprovalEntry[],
62
+ ): Record<string, ApprovalDecision> {
63
+ const bare = new Set<string>()
64
+ const qualified = new Map<string, Set<string>>()
65
+ for (const entry of entries) {
66
+ if (entry.qualifier === undefined) {
67
+ bare.add(entry.tool)
68
+ continue
69
+ }
70
+ if (bare.has(entry.tool)) continue
71
+ const set = qualified.get(entry.tool) ?? new Set<string>()
72
+ set.add(entry.qualifier)
73
+ qualified.set(entry.tool, set)
74
+ }
75
+ const configuration: Record<string, ApprovalDecision> = {}
76
+ for (const tool of bare) configuration[tool] = 'user-approval'
77
+ for (const [tool, qualifiers] of qualified) {
78
+ if (bare.has(tool)) continue
79
+ const field = DISCRIMINATOR_FIELDS[tool]
80
+ configuration[tool] = (input: unknown) => {
81
+ // A qualified entry for a tool without a declared discriminator never
82
+ // matches — there is nothing to compare.
83
+ if (field === undefined) return 'not-applicable'
84
+ const discriminator =
85
+ typeof input === 'object' && input !== null
86
+ ? (input as Record<string, unknown>)[field]
87
+ : undefined
88
+ return typeof discriminator === 'string' && qualifiers.has(discriminator)
89
+ ? 'user-approval'
90
+ : 'not-applicable'
91
+ }
92
+ }
93
+ return configuration
94
+ }
95
+
96
+ /** Whether a specific call would be suspended — the mirror of
97
+ * `approvalConfiguration` for tests and for the runner's own diagnostics. */
98
+ export function callNeedsApproval(
99
+ entries: readonly ParsedApprovalEntry[],
100
+ tool: string,
101
+ input: unknown,
102
+ ): boolean {
103
+ for (const entry of entries) {
104
+ if (entry.tool !== tool) continue
105
+ if (entry.qualifier === undefined) return true
106
+ const field = DISCRIMINATOR_FIELDS[tool]
107
+ if (field === undefined) continue
108
+ const discriminator =
109
+ typeof input === 'object' && input !== null
110
+ ? (input as Record<string, unknown>)[field]
111
+ : undefined
112
+ if (discriminator === entry.qualifier) return true
113
+ }
114
+ return false
115
+ }