@defrex/autobuild 0.8.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/LICENSE +201 -0
- package/README.md +192 -0
- package/SPEC.md +2269 -0
- package/bin/ab-build-runner.ts +47 -0
- package/bin/ab-dev.ts +27 -0
- package/bin/ab-dispatch-kernel.ts +92 -0
- package/bin/ab-harvest-runner.ts +29 -0
- package/bin/ab-init-probe.ts +19 -0
- package/bin/ab-process-group-reaper.ts +32 -0
- package/bin/ab.ts +6 -0
- package/bin/agent/ab +3 -0
- package/docs/answering-blocked-builds.md +89 -0
- package/docs/architecture.md +726 -0
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +1623 -0
- package/docs/hosted-dispatcher-cutover-validation.md +178 -0
- package/docs/hosted-dispatcher.md +204 -0
- package/docs/mcp.md +119 -0
- package/docs/operator-api.md +186 -0
- package/docs/remote-store-protocol.md +972 -0
- package/docs/remote-ticket-protocol.md +79 -0
- package/docs/setup.md +289 -0
- package/docs/spec-standard.md +63 -0
- package/docs/ticket-dependencies.md +59 -0
- package/docs/vercel-sandbox-rollout-probe.md +7 -0
- package/package.json +135 -0
- package/packages/core/src/cli/args.ts +74 -0
- package/packages/core/src/cli/artifact.ts +182 -0
- package/packages/core/src/cli/binary.ts +198 -0
- package/packages/core/src/cli/build-control.ts +789 -0
- package/packages/core/src/cli/build-progress.ts +55 -0
- package/packages/core/src/cli/bulk-control.ts +385 -0
- package/packages/core/src/cli/context.ts +470 -0
- package/packages/core/src/cli/dashboard/actions.ts +59 -0
- package/packages/core/src/cli/dashboard/cells.ts +92 -0
- package/packages/core/src/cli/dashboard/composer.ts +230 -0
- package/packages/core/src/cli/dashboard/detail.ts +100 -0
- package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
- package/packages/core/src/cli/dashboard/live.ts +152 -0
- package/packages/core/src/cli/dashboard/model.ts +1173 -0
- package/packages/core/src/cli/dashboard/poll.ts +196 -0
- package/packages/core/src/cli/dashboard/render.ts +1568 -0
- package/packages/core/src/cli/dashboard/selection.ts +66 -0
- package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
- package/packages/core/src/cli/dashboard/session-view.ts +506 -0
- package/packages/core/src/cli/dashboard/transcript.ts +108 -0
- package/packages/core/src/cli/dev.ts +46 -0
- package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
- package/packages/core/src/cli/dispatch-process.ts +259 -0
- package/packages/core/src/cli/dispatch.ts +3692 -0
- package/packages/core/src/cli/dotenv.ts +48 -0
- package/packages/core/src/cli/env.ts +206 -0
- package/packages/core/src/cli/harvest.ts +698 -0
- package/packages/core/src/cli/help.ts +713 -0
- package/packages/core/src/cli/init-agent.ts +106 -0
- package/packages/core/src/cli/init-validation.ts +583 -0
- package/packages/core/src/cli/init.ts +756 -0
- package/packages/core/src/cli/installation.ts +333 -0
- package/packages/core/src/cli/keyboard.ts +261 -0
- package/packages/core/src/cli/main.ts +1399 -0
- package/packages/core/src/cli/mcp.ts +279 -0
- package/packages/core/src/cli/models.ts +65 -0
- package/packages/core/src/cli/observe.ts +55 -0
- package/packages/core/src/cli/plugin.ts +227 -0
- package/packages/core/src/cli/pr-attachments.ts +138 -0
- package/packages/core/src/cli/pr-summary.ts +124 -0
- package/packages/core/src/cli/publication-settlement.ts +188 -0
- package/packages/core/src/cli/repo-state.ts +213 -0
- package/packages/core/src/cli/repository-status.ts +80 -0
- package/packages/core/src/cli/self-update.ts +443 -0
- package/packages/core/src/cli/status.ts +895 -0
- package/packages/core/src/cli/store-opening.contract.ts +289 -0
- package/packages/core/src/cli/store-opening.ts +167 -0
- package/packages/core/src/cli/store-ref.ts +33 -0
- package/packages/core/src/cli/terminal-restore.ts +174 -0
- package/packages/core/src/cli/terminal.ts +361 -0
- package/packages/core/src/cli/terminals.ts +995 -0
- package/packages/core/src/cli/testkit.ts +194 -0
- package/packages/core/src/cli/ticket.ts +484 -0
- package/packages/core/src/cli/upgrade-agent.ts +180 -0
- package/packages/core/src/cli/upgrade-commit.ts +468 -0
- package/packages/core/src/cli/upgrade-notice.ts +91 -0
- package/packages/core/src/cli/upgrade-progress.ts +110 -0
- package/packages/core/src/cli/upgrade.ts +888 -0
- package/packages/core/src/cli/wait.ts +587 -0
- package/packages/core/src/cli/watch.ts +932 -0
- package/packages/core/src/config/live.ts +255 -0
- package/packages/core/src/config/load.ts +98 -0
- package/packages/core/src/config/roles.ts +438 -0
- package/packages/core/src/config/schema.ts +774 -0
- package/packages/core/src/distribution.ts +24 -0
- package/packages/core/src/events/catalog.ts +201 -0
- package/packages/core/src/events/envelope.ts +59 -0
- package/packages/core/src/events/payloads.ts +618 -0
- package/packages/core/src/events/repository.ts +559 -0
- package/packages/core/src/events/sessions.ts +180 -0
- package/packages/core/src/harvest/schema.ts +147 -0
- package/packages/core/src/ids.ts +45 -0
- package/packages/core/src/integration/harness.ts +681 -0
- package/packages/core/src/kernel/auto-merge.ts +259 -0
- package/packages/core/src/kernel/converge.ts +117 -0
- package/packages/core/src/kernel/dispatch-settings.ts +60 -0
- package/packages/core/src/kernel/dispatch-status.ts +141 -0
- package/packages/core/src/kernel/engine.ts +1047 -0
- package/packages/core/src/kernel/harvest.ts +823 -0
- package/packages/core/src/kernel/origin.ts +69 -0
- package/packages/core/src/kernel/phases.ts +196 -0
- package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
- package/packages/core/src/kernel/pr-attachments.ts +82 -0
- package/packages/core/src/kernel/reducer.ts +752 -0
- package/packages/core/src/kernel/stall.ts +155 -0
- package/packages/core/src/kernel/verify-applicability.ts +52 -0
- package/packages/core/src/markdown.ts +67 -0
- package/packages/core/src/ontology.ts +288 -0
- package/packages/core/src/open-map.ts +203 -0
- package/packages/core/src/operator/annotations.ts +187 -0
- package/packages/core/src/operator/client.ts +311 -0
- package/packages/core/src/operator/control.ts +117 -0
- package/packages/core/src/operator/index.ts +62 -0
- package/packages/core/src/operator/presentation.ts +22 -0
- package/packages/core/src/operator/protocol.ts +98 -0
- package/packages/core/src/operator/query.ts +201 -0
- package/packages/core/src/operator/registry.ts +1043 -0
- package/packages/core/src/operator/requests.ts +138 -0
- package/packages/core/src/operator/sandbox.ts +458 -0
- package/packages/core/src/operator/server.ts +616 -0
- package/packages/core/src/operator/sessions.ts +223 -0
- package/packages/core/src/operator/tickets.ts +177 -0
- package/packages/core/src/plugin-sdk/index.ts +174 -0
- package/packages/core/src/plugins/contract-entry.ts +74 -0
- package/packages/core/src/plugins/contract-env.ts +3 -0
- package/packages/core/src/plugins/load.ts +259 -0
- package/packages/core/src/plugins/manifest.ts +236 -0
- package/packages/core/src/plugins/registry.ts +274 -0
- package/packages/core/src/plugins/runtimes.ts +75 -0
- package/packages/core/src/ports/forge/contract.ts +258 -0
- package/packages/core/src/ports/forge/create.ts +74 -0
- package/packages/core/src/ports/forge/fake.ts +488 -0
- package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
- package/packages/core/src/ports/forge/github-transport.ts +352 -0
- package/packages/core/src/ports/forge/github.ts +1162 -0
- package/packages/core/src/ports/forge/local-git.ts +568 -0
- package/packages/core/src/ports/runner/claude.ts +873 -0
- package/packages/core/src/ports/runner/cli-stream.ts +90 -0
- package/packages/core/src/ports/runner/codex.ts +873 -0
- package/packages/core/src/ports/runner/contract.ts +492 -0
- package/packages/core/src/ports/runner/fake.ts +213 -0
- package/packages/core/src/ports/runner/json-record.ts +2 -0
- package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
- package/packages/core/src/ports/runner/one-shot.ts +29 -0
- package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
- package/packages/core/src/ports/runner/pi-bridge.js +6 -0
- package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
- package/packages/core/src/ports/runner/pi.ts +617 -0
- package/packages/core/src/ports/runner/production.ts +74 -0
- package/packages/core/src/ports/runner/provider-error.ts +121 -0
- package/packages/core/src/ports/runner/routing.ts +234 -0
- package/packages/core/src/ports/runner/runtime.ts +214 -0
- package/packages/core/src/ports/runner/session-env.ts +34 -0
- package/packages/core/src/ports/runner/stream-parts.ts +103 -0
- package/packages/core/src/ports/tickets/contract.ts +570 -0
- package/packages/core/src/ports/tickets/create.ts +135 -0
- package/packages/core/src/ports/tickets/fake.ts +215 -0
- package/packages/core/src/ports/tickets/file.ts +559 -0
- package/packages/core/src/ports/tickets/linear.ts +811 -0
- package/packages/core/src/ports/tickets/operations.ts +161 -0
- package/packages/core/src/ports/tickets/remote-index.ts +13 -0
- package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
- package/packages/core/src/ports/tickets/remote-server.ts +185 -0
- package/packages/core/src/ports/tickets/remote.ts +130 -0
- package/packages/core/src/ports/tickets/update.ts +51 -0
- package/packages/core/src/ports/types.ts +528 -0
- package/packages/core/src/ports/workspace/build-execution.ts +123 -0
- package/packages/core/src/ports/workspace/contract.ts +136 -0
- package/packages/core/src/ports/workspace/create.ts +132 -0
- package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
- package/packages/core/src/ports/workspace/fake.ts +432 -0
- package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
- package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
- package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
- package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
- package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
- package/packages/core/src/processes/build-child-exit.ts +78 -0
- package/packages/core/src/processes/build-child.ts +119 -0
- package/packages/core/src/processes/build-execution-state.ts +95 -0
- package/packages/core/src/processes/build-parent-watch.ts +35 -0
- package/packages/core/src/processes/build-runner.ts +2614 -0
- package/packages/core/src/processes/dispatcher.ts +2825 -0
- package/packages/core/src/processes/execution-settlement.ts +171 -0
- package/packages/core/src/processes/harvest-child.ts +115 -0
- package/packages/core/src/processes/harvest-execution-state.ts +121 -0
- package/packages/core/src/processes/harvest-runner.ts +1656 -0
- package/packages/core/src/processes/harvest.ts +595 -0
- package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
- package/packages/core/src/processes/phase-failure-budget.ts +52 -0
- package/packages/core/src/processes/process-group-reaper.ts +35 -0
- package/packages/core/src/processes/process-group.ts +65 -0
- package/packages/core/src/processes/publication-loss.ts +62 -0
- package/packages/core/src/processes/publication-state.ts +93 -0
- package/packages/core/src/processes/sandbox-state.ts +101 -0
- package/packages/core/src/registry.ts +24 -0
- package/packages/core/src/sessions/reducer.ts +185 -0
- package/packages/core/src/skills.ts +7 -0
- package/packages/core/src/spec-standard.ts +47 -0
- package/packages/core/src/store/adapter.ts +80 -0
- package/packages/core/src/store/build-scope.ts +272 -0
- package/packages/core/src/store/contract.ts +2581 -0
- package/packages/core/src/store/local/blobs.ts +38 -0
- package/packages/core/src/store/local/schema.ts +180 -0
- package/packages/core/src/store/local/store.ts +1713 -0
- package/packages/core/src/store/memory.ts +1138 -0
- package/packages/core/src/store/phase-session.ts +425 -0
- package/packages/core/src/store/remote/client.ts +780 -0
- package/packages/core/src/store/remote/index.ts +12 -0
- package/packages/core/src/store/remote/protocol.ts +348 -0
- package/packages/core/src/store/remote/server.ts +923 -0
- package/packages/core/src/store/remote/token.ts +137 -0
- package/packages/core/src/store/remote/version.ts +10 -0
- package/packages/core/src/store/retention.ts +91 -0
- package/packages/core/src/store/session-handle.ts +265 -0
- package/packages/core/src/store/streams/assemble.ts +79 -0
- package/packages/core/src/store/streams/lock.ts +42 -0
- package/packages/core/src/store/streams/session-writer.ts +306 -0
- package/packages/core/src/store/streams/types.ts +171 -0
- package/packages/core/src/store/streams/wait.ts +127 -0
- package/packages/core/src/store/subscribe.ts +105 -0
- package/packages/core/src/store/types.ts +466 -0
- package/packages/core/src/testing/fixed.ts +44 -0
- package/packages/core/src/testing/store-failures.ts +32 -0
- package/packages/core/src/zod-issues.ts +91 -0
- package/skills/code-review/SKILL.md +100 -0
- package/skills/finalize/SKILL.md +44 -0
- package/skills/guide/SKILL.md +1781 -0
- package/skills/guide/references/operator-api.md +164 -0
- package/skills/guide/references/plugin-authoring.md +354 -0
- package/skills/guide/references/remote-store-protocol.md +972 -0
- package/skills/guide/references/remote-ticket-protocol.md +79 -0
- package/skills/guide/references/setup.md +289 -0
- package/skills/guide/references/spec-standard.md +63 -0
- package/skills/guide/references/ticket-dependencies.md +59 -0
- package/skills/harvest/SKILL.md +66 -0
- package/skills/harvest-review/SKILL.md +63 -0
- package/skills/implement/SKILL.md +82 -0
- package/skills/plan/SKILL.md +123 -0
- package/skills/plan-review/SKILL.md +121 -0
- package/skills/reconcile/SKILL.md +65 -0
- package/skills/spec/SKILL.md +132 -0
- package/skills/tickets/SKILL.md +139 -0
- package/templates/autobuild.toml +36 -0
|
@@ -0,0 +1,1043 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent tool registry — one typed table that is the single source of
|
|
3
|
+
* truth for the agent-facing operator surface (AUT-338).
|
|
4
|
+
*
|
|
5
|
+
* Every entry names a tool, describes it for a model, declares its Zod input
|
|
6
|
+
* schema (every field documented), carries MCP annotations and an approval
|
|
7
|
+
* class, and binds a handler that calls the same control/query/ticket service
|
|
8
|
+
* the operator API route calls — never a reimplementation. Bindings (the
|
|
9
|
+
* in-process registry itself, `ab mcp` over stdio, later transports) are
|
|
10
|
+
* generated from this table, so adding a tool here makes it appear in every
|
|
11
|
+
* face. The table never widens beyond the operator services.
|
|
12
|
+
*
|
|
13
|
+
* Wire-encoding invariant: every handler returns a JSON-serializable plain
|
|
14
|
+
* value — never a `Uint8Array` or other non-JSON type. Binary content is
|
|
15
|
+
* carried as base64 strings at the entry level (`builds.artifact`'s
|
|
16
|
+
* `contentBase64` is the base64 of the exact artifact bytes), so a binding's
|
|
17
|
+
* `JSON.stringify` can never silently corrupt bytes. The contract suite
|
|
18
|
+
* (registry.contract.test.ts) asserts the round-trip for every tool.
|
|
19
|
+
*
|
|
20
|
+
* Risk classes live in `operator/annotations.ts` (checked in, enforced by the
|
|
21
|
+
* contract suite). The registry itself enforces only what every binding needs
|
|
22
|
+
* identically: input validation before the handler, attributed identity for
|
|
23
|
+
* every mutator, and — for constrained bindings — repository targeting.
|
|
24
|
+
*/
|
|
25
|
+
import { z } from 'zod'
|
|
26
|
+
import { controlBuild, BuildControlError, type BuildControlAction } from '../cli/build-control'
|
|
27
|
+
import type { Via } from '../events/envelope'
|
|
28
|
+
import { bulkControlRepository, BulkWalkError } from '../cli/bulk-control'
|
|
29
|
+
import { systemClock, type BuildStore, type Clock } from '../store/types'
|
|
30
|
+
import { TicketOperationError } from '../ports/tickets/operations'
|
|
31
|
+
import { OPERATOR_NOTES_REPO_KIND } from '../store/retention'
|
|
32
|
+
import {
|
|
33
|
+
OperatorControlError,
|
|
34
|
+
setRepositorySetting,
|
|
35
|
+
toggleHarvestGate,
|
|
36
|
+
toggleRepositorySetting,
|
|
37
|
+
controlHarvestRun,
|
|
38
|
+
} from './control'
|
|
39
|
+
import {
|
|
40
|
+
getHarvestStatus,
|
|
41
|
+
getOperatorBuild,
|
|
42
|
+
getRepositoryStatus,
|
|
43
|
+
listOperatorBuilds,
|
|
44
|
+
OperatorQueryError,
|
|
45
|
+
} from './query'
|
|
46
|
+
import {
|
|
47
|
+
answerRequestSchema,
|
|
48
|
+
harvestControlRequestSchema,
|
|
49
|
+
ticketBlockerRequestSchema,
|
|
50
|
+
ticketCreateRequestSchema,
|
|
51
|
+
ticketMoveRequestSchema,
|
|
52
|
+
ticketUpdateRequestSchema,
|
|
53
|
+
type OperatorAnswerRequest,
|
|
54
|
+
} from './protocol'
|
|
55
|
+
import { OPERATOR_TOOL_ANNOTATIONS, type OperatorToolName } from './annotations'
|
|
56
|
+
import { answerAction, controlPrechecks, requireRouteBuild, RouteRefusalError } from './requests'
|
|
57
|
+
import { SandboxOperationError } from '../ports/workspace/operator-sandbox'
|
|
58
|
+
import type { OperatorSandboxService } from './sandbox'
|
|
59
|
+
import {
|
|
60
|
+
getOperatorTicket,
|
|
61
|
+
listOperatorTickets,
|
|
62
|
+
mutateOperatorTicket,
|
|
63
|
+
type OperatorTicketBackend,
|
|
64
|
+
} from './tickets'
|
|
65
|
+
|
|
66
|
+
/** The retention-managed repository artifact kind the notes tools serve. */
|
|
67
|
+
export const OPERATOR_NOTES_ARTIFACT = OPERATOR_NOTES_REPO_KIND
|
|
68
|
+
|
|
69
|
+
/** One tool invocation's full context: who is calling, and what to operate on. */
|
|
70
|
+
export interface ToolContext {
|
|
71
|
+
/** The opened store the handler operates through. */
|
|
72
|
+
store: BuildStore
|
|
73
|
+
/** The operator ticket backend; required only by the ticket tools. */
|
|
74
|
+
tickets?: OperatorTicketBackend
|
|
75
|
+
clock: Clock
|
|
76
|
+
/** The caller's attributed identity (the operator user every write names). */
|
|
77
|
+
identity?: string
|
|
78
|
+
/** Optional binding marker recorded with the caller's identity (e.g. which
|
|
79
|
+
* MCP client executed the call). Only human actors may carry `via`.
|
|
80
|
+
* (SPEC §15.1) */
|
|
81
|
+
via?: Via
|
|
82
|
+
/** The operator-sandbox backend; required only by the sandbox tools. */
|
|
83
|
+
sandbox?: OperatorSandboxService
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The failure body shape the operator API emits, mapped one-to-one so a tool
|
|
87
|
+
* and its route fail identically. HTTP status is a route concern; the kinds
|
|
88
|
+
* are shared vocabulary. */
|
|
89
|
+
export interface ToolFailureBody {
|
|
90
|
+
kind: 'validation' | 'auth' | 'not-found' | 'conflict' | 'refusal' | 'internal'
|
|
91
|
+
error: string
|
|
92
|
+
code?: string
|
|
93
|
+
progress?: unknown
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type RegistryErrorReason =
|
|
97
|
+
| 'validation'
|
|
98
|
+
| 'no-identity'
|
|
99
|
+
| 'unknown-tool'
|
|
100
|
+
| 'repo-mismatch'
|
|
101
|
+
| 'domain'
|
|
102
|
+
|
|
103
|
+
/** Every registry-level refusal. `reason` is the registry's own vocabulary;
|
|
104
|
+
* `body` is the operator-API-shaped failure a binding should surface. */
|
|
105
|
+
export class RegistryError extends Error {
|
|
106
|
+
override readonly name = 'RegistryError'
|
|
107
|
+
|
|
108
|
+
constructor(
|
|
109
|
+
readonly reason: RegistryErrorReason,
|
|
110
|
+
message: string,
|
|
111
|
+
readonly body: ToolFailureBody,
|
|
112
|
+
options?: { cause?: unknown },
|
|
113
|
+
) {
|
|
114
|
+
super(message, options)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type ToolApproval = 'never' | 'default'
|
|
119
|
+
|
|
120
|
+
/** Handlers receive the schema-validated input (typed `never` so the only
|
|
121
|
+
* invocation path is `registry.call`, which validates first) and the context. */
|
|
122
|
+
export type ToolHandler = (input: never, ctx: ToolContext) => Promise<unknown>
|
|
123
|
+
|
|
124
|
+
export interface ToolEntry {
|
|
125
|
+
name: OperatorToolName
|
|
126
|
+
/** Written for a model: what the tool does and what it returns. */
|
|
127
|
+
description: string
|
|
128
|
+
/** Zod schema; every field carries its own `.describe()`. */
|
|
129
|
+
inputSchema: z.ZodType
|
|
130
|
+
/** Appended to the description by bindings that can show it. */
|
|
131
|
+
outputDescription: string
|
|
132
|
+
/** MCP annotation hints, from the checked-in annotations table. */
|
|
133
|
+
annotations: {
|
|
134
|
+
readOnlyHint: boolean
|
|
135
|
+
destructiveHint: boolean
|
|
136
|
+
idempotentHint: boolean
|
|
137
|
+
}
|
|
138
|
+
approval: ToolApproval
|
|
139
|
+
handler: ToolHandler
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The attribution rule every mutating handler shares: refuse without a
|
|
143
|
+
* caller identity, else return it. `call()` enforces this before the handler
|
|
144
|
+
* runs; handlers call it again so a directly-invoked handler cannot write
|
|
145
|
+
* unattributed. */
|
|
146
|
+
export function attributed(ctx: ToolContext): string {
|
|
147
|
+
const identity = ctx.identity?.trim()
|
|
148
|
+
if (identity === undefined || identity === '') {
|
|
149
|
+
throw new RegistryError(
|
|
150
|
+
'no-identity',
|
|
151
|
+
'this tool mutates durable state and requires an attributed operator identity',
|
|
152
|
+
{
|
|
153
|
+
kind: 'auth',
|
|
154
|
+
error: 'this tool mutates durable state and requires an attributed operator identity',
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
return identity
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The ticket tools need the backend; the route refuses without one and so
|
|
162
|
+
* does the registry (same conflict body as the route). */
|
|
163
|
+
function ticketsOf(ctx: ToolContext): OperatorTicketBackend {
|
|
164
|
+
if (ctx.tickets === undefined) {
|
|
165
|
+
throw new RouteRefusalError({
|
|
166
|
+
kind: 'conflict',
|
|
167
|
+
error: 'ticket operator backend is not configured',
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
return ctx.tickets
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The sandbox tools need the backend; same refusal shape as `ticketsOf`. */
|
|
174
|
+
function sandboxOf(ctx: ToolContext): OperatorSandboxService {
|
|
175
|
+
if (ctx.sandbox === undefined) {
|
|
176
|
+
throw new RouteRefusalError({
|
|
177
|
+
kind: 'conflict',
|
|
178
|
+
error: 'operator sandbox backend is not configured',
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
return ctx.sandbox
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Extend every member of a request-union schema with shared routing fields
|
|
185
|
+
* (repo, slug) while preserving each member's own strict semantics — the
|
|
186
|
+
* `"{repo, slug} & request"` shape the routes express with URL + body. */
|
|
187
|
+
function extendUnion(schema: z.ZodType, extra: z.ZodRawShape): z.ZodType {
|
|
188
|
+
const options = (schema as unknown as { options?: readonly z.ZodObject[] }).options
|
|
189
|
+
if (!Array.isArray(options) || options.length === 0) {
|
|
190
|
+
throw new Error('extendUnion requires a union schema with options')
|
|
191
|
+
}
|
|
192
|
+
return z.union(options.map((option) => option.extend(extra)))
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── Shared input fields ──────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
const repoField = z
|
|
198
|
+
.string()
|
|
199
|
+
.min(1)
|
|
200
|
+
.describe(
|
|
201
|
+
'Repository identity as the store keys it: the checkout’s normalized origin URL (e.g. "https://github.com/acme/widgets").',
|
|
202
|
+
)
|
|
203
|
+
const slugField = z.string().min(1).describe('Build slug identifying one build in the store.')
|
|
204
|
+
const ticketIdField = z
|
|
205
|
+
.string()
|
|
206
|
+
.min(1)
|
|
207
|
+
.describe('Ticket id in the configured ticket source (e.g. "AUT-8").')
|
|
208
|
+
|
|
209
|
+
const buildListInput = z.strictObject({
|
|
210
|
+
repo: repoField,
|
|
211
|
+
scope: z
|
|
212
|
+
.enum(['active', 'queued', 'all'])
|
|
213
|
+
.default('active')
|
|
214
|
+
.describe(
|
|
215
|
+
'Which builds to include: "active" (running, paused, or blocked — the default), "queued", or "all".',
|
|
216
|
+
),
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
const buildDetailInput = z.strictObject({ repo: repoField, slug: slugField })
|
|
220
|
+
|
|
221
|
+
const ticketDetailInput = z.strictObject({ repo: repoField, id: ticketIdField })
|
|
222
|
+
|
|
223
|
+
const buildEventsInput = z.strictObject({
|
|
224
|
+
repo: repoField,
|
|
225
|
+
slug: slugField,
|
|
226
|
+
cursor: z
|
|
227
|
+
.number()
|
|
228
|
+
.int()
|
|
229
|
+
.min(0)
|
|
230
|
+
.describe(
|
|
231
|
+
'Event sequence cursor: return build events with seq strictly greater than this (0 = the whole log).',
|
|
232
|
+
),
|
|
233
|
+
waitSeconds: z
|
|
234
|
+
.number()
|
|
235
|
+
.int()
|
|
236
|
+
.min(0)
|
|
237
|
+
.max(30)
|
|
238
|
+
.default(0)
|
|
239
|
+
.describe(
|
|
240
|
+
'Bounded wait: hold the call for at most this many seconds waiting for at least one new event before returning an empty page (0–30; 0 returns immediately).',
|
|
241
|
+
),
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
const buildArtifactInput = z.strictObject({
|
|
245
|
+
repo: repoField,
|
|
246
|
+
slug: slugField,
|
|
247
|
+
kind: z.string().min(1).describe('Artifact kind to download (e.g. "spec", "plan", "notes").'),
|
|
248
|
+
rev: z
|
|
249
|
+
.number()
|
|
250
|
+
.int()
|
|
251
|
+
.min(0)
|
|
252
|
+
.optional()
|
|
253
|
+
.describe('Artifact revision to fetch; omit for the latest revision of the kind.'),
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
const buildControlInput = z.strictObject({
|
|
257
|
+
repo: repoField,
|
|
258
|
+
slug: slugField,
|
|
259
|
+
action: z
|
|
260
|
+
.enum([
|
|
261
|
+
'pause',
|
|
262
|
+
'cancel-pause',
|
|
263
|
+
'resume',
|
|
264
|
+
'auto-merge-on',
|
|
265
|
+
'auto-merge-off',
|
|
266
|
+
'abort',
|
|
267
|
+
'discard',
|
|
268
|
+
])
|
|
269
|
+
.describe(
|
|
270
|
+
'Control to request: "pause" (or "cancel-pause" to revoke a pending pause), "resume" a paused build, "auto-merge-on"/"auto-merge-off" for native auto-merge — or the destructive "abort" (cancels the build, cleans up its PR and workspace, returns the ticket to triage) and "discard" (dequeue a queued build).',
|
|
271
|
+
),
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
const buildsAnswerInput = extendUnion(answerRequestSchema, { repo: repoField, slug: slugField })
|
|
275
|
+
|
|
276
|
+
const repositoryStatusInput = z.strictObject({ repo: repoField })
|
|
277
|
+
|
|
278
|
+
const repositorySettingsInput = z.strictObject({
|
|
279
|
+
repo: repoField,
|
|
280
|
+
setting: z
|
|
281
|
+
.enum(['intake', 'auto-merge-default'])
|
|
282
|
+
.describe(
|
|
283
|
+
'"intake" gates whether new tickets may be claimed; "auto-merge-default" is the claim-time auto-merge default for newly claimed builds.',
|
|
284
|
+
),
|
|
285
|
+
enabled: z
|
|
286
|
+
.boolean()
|
|
287
|
+
.optional()
|
|
288
|
+
.describe('Set the value explicitly; omit to toggle the current durable value.'),
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
const bulkControlInput = z.strictObject({
|
|
292
|
+
repo: repoField,
|
|
293
|
+
action: z
|
|
294
|
+
.enum(['pause', 'resume'])
|
|
295
|
+
.describe(
|
|
296
|
+
'"pause" holds every queued build, turns intake off, and requests pause on every running build without a pending pause; "resume" reverses all three.',
|
|
297
|
+
),
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
const harvestStatusInput = z.strictObject({ repo: repoField })
|
|
301
|
+
|
|
302
|
+
const harvestControlInput = extendUnion(harvestControlRequestSchema, { repo: repoField })
|
|
303
|
+
|
|
304
|
+
const ticketsListInput = z.strictObject({
|
|
305
|
+
repo: repoField,
|
|
306
|
+
state: z
|
|
307
|
+
.string()
|
|
308
|
+
.min(1)
|
|
309
|
+
.optional()
|
|
310
|
+
.describe(
|
|
311
|
+
'Provider state to list; defaults to the effective triage state from the repository’s effective config.',
|
|
312
|
+
),
|
|
313
|
+
labels: z
|
|
314
|
+
.array(z.string().min(1))
|
|
315
|
+
.optional()
|
|
316
|
+
.describe('Narrow the list to tickets carrying every named label.'),
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
const ticketsCreateInput = ticketCreateRequestSchema.extend({ repo: repoField })
|
|
320
|
+
|
|
321
|
+
const ticketsUpdateInput = ticketUpdateRequestSchema
|
|
322
|
+
.extend({ repo: repoField, id: ticketIdField })
|
|
323
|
+
.refine(
|
|
324
|
+
(value) => value.title !== undefined || value.body !== undefined || value.labels !== undefined,
|
|
325
|
+
'update must name at least one field',
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
const ticketsBlockersInput = ticketBlockerRequestSchema.extend({
|
|
329
|
+
repo: repoField,
|
|
330
|
+
id: ticketIdField,
|
|
331
|
+
blockerIds: z
|
|
332
|
+
.array(z.string().min(1))
|
|
333
|
+
.min(1)
|
|
334
|
+
.describe(
|
|
335
|
+
'Blocker ticket ids to relate (at least one); the first id is the ticket being changed.',
|
|
336
|
+
),
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
const ticketsMoveInput = ticketMoveRequestSchema.extend({
|
|
340
|
+
repo: repoField,
|
|
341
|
+
id: ticketIdField,
|
|
342
|
+
state: z
|
|
343
|
+
.string()
|
|
344
|
+
.min(1)
|
|
345
|
+
.describe(
|
|
346
|
+
'Provider state to move the ticket into. Destructive: moving a ticket into the repository’s configured ready state queues it for dispatch.',
|
|
347
|
+
),
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
const notesReadInput = z.strictObject({ repo: repoField })
|
|
351
|
+
|
|
352
|
+
const notesWriteInput = z.strictObject({
|
|
353
|
+
repo: repoField,
|
|
354
|
+
document: z
|
|
355
|
+
.string()
|
|
356
|
+
.describe(
|
|
357
|
+
'The complete replacement notes document (UTF-8 text); an empty string clears the notes.',
|
|
358
|
+
),
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
const sandboxExecInput = z.strictObject({
|
|
362
|
+
repo: repoField,
|
|
363
|
+
command: z.string().min(1).describe('Shell command to run inside the operator sandbox.'),
|
|
364
|
+
cwd: z
|
|
365
|
+
.string()
|
|
366
|
+
.optional()
|
|
367
|
+
.describe(
|
|
368
|
+
'Working directory for the command, relative to the checkout; defaults to the checkout root. Absolute paths and ".." escapes are refused.',
|
|
369
|
+
),
|
|
370
|
+
timeoutSeconds: z
|
|
371
|
+
.number()
|
|
372
|
+
.int()
|
|
373
|
+
.min(1)
|
|
374
|
+
.max(300)
|
|
375
|
+
.optional()
|
|
376
|
+
.describe(
|
|
377
|
+
'Bounded wait for the command, 1-300 seconds (default 120). On expiry the command is killed and the tool fails with code "sandbox-exec-timeout".',
|
|
378
|
+
),
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
const sandboxStartInput = z.strictObject({
|
|
382
|
+
repo: repoField,
|
|
383
|
+
command: z
|
|
384
|
+
.string()
|
|
385
|
+
.min(1)
|
|
386
|
+
.describe('Shell command to launch detached inside the operator sandbox.'),
|
|
387
|
+
cwd: z
|
|
388
|
+
.string()
|
|
389
|
+
.optional()
|
|
390
|
+
.describe(
|
|
391
|
+
'Working directory for the command, relative to the checkout; defaults to the checkout root. Absolute paths and ".." escapes are refused.',
|
|
392
|
+
),
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
const sandboxWaitInput = z.strictObject({
|
|
396
|
+
repo: repoField,
|
|
397
|
+
commandId: z.string().min(1).describe('Command id returned by a previous sandbox.start.'),
|
|
398
|
+
waitSeconds: z
|
|
399
|
+
.number()
|
|
400
|
+
.int()
|
|
401
|
+
.min(0)
|
|
402
|
+
.max(300)
|
|
403
|
+
.describe(
|
|
404
|
+
'Bounded wait, 0-300 seconds. Returns state "running" (no output on remote providers, which report output only after exit) or "exited" with the exit code and output so far.',
|
|
405
|
+
),
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
const sandboxReadFileInput = z.strictObject({
|
|
409
|
+
repo: repoField,
|
|
410
|
+
path: z
|
|
411
|
+
.string()
|
|
412
|
+
.min(1)
|
|
413
|
+
.describe('File path relative to the checkout. Absolute paths and ".." escapes are refused.'),
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
const sandboxWriteFileInput = z.strictObject({
|
|
417
|
+
repo: repoField,
|
|
418
|
+
path: z
|
|
419
|
+
.string()
|
|
420
|
+
.min(1)
|
|
421
|
+
.describe('File path relative to the checkout. Absolute paths and ".." escapes are refused.'),
|
|
422
|
+
content: z.string().describe('File content (see encoding); at most 1 MiB after decoding.'),
|
|
423
|
+
encoding: z
|
|
424
|
+
.enum(['utf8', 'base64'])
|
|
425
|
+
.default('utf8')
|
|
426
|
+
.describe('How to decode `content`: "utf8" (default) or "base64".'),
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
const sandboxResetInput = z.strictObject({ repo: repoField })
|
|
430
|
+
|
|
431
|
+
// ── The closed version-one table ─────────────────────────────────────────────
|
|
432
|
+
|
|
433
|
+
function defineTool(
|
|
434
|
+
name: OperatorToolName,
|
|
435
|
+
description: string,
|
|
436
|
+
inputSchema: z.ZodType,
|
|
437
|
+
outputDescription: string,
|
|
438
|
+
handler: ToolHandler,
|
|
439
|
+
): ToolEntry {
|
|
440
|
+
const table = OPERATOR_TOOL_ANNOTATIONS[name]
|
|
441
|
+
return {
|
|
442
|
+
name,
|
|
443
|
+
description,
|
|
444
|
+
inputSchema,
|
|
445
|
+
outputDescription,
|
|
446
|
+
annotations: {
|
|
447
|
+
readOnlyHint: table.readOnlyHint,
|
|
448
|
+
destructiveHint: table.destructiveHint,
|
|
449
|
+
idempotentHint: table.idempotentHint,
|
|
450
|
+
},
|
|
451
|
+
approval: table.approval,
|
|
452
|
+
handler,
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** The closed version-one tool table. Adding a tool means adding an entry
|
|
457
|
+
* 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. */
|
|
459
|
+
export const TOOLS: readonly ToolEntry[] = [
|
|
460
|
+
defineTool(
|
|
461
|
+
'builds.list',
|
|
462
|
+
'List one repository’s builds, newest first, with effective status derived from the build log.',
|
|
463
|
+
buildListInput,
|
|
464
|
+
'JSON array of build summaries: slug, ticket ref, effective status, phase/round/attempt, PR number/url/state, updatedAt, lease health, and durable-event progress.',
|
|
465
|
+
async (raw, ctx) => {
|
|
466
|
+
const input = raw as z.infer<typeof buildListInput>
|
|
467
|
+
return listOperatorBuilds({
|
|
468
|
+
store: ctx.store,
|
|
469
|
+
repo: input.repo,
|
|
470
|
+
scope: input.scope,
|
|
471
|
+
now: ctx.clock(),
|
|
472
|
+
})
|
|
473
|
+
},
|
|
474
|
+
),
|
|
475
|
+
defineTool(
|
|
476
|
+
'builds.get',
|
|
477
|
+
'Show detailed state for one build: open escalations, verify progress, PR lifecycle, lease health, and the dashboard row.',
|
|
478
|
+
buildDetailInput,
|
|
479
|
+
'{detail, dashboardRow}: the full build detail projection and the dashboard row a terminal dashboard would render (null when the build has no dashboard row).',
|
|
480
|
+
async (raw, ctx) => {
|
|
481
|
+
const input = raw as z.infer<typeof buildDetailInput>
|
|
482
|
+
return getOperatorBuild({
|
|
483
|
+
store: ctx.store,
|
|
484
|
+
repo: input.repo,
|
|
485
|
+
slug: input.slug,
|
|
486
|
+
now: ctx.clock(),
|
|
487
|
+
})
|
|
488
|
+
},
|
|
489
|
+
),
|
|
490
|
+
defineTool(
|
|
491
|
+
'builds.events',
|
|
492
|
+
'Read one build’s event log after a cursor, optionally waiting (bounded, ≤30s) for new events — the polling companion to builds.get.',
|
|
493
|
+
buildEventsInput,
|
|
494
|
+
'{events, cursor}: the new events in sequence order (each with seq, ts, actor, type, payload) and the cursor to pass next time — the last returned event’s seq, or the requested cursor when the page is empty.',
|
|
495
|
+
async (raw, ctx) => {
|
|
496
|
+
const input = raw as z.infer<typeof buildEventsInput>
|
|
497
|
+
await requireRouteBuild(ctx.store, input.repo, input.slug)
|
|
498
|
+
let events = await ctx.store.getEvents(input.slug, input.cursor)
|
|
499
|
+
if (events.length === 0 && input.waitSeconds > 0) {
|
|
500
|
+
// The store interface offers subscribe but no bounded wait; polling
|
|
501
|
+
// keeps the handler finite. The schema caps waitSeconds at 30.
|
|
502
|
+
const deadline = Date.now() + input.waitSeconds * 1000
|
|
503
|
+
while (events.length === 0 && Date.now() < deadline) {
|
|
504
|
+
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
505
|
+
events = await ctx.store.getEvents(input.slug, input.cursor)
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return { events, cursor: events.length > 0 ? events.at(-1)!.seq : input.cursor }
|
|
509
|
+
},
|
|
510
|
+
),
|
|
511
|
+
defineTool(
|
|
512
|
+
'builds.artifact',
|
|
513
|
+
'Download one of a build’s deposited artifacts by kind and optional revision.',
|
|
514
|
+
buildArtifactInput,
|
|
515
|
+
'The artifact metadata {kind, revision, blobRef, createdAt} plus contentBase64: the exact artifact bytes base64-encoded. Decode contentBase64 to recover the download; the bytes are identical to the operator API’s artifact download body.',
|
|
516
|
+
async (raw, ctx) => {
|
|
517
|
+
const input = raw as z.infer<typeof buildArtifactInput>
|
|
518
|
+
await requireRouteBuild(ctx.store, input.repo, input.slug)
|
|
519
|
+
const artifact = await ctx.store.getArtifact(input.slug, input.kind, input.rev)
|
|
520
|
+
if (artifact === null) {
|
|
521
|
+
throw new RouteRefusalError({
|
|
522
|
+
kind: 'not-found',
|
|
523
|
+
error: `artifact ${input.kind}${input.rev === undefined ? '' : `@${input.rev}`} not found`,
|
|
524
|
+
})
|
|
525
|
+
}
|
|
526
|
+
return {
|
|
527
|
+
meta: {
|
|
528
|
+
kind: artifact.meta.kind,
|
|
529
|
+
revision: artifact.meta.revision,
|
|
530
|
+
blobRef: artifact.meta.blobRef,
|
|
531
|
+
createdAt: artifact.meta.createdAt,
|
|
532
|
+
},
|
|
533
|
+
contentBase64: Buffer.from(artifact.content).toString('base64'),
|
|
534
|
+
}
|
|
535
|
+
},
|
|
536
|
+
),
|
|
537
|
+
defineTool(
|
|
538
|
+
'builds.control',
|
|
539
|
+
'Request a durable control for one active build: pause/cancel-pause, resume, auto-merge on/off — or the destructive abort and discard.',
|
|
540
|
+
buildControlInput,
|
|
541
|
+
'Either {kind: "command", slug, command, event} for an accepted control, or {kind: "answer-required", slug, escalationIds} when a resume needs its open escalations answered first.',
|
|
542
|
+
async (raw, ctx) => {
|
|
543
|
+
const input = raw as z.infer<typeof buildControlInput>
|
|
544
|
+
await controlPrechecks(ctx.store, input.repo, input.slug, input.action)
|
|
545
|
+
// The route maps BOTH pause and cancel-pause to `dashboard-pause`:
|
|
546
|
+
// on a running build it requests a pause, and on a build with a pending
|
|
547
|
+
// pause the reducer-supersede rule makes the same command cancel it.
|
|
548
|
+
// Only `resume` maps to `dashboard-resume`.
|
|
549
|
+
const action: BuildControlAction =
|
|
550
|
+
input.action === 'pause' || input.action === 'cancel-pause'
|
|
551
|
+
? { kind: 'dashboard-pause' }
|
|
552
|
+
: input.action === 'resume'
|
|
553
|
+
? { kind: 'dashboard-resume' }
|
|
554
|
+
: { kind: input.action }
|
|
555
|
+
return controlBuild({
|
|
556
|
+
store: ctx.store,
|
|
557
|
+
repo: input.repo,
|
|
558
|
+
slug: input.slug,
|
|
559
|
+
user: attributed(ctx),
|
|
560
|
+
...(ctx.via !== undefined ? { via: ctx.via } : {}),
|
|
561
|
+
action,
|
|
562
|
+
})
|
|
563
|
+
},
|
|
564
|
+
),
|
|
565
|
+
defineTool(
|
|
566
|
+
'builds.answer',
|
|
567
|
+
'Answer a blocked build’s open escalations: guidance text, a bare retry, a dismissal of the cited findings, a review-round ceiling, or a spec revision (destructive — replaces the spec and restarts from plan).',
|
|
568
|
+
buildsAnswerInput,
|
|
569
|
+
'The controlBuild answer result: {kind: "answered", slug, count, resolution, resumed, …} describing how many escalations were answered and with what.',
|
|
570
|
+
async (raw, ctx) => {
|
|
571
|
+
const input = raw as OperatorAnswerRequest & { repo: string; slug: string }
|
|
572
|
+
await requireRouteBuild(ctx.store, input.repo, input.slug)
|
|
573
|
+
const { action, readTicketBody } = answerAction(input)
|
|
574
|
+
return controlBuild({
|
|
575
|
+
store: ctx.store,
|
|
576
|
+
repo: input.repo,
|
|
577
|
+
slug: input.slug,
|
|
578
|
+
user: attributed(ctx),
|
|
579
|
+
...(ctx.via !== undefined ? { via: ctx.via } : {}),
|
|
580
|
+
action,
|
|
581
|
+
...(readTicketBody !== undefined ? { readTicketBody } : {}),
|
|
582
|
+
})
|
|
583
|
+
},
|
|
584
|
+
),
|
|
585
|
+
defineTool(
|
|
586
|
+
'repository.status',
|
|
587
|
+
'Report one repository’s durable dispatcher settings and journal state.',
|
|
588
|
+
repositoryStatusInput,
|
|
589
|
+
'The repository status projection: ticket intake, repository-wide pause, claim-time auto-merge default, and the harvest/dispatch journal state the sessionless CLI and dashboard read.',
|
|
590
|
+
async (raw, ctx) => {
|
|
591
|
+
const input = raw as z.infer<typeof repositoryStatusInput>
|
|
592
|
+
return getRepositoryStatus(ctx.store, input.repo)
|
|
593
|
+
},
|
|
594
|
+
),
|
|
595
|
+
defineTool(
|
|
596
|
+
'repository.settings',
|
|
597
|
+
'Set or toggle a durable repository setting: ticket intake or the claim-time auto-merge default.',
|
|
598
|
+
repositorySettingsInput,
|
|
599
|
+
'{enabled, event}: the resulting durable value and the appended repository event.',
|
|
600
|
+
async (raw, ctx) => {
|
|
601
|
+
const input = raw as z.infer<typeof repositorySettingsInput>
|
|
602
|
+
const user = attributed(ctx)
|
|
603
|
+
return input.enabled === undefined
|
|
604
|
+
? toggleRepositorySetting({
|
|
605
|
+
store: ctx.store,
|
|
606
|
+
repo: input.repo,
|
|
607
|
+
user,
|
|
608
|
+
...(ctx.via !== undefined ? { via: ctx.via } : {}),
|
|
609
|
+
setting: input.setting,
|
|
610
|
+
})
|
|
611
|
+
: setRepositorySetting({
|
|
612
|
+
store: ctx.store,
|
|
613
|
+
repo: input.repo,
|
|
614
|
+
user,
|
|
615
|
+
...(ctx.via !== undefined ? { via: ctx.via } : {}),
|
|
616
|
+
setting: input.setting,
|
|
617
|
+
enabled: input.enabled,
|
|
618
|
+
})
|
|
619
|
+
},
|
|
620
|
+
),
|
|
621
|
+
defineTool(
|
|
622
|
+
'repository.bulk_control',
|
|
623
|
+
'Pause or resume the whole repository: hold queued builds, set intake, and request per-build pause/resume in one walk.',
|
|
624
|
+
bulkControlInput,
|
|
625
|
+
'{direction, slugs, paused, intake}: the direction, the slugs that received a durable request, and the repository-wide hold/intake values written. A partial walk surfaces as a refusal with code "bulk-partial" and a progress object naming what landed.',
|
|
626
|
+
async (raw, ctx) => {
|
|
627
|
+
const input = raw as z.infer<typeof bulkControlInput>
|
|
628
|
+
return bulkControlRepository({
|
|
629
|
+
store: ctx.store,
|
|
630
|
+
repo: input.repo,
|
|
631
|
+
user: attributed(ctx),
|
|
632
|
+
...(ctx.via !== undefined ? { via: ctx.via } : {}),
|
|
633
|
+
direction: input.action,
|
|
634
|
+
})
|
|
635
|
+
},
|
|
636
|
+
),
|
|
637
|
+
defineTool(
|
|
638
|
+
'harvest.status',
|
|
639
|
+
'Report one repository’s observation-harvest state: unresolved workflows, pending proposals, and their paper trail.',
|
|
640
|
+
harvestStatusInput,
|
|
641
|
+
'The harvest status projection the sessionless `ab harvest status` renders.',
|
|
642
|
+
async (raw, ctx) => {
|
|
643
|
+
const input = raw as z.infer<typeof harvestStatusInput>
|
|
644
|
+
return getHarvestStatus(ctx.store, input.repo)
|
|
645
|
+
},
|
|
646
|
+
),
|
|
647
|
+
defineTool(
|
|
648
|
+
'harvest.control',
|
|
649
|
+
'Toggle the repository’s harvest gate, or act on the concrete harvest run the dashboard captured.',
|
|
650
|
+
harvestControlInput,
|
|
651
|
+
'For "toggle-gate": {command: "pause"|"resume", event}. For a run action: {action, event} — the action the dashboard offered for that run.',
|
|
652
|
+
async (raw, ctx) => {
|
|
653
|
+
const input = raw as { repo: string } & (
|
|
654
|
+
| { action: 'toggle-gate' }
|
|
655
|
+
| { action: 'run'; run: string }
|
|
656
|
+
)
|
|
657
|
+
const user = attributed(ctx)
|
|
658
|
+
return input.action === 'toggle-gate'
|
|
659
|
+
? toggleHarvestGate({
|
|
660
|
+
store: ctx.store,
|
|
661
|
+
repo: input.repo,
|
|
662
|
+
user,
|
|
663
|
+
...(ctx.via !== undefined ? { via: ctx.via } : {}),
|
|
664
|
+
})
|
|
665
|
+
: controlHarvestRun({
|
|
666
|
+
store: ctx.store,
|
|
667
|
+
repo: input.repo,
|
|
668
|
+
user,
|
|
669
|
+
...(ctx.via !== undefined ? { via: ctx.via } : {}),
|
|
670
|
+
run: input.run,
|
|
671
|
+
})
|
|
672
|
+
},
|
|
673
|
+
),
|
|
674
|
+
defineTool(
|
|
675
|
+
'tickets.list',
|
|
676
|
+
'List the repository’s ticket grooming queue from the configured ticket source.',
|
|
677
|
+
ticketsListInput,
|
|
678
|
+
'{states, tickets, diagnostics, criteria, triageState, readyState}: the queue listing, the effective lifecycle names, and the criteria actually applied.',
|
|
679
|
+
async (raw, ctx) => {
|
|
680
|
+
const input = raw as z.infer<typeof ticketsListInput>
|
|
681
|
+
return listOperatorTickets({
|
|
682
|
+
store: ctx.store,
|
|
683
|
+
repo: input.repo,
|
|
684
|
+
backend: ticketsOf(ctx),
|
|
685
|
+
...(input.state !== undefined ? { state: input.state } : {}),
|
|
686
|
+
...(input.labels !== undefined ? { labels: input.labels } : {}),
|
|
687
|
+
})
|
|
688
|
+
},
|
|
689
|
+
),
|
|
690
|
+
defineTool(
|
|
691
|
+
'tickets.get',
|
|
692
|
+
'Show one ticket with its blockers and any related build.',
|
|
693
|
+
ticketDetailInput,
|
|
694
|
+
'{ticket, blockers, build}: the ticket, its dependency states, and the most relevant build for it (null when none).',
|
|
695
|
+
async (raw, ctx) => {
|
|
696
|
+
const input = raw as z.infer<typeof ticketDetailInput>
|
|
697
|
+
return getOperatorTicket({
|
|
698
|
+
store: ctx.store,
|
|
699
|
+
repo: input.repo,
|
|
700
|
+
backend: ticketsOf(ctx),
|
|
701
|
+
id: input.id,
|
|
702
|
+
})
|
|
703
|
+
},
|
|
704
|
+
),
|
|
705
|
+
defineTool(
|
|
706
|
+
'tickets.create',
|
|
707
|
+
'Create a ticket in the configured source, optionally labelled, pre-blocked, or filed in a nondefault state.',
|
|
708
|
+
ticketsCreateInput,
|
|
709
|
+
'{ticket, blockers, build}: the created ticket and its (usually empty) dependency states.',
|
|
710
|
+
async (raw, ctx) => {
|
|
711
|
+
const input = raw as z.infer<typeof ticketsCreateInput>
|
|
712
|
+
const { repo, title, body, labels, state, blockedBy } = input
|
|
713
|
+
return mutateOperatorTicket({
|
|
714
|
+
store: ctx.store,
|
|
715
|
+
repo,
|
|
716
|
+
backend: ticketsOf(ctx),
|
|
717
|
+
operation: {
|
|
718
|
+
kind: 'create',
|
|
719
|
+
title,
|
|
720
|
+
body,
|
|
721
|
+
...(labels !== undefined ? { labels } : {}),
|
|
722
|
+
...(state !== undefined ? { state } : {}),
|
|
723
|
+
...(blockedBy !== undefined ? { blockedBy } : {}),
|
|
724
|
+
},
|
|
725
|
+
})
|
|
726
|
+
},
|
|
727
|
+
),
|
|
728
|
+
defineTool(
|
|
729
|
+
'tickets.update',
|
|
730
|
+
'Partially replace a ticket’s editable fields (title, body, labels); state is never changed here.',
|
|
731
|
+
ticketsUpdateInput,
|
|
732
|
+
'{ticket, blockers, build}: the updated ticket.',
|
|
733
|
+
async (raw, ctx) => {
|
|
734
|
+
const input = raw as z.infer<typeof ticketsUpdateInput>
|
|
735
|
+
const { repo, id, ...patch } = input
|
|
736
|
+
return mutateOperatorTicket({
|
|
737
|
+
store: ctx.store,
|
|
738
|
+
repo,
|
|
739
|
+
backend: ticketsOf(ctx),
|
|
740
|
+
operation: { kind: 'update', id, patch },
|
|
741
|
+
})
|
|
742
|
+
},
|
|
743
|
+
),
|
|
744
|
+
defineTool(
|
|
745
|
+
'tickets.block',
|
|
746
|
+
'Add dependency edges: make one ticket blocked by one or more blockers.',
|
|
747
|
+
ticketsBlockersInput,
|
|
748
|
+
'{ticket, blockers, build}: the ticket after the edges were added.',
|
|
749
|
+
async (raw, ctx) => {
|
|
750
|
+
const input = raw as z.infer<typeof ticketsBlockersInput>
|
|
751
|
+
return mutateOperatorTicket({
|
|
752
|
+
store: ctx.store,
|
|
753
|
+
repo: input.repo,
|
|
754
|
+
backend: ticketsOf(ctx),
|
|
755
|
+
operation: { kind: 'block', id: input.id, blockerIds: input.blockerIds },
|
|
756
|
+
})
|
|
757
|
+
},
|
|
758
|
+
),
|
|
759
|
+
defineTool(
|
|
760
|
+
'tickets.unblock',
|
|
761
|
+
'Remove dependency edges from one ticket.',
|
|
762
|
+
ticketsBlockersInput,
|
|
763
|
+
'{ticket, blockers, build}: the ticket after the edges were removed.',
|
|
764
|
+
async (raw, ctx) => {
|
|
765
|
+
const input = raw as z.infer<typeof ticketsBlockersInput>
|
|
766
|
+
return mutateOperatorTicket({
|
|
767
|
+
store: ctx.store,
|
|
768
|
+
repo: input.repo,
|
|
769
|
+
backend: ticketsOf(ctx),
|
|
770
|
+
operation: { kind: 'unblock', id: input.id, blockerIds: input.blockerIds },
|
|
771
|
+
})
|
|
772
|
+
},
|
|
773
|
+
),
|
|
774
|
+
defineTool(
|
|
775
|
+
'tickets.move',
|
|
776
|
+
'Move one ticket to a provider state. Destructive when the target is the repository’s configured ready state: the ticket becomes dispatchable.',
|
|
777
|
+
ticketsMoveInput,
|
|
778
|
+
'{ticket, blockers, build}: the ticket in its new state.',
|
|
779
|
+
async (raw, ctx) => {
|
|
780
|
+
const input = raw as z.infer<typeof ticketsMoveInput>
|
|
781
|
+
return mutateOperatorTicket({
|
|
782
|
+
store: ctx.store,
|
|
783
|
+
repo: input.repo,
|
|
784
|
+
backend: ticketsOf(ctx),
|
|
785
|
+
operation: { kind: 'move', id: input.id, state: input.state },
|
|
786
|
+
})
|
|
787
|
+
},
|
|
788
|
+
),
|
|
789
|
+
defineTool(
|
|
790
|
+
'notes.read',
|
|
791
|
+
'Read the repository’s operator-notes artifact — the shared notes an operator agent leaves for the next round.',
|
|
792
|
+
notesReadInput,
|
|
793
|
+
'{document, revision, metadata}: the latest revision’s UTF-8 text, its artifact revision, and its deposit metadata (writer identity). An empty document with null revision/metadata when no notes exist yet.',
|
|
794
|
+
async (raw, ctx) => {
|
|
795
|
+
const input = raw as z.infer<typeof notesReadInput>
|
|
796
|
+
if ((await ctx.store.getRepo(input.repo)) === null) {
|
|
797
|
+
return { document: '', revision: null, metadata: null }
|
|
798
|
+
}
|
|
799
|
+
const artifact = await ctx.store.getRepoArtifact(input.repo, OPERATOR_NOTES_ARTIFACT)
|
|
800
|
+
if (artifact === null) {
|
|
801
|
+
return { document: '', revision: null, metadata: null }
|
|
802
|
+
}
|
|
803
|
+
return {
|
|
804
|
+
document: new TextDecoder().decode(artifact.content),
|
|
805
|
+
revision: artifact.meta.revision,
|
|
806
|
+
metadata: artifact.meta.metadata,
|
|
807
|
+
}
|
|
808
|
+
},
|
|
809
|
+
),
|
|
810
|
+
defineTool(
|
|
811
|
+
'notes.write',
|
|
812
|
+
'Deposit a new revision of the repository’s operator-notes artifact, attributed to the caller.',
|
|
813
|
+
notesWriteInput,
|
|
814
|
+
'{revision, blobRef, createdAt}: the newly deposited revision. The kind is retention-managed: only the newest 200 revisions (the store’s configured maxRevisions) survive.',
|
|
815
|
+
async (raw, ctx) => {
|
|
816
|
+
const input = raw as z.infer<typeof notesWriteInput>
|
|
817
|
+
const user = attributed(ctx)
|
|
818
|
+
await ctx.store.ensureRepo(input.repo)
|
|
819
|
+
// The `Via` object serializes into artifact metadata unchanged; no
|
|
820
|
+
// reader constrains its shape.
|
|
821
|
+
const meta = await ctx.store.putRepoArtifact(input.repo, {
|
|
822
|
+
kind: OPERATOR_NOTES_ARTIFACT,
|
|
823
|
+
content: input.document,
|
|
824
|
+
metadata: { user, ...(ctx.via !== undefined ? { via: ctx.via } : {}) },
|
|
825
|
+
})
|
|
826
|
+
return { revision: meta.revision, blobRef: meta.blobRef, createdAt: meta.createdAt }
|
|
827
|
+
},
|
|
828
|
+
),
|
|
829
|
+
defineTool(
|
|
830
|
+
'sandbox.exec',
|
|
831
|
+
'Run one shell command inside your operator sandbox: a persistent, credential-free environment provisioned from the repository (first call provisions; later calls reuse it). The command waits at most 300 seconds; on expiry it is killed and the tool fails with code "sandbox-exec-timeout". Stdout and stderr are each truncated at 65,536 bytes with a "[truncated by autobuild: output exceeded 65536 bytes]" marker. The environment holds no store, forge, or model credentials — the typed tools remain the only route to build state.',
|
|
832
|
+
sandboxExecInput,
|
|
833
|
+
'{exitCode, stdout, stderr}: the command’s exit status and truncated output streams.',
|
|
834
|
+
async (raw, ctx) => {
|
|
835
|
+
const input = raw as z.infer<typeof sandboxExecInput>
|
|
836
|
+
return sandboxOf(ctx).exec(attributed(ctx), input)
|
|
837
|
+
},
|
|
838
|
+
),
|
|
839
|
+
defineTool(
|
|
840
|
+
'sandbox.start',
|
|
841
|
+
'Launch one shell command detached inside your operator sandbox and return immediately. Pair with sandbox.wait to observe it. Calls against one environment are serialized; a detached command is tracked only by the serving process.',
|
|
842
|
+
sandboxStartInput,
|
|
843
|
+
'{commandId}: the id to pass to sandbox.wait.',
|
|
844
|
+
async (raw, ctx) => {
|
|
845
|
+
const input = raw as z.infer<typeof sandboxStartInput>
|
|
846
|
+
return sandboxOf(ctx).start(attributed(ctx), input)
|
|
847
|
+
},
|
|
848
|
+
),
|
|
849
|
+
defineTool(
|
|
850
|
+
'sandbox.wait',
|
|
851
|
+
'Wait, bounded (0–300 seconds), on a command started with sandbox.start. Returns state "running" (poll again) or "exited" with the exit code and output so far. Remote providers report command output only after exit, so a running command shows no output there.',
|
|
852
|
+
sandboxWaitInput,
|
|
853
|
+
'{state, exitCode?, stdout?, stderr?}: the command state; once exited, its exit code and truncated output streams; while running, the streamed output so far when the provider reports it (Vercel reports output only after exit).',
|
|
854
|
+
async (raw, ctx) => {
|
|
855
|
+
const input = raw as z.infer<typeof sandboxWaitInput>
|
|
856
|
+
return sandboxOf(ctx).wait(attributed(ctx), input)
|
|
857
|
+
},
|
|
858
|
+
),
|
|
859
|
+
defineTool(
|
|
860
|
+
'sandbox.read_file',
|
|
861
|
+
'Read one file from your operator sandbox, rooted at the checkout. Absolute paths and ".." escapes are refused.',
|
|
862
|
+
sandboxReadFileInput,
|
|
863
|
+
'{encoding, content}: the file bytes — "utf8" when they decode as UTF-8, else "base64".',
|
|
864
|
+
async (raw, ctx) => {
|
|
865
|
+
const input = raw as z.infer<typeof sandboxReadFileInput>
|
|
866
|
+
const bytes = await sandboxOf(ctx).readFile(attributed(ctx), input)
|
|
867
|
+
const decoded = new TextDecoder('utf-8').decode(bytes)
|
|
868
|
+
if (!decoded.includes('\uFFFD')) {
|
|
869
|
+
return { encoding: 'utf8', content: decoded }
|
|
870
|
+
}
|
|
871
|
+
return { encoding: 'base64', content: Buffer.from(bytes).toString('base64') }
|
|
872
|
+
},
|
|
873
|
+
),
|
|
874
|
+
defineTool(
|
|
875
|
+
'sandbox.write_file',
|
|
876
|
+
'Write one file inside your operator sandbox, rooted at the checkout (parent directories are created). Absolute paths and ".." escapes are refused; content is bounded at 1 MiB after decoding.',
|
|
877
|
+
sandboxWriteFileInput,
|
|
878
|
+
'{path}: the written path, confirming the durable write.',
|
|
879
|
+
async (raw, ctx) => {
|
|
880
|
+
const input = raw as z.infer<typeof sandboxWriteFileInput>
|
|
881
|
+
await sandboxOf(ctx).writeFile(attributed(ctx), input)
|
|
882
|
+
return { path: input.path }
|
|
883
|
+
},
|
|
884
|
+
),
|
|
885
|
+
defineTool(
|
|
886
|
+
'sandbox.reset',
|
|
887
|
+
'Destructively reset your operator sandbox: discard every change, purge the environment and its snapshots, and re-provision fresh from the current base branch head. Use when the environment is wedged or its checkout is dirty beyond repair.',
|
|
888
|
+
sandboxResetInput,
|
|
889
|
+
'{ok: true}: the environment was released and re-provisioned from the current base head.',
|
|
890
|
+
async (raw, ctx) => {
|
|
891
|
+
const input = raw as z.infer<typeof sandboxResetInput>
|
|
892
|
+
await sandboxOf(ctx).reset(attributed(ctx), input)
|
|
893
|
+
return { ok: true as const }
|
|
894
|
+
},
|
|
895
|
+
),
|
|
896
|
+
]
|
|
897
|
+
|
|
898
|
+
// ── The registry constructor ─────────────────────────────────────────────────
|
|
899
|
+
|
|
900
|
+
export interface RegistryOptions {
|
|
901
|
+
store: BuildStore
|
|
902
|
+
tickets?: OperatorTicketBackend
|
|
903
|
+
/** The operator-sandbox backend; when absent the six sandbox tools are
|
|
904
|
+
* neither advertised nor callable (the compatibility case). */
|
|
905
|
+
sandbox?: OperatorSandboxService
|
|
906
|
+
clock?: Clock
|
|
907
|
+
/** Constrain every call to this repository identity (the `ab mcp --repo` flag). */
|
|
908
|
+
allowedRepo?: string
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
export interface OperatorToolRegistry {
|
|
912
|
+
/** The closed table, in definition order. */
|
|
913
|
+
readonly entries: readonly ToolEntry[]
|
|
914
|
+
/** Validate, enforce registry rules, then run the tool's handler. Throws
|
|
915
|
+
* `RegistryError` (never a raw service error) on every refusal path. */
|
|
916
|
+
call(name: string, input: unknown, ctx?: Partial<ToolContext>): Promise<unknown>
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/** 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. */
|
|
921
|
+
function mapDomainError(error: unknown): RegistryError {
|
|
922
|
+
if (error instanceof RegistryError) return error
|
|
923
|
+
if (error instanceof RouteRefusalError) {
|
|
924
|
+
return new RegistryError('domain', error.message, {
|
|
925
|
+
kind: error.refusal.kind,
|
|
926
|
+
error: error.refusal.error,
|
|
927
|
+
})
|
|
928
|
+
}
|
|
929
|
+
if (error instanceof TicketOperationError) {
|
|
930
|
+
return new RegistryError('domain', error.message, {
|
|
931
|
+
kind: error.code === 'not-found' ? 'not-found' : 'refusal',
|
|
932
|
+
error: error.message,
|
|
933
|
+
code: error.code,
|
|
934
|
+
})
|
|
935
|
+
}
|
|
936
|
+
if (error instanceof BuildControlError || error instanceof OperatorControlError) {
|
|
937
|
+
return new RegistryError('domain', error.message, {
|
|
938
|
+
kind: 'refusal',
|
|
939
|
+
error: error.message,
|
|
940
|
+
code: error.code,
|
|
941
|
+
})
|
|
942
|
+
}
|
|
943
|
+
if (error instanceof BulkWalkError) {
|
|
944
|
+
return new RegistryError('domain', error.message, {
|
|
945
|
+
kind: 'refusal',
|
|
946
|
+
error: error.message,
|
|
947
|
+
code: 'bulk-partial',
|
|
948
|
+
progress: error.progress,
|
|
949
|
+
})
|
|
950
|
+
}
|
|
951
|
+
if (error instanceof SandboxOperationError) {
|
|
952
|
+
return new RegistryError('domain', error.message, {
|
|
953
|
+
kind: 'refusal',
|
|
954
|
+
error: error.message,
|
|
955
|
+
code: `sandbox-${error.stage}`,
|
|
956
|
+
})
|
|
957
|
+
}
|
|
958
|
+
if (error instanceof OperatorQueryError) {
|
|
959
|
+
return new RegistryError('domain', error.message, {
|
|
960
|
+
kind: error.code === 'not-found' ? 'not-found' : 'conflict',
|
|
961
|
+
error: error.message,
|
|
962
|
+
code: error.code,
|
|
963
|
+
})
|
|
964
|
+
}
|
|
965
|
+
return new RegistryError(
|
|
966
|
+
'domain',
|
|
967
|
+
'operator tool is unavailable',
|
|
968
|
+
{
|
|
969
|
+
kind: 'internal',
|
|
970
|
+
error: 'operator tool is unavailable',
|
|
971
|
+
},
|
|
972
|
+
{ cause: error },
|
|
973
|
+
)
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/** Bind the closed table to an opened store (and optional ticket and sandbox
|
|
977
|
+
* backends). The returned registry is the in-process binding; every other
|
|
978
|
+
* 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. */
|
|
982
|
+
export function buildRegistry(options: RegistryOptions): OperatorToolRegistry {
|
|
983
|
+
const sandboxToolNames = new Set(
|
|
984
|
+
Object.keys(OPERATOR_TOOL_ANNOTATIONS).filter((name) => name.startsWith('sandbox.')),
|
|
985
|
+
)
|
|
986
|
+
const table =
|
|
987
|
+
options.sandbox === undefined ? TOOLS.filter((tool) => !sandboxToolNames.has(tool.name)) : TOOLS
|
|
988
|
+
return {
|
|
989
|
+
entries: table,
|
|
990
|
+
async call(name, input, partial = {}) {
|
|
991
|
+
const entry = table.find((tool) => tool.name === name)
|
|
992
|
+
if (entry === undefined) {
|
|
993
|
+
throw new RegistryError('unknown-tool', `unknown tool "${name}"`, {
|
|
994
|
+
kind: 'not-found',
|
|
995
|
+
error: `unknown tool "${name}"`,
|
|
996
|
+
})
|
|
997
|
+
}
|
|
998
|
+
const parsed = entry.inputSchema.safeParse(input)
|
|
999
|
+
if (!parsed.success) {
|
|
1000
|
+
throw new RegistryError(
|
|
1001
|
+
'validation',
|
|
1002
|
+
`invalid input for tool "${name}": ${parsed.error.message}`,
|
|
1003
|
+
{
|
|
1004
|
+
kind: 'validation',
|
|
1005
|
+
error: `invalid input for tool "${name}": ${parsed.error.message}`,
|
|
1006
|
+
},
|
|
1007
|
+
)
|
|
1008
|
+
}
|
|
1009
|
+
const value = parsed.data as { repo?: unknown }
|
|
1010
|
+
const allowedRepo = options.allowedRepo
|
|
1011
|
+
if (allowedRepo !== undefined && value.repo !== allowedRepo) {
|
|
1012
|
+
const error = `tool "${name}" targets repository "${String(value.repo)}" but this binding serves "${allowedRepo}"`
|
|
1013
|
+
throw new RegistryError('repo-mismatch', error, { kind: 'validation', error })
|
|
1014
|
+
}
|
|
1015
|
+
const identity = partial.identity
|
|
1016
|
+
if (entry.approval === 'default' && (identity === undefined || identity.trim() === '')) {
|
|
1017
|
+
throw new RegistryError(
|
|
1018
|
+
'no-identity',
|
|
1019
|
+
`tool "${name}" mutates durable state and requires an attributed operator identity`,
|
|
1020
|
+
{
|
|
1021
|
+
kind: 'auth',
|
|
1022
|
+
error: `tool "${name}" mutates durable state and requires an attributed operator identity`,
|
|
1023
|
+
},
|
|
1024
|
+
)
|
|
1025
|
+
}
|
|
1026
|
+
const tickets = partial.tickets ?? options.tickets
|
|
1027
|
+
const sandbox = partial.sandbox ?? options.sandbox
|
|
1028
|
+
const ctx: ToolContext = {
|
|
1029
|
+
store: partial.store ?? options.store,
|
|
1030
|
+
...(tickets !== undefined ? { tickets } : {}),
|
|
1031
|
+
...(sandbox !== undefined ? { sandbox } : {}),
|
|
1032
|
+
clock: partial.clock ?? options.clock ?? systemClock,
|
|
1033
|
+
...(identity !== undefined ? { identity } : {}),
|
|
1034
|
+
...(partial.via !== undefined ? { via: partial.via } : {}),
|
|
1035
|
+
}
|
|
1036
|
+
try {
|
|
1037
|
+
return await entry.handler(parsed.data as never, ctx)
|
|
1038
|
+
} catch (error) {
|
|
1039
|
+
throw mapDomainError(error)
|
|
1040
|
+
}
|
|
1041
|
+
},
|
|
1042
|
+
}
|
|
1043
|
+
}
|