@chipschat/conformance 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +34 -2
- package/dist/checks.d.ts +29 -0
- package/dist/checks.d.ts.map +1 -0
- package/dist/checks.js +133 -0
- package/dist/checks.js.map +1 -0
- package/dist/exchange.d.ts +30 -0
- package/dist/exchange.d.ts.map +1 -0
- package/dist/exchange.js +102 -0
- package/dist/exchange.js.map +1 -0
- package/dist/fake-chips.d.ts +130 -0
- package/dist/fake-chips.d.ts.map +1 -0
- package/dist/fake-chips.js +1642 -0
- package/dist/fake-chips.js.map +1 -0
- package/dist/fake-join.d.ts +56 -0
- package/dist/fake-join.d.ts.map +1 -0
- package/dist/fake-join.js +181 -0
- package/dist/fake-join.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/project.d.ts +39 -0
- package/dist/project.d.ts.map +1 -0
- package/dist/project.js +254 -0
- package/dist/project.js.map +1 -0
- package/dist/report.d.ts +65 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +142 -0
- package/dist/report.js.map +1 -0
- package/dist/room-files.d.ts +54 -0
- package/dist/room-files.d.ts.map +1 -0
- package/dist/room-files.js +144 -0
- package/dist/room-files.js.map +1 -0
- package/dist/scenario.d.ts +41 -0
- package/dist/scenario.d.ts.map +1 -0
- package/dist/scenario.js +414 -0
- package/dist/scenario.js.map +1 -0
- package/dist/settings.d.ts +60 -0
- package/dist/settings.d.ts.map +1 -0
- package/dist/settings.js +142 -0
- package/dist/settings.js.map +1 -0
- package/dist/spend.d.ts +20 -0
- package/dist/spend.d.ts.map +1 -0
- package/dist/spend.js +50 -0
- package/dist/spend.js.map +1 -0
- package/dist/subject.d.ts +55 -0
- package/dist/subject.d.ts.map +1 -0
- package/dist/subject.js +22 -0
- package/dist/subject.js.map +1 -0
- package/dist/waiting.d.ts +12 -0
- package/dist/waiting.d.ts.map +1 -0
- package/dist/waiting.js +30 -0
- package/dist/waiting.js.map +1 -0
- package/dist/workspace.d.ts +893 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +3160 -0
- package/dist/workspace.js.map +1 -0
- package/package.json +38 -3
|
@@ -0,0 +1,3160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The workspace behind the fake: an event log with the door rules the real server applies, and
|
|
3
|
+
* the projections the scenario reads. Nothing here listens on a port — `fake-chips.ts` is the
|
|
4
|
+
* door — so every rule can be driven by a plain call.
|
|
5
|
+
*
|
|
6
|
+
* What is mirrored, and what is not. Mirrored: the envelope Chips fills in (the clock, the
|
|
7
|
+
* actor, who decided), the schema check with the protocol's own `parseEvent`, the direction an
|
|
8
|
+
* event travels, the idempotency key and what a repeat of one means, a run's life, membership,
|
|
9
|
+
* first answer wins, and — since Phase 5 — who may change a space or who is in it, that an added
|
|
10
|
+
* participant exists, and that a decision comes from who the ask named (rules 7 to 9, the Phase
|
|
11
|
+
* 3 review's finding 11); and a room a person asks the harness for, which only a harness that
|
|
12
|
+
* declares `new_spaces` may be asked (rule 15), and which it claims under a key of its own
|
|
13
|
+
* (`space.claimed`, the new-space plan's Phase 2); and agents (ADR 0087): the harness's agents, which
|
|
14
|
+
* rooms hold in its place, the actor rule that reads the harness as its one agent in a room, and
|
|
15
|
+
* which of them a message is for. Not mirrored, on purpose: the policy engine — nothing here decides an
|
|
16
|
+
* approval on the spot, every card waits for the person the scenario plays, which is what "ask
|
|
17
|
+
* first" means and what the handshake reports — and pricing, so a `usage` sent without a cost
|
|
18
|
+
* is delivered without one. An adapter that passes here has spoken the protocol; what the real
|
|
19
|
+
* server's dial would have allowed unasked is that server's business.
|
|
20
|
+
*/
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
22
|
+
import { actionMatters, CONTEXT_CEILING, canSee, clashKey, contextLines, DEFAULT_EXCHANGE_LIMITS, eventDefinition, eventIdOfMessage, FORK_WAIT_MS, freeAgentName, HARNESS_CAPABILITIES, isAgentKind, isFormOf, isId, linesOf, mayHoldName, mentionsEveryone, messageIdOf, NEW_AGENT_WAIT_MS, NOT_AN_AGENT_WORDS, nameProblem, notAnAgentName, parseEvent, parseSpaceIcon, passIfNothingSaid, pickAgentName, quoteOf, quotesBefore, REPLY_BEFORE_LINES, REPLY_QUOTE_CHARS, ROOM_FILES_IN_CONTEXT, refuseAgentInto, resetsTheCount, resolveAddress, resolveMentions, STOPPED_BECAUSE, spendStage, } from "@chipschat/protocol";
|
|
23
|
+
import { exchangeInLog, heldInLog } from "./exchange.js";
|
|
24
|
+
import { fileVersionsInLog, madeFromInLog, roomFilesInLog } from "./room-files.js";
|
|
25
|
+
import { spendInLog } from "./spend.js";
|
|
26
|
+
/**
|
|
27
|
+
* What a sender is told of its message held from the agents it named (ADR 0097), if it was: the
|
|
28
|
+
* count, and the workspace's hold (ADR 0187) where it has one.
|
|
29
|
+
*/
|
|
30
|
+
function heldWith(event, talked) {
|
|
31
|
+
if (event.payload["held"] !== true || talked === undefined)
|
|
32
|
+
return {};
|
|
33
|
+
const hold = (talked.limits ?? DEFAULT_EXCHANGE_LIMITS).hold;
|
|
34
|
+
return { held: { count: talked.count + 1, ...(hold === null ? {} : { allowance: hold }) } };
|
|
35
|
+
}
|
|
36
|
+
/** A deleted agent's key, asked for again (ADR 0095, point 5): deleted stays deleted. */
|
|
37
|
+
export class AgentDeleted extends Error {
|
|
38
|
+
name = "AgentDeleted";
|
|
39
|
+
constructor() {
|
|
40
|
+
super("that agent was deleted");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const DECISIONS = new Set(["approval.resolved", "question.answered"]);
|
|
44
|
+
/** The fields of a post that mean a room, which words held in no room do not keep (rule 6). */
|
|
45
|
+
const ROOM_FIELDS = [
|
|
46
|
+
"space_id",
|
|
47
|
+
"run_id",
|
|
48
|
+
"reply_to",
|
|
49
|
+
"session_ref",
|
|
50
|
+
"mentions",
|
|
51
|
+
"to",
|
|
52
|
+
"addressed_to",
|
|
53
|
+
"addressed_to_all",
|
|
54
|
+
"held",
|
|
55
|
+
"upload_ids",
|
|
56
|
+
"replaces",
|
|
57
|
+
];
|
|
58
|
+
/** The events that change a space itself or who is in it, which rule 7 has a say on. */
|
|
59
|
+
const SPACE_CHANGES = new Set([
|
|
60
|
+
"space.updated",
|
|
61
|
+
"membership.added",
|
|
62
|
+
"membership.removed",
|
|
63
|
+
]);
|
|
64
|
+
/** Fields Chips stamps from the credential and its clock; a sender's values are replaced. */
|
|
65
|
+
const ATTRIBUTION = {
|
|
66
|
+
"approval.resolved": { who: ["resolved_by"], when: ["resolved_at"] },
|
|
67
|
+
"question.answered": { who: ["answered_by"], when: ["answered_at"] },
|
|
68
|
+
"run.pause": { who: ["by"], when: [] },
|
|
69
|
+
"run.resume": { who: ["by"], when: [] },
|
|
70
|
+
"run.cancel": { who: ["by"], when: [] },
|
|
71
|
+
"policy.updated": { who: ["changed_by"], when: [] },
|
|
72
|
+
};
|
|
73
|
+
const TERMINAL = new Set(["done", "failed", "cancelled"]);
|
|
74
|
+
/** A run's life, as the real server's state machine has it, keyed by the event that moves it. */
|
|
75
|
+
const TRANSITIONS = {
|
|
76
|
+
"run.started": { queued: "running", running: "running" },
|
|
77
|
+
"approval.request": { running: "waiting", waiting: "waiting" },
|
|
78
|
+
"question.ask": { running: "waiting", waiting: "waiting" },
|
|
79
|
+
"approval.resolved": { waiting: "running" },
|
|
80
|
+
"question.answered": { waiting: "running" },
|
|
81
|
+
"run.pause": { running: "waiting" },
|
|
82
|
+
"run.resume": { waiting: "running" },
|
|
83
|
+
"run.completed": { running: "done", waiting: "done" },
|
|
84
|
+
"run.failed": { queued: "failed", running: "failed", waiting: "failed" },
|
|
85
|
+
"run.cancel": { queued: "cancelled", running: "cancelled", waiting: "cancelled" },
|
|
86
|
+
};
|
|
87
|
+
/** What the bytes are, from the name alone: the real server sniffs, and this is not a sniffer. */
|
|
88
|
+
const MIME_BY_EXTENSION = {
|
|
89
|
+
png: "image/png",
|
|
90
|
+
jpg: "image/jpeg",
|
|
91
|
+
jpeg: "image/jpeg",
|
|
92
|
+
gif: "image/gif",
|
|
93
|
+
webp: "image/webp",
|
|
94
|
+
pdf: "application/pdf",
|
|
95
|
+
txt: "text/plain",
|
|
96
|
+
md: "text/markdown",
|
|
97
|
+
json: "application/json",
|
|
98
|
+
csv: "text/csv",
|
|
99
|
+
};
|
|
100
|
+
const DEFAULT_WORKSPACE_MAX = 2;
|
|
101
|
+
function refusal(code, kind, message) {
|
|
102
|
+
return { code, kind, message };
|
|
103
|
+
}
|
|
104
|
+
function noun(kind) {
|
|
105
|
+
return kind === "harness" ? "a harness" : "a person";
|
|
106
|
+
}
|
|
107
|
+
function isRecord(value) {
|
|
108
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* What a run that has ended still takes: an approval's decision and its outcome (M3.5), a
|
|
112
|
+
* question's answer, which is the same card asked the other way, and what the run cost (ADR 0155).
|
|
113
|
+
*/
|
|
114
|
+
const AFTER_THE_END = new Set([
|
|
115
|
+
"approval.resolved",
|
|
116
|
+
"approval.executed",
|
|
117
|
+
"question.answered",
|
|
118
|
+
"usage",
|
|
119
|
+
]);
|
|
120
|
+
/** A short, stable digest of a key or a change, for an idempotency key. */
|
|
121
|
+
function digest(value) {
|
|
122
|
+
return createHash("sha256").update(value).digest("hex").slice(0, 32);
|
|
123
|
+
}
|
|
124
|
+
/** A face as `agent.updated` carries it: the mark and ground, or nothing for `null`. */
|
|
125
|
+
function faceOf(raw) {
|
|
126
|
+
const parsed = parseSpaceIcon(raw);
|
|
127
|
+
return parsed.ok ? parsed.value : undefined;
|
|
128
|
+
}
|
|
129
|
+
/** Whether a run in this state takes this event at the door. */
|
|
130
|
+
function runAccepts(state, type) {
|
|
131
|
+
if (TERMINAL.has(state) && !AFTER_THE_END.has(type))
|
|
132
|
+
return `the run has ended as ${state}`;
|
|
133
|
+
if (type === "run.pause" && TRANSITIONS[type]?.[state] === undefined) {
|
|
134
|
+
return `a run that is ${state} cannot be paused`;
|
|
135
|
+
}
|
|
136
|
+
if (type === "run.resume" && TRANSITIONS[type]?.[state] === undefined) {
|
|
137
|
+
return `a run that is ${state} is not paused`;
|
|
138
|
+
}
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The refusal for an event that is Chips's own to write, or nothing when a client may send it.
|
|
143
|
+
* Who sends each event is the catalogue's to say (`sender`), read here as the server reads it, so
|
|
144
|
+
* the two cannot differ on it.
|
|
145
|
+
*/
|
|
146
|
+
function chipsOwns(type) {
|
|
147
|
+
// An event nothing declares is left for the schema to refuse, as invalid.
|
|
148
|
+
if (eventDefinition(type)?.sender !== "chips")
|
|
149
|
+
return undefined;
|
|
150
|
+
return refusal("forbidden", "not_submittable", `${type} is written by Chips itself, and is not accepted from a client`);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Rule 2: the kind of event that kind of sender sends, as the catalogue says; and a heartbeat's
|
|
154
|
+
* check-in is a harness's, or its agent's, alone (ADR 0143).
|
|
155
|
+
*/
|
|
156
|
+
function refuseDirection(event, sender) {
|
|
157
|
+
const type = String(event["type"]);
|
|
158
|
+
const sends = eventDefinition(type)?.sender ?? "chips";
|
|
159
|
+
if (sends !== "either" && sender.kind !== sends) {
|
|
160
|
+
return refusal("forbidden", "wrong_direction", sends === "chips"
|
|
161
|
+
? `${type} is written by Chips itself, and is not accepted from a client`
|
|
162
|
+
: `${type} is something ${noun(sends)} sends, and this credential belongs to ${noun(sender.kind)}`);
|
|
163
|
+
}
|
|
164
|
+
if (type === "message.post" && event["heartbeat"] === true && sender.kind !== "harness") {
|
|
165
|
+
return refusal("forbidden", "wrong_direction", "a heartbeat's check-in is something a harness sends, and this credential belongs to a person");
|
|
166
|
+
}
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
/** Rules 1 to 3: the workspace, the actor, the direction, the key. */
|
|
170
|
+
function refuseWithoutLookup(event, sender) {
|
|
171
|
+
if (event["workspace_id"] !== sender.workspaceId) {
|
|
172
|
+
return refusal("forbidden", "wrong_workspace", "an event may only be sent to its sender's workspace");
|
|
173
|
+
}
|
|
174
|
+
const actor = event["actor"];
|
|
175
|
+
const own = actor.id === sender.participantId && actor.kind === sender.kind;
|
|
176
|
+
// One of the harness's agents, which the door has already said is its (ADR 0087).
|
|
177
|
+
const agent = actor.kind === "agent" && actor.harness_id === sender.participantId;
|
|
178
|
+
if (!own && !agent) {
|
|
179
|
+
return refusal("forbidden", "wrong_actor", "an event may only be sent on the sender's own behalf");
|
|
180
|
+
}
|
|
181
|
+
const wrongWay = refuseDirection(event, sender);
|
|
182
|
+
if (wrongWay !== undefined)
|
|
183
|
+
return wrongWay;
|
|
184
|
+
if (typeof event["idempotency_key"] !== "string") {
|
|
185
|
+
return refusal("invalid_event", "missing_idempotency_key", "every write carries an idempotency key, so that retrying it changes nothing");
|
|
186
|
+
}
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* What the sender must not choose: who decided and when, from the credential and the clock; a
|
|
191
|
+
* steer's `from`; a decision never "elsewhere" where it was made; the workspace cap on a dial.
|
|
192
|
+
*/
|
|
193
|
+
function stamp(draft, principal, now) {
|
|
194
|
+
const type = String(draft["type"]);
|
|
195
|
+
const attribution = ATTRIBUTION[type];
|
|
196
|
+
for (const field of attribution?.who ?? [])
|
|
197
|
+
draft[field] = principal.participantId;
|
|
198
|
+
for (const field of attribution?.when ?? [])
|
|
199
|
+
draft[field] = now;
|
|
200
|
+
if (type === "run.steer") {
|
|
201
|
+
draft["from"] = {
|
|
202
|
+
participant_id: principal.participantId,
|
|
203
|
+
kind: principal.kind,
|
|
204
|
+
display_name: principal.displayName,
|
|
205
|
+
role: "member",
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
if (type === "approval.resolved")
|
|
209
|
+
draft["resolved_elsewhere"] = false;
|
|
210
|
+
if (type === "policy.updated" && draft["workspace_max_autonomy"] === undefined) {
|
|
211
|
+
draft["workspace_max_autonomy"] = DEFAULT_WORKSPACE_MAX;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* What a sender does not get to write, at the door alone: the fields the catalogue says are
|
|
216
|
+
* Chips's (`chipsWrites`), and who took somebody out of a room, which is whoever sent the removal
|
|
217
|
+
* and nobody when they left themselves. Chips's own writes past the door keep theirs.
|
|
218
|
+
*/
|
|
219
|
+
function dropWhatChipsWrites(draft, principal) {
|
|
220
|
+
const type = String(draft["type"]);
|
|
221
|
+
for (const field of eventDefinition(type)?.chipsWrites ?? [])
|
|
222
|
+
delete draft[field];
|
|
223
|
+
if (type !== "membership.removed")
|
|
224
|
+
return;
|
|
225
|
+
if (draft["participant_id"] === principal.participantId)
|
|
226
|
+
delete draft["removed_by"];
|
|
227
|
+
else
|
|
228
|
+
draft["removed_by"] = principal.participantId;
|
|
229
|
+
}
|
|
230
|
+
export class Workspace {
|
|
231
|
+
id;
|
|
232
|
+
owner;
|
|
233
|
+
harness;
|
|
234
|
+
/** What the harness declares: `new_spaces` is what lets a person ask it for a room. */
|
|
235
|
+
capabilities;
|
|
236
|
+
log = [];
|
|
237
|
+
runs = new Map();
|
|
238
|
+
approvals = new Map();
|
|
239
|
+
questions = new Map();
|
|
240
|
+
spaces = new Map();
|
|
241
|
+
/**
|
|
242
|
+
* The first name each room asked of the harness holds for its agent there (ADR 0150), and the
|
|
243
|
+
* person whose naming it is, until the agent takes the harness's place.
|
|
244
|
+
*/
|
|
245
|
+
holds = new Map();
|
|
246
|
+
uploads = new Map();
|
|
247
|
+
/** The lanes opened by `task`, by lane id (M3.5). */
|
|
248
|
+
lanes = new Map();
|
|
249
|
+
/** The harness's agents, by id (ADR 0087). */
|
|
250
|
+
agents = new Map();
|
|
251
|
+
/** In each room, who took whose place: `membership.replaced`, from → to. */
|
|
252
|
+
replaced = new Map();
|
|
253
|
+
/** What free agents said, oldest first (ADR 0087, rule 6). */
|
|
254
|
+
held = [];
|
|
255
|
+
/** Every event the door turned away, for the report. */
|
|
256
|
+
refusals = [];
|
|
257
|
+
/** Every field the door dropped from an event it took, for the report. */
|
|
258
|
+
dropped = [];
|
|
259
|
+
seq = 0;
|
|
260
|
+
byKey = new Map();
|
|
261
|
+
listeners = new Set();
|
|
262
|
+
ids;
|
|
263
|
+
now;
|
|
264
|
+
constructor(options) {
|
|
265
|
+
this.id = options.workspaceId;
|
|
266
|
+
this.owner = options.owner;
|
|
267
|
+
this.harness = options.harness;
|
|
268
|
+
this.capabilities = options.capabilities ?? [];
|
|
269
|
+
this.ids = options.ids;
|
|
270
|
+
this.now = options.now;
|
|
271
|
+
}
|
|
272
|
+
/** Hear every append, in order. Returns the way to stop hearing. */
|
|
273
|
+
onAppend(listener) {
|
|
274
|
+
this.listeners.add(listener);
|
|
275
|
+
return () => this.listeners.delete(listener);
|
|
276
|
+
}
|
|
277
|
+
head() {
|
|
278
|
+
return this.seq;
|
|
279
|
+
}
|
|
280
|
+
nowIso() {
|
|
281
|
+
return this.now().toISOString();
|
|
282
|
+
}
|
|
283
|
+
/** The one way an event gets in: filled in, validated, checked against who sent it, appended. */
|
|
284
|
+
submit(principal, raw, extras = {}) {
|
|
285
|
+
const outcome = this.take(principal, raw, extras);
|
|
286
|
+
if (!outcome.ok) {
|
|
287
|
+
const type = isRecord(raw) && typeof raw["type"] === "string" ? raw["type"] : undefined;
|
|
288
|
+
this.refusals.push({ ...outcome.error, at: this.nowIso(), who: principal.kind, type });
|
|
289
|
+
}
|
|
290
|
+
return outcome;
|
|
291
|
+
}
|
|
292
|
+
take(principal, raw, extras) {
|
|
293
|
+
const draft = this.draftOf(principal, raw, extras);
|
|
294
|
+
dropWhatChipsWrites(draft, principal);
|
|
295
|
+
const validated = this.validate(draft, principal);
|
|
296
|
+
if (!validated.ok)
|
|
297
|
+
return validated;
|
|
298
|
+
const event = validated.value;
|
|
299
|
+
this.addedAsAgent(event, principal);
|
|
300
|
+
const early = this.speakerOf(event, principal) ?? refuseWithoutLookup(event, principal);
|
|
301
|
+
if (early !== undefined)
|
|
302
|
+
return { ok: false, error: early };
|
|
303
|
+
this.holdIfFree(event, principal);
|
|
304
|
+
const refused = this.refuseOnRun(event, principal) ??
|
|
305
|
+
this.refuseOnMembership(event) ??
|
|
306
|
+
this.refuseOnReply(event) ??
|
|
307
|
+
this.refuseOnSpaceChange(event, principal) ??
|
|
308
|
+
this.refuseOnParticipant(event) ??
|
|
309
|
+
this.refuseOnAgentMembership(event) ??
|
|
310
|
+
this.refuseOnLeadRemoval(event) ??
|
|
311
|
+
this.refuseOnAsking(event) ??
|
|
312
|
+
this.refuseSecondAnswer(event) ??
|
|
313
|
+
this.refuseNotAnApprover(event, principal);
|
|
314
|
+
if (refused !== undefined)
|
|
315
|
+
return { ok: false, error: refused };
|
|
316
|
+
const unsaid = this.refuseUnsaid(event);
|
|
317
|
+
if (unsaid !== undefined)
|
|
318
|
+
return { ok: false, error: unsaid };
|
|
319
|
+
if (event["type"] === "membership.removed" && typeof event["space_id"] === "string") {
|
|
320
|
+
this.stopWork(event["space_id"], {
|
|
321
|
+
reason: "removed",
|
|
322
|
+
by: principal.participantId,
|
|
323
|
+
agentId: String(event["participant_id"]),
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
const talked = this.fillAddress(event);
|
|
327
|
+
// An agent's `NO_REPLY` alone is its turn passed (ADR 0105), once every rule has had its say.
|
|
328
|
+
passIfNothingSaid(event);
|
|
329
|
+
// A key is the sender's own: the owner's `hello-1` and the harness's are two events.
|
|
330
|
+
// By who the event is by, as the log keeps it: an agent's, when the harness spoke as one.
|
|
331
|
+
const by = event["actor"].id;
|
|
332
|
+
const seen = this.byKey.get(keyOf(by, String(event["idempotency_key"])));
|
|
333
|
+
if (seen !== undefined)
|
|
334
|
+
return { ok: true, event: seen, duplicate: true, ...this.laneOf(seen) };
|
|
335
|
+
const appended = this.append(event);
|
|
336
|
+
if (appended.type === "task")
|
|
337
|
+
this.routeTask(appended);
|
|
338
|
+
return {
|
|
339
|
+
ok: true,
|
|
340
|
+
event: appended,
|
|
341
|
+
duplicate: false,
|
|
342
|
+
...this.laneOf(appended),
|
|
343
|
+
...heldWith(appended, talked),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* A message said in no room, which went to everybody (agents review, 3); and what each file of a
|
|
348
|
+
* post was made from (ADR 0099), refusing one made from a version no longer current.
|
|
349
|
+
*/
|
|
350
|
+
refuseUnsaid(event) {
|
|
351
|
+
if (event["type"] === "message.post" && typeof event["space_id"] !== "string") {
|
|
352
|
+
return refusal("invalid_event", "invalid", "a message is said in a space: name one, or the run it belongs to");
|
|
353
|
+
}
|
|
354
|
+
const made = madeFromInLog(this.log, event, (id) => this.whoIs().get(id)?.name ?? id);
|
|
355
|
+
if (made === undefined)
|
|
356
|
+
return undefined;
|
|
357
|
+
return refusal(made.kind === "invalid" ? "invalid_event" : "stale_file", made.kind, made.message);
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Who a harness's event is by (ADR 0087, point 2), as the real door reads it: an agent it names
|
|
361
|
+
* must be its own, and its own id is read as its one agent in the room — refused as ambiguous
|
|
362
|
+
* where it has several. A room it is in itself, and anything in no room, stay the harness's.
|
|
363
|
+
*/
|
|
364
|
+
speakerOf(event, principal) {
|
|
365
|
+
if (principal.kind !== "harness")
|
|
366
|
+
return undefined;
|
|
367
|
+
const actor = event["actor"];
|
|
368
|
+
if (actor.kind === "agent") {
|
|
369
|
+
const agent = this.agents.get(String(actor.id));
|
|
370
|
+
if (agent === undefined) {
|
|
371
|
+
return refusal("forbidden", "wrong_actor", "a harness speaks only as one of its own agents");
|
|
372
|
+
}
|
|
373
|
+
// Deleted stays deleted (ADR 0095): nothing more is said as it.
|
|
374
|
+
if (agent.deleted === true)
|
|
375
|
+
return refusal("forbidden", "wrong_actor", "that agent was deleted");
|
|
376
|
+
this.speakAs(event, principal, agent);
|
|
377
|
+
return undefined;
|
|
378
|
+
}
|
|
379
|
+
if (actor.kind !== "harness" || actor.id !== principal.participantId)
|
|
380
|
+
return undefined;
|
|
381
|
+
const room = this.roomOf(event);
|
|
382
|
+
if (room === undefined || this.spaces.get(room)?.members.has(principal.participantId)) {
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
const mine = this.agentsIn(room);
|
|
386
|
+
if (mine.length > 1) {
|
|
387
|
+
return refusal("invalid_event", "which_agent", `this harness has ${mine.length} agents in that space (${mine.map((each) => each.name).join(", ")}): name one as the actor`);
|
|
388
|
+
}
|
|
389
|
+
if (mine[0] !== undefined)
|
|
390
|
+
this.speakAs(event, principal, mine[0]);
|
|
391
|
+
return undefined;
|
|
392
|
+
}
|
|
393
|
+
/** The room an event is in: the one it names, or its run's. */
|
|
394
|
+
roomOf(event) {
|
|
395
|
+
if (typeof event["space_id"] === "string")
|
|
396
|
+
return event["space_id"];
|
|
397
|
+
return typeof event["run_id"] === "string"
|
|
398
|
+
? this.runs.get(event["run_id"])?.space_id
|
|
399
|
+
: undefined;
|
|
400
|
+
}
|
|
401
|
+
/** The event as its agent's, with the harness beside it, and who decided or spoke with it. */
|
|
402
|
+
speakAs(event, principal, agent) {
|
|
403
|
+
event["actor"] = { kind: "agent", id: agent.agent_id, harness_id: principal.participantId };
|
|
404
|
+
for (const field of ATTRIBUTION[String(event["type"])]?.who ?? [])
|
|
405
|
+
event[field] = agent.agent_id;
|
|
406
|
+
if (event["type"] === "run.steer") {
|
|
407
|
+
event["from"] = {
|
|
408
|
+
participant_id: agent.agent_id,
|
|
409
|
+
kind: "agent",
|
|
410
|
+
display_name: agent.name,
|
|
411
|
+
role: "member",
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* A person adding the harness to a room adds its default agent (ADR 0087's addendum, point 5),
|
|
417
|
+
* except to a room asked of it from the +, which holds the harness until it claims it.
|
|
418
|
+
*/
|
|
419
|
+
addedAsAgent(event, principal) {
|
|
420
|
+
if (principal.kind !== "person" || event["type"] !== "membership.added")
|
|
421
|
+
return;
|
|
422
|
+
const proposed = event["agent_name"];
|
|
423
|
+
delete event["agent_name"];
|
|
424
|
+
if (event["participant_id"] !== this.harness.participantId)
|
|
425
|
+
return;
|
|
426
|
+
const spaceId = String(event["space_id"]);
|
|
427
|
+
const room = this.spaces.get(spaceId);
|
|
428
|
+
// Asked, or asked beside another, it is held there until it claims the room or answers with
|
|
429
|
+
// an agent of its own, and a first name is held for that agent (ADR 0150).
|
|
430
|
+
const waits = (room?.asked_of === this.harness.participantId && room.external_key === undefined) ||
|
|
431
|
+
room?.also_asked_of.includes(this.harness.participantId) === true;
|
|
432
|
+
if (waits) {
|
|
433
|
+
const taken = this.takenNames();
|
|
434
|
+
const name = mayHoldName(proposed, taken)
|
|
435
|
+
? proposed.trim()
|
|
436
|
+
: pickAgentName(taken, [this.owner.displayName]);
|
|
437
|
+
event["agent_name"] = name;
|
|
438
|
+
this.holds.set(spaceId, { name, by: principal.participantId });
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
event["participant_id"] = this.agentForRoom(undefined, undefined).agent_id;
|
|
442
|
+
event["participant_kind"] = "agent";
|
|
443
|
+
}
|
|
444
|
+
/** Whether the harness speaks agents: it declared `agents` (ADR 0087's addendum). */
|
|
445
|
+
speaksAgents() {
|
|
446
|
+
return this.capabilities.includes(HARNESS_CAPABILITIES.agents);
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* The harness's agent for a key: found, renamed when the name has changed, archived or brought
|
|
450
|
+
* back as `state` says, or registered. A change is keyed by the log's head, as the server's is.
|
|
451
|
+
* One asked for as Main is the harness's Main from here, and no other is (ADR 0183).
|
|
452
|
+
*/
|
|
453
|
+
registerAgent(key, name, options = {}) {
|
|
454
|
+
const found = this.registerOrChange(key, name, options);
|
|
455
|
+
if (options.lead !== true)
|
|
456
|
+
return found;
|
|
457
|
+
const say = (agentId, lead) => {
|
|
458
|
+
const change = { lead };
|
|
459
|
+
const event = {
|
|
460
|
+
type: "agent.updated",
|
|
461
|
+
idempotency_key: `agent-${agentId}-${digest(JSON.stringify(change))}-at-${this.seq}`,
|
|
462
|
+
agent_id: agentId,
|
|
463
|
+
...change,
|
|
464
|
+
};
|
|
465
|
+
return options.byHarness === true
|
|
466
|
+
? this.appendAs(this.harness, event)
|
|
467
|
+
: this.appendSystem(event);
|
|
468
|
+
};
|
|
469
|
+
if (!found.agent.lead)
|
|
470
|
+
say(found.agent.agent_id, true);
|
|
471
|
+
for (const other of this.agents.values()) {
|
|
472
|
+
if (other.lead && other.agent_id !== found.agent.agent_id && other.deleted !== true) {
|
|
473
|
+
say(other.agent_id, false);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return found;
|
|
477
|
+
}
|
|
478
|
+
registerOrChange(key, name, options = {}) {
|
|
479
|
+
const { found, gone } = this.byAgentKey(key);
|
|
480
|
+
const say = (event) => options.byHarness === true ? this.appendAs(this.harness, event) : this.appendSystem(event);
|
|
481
|
+
const update = (agentId, change) => say({
|
|
482
|
+
type: "agent.updated",
|
|
483
|
+
idempotency_key: `agent-${agentId}-${digest(JSON.stringify(change))}-at-${this.seq}`,
|
|
484
|
+
agent_id: agentId,
|
|
485
|
+
...change,
|
|
486
|
+
});
|
|
487
|
+
if (found !== undefined) {
|
|
488
|
+
const state = options.state ?? found.state;
|
|
489
|
+
const next = this.nameFor(found, name, state, options.heldIn);
|
|
490
|
+
const renamed = next !== found.name;
|
|
491
|
+
if (!renamed && state === found.state)
|
|
492
|
+
return { agent: found, created: false, renamed };
|
|
493
|
+
update(found.agent_id, {
|
|
494
|
+
...(renamed ? { name: next } : {}),
|
|
495
|
+
...(state === found.state ? {} : { state }),
|
|
496
|
+
});
|
|
497
|
+
return { agent: found, created: false, renamed };
|
|
498
|
+
}
|
|
499
|
+
const agentId = this.ids.next("agent");
|
|
500
|
+
say({
|
|
501
|
+
type: "agent.registered",
|
|
502
|
+
idempotency_key: `agent-${this.harness.participantId}-${digest(key)}${gone}`,
|
|
503
|
+
agent_id: agentId,
|
|
504
|
+
harness_id: this.harness.participantId,
|
|
505
|
+
key,
|
|
506
|
+
name: options.state === "archived"
|
|
507
|
+
? name
|
|
508
|
+
: freeAgentName(name, this.takenNames(undefined, options.heldIn)),
|
|
509
|
+
lead: options.lead === true,
|
|
510
|
+
...(options.forkedFrom === undefined ? {} : { forked_from: options.forkedFrom }),
|
|
511
|
+
});
|
|
512
|
+
if (options.state === "archived")
|
|
513
|
+
update(agentId, { state: "archived" });
|
|
514
|
+
return { agent: this.agents.get(agentId), created: true, renamed: false };
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* The agent a key names, and, when a person deleted the last one, the suffix that keys a new
|
|
518
|
+
* one apart from it. Deleted stays deleted, but for the agent Chips makes for the harness
|
|
519
|
+
* itself, which is made afresh (ADR 0095, point 5).
|
|
520
|
+
*/
|
|
521
|
+
byAgentKey(key) {
|
|
522
|
+
const agents = [...this.agents.values()].filter((agent) => agent.key === key);
|
|
523
|
+
const found = agents.find((agent) => agent.deleted !== true);
|
|
524
|
+
const deleted = agents.findLast((agent) => agent.deleted === true);
|
|
525
|
+
if (found !== undefined || deleted === undefined)
|
|
526
|
+
return { found, gone: "" };
|
|
527
|
+
if (key !== this.harness.participantId)
|
|
528
|
+
throw new AgentDeleted();
|
|
529
|
+
return { found, gone: `-after-${deleted.agent_id}` };
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* The names nobody but `except` may be given, as the server keeps them (ADR 0091): the owner's,
|
|
533
|
+
* every active agent's, and the first names open rooms hold for the harness's agents-to-be but
|
|
534
|
+
* the one `heldIn` holds (ADR 0150). The harness's own row shares its default agent's name.
|
|
535
|
+
*/
|
|
536
|
+
takenNames(except, heldIn) {
|
|
537
|
+
const agents = [...this.agents.values()].filter((agent) => agent.agent_id !== except && agent.state === "active");
|
|
538
|
+
const held = [...this.holds].filter(([spaceId]) => {
|
|
539
|
+
const room = this.spaces.get(spaceId);
|
|
540
|
+
return (spaceId !== heldIn && room?.state === "open" && room.members.has(this.harness.participantId));
|
|
541
|
+
});
|
|
542
|
+
return new Set([
|
|
543
|
+
this.owner.displayName,
|
|
544
|
+
...agents.map((agent) => agent.name),
|
|
545
|
+
...held.map(([, hold]) => hold.name),
|
|
546
|
+
].map(clashKey));
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* The name a known agent has after its harness asks for `wanted`: the name it has where that is
|
|
550
|
+
* the one asked for or a number of it, a person's name for it whatever is asked, and numbered
|
|
551
|
+
* when it comes back to find its name taken, as the server's `registerAgent` does.
|
|
552
|
+
*/
|
|
553
|
+
nameFor(found, wanted, state, heldIn) {
|
|
554
|
+
const base = found.name_by === undefined ? wanted : found.name;
|
|
555
|
+
const keeps = isFormOf(found.name, base);
|
|
556
|
+
const waking = found.state === "archived" && state === "active";
|
|
557
|
+
if (keeps && !waking && state === found.state)
|
|
558
|
+
return found.name;
|
|
559
|
+
if (state !== "active")
|
|
560
|
+
return keeps ? found.name : base;
|
|
561
|
+
return freeAgentName(base, this.takenNames(found.agent_id, heldIn), found.name);
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* A person renames an agent, as `PUT /v1/agents/:id/name` does (ADR 0091): refused a name the
|
|
565
|
+
* owner or another active agent answers to, case and spaces aside; the same name records nothing.
|
|
566
|
+
*/
|
|
567
|
+
renameAgent(agentId, wanted) {
|
|
568
|
+
const agent = this.agents.get(agentId);
|
|
569
|
+
if (agent === undefined)
|
|
570
|
+
return { ok: false, message: "No agent of that id here." };
|
|
571
|
+
const name = wanted.trim().replace(/\s+/g, " ");
|
|
572
|
+
if (name === "" || name.length > 120) {
|
|
573
|
+
return { ok: false, message: "A name is something, up to 120 characters." };
|
|
574
|
+
}
|
|
575
|
+
if (name === agent.name)
|
|
576
|
+
return { ok: true, changed: false };
|
|
577
|
+
const problem = notAnAgentName(name) ? NOT_AN_AGENT_WORDS : nameProblem(name);
|
|
578
|
+
if (problem !== undefined)
|
|
579
|
+
return { ok: false, message: problem };
|
|
580
|
+
if (clashKey(this.owner.displayName) === clashKey(name)) {
|
|
581
|
+
return { ok: false, message: `Somebody here goes by ${this.owner.displayName} already.` };
|
|
582
|
+
}
|
|
583
|
+
const holder = [...this.agents.values()].find((each) => each.agent_id !== agentId &&
|
|
584
|
+
each.state === "active" &&
|
|
585
|
+
clashKey(each.name) === clashKey(name));
|
|
586
|
+
if (holder !== undefined) {
|
|
587
|
+
return { ok: false, message: `An agent here goes by ${holder.name} already.` };
|
|
588
|
+
}
|
|
589
|
+
this.appendAs(this.owner, {
|
|
590
|
+
type: "agent.updated",
|
|
591
|
+
idempotency_key: `agent-name-${agentId}-${digest(name)}-at-${this.seq}`,
|
|
592
|
+
agent_id: agentId,
|
|
593
|
+
name,
|
|
594
|
+
});
|
|
595
|
+
return { ok: true, changed: true };
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* The owner archives an agent or brings it back, as `PUT /v1/agents/:id/state` does (ADR 0095):
|
|
599
|
+
* `agent.updated` with the person as its actor, and rule 9's reactions after it. The Main agent
|
|
600
|
+
* and a deleted one are refused; the same state again records nothing.
|
|
601
|
+
*/
|
|
602
|
+
archiveAgent(agentId, state) {
|
|
603
|
+
const agent = this.agents.get(agentId);
|
|
604
|
+
if (agent === undefined || agent.lead || agent.deleted === true)
|
|
605
|
+
return false;
|
|
606
|
+
if (agent.state === state)
|
|
607
|
+
return false;
|
|
608
|
+
this.appendAs(this.owner, {
|
|
609
|
+
type: "agent.updated",
|
|
610
|
+
idempotency_key: `agent-state-${agentId}-${state}-at-${this.seq}`,
|
|
611
|
+
agent_id: agentId,
|
|
612
|
+
state,
|
|
613
|
+
});
|
|
614
|
+
return true;
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* The owner deletes an agent, as `DELETE /v1/agents/:id` does (ADR 0095): out of its room, which
|
|
618
|
+
* is archived when no other agent is left there, then `agent.deleted`, which its harness hears.
|
|
619
|
+
*/
|
|
620
|
+
deleteAgent(agentId) {
|
|
621
|
+
const agent = this.agents.get(agentId);
|
|
622
|
+
if (agent === undefined || agent.lead || agent.deleted === true)
|
|
623
|
+
return false;
|
|
624
|
+
const room = this.agentRoom(agentId);
|
|
625
|
+
if (room !== undefined) {
|
|
626
|
+
this.takeOut(room, agentId, {
|
|
627
|
+
reason: "agent_deleted",
|
|
628
|
+
by: this.owner.participantId,
|
|
629
|
+
key: `agent-delete-release-${agentId}`,
|
|
630
|
+
});
|
|
631
|
+
this.closeIfEmpty(room, this.owner, `agent-delete-close-${agentId}`);
|
|
632
|
+
}
|
|
633
|
+
this.appendAs(this.owner, {
|
|
634
|
+
type: "agent.deleted",
|
|
635
|
+
idempotency_key: `agent-deleted-${agentId}`,
|
|
636
|
+
agent_id: agentId,
|
|
637
|
+
harness_id: this.harness.participantId,
|
|
638
|
+
key: agent.key,
|
|
639
|
+
name: agent.name,
|
|
640
|
+
by: this.owner.participantId,
|
|
641
|
+
});
|
|
642
|
+
return true;
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* One of the harness's agents in a room in the place of another of its own there, or of the
|
|
646
|
+
* harness itself (ADR 0087, Phase 3), as the server's attach door does it.
|
|
647
|
+
*/
|
|
648
|
+
attachAgent(agentId, spaceId, replacing) {
|
|
649
|
+
const mine = (id) => id === this.harness.participantId || this.agents.has(id);
|
|
650
|
+
if (!this.agents.has(agentId) || !mine(replacing))
|
|
651
|
+
return { ok: false, kind: "unknown_agent" };
|
|
652
|
+
const room = this.spaces.get(spaceId);
|
|
653
|
+
if (room === undefined)
|
|
654
|
+
return { ok: false, kind: "unknown_space" };
|
|
655
|
+
const ours = (id) => room.members.has(id) && mine(id);
|
|
656
|
+
if (replacing === agentId || (ours(agentId) && !ours(replacing))) {
|
|
657
|
+
return { ok: true, attached: false };
|
|
658
|
+
}
|
|
659
|
+
if (!ours(replacing) || ours(agentId))
|
|
660
|
+
return { ok: false, kind: "invalid" };
|
|
661
|
+
// An agent is in one room at a time (ADR 0087, rule 1): not taken from another into this.
|
|
662
|
+
const where = this.agentRoom(agentId);
|
|
663
|
+
if (where !== undefined && where !== spaceId)
|
|
664
|
+
return { ok: false, kind: "invalid" };
|
|
665
|
+
this.appendAs(this.harness, {
|
|
666
|
+
type: "membership.replaced",
|
|
667
|
+
space_id: spaceId,
|
|
668
|
+
idempotency_key: `attach-${spaceId}-${replacing}-${agentId}-at-${this.seq}`,
|
|
669
|
+
from: replacing,
|
|
670
|
+
to: agentId,
|
|
671
|
+
});
|
|
672
|
+
return { ok: true, attached: true };
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* The agent the harness has in a room it opened or claimed: the key's own, for every harness
|
|
676
|
+
* (ADR 0087's addendum of 30 September, point 2), named by the room for one that follows its
|
|
677
|
+
* rooms and after the harness otherwise; its default for a room with no key.
|
|
678
|
+
*/
|
|
679
|
+
agentForRoom(key, title, heldIn) {
|
|
680
|
+
if (key === undefined) {
|
|
681
|
+
return this.registerAgent(this.harness.participantId, this.harness.displayName).agent;
|
|
682
|
+
}
|
|
683
|
+
// The first name a room holds for it, which it takes (ADR 0150).
|
|
684
|
+
const held = heldIn === undefined ? undefined : this.holds.get(heldIn)?.name;
|
|
685
|
+
if (this.capabilities.includes(HARNESS_CAPABILITIES.followsRooms)) {
|
|
686
|
+
return this.registerAgent(key, held ?? title ?? key, { heldIn }).agent;
|
|
687
|
+
}
|
|
688
|
+
const known = [...this.agents.values()].find((agent) => agent.key === key);
|
|
689
|
+
return known ?? this.registerAgent(key, held ?? this.harness.displayName, { heldIn }).agent;
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* The agent a room held a first name for takes it (ADR 0150), as named by the person who held
|
|
693
|
+
* it, so the harness asking for another later changes nothing. One a person named keeps that.
|
|
694
|
+
*/
|
|
695
|
+
nameAsHeld(agent, spaceId) {
|
|
696
|
+
const hold = this.holds.get(spaceId);
|
|
697
|
+
if (hold === undefined || agent.name_by !== undefined)
|
|
698
|
+
return;
|
|
699
|
+
const name = freeAgentName(hold.name, this.takenNames(agent.agent_id, spaceId), agent.name);
|
|
700
|
+
this.appendAs(this.owner, {
|
|
701
|
+
type: "agent.updated",
|
|
702
|
+
idempotency_key: `agent-${agent.agent_id}-${digest(JSON.stringify({ name }))}-at-${this.seq}`,
|
|
703
|
+
agent_id: agent.agent_id,
|
|
704
|
+
name,
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* The harness's agent in its place in a room, once. One that is in another room stays there,
|
|
709
|
+
* and the harness leaves this one: an agent is in one room at a time.
|
|
710
|
+
*/
|
|
711
|
+
agentInPlace(spaceId, key, title) {
|
|
712
|
+
const holds = this.holds.has(spaceId) ? spaceId : undefined;
|
|
713
|
+
const agent = this.agentForRoom(key, title, holds);
|
|
714
|
+
if (holds !== undefined)
|
|
715
|
+
this.nameAsHeld(agent, spaceId);
|
|
716
|
+
const where = this.agentRoom(agent.agent_id);
|
|
717
|
+
if (where !== undefined && where !== spaceId) {
|
|
718
|
+
this.appendOnce({
|
|
719
|
+
type: "membership.removed",
|
|
720
|
+
space_id: spaceId,
|
|
721
|
+
idempotency_key: `elsewhere-${spaceId}-${this.harness.participantId}`,
|
|
722
|
+
participant_id: this.harness.participantId,
|
|
723
|
+
});
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
this.appendSystem({
|
|
727
|
+
type: "membership.replaced",
|
|
728
|
+
space_id: spaceId,
|
|
729
|
+
idempotency_key: `replace-${spaceId}-${this.harness.participantId}`,
|
|
730
|
+
from: this.harness.participantId,
|
|
731
|
+
to: agent.agent_id,
|
|
732
|
+
});
|
|
733
|
+
// The agent holds the name now, as its own.
|
|
734
|
+
this.holds.delete(spaceId);
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* What the real door fills in on a message: the members its @handles name, and who it is for
|
|
738
|
+
* (M3.3, ADR 0081), by the protocol's own two functions, over what the fake knows. The fake
|
|
739
|
+
* knows the names of the owner and the harness under test, so only they can be @mentioned; a
|
|
740
|
+
* harness is any member with a harness id.
|
|
741
|
+
*/
|
|
742
|
+
fillAddress(event) {
|
|
743
|
+
if (event["type"] !== "message.post")
|
|
744
|
+
return undefined;
|
|
745
|
+
delete event["mentions"];
|
|
746
|
+
delete event["addressed_to"];
|
|
747
|
+
delete event["addressed_to_all"];
|
|
748
|
+
delete event["addressed_people"];
|
|
749
|
+
delete event["held"];
|
|
750
|
+
const space = typeof event["space_id"] === "string" ? this.spaces.get(event["space_id"]) : undefined;
|
|
751
|
+
const actor = event["actor"];
|
|
752
|
+
if (space === undefined)
|
|
753
|
+
return undefined;
|
|
754
|
+
const text = String(event["text"] ?? "");
|
|
755
|
+
// The referee's count (ADR 0097), weighed for an agent's words only.
|
|
756
|
+
const exchange = isAgentKind(actor.kind) ? exchangeInLog(this.log, space.space_id) : undefined;
|
|
757
|
+
const mine = this.agentsIn(space.space_id);
|
|
758
|
+
const mentions = resolveMentions(text, this.mentionable(space.members, mine));
|
|
759
|
+
if (mentions.length > 0)
|
|
760
|
+
event["mentions"] = mentions;
|
|
761
|
+
const address = resolveAddress({
|
|
762
|
+
sender: actor,
|
|
763
|
+
mentioned: mentions,
|
|
764
|
+
everyone: mentionsEveryone(text),
|
|
765
|
+
to: this.aimedAt(event["to"], mine),
|
|
766
|
+
...this.asNowIn(space.space_id, event["reply_to"], actor),
|
|
767
|
+
agents: [...space.members.keys()].filter((id) => isId("harness", id) || isId("agent", id)),
|
|
768
|
+
// Who besides its agents a person's message can be for (ADR 0204).
|
|
769
|
+
people: [...space.members.keys()].filter((id) => isId("person", id)),
|
|
770
|
+
...(exchange === undefined
|
|
771
|
+
? {}
|
|
772
|
+
: {
|
|
773
|
+
exchange: { count: exchange.count, paused: exchange.paused, limits: exchange.limits },
|
|
774
|
+
}),
|
|
775
|
+
});
|
|
776
|
+
event["addressed_to"] = address.addressed_to;
|
|
777
|
+
if (address.addressed_to_all === true)
|
|
778
|
+
event["addressed_to_all"] = true;
|
|
779
|
+
if (address.addressed_people !== undefined)
|
|
780
|
+
event["addressed_people"] = address.addressed_people;
|
|
781
|
+
if (address.held === true)
|
|
782
|
+
event["held"] = true;
|
|
783
|
+
return exchange;
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* The reply's author and the sender's last aim, as the room reads now: the harness before
|
|
787
|
+
* its agent took its place there is the agent (ADR 0087).
|
|
788
|
+
*/
|
|
789
|
+
asNowIn(spaceId, replyTo, actor) {
|
|
790
|
+
const now = (id) => this.replaced.get(spaceId)?.get(id) ?? id;
|
|
791
|
+
const reply = this.authorOf(replyTo, spaceId);
|
|
792
|
+
const last = actor.kind === "person" ? this.lastAddressed(spaceId, actor.id) : undefined;
|
|
793
|
+
return {
|
|
794
|
+
...(reply === undefined
|
|
795
|
+
? {}
|
|
796
|
+
: {
|
|
797
|
+
replyAuthor: {
|
|
798
|
+
id: now(reply.id),
|
|
799
|
+
kind: now(reply.id) === reply.id ? reply.kind : "agent",
|
|
800
|
+
},
|
|
801
|
+
}),
|
|
802
|
+
...(last === undefined ? {} : { last: Array.isArray(last) ? last.map(now) : last }),
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Who an @ in a room may name: the owner and the harness where they are in it, and the
|
|
807
|
+
* harness's agents — its name a handle for its one agent there (ADR 0087's addendum).
|
|
808
|
+
*/
|
|
809
|
+
mentionable(members, mine) {
|
|
810
|
+
return [
|
|
811
|
+
...[this.owner, this.harness]
|
|
812
|
+
.filter((each) => members.has(each.participantId))
|
|
813
|
+
.map((each) => ({ id: each.participantId, displayName: each.displayName })),
|
|
814
|
+
...mine.map((agent) => ({
|
|
815
|
+
id: agent.agent_id,
|
|
816
|
+
displayName: agent.name,
|
|
817
|
+
also: mine.length === 1 ? [this.harness.displayName] : [],
|
|
818
|
+
})),
|
|
819
|
+
];
|
|
820
|
+
}
|
|
821
|
+
/** The sender's `to`, with the harness's id read as its one agent in the room. */
|
|
822
|
+
aimedAt(to, mine) {
|
|
823
|
+
if (!Array.isArray(to))
|
|
824
|
+
return to === "all" ? "all" : undefined;
|
|
825
|
+
const only = mine.length === 1 ? mine[0] : undefined;
|
|
826
|
+
return to.map((id) => id === this.harness.participantId && only !== undefined ? only.agent_id : id);
|
|
827
|
+
}
|
|
828
|
+
/** The messages said in a space, newest first. */
|
|
829
|
+
messagesIn(spaceId) {
|
|
830
|
+
return this.log
|
|
831
|
+
.filter((each) => each.type === "message.post" && each.space_id === spaceId)
|
|
832
|
+
.reverse();
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* A reply answers a message said in the room it is said in (ADR 0219), as the real server's
|
|
836
|
+
* rule: one naming no message is `not_found`, one in another space `invalid_event`.
|
|
837
|
+
*/
|
|
838
|
+
refuseOnReply(event) {
|
|
839
|
+
const replyTo = event["reply_to"];
|
|
840
|
+
if (event["type"] !== "message.post" || typeof replyTo !== "string")
|
|
841
|
+
return undefined;
|
|
842
|
+
const said = this.saidMessage(replyTo);
|
|
843
|
+
if (said === undefined) {
|
|
844
|
+
return refusal("not_found", "unknown_message", "a reply answers a message, and there is no such message");
|
|
845
|
+
}
|
|
846
|
+
const space = event["space_id"];
|
|
847
|
+
if (typeof space === "string" && said.space_id !== null && said.space_id !== space) {
|
|
848
|
+
return refusal("invalid_event", "wrong_message", "that message is in another space");
|
|
849
|
+
}
|
|
850
|
+
return undefined;
|
|
851
|
+
}
|
|
852
|
+
/** The `message.post` a message id names, wherever it was said. */
|
|
853
|
+
saidMessage(messageId) {
|
|
854
|
+
let eventId;
|
|
855
|
+
try {
|
|
856
|
+
eventId = eventIdOfMessage(messageId);
|
|
857
|
+
}
|
|
858
|
+
catch {
|
|
859
|
+
return undefined;
|
|
860
|
+
}
|
|
861
|
+
return this.log.find((event) => event.id === eventId && event.type === "message.post");
|
|
862
|
+
}
|
|
863
|
+
/** Who wrote the message a reply answers, when it is in the same space. */
|
|
864
|
+
authorOf(replyTo, spaceId) {
|
|
865
|
+
if (typeof replyTo !== "string")
|
|
866
|
+
return undefined;
|
|
867
|
+
let eventId;
|
|
868
|
+
try {
|
|
869
|
+
eventId = eventIdOfMessage(replyTo);
|
|
870
|
+
}
|
|
871
|
+
catch {
|
|
872
|
+
return undefined;
|
|
873
|
+
}
|
|
874
|
+
const said = this.messagesIn(spaceId).find((each) => each.id === eventId);
|
|
875
|
+
const kind = said?.actor.kind;
|
|
876
|
+
return said !== undefined && kind !== undefined && kind !== "system"
|
|
877
|
+
? { id: said.actor.id, kind }
|
|
878
|
+
: undefined;
|
|
879
|
+
}
|
|
880
|
+
/** Where a sender's newest message in a space that says went, `all` for everyone (ADR 0081). */
|
|
881
|
+
lastAddressed(spaceId, actorId) {
|
|
882
|
+
const said = this.messagesIn(spaceId).find((each) => each.actor.id === actorId && Array.isArray(each.payload["addressed_to"]));
|
|
883
|
+
if (said?.payload["addressed_to_all"] === true)
|
|
884
|
+
return "all";
|
|
885
|
+
return said?.payload["addressed_to"];
|
|
886
|
+
}
|
|
887
|
+
/** The lane a task opened, for its acknowledgement; nothing for any other event. */
|
|
888
|
+
laneOf(event) {
|
|
889
|
+
if (event.type !== "task")
|
|
890
|
+
return {};
|
|
891
|
+
const lane = this.lanes.get(String(event.payload["task_id"]));
|
|
892
|
+
return lane === undefined
|
|
893
|
+
? {}
|
|
894
|
+
: { lane: { lane_id: lane.lane_id, run_id: lane.run_id, space_id: lane.home } };
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* What the real router does with a task, as far as one harness allows (M3.5, ADR 0032): a
|
|
898
|
+
* task the harness routes to itself opens a lane — a `run.requested` in the home it named,
|
|
899
|
+
* or in the direct space, with the run seeded there — and any other target is flattened into
|
|
900
|
+
* a step on the lead's run, since there is nobody else here to route to. A home the harness
|
|
901
|
+
* is not in is refused the same way.
|
|
902
|
+
*/
|
|
903
|
+
/**
|
|
904
|
+
* Where the route card appears: the space the task was sent from, and the space `link_to`
|
|
905
|
+
* names when the harness is in it — never one it is not (the real router's rule).
|
|
906
|
+
*/
|
|
907
|
+
linkedFor(task, home) {
|
|
908
|
+
const own = task.space_id ?? home;
|
|
909
|
+
const named = typeof task.payload["link_to"] === "string" ? task.payload["link_to"] : own;
|
|
910
|
+
const linkable = this.roleOf(named, this.harness.participantId) !== undefined;
|
|
911
|
+
return named === own || !linkable ? [own] : [own, named];
|
|
912
|
+
}
|
|
913
|
+
routeTask(task) {
|
|
914
|
+
const p = task.payload;
|
|
915
|
+
const laneId = String(p["task_id"]);
|
|
916
|
+
const to = p["to"];
|
|
917
|
+
if (!this.toItself(to, p["child"])) {
|
|
918
|
+
this.flatten(task, `Kept in this run: no harness ${String(to)} to route to.`);
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
const home = this.laneHome(p["home"], to);
|
|
922
|
+
// Its one agent in the home, or an agent it named (ADR 0087's addendum, point 3); or the
|
|
923
|
+
// harness itself, in a room it is in from before agents.
|
|
924
|
+
const inHome = this.roleOf(home, this.harness.participantId) !== undefined;
|
|
925
|
+
const agent = this.laneAgent(home, to);
|
|
926
|
+
if (!inHome && agent === undefined) {
|
|
927
|
+
this.flatten(task, `Kept in this run: ${home} is not a space of yours.`);
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
const runId = this.ids.next("run");
|
|
931
|
+
const linked = this.linkedFor(task, home);
|
|
932
|
+
const goal = String(p["goal"] ?? "");
|
|
933
|
+
this.appendSystem({
|
|
934
|
+
type: "run.requested",
|
|
935
|
+
space_id: home,
|
|
936
|
+
run_id: runId,
|
|
937
|
+
idempotency_key: `${laneId}-requested`,
|
|
938
|
+
lane_id: laneId,
|
|
939
|
+
goal,
|
|
940
|
+
on_behalf_of: String(p["on_behalf_of"]),
|
|
941
|
+
requested_by: task.actor.id,
|
|
942
|
+
specialist_id: this.harness.participantId,
|
|
943
|
+
...(agent === undefined || inHome ? {} : { agent_id: agent.agent_id }),
|
|
944
|
+
trail: [
|
|
945
|
+
{ participant_id: String(p["on_behalf_of"]) },
|
|
946
|
+
{
|
|
947
|
+
participant_id: this.harness.participantId,
|
|
948
|
+
...(task.run_id === null ? {} : { run_id: task.run_id }),
|
|
949
|
+
...this.leadGoal(task.run_id),
|
|
950
|
+
},
|
|
951
|
+
],
|
|
952
|
+
autonomy: typeof p["autonomy"] === "number" ? p["autonomy"] : 1,
|
|
953
|
+
link_to: linked,
|
|
954
|
+
...(typeof p["label"] === "string" ? { label: p["label"] } : {}),
|
|
955
|
+
...(Array.isArray(p["constraints"]) ? { constraints: p["constraints"] } : {}),
|
|
956
|
+
});
|
|
957
|
+
// The lane's run is its agent's, as the real server's is: moving the agent stops it.
|
|
958
|
+
this.openLane(task, {
|
|
959
|
+
laneId,
|
|
960
|
+
runId,
|
|
961
|
+
home,
|
|
962
|
+
linked,
|
|
963
|
+
agentId: inHome ? undefined : agent?.agent_id,
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
/** The rows a lane is: its run, going in its home, and the lane itself with where its news goes. */
|
|
967
|
+
openLane(task, lane) {
|
|
968
|
+
this.runs.set(lane.runId, {
|
|
969
|
+
run_id: lane.runId,
|
|
970
|
+
space_id: lane.home,
|
|
971
|
+
harness_id: this.harness.participantId,
|
|
972
|
+
...(lane.agentId === undefined ? {} : { agent_id: lane.agentId }),
|
|
973
|
+
state: "running",
|
|
974
|
+
});
|
|
975
|
+
this.lanes.set(lane.laneId, {
|
|
976
|
+
lane_id: lane.laneId,
|
|
977
|
+
run_id: lane.runId,
|
|
978
|
+
lead_run: task.run_id ?? undefined,
|
|
979
|
+
home: lane.home,
|
|
980
|
+
linked: lane.linked,
|
|
981
|
+
told: false,
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
/** What the lead itself was asked, to name its hop in a lane's trail: its run's goal. */
|
|
985
|
+
leadGoal(runId) {
|
|
986
|
+
if (runId === null)
|
|
987
|
+
return {};
|
|
988
|
+
const started = this.log.find((event) => event.type === "run.started" && event.run_id === runId);
|
|
989
|
+
const goal = started?.payload["goal"];
|
|
990
|
+
return typeof goal === "string" ? { goal } : {};
|
|
991
|
+
}
|
|
992
|
+
/**
|
|
993
|
+
* Cancelling a lead's run cancels the lanes it opened, and theirs, down the chain, as the real
|
|
994
|
+
* router does. A lane that has ended keeps its ending, and the chain below it is still walked.
|
|
995
|
+
* No cancel reached a lane anywhere here before (agents review, finding 37).
|
|
996
|
+
*/
|
|
997
|
+
cascade(leadRun) {
|
|
998
|
+
for (const lane of [...this.lanes.values()]) {
|
|
999
|
+
if (lane.lead_run !== leadRun)
|
|
1000
|
+
continue;
|
|
1001
|
+
const run = this.runs.get(lane.run_id);
|
|
1002
|
+
if (run === undefined || TERMINAL.has(run.state)) {
|
|
1003
|
+
this.cascade(lane.run_id);
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
// Its own cancel cascades further, as this one did.
|
|
1007
|
+
this.appendOnce({
|
|
1008
|
+
type: "run.cancel",
|
|
1009
|
+
space_id: lane.home,
|
|
1010
|
+
run_id: lane.run_id,
|
|
1011
|
+
idempotency_key: `${lane.lane_id}-cascade-cancel`,
|
|
1012
|
+
reason: "The lead's run was cancelled.",
|
|
1013
|
+
by: "system",
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Where a lane lives (ADR 0087, rule 8): the room named, when its agent is there or the room is
|
|
1019
|
+
* no room of the harness's; otherwise the room its agent is in, or, while it is free, a new
|
|
1020
|
+
* room with the person, as the real router has it.
|
|
1021
|
+
*/
|
|
1022
|
+
laneHome(named, to) {
|
|
1023
|
+
const agent = this.specialistAgent(to);
|
|
1024
|
+
if (typeof named === "string") {
|
|
1025
|
+
const ours = this.spaces.get(named)?.opened_by === this.harness.participantId;
|
|
1026
|
+
if (!ours || this.laneAgent(named, to) !== undefined)
|
|
1027
|
+
return named;
|
|
1028
|
+
}
|
|
1029
|
+
const last = agent.last_space ?? "none";
|
|
1030
|
+
return (this.agentRoom(agent.agent_id) ??
|
|
1031
|
+
this.roomWithOwner(agent, `lane-home-${agent.agent_id}-${this.owner.participantId}-after-${last}`));
|
|
1032
|
+
}
|
|
1033
|
+
/** The agent a lane to the harness is worked by: the one named, its lead, or its default. */
|
|
1034
|
+
specialistAgent(to) {
|
|
1035
|
+
if (typeof to === "string") {
|
|
1036
|
+
const named = this.agents.get(to);
|
|
1037
|
+
if (named !== undefined)
|
|
1038
|
+
return named;
|
|
1039
|
+
}
|
|
1040
|
+
const lead = [...this.agents.values()].find((agent) => agent.lead && agent.state === "active");
|
|
1041
|
+
return lead ?? this.agentForRoom(undefined, undefined);
|
|
1042
|
+
}
|
|
1043
|
+
/** A task the harness routes to itself or one of its agents, and not to a child. */
|
|
1044
|
+
toItself(to, child) {
|
|
1045
|
+
if (child !== undefined)
|
|
1046
|
+
return false;
|
|
1047
|
+
return to === this.harness.participantId || (typeof to === "string" && this.agents.has(to));
|
|
1048
|
+
}
|
|
1049
|
+
/** The agent of the harness's that works a lane in a home: the one named, or its only one. */
|
|
1050
|
+
laneAgent(home, to) {
|
|
1051
|
+
const mine = this.agentsIn(home);
|
|
1052
|
+
if (typeof to === "string" && this.agents.has(to)) {
|
|
1053
|
+
return mine.find((each) => each.agent_id === to);
|
|
1054
|
+
}
|
|
1055
|
+
return mine.length === 1 ? mine[0] : undefined;
|
|
1056
|
+
}
|
|
1057
|
+
/** A task Chips will not open a lane for: a done step on the lead's run, saying why. */
|
|
1058
|
+
flatten(task, note) {
|
|
1059
|
+
if (task.run_id === null || task.space_id === null)
|
|
1060
|
+
return;
|
|
1061
|
+
const stepId = `${String(task.payload["task_id"])}-flattened`;
|
|
1062
|
+
this.appendAs(this.harness, {
|
|
1063
|
+
type: "step.log",
|
|
1064
|
+
space_id: task.space_id,
|
|
1065
|
+
run_id: task.run_id,
|
|
1066
|
+
idempotency_key: stepId,
|
|
1067
|
+
step_id: stepId,
|
|
1068
|
+
status: "done",
|
|
1069
|
+
title: note,
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
/** A lane's run ended: tell the lead, in every space the lane is linked into. */
|
|
1073
|
+
tellLead(event, run) {
|
|
1074
|
+
const lane = [...this.lanes.values()].find((each) => each.run_id === run.run_id);
|
|
1075
|
+
if (lane === undefined || lane.told || !TERMINAL.has(run.state))
|
|
1076
|
+
return;
|
|
1077
|
+
lane.told = true;
|
|
1078
|
+
const state = run.state === "done" ? "completed" : run.state;
|
|
1079
|
+
const summary = event.type === "run.completed" ? event.payload["summary"] : undefined;
|
|
1080
|
+
for (const space of lane.linked) {
|
|
1081
|
+
this.appendSystem({
|
|
1082
|
+
type: "lane.updated",
|
|
1083
|
+
space_id: space,
|
|
1084
|
+
idempotency_key: `${lane.lane_id}-${state}-${space}`,
|
|
1085
|
+
lane_id: lane.lane_id,
|
|
1086
|
+
state,
|
|
1087
|
+
delivery: state === "completed" ? "queued_to_lead" : "failed",
|
|
1088
|
+
...(typeof summary === "string" ? { summary } : {}),
|
|
1089
|
+
...(state === "completed" ? this.filesOf(run.run_id) : {}),
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* The files a lane's answer carried, handed back to the lead with the summary: the attachments
|
|
1095
|
+
* on the specialist's own messages in the run. A lane that answers with photographs would
|
|
1096
|
+
* otherwise send the lead a sentence about them and nothing else.
|
|
1097
|
+
*/
|
|
1098
|
+
filesOf(runId) {
|
|
1099
|
+
const said = this.log.filter((event) => event.type === "message.post" &&
|
|
1100
|
+
event.run_id === runId &&
|
|
1101
|
+
event.actor.kind === "harness" &&
|
|
1102
|
+
Array.isArray(event.payload["attachments"]));
|
|
1103
|
+
const seen = new Set();
|
|
1104
|
+
const files = said
|
|
1105
|
+
.flatMap((event) => event.payload["attachments"])
|
|
1106
|
+
.filter((each) => {
|
|
1107
|
+
const id = each["attachment_id"];
|
|
1108
|
+
if (typeof id !== "string" || seen.has(id))
|
|
1109
|
+
return false;
|
|
1110
|
+
seen.add(id);
|
|
1111
|
+
return true;
|
|
1112
|
+
})
|
|
1113
|
+
.slice(0, 20);
|
|
1114
|
+
return files.length === 0 ? {} : { attachments: files };
|
|
1115
|
+
}
|
|
1116
|
+
/** An event Chips itself appends as `system`: filled in and validated, past the door. */
|
|
1117
|
+
appendSystem(event) {
|
|
1118
|
+
const draft = {
|
|
1119
|
+
v: 1,
|
|
1120
|
+
workspace_id: this.id,
|
|
1121
|
+
actor: { kind: "system", id: "system" },
|
|
1122
|
+
id: this.ids.next("event"),
|
|
1123
|
+
ts: this.nowIso(),
|
|
1124
|
+
...event,
|
|
1125
|
+
};
|
|
1126
|
+
const parsed = parseEvent(draft);
|
|
1127
|
+
if (!parsed.ok) {
|
|
1128
|
+
throw new Error(`the fake wrote an invalid ${String(event["type"])}: ${parsed.error.message}`);
|
|
1129
|
+
}
|
|
1130
|
+
return this.append(parsed.value);
|
|
1131
|
+
}
|
|
1132
|
+
/** What the transport knows and the sender must not choose, as the real server fills it. */
|
|
1133
|
+
draftOf(principal, raw, extras) {
|
|
1134
|
+
const now = this.nowIso();
|
|
1135
|
+
const draft = {
|
|
1136
|
+
v: 1,
|
|
1137
|
+
workspace_id: this.id,
|
|
1138
|
+
actor: { kind: principal.kind, id: principal.participantId },
|
|
1139
|
+
...(isRecord(raw) ? raw : {}),
|
|
1140
|
+
ts: now,
|
|
1141
|
+
...(extras.runId === undefined ? {} : { run_id: extras.runId }),
|
|
1142
|
+
};
|
|
1143
|
+
stamp(draft, principal, now);
|
|
1144
|
+
if (draft["id"] === undefined)
|
|
1145
|
+
draft["id"] = this.ids.next("event");
|
|
1146
|
+
if (draft["idempotency_key"] === undefined && extras.idempotencyKey !== undefined) {
|
|
1147
|
+
draft["idempotency_key"] = extras.idempotencyKey;
|
|
1148
|
+
}
|
|
1149
|
+
return draft;
|
|
1150
|
+
}
|
|
1151
|
+
validate(draft, principal) {
|
|
1152
|
+
const owned = chipsOwns(String(draft["type"]));
|
|
1153
|
+
if (owned !== undefined)
|
|
1154
|
+
return { ok: false, error: owned };
|
|
1155
|
+
if (draft["type"] === "message.post" && Array.isArray(draft["upload_ids"])) {
|
|
1156
|
+
const attached = Array.isArray(draft["attachments"]) ? [...draft["attachments"]] : [];
|
|
1157
|
+
for (const id of draft["upload_ids"]) {
|
|
1158
|
+
attached.push(this.attachmentFor(id, principal));
|
|
1159
|
+
}
|
|
1160
|
+
draft["attachments"] = attached;
|
|
1161
|
+
}
|
|
1162
|
+
const parsed = parseEvent(draft);
|
|
1163
|
+
if (parsed.ok) {
|
|
1164
|
+
const type = String(draft["type"]);
|
|
1165
|
+
for (const field of parsed.dropped)
|
|
1166
|
+
this.dropped.push({ who: principal.kind, type, field });
|
|
1167
|
+
return { ok: true, value: parsed.value };
|
|
1168
|
+
}
|
|
1169
|
+
return {
|
|
1170
|
+
ok: false,
|
|
1171
|
+
error: {
|
|
1172
|
+
code: "invalid_event",
|
|
1173
|
+
kind: "invalid",
|
|
1174
|
+
message: parsed.error.message,
|
|
1175
|
+
...(parsed.error.issues === undefined ? {} : { issues: parsed.error.issues }),
|
|
1176
|
+
},
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
/** The attachment an upload id stands for: the uploader's, and only once its bytes are in. */
|
|
1180
|
+
attachmentFor(uploadId, principal) {
|
|
1181
|
+
if (typeof uploadId !== "string")
|
|
1182
|
+
return uploadId;
|
|
1183
|
+
const row = this.uploads.get(uploadId);
|
|
1184
|
+
if (row?.stored === undefined || row.uploaded_by !== principal.participantId) {
|
|
1185
|
+
return { attachment_id: uploadId, upload_not_found: true };
|
|
1186
|
+
}
|
|
1187
|
+
const { bytes: _bytes, ...described } = row.stored;
|
|
1188
|
+
return { attachment_id: row.attachment_id, filename: row.filename, ...described };
|
|
1189
|
+
}
|
|
1190
|
+
/** Rule 4: the run the event names. Fills in the run's space when the event names none. */
|
|
1191
|
+
refuseOnRun(event, sender) {
|
|
1192
|
+
const runId = event["run_id"];
|
|
1193
|
+
if (typeof runId !== "string")
|
|
1194
|
+
return undefined;
|
|
1195
|
+
const type = String(event["type"]);
|
|
1196
|
+
const opening = type === "run.started";
|
|
1197
|
+
if (opening && typeof event["space_id"] !== "string") {
|
|
1198
|
+
return refusal("invalid_event", "invalid", "a run starts in a space: run.started names one");
|
|
1199
|
+
}
|
|
1200
|
+
const run = this.runs.get(runId);
|
|
1201
|
+
if (run === undefined) {
|
|
1202
|
+
if (opening)
|
|
1203
|
+
return undefined;
|
|
1204
|
+
return refusal("not_found", "unknown_run", `no run ${runId} in this workspace; a run begins with run.started`);
|
|
1205
|
+
}
|
|
1206
|
+
if (sender.kind === "harness" && run.harness_id !== sender.participantId) {
|
|
1207
|
+
return refusal("forbidden", "wrong_run", `run ${runId} was started by another harness`);
|
|
1208
|
+
}
|
|
1209
|
+
if (event["space_id"] === undefined)
|
|
1210
|
+
event["space_id"] = run.space_id;
|
|
1211
|
+
else if (event["space_id"] !== run.space_id) {
|
|
1212
|
+
return refusal("forbidden", "wrong_run", `run ${runId} belongs to another space`);
|
|
1213
|
+
}
|
|
1214
|
+
const reason = runAccepts(run.state, type);
|
|
1215
|
+
return reason === undefined
|
|
1216
|
+
? undefined
|
|
1217
|
+
: refusal("invalid_event", "run_state", `${type} was refused: ${reason}`);
|
|
1218
|
+
}
|
|
1219
|
+
/**
|
|
1220
|
+
* Rule 5: the space the event names, and whether the sender is in it. Answering an approval
|
|
1221
|
+
* and handing one on are the exceptions, judged by rule 9 instead (ADR 0042): whoever the ask
|
|
1222
|
+
* named answers from wherever they are.
|
|
1223
|
+
*/
|
|
1224
|
+
refuseOnMembership(event) {
|
|
1225
|
+
const space = event["space_id"];
|
|
1226
|
+
if (typeof space !== "string" || event["type"] === "space.created")
|
|
1227
|
+
return undefined;
|
|
1228
|
+
if (event["type"] === "approval.resolved" || event["type"] === "approval.routed") {
|
|
1229
|
+
return undefined;
|
|
1230
|
+
}
|
|
1231
|
+
const actor = event["actor"];
|
|
1232
|
+
if (this.roleOf(space, actor.id) === undefined) {
|
|
1233
|
+
return refusal("forbidden", "wrong_space", "an event may only be sent into a space its sender is a member of");
|
|
1234
|
+
}
|
|
1235
|
+
return undefined;
|
|
1236
|
+
}
|
|
1237
|
+
/**
|
|
1238
|
+
* Rule 7: a change to a space comes from somebody with standing in it. A harness retitles or
|
|
1239
|
+
* closes only a space it opened; a guest changes nothing; somebody else is removed only by the
|
|
1240
|
+
* space's owner, where leaving is anyone's. Rule 5 has already said the sender is in the space.
|
|
1241
|
+
*/
|
|
1242
|
+
refuseOnSpaceChange(event, sender) {
|
|
1243
|
+
const type = String(event["type"]);
|
|
1244
|
+
if (!SPACE_CHANGES.has(type))
|
|
1245
|
+
return undefined;
|
|
1246
|
+
const space = String(event["space_id"]);
|
|
1247
|
+
// An agent's standing is its own (ADR 0087): the door has said it is the sender's.
|
|
1248
|
+
const actor = event["actor"].id;
|
|
1249
|
+
const role = this.roleOf(space, actor);
|
|
1250
|
+
if (type === "space.updated")
|
|
1251
|
+
return this.refuseOnSpaceUpdate(event, sender, role);
|
|
1252
|
+
const leaving = event["participant_id"];
|
|
1253
|
+
if (type === "membership.removed" && (leaving === sender.participantId || leaving === actor)) {
|
|
1254
|
+
return undefined;
|
|
1255
|
+
}
|
|
1256
|
+
if (role === "guest") {
|
|
1257
|
+
return refusal("forbidden", "wrong_role", "a guest may not change who is in a space");
|
|
1258
|
+
}
|
|
1259
|
+
if (type === "membership.added" || role === "owner")
|
|
1260
|
+
return undefined;
|
|
1261
|
+
return refusal("forbidden", "wrong_role", "only the owner of a space removes somebody else from it");
|
|
1262
|
+
}
|
|
1263
|
+
refuseOnSpaceUpdate(event, sender, role) {
|
|
1264
|
+
const row = this.spaces.get(String(event["space_id"]));
|
|
1265
|
+
if (row === undefined) {
|
|
1266
|
+
return refusal("not_found", "unknown_space", "no such space in this workspace");
|
|
1267
|
+
}
|
|
1268
|
+
if (event["title"] === undefined &&
|
|
1269
|
+
event["state"] === undefined &&
|
|
1270
|
+
event["home"] === undefined) {
|
|
1271
|
+
return refusal("invalid_event", "invalid", "a space.updated says what changed: a title, a state or a home");
|
|
1272
|
+
}
|
|
1273
|
+
if (sender.kind === "harness")
|
|
1274
|
+
return refuseHarnessOnSpaceUpdate(event, sender, role, row);
|
|
1275
|
+
if (role === "guest") {
|
|
1276
|
+
return refusal("forbidden", "wrong_role", "a guest may not retitle or close a space");
|
|
1277
|
+
}
|
|
1278
|
+
// The harness's home is its to name, and stays on the list (ADR 0069).
|
|
1279
|
+
if (event["home"] !== undefined) {
|
|
1280
|
+
return refusal("forbidden", "wrong_role", "only the harness that opened a space makes it its home");
|
|
1281
|
+
}
|
|
1282
|
+
if (row.home && event["state"] === "closed") {
|
|
1283
|
+
return refusal("invalid_event", "invalid", "a harness's home is always there: it is not archived");
|
|
1284
|
+
}
|
|
1285
|
+
return undefined;
|
|
1286
|
+
}
|
|
1287
|
+
/** Rule 8: somebody added to a space is somebody — here, the owner or the harness. */
|
|
1288
|
+
refuseOnParticipant(event) {
|
|
1289
|
+
if (event["type"] !== "membership.added")
|
|
1290
|
+
return undefined;
|
|
1291
|
+
const named = event["participant_id"];
|
|
1292
|
+
const known = named === this.owner.participantId ||
|
|
1293
|
+
named === this.harness.participantId ||
|
|
1294
|
+
(typeof named === "string" && this.agents.has(named));
|
|
1295
|
+
return known
|
|
1296
|
+
? undefined
|
|
1297
|
+
: refusal("not_found", "unknown_participant", "nobody in this workspace has that id");
|
|
1298
|
+
}
|
|
1299
|
+
/**
|
|
1300
|
+
* Rule 15: a room asked of a harness is asked of one that takes new spaces, for it to claim
|
|
1301
|
+
* under a key of its own — so not a thread, which is its parent's, and not a room with a key.
|
|
1302
|
+
*/
|
|
1303
|
+
refuseOnAsking(event) {
|
|
1304
|
+
if (event["type"] !== "space.created" || event["asked_of"] === undefined)
|
|
1305
|
+
return undefined;
|
|
1306
|
+
if (event["kind"] === "thread") {
|
|
1307
|
+
return refusal("invalid_event", "invalid", "a thread is its space's; a new space is asked of a harness");
|
|
1308
|
+
}
|
|
1309
|
+
if (event["external_key"] !== undefined) {
|
|
1310
|
+
return refusal("invalid_event", "invalid", "a space asked of a harness has no key yet: the harness gives it one when it claims it");
|
|
1311
|
+
}
|
|
1312
|
+
const asked = [event["asked_of"], ...(event["also_asked_of"] ?? [])];
|
|
1313
|
+
if (asked.some((id) => id !== this.harness.participantId) || asked.length > 1) {
|
|
1314
|
+
// One harness here: another harness asked, or this one asked twice, is nobody to ask.
|
|
1315
|
+
if (asked.length > 1 && asked.every((id) => id === this.harness.participantId)) {
|
|
1316
|
+
return refusal("invalid_event", "invalid", "the harness asked first is not asked again");
|
|
1317
|
+
}
|
|
1318
|
+
return refusal("not_found", "unknown_participant", "no harness in this workspace has that id");
|
|
1319
|
+
}
|
|
1320
|
+
return this.capabilities.includes(HARNESS_CAPABILITIES.newSpaces)
|
|
1321
|
+
? undefined
|
|
1322
|
+
: refusal("invalid_event", "invalid", "that harness does not take new spaces: its manifest does not declare new_spaces");
|
|
1323
|
+
}
|
|
1324
|
+
/**
|
|
1325
|
+
* Rule 9: a decision comes from who the ask named to make it, when it named anyone — from
|
|
1326
|
+
* wherever they are, since being named is standing enough (ADR 0042) — and when it named
|
|
1327
|
+
* nobody, from anyone in the room but a guest (Gate 5), which is where the room is required.
|
|
1328
|
+
*/
|
|
1329
|
+
refuseNotAnApprover(event, sender) {
|
|
1330
|
+
if (sender.kind === "harness")
|
|
1331
|
+
return this.refuseHarnessAnswer(event, sender);
|
|
1332
|
+
if (event["type"] !== "approval.resolved")
|
|
1333
|
+
return undefined;
|
|
1334
|
+
const approval = this.approvals.get(String(event["approval_id"]));
|
|
1335
|
+
if (approval === undefined)
|
|
1336
|
+
return undefined;
|
|
1337
|
+
// An ask that matters is answered once (ADR 0195): no standing yes for it, either way.
|
|
1338
|
+
if (approval.matters && (event["scope"] === "session" || event["scope"] === "always")) {
|
|
1339
|
+
return refusal("invalid_event", "invalid", "an ask that matters is answered once: This session and Always are not given for money, outside messages, deletions or anything that can't be undone");
|
|
1340
|
+
}
|
|
1341
|
+
if (approval.approvers.length === 0) {
|
|
1342
|
+
const space = typeof event["space_id"] === "string" ? event["space_id"] : approval.space_id;
|
|
1343
|
+
return this.refuseOnAnyone(space, sender);
|
|
1344
|
+
}
|
|
1345
|
+
if (approval.approvers.includes(sender.participantId))
|
|
1346
|
+
return undefined;
|
|
1347
|
+
return refusal("forbidden", "not_an_approver", "that approval is somebody else's to answer: policy named who may");
|
|
1348
|
+
}
|
|
1349
|
+
/**
|
|
1350
|
+
* A harness settles an ask of its own run, answered in its own tools (ADR 0079): the ask is of
|
|
1351
|
+
* the run the event names, and that run is the harness's. Once, and never an expiry; This
|
|
1352
|
+
* session and Always are a person's to give. And only an ask that does not matter (SEC-10, ADR
|
|
1353
|
+
* 0195): money, outside messages, deletions and anything irreversible wait for a person. In this
|
|
1354
|
+
* fake nothing hands a harness an ask.
|
|
1355
|
+
*/
|
|
1356
|
+
refuseHarnessAnswer(event, sender) {
|
|
1357
|
+
const type = event["type"];
|
|
1358
|
+
if (type !== "approval.resolved" && type !== "question.answered")
|
|
1359
|
+
return undefined;
|
|
1360
|
+
const ask = type === "approval.resolved"
|
|
1361
|
+
? this.approvals.get(String(event["approval_id"]))
|
|
1362
|
+
: this.questions.get(String(event["ask_id"]));
|
|
1363
|
+
const run = ask === undefined ? undefined : this.runs.get(ask.run_id);
|
|
1364
|
+
if (ask?.run_id !== event["run_id"] || run?.harness_id !== sender.participantId) {
|
|
1365
|
+
return refusal("forbidden", "not_an_approver", "a harness answers only an approval policy handed it, or settles one its own run asked");
|
|
1366
|
+
}
|
|
1367
|
+
const scope = event["scope"];
|
|
1368
|
+
if ((scope !== undefined && scope !== "once") || event["decision"] === "expired") {
|
|
1369
|
+
return refusal("invalid_event", "invalid", "a harness settles its own ask once: This session and Always are a person's to give, and an expiry is Chips's");
|
|
1370
|
+
}
|
|
1371
|
+
if (event["timed_out"] === true) {
|
|
1372
|
+
return refusal("invalid_event", "invalid", "a timeout is Chips's to say");
|
|
1373
|
+
}
|
|
1374
|
+
// Only an ask that does not matter (SEC-10, ADR 0195): the rest wait for a person.
|
|
1375
|
+
if (type === "approval.resolved" && ask.matters) {
|
|
1376
|
+
return refusal("forbidden", "not_an_approver", "a harness settles its own ask only when it does not matter: money, outside messages, deletions and anything that can't be undone wait for a person");
|
|
1377
|
+
}
|
|
1378
|
+
return undefined;
|
|
1379
|
+
}
|
|
1380
|
+
/**
|
|
1381
|
+
* An ask that named nobody: anyone in the room but a guest, which is where the room is still
|
|
1382
|
+
* asked for, rule 5 having stood aside for every decision since ADR 0042.
|
|
1383
|
+
*/
|
|
1384
|
+
refuseOnAnyone(space, sender) {
|
|
1385
|
+
const standing = space === null ? undefined : this.roleOf(space, sender.participantId);
|
|
1386
|
+
if (space !== null && standing === undefined) {
|
|
1387
|
+
return refusal("forbidden", "wrong_space", "an approval that names nobody is answered in the space: this sender is not in it");
|
|
1388
|
+
}
|
|
1389
|
+
return standing === "guest"
|
|
1390
|
+
? refusal("forbidden", "not_an_approver", "an approval that names nobody is anyone's in the space but a guest's")
|
|
1391
|
+
: undefined;
|
|
1392
|
+
}
|
|
1393
|
+
/**
|
|
1394
|
+
* Who may answer an ask, as the real server resolves the refs a harness sent (ADR 0030):
|
|
1395
|
+
* `owner` is the harness's owner, `any` is everyone in the room, a person id is itself; a
|
|
1396
|
+
* role has nobody but the owner here. Empty means anyone in the room but a guest (Gate 5).
|
|
1397
|
+
* `owner` and a person id resolve wherever that person is, as the real server resolves them
|
|
1398
|
+
* since ADR 0042; `any` and a role are the room's.
|
|
1399
|
+
*/
|
|
1400
|
+
approversOf(refs) {
|
|
1401
|
+
if (!Array.isArray(refs) || refs.length === 0)
|
|
1402
|
+
return [];
|
|
1403
|
+
const ids = new Set();
|
|
1404
|
+
for (const ref of refs) {
|
|
1405
|
+
if (ref === "owner")
|
|
1406
|
+
ids.add(this.owner.participantId);
|
|
1407
|
+
else if (ref === "any")
|
|
1408
|
+
return [];
|
|
1409
|
+
else if (typeof ref === "string" && ref.startsWith("role:"))
|
|
1410
|
+
ids.add(this.owner.participantId);
|
|
1411
|
+
else if (typeof ref === "string")
|
|
1412
|
+
ids.add(ref);
|
|
1413
|
+
}
|
|
1414
|
+
return [...ids];
|
|
1415
|
+
}
|
|
1416
|
+
/** Rule 6: first answer wins. And an answer names an ask this workspace has. */
|
|
1417
|
+
refuseSecondAnswer(event) {
|
|
1418
|
+
const type = String(event["type"]);
|
|
1419
|
+
if (!DECISIONS.has(type))
|
|
1420
|
+
return undefined;
|
|
1421
|
+
if (type === "approval.resolved") {
|
|
1422
|
+
const approval = this.approvals.get(String(event["approval_id"]));
|
|
1423
|
+
if (approval === undefined) {
|
|
1424
|
+
return refusal("not_found", "unknown_approval", "no approval with that id in this workspace");
|
|
1425
|
+
}
|
|
1426
|
+
if (approval.state === "resolved") {
|
|
1427
|
+
return refusal("invalid_event", "already_decided", `that approval was already answered by ${approval.resolved_by ?? "somebody"}; the first answer wins`);
|
|
1428
|
+
}
|
|
1429
|
+
return undefined;
|
|
1430
|
+
}
|
|
1431
|
+
const question = this.questions.get(String(event["ask_id"]));
|
|
1432
|
+
if (question === undefined) {
|
|
1433
|
+
return refusal("not_found", "unknown_question", "no question with that id in this workspace");
|
|
1434
|
+
}
|
|
1435
|
+
if (question.state === "answered") {
|
|
1436
|
+
return refusal("invalid_event", "already_decided", `that question was already answered by ${question.answered_by ?? "somebody"}; the first answer wins`);
|
|
1437
|
+
}
|
|
1438
|
+
return undefined;
|
|
1439
|
+
}
|
|
1440
|
+
/** Into the log, then into the projections, then to whoever is listening. */
|
|
1441
|
+
append(event) {
|
|
1442
|
+
this.seq += 1;
|
|
1443
|
+
const stored = {
|
|
1444
|
+
seq: this.seq,
|
|
1445
|
+
id: String(event["id"]),
|
|
1446
|
+
type: String(event["type"]),
|
|
1447
|
+
ts: String(event["ts"]),
|
|
1448
|
+
space_id: typeof event["space_id"] === "string" ? event["space_id"] : null,
|
|
1449
|
+
run_id: typeof event["run_id"] === "string" ? event["run_id"] : null,
|
|
1450
|
+
actor: event["actor"],
|
|
1451
|
+
idempotency_key: typeof event["idempotency_key"] === "string" ? event["idempotency_key"] : null,
|
|
1452
|
+
payload: { ...event, seq: this.seq },
|
|
1453
|
+
};
|
|
1454
|
+
this.log.push(stored);
|
|
1455
|
+
if (stored.idempotency_key !== null) {
|
|
1456
|
+
this.byKey.set(keyOf(stored.actor.id, stored.idempotency_key), stored);
|
|
1457
|
+
}
|
|
1458
|
+
this.project(stored);
|
|
1459
|
+
for (const listener of this.listeners)
|
|
1460
|
+
listener(stored);
|
|
1461
|
+
// One room per agent (ADR 0087): every append has its consequences, as the real store's do.
|
|
1462
|
+
this.react(stored);
|
|
1463
|
+
return stored;
|
|
1464
|
+
}
|
|
1465
|
+
project(event) {
|
|
1466
|
+
this.projectRun(event);
|
|
1467
|
+
this.projectAsks(event);
|
|
1468
|
+
this.projectSpace(event);
|
|
1469
|
+
this.projectAgent(event);
|
|
1470
|
+
}
|
|
1471
|
+
/** An agent registered or changed (ADR 0087). */
|
|
1472
|
+
projectAgent(event) {
|
|
1473
|
+
const p = event.payload;
|
|
1474
|
+
if (event.type === "agent.registered") {
|
|
1475
|
+
this.agents.set(String(p["agent_id"]), {
|
|
1476
|
+
agent_id: String(p["agent_id"]),
|
|
1477
|
+
key: String(p["key"]),
|
|
1478
|
+
name: String(p["name"]),
|
|
1479
|
+
lead: p["lead"] === true,
|
|
1480
|
+
state: "active",
|
|
1481
|
+
...(typeof p["forked_from"] === "string" ? { forked_from: p["forked_from"] } : {}),
|
|
1482
|
+
});
|
|
1483
|
+
}
|
|
1484
|
+
else if (event.type === "agent.updated") {
|
|
1485
|
+
this.projectAgentChange(p, event.actor);
|
|
1486
|
+
}
|
|
1487
|
+
else if (event.type === "agent.deleted") {
|
|
1488
|
+
this.projectDeleted(String(p["agent_id"]));
|
|
1489
|
+
}
|
|
1490
|
+
else {
|
|
1491
|
+
this.projectWhereabouts(event);
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
/** An agent a person deleted (ADR 0095): marked, and what it was holding dropped. */
|
|
1495
|
+
projectDeleted(agentId) {
|
|
1496
|
+
const row = this.agents.get(agentId);
|
|
1497
|
+
if (row !== undefined)
|
|
1498
|
+
row.deleted = true;
|
|
1499
|
+
for (let at = this.held.length - 1; at >= 0; at -= 1) {
|
|
1500
|
+
if (this.held[at]?.agent_id === agentId)
|
|
1501
|
+
this.held.splice(at, 1);
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
/** An agent's new name, state or face, whichever `agent.updated` carries. */
|
|
1505
|
+
projectAgentChange(p, actor) {
|
|
1506
|
+
const row = this.agents.get(String(p["agent_id"]));
|
|
1507
|
+
if (row === undefined)
|
|
1508
|
+
return;
|
|
1509
|
+
if (typeof p["name"] === "string") {
|
|
1510
|
+
row.name = p["name"];
|
|
1511
|
+
// A person's name for it holds against its harness (ADR 0091).
|
|
1512
|
+
if (actor.kind === "person")
|
|
1513
|
+
row.name_by = actor.id;
|
|
1514
|
+
}
|
|
1515
|
+
if (p["state"] === "active" || p["state"] === "archived")
|
|
1516
|
+
row.state = p["state"];
|
|
1517
|
+
if ("face" in p)
|
|
1518
|
+
row.face = faceOf(p["face"]);
|
|
1519
|
+
if (typeof p["lead"] === "boolean")
|
|
1520
|
+
row.lead = p["lead"];
|
|
1521
|
+
}
|
|
1522
|
+
/** Where an agent was last, the room an archive took it from, and what it said meanwhile. */
|
|
1523
|
+
projectWhereabouts(event) {
|
|
1524
|
+
const p = event.payload;
|
|
1525
|
+
const agent = this.agents.get(String(p["agent_id"] ?? p["from"]));
|
|
1526
|
+
if (agent !== undefined)
|
|
1527
|
+
this.projectPlace(event, agent);
|
|
1528
|
+
if (event.type === "message.held") {
|
|
1529
|
+
this.held.push({
|
|
1530
|
+
event_id: event.id,
|
|
1531
|
+
agent_id: String(p["agent_id"]),
|
|
1532
|
+
said_at: event.ts,
|
|
1533
|
+
text: String(p["text"]),
|
|
1534
|
+
attachments: Array.isArray(p["attachments"]) ? p["attachments"] : [],
|
|
1535
|
+
});
|
|
1536
|
+
}
|
|
1537
|
+
else if (event.type === "message.post" && typeof p["from_held"] === "string") {
|
|
1538
|
+
const at = this.held.findIndex((row) => row.event_id === p["from_held"]);
|
|
1539
|
+
if (at >= 0)
|
|
1540
|
+
this.held.splice(at, 1);
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
/** An agent put in a room, taken out of one, or replaced in one. */
|
|
1544
|
+
projectPlace(event, agent) {
|
|
1545
|
+
const room = event.space_id ?? undefined;
|
|
1546
|
+
if (event.type === "agent.attached")
|
|
1547
|
+
agent.released_from = undefined;
|
|
1548
|
+
if (event.type === "membership.replaced")
|
|
1549
|
+
agent.last_space = room;
|
|
1550
|
+
if (event.type !== "agent.released")
|
|
1551
|
+
return;
|
|
1552
|
+
agent.last_space = room;
|
|
1553
|
+
const reason = event.payload["reason"];
|
|
1554
|
+
agent.released_from = reason === "archived" || reason === "agent_archived" ? room : undefined;
|
|
1555
|
+
}
|
|
1556
|
+
projectRun(event) {
|
|
1557
|
+
if (event.run_id === null)
|
|
1558
|
+
return;
|
|
1559
|
+
if (event.type === "run.started" && !this.runs.has(event.run_id)) {
|
|
1560
|
+
this.runs.set(event.run_id, {
|
|
1561
|
+
run_id: event.run_id,
|
|
1562
|
+
space_id: event.space_id ?? "",
|
|
1563
|
+
// The harness whose run it is: an agent's (ADR 0087), or the harness itself.
|
|
1564
|
+
harness_id: event.actor.harness_id ?? event.actor.id,
|
|
1565
|
+
...(event.actor.kind === "agent" ? { agent_id: event.actor.id } : {}),
|
|
1566
|
+
state: "running",
|
|
1567
|
+
});
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1570
|
+
const run = this.runs.get(event.run_id);
|
|
1571
|
+
if (run === undefined)
|
|
1572
|
+
return;
|
|
1573
|
+
run.state = TRANSITIONS[event.type]?.[run.state] ?? run.state;
|
|
1574
|
+
}
|
|
1575
|
+
projectAsks(event) {
|
|
1576
|
+
const p = event.payload;
|
|
1577
|
+
switch (event.type) {
|
|
1578
|
+
case "approval.request": {
|
|
1579
|
+
const seconds = Number(p["expires_in"]);
|
|
1580
|
+
this.approvals.set(String(p["approval_id"]), {
|
|
1581
|
+
approval_id: String(p["approval_id"]),
|
|
1582
|
+
run_id: event.run_id ?? "",
|
|
1583
|
+
space_id: event.space_id,
|
|
1584
|
+
expires_at: new Date(Date.parse(event.ts) + seconds * 1000).toISOString(),
|
|
1585
|
+
fallback: String(p["fallback"] ?? "deny"),
|
|
1586
|
+
approvers: this.approversOf(p["approvers"]),
|
|
1587
|
+
matters: asksWhatMatters(p),
|
|
1588
|
+
state: "pending",
|
|
1589
|
+
decision: null,
|
|
1590
|
+
scope: null,
|
|
1591
|
+
reason: null,
|
|
1592
|
+
resolved_by: null,
|
|
1593
|
+
resolved_at: null,
|
|
1594
|
+
updated_input: undefined,
|
|
1595
|
+
});
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
case "approval.resolved": {
|
|
1599
|
+
const row = this.approvals.get(String(p["approval_id"]));
|
|
1600
|
+
if (row === undefined)
|
|
1601
|
+
return;
|
|
1602
|
+
row.state = "resolved";
|
|
1603
|
+
row.decision = String(p["decision"]);
|
|
1604
|
+
row.scope = String(p["scope"] ?? "once");
|
|
1605
|
+
row.reason = typeof p["reason"] === "string" ? p["reason"] : null;
|
|
1606
|
+
row.resolved_by = String(p["resolved_by"]);
|
|
1607
|
+
row.resolved_at = String(p["resolved_at"]);
|
|
1608
|
+
row.updated_input = p["updated_input"];
|
|
1609
|
+
return;
|
|
1610
|
+
}
|
|
1611
|
+
case "question.ask":
|
|
1612
|
+
this.questions.set(String(p["ask_id"]), {
|
|
1613
|
+
ask_id: String(p["ask_id"]),
|
|
1614
|
+
run_id: event.run_id ?? "",
|
|
1615
|
+
space_id: event.space_id,
|
|
1616
|
+
questions: p["questions"],
|
|
1617
|
+
state: "pending",
|
|
1618
|
+
answered_by: null,
|
|
1619
|
+
});
|
|
1620
|
+
return;
|
|
1621
|
+
case "question.answered": {
|
|
1622
|
+
const row = this.questions.get(String(p["ask_id"]));
|
|
1623
|
+
if (row === undefined)
|
|
1624
|
+
return;
|
|
1625
|
+
row.state = "answered";
|
|
1626
|
+
row.answered_by = String(p["answered_by"]);
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
default:
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
projectSpace(event) {
|
|
1634
|
+
const space = event.space_id;
|
|
1635
|
+
if (space === null)
|
|
1636
|
+
return;
|
|
1637
|
+
const p = event.payload;
|
|
1638
|
+
if (event.type === "space.created") {
|
|
1639
|
+
this.spaces.set(space, createdSpace(space, event));
|
|
1640
|
+
return;
|
|
1641
|
+
}
|
|
1642
|
+
const row = this.spaces.get(space);
|
|
1643
|
+
if (row === undefined)
|
|
1644
|
+
return;
|
|
1645
|
+
if (event.type === "membership.added") {
|
|
1646
|
+
row.members.set(String(p["participant_id"]), p["role"] ?? "member");
|
|
1647
|
+
}
|
|
1648
|
+
else if (event.type === "membership.removed") {
|
|
1649
|
+
row.members.delete(String(p["participant_id"]));
|
|
1650
|
+
}
|
|
1651
|
+
else if (event.type === "membership.replaced") {
|
|
1652
|
+
this.replaceMember(row, String(p["from"]), String(p["to"]));
|
|
1653
|
+
}
|
|
1654
|
+
else if (event.type === "space.updated" || event.type === "space.claimed") {
|
|
1655
|
+
this.spaces.set(space, changedSpace(row, event));
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
/**
|
|
1659
|
+
* One member in another's place in a room, keeping its role; and whoever holds a place last is
|
|
1660
|
+
* who what was said in it reads as (ADR 0087, Phase 3).
|
|
1661
|
+
*/
|
|
1662
|
+
replaceMember(row, from, to) {
|
|
1663
|
+
const role = row.members.get(from) ?? "member";
|
|
1664
|
+
row.members.delete(from);
|
|
1665
|
+
row.members.set(to, role);
|
|
1666
|
+
const here = this.replaced.get(row.space_id) ?? new Map();
|
|
1667
|
+
for (const [was, now] of here)
|
|
1668
|
+
if (now === from)
|
|
1669
|
+
here.set(was, to);
|
|
1670
|
+
here.set(from, to);
|
|
1671
|
+
this.replaced.set(row.space_id, here);
|
|
1672
|
+
}
|
|
1673
|
+
// One room per agent (ADR 0087, Phase 4): the real door's rules and the real store's reactions.
|
|
1674
|
+
/** The open room an agent is in, or nothing while it is free (rule 1). */
|
|
1675
|
+
agentRoom(agentId) {
|
|
1676
|
+
for (const space of this.spaces.values()) {
|
|
1677
|
+
if (space.state === "open" && space.members.has(agentId))
|
|
1678
|
+
return space.space_id;
|
|
1679
|
+
}
|
|
1680
|
+
return undefined;
|
|
1681
|
+
}
|
|
1682
|
+
/**
|
|
1683
|
+
* Why this agent may not work on a room's project (ADR 0220), in the server's words, or nothing
|
|
1684
|
+
* when it may: it is the harness's, not deleted, and in the room now.
|
|
1685
|
+
*/
|
|
1686
|
+
refuseOnProject(agentId, spaceId) {
|
|
1687
|
+
if (agentId === undefined) {
|
|
1688
|
+
return "Name the agent this is for in the chips-actor header: a space's project is worked on by an agent.";
|
|
1689
|
+
}
|
|
1690
|
+
const agent = this.agents.get(agentId);
|
|
1691
|
+
if (agent === undefined)
|
|
1692
|
+
return "A harness works on a project only as one of its own agents.";
|
|
1693
|
+
if (agent.deleted === true)
|
|
1694
|
+
return "That agent was deleted.";
|
|
1695
|
+
if (this.spaces.get(spaceId)?.members.has(agentId) !== true) {
|
|
1696
|
+
return `${agent.name} is in no space with that id.`;
|
|
1697
|
+
}
|
|
1698
|
+
return undefined;
|
|
1699
|
+
}
|
|
1700
|
+
/** A push that moved a room's project forward (ADR 0220), as the server records it. */
|
|
1701
|
+
recordProjectChange(spaceId, agentId, change) {
|
|
1702
|
+
return this.appendSystem({
|
|
1703
|
+
type: "project.changed",
|
|
1704
|
+
space_id: spaceId,
|
|
1705
|
+
actor: { kind: "agent", id: agentId, harness_id: this.harness.participantId },
|
|
1706
|
+
idempotency_key: `project-${spaceId}-${String(change["after"])}`,
|
|
1707
|
+
by: agentId,
|
|
1708
|
+
...change,
|
|
1709
|
+
});
|
|
1710
|
+
}
|
|
1711
|
+
/** What the harness's agents route reports of an agent: its room, and how much it holds. */
|
|
1712
|
+
whereabouts(agent) {
|
|
1713
|
+
return {
|
|
1714
|
+
room: this.agentRoom(agent.agent_id) ?? null,
|
|
1715
|
+
released_from: agent.released_from ?? null,
|
|
1716
|
+
held: this.held.filter((row) => row.agent_id === agent.agent_id).length,
|
|
1717
|
+
};
|
|
1718
|
+
}
|
|
1719
|
+
/** A reaction's event, once: the real store's idempotency key does this for it. */
|
|
1720
|
+
appendOnce(event) {
|
|
1721
|
+
const actor = event["actor"]?.id ?? "system";
|
|
1722
|
+
const seen = this.byKey.get(keyOf(actor, String(event["idempotency_key"])));
|
|
1723
|
+
return seen ?? this.appendSystem(event);
|
|
1724
|
+
}
|
|
1725
|
+
/** The harness's own event, once, for a reaction said as the harness. */
|
|
1726
|
+
appendAsOnce(principal, event) {
|
|
1727
|
+
if (this.byKey.has(keyOf(principal.participantId, String(event["idempotency_key"]))))
|
|
1728
|
+
return;
|
|
1729
|
+
this.appendAs(principal, event);
|
|
1730
|
+
}
|
|
1731
|
+
reactions = {
|
|
1732
|
+
"membership.added": (event) => this.attachedNotice(event, event.payload["participant_id"]),
|
|
1733
|
+
"membership.replaced": (event) => this.attachedNotice(event, event.payload["to"]),
|
|
1734
|
+
"agent.attached": (event) => this.postHeld(event),
|
|
1735
|
+
"run.cancel": (event) => {
|
|
1736
|
+
if (event.run_id !== null)
|
|
1737
|
+
this.cascade(event.run_id);
|
|
1738
|
+
},
|
|
1739
|
+
"membership.removed": (event) => this.releasedNotice(event),
|
|
1740
|
+
"space.updated": (event) => {
|
|
1741
|
+
if (event.payload["state"] === "closed")
|
|
1742
|
+
this.archived(event);
|
|
1743
|
+
else if (event.payload["state"] === "open")
|
|
1744
|
+
this.unarchived(event);
|
|
1745
|
+
},
|
|
1746
|
+
"agent.updated": (event) => {
|
|
1747
|
+
const agentId = String(event.payload["agent_id"]);
|
|
1748
|
+
if (event.payload["state"] === "archived")
|
|
1749
|
+
this.agentArchived(event, agentId);
|
|
1750
|
+
else if (event.payload["state"] === "active")
|
|
1751
|
+
this.agentRestored(event, agentId);
|
|
1752
|
+
},
|
|
1753
|
+
};
|
|
1754
|
+
react(event) {
|
|
1755
|
+
// A lane's run ended: its lead is told once the event that ended it has gone out. Told from
|
|
1756
|
+
// inside the projection, the news went out first, and a socket, which takes events in order,
|
|
1757
|
+
// then dropped the event it was news of (agents review, finding 37).
|
|
1758
|
+
const run = event.run_id === null ? undefined : this.runs.get(event.run_id);
|
|
1759
|
+
if (run !== undefined)
|
|
1760
|
+
this.tellLead(event, run);
|
|
1761
|
+
this.reactions[event.type]?.(event);
|
|
1762
|
+
this.handOver(event);
|
|
1763
|
+
this.holdAtCeiling(event);
|
|
1764
|
+
}
|
|
1765
|
+
/**
|
|
1766
|
+
* A room's job weighed against its ceiling after each cost reported there (ADR 0118), as the
|
|
1767
|
+
* real `hold-at-ceiling`: from 80% a warning to the room, and at the ceiling the agents paused by
|
|
1768
|
+
* Chips, saying why, unless they are paused already, then the pause's warning. Once each per job.
|
|
1769
|
+
*/
|
|
1770
|
+
holdAtCeiling(event) {
|
|
1771
|
+
const spaceId = event.space_id;
|
|
1772
|
+
if (event.type !== "usage" || spaceId === null)
|
|
1773
|
+
return;
|
|
1774
|
+
const spend = spendInLog(this.log, spaceId, event.seq);
|
|
1775
|
+
const stage = spendStage(spend);
|
|
1776
|
+
if (stage === "within")
|
|
1777
|
+
return;
|
|
1778
|
+
const job = `spend-${spaceId}-${spend.since_seq}`;
|
|
1779
|
+
const once = (key) => !this.byKey.has(keyOf("system", key));
|
|
1780
|
+
if (stage === "ceiling" && once(`${job}-paused`) && !exchangeInLog(this.log, spaceId).paused) {
|
|
1781
|
+
this.appendSystem({
|
|
1782
|
+
type: "exchange.set",
|
|
1783
|
+
space_id: spaceId,
|
|
1784
|
+
idempotency_key: `${job}-paused`,
|
|
1785
|
+
state: "paused",
|
|
1786
|
+
why: "spend",
|
|
1787
|
+
spend,
|
|
1788
|
+
});
|
|
1789
|
+
}
|
|
1790
|
+
const at = stage === "ceiling" ? 100 : 80;
|
|
1791
|
+
if (!once(`${job}-${at}`))
|
|
1792
|
+
return;
|
|
1793
|
+
this.appendSystem({
|
|
1794
|
+
type: "budget.warning",
|
|
1795
|
+
space_id: spaceId,
|
|
1796
|
+
idempotency_key: `${job}-${at}`,
|
|
1797
|
+
budget: { limit_usd: spend.ceiling_usd, spent_usd: spend.spent_usd },
|
|
1798
|
+
threshold: at,
|
|
1799
|
+
action: at === 100 ? "pause" : "warn",
|
|
1800
|
+
scope: "job",
|
|
1801
|
+
since_seq: spend.since_seq,
|
|
1802
|
+
});
|
|
1803
|
+
}
|
|
1804
|
+
/**
|
|
1805
|
+
* What the referee held, handed over once a person acts in the room (ADR 0097), as the real
|
|
1806
|
+
* `hand-over-held`: for each agent, the newest held message that was for it, once.
|
|
1807
|
+
*/
|
|
1808
|
+
handOver(event) {
|
|
1809
|
+
const spaceId = event.space_id;
|
|
1810
|
+
if (event.actor.kind !== "person" || spaceId === null)
|
|
1811
|
+
return;
|
|
1812
|
+
if (!resetsTheCount(event.type, event.payload))
|
|
1813
|
+
return;
|
|
1814
|
+
const before = exchangeInLog(this.log, spaceId, event.seq - 1);
|
|
1815
|
+
const held = heldInLog(this.log, spaceId, before.personSeq ?? 0, event.seq);
|
|
1816
|
+
const space = this.spaces.get(spaceId);
|
|
1817
|
+
if (held.length === 0 || space === undefined)
|
|
1818
|
+
return;
|
|
1819
|
+
const agents = [...space.members.keys()].filter((id) => isId("harness", id) || isId("agent", id));
|
|
1820
|
+
const handed = new Set();
|
|
1821
|
+
for (const message of held) {
|
|
1822
|
+
const meant = resolveAddress({
|
|
1823
|
+
sender: message.actor,
|
|
1824
|
+
mentioned: message.payload["mentions"] ?? [],
|
|
1825
|
+
everyone: mentionsEveryone(String(message.payload["text"] ?? "")),
|
|
1826
|
+
...this.toOf(message),
|
|
1827
|
+
agents,
|
|
1828
|
+
}).addressed_to;
|
|
1829
|
+
for (const agentId of meant.filter((id) => !handed.has(id))) {
|
|
1830
|
+
handed.add(agentId);
|
|
1831
|
+
this.appendSystem({
|
|
1832
|
+
type: "message.handed",
|
|
1833
|
+
space_id: spaceId,
|
|
1834
|
+
idempotency_key: `handed-${message.id}-${agentId}`,
|
|
1835
|
+
message_id: messageIdOf(message.id),
|
|
1836
|
+
agent_id: agentId,
|
|
1837
|
+
});
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
/** A held message's own aim, as it was sent. */
|
|
1842
|
+
toOf(message) {
|
|
1843
|
+
const to = message.payload["to"];
|
|
1844
|
+
return to === "all" || Array.isArray(to) ? { to: to } : {};
|
|
1845
|
+
}
|
|
1846
|
+
/** How the agents' talk in a room stands as of a seq (ADR 0097), for what a turn is handed. */
|
|
1847
|
+
exchangeAt(spaceId, seq) {
|
|
1848
|
+
return exchangeInLog(this.log, spaceId, seq);
|
|
1849
|
+
}
|
|
1850
|
+
/** A person pauses the agents in a room, or lets them carry on (ADR 0097). */
|
|
1851
|
+
setExchange(spaceId, state, by) {
|
|
1852
|
+
return this.appendSystem({
|
|
1853
|
+
type: "exchange.set",
|
|
1854
|
+
space_id: spaceId,
|
|
1855
|
+
actor: { kind: "person", id: by },
|
|
1856
|
+
idempotency_key: `exchange-${spaceId}-${state}-at-${this.seq}`,
|
|
1857
|
+
state,
|
|
1858
|
+
});
|
|
1859
|
+
}
|
|
1860
|
+
/**
|
|
1861
|
+
* A person chooses who leads a room, or that nobody does (ADR 0104). The fake keeps no clock, so
|
|
1862
|
+
* it never hands a lead what was nobody's turn on its own; `handToLead` does, when a test says.
|
|
1863
|
+
*/
|
|
1864
|
+
setLead(spaceId, agentId, by) {
|
|
1865
|
+
return this.appendSystem({
|
|
1866
|
+
type: "space.lead_set",
|
|
1867
|
+
space_id: spaceId,
|
|
1868
|
+
actor: { kind: "person", id: by },
|
|
1869
|
+
idempotency_key: `lead-${spaceId}-${agentId ?? "none"}-at-${this.seq}`,
|
|
1870
|
+
agent_id: agentId,
|
|
1871
|
+
});
|
|
1872
|
+
}
|
|
1873
|
+
/** Who leads a room now (ADR 0104): the agent last chosen, while it is there. */
|
|
1874
|
+
leadOf(spaceId) {
|
|
1875
|
+
const chosen = this.log.findLast((event) => event.type === "space.lead_set" && event.space_id === spaceId)?.payload["agent_id"];
|
|
1876
|
+
return typeof chosen === "string" && this.spaces.get(spaceId)?.members.has(chosen) === true
|
|
1877
|
+
? chosen
|
|
1878
|
+
: undefined;
|
|
1879
|
+
}
|
|
1880
|
+
/** A person sets what a room allows a job, or sets it back to the default (ADR 0117). */
|
|
1881
|
+
setSpendCeiling(spaceId, usd, by) {
|
|
1882
|
+
return this.appendSystem({
|
|
1883
|
+
type: "space.spend_ceiling_set",
|
|
1884
|
+
space_id: spaceId,
|
|
1885
|
+
actor: { kind: "person", id: by },
|
|
1886
|
+
idempotency_key: `spend-ceiling-${spaceId}-${usd ?? "default"}-at-${this.seq}`,
|
|
1887
|
+
usd,
|
|
1888
|
+
});
|
|
1889
|
+
}
|
|
1890
|
+
/**
|
|
1891
|
+
* What a room's job has spent as of `upto`, against what the room allows a job (ADR 0117), as
|
|
1892
|
+
* Chips works it out: from the last person's act or brief handed in, what the room's `usage`
|
|
1893
|
+
* reports said since, a run's own report being its total so far.
|
|
1894
|
+
*/
|
|
1895
|
+
spendOf(spaceId, upto) {
|
|
1896
|
+
return spendInLog(this.log, spaceId, upto);
|
|
1897
|
+
}
|
|
1898
|
+
/** What was nobody's turn, handed to the room's lead as the sweep would (ADR 0104). */
|
|
1899
|
+
handToLead(spaceId, messageEventId) {
|
|
1900
|
+
const lead = this.leadOf(spaceId);
|
|
1901
|
+
if (lead === undefined)
|
|
1902
|
+
return undefined;
|
|
1903
|
+
return this.appendOnce({
|
|
1904
|
+
type: "message.handed",
|
|
1905
|
+
space_id: spaceId,
|
|
1906
|
+
actor: { kind: "system", id: "system" },
|
|
1907
|
+
idempotency_key: `lead-${messageEventId}`,
|
|
1908
|
+
message_id: messageIdOf(messageEventId),
|
|
1909
|
+
agent_id: lead,
|
|
1910
|
+
lead: true,
|
|
1911
|
+
});
|
|
1912
|
+
}
|
|
1913
|
+
/** An agent put in a room: said where everyone can see. */
|
|
1914
|
+
attachedNotice(event, agentId) {
|
|
1915
|
+
if (typeof agentId !== "string" || !this.agents.has(agentId) || event.space_id === null) {
|
|
1916
|
+
return;
|
|
1917
|
+
}
|
|
1918
|
+
const from = event.payload["moved_from"];
|
|
1919
|
+
const forkOf = event.payload["fork_of"];
|
|
1920
|
+
const madeFor = event.payload["made_for"];
|
|
1921
|
+
this.appendOnce({
|
|
1922
|
+
type: "agent.attached",
|
|
1923
|
+
space_id: event.space_id,
|
|
1924
|
+
idempotency_key: `attached-${event.id}`,
|
|
1925
|
+
agent_id: agentId,
|
|
1926
|
+
by: event.actor.id,
|
|
1927
|
+
...(typeof from === "string" ? { moved_from: from } : {}),
|
|
1928
|
+
...(typeof forkOf === "string" ? { fork_of: forkOf } : {}),
|
|
1929
|
+
...(typeof madeFor === "string" ? { made_for: madeFor } : {}),
|
|
1930
|
+
});
|
|
1931
|
+
}
|
|
1932
|
+
/** What an agent said while in no room, posted in the room it is in now, oldest first. */
|
|
1933
|
+
postHeld(event) {
|
|
1934
|
+
const agentId = String(event.payload["agent_id"]);
|
|
1935
|
+
for (const row of this.held.filter((each) => each.agent_id === agentId)) {
|
|
1936
|
+
this.appendOnce({
|
|
1937
|
+
type: "message.post",
|
|
1938
|
+
actor: { kind: "agent", id: agentId, harness_id: this.harness.participantId },
|
|
1939
|
+
space_id: event.space_id,
|
|
1940
|
+
idempotency_key: `post-${row.event_id}`,
|
|
1941
|
+
text: row.text,
|
|
1942
|
+
...(row.attachments.length === 0 ? {} : { attachments: row.attachments }),
|
|
1943
|
+
addressed_to: [],
|
|
1944
|
+
held_since: row.said_at,
|
|
1945
|
+
from_held: row.event_id,
|
|
1946
|
+
});
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
/** An agent out of a room: said, with why. */
|
|
1950
|
+
releasedNotice(event) {
|
|
1951
|
+
const p = event.payload;
|
|
1952
|
+
const agentId = String(p["participant_id"]);
|
|
1953
|
+
if (!this.agents.has(agentId) || event.space_id === null)
|
|
1954
|
+
return;
|
|
1955
|
+
this.appendOnce({
|
|
1956
|
+
type: "agent.released",
|
|
1957
|
+
space_id: event.space_id,
|
|
1958
|
+
idempotency_key: `released-${event.id}`,
|
|
1959
|
+
agent_id: agentId,
|
|
1960
|
+
by: typeof p["removed_by"] === "string" ? p["removed_by"] : event.actor.id,
|
|
1961
|
+
reason: typeof p["reason"] === "string" ? p["reason"] : "removed",
|
|
1962
|
+
...(typeof p["moved_to"] === "string" ? { moved_to: p["moved_to"] } : {}),
|
|
1963
|
+
});
|
|
1964
|
+
}
|
|
1965
|
+
/**
|
|
1966
|
+
* Stop the work in a room that somebody is leaving (agents review, findings 10 and 12): one
|
|
1967
|
+
* agent's runs there, or every run there when the room itself is going. Before the leaving is
|
|
1968
|
+
* written, as the real server does it, so the harness is still in the room to hear.
|
|
1969
|
+
*/
|
|
1970
|
+
stopWork(spaceId, how) {
|
|
1971
|
+
for (const run of this.runs.values()) {
|
|
1972
|
+
if (run.space_id !== spaceId || TERMINAL.has(run.state))
|
|
1973
|
+
continue;
|
|
1974
|
+
if (how.agentId !== undefined && run.agent_id !== how.agentId)
|
|
1975
|
+
continue;
|
|
1976
|
+
this.appendOnce({
|
|
1977
|
+
type: "run.cancel",
|
|
1978
|
+
space_id: spaceId,
|
|
1979
|
+
run_id: run.run_id,
|
|
1980
|
+
idempotency_key: `left-cancel-${run.run_id}`,
|
|
1981
|
+
reason: STOPPED_BECAUSE[how.reason],
|
|
1982
|
+
by: how.by,
|
|
1983
|
+
});
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
/** Take an agent out of a room, for a reason: its work there is stopped first. */
|
|
1987
|
+
takeOut(spaceId, agentId, how) {
|
|
1988
|
+
this.stopWork(spaceId, { reason: how.reason, by: how.by, agentId });
|
|
1989
|
+
this.appendOnce({
|
|
1990
|
+
type: "membership.removed",
|
|
1991
|
+
space_id: spaceId,
|
|
1992
|
+
idempotency_key: how.key,
|
|
1993
|
+
participant_id: agentId,
|
|
1994
|
+
removed_by: how.by,
|
|
1995
|
+
reason: how.reason,
|
|
1996
|
+
});
|
|
1997
|
+
}
|
|
1998
|
+
/** A room archived (rule 3): its runs stopped, then its agents released. */
|
|
1999
|
+
archived(event) {
|
|
2000
|
+
const spaceId = event.space_id;
|
|
2001
|
+
if (spaceId === null)
|
|
2002
|
+
return;
|
|
2003
|
+
this.stopWork(spaceId, { reason: "archived", by: event.actor.id });
|
|
2004
|
+
for (const agent of this.agentsIn(spaceId)) {
|
|
2005
|
+
this.takeOut(spaceId, agent.agent_id, {
|
|
2006
|
+
reason: "archived",
|
|
2007
|
+
by: event.actor.id,
|
|
2008
|
+
key: `archive-release-${event.id}-${agent.agent_id}`,
|
|
2009
|
+
});
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
/** A room unarchived (rule 4): the agents its archive released that are still free. */
|
|
2013
|
+
unarchived(event) {
|
|
2014
|
+
const spaceId = event.space_id;
|
|
2015
|
+
if (spaceId === null)
|
|
2016
|
+
return;
|
|
2017
|
+
for (const agent of this.agents.values()) {
|
|
2018
|
+
if (agent.released_from !== spaceId || this.agentRoom(agent.agent_id) !== undefined)
|
|
2019
|
+
continue;
|
|
2020
|
+
this.appendOnce({
|
|
2021
|
+
type: "membership.added",
|
|
2022
|
+
space_id: spaceId,
|
|
2023
|
+
idempotency_key: `unarchive-attach-${event.id}-${agent.agent_id}`,
|
|
2024
|
+
participant_id: agent.agent_id,
|
|
2025
|
+
participant_kind: "agent",
|
|
2026
|
+
role: "member",
|
|
2027
|
+
});
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
/** An agent archived in its harness (rule 9): out, and the room archived if it was alone. */
|
|
2031
|
+
agentArchived(event, agentId) {
|
|
2032
|
+
const spaceId = this.agentRoom(agentId);
|
|
2033
|
+
if (spaceId === undefined)
|
|
2034
|
+
return;
|
|
2035
|
+
// The Main agent stays in its Main room (rule 7), which is always there.
|
|
2036
|
+
if (this.agents.get(agentId)?.lead === true && this.spaces.get(spaceId)?.home === true)
|
|
2037
|
+
return;
|
|
2038
|
+
// A person's archive is said as theirs (ADR 0095); a harness's, as its.
|
|
2039
|
+
const who = event.actor.kind === "person" ? this.owner : this.harness;
|
|
2040
|
+
this.takeOut(spaceId, agentId, {
|
|
2041
|
+
reason: "agent_archived",
|
|
2042
|
+
by: who.participantId,
|
|
2043
|
+
key: `agent-archived-${event.id}`,
|
|
2044
|
+
});
|
|
2045
|
+
this.closeIfEmpty(spaceId, who, `agent-archived-close-${event.id}`);
|
|
2046
|
+
}
|
|
2047
|
+
/** A room archived because the only agent in it left for good (rule 9, ADR 0095). */
|
|
2048
|
+
closeIfEmpty(spaceId, who, key) {
|
|
2049
|
+
const room = this.spaces.get(spaceId);
|
|
2050
|
+
if (room === undefined || room.home || room.state === "closed")
|
|
2051
|
+
return;
|
|
2052
|
+
if (this.agentsIn(spaceId).length > 0)
|
|
2053
|
+
return;
|
|
2054
|
+
this.appendAsOnce(who, {
|
|
2055
|
+
type: "space.updated",
|
|
2056
|
+
space_id: spaceId,
|
|
2057
|
+
idempotency_key: key,
|
|
2058
|
+
state: "closed",
|
|
2059
|
+
});
|
|
2060
|
+
}
|
|
2061
|
+
/** An agent restored in its harness: back to the room its archive closed, reopened. */
|
|
2062
|
+
agentRestored(event, agentId) {
|
|
2063
|
+
const from = this.agents.get(agentId)?.released_from;
|
|
2064
|
+
if (from === undefined || this.agentRoom(agentId) !== undefined)
|
|
2065
|
+
return;
|
|
2066
|
+
const room = this.spaces.get(from);
|
|
2067
|
+
if (room === undefined)
|
|
2068
|
+
return;
|
|
2069
|
+
// A room that stayed open, for the agents still there, takes it back now (ADR 0095).
|
|
2070
|
+
if (room.state !== "closed") {
|
|
2071
|
+
this.appendOnce({
|
|
2072
|
+
type: "membership.added",
|
|
2073
|
+
space_id: from,
|
|
2074
|
+
idempotency_key: `unarchive-attach-${event.id}-${agentId}`,
|
|
2075
|
+
participant_id: agentId,
|
|
2076
|
+
participant_kind: "agent",
|
|
2077
|
+
role: "member",
|
|
2078
|
+
});
|
|
2079
|
+
return;
|
|
2080
|
+
}
|
|
2081
|
+
this.appendAsOnce(event.actor.kind === "person" ? this.owner : this.harness, {
|
|
2082
|
+
type: "space.updated",
|
|
2083
|
+
space_id: from,
|
|
2084
|
+
idempotency_key: `agent-restored-open-${event.id}`,
|
|
2085
|
+
state: "open",
|
|
2086
|
+
});
|
|
2087
|
+
}
|
|
2088
|
+
/**
|
|
2089
|
+
* Rule 16: an agent is in one room at a time; an archived room takes none; the Main agent is
|
|
2090
|
+
* only in its Main room, which takes no other agent; and an agent archived in its harness joins
|
|
2091
|
+
* nothing. All but the first clause below and the archived one are `refuseAgentInto`, the
|
|
2092
|
+
* function the real door and the real store ask (agents review, findings 5 to 8).
|
|
2093
|
+
*/
|
|
2094
|
+
refuseOnAgentMembership(event) {
|
|
2095
|
+
if (event["type"] !== "membership.added")
|
|
2096
|
+
return undefined;
|
|
2097
|
+
const agent = this.agents.get(String(event["participant_id"]));
|
|
2098
|
+
if (agent === undefined)
|
|
2099
|
+
return undefined;
|
|
2100
|
+
const space = this.spaces.get(String(event["space_id"]));
|
|
2101
|
+
if (agent.lead && space?.home !== true) {
|
|
2102
|
+
return refusal("forbidden", "lead_room", "the Main agent stays in its Main space");
|
|
2103
|
+
}
|
|
2104
|
+
const placed = this.refusePlacing(agent.agent_id, String(event["space_id"]));
|
|
2105
|
+
if (placed?.kind === "lead_room")
|
|
2106
|
+
return placed;
|
|
2107
|
+
if (agent.state === "archived") {
|
|
2108
|
+
return refusal("forbidden", "agent_archived", "that agent is archived");
|
|
2109
|
+
}
|
|
2110
|
+
return placed;
|
|
2111
|
+
}
|
|
2112
|
+
/** The one-room rule for an agent and a room, as the real store's gate asks it. */
|
|
2113
|
+
refusePlacing(agentId, spaceId, replacing) {
|
|
2114
|
+
const agent = this.agents.get(agentId);
|
|
2115
|
+
if (agent === undefined)
|
|
2116
|
+
return undefined;
|
|
2117
|
+
const space = this.spaces.get(spaceId);
|
|
2118
|
+
const elsewhere = [...this.spaces.values()].find((each) => each.space_id !== spaceId && each.members.has(agentId));
|
|
2119
|
+
const here = space === undefined ? [] : this.agentsIn(spaceId);
|
|
2120
|
+
const refused = refuseAgentInto({
|
|
2121
|
+
agent: { id: agentId, lead: agent.lead, deleted: agent.deleted === true },
|
|
2122
|
+
room: space === undefined
|
|
2123
|
+
? null
|
|
2124
|
+
: { space_id: space.space_id, state: space.state, home: space.home === true },
|
|
2125
|
+
elsewhere: elsewhere?.space_id ?? null,
|
|
2126
|
+
others: here.filter((each) => each.agent_id !== agentId && each.agent_id !== replacing)
|
|
2127
|
+
.length,
|
|
2128
|
+
replacesLead: here.some((each) => each.agent_id === replacing && each.lead),
|
|
2129
|
+
});
|
|
2130
|
+
if (refused === undefined)
|
|
2131
|
+
return undefined;
|
|
2132
|
+
return {
|
|
2133
|
+
...refusal(refused.kind === "unknown_space" ? "not_found" : "forbidden", refused.kind, refused.message),
|
|
2134
|
+
...(refused.space_id === undefined ? {} : { space_id: refused.space_id }),
|
|
2135
|
+
};
|
|
2136
|
+
}
|
|
2137
|
+
/** Rule 17: the Main agent is not taken out of its Main room (agents review, finding 9). */
|
|
2138
|
+
refuseOnLeadRemoval(event) {
|
|
2139
|
+
if (event["type"] !== "membership.removed")
|
|
2140
|
+
return undefined;
|
|
2141
|
+
const agent = this.agents.get(String(event["participant_id"]));
|
|
2142
|
+
const space = this.spaces.get(String(event["space_id"]));
|
|
2143
|
+
if (agent?.lead !== true || space?.home !== true)
|
|
2144
|
+
return undefined;
|
|
2145
|
+
return refusal("forbidden", "lead_room", "the Main agent stays in its Main space");
|
|
2146
|
+
}
|
|
2147
|
+
/**
|
|
2148
|
+
* What an agent in no room says (rule 6): held, or, for one never in a room, said in a room
|
|
2149
|
+
* opened with the harness's owner. Naming no room, one in a room says it there.
|
|
2150
|
+
*/
|
|
2151
|
+
holdIfFree(event, principal) {
|
|
2152
|
+
if (event["type"] !== "message.post" || principal.kind !== "harness")
|
|
2153
|
+
return;
|
|
2154
|
+
const named = typeof event["space_id"] === "string" ? event["space_id"] : undefined;
|
|
2155
|
+
const agentId = this.speakerOutside(event, principal, named);
|
|
2156
|
+
if (agentId === undefined)
|
|
2157
|
+
return;
|
|
2158
|
+
const room = this.agentRoom(agentId);
|
|
2159
|
+
if (room !== undefined) {
|
|
2160
|
+
if (named === undefined)
|
|
2161
|
+
event["space_id"] = room;
|
|
2162
|
+
return;
|
|
2163
|
+
}
|
|
2164
|
+
const agent = this.agents.get(agentId);
|
|
2165
|
+
if (agent !== undefined && !this.everIn(agent)) {
|
|
2166
|
+
event["space_id"] = this.roomWithOwner(agent, `agent-room-${agent.agent_id}`);
|
|
2167
|
+
return;
|
|
2168
|
+
}
|
|
2169
|
+
for (const field of ROOM_FIELDS)
|
|
2170
|
+
delete event[field];
|
|
2171
|
+
event["type"] = "message.held";
|
|
2172
|
+
event["agent_id"] = agentId;
|
|
2173
|
+
}
|
|
2174
|
+
/**
|
|
2175
|
+
* The agent a post is said by, when it is not in the room the post names. Nothing for an agent
|
|
2176
|
+
* in the room it names, and nothing for a room that is gone, which the door refuses and does
|
|
2177
|
+
* not hold for the next (agents review, finding 10).
|
|
2178
|
+
*/
|
|
2179
|
+
speakerOutside(event, principal, named) {
|
|
2180
|
+
if (named !== undefined && !this.spaces.has(named))
|
|
2181
|
+
return undefined;
|
|
2182
|
+
const agentId = this.speakerAgent(event, principal, named);
|
|
2183
|
+
if (agentId === undefined)
|
|
2184
|
+
return undefined;
|
|
2185
|
+
return named !== undefined && this.roleOf(named, agentId) !== undefined ? undefined : agentId;
|
|
2186
|
+
}
|
|
2187
|
+
/** Who a harness's post is said by: its agent named, or the one that last left the room named. */
|
|
2188
|
+
speakerAgent(event, principal, named) {
|
|
2189
|
+
const actor = event["actor"];
|
|
2190
|
+
if (actor.kind === "agent")
|
|
2191
|
+
return String(actor.id);
|
|
2192
|
+
if (named === undefined || this.roleOf(named, principal.participantId) !== undefined) {
|
|
2193
|
+
return undefined;
|
|
2194
|
+
}
|
|
2195
|
+
const left = [...this.agents.values()].find((agent) => agent.last_space === named && this.agentRoom(agent.agent_id) === undefined);
|
|
2196
|
+
if (left === undefined)
|
|
2197
|
+
return undefined;
|
|
2198
|
+
this.speakAs(event, principal, left);
|
|
2199
|
+
return left.agent_id;
|
|
2200
|
+
}
|
|
2201
|
+
/** Whether an agent was ever in a room. */
|
|
2202
|
+
everIn(agent) {
|
|
2203
|
+
if (agent.last_space !== undefined)
|
|
2204
|
+
return true;
|
|
2205
|
+
return [...this.spaces.values()].some((space) => space.members.has(agent.agent_id));
|
|
2206
|
+
}
|
|
2207
|
+
/** A room with the harness's owner for a free agent: its first words, or a hand-off (rule 8). */
|
|
2208
|
+
roomWithOwner(agent, key) {
|
|
2209
|
+
const spaceId = this.ids.next("space");
|
|
2210
|
+
const opened = this.appendOnce({
|
|
2211
|
+
type: "space.created",
|
|
2212
|
+
space_id: spaceId,
|
|
2213
|
+
idempotency_key: key,
|
|
2214
|
+
kind: "direct",
|
|
2215
|
+
title: agent.name,
|
|
2216
|
+
opened_for: this.owner.participantId,
|
|
2217
|
+
});
|
|
2218
|
+
const room = opened.space_id ?? spaceId;
|
|
2219
|
+
for (const [who, kind, role] of [
|
|
2220
|
+
[this.owner.participantId, "person", "owner"],
|
|
2221
|
+
[agent.agent_id, "agent", "member"],
|
|
2222
|
+
]) {
|
|
2223
|
+
this.appendOnce({
|
|
2224
|
+
type: "membership.added",
|
|
2225
|
+
space_id: room,
|
|
2226
|
+
idempotency_key: `${key}-${who}`,
|
|
2227
|
+
participant_id: who,
|
|
2228
|
+
participant_kind: kind,
|
|
2229
|
+
role,
|
|
2230
|
+
});
|
|
2231
|
+
}
|
|
2232
|
+
return room;
|
|
2233
|
+
}
|
|
2234
|
+
/** A claim whose key's agent is in another room: refused. */
|
|
2235
|
+
refuseElsewhere(key, spaceId) {
|
|
2236
|
+
const agent = [...this.agents.values()].find((each) => each.key === key);
|
|
2237
|
+
const where = agent === undefined ? undefined : this.agentRoom(agent.agent_id);
|
|
2238
|
+
if (where === undefined || where === spaceId)
|
|
2239
|
+
return undefined;
|
|
2240
|
+
return refusal("invalid_event", "agent_elsewhere", `That key's agent is in another space (${where}), and an agent is in one space at a time.`);
|
|
2241
|
+
}
|
|
2242
|
+
/** A room asked of the harness beside another: it answers with its key's agent in its place. */
|
|
2243
|
+
answer(row, key) {
|
|
2244
|
+
const unchanged = {
|
|
2245
|
+
ok: true,
|
|
2246
|
+
value: { spaceId: row.space_id, claimed: false, retitled: false },
|
|
2247
|
+
};
|
|
2248
|
+
if (!row.members.has(this.harness.participantId)) {
|
|
2249
|
+
const agent = [...this.agents.values()].find((each) => each.key === key);
|
|
2250
|
+
if (agent !== undefined && row.members.has(agent.agent_id))
|
|
2251
|
+
return unchanged;
|
|
2252
|
+
return {
|
|
2253
|
+
ok: false,
|
|
2254
|
+
error: refusal("forbidden", "not_asked", "This harness was taken out of that space, so it cannot answer."),
|
|
2255
|
+
};
|
|
2256
|
+
}
|
|
2257
|
+
const elsewhere = this.refuseElsewhere(key, row.space_id);
|
|
2258
|
+
if (elsewhere !== undefined)
|
|
2259
|
+
return { ok: false, error: elsewhere };
|
|
2260
|
+
this.agentInPlace(row.space_id, key, row.title);
|
|
2261
|
+
return { ok: true, value: { ...unchanged.value, claimed: true } };
|
|
2262
|
+
}
|
|
2263
|
+
/** Why an agent may not be moved into a room, or nothing when it may. */
|
|
2264
|
+
refuseMove(agentId, spaceId) {
|
|
2265
|
+
const agent = this.agents.get(agentId);
|
|
2266
|
+
const space = this.spaces.get(spaceId);
|
|
2267
|
+
if (agent === undefined)
|
|
2268
|
+
return refusal("not_found", "unknown_agent", "No agent of that id here.");
|
|
2269
|
+
if (space === undefined)
|
|
2270
|
+
return refusal("not_found", "unknown_space", "No space of that id here.");
|
|
2271
|
+
const stays = agent.lead || agent.state === "archived";
|
|
2272
|
+
if (stays || space.home || space.state !== "open") {
|
|
2273
|
+
return refusal("invalid_event", "invalid", "That agent cannot be moved into that space.");
|
|
2274
|
+
}
|
|
2275
|
+
return undefined;
|
|
2276
|
+
}
|
|
2277
|
+
/**
|
|
2278
|
+
* A person gives an agent a face, or its initials back with `null`, as `PUT` and `DELETE
|
|
2279
|
+
* /v1/agents/:id/face` do (ADR 0089): `agent.updated` carrying the face and nothing else, which
|
|
2280
|
+
* every connection hears. The same face again records nothing.
|
|
2281
|
+
*/
|
|
2282
|
+
setAgentFace(agentId, face) {
|
|
2283
|
+
const agent = this.agents.get(agentId);
|
|
2284
|
+
if (agent === undefined)
|
|
2285
|
+
return false;
|
|
2286
|
+
const now = agent.face ?? null;
|
|
2287
|
+
if (JSON.stringify(now) === JSON.stringify(face))
|
|
2288
|
+
return false;
|
|
2289
|
+
this.appendAs(this.owner, {
|
|
2290
|
+
type: "agent.updated",
|
|
2291
|
+
idempotency_key: `agent-face-${agentId}-${digest(JSON.stringify(face))}-at-${this.seq}`,
|
|
2292
|
+
agent_id: agentId,
|
|
2293
|
+
face,
|
|
2294
|
+
});
|
|
2295
|
+
return true;
|
|
2296
|
+
}
|
|
2297
|
+
/**
|
|
2298
|
+
* A person moves an agent into a room (ADR 0087, point 4): its runs where it was stop, it is
|
|
2299
|
+
* taken out saying where it went, and it is put here. The owner is the one person here.
|
|
2300
|
+
*/
|
|
2301
|
+
moveAgent(agentId, spaceId) {
|
|
2302
|
+
const refused = this.refuseMove(agentId, spaceId);
|
|
2303
|
+
if (refused !== undefined)
|
|
2304
|
+
return { ok: false, error: refused };
|
|
2305
|
+
const from = this.agentRoom(agentId) ?? null;
|
|
2306
|
+
if (from === spaceId)
|
|
2307
|
+
return { ok: true, moved: false, from };
|
|
2308
|
+
const at = this.seq;
|
|
2309
|
+
const owner = this.owner.participantId;
|
|
2310
|
+
if (from !== null) {
|
|
2311
|
+
this.stopWork(from, { reason: "moved", by: owner, agentId });
|
|
2312
|
+
this.appendAs(this.owner, {
|
|
2313
|
+
type: "membership.removed",
|
|
2314
|
+
space_id: from,
|
|
2315
|
+
idempotency_key: `move-out-${agentId}-${spaceId}-at-${at}`,
|
|
2316
|
+
participant_id: agentId,
|
|
2317
|
+
removed_by: owner,
|
|
2318
|
+
reason: "moved",
|
|
2319
|
+
moved_to: spaceId,
|
|
2320
|
+
});
|
|
2321
|
+
}
|
|
2322
|
+
this.appendAs(this.owner, {
|
|
2323
|
+
type: "membership.added",
|
|
2324
|
+
space_id: spaceId,
|
|
2325
|
+
idempotency_key: `move-in-${agentId}-${spaceId}-at-${at}`,
|
|
2326
|
+
participant_id: agentId,
|
|
2327
|
+
participant_kind: "agent",
|
|
2328
|
+
role: "member",
|
|
2329
|
+
...(from === null ? {} : { moved_from: from }),
|
|
2330
|
+
});
|
|
2331
|
+
return { ok: true, moved: true, from };
|
|
2332
|
+
}
|
|
2333
|
+
/**
|
|
2334
|
+
* The person deletes a room (ADR 0067; ADR 0087, rule 5), as `DELETE /v1/spaces/:id` does on
|
|
2335
|
+
* the real server: every run going in it is stopped while its agents are still there, what was
|
|
2336
|
+
* said in it goes from the log, one `space.erased` stays, and each of its agents is told it is
|
|
2337
|
+
* free. Asked to delete the room's one agent too, Chips archives it, and its harness deletes
|
|
2338
|
+
* its side. The owner is the one person here, and owns the harness.
|
|
2339
|
+
*/
|
|
2340
|
+
eraseSpace(spaceId, options = {}) {
|
|
2341
|
+
const space = this.spaces.get(spaceId);
|
|
2342
|
+
if (space === undefined) {
|
|
2343
|
+
return { ok: false, error: refusal("not_found", "unknown_space", "No such space.") };
|
|
2344
|
+
}
|
|
2345
|
+
const why = eraseRefusal(space);
|
|
2346
|
+
if (why !== undefined)
|
|
2347
|
+
return { ok: false, error: refusal("invalid_event", "invalid", why) };
|
|
2348
|
+
const agents = this.agentsIn(spaceId).map((agent) => agent.agent_id);
|
|
2349
|
+
const deleteAgent = options.deleteAgent === true;
|
|
2350
|
+
if (deleteAgent && agents.length !== 1) {
|
|
2351
|
+
return {
|
|
2352
|
+
ok: false,
|
|
2353
|
+
error: refusal("invalid_event", "invalid", "An agent is deleted with its space only when it is the one there."),
|
|
2354
|
+
};
|
|
2355
|
+
}
|
|
2356
|
+
const owner = this.owner.participantId;
|
|
2357
|
+
this.stopWork(spaceId, { reason: "deleted", by: owner });
|
|
2358
|
+
this.forget(spaceId);
|
|
2359
|
+
this.appendAs(this.owner, {
|
|
2360
|
+
type: "space.erased",
|
|
2361
|
+
idempotency_key: `erase-${spaceId}`,
|
|
2362
|
+
erased: [spaceId],
|
|
2363
|
+
});
|
|
2364
|
+
for (const agentId of agents) {
|
|
2365
|
+
this.appendSystem({
|
|
2366
|
+
type: "agent.released",
|
|
2367
|
+
space_id: spaceId,
|
|
2368
|
+
idempotency_key: `erase-${spaceId}-release-${agentId}`,
|
|
2369
|
+
agent_id: agentId,
|
|
2370
|
+
by: owner,
|
|
2371
|
+
reason: "deleted",
|
|
2372
|
+
...(deleteAgent ? { delete: true } : {}),
|
|
2373
|
+
});
|
|
2374
|
+
if (!deleteAgent)
|
|
2375
|
+
continue;
|
|
2376
|
+
this.appendSystem({
|
|
2377
|
+
type: "agent.updated",
|
|
2378
|
+
idempotency_key: `erase-${spaceId}-archive-${agentId}`,
|
|
2379
|
+
agent_id: agentId,
|
|
2380
|
+
state: "archived",
|
|
2381
|
+
});
|
|
2382
|
+
}
|
|
2383
|
+
return { ok: true };
|
|
2384
|
+
}
|
|
2385
|
+
/** A room gone from the log and from every row built from it, as the real erase leaves it. */
|
|
2386
|
+
forget(spaceId) {
|
|
2387
|
+
for (let at = this.log.length - 1; at >= 0; at -= 1) {
|
|
2388
|
+
if (this.log[at]?.space_id === spaceId)
|
|
2389
|
+
this.log.splice(at, 1);
|
|
2390
|
+
}
|
|
2391
|
+
this.spaces.delete(spaceId);
|
|
2392
|
+
this.replaced.delete(spaceId);
|
|
2393
|
+
for (const rows of [this.runs, this.approvals, this.questions]) {
|
|
2394
|
+
for (const [id, row] of rows)
|
|
2395
|
+
if (row.space_id === spaceId)
|
|
2396
|
+
rows.delete(id);
|
|
2397
|
+
}
|
|
2398
|
+
for (const [id, lane] of this.lanes)
|
|
2399
|
+
if (lane.home === spaceId)
|
|
2400
|
+
this.lanes.delete(id);
|
|
2401
|
+
}
|
|
2402
|
+
/** The newest ask for a copy of this agent in this room, and whether a copy joined for it. */
|
|
2403
|
+
forkAsk(agentId, spaceId) {
|
|
2404
|
+
const event = this.log.findLast((each) => each.type === "agent.fork" &&
|
|
2405
|
+
each.space_id === spaceId &&
|
|
2406
|
+
each.payload["agent_id"] === agentId);
|
|
2407
|
+
if (event === undefined)
|
|
2408
|
+
return undefined;
|
|
2409
|
+
const answered = this.log.some((each) => each.type === "membership.added" &&
|
|
2410
|
+
each.space_id === spaceId &&
|
|
2411
|
+
each.payload["fork_of"] === event.id);
|
|
2412
|
+
return { event, answered };
|
|
2413
|
+
}
|
|
2414
|
+
/** Whether an ask still waits for its copy: none joined, and the minute is not up. */
|
|
2415
|
+
stands(ask) {
|
|
2416
|
+
if (ask === undefined || ask.answered)
|
|
2417
|
+
return false;
|
|
2418
|
+
return this.now().getTime() - Date.parse(ask.event.ts) < FORK_WAIT_MS;
|
|
2419
|
+
}
|
|
2420
|
+
/** Why the owner may not ask for a copy of this agent in this room, or nothing when they may. */
|
|
2421
|
+
refuseFork(agentId, spaceId) {
|
|
2422
|
+
const agent = this.agents.get(agentId);
|
|
2423
|
+
const space = this.spaces.get(spaceId);
|
|
2424
|
+
if (agent === undefined)
|
|
2425
|
+
return refusal("not_found", "unknown_agent", "No agent of that id here.");
|
|
2426
|
+
if (space === undefined)
|
|
2427
|
+
return refusal("not_found", "unknown_space", "No space of that id here.");
|
|
2428
|
+
if (!this.capabilities.includes(HARNESS_CAPABILITIES.forks)) {
|
|
2429
|
+
return refusal("invalid_event", "invalid", "That harness doesn't make copies of its agents.");
|
|
2430
|
+
}
|
|
2431
|
+
const where = this.agentRoom(agentId);
|
|
2432
|
+
const stays = agent.lead || agent.state === "archived" || where === undefined;
|
|
2433
|
+
if (stays || where === spaceId || space.home || space.state !== "open") {
|
|
2434
|
+
return refusal("invalid_event", "invalid", "That agent cannot be forked into that space.");
|
|
2435
|
+
}
|
|
2436
|
+
return undefined;
|
|
2437
|
+
}
|
|
2438
|
+
/**
|
|
2439
|
+
* The owner asks for a copy of an agent in a room (ADR 0087, Fork): `agent.fork` there, which
|
|
2440
|
+
* the harness hears. Asked again while an ask stands, it is the same ask.
|
|
2441
|
+
*/
|
|
2442
|
+
forkAgent(agentId, spaceId) {
|
|
2443
|
+
const refused = this.refuseFork(agentId, spaceId);
|
|
2444
|
+
if (refused !== undefined)
|
|
2445
|
+
return { ok: false, error: refused };
|
|
2446
|
+
const ask = this.forkAsk(agentId, spaceId);
|
|
2447
|
+
if (ask !== undefined && this.stands(ask)) {
|
|
2448
|
+
return { ok: true, asked: false, forkId: ask.event.id };
|
|
2449
|
+
}
|
|
2450
|
+
const asked = this.appendAs(this.owner, {
|
|
2451
|
+
type: "agent.fork",
|
|
2452
|
+
space_id: spaceId,
|
|
2453
|
+
idempotency_key: `fork-${agentId}-${spaceId}-at-${this.seq}`,
|
|
2454
|
+
agent_id: agentId,
|
|
2455
|
+
by: this.owner.participantId,
|
|
2456
|
+
});
|
|
2457
|
+
return { ok: true, asked: true, forkId: asked.id };
|
|
2458
|
+
}
|
|
2459
|
+
/**
|
|
2460
|
+
* The harness's copy, registered for the room it was asked for: in the room while the ask
|
|
2461
|
+
* stands, free otherwise, with why, as the real server answers.
|
|
2462
|
+
*/
|
|
2463
|
+
answerFork(agentId, forkedFrom, spaceId) {
|
|
2464
|
+
const where = this.agentRoom(agentId);
|
|
2465
|
+
if (where === spaceId)
|
|
2466
|
+
return { attached: false };
|
|
2467
|
+
const ask = this.forkAsk(forkedFrom, spaceId);
|
|
2468
|
+
if (ask === undefined) {
|
|
2469
|
+
return { attached: false, why: "Nobody asked for a copy of that agent in that space." };
|
|
2470
|
+
}
|
|
2471
|
+
if (ask.answered)
|
|
2472
|
+
return { attached: false, why: "That space has its copy already." };
|
|
2473
|
+
if (!this.stands(ask)) {
|
|
2474
|
+
return {
|
|
2475
|
+
attached: false,
|
|
2476
|
+
why: "The space stopped waiting for the copy after a minute, so it is free: a person can put it in a space.",
|
|
2477
|
+
};
|
|
2478
|
+
}
|
|
2479
|
+
if (this.spaces.get(spaceId)?.state !== "open") {
|
|
2480
|
+
return {
|
|
2481
|
+
attached: false,
|
|
2482
|
+
why: "The space was archived while it waited, so the copy is free.",
|
|
2483
|
+
};
|
|
2484
|
+
}
|
|
2485
|
+
if (where !== undefined) {
|
|
2486
|
+
return {
|
|
2487
|
+
attached: false,
|
|
2488
|
+
why: "The copy is in another space, and an agent is in one at a time.",
|
|
2489
|
+
};
|
|
2490
|
+
}
|
|
2491
|
+
this.appendAs(this.harness, {
|
|
2492
|
+
type: "membership.added",
|
|
2493
|
+
space_id: spaceId,
|
|
2494
|
+
idempotency_key: `fork-in-${ask.event.id}`,
|
|
2495
|
+
participant_id: agentId,
|
|
2496
|
+
participant_kind: "agent",
|
|
2497
|
+
role: "member",
|
|
2498
|
+
fork_of: ask.event.id,
|
|
2499
|
+
});
|
|
2500
|
+
return { attached: true };
|
|
2501
|
+
}
|
|
2502
|
+
/** A person's ask for a new agent (ADR 0108), by its id, and whether an agent joined for it. */
|
|
2503
|
+
agentAsk(askId) {
|
|
2504
|
+
const event = this.log.find((each) => each.id === askId && each.type === "agent.asked");
|
|
2505
|
+
if (event === undefined)
|
|
2506
|
+
return undefined;
|
|
2507
|
+
const answered = this.log.some((each) => each.type === "membership.added" && each.payload["made_for"] === askId);
|
|
2508
|
+
return { event, answered };
|
|
2509
|
+
}
|
|
2510
|
+
/**
|
|
2511
|
+
* The owner asks the harness for a new agent in a room (ADR 0108): `agent.asked` there, which
|
|
2512
|
+
* the harness hears, under the name given or the harness's own, numbered to a free one.
|
|
2513
|
+
*/
|
|
2514
|
+
askAgent(spaceId, name) {
|
|
2515
|
+
const space = this.spaces.get(spaceId);
|
|
2516
|
+
if (space === undefined) {
|
|
2517
|
+
return {
|
|
2518
|
+
ok: false,
|
|
2519
|
+
error: refusal("not_found", "unknown_space", "No space of that id here."),
|
|
2520
|
+
};
|
|
2521
|
+
}
|
|
2522
|
+
if (!this.capabilities.includes(HARNESS_CAPABILITIES.newAgents)) {
|
|
2523
|
+
return {
|
|
2524
|
+
ok: false,
|
|
2525
|
+
error: refusal("invalid_event", "invalid", "That harness doesn't make new agents for a space."),
|
|
2526
|
+
};
|
|
2527
|
+
}
|
|
2528
|
+
if (space.home || space.state !== "open") {
|
|
2529
|
+
return {
|
|
2530
|
+
ok: false,
|
|
2531
|
+
error: refusal("invalid_event", "invalid", "A new agent cannot be asked for in that space."),
|
|
2532
|
+
};
|
|
2533
|
+
}
|
|
2534
|
+
const problem = name === undefined ? undefined : nameProblem(name.trim());
|
|
2535
|
+
if (problem !== undefined) {
|
|
2536
|
+
return { ok: false, error: refusal("invalid_event", "invalid", problem) };
|
|
2537
|
+
}
|
|
2538
|
+
const wanted = name?.trim() || this.harness.displayName;
|
|
2539
|
+
const named = freeAgentName(wanted.slice(0, 120).trim(), this.takenNames());
|
|
2540
|
+
const asked = this.appendAs(this.owner, {
|
|
2541
|
+
type: "agent.asked",
|
|
2542
|
+
space_id: spaceId,
|
|
2543
|
+
idempotency_key: `ask-${spaceId}-at-${this.seq}`,
|
|
2544
|
+
harness_id: this.harness.participantId,
|
|
2545
|
+
name: named,
|
|
2546
|
+
by: this.owner.participantId,
|
|
2547
|
+
});
|
|
2548
|
+
return { ok: true, askId: asked.id, name: named };
|
|
2549
|
+
}
|
|
2550
|
+
/**
|
|
2551
|
+
* The harness's new agent, registered for the ask it answers: in the ask's room while the ask
|
|
2552
|
+
* stands, free otherwise, with why, as the real server answers.
|
|
2553
|
+
*/
|
|
2554
|
+
answerAsk(agentId, askId, spaceId) {
|
|
2555
|
+
const where = this.agentRoom(agentId);
|
|
2556
|
+
if (where === spaceId)
|
|
2557
|
+
return { attached: false };
|
|
2558
|
+
const ask = this.agentAsk(askId);
|
|
2559
|
+
if (ask === undefined || ask.event.space_id !== spaceId) {
|
|
2560
|
+
return { attached: false, why: "Nobody asked this harness for an agent in that space." };
|
|
2561
|
+
}
|
|
2562
|
+
if (ask.answered)
|
|
2563
|
+
return { attached: false, why: "That ask has its agent already." };
|
|
2564
|
+
if (this.now().getTime() - Date.parse(ask.event.ts) >= NEW_AGENT_WAIT_MS) {
|
|
2565
|
+
return {
|
|
2566
|
+
attached: false,
|
|
2567
|
+
why: "The space stopped waiting for the agent after a minute, so it is free: a person can put it in a space.",
|
|
2568
|
+
};
|
|
2569
|
+
}
|
|
2570
|
+
if (this.spaces.get(spaceId)?.state !== "open") {
|
|
2571
|
+
return {
|
|
2572
|
+
attached: false,
|
|
2573
|
+
why: "The space was archived while it waited, so the agent is free.",
|
|
2574
|
+
};
|
|
2575
|
+
}
|
|
2576
|
+
if (where !== undefined) {
|
|
2577
|
+
return {
|
|
2578
|
+
attached: false,
|
|
2579
|
+
why: "The agent is in another space, and an agent is in one at a time.",
|
|
2580
|
+
};
|
|
2581
|
+
}
|
|
2582
|
+
this.appendAs(this.harness, {
|
|
2583
|
+
type: "membership.added",
|
|
2584
|
+
space_id: spaceId,
|
|
2585
|
+
idempotency_key: `made-in-${askId}`,
|
|
2586
|
+
participant_id: agentId,
|
|
2587
|
+
participant_kind: "agent",
|
|
2588
|
+
role: "member",
|
|
2589
|
+
made_for: askId,
|
|
2590
|
+
});
|
|
2591
|
+
return { attached: true };
|
|
2592
|
+
}
|
|
2593
|
+
/** Whether this run is a lane's: work the harness routed to one of its own rooms (M3.5). */
|
|
2594
|
+
isLaneRun(runId) {
|
|
2595
|
+
return runId !== null && [...this.lanes.values()].some((lane) => lane.run_id === runId);
|
|
2596
|
+
}
|
|
2597
|
+
/** The run of this harness in this space that has not ended, if there is one. */
|
|
2598
|
+
liveRunIn(spaceId, harnessId) {
|
|
2599
|
+
return [...this.runs.values()].find((run) => run.space_id === spaceId && run.harness_id === harnessId && !TERMINAL.has(run.state));
|
|
2600
|
+
}
|
|
2601
|
+
roleOf(spaceId, participantId) {
|
|
2602
|
+
return this.spaces.get(spaceId)?.members.get(participantId);
|
|
2603
|
+
}
|
|
2604
|
+
/** The rooms a participant is in: a harness's, its agents' too (ADR 0087). */
|
|
2605
|
+
memberSpacesOf(participantId) {
|
|
2606
|
+
const selves = participantId === this.harness.participantId
|
|
2607
|
+
? [participantId, ...this.agents.keys()]
|
|
2608
|
+
: [participantId];
|
|
2609
|
+
return [...this.spaces.values()]
|
|
2610
|
+
.filter((space) => selves.some((self) => space.members.has(self)))
|
|
2611
|
+
.map((space) => space.space_id);
|
|
2612
|
+
}
|
|
2613
|
+
/** The harness's agents in a room, in the order they came. */
|
|
2614
|
+
agentsIn(spaceId) {
|
|
2615
|
+
const members = this.spaces.get(spaceId)?.members;
|
|
2616
|
+
if (members === undefined)
|
|
2617
|
+
return [];
|
|
2618
|
+
return [...members.keys()]
|
|
2619
|
+
.map((id) => this.agents.get(id))
|
|
2620
|
+
.filter((agent) => agent !== undefined);
|
|
2621
|
+
}
|
|
2622
|
+
/**
|
|
2623
|
+
* A room's members as the room note lists them (ADR 0092): in the order they joined, the harness
|
|
2624
|
+
* in its own right as an agent, and each agent with its harness's name (ADR 0101), as the
|
|
2625
|
+
* server lists them.
|
|
2626
|
+
*/
|
|
2627
|
+
noteMembers(spaceId) {
|
|
2628
|
+
const members = this.spaces.get(spaceId)?.members;
|
|
2629
|
+
if (members === undefined)
|
|
2630
|
+
return [];
|
|
2631
|
+
const lead = this.leadOf(spaceId);
|
|
2632
|
+
return [...members.keys()].flatMap((id) => {
|
|
2633
|
+
if (id === this.owner.participantId) {
|
|
2634
|
+
return [{ id, kind: "person", name: this.owner.displayName }];
|
|
2635
|
+
}
|
|
2636
|
+
const leads = id === lead ? { lead: true } : {};
|
|
2637
|
+
if (id === this.harness.participantId) {
|
|
2638
|
+
// Waiting under the first name the room holds for its agent (ADR 0150).
|
|
2639
|
+
const held = this.holds.get(spaceId)?.name;
|
|
2640
|
+
return held === undefined
|
|
2641
|
+
? [{ id, kind: "agent", name: this.harness.displayName, ...leads }]
|
|
2642
|
+
: [{ id, kind: "agent", name: held, harness: this.harness.displayName, ...leads }];
|
|
2643
|
+
}
|
|
2644
|
+
const agent = this.agents.get(id);
|
|
2645
|
+
return agent === undefined
|
|
2646
|
+
? []
|
|
2647
|
+
: [{ id, kind: "agent", name: agent.name, harness: this.harness.displayName, ...leads }];
|
|
2648
|
+
});
|
|
2649
|
+
}
|
|
2650
|
+
/** The people, the harness and its agents sharing a space with this participant, themselves included. */
|
|
2651
|
+
participantsVisibleTo(participantId) {
|
|
2652
|
+
const mine = new Set(this.memberSpacesOf(participantId));
|
|
2653
|
+
const shares = (id) => this.spaces.size > 0 &&
|
|
2654
|
+
[...this.spaces.values()].some((space) => mine.has(space.space_id) && space.members.has(id));
|
|
2655
|
+
const people = [this.owner, this.harness]
|
|
2656
|
+
.filter((each) => shares(each.participantId))
|
|
2657
|
+
.map((each) => ({
|
|
2658
|
+
participantId: each.participantId,
|
|
2659
|
+
kind: each.kind,
|
|
2660
|
+
displayName: each.displayName,
|
|
2661
|
+
}));
|
|
2662
|
+
const agents = [...this.agents.values()]
|
|
2663
|
+
.filter((agent) => shares(agent.agent_id))
|
|
2664
|
+
.map((agent) => ({
|
|
2665
|
+
participantId: agent.agent_id,
|
|
2666
|
+
kind: "agent",
|
|
2667
|
+
displayName: agent.name,
|
|
2668
|
+
face: agent.face ?? null,
|
|
2669
|
+
}));
|
|
2670
|
+
return [...people, ...agents];
|
|
2671
|
+
}
|
|
2672
|
+
/**
|
|
2673
|
+
* Who may see an event: the real server's rule, which is the protocol's own function, so the
|
|
2674
|
+
* two cannot differ on it (phase-0-review finding 6; agents review, finding 37).
|
|
2675
|
+
*/
|
|
2676
|
+
canSee(event, viewer) {
|
|
2677
|
+
return canSee(event, viewer);
|
|
2678
|
+
}
|
|
2679
|
+
/**
|
|
2680
|
+
* What a participant is handed with the message at `seq` (ADR 0088), as the real server works it
|
|
2681
|
+
* out: the room's lines since it last spoke there, else since it joined, as the room reads now,
|
|
2682
|
+
* the newest that fit its budget. Nothing for a budget of no lines.
|
|
2683
|
+
*/
|
|
2684
|
+
contextFor(participantIds, spaceId, seq, budget, sign) {
|
|
2685
|
+
if (budget.lines === 0)
|
|
2686
|
+
return undefined;
|
|
2687
|
+
// An agent is asked about with the harness whose place it took there (ADR 0087).
|
|
2688
|
+
const replaced = [...(this.replaced.get(spaceId) ?? new Map())];
|
|
2689
|
+
const before = this.log.filter((event) => event.seq < seq && event.space_id === spaceId);
|
|
2690
|
+
// One context for all of them, from the one that has heard least (agents review, finding 15).
|
|
2691
|
+
const selves = new Set();
|
|
2692
|
+
const marks = [...new Set(participantIds)].map((id) => {
|
|
2693
|
+
const ids = new Set([id, ...replaced.filter(([, to]) => to === id).map(([from]) => from)]);
|
|
2694
|
+
for (const each of ids)
|
|
2695
|
+
selves.add(each);
|
|
2696
|
+
const spoke = before.findLast((event) => event.type === "message.post" && ids.has(event.actor.id));
|
|
2697
|
+
const joined = before.findLast((event) => (event.type === "membership.added" && ids.has(String(event.payload["participant_id"]))) ||
|
|
2698
|
+
(event.type === "space.created" && ids.has(event.actor.id)));
|
|
2699
|
+
return (spoke === undefined ? undefined : this.turnBegan(spoke)) ?? joined?.seq ?? 0;
|
|
2700
|
+
});
|
|
2701
|
+
const since = marks.length === 0 ? 0 : Math.min(...marks);
|
|
2702
|
+
// From where the turn that said its last words began (ADR 0105), without one taker's own
|
|
2703
|
+
// words; several are each owed what the others said.
|
|
2704
|
+
const lines = this.roomLines(spaceId, since, seq).filter((line) => marks.length > 1 || line.kind !== "said" || !selves.has(line.from.id));
|
|
2705
|
+
return this.withFiles(contextLines(lines, budget, { since, before: seq }), spaceId, seq, sign);
|
|
2706
|
+
}
|
|
2707
|
+
/** Where the turn that said these words began: its run's first event, else the words. */
|
|
2708
|
+
turnBegan(said) {
|
|
2709
|
+
if (said.run_id === null)
|
|
2710
|
+
return said.seq;
|
|
2711
|
+
const began = this.log.find((event) => event.run_id === said.run_id);
|
|
2712
|
+
return Math.min(said.seq, began?.seq ?? said.seq);
|
|
2713
|
+
}
|
|
2714
|
+
/**
|
|
2715
|
+
* The message a reply answers, as the real server hands it over (ADR 0219): its line as the room
|
|
2716
|
+
* reads now, of one taken back only who and when, its files signed, and beside a turn's context
|
|
2717
|
+
* (`handed`), when it is older than those lines, the lines said just before it. Nothing for a
|
|
2718
|
+
* message not said in this room.
|
|
2719
|
+
*/
|
|
2720
|
+
replyingToFor(spaceId, replyTo, handed, sign) {
|
|
2721
|
+
const said = this.saidMessage(replyTo);
|
|
2722
|
+
if (said === undefined || said.space_id !== spaceId)
|
|
2723
|
+
return undefined;
|
|
2724
|
+
const changes = this.log.filter((event) => event.payload["message_id"] === replyTo);
|
|
2725
|
+
const deleted = changes.some((event) => event.type === "message.deleted");
|
|
2726
|
+
const edited = changes.some((event) => event.type === "message.edited");
|
|
2727
|
+
// A message taken back is no line of the room's any more: its line as said, words dropped.
|
|
2728
|
+
const line = deleted
|
|
2729
|
+
? this.linesAsNow(spaceId, [said])[0]
|
|
2730
|
+
: this.roomLines(spaceId, said.seq - 1, said.seq + 1).find((each) => each.message_id === replyTo);
|
|
2731
|
+
if (line === undefined)
|
|
2732
|
+
return undefined;
|
|
2733
|
+
const before = quotesBefore(said.seq, handed)
|
|
2734
|
+
? contextLines(this.roomLines(spaceId, 0, said.seq), { lines: REPLY_BEFORE_LINES, chars: REPLY_QUOTE_CHARS }, { since: 0, before: said.seq }).lines
|
|
2735
|
+
: undefined;
|
|
2736
|
+
const quote = quoteOf({ ...line, message_id: replyTo }, { edited, deleted }, before);
|
|
2737
|
+
const files = deleted ? [] : signedFilesOf(said.payload, sign);
|
|
2738
|
+
return files.length === 0 ? quote : { ...quote, attachments: files };
|
|
2739
|
+
}
|
|
2740
|
+
/**
|
|
2741
|
+
* What an agent is handed as it joins a room (ADR 0088, point 5): the room's last lines before it
|
|
2742
|
+
* joined, within the budget. Nothing for a budget of no lines.
|
|
2743
|
+
*/
|
|
2744
|
+
catchUpFor(spaceId, seq, budget, sign) {
|
|
2745
|
+
if (budget.lines === 0)
|
|
2746
|
+
return undefined;
|
|
2747
|
+
const lines = contextLines(this.roomLines(spaceId, 0, seq), budget, { since: 0, before: seq });
|
|
2748
|
+
return this.withFiles(lines, spaceId, seq, sign);
|
|
2749
|
+
}
|
|
2750
|
+
/** A page of a room's lines before a cursor, and its files now, for `GET /v1/spaces/:id/context`. */
|
|
2751
|
+
contextPage(spaceId, before, limit, sign) {
|
|
2752
|
+
const lines = Math.min(Math.max(limit, 1), CONTEXT_CEILING.lines);
|
|
2753
|
+
const page = contextLines(this.roomLines(spaceId, 0, before), { lines, chars: CONTEXT_CEILING.chars }, { since: 0, before });
|
|
2754
|
+
return this.withFiles(page, spaceId, undefined, sign);
|
|
2755
|
+
}
|
|
2756
|
+
/**
|
|
2757
|
+
* A room's files at their current version, newest first (ADR 0099): as of the event at `upto`,
|
|
2758
|
+
* with it, or as of now. Who posted each as the room reads now.
|
|
2759
|
+
*/
|
|
2760
|
+
roomFiles(spaceId, most, upto, sign) {
|
|
2761
|
+
return roomFilesInLog(this.log, { spaceId, most, upto, sign, by: this.postedBy(spaceId) });
|
|
2762
|
+
}
|
|
2763
|
+
/** One name's versions in a room, newest first, each with what it was made from (ADR 0100). */
|
|
2764
|
+
fileVersions(spaceId, name, most, sign) {
|
|
2765
|
+
return fileVersionsInLog(this.log, { spaceId, name, most, sign, by: this.postedBy(spaceId) });
|
|
2766
|
+
}
|
|
2767
|
+
/** Who posted a room's file, as the room reads now. */
|
|
2768
|
+
postedBy(spaceId) {
|
|
2769
|
+
const people = this.whoIs();
|
|
2770
|
+
const now = (id) => this.replaced.get(spaceId)?.get(id) ?? id;
|
|
2771
|
+
return (posted) => {
|
|
2772
|
+
const id = now(posted);
|
|
2773
|
+
const known = people.get(id);
|
|
2774
|
+
const kind = known?.kind;
|
|
2775
|
+
return {
|
|
2776
|
+
id,
|
|
2777
|
+
kind: kind === "person" || kind === "agent" || kind === "system" ? kind : "harness",
|
|
2778
|
+
name: known?.name ?? id,
|
|
2779
|
+
};
|
|
2780
|
+
};
|
|
2781
|
+
}
|
|
2782
|
+
withFiles(context, spaceId, upto, sign) {
|
|
2783
|
+
const files = this.roomFiles(spaceId, ROOM_FILES_IN_CONTEXT, upto, sign);
|
|
2784
|
+
return files.length === 0 ? context : { ...context, files };
|
|
2785
|
+
}
|
|
2786
|
+
/** Who everybody the fake knows is now: the owner, the harness and its agents. */
|
|
2787
|
+
whoIs() {
|
|
2788
|
+
const people = new Map([
|
|
2789
|
+
// The owner's standing rides with the name (SEC-15), as the real server's lines carry it.
|
|
2790
|
+
[
|
|
2791
|
+
this.owner.participantId,
|
|
2792
|
+
{ kind: this.owner.kind, name: this.owner.displayName, workspace_role: "owner" },
|
|
2793
|
+
],
|
|
2794
|
+
[
|
|
2795
|
+
this.harness.participantId,
|
|
2796
|
+
{ kind: this.harness.kind, name: this.harness.displayName },
|
|
2797
|
+
],
|
|
2798
|
+
]);
|
|
2799
|
+
for (const agent of this.agents.values()) {
|
|
2800
|
+
people.set(agent.agent_id, { kind: "agent", name: agent.name });
|
|
2801
|
+
}
|
|
2802
|
+
return people;
|
|
2803
|
+
}
|
|
2804
|
+
/** A room's lines between two `seq`s, with every edit and deletion there is folded in. */
|
|
2805
|
+
roomLines(spaceId, after, before) {
|
|
2806
|
+
const members = this.spaces.get(spaceId)?.members;
|
|
2807
|
+
const inWindow = (seq) => seq > after && seq < before;
|
|
2808
|
+
const events = this.log.filter((event) => event.type === "message.edited" ||
|
|
2809
|
+
event.type === "message.deleted" ||
|
|
2810
|
+
(event.space_id === spaceId && inWindow(event.seq)) ||
|
|
2811
|
+
(event.type === "person.renamed" &&
|
|
2812
|
+
inWindow(event.seq) &&
|
|
2813
|
+
members?.has(String(event.payload["participant_id"])) === true));
|
|
2814
|
+
return this.linesAsNow(spaceId, events);
|
|
2815
|
+
}
|
|
2816
|
+
/** Events as lines, as a room reads them now: whose place each took, and who each is. */
|
|
2817
|
+
linesAsNow(spaceId, events) {
|
|
2818
|
+
const people = this.whoIs();
|
|
2819
|
+
// What the harness said here before its agent took its place is the agent's.
|
|
2820
|
+
const replaced = this.replaced.get(spaceId);
|
|
2821
|
+
const now = (id) => replaced?.get(id) ?? id;
|
|
2822
|
+
const read = replaced === undefined ? events : events.map((event) => asNow(event, now));
|
|
2823
|
+
// Each author with their standing in this room beside their name (SEC-15), as the server's.
|
|
2824
|
+
return linesOf(read, (id) => {
|
|
2825
|
+
const known = people.get(id);
|
|
2826
|
+
const role = this.roleOf(spaceId, id);
|
|
2827
|
+
return known === undefined
|
|
2828
|
+
? undefined
|
|
2829
|
+
: { ...known, ...(role === undefined ? {} : { role }) };
|
|
2830
|
+
});
|
|
2831
|
+
}
|
|
2832
|
+
/** A page of the log after `cursor`, as this viewer may see it. */
|
|
2833
|
+
since(cursor, limit, viewer) {
|
|
2834
|
+
const visible = this.log.filter((event) => event.seq > cursor && this.canSee(event, viewer));
|
|
2835
|
+
const page = visible.slice(0, limit);
|
|
2836
|
+
const last = page.at(-1);
|
|
2837
|
+
return {
|
|
2838
|
+
events: page,
|
|
2839
|
+
nextCursor: visible.length > page.length && last !== undefined ? last.seq : null,
|
|
2840
|
+
};
|
|
2841
|
+
}
|
|
2842
|
+
/**
|
|
2843
|
+
* The space a harness works in, by its own key: found when it exists, opened for its owner
|
|
2844
|
+
* when it does not (ADR 0024). The key is stored prefixed with the harness's id, as the real
|
|
2845
|
+
* server stores it, so no adapter can name another's space; a shared key — a worktree — is
|
|
2846
|
+
* stored under its own namespace, which a second harness would find (ADR 0033). With one
|
|
2847
|
+
* harness here there is nobody to join it, and `joined` is always false.
|
|
2848
|
+
*/
|
|
2849
|
+
openSpace(key, title, shared = false) {
|
|
2850
|
+
const external = shared ? `shared:${key}` : `${this.harness.participantId}:${key}`;
|
|
2851
|
+
const existing = [...this.spaces.values()].find((space) => space.external_key === external);
|
|
2852
|
+
if (existing !== undefined)
|
|
2853
|
+
return { spaceId: existing.space_id, created: false, joined: false };
|
|
2854
|
+
const spaceId = this.ids.next("space");
|
|
2855
|
+
this.appendAs(this.harness, {
|
|
2856
|
+
type: "space.created",
|
|
2857
|
+
space_id: spaceId,
|
|
2858
|
+
idempotency_key: `${spaceId}-created`,
|
|
2859
|
+
kind: "direct",
|
|
2860
|
+
...(title === undefined ? {} : { title }),
|
|
2861
|
+
external_key: external,
|
|
2862
|
+
opened_for: this.owner.participantId,
|
|
2863
|
+
});
|
|
2864
|
+
this.addMembers(spaceId, this.harness);
|
|
2865
|
+
this.agentInPlace(spaceId, key, title);
|
|
2866
|
+
return { spaceId, created: true, joined: false };
|
|
2867
|
+
}
|
|
2868
|
+
/**
|
|
2869
|
+
* The room a person asked the harness for, given the harness's key as `space.claimed`, as the
|
|
2870
|
+
* real space door claims it when it is sent a `space_id`: only a room asked of this harness,
|
|
2871
|
+
* that it is still in and that has no key, under a key no other room holds. A retry with the
|
|
2872
|
+
* key that claimed it finds it. The title goes with the claim unless a person named the room.
|
|
2873
|
+
* A refusal is kept with the others, for the report.
|
|
2874
|
+
*/
|
|
2875
|
+
claimSpace(spaceId, key, title) {
|
|
2876
|
+
const external = `${this.harness.participantId}:${key}`;
|
|
2877
|
+
const asked = this.spaces.get(spaceId);
|
|
2878
|
+
if (asked?.also_asked_of.includes(this.harness.participantId) === true) {
|
|
2879
|
+
return this.answer(asked, key);
|
|
2880
|
+
}
|
|
2881
|
+
const refused = this.refuseClaim(spaceId, external) ?? this.refuseElsewhere(key, spaceId);
|
|
2882
|
+
if (refused !== undefined) {
|
|
2883
|
+
this.refusals.push({ ...refused, at: this.nowIso(), who: "harness", type: "space.claimed" });
|
|
2884
|
+
return { ok: false, error: refused };
|
|
2885
|
+
}
|
|
2886
|
+
const row = this.spaces.get(spaceId);
|
|
2887
|
+
if (row?.external_key === external) {
|
|
2888
|
+
return { ok: true, value: { spaceId, claimed: false, retitled: false } };
|
|
2889
|
+
}
|
|
2890
|
+
const named = title === undefined || row?.title_by === "person" || row?.title === title ? undefined : title;
|
|
2891
|
+
this.appendAs(this.harness, {
|
|
2892
|
+
type: "space.claimed",
|
|
2893
|
+
space_id: spaceId,
|
|
2894
|
+
idempotency_key: `space-${spaceId}-claimed`,
|
|
2895
|
+
external_key: external,
|
|
2896
|
+
...(named === undefined ? {} : { title: named }),
|
|
2897
|
+
});
|
|
2898
|
+
this.agentInPlace(spaceId, key, named ?? row?.title);
|
|
2899
|
+
this.leadFromClaim(spaceId);
|
|
2900
|
+
return { ok: true, value: { spaceId, claimed: true, retitled: named !== undefined } };
|
|
2901
|
+
}
|
|
2902
|
+
/**
|
|
2903
|
+
* A room claimed from the + is led by the agent that took the harness's place in it (ADR 0104),
|
|
2904
|
+
* unless a person chose a lead first: as the real door has it.
|
|
2905
|
+
*/
|
|
2906
|
+
leadFromClaim(spaceId) {
|
|
2907
|
+
if (this.log.some((event) => event.type === "space.lead_set" && event.space_id === spaceId)) {
|
|
2908
|
+
return;
|
|
2909
|
+
}
|
|
2910
|
+
const placed = this.log.findLast((event) => event.type === "membership.replaced" && event.space_id === spaceId)?.payload["to"];
|
|
2911
|
+
if (typeof placed !== "string" || this.spaces.get(spaceId)?.members.has(placed) !== true) {
|
|
2912
|
+
return;
|
|
2913
|
+
}
|
|
2914
|
+
this.appendOnce({
|
|
2915
|
+
type: "space.lead_set",
|
|
2916
|
+
space_id: spaceId,
|
|
2917
|
+
idempotency_key: `lead-claimed-${spaceId}`,
|
|
2918
|
+
agent_id: placed,
|
|
2919
|
+
});
|
|
2920
|
+
}
|
|
2921
|
+
refuseClaim(spaceId, external) {
|
|
2922
|
+
const row = this.spaces.get(spaceId);
|
|
2923
|
+
if (row === undefined)
|
|
2924
|
+
return refusal("not_found", "unknown_space", "No space by that id here.");
|
|
2925
|
+
if (row.asked_of !== this.harness.participantId) {
|
|
2926
|
+
return refusal("forbidden", "not_asked", "Nobody asked this harness for that space, so it is not its to claim.");
|
|
2927
|
+
}
|
|
2928
|
+
if (row.external_key === external)
|
|
2929
|
+
return undefined;
|
|
2930
|
+
if (row.external_key !== undefined) {
|
|
2931
|
+
return refusal("invalid_event", "taken", "That space was claimed already, under another key.");
|
|
2932
|
+
}
|
|
2933
|
+
if (!row.members.has(this.harness.participantId)) {
|
|
2934
|
+
return refusal("forbidden", "not_asked", "This harness was taken out of that space, so it cannot claim it.");
|
|
2935
|
+
}
|
|
2936
|
+
const holder = [...this.spaces.values()].find((each) => each.external_key === external);
|
|
2937
|
+
return holder === undefined
|
|
2938
|
+
? undefined
|
|
2939
|
+
: refusal("invalid_event", "taken", `That key has a space already: ${holder.space_id}. Ask for it by the key alone.`);
|
|
2940
|
+
}
|
|
2941
|
+
/** Every room the harness opened or claimed by key, and whether each is on the list. */
|
|
2942
|
+
spacesOpened() {
|
|
2943
|
+
const harness = this.harness.participantId;
|
|
2944
|
+
return [...this.spaces.values()]
|
|
2945
|
+
.filter((row) => row.opened_by === harness && row.external_key !== undefined)
|
|
2946
|
+
.map((row) => ({
|
|
2947
|
+
space_id: row.space_id,
|
|
2948
|
+
...unscoped(harness, row.external_key ?? ""),
|
|
2949
|
+
title: row.title ?? null,
|
|
2950
|
+
state: row.state,
|
|
2951
|
+
...(row.state_by === undefined ? {} : { moved_by: row.state_by }),
|
|
2952
|
+
}));
|
|
2953
|
+
}
|
|
2954
|
+
/**
|
|
2955
|
+
* The rooms people asked the harness for that it has not claimed, and is still in as itself:
|
|
2956
|
+
* asked first, or beside another (ADR 0087's addendum of 30 September).
|
|
2957
|
+
*/
|
|
2958
|
+
spacesAsked() {
|
|
2959
|
+
const harness = this.harness.participantId;
|
|
2960
|
+
return [...this.spaces.values()]
|
|
2961
|
+
.filter((row) => row.members.has(harness) &&
|
|
2962
|
+
((row.asked_of === harness && row.external_key === undefined) ||
|
|
2963
|
+
row.also_asked_of.includes(harness)))
|
|
2964
|
+
.map((row) => ({
|
|
2965
|
+
space_id: row.space_id,
|
|
2966
|
+
asked_by: row.opened_by,
|
|
2967
|
+
title: row.title ?? null,
|
|
2968
|
+
state: row.state,
|
|
2969
|
+
asked_at: row.created_at,
|
|
2970
|
+
}));
|
|
2971
|
+
}
|
|
2972
|
+
/** The direct space `POST /v1/harnesses` opens: the owner and the harness, and nobody else. */
|
|
2973
|
+
createDirectSpace() {
|
|
2974
|
+
const spaceId = this.ids.next("space");
|
|
2975
|
+
this.appendAs(this.owner, {
|
|
2976
|
+
type: "space.created",
|
|
2977
|
+
space_id: spaceId,
|
|
2978
|
+
idempotency_key: `${spaceId}-created`,
|
|
2979
|
+
kind: "direct",
|
|
2980
|
+
});
|
|
2981
|
+
// The owner, and the harness's default agent, which it speaks as there (ADR 0087).
|
|
2982
|
+
const agent = this.agentForRoom(undefined, undefined);
|
|
2983
|
+
this.addMembers(spaceId, this.owner, { participantId: agent.agent_id, kind: "agent" });
|
|
2984
|
+
return spaceId;
|
|
2985
|
+
}
|
|
2986
|
+
addMembers(spaceId, by, beside = this.harness) {
|
|
2987
|
+
const members = [
|
|
2988
|
+
[this.owner, "owner"],
|
|
2989
|
+
[beside, "member"],
|
|
2990
|
+
];
|
|
2991
|
+
for (const [who, role] of members) {
|
|
2992
|
+
this.appendAs(by, {
|
|
2993
|
+
type: "membership.added",
|
|
2994
|
+
space_id: spaceId,
|
|
2995
|
+
idempotency_key: `${spaceId}-member-${who.participantId}`,
|
|
2996
|
+
participant_id: who.participantId,
|
|
2997
|
+
participant_kind: who.kind,
|
|
2998
|
+
role,
|
|
2999
|
+
});
|
|
3000
|
+
}
|
|
3001
|
+
}
|
|
3002
|
+
/** A workspace event Chips itself appends: filled in and validated, past the door's rules. */
|
|
3003
|
+
appendAs(principal, event) {
|
|
3004
|
+
const draft = this.draftOf(principal, event, {});
|
|
3005
|
+
const parsed = parseEvent(draft);
|
|
3006
|
+
if (!parsed.ok)
|
|
3007
|
+
throw new Error(`the fake wrote an invalid ${String(event["type"])}: ${parsed.error.message}`);
|
|
3008
|
+
return this.append(parsed.value);
|
|
3009
|
+
}
|
|
3010
|
+
// ---- uploads ----
|
|
3011
|
+
newUpload(principal, filename) {
|
|
3012
|
+
const row = {
|
|
3013
|
+
attachment_id: this.ids.next("attachment"),
|
|
3014
|
+
filename,
|
|
3015
|
+
uploaded_by: principal.participantId,
|
|
3016
|
+
stored: undefined,
|
|
3017
|
+
};
|
|
3018
|
+
this.uploads.set(row.attachment_id, row);
|
|
3019
|
+
return row;
|
|
3020
|
+
}
|
|
3021
|
+
storeUpload(principal, id, bytes) {
|
|
3022
|
+
const row = this.uploads.get(id);
|
|
3023
|
+
if (row === undefined) {
|
|
3024
|
+
return {
|
|
3025
|
+
ok: false,
|
|
3026
|
+
error: refusal("not_found", "unknown_slot", "No upload slot with that id."),
|
|
3027
|
+
};
|
|
3028
|
+
}
|
|
3029
|
+
if (row.uploaded_by !== principal.participantId) {
|
|
3030
|
+
return {
|
|
3031
|
+
ok: false,
|
|
3032
|
+
error: refusal("forbidden", "not_yours", "That upload slot is not yours."),
|
|
3033
|
+
};
|
|
3034
|
+
}
|
|
3035
|
+
const extension = row.filename.split(".").pop()?.toLowerCase() ?? "";
|
|
3036
|
+
row.stored = {
|
|
3037
|
+
bytes,
|
|
3038
|
+
mime_type: MIME_BY_EXTENSION[extension] ?? "application/octet-stream",
|
|
3039
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
3040
|
+
byte_size: bytes.byteLength,
|
|
3041
|
+
};
|
|
3042
|
+
return { ok: true, row };
|
|
3043
|
+
}
|
|
3044
|
+
}
|
|
3045
|
+
/** Who did it, in the words `title_by` and `state_by` use. */
|
|
3046
|
+
/**
|
|
3047
|
+
* Whose side changed a room: a harness's, when it or one of its agents did. An agent's change
|
|
3048
|
+
* read as a person's, so a harness's own archive was listed as a person's (`moved_by`), and an
|
|
3049
|
+
* adapter that follows its rooms would have taken its own archive for the person's word (agents
|
|
3050
|
+
* review, finding 37).
|
|
3051
|
+
*/
|
|
3052
|
+
function byWhom(event) {
|
|
3053
|
+
return event.actor.kind === "harness" || event.actor.kind === "agent" ? "harness" : "person";
|
|
3054
|
+
}
|
|
3055
|
+
/** A space as it is opened. Whoever opened it is its first member (ADR 0018); a harness's opening refines that. */
|
|
3056
|
+
function createdSpace(spaceId, event) {
|
|
3057
|
+
const p = event.payload;
|
|
3058
|
+
const title = typeof p["title"] === "string" ? p["title"] : undefined;
|
|
3059
|
+
return {
|
|
3060
|
+
space_id: spaceId,
|
|
3061
|
+
kind: typeof p["kind"] === "string" ? p["kind"] : "group",
|
|
3062
|
+
title,
|
|
3063
|
+
title_by: title === undefined ? undefined : byWhom(event),
|
|
3064
|
+
external_key: typeof p["external_key"] === "string" ? p["external_key"] : undefined,
|
|
3065
|
+
opened_by: event.actor.id,
|
|
3066
|
+
asked_of: typeof p["asked_of"] === "string" ? p["asked_of"] : undefined,
|
|
3067
|
+
also_asked_of: Array.isArray(p["also_asked_of"]) ? p["also_asked_of"] : [],
|
|
3068
|
+
home: false,
|
|
3069
|
+
state: "open",
|
|
3070
|
+
state_by: undefined,
|
|
3071
|
+
created_at: event.ts,
|
|
3072
|
+
members: new Map([[event.actor.id, "owner"]]),
|
|
3073
|
+
};
|
|
3074
|
+
}
|
|
3075
|
+
/** A space after a retitle, an archive or a claim. */
|
|
3076
|
+
function changedSpace(row, event) {
|
|
3077
|
+
const p = event.payload;
|
|
3078
|
+
const title = typeof p["title"] === "string" ? p["title"] : undefined;
|
|
3079
|
+
const state = p["state"] === "closed" || p["state"] === "open" ? p["state"] : undefined;
|
|
3080
|
+
return {
|
|
3081
|
+
...row,
|
|
3082
|
+
...(title === undefined ? {} : { title, title_by: byWhom(event) }),
|
|
3083
|
+
...(state === undefined ? {} : { state, state_by: byWhom(event) }),
|
|
3084
|
+
...(typeof p["home"] === "boolean" ? { home: p["home"] } : {}),
|
|
3085
|
+
// The harness that claims a room is its opener from then on, as if it had opened it.
|
|
3086
|
+
...(event.type === "space.claimed"
|
|
3087
|
+
? { external_key: String(p["external_key"]), opened_by: event.actor.id }
|
|
3088
|
+
: {}),
|
|
3089
|
+
};
|
|
3090
|
+
}
|
|
3091
|
+
/**
|
|
3092
|
+
* A harness changes a space it opened. It may also name as its home the direct room it was handed
|
|
3093
|
+
* when it was added, which a person opened, and never a group room (ADR 0069): the real door's
|
|
3094
|
+
* rule. The fake refused a harness naming its home at all, so a Main room could not be made here
|
|
3095
|
+
* as the server makes one (agents review, finding 37).
|
|
3096
|
+
*/
|
|
3097
|
+
function refuseHarnessOnSpaceUpdate(event, sender, role, space) {
|
|
3098
|
+
if (space.opened_by === sender.participantId)
|
|
3099
|
+
return undefined;
|
|
3100
|
+
const homeOnly = event["title"] === undefined && event["state"] === undefined;
|
|
3101
|
+
if (homeOnly && space.kind === "direct" && role !== undefined)
|
|
3102
|
+
return undefined;
|
|
3103
|
+
return refusal("forbidden", "not_the_opener", "a harness retitles or closes only a space it opened");
|
|
3104
|
+
}
|
|
3105
|
+
/**
|
|
3106
|
+
* Why a room is not erased, whoever asks (ADR 0067, ADR 0069): a harness's home is always
|
|
3107
|
+
* there, and a direct room nobody can open again is archived instead.
|
|
3108
|
+
*/
|
|
3109
|
+
function eraseRefusal(space) {
|
|
3110
|
+
if (space.home)
|
|
3111
|
+
return "This is a harness's home: it is always there, and is not erased.";
|
|
3112
|
+
if (space.kind === "direct" && space.external_key === undefined && space.asked_of === undefined) {
|
|
3113
|
+
return "This direct space is archived, not erased: it is the only way they reach you here.";
|
|
3114
|
+
}
|
|
3115
|
+
return undefined;
|
|
3116
|
+
}
|
|
3117
|
+
/** A stored key as the harness gave it, and whether it names a shared place. */
|
|
3118
|
+
/** Whether an approval's request is about an action that matters (ADR 0195): flags and risk. */
|
|
3119
|
+
function asksWhatMatters(payload) {
|
|
3120
|
+
const action = payload["action"];
|
|
3121
|
+
const risk = payload["risk"];
|
|
3122
|
+
return actionMatters({
|
|
3123
|
+
...(typeof action === "object" && action !== null ? action : {}),
|
|
3124
|
+
risk: Array.isArray(risk) ? risk : undefined,
|
|
3125
|
+
});
|
|
3126
|
+
}
|
|
3127
|
+
function unscoped(harnessId, external) {
|
|
3128
|
+
if (external.startsWith("shared:"))
|
|
3129
|
+
return { key: external.slice("shared:".length), shared: true };
|
|
3130
|
+
return { key: external.slice(harnessId.length + 1), shared: false };
|
|
3131
|
+
}
|
|
3132
|
+
/** What a duplicate is looked up by: one sender's key, never the key alone. */
|
|
3133
|
+
function keyOf(actorId, key) {
|
|
3134
|
+
return `${actorId}\n${key}`;
|
|
3135
|
+
}
|
|
3136
|
+
/** A message's files, each with a URL for this harness where something signs, as the server's. */
|
|
3137
|
+
function signedFilesOf(payload, sign) {
|
|
3138
|
+
const files = payload["attachments"];
|
|
3139
|
+
if (!Array.isArray(files))
|
|
3140
|
+
return [];
|
|
3141
|
+
return files
|
|
3142
|
+
.filter((file) => typeof file["attachment_id"] === "string")
|
|
3143
|
+
.map((file) => sign === undefined ? file : { ...file, ...sign(String(file["attachment_id"])) });
|
|
3144
|
+
}
|
|
3145
|
+
/** A line as it reads now: the harness's words in a room are its agent's there (ADR 0087). */
|
|
3146
|
+
function asNow(event, now) {
|
|
3147
|
+
const addressed = event.payload["addressed_to"];
|
|
3148
|
+
const subject = event.payload["participant_id"];
|
|
3149
|
+
const actor = now(event.actor.id);
|
|
3150
|
+
return {
|
|
3151
|
+
...event,
|
|
3152
|
+
actor: actor === event.actor.id ? event.actor : { kind: "agent", id: actor },
|
|
3153
|
+
payload: {
|
|
3154
|
+
...event.payload,
|
|
3155
|
+
...(Array.isArray(addressed) ? { addressed_to: addressed.map(now) } : {}),
|
|
3156
|
+
...(typeof subject === "string" ? { participant_id: now(subject) } : {}),
|
|
3157
|
+
},
|
|
3158
|
+
};
|
|
3159
|
+
}
|
|
3160
|
+
//# sourceMappingURL=workspace.js.map
|