@defrex/autobuild 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +313 -26
  3. package/bin/ab-build-runner.ts +8 -2
  4. package/docs/architecture.md +57 -14
  5. package/docs/assets/headline-wide.png +0 -0
  6. package/docs/configuration.md +151 -25
  7. package/docs/hosted-dispatcher.md +69 -13
  8. package/docs/operator-api.md +2 -2
  9. package/docs/remote-store-protocol.md +169 -16
  10. package/docs/remote-ticket-protocol.md +1 -1
  11. package/docs/setup.md +12 -9
  12. package/docs/website.md +10 -0
  13. package/package.json +43 -23
  14. package/packages/core/src/cli/artifact.ts +130 -6
  15. package/packages/core/src/cli/binary.ts +9 -1
  16. package/packages/core/src/cli/build-control.ts +110 -13
  17. package/packages/core/src/cli/context.ts +46 -0
  18. package/packages/core/src/cli/dashboard/model.ts +57 -7
  19. package/packages/core/src/cli/dashboard/poll.ts +69 -10
  20. package/packages/core/src/cli/dashboard/render.ts +34 -5
  21. package/packages/core/src/cli/dispatch-frontend.ts +54 -5
  22. package/packages/core/src/cli/dispatch.ts +320 -46
  23. package/packages/core/src/cli/harvest.ts +122 -8
  24. package/packages/core/src/cli/help.ts +47 -8
  25. package/packages/core/src/cli/init-readiness-remote.ts +110 -0
  26. package/packages/core/src/cli/init-readiness-shared.ts +72 -0
  27. package/packages/core/src/cli/init-validation.ts +248 -222
  28. package/packages/core/src/cli/main.ts +70 -11
  29. package/packages/core/src/cli/mcp.ts +23 -1
  30. package/packages/core/src/cli/plugin.ts +7 -4
  31. package/packages/core/src/cli/remote-poll.ts +277 -0
  32. package/packages/core/src/cli/repo-state.ts +22 -0
  33. package/packages/core/src/cli/repository-status.ts +39 -1
  34. package/packages/core/src/cli/status.ts +149 -27
  35. package/packages/core/src/cli/terminals.ts +2 -1
  36. package/packages/core/src/cli/ticket-assets.ts +247 -0
  37. package/packages/core/src/cli/ticket.ts +307 -7
  38. package/packages/core/src/cli/wait.ts +147 -39
  39. package/packages/core/src/cli/watch.ts +128 -166
  40. package/packages/core/src/config/live.ts +22 -0
  41. package/packages/core/src/config/pipeline-source.ts +376 -0
  42. package/packages/core/src/config/schema.ts +230 -19
  43. package/packages/core/src/events/catalog.ts +1 -0
  44. package/packages/core/src/events/globs.ts +47 -0
  45. package/packages/core/src/events/payloads.ts +52 -2
  46. package/packages/core/src/events/repository.ts +52 -1
  47. package/packages/core/src/events/sessions.ts +69 -10
  48. package/packages/core/src/events/workspace-remote.ts +12 -0
  49. package/packages/core/src/integration/harness.ts +2 -1
  50. package/packages/core/src/kernel/auto-merge-default.ts +123 -0
  51. package/packages/core/src/kernel/phases.ts +16 -2
  52. package/packages/core/src/kernel/reducer.ts +40 -1
  53. package/packages/core/src/operator/annotations.ts +6 -0
  54. package/packages/core/src/operator/control.ts +19 -5
  55. package/packages/core/src/operator/index.ts +71 -6
  56. package/packages/core/src/operator/query.ts +208 -19
  57. package/packages/core/src/operator/registry.ts +55 -10
  58. package/packages/core/src/operator/requests.ts +9 -6
  59. package/packages/core/src/operator/sandbox-host.ts +140 -0
  60. package/packages/core/src/operator/sandbox.ts +247 -14
  61. package/packages/core/src/operator/ticket-source-backend.ts +47 -0
  62. package/packages/core/src/orchestrator/approvals.ts +115 -0
  63. package/packages/core/src/orchestrator/conversation.ts +167 -0
  64. package/packages/core/src/orchestrator/model.ts +86 -0
  65. package/packages/core/src/orchestrator/prompt.ts +61 -0
  66. package/packages/core/src/orchestrator/tools.ts +117 -0
  67. package/packages/core/src/orchestrator/turn-runner.ts +457 -0
  68. package/packages/core/src/plugin-sdk/index.ts +51 -1
  69. package/packages/core/src/plugins/load.ts +135 -7
  70. package/packages/core/src/plugins/manifest.ts +183 -10
  71. package/packages/core/src/plugins/registry.ts +73 -4
  72. package/packages/core/src/ports/forge/create.ts +1 -1
  73. package/packages/core/src/ports/forge/fake.ts +31 -0
  74. package/packages/core/src/ports/forge/github-transport.ts +13 -2
  75. package/packages/core/src/ports/forge/github.ts +13 -2
  76. package/packages/core/src/ports/forge/local-git.ts +31 -1
  77. package/packages/core/src/ports/runner/claude.ts +2 -2
  78. package/packages/core/src/ports/runner/codex.ts +6 -2
  79. package/packages/core/src/ports/runner/fake.ts +7 -1
  80. package/packages/core/src/ports/runner/pi.ts +8 -6
  81. package/packages/core/src/ports/runner/provider-error.ts +13 -3
  82. package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
  83. package/packages/core/src/ports/types.ts +17 -2
  84. package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
  85. package/packages/core/src/ports/workspace/create.ts +74 -39
  86. package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
  87. package/packages/core/src/ports/workspace/fake.ts +169 -4
  88. package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
  89. package/packages/core/src/ports/workspace/github-origin.ts +36 -0
  90. package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
  91. package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
  92. package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
  93. package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
  94. package/packages/core/src/processes/build-child.ts +17 -2
  95. package/packages/core/src/processes/build-execution-state.ts +37 -0
  96. package/packages/core/src/processes/build-runner.ts +54 -3
  97. package/packages/core/src/processes/dispatcher.ts +247 -41
  98. package/packages/core/src/processes/harvest-child.ts +5 -0
  99. package/packages/core/src/processes/harvest-runner.ts +66 -19
  100. package/packages/core/src/processes/harvest.ts +196 -24
  101. package/packages/core/src/processes/orchestrator-tick.ts +319 -0
  102. package/packages/core/src/processes/sandbox-state.ts +10 -0
  103. package/packages/core/src/store/adapter.ts +32 -0
  104. package/packages/core/src/store/build-scope.ts +64 -0
  105. package/packages/core/src/store/contract.ts +868 -0
  106. package/packages/core/src/store/digest.ts +49 -0
  107. package/packages/core/src/store/local/schema.ts +29 -2
  108. package/packages/core/src/store/local/store.ts +338 -4
  109. package/packages/core/src/store/memory.ts +179 -0
  110. package/packages/core/src/store/new-build.ts +17 -0
  111. package/packages/core/src/store/phase-session.ts +71 -0
  112. package/packages/core/src/store/remote/client.ts +260 -1
  113. package/packages/core/src/store/remote/index.ts +12 -2
  114. package/packages/core/src/store/remote/protocol.ts +100 -12
  115. package/packages/core/src/store/remote/version.ts +6 -2
  116. package/packages/core/src/store/repo-state-events.ts +140 -0
  117. package/packages/core/src/store/retention.ts +7 -0
  118. package/packages/core/src/store/session-handle.ts +64 -0
  119. package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +28 -9
  120. package/packages/core/src/store/streams/session-writer.ts +42 -2
  121. package/packages/core/src/store/streams/wait.ts +4 -5
  122. package/packages/core/src/store/ticket-assets.ts +425 -0
  123. package/packages/core/src/store/types.ts +122 -1
  124. package/packages/core/src/testing/index.ts +59 -0
  125. package/skills/code-review/SKILL.md +5 -1
  126. package/skills/guide/SKILL.md +126 -33
  127. package/skills/guide/references/operator-api.md +2 -2
  128. package/skills/guide/references/plugin-authoring.md +17 -0
  129. package/skills/guide/references/remote-store-protocol.md +169 -16
  130. package/skills/guide/references/remote-ticket-protocol.md +1 -1
  131. package/skills/guide/references/setup.md +12 -9
  132. package/skills/implement/SKILL.md +3 -0
  133. package/skills/operate/SKILL.md +126 -0
  134. package/skills/plan/SKILL.md +4 -0
  135. package/skills/plan-review/SKILL.md +5 -1
  136. package/skills/spec/SKILL.md +46 -3
  137. package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
  138. package/packages/core/src/cli/store-opening.contract.ts +0 -289
  139. package/packages/core/src/cli/testkit.ts +0 -194
  140. package/packages/core/src/markdown.ts +0 -67
  141. package/packages/core/src/operator/client.ts +0 -311
  142. package/packages/core/src/operator/server.ts +0 -616
  143. package/packages/core/src/operator/sessions.ts +0 -223
  144. package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
  145. package/packages/core/src/ports/tickets/remote-server.ts +0 -185
  146. package/packages/core/src/store/remote/server.ts +0 -923
  147. package/packages/core/src/testing/store-failures.ts +0 -32
@@ -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 { displayName, effectiveRuntimeReferences, tomlKey } from './roles'
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
- if (
618
- config.workspace.provider === 'git-worktree' &&
619
- Object.keys(config.workspace.config).length > 0
620
- ) {
621
- ctx.addIssue({
622
- code: 'custom',
623
- path: ['workspace', 'config'],
624
- message:
625
- '[workspace.config] is not supported by the builtin "git-worktree" provider — remove it or select a plugin workspace provider',
626
- })
627
- } else if (config.workspace.provider === 'vercel-sandbox') {
628
- const parsed = vercelSandboxConfigSchema.safeParse(config.workspace.config)
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.runtimeProvisioning, group.runtime)) continue
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: `environment variable ${JSON.stringify(name)} is a store, forge, ticket-provider, model, or Vercel credential and may never be forwarded into an operator sandbox`,
978
+ message: sandboxForbiddenEnvMessage(name),
768
979
  })
769
980
  }
770
981
  })