@defrex/autobuild 0.8.0 → 0.10.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 +323 -26
- package/bin/ab-build-runner.ts +8 -2
- package/docs/architecture.md +166 -14
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +175 -28
- package/docs/hosted-dispatcher.md +69 -13
- package/docs/operator-api.md +2 -2
- package/docs/remote-store-protocol.md +204 -16
- package/docs/remote-ticket-protocol.md +1 -1
- package/docs/setup.md +12 -9
- package/docs/website.md +14 -0
- package/package.json +45 -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/facts.ts +215 -0
- package/packages/core/src/cli/dashboard/frame-parse.ts +223 -0
- package/packages/core/src/cli/dashboard/model.ts +141 -153
- package/packages/core/src/cli/dashboard/poll.ts +76 -12
- 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-process.ts +1 -13
- package/packages/core/src/cli/dispatch.ts +392 -75
- package/packages/core/src/cli/harvest.ts +122 -8
- package/packages/core/src/cli/help.ts +62 -9
- 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 +266 -222
- package/packages/core/src/cli/installation.ts +50 -8
- 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/self-update.ts +77 -2
- package/packages/core/src/cli/status.ts +150 -28
- 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 +453 -8
- package/packages/core/src/cli/wait.ts +157 -41
- package/packages/core/src/cli/watch.ts +138 -168
- package/packages/core/src/config/live.ts +23 -0
- package/packages/core/src/config/pipeline-source.ts +388 -0
- package/packages/core/src/config/schema.ts +251 -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 +115 -2
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +1369 -0
- package/packages/core/src/kernel/auto-merge-default.ts +157 -0
- package/packages/core/src/kernel/auto-merge.ts +99 -22
- package/packages/core/src/kernel/dispatch-settings.ts +42 -17
- package/packages/core/src/kernel/dispatch-status.ts +23 -0
- package/packages/core/src/kernel/engine.ts +9 -438
- package/packages/core/src/kernel/generators/build-log.ts +259 -0
- package/packages/core/src/kernel/generators/repository-journals.ts +206 -0
- package/packages/core/src/kernel/generators/session-log.ts +72 -0
- package/packages/core/src/kernel/harvest.ts +62 -18
- package/packages/core/src/kernel/incremental-contract.ts +75 -0
- package/packages/core/src/kernel/incremental-fixtures.ts +211 -0
- package/packages/core/src/kernel/incremental.ts +53 -0
- package/packages/core/src/kernel/log-index.ts +620 -0
- package/packages/core/src/kernel/phases.ts +16 -2
- package/packages/core/src/kernel/pr-attachments.ts +164 -46
- package/packages/core/src/kernel/reducer-registry.ts +164 -0
- package/packages/core/src/kernel/reducer.ts +254 -73
- 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 +253 -24
- package/packages/core/src/operator/read-view.ts +98 -0
- 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/fake.ts +5 -1
- package/packages/core/src/ports/tickets/file.ts +169 -48
- package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
- package/packages/core/src/ports/tickets/linear.ts +33 -1
- package/packages/core/src/ports/tickets/operations.ts +1 -1
- package/packages/core/src/ports/tickets/remote.ts +8 -1
- package/packages/core/src/ports/types.ts +49 -3
- 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 +68 -15
- package/packages/core/src/processes/build-runner.ts +238 -93
- package/packages/core/src/processes/dispatcher-selectors.ts +123 -0
- package/packages/core/src/processes/dispatcher.ts +501 -201
- package/packages/core/src/processes/execution-settlement.ts +72 -39
- package/packages/core/src/processes/harvest-child.ts +5 -0
- package/packages/core/src/processes/harvest-execution-state.ts +41 -29
- package/packages/core/src/processes/harvest-runner.ts +66 -19
- package/packages/core/src/processes/harvest.ts +196 -24
- package/packages/core/src/processes/infrastructure-failure-budget.ts +31 -18
- package/packages/core/src/processes/orchestrator-tick.ts +360 -0
- package/packages/core/src/processes/publication-state.ts +164 -52
- package/packages/core/src/processes/repo-view.ts +1436 -0
- package/packages/core/src/processes/sandbox-state.ts +36 -2
- package/packages/core/src/processes/tick-state.ts +33 -0
- package/packages/core/src/store/adapter.ts +41 -0
- package/packages/core/src/store/build-scope.ts +90 -0
- package/packages/core/src/store/contract.ts +1205 -0
- package/packages/core/src/store/digest.ts +102 -0
- package/packages/core/src/store/local/schema.ts +46 -2
- package/packages/core/src/store/local/store.ts +445 -4
- package/packages/core/src/store/memory.ts +246 -0
- package/packages/core/src/store/new-build.ts +17 -0
- package/packages/core/src/store/phase-session.ts +94 -0
- package/packages/core/src/store/remote/client.ts +310 -2
- package/packages/core/src/store/remote/index.ts +14 -2
- package/packages/core/src/store/remote/protocol.ts +115 -12
- package/packages/core/src/store/remote/version.ts +6 -2
- package/packages/core/src/store/repo-state-events.ts +205 -0
- package/packages/core/src/store/retention.ts +7 -0
- package/packages/core/src/store/session-handle.ts +94 -0
- package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +79 -19
- package/packages/core/src/store/snapshots.ts +88 -0
- 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 +458 -0
- package/packages/core/src/store/types.ts +160 -1
- package/packages/core/src/testing/index.ts +59 -0
- package/skills/code-review/SKILL.md +5 -1
- package/skills/guide/SKILL.md +152 -40
- 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 +204 -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/skills/tickets/SKILL.md +57 -5
- 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,923 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The remote BuildStore's HTTP face (SPEC §7.2 adapter 2): a small
|
|
3
|
-
* self-hosted API over any backing BuildStore. Routes are REST and boring;
|
|
4
|
-
* the properties that matter ride on top:
|
|
5
|
-
*
|
|
6
|
-
* - D6: an `EventValidationError` from the backing store crosses the wire
|
|
7
|
-
* as 422 `{kind: 'validation'}` with the message intact — validation
|
|
8
|
-
* failures stay agent feedback, never opaque 500s.
|
|
9
|
-
* - D8: when `secret` is set, every /builds route requires a Bearer token
|
|
10
|
-
* whose scope covers the addressed build (`'*'` = admin). Missing or
|
|
11
|
-
* expired token → 401; a token for another build → 403. No secret → open
|
|
12
|
-
* (local dev). `/health` is always open. The scope's session dimension
|
|
13
|
-
* (§8.1: "scoped to this build *and* session") gates event writes: a
|
|
14
|
-
* session-scoped token may only append events attributed to that agent
|
|
15
|
-
* session.
|
|
16
|
-
*
|
|
17
|
-
* Atomic deposits (POST …/deposits) implement the wire convention in
|
|
18
|
-
* protocol.ts: the request payload embeds negative placeholder refs, and
|
|
19
|
-
* this server substitutes the real revisions *inside* the backing store's
|
|
20
|
-
* `appendWithArtifacts` callback — atomicity is the backing store's, not
|
|
21
|
-
* re-implemented here.
|
|
22
|
-
*/
|
|
23
|
-
import type { ZodType } from 'zod'
|
|
24
|
-
import { EventValidationError, type AbEvent, type EventWrite } from '../../events/catalog'
|
|
25
|
-
import type { RepositoryEvent, RepositoryEventWrite } from '../../events/repository'
|
|
26
|
-
import type { SessionEvent, SessionEventWrite } from '../../events/sessions'
|
|
27
|
-
import type { Via } from '../../events/envelope'
|
|
28
|
-
import { systemClock, type BuildStore, type Clock } from '../types'
|
|
29
|
-
import type { StreamOutcome, StreamPart, StreamRead, StreamScope } from '../streams/types'
|
|
30
|
-
import {
|
|
31
|
-
MAX_STREAM_WAIT_SECONDS,
|
|
32
|
-
StreamBatchTooLargeError,
|
|
33
|
-
StreamClosedError,
|
|
34
|
-
} from '../streams/types'
|
|
35
|
-
import {
|
|
36
|
-
appendStreamBodySchema,
|
|
37
|
-
closeStreamBodySchema,
|
|
38
|
-
conditionalEventBodySchema,
|
|
39
|
-
createStreamBodySchema,
|
|
40
|
-
decodeBase64,
|
|
41
|
-
depositsBodySchema,
|
|
42
|
-
encodeBase64,
|
|
43
|
-
ensureRepoBodySchema,
|
|
44
|
-
eventWriteWireSchema,
|
|
45
|
-
leaseClaimBodySchema,
|
|
46
|
-
leaseHolderBodySchema,
|
|
47
|
-
newBuildBodySchema,
|
|
48
|
-
newSessionBodySchema,
|
|
49
|
-
putArtifactBodySchema,
|
|
50
|
-
substitutePlaceholderRefs,
|
|
51
|
-
type ErrorBody,
|
|
52
|
-
type ErrorKind,
|
|
53
|
-
} from './protocol'
|
|
54
|
-
import { tokenResource, verifyToken, type TokenScope } from './token'
|
|
55
|
-
import {
|
|
56
|
-
AUTOBUILD_VERSION,
|
|
57
|
-
AUTOBUILD_VERSION_HEADER,
|
|
58
|
-
REMOTE_STORE_PROTOCOL_VERSION,
|
|
59
|
-
REMOTE_STORE_PROTOCOL_VERSION_HEADER,
|
|
60
|
-
} from './version'
|
|
61
|
-
|
|
62
|
-
export interface StoreServerOptions {
|
|
63
|
-
store: BuildStore
|
|
64
|
-
/** When set, all /builds routes require a scoped Bearer token (D8). */
|
|
65
|
-
secret?: string
|
|
66
|
-
/** Time source for token expiry checks; defaults to the system clock. */
|
|
67
|
-
clock?: Clock
|
|
68
|
-
/** Maximum decoded size of each artifact. Unlimited when omitted. */
|
|
69
|
-
maxArtifactBytes?: number
|
|
70
|
-
/** Ceiling for a held event read (`wait` on the two event routes), in
|
|
71
|
-
* whole seconds. Larger requested values clamp to this, never reject;
|
|
72
|
-
* the default is the stream-wait ceiling (30). Hosted deployments set a
|
|
73
|
-
* smaller ceiling to fit the routes' function-duration limit. */
|
|
74
|
-
maxEventWaitSeconds?: number
|
|
75
|
-
/** Observes unexpected backing-store failures without changing their wire response. */
|
|
76
|
-
onInternalError?: (error: unknown, request: Request) => unknown | Promise<unknown>
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export interface StoreServer {
|
|
80
|
-
fetch: (req: Request) => Promise<Response>
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Thrown by route helpers; the top-level handler maps it to a response. */
|
|
84
|
-
class RequestError extends Error {
|
|
85
|
-
constructor(
|
|
86
|
-
readonly status: number,
|
|
87
|
-
readonly kind: ErrorKind,
|
|
88
|
-
message: string,
|
|
89
|
-
) {
|
|
90
|
-
super(message)
|
|
91
|
-
this.name = 'RequestError'
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function json(status: number, body: unknown): Response {
|
|
96
|
-
return new Response(JSON.stringify(body), {
|
|
97
|
-
status,
|
|
98
|
-
headers: { 'content-type': 'application/json' },
|
|
99
|
-
})
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function fail(status: number, kind: ErrorKind, error: string): Response {
|
|
103
|
-
return json(status, { error, kind } satisfies ErrorBody)
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
async function readBody<T>(req: Request, schema: ZodType<T>): Promise<T> {
|
|
107
|
-
let raw: unknown
|
|
108
|
-
try {
|
|
109
|
-
raw = await req.json()
|
|
110
|
-
} catch {
|
|
111
|
-
throw new RequestError(400, 'validation', 'request body is not valid JSON')
|
|
112
|
-
}
|
|
113
|
-
const parsed = schema.safeParse(raw)
|
|
114
|
-
if (!parsed.success) {
|
|
115
|
-
throw new RequestError(400, 'validation', `invalid request body: ${parsed.error.message}`)
|
|
116
|
-
}
|
|
117
|
-
return parsed.data
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function intParam(url: URL, name: string): number | undefined {
|
|
121
|
-
const raw = url.searchParams.get(name)
|
|
122
|
-
if (raw === null) return undefined
|
|
123
|
-
const value = Number(raw)
|
|
124
|
-
if (!Number.isInteger(value)) {
|
|
125
|
-
throw new RequestError(
|
|
126
|
-
400,
|
|
127
|
-
'validation',
|
|
128
|
-
`query parameter "${name}" must be an integer, got "${raw}"`,
|
|
129
|
-
)
|
|
130
|
-
}
|
|
131
|
-
return value
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* The `wait` parameter's own grammar (AUT-334): one or more ASCII digits —
|
|
136
|
-
* deliberately stricter than the lenient `since`/`rev` parse above; anything
|
|
137
|
-
* else (empty, whitespace, hex, signs, decimals) is `400 validation`. Values
|
|
138
|
-
* above the server's documented ceiling clamp to it; magnitudes beyond a
|
|
139
|
-
* safe integer are simply above the ceiling. Absence is the immediate form.
|
|
140
|
-
*/
|
|
141
|
-
function digitsParam(url: URL, name: string, ceiling: number): number | undefined {
|
|
142
|
-
const raw = url.searchParams.get(name)
|
|
143
|
-
if (raw === null) return undefined
|
|
144
|
-
if (!/^[0-9]+$/.test(raw)) {
|
|
145
|
-
throw new RequestError(
|
|
146
|
-
400,
|
|
147
|
-
'validation',
|
|
148
|
-
`query parameter "${name}" must be a whole number of seconds, got "${raw}"`,
|
|
149
|
-
)
|
|
150
|
-
}
|
|
151
|
-
const value = Number(raw)
|
|
152
|
-
if (!Number.isSafeInteger(value) || value > ceiling) return ceiling
|
|
153
|
-
return value
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function streamScopesEqual(a: StreamScope, b: StreamScope): boolean {
|
|
157
|
-
return (
|
|
158
|
-
a.kind === b.kind &&
|
|
159
|
-
(a.kind === 'build'
|
|
160
|
-
? b.kind === 'build' && a.build === b.build
|
|
161
|
-
: a.kind === 'repo'
|
|
162
|
-
? b.kind === 'repo' && a.repo === b.repo
|
|
163
|
-
: b.kind === 'session' && a.session === b.session)
|
|
164
|
-
)
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
export function createStoreServer(opts: StoreServerOptions): StoreServer {
|
|
168
|
-
const { store } = opts
|
|
169
|
-
const clock = opts.clock ?? systemClock
|
|
170
|
-
const maxEventWaitSeconds = opts.maxEventWaitSeconds ?? MAX_STREAM_WAIT_SECONDS
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* D8 gate. `buildScope` is the slug being addressed, or `'*'` for admin
|
|
174
|
-
* routes (create/list). Runs before build lookup, so an out-of-scope
|
|
175
|
-
* token learns nothing about which builds exist. Returns the verified
|
|
176
|
-
* scope (null in open local-dev mode) so event-write routes can enforce
|
|
177
|
-
* the session dimension.
|
|
178
|
-
*/
|
|
179
|
-
function validateIdentity(req: Request): void {
|
|
180
|
-
const clientAutobuild = req.headers.get(AUTOBUILD_VERSION_HEADER)
|
|
181
|
-
const clientProtocol = req.headers.get(REMOTE_STORE_PROTOCOL_VERSION_HEADER)
|
|
182
|
-
if (clientAutobuild !== AUTOBUILD_VERSION || clientProtocol !== REMOTE_STORE_PROTOCOL_VERSION) {
|
|
183
|
-
throw new RequestError(
|
|
184
|
-
409,
|
|
185
|
-
'conflict',
|
|
186
|
-
`remote store version mismatch: client Autobuild ${clientAutobuild ?? '(missing)'} protocol ${clientProtocol ?? '(missing)'}; server Autobuild ${AUTOBUILD_VERSION} protocol ${REMOTE_STORE_PROTOCOL_VERSION}`,
|
|
187
|
-
)
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function decodeArtifact(contentBase64: string): Uint8Array {
|
|
192
|
-
let content: Uint8Array
|
|
193
|
-
try {
|
|
194
|
-
content = decodeBase64(contentBase64)
|
|
195
|
-
} catch {
|
|
196
|
-
throw new RequestError(400, 'validation', 'artifact content is not valid base64')
|
|
197
|
-
}
|
|
198
|
-
if (opts.maxArtifactBytes !== undefined && content.byteLength > opts.maxArtifactBytes) {
|
|
199
|
-
throw new RequestError(
|
|
200
|
-
413,
|
|
201
|
-
'validation',
|
|
202
|
-
`artifact exceeds decoded byte ceiling of ${opts.maxArtifactBytes} bytes`,
|
|
203
|
-
)
|
|
204
|
-
}
|
|
205
|
-
return content
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function authorize(
|
|
209
|
-
req: Request,
|
|
210
|
-
kind: 'build' | 'repo' | 'session' | 'admin',
|
|
211
|
-
id: string,
|
|
212
|
-
): TokenScope | null {
|
|
213
|
-
if (opts.secret === undefined) return null
|
|
214
|
-
const header = req.headers.get('authorization')
|
|
215
|
-
const match = header === null ? null : /^Bearer\s+(.+)$/i.exec(header)
|
|
216
|
-
if (!match) throw new RequestError(401, 'auth', 'missing bearer token')
|
|
217
|
-
const scope = verifyToken(opts.secret, match[1]!, clock())
|
|
218
|
-
if (scope === null) {
|
|
219
|
-
throw new RequestError(401, 'auth', 'invalid or expired token')
|
|
220
|
-
}
|
|
221
|
-
const resource = tokenResource(scope)
|
|
222
|
-
const allowed =
|
|
223
|
-
resource.kind === 'deployment' ||
|
|
224
|
-
resource.kind === 'admin' ||
|
|
225
|
-
(kind !== 'admin' && resource.kind === kind && resource.id === id)
|
|
226
|
-
if (!allowed) {
|
|
227
|
-
const target = kind === 'admin' ? 'admin operations' : `${kind} "${id}"`
|
|
228
|
-
throw new RequestError(
|
|
229
|
-
403,
|
|
230
|
-
'auth',
|
|
231
|
-
`token scoped to ${resource.kind} "${resource.id}" may not access ${target}`,
|
|
232
|
-
)
|
|
233
|
-
}
|
|
234
|
-
return scope
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/** Loose agent-session extraction — full actor validation stays in the
|
|
238
|
-
* backing store so its D6 feedback survives the wire (protocol.ts keeps
|
|
239
|
-
* the event schema deliberately loose). */
|
|
240
|
-
function agentSession(actor: unknown): string | null {
|
|
241
|
-
if (typeof actor !== 'object' || actor === null) return null
|
|
242
|
-
const { kind, session } = actor as { kind?: unknown; session?: unknown }
|
|
243
|
-
return kind === 'agent' && typeof session === 'string' ? session : null
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/**
|
|
247
|
-
* The session half of D8 (§8.1: the token is scoped to this build *and
|
|
248
|
-
* session*): a session-scoped token may only write events attributed to
|
|
249
|
-
* the agent session it was minted for. `'*'` (admin/runner tokens) is
|
|
250
|
-
* unrestricted, and reads are gated by build scope alone — the session
|
|
251
|
-
* dimension is write attribution.
|
|
252
|
-
*/
|
|
253
|
-
function authorizeSession(scope: TokenScope | null, actor: unknown): void {
|
|
254
|
-
if (scope === null || !('session' in scope) || scope.session === '*') return
|
|
255
|
-
const session = agentSession(actor)
|
|
256
|
-
if (session !== scope.session) {
|
|
257
|
-
throw new RequestError(
|
|
258
|
-
403,
|
|
259
|
-
'auth',
|
|
260
|
-
`token scoped to session "${scope.session}" may not write events attributed to ${
|
|
261
|
-
session === null ? 'a non-agent actor' : `session "${session}"`
|
|
262
|
-
}`,
|
|
263
|
-
)
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* Delegated-write attribution (§15.1) on every event-bearing write, applied
|
|
269
|
-
* after token verification and before catalog validation (the backing
|
|
270
|
-
* store's EventValidationError is the ontology's voice; a mismatched via is
|
|
271
|
-
* an authority failure and must be 403 so a caller can distinguish "not
|
|
272
|
-
* your delegate" from "malformed event"). With no secret (open local-dev
|
|
273
|
-
* mode) there is no token to be authoritative and writes pass through
|
|
274
|
-
* unchanged. A human actor claiming a via the token does not carry → 403;
|
|
275
|
-
* a token carrying a via stamps it onto human actors (the token is
|
|
276
|
-
* authoritative); a non-human actor carrying via falls through to the
|
|
277
|
-
* backing store's validation.
|
|
278
|
-
*/
|
|
279
|
-
function enforceVia(scope: TokenScope | null, actor: unknown): unknown {
|
|
280
|
-
if (scope === null) return actor
|
|
281
|
-
const tokenVia: Via | undefined = 'via' in scope ? scope.via : undefined
|
|
282
|
-
const candidate = actor as { kind?: unknown; via?: Via } | null
|
|
283
|
-
if (typeof candidate !== 'object' || candidate === null || candidate.kind !== 'human') {
|
|
284
|
-
return actor
|
|
285
|
-
}
|
|
286
|
-
if (candidate.via !== undefined && tokenVia === undefined) {
|
|
287
|
-
throw new RequestError(403, 'auth', 'token carries no via; it may not write delegated events')
|
|
288
|
-
}
|
|
289
|
-
if (
|
|
290
|
-
candidate.via !== undefined &&
|
|
291
|
-
tokenVia !== undefined &&
|
|
292
|
-
JSON.stringify(candidate.via) !== JSON.stringify(tokenVia)
|
|
293
|
-
) {
|
|
294
|
-
throw new RequestError(
|
|
295
|
-
403,
|
|
296
|
-
'auth',
|
|
297
|
-
`token carries via ${JSON.stringify(tokenVia)}; it may not write events claiming via ${JSON.stringify(candidate.via)}`,
|
|
298
|
-
)
|
|
299
|
-
}
|
|
300
|
-
if (tokenVia !== undefined) {
|
|
301
|
-
return { ...candidate, via: tokenVia }
|
|
302
|
-
}
|
|
303
|
-
return actor
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
async function adminRoute(req: Request): Promise<Response> {
|
|
307
|
-
authorize(req, 'admin', '*')
|
|
308
|
-
if (req.method === 'POST') {
|
|
309
|
-
const body = await readBody(req, newBuildBodySchema)
|
|
310
|
-
if ((await store.getBuild(body.slug)) !== null) {
|
|
311
|
-
return fail(409, 'conflict', `build "${body.slug}" already exists`)
|
|
312
|
-
}
|
|
313
|
-
return json(201, await store.createBuild(body))
|
|
314
|
-
}
|
|
315
|
-
if (req.method === 'GET') {
|
|
316
|
-
return json(200, await store.listBuilds())
|
|
317
|
-
}
|
|
318
|
-
return fail(404, 'not-found', `no route: ${req.method} /builds`)
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
async function repoAdminRoute(req: Request): Promise<Response> {
|
|
322
|
-
authorize(req, 'admin', '*')
|
|
323
|
-
if (req.method !== 'POST') {
|
|
324
|
-
return fail(404, 'not-found', `no route: ${req.method} /repos`)
|
|
325
|
-
}
|
|
326
|
-
const body = await readBody(req, ensureRepoBodySchema)
|
|
327
|
-
return json(200, await store.ensureRepo(body.repo))
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
async function repoRoute(
|
|
331
|
-
req: Request,
|
|
332
|
-
url: URL,
|
|
333
|
-
repo: string,
|
|
334
|
-
rest: string,
|
|
335
|
-
scope: TokenScope | null,
|
|
336
|
-
): Promise<Response> {
|
|
337
|
-
const segments = rest.split('/')
|
|
338
|
-
if (segments[0] === 'streams') {
|
|
339
|
-
return streamRoute(req, url, { kind: 'repo', repo }, segments.slice(1))
|
|
340
|
-
}
|
|
341
|
-
switch (`${req.method} ${rest}`) {
|
|
342
|
-
case 'POST events': {
|
|
343
|
-
const body = await readBody(req, eventWriteWireSchema)
|
|
344
|
-
authorizeSession(scope, body.actor)
|
|
345
|
-
const actor = enforceVia(scope, body.actor)
|
|
346
|
-
return json(201, await store.appendRepo(repo, { ...body, actor } as RepositoryEventWrite))
|
|
347
|
-
}
|
|
348
|
-
case 'GET events': {
|
|
349
|
-
const since = intParam(url, 'since') ?? 0
|
|
350
|
-
const wait = digitsParam(url, 'wait', maxEventWaitSeconds)
|
|
351
|
-
return json(
|
|
352
|
-
200,
|
|
353
|
-
await withDisconnect(
|
|
354
|
-
req,
|
|
355
|
-
store.getRepoEvents(repo, since, {
|
|
356
|
-
...(wait !== undefined ? { waitSeconds: wait } : {}),
|
|
357
|
-
signal: req.signal,
|
|
358
|
-
}),
|
|
359
|
-
(): RepositoryEvent[] => [],
|
|
360
|
-
),
|
|
361
|
-
)
|
|
362
|
-
}
|
|
363
|
-
case 'POST deposits': {
|
|
364
|
-
const body = await readBody(req, depositsBodySchema)
|
|
365
|
-
authorizeSession(scope, body.event.actor)
|
|
366
|
-
const actor = enforceVia(scope, body.event.actor)
|
|
367
|
-
const inputs = body.artifacts.map((artifact) => ({
|
|
368
|
-
kind: artifact.kind,
|
|
369
|
-
content: decodeArtifact(artifact.contentBase64),
|
|
370
|
-
...(artifact.metadata !== undefined ? { metadata: artifact.metadata } : {}),
|
|
371
|
-
}))
|
|
372
|
-
const result = await store.appendRepoWithArtifacts(
|
|
373
|
-
repo,
|
|
374
|
-
inputs,
|
|
375
|
-
(deposited) =>
|
|
376
|
-
({
|
|
377
|
-
actor,
|
|
378
|
-
type: body.event.type,
|
|
379
|
-
payload: substitutePlaceholderRefs(body.event.payload, deposited),
|
|
380
|
-
}) as RepositoryEventWrite,
|
|
381
|
-
)
|
|
382
|
-
return json(201, result)
|
|
383
|
-
}
|
|
384
|
-
case 'POST artifacts': {
|
|
385
|
-
const body = await readBody(req, putArtifactBodySchema)
|
|
386
|
-
return json(
|
|
387
|
-
201,
|
|
388
|
-
await store.putRepoArtifact(repo, {
|
|
389
|
-
kind: body.kind,
|
|
390
|
-
content: decodeArtifact(body.contentBase64),
|
|
391
|
-
...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
|
|
392
|
-
}),
|
|
393
|
-
)
|
|
394
|
-
}
|
|
395
|
-
case 'GET artifacts': {
|
|
396
|
-
const kind = url.searchParams.get('kind')
|
|
397
|
-
if (kind === null || kind === '') {
|
|
398
|
-
throw new RequestError(400, 'validation', 'query parameter "kind" is required')
|
|
399
|
-
}
|
|
400
|
-
const artifact = await store.getRepoArtifact(repo, kind, intParam(url, 'rev'))
|
|
401
|
-
return artifact === null
|
|
402
|
-
? json(200, null)
|
|
403
|
-
: json(200, {
|
|
404
|
-
meta: artifact.meta,
|
|
405
|
-
contentBase64: encodeBase64(artifact.content),
|
|
406
|
-
})
|
|
407
|
-
}
|
|
408
|
-
case 'GET artifact-list':
|
|
409
|
-
return json(
|
|
410
|
-
200,
|
|
411
|
-
await store.listRepoArtifacts(repo, url.searchParams.get('kind') ?? undefined),
|
|
412
|
-
)
|
|
413
|
-
case 'POST lease/claim': {
|
|
414
|
-
const body = await readBody(req, leaseClaimBodySchema)
|
|
415
|
-
return json(200, {
|
|
416
|
-
ok: await store.claimRepoLease(repo, body.holder, body.ttlMs),
|
|
417
|
-
})
|
|
418
|
-
}
|
|
419
|
-
case 'POST lease/heartbeat': {
|
|
420
|
-
const body = await readBody(req, leaseHolderBodySchema)
|
|
421
|
-
return json(200, {
|
|
422
|
-
ok: await store.heartbeatRepo(repo, body.holder),
|
|
423
|
-
})
|
|
424
|
-
}
|
|
425
|
-
case 'POST lease/release': {
|
|
426
|
-
const body = await readBody(req, leaseHolderBodySchema)
|
|
427
|
-
await store.releaseRepoLease(repo, body.holder)
|
|
428
|
-
return json(200, { ok: true })
|
|
429
|
-
}
|
|
430
|
-
default:
|
|
431
|
-
return fail(404, 'not-found', `no route: ${req.method} /repos/:repo/${rest}`)
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
/**
|
|
436
|
-
* Session family routes (SPEC §7.1.1), mirroring the repository-journal
|
|
437
|
-
* family: a session resource token gates exactly its own `/sessions/{id}`
|
|
438
|
-
* operations; collection routes live under `/repos/{repo}/sessions` and
|
|
439
|
-
* keep the repo/admin matrix. A wrong-scope token gets `403 auth` before
|
|
440
|
-
* resource lookup (no existence leak).
|
|
441
|
-
*/
|
|
442
|
-
async function sessionCollectionRoute(req: Request, repo: string): Promise<Response> {
|
|
443
|
-
if (req.method === 'POST') {
|
|
444
|
-
const body = await readBody(req, newSessionBodySchema)
|
|
445
|
-
if (body.repo !== repo) {
|
|
446
|
-
return fail(
|
|
447
|
-
400,
|
|
448
|
-
'validation',
|
|
449
|
-
`session body repo ${JSON.stringify(body.repo)} does not match the path`,
|
|
450
|
-
)
|
|
451
|
-
}
|
|
452
|
-
return json(201, await store.createSession(body))
|
|
453
|
-
}
|
|
454
|
-
if (req.method === 'GET') {
|
|
455
|
-
return json(200, await store.listSessions(repo))
|
|
456
|
-
}
|
|
457
|
-
return fail(404, 'not-found', `no route: ${req.method} /repos/:repo/sessions`)
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
async function sessionRoute(req: Request, url: URL, id: string, rest: string): Promise<Response> {
|
|
461
|
-
const segments = rest.split('/')
|
|
462
|
-
if (segments[0] === 'streams') {
|
|
463
|
-
return streamRoute(req, url, { kind: 'session', session: id }, segments.slice(1))
|
|
464
|
-
}
|
|
465
|
-
switch (`${req.method} ${rest}`) {
|
|
466
|
-
case 'GET record':
|
|
467
|
-
return json(200, await store.getSession(id))
|
|
468
|
-
case 'POST events': {
|
|
469
|
-
const body = await readBody(req, eventWriteWireSchema)
|
|
470
|
-
return json(201, await store.appendSessionEvent(id, body as SessionEventWrite))
|
|
471
|
-
}
|
|
472
|
-
case 'GET events': {
|
|
473
|
-
const since = intParam(url, 'since') ?? 0
|
|
474
|
-
const wait = digitsParam(url, 'wait', maxEventWaitSeconds)
|
|
475
|
-
return json(
|
|
476
|
-
200,
|
|
477
|
-
await withDisconnect(
|
|
478
|
-
req,
|
|
479
|
-
store.getSessionEvents(id, since, {
|
|
480
|
-
...(wait !== undefined ? { waitSeconds: wait } : {}),
|
|
481
|
-
signal: req.signal,
|
|
482
|
-
}),
|
|
483
|
-
(): SessionEvent[] => [],
|
|
484
|
-
),
|
|
485
|
-
)
|
|
486
|
-
}
|
|
487
|
-
case 'POST deposits': {
|
|
488
|
-
const body = await readBody(req, depositsBodySchema)
|
|
489
|
-
const inputs = body.artifacts.map((artifact) => ({
|
|
490
|
-
kind: artifact.kind,
|
|
491
|
-
content: decodeArtifact(artifact.contentBase64),
|
|
492
|
-
...(artifact.metadata !== undefined ? { metadata: artifact.metadata } : {}),
|
|
493
|
-
}))
|
|
494
|
-
const result = await store.appendSessionWithArtifacts(
|
|
495
|
-
id,
|
|
496
|
-
inputs,
|
|
497
|
-
(deposited) =>
|
|
498
|
-
({
|
|
499
|
-
actor: body.event.actor,
|
|
500
|
-
type: body.event.type,
|
|
501
|
-
payload: substitutePlaceholderRefs(body.event.payload, deposited),
|
|
502
|
-
}) as SessionEventWrite,
|
|
503
|
-
)
|
|
504
|
-
return json(201, result)
|
|
505
|
-
}
|
|
506
|
-
case 'POST artifacts': {
|
|
507
|
-
const body = await readBody(req, putArtifactBodySchema)
|
|
508
|
-
return json(
|
|
509
|
-
201,
|
|
510
|
-
await store.putSessionArtifact(id, {
|
|
511
|
-
kind: body.kind,
|
|
512
|
-
content: decodeArtifact(body.contentBase64),
|
|
513
|
-
...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
|
|
514
|
-
}),
|
|
515
|
-
)
|
|
516
|
-
}
|
|
517
|
-
case 'GET artifacts': {
|
|
518
|
-
const kind = url.searchParams.get('kind')
|
|
519
|
-
if (kind === null || kind === '') {
|
|
520
|
-
throw new RequestError(400, 'validation', 'query parameter "kind" is required')
|
|
521
|
-
}
|
|
522
|
-
const artifact = await store.getSessionArtifact(id, kind, intParam(url, 'rev'))
|
|
523
|
-
return artifact === null
|
|
524
|
-
? json(200, null)
|
|
525
|
-
: json(200, {
|
|
526
|
-
meta: artifact.meta,
|
|
527
|
-
contentBase64: encodeBase64(artifact.content),
|
|
528
|
-
})
|
|
529
|
-
}
|
|
530
|
-
case 'GET artifact-list':
|
|
531
|
-
return json(
|
|
532
|
-
200,
|
|
533
|
-
await store.listSessionArtifacts(id, url.searchParams.get('kind') ?? undefined),
|
|
534
|
-
)
|
|
535
|
-
default:
|
|
536
|
-
return fail(404, 'not-found', `no route: ${req.method} /sessions/:id/${rest}`)
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
async function buildRoute(
|
|
541
|
-
req: Request,
|
|
542
|
-
url: URL,
|
|
543
|
-
slug: string,
|
|
544
|
-
rest: string,
|
|
545
|
-
scope: TokenScope | null,
|
|
546
|
-
): Promise<Response> {
|
|
547
|
-
const segments = rest.split('/')
|
|
548
|
-
if (segments[0] === 'streams') {
|
|
549
|
-
return streamRoute(req, url, { kind: 'build', build: slug }, segments.slice(1))
|
|
550
|
-
}
|
|
551
|
-
switch (`${req.method} ${rest}`) {
|
|
552
|
-
case 'POST events': {
|
|
553
|
-
const body = await readBody(req, eventWriteWireSchema)
|
|
554
|
-
authorizeSession(scope, body.actor)
|
|
555
|
-
const actor = enforceVia(scope, body.actor)
|
|
556
|
-
return json(201, await store.append(slug, { ...body, actor } as EventWrite))
|
|
557
|
-
}
|
|
558
|
-
case 'POST events/conditional': {
|
|
559
|
-
const body = await readBody(req, conditionalEventBodySchema)
|
|
560
|
-
authorizeSession(scope, body.event.actor)
|
|
561
|
-
const actor = enforceVia(scope, body.event.actor)
|
|
562
|
-
const event = await store.appendIfCurrent(slug, body.expectedSeq, {
|
|
563
|
-
...body.event,
|
|
564
|
-
actor,
|
|
565
|
-
} as EventWrite)
|
|
566
|
-
return json(event === null ? 200 : 201, event)
|
|
567
|
-
}
|
|
568
|
-
case 'GET events': {
|
|
569
|
-
const since = intParam(url, 'since') ?? 0
|
|
570
|
-
const wait = digitsParam(url, 'wait', maxEventWaitSeconds)
|
|
571
|
-
return json(
|
|
572
|
-
200,
|
|
573
|
-
await withDisconnect(
|
|
574
|
-
req,
|
|
575
|
-
store.getEvents(slug, since, {
|
|
576
|
-
...(wait !== undefined ? { waitSeconds: wait } : {}),
|
|
577
|
-
signal: req.signal,
|
|
578
|
-
}),
|
|
579
|
-
(): AbEvent[] => [],
|
|
580
|
-
),
|
|
581
|
-
)
|
|
582
|
-
}
|
|
583
|
-
case 'POST deposits': {
|
|
584
|
-
const body = await readBody(req, depositsBodySchema)
|
|
585
|
-
authorizeSession(scope, body.event.actor)
|
|
586
|
-
const actor = enforceVia(scope, body.event.actor)
|
|
587
|
-
const inputs = body.artifacts.map((artifact) => ({
|
|
588
|
-
kind: artifact.kind,
|
|
589
|
-
content: decodeArtifact(artifact.contentBase64),
|
|
590
|
-
...(artifact.metadata !== undefined ? { metadata: artifact.metadata } : {}),
|
|
591
|
-
}))
|
|
592
|
-
// The wire's makeEvent: substitute the deposited revisions for the
|
|
593
|
-
// payload's negative placeholders (protocol.ts). Runs inside the
|
|
594
|
-
// backing store's atomic appendWithArtifacts, so an invalid event
|
|
595
|
-
// rolls the whole deposit back (D6).
|
|
596
|
-
const result = await store.appendWithArtifacts(
|
|
597
|
-
slug,
|
|
598
|
-
inputs,
|
|
599
|
-
(deposited) =>
|
|
600
|
-
({
|
|
601
|
-
actor,
|
|
602
|
-
type: body.event.type,
|
|
603
|
-
payload: substitutePlaceholderRefs(body.event.payload, deposited),
|
|
604
|
-
}) as EventWrite,
|
|
605
|
-
)
|
|
606
|
-
return json(201, { event: result.event, artifacts: result.artifacts })
|
|
607
|
-
}
|
|
608
|
-
case 'POST artifacts': {
|
|
609
|
-
const body = await readBody(req, putArtifactBodySchema)
|
|
610
|
-
const meta = await store.putArtifact(slug, {
|
|
611
|
-
kind: body.kind,
|
|
612
|
-
content: decodeArtifact(body.contentBase64),
|
|
613
|
-
...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
|
|
614
|
-
})
|
|
615
|
-
return json(201, meta)
|
|
616
|
-
}
|
|
617
|
-
case 'GET artifacts': {
|
|
618
|
-
const kind = url.searchParams.get('kind')
|
|
619
|
-
if (kind === null || kind === '') {
|
|
620
|
-
throw new RequestError(400, 'validation', 'query parameter "kind" is required')
|
|
621
|
-
}
|
|
622
|
-
const artifact = await store.getArtifact(slug, kind, intParam(url, 'rev'))
|
|
623
|
-
if (artifact === null) return json(200, null)
|
|
624
|
-
return json(200, {
|
|
625
|
-
meta: artifact.meta,
|
|
626
|
-
contentBase64: encodeBase64(artifact.content),
|
|
627
|
-
})
|
|
628
|
-
}
|
|
629
|
-
case 'GET artifact-list': {
|
|
630
|
-
const kind = url.searchParams.get('kind') ?? undefined
|
|
631
|
-
return json(200, await store.listArtifacts(slug, kind))
|
|
632
|
-
}
|
|
633
|
-
case 'POST lease/claim': {
|
|
634
|
-
const body = await readBody(req, leaseClaimBodySchema)
|
|
635
|
-
return json(200, { ok: await store.claimLease(slug, body.holder, body.ttlMs) })
|
|
636
|
-
}
|
|
637
|
-
case 'POST lease/heartbeat': {
|
|
638
|
-
const body = await readBody(req, leaseHolderBodySchema)
|
|
639
|
-
return json(200, { ok: await store.heartbeat(slug, body.holder) })
|
|
640
|
-
}
|
|
641
|
-
case 'POST lease/release': {
|
|
642
|
-
const body = await readBody(req, leaseHolderBodySchema)
|
|
643
|
-
await store.releaseLease(slug, body.holder)
|
|
644
|
-
return json(200, { ok: true })
|
|
645
|
-
}
|
|
646
|
-
default:
|
|
647
|
-
return fail(404, 'not-found', `no route: ${req.method} /builds/:slug/${rest}`)
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
/**
|
|
652
|
-
* Stream routes (SPEC §7.6), shared by the build and repository families:
|
|
653
|
-
* `rest` is the segments after `streams`, the scope comes from the path.
|
|
654
|
-
* Addressed operations verify the stream actually belongs to this scope —
|
|
655
|
-
* an unknown stream and one scoped elsewhere are the same 404, so no
|
|
656
|
-
* cross-scope existence leaks.
|
|
657
|
-
*/
|
|
658
|
-
async function streamRoute(
|
|
659
|
-
req: Request,
|
|
660
|
-
url: URL,
|
|
661
|
-
scope: StreamScope,
|
|
662
|
-
rest: string[],
|
|
663
|
-
): Promise<Response> {
|
|
664
|
-
if (rest.length === 0) {
|
|
665
|
-
if (req.method === 'POST') {
|
|
666
|
-
const body = await readBody(req, createStreamBodySchema)
|
|
667
|
-
return json(201, await store.createStream(scope, body.label))
|
|
668
|
-
}
|
|
669
|
-
if (req.method === 'GET') return json(200, await store.listStreams(scope))
|
|
670
|
-
return fail(404, 'not-found', `no route: ${req.method} streams`)
|
|
671
|
-
}
|
|
672
|
-
const [streamId, leaf, ...extra] = rest
|
|
673
|
-
if (extra.length > 0 || streamId === undefined) {
|
|
674
|
-
return fail(404, 'not-found', `no route: ${req.method} streams/${rest.join('/')}`)
|
|
675
|
-
}
|
|
676
|
-
const record = await store.getStream(streamId)
|
|
677
|
-
if (record === null || !streamScopesEqual(record.scope, scope)) {
|
|
678
|
-
return fail(404, 'not-found', `unknown stream "${streamId}"`)
|
|
679
|
-
}
|
|
680
|
-
if (leaf === undefined || leaf === '') {
|
|
681
|
-
if (req.method === 'GET') return json(200, record)
|
|
682
|
-
return fail(404, 'not-found', `no route: ${req.method} streams/${streamId}`)
|
|
683
|
-
}
|
|
684
|
-
if (leaf === 'chunks') {
|
|
685
|
-
if (req.method === 'POST') {
|
|
686
|
-
const body = await readBody(req, appendStreamBodySchema)
|
|
687
|
-
return json(201, await store.appendStreamParts(streamId, body.parts as StreamPart[]))
|
|
688
|
-
}
|
|
689
|
-
if (req.method === 'GET') {
|
|
690
|
-
const since = intParam(url, 'since') ?? 0
|
|
691
|
-
// §9 grammar: `wait` is digits-only server-wide, clamped to the
|
|
692
|
-
// 30-second stream ceiling (MAX_STREAM_WAIT_SECONDS, not the hosted
|
|
693
|
-
// event ceiling of §6's hosted exception).
|
|
694
|
-
const wait = digitsParam(url, 'wait', MAX_STREAM_WAIT_SECONDS)
|
|
695
|
-
return json(
|
|
696
|
-
200,
|
|
697
|
-
await withDisconnect(
|
|
698
|
-
req,
|
|
699
|
-
store.readStream(streamId, {
|
|
700
|
-
since,
|
|
701
|
-
...(wait !== undefined ? { waitSeconds: wait } : {}),
|
|
702
|
-
signal: req.signal,
|
|
703
|
-
}),
|
|
704
|
-
// The empty read carries the already-fetched record's lifecycle
|
|
705
|
-
// fields verbatim — the response to a disconnected client is
|
|
706
|
-
// discarded either way, but the wire schema must still hold.
|
|
707
|
-
(): StreamRead => ({
|
|
708
|
-
chunks: [],
|
|
709
|
-
status: record.status,
|
|
710
|
-
...(record.outcome !== undefined ? { outcome: record.outcome } : {}),
|
|
711
|
-
...(record.artifact !== undefined ? { artifact: record.artifact } : {}),
|
|
712
|
-
}),
|
|
713
|
-
),
|
|
714
|
-
)
|
|
715
|
-
}
|
|
716
|
-
return fail(404, 'not-found', `no route: ${req.method} streams/${streamId}/chunks`)
|
|
717
|
-
}
|
|
718
|
-
if (leaf === 'close') {
|
|
719
|
-
if (req.method !== 'POST') {
|
|
720
|
-
return fail(404, 'not-found', `no route: ${req.method} streams/${streamId}/close`)
|
|
721
|
-
}
|
|
722
|
-
const body = await readBody(req, closeStreamBodySchema)
|
|
723
|
-
return json(200, await store.closeStream(streamId, body.outcome as StreamOutcome))
|
|
724
|
-
}
|
|
725
|
-
return fail(404, 'not-found', `no route: ${req.method} streams/${rest.join('/')}`)
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
/**
|
|
729
|
-
* Top-level addressed stream routes. A stream id is globally unique but
|
|
730
|
-
* carries no scope, so the client (which addresses streams by id alone)
|
|
731
|
-
* uses these: the server resolves the stream's own scope and authorizes
|
|
732
|
-
* against it, exactly like the family routes authorize by path. An
|
|
733
|
-
* unknown stream and a scope the token does not cover are both 404 — the
|
|
734
|
-
* same no-existence-leak rule the family routes apply.
|
|
735
|
-
*/
|
|
736
|
-
async function globalStreamRoute(req: Request, url: URL, segments: string[]): Promise<Response> {
|
|
737
|
-
const streamId = segments[1]
|
|
738
|
-
if (streamId === undefined || streamId === '') {
|
|
739
|
-
return fail(404, 'not-found', `no route: ${req.method} ${url.pathname}`)
|
|
740
|
-
}
|
|
741
|
-
const record = await store.getStream(streamId)
|
|
742
|
-
if (record === null) return fail(404, 'not-found', `unknown stream "${streamId}"`)
|
|
743
|
-
const scopeId =
|
|
744
|
-
record.scope.kind === 'build'
|
|
745
|
-
? record.scope.build
|
|
746
|
-
: record.scope.kind === 'repo'
|
|
747
|
-
? record.scope.repo
|
|
748
|
-
: record.scope.session
|
|
749
|
-
authorize(req, record.scope.kind, scopeId)
|
|
750
|
-
return streamRoute(req, url, record.scope, segments.slice(1))
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
async function route(req: Request): Promise<Response> {
|
|
754
|
-
const url = new URL(req.url)
|
|
755
|
-
let segments: string[]
|
|
756
|
-
try {
|
|
757
|
-
segments = url.pathname
|
|
758
|
-
.split('/')
|
|
759
|
-
.filter((part) => part.length > 0)
|
|
760
|
-
.map(decodeURIComponent)
|
|
761
|
-
} catch {
|
|
762
|
-
throw new RequestError(400, 'validation', `malformed path: ${url.pathname}`)
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
if (segments.length === 1 && segments[0] === 'health' && req.method === 'GET') {
|
|
766
|
-
return json(200, {
|
|
767
|
-
ok: true,
|
|
768
|
-
autobuildVersion: AUTOBUILD_VERSION,
|
|
769
|
-
protocolVersion: REMOTE_STORE_PROTOCOL_VERSION,
|
|
770
|
-
})
|
|
771
|
-
}
|
|
772
|
-
if (segments[0] === 'repos' || segments[0] === 'builds' || segments[0] === 'streams') {
|
|
773
|
-
validateIdentity(req)
|
|
774
|
-
}
|
|
775
|
-
if (segments[0] === 'streams') {
|
|
776
|
-
return globalStreamRoute(req, url, segments)
|
|
777
|
-
}
|
|
778
|
-
if (segments[0] === 'sessions' && segments.length >= 2) {
|
|
779
|
-
const id = segments[1]!
|
|
780
|
-
authorize(req, 'session', id)
|
|
781
|
-
if (segments.length === 2) {
|
|
782
|
-
if (req.method === 'GET') return sessionRoute(req, url, id, 'record')
|
|
783
|
-
return fail(404, 'not-found', `no route: ${req.method} ${url.pathname}`)
|
|
784
|
-
}
|
|
785
|
-
return sessionRoute(req, url, id, segments.slice(2).join('/'))
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
if (segments[0] === 'repos') {
|
|
789
|
-
if (segments.length === 1) return repoAdminRoute(req)
|
|
790
|
-
const repo = segments[1]!
|
|
791
|
-
const scope = authorize(req, 'repo', repo)
|
|
792
|
-
if (segments.length === 3 && segments[2] === 'sessions') {
|
|
793
|
-
return sessionCollectionRoute(req, repo)
|
|
794
|
-
}
|
|
795
|
-
const record = await store.getRepo(repo)
|
|
796
|
-
if (record === null) {
|
|
797
|
-
return fail(404, 'not-found', `unknown repo "${repo}"`)
|
|
798
|
-
}
|
|
799
|
-
if (segments.length === 2) {
|
|
800
|
-
if (req.method === 'GET') return json(200, record)
|
|
801
|
-
return fail(404, 'not-found', `no route: ${req.method} ${url.pathname}`)
|
|
802
|
-
}
|
|
803
|
-
return repoRoute(req, url, repo, segments.slice(2).join('/'), scope)
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
if (segments[0] !== 'builds') {
|
|
807
|
-
return fail(404, 'not-found', `no route: ${req.method} ${url.pathname}`)
|
|
808
|
-
}
|
|
809
|
-
if (segments.length === 1) return adminRoute(req)
|
|
810
|
-
|
|
811
|
-
const slug = segments[1]!
|
|
812
|
-
const scope = authorize(req, 'build', slug)
|
|
813
|
-
const record = await store.getBuild(slug)
|
|
814
|
-
if (record === null) {
|
|
815
|
-
return fail(404, 'not-found', `unknown build "${slug}"`)
|
|
816
|
-
}
|
|
817
|
-
if (segments.length === 2) {
|
|
818
|
-
if (req.method === 'GET') return json(200, record)
|
|
819
|
-
return fail(404, 'not-found', `no route: ${req.method} ${url.pathname}`)
|
|
820
|
-
}
|
|
821
|
-
return buildRoute(req, url, slug, segments.slice(2).join('/'), scope)
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
/** A held read must not outlive its client. When the peer disconnects, Bun
|
|
825
|
-
* keeps the handler promise pending — which would stall `server.stop()` —
|
|
826
|
-
* so a held read races the request's abort signal and resolves empty; the
|
|
827
|
-
* response to a disconnected client is discarded either way. The held
|
|
828
|
-
* routes also pass the same signal into the backing store read itself, so
|
|
829
|
-
* the poll loop behind the request stops too (AUT-380) instead of polling
|
|
830
|
-
* to its own wait bound; this guard remains for `server.stop()` and for
|
|
831
|
-
* any read that completes between abort and loop exit. */
|
|
832
|
-
function withDisconnect<T>(req: Request, read: Promise<T>, empty: () => T): Promise<T> {
|
|
833
|
-
const signal = req.signal
|
|
834
|
-
if (signal.aborted) return Promise.resolve(empty())
|
|
835
|
-
return new Promise<T>((resolve, reject) => {
|
|
836
|
-
const onAbort = (): void => resolve(empty())
|
|
837
|
-
signal.addEventListener('abort', onAbort, { once: true })
|
|
838
|
-
read.then(
|
|
839
|
-
(value) => {
|
|
840
|
-
signal.removeEventListener('abort', onAbort)
|
|
841
|
-
resolve(value)
|
|
842
|
-
},
|
|
843
|
-
(error) => {
|
|
844
|
-
signal.removeEventListener('abort', onAbort)
|
|
845
|
-
reject(error)
|
|
846
|
-
},
|
|
847
|
-
)
|
|
848
|
-
})
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
function errorResponse(error: unknown): Response {
|
|
852
|
-
if (error instanceof RequestError) {
|
|
853
|
-
return fail(error.status, error.kind, error.message)
|
|
854
|
-
}
|
|
855
|
-
// D6: validation feedback must survive the wire with its message intact.
|
|
856
|
-
if (error instanceof EventValidationError) {
|
|
857
|
-
return fail(422, 'validation', error.message)
|
|
858
|
-
}
|
|
859
|
-
if (error instanceof StreamBatchTooLargeError) {
|
|
860
|
-
return fail(413, 'validation', error.message)
|
|
861
|
-
}
|
|
862
|
-
if (error instanceof StreamClosedError) {
|
|
863
|
-
return fail(409, 'conflict', error.message)
|
|
864
|
-
}
|
|
865
|
-
if (error instanceof Error && error.message.includes('already exists')) {
|
|
866
|
-
return fail(409, 'conflict', error.message)
|
|
867
|
-
}
|
|
868
|
-
if (
|
|
869
|
-
error instanceof Error &&
|
|
870
|
-
(error.message.startsWith('unknown build') ||
|
|
871
|
-
error.message.startsWith('unknown repo') ||
|
|
872
|
-
error.message.startsWith('unknown session') ||
|
|
873
|
-
error.message.startsWith('unknown stream'))
|
|
874
|
-
) {
|
|
875
|
-
return fail(404, 'not-found', error.message)
|
|
876
|
-
}
|
|
877
|
-
return fail(500, 'internal', error instanceof Error ? error.message : String(error))
|
|
878
|
-
}
|
|
879
|
-
|
|
880
|
-
return {
|
|
881
|
-
fetch: async (req: Request): Promise<Response> => {
|
|
882
|
-
try {
|
|
883
|
-
return await route(req)
|
|
884
|
-
} catch (error) {
|
|
885
|
-
const response = errorResponse(error)
|
|
886
|
-
if (response.status >= 500 && opts.onInternalError !== undefined) {
|
|
887
|
-
try {
|
|
888
|
-
await opts.onInternalError(error, req)
|
|
889
|
-
} catch {
|
|
890
|
-
// Diagnostics must never replace the protocol response.
|
|
891
|
-
}
|
|
892
|
-
}
|
|
893
|
-
return response
|
|
894
|
-
}
|
|
895
|
-
},
|
|
896
|
-
}
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
export interface StartStoreServerOptions extends StoreServerOptions {
|
|
900
|
-
/** 0 (the default) lets the OS pick a free port — tests rely on this. */
|
|
901
|
-
port?: number
|
|
902
|
-
hostname?: string
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
/** Wrap the handler in Bun.serve; `stop` force-closes open connections. */
|
|
906
|
-
export function startStoreServer(opts: StartStoreServerOptions): {
|
|
907
|
-
url: string
|
|
908
|
-
stop: () => Promise<void>
|
|
909
|
-
} {
|
|
910
|
-
const handler = createStoreServer(opts)
|
|
911
|
-
const hostname = opts.hostname ?? '127.0.0.1'
|
|
912
|
-
const server = Bun.serve({
|
|
913
|
-
port: opts.port ?? 0,
|
|
914
|
-
hostname,
|
|
915
|
-
fetch: handler.fetch,
|
|
916
|
-
})
|
|
917
|
-
return {
|
|
918
|
-
url: `http://${hostname}:${server.port}`,
|
|
919
|
-
stop: async () => {
|
|
920
|
-
await server.stop(true)
|
|
921
|
-
},
|
|
922
|
-
}
|
|
923
|
-
}
|