@boxxy/server 0.0.1 → 0.1.4

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 +35 -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 +166 -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,212 @@
1
+ import {
2
+ actOnReading,
3
+ claimNext,
4
+ forgetSessions,
5
+ pickReadingAction,
6
+ resumeClaim,
7
+ sendNotices,
8
+ startClaim,
9
+ stopStraySession,
10
+ timeouts,
11
+ } from '@boxxy/dispatch';
12
+ import type { NoticeReport, SpawnSettings } from '@boxxy/dispatch';
13
+ import type {
14
+ AttemptId,
15
+ AttemptRow,
16
+ CapacityBackoff,
17
+ ClaimStore,
18
+ Instant,
19
+ ItemId,
20
+ LoadedRules,
21
+ NewId,
22
+ Notifier,
23
+ PipelineStep,
24
+ RunnerId,
25
+ SessionFault,
26
+ SessionReading,
27
+ WorkerKind,
28
+ } from '@boxxy/model';
29
+ import { brandSchemas } from '@boxxy/model';
30
+ import type { ServerLog } from '../types.ts';
31
+
32
+ export interface TickDeps {
33
+ readonly store: ClaimStore;
34
+ readonly runner: RunnerId;
35
+ readonly worker: WorkerKind;
36
+ readonly rules: LoadedRules;
37
+ readonly backoff: CapacityBackoff;
38
+ readonly spawn: SpawnSettings;
39
+ readonly notifier: Notifier;
40
+
41
+ // one value for the whole tick
42
+ readonly now: Instant;
43
+ readonly newId: NewId;
44
+
45
+ // the last reading of each live attempt; the tick replaces it whole
46
+ readonly readings: Map<AttemptId, SessionReading>;
47
+ readonly log: ServerLog;
48
+ }
49
+
50
+ export interface TickReport {
51
+ readonly claimed: number;
52
+ readonly read: number;
53
+ readonly strays: number;
54
+ readonly forgotten: number;
55
+ readonly notices: NoticeReport | null;
56
+ }
57
+
58
+ // One tick of the runner. Each part after the heartbeat runs even when an earlier part threw, so
59
+ // a fault in the claim loop never holds the readings back.
60
+ export async function runTick(deps: TickDeps): Promise<TickReport> {
61
+ await deps.store.exclusive(async (tx) => {
62
+ await tx.writeHeartbeat(deps.runner, deps.now);
63
+ await tx.syncLimits(deps.rules.limits, deps.now);
64
+ });
65
+
66
+ const claimed = await runClaimLoop(deps);
67
+ const read = await runReadings(deps);
68
+ const strays = await runStrayStops(deps);
69
+ const forgotten = await runForget(deps);
70
+ const notices = await runNotices(deps);
71
+
72
+ return { claimed, read, strays, forgotten, notices };
73
+ }
74
+
75
+ // The claim loop ends on three rules: `claimNext` gives no pick in a pool in backoff, no item is
76
+ // claimed twice in one tick, and an error ends the loop and not the tick.
77
+ async function runClaimLoop(deps: TickDeps): Promise<number> {
78
+ const claimed = new Set<ItemId>();
79
+
80
+ try {
81
+ for (let claim = await claimNext(deps); claim !== null; claim = await claimNext(deps)) {
82
+ const fault =
83
+ claim.kind === 'start'
84
+ ? await startClaim({ ...deps, claim })
85
+ : await resumeClaim({ ...deps, claim });
86
+
87
+ logFault(deps, fault);
88
+
89
+ if (claimed.has(claim.attempt.item)) {
90
+ deps.log.warn('the claim loop ended: an item was claimed twice in one tick', {
91
+ item: claim.attempt.item,
92
+ });
93
+
94
+ break;
95
+ }
96
+
97
+ claimed.add(claim.attempt.item);
98
+ }
99
+ } catch (error) {
100
+ deps.log.warn('the claim loop ended', { error: String(error) });
101
+ }
102
+
103
+ return claimed.size;
104
+ }
105
+
106
+ function logFault(deps: TickDeps, fault: SessionFault | null): void {
107
+ if (fault !== null) {
108
+ deps.log.warn('an agent session is bound to another attempt', {
109
+ attempt: fault.attempt,
110
+ holder: fault.holder,
111
+ });
112
+ }
113
+ }
114
+
115
+ // One reading per live attempt. A reading that throws skips its attempt; the next tick reads it
116
+ // again.
117
+ async function runReadings(deps: TickDeps): Promise<number> {
118
+ const live = await deps.store.read((tx) => tx.listLiveAttempts());
119
+
120
+ const readings = new Map<AttemptId, SessionReading>();
121
+
122
+ for (const attempt of live) {
123
+ try {
124
+ const reading = await deps.worker.reconcile(attempt);
125
+ const step = await readAttemptStep(deps, attempt);
126
+
127
+ readings.set(attempt.id, reading);
128
+
129
+ const action = pickReadingAction({
130
+ attempt,
131
+ reading,
132
+ step,
133
+ sleep: deps.rules.sleep,
134
+ now: deps.now,
135
+ });
136
+
137
+ const fault = await actOnReading({ ...deps, attempt, reading, action });
138
+
139
+ logFault(deps, fault);
140
+ } catch (error) {
141
+ deps.log.warn('the reading of an attempt failed', {
142
+ attempt: attempt.id,
143
+ error: String(error),
144
+ });
145
+ }
146
+ }
147
+
148
+ deps.readings.clear();
149
+
150
+ for (const [attempt, reading] of readings) {
151
+ deps.readings.set(attempt, reading);
152
+ }
153
+
154
+ return readings.size;
155
+ }
156
+
157
+ async function readAttemptStep(deps: TickDeps, attempt: AttemptRow): Promise<PipelineStep | null> {
158
+ const item = await deps.store.read((tx) => tx.readItem(attempt.item, deps.now, deps.rules));
159
+
160
+ const pipeline = item === null ? undefined : deps.rules.kinds.get(item.kind)?.pipeline;
161
+
162
+ return pipeline?.steps.find((step) => step.phase === attempt.phase) ?? null;
163
+ }
164
+
165
+ const hourMs = 3_600_000;
166
+
167
+ // The stop of each session that the give-up rule left running, sent again on every tick for
168
+ // `timeouts.strayHours`.
169
+ async function runStrayStops(deps: TickDeps): Promise<number> {
170
+ const since = brandSchemas.instant.parse(Math.max(0, deps.now - timeouts.strayHours * hourMs));
171
+ let stopped = 0;
172
+
173
+ try {
174
+ const strays = await deps.store.read((tx) => tx.listStrayAttempts(since));
175
+
176
+ for (const attempt of strays) {
177
+ const result = await stopStraySession({ ...deps, attempt, actor: runnerActor(deps) });
178
+
179
+ stopped += result === null ? 0 : 1;
180
+ }
181
+ } catch (error) {
182
+ deps.log.warn('the stop of the stray sessions failed', { error: String(error) });
183
+ }
184
+
185
+ return stopped;
186
+ }
187
+
188
+ function runnerActor(deps: TickDeps) {
189
+ return `boxxy:runner:${deps.runner}` as const;
190
+ }
191
+
192
+ async function runForget(deps: TickDeps): Promise<number> {
193
+ try {
194
+ const report = await forgetSessions({ ...deps, actor: runnerActor(deps) });
195
+
196
+ return report.forgotten.length;
197
+ } catch (error) {
198
+ deps.log.warn('the forget of the done sessions failed', { error: String(error) });
199
+
200
+ return 0;
201
+ }
202
+ }
203
+
204
+ async function runNotices(deps: TickDeps): Promise<NoticeReport | null> {
205
+ try {
206
+ return await sendNotices(deps);
207
+ } catch (error) {
208
+ deps.log.warn('the send of the hold notices failed', { error: String(error) });
209
+
210
+ return null;
211
+ }
212
+ }
@@ -0,0 +1,46 @@
1
+ import { setTimeout as sleep } from 'node:timers/promises';
2
+ import type { ServerLog } from '../types.ts';
3
+
4
+ export interface TickLoop {
5
+ // ends the loop after the tick in flight
6
+ readonly stop: () => Promise<void>;
7
+ }
8
+
9
+ interface TickLoopInput {
10
+ readonly runOnce: () => Promise<void>;
11
+ readonly intervalMs: number;
12
+
13
+ // the log line for a tick that throws
14
+ readonly failureMessage: string;
15
+ readonly log: ServerLog;
16
+ }
17
+
18
+ // Runs one tick, waits `intervalMs`, and runs the next. A tick that throws is logged, and the
19
+ // loop goes on; no two ticks overlap.
20
+ export function startTickLoop(input: TickLoopInput): TickLoop {
21
+ const run = { isStopped: false, wake: new AbortController() };
22
+
23
+ const runLoop = async (): Promise<void> => {
24
+ while (!run.isStopped) {
25
+ try {
26
+ await input.runOnce();
27
+ } catch (error) {
28
+ input.log.warn(input.failureMessage, { error: String(error) });
29
+ }
30
+
31
+ await sleep(input.intervalMs, null, { signal: run.wake.signal }).catch(() => null);
32
+ }
33
+ };
34
+
35
+ const loop = runLoop();
36
+
37
+ return {
38
+ stop: async () => {
39
+ run.isStopped = true;
40
+
41
+ run.wake.abort();
42
+
43
+ await loop;
44
+ },
45
+ };
46
+ }
package/src/types.ts ADDED
@@ -0,0 +1,86 @@
1
+ import type {
2
+ AtcSessionId,
3
+ AttemptId,
4
+ CallerIdentity,
5
+ CallerProof,
6
+ ClaimStore,
7
+ Clock,
8
+ Forge,
9
+ LoadRejection,
10
+ LoadResult,
11
+ LoadedRules,
12
+ NewId,
13
+ OutboxStatus,
14
+ PepysReader,
15
+ RunnerId,
16
+ SessionReading,
17
+ } from '@boxxy/model';
18
+
19
+ // The rules the server runs, and the last checkout the loader refused, which the previous rules
20
+ // outlive.
21
+ export interface RulesState {
22
+ readonly rules: LoadedRules;
23
+ readonly lastRejected: {
24
+ readonly commit: string;
25
+ readonly rejections: readonly LoadRejection[];
26
+ } | null;
27
+ }
28
+
29
+ export interface OutboxControl {
30
+ readonly status: () => Promise<OutboxStatus>;
31
+
32
+ // asks the sender for a turn now, as after `outbox.retry`
33
+ readonly nudge: () => void;
34
+ }
35
+
36
+ // Failures and state changes only. Never a token, a header or a secret.
37
+ export interface ServerLog {
38
+ readonly warn: (message: string, fields: Readonly<Record<string, unknown>>) => void;
39
+ }
40
+
41
+ // What every handler reads. The server builds it once; the tests build it on fakes.
42
+ export interface ServerDeps {
43
+ readonly store: ClaimStore;
44
+ readonly runner: RunnerId;
45
+ readonly rulesState: () => RulesState;
46
+
47
+ // loads the server's own rules checkout with this text as its rules file, for the dry run
48
+ readonly loadProposedRules: (text: string) => LoadResult;
49
+ readonly clock: Clock;
50
+ readonly newId: NewId;
51
+ readonly callerProof: CallerProof;
52
+
53
+ // the SHA-256 digest of the worker token; the server never keeps the token itself
54
+ readonly workerTokenDigest: Uint8Array;
55
+
56
+ // null: the configuration has no pepys section
57
+ readonly pepys: PepysReader | null;
58
+
59
+ // null: the configuration has no github section, so the pull request link answers `retry`
60
+ readonly forge: Forge | null;
61
+ readonly outbox: OutboxControl;
62
+
63
+ // the last reading of each live attempt, which the tick writes and `runner.status` reads
64
+ readonly readings: ReadonlyMap<AttemptId, SessionReading>;
65
+ readonly log: ServerLog;
66
+ }
67
+
68
+ // The two listeners. `owner` is on the owner's private network; `worker` is the one the broker
69
+ // reaches, and every request to it needs the worker token.
70
+ export type Listener = 'owner' | 'worker';
71
+
72
+ // `worker`: the request carries the worker token. `owner`: it carries none, on the owner listener.
73
+ export type RequestScope = 'owner' | 'worker';
74
+
75
+ export interface RequestContext {
76
+ readonly deps: ServerDeps;
77
+ readonly scope: RequestScope;
78
+ readonly request: Request;
79
+ }
80
+
81
+ // What the root middleware proved for a session or hook call: the caller, and the session whose
82
+ // latest attempt holds it. null: no session holds the caller.
83
+ export interface ProvenCall {
84
+ readonly session: AtcSessionId;
85
+ readonly caller: CallerIdentity;
86
+ }