@defrex/autobuild 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/SPEC.md +313 -26
- package/bin/ab-build-runner.ts +8 -2
- package/docs/architecture.md +57 -14
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +151 -25
- package/docs/hosted-dispatcher.md +69 -13
- package/docs/operator-api.md +2 -2
- package/docs/remote-store-protocol.md +169 -16
- package/docs/remote-ticket-protocol.md +1 -1
- package/docs/setup.md +12 -9
- package/docs/website.md +10 -0
- package/package.json +43 -23
- package/packages/core/src/cli/artifact.ts +130 -6
- package/packages/core/src/cli/binary.ts +9 -1
- package/packages/core/src/cli/build-control.ts +110 -13
- package/packages/core/src/cli/context.ts +46 -0
- package/packages/core/src/cli/dashboard/model.ts +57 -7
- package/packages/core/src/cli/dashboard/poll.ts +69 -10
- package/packages/core/src/cli/dashboard/render.ts +34 -5
- package/packages/core/src/cli/dispatch-frontend.ts +54 -5
- package/packages/core/src/cli/dispatch.ts +320 -46
- package/packages/core/src/cli/harvest.ts +122 -8
- package/packages/core/src/cli/help.ts +47 -8
- package/packages/core/src/cli/init-readiness-remote.ts +110 -0
- package/packages/core/src/cli/init-readiness-shared.ts +72 -0
- package/packages/core/src/cli/init-validation.ts +248 -222
- package/packages/core/src/cli/main.ts +70 -11
- package/packages/core/src/cli/mcp.ts +23 -1
- package/packages/core/src/cli/plugin.ts +7 -4
- package/packages/core/src/cli/remote-poll.ts +277 -0
- package/packages/core/src/cli/repo-state.ts +22 -0
- package/packages/core/src/cli/repository-status.ts +39 -1
- package/packages/core/src/cli/status.ts +149 -27
- package/packages/core/src/cli/terminals.ts +2 -1
- package/packages/core/src/cli/ticket-assets.ts +247 -0
- package/packages/core/src/cli/ticket.ts +307 -7
- package/packages/core/src/cli/wait.ts +147 -39
- package/packages/core/src/cli/watch.ts +128 -166
- package/packages/core/src/config/live.ts +22 -0
- package/packages/core/src/config/pipeline-source.ts +376 -0
- package/packages/core/src/config/schema.ts +230 -19
- package/packages/core/src/events/catalog.ts +1 -0
- package/packages/core/src/events/globs.ts +47 -0
- package/packages/core/src/events/payloads.ts +52 -2
- package/packages/core/src/events/repository.ts +52 -1
- package/packages/core/src/events/sessions.ts +69 -10
- package/packages/core/src/events/workspace-remote.ts +12 -0
- package/packages/core/src/integration/harness.ts +2 -1
- package/packages/core/src/kernel/auto-merge-default.ts +123 -0
- package/packages/core/src/kernel/phases.ts +16 -2
- package/packages/core/src/kernel/reducer.ts +40 -1
- package/packages/core/src/operator/annotations.ts +6 -0
- package/packages/core/src/operator/control.ts +19 -5
- package/packages/core/src/operator/index.ts +71 -6
- package/packages/core/src/operator/query.ts +208 -19
- package/packages/core/src/operator/registry.ts +55 -10
- package/packages/core/src/operator/requests.ts +9 -6
- package/packages/core/src/operator/sandbox-host.ts +140 -0
- package/packages/core/src/operator/sandbox.ts +247 -14
- package/packages/core/src/operator/ticket-source-backend.ts +47 -0
- package/packages/core/src/orchestrator/approvals.ts +115 -0
- package/packages/core/src/orchestrator/conversation.ts +167 -0
- package/packages/core/src/orchestrator/model.ts +86 -0
- package/packages/core/src/orchestrator/prompt.ts +61 -0
- package/packages/core/src/orchestrator/tools.ts +117 -0
- package/packages/core/src/orchestrator/turn-runner.ts +457 -0
- package/packages/core/src/plugin-sdk/index.ts +51 -1
- package/packages/core/src/plugins/load.ts +135 -7
- package/packages/core/src/plugins/manifest.ts +183 -10
- package/packages/core/src/plugins/registry.ts +73 -4
- package/packages/core/src/ports/forge/create.ts +1 -1
- package/packages/core/src/ports/forge/fake.ts +31 -0
- package/packages/core/src/ports/forge/github-transport.ts +13 -2
- package/packages/core/src/ports/forge/github.ts +13 -2
- package/packages/core/src/ports/forge/local-git.ts +31 -1
- package/packages/core/src/ports/runner/claude.ts +2 -2
- package/packages/core/src/ports/runner/codex.ts +6 -2
- package/packages/core/src/ports/runner/fake.ts +7 -1
- package/packages/core/src/ports/runner/pi.ts +8 -6
- package/packages/core/src/ports/runner/provider-error.ts +13 -3
- package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
- package/packages/core/src/ports/types.ts +17 -2
- package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
- package/packages/core/src/ports/workspace/create.ts +74 -39
- package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
- package/packages/core/src/ports/workspace/fake.ts +169 -4
- package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
- package/packages/core/src/ports/workspace/github-origin.ts +36 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
- package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
- package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
- package/packages/core/src/processes/build-child.ts +17 -2
- package/packages/core/src/processes/build-execution-state.ts +37 -0
- package/packages/core/src/processes/build-runner.ts +54 -3
- package/packages/core/src/processes/dispatcher.ts +247 -41
- package/packages/core/src/processes/harvest-child.ts +5 -0
- package/packages/core/src/processes/harvest-runner.ts +66 -19
- package/packages/core/src/processes/harvest.ts +196 -24
- package/packages/core/src/processes/orchestrator-tick.ts +319 -0
- package/packages/core/src/processes/sandbox-state.ts +10 -0
- package/packages/core/src/store/adapter.ts +32 -0
- package/packages/core/src/store/build-scope.ts +64 -0
- package/packages/core/src/store/contract.ts +868 -0
- package/packages/core/src/store/digest.ts +49 -0
- package/packages/core/src/store/local/schema.ts +29 -2
- package/packages/core/src/store/local/store.ts +338 -4
- package/packages/core/src/store/memory.ts +179 -0
- package/packages/core/src/store/new-build.ts +17 -0
- package/packages/core/src/store/phase-session.ts +71 -0
- package/packages/core/src/store/remote/client.ts +260 -1
- package/packages/core/src/store/remote/index.ts +12 -2
- package/packages/core/src/store/remote/protocol.ts +100 -12
- package/packages/core/src/store/remote/version.ts +6 -2
- package/packages/core/src/store/repo-state-events.ts +140 -0
- package/packages/core/src/store/retention.ts +7 -0
- package/packages/core/src/store/session-handle.ts +64 -0
- package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +28 -9
- package/packages/core/src/store/streams/session-writer.ts +42 -2
- package/packages/core/src/store/streams/wait.ts +4 -5
- package/packages/core/src/store/ticket-assets.ts +425 -0
- package/packages/core/src/store/types.ts +122 -1
- package/packages/core/src/testing/index.ts +59 -0
- package/skills/code-review/SKILL.md +5 -1
- package/skills/guide/SKILL.md +126 -33
- package/skills/guide/references/operator-api.md +2 -2
- package/skills/guide/references/plugin-authoring.md +17 -0
- package/skills/guide/references/remote-store-protocol.md +169 -16
- package/skills/guide/references/remote-ticket-protocol.md +1 -1
- package/skills/guide/references/setup.md +12 -9
- package/skills/implement/SKILL.md +3 -0
- package/skills/operate/SKILL.md +126 -0
- package/skills/plan/SKILL.md +4 -0
- package/skills/plan-review/SKILL.md +5 -1
- package/skills/spec/SKILL.md +46 -3
- package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
- package/packages/core/src/cli/store-opening.contract.ts +0 -289
- package/packages/core/src/cli/testkit.ts +0 -194
- package/packages/core/src/markdown.ts +0 -67
- package/packages/core/src/operator/client.ts +0 -311
- package/packages/core/src/operator/server.ts +0 -616
- package/packages/core/src/operator/sessions.ts +0 -223
- package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
- package/packages/core/src/ports/tickets/remote-server.ts +0 -185
- package/packages/core/src/store/remote/server.ts +0 -923
- package/packages/core/src/testing/store-failures.ts +0 -32
|
@@ -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
|
-
}
|