@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,457 @@
1
+ /**
2
+ * The orchestrator turn runner (AUT-342): a stateless agent loop over the
3
+ * operator tool registry. Every invocation reconstructs its whole
4
+ * conversation from durable state (`conversation.ts`), streams its output as
5
+ * protocol parts onto the turn's session-scoped stream, checkpoints at step
6
+ * boundaries via the stream sink's awaitable flush, and records its outcome
7
+ * as session facts. A turn that outlives one function invocation suspends on
8
+ * budget and resumes in a fresh invocation without losing a step; a tool in
9
+ * the approval list suspends the turn until the operator answers.
10
+ *
11
+ * Nothing here encodes this repository's specifics.
12
+ */
13
+ import {
14
+ ToolLoopAgent,
15
+ convertToModelMessages,
16
+ isStepCount,
17
+ toUIMessageChunk,
18
+ type LanguageModel,
19
+ } from 'ai'
20
+ import { createSessionStreamSink } from '../store/streams/session-writer'
21
+ import type { StreamPart } from '../store/streams/types'
22
+ import type { Config } from '../config/schema'
23
+ import { ORCHESTRATOR_ROUTE_LIMIT_SECONDS } from '../config/schema'
24
+ import type { OperatorToolRegistry } from '../operator/registry'
25
+ import { agentActor, type Via } from '../events/envelope'
26
+ import type { SessionEventEnvelope, SessionEventWrite, SessionEventType } from '../events/sessions'
27
+ import type { SessionTurnTrigger } from '../store/session-reducer'
28
+ import { reduceSession } from '../store/session-reducer'
29
+ import { validateExpectedSeq, type BuildStore, type Clock } from '../store/types'
30
+ import type { IdSource } from '../ids'
31
+ import { reconstructConversation } from './conversation'
32
+ import { buildTurnSystemPrompt } from './prompt'
33
+ import { registryTools } from './tools'
34
+ import { approvalConfiguration, parseApprovalEntries } from './approvals'
35
+ import { classifyModelError, resolveGatewayModel, type TurnFailureKind } from './model'
36
+ export type { TurnFailureKind }
37
+
38
+ /** Hard cap on tool-loop steps per invocation — a runaway loop stops here
39
+ * even when the budget condition somehow never fires. */
40
+ export const ORCHESTRATOR_MAX_STEPS = 32
41
+
42
+ /** Seconds of margin the budget condition reserves for the final durable
43
+ * flush and outcome writes: the stop condition fires slightly before the
44
+ * invocation deadline so the suspension lands durably. */
45
+ export const ORCHESTRATOR_FLUSH_MARGIN_SECONDS = 5
46
+
47
+ /** The wake input a wake trigger delivers: the attention event record and —
48
+ * for a build wake — the build's reduced state, frozen at wake time. A
49
+ * journal wake omits `buildState`. Embedded in the `turn.started` payload so
50
+ * reconstruction is byte-identical across invocations even though live build
51
+ * state keeps moving. */
52
+ export interface WakeTurnInput {
53
+ event: { seq: number; ts: string; type: string; payload: unknown }
54
+ buildState?: unknown
55
+ }
56
+
57
+ export interface TurnOutcome {
58
+ kind: 'completed' | 'suspended' | 'failed'
59
+ cause?: 'budget' | 'approval'
60
+ failureKind?: TurnFailureKind
61
+ usage?: { inputTokens: number; outputTokens: number; steps: number }
62
+ error?: string
63
+ }
64
+
65
+ export interface StartTurnOptions {
66
+ /** Remaining invocation budget in seconds (the tick's), clamping the
67
+ * configured `invocationBudgetSeconds` for this run. The tick recomputes
68
+ * it per session so every turn draws down the same deadline. */
69
+ remainingBudgetSeconds?: number
70
+ }
71
+
72
+ export interface StartTurnResult {
73
+ /** False when the session was not idle (or the CAS lost the race) — no
74
+ * turn was started and no loop is running. */
75
+ started: boolean
76
+ turn?: string
77
+ stream?: string
78
+ /** The running loop's outcome. The caller decides to await it (the tick)
79
+ * or background it (the operator route via `after()`); the promise starts
80
+ * executing immediately either way. */
81
+ outcome?: Promise<TurnOutcome>
82
+ }
83
+
84
+ export interface ResumeTurnOptions {
85
+ /** When the turn was suspended for approval and the operator has answered:
86
+ * the decision and the answered tool call id. The runner recovers the
87
+ * request's wire `approvalId` from the open stream and appends the
88
+ * protocol's `tool-approval-response` part so the SDK's resume path
89
+ * executes (approve) or synthesizes the denied output (deny). */
90
+ approval?: { decision: 'approve' | 'deny'; toolCallId: string }
91
+ /** Remaining invocation budget in seconds (the tick's), clamping the
92
+ * configured `invocationBudgetSeconds` for this run. */
93
+ remainingBudgetSeconds?: number
94
+ }
95
+
96
+ export interface ResumeTurnResult {
97
+ resumed: boolean
98
+ outcome?: Promise<TurnOutcome>
99
+ }
100
+
101
+ export interface OrchestratorTurnRunner {
102
+ startTurn(
103
+ sessionId: string,
104
+ trigger: SessionTurnTrigger,
105
+ wake?: WakeTurnInput,
106
+ opts?: StartTurnOptions,
107
+ ): Promise<StartTurnResult>
108
+ resumeTurn(sessionId: string, opts?: ResumeTurnOptions): Promise<ResumeTurnResult>
109
+ }
110
+
111
+ export interface OrchestratorRunnerOptions {
112
+ store: BuildStore
113
+ registry: OperatorToolRegistry
114
+ /** The repository this runner serves; the session's repository must match. */
115
+ repo: string
116
+ config: Config
117
+ clock: Clock
118
+ ids: IdSource
119
+ /** Injected language model; defaults to the configured gateway model. */
120
+ model?: LanguageModel
121
+ /** Remaining invocation budget in seconds (the tick's), clamping the
122
+ * configured `invocationBudgetSeconds` for turns this runner starts. */
123
+ remainingBudgetSeconds?: number
124
+ /** The agent's in-turn retry bound. Default 2 (the plan's bound); tests
125
+ * pass 0 to keep model-failure cases fast. */
126
+ maxRetries?: number
127
+ }
128
+
129
+ /** The agent actor every turn fact carries. */
130
+ function turnActor(turn: string) {
131
+ return agentActor('orchestrator', turn)
132
+ }
133
+
134
+ /** The session's reduced state and its event log's last sequence — the CAS
135
+ * expectation for the runner's own appends. */
136
+ async function reduceSessionState(
137
+ store: BuildStore,
138
+ sessionId: string,
139
+ ): Promise<{ state: ReturnType<typeof reduceSession>; lastSeq: number }> {
140
+ const events = await store.getSessionEvents(sessionId)
141
+ const state = reduceSession(events)
142
+ const lastSeq = events.length > 0 ? (events[events.length - 1]?.seq ?? 0) : 0
143
+ return { state, lastSeq }
144
+ }
145
+
146
+ export function createOrchestratorTurnRunner(
147
+ options: OrchestratorRunnerOptions,
148
+ ): OrchestratorTurnRunner {
149
+ const { store, registry, repo, config, clock, ids } = options
150
+ const approvals = parseApprovalEntries(config.orchestrator.approvals)
151
+ const approvalConfig = approvalConfiguration(approvals)
152
+ const budgetSeconds = Math.min(
153
+ config.orchestrator.invocationBudgetSeconds,
154
+ ORCHESTRATOR_ROUTE_LIMIT_SECONDS,
155
+ )
156
+
157
+ function modelFor(): LanguageModel {
158
+ return options.model ?? resolveGatewayModel(config.orchestrator.model ?? '')
159
+ }
160
+
161
+ /** The invocation deadline: min(configured budget, caller-supplied
162
+ * remaining budget) in wall-clock milliseconds from now. */
163
+ function deadlineMs(remainingBudgetSeconds?: number): number {
164
+ const effective =
165
+ remainingBudgetSeconds === undefined
166
+ ? budgetSeconds
167
+ : Math.max(0, Math.min(budgetSeconds, remainingBudgetSeconds))
168
+ return clock().getTime() + effective * 1000
169
+ }
170
+
171
+ /** Append one agent session fact; the store validates and sequences it. */
172
+ function append<T extends SessionEventType>(
173
+ sessionId: string,
174
+ turn: string,
175
+ type: T,
176
+ payload: SessionEventWrite<T>['payload'],
177
+ ): Promise<SessionEventEnvelope<T>> {
178
+ return store.appendSessionEvent(sessionId, {
179
+ actor: turnActor(turn),
180
+ type,
181
+ payload,
182
+ }) as Promise<SessionEventEnvelope<T>>
183
+ }
184
+
185
+ // ── The agent loop ────────────────────────────────────────────────────────
186
+
187
+ async function runLoop(
188
+ sessionId: string,
189
+ operator: string,
190
+ turn: string,
191
+ streamId: string,
192
+ deadline: number,
193
+ ): Promise<TurnOutcome> {
194
+ const sink = createSessionStreamSink({
195
+ store,
196
+ scope: { kind: 'session', session: sessionId },
197
+ })
198
+ await sink.resume(streamId)
199
+
200
+ // Durable reconstruction — identical model input across invocations.
201
+ const events = await store.getSessionEvents(sessionId)
202
+ const conversation = await reconstructConversation(store, sessionId, events)
203
+ const state = reduceSession(events)
204
+ const system = await buildTurnSystemPrompt({
205
+ store,
206
+ repo,
207
+ wakeGlobs: state.wakeGlobs,
208
+ config: config.orchestrator,
209
+ })
210
+
211
+ // Tool-call inputs, remembered as their `tool-input-available` parts pass
212
+ // so an approval request can name what the model asked to run.
213
+ const toolInputs = new Map<string, { toolName: string; input: unknown }>()
214
+ let approvalRequest: { toolCallId: string; toolName: string; input: unknown } | undefined
215
+ let budgetFired = false
216
+
217
+ const via: Via = { kind: 'session', id: sessionId }
218
+ const tools = registryTools({ registry, repo, operator, via })
219
+ const agent = new ToolLoopAgent({
220
+ model: modelFor(),
221
+ instructions: system,
222
+ tools,
223
+ toolApproval: approvalConfig,
224
+ maxRetries: options.maxRetries ?? 2,
225
+ stopWhen: [
226
+ (_loopState) => {
227
+ if (clock().getTime() >= deadline - ORCHESTRATOR_FLUSH_MARGIN_SECONDS * 1000) {
228
+ budgetFired = true
229
+ return true
230
+ }
231
+ return false
232
+ },
233
+ isStepCount(ORCHESTRATOR_MAX_STEPS),
234
+ ],
235
+ onStepEnd: async () => {
236
+ // The checkpoint: everything streamed so far is durable before the
237
+ // next step starts.
238
+ await sink.flush()
239
+ },
240
+ })
241
+
242
+ const abortController = new AbortController()
243
+ const abortTimer = setTimeout(
244
+ () => abortController.abort(new Error('orchestrator invocation deadline exceeded')),
245
+ Math.max(0, deadline - clock().getTime()),
246
+ )
247
+ abortTimer.unref?.()
248
+
249
+ // The SDK surfaces model failures as `error` parts on the full stream
250
+ // (never a thrown error), carrying the ORIGINAL error object — the UI
251
+ // chunk reduces it to a generic errorText, so the loop consumes the raw
252
+ // stream and maps each part through `toUIMessageChunk` itself: typed
253
+ // classification sees the real error class, the sink still stores
254
+ // protocol chunks.
255
+ try {
256
+ const result = await agent.stream({
257
+ messages: await convertToModelMessages(conversation.messages),
258
+ abortSignal: abortController.signal,
259
+ })
260
+ for await (const part of result.stream) {
261
+ if (part.type === 'error') {
262
+ throw part.error
263
+ }
264
+ const chunk = toUIMessageChunk(part as never, { tools: tools as never })
265
+ if (chunk === undefined) continue
266
+ sink.append([chunk as unknown as StreamPart])
267
+ if (chunk.type === 'tool-input-available') {
268
+ toolInputs.set(chunk.toolCallId, {
269
+ toolName: chunk.toolName as string,
270
+ input: chunk.input,
271
+ })
272
+ } else if (chunk.type === 'tool-approval-request') {
273
+ const known = toolInputs.get(chunk.toolCallId)
274
+ approvalRequest = {
275
+ toolCallId: chunk.toolCallId,
276
+ toolName: known?.toolName ?? 'unknown',
277
+ input: known?.input ?? {},
278
+ }
279
+ }
280
+ }
281
+ clearTimeout(abortTimer)
282
+
283
+ // An approval request suspends the turn for the operator; a budget stop
284
+ // suspends it for the next invocation; otherwise the turn completed.
285
+ if (approvalRequest !== undefined) {
286
+ await append(sessionId, turn, 'approval.requested', {
287
+ turn,
288
+ toolCallId: approvalRequest.toolCallId,
289
+ toolName: approvalRequest.toolName,
290
+ input: approvalRequest.input as Record<string, unknown>,
291
+ })
292
+ await append(sessionId, turn, 'turn.suspended', { turn, cause: 'approval' })
293
+ return { kind: 'suspended', cause: 'approval' }
294
+ }
295
+ if (budgetFired || abortController.signal.aborted) {
296
+ await append(sessionId, turn, 'turn.suspended', { turn, cause: 'budget' })
297
+ return { kind: 'suspended', cause: 'budget' }
298
+ }
299
+
300
+ const totalUsage = await result.totalUsage
301
+ const steps = await result.steps
302
+ const usage = {
303
+ inputTokens: totalUsage.inputTokens ?? 0,
304
+ outputTokens: totalUsage.outputTokens ?? 0,
305
+ steps: steps.length,
306
+ }
307
+ await append(sessionId, turn, 'turn.completed', { turn, usage })
308
+ await sink.close('completed')
309
+ return { kind: 'completed', usage }
310
+ } catch (error) {
311
+ clearTimeout(abortTimer)
312
+ if (abortController.signal.aborted) {
313
+ // A runaway step interrupted at the deadline: the durable state so
314
+ // far is the checkpoint; the next invocation resumes it.
315
+ await append(sessionId, turn, 'turn.suspended', { turn, cause: 'budget' })
316
+ return { kind: 'suspended', cause: 'budget' }
317
+ }
318
+ const kind = classifyModelError(error)
319
+ const message = error instanceof Error ? error.message : String(error)
320
+ await append(sessionId, turn, 'turn.failed', { turn, kind, error: message })
321
+ await sink.close('aborted')
322
+ return { kind: 'failed', failureKind: kind, error: message }
323
+ }
324
+ }
325
+
326
+ return {
327
+ async startTurn(sessionId, trigger, wake, opts = {}) {
328
+ const { state, lastSeq } = await reduceSessionState(store, sessionId)
329
+ if (state.status !== 'idle') return { started: false }
330
+ // Sessions with empty wake settings are never woken.
331
+ if (trigger.kind === 'wake' && state.wakeGlobs.length === 0) return { started: false }
332
+
333
+ const turn = ids('ot')
334
+ // The stream is created before the CAS because the fact must name its
335
+ // id; on a miss the just-created stream is closed aborted so a losing
336
+ // double-click cannot leak an open stream no turn ever references (the
337
+ // reaper only watches turns, so an orphan would otherwise live forever).
338
+ const stream = await store.createStream(
339
+ { kind: 'session', session: sessionId },
340
+ `turn:${turn}`,
341
+ )
342
+ const payload: Record<string, unknown> = { turn, stream: stream.id, trigger }
343
+ if (wake !== undefined) payload.wake = wake
344
+
345
+ validateExpectedSeq(lastSeq)
346
+ const appended = await store
347
+ .appendSessionEventIfCurrent(sessionId, lastSeq, {
348
+ actor: turnActor(turn),
349
+ type: 'turn.started',
350
+ payload,
351
+ } as unknown as SessionEventWrite<'turn.started'>)
352
+ .catch(() => null)
353
+ if (appended === null) {
354
+ await store.closeStream(stream.id, 'aborted').catch(() => undefined)
355
+ return { started: false }
356
+ }
357
+
358
+ const record = await store.getSession(sessionId)
359
+ const outcome = runLoop(
360
+ sessionId,
361
+ record?.operator ?? '',
362
+ turn,
363
+ stream.id,
364
+ deadlineMs(opts.remainingBudgetSeconds ?? options.remainingBudgetSeconds),
365
+ )
366
+ return { started: true, turn, stream: stream.id, outcome }
367
+ },
368
+
369
+ async resumeTurn(sessionId, opts = {}) {
370
+ const { state, lastSeq } = await reduceSessionState(store, sessionId)
371
+ if (state.status !== 'suspended') return { resumed: false }
372
+ if (state.pendingApproval !== undefined) return { resumed: false }
373
+ const open = state.openTurn
374
+ if (open === undefined) return { resumed: false }
375
+
376
+ if (opts.approval !== undefined) {
377
+ const recovered = await recoverApprovalResponse(store, open.stream, opts.approval)
378
+ if (recovered === null) {
379
+ // A missing request part (e.g. dropped by assembly) fails the
380
+ // resume with a typed failure rather than proceeding without it.
381
+ await append(sessionId, open.turn, 'turn.failed', {
382
+ turn: open.turn,
383
+ kind: 'internal',
384
+ error: `approval answer for tool call ${JSON.stringify(opts.approval.toolCallId)} matches no persisted approval request`,
385
+ })
386
+ // The failed resume is terminal, so the turn's stream closes here
387
+ // like every other terminal path — the reaper only watches running
388
+ // turns and would otherwise never close it. Closing an
389
+ // already-closed stream is a contract-pinned no-op, so the
390
+ // non-open-stream leg of recoverApprovalResponse's null is covered
391
+ // too.
392
+ await store.closeStream(open.stream, 'aborted').catch(() => undefined)
393
+ return { resumed: false }
394
+ }
395
+ }
396
+
397
+ validateExpectedSeq(lastSeq)
398
+ const appended = await store
399
+ .appendSessionEventIfCurrent(sessionId, lastSeq, {
400
+ actor: turnActor(open.turn),
401
+ type: 'turn.resumed',
402
+ payload: { turn: open.turn },
403
+ } as unknown as SessionEventWrite<'turn.resumed'>)
404
+ .catch(() => null)
405
+ if (appended === null) return { resumed: false }
406
+
407
+ const record = await store.getSession(sessionId)
408
+ const outcome = runLoop(
409
+ sessionId,
410
+ record?.operator ?? '',
411
+ open.turn,
412
+ open.stream,
413
+ deadlineMs(opts.remainingBudgetSeconds),
414
+ )
415
+ return { resumed: true, outcome }
416
+ },
417
+ }
418
+ }
419
+
420
+ /** Scan the open turn's stream for the `tool-approval-request` part whose
421
+ * top-level `toolCallId` matches the answered call, and append the protocol's
422
+ * `tool-approval-response` part. The persisted parts are UI message stream
423
+ * chunks — the SDK flattens the internal approval request into a chunk with
424
+ * the call id at the top level, and no `toolCall` property exists on the
425
+ * stored chunk — and neither `approval.requested` nor the answer event
426
+ * carries the wire `approvalId`, so the stream is its durable home. A deny
427
+ * response makes the SDK synthesize the denied tool output. Returns null
428
+ * (appending nothing) when no request part matches. */
429
+ async function recoverApprovalResponse(
430
+ store: BuildStore,
431
+ streamId: string,
432
+ answer: { decision: 'approve' | 'deny'; toolCallId: string },
433
+ ): Promise<{ approvalId: string } | null> {
434
+ const read = await store.readStream(streamId)
435
+ if (read.status !== 'open') return null
436
+ let approvalId: string | undefined
437
+ for (const chunk of read.chunks) {
438
+ for (const part of chunk.parts) {
439
+ if (
440
+ part.type === 'tool-approval-request' &&
441
+ part.toolCallId === answer.toolCallId &&
442
+ typeof part.approvalId === 'string'
443
+ ) {
444
+ approvalId = part.approvalId
445
+ }
446
+ }
447
+ }
448
+ if (approvalId === undefined) return null
449
+ await store.appendStreamParts(streamId, [
450
+ {
451
+ type: 'tool-approval-response',
452
+ approvalId,
453
+ approved: answer.decision === 'approve',
454
+ },
455
+ ])
456
+ return { approvalId }
457
+ }
@@ -6,6 +6,7 @@
6
6
  export type * from '../ports/types'
7
7
  export type * from '../ports/workspace/build-execution'
8
8
  export type * from '../store/types'
9
+ export * from '../store/session-reducer'
9
10
  export type {
10
11
  RuntimeRegistration,
11
12
  RuntimeRegistry,
@@ -77,9 +78,30 @@ export type {
77
78
  TicketSourcePluginDescriptor,
78
79
  TicketSourcePluginFactory,
79
80
  TicketSourcePluginRegistration,
81
+ WorkspaceProviderPluginDescriptor,
80
82
  WorkspaceProviderPluginFactory,
83
+ WorkspaceProviderPluginFactoryContext,
81
84
  WorkspaceProviderPluginRegistration,
82
85
  } from '../plugins/manifest'
86
+ export type {
87
+ GuestProbeReport,
88
+ InitValidationReport,
89
+ ReadinessCheck,
90
+ RuntimeReferencesSource,
91
+ WorkspaceProviderCapabilities,
92
+ WorkspaceProviderEnvRequirement,
93
+ WorkspaceReadinessContext,
94
+ } from '../ports/workspace/provider-capabilities'
95
+ // The runtime-route group shape behind `RuntimeReferencesSource`, so plugin
96
+ // authors can name it without reaching for an internal path.
97
+ export type { RuntimeReferenceGroup } from '../config/roles'
98
+
99
+ /** The single shared capability object for the `vercel-sandbox` provider
100
+ * (AUT-517): the builtin registration and the
101
+ * `@defrex/autobuild-vercel-sandbox` plugin reference the same runtime value,
102
+ * so their declarations cannot drift. The one runtime value a provider plugin
103
+ * needs from the SDK. */
104
+ export { VERCEL_SANDBOX_CAPABILITIES } from '../ports/workspace/vercel-capabilities'
83
105
 
84
106
  export {
85
107
  describeTicketSourceContract,
@@ -131,7 +153,14 @@ export { FakeTicketSource } from '../ports/tickets/fake'
131
153
  export { validateTicketUpdate } from '../ports/tickets/update'
132
154
  export { FakeWorkspaceProvider } from '../ports/workspace/fake'
133
155
  export { FakeForge } from '../ports/forge/fake'
134
- export { ScriptedAgentRunner } from '../ports/runner/fake'
156
+ // ScriptContext + defaultTurnResult are the scripted fake's own API: the
157
+ // script callback's context type and its default turn result (needed by the
158
+ // hosted-dispatcher integration test to script guest agents).
159
+ export {
160
+ defaultTurnResult,
161
+ ScriptedAgentRunner,
162
+ type ScriptContext,
163
+ } from '../ports/runner/fake'
135
164
  export { MemoryBlobStore, MemoryBuildStore } from '../store/memory'
136
165
 
137
166
  // Runtime primitives needed by out-of-tree BuildStore adapters.
@@ -172,3 +201,24 @@ export type {
172
201
  StreamStatus,
173
202
  } from '../store/streams/types'
174
203
  export { contentHash, systemClock, toBytes, validateExpectedSeq } from '../store/types'
204
+ export {
205
+ DEFAULT_TICKET_ASSET_LIMITS,
206
+ effectiveTicketAssetLimits,
207
+ summarizeTicketAsset,
208
+ TICKET_ASSET_MAX_BYTES,
209
+ TICKET_ASSET_MAX_ENTRIES,
210
+ TicketAssetValidationError,
211
+ validateTicketAssetInput,
212
+ validateTicketAssetStructure,
213
+ } from '../store/ticket-assets'
214
+ export type {
215
+ TicketAsset,
216
+ TicketAssetContentEntry,
217
+ TicketAssetEntry,
218
+ TicketAssetEntryInput,
219
+ TicketAssetInput,
220
+ TicketAssetLayout,
221
+ TicketAssetLimits,
222
+ TicketAssetMeta,
223
+ TicketAssetSummary,
224
+ } from '../store/ticket-assets'