@controlflow-ai/daemon 0.1.2 → 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.
Files changed (61) hide show
  1. package/README.md +54 -6
  2. package/package.json +3 -1
  3. package/src/agent-avatar.ts +30 -0
  4. package/src/agent-key.ts +28 -0
  5. package/src/agent-permissions.ts +359 -0
  6. package/src/agent-runtime.ts +795 -28
  7. package/src/agent-workspace.ts +183 -0
  8. package/src/app.ts +1970 -79
  9. package/src/args.ts +54 -7
  10. package/src/cli.ts +873 -14
  11. package/src/client.ts +472 -10
  12. package/src/coco.ts +9 -40
  13. package/src/codex.ts +33 -5
  14. package/src/config.ts +28 -4
  15. package/src/console.ts +230 -20
  16. package/src/daemon-client.ts +116 -3
  17. package/src/daemon.ts +936 -98
  18. package/src/db.ts +3128 -122
  19. package/src/delivery-ws.ts +269 -0
  20. package/src/format.ts +4 -1
  21. package/src/lark/cli.ts +3 -3
  22. package/src/lark/event-router.ts +60 -4
  23. package/src/lark/inbound-events.ts +156 -3
  24. package/src/lark/server-integration.ts +659 -111
  25. package/src/lark/ws-daemon.ts +136 -10
  26. package/src/local-api.ts +545 -15
  27. package/src/local-auth.ts +33 -1
  28. package/src/message-attachments.ts +71 -0
  29. package/src/messaging-cli.ts +741 -0
  30. package/src/messaging-status.ts +669 -0
  31. package/src/migrations/024_agents_model.ts +10 -0
  32. package/src/migrations/025_room_archive.ts +44 -0
  33. package/src/migrations/026_project_archive.ts +44 -0
  34. package/src/migrations/027_agent_permission_profiles.ts +16 -0
  35. package/src/migrations/028_lark_websocket_restart_state.ts +16 -0
  36. package/src/migrations/029_held_message_drafts.ts +32 -0
  37. package/src/migrations/030_agent_room_read_state.ts +25 -0
  38. package/src/migrations/031_room_tasks.ts +29 -0
  39. package/src/migrations/032_room_reminders.ts +29 -0
  40. package/src/migrations/033_room_saved_messages.ts +25 -0
  41. package/src/migrations/034_agent_activity_events.ts +27 -0
  42. package/src/migrations/035_agent_avatars.ts +17 -0
  43. package/src/migrations/036_project_agent_defaults.ts +21 -0
  44. package/src/migrations/037_message_attachments.ts +36 -0
  45. package/src/migrations/038_agent_activity_room_scope.ts +64 -0
  46. package/src/migrations/039_message_attachments_path.ts +34 -0
  47. package/src/migrations/040_message_attachments_file_schema.ts +80 -0
  48. package/src/migrations/041_room_system_events.ts +30 -0
  49. package/src/migrations/042_message_attachment_file_kind.ts +52 -0
  50. package/src/migrations/043_room_mode_skill_registry.ts +92 -0
  51. package/src/migrations/044_workflow_runtime.ts +69 -0
  52. package/src/migrations/045_skill_repository_ownership.ts +64 -0
  53. package/src/migrations.ts +69 -1
  54. package/src/neeko.ts +40 -4
  55. package/src/runtime-env.ts +179 -0
  56. package/src/runtime-registry.ts +83 -13
  57. package/src/server.ts +244 -4
  58. package/src/token-file.ts +13 -6
  59. package/src/types.ts +362 -0
  60. package/src/workflow-runtime.ts +275 -0
  61. package/src/web.ts +0 -904
@@ -0,0 +1,741 @@
1
+ import { boolFlag, flag, numberFlag } from './args.js';
2
+ import { LockClient } from './client.js';
3
+ import { defaultLarkConfigPath, loadLarkCredentials } from './lark/credentials.js';
4
+ import { resolveLarkBotInfo, type LarkBotInfoResult } from './lark/setup.js';
5
+ import { recommendLarkProviderAction, selectRestartableLarkBotAppIds } from './messaging-status.js';
6
+
7
+ export interface MessagingCliArgs {
8
+ values: string[];
9
+ flags: Record<string, string | boolean>;
10
+ }
11
+
12
+ interface LarkDoctorCheck {
13
+ code: string;
14
+ level: 'ok' | 'warn';
15
+ message: string;
16
+ hint?: string;
17
+ }
18
+
19
+ interface LarkDoctorBot {
20
+ app_id: string;
21
+ label: string | null;
22
+ agent: string | null;
23
+ recommended_action?: string;
24
+ checks: LarkDoctorCheck[];
25
+ }
26
+
27
+ function printJson(value: unknown): void {
28
+ console.log(JSON.stringify(value, null, 2));
29
+ }
30
+
31
+ function sleep(ms: number): Promise<void> {
32
+ return new Promise((resolve) => setTimeout(resolve, ms));
33
+ }
34
+
35
+ function larkConfigPath(flags: Record<string, string | boolean>): string {
36
+ return flag(flags, 'lark-config') ?? flag(flags, 'config') ?? defaultLarkConfigPath();
37
+ }
38
+
39
+ async function resolveBotInfoForDoctor(appId: string, appSecret: string, options: { budgetMs?: number } = {}): Promise<LarkBotInfoResult> {
40
+ if (process.env.NODE_ENV === 'test' && process.env.PAL_TEST_LARK_BOT_OPEN_ID) {
41
+ return { ok: true, openId: process.env.PAL_TEST_LARK_BOT_OPEN_ID };
42
+ }
43
+ return resolveLarkBotInfo(appId, appSecret, options);
44
+ }
45
+
46
+ function dbProviderEventCount(bot: { db_event_count?: number; db_provider_event_count?: number }): number {
47
+ return bot.db_provider_event_count ?? bot.db_event_count ?? 0;
48
+ }
49
+
50
+ function larkEventMarker(bot: { event_count?: number; provider_event_count?: number; db_event_count?: number; db_provider_event_count?: number; db_last_event?: unknown }) {
51
+ const dbLastEvent = bot.db_last_event && typeof bot.db_last_event === 'object'
52
+ ? bot.db_last_event as { id?: unknown; event_id?: unknown; received_at?: unknown; parse_ok?: unknown }
53
+ : null;
54
+ return {
55
+ event_count: bot.provider_event_count ?? bot.event_count ?? 0,
56
+ db_event_count: dbProviderEventCount(bot),
57
+ db_last_event_id: typeof dbLastEvent?.id === 'string' ? dbLastEvent.id : null,
58
+ db_last_provider_event_id: typeof dbLastEvent?.event_id === 'string' ? dbLastEvent.event_id : null,
59
+ db_last_received_at: typeof dbLastEvent?.received_at === 'string' ? dbLastEvent.received_at : null,
60
+ db_last_parse_ok: dbLastEvent?.parse_ok === 1 || dbLastEvent?.parse_ok === true ? true : dbLastEvent?.parse_ok === 0 || dbLastEvent?.parse_ok === false ? false : null,
61
+ };
62
+ }
63
+
64
+ function sameLarkEventMarker(a: ReturnType<typeof larkEventMarker>, b: ReturnType<typeof larkEventMarker>): boolean {
65
+ return a.event_count === b.event_count
66
+ && a.db_event_count === b.db_event_count
67
+ && a.db_last_event_id === b.db_last_event_id
68
+ && a.db_last_provider_event_id === b.db_last_provider_event_id
69
+ && a.db_last_received_at === b.db_last_received_at
70
+ && a.db_last_parse_ok === b.db_last_parse_ok;
71
+ }
72
+
73
+ function larkWatchSnapshot(status: Awaited<ReturnType<LockClient['getMessagingStatus']>>, appId: string) {
74
+ const bot = status.lark.bots.find((candidate) => candidate.app_id === appId);
75
+ if (!bot) throw new Error(`Lark bot not found: ${appId}`);
76
+ const marker = larkEventMarker(bot);
77
+ const diagnostics = status.diagnostics
78
+ .filter((diagnostic) => diagnostic.message.includes(appId))
79
+ .map((diagnostic) => diagnostic.code);
80
+ return {
81
+ marker,
82
+ ws_state: bot.ws_state ?? null,
83
+ provider_event_count: bot.provider_event_count ?? bot.event_count ?? 0,
84
+ total_event_count: bot.event_count ?? bot.provider_event_count ?? 0,
85
+ provider_message_event_count: bot.provider_message_event_count ?? bot.message_event_count ?? 0,
86
+ total_message_event_count: bot.message_event_count ?? bot.provider_message_event_count ?? 0,
87
+ last_provider_event_at: bot.last_provider_event_at ?? null,
88
+ last_provider_message_event_at: bot.last_provider_message_event_at ?? null,
89
+ diagnostics,
90
+ };
91
+ }
92
+
93
+ function formatMaybe(value: string | number | null | undefined): string {
94
+ return value === null || value === undefined || value === '' ? '-' : String(value);
95
+ }
96
+
97
+ function formatHealthDetailValue(value: unknown): string {
98
+ if (Array.isArray(value)) return value.length ? value.map((item) => formatHealthDetailValue(item)).join(',') : '[]';
99
+ if (value && typeof value === 'object') return JSON.stringify(value);
100
+ return formatMaybe(value as string | number | null | undefined);
101
+ }
102
+
103
+ function isTerminalDeliveryStatus(status: string): boolean {
104
+ return status === 'acked' || status === 'failed' || status === 'canceled';
105
+ }
106
+
107
+ function formatLarkDbLastEvent(bot: { db_last_event?: unknown }): string | null {
108
+ if (!bot.db_last_event || typeof bot.db_last_event !== 'object') return null;
109
+ const event = bot.db_last_event as {
110
+ received_at?: unknown;
111
+ event_id?: unknown;
112
+ event_type?: unknown;
113
+ parse_ok?: unknown;
114
+ };
115
+ const parseOk = event.parse_ok === 1 || event.parse_ok === true
116
+ ? '1'
117
+ : event.parse_ok === 0 || event.parse_ok === false
118
+ ? '0'
119
+ : '-';
120
+ return `latest_event=${formatMaybe(typeof event.event_type === 'string' ? event.event_type : null)} id=${formatMaybe(typeof event.event_id === 'string' ? event.event_id : null)} at=${formatMaybe(typeof event.received_at === 'string' ? event.received_at : null)} parse_ok=${parseOk}`;
121
+ }
122
+
123
+ async function larkIngressEvidence(serverClient: LockClient, appId: string, recentLimit: number) {
124
+ const [status, health, recent] = await Promise.all([
125
+ serverClient.getMessagingStatus(),
126
+ serverClient.getMessagingHealth(),
127
+ serverClient.listRecentLarkEvents(recentLimit),
128
+ ]);
129
+ const bot = status.lark.bots.find((candidate) => candidate.app_id === appId);
130
+ return {
131
+ status,
132
+ health,
133
+ recent,
134
+ bot: bot ?? null,
135
+ snapshot: bot ? larkWatchSnapshot(status, appId) : null,
136
+ marker: bot ? larkEventMarker(bot) : null,
137
+ };
138
+ }
139
+
140
+ function printLarkIngressEvidence(input: Awaited<ReturnType<typeof larkIngressEvidence>>): void {
141
+ const lark = input.health.domains.lark_provider;
142
+ console.log(` lark_provider=${lark.level} codes=${lark.codes.join(',') || '-'} action=${lark.recommended_action ?? '-'}`);
143
+ if (lark.details) {
144
+ const details = Object.entries(lark.details).map(([key, value]) => `${key}=${formatHealthDetailValue(value)}`).join(' ');
145
+ if (details) console.log(` ${details}`);
146
+ }
147
+ if (lark.hint) console.log(` hint: ${lark.hint}`);
148
+ const recent = input.recent.slice(0, 5);
149
+ if (recent.length === 0) {
150
+ console.log(' recent_lark_events=-');
151
+ return;
152
+ }
153
+ console.log(' recent_lark_events:');
154
+ for (const event of recent) {
155
+ console.log(` ${event.received_at} app=${event.app_id} type=${event.event_type} id=${event.event_id} probe=${event.is_probe ? 'true' : 'false'} parse_ok=${event.parse_ok}`);
156
+ }
157
+ }
158
+
159
+ async function printMessagingStatus(serverClient: LockClient, flags: Record<string, string | boolean>): Promise<void> {
160
+ const status = await serverClient.getMessagingStatus();
161
+ if (boolFlag(flags, 'json')) {
162
+ printJson(status);
163
+ return;
164
+ }
165
+ const verbose = boolFlag(flags, 'verbose');
166
+ console.log(`messaging: ${status.summary.level} warnings=${status.summary.warn_count} codes=${status.summary.codes.join(',') || '-'}`);
167
+ for (const diagnostic of status.diagnostics) {
168
+ console.log(` ${diagnostic.level} ${diagnostic.code}: ${diagnostic.message}`);
169
+ if (diagnostic.hint) console.log(` hint: ${diagnostic.hint}`);
170
+ }
171
+ for (const bot of status.lark.bots) {
172
+ const providerEvents = bot.provider_event_count ?? bot.event_count ?? 0;
173
+ const totalEvents = bot.event_count ?? providerEvents;
174
+ const eventSummary = typeof bot.provider_event_count === 'number'
175
+ ? `provider_events=${providerEvents} events=${totalEvents}`
176
+ : `events=${providerEvents}`;
177
+ console.log(` lark ${bot.app_id} ws=${bot.ws_state ?? '-'} agent=${bot.agent ?? '-'} authorized_users=${bot.authorized_user_count ?? 0} bot_open_id=${bot.bot_open_id_known ? 'known' : 'unknown'} ${eventSummary} db_provider_events=${dbProviderEventCount(bot)} db_probe_events=${bot.db_probe_event_count ?? 0} parse_errors=${bot.db_parse_error_count ?? 0}`);
178
+ if (bot.config_path) console.log(` config=${bot.config_path}`);
179
+ if (bot.db_path) console.log(` db=${bot.db_path}`);
180
+ const latestEvent = verbose ? formatLarkDbLastEvent(bot) : null;
181
+ if (latestEvent) console.log(` ${latestEvent}`);
182
+ if (verbose && (bot.last_provider_event_at || bot.last_provider_message_event_at)) {
183
+ console.log(` provider_event_at=${formatMaybe(bot.last_provider_event_at)} provider_message_at=${formatMaybe(bot.last_provider_message_event_at)}`);
184
+ }
185
+ if (verbose && (bot.ws_last_event_type || bot.ws_last_error)) {
186
+ console.log(` ws_event=${formatMaybe(bot.ws_last_event_type)} at=${formatMaybe(bot.ws_last_event_at)} error=${formatMaybe(bot.ws_last_error)}`);
187
+ }
188
+ if (verbose && ((bot.restart_count ?? 0) > 0 || bot.last_restart_at || bot.last_restart_reason)) {
189
+ console.log(` restarts=${bot.restart_count ?? 0} last_restart=${formatMaybe(bot.last_restart_at)} reason=${formatMaybe(bot.last_restart_reason)}`);
190
+ }
191
+ }
192
+ const totals = status.delivery_websocket.totals;
193
+ console.log(` delivery_ws connections=${totals.connections} open_sockets=${totals.open_sockets} pending=${totals.pending_deliveries} claimed=${totals.claimed_deliveries} processing_completed=${totals.processing_completed_deliveries} expired_active=${totals.expired_active_deliveries}`);
194
+ if (verbose) {
195
+ for (const connection of status.delivery_websocket.connections) {
196
+ console.log(` connection ${connection.connection_id} computer=${formatMaybe(connection.computer_id)} open_sockets=${connection.open_sockets} last_open=${formatMaybe(connection.last_open_at)} last_ping=${formatMaybe(connection.last_ping_at)} last_pong=${formatMaybe(connection.last_pong_at)} last_close=${formatMaybe(connection.last_close_at)} close_code=${formatMaybe(connection.last_close_code)}`);
197
+ const pendingAgents = connection.pending_agents.map((item) => `${item.agent}:${item.pending}`).join(',') || '-';
198
+ const backlog = connection.backlog
199
+ .map((item) => `${item.agent}:pending=${item.pending},claimed=${item.claimed},processing_completed=${item.processing_completed},expired=${item.expired_active}`)
200
+ .join(';') || '-';
201
+ console.log(` pending_agents=${pendingAgents}`);
202
+ console.log(` backlog=${backlog}`);
203
+ if (connection.last_close_reason) console.log(` close_reason=${connection.last_close_reason}`);
204
+ }
205
+ }
206
+ }
207
+
208
+ async function printMessagingHealth(serverClient: LockClient, flags: Record<string, string | boolean>): Promise<void> {
209
+ const health = await serverClient.getMessagingHealth();
210
+ if (boolFlag(flags, 'json')) {
211
+ printJson(health);
212
+ return;
213
+ }
214
+ console.log(`messaging health: ${health.level} warnings=${health.summary.warn_count} codes=${health.summary.codes.join(',') || '-'}`);
215
+ for (const [name, domain] of Object.entries(health.domains)) {
216
+ const codes = domain.codes.join(',') || '-';
217
+ const details = domain.details
218
+ ? Object.entries(domain.details).map(([key, value]) => `${key}=${formatHealthDetailValue(value)}`).join(' ')
219
+ : '';
220
+ console.log(` ${domain.level} ${name}: ${domain.message} codes=${codes}${domain.recommended_action ? ` action=${domain.recommended_action}` : ''}${details ? ` ${details}` : ''}`);
221
+ if (domain.hint) console.log(` hint: ${domain.hint}`);
222
+ }
223
+ }
224
+
225
+ async function watchLarkMessaging(serverClient: LockClient, flags: Record<string, string | boolean>): Promise<void> {
226
+ const appId = flag(flags, 'app-id') ?? flag(flags, 'id');
227
+ const timeoutMs = numberFlag(flags, 'timeout-ms', 60_000)!;
228
+ const intervalMs = numberFlag(flags, 'interval-ms', 1_000)!;
229
+ const json = boolFlag(flags, 'json');
230
+ if (timeoutMs <= 0) throw new Error('--timeout-ms must be positive');
231
+ if (intervalMs <= 0) throw new Error('--interval-ms must be positive');
232
+
233
+ const initial = await serverClient.getMessagingStatus();
234
+ const initialBot = appId
235
+ ? initial.lark.bots.find((bot) => bot.app_id === appId)
236
+ : initial.lark.bots[0];
237
+ if (!initialBot) throw new Error(appId ? `Lark bot not found: ${appId}` : 'No Lark bot is active on this server');
238
+ const targetAppId = initialBot.app_id;
239
+ const baseline = larkEventMarker(initialBot);
240
+ const baselineSnapshot = larkWatchSnapshot(initial, targetAppId);
241
+ const startedAt = Date.now();
242
+ let polls = 0;
243
+ let latest = initial;
244
+ let latestMarker = baseline;
245
+ let latestSnapshot = baselineSnapshot;
246
+
247
+ while (Date.now() - startedAt < timeoutMs) {
248
+ await sleep(Math.min(intervalMs, Math.max(1, timeoutMs - (Date.now() - startedAt))));
249
+ polls += 1;
250
+ latest = await serverClient.getMessagingStatus();
251
+ const bot = latest.lark.bots.find((candidate) => candidate.app_id === targetAppId);
252
+ if (!bot) throw new Error(`Lark bot disappeared while watching: ${targetAppId}`);
253
+ latestMarker = larkEventMarker(bot);
254
+ latestSnapshot = larkWatchSnapshot(latest, targetAppId);
255
+ if (!sameLarkEventMarker(baseline, latestMarker)) {
256
+ if (json) {
257
+ printJson({ observed: true, app_id: targetAppId, polls, baseline, latest: latestMarker, baseline_status: baselineSnapshot, latest_status: latestSnapshot, summary: latest.summary });
258
+ } else {
259
+ console.log(`Lark provider event observed. app=${targetAppId} polls=${polls} provider_events=${latestSnapshot.provider_event_count} total_events=${latestSnapshot.total_event_count} db_events=${latestMarker.db_event_count} latest=${latestMarker.db_last_provider_event_id ?? latestMarker.db_last_event_id ?? '-'} latest_at=${latestMarker.db_last_received_at ?? '-'}`);
260
+ }
261
+ return;
262
+ }
263
+ }
264
+
265
+ if (json) {
266
+ printJson({ observed: false, app_id: targetAppId, polls, timeout_ms: timeoutMs, baseline, latest: latestMarker, baseline_status: baselineSnapshot, latest_status: latestSnapshot, summary: latest.summary });
267
+ return;
268
+ }
269
+ const codes = latestSnapshot.diagnostics.join(',') || '-';
270
+ throw new Error(`Timed out waiting for Lark provider event for ${targetAppId} after ${timeoutMs}ms; provider_events=${latestSnapshot.provider_event_count} total_events=${latestSnapshot.total_event_count} latest=${latestMarker.db_last_provider_event_id ?? latestMarker.db_last_event_id ?? '-'} latest_at=${latestMarker.db_last_received_at ?? '-'} diagnostics=${codes}`);
271
+ }
272
+
273
+ async function verifyLarkIngressMessaging(serverClient: LockClient, flags: Record<string, string | boolean>): Promise<void> {
274
+ const appId = flag(flags, 'app-id') ?? flag(flags, 'id');
275
+ const timeoutMs = numberFlag(flags, 'timeout-ms', 60_000)!;
276
+ const intervalMs = numberFlag(flags, 'interval-ms', 1_000)!;
277
+ const recentLimit = numberFlag(flags, 'recent-limit', 5)!;
278
+ const json = boolFlag(flags, 'json');
279
+ if (timeoutMs <= 0) throw new Error('--timeout-ms must be positive');
280
+ if (intervalMs <= 0) throw new Error('--interval-ms must be positive');
281
+ if (recentLimit <= 0) throw new Error('--recent-limit must be positive');
282
+
283
+ const initial = await serverClient.getMessagingStatus();
284
+ const initialBot = appId
285
+ ? initial.lark.bots.find((bot) => bot.app_id === appId)
286
+ : initial.lark.bots[0];
287
+ if (!initialBot) throw new Error(appId ? `Lark bot not found: ${appId}` : 'No Lark bot is active on this server');
288
+ const targetAppId = initialBot.app_id;
289
+ const baseline = larkEventMarker(initialBot);
290
+ const baselineSnapshot = larkWatchSnapshot(initial, targetAppId);
291
+ const startedAt = Date.now();
292
+ let polls = 0;
293
+ let latest = initial;
294
+ let latestMarker = baseline;
295
+ let latestSnapshot = baselineSnapshot;
296
+
297
+ if (!json) {
298
+ console.log(`Lark ingress verification started. app=${targetAppId} timeout_ms=${timeoutMs}`);
299
+ console.log(' Send one real Feishu message to this bot now; local probe events are ignored.');
300
+ console.log(` baseline provider_events=${baselineSnapshot.provider_event_count} db_events=${baseline.db_event_count} latest=${baseline.db_last_provider_event_id ?? baseline.db_last_event_id ?? '-'} latest_at=${baseline.db_last_received_at ?? '-'}`);
301
+ }
302
+
303
+ while (Date.now() - startedAt < timeoutMs) {
304
+ await sleep(Math.min(intervalMs, Math.max(1, timeoutMs - (Date.now() - startedAt))));
305
+ polls += 1;
306
+ latest = await serverClient.getMessagingStatus();
307
+ const bot = latest.lark.bots.find((candidate) => candidate.app_id === targetAppId);
308
+ if (!bot) throw new Error(`Lark bot disappeared while verifying ingress: ${targetAppId}`);
309
+ latestMarker = larkEventMarker(bot);
310
+ latestSnapshot = larkWatchSnapshot(latest, targetAppId);
311
+ if (!sameLarkEventMarker(baseline, latestMarker)) {
312
+ const evidence = await larkIngressEvidence(serverClient, targetAppId, recentLimit);
313
+ const payload = {
314
+ observed: true,
315
+ app_id: targetAppId,
316
+ polls,
317
+ baseline,
318
+ latest: latestMarker,
319
+ baseline_status: baselineSnapshot,
320
+ latest_status: latestSnapshot,
321
+ health: evidence.health,
322
+ recent_events: evidence.recent,
323
+ summary: latest.summary,
324
+ };
325
+ if (json) {
326
+ printJson(payload);
327
+ return;
328
+ }
329
+ console.log(`Lark provider ingress verified. app=${targetAppId} polls=${polls} provider_events=${latestSnapshot.provider_event_count} total_events=${latestSnapshot.total_event_count} latest=${latestMarker.db_last_provider_event_id ?? latestMarker.db_last_event_id ?? '-'} latest_at=${latestMarker.db_last_received_at ?? '-'}`);
330
+ printLarkIngressEvidence(evidence);
331
+ return;
332
+ }
333
+ }
334
+
335
+ const evidence = await larkIngressEvidence(serverClient, targetAppId, recentLimit);
336
+ const payload = {
337
+ observed: false,
338
+ app_id: targetAppId,
339
+ polls,
340
+ timeout_ms: timeoutMs,
341
+ baseline,
342
+ latest: latestMarker,
343
+ baseline_status: baselineSnapshot,
344
+ latest_status: latestSnapshot,
345
+ health: evidence.health,
346
+ recent_events: evidence.recent,
347
+ summary: latest.summary,
348
+ };
349
+ if (json) {
350
+ printJson(payload);
351
+ return;
352
+ }
353
+ console.log(`No real Feishu provider event observed. app=${targetAppId} timeout_ms=${timeoutMs} polls=${polls}`);
354
+ console.log(` latest provider_events=${latestSnapshot.provider_event_count} db_events=${latestMarker.db_event_count} latest=${latestMarker.db_last_provider_event_id ?? latestMarker.db_last_event_id ?? '-'} latest_at=${latestMarker.db_last_received_at ?? '-'}`);
355
+ printLarkIngressEvidence(evidence);
356
+ throw new Error('Lark provider ingress verification failed; inspect Feishu long-connection event subscription or another consumer of this app stream.');
357
+ }
358
+
359
+ async function printRecentLarkEvents(serverClient: LockClient, flags: Record<string, string | boolean>): Promise<void> {
360
+ const limit = numberFlag(flags, 'limit', 20)!;
361
+ if (limit <= 0) throw new Error('--limit must be positive');
362
+ const events = await serverClient.listRecentLarkEvents(limit);
363
+ if (boolFlag(flags, 'json')) {
364
+ printJson({ events });
365
+ return;
366
+ }
367
+ if (events.length === 0) {
368
+ console.log('No recent Lark inbound events.');
369
+ return;
370
+ }
371
+ for (const event of events) {
372
+ console.log(`${event.received_at} app=${event.app_id} type=${event.event_type} id=${event.event_id} parse_ok=${event.parse_ok} bytes=${event.bytes}${event.is_probe ? ' probe=true' : ''}`);
373
+ }
374
+ }
375
+
376
+ async function repairLarkEventParseFailures(serverClient: LockClient, flags: Record<string, string | boolean>): Promise<void> {
377
+ const appId = flag(flags, 'app-id') ?? flag(flags, 'id');
378
+ const limit = numberFlag(flags, 'limit', 100)!;
379
+ if (limit <= 0) throw new Error('--limit must be positive');
380
+ const dryRun = !boolFlag(flags, 'apply');
381
+ const result = await serverClient.repairLarkEventParseFailures({ appId, limit, dryRun });
382
+ if (boolFlag(flags, 'json')) {
383
+ printJson(result);
384
+ return;
385
+ }
386
+ const mode = result.dry_run ? 'dry-run' : 'applied';
387
+ console.log(`Lark raw event parse repair ${mode}: scanned=${result.scanned} repaired=${result.repaired} unchanged=${result.unchanged} conflicts=${result.conflicts} errors=${result.errors}`);
388
+ for (const row of result.rows.slice(0, 20)) {
389
+ console.log(` ${row.status} app=${row.app_id} id=${row.old_event_id} -> ${row.new_event_id} type=${row.old_event_type} -> ${row.new_event_type}`);
390
+ if (row.error) console.log(` error=${row.error}`);
391
+ }
392
+ if (result.rows.length > 20) {
393
+ console.log(` ... ${result.rows.length - 20} more row(s) omitted`);
394
+ }
395
+ }
396
+
397
+ async function probeDeliveryMessaging(serverClient: LockClient, flags: Record<string, string | boolean>): Promise<void> {
398
+ const agent = flag(flags, 'agent') ?? flag(flags, 'to') ?? 'lock';
399
+ const room = flag(flags, 'room') ?? flag(flags, 'chat');
400
+ const sender = flag(flags, 'sender') ?? flag(flags, 'from');
401
+ const content = flag(flags, 'text') ?? flag(flags, 'content');
402
+ const timeoutMs = numberFlag(flags, 'timeout-ms', 120_000)!;
403
+ const intervalMs = numberFlag(flags, 'interval-ms', 1_000)!;
404
+ if (timeoutMs < 0) throw new Error('--timeout-ms must be zero or positive');
405
+ if (intervalMs <= 0) throw new Error('--interval-ms must be positive');
406
+
407
+ const created = await serverClient.probeDelivery({ agent, room, sender, content });
408
+ const deliveryIds = created.deliveries.map((delivery) => delivery.id);
409
+ const payload = {
410
+ created,
411
+ final_deliveries: created.deliveries,
412
+ replies: [] as Awaited<ReturnType<LockClient['getMessages']>>,
413
+ timed_out: false,
414
+ };
415
+
416
+ if (deliveryIds.length > 0 && timeoutMs > 0) {
417
+ const startedAt = Date.now();
418
+ while (Date.now() - startedAt < timeoutMs) {
419
+ const deliveries = await Promise.all(deliveryIds.map((id) => serverClient.getDelivery(id)));
420
+ payload.final_deliveries = deliveries;
421
+ if (deliveries.every((delivery) => isTerminalDeliveryStatus(delivery.status))) break;
422
+ await sleep(Math.min(intervalMs, Math.max(1, timeoutMs - (Date.now() - startedAt))));
423
+ }
424
+ if (!payload.final_deliveries.every((delivery) => isTerminalDeliveryStatus(delivery.status))) {
425
+ payload.timed_out = true;
426
+ }
427
+ } else if (deliveryIds.length > 0) {
428
+ payload.final_deliveries = await Promise.all(deliveryIds.map((id) => serverClient.getDelivery(id)));
429
+ }
430
+
431
+ const params = new URLSearchParams({ chat: created.probe.room, q: created.probe.token, limit: '20' });
432
+ payload.replies = (await serverClient.getMessages(params)).filter((message) => message.id !== created.message.id);
433
+
434
+ if (boolFlag(flags, 'json')) {
435
+ printJson(payload);
436
+ return;
437
+ }
438
+
439
+ console.log(`Delivery probe created. agent=${created.probe.agent} room=${created.probe.room} message=${created.message.id} deliveries=${deliveryIds.join(',') || '-'}`);
440
+ console.log(` notify deliveries=${created.notify.deliveries} target_connections=${created.notify.target_connections} open_sockets=${created.notify.open_sockets} websocket_frames=${created.notify.websocket_frames}`);
441
+ for (const delivery of payload.final_deliveries) {
442
+ console.log(` delivery ${delivery.id} status=${delivery.status} attempts=${delivery.attempts} run=${delivery.run_id ?? '-'} error=${delivery.last_error || '-'}`);
443
+ }
444
+ if (payload.replies.length > 0) {
445
+ for (const reply of payload.replies) {
446
+ console.log(` reply message=${reply.id} sender=${reply.sender} recipient=${reply.recipient ?? '-'} content=${reply.content}`);
447
+ }
448
+ }
449
+ if (payload.timed_out) {
450
+ throw new Error(`delivery probe timed out after ${timeoutMs}ms`);
451
+ }
452
+ if (payload.final_deliveries.length === 0) {
453
+ throw new Error('delivery probe created no deliveries');
454
+ }
455
+ const failed = payload.final_deliveries.find((delivery) => delivery.status !== 'acked');
456
+ if (failed) {
457
+ throw new Error(`delivery probe did not ack delivery ${failed.id}; status=${failed.status}`);
458
+ }
459
+ }
460
+
461
+ async function repairLarkMessaging(serverClient: LockClient, flags: Record<string, string | boolean>): Promise<void> {
462
+ const dryRun = boolFlag(flags, 'dry-run');
463
+ const json = boolFlag(flags, 'json');
464
+ const status = await serverClient.getMessagingStatus();
465
+ const appIds = selectRestartableLarkBotAppIds(status.lark.bots);
466
+ const result = dryRun || appIds.length === 0
467
+ ? null
468
+ : await serverClient.restartLarkWebSocket({ appIds });
469
+
470
+ if (json) {
471
+ printJson({
472
+ dry_run: dryRun,
473
+ restart_app_ids: appIds,
474
+ restarted: result?.restarted ?? [],
475
+ missing: result?.missing ?? [],
476
+ summary: status.summary,
477
+ });
478
+ return;
479
+ }
480
+
481
+ if (appIds.length === 0) {
482
+ console.log('No Lark websocket repair needed.');
483
+ return;
484
+ }
485
+ if (dryRun) {
486
+ console.log(`Lark websocket repair would restart: ${appIds.join(',')}`);
487
+ return;
488
+ }
489
+ console.log(`Lark websocket repair requested. restarted=${result?.restarted.join(',') || '-'} missing=${result?.missing.join(',') || '-'}`);
490
+ }
491
+
492
+ function pushCheck(checks: LarkDoctorCheck[], check: LarkDoctorCheck): void {
493
+ checks.push(check);
494
+ }
495
+
496
+ function statusDiagnosticsForApp(status: Awaited<ReturnType<LockClient['getMessagingStatus']>>, appId: string) {
497
+ return status.diagnostics.filter((diagnostic) => diagnostic.message.includes(appId));
498
+ }
499
+
500
+ async function doctorLarkMessaging(serverClient: LockClient, flags: Record<string, string | boolean>): Promise<void> {
501
+ const appId = flag(flags, 'app-id') ?? flag(flags, 'id');
502
+ const configPath = larkConfigPath(flags);
503
+ const json = boolFlag(flags, 'json');
504
+ const budgetMs = numberFlag(flags, 'budget-ms', 10_000)!;
505
+ const store = loadLarkCredentials(configPath);
506
+ const targets = appId ? store.bots.filter((bot) => bot.appId === appId) : store.bots;
507
+ if (targets.length === 0) throw new Error(appId ? `No Lark bot found in ${configPath}: ${appId}` : `No Lark bots configured in ${configPath}`);
508
+
509
+ const status = await serverClient.getMessagingStatus();
510
+ const bots: LarkDoctorBot[] = [];
511
+ let warnCount = 0;
512
+
513
+ for (const bot of targets) {
514
+ const checks: LarkDoctorCheck[] = [];
515
+ const serverBot = status.lark.bots.find((candidate) => candidate.app_id === bot.appId);
516
+ const agents = bot.agent?.trim() ? [bot.agent.trim()] : [];
517
+
518
+ pushCheck(checks, {
519
+ code: 'local_config_loaded',
520
+ level: 'ok',
521
+ message: `Local config contains app ${bot.appId} at ${configPath}.`,
522
+ });
523
+ pushCheck(checks, bot.appSecret?.trim()
524
+ ? { code: 'local_secret_present', level: 'ok', message: 'Local app secret is present (redacted).' }
525
+ : { code: 'local_secret_missing', level: 'warn', message: 'Local app secret is missing.', hint: 'Rebind this Lark bot with --lark-app-id and --lark-app-secret.' });
526
+ pushCheck(checks, agents.length > 0
527
+ ? { code: 'local_agent_bound', level: 'ok', message: `Bot is bound to agent ${agents.join(',')}.` }
528
+ : { code: 'local_agent_missing', level: 'warn', message: 'Bot is not bound to a Pal agent.', hint: 'Bind the bot to an agent before expecting Feishu messages to create work.' });
529
+
530
+ if (!serverBot) {
531
+ pushCheck(checks, {
532
+ code: 'server_bot_missing',
533
+ level: 'warn',
534
+ message: 'Server is not running this Lark bot.',
535
+ hint: 'Run messaging restart-lark or POST /api/lark/reload after saving the bot config.',
536
+ });
537
+ } else {
538
+ pushCheck(checks, serverBot.ws_state === 'connected'
539
+ ? { code: 'server_ws_connected', level: 'ok', message: 'Server Lark websocket is connected.' }
540
+ : { code: 'server_ws_not_connected', level: 'warn', message: `Server Lark websocket state is ${serverBot.ws_state ?? 'unknown'}.`, hint: 'Run messaging repair-lark and inspect server logs if reconnect fails.' });
541
+ pushCheck(checks, serverBot.authorized_user_count && serverBot.authorized_user_count > 0
542
+ ? { code: 'authorized_users_present', level: 'ok', message: `${serverBot.authorized_user_count} authorized Feishu user(s) are configured.` }
543
+ : { code: 'authorized_users_missing', level: 'warn', message: 'No authorized Feishu sender union IDs are configured.', hint: 'Add the sender with lark-users add, otherwise real events are stored but skipped.' });
544
+ const runtimeDiagnostics = statusDiagnosticsForApp(status, bot.appId);
545
+ for (const diagnostic of runtimeDiagnostics) {
546
+ if (diagnostic.level !== 'warn') continue;
547
+ pushCheck(checks, {
548
+ code: `runtime_${diagnostic.code}`,
549
+ level: 'warn',
550
+ message: diagnostic.message,
551
+ hint: diagnostic.hint,
552
+ });
553
+ }
554
+ }
555
+
556
+ if (bot.appSecret?.trim()) {
557
+ const botInfo = await resolveBotInfoForDoctor(bot.appId, bot.appSecret, { budgetMs });
558
+ if (botInfo.ok) {
559
+ pushCheck(checks, {
560
+ code: 'openapi_credentials_valid',
561
+ level: 'ok',
562
+ message: `OpenAPI credentials are valid${botInfo.appName ? ` for ${botInfo.appName}` : ''}.`,
563
+ });
564
+ if (bot.botOpenId?.trim()) {
565
+ pushCheck(checks, bot.botOpenId === botInfo.openId
566
+ ? { code: 'bot_open_id_matches', level: 'ok', message: 'Configured bot open_id matches OpenAPI bot info.' }
567
+ : { code: 'bot_open_id_mismatch', level: 'warn', message: 'Configured bot open_id does not match OpenAPI bot info.', hint: 'Rebind the Lark bot so mention routing uses the current app identity.' });
568
+ } else {
569
+ pushCheck(checks, {
570
+ code: 'bot_open_id_missing',
571
+ level: 'warn',
572
+ message: 'Local config does not store the bot open_id.',
573
+ hint: 'Rebind the Lark bot to cache bot open_id for reliable mention routing.',
574
+ });
575
+ }
576
+ } else {
577
+ pushCheck(checks, {
578
+ code: `openapi_${botInfo.error}`,
579
+ level: 'warn',
580
+ message: `OpenAPI credential check failed: ${botInfo.message}`,
581
+ hint: botInfo.error === 'invalid_credentials' ? 'Rebind the app with the current App ID and App Secret.' : 'Check network access to open.feishu.cn and retry.',
582
+ });
583
+ }
584
+ }
585
+
586
+ const stale = checks.some((check) => (
587
+ check.code === 'runtime_lark_events_stale'
588
+ || check.code === 'runtime_lark_no_events_since_restart'
589
+ || check.code === 'runtime_lark_probe_only_since_restart'
590
+ || check.code === 'runtime_lark_restart_ineffective'
591
+ ));
592
+ pushCheck(checks, {
593
+ code: 'platform_event_subscription_manual',
594
+ level: stale ? 'warn' : 'ok',
595
+ message: 'Feishu event subscription state cannot be verified through Pal local state.',
596
+ hint: stale
597
+ ? 'In Feishu developer console, verify this app uses long-connection event delivery and subscribes to im.message.receive_v1; also ensure no other environment is consuming the same app stream.'
598
+ : 'If real Feishu messages do not arrive, verify long-connection event delivery and im.message.receive_v1 in Feishu developer console.',
599
+ });
600
+ pushCheck(checks, {
601
+ code: 'platform_long_connection_cluster_mode',
602
+ level: stale ? 'warn' : 'ok',
603
+ message: 'Feishu long-connection delivery is cluster-mode: multiple clients for the same app share events instead of each receiving every event.',
604
+ hint: stale
605
+ ? 'Stop other local, demo, staging, or production websocket clients using this App ID while verifying ingress; otherwise a fresh message may be delivered to a different client.'
606
+ : 'Keep only the intended Pal server connected for this App ID when running deterministic E2E checks.',
607
+ });
608
+
609
+ const runtimeCodes = checks
610
+ .filter((check) => check.code.startsWith('runtime_'))
611
+ .map((check) => check.code.replace(/^runtime_/, ''));
612
+ const recommended = recommendLarkProviderAction(runtimeCodes);
613
+ warnCount += checks.filter((check) => check.level === 'warn').length;
614
+ bots.push({
615
+ app_id: bot.appId,
616
+ label: bot.label?.trim() || null,
617
+ agent: bot.agent?.trim() || null,
618
+ ...(recommended ? { recommended_action: recommended } : {}),
619
+ checks,
620
+ });
621
+ }
622
+
623
+ const payload = {
624
+ level: warnCount > 0 ? 'warn' : 'ok',
625
+ warn_count: warnCount,
626
+ config_path: configPath,
627
+ server: serverClient.baseUrl,
628
+ bots,
629
+ };
630
+ if (json) {
631
+ printJson(payload);
632
+ return;
633
+ }
634
+
635
+ console.log(`lark doctor: ${payload.level} warnings=${warnCount} config=${configPath} server=${serverClient.baseUrl}`);
636
+ for (const bot of bots) {
637
+ console.log(` app=${bot.app_id} agent=${bot.agent ?? '-'} label=${bot.label ?? '-'}`);
638
+ if (bot.recommended_action) console.log(` action: ${bot.recommended_action}`);
639
+ for (const check of bot.checks) {
640
+ console.log(` ${check.level} ${check.code}: ${check.message}`);
641
+ if (check.hint) console.log(` hint: ${check.hint}`);
642
+ }
643
+ }
644
+ }
645
+
646
+ async function probeLarkMessaging(serverClient: LockClient, flags: Record<string, string | boolean>): Promise<void> {
647
+ const appId = flag(flags, 'app-id') ?? flag(flags, 'id');
648
+ const senderUserId = flag(flags, 'sender-user-id') ?? flag(flags, 'user-id');
649
+ const chatId = flag(flags, 'chat-id');
650
+ if (!appId) throw new Error('--app-id is required');
651
+ if (!senderUserId) throw new Error('--sender-user-id is required');
652
+ if (!chatId) throw new Error('--chat-id is required');
653
+
654
+ const mentionOpenId = flag(flags, 'mention-open-id');
655
+ const text = flag(flags, 'text') ?? (mentionOpenId ? '@Probe hello from pal probe' : 'hello from pal probe');
656
+ const messageId = flag(flags, 'message-id') ?? `probe_${Date.now()}_${crypto.randomUUID()}`;
657
+ const data = {
658
+ sender: { sender_id: { open_id: flag(flags, 'sender-open-id') ?? `probe_${senderUserId}`, union_id: senderUserId }, sender_type: 'user' },
659
+ message: {
660
+ message_id: messageId,
661
+ chat_id: chatId,
662
+ chat_type: flag(flags, 'chat-type') ?? 'group',
663
+ message_type: 'text',
664
+ content: JSON.stringify({ text }),
665
+ ...(mentionOpenId ? { mentions: [{ id: { open_id: mentionOpenId }, name: flag(flags, 'mention-name') ?? 'Probe', key: '@_probe_1' }] } : {}),
666
+ },
667
+ };
668
+ const result = await serverClient.probeLarkEvent({ appId, envelope: 'im.message.receive_v1', data });
669
+ if (boolFlag(flags, 'json')) {
670
+ printJson(result);
671
+ return;
672
+ }
673
+ console.log(`Lark probe injected. app=${result.app_id} event=${result.raw_event_id} inserted=${result.inserted} handled=${result.handled} parse_ok=${result.parse_ok}`);
674
+ }
675
+
676
+ export async function runMessagingCommand(serverClient: LockClient, args: MessagingCliArgs, usage: () => string): Promise<void> {
677
+ const sub = args.values[0];
678
+
679
+ if (sub === 'status') {
680
+ await printMessagingStatus(serverClient, args.flags);
681
+ return;
682
+ }
683
+
684
+ if (sub === 'health') {
685
+ await printMessagingHealth(serverClient, args.flags);
686
+ return;
687
+ }
688
+
689
+ if (sub === 'restart-lark') {
690
+ const appId = flag(args.flags, 'app-id') ?? flag(args.flags, 'id');
691
+ const result = await serverClient.restartLarkWebSocket(appId ? { appId } : {});
692
+ if (args.flags.json) {
693
+ printJson(result);
694
+ } else {
695
+ console.log(`Lark websocket restart requested. restarted=${result.restarted.join(',') || '-'} missing=${result.missing.join(',') || '-'}`);
696
+ }
697
+ return;
698
+ }
699
+
700
+ if (sub === 'repair-lark') {
701
+ await repairLarkMessaging(serverClient, args.flags);
702
+ return;
703
+ }
704
+
705
+ if (sub === 'doctor-lark') {
706
+ await doctorLarkMessaging(serverClient, args.flags);
707
+ return;
708
+ }
709
+
710
+ if (sub === 'watch-lark') {
711
+ await watchLarkMessaging(serverClient, args.flags);
712
+ return;
713
+ }
714
+
715
+ if (sub === 'verify-lark-ingress') {
716
+ await verifyLarkIngressMessaging(serverClient, args.flags);
717
+ return;
718
+ }
719
+
720
+ if (sub === 'recent-lark') {
721
+ await printRecentLarkEvents(serverClient, args.flags);
722
+ return;
723
+ }
724
+
725
+ if (sub === 'repair-lark-events') {
726
+ await repairLarkEventParseFailures(serverClient, args.flags);
727
+ return;
728
+ }
729
+
730
+ if (sub === 'probe-delivery') {
731
+ await probeDeliveryMessaging(serverClient, args.flags);
732
+ return;
733
+ }
734
+
735
+ if (sub === 'probe-lark') {
736
+ await probeLarkMessaging(serverClient, args.flags);
737
+ return;
738
+ }
739
+
740
+ throw new Error(`unknown messaging subcommand: ${sub ?? '(none)'}\n\n${usage()}`);
741
+ }