@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
@@ -1,223 +0,0 @@
1
- /**
2
- * Operator-session operations behind the operator API (SPEC §7.1.1): the
3
- * signed-in operator's orchestrator conversations. Every write is attributed
4
- * to the signed-in operator — the token's user, never a client-supplied
5
- * identity — and `via` is never set on session events: session events are the
6
- * delegate's own log, and here the operator IS the actor.
7
- *
8
- * Ownership lives here, in the server seam, because the store has no
9
- * principal concept: a session belongs to its creating operator; other
10
- * operators of the same repository can read it and cannot write to it. The
11
- * store stays neutral; the API is the gatekeeper — writes of any kind to an
12
- * archived session are refusals here, not store errors.
13
- */
14
- import { humanActor } from '../events/envelope'
15
- import type { SessionEventWrite } from '../events/sessions'
16
- import { reduceSession, type SessionState, type SessionTurn } from '../sessions/reducer'
17
- import type { StreamRead } from '../store/streams/types'
18
- import type { BuildStore, SessionRecord } from '../store/types'
19
- import type { OperatorSandboxService } from './sandbox'
20
-
21
- export class OperatorSessionError extends Error {
22
- constructor(
23
- readonly code: 'not-found' | 'refusal' | 'forbidden',
24
- message: string,
25
- ) {
26
- super(message)
27
- this.name = 'OperatorSessionError'
28
- }
29
- }
30
-
31
- async function requireSession(
32
- store: BuildStore,
33
- repo: string,
34
- sid: string,
35
- ): Promise<SessionRecord> {
36
- const record = await store.getSession(sid)
37
- if (record === null || record.repo !== repo) {
38
- throw new OperatorSessionError('not-found', `unknown session "${sid}"`)
39
- }
40
- return record
41
- }
42
-
43
- function requireOwner(record: SessionRecord, user: string, operation: string): void {
44
- if (record.operator !== user) {
45
- throw new OperatorSessionError(
46
- 'forbidden',
47
- `session "${record.id}" belongs to operator ${JSON.stringify(record.operator)}; ${JSON.stringify(user)} may not ${operation}`,
48
- )
49
- }
50
- }
51
-
52
- /** Every append funnels through here: one attribution point, one archived
53
- * gate. The store has no principal concept, so this is where a token's user
54
- * meets a session's operator. */
55
- async function appendAs(
56
- store: BuildStore,
57
- record: SessionRecord,
58
- user: string,
59
- event: SessionEventWrite,
60
- ): Promise<void> {
61
- requireOwner(record, user, `write ${event.type}`)
62
- const state = reduceSession(await store.getSessionEvents(record.id))
63
- if (state.status === 'archived') {
64
- throw new OperatorSessionError('refusal', `session "${record.id}" is archived and read-only`)
65
- }
66
- await store.appendSessionEvent(record.id, event)
67
- }
68
-
69
- export async function listOperatorSessions(
70
- store: BuildStore,
71
- repo: string,
72
- ): Promise<SessionRecord[]> {
73
- const sessions = await store.listSessions(repo)
74
- return sessions.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
75
- }
76
-
77
- export async function createOperatorSession(
78
- store: BuildStore,
79
- repo: string,
80
- user: string,
81
- title?: string,
82
- ): Promise<SessionRecord> {
83
- return store.createSession({
84
- repo,
85
- operator: user,
86
- ...(title !== undefined ? { title } : {}),
87
- })
88
- }
89
-
90
- export interface OperatorSessionView {
91
- session: SessionRecord
92
- state: SessionState
93
- turns: SessionTurn[]
94
- }
95
-
96
- export async function getOperatorSession(
97
- store: BuildStore,
98
- repo: string,
99
- sid: string,
100
- ): Promise<OperatorSessionView> {
101
- const record = await requireSession(store, repo, sid)
102
- const state = reduceSession(await store.getSessionEvents(record.id))
103
- return { session: record, state, turns: state.turns }
104
- }
105
-
106
- export async function postOperatorMessage(
107
- store: BuildStore,
108
- repo: string,
109
- sid: string,
110
- user: string,
111
- text: string,
112
- ): Promise<void> {
113
- const record = await requireSession(store, repo, sid)
114
- await appendAs(store, record, user, {
115
- actor: humanActor(user),
116
- type: 'message.posted',
117
- payload: { text },
118
- })
119
- }
120
-
121
- export async function setOperatorWake(
122
- store: BuildStore,
123
- repo: string,
124
- sid: string,
125
- user: string,
126
- globs: string[],
127
- ): Promise<void> {
128
- const record = await requireSession(store, repo, sid)
129
- await appendAs(store, record, user, {
130
- actor: humanActor(user),
131
- type: 'session.wake-set',
132
- payload: { globs },
133
- })
134
- }
135
-
136
- export async function answerOperatorApproval(
137
- store: BuildStore,
138
- repo: string,
139
- sid: string,
140
- user: string,
141
- turn: string,
142
- toolCallId: string,
143
- decision: 'approve' | 'deny',
144
- ): Promise<void> {
145
- const record = await requireSession(store, repo, sid)
146
- requireOwner(record, user, 'answer an approval')
147
- const state = reduceSession(await store.getSessionEvents(record.id))
148
- const pending = state.pendingApproval
149
- if (
150
- state.status === 'archived' ||
151
- pending === undefined ||
152
- pending.turn !== turn ||
153
- pending.toolCallId !== toolCallId
154
- ) {
155
- throw new OperatorSessionError(
156
- 'refusal',
157
- `session "${record.id}" has no pending approval for turn ${JSON.stringify(turn)} tool call ${JSON.stringify(toolCallId)}`,
158
- )
159
- }
160
- await appendAs(store, record, user, {
161
- actor: humanActor(user),
162
- type: 'approval.answered',
163
- payload: { turn, toolCallId, decision },
164
- })
165
- }
166
-
167
- export async function archiveOperatorSession(
168
- store: BuildStore,
169
- repo: string,
170
- sid: string,
171
- user: string,
172
- sandbox?: OperatorSandboxService,
173
- ): Promise<void> {
174
- const record = await requireSession(store, repo, sid)
175
- requireOwner(record, user, 'archive')
176
- const state = reduceSession(await store.getSessionEvents(record.id))
177
- if (state.status === 'archived') {
178
- throw new OperatorSessionError('refusal', `session "${record.id}" is already archived`)
179
- }
180
- await store.appendSessionEvent(record.id, {
181
- actor: humanActor(user),
182
- type: 'session.archived',
183
- payload: {},
184
- })
185
- // When the archive leaves the operator with no open session for this
186
- // repository, their sandbox environment is released (snapshot purge,
187
- // journal fact). The service derives the environment via its pure
188
- // describe — never provisioning — and a never-provisioned operator is a
189
- // no-op. Contained: a release failure must not fail an accepted archive.
190
- if (sandbox !== undefined) {
191
- const sessions = await store.listSessions(repo)
192
- const open: SessionRecord[] = []
193
- for (const session of sessions) {
194
- if (session.operator !== user) continue
195
- const sessionState = reduceSession(await store.getSessionEvents(session.id))
196
- if (sessionState.status !== 'archived') open.push(session)
197
- }
198
- if (open.length === 0) {
199
- try {
200
- await sandbox.release(user, { repo })
201
- } catch {
202
- // The archive already succeeded; the environment stays journaled and
203
- // the dispatcher's idle settlement stops it later.
204
- }
205
- }
206
- }
207
- }
208
-
209
- export async function readOperatorTurnStream(
210
- store: BuildStore,
211
- repo: string,
212
- sid: string,
213
- turn: string,
214
- opts?: { since?: number; waitSeconds?: number },
215
- ): Promise<StreamRead> {
216
- const record = await requireSession(store, repo, sid)
217
- const state = reduceSession(await store.getSessionEvents(record.id))
218
- const target = state.turns.find((candidate) => candidate.turn === turn)
219
- if (target === undefined) {
220
- throw new OperatorSessionError('not-found', `unknown turn "${turn}" in session "${record.id}"`)
221
- }
222
- return store.readStream(target.stream, opts)
223
- }
@@ -1,176 +0,0 @@
1
- import { expect } from 'bun:test'
2
- import { chmod, copyFile, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
3
- import { tmpdir } from 'node:os'
4
- import { join } from 'node:path'
5
- import type { AgentRunner, AgentTurnResult, Transcript } from '../types'
6
- import type { OneShotCompletion } from './one-shot'
7
- import { AGENT_BIN_DIR } from './session-env'
8
-
9
- interface LiveFixture {
10
- cwd: string
11
- evidencePath: string
12
- cleanup: () => Promise<void>
13
- }
14
-
15
- interface ProbeEvidence {
16
- phase: string
17
- retained: string
18
- collide: string
19
- abPath: string
20
- helpStatus: string
21
- }
22
-
23
- async function createLiveFixture(): Promise<LiveFixture> {
24
- const cwd = await mkdtemp(join(tmpdir(), 'ab-runner-live-contract-'))
25
- const skillDir = join(cwd, '.agents', 'skills', 'ab-runner-contract')
26
- const claudeSkills = join(cwd, '.claude', 'skills')
27
- await mkdir(skillDir, { recursive: true })
28
- await mkdir(claudeSkills, { recursive: true })
29
- const skillPath = join(skillDir, 'SKILL.md')
30
- await writeFile(
31
- skillPath,
32
- `---\nname: ab-runner-contract\ndescription: Live AgentRunner contract probe.\ndisable-model-invocation: true\n---\n\n# Runner contract probe\n\nImmediately use the bash tool to execute \`./contract-probe\` exactly once.\nWait for it to finish, then reply with the single word \`probed\`.\n`,
33
- )
34
- const claudeSkill = join(claudeSkills, 'ab-runner-contract')
35
- try {
36
- await symlink(skillDir, claudeSkill, 'dir')
37
- } catch {
38
- await mkdir(claudeSkill, { recursive: true })
39
- await copyFile(skillPath, join(claudeSkill, 'SKILL.md'))
40
- }
41
-
42
- const evidencePath = join(cwd, 'contract-evidence.tsv')
43
- const probePath = join(cwd, 'contract-probe')
44
- await writeFile(
45
- probePath,
46
- `#!/bin/sh\nset -eu\nab_path="$(command -v ab)"\nif ab --help >/dev/null 2>&1; then help=0; else help=$?; fi\nprintf '%s\\t%s\\t%s\\t%s\\t%s\\n' "\${AB_CONTRACT_PHASE:-}" "\${AB_CONTRACT_RETAINED:-}" "\${AB_CONTRACT_COLLIDE:-}" "$ab_path" "$help" >> ${JSON.stringify(evidencePath)}\n`,
47
- )
48
- await chmod(probePath, 0o755)
49
- return {
50
- cwd,
51
- evidencePath,
52
- cleanup: () => rm(cwd, { recursive: true, force: true }),
53
- }
54
- }
55
-
56
- async function evidence(path: string): Promise<ProbeEvidence[]> {
57
- const content = await readFile(path, 'utf8')
58
- return content
59
- .trim()
60
- .split('\n')
61
- .filter((line) => line !== '')
62
- .map((line) => {
63
- const [phase = '', retained = '', collide = '', abPath = '', helpStatus = ''] =
64
- line.split('\t')
65
- return { phase, retained, collide, abPath, helpStatus }
66
- })
67
- }
68
-
69
- function expectCompleted(result: AgentTurnResult): void {
70
- expect(result.kind).toBe('completed')
71
- expect(Number.isInteger(result.usage.inputTokens)).toBe(true)
72
- expect(Number.isInteger(result.usage.outputTokens)).toBe(true)
73
- expect(result.usage.turns).toBeGreaterThan(0)
74
- }
75
-
76
- function expectTranscript(transcript: Transcript, runner: AgentRunner, model: string): void {
77
- expect(transcript.content.trim()).not.toBe('')
78
- expect(transcript.metadata.runner).toBe(runner.name)
79
- expect(transcript.metadata.model).toBe(model)
80
- expect(transcript.metadata.usage.turns).toBeGreaterThanOrEqual(2)
81
- }
82
-
83
- /** Successful real-provider smoke coverage. Deterministic failure taxonomy is
84
- * deliberately covered by the full injected adapter contract instead. */
85
- export async function runLiveAgentRunnerContract(
86
- runner: AgentRunner & OneShotCompletion,
87
- model: string,
88
- ): Promise<void> {
89
- const fixture = await createLiveFixture()
90
- let failure: unknown
91
- try {
92
- const started = await runner.start({
93
- skill: 'ab-runner-contract',
94
- invocation: 'live-contract',
95
- workspacePath: fixture.cwd,
96
- model,
97
- env: {
98
- AB_CONTRACT_PHASE: 'start',
99
- AB_CONTRACT_RETAINED: 'retained',
100
- AB_CONTRACT_COLLIDE: 'start-value',
101
- },
102
- })
103
- let transcript: Transcript | undefined
104
- try {
105
- expect(started.session.id.trim()).not.toBe('')
106
- expect(started.session.runner).toBe(runner.name)
107
- expect(started.session.model).toBe(model)
108
- expectCompleted(started.result)
109
- expect(await evidence(fixture.evidencePath)).toEqual([
110
- {
111
- phase: 'start',
112
- retained: 'retained',
113
- collide: 'start-value',
114
- abPath: join(AGENT_BIN_DIR, 'ab'),
115
- helpStatus: '0',
116
- },
117
- ])
118
-
119
- const continued = await runner.continue(
120
- started.session,
121
- 'Use the bash tool to execute `./contract-probe` exactly once, wait for it, then reply with exactly the same single word as your previous response.',
122
- {
123
- env: {
124
- AB_CONTRACT_PHASE: 'continue',
125
- AB_CONTRACT_COLLIDE: 'continue-value',
126
- },
127
- },
128
- )
129
- expectCompleted(continued)
130
- expect(continued.text.trim()).toBe('probed')
131
- expect(await evidence(fixture.evidencePath)).toEqual([
132
- {
133
- phase: 'start',
134
- retained: 'retained',
135
- collide: 'start-value',
136
- abPath: join(AGENT_BIN_DIR, 'ab'),
137
- helpStatus: '0',
138
- },
139
- {
140
- phase: 'continue',
141
- retained: 'retained',
142
- collide: 'continue-value',
143
- abPath: join(AGENT_BIN_DIR, 'ab'),
144
- helpStatus: '0',
145
- },
146
- ])
147
- } catch (error) {
148
- failure = error
149
- }
150
-
151
- try {
152
- transcript = await runner.end(started.session)
153
- } catch (endError) {
154
- if (failure !== undefined) {
155
- throw new AggregateError(
156
- [failure, endError],
157
- 'live AgentRunner assertion and session end both failed',
158
- )
159
- }
160
- throw endError
161
- }
162
- if (failure !== undefined) throw failure
163
- expectTranscript(transcript, runner, model)
164
-
165
- const nonce = crypto.randomUUID()
166
- const oneShot = await runner.complete({
167
- prompt: `Reply with exactly this token and no other text: ${nonce}`,
168
- cwd: fixture.cwd,
169
- env: { AB_CONTRACT_ONE_SHOT: nonce },
170
- model,
171
- })
172
- expect(oneShot.text.trim()).toContain(nonce)
173
- } finally {
174
- await fixture.cleanup()
175
- }
176
- }
@@ -1,185 +0,0 @@
1
- import type { TicketSource } from '../types'
2
- import { systemClock, type Clock } from '../../store/types'
3
- import type { ErrorBody, ErrorKind } from '../../store/remote/protocol'
4
- import { tokenResource, verifyToken } from '../../store/remote/token'
5
- import {
6
- AUTOBUILD_VERSION,
7
- AUTOBUILD_VERSION_HEADER,
8
- REMOTE_STORE_PROTOCOL_VERSION,
9
- REMOTE_STORE_PROTOCOL_VERSION_HEADER,
10
- } from '../../store/remote/version'
11
- import {
12
- hostedTicketRequestSchemas,
13
- type HostedTicketContext,
14
- type HostedTicketOperation,
15
- } from './remote-protocol'
16
-
17
- export interface TicketServerOptions {
18
- secret: string
19
- sourceFor(context: HostedTicketContext): TicketSource | Promise<TicketSource>
20
- clock?: Clock
21
- /** Observes unexpected source failures without changing their wire response. */
22
- onInternalError?: (error: unknown, request: Request) => unknown | Promise<unknown>
23
- }
24
-
25
- class TicketRequestError extends Error {
26
- constructor(
27
- readonly status: number,
28
- readonly kind: ErrorKind,
29
- message: string,
30
- ) {
31
- super(message)
32
- }
33
- }
34
-
35
- function json(status: number, body: unknown): Response {
36
- return Response.json(body, { status })
37
- }
38
-
39
- function failure(status: number, kind: ErrorKind, error: string): Response {
40
- return json(status, { error, kind } satisfies ErrorBody)
41
- }
42
-
43
- /** HTTP face of the TicketSource port. Authentication deliberately runs before
44
- * JSON parsing and source construction, so denied callers cannot probe either. */
45
- export function createTicketServer(options: TicketServerOptions): {
46
- fetch(req: Request): Promise<Response>
47
- } {
48
- const clock = options.clock ?? systemClock
49
-
50
- function identify(req: Request): void {
51
- const autobuild = req.headers.get(AUTOBUILD_VERSION_HEADER)
52
- const protocol = req.headers.get(REMOTE_STORE_PROTOCOL_VERSION_HEADER)
53
- if (autobuild !== AUTOBUILD_VERSION || protocol !== REMOTE_STORE_PROTOCOL_VERSION) {
54
- throw new TicketRequestError(
55
- 409,
56
- 'conflict',
57
- `remote store version mismatch: client Autobuild ${autobuild ?? '(missing)'} protocol ${protocol ?? '(missing)'}; server Autobuild ${AUTOBUILD_VERSION} protocol ${REMOTE_STORE_PROTOCOL_VERSION}`,
58
- )
59
- }
60
- }
61
-
62
- function authorize(req: Request): void {
63
- const match = /^Bearer\s+(.+)$/i.exec(req.headers.get('authorization') ?? '')
64
- if (!match) throw new TicketRequestError(401, 'auth', 'missing bearer token')
65
- const scope = verifyToken(options.secret, match[1]!, clock())
66
- if (scope === null) throw new TicketRequestError(401, 'auth', 'invalid or expired token')
67
- const resource = tokenResource(scope)
68
- if (resource.kind !== 'deployment') {
69
- throw new TicketRequestError(
70
- 403,
71
- 'auth',
72
- `token scoped to ${resource.kind} "${resource.id}" may not access ticket operations`,
73
- )
74
- }
75
- }
76
-
77
- async function body(req: Request): Promise<unknown> {
78
- try {
79
- return await req.json()
80
- } catch {
81
- throw new TicketRequestError(400, 'validation', 'request body is not valid JSON')
82
- }
83
- }
84
-
85
- async function route(req: Request, operation: HostedTicketOperation): Promise<Response> {
86
- const raw = await body(req)
87
- const parsed = hostedTicketRequestSchemas[operation].safeParse(raw)
88
- if (!parsed.success) {
89
- throw new TicketRequestError(
90
- 400,
91
- 'validation',
92
- `invalid request body: ${parsed.error.message}`,
93
- )
94
- }
95
- const context = parsed.data.context
96
- const source = await options.sourceFor(context)
97
-
98
- // Parse again through the operation-specific schema to preserve precise
99
- // inference in each branch while keeping the shared pre-source validation.
100
- switch (operation) {
101
- case 'list-ready': {
102
- const request = hostedTicketRequestSchemas['list-ready'].parse(raw)
103
- return json(200, await source.listReady(request.input))
104
- }
105
- case 'get': {
106
- const request = hostedTicketRequestSchemas.get.parse(raw)
107
- return json(200, await source.get(request.input.id))
108
- }
109
- case 'claim': {
110
- const request = hostedTicketRequestSchemas.claim.parse(raw)
111
- return json(200, { claimed: await source.claim(request.input.id) })
112
- }
113
- case 'comment': {
114
- const request = hostedTicketRequestSchemas.comment.parse(raw)
115
- await source.comment(request.input.id, request.input.body)
116
- return json(200, { ok: true })
117
- }
118
- case 'transition': {
119
- const request = hostedTicketRequestSchemas.transition.parse(raw)
120
- await source.transition(request.input.id, request.input.state)
121
- return json(200, { ok: true })
122
- }
123
- case 'create': {
124
- const request = hostedTicketRequestSchemas.create.parse(raw)
125
- return json(201, await source.create(request.input.draft, request.input.options))
126
- }
127
- case 'update': {
128
- const request = hostedTicketRequestSchemas.update.parse(raw)
129
- await source.update(request.input.id, request.input.patch)
130
- return json(200, { ok: true })
131
- }
132
- case 'add-blocker': {
133
- const request = hostedTicketRequestSchemas['add-blocker'].parse(raw)
134
- await source.addBlocker(request.input.id, request.input.blockerId)
135
- return json(200, { ok: true })
136
- }
137
- case 'remove-blocker': {
138
- const request = hostedTicketRequestSchemas['remove-blocker'].parse(raw)
139
- await source.removeBlocker(request.input.id, request.input.blockerId)
140
- return json(200, { ok: true })
141
- }
142
- case 'dependency-states': {
143
- const request = hostedTicketRequestSchemas['dependency-states'].parse(raw)
144
- return json(200, await source.dependencyStates(request.input.ids))
145
- }
146
- }
147
- }
148
-
149
- return {
150
- async fetch(req: Request): Promise<Response> {
151
- try {
152
- const url = new URL(req.url)
153
- if (req.method !== 'POST' || !url.pathname.startsWith('/tickets/')) {
154
- return failure(404, 'not-found', `no route: ${req.method} ${url.pathname}`)
155
- }
156
- identify(req)
157
- authorize(req)
158
- const operation = url.pathname.slice('/tickets/'.length) as HostedTicketOperation
159
- if (!Object.hasOwn(hostedTicketRequestSchemas, operation)) {
160
- return failure(404, 'not-found', `no route: ${req.method} ${url.pathname}`)
161
- }
162
- return await route(req, operation)
163
- } catch (error) {
164
- if (error instanceof TicketRequestError) {
165
- return failure(error.status, error.kind, error.message)
166
- }
167
- // Only the message crosses the trust boundary; causes and stacks never do.
168
- const message = error instanceof Error ? error.message : 'ticket backend failed'
169
- const notFound = /unknown (?:ticket|issue)|not found/i.test(message)
170
- // These are intentional domain feedback from TicketSource implementations.
171
- // Their historical standalone mapping remains unchanged.
172
- const expectedDomainFailure =
173
- message.startsWith('invalid ticket update') || message.includes('cannot block itself')
174
- if (!notFound && !expectedDomainFailure && options.onInternalError !== undefined) {
175
- try {
176
- await options.onInternalError(error, req)
177
- } catch {
178
- // Diagnostics must never replace the protocol response.
179
- }
180
- }
181
- return failure(notFound ? 404 : 500, notFound ? 'not-found' : 'internal', message)
182
- }
183
- },
184
- }
185
- }