@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.
Files changed (90) hide show
  1. package/README.md +57 -5
  2. package/dist/auth/authorize-call.d.ts +11 -0
  3. package/dist/auth/is-browser-request.d.ts +1 -0
  4. package/dist/auth/pick-request-scope.d.ts +9 -0
  5. package/dist/config/pick-server-secrets.d.ts +16 -0
  6. package/dist/config/read-caller-proof.d.ts +10 -0
  7. package/dist/config/read-server-config.d.ts +9 -0
  8. package/dist/config/read-trusted-issuers.d.ts +8 -0
  9. package/dist/config/server-config-schema.d.ts +42 -0
  10. package/dist/create-capacity-backoff.d.ts +2 -0
  11. package/dist/handlers/api.d.ts +1683 -0
  12. package/dist/handlers/attempt-handlers.d.ts +130 -0
  13. package/dist/handlers/build-owner-fact.d.ts +3 -0
  14. package/dist/handlers/item-handlers.d.ts +168 -0
  15. package/dist/handlers/outbox-handlers.d.ts +7 -0
  16. package/dist/handlers/plan-handlers.d.ts +62 -0
  17. package/dist/handlers/read-item-ids.d.ts +10 -0
  18. package/dist/handlers/relation-handlers.d.ts +41 -0
  19. package/dist/handlers/report-handlers.d.ts +102 -0
  20. package/dist/handlers/rules-handlers.d.ts +20 -0
  21. package/dist/handlers/runner-handlers.d.ts +65 -0
  22. package/dist/handlers/to-wire-json.d.ts +5 -0
  23. package/dist/handlers/write-levers.d.ts +11 -0
  24. package/dist/index.d.ts +12 -0
  25. package/dist/main.d.ts +1 -0
  26. package/dist/no-caller-proof.d.ts +2 -0
  27. package/dist/outbox/create-off-outbox.d.ts +3 -0
  28. package/dist/outbox/start-outbox-sender.d.ts +10 -0
  29. package/dist/read-limited-request.d.ts +1 -0
  30. package/dist/rules/create-rules-holder.d.ts +20 -0
  31. package/dist/rules/load-server-rules.d.ts +7 -0
  32. package/dist/rules/read-rules-checkout.d.ts +9 -0
  33. package/dist/serve-api.d.ts +50 -0
  34. package/dist/start-server.d.ts +18 -0
  35. package/dist/test-utils/build-mcp-request.d.ts +8 -0
  36. package/dist/test-utils/build-rpc-request.d.ts +7 -0
  37. package/dist/test-utils/build-session-input.d.ts +1 -0
  38. package/dist/test-utils/create-fake-caller-proof.d.ts +5 -0
  39. package/dist/test-utils/create-test-server.d.ts +30 -0
  40. package/dist/test-utils/list-owner-procedures.d.ts +5 -0
  41. package/dist/test-utils/read-mcp-answer.d.ts +11 -0
  42. package/dist/test-utils/wait-for.d.ts +1 -0
  43. package/dist/tick/run-tick.d.ts +24 -0
  44. package/dist/tick/start-tick-loop.d.ts +12 -0
  45. package/dist/types.d.ts +41 -0
  46. package/package.json +48 -4
  47. package/src/auth/authorize-call.ts +50 -0
  48. package/src/auth/is-browser-request.ts +8 -0
  49. package/src/auth/pick-request-scope.ts +37 -0
  50. package/src/config/pick-server-secrets.ts +48 -0
  51. package/src/config/read-caller-proof.ts +36 -0
  52. package/src/config/read-server-config.ts +41 -0
  53. package/src/config/read-trusted-issuers.ts +60 -0
  54. package/src/config/server-config-schema.ts +71 -0
  55. package/src/create-capacity-backoff.ts +18 -0
  56. package/src/handlers/api.ts +23 -0
  57. package/src/handlers/attempt-handlers.ts +244 -0
  58. package/src/handlers/build-owner-fact.ts +20 -0
  59. package/src/handlers/item-handlers.ts +462 -0
  60. package/src/handlers/outbox-handlers.ts +15 -0
  61. package/src/handlers/plan-handlers.ts +75 -0
  62. package/src/handlers/read-item-ids.ts +31 -0
  63. package/src/handlers/relation-handlers.ts +143 -0
  64. package/src/handlers/report-handlers.ts +112 -0
  65. package/src/handlers/rules-handlers.ts +46 -0
  66. package/src/handlers/runner-handlers.ts +117 -0
  67. package/src/handlers/to-wire-json.ts +20 -0
  68. package/src/handlers/write-levers.ts +53 -0
  69. package/src/index.ts +12 -0
  70. package/src/main.ts +31 -0
  71. package/src/no-caller-proof.ts +7 -0
  72. package/src/outbox/create-off-outbox.ts +21 -0
  73. package/src/outbox/start-outbox-sender.ts +89 -0
  74. package/src/read-limited-request.ts +43 -0
  75. package/src/rules/create-rules-holder.ts +107 -0
  76. package/src/rules/load-server-rules.ts +24 -0
  77. package/src/rules/read-rules-checkout.ts +65 -0
  78. package/src/serve-api.ts +178 -0
  79. package/src/start-server.ts +308 -0
  80. package/src/test-utils/build-mcp-request.ts +34 -0
  81. package/src/test-utils/build-rpc-request.ts +23 -0
  82. package/src/test-utils/build-session-input.ts +16 -0
  83. package/src/test-utils/create-fake-caller-proof.ts +18 -0
  84. package/src/test-utils/create-test-server.ts +142 -0
  85. package/src/test-utils/list-owner-procedures.ts +38 -0
  86. package/src/test-utils/read-mcp-answer.ts +26 -0
  87. package/src/test-utils/wait-for.ts +14 -0
  88. package/src/tick/run-tick.ts +212 -0
  89. package/src/tick/start-tick-loop.ts +46 -0
  90. package/src/types.ts +86 -0
@@ -0,0 +1,60 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ export type IssuersAnswer =
5
+ | { readonly kind: 'read'; readonly issuers: readonly unknown[] }
6
+ | { readonly kind: 'invalid'; readonly message: string };
7
+
8
+ // The trust documents, one per file of each folder, as parsed JSON in name order. The adapter
9
+ // checks their shape; this reads them only.
10
+ export async function readTrustedIssuers(folders: readonly string[]): Promise<IssuersAnswer> {
11
+ const issuers: unknown[] = [];
12
+
13
+ for (const folder of folders) {
14
+ const files = await readFileNames(folder);
15
+
16
+ if (files === null) {
17
+ return { kind: 'invalid', message: `no trusted issuers folder at ${folder}` };
18
+ }
19
+
20
+ for (const name of files) {
21
+ const file = path.join(folder, name);
22
+
23
+ const text = await Bun.file(file).text();
24
+
25
+ const json = parseJSON(text);
26
+
27
+ if (json === undefined) {
28
+ return { kind: 'invalid', message: `the trust document at ${file} is not JSON` };
29
+ }
30
+
31
+ issuers.push(json);
32
+ }
33
+ }
34
+
35
+ return { kind: 'read', issuers };
36
+ }
37
+
38
+ // null: the folder does not exist
39
+ async function readFileNames(folder: string): Promise<string[] | null> {
40
+ try {
41
+ const entries = await readdir(folder, { withFileTypes: true });
42
+
43
+ return entries
44
+ .filter((entry) => entry.isFile())
45
+ .map((entry) => entry.name)
46
+ .toSorted((left, right) => left.localeCompare(right));
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ function parseJSON(text: string): unknown {
53
+ try {
54
+ const value: unknown = JSON.parse(text);
55
+
56
+ return value;
57
+ } catch {
58
+ return undefined;
59
+ }
60
+ }
@@ -0,0 +1,71 @@
1
+ import { z } from 'zod';
2
+
3
+ const name = z.string().min(1);
4
+
5
+ const address = z.strictObject({
6
+ hostname: name,
7
+
8
+ // 0 asks the system for a free port
9
+ port: z.int().min(0).max(65_535),
10
+ });
11
+
12
+ const spawnSection = z.strictObject({
13
+ target: name,
14
+ repoPaths: z.record(z.string(), name),
15
+ });
16
+
17
+ const atcSection = z.strictObject({
18
+ // the atc daemon's oRPC route, such as `http://127.0.0.1:7777/rpc`
19
+ url: z.url(),
20
+
21
+ // by worker target: the folders the agent trusts, each with its subfolders
22
+ trustedFolders: z.record(z.string(), z.array(name)),
23
+
24
+ // the session that gets the hold notices; with none, no notice is sent
25
+ noticeSession: name.optional(),
26
+ });
27
+
28
+ const folders = z.array(name).min(1);
29
+
30
+ // the folders of the trust documents, one file per issuer, and the audience the tokens name
31
+ const trustedIssuersSection = z.strictObject({ folders, audience: name });
32
+
33
+ const pepysSection = z.strictObject({
34
+ // the receive route of boxxy's source on pepys ingest
35
+ ingestUrl: z.url(),
36
+
37
+ // the oRPC route of the pepys query API
38
+ apiUrl: z.url(),
39
+ environment: name,
40
+ });
41
+
42
+ // The server's configuration file, as JSON, at the path in `BOXXY_CONFIG`. It holds no secret:
43
+ // each token comes from the environment.
44
+ export const serverConfigSchema = z.strictObject({
45
+ runner: name,
46
+ store: z.strictObject({ file: name }),
47
+
48
+ // a git checkout, or a plain folder with no `.git`
49
+ rules: z.strictObject({ folder: name }),
50
+ api: z.strictObject({
51
+ // the owner's private network: `/rpc`, `/mcp` and `/healthz`, with no token
52
+ listen: address,
53
+
54
+ // the broker's target: `/rpc` and `/mcp/session`, with the worker token on every request
55
+ workerListen: address,
56
+ }),
57
+ spawn: spawnSection,
58
+ atc: atcSection,
59
+
60
+ // with none, every session and hook call is refused
61
+ trustedIssuers: trustedIssuersSection.optional(),
62
+
63
+ // with none, no fact is sent, and the two reports answer PEPYS_NOT_CONFIGURED
64
+ pepys: pepysSection.optional(),
65
+
66
+ // GitHub's REST root, or the broker's; with none, the hook's link answers `retry`, and no
67
+ // pull request watch runs
68
+ github: z.strictObject({ baseUrl: z.url() }).optional(),
69
+ });
70
+
71
+ export type ServerConfig = z.infer<typeof serverConfigSchema>;
@@ -0,0 +1,18 @@
1
+ import type { CapacityBackoff, Instant, PoolName } from '@boxxy/model';
2
+
3
+ // The runner's memory of the pools that a host refused for capacity. It lives in the process: a
4
+ // restart forgets it, and the next refusal holds the pool again. A second hold keeps the later
5
+ // end.
6
+ export function createCapacityBackoff(): CapacityBackoff {
7
+ const until = new Map<PoolName, Instant>();
8
+
9
+ return {
10
+ hold: (pool, end) => {
11
+ const held = until.get(pool);
12
+ const later = held !== undefined && held > end ? held : end;
13
+
14
+ until.set(pool, later);
15
+ },
16
+ listHeld: (now) => [...until].filter(([, end]) => end > now).map(([pool]) => pool),
17
+ };
18
+ }
@@ -0,0 +1,23 @@
1
+ import { contract } from '@boxxy/contract';
2
+ import { ORPCError, implement } from '@orpc/server';
3
+ import { match } from 'ts-pattern';
4
+ import { authorizeCall } from '../auth/authorize-call.ts';
5
+ import type { ProvenCall, RequestContext } from '../types.ts';
6
+
7
+ // The contract with the root middleware on every procedure, so each handler built from it runs
8
+ // after the access check. A handler of a session or hook procedure gets the proven call.
9
+ export const api = implement(contract)
10
+ .$context<RequestContext>()
11
+ .use(async (options) => {
12
+ const authorization = await authorizeCall(options.context, options.path);
13
+
14
+ const call = match(authorization)
15
+ .with({ kind: 'refused' }, (refused) => {
16
+ throw new ORPCError(refused.code);
17
+ })
18
+ .with({ kind: 'owner' }, (): ProvenCall | null => null)
19
+ .with({ kind: 'session' }, (session) => session.call)
20
+ .exhaustive();
21
+
22
+ return options.next({ context: { call } });
23
+ });
@@ -0,0 +1,244 @@
1
+ import {
2
+ bindAttemptSession,
3
+ closeOutAttempt,
4
+ linkPullRequest,
5
+ pickCallAttempt,
6
+ } from '@boxxy/dispatch';
7
+ import type { CallRef } from '@boxxy/dispatch';
8
+ import type {
9
+ AttemptRow,
10
+ EvidenceKind,
11
+ ItemKindName,
12
+ LoadedRules,
13
+ StoreChange,
14
+ } from '@boxxy/model';
15
+ import { brandSchemas } from '@boxxy/model';
16
+ import type { ProvenCall, ServerDeps } from '../types.ts';
17
+ import { api } from './api.ts';
18
+ import { toWireJSON } from './to-wire-json.ts';
19
+
20
+ const noAttempt = { kind: 'refused', reason: 'no-attempt' } as const;
21
+
22
+ // The procedures of a session and its hooks. Each takes its session and caller from the root
23
+ // middleware's proven call, never from the request; the request names only the attempt.
24
+ export const attemptHandlers = {
25
+ current: api.attempt.current.handler(async (options) => {
26
+ const context = options.context;
27
+
28
+ if (context.call === null) {
29
+ return noAttempt;
30
+ }
31
+
32
+ const call = context.call;
33
+
34
+ const latest = await context.deps.store.read((tx) => tx.findAttemptBySession(call.session));
35
+
36
+ if (latest === null) {
37
+ return noAttempt;
38
+ }
39
+
40
+ const picked = pickCallAttempt(latest, { ...call, attempt: latest.id });
41
+
42
+ if (picked.kind === 'refused') {
43
+ return picked;
44
+ }
45
+
46
+ return readCurrent(context.deps, picked.attempt);
47
+ }),
48
+ closeOut: api.attempt.closeOut.handler((options) => {
49
+ const input = options.input;
50
+ const deps = options.context.deps;
51
+ const call = buildCallRef(options.context.call, input.session.attempt);
52
+
53
+ if (call === null) {
54
+ return noAttempt;
55
+ }
56
+
57
+ return closeOutAttempt({
58
+ store: deps.store,
59
+ call,
60
+ body: input.closeOut,
61
+ rules: deps.rulesState().rules,
62
+ now: deps.clock.now(),
63
+ newId: deps.newId,
64
+ });
65
+ }),
66
+ evidence: api.attempt.evidence.handler((options) => {
67
+ const input = options.input;
68
+ const context = options.context;
69
+ const call = buildCallRef(context.call, input.session.attempt);
70
+
71
+ if (call === null) {
72
+ return noAttempt;
73
+ }
74
+
75
+ return writeEvidence(context.deps, call, {
76
+ kind: input.kind,
77
+ ref: input.ref,
78
+ observed: input.observed ?? null,
79
+ });
80
+ }),
81
+ bindSession: api.attempt.bindSession.handler(async (options) => {
82
+ const input = options.input;
83
+ const context = options.context;
84
+ const call = buildCallRef(context.call, input.session.attempt);
85
+ const agentSession = brandSchemas.agentSessionId.safeParse(input.session.agentSession);
86
+
87
+ if (call === null || !agentSession.success) {
88
+ return { kind: 'no-attempt' } as const;
89
+ }
90
+
91
+ const answer = await bindAttemptSession({
92
+ store: context.deps.store,
93
+ call,
94
+ agentSession: agentSession.data,
95
+ missingSkills: input.missingSkills ?? [],
96
+ now: context.deps.clock.now(),
97
+ newId: context.deps.newId,
98
+ });
99
+
100
+ if (answer.kind !== 'fault') {
101
+ return answer;
102
+ }
103
+
104
+ context.deps.log.warn('an agent session is bound to another attempt', {
105
+ attempt: answer.attempt,
106
+ holder: answer.holder,
107
+ });
108
+
109
+ return { kind: 'fault', fault: answer.fault, holder: answer.holder } as const;
110
+ }),
111
+ recordPullRequest: api.attempt.recordPullRequest.handler((options) => {
112
+ const input = options.input;
113
+ const deps = options.context.deps;
114
+ const call = buildCallRef(options.context.call, input.session.attempt);
115
+
116
+ if (call === null) {
117
+ return noAttempt;
118
+ }
119
+
120
+ if (deps.forge === null) {
121
+ return { kind: 'retry' } as const;
122
+ }
123
+
124
+ return linkPullRequest({
125
+ store: deps.store,
126
+ forge: deps.forge,
127
+ rules: deps.rulesState().rules,
128
+ call,
129
+ url: input.url,
130
+ now: deps.clock.now(),
131
+ newId: deps.newId,
132
+ });
133
+ }),
134
+ };
135
+
136
+ async function readCurrent(deps: ServerDeps, attempt: AttemptRow) {
137
+ const rules = deps.rulesState().rules;
138
+ const now = deps.clock.now();
139
+
140
+ const [item, notes] = await deps.store.read((tx) =>
141
+ Promise.all([tx.readItem(attempt.item, now, rules), tx.listItemNotes(attempt.item)]),
142
+ );
143
+
144
+ const phase = rules.phases.get(attempt.phase);
145
+
146
+ if (item === null || phase === undefined) {
147
+ return noAttempt;
148
+ }
149
+
150
+ return {
151
+ kind: 'attempt' as const,
152
+ attempt,
153
+ item,
154
+ input: toWireJSON(attempt.input),
155
+ closeOut: {
156
+ phase: attempt.phase,
157
+ schema: toWireJSON(phase.closeOut.jsonSchema),
158
+ outcomes: [...phase.outcomes],
159
+ },
160
+ skills: { pin: rules.skills, names: collectSkillNames(rules, item.kind, attempt) },
161
+ notes: notes.map((note) => ({ seq: note.seq, text: note.text, at: note.at })),
162
+ };
163
+ }
164
+
165
+ function collectSkillNames(rules: LoadedRules, kind: ItemKindName, attempt: AttemptRow): string[] {
166
+ const recipes = rules.kinds.get(kind)?.recipes ?? {};
167
+
168
+ const name =
169
+ (attempt.mode === 'attended' ? recipes[`${attempt.phase}/attended`] : undefined) ??
170
+ recipes[attempt.phase];
171
+
172
+ return name === undefined ? [] : [...(rules.recipes.get(name)?.recipe.skills ?? [])];
173
+ }
174
+
175
+ // an attempt ID that no store holds, so `pickCallAttempt` answers `not-current`
176
+ const missingAttemptId = '00000000-0000-7000-8000-000000000000';
177
+
178
+ // The attempt ID from the request, with the session and the caller that the middleware proved.
179
+ // A string that is no attempt ID names no attempt of the session.
180
+ function buildCallRef(proven: ProvenCall | null, attempt: string): CallRef | null {
181
+ const parsed = brandSchemas.attemptId.safeParse(attempt);
182
+
183
+ if (proven === null) {
184
+ return null;
185
+ }
186
+
187
+ return {
188
+ attempt: parsed.success ? parsed.data : brandSchemas.attemptId.parse(missingAttemptId),
189
+ session: proven.session,
190
+ caller: proven.caller,
191
+ };
192
+ }
193
+
194
+ interface EvidenceDraft {
195
+ readonly kind: Exclude<EvidenceKind, 'pull_request' | 'commit'>;
196
+ readonly ref: string;
197
+ readonly observed: string | null;
198
+ }
199
+
200
+ // Writes under the attempt's lease, so the evidence of a settled attempt is refused.
201
+ function writeEvidence(deps: ServerDeps, call: CallRef, draft: EvidenceDraft) {
202
+ return deps.store.exclusive(async (tx) => {
203
+ const latest = await tx.findAttemptBySession(call.session);
204
+
205
+ const picked = pickCallAttempt(latest, call);
206
+
207
+ if (picked.kind === 'refused') {
208
+ return picked;
209
+ }
210
+
211
+ const attempt = picked.attempt;
212
+ const factId = brandSchemas.factId.parse(deps.newId());
213
+ const at = deps.clock.now();
214
+
215
+ const result = await tx.applyChange({
216
+ at,
217
+ under: buildUnder(attempt),
218
+ writes: [
219
+ {
220
+ op: 'insert',
221
+ table: 'evidence',
222
+ row: { factId, attempt: attempt.id, ...draft, source: 'agent', at },
223
+ },
224
+ ],
225
+ facts: [
226
+ {
227
+ id: factId,
228
+ type: 'boxxy.evidence.recorded',
229
+ entity: `boxxy:item:${attempt.item}`,
230
+ actor: `boxxy:attempt:${attempt.id}`,
231
+ payload: { attempt: attempt.id, ...draft },
232
+ },
233
+ ],
234
+ });
235
+
236
+ return result.kind === 'applied'
237
+ ? ({ kind: 'recorded', factId } as const)
238
+ : ({ kind: 'refused', reason: 'not-current' } as const);
239
+ });
240
+ }
241
+
242
+ function buildUnder(attempt: AttemptRow): NonNullable<StoreChange['under']> {
243
+ return { item: attempt.item, phase: attempt.phase, gen: attempt.leaseGen };
244
+ }
@@ -0,0 +1,20 @@
1
+ import type { ItemId, NewFact, WorkFacts } from '@boxxy/model';
2
+ import { brandSchemas } from '@boxxy/model';
3
+ import type { ServerDeps } from '../types.ts';
4
+
5
+ // A fact of the owner's path. Its entity is the item it names (the entity rule of the fact
6
+ // catalog).
7
+ export function buildOwnerFact<Type extends keyof WorkFacts>(
8
+ deps: ServerDeps,
9
+ item: ItemId,
10
+ type: Type,
11
+ payload: WorkFacts[Type],
12
+ ): NewFact<Type> {
13
+ return {
14
+ id: brandSchemas.factId.parse(deps.newId()),
15
+ type,
16
+ entity: `boxxy:item:${item}`,
17
+ actor: 'boxxy:owner:local',
18
+ payload,
19
+ };
20
+ }