@chipschat/conformance 0.0.0-stage → 1.0.1

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 (59) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +34 -2
  3. package/dist/checks.d.ts +29 -0
  4. package/dist/checks.d.ts.map +1 -0
  5. package/dist/checks.js +133 -0
  6. package/dist/checks.js.map +1 -0
  7. package/dist/exchange.d.ts +30 -0
  8. package/dist/exchange.d.ts.map +1 -0
  9. package/dist/exchange.js +102 -0
  10. package/dist/exchange.js.map +1 -0
  11. package/dist/fake-chips.d.ts +130 -0
  12. package/dist/fake-chips.d.ts.map +1 -0
  13. package/dist/fake-chips.js +1642 -0
  14. package/dist/fake-chips.js.map +1 -0
  15. package/dist/fake-join.d.ts +56 -0
  16. package/dist/fake-join.d.ts.map +1 -0
  17. package/dist/fake-join.js +181 -0
  18. package/dist/fake-join.js.map +1 -0
  19. package/dist/index.d.ts +20 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +14 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/project.d.ts +39 -0
  24. package/dist/project.d.ts.map +1 -0
  25. package/dist/project.js +254 -0
  26. package/dist/project.js.map +1 -0
  27. package/dist/report.d.ts +65 -0
  28. package/dist/report.d.ts.map +1 -0
  29. package/dist/report.js +142 -0
  30. package/dist/report.js.map +1 -0
  31. package/dist/room-files.d.ts +54 -0
  32. package/dist/room-files.d.ts.map +1 -0
  33. package/dist/room-files.js +144 -0
  34. package/dist/room-files.js.map +1 -0
  35. package/dist/scenario.d.ts +41 -0
  36. package/dist/scenario.d.ts.map +1 -0
  37. package/dist/scenario.js +414 -0
  38. package/dist/scenario.js.map +1 -0
  39. package/dist/settings.d.ts +60 -0
  40. package/dist/settings.d.ts.map +1 -0
  41. package/dist/settings.js +142 -0
  42. package/dist/settings.js.map +1 -0
  43. package/dist/spend.d.ts +20 -0
  44. package/dist/spend.d.ts.map +1 -0
  45. package/dist/spend.js +50 -0
  46. package/dist/spend.js.map +1 -0
  47. package/dist/subject.d.ts +55 -0
  48. package/dist/subject.d.ts.map +1 -0
  49. package/dist/subject.js +22 -0
  50. package/dist/subject.js.map +1 -0
  51. package/dist/waiting.d.ts +12 -0
  52. package/dist/waiting.d.ts.map +1 -0
  53. package/dist/waiting.js +30 -0
  54. package/dist/waiting.js.map +1 -0
  55. package/dist/workspace.d.ts +893 -0
  56. package/dist/workspace.d.ts.map +1 -0
  57. package/dist/workspace.js +3160 -0
  58. package/dist/workspace.js.map +1 -0
  59. package/package.json +39 -3
@@ -0,0 +1,1642 @@
1
+ /**
2
+ * A fake Chips: the wire an adapter speaks, on a real port, with the log and the rules of
3
+ * `workspace.ts` behind it. HTTP for what a harness says — `POST /v1/harnesses/:id/events`,
4
+ * `POST /v1/runs/:id/events`, the space door, uploads, the approval long poll — and `/v1/ws`
5
+ * for what it hears, with the handshake, the subscription, the replay and the acks the real
6
+ * server gives. The person's side has no port: the kit plays it by calling `say`, `decide`,
7
+ * `answer` and `askForSpace` directly, and reads the log the same way.
8
+ *
9
+ * The shapes here are the real routes' shapes, field for field, because an adapter passing
10
+ * against a stand-in that is looser than the server would learn nothing. Where the real server
11
+ * would do something this one cannot — sniff an upload's bytes, sign an attachment URL — the
12
+ * answer is honest about it: the mime type comes from the filename, the signature is a word.
13
+ */
14
+ import { randomBytes } from "node:crypto";
15
+ import { createServer } from "node:http";
16
+ import { AGENT_EVENTS, budgetOf, byteRange, CONTEXT_BUDGET, clientFrame, contentRange, createIdGenerator, eventIdOfMessage, exchangeFactsOf, inLegacyView, jobRunsPost, messageIdOf, negotiateProtocol, ONE_ROOM_REFUSALS, opensFor, PROTOCOL_VERSION, projectRequest, ROOM_FILES_CEILING, renderExchangeNote, roomNoteFor, takersOf, toEveryAgent, withoutHidden, yoursWithoutAt, } from "@chipschat/protocol";
17
+ import { WebSocketServer } from "ws";
18
+ import { FakeJoins } from "./fake-join.js";
19
+ import { FakeProjects } from "./project.js";
20
+ import { ACCEPTS, FakeSettings } from "./settings.js";
21
+ import { AgentDeleted, Workspace, } from "./workspace.js";
22
+ const STATUS = {
23
+ unauthorized: 401,
24
+ forbidden: 403,
25
+ not_found: 404,
26
+ invalid_event: 422,
27
+ stale_file: 409,
28
+ invalid_frame: 400,
29
+ internal: 500,
30
+ };
31
+ /** The states a run has ended in. */
32
+ const ENDED = new Set(["done", "failed", "cancelled"]);
33
+ /** The ceiling on one approval poll, as the real route has it. */
34
+ const MAX_POLL_SECONDS = 900;
35
+ /** How long a signed attachment URL is good for (decision 10). */
36
+ const SIGNED_URL_MS = 10 * 60 * 1000;
37
+ const REPLAY_PAGE = 500;
38
+ const CLOSE_POLICY = 1008;
39
+ const CLOSE_GOING_AWAY = 1001;
40
+ class HttpFailure extends Error {
41
+ code;
42
+ issues;
43
+ name = "HttpFailure";
44
+ constructor(code, message, issues) {
45
+ super(message);
46
+ this.code = code;
47
+ this.issues = issues;
48
+ }
49
+ }
50
+ function isRecord(value) {
51
+ return typeof value === "object" && value !== null && !Array.isArray(value);
52
+ }
53
+ /** An agent to register, as the real door reads it: its key, name, whether it leads, its state. */
54
+ function agentBody(body, harnessId) {
55
+ const key = typeof body["key"] === "string" ? body["key"].trim() : "";
56
+ const name = typeof body["name"] === "string" ? withoutHidden(body["name"]).trim() : "";
57
+ // The harness's own id names its default agent (ADR 0183); no other harness's id is a key.
58
+ if (key === "" || key.length > 400 || (key.startsWith("hrn_") && key !== harnessId)) {
59
+ throw new HttpFailure("invalid_event", "Send the `key` you know the agent by.");
60
+ }
61
+ if (name === "" || name.length > 120) {
62
+ throw new HttpFailure("invalid_event", "Send a `name`, up to 120 characters.");
63
+ }
64
+ const state = body["state"];
65
+ if (state !== undefined && state !== "active" && state !== "archived") {
66
+ throw new HttpFailure("invalid_event", "Send `state` as `active` or `archived`.");
67
+ }
68
+ return { key, name, lead: body["lead"] === true, state, ...askedFor(body) };
69
+ }
70
+ /**
71
+ * What an agent asked for in a room says of itself, as the real door reads it: a fork's copy, the
72
+ * agent it copies; a new agent (ADR 0108), the ask it answers; and either, the room.
73
+ */
74
+ function askedFor(body) {
75
+ const forkedFrom = typeof body["forked_from"] === "string" ? body["forked_from"] : undefined;
76
+ const madeFor = typeof body["made_for"] === "string" ? body["made_for"] : undefined;
77
+ const spaceId = typeof body["space_id"] === "string" ? body["space_id"] : undefined;
78
+ if (forkedFrom !== undefined && madeFor !== undefined) {
79
+ throw new HttpFailure("invalid_event", "An agent is a fork's copy (`forked_from`) or a new one asked for (`made_for`), not both.");
80
+ }
81
+ if (spaceId !== undefined && forkedFrom === undefined && madeFor === undefined) {
82
+ throw new HttpFailure("invalid_event", "`space_id` is the space a fork's copy or a new agent was asked for: send `forked_from` or `made_for` with it.");
83
+ }
84
+ if (madeFor !== undefined && spaceId === undefined) {
85
+ throw new HttpFailure("invalid_event", "`made_for` is the ask a new agent answers: send the space's `space_id` with it.");
86
+ }
87
+ return { forkedFrom, madeFor, spaceId };
88
+ }
89
+ function jsonOf(body, what = "The body is not JSON.") {
90
+ const text = body.toString("utf8");
91
+ try {
92
+ const parsed = JSON.parse(text === "" ? "{}" : text);
93
+ if (!isRecord(parsed))
94
+ throw new Error("not an object");
95
+ return parsed;
96
+ }
97
+ catch {
98
+ throw new HttpFailure("invalid_event", what);
99
+ }
100
+ }
101
+ async function bodyOf(request) {
102
+ const chunks = [];
103
+ for await (const chunk of request)
104
+ chunks.push(Buffer.from(chunk));
105
+ return Buffer.concat(chunks);
106
+ }
107
+ function respond(response, answer) {
108
+ if (answer.bytes !== undefined) {
109
+ response.writeHead(answer.status, {
110
+ "content-length": answer.bytes.byteLength,
111
+ ...answer.headers,
112
+ });
113
+ response.end(Buffer.from(answer.bytes));
114
+ return;
115
+ }
116
+ const text = JSON.stringify(answer.body ?? {});
117
+ response.writeHead(answer.status, {
118
+ "content-type": "application/json",
119
+ "content-length": Buffer.byteLength(text),
120
+ ...answer.headers,
121
+ });
122
+ response.end(text);
123
+ }
124
+ /** A refusal at the door, as the HTTP error envelope. */
125
+ function refusalAnswer(failure) {
126
+ return {
127
+ status: STATUS[failure.code],
128
+ body: {
129
+ error: {
130
+ code: failure.code,
131
+ message: failure.message,
132
+ request_id: "conformance",
133
+ ...(failure.issues === undefined ? {} : { issues: failure.issues }),
134
+ },
135
+ },
136
+ };
137
+ }
138
+ function approvalJson(row) {
139
+ return {
140
+ approval_id: row.approval_id,
141
+ run_id: row.run_id,
142
+ state: row.state,
143
+ resolution: row.decision,
144
+ scope: row.scope,
145
+ reason: row.reason,
146
+ resolved_by: row.resolved_by,
147
+ resolved_at: row.resolved_at,
148
+ updated_input: row.updated_input,
149
+ expires_at: row.expires_at,
150
+ fallback: row.fallback,
151
+ };
152
+ }
153
+ /**
154
+ * One socket, as the real `Session` handles it: hello, subscribe, replay from `since`, an ack
155
+ * for every event, a pong for every ping, and delivery in `seq` order at most once.
156
+ */
157
+ class Session {
158
+ socket;
159
+ principal;
160
+ host;
161
+ version;
162
+ subscribed = false;
163
+ deliveredSeq = 0;
164
+ memberSpaces = new Set();
165
+ closed = false;
166
+ /** The server frames beyond the base set this socket said it takes, in its `hello`. */
167
+ accepts = new Set();
168
+ /** How much of a room it is handed with a message for it, from its `hello` (ADR 0088). */
169
+ budget = budgetOf();
170
+ constructor(socket, principal, host) {
171
+ this.socket = socket;
172
+ this.principal = principal;
173
+ this.host = host;
174
+ }
175
+ get isSubscribed() {
176
+ return this.subscribed && !this.closed;
177
+ }
178
+ receive(raw) {
179
+ let parsed;
180
+ try {
181
+ parsed = JSON.parse(raw);
182
+ }
183
+ catch {
184
+ this.fail("invalid_frame", "That frame is not JSON.");
185
+ return;
186
+ }
187
+ const frame = clientFrame.safeParse(parsed);
188
+ if (!frame.success) {
189
+ this.fail("invalid_frame", "That is not a frame this server understands.");
190
+ return;
191
+ }
192
+ switch (frame.data.frame) {
193
+ case "hello":
194
+ this.budget = budgetOf(frame.data.context);
195
+ this.accepts = new Set(frame.data.accepts ?? []);
196
+ this.host.helloed();
197
+ this.onHello(frame.data.protocol);
198
+ return;
199
+ case "subscribe":
200
+ this.onSubscribe(frame.data.workspace_id, frame.data.since);
201
+ return;
202
+ case "event":
203
+ this.onEvent(frame.data.event);
204
+ return;
205
+ case "ping":
206
+ this.send({ frame: "pong", ts: frame.data.ts });
207
+ return;
208
+ default:
209
+ return;
210
+ }
211
+ }
212
+ onHello(protocol) {
213
+ const version = negotiateProtocol(protocol, { min: PROTOCOL_VERSION, max: PROTOCOL_VERSION });
214
+ if (version === undefined) {
215
+ this.fail("protocol_unsupported", `This server speaks Agent Connect v${PROTOCOL_VERSION}; you asked for ${protocol.min}–${protocol.max}.`);
216
+ this.close(CLOSE_POLICY, "protocol_unsupported");
217
+ return;
218
+ }
219
+ this.version = version;
220
+ const { workspace } = this.host;
221
+ const me = this.principal;
222
+ const manifest = {
223
+ harness_id: me.participantId,
224
+ display_name: me.displayName,
225
+ owner_id: workspace.owner.participantId,
226
+ ephemeral: false,
227
+ };
228
+ // Its agents, with their rooms, for a harness that speaks them (ADR 0087).
229
+ const agents = this.speaks()
230
+ ? [...workspace.agents.values()].map((agent) => ({
231
+ agent_id: agent.agent_id,
232
+ key: agent.key,
233
+ name: agent.name,
234
+ lead: agent.lead,
235
+ state: agent.state,
236
+ spaces: workspace.memberSpacesOf(agent.agent_id),
237
+ room: workspace.whereabouts(agent).room,
238
+ held: workspace.whereabouts(agent).held,
239
+ }))
240
+ : undefined;
241
+ this.send({
242
+ frame: "event",
243
+ event: {
244
+ v: 1,
245
+ type: "hello",
246
+ id: this.host.ids.next("event"),
247
+ ts: workspace.nowIso(),
248
+ workspace_id: workspace.id,
249
+ actor: { kind: "system", id: "system" },
250
+ protocol: { min: PROTOCOL_VERSION, max: PROTOCOL_VERSION },
251
+ server: { name: "chips-conformance", version: this.host.version },
252
+ harnesses: [manifest],
253
+ ...(me.kind === "harness" ? { harness: manifest } : {}),
254
+ ...(agents === undefined ? {} : { agents }),
255
+ // Ask first: nothing here decides a card, so the dial reads as the level where nothing does.
256
+ autonomy: 1,
257
+ policy: { autonomy: 1, rules: [] },
258
+ spaces: workspace.memberSpacesOf(me.participantId),
259
+ },
260
+ });
261
+ }
262
+ onSubscribe(workspaceId, since) {
263
+ if (this.version === undefined) {
264
+ this.fail("invalid_frame", "Say hello before you subscribe.");
265
+ return;
266
+ }
267
+ if (workspaceId !== this.principal.workspaceId) {
268
+ this.fail("forbidden", "You may only subscribe to your own workspace.");
269
+ return;
270
+ }
271
+ const { workspace } = this.host;
272
+ this.memberSpaces = new Set(workspace.memberSpacesOf(this.principal.participantId));
273
+ this.deliveredSeq = since ?? workspace.head();
274
+ this.subscribed = true;
275
+ let cursor = this.deliveredSeq;
276
+ for (;;) {
277
+ const page = workspace.since(cursor, REPLAY_PAGE, this.viewer());
278
+ for (const event of page.events)
279
+ this.deliver(event);
280
+ if (page.nextCursor === null || this.closed)
281
+ return;
282
+ cursor = page.nextCursor;
283
+ }
284
+ }
285
+ onEvent(event) {
286
+ if (this.version === undefined) {
287
+ this.fail("invalid_frame", "Say hello before you send an event.");
288
+ return;
289
+ }
290
+ const key = event["idempotency_key"];
291
+ if (typeof key !== "string" || key.length === 0) {
292
+ this.fail("invalid_frame", "Every event you send carries an idempotency key.");
293
+ return;
294
+ }
295
+ const outcome = this.host.workspace.submit(this.principal, event);
296
+ if (!outcome.ok) {
297
+ const { error } = outcome;
298
+ this.send({
299
+ frame: "error",
300
+ code: error.code,
301
+ message: error.message,
302
+ idempotency_key: key,
303
+ // Which rule, and where the agent is, as the real socket says it (ADR 0087).
304
+ ...(ONE_ROOM_REFUSALS.has(error.kind) ? { refusal: error.kind } : {}),
305
+ ...(error.space_id === undefined ? {} : { space_id: error.space_id }),
306
+ });
307
+ return;
308
+ }
309
+ this.send({
310
+ frame: "ack",
311
+ idempotency_key: key,
312
+ seq: outcome.event.seq,
313
+ duplicate: outcome.duplicate,
314
+ // A task that opened a lane says where it is, as the real server does (M3.5).
315
+ ...(outcome.lane === undefined ? {} : { lane: outcome.lane }),
316
+ // A message held from the agents it named, so the sender can say so (ADR 0097).
317
+ ...(outcome.held === undefined ? {} : { held: true, exchange: outcome.held }),
318
+ });
319
+ }
320
+ viewer() {
321
+ return {
322
+ participantId: this.principal.participantId,
323
+ memberSpaces: this.memberSpaces,
324
+ alsoIs: this.agents(),
325
+ // The one person here owns the workspace, and is told who everybody is, and every agent
326
+ // (ADR 0034).
327
+ seesDirectory: this.principal.kind === "person",
328
+ seesAgents: this.principal.kind === "person",
329
+ };
330
+ }
331
+ /** The harness's agents, which it speaks for on this socket (ADR 0087); none for a person. */
332
+ agents() {
333
+ if (this.principal.kind !== "harness")
334
+ return new Set();
335
+ // A deleted one is not its harness's to speak for (ADR 0095).
336
+ const agents = [...this.host.workspace.agents.values()].filter((agent) => !agent.deleted);
337
+ return new Set(agents.map((agent) => agent.agent_id));
338
+ }
339
+ /** The ids this socket answers to: itself, and its agents. */
340
+ selves() {
341
+ return new Set([this.principal.participantId, ...this.agents()]);
342
+ }
343
+ /** Whether it is shown agents as they are: a person, or a harness that declared `agents`. */
344
+ speaks() {
345
+ return this.principal.kind !== "harness" || this.host.workspace.speaksAgents();
346
+ }
347
+ /** One event to this subscriber, in order and at most once, if it may see it. */
348
+ deliver(event) {
349
+ if (this.closed || !this.subscribed || event.seq <= this.deliveredSeq)
350
+ return;
351
+ if (!this.host.workspace.canSee(event, this.viewer())) {
352
+ this.deliveredSeq = event.seq;
353
+ return;
354
+ }
355
+ this.noteMembership(event);
356
+ this.deliveredSeq = event.seq;
357
+ // What a harness that has not learnt agents has no words for, and the view it knew.
358
+ if (!this.speaks() && AGENT_EVENTS.has(event.type))
359
+ return;
360
+ const payload = this.asDelivered(event);
361
+ this.send({
362
+ frame: "event",
363
+ event: this.speaks()
364
+ ? payload
365
+ : inLegacyView(payload, { harnessId: this.principal.participantId, own: this.agents() }),
366
+ });
367
+ this.receiptFor(event);
368
+ }
369
+ noteMembership(event) {
370
+ const space = event.space_id;
371
+ if (space === null)
372
+ return;
373
+ const me = this.principal.participantId;
374
+ const selves = this.selves();
375
+ const p = event.payload;
376
+ const named = [p["participant_id"], p["to"], p["from"]].some((id) => typeof id === "string" && selves.has(id));
377
+ const opened = opensFor(event, me);
378
+ const joins = event.type === "membership.added" || event.type === "membership.replaced";
379
+ if ((joins && named) || opened)
380
+ this.memberSpaces.add(space);
381
+ else if (event.type === "membership.removed" && named) {
382
+ // Read again, as the real server reads it: one of the harness's two agents leaving a room
383
+ // does not take the harness out of it. Forgetting the room made the socket deaf to the
384
+ // one still there (agents review, finding 37).
385
+ this.memberSpaces = new Set(this.host.workspace.memberSpacesOf(me));
386
+ }
387
+ }
388
+ /**
389
+ * What another participant said reaches a harness as `message.new` (decision 10) when it is for
390
+ * the harness, or was logged before Chips said who a message is for, and as
391
+ * `message.overheard` when it is for somebody else (ADR 0081).
392
+ */
393
+ asDelivered(event) {
394
+ if (event.type === "agent.attached")
395
+ return this.withCatchUp(event);
396
+ if (event.type === "message.handed")
397
+ return this.asHanded(event);
398
+ if (event.type !== "message.post")
399
+ return event.payload;
400
+ // Its own words as it said them, unless one of its agents said them to another (ADR 0101),
401
+ // which a harness that has not declared agents, knowing them all as itself, is never handed.
402
+ const own = this.selves().has(event.actor.id);
403
+ if (own && (!this.speaks() || this.turnTakers(event).length === 0)) {
404
+ return this.ownHeard(event);
405
+ }
406
+ return this.asHeard(event, this.isForMe(event) ? "message.new" : "message.overheard");
407
+ }
408
+ /**
409
+ * Its own agents with a turn going in the room, other than the one who said it and those it is
410
+ * for (ADR 0106), as the real server names them. The fake keeps no clock, so none is too quiet.
411
+ */
412
+ workingTurns(event) {
413
+ if (event.space_id === null || this.principal.kind !== "harness" || !this.speaks())
414
+ return [];
415
+ const skip = new Set([event.actor.id, ...this.turnTakers(event)]);
416
+ const mine = this.agents();
417
+ return [...this.host.workspace.runs.values()].flatMap((run) => run.space_id === event.space_id &&
418
+ run.agent_id !== undefined &&
419
+ mine.has(run.agent_id) &&
420
+ !skip.has(run.agent_id) &&
421
+ !ENDED.has(run.state)
422
+ ? [{ agent_id: run.agent_id, run_id: run.run_id }]
423
+ : []);
424
+ }
425
+ /** Its own words as said, and, with others of its agents working there, who said them (ADR 0106). */
426
+ ownHeard(event) {
427
+ const working = this.workingTurns(event);
428
+ if (working.length === 0)
429
+ return event.payload;
430
+ const attachments = this.signedAttachments(event);
431
+ return {
432
+ ...event.payload,
433
+ message_id: messageIdOf(event.id),
434
+ from: this.senderOf(event),
435
+ ...(attachments === undefined || attachments.length === 0 ? {} : { attachments }),
436
+ working_turns: working,
437
+ };
438
+ }
439
+ /** A message's files, each with a URL this harness may fetch. */
440
+ signedAttachments(event) {
441
+ return event.payload["attachments"]?.map((attachment) => ({
442
+ ...attachment,
443
+ ...this.host.signed(String(attachment["attachment_id"])),
444
+ }));
445
+ }
446
+ /** The ids a message is for; `undefined` from before Chips said who a message is for. */
447
+ addressedTo(event) {
448
+ const addressed = event.payload["addressed_to"];
449
+ return Array.isArray(addressed) ? addressed : undefined;
450
+ }
451
+ isForMe(event) {
452
+ return this.addressedTo(event) === undefined || this.turnTakers(event).length > 0;
453
+ }
454
+ /**
455
+ * Who of this socket takes the turn, as the real server says it (`takersOf`): those it is for,
456
+ * the harness itself where it is the member addressed, or, from before Chips said, every one
457
+ * of its own in the room; never the one who said it.
458
+ */
459
+ turnTakers(event) {
460
+ return takersOf(this.addressedTo(event), this.selves(), this.ownIn(event), event.actor.id);
461
+ }
462
+ /** This socket's own in a room: its agents there, or the harness itself where it is a member. */
463
+ ownIn(event) {
464
+ if (event.space_id === null)
465
+ return [];
466
+ const { workspace } = this.host;
467
+ const agents = workspace.agentsIn(event.space_id).map((agent) => agent.agent_id);
468
+ if (agents.length > 0)
469
+ return agents;
470
+ const me = this.principal.participantId;
471
+ return workspace.roleOf(event.space_id, me) === undefined ? [] : [me];
472
+ }
473
+ asHeard(event, type, nobodys) {
474
+ const attachments = this.signedAttachments(event);
475
+ const working = this.workingTurns(event);
476
+ const context = this.contextOf(event, type);
477
+ const replying = this.replyingOf(event, context);
478
+ // Older lines left out to fit the budget are said in the note (ADR 0105).
479
+ const cut = context?.truncated === true ? { shown: context.lines.length } : undefined;
480
+ const room = this.roomOf(event, type, { nobodys, cut });
481
+ const exchange = this.exchangeOf(event, type);
482
+ return {
483
+ v: 1,
484
+ type,
485
+ id: event.id,
486
+ ts: event.ts,
487
+ workspace_id: this.host.workspace.id,
488
+ ...(event.space_id === null ? {} : { space_id: event.space_id }),
489
+ ...(event.run_id === null ? {} : { run_id: event.run_id }),
490
+ seq: event.seq,
491
+ actor: { kind: "system", id: "system" },
492
+ ...(event.idempotency_key === null ? {} : { idempotency_key: event.idempotency_key }),
493
+ message_id: messageIdOf(event.id),
494
+ from: this.senderOf(event),
495
+ ...(attachments === undefined || attachments.length === 0 ? {} : { attachments }),
496
+ ...heardFields(event.payload, type),
497
+ ...(replying === undefined ? {} : { replying_to: replying }),
498
+ for: type === "message.new" ? this.turnTakers(event) : [],
499
+ ...(working.length === 0 ? {} : { working_turns: working }),
500
+ ...(context === undefined ? {} : { context }),
501
+ ...(room === undefined ? {} : { room }),
502
+ ...(exchange === undefined ? {} : { exchange }),
503
+ };
504
+ }
505
+ /**
506
+ * How the agents' talk stands, with an agent's message for this harness, once it is worth a
507
+ * word (ADR 0097): the real server's facts and words, as of the message.
508
+ */
509
+ exchangeOf(event, type) {
510
+ if (type !== "message.new" || event.space_id === null)
511
+ return undefined;
512
+ if (event.actor.kind !== "harness" && event.actor.kind !== "agent")
513
+ return undefined;
514
+ const { workspace } = this.host;
515
+ const takers = this.turnTakers(event);
516
+ const alsoFor = (this.addressedTo(event) ?? []).filter((id) => !takers.includes(id));
517
+ const facts = exchangeFactsOf(workspace.exchangeAt(event.space_id, event.seq), {
518
+ nowMs: Date.parse(event.ts),
519
+ alsoFor,
520
+ });
521
+ if (facts.stage === "quiet")
522
+ return undefined;
523
+ const names = new Map(workspace
524
+ .participantsVisibleTo(this.principal.participantId)
525
+ .map((each) => [each.participantId, each.displayName]));
526
+ return { ...facts, note: renderExchangeNote(facts, (id) => names.get(id) ?? "someone") };
527
+ }
528
+ /**
529
+ * A held message handed to one of this harness's own once a person acted (ADR 0097): that
530
+ * message as `message.new` for it, with `handed`, numbered as the hand-over, as the real one.
531
+ */
532
+ asHanded(event) {
533
+ const agentId = event.payload["agent_id"];
534
+ const messageId = event.payload["message_id"];
535
+ if (typeof agentId !== "string" || !this.selves().has(agentId))
536
+ return event.payload;
537
+ if (typeof messageId !== "string")
538
+ return event.payload;
539
+ let eventId;
540
+ try {
541
+ eventId = eventIdOfMessage(messageId);
542
+ }
543
+ catch {
544
+ return event.payload;
545
+ }
546
+ const original = this.host.workspace.log.find((each) => each.id === eventId);
547
+ if (original?.type !== "message.post")
548
+ return event.payload;
549
+ // What was nobody's turn, handed to the room's lead (ADR 0104): its note says why.
550
+ const toLead = event.payload["lead"] === true;
551
+ const heard = this.asHeard({ ...original, payload: { ...original.payload, addressed_to: [agentId] } }, "message.new", toLead ? this.nobodysOf(original) : undefined);
552
+ // Handed later, so no turn going now is to hear it as said (ADR 0106).
553
+ const { exchange: _talk, working_turns: _working, ...words } = heard;
554
+ return {
555
+ ...words,
556
+ id: event.id,
557
+ seq: event.seq,
558
+ handed: true,
559
+ ...(toLead ? { lead: true } : {}),
560
+ };
561
+ }
562
+ /** Who said what was nobody's turn, and the person to tell: the workspace's owner, here. */
563
+ nobodysOf(original) {
564
+ const { workspace } = this.host;
565
+ const saidBy = workspace
566
+ .participantsVisibleTo(this.principal.participantId)
567
+ .find((each) => each.participantId === original.actor.id)?.displayName ?? "An agent";
568
+ return { saidBy, tell: workspace.owner.displayName };
569
+ }
570
+ /** A participant's name as this harness knows it, or "Someone". */
571
+ nameOf(participantId) {
572
+ return (this.host.workspace
573
+ .participantsVisibleTo(this.principal.participantId)
574
+ .find((each) => each.participantId === participantId)?.displayName ?? "Someone");
575
+ }
576
+ /**
577
+ * Who is in the room and how @ works, for each who takes the turn (ADR 0092), and who leads it
578
+ * (ADR 0104); with, on what was nobody's turn handed to the lead, why it is its; that older
579
+ * lines were left out of the turn (ADR 0105); and what the room's job has spent (ADR 0117).
580
+ */
581
+ roomOf(event, type, turn) {
582
+ if (type !== "message.new" || event.space_id === null)
583
+ return undefined;
584
+ const takers = this.turnTakers(event);
585
+ const who = takers.length > 0 ? takers : [this.principal.participantId];
586
+ // A message for a taker with no @ for it says that it is theirs (ADR 0104 addendum), and one
587
+ // for every agent that each has it (ADR 0249).
588
+ const sender = turn.nobodys === undefined ? { saidBy: this.nameOf(event.actor.id) } : undefined;
589
+ const toAll = sender !== undefined && toEveryAgent(event.payload) ? sender : undefined;
590
+ const toldTo = (id) => ({
591
+ ...(turn.nobodys === undefined ? {} : { nobodys: turn.nobodys }),
592
+ ...(sender === undefined || !yoursWithoutAt(event.payload, id) ? {} : { yours: sender }),
593
+ ...(toAll === undefined ? {} : { toAll }),
594
+ ...(turn.cut === undefined ? {} : { cut: turn.cut }),
595
+ });
596
+ const told = who.map((id) => [id, toldTo(id)]);
597
+ const news = told.every(([, each]) => Object.keys(each).length === 0)
598
+ ? undefined
599
+ : Object.fromEntries(told);
600
+ return roomNoteFor(this.host.workspace.noteMembers(event.space_id), who, undefined, news, this.host.workspace.spendOf(event.space_id, event.seq));
601
+ }
602
+ /** One of its agents put in a room, with the room's last lines before it joined (ADR 0088). */
603
+ withCatchUp(event) {
604
+ const agentId = String(event.payload["agent_id"]);
605
+ if (!this.agents().has(agentId) || event.space_id === null)
606
+ return event.payload;
607
+ const catchUp = this.host.workspace.catchUpFor(event.space_id, event.seq, this.budget, this.host.signed);
608
+ return catchUp === undefined ? event.payload : { ...event.payload, catch_up: catchUp };
609
+ }
610
+ /**
611
+ * The message a reply answers (ADR 0219), as the real server hands it over: with a turn, the
612
+ * lines said before it too when it is older than the context; overheard, the quote alone.
613
+ */
614
+ replyingOf(event, handed) {
615
+ const replyTo = event.payload["reply_to"];
616
+ if (typeof replyTo !== "string" || event.space_id === null)
617
+ return undefined;
618
+ return this.host.workspace.replyingToFor(event.space_id, replyTo, handed, this.host.signed);
619
+ }
620
+ /** What was said since it last spoke there, with a message for it only (ADR 0088). */
621
+ contextOf(event, type) {
622
+ if (type !== "message.new" || event.space_id === null)
623
+ return undefined;
624
+ const takers = this.turnTakers(event);
625
+ const context = this.host.workspace.contextFor(takers.length > 0 ? takers : [this.principal.participantId], event.space_id, event.seq, this.budget, this.host.signed);
626
+ // What the room's job has spent, as of the message (ADR 0117).
627
+ return context === undefined
628
+ ? undefined
629
+ : { ...context, spend: this.host.workspace.spendOf(event.space_id, event.seq) };
630
+ }
631
+ /**
632
+ * Who said it: their name, their role in the room, and for the owner — the one person the fake
633
+ * has a standing for — their standing in the workspace (SEC-15), as the real server says it.
634
+ */
635
+ senderOf(event) {
636
+ const { workspace } = this.host;
637
+ const sender = workspace
638
+ .participantsVisibleTo(this.principal.participantId)
639
+ .find((each) => each.participantId === event.actor.id);
640
+ const role = event.space_id === null ? undefined : workspace.roleOf(event.space_id, event.actor.id);
641
+ const kind = event.actor.kind;
642
+ const owner = event.actor.kind === "person" && event.actor.id === workspace.owner.participantId;
643
+ return {
644
+ participant_id: event.actor.id,
645
+ kind: kind === "agent" || kind === "harness" ? kind : "person",
646
+ display_name: sender?.displayName ?? event.actor.id,
647
+ ...(role === undefined ? {} : { role }),
648
+ ...(owner ? { workspace_role: "owner" } : {}),
649
+ };
650
+ }
651
+ /** A person's message into this harness's live run gets a receipt beside it: queued or steered. */
652
+ receiptFor(event) {
653
+ if (event.actor.kind !== "person" || event.idempotency_key === null)
654
+ return;
655
+ // A message for somebody else is not this harness's, whatever run it has going (ADR 0081).
656
+ if (event.type === "message.post" && !this.isForMe(event))
657
+ return;
658
+ const disposition = event.type === "message.post" ? "queued" : event.type === "run.steer" ? "steered" : undefined;
659
+ if (disposition === undefined)
660
+ return;
661
+ const run = this.runOf(event);
662
+ if (run === undefined || run.harness_id !== this.principal.participantId)
663
+ return;
664
+ this.send({ frame: "receipt", idempotency_key: event.idempotency_key, disposition });
665
+ }
666
+ /** The run an event names, or this harness's run going in its space. */
667
+ runOf(event) {
668
+ const { workspace } = this.host;
669
+ if (event.run_id !== null)
670
+ return workspace.runs.get(event.run_id);
671
+ if (event.space_id === null)
672
+ return undefined;
673
+ return workspace.liveRunIn(event.space_id, this.principal.participantId);
674
+ }
675
+ send(frame) {
676
+ if (this.closed)
677
+ return;
678
+ this.socket.send(JSON.stringify(frame));
679
+ }
680
+ /** A frame beyond the base set, sent only when this socket said its word: now or never. */
681
+ offer(word, frame) {
682
+ if (!this.isSubscribed || !this.accepts.has(word))
683
+ return false;
684
+ this.send(frame);
685
+ return true;
686
+ }
687
+ fail(code, message) {
688
+ this.send({ frame: "error", code, message });
689
+ }
690
+ close(code, reason) {
691
+ if (this.closed)
692
+ return;
693
+ this.closed = true;
694
+ this.socket.close(code, reason);
695
+ }
696
+ onDisconnected() {
697
+ this.closed = true;
698
+ }
699
+ }
700
+ /** What a harness is told of a message's own words and aim, as logged: the real `heardFields`. */
701
+ function heardFields(payload, type) {
702
+ const { text, reply_to: replyTo, mentions, addressed_to: addressedTo, held } = payload;
703
+ return {
704
+ text: typeof text === "string" ? text : "",
705
+ ...(Array.isArray(mentions) ? { mentions } : {}),
706
+ ...(typeof replyTo === "string" ? { reply_to: replyTo } : {}),
707
+ ...(Array.isArray(addressedTo) ? { addressed_to: addressedTo } : {}),
708
+ ...(type === "message.overheard" && held === true ? { held: true } : {}),
709
+ };
710
+ }
711
+ /** A token as the server mints one: the prefix, and 192 bits of nothing anybody can guess. */
712
+ function newToken() {
713
+ return `chips_at_${randomBytes(24).toString("base64url")}`;
714
+ }
715
+ /** The door: the routes, the upgrade, and the person's side for the kit. */
716
+ class Door {
717
+ workspace;
718
+ address;
719
+ server;
720
+ sockets = new WebSocketServer({ noServer: true });
721
+ sessions = new Set();
722
+ /** Socket tickets minted and not yet spent (SEC-22). */
723
+ tickets = new Map();
724
+ /** The rooms' projects (ADR 0220), made on first use. */
725
+ projectsMade;
726
+ ids;
727
+ version;
728
+ host;
729
+ base = "";
730
+ heardOverHttp = false;
731
+ /** Join requests (ADR 0122): whatever joins is handed this fake's one harness. */
732
+ joins = new FakeJoins(() => this.address);
733
+ settings = new FakeSettings();
734
+ /** Runs of the harness's own jobs it posted, a run posted again in place (ADR 0252). */
735
+ jobRuns = [];
736
+ statuses = [];
737
+ helloCount = 0;
738
+ constructor(options) {
739
+ this.ids = options.ids ?? createIdGenerator();
740
+ this.version = options.version ?? "0.0.0-conformance";
741
+ this.host = options.host ?? "127.0.0.1";
742
+ const workspaceId = this.ids.next("workspace");
743
+ const owner = {
744
+ kind: "person",
745
+ participantId: this.ids.next("person"),
746
+ workspaceId,
747
+ displayName: options.ownerName ?? "Arjun",
748
+ };
749
+ const harness = {
750
+ kind: "harness",
751
+ participantId: this.ids.next("harness"),
752
+ workspaceId,
753
+ displayName: options.harnessName ?? "The harness under test",
754
+ };
755
+ this.workspace = new Workspace({
756
+ workspaceId,
757
+ owner,
758
+ harness,
759
+ ids: this.ids,
760
+ now: options.now ?? (() => new Date()),
761
+ capabilities: options.capabilities,
762
+ });
763
+ const spaceId = this.workspace.createDirectSpace();
764
+ this.address = {
765
+ url: "",
766
+ workspaceId,
767
+ harnessId: harness.participantId,
768
+ token: newToken(),
769
+ ownerId: owner.participantId,
770
+ spaceId,
771
+ };
772
+ this.server = createServer((request, response) => void this.handle(request, response));
773
+ this.server.on("upgrade", (request, socket, head) => {
774
+ const url = new URL(request.url ?? "/", this.base);
775
+ if (url.pathname !== "/v1/ws") {
776
+ socket.destroy();
777
+ return;
778
+ }
779
+ // A ticket from `POST /v1/socket-tickets`, as the SDK opens with (SEC-22); a header, or
780
+ // the token in the query that an older SDK sent, as the real door still takes this release.
781
+ const ticket = url.searchParams.get("ticket");
782
+ const query = url.searchParams.get("token");
783
+ const principal = ticket !== null
784
+ ? this.redeemTicket(ticket)
785
+ : this.principalOf(request.headers.authorization ?? (query === null ? undefined : `Bearer ${query}`));
786
+ if (principal === undefined) {
787
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
788
+ socket.destroy();
789
+ return;
790
+ }
791
+ this.sockets.handleUpgrade(request, socket, head, (ws) => this.open(ws, principal));
792
+ });
793
+ // Every append reaches every socket, in order: the log is the only source of a frame.
794
+ this.workspace.onAppend((event) => {
795
+ for (const session of this.sessions)
796
+ session.deliver(event);
797
+ });
798
+ }
799
+ async listen(port) {
800
+ await new Promise((resolve, reject) => {
801
+ this.server.once("error", reject);
802
+ this.server.listen(port, this.host, () => {
803
+ this.server.removeListener("error", reject);
804
+ resolve();
805
+ });
806
+ });
807
+ const bound = this.server.address().port;
808
+ this.base = `http://${this.host}:${bound}`;
809
+ this.address.url = this.base;
810
+ return this.address;
811
+ }
812
+ connections() {
813
+ return [...this.sessions].filter((session) => session.isSubscribed).length;
814
+ }
815
+ hellos() {
816
+ return this.helloCount;
817
+ }
818
+ /** A frame to every socket that said `word` in its `hello`: how many heard it. */
819
+ offer(word, frame) {
820
+ return [...this.sessions].filter((session) => session.offer(word, frame)).length;
821
+ }
822
+ askToChange(kind, id) {
823
+ const frame = this.settings.askOf(kind, id, {
824
+ ids: this.ids,
825
+ harnessId: this.workspace.harness.participantId,
826
+ ownerId: this.workspace.owner.participantId,
827
+ now: this.workspace.nowIso(),
828
+ });
829
+ return frame === undefined ? undefined : { frame, heard: this.offer(ACCEPTS[kind], frame) };
830
+ }
831
+ askToRestart() {
832
+ return this.offer("restart", {
833
+ frame: "restart",
834
+ asked_by: this.workspace.owner.participantId,
835
+ asked_at: this.workspace.nowIso(),
836
+ });
837
+ }
838
+ heard() {
839
+ return this.heardOverHttp || this.sessions.size > 0;
840
+ }
841
+ async close() {
842
+ for (const session of this.sessions)
843
+ session.close(CLOSE_GOING_AWAY, "closing");
844
+ this.sessions.clear();
845
+ this.sockets.close();
846
+ this.server.closeAllConnections();
847
+ await new Promise((resolve) => this.server.close(() => resolve()));
848
+ this.projectsMade?.close();
849
+ }
850
+ /** The URL a harness gets for an attachment: good for ten minutes, as the real one is. */
851
+ signed = (attachmentId) => ({
852
+ url: `${this.base}/v1/attachments/${attachmentId}?sig=conformance`,
853
+ url_expires_at: new Date(Date.now() + SIGNED_URL_MS).toISOString(),
854
+ });
855
+ open(ws, principal) {
856
+ const session = new Session(ws, principal, {
857
+ workspace: this.workspace,
858
+ ids: this.ids,
859
+ version: this.version,
860
+ signed: this.signed,
861
+ helloed: () => {
862
+ this.helloCount += 1;
863
+ },
864
+ });
865
+ this.sessions.add(session);
866
+ ws.on("message", (data) => session.receive(String(data)));
867
+ ws.on("close", () => {
868
+ session.onDisconnected();
869
+ this.sessions.delete(session);
870
+ });
871
+ ws.on("error", () => undefined);
872
+ }
873
+ /** A single-use ticket for the socket (SEC-22): minted to the token, spent on one upgrade. */
874
+ socketTicket(call) {
875
+ const ticket = newToken();
876
+ this.tickets.set(ticket, call.principal);
877
+ return { status: 201, body: { ticket, expires_in: 30 } };
878
+ }
879
+ redeemTicket(ticket) {
880
+ const principal = this.tickets.get(ticket);
881
+ this.tickets.delete(ticket);
882
+ return principal;
883
+ }
884
+ /** The harness by its token; nobody else has a credential here. */
885
+ principalOf(authorization) {
886
+ const token = authorization?.startsWith("Bearer ") ? authorization.slice("Bearer ".length) : "";
887
+ return token !== "" && token === this.address.token ? this.workspace.harness : undefined;
888
+ }
889
+ async handle(request, response) {
890
+ try {
891
+ if (await this.project(request, response))
892
+ return;
893
+ respond(response, await this.route(request));
894
+ }
895
+ catch (error) {
896
+ respond(response, error instanceof HttpFailure
897
+ ? refusalAnswer(error)
898
+ : refusalAnswer(new HttpFailure("internal", String(error))));
899
+ }
900
+ }
901
+ /**
902
+ * git's requests to a room's project (ADR 0220), streamed to git rather than read: whether this
903
+ * was one, and it has been answered.
904
+ */
905
+ async project(request, response) {
906
+ const url = new URL(request.url ?? "/", this.base);
907
+ const asked = projectRequest(url.pathname);
908
+ if (asked === undefined)
909
+ return false;
910
+ this.heardOverHttp = true;
911
+ if (this.principalOf(request.headers.authorization) === undefined) {
912
+ response.writeHead(401, {
913
+ "content-type": "text/plain; charset=utf-8",
914
+ "www-authenticate": 'Basic realm="Chips"',
915
+ });
916
+ response.end("Chips needs a token for this project.\n");
917
+ return true;
918
+ }
919
+ this.projectsMade ??= new FakeProjects(this.workspace);
920
+ await this.projectsMade.serve(request, response, { ...asked, query: url.search.slice(1) });
921
+ return true;
922
+ }
923
+ /** The doors with no credential: liveness, and the three a harness joins by (ADR 0122). */
924
+ async openDoor(method, url, request) {
925
+ if (method === "GET" && url.pathname === "/v1/health") {
926
+ return {
927
+ status: 200,
928
+ body: { ok: true, name: "chips-conformance", version: this.version },
929
+ headers: { date: new Date().toUTCString() },
930
+ };
931
+ }
932
+ if (method !== "POST" || !/^\/v1\/join(\/|$)/.test(url.pathname))
933
+ return undefined;
934
+ return this.joins.answer(method, url.pathname, jsonOf(await bodyOf(request)));
935
+ }
936
+ async route(request) {
937
+ const url = new URL(request.url ?? "/", this.base);
938
+ const method = request.method ?? "GET";
939
+ const opened = await this.openDoor(method, url, request);
940
+ if (opened !== undefined)
941
+ return opened;
942
+ const principal = this.principalOf(request.headers.authorization);
943
+ if (principal === undefined) {
944
+ throw new HttpFailure("unauthorized", "This needs a harness token: `Authorization: Bearer …`.");
945
+ }
946
+ this.heardOverHttp = true;
947
+ const body = await bodyOf(request);
948
+ for (const [verb, pattern, handle] of this.routes) {
949
+ const matched = verb === method ? pattern.exec(url.pathname) : null;
950
+ if (matched !== null) {
951
+ return await handle({
952
+ principal,
953
+ params: matched.slice(1),
954
+ url,
955
+ body,
956
+ headers: request.headers,
957
+ });
958
+ }
959
+ }
960
+ throw new HttpFailure("not_found", `Nothing answers ${method} ${url.pathname} here.`);
961
+ }
962
+ routes = [
963
+ ["POST", /^\/v1\/socket-tickets$/, async (call) => this.socketTicket(call)],
964
+ ["GET", /^\/v1\/participants$/, async (call) => this.participants(call)],
965
+ ["POST", /^\/v1\/harnesses\/([^/]+)\/spaces$/, async (call) => this.openSpace(call)],
966
+ ["GET", /^\/v1\/harnesses\/([^/]+)\/spaces$/, async (call) => this.listSpaces(call)],
967
+ ["POST", /^\/v1\/harnesses\/([^/]+)\/agents$/, async (call) => this.registerAgent(call)],
968
+ ["GET", /^\/v1\/harnesses\/([^/]+)\/agents$/, async (call) => this.listAgents(call)],
969
+ ["POST", /^\/v1\/agents\/([^/]+)\/attach$/, async (call) => this.attachAgent(call)],
970
+ ["POST", /^\/v1\/harnesses\/([^/]+)\/events$/, async (call) => this.harnessEvent(call)],
971
+ ["POST", /^\/v1\/runs\/([^/]+)\/events$/, async (call) => this.runEvent(call)],
972
+ ["GET", /^\/v1\/approvals\/([^/]+)\/wait$/, (call) => this.waitForApproval(call)],
973
+ ["GET", /^\/v1\/approvals\/([^/]+)$/, async (call) => this.approval(call)],
974
+ ["POST", /^\/v1\/uploads$/, async (call) => this.newUpload(call)],
975
+ ["PUT", /^\/v1\/uploads\/([^/]+)$/, async (call) => this.storeUpload(call)],
976
+ ["GET", /^\/v1\/attachments\/([^/]+)$/, async (call) => this.attachment(call)],
977
+ ["GET", /^\/v1\/events$/, async (call) => this.events(call)],
978
+ ["GET", /^\/v1\/spaces\/([^/]+)\/context$/, async (call) => this.roomContext(call)],
979
+ ["GET", /^\/v1\/spaces\/([^/]+)\/files$/, async (call) => this.roomFiles(call)],
980
+ ["PUT", /^\/v1\/harnesses\/([^/]+)\/status$/, async (call) => this.putStatus(call)],
981
+ ["PUT", /^\/v1\/agents\/([^/]+)\/context$/, async (call) => this.putSetting(call, "context")],
982
+ [
983
+ "PUT",
984
+ /^\/v1\/harnesses\/([^/]+)\/context$/,
985
+ async (call) => this.putSetting(call, "harness-context"),
986
+ ],
987
+ ["PUT", /^\/v1\/agents\/([^/]+)\/model$/, async (call) => this.putSetting(call, "model")],
988
+ [
989
+ "PUT",
990
+ /^\/v1\/agents\/([^/]+)\/heartbeat$/,
991
+ async (call) => this.putSetting(call, "heartbeat"),
992
+ ],
993
+ [
994
+ "PUT",
995
+ /^\/v1\/agents\/([^/]+)\/context-window$/,
996
+ async (call) => this.putSetting(call, "context-window"),
997
+ ],
998
+ ["PUT", /^\/v1\/harnesses\/([^/]+)\/jobs$/, async (call) => this.putSetting(call, "jobs")],
999
+ ["POST", /^\/v1\/harnesses\/([^/]+)\/jobs\/runs$/, async (call) => this.postJobRuns(call)],
1000
+ ];
1001
+ /** The harness says how it is: fine, or in trouble with why in a line, as the real door reads it. */
1002
+ putStatus(call) {
1003
+ if (call.principal.kind !== "harness" || call.params[0] !== call.principal.participantId) {
1004
+ throw new HttpFailure("forbidden", "A harness may only say how it is itself.");
1005
+ }
1006
+ const body = jsonOf(call.body);
1007
+ const reason = typeof body["reason"] === "string" ? body["reason"].replace(/\s+/g, " ").trim() : "";
1008
+ const at = this.workspace.nowIso();
1009
+ if (body["state"] === "ok")
1010
+ this.statuses.push({ state: "ok", at });
1011
+ else if (body["state"] === "trouble" && reason !== "" && reason.length <= 200) {
1012
+ this.statuses.push({ state: "trouble", reason, at });
1013
+ }
1014
+ else {
1015
+ throw new HttpFailure("invalid_event", "Send `state`: `ok`, or `trouble` with a `reason`.");
1016
+ }
1017
+ return { status: 200, body: { state: this.statuses.at(-1)?.state } };
1018
+ }
1019
+ /**
1020
+ * A harness's copy of one of its agents' settings, or of its own files, taken whole (ADRs 0134,
1021
+ * 0140, 0143, 0146): another's agent is no agent of its, and a copy the protocol does not take
1022
+ * is refused with where it went wrong.
1023
+ */
1024
+ putSetting(call, kind) {
1025
+ const id = String(call.params[0]);
1026
+ const harnessId = call.principal.participantId;
1027
+ if (call.principal.kind !== "harness") {
1028
+ throw new HttpFailure("forbidden", "A harness sends its own copies.");
1029
+ }
1030
+ const agent = this.workspace.agents.get(id);
1031
+ const mine = kind === "harness-context" || kind === "jobs"
1032
+ ? id === harnessId
1033
+ : agent !== undefined && !agent.deleted;
1034
+ if (!mine)
1035
+ throw new HttpFailure("not_found", "No agent of yours.");
1036
+ const taken = this.settings.put(kind, id, jsonOf(call.body));
1037
+ if (!taken.ok)
1038
+ throw new HttpFailure("invalid_event", taken.message, taken.issues);
1039
+ return { status: 200, body: { read_at: this.workspace.nowIso() } };
1040
+ }
1041
+ /**
1042
+ * A harness's runs of its own jobs (ADR 0252), checked as the real door checks them: its own,
1043
+ * whole, and only of a job its copy lists. A run posted again replaces the one before.
1044
+ */
1045
+ postJobRuns(call) {
1046
+ const harnessId = call.principal.participantId;
1047
+ if (call.principal.kind !== "harness" || call.params[0] !== harnessId) {
1048
+ throw new HttpFailure("forbidden", "Those jobs aren't yours.");
1049
+ }
1050
+ const parsed = jobRunsPost.safeParse(jsonOf(call.body));
1051
+ if (!parsed.success) {
1052
+ throw new HttpFailure("invalid_event", "That is not a post of a job's runs.", parsed.error.issues.map((issue) => ({
1053
+ path: issue.path.join(".") || "(root)",
1054
+ message: issue.message,
1055
+ })));
1056
+ }
1057
+ const copy = this.settings.copy("jobs", harnessId);
1058
+ const known = new Set((copy?.["jobs"] ?? []).map((job) => job.job_id));
1059
+ const stray = parsed.data.runs.find((run) => !known.has(run.job_id));
1060
+ if (stray !== undefined) {
1061
+ throw new HttpFailure("invalid_event", `No job ${stray.job_id} in your copy: send the copy before its runs.`);
1062
+ }
1063
+ for (const run of parsed.data.runs) {
1064
+ const at = this.jobRuns.findIndex((each) => each.job_id === run.job_id && each.run_id === run.run_id);
1065
+ if (at === -1)
1066
+ this.jobRuns.push(run);
1067
+ else
1068
+ this.jobRuns[at] = run;
1069
+ }
1070
+ return { status: 200, body: { kept: parsed.data.runs.length } };
1071
+ }
1072
+ /** What a harness reads over HTTP, as it is shown things on its socket (ADR 0087's addendum). */
1073
+ shown(call, value) {
1074
+ if (call.principal.kind !== "harness" || this.workspace.speaksAgents())
1075
+ return value;
1076
+ return inLegacyView(value, {
1077
+ harnessId: call.principal.participantId,
1078
+ own: new Set(this.workspace.agents.keys()),
1079
+ });
1080
+ }
1081
+ /** A harness's agents (ADR 0087): registered by its own key, idempotently, renamed as named. */
1082
+ registerAgent(call) {
1083
+ if (call.principal.kind !== "harness" || call.params[0] !== call.principal.participantId) {
1084
+ throw new HttpFailure("forbidden", "A harness registers its own agents.");
1085
+ }
1086
+ const { key, name, lead, state, forkedFrom, madeFor, spaceId } = agentBody(jsonOf(call.body), call.principal.participantId);
1087
+ if (forkedFrom !== undefined && !this.workspace.agents.has(forkedFrom)) {
1088
+ throw new HttpFailure("invalid_event", "A copy is of one of this harness's own agents.");
1089
+ }
1090
+ this.mayLead(key, lead, state);
1091
+ const found = this.registered(key, name, { lead, byHarness: true, state, forkedFrom });
1092
+ const fork = this.placed(found.agent.agent_id, { forkedFrom, madeFor, spaceId });
1093
+ return {
1094
+ status: found.created ? 201 : 200,
1095
+ body: {
1096
+ ...this.agentJson(found.agent),
1097
+ created: found.created,
1098
+ renamed: found.renamed,
1099
+ ...(fork === undefined ? {} : { attached: fork.attached }),
1100
+ ...(fork === undefined || fork.attached || fork.why === undefined ? {} : { why: fork.why }),
1101
+ },
1102
+ };
1103
+ }
1104
+ /**
1105
+ * An agent asked for in a room goes there while the room still waits for it: a fork's copy, or
1106
+ * a new agent (ADR 0108). Nothing for an agent nobody asked for.
1107
+ */
1108
+ placed(agentId, asked) {
1109
+ const { forkedFrom, madeFor, spaceId } = asked;
1110
+ if (spaceId === undefined)
1111
+ return undefined;
1112
+ if (madeFor !== undefined)
1113
+ return this.workspace.answerAsk(agentId, madeFor, spaceId);
1114
+ if (forkedFrom === undefined)
1115
+ return undefined;
1116
+ return this.workspace.answerFork(agentId, forkedFrom, spaceId);
1117
+ }
1118
+ /** The workspace's registration, a deleted agent's key refused as the real store refuses it. */
1119
+ registered(key, name, options) {
1120
+ try {
1121
+ return this.workspace.registerAgent(key, name, options);
1122
+ }
1123
+ catch (error) {
1124
+ if (error instanceof AgentDeleted)
1125
+ throw new HttpFailure("forbidden", "That agent was deleted.");
1126
+ throw error;
1127
+ }
1128
+ }
1129
+ /**
1130
+ * The Main agent's own rules, as the real door has them: a harness has one at most, and it is
1131
+ * always there, as its Main room is (agents review, finding 8). Its default agent may take Main
1132
+ * from another (ADR 0183).
1133
+ */
1134
+ mayLead(key, lead, state) {
1135
+ const agents = [...this.workspace.agents.values()].filter((each) => each.deleted !== true);
1136
+ const known = agents.find((each) => each.key === key);
1137
+ if ((lead || known?.lead === true) && state === "archived") {
1138
+ throw new HttpFailure("invalid_event", "The Main agent is always there: it is not archived.");
1139
+ }
1140
+ const other = agents.find((each) => each.lead && each.key !== key);
1141
+ if (lead && other !== undefined && key !== this.workspace.harness.participantId) {
1142
+ throw new HttpFailure("invalid_event", `This harness has a Main agent already: ${other.name}. A harness has one at most.`);
1143
+ }
1144
+ }
1145
+ /** One of its agents in a room, in the place of another of its own there or of itself. */
1146
+ attachAgent(call) {
1147
+ if (call.principal.kind !== "harness") {
1148
+ throw new HttpFailure("forbidden", "A harness attaches its own agents.");
1149
+ }
1150
+ const body = jsonOf(call.body);
1151
+ const spaceId = body["space_id"];
1152
+ const replacing = body["replacing"];
1153
+ if (typeof spaceId !== "string" || typeof replacing !== "string") {
1154
+ throw new HttpFailure("invalid_event", "Send the space's `space_id`, and `replacing`.");
1155
+ }
1156
+ const agentId = String(call.params[0]);
1157
+ const done = this.workspace.attachAgent(agentId, spaceId, replacing);
1158
+ if (!done.ok) {
1159
+ if (done.kind === "unknown_agent")
1160
+ throw new HttpFailure("not_found", "No agent of yours.");
1161
+ if (done.kind === "unknown_space")
1162
+ throw new HttpFailure("not_found", "No space of that id.");
1163
+ throw new HttpFailure("invalid_event", "Name what the agent takes the place of there.");
1164
+ }
1165
+ return {
1166
+ status: 200,
1167
+ body: { agent_id: agentId, space_id: spaceId, replacing, attached: done.attached },
1168
+ };
1169
+ }
1170
+ listAgents(call) {
1171
+ if (call.principal.kind !== "harness" || call.params[0] !== call.principal.participantId) {
1172
+ throw new HttpFailure("forbidden", "A harness lists its own agents.");
1173
+ }
1174
+ const agents = [...this.workspace.agents.values()]
1175
+ .filter((agent) => !agent.deleted)
1176
+ .map((agent) => this.agentJson(agent));
1177
+ return { status: 200, body: { agents } };
1178
+ }
1179
+ agentJson(agent) {
1180
+ return {
1181
+ agent_id: agent.agent_id,
1182
+ harness_id: this.workspace.harness.participantId,
1183
+ key: agent.key,
1184
+ name: agent.name,
1185
+ lead: agent.lead,
1186
+ state: agent.state,
1187
+ forked_from: agent.forked_from ?? null,
1188
+ spaces: this.workspace.memberSpacesOf(agent.agent_id),
1189
+ // Where it is, the room an archive took it from, and what it holds (ADR 0087, Phase 4).
1190
+ ...this.workspace.whereabouts(agent),
1191
+ };
1192
+ }
1193
+ /** A page of a room's lines for a harness in it (ADR 0088), as the real route answers it. */
1194
+ roomContext(call) {
1195
+ const spaceId = String(call.params[0]);
1196
+ // A room it is in, itself or as one of its agents (ADR 0087).
1197
+ if (!this.workspace.memberSpacesOf(call.principal.participantId).includes(spaceId)) {
1198
+ throw new HttpFailure("not_found", "You are in no space with that id.");
1199
+ }
1200
+ const number = (name) => {
1201
+ const raw = call.url.searchParams.get(name);
1202
+ if (raw === null)
1203
+ return undefined;
1204
+ const parsed = Number(raw);
1205
+ if (!Number.isSafeInteger(parsed) || parsed < 1) {
1206
+ throw new HttpFailure("invalid_frame", `\`${name}\` is a whole number, 1 or more.`);
1207
+ }
1208
+ return parsed;
1209
+ };
1210
+ const before = number("before") ?? this.workspace.head() + 1;
1211
+ const limit = number("limit") ?? CONTEXT_BUDGET.lines;
1212
+ return {
1213
+ status: 200,
1214
+ body: this.shown(call, this.workspace.contextPage(spaceId, before, limit, this.signed)),
1215
+ };
1216
+ }
1217
+ /**
1218
+ * A room's files at their current version (ADR 0099), or one name's versions (ADR 0100), as the
1219
+ * real route answers a harness.
1220
+ */
1221
+ roomFiles(call) {
1222
+ const spaceId = String(call.params[0]);
1223
+ if (!this.workspace.memberSpacesOf(call.principal.participantId).includes(spaceId)) {
1224
+ throw new HttpFailure("not_found", "You are in no space with that id.");
1225
+ }
1226
+ const name = call.url.searchParams.get("name");
1227
+ const files = name === null
1228
+ ? this.workspace.roomFiles(spaceId, ROOM_FILES_CEILING, undefined, this.signed)
1229
+ : this.workspace.fileVersions(spaceId, name, ROOM_FILES_CEILING, this.signed);
1230
+ return { status: 200, body: this.shown(call, { files }) };
1231
+ }
1232
+ participants(call) {
1233
+ const me = call.principal.participantId;
1234
+ const legacy = call.principal.kind === "harness" && !this.workspace.speaksAgents();
1235
+ const agents = this.workspace.agents;
1236
+ const listed = this.workspace.participantsVisibleTo(me);
1237
+ // A harness that has not learnt agents knows its own as itself: one entry, its own, where
1238
+ // the first of them was.
1239
+ const self = {
1240
+ participantId: me,
1241
+ kind: "harness",
1242
+ displayName: call.principal.displayName,
1243
+ };
1244
+ const mine = (each) => agents.has(each.participantId) || each.participantId === me;
1245
+ const at = listed.findIndex(mine);
1246
+ const others = listed.filter((each) => !mine(each));
1247
+ const named = legacy
1248
+ ? [
1249
+ ...others.slice(0, at < 0 ? others.length : at),
1250
+ self,
1251
+ ...others.slice(at < 0 ? others.length : at),
1252
+ ]
1253
+ : listed;
1254
+ const participants = named.map((each) => ({
1255
+ id: each.participantId,
1256
+ kind: each.kind,
1257
+ display_name: each.displayName,
1258
+ owner_id: each.kind === "person" ? null : this.workspace.owner.participantId,
1259
+ ...(each.kind === "agent" ? { harness_id: this.workspace.harness.participantId } : {}),
1260
+ // An agent's face, as a person chose it (ADR 0089); `null` while it wears its initials.
1261
+ ...("face" in each ? { face: each.face } : {}),
1262
+ }));
1263
+ return { status: 200, body: this.shown(call, { participants }) };
1264
+ }
1265
+ /**
1266
+ * The harness's one door: the space its key names, found or opened for its owner; or, with a
1267
+ * `space_id`, the room a person asked it for, claimed under the key (`space.claimed`).
1268
+ */
1269
+ openSpace(call) {
1270
+ if (call.params[0] !== call.principal.participantId) {
1271
+ throw new HttpFailure("forbidden", "A harness may only open its own spaces.");
1272
+ }
1273
+ const body = jsonOf(call.body);
1274
+ if (typeof body["key"] !== "string" || body["key"].trim() === "") {
1275
+ throw new HttpFailure("invalid_event", "Send a `key`: what this space is, in your own terms.");
1276
+ }
1277
+ const key = body["key"].trim();
1278
+ const trimmed = typeof body["title"] === "string" ? body["title"].trim() : undefined;
1279
+ const title = trimmed === "" ? undefined : trimmed;
1280
+ const shared = body["shared"] === true;
1281
+ if (typeof body["space_id"] === "string")
1282
+ return this.claim(body["space_id"], key, title, shared);
1283
+ const opened = this.workspace.openSpace(key, title, shared);
1284
+ return {
1285
+ status: opened.created ? 201 : 200,
1286
+ body: {
1287
+ space_id: opened.spaceId,
1288
+ created: opened.created,
1289
+ retitled: false,
1290
+ joined: opened.joined,
1291
+ claimed: false,
1292
+ },
1293
+ };
1294
+ }
1295
+ claim(spaceId, key, title, shared) {
1296
+ if (shared) {
1297
+ throw new HttpFailure("invalid_event", "A space asked for is claimed under a key of this harness's own, not a shared one.");
1298
+ }
1299
+ const claimed = this.workspace.claimSpace(spaceId, key, title);
1300
+ if (!claimed.ok)
1301
+ throw new HttpFailure(claimed.error.code, claimed.error.message);
1302
+ return {
1303
+ status: 200,
1304
+ body: {
1305
+ space_id: claimed.value.spaceId,
1306
+ created: false,
1307
+ retitled: claimed.value.retitled,
1308
+ joined: false,
1309
+ claimed: claimed.value.claimed,
1310
+ },
1311
+ };
1312
+ }
1313
+ /** The read side of the door: the rooms the harness opened by key, and the ones it was asked for. */
1314
+ listSpaces(call) {
1315
+ if (call.params[0] !== call.principal.participantId) {
1316
+ throw new HttpFailure("forbidden", "A harness may only list its own spaces.");
1317
+ }
1318
+ return {
1319
+ status: 200,
1320
+ body: { spaces: this.workspace.spacesOpened(), asked: this.workspace.spacesAsked() },
1321
+ };
1322
+ }
1323
+ harnessEvent(call) {
1324
+ if (call.params[0] !== call.principal.participantId) {
1325
+ throw new HttpFailure("forbidden", "A harness may only post its own events.");
1326
+ }
1327
+ return this.appended(call, undefined);
1328
+ }
1329
+ runEvent(call) {
1330
+ return this.appended(call, call.params[0]);
1331
+ }
1332
+ /** `POST` of one event: the same use case with the run in the path or not at all. */
1333
+ appended(call, runId) {
1334
+ const raw = jsonOf(call.body);
1335
+ const outcome = this.workspace.submit(call.principal, raw, {
1336
+ runId,
1337
+ idempotencyKey: headerOf(call, "idempotency-key"),
1338
+ });
1339
+ if (!outcome.ok) {
1340
+ throw new HttpFailure(outcome.error.code, outcome.error.message, outcome.error.issues);
1341
+ }
1342
+ const { event, duplicate, lane, held } = outcome;
1343
+ return {
1344
+ status: duplicate ? 200 : 201,
1345
+ body: {
1346
+ event_id: event.id,
1347
+ seq: event.seq,
1348
+ duplicate,
1349
+ // The id a hook waits on, so it need not compute one of its own. Never a resolution:
1350
+ // nothing here decides a card, so every wait is a person's answer.
1351
+ ...(event.type === "approval.request" ? { approval_id: event.payload["approval_id"] } : {}),
1352
+ // A task that opened a lane: where it is, so a lead driving it knows where to post.
1353
+ ...(lane === undefined ? {} : { lane }),
1354
+ // A message held from the agents it named (ADR 0097).
1355
+ ...(held === undefined ? {} : { held: true, exchange: held }),
1356
+ },
1357
+ };
1358
+ }
1359
+ waitForApproval(call) {
1360
+ const requested = Number.parseInt(call.url.searchParams.get("timeout") ?? "60", 10);
1361
+ if (!Number.isFinite(requested) || requested < 0) {
1362
+ throw new HttpFailure("invalid_frame", "`timeout` is a number of seconds.");
1363
+ }
1364
+ const approvalId = call.params[0] ?? "";
1365
+ const row = this.workspace.approvals.get(approvalId);
1366
+ if (row === undefined) {
1367
+ throw new HttpFailure("not_found", "No approval with that id in this workspace.");
1368
+ }
1369
+ if (row.state === "resolved" || requested === 0) {
1370
+ return Promise.resolve({ status: 200, body: approvalJson(row) });
1371
+ }
1372
+ return new Promise((resolve) => {
1373
+ const seconds = Math.min(requested, MAX_POLL_SECONDS);
1374
+ let timer;
1375
+ const off = this.workspace.onAppend((event) => {
1376
+ if (event.type === "approval.resolved" && event.payload["approval_id"] === approvalId)
1377
+ finish();
1378
+ });
1379
+ const finish = () => {
1380
+ clearTimeout(timer);
1381
+ off();
1382
+ resolve({ status: 200, body: approvalJson(row) });
1383
+ };
1384
+ timer = setTimeout(finish, seconds * 1000);
1385
+ });
1386
+ }
1387
+ approval(call) {
1388
+ const row = this.workspace.approvals.get(call.params[0] ?? "");
1389
+ if (row === undefined) {
1390
+ throw new HttpFailure("not_found", "No approval with that id in this workspace.");
1391
+ }
1392
+ return { status: 200, body: approvalJson(row) };
1393
+ }
1394
+ newUpload(call) {
1395
+ const body = jsonOf(call.body);
1396
+ const filename = typeof body["filename"] === "string" ? body["filename"].trim() : "";
1397
+ if (filename === "") {
1398
+ throw new HttpFailure("invalid_event", "`filename` is what the file is called.");
1399
+ }
1400
+ const row = this.workspace.newUpload(call.principal, filename);
1401
+ return {
1402
+ status: 201,
1403
+ body: {
1404
+ attachment_id: row.attachment_id,
1405
+ upload_url: `/v1/uploads/${row.attachment_id}`,
1406
+ method: "PUT",
1407
+ expires_at: new Date(Date.now() + SIGNED_URL_MS).toISOString(),
1408
+ },
1409
+ };
1410
+ }
1411
+ storeUpload(call) {
1412
+ if (call.body.byteLength === 0) {
1413
+ throw new HttpFailure("invalid_event", "Send the file's bytes as the body.");
1414
+ }
1415
+ const stored = this.workspace.storeUpload(call.principal, call.params[0] ?? "", call.body);
1416
+ if (!stored.ok)
1417
+ throw new HttpFailure(stored.error.code, stored.error.message);
1418
+ const { bytes: _bytes, ...described } = stored.row.stored;
1419
+ return {
1420
+ status: 201,
1421
+ body: {
1422
+ attachment: {
1423
+ attachment_id: stored.row.attachment_id,
1424
+ filename: stored.row.filename,
1425
+ ...described,
1426
+ },
1427
+ },
1428
+ };
1429
+ }
1430
+ /**
1431
+ * The bytes back, whole, or the run a `Range` asks for, as the real route answers it (ADR 0058):
1432
+ * `206` with `Content-Range`, `416` for a run past the end.
1433
+ */
1434
+ attachment(call) {
1435
+ const row = this.workspace.uploads.get(call.params[0] ?? "");
1436
+ if (row?.stored === undefined) {
1437
+ throw new HttpFailure("not_found", "No attachment with that id in this workspace.");
1438
+ }
1439
+ const { bytes } = row.stored;
1440
+ const headers = {
1441
+ "content-type": row.stored.mime_type,
1442
+ "content-disposition": `attachment; filename="${row.filename}"`,
1443
+ "x-content-type-options": "nosniff",
1444
+ "accept-ranges": "bytes",
1445
+ };
1446
+ const header = call.headers.range;
1447
+ const range = byteRange(Array.isArray(header) ? header[0] : header, bytes.byteLength);
1448
+ const said = contentRange(range, bytes.byteLength);
1449
+ if (range.kind === "unsatisfiable") {
1450
+ return {
1451
+ ...refusalAnswer(new HttpFailure("invalid_event", "That range starts past the end of the file.")),
1452
+ status: 416,
1453
+ headers: { "accept-ranges": "bytes", "content-range": said ?? "" },
1454
+ };
1455
+ }
1456
+ if (range.kind === "part") {
1457
+ return {
1458
+ status: 206,
1459
+ bytes: bytes.subarray(range.start, range.end + 1),
1460
+ headers: { ...headers, "content-range": said ?? "" },
1461
+ };
1462
+ }
1463
+ return { status: 200, bytes, headers };
1464
+ }
1465
+ events(call) {
1466
+ const since = Number.parseInt(call.url.searchParams.get("since") ?? "0", 10);
1467
+ if (!Number.isFinite(since) || since < 0) {
1468
+ throw new HttpFailure("invalid_frame", "`since` is a sequence number, and starts at zero.");
1469
+ }
1470
+ const requested = Number.parseInt(call.url.searchParams.get("limit") ?? "", 10);
1471
+ const limit = Number.isFinite(requested) ? Math.min(Math.max(requested, 1), 1000) : 500;
1472
+ // One space may be asked for on its own, as the real route allows: a room the harness was
1473
+ // added to, read from its beginning. Not one it is not in.
1474
+ const mine = this.workspace.memberSpacesOf(call.principal.participantId);
1475
+ const wanted = call.url.searchParams.get("space_id");
1476
+ const spaces = wanted === null ? mine : mine.filter((id) => id === wanted);
1477
+ if (wanted !== null && spaces.length === 0) {
1478
+ return { status: 200, body: { events: [], next_cursor: null } };
1479
+ }
1480
+ const harness = call.principal.kind === "harness";
1481
+ const viewer = {
1482
+ participantId: call.principal.participantId,
1483
+ memberSpaces: new Set(spaces),
1484
+ alsoIs: harness ? new Set(this.workspace.agents.keys()) : undefined,
1485
+ seesDirectory: !harness,
1486
+ seesAgents: !harness,
1487
+ };
1488
+ const page = this.workspace.since(since, limit, viewer);
1489
+ const legacy = harness && !this.workspace.speaksAgents();
1490
+ const events = page.events
1491
+ .filter((event) => !legacy || !AGENT_EVENTS.has(event.type))
1492
+ .map((event) => this.shown(call, event.payload));
1493
+ return { status: 200, body: { events, next_cursor: page.nextCursor } };
1494
+ }
1495
+ // ---- the person's side, for the kit ----
1496
+ asOwner(event) {
1497
+ const outcome = this.workspace.submit(this.workspace.owner, event, {
1498
+ idempotencyKey: `owner-${this.ids.next("event")}`,
1499
+ });
1500
+ if (!outcome.ok)
1501
+ throw new Error(`Chips refused the person's ${String(event["type"])}: ${outcome.error.message}`);
1502
+ return outcome.event;
1503
+ }
1504
+ /** A group room of the person's, with nobody else in it yet. */
1505
+ openRoom(title) {
1506
+ const spaceId = this.ids.next("space");
1507
+ this.asOwner({ type: "space.created", space_id: spaceId, kind: "group", title });
1508
+ return spaceId;
1509
+ }
1510
+ /**
1511
+ * The person asks for a copy of one of the harness's agents in a room (ADR 0087, Fork), as
1512
+ * `POST /v1/agents/:id/fork` does: the harness hears `agent.fork`, and its copy, registered with
1513
+ * `forked_from` and the room, joins it.
1514
+ */
1515
+ askForFork(agentId, spaceId) {
1516
+ const done = this.workspace.forkAgent(agentId, spaceId);
1517
+ if (!done.ok)
1518
+ throw new Error(`Chips refused the fork: ${done.error.message}`);
1519
+ return { forkId: done.forkId, asked: done.asked };
1520
+ }
1521
+ /**
1522
+ * The person asks the harness for a new agent in a room (ADR 0108), as
1523
+ * `POST /v1/spaces/:id/agents` does: the harness hears `agent.asked`, and its new agent,
1524
+ * registered with `made_for` and the room, joins it.
1525
+ */
1526
+ askForAgent(spaceId, name) {
1527
+ const done = this.workspace.askAgent(spaceId, name);
1528
+ if (!done.ok)
1529
+ throw new Error(`Chips refused the ask: ${done.error.message}`);
1530
+ return { askId: done.askId, name: done.name };
1531
+ }
1532
+ askForSpace(text) {
1533
+ const spaceId = this.ids.next("space");
1534
+ const harness = this.workspace.harness;
1535
+ this.asOwner({
1536
+ type: "space.created",
1537
+ space_id: spaceId,
1538
+ kind: "direct",
1539
+ asked_of: harness.participantId,
1540
+ });
1541
+ // With a first name for its agent there, as a person's device proposes one (ADR 0150).
1542
+ this.asOwner({
1543
+ type: "membership.added",
1544
+ space_id: spaceId,
1545
+ participant_id: harness.participantId,
1546
+ participant_kind: "harness",
1547
+ role: "member",
1548
+ agent_name: "Milo",
1549
+ });
1550
+ return { spaceId, message: this.asOwner({ type: "message.post", space_id: spaceId, text }) };
1551
+ }
1552
+ waitFor(match, timeoutMs) {
1553
+ for (const event of this.workspace.log) {
1554
+ const found = match(event);
1555
+ if (found !== undefined)
1556
+ return Promise.resolve(found);
1557
+ }
1558
+ return new Promise((resolve, reject) => {
1559
+ let timer;
1560
+ const off = this.workspace.onAppend((event) => {
1561
+ const found = match(event);
1562
+ if (found === undefined)
1563
+ return;
1564
+ clearTimeout(timer);
1565
+ off();
1566
+ resolve(found);
1567
+ });
1568
+ timer = setTimeout(() => {
1569
+ off();
1570
+ reject(new Error(`nothing matched within ${timeoutMs} ms`));
1571
+ }, timeoutMs);
1572
+ });
1573
+ }
1574
+ }
1575
+ function headerOf(call, name) {
1576
+ const value = call.headers[name];
1577
+ return Array.isArray(value) ? value[0] : value;
1578
+ }
1579
+ export async function startFakeChips(options = {}) {
1580
+ const door = new Door(options);
1581
+ const address = await door.listen(options.port ?? 0);
1582
+ const { workspace } = door;
1583
+ return {
1584
+ address,
1585
+ workspace,
1586
+ say: async (text, spaceId, options) => door.asOwner({
1587
+ type: "message.post",
1588
+ space_id: spaceId ?? address.spaceId,
1589
+ text,
1590
+ ...(options?.replyTo === undefined ? {} : { reply_to: options.replyTo }),
1591
+ }),
1592
+ decide: async (approvalId, decision, extra = {}) => {
1593
+ const row = workspace.approvals.get(approvalId);
1594
+ return door.asOwner({
1595
+ type: "approval.resolved",
1596
+ ...(row?.space_id == null ? {} : { space_id: row.space_id }),
1597
+ ...(row === undefined ? {} : { run_id: row.run_id }),
1598
+ approval_id: approvalId,
1599
+ decision,
1600
+ scope: extra.scope ?? "once",
1601
+ ...(extra.reason === undefined ? {} : { reason: extra.reason }),
1602
+ ...(extra.updatedInput === undefined ? {} : { updated_input: extra.updatedInput }),
1603
+ });
1604
+ },
1605
+ answer: async (askId, choices) => {
1606
+ const row = workspace.questions.get(askId);
1607
+ const answers = (row?.questions ?? []).map((question) => ({
1608
+ question_id: question.question_id,
1609
+ values: choices?.[question.question_id] ?? [
1610
+ question.default_value ?? question.options[0]?.value ?? "",
1611
+ ],
1612
+ }));
1613
+ return door.asOwner({
1614
+ type: "question.answered",
1615
+ ...(row?.space_id == null ? {} : { space_id: row.space_id }),
1616
+ ...(row === undefined ? {} : { run_id: row.run_id }),
1617
+ ask_id: askId,
1618
+ answers: answers.length === 0 ? [{ question_id: "q1", values: [] }] : answers,
1619
+ });
1620
+ },
1621
+ askForSpace: async (text) => door.askForSpace(text),
1622
+ openRoom: async (title) => door.openRoom(title),
1623
+ askForFork: async (agentId, spaceId) => door.askForFork(agentId, spaceId),
1624
+ askForAgent: async (spaceId, name) => door.askForAgent(spaceId, name),
1625
+ asOwner: async (event) => door.asOwner(event),
1626
+ makeJoinLink: (capabilities) => door.joins.makeLink(capabilities),
1627
+ allowJoin: (capabilities) => door.joins.allow(capabilities),
1628
+ denyJoin: () => door.joins.deny(),
1629
+ joinRequests: () => door.joins.list(),
1630
+ waitFor: (match, timeoutMs) => door.waitFor(match, timeoutMs),
1631
+ connections: () => door.connections(),
1632
+ hellos: () => door.hellos(),
1633
+ settings: door.settings,
1634
+ jobRuns: () => door.jobRuns,
1635
+ askToChange: (kind, id) => door.askToChange(kind, id),
1636
+ askToRestart: () => door.askToRestart(),
1637
+ statuses: () => door.statuses,
1638
+ heard: () => door.heard(),
1639
+ close: () => door.close(),
1640
+ };
1641
+ }
1642
+ //# sourceMappingURL=fake-chips.js.map