agent-relay-server 0.15.1 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/automations.ts +17 -17
- 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 +12 -12
- package/src/db.ts +289 -226
- package/src/insights-db.ts +6 -6
- package/src/lifecycle-manager.ts +3 -3
- package/src/maintenance.ts +25 -6
- 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/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 (
|
|
@@ -794,7 +846,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
794
846
|
normalizeExistingMessageReactions();
|
|
795
847
|
|
|
796
848
|
// Migrations
|
|
797
|
-
const cols = db.
|
|
849
|
+
const cols = db.query("PRAGMA table_info(messages)").all() as any[];
|
|
798
850
|
const colNames = cols.map((c: any) => c.name);
|
|
799
851
|
if (!colNames.includes("thread_id")) {
|
|
800
852
|
db.run("ALTER TABLE messages ADD COLUMN thread_id INTEGER");
|
|
@@ -838,7 +890,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
838
890
|
if (!colNames.includes("resolved_to_agent")) {
|
|
839
891
|
db.run("ALTER TABLE messages ADD COLUMN resolved_to_agent TEXT");
|
|
840
892
|
}
|
|
841
|
-
db.
|
|
893
|
+
db.query(
|
|
842
894
|
"UPDATE messages SET claim_expires_at = coalesce(claimed_at, ?) + ? WHERE claimed_by IS NOT NULL AND claim_expires_at IS NULL",
|
|
843
895
|
).run(Date.now(), CLAIM_LEASE_MS);
|
|
844
896
|
db.run("CREATE INDEX IF NOT EXISTS idx_msg_thread ON messages(thread_id)");
|
|
@@ -846,7 +898,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
846
898
|
db.run("CREATE INDEX IF NOT EXISTS idx_msg_delivery_status ON messages(delivery_status)");
|
|
847
899
|
db.run("CREATE INDEX IF NOT EXISTS idx_msg_resolved_to_agent ON messages(resolved_to_agent)");
|
|
848
900
|
|
|
849
|
-
const tokenCols = db.
|
|
901
|
+
const tokenCols = db.query("PRAGMA table_info(tokens)").all() as any[];
|
|
850
902
|
const tokenColNames = tokenCols.map((c: any) => c.name);
|
|
851
903
|
if (!tokenColNames.includes("constraints")) {
|
|
852
904
|
db.run("ALTER TABLE tokens ADD COLUMN constraints TEXT");
|
|
@@ -870,7 +922,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
870
922
|
`);
|
|
871
923
|
db.run("CREATE INDEX IF NOT EXISTS idx_mda_message ON message_delivery_attempts(message_id, created_at DESC)");
|
|
872
924
|
|
|
873
|
-
const channelBindingsSql = db.
|
|
925
|
+
const channelBindingsSql = db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'channel_bindings'").get() as { sql?: string } | undefined;
|
|
874
926
|
if (channelBindingsSql?.sql?.includes("UNIQUE(channel_id, conversation_key)")) {
|
|
875
927
|
db.transaction(() => {
|
|
876
928
|
db.run("ALTER TABLE channel_bindings RENAME TO channel_bindings_old");
|
|
@@ -899,7 +951,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
899
951
|
db.run("CREATE INDEX IF NOT EXISTS idx_channel_bindings_channel ON channel_bindings(channel_id, priority)");
|
|
900
952
|
db.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_bindings_target ON channel_bindings(channel_id, conversation_key, target_type, target_id)");
|
|
901
953
|
|
|
902
|
-
const bindingColNames = (db.
|
|
954
|
+
const bindingColNames = (db.query("PRAGMA table_info(channel_bindings)").all() as any[]).map((c: any) => c.name);
|
|
903
955
|
if (!bindingColNames.includes("pool_selector")) {
|
|
904
956
|
db.run("ALTER TABLE channel_bindings ADD COLUMN pool_selector TEXT");
|
|
905
957
|
db.run("ALTER TABLE channel_bindings ADD COLUMN pool_agent_id TEXT");
|
|
@@ -918,23 +970,23 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
918
970
|
// Backfill thread_id for pre-migration rows (self-threaded).
|
|
919
971
|
db.run("UPDATE messages SET thread_id = id WHERE thread_id IS NULL");
|
|
920
972
|
|
|
921
|
-
const taskCols = db.
|
|
973
|
+
const taskCols = db.query("PRAGMA table_info(tasks)").all() as any[];
|
|
922
974
|
const taskColNames = taskCols.map((c: any) => c.name);
|
|
923
975
|
if (!taskColNames.includes("claim_expires_at")) {
|
|
924
976
|
db.run("ALTER TABLE tasks ADD COLUMN claim_expires_at INTEGER");
|
|
925
977
|
}
|
|
926
|
-
db.
|
|
978
|
+
db.query(
|
|
927
979
|
"UPDATE tasks SET claim_expires_at = coalesce(claimed_at, ?) + ? WHERE claimed_by IS NOT NULL AND claim_expires_at IS NULL",
|
|
928
980
|
).run(Date.now(), CLAIM_LEASE_MS);
|
|
929
981
|
|
|
930
982
|
// Migration: orchestrators.api_url
|
|
931
|
-
const orchCols = db.
|
|
983
|
+
const orchCols = db.query("PRAGMA table_info(orchestrators)").all() as any[];
|
|
932
984
|
const orchColNames = orchCols.map((c: any) => c.name);
|
|
933
985
|
if (!orchColNames.includes("api_url")) {
|
|
934
986
|
db.run("ALTER TABLE orchestrators ADD COLUMN api_url TEXT");
|
|
935
987
|
}
|
|
936
988
|
|
|
937
|
-
const managedStateCols = db.
|
|
989
|
+
const managedStateCols = db.query("PRAGMA table_info(managed_agent_state)").all() as any[];
|
|
938
990
|
const managedStateColNames = managedStateCols.map((c: any) => c.name);
|
|
939
991
|
if (!managedStateColNames.includes("workspace_id")) {
|
|
940
992
|
db.run("ALTER TABLE managed_agent_state ADD COLUMN workspace_id TEXT");
|
|
@@ -954,7 +1006,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
954
1006
|
`);
|
|
955
1007
|
|
|
956
1008
|
// Migration: agents.label
|
|
957
|
-
const agentCols = db.
|
|
1009
|
+
const agentCols = db.query("PRAGMA table_info(agents)").all() as any[];
|
|
958
1010
|
const agentColNames = agentCols.map((c: any) => c.name);
|
|
959
1011
|
if (!agentColNames.includes("label")) {
|
|
960
1012
|
db.run("ALTER TABLE agents ADD COLUMN label TEXT");
|
|
@@ -1076,7 +1128,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
1076
1128
|
// pass the sendMessage validation. The reaper exempts these by checking
|
|
1077
1129
|
// meta.builtin (or by id for "user").
|
|
1078
1130
|
const now = Date.now();
|
|
1079
|
-
const builtinStmt = db.
|
|
1131
|
+
const builtinStmt = db.query(`
|
|
1080
1132
|
INSERT INTO agents (id, name, kind, tags, machine, rig, capabilities, ready, status, meta, last_seen, created_at)
|
|
1081
1133
|
VALUES (?, ?, ?, ?, NULL, NULL, '[]', 1, 'online', '{"builtin":true}', ?, ?)
|
|
1082
1134
|
ON CONFLICT(id) DO UPDATE SET status = 'online', ready = 1, last_seen = excluded.last_seen
|
|
@@ -1095,6 +1147,15 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
1095
1147
|
`);
|
|
1096
1148
|
}
|
|
1097
1149
|
|
|
1150
|
+
// Bootstrap planner statistics on first run (sqlite_stat1 absent means ANALYZE
|
|
1151
|
+
// has never run — the planner would otherwise rely on heuristics only), then
|
|
1152
|
+
// let PRAGMA optimize apply/refresh them cheaply on every startup.
|
|
1153
|
+
const hasStats = db
|
|
1154
|
+
.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'sqlite_stat1'")
|
|
1155
|
+
.get();
|
|
1156
|
+
if (!hasStats) db.run("ANALYZE");
|
|
1157
|
+
db.run("PRAGMA optimize");
|
|
1158
|
+
|
|
1098
1159
|
return db;
|
|
1099
1160
|
}
|
|
1100
1161
|
|
|
@@ -1687,7 +1748,7 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
|
|
|
1687
1748
|
const labelProvided = Object.prototype.hasOwnProperty.call(input, "label");
|
|
1688
1749
|
const readyProvided = Object.prototype.hasOwnProperty.call(input, "ready");
|
|
1689
1750
|
const instanceProvided = Boolean(input.instanceId);
|
|
1690
|
-
const stmt = db.
|
|
1751
|
+
const stmt = db.query(`
|
|
1691
1752
|
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
1753
|
VALUES ($id, $name, $kind, $label, $tags, $machine, $rig, $capabilities, $ready, $status, $instanceId, $initialEpoch, $providerCapabilities, $contextState, $meta, $now, $now)
|
|
1693
1754
|
ON CONFLICT(id) DO UPDATE SET
|
|
@@ -1759,18 +1820,18 @@ export function validateAgentSession(id: string, guard?: AgentSessionGuard): { o
|
|
|
1759
1820
|
export function setLabel(id: string, label: string | null): boolean {
|
|
1760
1821
|
const normalized = label && label.trim() ? label.trim() : null;
|
|
1761
1822
|
return (
|
|
1762
|
-
db.
|
|
1823
|
+
db.query("UPDATE agents SET label = ? WHERE id = ?").run(normalized, id).changes > 0
|
|
1763
1824
|
);
|
|
1764
1825
|
}
|
|
1765
1826
|
|
|
1766
1827
|
export function setTags(id: string, tags: string[]): AgentCard | null {
|
|
1767
1828
|
const normalized = [...new Set(tags.map((tag) => tag.trim()).filter(Boolean))];
|
|
1768
|
-
const result = db.
|
|
1829
|
+
const result = db.query("UPDATE agents SET tags = ?, last_seen = ? WHERE id = ?").run(JSON.stringify(normalized), Date.now(), id);
|
|
1769
1830
|
return result.changes > 0 ? getAgent(id) : null;
|
|
1770
1831
|
}
|
|
1771
1832
|
|
|
1772
1833
|
export function getAgent(id: string): AgentCard | null {
|
|
1773
|
-
const row = db.
|
|
1834
|
+
const row = db.query("SELECT * FROM agents WHERE id = ?").get(id) as any;
|
|
1774
1835
|
return row ? rowToAgent(row) : null;
|
|
1775
1836
|
}
|
|
1776
1837
|
|
|
@@ -1796,7 +1857,7 @@ export function listAgents(filter?: {
|
|
|
1796
1857
|
}
|
|
1797
1858
|
|
|
1798
1859
|
sql += " ORDER BY last_seen DESC";
|
|
1799
|
-
return (db.
|
|
1860
|
+
return (db.query(sql).all(...params) as any[]).map(rowToAgent);
|
|
1800
1861
|
}
|
|
1801
1862
|
|
|
1802
1863
|
export function setStatus(id: string, status: AgentCard["status"], guard?: AgentSessionGuard): boolean {
|
|
@@ -1806,7 +1867,7 @@ export function setStatus(id: string, status: AgentCard["status"], guard?: Agent
|
|
|
1806
1867
|
const sql = ready === 0
|
|
1807
1868
|
? "UPDATE agents SET status = ?, ready = 0, last_seen = ? WHERE id = ?"
|
|
1808
1869
|
: "UPDATE agents SET status = ?, last_seen = ? WHERE id = ?";
|
|
1809
|
-
const changed = db.
|
|
1870
|
+
const changed = db.query(sql).run(status, now, id).changes > 0;
|
|
1810
1871
|
if (changed && status === "offline") closeOpenPairsForAgent(id, now);
|
|
1811
1872
|
if (changed && status === "offline") electWorkspaceStewards();
|
|
1812
1873
|
return changed;
|
|
@@ -1817,7 +1878,7 @@ export function markReady(id: string, ready: boolean, guard?: AgentSessionGuard)
|
|
|
1817
1878
|
const now = Date.now();
|
|
1818
1879
|
return (
|
|
1819
1880
|
db
|
|
1820
|
-
.
|
|
1881
|
+
.query("UPDATE agents SET ready = ?, last_seen = ? WHERE id = ?")
|
|
1821
1882
|
.run(ready ? 1 : 0, now, id).changes > 0
|
|
1822
1883
|
);
|
|
1823
1884
|
}
|
|
@@ -1828,7 +1889,7 @@ export function mergeAgentMeta(id: string, meta: Record<string, unknown>, guard?
|
|
|
1828
1889
|
if (!agent) return false;
|
|
1829
1890
|
const merged = { ...(agent.meta ?? {}), ...meta };
|
|
1830
1891
|
const result = db
|
|
1831
|
-
.
|
|
1892
|
+
.query("UPDATE agents SET meta = ?, last_seen = ? WHERE id = ?")
|
|
1832
1893
|
.run(JSON.stringify(merged), Date.now(), id);
|
|
1833
1894
|
return result.changes > 0;
|
|
1834
1895
|
}
|
|
@@ -1844,7 +1905,7 @@ export function recordAgentExitDiagnostics(id: string, diagnostics: ManagedSessi
|
|
|
1844
1905
|
lastExitAt: diagnostics.detectedAt || now,
|
|
1845
1906
|
};
|
|
1846
1907
|
const result = db
|
|
1847
|
-
.
|
|
1908
|
+
.query("UPDATE agents SET status = 'offline', ready = 0, meta = ?, last_seen = ? WHERE id = ?")
|
|
1848
1909
|
.run(JSON.stringify(merged), now, id);
|
|
1849
1910
|
if (result.changes <= 0) return null;
|
|
1850
1911
|
closeOpenPairsForAgent(id, now);
|
|
@@ -1860,7 +1921,7 @@ export function heartbeat(
|
|
|
1860
1921
|
const now = Date.now();
|
|
1861
1922
|
if (runtime?.providerCapabilities || runtime?.context) {
|
|
1862
1923
|
const result = db
|
|
1863
|
-
.
|
|
1924
|
+
.query(`
|
|
1864
1925
|
UPDATE agents SET
|
|
1865
1926
|
last_seen = ?,
|
|
1866
1927
|
status = CASE WHEN status = 'offline' THEN 'idle' ELSE status END,
|
|
@@ -1878,7 +1939,7 @@ export function heartbeat(
|
|
|
1878
1939
|
return result.changes > 0;
|
|
1879
1940
|
}
|
|
1880
1941
|
const result = db
|
|
1881
|
-
.
|
|
1942
|
+
.query("UPDATE agents SET last_seen = ?, status = CASE WHEN status = 'offline' THEN 'idle' ELSE status END WHERE id = ?")
|
|
1882
1943
|
.run(now, id);
|
|
1883
1944
|
return result.changes > 0;
|
|
1884
1945
|
}
|
|
@@ -1893,23 +1954,23 @@ export function listContextSnapshots(agentId: string, options: { since?: number;
|
|
|
1893
1954
|
}
|
|
1894
1955
|
sql += " ORDER BY captured_at ASC LIMIT ?";
|
|
1895
1956
|
params.push(limit);
|
|
1896
|
-
return (db.
|
|
1957
|
+
return (db.query(sql).all(...params) as any[]).map(rowToContextSnapshot);
|
|
1897
1958
|
}
|
|
1898
1959
|
|
|
1899
1960
|
function pruneContextSnapshots(agentId?: string, olderThan = Date.now() - DAY_MS): number {
|
|
1900
1961
|
const result = agentId
|
|
1901
|
-
? db.
|
|
1902
|
-
: db.
|
|
1962
|
+
? db.query("DELETE FROM context_snapshots WHERE agent_id = ? AND captured_at < ?").run(agentId, olderThan)
|
|
1963
|
+
: db.query("DELETE FROM context_snapshots WHERE captured_at < ?").run(olderThan);
|
|
1903
1964
|
return result.changes;
|
|
1904
1965
|
}
|
|
1905
1966
|
|
|
1906
1967
|
function recordContextSnapshot(agentId: string, context: ContextState, now: number): void {
|
|
1907
1968
|
const latest = db
|
|
1908
|
-
.
|
|
1969
|
+
.query("SELECT captured_at FROM context_snapshots WHERE agent_id = ? ORDER BY captured_at DESC LIMIT 1")
|
|
1909
1970
|
.get(agentId) as { captured_at: number } | undefined;
|
|
1910
1971
|
if (latest && latest.captured_at > now - CONTEXT_SNAPSHOT_DEBOUNCE_MS) return;
|
|
1911
1972
|
|
|
1912
|
-
db.
|
|
1973
|
+
db.query(`
|
|
1913
1974
|
INSERT INTO context_snapshots (
|
|
1914
1975
|
agent_id,
|
|
1915
1976
|
utilization,
|
|
@@ -1999,7 +2060,7 @@ function upsertChannelForAgent(agent: AgentCard): void {
|
|
|
1999
2060
|
? (agent.meta.topicChannels as unknown[]).filter((item): item is string => typeof item === "string" && item.trim().length > 0)
|
|
2000
2061
|
: [];
|
|
2001
2062
|
|
|
2002
|
-
db.
|
|
2063
|
+
db.query(`
|
|
2003
2064
|
INSERT INTO channels (id, provider, account_id, display_name, agent_id, transport, direction, topic_channels, capabilities, meta, created_at, updated_at)
|
|
2004
2065
|
VALUES ($id, $provider, $accountId, $displayName, $agentId, $transport, $direction, $topicChannels, $capabilities, $meta, $now, $now)
|
|
2005
2066
|
ON CONFLICT(provider, account_id) DO UPDATE SET
|
|
@@ -2039,7 +2100,7 @@ function upsertChannelForAgent(agent: AgentCard): void {
|
|
|
2039
2100
|
}
|
|
2040
2101
|
|
|
2041
2102
|
export function listChannels(): ChannelSummary[] {
|
|
2042
|
-
const rows = db.
|
|
2103
|
+
const rows = db.query(`
|
|
2043
2104
|
SELECT
|
|
2044
2105
|
c.*,
|
|
2045
2106
|
c.capabilities AS channel_capabilities,
|
|
@@ -2074,8 +2135,8 @@ export function getChannel(channelId: string): ChannelSummary | null {
|
|
|
2074
2135
|
|
|
2075
2136
|
export function listChannelBindings(channelId?: string): ChannelBinding[] {
|
|
2076
2137
|
const rows = channelId
|
|
2077
|
-
? db.
|
|
2078
|
-
: db.
|
|
2138
|
+
? db.query("SELECT * FROM channel_bindings WHERE channel_id = ? ORDER BY priority DESC, updated_at DESC").all(channelId) as any[]
|
|
2139
|
+
: db.query("SELECT * FROM channel_bindings ORDER BY channel_id, priority DESC, updated_at DESC").all() as any[];
|
|
2079
2140
|
return rows.map(rowToChannelBinding);
|
|
2080
2141
|
}
|
|
2081
2142
|
|
|
@@ -2097,9 +2158,9 @@ export function upsertChannelBinding(input: {
|
|
|
2097
2158
|
const now = Date.now();
|
|
2098
2159
|
db.transaction(() => {
|
|
2099
2160
|
if (mode === "exclusive") {
|
|
2100
|
-
db.
|
|
2161
|
+
db.query("DELETE FROM channel_bindings WHERE channel_id = ? AND conversation_key = ?").run(input.channelId, conversationKey);
|
|
2101
2162
|
}
|
|
2102
|
-
db.
|
|
2163
|
+
db.query(`
|
|
2103
2164
|
INSERT INTO channel_bindings (id, channel_id, conversation_key, conversation_id, target_type, target_id, mode, priority, pool_selector, created_at, updated_at)
|
|
2104
2165
|
VALUES ($id, $channelId, $conversationKey, $conversationId, $targetType, $targetId, $mode, $priority, $poolSelector, $now, $now)
|
|
2105
2166
|
ON CONFLICT(channel_id, conversation_key, target_type, target_id) DO UPDATE SET
|
|
@@ -2122,7 +2183,7 @@ export function upsertChannelBinding(input: {
|
|
|
2122
2183
|
})();
|
|
2123
2184
|
|
|
2124
2185
|
evaluatePoolBindings(now);
|
|
2125
|
-
const row = db.
|
|
2186
|
+
const row = db.query("SELECT * FROM channel_bindings WHERE id = ?").get(id) as any;
|
|
2126
2187
|
return rowToChannelBinding(row);
|
|
2127
2188
|
}
|
|
2128
2189
|
|
|
@@ -2134,7 +2195,7 @@ interface PoolBindingChange {
|
|
|
2134
2195
|
}
|
|
2135
2196
|
|
|
2136
2197
|
export function evaluatePoolBindings(now: number = Date.now()): PoolBindingChange[] {
|
|
2137
|
-
const rows = db.
|
|
2198
|
+
const rows = db.query("SELECT * FROM channel_bindings WHERE target_type = 'pool'").all() as any[];
|
|
2138
2199
|
const changes: PoolBindingChange[] = [];
|
|
2139
2200
|
|
|
2140
2201
|
for (const row of rows) {
|
|
@@ -2149,7 +2210,7 @@ export function evaluatePoolBindings(now: number = Date.now()): PoolBindingChang
|
|
|
2149
2210
|
const holder = getAgent(currentAgentId);
|
|
2150
2211
|
if (holder && holder.status !== "offline" && holder.ready && holder.lastSeen > now - STALE_TTL_MS && agentCanServeChannel(holder, channelId)) {
|
|
2151
2212
|
if (currentEpoch === null || holder.epoch === currentEpoch) {
|
|
2152
|
-
db.
|
|
2213
|
+
db.query("UPDATE channel_bindings SET pool_claim_expires_at = ? WHERE id = ?")
|
|
2153
2214
|
.run(now + POOL_CLAIM_LEASE_MS, bindingId);
|
|
2154
2215
|
holderValid = true;
|
|
2155
2216
|
}
|
|
@@ -2157,7 +2218,7 @@ export function evaluatePoolBindings(now: number = Date.now()): PoolBindingChang
|
|
|
2157
2218
|
}
|
|
2158
2219
|
|
|
2159
2220
|
if (!holderValid && currentAgentId) {
|
|
2160
|
-
db.
|
|
2221
|
+
db.query("UPDATE channel_bindings SET pool_agent_id = NULL, pool_agent_epoch = NULL, pool_claim_expires_at = NULL WHERE id = ?")
|
|
2161
2222
|
.run(bindingId);
|
|
2162
2223
|
changes.push({ bindingId, channelId, previousAgentId: currentAgentId, newAgentId: null });
|
|
2163
2224
|
}
|
|
@@ -2173,7 +2234,7 @@ export function evaluatePoolBindings(now: number = Date.now()): PoolBindingChang
|
|
|
2173
2234
|
|
|
2174
2235
|
if (eligible.length > 0) {
|
|
2175
2236
|
const picked = eligible[0]!;
|
|
2176
|
-
db.
|
|
2237
|
+
db.query("UPDATE channel_bindings SET pool_agent_id = ?, pool_agent_epoch = ?, pool_claim_expires_at = ? WHERE id = ?")
|
|
2177
2238
|
.run(picked.id, picked.epoch, now + POOL_CLAIM_LEASE_MS, bindingId);
|
|
2178
2239
|
const lastChange = changes[changes.length - 1];
|
|
2179
2240
|
if (lastChange && lastChange.bindingId === bindingId && lastChange.newAgentId === null) {
|
|
@@ -2190,12 +2251,12 @@ export function evaluatePoolBindings(now: number = Date.now()): PoolBindingChang
|
|
|
2190
2251
|
|
|
2191
2252
|
export function resolveChannelRoutes(channelId: string, conversationId?: string): ChannelBinding[] {
|
|
2192
2253
|
const rows = conversationId
|
|
2193
|
-
? db.
|
|
2254
|
+
? db.query(`
|
|
2194
2255
|
SELECT * FROM channel_bindings
|
|
2195
2256
|
WHERE channel_id = ? AND conversation_key IN (?, '')
|
|
2196
2257
|
ORDER BY CASE WHEN conversation_key = ? THEN 1 ELSE 0 END DESC, priority DESC, updated_at DESC
|
|
2197
2258
|
`).all(channelId, conversationId, conversationId) as any[]
|
|
2198
|
-
: db.
|
|
2259
|
+
: db.query(`
|
|
2199
2260
|
SELECT * FROM channel_bindings
|
|
2200
2261
|
WHERE channel_id = ? AND conversation_key = ''
|
|
2201
2262
|
ORDER BY priority DESC, updated_at DESC
|
|
@@ -2208,9 +2269,9 @@ export function resolveChannelRoutes(channelId: string, conversationId?: string)
|
|
|
2208
2269
|
export function reapStaleAgents(ttlMs: number = STALE_TTL_MS): string[] {
|
|
2209
2270
|
const now = Date.now();
|
|
2210
2271
|
const cutoff = now - ttlMs;
|
|
2211
|
-
db.
|
|
2272
|
+
db.query("UPDATE agents SET last_seen = ? WHERE id IN ('user', 'system')").run(now);
|
|
2212
2273
|
const rows = db
|
|
2213
|
-
.
|
|
2274
|
+
.query(
|
|
2214
2275
|
"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
2276
|
)
|
|
2216
2277
|
.all(cutoff) as any[];
|
|
@@ -2232,9 +2293,9 @@ export function reapStaleAgents(ttlMs: number = STALE_TTL_MS): string[] {
|
|
|
2232
2293
|
// re-open (gated on status IN claimed/in_progress/blocked/orphaned) then skips.
|
|
2233
2294
|
function settleSingleTargetOnDemandTasks(condition: string, params: any[], now: number, reason: string): void {
|
|
2234
2295
|
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.
|
|
2296
|
+
const rows = db.query(selectSql).all(...params) as any[];
|
|
2236
2297
|
if (rows.length === 0) return;
|
|
2237
|
-
db.
|
|
2298
|
+
db.query(
|
|
2238
2299
|
`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
2300
|
).run(now, now, ...params);
|
|
2240
2301
|
for (const row of rows) {
|
|
@@ -2253,7 +2314,7 @@ export function pruneOfflineAgents(maxOfflineMs: number = DAY_MS): string[] {
|
|
|
2253
2314
|
const cutoff = Date.now() - maxOfflineMs;
|
|
2254
2315
|
return db.transaction(() => {
|
|
2255
2316
|
const rows = db
|
|
2256
|
-
.
|
|
2317
|
+
.query(
|
|
2257
2318
|
"SELECT id FROM agents WHERE status = 'offline' AND last_seen < ? AND id NOT IN ('user', 'system')"
|
|
2258
2319
|
)
|
|
2259
2320
|
.all(cutoff) as any[];
|
|
@@ -2262,7 +2323,7 @@ export function pruneOfflineAgents(maxOfflineMs: number = DAY_MS): string[] {
|
|
|
2262
2323
|
|
|
2263
2324
|
// Release claims held by pruned agents so work becomes claimable again.
|
|
2264
2325
|
db
|
|
2265
|
-
.
|
|
2326
|
+
.query(
|
|
2266
2327
|
"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
2328
|
)
|
|
2268
2329
|
.run(cutoff);
|
|
@@ -2271,7 +2332,7 @@ export function pruneOfflineAgents(maxOfflineMs: number = DAY_MS): string[] {
|
|
|
2271
2332
|
settleSingleTargetOnDemandTasks(offlineClaimCondition, [cutoff], now, "agent-pruned");
|
|
2272
2333
|
|
|
2273
2334
|
db
|
|
2274
|
-
.
|
|
2335
|
+
.query(
|
|
2275
2336
|
`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
2337
|
)
|
|
2277
2338
|
.run(now, cutoff);
|
|
@@ -2282,7 +2343,7 @@ export function pruneOfflineAgents(maxOfflineMs: number = DAY_MS): string[] {
|
|
|
2282
2343
|
}
|
|
2283
2344
|
|
|
2284
2345
|
db
|
|
2285
|
-
.
|
|
2346
|
+
.query(
|
|
2286
2347
|
"DELETE FROM agents WHERE status = 'offline' AND last_seen < ? AND id NOT IN ('user', 'system')"
|
|
2287
2348
|
)
|
|
2288
2349
|
.run(cutoff);
|
|
@@ -2299,7 +2360,7 @@ export function findAgentsByCapability(capability: string, onlineOnly = true): A
|
|
|
2299
2360
|
params.push(Date.now() - STALE_TTL_MS);
|
|
2300
2361
|
}
|
|
2301
2362
|
sql += " ORDER BY last_seen DESC";
|
|
2302
|
-
return (db.
|
|
2363
|
+
return (db.query(sql).all(...params) as any[]).map(rowToAgent);
|
|
2303
2364
|
}
|
|
2304
2365
|
|
|
2305
2366
|
export function deleteAgent(id: string): { ok: boolean; error?: string } {
|
|
@@ -2310,23 +2371,23 @@ export function deleteAgent(id: string): { ok: boolean; error?: string } {
|
|
|
2310
2371
|
// Release any claims held by this agent so the tasks become claimable again.
|
|
2311
2372
|
// from_agent is left intact as historical record.
|
|
2312
2373
|
const now = Date.now();
|
|
2313
|
-
db.
|
|
2374
|
+
db.query("UPDATE messages SET claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL WHERE claimed_by = ?").run(id);
|
|
2314
2375
|
settleSingleTargetOnDemandTasks("claimed_by = ?", [id], now, "agent-removed");
|
|
2315
|
-
db.
|
|
2376
|
+
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
2377
|
revokeRuntimeTokensForAgent(id, now);
|
|
2317
2378
|
closeOpenPairsForAgent(id, now);
|
|
2318
|
-
return db.
|
|
2379
|
+
return db.query("DELETE FROM agents WHERE id = ?").run(id).changes > 0;
|
|
2319
2380
|
})();
|
|
2320
2381
|
return deleted ? { ok: true } : { ok: false, error: "agent not found" };
|
|
2321
2382
|
}
|
|
2322
2383
|
|
|
2323
2384
|
export function revokeRuntimeTokensForAgent(agentId: string, now = Date.now()): string[] {
|
|
2324
|
-
const row = db.
|
|
2385
|
+
const row = db.query("SELECT meta FROM agents WHERE id = ?").get(agentId) as { meta?: string } | undefined;
|
|
2325
2386
|
const jtis = runtimeTokenJtisFromMeta(parseJson(row?.meta ?? "{}", {}));
|
|
2326
2387
|
if (jtis.length === 0) return [];
|
|
2327
2388
|
const revokedAt = Math.floor(now / 1000);
|
|
2328
2389
|
const placeholders = jtis.map(() => "?").join(",");
|
|
2329
|
-
const rows = db.
|
|
2390
|
+
const rows = db.query(`
|
|
2330
2391
|
UPDATE tokens
|
|
2331
2392
|
SET revoked_at = ?
|
|
2332
2393
|
WHERE revoked_at IS NULL
|
|
@@ -2360,13 +2421,13 @@ const TASK_SELECT = "SELECT * FROM tasks";
|
|
|
2360
2421
|
|
|
2361
2422
|
function findOpenTaskByDedupe(source: string, dedupeKey: string): Task | null {
|
|
2362
2423
|
const row = db
|
|
2363
|
-
.
|
|
2424
|
+
.query(`${TASK_SELECT} WHERE source = ? AND dedupe_key = ? AND status NOT IN ('done', 'failed', 'canceled') ORDER BY id DESC LIMIT 1`)
|
|
2364
2425
|
.get(source, dedupeKey) as any;
|
|
2365
2426
|
return row ? rowToTask(row) : null;
|
|
2366
2427
|
}
|
|
2367
2428
|
|
|
2368
2429
|
function insertTaskEvent(taskId: number, event: Required<Omit<TaskEvent, "id" | "taskId" | "createdAt">>, now: number): TaskEvent {
|
|
2369
|
-
const result = db.
|
|
2430
|
+
const result = db.query(`
|
|
2370
2431
|
INSERT INTO task_events (task_id, source, type, severity, title, body, metadata, created_at)
|
|
2371
2432
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
2372
2433
|
`).run(taskId, event.source, event.type, event.severity, event.title, event.body, JSON.stringify(event.metadata), now);
|
|
@@ -2374,7 +2435,7 @@ function insertTaskEvent(taskId: number, event: Required<Omit<TaskEvent, "id" |
|
|
|
2374
2435
|
}
|
|
2375
2436
|
|
|
2376
2437
|
function getTaskEvent(id: number): TaskEvent | null {
|
|
2377
|
-
const row = db.
|
|
2438
|
+
const row = db.query("SELECT * FROM task_events WHERE id = ?").get(id) as any;
|
|
2378
2439
|
return row ? rowToTaskEvent(row) : null;
|
|
2379
2440
|
}
|
|
2380
2441
|
|
|
@@ -2406,7 +2467,7 @@ export function ingestIntegrationEvent(input: IntegrationEventInput, integration
|
|
|
2406
2467
|
let created = false;
|
|
2407
2468
|
|
|
2408
2469
|
if (existing) {
|
|
2409
|
-
db.
|
|
2470
|
+
db.query(`
|
|
2410
2471
|
UPDATE tasks
|
|
2411
2472
|
SET title = ?, body = ?, severity = ?, target = ?, channel = ?, external_url = ?,
|
|
2412
2473
|
occurrence_count = occurrence_count + 1, metadata = ?, updated_at = ?, last_seen_at = ?,
|
|
@@ -2431,7 +2492,7 @@ export function ingestIntegrationEvent(input: IntegrationEventInput, integration
|
|
|
2431
2492
|
);
|
|
2432
2493
|
taskId = existing.id;
|
|
2433
2494
|
} else {
|
|
2434
|
-
const result = db.
|
|
2495
|
+
const result = db.query(`
|
|
2435
2496
|
INSERT INTO tasks (source, title, body, severity, status, target, channel, dedupe_key, external_url, metadata, created_at, updated_at, last_seen_at)
|
|
2436
2497
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2437
2498
|
`).run(
|
|
@@ -2485,7 +2546,7 @@ export function ingestIntegrationEvent(input: IntegrationEventInput, integration
|
|
|
2485
2546
|
...(attachmentRefs.length ? { attachments: attachmentRefs } : {}),
|
|
2486
2547
|
},
|
|
2487
2548
|
});
|
|
2488
|
-
db.
|
|
2549
|
+
db.query("UPDATE tasks SET message_id = ?, updated_at = ? WHERE id = ?").run(message.id, now, task.id);
|
|
2489
2550
|
}
|
|
2490
2551
|
|
|
2491
2552
|
return { task: getTask(taskId)!, event, created, message };
|
|
@@ -2493,7 +2554,7 @@ export function ingestIntegrationEvent(input: IntegrationEventInput, integration
|
|
|
2493
2554
|
}
|
|
2494
2555
|
|
|
2495
2556
|
export function getTask(id: number): Task | null {
|
|
2496
|
-
const row = db.
|
|
2557
|
+
const row = db.query(`${TASK_SELECT} WHERE id = ?`).get(id) as any;
|
|
2497
2558
|
return row ? rowToTask(row) : null;
|
|
2498
2559
|
}
|
|
2499
2560
|
|
|
@@ -2514,11 +2575,11 @@ export function listTasks(filter?: { status?: string; source?: string; target?:
|
|
|
2514
2575
|
}
|
|
2515
2576
|
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
2516
2577
|
params.push(filter?.limit ?? 100);
|
|
2517
|
-
return (db.
|
|
2578
|
+
return (db.query(`${TASK_SELECT} ${where} ORDER BY updated_at DESC LIMIT ?`).all(...params) as any[]).map(rowToTask);
|
|
2518
2579
|
}
|
|
2519
2580
|
|
|
2520
2581
|
export function listIntegrationTaskStats(): IntegrationTaskStats[] {
|
|
2521
|
-
const rows = db.
|
|
2582
|
+
const rows = db.query(`
|
|
2522
2583
|
SELECT
|
|
2523
2584
|
source,
|
|
2524
2585
|
COUNT(*) AS tasks,
|
|
@@ -2597,7 +2658,7 @@ export function upsertIntegrationRegistry(input: IntegrationRegistryInput): Retu
|
|
|
2597
2658
|
const name = stringValue(input.name);
|
|
2598
2659
|
if (!name) throw new ValidationError("integration name required");
|
|
2599
2660
|
const now = Date.now();
|
|
2600
|
-
db.
|
|
2661
|
+
db.query(`
|
|
2601
2662
|
INSERT INTO integration_registry (
|
|
2602
2663
|
name, display_name, description, enabled, scopes, targets, channels, type, icon, accent_color,
|
|
2603
2664
|
tags, homepage_url, repository_url, docs_url, manifest, source, created_at, updated_at
|
|
@@ -2644,17 +2705,17 @@ export function upsertIntegrationRegistry(input: IntegrationRegistryInput): Retu
|
|
|
2644
2705
|
}
|
|
2645
2706
|
|
|
2646
2707
|
function getIntegrationRegistry(name: string): ReturnType<typeof rowToIntegrationRegistry> | null {
|
|
2647
|
-
const row = db.
|
|
2708
|
+
const row = db.query("SELECT * FROM integration_registry WHERE name = ?").get(name) as any;
|
|
2648
2709
|
return row ? rowToIntegrationRegistry(row) : null;
|
|
2649
2710
|
}
|
|
2650
2711
|
|
|
2651
2712
|
export function listIntegrationRegistry(): ReturnType<typeof rowToIntegrationRegistry>[] {
|
|
2652
|
-
return (db.
|
|
2713
|
+
return (db.query("SELECT * FROM integration_registry ORDER BY display_name COLLATE NOCASE, name COLLATE NOCASE").all() as any[]).map(rowToIntegrationRegistry);
|
|
2653
2714
|
}
|
|
2654
2715
|
|
|
2655
2716
|
function updateIntegrationObserved(name: string, eventAt: number): void {
|
|
2656
2717
|
const now = Date.now();
|
|
2657
|
-
db.
|
|
2718
|
+
db.query(`
|
|
2658
2719
|
INSERT INTO integration_registry (name, enabled, scopes, targets, channels, tags, manifest, source, created_at, updated_at, last_event_at, last_task_at)
|
|
2659
2720
|
VALUES (?, 1, '[]', '[]', '[]', '[]', '{}', 'observed', ?, ?, ?, ?)
|
|
2660
2721
|
ON CONFLICT(name) DO UPDATE SET
|
|
@@ -2670,7 +2731,7 @@ export function isIntegrationRegistryEnabled(name: string): boolean {
|
|
|
2670
2731
|
}
|
|
2671
2732
|
|
|
2672
2733
|
export function listTaskEvents(taskId: number): TaskEvent[] {
|
|
2673
|
-
return (db.
|
|
2734
|
+
return (db.query("SELECT * FROM task_events WHERE task_id = ? ORDER BY id ASC").all(taskId) as any[]).map(rowToTaskEvent);
|
|
2674
2735
|
}
|
|
2675
2736
|
|
|
2676
2737
|
export function recordTaskEvent(taskId: number, input: {
|
|
@@ -2698,21 +2759,21 @@ export function releaseExpiredClaims(now: number = Date.now()): { messageIds: nu
|
|
|
2698
2759
|
settleSingleTargetOnDemandTasks("claimed_by IS NOT NULL AND (claim_expires_at IS NULL OR claim_expires_at <= ?)", [now], now, "claim-lease-expired");
|
|
2699
2760
|
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
2761
|
const messageRows = db
|
|
2701
|
-
.
|
|
2762
|
+
.query(`SELECT id FROM messages WHERE ${releasableMessageClaim}`)
|
|
2702
2763
|
.all(now) as any[];
|
|
2703
2764
|
const taskRows = db
|
|
2704
|
-
.
|
|
2765
|
+
.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
2766
|
.all(now) as any[];
|
|
2706
2767
|
|
|
2707
2768
|
if (messageRows.length > 0) {
|
|
2708
2769
|
db
|
|
2709
|
-
.
|
|
2770
|
+
.query(`UPDATE messages SET claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL WHERE ${releasableMessageClaim}`)
|
|
2710
2771
|
.run(now);
|
|
2711
2772
|
}
|
|
2712
2773
|
|
|
2713
2774
|
if (taskRows.length > 0) {
|
|
2714
2775
|
db
|
|
2715
|
-
.
|
|
2776
|
+
.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
2777
|
.run(now, now);
|
|
2717
2778
|
|
|
2718
2779
|
for (const row of taskRows) {
|
|
@@ -2737,11 +2798,11 @@ export function releaseExpiredClaims(now: number = Date.now()): { messageIds: nu
|
|
|
2737
2798
|
export function orphanTasksForAgent(agentId: string, now: number = Date.now()): Task[] {
|
|
2738
2799
|
return db.transaction(() => {
|
|
2739
2800
|
const rows = db
|
|
2740
|
-
.
|
|
2801
|
+
.query(`${TASK_SELECT} WHERE claimed_by = ? AND status IN ('claimed', 'in_progress')`)
|
|
2741
2802
|
.all(agentId) as any[];
|
|
2742
2803
|
if (rows.length === 0) return [];
|
|
2743
2804
|
|
|
2744
|
-
db.
|
|
2805
|
+
db.query(`
|
|
2745
2806
|
UPDATE tasks
|
|
2746
2807
|
SET status = 'orphaned', updated_at = ?, last_seen_at = ?
|
|
2747
2808
|
WHERE claimed_by = ? AND status IN ('claimed', 'in_progress')
|
|
@@ -2767,11 +2828,11 @@ export function releaseOrphanedTasks(graceMs = 120_000, now: number = Date.now()
|
|
|
2767
2828
|
const cutoff = now - graceMs;
|
|
2768
2829
|
settleSingleTargetOnDemandTasks("status = 'orphaned' AND claimed_by IS NOT NULL AND updated_at <= ?", [cutoff], now, "orphan-grace-elapsed");
|
|
2769
2830
|
const rows = db
|
|
2770
|
-
.
|
|
2831
|
+
.query(`${TASK_SELECT} WHERE status = 'orphaned' AND claimed_by IS NOT NULL AND updated_at <= ?`)
|
|
2771
2832
|
.all(cutoff) as any[];
|
|
2772
2833
|
if (rows.length === 0) return [];
|
|
2773
2834
|
|
|
2774
|
-
db.
|
|
2835
|
+
db.query(`
|
|
2775
2836
|
UPDATE tasks
|
|
2776
2837
|
SET status = 'open', claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL, updated_at = ?
|
|
2777
2838
|
WHERE status = 'orphaned' AND claimed_by IS NOT NULL AND updated_at <= ?
|
|
@@ -2807,7 +2868,7 @@ export function claimTask(taskId: number, agentId: string, guard?: AgentSessionG
|
|
|
2807
2868
|
return db.transaction(() => {
|
|
2808
2869
|
const now = Date.now();
|
|
2809
2870
|
const expiresAt = now + CLAIM_LEASE_MS;
|
|
2810
|
-
const result = db.
|
|
2871
|
+
const result = db.query(`
|
|
2811
2872
|
UPDATE tasks SET status = 'claimed', claimed_by = ?, claimed_at = ?, claim_expires_at = ?, updated_at = ?
|
|
2812
2873
|
WHERE id = ? AND status IN ('open', 'blocked')
|
|
2813
2874
|
`).run(agentId, now, expiresAt, now, taskId);
|
|
@@ -2846,9 +2907,9 @@ export function renewTaskClaim(taskId: number, agentId: string, guard?: AgentSes
|
|
|
2846
2907
|
|
|
2847
2908
|
const now = Date.now();
|
|
2848
2909
|
const expiresAt = now + CLAIM_LEASE_MS;
|
|
2849
|
-
db.
|
|
2910
|
+
db.query("UPDATE tasks SET claim_expires_at = ?, updated_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, now, taskId, agentId);
|
|
2850
2911
|
if (task.messageId) {
|
|
2851
|
-
db.
|
|
2912
|
+
db.query("UPDATE messages SET claim_expires_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, task.messageId, agentId);
|
|
2852
2913
|
}
|
|
2853
2914
|
return { ok: true, task: getTask(taskId)! };
|
|
2854
2915
|
}
|
|
@@ -2862,7 +2923,7 @@ export function updateTaskStatus(taskId: number, input: TaskStatusInput): { ok:
|
|
|
2862
2923
|
const session = validateAgentSession(agentId, input);
|
|
2863
2924
|
if (!session.ok) return { ok: false, error: session.error };
|
|
2864
2925
|
}
|
|
2865
|
-
const result = db.
|
|
2926
|
+
const result = db.query(`
|
|
2866
2927
|
UPDATE tasks
|
|
2867
2928
|
SET status = ?, result = COALESCE(?, result), claimed_by = COALESCE(?, claimed_by),
|
|
2868
2929
|
claimed_at = CASE WHEN claimed_by IS NULL AND ? IS NOT NULL THEN ? ELSE claimed_at END,
|
|
@@ -2872,13 +2933,13 @@ export function updateTaskStatus(taskId: number, input: TaskStatusInput): { ok:
|
|
|
2872
2933
|
`).run(input.status, input.result ?? null, agentId, agentId, now, input.status, now, now, taskId);
|
|
2873
2934
|
if (result.changes === 0) return { ok: false, error: "task not found" };
|
|
2874
2935
|
if (["done", "failed", "canceled"].includes(input.status) && task.messageId) {
|
|
2875
|
-
db.
|
|
2936
|
+
db.query("UPDATE messages SET claim_expires_at = NULL WHERE id = ?").run(task.messageId);
|
|
2876
2937
|
}
|
|
2877
2938
|
if (agentId && ["claimed", "in_progress", "blocked"].includes(input.status)) {
|
|
2878
2939
|
const expiresAt = now + CLAIM_LEASE_MS;
|
|
2879
|
-
db.
|
|
2940
|
+
db.query("UPDATE tasks SET claim_expires_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, taskId, agentId);
|
|
2880
2941
|
if (task.messageId) {
|
|
2881
|
-
db.
|
|
2942
|
+
db.query("UPDATE messages SET claim_expires_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, task.messageId, agentId);
|
|
2882
2943
|
}
|
|
2883
2944
|
}
|
|
2884
2945
|
const event = insertTaskEvent(taskId, {
|
|
@@ -2894,7 +2955,7 @@ export function updateTaskStatus(taskId: number, input: TaskStatusInput): { ok:
|
|
|
2894
2955
|
|
|
2895
2956
|
export function createCallbackDelivery(taskId: number, url: string, eventType: string, payload: unknown): number {
|
|
2896
2957
|
const now = Date.now();
|
|
2897
|
-
const result = db.
|
|
2958
|
+
const result = db.query(`
|
|
2898
2959
|
INSERT INTO task_callback_deliveries (task_id, url, event_type, payload, status, attempts, created_at, updated_at)
|
|
2899
2960
|
VALUES (?, ?, ?, ?, 'pending', 0, ?, ?)
|
|
2900
2961
|
`).run(taskId, url, eventType, JSON.stringify(payload), now, now);
|
|
@@ -2902,7 +2963,7 @@ export function createCallbackDelivery(taskId: number, url: string, eventType: s
|
|
|
2902
2963
|
}
|
|
2903
2964
|
|
|
2904
2965
|
export function finishCallbackDelivery(id: number, ok: boolean, error?: string): void {
|
|
2905
|
-
db.
|
|
2966
|
+
db.query(`
|
|
2906
2967
|
UPDATE task_callback_deliveries
|
|
2907
2968
|
SET status = ?, attempts = attempts + 1, last_error = ?, updated_at = ?
|
|
2908
2969
|
WHERE id = ?
|
|
@@ -2918,7 +2979,7 @@ export function listCallbackDeliveries(taskId: number): Array<{
|
|
|
2918
2979
|
attempts: number;
|
|
2919
2980
|
lastError?: string;
|
|
2920
2981
|
}> {
|
|
2921
|
-
return (db.
|
|
2982
|
+
return (db.query("SELECT * FROM task_callback_deliveries WHERE task_id = ? ORDER BY id ASC").all(taskId) as any[])
|
|
2922
2983
|
.map((row) => ({
|
|
2923
2984
|
id: row.id,
|
|
2924
2985
|
taskId: row.task_id,
|
|
@@ -2937,12 +2998,12 @@ const DEFAULT_PAIR_TTL_MS = 5 * 60_000;
|
|
|
2937
2998
|
const MAX_PAIR_TTL_MS = DAY_MS;
|
|
2938
2999
|
|
|
2939
3000
|
function expirePendingPairs(now: number = Date.now()): void {
|
|
2940
|
-
db.
|
|
3001
|
+
db.query("UPDATE pairs SET status = 'expired', updated_at = ?, ended_at = ? WHERE status = 'pending' AND expires_at <= ?")
|
|
2941
3002
|
.run(now, now, now);
|
|
2942
3003
|
}
|
|
2943
3004
|
|
|
2944
3005
|
function closeOpenPairsForAgent(agentId: string, now: number = Date.now()): void {
|
|
2945
|
-
db.
|
|
3006
|
+
db.query(`
|
|
2946
3007
|
UPDATE pairs
|
|
2947
3008
|
SET status = 'ended', ended_at = ?, ended_by = ?, updated_at = ?
|
|
2948
3009
|
WHERE status IN ('pending', 'active') AND (requester_id = ? OR target_id = ?)
|
|
@@ -2951,7 +3012,7 @@ function closeOpenPairsForAgent(agentId: string, now: number = Date.now()): void
|
|
|
2951
3012
|
|
|
2952
3013
|
function getOpenPairForAgent(agentId: string): PairSession | null {
|
|
2953
3014
|
expirePendingPairs();
|
|
2954
|
-
const row = db.
|
|
3015
|
+
const row = db.query(`
|
|
2955
3016
|
SELECT * FROM pairs
|
|
2956
3017
|
WHERE status IN ('pending', 'active') AND (requester_id = ? OR target_id = ?)
|
|
2957
3018
|
ORDER BY updated_at DESC
|
|
@@ -3038,7 +3099,7 @@ function pairSystemMessage(pair: PairSession, to: string, event: string, subject
|
|
|
3038
3099
|
|
|
3039
3100
|
export function getPair(id: string): PairSession | null {
|
|
3040
3101
|
expirePendingPairs();
|
|
3041
|
-
const row = db.
|
|
3102
|
+
const row = db.query("SELECT * FROM pairs WHERE id = ?").get(id) as any;
|
|
3042
3103
|
return row ? rowToPair(row) : null;
|
|
3043
3104
|
}
|
|
3044
3105
|
|
|
@@ -3055,7 +3116,7 @@ export function listPairs(filter?: { agentId?: string; status?: PairStatus }): P
|
|
|
3055
3116
|
params.push(filter.status);
|
|
3056
3117
|
}
|
|
3057
3118
|
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
3058
|
-
return (db.
|
|
3119
|
+
return (db.query(`SELECT * FROM pairs ${where} ORDER BY updated_at DESC LIMIT 100`).all(...params) as any[]).map(rowToPair);
|
|
3059
3120
|
}
|
|
3060
3121
|
|
|
3061
3122
|
export function createPair(input: CreatePairInput): {
|
|
@@ -3082,7 +3143,7 @@ export function createPair(input: CreatePairInput): {
|
|
|
3082
3143
|
const now = Date.now();
|
|
3083
3144
|
const ttlMs = Math.min(Math.max(input.ttlMs ?? DEFAULT_PAIR_TTL_MS, 10_000), MAX_PAIR_TTL_MS);
|
|
3084
3145
|
const id = randomUUID();
|
|
3085
|
-
db.
|
|
3146
|
+
db.query(`
|
|
3086
3147
|
INSERT INTO pairs (id, requester_id, target_id, status, objective, meta, created_at, updated_at, expires_at)
|
|
3087
3148
|
VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?)
|
|
3088
3149
|
`).run(id, input.from, resolved.agent.id, input.objective ?? null, JSON.stringify(input.meta ?? {}), now, now, now + ttlMs);
|
|
@@ -3122,7 +3183,7 @@ export function acceptPair(id: string, input: PairActionInput): { ok: true; pair
|
|
|
3122
3183
|
}
|
|
3123
3184
|
|
|
3124
3185
|
const now = Date.now();
|
|
3125
|
-
db.
|
|
3186
|
+
db.query("UPDATE pairs SET status = 'active', accepted_at = ?, updated_at = ? WHERE id = ? AND status = 'pending'")
|
|
3126
3187
|
.run(now, now, id);
|
|
3127
3188
|
const active = getPair(id)!;
|
|
3128
3189
|
const notices = [
|
|
@@ -3139,7 +3200,7 @@ export function rejectPair(id: string, input: PairActionInput): { ok: true; pair
|
|
|
3139
3200
|
if (pair.status !== "pending") return { ok: false, code: "invalid", error: `pair is ${pair.status}` };
|
|
3140
3201
|
if (pair.targetId !== input.agentId) return { ok: false, code: "forbidden", error: "only the target agent can reject this pair" };
|
|
3141
3202
|
const now = Date.now();
|
|
3142
|
-
db.
|
|
3203
|
+
db.query("UPDATE pairs SET status = 'rejected', ended_at = ?, ended_by = ?, updated_at = ? WHERE id = ?")
|
|
3143
3204
|
.run(now, input.agentId, now, id);
|
|
3144
3205
|
const rejected = getPair(id)!;
|
|
3145
3206
|
const reason = input.reason ? `\n\nReason:\n${input.reason}` : "";
|
|
@@ -3154,7 +3215,7 @@ export function endPair(id: string, input: PairActionInput): { ok: true; pair: P
|
|
|
3154
3215
|
if (!pairParticipant(pair, input.agentId)) return { ok: false, code: "forbidden", error: "only pair participants can hang up" };
|
|
3155
3216
|
if (!OPEN_PAIR_STATUSES.includes(pair.status as any)) return { ok: false, code: "invalid", error: `pair is ${pair.status}` };
|
|
3156
3217
|
const now = Date.now();
|
|
3157
|
-
db.
|
|
3218
|
+
db.query("UPDATE pairs SET status = 'ended', ended_at = ?, ended_by = ?, updated_at = ? WHERE id = ?")
|
|
3158
3219
|
.run(now, input.agentId, now, id);
|
|
3159
3220
|
const ended = getPair(id)!;
|
|
3160
3221
|
const reason = input.reason ? `\n\nReason:\n${input.reason}` : "";
|
|
@@ -3184,7 +3245,7 @@ export function sendPairMessage(id: string, input: PairMessageInput): { ok: true
|
|
|
3184
3245
|
targetId: pair.targetId,
|
|
3185
3246
|
},
|
|
3186
3247
|
});
|
|
3187
|
-
db.
|
|
3248
|
+
db.query("UPDATE pairs SET last_message_at = ?, updated_at = ? WHERE id = ?").run(now, now, id);
|
|
3188
3249
|
return { ok: true, pair: getPair(id)!, message };
|
|
3189
3250
|
}
|
|
3190
3251
|
|
|
@@ -3224,7 +3285,7 @@ export function upsertArtifactBlob(input: {
|
|
|
3224
3285
|
createdAt?: number;
|
|
3225
3286
|
}): ArtifactBlob {
|
|
3226
3287
|
const now = input.createdAt ?? Date.now();
|
|
3227
|
-
db.
|
|
3288
|
+
db.query(`
|
|
3228
3289
|
INSERT INTO artifact_blobs (digest, storage_uri, media_type, size, created_at)
|
|
3229
3290
|
VALUES (?, ?, ?, ?, ?)
|
|
3230
3291
|
ON CONFLICT(digest) DO UPDATE SET
|
|
@@ -3236,12 +3297,12 @@ export function upsertArtifactBlob(input: {
|
|
|
3236
3297
|
}
|
|
3237
3298
|
|
|
3238
3299
|
export function getArtifactBlob(digest: string): ArtifactBlob | null {
|
|
3239
|
-
const row = db.
|
|
3300
|
+
const row = db.query("SELECT * FROM artifact_blobs WHERE digest = ?").get(digest) as any;
|
|
3240
3301
|
return row ? rowToArtifactBlob(row) : null;
|
|
3241
3302
|
}
|
|
3242
3303
|
|
|
3243
3304
|
export function deleteArtifactBlob(digest: string): boolean {
|
|
3244
|
-
return db.
|
|
3305
|
+
return db.query("DELETE FROM artifact_blobs WHERE digest = ?").run(digest).changes > 0;
|
|
3245
3306
|
}
|
|
3246
3307
|
|
|
3247
3308
|
export function createArtifact(input: {
|
|
@@ -3260,7 +3321,7 @@ export function createArtifact(input: {
|
|
|
3260
3321
|
if (!getArtifactBlob(input.blobDigest)) throw new ValidationError("artifact blob not found");
|
|
3261
3322
|
const id = input.id ?? artifactId();
|
|
3262
3323
|
const now = Date.now();
|
|
3263
|
-
db.
|
|
3324
|
+
db.query(`
|
|
3264
3325
|
INSERT INTO artifacts (
|
|
3265
3326
|
id, blob_digest, media_type, kind, filename, size, visibility, sensitivity,
|
|
3266
3327
|
created_by, created_at, expires_at, metadata
|
|
@@ -3284,7 +3345,7 @@ export function createArtifact(input: {
|
|
|
3284
3345
|
}
|
|
3285
3346
|
|
|
3286
3347
|
export function getArtifact(id: string): Artifact | null {
|
|
3287
|
-
const row = db.
|
|
3348
|
+
const row = db.query("SELECT * FROM artifacts WHERE id = ?").get(id) as any;
|
|
3288
3349
|
if (!row) return null;
|
|
3289
3350
|
return rowToArtifact(row, listArtifactLinks(id));
|
|
3290
3351
|
}
|
|
@@ -3311,22 +3372,22 @@ export function listArtifacts(query: {
|
|
|
3311
3372
|
}
|
|
3312
3373
|
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
3313
3374
|
const limit = Math.min(Math.max(query.limit ?? 100, 1), 500);
|
|
3314
|
-
const rows = db.
|
|
3375
|
+
const rows = db.query(`SELECT a.* FROM artifacts a ${where} ORDER BY a.created_at DESC LIMIT ?`).all(...params, limit) as any[];
|
|
3315
3376
|
return rows.map((row) => rowToArtifact(row, listArtifactLinks(row.id)));
|
|
3316
3377
|
}
|
|
3317
3378
|
|
|
3318
3379
|
export function deleteArtifact(id: string): boolean {
|
|
3319
3380
|
return db.transaction(() => {
|
|
3320
|
-
return db.
|
|
3381
|
+
return db.query("DELETE FROM artifacts WHERE id = ?").run(id).changes > 0;
|
|
3321
3382
|
})();
|
|
3322
3383
|
}
|
|
3323
3384
|
|
|
3324
3385
|
function listArtifactLinks(artifactId: string): ArtifactLink[] {
|
|
3325
|
-
return (db.
|
|
3386
|
+
return (db.query("SELECT * FROM artifact_links WHERE artifact_id = ? ORDER BY created_at ASC").all(artifactId) as any[]).map(rowToArtifactLink);
|
|
3326
3387
|
}
|
|
3327
3388
|
|
|
3328
3389
|
export function listArtifactsForEntity(entityType: ArtifactLink["entityType"], entityId: string | number): Artifact[] {
|
|
3329
|
-
const rows = db.
|
|
3390
|
+
const rows = db.query(`
|
|
3330
3391
|
SELECT a.*
|
|
3331
3392
|
FROM artifacts a
|
|
3332
3393
|
JOIN artifact_links l ON l.artifact_id = a.id
|
|
@@ -3349,7 +3410,7 @@ export function linkArtifact(input: {
|
|
|
3349
3410
|
if (!getArtifact(input.artifactId)) throw new ValidationError(`artifact ${input.artifactId} not found`);
|
|
3350
3411
|
const now = Date.now();
|
|
3351
3412
|
const id = artifactLinkId();
|
|
3352
|
-
db.
|
|
3413
|
+
db.query(`
|
|
3353
3414
|
INSERT OR IGNORE INTO artifact_links (id, artifact_id, entity_type, entity_id, role, title, created_by, created_at)
|
|
3354
3415
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
3355
3416
|
`).run(
|
|
@@ -3362,7 +3423,7 @@ export function linkArtifact(input: {
|
|
|
3362
3423
|
input.createdBy,
|
|
3363
3424
|
now,
|
|
3364
3425
|
);
|
|
3365
|
-
const row = db.
|
|
3426
|
+
const row = db.query(`
|
|
3366
3427
|
SELECT * FROM artifact_links
|
|
3367
3428
|
WHERE artifact_id = ? AND entity_type = ? AND entity_id = ? AND ((role IS NULL AND ? IS NULL) OR role = ?)
|
|
3368
3429
|
ORDER BY created_at DESC
|
|
@@ -3372,16 +3433,16 @@ export function linkArtifact(input: {
|
|
|
3372
3433
|
}
|
|
3373
3434
|
|
|
3374
3435
|
function deleteArtifactLinksForEntity(entityType: ArtifactLink["entityType"], entityId: string | number): number {
|
|
3375
|
-
return db.
|
|
3436
|
+
return db.query("DELETE FROM artifact_links WHERE entity_type = ? AND entity_id = ?").run(entityType, String(entityId)).changes;
|
|
3376
3437
|
}
|
|
3377
3438
|
|
|
3378
3439
|
export function artifactBlobReferenceCount(digest: string): number {
|
|
3379
|
-
const row = db.
|
|
3440
|
+
const row = db.query("SELECT COUNT(*) AS count FROM artifacts WHERE blob_digest = ?").get(digest) as { count?: number };
|
|
3380
3441
|
return row.count ?? 0;
|
|
3381
3442
|
}
|
|
3382
3443
|
|
|
3383
3444
|
function unreferencedArtifactBlobs(): ArtifactBlob[] {
|
|
3384
|
-
return (db.
|
|
3445
|
+
return (db.query(`
|
|
3385
3446
|
SELECT b.*
|
|
3386
3447
|
FROM artifact_blobs b
|
|
3387
3448
|
WHERE NOT EXISTS (SELECT 1 FROM artifacts a WHERE a.blob_digest = b.digest)
|
|
@@ -3392,12 +3453,12 @@ export function sweepArtifacts(input: { now?: number; unlinkedGraceMs?: number }
|
|
|
3392
3453
|
const now = input.now ?? Date.now();
|
|
3393
3454
|
const unlinkedCutoff = now - (input.unlinkedGraceMs ?? 60 * 60 * 1000);
|
|
3394
3455
|
return db.transaction(() => {
|
|
3395
|
-
const rows = db.
|
|
3456
|
+
const rows = db.query(`
|
|
3396
3457
|
SELECT id FROM artifacts a
|
|
3397
3458
|
WHERE (expires_at IS NOT NULL AND expires_at <= ?)
|
|
3398
3459
|
OR (created_at <= ? AND NOT EXISTS (SELECT 1 FROM artifact_links l WHERE l.artifact_id = a.id))
|
|
3399
3460
|
`).all(now, unlinkedCutoff) as Array<{ id: string }>;
|
|
3400
|
-
for (const row of rows) db.
|
|
3461
|
+
for (const row of rows) db.query("DELETE FROM artifacts WHERE id = ?").run(row.id);
|
|
3401
3462
|
const blobs = unreferencedArtifactBlobs();
|
|
3402
3463
|
for (const blob of blobs) deleteArtifactBlob(blob.digest);
|
|
3403
3464
|
return { artifactIds: rows.map((row) => row.id), blobs };
|
|
@@ -3451,7 +3512,7 @@ function linkAttachmentRefs(entityType: ArtifactLink["entityType"], entityId: st
|
|
|
3451
3512
|
|
|
3452
3513
|
function findMessageByIdempotencyKey(from: string, key: string): Message | null {
|
|
3453
3514
|
const row = db
|
|
3454
|
-
.
|
|
3515
|
+
.query(`${MSG_SELECT} WHERE m.from_agent = ? AND m.idempotency_key = ? LIMIT 1`)
|
|
3455
3516
|
.get(from, key) as any;
|
|
3456
3517
|
return row ? rowToMessage(row) : null;
|
|
3457
3518
|
}
|
|
@@ -3463,12 +3524,12 @@ function policyNameFromTarget(target: string): string | null {
|
|
|
3463
3524
|
}
|
|
3464
3525
|
|
|
3465
3526
|
function spawnPolicyExists(policyName: string): boolean {
|
|
3466
|
-
const row = db.
|
|
3527
|
+
const row = db.query("SELECT 1 FROM config WHERE namespace = 'spawn-policy' AND key = ?").get(policyName);
|
|
3467
3528
|
return Boolean(row);
|
|
3468
3529
|
}
|
|
3469
3530
|
|
|
3470
3531
|
function runningAgentForPolicy(policyName: string): string | null {
|
|
3471
|
-
const row = db.
|
|
3532
|
+
const row = db.query(`
|
|
3472
3533
|
SELECT agent_id
|
|
3473
3534
|
FROM managed_agent_state
|
|
3474
3535
|
WHERE policy_name = ? AND status = 'running' AND agent_id IS NOT NULL
|
|
@@ -3480,7 +3541,7 @@ function runningAgentForPolicy(policyName: string): string | null {
|
|
|
3480
3541
|
}
|
|
3481
3542
|
|
|
3482
3543
|
function queueDepthLimit(target: string): number {
|
|
3483
|
-
const row = db.
|
|
3544
|
+
const row = db.query("SELECT value FROM config WHERE namespace = 'system' AND key = 'message-queue'").get() as { value?: string } | undefined;
|
|
3484
3545
|
const parsed = row?.value ? parseJson<Record<string, unknown>>(row.value, {}) : {};
|
|
3485
3546
|
const perTarget = parsed?.maxDepthPerTarget;
|
|
3486
3547
|
if (typeof perTarget === "number" && Number.isSafeInteger(perTarget) && perTarget > 0) return perTarget;
|
|
@@ -3494,7 +3555,7 @@ function queueDepthLimit(target: string): number {
|
|
|
3494
3555
|
|
|
3495
3556
|
function enforceQueueLimit(target: string): void {
|
|
3496
3557
|
const limit = queueDepthLimit(target);
|
|
3497
|
-
const rows = db.
|
|
3558
|
+
const rows = db.query(`
|
|
3498
3559
|
SELECT id FROM messages
|
|
3499
3560
|
WHERE to_target = ? AND delivery_status = 'queued'
|
|
3500
3561
|
ORDER BY queued_at DESC, id DESC
|
|
@@ -3596,7 +3657,7 @@ const REPLY_DUPLICATE_WINDOW_MS = 2 * 60 * 1000;
|
|
|
3596
3657
|
|
|
3597
3658
|
function findRecentDuplicateReply(input: SendMessageInput, threadId: number | null, now: number, hasAttachments: boolean): Message | null {
|
|
3598
3659
|
if (!input.replyTo || threadId === null || hasAttachments) return null;
|
|
3599
|
-
const row = db.
|
|
3660
|
+
const row = db.query(`
|
|
3600
3661
|
${MSG_SELECT}
|
|
3601
3662
|
WHERE m.from_agent = ?
|
|
3602
3663
|
AND m.to_target = ?
|
|
@@ -3645,7 +3706,7 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
|
|
|
3645
3706
|
const duplicateReply = findRecentDuplicateReply(input, threadId, now, attachmentRefs.length > 0);
|
|
3646
3707
|
if (duplicateReply) return { message: duplicateReply, created: false };
|
|
3647
3708
|
|
|
3648
|
-
const insert = db.
|
|
3709
|
+
const insert = db.query(`
|
|
3649
3710
|
INSERT INTO messages (
|
|
3650
3711
|
from_agent, to_target, kind, channel, subject, body, thread_id, reply_to, claimable,
|
|
3651
3712
|
idempotency_key, delivery_status, queued_at, max_age_seconds, resolved_to_agent,
|
|
@@ -3657,7 +3718,7 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
|
|
|
3657
3718
|
$payload, $meta, $now
|
|
3658
3719
|
)
|
|
3659
3720
|
`);
|
|
3660
|
-
const setSelfThread = db.
|
|
3721
|
+
const setSelfThread = db.query("UPDATE messages SET thread_id = ? WHERE id = ?");
|
|
3661
3722
|
const claimable = shouldStoreClaimable(input);
|
|
3662
3723
|
const kind = inferMessageKind(input);
|
|
3663
3724
|
const policyName = policyNameFromTarget(input.to);
|
|
@@ -3716,7 +3777,7 @@ export function getThread(messageId: number): Message[] {
|
|
|
3716
3777
|
const threadId = msg.threadId ?? msg.id;
|
|
3717
3778
|
return (
|
|
3718
3779
|
db
|
|
3719
|
-
.
|
|
3780
|
+
.query(`${MSG_SELECT} WHERE m.thread_id = ? ORDER BY m.created_at ASC`)
|
|
3720
3781
|
.all(threadId) as any[]
|
|
3721
3782
|
).map(rowToMessage);
|
|
3722
3783
|
}
|
|
@@ -3735,10 +3796,10 @@ export function setMessageReaction(input: {
|
|
|
3735
3796
|
|
|
3736
3797
|
const now = Date.now();
|
|
3737
3798
|
if (input.action === "remove") {
|
|
3738
|
-
db.
|
|
3799
|
+
db.query("DELETE FROM message_reactions WHERE message_id = ? AND actor_id = ? AND emoji = ?")
|
|
3739
3800
|
.run(input.messageId, actorId, emoji);
|
|
3740
3801
|
} else {
|
|
3741
|
-
db.
|
|
3802
|
+
db.query(`
|
|
3742
3803
|
INSERT INTO message_reactions (message_id, actor_id, emoji, created_at, updated_at)
|
|
3743
3804
|
VALUES (?, ?, ?, ?, ?)
|
|
3744
3805
|
ON CONFLICT(message_id, actor_id, emoji) DO UPDATE SET updated_at = excluded.updated_at
|
|
@@ -3753,7 +3814,7 @@ export function findMessageByTelegramSource(input: {
|
|
|
3753
3814
|
chatId: string;
|
|
3754
3815
|
messageId: string;
|
|
3755
3816
|
}): Message | null {
|
|
3756
|
-
const rows = db.
|
|
3817
|
+
const rows = db.query(`
|
|
3757
3818
|
${MSG_SELECT}
|
|
3758
3819
|
WHERE json_extract(m.payload, '$.source.telegram.chatId') = ?
|
|
3759
3820
|
AND json_extract(m.payload, '$.source.telegram.messageId') = ?
|
|
@@ -3767,7 +3828,7 @@ export function findMessageByTelegramSource(input: {
|
|
|
3767
3828
|
|
|
3768
3829
|
function claimMessageRow(messageId: number, agentId: string, now: number): { ok: false; error: string } | { ok: true } {
|
|
3769
3830
|
const expiresAt = now + CLAIM_LEASE_MS;
|
|
3770
|
-
const result = db.
|
|
3831
|
+
const result = db.query(
|
|
3771
3832
|
"UPDATE messages SET claimed_by = ?, claimed_at = ?, claim_expires_at = ? WHERE id = ? AND claimed_by IS NULL"
|
|
3772
3833
|
).run(agentId, now, expiresAt, messageId);
|
|
3773
3834
|
|
|
@@ -3807,7 +3868,7 @@ export function claimMessage(messageId: number, agentId: string, guard?: AgentSe
|
|
|
3807
3868
|
throw new ClaimError(`linked task is ${task.status}`);
|
|
3808
3869
|
}
|
|
3809
3870
|
|
|
3810
|
-
const taskClaim = db.
|
|
3871
|
+
const taskClaim = db.query(`
|
|
3811
3872
|
UPDATE tasks SET status = 'claimed', claimed_by = ?, claimed_at = ?, claim_expires_at = ?, updated_at = ?
|
|
3812
3873
|
WHERE id = ? AND message_id = ? AND status IN ('open', 'blocked')
|
|
3813
3874
|
`).run(agentId, now, expiresAt, now, taskId, messageId);
|
|
@@ -3846,12 +3907,12 @@ export function renewMessageClaim(messageId: number, agentId: string, guard?: Ag
|
|
|
3846
3907
|
const expiresAt = now + CLAIM_LEASE_MS;
|
|
3847
3908
|
let task: Task | undefined;
|
|
3848
3909
|
db.transaction(() => {
|
|
3849
|
-
db.
|
|
3910
|
+
db.query("UPDATE messages SET claim_expires_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, messageId, agentId);
|
|
3850
3911
|
const taskId = typeof msg.payload?.taskId === "number" && Number.isSafeInteger(msg.payload.taskId)
|
|
3851
3912
|
? msg.payload.taskId
|
|
3852
3913
|
: null;
|
|
3853
3914
|
if (taskId) {
|
|
3854
|
-
db.
|
|
3915
|
+
db.query("UPDATE tasks SET claim_expires_at = ?, updated_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, now, taskId, agentId);
|
|
3855
3916
|
task = getTask(taskId) ?? undefined;
|
|
3856
3917
|
}
|
|
3857
3918
|
})();
|
|
@@ -3859,13 +3920,13 @@ export function renewMessageClaim(messageId: number, agentId: string, guard?: Ag
|
|
|
3859
3920
|
}
|
|
3860
3921
|
|
|
3861
3922
|
export function getMessage(id: number): Message | null {
|
|
3862
|
-
const row = db.
|
|
3923
|
+
const row = db.query(`${MSG_SELECT} WHERE m.id = ?`).get(id) as any;
|
|
3863
3924
|
return row ? rowToMessage(row) : null;
|
|
3864
3925
|
}
|
|
3865
3926
|
|
|
3866
3927
|
export function getMessageDeliveryAttempts(messageId: number, limit = 50): MessageDeliveryAttempt[] {
|
|
3867
3928
|
const safeLimit = Math.min(Math.max(limit, 1), 200);
|
|
3868
|
-
return (db.
|
|
3929
|
+
return (db.query(`
|
|
3869
3930
|
SELECT * FROM message_delivery_attempts
|
|
3870
3931
|
WHERE message_id = ?
|
|
3871
3932
|
ORDER BY created_at DESC, id DESC
|
|
@@ -3885,7 +3946,7 @@ function insertMessageDeliveryAttempt(
|
|
|
3885
3946
|
},
|
|
3886
3947
|
now: number,
|
|
3887
3948
|
): void {
|
|
3888
|
-
db.
|
|
3949
|
+
db.query(`
|
|
3889
3950
|
INSERT INTO message_delivery_attempts (message_id, agent_id, action, status, error, next_retry_at, poison_reason, created_at)
|
|
3890
3951
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
3891
3952
|
`).run(
|
|
@@ -3911,7 +3972,7 @@ function setMessageDeliveryState(
|
|
|
3911
3972
|
},
|
|
3912
3973
|
now: number,
|
|
3913
3974
|
): boolean {
|
|
3914
|
-
return db.
|
|
3975
|
+
return db.query(`
|
|
3915
3976
|
UPDATE messages
|
|
3916
3977
|
SET delivery_status = ?,
|
|
3917
3978
|
delivery_attempts = delivery_attempts + ?,
|
|
@@ -4005,7 +4066,7 @@ export function getMessageDeliveryStatus(id: number): Pick<Message, "id" | "to"
|
|
|
4005
4066
|
|
|
4006
4067
|
export function listQueuedMessages(target: string, limit = 100): Message[] {
|
|
4007
4068
|
const safeLimit = Math.min(Math.max(limit, 1), 500);
|
|
4008
|
-
return (db.
|
|
4069
|
+
return (db.query(`
|
|
4009
4070
|
${MSG_SELECT}
|
|
4010
4071
|
WHERE m.to_target = ? AND m.delivery_status = 'queued'
|
|
4011
4072
|
ORDER BY m.queued_at ASC, m.id ASC
|
|
@@ -4015,7 +4076,7 @@ export function listQueuedMessages(target: string, limit = 100): Message[] {
|
|
|
4015
4076
|
|
|
4016
4077
|
export function resolveQueuedPolicyMessages(policyName: string, agentId: string): Message[] {
|
|
4017
4078
|
const target = `policy:${policyName}`;
|
|
4018
|
-
const rows = db.
|
|
4079
|
+
const rows = db.query(`
|
|
4019
4080
|
SELECT m.id
|
|
4020
4081
|
FROM messages m
|
|
4021
4082
|
WHERE m.to_target = ?
|
|
@@ -4031,7 +4092,7 @@ export function resolveQueuedPolicyMessages(policyName: string, agentId: string)
|
|
|
4031
4092
|
if (rows.length === 0) return [];
|
|
4032
4093
|
const ids = rows.map((row) => row.id);
|
|
4033
4094
|
const placeholders = ids.map(() => "?").join(",");
|
|
4034
|
-
db.
|
|
4095
|
+
db.query(`
|
|
4035
4096
|
UPDATE messages
|
|
4036
4097
|
SET delivery_status = 'pending',
|
|
4037
4098
|
resolved_to_agent = ?,
|
|
@@ -4050,7 +4111,7 @@ export function resolveQueuedPolicyMessages(policyName: string, agentId: string)
|
|
|
4050
4111
|
}
|
|
4051
4112
|
|
|
4052
4113
|
export function expireQueuedMessages(now: number = Date.now()): Message[] {
|
|
4053
|
-
const rows = db.
|
|
4114
|
+
const rows = db.query(`
|
|
4054
4115
|
SELECT id FROM messages
|
|
4055
4116
|
WHERE delivery_status = 'queued'
|
|
4056
4117
|
AND queued_at IS NOT NULL
|
|
@@ -4095,7 +4156,8 @@ export function listRecentMessages(limit: number = 100, since?: number, channel?
|
|
|
4095
4156
|
const sql = `${MSG_SELECT} ${where} ORDER BY m.created_at DESC LIMIT ?`;
|
|
4096
4157
|
params.push(limit);
|
|
4097
4158
|
|
|
4098
|
-
|
|
4159
|
+
const rows = timedQuery("listMessages", () => db.query(sql).all(...params) as any[]);
|
|
4160
|
+
return rows.map(rowToMessage).reverse();
|
|
4099
4161
|
}
|
|
4100
4162
|
|
|
4101
4163
|
export function pollMessages(query: PollQuery): Message[] {
|
|
@@ -4163,7 +4225,8 @@ export function pollMessages(query: PollQuery): Message[] {
|
|
|
4163
4225
|
const sql = `${MSG_SELECT} WHERE ${conditions.join(" AND ")} ORDER BY m.created_at ASC LIMIT ?`;
|
|
4164
4226
|
params.push(limit);
|
|
4165
4227
|
|
|
4166
|
-
|
|
4228
|
+
const rows = timedQuery("pollMessages", () => db.query(sql).all(...params) as any[]);
|
|
4229
|
+
return rows.map(rowToMessage);
|
|
4167
4230
|
}
|
|
4168
4231
|
|
|
4169
4232
|
function messageRequiresReply(message: Message): boolean {
|
|
@@ -4191,7 +4254,7 @@ function isCoveredByLaterAgentResponse(message: Message, agentId: string): boole
|
|
|
4191
4254
|
// Order by id, not created_at: ids are monotonic insertion order, so this is
|
|
4192
4255
|
// robust when a reply lands in the same millisecond as the message it covers
|
|
4193
4256
|
// (created_at > … strictly would miss it, leaving the message wrongly pending).
|
|
4194
|
-
const replies = (db.
|
|
4257
|
+
const replies = (db.query(`
|
|
4195
4258
|
${MSG_SELECT}
|
|
4196
4259
|
WHERE m.from_agent = ?
|
|
4197
4260
|
AND m.id > ?
|
|
@@ -4224,7 +4287,7 @@ function replyObligationFromMessage(message: Message, agentId: string): ReplyObl
|
|
|
4224
4287
|
|
|
4225
4288
|
export function listPendingReplyObligations(agentId: string, limit = 20): ReplyObligation[] {
|
|
4226
4289
|
const scanLimit = Math.max(limit * 5, 50);
|
|
4227
|
-
const rows = db.
|
|
4290
|
+
const rows = timedQuery("listPendingReplyObligations", () => db.query(`
|
|
4228
4291
|
${MSG_SELECT}
|
|
4229
4292
|
WHERE EXISTS (
|
|
4230
4293
|
SELECT 1 FROM message_reads mr
|
|
@@ -4238,7 +4301,7 @@ export function listPendingReplyObligations(agentId: string, limit = 20): ReplyO
|
|
|
4238
4301
|
)
|
|
4239
4302
|
ORDER BY m.created_at ASC
|
|
4240
4303
|
LIMIT ?
|
|
4241
|
-
`).all(agentId, agentId, agentId, scanLimit) as any[];
|
|
4304
|
+
`).all(agentId, agentId, agentId, scanLimit) as any[]);
|
|
4242
4305
|
return rows
|
|
4243
4306
|
.map(rowToMessage)
|
|
4244
4307
|
.filter(messageRequiresReply)
|
|
@@ -4248,12 +4311,12 @@ export function listPendingReplyObligations(agentId: string, limit = 20): ReplyO
|
|
|
4248
4311
|
}
|
|
4249
4312
|
|
|
4250
4313
|
export function markRead(messageId: number, agentId: string): boolean {
|
|
4251
|
-
const exists = db.
|
|
4314
|
+
const exists = db.query("SELECT 1 FROM messages WHERE id = ?").get(messageId);
|
|
4252
4315
|
if (!exists) return false;
|
|
4253
|
-
db.
|
|
4316
|
+
db.query(
|
|
4254
4317
|
"INSERT OR IGNORE INTO message_reads (message_id, agent_id, read_at) VALUES (?, ?, ?)"
|
|
4255
4318
|
).run(messageId, agentId, Date.now());
|
|
4256
|
-
db.
|
|
4319
|
+
db.query(`
|
|
4257
4320
|
UPDATE messages
|
|
4258
4321
|
SET delivery_status = 'delivered',
|
|
4259
4322
|
delivery_last_error = NULL,
|
|
@@ -4266,10 +4329,10 @@ export function markRead(messageId: number, agentId: string): boolean {
|
|
|
4266
4329
|
}
|
|
4267
4330
|
|
|
4268
4331
|
export function getInboxState(operatorId: string): InboxState {
|
|
4269
|
-
const threads = (db.
|
|
4332
|
+
const threads = (db.query(
|
|
4270
4333
|
"SELECT * FROM inbox_thread_state WHERE operator_id = ? ORDER BY updated_at DESC",
|
|
4271
4334
|
).all(operatorId) as any[]).map(rowToInboxThreadState);
|
|
4272
|
-
const drafts = (db.
|
|
4335
|
+
const drafts = (db.query(
|
|
4273
4336
|
"SELECT * FROM inbox_drafts WHERE operator_id = ? ORDER BY updated_at DESC",
|
|
4274
4337
|
).all(operatorId) as any[]).map(rowToInboxDraft);
|
|
4275
4338
|
return { operatorId, threads, drafts };
|
|
@@ -4282,7 +4345,7 @@ export function setInboxThreadState(input: {
|
|
|
4282
4345
|
archivedAtMessageId?: number | null;
|
|
4283
4346
|
}): InboxThreadState {
|
|
4284
4347
|
const now = Date.now();
|
|
4285
|
-
const current = db.
|
|
4348
|
+
const current = db.query(
|
|
4286
4349
|
"SELECT * FROM inbox_thread_state WHERE operator_id = ? AND peer_id = ?",
|
|
4287
4350
|
).get(input.operatorId, input.peerId) as any | undefined;
|
|
4288
4351
|
|
|
@@ -4293,7 +4356,7 @@ export function setInboxThreadState(input: {
|
|
|
4293
4356
|
? input.archivedAtMessageId ?? null
|
|
4294
4357
|
: current?.archived_at_message_id ?? null;
|
|
4295
4358
|
|
|
4296
|
-
db.
|
|
4359
|
+
db.query(`
|
|
4297
4360
|
INSERT INTO inbox_thread_state (operator_id, peer_id, read_cursor_message_id, archived_at_message_id, updated_at)
|
|
4298
4361
|
VALUES (?, ?, ?, ?, ?)
|
|
4299
4362
|
ON CONFLICT(operator_id, peer_id) DO UPDATE SET
|
|
@@ -4302,7 +4365,7 @@ export function setInboxThreadState(input: {
|
|
|
4302
4365
|
updated_at = excluded.updated_at
|
|
4303
4366
|
`).run(input.operatorId, input.peerId, readCursorMessageId, archivedAtMessageId, now);
|
|
4304
4367
|
|
|
4305
|
-
return rowToInboxThreadState(db.
|
|
4368
|
+
return rowToInboxThreadState(db.query(
|
|
4306
4369
|
"SELECT * FROM inbox_thread_state WHERE operator_id = ? AND peer_id = ?",
|
|
4307
4370
|
).get(input.operatorId, input.peerId));
|
|
4308
4371
|
}
|
|
@@ -4315,7 +4378,7 @@ export function setInboxDraft(input: {
|
|
|
4315
4378
|
channel?: string | null;
|
|
4316
4379
|
}): InboxDraft {
|
|
4317
4380
|
const now = Date.now();
|
|
4318
|
-
db.
|
|
4381
|
+
db.query(`
|
|
4319
4382
|
INSERT INTO inbox_drafts (operator_id, peer_id, body, subject, channel, updated_at)
|
|
4320
4383
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
4321
4384
|
ON CONFLICT(operator_id, peer_id) DO UPDATE SET
|
|
@@ -4325,13 +4388,13 @@ export function setInboxDraft(input: {
|
|
|
4325
4388
|
updated_at = excluded.updated_at
|
|
4326
4389
|
`).run(input.operatorId, input.peerId, input.body, input.subject ?? null, input.channel ?? null, now);
|
|
4327
4390
|
|
|
4328
|
-
return rowToInboxDraft(db.
|
|
4391
|
+
return rowToInboxDraft(db.query(
|
|
4329
4392
|
"SELECT * FROM inbox_drafts WHERE operator_id = ? AND peer_id = ?",
|
|
4330
4393
|
).get(input.operatorId, input.peerId));
|
|
4331
4394
|
}
|
|
4332
4395
|
|
|
4333
4396
|
export function deleteInboxDraft(operatorId: string, peerId: string): boolean {
|
|
4334
|
-
return db.
|
|
4397
|
+
return db.query("DELETE FROM inbox_drafts WHERE operator_id = ? AND peer_id = ?").run(operatorId, peerId).changes > 0;
|
|
4335
4398
|
}
|
|
4336
4399
|
|
|
4337
4400
|
export function createChatHistoryImport(input: {
|
|
@@ -4359,7 +4422,7 @@ export function createChatHistoryImport(input: {
|
|
|
4359
4422
|
const id = randomUUID();
|
|
4360
4423
|
const now = Date.now();
|
|
4361
4424
|
db.transaction(() => {
|
|
4362
|
-
db.
|
|
4425
|
+
db.query(`
|
|
4363
4426
|
INSERT INTO chat_history_imports (
|
|
4364
4427
|
id, target_agent_id, target_spawn_request_id, source_peer_id, source_agent_id,
|
|
4365
4428
|
source_thread_id, source_agent_label, imported_by, imported_at
|
|
@@ -4376,7 +4439,7 @@ export function createChatHistoryImport(input: {
|
|
|
4376
4439
|
now,
|
|
4377
4440
|
);
|
|
4378
4441
|
|
|
4379
|
-
const insertEntry = db.
|
|
4442
|
+
const insertEntry = db.query(`
|
|
4380
4443
|
INSERT INTO chat_history_import_entries (
|
|
4381
4444
|
import_id, position, original_message_id, original_from, original_to, original_created_at, message_snapshot
|
|
4382
4445
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
@@ -4398,9 +4461,9 @@ export function createChatHistoryImport(input: {
|
|
|
4398
4461
|
}
|
|
4399
4462
|
|
|
4400
4463
|
function getChatHistoryImport(id: string): ChatHistoryImport | null {
|
|
4401
|
-
const row = db.
|
|
4464
|
+
const row = db.query("SELECT * FROM chat_history_imports WHERE id = ?").get(id) as any | undefined;
|
|
4402
4465
|
if (!row) return null;
|
|
4403
|
-
const entries = (db.
|
|
4466
|
+
const entries = (db.query(
|
|
4404
4467
|
"SELECT * FROM chat_history_import_entries WHERE import_id = ? ORDER BY position ASC",
|
|
4405
4468
|
).all(id) as any[]).map(rowToChatHistoryImportEntry);
|
|
4406
4469
|
return rowToChatHistoryImport(row, entries);
|
|
@@ -4423,9 +4486,9 @@ export function listChatHistoryImports(input: {
|
|
|
4423
4486
|
}
|
|
4424
4487
|
const limit = Math.max(1, Math.min(input.limit ?? 100, 500));
|
|
4425
4488
|
const where = conditions.length ? `WHERE ${conditions.join(" OR ")}` : "";
|
|
4426
|
-
const rows = db.
|
|
4489
|
+
const rows = db.query(`SELECT * FROM chat_history_imports ${where} ORDER BY imported_at ASC LIMIT ?`).all(...params, limit) as any[];
|
|
4427
4490
|
return rows.map((row) => {
|
|
4428
|
-
const entries = (db.
|
|
4491
|
+
const entries = (db.query(
|
|
4429
4492
|
"SELECT * FROM chat_history_import_entries WHERE import_id = ? ORDER BY position ASC",
|
|
4430
4493
|
).all(row.id) as any[]).map(rowToChatHistoryImportEntry);
|
|
4431
4494
|
return rowToChatHistoryImport(row, entries);
|
|
@@ -4454,7 +4517,7 @@ export function listActivityEvents(input: {
|
|
|
4454
4517
|
}
|
|
4455
4518
|
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
4456
4519
|
params.push(input.limit ?? 200);
|
|
4457
|
-
return (db.
|
|
4520
|
+
return (db.query(
|
|
4458
4521
|
`SELECT * FROM activity_events ${where} ORDER BY created_at DESC, id DESC LIMIT ?`,
|
|
4459
4522
|
).all(...params) as any[]).map(rowToActivityEvent);
|
|
4460
4523
|
}
|
|
@@ -4483,11 +4546,11 @@ export function getAgentTimeline(agentId: string, opts: { limit?: number; since?
|
|
|
4483
4546
|
|
|
4484
4547
|
// Managed agents: include policy-scoped lifecycle transitions recorded before
|
|
4485
4548
|
// the agent registered (agent_id null), correlated via metadata.policyName.
|
|
4486
|
-
const managed = db.
|
|
4549
|
+
const managed = db.query("SELECT policy_name FROM managed_agent_state WHERE agent_id = ? LIMIT 1").get(agentId) as { policy_name?: string } | undefined;
|
|
4487
4550
|
if (managed?.policy_name) {
|
|
4488
4551
|
const rows = (since !== undefined
|
|
4489
|
-
? db.
|
|
4490
|
-
: db.
|
|
4552
|
+
? 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)
|
|
4553
|
+
: 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
4554
|
for (const ev of rows.map(rowToActivityEvent)) {
|
|
4492
4555
|
entries.push({ ts: ev.createdAt, source: "activity", kind: ev.kind, title: ev.title, detail: ev.body, metadata: ev.metadata });
|
|
4493
4556
|
}
|
|
@@ -4495,16 +4558,16 @@ export function getAgentTimeline(agentId: string, opts: { limit?: number; since?
|
|
|
4495
4558
|
|
|
4496
4559
|
// Commands targeting this agent (spawn/shutdown/restart/compact) with outcome.
|
|
4497
4560
|
const cmds = (since !== undefined
|
|
4498
|
-
? db.
|
|
4499
|
-
: db.
|
|
4561
|
+
? 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)
|
|
4562
|
+
: 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
4563
|
for (const c of cmds) {
|
|
4501
4564
|
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
4565
|
}
|
|
4503
4566
|
|
|
4504
4567
|
// Delivery attempts involving this agent (failures, retries, poison).
|
|
4505
4568
|
const attempts = (since !== undefined
|
|
4506
|
-
? db.
|
|
4507
|
-
: db.
|
|
4569
|
+
? db.query("SELECT * FROM message_delivery_attempts WHERE agent_id = ? AND created_at >= ? ORDER BY created_at DESC LIMIT ?").all(agentId, since, limit)
|
|
4570
|
+
: db.query("SELECT * FROM message_delivery_attempts WHERE agent_id = ? ORDER BY created_at DESC LIMIT ?").all(agentId, limit)) as any[];
|
|
4508
4571
|
for (const row of attempts) {
|
|
4509
4572
|
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
4573
|
}
|
|
@@ -4515,11 +4578,11 @@ export function getAgentTimeline(agentId: string, opts: { limit?: number; since?
|
|
|
4515
4578
|
|
|
4516
4579
|
export function createActivityEvent(input: ActivityEventInput): ActivityEvent {
|
|
4517
4580
|
if (input.clientId) {
|
|
4518
|
-
const existing = db.
|
|
4581
|
+
const existing = db.query("SELECT * FROM activity_events WHERE client_id = ?").get(input.clientId) as any | undefined;
|
|
4519
4582
|
if (existing) return rowToActivityEvent(existing);
|
|
4520
4583
|
}
|
|
4521
4584
|
const now = Date.now();
|
|
4522
|
-
const result = db.
|
|
4585
|
+
const result = db.query(`
|
|
4523
4586
|
INSERT INTO activity_events (
|
|
4524
4587
|
operator_id, client_id, kind, title, body, meta_text, icon, view, peer_id,
|
|
4525
4588
|
message_id, pair_id, task_id, agent_id, metadata, created_at
|
|
@@ -4542,21 +4605,21 @@ export function createActivityEvent(input: ActivityEventInput): ActivityEvent {
|
|
|
4542
4605
|
JSON.stringify(input.metadata ?? {}),
|
|
4543
4606
|
now,
|
|
4544
4607
|
);
|
|
4545
|
-
return rowToActivityEvent(db.
|
|
4608
|
+
return rowToActivityEvent(db.query("SELECT * FROM activity_events WHERE id = ?").get(Number(result.lastInsertRowid)));
|
|
4546
4609
|
}
|
|
4547
4610
|
|
|
4548
4611
|
export function deleteMessage(id: number): boolean {
|
|
4549
4612
|
return db.transaction(() => {
|
|
4550
4613
|
// Break reply_to references from children so the FK doesn't block delete.
|
|
4551
4614
|
// Children keep their thread_id — the thread shows up minus this message.
|
|
4552
|
-
db.
|
|
4615
|
+
db.query("UPDATE messages SET reply_to = NULL WHERE reply_to = ?").run(id);
|
|
4553
4616
|
deleteArtifactLinksForEntity("message", id);
|
|
4554
|
-
return db.
|
|
4617
|
+
return db.query("DELETE FROM messages WHERE id = ?").run(id).changes > 0;
|
|
4555
4618
|
})();
|
|
4556
4619
|
}
|
|
4557
4620
|
|
|
4558
4621
|
export function getLatestMessageId(): number {
|
|
4559
|
-
const row = db.
|
|
4622
|
+
const row = db.query("SELECT MAX(id) as id FROM messages").get() as any;
|
|
4560
4623
|
return row?.id ?? 0;
|
|
4561
4624
|
}
|
|
4562
4625
|
|
|
@@ -4564,10 +4627,10 @@ export function pruneOldMessages(maxAgeMs: number): number {
|
|
|
4564
4627
|
const cutoff = Date.now() - maxAgeMs;
|
|
4565
4628
|
return db.transaction(() => {
|
|
4566
4629
|
db
|
|
4567
|
-
.
|
|
4630
|
+
.query("UPDATE messages SET reply_to = NULL WHERE reply_to IN (SELECT id FROM messages WHERE created_at < ?)")
|
|
4568
4631
|
.run(cutoff);
|
|
4569
4632
|
return db
|
|
4570
|
-
.
|
|
4633
|
+
.query("DELETE FROM messages WHERE created_at < ?")
|
|
4571
4634
|
.run(cutoff).changes;
|
|
4572
4635
|
})();
|
|
4573
4636
|
}
|
|
@@ -4582,28 +4645,28 @@ export function getStats(): {
|
|
|
4582
4645
|
openTasks: number;
|
|
4583
4646
|
} {
|
|
4584
4647
|
const agents = (
|
|
4585
|
-
db.
|
|
4648
|
+
db.query("SELECT COUNT(*) as c FROM agents").get() as any
|
|
4586
4649
|
).c;
|
|
4587
4650
|
const online = (
|
|
4588
4651
|
db
|
|
4589
|
-
.
|
|
4652
|
+
.query(
|
|
4590
4653
|
"SELECT COUNT(*) as c FROM agents WHERE status != 'offline' AND last_seen > ?"
|
|
4591
4654
|
)
|
|
4592
4655
|
.get(Date.now() - STALE_TTL_MS) as any
|
|
4593
4656
|
).c;
|
|
4594
4657
|
const messages = (
|
|
4595
|
-
db.
|
|
4658
|
+
db.query("SELECT COUNT(*) as c FROM messages").get() as any
|
|
4596
4659
|
).c;
|
|
4597
4660
|
const messagesLast24h = (
|
|
4598
4661
|
db
|
|
4599
|
-
.
|
|
4662
|
+
.query("SELECT COUNT(*) as c FROM messages WHERE created_at > ?")
|
|
4600
4663
|
.get(Date.now() - DAY_MS) as any
|
|
4601
4664
|
).c;
|
|
4602
4665
|
const tasks = (
|
|
4603
|
-
db.
|
|
4666
|
+
db.query("SELECT COUNT(*) as c FROM tasks").get() as any
|
|
4604
4667
|
).c;
|
|
4605
4668
|
const openTasks = (
|
|
4606
|
-
db.
|
|
4669
|
+
db.query("SELECT COUNT(*) as c FROM tasks WHERE status NOT IN ('done', 'failed', 'canceled')").get() as any
|
|
4607
4670
|
).c;
|
|
4608
4671
|
|
|
4609
4672
|
return { version: VERSION, agents, online, messages, messagesLast24h, tasks, openTasks };
|
|
@@ -4613,14 +4676,14 @@ export function getHealth(now: number = Date.now()): HealthReport {
|
|
|
4613
4676
|
const checks: HealthCheck[] = [];
|
|
4614
4677
|
|
|
4615
4678
|
try {
|
|
4616
|
-
db.
|
|
4679
|
+
db.query("SELECT 1").get();
|
|
4617
4680
|
checks.push({ name: "database", status: "ok" });
|
|
4618
4681
|
} catch (e) {
|
|
4619
4682
|
checks.push({ name: "database", status: "error", detail: e instanceof Error ? e.message : "database check failed" });
|
|
4620
4683
|
}
|
|
4621
4684
|
|
|
4622
4685
|
const staleLiveAgents = (db
|
|
4623
|
-
.
|
|
4686
|
+
.query("SELECT COUNT(*) as c FROM agents WHERE status != 'offline' AND last_seen <= ? AND id NOT IN ('user', 'system')")
|
|
4624
4687
|
.get(now - STALE_TTL_MS) as any).c as number;
|
|
4625
4688
|
checks.push({
|
|
4626
4689
|
name: "stale-live-agents",
|
|
@@ -4630,7 +4693,7 @@ export function getHealth(now: number = Date.now()): HealthReport {
|
|
|
4630
4693
|
});
|
|
4631
4694
|
|
|
4632
4695
|
const expiredMessageClaims = (db
|
|
4633
|
-
.
|
|
4696
|
+
.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
4697
|
.get(now) as any).c as number;
|
|
4635
4698
|
checks.push({
|
|
4636
4699
|
name: "expired-message-claims",
|
|
@@ -4640,7 +4703,7 @@ export function getHealth(now: number = Date.now()): HealthReport {
|
|
|
4640
4703
|
});
|
|
4641
4704
|
|
|
4642
4705
|
const expiredTaskClaims = (db
|
|
4643
|
-
.
|
|
4706
|
+
.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
4707
|
.get(now) as any).c as number;
|
|
4645
4708
|
checks.push({
|
|
4646
4709
|
name: "expired-task-claims",
|
|
@@ -4650,7 +4713,7 @@ export function getHealth(now: number = Date.now()): HealthReport {
|
|
|
4650
4713
|
});
|
|
4651
4714
|
|
|
4652
4715
|
const offlineClaimedTasks = (db
|
|
4653
|
-
.
|
|
4716
|
+
.query(`
|
|
4654
4717
|
SELECT COUNT(*) as c
|
|
4655
4718
|
FROM tasks t
|
|
4656
4719
|
JOIN agents a ON a.id = t.claimed_by
|
|
@@ -4797,12 +4860,12 @@ function parseOrchestratorUpgrade(value: unknown): OrchestratorUpgradeState | un
|
|
|
4797
4860
|
* meta blob (no schema change). Pass null to clear.
|
|
4798
4861
|
*/
|
|
4799
4862
|
export function setOrchestratorUpgradeState(id: string, state: OrchestratorUpgradeState | null): Orchestrator | null {
|
|
4800
|
-
const row = db.
|
|
4863
|
+
const row = db.query("SELECT meta FROM orchestrators WHERE id = ?").get(id) as { meta?: string } | undefined;
|
|
4801
4864
|
if (!row) return null;
|
|
4802
4865
|
const meta = parseJson<Record<string, unknown>>(row.meta ?? "{}", {});
|
|
4803
4866
|
if (state) meta.upgrade = state;
|
|
4804
4867
|
else delete meta.upgrade;
|
|
4805
|
-
db.
|
|
4868
|
+
db.query("UPDATE orchestrators SET meta = ? WHERE id = ?").run(JSON.stringify(meta), id);
|
|
4806
4869
|
return getOrchestrator(id);
|
|
4807
4870
|
}
|
|
4808
4871
|
|
|
@@ -4890,11 +4953,11 @@ export function upsertOrchestrator(input: RegisterOrchestratorInput): Orchestrat
|
|
|
4890
4953
|
// Carry forward server-managed meta the orchestrator never reports (upgrade
|
|
4891
4954
|
// state) — registration meta would otherwise drop it on the very re-register
|
|
4892
4955
|
// that follows a self-upgrade restart, breaking version reconciliation.
|
|
4893
|
-
const existingRow = db.
|
|
4956
|
+
const existingRow = db.query("SELECT meta FROM orchestrators WHERE id = ?").get(input.id) as { meta?: string } | undefined;
|
|
4894
4957
|
const existingMeta = existingRow ? parseJson<Record<string, unknown>>(existingRow.meta ?? "{}", {}) : {};
|
|
4895
4958
|
const mergedMeta = mergeOrchestratorRuntimeMeta(input.meta ?? {}, input);
|
|
4896
4959
|
if (existingMeta.upgrade !== undefined && mergedMeta.upgrade === undefined) mergedMeta.upgrade = existingMeta.upgrade;
|
|
4897
|
-
const stmt = db.
|
|
4960
|
+
const stmt = db.query(`
|
|
4898
4961
|
INSERT INTO orchestrators (id, hostname, status, agent_id, providers, base_dir, api_url, env_keys, meta, last_seen, created_at)
|
|
4899
4962
|
VALUES ($id, $hostname, 'online', $agentId, $providers, $baseDir, $apiUrl, $envKeys, $meta, $now, $now)
|
|
4900
4963
|
ON CONFLICT(id) DO UPDATE SET
|
|
@@ -4944,24 +5007,24 @@ export function upsertOrchestrator(input: RegisterOrchestratorInput): Orchestrat
|
|
|
4944
5007
|
}
|
|
4945
5008
|
|
|
4946
5009
|
export function getOrchestrator(id: string): Orchestrator | null {
|
|
4947
|
-
const row = db.
|
|
5010
|
+
const row = db.query("SELECT * FROM orchestrators WHERE id = ?").get(id) as any;
|
|
4948
5011
|
return row ? rowToOrchestrator(row) : null;
|
|
4949
5012
|
}
|
|
4950
5013
|
|
|
4951
5014
|
export function listOrchestrators(): Orchestrator[] {
|
|
4952
|
-
return (db.
|
|
5015
|
+
return (db.query("SELECT * FROM orchestrators ORDER BY hostname").all() as any[]).map(rowToOrchestrator);
|
|
4953
5016
|
}
|
|
4954
5017
|
|
|
4955
5018
|
export function orchestratorHeartbeat(id: string, runtime: OrchestratorRuntimeInput = {}): Orchestrator | null {
|
|
4956
5019
|
const now = Date.now();
|
|
4957
|
-
const row = db.
|
|
5020
|
+
const row = db.query("SELECT meta FROM orchestrators WHERE id = ?").get(id) as { meta?: string } | undefined;
|
|
4958
5021
|
if (!row) return null;
|
|
4959
5022
|
const meta = mergeOrchestratorRuntimeMeta(parseJson<Record<string, unknown>>(row.meta ?? "{}", {}), runtime);
|
|
4960
5023
|
if (runtime.providers) {
|
|
4961
|
-
db.
|
|
5024
|
+
db.query("UPDATE orchestrators SET last_seen = ?, status = 'online', providers = ?, meta = ? WHERE id = ?")
|
|
4962
5025
|
.run(now, JSON.stringify(runtime.providers), JSON.stringify(meta), id);
|
|
4963
5026
|
} else {
|
|
4964
|
-
db.
|
|
5027
|
+
db.query("UPDATE orchestrators SET last_seen = ?, status = 'online', meta = ? WHERE id = ?").run(now, JSON.stringify(meta), id);
|
|
4965
5028
|
}
|
|
4966
5029
|
// Also heartbeat the agent
|
|
4967
5030
|
const orch = getOrchestrator(id);
|
|
@@ -4993,7 +5056,7 @@ export function updateManagedAgents(id: string, agents: ManagedAgent[]): Orchest
|
|
|
4993
5056
|
},
|
|
4994
5057
|
} : agent;
|
|
4995
5058
|
});
|
|
4996
|
-
db.
|
|
5059
|
+
db.query("UPDATE orchestrators SET managed_agents = ?, last_seen = ? WHERE id = ?")
|
|
4997
5060
|
.run(JSON.stringify(enriched), Date.now(), id);
|
|
4998
5061
|
return getOrchestrator(id);
|
|
4999
5062
|
}
|
|
@@ -5051,7 +5114,7 @@ function upsertWorkspaceFromManagedAgent(agent: ManagedAgent): WorkspaceRecord |
|
|
|
5051
5114
|
|
|
5052
5115
|
export function upsertWorkspace(input: Omit<WorkspaceRecord, "createdAt" | "updatedAt"> & Partial<Pick<WorkspaceRecord, "createdAt" | "updatedAt">>): WorkspaceRecord {
|
|
5053
5116
|
const now = Date.now();
|
|
5054
|
-
db.
|
|
5117
|
+
db.query(`
|
|
5055
5118
|
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
5119
|
VALUES ($id, $repoRoot, $sourceCwd, $worktreePath, $branch, $baseRef, $baseSha, $mode, $requestedMode, $status, $ownerAgentId, $ownerPolicyName, $ownerAutomationRunId, $stewardAgentId, $metadata, $createdAt, $updatedAt, $readyAt, $cleanedAt)
|
|
5057
5120
|
ON CONFLICT(id) DO UPDATE SET
|
|
@@ -5098,7 +5161,7 @@ export function upsertWorkspace(input: Omit<WorkspaceRecord, "createdAt" | "upda
|
|
|
5098
5161
|
}
|
|
5099
5162
|
|
|
5100
5163
|
export function getWorkspace(id: string): WorkspaceRecord | null {
|
|
5101
|
-
const row = db.
|
|
5164
|
+
const row = db.query("SELECT * FROM workspaces WHERE id = ?").get(id) as any;
|
|
5102
5165
|
return row ? rowToWorkspace(row) : null;
|
|
5103
5166
|
}
|
|
5104
5167
|
|
|
@@ -5109,11 +5172,11 @@ export function listWorkspaces(filter: { repoRoot?: string; ownerAgentId?: strin
|
|
|
5109
5172
|
if (filter.ownerAgentId) { where.push("owner_agent_id = ?"); params.push(filter.ownerAgentId); }
|
|
5110
5173
|
if (filter.status) { where.push("status = ?"); params.push(filter.status); }
|
|
5111
5174
|
const sql = `SELECT * FROM workspaces${where.length ? " WHERE " + where.join(" AND ") : ""} ORDER BY updated_at DESC`;
|
|
5112
|
-
return (db.
|
|
5175
|
+
return (db.query(sql).all(...params) as any[]).map(rowToWorkspace);
|
|
5113
5176
|
}
|
|
5114
5177
|
|
|
5115
5178
|
export function deleteWorkspace(id: string): boolean {
|
|
5116
|
-
return db.
|
|
5179
|
+
return db.query("DELETE FROM workspaces WHERE id = ?").run(id).changes > 0;
|
|
5117
5180
|
}
|
|
5118
5181
|
|
|
5119
5182
|
// Shared-mode rows are pure occupancy markers (no worktree on disk) that only
|
|
@@ -5131,9 +5194,9 @@ export function pruneOrphanedSharedWorkspaces(): string[] {
|
|
|
5131
5194
|
OR owner_agent_id NOT IN (SELECT id FROM agents)
|
|
5132
5195
|
OR owner_agent_id IN (SELECT id FROM agents WHERE status = 'offline')
|
|
5133
5196
|
)`;
|
|
5134
|
-
const rows = db.
|
|
5197
|
+
const rows = db.query(`SELECT id FROM workspaces WHERE ${orphanCondition}`).all() as Array<{ id: string }>;
|
|
5135
5198
|
if (!rows.length) return [];
|
|
5136
|
-
db.
|
|
5199
|
+
db.query(`DELETE FROM workspaces WHERE ${orphanCondition}`).run();
|
|
5137
5200
|
return rows.map((r) => r.id);
|
|
5138
5201
|
})();
|
|
5139
5202
|
}
|
|
@@ -5143,7 +5206,7 @@ export function updateWorkspaceStatus(id: string, status: WorkspaceStatus, metad
|
|
|
5143
5206
|
if (!existing) return null;
|
|
5144
5207
|
const nextMeta = { ...existing.metadata, ...metadata };
|
|
5145
5208
|
const now = Date.now();
|
|
5146
|
-
db.
|
|
5209
|
+
db.query(`
|
|
5147
5210
|
UPDATE workspaces
|
|
5148
5211
|
SET status = ?, metadata = ?, updated_at = ?, ready_at = coalesce(ready_at, ?), cleaned_at = coalesce(cleaned_at, ?)
|
|
5149
5212
|
WHERE id = ?
|
|
@@ -5182,19 +5245,19 @@ function rowToRepoSteward(row: any): RepoStewardRecord {
|
|
|
5182
5245
|
}
|
|
5183
5246
|
|
|
5184
5247
|
export function getRepoSteward(repoRoot: string): RepoStewardRecord | null {
|
|
5185
|
-
const row = db.
|
|
5248
|
+
const row = db.query("SELECT * FROM repo_stewards WHERE repo_root = ?").get(repoRoot) as any;
|
|
5186
5249
|
return row ? rowToRepoSteward(row) : null;
|
|
5187
5250
|
}
|
|
5188
5251
|
|
|
5189
5252
|
export function listRepoStewards(): RepoStewardRecord[] {
|
|
5190
|
-
return (db.
|
|
5253
|
+
return (db.query("SELECT * FROM repo_stewards ORDER BY updated_at DESC").all() as any[]).map(rowToRepoSteward);
|
|
5191
5254
|
}
|
|
5192
5255
|
|
|
5193
5256
|
// Persist the elected steward for a repo. The row is never deleted, so a repo's
|
|
5194
5257
|
// stewardship survives a full all-agents-offline gap (steward goes NULL/dormant,
|
|
5195
5258
|
// last_steward_agent_id keeps continuity) and resumes on the next agent join.
|
|
5196
5259
|
function upsertRepoSteward(repoRoot: string, steward: string | null, now: number): void {
|
|
5197
|
-
db.
|
|
5260
|
+
db.query(`
|
|
5198
5261
|
INSERT INTO repo_stewards (repo_root, steward_agent_id, last_steward_agent_id, elected_at, updated_at)
|
|
5199
5262
|
VALUES ($repoRoot, $steward, $steward, $electedAt, $now)
|
|
5200
5263
|
ON CONFLICT(repo_root) DO UPDATE SET
|
|
@@ -5210,7 +5273,7 @@ function upsertRepoSteward(repoRoot: string, steward: string | null, now: number
|
|
|
5210
5273
|
|
|
5211
5274
|
function electWorkspaceStewards(repoRoot?: string): void {
|
|
5212
5275
|
const params: string[] = repoRoot ? [repoRoot] : [];
|
|
5213
|
-
const repoRows = db.
|
|
5276
|
+
const repoRows = db.query(`
|
|
5214
5277
|
SELECT DISTINCT repo_root FROM workspaces
|
|
5215
5278
|
WHERE status IN (${STEWARD_LIVE_STATUSES})
|
|
5216
5279
|
${repoRoot ? "AND repo_root = ?" : ""}
|
|
@@ -5220,7 +5283,7 @@ function electWorkspaceStewards(repoRoot?: string): void {
|
|
|
5220
5283
|
// Candidate pool: owners of live workspaces in this repo who are online,
|
|
5221
5284
|
// oldest first. A steward must be an online agent actively in the repo — an
|
|
5222
5285
|
// offline agent can't coordinate, so it is never elected (the old bug).
|
|
5223
|
-
const pool = (db.
|
|
5286
|
+
const pool = (db.query(`
|
|
5224
5287
|
SELECT w.owner_agent_id AS id, MIN(w.created_at) AS created_at
|
|
5225
5288
|
FROM workspaces w JOIN agents a ON a.id = w.owner_agent_id
|
|
5226
5289
|
WHERE w.repo_root = ? AND w.owner_agent_id IS NOT NULL
|
|
@@ -5239,7 +5302,7 @@ function electWorkspaceStewards(repoRoot?: string): void {
|
|
|
5239
5302
|
// re-elections don't churn updated_at and reset the auto-abandon clock for a
|
|
5240
5303
|
// dormant repo (a stranded review_requested must still age out).
|
|
5241
5304
|
if (steward !== current) {
|
|
5242
|
-
db.
|
|
5305
|
+
db.query(`UPDATE workspaces SET steward_agent_id = ?, updated_at = ? WHERE repo_root = ? AND status IN (${STEWARD_LIVE_STATUSES})`)
|
|
5243
5306
|
.run(steward, now, row.repo_root);
|
|
5244
5307
|
}
|
|
5245
5308
|
}
|
|
@@ -5263,7 +5326,7 @@ export function patchWorkspaceMetadata(id: string, patch: Record<string, unknown
|
|
|
5263
5326
|
if (v === undefined) delete next[k];
|
|
5264
5327
|
else next[k] = v;
|
|
5265
5328
|
}
|
|
5266
|
-
db.
|
|
5329
|
+
db.query("UPDATE workspaces SET metadata = ? WHERE id = ?").run(JSON.stringify(next), id);
|
|
5267
5330
|
return getWorkspace(id);
|
|
5268
5331
|
}
|
|
5269
5332
|
|
|
@@ -5271,7 +5334,7 @@ export function patchWorkspaceMetadata(id: string, patch: Record<string, unknown
|
|
|
5271
5334
|
// when an agent (re)registers so a dormant repo regains a steward on rejoin
|
|
5272
5335
|
// without a full unscoped sweep.
|
|
5273
5336
|
function electWorkspaceStewardsForAgent(agentId: string): void {
|
|
5274
|
-
const repos = db.
|
|
5337
|
+
const repos = db.query(`
|
|
5275
5338
|
SELECT DISTINCT repo_root FROM workspaces
|
|
5276
5339
|
WHERE owner_agent_id = ? AND status IN (${STEWARD_LIVE_STATUSES})
|
|
5277
5340
|
`).all(agentId) as Array<{ repo_root: string }>;
|
|
@@ -5301,18 +5364,18 @@ function rowToMergeLease(row: any): MergeLeaseRecord {
|
|
|
5301
5364
|
}
|
|
5302
5365
|
|
|
5303
5366
|
export function getMergeLease(repoRoot: string): MergeLeaseRecord | null {
|
|
5304
|
-
const row = db.
|
|
5367
|
+
const row = db.query("SELECT * FROM workspace_merge_leases WHERE repo_root = ?").get(repoRoot) as any;
|
|
5305
5368
|
return row ? rowToMergeLease(row) : null;
|
|
5306
5369
|
}
|
|
5307
5370
|
|
|
5308
5371
|
export function listMergeLeases(): MergeLeaseRecord[] {
|
|
5309
|
-
return (db.
|
|
5372
|
+
return (db.query("SELECT * FROM workspace_merge_leases ORDER BY acquired_at DESC").all() as any[]).map(rowToMergeLease);
|
|
5310
5373
|
}
|
|
5311
5374
|
|
|
5312
5375
|
export function releaseExpiredMergeLeases(now: number = Date.now()): string[] {
|
|
5313
|
-
const expired = db.
|
|
5376
|
+
const expired = db.query("SELECT repo_root FROM workspace_merge_leases WHERE expires_at <= ?").all(now) as Array<{ repo_root: string }>;
|
|
5314
5377
|
if (!expired.length) return [];
|
|
5315
|
-
db.
|
|
5378
|
+
db.query("DELETE FROM workspace_merge_leases WHERE expires_at <= ?").run(now);
|
|
5316
5379
|
return expired.map((r) => r.repo_root);
|
|
5317
5380
|
}
|
|
5318
5381
|
|
|
@@ -5329,7 +5392,7 @@ export function acquireMergeLease(
|
|
|
5329
5392
|
const existing = getMergeLease(repoRoot);
|
|
5330
5393
|
if (existing && existing.expiresAt > now) return { ok: false as const, lease: existing };
|
|
5331
5394
|
const expiresAt = now + WORKSPACE_MERGE_LEASE_MS;
|
|
5332
|
-
db.
|
|
5395
|
+
db.query(`
|
|
5333
5396
|
INSERT INTO workspace_merge_leases (repo_root, workspace_id, command_id, holder, acquired_at, expires_at)
|
|
5334
5397
|
VALUES (?, ?, NULL, ?, ?, ?)
|
|
5335
5398
|
ON CONFLICT(repo_root) DO UPDATE SET
|
|
@@ -5343,7 +5406,7 @@ export function acquireMergeLease(
|
|
|
5343
5406
|
// Attach the dispatched command id to a held lease so it can be released by
|
|
5344
5407
|
// command id when the merge settles.
|
|
5345
5408
|
export function setMergeLeaseCommand(repoRoot: string, commandId: string): void {
|
|
5346
|
-
db.
|
|
5409
|
+
db.query("UPDATE workspace_merge_leases SET command_id = ? WHERE repo_root = ?").run(commandId, repoRoot);
|
|
5347
5410
|
}
|
|
5348
5411
|
|
|
5349
5412
|
// Release a merge lease. Guard by commandId/workspaceId when known so a stale
|
|
@@ -5355,26 +5418,26 @@ export function releaseMergeLease(opts: { repoRoot?: string; commandId?: string;
|
|
|
5355
5418
|
if (opts.commandId) { where.push("command_id = ?"); params.push(opts.commandId); }
|
|
5356
5419
|
if (opts.workspaceId) { where.push("workspace_id = ?"); params.push(opts.workspaceId); }
|
|
5357
5420
|
if (!where.length) return false;
|
|
5358
|
-
return db.
|
|
5421
|
+
return db.query(`DELETE FROM workspace_merge_leases WHERE ${where.join(" AND ")}`).run(...params).changes > 0;
|
|
5359
5422
|
}
|
|
5360
5423
|
|
|
5361
5424
|
export function deleteOrchestrator(id: string): boolean {
|
|
5362
5425
|
const orch = getOrchestrator(id);
|
|
5363
5426
|
if (!orch) return false;
|
|
5364
|
-
db.
|
|
5427
|
+
db.query("DELETE FROM orchestrators WHERE id = ?").run(id);
|
|
5365
5428
|
deleteAgent(orch.agentId);
|
|
5366
5429
|
return true;
|
|
5367
5430
|
}
|
|
5368
5431
|
|
|
5369
5432
|
export function reapStaleOrchestrators(): string[] {
|
|
5370
5433
|
const cutoff = Date.now() - STALE_TTL_MS;
|
|
5371
|
-
const stale = db.
|
|
5434
|
+
const stale = db.query("SELECT id, agent_id FROM orchestrators WHERE last_seen < ? AND status = 'online'").all(cutoff) as any[];
|
|
5372
5435
|
for (const row of stale) {
|
|
5373
|
-
db.
|
|
5436
|
+
db.query("UPDATE orchestrators SET status = 'offline' WHERE id = ?").run(row.id);
|
|
5374
5437
|
// An orchestrator agent holds no workspaces, so use a direct status update
|
|
5375
5438
|
// instead of setStatus() (which triggers an unscoped electWorkspaceStewards
|
|
5376
5439
|
// sweep across all repos on every offline transition).
|
|
5377
|
-
db.
|
|
5440
|
+
db.query("UPDATE agents SET status = 'offline', ready = 0 WHERE id = ?").run(row.agent_id);
|
|
5378
5441
|
}
|
|
5379
5442
|
return stale.map((row: any) => row.id);
|
|
5380
5443
|
}
|