multi-agent-collaboration-mcp 0.13.0 → 0.16.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 +49 -281
- package/dist/bounded-lines.js +1 -1
- package/dist/build-info.json +1 -1
- package/dist/check.js +131 -44
- package/dist/db.js +935 -583
- package/dist/index.js +571 -575
- package/dist/poller.js +179 -78
- package/package.json +3 -2
- package/scripts/connection-probe.mjs +220 -0
- package/web/index.html +783 -256
- package/web/server.mjs +377 -159
package/dist/db.js
CHANGED
|
@@ -22,8 +22,6 @@ function resolveDbPath() {
|
|
|
22
22
|
}
|
|
23
23
|
return join(homedir(), ".agent-chat-mcp", "chat.db");
|
|
24
24
|
}
|
|
25
|
-
/** SQLite's default maximum string/blob byte length (SQLITE_MAX_LENGTH). */
|
|
26
|
-
export const SQLITE_MAX_LENGTH = 1_000_000_000;
|
|
27
25
|
/** Application safety cap. SQLite's ~1 GB theoretical ceiling is not a safe
|
|
28
26
|
* API limit: JSON parsing, validation, binding, WAL, and FTS can hold several
|
|
29
27
|
* copies of one body at once. */
|
|
@@ -43,6 +41,17 @@ export const MAX_CLIENT_MESSAGE_ID_CHARS = 200;
|
|
|
43
41
|
* generates against it before allocating, rather than discovering the overflow
|
|
44
42
|
* as a store assertion after the fact. */
|
|
45
43
|
export const MAX_AGENT_ID_CHARS = 200;
|
|
44
|
+
/**
|
|
45
|
+
* Longest human BASE name, derived from MAX_AGENT_ID_CHARS rather than chosen:
|
|
46
|
+
* the canonical id is `human-` + base + `-` + ordinal, and the widest ordinal
|
|
47
|
+
* is Number.MAX_SAFE_INTEGER at 16 digits.
|
|
48
|
+
*
|
|
49
|
+
* 6 ("human-") + 177 (base) + 1 ("-") + 16 (ordinal) = 200
|
|
50
|
+
*
|
|
51
|
+
* The complete candidate is still validated before insertion; this bound only
|
|
52
|
+
* guarantees no VALID base can be rejected later by the id cap.
|
|
53
|
+
*/
|
|
54
|
+
export const MAX_HUMAN_BASE_CHARS = 177;
|
|
46
55
|
/** Above this body length the store's json well-formedness re-validation is
|
|
47
56
|
* skipped: parsing a ~GB body into memory to walk it would defeat the
|
|
48
57
|
* memory-bounded read design, and such a caller owns validation (the MCP
|
|
@@ -139,8 +148,9 @@ const THREAD_ENVELOPE = JSON.stringify({
|
|
|
139
148
|
}).length -
|
|
140
149
|
2 -
|
|
141
150
|
2;
|
|
142
|
-
/** Serialized
|
|
143
|
-
*
|
|
151
|
+
/** Serialized array allowance within which boundByBytes can fit any legal
|
|
152
|
+
* first row as a stub. It includes the array's two brackets, so boundByBytes
|
|
153
|
+
* passes STUB_ALLOWANCE - 2 as the row budget (fixed fields ~430 worst case). */
|
|
144
154
|
const STUB_ALLOWANCE = 500;
|
|
145
155
|
/** Smallest budget that can safely carry catch_up's fixed fields plus one
|
|
146
156
|
* shrunk message stub. The MCP wrapper subtracts its own routing/wait
|
|
@@ -148,62 +158,73 @@ const STUB_ALLOWANCE = 500;
|
|
|
148
158
|
* remains it must reject the call rather than advance past an undeliverable
|
|
149
159
|
* page. */
|
|
150
160
|
export const MIN_CATCH_UP_RESULT_BUDGET = Math.max(CATCH_UP_ENVELOPE, PRIORITY_CATCH_UP_ENVELOPE) + STUB_ALLOWANCE;
|
|
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
161
|
export class PersonaLostError extends Error {
|
|
159
162
|
agentId;
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
163
|
+
reason;
|
|
164
|
+
/** The agents row is gone entirely. */
|
|
165
|
+
missing;
|
|
166
|
+
/** The row exists and is terminally retired. */
|
|
167
|
+
retired;
|
|
168
|
+
constructor(agentId, reason) {
|
|
169
|
+
super(
|
|
170
|
+
// Describes ONLY the lost identity and the operation that failed. It
|
|
171
|
+
// deliberately gives no process-recovery advice: whether the caller
|
|
172
|
+
// should restart depends on session state this layer cannot see, and an
|
|
173
|
+
// unconditional "call identify_persona" would contradict the fact that a
|
|
174
|
+
// process still holding this dead binding is exactly the case where
|
|
175
|
+
// identify_persona must fail.
|
|
176
|
+
reason === "missing"
|
|
177
|
+
? `persona "${agentId}" no longer exists, so this operation cannot proceed`
|
|
178
|
+
: reason === "retired"
|
|
179
|
+
? `persona "${agentId}" was terminally retired and can never act ` +
|
|
180
|
+
`again; its history stands, but it cannot post, advance a read ` +
|
|
181
|
+
`marker, claim, or join`
|
|
182
|
+
: `persona "${agentId}" exists and is live, but it is not the ` +
|
|
183
|
+
`identity this operation was issued under; that authority is gone ` +
|
|
184
|
+
`and retrying this operation cannot restore it`);
|
|
168
185
|
this.name = "PersonaLostError";
|
|
169
186
|
this.agentId = agentId;
|
|
170
|
-
this.
|
|
171
|
-
|
|
187
|
+
this.reason = reason;
|
|
188
|
+
// Derived here rather than passed, so the two can never both be true: a
|
|
189
|
+
// row cannot be simultaneously absent and retired, and a live-row binding
|
|
190
|
+
// mismatch is neither.
|
|
191
|
+
this.missing = reason === "missing";
|
|
192
|
+
this.retired = reason === "retired";
|
|
172
193
|
}
|
|
173
194
|
}
|
|
174
195
|
/**
|
|
175
|
-
*
|
|
196
|
+
* This process generated a connection nonce that some row already carries,
|
|
197
|
+
* BEFORE it had bound anything.
|
|
176
198
|
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
|
|
199
|
+
* Thrown, not silently resolved, because the only correct response lives in the
|
|
200
|
+
* process that owns the nonce: generate another one and identify again. The
|
|
201
|
+
* store must never attach to a row merely because its nonce matches, which is
|
|
202
|
+
* exactly what a "reuse the matching row" fallback would do -- and that row
|
|
203
|
+
* belongs to a different process incarnation. Reachable only while unbound; a
|
|
204
|
+
* bound process compares against the binding it recorded.
|
|
205
|
+
*/
|
|
206
|
+
export class ConnectionNonceCollisionError extends Error {
|
|
207
|
+
connectionId;
|
|
208
|
+
constructor(connectionId) {
|
|
209
|
+
super(`connection nonce ${connectionId} is already recorded against another ` +
|
|
210
|
+
`persona row; regenerate it and identify again`);
|
|
211
|
+
this.name = "ConnectionNonceCollisionError";
|
|
212
|
+
this.connectionId = connectionId;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Nickname allocation exhausted its candidates inside the identify transaction.
|
|
184
217
|
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
218
|
+
* The six-hex token is only 24 bits, so a collision is a normal event and the
|
|
219
|
+
* caller retries with a fresh candidate. Running out means the id space for
|
|
220
|
+
* this tuple is genuinely saturated (or the candidate supplier is broken), and
|
|
221
|
+
* the transaction rolls back rather than committing a half-transition.
|
|
188
222
|
*/
|
|
189
|
-
export class
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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;
|
|
223
|
+
export class NicknameExhaustedError extends Error {
|
|
224
|
+
constructor(attempts) {
|
|
225
|
+
super(`could not allocate a free persona nickname after ${attempts} attempts; ` +
|
|
226
|
+
`no identity was created and any prior binding is unchanged`);
|
|
227
|
+
this.name = "NicknameExhaustedError";
|
|
207
228
|
}
|
|
208
229
|
}
|
|
209
230
|
/** Serialized-size budget for a metadata listing's row ARRAY, leaving room for
|
|
@@ -322,6 +343,33 @@ function fitRows(rows, budget) {
|
|
|
322
343
|
}
|
|
323
344
|
return { rows: kept, sizeTrimmed };
|
|
324
345
|
}
|
|
346
|
+
/**
|
|
347
|
+
* Fit an unread-room summary to a byte budget: drop whole rows with fitRows,
|
|
348
|
+
* then, because fitRows always keeps one, halve a lone survivor's `name` until
|
|
349
|
+
* the MEASURED serialized size fits. A fixed code-unit cut under-counts JSON
|
|
350
|
+
* escaping, so a control-heavy name slipped past it; room_id stays the stable
|
|
351
|
+
* key, and the caller keeps its own reason-for-truncation flag separate.
|
|
352
|
+
*/
|
|
353
|
+
function fitRoomSummary(rooms, budget) {
|
|
354
|
+
const fitted = fitRows(rooms, budget);
|
|
355
|
+
const kept = fitted.rows;
|
|
356
|
+
if (kept.length === 1 && JSON.stringify(kept).length > budget) {
|
|
357
|
+
let entry = { ...kept[0] };
|
|
358
|
+
while (JSON.stringify([entry]).length > budget && entry.name.length > 0) {
|
|
359
|
+
entry = {
|
|
360
|
+
...entry,
|
|
361
|
+
name: safeCut(entry.name, Math.floor(entry.name.length / 2)),
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
kept[0] = entry;
|
|
365
|
+
return { rooms: kept, sizeTrimmed: true };
|
|
366
|
+
}
|
|
367
|
+
return { rooms: kept, sizeTrimmed: fitted.sizeTrimmed };
|
|
368
|
+
}
|
|
369
|
+
/** Bounded list of a retired identity's former rooms carried in the transition
|
|
370
|
+
* response. The cap exists because a persona can be present in an unbounded
|
|
371
|
+
* number of rooms and the response must stay inside the client output cap. */
|
|
372
|
+
export const MAX_PREVIOUS_ROOM_NAMES = 200;
|
|
325
373
|
/** Full body length in CODEPOINTS, the unit the reported `length` field uses
|
|
326
374
|
* everywhere. Prefers the fetched-row codepoint count; for a synthetic row
|
|
327
375
|
* built in code (no body_cp), the number of codepoints in the body in hand. */
|
|
@@ -357,11 +405,11 @@ function messageCols(bodyCap) {
|
|
|
357
405
|
length(g.body) AS body_cp,
|
|
358
406
|
g.mentions, g.reply_to_seq,
|
|
359
407
|
datetime(g.created_at, 'localtime') AS created_local,
|
|
360
|
-
|
|
408
|
+
unixepoch(g.created_at) AS created_unix,
|
|
361
409
|
g.supersedes_seq,
|
|
362
|
-
(SELECT s.seq
|
|
363
|
-
|
|
364
|
-
|
|
410
|
+
(SELECT MAX(s.seq)
|
|
411
|
+
FROM messages s INDEXED BY idx_messages_supersedes
|
|
412
|
+
WHERE s.room_id = g.room_id AND s.supersedes_seq = g.seq) AS superseded_by,
|
|
365
413
|
p.agent_id AS reply_from, substr(p.body, 1, 101) AS reply_preview`;
|
|
366
414
|
}
|
|
367
415
|
// No join to agents: a message envelope carries the author id and nothing else
|
|
@@ -370,6 +418,11 @@ function messageCols(bodyCap) {
|
|
|
370
418
|
// never have held when writing it.
|
|
371
419
|
const MESSAGE_FROM = `messages g
|
|
372
420
|
LEFT JOIN messages p ON p.room_id = g.room_id AND p.seq = g.reply_to_seq`;
|
|
421
|
+
/** Deleted rooms cannot be rejoined; point callers to valid recovery paths. */
|
|
422
|
+
function deletedRoomMessage(roomId) {
|
|
423
|
+
return (`room ${roomId} no longer exists (it was deleted); it cannot be rejoined -- ` +
|
|
424
|
+
"use list_rooms to see what remains, or create_room to make a new one");
|
|
425
|
+
}
|
|
373
426
|
/**
|
|
374
427
|
* SQL predicate for "directed at me": an explicit mention, OR a reply to a
|
|
375
428
|
* message I authored. Binds the agent id to TWO `?` placeholders in order
|
|
@@ -386,6 +439,41 @@ export function directedAt(alias) {
|
|
|
386
439
|
return `(EXISTS (SELECT 1 FROM json_each(${alias}.mentions) WHERE value = ?)
|
|
387
440
|
OR IFNULL(${alias}.reply_to_agent = ?, 0))`;
|
|
388
441
|
}
|
|
442
|
+
function chmodBestEffort(path, mode) {
|
|
443
|
+
try {
|
|
444
|
+
chmodSync(path, mode);
|
|
445
|
+
}
|
|
446
|
+
catch { }
|
|
447
|
+
}
|
|
448
|
+
function enableWalWithRetry(db) {
|
|
449
|
+
// Switching a brand-new rollback-journal file to WAL needs an exclusive
|
|
450
|
+
// lock, and SQLite can return SQLITE_BUSY here WITHOUT consulting the
|
|
451
|
+
// busy handler (better-sqlite3's default 5s timeout does not cover this
|
|
452
|
+
// path), so two fresh processes racing to convert the same file
|
|
453
|
+
// intermittently crashed on startup (reproduced ~1 in 24 synchronized
|
|
454
|
+
// opens). This is a STARTUP RACE, not a compatibility path: retry with a
|
|
455
|
+
// short synchronous backoff and the loser finds the file already in WAL
|
|
456
|
+
// and succeeds immediately. A no-op on already-WAL files, i.e. every
|
|
457
|
+
// startup after the first.
|
|
458
|
+
for (let attempt = 1;; attempt++) {
|
|
459
|
+
try {
|
|
460
|
+
db.pragma("journal_mode = WAL");
|
|
461
|
+
break;
|
|
462
|
+
}
|
|
463
|
+
catch (e) {
|
|
464
|
+
// Prefix match: better-sqlite3 surfaces EXTENDED result codes (e.g.
|
|
465
|
+
// SQLITE_BUSY_RECOVERY when another connection is mid-WAL-recovery,
|
|
466
|
+
// plausible in exactly this conversion race), and all of them mean
|
|
467
|
+
// the same thing here: someone else holds the file, try again.
|
|
468
|
+
const code = e.code ?? "";
|
|
469
|
+
if (attempt >= 20 || !code.startsWith("SQLITE_BUSY")) {
|
|
470
|
+
throw e;
|
|
471
|
+
}
|
|
472
|
+
// Synchronous sleep (constructor context); ~4.75s worst-case total.
|
|
473
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25 * attempt);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
389
477
|
export class ChatStore {
|
|
390
478
|
path;
|
|
391
479
|
db;
|
|
@@ -402,56 +490,22 @@ export class ChatStore {
|
|
|
402
490
|
// already existed; chmod only that. Best-effort: permissions are a
|
|
403
491
|
// hardening layer, not a startup gate (and a no-op concept on Windows).
|
|
404
492
|
const created = mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
chmodSync(p, 0o600);
|
|
411
|
-
}
|
|
493
|
+
if (created)
|
|
494
|
+
chmodBestEffort(created, 0o700);
|
|
495
|
+
for (const p of [path, `${path}-wal`, `${path}-shm`]) {
|
|
496
|
+
if (existsSync(p))
|
|
497
|
+
chmodBestEffort(p, 0o600);
|
|
412
498
|
}
|
|
413
|
-
catch { }
|
|
414
499
|
}
|
|
415
500
|
this.db = new Database(path);
|
|
416
501
|
try {
|
|
417
502
|
if (path !== ":memory:") {
|
|
418
|
-
|
|
419
|
-
chmodSync(path, 0o600);
|
|
420
|
-
}
|
|
421
|
-
catch { }
|
|
422
|
-
}
|
|
423
|
-
// Switching a brand-new rollback-journal file to WAL needs an exclusive
|
|
424
|
-
// lock, and SQLite can return SQLITE_BUSY here WITHOUT consulting the
|
|
425
|
-
// busy handler (better-sqlite3's default 5s timeout does not cover this
|
|
426
|
-
// path), so two fresh processes racing to convert the same file
|
|
427
|
-
// intermittently crashed on startup (reproduced ~1 in 24 synchronized
|
|
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.
|
|
432
|
-
for (let attempt = 1;; attempt++) {
|
|
433
|
-
try {
|
|
434
|
-
this.db.pragma("journal_mode = WAL");
|
|
435
|
-
break;
|
|
436
|
-
}
|
|
437
|
-
catch (e) {
|
|
438
|
-
// Prefix match: better-sqlite3 surfaces EXTENDED result codes (e.g.
|
|
439
|
-
// SQLITE_BUSY_RECOVERY when another connection is mid-WAL-recovery,
|
|
440
|
-
// plausible in exactly this conversion race), and all of them mean
|
|
441
|
-
// the same thing here: someone else holds the file, try again.
|
|
442
|
-
const code = e.code ?? "";
|
|
443
|
-
if (attempt >= 20 || !code.startsWith("SQLITE_BUSY")) {
|
|
444
|
-
throw e;
|
|
445
|
-
}
|
|
446
|
-
// Synchronous sleep (constructor context); ~4.75s worst-case total.
|
|
447
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25 * attempt);
|
|
448
|
-
}
|
|
503
|
+
chmodBestEffort(path, 0o600);
|
|
449
504
|
}
|
|
505
|
+
enableWalWithRetry(this.db);
|
|
450
506
|
this.db.pragma("busy_timeout = 5000");
|
|
451
507
|
this.db.pragma("foreign_keys = ON");
|
|
452
|
-
//
|
|
453
|
-
// transaction, concurrent MCP startups could all find the FTS index
|
|
454
|
-
// missing and each repeat a full-corpus rebuild.
|
|
508
|
+
// Initialize the current schema atomically and serialize concurrent starts.
|
|
455
509
|
this.db.transaction(() => this.initializeSchema()).immediate();
|
|
456
510
|
}
|
|
457
511
|
catch (error) {
|
|
@@ -480,40 +534,133 @@ export class ChatStore {
|
|
|
480
534
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
481
535
|
);
|
|
482
536
|
|
|
483
|
-
-- One row per PERSONA
|
|
484
|
-
--
|
|
485
|
-
--
|
|
486
|
-
--
|
|
487
|
-
--
|
|
488
|
-
--
|
|
489
|
-
--
|
|
537
|
+
-- One row per PERSONA, in exactly three legal shapes:
|
|
538
|
+
--
|
|
539
|
+
-- human human_base/human_ordinal set, no tuple, no
|
|
540
|
+
-- connection, never retired
|
|
541
|
+
-- bound LLM complete tuple, a connection nonce, not retired
|
|
542
|
+
-- retired LLM complete tuple, no connection, retired_at set
|
|
543
|
+
--
|
|
544
|
+
-- The CHECK is what makes those the ONLY shapes ANY writer can create,
|
|
545
|
+
-- including the direct ones (web/server.mjs holds its own handle, tests
|
|
546
|
+
-- open the file, sqlite3 exists) that never pass through this file's JS
|
|
547
|
+
-- guards. JS still owns trim() semantics, UUID format, and readable
|
|
548
|
+
-- messages; SQL owns the shape, because JS cannot reach the other
|
|
549
|
+
-- writers.
|
|
550
|
+
--
|
|
551
|
+
-- "Bound" deliberately does not say LIVE. A hard-killed process leaves a
|
|
552
|
+
-- non-null connection_id behind and nothing may read that as liveness or
|
|
553
|
+
-- as possession: no caller can submit a connection value, and a first
|
|
554
|
+
-- bind that collides regenerates instead of adopting the row.
|
|
490
555
|
CREATE TABLE IF NOT EXISTS agents (
|
|
491
|
-
|
|
556
|
+
-- NOT NULL is NOT redundant here. In an ordinary rowid table SQLite
|
|
557
|
+
-- deliberately departs from the standard: a PRIMARY KEY on a non-
|
|
558
|
+
-- INTEGER column does not imply NOT NULL, so 'id TEXT PRIMARY KEY'
|
|
559
|
+
-- alone accepts NULL, and repeatedly, because NULLs compare distinct in
|
|
560
|
+
-- the implicit unique index. Two nameless participants is the result.
|
|
561
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
492
562
|
is_human INTEGER NOT NULL DEFAULT 0,
|
|
493
563
|
brand TEXT,
|
|
494
564
|
model TEXT,
|
|
495
565
|
version TEXT,
|
|
496
|
-
|
|
497
|
-
--
|
|
498
|
-
--
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
--
|
|
503
|
-
|
|
566
|
+
-- The MCP stdio server PROCESS INCARNATION holding this identity.
|
|
567
|
+
-- Internal association only: it is never returned by a tool, never
|
|
568
|
+
-- accepted as input, and never joined into a message or listing.
|
|
569
|
+
connection_id TEXT,
|
|
570
|
+
human_base TEXT,
|
|
571
|
+
human_ordinal INTEGER,
|
|
572
|
+
-- Terminal for LLM rows. Never cleared, so a nickname is never
|
|
573
|
+
-- reactivated: A -> B -> A is three distinct identities.
|
|
574
|
+
retired_at TEXT,
|
|
504
575
|
description TEXT,
|
|
505
576
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
577
|
+
CHECK (is_human IN (0, 1)),
|
|
578
|
+
-- NUL FIRST, because it is what makes every other text check honest.
|
|
579
|
+
-- SQLite's length(), substr(), and GLOB all stop at the first NUL, so
|
|
580
|
+
-- without this an id of 'ab<NUL>cd' measures 2, passes the bounds, and
|
|
581
|
+
-- passes the character grammar -- while every listing that reads it
|
|
582
|
+
-- back silently loses the tail. Measured, not assumed: length() of a
|
|
583
|
+
-- NUL-bearing string returns the prefix count, and the || operator
|
|
584
|
+
-- PRESERVES the NUL, which is why this one check also covers
|
|
585
|
+
-- human_base through the id-equality rule below.
|
|
586
|
+
CHECK (instr(id, char(0)) = 0),
|
|
587
|
+
-- Lower bound as well as upper: '' is a legal TEXT primary key in
|
|
588
|
+
-- SQLite, and an empty nickname would be an addressable participant
|
|
589
|
+
-- nobody can mention, reply to, or tell apart from a missing value.
|
|
590
|
+
CHECK (length(id) BETWEEN 1 AND ${MAX_AGENT_ID_CHARS}),
|
|
506
591
|
CHECK (
|
|
507
592
|
(is_human = 1
|
|
508
|
-
AND brand IS NULL AND model IS NULL
|
|
509
|
-
AND
|
|
593
|
+
AND brand IS NULL AND model IS NULL AND version IS NULL
|
|
594
|
+
AND connection_id IS NULL AND retired_at IS NULL
|
|
595
|
+
AND human_base IS NOT NULL AND human_ordinal IS NOT NULL
|
|
596
|
+
AND length(human_base) BETWEEN 1 AND ${MAX_HUMAN_BASE_CHARS}
|
|
597
|
+
-- ASCII letters, digits, underscore, dot, dash only. GLOB is
|
|
598
|
+
-- case-sensitive, so this rejects whitespace, ordinary control
|
|
599
|
+
-- characters, and anything non-ASCII. It does NOT reject an
|
|
600
|
+
-- embedded NUL: GLOB stops scanning there, so a NUL-bearing base
|
|
601
|
+
-- passes this test. The global instr(id, ...) check above is what
|
|
602
|
+
-- covers that case, via the id-equality rule below.
|
|
603
|
+
AND human_base NOT GLOB '*[^A-Za-z0-9_.-]*'
|
|
604
|
+
AND substr(human_base, 1, 1) GLOB '[A-Za-z0-9_]'
|
|
605
|
+
AND lower(substr(human_base, 1, 6)) <> 'human-'
|
|
606
|
+
-- typeof, not just bounds: 1.5 satisfies both bounds, and
|
|
607
|
+
-- CAST(1.5 AS TEXT) is '1.5', so the id-equality rule would
|
|
608
|
+
-- happily accept 'human-alex-1.5' as a canonical identity.
|
|
609
|
+
AND typeof(human_ordinal) = 'integer'
|
|
610
|
+
AND human_ordinal > 0
|
|
611
|
+
AND human_ordinal <= ${Number.MAX_SAFE_INTEGER}
|
|
612
|
+
-- Implies the reserved lowercase prefix: id is BUILT from it.
|
|
613
|
+
AND id = 'human-' || human_base || '-' || CAST(human_ordinal AS TEXT))
|
|
510
614
|
OR
|
|
511
615
|
(is_human = 0
|
|
512
|
-
AND
|
|
513
|
-
AND
|
|
616
|
+
AND human_base IS NULL AND human_ordinal IS NULL
|
|
617
|
+
AND brand IS NOT NULL AND model IS NOT NULL AND version IS NOT NULL
|
|
618
|
+
-- length() is NUL-terminated in SQLite, so the instr() guards do
|
|
619
|
+
-- the real work: a lone NUL measures 0 and fails the lower bound,
|
|
620
|
+
-- but a NUL in the MIDDLE measures only the prefix before it and
|
|
621
|
+
-- would otherwise pass.
|
|
622
|
+
AND length(brand) BETWEEN 1 AND 100
|
|
623
|
+
AND length(model) BETWEEN 1 AND 100
|
|
624
|
+
AND length(version) BETWEEN 1 AND 100
|
|
625
|
+
AND instr(brand, char(0)) = 0
|
|
626
|
+
AND instr(model, char(0)) = 0
|
|
627
|
+
AND instr(version, char(0)) = 0
|
|
628
|
+
-- Case-insensitive, so 'Human-' cannot squat the namespace.
|
|
629
|
+
AND lower(substr(id, 1, 6)) <> 'human-'
|
|
630
|
+
AND ((retired_at IS NULL AND connection_id IS NOT NULL)
|
|
631
|
+
OR (retired_at IS NOT NULL AND connection_id IS NULL)))
|
|
514
632
|
)
|
|
515
633
|
);
|
|
516
634
|
|
|
635
|
+
-- Durable idempotency for human nickname allocation, keyed by an
|
|
636
|
+
-- operation UUID the BROWSER generates before its first request. It
|
|
637
|
+
-- turns "the response was lost" and "localStorage.setItem threw" from
|
|
638
|
+
-- duplicate-identity events into replays.
|
|
639
|
+
--
|
|
640
|
+
-- Deliberately NO foreign key on room_id: deleting a room must not erase
|
|
641
|
+
-- the record needed to answer a replay. A replay for a deleted room
|
|
642
|
+
-- returns the identity that was committed and reports the room gone,
|
|
643
|
+
-- which is recoverable; losing the row would allocate a second ordinal
|
|
644
|
+
-- for one user action, which is not.
|
|
645
|
+
--
|
|
646
|
+
-- This is idempotency, not ownership. It authenticates nothing.
|
|
647
|
+
CREATE TABLE IF NOT EXISTS human_allocations (
|
|
648
|
+
-- NOT NULL for the same SQLite reason as agents.id, and here it is the
|
|
649
|
+
-- whole point of the table: NULL keys compare distinct, so without it
|
|
650
|
+
-- one browser could insert unlimited NULL-keyed rows and the
|
|
651
|
+
-- idempotency this table exists to provide would silently not exist.
|
|
652
|
+
operation_id TEXT PRIMARY KEY NOT NULL,
|
|
653
|
+
room_id INTEGER NOT NULL,
|
|
654
|
+
human_base TEXT NOT NULL,
|
|
655
|
+
result_agent_id TEXT NOT NULL REFERENCES agents(id),
|
|
656
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
657
|
+
-- typeof for the same reason as human_ordinal: affinity would let 1.5
|
|
658
|
+
-- through, and this value is compared against a rooms.id integer when
|
|
659
|
+
-- a replay decides whether the requested room still exists.
|
|
660
|
+
CHECK (typeof(room_id) = 'integer'
|
|
661
|
+
AND room_id > 0 AND room_id <= ${Number.MAX_SAFE_INTEGER})
|
|
662
|
+
);
|
|
663
|
+
|
|
517
664
|
-- role is ROOM-LOCAL and nullable: one persona can be reviewer in one
|
|
518
665
|
-- room and writer in another, and "no role" is a real state distinct from
|
|
519
666
|
-- an empty string. It is deliberately NOT on agents, where a single value
|
|
@@ -604,6 +751,35 @@ export class ChatStore {
|
|
|
604
751
|
WHEN instr(NEW.description, char(0)) > 0 BEGIN
|
|
605
752
|
SELECT RAISE(ABORT, 'agent description contains a NUL character (U+0000)');
|
|
606
753
|
END;
|
|
754
|
+
|
|
755
|
+
-- An allocation record must AGREE with the identity it records. The
|
|
756
|
+
-- foreign key only proves result_agent_id names some agent, so without
|
|
757
|
+
-- this a row could record base 'sam' against human-alex-1, or point at an
|
|
758
|
+
-- LLM row outright -- and a replay would then hand the browser back an
|
|
759
|
+
-- identity that is not the one the base asked for. A CHECK cannot express
|
|
760
|
+
-- this because it cannot read another table, hence a trigger.
|
|
761
|
+
CREATE TRIGGER IF NOT EXISTS human_allocations_require_matching_human
|
|
762
|
+
BEFORE INSERT ON human_allocations
|
|
763
|
+
WHEN NOT EXISTS (
|
|
764
|
+
SELECT 1 FROM agents a
|
|
765
|
+
WHERE a.id = NEW.result_agent_id
|
|
766
|
+
AND a.is_human = 1
|
|
767
|
+
AND a.human_base = NEW.human_base
|
|
768
|
+
) BEGIN
|
|
769
|
+
SELECT RAISE(ABORT, 'human_allocations.result_agent_id must name a human agent whose human_base equals the recorded base');
|
|
770
|
+
END;
|
|
771
|
+
|
|
772
|
+
-- Append-only. Denying UPDATE and DELETE outright is smaller than re-validating either,
|
|
773
|
+
-- and there is no legitimate reason to rewrite a committed allocation:
|
|
774
|
+
-- its whole purpose is to be the durable answer a replay returns.
|
|
775
|
+
CREATE TRIGGER IF NOT EXISTS human_allocations_append_only
|
|
776
|
+
BEFORE UPDATE ON human_allocations BEGIN
|
|
777
|
+
SELECT RAISE(ABORT, 'human_allocations is append-only: a committed allocation record cannot be modified');
|
|
778
|
+
END;
|
|
779
|
+
CREATE TRIGGER IF NOT EXISTS human_allocations_no_delete
|
|
780
|
+
BEFORE DELETE ON human_allocations BEGIN
|
|
781
|
+
SELECT RAISE(ABORT, 'human_allocations is append-only: a committed allocation record cannot be deleted');
|
|
782
|
+
END;
|
|
607
783
|
`);
|
|
608
784
|
this.db.exec(`
|
|
609
785
|
-- No explicit (room_id, seq) index: UNIQUE(room_id, seq) already provides
|
|
@@ -629,6 +805,19 @@ export class ChatStore {
|
|
|
629
805
|
CREATE INDEX IF NOT EXISTS idx_memberships_agent_present
|
|
630
806
|
ON memberships(agent_id, left_at, room_id);
|
|
631
807
|
|
|
808
|
+
-- One process incarnation holds at most one current LLM nickname. This
|
|
809
|
+
-- is the DATABASE's statement of that rule, so a direct writer cannot
|
|
810
|
+
-- point two live identities at one connection. Partial: retired and
|
|
811
|
+
-- human rows carry NULL and cost no index entry.
|
|
812
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_connection
|
|
813
|
+
ON agents(connection_id) WHERE connection_id IS NOT NULL;
|
|
814
|
+
|
|
815
|
+
-- What actually prevents two concurrent viewers from allocating the same
|
|
816
|
+
-- human-alex-1: SQLite serializes the writes, and this index rejects the
|
|
817
|
+
-- loser's duplicate ordinal instead of letting both commit.
|
|
818
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_human_identity
|
|
819
|
+
ON agents(human_base, human_ordinal) WHERE is_human = 1;
|
|
820
|
+
|
|
632
821
|
-- Advisory single-winner work claims with TTL. Purely advisory: nothing
|
|
633
822
|
-- fences the claimed resource itself; expiry frees claims from crashed
|
|
634
823
|
-- holders.
|
|
@@ -653,17 +842,16 @@ export class ChatStore {
|
|
|
653
842
|
-- memberships), so last_seen is the weaker signal of the two: recent
|
|
654
843
|
-- runtime activity. "watching" stays the strong one.
|
|
655
844
|
--
|
|
656
|
-
-- Keyed (room_id, agent_id)
|
|
657
|
-
--
|
|
658
|
-
--
|
|
659
|
-
--
|
|
660
|
-
--
|
|
661
|
-
-- is
|
|
662
|
-
--
|
|
845
|
+
-- Keyed (room_id, agent_id) and nothing else. One persona belongs to one
|
|
846
|
+
-- connection and an agent_id names one tenure for the life of the
|
|
847
|
+
-- database, so a persona has at most one wait row per room and two rows
|
|
848
|
+
-- would report two watchers for one persona. A late cleanup from a
|
|
849
|
+
-- superseded identity cannot reach a successor's row because the
|
|
850
|
+
-- successor is a DIFFERENT agent_id, and retirement deletes the retired
|
|
851
|
+
-- persona's leases outright in the same transaction.
|
|
663
852
|
CREATE TABLE IF NOT EXISTS wait_leases (
|
|
664
853
|
room_id INTEGER NOT NULL REFERENCES rooms(id),
|
|
665
854
|
agent_id TEXT NOT NULL REFERENCES agents(id),
|
|
666
|
-
epoch INTEGER NOT NULL,
|
|
667
855
|
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
668
856
|
expires_at TEXT NOT NULL,
|
|
669
857
|
PRIMARY KEY (room_id, agent_id)
|
|
@@ -696,51 +884,43 @@ export class ChatStore {
|
|
|
696
884
|
VALUES ('delete', old.id, old.body);
|
|
697
885
|
END;
|
|
698
886
|
`);
|
|
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
|
|
706
|
-
// reads the content table and always matches. BOTH counts are read in ONE
|
|
707
|
-
// statement (a single consistent snapshot): two separate SELECTs could
|
|
708
|
-
// straddle a concurrent insert -- messages counted pre-insert, fts counted
|
|
709
|
-
// post-trigger -- making an already-inconsistent {2,1} file read as {2,2}
|
|
710
|
-
// and skip the rebuild it actually needed.
|
|
711
|
-
const { hasMessages, hasFtsRows } = this.db
|
|
712
|
-
.prepare(`SELECT EXISTS(SELECT 1 FROM messages LIMIT 1) AS hasMessages,
|
|
713
|
-
EXISTS(SELECT 1 FROM messages_fts_docsize LIMIT 1) AS hasFtsRows`)
|
|
714
|
-
.get();
|
|
715
|
-
if (hasMessages !== hasFtsRows) {
|
|
716
|
-
this.db.exec("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')");
|
|
717
|
-
}
|
|
718
887
|
}
|
|
719
888
|
// --- rooms -------------------------------------------------------------
|
|
720
889
|
/**
|
|
721
|
-
* Create a room,
|
|
890
|
+
* Create a room, fenced to a live calling persona.
|
|
722
891
|
*
|
|
723
892
|
* Room administration stays UNAUTHENTICATED and global: any current persona
|
|
724
893
|
* can create or delete any room, and nothing verifies who they are. What the
|
|
725
894
|
* fence removes is authority from a runtime the system positively knows is
|
|
726
895
|
* superseded. Leaving the highest-blast-radius operations as the only
|
|
727
|
-
* unfenced writes made the
|
|
896
|
+
* unfenced writes made the fence exempt exactly what it should protect
|
|
728
897
|
* most: a fenced-out runtime could not post a message but could delete the
|
|
729
898
|
* room the message was in.
|
|
730
899
|
*
|
|
731
|
-
* Rooms must precede JOINS, not
|
|
732
|
-
*
|
|
733
|
-
*
|
|
900
|
+
* Rooms must precede JOINS, not identification. identify_persona is one call
|
|
901
|
+
* that every participating runtime makes anyway, and
|
|
902
|
+
* identify_persona -> create_room -> join_room satisfies the bootstrap
|
|
903
|
+
* workflow.
|
|
734
904
|
*/
|
|
735
|
-
createRoom(name, description, pinned, agentId
|
|
905
|
+
createRoom(name, description, pinned, agentId) {
|
|
736
906
|
assertStorable(name, "room name");
|
|
737
907
|
assertMaxLen(name, "room name", 200);
|
|
908
|
+
// Room names are exact identifiers, so reject edge whitespace, do not trim.
|
|
909
|
+
if (name.length === 0 || name !== name.trim()) {
|
|
910
|
+
throw new Error("room name must be non-empty with no leading or trailing whitespace");
|
|
911
|
+
}
|
|
912
|
+
// Numeric references resolve as ids first, so an all-digit name can retarget
|
|
913
|
+
// reads and destructive operations to a different room.
|
|
914
|
+
if (/^\d+$/.test(name)) {
|
|
915
|
+
throw new Error("room names cannot be all digits (ambiguous with room ids); " +
|
|
916
|
+
"pick a descriptive kebab-case topic name");
|
|
917
|
+
}
|
|
738
918
|
assertStorable(description, "room description");
|
|
739
919
|
assertMaxLen(description, "room description", 2000);
|
|
740
920
|
assertStorable(pinned, "room pinned intro");
|
|
741
921
|
assertMaxLen(pinned, "room pinned intro", 10_000);
|
|
742
922
|
const tx = this.db.transaction(() => {
|
|
743
|
-
this.
|
|
923
|
+
this.requireLive(agentId);
|
|
744
924
|
return this.db
|
|
745
925
|
.prepare(`INSERT INTO rooms (name, description, pinned) VALUES (?, ?, ?)
|
|
746
926
|
RETURNING *`)
|
|
@@ -748,18 +928,21 @@ export class ChatStore {
|
|
|
748
928
|
});
|
|
749
929
|
return tx.immediate();
|
|
750
930
|
}
|
|
751
|
-
setPinned(roomId, agentId,
|
|
931
|
+
setPinned(roomId, agentId, pinned) {
|
|
752
932
|
assertStorable(pinned, "room pinned intro");
|
|
753
933
|
assertMaxLen(pinned, "room pinned intro", 10_000);
|
|
754
934
|
const tx = this.db.transaction(() => {
|
|
755
|
-
this.
|
|
935
|
+
this.requireLive(agentId);
|
|
936
|
+
// Writing the pinned intro is room participation.
|
|
937
|
+
this.requirePresent(roomId, agentId);
|
|
756
938
|
const info = this.db
|
|
757
939
|
.prepare("UPDATE rooms SET pinned = ? WHERE id = ?")
|
|
758
940
|
.run(pinned, roomId);
|
|
759
|
-
//
|
|
760
|
-
//
|
|
941
|
+
// A present membership has a foreign key to this row in the same
|
|
942
|
+
// transaction, so zero changes means the invariant is broken.
|
|
761
943
|
if (info.changes === 0) {
|
|
762
|
-
throw new Error(`room ${roomId}
|
|
944
|
+
throw new Error(`internal invariant violated: room ${roomId} has a live membership ` +
|
|
945
|
+
"but no room row; the database may be corrupt");
|
|
763
946
|
}
|
|
764
947
|
});
|
|
765
948
|
tx.immediate();
|
|
@@ -767,17 +950,14 @@ export class ChatStore {
|
|
|
767
950
|
getRoom(roomId) {
|
|
768
951
|
return this.db.prepare("SELECT * FROM rooms WHERE id = ?").get(roomId);
|
|
769
952
|
}
|
|
770
|
-
/**
|
|
771
|
-
*
|
|
772
|
-
* a cross-process delete_room in the window yields this message instead of
|
|
773
|
-
* a raw FK constraint failure or a false no-op success. */
|
|
953
|
+
/** Recheck room existence inside transactions where membership is not the
|
|
954
|
+
* gate, so a concurrent deletion returns a useful error. */
|
|
774
955
|
requireRoom(roomId) {
|
|
775
956
|
const row = this.db
|
|
776
957
|
.prepare("SELECT 1 FROM rooms WHERE id = ?")
|
|
777
958
|
.get(roomId);
|
|
778
|
-
if (!row)
|
|
779
|
-
throw new Error(
|
|
780
|
-
}
|
|
959
|
+
if (!row)
|
|
960
|
+
throw new Error(deletedRoomMessage(roomId));
|
|
781
961
|
}
|
|
782
962
|
/** Exact name lookup (never interprets the value as an id). */
|
|
783
963
|
getRoomByName(name) {
|
|
@@ -790,10 +970,10 @@ export class ChatStore {
|
|
|
790
970
|
*/
|
|
791
971
|
currentTime() {
|
|
792
972
|
const r = this.db
|
|
793
|
-
.prepare(`SELECT
|
|
973
|
+
.prepare(`SELECT unixepoch('now') AS unix,
|
|
794
974
|
datetime('now','localtime') AS at,
|
|
795
|
-
|
|
796
|
-
-
|
|
975
|
+
unixepoch(datetime('now','localtime'))
|
|
976
|
+
- unixepoch('now') AS offset_seconds`)
|
|
797
977
|
.get();
|
|
798
978
|
// ISO 8601 local time with explicit offset, e.g. 2026-07-08T03:35:13-04:00.
|
|
799
979
|
// offset_seconds is the local wall-clock read as UTC minus true UTC, i.e.
|
|
@@ -830,6 +1010,8 @@ export class ChatStore {
|
|
|
830
1010
|
// a further page without a tail COUNT.
|
|
831
1011
|
const { rows, total } = this.db
|
|
832
1012
|
.transaction(() => {
|
|
1013
|
+
// Activity means the timestamp on the highest-seq message, not the
|
|
1014
|
+
// greatest wall-clock value. Clocks can move backward between posts.
|
|
833
1015
|
const rows = this.db
|
|
834
1016
|
.prepare(`SELECT r.id, r.name,
|
|
835
1017
|
substr(r.description, 1, ${PREVIEW}) AS description,
|
|
@@ -839,7 +1021,8 @@ export class ChatStore {
|
|
|
839
1021
|
r.created_at,
|
|
840
1022
|
(SELECT COUNT(*) FROM memberships m WHERE m.room_id = r.id AND m.left_at IS NULL) AS members,
|
|
841
1023
|
(SELECT COUNT(*) FROM messages g WHERE g.room_id = r.id) AS messages,
|
|
842
|
-
(SELECT
|
|
1024
|
+
(SELECT created_at FROM messages g
|
|
1025
|
+
WHERE g.room_id = r.id ORDER BY g.seq DESC LIMIT 1) AS last_activity
|
|
843
1026
|
FROM rooms r WHERE r.id > ? ORDER BY r.id LIMIT ?`)
|
|
844
1027
|
.all(Math.max(0, Math.floor(afterId)), lim + 1);
|
|
845
1028
|
const { c: total } = this.db
|
|
@@ -879,13 +1062,14 @@ export class ChatStore {
|
|
|
879
1062
|
.get(roomId);
|
|
880
1063
|
return c;
|
|
881
1064
|
}
|
|
882
|
-
/**
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
1065
|
+
/** Total and present room memberships for watcher arm-time diagnostics. */
|
|
1066
|
+
roomMembershipCounts(agentId) {
|
|
1067
|
+
return this.db
|
|
1068
|
+
.prepare(`SELECT COUNT(*) AS total,
|
|
1069
|
+
COALESCE(SUM(CASE WHEN left_at IS NULL THEN 1 ELSE 0 END), 0)
|
|
1070
|
+
AS present
|
|
1071
|
+
FROM memberships WHERE agent_id = ?`)
|
|
887
1072
|
.get(agentId);
|
|
888
|
-
return c;
|
|
889
1073
|
}
|
|
890
1074
|
/** Names of the rooms this persona is present in. Bounded: this feeds an
|
|
891
1075
|
* error message, and a persona in hundreds of rooms must not turn one
|
|
@@ -918,154 +1102,323 @@ export class ChatStore {
|
|
|
918
1102
|
}
|
|
919
1103
|
// --- personas -----------------------------------------------------------
|
|
920
1104
|
/**
|
|
921
|
-
* Verify that
|
|
922
|
-
* PersonaLostError if
|
|
1105
|
+
* Verify that this identity still EXISTS and is not retired, throwing
|
|
1106
|
+
* PersonaLostError if either fails.
|
|
1107
|
+
*
|
|
1108
|
+
* This is the complete stale-operation fence. An agent_id is allocated once
|
|
1109
|
+
* and never deleted, reinserted, revived, or rebound, so it names exactly one
|
|
1110
|
+
* tenure forever; an operation captured under a superseded identity therefore
|
|
1111
|
+
* carries an id whose row is now retired, and that is what this catches. No
|
|
1112
|
+
* ordinal is needed to tell tenures apart because an id never has two.
|
|
923
1113
|
*
|
|
924
1114
|
* MUST be called from INSIDE the transaction that performs the guarded write.
|
|
925
|
-
* Checking first and writing afterwards is not equivalent: a
|
|
1115
|
+
* Checking first and writing afterwards is not equivalent: a retirement
|
|
926
1116
|
* committing in the gap leaves the check passing and the write landing under
|
|
927
1117
|
* an authority the caller no longer holds, which is the entire failure this
|
|
928
1118
|
* exists to prevent. Every caller below is inside a tx.immediate().
|
|
929
1119
|
*/
|
|
930
|
-
|
|
1120
|
+
requireLive(agentId) {
|
|
931
1121
|
const row = this.db
|
|
932
|
-
.prepare("SELECT
|
|
1122
|
+
.prepare("SELECT retired_at FROM agents WHERE id = ?")
|
|
933
1123
|
.get(agentId);
|
|
934
1124
|
if (!row)
|
|
935
|
-
throw new PersonaLostError(agentId,
|
|
936
|
-
if (row.
|
|
937
|
-
throw new PersonaLostError(agentId,
|
|
938
|
-
}
|
|
1125
|
+
throw new PersonaLostError(agentId, "missing");
|
|
1126
|
+
if (row.retired_at !== null)
|
|
1127
|
+
throw new PersonaLostError(agentId, "retired");
|
|
939
1128
|
}
|
|
940
1129
|
/**
|
|
941
|
-
*
|
|
942
|
-
*
|
|
943
|
-
*
|
|
944
|
-
*
|
|
945
|
-
*
|
|
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.
|
|
1130
|
+
* Transactional gate for present-only operations. It returns the cursor from
|
|
1131
|
+
* the same membership read. On failure, a room lookup distinguishes a
|
|
1132
|
+
* cascaded deletion from never-joined and soft-left membership states.
|
|
1133
|
+
* It must run inside the transaction that performs the guarded write.
|
|
1134
|
+
* Non-advancing reads and claim release intentionally bypass this gate.
|
|
956
1135
|
*/
|
|
957
1136
|
requirePresent(roomId, agentId) {
|
|
958
1137
|
const row = this.db
|
|
959
|
-
.prepare(
|
|
1138
|
+
.prepare(`SELECT last_read_seq, left_at FROM memberships
|
|
1139
|
+
WHERE room_id = ? AND agent_id = ?`)
|
|
960
1140
|
.get(roomId, agentId);
|
|
961
|
-
if (
|
|
962
|
-
|
|
1141
|
+
if (row !== undefined && row.left_at === null) {
|
|
1142
|
+
return { last_read_seq: row.last_read_seq };
|
|
963
1143
|
}
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
1144
|
+
const room = this.db
|
|
1145
|
+
.prepare("SELECT name FROM rooms WHERE id = ?")
|
|
1146
|
+
.get(roomId);
|
|
1147
|
+
if (!room)
|
|
1148
|
+
throw new Error(deletedRoomMessage(roomId));
|
|
1149
|
+
const where = `room "${room.name}" (${roomId})`;
|
|
1150
|
+
if (row === undefined) {
|
|
1151
|
+
throw new Error(`you have never joined ${where}; join_room it first`);
|
|
968
1152
|
}
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1153
|
+
throw new Error(`you have LEFT ${where}, so you cannot participate in it: ` +
|
|
1154
|
+
`peers see you as absent. Call join_room to rejoin -- your read ` +
|
|
1155
|
+
`position and room-local role are preserved.`);
|
|
1156
|
+
}
|
|
1157
|
+
/** Why this binding is unusable, or null when it is live. For NON-advancing
|
|
1158
|
+
* reads, which DISCLOSE loss instead of failing, and for arm-time checks
|
|
1159
|
+
* that need the reason rather than an exception. */
|
|
1160
|
+
personaLoss(expected) {
|
|
1161
|
+
this.assertConnectionId(expected.connectionId);
|
|
973
1162
|
const row = this.db
|
|
974
|
-
.prepare("SELECT
|
|
975
|
-
.get(agentId);
|
|
976
|
-
|
|
1163
|
+
.prepare("SELECT is_human, connection_id, retired_at FROM agents WHERE id = ?")
|
|
1164
|
+
.get(expected.agentId);
|
|
1165
|
+
if (!row)
|
|
1166
|
+
return "missing";
|
|
1167
|
+
if (row.retired_at !== null)
|
|
1168
|
+
return "retired";
|
|
1169
|
+
return row.is_human !== 0 || row.connection_id !== expected.connectionId
|
|
1170
|
+
? "binding_mismatch"
|
|
1171
|
+
: null;
|
|
977
1172
|
}
|
|
978
1173
|
getPersona(id) {
|
|
979
1174
|
return this.db.prepare("SELECT * FROM agents WHERE id = ?").get(id);
|
|
980
1175
|
}
|
|
981
1176
|
/**
|
|
982
|
-
*
|
|
983
|
-
*
|
|
984
|
-
*
|
|
1177
|
+
* A connection nonce must be a UUID.
|
|
1178
|
+
*
|
|
1179
|
+
* The database only knows connection_id as opaque text, and the specification
|
|
1180
|
+
* assigns UUID format checking to this layer. Without it "" or "conn-A" bind
|
|
1181
|
+
* happily, and the column stops meaning "a value this server generated with
|
|
1182
|
+
* randomUUID()" -- which is the only claim it makes. Lowercase hex only,
|
|
1183
|
+
* because the sole legitimate producer is randomUUID().
|
|
985
1184
|
*/
|
|
986
|
-
|
|
1185
|
+
assertConnectionId(value) {
|
|
1186
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value)) {
|
|
1187
|
+
throw new Error("connection id must be a lowercase UUID generated by this server");
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
/** Validate the parts of an LLM identity this file is responsible for. The
|
|
1191
|
+
* table CHECK covers the same ground for writers that never come through
|
|
1192
|
+
* here; this exists to fail with a message a caller can act on. */
|
|
1193
|
+
assertLlmIdentity(p) {
|
|
987
1194
|
assertStorable(p.id, "persona id");
|
|
988
1195
|
assertMaxLen(p.id, "persona id", MAX_AGENT_ID_CHARS);
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
1196
|
+
// A candidate supplier that returns "" is a broken allocator, not a
|
|
1197
|
+
// collision, so say so here rather than letting the CHECK reject it as an
|
|
1198
|
+
// opaque constraint failure after the retry loop has burned its attempts.
|
|
1199
|
+
if (p.id.length === 0) {
|
|
1200
|
+
throw new Error("persona id candidate is empty; the nickname allocator produced no id");
|
|
1201
|
+
}
|
|
1202
|
+
for (const [field, value] of [
|
|
1203
|
+
["brand", p.brand],
|
|
1204
|
+
["model", p.model],
|
|
1205
|
+
["version", p.version],
|
|
1206
|
+
]) {
|
|
1207
|
+
assertStorable(value, field);
|
|
1208
|
+
assertMaxLen(value, field, 100);
|
|
1209
|
+
// EXACT trim normalization, checked rather than applied: silently
|
|
1210
|
+
// trimming would make " gpt" and "gpt" the same persona on one call and
|
|
1211
|
+
// different ones on the next, depending on which path normalized. The
|
|
1212
|
+
// tuple is compared byte for byte, so it must arrive already exact.
|
|
1213
|
+
if (value !== value.trim() || value.length === 0) {
|
|
1214
|
+
throw new Error(`${field} must be a trimmed, non-empty string (got ${JSON.stringify(value)})`);
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
997
1217
|
assertStorable(p.description, "persona description");
|
|
998
1218
|
assertMaxLen(p.description, "persona description", 2000);
|
|
1219
|
+
}
|
|
1220
|
+
/**
|
|
1221
|
+
* Insert one candidate nickname. False means the id was taken, so a caller
|
|
1222
|
+
* drawing candidates cannot collapse two personas onto one row (and with it
|
|
1223
|
+
* one read marker, one membership set, and one claim owner).
|
|
1224
|
+
*/
|
|
1225
|
+
tryInsertPersona(p) {
|
|
1226
|
+
this.assertLlmIdentity(p);
|
|
999
1227
|
const info = this.db
|
|
1000
1228
|
.prepare(`INSERT INTO agents
|
|
1001
|
-
(id, is_human, brand, model, version,
|
|
1002
|
-
VALUES (@id, 0, @brand, @model, @version, @
|
|
1229
|
+
(id, is_human, brand, model, version, connection_id, description)
|
|
1230
|
+
VALUES (@id, 0, @brand, @model, @version, @connectionId, @description)
|
|
1003
1231
|
ON CONFLICT(id) DO NOTHING`)
|
|
1004
1232
|
.run(p);
|
|
1005
1233
|
return info.changes > 0;
|
|
1006
1234
|
}
|
|
1235
|
+
/** Draw candidates until one inserts. The six-hex token is 24 bits, so a
|
|
1236
|
+
* collision is ordinary and correctness comes from the atomic insert, not
|
|
1237
|
+
* from the token's width. Exhaustion throws so the enclosing transaction
|
|
1238
|
+
* rolls back rather than committing half a transition. */
|
|
1239
|
+
allocatePersona(a) {
|
|
1240
|
+
for (let attempt = 0; attempt < a.maxAttempts; attempt++) {
|
|
1241
|
+
const id = a.nextCandidateId();
|
|
1242
|
+
if (this.tryInsertPersona({
|
|
1243
|
+
id,
|
|
1244
|
+
brand: a.brand,
|
|
1245
|
+
model: a.model,
|
|
1246
|
+
version: a.version,
|
|
1247
|
+
connectionId: a.connectionId,
|
|
1248
|
+
description: a.description,
|
|
1249
|
+
})) {
|
|
1250
|
+
// Read back rather than synthesize: created_at is a database default.
|
|
1251
|
+
return this.getPersona(id);
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
throw new NicknameExhaustedError(a.maxAttempts);
|
|
1255
|
+
}
|
|
1007
1256
|
/**
|
|
1008
|
-
*
|
|
1257
|
+
* The one identity operation: create, reuse, or replace, decided by this
|
|
1258
|
+
* process's connection nonce and the exact tuple.
|
|
1259
|
+
*
|
|
1260
|
+
* `expected` is what the PROCESS recorded when it last bound, and it is not a
|
|
1261
|
+
* cache. A row whose connection_id equals our nonce is NOT proof we own it --
|
|
1262
|
+
* a UUID collision produces exactly that -- so an unbound process treats any
|
|
1263
|
+
* such row as a collision to regenerate around, and a bound process requires
|
|
1264
|
+
* agent id AND nonce to match before it will touch anything.
|
|
1009
1265
|
*
|
|
1010
|
-
*
|
|
1011
|
-
*
|
|
1012
|
-
*
|
|
1013
|
-
*
|
|
1014
|
-
*
|
|
1015
|
-
* attach that skipped the increment would leave that runtime's pollers and
|
|
1016
|
-
* captured epochs valid alongside the new one's.
|
|
1266
|
+
* A changed tuple is a REPLACEMENT, not a rename: the old nickname is retired
|
|
1267
|
+
* terminally, keeps its authored history and membership rows, and is never
|
|
1268
|
+
* revived. Marking it retired in the same transaction is what fences the old
|
|
1269
|
+
* identity's in-flight writes, open waits, and detached pollers: they carry
|
|
1270
|
+
* the old agent_id, and that row is now terminal.
|
|
1017
1271
|
*
|
|
1018
|
-
*
|
|
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.
|
|
1272
|
+
* IMMEDIATE: it reads the binding, then writes under it.
|
|
1024
1273
|
*/
|
|
1025
|
-
|
|
1274
|
+
identifyPersona(a) {
|
|
1275
|
+
const maxAttempts = a.maxAttempts ?? 12;
|
|
1276
|
+
this.assertConnectionId(a.connectionId);
|
|
1277
|
+
if (a.expected !== null)
|
|
1278
|
+
this.assertConnectionId(a.expected.connectionId);
|
|
1026
1279
|
return this.db
|
|
1027
1280
|
.transaction(() => {
|
|
1028
|
-
|
|
1029
|
-
if (
|
|
1030
|
-
|
|
1281
|
+
// --- never bound: allocate, refusing to adopt a colliding row -------
|
|
1282
|
+
if (a.expected === null) {
|
|
1283
|
+
const clash = this.db
|
|
1284
|
+
.prepare("SELECT id FROM agents WHERE connection_id = ?")
|
|
1285
|
+
.get(a.connectionId);
|
|
1286
|
+
if (clash)
|
|
1287
|
+
throw new ConnectionNonceCollisionError(a.connectionId);
|
|
1288
|
+
const persona = this.allocatePersona({ ...a, maxAttempts });
|
|
1289
|
+
return { persona, bindingReused: false, identityChanged: false };
|
|
1031
1290
|
}
|
|
1032
|
-
//
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1291
|
+
// --- bound: every recorded field must still match -------------------
|
|
1292
|
+
const { agentId, connectionId } = a.expected;
|
|
1293
|
+
const row = this.getPersona(agentId);
|
|
1294
|
+
if (!row)
|
|
1295
|
+
throw new PersonaLostError(agentId, "missing");
|
|
1296
|
+
// Report retirement as retirement: a caller told only "your binding
|
|
1297
|
+
// does not match" would reasonably re-read and retry, and the terminal
|
|
1298
|
+
// wording says that cannot work.
|
|
1299
|
+
if (row.retired_at !== null) {
|
|
1300
|
+
throw new PersonaLostError(agentId, "retired");
|
|
1039
1301
|
}
|
|
1040
|
-
if (row.
|
|
1041
|
-
row.
|
|
1042
|
-
|
|
1043
|
-
throw new
|
|
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));
|
|
1302
|
+
if (row.is_human === 1 ||
|
|
1303
|
+
row.connection_id !== connectionId ||
|
|
1304
|
+
connectionId !== a.connectionId) {
|
|
1305
|
+
throw new PersonaLostError(agentId, "binding_mismatch");
|
|
1047
1306
|
}
|
|
1048
|
-
|
|
1307
|
+
// Exact string comparison: no case folding, alias table, or version
|
|
1308
|
+
// parsing. "5.0" and "5" are different models to this system because
|
|
1309
|
+
// nothing here is entitled to decide they are the same.
|
|
1310
|
+
if (row.brand === a.brand &&
|
|
1311
|
+
row.model === a.model &&
|
|
1312
|
+
row.version === a.version) {
|
|
1313
|
+
return { persona: row, bindingReused: true, identityChanged: false };
|
|
1314
|
+
}
|
|
1315
|
+
// --- tuple transition ------------------------------------------------
|
|
1316
|
+
const { present } = this.roomMembershipCounts(agentId);
|
|
1317
|
+
const names = this.presentRoomNames(agentId, MAX_PREVIOUS_ROOM_NAMES);
|
|
1318
|
+
// Clear the nonce BEFORE inserting the replacement: both rows would
|
|
1319
|
+
// otherwise hold it at once and the partial unique index would reject
|
|
1320
|
+
// the insert, rolling back a transition that should have succeeded.
|
|
1049
1321
|
this.db
|
|
1050
|
-
.prepare(
|
|
1051
|
-
|
|
1052
|
-
|
|
1322
|
+
.prepare(`UPDATE agents
|
|
1323
|
+
SET connection_id = NULL, retired_at = datetime('now')
|
|
1324
|
+
WHERE id = ? AND connection_id = ? AND retired_at IS NULL`)
|
|
1325
|
+
.run(agentId, connectionId);
|
|
1326
|
+
this.retireParticipation(agentId);
|
|
1327
|
+
const persona = this.allocatePersona({ ...a, maxAttempts });
|
|
1328
|
+
return {
|
|
1329
|
+
persona,
|
|
1330
|
+
bindingReused: false,
|
|
1331
|
+
identityChanged: true,
|
|
1332
|
+
previousAgentId: agentId,
|
|
1333
|
+
previousRoomCount: present,
|
|
1334
|
+
previousRoomNames: names,
|
|
1335
|
+
previousRoomNamesTruncated: present > names.length,
|
|
1336
|
+
};
|
|
1053
1337
|
})
|
|
1054
1338
|
.immediate();
|
|
1055
1339
|
}
|
|
1340
|
+
/**
|
|
1341
|
+
* Participation effects shared by every terminal retirement, applied only
|
|
1342
|
+
* after the caller's own guarded agents UPDATE proved this runtime still
|
|
1343
|
+
* describes the row, in that same transaction. Memberships are soft-left, so
|
|
1344
|
+
* the rows stay as durable history. Leases and claims are deleted because
|
|
1345
|
+
* their holder is terminal: it can never wake to serve the lease, and can
|
|
1346
|
+
* never release the claim, whose TTL ceiling would otherwise block a live
|
|
1347
|
+
* participant for up to 24h. Deleting claims is the contested call -- it
|
|
1348
|
+
* trades exclusivity away for work that outlived its holder.
|
|
1349
|
+
*/
|
|
1350
|
+
retireParticipation(agentId) {
|
|
1351
|
+
this.db
|
|
1352
|
+
.prepare(`UPDATE memberships
|
|
1353
|
+
SET left_at = datetime('now'), last_seen = datetime('now')
|
|
1354
|
+
WHERE agent_id = ? AND left_at IS NULL`)
|
|
1355
|
+
.run(agentId);
|
|
1356
|
+
this.db.prepare("DELETE FROM wait_leases WHERE agent_id = ?").run(agentId);
|
|
1357
|
+
this.db.prepare("DELETE FROM claims WHERE agent_id = ?").run(agentId);
|
|
1358
|
+
}
|
|
1359
|
+
/**
|
|
1360
|
+
* Graceful shutdown retirement, guarded on the EXACT binding this process
|
|
1361
|
+
* recorded.
|
|
1362
|
+
*
|
|
1363
|
+
* Returns false and mutates nothing when any guard differs. Nickname ids are
|
|
1364
|
+
* never reused, so the danger is not that some other process holds this id;
|
|
1365
|
+
* it is that a mismatch means THIS process's recorded state is stale -- the
|
|
1366
|
+
* row was already retired, or transitioned to a new identity, or altered by a
|
|
1367
|
+
* writer outside this file. Acting on a row we no longer describe is exactly
|
|
1368
|
+
* the mutation to refuse, so "a row with this id exists" is never enough.
|
|
1369
|
+
*
|
|
1370
|
+
* A hard kill cannot run this at all. The row then keeps its connection
|
|
1371
|
+
* nonce and its present memberships until the database is replaced. That
|
|
1372
|
+
* stale row confers no authority -- no caller can submit a nonce, and a
|
|
1373
|
+
* first-bind collision regenerates -- but it does inflate member counts,
|
|
1374
|
+
* collect directed work, and block non-forced pruning. There is deliberately
|
|
1375
|
+
* no timeout reaper: an idle live CLI and a dead one are indistinguishable
|
|
1376
|
+
* from here, and a reaper would retire the live one.
|
|
1377
|
+
*/
|
|
1378
|
+
retireConnection(a) {
|
|
1379
|
+
this.assertConnectionId(a.connectionId);
|
|
1380
|
+
return this.db
|
|
1381
|
+
.transaction(() => {
|
|
1382
|
+
const info = this.db
|
|
1383
|
+
.prepare(`UPDATE agents
|
|
1384
|
+
SET connection_id = NULL, retired_at = datetime('now')
|
|
1385
|
+
WHERE id = ? AND connection_id = ?
|
|
1386
|
+
AND is_human = 0 AND retired_at IS NULL`)
|
|
1387
|
+
.run(a.agentId, a.connectionId);
|
|
1388
|
+
if (info.changes === 0)
|
|
1389
|
+
return false;
|
|
1390
|
+
this.retireParticipation(a.agentId);
|
|
1391
|
+
return true;
|
|
1392
|
+
})
|
|
1393
|
+
.immediate();
|
|
1394
|
+
}
|
|
1395
|
+
/** Which of `ids` name terminally retired LLM identities. Recipient status
|
|
1396
|
+
* needs this for ids with NO membership row in the room: without it a
|
|
1397
|
+
* retired nickname reads as `unknown`, which tells a poster the name was
|
|
1398
|
+
* wrong when it was real and is simply finished. */
|
|
1399
|
+
retiredIds(ids) {
|
|
1400
|
+
if (ids.length === 0)
|
|
1401
|
+
return new Set();
|
|
1402
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
1403
|
+
const rows = this.db
|
|
1404
|
+
.prepare(`SELECT id FROM agents
|
|
1405
|
+
WHERE retired_at IS NOT NULL AND id IN (${placeholders})`)
|
|
1406
|
+
.all(...ids);
|
|
1407
|
+
return new Set(rows.map((r) => r.id));
|
|
1408
|
+
}
|
|
1056
1409
|
// --- membership ---------------------------------------------------------
|
|
1057
1410
|
/**
|
|
1058
1411
|
* Join (or rejoin) a room: clears any prior leave and refreshes liveness.
|
|
1059
1412
|
*
|
|
1060
|
-
* Presence is PER PERSONA.
|
|
1061
|
-
*
|
|
1413
|
+
* Presence is PER PERSONA. A persona belongs to one connection for its whole
|
|
1414
|
+
* life, so there is never a second live seat to reconcile.
|
|
1062
1415
|
*
|
|
1063
1416
|
* `cursorStart` applies ONLY when this call creates the membership. A rejoin
|
|
1064
|
-
* must never move a cursor:
|
|
1065
|
-
* read position
|
|
1066
|
-
* would silently discard the backlog it
|
|
1417
|
+
* must never move a cursor: a persona that steps out of a room and back keeps
|
|
1418
|
+
* its read position, and a "start at latest" that also applied on rejoin
|
|
1419
|
+
* would silently discard the backlog it returned to read.
|
|
1067
1420
|
*/
|
|
1068
|
-
joinRoom(roomId, agentId,
|
|
1421
|
+
joinRoom(roomId, agentId, opts = {}) {
|
|
1069
1422
|
if (opts.role !== undefined) {
|
|
1070
1423
|
assertStorable(opts.role, "role");
|
|
1071
1424
|
assertMaxLen(opts.role, "role", 200);
|
|
@@ -1074,10 +1427,10 @@ export class ChatStore {
|
|
|
1074
1427
|
// One IMMEDIATE transaction: this is a multi-statement read-then-write path,
|
|
1075
1428
|
// and a cross-process deleteRoom interleaving between statements otherwise
|
|
1076
1429
|
// surfaces as an opaque NOT NULL/FK constraint error instead of a clean
|
|
1077
|
-
// failure. It also scopes the
|
|
1078
|
-
// write below it.
|
|
1430
|
+
// failure. It also scopes the liveness guard to the same transaction as
|
|
1431
|
+
// every write below it.
|
|
1079
1432
|
const tx = this.db.transaction(() => {
|
|
1080
|
-
this.
|
|
1433
|
+
this.requireLive(agentId);
|
|
1081
1434
|
// Existence check INSIDE the write transaction: the caller resolved the
|
|
1082
1435
|
// room earlier, and a cross-process delete_room in that window otherwise
|
|
1083
1436
|
// surfaces as a raw "FOREIGN KEY constraint failed".
|
|
@@ -1117,15 +1470,13 @@ export class ChatStore {
|
|
|
1117
1470
|
* handled by not calling this at all. Room-local by construction: the same
|
|
1118
1471
|
* persona keeps a different role in every other room.
|
|
1119
1472
|
*/
|
|
1120
|
-
setRole(roomId, agentId,
|
|
1473
|
+
setRole(roomId, agentId, role) {
|
|
1121
1474
|
assertStorable(role, "role");
|
|
1122
1475
|
assertMaxLen(role, "role", 200);
|
|
1123
1476
|
assertRole(role);
|
|
1124
1477
|
const tx = this.db.transaction(() => {
|
|
1125
|
-
this.
|
|
1126
|
-
this.
|
|
1127
|
-
// A role describes what you are IN THIS ROOM; you cannot hold one while
|
|
1128
|
-
// absent.
|
|
1478
|
+
this.requireLive(agentId);
|
|
1479
|
+
// A role describes participation in this room.
|
|
1129
1480
|
this.requirePresent(roomId, agentId);
|
|
1130
1481
|
// Refresh last_seen in the SAME statement. A role change is a deliberate
|
|
1131
1482
|
// act in THIS room, so the seat is demonstrably attended here; leaving
|
|
@@ -1135,8 +1486,10 @@ export class ChatStore {
|
|
|
1135
1486
|
.prepare(`UPDATE memberships SET role = ?, last_seen = datetime('now')
|
|
1136
1487
|
WHERE room_id = ? AND agent_id = ?`)
|
|
1137
1488
|
.run(role, roomId, agentId);
|
|
1489
|
+
// requirePresent proved this row exists in the same transaction.
|
|
1138
1490
|
if (info.changes === 0) {
|
|
1139
|
-
throw new Error(`
|
|
1491
|
+
throw new Error(`internal invariant violated: membership for room ${roomId} passed ` +
|
|
1492
|
+
"the presence check and then matched no row; the database may be corrupt");
|
|
1140
1493
|
}
|
|
1141
1494
|
});
|
|
1142
1495
|
tx.immediate();
|
|
@@ -1152,9 +1505,9 @@ export class ChatStore {
|
|
|
1152
1505
|
* Soft leave: keep the membership row (and its read position and role) but
|
|
1153
1506
|
* mark the persona not present. Rejoining resumes exactly where it left off.
|
|
1154
1507
|
*/
|
|
1155
|
-
leaveRoom(roomId, agentId
|
|
1508
|
+
leaveRoom(roomId, agentId) {
|
|
1156
1509
|
const tx = this.db.transaction(() => {
|
|
1157
|
-
this.
|
|
1510
|
+
this.requireLive(agentId);
|
|
1158
1511
|
const info = this.db
|
|
1159
1512
|
.prepare(`UPDATE memberships SET left_at = datetime('now'), last_seen = datetime('now')
|
|
1160
1513
|
WHERE room_id = ? AND agent_id = ? AND left_at IS NULL`)
|
|
@@ -1164,42 +1517,29 @@ export class ChatStore {
|
|
|
1164
1517
|
return tx.immediate();
|
|
1165
1518
|
}
|
|
1166
1519
|
/**
|
|
1167
|
-
* Mark a persona alive in a room
|
|
1168
|
-
*
|
|
1520
|
+
* Mark a persona alive in a room it is PRESENT in. Liveness only: a room this
|
|
1521
|
+
* persona explicitly left stays left, because rejoining is a state transition
|
|
1522
|
+
* join_room owns and an incidental heartbeat must not perform it silently.
|
|
1523
|
+
* A room with no present membership is simply not refreshed. The method is
|
|
1524
|
+
* void because heartbeat callers do not act on whether an update matched.
|
|
1169
1525
|
*
|
|
1170
|
-
*
|
|
1526
|
+
* LIVE-IDENTITY-FENCED like any other write. A liveness touch looks harmless, but it
|
|
1171
1527
|
* is what makes a persona read as present/active to every other agent and to
|
|
1172
1528
|
* recipient status; an unfenced touch would let a runtime that has already
|
|
1173
1529
|
* lost the persona keep publishing it as an available listener, which is the
|
|
1174
|
-
* false-listener-truth failure
|
|
1530
|
+
* false-listener-truth failure this guard exists to close. Throwing here is
|
|
1175
1531
|
* correct: the caller treats liveness as best-effort and the next real
|
|
1176
1532
|
* operation reports persona_lost.
|
|
1177
1533
|
*/
|
|
1178
|
-
touch(roomId, agentId
|
|
1534
|
+
touch(roomId, agentId) {
|
|
1179
1535
|
const tx = this.db.transaction(() => {
|
|
1180
|
-
this.
|
|
1536
|
+
this.requireLive(agentId);
|
|
1181
1537
|
this.db
|
|
1182
|
-
.prepare(`UPDATE memberships SET last_seen = datetime('now'), left_at = NULL
|
|
1183
|
-
WHERE room_id = ? AND agent_id = ?`)
|
|
1184
|
-
.run(roomId, agentId);
|
|
1185
|
-
});
|
|
1186
|
-
tx.immediate();
|
|
1187
|
-
}
|
|
1188
|
-
/**
|
|
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.
|
|
1192
|
-
*/
|
|
1193
|
-
touchJoinedRoom(roomId, agentId, epoch) {
|
|
1194
|
-
const tx = this.db.transaction(() => {
|
|
1195
|
-
this.requireEpoch(agentId, epoch);
|
|
1196
|
-
const info = this.db
|
|
1197
1538
|
.prepare(`UPDATE memberships SET last_seen = datetime('now')
|
|
1198
1539
|
WHERE room_id = ? AND agent_id = ? AND left_at IS NULL`)
|
|
1199
1540
|
.run(roomId, agentId);
|
|
1200
|
-
return info.changes > 0;
|
|
1201
1541
|
});
|
|
1202
|
-
|
|
1542
|
+
tx.immediate();
|
|
1203
1543
|
}
|
|
1204
1544
|
/**
|
|
1205
1545
|
* Open an in-turn wait lease: this persona has a blocking catch_up pending in
|
|
@@ -1207,49 +1547,45 @@ export class ChatStore {
|
|
|
1207
1547
|
* leave a permanent "watching" ghost. Expired rows for the room are reaped in
|
|
1208
1548
|
* passing.
|
|
1209
1549
|
*
|
|
1210
|
-
*
|
|
1211
|
-
*
|
|
1212
|
-
*
|
|
1213
|
-
*
|
|
1214
|
-
* would not.
|
|
1550
|
+
* One row per (room, agent) and no tenure column: an agent_id has exactly one
|
|
1551
|
+
* tenure, so there is no second tenure of the same id whose lease this could
|
|
1552
|
+
* be confused with. Concurrent waits from the SAME identity share the row and
|
|
1553
|
+
* are counted process-side; the last waiter out closes it.
|
|
1215
1554
|
*/
|
|
1216
|
-
beginWaitLease(roomId, agentId,
|
|
1555
|
+
beginWaitLease(roomId, agentId, ttlSeconds) {
|
|
1217
1556
|
const tx = this.db.transaction(() => {
|
|
1218
|
-
this.
|
|
1219
|
-
|
|
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.
|
|
1557
|
+
this.requireLive(agentId);
|
|
1558
|
+
// A lease must not advertise an absent persona as watching.
|
|
1223
1559
|
this.requirePresent(roomId, agentId);
|
|
1224
1560
|
this.db
|
|
1225
1561
|
.prepare("DELETE FROM wait_leases WHERE room_id = ? AND expires_at <= datetime('now')")
|
|
1226
1562
|
.run(roomId);
|
|
1227
1563
|
this.db
|
|
1228
|
-
.prepare(
|
|
1229
|
-
|
|
1564
|
+
.prepare(
|
|
1565
|
+
// Overlapping waits share one row: keep the aggregate's first start
|
|
1566
|
+
// and furthest deadline rather than letting a later short wait
|
|
1567
|
+
// advertise that the still-open long wait expired.
|
|
1568
|
+
`INSERT INTO wait_leases (room_id, agent_id, expires_at)
|
|
1569
|
+
VALUES (?, ?, datetime('now', '+' || ? || ' seconds'))
|
|
1230
1570
|
ON CONFLICT(room_id, agent_id) DO UPDATE SET
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
expires_at = excluded.expires_at`)
|
|
1234
|
-
.run(roomId, agentId, epoch, Math.max(1, Math.floor(ttlSeconds)));
|
|
1571
|
+
expires_at = max(wait_leases.expires_at, excluded.expires_at)`)
|
|
1572
|
+
.run(roomId, agentId, Math.max(1, Math.floor(ttlSeconds)));
|
|
1235
1573
|
});
|
|
1236
1574
|
tx.immediate();
|
|
1237
1575
|
}
|
|
1238
1576
|
/**
|
|
1239
1577
|
* Close an in-turn wait lease (normal return, timeout, or abort alike).
|
|
1240
1578
|
*
|
|
1241
|
-
*
|
|
1242
|
-
*
|
|
1243
|
-
*
|
|
1244
|
-
*
|
|
1245
|
-
*
|
|
1246
|
-
* runtime must still be able to clean up after itself, it just must not touch
|
|
1247
|
-
* anything that is no longer its own.
|
|
1579
|
+
* Deliberately NOT wrapped in requireLive: a retired identity must still be
|
|
1580
|
+
* able to clean up after itself. It cannot reach anything that is not its
|
|
1581
|
+
* own, because the row is keyed by agent_id and a successor identity is a
|
|
1582
|
+
* DIFFERENT agent_id -- so a late cleanup from a superseded persona can only
|
|
1583
|
+
* ever delete that persona's own row.
|
|
1248
1584
|
*/
|
|
1249
|
-
endWaitLease(roomId, agentId
|
|
1585
|
+
endWaitLease(roomId, agentId) {
|
|
1250
1586
|
this.db
|
|
1251
|
-
.prepare("DELETE FROM wait_leases WHERE room_id = ? AND agent_id = ?
|
|
1252
|
-
.run(roomId, agentId
|
|
1587
|
+
.prepare("DELETE FROM wait_leases WHERE room_id = ? AND agent_id = ?")
|
|
1588
|
+
.run(roomId, agentId);
|
|
1253
1589
|
}
|
|
1254
1590
|
getMembership(roomId, agentId) {
|
|
1255
1591
|
return this.db
|
|
@@ -1310,12 +1646,13 @@ export class ChatStore {
|
|
|
1310
1646
|
// role comes from the MEMBERSHIP, not the persona: it is what this persona
|
|
1311
1647
|
// is in THIS room.
|
|
1312
1648
|
const cols = `SELECT a.id, a.brand, a.model, a.version, a.is_human,
|
|
1649
|
+
a.retired_at,
|
|
1313
1650
|
m.role AS role,
|
|
1314
1651
|
substr(a.description, 1, ${PREVIEW}) AS description,
|
|
1315
1652
|
CASE WHEN length(a.description) > ${PREVIEW} THEN 1 ELSE 0 END AS description_cut,
|
|
1316
1653
|
m.joined_at, m.rowid AS _rid,
|
|
1317
1654
|
m.last_read_seq, m.last_seen, m.left_at,
|
|
1318
|
-
(
|
|
1655
|
+
(unixepoch('now') - unixepoch(m.last_seen)) AS idle_seconds,
|
|
1319
1656
|
EXISTS(SELECT 1 FROM wait_leases wl
|
|
1320
1657
|
WHERE wl.room_id = m.room_id AND wl.agent_id = m.agent_id
|
|
1321
1658
|
AND wl.expires_at > datetime('now')) AS watching
|
|
@@ -1369,18 +1706,26 @@ export class ChatStore {
|
|
|
1369
1706
|
const page = hasMore ? rows.slice(0, lim) : rows;
|
|
1370
1707
|
const threshold = activeWithinMinutes * 60;
|
|
1371
1708
|
const mapped = page.map((r) => {
|
|
1372
|
-
const { left_at, description_cut, _rid, watching, ...rest } = r;
|
|
1709
|
+
const { left_at, retired_at, description_cut, _rid, watching, ...rest } = r;
|
|
1373
1710
|
void _rid;
|
|
1374
|
-
const
|
|
1711
|
+
const retired = retired_at !== null;
|
|
1712
|
+
// Retirement soft-leaves memberships and deletes leases in its own
|
|
1713
|
+
// transaction, so these are already false by the data. Forcing them is
|
|
1714
|
+
// for the writers this file cannot reach: a direct SQL writer can leave
|
|
1715
|
+
// a retired row present, and a listing that showed it as a live member
|
|
1716
|
+
// would send peers work it can never read.
|
|
1717
|
+
const isWatching = !retired && watching === 1;
|
|
1375
1718
|
return {
|
|
1376
1719
|
...rest,
|
|
1377
1720
|
is_human: rest.is_human === 1,
|
|
1378
1721
|
...(description_cut ? { description_truncated: true } : {}),
|
|
1379
|
-
present: left_at === null,
|
|
1380
|
-
active:
|
|
1722
|
+
present: !retired && left_at === null,
|
|
1723
|
+
active: !retired &&
|
|
1724
|
+
left_at === null &&
|
|
1381
1725
|
(isWatching ||
|
|
1382
1726
|
(r.idle_seconds !== null && r.idle_seconds <= threshold)),
|
|
1383
1727
|
watching: isWatching,
|
|
1728
|
+
retired,
|
|
1384
1729
|
};
|
|
1385
1730
|
});
|
|
1386
1731
|
const { rows: agents, sizeTrimmed } = fitRows(mapped, LIST_ROW_BUDGET);
|
|
@@ -1450,11 +1795,10 @@ export class ChatStore {
|
|
|
1450
1795
|
* the guard). The reject baseline is the TOKEN, unlike the accept path's
|
|
1451
1796
|
* cursor-relative crossed. opts.crossedPreviewChars additionally returns
|
|
1452
1797
|
* crossed previews on an ACCEPTED post. A post never consumes an unseen peer
|
|
1453
|
-
* message. After an accepted post, the
|
|
1454
|
-
* the
|
|
1455
|
-
* recurring probes do not rescan that suffix.
|
|
1798
|
+
* message. After an accepted post, the persona cursor is normalized through
|
|
1799
|
+
* the new own row when it is already at the proven safe peer floor.
|
|
1456
1800
|
*/
|
|
1457
|
-
postMessage(roomId, agentId, body, format, mentions, replyToSeq, supersedesSeq,
|
|
1801
|
+
postMessage(roomId, agentId, body, format, mentions, replyToSeq, supersedesSeq, opts = {}) {
|
|
1458
1802
|
// Reject unstorable text BEFORE the transaction: a body with an embedded
|
|
1459
1803
|
// NUL reads back truncated (SQLite substr/length stop at NUL) and catch_up
|
|
1460
1804
|
// would advance the marker past the lost tail. mentions are agent ids
|
|
@@ -1500,16 +1844,12 @@ export class ChatStore {
|
|
|
1500
1844
|
}
|
|
1501
1845
|
const ifToken = opts.ifLastReadSeq ?? null;
|
|
1502
1846
|
const tx = this.db.transaction(() => {
|
|
1503
|
-
// FIRST, before the dedup lookup: a
|
|
1504
|
-
// told the seq of a message it posted
|
|
1847
|
+
// FIRST, before the dedup lookup: a retired identity must not even be
|
|
1848
|
+
// told the seq of a message it posted before retirement, and must
|
|
1505
1849
|
// certainly not insert a new one.
|
|
1506
|
-
this.
|
|
1507
|
-
//
|
|
1508
|
-
|
|
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);
|
|
1850
|
+
this.requireLive(agentId);
|
|
1851
|
+
// Presence, room existence, and the cursor share one membership read.
|
|
1852
|
+
const membership = this.requirePresent(roomId, agentId);
|
|
1513
1853
|
// Lost-response retry: one indexed lookup only when the caller opted in.
|
|
1514
1854
|
// It precedes CAS/reference validation so a committed first attempt is
|
|
1515
1855
|
// recoverable even if room state or a referenced parent later changed.
|
|
@@ -1560,8 +1900,7 @@ export class ChatStore {
|
|
|
1560
1900
|
(!Number.isSafeInteger(ifToken) || ifToken < 0)) {
|
|
1561
1901
|
throw new Error("if_last_read_seq must be a non-negative safe integer");
|
|
1562
1902
|
}
|
|
1563
|
-
const
|
|
1564
|
-
const from = cursor?.last_read_seq ?? 0;
|
|
1903
|
+
const from = membership.last_read_seq;
|
|
1565
1904
|
if (ifToken !== null) {
|
|
1566
1905
|
// A future/wrong-cursor token made the predicate `seq > token` empty
|
|
1567
1906
|
// and silently disabled the CAS while unread messages still existed.
|
|
@@ -1634,15 +1973,20 @@ export class ChatStore {
|
|
|
1634
1973
|
throw new Error(`supersedes_seq ${supersedesSeq} was written by ${target.agent_id}; you can only supersede your own messages`);
|
|
1635
1974
|
}
|
|
1636
1975
|
}
|
|
1637
|
-
//
|
|
1638
|
-
//
|
|
1639
|
-
//
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1976
|
+
// A surviving CAS already proved stale.c === 0 after a token no newer
|
|
1977
|
+
// than `from`, so it also proved there are no crossings after `from`.
|
|
1978
|
+
// Ordinary posts still need the cursor-relative aggregate.
|
|
1979
|
+
let crossing = { c: 0, mn: null, mx: null, d: null };
|
|
1980
|
+
if (ifToken === null) {
|
|
1981
|
+
// The directedAt pair binds FIRST (it sits in the SELECT list, ahead of
|
|
1982
|
+
// the WHERE placeholders).
|
|
1983
|
+
crossing = this.db
|
|
1984
|
+
.prepare(`SELECT COUNT(*) AS c, MIN(seq) AS mn, MAX(seq) AS mx,
|
|
1985
|
+
SUM(CASE WHEN ${directedAt("messages")} THEN 1 ELSE 0 END) AS d
|
|
1986
|
+
FROM messages
|
|
1987
|
+
WHERE room_id = ? AND seq > ? AND agent_id != ?`)
|
|
1988
|
+
.get(agentId, agentId, roomId, from, agentId);
|
|
1989
|
+
}
|
|
1646
1990
|
const { next } = this.db
|
|
1647
1991
|
.prepare("SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM messages WHERE room_id = ?")
|
|
1648
1992
|
.get(roomId);
|
|
@@ -1706,14 +2050,21 @@ export class ChatStore {
|
|
|
1706
2050
|
// reported length (an emoji counts once). Deciding on codepointLen (the full
|
|
1707
2051
|
// codepoint count) rather than a UTF-16 length keeps the threshold in the
|
|
1708
2052
|
// same unit as the cut.
|
|
1709
|
-
|
|
2053
|
+
// The SQL fetch itself is capped before this mapper runs. body_len is the
|
|
2054
|
+
// exact full UTF-16 length stamped at write time, so it detects that cut
|
|
2055
|
+
// without scanning the body again. A cut JSON body must stay raw: parsing
|
|
2056
|
+
// a valid-looking prefix can change it (a huge numeric prefix becomes
|
|
2057
|
+
// Infinity, then serializes as null) while hiding that bytes remain.
|
|
2058
|
+
const fetchedTruncate = r.body.length < r.body_len;
|
|
2059
|
+
const truncate = fetchedTruncate ||
|
|
2060
|
+
(previewChars !== undefined && codepointLen(r) > previewChars);
|
|
1710
2061
|
// A truncated body is returned as a raw (possibly partial) string even for
|
|
1711
2062
|
// json: a sliced JSON string does not parse, so the caller must fetch the
|
|
1712
2063
|
// full body with get_message. `truncated`/`length` signal exactly that.
|
|
1713
|
-
// A body larger than the fetch cap arrives here already cut; it can never
|
|
1714
|
-
// fit the byte budget anyway, so shrinkToFit re-flags it downstream.
|
|
1715
2064
|
const content = truncate
|
|
1716
|
-
?
|
|
2065
|
+
? previewChars === undefined
|
|
2066
|
+
? r.body
|
|
2067
|
+
: cutToCodepoints(r.body, previewChars)
|
|
1717
2068
|
: r.format === "json"
|
|
1718
2069
|
? safeParse(r.body)
|
|
1719
2070
|
: r.body;
|
|
@@ -1751,7 +2102,7 @@ export class ChatStore {
|
|
|
1751
2102
|
* measured size fits -- a single code-unit cut under-counts JSON escaping,
|
|
1752
2103
|
* which is how control-heavy room names kept escaping the budget. Every
|
|
1753
2104
|
* stage measures the real serialized output, so for any budget >=
|
|
1754
|
-
* STUB_ALLOWANCE the result is guaranteed to fit.
|
|
2105
|
+
* STUB_ALLOWANCE - 2 the result is guaranteed to fit.
|
|
1755
2106
|
*/
|
|
1756
2107
|
shrinkToFit(r, previewChars, budget, map) {
|
|
1757
2108
|
const m = map(r, previewChars);
|
|
@@ -1775,8 +2126,11 @@ export class ChatStore {
|
|
|
1775
2126
|
// there. Stage 4 sets this explicitly, but stage 2's escaping correction can
|
|
1776
2127
|
// reach keep=0 on its own for a control-heavy body, and a caller that reads
|
|
1777
2128
|
// `oversized` to decide "fetch this with get_message" must not miss those.
|
|
1778
|
-
if (h.content === "" && codepointLen(r) > 0)
|
|
2129
|
+
if (h.content === "" && codepointLen(r) > 0) {
|
|
1779
2130
|
h.oversized = true;
|
|
2131
|
+
// The flag changes the size that gates mention shedding and stubbing.
|
|
2132
|
+
sz = JSON.stringify(head).length;
|
|
2133
|
+
}
|
|
1780
2134
|
// Stage 3: shed mentions (down to none if needed); size can live
|
|
1781
2135
|
// entirely in a legal `to` list. Flags set before the deciding measure.
|
|
1782
2136
|
if (sz > budget && Array.isArray(h.to) && h.to.length > 0) {
|
|
@@ -1944,9 +2298,13 @@ export class ChatStore {
|
|
|
1944
2298
|
// but returning it is the only progress-safe interpretation of SQLite's
|
|
1945
2299
|
// one-CODEPOINT window; shrinking it to "" made next_offset repeat forever.
|
|
1946
2300
|
const firstCodepointUnits = (chunk.codePointAt(0) ?? 0) > 0xffff ? 2 : 1;
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
2301
|
+
// Serializing is the expensive step here, so measure each chunk once and
|
|
2302
|
+
// re-measure only after cutting it.
|
|
2303
|
+
while (chunk.length > firstCodepointUnits) {
|
|
2304
|
+
const serialized = JSON.stringify(chunk).length - 2;
|
|
2305
|
+
if (serialized <= cap)
|
|
2306
|
+
break;
|
|
2307
|
+
const ratio = cap / serialized;
|
|
1950
2308
|
chunk = safeCut(chunk, Math.min(chunk.length - 1, Math.max(1, Math.floor(chunk.length * ratio))));
|
|
1951
2309
|
}
|
|
1952
2310
|
// Codepoints consumed by this slice (bounded, so counting is cheap).
|
|
@@ -1989,7 +2347,7 @@ export class ChatStore {
|
|
|
1989
2347
|
.get(roomId);
|
|
1990
2348
|
const rows = this.db
|
|
1991
2349
|
.prepare(`SELECT agent_id, last_read_seq, left_at,
|
|
1992
|
-
(
|
|
2350
|
+
(unixepoch('now') - unixepoch(last_seen)) AS idle_seconds,
|
|
1993
2351
|
EXISTS(SELECT 1 FROM wait_leases wl
|
|
1994
2352
|
WHERE wl.room_id = memberships.room_id
|
|
1995
2353
|
AND wl.agent_id = memberships.agent_id
|
|
@@ -1998,9 +2356,28 @@ export class ChatStore {
|
|
|
1998
2356
|
WHERE room_id = ? AND agent_id IN (${placeholders})`)
|
|
1999
2357
|
.all(roomId, ...ids);
|
|
2000
2358
|
const byId = new Map(rows.map((r) => [r.agent_id, r]));
|
|
2359
|
+
// Retirement is a property of the IDENTITY, not of a membership, so it is
|
|
2360
|
+
// resolved for every requested id -- including ids with no membership row
|
|
2361
|
+
// here, which would otherwise report `unknown` and tell a poster its
|
|
2362
|
+
// recipient never existed when in fact it existed and is finished.
|
|
2363
|
+
const retired = this.retiredIds(ids);
|
|
2001
2364
|
const threshold = activeWithinMinutes * 60;
|
|
2002
2365
|
return ids.map((id) => {
|
|
2003
2366
|
const r = byId.get(id);
|
|
2367
|
+
if (retired.has(id)) {
|
|
2368
|
+
return {
|
|
2369
|
+
id,
|
|
2370
|
+
status: "retired",
|
|
2371
|
+
present: false,
|
|
2372
|
+
idle_seconds: null,
|
|
2373
|
+
// Its final cursor is retained history and is still the truthful
|
|
2374
|
+
// answer to "how far did it get", so report it when a membership
|
|
2375
|
+
// exists. marker_behind stays meaningful for the same reason.
|
|
2376
|
+
last_read_seq: r ? r.last_read_seq : null,
|
|
2377
|
+
marker_behind: r ? Math.max(0, latest - r.last_read_seq) : null,
|
|
2378
|
+
watching: false,
|
|
2379
|
+
};
|
|
2380
|
+
}
|
|
2004
2381
|
if (!r) {
|
|
2005
2382
|
return {
|
|
2006
2383
|
id,
|
|
@@ -2054,8 +2431,8 @@ export class ChatStore {
|
|
|
2054
2431
|
SELECT mb.agent_id AS agent_id, mb.room_id AS room_id, r.name AS room_name,
|
|
2055
2432
|
COUNT(*) AS directed_unread,
|
|
2056
2433
|
MIN(g.seq) AS oldest_seq,
|
|
2057
|
-
MIN(
|
|
2058
|
-
(
|
|
2434
|
+
MIN(unixepoch(g.created_at)) AS oldest_unix,
|
|
2435
|
+
(unixepoch('now') - unixepoch(mb.last_seen)) AS idle_seconds,
|
|
2059
2436
|
mb.last_read_seq AS last_read_seq
|
|
2060
2437
|
FROM memberships mb
|
|
2061
2438
|
JOIN rooms r ON r.id = mb.room_id
|
|
@@ -2122,82 +2499,88 @@ export class ChatStore {
|
|
|
2122
2499
|
* page them via get_message.
|
|
2123
2500
|
*/
|
|
2124
2501
|
getThread(roomId, seq, maxDepth = 3, previewChars) {
|
|
2125
|
-
const
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2502
|
+
const tx = this.db.transaction(() => {
|
|
2503
|
+
const focalRow = this.getRawMessage(roomId, seq);
|
|
2504
|
+
if (!focalRow)
|
|
2505
|
+
return undefined;
|
|
2506
|
+
const mapPlain = (r, pc) => this.rowToMessage(r, pc);
|
|
2507
|
+
const budget = DEFAULT_MAX_BYTES - THREAD_ENVELOPE;
|
|
2508
|
+
// Reserve the parent's slot before spending on the focal message: a stub
|
|
2509
|
+
// allowance when a parent exists, 4 chars of literal null otherwise. The
|
|
2510
|
+
// trailing 2 covers an empty replies array.
|
|
2511
|
+
const parentSeq = focalRow.reply_to_seq;
|
|
2512
|
+
const parentReserve = parentSeq === null ? 4 : STUB_ALLOWANCE + 2;
|
|
2513
|
+
const message = this.shrinkToFit(focalRow, undefined, budget - parentReserve - 2, mapPlain);
|
|
2514
|
+
let remaining = budget - JSON.stringify(message).length;
|
|
2515
|
+
let parent = null;
|
|
2516
|
+
if (parentSeq !== null) {
|
|
2517
|
+
const parentRow = this.getRawMessage(roomId, parentSeq);
|
|
2518
|
+
if (parentRow) {
|
|
2519
|
+
// Leave a stub allowance (plus array brackets) for the replies when
|
|
2520
|
+
// more than that remains; otherwise the parent gets a stub itself.
|
|
2521
|
+
parent = this.shrinkToFit(parentRow, undefined, Math.max(STUB_ALLOWANCE, remaining - STUB_ALLOWANCE - 2), mapPlain);
|
|
2522
|
+
}
|
|
2144
2523
|
}
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
byteLimited =
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2524
|
+
remaining -= parent ? JSON.stringify(parent).length : 4;
|
|
2525
|
+
const cap = 500;
|
|
2526
|
+
// `path` uses the fixed width needed through Number.MAX_SAFE_INTEGER, the
|
|
2527
|
+
// supported writer bound, so sorting it yields numeric pre-order DFS.
|
|
2528
|
+
// The recursive ORDER/LIMIT keeps at most cap+1 rows for the expensive
|
|
2529
|
+
// message joins and final sort. It bounds the recursive result table, not
|
|
2530
|
+
// candidate discovery: wide fanout may still be enumerated and sorted.
|
|
2531
|
+
// fetchBounded separately stops large bodies at the response budget.
|
|
2532
|
+
const { rows, exhausted } = this.fetchBounded(this.db.prepare(`WITH RECURSIVE descendants(seq, depth, path) AS (
|
|
2533
|
+
SELECT g.seq, 1, printf('%016d', g.seq)
|
|
2534
|
+
FROM messages g
|
|
2535
|
+
WHERE g.room_id = @room AND g.reply_to_seq = @root
|
|
2536
|
+
UNION ALL
|
|
2537
|
+
SELECT c.seq, d.depth + 1,
|
|
2538
|
+
d.path || '/' || printf('%016d', c.seq) AS path
|
|
2539
|
+
FROM descendants d
|
|
2540
|
+
CROSS JOIN messages c INDEXED BY idx_messages_reply
|
|
2541
|
+
WHERE c.reply_to_seq = d.seq
|
|
2542
|
+
AND c.room_id = @room
|
|
2543
|
+
AND d.depth < @maxDepth
|
|
2544
|
+
ORDER BY path
|
|
2545
|
+
LIMIT @lim
|
|
2546
|
+
)
|
|
2547
|
+
SELECT ${messageCols(DEFAULT_MAX_BYTES)}, d.depth AS depth
|
|
2548
|
+
FROM descendants d
|
|
2549
|
+
CROSS JOIN messages g
|
|
2550
|
+
LEFT JOIN messages p ON p.room_id = g.room_id AND p.seq = g.reply_to_seq
|
|
2551
|
+
WHERE g.room_id = @room AND g.seq = d.seq
|
|
2552
|
+
ORDER BY d.path
|
|
2553
|
+
LIMIT @lim`), [{ room: roomId, root: seq, maxDepth, lim: cap + 1 }], Math.max(STUB_ALLOWANCE, remaining));
|
|
2554
|
+
const replies_capped = rows.length > cap;
|
|
2555
|
+
// Below a stub allowance boundByBytes cannot guarantee even its head row
|
|
2556
|
+
// fits; omit the replies instead of delivering an over-budget response
|
|
2557
|
+
// (byte_limited says they exist; get_thread on a reply seq fetches them).
|
|
2558
|
+
let replies = [];
|
|
2559
|
+
let byteLimited = false;
|
|
2560
|
+
if (rows.length > 0 && remaining < STUB_ALLOWANCE + 2) {
|
|
2561
|
+
byteLimited = true;
|
|
2562
|
+
}
|
|
2563
|
+
else if (rows.length > 0) {
|
|
2564
|
+
({ messages: replies, byteLimited } = this.boundByBytes(rows.slice(0, cap), previewChars, remaining, (r, pc) => ({
|
|
2565
|
+
...this.rowToMessage(r, pc),
|
|
2566
|
+
depth: r.depth,
|
|
2567
|
+
})));
|
|
2568
|
+
}
|
|
2569
|
+
// If fetchBounded stopped on the raw-byte budget (exhausted:false), replies
|
|
2570
|
+
// were left unfetched even when boundByBytes fit everything it got (a
|
|
2571
|
+
// preview_chars cut shrank them all): flag byte_limited so the omission is
|
|
2572
|
+
// never silent. get_thread has no reply-offset param; the recourse is
|
|
2573
|
+
// get_thread on a reply seq, which byte_limited signals is needed.
|
|
2574
|
+
byteLimited = byteLimited || !exhausted;
|
|
2575
|
+
return {
|
|
2576
|
+
message,
|
|
2577
|
+
parent,
|
|
2578
|
+
replies,
|
|
2579
|
+
replies_capped,
|
|
2580
|
+
...(byteLimited ? { byte_limited: true } : {}),
|
|
2581
|
+
};
|
|
2582
|
+
});
|
|
2583
|
+
return tx.deferred();
|
|
2201
2584
|
}
|
|
2202
2585
|
/** Count of messages newer than the marker that the agent did NOT write. */
|
|
2203
2586
|
unreadCount(roomId, lastReadSeq, agentId) {
|
|
@@ -2218,33 +2601,37 @@ export class ChatStore {
|
|
|
2218
2601
|
* lock the way catchUp's IMMEDIATE transaction would. Throws when the
|
|
2219
2602
|
* membership is gone (room deleted mid-wait), same as catchUp.
|
|
2220
2603
|
*
|
|
2221
|
-
*
|
|
2604
|
+
* FENCED ON LIVENESS, and this is what bounds how long a dead wait keeps
|
|
2222
2605
|
* sitting there. The advancing read on a hit was always fenced, but a QUIET
|
|
2223
|
-
* stale wait never reaches it: with no
|
|
2606
|
+
* stale wait never reaches it: with no check here, an identity retired at
|
|
2224
2607
|
* second 3 of a 25-second wait went right on waiting, holding a lease that
|
|
2225
2608
|
* told peers it was watching, and learned nothing until traffic arrived or
|
|
2226
2609
|
* the deadline expired. Checking each probe caps that at one interval.
|
|
2227
2610
|
*
|
|
2228
|
-
*
|
|
2229
|
-
* WHERE predicate: as a predicate a
|
|
2611
|
+
* retired_at rides as returned DATA anchored on the persona row, not as a
|
|
2612
|
+
* WHERE predicate: as a predicate a dead identity would get "no row", which
|
|
2230
2613
|
* this method already means "the room or membership is gone" -- a wrong and
|
|
2231
2614
|
* actively misleading diagnosis. Anchoring on agents keeps the two apart.
|
|
2232
2615
|
*/
|
|
2233
|
-
unreadProbe(roomId, agentId
|
|
2616
|
+
unreadProbe(roomId, agentId) {
|
|
2234
2617
|
const row = this.db
|
|
2235
|
-
.prepare(`SELECT a.
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2618
|
+
.prepare(`SELECT a.retired_at, mb.last_read_seq, mb.left_at
|
|
2619
|
+
FROM agents a
|
|
2620
|
+
LEFT JOIN memberships mb
|
|
2621
|
+
ON mb.room_id = @room AND mb.agent_id = a.id
|
|
2622
|
+
WHERE a.id = @agent`)
|
|
2240
2623
|
.get({ room: roomId, agent: agentId });
|
|
2624
|
+
// Same identity fence as requireLive: a quiet in-call wait must stop after
|
|
2625
|
+
// retirement rather than keep advertising a seat that can never act.
|
|
2241
2626
|
if (!row)
|
|
2242
|
-
throw new PersonaLostError(agentId,
|
|
2243
|
-
if (row.
|
|
2244
|
-
throw new PersonaLostError(agentId,
|
|
2245
|
-
}
|
|
2627
|
+
throw new PersonaLostError(agentId, "missing");
|
|
2628
|
+
if (row.retired_at !== null)
|
|
2629
|
+
throw new PersonaLostError(agentId, "retired");
|
|
2246
2630
|
if (row.last_read_seq === null)
|
|
2247
2631
|
throw new Error("not a member of this room");
|
|
2632
|
+
if (row.left_at !== null) {
|
|
2633
|
+
throw new Error(`persona "${agentId}" LEFT room ${roomId} while waiting; rejoin before waiting on it again`);
|
|
2634
|
+
}
|
|
2248
2635
|
// A wait only needs a yes/no wake signal. COUNT(*) rescanned the complete
|
|
2249
2636
|
// unread tail twice per second per wait (including a large self-authored
|
|
2250
2637
|
// tail that never advances); the room/seq index lets this stop at one row.
|
|
@@ -2263,8 +2650,9 @@ export class ChatStore {
|
|
|
2263
2650
|
* and catch_up's rooms_with_unread disclosure on an empty read. Rooms this
|
|
2264
2651
|
* persona soft-left are muted by the membership join. excludeRoomId drops the
|
|
2265
2652
|
* room just read (catch_up's summary lists OTHER rooms). Fetches limit+1 to
|
|
2266
|
-
* report truncation without a tail COUNT.
|
|
2267
|
-
*
|
|
2653
|
+
* report truncation without a tail COUNT. The returned room set is bounded;
|
|
2654
|
+
* exact unread/directed counts still require scanning every candidate row.
|
|
2655
|
+
* Read-only, so it is safe inside deferred and immediate transactions alike.
|
|
2268
2656
|
*/
|
|
2269
2657
|
unreadByRoom(agentId, limit, excludeRoomId) {
|
|
2270
2658
|
const lim = Math.max(1, Math.floor(limit));
|
|
@@ -2300,18 +2688,12 @@ export class ChatStore {
|
|
|
2300
2688
|
* and advances over lower-priority rows through a disclosed cutoff. Directed
|
|
2301
2689
|
* rows always qualify so advancing cannot silently erase my_mentions items.
|
|
2302
2690
|
*
|
|
2303
|
-
* unreadSummary: on an EMPTY read, include a bounded
|
|
2304
|
-
*
|
|
2305
|
-
* the
|
|
2306
|
-
*
|
|
2307
|
-
* "empty" while the summary lists this very room.
|
|
2691
|
+
* unreadSummary: on an EMPTY read, include a bounded rooms_with_unread
|
|
2692
|
+
* summary of every OTHER room holding unread. It is advisory and computed
|
|
2693
|
+
* after the current-room transaction so a large cross-room aggregation does
|
|
2694
|
+
* not hold the process-wide writer reservation.
|
|
2308
2695
|
*/
|
|
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) {
|
|
2696
|
+
catchUp(roomId, agentId, limit, previewChars, maxBytes = DEFAULT_MAX_BYTES, unreadSummary = null) {
|
|
2315
2697
|
if (!Number.isSafeInteger(maxBytes) ||
|
|
2316
2698
|
maxBytes < MIN_CATCH_UP_RESULT_BUDGET ||
|
|
2317
2699
|
maxBytes > MAX_BULK_RESULT_CHARS) {
|
|
@@ -2325,8 +2707,9 @@ export class ChatStore {
|
|
|
2325
2707
|
}
|
|
2326
2708
|
const pageLimit = Math.min(MAX_CATCH_UP_ROWS, Math.max(1, Math.floor(limit)));
|
|
2327
2709
|
// Advancing path: read the cursor, fetch, and advance inside one IMMEDIATE
|
|
2328
|
-
// transaction
|
|
2329
|
-
//
|
|
2710
|
+
// transaction. A deferred WAL transaction could let another writer commit
|
|
2711
|
+
// after this read snapshot, then fail the cursor update with
|
|
2712
|
+
// SQLITE_BUSY_SNAPSHOT.
|
|
2330
2713
|
// The byte bound trims BEFORE the advance, so the cursor never covers an
|
|
2331
2714
|
// undelivered peer row (it may later normalize across own rows, which are
|
|
2332
2715
|
// never returned): a response the client rejects as oversized can no
|
|
@@ -2335,19 +2718,9 @@ export class ChatStore {
|
|
|
2335
2718
|
// ADVANCING read: fenced like a mutation, because it moves the read
|
|
2336
2719
|
// marker. A stale runtime whose catch_up committed would consume messages
|
|
2337
2720
|
// the current runtime has not seen and can no longer reach.
|
|
2338
|
-
this.
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
// Distinguish a real non-member from a room deleted after the caller
|
|
2342
|
-
// resolved it. This extra PK lookup runs only on the error path.
|
|
2343
|
-
this.requireRoom(roomId);
|
|
2344
|
-
throw new Error("not a member of this room");
|
|
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);
|
|
2350
|
-
const from = cursor.last_read_seq;
|
|
2721
|
+
this.requireLive(agentId);
|
|
2722
|
+
// Advancing reads require presence; read_history remains non-advancing.
|
|
2723
|
+
const from = this.requirePresent(roomId, agentId).last_read_seq;
|
|
2351
2724
|
const priorityOnly = unreadSummary?.priorityOnly === true;
|
|
2352
2725
|
// Captured under the same IMMEDIATE snapshot as the filtered scan. Own
|
|
2353
2726
|
// rows count toward the cutoff but never toward skipped/remaining.
|
|
@@ -2409,39 +2782,6 @@ export class ChatStore {
|
|
|
2409
2782
|
if (lastSeq > from) {
|
|
2410
2783
|
this.setCursor(roomId, agentId, lastSeq);
|
|
2411
2784
|
}
|
|
2412
|
-
// Empty read: same-snapshot disclosure of where the traffic actually
|
|
2413
|
-
// is. Emitted even when no other room has unread ([]): that positively
|
|
2414
|
-
// answers "is anything anywhere?", the question an empty read raises.
|
|
2415
|
-
const UNREAD_SUMMARY_MAX = 20;
|
|
2416
|
-
let summary = null;
|
|
2417
|
-
if (unreadSummary !== null && messages.length === 0) {
|
|
2418
|
-
const fetched = this.unreadByRoom(agentId, UNREAD_SUMMARY_MAX, roomId);
|
|
2419
|
-
// The v0.9 summary was appended after catch_up had already spent the
|
|
2420
|
-
// entire page budget. Twenty legal, control-heavy room names could
|
|
2421
|
-
// inflate a declared 1k response past 25k. Bound the summary within
|
|
2422
|
-
// the same result budget, using the same measured-fit/name-halving
|
|
2423
|
-
// pattern as my_mentions.by_room.
|
|
2424
|
-
const roomBudget = maxBytes -
|
|
2425
|
-
(priorityOnly
|
|
2426
|
-
? PRIORITY_CATCH_UP_SUMMARY_ENVELOPE
|
|
2427
|
-
: CATCH_UP_SUMMARY_ENVELOPE);
|
|
2428
|
-
const fitted = fitRows(fetched.rooms, roomBudget);
|
|
2429
|
-
const rooms = fitted.rows;
|
|
2430
|
-
let truncated = fetched.truncated || fitted.sizeTrimmed;
|
|
2431
|
-
if (rooms.length === 1 && JSON.stringify(rooms).length > roomBudget) {
|
|
2432
|
-
let entry = { ...rooms[0] };
|
|
2433
|
-
while (JSON.stringify([entry]).length > roomBudget &&
|
|
2434
|
-
entry.name.length > 0) {
|
|
2435
|
-
entry = {
|
|
2436
|
-
...entry,
|
|
2437
|
-
name: safeCut(entry.name, Math.floor(entry.name.length / 2)),
|
|
2438
|
-
};
|
|
2439
|
-
}
|
|
2440
|
-
rooms[0] = entry;
|
|
2441
|
-
truncated = true;
|
|
2442
|
-
}
|
|
2443
|
-
summary = { rooms, truncated };
|
|
2444
|
-
}
|
|
2445
2785
|
return {
|
|
2446
2786
|
messages,
|
|
2447
2787
|
new_last_read_seq: lastSeq,
|
|
@@ -2460,11 +2800,36 @@ export class ChatStore {
|
|
|
2460
2800
|
// it that a preview/JSON shrink could otherwise hide. `remaining` is the
|
|
2461
2801
|
// authoritative "more unread" count here, but keep byte_limited honest.
|
|
2462
2802
|
...(byteLimited || !exhausted ? { byte_limited: true } : {}),
|
|
2463
|
-
...(summary !== null ? { rooms_with_unread: summary.rooms } : {}),
|
|
2464
|
-
...(summary?.truncated ? { rooms_with_unread_truncated: true } : {}),
|
|
2465
2803
|
};
|
|
2466
2804
|
});
|
|
2467
|
-
|
|
2805
|
+
const result = tx.immediate();
|
|
2806
|
+
if (unreadSummary === null || result.messages.length > 0)
|
|
2807
|
+
return result;
|
|
2808
|
+
// Keep the potentially large all-room aggregate outside IMMEDIATE. The
|
|
2809
|
+
// summary is routing advice; like any response, it can become stale as soon
|
|
2810
|
+
// as the current-room transaction commits.
|
|
2811
|
+
try {
|
|
2812
|
+
const UNREAD_SUMMARY_MAX = 20;
|
|
2813
|
+
const fetched = this.unreadByRoom(agentId, UNREAD_SUMMARY_MAX, roomId);
|
|
2814
|
+
const roomBudget = maxBytes -
|
|
2815
|
+
(unreadSummary.priorityOnly === true
|
|
2816
|
+
? PRIORITY_CATCH_UP_SUMMARY_ENVELOPE
|
|
2817
|
+
: CATCH_UP_SUMMARY_ENVELOPE);
|
|
2818
|
+
const fitted = fitRoomSummary(fetched.rooms, roomBudget);
|
|
2819
|
+
return {
|
|
2820
|
+
...result,
|
|
2821
|
+
rooms_with_unread: fitted.rooms,
|
|
2822
|
+
...(fetched.truncated || fitted.sizeTrimmed
|
|
2823
|
+
? { rooms_with_unread_truncated: true }
|
|
2824
|
+
: {}),
|
|
2825
|
+
};
|
|
2826
|
+
}
|
|
2827
|
+
catch {
|
|
2828
|
+
// The cursor may already be committed, including a lossy cutoff, so a
|
|
2829
|
+
// thrown response would invite a retry that cannot reconstruct the old
|
|
2830
|
+
// state. The advisory summary is optional; return the core result.
|
|
2831
|
+
return result;
|
|
2832
|
+
}
|
|
2468
2833
|
}
|
|
2469
2834
|
/**
|
|
2470
2835
|
* Cross-room mentions INBOX: unread messages directed at the agent (its
|
|
@@ -2497,6 +2862,9 @@ export class ChatStore {
|
|
|
2497
2862
|
// bulk reader with no "more remain" signal when exactly `limit` directed
|
|
2498
2863
|
// messages fit inside the byte budget: an agent paging on byte_limited
|
|
2499
2864
|
// (the documented signal) then silently under-read its own inbox.
|
|
2865
|
+
// Keep the candidate predicates in both queries below in sync with
|
|
2866
|
+
// directedAt and idx_messages_directed_candidates. It is redundant for
|
|
2867
|
+
// results but required for SQLite to prove the partial index is eligible.
|
|
2500
2868
|
const { rows: fetched, exhausted } = this.fetchBounded(this.db.prepare(`SELECT ${messageCols(maxBytes)}, g.id AS gid, g.room_id AS room_id, r.name AS room_name
|
|
2501
2869
|
FROM ${MESSAGE_FROM}
|
|
2502
2870
|
JOIN memberships mb ON mb.room_id = g.room_id
|
|
@@ -2504,6 +2872,7 @@ export class ChatStore {
|
|
|
2504
2872
|
JOIN rooms r ON r.id = g.room_id
|
|
2505
2873
|
WHERE g.seq > mb.last_read_seq
|
|
2506
2874
|
AND g.id > ? AND g.agent_id != ?
|
|
2875
|
+
AND (g.mentions IS NOT NULL OR g.reply_to_agent IS NOT NULL)
|
|
2507
2876
|
AND ${directedAt("g")}
|
|
2508
2877
|
ORDER BY g.id ASC LIMIT ?`), [agentId, afterId, agentId, agentId, agentId, limit + 1], maxBytes);
|
|
2509
2878
|
const hasExtra = fetched.length > limit;
|
|
@@ -2518,6 +2887,7 @@ export class ChatStore {
|
|
|
2518
2887
|
AND mb.agent_id = ? AND mb.left_at IS NULL
|
|
2519
2888
|
WHERE g.seq > mb.last_read_seq
|
|
2520
2889
|
AND g.agent_id != ?
|
|
2890
|
+
AND (g.mentions IS NOT NULL OR g.reply_to_agent IS NOT NULL)
|
|
2521
2891
|
AND ${directedAt("g")}`)
|
|
2522
2892
|
.get(agentId, agentId, agentId, agentId);
|
|
2523
2893
|
const total_directed = td;
|
|
@@ -2526,33 +2896,13 @@ export class ChatStore {
|
|
|
2526
2896
|
// lives in unreadByRoom, shared with catch_up's rooms_with_unread.
|
|
2527
2897
|
const BY_ROOM_MAX = 4000;
|
|
2528
2898
|
const byRoomFetch = this.unreadByRoom(agentId, BY_ROOM_MAX);
|
|
2529
|
-
let byRoom = byRoomFetch.rooms;
|
|
2530
2899
|
// Rooms past BY_ROOM_MAX (the least-directed) were dropped -- flag it.
|
|
2531
2900
|
const roomLimitHit = byRoomFetch.truncated;
|
|
2532
2901
|
const roomBudget = Math.floor(maxBytes / 3);
|
|
2533
|
-
//
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
const trimmed = fitRows(byRoom, roomBudget);
|
|
2538
|
-
byRoom = trimmed.rows;
|
|
2539
|
-
let by_room_truncated = trimmed.sizeTrimmed || roomLimitHit;
|
|
2540
|
-
// A single long-named room can still overflow a small budget: halve
|
|
2541
|
-
// the display name until the MEASURED serialized size fits (a fixed
|
|
2542
|
-
// code-unit cut under-counts JSON escaping, so a control-heavy name
|
|
2543
|
-
// slipped past it); room_id remains the stable key.
|
|
2544
|
-
if (byRoom.length === 1 && JSON.stringify(byRoom).length > roomBudget) {
|
|
2545
|
-
let entry = { ...byRoom[0] };
|
|
2546
|
-
while (JSON.stringify([entry]).length > roomBudget &&
|
|
2547
|
-
entry.name.length > 0) {
|
|
2548
|
-
entry = {
|
|
2549
|
-
...entry,
|
|
2550
|
-
name: safeCut(entry.name, Math.floor(entry.name.length / 2)),
|
|
2551
|
-
};
|
|
2552
|
-
}
|
|
2553
|
-
byRoom[0] = entry;
|
|
2554
|
-
by_room_truncated = true;
|
|
2555
|
-
}
|
|
2902
|
+
// Rows arrive most-directed first, so fitting drops the least-directed.
|
|
2903
|
+
const trimmed = fitRoomSummary(byRoomFetch.rooms, roomBudget);
|
|
2904
|
+
const byRoom = trimmed.rooms;
|
|
2905
|
+
const by_room_truncated = trimmed.sizeTrimmed || roomLimitHit;
|
|
2556
2906
|
// Joint budget with an exact envelope: messages get what by_room and
|
|
2557
2907
|
// the fixed response fields leave, floored at the stub allowance
|
|
2558
2908
|
// (which boundByBytes can always honor; at the schema's 1000-char
|
|
@@ -2627,25 +2977,17 @@ export class ChatStore {
|
|
|
2627
2977
|
* clamped to [0, latest]. A lower value re-exposes those messages to catch_up.
|
|
2628
2978
|
* Returns the previous and new marker plus the room's latest seq.
|
|
2629
2979
|
*/
|
|
2630
|
-
markRead(roomId, agentId,
|
|
2980
|
+
markRead(roomId, agentId, seq) {
|
|
2631
2981
|
const tx = this.db.transaction(() => {
|
|
2632
|
-
this.
|
|
2633
|
-
|
|
2634
|
-
|
|
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);
|
|
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);
|
|
2982
|
+
this.requireLive(agentId);
|
|
2983
|
+
// Advancing a marker requires presence and reports the prior cursor.
|
|
2984
|
+
const previous = this.requirePresent(roomId, agentId).last_read_seq;
|
|
2643
2985
|
const { latest } = this.db
|
|
2644
2986
|
.prepare("SELECT COALESCE(MAX(seq), 0) AS latest FROM messages WHERE room_id = ?")
|
|
2645
2987
|
.get(roomId);
|
|
2646
2988
|
const target = seq === undefined ? latest : Math.max(0, Math.min(seq, latest));
|
|
2647
2989
|
this.setCursor(roomId, agentId, target);
|
|
2648
|
-
return { previous
|
|
2990
|
+
return { previous, new: target, latest };
|
|
2649
2991
|
});
|
|
2650
2992
|
return tx.immediate();
|
|
2651
2993
|
}
|
|
@@ -2673,7 +3015,6 @@ export class ChatStore {
|
|
|
2673
3015
|
const { rows, exhausted } = this.fetchBounded(this.db.prepare(`SELECT ${messageCols(DEFAULT_MAX_BYTES)}
|
|
2674
3016
|
FROM messages_fts f
|
|
2675
3017
|
JOIN messages g ON g.id = f.rowid
|
|
2676
|
-
LEFT JOIN agents a ON a.id = g.agent_id
|
|
2677
3018
|
LEFT JOIN messages p ON p.room_id = g.room_id AND p.seq = g.reply_to_seq
|
|
2678
3019
|
WHERE f.body MATCH ? AND g.room_id = ?
|
|
2679
3020
|
ORDER BY rank, g.id LIMIT ? OFFSET ?`), [query, roomId, limit + 1, off], DEFAULT_MAX_BYTES);
|
|
@@ -2696,15 +3037,15 @@ export class ChatStore {
|
|
|
2696
3037
|
* Trim a room to its newest `keepLast` messages. Only the oldest are removed,
|
|
2697
3038
|
* so MAX(seq) is unchanged and future seq numbers stay monotonic.
|
|
2698
3039
|
*/
|
|
2699
|
-
pruneMessages(roomId, agentId,
|
|
3040
|
+
pruneMessages(roomId, agentId, keepLast, force) {
|
|
2700
3041
|
// Keep at least the newest message: keepLast=0 would hit OFFSET -1
|
|
2701
3042
|
// (clamped to 0 by SQLite, silently keeping one row anyway), and deleting
|
|
2702
3043
|
// ALL rows would reset MAX(seq), breaking the monotonic-seq invariant.
|
|
2703
3044
|
keepLast = Math.max(1, Math.floor(keepLast));
|
|
2704
3045
|
const tx = this.db.transaction(() => {
|
|
2705
|
-
this.
|
|
2706
|
-
//
|
|
2707
|
-
this.
|
|
3046
|
+
this.requireLive(agentId);
|
|
3047
|
+
// Pruning requires presence, including when force skips unread checks.
|
|
3048
|
+
this.requirePresent(roomId, agentId);
|
|
2708
3049
|
const { c: total } = this.db
|
|
2709
3050
|
.prepare("SELECT COUNT(*) AS c FROM messages WHERE room_id = ?")
|
|
2710
3051
|
.get(roomId);
|
|
@@ -2719,12 +3060,22 @@ export class ChatStore {
|
|
|
2719
3060
|
// read position for resume, so their unread is real until they return.
|
|
2720
3061
|
// (The author has implicitly "seen" its own message, matching catch_up's
|
|
2721
3062
|
// self-exclusion.) Pass force=true to prune past this.
|
|
3063
|
+
//
|
|
3064
|
+
// RETIRED LLM identities are the one exclusion, and it is required
|
|
3065
|
+
// rather than optional: retirement soft-leaves their memberships, and
|
|
3066
|
+
// a left membership blocks by design because it can come back. A
|
|
3067
|
+
// retired one cannot, so without this join a single model transition
|
|
3068
|
+
// would freeze the room's history permanently behind a cursor no
|
|
3069
|
+
// reader will ever advance. Humans that left stay blockers -- they are
|
|
3070
|
+
// resumable, which is exactly the difference.
|
|
2722
3071
|
const { u } = this.db
|
|
2723
3072
|
.prepare(`SELECT COUNT(*) AS u FROM messages g
|
|
2724
3073
|
WHERE g.room_id = ? AND g.seq < ?
|
|
2725
3074
|
AND EXISTS (
|
|
2726
3075
|
SELECT 1 FROM memberships mm
|
|
3076
|
+
JOIN agents aa ON aa.id = mm.agent_id
|
|
2727
3077
|
WHERE mm.room_id = g.room_id
|
|
3078
|
+
AND aa.retired_at IS NULL
|
|
2728
3079
|
AND mm.last_read_seq < g.seq AND mm.agent_id != g.agent_id
|
|
2729
3080
|
)`)
|
|
2730
3081
|
.get(roomId, cutoff.seq);
|
|
@@ -2735,7 +3086,8 @@ export class ChatStore {
|
|
|
2735
3086
|
// whom the refusal itself exempts.
|
|
2736
3087
|
const { m } = this.db
|
|
2737
3088
|
.prepare(`SELECT MIN(mm.last_read_seq) AS m FROM memberships mm
|
|
2738
|
-
|
|
3089
|
+
JOIN agents aa ON aa.id = mm.agent_id
|
|
3090
|
+
WHERE mm.room_id = ? AND aa.retired_at IS NULL AND EXISTS (
|
|
2739
3091
|
SELECT 1 FROM messages g WHERE g.room_id = mm.room_id
|
|
2740
3092
|
AND g.seq < ? AND g.seq > mm.last_read_seq
|
|
2741
3093
|
AND g.agent_id != mm.agent_id)`)
|
|
@@ -2762,12 +3114,12 @@ export class ChatStore {
|
|
|
2762
3114
|
/** Hard-delete a room and all of its messages and memberships. Throws a
|
|
2763
3115
|
* clean "already deleted" error if another process removed it first,
|
|
2764
3116
|
* instead of reporting a false success with zero counts. */
|
|
2765
|
-
deleteRoom(roomId, agentId
|
|
3117
|
+
deleteRoom(roomId, agentId) {
|
|
2766
3118
|
const tx = this.db.transaction(() => {
|
|
2767
3119
|
// Fenced for the same reason as createRoom, and more urgently: this is
|
|
2768
3120
|
// the one irreversible operation in the tool surface. No membership is
|
|
2769
3121
|
// required (deletion has always been global admin), only a live persona.
|
|
2770
|
-
this.
|
|
3122
|
+
this.requireLive(agentId);
|
|
2771
3123
|
this.requireRoom(roomId);
|
|
2772
3124
|
const { c: messages } = this.db
|
|
2773
3125
|
.prepare("SELECT COUNT(*) AS c FROM messages WHERE room_id = ?")
|
|
@@ -2793,29 +3145,29 @@ export class ChatStore {
|
|
|
2793
3145
|
* winner: the read-check and upsert run in one IMMEDIATE transaction, so two
|
|
2794
3146
|
* simultaneous claimants cannot both be granted (unlike two "I claim X" chat
|
|
2795
3147
|
* posts, which can cross). Re-claiming your own key renews the TTL; an
|
|
2796
|
-
* expired claim is grantable to anyone. Ownership is per PERSONA,
|
|
2797
|
-
*
|
|
3148
|
+
* expired claim is grantable to anyone. Ownership is per PERSONA, and a
|
|
3149
|
+
* persona belongs to one connection for life, so a claim has exactly one
|
|
3150
|
+
* holder until it expires, is released, or its holder is retired (retirement
|
|
3151
|
+
* DELETES it rather than transferring it).
|
|
2798
3152
|
*/
|
|
2799
|
-
claimResource(roomId, key, agentId,
|
|
3153
|
+
claimResource(roomId, key, agentId, ttlSeconds, note) {
|
|
2800
3154
|
assertStorable(key, "claim key");
|
|
2801
3155
|
assertMaxLen(key, "claim key", 500);
|
|
2802
3156
|
assertStorable(note, "claim note");
|
|
2803
3157
|
assertMaxLen(note, "claim note", 2000);
|
|
3158
|
+
if (!Number.isSafeInteger(ttlSeconds) ||
|
|
3159
|
+
ttlSeconds < 1 ||
|
|
3160
|
+
ttlSeconds > 86_400) {
|
|
3161
|
+
throw new Error("claim ttl_seconds must be an integer from 1 to 86400 seconds");
|
|
3162
|
+
}
|
|
2804
3163
|
const tx = this.db.transaction(() => {
|
|
2805
|
-
this.
|
|
2806
|
-
//
|
|
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.
|
|
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.
|
|
3164
|
+
this.requireLive(agentId);
|
|
3165
|
+
// Taking a claim requires presence; releasing one remains cleanup.
|
|
2814
3166
|
this.requirePresent(roomId, agentId);
|
|
2815
3167
|
const row = this.db
|
|
2816
3168
|
.prepare(`SELECT agent_id, note,
|
|
2817
3169
|
strftime('%Y-%m-%dT%H:%M:%SZ', expires_at) AS expires_at,
|
|
2818
|
-
(
|
|
3170
|
+
(unixepoch(expires_at) - unixepoch('now')) AS remaining
|
|
2819
3171
|
FROM claims WHERE room_id = ? AND key = ?`)
|
|
2820
3172
|
.get(roomId, key);
|
|
2821
3173
|
if (row && row.remaining > 0 && row.agent_id !== agentId) {
|
|
@@ -2849,13 +3201,13 @@ export class ChatStore {
|
|
|
2849
3201
|
return tx.immediate();
|
|
2850
3202
|
}
|
|
2851
3203
|
/** Release your own claim. Expired claims can be released by anyone. */
|
|
2852
|
-
releaseClaim(roomId, key, agentId
|
|
3204
|
+
releaseClaim(roomId, key, agentId) {
|
|
2853
3205
|
const tx = this.db.transaction(() => {
|
|
2854
|
-
this.
|
|
3206
|
+
this.requireLive(agentId);
|
|
2855
3207
|
this.requireRoom(roomId);
|
|
2856
3208
|
const row = this.db
|
|
2857
3209
|
.prepare(`SELECT agent_id,
|
|
2858
|
-
(
|
|
3210
|
+
(unixepoch(expires_at) - unixepoch('now')) AS remaining
|
|
2859
3211
|
FROM claims WHERE room_id = ? AND key = ?`)
|
|
2860
3212
|
.get(roomId, key);
|
|
2861
3213
|
if (!row)
|
|
@@ -2897,7 +3249,7 @@ export class ChatStore {
|
|
|
2897
3249
|
substr(note, 1, ${PREVIEW}) AS note,
|
|
2898
3250
|
CASE WHEN length(note) > ${PREVIEW} THEN 1 ELSE 0 END AS note_cut,
|
|
2899
3251
|
strftime('%Y-%m-%dT%H:%M:%SZ', expires_at) AS expires_at,
|
|
2900
|
-
(
|
|
3252
|
+
(unixepoch(expires_at) - unixepoch('now')) AS expires_in_seconds
|
|
2901
3253
|
FROM claims WHERE room_id = ? AND key > ? ORDER BY key LIMIT ?`)
|
|
2902
3254
|
.all(roomId, afterKey, lim + 1);
|
|
2903
3255
|
const { c: total } = this.db
|