@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.
- package/README.md +1 -1
- package/SPEC.md +313 -26
- package/bin/ab-build-runner.ts +8 -2
- package/docs/architecture.md +57 -14
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +151 -25
- package/docs/hosted-dispatcher.md +69 -13
- package/docs/operator-api.md +2 -2
- package/docs/remote-store-protocol.md +169 -16
- package/docs/remote-ticket-protocol.md +1 -1
- package/docs/setup.md +12 -9
- package/docs/website.md +10 -0
- package/package.json +43 -23
- package/packages/core/src/cli/artifact.ts +130 -6
- package/packages/core/src/cli/binary.ts +9 -1
- package/packages/core/src/cli/build-control.ts +110 -13
- package/packages/core/src/cli/context.ts +46 -0
- package/packages/core/src/cli/dashboard/model.ts +57 -7
- package/packages/core/src/cli/dashboard/poll.ts +69 -10
- package/packages/core/src/cli/dashboard/render.ts +34 -5
- package/packages/core/src/cli/dispatch-frontend.ts +54 -5
- package/packages/core/src/cli/dispatch.ts +320 -46
- package/packages/core/src/cli/harvest.ts +122 -8
- package/packages/core/src/cli/help.ts +47 -8
- package/packages/core/src/cli/init-readiness-remote.ts +110 -0
- package/packages/core/src/cli/init-readiness-shared.ts +72 -0
- package/packages/core/src/cli/init-validation.ts +248 -222
- package/packages/core/src/cli/main.ts +70 -11
- package/packages/core/src/cli/mcp.ts +23 -1
- package/packages/core/src/cli/plugin.ts +7 -4
- package/packages/core/src/cli/remote-poll.ts +277 -0
- package/packages/core/src/cli/repo-state.ts +22 -0
- package/packages/core/src/cli/repository-status.ts +39 -1
- package/packages/core/src/cli/status.ts +149 -27
- package/packages/core/src/cli/terminals.ts +2 -1
- package/packages/core/src/cli/ticket-assets.ts +247 -0
- package/packages/core/src/cli/ticket.ts +307 -7
- package/packages/core/src/cli/wait.ts +147 -39
- package/packages/core/src/cli/watch.ts +128 -166
- package/packages/core/src/config/live.ts +22 -0
- package/packages/core/src/config/pipeline-source.ts +376 -0
- package/packages/core/src/config/schema.ts +230 -19
- package/packages/core/src/events/catalog.ts +1 -0
- package/packages/core/src/events/globs.ts +47 -0
- package/packages/core/src/events/payloads.ts +52 -2
- package/packages/core/src/events/repository.ts +52 -1
- package/packages/core/src/events/sessions.ts +69 -10
- package/packages/core/src/events/workspace-remote.ts +12 -0
- package/packages/core/src/integration/harness.ts +2 -1
- package/packages/core/src/kernel/auto-merge-default.ts +123 -0
- package/packages/core/src/kernel/phases.ts +16 -2
- package/packages/core/src/kernel/reducer.ts +40 -1
- package/packages/core/src/operator/annotations.ts +6 -0
- package/packages/core/src/operator/control.ts +19 -5
- package/packages/core/src/operator/index.ts +71 -6
- package/packages/core/src/operator/query.ts +208 -19
- package/packages/core/src/operator/registry.ts +55 -10
- package/packages/core/src/operator/requests.ts +9 -6
- package/packages/core/src/operator/sandbox-host.ts +140 -0
- package/packages/core/src/operator/sandbox.ts +247 -14
- package/packages/core/src/operator/ticket-source-backend.ts +47 -0
- package/packages/core/src/orchestrator/approvals.ts +115 -0
- package/packages/core/src/orchestrator/conversation.ts +167 -0
- package/packages/core/src/orchestrator/model.ts +86 -0
- package/packages/core/src/orchestrator/prompt.ts +61 -0
- package/packages/core/src/orchestrator/tools.ts +117 -0
- package/packages/core/src/orchestrator/turn-runner.ts +457 -0
- package/packages/core/src/plugin-sdk/index.ts +51 -1
- package/packages/core/src/plugins/load.ts +135 -7
- package/packages/core/src/plugins/manifest.ts +183 -10
- package/packages/core/src/plugins/registry.ts +73 -4
- package/packages/core/src/ports/forge/create.ts +1 -1
- package/packages/core/src/ports/forge/fake.ts +31 -0
- package/packages/core/src/ports/forge/github-transport.ts +13 -2
- package/packages/core/src/ports/forge/github.ts +13 -2
- package/packages/core/src/ports/forge/local-git.ts +31 -1
- package/packages/core/src/ports/runner/claude.ts +2 -2
- package/packages/core/src/ports/runner/codex.ts +6 -2
- package/packages/core/src/ports/runner/fake.ts +7 -1
- package/packages/core/src/ports/runner/pi.ts +8 -6
- package/packages/core/src/ports/runner/provider-error.ts +13 -3
- package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
- package/packages/core/src/ports/types.ts +17 -2
- package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
- package/packages/core/src/ports/workspace/create.ts +74 -39
- package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
- package/packages/core/src/ports/workspace/fake.ts +169 -4
- package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
- package/packages/core/src/ports/workspace/github-origin.ts +36 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
- package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
- package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
- package/packages/core/src/processes/build-child.ts +17 -2
- package/packages/core/src/processes/build-execution-state.ts +37 -0
- package/packages/core/src/processes/build-runner.ts +54 -3
- package/packages/core/src/processes/dispatcher.ts +247 -41
- package/packages/core/src/processes/harvest-child.ts +5 -0
- package/packages/core/src/processes/harvest-runner.ts +66 -19
- package/packages/core/src/processes/harvest.ts +196 -24
- package/packages/core/src/processes/orchestrator-tick.ts +319 -0
- package/packages/core/src/processes/sandbox-state.ts +10 -0
- package/packages/core/src/store/adapter.ts +32 -0
- package/packages/core/src/store/build-scope.ts +64 -0
- package/packages/core/src/store/contract.ts +868 -0
- package/packages/core/src/store/digest.ts +49 -0
- package/packages/core/src/store/local/schema.ts +29 -2
- package/packages/core/src/store/local/store.ts +338 -4
- package/packages/core/src/store/memory.ts +179 -0
- package/packages/core/src/store/new-build.ts +17 -0
- package/packages/core/src/store/phase-session.ts +71 -0
- package/packages/core/src/store/remote/client.ts +260 -1
- package/packages/core/src/store/remote/index.ts +12 -2
- package/packages/core/src/store/remote/protocol.ts +100 -12
- package/packages/core/src/store/remote/version.ts +6 -2
- package/packages/core/src/store/repo-state-events.ts +140 -0
- package/packages/core/src/store/retention.ts +7 -0
- package/packages/core/src/store/session-handle.ts +64 -0
- package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +28 -9
- package/packages/core/src/store/streams/session-writer.ts +42 -2
- package/packages/core/src/store/streams/wait.ts +4 -5
- package/packages/core/src/store/ticket-assets.ts +425 -0
- package/packages/core/src/store/types.ts +122 -1
- package/packages/core/src/testing/index.ts +59 -0
- package/skills/code-review/SKILL.md +5 -1
- package/skills/guide/SKILL.md +126 -33
- package/skills/guide/references/operator-api.md +2 -2
- package/skills/guide/references/plugin-authoring.md +17 -0
- package/skills/guide/references/remote-store-protocol.md +169 -16
- package/skills/guide/references/remote-ticket-protocol.md +1 -1
- package/skills/guide/references/setup.md +12 -9
- package/skills/implement/SKILL.md +3 -0
- package/skills/operate/SKILL.md +126 -0
- package/skills/plan/SKILL.md +4 -0
- package/skills/plan-review/SKILL.md +5 -1
- package/skills/spec/SKILL.md +46 -3
- package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
- package/packages/core/src/cli/store-opening.contract.ts +0 -289
- package/packages/core/src/cli/testkit.ts +0 -194
- package/packages/core/src/markdown.ts +0 -67
- package/packages/core/src/operator/client.ts +0 -311
- package/packages/core/src/operator/server.ts +0 -616
- package/packages/core/src/operator/sessions.ts +0 -223
- package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
- package/packages/core/src/ports/tickets/remote-server.ts +0 -185
- package/packages/core/src/store/remote/server.ts +0 -923
- 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
|
|
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.
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
987
|
-
|
|
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
|
|
8
|
-
* .test.ts) rather than
|
|
9
|
-
* load-bearing — it must
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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 = (
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|