@defrex/autobuild 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/SPEC.md +313 -26
- package/bin/ab-build-runner.ts +8 -2
- package/docs/architecture.md +57 -14
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +151 -25
- package/docs/hosted-dispatcher.md +69 -13
- package/docs/operator-api.md +2 -2
- package/docs/remote-store-protocol.md +169 -16
- package/docs/remote-ticket-protocol.md +1 -1
- package/docs/setup.md +12 -9
- package/docs/website.md +10 -0
- package/package.json +43 -23
- package/packages/core/src/cli/artifact.ts +130 -6
- package/packages/core/src/cli/binary.ts +9 -1
- package/packages/core/src/cli/build-control.ts +110 -13
- package/packages/core/src/cli/context.ts +46 -0
- package/packages/core/src/cli/dashboard/model.ts +57 -7
- package/packages/core/src/cli/dashboard/poll.ts +69 -10
- package/packages/core/src/cli/dashboard/render.ts +34 -5
- package/packages/core/src/cli/dispatch-frontend.ts +54 -5
- package/packages/core/src/cli/dispatch.ts +320 -46
- package/packages/core/src/cli/harvest.ts +122 -8
- package/packages/core/src/cli/help.ts +47 -8
- package/packages/core/src/cli/init-readiness-remote.ts +110 -0
- package/packages/core/src/cli/init-readiness-shared.ts +72 -0
- package/packages/core/src/cli/init-validation.ts +248 -222
- package/packages/core/src/cli/main.ts +70 -11
- package/packages/core/src/cli/mcp.ts +23 -1
- package/packages/core/src/cli/plugin.ts +7 -4
- package/packages/core/src/cli/remote-poll.ts +277 -0
- package/packages/core/src/cli/repo-state.ts +22 -0
- package/packages/core/src/cli/repository-status.ts +39 -1
- package/packages/core/src/cli/status.ts +149 -27
- package/packages/core/src/cli/terminals.ts +2 -1
- package/packages/core/src/cli/ticket-assets.ts +247 -0
- package/packages/core/src/cli/ticket.ts +307 -7
- package/packages/core/src/cli/wait.ts +147 -39
- package/packages/core/src/cli/watch.ts +128 -166
- package/packages/core/src/config/live.ts +22 -0
- package/packages/core/src/config/pipeline-source.ts +376 -0
- package/packages/core/src/config/schema.ts +230 -19
- package/packages/core/src/events/catalog.ts +1 -0
- package/packages/core/src/events/globs.ts +47 -0
- package/packages/core/src/events/payloads.ts +52 -2
- package/packages/core/src/events/repository.ts +52 -1
- package/packages/core/src/events/sessions.ts +69 -10
- package/packages/core/src/events/workspace-remote.ts +12 -0
- package/packages/core/src/integration/harness.ts +2 -1
- package/packages/core/src/kernel/auto-merge-default.ts +123 -0
- package/packages/core/src/kernel/phases.ts +16 -2
- package/packages/core/src/kernel/reducer.ts +40 -1
- package/packages/core/src/operator/annotations.ts +6 -0
- package/packages/core/src/operator/control.ts +19 -5
- package/packages/core/src/operator/index.ts +71 -6
- package/packages/core/src/operator/query.ts +208 -19
- package/packages/core/src/operator/registry.ts +55 -10
- package/packages/core/src/operator/requests.ts +9 -6
- package/packages/core/src/operator/sandbox-host.ts +140 -0
- package/packages/core/src/operator/sandbox.ts +247 -14
- package/packages/core/src/operator/ticket-source-backend.ts +47 -0
- package/packages/core/src/orchestrator/approvals.ts +115 -0
- package/packages/core/src/orchestrator/conversation.ts +167 -0
- package/packages/core/src/orchestrator/model.ts +86 -0
- package/packages/core/src/orchestrator/prompt.ts +61 -0
- package/packages/core/src/orchestrator/tools.ts +117 -0
- package/packages/core/src/orchestrator/turn-runner.ts +457 -0
- package/packages/core/src/plugin-sdk/index.ts +51 -1
- package/packages/core/src/plugins/load.ts +135 -7
- package/packages/core/src/plugins/manifest.ts +183 -10
- package/packages/core/src/plugins/registry.ts +73 -4
- package/packages/core/src/ports/forge/create.ts +1 -1
- package/packages/core/src/ports/forge/fake.ts +31 -0
- package/packages/core/src/ports/forge/github-transport.ts +13 -2
- package/packages/core/src/ports/forge/github.ts +13 -2
- package/packages/core/src/ports/forge/local-git.ts +31 -1
- package/packages/core/src/ports/runner/claude.ts +2 -2
- package/packages/core/src/ports/runner/codex.ts +6 -2
- package/packages/core/src/ports/runner/fake.ts +7 -1
- package/packages/core/src/ports/runner/pi.ts +8 -6
- package/packages/core/src/ports/runner/provider-error.ts +13 -3
- package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
- package/packages/core/src/ports/types.ts +17 -2
- package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
- package/packages/core/src/ports/workspace/create.ts +74 -39
- package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
- package/packages/core/src/ports/workspace/fake.ts +169 -4
- package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
- package/packages/core/src/ports/workspace/github-origin.ts +36 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
- package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
- package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
- package/packages/core/src/processes/build-child.ts +17 -2
- package/packages/core/src/processes/build-execution-state.ts +37 -0
- package/packages/core/src/processes/build-runner.ts +54 -3
- package/packages/core/src/processes/dispatcher.ts +247 -41
- package/packages/core/src/processes/harvest-child.ts +5 -0
- package/packages/core/src/processes/harvest-runner.ts +66 -19
- package/packages/core/src/processes/harvest.ts +196 -24
- package/packages/core/src/processes/orchestrator-tick.ts +319 -0
- package/packages/core/src/processes/sandbox-state.ts +10 -0
- package/packages/core/src/store/adapter.ts +32 -0
- package/packages/core/src/store/build-scope.ts +64 -0
- package/packages/core/src/store/contract.ts +868 -0
- package/packages/core/src/store/digest.ts +49 -0
- package/packages/core/src/store/local/schema.ts +29 -2
- package/packages/core/src/store/local/store.ts +338 -4
- package/packages/core/src/store/memory.ts +179 -0
- package/packages/core/src/store/new-build.ts +17 -0
- package/packages/core/src/store/phase-session.ts +71 -0
- package/packages/core/src/store/remote/client.ts +260 -1
- package/packages/core/src/store/remote/index.ts +12 -2
- package/packages/core/src/store/remote/protocol.ts +100 -12
- package/packages/core/src/store/remote/version.ts +6 -2
- package/packages/core/src/store/repo-state-events.ts +140 -0
- package/packages/core/src/store/retention.ts +7 -0
- package/packages/core/src/store/session-handle.ts +64 -0
- package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +28 -9
- package/packages/core/src/store/streams/session-writer.ts +42 -2
- package/packages/core/src/store/streams/wait.ts +4 -5
- package/packages/core/src/store/ticket-assets.ts +425 -0
- package/packages/core/src/store/types.ts +122 -1
- package/packages/core/src/testing/index.ts +59 -0
- package/skills/code-review/SKILL.md +5 -1
- package/skills/guide/SKILL.md +126 -33
- package/skills/guide/references/operator-api.md +2 -2
- package/skills/guide/references/plugin-authoring.md +17 -0
- package/skills/guide/references/remote-store-protocol.md +169 -16
- package/skills/guide/references/remote-ticket-protocol.md +1 -1
- package/skills/guide/references/setup.md +12 -9
- package/skills/implement/SKILL.md +3 -0
- package/skills/operate/SKILL.md +126 -0
- package/skills/plan/SKILL.md +4 -0
- package/skills/plan-review/SKILL.md +5 -1
- package/skills/spec/SKILL.md +46 -3
- package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
- package/packages/core/src/cli/store-opening.contract.ts +0 -289
- package/packages/core/src/cli/testkit.ts +0 -194
- package/packages/core/src/markdown.ts +0 -67
- package/packages/core/src/operator/client.ts +0 -311
- package/packages/core/src/operator/server.ts +0 -616
- package/packages/core/src/operator/sessions.ts +0 -223
- package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
- package/packages/core/src/ports/tickets/remote-server.ts +0 -185
- package/packages/core/src/store/remote/server.ts +0 -923
- package/packages/core/src/testing/store-failures.ts +0 -32
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pipeline-source resolution (SPEC §16.1): where a build's build-owned config
|
|
3
|
+
* sections come from. A build's pipeline is pinned to its own branch — the
|
|
4
|
+
* branch head once the build has published commits there, otherwise the
|
|
5
|
+
* recorded base commit its workspace was cut from. The dispatcher resolves
|
|
6
|
+
* this once per deposit (launch and reload), records the exact commit in the
|
|
7
|
+
* effective-config artifact's metadata, and never substitutes a later
|
|
8
|
+
* base-branch change (the AUT-366 class of failure).
|
|
9
|
+
*/
|
|
10
|
+
import { readFile } from 'node:fs/promises'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import type { AbEvent } from '../events/catalog'
|
|
13
|
+
import type { Forge } from '../ports/types'
|
|
14
|
+
import type { Exec } from '../ports/workspace/git-worktree'
|
|
15
|
+
import type { BuildRecord } from '../store/types'
|
|
16
|
+
import { parseConfig } from './load'
|
|
17
|
+
import type { Config } from './schema'
|
|
18
|
+
|
|
19
|
+
/** Which fact the pinned pipeline was read from. `legacy-fallback` marks the
|
|
20
|
+
* no-migration path: no recorded base (or an unreadable one), so the deposit
|
|
21
|
+
* is the dispatcher's live snapshot exactly as before this change.
|
|
22
|
+
* `worktree-dirty` marks the checkout-mode worktree read whose git-normalized
|
|
23
|
+
* content does not match the resolved branch head (uncommitted edits, or a
|
|
24
|
+
* file never committed): no commit is recorded, because attributing worktree
|
|
25
|
+
* content to the branch-head commit would be false provenance. */
|
|
26
|
+
export type PipelineSourceRef = 'branch-head' | 'base' | 'legacy-fallback' | 'worktree-dirty'
|
|
27
|
+
|
|
28
|
+
const PIPELINE_SOURCE_REFS: readonly PipelineSourceRef[] = [
|
|
29
|
+
'branch-head',
|
|
30
|
+
'base',
|
|
31
|
+
'legacy-fallback',
|
|
32
|
+
'worktree-dirty',
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
export function isPipelineSourceRef(value: unknown): value is PipelineSourceRef {
|
|
36
|
+
return typeof value === 'string' && (PIPELINE_SOURCE_REFS as readonly string[]).includes(value)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Metadata recorded alongside every deposited effective-config artifact. */
|
|
40
|
+
export interface PipelineSourceMeta {
|
|
41
|
+
ref: PipelineSourceRef
|
|
42
|
+
/** Exact commit the pipeline sections were parsed from. Absent for the
|
|
43
|
+
* legacy fallback (no build-branch/base read was possible) and for
|
|
44
|
+
* `worktree-dirty` (worktree bytes that no commit claims). */
|
|
45
|
+
commit?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PipelineSource {
|
|
49
|
+
meta: PipelineSourceMeta
|
|
50
|
+
/** The parsed build-owned tables. */
|
|
51
|
+
config: Config
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface PipelineSourceInput {
|
|
55
|
+
slug: string
|
|
56
|
+
record: Pick<BuildRecord, 'branch'>
|
|
57
|
+
events: readonly AbEvent[]
|
|
58
|
+
/** 'origin' reads through the forge; 'checkout' reads through git exec in
|
|
59
|
+
* the dispatcher's checkout (build branches are refs of the shared repo). */
|
|
60
|
+
mode: 'origin' | 'checkout'
|
|
61
|
+
/** Origin-mode read capability. Required to read through the forge; absent
|
|
62
|
+
* degrades to the recorded base / legacy fallback. */
|
|
63
|
+
forge?: Forge
|
|
64
|
+
/** Checkout-mode seam: the physical main checkout and exec. */
|
|
65
|
+
checkout?: string
|
|
66
|
+
exec?: Exec
|
|
67
|
+
/** Checkout-mode seam: the open build worktree, whose `autobuild.toml` is
|
|
68
|
+
* the branch head. Preferred over `git show` when the file exists. */
|
|
69
|
+
workspacePath?: string
|
|
70
|
+
/** Optional degradation sink: one failure per read/parse attempt that forced
|
|
71
|
+
* a fallback, in order. Callers surface the actionable ones as per-build
|
|
72
|
+
* diagnostics (SPEC §16.1) instead of the resolver throwing. */
|
|
73
|
+
onFailure?: (failure: PipelineSourceFailure) => void
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Why a resolution degraded. `unpublished`, `base-source` and `no-base` are
|
|
77
|
+
* the documented fallback order (a build whose branch is not published yet, a
|
|
78
|
+
* legacy build with no readable recorded base) — normal operation, quiet.
|
|
79
|
+
* `capability` and `branch-source` are failures an operator can act on: a
|
|
80
|
+
* wiring gap, or a build-branch source that resolved but cannot be read or
|
|
81
|
+
* parsed. */
|
|
82
|
+
export type PipelineSourceFailureKind =
|
|
83
|
+
| 'capability'
|
|
84
|
+
| 'unpublished'
|
|
85
|
+
| 'branch-source'
|
|
86
|
+
| 'base-source'
|
|
87
|
+
| 'no-base'
|
|
88
|
+
| 'store'
|
|
89
|
+
|
|
90
|
+
export interface PipelineSourceFailure {
|
|
91
|
+
kind: PipelineSourceFailureKind
|
|
92
|
+
detail: string
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The failure kinds worth an operator-facing diagnostic: a wiring gap, a
|
|
96
|
+
* build-branch source that resolved but cannot be read or parsed, or a store
|
|
97
|
+
* failure — each something an operator (or the build's author) can fix. */
|
|
98
|
+
export function isActionablePipelineFailure(failure: PipelineSourceFailure): boolean {
|
|
99
|
+
return (
|
|
100
|
+
failure.kind === 'capability' || failure.kind === 'branch-source' || failure.kind === 'store'
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The recorded base commit of the build's latest workspace. The newest
|
|
105
|
+
* `workspace.provisioned` wins: a re-provisioned workspace is what the build's
|
|
106
|
+
* current environment was cut from. */
|
|
107
|
+
export function recordedBaseSha(events: readonly AbEvent[]): string | undefined {
|
|
108
|
+
let base: string | undefined
|
|
109
|
+
for (const event of events) {
|
|
110
|
+
if (event.type === 'workspace.provisioned') base = event.payload.base.sha
|
|
111
|
+
}
|
|
112
|
+
return base
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function buildBranch(record: Pick<BuildRecord, 'branch'>, slug: string): string {
|
|
116
|
+
return record.branch ?? `ab/${slug}`
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function git(
|
|
120
|
+
args: string[],
|
|
121
|
+
cwd: string,
|
|
122
|
+
exec: Exec,
|
|
123
|
+
): Promise<{ stdout: string; exitCode: number; stderr: string }> {
|
|
124
|
+
return exec(['git', ...args], { cwd })
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function readCheckoutFile(
|
|
128
|
+
ref: string,
|
|
129
|
+
path: string,
|
|
130
|
+
checkout: string,
|
|
131
|
+
exec: Exec,
|
|
132
|
+
): Promise<string> {
|
|
133
|
+
const result = await git(['show', `${ref}:${path}`], checkout, exec)
|
|
134
|
+
if (result.exitCode !== 0) {
|
|
135
|
+
throw new Error(`git show ${ref}:${path} failed: ${result.stderr.trim() || '(no output)'}`)
|
|
136
|
+
}
|
|
137
|
+
return result.stdout
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function message(error: unknown): string {
|
|
141
|
+
return error instanceof Error ? error.message : String(error)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Parse a read file into a pipeline source, or `undefined` when the TOML is
|
|
145
|
+
* malformed. A branch-source parse failure is terminal: the build's own branch
|
|
146
|
+
* exists but carries an unparseable pipeline, so falling back to the recorded
|
|
147
|
+
* base would run the build under a pipeline that is not its own — the AUT-366
|
|
148
|
+
* poisoning by another door. The caller keeps the build's last good deposit
|
|
149
|
+
* instead (SPEC §16.1). */
|
|
150
|
+
function parsePipeline(
|
|
151
|
+
commit: string | undefined,
|
|
152
|
+
ref: PipelineSourceRef,
|
|
153
|
+
content: string,
|
|
154
|
+
onFailure: (failure: PipelineSourceFailure) => void,
|
|
155
|
+
): PipelineSource | undefined {
|
|
156
|
+
const label = commit === undefined ? ref : `${ref}@${commit}`
|
|
157
|
+
try {
|
|
158
|
+
return {
|
|
159
|
+
meta: commit === undefined ? { ref } : { ref, commit },
|
|
160
|
+
config: parseConfig(content, `autobuild.toml@${label}`),
|
|
161
|
+
}
|
|
162
|
+
} catch (error) {
|
|
163
|
+
onFailure({
|
|
164
|
+
// `worktree-dirty` is a branch-source failure: the malformed file sits on
|
|
165
|
+
// the build's own side (its open worktree), so a base fallback would run
|
|
166
|
+
// the build under a pipeline that is not its own — same as `branch-head`.
|
|
167
|
+
kind: ref === 'base' ? 'base-source' : 'branch-source',
|
|
168
|
+
detail: `malformed autobuild.toml at ${label}: ${message(error)}`,
|
|
169
|
+
})
|
|
170
|
+
return undefined
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Resolve the pipeline source for one build, or `undefined` for the legacy
|
|
176
|
+
* fallback. Every failure — missing capability, missing base record, unread
|
|
177
|
+
* file, malformed TOML — degrades to the fallback instead of failing the
|
|
178
|
+
* caller: a config deposit is never allowed to fail a dispatcher tick or a
|
|
179
|
+
* launch.
|
|
180
|
+
*
|
|
181
|
+
* The resolution rule (SPEC §16.1): the build's branch head once it resolves
|
|
182
|
+
* (origin mode: the forge's remote branch; checkout mode: the shared repo's
|
|
183
|
+
* branch ref), else the workspace's recorded base commit. The caller re-
|
|
184
|
+
* resolves at every launch, so a build that publishes pipeline changes to its
|
|
185
|
+
* own branch picks them up at its next launch.
|
|
186
|
+
*/
|
|
187
|
+
export async function resolvePipelineSource(
|
|
188
|
+
input: PipelineSourceInput,
|
|
189
|
+
): Promise<PipelineSource | undefined> {
|
|
190
|
+
const { slug, record, events } = input
|
|
191
|
+
const onFailure = input.onFailure ?? (() => {})
|
|
192
|
+
const branch = buildBranch(record, slug)
|
|
193
|
+
const path = 'autobuild.toml'
|
|
194
|
+
|
|
195
|
+
if (input.mode === 'origin') {
|
|
196
|
+
const forge = input.forge
|
|
197
|
+
if (forge === undefined) {
|
|
198
|
+
onFailure({ kind: 'capability', detail: 'origin mode with no forge' })
|
|
199
|
+
return undefined
|
|
200
|
+
}
|
|
201
|
+
const { remoteBranchSha, readFile: forgeRead } = forge
|
|
202
|
+
if (remoteBranchSha === undefined || forgeRead === undefined) {
|
|
203
|
+
onFailure({
|
|
204
|
+
kind: 'capability',
|
|
205
|
+
detail:
|
|
206
|
+
`forge lacks the ${remoteBranchSha === undefined ? 'remoteBranchSha' : 'readFile'} ` +
|
|
207
|
+
'capability the pipeline-source read needs',
|
|
208
|
+
})
|
|
209
|
+
} else {
|
|
210
|
+
let head: string | undefined
|
|
211
|
+
try {
|
|
212
|
+
head = await remoteBranchSha.call(forge, branch)
|
|
213
|
+
} catch (error) {
|
|
214
|
+
// Branch not yet published (or the forge failed): fall through to the
|
|
215
|
+
// recorded base commit, which is exactly what the workspace was cut
|
|
216
|
+
// from. Surfaced as `unpublished` either way — the normal order.
|
|
217
|
+
onFailure({
|
|
218
|
+
kind: 'unpublished',
|
|
219
|
+
detail: `forge remoteBranchSha(${branch}) failed: ${message(error)}`,
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
if (head !== undefined) {
|
|
223
|
+
let content: string | undefined
|
|
224
|
+
try {
|
|
225
|
+
content = await forgeRead.call(forge, path, head)
|
|
226
|
+
} catch (error) {
|
|
227
|
+
// The branch resolved but its file did not come back: an actionable
|
|
228
|
+
// forge failure, not an unpublished branch.
|
|
229
|
+
onFailure({
|
|
230
|
+
kind: 'branch-source',
|
|
231
|
+
detail: `forge readFile(${path}, ${head}) failed: ${message(error)}`,
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
if (content !== undefined) {
|
|
235
|
+
// A malformed build-branch file is terminal: the build's own branch
|
|
236
|
+
// exists, so the recorded base is not its pipeline either.
|
|
237
|
+
return parsePipeline(head, 'branch-head', content, onFailure)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const baseSha = recordedBaseSha(events)
|
|
242
|
+
if (baseSha === undefined) {
|
|
243
|
+
onFailure({ kind: 'no-base', detail: 'no recorded base commit (legacy build)' })
|
|
244
|
+
return undefined
|
|
245
|
+
}
|
|
246
|
+
if (forgeRead === undefined) return undefined
|
|
247
|
+
let baseContent: string | undefined
|
|
248
|
+
try {
|
|
249
|
+
baseContent = await forgeRead.call(forge, path, baseSha)
|
|
250
|
+
} catch (error) {
|
|
251
|
+
// Base file unreadable (legacy logs, deleted history): legacy fallback.
|
|
252
|
+
onFailure({
|
|
253
|
+
kind: 'base-source',
|
|
254
|
+
detail: `forge readFile(${path}, base ${baseSha}) failed: ${message(error)}`,
|
|
255
|
+
})
|
|
256
|
+
return undefined
|
|
257
|
+
}
|
|
258
|
+
return parsePipeline(baseSha, 'base', baseContent, onFailure)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const { checkout, exec, workspacePath } = input
|
|
262
|
+
if (checkout === undefined || exec === undefined) {
|
|
263
|
+
onFailure({ kind: 'capability', detail: 'checkout mode with no checkout or exec seam' })
|
|
264
|
+
return undefined
|
|
265
|
+
}
|
|
266
|
+
// The build branch is a ref of the shared repository: it exists from
|
|
267
|
+
// provision time (cut from base) and survives workspace release, so the
|
|
268
|
+
// committed head is readable at every launch boundary — including the
|
|
269
|
+
// relaunch after finalize. The recorded commit is the exact sha read.
|
|
270
|
+
let head: string | undefined
|
|
271
|
+
try {
|
|
272
|
+
const resolved = await git(['rev-parse', '--verify', `refs/heads/${branch}`], checkout, exec)
|
|
273
|
+
if (resolved.exitCode === 0) {
|
|
274
|
+
head = resolved.stdout.trim()
|
|
275
|
+
} else {
|
|
276
|
+
// Unpublished branch: the normal order, resolved from the base next.
|
|
277
|
+
onFailure({
|
|
278
|
+
kind: 'unpublished',
|
|
279
|
+
detail: `git rev-parse refs/heads/${branch} failed: ${
|
|
280
|
+
resolved.stderr.trim() || '(no output)'
|
|
281
|
+
}`,
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
} catch (error) {
|
|
285
|
+
// Fall through to the recorded base commit.
|
|
286
|
+
onFailure({
|
|
287
|
+
kind: 'unpublished',
|
|
288
|
+
detail: `git rev-parse refs/heads/${branch} failed: ${message(error)}`,
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
if (head !== undefined) {
|
|
292
|
+
// Checkout mode's primary read is the open build worktree's file — it is
|
|
293
|
+
// the branch head. The committed ref read is the released-workspace
|
|
294
|
+
// fallback, and the recorded commit stays the resolved branch head.
|
|
295
|
+
if (workspacePath !== undefined) {
|
|
296
|
+
try {
|
|
297
|
+
const content = await readFile(join(workspacePath, path), 'utf8')
|
|
298
|
+
// The worktree file may carry uncommitted edits, so it is only the
|
|
299
|
+
// branch head's when git sees no difference between them. Compare
|
|
300
|
+
// git-normalized identity, not raw bytes: the stored blob can differ
|
|
301
|
+
// textually from the worktree file (core.autocrlf, eol attributes)
|
|
302
|
+
// while carrying the same content, so byte equality against `git show`
|
|
303
|
+
// output would misclassify a clean worktree as dirty. Hash the
|
|
304
|
+
// worktree file the way git would store it — `git hash-object` run in
|
|
305
|
+
// the workspace applies the same clean conversion as check-in — and
|
|
306
|
+
// compare against the committed blob's oid. Equal → attribute to the
|
|
307
|
+
// head commit as before; different bytes, a file the head never
|
|
308
|
+
// carried, or any failed comparison (a non-git workspace, a transient
|
|
309
|
+
// git error) → `worktree-dirty` with no commit: the provenance
|
|
310
|
+
// declines to attribute worktree bytes to any commit. A comparison
|
|
311
|
+
// failure is caught here, not by the committed-ref fallback below —
|
|
312
|
+
// it degrades to dirty, never to a different source.
|
|
313
|
+
let clean = false
|
|
314
|
+
try {
|
|
315
|
+
const [committedBlob, worktreeBlob] = await Promise.all([
|
|
316
|
+
git(['rev-parse', '--verify', `${head}:${path}`], checkout, exec),
|
|
317
|
+
exec(['git', 'hash-object', '--', path], { cwd: workspacePath }),
|
|
318
|
+
])
|
|
319
|
+
clean =
|
|
320
|
+
committedBlob.exitCode === 0 &&
|
|
321
|
+
worktreeBlob.exitCode === 0 &&
|
|
322
|
+
committedBlob.stdout.trim() === worktreeBlob.stdout.trim()
|
|
323
|
+
} catch {
|
|
324
|
+
clean = false
|
|
325
|
+
}
|
|
326
|
+
if (clean) {
|
|
327
|
+
// A malformed worktree file is terminal: the committed ref carries the
|
|
328
|
+
// same content, so the recorded base is not the build's pipeline
|
|
329
|
+
// either. The caller keeps the build's last good deposit.
|
|
330
|
+
return parsePipeline(head, 'branch-head', content, onFailure)
|
|
331
|
+
}
|
|
332
|
+
return parsePipeline(undefined, 'worktree-dirty', content, onFailure)
|
|
333
|
+
} catch (error) {
|
|
334
|
+
// Workspace released or unreadable: read the committed branch head.
|
|
335
|
+
onFailure({
|
|
336
|
+
kind: 'unpublished',
|
|
337
|
+
detail: `workspace file ${join(workspacePath, path)} unreadable: ${message(error)}`,
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
let content: string | undefined
|
|
342
|
+
try {
|
|
343
|
+
content = await readCheckoutFile(head, path, checkout, exec)
|
|
344
|
+
} catch (error) {
|
|
345
|
+
// The ref resolved but its file did not come back: corruption or an
|
|
346
|
+
// exec failure — actionable, unlike an unpublished branch.
|
|
347
|
+
onFailure({
|
|
348
|
+
kind: 'branch-source',
|
|
349
|
+
detail: `git show ${head}:${path} failed: ${message(error)}`,
|
|
350
|
+
})
|
|
351
|
+
}
|
|
352
|
+
if (content !== undefined) {
|
|
353
|
+
// Malformed committed branch file: terminal, as above.
|
|
354
|
+
const parsed = parsePipeline(head, 'branch-head', content, onFailure)
|
|
355
|
+
if (parsed === undefined) return undefined
|
|
356
|
+
return parsed
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
const baseSha = recordedBaseSha(events)
|
|
360
|
+
if (baseSha === undefined) {
|
|
361
|
+
onFailure({ kind: 'no-base', detail: 'no recorded base commit (legacy build)' })
|
|
362
|
+
return undefined
|
|
363
|
+
}
|
|
364
|
+
let baseContent: string | undefined
|
|
365
|
+
try {
|
|
366
|
+
baseContent = await readCheckoutFile(baseSha, path, checkout, exec)
|
|
367
|
+
} catch (error) {
|
|
368
|
+
// Base file unreadable: legacy fallback.
|
|
369
|
+
onFailure({
|
|
370
|
+
kind: 'base-source',
|
|
371
|
+
detail: `git show ${baseSha}:${path} failed: ${message(error)}`,
|
|
372
|
+
})
|
|
373
|
+
return undefined
|
|
374
|
+
}
|
|
375
|
+
return parsePipeline(baseSha, 'base', baseContent, onFailure)
|
|
376
|
+
}
|
|
@@ -14,9 +14,16 @@
|
|
|
14
14
|
import { z } from 'zod'
|
|
15
15
|
import { prImageHostSchema } from '../ontology'
|
|
16
16
|
import { SANDBOX_FORBIDDEN_ENV } from '../ports/workspace/operator-sandbox'
|
|
17
|
+
import {
|
|
18
|
+
runtimeProvisioningMap,
|
|
19
|
+
runtimeProvisioningMissingMessage,
|
|
20
|
+
sandboxForbiddenEnvMessage,
|
|
21
|
+
} from '../ports/workspace/provider-capabilities'
|
|
17
22
|
import { defineEntry, openMap, ownEntries, parseEntry } from '../open-map'
|
|
18
23
|
import { forwardIssues } from '../zod-issues'
|
|
19
|
-
import {
|
|
24
|
+
import { compileEventGlobs } from '../events/globs'
|
|
25
|
+
import { REPOSITORY_ATTENTION_EVENTS } from '../events/repository'
|
|
26
|
+
import { effectiveRuntimeReferences } from './roles'
|
|
20
27
|
|
|
21
28
|
// ── Open maps ────────────────────────────────────────────────────────────────
|
|
22
29
|
|
|
@@ -242,6 +249,70 @@ export const workspaceSchema = z.strictObject({
|
|
|
242
249
|
})
|
|
243
250
|
export type WorkspaceConfig = z.infer<typeof workspaceSchema>
|
|
244
251
|
|
|
252
|
+
/**
|
|
253
|
+
* The parse-time subset of workspace-provider behavior a builtin declares
|
|
254
|
+
* (AUT-516). This table lives in `config/schema.ts` because the
|
|
255
|
+
* `[workspace.config]` validation runs inside `configSchema`'s
|
|
256
|
+
* `superRefine` — at parse time, long before plugins load, in processes that
|
|
257
|
+
* never touch the registry. The full capability objects (including the
|
|
258
|
+
* registry-seam declarations) are assembled in
|
|
259
|
+
* `ports/workspace/builtin-capabilities.ts`, which takes this subset from
|
|
260
|
+
* here — a parity now enforced by
|
|
261
|
+
* `ports/workspace/builtin-capabilities.test.ts` (AUT-573), so a builtin
|
|
262
|
+
* added to only one table fails tests rather than compiling silently. The
|
|
263
|
+
* construction-site refusal text differs by design from the
|
|
264
|
+
* parse-site one, so it is NOT copied from this table.
|
|
265
|
+
*/
|
|
266
|
+
export interface WorkspaceProviderConfigDeclaration {
|
|
267
|
+
/** Strict schema applied to `[workspace.config]`. A builtin declaration
|
|
268
|
+
* carries EXACTLY ONE of `configSchema` / `configRefusalMessage`: when the
|
|
269
|
+
* schema is absent, the declaration must carry `configRefusalMessage` for
|
|
270
|
+
* nonempty provider config to be refused (the neither-key side is pinned by
|
|
271
|
+
* the AUT-565 test in config.test.ts), and carrying BOTH is forbidden —
|
|
272
|
+
* `configSchema`'s superRefine checks `configRefusalMessage` first, so the
|
|
273
|
+
* refusal branch would win and the schema would silently never apply (the
|
|
274
|
+
* both-keys side is pinned by its sibling test). Providers absent from
|
|
275
|
+
* `BUILTIN_WORKSPACE_PROVIDER_CONFIG` are plugin-owned pass-throughs and
|
|
276
|
+
* are never refused here. */
|
|
277
|
+
configSchema?: z.ZodType
|
|
278
|
+
/** Parse-site refusal text for a provider that rejects `[workspace.config]`
|
|
279
|
+
* outright. This is the sole parse-site refusal key: a declaration without
|
|
280
|
+
* it never refuses `[workspace.config]` here, with or without a schema.
|
|
281
|
+
* Distinct from the capability's construction-site `configRefusal`. */
|
|
282
|
+
configRefusalMessage?: string
|
|
283
|
+
/** Referenced runtimes must have `[workspace.config.runtimeProvisioning]`
|
|
284
|
+
* entries (checked only once roles, verify, and finalize are all present). */
|
|
285
|
+
requireRuntimeProvisioning?: boolean
|
|
286
|
+
/** Names added to the operator-sandbox forbidden-forwarding set beyond
|
|
287
|
+
* `SANDBOX_FORBIDDEN_ENV`. */
|
|
288
|
+
sandboxForbiddenEnv?: readonly string[]
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Parse-time behavior of every builtin workspace provider, keyed by the
|
|
292
|
+
* provider name an operator writes in `[workspace].provider`. No provider
|
|
293
|
+
* name appears in any validation branch — every check reads this table. */
|
|
294
|
+
export const BUILTIN_WORKSPACE_PROVIDER_CONFIG: ReadonlyMap<
|
|
295
|
+
string,
|
|
296
|
+
WorkspaceProviderConfigDeclaration
|
|
297
|
+
> = new Map([
|
|
298
|
+
[
|
|
299
|
+
'git-worktree',
|
|
300
|
+
{
|
|
301
|
+
configRefusalMessage:
|
|
302
|
+
'[workspace.config] is not supported by the builtin "git-worktree" provider — remove it or select a plugin workspace provider',
|
|
303
|
+
},
|
|
304
|
+
],
|
|
305
|
+
[
|
|
306
|
+
'vercel-sandbox',
|
|
307
|
+
{
|
|
308
|
+
configSchema: vercelSandboxConfigSchema,
|
|
309
|
+
requireRuntimeProvisioning: true,
|
|
310
|
+
// Its credentials are already in SANDBOX_FORBIDDEN_ENV.
|
|
311
|
+
sandboxForbiddenEnv: [],
|
|
312
|
+
},
|
|
313
|
+
],
|
|
314
|
+
])
|
|
315
|
+
|
|
245
316
|
// ── [commands] ───────────────────────────────────────────────────────────────
|
|
246
317
|
//
|
|
247
318
|
// Open map of deterministic verbs the kernel may run. `setup`, `lint`,
|
|
@@ -534,9 +605,107 @@ export const orchestratorSandboxSchema = z.strictObject({
|
|
|
534
605
|
})
|
|
535
606
|
export type OrchestratorSandboxConfig = z.infer<typeof orchestratorSandboxSchema>
|
|
536
607
|
|
|
608
|
+
/** The turn runner lives inside the hosted operator route and the dispatcher
|
|
609
|
+
* tick, both of which run under a Vercel function duration limit the config's
|
|
610
|
+
* invocation budget can never exceed: a value above this is clamped (not
|
|
611
|
+
* rejected) at parse time so a mis-set budget degrades to the limit instead of
|
|
612
|
+
* failing every deploy. The operator route pins `maxDuration` to this same
|
|
613
|
+
* constant, so one number is cited by the config, the route, and the docs. */
|
|
614
|
+
export const ORCHESTRATOR_ROUTE_LIMIT_SECONDS = 300
|
|
615
|
+
|
|
616
|
+
/** Turn budget floor for the dispatcher tick's orchestrator step: when less
|
|
617
|
+
* than this many seconds of tick budget remain, the step skips the pass
|
|
618
|
+
* entirely (suspension state is durable, so skipping is always safe). */
|
|
619
|
+
export const ORCHESTRATOR_MIN_TURN_SECONDS = 30
|
|
620
|
+
|
|
621
|
+
/** The turn approval entries' shape: a registry tool name, optionally
|
|
622
|
+
* qualified by the discriminator field's accepted value (`tool:qualifier`).
|
|
623
|
+
* Validation is syntactic only — an entry naming an absent or unknown tool is
|
|
624
|
+
* inert by design (documented in docs/configuration.md), because the list
|
|
625
|
+
* is meant to stay stable while the registry grows — e.g. `sandbox.publish`
|
|
626
|
+
* was named here before its ticket landed, and stays inert in bindings
|
|
627
|
+
* where no sandbox backend is bound (the hosted default). */
|
|
628
|
+
const ORCHESTRATOR_APPROVAL_ENTRY = /^[a-z][a-z0-9_.]*(:[a-z0-9_-]+)?$/
|
|
629
|
+
|
|
630
|
+
/** The attention set a new session's wake settings inherit when the
|
|
631
|
+
* repository does not override `wake`: the build events an operator or agent
|
|
632
|
+
* must wake up for (the same set `ab watch` filters on by default) plus the
|
|
633
|
+
* repository-journal attention events (the same set `ab watch --repository`
|
|
634
|
+
* follows), spelled as literal globs. Both halves of the attention set can
|
|
635
|
+
* wake a session. */
|
|
636
|
+
export const defaultOrchestratorWakeGlobs: readonly string[] = [
|
|
637
|
+
'escalation.raised',
|
|
638
|
+
'phase.failed',
|
|
639
|
+
'infrastructure.failed',
|
|
640
|
+
'runner.setup-failed',
|
|
641
|
+
'dispatch.failed',
|
|
642
|
+
'publication.lost',
|
|
643
|
+
'finalize.completed',
|
|
644
|
+
'pr.conflicted',
|
|
645
|
+
'pr.merged',
|
|
646
|
+
'pr.closed',
|
|
647
|
+
'build.completed',
|
|
648
|
+
'build.aborted',
|
|
649
|
+
...REPOSITORY_ATTENTION_EVENTS,
|
|
650
|
+
]
|
|
651
|
+
|
|
652
|
+
/** The registry tools a turn asks its operator about before executing.
|
|
653
|
+
* The maintainer sets this to `[]` for autonomous operation. */
|
|
654
|
+
export const defaultOrchestratorApprovals: readonly string[] = [
|
|
655
|
+
'builds.control:abort',
|
|
656
|
+
'builds.control:discard',
|
|
657
|
+
'builds.answer:revise-spec',
|
|
658
|
+
'sandbox.publish',
|
|
659
|
+
'tickets.move:ready',
|
|
660
|
+
]
|
|
661
|
+
|
|
662
|
+
/** A session's effective wake globs: the configured `wake` when declared
|
|
663
|
+
* (an explicit `[]` means never wake), else the default attention set. Kept
|
|
664
|
+
* as a resolver rather than a schema default so `wake`'s absence (inherit)
|
|
665
|
+
* and emptiness (never wake) stay distinct. */
|
|
666
|
+
export function orchestratorWakeGlobs(config: OrchestratorConfig): string[] {
|
|
667
|
+
return [...(config.wake ?? defaultOrchestratorWakeGlobs)]
|
|
668
|
+
}
|
|
669
|
+
|
|
537
670
|
export const orchestratorSchema = z.strictObject({
|
|
538
671
|
enabled: z.boolean().default(false),
|
|
539
672
|
sandbox: orchestratorSandboxSchema.prefault({}),
|
|
673
|
+
/** Provider-qualified model string for the turn runner, in the same
|
|
674
|
+
* vocabulary as `[roles]` model strings (nonblank; a leading
|
|
675
|
+
* `vercel-ai-gateway/` prefix is stripped before the gateway provider).
|
|
676
|
+
* Resolved through the deployment's gateway credential. Required when
|
|
677
|
+
* `enabled = true` (cross-validated below); the runner never starts a turn
|
|
678
|
+
* on a repository whose config omits it. */
|
|
679
|
+
model: z.string().min(1).optional(),
|
|
680
|
+
/** Wall-clock budget for one turn invocation, in seconds. Clamped — not
|
|
681
|
+
* rejected — to ORCHESTRATOR_ROUTE_LIMIT_SECONDS, the hosted function
|
|
682
|
+
* duration the runner runs under. */
|
|
683
|
+
invocationBudgetSeconds: z
|
|
684
|
+
.number()
|
|
685
|
+
.int()
|
|
686
|
+
.positive()
|
|
687
|
+
.default(240)
|
|
688
|
+
.transform((value) => Math.min(value, ORCHESTRATOR_ROUTE_LIMIT_SECONDS)),
|
|
689
|
+
/** Registry tool names (optionally `tool:qualifier`) whose calls suspend a
|
|
690
|
+
* turn until the operator answers. Empty means none. Entries naming an
|
|
691
|
+
* absent or unknown tool are inert by design, which is what keeps the
|
|
692
|
+
* default list stable while the registry grows. */
|
|
693
|
+
approvals: z
|
|
694
|
+
.array(
|
|
695
|
+
z
|
|
696
|
+
.string()
|
|
697
|
+
.regex(
|
|
698
|
+
ORCHESTRATOR_APPROVAL_ENTRY,
|
|
699
|
+
'approval entries look like "tool" or "tool:qualifier"',
|
|
700
|
+
),
|
|
701
|
+
)
|
|
702
|
+
.default([...defaultOrchestratorApprovals]),
|
|
703
|
+
/** Wake-filter globs a NEW session inherits. Kept schema-optional (not
|
|
704
|
+
* `.default()`) so absent ("inherit the default attention set") and `[]`
|
|
705
|
+
* ("never wake") stay distinct. Each glob must match at least one build
|
|
706
|
+
* or repository-journal event type — validated below with the shared
|
|
707
|
+
* `ab watch` compiler. */
|
|
708
|
+
wake: z.array(z.string().min(1)).optional(),
|
|
540
709
|
})
|
|
541
710
|
export type OrchestratorConfig = z.infer<typeof orchestratorSchema>
|
|
542
711
|
|
|
@@ -614,33 +783,34 @@ export const configSchema = configRootSchema.superRefine((config, ctx) => {
|
|
|
614
783
|
})
|
|
615
784
|
})
|
|
616
785
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
'
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
786
|
+
const workspaceDeclaration = BUILTIN_WORKSPACE_PROVIDER_CONFIG.get(config.workspace.provider)
|
|
787
|
+
// The parse-site refusal fires only for a declaration carrying
|
|
788
|
+
// `configRefusalMessage`; providers absent from the table are plugin-owned
|
|
789
|
+
// pass-throughs and are never refused here.
|
|
790
|
+
if (workspaceDeclaration?.configRefusalMessage !== undefined) {
|
|
791
|
+
if (Object.keys(config.workspace.config).length > 0) {
|
|
792
|
+
ctx.addIssue({
|
|
793
|
+
code: 'custom',
|
|
794
|
+
path: ['workspace', 'config'],
|
|
795
|
+
message: workspaceDeclaration.configRefusalMessage,
|
|
796
|
+
})
|
|
797
|
+
}
|
|
798
|
+
} else if (workspaceDeclaration?.configSchema !== undefined) {
|
|
799
|
+
const parsed = workspaceDeclaration.configSchema.safeParse(config.workspace.config)
|
|
629
800
|
if (!parsed.success) {
|
|
630
801
|
forwardIssues(parsed.error.issues, ctx, ['workspace', 'config'])
|
|
631
802
|
} else if (
|
|
803
|
+
workspaceDeclaration.requireRuntimeProvisioning === true &&
|
|
632
804
|
config.roles !== undefined &&
|
|
633
805
|
config.verify !== undefined &&
|
|
634
806
|
config.finalize !== undefined
|
|
635
807
|
) {
|
|
636
808
|
for (const group of effectiveRuntimeReferences(config)) {
|
|
637
|
-
if (Object.hasOwn(parsed.data
|
|
809
|
+
if (Object.hasOwn(runtimeProvisioningMap(parsed.data), group.runtime)) continue
|
|
638
810
|
ctx.addIssue({
|
|
639
811
|
code: 'custom',
|
|
640
812
|
path: ['workspace', 'config', 'runtimeProvisioning', group.runtime],
|
|
641
|
-
message:
|
|
642
|
-
`runtime ${displayName(group.runtime)} is selected by ${group.references.join(', ')} but has no sandbox provisioning; add ` +
|
|
643
|
-
`[workspace.config.runtimeProvisioning.${tomlKey(group.runtime)}] with nonblank install and preflight commands`,
|
|
813
|
+
message: runtimeProvisioningMissingMessage(group),
|
|
644
814
|
})
|
|
645
815
|
}
|
|
646
816
|
}
|
|
@@ -758,13 +928,54 @@ export const configSchema = configRootSchema.superRefine((config, ctx) => {
|
|
|
758
928
|
|
|
759
929
|
// The credential-free sandbox rule (AUT-340): a forwarded variable name may
|
|
760
930
|
// never name a store, forge, ticket-provider, model, or Vercel credential.
|
|
931
|
+
// A workspace provider can declare additional forbidden names beyond the
|
|
932
|
+
// shared set. This check is deliberately builtin-only: plugins are not
|
|
933
|
+
// loaded when config is parsed, so a plugin provider's declared names cannot
|
|
934
|
+
// be seen here (AUT-536). The deferral is accepted, not an oversight — the
|
|
935
|
+
// declared extras are enforced at the registry-aware seams instead,
|
|
936
|
+
// `createWorkspaceProvider` (construction) and `validateInitReadiness`
|
|
937
|
+
// (init validation), both of which run after plugin load; no site loads
|
|
938
|
+
// plugins earlier to widen parse-time coverage.
|
|
939
|
+
// Wake globs are validated with the same compiler `ab watch` uses, over the
|
|
940
|
+
// build AND repository-journal catalogs — the wake pass scans both — so a
|
|
941
|
+
// typo'd glob fails at parse time instead of silently matching nothing —
|
|
942
|
+
// regardless of `enabled`, since a later enablement would inherit the
|
|
943
|
+
// already-broken value.
|
|
944
|
+
if (config.orchestrator.wake !== undefined) {
|
|
945
|
+
try {
|
|
946
|
+
compileEventGlobs(config.orchestrator.wake, {
|
|
947
|
+
repository: true,
|
|
948
|
+
usage: '[orchestrator].wake',
|
|
949
|
+
})
|
|
950
|
+
} catch (error) {
|
|
951
|
+
ctx.addIssue({
|
|
952
|
+
code: 'custom',
|
|
953
|
+
path: ['orchestrator', 'wake'],
|
|
954
|
+
message: error instanceof Error ? error.message : String(error),
|
|
955
|
+
})
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
761
959
|
if (config.orchestrator.enabled) {
|
|
960
|
+
// The turn runner's model is not optional when the orchestrator is on:
|
|
961
|
+
// an enabled table without one would fail at first turn start, far from
|
|
962
|
+
// the edit that caused it.
|
|
963
|
+
if (config.orchestrator.model === undefined) {
|
|
964
|
+
ctx.addIssue({
|
|
965
|
+
code: 'custom',
|
|
966
|
+
path: ['orchestrator', 'model'],
|
|
967
|
+
message:
|
|
968
|
+
'[orchestrator].enabled = true requires [orchestrator].model — name the provider-qualified model string the turn runner uses (same vocabulary as [roles], resolved through the deployment gateway credential)',
|
|
969
|
+
})
|
|
970
|
+
}
|
|
971
|
+
const providerForbidden =
|
|
972
|
+
BUILTIN_WORKSPACE_PROVIDER_CONFIG.get(config.workspace.provider)?.sandboxForbiddenEnv ?? []
|
|
762
973
|
config.orchestrator.sandbox.environmentVariables.forEach((name, index) => {
|
|
763
|
-
if (SANDBOX_FORBIDDEN_ENV.includes(name)) {
|
|
974
|
+
if (SANDBOX_FORBIDDEN_ENV.includes(name) || providerForbidden.includes(name)) {
|
|
764
975
|
ctx.addIssue({
|
|
765
976
|
code: 'custom',
|
|
766
977
|
path: ['orchestrator', 'sandbox', 'environmentVariables', index],
|
|
767
|
-
message:
|
|
978
|
+
message: sandboxForbiddenEnvMessage(name),
|
|
768
979
|
})
|
|
769
980
|
}
|
|
770
981
|
})
|