agent-relay-server 0.15.1 → 0.17.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/docs/openapi.json +101 -1
- package/package.json +2 -2
- package/public/index.html +75 -3
- package/src/automations.ts +19 -18
- package/src/bus-outbox.ts +5 -5
- package/src/bus.ts +1 -1
- package/src/commands-db.ts +5 -5
- package/src/config-store.ts +71 -12
- package/src/db.ts +311 -229
- package/src/insights-db.ts +6 -6
- package/src/lifecycle-manager.ts +3 -3
- package/src/maintenance.ts +25 -6
- package/src/managed-policy.ts +2 -1
- package/src/memory-sqlite-broker.ts +12 -12
- package/src/provider-catalog-store.ts +2 -2
- package/src/recipe-db.ts +9 -9
- package/src/routes.ts +44 -0
- package/src/security.ts +1 -1
- package/src/token-db.ts +10 -10
package/src/db.ts
CHANGED
|
@@ -76,6 +76,59 @@ import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_ME
|
|
|
76
76
|
let db: Database;
|
|
77
77
|
const CONTEXT_SNAPSHOT_DEBOUNCE_MS = 60_000;
|
|
78
78
|
|
|
79
|
+
// Connection-scoped pragmas. journal_mode and auto_vacuum persist in the file;
|
|
80
|
+
// the rest reset per connection, so they must be re-applied on every open.
|
|
81
|
+
function applyConnectionPragmas(conn: Database): void {
|
|
82
|
+
conn.run("PRAGMA journal_mode = WAL");
|
|
83
|
+
// Wait up to 5s for a held lock instead of failing instantly with SQLITE_BUSY.
|
|
84
|
+
// Matters with many agents polling/writing concurrently.
|
|
85
|
+
conn.run("PRAGMA busy_timeout = 5000");
|
|
86
|
+
// NORMAL is the recommended durability level under WAL: as safe as FULL except
|
|
87
|
+
// for a power loss mid-checkpoint, and meaningfully faster on writes.
|
|
88
|
+
conn.run("PRAGMA synchronous = NORMAL");
|
|
89
|
+
conn.run("PRAGMA foreign_keys = ON");
|
|
90
|
+
// ~8MB page cache (negative = KiB) and 256MB mmap window for the read-heavy
|
|
91
|
+
// poll workload. Both are best-effort; SQLite ignores them if unsupported.
|
|
92
|
+
conn.run("PRAGMA cache_size = -8000");
|
|
93
|
+
conn.run("PRAGMA mmap_size = 268435456");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Slow-query log. Off unless AGENT_RELAY_DB_SLOW_MS is set (>0). Wraps the
|
|
97
|
+
// execution of the heavy dynamic queries so the next slow query surfaces before
|
|
98
|
+
// it wedges something (see issue #197 — the 8.6s reply-obligation query).
|
|
99
|
+
const SLOW_QUERY_MS = Number(process.env.AGENT_RELAY_DB_SLOW_MS) || 0;
|
|
100
|
+
function timedQuery<T>(label: string, run: () => T): T {
|
|
101
|
+
if (SLOW_QUERY_MS <= 0) return run();
|
|
102
|
+
const start = performance.now();
|
|
103
|
+
try {
|
|
104
|
+
return run();
|
|
105
|
+
} finally {
|
|
106
|
+
const ms = performance.now() - start;
|
|
107
|
+
if (ms >= SLOW_QUERY_MS) {
|
|
108
|
+
console.warn(`[db.slow] ${label} took ${ms.toFixed(1)}ms (threshold ${SLOW_QUERY_MS}ms)`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Periodic DB upkeep, invoked by the maintenance scheduler. ANALYZE refreshes
|
|
114
|
+
// planner statistics, PRAGMA optimize applies them, wal_checkpoint(TRUNCATE)
|
|
115
|
+
// bounds WAL growth, and VACUUM (opt-in) reclaims space since auto_vacuum is off.
|
|
116
|
+
export function runDbMaintenance(options: { vacuum?: boolean } = {}): {
|
|
117
|
+
analyzed: boolean;
|
|
118
|
+
checkpointed: boolean;
|
|
119
|
+
vacuumed: boolean;
|
|
120
|
+
} {
|
|
121
|
+
db.run("ANALYZE");
|
|
122
|
+
db.run("PRAGMA optimize");
|
|
123
|
+
db.run("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
124
|
+
let vacuumed = false;
|
|
125
|
+
if (options.vacuum) {
|
|
126
|
+
db.run("VACUUM");
|
|
127
|
+
vacuumed = true;
|
|
128
|
+
}
|
|
129
|
+
return { analyzed: true, checkpointed: true, vacuumed };
|
|
130
|
+
}
|
|
131
|
+
|
|
79
132
|
const REACTION_EMOJI_ALIASES: Record<string, string> = {
|
|
80
133
|
"+1": "👍",
|
|
81
134
|
":+1:": "👍",
|
|
@@ -110,7 +163,7 @@ export function normalizeReactionEmoji(value: string): string {
|
|
|
110
163
|
}
|
|
111
164
|
|
|
112
165
|
function normalizeExistingMessageReactions(): void {
|
|
113
|
-
const rows = db.
|
|
166
|
+
const rows = db.query("SELECT message_id, actor_id, emoji, created_at, updated_at FROM message_reactions").all() as Array<{
|
|
114
167
|
message_id: number;
|
|
115
168
|
actor_id: string;
|
|
116
169
|
emoji: string;
|
|
@@ -118,14 +171,14 @@ function normalizeExistingMessageReactions(): void {
|
|
|
118
171
|
updated_at: number;
|
|
119
172
|
}>;
|
|
120
173
|
const migrate = db.transaction((items: typeof rows) => {
|
|
121
|
-
const upsert = db.
|
|
174
|
+
const upsert = db.query(`
|
|
122
175
|
INSERT INTO message_reactions (message_id, actor_id, emoji, created_at, updated_at)
|
|
123
176
|
VALUES (?, ?, ?, ?, ?)
|
|
124
177
|
ON CONFLICT(message_id, actor_id, emoji) DO UPDATE SET
|
|
125
178
|
created_at = min(message_reactions.created_at, excluded.created_at),
|
|
126
179
|
updated_at = max(message_reactions.updated_at, excluded.updated_at)
|
|
127
180
|
`);
|
|
128
|
-
const remove = db.
|
|
181
|
+
const remove = db.query("DELETE FROM message_reactions WHERE message_id = ? AND actor_id = ? AND emoji = ?");
|
|
129
182
|
for (const row of items) {
|
|
130
183
|
const normalized = normalizeReactionEmoji(row.emoji);
|
|
131
184
|
if (normalized === row.emoji) continue;
|
|
@@ -138,8 +191,7 @@ function normalizeExistingMessageReactions(): void {
|
|
|
138
191
|
|
|
139
192
|
export function initDb(path: string = "agent-relay.db"): Database {
|
|
140
193
|
db = new Database(path, { create: true });
|
|
141
|
-
db
|
|
142
|
-
db.run("PRAGMA foreign_keys = ON");
|
|
194
|
+
applyConnectionPragmas(db);
|
|
143
195
|
|
|
144
196
|
db.run(`
|
|
145
197
|
CREATE TABLE IF NOT EXISTS agents (
|
|
@@ -187,7 +239,8 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
187
239
|
payload TEXT NOT NULL DEFAULT '{}',
|
|
188
240
|
meta TEXT NOT NULL DEFAULT '{}',
|
|
189
241
|
read_by TEXT NOT NULL DEFAULT '[]',
|
|
190
|
-
created_at INTEGER NOT NULL
|
|
242
|
+
created_at INTEGER NOT NULL,
|
|
243
|
+
occurred_at INTEGER
|
|
191
244
|
);
|
|
192
245
|
|
|
193
246
|
CREATE INDEX IF NOT EXISTS idx_msg_to ON messages(to_target);
|
|
@@ -794,7 +847,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
794
847
|
normalizeExistingMessageReactions();
|
|
795
848
|
|
|
796
849
|
// Migrations
|
|
797
|
-
const cols = db.
|
|
850
|
+
const cols = db.query("PRAGMA table_info(messages)").all() as any[];
|
|
798
851
|
const colNames = cols.map((c: any) => c.name);
|
|
799
852
|
if (!colNames.includes("thread_id")) {
|
|
800
853
|
db.run("ALTER TABLE messages ADD COLUMN thread_id INTEGER");
|
|
@@ -838,7 +891,12 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
838
891
|
if (!colNames.includes("resolved_to_agent")) {
|
|
839
892
|
db.run("ALTER TABLE messages ADD COLUMN resolved_to_agent TEXT");
|
|
840
893
|
}
|
|
841
|
-
|
|
894
|
+
// Event time (#196): when a Runner queues a message in its durable outbox during an
|
|
895
|
+
// outage, occurred_at preserves when it really happened vs. the later receive time.
|
|
896
|
+
if (!colNames.includes("occurred_at")) {
|
|
897
|
+
db.run("ALTER TABLE messages ADD COLUMN occurred_at INTEGER");
|
|
898
|
+
}
|
|
899
|
+
db.query(
|
|
842
900
|
"UPDATE messages SET claim_expires_at = coalesce(claimed_at, ?) + ? WHERE claimed_by IS NOT NULL AND claim_expires_at IS NULL",
|
|
843
901
|
).run(Date.now(), CLAIM_LEASE_MS);
|
|
844
902
|
db.run("CREATE INDEX IF NOT EXISTS idx_msg_thread ON messages(thread_id)");
|
|
@@ -846,7 +904,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
846
904
|
db.run("CREATE INDEX IF NOT EXISTS idx_msg_delivery_status ON messages(delivery_status)");
|
|
847
905
|
db.run("CREATE INDEX IF NOT EXISTS idx_msg_resolved_to_agent ON messages(resolved_to_agent)");
|
|
848
906
|
|
|
849
|
-
const tokenCols = db.
|
|
907
|
+
const tokenCols = db.query("PRAGMA table_info(tokens)").all() as any[];
|
|
850
908
|
const tokenColNames = tokenCols.map((c: any) => c.name);
|
|
851
909
|
if (!tokenColNames.includes("constraints")) {
|
|
852
910
|
db.run("ALTER TABLE tokens ADD COLUMN constraints TEXT");
|
|
@@ -870,7 +928,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
870
928
|
`);
|
|
871
929
|
db.run("CREATE INDEX IF NOT EXISTS idx_mda_message ON message_delivery_attempts(message_id, created_at DESC)");
|
|
872
930
|
|
|
873
|
-
const channelBindingsSql = db.
|
|
931
|
+
const channelBindingsSql = db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'channel_bindings'").get() as { sql?: string } | undefined;
|
|
874
932
|
if (channelBindingsSql?.sql?.includes("UNIQUE(channel_id, conversation_key)")) {
|
|
875
933
|
db.transaction(() => {
|
|
876
934
|
db.run("ALTER TABLE channel_bindings RENAME TO channel_bindings_old");
|
|
@@ -899,7 +957,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
899
957
|
db.run("CREATE INDEX IF NOT EXISTS idx_channel_bindings_channel ON channel_bindings(channel_id, priority)");
|
|
900
958
|
db.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_bindings_target ON channel_bindings(channel_id, conversation_key, target_type, target_id)");
|
|
901
959
|
|
|
902
|
-
const bindingColNames = (db.
|
|
960
|
+
const bindingColNames = (db.query("PRAGMA table_info(channel_bindings)").all() as any[]).map((c: any) => c.name);
|
|
903
961
|
if (!bindingColNames.includes("pool_selector")) {
|
|
904
962
|
db.run("ALTER TABLE channel_bindings ADD COLUMN pool_selector TEXT");
|
|
905
963
|
db.run("ALTER TABLE channel_bindings ADD COLUMN pool_agent_id TEXT");
|
|
@@ -918,23 +976,23 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
918
976
|
// Backfill thread_id for pre-migration rows (self-threaded).
|
|
919
977
|
db.run("UPDATE messages SET thread_id = id WHERE thread_id IS NULL");
|
|
920
978
|
|
|
921
|
-
const taskCols = db.
|
|
979
|
+
const taskCols = db.query("PRAGMA table_info(tasks)").all() as any[];
|
|
922
980
|
const taskColNames = taskCols.map((c: any) => c.name);
|
|
923
981
|
if (!taskColNames.includes("claim_expires_at")) {
|
|
924
982
|
db.run("ALTER TABLE tasks ADD COLUMN claim_expires_at INTEGER");
|
|
925
983
|
}
|
|
926
|
-
db.
|
|
984
|
+
db.query(
|
|
927
985
|
"UPDATE tasks SET claim_expires_at = coalesce(claimed_at, ?) + ? WHERE claimed_by IS NOT NULL AND claim_expires_at IS NULL",
|
|
928
986
|
).run(Date.now(), CLAIM_LEASE_MS);
|
|
929
987
|
|
|
930
988
|
// Migration: orchestrators.api_url
|
|
931
|
-
const orchCols = db.
|
|
989
|
+
const orchCols = db.query("PRAGMA table_info(orchestrators)").all() as any[];
|
|
932
990
|
const orchColNames = orchCols.map((c: any) => c.name);
|
|
933
991
|
if (!orchColNames.includes("api_url")) {
|
|
934
992
|
db.run("ALTER TABLE orchestrators ADD COLUMN api_url TEXT");
|
|
935
993
|
}
|
|
936
994
|
|
|
937
|
-
const managedStateCols = db.
|
|
995
|
+
const managedStateCols = db.query("PRAGMA table_info(managed_agent_state)").all() as any[];
|
|
938
996
|
const managedStateColNames = managedStateCols.map((c: any) => c.name);
|
|
939
997
|
if (!managedStateColNames.includes("workspace_id")) {
|
|
940
998
|
db.run("ALTER TABLE managed_agent_state ADD COLUMN workspace_id TEXT");
|
|
@@ -954,7 +1012,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
954
1012
|
`);
|
|
955
1013
|
|
|
956
1014
|
// Migration: agents.label
|
|
957
|
-
const agentCols = db.
|
|
1015
|
+
const agentCols = db.query("PRAGMA table_info(agents)").all() as any[];
|
|
958
1016
|
const agentColNames = agentCols.map((c: any) => c.name);
|
|
959
1017
|
if (!agentColNames.includes("label")) {
|
|
960
1018
|
db.run("ALTER TABLE agents ADD COLUMN label TEXT");
|
|
@@ -1076,7 +1134,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
1076
1134
|
// pass the sendMessage validation. The reaper exempts these by checking
|
|
1077
1135
|
// meta.builtin (or by id for "user").
|
|
1078
1136
|
const now = Date.now();
|
|
1079
|
-
const builtinStmt = db.
|
|
1137
|
+
const builtinStmt = db.query(`
|
|
1080
1138
|
INSERT INTO agents (id, name, kind, tags, machine, rig, capabilities, ready, status, meta, last_seen, created_at)
|
|
1081
1139
|
VALUES (?, ?, ?, ?, NULL, NULL, '[]', 1, 'online', '{"builtin":true}', ?, ?)
|
|
1082
1140
|
ON CONFLICT(id) DO UPDATE SET status = 'online', ready = 1, last_seen = excluded.last_seen
|
|
@@ -1095,6 +1153,15 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
1095
1153
|
`);
|
|
1096
1154
|
}
|
|
1097
1155
|
|
|
1156
|
+
// Bootstrap planner statistics on first run (sqlite_stat1 absent means ANALYZE
|
|
1157
|
+
// has never run — the planner would otherwise rely on heuristics only), then
|
|
1158
|
+
// let PRAGMA optimize apply/refresh them cheaply on every startup.
|
|
1159
|
+
const hasStats = db
|
|
1160
|
+
.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'sqlite_stat1'")
|
|
1161
|
+
.get();
|
|
1162
|
+
if (!hasStats) db.run("ANALYZE");
|
|
1163
|
+
db.run("PRAGMA optimize");
|
|
1164
|
+
|
|
1098
1165
|
return db;
|
|
1099
1166
|
}
|
|
1100
1167
|
|
|
@@ -1247,6 +1314,7 @@ function rowToMessage(row: any): Message {
|
|
|
1247
1314
|
reactions: parseJson(row.reactions_json ?? "[]", []),
|
|
1248
1315
|
readBy: parseJson(row.read_by_agents ?? "[]", []),
|
|
1249
1316
|
createdAt: row.created_at,
|
|
1317
|
+
occurredAt: row.occurred_at ?? undefined,
|
|
1250
1318
|
};
|
|
1251
1319
|
}
|
|
1252
1320
|
|
|
@@ -1687,7 +1755,7 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
|
|
|
1687
1755
|
const labelProvided = Object.prototype.hasOwnProperty.call(input, "label");
|
|
1688
1756
|
const readyProvided = Object.prototype.hasOwnProperty.call(input, "ready");
|
|
1689
1757
|
const instanceProvided = Boolean(input.instanceId);
|
|
1690
|
-
const stmt = db.
|
|
1758
|
+
const stmt = db.query(`
|
|
1691
1759
|
INSERT INTO agents (id, name, kind, label, tags, machine, rig, capabilities, ready, status, instance_id, epoch, provider_capabilities, context_state, meta, last_seen, created_at)
|
|
1692
1760
|
VALUES ($id, $name, $kind, $label, $tags, $machine, $rig, $capabilities, $ready, $status, $instanceId, $initialEpoch, $providerCapabilities, $contextState, $meta, $now, $now)
|
|
1693
1761
|
ON CONFLICT(id) DO UPDATE SET
|
|
@@ -1759,18 +1827,18 @@ export function validateAgentSession(id: string, guard?: AgentSessionGuard): { o
|
|
|
1759
1827
|
export function setLabel(id: string, label: string | null): boolean {
|
|
1760
1828
|
const normalized = label && label.trim() ? label.trim() : null;
|
|
1761
1829
|
return (
|
|
1762
|
-
db.
|
|
1830
|
+
db.query("UPDATE agents SET label = ? WHERE id = ?").run(normalized, id).changes > 0
|
|
1763
1831
|
);
|
|
1764
1832
|
}
|
|
1765
1833
|
|
|
1766
1834
|
export function setTags(id: string, tags: string[]): AgentCard | null {
|
|
1767
1835
|
const normalized = [...new Set(tags.map((tag) => tag.trim()).filter(Boolean))];
|
|
1768
|
-
const result = db.
|
|
1836
|
+
const result = db.query("UPDATE agents SET tags = ?, last_seen = ? WHERE id = ?").run(JSON.stringify(normalized), Date.now(), id);
|
|
1769
1837
|
return result.changes > 0 ? getAgent(id) : null;
|
|
1770
1838
|
}
|
|
1771
1839
|
|
|
1772
1840
|
export function getAgent(id: string): AgentCard | null {
|
|
1773
|
-
const row = db.
|
|
1841
|
+
const row = db.query("SELECT * FROM agents WHERE id = ?").get(id) as any;
|
|
1774
1842
|
return row ? rowToAgent(row) : null;
|
|
1775
1843
|
}
|
|
1776
1844
|
|
|
@@ -1796,7 +1864,7 @@ export function listAgents(filter?: {
|
|
|
1796
1864
|
}
|
|
1797
1865
|
|
|
1798
1866
|
sql += " ORDER BY last_seen DESC";
|
|
1799
|
-
return (db.
|
|
1867
|
+
return (db.query(sql).all(...params) as any[]).map(rowToAgent);
|
|
1800
1868
|
}
|
|
1801
1869
|
|
|
1802
1870
|
export function setStatus(id: string, status: AgentCard["status"], guard?: AgentSessionGuard): boolean {
|
|
@@ -1806,7 +1874,7 @@ export function setStatus(id: string, status: AgentCard["status"], guard?: Agent
|
|
|
1806
1874
|
const sql = ready === 0
|
|
1807
1875
|
? "UPDATE agents SET status = ?, ready = 0, last_seen = ? WHERE id = ?"
|
|
1808
1876
|
: "UPDATE agents SET status = ?, last_seen = ? WHERE id = ?";
|
|
1809
|
-
const changed = db.
|
|
1877
|
+
const changed = db.query(sql).run(status, now, id).changes > 0;
|
|
1810
1878
|
if (changed && status === "offline") closeOpenPairsForAgent(id, now);
|
|
1811
1879
|
if (changed && status === "offline") electWorkspaceStewards();
|
|
1812
1880
|
return changed;
|
|
@@ -1817,7 +1885,7 @@ export function markReady(id: string, ready: boolean, guard?: AgentSessionGuard)
|
|
|
1817
1885
|
const now = Date.now();
|
|
1818
1886
|
return (
|
|
1819
1887
|
db
|
|
1820
|
-
.
|
|
1888
|
+
.query("UPDATE agents SET ready = ?, last_seen = ? WHERE id = ?")
|
|
1821
1889
|
.run(ready ? 1 : 0, now, id).changes > 0
|
|
1822
1890
|
);
|
|
1823
1891
|
}
|
|
@@ -1828,7 +1896,7 @@ export function mergeAgentMeta(id: string, meta: Record<string, unknown>, guard?
|
|
|
1828
1896
|
if (!agent) return false;
|
|
1829
1897
|
const merged = { ...(agent.meta ?? {}), ...meta };
|
|
1830
1898
|
const result = db
|
|
1831
|
-
.
|
|
1899
|
+
.query("UPDATE agents SET meta = ?, last_seen = ? WHERE id = ?")
|
|
1832
1900
|
.run(JSON.stringify(merged), Date.now(), id);
|
|
1833
1901
|
return result.changes > 0;
|
|
1834
1902
|
}
|
|
@@ -1844,7 +1912,7 @@ export function recordAgentExitDiagnostics(id: string, diagnostics: ManagedSessi
|
|
|
1844
1912
|
lastExitAt: diagnostics.detectedAt || now,
|
|
1845
1913
|
};
|
|
1846
1914
|
const result = db
|
|
1847
|
-
.
|
|
1915
|
+
.query("UPDATE agents SET status = 'offline', ready = 0, meta = ?, last_seen = ? WHERE id = ?")
|
|
1848
1916
|
.run(JSON.stringify(merged), now, id);
|
|
1849
1917
|
if (result.changes <= 0) return null;
|
|
1850
1918
|
closeOpenPairsForAgent(id, now);
|
|
@@ -1860,7 +1928,7 @@ export function heartbeat(
|
|
|
1860
1928
|
const now = Date.now();
|
|
1861
1929
|
if (runtime?.providerCapabilities || runtime?.context) {
|
|
1862
1930
|
const result = db
|
|
1863
|
-
.
|
|
1931
|
+
.query(`
|
|
1864
1932
|
UPDATE agents SET
|
|
1865
1933
|
last_seen = ?,
|
|
1866
1934
|
status = CASE WHEN status = 'offline' THEN 'idle' ELSE status END,
|
|
@@ -1878,7 +1946,7 @@ export function heartbeat(
|
|
|
1878
1946
|
return result.changes > 0;
|
|
1879
1947
|
}
|
|
1880
1948
|
const result = db
|
|
1881
|
-
.
|
|
1949
|
+
.query("UPDATE agents SET last_seen = ?, status = CASE WHEN status = 'offline' THEN 'idle' ELSE status END WHERE id = ?")
|
|
1882
1950
|
.run(now, id);
|
|
1883
1951
|
return result.changes > 0;
|
|
1884
1952
|
}
|
|
@@ -1893,23 +1961,23 @@ export function listContextSnapshots(agentId: string, options: { since?: number;
|
|
|
1893
1961
|
}
|
|
1894
1962
|
sql += " ORDER BY captured_at ASC LIMIT ?";
|
|
1895
1963
|
params.push(limit);
|
|
1896
|
-
return (db.
|
|
1964
|
+
return (db.query(sql).all(...params) as any[]).map(rowToContextSnapshot);
|
|
1897
1965
|
}
|
|
1898
1966
|
|
|
1899
1967
|
function pruneContextSnapshots(agentId?: string, olderThan = Date.now() - DAY_MS): number {
|
|
1900
1968
|
const result = agentId
|
|
1901
|
-
? db.
|
|
1902
|
-
: db.
|
|
1969
|
+
? db.query("DELETE FROM context_snapshots WHERE agent_id = ? AND captured_at < ?").run(agentId, olderThan)
|
|
1970
|
+
: db.query("DELETE FROM context_snapshots WHERE captured_at < ?").run(olderThan);
|
|
1903
1971
|
return result.changes;
|
|
1904
1972
|
}
|
|
1905
1973
|
|
|
1906
1974
|
function recordContextSnapshot(agentId: string, context: ContextState, now: number): void {
|
|
1907
1975
|
const latest = db
|
|
1908
|
-
.
|
|
1976
|
+
.query("SELECT captured_at FROM context_snapshots WHERE agent_id = ? ORDER BY captured_at DESC LIMIT 1")
|
|
1909
1977
|
.get(agentId) as { captured_at: number } | undefined;
|
|
1910
1978
|
if (latest && latest.captured_at > now - CONTEXT_SNAPSHOT_DEBOUNCE_MS) return;
|
|
1911
1979
|
|
|
1912
|
-
db.
|
|
1980
|
+
db.query(`
|
|
1913
1981
|
INSERT INTO context_snapshots (
|
|
1914
1982
|
agent_id,
|
|
1915
1983
|
utilization,
|
|
@@ -1999,7 +2067,7 @@ function upsertChannelForAgent(agent: AgentCard): void {
|
|
|
1999
2067
|
? (agent.meta.topicChannels as unknown[]).filter((item): item is string => typeof item === "string" && item.trim().length > 0)
|
|
2000
2068
|
: [];
|
|
2001
2069
|
|
|
2002
|
-
db.
|
|
2070
|
+
db.query(`
|
|
2003
2071
|
INSERT INTO channels (id, provider, account_id, display_name, agent_id, transport, direction, topic_channels, capabilities, meta, created_at, updated_at)
|
|
2004
2072
|
VALUES ($id, $provider, $accountId, $displayName, $agentId, $transport, $direction, $topicChannels, $capabilities, $meta, $now, $now)
|
|
2005
2073
|
ON CONFLICT(provider, account_id) DO UPDATE SET
|
|
@@ -2039,7 +2107,7 @@ function upsertChannelForAgent(agent: AgentCard): void {
|
|
|
2039
2107
|
}
|
|
2040
2108
|
|
|
2041
2109
|
export function listChannels(): ChannelSummary[] {
|
|
2042
|
-
const rows = db.
|
|
2110
|
+
const rows = db.query(`
|
|
2043
2111
|
SELECT
|
|
2044
2112
|
c.*,
|
|
2045
2113
|
c.capabilities AS channel_capabilities,
|
|
@@ -2074,8 +2142,8 @@ export function getChannel(channelId: string): ChannelSummary | null {
|
|
|
2074
2142
|
|
|
2075
2143
|
export function listChannelBindings(channelId?: string): ChannelBinding[] {
|
|
2076
2144
|
const rows = channelId
|
|
2077
|
-
? db.
|
|
2078
|
-
: db.
|
|
2145
|
+
? db.query("SELECT * FROM channel_bindings WHERE channel_id = ? ORDER BY priority DESC, updated_at DESC").all(channelId) as any[]
|
|
2146
|
+
: db.query("SELECT * FROM channel_bindings ORDER BY channel_id, priority DESC, updated_at DESC").all() as any[];
|
|
2079
2147
|
return rows.map(rowToChannelBinding);
|
|
2080
2148
|
}
|
|
2081
2149
|
|
|
@@ -2097,9 +2165,9 @@ export function upsertChannelBinding(input: {
|
|
|
2097
2165
|
const now = Date.now();
|
|
2098
2166
|
db.transaction(() => {
|
|
2099
2167
|
if (mode === "exclusive") {
|
|
2100
|
-
db.
|
|
2168
|
+
db.query("DELETE FROM channel_bindings WHERE channel_id = ? AND conversation_key = ?").run(input.channelId, conversationKey);
|
|
2101
2169
|
}
|
|
2102
|
-
db.
|
|
2170
|
+
db.query(`
|
|
2103
2171
|
INSERT INTO channel_bindings (id, channel_id, conversation_key, conversation_id, target_type, target_id, mode, priority, pool_selector, created_at, updated_at)
|
|
2104
2172
|
VALUES ($id, $channelId, $conversationKey, $conversationId, $targetType, $targetId, $mode, $priority, $poolSelector, $now, $now)
|
|
2105
2173
|
ON CONFLICT(channel_id, conversation_key, target_type, target_id) DO UPDATE SET
|
|
@@ -2122,7 +2190,7 @@ export function upsertChannelBinding(input: {
|
|
|
2122
2190
|
})();
|
|
2123
2191
|
|
|
2124
2192
|
evaluatePoolBindings(now);
|
|
2125
|
-
const row = db.
|
|
2193
|
+
const row = db.query("SELECT * FROM channel_bindings WHERE id = ?").get(id) as any;
|
|
2126
2194
|
return rowToChannelBinding(row);
|
|
2127
2195
|
}
|
|
2128
2196
|
|
|
@@ -2134,7 +2202,7 @@ interface PoolBindingChange {
|
|
|
2134
2202
|
}
|
|
2135
2203
|
|
|
2136
2204
|
export function evaluatePoolBindings(now: number = Date.now()): PoolBindingChange[] {
|
|
2137
|
-
const rows = db.
|
|
2205
|
+
const rows = db.query("SELECT * FROM channel_bindings WHERE target_type = 'pool'").all() as any[];
|
|
2138
2206
|
const changes: PoolBindingChange[] = [];
|
|
2139
2207
|
|
|
2140
2208
|
for (const row of rows) {
|
|
@@ -2149,7 +2217,7 @@ export function evaluatePoolBindings(now: number = Date.now()): PoolBindingChang
|
|
|
2149
2217
|
const holder = getAgent(currentAgentId);
|
|
2150
2218
|
if (holder && holder.status !== "offline" && holder.ready && holder.lastSeen > now - STALE_TTL_MS && agentCanServeChannel(holder, channelId)) {
|
|
2151
2219
|
if (currentEpoch === null || holder.epoch === currentEpoch) {
|
|
2152
|
-
db.
|
|
2220
|
+
db.query("UPDATE channel_bindings SET pool_claim_expires_at = ? WHERE id = ?")
|
|
2153
2221
|
.run(now + POOL_CLAIM_LEASE_MS, bindingId);
|
|
2154
2222
|
holderValid = true;
|
|
2155
2223
|
}
|
|
@@ -2157,7 +2225,7 @@ export function evaluatePoolBindings(now: number = Date.now()): PoolBindingChang
|
|
|
2157
2225
|
}
|
|
2158
2226
|
|
|
2159
2227
|
if (!holderValid && currentAgentId) {
|
|
2160
|
-
db.
|
|
2228
|
+
db.query("UPDATE channel_bindings SET pool_agent_id = NULL, pool_agent_epoch = NULL, pool_claim_expires_at = NULL WHERE id = ?")
|
|
2161
2229
|
.run(bindingId);
|
|
2162
2230
|
changes.push({ bindingId, channelId, previousAgentId: currentAgentId, newAgentId: null });
|
|
2163
2231
|
}
|
|
@@ -2173,7 +2241,7 @@ export function evaluatePoolBindings(now: number = Date.now()): PoolBindingChang
|
|
|
2173
2241
|
|
|
2174
2242
|
if (eligible.length > 0) {
|
|
2175
2243
|
const picked = eligible[0]!;
|
|
2176
|
-
db.
|
|
2244
|
+
db.query("UPDATE channel_bindings SET pool_agent_id = ?, pool_agent_epoch = ?, pool_claim_expires_at = ? WHERE id = ?")
|
|
2177
2245
|
.run(picked.id, picked.epoch, now + POOL_CLAIM_LEASE_MS, bindingId);
|
|
2178
2246
|
const lastChange = changes[changes.length - 1];
|
|
2179
2247
|
if (lastChange && lastChange.bindingId === bindingId && lastChange.newAgentId === null) {
|
|
@@ -2190,12 +2258,12 @@ export function evaluatePoolBindings(now: number = Date.now()): PoolBindingChang
|
|
|
2190
2258
|
|
|
2191
2259
|
export function resolveChannelRoutes(channelId: string, conversationId?: string): ChannelBinding[] {
|
|
2192
2260
|
const rows = conversationId
|
|
2193
|
-
? db.
|
|
2261
|
+
? db.query(`
|
|
2194
2262
|
SELECT * FROM channel_bindings
|
|
2195
2263
|
WHERE channel_id = ? AND conversation_key IN (?, '')
|
|
2196
2264
|
ORDER BY CASE WHEN conversation_key = ? THEN 1 ELSE 0 END DESC, priority DESC, updated_at DESC
|
|
2197
2265
|
`).all(channelId, conversationId, conversationId) as any[]
|
|
2198
|
-
: db.
|
|
2266
|
+
: db.query(`
|
|
2199
2267
|
SELECT * FROM channel_bindings
|
|
2200
2268
|
WHERE channel_id = ? AND conversation_key = ''
|
|
2201
2269
|
ORDER BY priority DESC, updated_at DESC
|
|
@@ -2208,9 +2276,9 @@ export function resolveChannelRoutes(channelId: string, conversationId?: string)
|
|
|
2208
2276
|
export function reapStaleAgents(ttlMs: number = STALE_TTL_MS): string[] {
|
|
2209
2277
|
const now = Date.now();
|
|
2210
2278
|
const cutoff = now - ttlMs;
|
|
2211
|
-
db.
|
|
2279
|
+
db.query("UPDATE agents SET last_seen = ? WHERE id IN ('user', 'system')").run(now);
|
|
2212
2280
|
const rows = db
|
|
2213
|
-
.
|
|
2281
|
+
.query(
|
|
2214
2282
|
"UPDATE agents SET status = 'offline', ready = 0 WHERE status NOT IN ('offline', 'stale') AND last_seen < ? AND id NOT IN ('user', 'system') RETURNING id"
|
|
2215
2283
|
)
|
|
2216
2284
|
.all(cutoff) as any[];
|
|
@@ -2232,9 +2300,9 @@ export function reapStaleAgents(ttlMs: number = STALE_TTL_MS): string[] {
|
|
|
2232
2300
|
// re-open (gated on status IN claimed/in_progress/blocked/orphaned) then skips.
|
|
2233
2301
|
function settleSingleTargetOnDemandTasks(condition: string, params: any[], now: number, reason: string): void {
|
|
2234
2302
|
const selectSql = `${TASK_SELECT} WHERE (${condition}) AND json_extract(metadata, '$.targetMode') = 'on_demand_agent' AND status IN ('claimed', 'in_progress', 'blocked', 'orphaned')`;
|
|
2235
|
-
const rows = db.
|
|
2303
|
+
const rows = db.query(selectSql).all(...params) as any[];
|
|
2236
2304
|
if (rows.length === 0) return;
|
|
2237
|
-
db.
|
|
2305
|
+
db.query(
|
|
2238
2306
|
`UPDATE tasks SET status = 'done', claim_expires_at = NULL, updated_at = ?, last_seen_at = ? WHERE (${condition}) AND json_extract(metadata, '$.targetMode') = 'on_demand_agent' AND status IN ('claimed', 'in_progress', 'blocked', 'orphaned')`
|
|
2239
2307
|
).run(now, now, ...params);
|
|
2240
2308
|
for (const row of rows) {
|
|
@@ -2253,7 +2321,7 @@ export function pruneOfflineAgents(maxOfflineMs: number = DAY_MS): string[] {
|
|
|
2253
2321
|
const cutoff = Date.now() - maxOfflineMs;
|
|
2254
2322
|
return db.transaction(() => {
|
|
2255
2323
|
const rows = db
|
|
2256
|
-
.
|
|
2324
|
+
.query(
|
|
2257
2325
|
"SELECT id FROM agents WHERE status = 'offline' AND last_seen < ? AND id NOT IN ('user', 'system')"
|
|
2258
2326
|
)
|
|
2259
2327
|
.all(cutoff) as any[];
|
|
@@ -2262,7 +2330,7 @@ export function pruneOfflineAgents(maxOfflineMs: number = DAY_MS): string[] {
|
|
|
2262
2330
|
|
|
2263
2331
|
// Release claims held by pruned agents so work becomes claimable again.
|
|
2264
2332
|
db
|
|
2265
|
-
.
|
|
2333
|
+
.query(
|
|
2266
2334
|
"UPDATE messages SET claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL WHERE claimed_by IN (SELECT id FROM agents WHERE status = 'offline' AND last_seen < ? AND id NOT IN ('user', 'system'))"
|
|
2267
2335
|
)
|
|
2268
2336
|
.run(cutoff);
|
|
@@ -2271,7 +2339,7 @@ export function pruneOfflineAgents(maxOfflineMs: number = DAY_MS): string[] {
|
|
|
2271
2339
|
settleSingleTargetOnDemandTasks(offlineClaimCondition, [cutoff], now, "agent-pruned");
|
|
2272
2340
|
|
|
2273
2341
|
db
|
|
2274
|
-
.
|
|
2342
|
+
.query(
|
|
2275
2343
|
`UPDATE tasks SET status = 'open', claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL, updated_at = ? WHERE ${offlineClaimCondition} AND status IN ('claimed', 'in_progress', 'blocked')`
|
|
2276
2344
|
)
|
|
2277
2345
|
.run(now, cutoff);
|
|
@@ -2282,7 +2350,7 @@ export function pruneOfflineAgents(maxOfflineMs: number = DAY_MS): string[] {
|
|
|
2282
2350
|
}
|
|
2283
2351
|
|
|
2284
2352
|
db
|
|
2285
|
-
.
|
|
2353
|
+
.query(
|
|
2286
2354
|
"DELETE FROM agents WHERE status = 'offline' AND last_seen < ? AND id NOT IN ('user', 'system')"
|
|
2287
2355
|
)
|
|
2288
2356
|
.run(cutoff);
|
|
@@ -2299,7 +2367,7 @@ export function findAgentsByCapability(capability: string, onlineOnly = true): A
|
|
|
2299
2367
|
params.push(Date.now() - STALE_TTL_MS);
|
|
2300
2368
|
}
|
|
2301
2369
|
sql += " ORDER BY last_seen DESC";
|
|
2302
|
-
return (db.
|
|
2370
|
+
return (db.query(sql).all(...params) as any[]).map(rowToAgent);
|
|
2303
2371
|
}
|
|
2304
2372
|
|
|
2305
2373
|
export function deleteAgent(id: string): { ok: boolean; error?: string } {
|
|
@@ -2310,23 +2378,23 @@ export function deleteAgent(id: string): { ok: boolean; error?: string } {
|
|
|
2310
2378
|
// Release any claims held by this agent so the tasks become claimable again.
|
|
2311
2379
|
// from_agent is left intact as historical record.
|
|
2312
2380
|
const now = Date.now();
|
|
2313
|
-
db.
|
|
2381
|
+
db.query("UPDATE messages SET claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL WHERE claimed_by = ?").run(id);
|
|
2314
2382
|
settleSingleTargetOnDemandTasks("claimed_by = ?", [id], now, "agent-removed");
|
|
2315
|
-
db.
|
|
2383
|
+
db.query("UPDATE tasks SET status = 'open', claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL, updated_at = ? WHERE claimed_by = ? AND status IN ('claimed', 'in_progress', 'blocked')").run(now, id);
|
|
2316
2384
|
revokeRuntimeTokensForAgent(id, now);
|
|
2317
2385
|
closeOpenPairsForAgent(id, now);
|
|
2318
|
-
return db.
|
|
2386
|
+
return db.query("DELETE FROM agents WHERE id = ?").run(id).changes > 0;
|
|
2319
2387
|
})();
|
|
2320
2388
|
return deleted ? { ok: true } : { ok: false, error: "agent not found" };
|
|
2321
2389
|
}
|
|
2322
2390
|
|
|
2323
2391
|
export function revokeRuntimeTokensForAgent(agentId: string, now = Date.now()): string[] {
|
|
2324
|
-
const row = db.
|
|
2392
|
+
const row = db.query("SELECT meta FROM agents WHERE id = ?").get(agentId) as { meta?: string } | undefined;
|
|
2325
2393
|
const jtis = runtimeTokenJtisFromMeta(parseJson(row?.meta ?? "{}", {}));
|
|
2326
2394
|
if (jtis.length === 0) return [];
|
|
2327
2395
|
const revokedAt = Math.floor(now / 1000);
|
|
2328
2396
|
const placeholders = jtis.map(() => "?").join(",");
|
|
2329
|
-
const rows = db.
|
|
2397
|
+
const rows = db.query(`
|
|
2330
2398
|
UPDATE tokens
|
|
2331
2399
|
SET revoked_at = ?
|
|
2332
2400
|
WHERE revoked_at IS NULL
|
|
@@ -2360,13 +2428,13 @@ const TASK_SELECT = "SELECT * FROM tasks";
|
|
|
2360
2428
|
|
|
2361
2429
|
function findOpenTaskByDedupe(source: string, dedupeKey: string): Task | null {
|
|
2362
2430
|
const row = db
|
|
2363
|
-
.
|
|
2431
|
+
.query(`${TASK_SELECT} WHERE source = ? AND dedupe_key = ? AND status NOT IN ('done', 'failed', 'canceled') ORDER BY id DESC LIMIT 1`)
|
|
2364
2432
|
.get(source, dedupeKey) as any;
|
|
2365
2433
|
return row ? rowToTask(row) : null;
|
|
2366
2434
|
}
|
|
2367
2435
|
|
|
2368
2436
|
function insertTaskEvent(taskId: number, event: Required<Omit<TaskEvent, "id" | "taskId" | "createdAt">>, now: number): TaskEvent {
|
|
2369
|
-
const result = db.
|
|
2437
|
+
const result = db.query(`
|
|
2370
2438
|
INSERT INTO task_events (task_id, source, type, severity, title, body, metadata, created_at)
|
|
2371
2439
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
2372
2440
|
`).run(taskId, event.source, event.type, event.severity, event.title, event.body, JSON.stringify(event.metadata), now);
|
|
@@ -2374,7 +2442,7 @@ function insertTaskEvent(taskId: number, event: Required<Omit<TaskEvent, "id" |
|
|
|
2374
2442
|
}
|
|
2375
2443
|
|
|
2376
2444
|
function getTaskEvent(id: number): TaskEvent | null {
|
|
2377
|
-
const row = db.
|
|
2445
|
+
const row = db.query("SELECT * FROM task_events WHERE id = ?").get(id) as any;
|
|
2378
2446
|
return row ? rowToTaskEvent(row) : null;
|
|
2379
2447
|
}
|
|
2380
2448
|
|
|
@@ -2406,7 +2474,7 @@ export function ingestIntegrationEvent(input: IntegrationEventInput, integration
|
|
|
2406
2474
|
let created = false;
|
|
2407
2475
|
|
|
2408
2476
|
if (existing) {
|
|
2409
|
-
db.
|
|
2477
|
+
db.query(`
|
|
2410
2478
|
UPDATE tasks
|
|
2411
2479
|
SET title = ?, body = ?, severity = ?, target = ?, channel = ?, external_url = ?,
|
|
2412
2480
|
occurrence_count = occurrence_count + 1, metadata = ?, updated_at = ?, last_seen_at = ?,
|
|
@@ -2431,7 +2499,7 @@ export function ingestIntegrationEvent(input: IntegrationEventInput, integration
|
|
|
2431
2499
|
);
|
|
2432
2500
|
taskId = existing.id;
|
|
2433
2501
|
} else {
|
|
2434
|
-
const result = db.
|
|
2502
|
+
const result = db.query(`
|
|
2435
2503
|
INSERT INTO tasks (source, title, body, severity, status, target, channel, dedupe_key, external_url, metadata, created_at, updated_at, last_seen_at)
|
|
2436
2504
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2437
2505
|
`).run(
|
|
@@ -2485,7 +2553,7 @@ export function ingestIntegrationEvent(input: IntegrationEventInput, integration
|
|
|
2485
2553
|
...(attachmentRefs.length ? { attachments: attachmentRefs } : {}),
|
|
2486
2554
|
},
|
|
2487
2555
|
});
|
|
2488
|
-
db.
|
|
2556
|
+
db.query("UPDATE tasks SET message_id = ?, updated_at = ? WHERE id = ?").run(message.id, now, task.id);
|
|
2489
2557
|
}
|
|
2490
2558
|
|
|
2491
2559
|
return { task: getTask(taskId)!, event, created, message };
|
|
@@ -2493,7 +2561,7 @@ export function ingestIntegrationEvent(input: IntegrationEventInput, integration
|
|
|
2493
2561
|
}
|
|
2494
2562
|
|
|
2495
2563
|
export function getTask(id: number): Task | null {
|
|
2496
|
-
const row = db.
|
|
2564
|
+
const row = db.query(`${TASK_SELECT} WHERE id = ?`).get(id) as any;
|
|
2497
2565
|
return row ? rowToTask(row) : null;
|
|
2498
2566
|
}
|
|
2499
2567
|
|
|
@@ -2514,11 +2582,11 @@ export function listTasks(filter?: { status?: string; source?: string; target?:
|
|
|
2514
2582
|
}
|
|
2515
2583
|
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
2516
2584
|
params.push(filter?.limit ?? 100);
|
|
2517
|
-
return (db.
|
|
2585
|
+
return (db.query(`${TASK_SELECT} ${where} ORDER BY updated_at DESC LIMIT ?`).all(...params) as any[]).map(rowToTask);
|
|
2518
2586
|
}
|
|
2519
2587
|
|
|
2520
2588
|
export function listIntegrationTaskStats(): IntegrationTaskStats[] {
|
|
2521
|
-
const rows = db.
|
|
2589
|
+
const rows = db.query(`
|
|
2522
2590
|
SELECT
|
|
2523
2591
|
source,
|
|
2524
2592
|
COUNT(*) AS tasks,
|
|
@@ -2597,7 +2665,7 @@ export function upsertIntegrationRegistry(input: IntegrationRegistryInput): Retu
|
|
|
2597
2665
|
const name = stringValue(input.name);
|
|
2598
2666
|
if (!name) throw new ValidationError("integration name required");
|
|
2599
2667
|
const now = Date.now();
|
|
2600
|
-
db.
|
|
2668
|
+
db.query(`
|
|
2601
2669
|
INSERT INTO integration_registry (
|
|
2602
2670
|
name, display_name, description, enabled, scopes, targets, channels, type, icon, accent_color,
|
|
2603
2671
|
tags, homepage_url, repository_url, docs_url, manifest, source, created_at, updated_at
|
|
@@ -2644,17 +2712,17 @@ export function upsertIntegrationRegistry(input: IntegrationRegistryInput): Retu
|
|
|
2644
2712
|
}
|
|
2645
2713
|
|
|
2646
2714
|
function getIntegrationRegistry(name: string): ReturnType<typeof rowToIntegrationRegistry> | null {
|
|
2647
|
-
const row = db.
|
|
2715
|
+
const row = db.query("SELECT * FROM integration_registry WHERE name = ?").get(name) as any;
|
|
2648
2716
|
return row ? rowToIntegrationRegistry(row) : null;
|
|
2649
2717
|
}
|
|
2650
2718
|
|
|
2651
2719
|
export function listIntegrationRegistry(): ReturnType<typeof rowToIntegrationRegistry>[] {
|
|
2652
|
-
return (db.
|
|
2720
|
+
return (db.query("SELECT * FROM integration_registry ORDER BY display_name COLLATE NOCASE, name COLLATE NOCASE").all() as any[]).map(rowToIntegrationRegistry);
|
|
2653
2721
|
}
|
|
2654
2722
|
|
|
2655
2723
|
function updateIntegrationObserved(name: string, eventAt: number): void {
|
|
2656
2724
|
const now = Date.now();
|
|
2657
|
-
db.
|
|
2725
|
+
db.query(`
|
|
2658
2726
|
INSERT INTO integration_registry (name, enabled, scopes, targets, channels, tags, manifest, source, created_at, updated_at, last_event_at, last_task_at)
|
|
2659
2727
|
VALUES (?, 1, '[]', '[]', '[]', '[]', '{}', 'observed', ?, ?, ?, ?)
|
|
2660
2728
|
ON CONFLICT(name) DO UPDATE SET
|
|
@@ -2670,7 +2738,7 @@ export function isIntegrationRegistryEnabled(name: string): boolean {
|
|
|
2670
2738
|
}
|
|
2671
2739
|
|
|
2672
2740
|
export function listTaskEvents(taskId: number): TaskEvent[] {
|
|
2673
|
-
return (db.
|
|
2741
|
+
return (db.query("SELECT * FROM task_events WHERE task_id = ? ORDER BY id ASC").all(taskId) as any[]).map(rowToTaskEvent);
|
|
2674
2742
|
}
|
|
2675
2743
|
|
|
2676
2744
|
export function recordTaskEvent(taskId: number, input: {
|
|
@@ -2698,21 +2766,21 @@ export function releaseExpiredClaims(now: number = Date.now()): { messageIds: nu
|
|
|
2698
2766
|
settleSingleTargetOnDemandTasks("claimed_by IS NOT NULL AND (claim_expires_at IS NULL OR claim_expires_at <= ?)", [now], now, "claim-lease-expired");
|
|
2699
2767
|
const releasableMessageClaim = "claimed_by IS NOT NULL AND (claim_expires_at IS NULL OR claim_expires_at <= ?) AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.message_id = messages.id AND t.status IN ('done', 'failed', 'canceled'))";
|
|
2700
2768
|
const messageRows = db
|
|
2701
|
-
.
|
|
2769
|
+
.query(`SELECT id FROM messages WHERE ${releasableMessageClaim}`)
|
|
2702
2770
|
.all(now) as any[];
|
|
2703
2771
|
const taskRows = db
|
|
2704
|
-
.
|
|
2772
|
+
.query(`${TASK_SELECT} WHERE claimed_by IS NOT NULL AND (claim_expires_at IS NULL OR claim_expires_at <= ?) AND status IN ('claimed', 'in_progress', 'blocked')`)
|
|
2705
2773
|
.all(now) as any[];
|
|
2706
2774
|
|
|
2707
2775
|
if (messageRows.length > 0) {
|
|
2708
2776
|
db
|
|
2709
|
-
.
|
|
2777
|
+
.query(`UPDATE messages SET claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL WHERE ${releasableMessageClaim}`)
|
|
2710
2778
|
.run(now);
|
|
2711
2779
|
}
|
|
2712
2780
|
|
|
2713
2781
|
if (taskRows.length > 0) {
|
|
2714
2782
|
db
|
|
2715
|
-
.
|
|
2783
|
+
.query("UPDATE tasks SET status = 'open', claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL, updated_at = ? WHERE claimed_by IS NOT NULL AND (claim_expires_at IS NULL OR claim_expires_at <= ?) AND status IN ('claimed', 'in_progress', 'blocked')")
|
|
2716
2784
|
.run(now, now);
|
|
2717
2785
|
|
|
2718
2786
|
for (const row of taskRows) {
|
|
@@ -2737,11 +2805,11 @@ export function releaseExpiredClaims(now: number = Date.now()): { messageIds: nu
|
|
|
2737
2805
|
export function orphanTasksForAgent(agentId: string, now: number = Date.now()): Task[] {
|
|
2738
2806
|
return db.transaction(() => {
|
|
2739
2807
|
const rows = db
|
|
2740
|
-
.
|
|
2808
|
+
.query(`${TASK_SELECT} WHERE claimed_by = ? AND status IN ('claimed', 'in_progress')`)
|
|
2741
2809
|
.all(agentId) as any[];
|
|
2742
2810
|
if (rows.length === 0) return [];
|
|
2743
2811
|
|
|
2744
|
-
db.
|
|
2812
|
+
db.query(`
|
|
2745
2813
|
UPDATE tasks
|
|
2746
2814
|
SET status = 'orphaned', updated_at = ?, last_seen_at = ?
|
|
2747
2815
|
WHERE claimed_by = ? AND status IN ('claimed', 'in_progress')
|
|
@@ -2767,11 +2835,11 @@ export function releaseOrphanedTasks(graceMs = 120_000, now: number = Date.now()
|
|
|
2767
2835
|
const cutoff = now - graceMs;
|
|
2768
2836
|
settleSingleTargetOnDemandTasks("status = 'orphaned' AND claimed_by IS NOT NULL AND updated_at <= ?", [cutoff], now, "orphan-grace-elapsed");
|
|
2769
2837
|
const rows = db
|
|
2770
|
-
.
|
|
2838
|
+
.query(`${TASK_SELECT} WHERE status = 'orphaned' AND claimed_by IS NOT NULL AND updated_at <= ?`)
|
|
2771
2839
|
.all(cutoff) as any[];
|
|
2772
2840
|
if (rows.length === 0) return [];
|
|
2773
2841
|
|
|
2774
|
-
db.
|
|
2842
|
+
db.query(`
|
|
2775
2843
|
UPDATE tasks
|
|
2776
2844
|
SET status = 'open', claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL, updated_at = ?
|
|
2777
2845
|
WHERE status = 'orphaned' AND claimed_by IS NOT NULL AND updated_at <= ?
|
|
@@ -2807,7 +2875,7 @@ export function claimTask(taskId: number, agentId: string, guard?: AgentSessionG
|
|
|
2807
2875
|
return db.transaction(() => {
|
|
2808
2876
|
const now = Date.now();
|
|
2809
2877
|
const expiresAt = now + CLAIM_LEASE_MS;
|
|
2810
|
-
const result = db.
|
|
2878
|
+
const result = db.query(`
|
|
2811
2879
|
UPDATE tasks SET status = 'claimed', claimed_by = ?, claimed_at = ?, claim_expires_at = ?, updated_at = ?
|
|
2812
2880
|
WHERE id = ? AND status IN ('open', 'blocked')
|
|
2813
2881
|
`).run(agentId, now, expiresAt, now, taskId);
|
|
@@ -2846,9 +2914,9 @@ export function renewTaskClaim(taskId: number, agentId: string, guard?: AgentSes
|
|
|
2846
2914
|
|
|
2847
2915
|
const now = Date.now();
|
|
2848
2916
|
const expiresAt = now + CLAIM_LEASE_MS;
|
|
2849
|
-
db.
|
|
2917
|
+
db.query("UPDATE tasks SET claim_expires_at = ?, updated_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, now, taskId, agentId);
|
|
2850
2918
|
if (task.messageId) {
|
|
2851
|
-
db.
|
|
2919
|
+
db.query("UPDATE messages SET claim_expires_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, task.messageId, agentId);
|
|
2852
2920
|
}
|
|
2853
2921
|
return { ok: true, task: getTask(taskId)! };
|
|
2854
2922
|
}
|
|
@@ -2862,7 +2930,7 @@ export function updateTaskStatus(taskId: number, input: TaskStatusInput): { ok:
|
|
|
2862
2930
|
const session = validateAgentSession(agentId, input);
|
|
2863
2931
|
if (!session.ok) return { ok: false, error: session.error };
|
|
2864
2932
|
}
|
|
2865
|
-
const result = db.
|
|
2933
|
+
const result = db.query(`
|
|
2866
2934
|
UPDATE tasks
|
|
2867
2935
|
SET status = ?, result = COALESCE(?, result), claimed_by = COALESCE(?, claimed_by),
|
|
2868
2936
|
claimed_at = CASE WHEN claimed_by IS NULL AND ? IS NOT NULL THEN ? ELSE claimed_at END,
|
|
@@ -2872,13 +2940,13 @@ export function updateTaskStatus(taskId: number, input: TaskStatusInput): { ok:
|
|
|
2872
2940
|
`).run(input.status, input.result ?? null, agentId, agentId, now, input.status, now, now, taskId);
|
|
2873
2941
|
if (result.changes === 0) return { ok: false, error: "task not found" };
|
|
2874
2942
|
if (["done", "failed", "canceled"].includes(input.status) && task.messageId) {
|
|
2875
|
-
db.
|
|
2943
|
+
db.query("UPDATE messages SET claim_expires_at = NULL WHERE id = ?").run(task.messageId);
|
|
2876
2944
|
}
|
|
2877
2945
|
if (agentId && ["claimed", "in_progress", "blocked"].includes(input.status)) {
|
|
2878
2946
|
const expiresAt = now + CLAIM_LEASE_MS;
|
|
2879
|
-
db.
|
|
2947
|
+
db.query("UPDATE tasks SET claim_expires_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, taskId, agentId);
|
|
2880
2948
|
if (task.messageId) {
|
|
2881
|
-
db.
|
|
2949
|
+
db.query("UPDATE messages SET claim_expires_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, task.messageId, agentId);
|
|
2882
2950
|
}
|
|
2883
2951
|
}
|
|
2884
2952
|
const event = insertTaskEvent(taskId, {
|
|
@@ -2894,7 +2962,7 @@ export function updateTaskStatus(taskId: number, input: TaskStatusInput): { ok:
|
|
|
2894
2962
|
|
|
2895
2963
|
export function createCallbackDelivery(taskId: number, url: string, eventType: string, payload: unknown): number {
|
|
2896
2964
|
const now = Date.now();
|
|
2897
|
-
const result = db.
|
|
2965
|
+
const result = db.query(`
|
|
2898
2966
|
INSERT INTO task_callback_deliveries (task_id, url, event_type, payload, status, attempts, created_at, updated_at)
|
|
2899
2967
|
VALUES (?, ?, ?, ?, 'pending', 0, ?, ?)
|
|
2900
2968
|
`).run(taskId, url, eventType, JSON.stringify(payload), now, now);
|
|
@@ -2902,7 +2970,7 @@ export function createCallbackDelivery(taskId: number, url: string, eventType: s
|
|
|
2902
2970
|
}
|
|
2903
2971
|
|
|
2904
2972
|
export function finishCallbackDelivery(id: number, ok: boolean, error?: string): void {
|
|
2905
|
-
db.
|
|
2973
|
+
db.query(`
|
|
2906
2974
|
UPDATE task_callback_deliveries
|
|
2907
2975
|
SET status = ?, attempts = attempts + 1, last_error = ?, updated_at = ?
|
|
2908
2976
|
WHERE id = ?
|
|
@@ -2918,7 +2986,7 @@ export function listCallbackDeliveries(taskId: number): Array<{
|
|
|
2918
2986
|
attempts: number;
|
|
2919
2987
|
lastError?: string;
|
|
2920
2988
|
}> {
|
|
2921
|
-
return (db.
|
|
2989
|
+
return (db.query("SELECT * FROM task_callback_deliveries WHERE task_id = ? ORDER BY id ASC").all(taskId) as any[])
|
|
2922
2990
|
.map((row) => ({
|
|
2923
2991
|
id: row.id,
|
|
2924
2992
|
taskId: row.task_id,
|
|
@@ -2937,12 +3005,12 @@ const DEFAULT_PAIR_TTL_MS = 5 * 60_000;
|
|
|
2937
3005
|
const MAX_PAIR_TTL_MS = DAY_MS;
|
|
2938
3006
|
|
|
2939
3007
|
function expirePendingPairs(now: number = Date.now()): void {
|
|
2940
|
-
db.
|
|
3008
|
+
db.query("UPDATE pairs SET status = 'expired', updated_at = ?, ended_at = ? WHERE status = 'pending' AND expires_at <= ?")
|
|
2941
3009
|
.run(now, now, now);
|
|
2942
3010
|
}
|
|
2943
3011
|
|
|
2944
3012
|
function closeOpenPairsForAgent(agentId: string, now: number = Date.now()): void {
|
|
2945
|
-
db.
|
|
3013
|
+
db.query(`
|
|
2946
3014
|
UPDATE pairs
|
|
2947
3015
|
SET status = 'ended', ended_at = ?, ended_by = ?, updated_at = ?
|
|
2948
3016
|
WHERE status IN ('pending', 'active') AND (requester_id = ? OR target_id = ?)
|
|
@@ -2951,7 +3019,7 @@ function closeOpenPairsForAgent(agentId: string, now: number = Date.now()): void
|
|
|
2951
3019
|
|
|
2952
3020
|
function getOpenPairForAgent(agentId: string): PairSession | null {
|
|
2953
3021
|
expirePendingPairs();
|
|
2954
|
-
const row = db.
|
|
3022
|
+
const row = db.query(`
|
|
2955
3023
|
SELECT * FROM pairs
|
|
2956
3024
|
WHERE status IN ('pending', 'active') AND (requester_id = ? OR target_id = ?)
|
|
2957
3025
|
ORDER BY updated_at DESC
|
|
@@ -3038,7 +3106,7 @@ function pairSystemMessage(pair: PairSession, to: string, event: string, subject
|
|
|
3038
3106
|
|
|
3039
3107
|
export function getPair(id: string): PairSession | null {
|
|
3040
3108
|
expirePendingPairs();
|
|
3041
|
-
const row = db.
|
|
3109
|
+
const row = db.query("SELECT * FROM pairs WHERE id = ?").get(id) as any;
|
|
3042
3110
|
return row ? rowToPair(row) : null;
|
|
3043
3111
|
}
|
|
3044
3112
|
|
|
@@ -3055,7 +3123,7 @@ export function listPairs(filter?: { agentId?: string; status?: PairStatus }): P
|
|
|
3055
3123
|
params.push(filter.status);
|
|
3056
3124
|
}
|
|
3057
3125
|
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
3058
|
-
return (db.
|
|
3126
|
+
return (db.query(`SELECT * FROM pairs ${where} ORDER BY updated_at DESC LIMIT 100`).all(...params) as any[]).map(rowToPair);
|
|
3059
3127
|
}
|
|
3060
3128
|
|
|
3061
3129
|
export function createPair(input: CreatePairInput): {
|
|
@@ -3082,7 +3150,7 @@ export function createPair(input: CreatePairInput): {
|
|
|
3082
3150
|
const now = Date.now();
|
|
3083
3151
|
const ttlMs = Math.min(Math.max(input.ttlMs ?? DEFAULT_PAIR_TTL_MS, 10_000), MAX_PAIR_TTL_MS);
|
|
3084
3152
|
const id = randomUUID();
|
|
3085
|
-
db.
|
|
3153
|
+
db.query(`
|
|
3086
3154
|
INSERT INTO pairs (id, requester_id, target_id, status, objective, meta, created_at, updated_at, expires_at)
|
|
3087
3155
|
VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?)
|
|
3088
3156
|
`).run(id, input.from, resolved.agent.id, input.objective ?? null, JSON.stringify(input.meta ?? {}), now, now, now + ttlMs);
|
|
@@ -3122,7 +3190,7 @@ export function acceptPair(id: string, input: PairActionInput): { ok: true; pair
|
|
|
3122
3190
|
}
|
|
3123
3191
|
|
|
3124
3192
|
const now = Date.now();
|
|
3125
|
-
db.
|
|
3193
|
+
db.query("UPDATE pairs SET status = 'active', accepted_at = ?, updated_at = ? WHERE id = ? AND status = 'pending'")
|
|
3126
3194
|
.run(now, now, id);
|
|
3127
3195
|
const active = getPair(id)!;
|
|
3128
3196
|
const notices = [
|
|
@@ -3139,7 +3207,7 @@ export function rejectPair(id: string, input: PairActionInput): { ok: true; pair
|
|
|
3139
3207
|
if (pair.status !== "pending") return { ok: false, code: "invalid", error: `pair is ${pair.status}` };
|
|
3140
3208
|
if (pair.targetId !== input.agentId) return { ok: false, code: "forbidden", error: "only the target agent can reject this pair" };
|
|
3141
3209
|
const now = Date.now();
|
|
3142
|
-
db.
|
|
3210
|
+
db.query("UPDATE pairs SET status = 'rejected', ended_at = ?, ended_by = ?, updated_at = ? WHERE id = ?")
|
|
3143
3211
|
.run(now, input.agentId, now, id);
|
|
3144
3212
|
const rejected = getPair(id)!;
|
|
3145
3213
|
const reason = input.reason ? `\n\nReason:\n${input.reason}` : "";
|
|
@@ -3154,7 +3222,7 @@ export function endPair(id: string, input: PairActionInput): { ok: true; pair: P
|
|
|
3154
3222
|
if (!pairParticipant(pair, input.agentId)) return { ok: false, code: "forbidden", error: "only pair participants can hang up" };
|
|
3155
3223
|
if (!OPEN_PAIR_STATUSES.includes(pair.status as any)) return { ok: false, code: "invalid", error: `pair is ${pair.status}` };
|
|
3156
3224
|
const now = Date.now();
|
|
3157
|
-
db.
|
|
3225
|
+
db.query("UPDATE pairs SET status = 'ended', ended_at = ?, ended_by = ?, updated_at = ? WHERE id = ?")
|
|
3158
3226
|
.run(now, input.agentId, now, id);
|
|
3159
3227
|
const ended = getPair(id)!;
|
|
3160
3228
|
const reason = input.reason ? `\n\nReason:\n${input.reason}` : "";
|
|
@@ -3184,7 +3252,7 @@ export function sendPairMessage(id: string, input: PairMessageInput): { ok: true
|
|
|
3184
3252
|
targetId: pair.targetId,
|
|
3185
3253
|
},
|
|
3186
3254
|
});
|
|
3187
|
-
db.
|
|
3255
|
+
db.query("UPDATE pairs SET last_message_at = ?, updated_at = ? WHERE id = ?").run(now, now, id);
|
|
3188
3256
|
return { ok: true, pair: getPair(id)!, message };
|
|
3189
3257
|
}
|
|
3190
3258
|
|
|
@@ -3224,7 +3292,7 @@ export function upsertArtifactBlob(input: {
|
|
|
3224
3292
|
createdAt?: number;
|
|
3225
3293
|
}): ArtifactBlob {
|
|
3226
3294
|
const now = input.createdAt ?? Date.now();
|
|
3227
|
-
db.
|
|
3295
|
+
db.query(`
|
|
3228
3296
|
INSERT INTO artifact_blobs (digest, storage_uri, media_type, size, created_at)
|
|
3229
3297
|
VALUES (?, ?, ?, ?, ?)
|
|
3230
3298
|
ON CONFLICT(digest) DO UPDATE SET
|
|
@@ -3236,12 +3304,12 @@ export function upsertArtifactBlob(input: {
|
|
|
3236
3304
|
}
|
|
3237
3305
|
|
|
3238
3306
|
export function getArtifactBlob(digest: string): ArtifactBlob | null {
|
|
3239
|
-
const row = db.
|
|
3307
|
+
const row = db.query("SELECT * FROM artifact_blobs WHERE digest = ?").get(digest) as any;
|
|
3240
3308
|
return row ? rowToArtifactBlob(row) : null;
|
|
3241
3309
|
}
|
|
3242
3310
|
|
|
3243
3311
|
export function deleteArtifactBlob(digest: string): boolean {
|
|
3244
|
-
return db.
|
|
3312
|
+
return db.query("DELETE FROM artifact_blobs WHERE digest = ?").run(digest).changes > 0;
|
|
3245
3313
|
}
|
|
3246
3314
|
|
|
3247
3315
|
export function createArtifact(input: {
|
|
@@ -3260,7 +3328,7 @@ export function createArtifact(input: {
|
|
|
3260
3328
|
if (!getArtifactBlob(input.blobDigest)) throw new ValidationError("artifact blob not found");
|
|
3261
3329
|
const id = input.id ?? artifactId();
|
|
3262
3330
|
const now = Date.now();
|
|
3263
|
-
db.
|
|
3331
|
+
db.query(`
|
|
3264
3332
|
INSERT INTO artifacts (
|
|
3265
3333
|
id, blob_digest, media_type, kind, filename, size, visibility, sensitivity,
|
|
3266
3334
|
created_by, created_at, expires_at, metadata
|
|
@@ -3284,7 +3352,7 @@ export function createArtifact(input: {
|
|
|
3284
3352
|
}
|
|
3285
3353
|
|
|
3286
3354
|
export function getArtifact(id: string): Artifact | null {
|
|
3287
|
-
const row = db.
|
|
3355
|
+
const row = db.query("SELECT * FROM artifacts WHERE id = ?").get(id) as any;
|
|
3288
3356
|
if (!row) return null;
|
|
3289
3357
|
return rowToArtifact(row, listArtifactLinks(id));
|
|
3290
3358
|
}
|
|
@@ -3311,22 +3379,22 @@ export function listArtifacts(query: {
|
|
|
3311
3379
|
}
|
|
3312
3380
|
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
3313
3381
|
const limit = Math.min(Math.max(query.limit ?? 100, 1), 500);
|
|
3314
|
-
const rows = db.
|
|
3382
|
+
const rows = db.query(`SELECT a.* FROM artifacts a ${where} ORDER BY a.created_at DESC LIMIT ?`).all(...params, limit) as any[];
|
|
3315
3383
|
return rows.map((row) => rowToArtifact(row, listArtifactLinks(row.id)));
|
|
3316
3384
|
}
|
|
3317
3385
|
|
|
3318
3386
|
export function deleteArtifact(id: string): boolean {
|
|
3319
3387
|
return db.transaction(() => {
|
|
3320
|
-
return db.
|
|
3388
|
+
return db.query("DELETE FROM artifacts WHERE id = ?").run(id).changes > 0;
|
|
3321
3389
|
})();
|
|
3322
3390
|
}
|
|
3323
3391
|
|
|
3324
3392
|
function listArtifactLinks(artifactId: string): ArtifactLink[] {
|
|
3325
|
-
return (db.
|
|
3393
|
+
return (db.query("SELECT * FROM artifact_links WHERE artifact_id = ? ORDER BY created_at ASC").all(artifactId) as any[]).map(rowToArtifactLink);
|
|
3326
3394
|
}
|
|
3327
3395
|
|
|
3328
3396
|
export function listArtifactsForEntity(entityType: ArtifactLink["entityType"], entityId: string | number): Artifact[] {
|
|
3329
|
-
const rows = db.
|
|
3397
|
+
const rows = db.query(`
|
|
3330
3398
|
SELECT a.*
|
|
3331
3399
|
FROM artifacts a
|
|
3332
3400
|
JOIN artifact_links l ON l.artifact_id = a.id
|
|
@@ -3349,7 +3417,7 @@ export function linkArtifact(input: {
|
|
|
3349
3417
|
if (!getArtifact(input.artifactId)) throw new ValidationError(`artifact ${input.artifactId} not found`);
|
|
3350
3418
|
const now = Date.now();
|
|
3351
3419
|
const id = artifactLinkId();
|
|
3352
|
-
db.
|
|
3420
|
+
db.query(`
|
|
3353
3421
|
INSERT OR IGNORE INTO artifact_links (id, artifact_id, entity_type, entity_id, role, title, created_by, created_at)
|
|
3354
3422
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
3355
3423
|
`).run(
|
|
@@ -3362,7 +3430,7 @@ export function linkArtifact(input: {
|
|
|
3362
3430
|
input.createdBy,
|
|
3363
3431
|
now,
|
|
3364
3432
|
);
|
|
3365
|
-
const row = db.
|
|
3433
|
+
const row = db.query(`
|
|
3366
3434
|
SELECT * FROM artifact_links
|
|
3367
3435
|
WHERE artifact_id = ? AND entity_type = ? AND entity_id = ? AND ((role IS NULL AND ? IS NULL) OR role = ?)
|
|
3368
3436
|
ORDER BY created_at DESC
|
|
@@ -3372,16 +3440,16 @@ export function linkArtifact(input: {
|
|
|
3372
3440
|
}
|
|
3373
3441
|
|
|
3374
3442
|
function deleteArtifactLinksForEntity(entityType: ArtifactLink["entityType"], entityId: string | number): number {
|
|
3375
|
-
return db.
|
|
3443
|
+
return db.query("DELETE FROM artifact_links WHERE entity_type = ? AND entity_id = ?").run(entityType, String(entityId)).changes;
|
|
3376
3444
|
}
|
|
3377
3445
|
|
|
3378
3446
|
export function artifactBlobReferenceCount(digest: string): number {
|
|
3379
|
-
const row = db.
|
|
3447
|
+
const row = db.query("SELECT COUNT(*) AS count FROM artifacts WHERE blob_digest = ?").get(digest) as { count?: number };
|
|
3380
3448
|
return row.count ?? 0;
|
|
3381
3449
|
}
|
|
3382
3450
|
|
|
3383
3451
|
function unreferencedArtifactBlobs(): ArtifactBlob[] {
|
|
3384
|
-
return (db.
|
|
3452
|
+
return (db.query(`
|
|
3385
3453
|
SELECT b.*
|
|
3386
3454
|
FROM artifact_blobs b
|
|
3387
3455
|
WHERE NOT EXISTS (SELECT 1 FROM artifacts a WHERE a.blob_digest = b.digest)
|
|
@@ -3392,12 +3460,12 @@ export function sweepArtifacts(input: { now?: number; unlinkedGraceMs?: number }
|
|
|
3392
3460
|
const now = input.now ?? Date.now();
|
|
3393
3461
|
const unlinkedCutoff = now - (input.unlinkedGraceMs ?? 60 * 60 * 1000);
|
|
3394
3462
|
return db.transaction(() => {
|
|
3395
|
-
const rows = db.
|
|
3463
|
+
const rows = db.query(`
|
|
3396
3464
|
SELECT id FROM artifacts a
|
|
3397
3465
|
WHERE (expires_at IS NOT NULL AND expires_at <= ?)
|
|
3398
3466
|
OR (created_at <= ? AND NOT EXISTS (SELECT 1 FROM artifact_links l WHERE l.artifact_id = a.id))
|
|
3399
3467
|
`).all(now, unlinkedCutoff) as Array<{ id: string }>;
|
|
3400
|
-
for (const row of rows) db.
|
|
3468
|
+
for (const row of rows) db.query("DELETE FROM artifacts WHERE id = ?").run(row.id);
|
|
3401
3469
|
const blobs = unreferencedArtifactBlobs();
|
|
3402
3470
|
for (const blob of blobs) deleteArtifactBlob(blob.digest);
|
|
3403
3471
|
return { artifactIds: rows.map((row) => row.id), blobs };
|
|
@@ -3451,7 +3519,7 @@ function linkAttachmentRefs(entityType: ArtifactLink["entityType"], entityId: st
|
|
|
3451
3519
|
|
|
3452
3520
|
function findMessageByIdempotencyKey(from: string, key: string): Message | null {
|
|
3453
3521
|
const row = db
|
|
3454
|
-
.
|
|
3522
|
+
.query(`${MSG_SELECT} WHERE m.from_agent = ? AND m.idempotency_key = ? LIMIT 1`)
|
|
3455
3523
|
.get(from, key) as any;
|
|
3456
3524
|
return row ? rowToMessage(row) : null;
|
|
3457
3525
|
}
|
|
@@ -3463,12 +3531,12 @@ function policyNameFromTarget(target: string): string | null {
|
|
|
3463
3531
|
}
|
|
3464
3532
|
|
|
3465
3533
|
function spawnPolicyExists(policyName: string): boolean {
|
|
3466
|
-
const row = db.
|
|
3534
|
+
const row = db.query("SELECT 1 FROM config WHERE namespace = 'spawn-policy' AND key = ?").get(policyName);
|
|
3467
3535
|
return Boolean(row);
|
|
3468
3536
|
}
|
|
3469
3537
|
|
|
3470
3538
|
function runningAgentForPolicy(policyName: string): string | null {
|
|
3471
|
-
const row = db.
|
|
3539
|
+
const row = db.query(`
|
|
3472
3540
|
SELECT agent_id
|
|
3473
3541
|
FROM managed_agent_state
|
|
3474
3542
|
WHERE policy_name = ? AND status = 'running' AND agent_id IS NOT NULL
|
|
@@ -3480,7 +3548,7 @@ function runningAgentForPolicy(policyName: string): string | null {
|
|
|
3480
3548
|
}
|
|
3481
3549
|
|
|
3482
3550
|
function queueDepthLimit(target: string): number {
|
|
3483
|
-
const row = db.
|
|
3551
|
+
const row = db.query("SELECT value FROM config WHERE namespace = 'system' AND key = 'message-queue'").get() as { value?: string } | undefined;
|
|
3484
3552
|
const parsed = row?.value ? parseJson<Record<string, unknown>>(row.value, {}) : {};
|
|
3485
3553
|
const perTarget = parsed?.maxDepthPerTarget;
|
|
3486
3554
|
if (typeof perTarget === "number" && Number.isSafeInteger(perTarget) && perTarget > 0) return perTarget;
|
|
@@ -3494,7 +3562,7 @@ function queueDepthLimit(target: string): number {
|
|
|
3494
3562
|
|
|
3495
3563
|
function enforceQueueLimit(target: string): void {
|
|
3496
3564
|
const limit = queueDepthLimit(target);
|
|
3497
|
-
const rows = db.
|
|
3565
|
+
const rows = db.query(`
|
|
3498
3566
|
SELECT id FROM messages
|
|
3499
3567
|
WHERE to_target = ? AND delivery_status = 'queued'
|
|
3500
3568
|
ORDER BY queued_at DESC, id DESC
|
|
@@ -3596,7 +3664,7 @@ const REPLY_DUPLICATE_WINDOW_MS = 2 * 60 * 1000;
|
|
|
3596
3664
|
|
|
3597
3665
|
function findRecentDuplicateReply(input: SendMessageInput, threadId: number | null, now: number, hasAttachments: boolean): Message | null {
|
|
3598
3666
|
if (!input.replyTo || threadId === null || hasAttachments) return null;
|
|
3599
|
-
const row = db.
|
|
3667
|
+
const row = db.query(`
|
|
3600
3668
|
${MSG_SELECT}
|
|
3601
3669
|
WHERE m.from_agent = ?
|
|
3602
3670
|
AND m.to_target = ?
|
|
@@ -3619,6 +3687,16 @@ function findRecentDuplicateReply(input: SendMessageInput, threadId: number | nu
|
|
|
3619
3687
|
return row ? rowToMessage(row) : null;
|
|
3620
3688
|
}
|
|
3621
3689
|
|
|
3690
|
+
// Event time may be queued-then-backfilled, so it can legitimately be older than the
|
|
3691
|
+
// receive time — but it must be a sane epoch-ms value. Returns null (column stays NULL, so
|
|
3692
|
+
// readers fall back to created_at) for absent/invalid values or anything more than a minute
|
|
3693
|
+
// in the future (clock-skew guard). Only a real backfilled time is stored.
|
|
3694
|
+
function sanitizeOccurredAt(occurredAt: number | undefined, receivedAt: number): number | null {
|
|
3695
|
+
if (typeof occurredAt !== "number" || !Number.isFinite(occurredAt)) return null;
|
|
3696
|
+
if (occurredAt <= 0 || occurredAt > receivedAt + 60_000) return null;
|
|
3697
|
+
return Math.floor(occurredAt);
|
|
3698
|
+
}
|
|
3699
|
+
|
|
3622
3700
|
export function sendMessageWithResult(input: SendMessageInput): { message: Message; created: boolean } {
|
|
3623
3701
|
const now = Date.now();
|
|
3624
3702
|
const payload = input.payload ?? {};
|
|
@@ -3645,19 +3723,19 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
|
|
|
3645
3723
|
const duplicateReply = findRecentDuplicateReply(input, threadId, now, attachmentRefs.length > 0);
|
|
3646
3724
|
if (duplicateReply) return { message: duplicateReply, created: false };
|
|
3647
3725
|
|
|
3648
|
-
const insert = db.
|
|
3726
|
+
const insert = db.query(`
|
|
3649
3727
|
INSERT INTO messages (
|
|
3650
3728
|
from_agent, to_target, kind, channel, subject, body, thread_id, reply_to, claimable,
|
|
3651
3729
|
idempotency_key, delivery_status, queued_at, max_age_seconds, resolved_to_agent,
|
|
3652
|
-
payload, meta, created_at
|
|
3730
|
+
payload, meta, created_at, occurred_at
|
|
3653
3731
|
)
|
|
3654
3732
|
VALUES (
|
|
3655
3733
|
$from, $to, $kind, $channel, $subject, $body, $threadId, $replyTo, $claimable,
|
|
3656
3734
|
$idempotencyKey, $deliveryStatus, $queuedAt, $maxAgeSeconds, $resolvedToAgent,
|
|
3657
|
-
$payload, $meta, $now
|
|
3735
|
+
$payload, $meta, $now, $occurredAt
|
|
3658
3736
|
)
|
|
3659
3737
|
`);
|
|
3660
|
-
const setSelfThread = db.
|
|
3738
|
+
const setSelfThread = db.query("UPDATE messages SET thread_id = ? WHERE id = ?");
|
|
3661
3739
|
const claimable = shouldStoreClaimable(input);
|
|
3662
3740
|
const kind = inferMessageKind(input);
|
|
3663
3741
|
const policyName = policyNameFromTarget(input.to);
|
|
@@ -3695,6 +3773,8 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
|
|
|
3695
3773
|
$payload: JSON.stringify(payload),
|
|
3696
3774
|
$meta: JSON.stringify(input.meta ?? {}),
|
|
3697
3775
|
$now: now,
|
|
3776
|
+
// Sanitize: only accept a plausible epoch-ms event time, else fall back to receive time.
|
|
3777
|
+
$occurredAt: sanitizeOccurredAt(input.occurredAt, now),
|
|
3698
3778
|
});
|
|
3699
3779
|
const newId = Number(result.lastInsertRowid);
|
|
3700
3780
|
if (threadId === null) setSelfThread.run(newId, newId);
|
|
@@ -3716,7 +3796,7 @@ export function getThread(messageId: number): Message[] {
|
|
|
3716
3796
|
const threadId = msg.threadId ?? msg.id;
|
|
3717
3797
|
return (
|
|
3718
3798
|
db
|
|
3719
|
-
.
|
|
3799
|
+
.query(`${MSG_SELECT} WHERE m.thread_id = ? ORDER BY m.created_at ASC`)
|
|
3720
3800
|
.all(threadId) as any[]
|
|
3721
3801
|
).map(rowToMessage);
|
|
3722
3802
|
}
|
|
@@ -3735,10 +3815,10 @@ export function setMessageReaction(input: {
|
|
|
3735
3815
|
|
|
3736
3816
|
const now = Date.now();
|
|
3737
3817
|
if (input.action === "remove") {
|
|
3738
|
-
db.
|
|
3818
|
+
db.query("DELETE FROM message_reactions WHERE message_id = ? AND actor_id = ? AND emoji = ?")
|
|
3739
3819
|
.run(input.messageId, actorId, emoji);
|
|
3740
3820
|
} else {
|
|
3741
|
-
db.
|
|
3821
|
+
db.query(`
|
|
3742
3822
|
INSERT INTO message_reactions (message_id, actor_id, emoji, created_at, updated_at)
|
|
3743
3823
|
VALUES (?, ?, ?, ?, ?)
|
|
3744
3824
|
ON CONFLICT(message_id, actor_id, emoji) DO UPDATE SET updated_at = excluded.updated_at
|
|
@@ -3753,7 +3833,7 @@ export function findMessageByTelegramSource(input: {
|
|
|
3753
3833
|
chatId: string;
|
|
3754
3834
|
messageId: string;
|
|
3755
3835
|
}): Message | null {
|
|
3756
|
-
const rows = db.
|
|
3836
|
+
const rows = db.query(`
|
|
3757
3837
|
${MSG_SELECT}
|
|
3758
3838
|
WHERE json_extract(m.payload, '$.source.telegram.chatId') = ?
|
|
3759
3839
|
AND json_extract(m.payload, '$.source.telegram.messageId') = ?
|
|
@@ -3767,7 +3847,7 @@ export function findMessageByTelegramSource(input: {
|
|
|
3767
3847
|
|
|
3768
3848
|
function claimMessageRow(messageId: number, agentId: string, now: number): { ok: false; error: string } | { ok: true } {
|
|
3769
3849
|
const expiresAt = now + CLAIM_LEASE_MS;
|
|
3770
|
-
const result = db.
|
|
3850
|
+
const result = db.query(
|
|
3771
3851
|
"UPDATE messages SET claimed_by = ?, claimed_at = ?, claim_expires_at = ? WHERE id = ? AND claimed_by IS NULL"
|
|
3772
3852
|
).run(agentId, now, expiresAt, messageId);
|
|
3773
3853
|
|
|
@@ -3807,7 +3887,7 @@ export function claimMessage(messageId: number, agentId: string, guard?: AgentSe
|
|
|
3807
3887
|
throw new ClaimError(`linked task is ${task.status}`);
|
|
3808
3888
|
}
|
|
3809
3889
|
|
|
3810
|
-
const taskClaim = db.
|
|
3890
|
+
const taskClaim = db.query(`
|
|
3811
3891
|
UPDATE tasks SET status = 'claimed', claimed_by = ?, claimed_at = ?, claim_expires_at = ?, updated_at = ?
|
|
3812
3892
|
WHERE id = ? AND message_id = ? AND status IN ('open', 'blocked')
|
|
3813
3893
|
`).run(agentId, now, expiresAt, now, taskId, messageId);
|
|
@@ -3846,12 +3926,12 @@ export function renewMessageClaim(messageId: number, agentId: string, guard?: Ag
|
|
|
3846
3926
|
const expiresAt = now + CLAIM_LEASE_MS;
|
|
3847
3927
|
let task: Task | undefined;
|
|
3848
3928
|
db.transaction(() => {
|
|
3849
|
-
db.
|
|
3929
|
+
db.query("UPDATE messages SET claim_expires_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, messageId, agentId);
|
|
3850
3930
|
const taskId = typeof msg.payload?.taskId === "number" && Number.isSafeInteger(msg.payload.taskId)
|
|
3851
3931
|
? msg.payload.taskId
|
|
3852
3932
|
: null;
|
|
3853
3933
|
if (taskId) {
|
|
3854
|
-
db.
|
|
3934
|
+
db.query("UPDATE tasks SET claim_expires_at = ?, updated_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, now, taskId, agentId);
|
|
3855
3935
|
task = getTask(taskId) ?? undefined;
|
|
3856
3936
|
}
|
|
3857
3937
|
})();
|
|
@@ -3859,13 +3939,13 @@ export function renewMessageClaim(messageId: number, agentId: string, guard?: Ag
|
|
|
3859
3939
|
}
|
|
3860
3940
|
|
|
3861
3941
|
export function getMessage(id: number): Message | null {
|
|
3862
|
-
const row = db.
|
|
3942
|
+
const row = db.query(`${MSG_SELECT} WHERE m.id = ?`).get(id) as any;
|
|
3863
3943
|
return row ? rowToMessage(row) : null;
|
|
3864
3944
|
}
|
|
3865
3945
|
|
|
3866
3946
|
export function getMessageDeliveryAttempts(messageId: number, limit = 50): MessageDeliveryAttempt[] {
|
|
3867
3947
|
const safeLimit = Math.min(Math.max(limit, 1), 200);
|
|
3868
|
-
return (db.
|
|
3948
|
+
return (db.query(`
|
|
3869
3949
|
SELECT * FROM message_delivery_attempts
|
|
3870
3950
|
WHERE message_id = ?
|
|
3871
3951
|
ORDER BY created_at DESC, id DESC
|
|
@@ -3885,7 +3965,7 @@ function insertMessageDeliveryAttempt(
|
|
|
3885
3965
|
},
|
|
3886
3966
|
now: number,
|
|
3887
3967
|
): void {
|
|
3888
|
-
db.
|
|
3968
|
+
db.query(`
|
|
3889
3969
|
INSERT INTO message_delivery_attempts (message_id, agent_id, action, status, error, next_retry_at, poison_reason, created_at)
|
|
3890
3970
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
3891
3971
|
`).run(
|
|
@@ -3911,7 +3991,7 @@ function setMessageDeliveryState(
|
|
|
3911
3991
|
},
|
|
3912
3992
|
now: number,
|
|
3913
3993
|
): boolean {
|
|
3914
|
-
return db.
|
|
3994
|
+
return db.query(`
|
|
3915
3995
|
UPDATE messages
|
|
3916
3996
|
SET delivery_status = ?,
|
|
3917
3997
|
delivery_attempts = delivery_attempts + ?,
|
|
@@ -4005,7 +4085,7 @@ export function getMessageDeliveryStatus(id: number): Pick<Message, "id" | "to"
|
|
|
4005
4085
|
|
|
4006
4086
|
export function listQueuedMessages(target: string, limit = 100): Message[] {
|
|
4007
4087
|
const safeLimit = Math.min(Math.max(limit, 1), 500);
|
|
4008
|
-
return (db.
|
|
4088
|
+
return (db.query(`
|
|
4009
4089
|
${MSG_SELECT}
|
|
4010
4090
|
WHERE m.to_target = ? AND m.delivery_status = 'queued'
|
|
4011
4091
|
ORDER BY m.queued_at ASC, m.id ASC
|
|
@@ -4015,7 +4095,7 @@ export function listQueuedMessages(target: string, limit = 100): Message[] {
|
|
|
4015
4095
|
|
|
4016
4096
|
export function resolveQueuedPolicyMessages(policyName: string, agentId: string): Message[] {
|
|
4017
4097
|
const target = `policy:${policyName}`;
|
|
4018
|
-
const rows = db.
|
|
4098
|
+
const rows = db.query(`
|
|
4019
4099
|
SELECT m.id
|
|
4020
4100
|
FROM messages m
|
|
4021
4101
|
WHERE m.to_target = ?
|
|
@@ -4031,7 +4111,7 @@ export function resolveQueuedPolicyMessages(policyName: string, agentId: string)
|
|
|
4031
4111
|
if (rows.length === 0) return [];
|
|
4032
4112
|
const ids = rows.map((row) => row.id);
|
|
4033
4113
|
const placeholders = ids.map(() => "?").join(",");
|
|
4034
|
-
db.
|
|
4114
|
+
db.query(`
|
|
4035
4115
|
UPDATE messages
|
|
4036
4116
|
SET delivery_status = 'pending',
|
|
4037
4117
|
resolved_to_agent = ?,
|
|
@@ -4050,7 +4130,7 @@ export function resolveQueuedPolicyMessages(policyName: string, agentId: string)
|
|
|
4050
4130
|
}
|
|
4051
4131
|
|
|
4052
4132
|
export function expireQueuedMessages(now: number = Date.now()): Message[] {
|
|
4053
|
-
const rows = db.
|
|
4133
|
+
const rows = db.query(`
|
|
4054
4134
|
SELECT id FROM messages
|
|
4055
4135
|
WHERE delivery_status = 'queued'
|
|
4056
4136
|
AND queued_at IS NOT NULL
|
|
@@ -4095,7 +4175,8 @@ export function listRecentMessages(limit: number = 100, since?: number, channel?
|
|
|
4095
4175
|
const sql = `${MSG_SELECT} ${where} ORDER BY m.created_at DESC LIMIT ?`;
|
|
4096
4176
|
params.push(limit);
|
|
4097
4177
|
|
|
4098
|
-
|
|
4178
|
+
const rows = timedQuery("listMessages", () => db.query(sql).all(...params) as any[]);
|
|
4179
|
+
return rows.map(rowToMessage).reverse();
|
|
4099
4180
|
}
|
|
4100
4181
|
|
|
4101
4182
|
export function pollMessages(query: PollQuery): Message[] {
|
|
@@ -4163,7 +4244,8 @@ export function pollMessages(query: PollQuery): Message[] {
|
|
|
4163
4244
|
const sql = `${MSG_SELECT} WHERE ${conditions.join(" AND ")} ORDER BY m.created_at ASC LIMIT ?`;
|
|
4164
4245
|
params.push(limit);
|
|
4165
4246
|
|
|
4166
|
-
|
|
4247
|
+
const rows = timedQuery("pollMessages", () => db.query(sql).all(...params) as any[]);
|
|
4248
|
+
return rows.map(rowToMessage);
|
|
4167
4249
|
}
|
|
4168
4250
|
|
|
4169
4251
|
function messageRequiresReply(message: Message): boolean {
|
|
@@ -4191,7 +4273,7 @@ function isCoveredByLaterAgentResponse(message: Message, agentId: string): boole
|
|
|
4191
4273
|
// Order by id, not created_at: ids are monotonic insertion order, so this is
|
|
4192
4274
|
// robust when a reply lands in the same millisecond as the message it covers
|
|
4193
4275
|
// (created_at > … strictly would miss it, leaving the message wrongly pending).
|
|
4194
|
-
const replies = (db.
|
|
4276
|
+
const replies = (db.query(`
|
|
4195
4277
|
${MSG_SELECT}
|
|
4196
4278
|
WHERE m.from_agent = ?
|
|
4197
4279
|
AND m.id > ?
|
|
@@ -4224,7 +4306,7 @@ function replyObligationFromMessage(message: Message, agentId: string): ReplyObl
|
|
|
4224
4306
|
|
|
4225
4307
|
export function listPendingReplyObligations(agentId: string, limit = 20): ReplyObligation[] {
|
|
4226
4308
|
const scanLimit = Math.max(limit * 5, 50);
|
|
4227
|
-
const rows = db.
|
|
4309
|
+
const rows = timedQuery("listPendingReplyObligations", () => db.query(`
|
|
4228
4310
|
${MSG_SELECT}
|
|
4229
4311
|
WHERE EXISTS (
|
|
4230
4312
|
SELECT 1 FROM message_reads mr
|
|
@@ -4238,7 +4320,7 @@ export function listPendingReplyObligations(agentId: string, limit = 20): ReplyO
|
|
|
4238
4320
|
)
|
|
4239
4321
|
ORDER BY m.created_at ASC
|
|
4240
4322
|
LIMIT ?
|
|
4241
|
-
`).all(agentId, agentId, agentId, scanLimit) as any[];
|
|
4323
|
+
`).all(agentId, agentId, agentId, scanLimit) as any[]);
|
|
4242
4324
|
return rows
|
|
4243
4325
|
.map(rowToMessage)
|
|
4244
4326
|
.filter(messageRequiresReply)
|
|
@@ -4248,12 +4330,12 @@ export function listPendingReplyObligations(agentId: string, limit = 20): ReplyO
|
|
|
4248
4330
|
}
|
|
4249
4331
|
|
|
4250
4332
|
export function markRead(messageId: number, agentId: string): boolean {
|
|
4251
|
-
const exists = db.
|
|
4333
|
+
const exists = db.query("SELECT 1 FROM messages WHERE id = ?").get(messageId);
|
|
4252
4334
|
if (!exists) return false;
|
|
4253
|
-
db.
|
|
4335
|
+
db.query(
|
|
4254
4336
|
"INSERT OR IGNORE INTO message_reads (message_id, agent_id, read_at) VALUES (?, ?, ?)"
|
|
4255
4337
|
).run(messageId, agentId, Date.now());
|
|
4256
|
-
db.
|
|
4338
|
+
db.query(`
|
|
4257
4339
|
UPDATE messages
|
|
4258
4340
|
SET delivery_status = 'delivered',
|
|
4259
4341
|
delivery_last_error = NULL,
|
|
@@ -4266,10 +4348,10 @@ export function markRead(messageId: number, agentId: string): boolean {
|
|
|
4266
4348
|
}
|
|
4267
4349
|
|
|
4268
4350
|
export function getInboxState(operatorId: string): InboxState {
|
|
4269
|
-
const threads = (db.
|
|
4351
|
+
const threads = (db.query(
|
|
4270
4352
|
"SELECT * FROM inbox_thread_state WHERE operator_id = ? ORDER BY updated_at DESC",
|
|
4271
4353
|
).all(operatorId) as any[]).map(rowToInboxThreadState);
|
|
4272
|
-
const drafts = (db.
|
|
4354
|
+
const drafts = (db.query(
|
|
4273
4355
|
"SELECT * FROM inbox_drafts WHERE operator_id = ? ORDER BY updated_at DESC",
|
|
4274
4356
|
).all(operatorId) as any[]).map(rowToInboxDraft);
|
|
4275
4357
|
return { operatorId, threads, drafts };
|
|
@@ -4282,7 +4364,7 @@ export function setInboxThreadState(input: {
|
|
|
4282
4364
|
archivedAtMessageId?: number | null;
|
|
4283
4365
|
}): InboxThreadState {
|
|
4284
4366
|
const now = Date.now();
|
|
4285
|
-
const current = db.
|
|
4367
|
+
const current = db.query(
|
|
4286
4368
|
"SELECT * FROM inbox_thread_state WHERE operator_id = ? AND peer_id = ?",
|
|
4287
4369
|
).get(input.operatorId, input.peerId) as any | undefined;
|
|
4288
4370
|
|
|
@@ -4293,7 +4375,7 @@ export function setInboxThreadState(input: {
|
|
|
4293
4375
|
? input.archivedAtMessageId ?? null
|
|
4294
4376
|
: current?.archived_at_message_id ?? null;
|
|
4295
4377
|
|
|
4296
|
-
db.
|
|
4378
|
+
db.query(`
|
|
4297
4379
|
INSERT INTO inbox_thread_state (operator_id, peer_id, read_cursor_message_id, archived_at_message_id, updated_at)
|
|
4298
4380
|
VALUES (?, ?, ?, ?, ?)
|
|
4299
4381
|
ON CONFLICT(operator_id, peer_id) DO UPDATE SET
|
|
@@ -4302,7 +4384,7 @@ export function setInboxThreadState(input: {
|
|
|
4302
4384
|
updated_at = excluded.updated_at
|
|
4303
4385
|
`).run(input.operatorId, input.peerId, readCursorMessageId, archivedAtMessageId, now);
|
|
4304
4386
|
|
|
4305
|
-
return rowToInboxThreadState(db.
|
|
4387
|
+
return rowToInboxThreadState(db.query(
|
|
4306
4388
|
"SELECT * FROM inbox_thread_state WHERE operator_id = ? AND peer_id = ?",
|
|
4307
4389
|
).get(input.operatorId, input.peerId));
|
|
4308
4390
|
}
|
|
@@ -4315,7 +4397,7 @@ export function setInboxDraft(input: {
|
|
|
4315
4397
|
channel?: string | null;
|
|
4316
4398
|
}): InboxDraft {
|
|
4317
4399
|
const now = Date.now();
|
|
4318
|
-
db.
|
|
4400
|
+
db.query(`
|
|
4319
4401
|
INSERT INTO inbox_drafts (operator_id, peer_id, body, subject, channel, updated_at)
|
|
4320
4402
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
4321
4403
|
ON CONFLICT(operator_id, peer_id) DO UPDATE SET
|
|
@@ -4325,13 +4407,13 @@ export function setInboxDraft(input: {
|
|
|
4325
4407
|
updated_at = excluded.updated_at
|
|
4326
4408
|
`).run(input.operatorId, input.peerId, input.body, input.subject ?? null, input.channel ?? null, now);
|
|
4327
4409
|
|
|
4328
|
-
return rowToInboxDraft(db.
|
|
4410
|
+
return rowToInboxDraft(db.query(
|
|
4329
4411
|
"SELECT * FROM inbox_drafts WHERE operator_id = ? AND peer_id = ?",
|
|
4330
4412
|
).get(input.operatorId, input.peerId));
|
|
4331
4413
|
}
|
|
4332
4414
|
|
|
4333
4415
|
export function deleteInboxDraft(operatorId: string, peerId: string): boolean {
|
|
4334
|
-
return db.
|
|
4416
|
+
return db.query("DELETE FROM inbox_drafts WHERE operator_id = ? AND peer_id = ?").run(operatorId, peerId).changes > 0;
|
|
4335
4417
|
}
|
|
4336
4418
|
|
|
4337
4419
|
export function createChatHistoryImport(input: {
|
|
@@ -4359,7 +4441,7 @@ export function createChatHistoryImport(input: {
|
|
|
4359
4441
|
const id = randomUUID();
|
|
4360
4442
|
const now = Date.now();
|
|
4361
4443
|
db.transaction(() => {
|
|
4362
|
-
db.
|
|
4444
|
+
db.query(`
|
|
4363
4445
|
INSERT INTO chat_history_imports (
|
|
4364
4446
|
id, target_agent_id, target_spawn_request_id, source_peer_id, source_agent_id,
|
|
4365
4447
|
source_thread_id, source_agent_label, imported_by, imported_at
|
|
@@ -4376,7 +4458,7 @@ export function createChatHistoryImport(input: {
|
|
|
4376
4458
|
now,
|
|
4377
4459
|
);
|
|
4378
4460
|
|
|
4379
|
-
const insertEntry = db.
|
|
4461
|
+
const insertEntry = db.query(`
|
|
4380
4462
|
INSERT INTO chat_history_import_entries (
|
|
4381
4463
|
import_id, position, original_message_id, original_from, original_to, original_created_at, message_snapshot
|
|
4382
4464
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
@@ -4398,9 +4480,9 @@ export function createChatHistoryImport(input: {
|
|
|
4398
4480
|
}
|
|
4399
4481
|
|
|
4400
4482
|
function getChatHistoryImport(id: string): ChatHistoryImport | null {
|
|
4401
|
-
const row = db.
|
|
4483
|
+
const row = db.query("SELECT * FROM chat_history_imports WHERE id = ?").get(id) as any | undefined;
|
|
4402
4484
|
if (!row) return null;
|
|
4403
|
-
const entries = (db.
|
|
4485
|
+
const entries = (db.query(
|
|
4404
4486
|
"SELECT * FROM chat_history_import_entries WHERE import_id = ? ORDER BY position ASC",
|
|
4405
4487
|
).all(id) as any[]).map(rowToChatHistoryImportEntry);
|
|
4406
4488
|
return rowToChatHistoryImport(row, entries);
|
|
@@ -4423,9 +4505,9 @@ export function listChatHistoryImports(input: {
|
|
|
4423
4505
|
}
|
|
4424
4506
|
const limit = Math.max(1, Math.min(input.limit ?? 100, 500));
|
|
4425
4507
|
const where = conditions.length ? `WHERE ${conditions.join(" OR ")}` : "";
|
|
4426
|
-
const rows = db.
|
|
4508
|
+
const rows = db.query(`SELECT * FROM chat_history_imports ${where} ORDER BY imported_at ASC LIMIT ?`).all(...params, limit) as any[];
|
|
4427
4509
|
return rows.map((row) => {
|
|
4428
|
-
const entries = (db.
|
|
4510
|
+
const entries = (db.query(
|
|
4429
4511
|
"SELECT * FROM chat_history_import_entries WHERE import_id = ? ORDER BY position ASC",
|
|
4430
4512
|
).all(row.id) as any[]).map(rowToChatHistoryImportEntry);
|
|
4431
4513
|
return rowToChatHistoryImport(row, entries);
|
|
@@ -4454,7 +4536,7 @@ export function listActivityEvents(input: {
|
|
|
4454
4536
|
}
|
|
4455
4537
|
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
4456
4538
|
params.push(input.limit ?? 200);
|
|
4457
|
-
return (db.
|
|
4539
|
+
return (db.query(
|
|
4458
4540
|
`SELECT * FROM activity_events ${where} ORDER BY created_at DESC, id DESC LIMIT ?`,
|
|
4459
4541
|
).all(...params) as any[]).map(rowToActivityEvent);
|
|
4460
4542
|
}
|
|
@@ -4483,11 +4565,11 @@ export function getAgentTimeline(agentId: string, opts: { limit?: number; since?
|
|
|
4483
4565
|
|
|
4484
4566
|
// Managed agents: include policy-scoped lifecycle transitions recorded before
|
|
4485
4567
|
// the agent registered (agent_id null), correlated via metadata.policyName.
|
|
4486
|
-
const managed = db.
|
|
4568
|
+
const managed = db.query("SELECT policy_name FROM managed_agent_state WHERE agent_id = ? LIMIT 1").get(agentId) as { policy_name?: string } | undefined;
|
|
4487
4569
|
if (managed?.policy_name) {
|
|
4488
4570
|
const rows = (since !== undefined
|
|
4489
|
-
? db.
|
|
4490
|
-
: db.
|
|
4571
|
+
? db.query("SELECT * FROM activity_events WHERE agent_id IS NULL AND json_extract(metadata, '$.policyName') = ? AND created_at >= ? ORDER BY created_at DESC LIMIT ?").all(managed.policy_name, since, limit)
|
|
4572
|
+
: db.query("SELECT * FROM activity_events WHERE agent_id IS NULL AND json_extract(metadata, '$.policyName') = ? ORDER BY created_at DESC LIMIT ?").all(managed.policy_name, limit)) as any[];
|
|
4491
4573
|
for (const ev of rows.map(rowToActivityEvent)) {
|
|
4492
4574
|
entries.push({ ts: ev.createdAt, source: "activity", kind: ev.kind, title: ev.title, detail: ev.body, metadata: ev.metadata });
|
|
4493
4575
|
}
|
|
@@ -4495,16 +4577,16 @@ export function getAgentTimeline(agentId: string, opts: { limit?: number; since?
|
|
|
4495
4577
|
|
|
4496
4578
|
// Commands targeting this agent (spawn/shutdown/restart/compact) with outcome.
|
|
4497
4579
|
const cmds = (since !== undefined
|
|
4498
|
-
? db.
|
|
4499
|
-
: db.
|
|
4580
|
+
? db.query("SELECT id, type, status, result, correlation_id, created_at, updated_at FROM commands WHERE target = ? AND updated_at >= ? ORDER BY updated_at DESC LIMIT ?").all(agentId, since, limit)
|
|
4581
|
+
: db.query("SELECT id, type, status, result, correlation_id, created_at, updated_at FROM commands WHERE target = ? ORDER BY updated_at DESC LIMIT ?").all(agentId, limit)) as any[];
|
|
4500
4582
|
for (const c of cmds) {
|
|
4501
4583
|
entries.push({ ts: c.updated_at ?? c.created_at, source: "command", kind: c.type, title: `${c.type} · ${c.status}`, detail: c.result ?? undefined, metadata: { id: c.id, status: c.status, correlationId: c.correlation_id ?? undefined } });
|
|
4502
4584
|
}
|
|
4503
4585
|
|
|
4504
4586
|
// Delivery attempts involving this agent (failures, retries, poison).
|
|
4505
4587
|
const attempts = (since !== undefined
|
|
4506
|
-
? db.
|
|
4507
|
-
: db.
|
|
4588
|
+
? db.query("SELECT * FROM message_delivery_attempts WHERE agent_id = ? AND created_at >= ? ORDER BY created_at DESC LIMIT ?").all(agentId, since, limit)
|
|
4589
|
+
: db.query("SELECT * FROM message_delivery_attempts WHERE agent_id = ? ORDER BY created_at DESC LIMIT ?").all(agentId, limit)) as any[];
|
|
4508
4590
|
for (const row of attempts) {
|
|
4509
4591
|
entries.push({ ts: row.created_at, source: "delivery", kind: `delivery.${row.action}`, title: `delivery ${row.status}`, detail: row.error ?? undefined, metadata: { messageId: row.message_id, status: row.status, poisonReason: row.poison_reason ?? undefined } });
|
|
4510
4592
|
}
|
|
@@ -4515,11 +4597,11 @@ export function getAgentTimeline(agentId: string, opts: { limit?: number; since?
|
|
|
4515
4597
|
|
|
4516
4598
|
export function createActivityEvent(input: ActivityEventInput): ActivityEvent {
|
|
4517
4599
|
if (input.clientId) {
|
|
4518
|
-
const existing = db.
|
|
4600
|
+
const existing = db.query("SELECT * FROM activity_events WHERE client_id = ?").get(input.clientId) as any | undefined;
|
|
4519
4601
|
if (existing) return rowToActivityEvent(existing);
|
|
4520
4602
|
}
|
|
4521
4603
|
const now = Date.now();
|
|
4522
|
-
const result = db.
|
|
4604
|
+
const result = db.query(`
|
|
4523
4605
|
INSERT INTO activity_events (
|
|
4524
4606
|
operator_id, client_id, kind, title, body, meta_text, icon, view, peer_id,
|
|
4525
4607
|
message_id, pair_id, task_id, agent_id, metadata, created_at
|
|
@@ -4542,21 +4624,21 @@ export function createActivityEvent(input: ActivityEventInput): ActivityEvent {
|
|
|
4542
4624
|
JSON.stringify(input.metadata ?? {}),
|
|
4543
4625
|
now,
|
|
4544
4626
|
);
|
|
4545
|
-
return rowToActivityEvent(db.
|
|
4627
|
+
return rowToActivityEvent(db.query("SELECT * FROM activity_events WHERE id = ?").get(Number(result.lastInsertRowid)));
|
|
4546
4628
|
}
|
|
4547
4629
|
|
|
4548
4630
|
export function deleteMessage(id: number): boolean {
|
|
4549
4631
|
return db.transaction(() => {
|
|
4550
4632
|
// Break reply_to references from children so the FK doesn't block delete.
|
|
4551
4633
|
// Children keep their thread_id — the thread shows up minus this message.
|
|
4552
|
-
db.
|
|
4634
|
+
db.query("UPDATE messages SET reply_to = NULL WHERE reply_to = ?").run(id);
|
|
4553
4635
|
deleteArtifactLinksForEntity("message", id);
|
|
4554
|
-
return db.
|
|
4636
|
+
return db.query("DELETE FROM messages WHERE id = ?").run(id).changes > 0;
|
|
4555
4637
|
})();
|
|
4556
4638
|
}
|
|
4557
4639
|
|
|
4558
4640
|
export function getLatestMessageId(): number {
|
|
4559
|
-
const row = db.
|
|
4641
|
+
const row = db.query("SELECT MAX(id) as id FROM messages").get() as any;
|
|
4560
4642
|
return row?.id ?? 0;
|
|
4561
4643
|
}
|
|
4562
4644
|
|
|
@@ -4564,10 +4646,10 @@ export function pruneOldMessages(maxAgeMs: number): number {
|
|
|
4564
4646
|
const cutoff = Date.now() - maxAgeMs;
|
|
4565
4647
|
return db.transaction(() => {
|
|
4566
4648
|
db
|
|
4567
|
-
.
|
|
4649
|
+
.query("UPDATE messages SET reply_to = NULL WHERE reply_to IN (SELECT id FROM messages WHERE created_at < ?)")
|
|
4568
4650
|
.run(cutoff);
|
|
4569
4651
|
return db
|
|
4570
|
-
.
|
|
4652
|
+
.query("DELETE FROM messages WHERE created_at < ?")
|
|
4571
4653
|
.run(cutoff).changes;
|
|
4572
4654
|
})();
|
|
4573
4655
|
}
|
|
@@ -4582,28 +4664,28 @@ export function getStats(): {
|
|
|
4582
4664
|
openTasks: number;
|
|
4583
4665
|
} {
|
|
4584
4666
|
const agents = (
|
|
4585
|
-
db.
|
|
4667
|
+
db.query("SELECT COUNT(*) as c FROM agents").get() as any
|
|
4586
4668
|
).c;
|
|
4587
4669
|
const online = (
|
|
4588
4670
|
db
|
|
4589
|
-
.
|
|
4671
|
+
.query(
|
|
4590
4672
|
"SELECT COUNT(*) as c FROM agents WHERE status != 'offline' AND last_seen > ?"
|
|
4591
4673
|
)
|
|
4592
4674
|
.get(Date.now() - STALE_TTL_MS) as any
|
|
4593
4675
|
).c;
|
|
4594
4676
|
const messages = (
|
|
4595
|
-
db.
|
|
4677
|
+
db.query("SELECT COUNT(*) as c FROM messages").get() as any
|
|
4596
4678
|
).c;
|
|
4597
4679
|
const messagesLast24h = (
|
|
4598
4680
|
db
|
|
4599
|
-
.
|
|
4681
|
+
.query("SELECT COUNT(*) as c FROM messages WHERE created_at > ?")
|
|
4600
4682
|
.get(Date.now() - DAY_MS) as any
|
|
4601
4683
|
).c;
|
|
4602
4684
|
const tasks = (
|
|
4603
|
-
db.
|
|
4685
|
+
db.query("SELECT COUNT(*) as c FROM tasks").get() as any
|
|
4604
4686
|
).c;
|
|
4605
4687
|
const openTasks = (
|
|
4606
|
-
db.
|
|
4688
|
+
db.query("SELECT COUNT(*) as c FROM tasks WHERE status NOT IN ('done', 'failed', 'canceled')").get() as any
|
|
4607
4689
|
).c;
|
|
4608
4690
|
|
|
4609
4691
|
return { version: VERSION, agents, online, messages, messagesLast24h, tasks, openTasks };
|
|
@@ -4613,14 +4695,14 @@ export function getHealth(now: number = Date.now()): HealthReport {
|
|
|
4613
4695
|
const checks: HealthCheck[] = [];
|
|
4614
4696
|
|
|
4615
4697
|
try {
|
|
4616
|
-
db.
|
|
4698
|
+
db.query("SELECT 1").get();
|
|
4617
4699
|
checks.push({ name: "database", status: "ok" });
|
|
4618
4700
|
} catch (e) {
|
|
4619
4701
|
checks.push({ name: "database", status: "error", detail: e instanceof Error ? e.message : "database check failed" });
|
|
4620
4702
|
}
|
|
4621
4703
|
|
|
4622
4704
|
const staleLiveAgents = (db
|
|
4623
|
-
.
|
|
4705
|
+
.query("SELECT COUNT(*) as c FROM agents WHERE status != 'offline' AND last_seen <= ? AND id NOT IN ('user', 'system')")
|
|
4624
4706
|
.get(now - STALE_TTL_MS) as any).c as number;
|
|
4625
4707
|
checks.push({
|
|
4626
4708
|
name: "stale-live-agents",
|
|
@@ -4630,7 +4712,7 @@ export function getHealth(now: number = Date.now()): HealthReport {
|
|
|
4630
4712
|
});
|
|
4631
4713
|
|
|
4632
4714
|
const expiredMessageClaims = (db
|
|
4633
|
-
.
|
|
4715
|
+
.query("SELECT COUNT(*) as c FROM messages WHERE claimed_by IS NOT NULL AND (claim_expires_at IS NULL OR claim_expires_at <= ?) AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.message_id = messages.id AND t.status IN ('done', 'failed', 'canceled'))")
|
|
4634
4716
|
.get(now) as any).c as number;
|
|
4635
4717
|
checks.push({
|
|
4636
4718
|
name: "expired-message-claims",
|
|
@@ -4640,7 +4722,7 @@ export function getHealth(now: number = Date.now()): HealthReport {
|
|
|
4640
4722
|
});
|
|
4641
4723
|
|
|
4642
4724
|
const expiredTaskClaims = (db
|
|
4643
|
-
.
|
|
4725
|
+
.query("SELECT COUNT(*) as c FROM tasks WHERE claimed_by IS NOT NULL AND (claim_expires_at IS NULL OR claim_expires_at <= ?) AND status IN ('claimed', 'in_progress', 'blocked')")
|
|
4644
4726
|
.get(now) as any).c as number;
|
|
4645
4727
|
checks.push({
|
|
4646
4728
|
name: "expired-task-claims",
|
|
@@ -4650,7 +4732,7 @@ export function getHealth(now: number = Date.now()): HealthReport {
|
|
|
4650
4732
|
});
|
|
4651
4733
|
|
|
4652
4734
|
const offlineClaimedTasks = (db
|
|
4653
|
-
.
|
|
4735
|
+
.query(`
|
|
4654
4736
|
SELECT COUNT(*) as c
|
|
4655
4737
|
FROM tasks t
|
|
4656
4738
|
JOIN agents a ON a.id = t.claimed_by
|
|
@@ -4797,12 +4879,12 @@ function parseOrchestratorUpgrade(value: unknown): OrchestratorUpgradeState | un
|
|
|
4797
4879
|
* meta blob (no schema change). Pass null to clear.
|
|
4798
4880
|
*/
|
|
4799
4881
|
export function setOrchestratorUpgradeState(id: string, state: OrchestratorUpgradeState | null): Orchestrator | null {
|
|
4800
|
-
const row = db.
|
|
4882
|
+
const row = db.query("SELECT meta FROM orchestrators WHERE id = ?").get(id) as { meta?: string } | undefined;
|
|
4801
4883
|
if (!row) return null;
|
|
4802
4884
|
const meta = parseJson<Record<string, unknown>>(row.meta ?? "{}", {});
|
|
4803
4885
|
if (state) meta.upgrade = state;
|
|
4804
4886
|
else delete meta.upgrade;
|
|
4805
|
-
db.
|
|
4887
|
+
db.query("UPDATE orchestrators SET meta = ? WHERE id = ?").run(JSON.stringify(meta), id);
|
|
4806
4888
|
return getOrchestrator(id);
|
|
4807
4889
|
}
|
|
4808
4890
|
|
|
@@ -4890,11 +4972,11 @@ export function upsertOrchestrator(input: RegisterOrchestratorInput): Orchestrat
|
|
|
4890
4972
|
// Carry forward server-managed meta the orchestrator never reports (upgrade
|
|
4891
4973
|
// state) — registration meta would otherwise drop it on the very re-register
|
|
4892
4974
|
// that follows a self-upgrade restart, breaking version reconciliation.
|
|
4893
|
-
const existingRow = db.
|
|
4975
|
+
const existingRow = db.query("SELECT meta FROM orchestrators WHERE id = ?").get(input.id) as { meta?: string } | undefined;
|
|
4894
4976
|
const existingMeta = existingRow ? parseJson<Record<string, unknown>>(existingRow.meta ?? "{}", {}) : {};
|
|
4895
4977
|
const mergedMeta = mergeOrchestratorRuntimeMeta(input.meta ?? {}, input);
|
|
4896
4978
|
if (existingMeta.upgrade !== undefined && mergedMeta.upgrade === undefined) mergedMeta.upgrade = existingMeta.upgrade;
|
|
4897
|
-
const stmt = db.
|
|
4979
|
+
const stmt = db.query(`
|
|
4898
4980
|
INSERT INTO orchestrators (id, hostname, status, agent_id, providers, base_dir, api_url, env_keys, meta, last_seen, created_at)
|
|
4899
4981
|
VALUES ($id, $hostname, 'online', $agentId, $providers, $baseDir, $apiUrl, $envKeys, $meta, $now, $now)
|
|
4900
4982
|
ON CONFLICT(id) DO UPDATE SET
|
|
@@ -4944,24 +5026,24 @@ export function upsertOrchestrator(input: RegisterOrchestratorInput): Orchestrat
|
|
|
4944
5026
|
}
|
|
4945
5027
|
|
|
4946
5028
|
export function getOrchestrator(id: string): Orchestrator | null {
|
|
4947
|
-
const row = db.
|
|
5029
|
+
const row = db.query("SELECT * FROM orchestrators WHERE id = ?").get(id) as any;
|
|
4948
5030
|
return row ? rowToOrchestrator(row) : null;
|
|
4949
5031
|
}
|
|
4950
5032
|
|
|
4951
5033
|
export function listOrchestrators(): Orchestrator[] {
|
|
4952
|
-
return (db.
|
|
5034
|
+
return (db.query("SELECT * FROM orchestrators ORDER BY hostname").all() as any[]).map(rowToOrchestrator);
|
|
4953
5035
|
}
|
|
4954
5036
|
|
|
4955
5037
|
export function orchestratorHeartbeat(id: string, runtime: OrchestratorRuntimeInput = {}): Orchestrator | null {
|
|
4956
5038
|
const now = Date.now();
|
|
4957
|
-
const row = db.
|
|
5039
|
+
const row = db.query("SELECT meta FROM orchestrators WHERE id = ?").get(id) as { meta?: string } | undefined;
|
|
4958
5040
|
if (!row) return null;
|
|
4959
5041
|
const meta = mergeOrchestratorRuntimeMeta(parseJson<Record<string, unknown>>(row.meta ?? "{}", {}), runtime);
|
|
4960
5042
|
if (runtime.providers) {
|
|
4961
|
-
db.
|
|
5043
|
+
db.query("UPDATE orchestrators SET last_seen = ?, status = 'online', providers = ?, meta = ? WHERE id = ?")
|
|
4962
5044
|
.run(now, JSON.stringify(runtime.providers), JSON.stringify(meta), id);
|
|
4963
5045
|
} else {
|
|
4964
|
-
db.
|
|
5046
|
+
db.query("UPDATE orchestrators SET last_seen = ?, status = 'online', meta = ? WHERE id = ?").run(now, JSON.stringify(meta), id);
|
|
4965
5047
|
}
|
|
4966
5048
|
// Also heartbeat the agent
|
|
4967
5049
|
const orch = getOrchestrator(id);
|
|
@@ -4993,7 +5075,7 @@ export function updateManagedAgents(id: string, agents: ManagedAgent[]): Orchest
|
|
|
4993
5075
|
},
|
|
4994
5076
|
} : agent;
|
|
4995
5077
|
});
|
|
4996
|
-
db.
|
|
5078
|
+
db.query("UPDATE orchestrators SET managed_agents = ?, last_seen = ? WHERE id = ?")
|
|
4997
5079
|
.run(JSON.stringify(enriched), Date.now(), id);
|
|
4998
5080
|
return getOrchestrator(id);
|
|
4999
5081
|
}
|
|
@@ -5051,7 +5133,7 @@ function upsertWorkspaceFromManagedAgent(agent: ManagedAgent): WorkspaceRecord |
|
|
|
5051
5133
|
|
|
5052
5134
|
export function upsertWorkspace(input: Omit<WorkspaceRecord, "createdAt" | "updatedAt"> & Partial<Pick<WorkspaceRecord, "createdAt" | "updatedAt">>): WorkspaceRecord {
|
|
5053
5135
|
const now = Date.now();
|
|
5054
|
-
db.
|
|
5136
|
+
db.query(`
|
|
5055
5137
|
INSERT INTO workspaces (id, repo_root, source_cwd, worktree_path, branch, base_ref, base_sha, mode, requested_mode, status, owner_agent_id, owner_policy_name, owner_automation_run_id, steward_agent_id, metadata, created_at, updated_at, ready_at, cleaned_at)
|
|
5056
5138
|
VALUES ($id, $repoRoot, $sourceCwd, $worktreePath, $branch, $baseRef, $baseSha, $mode, $requestedMode, $status, $ownerAgentId, $ownerPolicyName, $ownerAutomationRunId, $stewardAgentId, $metadata, $createdAt, $updatedAt, $readyAt, $cleanedAt)
|
|
5057
5139
|
ON CONFLICT(id) DO UPDATE SET
|
|
@@ -5098,7 +5180,7 @@ export function upsertWorkspace(input: Omit<WorkspaceRecord, "createdAt" | "upda
|
|
|
5098
5180
|
}
|
|
5099
5181
|
|
|
5100
5182
|
export function getWorkspace(id: string): WorkspaceRecord | null {
|
|
5101
|
-
const row = db.
|
|
5183
|
+
const row = db.query("SELECT * FROM workspaces WHERE id = ?").get(id) as any;
|
|
5102
5184
|
return row ? rowToWorkspace(row) : null;
|
|
5103
5185
|
}
|
|
5104
5186
|
|
|
@@ -5109,11 +5191,11 @@ export function listWorkspaces(filter: { repoRoot?: string; ownerAgentId?: strin
|
|
|
5109
5191
|
if (filter.ownerAgentId) { where.push("owner_agent_id = ?"); params.push(filter.ownerAgentId); }
|
|
5110
5192
|
if (filter.status) { where.push("status = ?"); params.push(filter.status); }
|
|
5111
5193
|
const sql = `SELECT * FROM workspaces${where.length ? " WHERE " + where.join(" AND ") : ""} ORDER BY updated_at DESC`;
|
|
5112
|
-
return (db.
|
|
5194
|
+
return (db.query(sql).all(...params) as any[]).map(rowToWorkspace);
|
|
5113
5195
|
}
|
|
5114
5196
|
|
|
5115
5197
|
export function deleteWorkspace(id: string): boolean {
|
|
5116
|
-
return db.
|
|
5198
|
+
return db.query("DELETE FROM workspaces WHERE id = ?").run(id).changes > 0;
|
|
5117
5199
|
}
|
|
5118
5200
|
|
|
5119
5201
|
// Shared-mode rows are pure occupancy markers (no worktree on disk) that only
|
|
@@ -5131,9 +5213,9 @@ export function pruneOrphanedSharedWorkspaces(): string[] {
|
|
|
5131
5213
|
OR owner_agent_id NOT IN (SELECT id FROM agents)
|
|
5132
5214
|
OR owner_agent_id IN (SELECT id FROM agents WHERE status = 'offline')
|
|
5133
5215
|
)`;
|
|
5134
|
-
const rows = db.
|
|
5216
|
+
const rows = db.query(`SELECT id FROM workspaces WHERE ${orphanCondition}`).all() as Array<{ id: string }>;
|
|
5135
5217
|
if (!rows.length) return [];
|
|
5136
|
-
db.
|
|
5218
|
+
db.query(`DELETE FROM workspaces WHERE ${orphanCondition}`).run();
|
|
5137
5219
|
return rows.map((r) => r.id);
|
|
5138
5220
|
})();
|
|
5139
5221
|
}
|
|
@@ -5143,7 +5225,7 @@ export function updateWorkspaceStatus(id: string, status: WorkspaceStatus, metad
|
|
|
5143
5225
|
if (!existing) return null;
|
|
5144
5226
|
const nextMeta = { ...existing.metadata, ...metadata };
|
|
5145
5227
|
const now = Date.now();
|
|
5146
|
-
db.
|
|
5228
|
+
db.query(`
|
|
5147
5229
|
UPDATE workspaces
|
|
5148
5230
|
SET status = ?, metadata = ?, updated_at = ?, ready_at = coalesce(ready_at, ?), cleaned_at = coalesce(cleaned_at, ?)
|
|
5149
5231
|
WHERE id = ?
|
|
@@ -5182,19 +5264,19 @@ function rowToRepoSteward(row: any): RepoStewardRecord {
|
|
|
5182
5264
|
}
|
|
5183
5265
|
|
|
5184
5266
|
export function getRepoSteward(repoRoot: string): RepoStewardRecord | null {
|
|
5185
|
-
const row = db.
|
|
5267
|
+
const row = db.query("SELECT * FROM repo_stewards WHERE repo_root = ?").get(repoRoot) as any;
|
|
5186
5268
|
return row ? rowToRepoSteward(row) : null;
|
|
5187
5269
|
}
|
|
5188
5270
|
|
|
5189
5271
|
export function listRepoStewards(): RepoStewardRecord[] {
|
|
5190
|
-
return (db.
|
|
5272
|
+
return (db.query("SELECT * FROM repo_stewards ORDER BY updated_at DESC").all() as any[]).map(rowToRepoSteward);
|
|
5191
5273
|
}
|
|
5192
5274
|
|
|
5193
5275
|
// Persist the elected steward for a repo. The row is never deleted, so a repo's
|
|
5194
5276
|
// stewardship survives a full all-agents-offline gap (steward goes NULL/dormant,
|
|
5195
5277
|
// last_steward_agent_id keeps continuity) and resumes on the next agent join.
|
|
5196
5278
|
function upsertRepoSteward(repoRoot: string, steward: string | null, now: number): void {
|
|
5197
|
-
db.
|
|
5279
|
+
db.query(`
|
|
5198
5280
|
INSERT INTO repo_stewards (repo_root, steward_agent_id, last_steward_agent_id, elected_at, updated_at)
|
|
5199
5281
|
VALUES ($repoRoot, $steward, $steward, $electedAt, $now)
|
|
5200
5282
|
ON CONFLICT(repo_root) DO UPDATE SET
|
|
@@ -5210,7 +5292,7 @@ function upsertRepoSteward(repoRoot: string, steward: string | null, now: number
|
|
|
5210
5292
|
|
|
5211
5293
|
function electWorkspaceStewards(repoRoot?: string): void {
|
|
5212
5294
|
const params: string[] = repoRoot ? [repoRoot] : [];
|
|
5213
|
-
const repoRows = db.
|
|
5295
|
+
const repoRows = db.query(`
|
|
5214
5296
|
SELECT DISTINCT repo_root FROM workspaces
|
|
5215
5297
|
WHERE status IN (${STEWARD_LIVE_STATUSES})
|
|
5216
5298
|
${repoRoot ? "AND repo_root = ?" : ""}
|
|
@@ -5220,7 +5302,7 @@ function electWorkspaceStewards(repoRoot?: string): void {
|
|
|
5220
5302
|
// Candidate pool: owners of live workspaces in this repo who are online,
|
|
5221
5303
|
// oldest first. A steward must be an online agent actively in the repo — an
|
|
5222
5304
|
// offline agent can't coordinate, so it is never elected (the old bug).
|
|
5223
|
-
const pool = (db.
|
|
5305
|
+
const pool = (db.query(`
|
|
5224
5306
|
SELECT w.owner_agent_id AS id, MIN(w.created_at) AS created_at
|
|
5225
5307
|
FROM workspaces w JOIN agents a ON a.id = w.owner_agent_id
|
|
5226
5308
|
WHERE w.repo_root = ? AND w.owner_agent_id IS NOT NULL
|
|
@@ -5239,7 +5321,7 @@ function electWorkspaceStewards(repoRoot?: string): void {
|
|
|
5239
5321
|
// re-elections don't churn updated_at and reset the auto-abandon clock for a
|
|
5240
5322
|
// dormant repo (a stranded review_requested must still age out).
|
|
5241
5323
|
if (steward !== current) {
|
|
5242
|
-
db.
|
|
5324
|
+
db.query(`UPDATE workspaces SET steward_agent_id = ?, updated_at = ? WHERE repo_root = ? AND status IN (${STEWARD_LIVE_STATUSES})`)
|
|
5243
5325
|
.run(steward, now, row.repo_root);
|
|
5244
5326
|
}
|
|
5245
5327
|
}
|
|
@@ -5263,7 +5345,7 @@ export function patchWorkspaceMetadata(id: string, patch: Record<string, unknown
|
|
|
5263
5345
|
if (v === undefined) delete next[k];
|
|
5264
5346
|
else next[k] = v;
|
|
5265
5347
|
}
|
|
5266
|
-
db.
|
|
5348
|
+
db.query("UPDATE workspaces SET metadata = ? WHERE id = ?").run(JSON.stringify(next), id);
|
|
5267
5349
|
return getWorkspace(id);
|
|
5268
5350
|
}
|
|
5269
5351
|
|
|
@@ -5271,7 +5353,7 @@ export function patchWorkspaceMetadata(id: string, patch: Record<string, unknown
|
|
|
5271
5353
|
// when an agent (re)registers so a dormant repo regains a steward on rejoin
|
|
5272
5354
|
// without a full unscoped sweep.
|
|
5273
5355
|
function electWorkspaceStewardsForAgent(agentId: string): void {
|
|
5274
|
-
const repos = db.
|
|
5356
|
+
const repos = db.query(`
|
|
5275
5357
|
SELECT DISTINCT repo_root FROM workspaces
|
|
5276
5358
|
WHERE owner_agent_id = ? AND status IN (${STEWARD_LIVE_STATUSES})
|
|
5277
5359
|
`).all(agentId) as Array<{ repo_root: string }>;
|
|
@@ -5301,18 +5383,18 @@ function rowToMergeLease(row: any): MergeLeaseRecord {
|
|
|
5301
5383
|
}
|
|
5302
5384
|
|
|
5303
5385
|
export function getMergeLease(repoRoot: string): MergeLeaseRecord | null {
|
|
5304
|
-
const row = db.
|
|
5386
|
+
const row = db.query("SELECT * FROM workspace_merge_leases WHERE repo_root = ?").get(repoRoot) as any;
|
|
5305
5387
|
return row ? rowToMergeLease(row) : null;
|
|
5306
5388
|
}
|
|
5307
5389
|
|
|
5308
5390
|
export function listMergeLeases(): MergeLeaseRecord[] {
|
|
5309
|
-
return (db.
|
|
5391
|
+
return (db.query("SELECT * FROM workspace_merge_leases ORDER BY acquired_at DESC").all() as any[]).map(rowToMergeLease);
|
|
5310
5392
|
}
|
|
5311
5393
|
|
|
5312
5394
|
export function releaseExpiredMergeLeases(now: number = Date.now()): string[] {
|
|
5313
|
-
const expired = db.
|
|
5395
|
+
const expired = db.query("SELECT repo_root FROM workspace_merge_leases WHERE expires_at <= ?").all(now) as Array<{ repo_root: string }>;
|
|
5314
5396
|
if (!expired.length) return [];
|
|
5315
|
-
db.
|
|
5397
|
+
db.query("DELETE FROM workspace_merge_leases WHERE expires_at <= ?").run(now);
|
|
5316
5398
|
return expired.map((r) => r.repo_root);
|
|
5317
5399
|
}
|
|
5318
5400
|
|
|
@@ -5329,7 +5411,7 @@ export function acquireMergeLease(
|
|
|
5329
5411
|
const existing = getMergeLease(repoRoot);
|
|
5330
5412
|
if (existing && existing.expiresAt > now) return { ok: false as const, lease: existing };
|
|
5331
5413
|
const expiresAt = now + WORKSPACE_MERGE_LEASE_MS;
|
|
5332
|
-
db.
|
|
5414
|
+
db.query(`
|
|
5333
5415
|
INSERT INTO workspace_merge_leases (repo_root, workspace_id, command_id, holder, acquired_at, expires_at)
|
|
5334
5416
|
VALUES (?, ?, NULL, ?, ?, ?)
|
|
5335
5417
|
ON CONFLICT(repo_root) DO UPDATE SET
|
|
@@ -5343,7 +5425,7 @@ export function acquireMergeLease(
|
|
|
5343
5425
|
// Attach the dispatched command id to a held lease so it can be released by
|
|
5344
5426
|
// command id when the merge settles.
|
|
5345
5427
|
export function setMergeLeaseCommand(repoRoot: string, commandId: string): void {
|
|
5346
|
-
db.
|
|
5428
|
+
db.query("UPDATE workspace_merge_leases SET command_id = ? WHERE repo_root = ?").run(commandId, repoRoot);
|
|
5347
5429
|
}
|
|
5348
5430
|
|
|
5349
5431
|
// Release a merge lease. Guard by commandId/workspaceId when known so a stale
|
|
@@ -5355,26 +5437,26 @@ export function releaseMergeLease(opts: { repoRoot?: string; commandId?: string;
|
|
|
5355
5437
|
if (opts.commandId) { where.push("command_id = ?"); params.push(opts.commandId); }
|
|
5356
5438
|
if (opts.workspaceId) { where.push("workspace_id = ?"); params.push(opts.workspaceId); }
|
|
5357
5439
|
if (!where.length) return false;
|
|
5358
|
-
return db.
|
|
5440
|
+
return db.query(`DELETE FROM workspace_merge_leases WHERE ${where.join(" AND ")}`).run(...params).changes > 0;
|
|
5359
5441
|
}
|
|
5360
5442
|
|
|
5361
5443
|
export function deleteOrchestrator(id: string): boolean {
|
|
5362
5444
|
const orch = getOrchestrator(id);
|
|
5363
5445
|
if (!orch) return false;
|
|
5364
|
-
db.
|
|
5446
|
+
db.query("DELETE FROM orchestrators WHERE id = ?").run(id);
|
|
5365
5447
|
deleteAgent(orch.agentId);
|
|
5366
5448
|
return true;
|
|
5367
5449
|
}
|
|
5368
5450
|
|
|
5369
5451
|
export function reapStaleOrchestrators(): string[] {
|
|
5370
5452
|
const cutoff = Date.now() - STALE_TTL_MS;
|
|
5371
|
-
const stale = db.
|
|
5453
|
+
const stale = db.query("SELECT id, agent_id FROM orchestrators WHERE last_seen < ? AND status = 'online'").all(cutoff) as any[];
|
|
5372
5454
|
for (const row of stale) {
|
|
5373
|
-
db.
|
|
5455
|
+
db.query("UPDATE orchestrators SET status = 'offline' WHERE id = ?").run(row.id);
|
|
5374
5456
|
// An orchestrator agent holds no workspaces, so use a direct status update
|
|
5375
5457
|
// instead of setStatus() (which triggers an unscoped electWorkspaceStewards
|
|
5376
5458
|
// sweep across all repos on every offline transition).
|
|
5377
|
-
db.
|
|
5459
|
+
db.query("UPDATE agents SET status = 'offline', ready = 0 WHERE id = ?").run(row.agent_id);
|
|
5378
5460
|
}
|
|
5379
5461
|
return stale.map((row: any) => row.id);
|
|
5380
5462
|
}
|