@fungi.computer/oyster 0.4.1 → 0.5.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/dist/agents.d.ts +2 -1
- package/dist/agents.js +63 -8
- package/dist/apps/gate.d.ts +4 -0
- package/dist/apps/gate.js +3 -0
- package/dist/boot-host-script.js +1 -1
- package/dist/build/candidate-cli.mjs +19 -4
- package/dist/channels/binding.d.ts +63 -0
- package/dist/channels/binding.js +85 -0
- package/dist/channels/conversations.d.ts +235 -0
- package/dist/channels/conversations.js +705 -0
- package/dist/channels/ids.d.ts +8 -0
- package/dist/channels/ids.js +15 -0
- package/dist/host.d.ts +1 -1
- package/dist/host.js +21 -4
- package/dist/keys.d.ts +4 -1
- package/dist/keys.js +15 -4
- package/dist/machine-link/authority.d.ts +7 -0
- package/dist/machine-link/delivery.d.ts +12 -0
- package/dist/machine-link/delivery.js +22 -1
- package/dist/machine-link/disposition.d.ts +16 -0
- package/dist/machine-link/disposition.js +65 -0
- package/dist/machine-link/host.d.ts +11 -2
- package/dist/machine-link/host.js +125 -5
- package/dist/machine-link/link.d.ts +4 -0
- package/dist/machine-link/link.js +47 -1
- package/dist/page-script.js +4 -4
- package/dist/standalone/agent-mail.js +4 -1
- package/dist/standalone/agents.d.ts +8 -0
- package/dist/standalone/agents.js +21 -2
- package/dist/standalone/channel-session-door.d.ts +4 -0
- package/dist/standalone/channel-session-door.js +42 -0
- package/dist/standalone/machine-link.d.ts +2 -0
- package/dist/standalone/machine-link.js +34 -1
- package/dist/testing/capabilities.d.ts +2 -0
- package/dist/testing/capabilities.js +3 -1
- package/dist/workers-git.js +1 -1
- package/dist/workers.d.ts +3 -0
- package/docs/api/agents.md +8 -4
- package/docs/api/apps.md +8 -4
- package/docs/api/browser.md +40 -16
- package/docs/api/keys.md +25 -3
- package/docs/api/workers.md +21 -14
- package/docs/site/agent-quickstart.mdx +23 -0
- package/examples/standalone/wrangler.jsonc +3 -4
- package/package.json +28 -16
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { authorMeta, FungiMessageMetaSchema, } from "@fungi.computer/agar/agents";
|
|
3
|
+
import { handoffSchema } from "@fungi.computer/agar/channels";
|
|
4
|
+
import { EVENT_REPLAY_WINDOW_SECONDS, channelPartySchema, limitsOf, partyLabel, } from "./binding.js";
|
|
5
|
+
/** Decide one handoff; persistence, notifications and cancellation stay with the owner. */
|
|
6
|
+
export function transitionHandoff(current, event) {
|
|
7
|
+
if (current === null)
|
|
8
|
+
return { kind: "not_found" };
|
|
9
|
+
if (current.version !== event.expectedVersion)
|
|
10
|
+
return { kind: "conflict" };
|
|
11
|
+
switch (event.kind) {
|
|
12
|
+
case "request":
|
|
13
|
+
if (current.state === "requested")
|
|
14
|
+
return { kind: "ok", handoff: current };
|
|
15
|
+
if (current.state !== "agent")
|
|
16
|
+
return { kind: "not_holder" };
|
|
17
|
+
return {
|
|
18
|
+
kind: "ok",
|
|
19
|
+
handoff: {
|
|
20
|
+
state: "requested",
|
|
21
|
+
version: current.version + 1,
|
|
22
|
+
reason: event.reason,
|
|
23
|
+
at: event.at,
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
case "take_over":
|
|
27
|
+
if (current.state === "person") {
|
|
28
|
+
if (current.memberId === event.member.id)
|
|
29
|
+
return { kind: "ok", handoff: current };
|
|
30
|
+
if (!event.confirm)
|
|
31
|
+
return { kind: "confirmation_required" };
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
kind: "ok",
|
|
35
|
+
handoff: {
|
|
36
|
+
state: "person",
|
|
37
|
+
version: current.version + 1,
|
|
38
|
+
memberId: event.member.id,
|
|
39
|
+
memberName: event.member.name,
|
|
40
|
+
since: event.at,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
case "hand_back":
|
|
44
|
+
if (current.state !== "person" ||
|
|
45
|
+
(current.memberId !== event.memberId && !event.confirm))
|
|
46
|
+
return { kind: "not_holder" };
|
|
47
|
+
return {
|
|
48
|
+
kind: "ok",
|
|
49
|
+
handoff: { state: "agent", version: current.version + 1 },
|
|
50
|
+
};
|
|
51
|
+
case "dismiss":
|
|
52
|
+
return current.state === "requested"
|
|
53
|
+
? {
|
|
54
|
+
kind: "ok",
|
|
55
|
+
handoff: { state: "agent", version: current.version + 1 },
|
|
56
|
+
}
|
|
57
|
+
: { kind: "not_holder" };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const boundedKey = z
|
|
61
|
+
.string()
|
|
62
|
+
.min(1)
|
|
63
|
+
.max(160)
|
|
64
|
+
.regex(/^[\x21-\x7e]+$/u, "Printable ASCII only");
|
|
65
|
+
/** One verified inbound event from a provider edge. */
|
|
66
|
+
const inboundEventSchema = z.strictObject({
|
|
67
|
+
bindingId: z.string().min(1).max(64),
|
|
68
|
+
/** Adapter-chosen conversation key: visitor id, thread id, Message-ID… */
|
|
69
|
+
conversation: boundedKey,
|
|
70
|
+
/** Provider event identity; a retry carries the same one. */
|
|
71
|
+
eventId: boundedKey,
|
|
72
|
+
party: channelPartySchema,
|
|
73
|
+
text: z.string().min(1).max(16_000),
|
|
74
|
+
/** Present for a host-translated caller; provider events use their own identity. */
|
|
75
|
+
sourceRequestId: FungiMessageMetaSchema.shape.sourceRequestId,
|
|
76
|
+
/**
|
|
77
|
+
* SHA-256 (hex) of the verified request body. GitHub does not sign its
|
|
78
|
+
* delivery id, so intake dedupes GitHub events on this too.
|
|
79
|
+
*/
|
|
80
|
+
bodyDigest: z
|
|
81
|
+
.string()
|
|
82
|
+
.regex(/^[a-f0-9]{64}$/u)
|
|
83
|
+
.optional(),
|
|
84
|
+
});
|
|
85
|
+
/** Channel events and composer requests do not share the Session owner's key space. */
|
|
86
|
+
export function channelRequestId(bindingId, eventId) {
|
|
87
|
+
return `ch:${bindingId}:${eventId}`;
|
|
88
|
+
}
|
|
89
|
+
const takeoverRow = z.strictObject({
|
|
90
|
+
session_id: z.string(),
|
|
91
|
+
version: z.number().int().positive(),
|
|
92
|
+
member_id: z.string().min(1).max(256),
|
|
93
|
+
member_name: z.string().min(1).max(128),
|
|
94
|
+
previous_member_id: z.string().nullable(),
|
|
95
|
+
taken_at: z.number().int(),
|
|
96
|
+
});
|
|
97
|
+
const DAY_MS = 86_400_000;
|
|
98
|
+
const HOUR_MS = 3_600_000;
|
|
99
|
+
const MINUTE_MS = 60_000;
|
|
100
|
+
/** The one conversation every event of a `named` events binding joins. */
|
|
101
|
+
const NAMED_EVENT_CONVERSATION = "events";
|
|
102
|
+
const conversationRow = z.strictObject({
|
|
103
|
+
binding_id: z.string(),
|
|
104
|
+
conversation: z.string(),
|
|
105
|
+
session_id: z.string(),
|
|
106
|
+
party_json: z.string(),
|
|
107
|
+
created_at: z.number().int(),
|
|
108
|
+
last_event_at: z.number().int(),
|
|
109
|
+
handoff_json: z.string(),
|
|
110
|
+
notify_due_at: z.number().int().nullable(),
|
|
111
|
+
});
|
|
112
|
+
const intakeRow = z.strictObject({
|
|
113
|
+
conversation: z.string(),
|
|
114
|
+
session_id: z.string(),
|
|
115
|
+
text: z.string(),
|
|
116
|
+
state: z.enum(["pending", "accepted", "held"]),
|
|
117
|
+
command_id: z.string().nullable(),
|
|
118
|
+
});
|
|
119
|
+
const countRow = z.strictObject({ count: z.number().int() });
|
|
120
|
+
const eventReceiptRow = z.strictObject({
|
|
121
|
+
event_id: z.string(),
|
|
122
|
+
received_at: z.number().int(),
|
|
123
|
+
state: z.enum(["pending", "accepted", "held"]),
|
|
124
|
+
size: z.number().int().nonnegative(),
|
|
125
|
+
session_id: z.string(),
|
|
126
|
+
});
|
|
127
|
+
/**
|
|
128
|
+
* Oyster's channel conversation owner. It maps (binding, conversation) to one
|
|
129
|
+
* server-minted Session, records each inbound event as pending with one atomic
|
|
130
|
+
* insert-or-ignore, submits it through the Session door under a derived
|
|
131
|
+
* requestId, and marks it accepted only after the door committed. A retry of
|
|
132
|
+
* the same event replays the same command; changed text conflicts.
|
|
133
|
+
*/
|
|
134
|
+
const sessionIdRow = z.object({ session_id: z.string() });
|
|
135
|
+
export function createChannelConversations(options) {
|
|
136
|
+
const { sql } = options;
|
|
137
|
+
const ensure = () => {
|
|
138
|
+
sql.exec(`CREATE TABLE IF NOT EXISTS hub_channel_conversations (
|
|
139
|
+
binding_id TEXT NOT NULL,
|
|
140
|
+
conversation TEXT NOT NULL,
|
|
141
|
+
session_id TEXT NOT NULL UNIQUE,
|
|
142
|
+
party_json TEXT NOT NULL,
|
|
143
|
+
created_at INTEGER NOT NULL,
|
|
144
|
+
last_event_at INTEGER NOT NULL,
|
|
145
|
+
handoff_json TEXT NOT NULL DEFAULT '{"state":"agent","version":0}' CHECK (json_valid(handoff_json)),
|
|
146
|
+
notify_due_at INTEGER,
|
|
147
|
+
PRIMARY KEY (binding_id, conversation)
|
|
148
|
+
) STRICT`);
|
|
149
|
+
sql.exec(`CREATE TABLE IF NOT EXISTS hub_channel_intake (
|
|
150
|
+
binding_id TEXT NOT NULL,
|
|
151
|
+
event_id TEXT NOT NULL,
|
|
152
|
+
conversation TEXT NOT NULL,
|
|
153
|
+
session_id TEXT NOT NULL,
|
|
154
|
+
text TEXT NOT NULL,
|
|
155
|
+
state TEXT NOT NULL CHECK (state IN ('pending','accepted','held')),
|
|
156
|
+
command_id TEXT,
|
|
157
|
+
received_at INTEGER NOT NULL,
|
|
158
|
+
body_digest TEXT,
|
|
159
|
+
PRIMARY KEY (binding_id, event_id)
|
|
160
|
+
) STRICT`);
|
|
161
|
+
sql.exec("CREATE UNIQUE INDEX IF NOT EXISTS hub_channel_intake_body_digest ON hub_channel_intake(binding_id, body_digest) WHERE body_digest IS NOT NULL");
|
|
162
|
+
sql.exec("CREATE INDEX IF NOT EXISTS hub_channel_intake_binding ON hub_channel_intake(binding_id, received_at)");
|
|
163
|
+
sql.exec("CREATE INDEX IF NOT EXISTS hub_channel_intake_party ON hub_channel_intake(binding_id, conversation, received_at)");
|
|
164
|
+
sql.exec(`CREATE TABLE IF NOT EXISTS hub_channel_bans (
|
|
165
|
+
binding_id TEXT NOT NULL,
|
|
166
|
+
conversation TEXT NOT NULL,
|
|
167
|
+
banned_at INTEGER NOT NULL,
|
|
168
|
+
PRIMARY KEY (binding_id, conversation)
|
|
169
|
+
) STRICT`);
|
|
170
|
+
sql.exec(`CREATE TABLE IF NOT EXISTS hub_channel_takeovers (
|
|
171
|
+
session_id TEXT NOT NULL,
|
|
172
|
+
version INTEGER NOT NULL,
|
|
173
|
+
member_id TEXT NOT NULL,
|
|
174
|
+
member_name TEXT NOT NULL,
|
|
175
|
+
previous_member_id TEXT,
|
|
176
|
+
taken_at INTEGER NOT NULL,
|
|
177
|
+
PRIMARY KEY (session_id, version)
|
|
178
|
+
) STRICT`);
|
|
179
|
+
sql.exec(`CREATE TABLE IF NOT EXISTS hub_channel_turns (
|
|
180
|
+
session_id TEXT NOT NULL,
|
|
181
|
+
command_id TEXT NOT NULL,
|
|
182
|
+
handoff_version INTEGER NOT NULL,
|
|
183
|
+
PRIMARY KEY (session_id, command_id)
|
|
184
|
+
) STRICT`);
|
|
185
|
+
sql.exec(`CREATE TABLE IF NOT EXISTS hub_channel_reply_delivery (
|
|
186
|
+
session_id TEXT NOT NULL,
|
|
187
|
+
command_id TEXT NOT NULL,
|
|
188
|
+
kind TEXT NOT NULL CHECK(kind IN ('message','settlement')),
|
|
189
|
+
reply_id TEXT NOT NULL,
|
|
190
|
+
delivered INTEGER NOT NULL CHECK(delivered IN (0,1)),
|
|
191
|
+
PRIMARY KEY (session_id, command_id, kind, reply_id)
|
|
192
|
+
) STRICT`);
|
|
193
|
+
};
|
|
194
|
+
const decodeConversation = (row) => {
|
|
195
|
+
const party = channelPartySchema.parse(JSON.parse(row.party_json));
|
|
196
|
+
return {
|
|
197
|
+
bindingId: row.binding_id,
|
|
198
|
+
conversation: row.conversation,
|
|
199
|
+
sessionId: row.session_id,
|
|
200
|
+
party,
|
|
201
|
+
label: partyLabel(party),
|
|
202
|
+
createdAt: row.created_at,
|
|
203
|
+
lastEventAt: row.last_event_at,
|
|
204
|
+
handoff: handoffSchema.parse(JSON.parse(row.handoff_json)),
|
|
205
|
+
};
|
|
206
|
+
};
|
|
207
|
+
const findConversation = (bindingId, conversation) => {
|
|
208
|
+
ensure();
|
|
209
|
+
const raw = sql
|
|
210
|
+
.exec("SELECT * FROM hub_channel_conversations WHERE binding_id=? AND conversation=?", bindingId, conversation)
|
|
211
|
+
.toArray()[0];
|
|
212
|
+
return raw === undefined
|
|
213
|
+
? null
|
|
214
|
+
: decodeConversation(conversationRow.parse(raw));
|
|
215
|
+
};
|
|
216
|
+
const count = (statement, ...bindings) => countRow.parse(sql.exec(statement, ...bindings).toArray()[0]).count;
|
|
217
|
+
/** Which binding, if any, a Session belongs to. Unbound means owner. */
|
|
218
|
+
const sessionConversation = (sessionId) => {
|
|
219
|
+
ensure();
|
|
220
|
+
const raw = sql
|
|
221
|
+
.exec("SELECT * FROM hub_channel_conversations WHERE session_id=?", sessionId)
|
|
222
|
+
.toArray()[0];
|
|
223
|
+
return raw === undefined
|
|
224
|
+
? null
|
|
225
|
+
: decodeConversation(conversationRow.parse(raw));
|
|
226
|
+
};
|
|
227
|
+
/** Every channel conversation's Session; owner Sessions are not listed. */
|
|
228
|
+
const sessionIds = () => {
|
|
229
|
+
ensure();
|
|
230
|
+
return sql
|
|
231
|
+
.exec("SELECT session_id FROM hub_channel_conversations")
|
|
232
|
+
.toArray()
|
|
233
|
+
.map((raw) => sessionIdRow.parse(raw).session_id);
|
|
234
|
+
};
|
|
235
|
+
/** Open (or find) the conversation Session without submitting work. */
|
|
236
|
+
const open = async (binding, conversation, party) => {
|
|
237
|
+
if (isBanned(binding.bindingId, conversation))
|
|
238
|
+
return { kind: "denied", reason: "banned" };
|
|
239
|
+
const existing = findConversation(binding.bindingId, conversation);
|
|
240
|
+
if (existing !== null)
|
|
241
|
+
return { kind: "open", conversation: existing };
|
|
242
|
+
const limits = limitsOf(binding);
|
|
243
|
+
const now = options.now();
|
|
244
|
+
if (count("SELECT COUNT(*) AS count FROM hub_channel_conversations WHERE binding_id=? AND created_at>?", binding.bindingId, now - HOUR_MS) >= limits.newPartiesPerHour)
|
|
245
|
+
return { kind: "denied", reason: "new_party_limit" };
|
|
246
|
+
// Session creation is idempotent per request id, so a crash between the
|
|
247
|
+
// door and this row leaves nothing to reconcile: the retry finds the same
|
|
248
|
+
// Session.
|
|
249
|
+
const sessionId = await options.door.createSession(`ch:${binding.bindingId}:${conversation}`);
|
|
250
|
+
options.transactionSync(() => {
|
|
251
|
+
sql.exec("INSERT OR IGNORE INTO hub_channel_conversations (binding_id,conversation,session_id,party_json,created_at,last_event_at) VALUES (?,?,?,?,?,?)", binding.bindingId, conversation, sessionId, JSON.stringify(party), now, now);
|
|
252
|
+
});
|
|
253
|
+
const opened = findConversation(binding.bindingId, conversation);
|
|
254
|
+
if (opened === null)
|
|
255
|
+
throw new Error("Channel conversation was not stored");
|
|
256
|
+
// The row now names the binding, so run admission is known: deliver tools
|
|
257
|
+
// before any run of this Session can call a tool.
|
|
258
|
+
options.door.deliverGrants(sessionId);
|
|
259
|
+
return { kind: "open", conversation: opened };
|
|
260
|
+
};
|
|
261
|
+
const isBanned = (bindingId, conversation) => {
|
|
262
|
+
ensure();
|
|
263
|
+
return (sql
|
|
264
|
+
.exec("SELECT 1 FROM hub_channel_bans WHERE binding_id=? AND conversation=?", bindingId, conversation)
|
|
265
|
+
.toArray().length > 0);
|
|
266
|
+
};
|
|
267
|
+
/**
|
|
268
|
+
* The conversation an event lands in. An incoming-events binding owns the
|
|
269
|
+
* choice: `named` joins every event to one Session, `new` gives each event
|
|
270
|
+
* the key its edge derived from the event id.
|
|
271
|
+
*/
|
|
272
|
+
const conversationOf = (binding, event) => binding.selector.provider === "event" && binding.selector.target === "named"
|
|
273
|
+
? NAMED_EVENT_CONVERSATION
|
|
274
|
+
: event.conversation;
|
|
275
|
+
/**
|
|
276
|
+
* The digest an event is deduped on: only GitHub's, whose delivery id the
|
|
277
|
+
* sender does not sign. Every other verifier either signs its event id
|
|
278
|
+
* (fungi-v1) or has none to dedupe on (secret-url: each request is new).
|
|
279
|
+
*/
|
|
280
|
+
const replayDigest = (binding, event) => {
|
|
281
|
+
if (binding.selector.provider !== "event" ||
|
|
282
|
+
binding.selector.verifier !== "github")
|
|
283
|
+
return null;
|
|
284
|
+
// The edge always computes it; a GitHub event without one is a bug there.
|
|
285
|
+
if (event.bodyDigest === undefined)
|
|
286
|
+
throw new Error("A GitHub event needs the digest of its verified body");
|
|
287
|
+
return event.bodyDigest;
|
|
288
|
+
};
|
|
289
|
+
/** The event id that already admitted this body inside the replay window. */
|
|
290
|
+
const digestHit = (bindingId, digest, now) => {
|
|
291
|
+
const row = sql
|
|
292
|
+
.exec("SELECT event_id FROM hub_channel_intake WHERE binding_id=? AND body_digest=? AND received_at>?", bindingId, digest, now - EVENT_REPLAY_WINDOW_SECONDS * 1_000)
|
|
293
|
+
.toArray()[0];
|
|
294
|
+
return row === undefined
|
|
295
|
+
? null
|
|
296
|
+
: z.strictObject({ event_id: z.string() }).parse(row).event_id;
|
|
297
|
+
};
|
|
298
|
+
/** Has this events binding admitted its per-minute allowance already? */
|
|
299
|
+
const rateLimited = (binding, now) => binding.selector.provider === "event" &&
|
|
300
|
+
count("SELECT COUNT(*) AS count FROM hub_channel_intake WHERE binding_id=? AND received_at>?", binding.bindingId, now - MINUTE_MS) >= binding.selector.eventsPerMinute;
|
|
301
|
+
/** Accept one inbound event as ordinary Agent work, exactly once. */
|
|
302
|
+
const admit = async (rawEvent, retried) => {
|
|
303
|
+
const parsed = inboundEventSchema.parse(rawEvent);
|
|
304
|
+
ensure();
|
|
305
|
+
const binding = options.binding(parsed.bindingId);
|
|
306
|
+
if (binding === null)
|
|
307
|
+
return { kind: "denied", reason: "unmapped" };
|
|
308
|
+
if (binding.lifecycle !== "active")
|
|
309
|
+
return { kind: "denied", reason: "suspended" };
|
|
310
|
+
const conversation = conversationOf(binding, parsed);
|
|
311
|
+
if (isBanned(binding.bindingId, conversation))
|
|
312
|
+
return { kind: "denied", reason: "banned" };
|
|
313
|
+
const limits = limitsOf(binding);
|
|
314
|
+
if (parsed.text.length > limits.charsPerMessage)
|
|
315
|
+
return { kind: "denied", reason: "too_long" };
|
|
316
|
+
// A body that already admitted an event inside the replay window is that
|
|
317
|
+
// event again, whatever delivery id the replay carries.
|
|
318
|
+
const digest = replayDigest(binding, parsed);
|
|
319
|
+
const replayOf = digest === null
|
|
320
|
+
? null
|
|
321
|
+
: digestHit(binding.bindingId, digest, options.now());
|
|
322
|
+
const event = {
|
|
323
|
+
...parsed,
|
|
324
|
+
conversation,
|
|
325
|
+
eventId: replayOf ?? parsed.eventId,
|
|
326
|
+
};
|
|
327
|
+
const prior = sql
|
|
328
|
+
.exec("SELECT conversation,session_id,text,state,command_id FROM hub_channel_intake WHERE binding_id=? AND event_id=?", binding.bindingId, event.eventId)
|
|
329
|
+
.toArray()[0];
|
|
330
|
+
const priorIntake = prior === undefined ? null : intakeRow.parse(prior);
|
|
331
|
+
// Visitor bearers own separate conversations. A verified provider-body
|
|
332
|
+
// replay, in contrast, intentionally resolves to its original conversation.
|
|
333
|
+
if (priorIntake !== null &&
|
|
334
|
+
((event.party.kind === "visitor" &&
|
|
335
|
+
priorIntake.conversation !== event.conversation) ||
|
|
336
|
+
priorIntake.text !== event.text))
|
|
337
|
+
return { kind: "denied", reason: "event_conflict" };
|
|
338
|
+
if (priorIntake?.state === "accepted" && priorIntake.command_id !== null)
|
|
339
|
+
return {
|
|
340
|
+
kind: "accepted",
|
|
341
|
+
sessionId: priorIntake.session_id,
|
|
342
|
+
commandId: priorIntake.command_id,
|
|
343
|
+
delivery: "replay",
|
|
344
|
+
};
|
|
345
|
+
if (priorIntake?.state === "held")
|
|
346
|
+
return {
|
|
347
|
+
kind: "held",
|
|
348
|
+
sessionId: priorIntake.session_id,
|
|
349
|
+
delivery: "replay",
|
|
350
|
+
};
|
|
351
|
+
let sessionId;
|
|
352
|
+
if (priorIntake === null) {
|
|
353
|
+
// Refuse a flood before it opens a Session per rejected event.
|
|
354
|
+
if (rateLimited(binding, options.now()))
|
|
355
|
+
return { kind: "denied", reason: "rate_limited" };
|
|
356
|
+
const opened = await open(binding, event.conversation, event.party);
|
|
357
|
+
if (opened.kind === "denied")
|
|
358
|
+
return opened;
|
|
359
|
+
if (opened.conversation.party.kind !== event.party.kind)
|
|
360
|
+
return { kind: "denied", reason: "event_conflict" };
|
|
361
|
+
sessionId = opened.conversation.sessionId;
|
|
362
|
+
const now = options.now();
|
|
363
|
+
const admitted = options.transactionSync(() => {
|
|
364
|
+
// Digests past the window stop blocking, so the same body may be
|
|
365
|
+
// admitted again later.
|
|
366
|
+
sql.exec("UPDATE hub_channel_intake SET body_digest=NULL WHERE binding_id=? AND body_digest IS NOT NULL AND received_at<=?", binding.bindingId, now - EVENT_REPLAY_WINDOW_SECONDS * 1_000);
|
|
367
|
+
// Session creation yielded. Resolve a competing reservation through
|
|
368
|
+
// the same conversation/text checks before replaying its receipt.
|
|
369
|
+
if (sql
|
|
370
|
+
.exec("SELECT 1 FROM hub_channel_intake WHERE binding_id=? AND event_id=?", binding.bindingId, event.eventId)
|
|
371
|
+
.toArray().length > 0)
|
|
372
|
+
return "replay";
|
|
373
|
+
// Another delivery of this body may also have landed.
|
|
374
|
+
if (digest !== null &&
|
|
375
|
+
digestHit(binding.bindingId, digest, now) !== null)
|
|
376
|
+
return "replay";
|
|
377
|
+
if (count("SELECT COUNT(*) AS count FROM hub_channel_intake WHERE binding_id=? AND conversation=? AND received_at>?", binding.bindingId, event.conversation, now - DAY_MS) >= limits.messagesPerPartyPerDay)
|
|
378
|
+
return "party_daily_limit";
|
|
379
|
+
if (rateLimited(binding, now))
|
|
380
|
+
return "rate_limited";
|
|
381
|
+
sql.exec("INSERT OR IGNORE INTO hub_channel_intake (binding_id,event_id,conversation,session_id,text,state,command_id,received_at,body_digest) VALUES (?,?,?,?,?,'pending',NULL,?,?)", binding.bindingId, event.eventId, event.conversation, sessionId, event.text, now, digest);
|
|
382
|
+
return "inserted";
|
|
383
|
+
});
|
|
384
|
+
if (admitted === "replay") {
|
|
385
|
+
// Intake lost a race while opening the Session. Re-read the winner,
|
|
386
|
+
// including its conversation, rather than treating insert-ignore as ours.
|
|
387
|
+
if (retried)
|
|
388
|
+
throw new Error("A replayed event body was not found");
|
|
389
|
+
return await admit(rawEvent, true);
|
|
390
|
+
}
|
|
391
|
+
if (admitted !== "inserted")
|
|
392
|
+
return { kind: "denied", reason: admitted };
|
|
393
|
+
}
|
|
394
|
+
else
|
|
395
|
+
sessionId = priorIntake.session_id;
|
|
396
|
+
const requestId = channelRequestId(binding.bindingId, event.eventId);
|
|
397
|
+
const delivery = priorIntake === null ? "new" : "replay";
|
|
398
|
+
const party = event.party;
|
|
399
|
+
const author = {
|
|
400
|
+
id: party.kind === "visitor"
|
|
401
|
+
? party.visitorId
|
|
402
|
+
: party.kind === "discord-user"
|
|
403
|
+
? party.userId
|
|
404
|
+
: party.kind === "email-sender"
|
|
405
|
+
? party.address
|
|
406
|
+
: party.kind === "player"
|
|
407
|
+
? party.playerId
|
|
408
|
+
: party.source,
|
|
409
|
+
name: partyLabel(party).slice(0, 128),
|
|
410
|
+
};
|
|
411
|
+
const meta = event.sourceRequestId === undefined
|
|
412
|
+
? authorMeta(author)
|
|
413
|
+
: { ...authorMeta(author), sourceRequestId: event.sourceRequestId };
|
|
414
|
+
try {
|
|
415
|
+
// A held conversation is a person's: the event is a run-free party line.
|
|
416
|
+
if (sessionConversation(sessionId)?.handoff.state === "person") {
|
|
417
|
+
await options.door.line(sessionId, {
|
|
418
|
+
requestId,
|
|
419
|
+
author: "party",
|
|
420
|
+
text: event.text,
|
|
421
|
+
meta,
|
|
422
|
+
});
|
|
423
|
+
options.transactionSync(() => {
|
|
424
|
+
sql.exec("UPDATE hub_channel_intake SET state='held' WHERE binding_id=? AND event_id=? AND state='pending'", binding.bindingId, event.eventId);
|
|
425
|
+
sql.exec("UPDATE hub_channel_conversations SET last_event_at=? WHERE session_id=?", options.now(), sessionId);
|
|
426
|
+
});
|
|
427
|
+
return { kind: "held", sessionId, delivery };
|
|
428
|
+
}
|
|
429
|
+
const { commandId } = await options.door.prompt(sessionId, {
|
|
430
|
+
requestId,
|
|
431
|
+
text: event.text,
|
|
432
|
+
meta,
|
|
433
|
+
});
|
|
434
|
+
options.transactionSync(() => {
|
|
435
|
+
sql.exec("UPDATE hub_channel_intake SET state='accepted', command_id=? WHERE binding_id=? AND event_id=? AND state='pending'", commandId, binding.bindingId, event.eventId);
|
|
436
|
+
sql.exec("UPDATE hub_channel_conversations SET last_event_at=? WHERE session_id=?", options.now(), sessionId);
|
|
437
|
+
options.onAccepted?.({ binding, event, sessionId, commandId });
|
|
438
|
+
});
|
|
439
|
+
return { kind: "accepted", sessionId, commandId, delivery };
|
|
440
|
+
}
|
|
441
|
+
catch (error) {
|
|
442
|
+
if (event.party.kind !== "visitor" ||
|
|
443
|
+
!(error instanceof Error) ||
|
|
444
|
+
options.door.errorKind(error, requestId) !== "closed_request")
|
|
445
|
+
throw error;
|
|
446
|
+
// The owner's durable withdrawal fence is authoritative. This reservation
|
|
447
|
+
// never became a line or command; do not present it as pending intake.
|
|
448
|
+
sql.exec("DELETE FROM hub_channel_intake WHERE binding_id=? AND event_id=? AND session_id=? AND state='pending'", binding.bindingId, event.eventId, sessionId);
|
|
449
|
+
return { kind: "denied", reason: "event_conflict" };
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
const receive = (rawEvent) => admit(rawEvent, false);
|
|
453
|
+
const changeHandoff = (sessionId, event) => options.transactionSync(() => {
|
|
454
|
+
const current = sessionConversation(sessionId);
|
|
455
|
+
if (current === null)
|
|
456
|
+
return { kind: "not_found" };
|
|
457
|
+
const result = transitionHandoff(current.handoff, event);
|
|
458
|
+
if (result.kind !== "ok")
|
|
459
|
+
return result;
|
|
460
|
+
const next = handoffSchema.parse(result.handoff);
|
|
461
|
+
if (next.version === current.handoff.version)
|
|
462
|
+
return { kind: "ok", conversation: current };
|
|
463
|
+
const changed = sql
|
|
464
|
+
.exec("UPDATE hub_channel_conversations SET handoff_json=?, notify_due_at=? WHERE session_id=? AND json_extract(handoff_json,'$.version')=? RETURNING session_id", JSON.stringify(next), next.state === "requested" ? next.at : null, sessionId, event.expectedVersion)
|
|
465
|
+
.toArray();
|
|
466
|
+
if (changed.length !== 1)
|
|
467
|
+
return { kind: "conflict" };
|
|
468
|
+
if (event.kind === "take_over" && next.state === "person") {
|
|
469
|
+
sql.exec("INSERT INTO hub_channel_takeovers VALUES (?,?,?,?,?,?)", sessionId, next.version, next.memberId, next.memberName, current.handoff.state === "person" ? current.handoff.memberId : null, next.since);
|
|
470
|
+
sql.exec("DELETE FROM hub_channel_takeovers WHERE session_id=? AND version NOT IN (SELECT version FROM hub_channel_takeovers WHERE session_id=? ORDER BY version DESC LIMIT 100)", sessionId, sessionId);
|
|
471
|
+
}
|
|
472
|
+
return { kind: "ok", conversation: { ...current, handoff: next } };
|
|
473
|
+
});
|
|
474
|
+
/** Capture only the immutable command supplied by the real turn-start producer. */
|
|
475
|
+
const captureTurn = (sessionId, commandId) => {
|
|
476
|
+
options.transactionSync(() => {
|
|
477
|
+
const conversation = sessionConversation(sessionId);
|
|
478
|
+
if (conversation === null)
|
|
479
|
+
return;
|
|
480
|
+
sql.exec("INSERT OR IGNORE INTO hub_channel_turns VALUES (?,?,?)", sessionId, commandId, conversation.handoff.version);
|
|
481
|
+
});
|
|
482
|
+
};
|
|
483
|
+
const turnIsCurrent = (sessionId, commandId) => {
|
|
484
|
+
const conversation = sessionConversation(sessionId);
|
|
485
|
+
if (conversation === null || conversation.handoff.state === "person")
|
|
486
|
+
return false;
|
|
487
|
+
const row = sql
|
|
488
|
+
.exec("SELECT handoff_version FROM hub_channel_turns WHERE session_id=? AND command_id=?", sessionId, commandId)
|
|
489
|
+
.toArray()[0];
|
|
490
|
+
if (row === undefined)
|
|
491
|
+
return false;
|
|
492
|
+
const capturedVersion = z
|
|
493
|
+
.strictObject({ handoff_version: z.number().int().nonnegative() })
|
|
494
|
+
.parse(row).handoff_version;
|
|
495
|
+
const handoff = conversation.handoff;
|
|
496
|
+
// Requesting a person leaves the Agent in control: its current turn must
|
|
497
|
+
// still be able to tell the participant someone will follow up. Only the
|
|
498
|
+
// immediately following request is safe; any takeover/hand-back moves
|
|
499
|
+
// beyond it. Never recapture the command at a newer version.
|
|
500
|
+
return (capturedVersion === handoff.version ||
|
|
501
|
+
(handoff.state === "requested" && handoff.version === capturedVersion + 1));
|
|
502
|
+
};
|
|
503
|
+
/** Keep the first delivery decision: later handoffs cannot erase delivered history. */
|
|
504
|
+
const deliverAgentReply = (sessionId, commandId, reply) => options.transactionSync(() => {
|
|
505
|
+
if (sessionConversation(sessionId) === null)
|
|
506
|
+
return false;
|
|
507
|
+
const replyId = reply.kind === "message" ? reply.messageId : "";
|
|
508
|
+
const existing = sql
|
|
509
|
+
.exec("SELECT delivered FROM hub_channel_reply_delivery WHERE session_id=? AND command_id=? AND kind=? AND reply_id=?", sessionId, commandId, reply.kind, replyId)
|
|
510
|
+
.toArray()[0];
|
|
511
|
+
if (existing !== undefined)
|
|
512
|
+
return (z
|
|
513
|
+
.strictObject({ delivered: z.union([z.literal(0), z.literal(1)]) })
|
|
514
|
+
.parse(existing).delivered === 1);
|
|
515
|
+
// Missing turn capture is not proof of a delivery or a permanent draft.
|
|
516
|
+
const captured = sql
|
|
517
|
+
.exec("SELECT command_id FROM hub_channel_turns WHERE session_id=? AND command_id=?", sessionId, commandId)
|
|
518
|
+
.toArray();
|
|
519
|
+
if (captured.length === 0)
|
|
520
|
+
return false;
|
|
521
|
+
const delivered = turnIsCurrent(sessionId, commandId);
|
|
522
|
+
sql.exec("INSERT INTO hub_channel_reply_delivery VALUES (?,?,?,?,?)", sessionId, commandId, reply.kind, replyId, delivered ? 1 : 0);
|
|
523
|
+
return delivered;
|
|
524
|
+
});
|
|
525
|
+
/**
|
|
526
|
+
* The Agent asks for a person; the first reason stands until dismissal or takeover. A
|
|
527
|
+
* new request owes the owner one notification, due now.
|
|
528
|
+
*/
|
|
529
|
+
const requestHandoff = (sessionId, reason) => {
|
|
530
|
+
const current = sessionConversation(sessionId);
|
|
531
|
+
if (current === null)
|
|
532
|
+
return { kind: "not_found" };
|
|
533
|
+
return changeHandoff(sessionId, {
|
|
534
|
+
kind: "request",
|
|
535
|
+
expectedVersion: current.handoff.version,
|
|
536
|
+
reason,
|
|
537
|
+
at: options.now(),
|
|
538
|
+
});
|
|
539
|
+
};
|
|
540
|
+
/** Handoffs whose owner notification is due, oldest first. */
|
|
541
|
+
const dueNotifications = (now) => {
|
|
542
|
+
ensure();
|
|
543
|
+
return sql
|
|
544
|
+
.exec("SELECT * FROM hub_channel_conversations WHERE notify_due_at IS NOT NULL AND notify_due_at<=? AND json_extract(handoff_json,'$.state')='requested' ORDER BY notify_due_at LIMIT 16", now)
|
|
545
|
+
.toArray()
|
|
546
|
+
.map((raw) => decodeConversation(conversationRow.parse(raw)));
|
|
547
|
+
};
|
|
548
|
+
/** The earliest owed notification, or null. */
|
|
549
|
+
const nextNotification = () => {
|
|
550
|
+
ensure();
|
|
551
|
+
return z
|
|
552
|
+
.object({ due: z.number().int().nullable() })
|
|
553
|
+
.parse(sql
|
|
554
|
+
.exec("SELECT MIN(notify_due_at) AS due FROM hub_channel_conversations WHERE json_extract(handoff_json,'$.state')='requested'")
|
|
555
|
+
.toArray()[0]).due;
|
|
556
|
+
};
|
|
557
|
+
/** Settle (null) or reschedule one conversation's owed notification. */
|
|
558
|
+
const scheduleNotification = (sessionId, dueAt) => {
|
|
559
|
+
ensure();
|
|
560
|
+
sql.exec("UPDATE hub_channel_conversations SET notify_due_at=? WHERE session_id=? AND json_extract(handoff_json,'$.state')='requested'", dueAt, sessionId);
|
|
561
|
+
};
|
|
562
|
+
/** Whether this exact handoff (by its start time) is still open. */
|
|
563
|
+
const handoffOpen = (sessionId, version) => {
|
|
564
|
+
const handoff = sessionConversation(sessionId)?.handoff;
|
|
565
|
+
return handoff?.state === "requested" && handoff.version === version;
|
|
566
|
+
};
|
|
567
|
+
/**
|
|
568
|
+
* Conversations past their binding's retention, oldest first. A held
|
|
569
|
+
* conversation is a person's and is never swept from under them.
|
|
570
|
+
*/
|
|
571
|
+
const expired = (now, retentionMs) => {
|
|
572
|
+
ensure();
|
|
573
|
+
return sql
|
|
574
|
+
.exec("SELECT * FROM hub_channel_conversations WHERE json_extract(handoff_json,'$.state')!='person' ORDER BY last_event_at LIMIT 256")
|
|
575
|
+
.toArray()
|
|
576
|
+
.map((raw) => decodeConversation(conversationRow.parse(raw)))
|
|
577
|
+
.filter((conversation) => conversation.lastEventAt + retentionMs(conversation.bindingId) <= now)
|
|
578
|
+
.slice(0, 32);
|
|
579
|
+
};
|
|
580
|
+
/** The earliest time any unheld conversation passes its retention. */
|
|
581
|
+
const nextExpiry = (retentionMs) => {
|
|
582
|
+
ensure();
|
|
583
|
+
const rows = sql
|
|
584
|
+
.exec("SELECT binding_id, MIN(last_event_at) AS oldest FROM hub_channel_conversations WHERE json_extract(handoff_json,'$.state')!='person' GROUP BY binding_id")
|
|
585
|
+
.toArray()
|
|
586
|
+
.map((raw) => z
|
|
587
|
+
.strictObject({ binding_id: z.string(), oldest: z.number().int() })
|
|
588
|
+
.parse(raw));
|
|
589
|
+
const due = rows.map((row) => row.oldest + retentionMs(row.binding_id));
|
|
590
|
+
return due.length === 0 ? null : Math.min(...due);
|
|
591
|
+
};
|
|
592
|
+
/** Forget a swept conversation: its map row and its intake log. */
|
|
593
|
+
const forget = (conversation) => {
|
|
594
|
+
ensure();
|
|
595
|
+
options.transactionSync(() => {
|
|
596
|
+
sql.exec("DELETE FROM hub_channel_intake WHERE binding_id=? AND conversation=?", conversation.bindingId, conversation.conversation);
|
|
597
|
+
sql.exec("DELETE FROM hub_channel_conversations WHERE session_id=?", conversation.sessionId);
|
|
598
|
+
sql.exec("DELETE FROM hub_channel_turns WHERE session_id=?", conversation.sessionId);
|
|
599
|
+
sql.exec("DELETE FROM hub_channel_reply_delivery WHERE session_id=?", conversation.sessionId);
|
|
600
|
+
sql.exec("DELETE FROM hub_channel_takeovers WHERE session_id=?", conversation.sessionId);
|
|
601
|
+
});
|
|
602
|
+
};
|
|
603
|
+
/** The same transition function owns requests, takeovers, hand back and dismissal. */
|
|
604
|
+
const takeOver = (sessionId, member, expectedVersion, confirm) => changeHandoff(sessionId, {
|
|
605
|
+
kind: "take_over",
|
|
606
|
+
member,
|
|
607
|
+
expectedVersion,
|
|
608
|
+
confirm,
|
|
609
|
+
at: options.now(),
|
|
610
|
+
});
|
|
611
|
+
const handBack = (sessionId, memberId, expectedVersion, confirm) => changeHandoff(sessionId, {
|
|
612
|
+
kind: "hand_back",
|
|
613
|
+
memberId,
|
|
614
|
+
expectedVersion,
|
|
615
|
+
confirm,
|
|
616
|
+
});
|
|
617
|
+
const dismiss = (sessionId, expectedVersion) => changeHandoff(sessionId, { kind: "dismiss", expectedVersion });
|
|
618
|
+
const takeovers = (sessionId) => {
|
|
619
|
+
ensure();
|
|
620
|
+
return sql
|
|
621
|
+
.exec("SELECT * FROM hub_channel_takeovers WHERE session_id=? ORDER BY version DESC LIMIT 100", sessionId)
|
|
622
|
+
.toArray()
|
|
623
|
+
.map((raw) => {
|
|
624
|
+
const row = takeoverRow.parse(raw);
|
|
625
|
+
return {
|
|
626
|
+
version: row.version,
|
|
627
|
+
memberId: row.member_id,
|
|
628
|
+
memberName: row.member_name,
|
|
629
|
+
previousMemberId: row.previous_member_id,
|
|
630
|
+
at: row.taken_at,
|
|
631
|
+
};
|
|
632
|
+
});
|
|
633
|
+
};
|
|
634
|
+
/** Owner action: refuse further events from one conversation. */
|
|
635
|
+
const ban = (bindingId, conversation) => {
|
|
636
|
+
ensure();
|
|
637
|
+
sql.exec("INSERT OR IGNORE INTO hub_channel_bans VALUES (?,?,?)", bindingId, conversation, options.now());
|
|
638
|
+
};
|
|
639
|
+
/** Every conversation, newest activity first, for owner surfaces. */
|
|
640
|
+
const list = (bindingId) => {
|
|
641
|
+
ensure();
|
|
642
|
+
const rows = bindingId === undefined
|
|
643
|
+
? sql
|
|
644
|
+
.exec("SELECT * FROM hub_channel_conversations ORDER BY last_event_at DESC")
|
|
645
|
+
.toArray()
|
|
646
|
+
: sql
|
|
647
|
+
.exec("SELECT * FROM hub_channel_conversations WHERE binding_id=? ORDER BY last_event_at DESC", bindingId)
|
|
648
|
+
.toArray();
|
|
649
|
+
return rows.map((raw) => decodeConversation(conversationRow.parse(raw)));
|
|
650
|
+
};
|
|
651
|
+
/** Last 100 events on a projected event binding, with no payload or digest. */
|
|
652
|
+
const recentEvents = (bindingId) => {
|
|
653
|
+
ensure();
|
|
654
|
+
if (options.binding(bindingId)?.selector.provider !== "event")
|
|
655
|
+
return [];
|
|
656
|
+
return sql
|
|
657
|
+
.exec("SELECT event_id,received_at,state,length(CAST(text AS BLOB)) AS size,session_id FROM hub_channel_intake WHERE binding_id=? ORDER BY received_at DESC,event_id DESC LIMIT 100", bindingId)
|
|
658
|
+
.toArray()
|
|
659
|
+
.map((raw) => {
|
|
660
|
+
const row = eventReceiptRow.parse(raw);
|
|
661
|
+
return {
|
|
662
|
+
eventId: row.event_id,
|
|
663
|
+
receivedAt: row.received_at,
|
|
664
|
+
status: row.state,
|
|
665
|
+
size: row.size,
|
|
666
|
+
sessionId: row.session_id,
|
|
667
|
+
};
|
|
668
|
+
});
|
|
669
|
+
};
|
|
670
|
+
return {
|
|
671
|
+
ensure,
|
|
672
|
+
open,
|
|
673
|
+
receive,
|
|
674
|
+
/** A visitor bearer may never resolve another conversation's intake key. */
|
|
675
|
+
visitorEventConflict: (bindingId, visitorId, eventId) => {
|
|
676
|
+
const row = sql
|
|
677
|
+
.exec("SELECT conversation FROM hub_channel_intake WHERE binding_id=? AND event_id=?", bindingId, eventId)
|
|
678
|
+
.toArray()[0];
|
|
679
|
+
return (row !== undefined &&
|
|
680
|
+
z.strictObject({ conversation: z.string() }).parse(row).conversation !==
|
|
681
|
+
visitorId);
|
|
682
|
+
},
|
|
683
|
+
ban,
|
|
684
|
+
list,
|
|
685
|
+
recentEvents,
|
|
686
|
+
requestHandoff,
|
|
687
|
+
captureTurn,
|
|
688
|
+
turnIsCurrent,
|
|
689
|
+
deliverAgentReply,
|
|
690
|
+
dueNotifications,
|
|
691
|
+
nextNotification,
|
|
692
|
+
scheduleNotification,
|
|
693
|
+
handoffOpen,
|
|
694
|
+
takeOver,
|
|
695
|
+
handBack,
|
|
696
|
+
dismiss,
|
|
697
|
+
takeovers,
|
|
698
|
+
expired,
|
|
699
|
+
nextExpiry,
|
|
700
|
+
forget,
|
|
701
|
+
find: findConversation,
|
|
702
|
+
sessionConversation,
|
|
703
|
+
sessionIds,
|
|
704
|
+
};
|
|
705
|
+
}
|