@controlflow-ai/daemon 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +54 -6
  2. package/bin/daemon.js +6 -1
  3. package/package.json +3 -1
  4. package/src/agent-avatar.ts +30 -0
  5. package/src/agent-key.ts +28 -0
  6. package/src/agent-permissions.ts +359 -0
  7. package/src/agent-runtime.ts +795 -28
  8. package/src/agent-workspace.ts +183 -0
  9. package/src/app.ts +1970 -79
  10. package/src/args.ts +54 -7
  11. package/src/cli.ts +873 -14
  12. package/src/client.ts +472 -10
  13. package/src/coco.ts +9 -40
  14. package/src/codex.ts +33 -5
  15. package/src/config.ts +28 -4
  16. package/src/console.ts +230 -20
  17. package/src/daemon-client.ts +116 -3
  18. package/src/daemon.ts +937 -99
  19. package/src/db.ts +3128 -122
  20. package/src/delivery-ws.ts +269 -0
  21. package/src/format.ts +4 -1
  22. package/src/lark/cli.ts +3 -3
  23. package/src/lark/event-router.ts +60 -4
  24. package/src/lark/inbound-events.ts +156 -3
  25. package/src/lark/server-integration.ts +659 -111
  26. package/src/lark/ws-daemon.ts +136 -10
  27. package/src/local-api.ts +545 -15
  28. package/src/local-auth.ts +33 -1
  29. package/src/message-attachments.ts +71 -0
  30. package/src/messaging-cli.ts +741 -0
  31. package/src/messaging-status.ts +669 -0
  32. package/src/migrations/024_agents_model.ts +10 -0
  33. package/src/migrations/025_room_archive.ts +44 -0
  34. package/src/migrations/026_project_archive.ts +44 -0
  35. package/src/migrations/027_agent_permission_profiles.ts +16 -0
  36. package/src/migrations/028_lark_websocket_restart_state.ts +16 -0
  37. package/src/migrations/029_held_message_drafts.ts +32 -0
  38. package/src/migrations/030_agent_room_read_state.ts +25 -0
  39. package/src/migrations/031_room_tasks.ts +29 -0
  40. package/src/migrations/032_room_reminders.ts +29 -0
  41. package/src/migrations/033_room_saved_messages.ts +25 -0
  42. package/src/migrations/034_agent_activity_events.ts +27 -0
  43. package/src/migrations/035_agent_avatars.ts +17 -0
  44. package/src/migrations/036_project_agent_defaults.ts +21 -0
  45. package/src/migrations/037_message_attachments.ts +36 -0
  46. package/src/migrations/038_agent_activity_room_scope.ts +64 -0
  47. package/src/migrations/039_message_attachments_path.ts +34 -0
  48. package/src/migrations/040_message_attachments_file_schema.ts +80 -0
  49. package/src/migrations/041_room_system_events.ts +30 -0
  50. package/src/migrations/042_message_attachment_file_kind.ts +52 -0
  51. package/src/migrations/043_room_mode_skill_registry.ts +92 -0
  52. package/src/migrations/044_workflow_runtime.ts +69 -0
  53. package/src/migrations/045_skill_repository_ownership.ts +64 -0
  54. package/src/migrations.ts +69 -1
  55. package/src/neeko.ts +40 -4
  56. package/src/runtime-env.ts +179 -0
  57. package/src/runtime-registry.ts +83 -13
  58. package/src/server.ts +244 -4
  59. package/src/token-file.ts +13 -6
  60. package/src/types.ts +362 -0
  61. package/src/workflow-runtime.ts +275 -0
  62. package/src/web.ts +0 -904
@@ -0,0 +1,269 @@
1
+ import type { Server, ServerWebSocket } from 'bun';
2
+ import { MessageStore } from './db.js';
3
+ import { failure, HttpError } from './http.js';
4
+ import type { MessageDelivery } from './types.js';
5
+ import type { DeliveryConnectionStats, DeliveryWebSocketSummary } from './messaging-status.js';
6
+
7
+ export interface DeliveryWebSocketData {
8
+ kind: 'daemon-delivery';
9
+ computerId: string;
10
+ connectionId: string;
11
+ }
12
+
13
+ export type DeliveryWebSocketFrame =
14
+ | { type: 'hello'; computer_id: string; connection_id: string }
15
+ | { type: 'delivery'; delivery: Pick<MessageDelivery, 'id' | 'message_id' | 'chat_id' | 'agent' | 'status'> }
16
+ | { type: 'pending'; agent: string; pending?: number }
17
+ | { type: 'pong'; at: string };
18
+
19
+ export interface DeliveryNotifyResult {
20
+ deliveries: number;
21
+ target_connections: number;
22
+ open_sockets: number;
23
+ websocket_frames: number;
24
+ }
25
+
26
+ export type { DeliveryConnectionStats, DeliveryWebSocketSummary } from './messaging-status.js';
27
+
28
+ export interface DeliveryWebSocketHubOptions {
29
+ logger?: Partial<Pick<Console, 'log' | 'warn'>>;
30
+ staleConnectionTimeoutMs?: number | null;
31
+ }
32
+
33
+ interface DeliveryConnectionTelemetry {
34
+ computerId: string | null;
35
+ lastOpenAt: string | null;
36
+ lastCloseAt: string | null;
37
+ lastPingAt: string | null;
38
+ lastPongAt: string | null;
39
+ lastCloseCode: number | null;
40
+ lastCloseReason: string | null;
41
+ }
42
+
43
+ function connectionAuthFromRequest(request: Request): { computerId: string; connectionId: string; token: string } {
44
+ const url = new URL(request.url);
45
+ const computerId = request.headers.get('x-pal-computer-id')?.trim() || url.searchParams.get('computer_id')?.trim();
46
+ const connectionId = request.headers.get('x-pal-connection-id')?.trim() || url.searchParams.get('connection_id')?.trim();
47
+ const token = request.headers.get('x-pal-connection-token')?.trim() || url.searchParams.get('token')?.trim();
48
+ if (!computerId || !connectionId || !token) {
49
+ throw new HttpError(401, 'MISSING_CONNECTION_AUTH', 'computer connection auth is required');
50
+ }
51
+ return { computerId, connectionId, token };
52
+ }
53
+
54
+ function sendFrame(ws: ServerWebSocket<DeliveryWebSocketData>, frame: DeliveryWebSocketFrame): boolean {
55
+ try {
56
+ return ws.send(JSON.stringify(frame)) > 0;
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ export class DeliveryWebSocketHub {
63
+ private readonly socketsByConnection = new Map<string, Set<ServerWebSocket<DeliveryWebSocketData>>>();
64
+ private readonly telemetryByConnection = new Map<string, DeliveryConnectionTelemetry>();
65
+ private readonly log: Pick<Console, 'log' | 'warn'>;
66
+ private readonly staleConnectionTimeoutMs: number | null;
67
+
68
+ constructor(private readonly store: MessageStore, options: DeliveryWebSocketHubOptions = {}) {
69
+ this.log = {
70
+ log: options.logger?.log ?? console.log,
71
+ warn: options.logger?.warn ?? console.warn,
72
+ };
73
+ this.staleConnectionTimeoutMs = typeof options.staleConnectionTimeoutMs === 'number' && Number.isFinite(options.staleConnectionTimeoutMs) && options.staleConnectionTimeoutMs >= 0
74
+ ? options.staleConnectionTimeoutMs
75
+ : null;
76
+ }
77
+
78
+ pruneStaleConnections(now = new Date()): number {
79
+ if (this.staleConnectionTimeoutMs === null) return 0;
80
+ const closed = this.store.closeStaleComputerConnections(this.staleConnectionTimeoutMs, now);
81
+ if (closed > 0) this.log.warn(`[delivery/ws] closed ${closed} stale computer connection(s)`);
82
+ return closed;
83
+ }
84
+
85
+ private telemetry(connectionId: string, computerId: string | null): DeliveryConnectionTelemetry {
86
+ let telemetry = this.telemetryByConnection.get(connectionId);
87
+ if (!telemetry) {
88
+ telemetry = {
89
+ computerId,
90
+ lastOpenAt: null,
91
+ lastCloseAt: null,
92
+ lastPingAt: null,
93
+ lastPongAt: null,
94
+ lastCloseCode: null,
95
+ lastCloseReason: null,
96
+ };
97
+ this.telemetryByConnection.set(connectionId, telemetry);
98
+ } else if (computerId) {
99
+ telemetry.computerId = computerId;
100
+ }
101
+ return telemetry;
102
+ }
103
+
104
+ handleUpgrade(request: Request, server: Server<DeliveryWebSocketData>): Response | null {
105
+ const url = new URL(request.url);
106
+ if (request.method !== 'GET' || url.pathname !== '/api/daemon/ws') return null;
107
+ try {
108
+ const auth = connectionAuthFromRequest(request);
109
+ const connection = this.store.assertActiveComputerConnection({
110
+ computerId: auth.computerId,
111
+ connectionId: auth.connectionId,
112
+ token: auth.token,
113
+ });
114
+ const upgraded = server.upgrade(request, {
115
+ data: {
116
+ kind: 'daemon-delivery',
117
+ computerId: connection.computer_id,
118
+ connectionId: connection.id,
119
+ },
120
+ });
121
+ return upgraded
122
+ ? new Response(null)
123
+ : new Response('websocket upgrade failed', { status: 400 });
124
+ } catch (error) {
125
+ return failure(error);
126
+ }
127
+ }
128
+
129
+ websocket = {
130
+ open: (ws: ServerWebSocket<DeliveryWebSocketData>) => {
131
+ const { connectionId, computerId } = ws.data;
132
+ let sockets = this.socketsByConnection.get(connectionId);
133
+ if (!sockets) {
134
+ sockets = new Set();
135
+ this.socketsByConnection.set(connectionId, sockets);
136
+ }
137
+ sockets.add(ws);
138
+ const telemetry = this.telemetry(connectionId, computerId);
139
+ telemetry.lastOpenAt = new Date().toISOString();
140
+ telemetry.lastCloseAt = null;
141
+ telemetry.lastCloseCode = null;
142
+ telemetry.lastCloseReason = null;
143
+ this.log.log(`[delivery/ws] open computer=${computerId} connection=${connectionId} sockets=${sockets.size}`);
144
+ sendFrame(ws, { type: 'hello', computer_id: computerId, connection_id: connectionId });
145
+ for (const { agent, pending } of this.store.listPendingDeliveryAgentsForConnection(connectionId)) {
146
+ sendFrame(ws, { type: 'pending', agent, pending });
147
+ }
148
+ },
149
+ message: (ws: ServerWebSocket<DeliveryWebSocketData>, data: string | Buffer) => {
150
+ if (String(data) === 'ping') {
151
+ const at = new Date().toISOString();
152
+ const telemetry = this.telemetry(ws.data.connectionId, ws.data.computerId);
153
+ telemetry.lastPingAt = at;
154
+ if (sendFrame(ws, { type: 'pong', at })) {
155
+ telemetry.lastPongAt = at;
156
+ }
157
+ }
158
+ },
159
+ close: (ws: ServerWebSocket<DeliveryWebSocketData>, code: number, reason: string) => {
160
+ const sockets = this.socketsByConnection.get(ws.data.connectionId);
161
+ if (sockets) {
162
+ sockets.delete(ws);
163
+ if (sockets.size === 0) this.socketsByConnection.delete(ws.data.connectionId);
164
+ }
165
+ const telemetry = this.telemetry(ws.data.connectionId, ws.data.computerId);
166
+ telemetry.lastCloseAt = new Date().toISOString();
167
+ telemetry.lastCloseCode = code;
168
+ telemetry.lastCloseReason = reason || null;
169
+ this.log.log(`[delivery/ws] close computer=${ws.data.computerId} connection=${ws.data.connectionId} code=${code} sockets=${sockets?.size ?? 0}`);
170
+ },
171
+ };
172
+
173
+ notifyDeliveries(deliveries: MessageDelivery[]): DeliveryNotifyResult {
174
+ this.pruneStaleConnections();
175
+ const targetConnections = new Set<string>();
176
+ const pendingAgentsByConnection = new Map<string, Set<string>>();
177
+ let pendingDeliveries = 0;
178
+ let openSockets = 0;
179
+ let websocketFrames = 0;
180
+ for (const delivery of deliveries) {
181
+ if (delivery.status !== 'pending') continue;
182
+ pendingDeliveries += 1;
183
+ for (const connectionId of this.store.listOnlineDaemonConnectionsForAgent(delivery.agent)) {
184
+ targetConnections.add(connectionId);
185
+ let pendingAgents = pendingAgentsByConnection.get(connectionId);
186
+ if (!pendingAgents) {
187
+ pendingAgents = new Set();
188
+ pendingAgentsByConnection.set(connectionId, pendingAgents);
189
+ }
190
+ pendingAgents.add(delivery.agent);
191
+ }
192
+ }
193
+
194
+ for (const [connectionId, agents] of pendingAgentsByConnection) {
195
+ const sockets = this.socketsByConnection.get(connectionId);
196
+ if (!sockets?.size) continue;
197
+ openSockets += sockets.size;
198
+ const pendingCounts = new Map(
199
+ this.store.listPendingDeliveryAgentsForConnection(connectionId).map((item) => [item.agent, item.pending]),
200
+ );
201
+ for (const agent of agents) {
202
+ for (const ws of sockets) {
203
+ if (sendFrame(ws, { type: 'pending', agent, pending: pendingCounts.get(agent) ?? 0 })) {
204
+ websocketFrames += 1;
205
+ }
206
+ }
207
+ }
208
+ }
209
+ return {
210
+ deliveries: pendingDeliveries,
211
+ target_connections: targetConnections.size,
212
+ open_sockets: openSockets,
213
+ websocket_frames: websocketFrames,
214
+ };
215
+ }
216
+
217
+ statsForConnection(connectionId: string): DeliveryConnectionStats {
218
+ const connection = this.store.getComputerConnection(connectionId);
219
+ const telemetry = this.telemetryByConnection.get(connectionId);
220
+ return {
221
+ connection_id: connectionId,
222
+ computer_id: connection?.computer_id ?? telemetry?.computerId ?? null,
223
+ open_sockets: this.socketsByConnection.get(connectionId)?.size ?? 0,
224
+ last_open_at: telemetry?.lastOpenAt ?? null,
225
+ last_close_at: telemetry?.lastCloseAt ?? null,
226
+ last_ping_at: telemetry?.lastPingAt ?? null,
227
+ last_pong_at: telemetry?.lastPongAt ?? null,
228
+ last_close_code: telemetry?.lastCloseCode ?? null,
229
+ last_close_reason: telemetry?.lastCloseReason ?? null,
230
+ pending_agents: this.store.listPendingDeliveryAgentsForConnection(connectionId),
231
+ backlog: this.store.listDeliveryBacklogForConnection(connectionId),
232
+ };
233
+ }
234
+
235
+ statsAllConnections(): DeliveryWebSocketSummary {
236
+ this.pruneStaleConnections();
237
+ const connections = this.store.listActiveComputerConnections()
238
+ .map((connection) => this.statsForConnection(connection.id));
239
+ return {
240
+ connections,
241
+ totals: connections.reduce((totals, connection) => {
242
+ totals.connections += 1;
243
+ totals.open_sockets += connection.open_sockets;
244
+ for (const item of connection.backlog) {
245
+ totals.pending_deliveries += item.pending;
246
+ totals.claimed_deliveries += item.claimed;
247
+ totals.processing_completed_deliveries += item.processing_completed;
248
+ totals.expired_active_deliveries += item.expired_active;
249
+ }
250
+ return totals;
251
+ }, {
252
+ connections: 0,
253
+ open_sockets: 0,
254
+ pending_deliveries: 0,
255
+ claimed_deliveries: 0,
256
+ processing_completed_deliveries: 0,
257
+ expired_active_deliveries: 0,
258
+ }),
259
+ };
260
+ }
261
+
262
+ close(): void {
263
+ for (const sockets of this.socketsByConnection.values()) {
264
+ for (const ws of sockets) ws.close(1001, 'server shutting down');
265
+ }
266
+ this.socketsByConnection.clear();
267
+ }
268
+
269
+ }
package/src/format.ts CHANGED
@@ -5,7 +5,10 @@ export function formatMessage(message: Message): string {
5
5
  const chatName = sanitizeProviderIds(message.chat_name);
6
6
  const target = message.parent_id === null ? `#${chatName}` : `#${chatName}:${message.parent_id}`;
7
7
  const to = message.recipient ? ` -> @${sanitizeProviderIds(message.recipient)}` : '';
8
- return `[${message.id} ${target} ${message.created_at}] @${sanitizeProviderIds(message.sender)}${to}: ${sanitizeProviderIds(message.content)}`;
8
+ const mentions = message.mentions?.length
9
+ ? ` -> ${message.mentions.map((mention) => `@${sanitizeProviderIds(mention)}`).join(',')}`
10
+ : '';
11
+ return `[${message.id} ${target} ${message.created_at}] @${sanitizeProviderIds(message.sender)}${to || mentions}: ${sanitizeProviderIds(message.content)}`;
9
12
  }
10
13
 
11
14
  export function formatMessages(messages: Message[]): string {
package/src/lark/cli.ts CHANGED
@@ -195,17 +195,17 @@ async function runDaemon(argv: LarkCliArgs, log: NonNullable<RunOptions['log']>)
195
195
  let resolvedRuntime: string | null = null;
196
196
  if (agentSpec) {
197
197
  const directRuntime = agentSpec.split(':')[0];
198
- if (directRuntime === 'neeko' || directRuntime === 'coco' || directRuntime === 'coco-stream-json' || directRuntime === 'codex') {
198
+ if (directRuntime === 'neeko' || directRuntime === 'coco' || directRuntime === 'codex') {
199
199
  resolvedRuntime = directRuntime;
200
200
  } else {
201
201
  resolvedRuntime = msgStore.getAgentRuntime(agentSpec);
202
202
  if (!resolvedRuntime) {
203
- (log.warn ?? console.warn)(`[lark] agent ${agentSpec} has no runtime configured in DB; defaulting to neeko. Run "bun run src/cli.ts agents create --key ${agentSpec} --name <name> --runtime neeko|coco|coco-stream-json|codex" to configure.`);
203
+ (log.warn ?? console.warn)(`[lark] agent ${agentSpec} has no runtime configured in DB; defaulting to neeko. Run "bun run src/cli.ts agents create --key ${agentSpec} --name <name> --runtime neeko|coco|codex" to configure.`);
204
204
  resolvedRuntime = 'neeko';
205
205
  }
206
206
  }
207
207
  }
208
- const isDeliveryAgent = resolvedRuntime === 'neeko' || resolvedRuntime === 'coco' || resolvedRuntime === 'coco-stream-json' || resolvedRuntime === 'codex';
208
+ const isDeliveryAgent = resolvedRuntime === 'neeko' || resolvedRuntime === 'coco' || resolvedRuntime === 'codex';
209
209
 
210
210
  const dispatchers = new Map<string, { dispatcher: ChatDispatcher; periodic: PeriodicQueue | null; chatIdToLarkChatId: Map<string, string> }>();
211
211
  if (agentSpec && !isDeliveryAgent) {
@@ -14,7 +14,7 @@ import { palIdentityHandle } from '../provider-identity.js';
14
14
  * | message.chat_id | chatName (prefixed `lark:`) |
15
15
  * | message.message_id | idempotencyKey |
16
16
  * | sender.sender_id.open_id | sender |
17
- * | message.mentions[0]?.id.open_id | recipient (first @mention)|
17
+ * | message.mentions[].id.open_id | mentions for group delivery; recipient for non-group direct routing |
18
18
  * | message.content (parsed JSON text)| content |
19
19
  * | message.root_id (thread reply) | parentId via lookup |
20
20
  *
@@ -61,10 +61,16 @@ export interface MapLarkMessageResult {
61
61
  status: 'ok' | 'skipped';
62
62
  reason?: 'missing_message_id' | 'missing_chat_id' | 'missing_sender' | 'unsupported_message_type' | 'empty_text';
63
63
  input?: CreateMessageInput;
64
- /** Lark root_id when present (for thread replies). The router pre-resolves the parentId from it if possible. */
64
+ /** Lark root_id, or parent_id when root_id is absent, used to pre-resolve the local topic parent. */
65
65
  rootMessageId?: string;
66
66
  }
67
67
 
68
+ export interface LarkImageResource {
69
+ fileKey: string;
70
+ filename: string;
71
+ mimeType: string;
72
+ }
73
+
68
74
  export function buildLockChatName(appId: string, chatId: string, chatType?: string): string {
69
75
  if (chatType === 'group') {
70
76
  return `lark:group:${chatId}`;
@@ -125,6 +131,49 @@ function parseLarkTextContent(rawContent: string | undefined, messageType: strin
125
131
  }
126
132
  }
127
133
 
134
+ function parseJsonObject(rawContent: string | undefined): Record<string, unknown> | null {
135
+ if (!rawContent) return null;
136
+ try {
137
+ const parsed = JSON.parse(rawContent) as unknown;
138
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null;
139
+ } catch {
140
+ return null;
141
+ }
142
+ }
143
+
144
+ export function extractLarkImageResources(envelope: LarkMessageEnvelope): LarkImageResource[] {
145
+ const msg = envelope.message;
146
+ const out: LarkImageResource[] = [];
147
+ const seen = new Set<string>();
148
+ const add = (fileKey: unknown, filename?: unknown) => {
149
+ if (typeof fileKey !== 'string' || !fileKey.trim() || seen.has(fileKey)) return;
150
+ seen.add(fileKey);
151
+ out.push({
152
+ fileKey,
153
+ filename: typeof filename === 'string' && filename.trim() ? filename.trim() : `${fileKey}.jpg`,
154
+ mimeType: 'image/jpeg',
155
+ });
156
+ };
157
+ const parsed = parseJsonObject(msg?.content);
158
+ if (msg?.message_type === 'image') {
159
+ add(parsed?.image_key ?? parsed?.file_key, parsed?.file_name ?? parsed?.name);
160
+ }
161
+ if (msg?.message_type === 'post' && parsed) {
162
+ const content = parsed.content;
163
+ if (Array.isArray(content)) {
164
+ for (const line of content) {
165
+ if (!Array.isArray(line)) continue;
166
+ for (const item of line) {
167
+ if (!item || typeof item !== 'object') continue;
168
+ const record = item as Record<string, unknown>;
169
+ if (record.tag === 'img' || record.tag === 'image') add(record.image_key ?? record.file_key);
170
+ }
171
+ }
172
+ }
173
+ }
174
+ return out;
175
+ }
176
+
128
177
  /**
129
178
  * Pure mapper: no DB access. Returns a `CreateMessageInput` skeleton (without
130
179
  * parentId resolution — that happens in `resolveThreadParent`).
@@ -146,11 +195,14 @@ export function mapLarkMessageToCreateInput(input: MapLarkMessageInput): MapLark
146
195
  ...(mentionsAllAgents(envelope) ? [ALL_AGENTS_MENTION] : []),
147
196
  ...(msg.mentions?.map((m) => m.id?.open_id ? input.recipientByMentionOpenId?.get(m.id.open_id) ?? null : null) ?? []),
148
197
  ]);
198
+ const firstMappedMention = firstMention ? input.recipientByMentionOpenId?.get(firstMention) : undefined;
149
199
  const recipient = input.recipientOverride !== undefined
150
200
  ? input.recipientOverride
151
- : firstMention ? input.recipientByMentionOpenId?.get(firstMention) ?? firstMention : null;
201
+ : msg.chat_type === 'group'
202
+ ? null
203
+ : firstMention ? firstMappedMention ?? firstMention : null;
152
204
  const chatName = buildLockChatName(input.appId, msg.chat_id, msg.chat_type);
153
- const rootMessageId = msg.root_id?.trim() || undefined;
205
+ const rootMessageId = msg.root_id?.trim() || msg.parent_id?.trim() || undefined;
154
206
 
155
207
  const createInput: CreateMessageInput = {
156
208
  chatName,
@@ -291,6 +343,8 @@ export function ingestLarkMessage(input: IngestLarkMessageInput): IngestLarkMess
291
343
  content: mapped.input.content,
292
344
  type: mapped.input.type,
293
345
  idempotencyKey: mapped.input.idempotencyKey,
346
+ provider: 'lark',
347
+ mentions: mapped.input.mentions,
294
348
  }
295
349
  : threadOrphan
296
350
  ? {
@@ -300,6 +354,8 @@ export function ingestLarkMessage(input: IngestLarkMessageInput): IngestLarkMess
300
354
  content: mapped.input.content,
301
355
  type: mapped.input.type,
302
356
  idempotencyKey: mapped.input.idempotencyKey,
357
+ provider: 'lark',
358
+ mentions: mapped.input.mentions,
303
359
  }
304
360
  : { ...mapped.input, sender: senderHandle || mapped.input.sender };
305
361
 
@@ -10,6 +10,8 @@ export interface InboundRawEvent {
10
10
  raw_body_bytes: Uint8Array;
11
11
  }
12
12
 
13
+ export type InboundRawEventSummary = Omit<InboundRawEvent, 'raw_body_bytes'>;
14
+
13
15
  export interface StoreInboundEventInput {
14
16
  appId: string;
15
17
  rawBody: string | Uint8Array;
@@ -24,6 +26,31 @@ export interface StoreInboundEventResult {
24
26
  duplicate: boolean;
25
27
  }
26
28
 
29
+ export interface RepairInboundEventParseFailuresOptions {
30
+ appId?: string;
31
+ limit?: number;
32
+ dryRun?: boolean;
33
+ }
34
+
35
+ export interface RepairInboundEventParseFailuresResult {
36
+ dry_run: boolean;
37
+ scanned: number;
38
+ repaired: number;
39
+ unchanged: number;
40
+ conflicts: number;
41
+ errors: number;
42
+ rows: Array<{
43
+ id: string;
44
+ app_id: string;
45
+ old_event_id: string;
46
+ old_event_type: string;
47
+ new_event_id: string;
48
+ new_event_type: string;
49
+ status: 'repaired' | 'would_repair' | 'unchanged' | 'conflict' | 'error';
50
+ error?: string;
51
+ }>;
52
+ }
53
+
27
54
  const ENCODER = new TextEncoder();
28
55
 
29
56
  function toBytes(body: string | Uint8Array): Uint8Array {
@@ -48,8 +75,9 @@ export async function parseEventEnvelope(rawBytes: Uint8Array): Promise<ParsedEn
48
75
  const text = new TextDecoder('utf8', { fatal: false }).decode(rawBytes);
49
76
  const json = JSON.parse(text) as Record<string, unknown>;
50
77
  const header = (json.header && typeof json.header === 'object') ? json.header as Record<string, unknown> : null;
51
- const eventId = (header?.event_id ?? json.uuid) as unknown;
52
- const eventType = (header?.event_type ?? json.type) as unknown;
78
+ const event = (json.event && typeof json.event === 'object') ? json.event as Record<string, unknown> : null;
79
+ const eventId = (header?.event_id ?? event?.event_id ?? json.uuid) as unknown;
80
+ const eventType = (header?.event_type ?? event?.event_type ?? json.type) as unknown;
53
81
  if (typeof eventId === 'string' && eventId.length > 0 && typeof eventType === 'string' && eventType.length > 0) {
54
82
  return { event_id: eventId, event_type: eventType, parse_ok: 1 };
55
83
  }
@@ -117,10 +145,22 @@ export function getInboundEvent(db: Database, id: string): InboundRawEvent | nul
117
145
 
118
146
  export function listRecentInboundEvents(db: Database, limit = 20): InboundRawEvent[] {
119
147
  return db
120
- .query('SELECT id, received_at, app_id, event_type, event_id, parse_ok, raw_body_bytes FROM channel_inbound_raw_events ORDER BY received_at DESC, id DESC LIMIT ?')
148
+ .query('SELECT id, received_at, app_id, event_type, event_id, parse_ok, raw_body_bytes FROM channel_inbound_raw_events ORDER BY received_at DESC, rowid DESC LIMIT ?')
121
149
  .all(Math.max(1, Math.min(limit, 500))) as InboundRawEvent[];
122
150
  }
123
151
 
152
+ export function latestInboundEventSummary(db: Database, appId: string): InboundRawEventSummary | null {
153
+ return db
154
+ .query("SELECT id, received_at, app_id, event_type, event_id, parse_ok FROM channel_inbound_raw_events WHERE app_id = ? AND event_type NOT LIKE 'pal.probe.%' ORDER BY received_at DESC, rowid DESC LIMIT 1")
155
+ .get(appId) as InboundRawEventSummary | null;
156
+ }
157
+
158
+ export function latestProbeInboundEventSummary(db: Database, appId: string): InboundRawEventSummary | null {
159
+ return db
160
+ .query("SELECT id, received_at, app_id, event_type, event_id, parse_ok FROM channel_inbound_raw_events WHERE app_id = ? AND event_type LIKE 'pal.probe.%' ORDER BY received_at DESC, rowid DESC LIMIT 1")
161
+ .get(appId) as InboundRawEventSummary | null;
162
+ }
163
+
124
164
  export function countInboundEvents(db: Database, appId?: string): number {
125
165
  if (appId) {
126
166
  const row = db.query('SELECT COUNT(*) AS n FROM channel_inbound_raw_events WHERE app_id = ?').get(appId) as { n: number };
@@ -129,3 +169,116 @@ export function countInboundEvents(db: Database, appId?: string): number {
129
169
  const row = db.query('SELECT COUNT(*) AS n FROM channel_inbound_raw_events').get() as { n: number };
130
170
  return row.n;
131
171
  }
172
+
173
+ export function countProviderInboundEvents(db: Database, appId?: string): number {
174
+ if (appId) {
175
+ const row = db.query("SELECT COUNT(*) AS n FROM channel_inbound_raw_events WHERE app_id = ? AND event_type NOT LIKE 'pal.probe.%'").get(appId) as { n: number };
176
+ return row.n;
177
+ }
178
+ const row = db.query("SELECT COUNT(*) AS n FROM channel_inbound_raw_events WHERE event_type NOT LIKE 'pal.probe.%'").get() as { n: number };
179
+ return row.n;
180
+ }
181
+
182
+ export function countProbeInboundEvents(db: Database, appId?: string): number {
183
+ if (appId) {
184
+ const row = db.query("SELECT COUNT(*) AS n FROM channel_inbound_raw_events WHERE app_id = ? AND event_type LIKE 'pal.probe.%'").get(appId) as { n: number };
185
+ return row.n;
186
+ }
187
+ const row = db.query("SELECT COUNT(*) AS n FROM channel_inbound_raw_events WHERE event_type LIKE 'pal.probe.%'").get() as { n: number };
188
+ return row.n;
189
+ }
190
+
191
+ export function countInboundParseFailures(db: Database, appId?: string): number {
192
+ if (appId) {
193
+ const row = db.query('SELECT COUNT(*) AS n FROM channel_inbound_raw_events WHERE app_id = ? AND parse_ok = 0').get(appId) as { n: number };
194
+ return row.n;
195
+ }
196
+ const row = db.query('SELECT COUNT(*) AS n FROM channel_inbound_raw_events WHERE parse_ok = 0').get() as { n: number };
197
+ return row.n;
198
+ }
199
+
200
+ export async function repairInboundEventParseFailures(
201
+ db: Database,
202
+ options: RepairInboundEventParseFailuresOptions = {},
203
+ ): Promise<RepairInboundEventParseFailuresResult> {
204
+ const limit = Math.max(1, Math.min(options.limit ?? 100, 1000));
205
+ const dryRun = options.dryRun ?? false;
206
+ const rows = options.appId
207
+ ? db
208
+ .query('SELECT id, app_id, event_type, event_id, raw_body_bytes FROM channel_inbound_raw_events WHERE app_id = ? AND parse_ok = 0 ORDER BY received_at DESC, rowid DESC LIMIT ?')
209
+ .all(options.appId, limit)
210
+ : db
211
+ .query('SELECT id, app_id, event_type, event_id, raw_body_bytes FROM channel_inbound_raw_events WHERE parse_ok = 0 ORDER BY received_at DESC, rowid DESC LIMIT ?')
212
+ .all(limit);
213
+ const result: RepairInboundEventParseFailuresResult = {
214
+ dry_run: dryRun,
215
+ scanned: rows.length,
216
+ repaired: 0,
217
+ unchanged: 0,
218
+ conflicts: 0,
219
+ errors: 0,
220
+ rows: [],
221
+ };
222
+
223
+ for (const row of rows as Array<{ id: string; app_id: string; event_type: string; event_id: string; raw_body_bytes: Uint8Array }>) {
224
+ const parsed = await parseEventEnvelope(row.raw_body_bytes);
225
+ if (parsed.parse_ok !== 1) {
226
+ result.unchanged += 1;
227
+ result.rows.push({
228
+ id: row.id,
229
+ app_id: row.app_id,
230
+ old_event_id: row.event_id,
231
+ old_event_type: row.event_type,
232
+ new_event_id: parsed.event_id,
233
+ new_event_type: parsed.event_type,
234
+ status: 'unchanged',
235
+ });
236
+ continue;
237
+ }
238
+ if (dryRun) {
239
+ result.repaired += 1;
240
+ result.rows.push({
241
+ id: row.id,
242
+ app_id: row.app_id,
243
+ old_event_id: row.event_id,
244
+ old_event_type: row.event_type,
245
+ new_event_id: parsed.event_id,
246
+ new_event_type: parsed.event_type,
247
+ status: 'would_repair',
248
+ });
249
+ continue;
250
+ }
251
+ try {
252
+ db
253
+ .query('UPDATE channel_inbound_raw_events SET event_id = ?, event_type = ?, parse_ok = 1 WHERE id = ?')
254
+ .run(parsed.event_id, parsed.event_type, row.id);
255
+ result.repaired += 1;
256
+ result.rows.push({
257
+ id: row.id,
258
+ app_id: row.app_id,
259
+ old_event_id: row.event_id,
260
+ old_event_type: row.event_type,
261
+ new_event_id: parsed.event_id,
262
+ new_event_type: parsed.event_type,
263
+ status: 'repaired',
264
+ });
265
+ } catch (err) {
266
+ const message = err instanceof Error ? err.message : String(err);
267
+ const conflict = message.toLowerCase().includes('unique') || message.toLowerCase().includes('constraint');
268
+ if (conflict) result.conflicts += 1;
269
+ else result.errors += 1;
270
+ result.rows.push({
271
+ id: row.id,
272
+ app_id: row.app_id,
273
+ old_event_id: row.event_id,
274
+ old_event_type: row.event_type,
275
+ new_event_id: parsed.event_id,
276
+ new_event_type: parsed.event_type,
277
+ status: conflict ? 'conflict' : 'error',
278
+ error: message,
279
+ });
280
+ }
281
+ }
282
+
283
+ return result;
284
+ }