@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.
- package/README.md +1 -1
- package/SPEC.md +323 -26
- package/bin/ab-build-runner.ts +8 -2
- package/docs/architecture.md +166 -14
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +175 -28
- package/docs/hosted-dispatcher.md +69 -13
- package/docs/operator-api.md +2 -2
- package/docs/remote-store-protocol.md +204 -16
- package/docs/remote-ticket-protocol.md +1 -1
- package/docs/setup.md +12 -9
- package/docs/website.md +14 -0
- package/package.json +45 -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/facts.ts +215 -0
- package/packages/core/src/cli/dashboard/frame-parse.ts +223 -0
- package/packages/core/src/cli/dashboard/model.ts +141 -153
- package/packages/core/src/cli/dashboard/poll.ts +76 -12
- 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-process.ts +1 -13
- package/packages/core/src/cli/dispatch.ts +392 -75
- package/packages/core/src/cli/harvest.ts +122 -8
- package/packages/core/src/cli/help.ts +62 -9
- 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 +266 -222
- package/packages/core/src/cli/installation.ts +50 -8
- 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/self-update.ts +77 -2
- package/packages/core/src/cli/status.ts +150 -28
- 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 +453 -8
- package/packages/core/src/cli/wait.ts +157 -41
- package/packages/core/src/cli/watch.ts +138 -168
- package/packages/core/src/config/live.ts +23 -0
- package/packages/core/src/config/pipeline-source.ts +388 -0
- package/packages/core/src/config/schema.ts +251 -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 +115 -2
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +1369 -0
- package/packages/core/src/kernel/auto-merge-default.ts +157 -0
- package/packages/core/src/kernel/auto-merge.ts +99 -22
- package/packages/core/src/kernel/dispatch-settings.ts +42 -17
- package/packages/core/src/kernel/dispatch-status.ts +23 -0
- package/packages/core/src/kernel/engine.ts +9 -438
- package/packages/core/src/kernel/generators/build-log.ts +259 -0
- package/packages/core/src/kernel/generators/repository-journals.ts +206 -0
- package/packages/core/src/kernel/generators/session-log.ts +72 -0
- package/packages/core/src/kernel/harvest.ts +62 -18
- package/packages/core/src/kernel/incremental-contract.ts +75 -0
- package/packages/core/src/kernel/incremental-fixtures.ts +211 -0
- package/packages/core/src/kernel/incremental.ts +53 -0
- package/packages/core/src/kernel/log-index.ts +620 -0
- package/packages/core/src/kernel/phases.ts +16 -2
- package/packages/core/src/kernel/pr-attachments.ts +164 -46
- package/packages/core/src/kernel/reducer-registry.ts +164 -0
- package/packages/core/src/kernel/reducer.ts +254 -73
- 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 +253 -24
- package/packages/core/src/operator/read-view.ts +98 -0
- 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/fake.ts +5 -1
- package/packages/core/src/ports/tickets/file.ts +169 -48
- package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
- package/packages/core/src/ports/tickets/linear.ts +33 -1
- package/packages/core/src/ports/tickets/operations.ts +1 -1
- package/packages/core/src/ports/tickets/remote.ts +8 -1
- package/packages/core/src/ports/types.ts +49 -3
- 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 +68 -15
- package/packages/core/src/processes/build-runner.ts +238 -93
- package/packages/core/src/processes/dispatcher-selectors.ts +123 -0
- package/packages/core/src/processes/dispatcher.ts +501 -201
- package/packages/core/src/processes/execution-settlement.ts +72 -39
- package/packages/core/src/processes/harvest-child.ts +5 -0
- package/packages/core/src/processes/harvest-execution-state.ts +41 -29
- package/packages/core/src/processes/harvest-runner.ts +66 -19
- package/packages/core/src/processes/harvest.ts +196 -24
- package/packages/core/src/processes/infrastructure-failure-budget.ts +31 -18
- package/packages/core/src/processes/orchestrator-tick.ts +360 -0
- package/packages/core/src/processes/publication-state.ts +164 -52
- package/packages/core/src/processes/repo-view.ts +1436 -0
- package/packages/core/src/processes/sandbox-state.ts +36 -2
- package/packages/core/src/processes/tick-state.ts +33 -0
- package/packages/core/src/store/adapter.ts +41 -0
- package/packages/core/src/store/build-scope.ts +90 -0
- package/packages/core/src/store/contract.ts +1205 -0
- package/packages/core/src/store/digest.ts +102 -0
- package/packages/core/src/store/local/schema.ts +46 -2
- package/packages/core/src/store/local/store.ts +445 -4
- package/packages/core/src/store/memory.ts +246 -0
- package/packages/core/src/store/new-build.ts +17 -0
- package/packages/core/src/store/phase-session.ts +94 -0
- package/packages/core/src/store/remote/client.ts +310 -2
- package/packages/core/src/store/remote/index.ts +14 -2
- package/packages/core/src/store/remote/protocol.ts +115 -12
- package/packages/core/src/store/remote/version.ts +6 -2
- package/packages/core/src/store/repo-state-events.ts +205 -0
- package/packages/core/src/store/retention.ts +7 -0
- package/packages/core/src/store/session-handle.ts +94 -0
- package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +79 -19
- package/packages/core/src/store/snapshots.ts +88 -0
- 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 +458 -0
- package/packages/core/src/store/types.ts +160 -1
- package/packages/core/src/testing/index.ts +59 -0
- package/skills/code-review/SKILL.md +5 -1
- package/skills/guide/SKILL.md +152 -40
- 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 +204 -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/skills/tickets/SKILL.md +57 -5
- 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
|
@@ -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.
|
|
@@ -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
|
+
}
|