@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
@@ -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): {
@@ -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.