@controlflow-ai/daemon 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -24
- package/package.json +16 -3
- package/src/agent-avatar.ts +30 -0
- package/src/agent-key.ts +28 -0
- package/src/agent-permissions.ts +359 -0
- package/src/agent-runtime.ts +810 -28
- package/src/agent-workspace.ts +183 -0
- package/src/app.ts +2183 -79
- package/src/args.ts +54 -7
- package/src/cli.ts +873 -14
- package/src/client.ts +482 -12
- package/src/coco.ts +9 -40
- package/src/codex.ts +33 -5
- package/src/config.ts +28 -4
- package/src/console.ts +460 -26
- package/src/daemon-client.ts +116 -3
- package/src/daemon.ts +958 -101
- package/src/db.ts +3216 -113
- package/src/delivery-ws.ts +269 -0
- package/src/format.ts +4 -1
- package/src/lark/app-registration.ts +141 -0
- package/src/lark/cli.ts +7 -137
- package/src/lark/credentials.ts +36 -3
- package/src/lark/event-router.ts +61 -5
- package/src/lark/inbound-events.ts +156 -3
- package/src/lark/server-integration.ts +659 -111
- package/src/lark/setup.ts +74 -5
- package/src/lark/ws-daemon.ts +136 -10
- package/src/local-api.ts +611 -14
- package/src/local-auth.ts +36 -3
- package/src/message-attachments.ts +71 -0
- package/src/messaging-cli.ts +741 -0
- package/src/messaging-status.ts +669 -0
- package/src/migrations/023_projects.ts +65 -0
- package/src/migrations/024_agents_model.ts +10 -0
- package/src/migrations/025_room_archive.ts +44 -0
- package/src/migrations/026_project_archive.ts +44 -0
- package/src/migrations/027_agent_permission_profiles.ts +16 -0
- package/src/migrations/028_lark_websocket_restart_state.ts +16 -0
- package/src/migrations/029_held_message_drafts.ts +32 -0
- package/src/migrations/030_agent_room_read_state.ts +25 -0
- package/src/migrations/031_room_tasks.ts +29 -0
- package/src/migrations/032_room_reminders.ts +29 -0
- package/src/migrations/033_room_saved_messages.ts +25 -0
- package/src/migrations/034_agent_activity_events.ts +27 -0
- package/src/migrations/035_agent_avatars.ts +17 -0
- package/src/migrations/036_project_agent_defaults.ts +21 -0
- package/src/migrations/037_message_attachments.ts +36 -0
- package/src/migrations/038_agent_activity_room_scope.ts +64 -0
- package/src/migrations/039_message_attachments_path.ts +34 -0
- package/src/migrations/040_message_attachments_file_schema.ts +80 -0
- package/src/migrations/041_room_system_events.ts +30 -0
- package/src/migrations/042_message_attachment_file_kind.ts +52 -0
- package/src/migrations/043_room_mode_skill_registry.ts +92 -0
- package/src/migrations/044_workflow_runtime.ts +69 -0
- package/src/migrations/045_skill_repository_ownership.ts +64 -0
- package/src/migrations.ts +70 -1
- package/src/neeko.ts +40 -4
- package/src/runtime-env.ts +179 -0
- package/src/runtime-registry.ts +83 -13
- package/src/server.ts +244 -4
- package/src/token-file.ts +13 -6
- package/src/types.ts +394 -0
- package/src/workflow-runtime.ts +275 -0
- package/src/web.ts +0 -904
package/src/db.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { Database, type SQLQueryBindings } from 'bun:sqlite';
|
|
2
2
|
import { createHash, randomBytes } from 'node:crypto';
|
|
3
|
-
import {
|
|
3
|
+
import { isAbsolute } from 'node:path';
|
|
4
|
+
import { ensureParentDir, homeDir as defaultPalHomeDir } from './config.js';
|
|
4
5
|
import { runMigrations } from './migrations.js';
|
|
5
6
|
import { artifactExpiry, generateArtifactToken, hashArtifactToken, validateArtifactContent } from './artifacts.js';
|
|
7
|
+
import { storeMessageAttachmentFile, validateMessageAttachment } from './message-attachments.js';
|
|
6
8
|
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, LarkAuthorizedUser, 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';
|
|
9
|
+
import type { AgentActivityEvent, AgentActivityKind, AgentDefinition, AgentDeletionImpact, AgentDeletionLarkBindingImpact, AgentDeletionResult, AgentDeletionRoomImpact, AgentPermissionProfile, AgentRun, AgentSession, AgentValidationResult, AgentWorkbench, AgentWorkbenchCollaboration, AgentWorkbenchLarkBinding, AgentWorkbenchRoom, Artifact, ArtifactMetadata, ChannelAccount, ChannelConversation, ChannelMessageMapping, ChannelOutboxRecord, Chat, ChatKind, ChatStatus, Computer, ComputerAgentAssignment, ComputerConnection, ComputerDeletionImpact, ComputerDeletionResult, ComputerWorkload, DaemonInstance, DeliveryContext, EnabledSkill, HeldDraftStatus, HeldMessageDraft, LarkAuthorizedUser, LarkGroupRoomMapping, LockProviderEvidence, LockTranscriptMessage, Message, MessageAttachment, MessageAttachmentKind, MessageAttachmentMetadata, MessageDelivery, MessageType, AgentRoomSubscription, AgentRoomSubscriptionMode, PendingInboundEvent, Project, ProjectAgentDefault, ProjectStatus, ProvisionedComputer, ProviderExternalType, ProviderIdentityBinding, PalIdentity, RoomAgentActivityStatus, RoomChannel, RoomParticipant, RoomParticipantKind, RoomParticipantSource, RoomProvider, RoomReminder, RoomReminderStatus, RoomSavedMessage, RoomMode, RoomSystemEvent, RoomSystemEventActorKind, RoomSystemEventType, RoomTask, RoomTaskStatus, RunAction, RunStatus, SkillBinding, SkillBindingScope, SkillDefinition, SkillSource, TranscriptReadModel, WorkbenchArtifact, WorkflowNode, WorkflowNodeKind, WorkflowNodeStatus, WorkflowRun, WorkflowRunRecord, WorkflowRunStatus } from './types.js';
|
|
10
|
+
import { defaultAgentPermissionProfile, normalizeExtraWritableRoots, validateAgentPermissionProfile, type AgentPermissionProfile as RuntimeAgentPermissionProfile } from './agent-permissions.js';
|
|
11
|
+
import { normalizeAgentAvatar } from './agent-avatar.js';
|
|
8
12
|
|
|
9
13
|
export const ALL_AGENTS_MENTION = '__pal_all_agents__';
|
|
10
14
|
|
|
@@ -20,6 +24,16 @@ export interface CreateMessageInput {
|
|
|
20
24
|
channelId?: string | null;
|
|
21
25
|
provider?: RoomProvider;
|
|
22
26
|
mentions?: string[];
|
|
27
|
+
attachments?: CreateMessageAttachmentInput[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CreateMessageAttachmentInput {
|
|
31
|
+
kind: MessageAttachmentKind;
|
|
32
|
+
mimeType: string;
|
|
33
|
+
filename?: string | null;
|
|
34
|
+
content: Uint8Array;
|
|
35
|
+
sourceProvider?: RoomProvider | null;
|
|
36
|
+
sourceRef?: string | null;
|
|
23
37
|
}
|
|
24
38
|
|
|
25
39
|
export interface ListMessagesInput {
|
|
@@ -27,10 +41,36 @@ export interface ListMessagesInput {
|
|
|
27
41
|
chatName?: string;
|
|
28
42
|
parentId?: number | null;
|
|
29
43
|
after?: number;
|
|
44
|
+
before?: number;
|
|
30
45
|
limit?: number;
|
|
31
46
|
q?: string;
|
|
32
47
|
}
|
|
33
48
|
|
|
49
|
+
export interface UpdateAgentRoomSubscriptionInput {
|
|
50
|
+
roomId: string;
|
|
51
|
+
agent: string;
|
|
52
|
+
mode: AgentRoomSubscriptionMode;
|
|
53
|
+
actorKind?: RoomSystemEventActorKind;
|
|
54
|
+
actorId?: string | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface RecordRoomSystemEventInput {
|
|
58
|
+
roomId: string;
|
|
59
|
+
eventType: RoomSystemEventType;
|
|
60
|
+
actorKind?: RoomSystemEventActorKind;
|
|
61
|
+
actorId?: string | null;
|
|
62
|
+
subjectAgent?: string | null;
|
|
63
|
+
metadata?: Record<string, unknown> | null;
|
|
64
|
+
content?: string;
|
|
65
|
+
deliveryAgents?: string[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface MarkAgentRoomReadInput {
|
|
69
|
+
roomId: string;
|
|
70
|
+
agent: string;
|
|
71
|
+
messageId?: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
34
74
|
export interface StartRunInput {
|
|
35
75
|
messageId: number;
|
|
36
76
|
agent: string;
|
|
@@ -53,6 +93,14 @@ export interface FinishRunInput {
|
|
|
53
93
|
output?: string;
|
|
54
94
|
}
|
|
55
95
|
|
|
96
|
+
export interface RecordAgentActivityInput {
|
|
97
|
+
runId: string;
|
|
98
|
+
kind: AgentActivityKind;
|
|
99
|
+
title: string;
|
|
100
|
+
detail?: string | null;
|
|
101
|
+
metadata?: Record<string, unknown> | null;
|
|
102
|
+
}
|
|
103
|
+
|
|
56
104
|
export interface RegisterDaemonInput {
|
|
57
105
|
id?: string;
|
|
58
106
|
name: string;
|
|
@@ -85,6 +133,7 @@ export interface ConnectComputerResult {
|
|
|
85
133
|
computer: Computer;
|
|
86
134
|
connection: ComputerConnection;
|
|
87
135
|
token: string;
|
|
136
|
+
localControlToken: string;
|
|
88
137
|
daemon: DaemonInstance;
|
|
89
138
|
agents: ComputerAgentAssignment[];
|
|
90
139
|
}
|
|
@@ -95,16 +144,155 @@ export interface ProvisionComputerInput {
|
|
|
95
144
|
packageName?: string;
|
|
96
145
|
}
|
|
97
146
|
|
|
147
|
+
export interface RegenerateComputerCommandInput {
|
|
148
|
+
serverUrl?: string;
|
|
149
|
+
packageName?: string;
|
|
150
|
+
}
|
|
151
|
+
|
|
98
152
|
export interface CreateDeliveryInput {
|
|
99
153
|
messageId: number;
|
|
100
154
|
agent: string;
|
|
101
155
|
}
|
|
102
156
|
|
|
157
|
+
function daemonCommandPrefix(input?: string): string {
|
|
158
|
+
const raw = input?.trim() || 'bun run daemon';
|
|
159
|
+
if (/\s/.test(raw) || raw.startsWith('./') || raw.startsWith('/') || raw.startsWith('node ')) return raw;
|
|
160
|
+
return `npx ${raw}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function shellValue(value: string): string {
|
|
164
|
+
if (/^[A-Za-z0-9_./:@-]+$/.test(value)) return value;
|
|
165
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function daemonStartCommand(input: { commandPrefix?: string; serverUrl: string; apiKey: string; name: string }): string {
|
|
169
|
+
return `PAL_SERVER=${shellValue(input.serverUrl)} PAL_API_KEY=${shellValue(input.apiKey)} ${daemonCommandPrefix(input.commandPrefix)} # ${input.name}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface CreateHeldMessageDraftInput {
|
|
173
|
+
chatId: string;
|
|
174
|
+
agent: string;
|
|
175
|
+
sender: string;
|
|
176
|
+
content: string;
|
|
177
|
+
mentions?: string[];
|
|
178
|
+
baseMessageId: number;
|
|
179
|
+
latestMessageIdAtHold: number;
|
|
180
|
+
holdReason: string;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface CreateRoomTasksInput {
|
|
184
|
+
roomId: string;
|
|
185
|
+
titles: string[];
|
|
186
|
+
createdBy?: string | null;
|
|
187
|
+
sourceMessageId?: number | null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface ClaimRoomTaskInput {
|
|
191
|
+
roomId: string;
|
|
192
|
+
taskNumber: number;
|
|
193
|
+
assignee: string;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export interface UpdateRoomTaskInput {
|
|
197
|
+
roomId: string;
|
|
198
|
+
taskNumber: number;
|
|
199
|
+
status: RoomTaskStatus;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export interface CreateRoomReminderInput {
|
|
203
|
+
sourceMessageId: number;
|
|
204
|
+
title: string;
|
|
205
|
+
createdBy?: string | null;
|
|
206
|
+
fireAt?: string | null;
|
|
207
|
+
delaySeconds?: number | null;
|
|
208
|
+
repeat?: string | null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface ListRoomRemindersInput {
|
|
212
|
+
roomId?: string | null;
|
|
213
|
+
status?: RoomReminderStatus | 'all';
|
|
214
|
+
createdBy?: string | null;
|
|
215
|
+
limit?: number;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export interface CreateRoomSavedMessageInput {
|
|
219
|
+
sourceMessageId: number;
|
|
220
|
+
savedBy: string;
|
|
221
|
+
note?: string | null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export interface ListRoomSavedMessagesInput {
|
|
225
|
+
roomId?: string | null;
|
|
226
|
+
savedBy?: string | null;
|
|
227
|
+
limit?: number;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export interface CreateWorkflowRunInput {
|
|
231
|
+
filePath: string;
|
|
232
|
+
goal?: string | null;
|
|
233
|
+
createdBy?: string | null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export interface FinishWorkflowRunInput {
|
|
237
|
+
status: WorkflowRunStatus;
|
|
238
|
+
finalOutput?: Record<string, unknown> | null;
|
|
239
|
+
error?: string | null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export interface CreateWorkflowNodeInput {
|
|
243
|
+
runId: string;
|
|
244
|
+
parentId?: string | null;
|
|
245
|
+
kind: WorkflowNodeKind;
|
|
246
|
+
title: string;
|
|
247
|
+
role?: string | null;
|
|
248
|
+
status?: WorkflowNodeStatus;
|
|
249
|
+
context?: Record<string, unknown> | null;
|
|
250
|
+
instruction?: string | null;
|
|
251
|
+
capabilities?: string[];
|
|
252
|
+
outputContract?: Record<string, unknown> | null;
|
|
253
|
+
output?: Record<string, unknown> | null;
|
|
254
|
+
evidence?: Record<string, unknown> | null;
|
|
255
|
+
taskId?: string | null;
|
|
256
|
+
messageId?: number | null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export interface UpdateWorkflowNodeInput {
|
|
260
|
+
status?: WorkflowNodeStatus;
|
|
261
|
+
output?: Record<string, unknown> | null;
|
|
262
|
+
evidence?: Record<string, unknown> | null;
|
|
263
|
+
taskId?: string | null;
|
|
264
|
+
messageId?: number | null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export interface SaveRoomMessageResult {
|
|
268
|
+
savedMessage: RoomSavedMessage;
|
|
269
|
+
created: boolean;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export interface FireDueRoomRemindersInput {
|
|
273
|
+
now?: string | Date | null;
|
|
274
|
+
limit?: number;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export interface FiredRoomReminder {
|
|
278
|
+
reminder: RoomReminder;
|
|
279
|
+
message: Message;
|
|
280
|
+
deliveries: MessageDelivery[];
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export interface FireDueRoomRemindersResult {
|
|
284
|
+
reminders: RoomReminder[];
|
|
285
|
+
messages: Message[];
|
|
286
|
+
deliveries: MessageDelivery[];
|
|
287
|
+
fired: FiredRoomReminder[];
|
|
288
|
+
}
|
|
289
|
+
|
|
103
290
|
export interface ClaimDeliveryInput {
|
|
104
291
|
daemonId: string;
|
|
105
292
|
connectionId?: string | null;
|
|
106
293
|
computerId?: string | null;
|
|
107
294
|
leaseMs?: number;
|
|
295
|
+
steerRunId?: string | null;
|
|
108
296
|
}
|
|
109
297
|
|
|
110
298
|
export interface FinishDeliveryInput {
|
|
@@ -115,6 +303,22 @@ export interface FinishDeliveryInput {
|
|
|
115
303
|
error?: string;
|
|
116
304
|
}
|
|
117
305
|
|
|
306
|
+
export interface DeliveryBacklogSummary {
|
|
307
|
+
agent: string;
|
|
308
|
+
pending: number;
|
|
309
|
+
claimed: number;
|
|
310
|
+
processing_completed: number;
|
|
311
|
+
expired_active: number;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export interface LarkWebsocketRestartState {
|
|
315
|
+
app_id: string;
|
|
316
|
+
restart_count: number;
|
|
317
|
+
last_restart_at: string | null;
|
|
318
|
+
last_restart_reason: string | null;
|
|
319
|
+
updated_at: string;
|
|
320
|
+
}
|
|
321
|
+
|
|
118
322
|
export interface CreateArtifactInput {
|
|
119
323
|
title?: string;
|
|
120
324
|
filename?: string;
|
|
@@ -250,7 +454,7 @@ export interface UpsertRoomParticipantInput {
|
|
|
250
454
|
source: RoomParticipantSource;
|
|
251
455
|
}
|
|
252
456
|
|
|
253
|
-
const agentFieldAllowlist = new Set(['id', 'agent_key', 'display_name', 'description', 'runtime', 'created_at', 'updated_at']);
|
|
457
|
+
const agentFieldAllowlist = new Set(['id', 'agent_key', 'display_name', 'description', 'runtime', 'model', 'created_at', 'updated_at']);
|
|
254
458
|
const forbiddenAgentFieldPattern = /provider|adapter|enabled|disabled|status|state|mode|config|capabilities|route|routing|binding|permission|owner|workspace|daemon|delivery|channel|lark|acl|authz/i;
|
|
255
459
|
|
|
256
460
|
export type ChannelMessageConflictCode =
|
|
@@ -313,6 +517,156 @@ function normalizeChatName(name?: string): string {
|
|
|
313
517
|
return stripped;
|
|
314
518
|
}
|
|
315
519
|
|
|
520
|
+
const roomTaskStatuses: RoomTaskStatus[] = ['todo', 'in_progress', 'in_review', 'done', 'closed'];
|
|
521
|
+
const roomReminderStatuses: RoomReminderStatus[] = ['scheduled', 'fired', 'canceled'];
|
|
522
|
+
const roomModes: RoomMode[] = ['standard', 'idea_development'];
|
|
523
|
+
const skillSources: SkillSource[] = ['system', 'user', 'project'];
|
|
524
|
+
const skillBindingScopes: SkillBindingScope[] = ['project', 'room', 'agent'];
|
|
525
|
+
const skillStatuses: SkillDefinition['status'][] = ['active', 'disabled'];
|
|
526
|
+
const workflowRunStatuses: WorkflowRunStatus[] = ['running', 'completed', 'failed'];
|
|
527
|
+
const workflowNodeKinds: WorkflowNodeKind[] = ['phase', 'agent', 'task', 'message', 'final'];
|
|
528
|
+
const workflowNodeStatuses: WorkflowNodeStatus[] = ['pending', 'running', 'done', 'failed'];
|
|
529
|
+
|
|
530
|
+
function assertRoomMode(value: string): RoomMode {
|
|
531
|
+
if (!roomModes.includes(value as RoomMode)) throw new Error('INVALID_ROOM_MODE');
|
|
532
|
+
return value as RoomMode;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function assertSkillBindingScope(value: string): SkillBindingScope {
|
|
536
|
+
if (!skillBindingScopes.includes(value as SkillBindingScope)) throw new Error('INVALID_SKILL_BINDING_SCOPE');
|
|
537
|
+
return value as SkillBindingScope;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function assertSkillSource(value: string): SkillSource {
|
|
541
|
+
if (!skillSources.includes(value as SkillSource)) throw new Error('INVALID_SKILL_SOURCE');
|
|
542
|
+
return value as SkillSource;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function assertSkillStatus(value: string): SkillDefinition['status'] {
|
|
546
|
+
if (!skillStatuses.includes(value as SkillDefinition['status'])) throw new Error('INVALID_SKILL_STATUS');
|
|
547
|
+
return value as SkillDefinition['status'];
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function assertRoomTaskStatus(value: string): RoomTaskStatus {
|
|
551
|
+
if (!roomTaskStatuses.includes(value as RoomTaskStatus)) throw new Error('INVALID_TASK_STATUS');
|
|
552
|
+
return value as RoomTaskStatus;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function assertRoomReminderStatus(value: string): RoomReminderStatus {
|
|
556
|
+
if (!roomReminderStatuses.includes(value as RoomReminderStatus)) throw new Error('INVALID_REMINDER_STATUS');
|
|
557
|
+
return value as RoomReminderStatus;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function assertWorkflowRunStatus(value: string): WorkflowRunStatus {
|
|
561
|
+
if (!workflowRunStatuses.includes(value as WorkflowRunStatus)) throw new Error('INVALID_WORKFLOW_RUN_STATUS');
|
|
562
|
+
return value as WorkflowRunStatus;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function assertWorkflowNodeKind(value: string): WorkflowNodeKind {
|
|
566
|
+
if (!workflowNodeKinds.includes(value as WorkflowNodeKind)) throw new Error('INVALID_WORKFLOW_NODE_KIND');
|
|
567
|
+
return value as WorkflowNodeKind;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function assertWorkflowNodeStatus(value: string): WorkflowNodeStatus {
|
|
571
|
+
if (!workflowNodeStatuses.includes(value as WorkflowNodeStatus)) throw new Error('INVALID_WORKFLOW_NODE_STATUS');
|
|
572
|
+
return value as WorkflowNodeStatus;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function jsonRecordOrNull(value: unknown): Record<string, unknown> | null {
|
|
576
|
+
if (value === null || value === undefined || value === '') return null;
|
|
577
|
+
try {
|
|
578
|
+
const parsed = JSON.parse(String(value)) as unknown;
|
|
579
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
580
|
+
return parsed as Record<string, unknown>;
|
|
581
|
+
} catch {
|
|
582
|
+
return null;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function jsonStringArray(value: unknown): string[] {
|
|
587
|
+
if (value === null || value === undefined || value === '') return [];
|
|
588
|
+
try {
|
|
589
|
+
const parsed = JSON.parse(String(value)) as unknown;
|
|
590
|
+
if (!Array.isArray(parsed)) return [];
|
|
591
|
+
return parsed.filter((entry): entry is string => typeof entry === 'string');
|
|
592
|
+
} catch {
|
|
593
|
+
return [];
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function normalizeReminderFireAt(input: { fireAt?: string | null; delaySeconds?: number | null }): string {
|
|
598
|
+
if (input.fireAt?.trim()) {
|
|
599
|
+
const date = new Date(input.fireAt);
|
|
600
|
+
if (Number.isNaN(date.getTime())) throw new Error('INVALID_REMINDER_TIME');
|
|
601
|
+
return date.toISOString();
|
|
602
|
+
}
|
|
603
|
+
if (input.delaySeconds !== undefined && input.delaySeconds !== null) {
|
|
604
|
+
if (!Number.isFinite(input.delaySeconds) || input.delaySeconds < 0) throw new Error('INVALID_REMINDER_TIME');
|
|
605
|
+
return new Date(Date.now() + input.delaySeconds * 1000).toISOString();
|
|
606
|
+
}
|
|
607
|
+
throw new Error('INVALID_REMINDER_TIME');
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function normalizeReminderFireNow(value?: string | Date | null): string {
|
|
611
|
+
if (value === undefined || value === null) return new Date().toISOString();
|
|
612
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
613
|
+
if (Number.isNaN(date.getTime())) throw new Error('INVALID_REMINDER_TIME');
|
|
614
|
+
return date.toISOString();
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const reminderRepeatAliases = new Map<string, string>([
|
|
618
|
+
['hourly', 'every:1h'],
|
|
619
|
+
['daily', 'every:1d'],
|
|
620
|
+
['weekly', 'every:1w'],
|
|
621
|
+
]);
|
|
622
|
+
|
|
623
|
+
const reminderRepeatUnitMs: Record<string, number> = {
|
|
624
|
+
s: 1000,
|
|
625
|
+
m: 60 * 1000,
|
|
626
|
+
h: 60 * 60 * 1000,
|
|
627
|
+
d: 24 * 60 * 60 * 1000,
|
|
628
|
+
w: 7 * 24 * 60 * 60 * 1000,
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
function normalizeReminderRepeat(value?: string | null): string | null {
|
|
632
|
+
const raw = value?.trim().toLowerCase() || '';
|
|
633
|
+
if (!raw || raw === 'none' || raw === 'off') return null;
|
|
634
|
+
const aliased = reminderRepeatAliases.get(raw) ?? raw;
|
|
635
|
+
const match = aliased.match(/^every:(\d+)([smhdw])$/);
|
|
636
|
+
if (!match) throw new Error('INVALID_REMINDER_REPEAT');
|
|
637
|
+
const count = Number(match[1]);
|
|
638
|
+
const unit = match[2]!;
|
|
639
|
+
if (!Number.isInteger(count) || count < 1) throw new Error('INVALID_REMINDER_REPEAT');
|
|
640
|
+
return `every:${count}${unit}`;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function reminderRepeatMs(repeat: string): number {
|
|
644
|
+
const normalized = normalizeReminderRepeat(repeat);
|
|
645
|
+
if (!normalized) throw new Error('INVALID_REMINDER_REPEAT');
|
|
646
|
+
const match = normalized.match(/^every:(\d+)([smhdw])$/)!;
|
|
647
|
+
return Number(match[1]) * reminderRepeatUnitMs[match[2]!]!;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function nextReminderFireAt(reminder: RoomReminder, now: string): string {
|
|
651
|
+
const fireMs = new Date(reminder.fire_at).getTime();
|
|
652
|
+
const nowMs = new Date(now).getTime();
|
|
653
|
+
const intervalMs = reminderRepeatMs(reminder.repeat ?? '');
|
|
654
|
+
if (Number.isNaN(fireMs) || Number.isNaN(nowMs)) throw new Error('INVALID_REMINDER_TIME');
|
|
655
|
+
const periods = Math.max(1, Math.floor((nowMs - fireMs) / intervalMs) + 1);
|
|
656
|
+
return new Date(fireMs + periods * intervalMs).toISOString();
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function reminderFireIdempotencyKey(reminder: RoomReminder): string {
|
|
660
|
+
return reminder.repeat ? `room-reminder:${reminder.id}:fire:${reminder.fire_at}` : `room-reminder:${reminder.id}:fire`;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function reminderFireMessageContent(reminder: RoomReminder, source: Message | null): string {
|
|
664
|
+
const sourceLine = source
|
|
665
|
+
? `Source message #${source.id} from @${source.sender}: ${source.content}`
|
|
666
|
+
: `Source message #${reminder.source_message_id}`;
|
|
667
|
+
return `Reminder: ${reminder.title}\n${sourceLine}`;
|
|
668
|
+
}
|
|
669
|
+
|
|
316
670
|
function parseCapabilities(value: unknown): { topics: 'native' | 'unsupported' } {
|
|
317
671
|
if (typeof value !== 'string') return { topics: 'native' };
|
|
318
672
|
try {
|
|
@@ -332,12 +686,32 @@ function generateConnectionToken(): string {
|
|
|
332
686
|
}
|
|
333
687
|
|
|
334
688
|
function rowToAgent(row: Record<string, unknown>): AgentDefinition {
|
|
689
|
+
const agentKey = String(row.agent_key);
|
|
690
|
+
const displayName = String(row.display_name);
|
|
335
691
|
return {
|
|
336
692
|
id: String(row.id),
|
|
337
|
-
agent_key:
|
|
338
|
-
display_name:
|
|
693
|
+
agent_key: agentKey,
|
|
694
|
+
display_name: displayName,
|
|
339
695
|
description: row.description === null ? null : String(row.description),
|
|
340
696
|
runtime: row.runtime === null ? null : String(row.runtime),
|
|
697
|
+
model: row.model === null || row.model === undefined ? null : String(row.model),
|
|
698
|
+
avatar: normalizeAgentAvatar(row.avatar, agentKey, displayName),
|
|
699
|
+
created_at: String(row.created_at),
|
|
700
|
+
updated_at: String(row.updated_at),
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function rowToAgentPermissionProfile(row: Record<string, unknown>): AgentPermissionProfile {
|
|
705
|
+
let roots: string[] = [];
|
|
706
|
+
try {
|
|
707
|
+
roots = normalizeExtraWritableRoots(JSON.parse(String(row.extra_writable_roots_json ?? '[]')));
|
|
708
|
+
} catch {
|
|
709
|
+
roots = [];
|
|
710
|
+
}
|
|
711
|
+
return {
|
|
712
|
+
agent_key: String(row.agent_key),
|
|
713
|
+
filesystem_mode: String(row.filesystem_mode) as AgentPermissionProfile['filesystem_mode'],
|
|
714
|
+
extra_writable_roots: roots,
|
|
341
715
|
created_at: String(row.created_at),
|
|
342
716
|
updated_at: String(row.updated_at),
|
|
343
717
|
};
|
|
@@ -348,6 +722,7 @@ function rowToComputerAgentAssignment(row: Record<string, unknown>): ComputerAge
|
|
|
348
722
|
agent: String(row.agent),
|
|
349
723
|
display_name: String(row.display_name),
|
|
350
724
|
runtime: row.runtime === null ? null : String(row.runtime),
|
|
725
|
+
model: row.model === null || row.model === undefined ? null : String(row.model),
|
|
351
726
|
computer_id: String(row.computer_id),
|
|
352
727
|
cwd: String(row.cwd ?? ''),
|
|
353
728
|
status: String(row.status) as ComputerAgentAssignment['status'],
|
|
@@ -356,6 +731,71 @@ function rowToComputerAgentAssignment(row: Record<string, unknown>): ComputerAge
|
|
|
356
731
|
};
|
|
357
732
|
}
|
|
358
733
|
|
|
734
|
+
function rowToProject(row: Record<string, unknown>): Project {
|
|
735
|
+
return {
|
|
736
|
+
id: String(row.id),
|
|
737
|
+
name: String(row.name),
|
|
738
|
+
computer_id: String(row.computer_id),
|
|
739
|
+
computer_name: row.computer_name === null || row.computer_name === undefined ? null : String(row.computer_name),
|
|
740
|
+
root_path: String(row.root_path),
|
|
741
|
+
status: row.status === null || row.status === undefined ? 'active' : String(row.status) as Project['status'],
|
|
742
|
+
room_count: Number(row.room_count ?? 0),
|
|
743
|
+
created_at: String(row.created_at),
|
|
744
|
+
updated_at: String(row.updated_at),
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function rowToProjectAgentDefault(row: Record<string, unknown>): ProjectAgentDefault {
|
|
749
|
+
return {
|
|
750
|
+
id: String(row.id),
|
|
751
|
+
project_id: String(row.project_id),
|
|
752
|
+
agent: String(row.agent),
|
|
753
|
+
display_name: row.display_name === null || row.display_name === undefined ? null : String(row.display_name),
|
|
754
|
+
mode: String(row.mode) as AgentRoomSubscriptionMode,
|
|
755
|
+
created_at: String(row.created_at),
|
|
756
|
+
updated_at: String(row.updated_at),
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function rowToSkillDefinition(row: Record<string, unknown>): SkillDefinition {
|
|
761
|
+
return {
|
|
762
|
+
key: String(row.key),
|
|
763
|
+
name: String(row.name),
|
|
764
|
+
description: String(row.description),
|
|
765
|
+
instruction_content: String(row.instruction_content),
|
|
766
|
+
status: String(row.status) as SkillDefinition['status'],
|
|
767
|
+
version: String(row.version),
|
|
768
|
+
source: String(row.source ?? 'system') as SkillSource,
|
|
769
|
+
owner_user_id: row.owner_user_id === null || row.owner_user_id === undefined ? null : String(row.owner_user_id),
|
|
770
|
+
project_id: row.project_id === null || row.project_id === undefined ? null : String(row.project_id),
|
|
771
|
+
repository_path: row.repository_path === null || row.repository_path === undefined ? null : String(row.repository_path),
|
|
772
|
+
created_at: String(row.created_at),
|
|
773
|
+
updated_at: String(row.updated_at),
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function rowToSkillBinding(row: Record<string, unknown>): SkillBinding {
|
|
778
|
+
return {
|
|
779
|
+
id: String(row.id),
|
|
780
|
+
scope: String(row.scope) as SkillBindingScope,
|
|
781
|
+
scope_id: String(row.scope_id),
|
|
782
|
+
skill_key: String(row.skill_key),
|
|
783
|
+
enabled: Number(row.enabled) === 1,
|
|
784
|
+
priority: Number(row.priority),
|
|
785
|
+
created_at: String(row.created_at),
|
|
786
|
+
updated_at: String(row.updated_at),
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function rowToEnabledSkill(row: Record<string, unknown>): EnabledSkill {
|
|
791
|
+
return {
|
|
792
|
+
...rowToSkillDefinition(row),
|
|
793
|
+
binding_scope: String(row.binding_scope) as SkillBindingScope,
|
|
794
|
+
binding_scope_id: String(row.binding_scope_id),
|
|
795
|
+
binding_priority: Number(row.binding_priority),
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
|
|
359
799
|
function rowToMessage(row: Record<string, unknown>): Message {
|
|
360
800
|
return {
|
|
361
801
|
id: Number(row.id),
|
|
@@ -372,6 +812,161 @@ function rowToMessage(row: Record<string, unknown>): Message {
|
|
|
372
812
|
channel_id: row.channel_id === null || row.channel_id === undefined ? null : String(row.channel_id),
|
|
373
813
|
provider: row.provider === null || row.provider === undefined ? undefined : String(row.provider) as RoomProvider,
|
|
374
814
|
mentions: parseMentionsJson(row.mentions_json),
|
|
815
|
+
attachments: [],
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function rowToRoomSystemEvent(row: Record<string, unknown>): RoomSystemEvent {
|
|
820
|
+
let metadata: Record<string, unknown> = {};
|
|
821
|
+
try {
|
|
822
|
+
const parsed = JSON.parse(String(row.metadata_json ?? '{}')) as unknown;
|
|
823
|
+
metadata = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
|
|
824
|
+
} catch {
|
|
825
|
+
metadata = {};
|
|
826
|
+
}
|
|
827
|
+
return {
|
|
828
|
+
id: String(row.id),
|
|
829
|
+
room_id: String(row.room_id),
|
|
830
|
+
message_id: Number(row.message_id),
|
|
831
|
+
event_type: String(row.event_type) as RoomSystemEventType,
|
|
832
|
+
actor_kind: String(row.actor_kind) as RoomSystemEventActorKind,
|
|
833
|
+
actor_id: row.actor_id === null || row.actor_id === undefined ? null : String(row.actor_id),
|
|
834
|
+
subject_agent: row.subject_agent === null || row.subject_agent === undefined ? null : String(row.subject_agent),
|
|
835
|
+
metadata,
|
|
836
|
+
created_at: String(row.created_at),
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function rowToMessageAttachmentMetadata(row: Record<string, unknown>): MessageAttachmentMetadata {
|
|
841
|
+
return {
|
|
842
|
+
id: String(row.id),
|
|
843
|
+
message_id: Number(row.message_id),
|
|
844
|
+
kind: String(row.kind) as MessageAttachmentKind,
|
|
845
|
+
mime_type: String(row.mime_type),
|
|
846
|
+
filename: String(row.filename ?? ''),
|
|
847
|
+
size_bytes: Number(row.size_bytes),
|
|
848
|
+
path: String(row.path ?? ''),
|
|
849
|
+
source_provider: row.source_provider === null || row.source_provider === undefined ? null : String(row.source_provider) as RoomProvider,
|
|
850
|
+
created_at: String(row.created_at),
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function rowToMessageAttachment(row: Record<string, unknown>): MessageAttachment {
|
|
855
|
+
return {
|
|
856
|
+
...rowToMessageAttachmentMetadata(row),
|
|
857
|
+
source_ref: row.source_ref === null || row.source_ref === undefined ? null : String(row.source_ref),
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function rowToHeldMessageDraft(row: Record<string, unknown>): HeldMessageDraft {
|
|
862
|
+
return {
|
|
863
|
+
id: String(row.id),
|
|
864
|
+
chat_id: String(row.chat_id),
|
|
865
|
+
chat_name: String(row.chat_name ?? ''),
|
|
866
|
+
chat_display_name: row.chat_display_name === null || row.chat_display_name === undefined ? null : String(row.chat_display_name),
|
|
867
|
+
agent: String(row.agent),
|
|
868
|
+
sender: String(row.sender),
|
|
869
|
+
content: String(row.content),
|
|
870
|
+
mentions: parseMentionsJson(row.mentions_json),
|
|
871
|
+
base_message_id: Number(row.base_message_id),
|
|
872
|
+
latest_message_id_at_hold: Number(row.latest_message_id_at_hold),
|
|
873
|
+
status: String(row.status) as HeldDraftStatus,
|
|
874
|
+
hold_reason: String(row.hold_reason),
|
|
875
|
+
hold_count: Number(row.hold_count),
|
|
876
|
+
intervening_message_count: Number(row.intervening_message_count ?? 0),
|
|
877
|
+
resolved_message_id: row.resolved_message_id === null || row.resolved_message_id === undefined ? null : Number(row.resolved_message_id),
|
|
878
|
+
created_at: String(row.created_at),
|
|
879
|
+
updated_at: String(row.updated_at),
|
|
880
|
+
resolved_at: row.resolved_at === null || row.resolved_at === undefined ? null : String(row.resolved_at),
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function rowToRoomTask(row: Record<string, unknown>): RoomTask {
|
|
885
|
+
return {
|
|
886
|
+
id: String(row.id),
|
|
887
|
+
room_id: String(row.room_id),
|
|
888
|
+
room_name: String(row.room_name ?? ''),
|
|
889
|
+
task_number: Number(row.task_number),
|
|
890
|
+
title: String(row.title),
|
|
891
|
+
status: String(row.status) as RoomTaskStatus,
|
|
892
|
+
assignee: row.assignee === null || row.assignee === undefined ? null : String(row.assignee),
|
|
893
|
+
created_by: row.created_by === null || row.created_by === undefined ? null : String(row.created_by),
|
|
894
|
+
source_message_id: row.source_message_id === null || row.source_message_id === undefined ? null : Number(row.source_message_id),
|
|
895
|
+
created_at: String(row.created_at),
|
|
896
|
+
updated_at: String(row.updated_at),
|
|
897
|
+
completed_at: row.completed_at === null || row.completed_at === undefined ? null : String(row.completed_at),
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function rowToRoomReminder(row: Record<string, unknown>): RoomReminder {
|
|
902
|
+
return {
|
|
903
|
+
id: String(row.id),
|
|
904
|
+
room_id: String(row.room_id),
|
|
905
|
+
room_name: String(row.room_name ?? ''),
|
|
906
|
+
source_message_id: Number(row.source_message_id),
|
|
907
|
+
title: String(row.title),
|
|
908
|
+
status: String(row.status) as RoomReminderStatus,
|
|
909
|
+
created_by: row.created_by === null || row.created_by === undefined ? null : String(row.created_by),
|
|
910
|
+
fire_at: String(row.fire_at),
|
|
911
|
+
repeat: row.repeat === null || row.repeat === undefined ? null : String(row.repeat),
|
|
912
|
+
fired_at: row.fired_at === null || row.fired_at === undefined ? null : String(row.fired_at),
|
|
913
|
+
canceled_at: row.canceled_at === null || row.canceled_at === undefined ? null : String(row.canceled_at),
|
|
914
|
+
created_at: String(row.created_at),
|
|
915
|
+
updated_at: String(row.updated_at),
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function rowToRoomSavedMessage(row: Record<string, unknown>): RoomSavedMessage {
|
|
920
|
+
return {
|
|
921
|
+
id: String(row.id),
|
|
922
|
+
room_id: String(row.room_id),
|
|
923
|
+
room_name: String(row.room_name ?? ''),
|
|
924
|
+
message_id: Number(row.message_id),
|
|
925
|
+
message_sender: String(row.message_sender),
|
|
926
|
+
message_content: String(row.message_content),
|
|
927
|
+
message_created_at: String(row.message_created_at),
|
|
928
|
+
saved_by: String(row.saved_by),
|
|
929
|
+
note: row.note === null || row.note === undefined ? null : String(row.note),
|
|
930
|
+
created_at: String(row.created_at),
|
|
931
|
+
updated_at: String(row.updated_at),
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function rowToWorkflowRun(row: Record<string, unknown>): WorkflowRun {
|
|
936
|
+
return {
|
|
937
|
+
id: String(row.id),
|
|
938
|
+
file_path: String(row.file_path),
|
|
939
|
+
goal: row.goal === null || row.goal === undefined ? null : String(row.goal),
|
|
940
|
+
status: String(row.status) as WorkflowRunStatus,
|
|
941
|
+
created_by: row.created_by === null || row.created_by === undefined ? null : String(row.created_by),
|
|
942
|
+
final_output: jsonRecordOrNull(row.final_output_json),
|
|
943
|
+
error: row.error === null || row.error === undefined ? null : String(row.error),
|
|
944
|
+
created_at: String(row.created_at),
|
|
945
|
+
updated_at: String(row.updated_at),
|
|
946
|
+
completed_at: row.completed_at === null || row.completed_at === undefined ? null : String(row.completed_at),
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function rowToWorkflowNode(row: Record<string, unknown>): WorkflowNode {
|
|
951
|
+
return {
|
|
952
|
+
id: String(row.id),
|
|
953
|
+
run_id: String(row.run_id),
|
|
954
|
+
parent_id: row.parent_id === null || row.parent_id === undefined ? null : String(row.parent_id),
|
|
955
|
+
kind: String(row.kind) as WorkflowNodeKind,
|
|
956
|
+
title: String(row.title),
|
|
957
|
+
role: row.role === null || row.role === undefined ? null : String(row.role),
|
|
958
|
+
status: String(row.status) as WorkflowNodeStatus,
|
|
959
|
+
context: jsonRecordOrNull(row.context_json),
|
|
960
|
+
instruction: row.instruction === null || row.instruction === undefined ? null : String(row.instruction),
|
|
961
|
+
capabilities: jsonStringArray(row.capabilities_json),
|
|
962
|
+
output_contract: jsonRecordOrNull(row.output_contract_json),
|
|
963
|
+
output: jsonRecordOrNull(row.output_json),
|
|
964
|
+
evidence: jsonRecordOrNull(row.evidence_json),
|
|
965
|
+
task_id: row.task_id === null || row.task_id === undefined ? null : String(row.task_id),
|
|
966
|
+
message_id: row.message_id === null || row.message_id === undefined ? null : Number(row.message_id),
|
|
967
|
+
created_at: String(row.created_at),
|
|
968
|
+
updated_at: String(row.updated_at),
|
|
969
|
+
completed_at: row.completed_at === null || row.completed_at === undefined ? null : String(row.completed_at),
|
|
375
970
|
};
|
|
376
971
|
}
|
|
377
972
|
|
|
@@ -401,6 +996,28 @@ function rowToRun(row: Record<string, unknown>): AgentRun {
|
|
|
401
996
|
};
|
|
402
997
|
}
|
|
403
998
|
|
|
999
|
+
function rowToActivityEvent(row: Record<string, unknown>): AgentActivityEvent {
|
|
1000
|
+
let metadata: Record<string, unknown> = {};
|
|
1001
|
+
try {
|
|
1002
|
+
const parsed = JSON.parse(String(row.metadata ?? '{}')) as unknown;
|
|
1003
|
+
metadata = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
|
|
1004
|
+
} catch {
|
|
1005
|
+
metadata = {};
|
|
1006
|
+
}
|
|
1007
|
+
return {
|
|
1008
|
+
id: String(row.id),
|
|
1009
|
+
run_id: String(row.run_id),
|
|
1010
|
+
session_id: row.session_id === null || row.session_id === undefined ? null : String(row.session_id),
|
|
1011
|
+
agent: String(row.agent),
|
|
1012
|
+
room_id: String(row.room_id ?? row.chat_id),
|
|
1013
|
+
kind: String(row.kind) as AgentActivityKind,
|
|
1014
|
+
title: String(row.title),
|
|
1015
|
+
detail: String(row.detail ?? ''),
|
|
1016
|
+
metadata,
|
|
1017
|
+
created_at: String(row.created_at),
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
|
|
404
1021
|
function rowToSession(row: Record<string, unknown>): AgentSession {
|
|
405
1022
|
return {
|
|
406
1023
|
id: String(row.id),
|
|
@@ -460,6 +1077,7 @@ function rowToDelivery(row: Record<string, unknown>): MessageDelivery {
|
|
|
460
1077
|
id: String(row.id),
|
|
461
1078
|
message_id: Number(row.message_id),
|
|
462
1079
|
chat_id: String(row.chat_id),
|
|
1080
|
+
...(row.chat_name === undefined ? {} : { chat_name: row.chat_name === null ? null : String(row.chat_name) }),
|
|
463
1081
|
agent: String(row.agent),
|
|
464
1082
|
status: String(row.status) as MessageDelivery['status'],
|
|
465
1083
|
daemon_id: row.daemon_id === null ? null : String(row.daemon_id),
|
|
@@ -603,6 +1221,8 @@ function rowToAgentRoomSubscription(row: Record<string, unknown>): AgentRoomSubs
|
|
|
603
1221
|
room_id: String(row.room_id),
|
|
604
1222
|
channel_id: row.channel_id === null || row.channel_id === undefined ? null : String(row.channel_id),
|
|
605
1223
|
mode: String(row.mode) as AgentRoomSubscriptionMode,
|
|
1224
|
+
last_read_message_id: row.last_read_message_id === null || row.last_read_message_id === undefined ? 0 : Number(row.last_read_message_id),
|
|
1225
|
+
last_read_at: row.last_read_at === null || row.last_read_at === undefined ? null : String(row.last_read_at),
|
|
606
1226
|
created_at: String(row.created_at),
|
|
607
1227
|
updated_at: String(row.updated_at),
|
|
608
1228
|
};
|
|
@@ -703,7 +1323,41 @@ function normalizeMentionsInput(value: string[] | undefined): string[] {
|
|
|
703
1323
|
seen.add(trimmed);
|
|
704
1324
|
result.push(trimmed);
|
|
705
1325
|
}
|
|
706
|
-
return result;
|
|
1326
|
+
return result;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
function normalizeSystemEventActorKind(value?: RoomSystemEventActorKind): RoomSystemEventActorKind {
|
|
1330
|
+
if (value === 'user' || value === 'agent' || value === 'system') return value;
|
|
1331
|
+
return 'system';
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
function normalizeSystemEventMetadata(value?: Record<string, unknown> | null): Record<string, unknown> {
|
|
1335
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
|
1336
|
+
return value;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
function systemEventContent(input: {
|
|
1340
|
+
eventType: RoomSystemEventType;
|
|
1341
|
+
actorKind: RoomSystemEventActorKind;
|
|
1342
|
+
actorId: string | null;
|
|
1343
|
+
subjectAgent: string | null;
|
|
1344
|
+
metadata: Record<string, unknown>;
|
|
1345
|
+
}): string {
|
|
1346
|
+
const actor = input.actorId ? ` by ${input.actorKind}:${input.actorId}` : '';
|
|
1347
|
+
const agent = input.subjectAgent ? `@${input.subjectAgent}` : 'An agent';
|
|
1348
|
+
switch (input.eventType) {
|
|
1349
|
+
case 'agent_invited': {
|
|
1350
|
+
const mode = typeof input.metadata.mode === 'string' ? input.metadata.mode : 'mentions';
|
|
1351
|
+
return `System event: ${agent} was invited to this room with receive mode "${mode}"${actor}.`;
|
|
1352
|
+
}
|
|
1353
|
+
case 'agent_removed':
|
|
1354
|
+
return `System event: ${agent} was removed from this room${actor}.`;
|
|
1355
|
+
case 'agent_receive_mode_changed': {
|
|
1356
|
+
const oldMode = typeof input.metadata.old_mode === 'string' ? input.metadata.old_mode : 'unknown';
|
|
1357
|
+
const newMode = typeof input.metadata.new_mode === 'string' ? input.metadata.new_mode : 'unknown';
|
|
1358
|
+
return `System event: ${agent} changed room receive mode from "${oldMode}" to "${newMode}"${actor}.`;
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
707
1361
|
}
|
|
708
1362
|
|
|
709
1363
|
function rowToTranscriptReadModel(row: Record<string, unknown>): TranscriptReadModel {
|
|
@@ -788,8 +1442,10 @@ function limitValue(value: number | undefined, fallback: number): number {
|
|
|
788
1442
|
|
|
789
1443
|
export class MessageStore {
|
|
790
1444
|
readonly db: Database;
|
|
1445
|
+
private readonly palHome: string;
|
|
791
1446
|
|
|
792
|
-
constructor(path: string) {
|
|
1447
|
+
constructor(path: string, options: { palHome?: string } = {}) {
|
|
1448
|
+
this.palHome = options.palHome ?? defaultPalHomeDir();
|
|
793
1449
|
ensureParentDir(path);
|
|
794
1450
|
this.db = new Database(path);
|
|
795
1451
|
this.db.exec('PRAGMA journal_mode = WAL');
|
|
@@ -802,6 +1458,43 @@ export class MessageStore {
|
|
|
802
1458
|
this.db.close();
|
|
803
1459
|
}
|
|
804
1460
|
|
|
1461
|
+
getLarkWebsocketRestartState(appId: string): LarkWebsocketRestartState | null {
|
|
1462
|
+
const key = appId.trim();
|
|
1463
|
+
if (!key) return null;
|
|
1464
|
+
const row = this.db.query(`
|
|
1465
|
+
SELECT app_id, restart_count, last_restart_at, last_restart_reason, updated_at
|
|
1466
|
+
FROM lark_websocket_restart_state
|
|
1467
|
+
WHERE app_id = ?
|
|
1468
|
+
LIMIT 1
|
|
1469
|
+
`).get(key) as LarkWebsocketRestartState | null;
|
|
1470
|
+
if (!row) return null;
|
|
1471
|
+
return {
|
|
1472
|
+
app_id: String(row.app_id),
|
|
1473
|
+
restart_count: Number(row.restart_count),
|
|
1474
|
+
last_restart_at: row.last_restart_at === null ? null : String(row.last_restart_at),
|
|
1475
|
+
last_restart_reason: row.last_restart_reason === null ? null : String(row.last_restart_reason),
|
|
1476
|
+
updated_at: String(row.updated_at),
|
|
1477
|
+
};
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
recordLarkWebsocketRestart(input: { appId: string; restartedAt: string; reason: string }): LarkWebsocketRestartState {
|
|
1481
|
+
const appId = input.appId.trim();
|
|
1482
|
+
if (!appId) throw new Error('appId is required');
|
|
1483
|
+
const restartedAt = input.restartedAt.trim();
|
|
1484
|
+
if (!restartedAt) throw new Error('restartedAt is required');
|
|
1485
|
+
const reason = input.reason.trim() || 'unknown';
|
|
1486
|
+
this.db.query(`
|
|
1487
|
+
INSERT INTO lark_websocket_restart_state (app_id, restart_count, last_restart_at, last_restart_reason, updated_at)
|
|
1488
|
+
VALUES (?, 1, ?, ?, datetime('now'))
|
|
1489
|
+
ON CONFLICT(app_id) DO UPDATE SET
|
|
1490
|
+
restart_count = lark_websocket_restart_state.restart_count + 1,
|
|
1491
|
+
last_restart_at = excluded.last_restart_at,
|
|
1492
|
+
last_restart_reason = excluded.last_restart_reason,
|
|
1493
|
+
updated_at = datetime('now')
|
|
1494
|
+
`).run(appId, restartedAt, reason);
|
|
1495
|
+
return this.getLarkWebsocketRestartState(appId)!;
|
|
1496
|
+
}
|
|
1497
|
+
|
|
805
1498
|
getOrCreateChat(name: string, kind: ChatKind = 'group'): Chat {
|
|
806
1499
|
const chatName = normalizeChatName(name);
|
|
807
1500
|
const existing = this.db.query('SELECT * FROM chat_stats WHERE name = ?').get(chatName) as Chat | null;
|
|
@@ -812,6 +1505,448 @@ export class MessageStore {
|
|
|
812
1505
|
return this.getChatById(id)!;
|
|
813
1506
|
}
|
|
814
1507
|
|
|
1508
|
+
createProject(input: { name: string; computerId: string; rootPath: string }): Project {
|
|
1509
|
+
const name = input.name.trim();
|
|
1510
|
+
const computerId = input.computerId.trim();
|
|
1511
|
+
const rootPath = input.rootPath.trim();
|
|
1512
|
+
if (!name) throw new Error('project name is required');
|
|
1513
|
+
if (!computerId) throw new Error('computer_id is required');
|
|
1514
|
+
if (!rootPath) throw new Error('root_path is required');
|
|
1515
|
+
if (!isAbsolute(rootPath)) throw new Error('root_path must be absolute');
|
|
1516
|
+
const computer = this.getComputer(computerId);
|
|
1517
|
+
if (!computer) throw new Error(`computer ${computerId} not found`);
|
|
1518
|
+
|
|
1519
|
+
const id = crypto.randomUUID();
|
|
1520
|
+
this.db.query(`
|
|
1521
|
+
INSERT INTO projects (id, name, computer_id, root_path, created_at, updated_at)
|
|
1522
|
+
VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))
|
|
1523
|
+
`).run(id, name, computerId, rootPath);
|
|
1524
|
+
return this.getProject(id)!;
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
deleteProject(projectId: string): Project | null {
|
|
1528
|
+
const project = this.getProject(projectId);
|
|
1529
|
+
if (!project) return null;
|
|
1530
|
+
if (project.status !== 'archived') throw new Error('PROJECT_NOT_ARCHIVED: project must be archived before deletion');
|
|
1531
|
+
this.db.transaction(() => {
|
|
1532
|
+
this.db.query('DELETE FROM chats WHERE project_id = ?').run(project.id);
|
|
1533
|
+
this.db.query('DELETE FROM projects WHERE id = ?').run(project.id);
|
|
1534
|
+
})();
|
|
1535
|
+
return project;
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
getOrCreateAgentDm(agentKey: string): Chat {
|
|
1539
|
+
const key = agentKey.trim();
|
|
1540
|
+
if (!key) throw new Error('agent is required');
|
|
1541
|
+
const agent = this.getAgent(key);
|
|
1542
|
+
if (!agent) throw new Error(`agent ${key} not found`);
|
|
1543
|
+
const chatName = normalizeChatName(`dm-owner-${key}`);
|
|
1544
|
+
const existing = this.getChatByName(chatName);
|
|
1545
|
+
if (existing && (existing.provider !== 'web' || existing.kind !== 'dm')) {
|
|
1546
|
+
throw new Error(`room ${chatName} already exists and is not a web DM`);
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
let room = existing;
|
|
1550
|
+
if (!room) {
|
|
1551
|
+
const id = crypto.randomUUID();
|
|
1552
|
+
this.db.query(`
|
|
1553
|
+
INSERT INTO chats (id, name, display_name, kind, provider, dm_type, capabilities_json)
|
|
1554
|
+
VALUES (?, ?, ?, 'dm', 'web', 'user_agent', '{"topics":"unsupported"}')
|
|
1555
|
+
`).run(id, chatName, agent.display_name);
|
|
1556
|
+
room = this.getChatById(id)!;
|
|
1557
|
+
} else {
|
|
1558
|
+
this.db.query(`
|
|
1559
|
+
UPDATE chats
|
|
1560
|
+
SET display_name = ?, status = 'active'
|
|
1561
|
+
WHERE id = ?
|
|
1562
|
+
`).run(agent.display_name, room.id);
|
|
1563
|
+
room = this.getChatById(room.id)!;
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
this.inviteAgentToRoom({ roomId: room.id, agent: key, mode: 'all' });
|
|
1567
|
+
return this.getChatById(room.id)!;
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
getProject(id: string): Project | null {
|
|
1571
|
+
const row = this.db.query(`
|
|
1572
|
+
SELECT p.*, c.name AS computer_name, COUNT(ch.id) AS room_count
|
|
1573
|
+
FROM projects p
|
|
1574
|
+
LEFT JOIN computers c ON c.id = p.computer_id
|
|
1575
|
+
LEFT JOIN chats ch ON ch.project_id = p.id
|
|
1576
|
+
WHERE p.id = ?
|
|
1577
|
+
GROUP BY p.id
|
|
1578
|
+
`).get(id.trim()) as Record<string, unknown> | null;
|
|
1579
|
+
return row ? rowToProject(row) : null;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
private getWorkbenchProjectForAgent(agentKey: string, assignment: ComputerAgentAssignment | null): Project | null {
|
|
1583
|
+
const roomProject = this.db.query(`
|
|
1584
|
+
SELECT p.*, c.name AS computer_name, COUNT(ch.id) AS room_count
|
|
1585
|
+
FROM room_participants rp
|
|
1586
|
+
JOIN chats agent_room ON agent_room.id = rp.room_id
|
|
1587
|
+
JOIN projects p ON p.id = agent_room.project_id
|
|
1588
|
+
LEFT JOIN computers c ON c.id = p.computer_id
|
|
1589
|
+
LEFT JOIN chats ch ON ch.project_id = p.id
|
|
1590
|
+
WHERE rp.participant_id = ? AND rp.kind = 'agent' AND rp.status = 'active' AND p.status = 'active'
|
|
1591
|
+
GROUP BY p.id
|
|
1592
|
+
ORDER BY datetime(p.updated_at) DESC, p.name ASC
|
|
1593
|
+
LIMIT 1
|
|
1594
|
+
`).get(agentKey.trim()) as Record<string, unknown> | null;
|
|
1595
|
+
if (roomProject) return rowToProject(roomProject);
|
|
1596
|
+
if (!assignment) return null;
|
|
1597
|
+
const assignedProject = this.db.query(`
|
|
1598
|
+
SELECT p.*, c.name AS computer_name, COUNT(ch.id) AS room_count
|
|
1599
|
+
FROM projects p
|
|
1600
|
+
LEFT JOIN computers c ON c.id = p.computer_id
|
|
1601
|
+
LEFT JOIN chats ch ON ch.project_id = p.id
|
|
1602
|
+
WHERE p.computer_id = ? AND p.status = 'active'
|
|
1603
|
+
GROUP BY p.id
|
|
1604
|
+
ORDER BY datetime(p.updated_at) DESC, p.name ASC
|
|
1605
|
+
LIMIT 1
|
|
1606
|
+
`).get(assignment.computer_id) as Record<string, unknown> | null;
|
|
1607
|
+
return assignedProject ? rowToProject(assignedProject) : null;
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
listProjects(limit = 50): Project[] {
|
|
1611
|
+
const rows = this.db.query(`
|
|
1612
|
+
SELECT p.*, c.name AS computer_name, COUNT(ch.id) AS room_count
|
|
1613
|
+
FROM projects p
|
|
1614
|
+
LEFT JOIN computers c ON c.id = p.computer_id
|
|
1615
|
+
LEFT JOIN chats ch ON ch.project_id = p.id
|
|
1616
|
+
GROUP BY p.id
|
|
1617
|
+
ORDER BY p.updated_at DESC, p.created_at DESC
|
|
1618
|
+
LIMIT ?
|
|
1619
|
+
`).all(limitValue(limit, 50)) as Record<string, unknown>[];
|
|
1620
|
+
return rows.map(rowToProject);
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
listProjectsForComputer(computerId: string): Project[] {
|
|
1624
|
+
const rows = this.db.query(`
|
|
1625
|
+
SELECT p.*, c.name AS computer_name, COUNT(ch.id) AS room_count
|
|
1626
|
+
FROM projects p
|
|
1627
|
+
LEFT JOIN computers c ON c.id = p.computer_id
|
|
1628
|
+
LEFT JOIN chats ch ON ch.project_id = p.id
|
|
1629
|
+
WHERE p.computer_id = ?
|
|
1630
|
+
GROUP BY p.id
|
|
1631
|
+
ORDER BY p.status ASC, p.updated_at DESC, p.created_at DESC
|
|
1632
|
+
`).all(computerId.trim()) as Record<string, unknown>[];
|
|
1633
|
+
return rows.map(rowToProject);
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
updateProjectStatus(projectId: string, status: ProjectStatus): Project {
|
|
1637
|
+
if (status !== 'active' && status !== 'archived') throw new Error('project status must be active or archived');
|
|
1638
|
+
const project = this.getProject(projectId);
|
|
1639
|
+
if (!project) throw new Error(`project ${projectId} was not found`);
|
|
1640
|
+
this.db.query(`UPDATE projects SET status = ?, updated_at = datetime('now') WHERE id = ?`).run(status, project.id);
|
|
1641
|
+
return this.getProject(project.id)!;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
listProjectAgentDefaults(projectId: string): ProjectAgentDefault[] {
|
|
1645
|
+
const project = this.getProject(projectId);
|
|
1646
|
+
if (!project) throw new Error(`project ${projectId} was not found`);
|
|
1647
|
+
const rows = this.db.query(`
|
|
1648
|
+
SELECT pad.*, a.display_name
|
|
1649
|
+
FROM project_agent_defaults pad
|
|
1650
|
+
LEFT JOIN agents a ON a.agent_key = pad.agent
|
|
1651
|
+
WHERE pad.project_id = ?
|
|
1652
|
+
ORDER BY pad.agent ASC
|
|
1653
|
+
`).all(project.id) as Record<string, unknown>[];
|
|
1654
|
+
return rows.map(rowToProjectAgentDefault);
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
setProjectAgentDefaults(projectId: string, defaults: Array<{ agent: string; mode: AgentRoomSubscriptionMode }>): ProjectAgentDefault[] {
|
|
1658
|
+
const project = this.getProject(projectId);
|
|
1659
|
+
if (!project) throw new Error(`project ${projectId} was not found`);
|
|
1660
|
+
const normalized = new Map<string, AgentRoomSubscriptionMode>();
|
|
1661
|
+
for (const item of defaults) {
|
|
1662
|
+
const agent = item.agent.trim();
|
|
1663
|
+
if (!agent) continue;
|
|
1664
|
+
if (!this.getAgent(agent)) throw new Error(`agent ${agent} not found`);
|
|
1665
|
+
normalized.set(agent, item.mode);
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
this.db.transaction(() => {
|
|
1669
|
+
const agents = [...normalized.keys()];
|
|
1670
|
+
if (agents.length) {
|
|
1671
|
+
const placeholders = agents.map(() => '?').join(', ');
|
|
1672
|
+
this.db.query(`DELETE FROM project_agent_defaults WHERE project_id = ? AND agent NOT IN (${placeholders})`).run(project.id, ...agents);
|
|
1673
|
+
} else {
|
|
1674
|
+
this.db.query(`DELETE FROM project_agent_defaults WHERE project_id = ?`).run(project.id);
|
|
1675
|
+
}
|
|
1676
|
+
for (const [agent, mode] of normalized) {
|
|
1677
|
+
this.db.query(`
|
|
1678
|
+
INSERT INTO project_agent_defaults (id, project_id, agent, mode, created_at, updated_at)
|
|
1679
|
+
VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))
|
|
1680
|
+
ON CONFLICT(project_id, agent) DO UPDATE SET
|
|
1681
|
+
mode = excluded.mode,
|
|
1682
|
+
updated_at = datetime('now')
|
|
1683
|
+
`).run(crypto.randomUUID(), project.id, agent, mode);
|
|
1684
|
+
}
|
|
1685
|
+
this.db.query(`UPDATE projects SET updated_at = datetime('now') WHERE id = ?`).run(project.id);
|
|
1686
|
+
})();
|
|
1687
|
+
|
|
1688
|
+
return this.listProjectAgentDefaults(project.id);
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
getSkillDefinition(key: string): SkillDefinition | null {
|
|
1692
|
+
const row = this.db.query('SELECT * FROM skill_definitions WHERE key = ?').get(key.trim()) as Record<string, unknown> | null;
|
|
1693
|
+
return row ? rowToSkillDefinition(row) : null;
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
listSkillDefinitions(input: { source?: SkillSource; ownerUserId?: string | null; projectId?: string | null; includeDisabled?: boolean } = {}): SkillDefinition[] {
|
|
1697
|
+
const where: string[] = [];
|
|
1698
|
+
const params: SQLQueryBindings[] = [];
|
|
1699
|
+
if (input.source) {
|
|
1700
|
+
where.push('source = ?');
|
|
1701
|
+
params.push(assertSkillSource(input.source));
|
|
1702
|
+
}
|
|
1703
|
+
if (input.ownerUserId !== undefined) {
|
|
1704
|
+
if (input.ownerUserId === null) {
|
|
1705
|
+
where.push('owner_user_id IS NULL');
|
|
1706
|
+
} else {
|
|
1707
|
+
where.push('owner_user_id = ?');
|
|
1708
|
+
params.push(input.ownerUserId.trim());
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
if (input.projectId !== undefined) {
|
|
1712
|
+
if (input.projectId === null) {
|
|
1713
|
+
where.push('project_id IS NULL');
|
|
1714
|
+
} else {
|
|
1715
|
+
where.push('project_id = ?');
|
|
1716
|
+
params.push(input.projectId.trim());
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
if (!input.includeDisabled) where.push("status = 'active'");
|
|
1720
|
+
const rows = this.db.query(`
|
|
1721
|
+
SELECT * FROM skill_definitions
|
|
1722
|
+
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
|
1723
|
+
ORDER BY source ASC, owner_user_id ASC, project_id ASC, key ASC
|
|
1724
|
+
`).all(...params) as Record<string, unknown>[];
|
|
1725
|
+
return rows.map(rowToSkillDefinition);
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
upsertSkillDefinition(input: {
|
|
1729
|
+
key: string;
|
|
1730
|
+
name: string;
|
|
1731
|
+
description?: string;
|
|
1732
|
+
instructionContent: string;
|
|
1733
|
+
status?: SkillDefinition['status'];
|
|
1734
|
+
version?: string;
|
|
1735
|
+
source?: SkillSource;
|
|
1736
|
+
ownerUserId?: string | null;
|
|
1737
|
+
projectId?: string | null;
|
|
1738
|
+
repositoryPath?: string | null;
|
|
1739
|
+
}): SkillDefinition {
|
|
1740
|
+
const key = input.key.trim();
|
|
1741
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{1,80}$/.test(key)) throw new Error('INVALID_SKILL_KEY');
|
|
1742
|
+
const name = input.name.trim();
|
|
1743
|
+
if (!name) throw new Error('skill name is required');
|
|
1744
|
+
const instructionContent = input.instructionContent.trim();
|
|
1745
|
+
if (!instructionContent) throw new Error('skill instruction_content is required');
|
|
1746
|
+
const source = assertSkillSource(input.source ?? 'user');
|
|
1747
|
+
const status = assertSkillStatus(input.status ?? 'active');
|
|
1748
|
+
const version = input.version?.trim() || '1';
|
|
1749
|
+
const ownerUserId = input.ownerUserId?.trim() || null;
|
|
1750
|
+
const projectId = input.projectId?.trim() || null;
|
|
1751
|
+
if (source === 'user' && !ownerUserId) throw new Error('owner_user_id is required for user skills');
|
|
1752
|
+
if (source === 'project' && !projectId) throw new Error('project_id is required for project skills');
|
|
1753
|
+
const repositoryPath = input.repositoryPath?.trim() || (source === 'user' ? `user/${ownerUserId}/${key}` : source === 'project' ? `project/${projectId}/${key}` : null);
|
|
1754
|
+
const existing = this.getSkillDefinition(key);
|
|
1755
|
+
if (existing) {
|
|
1756
|
+
if (existing.source !== source) throw new Error('SKILL_SOURCE_IMMUTABLE');
|
|
1757
|
+
if (existing.source === 'system' && (ownerUserId || projectId || repositoryPath)) {
|
|
1758
|
+
throw new Error('SYSTEM_SKILL_OWNERSHIP_IMMUTABLE');
|
|
1759
|
+
}
|
|
1760
|
+
if (existing.source === 'user' && existing.owner_user_id !== ownerUserId) throw new Error('SKILL_OWNER_IMMUTABLE');
|
|
1761
|
+
if (existing.source === 'project' && existing.project_id !== projectId) throw new Error('SKILL_PROJECT_IMMUTABLE');
|
|
1762
|
+
}
|
|
1763
|
+
this.db.query(`
|
|
1764
|
+
INSERT INTO skill_definitions (
|
|
1765
|
+
key, name, description, instruction_content, status, version, source, owner_user_id, project_id, repository_path, created_at, updated_at
|
|
1766
|
+
)
|
|
1767
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
|
1768
|
+
ON CONFLICT(key) DO UPDATE SET
|
|
1769
|
+
name = excluded.name,
|
|
1770
|
+
description = excluded.description,
|
|
1771
|
+
instruction_content = excluded.instruction_content,
|
|
1772
|
+
status = excluded.status,
|
|
1773
|
+
version = excluded.version,
|
|
1774
|
+
source = excluded.source,
|
|
1775
|
+
owner_user_id = excluded.owner_user_id,
|
|
1776
|
+
project_id = excluded.project_id,
|
|
1777
|
+
repository_path = excluded.repository_path,
|
|
1778
|
+
updated_at = datetime('now')
|
|
1779
|
+
`).run(
|
|
1780
|
+
key,
|
|
1781
|
+
name,
|
|
1782
|
+
input.description?.trim() || '',
|
|
1783
|
+
instructionContent,
|
|
1784
|
+
status,
|
|
1785
|
+
version,
|
|
1786
|
+
source,
|
|
1787
|
+
ownerUserId,
|
|
1788
|
+
projectId,
|
|
1789
|
+
repositoryPath,
|
|
1790
|
+
);
|
|
1791
|
+
const skill = this.getSkillDefinition(key);
|
|
1792
|
+
if (!skill) throw new Error('SKILL_NOT_FOUND');
|
|
1793
|
+
return skill;
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
createSkillDefinition(input: {
|
|
1797
|
+
key: string;
|
|
1798
|
+
name: string;
|
|
1799
|
+
description?: string;
|
|
1800
|
+
instructionContent: string;
|
|
1801
|
+
status?: SkillDefinition['status'];
|
|
1802
|
+
source?: SkillSource;
|
|
1803
|
+
ownerUserId?: string | null;
|
|
1804
|
+
projectId?: string | null;
|
|
1805
|
+
repositoryPath?: string | null;
|
|
1806
|
+
}): SkillDefinition {
|
|
1807
|
+
const key = input.key.trim();
|
|
1808
|
+
if (this.getSkillDefinition(key)) throw new Error('SKILL_ALREADY_EXISTS');
|
|
1809
|
+
if ((input.source ?? 'user') === 'system') throw new Error('SYSTEM_SKILL_CREATE_FORBIDDEN');
|
|
1810
|
+
return this.upsertSkillDefinition(input);
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
updateSkillDefinition(key: string, input: {
|
|
1814
|
+
name?: string;
|
|
1815
|
+
description?: string;
|
|
1816
|
+
instructionContent?: string;
|
|
1817
|
+
status?: SkillDefinition['status'];
|
|
1818
|
+
ownerUserId?: string | null;
|
|
1819
|
+
projectId?: string | null;
|
|
1820
|
+
repositoryPath?: string | null;
|
|
1821
|
+
}): SkillDefinition {
|
|
1822
|
+
const existing = this.getSkillDefinition(key);
|
|
1823
|
+
if (!existing) throw new Error(`skill ${key} was not found`);
|
|
1824
|
+
if (existing.source === 'system') throw new Error('SYSTEM_SKILL_READ_ONLY');
|
|
1825
|
+
return this.upsertSkillDefinition({
|
|
1826
|
+
key: existing.key,
|
|
1827
|
+
name: input.name ?? existing.name,
|
|
1828
|
+
description: input.description ?? existing.description,
|
|
1829
|
+
instructionContent: input.instructionContent ?? existing.instruction_content,
|
|
1830
|
+
status: input.status ?? existing.status,
|
|
1831
|
+
version: existing.version,
|
|
1832
|
+
source: existing.source,
|
|
1833
|
+
ownerUserId: input.ownerUserId === undefined ? existing.owner_user_id : input.ownerUserId,
|
|
1834
|
+
projectId: input.projectId === undefined ? existing.project_id : input.projectId,
|
|
1835
|
+
repositoryPath: input.repositoryPath === undefined ? existing.repository_path : input.repositoryPath,
|
|
1836
|
+
});
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
upsertSkillBinding(input: { scope: SkillBindingScope; scopeId: string; skillKey: string; enabled?: boolean; priority?: number }): SkillBinding {
|
|
1840
|
+
const scope = assertSkillBindingScope(input.scope);
|
|
1841
|
+
const scopeId = input.scopeId.trim();
|
|
1842
|
+
const skillKey = input.skillKey.trim();
|
|
1843
|
+
if (!scopeId) throw new Error('scope_id is required');
|
|
1844
|
+
if (!skillKey) throw new Error('skill_key is required');
|
|
1845
|
+
if (!this.getSkillDefinition(skillKey)) throw new Error(`skill ${skillKey} was not found`);
|
|
1846
|
+
const enabled = input.enabled === false ? 0 : 1;
|
|
1847
|
+
const priority = Number.isInteger(input.priority) ? Number(input.priority) : 100;
|
|
1848
|
+
const id = crypto.randomUUID();
|
|
1849
|
+
this.db.query(`
|
|
1850
|
+
INSERT INTO skill_bindings (id, scope, scope_id, skill_key, enabled, priority, created_at, updated_at)
|
|
1851
|
+
VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
|
1852
|
+
ON CONFLICT(scope, scope_id, skill_key) DO UPDATE SET
|
|
1853
|
+
enabled = excluded.enabled,
|
|
1854
|
+
priority = excluded.priority,
|
|
1855
|
+
updated_at = datetime('now')
|
|
1856
|
+
`).run(id, scope, scopeId, skillKey, enabled, priority);
|
|
1857
|
+
const row = this.db.query(`
|
|
1858
|
+
SELECT * FROM skill_bindings
|
|
1859
|
+
WHERE scope = ? AND scope_id = ? AND skill_key = ?
|
|
1860
|
+
`).get(scope, scopeId, skillKey) as Record<string, unknown> | null;
|
|
1861
|
+
if (!row) throw new Error('SKILL_BINDING_NOT_FOUND');
|
|
1862
|
+
return rowToSkillBinding(row);
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
listSkillBindings(input: { scope?: SkillBindingScope; scopeId?: string } = {}): SkillBinding[] {
|
|
1866
|
+
const params: SQLQueryBindings[] = [];
|
|
1867
|
+
let where = '';
|
|
1868
|
+
if (input.scope) {
|
|
1869
|
+
where += ' WHERE scope = ?';
|
|
1870
|
+
params.push(assertSkillBindingScope(input.scope));
|
|
1871
|
+
}
|
|
1872
|
+
if (input.scopeId?.trim()) {
|
|
1873
|
+
where += where ? ' AND scope_id = ?' : ' WHERE scope_id = ?';
|
|
1874
|
+
params.push(input.scopeId.trim());
|
|
1875
|
+
}
|
|
1876
|
+
const rows = this.db.query(`
|
|
1877
|
+
SELECT * FROM skill_bindings
|
|
1878
|
+
${where}
|
|
1879
|
+
ORDER BY scope ASC, scope_id ASC, priority ASC, skill_key ASC
|
|
1880
|
+
`).all(...params) as Record<string, unknown>[];
|
|
1881
|
+
return rows.map(rowToSkillBinding);
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
deleteSkillBinding(input: { scope: SkillBindingScope; scopeId: string; skillKey: string }): boolean {
|
|
1885
|
+
const scope = assertSkillBindingScope(input.scope);
|
|
1886
|
+
const scopeId = input.scopeId.trim();
|
|
1887
|
+
const skillKey = input.skillKey.trim();
|
|
1888
|
+
if (!scopeId) throw new Error('scope_id is required');
|
|
1889
|
+
if (!skillKey) throw new Error('skill_key is required');
|
|
1890
|
+
const result = this.db.query('DELETE FROM skill_bindings WHERE scope = ? AND scope_id = ? AND skill_key = ?').run(scope, scopeId, skillKey);
|
|
1891
|
+
return result.changes > 0;
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
listEnabledSkills(input: { projectId?: string | null; roomId?: string | null; agent?: string | null }): EnabledSkill[] {
|
|
1895
|
+
const orderedScopes: Array<{ scope: SkillBindingScope; scopeId: string; rank: number }> = [];
|
|
1896
|
+
if (input.projectId?.trim()) orderedScopes.push({ scope: 'project', scopeId: input.projectId.trim(), rank: 0 });
|
|
1897
|
+
if (input.roomId?.trim()) orderedScopes.push({ scope: 'room', scopeId: input.roomId.trim(), rank: 1 });
|
|
1898
|
+
if (input.agent?.trim()) orderedScopes.push({ scope: 'agent', scopeId: input.agent.trim(), rank: 2 });
|
|
1899
|
+
if (orderedScopes.length === 0) return [];
|
|
1900
|
+
|
|
1901
|
+
const rows: EnabledSkill[] = [];
|
|
1902
|
+
for (const scope of orderedScopes) {
|
|
1903
|
+
const scopeRows = this.db.query(`
|
|
1904
|
+
SELECT
|
|
1905
|
+
sd.*,
|
|
1906
|
+
sb.scope AS binding_scope,
|
|
1907
|
+
sb.scope_id AS binding_scope_id,
|
|
1908
|
+
sb.priority AS binding_priority
|
|
1909
|
+
FROM skill_bindings sb
|
|
1910
|
+
JOIN skill_definitions sd ON sd.key = sb.skill_key
|
|
1911
|
+
WHERE sb.scope = ?
|
|
1912
|
+
AND sb.scope_id = ?
|
|
1913
|
+
AND sb.enabled = 1
|
|
1914
|
+
AND sd.status = 'active'
|
|
1915
|
+
ORDER BY sb.priority ASC, sd.key ASC
|
|
1916
|
+
`).all(scope.scope, scope.scopeId) as Record<string, unknown>[];
|
|
1917
|
+
rows.push(...scopeRows.map(rowToEnabledSkill));
|
|
1918
|
+
}
|
|
1919
|
+
return rows;
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
createProjectRoom(input: { projectId: string; name: string; kind?: ChatKind; mode?: RoomMode }): Chat {
|
|
1923
|
+
const project = this.getProject(input.projectId);
|
|
1924
|
+
if (!project) throw new Error(`project ${input.projectId} was not found`);
|
|
1925
|
+
if (project.status === 'archived') throw new Error('PROJECT_ARCHIVED: archived projects cannot create rooms');
|
|
1926
|
+
const kind = input.kind ?? 'group';
|
|
1927
|
+
if (kind !== 'group' && kind !== 'dm') throw new Error('kind must be group or dm');
|
|
1928
|
+
const mode = assertRoomMode(input.mode ?? 'standard');
|
|
1929
|
+
const chatName = normalizeChatName(input.name);
|
|
1930
|
+
if (!chatName) throw new Error('room name is required');
|
|
1931
|
+
if (this.getChatByName(chatName)) throw new Error(`room ${chatName} already exists`);
|
|
1932
|
+
|
|
1933
|
+
const id = crypto.randomUUID();
|
|
1934
|
+
this.db.transaction(() => {
|
|
1935
|
+
this.db.query(`
|
|
1936
|
+
INSERT INTO chats (id, name, kind, provider, capabilities_json, project_id, mode)
|
|
1937
|
+
VALUES (?, ?, ?, 'web', ?, ?, ?)
|
|
1938
|
+
`).run(id, chatName, kind, kind === 'dm' ? '{"topics":"unsupported"}' : '{"topics":"native"}', project.id, mode);
|
|
1939
|
+
if (mode === 'idea_development') {
|
|
1940
|
+
this.upsertSkillBinding({ scope: 'room', scopeId: id, skillKey: 'idea_development', enabled: true, priority: 100 });
|
|
1941
|
+
}
|
|
1942
|
+
for (const defaultMember of this.listProjectAgentDefaults(project.id)) {
|
|
1943
|
+
this.inviteAgentToRoom({ roomId: id, agent: defaultMember.agent, mode: defaultMember.mode });
|
|
1944
|
+
}
|
|
1945
|
+
this.db.query(`UPDATE projects SET updated_at = datetime('now') WHERE id = ?`).run(project.id);
|
|
1946
|
+
})();
|
|
1947
|
+
return this.getChatById(id)!;
|
|
1948
|
+
}
|
|
1949
|
+
|
|
815
1950
|
updateRoomDisplayName(roomId: string, displayName: string | null): Chat {
|
|
816
1951
|
const room = this.getChatById(roomId);
|
|
817
1952
|
if (!room) throw new Error(`room ${roomId} was not found`);
|
|
@@ -820,11 +1955,20 @@ export class MessageStore {
|
|
|
820
1955
|
return this.getChatById(room.id)!;
|
|
821
1956
|
}
|
|
822
1957
|
|
|
1958
|
+
updateRoomStatus(roomId: string, status: ChatStatus): Chat {
|
|
1959
|
+
if (status !== 'active' && status !== 'archived') throw new Error('room status must be active or archived');
|
|
1960
|
+
const room = this.getChatById(roomId);
|
|
1961
|
+
if (!room) throw new Error(`room ${roomId} was not found`);
|
|
1962
|
+
if (room.provider !== 'web') throw new Error('EXTERNAL_ROOM_STATUS_READ_ONLY: external provider rooms cannot be archived from Web');
|
|
1963
|
+
this.db.query('UPDATE chats SET status = ? WHERE id = ?').run(status, room.id);
|
|
1964
|
+
return this.getChatById(room.id)!;
|
|
1965
|
+
}
|
|
1966
|
+
|
|
823
1967
|
backfillLarkDmDisplayNames(): number {
|
|
824
1968
|
const result = this.db.query(`
|
|
825
1969
|
UPDATE chats
|
|
826
1970
|
SET display_name = (
|
|
827
|
-
SELECT
|
|
1971
|
+
SELECT rp.display_name
|
|
828
1972
|
FROM room_participants rp
|
|
829
1973
|
WHERE rp.room_id = chats.id
|
|
830
1974
|
AND rp.kind = 'bot'
|
|
@@ -836,7 +1980,7 @@ export class MessageStore {
|
|
|
836
1980
|
)
|
|
837
1981
|
WHERE provider = 'lark'
|
|
838
1982
|
AND kind = 'dm'
|
|
839
|
-
AND (display_name IS NULL OR trim(display_name) = '' OR display_name LIKE 'lark:%')
|
|
1983
|
+
AND (display_name IS NULL OR trim(display_name) = '' OR display_name LIKE 'lark:%' OR display_name LIKE 'DM with %')
|
|
840
1984
|
AND EXISTS (
|
|
841
1985
|
SELECT 1
|
|
842
1986
|
FROM room_participants rp
|
|
@@ -850,6 +1994,15 @@ export class MessageStore {
|
|
|
850
1994
|
return result.changes;
|
|
851
1995
|
}
|
|
852
1996
|
|
|
1997
|
+
deleteRoom(roomId: string): Chat | null {
|
|
1998
|
+
const room = this.getChatById(roomId);
|
|
1999
|
+
if (!room) return null;
|
|
2000
|
+
if (room.provider !== 'web') throw new Error('EXTERNAL_ROOM_DELETE_READ_ONLY: external provider rooms cannot be deleted from Web');
|
|
2001
|
+
if (room.status !== 'archived') throw new Error('ROOM_NOT_ARCHIVED: room must be archived before deletion');
|
|
2002
|
+
this.db.query('DELETE FROM chats WHERE id = ?').run(room.id);
|
|
2003
|
+
return room;
|
|
2004
|
+
}
|
|
2005
|
+
|
|
853
2006
|
getChatById(id: string): Chat | null {
|
|
854
2007
|
const row = this.db.query('SELECT * FROM chat_stats WHERE id = ?').get(id) as Chat | null;
|
|
855
2008
|
return row ?? null;
|
|
@@ -864,6 +2017,10 @@ export class MessageStore {
|
|
|
864
2017
|
return this.db.query('SELECT * FROM chat_stats ORDER BY COALESCE(last_message_at, created_at) DESC').all() as Chat[];
|
|
865
2018
|
}
|
|
866
2019
|
|
|
2020
|
+
listChatsForAgent(agent: string): Chat[] {
|
|
2021
|
+
return this.listChats().filter((room) => this.canAgentParticipateInRoom(agent.trim(), room));
|
|
2022
|
+
}
|
|
2023
|
+
|
|
867
2024
|
listLarkAuthorizedUsers(): LarkAuthorizedUser[] {
|
|
868
2025
|
const rows = this.db.query('SELECT * FROM lark_authorized_users ORDER BY COALESCE(display_name, user_id), user_id').all() as Record<string, unknown>[];
|
|
869
2026
|
return rows.map(rowToLarkAuthorizedUser);
|
|
@@ -1012,24 +2169,115 @@ export class MessageStore {
|
|
|
1012
2169
|
return Number(row.id ?? 0);
|
|
1013
2170
|
}
|
|
1014
2171
|
|
|
2172
|
+
private systemEventDeliveryAgents(room: Chat, extraAgents: string[] = []): string[] {
|
|
2173
|
+
const candidates = new Set<string>();
|
|
2174
|
+
for (const agent of this.listRoomAgents(room)) {
|
|
2175
|
+
const mode = this.getAgentRoomSubscription(agent, room.id)?.mode ?? 'mentions';
|
|
2176
|
+
if (mode !== 'off' && mode !== 'muted') candidates.add(agent);
|
|
2177
|
+
}
|
|
2178
|
+
for (const agent of extraAgents) {
|
|
2179
|
+
const normalized = agent.trim();
|
|
2180
|
+
if (normalized && this.getAgent(normalized)) candidates.add(normalized);
|
|
2181
|
+
}
|
|
2182
|
+
return [...candidates].sort((left, right) => left.localeCompare(right));
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
recordRoomSystemEvent(input: RecordRoomSystemEventInput): { event: RoomSystemEvent; message: Message; deliveries: MessageDelivery[] } {
|
|
2186
|
+
const room = this.getChatById(input.roomId);
|
|
2187
|
+
if (!room) throw new Error(`room ${input.roomId} was not found`);
|
|
2188
|
+
const actorKind = normalizeSystemEventActorKind(input.actorKind);
|
|
2189
|
+
const actorId = input.actorId?.trim() || null;
|
|
2190
|
+
const subjectAgent = input.subjectAgent?.trim() || null;
|
|
2191
|
+
const metadata = normalizeSystemEventMetadata(input.metadata);
|
|
2192
|
+
const content = input.content?.trim() || systemEventContent({
|
|
2193
|
+
eventType: input.eventType,
|
|
2194
|
+
actorKind,
|
|
2195
|
+
actorId,
|
|
2196
|
+
subjectAgent,
|
|
2197
|
+
metadata,
|
|
2198
|
+
});
|
|
2199
|
+
|
|
2200
|
+
return this.db.transaction(() => {
|
|
2201
|
+
const message = this.createMessage({
|
|
2202
|
+
chatId: room.id,
|
|
2203
|
+
sender: 'system',
|
|
2204
|
+
content,
|
|
2205
|
+
type: 'system',
|
|
2206
|
+
mentions: subjectAgent ? [subjectAgent] : [],
|
|
2207
|
+
});
|
|
2208
|
+
const id = crypto.randomUUID();
|
|
2209
|
+
this.db.query(`
|
|
2210
|
+
INSERT INTO room_system_events (id, room_id, message_id, event_type, actor_kind, actor_id, subject_agent, metadata_json, created_at)
|
|
2211
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
2212
|
+
`).run(id, room.id, message.id, input.eventType, actorKind, actorId, subjectAgent, JSON.stringify(metadata));
|
|
2213
|
+
|
|
2214
|
+
const deliveryAgents = input.deliveryAgents ?? this.systemEventDeliveryAgents(room);
|
|
2215
|
+
const deliveries: MessageDelivery[] = [];
|
|
2216
|
+
for (const agent of normalizeMentionsInput(deliveryAgents)) {
|
|
2217
|
+
if (agent === message.sender) continue;
|
|
2218
|
+
try {
|
|
2219
|
+
deliveries.push(this.createDelivery({ messageId: message.id, agent }));
|
|
2220
|
+
} catch {
|
|
2221
|
+
// Delivery eligibility is best-effort here; the event record and message remain the source of truth.
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
return { event: this.getRoomSystemEvent(id)!, message, deliveries };
|
|
2225
|
+
})();
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
getRoomSystemEvent(id: string): RoomSystemEvent | null {
|
|
2229
|
+
const row = this.db.query('SELECT * FROM room_system_events WHERE id = ?').get(id) as Record<string, unknown> | null;
|
|
2230
|
+
return row ? rowToRoomSystemEvent(row) : null;
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
getRoomSystemEventForMessage(messageId: number): RoomSystemEvent | null {
|
|
2234
|
+
const row = this.db.query('SELECT * FROM room_system_events WHERE message_id = ?').get(messageId) as Record<string, unknown> | null;
|
|
2235
|
+
return row ? rowToRoomSystemEvent(row) : null;
|
|
2236
|
+
}
|
|
2237
|
+
|
|
1015
2238
|
inviteAgentToRoom(input: { roomId: string; agent: string; mode?: AgentRoomSubscriptionMode }): { participant: RoomParticipant; subscription: AgentRoomSubscription } {
|
|
1016
2239
|
const room = this.getChatById(input.roomId);
|
|
1017
2240
|
if (!room) throw new Error(`room ${input.roomId} was not found`);
|
|
1018
2241
|
const agent = input.agent.trim();
|
|
1019
2242
|
if (!agent) throw new Error('agent is required');
|
|
1020
|
-
const
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
2243
|
+
const mode = input.mode ?? 'mentions';
|
|
2244
|
+
let result!: { participant: RoomParticipant; subscription: AgentRoomSubscription };
|
|
2245
|
+
this.db.transaction(() => {
|
|
2246
|
+
const existingParticipant = this.getRoomParticipant(room.id, agent);
|
|
2247
|
+
const existingSubscription = this.getAgentRoomSubscription(agent, room.id);
|
|
2248
|
+
const participant = this.upsertRoomParticipant({
|
|
2249
|
+
roomId: room.id,
|
|
2250
|
+
participantId: agent,
|
|
2251
|
+
kind: 'agent',
|
|
2252
|
+
displayName: this.getAgent(agent)?.display_name ?? agent,
|
|
2253
|
+
source: 'local_agent',
|
|
2254
|
+
});
|
|
2255
|
+
this.db.query(`
|
|
2256
|
+
UPDATE room_participants
|
|
2257
|
+
SET delivery_cursor_message_id = COALESCE(delivery_cursor_message_id, ?), updated_at = datetime('now')
|
|
2258
|
+
WHERE room_id = ? AND participant_id = ?
|
|
2259
|
+
`).run(this.getLatestMessageId(room.id), room.id, agent);
|
|
2260
|
+
const subscription = this.upsertAgentRoomSubscription({ agent, roomId: room.id, mode });
|
|
2261
|
+
if (existingParticipant?.status !== 'active') {
|
|
2262
|
+
this.recordRoomSystemEvent({
|
|
2263
|
+
roomId: room.id,
|
|
2264
|
+
eventType: 'agent_invited',
|
|
2265
|
+
subjectAgent: agent,
|
|
2266
|
+
metadata: { mode },
|
|
2267
|
+
deliveryAgents: [],
|
|
2268
|
+
});
|
|
2269
|
+
} else if (existingSubscription && existingSubscription.mode !== mode) {
|
|
2270
|
+
this.recordRoomSystemEvent({
|
|
2271
|
+
roomId: room.id,
|
|
2272
|
+
eventType: 'agent_receive_mode_changed',
|
|
2273
|
+
subjectAgent: agent,
|
|
2274
|
+
metadata: { old_mode: existingSubscription.mode, new_mode: mode },
|
|
2275
|
+
deliveryAgents: this.systemEventDeliveryAgents(room),
|
|
2276
|
+
});
|
|
2277
|
+
}
|
|
2278
|
+
result = { participant: this.getRoomParticipant(room.id, agent) ?? participant, subscription };
|
|
2279
|
+
})();
|
|
2280
|
+
return result;
|
|
1033
2281
|
}
|
|
1034
2282
|
|
|
1035
2283
|
upsertAgentRoomSubscription(input: { agent: string; roomId: string; mode: AgentRoomSubscriptionMode; channelId?: string | null }): AgentRoomSubscription {
|
|
@@ -1048,10 +2296,11 @@ export class MessageStore {
|
|
|
1048
2296
|
return this.getAgentRoomSubscription(agent, room.id)!;
|
|
1049
2297
|
}
|
|
1050
2298
|
const id = crypto.randomUUID();
|
|
2299
|
+
const latestMessageId = this.getLatestMessageId(room.id);
|
|
1051
2300
|
this.db.query(`
|
|
1052
|
-
INSERT INTO agent_room_subscriptions (id, agent, room_id, channel_id, mode, created_at, updated_at)
|
|
1053
|
-
VALUES (?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
|
1054
|
-
`).run(id, agent, room.id, channelId, input.mode);
|
|
2301
|
+
INSERT INTO agent_room_subscriptions (id, agent, room_id, channel_id, mode, last_read_message_id, created_at, updated_at)
|
|
2302
|
+
VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
|
2303
|
+
`).run(id, agent, room.id, channelId, input.mode, latestMessageId);
|
|
1055
2304
|
const row = this.db.query('SELECT * FROM agent_room_subscriptions WHERE id = ?').get(id) as Record<string, unknown>;
|
|
1056
2305
|
return rowToAgentRoomSubscription(row);
|
|
1057
2306
|
}
|
|
@@ -1065,6 +2314,208 @@ export class MessageStore {
|
|
|
1065
2314
|
return row ? rowToAgentRoomSubscription(row) : null;
|
|
1066
2315
|
}
|
|
1067
2316
|
|
|
2317
|
+
listAgentRoomSubscriptions(roomId: string): AgentRoomSubscription[] {
|
|
2318
|
+
const rows = this.db.query(`
|
|
2319
|
+
SELECT *
|
|
2320
|
+
FROM agent_room_subscriptions
|
|
2321
|
+
WHERE room_id = ? AND channel_id IS NULL
|
|
2322
|
+
ORDER BY agent ASC
|
|
2323
|
+
`).all(roomId) as Record<string, unknown>[];
|
|
2324
|
+
return rows.map(rowToAgentRoomSubscription);
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
private unreadCountForAgentRoom(agent: string, roomId: string, lastReadMessageId: number): number {
|
|
2328
|
+
const row = this.db.query(`
|
|
2329
|
+
SELECT COUNT(*) AS count
|
|
2330
|
+
FROM messages
|
|
2331
|
+
WHERE chat_id = ? AND id > ? AND sender != ?
|
|
2332
|
+
`).get(roomId, lastReadMessageId, agent.trim()) as { count: number };
|
|
2333
|
+
return Number(row.count ?? 0);
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
private taskCountsForRoom(roomId: string): { task_count: number; open_task_count: number } {
|
|
2337
|
+
const row = this.db.query(`
|
|
2338
|
+
SELECT
|
|
2339
|
+
COUNT(*) AS task_count,
|
|
2340
|
+
SUM(CASE WHEN status NOT IN ('done', 'closed') THEN 1 ELSE 0 END) AS open_task_count
|
|
2341
|
+
FROM room_tasks
|
|
2342
|
+
WHERE room_id = ?
|
|
2343
|
+
`).get(roomId) as { task_count: number; open_task_count: number | null };
|
|
2344
|
+
return {
|
|
2345
|
+
task_count: Number(row.task_count ?? 0),
|
|
2346
|
+
open_task_count: Number(row.open_task_count ?? 0),
|
|
2347
|
+
};
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
private scheduledReminderCountForAgentRoom(agent: string, roomId: string): number {
|
|
2351
|
+
const row = this.db.query(`
|
|
2352
|
+
SELECT COUNT(*) AS count
|
|
2353
|
+
FROM room_reminders
|
|
2354
|
+
WHERE room_id = ? AND created_by = ? AND status = 'scheduled'
|
|
2355
|
+
`).get(roomId, agent.trim()) as { count: number };
|
|
2356
|
+
return Number(row.count ?? 0);
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
private savedMessageCountForAgentRoom(agent: string, roomId: string): number {
|
|
2360
|
+
const row = this.db.query(`
|
|
2361
|
+
SELECT COUNT(*) AS count
|
|
2362
|
+
FROM room_saved_messages
|
|
2363
|
+
WHERE room_id = ? AND saved_by = ?
|
|
2364
|
+
`).get(roomId, agent.trim()) as { count: number };
|
|
2365
|
+
return Number(row.count ?? 0);
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
private buildAgentWorkbenchRoom(agentKey: string, row: Record<string, unknown>): AgentWorkbenchRoom {
|
|
2369
|
+
const key = agentKey.trim();
|
|
2370
|
+
const room = row as unknown as Chat;
|
|
2371
|
+
const participant = this.getRoomParticipant(room.id, key);
|
|
2372
|
+
const subscription = this.getAgentRoomSubscription(key, room.id);
|
|
2373
|
+
const latestMessageId = this.getLatestMessageId(room.id);
|
|
2374
|
+
const lastReadMessageId = subscription?.last_read_message_id ?? participant?.delivery_cursor_message_id ?? 0;
|
|
2375
|
+
const heldDraftCount = this.db.query(`
|
|
2376
|
+
SELECT COUNT(*) AS count
|
|
2377
|
+
FROM held_message_drafts
|
|
2378
|
+
WHERE agent = ? AND chat_id = ? AND status = 'held'
|
|
2379
|
+
`).get(key, room.id) as { count: number };
|
|
2380
|
+
const taskCounts = this.taskCountsForRoom(room.id);
|
|
2381
|
+
return {
|
|
2382
|
+
...room,
|
|
2383
|
+
participant,
|
|
2384
|
+
subscription,
|
|
2385
|
+
held_draft_count: Number(heldDraftCount.count ?? 0),
|
|
2386
|
+
latest_message_id: latestMessageId,
|
|
2387
|
+
last_read_message_id: lastReadMessageId,
|
|
2388
|
+
last_read_at: subscription?.last_read_at ?? null,
|
|
2389
|
+
unread_count: this.unreadCountForAgentRoom(key, room.id, lastReadMessageId),
|
|
2390
|
+
is_followed: subscription ? !['muted', 'off'].includes(subscription.mode) : false,
|
|
2391
|
+
task_count: taskCounts.task_count,
|
|
2392
|
+
open_task_count: taskCounts.open_task_count,
|
|
2393
|
+
scheduled_reminder_count: this.scheduledReminderCountForAgentRoom(key, room.id),
|
|
2394
|
+
saved_message_count: this.savedMessageCountForAgentRoom(key, room.id),
|
|
2395
|
+
};
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
getAgentWorkbenchRoom(agentKey: string, roomId: string): AgentWorkbenchRoom | null {
|
|
2399
|
+
const key = agentKey.trim();
|
|
2400
|
+
if (!key) throw new Error('agent is required');
|
|
2401
|
+
const row = this.db.query('SELECT * FROM chat_stats WHERE id = ?').get(roomId) as Record<string, unknown> | null;
|
|
2402
|
+
return row ? this.buildAgentWorkbenchRoom(key, row) : null;
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
listAgentWorkbenchRooms(agentKey: string): AgentWorkbenchRoom[] {
|
|
2406
|
+
const key = agentKey.trim();
|
|
2407
|
+
const rows = this.db.query(`
|
|
2408
|
+
SELECT c.*
|
|
2409
|
+
FROM room_participants rp
|
|
2410
|
+
JOIN chat_stats c ON c.id = rp.room_id
|
|
2411
|
+
WHERE rp.participant_id = ? AND rp.kind = 'agent' AND rp.status = 'active'
|
|
2412
|
+
ORDER BY COALESCE(c.display_name, c.name) ASC
|
|
2413
|
+
`).all(key) as Record<string, unknown>[];
|
|
2414
|
+
return rows.map((row) => this.buildAgentWorkbenchRoom(key, row));
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
updateAgentRoomSubscriptionMode(input: UpdateAgentRoomSubscriptionInput): { subscription: AgentRoomSubscription; room: AgentWorkbenchRoom } {
|
|
2418
|
+
const agent = input.agent.trim();
|
|
2419
|
+
if (!agent) throw new Error('agent is required');
|
|
2420
|
+
const room = this.getChatById(input.roomId);
|
|
2421
|
+
if (!room) throw new Error(`room ${input.roomId} was not found`);
|
|
2422
|
+
if (!this.getAgent(agent)) throw new Error(`agent ${agent} not found`);
|
|
2423
|
+
if (!this.canAgentParticipateInRoom(agent, room)) throw new Error('ROOM_ACCESS_DENIED');
|
|
2424
|
+
const existing = this.getAgentRoomSubscription(agent, room.id);
|
|
2425
|
+
const subscription = this.upsertAgentRoomSubscription({ agent, roomId: room.id, mode: input.mode });
|
|
2426
|
+
if ((existing?.mode ?? null) !== subscription.mode) {
|
|
2427
|
+
this.recordRoomSystemEvent({
|
|
2428
|
+
roomId: room.id,
|
|
2429
|
+
eventType: 'agent_receive_mode_changed',
|
|
2430
|
+
actorKind: input.actorKind,
|
|
2431
|
+
actorId: input.actorId,
|
|
2432
|
+
subjectAgent: agent,
|
|
2433
|
+
metadata: { old_mode: existing?.mode ?? null, new_mode: subscription.mode },
|
|
2434
|
+
deliveryAgents: this.systemEventDeliveryAgents(room),
|
|
2435
|
+
});
|
|
2436
|
+
}
|
|
2437
|
+
return { subscription, room: this.getAgentWorkbenchRoom(agent, room.id)! };
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
markAgentRoomRead(input: MarkAgentRoomReadInput): AgentWorkbenchRoom {
|
|
2441
|
+
const agent = input.agent.trim();
|
|
2442
|
+
if (!agent) throw new Error('agent is required');
|
|
2443
|
+
const room = this.getChatById(input.roomId);
|
|
2444
|
+
if (!room) throw new Error(`room ${input.roomId} was not found`);
|
|
2445
|
+
if (!this.getAgent(agent)) throw new Error(`agent ${agent} not found`);
|
|
2446
|
+
if (!this.canAgentParticipateInRoom(agent, room)) throw new Error('ROOM_ACCESS_DENIED');
|
|
2447
|
+
|
|
2448
|
+
const existing = this.getAgentRoomSubscription(agent, room.id)
|
|
2449
|
+
?? this.upsertAgentRoomSubscription({ agent, roomId: room.id, mode: 'mentions' });
|
|
2450
|
+
const latestMessageId = this.getLatestMessageId(room.id);
|
|
2451
|
+
const requestedMessageId = input.messageId === undefined ? latestMessageId : Number(input.messageId);
|
|
2452
|
+
if (!Number.isInteger(requestedMessageId) || requestedMessageId < 0) {
|
|
2453
|
+
throw new Error('INVALID_READ_MESSAGE_ID');
|
|
2454
|
+
}
|
|
2455
|
+
const nextReadMessageId = Math.max(existing.last_read_message_id, Math.min(requestedMessageId, latestMessageId));
|
|
2456
|
+
this.db.query(`
|
|
2457
|
+
UPDATE agent_room_subscriptions
|
|
2458
|
+
SET last_read_message_id = ?, last_read_at = datetime('now'), updated_at = datetime('now')
|
|
2459
|
+
WHERE id = ?
|
|
2460
|
+
`).run(nextReadMessageId, existing.id);
|
|
2461
|
+
return this.getAgentWorkbenchRoom(agent, room.id)!;
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
leaveAgentRoom(input: { roomId: string; agent: string }): { room: Chat; agent: string; participant_removed: boolean; subscriptions_removed: number; active_deliveries_canceled: number } {
|
|
2465
|
+
const room = this.getChatById(input.roomId);
|
|
2466
|
+
if (!room) throw new Error(`room ${input.roomId} was not found`);
|
|
2467
|
+
const agent = input.agent.trim();
|
|
2468
|
+
if (!agent) throw new Error('agent is required');
|
|
2469
|
+
if (!this.getAgent(agent)) throw new Error(`agent ${agent} not found`);
|
|
2470
|
+
|
|
2471
|
+
let participantRemoved = false;
|
|
2472
|
+
let subscriptionsRemoved = 0;
|
|
2473
|
+
let activeDeliveriesCanceled = 0;
|
|
2474
|
+
let removalMessageId = 0;
|
|
2475
|
+
|
|
2476
|
+
this.db.transaction(() => {
|
|
2477
|
+
const existingParticipant = this.getRoomParticipant(room.id, agent);
|
|
2478
|
+
if (existingParticipant?.status === 'active') {
|
|
2479
|
+
const eventResult = this.recordRoomSystemEvent({
|
|
2480
|
+
roomId: room.id,
|
|
2481
|
+
eventType: 'agent_removed',
|
|
2482
|
+
subjectAgent: agent,
|
|
2483
|
+
deliveryAgents: this.systemEventDeliveryAgents(room, [agent]),
|
|
2484
|
+
});
|
|
2485
|
+
removalMessageId = eventResult.message.id;
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
const participantResult = this.db.query(`
|
|
2489
|
+
UPDATE room_participants
|
|
2490
|
+
SET status = 'removed', updated_at = datetime('now')
|
|
2491
|
+
WHERE room_id = ? AND participant_id = ? AND kind = 'agent' AND status = 'active'
|
|
2492
|
+
`).run(room.id, agent);
|
|
2493
|
+
participantRemoved = participantResult.changes > 0;
|
|
2494
|
+
|
|
2495
|
+
const subscriptionResult = this.db.query(`
|
|
2496
|
+
DELETE FROM agent_room_subscriptions
|
|
2497
|
+
WHERE room_id = ? AND agent = ?
|
|
2498
|
+
`).run(room.id, agent);
|
|
2499
|
+
subscriptionsRemoved = subscriptionResult.changes;
|
|
2500
|
+
|
|
2501
|
+
const deliveryResult = this.db.query(`
|
|
2502
|
+
UPDATE message_deliveries
|
|
2503
|
+
SET status = 'canceled', last_error = CASE WHEN last_error = '' THEN 'agent left room' ELSE last_error END
|
|
2504
|
+
WHERE chat_id = ? AND agent = ? AND status IN ('pending', 'claimed', 'processing_completed')
|
|
2505
|
+
AND message_id != ?
|
|
2506
|
+
`).run(room.id, agent, removalMessageId);
|
|
2507
|
+
activeDeliveriesCanceled = deliveryResult.changes;
|
|
2508
|
+
})();
|
|
2509
|
+
|
|
2510
|
+
return {
|
|
2511
|
+
room,
|
|
2512
|
+
agent,
|
|
2513
|
+
participant_removed: participantRemoved,
|
|
2514
|
+
subscriptions_removed: subscriptionsRemoved,
|
|
2515
|
+
active_deliveries_canceled: activeDeliveriesCanceled,
|
|
2516
|
+
};
|
|
2517
|
+
}
|
|
2518
|
+
|
|
1068
2519
|
createRoomChannel(input: { roomId: string; name: string; createdBy?: string | null; externalRef?: string | null }): RoomChannel {
|
|
1069
2520
|
const room = this.getChatById(input.roomId);
|
|
1070
2521
|
if (!room) throw new Error(`room ${input.roomId} was not found`);
|
|
@@ -1085,7 +2536,7 @@ export class MessageStore {
|
|
|
1085
2536
|
|
|
1086
2537
|
listAgents(limit = 50): AgentDefinition[] {
|
|
1087
2538
|
const rows = this.db.query(`
|
|
1088
|
-
SELECT id, agent_key, display_name, description, runtime, created_at, updated_at
|
|
2539
|
+
SELECT id, agent_key, display_name, description, runtime, model, avatar, created_at, updated_at
|
|
1089
2540
|
FROM agents
|
|
1090
2541
|
ORDER BY agent_key ASC
|
|
1091
2542
|
LIMIT ?
|
|
@@ -1097,7 +2548,7 @@ export class MessageStore {
|
|
|
1097
2548
|
const key = identifier.trim();
|
|
1098
2549
|
if (!key) return null;
|
|
1099
2550
|
const row = this.db.query(`
|
|
1100
|
-
SELECT id, agent_key, display_name, description, runtime, created_at, updated_at
|
|
2551
|
+
SELECT id, agent_key, display_name, description, runtime, model, avatar, created_at, updated_at
|
|
1101
2552
|
FROM agents
|
|
1102
2553
|
WHERE id = ? OR agent_key = ?
|
|
1103
2554
|
LIMIT 1
|
|
@@ -1112,39 +2563,316 @@ export class MessageStore {
|
|
|
1112
2563
|
return row?.runtime ?? null;
|
|
1113
2564
|
}
|
|
1114
2565
|
|
|
1115
|
-
|
|
2566
|
+
getAgentPermissionProfile(agentKey: string): AgentPermissionProfile {
|
|
2567
|
+
const key = agentKey.trim();
|
|
2568
|
+
if (!key) throw new Error('agent_key is required');
|
|
2569
|
+
if (!this.getAgent(key)) throw new Error(`agent ${key} not found`);
|
|
2570
|
+
const row = this.db.query(`
|
|
2571
|
+
SELECT * FROM agent_permission_profiles WHERE agent_key = ? LIMIT 1
|
|
2572
|
+
`).get(key) as Record<string, unknown> | null;
|
|
2573
|
+
if (row) return rowToAgentPermissionProfile(row);
|
|
2574
|
+
const now = new Date().toISOString();
|
|
2575
|
+
return {
|
|
2576
|
+
agent_key: key,
|
|
2577
|
+
filesystem_mode: defaultAgentPermissionProfile.filesystemMode,
|
|
2578
|
+
extra_writable_roots: [...defaultAgentPermissionProfile.extraWritableRoots],
|
|
2579
|
+
created_at: now,
|
|
2580
|
+
updated_at: now,
|
|
2581
|
+
};
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
upsertAgentPermissionProfile(agentKey: string, input: RuntimeAgentPermissionProfile): AgentPermissionProfile {
|
|
2585
|
+
const key = agentKey.trim();
|
|
2586
|
+
if (!key) throw new Error('agent_key is required');
|
|
2587
|
+
if (!this.getAgent(key)) throw new Error(`agent ${key} not found`);
|
|
2588
|
+
const validation = validateAgentPermissionProfile(input);
|
|
2589
|
+
if (!validation.ok) {
|
|
2590
|
+
throw new Error(validation.diagnostics.filter((diagnostic) => diagnostic.level === 'error').map((diagnostic) => diagnostic.message).join('; '));
|
|
2591
|
+
}
|
|
2592
|
+
this.db.query(`
|
|
2593
|
+
INSERT INTO agent_permission_profiles (agent_key, filesystem_mode, extra_writable_roots_json, created_at, updated_at)
|
|
2594
|
+
VALUES (?, ?, ?, datetime('now'), datetime('now'))
|
|
2595
|
+
ON CONFLICT(agent_key) DO UPDATE SET
|
|
2596
|
+
filesystem_mode = excluded.filesystem_mode,
|
|
2597
|
+
extra_writable_roots_json = excluded.extra_writable_roots_json,
|
|
2598
|
+
updated_at = datetime('now')
|
|
2599
|
+
`).run(key, validation.profile.filesystemMode, JSON.stringify(validation.profile.extraWritableRoots));
|
|
2600
|
+
return this.getAgentPermissionProfile(key);
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
upsertAgent(input: { id?: string; agent_key: string; display_name: string; description?: string | null; runtime?: string | null; model?: string | null; avatar?: string | null }): AgentDefinition {
|
|
1116
2604
|
const id = input.id?.trim() || crypto.randomUUID();
|
|
1117
2605
|
const agentKey = input.agent_key.trim();
|
|
1118
2606
|
const displayName = input.display_name.trim();
|
|
1119
2607
|
if (!agentKey) throw new Error('agent_key is required');
|
|
1120
2608
|
if (!displayName) throw new Error('display_name is required');
|
|
1121
2609
|
|
|
2610
|
+
const avatar = normalizeAgentAvatar(input.avatar, agentKey, displayName);
|
|
2611
|
+
|
|
1122
2612
|
this.db.query(`
|
|
1123
|
-
INSERT INTO agents (id, agent_key, display_name, description, runtime, created_at, updated_at)
|
|
1124
|
-
VALUES (?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
|
2613
|
+
INSERT INTO agents (id, agent_key, display_name, description, runtime, model, avatar, created_at, updated_at)
|
|
2614
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
|
1125
2615
|
ON CONFLICT(agent_key) DO UPDATE SET
|
|
1126
2616
|
display_name = excluded.display_name,
|
|
1127
2617
|
description = COALESCE(excluded.description, agents.description),
|
|
1128
2618
|
runtime = COALESCE(excluded.runtime, agents.runtime),
|
|
2619
|
+
model = COALESCE(excluded.model, agents.model),
|
|
2620
|
+
avatar = COALESCE(NULLIF(agents.avatar, ''), excluded.avatar),
|
|
1129
2621
|
updated_at = datetime('now')
|
|
1130
|
-
`).run(id, agentKey, displayName, input.description ?? null, input.runtime ?? null);
|
|
2622
|
+
`).run(id, agentKey, displayName, input.description ?? null, input.runtime ?? null, input.model ?? null, avatar);
|
|
1131
2623
|
|
|
1132
2624
|
return this.getAgent(agentKey)!;
|
|
1133
2625
|
}
|
|
1134
2626
|
|
|
1135
2627
|
updateAgentRuntime(agentKey: string, runtime: string | null): AgentDefinition | null {
|
|
2628
|
+
return this.updateAgentRuntimeConfig(agentKey, { runtime });
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
updateAgentRuntimeConfig(agentKey: string, input: { runtime?: string | null; model?: string | null }): AgentDefinition | null {
|
|
1136
2632
|
const key = agentKey.trim();
|
|
1137
2633
|
if (!key) throw new Error('agent_key is required');
|
|
1138
2634
|
const existing = this.getAgent(key);
|
|
1139
2635
|
if (!existing) return null;
|
|
1140
2636
|
|
|
2637
|
+
const runtime = Object.prototype.hasOwnProperty.call(input, 'runtime') ? input.runtime ?? null : existing.runtime;
|
|
2638
|
+
const model = Object.prototype.hasOwnProperty.call(input, 'model') ? input.model ?? null : existing.model;
|
|
1141
2639
|
this.db.query(`
|
|
1142
|
-
UPDATE agents SET runtime = ?, updated_at = datetime('now') WHERE agent_key = ?
|
|
1143
|
-
`).run(runtime, key);
|
|
2640
|
+
UPDATE agents SET runtime = ?, model = ?, updated_at = datetime('now') WHERE agent_key = ?
|
|
2641
|
+
`).run(runtime, model, key);
|
|
1144
2642
|
|
|
1145
2643
|
return this.getAgent(key);
|
|
1146
2644
|
}
|
|
1147
2645
|
|
|
2646
|
+
listAgentWorkbenchLarkBindings(agentKey: string): AgentWorkbenchLarkBinding[] {
|
|
2647
|
+
const key = agentKey.trim();
|
|
2648
|
+
const rows = this.db.query(`
|
|
2649
|
+
SELECT 'provider_account' AS source,
|
|
2650
|
+
external_account_id AS app_id,
|
|
2651
|
+
NULL AS label,
|
|
2652
|
+
bot_external_user_id AS bot_open_id,
|
|
2653
|
+
status
|
|
2654
|
+
FROM provider_accounts
|
|
2655
|
+
WHERE provider = 'lark' AND agent = ?
|
|
2656
|
+
UNION ALL
|
|
2657
|
+
SELECT 'channel_account' AS source,
|
|
2658
|
+
app_id,
|
|
2659
|
+
name AS label,
|
|
2660
|
+
bot_open_id,
|
|
2661
|
+
status
|
|
2662
|
+
FROM channel_accounts
|
|
2663
|
+
WHERE channel = 'lark' AND agent = ?
|
|
2664
|
+
ORDER BY app_id ASC, source ASC
|
|
2665
|
+
`).all(key, key) as Array<Record<string, unknown>>;
|
|
2666
|
+
return rows.map((row): AgentWorkbenchLarkBinding => ({
|
|
2667
|
+
source: String(row.source) as AgentWorkbenchLarkBinding['source'],
|
|
2668
|
+
app_id: String(row.app_id),
|
|
2669
|
+
label: row.label === null || row.label === undefined ? null : String(row.label),
|
|
2670
|
+
bot_open_id: row.bot_open_id === null || row.bot_open_id === undefined ? null : String(row.bot_open_id),
|
|
2671
|
+
status: String(row.status) as AgentWorkbenchLarkBinding['status'],
|
|
2672
|
+
}));
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
getAgentWorkbenchCollaboration(agentKey: string, limit = 20): AgentWorkbenchCollaboration {
|
|
2676
|
+
const key = agentKey.trim();
|
|
2677
|
+
if (!key) throw new Error('agent_key is required');
|
|
2678
|
+
const cappedLimit = limitValue(limit, 20);
|
|
2679
|
+
const taskRows = this.db.query(`
|
|
2680
|
+
SELECT rt.*, c.name AS room_name
|
|
2681
|
+
FROM room_tasks rt
|
|
2682
|
+
JOIN chats c ON c.id = rt.room_id
|
|
2683
|
+
JOIN room_participants rp ON rp.room_id = rt.room_id
|
|
2684
|
+
WHERE rp.participant_id = ?
|
|
2685
|
+
AND rp.kind = 'agent'
|
|
2686
|
+
AND rp.status = 'active'
|
|
2687
|
+
AND rt.status NOT IN ('done', 'closed')
|
|
2688
|
+
AND (rt.assignee = ? OR rt.created_by = ?)
|
|
2689
|
+
ORDER BY CASE WHEN rt.assignee = ? THEN 0 ELSE 1 END, rt.updated_at DESC, rt.task_number ASC
|
|
2690
|
+
LIMIT ?
|
|
2691
|
+
`).all(key, key, key, key, cappedLimit) as Record<string, unknown>[];
|
|
2692
|
+
const reminderRows = this.db.query(`
|
|
2693
|
+
SELECT rr.*, c.name AS room_name
|
|
2694
|
+
FROM room_reminders rr
|
|
2695
|
+
JOIN chats c ON c.id = rr.room_id
|
|
2696
|
+
JOIN room_participants rp ON rp.room_id = rr.room_id
|
|
2697
|
+
WHERE rp.participant_id = ?
|
|
2698
|
+
AND rp.kind = 'agent'
|
|
2699
|
+
AND rp.status = 'active'
|
|
2700
|
+
AND rr.created_by = ?
|
|
2701
|
+
AND rr.status = 'scheduled'
|
|
2702
|
+
ORDER BY rr.fire_at ASC, rr.created_at ASC
|
|
2703
|
+
LIMIT ?
|
|
2704
|
+
`).all(key, key, cappedLimit) as Record<string, unknown>[];
|
|
2705
|
+
const savedRows = this.db.query(`
|
|
2706
|
+
SELECT
|
|
2707
|
+
rsm.*,
|
|
2708
|
+
c.name AS room_name,
|
|
2709
|
+
m.sender AS message_sender,
|
|
2710
|
+
m.content AS message_content,
|
|
2711
|
+
m.created_at AS message_created_at
|
|
2712
|
+
FROM room_saved_messages rsm
|
|
2713
|
+
JOIN chats c ON c.id = rsm.room_id
|
|
2714
|
+
JOIN messages m ON m.id = rsm.message_id
|
|
2715
|
+
JOIN room_participants rp ON rp.room_id = rsm.room_id
|
|
2716
|
+
WHERE rp.participant_id = ?
|
|
2717
|
+
AND rp.kind = 'agent'
|
|
2718
|
+
AND rp.status = 'active'
|
|
2719
|
+
AND rsm.saved_by = ?
|
|
2720
|
+
ORDER BY rsm.updated_at DESC, rsm.created_at DESC
|
|
2721
|
+
LIMIT ?
|
|
2722
|
+
`).all(key, key, cappedLimit) as Record<string, unknown>[];
|
|
2723
|
+
|
|
2724
|
+
return {
|
|
2725
|
+
tasks: taskRows.map(rowToRoomTask),
|
|
2726
|
+
reminders: reminderRows.map(rowToRoomReminder),
|
|
2727
|
+
saved_messages: savedRows.map(rowToRoomSavedMessage),
|
|
2728
|
+
};
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
getAgentWorkbench(agentKey: string): AgentWorkbench | null {
|
|
2732
|
+
const key = agentKey.trim();
|
|
2733
|
+
if (!key) throw new Error('agent_key is required');
|
|
2734
|
+
const agent = this.getAgent(key);
|
|
2735
|
+
if (!agent) return null;
|
|
2736
|
+
const assignment = this.getComputerAgentAssignment(agent.agent_key);
|
|
2737
|
+
return {
|
|
2738
|
+
agent,
|
|
2739
|
+
permissions: this.getAgentPermissionProfile(agent.agent_key),
|
|
2740
|
+
assignment,
|
|
2741
|
+
machine: assignment ? this.getComputer(assignment.computer_id) : null,
|
|
2742
|
+
project: this.getWorkbenchProjectForAgent(agent.agent_key, assignment),
|
|
2743
|
+
rooms: this.listAgentWorkbenchRooms(agent.agent_key),
|
|
2744
|
+
inbox: this.listInbox(agent.agent_key, 0, 20),
|
|
2745
|
+
deliveries: this.listRecentDeliveriesForAgent(agent.agent_key, 20),
|
|
2746
|
+
runs: this.listRunsForAgent(agent.agent_key, 10),
|
|
2747
|
+
activity: this.listActivityForAgent(agent.agent_key, 80),
|
|
2748
|
+
sessions: this.listSessionsForAgent(agent.agent_key, 10),
|
|
2749
|
+
held_drafts: this.listHeldMessageDrafts(agent.agent_key, 'held', 20),
|
|
2750
|
+
collaboration: this.getAgentWorkbenchCollaboration(agent.agent_key),
|
|
2751
|
+
lark_bindings: this.listAgentWorkbenchLarkBindings(agent.agent_key),
|
|
2752
|
+
};
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
getAgentDeletionImpact(agentKey: string): AgentDeletionImpact | null {
|
|
2756
|
+
const key = agentKey.trim();
|
|
2757
|
+
if (!key) throw new Error('agent_key is required');
|
|
2758
|
+
const agent = this.getAgent(key);
|
|
2759
|
+
if (!agent) return null;
|
|
2760
|
+
const rooms = this.db.query(`
|
|
2761
|
+
SELECT c.id, c.name, c.display_name, c.kind, c.provider, c.status
|
|
2762
|
+
FROM room_participants rp
|
|
2763
|
+
INNER JOIN chat_stats c ON c.id = rp.room_id
|
|
2764
|
+
WHERE rp.participant_id = ? AND rp.kind = 'agent' AND rp.status = 'active'
|
|
2765
|
+
ORDER BY COALESCE(c.display_name, c.name) ASC
|
|
2766
|
+
`).all(key) as Array<Record<string, unknown>>;
|
|
2767
|
+
const providerBindings = this.db.query(`
|
|
2768
|
+
SELECT 'provider_account' AS source, external_account_id AS app_id, NULL AS label
|
|
2769
|
+
FROM provider_accounts
|
|
2770
|
+
WHERE provider = 'lark' AND agent = ? AND status = 'active'
|
|
2771
|
+
UNION ALL
|
|
2772
|
+
SELECT 'channel_account' AS source, app_id, name AS label
|
|
2773
|
+
FROM channel_accounts
|
|
2774
|
+
WHERE channel = 'lark' AND agent = ? AND status = 'active'
|
|
2775
|
+
ORDER BY app_id ASC, source ASC
|
|
2776
|
+
`).all(key, key) as Array<Record<string, unknown>>;
|
|
2777
|
+
return {
|
|
2778
|
+
agent,
|
|
2779
|
+
rooms: rooms.map((row): AgentDeletionRoomImpact => ({
|
|
2780
|
+
id: String(row.id),
|
|
2781
|
+
name: String(row.name),
|
|
2782
|
+
display_name: row.display_name === null || row.display_name === undefined ? null : String(row.display_name),
|
|
2783
|
+
kind: String(row.kind) as ChatKind,
|
|
2784
|
+
provider: String(row.provider) as RoomProvider,
|
|
2785
|
+
status: String(row.status) as ChatStatus,
|
|
2786
|
+
})),
|
|
2787
|
+
lark_bindings: providerBindings.map((row): AgentDeletionLarkBindingImpact => ({
|
|
2788
|
+
source: String(row.source) as AgentDeletionLarkBindingImpact['source'],
|
|
2789
|
+
app_id: String(row.app_id),
|
|
2790
|
+
label: row.label === null || row.label === undefined ? null : String(row.label),
|
|
2791
|
+
})),
|
|
2792
|
+
};
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
deleteAgent(agentKey: string): AgentDeletionResult | null {
|
|
2796
|
+
const key = agentKey.trim();
|
|
2797
|
+
if (!key) throw new Error('agent_key is required');
|
|
2798
|
+
const existing = this.getAgent(key);
|
|
2799
|
+
if (!existing) return null;
|
|
2800
|
+
|
|
2801
|
+
const result: AgentDeletionResult = {
|
|
2802
|
+
agent: existing,
|
|
2803
|
+
rooms_left: 0,
|
|
2804
|
+
subscriptions_removed: 0,
|
|
2805
|
+
assignments_removed: 0,
|
|
2806
|
+
daemon_registrations_removed: 0,
|
|
2807
|
+
provider_accounts_disabled: 0,
|
|
2808
|
+
channel_accounts_disabled: 0,
|
|
2809
|
+
active_deliveries_canceled: 0,
|
|
2810
|
+
running_runs_marked_kill: 0,
|
|
2811
|
+
};
|
|
2812
|
+
|
|
2813
|
+
this.db.transaction(() => {
|
|
2814
|
+
const roomsResult = this.db.query(`
|
|
2815
|
+
UPDATE room_participants
|
|
2816
|
+
SET status = 'removed', updated_at = datetime('now')
|
|
2817
|
+
WHERE participant_id = ? AND kind = 'agent' AND status = 'active'
|
|
2818
|
+
`).run(key);
|
|
2819
|
+
result.rooms_left = roomsResult.changes;
|
|
2820
|
+
|
|
2821
|
+
const subscriptionsResult = this.db.query(`
|
|
2822
|
+
DELETE FROM agent_room_subscriptions
|
|
2823
|
+
WHERE agent = ?
|
|
2824
|
+
`).run(key);
|
|
2825
|
+
result.subscriptions_removed = subscriptionsResult.changes;
|
|
2826
|
+
|
|
2827
|
+
const assignmentsResult = this.db.query(`
|
|
2828
|
+
DELETE FROM computer_agent_assignments
|
|
2829
|
+
WHERE agent = ?
|
|
2830
|
+
`).run(key);
|
|
2831
|
+
result.assignments_removed = assignmentsResult.changes;
|
|
2832
|
+
|
|
2833
|
+
const daemonResult = this.db.query(`
|
|
2834
|
+
DELETE FROM daemon_agents
|
|
2835
|
+
WHERE agent = ?
|
|
2836
|
+
`).run(key);
|
|
2837
|
+
result.daemon_registrations_removed = daemonResult.changes;
|
|
2838
|
+
|
|
2839
|
+
const providerResult = this.db.query(`
|
|
2840
|
+
UPDATE provider_accounts
|
|
2841
|
+
SET status = 'disabled', updated_at = datetime('now')
|
|
2842
|
+
WHERE agent = ? AND status = 'active'
|
|
2843
|
+
`).run(key);
|
|
2844
|
+
result.provider_accounts_disabled = providerResult.changes;
|
|
2845
|
+
|
|
2846
|
+
const channelResult = this.db.query(`
|
|
2847
|
+
UPDATE channel_accounts
|
|
2848
|
+
SET status = 'disabled', updated_at = datetime('now')
|
|
2849
|
+
WHERE agent = ? AND status = 'active'
|
|
2850
|
+
`).run(key);
|
|
2851
|
+
result.channel_accounts_disabled = channelResult.changes;
|
|
2852
|
+
|
|
2853
|
+
const deliveryResult = this.db.query(`
|
|
2854
|
+
UPDATE message_deliveries
|
|
2855
|
+
SET status = 'canceled', last_error = CASE WHEN last_error = '' THEN 'agent deleted' ELSE last_error END
|
|
2856
|
+
WHERE agent = ? AND status IN ('pending', 'claimed', 'processing_completed')
|
|
2857
|
+
`).run(key);
|
|
2858
|
+
result.active_deliveries_canceled = deliveryResult.changes;
|
|
2859
|
+
|
|
2860
|
+
const runResult = this.db.query(`
|
|
2861
|
+
UPDATE agent_runs
|
|
2862
|
+
SET action = 'kill', updated_at = datetime('now')
|
|
2863
|
+
WHERE agent = ? AND status = 'running'
|
|
2864
|
+
`).run(key);
|
|
2865
|
+
result.running_runs_marked_kill = runResult.changes;
|
|
2866
|
+
|
|
2867
|
+
this.db.query(`
|
|
2868
|
+
DELETE FROM agents
|
|
2869
|
+
WHERE agent_key = ?
|
|
2870
|
+
`).run(key);
|
|
2871
|
+
})();
|
|
2872
|
+
|
|
2873
|
+
return result;
|
|
2874
|
+
}
|
|
2875
|
+
|
|
1148
2876
|
assignAgentToComputer(input: { agent: string; computerId: string }): ComputerAgentAssignment {
|
|
1149
2877
|
const agent = input.agent.trim();
|
|
1150
2878
|
const computerId = input.computerId.trim();
|
|
@@ -1166,9 +2894,19 @@ export class MessageStore {
|
|
|
1166
2894
|
return this.getComputerAgentAssignment(agent)!;
|
|
1167
2895
|
}
|
|
1168
2896
|
|
|
2897
|
+
unassignAgentFromComputer(agent: string): boolean {
|
|
2898
|
+
const key = agent.trim();
|
|
2899
|
+
if (!key) throw new Error('agent is required');
|
|
2900
|
+
const result = this.db.query(`
|
|
2901
|
+
DELETE FROM computer_agent_assignments
|
|
2902
|
+
WHERE agent = ?
|
|
2903
|
+
`).run(key);
|
|
2904
|
+
return result.changes > 0;
|
|
2905
|
+
}
|
|
2906
|
+
|
|
1169
2907
|
getComputerAgentAssignment(agent: string): ComputerAgentAssignment | null {
|
|
1170
2908
|
const row = this.db.query(`
|
|
1171
|
-
SELECT caa.*, a.display_name, a.runtime
|
|
2909
|
+
SELECT caa.*, a.display_name, a.runtime, a.model
|
|
1172
2910
|
FROM computer_agent_assignments caa
|
|
1173
2911
|
JOIN agents a ON a.agent_key = caa.agent
|
|
1174
2912
|
WHERE caa.agent = ?
|
|
@@ -1179,7 +2917,7 @@ export class MessageStore {
|
|
|
1179
2917
|
|
|
1180
2918
|
listComputerAgentAssignments(computerId: string): ComputerAgentAssignment[] {
|
|
1181
2919
|
const rows = this.db.query(`
|
|
1182
|
-
SELECT caa.*, a.display_name, a.runtime
|
|
2920
|
+
SELECT caa.*, a.display_name, a.runtime, a.model
|
|
1183
2921
|
FROM computer_agent_assignments caa
|
|
1184
2922
|
JOIN agents a ON a.agent_key = caa.agent
|
|
1185
2923
|
WHERE caa.computer_id = ? AND caa.status = 'active'
|
|
@@ -1220,6 +2958,10 @@ export class MessageStore {
|
|
|
1220
2958
|
diagnostics.push({ level: 'error', code: 'INVALID_FIELD', message: 'runtime must be a string or null' });
|
|
1221
2959
|
}
|
|
1222
2960
|
|
|
2961
|
+
if ('model' in input && input.model !== null && typeof input.model !== 'string') {
|
|
2962
|
+
diagnostics.push({ level: 'error', code: 'INVALID_FIELD', message: 'model must be a string or null' });
|
|
2963
|
+
}
|
|
2964
|
+
|
|
1223
2965
|
for (const key of ['created_at', 'updated_at']) {
|
|
1224
2966
|
if (key in input && typeof input[key] !== 'string') {
|
|
1225
2967
|
diagnostics.push({ level: 'error', code: 'INVALID_FIELD', message: `${key} must be a string when provided` });
|
|
@@ -2028,9 +3770,13 @@ export class MessageStore {
|
|
|
2028
3770
|
|
|
2029
3771
|
createMessage(input: CreateMessageInput): Message {
|
|
2030
3772
|
const sender = input.sender.trim();
|
|
2031
|
-
const
|
|
3773
|
+
const attachments = input.attachments ?? [];
|
|
3774
|
+
const content = input.content.trim() || (attachments.length > 0 ? (attachments.some((attachment) => attachment.kind === 'image') ? '[image]' : '[file]') : '');
|
|
2032
3775
|
if (!sender) throw new Error('sender is required');
|
|
2033
3776
|
if (!content) throw new Error('content is required');
|
|
3777
|
+
for (const attachment of attachments) {
|
|
3778
|
+
validateMessageAttachment({ kind: attachment.kind, mimeType: attachment.mimeType, content: attachment.content });
|
|
3779
|
+
}
|
|
2034
3780
|
|
|
2035
3781
|
let chat: Chat | null = null;
|
|
2036
3782
|
let parentId: number | null = null;
|
|
@@ -2061,7 +3807,7 @@ export class MessageStore {
|
|
|
2061
3807
|
JOIN chats c ON c.id = m.chat_id
|
|
2062
3808
|
WHERE m.idempotency_key = ?
|
|
2063
3809
|
`).get(idempotencyKey) as Record<string, unknown> | null;
|
|
2064
|
-
if (existing) return rowToMessage(existing)
|
|
3810
|
+
if (existing) return this.attachMessageAttachments([rowToMessage(existing)])[0]!;
|
|
2065
3811
|
}
|
|
2066
3812
|
|
|
2067
3813
|
const mentions = normalizeMentionsInput(input.mentions);
|
|
@@ -2072,79 +3818,863 @@ export class MessageStore {
|
|
|
2072
3818
|
if (channel.room_id !== chat.id) throw new Error(`channel ${channelId} does not belong to room ${chat.id}`);
|
|
2073
3819
|
}
|
|
2074
3820
|
const provider = input.provider ?? chat.provider ?? 'web';
|
|
2075
|
-
const
|
|
2076
|
-
|
|
2077
|
-
|
|
3821
|
+
const id = this.db.transaction(() => {
|
|
3822
|
+
const result = this.db.query(`
|
|
3823
|
+
INSERT INTO messages (chat_id, parent_id, depth, sender, recipient, content, type, idempotency_key, channel_id, provider, mentions_json)
|
|
3824
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
3825
|
+
`).run(
|
|
3826
|
+
chat.id,
|
|
3827
|
+
parentId,
|
|
3828
|
+
depth,
|
|
3829
|
+
sender,
|
|
3830
|
+
input.recipient?.trim() || null,
|
|
3831
|
+
content,
|
|
3832
|
+
input.type ?? 'message',
|
|
3833
|
+
idempotencyKey,
|
|
3834
|
+
channelId,
|
|
3835
|
+
provider,
|
|
3836
|
+
JSON.stringify(mentions),
|
|
3837
|
+
);
|
|
3838
|
+
const messageId = Number(result.lastInsertRowid);
|
|
3839
|
+
for (const attachment of attachments) {
|
|
3840
|
+
const attachmentId = crypto.randomUUID();
|
|
3841
|
+
const stored = storeMessageAttachmentFile({
|
|
3842
|
+
palHome: this.palHome,
|
|
3843
|
+
roomId: chat.id,
|
|
3844
|
+
messageId,
|
|
3845
|
+
attachmentId,
|
|
3846
|
+
filename: attachment.filename,
|
|
3847
|
+
mimeType: attachment.mimeType,
|
|
3848
|
+
content: attachment.content,
|
|
3849
|
+
});
|
|
3850
|
+
this.db.query(`
|
|
3851
|
+
INSERT INTO message_attachments (id, message_id, kind, mime_type, filename, size_bytes, path, source_provider, source_ref)
|
|
3852
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
3853
|
+
`).run(
|
|
3854
|
+
attachmentId,
|
|
3855
|
+
messageId,
|
|
3856
|
+
attachment.kind,
|
|
3857
|
+
attachment.mimeType,
|
|
3858
|
+
stored.filename,
|
|
3859
|
+
attachment.content.length,
|
|
3860
|
+
stored.path,
|
|
3861
|
+
attachment.sourceProvider ?? provider,
|
|
3862
|
+
attachment.sourceRef?.trim() || null,
|
|
3863
|
+
);
|
|
3864
|
+
}
|
|
3865
|
+
return messageId;
|
|
3866
|
+
})();
|
|
3867
|
+
return this.getMessage(id)!;
|
|
3868
|
+
}
|
|
3869
|
+
|
|
3870
|
+
private attachMessageAttachments(messages: Message[]): Message[] {
|
|
3871
|
+
if (messages.length === 0) return messages;
|
|
3872
|
+
const ids = messages.map((message) => message.id);
|
|
3873
|
+
const placeholders = ids.map(() => '?').join(', ');
|
|
3874
|
+
const rows = this.db.query(`
|
|
3875
|
+
SELECT id, message_id, kind, mime_type, filename, size_bytes, path, source_provider, created_at
|
|
3876
|
+
FROM message_attachments
|
|
3877
|
+
WHERE message_id IN (${placeholders})
|
|
3878
|
+
ORDER BY message_id ASC, id ASC
|
|
3879
|
+
`).all(...ids) as Record<string, unknown>[];
|
|
3880
|
+
const byMessage = new Map<number, MessageAttachmentMetadata[]>();
|
|
3881
|
+
for (const row of rows) {
|
|
3882
|
+
const attachment = rowToMessageAttachmentMetadata(row);
|
|
3883
|
+
const list = byMessage.get(attachment.message_id) ?? [];
|
|
3884
|
+
list.push(attachment);
|
|
3885
|
+
byMessage.set(attachment.message_id, list);
|
|
3886
|
+
}
|
|
3887
|
+
return messages.map((message) => ({ ...message, attachments: byMessage.get(message.id) ?? [] }));
|
|
3888
|
+
}
|
|
3889
|
+
|
|
3890
|
+
listMessageAttachments(messageId: number): MessageAttachmentMetadata[] {
|
|
3891
|
+
const rows = this.db.query(`
|
|
3892
|
+
SELECT id, message_id, kind, mime_type, filename, size_bytes, path, source_provider, created_at
|
|
3893
|
+
FROM message_attachments
|
|
3894
|
+
WHERE message_id = ?
|
|
3895
|
+
ORDER BY id ASC
|
|
3896
|
+
`).all(messageId) as Record<string, unknown>[];
|
|
3897
|
+
return rows.map(rowToMessageAttachmentMetadata);
|
|
3898
|
+
}
|
|
3899
|
+
|
|
3900
|
+
addMessageAttachment(messageId: number, attachment: CreateMessageAttachmentInput): MessageAttachmentMetadata {
|
|
3901
|
+
const message = this.getMessage(messageId);
|
|
3902
|
+
if (!message) throw new Error(`message ${messageId} was not found`);
|
|
3903
|
+
validateMessageAttachment({ kind: attachment.kind, mimeType: attachment.mimeType, content: attachment.content });
|
|
3904
|
+
const id = crypto.randomUUID();
|
|
3905
|
+
const stored = storeMessageAttachmentFile({
|
|
3906
|
+
palHome: this.palHome,
|
|
3907
|
+
roomId: message.chat_id,
|
|
3908
|
+
messageId,
|
|
3909
|
+
attachmentId: id,
|
|
3910
|
+
filename: attachment.filename,
|
|
3911
|
+
mimeType: attachment.mimeType,
|
|
3912
|
+
content: attachment.content,
|
|
3913
|
+
});
|
|
3914
|
+
this.db.query(`
|
|
3915
|
+
INSERT INTO message_attachments (id, message_id, kind, mime_type, filename, size_bytes, path, source_provider, source_ref)
|
|
3916
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
3917
|
+
`).run(
|
|
3918
|
+
id,
|
|
3919
|
+
messageId,
|
|
3920
|
+
attachment.kind,
|
|
3921
|
+
attachment.mimeType,
|
|
3922
|
+
stored.filename,
|
|
3923
|
+
attachment.content.length,
|
|
3924
|
+
stored.path,
|
|
3925
|
+
attachment.sourceProvider ?? message.provider ?? null,
|
|
3926
|
+
attachment.sourceRef?.trim() || null,
|
|
3927
|
+
);
|
|
3928
|
+
return this.listMessageAttachments(messageId).find((item) => item.id === id)!;
|
|
3929
|
+
}
|
|
3930
|
+
|
|
3931
|
+
getMessageAttachment(id: string): MessageAttachment | null {
|
|
3932
|
+
const row = this.db.query('SELECT * FROM message_attachments WHERE id = ?').get(id.trim()) as Record<string, unknown> | null;
|
|
3933
|
+
return row ? rowToMessageAttachment(row) : null;
|
|
3934
|
+
}
|
|
3935
|
+
|
|
3936
|
+
getMessage(id: number): Message | null {
|
|
3937
|
+
const row = this.db.query(`
|
|
3938
|
+
SELECT m.*, c.name AS chat_name
|
|
3939
|
+
FROM messages m
|
|
3940
|
+
JOIN chats c ON c.id = m.chat_id
|
|
3941
|
+
WHERE m.id = ?
|
|
3942
|
+
`).get(id) as Record<string, unknown> | null;
|
|
3943
|
+
return row ? this.attachMessageAttachments([rowToMessage(row)])[0]! : null;
|
|
3944
|
+
}
|
|
3945
|
+
|
|
3946
|
+
listMessages(input: ListMessagesInput = {}): Message[] {
|
|
3947
|
+
const conditions: string[] = [];
|
|
3948
|
+
const params: SQLQueryBindings[] = [];
|
|
3949
|
+
|
|
3950
|
+
if (input.chatId) {
|
|
3951
|
+
conditions.push('m.chat_id = ?');
|
|
3952
|
+
params.push(input.chatId);
|
|
3953
|
+
} else if (input.chatName) {
|
|
3954
|
+
conditions.push('c.name = ?');
|
|
3955
|
+
params.push(normalizeChatName(input.chatName));
|
|
3956
|
+
}
|
|
3957
|
+
|
|
3958
|
+
if (input.parentId !== undefined) {
|
|
3959
|
+
if (input.parentId === null) {
|
|
3960
|
+
conditions.push('m.parent_id IS NULL');
|
|
3961
|
+
} else {
|
|
3962
|
+
conditions.push('(m.id = ? OR m.parent_id = ?)');
|
|
3963
|
+
params.push(input.parentId, input.parentId);
|
|
3964
|
+
}
|
|
3965
|
+
}
|
|
3966
|
+
|
|
3967
|
+
if (input.after !== undefined) {
|
|
3968
|
+
conditions.push('m.id > ?');
|
|
3969
|
+
params.push(input.after);
|
|
3970
|
+
}
|
|
3971
|
+
|
|
3972
|
+
if (input.before !== undefined) {
|
|
3973
|
+
conditions.push('m.id < ?');
|
|
3974
|
+
params.push(input.before);
|
|
3975
|
+
}
|
|
3976
|
+
|
|
3977
|
+
if (input.q) {
|
|
3978
|
+
conditions.push('m.content LIKE ?');
|
|
3979
|
+
params.push(`%${input.q}%`);
|
|
3980
|
+
}
|
|
3981
|
+
|
|
3982
|
+
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
3983
|
+
const limit = limitValue(input.limit, 50);
|
|
3984
|
+
const useLatestWindow = input.after === undefined && input.q === undefined;
|
|
3985
|
+
const rows = useLatestWindow
|
|
3986
|
+
? this.db.query(`
|
|
3987
|
+
SELECT *
|
|
3988
|
+
FROM (
|
|
3989
|
+
SELECT m.*, c.name AS chat_name
|
|
3990
|
+
FROM messages m
|
|
3991
|
+
JOIN chats c ON c.id = m.chat_id
|
|
3992
|
+
${where}
|
|
3993
|
+
ORDER BY m.id DESC
|
|
3994
|
+
LIMIT ?
|
|
3995
|
+
)
|
|
3996
|
+
ORDER BY id ASC
|
|
3997
|
+
`).all(...params, limit) as Record<string, unknown>[]
|
|
3998
|
+
: this.db.query(`
|
|
3999
|
+
SELECT m.*, c.name AS chat_name
|
|
4000
|
+
FROM messages m
|
|
4001
|
+
JOIN chats c ON c.id = m.chat_id
|
|
4002
|
+
${where}
|
|
4003
|
+
ORDER BY m.id ASC
|
|
4004
|
+
LIMIT ?
|
|
4005
|
+
`).all(...params, limit) as Record<string, unknown>[];
|
|
4006
|
+
|
|
4007
|
+
return this.attachMessageAttachments(rows.map(rowToMessage));
|
|
4008
|
+
}
|
|
4009
|
+
|
|
4010
|
+
getLatestMessageIdForFreshness(chatId: string, sender: string): number {
|
|
4011
|
+
const row = this.db.query(`
|
|
4012
|
+
SELECT COALESCE(MAX(id), 0) AS latest
|
|
4013
|
+
FROM messages
|
|
4014
|
+
WHERE chat_id = ? AND sender != ?
|
|
4015
|
+
`).get(chatId, sender.trim()) as { latest: number } | null;
|
|
4016
|
+
return Number(row?.latest ?? 0);
|
|
4017
|
+
}
|
|
4018
|
+
|
|
4019
|
+
listInterveningMessagesForFreshness(input: { chatId: string; baseMessageId: number; sender: string; limit?: number }): Message[] {
|
|
4020
|
+
const rows = this.db.query(`
|
|
4021
|
+
SELECT m.*, c.name AS chat_name
|
|
4022
|
+
FROM messages m
|
|
4023
|
+
JOIN chats c ON c.id = m.chat_id
|
|
4024
|
+
WHERE m.chat_id = ?
|
|
4025
|
+
AND m.id > ?
|
|
4026
|
+
AND m.sender != ?
|
|
4027
|
+
ORDER BY m.id ASC
|
|
4028
|
+
LIMIT ?
|
|
4029
|
+
`).all(input.chatId, input.baseMessageId, input.sender.trim(), limitValue(input.limit, 50)) as Record<string, unknown>[];
|
|
4030
|
+
return this.attachMessageAttachments(rows.map(rowToMessage));
|
|
4031
|
+
}
|
|
4032
|
+
|
|
4033
|
+
listRoomTasks(roomId: string, status: RoomTaskStatus | 'all' = 'all', limit = 50): RoomTask[] {
|
|
4034
|
+
const room = this.getChatById(roomId);
|
|
4035
|
+
if (!room) throw new Error(`room ${roomId} was not found`);
|
|
4036
|
+
const conditions = ['rt.room_id = ?'];
|
|
4037
|
+
const params: SQLQueryBindings[] = [room.id];
|
|
4038
|
+
if (status !== 'all') {
|
|
4039
|
+
conditions.push('rt.status = ?');
|
|
4040
|
+
params.push(assertRoomTaskStatus(status));
|
|
4041
|
+
}
|
|
4042
|
+
const rows = this.db.query(`
|
|
4043
|
+
SELECT rt.*, c.name AS room_name
|
|
4044
|
+
FROM room_tasks rt
|
|
4045
|
+
JOIN chats c ON c.id = rt.room_id
|
|
4046
|
+
WHERE ${conditions.join(' AND ')}
|
|
4047
|
+
ORDER BY rt.task_number ASC
|
|
4048
|
+
LIMIT ?
|
|
4049
|
+
`).all(...params, limitValue(limit, 50)) as Record<string, unknown>[];
|
|
4050
|
+
return rows.map(rowToRoomTask);
|
|
4051
|
+
}
|
|
4052
|
+
|
|
4053
|
+
getRoomTask(roomId: string, taskNumber: number): RoomTask | null {
|
|
4054
|
+
const row = this.db.query(`
|
|
4055
|
+
SELECT rt.*, c.name AS room_name
|
|
4056
|
+
FROM room_tasks rt
|
|
4057
|
+
JOIN chats c ON c.id = rt.room_id
|
|
4058
|
+
WHERE rt.room_id = ? AND rt.task_number = ?
|
|
4059
|
+
`).get(roomId, taskNumber) as Record<string, unknown> | null;
|
|
4060
|
+
return row ? rowToRoomTask(row) : null;
|
|
4061
|
+
}
|
|
4062
|
+
|
|
4063
|
+
createRoomTasks(input: CreateRoomTasksInput): RoomTask[] {
|
|
4064
|
+
const room = this.getChatById(input.roomId);
|
|
4065
|
+
if (!room) throw new Error(`room ${input.roomId} was not found`);
|
|
4066
|
+
const titles = input.titles.map((title) => title.trim()).filter(Boolean);
|
|
4067
|
+
if (!titles.length) throw new Error('task title is required');
|
|
4068
|
+
|
|
4069
|
+
const sourceMessageId = input.sourceMessageId === undefined ? null : input.sourceMessageId;
|
|
4070
|
+
if (sourceMessageId !== null) {
|
|
4071
|
+
if (!Number.isInteger(sourceMessageId) || sourceMessageId < 1) throw new Error('INVALID_SOURCE_MESSAGE_ID');
|
|
4072
|
+
const sourceMessage = this.getMessage(sourceMessageId);
|
|
4073
|
+
if (!sourceMessage || sourceMessage.chat_id !== room.id) throw new Error('SOURCE_MESSAGE_NOT_FOUND');
|
|
4074
|
+
}
|
|
4075
|
+
const createdBy = input.createdBy?.trim() || null;
|
|
4076
|
+
|
|
4077
|
+
return this.db.transaction(() => {
|
|
4078
|
+
const nextRow = this.db.query(`
|
|
4079
|
+
SELECT COALESCE(MAX(task_number), 0) + 1 AS task_number
|
|
4080
|
+
FROM room_tasks
|
|
4081
|
+
WHERE room_id = ?
|
|
4082
|
+
`).get(room.id) as { task_number: number };
|
|
4083
|
+
let taskNumber = Number(nextRow.task_number ?? 1);
|
|
4084
|
+
const created: RoomTask[] = [];
|
|
4085
|
+
for (const title of titles) {
|
|
4086
|
+
const id = crypto.randomUUID();
|
|
4087
|
+
this.db.query(`
|
|
4088
|
+
INSERT INTO room_tasks (id, room_id, task_number, title, status, assignee, created_by, source_message_id, created_at, updated_at)
|
|
4089
|
+
VALUES (?, ?, ?, ?, 'todo', NULL, ?, ?, datetime('now'), datetime('now'))
|
|
4090
|
+
`).run(id, room.id, taskNumber, title, createdBy, sourceMessageId);
|
|
4091
|
+
created.push(this.getRoomTask(room.id, taskNumber)!);
|
|
4092
|
+
taskNumber += 1;
|
|
4093
|
+
}
|
|
4094
|
+
return created;
|
|
4095
|
+
})();
|
|
4096
|
+
}
|
|
4097
|
+
|
|
4098
|
+
claimRoomTask(input: ClaimRoomTaskInput): RoomTask | null {
|
|
4099
|
+
const room = this.getChatById(input.roomId);
|
|
4100
|
+
if (!room) throw new Error(`room ${input.roomId} was not found`);
|
|
4101
|
+
const assignee = input.assignee.trim();
|
|
4102
|
+
if (!assignee) throw new Error('assignee is required');
|
|
4103
|
+
if (!Number.isInteger(input.taskNumber) || input.taskNumber < 1) throw new Error('INVALID_TASK_NUMBER');
|
|
4104
|
+
const existing = this.getRoomTask(room.id, input.taskNumber);
|
|
4105
|
+
if (!existing) return null;
|
|
4106
|
+
this.db.query(`
|
|
4107
|
+
UPDATE room_tasks
|
|
4108
|
+
SET assignee = ?, status = 'in_progress', completed_at = NULL, updated_at = datetime('now')
|
|
4109
|
+
WHERE room_id = ? AND task_number = ?
|
|
4110
|
+
`).run(assignee, room.id, input.taskNumber);
|
|
4111
|
+
return this.getRoomTask(room.id, input.taskNumber);
|
|
4112
|
+
}
|
|
4113
|
+
|
|
4114
|
+
unclaimRoomTask(roomId: string, taskNumber: number): RoomTask | null {
|
|
4115
|
+
const room = this.getChatById(roomId);
|
|
4116
|
+
if (!room) throw new Error(`room ${roomId} was not found`);
|
|
4117
|
+
if (!Number.isInteger(taskNumber) || taskNumber < 1) throw new Error('INVALID_TASK_NUMBER');
|
|
4118
|
+
const existing = this.getRoomTask(room.id, taskNumber);
|
|
4119
|
+
if (!existing) return null;
|
|
4120
|
+
this.db.query(`
|
|
4121
|
+
UPDATE room_tasks
|
|
4122
|
+
SET assignee = NULL, status = 'todo', completed_at = NULL, updated_at = datetime('now')
|
|
4123
|
+
WHERE room_id = ? AND task_number = ?
|
|
4124
|
+
`).run(room.id, taskNumber);
|
|
4125
|
+
return this.getRoomTask(room.id, taskNumber);
|
|
4126
|
+
}
|
|
4127
|
+
|
|
4128
|
+
updateRoomTaskStatus(input: UpdateRoomTaskInput): RoomTask | null {
|
|
4129
|
+
const room = this.getChatById(input.roomId);
|
|
4130
|
+
if (!room) throw new Error(`room ${input.roomId} was not found`);
|
|
4131
|
+
if (!Number.isInteger(input.taskNumber) || input.taskNumber < 1) throw new Error('INVALID_TASK_NUMBER');
|
|
4132
|
+
const status = assertRoomTaskStatus(input.status);
|
|
4133
|
+
const existing = this.getRoomTask(room.id, input.taskNumber);
|
|
4134
|
+
if (!existing) return null;
|
|
4135
|
+
const completedAtSql = status === 'done' || status === 'closed'
|
|
4136
|
+
? "COALESCE(completed_at, datetime('now'))"
|
|
4137
|
+
: 'NULL';
|
|
4138
|
+
this.db.query(`
|
|
4139
|
+
UPDATE room_tasks
|
|
4140
|
+
SET status = ?, completed_at = ${completedAtSql}, updated_at = datetime('now')
|
|
4141
|
+
WHERE room_id = ? AND task_number = ?
|
|
4142
|
+
`).run(status, room.id, input.taskNumber);
|
|
4143
|
+
return this.getRoomTask(room.id, input.taskNumber);
|
|
4144
|
+
}
|
|
4145
|
+
|
|
4146
|
+
getRoomReminder(id: string): RoomReminder | null {
|
|
4147
|
+
const row = this.db.query(`
|
|
4148
|
+
SELECT rr.*, c.name AS room_name
|
|
4149
|
+
FROM room_reminders rr
|
|
4150
|
+
JOIN chats c ON c.id = rr.room_id
|
|
4151
|
+
WHERE rr.id = ?
|
|
4152
|
+
`).get(id) as Record<string, unknown> | null;
|
|
4153
|
+
return row ? rowToRoomReminder(row) : null;
|
|
4154
|
+
}
|
|
4155
|
+
|
|
4156
|
+
listRoomReminders(input: ListRoomRemindersInput = {}): RoomReminder[] {
|
|
4157
|
+
const conditions: string[] = [];
|
|
4158
|
+
const params: SQLQueryBindings[] = [];
|
|
4159
|
+
if (input.roomId?.trim()) {
|
|
4160
|
+
const room = this.resolveRoom(input.roomId);
|
|
4161
|
+
if (!room) throw new Error(`room ${input.roomId} was not found`);
|
|
4162
|
+
conditions.push('rr.room_id = ?');
|
|
4163
|
+
params.push(room.id);
|
|
4164
|
+
}
|
|
4165
|
+
if ((input.status ?? 'scheduled') !== 'all') {
|
|
4166
|
+
conditions.push('rr.status = ?');
|
|
4167
|
+
params.push(assertRoomReminderStatus(input.status ?? 'scheduled'));
|
|
4168
|
+
}
|
|
4169
|
+
if (input.createdBy?.trim()) {
|
|
4170
|
+
conditions.push('rr.created_by = ?');
|
|
4171
|
+
params.push(input.createdBy.trim());
|
|
4172
|
+
}
|
|
4173
|
+
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
4174
|
+
const rows = this.db.query(`
|
|
4175
|
+
SELECT rr.*, c.name AS room_name
|
|
4176
|
+
FROM room_reminders rr
|
|
4177
|
+
JOIN chats c ON c.id = rr.room_id
|
|
4178
|
+
${where}
|
|
4179
|
+
ORDER BY rr.fire_at ASC, rr.created_at ASC
|
|
4180
|
+
LIMIT ?
|
|
4181
|
+
`).all(...params, limitValue(input.limit, 50)) as Record<string, unknown>[];
|
|
4182
|
+
return rows.map(rowToRoomReminder);
|
|
4183
|
+
}
|
|
4184
|
+
|
|
4185
|
+
createRoomReminder(input: CreateRoomReminderInput): RoomReminder {
|
|
4186
|
+
if (!Number.isInteger(input.sourceMessageId) || input.sourceMessageId < 1) throw new Error('INVALID_SOURCE_MESSAGE_ID');
|
|
4187
|
+
const sourceMessage = this.getMessage(input.sourceMessageId);
|
|
4188
|
+
if (!sourceMessage) throw new Error('SOURCE_MESSAGE_NOT_FOUND');
|
|
4189
|
+
const title = input.title.trim();
|
|
4190
|
+
if (!title) throw new Error('reminder title is required');
|
|
4191
|
+
const fireAt = normalizeReminderFireAt(input);
|
|
4192
|
+
const createdBy = input.createdBy?.trim() || null;
|
|
4193
|
+
const repeat = normalizeReminderRepeat(input.repeat);
|
|
4194
|
+
const id = crypto.randomUUID();
|
|
4195
|
+
this.db.query(`
|
|
4196
|
+
INSERT INTO room_reminders (id, room_id, source_message_id, title, status, created_by, fire_at, repeat, created_at, updated_at)
|
|
4197
|
+
VALUES (?, ?, ?, ?, 'scheduled', ?, ?, ?, datetime('now'), datetime('now'))
|
|
4198
|
+
`).run(id, sourceMessage.chat_id, sourceMessage.id, title, createdBy, fireAt, repeat);
|
|
4199
|
+
return this.getRoomReminder(id)!;
|
|
4200
|
+
}
|
|
4201
|
+
|
|
4202
|
+
cancelRoomReminder(id: string): RoomReminder | null {
|
|
4203
|
+
const reminderId = id.trim();
|
|
4204
|
+
if (!reminderId) return null;
|
|
4205
|
+
const existing = this.getRoomReminder(reminderId);
|
|
4206
|
+
if (!existing) return null;
|
|
4207
|
+
if (existing.status === 'scheduled') {
|
|
4208
|
+
this.db.query(`
|
|
4209
|
+
UPDATE room_reminders
|
|
4210
|
+
SET status = 'canceled', canceled_at = datetime('now'), updated_at = datetime('now')
|
|
4211
|
+
WHERE id = ?
|
|
4212
|
+
`).run(reminderId);
|
|
4213
|
+
}
|
|
4214
|
+
return this.getRoomReminder(reminderId);
|
|
4215
|
+
}
|
|
4216
|
+
|
|
4217
|
+
getRoomSavedMessage(id: string): RoomSavedMessage | null {
|
|
4218
|
+
const savedMessageId = id.trim();
|
|
4219
|
+
if (!savedMessageId) return null;
|
|
4220
|
+
const row = this.db.query(`
|
|
4221
|
+
SELECT
|
|
4222
|
+
rsm.*,
|
|
4223
|
+
c.name AS room_name,
|
|
4224
|
+
m.sender AS message_sender,
|
|
4225
|
+
m.content AS message_content,
|
|
4226
|
+
m.created_at AS message_created_at
|
|
4227
|
+
FROM room_saved_messages rsm
|
|
4228
|
+
JOIN chats c ON c.id = rsm.room_id
|
|
4229
|
+
JOIN messages m ON m.id = rsm.message_id
|
|
4230
|
+
WHERE rsm.id = ?
|
|
4231
|
+
`).get(savedMessageId) as Record<string, unknown> | null;
|
|
4232
|
+
return row ? rowToRoomSavedMessage(row) : null;
|
|
4233
|
+
}
|
|
4234
|
+
|
|
4235
|
+
listRoomSavedMessages(input: ListRoomSavedMessagesInput = {}): RoomSavedMessage[] {
|
|
4236
|
+
const conditions: string[] = [];
|
|
4237
|
+
const params: SQLQueryBindings[] = [];
|
|
4238
|
+
if (input.roomId?.trim()) {
|
|
4239
|
+
const room = this.resolveRoom(input.roomId);
|
|
4240
|
+
if (!room) throw new Error(`room ${input.roomId} was not found`);
|
|
4241
|
+
conditions.push('rsm.room_id = ?');
|
|
4242
|
+
params.push(room.id);
|
|
4243
|
+
}
|
|
4244
|
+
if (input.savedBy?.trim()) {
|
|
4245
|
+
conditions.push('rsm.saved_by = ?');
|
|
4246
|
+
params.push(input.savedBy.trim());
|
|
4247
|
+
}
|
|
4248
|
+
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
4249
|
+
const rows = this.db.query(`
|
|
4250
|
+
SELECT
|
|
4251
|
+
rsm.*,
|
|
4252
|
+
c.name AS room_name,
|
|
4253
|
+
m.sender AS message_sender,
|
|
4254
|
+
m.content AS message_content,
|
|
4255
|
+
m.created_at AS message_created_at
|
|
4256
|
+
FROM room_saved_messages rsm
|
|
4257
|
+
JOIN chats c ON c.id = rsm.room_id
|
|
4258
|
+
JOIN messages m ON m.id = rsm.message_id
|
|
4259
|
+
${where}
|
|
4260
|
+
ORDER BY rsm.created_at DESC, rsm.updated_at DESC
|
|
4261
|
+
LIMIT ?
|
|
4262
|
+
`).all(...params, limitValue(input.limit, 50)) as Record<string, unknown>[];
|
|
4263
|
+
return rows.map(rowToRoomSavedMessage);
|
|
4264
|
+
}
|
|
4265
|
+
|
|
4266
|
+
saveRoomMessage(input: CreateRoomSavedMessageInput): SaveRoomMessageResult {
|
|
4267
|
+
if (!Number.isInteger(input.sourceMessageId) || input.sourceMessageId < 1) throw new Error('INVALID_SOURCE_MESSAGE_ID');
|
|
4268
|
+
const sourceMessage = this.getMessage(input.sourceMessageId);
|
|
4269
|
+
if (!sourceMessage) throw new Error('SOURCE_MESSAGE_NOT_FOUND');
|
|
4270
|
+
const savedBy = input.savedBy.trim();
|
|
4271
|
+
if (!savedBy) throw new Error('saved_by is required');
|
|
4272
|
+
const note = input.note?.trim() || null;
|
|
4273
|
+
const existing = this.db.query(`
|
|
4274
|
+
SELECT id
|
|
4275
|
+
FROM room_saved_messages
|
|
4276
|
+
WHERE message_id = ? AND saved_by = ?
|
|
4277
|
+
`).get(sourceMessage.id, savedBy) as { id: string } | null;
|
|
4278
|
+
if (existing) {
|
|
4279
|
+
this.db.query(`
|
|
4280
|
+
UPDATE room_saved_messages
|
|
4281
|
+
SET note = ?, updated_at = datetime('now')
|
|
4282
|
+
WHERE id = ?
|
|
4283
|
+
`).run(note, existing.id);
|
|
4284
|
+
return { savedMessage: this.getRoomSavedMessage(existing.id)!, created: false };
|
|
4285
|
+
}
|
|
4286
|
+
const id = crypto.randomUUID();
|
|
4287
|
+
this.db.query(`
|
|
4288
|
+
INSERT INTO room_saved_messages (id, room_id, message_id, saved_by, note, created_at, updated_at)
|
|
4289
|
+
VALUES (?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
|
4290
|
+
`).run(id, sourceMessage.chat_id, sourceMessage.id, savedBy, note);
|
|
4291
|
+
return { savedMessage: this.getRoomSavedMessage(id)!, created: true };
|
|
4292
|
+
}
|
|
4293
|
+
|
|
4294
|
+
removeRoomSavedMessage(id: string): RoomSavedMessage | null {
|
|
4295
|
+
const savedMessage = this.getRoomSavedMessage(id);
|
|
4296
|
+
if (!savedMessage) return null;
|
|
4297
|
+
this.db.query('DELETE FROM room_saved_messages WHERE id = ?').run(savedMessage.id);
|
|
4298
|
+
return savedMessage;
|
|
4299
|
+
}
|
|
4300
|
+
|
|
4301
|
+
createWorkflowRun(input: CreateWorkflowRunInput): WorkflowRun {
|
|
4302
|
+
const filePath = input.filePath.trim();
|
|
4303
|
+
if (!filePath) throw new Error('workflow file_path is required');
|
|
4304
|
+
const id = crypto.randomUUID();
|
|
4305
|
+
this.db.query(`
|
|
4306
|
+
INSERT INTO workflow_runs (id, file_path, goal, status, created_by, created_at, updated_at)
|
|
4307
|
+
VALUES (?, ?, ?, 'running', ?, datetime('now'), datetime('now'))
|
|
4308
|
+
`).run(id, filePath, input.goal?.trim() || null, input.createdBy?.trim() || null);
|
|
4309
|
+
return this.getWorkflowRun(id)!;
|
|
4310
|
+
}
|
|
4311
|
+
|
|
4312
|
+
getWorkflowRun(id: string): WorkflowRun | null {
|
|
4313
|
+
const row = this.db.query('SELECT * FROM workflow_runs WHERE id = ?').get(id.trim()) as Record<string, unknown> | null;
|
|
4314
|
+
return row ? rowToWorkflowRun(row) : null;
|
|
4315
|
+
}
|
|
4316
|
+
|
|
4317
|
+
listWorkflowRuns(input: { status?: WorkflowRunStatus | 'all'; limit?: number } = {}): WorkflowRun[] {
|
|
4318
|
+
const status = input.status ?? 'all';
|
|
4319
|
+
const rows = status === 'all'
|
|
4320
|
+
? this.db.query(`
|
|
4321
|
+
SELECT * FROM workflow_runs
|
|
4322
|
+
ORDER BY datetime(updated_at) DESC, datetime(created_at) DESC
|
|
4323
|
+
LIMIT ?
|
|
4324
|
+
`).all(limitValue(input.limit, 50)) as Record<string, unknown>[]
|
|
4325
|
+
: this.db.query(`
|
|
4326
|
+
SELECT * FROM workflow_runs
|
|
4327
|
+
WHERE status = ?
|
|
4328
|
+
ORDER BY datetime(updated_at) DESC, datetime(created_at) DESC
|
|
4329
|
+
LIMIT ?
|
|
4330
|
+
`).all(assertWorkflowRunStatus(status), limitValue(input.limit, 50)) as Record<string, unknown>[];
|
|
4331
|
+
return rows.map(rowToWorkflowRun);
|
|
4332
|
+
}
|
|
4333
|
+
|
|
4334
|
+
finishWorkflowRun(id: string, input: FinishWorkflowRunInput): WorkflowRun {
|
|
4335
|
+
const run = this.getWorkflowRun(id);
|
|
4336
|
+
if (!run) throw new Error(`workflow run ${id} was not found`);
|
|
4337
|
+
const status = assertWorkflowRunStatus(input.status);
|
|
4338
|
+
const finalOutput = input.finalOutput === undefined ? run.final_output : input.finalOutput;
|
|
4339
|
+
const error = input.error === undefined ? run.error : input.error;
|
|
4340
|
+
const completedAtSql = status === 'running' ? 'NULL' : "COALESCE(completed_at, datetime('now'))";
|
|
4341
|
+
this.db.query(`
|
|
4342
|
+
UPDATE workflow_runs
|
|
4343
|
+
SET status = ?,
|
|
4344
|
+
final_output_json = ?,
|
|
4345
|
+
error = ?,
|
|
4346
|
+
completed_at = ${completedAtSql},
|
|
4347
|
+
updated_at = datetime('now')
|
|
4348
|
+
WHERE id = ?
|
|
4349
|
+
`).run(status, finalOutput ? JSON.stringify(finalOutput) : null, error?.trim() || null, run.id);
|
|
4350
|
+
return this.getWorkflowRun(run.id)!;
|
|
4351
|
+
}
|
|
4352
|
+
|
|
4353
|
+
createWorkflowNode(input: CreateWorkflowNodeInput): WorkflowNode {
|
|
4354
|
+
const run = this.getWorkflowRun(input.runId);
|
|
4355
|
+
if (!run) throw new Error(`workflow run ${input.runId} was not found`);
|
|
4356
|
+
const parentId = input.parentId?.trim() || null;
|
|
4357
|
+
if (parentId) {
|
|
4358
|
+
const parent = this.getWorkflowNode(parentId);
|
|
4359
|
+
if (!parent) throw new Error(`workflow parent node ${parentId} was not found`);
|
|
4360
|
+
if (parent.run_id !== run.id) throw new Error('WORKFLOW_PARENT_RUN_MISMATCH');
|
|
4361
|
+
}
|
|
4362
|
+
const title = input.title.trim();
|
|
4363
|
+
if (!title) throw new Error('workflow node title is required');
|
|
4364
|
+
const status = assertWorkflowNodeStatus(input.status ?? 'pending');
|
|
4365
|
+
const id = crypto.randomUUID();
|
|
4366
|
+
const completedAtSql = status === 'done' || status === 'failed' ? "datetime('now')" : 'NULL';
|
|
4367
|
+
this.db.query(`
|
|
4368
|
+
INSERT INTO workflow_nodes (
|
|
4369
|
+
id, run_id, parent_id, kind, title, role, status, context_json, instruction,
|
|
4370
|
+
capabilities_json, output_contract_json, output_json, evidence_json, task_id,
|
|
4371
|
+
message_id, created_at, updated_at, completed_at
|
|
4372
|
+
)
|
|
4373
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'), ${completedAtSql})
|
|
2078
4374
|
`).run(
|
|
2079
|
-
|
|
4375
|
+
id,
|
|
4376
|
+
run.id,
|
|
2080
4377
|
parentId,
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
input.
|
|
2084
|
-
|
|
2085
|
-
input.
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
JSON.stringify(
|
|
4378
|
+
assertWorkflowNodeKind(input.kind),
|
|
4379
|
+
title,
|
|
4380
|
+
input.role?.trim() || null,
|
|
4381
|
+
status,
|
|
4382
|
+
input.context ? JSON.stringify(input.context) : null,
|
|
4383
|
+
input.instruction?.trim() || null,
|
|
4384
|
+
JSON.stringify(input.capabilities ?? []),
|
|
4385
|
+
input.outputContract ? JSON.stringify(input.outputContract) : null,
|
|
4386
|
+
input.output ? JSON.stringify(input.output) : null,
|
|
4387
|
+
input.evidence ? JSON.stringify(input.evidence) : null,
|
|
4388
|
+
input.taskId?.trim() || null,
|
|
4389
|
+
input.messageId ?? null,
|
|
2090
4390
|
);
|
|
4391
|
+
return this.getWorkflowNode(id)!;
|
|
4392
|
+
}
|
|
2091
4393
|
|
|
2092
|
-
|
|
2093
|
-
|
|
4394
|
+
getWorkflowNode(id: string): WorkflowNode | null {
|
|
4395
|
+
const row = this.db.query('SELECT * FROM workflow_nodes WHERE id = ?').get(id.trim()) as Record<string, unknown> | null;
|
|
4396
|
+
return row ? rowToWorkflowNode(row) : null;
|
|
2094
4397
|
}
|
|
2095
4398
|
|
|
2096
|
-
|
|
2097
|
-
const
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
4399
|
+
updateWorkflowNode(id: string, input: UpdateWorkflowNodeInput): WorkflowNode {
|
|
4400
|
+
const node = this.getWorkflowNode(id);
|
|
4401
|
+
if (!node) throw new Error(`workflow node ${id} was not found`);
|
|
4402
|
+
const status = input.status ? assertWorkflowNodeStatus(input.status) : node.status;
|
|
4403
|
+
const completedAtSql = status === 'done' || status === 'failed'
|
|
4404
|
+
? "COALESCE(completed_at, datetime('now'))"
|
|
4405
|
+
: 'NULL';
|
|
4406
|
+
const output = input.output === undefined ? node.output : input.output;
|
|
4407
|
+
const evidence = input.evidence === undefined ? node.evidence : input.evidence;
|
|
4408
|
+
const taskId = input.taskId === undefined ? node.task_id : input.taskId;
|
|
4409
|
+
const messageId = input.messageId === undefined ? node.message_id : input.messageId;
|
|
4410
|
+
this.db.query(`
|
|
4411
|
+
UPDATE workflow_nodes
|
|
4412
|
+
SET status = ?,
|
|
4413
|
+
output_json = ?,
|
|
4414
|
+
evidence_json = ?,
|
|
4415
|
+
task_id = ?,
|
|
4416
|
+
message_id = ?,
|
|
4417
|
+
completed_at = ${completedAtSql},
|
|
4418
|
+
updated_at = datetime('now')
|
|
4419
|
+
WHERE id = ?
|
|
4420
|
+
`).run(
|
|
4421
|
+
status,
|
|
4422
|
+
output ? JSON.stringify(output) : null,
|
|
4423
|
+
evidence ? JSON.stringify(evidence) : null,
|
|
4424
|
+
taskId?.trim() || null,
|
|
4425
|
+
messageId ?? null,
|
|
4426
|
+
node.id,
|
|
4427
|
+
);
|
|
4428
|
+
return this.getWorkflowNode(node.id)!;
|
|
2104
4429
|
}
|
|
2105
4430
|
|
|
2106
|
-
|
|
2107
|
-
const
|
|
2108
|
-
|
|
4431
|
+
listWorkflowNodes(runId: string): WorkflowNode[] {
|
|
4432
|
+
const run = this.getWorkflowRun(runId);
|
|
4433
|
+
if (!run) throw new Error(`workflow run ${runId} was not found`);
|
|
4434
|
+
const rows = this.db.query(`
|
|
4435
|
+
SELECT *
|
|
4436
|
+
FROM workflow_nodes
|
|
4437
|
+
WHERE run_id = ?
|
|
4438
|
+
ORDER BY datetime(created_at) ASC, rowid ASC
|
|
4439
|
+
`).all(run.id) as Record<string, unknown>[];
|
|
4440
|
+
return rows.map(rowToWorkflowNode);
|
|
4441
|
+
}
|
|
2109
4442
|
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
params.push(normalizeChatName(input.chatName));
|
|
2116
|
-
}
|
|
4443
|
+
getWorkflowRunRecord(id: string): WorkflowRunRecord | null {
|
|
4444
|
+
const run = this.getWorkflowRun(id);
|
|
4445
|
+
if (!run) return null;
|
|
4446
|
+
return { run, nodes: this.listWorkflowNodes(run.id) };
|
|
4447
|
+
}
|
|
2117
4448
|
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
4449
|
+
fireDueRoomReminders(input: FireDueRoomRemindersInput = {}): FireDueRoomRemindersResult {
|
|
4450
|
+
const now = normalizeReminderFireNow(input.now);
|
|
4451
|
+
const due = this.db.query(`
|
|
4452
|
+
SELECT rr.*, c.name AS room_name
|
|
4453
|
+
FROM room_reminders rr
|
|
4454
|
+
JOIN chats c ON c.id = rr.room_id
|
|
4455
|
+
WHERE rr.status = 'scheduled' AND rr.fire_at <= ?
|
|
4456
|
+
ORDER BY rr.fire_at ASC, rr.created_at ASC
|
|
4457
|
+
LIMIT ?
|
|
4458
|
+
`).all(now, limitValue(input.limit, 25)) as Record<string, unknown>[];
|
|
4459
|
+
|
|
4460
|
+
return this.db.transaction(() => {
|
|
4461
|
+
const fired: FiredRoomReminder[] = [];
|
|
4462
|
+
const messages: Message[] = [];
|
|
4463
|
+
const deliveries: MessageDelivery[] = [];
|
|
4464
|
+
|
|
4465
|
+
for (const row of due) {
|
|
4466
|
+
const reminder = this.getRoomReminder(String(row.id));
|
|
4467
|
+
if (!reminder || reminder.status !== 'scheduled' || reminder.fire_at > now) continue;
|
|
4468
|
+
const source = this.getMessage(reminder.source_message_id);
|
|
4469
|
+
const recipient = reminder.created_by?.trim() || null;
|
|
4470
|
+
const message = this.createMessage({
|
|
4471
|
+
chatId: reminder.room_id,
|
|
4472
|
+
sender: 'pal',
|
|
4473
|
+
recipient,
|
|
4474
|
+
content: reminderFireMessageContent(reminder, source),
|
|
4475
|
+
type: 'system',
|
|
4476
|
+
idempotencyKey: reminderFireIdempotencyKey(reminder),
|
|
4477
|
+
mentions: recipient ? [recipient] : [],
|
|
4478
|
+
});
|
|
4479
|
+
const reminderDeliveries = this.resolveDeliveriesForMessage(message.id);
|
|
4480
|
+
const updated = reminder.repeat
|
|
4481
|
+
? this.db.query(`
|
|
4482
|
+
UPDATE room_reminders
|
|
4483
|
+
SET fire_at = ?, fired_at = ?, updated_at = ?
|
|
4484
|
+
WHERE id = ? AND status = 'scheduled'
|
|
4485
|
+
`).run(nextReminderFireAt(reminder, now), now, now, reminder.id)
|
|
4486
|
+
: this.db.query(`
|
|
4487
|
+
UPDATE room_reminders
|
|
4488
|
+
SET status = 'fired', fired_at = ?, updated_at = ?
|
|
4489
|
+
WHERE id = ? AND status = 'scheduled'
|
|
4490
|
+
`).run(now, now, reminder.id);
|
|
4491
|
+
if (updated.changes === 0) continue;
|
|
4492
|
+
const firedReminder = this.getRoomReminder(reminder.id)!;
|
|
4493
|
+
fired.push({ reminder: firedReminder, message, deliveries: reminderDeliveries });
|
|
4494
|
+
messages.push(message);
|
|
4495
|
+
deliveries.push(...reminderDeliveries);
|
|
2124
4496
|
}
|
|
2125
|
-
}
|
|
2126
4497
|
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
4498
|
+
return {
|
|
4499
|
+
fired,
|
|
4500
|
+
reminders: fired.map((item) => item.reminder),
|
|
4501
|
+
messages,
|
|
4502
|
+
deliveries,
|
|
4503
|
+
};
|
|
4504
|
+
})();
|
|
4505
|
+
}
|
|
2131
4506
|
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
4507
|
+
createHeldMessageDraft(input: CreateHeldMessageDraftInput): HeldMessageDraft {
|
|
4508
|
+
const room = this.getChatById(input.chatId);
|
|
4509
|
+
if (!room) throw new Error(`chat ${input.chatId} was not found`);
|
|
4510
|
+
const agent = input.agent.trim();
|
|
4511
|
+
const sender = input.sender.trim();
|
|
4512
|
+
const content = input.content.trim();
|
|
4513
|
+
if (!agent) throw new Error('agent is required');
|
|
4514
|
+
if (!sender) throw new Error('sender is required');
|
|
4515
|
+
if (!content) throw new Error('content is required');
|
|
4516
|
+
if (!this.getAgent(agent)) throw new Error(`agent ${agent} not found`);
|
|
4517
|
+
const id = crypto.randomUUID();
|
|
4518
|
+
this.db.query(`
|
|
4519
|
+
INSERT INTO held_message_drafts (id, chat_id, agent, sender, content, mentions_json, base_message_id, latest_message_id_at_hold, status, hold_reason)
|
|
4520
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'held', ?)
|
|
4521
|
+
`).run(
|
|
4522
|
+
id,
|
|
4523
|
+
room.id,
|
|
4524
|
+
agent,
|
|
4525
|
+
sender,
|
|
4526
|
+
content,
|
|
4527
|
+
JSON.stringify(normalizeMentionsInput(input.mentions)),
|
|
4528
|
+
input.baseMessageId,
|
|
4529
|
+
input.latestMessageIdAtHold,
|
|
4530
|
+
input.holdReason,
|
|
4531
|
+
);
|
|
4532
|
+
return this.getHeldMessageDraft(id)!;
|
|
4533
|
+
}
|
|
2136
4534
|
|
|
2137
|
-
|
|
4535
|
+
getHeldMessageDraft(id: string): HeldMessageDraft | null {
|
|
4536
|
+
const row = this.db.query(`
|
|
4537
|
+
SELECT hmd.*, c.name AS chat_name, c.display_name AS chat_display_name,
|
|
4538
|
+
(
|
|
4539
|
+
SELECT COUNT(*)
|
|
4540
|
+
FROM messages m
|
|
4541
|
+
WHERE m.chat_id = hmd.chat_id
|
|
4542
|
+
AND m.id > hmd.base_message_id
|
|
4543
|
+
AND m.id <= hmd.latest_message_id_at_hold
|
|
4544
|
+
AND m.sender != hmd.sender
|
|
4545
|
+
) AS intervening_message_count
|
|
4546
|
+
FROM held_message_drafts hmd
|
|
4547
|
+
JOIN chats c ON c.id = hmd.chat_id
|
|
4548
|
+
WHERE hmd.id = ?
|
|
4549
|
+
LIMIT 1
|
|
4550
|
+
`).get(id.trim()) as Record<string, unknown> | null;
|
|
4551
|
+
return row ? rowToHeldMessageDraft(row) : null;
|
|
4552
|
+
}
|
|
4553
|
+
|
|
4554
|
+
listHeldMessageDrafts(agent: string, status: HeldDraftStatus = 'held', limit = 50): HeldMessageDraft[] {
|
|
2138
4555
|
const rows = this.db.query(`
|
|
2139
|
-
SELECT
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
4556
|
+
SELECT hmd.*, c.name AS chat_name, c.display_name AS chat_display_name,
|
|
4557
|
+
(
|
|
4558
|
+
SELECT COUNT(*)
|
|
4559
|
+
FROM messages m
|
|
4560
|
+
WHERE m.chat_id = hmd.chat_id
|
|
4561
|
+
AND m.id > hmd.base_message_id
|
|
4562
|
+
AND m.id <= hmd.latest_message_id_at_hold
|
|
4563
|
+
AND m.sender != hmd.sender
|
|
4564
|
+
) AS intervening_message_count
|
|
4565
|
+
FROM held_message_drafts hmd
|
|
4566
|
+
JOIN chats c ON c.id = hmd.chat_id
|
|
4567
|
+
WHERE hmd.agent = ? AND hmd.status = ?
|
|
4568
|
+
ORDER BY datetime(hmd.created_at) DESC, hmd.id ASC
|
|
2144
4569
|
LIMIT ?
|
|
2145
|
-
`).all(
|
|
4570
|
+
`).all(agent.trim(), status, limitValue(limit, 50)) as Record<string, unknown>[];
|
|
4571
|
+
return rows.map(rowToHeldMessageDraft);
|
|
4572
|
+
}
|
|
4573
|
+
|
|
4574
|
+
abandonHeldMessageDraft(id: string): HeldMessageDraft | null {
|
|
4575
|
+
const existing = this.getHeldMessageDraft(id);
|
|
4576
|
+
if (!existing) return null;
|
|
4577
|
+
if (existing.status !== 'held') throw new Error('HELD_DRAFT_ALREADY_RESOLVED');
|
|
4578
|
+
const result = this.db.query(`
|
|
4579
|
+
UPDATE held_message_drafts
|
|
4580
|
+
SET status = 'abandoned', updated_at = datetime('now'), resolved_at = datetime('now')
|
|
4581
|
+
WHERE id = ? AND status = 'held'
|
|
4582
|
+
`).run(existing.id);
|
|
4583
|
+
if (result.changes === 0) throw new Error('HELD_DRAFT_ALREADY_RESOLVED');
|
|
4584
|
+
return this.getHeldMessageDraft(existing.id)!;
|
|
4585
|
+
}
|
|
4586
|
+
|
|
4587
|
+
sendHeldMessageDraftAnyway(id: string): { draft: HeldMessageDraft; message: Message } | null {
|
|
4588
|
+
return this.db.transaction(() => {
|
|
4589
|
+
const existing = this.getHeldMessageDraft(id);
|
|
4590
|
+
if (!existing) return null;
|
|
4591
|
+
if (existing.status !== 'held') throw new Error('HELD_DRAFT_ALREADY_RESOLVED');
|
|
4592
|
+
const message = this.createMessage({
|
|
4593
|
+
chatId: existing.chat_id,
|
|
4594
|
+
sender: existing.sender,
|
|
4595
|
+
content: existing.content,
|
|
4596
|
+
mentions: existing.mentions,
|
|
4597
|
+
});
|
|
4598
|
+
const result = this.db.query(`
|
|
4599
|
+
UPDATE held_message_drafts
|
|
4600
|
+
SET status = 'sent_anyway',
|
|
4601
|
+
resolved_message_id = ?,
|
|
4602
|
+
updated_at = datetime('now'),
|
|
4603
|
+
resolved_at = datetime('now')
|
|
4604
|
+
WHERE id = ? AND status = 'held'
|
|
4605
|
+
`).run(message.id, existing.id);
|
|
4606
|
+
if (result.changes === 0) throw new Error('HELD_DRAFT_ALREADY_RESOLVED');
|
|
4607
|
+
return { draft: this.getHeldMessageDraft(existing.id)!, message };
|
|
4608
|
+
})();
|
|
4609
|
+
}
|
|
4610
|
+
|
|
4611
|
+
reviseHeldMessageDraft(id: string, content: string): { draft: HeldMessageDraft; message: Message } | null {
|
|
4612
|
+
const revisedContent = content.trim();
|
|
4613
|
+
if (!revisedContent) throw new Error('content is required');
|
|
4614
|
+
return this.db.transaction(() => {
|
|
4615
|
+
const existing = this.getHeldMessageDraft(id);
|
|
4616
|
+
if (!existing) return null;
|
|
4617
|
+
if (existing.status !== 'held') throw new Error('HELD_DRAFT_ALREADY_RESOLVED');
|
|
4618
|
+
const message = this.createMessage({
|
|
4619
|
+
chatId: existing.chat_id,
|
|
4620
|
+
sender: existing.sender,
|
|
4621
|
+
content: revisedContent,
|
|
4622
|
+
mentions: existing.mentions,
|
|
4623
|
+
});
|
|
4624
|
+
const result = this.db.query(`
|
|
4625
|
+
UPDATE held_message_drafts
|
|
4626
|
+
SET status = 'sent_anyway',
|
|
4627
|
+
resolved_message_id = ?,
|
|
4628
|
+
updated_at = datetime('now'),
|
|
4629
|
+
resolved_at = datetime('now')
|
|
4630
|
+
WHERE id = ? AND status = 'held'
|
|
4631
|
+
`).run(message.id, existing.id);
|
|
4632
|
+
if (result.changes === 0) throw new Error('HELD_DRAFT_ALREADY_RESOLVED');
|
|
4633
|
+
return { draft: this.getHeldMessageDraft(existing.id)!, message };
|
|
4634
|
+
})();
|
|
4635
|
+
}
|
|
2146
4636
|
|
|
2147
|
-
|
|
4637
|
+
getDeliveryContext(input: { agent: string; chatId: string; messageId: number; limit?: number }): DeliveryContext {
|
|
4638
|
+
const room = this.getChatById(input.chatId);
|
|
4639
|
+
if (!room) throw new Error(`room ${input.chatId} was not found`);
|
|
4640
|
+
const trigger = this.getMessage(input.messageId);
|
|
4641
|
+
if (!trigger) throw new Error(`message ${input.messageId} was not found`);
|
|
4642
|
+
if (trigger.chat_id !== room.id) throw new Error(`message ${input.messageId} does not belong to room ${room.id}`);
|
|
4643
|
+
|
|
4644
|
+
const participant = this.getRoomParticipant(room.id, input.agent);
|
|
4645
|
+
const cursor = participant?.delivery_cursor_message_id ?? 0;
|
|
4646
|
+
const previous = this.db.query(`
|
|
4647
|
+
SELECT COALESCE(MAX(message_id), 0) AS message_id
|
|
4648
|
+
FROM message_deliveries
|
|
4649
|
+
WHERE agent = ? AND chat_id = ? AND message_id < ?
|
|
4650
|
+
`).get(input.agent, room.id, trigger.id) as { message_id: number } | null;
|
|
4651
|
+
const startMessageId = Math.max(Number(cursor ?? 0), Number(previous?.message_id ?? 0), 0);
|
|
4652
|
+
const total = this.db.query(`
|
|
4653
|
+
SELECT COUNT(*) AS count
|
|
4654
|
+
FROM messages
|
|
4655
|
+
WHERE chat_id = ? AND id > ? AND id <= ?
|
|
4656
|
+
`).get(room.id, startMessageId, trigger.id) as { count: number };
|
|
4657
|
+
const limit = limitValue(input.limit, 50);
|
|
4658
|
+
const rows = this.db.query(`
|
|
4659
|
+
SELECT *
|
|
4660
|
+
FROM (
|
|
4661
|
+
SELECT m.*, c.name AS chat_name
|
|
4662
|
+
FROM messages m
|
|
4663
|
+
JOIN chats c ON c.id = m.chat_id
|
|
4664
|
+
WHERE m.chat_id = ? AND m.id > ? AND m.id <= ?
|
|
4665
|
+
ORDER BY m.id DESC
|
|
4666
|
+
LIMIT ?
|
|
4667
|
+
)
|
|
4668
|
+
ORDER BY id ASC
|
|
4669
|
+
`).all(room.id, startMessageId, trigger.id, limit) as Record<string, unknown>[];
|
|
4670
|
+
const totalCount = Number(total.count ?? 0);
|
|
4671
|
+
return {
|
|
4672
|
+
startMessageId,
|
|
4673
|
+
totalCount,
|
|
4674
|
+
omittedCount: Math.max(0, totalCount - rows.length),
|
|
4675
|
+
messages: this.attachMessageAttachments(rows.map(rowToMessage)),
|
|
4676
|
+
savedMessages: this.listRoomSavedMessages({ roomId: room.id, limit: 10 }),
|
|
4677
|
+
};
|
|
2148
4678
|
}
|
|
2149
4679
|
|
|
2150
4680
|
listInbox(agent: string, after = 0, limit = 50): Message[] {
|
|
@@ -2159,7 +4689,7 @@ export class MessageStore {
|
|
|
2159
4689
|
LIMIT ?
|
|
2160
4690
|
`).all(after, agent, agent, limitValue(limit, 50)) as Record<string, unknown>[];
|
|
2161
4691
|
|
|
2162
|
-
return rows.map(rowToMessage);
|
|
4692
|
+
return this.attachMessageAttachments(rows.map(rowToMessage));
|
|
2163
4693
|
}
|
|
2164
4694
|
|
|
2165
4695
|
getComputer(id: string): Computer | null {
|
|
@@ -2176,6 +4706,79 @@ export class MessageStore {
|
|
|
2176
4706
|
return rows.map(rowToComputer);
|
|
2177
4707
|
}
|
|
2178
4708
|
|
|
4709
|
+
updateComputerName(id: string, name: string): Computer {
|
|
4710
|
+
const computerId = id.trim();
|
|
4711
|
+
const displayName = name.trim();
|
|
4712
|
+
if (!computerId) throw new Error('computer id is required');
|
|
4713
|
+
if (!displayName) throw new Error('computer name is required');
|
|
4714
|
+
this.db.query(`
|
|
4715
|
+
UPDATE computers
|
|
4716
|
+
SET name = ?, updated_at = datetime('now')
|
|
4717
|
+
WHERE id = ?
|
|
4718
|
+
`).run(displayName, computerId);
|
|
4719
|
+
const computer = this.getComputer(computerId);
|
|
4720
|
+
if (!computer) throw new Error('computer not found');
|
|
4721
|
+
return computer;
|
|
4722
|
+
}
|
|
4723
|
+
|
|
4724
|
+
regenerateComputerCommand(id: string, input: RegenerateComputerCommandInput = {}): ProvisionedComputer {
|
|
4725
|
+
const computer = this.getComputer(id);
|
|
4726
|
+
if (!computer) throw new Error('computer not found');
|
|
4727
|
+
const apiKey = `sk_machine_${randomBytes(32).toString('hex')}`;
|
|
4728
|
+
this.db.query(`
|
|
4729
|
+
UPDATE computers
|
|
4730
|
+
SET credential_hash = ?, updated_at = datetime('now')
|
|
4731
|
+
WHERE id = ?
|
|
4732
|
+
`).run(hashSecret(apiKey), computer.id);
|
|
4733
|
+
const serverUrl = input.serverUrl?.trim() || 'http://127.0.0.1:4127';
|
|
4734
|
+
const command = daemonStartCommand({ commandPrefix: input.packageName, serverUrl, apiKey, name: computer.name });
|
|
4735
|
+
return { computer: this.getComputer(computer.id)!, api_key: apiKey, command };
|
|
4736
|
+
}
|
|
4737
|
+
|
|
4738
|
+
getComputerDeletionImpact(id: string): ComputerDeletionImpact | null {
|
|
4739
|
+
const computer = this.getComputer(id);
|
|
4740
|
+
if (!computer) return null;
|
|
4741
|
+
return {
|
|
4742
|
+
computer,
|
|
4743
|
+
projects: this.listProjectsForComputer(computer.id),
|
|
4744
|
+
assignments: this.listComputerAgentAssignments(computer.id),
|
|
4745
|
+
};
|
|
4746
|
+
}
|
|
4747
|
+
|
|
4748
|
+
deleteComputer(id: string, options: { removeProjects?: boolean; removeAssignments?: boolean } = {}): ComputerDeletionResult {
|
|
4749
|
+
const computer = this.getComputer(id);
|
|
4750
|
+
if (!computer) throw new Error('computer not found');
|
|
4751
|
+
const impact = this.getComputerDeletionImpact(computer.id)!;
|
|
4752
|
+
if (impact.projects.length > 0 && options.removeProjects !== true) throw new Error('COMPUTER_HAS_PROJECTS');
|
|
4753
|
+
if (impact.assignments.length > 0 && options.removeAssignments !== true) throw new Error('COMPUTER_HAS_ASSIGNMENTS');
|
|
4754
|
+
let projectsRemoved = 0;
|
|
4755
|
+
let assignmentsRemoved = 0;
|
|
4756
|
+
let connectionsRemoved = 0;
|
|
4757
|
+
this.db.transaction(() => {
|
|
4758
|
+
if (options.removeProjects === true) {
|
|
4759
|
+
const projectIds = impact.projects.map((project) => project.id);
|
|
4760
|
+
for (const projectId of projectIds) {
|
|
4761
|
+
this.db.query('DELETE FROM chats WHERE project_id = ?').run(projectId);
|
|
4762
|
+
const result = this.db.query('DELETE FROM projects WHERE id = ?').run(projectId);
|
|
4763
|
+
projectsRemoved += result.changes;
|
|
4764
|
+
}
|
|
4765
|
+
}
|
|
4766
|
+
if (options.removeAssignments === true) {
|
|
4767
|
+
const result = this.db.query('DELETE FROM computer_agent_assignments WHERE computer_id = ?').run(computer.id);
|
|
4768
|
+
assignmentsRemoved += result.changes;
|
|
4769
|
+
}
|
|
4770
|
+
const connectionsResult = this.db.query('DELETE FROM computer_connections WHERE computer_id = ?').run(computer.id);
|
|
4771
|
+
connectionsRemoved += connectionsResult.changes;
|
|
4772
|
+
this.db.query('DELETE FROM computers WHERE id = ?').run(computer.id);
|
|
4773
|
+
})();
|
|
4774
|
+
return {
|
|
4775
|
+
computer,
|
|
4776
|
+
projects_removed: projectsRemoved,
|
|
4777
|
+
assignments_removed: assignmentsRemoved,
|
|
4778
|
+
connections_removed: connectionsRemoved,
|
|
4779
|
+
};
|
|
4780
|
+
}
|
|
4781
|
+
|
|
2179
4782
|
private getComputerByCredential(secret: string): Computer | null {
|
|
2180
4783
|
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;
|
|
2181
4784
|
return row ? rowToComputer(row) : null;
|
|
@@ -2186,6 +4789,17 @@ export class MessageStore {
|
|
|
2186
4789
|
return row ? rowToComputerConnection(row) : null;
|
|
2187
4790
|
}
|
|
2188
4791
|
|
|
4792
|
+
listActiveComputerConnections(): ComputerConnection[] {
|
|
4793
|
+
const rows = this.db.query(`
|
|
4794
|
+
SELECT cc.*
|
|
4795
|
+
FROM computer_connections cc
|
|
4796
|
+
JOIN computers c ON c.active_connection_id = cc.id
|
|
4797
|
+
WHERE cc.status = 'active' AND c.status = 'online'
|
|
4798
|
+
ORDER BY datetime(cc.last_heartbeat_at) DESC, cc.id ASC
|
|
4799
|
+
`).all() as Record<string, unknown>[];
|
|
4800
|
+
return rows.map(rowToComputerConnection);
|
|
4801
|
+
}
|
|
4802
|
+
|
|
2189
4803
|
provisionComputer(input: ProvisionComputerInput = {}): ProvisionedComputer {
|
|
2190
4804
|
const id = `machine_${randomBytes(8).toString('hex')}`;
|
|
2191
4805
|
const apiKey = `sk_machine_${randomBytes(32).toString('hex')}`;
|
|
@@ -2195,9 +4809,8 @@ export class MessageStore {
|
|
|
2195
4809
|
VALUES (?, ?, ?, 'offline', NULL, NULL, datetime('now'))
|
|
2196
4810
|
`).run(id, name, hashSecret(apiKey));
|
|
2197
4811
|
|
|
2198
|
-
const packageName = input.packageName?.trim() || '@controlflow-ai/daemon@latest';
|
|
2199
4812
|
const serverUrl = input.serverUrl?.trim() || 'http://127.0.0.1:4127';
|
|
2200
|
-
const command =
|
|
4813
|
+
const command = daemonStartCommand({ commandPrefix: input.packageName, serverUrl, apiKey, name });
|
|
2201
4814
|
return { computer: this.getComputer(id)!, api_key: apiKey, command };
|
|
2202
4815
|
}
|
|
2203
4816
|
|
|
@@ -2209,13 +4822,14 @@ export class MessageStore {
|
|
|
2209
4822
|
if (!computerId) throw new Error('computer_id is required');
|
|
2210
4823
|
if (!secret) throw new Error('computer secret is required');
|
|
2211
4824
|
const credentialHash = hashSecret(secret);
|
|
2212
|
-
const existing = this.db.query('SELECT credential_hash FROM computers WHERE id = ?').get(computerId) as { credential_hash: string } | null;
|
|
4825
|
+
const existing = this.db.query('SELECT credential_hash, name FROM computers WHERE id = ?').get(computerId) as { credential_hash: string; name: string } | null;
|
|
2213
4826
|
if (existing && existing.credential_hash !== credentialHash) {
|
|
2214
4827
|
throw new Error('invalid computer credential');
|
|
2215
4828
|
}
|
|
2216
4829
|
|
|
2217
4830
|
const connectionId = crypto.randomUUID();
|
|
2218
4831
|
const token = generateConnectionToken();
|
|
4832
|
+
const localControlToken = generateConnectionToken();
|
|
2219
4833
|
const tokenHash = hashSecret(token);
|
|
2220
4834
|
const revokedRows = this.db.query(`
|
|
2221
4835
|
SELECT id FROM computer_connections
|
|
@@ -2224,7 +4838,8 @@ export class MessageStore {
|
|
|
2224
4838
|
const revokedConnectionIds = revokedRows.map((row) => row.id);
|
|
2225
4839
|
const epochRow = this.db.query('SELECT COALESCE(MAX(epoch), 0) + 1 AS epoch FROM computer_connections WHERE computer_id = ?').get(computerId) as { epoch: number };
|
|
2226
4840
|
const epoch = Number(epochRow.epoch);
|
|
2227
|
-
const
|
|
4841
|
+
const providedName = input.name?.trim() || null;
|
|
4842
|
+
const name = providedName ?? existing?.name ?? computerId;
|
|
2228
4843
|
|
|
2229
4844
|
this.db.transaction(() => {
|
|
2230
4845
|
if (!existing) {
|
|
@@ -2235,9 +4850,13 @@ export class MessageStore {
|
|
|
2235
4850
|
} else {
|
|
2236
4851
|
this.db.query(`
|
|
2237
4852
|
UPDATE computers
|
|
2238
|
-
SET name =
|
|
4853
|
+
SET name = CASE WHEN ? IS NOT NULL THEN ? ELSE name END,
|
|
4854
|
+
status = 'online',
|
|
4855
|
+
active_connection_id = ?,
|
|
4856
|
+
last_seen_at = datetime('now'),
|
|
4857
|
+
updated_at = datetime('now')
|
|
2239
4858
|
WHERE id = ?
|
|
2240
|
-
`).run(
|
|
4859
|
+
`).run(providedName, providedName, connectionId, computerId);
|
|
2241
4860
|
}
|
|
2242
4861
|
this.db.query(`
|
|
2243
4862
|
UPDATE computer_connections
|
|
@@ -2270,9 +4889,9 @@ export class MessageStore {
|
|
|
2270
4889
|
}
|
|
2271
4890
|
}
|
|
2272
4891
|
this.db.query(`
|
|
2273
|
-
INSERT INTO computer_connections (id, computer_id, token_hash, epoch, status, connected_at, last_heartbeat_at)
|
|
2274
|
-
VALUES (?, ?, ?, ?, 'active', datetime('now'), datetime('now'))
|
|
2275
|
-
`).run(connectionId, computerId, tokenHash, epoch);
|
|
4892
|
+
INSERT INTO computer_connections (id, computer_id, token_hash, local_control_token, epoch, status, connected_at, last_heartbeat_at)
|
|
4893
|
+
VALUES (?, ?, ?, ?, ?, 'active', datetime('now'), datetime('now'))
|
|
4894
|
+
`).run(connectionId, computerId, tokenHash, localControlToken, epoch);
|
|
2276
4895
|
this.db.query(`
|
|
2277
4896
|
INSERT INTO daemon_instances (id, name, host, local_url, server_url, status, last_seen_at)
|
|
2278
4897
|
VALUES (?, ?, ?, ?, ?, 'online', datetime('now'))
|
|
@@ -2303,11 +4922,45 @@ export class MessageStore {
|
|
|
2303
4922
|
computer: this.getComputer(computerId)!,
|
|
2304
4923
|
connection: this.getComputerConnection(connectionId)!,
|
|
2305
4924
|
token,
|
|
4925
|
+
localControlToken,
|
|
2306
4926
|
daemon: this.getDaemon(connectionId)!,
|
|
2307
4927
|
agents: this.listComputerAgentAssignments(computerId),
|
|
2308
4928
|
};
|
|
2309
4929
|
}
|
|
2310
4930
|
|
|
4931
|
+
getComputerLocalControl(computerId: string): { computer: Computer; connection: ComputerConnection; daemon: DaemonInstance; token: string } | null {
|
|
4932
|
+
const computer = this.getComputer(computerId);
|
|
4933
|
+
if (!computer || computer.status !== 'online' || !computer.active_connection_id) return null;
|
|
4934
|
+
const connection = this.getComputerConnection(computer.active_connection_id);
|
|
4935
|
+
if (!connection || connection.status !== 'active') return null;
|
|
4936
|
+
const row = this.db.query(`
|
|
4937
|
+
SELECT local_control_token
|
|
4938
|
+
FROM computer_connections
|
|
4939
|
+
WHERE id = ? AND computer_id = ? AND status = 'active'
|
|
4940
|
+
LIMIT 1
|
|
4941
|
+
`).get(connection.id, computer.id) as { local_control_token: string | null } | null;
|
|
4942
|
+
const token = row?.local_control_token?.trim();
|
|
4943
|
+
if (!token) return null;
|
|
4944
|
+
const daemon = this.getDaemon(connection.id);
|
|
4945
|
+
if (!daemon || daemon.status !== 'online' || !daemon.local_url.trim()) return null;
|
|
4946
|
+
return { computer, connection, daemon, token };
|
|
4947
|
+
}
|
|
4948
|
+
|
|
4949
|
+
getComputerWorkload(computerId: string): ComputerWorkload | null {
|
|
4950
|
+
const computer = this.getComputer(computerId);
|
|
4951
|
+
if (!computer) return null;
|
|
4952
|
+
const connection = computer.active_connection_id ? this.getComputerConnection(computer.active_connection_id) : null;
|
|
4953
|
+
const daemon = connection ? this.getDaemon(connection.id) : null;
|
|
4954
|
+
return {
|
|
4955
|
+
computer,
|
|
4956
|
+
connection,
|
|
4957
|
+
daemon,
|
|
4958
|
+
assignments: this.listComputerAgentAssignments(computer.id),
|
|
4959
|
+
runs: this.listRunsForComputer(computer.id, 20),
|
|
4960
|
+
deliveries: this.listActiveDeliveriesForComputer(computer.id, 20),
|
|
4961
|
+
};
|
|
4962
|
+
}
|
|
4963
|
+
|
|
2311
4964
|
closeStaleComputerConnections(timeoutMs: number, now = new Date()): number {
|
|
2312
4965
|
if (!Number.isFinite(timeoutMs) || timeoutMs < 0) throw new Error('timeoutMs must be non-negative');
|
|
2313
4966
|
const cutoff = new Date(now.getTime() - timeoutMs).toISOString();
|
|
@@ -2317,7 +4970,6 @@ export class MessageStore {
|
|
|
2317
4970
|
JOIN computers c ON c.active_connection_id = cc.id
|
|
2318
4971
|
WHERE cc.status = 'active' AND datetime(cc.last_heartbeat_at) < datetime(?)
|
|
2319
4972
|
`).all(cutoff) as Array<{ id: string; computer_id: string }>;
|
|
2320
|
-
if (staleRows.length === 0) return 0;
|
|
2321
4973
|
this.db.transaction(() => {
|
|
2322
4974
|
for (const row of staleRows) {
|
|
2323
4975
|
this.db.query(`
|
|
@@ -2330,7 +4982,103 @@ export class MessageStore {
|
|
|
2330
4982
|
SET status = 'offline', active_connection_id = NULL, updated_at = datetime('now')
|
|
2331
4983
|
WHERE id = ? AND active_connection_id = ?
|
|
2332
4984
|
`).run(row.computer_id, row.id);
|
|
4985
|
+
this.db.query(`
|
|
4986
|
+
UPDATE agent_runs
|
|
4987
|
+
SET status = 'killed',
|
|
4988
|
+
action = NULL,
|
|
4989
|
+
ended_at = COALESCE(ended_at, datetime('now')),
|
|
4990
|
+
updated_at = datetime('now'),
|
|
4991
|
+
output = CASE WHEN output = '' THEN 'computer connection heartbeat timed out' ELSE output END
|
|
4992
|
+
WHERE computer_id = ? AND status = 'running'
|
|
4993
|
+
`).run(row.computer_id);
|
|
4994
|
+
this.db.query(`
|
|
4995
|
+
UPDATE message_deliveries
|
|
4996
|
+
SET status = 'pending',
|
|
4997
|
+
daemon_id = NULL,
|
|
4998
|
+
connection_id = NULL,
|
|
4999
|
+
claim_token = NULL,
|
|
5000
|
+
lease_until = NULL,
|
|
5001
|
+
run_id = NULL,
|
|
5002
|
+
last_error = '',
|
|
5003
|
+
claimed_at = NULL
|
|
5004
|
+
WHERE status IN ('claimed', 'processing_completed')
|
|
5005
|
+
AND (
|
|
5006
|
+
connection_id = ?
|
|
5007
|
+
OR (connection_id IS NULL AND daemon_id = ?)
|
|
5008
|
+
)
|
|
5009
|
+
`).run(row.id, row.id);
|
|
2333
5010
|
}
|
|
5011
|
+
this.db.query(`
|
|
5012
|
+
UPDATE agent_runs
|
|
5013
|
+
SET status = 'killed',
|
|
5014
|
+
action = NULL,
|
|
5015
|
+
ended_at = COALESCE(ended_at, datetime('now')),
|
|
5016
|
+
updated_at = datetime('now'),
|
|
5017
|
+
output = CASE WHEN output = '' THEN 'computer connection is no longer active' ELSE output END
|
|
5018
|
+
WHERE status = 'running'
|
|
5019
|
+
AND (
|
|
5020
|
+
(
|
|
5021
|
+
connection_id IS NOT NULL
|
|
5022
|
+
AND NOT EXISTS (
|
|
5023
|
+
SELECT 1
|
|
5024
|
+
FROM computer_connections cc
|
|
5025
|
+
JOIN computers c ON c.active_connection_id = cc.id
|
|
5026
|
+
WHERE cc.id = agent_runs.connection_id
|
|
5027
|
+
AND cc.status = 'active'
|
|
5028
|
+
AND c.status = 'online'
|
|
5029
|
+
)
|
|
5030
|
+
)
|
|
5031
|
+
OR (
|
|
5032
|
+
connection_id IS NULL
|
|
5033
|
+
AND computer_id IS NOT NULL
|
|
5034
|
+
AND NOT EXISTS (
|
|
5035
|
+
SELECT 1
|
|
5036
|
+
FROM computers c
|
|
5037
|
+
JOIN computer_connections cc ON cc.id = c.active_connection_id
|
|
5038
|
+
WHERE c.id = agent_runs.computer_id
|
|
5039
|
+
AND c.status = 'online'
|
|
5040
|
+
AND cc.status = 'active'
|
|
5041
|
+
)
|
|
5042
|
+
)
|
|
5043
|
+
)
|
|
5044
|
+
`).run();
|
|
5045
|
+
this.db.query(`
|
|
5046
|
+
UPDATE message_deliveries
|
|
5047
|
+
SET status = 'pending',
|
|
5048
|
+
daemon_id = NULL,
|
|
5049
|
+
connection_id = NULL,
|
|
5050
|
+
claim_token = NULL,
|
|
5051
|
+
lease_until = NULL,
|
|
5052
|
+
run_id = NULL,
|
|
5053
|
+
last_error = '',
|
|
5054
|
+
claimed_at = NULL
|
|
5055
|
+
WHERE status IN ('claimed', 'processing_completed')
|
|
5056
|
+
AND (
|
|
5057
|
+
(
|
|
5058
|
+
connection_id IS NOT NULL
|
|
5059
|
+
AND NOT EXISTS (
|
|
5060
|
+
SELECT 1
|
|
5061
|
+
FROM computer_connections cc
|
|
5062
|
+
JOIN computers c ON c.active_connection_id = cc.id
|
|
5063
|
+
WHERE cc.id = message_deliveries.connection_id
|
|
5064
|
+
AND cc.status = 'active'
|
|
5065
|
+
AND c.status = 'online'
|
|
5066
|
+
)
|
|
5067
|
+
)
|
|
5068
|
+
OR (
|
|
5069
|
+
connection_id IS NULL
|
|
5070
|
+
AND daemon_id IN (SELECT id FROM computer_connections)
|
|
5071
|
+
AND NOT EXISTS (
|
|
5072
|
+
SELECT 1
|
|
5073
|
+
FROM computer_connections cc
|
|
5074
|
+
JOIN computers c ON c.active_connection_id = cc.id
|
|
5075
|
+
WHERE cc.id = message_deliveries.daemon_id
|
|
5076
|
+
AND cc.status = 'active'
|
|
5077
|
+
AND c.status = 'online'
|
|
5078
|
+
)
|
|
5079
|
+
)
|
|
5080
|
+
)
|
|
5081
|
+
`).run();
|
|
2334
5082
|
})();
|
|
2335
5083
|
return staleRows.length;
|
|
2336
5084
|
}
|
|
@@ -2420,6 +5168,114 @@ export class MessageStore {
|
|
|
2420
5168
|
return row !== null;
|
|
2421
5169
|
}
|
|
2422
5170
|
|
|
5171
|
+
listDaemonAgents(daemonId: string): string[] {
|
|
5172
|
+
const rows = this.db.query(`
|
|
5173
|
+
SELECT agent FROM daemon_agents
|
|
5174
|
+
WHERE daemon_id = ? AND status = 'online'
|
|
5175
|
+
ORDER BY agent ASC
|
|
5176
|
+
`).all(daemonId) as Array<{ agent: string }>;
|
|
5177
|
+
return rows.map((row) => row.agent);
|
|
5178
|
+
}
|
|
5179
|
+
|
|
5180
|
+
listPendingDeliveryAgentsForConnection(connectionId: string): Array<{ agent: string; pending: number }> {
|
|
5181
|
+
this.releaseExpiredDeliveries();
|
|
5182
|
+
const rows = this.db.query(`
|
|
5183
|
+
SELECT md.agent AS agent, COUNT(*) AS pending
|
|
5184
|
+
FROM message_deliveries md
|
|
5185
|
+
JOIN daemon_agents da ON da.agent = md.agent
|
|
5186
|
+
JOIN computer_connections cc ON cc.id = da.daemon_id
|
|
5187
|
+
JOIN computers c ON c.active_connection_id = cc.id
|
|
5188
|
+
WHERE da.daemon_id = ?
|
|
5189
|
+
AND da.status = 'online'
|
|
5190
|
+
AND cc.status = 'active'
|
|
5191
|
+
AND c.status = 'online'
|
|
5192
|
+
AND md.status = 'pending'
|
|
5193
|
+
GROUP BY md.agent
|
|
5194
|
+
ORDER BY MIN(datetime(md.created_at)) ASC, md.agent ASC
|
|
5195
|
+
`).all(connectionId.trim()) as Array<{ agent: string; pending: number }>;
|
|
5196
|
+
return rows.map((row) => ({ agent: row.agent, pending: Number(row.pending) }));
|
|
5197
|
+
}
|
|
5198
|
+
|
|
5199
|
+
listDeliveryBacklogForConnection(connectionId: string): DeliveryBacklogSummary[] {
|
|
5200
|
+
this.releaseExpiredDeliveries();
|
|
5201
|
+
const rows = this.db.query(`
|
|
5202
|
+
SELECT
|
|
5203
|
+
md.agent AS agent,
|
|
5204
|
+
SUM(CASE WHEN md.status = 'pending' THEN 1 ELSE 0 END) AS pending,
|
|
5205
|
+
SUM(CASE WHEN md.status = 'claimed' THEN 1 ELSE 0 END) AS claimed,
|
|
5206
|
+
SUM(CASE WHEN md.status = 'processing_completed' THEN 1 ELSE 0 END) AS processing_completed,
|
|
5207
|
+
SUM(CASE
|
|
5208
|
+
WHEN md.status IN ('claimed', 'processing_completed')
|
|
5209
|
+
AND md.lease_until IS NOT NULL
|
|
5210
|
+
AND datetime(md.lease_until) <= datetime('now')
|
|
5211
|
+
THEN 1 ELSE 0
|
|
5212
|
+
END) AS expired_active
|
|
5213
|
+
FROM message_deliveries md
|
|
5214
|
+
JOIN daemon_agents da ON da.agent = md.agent
|
|
5215
|
+
JOIN computer_connections cc ON cc.id = da.daemon_id
|
|
5216
|
+
JOIN computers c ON c.active_connection_id = cc.id
|
|
5217
|
+
WHERE da.daemon_id = ?
|
|
5218
|
+
AND da.status = 'online'
|
|
5219
|
+
AND cc.status = 'active'
|
|
5220
|
+
AND c.status = 'online'
|
|
5221
|
+
AND md.status IN ('pending', 'claimed', 'processing_completed')
|
|
5222
|
+
GROUP BY md.agent
|
|
5223
|
+
ORDER BY MIN(datetime(md.created_at)) ASC, md.agent ASC
|
|
5224
|
+
`).all(connectionId.trim()) as Array<{
|
|
5225
|
+
agent: string;
|
|
5226
|
+
pending: number;
|
|
5227
|
+
claimed: number;
|
|
5228
|
+
processing_completed: number;
|
|
5229
|
+
expired_active: number;
|
|
5230
|
+
}>;
|
|
5231
|
+
return rows.map((row) => ({
|
|
5232
|
+
agent: row.agent,
|
|
5233
|
+
pending: Number(row.pending),
|
|
5234
|
+
claimed: Number(row.claimed),
|
|
5235
|
+
processing_completed: Number(row.processing_completed),
|
|
5236
|
+
expired_active: Number(row.expired_active),
|
|
5237
|
+
}));
|
|
5238
|
+
}
|
|
5239
|
+
|
|
5240
|
+
releaseExpiredDeliveries(now = new Date()): number {
|
|
5241
|
+
const result = this.db.query(`
|
|
5242
|
+
UPDATE message_deliveries
|
|
5243
|
+
SET status = 'pending',
|
|
5244
|
+
daemon_id = NULL,
|
|
5245
|
+
connection_id = NULL,
|
|
5246
|
+
claim_token = NULL,
|
|
5247
|
+
lease_until = NULL,
|
|
5248
|
+
run_id = NULL,
|
|
5249
|
+
last_error = '',
|
|
5250
|
+
claimed_at = NULL
|
|
5251
|
+
WHERE status IN ('claimed', 'processing_completed')
|
|
5252
|
+
AND lease_until IS NOT NULL
|
|
5253
|
+
AND datetime(lease_until) <= datetime(?)
|
|
5254
|
+
AND NOT EXISTS (
|
|
5255
|
+
SELECT 1
|
|
5256
|
+
FROM agent_runs r
|
|
5257
|
+
WHERE r.delivery_id = message_deliveries.id
|
|
5258
|
+
AND r.status = 'running'
|
|
5259
|
+
)
|
|
5260
|
+
`).run(now.toISOString());
|
|
5261
|
+
return result.changes;
|
|
5262
|
+
}
|
|
5263
|
+
|
|
5264
|
+
listOnlineDaemonConnectionsForAgent(agent: string): string[] {
|
|
5265
|
+
const rows = this.db.query(`
|
|
5266
|
+
SELECT da.daemon_id AS connection_id
|
|
5267
|
+
FROM daemon_agents da
|
|
5268
|
+
JOIN computer_connections cc ON cc.id = da.daemon_id
|
|
5269
|
+
JOIN computers c ON c.active_connection_id = cc.id
|
|
5270
|
+
WHERE da.agent = ?
|
|
5271
|
+
AND da.status = 'online'
|
|
5272
|
+
AND cc.status = 'active'
|
|
5273
|
+
AND c.status = 'online'
|
|
5274
|
+
ORDER BY datetime(da.last_seen_at) DESC
|
|
5275
|
+
`).all(agent.trim()) as Array<{ connection_id: string }>;
|
|
5276
|
+
return rows.map((row) => row.connection_id);
|
|
5277
|
+
}
|
|
5278
|
+
|
|
2423
5279
|
hasDaemonAgent(agent: string): boolean {
|
|
2424
5280
|
const row = this.db.query(`
|
|
2425
5281
|
SELECT 1 FROM daemon_agents
|
|
@@ -2429,13 +5285,90 @@ export class MessageStore {
|
|
|
2429
5285
|
return row !== null;
|
|
2430
5286
|
}
|
|
2431
5287
|
|
|
2432
|
-
listDeliveries(agent: string, status = 'pending', limit = 50): MessageDelivery[] {
|
|
5288
|
+
listDeliveries(agent: string, status = 'pending', limit = 50, options: { distinctChat?: boolean; excludeRunningComputerId?: string | null; connectionId?: string | null } = {}): MessageDelivery[] {
|
|
5289
|
+
if (status === 'pending') this.releaseExpiredDeliveries();
|
|
5290
|
+
const excludeRunningComputerId = options.excludeRunningComputerId?.trim() || null;
|
|
5291
|
+
const rows = options.distinctChat
|
|
5292
|
+
? excludeRunningComputerId
|
|
5293
|
+
? this.db.query(`
|
|
5294
|
+
SELECT md.*
|
|
5295
|
+
FROM message_deliveries md
|
|
5296
|
+
INNER JOIN (
|
|
5297
|
+
SELECT pending.chat_id, MIN(pending.rowid) AS first_rowid
|
|
5298
|
+
FROM message_deliveries pending
|
|
5299
|
+
WHERE pending.agent = ? AND pending.status = ?
|
|
5300
|
+
AND NOT EXISTS (
|
|
5301
|
+
SELECT 1
|
|
5302
|
+
FROM agent_runs ar
|
|
5303
|
+
WHERE ar.computer_id = ?
|
|
5304
|
+
AND ar.agent = pending.agent
|
|
5305
|
+
AND ar.status = 'running'
|
|
5306
|
+
AND EXISTS (
|
|
5307
|
+
SELECT 1
|
|
5308
|
+
FROM messages running_message
|
|
5309
|
+
WHERE running_message.id = ar.message_id
|
|
5310
|
+
AND running_message.chat_id = pending.chat_id
|
|
5311
|
+
)
|
|
5312
|
+
)
|
|
5313
|
+
GROUP BY pending.chat_id
|
|
5314
|
+
) first ON md.rowid = first.first_rowid
|
|
5315
|
+
WHERE md.agent = ? AND md.status = ?
|
|
5316
|
+
ORDER BY datetime(md.created_at) ASC, md.rowid ASC
|
|
5317
|
+
LIMIT ?
|
|
5318
|
+
`).all(agent, status, excludeRunningComputerId, agent, status, limitValue(limit, 50)) as Record<string, unknown>[]
|
|
5319
|
+
: this.db.query(`
|
|
5320
|
+
SELECT md.*
|
|
5321
|
+
FROM message_deliveries md
|
|
5322
|
+
INNER JOIN (
|
|
5323
|
+
SELECT chat_id, MIN(rowid) AS first_rowid
|
|
5324
|
+
FROM message_deliveries
|
|
5325
|
+
WHERE agent = ? AND status = ?
|
|
5326
|
+
GROUP BY chat_id
|
|
5327
|
+
) first ON md.rowid = first.first_rowid
|
|
5328
|
+
WHERE md.agent = ? AND md.status = ?
|
|
5329
|
+
ORDER BY datetime(md.created_at) ASC, md.rowid ASC
|
|
5330
|
+
LIMIT ?
|
|
5331
|
+
`).all(agent, status, agent, status, limitValue(limit, 50)) as Record<string, unknown>[]
|
|
5332
|
+
: this.db.query(`
|
|
5333
|
+
SELECT * FROM message_deliveries
|
|
5334
|
+
WHERE agent = ? AND status = ?
|
|
5335
|
+
ORDER BY datetime(created_at) ASC, rowid ASC
|
|
5336
|
+
LIMIT ?
|
|
5337
|
+
`).all(agent, status, limitValue(limit, 50)) as Record<string, unknown>[];
|
|
5338
|
+
return rows.map(rowToDelivery);
|
|
5339
|
+
}
|
|
5340
|
+
|
|
5341
|
+
listRecentDeliveriesForAgent(agent: string, limit = 20): MessageDelivery[] {
|
|
5342
|
+
this.releaseExpiredDeliveries();
|
|
2433
5343
|
const rows = this.db.query(`
|
|
2434
|
-
SELECT *
|
|
2435
|
-
|
|
2436
|
-
|
|
5344
|
+
SELECT *
|
|
5345
|
+
FROM message_deliveries
|
|
5346
|
+
WHERE agent = ?
|
|
5347
|
+
ORDER BY datetime(created_at) DESC, rowid DESC
|
|
5348
|
+
LIMIT ?
|
|
5349
|
+
`).all(agent.trim(), limitValue(limit, 20)) as Record<string, unknown>[];
|
|
5350
|
+
return rows.map(rowToDelivery);
|
|
5351
|
+
}
|
|
5352
|
+
|
|
5353
|
+
listActiveDeliveriesForComputer(computerId: string, limit = 20): MessageDelivery[] {
|
|
5354
|
+
this.releaseExpiredDeliveries();
|
|
5355
|
+
const rows = this.db.query(`
|
|
5356
|
+
SELECT md.*, c.name AS chat_name
|
|
5357
|
+
FROM message_deliveries md
|
|
5358
|
+
JOIN chats c ON c.id = md.chat_id
|
|
5359
|
+
WHERE md.status IN ('pending', 'claimed', 'processing_completed')
|
|
5360
|
+
AND (
|
|
5361
|
+
md.connection_id IN (SELECT id FROM computer_connections WHERE computer_id = ?)
|
|
5362
|
+
OR md.daemon_id IN (SELECT id FROM computer_connections WHERE computer_id = ?)
|
|
5363
|
+
OR (md.status = 'pending' AND md.agent IN (
|
|
5364
|
+
SELECT agent
|
|
5365
|
+
FROM computer_agent_assignments
|
|
5366
|
+
WHERE computer_id = ? AND status = 'active'
|
|
5367
|
+
))
|
|
5368
|
+
)
|
|
5369
|
+
ORDER BY datetime(md.created_at) DESC, md.rowid DESC
|
|
2437
5370
|
LIMIT ?
|
|
2438
|
-
`).all(
|
|
5371
|
+
`).all(computerId.trim(), computerId.trim(), computerId.trim(), limitValue(limit, 20)) as Record<string, unknown>[];
|
|
2439
5372
|
return rows.map(rowToDelivery);
|
|
2440
5373
|
}
|
|
2441
5374
|
|
|
@@ -2567,11 +5500,22 @@ export class MessageStore {
|
|
|
2567
5500
|
}
|
|
2568
5501
|
|
|
2569
5502
|
canSendWebMessageToRoom(input: { room: Chat; sender: string }): boolean {
|
|
5503
|
+
if (input.room.status === 'archived') return false;
|
|
2570
5504
|
if (input.room.provider === 'web') return true;
|
|
2571
5505
|
if (!this.getAgent(input.sender)) return false;
|
|
2572
5506
|
if (input.room.provider === 'lark') {
|
|
2573
|
-
const
|
|
2574
|
-
|
|
5507
|
+
const row = this.db.query(`
|
|
5508
|
+
SELECT 1
|
|
5509
|
+
FROM channel_conversations cc
|
|
5510
|
+
JOIN channel_accounts ca ON ca.id = cc.channel_account_id
|
|
5511
|
+
WHERE cc.lock_chat_id = ?
|
|
5512
|
+
AND cc.audit_only = 0
|
|
5513
|
+
AND ca.channel = 'lark'
|
|
5514
|
+
AND ca.status = 'active'
|
|
5515
|
+
AND ca.agent = ?
|
|
5516
|
+
LIMIT 1
|
|
5517
|
+
`).get(input.room.id, input.sender) as Record<string, unknown> | null;
|
|
5518
|
+
return row !== null;
|
|
2575
5519
|
}
|
|
2576
5520
|
return false;
|
|
2577
5521
|
}
|
|
@@ -2655,6 +5599,17 @@ export class MessageStore {
|
|
|
2655
5599
|
return rows.map(rowToSession);
|
|
2656
5600
|
}
|
|
2657
5601
|
|
|
5602
|
+
listSessionsForAgent(agent: string, limit = 10): AgentSession[] {
|
|
5603
|
+
const rows = this.db.query(`
|
|
5604
|
+
SELECT *
|
|
5605
|
+
FROM agent_sessions
|
|
5606
|
+
WHERE agent = ?
|
|
5607
|
+
ORDER BY datetime(updated_at) DESC, datetime(created_at) DESC
|
|
5608
|
+
LIMIT ?
|
|
5609
|
+
`).all(agent.trim(), limitValue(limit, 10)) as Record<string, unknown>[];
|
|
5610
|
+
return rows.map(rowToSession);
|
|
5611
|
+
}
|
|
5612
|
+
|
|
2658
5613
|
listRunsForSession(sessionId: string, limit = 50): AgentRun[] {
|
|
2659
5614
|
const rows = this.db.query(`
|
|
2660
5615
|
SELECT * FROM agent_runs
|
|
@@ -2665,7 +5620,108 @@ export class MessageStore {
|
|
|
2665
5620
|
return rows.map(rowToRun);
|
|
2666
5621
|
}
|
|
2667
5622
|
|
|
5623
|
+
recordAgentActivity(input: RecordAgentActivityInput): AgentActivityEvent {
|
|
5624
|
+
const run = this.getRun(input.runId);
|
|
5625
|
+
if (!run) throw new Error(`run ${input.runId} was not found`);
|
|
5626
|
+
const message = this.getMessage(run.message_id);
|
|
5627
|
+
if (!message) throw new Error(`message ${run.message_id} was not found`);
|
|
5628
|
+
const id = crypto.randomUUID();
|
|
5629
|
+
this.db.query(`
|
|
5630
|
+
INSERT INTO agent_activity_events (id, run_id, session_id, agent, room_id, kind, title, detail, metadata)
|
|
5631
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
5632
|
+
`).run(
|
|
5633
|
+
id,
|
|
5634
|
+
run.id,
|
|
5635
|
+
run.session_id,
|
|
5636
|
+
run.agent,
|
|
5637
|
+
message.chat_id,
|
|
5638
|
+
input.kind,
|
|
5639
|
+
input.title.trim().slice(0, 160) || input.kind,
|
|
5640
|
+
(input.detail ?? '').trim().slice(0, 4000),
|
|
5641
|
+
JSON.stringify(input.metadata ?? {}),
|
|
5642
|
+
);
|
|
5643
|
+
return this.getAgentActivityEvent(id)!;
|
|
5644
|
+
}
|
|
5645
|
+
|
|
5646
|
+
getAgentActivityEvent(id: string): AgentActivityEvent | null {
|
|
5647
|
+
const row = this.db.query('SELECT * FROM agent_activity_events WHERE id = ?').get(id) as Record<string, unknown> | null;
|
|
5648
|
+
return row ? rowToActivityEvent(row) : null;
|
|
5649
|
+
}
|
|
5650
|
+
|
|
5651
|
+
listActivityForRun(runId: string, limit = 100): AgentActivityEvent[] {
|
|
5652
|
+
const rows = this.db.query(`
|
|
5653
|
+
SELECT * FROM agent_activity_events
|
|
5654
|
+
WHERE run_id = ?
|
|
5655
|
+
ORDER BY datetime(created_at) ASC, rowid ASC
|
|
5656
|
+
LIMIT ?
|
|
5657
|
+
`).all(runId, limitValue(limit, 100)) as Record<string, unknown>[];
|
|
5658
|
+
return rows.map(rowToActivityEvent);
|
|
5659
|
+
}
|
|
5660
|
+
|
|
5661
|
+
listActivityForAgent(agent: string, limit = 100): AgentActivityEvent[] {
|
|
5662
|
+
const rows = this.db.query(`
|
|
5663
|
+
SELECT * FROM agent_activity_events
|
|
5664
|
+
WHERE agent = ?
|
|
5665
|
+
ORDER BY datetime(created_at) DESC, rowid DESC
|
|
5666
|
+
LIMIT ?
|
|
5667
|
+
`).all(agent.trim(), limitValue(limit, 100)) as Record<string, unknown>[];
|
|
5668
|
+
return rows.map(rowToActivityEvent);
|
|
5669
|
+
}
|
|
5670
|
+
|
|
5671
|
+
listActivityForRoomAgent(roomId: string, agent: string, limit = 100): AgentActivityEvent[] {
|
|
5672
|
+
const room = this.getChatById(roomId);
|
|
5673
|
+
if (!room) return [];
|
|
5674
|
+
const rows = this.db.query(`
|
|
5675
|
+
SELECT *
|
|
5676
|
+
FROM agent_activity_events
|
|
5677
|
+
WHERE room_id = ? AND agent = ?
|
|
5678
|
+
ORDER BY datetime(created_at) DESC, rowid DESC
|
|
5679
|
+
LIMIT ?
|
|
5680
|
+
`).all(room.id, agent.trim(), limitValue(limit, 100)) as Record<string, unknown>[];
|
|
5681
|
+
return rows.map(rowToActivityEvent);
|
|
5682
|
+
}
|
|
5683
|
+
|
|
5684
|
+
listRoomAgentActivity(roomId: string): RoomAgentActivityStatus[] {
|
|
5685
|
+
const room = this.getChatById(roomId);
|
|
5686
|
+
if (!room) return [];
|
|
5687
|
+
const rows = this.db.query(`
|
|
5688
|
+
SELECT
|
|
5689
|
+
ar.id AS run_id,
|
|
5690
|
+
ar.agent,
|
|
5691
|
+
COALESCE(ae.kind, 'running') AS kind,
|
|
5692
|
+
COALESCE(ae.title, 'Working') AS title,
|
|
5693
|
+
COALESCE(ae.created_at, ar.updated_at) AS updated_at
|
|
5694
|
+
FROM agent_runs ar
|
|
5695
|
+
INNER JOIN messages m ON m.id = ar.message_id
|
|
5696
|
+
LEFT JOIN agent_activity_events ae ON ae.id = (
|
|
5697
|
+
SELECT latest.id
|
|
5698
|
+
FROM agent_activity_events latest
|
|
5699
|
+
WHERE latest.run_id = ar.id AND latest.room_id = m.chat_id
|
|
5700
|
+
ORDER BY datetime(latest.created_at) DESC, latest.rowid DESC
|
|
5701
|
+
LIMIT 1
|
|
5702
|
+
)
|
|
5703
|
+
WHERE m.chat_id = ? AND ar.status = 'running'
|
|
5704
|
+
AND (
|
|
5705
|
+
ae.id IS NULL
|
|
5706
|
+
OR NOT (
|
|
5707
|
+
ae.kind = 'lifecycle'
|
|
5708
|
+
AND lower(trim(ae.title)) IN ('step completed', 'runtime completed', 'run completed')
|
|
5709
|
+
)
|
|
5710
|
+
)
|
|
5711
|
+
ORDER BY datetime(updated_at) DESC, ar.agent ASC
|
|
5712
|
+
`).all(room.id) as Record<string, unknown>[];
|
|
5713
|
+
return rows.map((row) => ({
|
|
5714
|
+
agent: String(row.agent),
|
|
5715
|
+
run_id: String(row.run_id),
|
|
5716
|
+
status: 'working',
|
|
5717
|
+
kind: String(row.kind) as RoomAgentActivityStatus['kind'],
|
|
5718
|
+
title: String(row.title),
|
|
5719
|
+
updated_at: String(row.updated_at),
|
|
5720
|
+
}));
|
|
5721
|
+
}
|
|
5722
|
+
|
|
2668
5723
|
claimDelivery(id: string, input: ClaimDeliveryInput): MessageDelivery {
|
|
5724
|
+
this.releaseExpiredDeliveries();
|
|
2669
5725
|
const claimToken = crypto.randomUUID();
|
|
2670
5726
|
const leaseMs = input.leaseMs ?? 30_000;
|
|
2671
5727
|
const leaseUntil = new Date(Date.now() + leaseMs).toISOString();
|
|
@@ -2673,13 +5729,19 @@ export class MessageStore {
|
|
|
2673
5729
|
if (!delivery) throw new Error(`pending delivery ${id} was not found`);
|
|
2674
5730
|
if (input.computerId) {
|
|
2675
5731
|
const busy = this.db.query(`
|
|
2676
|
-
SELECT
|
|
5732
|
+
SELECT r.id, r.connection_id
|
|
2677
5733
|
FROM agent_runs r
|
|
2678
5734
|
JOIN agent_sessions s ON s.id = r.session_id
|
|
2679
5735
|
WHERE r.computer_id = ? AND r.agent = ? AND s.chat_id = ? AND r.status = 'running'
|
|
2680
5736
|
LIMIT 1
|
|
2681
5737
|
`).get(input.computerId, delivery.agent, delivery.chat_id) as Record<string, unknown> | null;
|
|
2682
|
-
if (busy)
|
|
5738
|
+
if (busy) {
|
|
5739
|
+
const matchesSteerRun = input.steerRunId && String(busy.id) === input.steerRunId;
|
|
5740
|
+
const matchesConnection = !input.connectionId || !busy.connection_id || String(busy.connection_id) === input.connectionId;
|
|
5741
|
+
if (!matchesSteerRun || !matchesConnection) {
|
|
5742
|
+
throw new Error(`runtime invocation already running for computer ${input.computerId} agent ${delivery.agent} chat ${delivery.chat_id}`);
|
|
5743
|
+
}
|
|
5744
|
+
}
|
|
2683
5745
|
}
|
|
2684
5746
|
const connectionId = input.connectionId ?? null;
|
|
2685
5747
|
const result = this.db.query(`
|
|
@@ -2803,6 +5865,25 @@ export class MessageStore {
|
|
|
2803
5865
|
return this.getRun(runId)!;
|
|
2804
5866
|
}
|
|
2805
5867
|
|
|
5868
|
+
requestRoomAgentRunAction(input: { roomId: string; agent: string; action: RunAction }): AgentRun {
|
|
5869
|
+
const room = this.getChatById(input.roomId);
|
|
5870
|
+
if (!room) throw new Error(`room ${input.roomId} was not found`);
|
|
5871
|
+
const agent = input.agent.trim();
|
|
5872
|
+
if (!agent) throw new Error('agent is required');
|
|
5873
|
+
const row = this.db.query(`
|
|
5874
|
+
SELECT ar.*
|
|
5875
|
+
FROM agent_runs ar
|
|
5876
|
+
JOIN messages m ON m.id = ar.message_id
|
|
5877
|
+
WHERE ar.status = 'running'
|
|
5878
|
+
AND ar.agent = ?
|
|
5879
|
+
AND m.chat_id = ?
|
|
5880
|
+
ORDER BY datetime(ar.updated_at) DESC, datetime(ar.started_at) DESC, ar.rowid DESC
|
|
5881
|
+
LIMIT 1
|
|
5882
|
+
`).get(agent, room.id) as Record<string, unknown> | null;
|
|
5883
|
+
if (!row) throw new Error(`running run for agent ${agent} in room ${room.id} was not found`);
|
|
5884
|
+
return this.requestRunAction(String(row.id), input.action);
|
|
5885
|
+
}
|
|
5886
|
+
|
|
2806
5887
|
clearRunAction(runId: string): void {
|
|
2807
5888
|
this.db.query(`
|
|
2808
5889
|
UPDATE agent_runs
|
|
@@ -2825,6 +5906,28 @@ export class MessageStore {
|
|
|
2825
5906
|
return rows.map(rowToRun);
|
|
2826
5907
|
}
|
|
2827
5908
|
|
|
5909
|
+
listRunsForAgent(agent: string, limit = 10): AgentRun[] {
|
|
5910
|
+
const rows = this.db.query(`
|
|
5911
|
+
SELECT *
|
|
5912
|
+
FROM agent_runs
|
|
5913
|
+
WHERE agent = ?
|
|
5914
|
+
ORDER BY datetime(updated_at) DESC, datetime(started_at) DESC
|
|
5915
|
+
LIMIT ?
|
|
5916
|
+
`).all(agent.trim(), limitValue(limit, 10)) as Record<string, unknown>[];
|
|
5917
|
+
return rows.map(rowToRun);
|
|
5918
|
+
}
|
|
5919
|
+
|
|
5920
|
+
listRunsForComputer(computerId: string, limit = 20): AgentRun[] {
|
|
5921
|
+
const rows = this.db.query(`
|
|
5922
|
+
SELECT *
|
|
5923
|
+
FROM agent_runs
|
|
5924
|
+
WHERE computer_id = ?
|
|
5925
|
+
ORDER BY datetime(updated_at) DESC, datetime(started_at) DESC
|
|
5926
|
+
LIMIT ?
|
|
5927
|
+
`).all(computerId.trim(), limitValue(limit, 20)) as Record<string, unknown>[];
|
|
5928
|
+
return rows.map(rowToRun);
|
|
5929
|
+
}
|
|
5930
|
+
|
|
2828
5931
|
createArtifact(input: CreateArtifactInput): { artifact: ArtifactMetadata; token: string } {
|
|
2829
5932
|
validateArtifactContent(input.mimeType, input.content);
|
|
2830
5933
|
const id = crypto.randomUUID();
|