@boxxy/server 0.0.1 → 0.1.3
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 +57 -5
- package/dist/auth/authorize-call.d.ts +11 -0
- package/dist/auth/is-browser-request.d.ts +1 -0
- package/dist/auth/pick-request-scope.d.ts +9 -0
- package/dist/config/pick-server-secrets.d.ts +16 -0
- package/dist/config/read-caller-proof.d.ts +10 -0
- package/dist/config/read-server-config.d.ts +9 -0
- package/dist/config/read-trusted-issuers.d.ts +8 -0
- package/dist/config/server-config-schema.d.ts +42 -0
- package/dist/create-capacity-backoff.d.ts +2 -0
- package/dist/handlers/api.d.ts +1683 -0
- package/dist/handlers/attempt-handlers.d.ts +130 -0
- package/dist/handlers/build-owner-fact.d.ts +3 -0
- package/dist/handlers/item-handlers.d.ts +168 -0
- package/dist/handlers/outbox-handlers.d.ts +7 -0
- package/dist/handlers/plan-handlers.d.ts +62 -0
- package/dist/handlers/read-item-ids.d.ts +10 -0
- package/dist/handlers/relation-handlers.d.ts +41 -0
- package/dist/handlers/report-handlers.d.ts +102 -0
- package/dist/handlers/rules-handlers.d.ts +20 -0
- package/dist/handlers/runner-handlers.d.ts +65 -0
- package/dist/handlers/to-wire-json.d.ts +5 -0
- package/dist/handlers/write-levers.d.ts +11 -0
- package/dist/index.d.ts +12 -0
- package/dist/main.d.ts +1 -0
- package/dist/no-caller-proof.d.ts +2 -0
- package/dist/outbox/create-off-outbox.d.ts +3 -0
- package/dist/outbox/start-outbox-sender.d.ts +10 -0
- package/dist/read-limited-request.d.ts +1 -0
- package/dist/rules/create-rules-holder.d.ts +20 -0
- package/dist/rules/load-server-rules.d.ts +7 -0
- package/dist/rules/read-rules-checkout.d.ts +9 -0
- package/dist/serve-api.d.ts +50 -0
- package/dist/start-server.d.ts +18 -0
- package/dist/test-utils/build-mcp-request.d.ts +8 -0
- package/dist/test-utils/build-rpc-request.d.ts +7 -0
- package/dist/test-utils/build-session-input.d.ts +1 -0
- package/dist/test-utils/create-fake-caller-proof.d.ts +5 -0
- package/dist/test-utils/create-test-server.d.ts +30 -0
- package/dist/test-utils/list-owner-procedures.d.ts +5 -0
- package/dist/test-utils/read-mcp-answer.d.ts +11 -0
- package/dist/test-utils/wait-for.d.ts +1 -0
- package/dist/tick/run-tick.d.ts +24 -0
- package/dist/tick/start-tick-loop.d.ts +12 -0
- package/dist/types.d.ts +41 -0
- package/package.json +48 -4
- package/src/auth/authorize-call.ts +50 -0
- package/src/auth/is-browser-request.ts +8 -0
- package/src/auth/pick-request-scope.ts +37 -0
- package/src/config/pick-server-secrets.ts +48 -0
- package/src/config/read-caller-proof.ts +36 -0
- package/src/config/read-server-config.ts +41 -0
- package/src/config/read-trusted-issuers.ts +60 -0
- package/src/config/server-config-schema.ts +71 -0
- package/src/create-capacity-backoff.ts +18 -0
- package/src/handlers/api.ts +23 -0
- package/src/handlers/attempt-handlers.ts +244 -0
- package/src/handlers/build-owner-fact.ts +20 -0
- package/src/handlers/item-handlers.ts +462 -0
- package/src/handlers/outbox-handlers.ts +15 -0
- package/src/handlers/plan-handlers.ts +75 -0
- package/src/handlers/read-item-ids.ts +31 -0
- package/src/handlers/relation-handlers.ts +143 -0
- package/src/handlers/report-handlers.ts +112 -0
- package/src/handlers/rules-handlers.ts +46 -0
- package/src/handlers/runner-handlers.ts +117 -0
- package/src/handlers/to-wire-json.ts +20 -0
- package/src/handlers/write-levers.ts +53 -0
- package/src/index.ts +12 -0
- package/src/main.ts +31 -0
- package/src/no-caller-proof.ts +7 -0
- package/src/outbox/create-off-outbox.ts +21 -0
- package/src/outbox/start-outbox-sender.ts +89 -0
- package/src/read-limited-request.ts +43 -0
- package/src/rules/create-rules-holder.ts +107 -0
- package/src/rules/load-server-rules.ts +24 -0
- package/src/rules/read-rules-checkout.ts +65 -0
- package/src/serve-api.ts +178 -0
- package/src/start-server.ts +308 -0
- package/src/test-utils/build-mcp-request.ts +34 -0
- package/src/test-utils/build-rpc-request.ts +23 -0
- package/src/test-utils/build-session-input.ts +16 -0
- package/src/test-utils/create-fake-caller-proof.ts +18 -0
- package/src/test-utils/create-test-server.ts +142 -0
- package/src/test-utils/list-owner-procedures.ts +38 -0
- package/src/test-utils/read-mcp-answer.ts +26 -0
- package/src/test-utils/wait-for.ts +14 -0
- package/src/tick/run-tick.ts +212 -0
- package/src/tick/start-tick-loop.ts +46 -0
- package/src/types.ts +86 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { createNullNotifier, outboxLimits, watchPullRequests } from '@boxxy/dispatch';
|
|
2
|
+
import type { SpawnSettings } from '@boxxy/dispatch';
|
|
3
|
+
import { createGithubForge } from '@boxxy/forge-github';
|
|
4
|
+
import type { AttemptId, Notifier, SessionReading } from '@boxxy/model';
|
|
5
|
+
import { brandSchemas } from '@boxxy/model';
|
|
6
|
+
import { createAtcNotifier } from '@boxxy/notifier-atc';
|
|
7
|
+
import type { UncheckedQueryClient } from '@boxxy/pepys';
|
|
8
|
+
import { createPepysReader } from '@boxxy/pepys/reader';
|
|
9
|
+
import { createPepysFactSink } from '@boxxy/pepys/sink';
|
|
10
|
+
import { createSQLiteClaimStore } from '@boxxy/store-sqlite';
|
|
11
|
+
import type { AtcClient } from '@boxxy/worker-atc';
|
|
12
|
+
import { createAtcWorker } from '@boxxy/worker-atc';
|
|
13
|
+
import { createORPCClient } from '@orpc/client';
|
|
14
|
+
import { RPCLink } from '@orpc/client/fetch';
|
|
15
|
+
import { buildTokenDigest } from './auth/pick-request-scope.ts';
|
|
16
|
+
import { pickServerSecrets } from './config/pick-server-secrets.ts';
|
|
17
|
+
import { readCallerProof } from './config/read-caller-proof.ts';
|
|
18
|
+
import { readServerConfig } from './config/read-server-config.ts';
|
|
19
|
+
import type { ServerConfig } from './config/server-config-schema.ts';
|
|
20
|
+
import { createCapacityBackoff } from './create-capacity-backoff.ts';
|
|
21
|
+
import { createOffOutbox } from './outbox/create-off-outbox.ts';
|
|
22
|
+
import type { OutboxSender } from './outbox/start-outbox-sender.ts';
|
|
23
|
+
import { startOutboxSender } from './outbox/start-outbox-sender.ts';
|
|
24
|
+
import { createRulesHolder } from './rules/create-rules-holder.ts';
|
|
25
|
+
import { maxRequestBytes, serveApi } from './serve-api.ts';
|
|
26
|
+
import { runTick } from './tick/run-tick.ts';
|
|
27
|
+
import { startTickLoop } from './tick/start-tick-loop.ts';
|
|
28
|
+
import type { ServerDeps, ServerLog } from './types.ts';
|
|
29
|
+
|
|
30
|
+
export interface StartServerInput {
|
|
31
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
32
|
+
readonly log: ServerLog;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface RunningServer {
|
|
36
|
+
readonly ownerPort: number;
|
|
37
|
+
readonly workerPort: number;
|
|
38
|
+
readonly stop: () => Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type StartAnswer =
|
|
42
|
+
| { readonly kind: 'started'; readonly server: RunningServer }
|
|
43
|
+
|
|
44
|
+
// the server did not start; the message names no secret value
|
|
45
|
+
| { readonly kind: 'refused'; readonly message: string };
|
|
46
|
+
|
|
47
|
+
const tickMs = 5000;
|
|
48
|
+
const watchMs = 5 * 60 * 1000;
|
|
49
|
+
|
|
50
|
+
// Reads the configuration and the secrets, loads the rules, opens the store, builds the adapters,
|
|
51
|
+
// starts both listeners, the runner tick, the pull request watch and the outbox sender.
|
|
52
|
+
export async function startServer(input: StartServerInput): Promise<StartAnswer> {
|
|
53
|
+
const configPath = input.env['BOXXY_CONFIG'] ?? '';
|
|
54
|
+
|
|
55
|
+
if (configPath === '') {
|
|
56
|
+
return { kind: 'refused', message: 'set BOXXY_CONFIG to the path of the configuration file' };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const read = await readServerConfig(configPath);
|
|
60
|
+
|
|
61
|
+
if (read.kind === 'invalid') {
|
|
62
|
+
return { kind: 'refused', message: read.message };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const config = read.config;
|
|
66
|
+
const secrets = pickServerSecrets(config, input.env);
|
|
67
|
+
|
|
68
|
+
if (secrets.kind === 'missing') {
|
|
69
|
+
return { kind: 'refused', message: `set these variables: ${secrets.names.join(', ')}` };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const rules = await createRulesHolder(config.rules.folder, input.log);
|
|
73
|
+
|
|
74
|
+
if (rules.kind !== 'loaded') {
|
|
75
|
+
return { kind: 'refused', message: `the rules did not load: ${JSON.stringify(rules)}` };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const clock = { now: () => brandSchemas.instant.parse(Date.now()) };
|
|
79
|
+
|
|
80
|
+
const proof = await readCallerProof(config.trustedIssuers, clock);
|
|
81
|
+
|
|
82
|
+
if (proof.kind === 'refused') {
|
|
83
|
+
return proof;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const store = await createSQLiteClaimStore(config.store.file);
|
|
87
|
+
|
|
88
|
+
const runner = brandSchemas.runnerId.parse(config.runner);
|
|
89
|
+
|
|
90
|
+
// the client type is an object literal of the port's calls: an interface has no index
|
|
91
|
+
// signature, so `createORPCClient` refuses `AtcClient` itself
|
|
92
|
+
const atc = createORPCClient<{ readonly session: AtcClient['session'] }>(
|
|
93
|
+
new RPCLink({
|
|
94
|
+
...toLinkTarget(config.atc.url),
|
|
95
|
+
headers: { authorization: `Bearer ${secrets.secrets.atcToken}` },
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const worker = createAtcWorker({ client: atc, trustedFolders: config.atc.trustedFolders });
|
|
100
|
+
const notifier = buildNotifier(config, atc);
|
|
101
|
+
|
|
102
|
+
const pepys = startPepys({
|
|
103
|
+
config,
|
|
104
|
+
secret: secrets.secrets.pepysSigningSecret,
|
|
105
|
+
store,
|
|
106
|
+
clock,
|
|
107
|
+
log: input.log,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const forge =
|
|
111
|
+
config.github === undefined
|
|
112
|
+
? null
|
|
113
|
+
: createGithubForge({
|
|
114
|
+
fetch,
|
|
115
|
+
baseUrl: config.github.baseUrl,
|
|
116
|
+
...(secrets.secrets.githubToken !== null && { token: secrets.secrets.githubToken }),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const readings = new Map<AttemptId, SessionReading>();
|
|
120
|
+
|
|
121
|
+
const holder = rules.holder;
|
|
122
|
+
|
|
123
|
+
const deps: ServerDeps = {
|
|
124
|
+
store,
|
|
125
|
+
runner,
|
|
126
|
+
rulesState: holder.state,
|
|
127
|
+
loadProposedRules: holder.loadProposed,
|
|
128
|
+
clock,
|
|
129
|
+
newId,
|
|
130
|
+
callerProof: proof.callerProof,
|
|
131
|
+
workerTokenDigest: buildTokenDigest(secrets.secrets.workerToken),
|
|
132
|
+
pepys: pepys?.reader ?? null,
|
|
133
|
+
forge,
|
|
134
|
+
outbox: pepys?.sender ?? createOffOutbox(store, clock),
|
|
135
|
+
readings,
|
|
136
|
+
log: input.log,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const listeners = serveApi(deps);
|
|
140
|
+
|
|
141
|
+
const owner = listeners.owner.listen({
|
|
142
|
+
...config.api.listen,
|
|
143
|
+
maxRequestBodySize: maxRequestBytes,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const workerListener = listeners.worker.listen({
|
|
147
|
+
...config.api.workerListen,
|
|
148
|
+
maxRequestBodySize: maxRequestBytes,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const backoff = createCapacityBackoff();
|
|
152
|
+
|
|
153
|
+
const ticks = startTickLoop({
|
|
154
|
+
runOnce: async () => {
|
|
155
|
+
await holder.reload();
|
|
156
|
+
|
|
157
|
+
await runTick({
|
|
158
|
+
store,
|
|
159
|
+
runner,
|
|
160
|
+
worker,
|
|
161
|
+
rules: holder.state().rules,
|
|
162
|
+
backoff,
|
|
163
|
+
spawn: buildSpawnSettings(config, holder.texts()),
|
|
164
|
+
notifier,
|
|
165
|
+
now: clock.now(),
|
|
166
|
+
newId,
|
|
167
|
+
readings,
|
|
168
|
+
log: input.log,
|
|
169
|
+
});
|
|
170
|
+
},
|
|
171
|
+
intervalMs: tickMs,
|
|
172
|
+
failureMessage: 'the runner tick failed',
|
|
173
|
+
log: input.log,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const watch =
|
|
177
|
+
forge === null
|
|
178
|
+
? null
|
|
179
|
+
: startTickLoop({
|
|
180
|
+
runOnce: async () => {
|
|
181
|
+
const report = await watchPullRequests({
|
|
182
|
+
store,
|
|
183
|
+
forge,
|
|
184
|
+
rules: holder.state().rules,
|
|
185
|
+
runner,
|
|
186
|
+
now: clock.now(),
|
|
187
|
+
newId,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
if (report.unanswered > 0) {
|
|
191
|
+
input.log.warn('the forge did not answer the pull request watch', { ...report });
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
intervalMs: watchMs,
|
|
195
|
+
failureMessage: 'the pull request watch failed',
|
|
196
|
+
log: input.log,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
kind: 'started',
|
|
201
|
+
server: {
|
|
202
|
+
ownerPort: owner.server?.port ?? 0,
|
|
203
|
+
workerPort: workerListener.server?.port ?? 0,
|
|
204
|
+
stop: async () => {
|
|
205
|
+
await ticks.stop();
|
|
206
|
+
await watch?.stop();
|
|
207
|
+
await pepys?.sender.stop();
|
|
208
|
+
await owner.stop();
|
|
209
|
+
await workerListener.stop();
|
|
210
|
+
await store.close();
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// oRPC's link takes the origin apart from the route's path
|
|
217
|
+
function toLinkTarget(address: string): { origin: string; url: `/${string}` } {
|
|
218
|
+
const parsed = new URL(address);
|
|
219
|
+
|
|
220
|
+
return { origin: parsed.origin, url: `/${parsed.pathname.replace(/^\/+/u, '')}` };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function buildNotifier(config: ServerConfig, atc: AtcClient): Notifier {
|
|
224
|
+
const session = config.atc.noticeSession;
|
|
225
|
+
|
|
226
|
+
if (session === undefined) {
|
|
227
|
+
return createNullNotifier();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return createAtcNotifier({
|
|
231
|
+
client: { sendMessage: (request) => atc.session.message(request) },
|
|
232
|
+
session,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
interface PepysWiring {
|
|
237
|
+
readonly reader: ReturnType<typeof createPepysReader>;
|
|
238
|
+
readonly sender: OutboxSender;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
interface PepysInput {
|
|
242
|
+
readonly config: ServerConfig;
|
|
243
|
+
readonly secret: string | null;
|
|
244
|
+
readonly store: ServerDeps['store'];
|
|
245
|
+
readonly clock: ServerDeps['clock'];
|
|
246
|
+
readonly log: ServerLog;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const pepysTimeoutMs = 10_000;
|
|
250
|
+
const outboxIdleMs = 5000;
|
|
251
|
+
|
|
252
|
+
// null: the configuration has no pepys section
|
|
253
|
+
function startPepys(input: PepysInput): PepysWiring | null {
|
|
254
|
+
const config = input.config;
|
|
255
|
+
const secret = input.secret;
|
|
256
|
+
|
|
257
|
+
if (config.pepys === undefined || secret === null) {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const client = createORPCClient<UncheckedQueryClient>(
|
|
262
|
+
new RPCLink(toLinkTarget(config.pepys.apiUrl)),
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const reader = createPepysReader({
|
|
266
|
+
client,
|
|
267
|
+
environment: config.pepys.environment,
|
|
268
|
+
timeoutMs: pepysTimeoutMs,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const sink = createPepysFactSink({
|
|
272
|
+
url: config.pepys.ingestUrl,
|
|
273
|
+
secret,
|
|
274
|
+
timeoutMs: pepysTimeoutMs,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const sender = startOutboxSender({
|
|
278
|
+
store: input.store,
|
|
279
|
+
sink,
|
|
280
|
+
clock: input.clock,
|
|
281
|
+
limits: outboxLimits,
|
|
282
|
+
random: Math.random,
|
|
283
|
+
idleMs: outboxIdleMs,
|
|
284
|
+
log: input.log,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
return { reader, sender };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function newId(): string {
|
|
291
|
+
return Bun.randomUUIDv7();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function buildSpawnSettings(
|
|
295
|
+
config: ServerConfig,
|
|
296
|
+
prompts: ReadonlyMap<string, string>,
|
|
297
|
+
): SpawnSettings {
|
|
298
|
+
return {
|
|
299
|
+
target: config.spawn.target,
|
|
300
|
+
repoPaths: new Map(
|
|
301
|
+
Object.entries(config.spawn.repoPaths).map(([repo, folder]) => [
|
|
302
|
+
brandSchemas.repoName.parse(repo),
|
|
303
|
+
folder,
|
|
304
|
+
]),
|
|
305
|
+
),
|
|
306
|
+
prompts,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
interface MCPRequestInput {
|
|
2
|
+
readonly path: '/mcp' | '/mcp/session';
|
|
3
|
+
|
|
4
|
+
// the JSON-RPC method, such as `tools/list`
|
|
5
|
+
readonly method: string;
|
|
6
|
+
readonly params: Readonly<Record<string, unknown>>;
|
|
7
|
+
|
|
8
|
+
// the bearer token; none when left out
|
|
9
|
+
readonly token?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// One JSON-RPC request to an MCP endpoint. The endpoints keep no session, so a request needs no
|
|
13
|
+
// `initialize` first.
|
|
14
|
+
export function buildMCPRequest(request: MCPRequestInput): Request {
|
|
15
|
+
const headers = new Headers({
|
|
16
|
+
'content-type': 'application/json',
|
|
17
|
+
accept: 'application/json, text/event-stream',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
if (request.token !== undefined) {
|
|
21
|
+
headers.set('authorization', `Bearer ${request.token}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return new Request(`http://boxxy.test${request.path}`, {
|
|
25
|
+
method: 'POST',
|
|
26
|
+
headers,
|
|
27
|
+
body: JSON.stringify({
|
|
28
|
+
jsonrpc: '2.0',
|
|
29
|
+
id: 1,
|
|
30
|
+
method: request.method,
|
|
31
|
+
params: request.params,
|
|
32
|
+
}),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
interface RPCRequestInput {
|
|
2
|
+
// the procedure as the access table names it, such as `item.get`
|
|
3
|
+
readonly procedure: string;
|
|
4
|
+
readonly input: unknown;
|
|
5
|
+
|
|
6
|
+
// the bearer token; none when left out
|
|
7
|
+
readonly token?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// A call of one procedure on the oRPC route, as the oRPC client sends it.
|
|
11
|
+
export function buildRPCRequest(request: RPCRequestInput): Request {
|
|
12
|
+
const headers = new Headers({ 'content-type': 'application/json' });
|
|
13
|
+
|
|
14
|
+
if (request.token !== undefined) {
|
|
15
|
+
headers.set('authorization', `Bearer ${request.token}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return new Request(`http://boxxy.test/rpc/${request.procedure.replace('.', '/')}`, {
|
|
19
|
+
method: 'POST',
|
|
20
|
+
headers,
|
|
21
|
+
body: JSON.stringify({ json: request.input }),
|
|
22
|
+
});
|
|
23
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// A valid input of a session or hook procedure that names the attempt; `{}` for any other.
|
|
2
|
+
export function buildSessionInput(procedure: string, attempt: string): unknown {
|
|
3
|
+
const session = { attempt };
|
|
4
|
+
|
|
5
|
+
const inputs: Readonly<Record<string, unknown>> = {
|
|
6
|
+
'attempt.evidence': { session, kind: 'test', ref: 'bun test' },
|
|
7
|
+
'attempt.closeOut': { session, closeOut: {} },
|
|
8
|
+
'attempt.bindSession': {
|
|
9
|
+
session: { attempt, agentSession: 'agent-1', product: 'claude_code' },
|
|
10
|
+
seenAt: '2026-11-01T09:00:00Z',
|
|
11
|
+
},
|
|
12
|
+
'attempt.recordPullRequest': { session, url: 'https://github.com/example/app/pull/7' },
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
return inputs[procedure] ?? {};
|
|
16
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { CallerIdentity, CallerProof } from '@boxxy/model';
|
|
2
|
+
|
|
3
|
+
export interface FakeCallerProof extends CallerProof {
|
|
4
|
+
// who every later request is from; null proves no request
|
|
5
|
+
setCaller: (caller: CallerIdentity | null) => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// A proof that proves no request until the test names a caller.
|
|
9
|
+
export function createFakeCallerProof(): FakeCallerProof {
|
|
10
|
+
let current: CallerIdentity | null = null;
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
prove: () => Promise.resolve(current),
|
|
14
|
+
setCaller: (caller) => {
|
|
15
|
+
current = caller;
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { FakeForge } from '@boxxy/dispatch/test-utils/create-fake-forge';
|
|
2
|
+
import { createFakeForge } from '@boxxy/dispatch/test-utils/create-fake-forge';
|
|
3
|
+
import type { TestDispatch } from '@boxxy/dispatch/test-utils/create-test-dispatch';
|
|
4
|
+
import { createTestDispatch } from '@boxxy/dispatch/test-utils/create-test-dispatch';
|
|
5
|
+
import type {
|
|
6
|
+
AttemptId,
|
|
7
|
+
AttemptRow,
|
|
8
|
+
CallerProof,
|
|
9
|
+
Clock,
|
|
10
|
+
PhaseName,
|
|
11
|
+
SessionReading,
|
|
12
|
+
} from '@boxxy/model';
|
|
13
|
+
import type { FakeNotifier } from '@boxxy/model/test-utils/create-fake-notifier';
|
|
14
|
+
import { createFakeNotifier } from '@boxxy/model/test-utils/create-fake-notifier';
|
|
15
|
+
import type { FakePepysReader } from '@boxxy/model/test-utils/create-fake-pepys-reader';
|
|
16
|
+
import { createFakePepysReader } from '@boxxy/model/test-utils/create-fake-pepys-reader';
|
|
17
|
+
import { buildExampleLoadInput } from '@boxxy/rules/test-utils/build-example-load-input';
|
|
18
|
+
import invariant from 'tiny-invariant';
|
|
19
|
+
import { buildTokenDigest } from '../auth/pick-request-scope.ts';
|
|
20
|
+
import { createOffOutbox } from '../outbox/create-off-outbox.ts';
|
|
21
|
+
import { makeProposedRulesLoader } from '../rules/load-server-rules.ts';
|
|
22
|
+
import { serveApi } from '../serve-api.ts';
|
|
23
|
+
import type { TickReport } from '../tick/run-tick.ts';
|
|
24
|
+
import { runTick } from '../tick/run-tick.ts';
|
|
25
|
+
import type { ServerDeps, ServerLog } from '../types.ts';
|
|
26
|
+
import type { FakeCallerProof } from './create-fake-caller-proof.ts';
|
|
27
|
+
import { createFakeCallerProof } from './create-fake-caller-proof.ts';
|
|
28
|
+
|
|
29
|
+
export interface TestServerOptions {
|
|
30
|
+
// false: the server runs as one whose configuration has no pepys section
|
|
31
|
+
readonly hasPepys?: boolean;
|
|
32
|
+
|
|
33
|
+
// the server's real proof in place of the fake one, built on the test's clock; the fake that
|
|
34
|
+
// `callerProof` returns then proves nothing
|
|
35
|
+
readonly makeCallerProof?: (clock: Clock) => Promise<CallerProof>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TestServer {
|
|
39
|
+
// the store, clock, worker and rules behind the server, with their seeders
|
|
40
|
+
readonly dispatch: TestDispatch;
|
|
41
|
+
readonly callerProof: FakeCallerProof;
|
|
42
|
+
readonly pepys: FakePepysReader;
|
|
43
|
+
readonly notifier: FakeNotifier;
|
|
44
|
+
readonly forge: FakeForge;
|
|
45
|
+
|
|
46
|
+
// the token the worker listener takes
|
|
47
|
+
readonly workerToken: string;
|
|
48
|
+
|
|
49
|
+
// what the server logged, in order
|
|
50
|
+
readonly logged: readonly { readonly message: string; readonly fields: unknown }[];
|
|
51
|
+
readonly deps: ServerDeps;
|
|
52
|
+
|
|
53
|
+
// the two listeners, as fetch handlers
|
|
54
|
+
readonly owner: (request: Request) => Promise<Response>;
|
|
55
|
+
readonly worker: (request: Request) => Promise<Response>;
|
|
56
|
+
|
|
57
|
+
// one runner tick at the clock's time
|
|
58
|
+
readonly tick: () => Promise<TickReport>;
|
|
59
|
+
|
|
60
|
+
// an item at the phase, claimed and started by the fake worker; its row carries the caller
|
|
61
|
+
// that its session reports
|
|
62
|
+
readonly seedStartedAttempt: (phase: PhaseName) => Promise<AttemptRow>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The server's two listeners on the memory store, the fake worker, the fake clock, a fake pepys
|
|
66
|
+
// reader, a fake notifier and a fake forge, with the example rules. No port is opened.
|
|
67
|
+
export async function createTestServer(options: TestServerOptions = {}): Promise<TestServer> {
|
|
68
|
+
const dispatch = await createTestDispatch();
|
|
69
|
+
|
|
70
|
+
const callerProof = createFakeCallerProof();
|
|
71
|
+
const pepys = createFakePepysReader();
|
|
72
|
+
const notifier = createFakeNotifier();
|
|
73
|
+
const forge = createFakeForge();
|
|
74
|
+
const workerToken = 'test-worker-token';
|
|
75
|
+
|
|
76
|
+
const readings = new Map<AttemptId, SessionReading>();
|
|
77
|
+
|
|
78
|
+
const logged: { message: string; fields: unknown }[] = [];
|
|
79
|
+
|
|
80
|
+
const log: ServerLog = {
|
|
81
|
+
warn: (message, fields) => {
|
|
82
|
+
logged.push({ message, fields });
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const deps: ServerDeps = {
|
|
87
|
+
store: dispatch.store,
|
|
88
|
+
runner: dispatch.runner,
|
|
89
|
+
rulesState: () => ({ rules: dispatch.rules(), lastRejected: null }),
|
|
90
|
+
loadProposedRules: makeProposedRulesLoader(buildExampleLoadInput().checkout),
|
|
91
|
+
clock: dispatch.clock,
|
|
92
|
+
newId: dispatch.newId,
|
|
93
|
+
callerProof:
|
|
94
|
+
options.makeCallerProof === undefined
|
|
95
|
+
? callerProof
|
|
96
|
+
: await options.makeCallerProof(dispatch.clock),
|
|
97
|
+
workerTokenDigest: buildTokenDigest(workerToken),
|
|
98
|
+
pepys: options.hasPepys === false ? null : pepys,
|
|
99
|
+
forge,
|
|
100
|
+
outbox: createOffOutbox(dispatch.store, dispatch.clock),
|
|
101
|
+
readings,
|
|
102
|
+
log,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const listeners = serveApi(deps);
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
dispatch,
|
|
109
|
+
callerProof,
|
|
110
|
+
pepys,
|
|
111
|
+
notifier,
|
|
112
|
+
forge,
|
|
113
|
+
workerToken,
|
|
114
|
+
logged,
|
|
115
|
+
deps,
|
|
116
|
+
owner: (request) => listeners.owner.handle(request),
|
|
117
|
+
worker: (request) => listeners.worker.handle(request),
|
|
118
|
+
tick: () =>
|
|
119
|
+
runTick({
|
|
120
|
+
store: dispatch.store,
|
|
121
|
+
runner: dispatch.runner,
|
|
122
|
+
worker: dispatch.worker,
|
|
123
|
+
rules: dispatch.rules(),
|
|
124
|
+
backoff: dispatch.backoff,
|
|
125
|
+
spawn: dispatch.spawn,
|
|
126
|
+
notifier,
|
|
127
|
+
now: dispatch.clock.now(),
|
|
128
|
+
newId: dispatch.newId,
|
|
129
|
+
readings,
|
|
130
|
+
log,
|
|
131
|
+
}),
|
|
132
|
+
seedStartedAttempt: async (phase) => {
|
|
133
|
+
await dispatch.seedItem({ phase });
|
|
134
|
+
|
|
135
|
+
const claim = await dispatch.claim();
|
|
136
|
+
|
|
137
|
+
invariant(claim !== null, 'the seeded item is claimed');
|
|
138
|
+
|
|
139
|
+
return dispatch.readAttempt(claim.attempt.id);
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ProcedureName } from '@boxxy/contract';
|
|
2
|
+
import { access } from '@boxxy/contract';
|
|
3
|
+
|
|
4
|
+
// Every procedure that the owner calls, from the access table, one row each for `test.each`.
|
|
5
|
+
export function listOwnerProcedures(): [ProcedureName][] {
|
|
6
|
+
return collectNames()
|
|
7
|
+
.filter((name) => access[name].caller === 'owner')
|
|
8
|
+
.map((name) => [name]);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// The MCP tool name of every procedure that `/mcp` lists.
|
|
12
|
+
export function listOwnerTools(): [string][] {
|
|
13
|
+
return collectNames()
|
|
14
|
+
.filter((name) => access[name].tool === 'owner')
|
|
15
|
+
.map((name) => [toToolName(name)]);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Every session and hook procedure.
|
|
19
|
+
export function listSessionProcedures(): [ProcedureName][] {
|
|
20
|
+
return collectNames()
|
|
21
|
+
.filter((name) => access[name].caller !== 'owner')
|
|
22
|
+
.map((name) => [name]);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The MCP tool name of every procedure that `/mcp/session` lists.
|
|
26
|
+
export function listSessionTools(): [string][] {
|
|
27
|
+
return collectNames()
|
|
28
|
+
.filter((name) => access[name].tool === 'session')
|
|
29
|
+
.map((name) => [toToolName(name)]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function collectNames(): ProcedureName[] {
|
|
33
|
+
return Object.keys(access).filter((name): name is ProcedureName => Object.hasOwn(access, name));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function toToolName(name: string): string {
|
|
37
|
+
return name.replace('.', '_').replaceAll(/[A-Z]/gu, (letter) => `_${letter.toLowerCase()}`);
|
|
38
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
const answerSchema = z
|
|
4
|
+
.object({
|
|
5
|
+
result: z.record(z.string(), z.unknown()).optional(),
|
|
6
|
+
error: z.object({ code: z.number(), message: z.string() }).optional(),
|
|
7
|
+
})
|
|
8
|
+
.loose();
|
|
9
|
+
|
|
10
|
+
export type MCPAnswer = z.infer<typeof answerSchema>;
|
|
11
|
+
|
|
12
|
+
// The JSON-RPC answer of an MCP endpoint, from a JSON body or from the one event of a stream.
|
|
13
|
+
export async function readMCPAnswer(response: Response): Promise<MCPAnswer> {
|
|
14
|
+
const text = await response.text();
|
|
15
|
+
|
|
16
|
+
const isStream = (response.headers.get('content-type') ?? '').startsWith('text/event-stream');
|
|
17
|
+
|
|
18
|
+
const json = isStream
|
|
19
|
+
? (text
|
|
20
|
+
.split('\n')
|
|
21
|
+
.find((line) => line.startsWith('data: '))
|
|
22
|
+
?.slice('data: '.length) ?? '')
|
|
23
|
+
: text;
|
|
24
|
+
|
|
25
|
+
return answerSchema.parse(JSON.parse(json));
|
|
26
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
2
|
+
|
|
3
|
+
// Polls the check until it is true. Throws when it is still false after `timeoutMs`.
|
|
4
|
+
export async function waitFor(check: () => boolean | Promise<boolean>, timeoutMs = 2000) {
|
|
5
|
+
const deadline = performance.now() + timeoutMs;
|
|
6
|
+
|
|
7
|
+
while (!(await check())) {
|
|
8
|
+
if (performance.now() > deadline) {
|
|
9
|
+
throw new Error(`the condition stayed false for ${timeoutMs} ms`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
await sleep(5);
|
|
13
|
+
}
|
|
14
|
+
}
|