Skip to content

Commit a837d2e

Browse files
tests
1 parent 8d36d7c commit a837d2e

10 files changed

Lines changed: 1081 additions & 80 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
-- Adds a foreign key on messages.to_agent → identity.agent_name with
2+
-- ON DELETE CASCADE so deleting an agent removes inbound messages too.
3+
-- Previously only from_agent had a FK, which left orphaned messages
4+
-- whenever the recipient was deleted.
5+
-- RedefineTables
6+
PRAGMA defer_foreign_keys=ON;
7+
PRAGMA foreign_keys=OFF;
8+
CREATE TABLE "new_messages" (
9+
"id" TEXT NOT NULL PRIMARY KEY,
10+
"from_agent" TEXT NOT NULL,
11+
"to_agent" TEXT NOT NULL,
12+
"content" TEXT NOT NULL,
13+
"created_at" BIGINT NOT NULL,
14+
"read_at" BIGINT,
15+
CONSTRAINT "messages_from_agent_fkey" FOREIGN KEY ("from_agent") REFERENCES "identity" ("agent_name") ON DELETE CASCADE ON UPDATE CASCADE,
16+
CONSTRAINT "messages_to_agent_fkey" FOREIGN KEY ("to_agent") REFERENCES "identity" ("agent_name") ON DELETE CASCADE ON UPDATE CASCADE
17+
);
18+
INSERT INTO "new_messages" ("content", "created_at", "from_agent", "id", "read_at", "to_agent") SELECT "content", "created_at", "from_agent", "id", "read_at", "to_agent" FROM "messages";
19+
DROP TABLE "messages";
20+
ALTER TABLE "new_messages" RENAME TO "messages";
21+
CREATE INDEX "idx_messages_inbox" ON "messages"("to_agent", "read_at", "created_at" DESC);
22+
PRAGMA foreign_keys=ON;
23+
PRAGMA defer_foreign_keys=OFF;

too-many-cooks/packages/too-many-cooks/prisma/schema.prisma

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@ model Identity {
1414
registeredAt BigInt @map("registered_at")
1515
lastActive BigInt @map("last_active")
1616
17-
locks Lock[]
18-
messages Message[]
19-
messageReads MessageRead[]
20-
plans Plan[]
17+
locks Lock[]
18+
messagesSent Message[] @relation("MessageFrom")
19+
messagesReceived Message[] @relation("MessageTo")
20+
messageReads MessageRead[]
21+
plans Plan[]
2122
2223
@@map("identity")
2324
}
@@ -43,7 +44,8 @@ model Message {
4344
createdAt BigInt @map("created_at")
4445
readAt BigInt? @map("read_at")
4546
46-
identity Identity @relation(fields: [fromAgent], references: [agentName], onDelete: Cascade)
47+
from Identity @relation("MessageFrom", fields: [fromAgent], references: [agentName], onDelete: Cascade)
48+
to Identity @relation("MessageTo", fields: [toAgent], references: [agentName], onDelete: Cascade)
4749
messageReads MessageRead[]
4850
4951
@@index([toAgent, readAt, createdAt(sort: Desc)], map: "idx_messages_inbox")

too-many-cooks/packages/too-many-cooks/src/db-sqlite.ts

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,24 @@ const ACTIVE_TRUE: number = 1;
5656
/** Inactive flag value. */
5757
const ACTIVE_FALSE: number = 0;
5858

59-
/** Broadcast recipient marker. */
59+
/** Broadcast recipient marker. Also the reserved agent_name of the sentinel
60+
* identity row that lets the messages.to_agent foreign key target broadcasts
61+
* without orphaning them. The sentinel is created with active=0 so
62+
* listAgents (which filters by active=1) never surfaces it in the UI. */
6063
const BROADCAST_RECIPIENT: string = "*";
6164

65+
/** Insert the reserved '*' identity row required by the messages.to_agent FK
66+
* for broadcasts. Idempotent — re-runs on every DB open are safe. The row
67+
* is kept active=0 so it never appears in agent listings. */
68+
const seedBroadcastSentinel: (db: Database.Database) => void = (
69+
db: Database.Database,
70+
): void => {
71+
const timestamp: number = now();
72+
db.prepare(
73+
"INSERT OR IGNORE INTO identity (agent_name, agent_key, active, registered_at, last_active) VALUES (?, ?, 0, ?, ?)",
74+
).run(BROADCAST_RECIPIENT, `__broadcast_sentinel_${generateKey()}`, timestamp, timestamp);
75+
};
76+
6277
/** SQLite-specific retryable errors. */
6378
const isSqliteRetryable: (err: string) => boolean = (err: string): boolean =>
6479
err.includes("disk I/O error") ||
@@ -136,6 +151,7 @@ const openAndInit: (
136151
try {
137152
db = new Database(config.dbPath);
138153
db.pragma("foreign_keys = ON");
154+
seedBroadcastSentinel(db);
139155
} catch (e: unknown) {
140156
return error(`Failed to open database: ${String(e)}`);
141157
}
@@ -211,6 +227,10 @@ const register: (
211227
log.warn("Registration failed: invalid name length");
212228
return error({ code: ERR_VALIDATION, message: "Name must be 1-50 chars" });
213229
}
230+
if (name === BROADCAST_RECIPIENT) {
231+
log.warn("Registration failed: reserved broadcast name");
232+
return error({ code: ERR_VALIDATION, message: "Name '*' is reserved for broadcasts" });
233+
}
214234
const key: string = generateKey();
215235
const timestamp: number = now();
216236
try {
@@ -905,14 +925,14 @@ const adminDeleteAgent: (
905925
agentName: string,
906926
): Result<void, DbError> => {
907927
log.warn(`Admin deleting agent ${agentName}`);
928+
if (agentName === BROADCAST_RECIPIENT) {
929+
return error({ code: ERR_VALIDATION, message: "Cannot delete broadcast sentinel" });
930+
}
908931
try {
909-
// Delete child rows explicitly (in FK-safe order) before deleting the identity row.
910-
// Cascade is defined in the schema but must not be relied upon — explicit deletes
911-
// are more reliable across SQLite versions and PRAGMA states.
912-
db.prepare("DELETE FROM locks WHERE agent_name = ?").run(agentName);
913-
db.prepare("DELETE FROM plans WHERE agent_name = ?").run(agentName);
914-
db.prepare("DELETE FROM messages WHERE from_agent = ?").run(agentName);
915-
db.prepare("DELETE FROM messages WHERE to_agent = ?").run(agentName);
932+
// Cascade is enforced by the schema (locks, plans, messages.from_agent,
933+
// messages.to_agent all ON DELETE CASCADE), so the single DELETE on
934+
// identity removes every dependent row atomically. No manual fan-out —
935+
// doing so would mask FK regressions.
916936
const result: Database.RunResult = db
917937
.prepare("DELETE FROM identity WHERE agent_name = ?")
918938
.run(agentName);
@@ -1014,9 +1034,17 @@ const adminSendMessage: (
10141034
const timestamp: number = now();
10151035
try {
10161036
const ensureStmt: Database.Statement = db.prepare(
1017-
"INSERT OR IGNORE INTO identity (agent_name, agent_key, registered_at, last_active) VALUES (?, ?, ?, ?)",
1037+
"INSERT OR IGNORE INTO identity (agent_name, agent_key, active, registered_at, last_active) VALUES (?, ?, 0, ?, ?)",
10181038
);
1039+
// Auto-create sender (existing behaviour) AND recipient so the to_agent
1040+
// FK is satisfied. '*' is skipped because the broadcast sentinel is
1041+
// seeded at DB open. Auto-created peers are inactive so they don't
1042+
// pollute agent listings; if they later register for real, the upsert
1043+
// in register() reactivates them.
10191044
ensureStmt.run(fromAgent, generateKey(), timestamp, timestamp);
1045+
if (toAgent !== BROADCAST_RECIPIENT) {
1046+
ensureStmt.run(toAgent, generateKey(), timestamp, timestamp);
1047+
}
10201048
} catch (e: unknown) {
10211049
return error({ code: ERR_DATABASE, message: String(e) });
10221050
}

0 commit comments

Comments
 (0)