agent-relay-server 0.1.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 +200 -0
- package/package.json +44 -0
- package/public/index.html +501 -0
- package/src/config.ts +11 -0
- package/src/db.ts +499 -0
- package/src/index.ts +82 -0
- package/src/routes.ts +343 -0
- package/src/types.ts +62 -0
package/src/db.ts
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import type {
|
|
3
|
+
AgentCard,
|
|
4
|
+
Message,
|
|
5
|
+
RegisterAgentInput,
|
|
6
|
+
SendMessageInput,
|
|
7
|
+
PollQuery,
|
|
8
|
+
} from "./types";
|
|
9
|
+
import { STALE_TTL_MS, DAY_MS } from "./config";
|
|
10
|
+
|
|
11
|
+
let db: Database;
|
|
12
|
+
|
|
13
|
+
export function initDb(path: string = "agent-relay.db"): Database {
|
|
14
|
+
db = new Database(path, { create: true });
|
|
15
|
+
db.run("PRAGMA journal_mode = WAL");
|
|
16
|
+
db.run("PRAGMA foreign_keys = ON");
|
|
17
|
+
|
|
18
|
+
db.run(`
|
|
19
|
+
CREATE TABLE IF NOT EXISTS agents (
|
|
20
|
+
id TEXT PRIMARY KEY,
|
|
21
|
+
name TEXT NOT NULL,
|
|
22
|
+
tags TEXT NOT NULL DEFAULT '[]',
|
|
23
|
+
machine TEXT,
|
|
24
|
+
rig TEXT,
|
|
25
|
+
capabilities TEXT NOT NULL DEFAULT '[]',
|
|
26
|
+
status TEXT NOT NULL DEFAULT 'idle',
|
|
27
|
+
meta TEXT NOT NULL DEFAULT '{}',
|
|
28
|
+
last_seen INTEGER NOT NULL,
|
|
29
|
+
created_at INTEGER NOT NULL
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
33
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
34
|
+
from_agent TEXT NOT NULL,
|
|
35
|
+
to_target TEXT NOT NULL,
|
|
36
|
+
channel TEXT,
|
|
37
|
+
subject TEXT,
|
|
38
|
+
body TEXT NOT NULL,
|
|
39
|
+
thread_id INTEGER,
|
|
40
|
+
reply_to INTEGER REFERENCES messages(id),
|
|
41
|
+
claimable INTEGER NOT NULL DEFAULT 0,
|
|
42
|
+
claimed_by TEXT,
|
|
43
|
+
claimed_at INTEGER,
|
|
44
|
+
meta TEXT NOT NULL DEFAULT '{}',
|
|
45
|
+
read_by TEXT NOT NULL DEFAULT '[]',
|
|
46
|
+
created_at INTEGER NOT NULL
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
CREATE INDEX IF NOT EXISTS idx_msg_to ON messages(to_target);
|
|
50
|
+
CREATE INDEX IF NOT EXISTS idx_msg_created ON messages(created_at);
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_msg_channel ON messages(channel);
|
|
52
|
+
`);
|
|
53
|
+
|
|
54
|
+
// Migrations
|
|
55
|
+
const cols = db.prepare("PRAGMA table_info(messages)").all() as any[];
|
|
56
|
+
const colNames = cols.map((c: any) => c.name);
|
|
57
|
+
if (!colNames.includes("thread_id")) {
|
|
58
|
+
db.run("ALTER TABLE messages ADD COLUMN thread_id INTEGER");
|
|
59
|
+
db.run("ALTER TABLE messages ADD COLUMN reply_to INTEGER REFERENCES messages(id)");
|
|
60
|
+
}
|
|
61
|
+
if (!colNames.includes("claimable")) {
|
|
62
|
+
db.run("ALTER TABLE messages ADD COLUMN claimable INTEGER NOT NULL DEFAULT 0");
|
|
63
|
+
db.run("ALTER TABLE messages ADD COLUMN claimed_by TEXT");
|
|
64
|
+
db.run("ALTER TABLE messages ADD COLUMN claimed_at INTEGER");
|
|
65
|
+
}
|
|
66
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_msg_thread ON messages(thread_id)");
|
|
67
|
+
|
|
68
|
+
// Backfill thread_id for pre-migration rows (self-threaded).
|
|
69
|
+
db.run("UPDATE messages SET thread_id = id WHERE thread_id IS NULL");
|
|
70
|
+
|
|
71
|
+
// message_reads: relational replacement for the read_by JSON array.
|
|
72
|
+
db.run(`
|
|
73
|
+
CREATE TABLE IF NOT EXISTS message_reads (
|
|
74
|
+
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
75
|
+
agent_id TEXT NOT NULL,
|
|
76
|
+
read_at INTEGER NOT NULL,
|
|
77
|
+
PRIMARY KEY (message_id, agent_id)
|
|
78
|
+
);
|
|
79
|
+
CREATE INDEX IF NOT EXISTS idx_mr_agent ON message_reads(agent_id);
|
|
80
|
+
`);
|
|
81
|
+
|
|
82
|
+
// Migration: agents.label
|
|
83
|
+
const agentCols = db.prepare("PRAGMA table_info(agents)").all() as any[];
|
|
84
|
+
const agentColNames = agentCols.map((c: any) => c.name);
|
|
85
|
+
if (!agentColNames.includes("label")) {
|
|
86
|
+
db.run("ALTER TABLE agents ADD COLUMN label TEXT");
|
|
87
|
+
}
|
|
88
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_agents_label ON agents(label)");
|
|
89
|
+
|
|
90
|
+
// Built-in "user" agent — represents the human operator driving the
|
|
91
|
+
// dashboard. Registered unconditionally so dashboard sends (from=user)
|
|
92
|
+
// pass the sendMessage validation. The reaper exempts this id so it
|
|
93
|
+
// never flips to offline.
|
|
94
|
+
const now = Date.now();
|
|
95
|
+
db.prepare(`
|
|
96
|
+
INSERT INTO agents (id, name, tags, machine, rig, capabilities, status, meta, last_seen, created_at)
|
|
97
|
+
VALUES ('user', 'User', '["human"]', NULL, NULL, '[]', 'online', '{"builtin":true}', ?, ?)
|
|
98
|
+
ON CONFLICT(id) DO UPDATE SET status = 'online', last_seen = excluded.last_seen
|
|
99
|
+
`).run(now, now);
|
|
100
|
+
|
|
101
|
+
// One-shot migration: backfill message_reads from legacy read_by JSON
|
|
102
|
+
// if that column still carries data. Safe to run repeatedly (INSERT OR IGNORE).
|
|
103
|
+
if (colNames.includes("read_by")) {
|
|
104
|
+
db.run(`
|
|
105
|
+
INSERT OR IGNORE INTO message_reads (message_id, agent_id, read_at)
|
|
106
|
+
SELECT m.id, je.value, m.created_at
|
|
107
|
+
FROM messages m, json_each(m.read_by) je
|
|
108
|
+
WHERE json_valid(m.read_by)
|
|
109
|
+
`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return db;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export class ValidationError extends Error {}
|
|
116
|
+
|
|
117
|
+
function parseJson<T>(raw: string, fallback: T): T {
|
|
118
|
+
try {
|
|
119
|
+
return JSON.parse(raw);
|
|
120
|
+
} catch {
|
|
121
|
+
return fallback;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function rowToAgent(row: any): AgentCard {
|
|
126
|
+
return {
|
|
127
|
+
id: row.id,
|
|
128
|
+
name: row.name,
|
|
129
|
+
label: row.label ?? undefined,
|
|
130
|
+
tags: parseJson(row.tags, []),
|
|
131
|
+
machine: row.machine ?? undefined,
|
|
132
|
+
rig: row.rig ?? undefined,
|
|
133
|
+
capabilities: parseJson(row.capabilities, []),
|
|
134
|
+
status: row.status,
|
|
135
|
+
meta: parseJson(row.meta, {}),
|
|
136
|
+
lastSeen: row.last_seen,
|
|
137
|
+
createdAt: row.created_at,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function rowToMessage(row: any): Message {
|
|
142
|
+
return {
|
|
143
|
+
id: row.id,
|
|
144
|
+
from: row.from_agent,
|
|
145
|
+
to: row.to_target,
|
|
146
|
+
channel: row.channel ?? undefined,
|
|
147
|
+
subject: row.subject ?? undefined,
|
|
148
|
+
body: row.body,
|
|
149
|
+
threadId: row.thread_id ?? undefined,
|
|
150
|
+
replyTo: row.reply_to ?? undefined,
|
|
151
|
+
claimable: row.claimable === 1 ? true : undefined,
|
|
152
|
+
claimedBy: row.claimed_by ?? undefined,
|
|
153
|
+
claimedAt: row.claimed_at ?? undefined,
|
|
154
|
+
meta: parseJson(row.meta, {}),
|
|
155
|
+
readBy: parseJson(row.read_by_agents ?? "[]", []),
|
|
156
|
+
createdAt: row.created_at,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const MSG_SELECT = `SELECT m.*, (
|
|
161
|
+
SELECT json_group_array(agent_id) FROM message_reads WHERE message_id = m.id
|
|
162
|
+
) AS read_by_agents FROM messages m`;
|
|
163
|
+
|
|
164
|
+
// --- Agents ---
|
|
165
|
+
|
|
166
|
+
export function upsertAgent(input: RegisterAgentInput): AgentCard {
|
|
167
|
+
const now = Date.now();
|
|
168
|
+
// Preserve the existing label across re-registrations unless the caller
|
|
169
|
+
// explicitly sends one (including null to clear).
|
|
170
|
+
const labelProvided = Object.prototype.hasOwnProperty.call(input, "label");
|
|
171
|
+
const stmt = db.prepare(`
|
|
172
|
+
INSERT INTO agents (id, name, label, tags, machine, rig, capabilities, status, meta, last_seen, created_at)
|
|
173
|
+
VALUES ($id, $name, $label, $tags, $machine, $rig, $capabilities, $status, $meta, $now, $now)
|
|
174
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
175
|
+
name = $name,
|
|
176
|
+
label = CASE WHEN $labelProvided = 1 THEN $label ELSE agents.label END,
|
|
177
|
+
tags = $tags,
|
|
178
|
+
machine = coalesce($machine, agents.machine),
|
|
179
|
+
rig = coalesce($rig, agents.rig),
|
|
180
|
+
capabilities = $capabilities,
|
|
181
|
+
status = $status,
|
|
182
|
+
meta = $meta,
|
|
183
|
+
last_seen = $now
|
|
184
|
+
`);
|
|
185
|
+
|
|
186
|
+
stmt.run({
|
|
187
|
+
$id: input.id,
|
|
188
|
+
$name: input.name,
|
|
189
|
+
$label: input.label ?? null,
|
|
190
|
+
$labelProvided: labelProvided ? 1 : 0,
|
|
191
|
+
$tags: JSON.stringify(input.tags ?? []),
|
|
192
|
+
$machine: input.machine ?? null,
|
|
193
|
+
$rig: input.rig ?? null,
|
|
194
|
+
$capabilities: JSON.stringify(input.capabilities ?? []),
|
|
195
|
+
$status: input.status ?? "idle",
|
|
196
|
+
$meta: JSON.stringify(input.meta ?? {}),
|
|
197
|
+
$now: now,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
return getAgent(input.id)!;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function setLabel(id: string, label: string | null): boolean {
|
|
204
|
+
const normalized = label && label.trim() ? label.trim() : null;
|
|
205
|
+
return (
|
|
206
|
+
db.prepare("UPDATE agents SET label = ? WHERE id = ?").run(normalized, id).changes > 0
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function getAgent(id: string): AgentCard | null {
|
|
211
|
+
const row = db.prepare("SELECT * FROM agents WHERE id = ?").get(id) as any;
|
|
212
|
+
return row ? rowToAgent(row) : null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function listAgents(filter?: {
|
|
216
|
+
tag?: string;
|
|
217
|
+
machine?: string;
|
|
218
|
+
status?: string;
|
|
219
|
+
}): AgentCard[] {
|
|
220
|
+
let sql = "SELECT * FROM agents WHERE 1=1";
|
|
221
|
+
const params: any[] = [];
|
|
222
|
+
|
|
223
|
+
if (filter?.tag) {
|
|
224
|
+
sql += " AND EXISTS (SELECT 1 FROM json_each(tags) WHERE value = ?)";
|
|
225
|
+
params.push(filter.tag);
|
|
226
|
+
}
|
|
227
|
+
if (filter?.machine) {
|
|
228
|
+
sql += " AND machine = ?";
|
|
229
|
+
params.push(filter.machine);
|
|
230
|
+
}
|
|
231
|
+
if (filter?.status) {
|
|
232
|
+
sql += " AND status = ?";
|
|
233
|
+
params.push(filter.status);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
sql += " ORDER BY last_seen DESC";
|
|
237
|
+
return (db.prepare(sql).all(...params) as any[]).map(rowToAgent);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function setStatus(id: string, status: AgentCard["status"]): boolean {
|
|
241
|
+
const now = Date.now();
|
|
242
|
+
return (
|
|
243
|
+
db
|
|
244
|
+
.prepare("UPDATE agents SET status = ?, last_seen = ? WHERE id = ?")
|
|
245
|
+
.run(status, now, id).changes > 0
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function heartbeat(id: string): boolean {
|
|
250
|
+
const result = db
|
|
251
|
+
.prepare("UPDATE agents SET last_seen = ? WHERE id = ?")
|
|
252
|
+
.run(Date.now(), id);
|
|
253
|
+
return result.changes > 0;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function reapStaleAgents(ttlMs: number = STALE_TTL_MS): number {
|
|
257
|
+
const cutoff = Date.now() - ttlMs;
|
|
258
|
+
return db
|
|
259
|
+
.prepare(
|
|
260
|
+
"UPDATE agents SET status = 'offline' WHERE status != 'offline' AND last_seen < ? AND id != 'user'"
|
|
261
|
+
)
|
|
262
|
+
.run(cutoff).changes;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function findAgentsByCapability(capability: string, onlineOnly = true): AgentCard[] {
|
|
266
|
+
let sql = `SELECT * FROM agents WHERE EXISTS (SELECT 1 FROM json_each(capabilities) WHERE value = ?)`;
|
|
267
|
+
const params: any[] = [capability];
|
|
268
|
+
if (onlineOnly) {
|
|
269
|
+
sql += ` AND status != 'offline' AND last_seen > ?`;
|
|
270
|
+
params.push(Date.now() - STALE_TTL_MS);
|
|
271
|
+
}
|
|
272
|
+
sql += " ORDER BY last_seen DESC";
|
|
273
|
+
return (db.prepare(sql).all(...params) as any[]).map(rowToAgent);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function deleteAgent(id: string): { ok: boolean; error?: string } {
|
|
277
|
+
if (id === "user") return { ok: false, error: "built-in user agent cannot be deleted" };
|
|
278
|
+
const deleted = db.transaction(() => {
|
|
279
|
+
// Release any claims held by this agent so the tasks become claimable again.
|
|
280
|
+
// from_agent is left intact as historical record.
|
|
281
|
+
db.prepare("UPDATE messages SET claimed_by = NULL, claimed_at = NULL WHERE claimed_by = ?").run(id);
|
|
282
|
+
return db.prepare("DELETE FROM agents WHERE id = ?").run(id).changes > 0;
|
|
283
|
+
})();
|
|
284
|
+
return deleted ? { ok: true } : { ok: false, error: "agent not found" };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// --- Messages ---
|
|
288
|
+
|
|
289
|
+
export function sendMessage(input: SendMessageInput): Message {
|
|
290
|
+
const now = Date.now();
|
|
291
|
+
|
|
292
|
+
if (!getAgent(input.from)) {
|
|
293
|
+
throw new ValidationError(`sender agent ${input.from} not registered`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Resolve thread: if replying, inherit from parent; reject unknown replyTo
|
|
297
|
+
// rather than silently orphaning the message (leaves thread_id NULL).
|
|
298
|
+
let threadId: number | null = null;
|
|
299
|
+
if (input.replyTo !== undefined && input.replyTo !== null) {
|
|
300
|
+
const parent = getMessage(input.replyTo);
|
|
301
|
+
if (!parent) throw new ValidationError(`replyTo message ${input.replyTo} not found`);
|
|
302
|
+
threadId = parent.threadId ?? parent.id;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const insert = db.prepare(`
|
|
306
|
+
INSERT INTO messages (from_agent, to_target, channel, subject, body, thread_id, reply_to, claimable, meta, created_at)
|
|
307
|
+
VALUES ($from, $to, $channel, $subject, $body, $threadId, $replyTo, $claimable, $meta, $now)
|
|
308
|
+
`);
|
|
309
|
+
const setSelfThread = db.prepare("UPDATE messages SET thread_id = ? WHERE id = ?");
|
|
310
|
+
|
|
311
|
+
const id = db.transaction(() => {
|
|
312
|
+
const result = insert.run({
|
|
313
|
+
$from: input.from,
|
|
314
|
+
$to: input.to,
|
|
315
|
+
$channel: input.channel ?? null,
|
|
316
|
+
$subject: input.subject ?? null,
|
|
317
|
+
$body: input.body,
|
|
318
|
+
$threadId: threadId,
|
|
319
|
+
$replyTo: input.replyTo ?? null,
|
|
320
|
+
$claimable: input.claimable ? 1 : 0,
|
|
321
|
+
$meta: JSON.stringify(input.meta ?? {}),
|
|
322
|
+
$now: now,
|
|
323
|
+
});
|
|
324
|
+
const newId = Number(result.lastInsertRowid);
|
|
325
|
+
if (threadId === null) setSelfThread.run(newId, newId);
|
|
326
|
+
return newId;
|
|
327
|
+
})();
|
|
328
|
+
|
|
329
|
+
return getMessage(id)!;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function getThread(messageId: number): Message[] {
|
|
333
|
+
const msg = getMessage(messageId);
|
|
334
|
+
if (!msg) return [];
|
|
335
|
+
const threadId = msg.threadId ?? msg.id;
|
|
336
|
+
return (
|
|
337
|
+
db
|
|
338
|
+
.prepare(`${MSG_SELECT} WHERE m.thread_id = ? ORDER BY m.created_at ASC`)
|
|
339
|
+
.all(threadId) as any[]
|
|
340
|
+
).map(rowToMessage);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function claimMessage(messageId: number, agentId: string): { ok: boolean; error?: string } {
|
|
344
|
+
if (!getAgent(agentId)) return { ok: false, error: "claiming agent not found" };
|
|
345
|
+
|
|
346
|
+
const msg = getMessage(messageId);
|
|
347
|
+
if (!msg) return { ok: false, error: "message not found" };
|
|
348
|
+
if (!msg.claimable) return { ok: false, error: "message is not claimable" };
|
|
349
|
+
if (msg.claimedBy) return { ok: false, error: `already claimed by ${msg.claimedBy}` };
|
|
350
|
+
|
|
351
|
+
const result = db.prepare(
|
|
352
|
+
"UPDATE messages SET claimed_by = ?, claimed_at = ? WHERE id = ? AND claimed_by IS NULL"
|
|
353
|
+
).run(agentId, Date.now(), messageId);
|
|
354
|
+
|
|
355
|
+
// Atomic: if changes === 0, someone else claimed it between our read and write
|
|
356
|
+
if (result.changes === 0) return { ok: false, error: "claim race — already claimed" };
|
|
357
|
+
return { ok: true };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function getMessage(id: number): Message | null {
|
|
361
|
+
const row = db.prepare(`${MSG_SELECT} WHERE m.id = ?`).get(id) as any;
|
|
362
|
+
return row ? rowToMessage(row) : null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function listRecentMessages(limit: number = 100, since?: number, channel?: string): Message[] {
|
|
366
|
+
const conditions: string[] = [];
|
|
367
|
+
const params: any[] = [];
|
|
368
|
+
|
|
369
|
+
if (since !== undefined) {
|
|
370
|
+
conditions.push("created_at > ?");
|
|
371
|
+
params.push(since);
|
|
372
|
+
}
|
|
373
|
+
if (channel) {
|
|
374
|
+
conditions.push("channel = ?");
|
|
375
|
+
params.push(channel);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
379
|
+
const sql = `${MSG_SELECT} ${where} ORDER BY m.created_at DESC LIMIT ?`;
|
|
380
|
+
params.push(limit);
|
|
381
|
+
|
|
382
|
+
return (db.prepare(sql).all(...params) as any[]).map(rowToMessage).reverse();
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function pollMessages(query: PollQuery): Message[] {
|
|
386
|
+
const agent = getAgent(query.for);
|
|
387
|
+
const agentTags = agent?.tags ?? [];
|
|
388
|
+
const agentCaps = agent?.capabilities ?? [];
|
|
389
|
+
const agentLabel = agent?.label;
|
|
390
|
+
|
|
391
|
+
const conditions: string[] = [];
|
|
392
|
+
const params: any[] = [];
|
|
393
|
+
|
|
394
|
+
// Build target matching: direct + broadcast + tag + capability + label
|
|
395
|
+
const targetClauses = ["to_target = ?", "to_target = 'broadcast'"];
|
|
396
|
+
params.push(query.for);
|
|
397
|
+
|
|
398
|
+
for (const tag of agentTags) {
|
|
399
|
+
targetClauses.push("to_target = ?");
|
|
400
|
+
params.push(`tag:${tag}`);
|
|
401
|
+
}
|
|
402
|
+
for (const cap of agentCaps) {
|
|
403
|
+
targetClauses.push("to_target = ?");
|
|
404
|
+
params.push(`cap:${cap}`);
|
|
405
|
+
}
|
|
406
|
+
if (agentLabel) {
|
|
407
|
+
targetClauses.push("to_target = ?");
|
|
408
|
+
params.push(`label:${agentLabel}`);
|
|
409
|
+
}
|
|
410
|
+
conditions.push(`(${targetClauses.join(" OR ")})`);
|
|
411
|
+
|
|
412
|
+
// Hide claimable messages that were claimed by someone else
|
|
413
|
+
conditions.push(`(claimable = 0 OR claimed_by IS NULL OR claimed_by = ?)`);
|
|
414
|
+
params.push(query.for);
|
|
415
|
+
|
|
416
|
+
if (query.sinceId !== undefined) {
|
|
417
|
+
conditions.push("id > ?");
|
|
418
|
+
params.push(query.sinceId);
|
|
419
|
+
} else if (query.since !== undefined) {
|
|
420
|
+
conditions.push("created_at > ?");
|
|
421
|
+
params.push(query.since);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (query.unread) {
|
|
425
|
+
conditions.push(
|
|
426
|
+
"NOT EXISTS (SELECT 1 FROM message_reads WHERE message_id = m.id AND agent_id = ?)"
|
|
427
|
+
);
|
|
428
|
+
params.push(query.for);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (query.channel) {
|
|
432
|
+
conditions.push("channel = ?");
|
|
433
|
+
params.push(query.channel);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const limit = query.limit ?? 50;
|
|
437
|
+
const sql = `${MSG_SELECT} WHERE ${conditions.join(" AND ")} ORDER BY m.created_at ASC LIMIT ?`;
|
|
438
|
+
params.push(limit);
|
|
439
|
+
|
|
440
|
+
return (db.prepare(sql).all(...params) as any[]).map(rowToMessage);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export function markRead(messageId: number, agentId: string): boolean {
|
|
444
|
+
const exists = db.prepare("SELECT 1 FROM messages WHERE id = ?").get(messageId);
|
|
445
|
+
if (!exists) return false;
|
|
446
|
+
db.prepare(
|
|
447
|
+
"INSERT OR IGNORE INTO message_reads (message_id, agent_id, read_at) VALUES (?, ?, ?)"
|
|
448
|
+
).run(messageId, agentId, Date.now());
|
|
449
|
+
return true;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export function deleteMessage(id: number): boolean {
|
|
453
|
+
return db.transaction(() => {
|
|
454
|
+
// Break reply_to references from children so the FK doesn't block delete.
|
|
455
|
+
// Children keep their thread_id — the thread shows up minus this message.
|
|
456
|
+
db.prepare("UPDATE messages SET reply_to = NULL WHERE reply_to = ?").run(id);
|
|
457
|
+
return db.prepare("DELETE FROM messages WHERE id = ?").run(id).changes > 0;
|
|
458
|
+
})();
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export function getLatestMessageId(): number {
|
|
462
|
+
const row = db.prepare("SELECT MAX(id) as id FROM messages").get() as any;
|
|
463
|
+
return row?.id ?? 0;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export function pruneOldMessages(maxAgeMs: number): number {
|
|
467
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
468
|
+
return db
|
|
469
|
+
.prepare("DELETE FROM messages WHERE created_at < ?")
|
|
470
|
+
.run(cutoff).changes;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export function getStats(): {
|
|
474
|
+
agents: number;
|
|
475
|
+
online: number;
|
|
476
|
+
messages: number;
|
|
477
|
+
messagesLast24h: number;
|
|
478
|
+
} {
|
|
479
|
+
const agents = (
|
|
480
|
+
db.prepare("SELECT COUNT(*) as c FROM agents").get() as any
|
|
481
|
+
).c;
|
|
482
|
+
const online = (
|
|
483
|
+
db
|
|
484
|
+
.prepare(
|
|
485
|
+
"SELECT COUNT(*) as c FROM agents WHERE status != 'offline' AND last_seen > ?"
|
|
486
|
+
)
|
|
487
|
+
.get(Date.now() - STALE_TTL_MS) as any
|
|
488
|
+
).c;
|
|
489
|
+
const messages = (
|
|
490
|
+
db.prepare("SELECT COUNT(*) as c FROM messages").get() as any
|
|
491
|
+
).c;
|
|
492
|
+
const messagesLast24h = (
|
|
493
|
+
db
|
|
494
|
+
.prepare("SELECT COUNT(*) as c FROM messages WHERE created_at > ?")
|
|
495
|
+
.get(Date.now() - DAY_MS) as any
|
|
496
|
+
).c;
|
|
497
|
+
|
|
498
|
+
return { agents, online, messages, messagesLast24h };
|
|
499
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { initDb, reapStaleAgents, pruneOldMessages } from "./db";
|
|
3
|
+
import { matchRoute } from "./routes";
|
|
4
|
+
import { resolve, sep } from "path";
|
|
5
|
+
import { REAP_INTERVAL_MS, STALE_TTL_MS, MAX_BODY_BYTES, DAY_MS } from "./config";
|
|
6
|
+
|
|
7
|
+
const PORT = Number(process.env.PORT) || 4850;
|
|
8
|
+
const HOST = process.env.HOST || "127.0.0.1";
|
|
9
|
+
const DB_PATH = process.env.DB_PATH || "agent-relay.db";
|
|
10
|
+
const RETENTION_DAYS = Number(process.env.RETENTION_DAYS) || 30;
|
|
11
|
+
const LOG_REQUESTS = process.env.AGENT_RELAY_LOG_REQUESTS === "1";
|
|
12
|
+
|
|
13
|
+
initDb(DB_PATH);
|
|
14
|
+
|
|
15
|
+
setInterval(() => {
|
|
16
|
+
const reaped = reapStaleAgents(STALE_TTL_MS);
|
|
17
|
+
if (reaped > 0) console.log(`reaped ${reaped} stale agent(s)`);
|
|
18
|
+
}, REAP_INTERVAL_MS);
|
|
19
|
+
|
|
20
|
+
// Daily message prune
|
|
21
|
+
setInterval(() => {
|
|
22
|
+
const pruned = pruneOldMessages(RETENTION_DAYS * DAY_MS);
|
|
23
|
+
if (pruned > 0) console.log(`pruned ${pruned} old message(s)`);
|
|
24
|
+
}, DAY_MS);
|
|
25
|
+
|
|
26
|
+
const publicDir = resolve(import.meta.dir, "../public");
|
|
27
|
+
const publicDirPrefix = publicDir + sep;
|
|
28
|
+
|
|
29
|
+
Bun.serve({
|
|
30
|
+
port: PORT,
|
|
31
|
+
hostname: HOST,
|
|
32
|
+
async fetch(req) {
|
|
33
|
+
const url = new URL(req.url);
|
|
34
|
+
|
|
35
|
+
// CORS
|
|
36
|
+
if (req.method === "OPTIONS") {
|
|
37
|
+
return new Response(null, {
|
|
38
|
+
headers: {
|
|
39
|
+
"Access-Control-Allow-Origin": "*",
|
|
40
|
+
"Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
|
|
41
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Body size guard for write methods
|
|
47
|
+
if (req.method === "POST" || req.method === "PATCH" || req.method === "PUT") {
|
|
48
|
+
const len = Number(req.headers.get("content-length") ?? 0);
|
|
49
|
+
if (len > MAX_BODY_BYTES) {
|
|
50
|
+
return Response.json(
|
|
51
|
+
{ error: `request body exceeds ${MAX_BODY_BYTES} bytes` },
|
|
52
|
+
{ status: 413 },
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// API routes
|
|
58
|
+
const matched = matchRoute(req.method, url.pathname);
|
|
59
|
+
if (matched) {
|
|
60
|
+
const response = await matched.handler(req, matched.params);
|
|
61
|
+
response.headers.set("Access-Control-Allow-Origin", "*");
|
|
62
|
+
if (LOG_REQUESTS && url.pathname.startsWith("/api/")) {
|
|
63
|
+
console.log(`${req.method} ${url.pathname} → ${response.status}`);
|
|
64
|
+
}
|
|
65
|
+
return response;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Dashboard — serve static files, rejecting path traversal and directory requests
|
|
69
|
+
let requested = url.pathname === "/" ? "/index.html" : url.pathname;
|
|
70
|
+
if (requested.endsWith("/")) requested += "index.html";
|
|
71
|
+
const resolved = resolve(publicDir, `.${requested}`);
|
|
72
|
+
if (!resolved.startsWith(publicDirPrefix)) {
|
|
73
|
+
return Response.json({ error: "not found" }, { status: 404 });
|
|
74
|
+
}
|
|
75
|
+
const file = Bun.file(resolved);
|
|
76
|
+
if (await file.exists()) return new Response(file);
|
|
77
|
+
|
|
78
|
+
return Response.json({ error: "not found" }, { status: 404 });
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
console.log(`agent-relay running on http://localhost:${PORT}`);
|