@pinet/broker-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +26 -0
- package/dist/agent-messaging.d.ts +47 -0
- package/dist/agent-messaging.js +176 -0
- package/dist/auth.d.ts +7 -0
- package/dist/auth.js +59 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/leader.d.ts +29 -0
- package/dist/leader.js +95 -0
- package/dist/mail-classification.d.ts +17 -0
- package/dist/mail-classification.js +103 -0
- package/dist/maintenance.d.ts +50 -0
- package/dist/maintenance.js +134 -0
- package/dist/message-send.d.ts +31 -0
- package/dist/message-send.js +75 -0
- package/dist/paths.d.ts +14 -0
- package/dist/paths.js +29 -0
- package/dist/raw-tcp-loopback.d.ts +2 -0
- package/dist/raw-tcp-loopback.js +39 -0
- package/dist/router.d.ts +60 -0
- package/dist/router.js +336 -0
- package/dist/schema.d.ts +155 -0
- package/dist/schema.js +3076 -0
- package/dist/types.d.ts +312 -0
- package/dist/types.js +16 -0
- package/package.json +49 -0
package/dist/schema.js
ADDED
|
@@ -0,0 +1,3076 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import * as crypto from "node:crypto";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { classifyPinetMail } from "./mail-classification.js";
|
|
6
|
+
import { getDefaultDbPath } from "./paths.js";
|
|
7
|
+
import { DEFAULT_EXTERNAL_THREAD_SOURCE } from "./types.js";
|
|
8
|
+
function getSqliteJournalMode(result) {
|
|
9
|
+
const mode = result?.journal_mode?.trim().toLowerCase();
|
|
10
|
+
return mode && mode.length > 0 ? mode : "unknown";
|
|
11
|
+
}
|
|
12
|
+
function isSqliteWalEnabled(result) {
|
|
13
|
+
return getSqliteJournalMode(result) === "wal";
|
|
14
|
+
}
|
|
15
|
+
function buildSqliteWalFallbackWarning(component, result) {
|
|
16
|
+
return `[${component}] SQLite WAL mode not available, using ${getSqliteJournalMode(result)} journal mode fallback`;
|
|
17
|
+
}
|
|
18
|
+
// ─── Mappers ─────────────────────────────────────────────
|
|
19
|
+
function rowToAgent(row) {
|
|
20
|
+
return {
|
|
21
|
+
id: row.id,
|
|
22
|
+
stableId: row.stable_id,
|
|
23
|
+
name: row.name,
|
|
24
|
+
emoji: row.emoji,
|
|
25
|
+
pid: row.pid,
|
|
26
|
+
connectedAt: row.connected_at,
|
|
27
|
+
lastSeen: row.last_seen,
|
|
28
|
+
lastHeartbeat: row.last_heartbeat,
|
|
29
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : null,
|
|
30
|
+
status: row.status === "working" ? "working" : "idle",
|
|
31
|
+
disconnectedAt: row.disconnected_at,
|
|
32
|
+
resumableUntil: row.resumable_until,
|
|
33
|
+
idleSince: row.idle_since,
|
|
34
|
+
lastActivity: row.last_activity,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function rowToThread(row) {
|
|
38
|
+
return {
|
|
39
|
+
threadId: row.thread_id,
|
|
40
|
+
source: row.source,
|
|
41
|
+
channel: row.channel,
|
|
42
|
+
ownerAgent: row.owner_agent,
|
|
43
|
+
ownerBinding: row.owner_binding === "explicit" ? "explicit" : null,
|
|
44
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : null,
|
|
45
|
+
createdAt: row.created_at,
|
|
46
|
+
updatedAt: row.updated_at,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function rowToPortLease(row) {
|
|
50
|
+
return {
|
|
51
|
+
id: row.id,
|
|
52
|
+
purpose: row.purpose,
|
|
53
|
+
port: row.port,
|
|
54
|
+
host: row.host,
|
|
55
|
+
ownerAgentId: row.owner_agent_id,
|
|
56
|
+
pid: row.pid,
|
|
57
|
+
status: normalizePortLeaseStatus(row.status),
|
|
58
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : null,
|
|
59
|
+
acquiredAt: row.acquired_at,
|
|
60
|
+
renewedAt: row.renewed_at,
|
|
61
|
+
expiresAt: row.expires_at,
|
|
62
|
+
releasedAt: row.released_at,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function normalizePortLeaseStatus(value) {
|
|
66
|
+
if (value === "released" || value === "expired") {
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
return "active";
|
|
70
|
+
}
|
|
71
|
+
function getStringMetadataValue(metadata, keys) {
|
|
72
|
+
for (const key of keys) {
|
|
73
|
+
const value = metadata?.[key];
|
|
74
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
75
|
+
return value.trim();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
const INTERNAL_AGENT_SOURCE = "agent";
|
|
81
|
+
function isExternalTransportSource(source) {
|
|
82
|
+
return source.trim().length > 0 && source !== INTERNAL_AGENT_SOURCE;
|
|
83
|
+
}
|
|
84
|
+
function deriveMessageSyncIdentity(threadId, source, metadata) {
|
|
85
|
+
const explicitExternalId = getStringMetadataValue(metadata, [
|
|
86
|
+
"externalId",
|
|
87
|
+
"external_id",
|
|
88
|
+
"transportMessageId",
|
|
89
|
+
"transport_message_id",
|
|
90
|
+
]);
|
|
91
|
+
const explicitExternalTs = getStringMetadataValue(metadata, [
|
|
92
|
+
"externalTs",
|
|
93
|
+
"external_ts",
|
|
94
|
+
"timestamp",
|
|
95
|
+
"ts",
|
|
96
|
+
]);
|
|
97
|
+
if (explicitExternalId) {
|
|
98
|
+
return { externalId: explicitExternalId, externalTs: explicitExternalTs };
|
|
99
|
+
}
|
|
100
|
+
if (!isExternalTransportSource(source)) {
|
|
101
|
+
return { externalId: null, externalTs: explicitExternalTs };
|
|
102
|
+
}
|
|
103
|
+
const channel = getStringMetadataValue(metadata, [
|
|
104
|
+
"transportChannelId",
|
|
105
|
+
"transport_channel_id",
|
|
106
|
+
"conversationId",
|
|
107
|
+
"conversation_id",
|
|
108
|
+
"channel",
|
|
109
|
+
"channelId",
|
|
110
|
+
"channel_id",
|
|
111
|
+
]);
|
|
112
|
+
const timestamp = getStringMetadataValue(metadata, [
|
|
113
|
+
"transportTimestamp",
|
|
114
|
+
"transport_timestamp",
|
|
115
|
+
"timestamp",
|
|
116
|
+
"ts",
|
|
117
|
+
]);
|
|
118
|
+
if (timestamp && channel) {
|
|
119
|
+
return { externalId: `${channel}:${timestamp}`, externalTs: timestamp };
|
|
120
|
+
}
|
|
121
|
+
if (timestamp && threadId.trim().length > 0) {
|
|
122
|
+
return { externalId: `${threadId}:${timestamp}`, externalTs: timestamp };
|
|
123
|
+
}
|
|
124
|
+
return { externalId: null, externalTs: explicitExternalTs };
|
|
125
|
+
}
|
|
126
|
+
function parseJsonMetadata(value) {
|
|
127
|
+
if (!value)
|
|
128
|
+
return {};
|
|
129
|
+
try {
|
|
130
|
+
const parsed = JSON.parse(value);
|
|
131
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
132
|
+
? parsed
|
|
133
|
+
: {};
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return {};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function appendMetadataAudit(metadata, key, audit) {
|
|
140
|
+
const existing = Array.isArray(metadata[key])
|
|
141
|
+
? metadata[key].filter((item) => item !== null && typeof item === "object" && !Array.isArray(item))
|
|
142
|
+
: [];
|
|
143
|
+
metadata[key] = [...existing.slice(-19), audit];
|
|
144
|
+
}
|
|
145
|
+
const PINET_MAIL_CLASS_PRIORITY = {
|
|
146
|
+
steering: 0,
|
|
147
|
+
fwup: 1,
|
|
148
|
+
maintenance_context: 2,
|
|
149
|
+
};
|
|
150
|
+
function comparePinetMailClassPriority(a, b) {
|
|
151
|
+
return PINET_MAIL_CLASS_PRIORITY[a] - PINET_MAIL_CLASS_PRIORITY[b];
|
|
152
|
+
}
|
|
153
|
+
function emptyMailClassCounts() {
|
|
154
|
+
return { steering: 0, fwup: 0, maintenance_context: 0 };
|
|
155
|
+
}
|
|
156
|
+
function rowToBrokerMessage(row) {
|
|
157
|
+
return {
|
|
158
|
+
id: row.id,
|
|
159
|
+
threadId: row.thread_id,
|
|
160
|
+
source: row.source,
|
|
161
|
+
direction: row.direction,
|
|
162
|
+
sender: row.sender,
|
|
163
|
+
body: row.body,
|
|
164
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : null,
|
|
165
|
+
...(row.external_id ? { externalId: row.external_id } : {}),
|
|
166
|
+
...(row.external_ts ? { externalTs: row.external_ts } : {}),
|
|
167
|
+
createdAt: row.created_at,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
function rowToBacklog(row) {
|
|
171
|
+
return {
|
|
172
|
+
id: row.id,
|
|
173
|
+
threadId: row.thread_id,
|
|
174
|
+
channel: row.channel,
|
|
175
|
+
messageId: row.message_id,
|
|
176
|
+
reason: row.reason,
|
|
177
|
+
status: row.status === "assigned" ? "assigned" : row.status === "dropped" ? "dropped" : "pending",
|
|
178
|
+
preferredAgentId: row.preferred_agent_id,
|
|
179
|
+
assignedAgentId: row.assigned_agent_id,
|
|
180
|
+
attemptCount: row.attempt_count,
|
|
181
|
+
lastAttemptAt: row.last_attempt_at,
|
|
182
|
+
createdAt: row.created_at,
|
|
183
|
+
updatedAt: row.updated_at,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function normalizeTaskAssignmentKind(value) {
|
|
187
|
+
switch (value) {
|
|
188
|
+
case "implementation":
|
|
189
|
+
case "review":
|
|
190
|
+
case "qa":
|
|
191
|
+
case "merge":
|
|
192
|
+
case "interactive":
|
|
193
|
+
case "unknown":
|
|
194
|
+
return value;
|
|
195
|
+
default:
|
|
196
|
+
return "unknown";
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function buildTaskAssignmentRepoKey(input) {
|
|
200
|
+
const owner = input.repoOwner?.trim().toLowerCase();
|
|
201
|
+
const name = input.repoName?.trim().toLowerCase();
|
|
202
|
+
if (owner && name) {
|
|
203
|
+
return `${owner}/${name}`;
|
|
204
|
+
}
|
|
205
|
+
const repoRoot = input.repoRoot?.trim();
|
|
206
|
+
return repoRoot ? `root:${repoRoot}` : "repo_unknown";
|
|
207
|
+
}
|
|
208
|
+
function rowToTaskAssignment(row) {
|
|
209
|
+
return {
|
|
210
|
+
id: row.id,
|
|
211
|
+
agentId: row.agent_id,
|
|
212
|
+
issueNumber: row.issue_number,
|
|
213
|
+
branch: row.branch,
|
|
214
|
+
prNumber: row.pr_number,
|
|
215
|
+
status: row.status,
|
|
216
|
+
threadId: row.thread_id,
|
|
217
|
+
sourceMessageId: row.source_message_id,
|
|
218
|
+
repoOwner: row.repo_owner,
|
|
219
|
+
repoName: row.repo_name,
|
|
220
|
+
repoRoot: row.repo_root,
|
|
221
|
+
taskKind: normalizeTaskAssignmentKind(row.task_kind),
|
|
222
|
+
createdAt: row.created_at,
|
|
223
|
+
updatedAt: row.updated_at,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
function rowToScheduledWakeup(row) {
|
|
227
|
+
return {
|
|
228
|
+
id: row.id,
|
|
229
|
+
agentId: row.agent_id,
|
|
230
|
+
threadId: row.thread_id,
|
|
231
|
+
body: row.body,
|
|
232
|
+
fireAt: row.fire_at,
|
|
233
|
+
createdAt: row.created_at,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
const PINET_LANE_STATES = new Set([
|
|
237
|
+
"planned",
|
|
238
|
+
"active",
|
|
239
|
+
"blocked",
|
|
240
|
+
"review",
|
|
241
|
+
"ready",
|
|
242
|
+
"done",
|
|
243
|
+
"cancelled",
|
|
244
|
+
"detached",
|
|
245
|
+
]);
|
|
246
|
+
const PINET_LANE_ROLES = new Set([
|
|
247
|
+
"broker",
|
|
248
|
+
"coordinator",
|
|
249
|
+
"pm",
|
|
250
|
+
"lead",
|
|
251
|
+
"implementer",
|
|
252
|
+
"reviewer",
|
|
253
|
+
"second_pass_reviewer",
|
|
254
|
+
"observer",
|
|
255
|
+
]);
|
|
256
|
+
function parseMetadataJson(value) {
|
|
257
|
+
if (!value)
|
|
258
|
+
return null;
|
|
259
|
+
try {
|
|
260
|
+
return JSON.parse(value);
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function rowToPinetLaneParticipant(row) {
|
|
267
|
+
return {
|
|
268
|
+
laneId: row.lane_id,
|
|
269
|
+
agentId: row.agent_id,
|
|
270
|
+
role: normalizePinetLaneRole(row.lane_role),
|
|
271
|
+
status: row.status,
|
|
272
|
+
summary: row.summary,
|
|
273
|
+
metadata: parseMetadataJson(row.metadata),
|
|
274
|
+
createdAt: row.created_at,
|
|
275
|
+
updatedAt: row.updated_at,
|
|
276
|
+
lastActivityAt: row.last_activity_at,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
function rowToPinetLane(row, participants = []) {
|
|
280
|
+
return {
|
|
281
|
+
laneId: row.lane_id,
|
|
282
|
+
name: row.name,
|
|
283
|
+
task: row.task,
|
|
284
|
+
issueNumber: row.issue_number,
|
|
285
|
+
prNumber: row.pr_number,
|
|
286
|
+
threadId: row.thread_id,
|
|
287
|
+
ownerAgentId: row.owner_agent_id,
|
|
288
|
+
implementationLeadAgentId: row.implementation_lead_agent_id,
|
|
289
|
+
pmMode: row.pm_mode === 1,
|
|
290
|
+
state: normalizePinetLaneState(row.state),
|
|
291
|
+
summary: row.summary,
|
|
292
|
+
metadata: parseMetadataJson(row.metadata),
|
|
293
|
+
createdAt: row.created_at,
|
|
294
|
+
updatedAt: row.updated_at,
|
|
295
|
+
lastActivityAt: row.last_activity_at,
|
|
296
|
+
participants,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
function normalizePinetLaneState(value, fallback = "active") {
|
|
300
|
+
return typeof value === "string" && PINET_LANE_STATES.has(value)
|
|
301
|
+
? value
|
|
302
|
+
: fallback;
|
|
303
|
+
}
|
|
304
|
+
function requirePinetLaneState(value) {
|
|
305
|
+
if (typeof value === "string" && PINET_LANE_STATES.has(value)) {
|
|
306
|
+
return value;
|
|
307
|
+
}
|
|
308
|
+
throw new Error(`Invalid Pinet lane state: ${String(value)}`);
|
|
309
|
+
}
|
|
310
|
+
function normalizePinetLaneRole(value, fallback = "observer") {
|
|
311
|
+
return typeof value === "string" && PINET_LANE_ROLES.has(value)
|
|
312
|
+
? value
|
|
313
|
+
: fallback;
|
|
314
|
+
}
|
|
315
|
+
function requirePinetLaneRole(value) {
|
|
316
|
+
if (typeof value === "string" && PINET_LANE_ROLES.has(value)) {
|
|
317
|
+
return value;
|
|
318
|
+
}
|
|
319
|
+
throw new Error(`Invalid Pinet lane role: ${String(value)}`);
|
|
320
|
+
}
|
|
321
|
+
function normalizeLaneId(value) {
|
|
322
|
+
const trimmed = value.trim();
|
|
323
|
+
if (!trimmed) {
|
|
324
|
+
throw new Error("laneId must be a non-empty string");
|
|
325
|
+
}
|
|
326
|
+
return trimmed;
|
|
327
|
+
}
|
|
328
|
+
function normalizeOptionalText(value) {
|
|
329
|
+
if (value === undefined)
|
|
330
|
+
return undefined;
|
|
331
|
+
if (value === null)
|
|
332
|
+
return null;
|
|
333
|
+
const trimmed = value.trim();
|
|
334
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
335
|
+
}
|
|
336
|
+
function normalizeOptionalInteger(value) {
|
|
337
|
+
if (value === undefined || value === null)
|
|
338
|
+
return value;
|
|
339
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
340
|
+
throw new Error("lane issue/PR numbers must be non-negative integers");
|
|
341
|
+
}
|
|
342
|
+
return value;
|
|
343
|
+
}
|
|
344
|
+
function serializeOptionalMetadata(value) {
|
|
345
|
+
if (value === undefined)
|
|
346
|
+
return undefined;
|
|
347
|
+
return value === null ? null : JSON.stringify(value);
|
|
348
|
+
}
|
|
349
|
+
function normalizePortLeasePurpose(value) {
|
|
350
|
+
const trimmed = value.trim();
|
|
351
|
+
if (!trimmed) {
|
|
352
|
+
throw new Error("purpose must be a non-empty string");
|
|
353
|
+
}
|
|
354
|
+
return trimmed;
|
|
355
|
+
}
|
|
356
|
+
function normalizePortLeaseId(value) {
|
|
357
|
+
const trimmed = value.trim();
|
|
358
|
+
if (!trimmed) {
|
|
359
|
+
throw new Error("leaseId must be a non-empty string");
|
|
360
|
+
}
|
|
361
|
+
return trimmed;
|
|
362
|
+
}
|
|
363
|
+
function normalizeOptionalPortLeaseOwner(value) {
|
|
364
|
+
if (value === undefined)
|
|
365
|
+
return undefined;
|
|
366
|
+
if (value === null)
|
|
367
|
+
return null;
|
|
368
|
+
const trimmed = value.trim();
|
|
369
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
370
|
+
}
|
|
371
|
+
function normalizePortLeaseHost(value) {
|
|
372
|
+
const trimmed = value?.trim();
|
|
373
|
+
return trimmed && trimmed.length > 0 ? trimmed : "127.0.0.1";
|
|
374
|
+
}
|
|
375
|
+
function normalizePortLeasePort(value, label = "port") {
|
|
376
|
+
if (!Number.isInteger(value) || value < 1 || value > 65535) {
|
|
377
|
+
throw new Error(`${label} must be an integer between 1 and 65535`);
|
|
378
|
+
}
|
|
379
|
+
return value;
|
|
380
|
+
}
|
|
381
|
+
function normalizePortLeaseTtlMs(value) {
|
|
382
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
383
|
+
throw new Error("ttlMs must be a positive finite number");
|
|
384
|
+
}
|
|
385
|
+
return Math.max(1, Math.round(value));
|
|
386
|
+
}
|
|
387
|
+
function normalizePortLeasePid(value) {
|
|
388
|
+
if (value === undefined || value === null)
|
|
389
|
+
return null;
|
|
390
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
391
|
+
throw new Error("pid must be a positive integer");
|
|
392
|
+
}
|
|
393
|
+
return value;
|
|
394
|
+
}
|
|
395
|
+
function normalizePortLeaseRange(minPort, maxPort) {
|
|
396
|
+
const min = normalizePortLeasePort(minPort ?? 49152, "minPort");
|
|
397
|
+
const max = normalizePortLeasePort(maxPort ?? 65535, "maxPort");
|
|
398
|
+
if (min > max) {
|
|
399
|
+
throw new Error("minPort must be less than or equal to maxPort");
|
|
400
|
+
}
|
|
401
|
+
return { minPort: min, maxPort: max };
|
|
402
|
+
}
|
|
403
|
+
// ─── Default DB path ─────────────────────────────────────
|
|
404
|
+
export function defaultDbPath() {
|
|
405
|
+
return getDefaultDbPath();
|
|
406
|
+
}
|
|
407
|
+
export const DEFAULT_RESUMABLE_WINDOW_MS = 15_000;
|
|
408
|
+
export const DEFAULT_DISCONNECTED_PURGE_GRACE_MS = 60 * 60_000;
|
|
409
|
+
export const CURRENT_BROKER_SCHEMA_VERSION = 17;
|
|
410
|
+
const REQUIRED_AGENT_LIFECYCLE_COLUMNS = [
|
|
411
|
+
"stable_id",
|
|
412
|
+
"metadata",
|
|
413
|
+
"status",
|
|
414
|
+
"last_heartbeat",
|
|
415
|
+
"disconnected_at",
|
|
416
|
+
"resumable_until",
|
|
417
|
+
];
|
|
418
|
+
function getUserVersion(db) {
|
|
419
|
+
const row = db.prepare("PRAGMA user_version").get();
|
|
420
|
+
return Number(row?.user_version ?? 0);
|
|
421
|
+
}
|
|
422
|
+
function setUserVersion(db, version) {
|
|
423
|
+
db.exec(`PRAGMA user_version = ${version}`);
|
|
424
|
+
}
|
|
425
|
+
function getTableColumns(db, tableName) {
|
|
426
|
+
const rows = db.prepare(`PRAGMA table_info(${tableName})`).all();
|
|
427
|
+
return new Set(rows.map((row) => row.name));
|
|
428
|
+
}
|
|
429
|
+
function tableExists(db, tableName) {
|
|
430
|
+
const row = db
|
|
431
|
+
.prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?")
|
|
432
|
+
.get(tableName);
|
|
433
|
+
return row !== undefined;
|
|
434
|
+
}
|
|
435
|
+
function ensureColumn(db, tableName, columnName, sql) {
|
|
436
|
+
if (!getTableColumns(db, tableName).has(columnName)) {
|
|
437
|
+
db.exec(sql);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
function createCoreTables(db) {
|
|
441
|
+
db.exec(`
|
|
442
|
+
CREATE TABLE IF NOT EXISTS agents (
|
|
443
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
444
|
+
name TEXT NOT NULL,
|
|
445
|
+
emoji TEXT NOT NULL,
|
|
446
|
+
pid INTEGER NOT NULL,
|
|
447
|
+
connected_at TEXT NOT NULL,
|
|
448
|
+
last_seen TEXT NOT NULL
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
CREATE TABLE IF NOT EXISTS threads (
|
|
452
|
+
thread_id TEXT PRIMARY KEY NOT NULL,
|
|
453
|
+
source TEXT NOT NULL,
|
|
454
|
+
channel TEXT NOT NULL,
|
|
455
|
+
owner_agent TEXT,
|
|
456
|
+
owner_binding TEXT,
|
|
457
|
+
metadata TEXT,
|
|
458
|
+
created_at TEXT NOT NULL,
|
|
459
|
+
updated_at TEXT NOT NULL
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
463
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
464
|
+
thread_id TEXT NOT NULL,
|
|
465
|
+
source TEXT NOT NULL,
|
|
466
|
+
direction TEXT NOT NULL CHECK(direction IN ('inbound', 'outbound')),
|
|
467
|
+
sender TEXT NOT NULL,
|
|
468
|
+
body TEXT NOT NULL,
|
|
469
|
+
metadata TEXT,
|
|
470
|
+
external_id TEXT,
|
|
471
|
+
external_ts TEXT,
|
|
472
|
+
created_at TEXT NOT NULL
|
|
473
|
+
);
|
|
474
|
+
|
|
475
|
+
CREATE TABLE IF NOT EXISTS inbox (
|
|
476
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
477
|
+
agent_id TEXT NOT NULL,
|
|
478
|
+
message_id INTEGER NOT NULL,
|
|
479
|
+
delivered INTEGER NOT NULL DEFAULT 0,
|
|
480
|
+
read_at TEXT,
|
|
481
|
+
created_at TEXT NOT NULL
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
CREATE INDEX IF NOT EXISTS idx_messages_thread
|
|
485
|
+
ON messages(thread_id, created_at);
|
|
486
|
+
CREATE INDEX IF NOT EXISTS idx_inbox_agent_delivered
|
|
487
|
+
ON inbox(agent_id, delivered, created_at);
|
|
488
|
+
CREATE INDEX IF NOT EXISTS idx_inbox_message
|
|
489
|
+
ON inbox(message_id);
|
|
490
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_inbox_agent_message_pending_unique
|
|
491
|
+
ON inbox(agent_id, message_id)
|
|
492
|
+
WHERE delivered = 0;
|
|
493
|
+
`);
|
|
494
|
+
const messageColumns = getTableColumns(db, "messages");
|
|
495
|
+
if (messageColumns.has("external_id")) {
|
|
496
|
+
db.exec(`
|
|
497
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_source_external_id
|
|
498
|
+
ON messages(source, external_id)
|
|
499
|
+
WHERE external_id IS NOT NULL;
|
|
500
|
+
`);
|
|
501
|
+
}
|
|
502
|
+
if (messageColumns.has("external_ts")) {
|
|
503
|
+
db.exec(`
|
|
504
|
+
CREATE INDEX IF NOT EXISTS idx_messages_source_external_ts
|
|
505
|
+
ON messages(source, external_ts);
|
|
506
|
+
`);
|
|
507
|
+
}
|
|
508
|
+
if (getTableColumns(db, "inbox").has("read_at")) {
|
|
509
|
+
db.exec(`
|
|
510
|
+
CREATE INDEX IF NOT EXISTS idx_inbox_agent_read
|
|
511
|
+
ON inbox(agent_id, read_at, created_at);
|
|
512
|
+
`);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
function createBacklogTable(db) {
|
|
516
|
+
db.exec(`
|
|
517
|
+
CREATE TABLE IF NOT EXISTS unrouted_backlog (
|
|
518
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
519
|
+
thread_id TEXT NOT NULL,
|
|
520
|
+
channel TEXT NOT NULL,
|
|
521
|
+
message_id INTEGER NOT NULL UNIQUE,
|
|
522
|
+
reason TEXT NOT NULL,
|
|
523
|
+
status TEXT NOT NULL CHECK(status IN ('pending', 'assigned', 'dropped')),
|
|
524
|
+
preferred_agent_id TEXT,
|
|
525
|
+
assigned_agent_id TEXT,
|
|
526
|
+
attempt_count INTEGER NOT NULL DEFAULT 0,
|
|
527
|
+
last_attempt_at TEXT,
|
|
528
|
+
created_at TEXT NOT NULL,
|
|
529
|
+
updated_at TEXT NOT NULL
|
|
530
|
+
);
|
|
531
|
+
|
|
532
|
+
CREATE INDEX IF NOT EXISTS idx_backlog_status_created
|
|
533
|
+
ON unrouted_backlog(status, created_at);
|
|
534
|
+
CREATE INDEX IF NOT EXISTS idx_backlog_thread_status
|
|
535
|
+
ON unrouted_backlog(thread_id, status);
|
|
536
|
+
CREATE INDEX IF NOT EXISTS idx_backlog_preferred_agent_status
|
|
537
|
+
ON unrouted_backlog(preferred_agent_id, status);
|
|
538
|
+
`);
|
|
539
|
+
}
|
|
540
|
+
function createSettingsTable(db) {
|
|
541
|
+
db.exec(`
|
|
542
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
543
|
+
key TEXT PRIMARY KEY NOT NULL,
|
|
544
|
+
value TEXT NOT NULL,
|
|
545
|
+
updated_at TEXT NOT NULL
|
|
546
|
+
);
|
|
547
|
+
`);
|
|
548
|
+
}
|
|
549
|
+
function addAgentLifecycleColumns(db) {
|
|
550
|
+
ensureColumn(db, "agents", "stable_id", "ALTER TABLE agents ADD COLUMN stable_id TEXT");
|
|
551
|
+
ensureColumn(db, "agents", "metadata", "ALTER TABLE agents ADD COLUMN metadata TEXT");
|
|
552
|
+
ensureColumn(db, "agents", "status", "ALTER TABLE agents ADD COLUMN status TEXT NOT NULL DEFAULT 'idle'");
|
|
553
|
+
ensureColumn(db, "agents", "last_heartbeat", "ALTER TABLE agents ADD COLUMN last_heartbeat TEXT");
|
|
554
|
+
ensureColumn(db, "agents", "disconnected_at", "ALTER TABLE agents ADD COLUMN disconnected_at TEXT");
|
|
555
|
+
ensureColumn(db, "agents", "resumable_until", "ALTER TABLE agents ADD COLUMN resumable_until TEXT");
|
|
556
|
+
db.exec(`
|
|
557
|
+
UPDATE agents
|
|
558
|
+
SET last_heartbeat = COALESCE(last_heartbeat, last_seen)
|
|
559
|
+
WHERE last_heartbeat IS NULL;
|
|
560
|
+
|
|
561
|
+
CREATE INDEX IF NOT EXISTS idx_agents_last_heartbeat
|
|
562
|
+
ON agents(last_heartbeat);
|
|
563
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_stable_id
|
|
564
|
+
ON agents(stable_id)
|
|
565
|
+
WHERE stable_id IS NOT NULL;
|
|
566
|
+
`);
|
|
567
|
+
}
|
|
568
|
+
function addObservabilityColumns(db) {
|
|
569
|
+
ensureColumn(db, "agents", "idle_since", "ALTER TABLE agents ADD COLUMN idle_since TEXT");
|
|
570
|
+
ensureColumn(db, "agents", "last_activity", "ALTER TABLE agents ADD COLUMN last_activity TEXT");
|
|
571
|
+
// Set idle_since for currently idle agents that lack it
|
|
572
|
+
db.exec(`
|
|
573
|
+
UPDATE agents
|
|
574
|
+
SET idle_since = COALESCE(idle_since, last_seen)
|
|
575
|
+
WHERE status = 'idle' AND idle_since IS NULL;
|
|
576
|
+
`);
|
|
577
|
+
}
|
|
578
|
+
function addThreadOwnershipBindingColumn(db) {
|
|
579
|
+
createCoreTables(db);
|
|
580
|
+
ensureColumn(db, "threads", "owner_binding", "ALTER TABLE threads ADD COLUMN owner_binding TEXT");
|
|
581
|
+
}
|
|
582
|
+
function addInboxReadCursorColumn(db) {
|
|
583
|
+
createCoreTables(db);
|
|
584
|
+
ensureColumn(db, "inbox", "read_at", "ALTER TABLE inbox ADD COLUMN read_at TEXT");
|
|
585
|
+
db.exec(`
|
|
586
|
+
CREATE INDEX IF NOT EXISTS idx_inbox_agent_read
|
|
587
|
+
ON inbox(agent_id, read_at, created_at);
|
|
588
|
+
`);
|
|
589
|
+
}
|
|
590
|
+
function addThreadMetadataColumn(db) {
|
|
591
|
+
createCoreTables(db);
|
|
592
|
+
ensureColumn(db, "threads", "metadata", "ALTER TABLE threads ADD COLUMN metadata TEXT");
|
|
593
|
+
}
|
|
594
|
+
function backfillMessageSyncIdentities(db) {
|
|
595
|
+
const rows = db
|
|
596
|
+
.prepare("SELECT id, thread_id, source, metadata FROM messages WHERE metadata IS NOT NULL")
|
|
597
|
+
.all();
|
|
598
|
+
const update = db.prepare("UPDATE messages SET external_id = ?, external_ts = ? WHERE id = ?");
|
|
599
|
+
for (const row of rows) {
|
|
600
|
+
if (!row.metadata)
|
|
601
|
+
continue;
|
|
602
|
+
try {
|
|
603
|
+
const metadata = JSON.parse(row.metadata);
|
|
604
|
+
const identity = deriveMessageSyncIdentity(row.thread_id, row.source, metadata);
|
|
605
|
+
if (identity.externalId || identity.externalTs) {
|
|
606
|
+
update.run(identity.externalId, identity.externalTs, row.id);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
catch {
|
|
610
|
+
// Keep corrupt legacy metadata readable; it simply cannot receive a sync identity.
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
function pickBacklogStatus(left, right) {
|
|
615
|
+
if (left === "assigned" || right === "assigned")
|
|
616
|
+
return "assigned";
|
|
617
|
+
if (left === "pending" || right === "pending")
|
|
618
|
+
return "pending";
|
|
619
|
+
return "dropped";
|
|
620
|
+
}
|
|
621
|
+
function maxNullableIso(left, right) {
|
|
622
|
+
if (!left)
|
|
623
|
+
return right;
|
|
624
|
+
if (!right)
|
|
625
|
+
return left;
|
|
626
|
+
return left >= right ? left : right;
|
|
627
|
+
}
|
|
628
|
+
function minIso(left, right) {
|
|
629
|
+
return left <= right ? left : right;
|
|
630
|
+
}
|
|
631
|
+
function mergeBacklogMessageId(db, keepMessageId, duplicateMessageId) {
|
|
632
|
+
const getBacklog = db.prepare("SELECT * FROM unrouted_backlog WHERE message_id = ?");
|
|
633
|
+
const keep = getBacklog.get(keepMessageId);
|
|
634
|
+
const duplicate = getBacklog.get(duplicateMessageId);
|
|
635
|
+
if (!duplicate)
|
|
636
|
+
return;
|
|
637
|
+
if (!keep) {
|
|
638
|
+
db.prepare("UPDATE unrouted_backlog SET message_id = ? WHERE id = ?").run(keepMessageId, duplicate.id);
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
const mergedStatus = pickBacklogStatus(keep.status, duplicate.status);
|
|
642
|
+
db.prepare(`UPDATE unrouted_backlog
|
|
643
|
+
SET reason = ?,
|
|
644
|
+
status = ?,
|
|
645
|
+
preferred_agent_id = ?,
|
|
646
|
+
assigned_agent_id = ?,
|
|
647
|
+
attempt_count = ?,
|
|
648
|
+
last_attempt_at = ?,
|
|
649
|
+
created_at = ?,
|
|
650
|
+
updated_at = ?
|
|
651
|
+
WHERE id = ?`).run(keep.reason || duplicate.reason, mergedStatus, keep.preferred_agent_id ?? duplicate.preferred_agent_id, mergedStatus === "assigned" ? (keep.assigned_agent_id ?? duplicate.assigned_agent_id) : null, Math.max(keep.attempt_count, duplicate.attempt_count), maxNullableIso(keep.last_attempt_at, duplicate.last_attempt_at), minIso(keep.created_at, duplicate.created_at), maxNullableIso(keep.updated_at, duplicate.updated_at) ?? keep.updated_at, keep.id);
|
|
652
|
+
db.prepare("DELETE FROM unrouted_backlog WHERE id = ?").run(duplicate.id);
|
|
653
|
+
}
|
|
654
|
+
function consolidateDuplicateMessageSyncIdentities(db) {
|
|
655
|
+
const duplicateRows = db
|
|
656
|
+
.prepare(`SELECT source, external_id, MIN(id) AS keep_id
|
|
657
|
+
FROM messages
|
|
658
|
+
WHERE external_id IS NOT NULL
|
|
659
|
+
GROUP BY source, external_id
|
|
660
|
+
HAVING COUNT(*) > 1`)
|
|
661
|
+
.all();
|
|
662
|
+
const findDuplicates = db.prepare(`SELECT id
|
|
663
|
+
FROM messages
|
|
664
|
+
WHERE source = ?
|
|
665
|
+
AND external_id = ?
|
|
666
|
+
AND id <> ?`);
|
|
667
|
+
const repointInbox = db.prepare("UPDATE inbox SET message_id = ? WHERE message_id = ?");
|
|
668
|
+
const repointTaskAssignments = tableExists(db, "task_assignments")
|
|
669
|
+
? db.prepare("UPDATE task_assignments SET source_message_id = ? WHERE source_message_id = ?")
|
|
670
|
+
: null;
|
|
671
|
+
const hasBacklog = tableExists(db, "unrouted_backlog");
|
|
672
|
+
const clearDuplicate = db.prepare(`UPDATE messages
|
|
673
|
+
SET external_id = NULL,
|
|
674
|
+
external_ts = NULL
|
|
675
|
+
WHERE id = ?`);
|
|
676
|
+
for (const row of duplicateRows) {
|
|
677
|
+
const duplicates = findDuplicates.all(row.source, row.external_id, row.keep_id);
|
|
678
|
+
for (const duplicate of duplicates) {
|
|
679
|
+
repointInbox.run(row.keep_id, duplicate.id);
|
|
680
|
+
repointTaskAssignments?.run(row.keep_id, duplicate.id);
|
|
681
|
+
if (hasBacklog) {
|
|
682
|
+
mergeBacklogMessageId(db, row.keep_id, duplicate.id);
|
|
683
|
+
}
|
|
684
|
+
clearDuplicate.run(duplicate.id);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
function deleteDuplicateInboxRows(db) {
|
|
689
|
+
db.exec(`
|
|
690
|
+
UPDATE inbox
|
|
691
|
+
SET read_at = (
|
|
692
|
+
SELECT MAX(duplicate.read_at)
|
|
693
|
+
FROM inbox AS duplicate
|
|
694
|
+
WHERE duplicate.agent_id = inbox.agent_id
|
|
695
|
+
AND duplicate.message_id = inbox.message_id
|
|
696
|
+
AND duplicate.read_at IS NOT NULL
|
|
697
|
+
)
|
|
698
|
+
WHERE EXISTS (
|
|
699
|
+
SELECT 1
|
|
700
|
+
FROM inbox AS duplicate
|
|
701
|
+
WHERE duplicate.agent_id = inbox.agent_id
|
|
702
|
+
AND duplicate.message_id = inbox.message_id
|
|
703
|
+
AND duplicate.read_at IS NOT NULL
|
|
704
|
+
);
|
|
705
|
+
|
|
706
|
+
DELETE FROM inbox
|
|
707
|
+
WHERE id NOT IN (
|
|
708
|
+
SELECT COALESCE(
|
|
709
|
+
MIN(CASE WHEN delivered = 1 THEN id END),
|
|
710
|
+
MIN(id)
|
|
711
|
+
)
|
|
712
|
+
FROM inbox
|
|
713
|
+
GROUP BY agent_id, message_id
|
|
714
|
+
);
|
|
715
|
+
`);
|
|
716
|
+
}
|
|
717
|
+
function addMessageSyncIdentityColumns(db) {
|
|
718
|
+
ensureColumn(db, "messages", "external_id", "ALTER TABLE messages ADD COLUMN external_id TEXT");
|
|
719
|
+
ensureColumn(db, "messages", "external_ts", "ALTER TABLE messages ADD COLUMN external_ts TEXT");
|
|
720
|
+
backfillMessageSyncIdentities(db);
|
|
721
|
+
consolidateDuplicateMessageSyncIdentities(db);
|
|
722
|
+
deleteDuplicateInboxRows(db);
|
|
723
|
+
db.exec(`
|
|
724
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_source_external_id
|
|
725
|
+
ON messages(source, external_id)
|
|
726
|
+
WHERE external_id IS NOT NULL;
|
|
727
|
+
CREATE INDEX IF NOT EXISTS idx_messages_source_external_ts
|
|
728
|
+
ON messages(source, external_ts);
|
|
729
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_inbox_agent_message_pending_unique
|
|
730
|
+
ON inbox(agent_id, message_id)
|
|
731
|
+
WHERE delivered = 0;
|
|
732
|
+
`);
|
|
733
|
+
}
|
|
734
|
+
function addBacklogAffinityColumns(db) {
|
|
735
|
+
ensureColumn(db, "unrouted_backlog", "preferred_agent_id", "ALTER TABLE unrouted_backlog ADD COLUMN preferred_agent_id TEXT");
|
|
736
|
+
db.exec(`
|
|
737
|
+
CREATE INDEX IF NOT EXISTS idx_backlog_preferred_agent_status
|
|
738
|
+
ON unrouted_backlog(preferred_agent_id, status);
|
|
739
|
+
`);
|
|
740
|
+
}
|
|
741
|
+
function createTaskAssignmentTable(db) {
|
|
742
|
+
db.exec(`
|
|
743
|
+
CREATE TABLE IF NOT EXISTS task_assignments (
|
|
744
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
745
|
+
agent_id TEXT NOT NULL,
|
|
746
|
+
issue_number INTEGER NOT NULL,
|
|
747
|
+
branch TEXT,
|
|
748
|
+
pr_number INTEGER,
|
|
749
|
+
status TEXT NOT NULL DEFAULT 'assigned'
|
|
750
|
+
CHECK(status IN ('assigned', 'branch_pushed', 'pr_open', 'pr_merged', 'pr_closed')),
|
|
751
|
+
thread_id TEXT NOT NULL,
|
|
752
|
+
source_message_id INTEGER,
|
|
753
|
+
repo_key TEXT NOT NULL DEFAULT 'repo_unknown',
|
|
754
|
+
repo_owner TEXT,
|
|
755
|
+
repo_name TEXT,
|
|
756
|
+
repo_root TEXT,
|
|
757
|
+
task_kind TEXT NOT NULL DEFAULT 'unknown'
|
|
758
|
+
CHECK(task_kind IN ('implementation', 'review', 'qa', 'merge', 'interactive', 'unknown')),
|
|
759
|
+
created_at TEXT NOT NULL,
|
|
760
|
+
updated_at TEXT NOT NULL,
|
|
761
|
+
UNIQUE(repo_key, issue_number)
|
|
762
|
+
);
|
|
763
|
+
|
|
764
|
+
CREATE INDEX IF NOT EXISTS idx_task_assignments_agent_status
|
|
765
|
+
ON task_assignments(agent_id, status, updated_at DESC);
|
|
766
|
+
CREATE INDEX IF NOT EXISTS idx_task_assignments_branch
|
|
767
|
+
ON task_assignments(branch);
|
|
768
|
+
`);
|
|
769
|
+
}
|
|
770
|
+
function migrateTaskAssignmentsToIssueOwnership(db) {
|
|
771
|
+
const existingTable = db
|
|
772
|
+
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'task_assignments'")
|
|
773
|
+
.get();
|
|
774
|
+
if (!existingTable) {
|
|
775
|
+
createTaskAssignmentTable(db);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
db.exec(`
|
|
779
|
+
ALTER TABLE task_assignments RENAME TO task_assignments_legacy;
|
|
780
|
+
DROP INDEX IF EXISTS idx_task_assignments_agent_status;
|
|
781
|
+
DROP INDEX IF EXISTS idx_task_assignments_branch;
|
|
782
|
+
`);
|
|
783
|
+
createTaskAssignmentTable(db);
|
|
784
|
+
db.exec(`
|
|
785
|
+
INSERT INTO task_assignments (
|
|
786
|
+
agent_id,
|
|
787
|
+
issue_number,
|
|
788
|
+
branch,
|
|
789
|
+
pr_number,
|
|
790
|
+
status,
|
|
791
|
+
thread_id,
|
|
792
|
+
source_message_id,
|
|
793
|
+
repo_key,
|
|
794
|
+
repo_owner,
|
|
795
|
+
repo_name,
|
|
796
|
+
repo_root,
|
|
797
|
+
task_kind,
|
|
798
|
+
created_at,
|
|
799
|
+
updated_at
|
|
800
|
+
)
|
|
801
|
+
SELECT
|
|
802
|
+
legacy.agent_id,
|
|
803
|
+
legacy.issue_number,
|
|
804
|
+
legacy.branch,
|
|
805
|
+
legacy.pr_number,
|
|
806
|
+
legacy.status,
|
|
807
|
+
legacy.thread_id,
|
|
808
|
+
legacy.source_message_id,
|
|
809
|
+
'repo_unknown',
|
|
810
|
+
NULL,
|
|
811
|
+
NULL,
|
|
812
|
+
NULL,
|
|
813
|
+
'unknown',
|
|
814
|
+
legacy.created_at,
|
|
815
|
+
legacy.updated_at
|
|
816
|
+
FROM task_assignments_legacy AS legacy
|
|
817
|
+
WHERE legacy.id = (
|
|
818
|
+
SELECT latest.id
|
|
819
|
+
FROM task_assignments_legacy AS latest
|
|
820
|
+
WHERE latest.issue_number = legacy.issue_number
|
|
821
|
+
ORDER BY latest.updated_at DESC, latest.created_at DESC, latest.id DESC
|
|
822
|
+
LIMIT 1
|
|
823
|
+
);
|
|
824
|
+
|
|
825
|
+
DROP TABLE task_assignments_legacy;
|
|
826
|
+
`);
|
|
827
|
+
}
|
|
828
|
+
function migrateTaskAssignmentsToRepoScopedTracking(db) {
|
|
829
|
+
if (!tableExists(db, "task_assignments")) {
|
|
830
|
+
createTaskAssignmentTable(db);
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
const existingColumns = getTableColumns(db, "task_assignments");
|
|
834
|
+
if (!existingColumns.has("agent_id") || !existingColumns.has("issue_number")) {
|
|
835
|
+
db.exec("DROP TABLE task_assignments");
|
|
836
|
+
createTaskAssignmentTable(db);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
db.exec(`
|
|
840
|
+
ALTER TABLE task_assignments RENAME TO task_assignments_legacy;
|
|
841
|
+
DROP INDEX IF EXISTS idx_task_assignments_agent_status;
|
|
842
|
+
DROP INDEX IF EXISTS idx_task_assignments_branch;
|
|
843
|
+
`);
|
|
844
|
+
createTaskAssignmentTable(db);
|
|
845
|
+
const legacyColumns = getTableColumns(db, "task_assignments_legacy");
|
|
846
|
+
const repoKeyExpr = legacyColumns.has("repo_key")
|
|
847
|
+
? "COALESCE(repo_key, 'repo_unknown')"
|
|
848
|
+
: "'repo_unknown'";
|
|
849
|
+
const repoOwnerExpr = legacyColumns.has("repo_owner") ? "repo_owner" : "NULL";
|
|
850
|
+
const repoNameExpr = legacyColumns.has("repo_name") ? "repo_name" : "NULL";
|
|
851
|
+
const repoRootExpr = legacyColumns.has("repo_root") ? "repo_root" : "NULL";
|
|
852
|
+
const taskKindExpr = legacyColumns.has("task_kind")
|
|
853
|
+
? "COALESCE(task_kind, 'unknown')"
|
|
854
|
+
: "'unknown'";
|
|
855
|
+
db.exec(`
|
|
856
|
+
INSERT INTO task_assignments (
|
|
857
|
+
agent_id,
|
|
858
|
+
issue_number,
|
|
859
|
+
branch,
|
|
860
|
+
pr_number,
|
|
861
|
+
status,
|
|
862
|
+
thread_id,
|
|
863
|
+
source_message_id,
|
|
864
|
+
repo_key,
|
|
865
|
+
repo_owner,
|
|
866
|
+
repo_name,
|
|
867
|
+
repo_root,
|
|
868
|
+
task_kind,
|
|
869
|
+
created_at,
|
|
870
|
+
updated_at
|
|
871
|
+
)
|
|
872
|
+
SELECT
|
|
873
|
+
legacy.agent_id,
|
|
874
|
+
legacy.issue_number,
|
|
875
|
+
legacy.branch,
|
|
876
|
+
legacy.pr_number,
|
|
877
|
+
legacy.status,
|
|
878
|
+
legacy.thread_id,
|
|
879
|
+
legacy.source_message_id,
|
|
880
|
+
${repoKeyExpr},
|
|
881
|
+
${repoOwnerExpr},
|
|
882
|
+
${repoNameExpr},
|
|
883
|
+
${repoRootExpr},
|
|
884
|
+
${taskKindExpr},
|
|
885
|
+
legacy.created_at,
|
|
886
|
+
legacy.updated_at
|
|
887
|
+
FROM task_assignments_legacy AS legacy
|
|
888
|
+
WHERE legacy.id = (
|
|
889
|
+
SELECT latest.id
|
|
890
|
+
FROM task_assignments_legacy AS latest
|
|
891
|
+
WHERE latest.issue_number = legacy.issue_number
|
|
892
|
+
ORDER BY latest.updated_at DESC, latest.created_at DESC, latest.id DESC
|
|
893
|
+
LIMIT 1
|
|
894
|
+
);
|
|
895
|
+
|
|
896
|
+
DROP TABLE task_assignments_legacy;
|
|
897
|
+
`);
|
|
898
|
+
}
|
|
899
|
+
function createScheduledWakeupsTable(db) {
|
|
900
|
+
db.exec(`
|
|
901
|
+
CREATE TABLE IF NOT EXISTS scheduled_wakeups (
|
|
902
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
903
|
+
agent_id TEXT NOT NULL,
|
|
904
|
+
agent_stable_id TEXT,
|
|
905
|
+
thread_id TEXT NOT NULL,
|
|
906
|
+
body TEXT NOT NULL,
|
|
907
|
+
fire_at TEXT NOT NULL,
|
|
908
|
+
created_at TEXT NOT NULL
|
|
909
|
+
);
|
|
910
|
+
|
|
911
|
+
CREATE INDEX IF NOT EXISTS idx_scheduled_wakeups_fire_target
|
|
912
|
+
ON scheduled_wakeups(fire_at, agent_stable_id, agent_id);
|
|
913
|
+
`);
|
|
914
|
+
}
|
|
915
|
+
function addScheduledWakeupStableIdColumn(db) {
|
|
916
|
+
ensureColumn(db, "scheduled_wakeups", "agent_stable_id", "ALTER TABLE scheduled_wakeups ADD COLUMN agent_stable_id TEXT");
|
|
917
|
+
db.exec(`
|
|
918
|
+
CREATE INDEX IF NOT EXISTS idx_scheduled_wakeups_fire_target
|
|
919
|
+
ON scheduled_wakeups(fire_at, agent_stable_id, agent_id);
|
|
920
|
+
`);
|
|
921
|
+
db.prepare(`UPDATE scheduled_wakeups
|
|
922
|
+
SET agent_stable_id = (
|
|
923
|
+
SELECT stable_id FROM agents WHERE agents.id = scheduled_wakeups.agent_id
|
|
924
|
+
)
|
|
925
|
+
WHERE agent_stable_id IS NULL`).run();
|
|
926
|
+
}
|
|
927
|
+
function createPortLeaseTable(db) {
|
|
928
|
+
db.exec(`
|
|
929
|
+
CREATE TABLE IF NOT EXISTS port_leases (
|
|
930
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
931
|
+
purpose TEXT NOT NULL,
|
|
932
|
+
port INTEGER NOT NULL,
|
|
933
|
+
host TEXT NOT NULL DEFAULT '127.0.0.1',
|
|
934
|
+
owner_agent_id TEXT,
|
|
935
|
+
pid INTEGER,
|
|
936
|
+
status TEXT NOT NULL DEFAULT 'active'
|
|
937
|
+
CHECK(status IN ('active', 'released', 'expired')),
|
|
938
|
+
metadata TEXT,
|
|
939
|
+
acquired_at TEXT NOT NULL,
|
|
940
|
+
renewed_at TEXT NOT NULL,
|
|
941
|
+
expires_at TEXT NOT NULL,
|
|
942
|
+
released_at TEXT
|
|
943
|
+
);
|
|
944
|
+
|
|
945
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_port_leases_active_port
|
|
946
|
+
ON port_leases(host, port)
|
|
947
|
+
WHERE status = 'active';
|
|
948
|
+
CREATE INDEX IF NOT EXISTS idx_port_leases_expiry
|
|
949
|
+
ON port_leases(status, expires_at);
|
|
950
|
+
CREATE INDEX IF NOT EXISTS idx_port_leases_owner
|
|
951
|
+
ON port_leases(owner_agent_id, status, expires_at);
|
|
952
|
+
CREATE INDEX IF NOT EXISTS idx_port_leases_purpose
|
|
953
|
+
ON port_leases(purpose, status, expires_at);
|
|
954
|
+
`);
|
|
955
|
+
}
|
|
956
|
+
function createPinetLaneTables(db) {
|
|
957
|
+
db.exec(`
|
|
958
|
+
CREATE TABLE IF NOT EXISTS pinet_lanes (
|
|
959
|
+
lane_id TEXT PRIMARY KEY NOT NULL,
|
|
960
|
+
name TEXT,
|
|
961
|
+
task TEXT,
|
|
962
|
+
issue_number INTEGER,
|
|
963
|
+
pr_number INTEGER,
|
|
964
|
+
thread_id TEXT,
|
|
965
|
+
owner_agent_id TEXT,
|
|
966
|
+
implementation_lead_agent_id TEXT,
|
|
967
|
+
pm_mode INTEGER NOT NULL DEFAULT 0 CHECK(pm_mode IN (0, 1)),
|
|
968
|
+
state TEXT NOT NULL DEFAULT 'active'
|
|
969
|
+
CHECK(state IN ('planned', 'active', 'blocked', 'review', 'ready', 'done', 'cancelled', 'detached')),
|
|
970
|
+
summary TEXT,
|
|
971
|
+
metadata TEXT,
|
|
972
|
+
created_at TEXT NOT NULL,
|
|
973
|
+
updated_at TEXT NOT NULL,
|
|
974
|
+
last_activity_at TEXT NOT NULL
|
|
975
|
+
);
|
|
976
|
+
|
|
977
|
+
CREATE TABLE IF NOT EXISTS pinet_lane_participants (
|
|
978
|
+
lane_id TEXT NOT NULL,
|
|
979
|
+
agent_id TEXT NOT NULL,
|
|
980
|
+
lane_role TEXT NOT NULL
|
|
981
|
+
CHECK(lane_role IN ('broker', 'coordinator', 'pm', 'lead', 'implementer', 'reviewer', 'second_pass_reviewer', 'observer')),
|
|
982
|
+
status TEXT,
|
|
983
|
+
summary TEXT,
|
|
984
|
+
metadata TEXT,
|
|
985
|
+
created_at TEXT NOT NULL,
|
|
986
|
+
updated_at TEXT NOT NULL,
|
|
987
|
+
last_activity_at TEXT NOT NULL,
|
|
988
|
+
PRIMARY KEY(lane_id, agent_id)
|
|
989
|
+
);
|
|
990
|
+
|
|
991
|
+
CREATE INDEX IF NOT EXISTS idx_pinet_lanes_state_updated
|
|
992
|
+
ON pinet_lanes(state, updated_at DESC);
|
|
993
|
+
CREATE INDEX IF NOT EXISTS idx_pinet_lanes_owner_state
|
|
994
|
+
ON pinet_lanes(owner_agent_id, state, updated_at DESC);
|
|
995
|
+
CREATE INDEX IF NOT EXISTS idx_pinet_lanes_issue
|
|
996
|
+
ON pinet_lanes(issue_number);
|
|
997
|
+
CREATE INDEX IF NOT EXISTS idx_pinet_lane_participants_agent
|
|
998
|
+
ON pinet_lane_participants(agent_id, lane_role, updated_at DESC);
|
|
999
|
+
`);
|
|
1000
|
+
}
|
|
1001
|
+
function runSchemaMigrations(db) {
|
|
1002
|
+
const currentVersion = getUserVersion(db);
|
|
1003
|
+
if (currentVersion >= CURRENT_BROKER_SCHEMA_VERSION) {
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
for (let nextVersion = currentVersion + 1; nextVersion <= CURRENT_BROKER_SCHEMA_VERSION; nextVersion += 1) {
|
|
1007
|
+
db.exec("BEGIN IMMEDIATE");
|
|
1008
|
+
try {
|
|
1009
|
+
switch (nextVersion) {
|
|
1010
|
+
case 1:
|
|
1011
|
+
createCoreTables(db);
|
|
1012
|
+
break;
|
|
1013
|
+
case 2:
|
|
1014
|
+
createBacklogTable(db);
|
|
1015
|
+
break;
|
|
1016
|
+
case 3:
|
|
1017
|
+
addAgentLifecycleColumns(db);
|
|
1018
|
+
break;
|
|
1019
|
+
case 4:
|
|
1020
|
+
addObservabilityColumns(db);
|
|
1021
|
+
break;
|
|
1022
|
+
case 5:
|
|
1023
|
+
addBacklogAffinityColumns(db);
|
|
1024
|
+
break;
|
|
1025
|
+
case 6:
|
|
1026
|
+
createTaskAssignmentTable(db);
|
|
1027
|
+
break;
|
|
1028
|
+
case 7:
|
|
1029
|
+
migrateTaskAssignmentsToIssueOwnership(db);
|
|
1030
|
+
break;
|
|
1031
|
+
case 8:
|
|
1032
|
+
createScheduledWakeupsTable(db);
|
|
1033
|
+
break;
|
|
1034
|
+
case 9:
|
|
1035
|
+
addScheduledWakeupStableIdColumn(db);
|
|
1036
|
+
break;
|
|
1037
|
+
case 10:
|
|
1038
|
+
createSettingsTable(db);
|
|
1039
|
+
break;
|
|
1040
|
+
case 11:
|
|
1041
|
+
addThreadOwnershipBindingColumn(db);
|
|
1042
|
+
break;
|
|
1043
|
+
case 12:
|
|
1044
|
+
addInboxReadCursorColumn(db);
|
|
1045
|
+
break;
|
|
1046
|
+
case 13:
|
|
1047
|
+
addMessageSyncIdentityColumns(db);
|
|
1048
|
+
break;
|
|
1049
|
+
case 14:
|
|
1050
|
+
addThreadMetadataColumn(db);
|
|
1051
|
+
break;
|
|
1052
|
+
case 15:
|
|
1053
|
+
createPinetLaneTables(db);
|
|
1054
|
+
break;
|
|
1055
|
+
case 16:
|
|
1056
|
+
createPortLeaseTable(db);
|
|
1057
|
+
break;
|
|
1058
|
+
case 17:
|
|
1059
|
+
migrateTaskAssignmentsToRepoScopedTracking(db);
|
|
1060
|
+
break;
|
|
1061
|
+
default:
|
|
1062
|
+
throw new Error(`Unsupported broker schema migration target: ${nextVersion}`);
|
|
1063
|
+
}
|
|
1064
|
+
setUserVersion(db, nextVersion);
|
|
1065
|
+
db.exec("COMMIT");
|
|
1066
|
+
}
|
|
1067
|
+
catch (error) {
|
|
1068
|
+
try {
|
|
1069
|
+
db.exec("ROLLBACK");
|
|
1070
|
+
}
|
|
1071
|
+
catch {
|
|
1072
|
+
/* best effort */
|
|
1073
|
+
}
|
|
1074
|
+
throw new Error(`Broker schema migration v${nextVersion} failed`, { cause: error });
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
// ─── BrokerDB ────────────────────────────────────────────
|
|
1079
|
+
export class BrokerDB {
|
|
1080
|
+
db = null;
|
|
1081
|
+
dbPath;
|
|
1082
|
+
allowedUsers = new Set();
|
|
1083
|
+
constructor(dbPath) {
|
|
1084
|
+
this.dbPath = dbPath ?? defaultDbPath();
|
|
1085
|
+
}
|
|
1086
|
+
initialize() {
|
|
1087
|
+
if (this.db)
|
|
1088
|
+
return;
|
|
1089
|
+
const dir = path.dirname(this.dbPath);
|
|
1090
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1091
|
+
try {
|
|
1092
|
+
this.openAndMigrate();
|
|
1093
|
+
}
|
|
1094
|
+
catch (error) {
|
|
1095
|
+
console.error(`[BrokerDB] Failed to open or migrate ${this.dbPath}; recreating from scratch`, error);
|
|
1096
|
+
this.resetDatabaseFiles();
|
|
1097
|
+
try {
|
|
1098
|
+
this.openAndMigrate();
|
|
1099
|
+
}
|
|
1100
|
+
catch (recreateError) {
|
|
1101
|
+
console.error(`[BrokerDB] Failed to recreate ${this.dbPath} from scratch`, recreateError);
|
|
1102
|
+
this.close();
|
|
1103
|
+
throw recreateError;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
// Broker startup reconciliation: any connected rows belong to a previous
|
|
1107
|
+
// broker session, so mark them resumably disconnected and wait for workers
|
|
1108
|
+
// to reconnect by stableId.
|
|
1109
|
+
this.reconcileStartupAgents();
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* Mark all previously connected agents as resumably disconnected on broker
|
|
1113
|
+
* startup. Their inbox/thread ownership stays intact during the lease window
|
|
1114
|
+
* so reconnecting workers can resume by stableId.
|
|
1115
|
+
*/
|
|
1116
|
+
reconcileStartupAgents(resumableForMs = DEFAULT_RESUMABLE_WINDOW_MS) {
|
|
1117
|
+
const db = this.getDb();
|
|
1118
|
+
const missingColumns = this.getMissingRequiredAgentLifecycleColumns(db);
|
|
1119
|
+
if (missingColumns.length > 0) {
|
|
1120
|
+
console.error(`[BrokerDB] Skipping startup reconciliation; agents table is missing columns: ${missingColumns.join(", ")}`);
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
const now = new Date();
|
|
1124
|
+
const disconnectedAt = now.toISOString();
|
|
1125
|
+
const resumableUntil = new Date(now.getTime() + resumableForMs).toISOString();
|
|
1126
|
+
db.prepare(`UPDATE agents
|
|
1127
|
+
SET disconnected_at = ?,
|
|
1128
|
+
resumable_until = COALESCE(resumable_until, ?)
|
|
1129
|
+
WHERE disconnected_at IS NULL`).run(disconnectedAt, resumableUntil);
|
|
1130
|
+
}
|
|
1131
|
+
close() {
|
|
1132
|
+
if (this.db) {
|
|
1133
|
+
this.db.close();
|
|
1134
|
+
this.db = null;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
// ─── Agents ──────────────────────────────────────────
|
|
1138
|
+
registerAgent(id, name, emoji, pid, metadata, stableId) {
|
|
1139
|
+
const db = this.getDb();
|
|
1140
|
+
const now = new Date().toISOString();
|
|
1141
|
+
const existing = stableId ? this.getAgentRowByStableId(stableId) : null;
|
|
1142
|
+
const existingById = this.getAgentRowById(existing?.id ?? id);
|
|
1143
|
+
const existingRow = existingById ?? existing;
|
|
1144
|
+
const agentId = existing?.id ?? id;
|
|
1145
|
+
const finalName = this.ensureUniqueAgentName(name, agentId);
|
|
1146
|
+
const finalEmoji = emoji.trim() || existingRow?.emoji || "";
|
|
1147
|
+
const persistedStableId = stableId ?? existing?.stable_id ?? existingById?.stable_id ?? null;
|
|
1148
|
+
// Reconnecting agents are authoritative for their current runtime identity. If a
|
|
1149
|
+
// stable session comes back with a new name/emoji, refresh the broker roster
|
|
1150
|
+
// instead of replaying stale values from the previous broker DB row.
|
|
1151
|
+
const finalMetadata = metadata ??
|
|
1152
|
+
(existingRow?.metadata
|
|
1153
|
+
? JSON.parse(existingRow.metadata)
|
|
1154
|
+
: undefined);
|
|
1155
|
+
const meta = finalMetadata ? JSON.stringify(finalMetadata) : null;
|
|
1156
|
+
db.prepare(`INSERT INTO agents (
|
|
1157
|
+
id, stable_id, name, emoji, pid,
|
|
1158
|
+
connected_at, last_seen, last_heartbeat,
|
|
1159
|
+
metadata, status, disconnected_at, resumable_until,
|
|
1160
|
+
idle_since, last_activity
|
|
1161
|
+
)
|
|
1162
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'idle', NULL, NULL, ?, NULL)
|
|
1163
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
1164
|
+
stable_id = COALESCE(excluded.stable_id, agents.stable_id),
|
|
1165
|
+
name = excluded.name,
|
|
1166
|
+
emoji = excluded.emoji,
|
|
1167
|
+
pid = excluded.pid,
|
|
1168
|
+
connected_at = excluded.connected_at,
|
|
1169
|
+
last_seen = excluded.last_seen,
|
|
1170
|
+
last_heartbeat = excluded.last_heartbeat,
|
|
1171
|
+
metadata = excluded.metadata,
|
|
1172
|
+
status = 'idle',
|
|
1173
|
+
disconnected_at = NULL,
|
|
1174
|
+
resumable_until = NULL,
|
|
1175
|
+
idle_since = excluded.idle_since,
|
|
1176
|
+
last_activity = NULL`).run(agentId, persistedStableId, finalName, finalEmoji, pid, now, now, now, meta, now);
|
|
1177
|
+
return {
|
|
1178
|
+
id: agentId,
|
|
1179
|
+
name: finalName,
|
|
1180
|
+
emoji: finalEmoji,
|
|
1181
|
+
pid,
|
|
1182
|
+
connectedAt: now,
|
|
1183
|
+
lastSeen: now,
|
|
1184
|
+
lastHeartbeat: now,
|
|
1185
|
+
metadata: finalMetadata ?? null,
|
|
1186
|
+
status: "idle",
|
|
1187
|
+
idleSince: now,
|
|
1188
|
+
lastActivity: null,
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
unregisterAgent(id) {
|
|
1192
|
+
const db = this.getDb();
|
|
1193
|
+
const now = new Date().toISOString();
|
|
1194
|
+
this.withTransaction(() => {
|
|
1195
|
+
this.requeueUndeliveredMessagesInternal(id, "agent_disconnected");
|
|
1196
|
+
db.prepare("DELETE FROM inbox WHERE agent_id = ?").run(id);
|
|
1197
|
+
db.prepare("UPDATE agents SET disconnected_at = ?, resumable_until = NULL WHERE id = ?").run(now, id);
|
|
1198
|
+
db.prepare("UPDATE threads SET owner_agent = NULL WHERE owner_agent = ?").run(id);
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
disconnectAgent(id, resumableForMs = DEFAULT_RESUMABLE_WINDOW_MS) {
|
|
1202
|
+
const db = this.getDb();
|
|
1203
|
+
const now = new Date();
|
|
1204
|
+
const resumableUntil = new Date(now.getTime() + resumableForMs).toISOString();
|
|
1205
|
+
db.prepare("UPDATE agents SET disconnected_at = ?, resumable_until = ? WHERE id = ?").run(now.toISOString(), resumableUntil, id);
|
|
1206
|
+
}
|
|
1207
|
+
getAgentById(id) {
|
|
1208
|
+
const row = this.getAgentRowById(id);
|
|
1209
|
+
return row ? rowToAgent(row) : null;
|
|
1210
|
+
}
|
|
1211
|
+
getCurrentSessionOutboundCount(agentId, connectedAt) {
|
|
1212
|
+
const db = this.getDb();
|
|
1213
|
+
const row = db
|
|
1214
|
+
.prepare(`SELECT COUNT(*) AS count
|
|
1215
|
+
FROM messages
|
|
1216
|
+
WHERE sender = ?
|
|
1217
|
+
AND source = 'agent'
|
|
1218
|
+
AND created_at >= ?`)
|
|
1219
|
+
.get(agentId, connectedAt);
|
|
1220
|
+
return Number(row?.count ?? 0);
|
|
1221
|
+
}
|
|
1222
|
+
rowToAgentWithCurrentSessionOutboundCount(row) {
|
|
1223
|
+
const agent = rowToAgent(row);
|
|
1224
|
+
return {
|
|
1225
|
+
...agent,
|
|
1226
|
+
outboundCount: this.getCurrentSessionOutboundCount(agent.id, agent.connectedAt),
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
getAgents() {
|
|
1230
|
+
const db = this.getDb();
|
|
1231
|
+
const rows = db
|
|
1232
|
+
.prepare("SELECT * FROM agents WHERE disconnected_at IS NULL ORDER BY connected_at ASC")
|
|
1233
|
+
.all();
|
|
1234
|
+
return rows.map((row) => this.rowToAgentWithCurrentSessionOutboundCount(row));
|
|
1235
|
+
}
|
|
1236
|
+
getAllAgents() {
|
|
1237
|
+
const db = this.getDb();
|
|
1238
|
+
const rows = db
|
|
1239
|
+
.prepare(`SELECT * FROM agents
|
|
1240
|
+
ORDER BY CASE WHEN disconnected_at IS NULL THEN 0 ELSE 1 END, connected_at ASC`)
|
|
1241
|
+
.all();
|
|
1242
|
+
return rows.map((row) => this.rowToAgentWithCurrentSessionOutboundCount(row));
|
|
1243
|
+
}
|
|
1244
|
+
getSetting(key) {
|
|
1245
|
+
const db = this.getDb();
|
|
1246
|
+
const row = db.prepare("SELECT value FROM settings WHERE key = ?").get(key);
|
|
1247
|
+
if (!row)
|
|
1248
|
+
return null;
|
|
1249
|
+
return JSON.parse(row.value);
|
|
1250
|
+
}
|
|
1251
|
+
setSetting(key, value) {
|
|
1252
|
+
const db = this.getDb();
|
|
1253
|
+
const now = new Date().toISOString();
|
|
1254
|
+
db.prepare(`INSERT INTO settings (key, value, updated_at)
|
|
1255
|
+
VALUES (?, ?, ?)
|
|
1256
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`).run(key, JSON.stringify(value), now);
|
|
1257
|
+
}
|
|
1258
|
+
deleteSetting(key) {
|
|
1259
|
+
const db = this.getDb();
|
|
1260
|
+
db.prepare("DELETE FROM settings WHERE key = ?").run(key);
|
|
1261
|
+
}
|
|
1262
|
+
// ─── Port leases ─────────────────────────────────────
|
|
1263
|
+
acquirePortLease(input) {
|
|
1264
|
+
const db = this.getDb();
|
|
1265
|
+
const purpose = normalizePortLeasePurpose(input.purpose);
|
|
1266
|
+
const ttlMs = normalizePortLeaseTtlMs(input.ttlMs);
|
|
1267
|
+
const host = normalizePortLeaseHost(input.host);
|
|
1268
|
+
const requestedPort = input.port === undefined ? undefined : normalizePortLeasePort(input.port);
|
|
1269
|
+
const explicitRange = input.minPort !== undefined || input.maxPort !== undefined;
|
|
1270
|
+
const { minPort, maxPort } = explicitRange
|
|
1271
|
+
? normalizePortLeaseRange(input.minPort, input.maxPort)
|
|
1272
|
+
: requestedPort === undefined
|
|
1273
|
+
? normalizePortLeaseRange(input.minPort, input.maxPort)
|
|
1274
|
+
: { minPort: requestedPort, maxPort: requestedPort };
|
|
1275
|
+
const ownerAgentId = normalizeOptionalPortLeaseOwner(input.ownerAgentId) ?? null;
|
|
1276
|
+
const pid = normalizePortLeasePid(input.pid);
|
|
1277
|
+
const metadata = serializeOptionalMetadata(input.metadata) ?? null;
|
|
1278
|
+
if (requestedPort !== undefined && (requestedPort < minPort || requestedPort > maxPort)) {
|
|
1279
|
+
throw new Error("port must be within minPort and maxPort");
|
|
1280
|
+
}
|
|
1281
|
+
return this.withTransaction(() => {
|
|
1282
|
+
const now = new Date();
|
|
1283
|
+
const nowIso = now.toISOString();
|
|
1284
|
+
this.expirePortLeasesInternal(nowIso);
|
|
1285
|
+
const port = requestedPort ?? this.findAvailablePortLeasePort(host, minPort, maxPort);
|
|
1286
|
+
if (port === null) {
|
|
1287
|
+
throw new Error(`No available port lease in range ${minPort}-${maxPort} for ${host}`);
|
|
1288
|
+
}
|
|
1289
|
+
const leaseId = crypto.randomUUID();
|
|
1290
|
+
const expiresAt = new Date(now.getTime() + ttlMs).toISOString();
|
|
1291
|
+
try {
|
|
1292
|
+
db.prepare(`INSERT INTO port_leases (
|
|
1293
|
+
id, purpose, port, host, owner_agent_id, pid, status, metadata,
|
|
1294
|
+
acquired_at, renewed_at, expires_at, released_at
|
|
1295
|
+
) VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, NULL)`).run(leaseId, purpose, port, host, ownerAgentId, pid, metadata, nowIso, nowIso, expiresAt);
|
|
1296
|
+
}
|
|
1297
|
+
catch (error) {
|
|
1298
|
+
if (requestedPort !== undefined) {
|
|
1299
|
+
throw new Error(`Port ${host}:${requestedPort} already has an active lease`, {
|
|
1300
|
+
cause: error,
|
|
1301
|
+
});
|
|
1302
|
+
}
|
|
1303
|
+
throw error;
|
|
1304
|
+
}
|
|
1305
|
+
const lease = this.getPortLeaseRowById(leaseId);
|
|
1306
|
+
if (!lease) {
|
|
1307
|
+
throw new Error(`Failed to create port lease ${leaseId}`);
|
|
1308
|
+
}
|
|
1309
|
+
return rowToPortLease(lease);
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
renewPortLease(input) {
|
|
1313
|
+
const db = this.getDb();
|
|
1314
|
+
const leaseId = normalizePortLeaseId(input.leaseId);
|
|
1315
|
+
const ttlMs = normalizePortLeaseTtlMs(input.ttlMs);
|
|
1316
|
+
const ownerAgentId = normalizeOptionalPortLeaseOwner(input.ownerAgentId);
|
|
1317
|
+
return this.withTransaction(() => {
|
|
1318
|
+
const now = new Date();
|
|
1319
|
+
const nowIso = now.toISOString();
|
|
1320
|
+
this.expirePortLeasesInternal(nowIso);
|
|
1321
|
+
const expiresAt = new Date(now.getTime() + ttlMs).toISOString();
|
|
1322
|
+
const result = ownerAgentId === undefined
|
|
1323
|
+
? db
|
|
1324
|
+
.prepare(`UPDATE port_leases
|
|
1325
|
+
SET renewed_at = ?, expires_at = ?
|
|
1326
|
+
WHERE id = ? AND status = 'active'`)
|
|
1327
|
+
.run(nowIso, expiresAt, leaseId)
|
|
1328
|
+
: db
|
|
1329
|
+
.prepare(`UPDATE port_leases
|
|
1330
|
+
SET renewed_at = ?, expires_at = ?
|
|
1331
|
+
WHERE id = ? AND status = 'active' AND owner_agent_id IS ?`)
|
|
1332
|
+
.run(nowIso, expiresAt, leaseId, ownerAgentId);
|
|
1333
|
+
if (Number(result.changes ?? 0) === 0) {
|
|
1334
|
+
throw new Error("No active port lease matched leaseId/ownerAgentId");
|
|
1335
|
+
}
|
|
1336
|
+
const lease = this.getPortLeaseRowById(leaseId);
|
|
1337
|
+
if (!lease) {
|
|
1338
|
+
throw new Error(`Port lease ${leaseId} disappeared after renew`);
|
|
1339
|
+
}
|
|
1340
|
+
return rowToPortLease(lease);
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
releasePortLease(input) {
|
|
1344
|
+
const db = this.getDb();
|
|
1345
|
+
const leaseId = normalizePortLeaseId(input.leaseId);
|
|
1346
|
+
const ownerAgentId = normalizeOptionalPortLeaseOwner(input.ownerAgentId);
|
|
1347
|
+
return this.withTransaction(() => {
|
|
1348
|
+
const nowIso = new Date().toISOString();
|
|
1349
|
+
this.expirePortLeasesInternal(nowIso);
|
|
1350
|
+
const result = ownerAgentId === undefined
|
|
1351
|
+
? db
|
|
1352
|
+
.prepare(`UPDATE port_leases
|
|
1353
|
+
SET status = 'released', released_at = ?
|
|
1354
|
+
WHERE id = ? AND status = 'active'`)
|
|
1355
|
+
.run(nowIso, leaseId)
|
|
1356
|
+
: db
|
|
1357
|
+
.prepare(`UPDATE port_leases
|
|
1358
|
+
SET status = 'released', released_at = ?
|
|
1359
|
+
WHERE id = ? AND status = 'active' AND owner_agent_id IS ?`)
|
|
1360
|
+
.run(nowIso, leaseId, ownerAgentId);
|
|
1361
|
+
if (Number(result.changes ?? 0) === 0) {
|
|
1362
|
+
throw new Error("No active port lease matched leaseId/ownerAgentId");
|
|
1363
|
+
}
|
|
1364
|
+
const lease = this.getPortLeaseRowById(leaseId);
|
|
1365
|
+
if (!lease) {
|
|
1366
|
+
throw new Error(`Port lease ${leaseId} disappeared after release`);
|
|
1367
|
+
}
|
|
1368
|
+
return rowToPortLease(lease);
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
getPortLease(leaseId) {
|
|
1372
|
+
const id = normalizePortLeaseId(leaseId);
|
|
1373
|
+
this.expirePortLeases();
|
|
1374
|
+
const row = this.getPortLeaseRowById(id);
|
|
1375
|
+
return row ? rowToPortLease(row) : null;
|
|
1376
|
+
}
|
|
1377
|
+
listPortLeases(options = {}) {
|
|
1378
|
+
const db = this.getDb();
|
|
1379
|
+
this.expirePortLeases();
|
|
1380
|
+
const clauses = [];
|
|
1381
|
+
const values = [];
|
|
1382
|
+
if (options.expiredOnly) {
|
|
1383
|
+
clauses.push("status = 'expired'");
|
|
1384
|
+
}
|
|
1385
|
+
else if (!options.includeInactive) {
|
|
1386
|
+
clauses.push("status = 'active'");
|
|
1387
|
+
}
|
|
1388
|
+
if (options.ownerAgentId !== undefined) {
|
|
1389
|
+
clauses.push("owner_agent_id IS ?");
|
|
1390
|
+
values.push(normalizeOptionalPortLeaseOwner(options.ownerAgentId) ?? null);
|
|
1391
|
+
}
|
|
1392
|
+
if (options.purpose !== undefined) {
|
|
1393
|
+
clauses.push("purpose = ?");
|
|
1394
|
+
values.push(normalizePortLeasePurpose(options.purpose));
|
|
1395
|
+
}
|
|
1396
|
+
if (options.host !== undefined) {
|
|
1397
|
+
clauses.push("host = ?");
|
|
1398
|
+
values.push(normalizePortLeaseHost(options.host));
|
|
1399
|
+
}
|
|
1400
|
+
const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
1401
|
+
const rows = db
|
|
1402
|
+
.prepare(`SELECT * FROM port_leases
|
|
1403
|
+
${where}
|
|
1404
|
+
ORDER BY CASE status WHEN 'active' THEN 0 WHEN 'expired' THEN 1 ELSE 2 END,
|
|
1405
|
+
expires_at ASC,
|
|
1406
|
+
acquired_at ASC`)
|
|
1407
|
+
.all(...values);
|
|
1408
|
+
return rows.map(rowToPortLease);
|
|
1409
|
+
}
|
|
1410
|
+
expirePortLeases(nowIso = new Date().toISOString()) {
|
|
1411
|
+
return this.withTransaction(() => this.expirePortLeasesInternal(nowIso));
|
|
1412
|
+
}
|
|
1413
|
+
expirePortLeasesInternal(nowIso) {
|
|
1414
|
+
const db = this.getDb();
|
|
1415
|
+
if (Number.isNaN(Date.parse(nowIso))) {
|
|
1416
|
+
throw new Error("nowIso must be a valid ISO timestamp");
|
|
1417
|
+
}
|
|
1418
|
+
const expiredRows = db
|
|
1419
|
+
.prepare(`SELECT * FROM port_leases
|
|
1420
|
+
WHERE status = 'active' AND expires_at <= ?
|
|
1421
|
+
ORDER BY expires_at ASC, acquired_at ASC`)
|
|
1422
|
+
.all(nowIso);
|
|
1423
|
+
if (expiredRows.length === 0) {
|
|
1424
|
+
return [];
|
|
1425
|
+
}
|
|
1426
|
+
const expire = db.prepare(`UPDATE port_leases
|
|
1427
|
+
SET status = 'expired', released_at = COALESCE(released_at, ?)
|
|
1428
|
+
WHERE id = ? AND status = 'active'`);
|
|
1429
|
+
for (const row of expiredRows) {
|
|
1430
|
+
expire.run(nowIso, row.id);
|
|
1431
|
+
}
|
|
1432
|
+
const placeholders = expiredRows.map(() => "?").join(", ");
|
|
1433
|
+
const rows = db
|
|
1434
|
+
.prepare(`SELECT * FROM port_leases WHERE id IN (${placeholders}) ORDER BY expires_at ASC`)
|
|
1435
|
+
.all(...expiredRows.map((row) => row.id));
|
|
1436
|
+
return rows.map(rowToPortLease);
|
|
1437
|
+
}
|
|
1438
|
+
findAvailablePortLeasePort(host, minPort, maxPort) {
|
|
1439
|
+
const db = this.getDb();
|
|
1440
|
+
const rows = db
|
|
1441
|
+
.prepare(`SELECT port FROM port_leases
|
|
1442
|
+
WHERE host = ? AND status = 'active' AND port BETWEEN ? AND ?
|
|
1443
|
+
ORDER BY port ASC`)
|
|
1444
|
+
.all(host, minPort, maxPort);
|
|
1445
|
+
const activePorts = new Set(rows.map((row) => row.port));
|
|
1446
|
+
for (let port = minPort; port <= maxPort; port += 1) {
|
|
1447
|
+
if (!activePorts.has(port)) {
|
|
1448
|
+
return port;
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
return null;
|
|
1452
|
+
}
|
|
1453
|
+
getPortLeaseRowById(leaseId) {
|
|
1454
|
+
const db = this.getDb();
|
|
1455
|
+
const row = db.prepare("SELECT * FROM port_leases WHERE id = ?").get(leaseId);
|
|
1456
|
+
return row ?? null;
|
|
1457
|
+
}
|
|
1458
|
+
touchAgent(id) {
|
|
1459
|
+
const db = this.getDb();
|
|
1460
|
+
db.prepare("UPDATE agents SET last_seen = ? WHERE id = ?").run(new Date().toISOString(), id);
|
|
1461
|
+
}
|
|
1462
|
+
heartbeatAgent(id) {
|
|
1463
|
+
const db = this.getDb();
|
|
1464
|
+
db.prepare("UPDATE agents SET last_heartbeat = ?, disconnected_at = NULL, resumable_until = NULL WHERE id = ?").run(new Date().toISOString(), id);
|
|
1465
|
+
}
|
|
1466
|
+
pruneStaleAgents(staleAfterMs) {
|
|
1467
|
+
const db = this.getDb();
|
|
1468
|
+
const cutoff = new Date(Date.now() - staleAfterMs).toISOString();
|
|
1469
|
+
const now = new Date().toISOString();
|
|
1470
|
+
return this.withTransaction(() => {
|
|
1471
|
+
const staleRows = db
|
|
1472
|
+
.prepare(`SELECT id FROM agents
|
|
1473
|
+
WHERE (disconnected_at IS NULL AND last_heartbeat <= ?)
|
|
1474
|
+
OR (disconnected_at IS NOT NULL AND resumable_until IS NOT NULL AND resumable_until <= ?)`)
|
|
1475
|
+
.all(cutoff, now);
|
|
1476
|
+
if (staleRows.length === 0) {
|
|
1477
|
+
return [];
|
|
1478
|
+
}
|
|
1479
|
+
const disconnectAgent = db.prepare("UPDATE agents SET disconnected_at = COALESCE(disconnected_at, ?), resumable_until = NULL WHERE id = ?");
|
|
1480
|
+
const releaseClaims = db.prepare("UPDATE threads SET owner_agent = NULL WHERE owner_agent = ?");
|
|
1481
|
+
for (const row of staleRows) {
|
|
1482
|
+
this.requeueUndeliveredMessagesInternal(row.id, "agent_disconnected");
|
|
1483
|
+
disconnectAgent.run(now, row.id);
|
|
1484
|
+
releaseClaims.run(row.id);
|
|
1485
|
+
}
|
|
1486
|
+
return staleRows.map((row) => row.id);
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1489
|
+
purgeDisconnectedAgents(graceMs = DEFAULT_DISCONNECTED_PURGE_GRACE_MS) {
|
|
1490
|
+
const db = this.getDb();
|
|
1491
|
+
const now = Date.now();
|
|
1492
|
+
const nowIso = new Date(now).toISOString();
|
|
1493
|
+
const cutoff = new Date(now - graceMs).toISOString();
|
|
1494
|
+
return this.withTransaction(() => {
|
|
1495
|
+
const rows = db
|
|
1496
|
+
.prepare(`SELECT id FROM agents
|
|
1497
|
+
WHERE disconnected_at IS NOT NULL
|
|
1498
|
+
AND disconnected_at <= ?
|
|
1499
|
+
AND (resumable_until IS NULL OR resumable_until <= ?)`)
|
|
1500
|
+
.all(cutoff, nowIso);
|
|
1501
|
+
if (rows.length === 0) {
|
|
1502
|
+
return [];
|
|
1503
|
+
}
|
|
1504
|
+
const releaseThreads = db.prepare("UPDATE threads SET owner_agent = NULL WHERE owner_agent = ?");
|
|
1505
|
+
const deleteInbox = db.prepare("DELETE FROM inbox WHERE agent_id = ?");
|
|
1506
|
+
for (const row of rows) {
|
|
1507
|
+
// Requeue undelivered messages to the backlog
|
|
1508
|
+
this.requeueUndeliveredMessagesInternal(row.id, "agent_disconnected");
|
|
1509
|
+
// Release thread ownership for the purged agent
|
|
1510
|
+
releaseThreads.run(row.id);
|
|
1511
|
+
// Clean up all inbox entries (both delivered and undelivered) for the agent
|
|
1512
|
+
deleteInbox.run(row.id);
|
|
1513
|
+
}
|
|
1514
|
+
db.prepare(`DELETE FROM agents
|
|
1515
|
+
WHERE disconnected_at IS NOT NULL
|
|
1516
|
+
AND disconnected_at <= ?
|
|
1517
|
+
AND (resumable_until IS NULL OR resumable_until <= ?)`).run(cutoff, nowIso);
|
|
1518
|
+
return rows.map((row) => row.id);
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
updateAgentStatus(id, status) {
|
|
1522
|
+
const db = this.getDb();
|
|
1523
|
+
const now = new Date().toISOString();
|
|
1524
|
+
if (status === "idle") {
|
|
1525
|
+
// Transitioning to idle: set idle_since, preserve last_activity
|
|
1526
|
+
db.prepare(`UPDATE agents
|
|
1527
|
+
SET status = ?, last_seen = ?,
|
|
1528
|
+
idle_since = COALESCE(CASE WHEN status = 'idle' THEN idle_since ELSE NULL END, ?)
|
|
1529
|
+
WHERE id = ?`).run(status, now, now, id);
|
|
1530
|
+
}
|
|
1531
|
+
else {
|
|
1532
|
+
// Transitioning to working: clear idle_since, update last_activity
|
|
1533
|
+
db.prepare("UPDATE agents SET status = ?, last_seen = ?, idle_since = NULL, last_activity = ? WHERE id = ?").run(status, now, now, id);
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
updateAgentMetadata(id, metadata) {
|
|
1537
|
+
const db = this.getDb();
|
|
1538
|
+
if (!this.getAgentRowById(id))
|
|
1539
|
+
return null;
|
|
1540
|
+
db.prepare("UPDATE agents SET metadata = ?, last_seen = ? WHERE id = ?").run(metadata ? JSON.stringify(metadata) : null, new Date().toISOString(), id);
|
|
1541
|
+
const updated = this.getAgentRowById(id);
|
|
1542
|
+
return updated ? rowToAgent(updated) : null;
|
|
1543
|
+
}
|
|
1544
|
+
updateAgentIdentity(id, identity) {
|
|
1545
|
+
const db = this.getDb();
|
|
1546
|
+
const existing = this.getAgentRowById(id);
|
|
1547
|
+
if (!existing)
|
|
1548
|
+
return null;
|
|
1549
|
+
const finalName = this.ensureUniqueAgentName(identity.name, id);
|
|
1550
|
+
const finalEmoji = identity.emoji.trim() || existing.emoji;
|
|
1551
|
+
const metadata = identity.metadata ?? (existing.metadata ? JSON.parse(existing.metadata) : null);
|
|
1552
|
+
const metadataJson = metadata ? JSON.stringify(metadata) : null;
|
|
1553
|
+
db.prepare(`UPDATE agents
|
|
1554
|
+
SET name = ?, emoji = ?, metadata = ?, last_seen = ?
|
|
1555
|
+
WHERE id = ?`).run(finalName, finalEmoji, metadataJson, new Date().toISOString(), id);
|
|
1556
|
+
const updated = this.getAgentRowById(id);
|
|
1557
|
+
return updated ? rowToAgent(updated) : null;
|
|
1558
|
+
}
|
|
1559
|
+
touchAgentActivity(id) {
|
|
1560
|
+
const db = this.getDb();
|
|
1561
|
+
db.prepare("UPDATE agents SET last_activity = ?, last_seen = ? WHERE id = ?").run(new Date().toISOString(), new Date().toISOString(), id);
|
|
1562
|
+
}
|
|
1563
|
+
ensureUniqueAgentName(name, agentId) {
|
|
1564
|
+
const db = this.getDb();
|
|
1565
|
+
const baseName = name.trim() || "Agent";
|
|
1566
|
+
let candidate = baseName;
|
|
1567
|
+
let suffix = 2;
|
|
1568
|
+
while (true) {
|
|
1569
|
+
const row = db
|
|
1570
|
+
.prepare("SELECT id FROM agents WHERE lower(name) = lower(?) AND id != ? LIMIT 1")
|
|
1571
|
+
.get(candidate, agentId);
|
|
1572
|
+
if (!row) {
|
|
1573
|
+
return candidate;
|
|
1574
|
+
}
|
|
1575
|
+
candidate = `${baseName} ${suffix}`;
|
|
1576
|
+
suffix += 1;
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
getAgentRowById(id) {
|
|
1580
|
+
const db = this.getDb();
|
|
1581
|
+
const row = db.prepare("SELECT * FROM agents WHERE id = ?").get(id);
|
|
1582
|
+
return row ?? null;
|
|
1583
|
+
}
|
|
1584
|
+
getAgentByStableId(stableId) {
|
|
1585
|
+
const row = this.getAgentRowByStableId(stableId);
|
|
1586
|
+
return row ? rowToAgent(row) : null;
|
|
1587
|
+
}
|
|
1588
|
+
findAgentNameConflict(name, id, stableId) {
|
|
1589
|
+
const db = this.getDb();
|
|
1590
|
+
const existing = stableId ? this.getAgentRowByStableId(stableId) : null;
|
|
1591
|
+
const existingById = this.getAgentRowById(existing?.id ?? id);
|
|
1592
|
+
const agentId = existing?.id ?? existingById?.id ?? id;
|
|
1593
|
+
const normalizedName = name.trim();
|
|
1594
|
+
if (!normalizedName) {
|
|
1595
|
+
return null;
|
|
1596
|
+
}
|
|
1597
|
+
const row = db
|
|
1598
|
+
.prepare(`SELECT id, stable_id, name
|
|
1599
|
+
FROM agents
|
|
1600
|
+
WHERE lower(name) = lower(?) AND id != ?
|
|
1601
|
+
LIMIT 1`)
|
|
1602
|
+
.get(normalizedName, agentId);
|
|
1603
|
+
if (!row) {
|
|
1604
|
+
return null;
|
|
1605
|
+
}
|
|
1606
|
+
return {
|
|
1607
|
+
id: row.id,
|
|
1608
|
+
stableId: row.stable_id,
|
|
1609
|
+
name: row.name,
|
|
1610
|
+
};
|
|
1611
|
+
}
|
|
1612
|
+
getAgentRowByStableId(stableId) {
|
|
1613
|
+
const db = this.getDb();
|
|
1614
|
+
const row = db.prepare("SELECT * FROM agents WHERE stable_id = ?").get(stableId);
|
|
1615
|
+
return row ?? null;
|
|
1616
|
+
}
|
|
1617
|
+
createThread(threadOrId, source, channel, ownerAgent) {
|
|
1618
|
+
const db = this.getDb();
|
|
1619
|
+
const now = new Date().toISOString();
|
|
1620
|
+
const tId = typeof threadOrId === "string" ? threadOrId : threadOrId.threadId;
|
|
1621
|
+
const src = typeof threadOrId === "string" ? source : threadOrId.source;
|
|
1622
|
+
const ch = typeof threadOrId === "string" ? channel : threadOrId.channel;
|
|
1623
|
+
const owner = typeof threadOrId === "string" ? (ownerAgent ?? null) : threadOrId.ownerAgent;
|
|
1624
|
+
const ownerBinding = typeof threadOrId === "string" ? null : (threadOrId.ownerBinding ?? null);
|
|
1625
|
+
const metadata = typeof threadOrId === "string" ? null : (threadOrId.metadata ?? null);
|
|
1626
|
+
const serializedMetadata = metadata ? JSON.stringify(metadata) : null;
|
|
1627
|
+
db.prepare(`INSERT INTO threads (thread_id, source, channel, owner_agent, owner_binding, metadata, created_at, updated_at)
|
|
1628
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1629
|
+
ON CONFLICT(thread_id) DO UPDATE SET updated_at = excluded.updated_at`).run(tId, src, ch, owner, ownerBinding, serializedMetadata, now, now);
|
|
1630
|
+
return {
|
|
1631
|
+
threadId: tId,
|
|
1632
|
+
source: src,
|
|
1633
|
+
channel: ch,
|
|
1634
|
+
ownerAgent: owner,
|
|
1635
|
+
ownerBinding,
|
|
1636
|
+
metadata,
|
|
1637
|
+
createdAt: now,
|
|
1638
|
+
updatedAt: now,
|
|
1639
|
+
};
|
|
1640
|
+
}
|
|
1641
|
+
updateThread(threadId, updates) {
|
|
1642
|
+
const db = this.getDb();
|
|
1643
|
+
const now = new Date().toISOString();
|
|
1644
|
+
// Upsert: create the thread if it doesn't exist yet
|
|
1645
|
+
const existing = this.getThread(threadId);
|
|
1646
|
+
if (!existing) {
|
|
1647
|
+
db.prepare(`INSERT INTO threads (thread_id, source, channel, owner_agent, owner_binding, metadata, created_at, updated_at)
|
|
1648
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(threadId, updates.source ?? DEFAULT_EXTERNAL_THREAD_SOURCE, updates.channel ?? "", updates.ownerAgent !== undefined ? updates.ownerAgent : null, updates.ownerBinding !== undefined ? updates.ownerBinding : null, updates.metadata !== undefined && updates.metadata !== null
|
|
1649
|
+
? JSON.stringify(updates.metadata)
|
|
1650
|
+
: null, now, now);
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
const sets = [];
|
|
1654
|
+
const values = [];
|
|
1655
|
+
if (updates.ownerAgent !== undefined) {
|
|
1656
|
+
sets.push("owner_agent = ?");
|
|
1657
|
+
values.push(updates.ownerAgent);
|
|
1658
|
+
}
|
|
1659
|
+
if (updates.channel !== undefined) {
|
|
1660
|
+
sets.push("channel = ?");
|
|
1661
|
+
values.push(updates.channel);
|
|
1662
|
+
}
|
|
1663
|
+
if (updates.source !== undefined) {
|
|
1664
|
+
sets.push("source = ?");
|
|
1665
|
+
values.push(updates.source);
|
|
1666
|
+
}
|
|
1667
|
+
if (updates.ownerBinding !== undefined) {
|
|
1668
|
+
sets.push("owner_binding = ?");
|
|
1669
|
+
values.push(updates.ownerBinding);
|
|
1670
|
+
}
|
|
1671
|
+
if (updates.metadata !== undefined) {
|
|
1672
|
+
sets.push("metadata = ?");
|
|
1673
|
+
values.push(updates.metadata === null ? null : JSON.stringify(updates.metadata));
|
|
1674
|
+
}
|
|
1675
|
+
sets.push("updated_at = ?");
|
|
1676
|
+
values.push(now);
|
|
1677
|
+
values.push(threadId);
|
|
1678
|
+
db.prepare(`UPDATE threads SET ${sets.join(", ")} WHERE thread_id = ?`).run(...values);
|
|
1679
|
+
}
|
|
1680
|
+
transferThreadOwnership(threadId, ownerAgent) {
|
|
1681
|
+
const db = this.getDb();
|
|
1682
|
+
const now = new Date().toISOString();
|
|
1683
|
+
const thread = this.getThread(threadId);
|
|
1684
|
+
if (!thread) {
|
|
1685
|
+
throw new Error(`Unknown thread ${threadId}`);
|
|
1686
|
+
}
|
|
1687
|
+
db.exec("BEGIN IMMEDIATE");
|
|
1688
|
+
try {
|
|
1689
|
+
db.prepare(`UPDATE threads
|
|
1690
|
+
SET owner_agent = ?, owner_binding = 'explicit', updated_at = ?
|
|
1691
|
+
WHERE thread_id = ?`).run(ownerAgent, now, threadId);
|
|
1692
|
+
const rows = db
|
|
1693
|
+
.prepare(`SELECT i.id AS inbox_id,
|
|
1694
|
+
i.agent_id AS agent_id,
|
|
1695
|
+
i.message_id AS message_id,
|
|
1696
|
+
m.metadata AS metadata
|
|
1697
|
+
FROM inbox i
|
|
1698
|
+
JOIN messages m ON m.id = i.message_id
|
|
1699
|
+
WHERE m.thread_id = ?
|
|
1700
|
+
AND m.source <> 'agent'
|
|
1701
|
+
AND m.direction = 'inbound'
|
|
1702
|
+
AND i.read_at IS NULL`)
|
|
1703
|
+
.all(threadId);
|
|
1704
|
+
let reassignedInboxCount = 0;
|
|
1705
|
+
const updatedMessageIds = new Set();
|
|
1706
|
+
const updateMessageMetadata = db.prepare("UPDATE messages SET metadata = ? WHERE id = ?");
|
|
1707
|
+
const findExistingInbox = db.prepare("SELECT id FROM inbox WHERE agent_id = ? AND message_id = ?");
|
|
1708
|
+
const reassignInbox = db.prepare("UPDATE inbox SET agent_id = ?, delivered = 0 WHERE id = ? AND agent_id = ?");
|
|
1709
|
+
const markDuplicateRead = db.prepare("UPDATE inbox SET delivered = 1, read_at = COALESCE(read_at, ?) WHERE id = ?");
|
|
1710
|
+
for (const row of rows) {
|
|
1711
|
+
if (!updatedMessageIds.has(row.message_id)) {
|
|
1712
|
+
const metadata = row.metadata ? parseJsonMetadata(row.metadata) : {};
|
|
1713
|
+
metadata.threadAffinityOwnerAgentId = ownerAgent;
|
|
1714
|
+
updateMessageMetadata.run(JSON.stringify(metadata), row.message_id);
|
|
1715
|
+
updatedMessageIds.add(row.message_id);
|
|
1716
|
+
}
|
|
1717
|
+
if (row.agent_id === ownerAgent) {
|
|
1718
|
+
continue;
|
|
1719
|
+
}
|
|
1720
|
+
const existing = findExistingInbox.get(ownerAgent, row.message_id);
|
|
1721
|
+
if (existing) {
|
|
1722
|
+
markDuplicateRead.run(now, row.inbox_id);
|
|
1723
|
+
continue;
|
|
1724
|
+
}
|
|
1725
|
+
const result = reassignInbox.run(ownerAgent, row.inbox_id, row.agent_id);
|
|
1726
|
+
reassignedInboxCount += Number(result.changes ?? 0);
|
|
1727
|
+
}
|
|
1728
|
+
db.exec("COMMIT");
|
|
1729
|
+
return { reassignedInboxCount, updatedMessageCount: updatedMessageIds.size };
|
|
1730
|
+
}
|
|
1731
|
+
catch (err) {
|
|
1732
|
+
db.exec("ROLLBACK");
|
|
1733
|
+
throw err;
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
claimThread(threadId, agentId, source = DEFAULT_EXTERNAL_THREAD_SOURCE, channel = "") {
|
|
1737
|
+
const db = this.getDb();
|
|
1738
|
+
const now = new Date().toISOString();
|
|
1739
|
+
// Atomic claim: insert the thread if new, or update the owner only
|
|
1740
|
+
// if the thread is currently unclaimed or already owned by this agent.
|
|
1741
|
+
// A single statement avoids the TOCTOU race of read-then-write. (#125)
|
|
1742
|
+
db.prepare(`INSERT INTO threads (thread_id, source, channel, owner_agent, owner_binding, metadata, created_at, updated_at)
|
|
1743
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1744
|
+
ON CONFLICT(thread_id) DO UPDATE SET
|
|
1745
|
+
owner_agent = excluded.owner_agent,
|
|
1746
|
+
updated_at = excluded.updated_at
|
|
1747
|
+
WHERE threads.owner_agent IS NULL OR threads.owner_agent = excluded.owner_agent`).run(threadId, source, channel, agentId, null, null, now, now);
|
|
1748
|
+
// Verify: read back the owner. If the WHERE clause above didn't
|
|
1749
|
+
// match (another agent owns the thread), the row was not updated
|
|
1750
|
+
// and the owner will differ from agentId.
|
|
1751
|
+
const thread = this.getThread(threadId);
|
|
1752
|
+
return thread?.ownerAgent === agentId;
|
|
1753
|
+
}
|
|
1754
|
+
setAllowedUsers(users) {
|
|
1755
|
+
this.allowedUsers = users === null ? null : new Set(users);
|
|
1756
|
+
}
|
|
1757
|
+
getAllowedUsers() {
|
|
1758
|
+
return this.allowedUsers === null ? null : new Set(this.allowedUsers);
|
|
1759
|
+
}
|
|
1760
|
+
getChannelAssignment(_channel) {
|
|
1761
|
+
return null;
|
|
1762
|
+
}
|
|
1763
|
+
getThread(threadId) {
|
|
1764
|
+
const db = this.getDb();
|
|
1765
|
+
const row = db.prepare("SELECT * FROM threads WHERE thread_id = ?").get(threadId);
|
|
1766
|
+
return row ? rowToThread(row) : null;
|
|
1767
|
+
}
|
|
1768
|
+
getThreads(ownerAgent) {
|
|
1769
|
+
const db = this.getDb();
|
|
1770
|
+
if (ownerAgent) {
|
|
1771
|
+
const rows = db
|
|
1772
|
+
.prepare("SELECT * FROM threads WHERE owner_agent = ? ORDER BY updated_at DESC")
|
|
1773
|
+
.all(ownerAgent);
|
|
1774
|
+
return rows.map(rowToThread);
|
|
1775
|
+
}
|
|
1776
|
+
const rows = db
|
|
1777
|
+
.prepare("SELECT * FROM threads ORDER BY updated_at DESC")
|
|
1778
|
+
.all();
|
|
1779
|
+
return rows.map(rowToThread);
|
|
1780
|
+
}
|
|
1781
|
+
getPendingBacklog(limit = 50) {
|
|
1782
|
+
const db = this.getDb();
|
|
1783
|
+
const rows = db
|
|
1784
|
+
.prepare(`SELECT * FROM unrouted_backlog
|
|
1785
|
+
WHERE status = 'pending'
|
|
1786
|
+
ORDER BY created_at ASC
|
|
1787
|
+
LIMIT ?`)
|
|
1788
|
+
.all(limit);
|
|
1789
|
+
return rows.map(rowToBacklog);
|
|
1790
|
+
}
|
|
1791
|
+
getBacklogCount(status = "pending") {
|
|
1792
|
+
const db = this.getDb();
|
|
1793
|
+
const row = db
|
|
1794
|
+
.prepare("SELECT COUNT(*) AS count FROM unrouted_backlog WHERE status = ?")
|
|
1795
|
+
.get(status);
|
|
1796
|
+
return row.count;
|
|
1797
|
+
}
|
|
1798
|
+
queueUnroutedMessage(message, reason = "no_route") {
|
|
1799
|
+
const metadata = this.withInboundMailClassMetadata(message, {
|
|
1800
|
+
...message.metadata,
|
|
1801
|
+
channel: message.channel,
|
|
1802
|
+
userName: message.userName,
|
|
1803
|
+
userId: message.userId,
|
|
1804
|
+
timestamp: message.timestamp,
|
|
1805
|
+
...(message.isChannelMention ? { isChannelMention: true } : {}),
|
|
1806
|
+
});
|
|
1807
|
+
const existingThread = this.getThread(message.threadId);
|
|
1808
|
+
if (!existingThread) {
|
|
1809
|
+
this.createThread(message.threadId, message.source, message.channel, null);
|
|
1810
|
+
}
|
|
1811
|
+
const brokerMessage = this.insertMessage(message.threadId, message.source, "inbound", message.userId, message.text, [], metadata);
|
|
1812
|
+
const existingBacklog = this.getBacklogByMessageId(brokerMessage.id);
|
|
1813
|
+
if (existingBacklog) {
|
|
1814
|
+
return existingBacklog;
|
|
1815
|
+
}
|
|
1816
|
+
if (existingThread) {
|
|
1817
|
+
this.updateThread(message.threadId, {
|
|
1818
|
+
channel: message.channel,
|
|
1819
|
+
source: message.source,
|
|
1820
|
+
ownerAgent: null,
|
|
1821
|
+
});
|
|
1822
|
+
}
|
|
1823
|
+
return this.upsertBacklogEntry(brokerMessage.id, message.threadId, message.channel, reason, "pending", null, null);
|
|
1824
|
+
}
|
|
1825
|
+
assignBacklogEntry(id, agentId) {
|
|
1826
|
+
const db = this.getDb();
|
|
1827
|
+
return this.withTransaction(() => {
|
|
1828
|
+
const row = db
|
|
1829
|
+
.prepare("SELECT * FROM unrouted_backlog WHERE id = ? AND status = 'pending'")
|
|
1830
|
+
.get(id);
|
|
1831
|
+
if (!row)
|
|
1832
|
+
return null;
|
|
1833
|
+
const now = new Date().toISOString();
|
|
1834
|
+
const message = db
|
|
1835
|
+
.prepare("SELECT source, direction, metadata FROM messages WHERE id = ?")
|
|
1836
|
+
.get(row.message_id);
|
|
1837
|
+
if (message && isExternalTransportSource(message.source) && message.direction === "inbound") {
|
|
1838
|
+
let metadata = {};
|
|
1839
|
+
if (message.metadata) {
|
|
1840
|
+
try {
|
|
1841
|
+
metadata = JSON.parse(message.metadata);
|
|
1842
|
+
}
|
|
1843
|
+
catch {
|
|
1844
|
+
metadata = {};
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
metadata.threadAffinityOwnerAgentId = agentId;
|
|
1848
|
+
db.prepare("UPDATE messages SET metadata = ? WHERE id = ?").run(JSON.stringify(metadata), row.message_id);
|
|
1849
|
+
}
|
|
1850
|
+
db.prepare(`INSERT OR IGNORE INTO inbox (agent_id, message_id, delivered, created_at)
|
|
1851
|
+
VALUES (?, ?, 0, ?)`).run(agentId, row.message_id, now);
|
|
1852
|
+
db.prepare(`UPDATE unrouted_backlog
|
|
1853
|
+
SET status = 'assigned',
|
|
1854
|
+
assigned_agent_id = ?,
|
|
1855
|
+
attempt_count = attempt_count + 1,
|
|
1856
|
+
last_attempt_at = ?,
|
|
1857
|
+
updated_at = ?
|
|
1858
|
+
WHERE id = ?`).run(agentId, now, now, id);
|
|
1859
|
+
this.updateThread(row.thread_id, { ownerAgent: agentId, channel: row.channel });
|
|
1860
|
+
return this.getBacklogById(id);
|
|
1861
|
+
});
|
|
1862
|
+
}
|
|
1863
|
+
recoverPendingTargetedBacklog(agentId) {
|
|
1864
|
+
const agent = this.getAgentRowById(agentId);
|
|
1865
|
+
if (!agent || agent.disconnected_at) {
|
|
1866
|
+
return 0;
|
|
1867
|
+
}
|
|
1868
|
+
const db = this.getDb();
|
|
1869
|
+
const rows = db
|
|
1870
|
+
.prepare(`SELECT id
|
|
1871
|
+
FROM unrouted_backlog
|
|
1872
|
+
WHERE status = 'pending'
|
|
1873
|
+
AND preferred_agent_id = ?
|
|
1874
|
+
ORDER BY created_at ASC`)
|
|
1875
|
+
.all(agentId);
|
|
1876
|
+
let recoveredCount = 0;
|
|
1877
|
+
for (const row of rows) {
|
|
1878
|
+
if (this.assignBacklogEntry(row.id, agentId)) {
|
|
1879
|
+
recoveredCount += 1;
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
return recoveredCount;
|
|
1883
|
+
}
|
|
1884
|
+
dropBacklogEntry(id, reason) {
|
|
1885
|
+
const db = this.getDb();
|
|
1886
|
+
const now = new Date().toISOString();
|
|
1887
|
+
const result = db
|
|
1888
|
+
.prepare(`UPDATE unrouted_backlog
|
|
1889
|
+
SET status = 'dropped',
|
|
1890
|
+
reason = ?,
|
|
1891
|
+
assigned_agent_id = NULL,
|
|
1892
|
+
updated_at = ?
|
|
1893
|
+
WHERE id = ?
|
|
1894
|
+
AND status = 'pending'`)
|
|
1895
|
+
.run(reason, now, id);
|
|
1896
|
+
if (Number(result.changes ?? 0) === 0) {
|
|
1897
|
+
return null;
|
|
1898
|
+
}
|
|
1899
|
+
return this.getBacklogById(id);
|
|
1900
|
+
}
|
|
1901
|
+
repairOrphanedAssignedBacklog() {
|
|
1902
|
+
const db = this.getDb();
|
|
1903
|
+
return this.withTransaction(() => {
|
|
1904
|
+
const rows = db
|
|
1905
|
+
.prepare(`SELECT id, message_id, preferred_agent_id, assigned_agent_id
|
|
1906
|
+
FROM unrouted_backlog
|
|
1907
|
+
WHERE status = 'assigned'
|
|
1908
|
+
AND (
|
|
1909
|
+
(
|
|
1910
|
+
preferred_agent_id IS NOT NULL
|
|
1911
|
+
AND (
|
|
1912
|
+
assigned_agent_id IS NULL
|
|
1913
|
+
OR assigned_agent_id NOT IN (SELECT id FROM agents)
|
|
1914
|
+
OR NOT EXISTS (
|
|
1915
|
+
SELECT 1
|
|
1916
|
+
FROM inbox
|
|
1917
|
+
WHERE inbox.message_id = unrouted_backlog.message_id
|
|
1918
|
+
AND inbox.agent_id = unrouted_backlog.assigned_agent_id
|
|
1919
|
+
)
|
|
1920
|
+
)
|
|
1921
|
+
)
|
|
1922
|
+
OR (
|
|
1923
|
+
preferred_agent_id IS NULL
|
|
1924
|
+
AND (
|
|
1925
|
+
assigned_agent_id IS NULL
|
|
1926
|
+
OR assigned_agent_id NOT IN (SELECT id FROM agents)
|
|
1927
|
+
)
|
|
1928
|
+
)
|
|
1929
|
+
)`)
|
|
1930
|
+
.all();
|
|
1931
|
+
if (rows.length === 0) {
|
|
1932
|
+
return { resetToPendingCount: 0, droppedCount: 0 };
|
|
1933
|
+
}
|
|
1934
|
+
const now = new Date().toISOString();
|
|
1935
|
+
const clearStaleInbox = db.prepare(`DELETE FROM inbox
|
|
1936
|
+
WHERE message_id = ?
|
|
1937
|
+
AND agent_id = ?`);
|
|
1938
|
+
const resetPending = db.prepare(`UPDATE unrouted_backlog
|
|
1939
|
+
SET status = 'pending',
|
|
1940
|
+
assigned_agent_id = NULL,
|
|
1941
|
+
updated_at = ?
|
|
1942
|
+
WHERE id = ?
|
|
1943
|
+
AND status = 'assigned'`);
|
|
1944
|
+
const dropAssigned = db.prepare(`UPDATE unrouted_backlog
|
|
1945
|
+
SET status = 'dropped',
|
|
1946
|
+
reason = 'preferred_agent_missing',
|
|
1947
|
+
assigned_agent_id = NULL,
|
|
1948
|
+
updated_at = ?
|
|
1949
|
+
WHERE id = ?
|
|
1950
|
+
AND status = 'assigned'`);
|
|
1951
|
+
let resetToPendingCount = 0;
|
|
1952
|
+
let droppedCount = 0;
|
|
1953
|
+
for (const row of rows) {
|
|
1954
|
+
if (row.assigned_agent_id) {
|
|
1955
|
+
clearStaleInbox.run(row.message_id, row.assigned_agent_id);
|
|
1956
|
+
}
|
|
1957
|
+
if (!row.preferred_agent_id) {
|
|
1958
|
+
resetToPendingCount += Number(resetPending.run(now, row.id).changes ?? 0);
|
|
1959
|
+
continue;
|
|
1960
|
+
}
|
|
1961
|
+
if (this.getAgentRowById(row.preferred_agent_id)) {
|
|
1962
|
+
resetToPendingCount += Number(resetPending.run(now, row.id).changes ?? 0);
|
|
1963
|
+
continue;
|
|
1964
|
+
}
|
|
1965
|
+
droppedCount += Number(dropAssigned.run(now, row.id).changes ?? 0);
|
|
1966
|
+
}
|
|
1967
|
+
return { resetToPendingCount, droppedCount };
|
|
1968
|
+
});
|
|
1969
|
+
}
|
|
1970
|
+
requeueUndeliveredMessages(agentId, reason = "agent_disconnected") {
|
|
1971
|
+
return this.withTransaction(() => this.requeueUndeliveredMessagesInternal(agentId, reason));
|
|
1972
|
+
}
|
|
1973
|
+
getPendingInboxCount(agentId) {
|
|
1974
|
+
return this.withTransaction(() => {
|
|
1975
|
+
this.dropStaleTransportInboxRows(agentId);
|
|
1976
|
+
const db = this.getDb();
|
|
1977
|
+
const row = db
|
|
1978
|
+
.prepare("SELECT COUNT(*) AS count FROM inbox WHERE agent_id = ? AND delivered = 0")
|
|
1979
|
+
.get(agentId);
|
|
1980
|
+
return row.count;
|
|
1981
|
+
});
|
|
1982
|
+
}
|
|
1983
|
+
getOwnedThreadCount(agentId) {
|
|
1984
|
+
const db = this.getDb();
|
|
1985
|
+
const row = db
|
|
1986
|
+
.prepare("SELECT COUNT(*) AS count FROM threads WHERE owner_agent = ?")
|
|
1987
|
+
.get(agentId);
|
|
1988
|
+
return row.count;
|
|
1989
|
+
}
|
|
1990
|
+
releaseThreadClaims(agentId) {
|
|
1991
|
+
const db = this.getDb();
|
|
1992
|
+
const result = db
|
|
1993
|
+
.prepare("UPDATE threads SET owner_agent = NULL WHERE owner_agent = ?")
|
|
1994
|
+
.run(agentId);
|
|
1995
|
+
return Number(result.changes ?? 0);
|
|
1996
|
+
}
|
|
1997
|
+
// ─── Task assignments ───────────────────────────────
|
|
1998
|
+
recordTaskAssignment(agentId, issueNumber, branch, threadId, sourceMessageId, options = {}) {
|
|
1999
|
+
const db = this.getDb();
|
|
2000
|
+
const now = new Date().toISOString();
|
|
2001
|
+
const repoOwner = options.repoOwner ?? null;
|
|
2002
|
+
const repoName = options.repoName ?? null;
|
|
2003
|
+
const repoRoot = options.repoRoot ?? null;
|
|
2004
|
+
const repoKey = buildTaskAssignmentRepoKey({ repoOwner, repoName, repoRoot });
|
|
2005
|
+
const taskKind = normalizeTaskAssignmentKind(options.taskKind ?? "implementation");
|
|
2006
|
+
const existing = db
|
|
2007
|
+
.prepare("SELECT * FROM task_assignments WHERE repo_key = ? AND issue_number = ?")
|
|
2008
|
+
.get(repoKey, issueNumber);
|
|
2009
|
+
if (!existing) {
|
|
2010
|
+
const info = db
|
|
2011
|
+
.prepare(`INSERT INTO task_assignments (
|
|
2012
|
+
agent_id, issue_number, branch, pr_number, status,
|
|
2013
|
+
thread_id, source_message_id, repo_key, repo_owner, repo_name, repo_root, task_kind,
|
|
2014
|
+
created_at, updated_at
|
|
2015
|
+
) VALUES (?, ?, ?, NULL, 'assigned', ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
2016
|
+
.run(agentId, issueNumber, branch, threadId, sourceMessageId, repoKey, repoOwner, repoName, repoRoot, taskKind, now, now);
|
|
2017
|
+
const row = db
|
|
2018
|
+
.prepare("SELECT * FROM task_assignments WHERE id = ?")
|
|
2019
|
+
.get(Number(info.lastInsertRowid));
|
|
2020
|
+
if (!row) {
|
|
2021
|
+
throw new Error(`Failed to create task assignment for ${agentId}#${issueNumber}`);
|
|
2022
|
+
}
|
|
2023
|
+
return rowToTaskAssignment(row);
|
|
2024
|
+
}
|
|
2025
|
+
const isReassignment = existing.agent_id !== agentId;
|
|
2026
|
+
const nextBranch = isReassignment ? branch : (branch ?? existing.branch);
|
|
2027
|
+
const nextTaskKind = taskKind === "unknown" ? normalizeTaskAssignmentKind(existing.task_kind) : taskKind;
|
|
2028
|
+
const shouldResetProgress = isReassignment || nextBranch !== existing.branch;
|
|
2029
|
+
db.prepare(`UPDATE task_assignments
|
|
2030
|
+
SET agent_id = ?,
|
|
2031
|
+
branch = ?,
|
|
2032
|
+
pr_number = CASE WHEN ? THEN NULL ELSE pr_number END,
|
|
2033
|
+
status = CASE WHEN ? THEN 'assigned' ELSE status END,
|
|
2034
|
+
thread_id = ?,
|
|
2035
|
+
source_message_id = ?,
|
|
2036
|
+
repo_owner = ?,
|
|
2037
|
+
repo_name = ?,
|
|
2038
|
+
repo_root = ?,
|
|
2039
|
+
task_kind = ?,
|
|
2040
|
+
updated_at = ?
|
|
2041
|
+
WHERE id = ?`).run(agentId, nextBranch, shouldResetProgress ? 1 : 0, shouldResetProgress ? 1 : 0, threadId, sourceMessageId, repoOwner, repoName, repoRoot, nextTaskKind, now, existing.id);
|
|
2042
|
+
const row = db.prepare("SELECT * FROM task_assignments WHERE id = ?").get(existing.id);
|
|
2043
|
+
if (!row) {
|
|
2044
|
+
throw new Error(`Failed to update task assignment for ${agentId}#${issueNumber}`);
|
|
2045
|
+
}
|
|
2046
|
+
return rowToTaskAssignment(row);
|
|
2047
|
+
}
|
|
2048
|
+
listTaskAssignments() {
|
|
2049
|
+
const db = this.getDb();
|
|
2050
|
+
const rows = db
|
|
2051
|
+
.prepare(`SELECT * FROM task_assignments
|
|
2052
|
+
ORDER BY updated_at DESC, created_at DESC, id DESC`)
|
|
2053
|
+
.all();
|
|
2054
|
+
return rows.map(rowToTaskAssignment);
|
|
2055
|
+
}
|
|
2056
|
+
listTaskAssignmentsAwaitingFirstReply() {
|
|
2057
|
+
const db = this.getDb();
|
|
2058
|
+
const rows = db
|
|
2059
|
+
.prepare(`SELECT
|
|
2060
|
+
ta.id AS id,
|
|
2061
|
+
ta.agent_id AS agent_id,
|
|
2062
|
+
ta.issue_number AS issue_number,
|
|
2063
|
+
ta.status AS status,
|
|
2064
|
+
ta.source_message_id AS source_message_id,
|
|
2065
|
+
source.sender AS original_sender_agent_id
|
|
2066
|
+
FROM task_assignments ta
|
|
2067
|
+
JOIN messages source ON source.id = ta.source_message_id
|
|
2068
|
+
WHERE ta.source_message_id IS NOT NULL
|
|
2069
|
+
AND ta.status IN ('assigned', 'branch_pushed', 'pr_open')
|
|
2070
|
+
AND source.source = 'agent'
|
|
2071
|
+
AND source.direction = 'inbound'
|
|
2072
|
+
AND source.sender != ta.agent_id
|
|
2073
|
+
AND NOT EXISTS (
|
|
2074
|
+
SELECT 1
|
|
2075
|
+
FROM messages reply
|
|
2076
|
+
JOIN inbox reply_inbox ON reply_inbox.message_id = reply.id
|
|
2077
|
+
WHERE reply.source = 'agent'
|
|
2078
|
+
AND reply.direction = 'inbound'
|
|
2079
|
+
AND reply.sender = ta.agent_id
|
|
2080
|
+
AND reply.id > source.id
|
|
2081
|
+
AND reply_inbox.agent_id = source.sender
|
|
2082
|
+
)
|
|
2083
|
+
ORDER BY ta.updated_at DESC, ta.created_at DESC, ta.id DESC`)
|
|
2084
|
+
.all();
|
|
2085
|
+
return rows.map((row) => ({
|
|
2086
|
+
id: row.id,
|
|
2087
|
+
agentId: row.agent_id,
|
|
2088
|
+
issueNumber: row.issue_number,
|
|
2089
|
+
status: row.status,
|
|
2090
|
+
sourceMessageId: row.source_message_id,
|
|
2091
|
+
originalSenderAgentId: row.original_sender_agent_id,
|
|
2092
|
+
}));
|
|
2093
|
+
}
|
|
2094
|
+
updateTaskAssignmentProgress(id, status, prNumber) {
|
|
2095
|
+
const db = this.getDb();
|
|
2096
|
+
db.prepare(`UPDATE task_assignments
|
|
2097
|
+
SET status = ?,
|
|
2098
|
+
pr_number = ?,
|
|
2099
|
+
updated_at = ?
|
|
2100
|
+
WHERE id = ?`).run(status, prNumber, new Date().toISOString(), id);
|
|
2101
|
+
}
|
|
2102
|
+
// ─── Pinet lane metadata ─────────────────────────────
|
|
2103
|
+
upsertPinetLane(input) {
|
|
2104
|
+
const db = this.getDb();
|
|
2105
|
+
const laneId = normalizeLaneId(input.laneId);
|
|
2106
|
+
const now = new Date().toISOString();
|
|
2107
|
+
const existing = db.prepare("SELECT * FROM pinet_lanes WHERE lane_id = ?").get(laneId);
|
|
2108
|
+
const nextState = input.state === undefined
|
|
2109
|
+
? existing
|
|
2110
|
+
? rowToPinetLane(existing).state
|
|
2111
|
+
: "active"
|
|
2112
|
+
: requirePinetLaneState(input.state);
|
|
2113
|
+
if (!existing) {
|
|
2114
|
+
db.prepare(`INSERT INTO pinet_lanes (
|
|
2115
|
+
lane_id, name, task, issue_number, pr_number, thread_id,
|
|
2116
|
+
owner_agent_id, implementation_lead_agent_id, pm_mode, state,
|
|
2117
|
+
summary, metadata, created_at, updated_at, last_activity_at
|
|
2118
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(laneId, normalizeOptionalText(input.name) ?? null, normalizeOptionalText(input.task) ?? null, normalizeOptionalInteger(input.issueNumber) ?? null, normalizeOptionalInteger(input.prNumber) ?? null, normalizeOptionalText(input.threadId) ?? null, normalizeOptionalText(input.ownerAgentId) ?? null, normalizeOptionalText(input.implementationLeadAgentId) ?? null, input.pmMode === true ? 1 : 0, nextState, normalizeOptionalText(input.summary) ?? null, serializeOptionalMetadata(input.metadata) ?? null, now, now, now);
|
|
2119
|
+
return this.getPinetLane(laneId);
|
|
2120
|
+
}
|
|
2121
|
+
db.prepare(`UPDATE pinet_lanes
|
|
2122
|
+
SET name = ?,
|
|
2123
|
+
task = ?,
|
|
2124
|
+
issue_number = ?,
|
|
2125
|
+
pr_number = ?,
|
|
2126
|
+
thread_id = ?,
|
|
2127
|
+
owner_agent_id = ?,
|
|
2128
|
+
implementation_lead_agent_id = ?,
|
|
2129
|
+
pm_mode = ?,
|
|
2130
|
+
state = ?,
|
|
2131
|
+
summary = ?,
|
|
2132
|
+
metadata = ?,
|
|
2133
|
+
updated_at = ?,
|
|
2134
|
+
last_activity_at = ?
|
|
2135
|
+
WHERE lane_id = ?`).run(input.name === undefined ? existing.name : (normalizeOptionalText(input.name) ?? null), input.task === undefined ? existing.task : (normalizeOptionalText(input.task) ?? null), input.issueNumber === undefined
|
|
2136
|
+
? existing.issue_number
|
|
2137
|
+
: (normalizeOptionalInteger(input.issueNumber) ?? null), input.prNumber === undefined
|
|
2138
|
+
? existing.pr_number
|
|
2139
|
+
: (normalizeOptionalInteger(input.prNumber) ?? null), input.threadId === undefined
|
|
2140
|
+
? existing.thread_id
|
|
2141
|
+
: (normalizeOptionalText(input.threadId) ?? null), input.ownerAgentId === undefined
|
|
2142
|
+
? existing.owner_agent_id
|
|
2143
|
+
: (normalizeOptionalText(input.ownerAgentId) ?? null), input.implementationLeadAgentId === undefined
|
|
2144
|
+
? existing.implementation_lead_agent_id
|
|
2145
|
+
: (normalizeOptionalText(input.implementationLeadAgentId) ?? null), input.pmMode === undefined ? existing.pm_mode : input.pmMode ? 1 : 0, nextState, input.summary === undefined
|
|
2146
|
+
? existing.summary
|
|
2147
|
+
: (normalizeOptionalText(input.summary) ?? null), input.metadata === undefined
|
|
2148
|
+
? existing.metadata
|
|
2149
|
+
: (serializeOptionalMetadata(input.metadata) ?? null), now, now, laneId);
|
|
2150
|
+
return this.getPinetLane(laneId);
|
|
2151
|
+
}
|
|
2152
|
+
setPinetLaneParticipant(input) {
|
|
2153
|
+
const db = this.getDb();
|
|
2154
|
+
const laneId = normalizeLaneId(input.laneId);
|
|
2155
|
+
const agentId = normalizeLaneId(input.agentId);
|
|
2156
|
+
const role = requirePinetLaneRole(input.role);
|
|
2157
|
+
if (!this.getPinetLane(laneId)) {
|
|
2158
|
+
throw new Error(`Pinet lane not found: ${laneId}`);
|
|
2159
|
+
}
|
|
2160
|
+
const now = new Date().toISOString();
|
|
2161
|
+
const existing = db
|
|
2162
|
+
.prepare("SELECT * FROM pinet_lane_participants WHERE lane_id = ? AND agent_id = ?")
|
|
2163
|
+
.get(laneId, agentId);
|
|
2164
|
+
if (!existing) {
|
|
2165
|
+
db.prepare(`INSERT INTO pinet_lane_participants (
|
|
2166
|
+
lane_id, agent_id, lane_role, status, summary, metadata,
|
|
2167
|
+
created_at, updated_at, last_activity_at
|
|
2168
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(laneId, agentId, role, normalizeOptionalText(input.status) ?? null, normalizeOptionalText(input.summary) ?? null, serializeOptionalMetadata(input.metadata) ?? null, now, now, now);
|
|
2169
|
+
}
|
|
2170
|
+
else {
|
|
2171
|
+
db.prepare(`UPDATE pinet_lane_participants
|
|
2172
|
+
SET lane_role = ?,
|
|
2173
|
+
status = ?,
|
|
2174
|
+
summary = ?,
|
|
2175
|
+
metadata = ?,
|
|
2176
|
+
updated_at = ?,
|
|
2177
|
+
last_activity_at = ?
|
|
2178
|
+
WHERE lane_id = ? AND agent_id = ?`).run(role, input.status === undefined
|
|
2179
|
+
? existing.status
|
|
2180
|
+
: (normalizeOptionalText(input.status) ?? null), input.summary === undefined
|
|
2181
|
+
? existing.summary
|
|
2182
|
+
: (normalizeOptionalText(input.summary) ?? null), input.metadata === undefined
|
|
2183
|
+
? existing.metadata
|
|
2184
|
+
: (serializeOptionalMetadata(input.metadata) ?? null), now, now, laneId, agentId);
|
|
2185
|
+
}
|
|
2186
|
+
db.prepare(`UPDATE pinet_lanes
|
|
2187
|
+
SET updated_at = ?, last_activity_at = ?
|
|
2188
|
+
WHERE lane_id = ?`).run(now, now, laneId);
|
|
2189
|
+
const row = db
|
|
2190
|
+
.prepare("SELECT * FROM pinet_lane_participants WHERE lane_id = ? AND agent_id = ?")
|
|
2191
|
+
.get(laneId, agentId);
|
|
2192
|
+
if (!row) {
|
|
2193
|
+
throw new Error(`Failed to update Pinet lane participant ${agentId} for ${laneId}`);
|
|
2194
|
+
}
|
|
2195
|
+
return rowToPinetLaneParticipant(row);
|
|
2196
|
+
}
|
|
2197
|
+
getPinetLane(laneId) {
|
|
2198
|
+
const db = this.getDb();
|
|
2199
|
+
const canonicalLaneId = normalizeLaneId(laneId);
|
|
2200
|
+
const row = db.prepare("SELECT * FROM pinet_lanes WHERE lane_id = ?").get(canonicalLaneId);
|
|
2201
|
+
if (!row)
|
|
2202
|
+
return null;
|
|
2203
|
+
const participantRows = db
|
|
2204
|
+
.prepare("SELECT * FROM pinet_lane_participants WHERE lane_id = ? ORDER BY updated_at DESC")
|
|
2205
|
+
.all(canonicalLaneId);
|
|
2206
|
+
return rowToPinetLane(row, participantRows.map(rowToPinetLaneParticipant));
|
|
2207
|
+
}
|
|
2208
|
+
listPinetLanes(options = {}) {
|
|
2209
|
+
const db = this.getDb();
|
|
2210
|
+
const clauses = [];
|
|
2211
|
+
const values = [];
|
|
2212
|
+
if (options.state) {
|
|
2213
|
+
clauses.push("state = ?");
|
|
2214
|
+
values.push(requirePinetLaneState(options.state));
|
|
2215
|
+
}
|
|
2216
|
+
else if (!options.includeDone) {
|
|
2217
|
+
clauses.push("state NOT IN ('done', 'cancelled', 'detached')");
|
|
2218
|
+
}
|
|
2219
|
+
const ownerAgentId = normalizeOptionalText(options.ownerAgentId);
|
|
2220
|
+
if (ownerAgentId) {
|
|
2221
|
+
clauses.push("owner_agent_id = ?");
|
|
2222
|
+
values.push(ownerAgentId);
|
|
2223
|
+
}
|
|
2224
|
+
const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
2225
|
+
const rows = db
|
|
2226
|
+
.prepare(`SELECT * FROM pinet_lanes ${where} ORDER BY updated_at DESC, created_at DESC`)
|
|
2227
|
+
.all(...values);
|
|
2228
|
+
return rows.map((row) => this.getPinetLane(row.lane_id));
|
|
2229
|
+
}
|
|
2230
|
+
// ─── Scheduled wake-ups ──────────────────────────────
|
|
2231
|
+
scheduleWakeup(agentId, body, fireAt, threadId = `wakeup:${agentId}`) {
|
|
2232
|
+
const db = this.getDb();
|
|
2233
|
+
const createdAt = new Date().toISOString();
|
|
2234
|
+
const canonicalFireAt = new Date(fireAt).toISOString();
|
|
2235
|
+
const agentStableId = this.getAgentRowById(agentId)?.stable_id ?? null;
|
|
2236
|
+
const info = db
|
|
2237
|
+
.prepare(`INSERT INTO scheduled_wakeups (
|
|
2238
|
+
agent_id,
|
|
2239
|
+
agent_stable_id,
|
|
2240
|
+
thread_id,
|
|
2241
|
+
body,
|
|
2242
|
+
fire_at,
|
|
2243
|
+
created_at
|
|
2244
|
+
)
|
|
2245
|
+
VALUES (?, ?, ?, ?, ?, ?)`)
|
|
2246
|
+
.run(agentId, agentStableId, threadId, body, canonicalFireAt, createdAt);
|
|
2247
|
+
const row = db
|
|
2248
|
+
.prepare("SELECT * FROM scheduled_wakeups WHERE id = ?")
|
|
2249
|
+
.get(Number(info.lastInsertRowid));
|
|
2250
|
+
if (!row) {
|
|
2251
|
+
throw new Error(`Failed to create scheduled wake-up for ${agentId}`);
|
|
2252
|
+
}
|
|
2253
|
+
return rowToScheduledWakeup(row);
|
|
2254
|
+
}
|
|
2255
|
+
listScheduledWakeups(agentId) {
|
|
2256
|
+
const db = this.getDb();
|
|
2257
|
+
const agentStableId = agentId ? (this.getAgentRowById(agentId)?.stable_id ?? null) : null;
|
|
2258
|
+
const rows = (agentId
|
|
2259
|
+
? agentStableId
|
|
2260
|
+
? db
|
|
2261
|
+
.prepare(`SELECT * FROM scheduled_wakeups
|
|
2262
|
+
WHERE agent_stable_id = ?
|
|
2263
|
+
OR (agent_stable_id IS NULL AND agent_id = ?)
|
|
2264
|
+
ORDER BY fire_at ASC, id ASC`)
|
|
2265
|
+
.all(agentStableId, agentId)
|
|
2266
|
+
: db
|
|
2267
|
+
.prepare(`SELECT * FROM scheduled_wakeups
|
|
2268
|
+
WHERE agent_id = ?
|
|
2269
|
+
ORDER BY fire_at ASC, id ASC`)
|
|
2270
|
+
.all(agentId)
|
|
2271
|
+
: db
|
|
2272
|
+
.prepare(`SELECT * FROM scheduled_wakeups
|
|
2273
|
+
ORDER BY fire_at ASC, id ASC`)
|
|
2274
|
+
.all());
|
|
2275
|
+
return rows.map(rowToScheduledWakeup);
|
|
2276
|
+
}
|
|
2277
|
+
deliverDueScheduledWakeups(now = new Date().toISOString(), limit = 50) {
|
|
2278
|
+
const db = this.getDb();
|
|
2279
|
+
return this.withTransaction(() => {
|
|
2280
|
+
const rows = db
|
|
2281
|
+
.prepare(`SELECT
|
|
2282
|
+
sw.*,
|
|
2283
|
+
COALESCE(stable_agent.id, direct_agent.id) AS target_agent_id
|
|
2284
|
+
FROM scheduled_wakeups sw
|
|
2285
|
+
LEFT JOIN agents stable_agent
|
|
2286
|
+
ON sw.agent_stable_id IS NOT NULL
|
|
2287
|
+
AND stable_agent.stable_id = sw.agent_stable_id
|
|
2288
|
+
AND stable_agent.disconnected_at IS NULL
|
|
2289
|
+
LEFT JOIN agents direct_agent
|
|
2290
|
+
ON sw.agent_stable_id IS NULL
|
|
2291
|
+
AND direct_agent.id = sw.agent_id
|
|
2292
|
+
AND direct_agent.disconnected_at IS NULL
|
|
2293
|
+
WHERE sw.fire_at <= ?
|
|
2294
|
+
AND COALESCE(stable_agent.id, direct_agent.id) IS NOT NULL
|
|
2295
|
+
ORDER BY sw.fire_at ASC, sw.id ASC
|
|
2296
|
+
LIMIT ?`)
|
|
2297
|
+
.all(now, limit);
|
|
2298
|
+
if (rows.length === 0) {
|
|
2299
|
+
return [];
|
|
2300
|
+
}
|
|
2301
|
+
const deleteWakeup = db.prepare("DELETE FROM scheduled_wakeups WHERE id = ?");
|
|
2302
|
+
const deliveries = [];
|
|
2303
|
+
for (const row of rows) {
|
|
2304
|
+
const targetAgentId = row.target_agent_id;
|
|
2305
|
+
if (!this.getThread(row.thread_id)) {
|
|
2306
|
+
this.createThread(row.thread_id, "agent", "", targetAgentId);
|
|
2307
|
+
}
|
|
2308
|
+
else {
|
|
2309
|
+
this.updateThread(row.thread_id, { ownerAgent: targetAgentId });
|
|
2310
|
+
}
|
|
2311
|
+
const message = this.insertMessage(row.thread_id, "agent", "inbound", "scheduler", row.body, [targetAgentId], {
|
|
2312
|
+
senderAgent: "Pinet Scheduler",
|
|
2313
|
+
scheduledWakeup: true,
|
|
2314
|
+
a2a: true,
|
|
2315
|
+
pinetMailClass: "fwup",
|
|
2316
|
+
wakeupId: row.id,
|
|
2317
|
+
fireAt: row.fire_at,
|
|
2318
|
+
});
|
|
2319
|
+
deleteWakeup.run(row.id);
|
|
2320
|
+
deliveries.push({ wakeup: rowToScheduledWakeup(row), message });
|
|
2321
|
+
}
|
|
2322
|
+
return deliveries;
|
|
2323
|
+
});
|
|
2324
|
+
}
|
|
2325
|
+
repairThreadOwnership() {
|
|
2326
|
+
const db = this.getDb();
|
|
2327
|
+
return this.withTransaction(() => {
|
|
2328
|
+
const rows = db
|
|
2329
|
+
.prepare(`SELECT owner_agent, COUNT(*) AS claim_count
|
|
2330
|
+
FROM threads
|
|
2331
|
+
WHERE owner_agent IS NOT NULL
|
|
2332
|
+
AND owner_agent NOT IN (
|
|
2333
|
+
SELECT id FROM agents WHERE disconnected_at IS NULL
|
|
2334
|
+
)
|
|
2335
|
+
GROUP BY owner_agent`)
|
|
2336
|
+
.all();
|
|
2337
|
+
if (rows.length === 0) {
|
|
2338
|
+
return { releasedClaimCount: 0, releasedAgentIds: [] };
|
|
2339
|
+
}
|
|
2340
|
+
db.prepare(`UPDATE threads
|
|
2341
|
+
SET owner_agent = NULL
|
|
2342
|
+
WHERE owner_agent IS NOT NULL
|
|
2343
|
+
AND owner_agent NOT IN (
|
|
2344
|
+
SELECT id FROM agents WHERE disconnected_at IS NULL
|
|
2345
|
+
)`).run();
|
|
2346
|
+
return {
|
|
2347
|
+
releasedClaimCount: rows.reduce((count, row) => count + Number(row.claim_count), 0),
|
|
2348
|
+
releasedAgentIds: rows.map((row) => row.owner_agent),
|
|
2349
|
+
};
|
|
2350
|
+
});
|
|
2351
|
+
}
|
|
2352
|
+
// ─── Messages + Inbox ────────────────────────────────
|
|
2353
|
+
// ─── Interface-compatible queueMessage (single agent) ──
|
|
2354
|
+
queueMessage(agentId, message) {
|
|
2355
|
+
this.withTransaction(() => {
|
|
2356
|
+
this.insertMessage(message.threadId, message.source, "inbound", message.userId, message.text, [agentId], this.buildInboundMessageMetadata(message));
|
|
2357
|
+
this.reclassifyReferencedMessageFromReaction(message);
|
|
2358
|
+
});
|
|
2359
|
+
}
|
|
2360
|
+
queueDeliveredMessage(agentId, message) {
|
|
2361
|
+
return this.withTransaction(() => {
|
|
2362
|
+
const db = this.getDb();
|
|
2363
|
+
const brokerMessage = this.insertMessage(message.threadId, message.source, "inbound", message.userId, message.text, [], this.buildInboundMessageMetadata(message));
|
|
2364
|
+
this.reclassifyReferencedMessageFromReaction(message);
|
|
2365
|
+
const existing = db
|
|
2366
|
+
.prepare("SELECT id FROM inbox WHERE agent_id = ? AND message_id = ? ORDER BY id ASC LIMIT 1")
|
|
2367
|
+
.get(agentId, brokerMessage.id);
|
|
2368
|
+
if (existing) {
|
|
2369
|
+
db.prepare("UPDATE inbox SET delivered = 1 WHERE id = ? AND agent_id = ?").run(existing.id, agentId);
|
|
2370
|
+
const entry = this.getInboxEntryById(existing.id, agentId);
|
|
2371
|
+
if (!entry) {
|
|
2372
|
+
throw new Error("Failed to read delivered inbox entry");
|
|
2373
|
+
}
|
|
2374
|
+
return { entry, message: brokerMessage, freshDelivery: false };
|
|
2375
|
+
}
|
|
2376
|
+
const now = new Date().toISOString();
|
|
2377
|
+
const result = db
|
|
2378
|
+
.prepare(`INSERT INTO inbox (agent_id, message_id, delivered, created_at)
|
|
2379
|
+
VALUES (?, ?, 1, ?)`)
|
|
2380
|
+
.run(agentId, brokerMessage.id, now);
|
|
2381
|
+
const inboxId = Number(result.lastInsertRowid);
|
|
2382
|
+
return {
|
|
2383
|
+
entry: {
|
|
2384
|
+
id: inboxId,
|
|
2385
|
+
agentId,
|
|
2386
|
+
messageId: brokerMessage.id,
|
|
2387
|
+
delivered: true,
|
|
2388
|
+
readAt: null,
|
|
2389
|
+
createdAt: now,
|
|
2390
|
+
},
|
|
2391
|
+
message: brokerMessage,
|
|
2392
|
+
freshDelivery: true,
|
|
2393
|
+
};
|
|
2394
|
+
});
|
|
2395
|
+
}
|
|
2396
|
+
buildInboundMessageMetadata(message) {
|
|
2397
|
+
const threadOwner = isExternalTransportSource(message.source) && message.threadId
|
|
2398
|
+
? this.getThread(message.threadId)?.ownerAgent
|
|
2399
|
+
: null;
|
|
2400
|
+
return this.withInboundMailClassMetadata(message, {
|
|
2401
|
+
...message.metadata,
|
|
2402
|
+
channel: message.channel,
|
|
2403
|
+
userName: message.userName,
|
|
2404
|
+
userId: message.userId,
|
|
2405
|
+
timestamp: message.timestamp,
|
|
2406
|
+
...(threadOwner ? { threadAffinityOwnerAgentId: threadOwner } : {}),
|
|
2407
|
+
...(message.isChannelMention ? { isChannelMention: true } : {}),
|
|
2408
|
+
...(message.scope ? { scope: message.scope } : {}),
|
|
2409
|
+
});
|
|
2410
|
+
}
|
|
2411
|
+
withInboundMailClassMetadata(message, metadata) {
|
|
2412
|
+
if (!isExternalTransportSource(message.source)) {
|
|
2413
|
+
return metadata;
|
|
2414
|
+
}
|
|
2415
|
+
const classification = classifyPinetMail({
|
|
2416
|
+
source: message.source,
|
|
2417
|
+
threadId: message.threadId,
|
|
2418
|
+
sender: message.userId,
|
|
2419
|
+
body: message.text,
|
|
2420
|
+
metadata,
|
|
2421
|
+
});
|
|
2422
|
+
if (classification.explicit) {
|
|
2423
|
+
return metadata;
|
|
2424
|
+
}
|
|
2425
|
+
return {
|
|
2426
|
+
...metadata,
|
|
2427
|
+
pinetMailClass: classification.class,
|
|
2428
|
+
};
|
|
2429
|
+
}
|
|
2430
|
+
reclassifyReferencedMessageFromReaction(message) {
|
|
2431
|
+
const metadata = message.metadata;
|
|
2432
|
+
if (!metadata)
|
|
2433
|
+
return;
|
|
2434
|
+
if (metadata.reactionTrigger !== true)
|
|
2435
|
+
return;
|
|
2436
|
+
const reactionAction = getStringMetadataValue(metadata, ["reactionAction", "reaction_action"]);
|
|
2437
|
+
if (reactionAction !== "steer")
|
|
2438
|
+
return;
|
|
2439
|
+
const reactionName = getStringMetadataValue(metadata, ["reactionName", "reaction_name"]);
|
|
2440
|
+
const referencedSource = getStringMetadataValue(metadata, ["referencedSource", "referenced_source"]) ?? message.source;
|
|
2441
|
+
const referencedExternalId = getStringMetadataValue(metadata, [
|
|
2442
|
+
"referencedExternalId",
|
|
2443
|
+
"referenced_external_id",
|
|
2444
|
+
]);
|
|
2445
|
+
const referencedChannel = getStringMetadataValue(metadata, [
|
|
2446
|
+
"referencedChannel",
|
|
2447
|
+
"referenced_channel",
|
|
2448
|
+
]);
|
|
2449
|
+
const referencedMessageTs = getStringMetadataValue(metadata, [
|
|
2450
|
+
"referencedMessageTs",
|
|
2451
|
+
"referenced_message_ts",
|
|
2452
|
+
"messageTs",
|
|
2453
|
+
"message_ts",
|
|
2454
|
+
]);
|
|
2455
|
+
const externalId = referencedExternalId ??
|
|
2456
|
+
(referencedChannel && referencedMessageTs
|
|
2457
|
+
? `${referencedChannel}:${referencedMessageTs}`
|
|
2458
|
+
: null);
|
|
2459
|
+
if (!externalId)
|
|
2460
|
+
return;
|
|
2461
|
+
const referenced = this.getMessageByExternalId(referencedSource, externalId);
|
|
2462
|
+
if (!referenced)
|
|
2463
|
+
return;
|
|
2464
|
+
const targetAgentIds = this.getUnreadReactionEscalationTargets(referenced);
|
|
2465
|
+
if (targetAgentIds.length === 0)
|
|
2466
|
+
return;
|
|
2467
|
+
const reclassified = this.reclassifyMessageByExternalId(referencedSource, externalId, "steering", {
|
|
2468
|
+
reason: "reaction_steer",
|
|
2469
|
+
reactionName,
|
|
2470
|
+
reactorUserId: getStringMetadataValue(metadata, ["reactorUserId", "reactor_user_id"]),
|
|
2471
|
+
reactorName: getStringMetadataValue(metadata, ["reactorName", "reactor_name"]),
|
|
2472
|
+
reactionEventTs: getStringMetadataValue(metadata, ["reactionEventTs", "reaction_event_ts"]),
|
|
2473
|
+
referencedThreadId: message.threadId,
|
|
2474
|
+
referencedMessageTs,
|
|
2475
|
+
});
|
|
2476
|
+
if (reclassified) {
|
|
2477
|
+
this.redeliverReclassifiedReactionMessage(reclassified, targetAgentIds);
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
getUnreadReactionEscalationTargets(message) {
|
|
2481
|
+
const db = this.getDb();
|
|
2482
|
+
const ownerAgent = this.getThread(message.threadId)?.ownerAgent;
|
|
2483
|
+
if (ownerAgent) {
|
|
2484
|
+
const row = db
|
|
2485
|
+
.prepare("SELECT 1 FROM inbox WHERE agent_id = ? AND message_id = ? AND read_at IS NULL")
|
|
2486
|
+
.get(ownerAgent, message.id);
|
|
2487
|
+
return row ? [ownerAgent] : [];
|
|
2488
|
+
}
|
|
2489
|
+
const existingUnreadRecipients = db
|
|
2490
|
+
.prepare("SELECT DISTINCT agent_id FROM inbox WHERE message_id = ? AND read_at IS NULL")
|
|
2491
|
+
.all(message.id);
|
|
2492
|
+
return existingUnreadRecipients.map((row) => row.agent_id).filter(Boolean);
|
|
2493
|
+
}
|
|
2494
|
+
redeliverReclassifiedReactionMessage(message, targetAgentIds) {
|
|
2495
|
+
if (targetAgentIds.length === 0)
|
|
2496
|
+
return;
|
|
2497
|
+
const reopenUnreadInbox = this.getDb().prepare("UPDATE inbox SET delivered = 0 WHERE agent_id = ? AND message_id = ? AND read_at IS NULL");
|
|
2498
|
+
for (const agentId of targetAgentIds) {
|
|
2499
|
+
reopenUnreadInbox.run(agentId, message.id);
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
reclassifyMessageByExternalId(source, externalId, mailClass, audit) {
|
|
2503
|
+
const db = this.getDb();
|
|
2504
|
+
const row = db
|
|
2505
|
+
.prepare("SELECT id, metadata FROM messages WHERE source = ? AND external_id = ?")
|
|
2506
|
+
.get(source, externalId);
|
|
2507
|
+
if (!row)
|
|
2508
|
+
return null;
|
|
2509
|
+
const metadata = parseJsonMetadata(row.metadata);
|
|
2510
|
+
metadata.pinetMailClass = mailClass;
|
|
2511
|
+
metadata.pinet_mail_class = mailClass;
|
|
2512
|
+
metadata.pinet_mail_class_reason = audit.reason ?? "manual_reclassification";
|
|
2513
|
+
appendMetadataAudit(metadata, "pinet_mail_class_audit", {
|
|
2514
|
+
...audit,
|
|
2515
|
+
class: mailClass,
|
|
2516
|
+
at: new Date().toISOString(),
|
|
2517
|
+
});
|
|
2518
|
+
db.prepare("UPDATE messages SET metadata = ? WHERE id = ?").run(JSON.stringify(metadata), row.id);
|
|
2519
|
+
return this.getMessageById(row.id);
|
|
2520
|
+
}
|
|
2521
|
+
getInboxEntryById(id, agentId) {
|
|
2522
|
+
const row = this.getDb()
|
|
2523
|
+
.prepare("SELECT id, agent_id, message_id, delivered, read_at, created_at FROM inbox WHERE id = ? AND agent_id = ?")
|
|
2524
|
+
.get(id, agentId);
|
|
2525
|
+
return row
|
|
2526
|
+
? {
|
|
2527
|
+
id: row.id,
|
|
2528
|
+
agentId: row.agent_id,
|
|
2529
|
+
messageId: row.message_id,
|
|
2530
|
+
delivered: row.delivered === 1,
|
|
2531
|
+
readAt: row.read_at,
|
|
2532
|
+
createdAt: row.created_at,
|
|
2533
|
+
}
|
|
2534
|
+
: null;
|
|
2535
|
+
}
|
|
2536
|
+
// ─── Detailed message insert (used by socket server) ──
|
|
2537
|
+
insertMessage(threadId, source, direction, sender, body, targetAgentIds, metadata) {
|
|
2538
|
+
const db = this.getDb();
|
|
2539
|
+
const now = new Date().toISOString();
|
|
2540
|
+
const metaJson = metadata ? JSON.stringify(metadata) : null;
|
|
2541
|
+
const identity = deriveMessageSyncIdentity(threadId, source, metadata);
|
|
2542
|
+
const info = db
|
|
2543
|
+
.prepare(`INSERT OR IGNORE INTO messages (
|
|
2544
|
+
thread_id,
|
|
2545
|
+
source,
|
|
2546
|
+
direction,
|
|
2547
|
+
sender,
|
|
2548
|
+
body,
|
|
2549
|
+
metadata,
|
|
2550
|
+
external_id,
|
|
2551
|
+
external_ts,
|
|
2552
|
+
created_at
|
|
2553
|
+
)
|
|
2554
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
2555
|
+
.run(threadId, source, direction, sender, body, metaJson, identity.externalId, identity.externalTs, now);
|
|
2556
|
+
const insertedMessageId = Number(info.lastInsertRowid);
|
|
2557
|
+
const messageId = Number(info.changes ?? 0) > 0
|
|
2558
|
+
? insertedMessageId
|
|
2559
|
+
: this.getExistingMessageIdForIdentity(source, identity.externalId);
|
|
2560
|
+
if (messageId == null) {
|
|
2561
|
+
throw new Error("Failed to persist broker message");
|
|
2562
|
+
}
|
|
2563
|
+
const insertInbox = db.prepare(`INSERT INTO inbox (agent_id, message_id, delivered, created_at)
|
|
2564
|
+
SELECT ?, ?, 0, ?
|
|
2565
|
+
WHERE NOT EXISTS (
|
|
2566
|
+
SELECT 1
|
|
2567
|
+
FROM inbox
|
|
2568
|
+
WHERE agent_id = ?
|
|
2569
|
+
AND message_id = ?
|
|
2570
|
+
)`);
|
|
2571
|
+
for (const agentId of targetAgentIds) {
|
|
2572
|
+
insertInbox.run(agentId, messageId, now, agentId, messageId);
|
|
2573
|
+
}
|
|
2574
|
+
// Update thread timestamp
|
|
2575
|
+
db.prepare("UPDATE threads SET updated_at = ? WHERE thread_id = ?").run(now, threadId);
|
|
2576
|
+
return (this.getMessageById(messageId) ?? {
|
|
2577
|
+
id: messageId,
|
|
2578
|
+
threadId,
|
|
2579
|
+
source,
|
|
2580
|
+
direction,
|
|
2581
|
+
sender,
|
|
2582
|
+
body,
|
|
2583
|
+
metadata: metadata ?? null,
|
|
2584
|
+
...(identity.externalId ? { externalId: identity.externalId } : {}),
|
|
2585
|
+
...(identity.externalTs ? { externalTs: identity.externalTs } : {}),
|
|
2586
|
+
createdAt: now,
|
|
2587
|
+
});
|
|
2588
|
+
}
|
|
2589
|
+
getExistingMessageIdForIdentity(source, externalId) {
|
|
2590
|
+
if (!externalId) {
|
|
2591
|
+
return null;
|
|
2592
|
+
}
|
|
2593
|
+
const row = this.getDb()
|
|
2594
|
+
.prepare("SELECT id FROM messages WHERE source = ? AND external_id = ?")
|
|
2595
|
+
.get(source, externalId);
|
|
2596
|
+
return typeof row?.id === "number" ? row.id : null;
|
|
2597
|
+
}
|
|
2598
|
+
getMessageByExternalId(source, externalId) {
|
|
2599
|
+
const messageId = this.getExistingMessageIdForIdentity(source, externalId);
|
|
2600
|
+
return messageId === null ? null : this.getMessageById(messageId);
|
|
2601
|
+
}
|
|
2602
|
+
getMessageById(messageId) {
|
|
2603
|
+
const row = this.getDb()
|
|
2604
|
+
.prepare(`SELECT id, thread_id, source, direction, sender, body, metadata, external_id, external_ts, created_at
|
|
2605
|
+
FROM messages
|
|
2606
|
+
WHERE id = ?`)
|
|
2607
|
+
.get(messageId);
|
|
2608
|
+
return row ? rowToBrokerMessage(row) : null;
|
|
2609
|
+
}
|
|
2610
|
+
dropStaleTransportInboxRows(agentId) {
|
|
2611
|
+
const db = this.getDb();
|
|
2612
|
+
const rows = db
|
|
2613
|
+
.prepare(`SELECT i.id AS inbox_id,
|
|
2614
|
+
i.agent_id AS agent_id,
|
|
2615
|
+
m.metadata AS metadata,
|
|
2616
|
+
t.owner_agent AS owner_agent
|
|
2617
|
+
FROM inbox i
|
|
2618
|
+
JOIN messages m ON m.id = i.message_id
|
|
2619
|
+
JOIN threads t ON t.thread_id = m.thread_id
|
|
2620
|
+
WHERE i.agent_id = ?
|
|
2621
|
+
AND (i.delivered = 0 OR i.read_at IS NULL)
|
|
2622
|
+
AND m.source <> 'agent'
|
|
2623
|
+
AND m.direction = 'inbound'
|
|
2624
|
+
AND t.owner_agent IS NOT NULL`)
|
|
2625
|
+
.all(agentId);
|
|
2626
|
+
const staleIds = [];
|
|
2627
|
+
for (const row of rows) {
|
|
2628
|
+
if (!row.metadata)
|
|
2629
|
+
continue;
|
|
2630
|
+
let metadata;
|
|
2631
|
+
try {
|
|
2632
|
+
metadata = JSON.parse(row.metadata);
|
|
2633
|
+
}
|
|
2634
|
+
catch {
|
|
2635
|
+
continue;
|
|
2636
|
+
}
|
|
2637
|
+
const affinityOwner = metadata.threadAffinityOwnerAgentId;
|
|
2638
|
+
if (typeof affinityOwner !== "string" || affinityOwner.length === 0)
|
|
2639
|
+
continue;
|
|
2640
|
+
if (row.agent_id !== row.owner_agent || affinityOwner !== row.owner_agent) {
|
|
2641
|
+
staleIds.push(row.inbox_id);
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
if (staleIds.length === 0)
|
|
2645
|
+
return 0;
|
|
2646
|
+
const now = new Date().toISOString();
|
|
2647
|
+
const stmt = db.prepare("UPDATE inbox SET delivered = 1, read_at = COALESCE(read_at, ?) WHERE id = ? AND agent_id = ?");
|
|
2648
|
+
for (const staleId of staleIds) {
|
|
2649
|
+
stmt.run(now, staleId, agentId);
|
|
2650
|
+
}
|
|
2651
|
+
return staleIds.length;
|
|
2652
|
+
}
|
|
2653
|
+
getInbox(agentId, limit = 50) {
|
|
2654
|
+
this.dropStaleTransportInboxRows(agentId);
|
|
2655
|
+
const db = this.getDb();
|
|
2656
|
+
const rows = db
|
|
2657
|
+
.prepare(`SELECT
|
|
2658
|
+
i.id AS i_id, i.agent_id AS i_agent_id, i.message_id AS i_message_id,
|
|
2659
|
+
i.delivered AS i_delivered, i.read_at AS i_read_at, i.created_at AS i_created_at,
|
|
2660
|
+
m.id AS m_id, m.thread_id AS m_thread_id, m.source AS m_source,
|
|
2661
|
+
m.direction AS m_direction, m.sender AS m_sender, m.body AS m_body,
|
|
2662
|
+
m.metadata AS m_metadata, m.external_id AS m_external_id,
|
|
2663
|
+
m.external_ts AS m_external_ts, m.created_at AS m_created_at
|
|
2664
|
+
FROM inbox i
|
|
2665
|
+
JOIN messages m ON m.id = i.message_id
|
|
2666
|
+
WHERE i.agent_id = ? AND i.delivered = 0
|
|
2667
|
+
ORDER BY i.created_at ASC
|
|
2668
|
+
LIMIT ?`)
|
|
2669
|
+
.all(agentId, limit);
|
|
2670
|
+
return rows.map((r) => ({
|
|
2671
|
+
entry: {
|
|
2672
|
+
id: r.i_id,
|
|
2673
|
+
agentId: r.i_agent_id,
|
|
2674
|
+
messageId: r.i_message_id,
|
|
2675
|
+
delivered: r.i_delivered === 1,
|
|
2676
|
+
readAt: r.i_read_at,
|
|
2677
|
+
createdAt: r.i_created_at,
|
|
2678
|
+
},
|
|
2679
|
+
message: {
|
|
2680
|
+
id: r.m_id,
|
|
2681
|
+
threadId: r.m_thread_id,
|
|
2682
|
+
source: r.m_source,
|
|
2683
|
+
direction: r.m_direction,
|
|
2684
|
+
sender: r.m_sender,
|
|
2685
|
+
body: r.m_body,
|
|
2686
|
+
metadata: r.m_metadata ? JSON.parse(r.m_metadata) : null,
|
|
2687
|
+
...(r.m_external_id ? { externalId: r.m_external_id } : {}),
|
|
2688
|
+
...(r.m_external_ts ? { externalTs: r.m_external_ts } : {}),
|
|
2689
|
+
createdAt: r.m_created_at,
|
|
2690
|
+
},
|
|
2691
|
+
}));
|
|
2692
|
+
}
|
|
2693
|
+
readInbox(agentId, options = {}) {
|
|
2694
|
+
this.dropStaleTransportInboxRows(agentId);
|
|
2695
|
+
const db = this.getDb();
|
|
2696
|
+
const unreadOnly = options.unreadOnly ?? true;
|
|
2697
|
+
const markRead = options.markRead ?? true;
|
|
2698
|
+
const limit = Math.min(Math.max(Math.trunc(options.limit ?? 20), 1), 100);
|
|
2699
|
+
const threadId = options.threadId?.trim();
|
|
2700
|
+
const unreadCountBefore = this.getUnreadInboxCount(agentId);
|
|
2701
|
+
const clauses = ["i.agent_id = ?"];
|
|
2702
|
+
const values = [agentId];
|
|
2703
|
+
if (unreadOnly) {
|
|
2704
|
+
clauses.push("i.read_at IS NULL");
|
|
2705
|
+
}
|
|
2706
|
+
if (threadId) {
|
|
2707
|
+
clauses.push("m.thread_id = ?");
|
|
2708
|
+
values.push(threadId);
|
|
2709
|
+
}
|
|
2710
|
+
const order = unreadOnly ? "m.created_at ASC, i.id ASC" : "m.created_at DESC, i.id DESC";
|
|
2711
|
+
const limitClause = unreadOnly ? "" : "\n LIMIT ?";
|
|
2712
|
+
const queryValues = unreadOnly ? values : [...values, limit];
|
|
2713
|
+
const rows = db
|
|
2714
|
+
.prepare(`SELECT
|
|
2715
|
+
i.id AS i_id, i.agent_id AS i_agent_id, i.message_id AS i_message_id,
|
|
2716
|
+
i.delivered AS i_delivered, i.read_at AS i_read_at, i.created_at AS i_created_at,
|
|
2717
|
+
m.id AS m_id, m.thread_id AS m_thread_id, m.source AS m_source,
|
|
2718
|
+
m.direction AS m_direction, m.sender AS m_sender, m.body AS m_body,
|
|
2719
|
+
m.metadata AS m_metadata, m.external_id AS m_external_id,
|
|
2720
|
+
m.external_ts AS m_external_ts, m.created_at AS m_created_at
|
|
2721
|
+
FROM inbox i
|
|
2722
|
+
JOIN messages m ON m.id = i.message_id
|
|
2723
|
+
WHERE ${clauses.join(" AND ")}
|
|
2724
|
+
ORDER BY ${order}${limitClause}`)
|
|
2725
|
+
.all(...queryValues);
|
|
2726
|
+
const orderedRows = unreadOnly ? rows : rows.reverse();
|
|
2727
|
+
const messages = orderedRows.map((r) => ({
|
|
2728
|
+
entry: {
|
|
2729
|
+
id: r.i_id,
|
|
2730
|
+
agentId: r.i_agent_id,
|
|
2731
|
+
messageId: r.i_message_id,
|
|
2732
|
+
delivered: r.i_delivered === 1,
|
|
2733
|
+
readAt: r.i_read_at,
|
|
2734
|
+
createdAt: r.i_created_at,
|
|
2735
|
+
},
|
|
2736
|
+
message: {
|
|
2737
|
+
id: r.m_id,
|
|
2738
|
+
threadId: r.m_thread_id,
|
|
2739
|
+
source: r.m_source,
|
|
2740
|
+
direction: r.m_direction,
|
|
2741
|
+
sender: r.m_sender,
|
|
2742
|
+
body: r.m_body,
|
|
2743
|
+
metadata: r.m_metadata ? JSON.parse(r.m_metadata) : null,
|
|
2744
|
+
...(r.m_external_id ? { externalId: r.m_external_id } : {}),
|
|
2745
|
+
...(r.m_external_ts ? { externalTs: r.m_external_ts } : {}),
|
|
2746
|
+
createdAt: r.m_created_at,
|
|
2747
|
+
},
|
|
2748
|
+
}));
|
|
2749
|
+
const prioritizedMessages = unreadOnly
|
|
2750
|
+
? messages
|
|
2751
|
+
.map((item, index) => ({
|
|
2752
|
+
item,
|
|
2753
|
+
index,
|
|
2754
|
+
mailClass: classifyPinetMail({
|
|
2755
|
+
source: item.message.source,
|
|
2756
|
+
threadId: item.message.threadId,
|
|
2757
|
+
sender: item.message.sender,
|
|
2758
|
+
body: item.message.body,
|
|
2759
|
+
metadata: item.message.metadata,
|
|
2760
|
+
}).class,
|
|
2761
|
+
}))
|
|
2762
|
+
.sort((a, b) => {
|
|
2763
|
+
const classOrder = comparePinetMailClassPriority(a.mailClass, b.mailClass);
|
|
2764
|
+
if (classOrder !== 0)
|
|
2765
|
+
return classOrder;
|
|
2766
|
+
const createdOrder = a.item.message.createdAt.localeCompare(b.item.message.createdAt);
|
|
2767
|
+
if (createdOrder !== 0)
|
|
2768
|
+
return createdOrder;
|
|
2769
|
+
return a.index - b.index;
|
|
2770
|
+
})
|
|
2771
|
+
.slice(0, limit)
|
|
2772
|
+
.map(({ item }) => item)
|
|
2773
|
+
: messages;
|
|
2774
|
+
const markedReadIds = prioritizedMessages
|
|
2775
|
+
.filter((item) => item.entry.readAt === null)
|
|
2776
|
+
.map((item) => item.entry.id);
|
|
2777
|
+
if (markRead && markedReadIds.length > 0) {
|
|
2778
|
+
this.markRead(markedReadIds, agentId);
|
|
2779
|
+
const readAt = new Date().toISOString();
|
|
2780
|
+
for (const item of prioritizedMessages) {
|
|
2781
|
+
if (markedReadIds.includes(item.entry.id)) {
|
|
2782
|
+
item.entry.readAt = readAt;
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
}
|
|
2786
|
+
const unreadCountAfter = this.getUnreadInboxCount(agentId);
|
|
2787
|
+
const unreadThreads = this.getUnreadThreadSummary(agentId);
|
|
2788
|
+
return {
|
|
2789
|
+
messages: prioritizedMessages,
|
|
2790
|
+
unreadCountBefore,
|
|
2791
|
+
unreadCountAfter,
|
|
2792
|
+
unreadThreads,
|
|
2793
|
+
markedReadIds: markRead ? markedReadIds : [],
|
|
2794
|
+
};
|
|
2795
|
+
}
|
|
2796
|
+
getUnreadInboxCount(agentId) {
|
|
2797
|
+
this.dropStaleTransportInboxRows(agentId);
|
|
2798
|
+
const db = this.getDb();
|
|
2799
|
+
const row = db
|
|
2800
|
+
.prepare("SELECT COUNT(*) AS count FROM inbox WHERE agent_id = ? AND read_at IS NULL")
|
|
2801
|
+
.get(agentId);
|
|
2802
|
+
return Number(row?.count ?? 0);
|
|
2803
|
+
}
|
|
2804
|
+
getUnreadThreadSummary(agentId, limit = 20) {
|
|
2805
|
+
this.dropStaleTransportInboxRows(agentId);
|
|
2806
|
+
const db = this.getDb();
|
|
2807
|
+
const clampedLimit = Math.min(Math.max(Math.trunc(limit), 1), 100);
|
|
2808
|
+
const rows = db
|
|
2809
|
+
.prepare(`SELECT
|
|
2810
|
+
m.thread_id AS thread_id,
|
|
2811
|
+
m.source AS source,
|
|
2812
|
+
COALESCE(t.channel, '') AS channel,
|
|
2813
|
+
m.id AS message_id,
|
|
2814
|
+
m.sender AS sender,
|
|
2815
|
+
m.body AS body,
|
|
2816
|
+
m.metadata AS metadata,
|
|
2817
|
+
m.created_at AS created_at
|
|
2818
|
+
FROM inbox i
|
|
2819
|
+
JOIN messages m ON m.id = i.message_id
|
|
2820
|
+
LEFT JOIN threads t ON t.thread_id = m.thread_id
|
|
2821
|
+
WHERE i.agent_id = ? AND i.read_at IS NULL
|
|
2822
|
+
ORDER BY m.created_at DESC, m.id DESC`)
|
|
2823
|
+
.all(agentId);
|
|
2824
|
+
const summaries = new Map();
|
|
2825
|
+
for (const row of rows) {
|
|
2826
|
+
const key = `${row.source}\u0000${row.thread_id}\u0000${row.channel}`;
|
|
2827
|
+
const metadata = row.metadata ? parseJsonMetadata(row.metadata) : null;
|
|
2828
|
+
const mailClass = classifyPinetMail({
|
|
2829
|
+
source: row.source,
|
|
2830
|
+
threadId: row.thread_id,
|
|
2831
|
+
sender: row.sender,
|
|
2832
|
+
body: row.body,
|
|
2833
|
+
metadata,
|
|
2834
|
+
}).class;
|
|
2835
|
+
const existing = summaries.get(key);
|
|
2836
|
+
if (!existing) {
|
|
2837
|
+
const counts = emptyMailClassCounts();
|
|
2838
|
+
counts[mailClass] = 1;
|
|
2839
|
+
summaries.set(key, {
|
|
2840
|
+
threadId: row.thread_id,
|
|
2841
|
+
source: row.source,
|
|
2842
|
+
channel: row.channel,
|
|
2843
|
+
unreadCount: 1,
|
|
2844
|
+
latestMessageId: row.message_id,
|
|
2845
|
+
latestAt: row.created_at,
|
|
2846
|
+
highestMailClass: mailClass,
|
|
2847
|
+
mailClassCounts: counts,
|
|
2848
|
+
});
|
|
2849
|
+
continue;
|
|
2850
|
+
}
|
|
2851
|
+
existing.unreadCount += 1;
|
|
2852
|
+
existing.mailClassCounts[mailClass] += 1;
|
|
2853
|
+
if (comparePinetMailClassPriority(mailClass, existing.highestMailClass) < 0) {
|
|
2854
|
+
existing.highestMailClass = mailClass;
|
|
2855
|
+
}
|
|
2856
|
+
if (row.created_at > existing.latestAt ||
|
|
2857
|
+
(row.created_at === existing.latestAt && row.message_id > existing.latestMessageId)) {
|
|
2858
|
+
existing.latestAt = row.created_at;
|
|
2859
|
+
existing.latestMessageId = row.message_id;
|
|
2860
|
+
}
|
|
2861
|
+
}
|
|
2862
|
+
return Array.from(summaries.values())
|
|
2863
|
+
.sort((a, b) => {
|
|
2864
|
+
const classOrder = comparePinetMailClassPriority(a.highestMailClass, b.highestMailClass);
|
|
2865
|
+
if (classOrder !== 0)
|
|
2866
|
+
return classOrder;
|
|
2867
|
+
const latestOrder = b.latestAt.localeCompare(a.latestAt);
|
|
2868
|
+
if (latestOrder !== 0)
|
|
2869
|
+
return latestOrder;
|
|
2870
|
+
return b.latestMessageId - a.latestMessageId;
|
|
2871
|
+
})
|
|
2872
|
+
.slice(0, clampedLimit);
|
|
2873
|
+
}
|
|
2874
|
+
markRead(inboxIds, agentId) {
|
|
2875
|
+
if (inboxIds.length === 0)
|
|
2876
|
+
return;
|
|
2877
|
+
const db = this.getDb();
|
|
2878
|
+
const now = new Date().toISOString();
|
|
2879
|
+
const stmt = db.prepare("UPDATE inbox SET read_at = COALESCE(read_at, ?) WHERE id = ? AND agent_id = ?");
|
|
2880
|
+
for (const id of inboxIds) {
|
|
2881
|
+
stmt.run(now, id, agentId);
|
|
2882
|
+
}
|
|
2883
|
+
}
|
|
2884
|
+
getMessagesByIds(messageIds) {
|
|
2885
|
+
if (messageIds.length === 0) {
|
|
2886
|
+
return [];
|
|
2887
|
+
}
|
|
2888
|
+
const db = this.getDb();
|
|
2889
|
+
const placeholders = messageIds.map(() => "?").join(", ");
|
|
2890
|
+
const rows = db
|
|
2891
|
+
.prepare(`SELECT id, thread_id, source, direction, sender, body, metadata, external_id, external_ts, created_at
|
|
2892
|
+
FROM messages
|
|
2893
|
+
WHERE id IN (${placeholders})
|
|
2894
|
+
ORDER BY id ASC`)
|
|
2895
|
+
.all(...messageIds);
|
|
2896
|
+
return rows.map(rowToBrokerMessage);
|
|
2897
|
+
}
|
|
2898
|
+
markDelivered(inboxIds, agentId) {
|
|
2899
|
+
if (inboxIds.length === 0)
|
|
2900
|
+
return;
|
|
2901
|
+
const db = this.getDb();
|
|
2902
|
+
const lookup = db.prepare("SELECT message_id, agent_id FROM inbox WHERE id = ?");
|
|
2903
|
+
if (agentId) {
|
|
2904
|
+
const stmt = db.prepare("UPDATE inbox SET delivered = 1 WHERE id = ? AND agent_id = ?");
|
|
2905
|
+
for (const id of inboxIds) {
|
|
2906
|
+
const result = stmt.run(id, agentId);
|
|
2907
|
+
if (Number(result.changes ?? 0) === 0)
|
|
2908
|
+
continue;
|
|
2909
|
+
const row = lookup.get(id);
|
|
2910
|
+
if (row) {
|
|
2911
|
+
this.completeTargetedBacklogAssignment(row.message_id, row.agent_id);
|
|
2912
|
+
}
|
|
2913
|
+
}
|
|
2914
|
+
return;
|
|
2915
|
+
}
|
|
2916
|
+
const stmt = db.prepare("UPDATE inbox SET delivered = 1 WHERE id = ?");
|
|
2917
|
+
for (const id of inboxIds) {
|
|
2918
|
+
const result = stmt.run(id);
|
|
2919
|
+
if (Number(result.changes ?? 0) === 0)
|
|
2920
|
+
continue;
|
|
2921
|
+
const row = lookup.get(id);
|
|
2922
|
+
if (row) {
|
|
2923
|
+
this.completeTargetedBacklogAssignment(row.message_id, row.agent_id);
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
}
|
|
2927
|
+
/** Mark all undelivered inbox rows for a given message+agent as delivered. */
|
|
2928
|
+
markDeliveredByMessageId(messageId, agentId) {
|
|
2929
|
+
const db = this.getDb();
|
|
2930
|
+
const result = db
|
|
2931
|
+
.prepare("UPDATE inbox SET delivered = 1 WHERE message_id = ? AND agent_id = ? AND delivered = 0")
|
|
2932
|
+
.run(messageId, agentId);
|
|
2933
|
+
if (Number(result.changes ?? 0) > 0) {
|
|
2934
|
+
this.completeTargetedBacklogAssignment(messageId, agentId);
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
completeTargetedBacklogAssignment(messageId, agentId) {
|
|
2938
|
+
const db = this.getDb();
|
|
2939
|
+
db.prepare(`DELETE FROM unrouted_backlog
|
|
2940
|
+
WHERE message_id = ?
|
|
2941
|
+
AND status = 'assigned'
|
|
2942
|
+
AND preferred_agent_id IS NOT NULL
|
|
2943
|
+
AND assigned_agent_id = ?`).run(messageId, agentId);
|
|
2944
|
+
}
|
|
2945
|
+
// ─── Internal ────────────────────────────────────────
|
|
2946
|
+
requeueUndeliveredMessagesInternal(agentId, reason = "agent_disconnected") {
|
|
2947
|
+
const db = this.getDb();
|
|
2948
|
+
const rows = db
|
|
2949
|
+
.prepare(`SELECT
|
|
2950
|
+
i.id AS inbox_id,
|
|
2951
|
+
i.agent_id AS target_agent_id,
|
|
2952
|
+
m.id AS message_id,
|
|
2953
|
+
m.thread_id AS thread_id,
|
|
2954
|
+
m.source AS source,
|
|
2955
|
+
m.metadata AS metadata
|
|
2956
|
+
FROM inbox i
|
|
2957
|
+
JOIN messages m ON m.id = i.message_id
|
|
2958
|
+
WHERE i.agent_id = ?
|
|
2959
|
+
AND i.delivered = 0
|
|
2960
|
+
AND m.direction = 'inbound'`)
|
|
2961
|
+
.all(agentId);
|
|
2962
|
+
if (rows.length === 0) {
|
|
2963
|
+
return 0;
|
|
2964
|
+
}
|
|
2965
|
+
const markDelivered = db.prepare("UPDATE inbox SET delivered = 1 WHERE id = ?");
|
|
2966
|
+
for (const row of rows) {
|
|
2967
|
+
const metadata = row.metadata ? JSON.parse(row.metadata) : {};
|
|
2968
|
+
const channel = typeof metadata.channel === "string" ? metadata.channel : "";
|
|
2969
|
+
const preferredAgentId = row.source === "agent" ? row.target_agent_id : null;
|
|
2970
|
+
this.upsertBacklogEntry(row.message_id, row.thread_id, channel, reason, "pending", preferredAgentId, null);
|
|
2971
|
+
markDelivered.run(row.inbox_id);
|
|
2972
|
+
}
|
|
2973
|
+
return rows.length;
|
|
2974
|
+
}
|
|
2975
|
+
getBacklogById(id) {
|
|
2976
|
+
const db = this.getDb();
|
|
2977
|
+
const row = db.prepare("SELECT * FROM unrouted_backlog WHERE id = ?").get(id);
|
|
2978
|
+
return row ? rowToBacklog(row) : null;
|
|
2979
|
+
}
|
|
2980
|
+
getBacklogByMessageId(messageId) {
|
|
2981
|
+
const db = this.getDb();
|
|
2982
|
+
const row = db.prepare("SELECT * FROM unrouted_backlog WHERE message_id = ?").get(messageId);
|
|
2983
|
+
return row ? rowToBacklog(row) : null;
|
|
2984
|
+
}
|
|
2985
|
+
upsertBacklogEntry(messageId, threadId, channel, reason, status, preferredAgentId, assignedAgentId) {
|
|
2986
|
+
const db = this.getDb();
|
|
2987
|
+
const now = new Date().toISOString();
|
|
2988
|
+
db.prepare(`INSERT INTO unrouted_backlog (
|
|
2989
|
+
thread_id,
|
|
2990
|
+
channel,
|
|
2991
|
+
message_id,
|
|
2992
|
+
reason,
|
|
2993
|
+
status,
|
|
2994
|
+
preferred_agent_id,
|
|
2995
|
+
assigned_agent_id,
|
|
2996
|
+
attempt_count,
|
|
2997
|
+
last_attempt_at,
|
|
2998
|
+
created_at,
|
|
2999
|
+
updated_at
|
|
3000
|
+
)
|
|
3001
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?)
|
|
3002
|
+
ON CONFLICT(message_id) DO UPDATE SET
|
|
3003
|
+
thread_id = excluded.thread_id,
|
|
3004
|
+
channel = excluded.channel,
|
|
3005
|
+
reason = excluded.reason,
|
|
3006
|
+
status = excluded.status,
|
|
3007
|
+
preferred_agent_id = excluded.preferred_agent_id,
|
|
3008
|
+
assigned_agent_id = excluded.assigned_agent_id,
|
|
3009
|
+
updated_at = excluded.updated_at`).run(threadId, channel, messageId, reason, status, preferredAgentId, assignedAgentId, now, now);
|
|
3010
|
+
const row = db.prepare("SELECT * FROM unrouted_backlog WHERE message_id = ?").get(messageId);
|
|
3011
|
+
if (!row) {
|
|
3012
|
+
throw new Error(`Failed to upsert backlog entry for message ${messageId}`);
|
|
3013
|
+
}
|
|
3014
|
+
return rowToBacklog(row);
|
|
3015
|
+
}
|
|
3016
|
+
withTransaction(operation) {
|
|
3017
|
+
const db = this.getDb();
|
|
3018
|
+
db.exec("BEGIN IMMEDIATE");
|
|
3019
|
+
try {
|
|
3020
|
+
const result = operation();
|
|
3021
|
+
db.exec("COMMIT");
|
|
3022
|
+
return result;
|
|
3023
|
+
}
|
|
3024
|
+
catch (err) {
|
|
3025
|
+
try {
|
|
3026
|
+
db.exec("ROLLBACK");
|
|
3027
|
+
}
|
|
3028
|
+
catch {
|
|
3029
|
+
/* best effort */
|
|
3030
|
+
}
|
|
3031
|
+
throw err;
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
openAndMigrate() {
|
|
3035
|
+
const db = this.openDatabase();
|
|
3036
|
+
this.db = db;
|
|
3037
|
+
runSchemaMigrations(db);
|
|
3038
|
+
this.ensureRequiredAgentLifecycleColumns(db);
|
|
3039
|
+
}
|
|
3040
|
+
openDatabase() {
|
|
3041
|
+
const db = new DatabaseSync(this.dbPath, { timeout: 5000 });
|
|
3042
|
+
const journalMode = db.prepare("PRAGMA journal_mode=WAL").get();
|
|
3043
|
+
if (!isSqliteWalEnabled(journalMode)) {
|
|
3044
|
+
console.warn(buildSqliteWalFallbackWarning("BrokerDB", journalMode));
|
|
3045
|
+
}
|
|
3046
|
+
db.exec("PRAGMA busy_timeout=5000");
|
|
3047
|
+
return db;
|
|
3048
|
+
}
|
|
3049
|
+
resetDatabaseFiles() {
|
|
3050
|
+
this.close();
|
|
3051
|
+
for (const file of [this.dbPath, `${this.dbPath}-wal`, `${this.dbPath}-shm`]) {
|
|
3052
|
+
try {
|
|
3053
|
+
fs.rmSync(file, { force: true });
|
|
3054
|
+
}
|
|
3055
|
+
catch {
|
|
3056
|
+
/* best effort */
|
|
3057
|
+
}
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
getMissingRequiredAgentLifecycleColumns(db) {
|
|
3061
|
+
const columns = getTableColumns(db, "agents");
|
|
3062
|
+
return REQUIRED_AGENT_LIFECYCLE_COLUMNS.filter((column) => !columns.has(column));
|
|
3063
|
+
}
|
|
3064
|
+
ensureRequiredAgentLifecycleColumns(db) {
|
|
3065
|
+
const missingColumns = this.getMissingRequiredAgentLifecycleColumns(db);
|
|
3066
|
+
if (missingColumns.length > 0) {
|
|
3067
|
+
throw new Error(`agents table missing required columns: ${missingColumns.join(", ")}`);
|
|
3068
|
+
}
|
|
3069
|
+
}
|
|
3070
|
+
getDb() {
|
|
3071
|
+
if (!this.db) {
|
|
3072
|
+
throw new Error("BrokerDB not initialized — call initialize() first");
|
|
3073
|
+
}
|
|
3074
|
+
return this.db;
|
|
3075
|
+
}
|
|
3076
|
+
}
|