multi-agent-collaboration-mcp 0.12.0 → 0.13.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/README.md +227 -138
- package/dist/build-info.json +1 -1
- package/dist/check.js +25 -140
- package/dist/db.js +670 -824
- package/dist/index.js +841 -422
- package/dist/poller.js +188 -81
- package/package.json +2 -5
- package/scripts/stamp-build.mjs +4 -2
- package/web/index.html +19 -39
- package/web/server.mjs +82 -165
- package/scripts/wait-for-updates.sh +0 -39
package/dist/db.js
CHANGED
|
@@ -39,6 +39,10 @@ const MAX_GET_MESSAGE_CHARS = 400_000;
|
|
|
39
39
|
/** Caller-supplied post idempotency keys are opaque, room/author scoped, and
|
|
40
40
|
* intentionally small enough to keep the sparse unique index cheap. */
|
|
41
41
|
export const MAX_CLIENT_MESSAGE_ID_CHARS = 200;
|
|
42
|
+
/** Persona/human id cap. Exported because the server SIZES the canonical id it
|
|
43
|
+
* generates against it before allocating, rather than discovering the overflow
|
|
44
|
+
* as a store assertion after the fact. */
|
|
45
|
+
export const MAX_AGENT_ID_CHARS = 200;
|
|
42
46
|
/** Above this body length the store's json well-formedness re-validation is
|
|
43
47
|
* skipped: parsing a ~GB body into memory to walk it would defeat the
|
|
44
48
|
* memory-bounded read design, and such a caller owns validation (the MCP
|
|
@@ -144,10 +148,64 @@ const STUB_ALLOWANCE = 500;
|
|
|
144
148
|
* remains it must reject the call rather than advance past an undeliverable
|
|
145
149
|
* page. */
|
|
146
150
|
export const MIN_CATCH_UP_RESULT_BUDGET = Math.max(CATCH_UP_ENVELOPE, PRIORITY_CATCH_UP_ENVELOPE) + STUB_ALLOWANCE;
|
|
147
|
-
/**
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
|
|
151
|
+
/**
|
|
152
|
+
* A persona-authored operation was fenced out: the runtime that issued it no
|
|
153
|
+
* longer holds the persona, because a later valid resume took it over. Terminal
|
|
154
|
+
* by contract -- retrying cannot succeed, and the caller must create or resume a
|
|
155
|
+
* persona before acting again. A distinct class (not a bare Error) so the MCP
|
|
156
|
+
* layer can render it as `persona_lost` without string-matching a message.
|
|
157
|
+
*/
|
|
158
|
+
export class PersonaLostError extends Error {
|
|
159
|
+
agentId;
|
|
160
|
+
expectedEpoch;
|
|
161
|
+
currentEpoch;
|
|
162
|
+
constructor(agentId, expectedEpoch, currentEpoch) {
|
|
163
|
+
super(currentEpoch === null
|
|
164
|
+
? `persona "${agentId}" no longer exists; create or resume a persona before acting`
|
|
165
|
+
: `persona "${agentId}" was taken over by a later runtime ` +
|
|
166
|
+
`(your epoch ${expectedEpoch}, current ${currentEpoch}); ` +
|
|
167
|
+
`this runtime's authority is gone and retrying cannot restore it`);
|
|
168
|
+
this.name = "PersonaLostError";
|
|
169
|
+
this.agentId = agentId;
|
|
170
|
+
this.expectedEpoch = expectedEpoch;
|
|
171
|
+
this.currentEpoch = currentEpoch;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* A CORRECT resume word presented with a different brand/model/version.
|
|
176
|
+
*
|
|
177
|
+
* This is not a rejected credential. The word proves the caller is the
|
|
178
|
+
* persona's legitimate owner; what it reports is that the thing sitting behind
|
|
179
|
+
* the seat CHANGED. brand/model/version are immutable by construction, so a
|
|
180
|
+
* runtime whose actual model no longer matches them is a different participant
|
|
181
|
+
* asking to wear an old name, and letting it through would keep posting under a
|
|
182
|
+
* model identifier that is false to every peer reading the room -- the one
|
|
183
|
+
* thing the tuple exists to prevent.
|
|
184
|
+
*
|
|
185
|
+
* Distinct from a wrong word (a rejected credential, whose remedy is to find
|
|
186
|
+
* the right word) and from PersonaLostError (a takeover, whose remedy is to
|
|
187
|
+
* resume again). The remedy here is a NEW persona.
|
|
188
|
+
*/
|
|
189
|
+
export class ModelTupleMismatchError extends Error {
|
|
190
|
+
agentId;
|
|
191
|
+
stored;
|
|
192
|
+
offered;
|
|
193
|
+
/** Rooms the persona is currently present in, so the caller can be told which
|
|
194
|
+
* conversations to notify. It has no binding to look them up with itself. */
|
|
195
|
+
rooms;
|
|
196
|
+
constructor(agentId, stored, offered, rooms) {
|
|
197
|
+
super(`persona "${agentId}" is ${stored.brand}/${stored.model}/${stored.version}, ` +
|
|
198
|
+
`but this runtime is ${offered.brand}/${offered.model}/${offered.version}. ` +
|
|
199
|
+
`The resume word is correct, so this is a MODEL CHANGE, not a bad ` +
|
|
200
|
+
`credential: brand/model/version are immutable and a persona cannot be ` +
|
|
201
|
+
`carried across them. Create a new persona instead.`);
|
|
202
|
+
this.name = "ModelTupleMismatchError";
|
|
203
|
+
this.agentId = agentId;
|
|
204
|
+
this.stored = stored;
|
|
205
|
+
this.offered = offered;
|
|
206
|
+
this.rooms = rooms;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
151
209
|
/** Serialized-size budget for a metadata listing's row ARRAY, leaving room for
|
|
152
210
|
* the response envelope (total, truncated/size_trimmed flags) AND the keyset
|
|
153
211
|
* paging cursor, so the WHOLE response stays under DEFAULT_MAX_BYTES. The
|
|
@@ -225,6 +283,18 @@ function assertMaxLen(value, field, max) {
|
|
|
225
283
|
throw new Error(`${field} exceeds ${max} characters`);
|
|
226
284
|
}
|
|
227
285
|
}
|
|
286
|
+
/**
|
|
287
|
+
* NULL is the only way to say "no role". An empty or whitespace-only string is
|
|
288
|
+
* a third state that renders as no role while still being a set value, so the
|
|
289
|
+
* membership row would report a role that no listing can show and no filter can
|
|
290
|
+
* match. Rejected at the store, not just at the tool schema, so a direct-store
|
|
291
|
+
* caller cannot create the state either.
|
|
292
|
+
*/
|
|
293
|
+
function assertRole(role) {
|
|
294
|
+
if (role !== null && role.trim() === "") {
|
|
295
|
+
throw new Error("role must be a non-blank string, or null to clear it");
|
|
296
|
+
}
|
|
297
|
+
}
|
|
228
298
|
/**
|
|
229
299
|
* Trim a metadata listing to a serialized-size budget, dropping WHOLE rows off
|
|
230
300
|
* the end (still reachable via offset paging). The per-row preview caps bound
|
|
@@ -282,7 +352,7 @@ function messageCols(bodyCap) {
|
|
|
282
352
|
// (which pages in codepoints) and the bulk reads used to disagree by the
|
|
283
353
|
// astral factor (an emoji is 1 codepoint but 2 UTF-16 units). NUL is
|
|
284
354
|
// rejected/sanitized, so length() is exact.
|
|
285
|
-
return `g.seq, g.agent_id,
|
|
355
|
+
return `g.seq, g.agent_id,
|
|
286
356
|
g.format, g.priority, substr(g.body, 1, ${cap}) AS body, g.body_len,
|
|
287
357
|
length(g.body) AS body_cp,
|
|
288
358
|
g.mentions, g.reply_to_seq,
|
|
@@ -294,8 +364,11 @@ function messageCols(bodyCap) {
|
|
|
294
364
|
ORDER BY s.seq DESC LIMIT 1) AS superseded_by,
|
|
295
365
|
p.agent_id AS reply_from, substr(p.body, 1, 101) AS reply_preview`;
|
|
296
366
|
}
|
|
367
|
+
// No join to agents: a message envelope carries the author id and nothing else
|
|
368
|
+
// about the author. Role is room-local and mutable, so stamping it into a read
|
|
369
|
+
// of an old message reported that message as written in a role its author may
|
|
370
|
+
// never have held when writing it.
|
|
297
371
|
const MESSAGE_FROM = `messages g
|
|
298
|
-
LEFT JOIN agents a ON a.id = g.agent_id
|
|
299
372
|
LEFT JOIN messages p ON p.room_id = g.room_id AND p.seq = g.reply_to_seq`;
|
|
300
373
|
/**
|
|
301
374
|
* SQL predicate for "directed at me": an explicit mention, OR a reply to a
|
|
@@ -347,14 +420,15 @@ export class ChatStore {
|
|
|
347
420
|
}
|
|
348
421
|
catch { }
|
|
349
422
|
}
|
|
350
|
-
//
|
|
423
|
+
// Switching a brand-new rollback-journal file to WAL needs an exclusive
|
|
351
424
|
// lock, and SQLite can return SQLITE_BUSY here WITHOUT consulting the
|
|
352
425
|
// busy handler (better-sqlite3's default 5s timeout does not cover this
|
|
353
|
-
// path), so two fresh processes racing to convert the same
|
|
426
|
+
// path), so two fresh processes racing to convert the same file
|
|
354
427
|
// intermittently crashed on startup (reproduced ~1 in 24 synchronized
|
|
355
|
-
// opens).
|
|
356
|
-
// finds the file already in WAL
|
|
357
|
-
// already-WAL files, i.e. every
|
|
428
|
+
// opens). This is a STARTUP RACE, not a compatibility path: retry with a
|
|
429
|
+
// short synchronous backoff and the loser finds the file already in WAL
|
|
430
|
+
// and succeeds immediately. A no-op on already-WAL files, i.e. every
|
|
431
|
+
// startup after the first.
|
|
358
432
|
for (let attempt = 1;; attempt++) {
|
|
359
433
|
try {
|
|
360
434
|
this.db.pragma("journal_mode = WAL");
|
|
@@ -375,14 +449,14 @@ export class ChatStore {
|
|
|
375
449
|
}
|
|
376
450
|
this.db.pragma("busy_timeout = 5000");
|
|
377
451
|
this.db.pragma("foreign_keys = ON");
|
|
378
|
-
// One process
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
this.db.transaction(() => this.
|
|
452
|
+
// One process initializes the schema at a time. Without an IMMEDIATE
|
|
453
|
+
// transaction, concurrent MCP startups could all find the FTS index
|
|
454
|
+
// missing and each repeat a full-corpus rebuild.
|
|
455
|
+
this.db.transaction(() => this.initializeSchema()).immediate();
|
|
382
456
|
}
|
|
383
457
|
catch (error) {
|
|
384
458
|
// A constructor that throws has no caller-visible instance on which to
|
|
385
|
-
// call close(); cover pragma/setup failures as well as
|
|
459
|
+
// call close(); cover pragma/setup failures as well as schema errors.
|
|
386
460
|
try {
|
|
387
461
|
this.db.close();
|
|
388
462
|
}
|
|
@@ -390,7 +464,13 @@ export class ChatStore {
|
|
|
390
464
|
throw error;
|
|
391
465
|
}
|
|
392
466
|
}
|
|
393
|
-
|
|
467
|
+
/**
|
|
468
|
+
* Create the current schema. NOT a migration: there is exactly one schema,
|
|
469
|
+
* every statement is CREATE ... IF NOT EXISTS, and a database written by an
|
|
470
|
+
* older build is neither detected nor upgraded -- its queries fail raw.
|
|
471
|
+
* Replacing the file is a deployment step.
|
|
472
|
+
*/
|
|
473
|
+
initializeSchema() {
|
|
394
474
|
this.db.exec(`
|
|
395
475
|
CREATE TABLE IF NOT EXISTS rooms (
|
|
396
476
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -400,14 +480,54 @@ export class ChatStore {
|
|
|
400
480
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
401
481
|
);
|
|
402
482
|
|
|
483
|
+
-- One row per PERSONA. An LLM persona carries an immutable
|
|
484
|
+
-- brand/model/version tuple and a server-generated resume word; a human
|
|
485
|
+
-- web participant carries neither. The CHECK makes those the ONLY two
|
|
486
|
+
-- shapes any writer can create -- including direct ones (the web server,
|
|
487
|
+
-- sqlite3, a test) that never pass through this file's JS guards -- so a
|
|
488
|
+
-- half-persona (resumable without metadata, or metadata with no resume
|
|
489
|
+
-- path) cannot exist in the database.
|
|
403
490
|
CREATE TABLE IF NOT EXISTS agents (
|
|
404
|
-
id
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
491
|
+
id TEXT PRIMARY KEY,
|
|
492
|
+
is_human INTEGER NOT NULL DEFAULT 0,
|
|
493
|
+
brand TEXT,
|
|
494
|
+
model TEXT,
|
|
495
|
+
version TEXT,
|
|
496
|
+
resume_word TEXT,
|
|
497
|
+
-- Monotonic, never reused. Creation binds runtime 1; every bindingless
|
|
498
|
+
-- attach increments. A runtime captures this value when it binds, and
|
|
499
|
+
-- an operation carrying a captured value below the current one is from
|
|
500
|
+
-- a superseded tenure. Monotonicity is what a reusable process nonce
|
|
501
|
+
-- lacked: a nonce can be handed back to the same runtime after it lost
|
|
502
|
+
-- and regained the persona, making a stale in-flight write look current.
|
|
503
|
+
runtime_epoch INTEGER NOT NULL DEFAULT 1,
|
|
504
|
+
description TEXT,
|
|
505
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
506
|
+
CHECK (
|
|
507
|
+
(is_human = 1
|
|
508
|
+
AND brand IS NULL AND model IS NULL
|
|
509
|
+
AND version IS NULL AND resume_word IS NULL)
|
|
510
|
+
OR
|
|
511
|
+
(is_human = 0
|
|
512
|
+
AND brand IS NOT NULL AND model IS NOT NULL
|
|
513
|
+
AND version IS NOT NULL AND resume_word IS NOT NULL)
|
|
514
|
+
)
|
|
409
515
|
);
|
|
410
516
|
|
|
517
|
+
-- role is ROOM-LOCAL and nullable: one persona can be reviewer in one
|
|
518
|
+
-- room and writer in another, and "no role" is a real state distinct from
|
|
519
|
+
-- an empty string. It is deliberately NOT on agents, where a single value
|
|
520
|
+
-- had to be true everywhere at once.
|
|
521
|
+
--
|
|
522
|
+
-- last_seen means RECENT RUNTIME ACTIVITY, not model activity. Two things
|
|
523
|
+
-- set it: an MCP call from the bound runtime, and the two-minute heartbeat
|
|
524
|
+
-- of a generated watcher armed by wait_for_messages. So a fresh last_seen
|
|
525
|
+
-- proves a runtime exists and is reachable; it does NOT prove the model is
|
|
526
|
+
-- reasoning, reading, or able to wake. An armed seat therefore reads as
|
|
527
|
+
-- recently active for as long as its watcher lives, which is the point:
|
|
528
|
+
-- the alternative was a seat that looked offline every time the model went
|
|
529
|
+
-- quiet between turns. Code that needs the stronger claim wants
|
|
530
|
+
-- wait_leases ("watching"), which is an open blocking call.
|
|
411
531
|
CREATE TABLE IF NOT EXISTS memberships (
|
|
412
532
|
room_id INTEGER NOT NULL REFERENCES rooms(id),
|
|
413
533
|
agent_id TEXT NOT NULL REFERENCES agents(id),
|
|
@@ -415,6 +535,7 @@ export class ChatStore {
|
|
|
415
535
|
last_read_seq INTEGER NOT NULL DEFAULT 0,
|
|
416
536
|
last_seen TEXT,
|
|
417
537
|
left_at TEXT,
|
|
538
|
+
role TEXT,
|
|
418
539
|
PRIMARY KEY (room_id, agent_id)
|
|
419
540
|
);
|
|
420
541
|
|
|
@@ -426,65 +547,40 @@ export class ChatStore {
|
|
|
426
547
|
format TEXT NOT NULL DEFAULT 'text',
|
|
427
548
|
priority INTEGER NOT NULL DEFAULT 0,
|
|
428
549
|
body TEXT NOT NULL,
|
|
550
|
+
-- The exact UTF-16 length the web viewer reports, stamped by every
|
|
551
|
+
-- insert (SQLite length() under-counts astral text). NOT NULL: both
|
|
552
|
+
-- supported writers stamp it, so read paths carry no fallback and a
|
|
553
|
+
-- direct SQL writer that omits it is rejected rather than producing a
|
|
554
|
+
-- row whose reported length is silently wrong for astral text.
|
|
555
|
+
body_len INTEGER NOT NULL,
|
|
429
556
|
mentions TEXT,
|
|
430
557
|
reply_to_seq INTEGER,
|
|
558
|
+
-- Denormalized reply author, so my_mentions finds a reply whose parent
|
|
559
|
+
-- was later pruned.
|
|
560
|
+
reply_to_agent TEXT,
|
|
561
|
+
supersedes_seq INTEGER,
|
|
431
562
|
client_message_id TEXT,
|
|
432
563
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
433
564
|
UNIQUE (room_id, seq)
|
|
434
565
|
);
|
|
435
566
|
`);
|
|
436
|
-
//
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
this.ensureColumn("messages", "reply_to_seq", "INTEGER");
|
|
445
|
-
this.ensureColumn("messages", "mentions", "TEXT");
|
|
446
|
-
this.ensureColumn("messages", "supersedes_seq", "INTEGER");
|
|
447
|
-
this.ensureColumn("messages", "reply_to_agent", "TEXT");
|
|
448
|
-
this.ensureColumn("messages", "body_len", "INTEGER");
|
|
449
|
-
this.ensureColumn("messages", "client_message_id", "TEXT");
|
|
450
|
-
// DB-level enforcement for MIXED-VERSION windows: a still-running old
|
|
451
|
-
// build inserts without these columns, and if the reply's parent is
|
|
452
|
-
// pruned before any new-build process restarts, the startup backfill
|
|
453
|
-
// below can never recover the author -- the reply is silently
|
|
454
|
-
// undirected forever. Triggers live in the database file, so they fire
|
|
455
|
-
// for the old build's inserts too. New builds stamp both columns
|
|
456
|
-
// explicitly, so the WHEN clauses skip their rows.
|
|
567
|
+
// Reject an embedded NUL at the DATABASE level: SQLite's substr()/length()
|
|
568
|
+
// stop at U+0000, so a NUL body reads back truncated with the read marker
|
|
569
|
+
// advancing past the lost tail. This file's writers already reject it in JS
|
|
570
|
+
// (assertStorable), but the web server holds its own handle and tests open
|
|
571
|
+
// the file directly; a trigger lives in the database and therefore covers
|
|
572
|
+
// every writer, which is the same reason the agents CHECK above is in SQL.
|
|
573
|
+
// (A trigger cannot SANITIZE the value -- replace() is itself
|
|
574
|
+
// NUL-terminated -- so it aborts instead.)
|
|
457
575
|
this.db.exec(`
|
|
458
|
-
CREATE TRIGGER IF NOT EXISTS messages_reply_agent_ai AFTER INSERT ON messages
|
|
459
|
-
WHEN NEW.reply_to_seq IS NOT NULL AND NEW.reply_to_agent IS NULL BEGIN
|
|
460
|
-
UPDATE messages SET reply_to_agent =
|
|
461
|
-
(SELECT p.agent_id FROM messages p
|
|
462
|
-
WHERE p.room_id = NEW.room_id AND p.seq = NEW.reply_to_seq)
|
|
463
|
-
WHERE id = NEW.id;
|
|
464
|
-
END;
|
|
465
|
-
|
|
466
|
-
-- Routine current-build inserts provide body_len and occupy no entry.
|
|
467
|
-
-- This makes the recurring mixed-version repair an empty-index probe on a
|
|
468
|
-
-- healthy file instead of a corpus scan.
|
|
469
|
-
CREATE INDEX IF NOT EXISTS idx_messages_body_len_missing
|
|
470
|
-
ON messages(id) WHERE body_len IS NULL;
|
|
471
|
-
|
|
472
|
-
-- Reject an embedded NUL at the DATABASE level: SQLite's substr()/length()
|
|
473
|
-
-- stop at U+0000, so a NUL body reads back truncated with the marker
|
|
474
|
-
-- advancing past the lost tail. New builds already reject it in JS
|
|
475
|
-
-- (assertStorable), but an OLD build writing during a rolling upgrade
|
|
476
|
-
-- would not -- this trigger fires for its inserts too and aborts them,
|
|
477
|
-
-- closing that window. (A SQL trigger cannot SANITIZE the value: replace()
|
|
478
|
-
-- is itself NUL-terminated. Existing NUL rows are healed in JS below.)
|
|
479
576
|
CREATE TRIGGER IF NOT EXISTS messages_reject_nul BEFORE INSERT ON messages
|
|
480
577
|
WHEN instr(NEW.body, char(0)) > 0 BEGIN
|
|
481
578
|
SELECT RAISE(ABORT, 'message body contains a NUL character (U+0000), which SQLite cannot store without silently truncating');
|
|
482
579
|
END;
|
|
483
580
|
|
|
484
|
-
-- Metadata listings also use SQLite substr()/length(), so
|
|
485
|
-
--
|
|
486
|
-
--
|
|
487
|
-
-- fields one at a time without the other field blocking it.
|
|
581
|
+
-- Metadata listings also use SQLite substr()/length(), so no writer may
|
|
582
|
+
-- introduce a silently truncated value. UPDATE guards are per-column so
|
|
583
|
+
-- one malformed room field never blocks repairing the other.
|
|
488
584
|
CREATE TRIGGER IF NOT EXISTS rooms_reject_nul_insert BEFORE INSERT ON rooms
|
|
489
585
|
WHEN instr(NEW.description, char(0)) > 0 OR instr(NEW.pinned, char(0)) > 0 BEGIN
|
|
490
586
|
SELECT RAISE(ABORT, 'room metadata contains a NUL character (U+0000)');
|
|
@@ -509,126 +605,10 @@ export class ChatStore {
|
|
|
509
605
|
SELECT RAISE(ABORT, 'agent description contains a NUL character (U+0000)');
|
|
510
606
|
END;
|
|
511
607
|
`);
|
|
512
|
-
// The former messages_body_len_ai trigger stamped SQLite length(NEW.body),
|
|
513
|
-
// which under-counts astral text versus the web viewer's UTF-16 body_len.
|
|
514
|
-
// Keep a harmless blocker UNDER THE LEGACY NAME so a restarted old build's
|
|
515
|
-
// CREATE TRIGGER IF NOT EXISTS cannot resurrect that stamper. Inspect the
|
|
516
|
-
// stored definition first: unconditional DROP/CREATE caused schema churn,
|
|
517
|
-
// WAL writes, and an exclusive schema lock on every healthy MCP startup.
|
|
518
|
-
const bodyLenBlockerMarker = "agent-chat-body-len-blocker-v3";
|
|
519
|
-
const existingBodyLenTrigger = this.db
|
|
520
|
-
.prepare("SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = 'messages_body_len_ai'")
|
|
521
|
-
.get();
|
|
522
|
-
if (!existingBodyLenTrigger?.sql?.includes(bodyLenBlockerMarker)) {
|
|
523
|
-
this.db.exec(`
|
|
524
|
-
DROP TRIGGER IF EXISTS messages_body_len_ai;
|
|
525
|
-
CREATE TRIGGER messages_body_len_ai AFTER INSERT ON messages
|
|
526
|
-
WHEN NEW.body_len IS NULL BEGIN
|
|
527
|
-
SELECT '${bodyLenBlockerMarker}';
|
|
528
|
-
END;
|
|
529
|
-
`);
|
|
530
|
-
}
|
|
531
|
-
const SCHEMA_VERSION = 3;
|
|
532
|
-
const currentVersion = this.db.pragma("user_version", {
|
|
533
|
-
simple: true,
|
|
534
|
-
});
|
|
535
|
-
// Keep version gates narrow. Advancing v2 -> v3 for bounded metadata repair
|
|
536
|
-
// must not repeat the older full-message maintenance pass.
|
|
537
|
-
const needsV2Maintenance = currentVersion < 2;
|
|
538
|
-
const needsMetadataV3 = currentVersion < 3;
|
|
539
|
-
const needsBodyNulHeal = currentVersion < 1;
|
|
540
|
-
// Backfill the denormalized reply author AFTER the old-writer trigger above
|
|
541
|
-
// exists, closing a rolling-upgrade gap: with the backfill running FIRST, an
|
|
542
|
-
// old build inserting a reply in the window between the two steps hit
|
|
543
|
-
// neither (backfill already passed, trigger not yet created), and if the
|
|
544
|
-
// parent was pruned before any restart the author was unrecoverable and
|
|
545
|
-
// my_mentions missed the reply forever. Now a reply inserted before the
|
|
546
|
-
// trigger is caught here; one inserted after is stamped by the trigger.
|
|
547
|
-
// Rows whose parent is already gone stay NULL (unrecoverable) and are
|
|
548
|
-
// re-examined harmlessly.
|
|
549
|
-
if (needsV2Maintenance) {
|
|
550
|
-
this.db.exec(`
|
|
551
|
-
UPDATE messages SET reply_to_agent =
|
|
552
|
-
(SELECT p.agent_id FROM messages p
|
|
553
|
-
WHERE p.room_id = messages.room_id AND p.seq = messages.reply_to_seq)
|
|
554
|
-
WHERE reply_to_seq IS NOT NULL AND reply_to_agent IS NULL
|
|
555
|
-
`);
|
|
556
|
-
}
|
|
557
|
-
// The message-body NUL scan below runs ONLY until this file is marked
|
|
558
|
-
// migrated via PRAGMA user_version. It reads EVERY body to evaluate
|
|
559
|
-
// instr(body, char(0)) -- costly on a large corpus (a 1 GB body is read
|
|
560
|
-
// every startup) and pointless once done: the reject trigger blocks any new
|
|
561
|
-
// NUL row, so a migrated file cannot acquire one. The gate is set at the END
|
|
562
|
-
// of migrate(), so a crash mid-scan just re-runs. Everything else
|
|
563
|
-
// Schema/index/trigger creation and the sparse body_len repair stay
|
|
564
|
-
// recurring; each later migration has its own narrowly-scoped gate.
|
|
565
|
-
// Heal existing rows that already hold a NUL (written by a pre-guard build):
|
|
566
|
-
// a plain SELECT is NOT NUL-terminated, so the full body is recoverable;
|
|
567
|
-
// replace each NUL with U+FFFD so substr()/length() readers see it whole,
|
|
568
|
-
// and clear body_len so the backfill below re-stamps the corrected length.
|
|
569
|
-
// Cursored ONE ROW AT A TIME (not .all(), which materialized every
|
|
570
|
-
// malformed body at once and could OOM the process at startup on many/large
|
|
571
|
-
// NUL bodies), via a regex replace (not split/join, which builds a giant
|
|
572
|
-
// array on an all-NUL body). instr() detects them; healing a row clears its
|
|
573
|
-
// NUL, so the predicate never revisits it and the id cursor moves strictly
|
|
574
|
-
// forward.
|
|
575
|
-
let healedMessageBody = false;
|
|
576
|
-
if (needsBodyNulHeal) {
|
|
577
|
-
const nextNul = this.db.prepare(`SELECT id, length(CAST(body AS BLOB)) AS bytes
|
|
578
|
-
FROM messages
|
|
579
|
-
WHERE instr(body, char(0)) > 0 AND id > ?
|
|
580
|
-
ORDER BY id LIMIT 1`);
|
|
581
|
-
const getBody = this.db.prepare("SELECT body FROM messages WHERE id = ?");
|
|
582
|
-
const fix = this.db.prepare("UPDATE messages SET body = ?, body_len = NULL WHERE id = ?");
|
|
583
|
-
let cursor = 0;
|
|
584
|
-
for (;;) {
|
|
585
|
-
const row = nextNul.get(cursor);
|
|
586
|
-
if (!row)
|
|
587
|
-
break;
|
|
588
|
-
if (row.bytes > MAX_MESSAGE_BODY_BYTES) {
|
|
589
|
-
throw new Error(`legacy message ${row.id} is ${row.bytes} bytes and contains NUL; ` +
|
|
590
|
-
`refusing to load it above the ${MAX_MESSAGE_BODY_BYTES}-byte safety limit`);
|
|
591
|
-
}
|
|
592
|
-
const { body } = getBody.get(row.id);
|
|
593
|
-
fix.run(body.replace(/\u0000/g, "\ufffd"), row.id);
|
|
594
|
-
healedMessageBody = true;
|
|
595
|
-
cursor = row.id;
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
// Backfill body_len (the exact UTF-16 length the WEB viewer reports) for
|
|
599
|
-
// rows that predate the column. It must be measured in JS, so we LOAD the
|
|
600
|
-
// body -- but only for rows small enough to be safe (length() is a cheap
|
|
601
|
-
// codepoint gate, an upper bound on UTF-16 units is 2x that, so <=
|
|
602
|
-
// BACKFILL_MAX_CHARS codepoints is at most ~2x that many UTF-16 units held
|
|
603
|
-
// at once). Giant legacy rows are stamped with the memory-safe codepoint
|
|
604
|
-
// count via SQL (a low-but-nonzero bound; the web viewer's COALESCE still
|
|
605
|
-
// shows a length and its total>shown guard tolerates the codepoint skew).
|
|
606
|
-
// Cursored by id so the whole sweep is one table pass, one body resident.
|
|
607
|
-
// Always repair the sparse set of mixed-version NULL rows. On a healthy
|
|
608
|
-
// file idx_messages_body_len_missing is empty, so this is O(1); it avoids a
|
|
609
|
-
// full startup scan while keeping the documented next-reopen guarantee.
|
|
610
|
-
const BACKFILL_MAX_CHARS = 1_000_000;
|
|
611
|
-
this.db
|
|
612
|
-
.prepare(`UPDATE messages INDEXED BY idx_messages_body_len_missing
|
|
613
|
-
SET body_len = length(body)
|
|
614
|
-
WHERE body_len IS NULL AND length(body) > ?`)
|
|
615
|
-
.run(BACKFILL_MAX_CHARS);
|
|
616
|
-
const nextMissingBodyLen = this.db.prepare(`SELECT id, body FROM messages INDEXED BY idx_messages_body_len_missing
|
|
617
|
-
WHERE body_len IS NULL AND id > ? ORDER BY id LIMIT 1`);
|
|
618
|
-
const setBodyLen = this.db.prepare("UPDATE messages SET body_len = ? WHERE id = ?");
|
|
619
|
-
let bodyLenCursor = 0;
|
|
620
|
-
for (;;) {
|
|
621
|
-
const row = nextMissingBodyLen.get(bodyLenCursor);
|
|
622
|
-
if (!row)
|
|
623
|
-
break;
|
|
624
|
-
setBodyLen.run(row.body.length, row.id);
|
|
625
|
-
bodyLenCursor = row.id;
|
|
626
|
-
}
|
|
627
608
|
this.db.exec(`
|
|
628
|
-
--
|
|
629
|
-
-- (sqlite_autoindex, same query plans)
|
|
630
|
-
-- every insert.
|
|
631
|
-
DROP INDEX IF EXISTS idx_messages_room_seq;
|
|
609
|
+
-- No explicit (room_id, seq) index: UNIQUE(room_id, seq) already provides
|
|
610
|
+
-- one (sqlite_autoindex, same query plans), and a duplicate only taxes
|
|
611
|
+
-- every insert.
|
|
632
612
|
CREATE INDEX IF NOT EXISTS idx_messages_reply ON messages(room_id, reply_to_seq);
|
|
633
613
|
CREATE INDEX IF NOT EXISTS idx_messages_supersedes ON messages(room_id, supersedes_seq);
|
|
634
614
|
-- Routine posts store NULL and therefore occupy no entry in this sparse
|
|
@@ -649,51 +629,6 @@ export class ChatStore {
|
|
|
649
629
|
CREATE INDEX IF NOT EXISTS idx_memberships_agent_present
|
|
650
630
|
ON memberships(agent_id, left_at, room_id);
|
|
651
631
|
|
|
652
|
-
-- Per-session read CURSORS for identities running multiple concurrent
|
|
653
|
-
-- sessions (join_room cursor:'private'). The memberships marker stays the
|
|
654
|
-
-- identity-level read receipt (advanced to the MAX across sessions). The
|
|
655
|
-
-- left_at column here is VESTIGIAL and no longer read: presence moved to
|
|
656
|
-
-- the session_presence table below, which every session (shared too)
|
|
657
|
-
-- registers in, so a leave can no longer evict a live twin.
|
|
658
|
-
CREATE TABLE IF NOT EXISTS session_markers (
|
|
659
|
-
room_id INTEGER NOT NULL REFERENCES rooms(id),
|
|
660
|
-
agent_id TEXT NOT NULL REFERENCES agents(id),
|
|
661
|
-
session_id TEXT NOT NULL,
|
|
662
|
-
last_read_seq INTEGER NOT NULL DEFAULT 0,
|
|
663
|
-
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
664
|
-
left_at TEXT,
|
|
665
|
-
PRIMARY KEY (room_id, agent_id, session_id)
|
|
666
|
-
);
|
|
667
|
-
-- touch()/touchSessionMarkers refresh a session's rows by session_id
|
|
668
|
-
-- ALONE (a process-unique nonce, keyed independently of room/agent). The
|
|
669
|
-
-- PK starts with room_id, so that predicate had no usable index and did a
|
|
670
|
-
-- full session_markers scan on every liveness touch; this covers it.
|
|
671
|
-
CREATE INDEX IF NOT EXISTS idx_session_markers_session
|
|
672
|
-
ON session_markers(session_id);
|
|
673
|
-
CREATE INDEX IF NOT EXISTS idx_session_markers_room_updated
|
|
674
|
-
ON session_markers(room_id, updated_at);
|
|
675
|
-
|
|
676
|
-
-- Per-session PRESENCE, decoupled from cursors: EVERY session (shared or
|
|
677
|
-
-- private) registers a row here on join, keyed by its process nonce, so a
|
|
678
|
-
-- leave can tell whether any OTHER session of the identity is still here.
|
|
679
|
-
-- memberships.left_at is recomputed from this table as "present iff any
|
|
680
|
-
-- row is live (left_at IS NULL and refreshed within the GC window)", which
|
|
681
|
-
-- keeps the cross-process poller (it reads memberships.left_at) working
|
|
682
|
-
-- unchanged. Separate from session_markers so it never perturbs cursor
|
|
683
|
-
-- semantics (a shared session has a presence row but no cursor row).
|
|
684
|
-
CREATE TABLE IF NOT EXISTS session_presence (
|
|
685
|
-
room_id INTEGER NOT NULL REFERENCES rooms(id),
|
|
686
|
-
agent_id TEXT NOT NULL REFERENCES agents(id),
|
|
687
|
-
session_id TEXT NOT NULL,
|
|
688
|
-
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
689
|
-
left_at TEXT,
|
|
690
|
-
PRIMARY KEY (room_id, agent_id, session_id)
|
|
691
|
-
);
|
|
692
|
-
CREATE INDEX IF NOT EXISTS idx_session_presence_session
|
|
693
|
-
ON session_presence(session_id);
|
|
694
|
-
CREATE INDEX IF NOT EXISTS idx_session_presence_room_updated
|
|
695
|
-
ON session_presence(room_id, updated_at);
|
|
696
|
-
|
|
697
632
|
-- Advisory single-winner work claims with TTL. Purely advisory: nothing
|
|
698
633
|
-- fences the claimed resource itself; expiry frees claims from crashed
|
|
699
634
|
-- holders.
|
|
@@ -707,29 +642,35 @@ export class ChatStore {
|
|
|
707
642
|
PRIMARY KEY (room_id, key)
|
|
708
643
|
);
|
|
709
644
|
|
|
710
|
-
-- In-turn wait leases: a row exists ONLY while
|
|
645
|
+
-- In-turn wait leases: a row exists ONLY while a persona's blocking
|
|
711
646
|
-- catch_up wait is open in that room, making "actively watching" a
|
|
712
647
|
-- verifiable server state (recipientStatus/list_agents expose it as
|
|
713
648
|
-- "watching"). expires_at is the wait deadline plus a small grace, so a
|
|
714
649
|
-- crashed waiter's row self-expires; the handler deletes it on every
|
|
715
|
-
-- normal or aborted exit. Detached pollers never write here
|
|
716
|
-
--
|
|
717
|
-
--
|
|
650
|
+
-- normal or aborted exit. Detached pollers never write here, which is
|
|
651
|
+
-- deliberate: an armed watcher is a shell process, and "watching" claims
|
|
652
|
+
-- an OPEN BLOCKING CALL. A generated watcher does refresh last_seen (see
|
|
653
|
+
-- memberships), so last_seen is the weaker signal of the two: recent
|
|
654
|
+
-- runtime activity. "watching" stays the strong one.
|
|
655
|
+
--
|
|
656
|
+
-- Keyed (room_id, agent_id) with the OWNING epoch as data, not as part of
|
|
657
|
+
-- the key: one persona has one runtime, so it has at most one wait per
|
|
658
|
+
-- room, and a takeover must REPLACE the loser's row rather than sit
|
|
659
|
+
-- beside it (two rows would report two watchers for one persona). The
|
|
660
|
+
-- epoch column is what makes the loser's later cleanup safe: its DELETE
|
|
661
|
+
-- is guarded on the epoch it captured, so it cannot remove the winner's
|
|
662
|
+
-- row after an upsert has already overwritten it.
|
|
718
663
|
CREATE TABLE IF NOT EXISTS wait_leases (
|
|
719
664
|
room_id INTEGER NOT NULL REFERENCES rooms(id),
|
|
720
665
|
agent_id TEXT NOT NULL REFERENCES agents(id),
|
|
721
|
-
|
|
666
|
+
epoch INTEGER NOT NULL,
|
|
722
667
|
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
723
668
|
expires_at TEXT NOT NULL,
|
|
724
|
-
PRIMARY KEY (room_id, agent_id
|
|
669
|
+
PRIMARY KEY (room_id, agent_id)
|
|
725
670
|
);
|
|
726
671
|
`);
|
|
727
|
-
//
|
|
728
|
-
//
|
|
729
|
-
// files). Runs after the table exists, so ensureColumn's ALTER is valid.
|
|
730
|
-
this.ensureColumn("session_markers", "left_at", "TEXT");
|
|
731
|
-
// claims is created in the block above, later than rooms/agents, so install
|
|
732
|
-
// its old/direct-writer NUL guards here before the one-time v3 heal.
|
|
672
|
+
// claims is created in the block above, later than rooms/agents, so its
|
|
673
|
+
// NUL guards install here, after the table exists.
|
|
733
674
|
this.db.exec(`
|
|
734
675
|
CREATE TRIGGER IF NOT EXISTS claims_reject_nul_insert BEFORE INSERT ON claims
|
|
735
676
|
WHEN instr(NEW.note, char(0)) > 0 BEGIN
|
|
@@ -741,18 +682,6 @@ export class ChatStore {
|
|
|
741
682
|
SELECT RAISE(ABORT, 'claim note contains a NUL character (U+0000)');
|
|
742
683
|
END;
|
|
743
684
|
`);
|
|
744
|
-
// Heal legacy embedded NULs in metadata columns too (message bodies are
|
|
745
|
-
// healed above, with their body_len reset). Listing SQL uses substr(), which
|
|
746
|
-
// stops at the first NUL, so a legacy NUL silently truncated the shown
|
|
747
|
-
// value. Runs after every table exists; new writes are already rejected.
|
|
748
|
-
// Run once with the versioned maintenance pass. Even small full-table
|
|
749
|
-
// scans become expensive when multiplied across many MCP processes.
|
|
750
|
-
if (needsMetadataV3) {
|
|
751
|
-
this.healNulColumn("rooms", "description");
|
|
752
|
-
this.healNulColumn("rooms", "pinned");
|
|
753
|
-
this.healNulColumn("agents", "description");
|
|
754
|
-
this.healNulColumn("claims", "note");
|
|
755
|
-
}
|
|
756
685
|
// Full-text search over message bodies. External-content FTS5 mirrors
|
|
757
686
|
// messages.body keyed by messages.id; triggers keep it in sync.
|
|
758
687
|
this.db.exec(`
|
|
@@ -767,112 +696,73 @@ export class ChatStore {
|
|
|
767
696
|
VALUES ('delete', old.id, old.body);
|
|
768
697
|
END;
|
|
769
698
|
`);
|
|
770
|
-
//
|
|
771
|
-
// dying between the CREATE above and the rebuild below
|
|
772
|
-
//
|
|
773
|
-
//
|
|
774
|
-
//
|
|
775
|
-
//
|
|
776
|
-
//
|
|
777
|
-
// table: with external content, COUNT(*) on the virtual table itself
|
|
699
|
+
// Rebuild decision by INDEX CONSISTENCY, not table existence: a process
|
|
700
|
+
// dying between the CREATE above and the rebuild below leaves a database
|
|
701
|
+
// where every later start sees the table and skips the rebuild forever,
|
|
702
|
+
// making everything written before the crash permanently invisible to
|
|
703
|
+
// search. The empty/nonempty mismatch repairs that window in O(1), with no
|
|
704
|
+
// corpus scan. The index row count MUST come from the messages_fts_docsize
|
|
705
|
+
// shadow table: with external content, COUNT(*) on the virtual table itself
|
|
778
706
|
// reads the content table and always matches. BOTH counts are read in ONE
|
|
779
707
|
// statement (a single consistent snapshot): two separate SELECTs could
|
|
780
708
|
// straddle a concurrent insert -- messages counted pre-insert, fts counted
|
|
781
709
|
// post-trigger -- making an already-inconsistent {2,1} file read as {2,2}
|
|
782
|
-
// and skip the rebuild it actually needed.
|
|
783
|
-
// body before these triggers exist; row counts still match in that case,
|
|
784
|
-
// so the repair flag must force a rebuild to replace stale tokens.
|
|
710
|
+
// and skip the rebuild it actually needed.
|
|
785
711
|
const { hasMessages, hasFtsRows } = this.db
|
|
786
712
|
.prepare(`SELECT EXISTS(SELECT 1 FROM messages LIMIT 1) AS hasMessages,
|
|
787
713
|
EXISTS(SELECT 1 FROM messages_fts_docsize LIMIT 1) AS hasFtsRows`)
|
|
788
714
|
.get();
|
|
789
|
-
|
|
790
|
-
// The steady-state sentinel above catches the historical empty-index crash
|
|
791
|
-
// class in O(1). A full consistency count remains appropriate during the
|
|
792
|
-
// one-time pre-v2 migration, but not on every MCP process startup.
|
|
793
|
-
if (!rebuildFts && needsV2Maintenance) {
|
|
794
|
-
const { msgCount, ftsCount } = this.db
|
|
795
|
-
.prepare(`SELECT (SELECT COUNT(*) FROM messages) AS msgCount,
|
|
796
|
-
(SELECT COUNT(*) FROM messages_fts_docsize) AS ftsCount`)
|
|
797
|
-
.get();
|
|
798
|
-
rebuildFts = ftsCount !== msgCount;
|
|
799
|
-
}
|
|
800
|
-
if (rebuildFts) {
|
|
715
|
+
if (hasMessages !== hasFtsRows) {
|
|
801
716
|
this.db.exec("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')");
|
|
802
717
|
}
|
|
803
|
-
// Mark this file migrated so the message-body NUL scan above is skipped on
|
|
804
|
-
// future startups (one-time work; the reject trigger keeps new rows clean).
|
|
805
|
-
// Set LAST, only after the scan ran, so a crash mid-scan leaves user_version
|
|
806
|
-
// unchanged and it re-runs.
|
|
807
|
-
if (currentVersion < SCHEMA_VERSION) {
|
|
808
|
-
this.db.pragma(`user_version = ${SCHEMA_VERSION}`);
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
ensureColumn(table, column, type) {
|
|
812
|
-
const cols = this.db
|
|
813
|
-
.prepare(`PRAGMA table_info(${table})`)
|
|
814
|
-
.all();
|
|
815
|
-
if (!cols.some((c) => c.name === column)) {
|
|
816
|
-
try {
|
|
817
|
-
this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
|
|
818
|
-
}
|
|
819
|
-
catch (e) {
|
|
820
|
-
// Two fresh processes migrating the same legacy file can both pass
|
|
821
|
-
// the PRAGMA check; the loser's ALTER must be a no-op, not a startup
|
|
822
|
-
// crash.
|
|
823
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
824
|
-
if (!/duplicate column name/i.test(msg))
|
|
825
|
-
throw e;
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
718
|
}
|
|
719
|
+
// --- rooms -------------------------------------------------------------
|
|
829
720
|
/**
|
|
830
|
-
*
|
|
831
|
-
*
|
|
832
|
-
*
|
|
833
|
-
*
|
|
834
|
-
*
|
|
835
|
-
*
|
|
836
|
-
*
|
|
837
|
-
* a
|
|
721
|
+
* Create a room, epoch-fenced to the calling persona.
|
|
722
|
+
*
|
|
723
|
+
* Room administration stays UNAUTHENTICATED and global: any current persona
|
|
724
|
+
* can create or delete any room, and nothing verifies who they are. What the
|
|
725
|
+
* fence removes is authority from a runtime the system positively knows is
|
|
726
|
+
* superseded. Leaving the highest-blast-radius operations as the only
|
|
727
|
+
* unfenced writes made the epoch model exempt exactly what it should protect
|
|
728
|
+
* most: a fenced-out runtime could not post a message but could delete the
|
|
729
|
+
* room the message was in.
|
|
730
|
+
*
|
|
731
|
+
* Rooms must precede JOINS, not bindings. create_persona/resume_persona is
|
|
732
|
+
* one call that every participating runtime makes anyway, and
|
|
733
|
+
* bind -> create_room -> join_room satisfies the bootstrap workflow.
|
|
838
734
|
*/
|
|
839
|
-
|
|
840
|
-
const next = this.db.prepare(`SELECT rowid AS rid, ${col} AS v FROM ${table}
|
|
841
|
-
WHERE instr(${col}, char(0)) > 0 AND rowid > ? ORDER BY rowid LIMIT 1`);
|
|
842
|
-
const fix = this.db.prepare(`UPDATE ${table} SET ${col} = ? WHERE rowid = ?`);
|
|
843
|
-
let cursor = 0;
|
|
844
|
-
for (;;) {
|
|
845
|
-
const row = next.get(cursor);
|
|
846
|
-
if (!row)
|
|
847
|
-
break;
|
|
848
|
-
fix.run(row.v.replace(/\u0000/g, "\ufffd"), row.rid);
|
|
849
|
-
cursor = row.rid;
|
|
850
|
-
}
|
|
851
|
-
}
|
|
852
|
-
// --- rooms -------------------------------------------------------------
|
|
853
|
-
createRoom(name, description, pinned) {
|
|
735
|
+
createRoom(name, description, pinned, agentId, epoch) {
|
|
854
736
|
assertStorable(name, "room name");
|
|
855
737
|
assertMaxLen(name, "room name", 200);
|
|
856
738
|
assertStorable(description, "room description");
|
|
857
739
|
assertMaxLen(description, "room description", 2000);
|
|
858
740
|
assertStorable(pinned, "room pinned intro");
|
|
859
741
|
assertMaxLen(pinned, "room pinned intro", 10_000);
|
|
860
|
-
|
|
861
|
-
.
|
|
862
|
-
|
|
863
|
-
|
|
742
|
+
const tx = this.db.transaction(() => {
|
|
743
|
+
this.requireEpoch(agentId, epoch);
|
|
744
|
+
return this.db
|
|
745
|
+
.prepare(`INSERT INTO rooms (name, description, pinned) VALUES (?, ?, ?)
|
|
746
|
+
RETURNING *`)
|
|
747
|
+
.get(name, description, pinned);
|
|
748
|
+
});
|
|
749
|
+
return tx.immediate();
|
|
864
750
|
}
|
|
865
|
-
setPinned(roomId, pinned) {
|
|
751
|
+
setPinned(roomId, agentId, epoch, pinned) {
|
|
866
752
|
assertStorable(pinned, "room pinned intro");
|
|
867
753
|
assertMaxLen(pinned, "room pinned intro", 10_000);
|
|
868
|
-
const
|
|
869
|
-
.
|
|
870
|
-
.
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
754
|
+
const tx = this.db.transaction(() => {
|
|
755
|
+
this.requireEpoch(agentId, epoch);
|
|
756
|
+
const info = this.db
|
|
757
|
+
.prepare("UPDATE rooms SET pinned = ? WHERE id = ?")
|
|
758
|
+
.run(pinned, roomId);
|
|
759
|
+
// 0 rows = the room vanished under us; report it instead of a false
|
|
760
|
+
// success the caller would trust.
|
|
761
|
+
if (info.changes === 0) {
|
|
762
|
+
throw new Error(`room ${roomId} no longer exists (deleted); rejoin with join_room`);
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
tx.immediate();
|
|
876
766
|
}
|
|
877
767
|
getRoom(roomId) {
|
|
878
768
|
return this.db.prepare("SELECT * FROM rooms WHERE id = ?").get(roomId);
|
|
@@ -997,6 +887,17 @@ export class ChatStore {
|
|
|
997
887
|
.get(agentId);
|
|
998
888
|
return c;
|
|
999
889
|
}
|
|
890
|
+
/** Names of the rooms this persona is present in. Bounded: this feeds an
|
|
891
|
+
* error message, and a persona in hundreds of rooms must not turn one
|
|
892
|
+
* rejection into an unbounded response. */
|
|
893
|
+
presentRoomNames(agentId, limit = 50) {
|
|
894
|
+
return this.db
|
|
895
|
+
.prepare(`SELECT r.name AS name FROM memberships m
|
|
896
|
+
JOIN rooms r ON r.id = m.room_id
|
|
897
|
+
WHERE m.agent_id = ? AND m.left_at IS NULL
|
|
898
|
+
ORDER BY r.id LIMIT ?`)
|
|
899
|
+
.all(agentId, limit).map((r) => r.name);
|
|
900
|
+
}
|
|
1000
901
|
/** Resolve a room reference that may be a numeric id or a name. */
|
|
1001
902
|
resolveRoom(ref) {
|
|
1002
903
|
// Number.isSafeInteger gate: a 16+ digit numeric ref past 2^53 rounds to a
|
|
@@ -1015,218 +916,245 @@ export class ChatStore {
|
|
|
1015
916
|
}
|
|
1016
917
|
return this.db.prepare("SELECT * FROM rooms WHERE name = ?").get(ref);
|
|
1017
918
|
}
|
|
1018
|
-
// ---
|
|
1019
|
-
upsertAgent(id, type, role, description) {
|
|
1020
|
-
assertStorable(id, "agent id");
|
|
1021
|
-
assertMaxLen(id, "agent id", 200);
|
|
1022
|
-
assertStorable(type, "agent type");
|
|
1023
|
-
assertMaxLen(type, "agent type", 100);
|
|
1024
|
-
assertStorable(role, "agent role");
|
|
1025
|
-
assertMaxLen(role, "agent role", 200);
|
|
1026
|
-
assertStorable(description, "agent description");
|
|
1027
|
-
assertMaxLen(description, "agent description", 2000);
|
|
1028
|
-
this.db
|
|
1029
|
-
.prepare(`INSERT INTO agents (id, type, role, description)
|
|
1030
|
-
VALUES (@id, @type, @role, @description)
|
|
1031
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
1032
|
-
type = COALESCE(excluded.type, agents.type),
|
|
1033
|
-
role = COALESCE(excluded.role, agents.role),
|
|
1034
|
-
description = COALESCE(excluded.description, agents.description)`)
|
|
1035
|
-
.run({ id, type, role, description });
|
|
1036
|
-
}
|
|
919
|
+
// --- personas -----------------------------------------------------------
|
|
1037
920
|
/**
|
|
1038
|
-
*
|
|
1039
|
-
*
|
|
1040
|
-
*
|
|
1041
|
-
*
|
|
921
|
+
* Verify that `epoch` is still the persona's current runtime epoch, throwing
|
|
922
|
+
* PersonaLostError if not.
|
|
923
|
+
*
|
|
924
|
+
* MUST be called from INSIDE the transaction that performs the guarded write.
|
|
925
|
+
* Checking first and writing afterwards is not equivalent: a takeover
|
|
926
|
+
* committing in the gap leaves the check passing and the write landing under
|
|
927
|
+
* an authority the caller no longer holds, which is the entire failure this
|
|
928
|
+
* exists to prevent. Every caller below is inside a tx.immediate().
|
|
1042
929
|
*/
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
const info = this.db
|
|
1053
|
-
.prepare(`INSERT INTO agents (id, type, role, description)
|
|
1054
|
-
VALUES (@id, @type, @role, @description)
|
|
1055
|
-
ON CONFLICT(id) DO NOTHING`)
|
|
1056
|
-
.run({ id, type, role, description });
|
|
1057
|
-
return info.changes > 0;
|
|
930
|
+
requireEpoch(agentId, epoch) {
|
|
931
|
+
const row = this.db
|
|
932
|
+
.prepare("SELECT runtime_epoch FROM agents WHERE id = ?")
|
|
933
|
+
.get(agentId);
|
|
934
|
+
if (!row)
|
|
935
|
+
throw new PersonaLostError(agentId, epoch, null);
|
|
936
|
+
if (row.runtime_epoch !== epoch) {
|
|
937
|
+
throw new PersonaLostError(agentId, epoch, row.runtime_epoch);
|
|
938
|
+
}
|
|
1058
939
|
}
|
|
1059
940
|
/**
|
|
1060
|
-
*
|
|
1061
|
-
*
|
|
1062
|
-
*
|
|
1063
|
-
*
|
|
1064
|
-
*
|
|
1065
|
-
*
|
|
941
|
+
* Verify that this persona is PRESENT in the room, throwing if it has left.
|
|
942
|
+
*
|
|
943
|
+
* Like requireEpoch, this MUST run inside the transaction that performs the
|
|
944
|
+
* guarded write. Checking first and writing afterwards leaves a leave_room
|
|
945
|
+
* committing in the gap, and the write then lands from a persona every peer
|
|
946
|
+
* has already been told is absent.
|
|
947
|
+
*
|
|
948
|
+
* Participation is what this guards: authoring, advancing a read marker,
|
|
949
|
+
* changing a role, taking a new claim, and opening a wait. It deliberately
|
|
950
|
+
* does NOT guard non-advancing history reads or releasing a claim you already
|
|
951
|
+
* hold -- both are useful while absent and neither claims presence.
|
|
952
|
+
*
|
|
953
|
+
* The soft-left membership row survives, so the remedy is always the same and
|
|
954
|
+
* the error says it: rejoin. join_room keeps the read marker and the role, so
|
|
955
|
+
* recovery costs one call and loses nothing.
|
|
1066
956
|
*/
|
|
1067
|
-
|
|
1068
|
-
const
|
|
1069
|
-
.prepare("SELECT
|
|
957
|
+
requirePresent(roomId, agentId) {
|
|
958
|
+
const row = this.db
|
|
959
|
+
.prepare("SELECT left_at FROM memberships WHERE room_id = ? AND agent_id = ?")
|
|
1070
960
|
.get(roomId, agentId);
|
|
1071
|
-
if (!
|
|
1072
|
-
|
|
1073
|
-
const live = this.db
|
|
1074
|
-
.prepare(`SELECT 1 FROM session_presence
|
|
1075
|
-
WHERE room_id = ? AND agent_id = ? AND left_at IS NULL
|
|
1076
|
-
AND updated_at >= datetime('now', ?) LIMIT 1`)
|
|
1077
|
-
.get(roomId, agentId, SESSION_GC_AGE);
|
|
1078
|
-
if (live) {
|
|
1079
|
-
this.db
|
|
1080
|
-
.prepare("UPDATE memberships SET left_at = NULL WHERE room_id = ? AND agent_id = ?")
|
|
1081
|
-
.run(roomId, agentId);
|
|
961
|
+
if (!row) {
|
|
962
|
+
throw new Error(`you are not a member of room ${roomId}; join_room it first`);
|
|
1082
963
|
}
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
.run(roomId, agentId);
|
|
964
|
+
if (row.left_at !== null) {
|
|
965
|
+
throw new Error(`you have LEFT room ${roomId}, so you cannot participate in it: ` +
|
|
966
|
+
`peers see you as absent. Call join_room to rejoin -- your read ` +
|
|
967
|
+
`position and room-local role are preserved.`);
|
|
1088
968
|
}
|
|
1089
969
|
}
|
|
970
|
+
/** The persona's current epoch, or null if the row is gone. For
|
|
971
|
+
* NON-advancing reads, which disclose loss without failing. */
|
|
972
|
+
currentEpoch(agentId) {
|
|
973
|
+
const row = this.db
|
|
974
|
+
.prepare("SELECT runtime_epoch FROM agents WHERE id = ?")
|
|
975
|
+
.get(agentId);
|
|
976
|
+
return row ? row.runtime_epoch : null;
|
|
977
|
+
}
|
|
978
|
+
getPersona(id) {
|
|
979
|
+
return this.db.prepare("SELECT * FROM agents WHERE id = ?").get(id);
|
|
980
|
+
}
|
|
1090
981
|
/**
|
|
1091
|
-
*
|
|
1092
|
-
*
|
|
1093
|
-
*
|
|
1094
|
-
* unmanaged (no-rows) identity -- THEN delete the left/expired rows.
|
|
982
|
+
* Claim a generated persona id atomically. Returns false if the id is taken,
|
|
983
|
+
* so a caller drawing candidate ids cannot collapse two personas onto one row
|
|
984
|
+
* (and with it one read marker, one membership set, and one claim owner).
|
|
1095
985
|
*/
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
this.db
|
|
1110
|
-
.prepare(`
|
|
1111
|
-
|
|
986
|
+
tryCreatePersona(p) {
|
|
987
|
+
assertStorable(p.id, "persona id");
|
|
988
|
+
assertMaxLen(p.id, "persona id", MAX_AGENT_ID_CHARS);
|
|
989
|
+
assertStorable(p.brand, "brand");
|
|
990
|
+
assertMaxLen(p.brand, "brand", 100);
|
|
991
|
+
assertStorable(p.model, "model");
|
|
992
|
+
assertMaxLen(p.model, "model", 100);
|
|
993
|
+
assertStorable(p.version, "version");
|
|
994
|
+
assertMaxLen(p.version, "version", 100);
|
|
995
|
+
assertStorable(p.resumeWord, "resume word");
|
|
996
|
+
assertMaxLen(p.resumeWord, "resume word", 200);
|
|
997
|
+
assertStorable(p.description, "persona description");
|
|
998
|
+
assertMaxLen(p.description, "persona description", 2000);
|
|
999
|
+
const info = this.db
|
|
1000
|
+
.prepare(`INSERT INTO agents
|
|
1001
|
+
(id, is_human, brand, model, version, resume_word, runtime_epoch, description)
|
|
1002
|
+
VALUES (@id, 0, @brand, @model, @version, @resumeWord, 1, @description)
|
|
1003
|
+
ON CONFLICT(id) DO NOTHING`)
|
|
1004
|
+
.run(p);
|
|
1005
|
+
return info.changes > 0;
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Bindingless resume: take over a persona and return the NEW epoch.
|
|
1009
|
+
*
|
|
1010
|
+
* The increment is unconditional on success and happens in the same
|
|
1011
|
+
* transaction as the credential check, so "latest valid resume wins" is
|
|
1012
|
+
* decided by SQLite's write serialization rather than by call ordering in any
|
|
1013
|
+
* one process. It runs on EVERY successful attach, not only on a contested
|
|
1014
|
+
* one: a caller cannot tell whether the previous runtime is alive, and an
|
|
1015
|
+
* attach that skipped the increment would leave that runtime's pollers and
|
|
1016
|
+
* captured epochs valid alongside the new one's.
|
|
1017
|
+
*
|
|
1018
|
+
* The two failures are deliberately distinguished: an unknown id says so, and
|
|
1019
|
+
* bad credentials on a known id say so. That is the right trade HERE because
|
|
1020
|
+
* the threat model is an operator pasting the wrong id, not an attacker
|
|
1021
|
+
* probing for valid ones -- an operator who mistyped an id needs to be told
|
|
1022
|
+
* that, not handed a uniform "rejected" that sends them hunting for a lost
|
|
1023
|
+
* word. This is collision prevention, not authentication.
|
|
1024
|
+
*/
|
|
1025
|
+
attachPersona(a) {
|
|
1026
|
+
return this.db
|
|
1027
|
+
.transaction(() => {
|
|
1028
|
+
const row = this.getPersona(a.id);
|
|
1029
|
+
if (!row || row.is_human === 1) {
|
|
1030
|
+
throw new Error(`no LLM persona "${a.id}"; check the id, or create_persona for a new one`);
|
|
1031
|
+
}
|
|
1032
|
+
// Word FIRST, then tuple. A caller who cannot present the word has no
|
|
1033
|
+
// standing to be told anything about the persona, including which model
|
|
1034
|
+
// it is; only after the word matches is a tuple mismatch meaningful --
|
|
1035
|
+
// and then it is a model change, not a rejected credential.
|
|
1036
|
+
if (row.resume_word !== a.resumeWord) {
|
|
1037
|
+
throw new Error(`resume rejected for persona "${a.id}": the resume word does not ` +
|
|
1038
|
+
`match the one it was created with`);
|
|
1039
|
+
}
|
|
1040
|
+
if (row.brand !== a.brand ||
|
|
1041
|
+
row.model !== a.model ||
|
|
1042
|
+
row.version !== a.version) {
|
|
1043
|
+
throw new ModelTupleMismatchError(a.id,
|
|
1044
|
+
// Non-null by the is_human check above: the table CHECK keeps an
|
|
1045
|
+
// LLM row's tuple whole.
|
|
1046
|
+
{ brand: row.brand, model: row.model, version: row.version }, { brand: a.brand, model: a.model, version: a.version }, this.presentRoomNames(a.id));
|
|
1047
|
+
}
|
|
1048
|
+
const next = row.runtime_epoch + 1;
|
|
1049
|
+
this.db
|
|
1050
|
+
.prepare("UPDATE agents SET runtime_epoch = ? WHERE id = ?")
|
|
1051
|
+
.run(next, a.id);
|
|
1052
|
+
return { epoch: next, persona: { ...row, runtime_epoch: next } };
|
|
1053
|
+
})
|
|
1054
|
+
.immediate();
|
|
1112
1055
|
}
|
|
1056
|
+
// --- membership ---------------------------------------------------------
|
|
1113
1057
|
/**
|
|
1114
|
-
* Join (or rejoin) a room: clears any prior leave
|
|
1115
|
-
*
|
|
1116
|
-
*
|
|
1117
|
-
*
|
|
1118
|
-
*
|
|
1058
|
+
* Join (or rejoin) a room: clears any prior leave and refreshes liveness.
|
|
1059
|
+
*
|
|
1060
|
+
* Presence is PER PERSONA. With one runtime per persona there is no second
|
|
1061
|
+
* live seat to reconcile: a takeover fences the loser out instead.
|
|
1062
|
+
*
|
|
1063
|
+
* `cursorStart` applies ONLY when this call creates the membership. A rejoin
|
|
1064
|
+
* must never move a cursor: the whole point of resuming a persona is that its
|
|
1065
|
+
* read position survives, and a "start at latest" that also applied on rejoin
|
|
1066
|
+
* would silently discard the backlog it was resumed to read.
|
|
1119
1067
|
*/
|
|
1120
|
-
joinRoom(roomId, agentId,
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1068
|
+
joinRoom(roomId, agentId, epoch, opts = {}) {
|
|
1069
|
+
if (opts.role !== undefined) {
|
|
1070
|
+
assertStorable(opts.role, "role");
|
|
1071
|
+
assertMaxLen(opts.role, "role", 200);
|
|
1072
|
+
assertRole(opts.role);
|
|
1073
|
+
}
|
|
1074
|
+
// One IMMEDIATE transaction: this is a multi-statement read-then-write path,
|
|
1075
|
+
// and a cross-process deleteRoom interleaving between statements otherwise
|
|
1076
|
+
// surfaces as an opaque NOT NULL/FK constraint error instead of a clean
|
|
1077
|
+
// failure. It also scopes the epoch guard to the same transaction as every
|
|
1078
|
+
// write below it.
|
|
1125
1079
|
const tx = this.db.transaction(() => {
|
|
1080
|
+
this.requireEpoch(agentId, epoch);
|
|
1126
1081
|
// Existence check INSIDE the write transaction: the caller resolved the
|
|
1127
1082
|
// room earlier, and a cross-process delete_room in that window otherwise
|
|
1128
1083
|
// surfaces as a raw "FOREIGN KEY constraint failed".
|
|
1129
1084
|
this.requireRoom(roomId);
|
|
1130
|
-
this.db
|
|
1085
|
+
const info = this.db
|
|
1131
1086
|
.prepare("INSERT OR IGNORE INTO memberships (room_id, agent_id) VALUES (?, ?)")
|
|
1132
1087
|
.run(roomId, agentId);
|
|
1088
|
+
const created = info.changes > 0;
|
|
1089
|
+
if (created && opts.cursorStart === "latest") {
|
|
1090
|
+
// Baseline a NEW membership at the room head so the joiner sees only
|
|
1091
|
+
// what arrives from now on. Read inside this transaction, so a message
|
|
1092
|
+
// committed concurrently is either below the baseline (skipped, as
|
|
1093
|
+
// asked) or above it (delivered) -- never lost between the two.
|
|
1094
|
+
this.db
|
|
1095
|
+
.prepare(`UPDATE memberships
|
|
1096
|
+
SET last_read_seq = COALESCE(
|
|
1097
|
+
(SELECT MAX(seq) FROM messages WHERE room_id = ?), 0)
|
|
1098
|
+
WHERE room_id = ? AND agent_id = ?`)
|
|
1099
|
+
.run(roomId, roomId, agentId);
|
|
1100
|
+
}
|
|
1133
1101
|
this.db
|
|
1134
1102
|
.prepare(`UPDATE memberships SET left_at = NULL, last_seen = datetime('now')
|
|
1135
1103
|
WHERE room_id = ? AND agent_id = ?`)
|
|
1136
1104
|
.run(roomId, agentId);
|
|
1137
|
-
if (
|
|
1138
|
-
// Register/refresh this session's presence (present => left_at NULL).
|
|
1139
|
-
this.db
|
|
1140
|
-
.prepare(`INSERT INTO session_presence (room_id, agent_id, session_id)
|
|
1141
|
-
VALUES (?, ?, ?)
|
|
1142
|
-
ON CONFLICT(room_id, agent_id, session_id) DO UPDATE SET
|
|
1143
|
-
updated_at = datetime('now'), left_at = NULL`)
|
|
1144
|
-
.run(roomId, agentId, presenceId);
|
|
1145
|
-
// Reap dead presence rows and reconcile memberships.left_at.
|
|
1146
|
-
this.gcSessionPresence(roomId);
|
|
1147
|
-
}
|
|
1148
|
-
if (sessionId !== null) {
|
|
1149
|
-
// Private cursor: seed a new one from the identity marker; an existing
|
|
1150
|
-
// one keeps its position (refresh only updated_at against the GC, which
|
|
1151
|
-
// must never reap the very session this join resumes).
|
|
1105
|
+
if (opts.role !== undefined) {
|
|
1152
1106
|
this.db
|
|
1153
|
-
.prepare(
|
|
1154
|
-
|
|
1155
|
-
ON CONFLICT(room_id, agent_id, session_id) DO UPDATE SET
|
|
1156
|
-
updated_at = datetime('now')`)
|
|
1157
|
-
.run(roomId, agentId, sessionId, roomId, agentId);
|
|
1158
|
-
this.db
|
|
1159
|
-
.prepare("DELETE FROM session_markers WHERE room_id = ? AND updated_at < datetime('now', ?)")
|
|
1160
|
-
.run(roomId, SESSION_GC_AGE);
|
|
1107
|
+
.prepare("UPDATE memberships SET role = ? WHERE room_id = ? AND agent_id = ?")
|
|
1108
|
+
.run(opts.role, roomId, agentId);
|
|
1161
1109
|
}
|
|
1110
|
+
return { created };
|
|
1162
1111
|
});
|
|
1163
|
-
tx.immediate();
|
|
1112
|
+
return tx.immediate();
|
|
1164
1113
|
}
|
|
1165
1114
|
/**
|
|
1166
|
-
*
|
|
1167
|
-
*
|
|
1168
|
-
*
|
|
1169
|
-
*
|
|
1115
|
+
* Set or clear this persona's ROOM-LOCAL role. `role` is the new value and
|
|
1116
|
+
* null clears it; there is no "omit to keep" path here, because omission is
|
|
1117
|
+
* handled by not calling this at all. Room-local by construction: the same
|
|
1118
|
+
* persona keeps a different role in every other room.
|
|
1170
1119
|
*/
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1120
|
+
setRole(roomId, agentId, epoch, role) {
|
|
1121
|
+
assertStorable(role, "role");
|
|
1122
|
+
assertMaxLen(role, "role", 200);
|
|
1123
|
+
assertRole(role);
|
|
1124
|
+
const tx = this.db.transaction(() => {
|
|
1125
|
+
this.requireEpoch(agentId, epoch);
|
|
1126
|
+
this.requireRoom(roomId);
|
|
1127
|
+
// A role describes what you are IN THIS ROOM; you cannot hold one while
|
|
1128
|
+
// absent.
|
|
1129
|
+
this.requirePresent(roomId, agentId);
|
|
1130
|
+
// Refresh last_seen in the SAME statement. A role change is a deliberate
|
|
1131
|
+
// act in THIS room, so the seat is demonstrably attended here; leaving
|
|
1132
|
+
// last_seen alone would let an explicit set_role in room B refresh only
|
|
1133
|
+
// whatever room the runtime happens to have active.
|
|
1134
|
+
const info = this.db
|
|
1135
|
+
.prepare(`UPDATE memberships SET role = ?, last_seen = datetime('now')
|
|
1136
|
+
WHERE room_id = ? AND agent_id = ?`)
|
|
1137
|
+
.run(role, roomId, agentId);
|
|
1138
|
+
if (info.changes === 0) {
|
|
1139
|
+
throw new Error(`you are not a member of room ${roomId}; join_room it first`);
|
|
1140
|
+
}
|
|
1141
|
+
});
|
|
1142
|
+
tx.immediate();
|
|
1143
|
+
}
|
|
1144
|
+
/** This persona's room-local role, or null. */
|
|
1145
|
+
getRole(roomId, agentId) {
|
|
1146
|
+
const row = this.db
|
|
1147
|
+
.prepare("SELECT role FROM memberships WHERE room_id = ? AND agent_id = ?")
|
|
1148
|
+
.get(roomId, agentId);
|
|
1149
|
+
return row ? row.role : null;
|
|
1175
1150
|
}
|
|
1176
1151
|
/**
|
|
1177
|
-
* Soft leave: keep the membership row (and read
|
|
1178
|
-
*
|
|
1179
|
-
* lagging session's true read position lives only in its session_markers row,
|
|
1180
|
-
* and dead ones are reaped by the 7-day GC.
|
|
1181
|
-
*
|
|
1182
|
-
* SESSION-aware via session_presence: mark this session's presence row left,
|
|
1183
|
-
* then recompute the identity-level memberships.left_at from the surviving
|
|
1184
|
-
* sessions -- present iff any twin (shared OR private) still has a live
|
|
1185
|
-
* presence row. So one session leaving never evicts a live twin, for EVERY
|
|
1186
|
-
* cursor mode (the earlier session_markers-only twin check saw private
|
|
1187
|
-
* sessions only, so shared twins evicted each other and a private leave
|
|
1188
|
-
* evicted a shared twin). A caller with no presence row (presenceId null, or
|
|
1189
|
-
* a pre-redesign session) falls back to an identity-level leave.
|
|
1152
|
+
* Soft leave: keep the membership row (and its read position and role) but
|
|
1153
|
+
* mark the persona not present. Rejoining resumes exactly where it left off.
|
|
1190
1154
|
*/
|
|
1191
|
-
leaveRoom(roomId, agentId,
|
|
1155
|
+
leaveRoom(roomId, agentId, epoch) {
|
|
1192
1156
|
const tx = this.db.transaction(() => {
|
|
1193
|
-
|
|
1194
|
-
// beyond join, so a crashed twin's aged row is reaped (and its identity
|
|
1195
|
-
// recomputed) whenever anyone leaves, not only on the next join. The
|
|
1196
|
-
// active leaver's own row was refreshed on its last join/touch, so it is
|
|
1197
|
-
// not expired and is not reaped here.
|
|
1198
|
-
this.gcSessionPresence(roomId);
|
|
1199
|
-
if (presenceId !== null) {
|
|
1200
|
-
const row = this.db
|
|
1201
|
-
.prepare("SELECT 1 FROM session_presence WHERE room_id = ? AND agent_id = ? AND session_id = ?")
|
|
1202
|
-
.get(roomId, agentId, presenceId);
|
|
1203
|
-
if (row) {
|
|
1204
|
-
const s = this.db
|
|
1205
|
-
.prepare(`UPDATE session_presence SET left_at = datetime('now')
|
|
1206
|
-
WHERE room_id = ? AND agent_id = ? AND session_id = ? AND left_at IS NULL`)
|
|
1207
|
-
.run(roomId, agentId, presenceId);
|
|
1208
|
-
// Reconcile the identity flag from the sessions that remain.
|
|
1209
|
-
this.recomputeMembershipPresence(roomId, agentId);
|
|
1210
|
-
return s.changes > 0; // true iff this session went present -> left
|
|
1211
|
-
}
|
|
1212
|
-
// No presence row for THIS session -- e.g. the 7-day GC reaped it while
|
|
1213
|
-
// the process stayed alive (idle, no touch). If the identity still has
|
|
1214
|
-
// OTHER presence rows (a live twin), reconcile from them rather than
|
|
1215
|
-
// blindly evicting via the identity-level leave below (which would defeat
|
|
1216
|
-
// the redesign's no-twin-eviction guarantee). Only an identity with NO
|
|
1217
|
-
// presence rows at all (web viewer, tests, pre-redesign) takes that path.
|
|
1218
|
-
}
|
|
1219
|
-
// The same protection is required for a legacy/sessionless leave. During
|
|
1220
|
-
// a rolling upgrade it may share an identity with current session-aware
|
|
1221
|
-
// twins; an unconditional identity leave must not evict those live rows.
|
|
1222
|
-
const anyLive = this.db
|
|
1223
|
-
.prepare(`SELECT 1 FROM session_presence
|
|
1224
|
-
WHERE room_id = ? AND agent_id = ? AND left_at IS NULL LIMIT 1`)
|
|
1225
|
-
.get(roomId, agentId);
|
|
1226
|
-
if (anyLive) {
|
|
1227
|
-
this.recomputeMembershipPresence(roomId, agentId);
|
|
1228
|
-
return false;
|
|
1229
|
-
}
|
|
1157
|
+
this.requireEpoch(agentId, epoch);
|
|
1230
1158
|
const info = this.db
|
|
1231
1159
|
.prepare(`UPDATE memberships SET left_at = datetime('now'), last_seen = datetime('now')
|
|
1232
1160
|
WHERE room_id = ? AND agent_id = ? AND left_at IS NULL`)
|
|
@@ -1236,192 +1164,108 @@ export class ChatStore {
|
|
|
1236
1164
|
return tx.immediate();
|
|
1237
1165
|
}
|
|
1238
1166
|
/**
|
|
1239
|
-
*
|
|
1240
|
-
*
|
|
1241
|
-
* active room (post-leave my_mentions polling must still shield cursors).
|
|
1242
|
-
*/
|
|
1243
|
-
touchSessionMarkers(_agentId, sessionId) {
|
|
1244
|
-
// Key on session_id ALONE, not (agent_id, session_id): the session_id is a
|
|
1245
|
-
// process-unique nonce, and a single process can switch identity
|
|
1246
|
-
// (join_room under a new agent_id). Its earlier identity's private cursor
|
|
1247
|
-
// still belongs to THIS live session, so it must keep being refreshed;
|
|
1248
|
-
// scoping the refresh to the current agent_id let that older cursor age
|
|
1249
|
-
// out and get GC'd, and switching back recreated it at the identity
|
|
1250
|
-
// marker, silently skipping everything the private cursor had not read.
|
|
1251
|
-
this.db
|
|
1252
|
-
.prepare("UPDATE session_markers SET updated_at = datetime('now') WHERE session_id = ?")
|
|
1253
|
-
.run(sessionId);
|
|
1254
|
-
}
|
|
1255
|
-
/**
|
|
1256
|
-
* Mark an active agent alive. Also clears the ACTIVE room's left_at (an
|
|
1257
|
-
* actively-acting session re-asserts identity presence there). With
|
|
1258
|
-
* presenceId, refresh this session's cursor rows (every identity) and the
|
|
1259
|
-
* current identity's presence rows in EVERY room against the 7-day GC, and
|
|
1260
|
-
* reconcile the active room's presence.
|
|
1261
|
-
*
|
|
1262
|
-
* One IMMEDIATE transaction: these statements ran as separate autocommits,
|
|
1263
|
-
* and another process's GC interleaving between the membership update and
|
|
1264
|
-
* the presence upsert could recompute from the half-applied state, leaving
|
|
1265
|
-
* a live presence row beside a left membership (an active session hidden
|
|
1266
|
-
* from inboxes and pollers) until the next touch.
|
|
1167
|
+
* Mark a persona alive in a room, clearing that room's left_at (an actively
|
|
1168
|
+
* acting runtime re-asserts presence there).
|
|
1267
1169
|
*
|
|
1268
|
-
*
|
|
1269
|
-
*
|
|
1270
|
-
*
|
|
1271
|
-
* the
|
|
1272
|
-
*
|
|
1273
|
-
*
|
|
1170
|
+
* EPOCH-FENCED like any other write. A liveness touch looks harmless, but it
|
|
1171
|
+
* is what makes a persona read as present/active to every other agent and to
|
|
1172
|
+
* recipient status; an unfenced touch would let a runtime that has already
|
|
1173
|
+
* lost the persona keep publishing it as an available listener, which is the
|
|
1174
|
+
* false-listener-truth failure the epoch exists to close. Throwing here is
|
|
1175
|
+
* correct: the caller treats liveness as best-effort and the next real
|
|
1176
|
+
* operation reports persona_lost.
|
|
1274
1177
|
*/
|
|
1275
|
-
touch(roomId, agentId,
|
|
1178
|
+
touch(roomId, agentId, epoch) {
|
|
1276
1179
|
const tx = this.db.transaction(() => {
|
|
1180
|
+
this.requireEpoch(agentId, epoch);
|
|
1277
1181
|
this.db
|
|
1278
|
-
.prepare(
|
|
1182
|
+
.prepare(`UPDATE memberships SET last_seen = datetime('now'), left_at = NULL
|
|
1183
|
+
WHERE room_id = ? AND agent_id = ?`)
|
|
1279
1184
|
.run(roomId, agentId);
|
|
1280
|
-
if (presenceId !== null) {
|
|
1281
|
-
// Re-assert this session's presence in the ACTIVE room (recreating a
|
|
1282
|
-
// row the 7-day GC reaped while the process stayed alive but idle), so
|
|
1283
|
-
// a NULL memberships.left_at is always backed by a live presence row
|
|
1284
|
-
// and a later leave or crash reconciles correctly. The active room is
|
|
1285
|
-
// never one the session soft-left (leave clears the session's active
|
|
1286
|
-
// room), so this cannot resurrect a left room's presence. Upsert FIRST
|
|
1287
|
-
// so the GC below never reaps the toucher itself.
|
|
1288
|
-
this.db
|
|
1289
|
-
.prepare(`INSERT INTO session_presence (room_id, agent_id, session_id)
|
|
1290
|
-
VALUES (?, ?, ?)
|
|
1291
|
-
ON CONFLICT(room_id, agent_id, session_id) DO UPDATE SET
|
|
1292
|
-
updated_at = datetime('now'), left_at = NULL`)
|
|
1293
|
-
.run(roomId, agentId, presenceId);
|
|
1294
|
-
this.gcSessionPresence(roomId);
|
|
1295
|
-
this.touchSessionAlive(presenceId, agentId);
|
|
1296
|
-
}
|
|
1297
1185
|
});
|
|
1298
1186
|
tx.immediate();
|
|
1299
1187
|
}
|
|
1300
1188
|
/**
|
|
1301
|
-
*
|
|
1302
|
-
*
|
|
1303
|
-
*
|
|
1304
|
-
* identity the session has ever held (see touchSessionMarkers for why).
|
|
1305
|
-
* PRESENCE rows are refreshed only for the CURRENT identity: a session that
|
|
1306
|
-
* switched identity no longer acts as the old one, so the old identity's
|
|
1307
|
-
* presence must age out via the GC and read as left -- a nonce-wide refresh
|
|
1308
|
-
* kept it `present` for the life of the process with no way to leave it. The
|
|
1309
|
-
* old identity's preserved cursor row means a later rejoin under that id
|
|
1310
|
-
* resumes its exact read position. LEFT rows (leave tombstones) are refreshed
|
|
1311
|
-
* too -- the GC reaps by updated_at alone, so a live session's my_mentions
|
|
1312
|
-
* muting otherwise silently expired at the GC age while the session kept
|
|
1313
|
-
* polling; left_at itself is never cleared here, and a dead session's
|
|
1314
|
-
* tombstones still age out. Used for the no-active-room case (post-leave
|
|
1315
|
-
* my_mentions polling) and by touch().
|
|
1316
|
-
*/
|
|
1317
|
-
touchSessionAlive(sessionId, agentId) {
|
|
1318
|
-
this.touchSessionMarkers("", sessionId);
|
|
1319
|
-
this.db
|
|
1320
|
-
.prepare("UPDATE session_presence SET updated_at = datetime('now') WHERE session_id = ? AND agent_id = ?")
|
|
1321
|
-
.run(sessionId, agentId);
|
|
1322
|
-
}
|
|
1323
|
-
/**
|
|
1324
|
-
* Refresh activity for one room captured by a cross-room operation without
|
|
1325
|
-
* silently rejoining it. The exact session-presence row must still be live;
|
|
1326
|
-
* a left/tombstoned or never-joined session is a no-op even when a twin keeps
|
|
1327
|
-
* the identity-level membership present. IMMEDIATE makes the live-row check
|
|
1328
|
-
* and refresh atomic with a concurrent leave.
|
|
1189
|
+
* Refresh activity for one room captured by a cross-room operation, WITHOUT
|
|
1190
|
+
* rejoining it: a room this persona explicitly left stays left. Returns false
|
|
1191
|
+
* when there was no present membership to refresh.
|
|
1329
1192
|
*/
|
|
1330
|
-
|
|
1193
|
+
touchJoinedRoom(roomId, agentId, epoch) {
|
|
1331
1194
|
const tx = this.db.transaction(() => {
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
.get(roomId, agentId, sessionId);
|
|
1337
|
-
if (!live)
|
|
1338
|
-
return false;
|
|
1339
|
-
this.db
|
|
1340
|
-
.prepare(`UPDATE session_presence SET updated_at = datetime('now')
|
|
1341
|
-
WHERE room_id = ? AND agent_id = ? AND session_id = ?
|
|
1342
|
-
AND left_at IS NULL`)
|
|
1343
|
-
.run(roomId, agentId, sessionId);
|
|
1344
|
-
this.db
|
|
1345
|
-
.prepare(`UPDATE memberships SET last_seen = datetime('now'), left_at = NULL
|
|
1346
|
-
WHERE room_id = ? AND agent_id = ?`)
|
|
1195
|
+
this.requireEpoch(agentId, epoch);
|
|
1196
|
+
const info = this.db
|
|
1197
|
+
.prepare(`UPDATE memberships SET last_seen = datetime('now')
|
|
1198
|
+
WHERE room_id = ? AND agent_id = ? AND left_at IS NULL`)
|
|
1347
1199
|
.run(roomId, agentId);
|
|
1348
|
-
return
|
|
1200
|
+
return info.changes > 0;
|
|
1349
1201
|
});
|
|
1350
1202
|
return tx.immediate();
|
|
1351
1203
|
}
|
|
1352
1204
|
/**
|
|
1353
|
-
* Open an in-turn wait lease: this
|
|
1354
|
-
*
|
|
1355
|
-
*
|
|
1356
|
-
*
|
|
1357
|
-
*
|
|
1358
|
-
*
|
|
1205
|
+
* Open an in-turn wait lease: this persona has a blocking catch_up pending in
|
|
1206
|
+
* this room. TTL covers the wait plus grace, so a hard-killed process cannot
|
|
1207
|
+
* leave a permanent "watching" ghost. Expired rows for the room are reaped in
|
|
1208
|
+
* passing.
|
|
1209
|
+
*
|
|
1210
|
+
* The upsert writes the CALLER'S epoch into the row (`epoch = excluded.epoch`
|
|
1211
|
+
* on conflict). Leaving the old value in place would invert the guard in
|
|
1212
|
+
* endWaitLease: the winner's row would carry the loser's epoch, so the
|
|
1213
|
+
* loser's cleanup would match and delete it while the winner's own cleanup
|
|
1214
|
+
* would not.
|
|
1359
1215
|
*/
|
|
1360
|
-
beginWaitLease(roomId, agentId,
|
|
1216
|
+
beginWaitLease(roomId, agentId, epoch, ttlSeconds) {
|
|
1361
1217
|
const tx = this.db.transaction(() => {
|
|
1218
|
+
this.requireEpoch(agentId, epoch);
|
|
1362
1219
|
this.requireRoom(roomId);
|
|
1220
|
+
// A lease ADVERTISES this persona as watching the room. An absent
|
|
1221
|
+
// persona advertising a live watch is the exact contradiction the
|
|
1222
|
+
// present-membership rule exists to remove.
|
|
1223
|
+
this.requirePresent(roomId, agentId);
|
|
1363
1224
|
this.db
|
|
1364
1225
|
.prepare("DELETE FROM wait_leases WHERE room_id = ? AND expires_at <= datetime('now')")
|
|
1365
1226
|
.run(roomId);
|
|
1366
1227
|
this.db
|
|
1367
|
-
.prepare(`INSERT INTO wait_leases (room_id, agent_id,
|
|
1228
|
+
.prepare(`INSERT INTO wait_leases (room_id, agent_id, epoch, expires_at)
|
|
1368
1229
|
VALUES (?, ?, ?, datetime('now', '+' || ? || ' seconds'))
|
|
1369
|
-
ON CONFLICT(room_id, agent_id
|
|
1370
|
-
|
|
1371
|
-
|
|
1230
|
+
ON CONFLICT(room_id, agent_id) DO UPDATE SET
|
|
1231
|
+
epoch = excluded.epoch,
|
|
1232
|
+
started_at = datetime('now'),
|
|
1233
|
+
expires_at = excluded.expires_at`)
|
|
1234
|
+
.run(roomId, agentId, epoch, Math.max(1, Math.floor(ttlSeconds)));
|
|
1372
1235
|
});
|
|
1373
1236
|
tx.immediate();
|
|
1374
1237
|
}
|
|
1375
|
-
/**
|
|
1376
|
-
|
|
1238
|
+
/**
|
|
1239
|
+
* Close an in-turn wait lease (normal return, timeout, or abort alike).
|
|
1240
|
+
*
|
|
1241
|
+
* Guarded on the epoch the caller CAPTURED when it opened the lease, never on
|
|
1242
|
+
* the persona's current epoch. A fenced-out runtime still runs its finally
|
|
1243
|
+
* block, and that cleanup can land after the winner has already replaced the
|
|
1244
|
+
* row; an unguarded DELETE would then remove the winner's live lease and
|
|
1245
|
+
* report it as not watching. Deliberately NOT wrapped in requireEpoch: a lost
|
|
1246
|
+
* runtime must still be able to clean up after itself, it just must not touch
|
|
1247
|
+
* anything that is no longer its own.
|
|
1248
|
+
*/
|
|
1249
|
+
endWaitLease(roomId, agentId, epoch) {
|
|
1377
1250
|
this.db
|
|
1378
|
-
.prepare("DELETE FROM wait_leases WHERE room_id = ? AND agent_id = ? AND
|
|
1379
|
-
.run(roomId, agentId,
|
|
1251
|
+
.prepare("DELETE FROM wait_leases WHERE room_id = ? AND agent_id = ? AND epoch = ?")
|
|
1252
|
+
.run(roomId, agentId, epoch);
|
|
1380
1253
|
}
|
|
1381
1254
|
getMembership(roomId, agentId) {
|
|
1382
1255
|
return this.db
|
|
1383
1256
|
.prepare("SELECT last_read_seq, left_at FROM memberships WHERE room_id = ? AND agent_id = ?")
|
|
1384
1257
|
.get(roomId, agentId);
|
|
1385
1258
|
}
|
|
1386
|
-
/**
|
|
1387
|
-
*
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
* (read) transaction.
|
|
1391
|
-
*/
|
|
1392
|
-
getCursor(roomId, agentId, sessionId) {
|
|
1393
|
-
const membership = this.getMembership(roomId, agentId);
|
|
1394
|
-
if (!membership || sessionId === null)
|
|
1395
|
-
return membership;
|
|
1396
|
-
const s = this.db
|
|
1397
|
-
.prepare("SELECT last_read_seq FROM session_markers WHERE room_id = ? AND agent_id = ? AND session_id = ?")
|
|
1398
|
-
.get(roomId, agentId, sessionId);
|
|
1399
|
-
return s
|
|
1400
|
-
? { last_read_seq: s.last_read_seq, left_at: membership.left_at }
|
|
1401
|
-
: membership;
|
|
1259
|
+
/** Read position for (room, persona). Read-only, so it is safe inside a
|
|
1260
|
+
* deferred (read) transaction. */
|
|
1261
|
+
getCursor(roomId, agentId) {
|
|
1262
|
+
return this.getMembership(roomId, agentId);
|
|
1402
1263
|
}
|
|
1403
|
-
/**
|
|
1404
|
-
*
|
|
1405
|
-
|
|
1406
|
-
* session cursor exactly and raises the identity marker monotonically to the
|
|
1407
|
-
* MAX across sessions, keeping it meaningful as "some session of this
|
|
1408
|
-
* identity has read this far" for read receipts.
|
|
1409
|
-
*/
|
|
1410
|
-
setCursor(roomId, agentId, sessionId, seq) {
|
|
1411
|
-
if (sessionId === null) {
|
|
1412
|
-
this.db
|
|
1413
|
-
.prepare("UPDATE memberships SET last_read_seq = ? WHERE room_id = ? AND agent_id = ?")
|
|
1414
|
-
.run(seq, roomId, agentId);
|
|
1415
|
-
return;
|
|
1416
|
-
}
|
|
1417
|
-
this.db
|
|
1418
|
-
.prepare(`INSERT INTO session_markers (room_id, agent_id, session_id, last_read_seq)
|
|
1419
|
-
VALUES (?, ?, ?, ?)
|
|
1420
|
-
ON CONFLICT(room_id, agent_id, session_id) DO UPDATE SET
|
|
1421
|
-
last_read_seq = excluded.last_read_seq, updated_at = datetime('now')`)
|
|
1422
|
-
.run(roomId, agentId, sessionId, seq);
|
|
1264
|
+
/** Move the read position to seq exactly. Rewind is allowed, which is what
|
|
1265
|
+
* mark_read's explicit-seq form relies on. */
|
|
1266
|
+
setCursor(roomId, agentId, seq) {
|
|
1423
1267
|
this.db
|
|
1424
|
-
.prepare("UPDATE memberships SET last_read_seq =
|
|
1268
|
+
.prepare("UPDATE memberships SET last_read_seq = ? WHERE room_id = ? AND agent_id = ?")
|
|
1425
1269
|
.run(seq, roomId, agentId);
|
|
1426
1270
|
}
|
|
1427
1271
|
/**
|
|
@@ -1443,13 +1287,12 @@ export class ChatStore {
|
|
|
1443
1287
|
return floor;
|
|
1444
1288
|
}
|
|
1445
1289
|
/**
|
|
1446
|
-
* Advance
|
|
1447
|
-
*
|
|
1448
|
-
* aggregate: it is either the latest peer seq in the room or the
|
|
1449
|
-
* cursor when no later peer exists.
|
|
1450
|
-
*
|
|
1451
|
-
*
|
|
1452
|
-
* dead cursors must still GC.
|
|
1290
|
+
* Advance the author's own cursor past a self-authored post, but only from at
|
|
1291
|
+
* or beyond a proven safe floor. The caller derives that floor from the
|
|
1292
|
+
* crossing aggregate: it is either the latest peer seq in the room or the
|
|
1293
|
+
* posting cursor when no later peer exists. Guarding on the floor is what
|
|
1294
|
+
* keeps this from stepping over an unread PEER message that happens to sit
|
|
1295
|
+
* below the new post.
|
|
1453
1296
|
*/
|
|
1454
1297
|
advanceOwnOnlyCursors(roomId, agentId, throughSeq, safeFloor) {
|
|
1455
1298
|
this.db
|
|
@@ -1457,11 +1300,6 @@ export class ChatStore {
|
|
|
1457
1300
|
SET last_read_seq = max(last_read_seq, ?)
|
|
1458
1301
|
WHERE room_id = ? AND agent_id = ? AND last_read_seq >= ?`)
|
|
1459
1302
|
.run(throughSeq, roomId, agentId, safeFloor);
|
|
1460
|
-
this.db
|
|
1461
|
-
.prepare(`UPDATE session_markers
|
|
1462
|
-
SET last_read_seq = max(last_read_seq, ?)
|
|
1463
|
-
WHERE room_id = ? AND agent_id = ? AND last_read_seq >= ?`)
|
|
1464
|
-
.run(throughSeq, roomId, agentId, safeFloor);
|
|
1465
1303
|
}
|
|
1466
1304
|
/**
|
|
1467
1305
|
* Agents in a room, bounded like listRooms: at most `limit` rows (with the
|
|
@@ -1469,7 +1307,10 @@ export class ChatStore {
|
|
|
1469
1307
|
*/
|
|
1470
1308
|
listAgents(roomId, activeWithinMinutes, filter, limit = 200, after) {
|
|
1471
1309
|
const PREVIEW = 300;
|
|
1472
|
-
|
|
1310
|
+
// role comes from the MEMBERSHIP, not the persona: it is what this persona
|
|
1311
|
+
// is in THIS room.
|
|
1312
|
+
const cols = `SELECT a.id, a.brand, a.model, a.version, a.is_human,
|
|
1313
|
+
m.role AS role,
|
|
1473
1314
|
substr(a.description, 1, ${PREVIEW}) AS description,
|
|
1474
1315
|
CASE WHEN length(a.description) > ${PREVIEW} THEN 1 ELSE 0 END AS description_cut,
|
|
1475
1316
|
m.joined_at, m.rowid AS _rid,
|
|
@@ -1493,7 +1334,13 @@ export class ChatStore {
|
|
|
1493
1334
|
// Literal-substring semantics: escape LIKE wildcards so a filter of
|
|
1494
1335
|
// "50%" matches those three characters, not everything.
|
|
1495
1336
|
base.like = `%${filter.trim().replace(/[\\%_]/g, "\\$&")}%`;
|
|
1496
|
-
|
|
1337
|
+
// Every field this listing SHOWS is searchable. version was missing, so
|
|
1338
|
+
// "find the runtimes still on v2" returned nothing while the column sat
|
|
1339
|
+
// in the response.
|
|
1340
|
+
cond = ` AND (IFNULL(m.role,'') LIKE @like ESCAPE '\\'
|
|
1341
|
+
OR IFNULL(a.brand,'') LIKE @like ESCAPE '\\'
|
|
1342
|
+
OR IFNULL(a.model,'') LIKE @like ESCAPE '\\'
|
|
1343
|
+
OR IFNULL(a.version,'') LIKE @like ESCAPE '\\'
|
|
1497
1344
|
OR IFNULL(a.description,'') LIKE @like ESCAPE '\\' OR a.id LIKE @like ESCAPE '\\')`;
|
|
1498
1345
|
}
|
|
1499
1346
|
// KEYSET on the MONOTONIC membership rowid (NOT (joined_at, id)): rowid is
|
|
@@ -1527,6 +1374,7 @@ export class ChatStore {
|
|
|
1527
1374
|
const isWatching = watching === 1;
|
|
1528
1375
|
return {
|
|
1529
1376
|
...rest,
|
|
1377
|
+
is_human: rest.is_human === 1,
|
|
1530
1378
|
...(description_cut ? { description_truncated: true } : {}),
|
|
1531
1379
|
present: left_at === null,
|
|
1532
1380
|
active: left_at === null &&
|
|
@@ -1597,7 +1445,7 @@ export class ChatStore {
|
|
|
1597
1445
|
* ANY message from others carries seq above the token, NOTHING is inserted
|
|
1598
1446
|
* and the reject result carries the crossing messages (bounded previews,
|
|
1599
1447
|
* per-row directed) so the caller can assess the delta and idempotently
|
|
1600
|
-
* retry; a token ahead of this
|
|
1448
|
+
* retry; a token ahead of this persona's read cursor is invalid (it
|
|
1601
1449
|
* cannot have come from that cursor's catch_up and would otherwise bypass
|
|
1602
1450
|
* the guard). The reject baseline is the TOKEN, unlike the accept path's
|
|
1603
1451
|
* cursor-relative crossed. opts.crossedPreviewChars additionally returns
|
|
@@ -1606,7 +1454,7 @@ export class ChatStore {
|
|
|
1606
1454
|
* the proven safe peer floor are normalized through the new own row so their
|
|
1607
1455
|
* recurring probes do not rescan that suffix.
|
|
1608
1456
|
*/
|
|
1609
|
-
postMessage(roomId, agentId, body, format, mentions, replyToSeq, supersedesSeq
|
|
1457
|
+
postMessage(roomId, agentId, body, format, mentions, replyToSeq, supersedesSeq, epoch, opts = {}) {
|
|
1610
1458
|
// Reject unstorable text BEFORE the transaction: a body with an embedded
|
|
1611
1459
|
// NUL reads back truncated (SQLite substr/length stop at NUL) and catch_up
|
|
1612
1460
|
// would advance the marker past the lost tail. mentions are agent ids
|
|
@@ -1652,9 +1500,16 @@ export class ChatStore {
|
|
|
1652
1500
|
}
|
|
1653
1501
|
const ifToken = opts.ifLastReadSeq ?? null;
|
|
1654
1502
|
const tx = this.db.transaction(() => {
|
|
1503
|
+
// FIRST, before the dedup lookup: a fenced-out runtime must not even be
|
|
1504
|
+
// told the seq of a message it posted in a previous tenure, and must
|
|
1505
|
+
// certainly not insert a new one.
|
|
1506
|
+
this.requireEpoch(agentId, epoch);
|
|
1655
1507
|
// The caller's room reference predates this transaction; a concurrent
|
|
1656
1508
|
// delete_room otherwise surfaces as a raw FK failure on the INSERT.
|
|
1657
1509
|
this.requireRoom(roomId);
|
|
1510
|
+
// Authoring is the loudest form of participation. A persona every peer
|
|
1511
|
+
// has been told is absent must not appear in the transcript.
|
|
1512
|
+
this.requirePresent(roomId, agentId);
|
|
1658
1513
|
// Lost-response retry: one indexed lookup only when the caller opted in.
|
|
1659
1514
|
// It precedes CAS/reference validation so a committed first attempt is
|
|
1660
1515
|
// recoverable even if room state or a referenced parent later changed.
|
|
@@ -1705,14 +1560,14 @@ export class ChatStore {
|
|
|
1705
1560
|
(!Number.isSafeInteger(ifToken) || ifToken < 0)) {
|
|
1706
1561
|
throw new Error("if_last_read_seq must be a non-negative safe integer");
|
|
1707
1562
|
}
|
|
1708
|
-
const cursor = this.getCursor(roomId, agentId
|
|
1563
|
+
const cursor = this.getCursor(roomId, agentId);
|
|
1709
1564
|
const from = cursor?.last_read_seq ?? 0;
|
|
1710
1565
|
if (ifToken !== null) {
|
|
1711
1566
|
// A future/wrong-cursor token made the predicate `seq > token` empty
|
|
1712
1567
|
// and silently disabled the CAS while unread messages still existed.
|
|
1713
|
-
// Bind the token to the
|
|
1714
|
-
//
|
|
1715
|
-
//
|
|
1568
|
+
// Bind the token to the read cursor that could actually have produced
|
|
1569
|
+
// it. This is misuse detection, not auth: the optional guard remains a
|
|
1570
|
+
// caller-chosen safety primitive.
|
|
1716
1571
|
if (ifToken > from) {
|
|
1717
1572
|
throw new Error(`if_last_read_seq ${ifToken} is ahead of the current read marker ${from}; ` +
|
|
1718
1573
|
"call catch_up for this room and use its new_last_read_seq");
|
|
@@ -1800,8 +1655,8 @@ export class ChatStore {
|
|
|
1800
1655
|
// own-only suffix made every 5s poll (and every 500ms blocking probe) walk
|
|
1801
1656
|
// that suffix forever. If crossing found peers after `from`, its MAX is
|
|
1802
1657
|
// the room's latest peer row; otherwise `from` itself is known safe. Any
|
|
1803
|
-
// cursor at/after that floor can move through this own post
|
|
1804
|
-
//
|
|
1658
|
+
// cursor at/after that floor can move through this own post, without
|
|
1659
|
+
// scanning history.
|
|
1805
1660
|
const safeCursorFloor = crossing.c > 0 ? crossing.mx : from;
|
|
1806
1661
|
this.advanceOwnOnlyCursors(roomId, agentId, next, safeCursorFloor);
|
|
1807
1662
|
// Opt-in crossed previews on an ACCEPTED post, in the same transaction
|
|
@@ -1865,8 +1720,6 @@ export class ChatStore {
|
|
|
1865
1720
|
return {
|
|
1866
1721
|
seq: r.seq,
|
|
1867
1722
|
from: r.agent_id,
|
|
1868
|
-
from_type: r.from_type,
|
|
1869
|
-
from_role: r.from_role,
|
|
1870
1723
|
format: r.format,
|
|
1871
1724
|
...(r.priority > 0 ? { priority: true } : {}),
|
|
1872
1725
|
content,
|
|
@@ -1918,6 +1771,12 @@ export class ChatStore {
|
|
|
1918
1771
|
sz = JSON.stringify(head).length;
|
|
1919
1772
|
}
|
|
1920
1773
|
const h = head;
|
|
1774
|
+
// A body cut all the way to empty is an oversized row whatever stage got it
|
|
1775
|
+
// there. Stage 4 sets this explicitly, but stage 2's escaping correction can
|
|
1776
|
+
// reach keep=0 on its own for a control-heavy body, and a caller that reads
|
|
1777
|
+
// `oversized` to decide "fetch this with get_message" must not miss those.
|
|
1778
|
+
if (h.content === "" && codepointLen(r) > 0)
|
|
1779
|
+
h.oversized = true;
|
|
1921
1780
|
// Stage 3: shed mentions (down to none if needed); size can live
|
|
1922
1781
|
// entirely in a legal `to` list. Flags set before the deciding measure.
|
|
1923
1782
|
if (sz > budget && Array.isArray(h.to) && h.to.length > 0) {
|
|
@@ -1940,16 +1799,8 @@ export class ChatStore {
|
|
|
1940
1799
|
stub.oversized = true;
|
|
1941
1800
|
sz = JSON.stringify(head).length;
|
|
1942
1801
|
const half = (s) => safeCut(s, Math.floor(s.length / 2));
|
|
1943
|
-
while (sz > budget &&
|
|
1944
|
-
|
|
1945
|
-
(stub.from_type?.length ?? 0) > 0 ||
|
|
1946
|
-
(stub.room_name?.length ?? 0) > 0)) {
|
|
1947
|
-
if (stub.from_role)
|
|
1948
|
-
stub.from_role = half(stub.from_role);
|
|
1949
|
-
if (stub.from_type)
|
|
1950
|
-
stub.from_type = half(stub.from_type);
|
|
1951
|
-
if (stub.room_name)
|
|
1952
|
-
stub.room_name = half(stub.room_name);
|
|
1802
|
+
while (sz > budget && (stub.room_name?.length ?? 0) > 0) {
|
|
1803
|
+
stub.room_name = half(stub.room_name);
|
|
1953
1804
|
sz = JSON.stringify(head).length;
|
|
1954
1805
|
}
|
|
1955
1806
|
// Sender identity shortens last (the seq still identifies the message).
|
|
@@ -2183,9 +2034,8 @@ export class ChatStore {
|
|
|
2183
2034
|
* messages), one row per (agent, room), oldest pending first (the most
|
|
2184
2035
|
* starved recipient leads). This is the read a supervisor polls to decide
|
|
2185
2036
|
* whom to wake; my_mentions is self-scoped and cannot answer it. Markers
|
|
2186
|
-
* are
|
|
2187
|
-
*
|
|
2188
|
-
* idle_seconds is per room membership, matching list_agents. The directed
|
|
2037
|
+
* are per (persona, room), and idle_seconds is per room membership, matching
|
|
2038
|
+
* list_agents. The directed
|
|
2189
2039
|
* predicate is inlined rather than directedAt(): that helper binds one
|
|
2190
2040
|
* FIXED id, and here the id varies per membership row. Fetches limit+1 to
|
|
2191
2041
|
* report truncation without a tail COUNT. Keyset paging makes a bounded
|
|
@@ -2360,16 +2210,40 @@ export class ChatStore {
|
|
|
2360
2210
|
/**
|
|
2361
2211
|
* Non-advancing unread probe for the blocking wait: EXACTLY catchUp's
|
|
2362
2212
|
* predicate (seq > cursor AND agent_id != self, the cursor resolved through
|
|
2363
|
-
* the
|
|
2213
|
+
* the persona's one marker for the room), minus the fetch and the advance.
|
|
2214
|
+
* Any drift
|
|
2364
2215
|
* between this predicate and catchUp's makes the wait loop spin (a positive
|
|
2365
2216
|
* probe whose advancing read returns nothing). Autocommit reads: under WAL
|
|
2366
2217
|
* they take a shared lock, so a 500ms cadence never contends for the write
|
|
2367
2218
|
* lock the way catchUp's IMMEDIATE transaction would. Throws when the
|
|
2368
2219
|
* membership is gone (room deleted mid-wait), same as catchUp.
|
|
2220
|
+
*
|
|
2221
|
+
* EPOCH-FENCED, and this is what bounds how long a fenced-out wait keeps
|
|
2222
|
+
* sitting there. The advancing read on a hit was always fenced, but a QUIET
|
|
2223
|
+
* stale wait never reaches it: with no epoch here, a runtime taken over at
|
|
2224
|
+
* second 3 of a 25-second wait went right on waiting, holding a lease that
|
|
2225
|
+
* told peers it was watching, and learned nothing until traffic arrived or
|
|
2226
|
+
* the deadline expired. Checking each probe caps that at one interval.
|
|
2227
|
+
*
|
|
2228
|
+
* The epoch rides as returned DATA anchored on the persona row, not as a
|
|
2229
|
+
* WHERE predicate: as a predicate a fenced runtime would get "no row", which
|
|
2230
|
+
* this method already means "the room or membership is gone" -- a wrong and
|
|
2231
|
+
* actively misleading diagnosis. Anchoring on agents keeps the two apart.
|
|
2369
2232
|
*/
|
|
2370
|
-
unreadProbe(roomId, agentId,
|
|
2371
|
-
const
|
|
2372
|
-
|
|
2233
|
+
unreadProbe(roomId, agentId, epoch) {
|
|
2234
|
+
const row = this.db
|
|
2235
|
+
.prepare(`SELECT a.runtime_epoch AS current_epoch,
|
|
2236
|
+
(SELECT mb.last_read_seq FROM memberships mb
|
|
2237
|
+
WHERE mb.room_id = @room AND mb.agent_id = @agent)
|
|
2238
|
+
AS last_read_seq
|
|
2239
|
+
FROM agents a WHERE a.id = @agent`)
|
|
2240
|
+
.get({ room: roomId, agent: agentId });
|
|
2241
|
+
if (!row)
|
|
2242
|
+
throw new PersonaLostError(agentId, epoch, null);
|
|
2243
|
+
if (row.current_epoch !== epoch) {
|
|
2244
|
+
throw new PersonaLostError(agentId, epoch, row.current_epoch);
|
|
2245
|
+
}
|
|
2246
|
+
if (row.last_read_seq === null)
|
|
2373
2247
|
throw new Error("not a member of this room");
|
|
2374
2248
|
// A wait only needs a yes/no wake signal. COUNT(*) rescanned the complete
|
|
2375
2249
|
// unread tail twice per second per wait (including a large self-authored
|
|
@@ -2377,39 +2251,28 @@ export class ChatStore {
|
|
|
2377
2251
|
return this.db
|
|
2378
2252
|
.prepare(`SELECT 1 FROM messages
|
|
2379
2253
|
WHERE room_id = ? AND seq > ? AND agent_id != ? LIMIT 1`)
|
|
2380
|
-
.get(roomId,
|
|
2254
|
+
.get(roomId, row.last_read_seq, agentId)
|
|
2381
2255
|
? 1
|
|
2382
2256
|
: 0;
|
|
2383
2257
|
}
|
|
2384
2258
|
/**
|
|
2385
2259
|
* Bounded per-room unread summary for one agent: every room the agent is
|
|
2386
|
-
* present in (rooms this
|
|
2260
|
+
* present in (rooms this persona soft-left are muted) holding unread
|
|
2387
2261
|
* messages from others, with total `unread` and `directed` (aimed at the
|
|
2388
2262
|
* agent) counts, most-directed first. Feeds both my_mentions' by_room arm
|
|
2389
|
-
* and catch_up's rooms_with_unread disclosure on an empty read.
|
|
2390
|
-
*
|
|
2391
|
-
*
|
|
2392
|
-
*
|
|
2393
|
-
*
|
|
2394
|
-
* a tail COUNT. Read-only, so it is safe inside deferred and immediate
|
|
2395
|
-
* transactions alike.
|
|
2263
|
+
* and catch_up's rooms_with_unread disclosure on an empty read. Rooms this
|
|
2264
|
+
* persona soft-left are muted by the membership join. excludeRoomId drops the
|
|
2265
|
+
* room just read (catch_up's summary lists OTHER rooms). Fetches limit+1 to
|
|
2266
|
+
* report truncation without a tail COUNT. Read-only, so it is safe inside
|
|
2267
|
+
* deferred and immediate transactions alike.
|
|
2396
2268
|
*/
|
|
2397
|
-
unreadByRoom(agentId,
|
|
2398
|
-
// '' never collides with a real session id (same convention as myMentions).
|
|
2399
|
-
const sessionKey = sessionId ?? "";
|
|
2269
|
+
unreadByRoom(agentId, limit, excludeRoomId) {
|
|
2400
2270
|
const lim = Math.max(1, Math.floor(limit));
|
|
2401
2271
|
const excl = excludeRoomId !== undefined ? " AND g.room_id != ?" : "";
|
|
2402
2272
|
// Placeholders in SQL text order: the directedAt pair (SELECT), the
|
|
2403
|
-
// membership join, the
|
|
2404
|
-
//
|
|
2405
|
-
const params = [
|
|
2406
|
-
agentId,
|
|
2407
|
-
agentId,
|
|
2408
|
-
agentId,
|
|
2409
|
-
sessionKey,
|
|
2410
|
-
agentId,
|
|
2411
|
-
sessionKey,
|
|
2412
|
-
];
|
|
2273
|
+
// membership join, the author exclusion, the optional room exclusion, then
|
|
2274
|
+
// the LIMIT.
|
|
2275
|
+
const params = [agentId, agentId, agentId, agentId];
|
|
2413
2276
|
if (excludeRoomId !== undefined)
|
|
2414
2277
|
params.push(excludeRoomId);
|
|
2415
2278
|
params.push(lim + 1);
|
|
@@ -2419,14 +2282,9 @@ export class ChatStore {
|
|
|
2419
2282
|
FROM messages g
|
|
2420
2283
|
JOIN memberships mb ON mb.room_id = g.room_id
|
|
2421
2284
|
AND mb.agent_id = ? AND mb.left_at IS NULL
|
|
2422
|
-
LEFT JOIN session_markers sm ON sm.room_id = g.room_id
|
|
2423
|
-
AND sm.agent_id = mb.agent_id AND sm.session_id = ?
|
|
2424
2285
|
JOIN rooms r ON r.id = g.room_id
|
|
2425
|
-
WHERE g.seq >
|
|
2426
|
-
AND g.agent_id !=
|
|
2427
|
-
AND NOT EXISTS (SELECT 1 FROM session_presence sp
|
|
2428
|
-
WHERE sp.room_id = g.room_id AND sp.agent_id = mb.agent_id
|
|
2429
|
-
AND sp.session_id = ? AND sp.left_at IS NOT NULL)${excl}
|
|
2286
|
+
WHERE g.seq > mb.last_read_seq
|
|
2287
|
+
AND g.agent_id != ?${excl}
|
|
2430
2288
|
GROUP BY g.room_id, r.name
|
|
2431
2289
|
ORDER BY directed DESC, unread DESC, g.room_id ASC
|
|
2432
2290
|
LIMIT ?`)
|
|
@@ -2442,14 +2300,18 @@ export class ChatStore {
|
|
|
2442
2300
|
* and advances over lower-priority rows through a disclosed cutoff. Directed
|
|
2443
2301
|
* rows always qualify so advancing cannot silently erase my_mentions items.
|
|
2444
2302
|
*
|
|
2445
|
-
* unreadSummary
|
|
2446
|
-
* not this room's cursor selector): on an EMPTY read, include a bounded
|
|
2303
|
+
* unreadSummary: on an EMPTY read, include a bounded
|
|
2447
2304
|
* rooms_with_unread summary of every OTHER room holding unread, computed in
|
|
2448
2305
|
* the SAME snapshot as the empty determination -- across two separate
|
|
2449
2306
|
* queries a message arriving in this room could make the read report
|
|
2450
2307
|
* "empty" while the summary lists this very room.
|
|
2451
2308
|
*/
|
|
2452
|
-
catchUp(roomId, agentId, limit, previewChars,
|
|
2309
|
+
catchUp(roomId, agentId, limit, previewChars,
|
|
2310
|
+
// Defaulted only because it sits after an optional parameter. 0 is a
|
|
2311
|
+
// FAIL-CLOSED sentinel, not "no fencing": personas start at epoch 1 and
|
|
2312
|
+
// only ever increment, so an omitted epoch matches nothing and the read is
|
|
2313
|
+
// rejected rather than silently running unfenced.
|
|
2314
|
+
maxBytes = DEFAULT_MAX_BYTES, epoch = 0, unreadSummary = null) {
|
|
2453
2315
|
if (!Number.isSafeInteger(maxBytes) ||
|
|
2454
2316
|
maxBytes < MIN_CATCH_UP_RESULT_BUDGET ||
|
|
2455
2317
|
maxBytes > MAX_BULK_RESULT_CHARS) {
|
|
@@ -2470,13 +2332,21 @@ export class ChatStore {
|
|
|
2470
2332
|
// never returned): a response the client rejects as oversized can no
|
|
2471
2333
|
// longer strand a peer message behind an advanced marker.
|
|
2472
2334
|
const tx = this.db.transaction(() => {
|
|
2473
|
-
|
|
2335
|
+
// ADVANCING read: fenced like a mutation, because it moves the read
|
|
2336
|
+
// marker. A stale runtime whose catch_up committed would consume messages
|
|
2337
|
+
// the current runtime has not seen and can no longer reach.
|
|
2338
|
+
this.requireEpoch(agentId, epoch);
|
|
2339
|
+
const cursor = this.getCursor(roomId, agentId);
|
|
2474
2340
|
if (!cursor) {
|
|
2475
2341
|
// Distinguish a real non-member from a room deleted after the caller
|
|
2476
2342
|
// resolved it. This extra PK lookup runs only on the error path.
|
|
2477
2343
|
this.requireRoom(roomId);
|
|
2478
2344
|
throw new Error("not a member of this room");
|
|
2479
2345
|
}
|
|
2346
|
+
// Consuming messages while absent is invisible consumption: peers see a
|
|
2347
|
+
// left persona and cannot tell their traffic is being read. read_history
|
|
2348
|
+
// stays open for exactly this reason -- it consumes nothing.
|
|
2349
|
+
this.requirePresent(roomId, agentId);
|
|
2480
2350
|
const from = cursor.last_read_seq;
|
|
2481
2351
|
const priorityOnly = unreadSummary?.priorityOnly === true;
|
|
2482
2352
|
// Captured under the same IMMEDIATE snapshot as the filtered scan. Own
|
|
@@ -2537,7 +2407,7 @@ export class ChatStore {
|
|
|
2537
2407
|
.get(roomId, from, lastSeq, agentId, agentId, agentId).c;
|
|
2538
2408
|
}
|
|
2539
2409
|
if (lastSeq > from) {
|
|
2540
|
-
this.setCursor(roomId, agentId,
|
|
2410
|
+
this.setCursor(roomId, agentId, lastSeq);
|
|
2541
2411
|
}
|
|
2542
2412
|
// Empty read: same-snapshot disclosure of where the traffic actually
|
|
2543
2413
|
// is. Emitted even when no other room has unread ([]): that positively
|
|
@@ -2545,7 +2415,7 @@ export class ChatStore {
|
|
|
2545
2415
|
const UNREAD_SUMMARY_MAX = 20;
|
|
2546
2416
|
let summary = null;
|
|
2547
2417
|
if (unreadSummary !== null && messages.length === 0) {
|
|
2548
|
-
const fetched = this.unreadByRoom(agentId,
|
|
2418
|
+
const fetched = this.unreadByRoom(agentId, UNREAD_SUMMARY_MAX, roomId);
|
|
2549
2419
|
// The v0.9 summary was appended after catch_up had already spent the
|
|
2550
2420
|
// entire page budget. Twenty legal, control-heavy room names could
|
|
2551
2421
|
// inflate a declared 1k response past 25k. Bound the summary within
|
|
@@ -2603,11 +2473,9 @@ export class ChatStore {
|
|
|
2603
2473
|
* global total order across rooms), each row tagged room_id/room_name.
|
|
2604
2474
|
*
|
|
2605
2475
|
* Strictly a PEEK: no read marker moves. An entry clears when its room is
|
|
2606
|
-
* actually read (catch_up / mark_read there). Baselines follow the
|
|
2607
|
-
*
|
|
2608
|
-
*
|
|
2609
|
-
* message the same session's catch_up would still deliver; identity-level
|
|
2610
|
-
* otherwise. The poller remains identity-level (it cannot know the nonce).
|
|
2476
|
+
* actually read (catch_up / mark_read there). Baselines follow the persona's
|
|
2477
|
+
* one read marker per room, which is the same marker catch_up and the poller
|
|
2478
|
+
* use, so the inbox can never hide a message catch_up would still deliver.
|
|
2611
2479
|
*
|
|
2612
2480
|
* Paging: rows come back oldest-first by messages.id (a global total order
|
|
2613
2481
|
* across rooms); pass next_after_id back as afterId to page past `limit` or
|
|
@@ -2622,10 +2490,7 @@ export class ChatStore {
|
|
|
2622
2490
|
* cannot bury the entries themselves. Rows and counts read one DEFERRED
|
|
2623
2491
|
* snapshot.
|
|
2624
2492
|
*/
|
|
2625
|
-
myMentions(agentId, limit, previewChars, maxBytes = DEFAULT_MAX_BYTES,
|
|
2626
|
-
// '' never collides with a real session id, so one query shape serves
|
|
2627
|
-
// both shared and private cursors.
|
|
2628
|
-
const sessionKey = sessionId ?? "";
|
|
2493
|
+
myMentions(agentId, limit, previewChars, maxBytes = DEFAULT_MAX_BYTES, afterId = 0) {
|
|
2629
2494
|
const tx = this.db.transaction(() => {
|
|
2630
2495
|
// Fetch ONE more than `limit` so a page cut by the ROW limit (not the
|
|
2631
2496
|
// byte budget) is detectable. Without the probe, my_mentions was the only
|
|
@@ -2636,44 +2501,31 @@ export class ChatStore {
|
|
|
2636
2501
|
FROM ${MESSAGE_FROM}
|
|
2637
2502
|
JOIN memberships mb ON mb.room_id = g.room_id
|
|
2638
2503
|
AND mb.agent_id = ? AND mb.left_at IS NULL
|
|
2639
|
-
LEFT JOIN session_markers sm ON sm.room_id = g.room_id
|
|
2640
|
-
AND sm.agent_id = mb.agent_id AND sm.session_id = ?
|
|
2641
2504
|
JOIN rooms r ON r.id = g.room_id
|
|
2642
|
-
WHERE g.seq >
|
|
2505
|
+
WHERE g.seq > mb.last_read_seq
|
|
2643
2506
|
AND g.id > ? AND g.agent_id != ?
|
|
2644
2507
|
AND ${directedAt("g")}
|
|
2645
|
-
|
|
2646
|
-
WHERE sp.room_id = g.room_id AND sp.agent_id = mb.agent_id
|
|
2647
|
-
AND sp.session_id = ? AND sp.left_at IS NOT NULL)
|
|
2648
|
-
ORDER BY g.id ASC LIMIT ?`), [agentId, sessionKey, afterId, agentId, agentId, agentId, sessionKey, limit + 1], maxBytes);
|
|
2508
|
+
ORDER BY g.id ASC LIMIT ?`), [agentId, afterId, agentId, agentId, agentId, limit + 1], maxBytes);
|
|
2649
2509
|
const hasExtra = fetched.length > limit;
|
|
2650
2510
|
const rows = hasExtra ? fetched.slice(0, limit) : fetched;
|
|
2651
|
-
// total_directed: a SCALAR aggregate over
|
|
2652
|
-
//
|
|
2653
|
-
//
|
|
2654
|
-
//
|
|
2655
|
-
// identity present. Placeholders: the membership join, the session-marker
|
|
2656
|
-
// key, the author exclusion, the directedAt pair, then the presence key.
|
|
2511
|
+
// total_directed: a SCALAR aggregate over every present room -- NOT a
|
|
2512
|
+
// materialized per-room array reduced in JS (a persona in 100k unread
|
|
2513
|
+
// rooms otherwise cloned and sorted the whole set). Placeholders: the
|
|
2514
|
+
// membership join, the author exclusion, then the directedAt pair.
|
|
2657
2515
|
const { td } = this.db
|
|
2658
2516
|
.prepare(`SELECT COUNT(*) AS td FROM messages g
|
|
2659
2517
|
JOIN memberships mb ON mb.room_id = g.room_id
|
|
2660
2518
|
AND mb.agent_id = ? AND mb.left_at IS NULL
|
|
2661
|
-
|
|
2662
|
-
AND sm.agent_id = mb.agent_id AND sm.session_id = ?
|
|
2663
|
-
WHERE g.seq > COALESCE(sm.last_read_seq, mb.last_read_seq)
|
|
2519
|
+
WHERE g.seq > mb.last_read_seq
|
|
2664
2520
|
AND g.agent_id != ?
|
|
2665
|
-
AND ${directedAt("g")}
|
|
2666
|
-
|
|
2667
|
-
WHERE sp.room_id = g.room_id AND sp.agent_id = mb.agent_id
|
|
2668
|
-
AND sp.session_id = ? AND sp.left_at IS NOT NULL)`)
|
|
2669
|
-
.get(agentId, sessionKey, agentId, agentId, agentId, sessionKey);
|
|
2521
|
+
AND ${directedAt("g")}`)
|
|
2522
|
+
.get(agentId, agentId, agentId, agentId);
|
|
2670
2523
|
const total_directed = td;
|
|
2671
2524
|
// by_room: fetch only the TOP rooms by directed count in SQL (bounded
|
|
2672
2525
|
// memory), most-directed first, then trim to the byte budget. The query
|
|
2673
|
-
// lives in unreadByRoom
|
|
2674
|
-
// carries the same session-aware muting and cursor baselines.
|
|
2526
|
+
// lives in unreadByRoom, shared with catch_up's rooms_with_unread.
|
|
2675
2527
|
const BY_ROOM_MAX = 4000;
|
|
2676
|
-
const byRoomFetch = this.unreadByRoom(agentId,
|
|
2528
|
+
const byRoomFetch = this.unreadByRoom(agentId, BY_ROOM_MAX);
|
|
2677
2529
|
let byRoom = byRoomFetch.rooms;
|
|
2678
2530
|
// Rooms past BY_ROOM_MAX (the least-directed) were dropped -- flag it.
|
|
2679
2531
|
const roomLimitHit = byRoomFetch.truncated;
|
|
@@ -2775,16 +2627,24 @@ export class ChatStore {
|
|
|
2775
2627
|
* clamped to [0, latest]. A lower value re-exposes those messages to catch_up.
|
|
2776
2628
|
* Returns the previous and new marker plus the room's latest seq.
|
|
2777
2629
|
*/
|
|
2778
|
-
markRead(roomId, agentId,
|
|
2630
|
+
markRead(roomId, agentId, epoch, seq) {
|
|
2779
2631
|
const tx = this.db.transaction(() => {
|
|
2780
|
-
|
|
2781
|
-
|
|
2632
|
+
this.requireEpoch(agentId, epoch);
|
|
2633
|
+
const cursor = this.getCursor(roomId, agentId);
|
|
2634
|
+
if (!cursor) {
|
|
2635
|
+
// Same ordering rule as claimResource: name a deleted room as deleted
|
|
2636
|
+
// rather than reporting its vanished membership as a non-membership.
|
|
2637
|
+
this.requireRoom(roomId);
|
|
2782
2638
|
throw new Error("not a member of this room");
|
|
2639
|
+
}
|
|
2640
|
+
// Advancing a marker is participation: it consumes messages peers can
|
|
2641
|
+
// see you have not read.
|
|
2642
|
+
this.requirePresent(roomId, agentId);
|
|
2783
2643
|
const { latest } = this.db
|
|
2784
2644
|
.prepare("SELECT COALESCE(MAX(seq), 0) AS latest FROM messages WHERE room_id = ?")
|
|
2785
2645
|
.get(roomId);
|
|
2786
2646
|
const target = seq === undefined ? latest : Math.max(0, Math.min(seq, latest));
|
|
2787
|
-
this.setCursor(roomId, agentId,
|
|
2647
|
+
this.setCursor(roomId, agentId, target);
|
|
2788
2648
|
return { previous: cursor.last_read_seq, new: target, latest };
|
|
2789
2649
|
});
|
|
2790
2650
|
return tx.immediate();
|
|
@@ -2836,24 +2696,15 @@ export class ChatStore {
|
|
|
2836
2696
|
* Trim a room to its newest `keepLast` messages. Only the oldest are removed,
|
|
2837
2697
|
* so MAX(seq) is unchanged and future seq numbers stay monotonic.
|
|
2838
2698
|
*/
|
|
2839
|
-
pruneMessages(roomId, keepLast, force) {
|
|
2699
|
+
pruneMessages(roomId, agentId, epoch, keepLast, force) {
|
|
2840
2700
|
// Keep at least the newest message: keepLast=0 would hit OFFSET -1
|
|
2841
2701
|
// (clamped to 0 by SQLite, silently keeping one row anyway), and deleting
|
|
2842
2702
|
// ALL rows would reset MAX(seq), breaking the monotonic-seq invariant.
|
|
2843
2703
|
keepLast = Math.max(1, Math.floor(keepLast));
|
|
2844
2704
|
const tx = this.db.transaction(() => {
|
|
2705
|
+
this.requireEpoch(agentId, epoch);
|
|
2845
2706
|
// A deleted room must not report a successful no-op prune.
|
|
2846
2707
|
this.requireRoom(roomId);
|
|
2847
|
-
// Reap EXPIRED private session cursors first (same 7-day window as the
|
|
2848
|
-
// GC in joinRoom): that GC only runs on private joins, so a room whose
|
|
2849
|
-
// sessions all vanished otherwise kept a dead marker at some ancient
|
|
2850
|
-
// seq blocking every future unforced prune.
|
|
2851
|
-
this.db
|
|
2852
|
-
.prepare("DELETE FROM session_markers WHERE room_id = ? AND updated_at < datetime('now', ?)")
|
|
2853
|
-
.run(roomId, SESSION_GC_AGE);
|
|
2854
|
-
// Reconcile presence too (reap crashed sessions' aged rows, recompute
|
|
2855
|
-
// memberships.left_at) so a prune also refreshes who is present.
|
|
2856
|
-
this.gcSessionPresence(roomId);
|
|
2857
2708
|
const { c: total } = this.db
|
|
2858
2709
|
.prepare("SELECT COUNT(*) AS c FROM messages WHERE room_id = ?")
|
|
2859
2710
|
.get(roomId);
|
|
@@ -2866,23 +2717,16 @@ export class ChatStore {
|
|
|
2866
2717
|
// Refuse to delete a message that ANY member who did NOT author it has
|
|
2867
2718
|
// not yet read. Members that left are included: soft leave preserves the
|
|
2868
2719
|
// read position for resume, so their unread is real until they return.
|
|
2869
|
-
//
|
|
2870
|
-
//
|
|
2871
|
-
// only the session_markers row knows. (The author has implicitly
|
|
2872
|
-
// "seen" its own message, matching catch_up's self-exclusion.) Pass
|
|
2873
|
-
// force=true to prune past this.
|
|
2720
|
+
// (The author has implicitly "seen" its own message, matching catch_up's
|
|
2721
|
+
// self-exclusion.) Pass force=true to prune past this.
|
|
2874
2722
|
const { u } = this.db
|
|
2875
2723
|
.prepare(`SELECT COUNT(*) AS u FROM messages g
|
|
2876
2724
|
WHERE g.room_id = ? AND g.seq < ?
|
|
2877
|
-
AND
|
|
2725
|
+
AND EXISTS (
|
|
2878
2726
|
SELECT 1 FROM memberships mm
|
|
2879
2727
|
WHERE mm.room_id = g.room_id
|
|
2880
2728
|
AND mm.last_read_seq < g.seq AND mm.agent_id != g.agent_id
|
|
2881
|
-
)
|
|
2882
|
-
SELECT 1 FROM session_markers sm
|
|
2883
|
-
WHERE sm.room_id = g.room_id
|
|
2884
|
-
AND sm.last_read_seq < g.seq AND sm.agent_id != g.agent_id
|
|
2885
|
-
))`)
|
|
2729
|
+
)`)
|
|
2886
2730
|
.get(roomId, cutoff.seq);
|
|
2887
2731
|
if (u > 0) {
|
|
2888
2732
|
// min over markers that actually BLOCK the prune (same predicate
|
|
@@ -2890,20 +2734,12 @@ export class ChatStore {
|
|
|
2890
2734
|
// harmless laggards, most commonly the doomed messages' own author,
|
|
2891
2735
|
// whom the refusal itself exempts.
|
|
2892
2736
|
const { m } = this.db
|
|
2893
|
-
.prepare(`SELECT MIN(
|
|
2894
|
-
|
|
2895
|
-
WHERE
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
UNION ALL
|
|
2900
|
-
SELECT sm.last_read_seq FROM session_markers sm
|
|
2901
|
-
WHERE sm.room_id = ? AND EXISTS (
|
|
2902
|
-
SELECT 1 FROM messages g WHERE g.room_id = sm.room_id
|
|
2903
|
-
AND g.seq < ? AND g.seq > sm.last_read_seq
|
|
2904
|
-
AND g.agent_id != sm.agent_id)
|
|
2905
|
-
)`)
|
|
2906
|
-
.get(roomId, cutoff.seq, roomId, cutoff.seq);
|
|
2737
|
+
.prepare(`SELECT MIN(mm.last_read_seq) AS m FROM memberships mm
|
|
2738
|
+
WHERE mm.room_id = ? AND EXISTS (
|
|
2739
|
+
SELECT 1 FROM messages g WHERE g.room_id = mm.room_id
|
|
2740
|
+
AND g.seq < ? AND g.seq > mm.last_read_seq
|
|
2741
|
+
AND g.agent_id != mm.agent_id)`)
|
|
2742
|
+
.get(roomId, cutoff.seq);
|
|
2907
2743
|
return {
|
|
2908
2744
|
deleted: 0,
|
|
2909
2745
|
kept: total,
|
|
@@ -2926,8 +2762,12 @@ export class ChatStore {
|
|
|
2926
2762
|
/** Hard-delete a room and all of its messages and memberships. Throws a
|
|
2927
2763
|
* clean "already deleted" error if another process removed it first,
|
|
2928
2764
|
* instead of reporting a false success with zero counts. */
|
|
2929
|
-
deleteRoom(roomId) {
|
|
2765
|
+
deleteRoom(roomId, agentId, epoch) {
|
|
2930
2766
|
const tx = this.db.transaction(() => {
|
|
2767
|
+
// Fenced for the same reason as createRoom, and more urgently: this is
|
|
2768
|
+
// the one irreversible operation in the tool surface. No membership is
|
|
2769
|
+
// required (deletion has always been global admin), only a live persona.
|
|
2770
|
+
this.requireEpoch(agentId, epoch);
|
|
2931
2771
|
this.requireRoom(roomId);
|
|
2932
2772
|
const { c: messages } = this.db
|
|
2933
2773
|
.prepare("SELECT COUNT(*) AS c FROM messages WHERE room_id = ?")
|
|
@@ -2939,8 +2779,6 @@ export class ChatStore {
|
|
|
2939
2779
|
// and so foreign keys to rooms(id) are satisfied.
|
|
2940
2780
|
this.db.prepare("DELETE FROM messages WHERE room_id = ?").run(roomId);
|
|
2941
2781
|
this.db.prepare("DELETE FROM memberships WHERE room_id = ?").run(roomId);
|
|
2942
|
-
this.db.prepare("DELETE FROM session_markers WHERE room_id = ?").run(roomId);
|
|
2943
|
-
this.db.prepare("DELETE FROM session_presence WHERE room_id = ?").run(roomId);
|
|
2944
2782
|
this.db.prepare("DELETE FROM claims WHERE room_id = ?").run(roomId);
|
|
2945
2783
|
this.db.prepare("DELETE FROM wait_leases WHERE room_id = ?").run(roomId);
|
|
2946
2784
|
this.db.prepare("DELETE FROM rooms WHERE id = ?").run(roomId);
|
|
@@ -2955,18 +2793,25 @@ export class ChatStore {
|
|
|
2955
2793
|
* winner: the read-check and upsert run in one IMMEDIATE transaction, so two
|
|
2956
2794
|
* simultaneous claimants cannot both be granted (unlike two "I claim X" chat
|
|
2957
2795
|
* posts, which can cross). Re-claiming your own key renews the TTL; an
|
|
2958
|
-
* expired claim is grantable to anyone. Ownership is per
|
|
2959
|
-
*
|
|
2796
|
+
* expired claim is grantable to anyone. Ownership is per PERSONA, so a claim
|
|
2797
|
+
* survives a resume: the runtime that takes the persona over inherits it.
|
|
2960
2798
|
*/
|
|
2961
|
-
claimResource(roomId, key, agentId, ttlSeconds, note) {
|
|
2799
|
+
claimResource(roomId, key, agentId, epoch, ttlSeconds, note) {
|
|
2962
2800
|
assertStorable(key, "claim key");
|
|
2963
2801
|
assertMaxLen(key, "claim key", 500);
|
|
2964
2802
|
assertStorable(note, "claim note");
|
|
2965
2803
|
assertMaxLen(note, "claim note", 2000);
|
|
2966
2804
|
const tx = this.db.transaction(() => {
|
|
2805
|
+
this.requireEpoch(agentId, epoch);
|
|
2967
2806
|
// Same deleted-room window as postMessage: fail cleanly, not with a
|
|
2968
|
-
// raw FK error from the claims INSERT.
|
|
2807
|
+
// raw FK error from the claims INSERT. BEFORE the presence check, because
|
|
2808
|
+
// a delete cascades the membership away and "you are not a member" is a
|
|
2809
|
+
// false diagnosis of a room that no longer exists.
|
|
2969
2810
|
this.requireRoom(roomId);
|
|
2811
|
+
// Taking (or renewing) a claim asserts coordination inside a room this
|
|
2812
|
+
// persona is not in. release_claim stays open: dropping a claim you
|
|
2813
|
+
// already hold is cleanup, not participation.
|
|
2814
|
+
this.requirePresent(roomId, agentId);
|
|
2970
2815
|
const row = this.db
|
|
2971
2816
|
.prepare(`SELECT agent_id, note,
|
|
2972
2817
|
strftime('%Y-%m-%dT%H:%M:%SZ', expires_at) AS expires_at,
|
|
@@ -3004,8 +2849,9 @@ export class ChatStore {
|
|
|
3004
2849
|
return tx.immediate();
|
|
3005
2850
|
}
|
|
3006
2851
|
/** Release your own claim. Expired claims can be released by anyone. */
|
|
3007
|
-
releaseClaim(roomId, key, agentId) {
|
|
2852
|
+
releaseClaim(roomId, key, agentId, epoch) {
|
|
3008
2853
|
const tx = this.db.transaction(() => {
|
|
2854
|
+
this.requireEpoch(agentId, epoch);
|
|
3009
2855
|
this.requireRoom(roomId);
|
|
3010
2856
|
const row = this.db
|
|
3011
2857
|
.prepare(`SELECT agent_id,
|