@controlflow-ai/daemon 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.
Files changed (67) hide show
  1. package/README.md +360 -0
  2. package/bin/console.js +2 -0
  3. package/bin/daemon.js +2 -0
  4. package/bin/pal.js +2 -0
  5. package/bin/server.js +2 -0
  6. package/package.json +31 -0
  7. package/src/agent-runtime.ts +285 -0
  8. package/src/app.ts +745 -0
  9. package/src/args.ts +54 -0
  10. package/src/artifacts.ts +85 -0
  11. package/src/cli.ts +284 -0
  12. package/src/client.ts +310 -0
  13. package/src/coco.ts +52 -0
  14. package/src/codex.ts +41 -0
  15. package/src/coding-agent-runtime.ts +20 -0
  16. package/src/config.ts +106 -0
  17. package/src/console.ts +349 -0
  18. package/src/daemon-client.ts +91 -0
  19. package/src/daemon.ts +580 -0
  20. package/src/db.ts +2830 -0
  21. package/src/failure-message.ts +17 -0
  22. package/src/format.ts +13 -0
  23. package/src/http.ts +55 -0
  24. package/src/lark/agent-runtime.ts +142 -0
  25. package/src/lark/cli.ts +549 -0
  26. package/src/lark/credentials.ts +105 -0
  27. package/src/lark/daemon-integration.ts +108 -0
  28. package/src/lark/dispatcher.ts +374 -0
  29. package/src/lark/event-router.ts +329 -0
  30. package/src/lark/inbound-events.ts +131 -0
  31. package/src/lark/server-integration.ts +445 -0
  32. package/src/lark/setup.ts +326 -0
  33. package/src/lark/ws-daemon.ts +224 -0
  34. package/src/lark-fixture-diagnostics.ts +56 -0
  35. package/src/lark-fixture.ts +277 -0
  36. package/src/local-api.ts +155 -0
  37. package/src/local-auth.ts +45 -0
  38. package/src/migrations/001_initial.ts +61 -0
  39. package/src/migrations/002_daemon_deliveries.ts +52 -0
  40. package/src/migrations/003_sessions_runs.ts +49 -0
  41. package/src/migrations/004_message_idempotency.ts +21 -0
  42. package/src/migrations/005_artifacts.ts +24 -0
  43. package/src/migrations/006_lark_channel_foundation.ts +119 -0
  44. package/src/migrations/007_agents_a0.ts +17 -0
  45. package/src/migrations/008_b0_chat_history.ts +31 -0
  46. package/src/migrations/009_b0_transcript_ingest_seq.ts +35 -0
  47. package/src/migrations/010_b0_transcript_shadow_external_ids.ts +32 -0
  48. package/src/migrations/011_b0_channel_conversation_audit_only.ts +27 -0
  49. package/src/migrations/012_b0_cross_conversation_invariant.ts +45 -0
  50. package/src/migrations/013_b1_0_eng_inbound_raw_events.ts +56 -0
  51. package/src/migrations/014_agents_runtime.ts +10 -0
  52. package/src/migrations/015_agent_runtime_sessions.ts +15 -0
  53. package/src/migrations/016_room_participants.ts +27 -0
  54. package/src/migrations/017_unified_room_delivery.ts +203 -0
  55. package/src/migrations/018_room_display_names.ts +36 -0
  56. package/src/migrations/019_computer_connections.ts +63 -0
  57. package/src/migrations/020_computer_agent_assignments.ts +20 -0
  58. package/src/migrations/021_provider_identity_bindings.ts +32 -0
  59. package/src/migrations.ts +85 -0
  60. package/src/neeko.ts +23 -0
  61. package/src/provider-identity.ts +40 -0
  62. package/src/runtime-registry.ts +41 -0
  63. package/src/server-auth.ts +13 -0
  64. package/src/server.ts +63 -0
  65. package/src/token-file.ts +57 -0
  66. package/src/types.ts +408 -0
  67. package/src/web.ts +565 -0
package/src/db.ts ADDED
@@ -0,0 +1,2830 @@
1
+ import { Database, type SQLQueryBindings } from 'bun:sqlite';
2
+ import { createHash, randomBytes } from 'node:crypto';
3
+ import { ensureParentDir } from './config.js';
4
+ import { runMigrations } from './migrations.js';
5
+ import { artifactExpiry, generateArtifactToken, hashArtifactToken, validateArtifactContent } from './artifacts.js';
6
+ import { palIdentityHandle } from './provider-identity.js';
7
+ import type { AgentDefinition, AgentRun, AgentSession, AgentValidationResult, Artifact, ArtifactMetadata, ChannelAccount, ChannelConversation, ChannelMessageMapping, ChannelOutboxRecord, Chat, ChatKind, Computer, ComputerAgentAssignment, ComputerConnection, DaemonInstance, LarkGroupRoomMapping, LockProviderEvidence, LockTranscriptMessage, Message, MessageDelivery, MessageType, AgentRoomSubscription, AgentRoomSubscriptionMode, PendingInboundEvent, ProvisionedComputer, ProviderExternalType, ProviderIdentityBinding, PalIdentity, RoomChannel, RoomParticipant, RoomParticipantKind, RoomParticipantSource, RoomProvider, RunAction, RunStatus, TranscriptReadModel, WorkbenchArtifact } from './types.js';
8
+
9
+ export interface CreateMessageInput {
10
+ chatId?: string;
11
+ chatName?: string;
12
+ parentId?: number;
13
+ sender: string;
14
+ recipient?: string | null;
15
+ content: string;
16
+ type?: MessageType;
17
+ idempotencyKey?: string | null;
18
+ channelId?: string | null;
19
+ provider?: RoomProvider;
20
+ mentions?: string[];
21
+ }
22
+
23
+ export interface ListMessagesInput {
24
+ chatId?: string;
25
+ chatName?: string;
26
+ parentId?: number | null;
27
+ after?: number;
28
+ limit?: number;
29
+ q?: string;
30
+ }
31
+
32
+ export interface StartRunInput {
33
+ messageId: number;
34
+ agent: string;
35
+ cwd: string;
36
+ attempt: number;
37
+ pid?: number | null;
38
+ sessionId?: string | null;
39
+ triggerMessageId?: number | null;
40
+ daemonId?: string | null;
41
+ connectionId?: string | null;
42
+ computerId?: string | null;
43
+ runtimeProvider?: string | null;
44
+ runtimeInvocationId?: string | null;
45
+ deliveryId?: string | null;
46
+ }
47
+
48
+ export interface FinishRunInput {
49
+ status: RunStatus;
50
+ exitCode?: number | null;
51
+ output?: string;
52
+ }
53
+
54
+ export interface RegisterDaemonInput {
55
+ id?: string;
56
+ name: string;
57
+ computerId?: string | null;
58
+ connectionId?: string | null;
59
+ host?: string;
60
+ localUrl?: string;
61
+ serverUrl?: string;
62
+ agents?: Array<{ agent: string; cwd?: string; capabilities?: Record<string, unknown> }>;
63
+ }
64
+
65
+ export interface ComputerAuthInput {
66
+ computerId: string;
67
+ connectionId: string;
68
+ token: string;
69
+ }
70
+
71
+ export interface ConnectComputerInput {
72
+ computerId?: string;
73
+ secret?: string;
74
+ apiKey?: string;
75
+ name?: string;
76
+ host?: string;
77
+ localUrl?: string;
78
+ serverUrl?: string;
79
+ agents?: RegisterDaemonInput['agents'];
80
+ }
81
+
82
+ export interface ConnectComputerResult {
83
+ computer: Computer;
84
+ connection: ComputerConnection;
85
+ token: string;
86
+ daemon: DaemonInstance;
87
+ agents: ComputerAgentAssignment[];
88
+ }
89
+
90
+ export interface ProvisionComputerInput {
91
+ name?: string;
92
+ serverUrl?: string;
93
+ packageName?: string;
94
+ }
95
+
96
+ export interface CreateDeliveryInput {
97
+ messageId: number;
98
+ agent: string;
99
+ }
100
+
101
+ export interface ClaimDeliveryInput {
102
+ daemonId: string;
103
+ connectionId?: string | null;
104
+ computerId?: string | null;
105
+ leaseMs?: number;
106
+ }
107
+
108
+ export interface FinishDeliveryInput {
109
+ daemonId: string;
110
+ connectionId?: string | null;
111
+ claimToken: string;
112
+ runId?: string;
113
+ error?: string;
114
+ }
115
+
116
+ export interface CreateArtifactInput {
117
+ title?: string;
118
+ filename?: string;
119
+ mimeType: string;
120
+ content: Uint8Array;
121
+ ttlSeconds?: number;
122
+ }
123
+
124
+ export interface RegisterChannelAccountInput {
125
+ name: string;
126
+ appId: string;
127
+ botOpenId?: string | null;
128
+ agent?: string | null;
129
+ }
130
+
131
+ export interface ResolveChannelConversationInput {
132
+ accountId: string;
133
+ chatName: string;
134
+ conversationKey: string;
135
+ externalChatId: string;
136
+ externalRootId?: string | null;
137
+ externalThreadId?: string | null;
138
+ scope: ChannelConversation['scope'];
139
+ chatType: ChannelConversation['chat_type'];
140
+ auditOnly?: boolean;
141
+ }
142
+
143
+ export interface CreateTranscriptInput {
144
+ conversationId: string;
145
+ lockMessageId?: number | null;
146
+ direction: LockTranscriptMessage['direction'];
147
+ senderType: LockTranscriptMessage['sender_type'];
148
+ senderId: string;
149
+ recipientRef?: string | null;
150
+ content: string;
151
+ contentType?: string;
152
+ sourceType: string;
153
+ sourceUniqueKey: string;
154
+ mentions?: string[];
155
+ replyToTranscriptId?: string | null;
156
+ quoteRootTranscriptId?: string | null;
157
+ quoteMessageTranscriptId?: string | null;
158
+ replyToExternalMessageId?: string | null;
159
+ quoteRootExternalMessageId?: string | null;
160
+ quoteMessageExternalMessageId?: string | null;
161
+ }
162
+
163
+ export interface AttachChannelMessageInput {
164
+ accountId: string;
165
+ conversationId: string;
166
+ lockMessageId?: number | null;
167
+ transcriptMessageId?: string | null;
168
+ direction: ChannelMessageMapping['direction'];
169
+ externalMessageId?: string | null;
170
+ externalChatId: string;
171
+ externalRootId?: string | null;
172
+ externalThreadId?: string | null;
173
+ senderOpenId?: string | null;
174
+ senderType?: string;
175
+ rawType?: string;
176
+ status: ChannelMessageMapping['status'];
177
+ reasonCode?: string | null;
178
+ source?: ChannelMessageMapping['source'];
179
+ admitted?: boolean;
180
+ rawPayloadRedactedJson?: string;
181
+ }
182
+
183
+ export interface IngestAuditEnvelopeInput {
184
+ conversation: Omit<ResolveChannelConversationInput, 'auditOnly'>;
185
+ envelope: AttachChannelMessageInput;
186
+ }
187
+
188
+ export interface IngestCanonicalEnvelopeInput {
189
+ conversation: ResolveChannelConversationInput;
190
+ transcript: Omit<CreateTranscriptInput, 'conversationId'>;
191
+ envelope: Omit<AttachChannelMessageInput, 'conversationId' | 'transcriptMessageId'>;
192
+ }
193
+
194
+ export interface IngestEnvelopeResult {
195
+ conversation: ChannelConversation;
196
+ mapping: ChannelMessageMapping;
197
+ transcript: LockTranscriptMessage | null;
198
+ }
199
+
200
+ export interface EnqueueChannelOutboxInput {
201
+ accountId: string;
202
+ conversationId: string;
203
+ transcriptMessageId: string;
204
+ lockMessageId: number;
205
+ purpose?: string;
206
+ idempotencyKey: string;
207
+ }
208
+
209
+ export interface ListChannelMessagesInput {
210
+ conversationId?: string;
211
+ transcriptMessageId?: string | null;
212
+ status?: ChannelMessageMapping['status'];
213
+ source?: ChannelMessageMapping['source'];
214
+ admitted?: boolean;
215
+ limit?: number;
216
+ }
217
+
218
+ export interface AddProviderEvidenceInput {
219
+ transcriptMessageId: string;
220
+ providerMessageId?: string | null;
221
+ providerEventId?: string | null;
222
+ attemptId?: string | null;
223
+ evidenceType: LockProviderEvidence['evidence_type'];
224
+ receiptState: LockProviderEvidence['receipt_state'];
225
+ errorCode?: string | null;
226
+ errorMessage?: string | null;
227
+ rawPayloadRedactedJson?: string;
228
+ observedAt?: string | null;
229
+ }
230
+
231
+ export interface GetOrCreateProviderIdentityInput {
232
+ provider: Exclude<RoomProvider, 'web'>;
233
+ providerAccountId: string;
234
+ externalType: ProviderExternalType;
235
+ externalId: string;
236
+ displayName?: string | null;
237
+ }
238
+
239
+ export interface BindProviderIdentityInput extends GetOrCreateProviderIdentityInput {
240
+ identityId: string;
241
+ }
242
+
243
+ export interface UpsertRoomParticipantInput {
244
+ roomId: string;
245
+ participantId: string;
246
+ kind: RoomParticipantKind;
247
+ displayName?: string | null;
248
+ source: RoomParticipantSource;
249
+ }
250
+
251
+ const agentFieldAllowlist = new Set(['id', 'agent_key', 'display_name', 'description', 'runtime', 'created_at', 'updated_at']);
252
+ const forbiddenAgentFieldPattern = /provider|adapter|enabled|disabled|status|state|mode|config|capabilities|route|routing|binding|permission|owner|workspace|daemon|delivery|channel|lark|acl|authz/i;
253
+
254
+ export type ChannelMessageConflictCode =
255
+ | 'CHANNEL_MESSAGE_LOCK_BINDING_CONFLICT'
256
+ | 'CHANNEL_MESSAGE_AUDIT_OUTCOME_CONFLICT'
257
+ | 'CHANNEL_MESSAGE_ATTRIBUTES_CONFLICT'
258
+ | 'CHANNEL_MESSAGE_UNIQUE_CONFLICT';
259
+
260
+ export class ChannelMessageConflictError extends Error {
261
+ readonly code: ChannelMessageConflictCode;
262
+ readonly existing: ChannelMessageMapping | null;
263
+ readonly incoming: Record<string, unknown>;
264
+ constructor(code: ChannelMessageConflictCode, message: string, existing: ChannelMessageMapping | null, incoming: Record<string, unknown>) {
265
+ super(message);
266
+ this.name = 'ChannelMessageConflictError';
267
+ this.code = code;
268
+ this.existing = existing;
269
+ this.incoming = incoming;
270
+ }
271
+ }
272
+
273
+ function createChannelMessageConflict(code: ChannelMessageConflictCode, message: string, existing: ChannelMessageMapping | null, incoming: Record<string, unknown>): ChannelMessageConflictError {
274
+ return new ChannelMessageConflictError(code, message, existing, incoming);
275
+ }
276
+
277
+ export class TranscriptCrossConversationReferenceError extends Error {
278
+ readonly code = 'TRANSCRIPT_CROSS_CONVERSATION_REFERENCE' as const;
279
+ readonly column: 'reply_to_transcript_id' | 'quote_root_transcript_id' | 'quote_message_transcript_id';
280
+ readonly transcriptId: string | null;
281
+ readonly referencedTranscriptId: string;
282
+ readonly expectedConversationId: string;
283
+ readonly actualConversationId: string;
284
+ constructor(input: {
285
+ column: TranscriptCrossConversationReferenceError['column'];
286
+ transcriptId: string | null;
287
+ referencedTranscriptId: string;
288
+ expectedConversationId: string;
289
+ actualConversationId: string;
290
+ }) {
291
+ super(`transcript ${input.column} ${input.referencedTranscriptId} lives in conversation ${input.actualConversationId}, not ${input.expectedConversationId}`);
292
+ this.name = 'TranscriptCrossConversationReferenceError';
293
+ this.column = input.column;
294
+ this.transcriptId = input.transcriptId;
295
+ this.referencedTranscriptId = input.referencedTranscriptId;
296
+ this.expectedConversationId = input.expectedConversationId;
297
+ this.actualConversationId = input.actualConversationId;
298
+ }
299
+ }
300
+
301
+ function isUniqueConstraintError(error: unknown): boolean {
302
+ if (!error || typeof error !== 'object') return false;
303
+ const message = String((error as { message?: unknown }).message ?? '');
304
+ return message.includes('UNIQUE constraint failed');
305
+ }
306
+
307
+ function normalizeChatName(name?: string): string {
308
+ const raw = (name ?? 'general').trim();
309
+ const stripped = raw.startsWith('#') ? raw.slice(1) : raw;
310
+ if (!stripped) throw new Error('chat name cannot be empty');
311
+ return stripped;
312
+ }
313
+
314
+ function parseCapabilities(value: unknown): { topics: 'native' | 'unsupported' } {
315
+ if (typeof value !== 'string') return { topics: 'native' };
316
+ try {
317
+ const parsed = JSON.parse(value) as { topics?: unknown };
318
+ return parsed.topics === 'unsupported' ? { topics: 'unsupported' } : { topics: 'native' };
319
+ } catch {
320
+ return { topics: 'native' };
321
+ }
322
+ }
323
+
324
+ function hashSecret(value: string): string {
325
+ return createHash('sha256').update(value).digest('hex');
326
+ }
327
+
328
+ function generateConnectionToken(): string {
329
+ return randomBytes(32).toString('hex');
330
+ }
331
+
332
+ function rowToAgent(row: Record<string, unknown>): AgentDefinition {
333
+ return {
334
+ id: String(row.id),
335
+ agent_key: String(row.agent_key),
336
+ display_name: String(row.display_name),
337
+ description: row.description === null ? null : String(row.description),
338
+ runtime: row.runtime === null ? null : String(row.runtime),
339
+ created_at: String(row.created_at),
340
+ updated_at: String(row.updated_at),
341
+ };
342
+ }
343
+
344
+ function rowToComputerAgentAssignment(row: Record<string, unknown>): ComputerAgentAssignment {
345
+ return {
346
+ agent: String(row.agent),
347
+ display_name: String(row.display_name),
348
+ runtime: row.runtime === null ? null : String(row.runtime),
349
+ computer_id: String(row.computer_id),
350
+ cwd: String(row.cwd ?? ''),
351
+ status: String(row.status) as ComputerAgentAssignment['status'],
352
+ created_at: String(row.created_at),
353
+ updated_at: String(row.updated_at),
354
+ };
355
+ }
356
+
357
+ function rowToMessage(row: Record<string, unknown>): Message {
358
+ return {
359
+ id: Number(row.id),
360
+ chat_id: String(row.chat_id),
361
+ chat_name: String(row.chat_name),
362
+ parent_id: row.parent_id === null ? null : Number(row.parent_id),
363
+ depth: Number(row.depth) as 0 | 1,
364
+ sender: String(row.sender),
365
+ recipient: row.recipient === null ? null : String(row.recipient),
366
+ content: String(row.content),
367
+ type: String(row.type) as MessageType,
368
+ created_at: String(row.created_at),
369
+ idempotency_key: row.idempotency_key === null || row.idempotency_key === undefined ? null : String(row.idempotency_key),
370
+ channel_id: row.channel_id === null || row.channel_id === undefined ? null : String(row.channel_id),
371
+ provider: row.provider === null || row.provider === undefined ? undefined : String(row.provider) as RoomProvider,
372
+ mentions: parseMentionsJson(row.mentions_json),
373
+ };
374
+ }
375
+
376
+ function rowToRun(row: Record<string, unknown>): AgentRun {
377
+ return {
378
+ id: String(row.id),
379
+ message_id: Number(row.message_id),
380
+ agent: String(row.agent),
381
+ status: String(row.status) as RunStatus,
382
+ action: row.action === null ? null : String(row.action) as RunAction,
383
+ attempt: Number(row.attempt),
384
+ pid: row.pid === null ? null : Number(row.pid),
385
+ cwd: String(row.cwd),
386
+ started_at: String(row.started_at),
387
+ updated_at: String(row.updated_at),
388
+ ended_at: row.ended_at === null ? null : String(row.ended_at),
389
+ exit_code: row.exit_code === null ? null : Number(row.exit_code),
390
+ output: String(row.output ?? ''),
391
+ session_id: row.session_id === null || row.session_id === undefined ? null : String(row.session_id),
392
+ trigger_message_id: row.trigger_message_id === null || row.trigger_message_id === undefined ? null : Number(row.trigger_message_id),
393
+ daemon_id: row.daemon_id === null || row.daemon_id === undefined ? null : String(row.daemon_id),
394
+ connection_id: row.connection_id === null || row.connection_id === undefined ? null : String(row.connection_id),
395
+ computer_id: row.computer_id === null || row.computer_id === undefined ? null : String(row.computer_id),
396
+ runtime_provider: row.runtime_provider === null || row.runtime_provider === undefined ? null : String(row.runtime_provider),
397
+ runtime_invocation_id: row.runtime_invocation_id === null || row.runtime_invocation_id === undefined ? null : String(row.runtime_invocation_id),
398
+ delivery_id: row.delivery_id === null || row.delivery_id === undefined ? null : String(row.delivery_id),
399
+ };
400
+ }
401
+
402
+ function rowToSession(row: Record<string, unknown>): AgentSession {
403
+ return {
404
+ id: String(row.id),
405
+ chat_id: String(row.chat_id),
406
+ agent: String(row.agent),
407
+ daemon_id: String(row.daemon_id),
408
+ computer_id: row.computer_id === null || row.computer_id === undefined ? null : String(row.computer_id),
409
+ runtime_provider: row.runtime_provider === null || row.runtime_provider === undefined ? null : String(row.runtime_provider),
410
+ cwd: String(row.cwd),
411
+ session_key: String(row.session_key),
412
+ created_at: String(row.created_at),
413
+ updated_at: String(row.updated_at),
414
+ last_message_id: row.last_message_id === null ? null : Number(row.last_message_id),
415
+ runtime_session_id: row.runtime_session_id === null || row.runtime_session_id === undefined ? null : String(row.runtime_session_id),
416
+ };
417
+ }
418
+
419
+ function rowToDaemon(row: Record<string, unknown>): DaemonInstance {
420
+ return {
421
+ id: String(row.id),
422
+ name: String(row.name),
423
+ host: String(row.host),
424
+ local_url: String(row.local_url),
425
+ server_url: String(row.server_url),
426
+ status: String(row.status) as DaemonInstance['status'],
427
+ created_at: String(row.created_at),
428
+ last_seen_at: String(row.last_seen_at),
429
+ };
430
+ }
431
+
432
+ function rowToComputer(row: Record<string, unknown>): Computer {
433
+ return {
434
+ id: String(row.id),
435
+ name: String(row.name),
436
+ status: String(row.status) as Computer['status'],
437
+ active_connection_id: row.active_connection_id === null || row.active_connection_id === undefined ? null : String(row.active_connection_id),
438
+ last_seen_at: row.last_seen_at === null || row.last_seen_at === undefined ? null : String(row.last_seen_at),
439
+ created_at: String(row.created_at),
440
+ updated_at: String(row.updated_at),
441
+ };
442
+ }
443
+
444
+ function rowToComputerConnection(row: Record<string, unknown>): ComputerConnection {
445
+ return {
446
+ id: String(row.id),
447
+ computer_id: String(row.computer_id),
448
+ epoch: Number(row.epoch),
449
+ status: String(row.status) as ComputerConnection['status'],
450
+ connected_at: String(row.connected_at),
451
+ last_heartbeat_at: String(row.last_heartbeat_at),
452
+ revoked_at: row.revoked_at === null || row.revoked_at === undefined ? null : String(row.revoked_at),
453
+ };
454
+ }
455
+
456
+ function rowToDelivery(row: Record<string, unknown>): MessageDelivery {
457
+ return {
458
+ id: String(row.id),
459
+ message_id: Number(row.message_id),
460
+ chat_id: String(row.chat_id),
461
+ agent: String(row.agent),
462
+ status: String(row.status) as MessageDelivery['status'],
463
+ daemon_id: row.daemon_id === null ? null : String(row.daemon_id),
464
+ connection_id: row.connection_id === null || row.connection_id === undefined ? null : String(row.connection_id),
465
+ claim_token: row.claim_token === null ? null : String(row.claim_token),
466
+ lease_until: row.lease_until === null ? null : String(row.lease_until),
467
+ attempts: Number(row.attempts),
468
+ idempotency_key: String(row.idempotency_key),
469
+ run_id: row.run_id === null ? null : String(row.run_id),
470
+ last_error: String(row.last_error ?? ''),
471
+ created_at: String(row.created_at),
472
+ claimed_at: row.claimed_at === null ? null : String(row.claimed_at),
473
+ acked_at: row.acked_at === null ? null : String(row.acked_at),
474
+ failed_at: row.failed_at === null ? null : String(row.failed_at),
475
+ };
476
+ }
477
+
478
+ function rowToArtifact(row: Record<string, unknown>): Artifact {
479
+ return {
480
+ id: String(row.id),
481
+ token_hash: String(row.token_hash),
482
+ title: String(row.title),
483
+ filename: String(row.filename),
484
+ mime_type: String(row.mime_type),
485
+ size_bytes: Number(row.size_bytes),
486
+ content: row.content as Uint8Array,
487
+ created_at: String(row.created_at),
488
+ expires_at: String(row.expires_at),
489
+ revoked_at: row.revoked_at === null ? null : String(row.revoked_at),
490
+ };
491
+ }
492
+
493
+ function rowToArtifactMetadata(row: Record<string, unknown>): ArtifactMetadata {
494
+ return {
495
+ id: String(row.id),
496
+ title: String(row.title),
497
+ filename: String(row.filename),
498
+ mime_type: String(row.mime_type),
499
+ size_bytes: Number(row.size_bytes),
500
+ created_at: String(row.created_at),
501
+ expires_at: String(row.expires_at),
502
+ revoked_at: row.revoked_at === null ? null : String(row.revoked_at),
503
+ };
504
+ }
505
+
506
+ function rowToWorkbenchArtifact(row: Record<string, unknown>): WorkbenchArtifact {
507
+ return {
508
+ id: String(row.id),
509
+ title: String(row.title),
510
+ filename: String(row.filename),
511
+ mime_type: String(row.mime_type),
512
+ size_bytes: Number(row.size_bytes),
513
+ created_at: String(row.created_at),
514
+ };
515
+ }
516
+
517
+ function rowToChannelAccount(row: Record<string, unknown>): ChannelAccount {
518
+ return {
519
+ id: String(row.id),
520
+ channel: 'lark',
521
+ name: String(row.name),
522
+ app_id: String(row.app_id),
523
+ bot_open_id: row.bot_open_id === null ? null : String(row.bot_open_id),
524
+ agent: row.agent === null || row.agent === undefined ? null : String(row.agent),
525
+ provider_account_id: row.provider_account_id === null || row.provider_account_id === undefined ? null : String(row.provider_account_id),
526
+ status: String(row.status) as ChannelAccount['status'],
527
+ created_at: String(row.created_at),
528
+ updated_at: String(row.updated_at),
529
+ };
530
+ }
531
+
532
+ function rowToPalIdentity(row: Record<string, unknown>): PalIdentity {
533
+ return {
534
+ id: String(row.id),
535
+ kind: String(row.kind) as PalIdentity['kind'],
536
+ display_name: row.display_name === null || row.display_name === undefined ? null : String(row.display_name),
537
+ stable_handle: String(row.stable_handle),
538
+ created_at: String(row.created_at),
539
+ updated_at: String(row.updated_at),
540
+ };
541
+ }
542
+
543
+ function rowToProviderIdentityBinding(row: Record<string, unknown>): ProviderIdentityBinding {
544
+ return {
545
+ id: String(row.id),
546
+ provider: String(row.provider) as ProviderIdentityBinding['provider'],
547
+ provider_account_id: String(row.provider_account_id),
548
+ external_type: String(row.external_type) as ProviderIdentityBinding['external_type'],
549
+ external_id: String(row.external_id),
550
+ identity_id: String(row.identity_id),
551
+ created_at: String(row.created_at),
552
+ updated_at: String(row.updated_at),
553
+ };
554
+ }
555
+
556
+ function rowToRoomParticipant(row: Record<string, unknown>): RoomParticipant {
557
+ return {
558
+ id: String(row.id),
559
+ room_id: String(row.room_id),
560
+ participant_id: String(row.participant_id),
561
+ kind: String(row.kind) as RoomParticipant['kind'],
562
+ display_name: row.display_name === null || row.display_name === undefined ? null : String(row.display_name),
563
+ source: String(row.source) as RoomParticipant['source'],
564
+ last_seen_at: String(row.last_seen_at),
565
+ status: row.status === null || row.status === undefined ? 'active' : String(row.status) as RoomParticipant['status'],
566
+ joined_at: row.joined_at === null || row.joined_at === undefined ? String(row.created_at) : String(row.joined_at),
567
+ delivery_cursor_message_id: row.delivery_cursor_message_id === null || row.delivery_cursor_message_id === undefined ? null : Number(row.delivery_cursor_message_id),
568
+ created_at: String(row.created_at),
569
+ updated_at: String(row.updated_at),
570
+ };
571
+ }
572
+
573
+ function participantSourceRank(source: RoomParticipantSource): number {
574
+ switch (source) {
575
+ case 'local_agent':
576
+ return 4;
577
+ case 'known_bot':
578
+ return 3;
579
+ case 'lark_member_api':
580
+ return 2;
581
+ case 'event':
582
+ return 1;
583
+ }
584
+ }
585
+
586
+
587
+ function rowToAgentRoomSubscription(row: Record<string, unknown>): AgentRoomSubscription {
588
+ return {
589
+ id: String(row.id),
590
+ agent: String(row.agent),
591
+ room_id: String(row.room_id),
592
+ channel_id: row.channel_id === null || row.channel_id === undefined ? null : String(row.channel_id),
593
+ mode: String(row.mode) as AgentRoomSubscriptionMode,
594
+ created_at: String(row.created_at),
595
+ updated_at: String(row.updated_at),
596
+ };
597
+ }
598
+
599
+ function rowToRoomChannel(row: Record<string, unknown>): RoomChannel {
600
+ return {
601
+ id: String(row.id),
602
+ room_id: String(row.room_id),
603
+ kind: 'topic',
604
+ name: String(row.name),
605
+ external_ref: row.external_ref === null || row.external_ref === undefined ? null : String(row.external_ref),
606
+ created_by: row.created_by === null || row.created_by === undefined ? null : String(row.created_by),
607
+ created_at: String(row.created_at),
608
+ };
609
+ }
610
+
611
+ function rowToPendingInboundEvent(row: Record<string, unknown>): PendingInboundEvent {
612
+ return {
613
+ id: String(row.id),
614
+ raw_event_id: String(row.raw_event_id),
615
+ provider: String(row.provider) as PendingInboundEvent['provider'],
616
+ provider_account_id: row.provider_account_id === null || row.provider_account_id === undefined ? null : String(row.provider_account_id),
617
+ reason: String(row.reason),
618
+ retry_count: Number(row.retry_count),
619
+ next_retry_at: row.next_retry_at === null || row.next_retry_at === undefined ? null : String(row.next_retry_at),
620
+ last_error: String(row.last_error ?? ''),
621
+ status: String(row.status) as PendingInboundEvent['status'],
622
+ created_at: String(row.created_at),
623
+ updated_at: String(row.updated_at),
624
+ };
625
+ }
626
+
627
+ function rowToChannelConversation(row: Record<string, unknown>): ChannelConversation {
628
+ return {
629
+ id: String(row.id),
630
+ channel_account_id: String(row.channel_account_id),
631
+ lock_chat_id: String(row.lock_chat_id),
632
+ conversation_key: String(row.conversation_key),
633
+ external_chat_id: String(row.external_chat_id),
634
+ external_root_id: row.external_root_id === null ? null : String(row.external_root_id),
635
+ external_thread_id: row.external_thread_id === null ? null : String(row.external_thread_id),
636
+ scope: String(row.scope) as ChannelConversation['scope'],
637
+ chat_type: String(row.chat_type) as ChannelConversation['chat_type'],
638
+ audit_only: Number(row.audit_only ?? 0) as ChannelConversation['audit_only'],
639
+ created_at: String(row.created_at),
640
+ last_seen_at: String(row.last_seen_at),
641
+ };
642
+ }
643
+
644
+ function rowToTranscript(row: Record<string, unknown>): LockTranscriptMessage {
645
+ return {
646
+ id: String(row.id),
647
+ lock_message_id: row.lock_message_id === null ? null : Number(row.lock_message_id),
648
+ conversation_id: String(row.conversation_id),
649
+ channel_type: String(row.channel_type) as LockTranscriptMessage['channel_type'],
650
+ direction: String(row.direction) as LockTranscriptMessage['direction'],
651
+ sender_type: String(row.sender_type) as LockTranscriptMessage['sender_type'],
652
+ sender_id: String(row.sender_id),
653
+ recipient_ref: row.recipient_ref === null ? null : String(row.recipient_ref),
654
+ content: String(row.content),
655
+ content_type: String(row.content_type),
656
+ source_type: String(row.source_type),
657
+ source_unique_key: String(row.source_unique_key),
658
+ status: String(row.status) as LockTranscriptMessage['status'],
659
+ mentions: parseMentionsJson(row.mentions_json),
660
+ reply_to_transcript_id: row.reply_to_transcript_id === null || row.reply_to_transcript_id === undefined ? null : String(row.reply_to_transcript_id),
661
+ quote_root_transcript_id: row.quote_root_transcript_id === null || row.quote_root_transcript_id === undefined ? null : String(row.quote_root_transcript_id),
662
+ quote_message_transcript_id: row.quote_message_transcript_id === null || row.quote_message_transcript_id === undefined ? null : String(row.quote_message_transcript_id),
663
+ reply_to_external_message_id: row.reply_to_external_message_id === null || row.reply_to_external_message_id === undefined ? null : String(row.reply_to_external_message_id),
664
+ quote_root_external_message_id: row.quote_root_external_message_id === null || row.quote_root_external_message_id === undefined ? null : String(row.quote_root_external_message_id),
665
+ quote_message_external_message_id: row.quote_message_external_message_id === null || row.quote_message_external_message_id === undefined ? null : String(row.quote_message_external_message_id),
666
+ created_at: String(row.created_at),
667
+ recorded_at: String(row.recorded_at),
668
+ };
669
+ }
670
+
671
+ function parseMentionsJson(value: unknown): string[] {
672
+ if (value === null || value === undefined) return [];
673
+ if (typeof value !== 'string') return [];
674
+ try {
675
+ const parsed = JSON.parse(value) as unknown;
676
+ if (!Array.isArray(parsed)) return [];
677
+ return parsed.filter((entry): entry is string => typeof entry === 'string');
678
+ } catch {
679
+ return [];
680
+ }
681
+ }
682
+
683
+ function normalizeMentionsInput(value: string[] | undefined): string[] {
684
+ if (!value || value.length === 0) return [];
685
+ const seen = new Set<string>();
686
+ const result: string[] = [];
687
+ for (const entry of value) {
688
+ if (typeof entry !== 'string') continue;
689
+ const trimmed = entry.trim();
690
+ if (!trimmed || seen.has(trimmed)) continue;
691
+ seen.add(trimmed);
692
+ result.push(trimmed);
693
+ }
694
+ return result;
695
+ }
696
+
697
+ function rowToTranscriptReadModel(row: Record<string, unknown>): TranscriptReadModel {
698
+ return {
699
+ id: String(row.id),
700
+ conversation_id: String(row.conversation_id),
701
+ direction: String(row.direction) as TranscriptReadModel['direction'],
702
+ sender_type: String(row.sender_type) as TranscriptReadModel['sender_type'],
703
+ sender_id: String(row.sender_id),
704
+ recipient_ref: row.recipient_ref === null ? null : String(row.recipient_ref),
705
+ content: String(row.content),
706
+ content_type: String(row.content_type),
707
+ status: String(row.status) as TranscriptReadModel['status'],
708
+ mentions: parseMentionsJson(row.mentions_json),
709
+ reply_to_transcript_id: row.reply_to_transcript_id === null || row.reply_to_transcript_id === undefined ? null : String(row.reply_to_transcript_id),
710
+ quote_root_transcript_id: row.quote_root_transcript_id === null || row.quote_root_transcript_id === undefined ? null : String(row.quote_root_transcript_id),
711
+ quote_message_transcript_id: row.quote_message_transcript_id === null || row.quote_message_transcript_id === undefined ? null : String(row.quote_message_transcript_id),
712
+ created_at: String(row.created_at),
713
+ recorded_at: String(row.recorded_at),
714
+ };
715
+ }
716
+
717
+ function rowToChannelMessage(row: Record<string, unknown>): ChannelMessageMapping {
718
+ return {
719
+ id: String(row.id),
720
+ channel_account_id: String(row.channel_account_id),
721
+ channel_conversation_id: String(row.channel_conversation_id),
722
+ lock_message_id: row.lock_message_id === null ? null : Number(row.lock_message_id),
723
+ transcript_message_id: row.transcript_message_id === null ? null : String(row.transcript_message_id),
724
+ direction: String(row.direction) as ChannelMessageMapping['direction'],
725
+ external_message_id: row.external_message_id === null ? null : String(row.external_message_id),
726
+ external_chat_id: String(row.external_chat_id),
727
+ external_root_id: row.external_root_id === null ? null : String(row.external_root_id),
728
+ external_thread_id: row.external_thread_id === null ? null : String(row.external_thread_id),
729
+ sender_open_id: row.sender_open_id === null ? null : String(row.sender_open_id),
730
+ sender_type: String(row.sender_type),
731
+ raw_type: String(row.raw_type),
732
+ status: String(row.status) as ChannelMessageMapping['status'],
733
+ reason_code: row.reason_code === null ? null : String(row.reason_code),
734
+ source: String(row.source) as ChannelMessageMapping['source'],
735
+ admitted: Number(row.admitted) as ChannelMessageMapping['admitted'],
736
+ raw_payload_redacted_json: String(row.raw_payload_redacted_json),
737
+ created_at: String(row.created_at),
738
+ };
739
+ }
740
+
741
+ function rowToChannelOutbox(row: Record<string, unknown>): ChannelOutboxRecord {
742
+ return {
743
+ id: String(row.id),
744
+ channel_account_id: String(row.channel_account_id),
745
+ channel_conversation_id: String(row.channel_conversation_id),
746
+ transcript_message_id: String(row.transcript_message_id),
747
+ lock_message_id: row.lock_message_id === null ? null : Number(row.lock_message_id),
748
+ purpose: String(row.purpose),
749
+ idempotency_key: String(row.idempotency_key),
750
+ created_at: String(row.created_at),
751
+ };
752
+ }
753
+
754
+ function rowToProviderEvidence(row: Record<string, unknown>): LockProviderEvidence {
755
+ return {
756
+ id: String(row.id),
757
+ transcript_message_id: String(row.transcript_message_id),
758
+ provider: 'lark',
759
+ provider_message_id: row.provider_message_id === null ? null : String(row.provider_message_id),
760
+ provider_event_id: row.provider_event_id === null ? null : String(row.provider_event_id),
761
+ attempt_id: row.attempt_id === null ? null : String(row.attempt_id),
762
+ evidence_type: String(row.evidence_type) as LockProviderEvidence['evidence_type'],
763
+ receipt_state: String(row.receipt_state) as LockProviderEvidence['receipt_state'],
764
+ error_code: row.error_code === null ? null : String(row.error_code),
765
+ error_message: row.error_message === null ? null : String(row.error_message),
766
+ raw_payload_redacted_json: String(row.raw_payload_redacted_json),
767
+ observed_at: row.observed_at === null ? null : String(row.observed_at),
768
+ created_at: String(row.created_at),
769
+ };
770
+ }
771
+
772
+ function limitValue(value: number | undefined, fallback: number): number {
773
+ if (!value || !Number.isFinite(value)) return fallback;
774
+ return Math.min(Math.max(Math.trunc(value), 1), 200);
775
+ }
776
+
777
+ export class MessageStore {
778
+ readonly db: Database;
779
+
780
+ constructor(path: string) {
781
+ ensureParentDir(path);
782
+ this.db = new Database(path);
783
+ this.db.exec('PRAGMA journal_mode = WAL');
784
+ this.db.exec('PRAGMA foreign_keys = ON');
785
+ this.db.exec('PRAGMA busy_timeout = 5000');
786
+ runMigrations(this.db);
787
+ }
788
+
789
+ close(): void {
790
+ this.db.close();
791
+ }
792
+
793
+ getOrCreateChat(name: string, kind: ChatKind = 'group'): Chat {
794
+ const chatName = normalizeChatName(name);
795
+ const existing = this.db.query('SELECT * FROM chat_stats WHERE name = ?').get(chatName) as Chat | null;
796
+ if (existing) return existing;
797
+
798
+ const id = crypto.randomUUID();
799
+ this.db.query("INSERT INTO chats (id, name, kind, provider, capabilities_json) VALUES (?, ?, ?, 'web', ?)").run(id, chatName, kind, kind === 'dm' ? '{"topics":"unsupported"}' : '{"topics":"native"}');
800
+ return this.getChatById(id)!;
801
+ }
802
+
803
+ updateRoomDisplayName(roomId: string, displayName: string | null): Chat {
804
+ const room = this.getChatById(roomId);
805
+ if (!room) throw new Error(`room ${roomId} was not found`);
806
+ const trimmed = displayName?.trim() || null;
807
+ this.db.query('UPDATE chats SET display_name = ? WHERE id = ?').run(trimmed, room.id);
808
+ return this.getChatById(room.id)!;
809
+ }
810
+
811
+ backfillLarkDmDisplayNames(): number {
812
+ const result = this.db.query(`
813
+ UPDATE chats
814
+ SET display_name = (
815
+ SELECT 'DM with ' || rp.display_name
816
+ FROM room_participants rp
817
+ WHERE rp.room_id = chats.id
818
+ AND rp.kind = 'bot'
819
+ AND rp.status = 'active'
820
+ AND rp.display_name IS NOT NULL
821
+ AND trim(rp.display_name) != ''
822
+ ORDER BY datetime(rp.updated_at) DESC, rp.participant_id ASC
823
+ LIMIT 1
824
+ )
825
+ WHERE provider = 'lark'
826
+ AND kind = 'dm'
827
+ AND (display_name IS NULL OR trim(display_name) = '' OR display_name LIKE 'lark:%')
828
+ AND EXISTS (
829
+ SELECT 1
830
+ FROM room_participants rp
831
+ WHERE rp.room_id = chats.id
832
+ AND rp.kind = 'bot'
833
+ AND rp.status = 'active'
834
+ AND rp.display_name IS NOT NULL
835
+ AND trim(rp.display_name) != ''
836
+ )
837
+ `).run();
838
+ return result.changes;
839
+ }
840
+
841
+ getChatById(id: string): Chat | null {
842
+ const row = this.db.query('SELECT * FROM chat_stats WHERE id = ?').get(id) as Chat | null;
843
+ return row ?? null;
844
+ }
845
+
846
+ getChatByName(name: string): Chat | null {
847
+ const row = this.db.query('SELECT * FROM chat_stats WHERE name = ?').get(normalizeChatName(name)) as Chat | null;
848
+ return row ?? null;
849
+ }
850
+
851
+ listChats(): Chat[] {
852
+ return this.db.query('SELECT * FROM chat_stats ORDER BY COALESCE(last_message_at, created_at) DESC').all() as Chat[];
853
+ }
854
+
855
+ listLarkGroupRoomMappings(): LarkGroupRoomMapping[] {
856
+ const rows = this.db.query(`
857
+ SELECT DISTINCT
858
+ ch.id AS room_id,
859
+ ch.name AS room_name,
860
+ ch.display_name AS display_name,
861
+ cc.external_chat_id AS external_chat_id,
862
+ ca.app_id AS app_id,
863
+ ca.bot_open_id AS bot_open_id,
864
+ ca.agent AS agent
865
+ FROM channel_conversations cc
866
+ INNER JOIN channel_accounts ca ON ca.id = cc.channel_account_id
867
+ INNER JOIN chats ch ON ch.id = cc.lock_chat_id
868
+ WHERE ca.channel = 'lark'
869
+ AND cc.chat_type = 'group'
870
+ AND cc.audit_only = 0
871
+ AND cc.external_chat_id IS NOT NULL
872
+ `).all() as Record<string, unknown>[];
873
+ return rows.map((row) => ({
874
+ room_id: String(row.room_id),
875
+ room_name: String(row.room_name),
876
+ display_name: row.display_name === null || row.display_name === undefined ? null : String(row.display_name),
877
+ external_chat_id: String(row.external_chat_id),
878
+ app_id: String(row.app_id),
879
+ bot_open_id: row.bot_open_id === null || row.bot_open_id === undefined ? null : String(row.bot_open_id),
880
+ agent: row.agent === null || row.agent === undefined ? null : String(row.agent),
881
+ }));
882
+ }
883
+
884
+ resolveRoom(identifier: string): Chat | null {
885
+ const raw = identifier.trim();
886
+ if (!raw) return null;
887
+ return this.getChatById(raw) ?? this.getChatByName(raw);
888
+ }
889
+
890
+ upsertRoomParticipant(input: UpsertRoomParticipantInput): RoomParticipant {
891
+ const participantId = input.participantId.trim();
892
+ if (!participantId) throw new Error('participant_id is required');
893
+ if (input.source === 'lark_member_api' && input.kind !== 'user') {
894
+ throw new Error('lark_member_api participants must be kind=user because Feishu filters bots from the member-list API');
895
+ }
896
+ const room = this.getChatById(input.roomId);
897
+ if (!room) throw new Error(`room ${input.roomId} was not found`);
898
+
899
+ const existing = this.db
900
+ .query('SELECT * FROM room_participants WHERE room_id = ? AND participant_id = ?')
901
+ .get(input.roomId, participantId) as Record<string, unknown> | null;
902
+ const nextSource = existing && participantSourceRank(existing.source as RoomParticipantSource) > participantSourceRank(input.source)
903
+ ? existing.source as RoomParticipantSource
904
+ : input.source;
905
+ const nextKind = existing && participantSourceRank(existing.source as RoomParticipantSource) > participantSourceRank(input.source)
906
+ ? existing.kind as RoomParticipantKind
907
+ : input.kind;
908
+ const nextName = input.displayName?.trim() || (existing?.display_name === null || existing?.display_name === undefined ? null : String(existing.display_name));
909
+
910
+ const id = existing ? String(existing.id) : crypto.randomUUID();
911
+ this.db.query(`
912
+ INSERT INTO room_participants (id, room_id, participant_id, kind, display_name, source, last_seen_at, created_at, updated_at)
913
+ VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'), datetime('now'))
914
+ ON CONFLICT(room_id, participant_id) DO UPDATE SET
915
+ kind = excluded.kind,
916
+ display_name = COALESCE(excluded.display_name, room_participants.display_name),
917
+ source = excluded.source,
918
+ status = 'active',
919
+ last_seen_at = datetime('now'),
920
+ updated_at = datetime('now')
921
+ `).run(id, input.roomId, participantId, nextKind, nextName, nextSource);
922
+
923
+ return this.getRoomParticipant(input.roomId, participantId)!;
924
+ }
925
+
926
+ getRoomParticipant(roomId: string, participantId: string): RoomParticipant | null {
927
+ const row = this.db
928
+ .query('SELECT * FROM room_participants WHERE room_id = ? AND participant_id = ?')
929
+ .get(roomId, participantId.trim()) as Record<string, unknown> | null;
930
+ return row ? rowToRoomParticipant(row) : null;
931
+ }
932
+
933
+ listRoomParticipants(roomId: string): RoomParticipant[] {
934
+ const rows = this.db.query(`
935
+ SELECT *
936
+ FROM room_participants
937
+ WHERE room_id = ? AND status = 'active'
938
+ ORDER BY
939
+ CASE kind WHEN 'user' THEN 0 WHEN 'bot' THEN 1 ELSE 2 END,
940
+ COALESCE(display_name, participant_id) ASC
941
+ `).all(roomId) as Record<string, unknown>[];
942
+ return rows.map(rowToRoomParticipant);
943
+ }
944
+
945
+ listMentionableRoomParticipants(roomId: string): RoomParticipant[] {
946
+ const room = this.getChatById(roomId);
947
+ if (!room) throw new Error(`room ${roomId} was not found`);
948
+ const participants = new Map<string, RoomParticipant>();
949
+ for (const participant of this.listRoomParticipants(room.id)) {
950
+ participants.set(participant.participant_id, participant);
951
+ }
952
+ return [...participants.values()].sort((left, right) => {
953
+ const leftName = left.display_name ?? left.participant_id;
954
+ const rightName = right.display_name ?? right.participant_id;
955
+ return leftName.localeCompare(rightName);
956
+ });
957
+ }
958
+
959
+ getLatestMessageId(roomId?: string): number {
960
+ const row = roomId
961
+ ? this.db.query('SELECT COALESCE(MAX(id), 0) AS id FROM messages WHERE chat_id = ?').get(roomId) as { id: number }
962
+ : this.db.query('SELECT COALESCE(MAX(id), 0) AS id FROM messages').get() as { id: number };
963
+ return Number(row.id ?? 0);
964
+ }
965
+
966
+ inviteAgentToRoom(input: { roomId: string; agent: string; mode?: AgentRoomSubscriptionMode }): { participant: RoomParticipant; subscription: AgentRoomSubscription } {
967
+ const room = this.getChatById(input.roomId);
968
+ if (!room) throw new Error(`room ${input.roomId} was not found`);
969
+ const agent = input.agent.trim();
970
+ if (!agent) throw new Error('agent is required');
971
+ const participant = this.upsertRoomParticipant({
972
+ roomId: room.id,
973
+ participantId: agent,
974
+ kind: 'agent',
975
+ displayName: this.getAgent(agent)?.display_name ?? agent,
976
+ source: 'local_agent',
977
+ });
978
+ this.db.query(`
979
+ UPDATE room_participants
980
+ SET delivery_cursor_message_id = COALESCE(delivery_cursor_message_id, ?), updated_at = datetime('now')
981
+ WHERE room_id = ? AND participant_id = ?
982
+ `).run(this.getLatestMessageId(room.id), room.id, agent);
983
+ return { participant, subscription: this.upsertAgentRoomSubscription({ agent, roomId: room.id, mode: input.mode ?? 'mentions' }) };
984
+ }
985
+
986
+ upsertAgentRoomSubscription(input: { agent: string; roomId: string; mode: AgentRoomSubscriptionMode; channelId?: string | null }): AgentRoomSubscription {
987
+ const agent = input.agent.trim();
988
+ if (!agent) throw new Error('agent is required');
989
+ const room = this.getChatById(input.roomId);
990
+ if (!room) throw new Error(`room ${input.roomId} was not found`);
991
+ const channelId = input.channelId ?? null;
992
+ const existing = this.db.query(`
993
+ SELECT * FROM agent_room_subscriptions
994
+ WHERE agent = ? AND room_id = ? AND channel_id IS ?
995
+ LIMIT 1
996
+ `).get(agent, room.id, channelId) as Record<string, unknown> | null;
997
+ if (existing) {
998
+ this.db.query(`UPDATE agent_room_subscriptions SET mode = ?, updated_at = datetime('now') WHERE id = ?`).run(input.mode, String(existing.id));
999
+ return this.getAgentRoomSubscription(agent, room.id)!;
1000
+ }
1001
+ const id = crypto.randomUUID();
1002
+ this.db.query(`
1003
+ INSERT INTO agent_room_subscriptions (id, agent, room_id, channel_id, mode, created_at, updated_at)
1004
+ VALUES (?, ?, ?, ?, ?, datetime('now'), datetime('now'))
1005
+ `).run(id, agent, room.id, channelId, input.mode);
1006
+ const row = this.db.query('SELECT * FROM agent_room_subscriptions WHERE id = ?').get(id) as Record<string, unknown>;
1007
+ return rowToAgentRoomSubscription(row);
1008
+ }
1009
+
1010
+ getAgentRoomSubscription(agent: string, roomId: string): AgentRoomSubscription | null {
1011
+ const row = this.db.query(`
1012
+ SELECT * FROM agent_room_subscriptions
1013
+ WHERE agent = ? AND room_id = ? AND channel_id IS NULL
1014
+ LIMIT 1
1015
+ `).get(agent.trim(), roomId) as Record<string, unknown> | null;
1016
+ return row ? rowToAgentRoomSubscription(row) : null;
1017
+ }
1018
+
1019
+ createRoomChannel(input: { roomId: string; name: string; createdBy?: string | null; externalRef?: string | null }): RoomChannel {
1020
+ const room = this.getChatById(input.roomId);
1021
+ if (!room) throw new Error(`room ${input.roomId} was not found`);
1022
+ if (room.kind === 'dm') throw new Error('ROOM_TOPICS_UNSUPPORTED: dm rooms do not support topics');
1023
+ const capabilities = parseCapabilities(room.capabilities_json);
1024
+ if (capabilities.topics !== 'native') throw new Error('ROOM_TOPICS_UNSUPPORTED: this room does not support topics');
1025
+ const name = input.name.trim();
1026
+ if (!name) throw new Error('topic name is required');
1027
+ const id = crypto.randomUUID();
1028
+ this.db.query(`
1029
+ INSERT INTO room_channels (id, room_id, kind, name, external_ref, created_by)
1030
+ VALUES (?, ?, 'topic', ?, ?, ?)
1031
+ ON CONFLICT(room_id, name) DO NOTHING
1032
+ `).run(id, room.id, name, input.externalRef ?? null, input.createdBy ?? null);
1033
+ const row = this.db.query('SELECT * FROM room_channels WHERE room_id = ? AND name = ?').get(room.id, name) as Record<string, unknown>;
1034
+ return rowToRoomChannel(row);
1035
+ }
1036
+
1037
+ listAgents(limit = 50): AgentDefinition[] {
1038
+ const rows = this.db.query(`
1039
+ SELECT id, agent_key, display_name, description, runtime, created_at, updated_at
1040
+ FROM agents
1041
+ ORDER BY agent_key ASC
1042
+ LIMIT ?
1043
+ `).all(limitValue(limit, 50)) as Record<string, unknown>[];
1044
+ return rows.map(rowToAgent);
1045
+ }
1046
+
1047
+ getAgent(identifier: string): AgentDefinition | null {
1048
+ const key = identifier.trim();
1049
+ if (!key) return null;
1050
+ const row = this.db.query(`
1051
+ SELECT id, agent_key, display_name, description, runtime, created_at, updated_at
1052
+ FROM agents
1053
+ WHERE id = ? OR agent_key = ?
1054
+ LIMIT 1
1055
+ `).get(key, key) as Record<string, unknown> | null;
1056
+ return row ? rowToAgent(row) : null;
1057
+ }
1058
+
1059
+ getAgentRuntime(agentKey: string): string | null {
1060
+ const row = this.db.query(`
1061
+ SELECT runtime FROM agents WHERE agent_key = ? LIMIT 1
1062
+ `).get(agentKey.trim()) as { runtime: string | null } | null;
1063
+ return row?.runtime ?? null;
1064
+ }
1065
+
1066
+ upsertAgent(input: { id?: string; agent_key: string; display_name: string; description?: string | null; runtime?: string | null }): AgentDefinition {
1067
+ const id = input.id?.trim() || crypto.randomUUID();
1068
+ const agentKey = input.agent_key.trim();
1069
+ const displayName = input.display_name.trim();
1070
+ if (!agentKey) throw new Error('agent_key is required');
1071
+ if (!displayName) throw new Error('display_name is required');
1072
+
1073
+ this.db.query(`
1074
+ INSERT INTO agents (id, agent_key, display_name, description, runtime, created_at, updated_at)
1075
+ VALUES (?, ?, ?, ?, ?, datetime('now'), datetime('now'))
1076
+ ON CONFLICT(agent_key) DO UPDATE SET
1077
+ display_name = excluded.display_name,
1078
+ description = COALESCE(excluded.description, agents.description),
1079
+ runtime = COALESCE(excluded.runtime, agents.runtime),
1080
+ updated_at = datetime('now')
1081
+ `).run(id, agentKey, displayName, input.description ?? null, input.runtime ?? null);
1082
+
1083
+ return this.getAgent(agentKey)!;
1084
+ }
1085
+
1086
+ updateAgentRuntime(agentKey: string, runtime: string | null): AgentDefinition | null {
1087
+ const key = agentKey.trim();
1088
+ if (!key) throw new Error('agent_key is required');
1089
+ const existing = this.getAgent(key);
1090
+ if (!existing) return null;
1091
+
1092
+ this.db.query(`
1093
+ UPDATE agents SET runtime = ?, updated_at = datetime('now') WHERE agent_key = ?
1094
+ `).run(runtime, key);
1095
+
1096
+ return this.getAgent(key);
1097
+ }
1098
+
1099
+ assignAgentToComputer(input: { agent: string; computerId: string }): ComputerAgentAssignment {
1100
+ const agent = input.agent.trim();
1101
+ const computerId = input.computerId.trim();
1102
+ if (!agent) throw new Error('agent is required');
1103
+ if (!computerId) throw new Error('computer_id is required');
1104
+ if (!this.getAgent(agent)) throw new Error(`agent ${agent} not found`);
1105
+ if (!this.getComputer(computerId)) throw new Error(`computer ${computerId} not found`);
1106
+
1107
+ this.db.query(`
1108
+ INSERT INTO computer_agent_assignments (agent, computer_id, cwd, status, updated_at)
1109
+ VALUES (?, ?, ?, 'active', datetime('now'))
1110
+ ON CONFLICT(agent) DO UPDATE SET
1111
+ computer_id = excluded.computer_id,
1112
+ cwd = excluded.cwd,
1113
+ status = 'active',
1114
+ updated_at = datetime('now')
1115
+ `).run(agent, computerId, '');
1116
+
1117
+ return this.getComputerAgentAssignment(agent)!;
1118
+ }
1119
+
1120
+ getComputerAgentAssignment(agent: string): ComputerAgentAssignment | null {
1121
+ const row = this.db.query(`
1122
+ SELECT caa.*, a.display_name, a.runtime
1123
+ FROM computer_agent_assignments caa
1124
+ JOIN agents a ON a.agent_key = caa.agent
1125
+ WHERE caa.agent = ?
1126
+ LIMIT 1
1127
+ `).get(agent.trim()) as Record<string, unknown> | null;
1128
+ return row ? rowToComputerAgentAssignment(row) : null;
1129
+ }
1130
+
1131
+ listComputerAgentAssignments(computerId: string): ComputerAgentAssignment[] {
1132
+ const rows = this.db.query(`
1133
+ SELECT caa.*, a.display_name, a.runtime
1134
+ FROM computer_agent_assignments caa
1135
+ JOIN agents a ON a.agent_key = caa.agent
1136
+ WHERE caa.computer_id = ? AND caa.status = 'active'
1137
+ ORDER BY caa.agent ASC
1138
+ `).all(computerId.trim()) as Record<string, unknown>[];
1139
+ return rows.map(rowToComputerAgentAssignment);
1140
+ }
1141
+
1142
+ validateAgentSchema(input: Record<string, unknown>): AgentValidationResult {
1143
+ const diagnostics: AgentValidationResult['diagnostics'] = [];
1144
+
1145
+ for (const key of Object.keys(input)) {
1146
+ if (!agentFieldAllowlist.has(key)) {
1147
+ diagnostics.push({
1148
+ level: 'error',
1149
+ code: forbiddenAgentFieldPattern.test(key) ? 'FORBIDDEN_FIELD' : 'UNKNOWN_FIELD',
1150
+ message: `${key} is not an A0 agent field`,
1151
+ });
1152
+ }
1153
+ }
1154
+
1155
+ for (const key of ['agent_key', 'display_name']) {
1156
+ const value = input[key];
1157
+ if (typeof value !== 'string' || value.trim() === '') {
1158
+ diagnostics.push({ level: 'error', code: 'INVALID_FIELD', message: `${key} must be a non-empty string` });
1159
+ }
1160
+ }
1161
+
1162
+ if ('id' in input && (typeof input.id !== 'string' || input.id.trim() === '')) {
1163
+ diagnostics.push({ level: 'error', code: 'INVALID_FIELD', message: 'id must be a non-empty string when provided' });
1164
+ }
1165
+
1166
+ if ('description' in input && input.description !== null && typeof input.description !== 'string') {
1167
+ diagnostics.push({ level: 'error', code: 'INVALID_FIELD', message: 'description must be a string or null' });
1168
+ }
1169
+
1170
+ if ('runtime' in input && input.runtime !== null && typeof input.runtime !== 'string') {
1171
+ diagnostics.push({ level: 'error', code: 'INVALID_FIELD', message: 'runtime must be a string or null' });
1172
+ }
1173
+
1174
+ for (const key of ['created_at', 'updated_at']) {
1175
+ if (key in input && typeof input[key] !== 'string') {
1176
+ diagnostics.push({ level: 'error', code: 'INVALID_FIELD', message: `${key} must be a string when provided` });
1177
+ }
1178
+ }
1179
+
1180
+ return { ok: diagnostics.length === 0, diagnostics };
1181
+ }
1182
+
1183
+ registerChannelAccount(input: RegisterChannelAccountInput): ChannelAccount {
1184
+ const existing = this.db.query('SELECT * FROM channel_accounts WHERE channel = ? AND app_id = ?').get('lark', input.appId) as Record<string, unknown> | null;
1185
+ const agent = input.agent?.trim() || null;
1186
+ if (existing) {
1187
+ let providerAccount: { id: string } | null = null;
1188
+ if (agent) {
1189
+ providerAccount = this.upsertProviderAccount({ provider: 'lark', externalAccountId: input.appId, agent, botExternalUserId: input.botOpenId ?? null });
1190
+ }
1191
+ if ((agent && existing.agent !== agent) || (input.botOpenId && existing.bot_open_id !== input.botOpenId)) {
1192
+ this.db.query(`
1193
+ UPDATE channel_accounts
1194
+ SET agent = COALESCE(?, agent),
1195
+ bot_open_id = COALESCE(?, bot_open_id),
1196
+ provider_account_id = COALESCE(?, provider_account_id),
1197
+ updated_at = datetime('now')
1198
+ WHERE id = ?
1199
+ `).run(agent, input.botOpenId ?? null, providerAccount?.id ?? null, String(existing.id));
1200
+ }
1201
+ return this.getChannelAccount(String(existing.id))!;
1202
+ }
1203
+
1204
+ const id = crypto.randomUUID();
1205
+ const providerAccount = agent ? this.upsertProviderAccount({ provider: 'lark', externalAccountId: input.appId, agent, botExternalUserId: input.botOpenId ?? null }) : null;
1206
+ this.db.query(`
1207
+ INSERT INTO channel_accounts (id, channel, name, app_id, bot_open_id, agent, provider_account_id)
1208
+ VALUES (?, 'lark', ?, ?, ?, ?, ?)
1209
+ `).run(id, input.name, input.appId, input.botOpenId ?? null, agent, providerAccount?.id ?? null);
1210
+ return this.getChannelAccount(id)!;
1211
+ }
1212
+
1213
+ upsertProviderAccount(input: { provider: Exclude<RoomProvider, 'web'>; externalAccountId: string; agent: string; botExternalUserId?: string | null }): { id: string } {
1214
+ const externalAccountId = input.externalAccountId.trim();
1215
+ const agent = input.agent.trim();
1216
+ if (!externalAccountId || !agent) throw new Error('provider account requires externalAccountId and agent');
1217
+ const existing = this.db.query('SELECT id FROM provider_accounts WHERE provider = ? AND external_account_id = ?').get(input.provider, externalAccountId) as { id: string } | null;
1218
+ const id = existing?.id ?? crypto.randomUUID();
1219
+ this.db.query(`
1220
+ INSERT INTO provider_accounts (id, provider, external_account_id, agent, bot_external_user_id, status, created_at, updated_at)
1221
+ VALUES (?, ?, ?, ?, ?, 'active', datetime('now'), datetime('now'))
1222
+ ON CONFLICT(provider, external_account_id) DO UPDATE SET
1223
+ agent = excluded.agent,
1224
+ bot_external_user_id = COALESCE(excluded.bot_external_user_id, provider_accounts.bot_external_user_id),
1225
+ status = 'active',
1226
+ updated_at = datetime('now')
1227
+ `).run(id, input.provider, externalAccountId, agent, input.botExternalUserId ?? null);
1228
+ return { id };
1229
+ }
1230
+
1231
+ getOrCreateProviderIdentity(input: GetOrCreateProviderIdentityInput): { identity: PalIdentity; binding: ProviderIdentityBinding } {
1232
+ const providerAccountId = input.providerAccountId.trim();
1233
+ const externalId = input.externalId.trim();
1234
+ if (!providerAccountId || !externalId) throw new Error('provider identity requires providerAccountId and externalId');
1235
+
1236
+ const existing = this.db.query(`
1237
+ SELECT b.*, i.id AS identity_id, i.kind AS identity_kind, i.display_name AS identity_display_name,
1238
+ i.stable_handle AS identity_stable_handle, i.created_at AS identity_created_at, i.updated_at AS identity_updated_at
1239
+ FROM provider_identity_bindings b
1240
+ INNER JOIN pal_identities i ON i.id = b.identity_id
1241
+ WHERE b.provider = ? AND b.provider_account_id = ? AND b.external_type = ? AND b.external_id = ?
1242
+ LIMIT 1
1243
+ `).get(input.provider, providerAccountId, input.externalType, externalId) as Record<string, unknown> | null;
1244
+ if (existing) {
1245
+ const displayName = input.displayName?.trim() || null;
1246
+ if (displayName) {
1247
+ this.db.query(`
1248
+ UPDATE pal_identities
1249
+ SET display_name = COALESCE(?, display_name), updated_at = datetime('now')
1250
+ WHERE id = ?
1251
+ `).run(displayName, String(existing.identity_id));
1252
+ }
1253
+ return {
1254
+ identity: rowToPalIdentity({
1255
+ id: existing.identity_id,
1256
+ kind: existing.identity_kind,
1257
+ display_name: displayName ?? existing.identity_display_name,
1258
+ stable_handle: existing.identity_stable_handle,
1259
+ created_at: existing.identity_created_at,
1260
+ updated_at: existing.identity_updated_at,
1261
+ }),
1262
+ binding: rowToProviderIdentityBinding(existing),
1263
+ };
1264
+ }
1265
+
1266
+ const identityId = crypto.randomUUID();
1267
+ const bindingId = crypto.randomUUID();
1268
+ const displayName = input.displayName?.trim() || null;
1269
+ const stableHandle = palIdentityHandle(input.externalType, `${input.provider}:${providerAccountId}:${input.externalType}:${externalId}`);
1270
+ this.db.query(`
1271
+ INSERT INTO pal_identities (id, kind, display_name, stable_handle, created_at, updated_at)
1272
+ VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))
1273
+ `).run(identityId, input.externalType, displayName, stableHandle);
1274
+ this.db.query(`
1275
+ INSERT INTO provider_identity_bindings (id, provider, provider_account_id, external_type, external_id, identity_id, created_at, updated_at)
1276
+ VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
1277
+ `).run(bindingId, input.provider, providerAccountId, input.externalType, externalId, identityId);
1278
+
1279
+ return {
1280
+ identity: rowToPalIdentity(this.db.query('SELECT * FROM pal_identities WHERE id = ?').get(identityId) as Record<string, unknown>),
1281
+ binding: rowToProviderIdentityBinding(this.db.query('SELECT * FROM provider_identity_bindings WHERE id = ?').get(bindingId) as Record<string, unknown>),
1282
+ };
1283
+ }
1284
+
1285
+ bindProviderIdentity(input: BindProviderIdentityInput): { identity: PalIdentity; binding: ProviderIdentityBinding } {
1286
+ const providerAccountId = input.providerAccountId.trim();
1287
+ const externalId = input.externalId.trim();
1288
+ const identityId = input.identityId.trim();
1289
+ if (!providerAccountId || !externalId || !identityId) throw new Error('provider identity binding requires providerAccountId, externalId, and identityId');
1290
+ const identity = this.db.query('SELECT * FROM pal_identities WHERE id = ?').get(identityId) as Record<string, unknown> | null;
1291
+ if (!identity) throw new Error(`PAL identity ${identityId} was not found`);
1292
+ const displayName = input.displayName?.trim() || null;
1293
+ if (displayName) {
1294
+ this.db.query(`
1295
+ UPDATE pal_identities
1296
+ SET display_name = COALESCE(?, display_name), updated_at = datetime('now')
1297
+ WHERE id = ?
1298
+ `).run(displayName, identityId);
1299
+ }
1300
+ const bindingId = crypto.randomUUID();
1301
+ this.db.query(`
1302
+ INSERT INTO provider_identity_bindings (id, provider, provider_account_id, external_type, external_id, identity_id, created_at, updated_at)
1303
+ VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
1304
+ ON CONFLICT(provider, provider_account_id, external_type, external_id) DO UPDATE SET
1305
+ identity_id = excluded.identity_id,
1306
+ updated_at = datetime('now')
1307
+ `).run(bindingId, input.provider, providerAccountId, input.externalType, externalId, identityId);
1308
+ const binding = this.db.query(`
1309
+ SELECT * FROM provider_identity_bindings
1310
+ WHERE provider = ? AND provider_account_id = ? AND external_type = ? AND external_id = ?
1311
+ `).get(input.provider, providerAccountId, input.externalType, externalId) as Record<string, unknown>;
1312
+ return {
1313
+ identity: rowToPalIdentity(this.db.query('SELECT * FROM pal_identities WHERE id = ?').get(identityId) as Record<string, unknown>),
1314
+ binding: rowToProviderIdentityBinding(binding),
1315
+ };
1316
+ }
1317
+
1318
+ getCachedProviderUnionId(input: { provider: Exclude<RoomProvider, 'web'>; appId: string; openId: string }): string | null {
1319
+ const row = this.db.query(`
1320
+ SELECT union_id FROM provider_identity_cache
1321
+ WHERE provider = ? AND app_id = ? AND open_id = ?
1322
+ AND (expires_at IS NULL OR expires_at > datetime('now'))
1323
+ LIMIT 1
1324
+ `).get(input.provider, input.appId, input.openId) as { union_id: string } | null;
1325
+ return row?.union_id ?? null;
1326
+ }
1327
+
1328
+ cacheProviderUnionId(input: { provider: Exclude<RoomProvider, 'web'>; appId: string; openId: string; unionId: string; expiresAt?: string | null }): void {
1329
+ const appId = input.appId.trim();
1330
+ const openId = input.openId.trim();
1331
+ const unionId = input.unionId.trim();
1332
+ if (!appId || !openId || !unionId) return;
1333
+ this.db.query(`
1334
+ INSERT INTO provider_identity_cache (id, provider, app_id, open_id, union_id, fetched_at, expires_at)
1335
+ VALUES (?, ?, ?, ?, ?, datetime('now'), ?)
1336
+ ON CONFLICT(provider, app_id, open_id) DO UPDATE SET
1337
+ union_id = excluded.union_id,
1338
+ fetched_at = datetime('now'),
1339
+ expires_at = excluded.expires_at
1340
+ `).run(crypto.randomUUID(), input.provider, appId, openId, unionId, input.expiresAt ?? null);
1341
+ }
1342
+
1343
+ getChannelAccount(id: string): ChannelAccount | null {
1344
+ const row = this.db.query('SELECT * FROM channel_accounts WHERE id = ?').get(id) as Record<string, unknown> | null;
1345
+ return row ? rowToChannelAccount(row) : null;
1346
+ }
1347
+
1348
+ getChannelAccountByAppId(appId: string): ChannelAccount | null {
1349
+ const row = this.db.query('SELECT * FROM channel_accounts WHERE channel = ? AND app_id = ?').get('lark', appId) as Record<string, unknown> | null;
1350
+ return row ? rowToChannelAccount(row) : null;
1351
+ }
1352
+
1353
+ resolveChannelConversation(input: ResolveChannelConversationInput): ChannelConversation {
1354
+ // P1-4(b): pure-read on hit. last_seen_at is only written by ingest paths
1355
+ // (ingestAuditEnvelope / ingestCanonicalEnvelope), never by resolution itself.
1356
+ const existing = this.db.query('SELECT * FROM channel_conversations WHERE conversation_key = ?').get(input.conversationKey) as Record<string, unknown> | null;
1357
+ if (existing) {
1358
+ this.markRoomAsLark(String(existing.lock_chat_id), input.chatType);
1359
+ const account = this.getChannelAccount(input.accountId);
1360
+ if (account?.provider_account_id) {
1361
+ this.getOrCreateProviderIdentity({
1362
+ provider: 'lark',
1363
+ providerAccountId: account.provider_account_id,
1364
+ externalType: 'room',
1365
+ externalId: input.externalChatId,
1366
+ displayName: input.chatName,
1367
+ });
1368
+ }
1369
+ if (input.chatType === 'p2p') this.backfillLarkDmDisplayNames();
1370
+ return this.getChannelConversation(String(existing.id))!;
1371
+ }
1372
+
1373
+ const chat = this.getOrCreateChat(input.chatName, input.chatType === 'p2p' ? 'dm' : 'group');
1374
+ this.markRoomAsLark(chat.id, input.chatType);
1375
+ const id = crypto.randomUUID();
1376
+ this.db.query(`
1377
+ INSERT INTO channel_conversations (id, channel_account_id, lock_chat_id, conversation_key, external_chat_id, external_root_id, external_thread_id, scope, chat_type, audit_only)
1378
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1379
+ `).run(id, input.accountId, chat.id, input.conversationKey, input.externalChatId, input.externalRootId ?? null, input.externalThreadId ?? null, input.scope, input.chatType, input.auditOnly ? 1 : 0);
1380
+ const account = this.getChannelAccount(input.accountId);
1381
+ if (account?.provider_account_id) {
1382
+ this.getOrCreateProviderIdentity({
1383
+ provider: 'lark',
1384
+ providerAccountId: account.provider_account_id,
1385
+ externalType: 'room',
1386
+ externalId: input.externalChatId,
1387
+ displayName: input.chatName,
1388
+ });
1389
+ this.db.query(`
1390
+ INSERT INTO provider_conversations (id, provider_account_id, room_id, channel_id, external_room_id, external_channel_id)
1391
+ VALUES (?, ?, ?, NULL, ?, ?)
1392
+ ON CONFLICT(provider_account_id, external_room_id, external_channel_id) DO UPDATE SET last_seen_at = datetime('now')
1393
+ `).run(crypto.randomUUID(), account.provider_account_id, chat.id, input.externalChatId, input.externalThreadId ?? input.externalRootId ?? null);
1394
+ }
1395
+ if (account?.bot_open_id && account.provider_account_id) {
1396
+ const botIdentity = this.getOrCreateProviderIdentity({
1397
+ provider: 'lark',
1398
+ providerAccountId: account.provider_account_id,
1399
+ externalType: 'bot',
1400
+ externalId: account.bot_open_id,
1401
+ displayName: account.name,
1402
+ }).identity;
1403
+ this.upsertRoomParticipant({
1404
+ roomId: chat.id,
1405
+ participantId: botIdentity.stable_handle,
1406
+ kind: 'bot',
1407
+ displayName: account.name,
1408
+ source: 'known_bot',
1409
+ });
1410
+ }
1411
+ if (input.chatType === 'p2p') this.backfillLarkDmDisplayNames();
1412
+ return this.getChannelConversation(id)!;
1413
+ }
1414
+
1415
+ private markRoomAsLark(roomId: string, chatType: 'p2p' | 'group'): void {
1416
+ const kind = chatType === 'p2p' ? 'dm' : 'group';
1417
+ this.db.query(`
1418
+ UPDATE chats
1419
+ SET provider = 'lark', kind = ?, capabilities_json = ?, dm_type = CASE WHEN ? = 'dm' THEN COALESCE(dm_type, 'user_agent') ELSE dm_type END
1420
+ WHERE id = ?
1421
+ `).run(kind, kind === 'dm' ? '{"topics":"unsupported"}' : '{"topics":"native"}', kind, roomId);
1422
+ }
1423
+
1424
+ private bumpChannelConversationLastSeen(conversationId: string): void {
1425
+ this.db.query("UPDATE channel_conversations SET last_seen_at = datetime('now') WHERE id = ?").run(conversationId);
1426
+ }
1427
+
1428
+ getChannelConversation(id: string): ChannelConversation | null {
1429
+ const row = this.db.query('SELECT * FROM channel_conversations WHERE id = ?').get(id) as Record<string, unknown> | null;
1430
+ return row ? rowToChannelConversation(row) : null;
1431
+ }
1432
+
1433
+ /**
1434
+ * Find the Lark channel conversation associated with a lock chat.
1435
+ * Returns null if no Lark channel is linked to this chat.
1436
+ */
1437
+ findLarkChannelForChat(chatId: string, appId?: string): { conversation: ChannelConversation; account: ChannelAccount } | null {
1438
+ const params: SQLQueryBindings[] = [chatId];
1439
+ let appFilter = '';
1440
+ if (appId) {
1441
+ appFilter = 'AND ca.app_id = ?';
1442
+ params.push(appId);
1443
+ }
1444
+ const row = this.db.query(`
1445
+ SELECT cc.*, ca.id as account_id, ca.channel as account_channel, ca.name as account_name, ca.app_id as account_app_id, ca.bot_open_id as account_bot_open_id, ca.agent as account_agent, ca.provider_account_id as account_provider_account_id, ca.status as account_status, ca.created_at as account_created_at, ca.updated_at as account_updated_at
1446
+ FROM channel_conversations cc
1447
+ JOIN channel_accounts ca ON ca.id = cc.channel_account_id
1448
+ WHERE cc.lock_chat_id = ? AND ca.channel = 'lark' AND ca.status = 'active' ${appFilter}
1449
+ LIMIT 1
1450
+ `).get(...params) as Record<string, unknown> | null;
1451
+ if (!row) return null;
1452
+ const accountRow: Record<string, unknown> = {
1453
+ id: row.account_id,
1454
+ channel: row.account_channel,
1455
+ name: row.account_name,
1456
+ app_id: row.account_app_id,
1457
+ bot_open_id: row.account_bot_open_id,
1458
+ agent: row.account_agent,
1459
+ provider_account_id: row.account_provider_account_id,
1460
+ status: row.account_status,
1461
+ created_at: row.account_created_at,
1462
+ updated_at: row.account_updated_at,
1463
+ };
1464
+ return {
1465
+ conversation: rowToChannelConversation(row),
1466
+ account: rowToChannelAccount(accountRow),
1467
+ };
1468
+ }
1469
+
1470
+ createTranscript(input: CreateTranscriptInput): LockTranscriptMessage {
1471
+ const mentions = normalizeMentionsInput(input.mentions);
1472
+ return this.db.transaction(() => {
1473
+ const assertSameConversation = (column: TranscriptCrossConversationReferenceError['column'], referencedId: string | null | undefined, originTranscriptId: string | null): void => {
1474
+ if (!referencedId) return;
1475
+ const row = this.db.query('SELECT conversation_id FROM lock_transcript_messages WHERE id = ?').get(referencedId) as { conversation_id: string } | null;
1476
+ if (!row) return;
1477
+ if (row.conversation_id !== input.conversationId) {
1478
+ throw new TranscriptCrossConversationReferenceError({
1479
+ column,
1480
+ transcriptId: originTranscriptId,
1481
+ referencedTranscriptId: referencedId,
1482
+ expectedConversationId: input.conversationId,
1483
+ actualConversationId: row.conversation_id,
1484
+ });
1485
+ }
1486
+ };
1487
+
1488
+ const existing = this.db.query('SELECT * FROM lock_transcript_messages WHERE source_type = ? AND source_unique_key = ?').get(input.sourceType, input.sourceUniqueKey) as Record<string, unknown> | null;
1489
+ if (existing) {
1490
+ const merged = rowToTranscript(existing);
1491
+ assertSameConversation('reply_to_transcript_id', input.replyToTranscriptId ?? null, merged.id);
1492
+ assertSameConversation('quote_root_transcript_id', input.quoteRootTranscriptId ?? null, merged.id);
1493
+ assertSameConversation('quote_message_transcript_id', input.quoteMessageTranscriptId ?? null, merged.id);
1494
+
1495
+ const updates: string[] = [];
1496
+ const params: SQLQueryBindings[] = [];
1497
+
1498
+ const maybeMerge = (column: string, currentValue: string | null, newValue: string | null | undefined): void => {
1499
+ if (currentValue === null && newValue !== null && newValue !== undefined) {
1500
+ updates.push(`${column} = ?`);
1501
+ params.push(newValue);
1502
+ }
1503
+ };
1504
+ maybeMerge('reply_to_transcript_id', merged.reply_to_transcript_id, input.replyToTranscriptId ?? null);
1505
+ maybeMerge('quote_root_transcript_id', merged.quote_root_transcript_id, input.quoteRootTranscriptId ?? null);
1506
+ maybeMerge('quote_message_transcript_id', merged.quote_message_transcript_id, input.quoteMessageTranscriptId ?? null);
1507
+ maybeMerge('reply_to_external_message_id', merged.reply_to_external_message_id, input.replyToExternalMessageId ?? null);
1508
+ maybeMerge('quote_root_external_message_id', merged.quote_root_external_message_id, input.quoteRootExternalMessageId ?? null);
1509
+ maybeMerge('quote_message_external_message_id', merged.quote_message_external_message_id, input.quoteMessageExternalMessageId ?? null);
1510
+
1511
+ if (updates.length > 0) {
1512
+ params.push(merged.id);
1513
+ this.db.query(`UPDATE lock_transcript_messages SET ${updates.join(', ')} WHERE id = ?`).run(...params);
1514
+ return this.getTranscript(merged.id)!;
1515
+ }
1516
+ return merged;
1517
+ }
1518
+
1519
+ assertSameConversation('reply_to_transcript_id', input.replyToTranscriptId ?? null, null);
1520
+ assertSameConversation('quote_root_transcript_id', input.quoteRootTranscriptId ?? null, null);
1521
+ assertSameConversation('quote_message_transcript_id', input.quoteMessageTranscriptId ?? null, null);
1522
+
1523
+ const id = crypto.randomUUID();
1524
+ const seqRow = this.db.query('SELECT COALESCE(MAX(ingest_seq), 0) + 1 AS next FROM lock_transcript_messages').get() as { next: number };
1525
+ const nextSeq = Number(seqRow.next);
1526
+ this.db.query(`
1527
+ INSERT INTO lock_transcript_messages (id, lock_message_id, conversation_id, channel_type, direction, sender_type, sender_id, recipient_ref, content, content_type, source_type, source_unique_key, mentions_json, reply_to_transcript_id, quote_root_transcript_id, quote_message_transcript_id, reply_to_external_message_id, quote_root_external_message_id, quote_message_external_message_id, ingest_seq)
1528
+ VALUES (?, ?, ?, 'lark', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1529
+ `).run(
1530
+ id,
1531
+ input.lockMessageId ?? null,
1532
+ input.conversationId,
1533
+ input.direction,
1534
+ input.senderType,
1535
+ input.senderId,
1536
+ input.recipientRef ?? null,
1537
+ input.content,
1538
+ input.contentType ?? 'text',
1539
+ input.sourceType,
1540
+ input.sourceUniqueKey,
1541
+ JSON.stringify(mentions),
1542
+ input.replyToTranscriptId ?? null,
1543
+ input.quoteRootTranscriptId ?? null,
1544
+ input.quoteMessageTranscriptId ?? null,
1545
+ input.replyToExternalMessageId ?? null,
1546
+ input.quoteRootExternalMessageId ?? null,
1547
+ input.quoteMessageExternalMessageId ?? null,
1548
+ nextSeq,
1549
+ );
1550
+ return this.getTranscript(id)!;
1551
+ })();
1552
+ }
1553
+
1554
+ resolvePendingTranscriptRelations(conversationId: string): number {
1555
+ return this.db.transaction(() => {
1556
+ const pendings = this.db.query(`
1557
+ SELECT id, conversation_id, reply_to_transcript_id, quote_root_transcript_id, quote_message_transcript_id,
1558
+ reply_to_external_message_id, quote_root_external_message_id, quote_message_external_message_id
1559
+ FROM lock_transcript_messages
1560
+ WHERE conversation_id = ?
1561
+ AND (
1562
+ (reply_to_transcript_id IS NULL AND reply_to_external_message_id IS NOT NULL)
1563
+ OR (quote_root_transcript_id IS NULL AND quote_root_external_message_id IS NOT NULL)
1564
+ OR (quote_message_transcript_id IS NULL AND quote_message_external_message_id IS NOT NULL)
1565
+ )
1566
+ `).all(conversationId) as Array<Record<string, unknown>>;
1567
+ let updated = 0;
1568
+ for (const row of pendings) {
1569
+ const updates: string[] = [];
1570
+ const params: SQLQueryBindings[] = [];
1571
+ const resolveOne = (transcriptColumn: string, transcriptCurrent: unknown, externalValue: unknown): void => {
1572
+ if (transcriptCurrent !== null && transcriptCurrent !== undefined) return;
1573
+ if (externalValue === null || externalValue === undefined) return;
1574
+ const parent = this.db.query(`
1575
+ SELECT t.id FROM lock_transcript_messages t
1576
+ INNER JOIN channel_messages cm ON cm.transcript_message_id = t.id
1577
+ WHERE t.conversation_id = ?
1578
+ AND cm.channel_conversation_id = ?
1579
+ AND cm.external_message_id = ?
1580
+ ORDER BY t.ingest_seq ASC
1581
+ LIMIT 1
1582
+ `).get(conversationId, conversationId, String(externalValue)) as { id: string } | null;
1583
+ if (!parent) return;
1584
+ updates.push(`${transcriptColumn} = ?`);
1585
+ params.push(parent.id);
1586
+ };
1587
+ resolveOne('reply_to_transcript_id', row.reply_to_transcript_id, row.reply_to_external_message_id);
1588
+ resolveOne('quote_root_transcript_id', row.quote_root_transcript_id, row.quote_root_external_message_id);
1589
+ resolveOne('quote_message_transcript_id', row.quote_message_transcript_id, row.quote_message_external_message_id);
1590
+ if (updates.length > 0) {
1591
+ params.push(String(row.id));
1592
+ this.db.query(`UPDATE lock_transcript_messages SET ${updates.join(', ')} WHERE id = ?`).run(...params);
1593
+ updated += 1;
1594
+ }
1595
+ }
1596
+ return updated;
1597
+ })();
1598
+ }
1599
+
1600
+ getTranscript(id: string): LockTranscriptMessage | null {
1601
+ const row = this.db.query('SELECT * FROM lock_transcript_messages WHERE id = ?').get(id) as Record<string, unknown> | null;
1602
+ return row ? rowToTranscript(row) : null;
1603
+ }
1604
+
1605
+ listTranscripts(conversationId: string, limit = 50): LockTranscriptMessage[] {
1606
+ const rows = this.db.query(`
1607
+ SELECT * FROM lock_transcript_messages
1608
+ WHERE conversation_id = ?
1609
+ ORDER BY ingest_seq ASC
1610
+ LIMIT ?
1611
+ `).all(conversationId, limitValue(limit, 50)) as Record<string, unknown>[];
1612
+ return rows.map(rowToTranscript);
1613
+ }
1614
+
1615
+ listTranscriptMessagesReadOnly(conversationId: string, limit = 50): TranscriptReadModel[] {
1616
+ const rows = this.db.query(`
1617
+ SELECT id, conversation_id, direction, sender_type, sender_id, recipient_ref, content, content_type, status, mentions_json, reply_to_transcript_id, quote_root_transcript_id, quote_message_transcript_id, created_at, recorded_at
1618
+ FROM lock_transcript_messages
1619
+ WHERE conversation_id = ?
1620
+ ORDER BY ingest_seq ASC
1621
+ LIMIT ?
1622
+ `).all(conversationId, limitValue(limit, 50)) as Array<Record<string, unknown>>;
1623
+ return rows.map(rowToTranscriptReadModel);
1624
+ }
1625
+
1626
+ attachChannelMessage(input: AttachChannelMessageInput): ChannelMessageMapping {
1627
+ try {
1628
+ return this.attachChannelMessageInner(input);
1629
+ } catch (error) {
1630
+ if (error instanceof ChannelMessageConflictError) throw error;
1631
+ if (isUniqueConstraintError(error)) {
1632
+ throw createChannelMessageConflict('CHANNEL_MESSAGE_UNIQUE_CONFLICT', `channel message unique constraint conflict (${String((error as { message?: unknown }).message ?? '')})`, null, { input_external_message_id: input.externalMessageId ?? null, input_lock_message_id: input.lockMessageId ?? null });
1633
+ }
1634
+ throw error;
1635
+ }
1636
+ }
1637
+
1638
+ private attachChannelMessageInner(input: AttachChannelMessageInput): ChannelMessageMapping {
1639
+ return this.db.transaction(() => {
1640
+ const conflictFields = {
1641
+ transcript_message_id: input.transcriptMessageId ?? null,
1642
+ lock_message_id: input.lockMessageId ?? null,
1643
+ status: input.status,
1644
+ reason_code: input.reasonCode ?? null,
1645
+ external_root_id: input.externalRootId ?? null,
1646
+ external_thread_id: input.externalThreadId ?? null,
1647
+ sender_open_id: input.senderOpenId ?? null,
1648
+ raw_type: input.rawType ?? 'text',
1649
+ admitted: input.admitted ? 1 : 0,
1650
+ direction: input.direction,
1651
+ external_chat_id: input.externalChatId,
1652
+ source: input.source ?? 'local_fixture',
1653
+ } as const;
1654
+
1655
+ const checkExisting = (mapping: ChannelMessageMapping): ChannelMessageMapping => {
1656
+ // P1-5 priority lock: AUDIT_OUTCOME > ATTRIBUTES > LOCK_BINDING.
1657
+ if (
1658
+ mapping.admitted !== conflictFields.admitted ||
1659
+ mapping.direction !== conflictFields.direction
1660
+ ) {
1661
+ throw createChannelMessageConflict('CHANNEL_MESSAGE_AUDIT_OUTCOME_CONFLICT', 'channel message is already mapped with a different audit outcome', mapping, conflictFields);
1662
+ }
1663
+ if (
1664
+ mapping.external_root_id !== conflictFields.external_root_id ||
1665
+ mapping.external_thread_id !== conflictFields.external_thread_id ||
1666
+ mapping.sender_open_id !== conflictFields.sender_open_id ||
1667
+ mapping.raw_type !== conflictFields.raw_type ||
1668
+ mapping.external_chat_id !== conflictFields.external_chat_id ||
1669
+ mapping.source !== conflictFields.source
1670
+ ) {
1671
+ throw createChannelMessageConflict('CHANNEL_MESSAGE_ATTRIBUTES_CONFLICT', 'channel message is already mapped with different envelope attributes', mapping, conflictFields);
1672
+ }
1673
+ if (
1674
+ mapping.transcript_message_id !== conflictFields.transcript_message_id ||
1675
+ mapping.lock_message_id !== conflictFields.lock_message_id ||
1676
+ mapping.status !== conflictFields.status ||
1677
+ mapping.reason_code !== conflictFields.reason_code
1678
+ ) {
1679
+ throw createChannelMessageConflict('CHANNEL_MESSAGE_LOCK_BINDING_CONFLICT', 'channel message is already mapped to a different Lock binding or audit state', mapping, conflictFields);
1680
+ }
1681
+ return mapping;
1682
+ };
1683
+
1684
+ const reReadByExternalMessageId = (): ChannelMessageMapping | null => {
1685
+ if (!input.externalMessageId) return null;
1686
+ const row = this.db.query(`
1687
+ SELECT * FROM channel_messages
1688
+ WHERE channel_account_id = ? AND external_message_id = ?
1689
+ `).get(input.accountId, input.externalMessageId) as Record<string, unknown> | null;
1690
+ return row ? rowToChannelMessage(row) : null;
1691
+ };
1692
+
1693
+ const reReadByLockBinding = (): ChannelMessageMapping | null => {
1694
+ if (input.lockMessageId === null || input.lockMessageId === undefined) return null;
1695
+ const row = this.db.query(`
1696
+ SELECT * FROM channel_messages
1697
+ WHERE channel_account_id = ?
1698
+ AND channel_conversation_id = ?
1699
+ AND direction = ?
1700
+ AND lock_message_id = ?
1701
+ `).get(input.accountId, input.conversationId, input.direction, input.lockMessageId) as Record<string, unknown> | null;
1702
+ return row ? rowToChannelMessage(row) : null;
1703
+ };
1704
+
1705
+ const existingByExternal = reReadByExternalMessageId();
1706
+ if (existingByExternal) return checkExisting(existingByExternal);
1707
+ const existingByLock = reReadByLockBinding();
1708
+ if (existingByLock) {
1709
+ // The lock-binding UNIQUE index would forbid inserting a new row with a different
1710
+ // external_message_id under the same (account, conversation, direction, lock_message_id).
1711
+ // Surface that as a structured lock-binding conflict rather than silently returning the
1712
+ // existing row, since checkExisting no longer compares external_message_id.
1713
+ if (existingByLock.external_message_id !== (input.externalMessageId ?? null)) {
1714
+ throw createChannelMessageConflict('CHANNEL_MESSAGE_LOCK_BINDING_CONFLICT', 'channel message lock binding already mapped to a different external message id', existingByLock, conflictFields);
1715
+ }
1716
+ return checkExisting(existingByLock);
1717
+ }
1718
+
1719
+ const id = crypto.randomUUID();
1720
+ try {
1721
+ this.db.query(`
1722
+ INSERT INTO channel_messages (id, channel_account_id, channel_conversation_id, lock_message_id, transcript_message_id, direction, external_message_id, external_chat_id, external_root_id, external_thread_id, sender_open_id, sender_type, raw_type, status, reason_code, source, admitted, raw_payload_redacted_json)
1723
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1724
+ `).run(
1725
+ id,
1726
+ input.accountId,
1727
+ input.conversationId,
1728
+ input.lockMessageId ?? null,
1729
+ input.transcriptMessageId ?? null,
1730
+ input.direction,
1731
+ input.externalMessageId ?? null,
1732
+ input.externalChatId,
1733
+ input.externalRootId ?? null,
1734
+ input.externalThreadId ?? null,
1735
+ input.senderOpenId ?? null,
1736
+ input.senderType ?? 'unknown',
1737
+ input.rawType ?? 'text',
1738
+ input.status,
1739
+ input.reasonCode ?? null,
1740
+ input.source ?? 'local_fixture',
1741
+ input.admitted ? 1 : 0,
1742
+ input.rawPayloadRedactedJson ?? '{}',
1743
+ );
1744
+ } catch (error) {
1745
+ if (isUniqueConstraintError(error)) {
1746
+ const message = String((error as { message?: unknown }).message ?? '');
1747
+ if (message.includes('idx_channel_messages_external_id') || (input.externalMessageId && message.includes('external_message_id'))) {
1748
+ const raced = reReadByExternalMessageId();
1749
+ if (raced) return checkExisting(raced);
1750
+ }
1751
+ if (message.includes('idx_channel_messages_lock_message') || message.includes('lock_message_id')) {
1752
+ const raced = reReadByLockBinding();
1753
+ if (raced) throw createChannelMessageConflict('CHANNEL_MESSAGE_LOCK_BINDING_CONFLICT', 'channel message lock binding unique index conflict', raced, conflictFields);
1754
+ }
1755
+ const fallbackExternal = reReadByExternalMessageId();
1756
+ if (fallbackExternal) return checkExisting(fallbackExternal);
1757
+ const fallbackLock = reReadByLockBinding();
1758
+ if (fallbackLock) throw createChannelMessageConflict('CHANNEL_MESSAGE_LOCK_BINDING_CONFLICT', 'channel message lock binding unique index conflict', fallbackLock, conflictFields);
1759
+ throw createChannelMessageConflict('CHANNEL_MESSAGE_UNIQUE_CONFLICT', `channel message unique constraint conflict (${message})`, null, conflictFields);
1760
+ }
1761
+ throw error;
1762
+ }
1763
+ return this.getChannelMessage(id)!;
1764
+ })();
1765
+ }
1766
+
1767
+ getChannelMessage(id: string): ChannelMessageMapping | null {
1768
+ const row = this.db.query('SELECT * FROM channel_messages WHERE id = ?').get(id) as Record<string, unknown> | null;
1769
+ return row ? rowToChannelMessage(row) : null;
1770
+ }
1771
+
1772
+ ingestAuditEnvelope(input: IngestAuditEnvelopeInput): IngestEnvelopeResult {
1773
+ return this.db.transaction(() => {
1774
+ const conversation = this.resolveChannelConversation({ ...input.conversation, auditOnly: true });
1775
+ const mapping = this.attachChannelMessage({ ...input.envelope, conversationId: conversation.id, transcriptMessageId: null });
1776
+ this.bumpChannelConversationLastSeen(conversation.id);
1777
+ return { conversation: this.getChannelConversation(conversation.id)!, mapping, transcript: null };
1778
+ })();
1779
+ }
1780
+
1781
+ ingestCanonicalEnvelope(input: IngestCanonicalEnvelopeInput): IngestEnvelopeResult {
1782
+ return this.db.transaction(() => {
1783
+ const conversation = this.resolveChannelConversation({ ...input.conversation, auditOnly: false });
1784
+ const transcript = this.createTranscript({ ...input.transcript, conversationId: conversation.id });
1785
+ const mapping = this.attachChannelMessage({ ...input.envelope, conversationId: conversation.id, transcriptMessageId: transcript.id });
1786
+ this.bumpChannelConversationLastSeen(conversation.id);
1787
+ return { conversation: this.getChannelConversation(conversation.id)!, mapping, transcript };
1788
+ })();
1789
+ }
1790
+
1791
+ listChannelMessagesForTranscript(transcriptMessageId: string): ChannelMessageMapping[] {
1792
+ return this.listChannelMessages({ transcriptMessageId });
1793
+ }
1794
+
1795
+ listChannelMessages(input: ListChannelMessagesInput = {}): ChannelMessageMapping[] {
1796
+ if (input.limit !== undefined && input.limit <= 0) return [];
1797
+ const conditions: string[] = [];
1798
+ const params: SQLQueryBindings[] = [];
1799
+
1800
+ if (input.conversationId !== undefined) {
1801
+ conditions.push('channel_conversation_id = ?');
1802
+ params.push(input.conversationId);
1803
+ }
1804
+ if (input.transcriptMessageId !== undefined) {
1805
+ if (input.transcriptMessageId === null) {
1806
+ conditions.push('transcript_message_id IS NULL');
1807
+ } else {
1808
+ conditions.push('transcript_message_id = ?');
1809
+ params.push(input.transcriptMessageId);
1810
+ }
1811
+ }
1812
+ if (input.status !== undefined) {
1813
+ conditions.push('status = ?');
1814
+ params.push(input.status);
1815
+ }
1816
+ if (input.source !== undefined) {
1817
+ conditions.push('source = ?');
1818
+ params.push(input.source);
1819
+ }
1820
+ if (input.admitted !== undefined) {
1821
+ conditions.push('admitted = ?');
1822
+ params.push(input.admitted ? 1 : 0);
1823
+ }
1824
+
1825
+ const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
1826
+ const rows = this.db.query(`
1827
+ SELECT * FROM channel_messages
1828
+ ${where}
1829
+ ORDER BY datetime(created_at) ASC, id ASC
1830
+ LIMIT ?
1831
+ `).all(...params, limitValue(input.limit, 50)) as Record<string, unknown>[];
1832
+ return rows.map(rowToChannelMessage);
1833
+ }
1834
+
1835
+ enqueueChannelOutbox(input: EnqueueChannelOutboxInput): ChannelOutboxRecord {
1836
+ const purpose = input.purpose?.trim() || 'reply';
1837
+ if (!this.getMessage(input.lockMessageId)) throw new Error(`lock message ${input.lockMessageId} was not found`);
1838
+
1839
+ const existing = this.db.query('SELECT * FROM channel_outbox WHERE idempotency_key = ?').get(input.idempotencyKey) as Record<string, unknown> | null;
1840
+ if (existing) {
1841
+ const outbox = rowToChannelOutbox(existing);
1842
+ if (
1843
+ outbox.channel_account_id !== input.accountId ||
1844
+ outbox.channel_conversation_id !== input.conversationId ||
1845
+ outbox.transcript_message_id !== input.transcriptMessageId ||
1846
+ outbox.lock_message_id !== input.lockMessageId ||
1847
+ outbox.purpose !== purpose
1848
+ ) {
1849
+ throw new Error('channel outbox idempotency key conflicts with a different semantic payload');
1850
+ }
1851
+ return outbox;
1852
+ }
1853
+
1854
+ const id = crypto.randomUUID();
1855
+ this.db.query(`
1856
+ INSERT INTO channel_outbox (id, channel_account_id, channel_conversation_id, transcript_message_id, lock_message_id, purpose, idempotency_key)
1857
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1858
+ `).run(id, input.accountId, input.conversationId, input.transcriptMessageId, input.lockMessageId, purpose, input.idempotencyKey);
1859
+ return this.getChannelOutbox(id)!;
1860
+ }
1861
+
1862
+ getChannelOutbox(id: string): ChannelOutboxRecord | null {
1863
+ const row = this.db.query('SELECT * FROM channel_outbox WHERE id = ?').get(id) as Record<string, unknown> | null;
1864
+ return row ? rowToChannelOutbox(row) : null;
1865
+ }
1866
+
1867
+ getChannelOutboxByIdempotencyKey(idempotencyKey: string): ChannelOutboxRecord | null {
1868
+ const row = this.db.query('SELECT * FROM channel_outbox WHERE idempotency_key = ?').get(idempotencyKey) as Record<string, unknown> | null;
1869
+ return row ? rowToChannelOutbox(row) : null;
1870
+ }
1871
+
1872
+ addProviderEvidence(input: AddProviderEvidenceInput): LockProviderEvidence {
1873
+ const id = crypto.randomUUID();
1874
+ this.db.query(`
1875
+ INSERT INTO lock_provider_evidence (id, transcript_message_id, provider_message_id, provider_event_id, attempt_id, evidence_type, receipt_state, error_code, error_message, raw_payload_redacted_json, observed_at)
1876
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1877
+ `).run(
1878
+ id,
1879
+ input.transcriptMessageId,
1880
+ input.providerMessageId ?? null,
1881
+ input.providerEventId ?? null,
1882
+ input.attemptId ?? null,
1883
+ input.evidenceType,
1884
+ input.receiptState,
1885
+ input.errorCode ?? null,
1886
+ input.errorMessage ?? null,
1887
+ input.rawPayloadRedactedJson ?? '{}',
1888
+ input.observedAt ?? null,
1889
+ );
1890
+ return this.getProviderEvidence(id)!;
1891
+ }
1892
+
1893
+ getProviderEvidence(id: string): LockProviderEvidence | null {
1894
+ const row = this.db.query('SELECT * FROM lock_provider_evidence WHERE id = ?').get(id) as Record<string, unknown> | null;
1895
+ return row ? rowToProviderEvidence(row) : null;
1896
+ }
1897
+
1898
+ listProviderEvidence(transcriptMessageId: string): LockProviderEvidence[] {
1899
+ const rows = this.db.query('SELECT * FROM lock_provider_evidence WHERE transcript_message_id = ? ORDER BY datetime(created_at) ASC').all(transcriptMessageId) as Record<string, unknown>[];
1900
+ return rows.map(rowToProviderEvidence);
1901
+ }
1902
+
1903
+ findChannelConversationByExternal(input: { accountId: string; externalChatId: string; externalThreadId?: string | null }): ChannelConversation | null {
1904
+ const conditions: string[] = ['channel_account_id = ?', 'external_chat_id = ?', 'audit_only = 0'];
1905
+ const params: SQLQueryBindings[] = [input.accountId, input.externalChatId];
1906
+ if (input.externalThreadId === undefined) {
1907
+ // no constraint on thread id
1908
+ } else if (input.externalThreadId === null) {
1909
+ conditions.push('external_thread_id IS NULL');
1910
+ } else {
1911
+ conditions.push('external_thread_id = ?');
1912
+ params.push(input.externalThreadId);
1913
+ }
1914
+
1915
+ const row = this.db.query(`
1916
+ SELECT * FROM channel_conversations
1917
+ WHERE ${conditions.join(' AND ')}
1918
+ ORDER BY datetime(last_seen_at) DESC, id ASC
1919
+ LIMIT 1
1920
+ `).get(...params) as Record<string, unknown> | null;
1921
+ return row ? rowToChannelConversation(row) : null;
1922
+ }
1923
+
1924
+ findTranscriptByExternalMessage(input: { accountId: string; externalChatId: string; externalMessageId: string }): LockTranscriptMessage | null {
1925
+ const row = this.db.query(`
1926
+ SELECT t.* FROM lock_transcript_messages t
1927
+ INNER JOIN channel_messages cm ON cm.transcript_message_id = t.id
1928
+ WHERE cm.channel_account_id = ?
1929
+ AND cm.external_chat_id = ?
1930
+ AND cm.external_message_id = ?
1931
+ ORDER BY t.ingest_seq ASC
1932
+ LIMIT 1
1933
+ `).get(input.accountId, input.externalChatId, input.externalMessageId) as Record<string, unknown> | null;
1934
+ return row ? rowToTranscript(row) : null;
1935
+ }
1936
+
1937
+ listTranscriptsByExternalThread(input: { accountId: string; externalChatId: string; externalThreadId: string; limit?: number }): LockTranscriptMessage[] {
1938
+ const rows = this.db.query(`
1939
+ SELECT t.* FROM lock_transcript_messages t
1940
+ INNER JOIN channel_conversations c ON c.id = t.conversation_id
1941
+ WHERE c.channel_account_id = ?
1942
+ AND c.external_chat_id = ?
1943
+ AND c.external_thread_id = ?
1944
+ ORDER BY t.ingest_seq ASC
1945
+ LIMIT ?
1946
+ `).all(input.accountId, input.externalChatId, input.externalThreadId, limitValue(input.limit, 50)) as Record<string, unknown>[];
1947
+ return rows.map(rowToTranscript);
1948
+ }
1949
+
1950
+ listTranscriptsByExternalRoot(input: { accountId: string; externalChatId: string; externalRootId: string; limit?: number }): LockTranscriptMessage[] {
1951
+ const rows = this.db.query(`
1952
+ SELECT t.* FROM lock_transcript_messages t
1953
+ INNER JOIN channel_conversations c ON c.id = t.conversation_id
1954
+ WHERE c.channel_account_id = ?
1955
+ AND c.external_chat_id = ?
1956
+ AND c.external_root_id = ?
1957
+ ORDER BY t.ingest_seq ASC
1958
+ LIMIT ?
1959
+ `).all(input.accountId, input.externalChatId, input.externalRootId, limitValue(input.limit, 50)) as Record<string, unknown>[];
1960
+ return rows.map(rowToTranscript);
1961
+ }
1962
+
1963
+ bindTranscriptToLockMessage(transcriptId: string, lockMessageId: number): LockTranscriptMessage {
1964
+ const transcript = this.getTranscript(transcriptId);
1965
+ if (!transcript) throw new Error(`transcript ${transcriptId} was not found`);
1966
+ const message = this.getMessage(lockMessageId);
1967
+ if (!message) throw new Error(`lock message ${lockMessageId} was not found`);
1968
+
1969
+ if (transcript.lock_message_id !== null) {
1970
+ if (transcript.lock_message_id !== lockMessageId) {
1971
+ throw new Error(`transcript ${transcriptId} is already bound to lock message ${transcript.lock_message_id}`);
1972
+ }
1973
+ return transcript;
1974
+ }
1975
+
1976
+ this.db.query('UPDATE lock_transcript_messages SET lock_message_id = ? WHERE id = ?').run(lockMessageId, transcriptId);
1977
+ return this.getTranscript(transcriptId)!;
1978
+ }
1979
+
1980
+ createMessage(input: CreateMessageInput): Message {
1981
+ const sender = input.sender.trim();
1982
+ const content = input.content.trim();
1983
+ if (!sender) throw new Error('sender is required');
1984
+ if (!content) throw new Error('content is required');
1985
+
1986
+ let chat: Chat | null = null;
1987
+ let parentId: number | null = null;
1988
+ let depth: 0 | 1 = 0;
1989
+
1990
+ if (input.parentId !== undefined) {
1991
+ const parent = this.getMessage(input.parentId);
1992
+ if (!parent) throw new Error(`parent message ${input.parentId} was not found`);
1993
+ if (parent.parent_id !== null || parent.depth !== 0) {
1994
+ throw new Error('topics cannot derive another topic');
1995
+ }
1996
+ chat = this.getChatById(parent.chat_id);
1997
+ if (!chat) throw new Error(`chat ${parent.chat_id} was not found`);
1998
+ parentId = parent.id;
1999
+ depth = 1;
2000
+ } else if (input.chatId) {
2001
+ chat = this.getChatById(input.chatId);
2002
+ if (!chat) throw new Error(`chat ${input.chatId} was not found`);
2003
+ } else {
2004
+ chat = this.getOrCreateChat(input.chatName ?? 'general');
2005
+ }
2006
+
2007
+ const idempotencyKey = input.idempotencyKey?.trim() || null;
2008
+ if (idempotencyKey) {
2009
+ const existing = this.db.query(`
2010
+ SELECT m.*, c.name AS chat_name
2011
+ FROM messages m
2012
+ JOIN chats c ON c.id = m.chat_id
2013
+ WHERE m.idempotency_key = ?
2014
+ `).get(idempotencyKey) as Record<string, unknown> | null;
2015
+ if (existing) return rowToMessage(existing);
2016
+ }
2017
+
2018
+ const mentions = normalizeMentionsInput(input.mentions);
2019
+ const channelId = input.channelId?.trim() || null;
2020
+ if (channelId) {
2021
+ const channel = this.db.query('SELECT room_id FROM room_channels WHERE id = ?').get(channelId) as { room_id: string } | null;
2022
+ if (!channel) throw new Error(`channel ${channelId} was not found`);
2023
+ if (channel.room_id !== chat.id) throw new Error(`channel ${channelId} does not belong to room ${chat.id}`);
2024
+ }
2025
+ const provider = input.provider ?? chat.provider ?? 'web';
2026
+ const result = this.db.query(`
2027
+ INSERT INTO messages (chat_id, parent_id, depth, sender, recipient, content, type, idempotency_key, channel_id, provider, mentions_json)
2028
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2029
+ `).run(
2030
+ chat.id,
2031
+ parentId,
2032
+ depth,
2033
+ sender,
2034
+ input.recipient?.trim() || null,
2035
+ content,
2036
+ input.type ?? 'message',
2037
+ idempotencyKey,
2038
+ channelId,
2039
+ provider,
2040
+ JSON.stringify(mentions),
2041
+ );
2042
+
2043
+ const id = Number(result.lastInsertRowid);
2044
+ return this.getMessage(id)!;
2045
+ }
2046
+
2047
+ getMessage(id: number): Message | null {
2048
+ const row = this.db.query(`
2049
+ SELECT m.*, c.name AS chat_name
2050
+ FROM messages m
2051
+ JOIN chats c ON c.id = m.chat_id
2052
+ WHERE m.id = ?
2053
+ `).get(id) as Record<string, unknown> | null;
2054
+ return row ? rowToMessage(row) : null;
2055
+ }
2056
+
2057
+ listMessages(input: ListMessagesInput = {}): Message[] {
2058
+ const conditions: string[] = [];
2059
+ const params: SQLQueryBindings[] = [];
2060
+
2061
+ if (input.chatId) {
2062
+ conditions.push('m.chat_id = ?');
2063
+ params.push(input.chatId);
2064
+ } else if (input.chatName) {
2065
+ conditions.push('c.name = ?');
2066
+ params.push(normalizeChatName(input.chatName));
2067
+ }
2068
+
2069
+ if (input.parentId !== undefined) {
2070
+ if (input.parentId === null) {
2071
+ conditions.push('m.parent_id IS NULL');
2072
+ } else {
2073
+ conditions.push('(m.id = ? OR m.parent_id = ?)');
2074
+ params.push(input.parentId, input.parentId);
2075
+ }
2076
+ }
2077
+
2078
+ if (input.after !== undefined) {
2079
+ conditions.push('m.id > ?');
2080
+ params.push(input.after);
2081
+ }
2082
+
2083
+ if (input.q) {
2084
+ conditions.push('m.content LIKE ?');
2085
+ params.push(`%${input.q}%`);
2086
+ }
2087
+
2088
+ const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
2089
+ const rows = this.db.query(`
2090
+ SELECT m.*, c.name AS chat_name
2091
+ FROM messages m
2092
+ JOIN chats c ON c.id = m.chat_id
2093
+ ${where}
2094
+ ORDER BY m.id ASC
2095
+ LIMIT ?
2096
+ `).all(...params, limitValue(input.limit, 50)) as Record<string, unknown>[];
2097
+
2098
+ return rows.map(rowToMessage);
2099
+ }
2100
+
2101
+ listInbox(agent: string, after = 0, limit = 50): Message[] {
2102
+ const rows = this.db.query(`
2103
+ SELECT m.*, c.name AS chat_name
2104
+ FROM messages m
2105
+ JOIN chats c ON c.id = m.chat_id
2106
+ WHERE m.id > ?
2107
+ AND m.sender != ?
2108
+ AND (m.recipient IS NULL OR m.recipient = ?)
2109
+ ORDER BY m.id ASC
2110
+ LIMIT ?
2111
+ `).all(after, agent, agent, limitValue(limit, 50)) as Record<string, unknown>[];
2112
+
2113
+ return rows.map(rowToMessage);
2114
+ }
2115
+
2116
+ getComputer(id: string): Computer | null {
2117
+ const row = this.db.query('SELECT * FROM computers WHERE id = ?').get(id.trim()) as Record<string, unknown> | null;
2118
+ return row ? rowToComputer(row) : null;
2119
+ }
2120
+
2121
+ listComputers(limit = 50): Computer[] {
2122
+ const rows = this.db.query(`
2123
+ SELECT * FROM computers
2124
+ ORDER BY datetime(updated_at) DESC, datetime(created_at) DESC
2125
+ LIMIT ?
2126
+ `).all(limitValue(limit, 50)) as Record<string, unknown>[];
2127
+ return rows.map(rowToComputer);
2128
+ }
2129
+
2130
+ private getComputerByCredential(secret: string): Computer | null {
2131
+ const row = this.db.query('SELECT * FROM computers WHERE credential_hash = ? ORDER BY created_at DESC LIMIT 1').get(hashSecret(secret.trim())) as Record<string, unknown> | null;
2132
+ return row ? rowToComputer(row) : null;
2133
+ }
2134
+
2135
+ getComputerConnection(id: string): ComputerConnection | null {
2136
+ const row = this.db.query('SELECT * FROM computer_connections WHERE id = ?').get(id.trim()) as Record<string, unknown> | null;
2137
+ return row ? rowToComputerConnection(row) : null;
2138
+ }
2139
+
2140
+ provisionComputer(input: ProvisionComputerInput = {}): ProvisionedComputer {
2141
+ const id = `machine_${randomBytes(8).toString('hex')}`;
2142
+ const apiKey = `sk_machine_${randomBytes(32).toString('hex')}`;
2143
+ const name = input.name?.trim() || id;
2144
+ this.db.query(`
2145
+ INSERT INTO computers (id, name, credential_hash, status, active_connection_id, last_seen_at, updated_at)
2146
+ VALUES (?, ?, ?, 'offline', NULL, NULL, datetime('now'))
2147
+ `).run(id, name, hashSecret(apiKey));
2148
+
2149
+ const packageName = input.packageName?.trim() || '@slock-ai/daemon@latest';
2150
+ const serverUrl = input.serverUrl?.trim() || 'http://127.0.0.1:4127';
2151
+ const command = `npx ${packageName} --server-url ${serverUrl} --api-key ${apiKey} # ${name}`;
2152
+ return { computer: this.getComputer(id)!, api_key: apiKey, command };
2153
+ }
2154
+
2155
+ connectComputer(input: ConnectComputerInput): ConnectComputerResult {
2156
+ const apiKey = input.apiKey?.trim();
2157
+ const provisionedComputer = apiKey ? this.getComputerByCredential(apiKey) : null;
2158
+ const computerId = (provisionedComputer?.id ?? input.computerId ?? '').trim();
2159
+ const secret = (apiKey ?? input.secret ?? '').trim();
2160
+ if (!computerId) throw new Error('computer_id is required');
2161
+ if (!secret) throw new Error('computer secret is required');
2162
+ const credentialHash = hashSecret(secret);
2163
+ const existing = this.db.query('SELECT credential_hash FROM computers WHERE id = ?').get(computerId) as { credential_hash: string } | null;
2164
+ if (existing && existing.credential_hash !== credentialHash) {
2165
+ throw new Error('invalid computer credential');
2166
+ }
2167
+
2168
+ const connectionId = crypto.randomUUID();
2169
+ const token = generateConnectionToken();
2170
+ const tokenHash = hashSecret(token);
2171
+ const revokedRows = this.db.query(`
2172
+ SELECT id FROM computer_connections
2173
+ WHERE computer_id = ? AND status = 'active'
2174
+ `).all(computerId) as Array<{ id: string }>;
2175
+ const revokedConnectionIds = revokedRows.map((row) => row.id);
2176
+ const epochRow = this.db.query('SELECT COALESCE(MAX(epoch), 0) + 1 AS epoch FROM computer_connections WHERE computer_id = ?').get(computerId) as { epoch: number };
2177
+ const epoch = Number(epochRow.epoch);
2178
+ const name = input.name?.trim() || computerId;
2179
+
2180
+ this.db.transaction(() => {
2181
+ if (!existing) {
2182
+ this.db.query(`
2183
+ INSERT INTO computers (id, name, credential_hash, status, active_connection_id, last_seen_at, updated_at)
2184
+ VALUES (?, ?, ?, 'online', ?, datetime('now'), datetime('now'))
2185
+ `).run(computerId, name, credentialHash, connectionId);
2186
+ } else {
2187
+ this.db.query(`
2188
+ UPDATE computers
2189
+ SET name = ?, status = 'online', active_connection_id = ?, last_seen_at = datetime('now'), updated_at = datetime('now')
2190
+ WHERE id = ?
2191
+ `).run(name, connectionId, computerId);
2192
+ }
2193
+ this.db.query(`
2194
+ UPDATE computer_connections
2195
+ SET status = 'revoked', revoked_at = COALESCE(revoked_at, datetime('now'))
2196
+ WHERE computer_id = ? AND status = 'active'
2197
+ `).run(computerId);
2198
+ if (revokedConnectionIds.length > 0) {
2199
+ for (const revokedConnectionId of revokedConnectionIds) {
2200
+ this.db.query(`
2201
+ UPDATE agent_runs
2202
+ SET status = 'killed',
2203
+ action = NULL,
2204
+ ended_at = COALESCE(ended_at, datetime('now')),
2205
+ updated_at = datetime('now'),
2206
+ output = CASE WHEN output = '' THEN 'connection revoked by newer daemon connection' ELSE output END
2207
+ WHERE connection_id = ? AND status = 'running'
2208
+ `).run(revokedConnectionId);
2209
+ this.db.query(`
2210
+ UPDATE message_deliveries
2211
+ SET status = 'pending',
2212
+ daemon_id = NULL,
2213
+ connection_id = NULL,
2214
+ claim_token = NULL,
2215
+ lease_until = NULL,
2216
+ run_id = NULL,
2217
+ last_error = '',
2218
+ claimed_at = NULL
2219
+ WHERE connection_id = ? AND status IN ('claimed', 'processing_completed')
2220
+ `).run(revokedConnectionId);
2221
+ }
2222
+ }
2223
+ this.db.query(`
2224
+ INSERT INTO computer_connections (id, computer_id, token_hash, epoch, status, connected_at, last_heartbeat_at)
2225
+ VALUES (?, ?, ?, ?, 'active', datetime('now'), datetime('now'))
2226
+ `).run(connectionId, computerId, tokenHash, epoch);
2227
+ this.db.query(`
2228
+ INSERT INTO daemon_instances (id, name, host, local_url, server_url, status, last_seen_at)
2229
+ VALUES (?, ?, ?, ?, ?, 'online', datetime('now'))
2230
+ ON CONFLICT(id) DO UPDATE SET
2231
+ name = excluded.name,
2232
+ host = excluded.host,
2233
+ local_url = excluded.local_url,
2234
+ server_url = excluded.server_url,
2235
+ status = 'online',
2236
+ last_seen_at = datetime('now')
2237
+ `).run(connectionId, name, input.host ?? '', input.localUrl ?? '', input.serverUrl ?? '');
2238
+ for (const agent of input.agents ?? []) {
2239
+ const agentName = agent.agent.trim();
2240
+ if (!agentName) continue;
2241
+ this.db.query(`
2242
+ INSERT INTO daemon_agents (daemon_id, agent, cwd, capabilities, status, last_seen_at)
2243
+ VALUES (?, ?, ?, ?, 'online', datetime('now'))
2244
+ ON CONFLICT(daemon_id, agent) DO UPDATE SET
2245
+ cwd = excluded.cwd,
2246
+ capabilities = excluded.capabilities,
2247
+ status = 'online',
2248
+ last_seen_at = datetime('now')
2249
+ `).run(connectionId, agentName, agent.cwd ?? '', JSON.stringify(agent.capabilities ?? {}));
2250
+ }
2251
+ })();
2252
+
2253
+ return {
2254
+ computer: this.getComputer(computerId)!,
2255
+ connection: this.getComputerConnection(connectionId)!,
2256
+ token,
2257
+ daemon: this.getDaemon(connectionId)!,
2258
+ agents: this.listComputerAgentAssignments(computerId),
2259
+ };
2260
+ }
2261
+
2262
+ closeStaleComputerConnections(timeoutMs: number, now = new Date()): number {
2263
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0) throw new Error('timeoutMs must be non-negative');
2264
+ const cutoff = new Date(now.getTime() - timeoutMs).toISOString();
2265
+ const staleRows = this.db.query(`
2266
+ SELECT cc.id, cc.computer_id
2267
+ FROM computer_connections cc
2268
+ JOIN computers c ON c.active_connection_id = cc.id
2269
+ WHERE cc.status = 'active' AND datetime(cc.last_heartbeat_at) < datetime(?)
2270
+ `).all(cutoff) as Array<{ id: string; computer_id: string }>;
2271
+ if (staleRows.length === 0) return 0;
2272
+ this.db.transaction(() => {
2273
+ for (const row of staleRows) {
2274
+ this.db.query(`
2275
+ UPDATE computer_connections
2276
+ SET status = 'closed', revoked_at = COALESCE(revoked_at, datetime('now'))
2277
+ WHERE id = ? AND status = 'active'
2278
+ `).run(row.id);
2279
+ this.db.query(`
2280
+ UPDATE computers
2281
+ SET status = 'offline', active_connection_id = NULL, updated_at = datetime('now')
2282
+ WHERE id = ? AND active_connection_id = ?
2283
+ `).run(row.computer_id, row.id);
2284
+ }
2285
+ })();
2286
+ return staleRows.length;
2287
+ }
2288
+
2289
+ assertActiveComputerConnection(input: ComputerAuthInput): ComputerConnection {
2290
+ const computerId = input.computerId.trim();
2291
+ const connectionId = input.connectionId.trim();
2292
+ const token = input.token.trim();
2293
+ const row = this.db.query(`
2294
+ SELECT cc.*
2295
+ FROM computer_connections cc
2296
+ JOIN computers c ON c.id = cc.computer_id
2297
+ WHERE cc.id = ? AND cc.computer_id = ? AND cc.status = 'active'
2298
+ AND cc.token_hash = ? AND c.active_connection_id = cc.id AND c.status = 'online'
2299
+ LIMIT 1
2300
+ `).get(connectionId, computerId, hashSecret(token)) as Record<string, unknown> | null;
2301
+ if (!row) throw new Error('CONNECTION_REVOKED');
2302
+ return rowToComputerConnection(row);
2303
+ }
2304
+
2305
+ heartbeatComputer(input: ComputerAuthInput): { computer: Computer; connection: ComputerConnection; agents: ComputerAgentAssignment[] } {
2306
+ const connection = this.assertActiveComputerConnection(input);
2307
+ this.db.query(`
2308
+ UPDATE computer_connections SET last_heartbeat_at = datetime('now') WHERE id = ?
2309
+ `).run(connection.id);
2310
+ this.db.query(`
2311
+ UPDATE computers SET status = 'online', last_seen_at = datetime('now'), updated_at = datetime('now') WHERE id = ?
2312
+ `).run(connection.computer_id);
2313
+ return {
2314
+ computer: this.getComputer(connection.computer_id)!,
2315
+ connection: this.getComputerConnection(connection.id)!,
2316
+ agents: this.listComputerAgentAssignments(connection.computer_id),
2317
+ };
2318
+ }
2319
+
2320
+ registerDaemon(input: RegisterDaemonInput): DaemonInstance {
2321
+ const id = input.id?.trim() || crypto.randomUUID();
2322
+ const name = input.name.trim();
2323
+ if (!name) throw new Error('daemon name is required');
2324
+
2325
+ this.db.transaction(() => {
2326
+ this.db.query(`
2327
+ INSERT INTO daemon_instances (id, name, host, local_url, server_url, status, last_seen_at)
2328
+ VALUES (?, ?, ?, ?, ?, 'online', datetime('now'))
2329
+ ON CONFLICT(id) DO UPDATE SET
2330
+ name = excluded.name,
2331
+ host = excluded.host,
2332
+ local_url = excluded.local_url,
2333
+ server_url = excluded.server_url,
2334
+ status = 'online',
2335
+ last_seen_at = datetime('now')
2336
+ `).run(id, name, input.host ?? '', input.localUrl ?? '', input.serverUrl ?? '');
2337
+
2338
+ this.db.query(`
2339
+ UPDATE daemon_agents SET status = 'offline', last_seen_at = datetime('now') WHERE daemon_id = ?
2340
+ `).run(id);
2341
+
2342
+ for (const agent of input.agents ?? []) {
2343
+ const agentName = agent.agent.trim();
2344
+ if (!agentName) continue;
2345
+ this.db.query(`
2346
+ INSERT INTO daemon_agents (daemon_id, agent, cwd, capabilities, status, last_seen_at)
2347
+ VALUES (?, ?, ?, ?, 'online', datetime('now'))
2348
+ ON CONFLICT(daemon_id, agent) DO UPDATE SET
2349
+ cwd = excluded.cwd,
2350
+ capabilities = excluded.capabilities,
2351
+ status = 'online',
2352
+ last_seen_at = datetime('now')
2353
+ `).run(id, agentName, agent.cwd ?? '', JSON.stringify(agent.capabilities ?? {}));
2354
+ }
2355
+ })();
2356
+
2357
+ return this.getDaemon(id)!;
2358
+ }
2359
+
2360
+ getDaemon(id: string): DaemonInstance | null {
2361
+ const row = this.db.query('SELECT * FROM daemon_instances WHERE id = ?').get(id) as Record<string, unknown> | null;
2362
+ return row ? rowToDaemon(row) : null;
2363
+ }
2364
+
2365
+ daemonHasAgent(daemonId: string, agent: string): boolean {
2366
+ const row = this.db.query(`
2367
+ SELECT 1 FROM daemon_agents
2368
+ WHERE daemon_id = ? AND agent = ? AND status = 'online'
2369
+ LIMIT 1
2370
+ `).get(daemonId, agent.trim()) as Record<string, unknown> | null;
2371
+ return row !== null;
2372
+ }
2373
+
2374
+ hasDaemonAgent(agent: string): boolean {
2375
+ const row = this.db.query(`
2376
+ SELECT 1 FROM daemon_agents
2377
+ WHERE agent = ? AND status = 'online'
2378
+ LIMIT 1
2379
+ `).get(agent.trim()) as Record<string, unknown> | null;
2380
+ return row !== null;
2381
+ }
2382
+
2383
+ listDeliveries(agent: string, status = 'pending', limit = 50): MessageDelivery[] {
2384
+ const rows = this.db.query(`
2385
+ SELECT * FROM message_deliveries
2386
+ WHERE agent = ? AND status = ?
2387
+ ORDER BY datetime(created_at) ASC
2388
+ LIMIT ?
2389
+ `).all(agent, status, limitValue(limit, 50)) as Record<string, unknown>[];
2390
+ return rows.map(rowToDelivery);
2391
+ }
2392
+
2393
+ listAllDeliveries(limit = 50): MessageDelivery[] {
2394
+ const rows = this.db.query(`
2395
+ SELECT * FROM message_deliveries
2396
+ ORDER BY datetime(created_at) DESC
2397
+ LIMIT ?
2398
+ `).all(limitValue(limit, 50)) as Record<string, unknown>[];
2399
+ return rows.map(rowToDelivery);
2400
+ }
2401
+
2402
+ createDelivery(input: CreateDeliveryInput): MessageDelivery {
2403
+
2404
+ const message = this.getMessage(input.messageId);
2405
+ if (!message) throw new Error(`message ${input.messageId} was not found`);
2406
+ const agent = input.agent.trim();
2407
+ if (!agent) throw new Error('delivery agent is required');
2408
+ if (message.sender === agent) throw new Error('cannot deliver a message to its sender');
2409
+ if (message.recipient !== null && message.recipient !== agent) {
2410
+ throw new Error(`message ${message.id} is addressed to ${message.recipient}`);
2411
+ }
2412
+
2413
+ const idempotencyKey = `${message.id}:${message.chat_id}:${agent}`;
2414
+ const existing = this.db.query('SELECT * FROM message_deliveries WHERE idempotency_key = ?').get(idempotencyKey) as Record<string, unknown> | null;
2415
+ if (existing) return rowToDelivery(existing);
2416
+
2417
+ const id = crypto.randomUUID();
2418
+ this.db.query(`
2419
+ INSERT INTO message_deliveries (id, message_id, chat_id, agent, status, idempotency_key)
2420
+ VALUES (?, ?, ?, ?, 'pending', ?)
2421
+ `).run(id, message.id, message.chat_id, agent, idempotencyKey);
2422
+
2423
+ return this.getDelivery(id)!;
2424
+ }
2425
+
2426
+
2427
+ resolveDeliveriesForMessage(messageId: number): MessageDelivery[] {
2428
+ const message = this.getMessage(messageId);
2429
+ if (!message) throw new Error(`message ${messageId} was not found`);
2430
+ const room = this.getChatById(message.chat_id);
2431
+ if (!room) throw new Error(`room ${message.chat_id} was not found`);
2432
+ const candidates = new Set<string>();
2433
+ if (message.recipient && this.getAgent(message.recipient)) candidates.add(message.recipient);
2434
+ for (const mention of message.mentions ?? []) {
2435
+ if (this.getAgent(mention)) candidates.add(mention);
2436
+ }
2437
+ if (room.kind === 'dm') {
2438
+ const rows = this.db.query(`
2439
+ SELECT participant_id AS agent FROM room_participants
2440
+ WHERE room_id = ? AND kind = 'agent' AND status = 'active'
2441
+ `).all(room.id) as Array<{ agent: string }>;
2442
+ for (const row of rows) candidates.add(row.agent);
2443
+ }
2444
+ const allRows = this.db.query(`
2445
+ SELECT agent FROM agent_room_subscriptions
2446
+ WHERE room_id = ? AND channel_id IS NULL AND mode IN ('all', 'periodic')
2447
+ `).all(room.id) as Array<{ agent: string }>;
2448
+ for (const row of allRows) candidates.add(row.agent);
2449
+
2450
+ const deliveries: MessageDelivery[] = [];
2451
+ for (const agent of candidates) {
2452
+ if (agent === message.sender) continue;
2453
+ if (!this.shouldCreateDeliveryForAgent(message, room, agent)) continue;
2454
+ try {
2455
+ deliveries.push(this.createDelivery({ messageId: message.id, agent }));
2456
+ } catch {
2457
+ // createDelivery enforces recipient/idempotency constraints; resolver skips conflicts.
2458
+ }
2459
+ }
2460
+ return deliveries;
2461
+ }
2462
+
2463
+ private shouldCreateDeliveryForAgent(message: Message, room: Chat, agent: string): boolean {
2464
+ if (!this.canAgentParticipateInRoom(agent, room)) return false;
2465
+ const direct = message.recipient === agent || (message.mentions ?? []).includes(agent);
2466
+ if (room.kind === 'dm') return direct || message.recipient === null;
2467
+ const subscription = this.getAgentRoomSubscription(agent, room.id);
2468
+ const mode = subscription?.mode ?? 'mentions';
2469
+ if (mode === 'off') return false;
2470
+ if (direct) return true;
2471
+ if (mode === 'all') return true;
2472
+ return false;
2473
+ }
2474
+
2475
+ canAgentParticipateInRoom(agent: string, room: Chat): boolean {
2476
+ if (!this.getAgent(agent)) return false;
2477
+ if (room.provider === 'web') {
2478
+ const membership = this.getRoomParticipant(room.id, agent);
2479
+ if (membership?.kind === 'agent' && membership.status === 'active') return true;
2480
+ const row = this.db.query("SELECT COUNT(*) AS n FROM room_participants WHERE room_id = ? AND kind = 'agent' AND status = 'active'").get(room.id) as { n: number };
2481
+ return Number(row.n) === 0;
2482
+ }
2483
+ const account = this.db.query(`
2484
+ SELECT 1 FROM provider_accounts
2485
+ WHERE provider = ? AND agent = ? AND status = 'active'
2486
+ LIMIT 1
2487
+ `).get(room.provider, agent) as Record<string, unknown> | null;
2488
+ return account !== null;
2489
+ }
2490
+
2491
+ canSendWebMessageToRoom(input: { room: Chat; sender: string }): boolean {
2492
+ if (input.room.provider === 'web') return true;
2493
+ if (!this.getAgent(input.sender)) return false;
2494
+ if (input.room.provider === 'lark') {
2495
+ const route = this.findLarkChannelForChat(input.room.id);
2496
+ return route?.account.agent === input.sender;
2497
+ }
2498
+ return false;
2499
+ }
2500
+
2501
+ recordPendingInboundEvent(input: { rawEventId: string; provider: Exclude<RoomProvider, 'web'>; providerAccountId?: string | null; reason: string; error?: string; nextRetryAt?: string | null }): PendingInboundEvent {
2502
+ const id = crypto.randomUUID();
2503
+ this.db.query(`
2504
+ INSERT INTO pending_inbound_events (id, raw_event_id, provider, provider_account_id, reason, next_retry_at, last_error, status, created_at, updated_at)
2505
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', datetime('now'), datetime('now'))
2506
+ ON CONFLICT(raw_event_id, reason) DO UPDATE SET
2507
+ retry_count = retry_count + 1,
2508
+ next_retry_at = excluded.next_retry_at,
2509
+ last_error = excluded.last_error,
2510
+ updated_at = datetime('now')
2511
+ `).run(id, input.rawEventId, input.provider, input.providerAccountId ?? null, input.reason, input.nextRetryAt ?? null, input.error ?? '');
2512
+ const row = this.db.query('SELECT * FROM pending_inbound_events WHERE raw_event_id = ? AND reason = ?').get(input.rawEventId, input.reason) as Record<string, unknown>;
2513
+ return rowToPendingInboundEvent(row);
2514
+ }
2515
+
2516
+ getDelivery(id: string): MessageDelivery | null {
2517
+ const row = this.db.query('SELECT * FROM message_deliveries WHERE id = ?').get(id) as Record<string, unknown> | null;
2518
+ return row ? rowToDelivery(row) : null;
2519
+ }
2520
+
2521
+ getOrCreateSession(input: { chatId: string; agent: string; daemonId: string; cwd: string; lastMessageId?: number | null; computerId?: string | null; runtimeProvider?: string | null }): AgentSession {
2522
+ const computerId = input.computerId?.trim() || null;
2523
+ const runtimeProvider = input.runtimeProvider?.trim() || null;
2524
+ const existing = computerId && runtimeProvider
2525
+ ? this.db.query(`
2526
+ SELECT * FROM agent_sessions
2527
+ WHERE chat_id = ? AND agent = ? AND computer_id = ? AND runtime_provider = ?
2528
+ `).get(input.chatId, input.agent, computerId, runtimeProvider) as Record<string, unknown> | null
2529
+ : this.db.query(`
2530
+ SELECT * FROM agent_sessions
2531
+ WHERE chat_id = ? AND agent = ? AND daemon_id = ?
2532
+ `).get(input.chatId, input.agent, input.daemonId) as Record<string, unknown> | null;
2533
+
2534
+ if (existing) {
2535
+ this.db.query(`
2536
+ UPDATE agent_sessions
2537
+ SET daemon_id = ?, cwd = ?, last_message_id = COALESCE(?, last_message_id), updated_at = datetime('now')
2538
+ WHERE id = ?
2539
+ `).run(input.daemonId, input.cwd, input.lastMessageId ?? null, String(existing.id));
2540
+ return this.getSession(String(existing.id))!;
2541
+ }
2542
+
2543
+ const id = crypto.randomUUID();
2544
+ const sessionKey = computerId && runtimeProvider
2545
+ ? `${input.chatId}:${input.agent}:${computerId}:${runtimeProvider}`
2546
+ : `${input.chatId}:${input.agent}:${input.daemonId}`;
2547
+ this.db.query(`
2548
+ INSERT INTO agent_sessions (id, chat_id, agent, daemon_id, cwd, session_key, last_message_id, computer_id, runtime_provider)
2549
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
2550
+ `).run(id, input.chatId, input.agent, input.daemonId, input.cwd, sessionKey, input.lastMessageId ?? null, computerId, runtimeProvider);
2551
+ return this.getSession(id)!;
2552
+ }
2553
+
2554
+ getSession(id: string): AgentSession | null {
2555
+ const row = this.db.query('SELECT * FROM agent_sessions WHERE id = ?').get(id) as Record<string, unknown> | null;
2556
+ return row ? rowToSession(row) : null;
2557
+ }
2558
+
2559
+ updateSessionRuntimeSessionId(id: string, runtimeSessionId: string): AgentSession {
2560
+ const trimmed = runtimeSessionId.trim();
2561
+ if (!trimmed) throw new Error('runtimeSessionId is required');
2562
+ const result = this.db.query(`
2563
+ UPDATE agent_sessions
2564
+ SET runtime_session_id = ?, updated_at = datetime('now')
2565
+ WHERE id = ?
2566
+ `).run(trimmed, id);
2567
+ if (result.changes === 0) throw new Error(`session ${id} was not found`);
2568
+ return this.getSession(id)!;
2569
+ }
2570
+
2571
+ listSessions(limit = 50): AgentSession[] {
2572
+ const rows = this.db.query(`
2573
+ SELECT * FROM agent_sessions
2574
+ ORDER BY datetime(updated_at) DESC, datetime(created_at) DESC
2575
+ LIMIT ?
2576
+ `).all(limitValue(limit, 50)) as Record<string, unknown>[];
2577
+ return rows.map(rowToSession);
2578
+ }
2579
+
2580
+ listRunsForSession(sessionId: string, limit = 50): AgentRun[] {
2581
+ const rows = this.db.query(`
2582
+ SELECT * FROM agent_runs
2583
+ WHERE session_id = ?
2584
+ ORDER BY datetime(updated_at) DESC, datetime(started_at) DESC
2585
+ LIMIT ?
2586
+ `).all(sessionId, limitValue(limit, 50)) as Record<string, unknown>[];
2587
+ return rows.map(rowToRun);
2588
+ }
2589
+
2590
+ claimDelivery(id: string, input: ClaimDeliveryInput): MessageDelivery {
2591
+ const claimToken = crypto.randomUUID();
2592
+ const leaseMs = input.leaseMs ?? 30_000;
2593
+ const leaseUntil = new Date(Date.now() + leaseMs).toISOString();
2594
+ const delivery = this.getDelivery(id);
2595
+ if (!delivery) throw new Error(`pending delivery ${id} was not found`);
2596
+ if (input.computerId) {
2597
+ const busy = this.db.query(`
2598
+ SELECT 1
2599
+ FROM agent_runs r
2600
+ JOIN agent_sessions s ON s.id = r.session_id
2601
+ WHERE r.computer_id = ? AND r.agent = ? AND s.chat_id = ? AND r.status = 'running'
2602
+ LIMIT 1
2603
+ `).get(input.computerId, delivery.agent, delivery.chat_id) as Record<string, unknown> | null;
2604
+ if (busy) throw new Error(`runtime invocation already running for computer ${input.computerId} agent ${delivery.agent} chat ${delivery.chat_id}`);
2605
+ }
2606
+ const connectionId = input.connectionId ?? null;
2607
+ const result = this.db.query(`
2608
+ UPDATE message_deliveries
2609
+ SET status = 'claimed', daemon_id = ?, connection_id = ?, claim_token = ?, lease_until = ?, attempts = attempts + 1, claimed_at = datetime('now')
2610
+ WHERE id = ? AND status = 'pending'
2611
+ `).run(input.daemonId, connectionId, claimToken, leaseUntil, id);
2612
+ if (result.changes === 0) throw new Error(`pending delivery ${id} was not found`);
2613
+ return this.getDelivery(id)!;
2614
+ }
2615
+
2616
+ markDeliveryProcessingCompleted(id: string, input: FinishDeliveryInput): MessageDelivery {
2617
+ const result = this.db.query(`
2618
+ UPDATE message_deliveries
2619
+ SET status = 'processing_completed', run_id = COALESCE(?, run_id)
2620
+ WHERE id = ? AND status = 'claimed' AND (connection_id = ? OR (connection_id IS NULL AND daemon_id = ?)) AND claim_token = ?
2621
+ `).run(input.runId ?? null, id, input.connectionId ?? input.daemonId, input.daemonId, input.claimToken);
2622
+ if (result.changes === 0) throw new Error(`claimed delivery ${id} was not found`);
2623
+ return this.getDelivery(id)!;
2624
+ }
2625
+
2626
+ cancelDelivery(id: string): MessageDelivery {
2627
+ const result = this.db.query(`
2628
+ UPDATE message_deliveries
2629
+ SET status = 'canceled'
2630
+ WHERE id = ? AND status IN ('pending', 'claimed', 'processing_completed')
2631
+ `).run(id);
2632
+ if (result.changes === 0) throw new Error(`active delivery ${id} was not found`);
2633
+ return this.getDelivery(id)!;
2634
+ }
2635
+
2636
+ ackDelivery(id: string, input: FinishDeliveryInput): MessageDelivery {
2637
+ const existing = this.getDelivery(id);
2638
+ if (existing?.status === 'acked') {
2639
+ if ((existing.connection_id ?? existing.daemon_id) === (input.connectionId ?? input.daemonId) && existing.claim_token === input.claimToken && existing.run_id === (input.runId ?? null)) {
2640
+ return existing;
2641
+ }
2642
+ throw new Error(`terminal delivery ${id} conflicts with ack request`);
2643
+ }
2644
+ if (existing && ['failed', 'canceled'].includes(existing.status)) {
2645
+ throw new Error(`terminal delivery ${id} cannot be acked from ${existing.status}`);
2646
+ }
2647
+
2648
+ const result = this.db.query(`
2649
+ UPDATE message_deliveries
2650
+ SET status = 'acked', run_id = COALESCE(?, run_id), acked_at = COALESCE(acked_at, datetime('now'))
2651
+ WHERE id = ? AND status IN ('claimed', 'processing_completed') AND (connection_id = ? OR (connection_id IS NULL AND daemon_id = ?)) AND claim_token = ?
2652
+ `).run(input.runId ?? null, id, input.connectionId ?? input.daemonId, input.daemonId, input.claimToken);
2653
+ if (result.changes === 0) throw new Error(`claimed delivery ${id} was not found`);
2654
+ return this.getDelivery(id)!;
2655
+ }
2656
+
2657
+ failDelivery(id: string, input: FinishDeliveryInput): MessageDelivery {
2658
+ const existing = this.getDelivery(id);
2659
+ if (existing?.status === 'failed') {
2660
+ if ((existing.connection_id ?? existing.daemon_id) === (input.connectionId ?? input.daemonId) && existing.claim_token === input.claimToken && existing.run_id === (input.runId ?? null)) {
2661
+ return existing;
2662
+ }
2663
+ throw new Error(`terminal delivery ${id} conflicts with fail request`);
2664
+ }
2665
+ if (existing && ['acked', 'canceled'].includes(existing.status)) {
2666
+ throw new Error(`terminal delivery ${id} cannot fail from ${existing.status}`);
2667
+ }
2668
+
2669
+ const result = this.db.query(`
2670
+ UPDATE message_deliveries
2671
+ SET status = 'failed', run_id = COALESCE(?, run_id), last_error = ?, failed_at = COALESCE(failed_at, datetime('now'))
2672
+ WHERE id = ? AND status IN ('claimed', 'processing_completed') AND (connection_id = ? OR (connection_id IS NULL AND daemon_id = ?)) AND claim_token = ?
2673
+ `).run(input.runId ?? null, input.error ?? '', id, input.connectionId ?? input.daemonId, input.daemonId, input.claimToken);
2674
+ if (result.changes === 0) throw new Error(`claimed delivery ${id} was not found`);
2675
+ return this.getDelivery(id)!;
2676
+ }
2677
+
2678
+ startRun(input: StartRunInput): AgentRun {
2679
+ const runId = crypto.randomUUID();
2680
+ this.db.query(`
2681
+ INSERT INTO agent_runs (id, message_id, agent, status, attempt, pid, cwd, session_id, trigger_message_id, daemon_id, connection_id, computer_id, runtime_provider, runtime_invocation_id, delivery_id)
2682
+ VALUES (?, ?, ?, 'running', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2683
+ `).run(
2684
+ runId,
2685
+ input.messageId,
2686
+ input.agent,
2687
+ input.attempt,
2688
+ input.pid ?? null,
2689
+ input.cwd,
2690
+ input.sessionId ?? null,
2691
+ input.triggerMessageId ?? input.messageId,
2692
+ input.daemonId ?? null,
2693
+ input.connectionId ?? null,
2694
+ input.computerId ?? null,
2695
+ input.runtimeProvider ?? null,
2696
+ input.runtimeInvocationId ?? null,
2697
+ input.deliveryId ?? null,
2698
+ );
2699
+ return this.getRun(runId)!;
2700
+ }
2701
+
2702
+ setRunPid(runId: string, pid: number | null): void {
2703
+ this.db.query(`
2704
+ UPDATE agent_runs
2705
+ SET pid = ?, updated_at = datetime('now')
2706
+ WHERE id = ? AND status = 'running'
2707
+ `).run(pid, runId);
2708
+ }
2709
+
2710
+ finishRun(runId: string, input: FinishRunInput): void {
2711
+ this.db.query(`
2712
+ UPDATE agent_runs
2713
+ SET status = ?, action = NULL, ended_at = datetime('now'), updated_at = datetime('now'), exit_code = ?, output = ?
2714
+ WHERE id = ?
2715
+ `).run(input.status, input.exitCode ?? null, input.output ?? '', runId);
2716
+ }
2717
+
2718
+ requestRunAction(runId: string, action: RunAction): AgentRun {
2719
+ const result = this.db.query(`
2720
+ UPDATE agent_runs
2721
+ SET action = ?, updated_at = datetime('now')
2722
+ WHERE id = ? AND status = 'running'
2723
+ `).run(action, runId);
2724
+ if (result.changes === 0) throw new Error(`running run ${runId} was not found`);
2725
+ return this.getRun(runId)!;
2726
+ }
2727
+
2728
+ clearRunAction(runId: string): void {
2729
+ this.db.query(`
2730
+ UPDATE agent_runs
2731
+ SET action = NULL, updated_at = datetime('now')
2732
+ WHERE id = ?
2733
+ `).run(runId);
2734
+ }
2735
+
2736
+ getRun(runId: string): AgentRun | null {
2737
+ const row = this.db.query('SELECT * FROM agent_runs WHERE id = ?').get(runId) as Record<string, unknown> | null;
2738
+ return row ? rowToRun(row) : null;
2739
+ }
2740
+
2741
+ listRuns(limit = 50): AgentRun[] {
2742
+ const rows = this.db.query(`
2743
+ SELECT * FROM agent_runs
2744
+ ORDER BY datetime(updated_at) DESC, datetime(started_at) DESC
2745
+ LIMIT ?
2746
+ `).all(limitValue(limit, 50)) as Record<string, unknown>[];
2747
+ return rows.map(rowToRun);
2748
+ }
2749
+
2750
+ createArtifact(input: CreateArtifactInput): { artifact: ArtifactMetadata; token: string } {
2751
+ validateArtifactContent(input.mimeType, input.content);
2752
+ const id = crypto.randomUUID();
2753
+ const token = generateArtifactToken();
2754
+ const tokenHash = hashArtifactToken(token);
2755
+ const expiresAt = artifactExpiry(input.ttlSeconds);
2756
+
2757
+ this.db.query(`
2758
+ INSERT INTO artifacts (id, token_hash, title, filename, mime_type, size_bytes, content, expires_at)
2759
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
2760
+ `).run(
2761
+ id,
2762
+ tokenHash,
2763
+ input.title?.trim() ?? '',
2764
+ input.filename?.trim() ?? '',
2765
+ input.mimeType,
2766
+ input.content.length,
2767
+ input.content,
2768
+ expiresAt,
2769
+ );
2770
+
2771
+ return { artifact: this.getArtifactMetadata(id)!, token };
2772
+ }
2773
+
2774
+ listArtifacts(limit = 50): ArtifactMetadata[] {
2775
+ const rows = this.db.query(`
2776
+ SELECT id, title, filename, mime_type, size_bytes, created_at, expires_at, revoked_at
2777
+ FROM artifacts
2778
+ ORDER BY datetime(created_at) DESC
2779
+ LIMIT ?
2780
+ `).all(limitValue(limit, 50)) as Record<string, unknown>[];
2781
+ return rows.map(rowToArtifactMetadata);
2782
+ }
2783
+
2784
+ getArtifactMetadata(id: string): ArtifactMetadata | null {
2785
+ const row = this.db.query(`
2786
+ SELECT id, title, filename, mime_type, size_bytes, created_at, expires_at, revoked_at
2787
+ FROM artifacts
2788
+ WHERE id = ?
2789
+ `).get(id) as Record<string, unknown> | null;
2790
+ return row ? rowToArtifactMetadata(row) : null;
2791
+ }
2792
+
2793
+ listWorkbenchArtifacts(limit = 50): WorkbenchArtifact[] {
2794
+ const rows = this.db.query(`
2795
+ SELECT id, title, filename, mime_type, size_bytes, created_at
2796
+ FROM artifacts
2797
+ WHERE revoked_at IS NULL AND datetime(expires_at) > datetime('now')
2798
+ ORDER BY datetime(created_at) DESC
2799
+ LIMIT ?
2800
+ `).all(limitValue(limit, 50)) as Record<string, unknown>[];
2801
+ return rows.map(rowToWorkbenchArtifact);
2802
+ }
2803
+
2804
+ getArtifactByToken(token: string): Artifact | null {
2805
+ const row = this.db.query('SELECT * FROM artifacts WHERE token_hash = ?').get(hashArtifactToken(token)) as Record<string, unknown> | null;
2806
+ if (!row) return null;
2807
+ const artifact = rowToArtifact(row);
2808
+ if (artifact.revoked_at) return null;
2809
+ if (Date.parse(artifact.expires_at) <= Date.now()) return null;
2810
+ return artifact;
2811
+ }
2812
+
2813
+ revokeArtifact(id: string): ArtifactMetadata {
2814
+ const result = this.db.query(`
2815
+ UPDATE artifacts
2816
+ SET revoked_at = COALESCE(revoked_at, datetime('now'))
2817
+ WHERE id = ?
2818
+ `).run(id);
2819
+ if (result.changes === 0) throw new Error(`artifact ${id} was not found`);
2820
+ return this.getArtifactMetadata(id)!;
2821
+ }
2822
+
2823
+ cleanupArtifacts(): number {
2824
+ const result = this.db.query(`
2825
+ DELETE FROM artifacts
2826
+ WHERE revoked_at IS NOT NULL OR datetime(expires_at) <= datetime('now')
2827
+ `).run();
2828
+ return result.changes;
2829
+ }
2830
+ }