@tokagent/tokagentos 2.0.24 → 2.0.29

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.
@@ -0,0 +1,924 @@
1
+ import type http from "node:http";
2
+ import {
3
+ type ConversationMetadata,
4
+ type ConversationScope,
5
+ extractConversationMetadataFromRoom,
6
+ isAutomationConversationMetadata,
7
+ listTriggerTasks,
8
+ loadElizaConfig,
9
+ type TriggerSummary,
10
+ taskToTriggerSummary,
11
+ toWorkbenchTask,
12
+ } from "@elizaos/agent";
13
+ import { LifeOpsService } from "@elizaos/app-lifeops/lifeops/service";
14
+ import {
15
+ type AgentRuntime,
16
+ logger,
17
+ type Room,
18
+ stringToUuid,
19
+ type UUID,
20
+ } from "@elizaos/core";
21
+ import type {
22
+ LifeOpsDiscordConnectorStatus,
23
+ LifeOpsGoogleConnectorStatus,
24
+ LifeOpsSignalConnectorStatus,
25
+ LifeOpsTelegramConnectorStatus,
26
+ } from "@elizaos/shared";
27
+ import { ensureRouteAuthorized } from "./auth";
28
+ import type { N8nStatusResponse, N8nWorkflow } from "./client-types-chat";
29
+ import type {
30
+ AutomationItem,
31
+ AutomationNodeCatalogResponse,
32
+ AutomationNodeDescriptor,
33
+ AutomationRoomBinding,
34
+ AutomationSummary,
35
+ WorkbenchTask,
36
+ } from "./client-types-config";
37
+ import type { CompatRuntimeState } from "./compat-route-shared";
38
+ import { handleN8nRoutes } from "./n8n-routes";
39
+ import {
40
+ sendJsonError as sendJsonErrorResponse,
41
+ sendJson as sendJsonResponse,
42
+ } from "./response";
43
+
44
+ interface AutomationListResponse {
45
+ automations: AutomationItem[];
46
+ summary: AutomationSummary;
47
+ n8nStatus: N8nStatusResponse | null;
48
+ workflowFetchError: string | null;
49
+ }
50
+
51
+ interface AutomationRoomRecord {
52
+ title: string;
53
+ roomId: string;
54
+ conversationId: string | null;
55
+ metadata: ConversationMetadata;
56
+ updatedAt: string | null;
57
+ }
58
+
59
+ interface N8nRouteCapture<T> {
60
+ status: number;
61
+ payload: T | null;
62
+ }
63
+
64
+ const WORKFLOW_DRAFT_TITLE = "New Workflow Draft";
65
+ const SYSTEM_TASK_NAMES = new Set([
66
+ "EMBEDDING_DRAIN",
67
+ "PROACTIVE_AGENT",
68
+ "LIFEOPS_SCHEDULER",
69
+ "TRIGGER_DISPATCH",
70
+ "heartbeat",
71
+ ]);
72
+ const BLOCKED_AUTOMATION_PROVIDER_NODES = new Set([
73
+ "recent-conversations",
74
+ "relevant-conversations",
75
+ ]);
76
+
77
+ function asRecord(value: unknown): Record<string, unknown> | null {
78
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
79
+ return null;
80
+ }
81
+ return value as Record<string, unknown>;
82
+ }
83
+
84
+ function asString(value: unknown): string | undefined {
85
+ if (typeof value !== "string") {
86
+ return undefined;
87
+ }
88
+ const trimmed = value.trim();
89
+ return trimmed.length > 0 ? trimmed : undefined;
90
+ }
91
+
92
+ function normalizeDateValue(value: unknown): string | null {
93
+ if (typeof value === "string") {
94
+ const parsed = Date.parse(value);
95
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
96
+ }
97
+ if (typeof value === "number" && Number.isFinite(value)) {
98
+ return new Date(value).toISOString();
99
+ }
100
+ if (value instanceof Date) {
101
+ return value.toISOString();
102
+ }
103
+ return null;
104
+ }
105
+
106
+ function humanizeCapabilityName(value: string): string {
107
+ return value
108
+ .trim()
109
+ .replace(/[_-]+/g, " ")
110
+ .replace(/\s+/g, " ")
111
+ .toLowerCase()
112
+ .replace(/\b\w/g, (char) => char.toUpperCase());
113
+ }
114
+
115
+ function resolveAgentName(
116
+ runtime: AgentRuntime | null,
117
+ config: ReturnType<typeof loadElizaConfig>,
118
+ ): string {
119
+ return (
120
+ runtime?.character?.name?.trim() ||
121
+ config.ui?.assistant?.name?.trim() ||
122
+ "Eliza"
123
+ );
124
+ }
125
+
126
+ function resolveAdminEntityId(
127
+ config: ReturnType<typeof loadElizaConfig>,
128
+ agentName: string,
129
+ ): UUID {
130
+ const configured = config.agents?.defaults?.adminEntityId?.trim();
131
+ if (configured) {
132
+ return configured as UUID;
133
+ }
134
+ return stringToUuid(`${agentName}-admin-entity`) as UUID;
135
+ }
136
+
137
+ function isSystemTask(task: WorkbenchTask): boolean {
138
+ if (SYSTEM_TASK_NAMES.has(task.name)) {
139
+ return true;
140
+ }
141
+ const tags = new Set(task.tags ?? []);
142
+ return tags.has("queue") && tags.has("repeat");
143
+ }
144
+
145
+ function choosePreferredSystemTask(
146
+ current: WorkbenchTask,
147
+ candidate: WorkbenchTask,
148
+ ): WorkbenchTask {
149
+ const currentHasDescription = current.description.trim().length > 0;
150
+ const candidateHasDescription = candidate.description.trim().length > 0;
151
+ if (candidateHasDescription && !currentHasDescription) {
152
+ return candidate;
153
+ }
154
+ if (currentHasDescription && !candidateHasDescription) {
155
+ return current;
156
+ }
157
+ return (candidate.updatedAt ?? 0) > (current.updatedAt ?? 0)
158
+ ? candidate
159
+ : current;
160
+ }
161
+
162
+ function deduplicateSystemTasks(tasks: WorkbenchTask[]): WorkbenchTask[] {
163
+ const systemTasksByName = new Map<string, WorkbenchTask>();
164
+ const userTasks: WorkbenchTask[] = [];
165
+
166
+ for (const task of tasks) {
167
+ if (!isSystemTask(task)) {
168
+ userTasks.push(task);
169
+ continue;
170
+ }
171
+ const existing = systemTasksByName.get(task.name);
172
+ if (!existing) {
173
+ systemTasksByName.set(task.name, task);
174
+ continue;
175
+ }
176
+ systemTasksByName.set(task.name, choosePreferredSystemTask(existing, task));
177
+ }
178
+
179
+ return [...userTasks, ...systemTasksByName.values()];
180
+ }
181
+
182
+ function buildRoomBinding(
183
+ room: AutomationRoomRecord | undefined,
184
+ ): AutomationRoomBinding | null {
185
+ if (!room) {
186
+ return null;
187
+ }
188
+ return {
189
+ conversationId: room.conversationId,
190
+ roomId: room.roomId,
191
+ scope: (room.metadata.scope ?? "general") as ConversationScope,
192
+ ...(room.metadata.sourceConversationId
193
+ ? { sourceConversationId: room.metadata.sourceConversationId }
194
+ : {}),
195
+ ...(room.metadata.terminalBridgeConversationId
196
+ ? {
197
+ terminalBridgeConversationId:
198
+ room.metadata.terminalBridgeConversationId,
199
+ }
200
+ : {}),
201
+ };
202
+ }
203
+
204
+ function readAutomationRoomRecord(
205
+ room: Record<string, unknown>,
206
+ ): AutomationRoomRecord | null {
207
+ const roomId = asString(room.id);
208
+ if (!roomId) {
209
+ return null;
210
+ }
211
+
212
+ const metadata = extractConversationMetadataFromRoom(
213
+ room as unknown as Pick<Room, "metadata">,
214
+ );
215
+ if (!metadata || !isAutomationConversationMetadata(metadata)) {
216
+ return null;
217
+ }
218
+
219
+ const webConversation = asRecord(asRecord(room.metadata)?.webConversation);
220
+
221
+ return {
222
+ title: asString(room.name) ?? "Automation",
223
+ roomId,
224
+ conversationId: asString(webConversation?.conversationId) ?? null,
225
+ metadata,
226
+ updatedAt: normalizeDateValue(room.updatedAt),
227
+ };
228
+ }
229
+
230
+ async function listAutomationRooms(
231
+ runtime: AgentRuntime,
232
+ agentName: string,
233
+ ): Promise<AutomationRoomRecord[]> {
234
+ const worldId = stringToUuid(`${agentName}-web-chat-world`) as UUID;
235
+ const rooms = await runtime.getRooms(worldId);
236
+ return rooms
237
+ .map((room) =>
238
+ readAutomationRoomRecord(room as unknown as Record<string, unknown>),
239
+ )
240
+ .filter((room): room is AutomationRoomRecord => room !== null);
241
+ }
242
+
243
+ async function invokeN8nCompatRoute<T>(
244
+ req: http.IncomingMessage,
245
+ res: http.ServerResponse,
246
+ state: CompatRuntimeState,
247
+ pathname: string,
248
+ ): Promise<N8nRouteCapture<T>> {
249
+ let payload: T | null = null;
250
+ let status = 200;
251
+
252
+ await handleN8nRoutes({
253
+ req,
254
+ res,
255
+ method: "GET",
256
+ pathname,
257
+ config: loadElizaConfig(),
258
+ runtime: state.current,
259
+ json: (_res, body, nextStatus = 200) => {
260
+ payload = body as T;
261
+ status = nextStatus;
262
+ },
263
+ });
264
+
265
+ return { status, payload };
266
+ }
267
+
268
+ function extractErrorMessage(payload: unknown): string | null {
269
+ const record = asRecord(payload);
270
+ const errorValue = record?.error ?? record?.message;
271
+ return typeof errorValue === "string" && errorValue.trim().length > 0
272
+ ? errorValue
273
+ : null;
274
+ }
275
+
276
+ function buildCoordinatorTaskItem(
277
+ task: WorkbenchTask,
278
+ room: AutomationRoomRecord | undefined,
279
+ ): AutomationItem {
280
+ const system = isSystemTask(task);
281
+ return {
282
+ id: `task:${task.id}`,
283
+ type: "coordinator_text",
284
+ source: "workbench_task",
285
+ title: task.name,
286
+ description: task.description,
287
+ status: system ? "system" : task.isCompleted ? "completed" : "active",
288
+ enabled: !task.isCompleted,
289
+ system,
290
+ isDraft: false,
291
+ hasBackingWorkflow: false,
292
+ updatedAt: room?.updatedAt ?? normalizeDateValue(task.updatedAt),
293
+ taskId: task.id,
294
+ task,
295
+ schedules: [],
296
+ room: buildRoomBinding(room),
297
+ };
298
+ }
299
+
300
+ function buildCoordinatorTriggerItem(
301
+ trigger: TriggerSummary,
302
+ room: AutomationRoomRecord | undefined,
303
+ ): AutomationItem {
304
+ return {
305
+ id: `trigger:${trigger.id}`,
306
+ type: "coordinator_text",
307
+ source: "trigger",
308
+ title: trigger.displayName,
309
+ description: trigger.instructions,
310
+ status: trigger.enabled ? "active" : "paused",
311
+ enabled: trigger.enabled,
312
+ system: false,
313
+ isDraft: false,
314
+ hasBackingWorkflow: false,
315
+ updatedAt:
316
+ room?.updatedAt ??
317
+ normalizeDateValue(trigger.updatedAt) ??
318
+ normalizeDateValue(trigger.lastRunAtIso),
319
+ triggerId: trigger.id,
320
+ trigger,
321
+ schedules: [trigger],
322
+ room: buildRoomBinding(room),
323
+ };
324
+ }
325
+
326
+ function buildWorkflowDraftItem(room: AutomationRoomRecord): AutomationItem {
327
+ const metadata = room.metadata;
328
+ const title =
329
+ metadata.workflowName?.trim() || room.title.trim() || WORKFLOW_DRAFT_TITLE;
330
+ return {
331
+ id: `workflow-draft:${metadata.draftId}`,
332
+ type: "n8n_workflow",
333
+ source: "workflow_draft",
334
+ title,
335
+ description: "",
336
+ status: "draft",
337
+ enabled: true,
338
+ system: false,
339
+ isDraft: true,
340
+ hasBackingWorkflow: false,
341
+ updatedAt: room.updatedAt,
342
+ draftId: room.metadata.draftId,
343
+ schedules: [],
344
+ room: buildRoomBinding(room),
345
+ };
346
+ }
347
+
348
+ function buildAutomationDraftItem(room: AutomationRoomRecord): AutomationItem {
349
+ const metadata = room.metadata;
350
+ const trimmedTitle = room.title.trim();
351
+ const title =
352
+ trimmedTitle && trimmedTitle.toLowerCase() !== "default"
353
+ ? trimmedTitle
354
+ : "New automation";
355
+ return {
356
+ id: `automation-draft:${metadata.draftId}`,
357
+ type: "automation_draft",
358
+ source: "automation_draft",
359
+ title,
360
+ description: "",
361
+ status: "draft",
362
+ enabled: true,
363
+ system: false,
364
+ isDraft: true,
365
+ hasBackingWorkflow: false,
366
+ updatedAt: room.updatedAt,
367
+ draftId: metadata.draftId,
368
+ schedules: [],
369
+ room: buildRoomBinding(room),
370
+ };
371
+ }
372
+
373
+ function buildWorkflowItem(
374
+ workflow: N8nWorkflow | undefined,
375
+ room: AutomationRoomRecord | undefined,
376
+ fallback: {
377
+ workflowId: string;
378
+ workflowName?: string;
379
+ trigger?: TriggerSummary;
380
+ },
381
+ ): AutomationItem {
382
+ const missingBackingWorkflow = !workflow && !fallback.trigger;
383
+ const title =
384
+ workflow?.name?.trim() ||
385
+ room?.metadata.workflowName?.trim() ||
386
+ fallback.workflowName?.trim() ||
387
+ fallback.workflowId;
388
+ const enabled =
389
+ missingBackingWorkflow === true
390
+ ? false
391
+ : (workflow?.active ?? fallback.trigger?.enabled ?? false);
392
+ const description =
393
+ workflow?.description?.trim() ||
394
+ (fallback.trigger ? `Scheduled workflow automation for ${title}.` : "");
395
+
396
+ return {
397
+ id: `workflow:${fallback.workflowId}`,
398
+ type: "n8n_workflow",
399
+ source: workflow ? "n8n_workflow" : "workflow_shadow",
400
+ title,
401
+ description,
402
+ status: missingBackingWorkflow ? "draft" : enabled ? "active" : "paused",
403
+ enabled,
404
+ system: false,
405
+ isDraft: missingBackingWorkflow,
406
+ hasBackingWorkflow: Boolean(workflow),
407
+ updatedAt:
408
+ room?.updatedAt ??
409
+ normalizeDateValue(fallback.trigger?.updatedAt) ??
410
+ normalizeDateValue(fallback.trigger?.lastRunAtIso),
411
+ workflowId: fallback.workflowId,
412
+ workflow,
413
+ schedules: fallback.trigger ? [fallback.trigger] : [],
414
+ room: buildRoomBinding(room),
415
+ };
416
+ }
417
+
418
+ function compareAutomationItems(
419
+ left: AutomationItem,
420
+ right: AutomationItem,
421
+ ): number {
422
+ if (left.system !== right.system) {
423
+ return left.system ? 1 : -1;
424
+ }
425
+ if (left.isDraft !== right.isDraft) {
426
+ return left.isDraft ? -1 : 1;
427
+ }
428
+ const leftUpdated = left.updatedAt ? Date.parse(left.updatedAt) : 0;
429
+ const rightUpdated = right.updatedAt ? Date.parse(right.updatedAt) : 0;
430
+ if (rightUpdated !== leftUpdated) {
431
+ return rightUpdated - leftUpdated;
432
+ }
433
+ return left.title.localeCompare(right.title);
434
+ }
435
+
436
+ async function buildAutomationListResponse(
437
+ req: http.IncomingMessage,
438
+ res: http.ServerResponse,
439
+ state: CompatRuntimeState,
440
+ ): Promise<AutomationListResponse> {
441
+ const runtime = state.current;
442
+ if (!runtime) {
443
+ throw new Error("Agent runtime is not available");
444
+ }
445
+
446
+ const config = loadElizaConfig();
447
+ const agentName = resolveAgentName(runtime, config);
448
+ const rooms = await listAutomationRooms(runtime, agentName);
449
+ const taskRooms = new Map(
450
+ rooms
451
+ .filter((room) => room.metadata.taskId)
452
+ .map((room) => [room.metadata.taskId as string, room]),
453
+ );
454
+ const triggerRooms = new Map(
455
+ rooms
456
+ .filter((room) => room.metadata.triggerId)
457
+ .map((room) => [room.metadata.triggerId as string, room]),
458
+ );
459
+ const workflowRooms = new Map(
460
+ rooms
461
+ .filter((room) => room.metadata.workflowId)
462
+ .map((room) => [room.metadata.workflowId as string, room]),
463
+ );
464
+ const workflowDraftItems = rooms
465
+ .filter((room) => room.metadata.scope === "automation-workflow-draft")
466
+ .filter((room) => typeof room.metadata.draftId === "string")
467
+ .map((room) => buildWorkflowDraftItem(room));
468
+ const automationDraftItems = rooms
469
+ .filter((room) => room.metadata.scope === "automation-draft")
470
+ .filter((room) => typeof room.metadata.draftId === "string")
471
+ .map((room) => buildAutomationDraftItem(room));
472
+
473
+ const tasks = deduplicateSystemTasks(
474
+ (await runtime.getTasks({}))
475
+ .map((task) => toWorkbenchTask(task))
476
+ .filter((task): task is WorkbenchTask => task !== null),
477
+ );
478
+
479
+ const triggerItems = (await listTriggerTasks(runtime))
480
+ .map((task) => taskToTriggerSummary(task))
481
+ .filter((trigger): trigger is TriggerSummary => trigger !== null);
482
+ const triggerTaskIds = new Set(triggerItems.map((trigger) => trigger.taskId));
483
+ const taskItems = tasks
484
+ .filter((task) => !triggerTaskIds.has(task.id))
485
+ .map((task) => buildCoordinatorTaskItem(task, taskRooms.get(task.id)));
486
+
487
+ const n8nStatusResult = await invokeN8nCompatRoute<N8nStatusResponse>(
488
+ req,
489
+ res,
490
+ state,
491
+ "/api/n8n/status",
492
+ );
493
+ const n8nStatus =
494
+ n8nStatusResult.status === 200 ? n8nStatusResult.payload : null;
495
+
496
+ const n8nWorkflowsResult = await invokeN8nCompatRoute<{
497
+ workflows?: N8nWorkflow[];
498
+ error?: string;
499
+ }>(req, res, state, "/api/n8n/workflows");
500
+ const workflowFetchError =
501
+ n8nWorkflowsResult.status === 200
502
+ ? null
503
+ : (extractErrorMessage(n8nWorkflowsResult.payload) ??
504
+ "Unable to load workflows");
505
+ const workflowList =
506
+ n8nWorkflowsResult.status === 200 &&
507
+ Array.isArray(n8nWorkflowsResult.payload?.workflows)
508
+ ? n8nWorkflowsResult.payload.workflows
509
+ : [];
510
+
511
+ const workflowItemsById = new Map<string, AutomationItem>();
512
+ for (const workflow of workflowList) {
513
+ workflowItemsById.set(
514
+ workflow.id,
515
+ buildWorkflowItem(workflow, workflowRooms.get(workflow.id), {
516
+ workflowId: workflow.id,
517
+ workflowName: workflow.name,
518
+ }),
519
+ );
520
+ }
521
+
522
+ for (const trigger of triggerItems) {
523
+ if (trigger.kind === "workflow" && trigger.workflowId) {
524
+ const existing = workflowItemsById.get(trigger.workflowId);
525
+ if (existing) {
526
+ existing.schedules = [...existing.schedules, trigger];
527
+ existing.updatedAt =
528
+ existing.updatedAt ??
529
+ normalizeDateValue(trigger.updatedAt) ??
530
+ normalizeDateValue(trigger.lastRunAtIso);
531
+ continue;
532
+ }
533
+ workflowItemsById.set(
534
+ trigger.workflowId,
535
+ buildWorkflowItem(undefined, workflowRooms.get(trigger.workflowId), {
536
+ workflowId: trigger.workflowId,
537
+ workflowName: trigger.workflowName,
538
+ trigger,
539
+ }),
540
+ );
541
+ }
542
+ }
543
+
544
+ // Only synthesize workflow items from rooms when n8n itself is offline
545
+ // (`workflowFetchError` set) — in that case the room is the most-recent
546
+ // ground truth we have and should be surfaced. When n8n IS online and
547
+ // returned a list, any workflowId in `workflowRooms` that isn't in the
548
+ // current n8n list is an ORPHAN: the workflow was deleted but the chat
549
+ // room/conversation wasn't cleaned up. Surfacing those creates ghost
550
+ // rows the user can't dismiss. Skip them; the UI's deleteWorkflow path
551
+ // also deletes the conversation now, so future deletions won't leak
552
+ // rooms.
553
+ const n8nOffline = workflowFetchError !== null;
554
+ if (n8nOffline) {
555
+ for (const [workflowId, room] of workflowRooms.entries()) {
556
+ if (!workflowItemsById.has(workflowId)) {
557
+ workflowItemsById.set(
558
+ workflowId,
559
+ buildWorkflowItem(undefined, room, {
560
+ workflowId,
561
+ workflowName: room.metadata.workflowName,
562
+ }),
563
+ );
564
+ }
565
+ }
566
+ }
567
+
568
+ const coordinatorTriggerItems = triggerItems
569
+ .filter((trigger) => trigger.kind !== "workflow")
570
+ .map((trigger) =>
571
+ buildCoordinatorTriggerItem(trigger, triggerRooms.get(trigger.id)),
572
+ );
573
+
574
+ const automations = [
575
+ ...automationDraftItems,
576
+ ...workflowDraftItems,
577
+ ...taskItems,
578
+ ...coordinatorTriggerItems,
579
+ ...workflowItemsById.values(),
580
+ ].sort(compareAutomationItems);
581
+
582
+ const summary: AutomationSummary = {
583
+ total: automations.length,
584
+ coordinatorCount: automations.filter(
585
+ (automation) => automation.type === "coordinator_text",
586
+ ).length,
587
+ workflowCount: automations.filter(
588
+ (automation) => automation.type === "n8n_workflow",
589
+ ).length,
590
+ scheduledCount: automations.filter(
591
+ (automation) => automation.schedules.length > 0,
592
+ ).length,
593
+ draftCount: automations.filter((automation) => automation.isDraft).length,
594
+ };
595
+
596
+ return {
597
+ automations,
598
+ summary,
599
+ n8nStatus,
600
+ workflowFetchError,
601
+ };
602
+ }
603
+
604
+ /**
605
+ * Returns true when the error is "the LifeOps schema isn't migrated in this
606
+ * deployment" — relation/table missing. Postgres surfaces this as SQLSTATE
607
+ * 42P01; SQLite as "no such table". When LifeOps isn't loaded (it's an
608
+ * optional plugin), the migration never runs but these resolvers still get
609
+ * called by the Automations page refresh — that's expected, not an error.
610
+ */
611
+ function isMissingLifeOpsTableError(error: unknown): boolean {
612
+ if (!error || typeof error !== "object") return false;
613
+ const code = (error as { code?: unknown }).code;
614
+ if (code === "42P01") return true; // Postgres: undefined_table
615
+ if (code === "SQLITE_ERROR") {
616
+ const msg = (error as { message?: unknown }).message;
617
+ if (typeof msg === "string" && /no such table/i.test(msg)) return true;
618
+ }
619
+ const msg = (error as { message?: unknown }).message;
620
+ if (typeof msg !== "string") return false;
621
+ return (
622
+ /relation\s+"?life_connector_grants"?\s+does not exist/i.test(msg) ||
623
+ /no such table:\s*life_connector_grants/i.test(msg)
624
+ );
625
+ }
626
+
627
+ /**
628
+ * Log connector-resolver failures with severity matched to the cause.
629
+ * Missing-table errors go to debug (LifeOps isn't installed — expected); any
630
+ * other error stays at warn so real problems remain visible.
631
+ */
632
+ function logConnectorResolverError(connector: string, error: unknown): void {
633
+ const message = error instanceof Error ? error.message : String(error);
634
+ if (isMissingLifeOpsTableError(error)) {
635
+ logger.debug(
636
+ `[automations] ${connector} connector status unavailable (LifeOps schema not migrated in this deployment).`,
637
+ );
638
+ return;
639
+ }
640
+ logger.warn(
641
+ `[automations] Failed to resolve ${connector} connector status: ${message}`,
642
+ );
643
+ }
644
+
645
+ async function resolveGoogleStatus(
646
+ lifeOps: LifeOpsService,
647
+ ): Promise<LifeOpsGoogleConnectorStatus | null> {
648
+ try {
649
+ return await lifeOps.getGoogleConnectorStatus(
650
+ new URL("http://127.0.0.1/api/lifeops/connectors/google/status"),
651
+ undefined,
652
+ "owner",
653
+ );
654
+ } catch (error) {
655
+ logConnectorResolverError("Google", error);
656
+ return null;
657
+ }
658
+ }
659
+
660
+ async function resolveTelegramStatus(
661
+ lifeOps: LifeOpsService,
662
+ ): Promise<LifeOpsTelegramConnectorStatus | null> {
663
+ try {
664
+ return await lifeOps.getTelegramConnectorStatus("owner");
665
+ } catch (error) {
666
+ logConnectorResolverError("Telegram", error);
667
+ return null;
668
+ }
669
+ }
670
+
671
+ async function resolveSignalStatus(
672
+ lifeOps: LifeOpsService,
673
+ ): Promise<LifeOpsSignalConnectorStatus | null> {
674
+ try {
675
+ return await lifeOps.getSignalConnectorStatus("owner");
676
+ } catch (error) {
677
+ logConnectorResolverError("Signal", error);
678
+ return null;
679
+ }
680
+ }
681
+
682
+ async function resolveDiscordStatus(
683
+ lifeOps: LifeOpsService,
684
+ ): Promise<LifeOpsDiscordConnectorStatus | null> {
685
+ try {
686
+ return await lifeOps.getDiscordConnectorStatus("owner");
687
+ } catch (error) {
688
+ logConnectorResolverError("Discord", error);
689
+ return null;
690
+ }
691
+ }
692
+
693
+ function buildLifeOpsNode(
694
+ id: string,
695
+ label: string,
696
+ description: string,
697
+ enabled: boolean,
698
+ disabledReason: string,
699
+ ): AutomationNodeDescriptor {
700
+ return {
701
+ id,
702
+ label,
703
+ description,
704
+ class: "integration",
705
+ source: "lifeops",
706
+ backingCapability: id,
707
+ ownerScoped: true,
708
+ requiresSetup: true,
709
+ availability: enabled ? "enabled" : "disabled",
710
+ ...(enabled ? {} : { disabledReason }),
711
+ };
712
+ }
713
+
714
+ function buildLifeOpsEventNode(
715
+ eventKind: string,
716
+ label: string,
717
+ description: string,
718
+ enabled: boolean,
719
+ disabledReason: string,
720
+ ): AutomationNodeDescriptor {
721
+ return {
722
+ id: `event:${eventKind}`,
723
+ label,
724
+ description,
725
+ class: "trigger",
726
+ source: "lifeops_event",
727
+ backingCapability: eventKind,
728
+ ownerScoped: true,
729
+ requiresSetup: !enabled,
730
+ availability: enabled ? "enabled" : "disabled",
731
+ ...(enabled ? {} : { disabledReason }),
732
+ };
733
+ }
734
+
735
+ async function buildAutomationNodeCatalog(
736
+ state: CompatRuntimeState,
737
+ ): Promise<AutomationNodeCatalogResponse> {
738
+ const runtime = state.current;
739
+ if (!runtime) {
740
+ throw new Error("Agent runtime is not available");
741
+ }
742
+
743
+ const config = loadElizaConfig();
744
+ const agentName = resolveAgentName(runtime, config);
745
+ const adminEntityId = resolveAdminEntityId(config, agentName);
746
+ const lifeOps = new LifeOpsService(runtime, { ownerEntityId: adminEntityId });
747
+ const [googleStatus, telegramStatus, signalStatus, discordStatus] =
748
+ await Promise.all([
749
+ resolveGoogleStatus(lifeOps),
750
+ resolveTelegramStatus(lifeOps),
751
+ resolveSignalStatus(lifeOps),
752
+ resolveDiscordStatus(lifeOps),
753
+ ]);
754
+
755
+ const runtimeActionNodes: AutomationNodeDescriptor[] = runtime.actions
756
+ .slice()
757
+ .sort((left, right) => left.name.localeCompare(right.name))
758
+ .map((action) => ({
759
+ id: `action:${action.name}`,
760
+ label: humanizeCapabilityName(action.name),
761
+ description: action.description || `${action.name} runtime action`,
762
+ class:
763
+ action.name === "CREATE_TASK" || action.name === "CODE_TASK"
764
+ ? "agent"
765
+ : "action",
766
+ source: "runtime_action",
767
+ backingCapability: action.name,
768
+ ownerScoped: false,
769
+ requiresSetup: false,
770
+ availability: "enabled",
771
+ }));
772
+
773
+ const runtimeProviderNodes: AutomationNodeDescriptor[] = runtime.providers
774
+ .slice()
775
+ .filter((provider) => !BLOCKED_AUTOMATION_PROVIDER_NODES.has(provider.name))
776
+ .sort((left, right) => left.name.localeCompare(right.name))
777
+ .map((provider) => ({
778
+ id: `provider:${provider.name}`,
779
+ label: humanizeCapabilityName(provider.name),
780
+ description: provider.description || `${provider.name} runtime provider`,
781
+ class: "context",
782
+ source: "runtime_provider",
783
+ backingCapability: provider.name,
784
+ ownerScoped: false,
785
+ requiresSetup: false,
786
+ availability: "enabled",
787
+ }));
788
+
789
+ const googleCapabilities = new Set(googleStatus?.grantedCapabilities ?? []);
790
+ const githubToken = runtime.getSetting("GITHUB_TOKEN");
791
+ const githubConnected =
792
+ typeof githubToken === "string" && githubToken.trim().length > 0;
793
+
794
+ const lifeOpsNodes: AutomationNodeDescriptor[] = [
795
+ buildLifeOpsNode(
796
+ "lifeops:gmail",
797
+ "Gmail",
798
+ "Owner-scoped Gmail triage, drafting, and send operations.",
799
+ Boolean(
800
+ googleStatus?.connected &&
801
+ [...googleCapabilities].some((capability) =>
802
+ capability.includes("gmail"),
803
+ ),
804
+ ),
805
+ "Connect the owner Google account with Gmail access.",
806
+ ),
807
+ buildLifeOpsNode(
808
+ "lifeops:calendar",
809
+ "Calendar",
810
+ "Owner-scoped calendar reading and event creation.",
811
+ Boolean(
812
+ googleStatus?.connected &&
813
+ [...googleCapabilities].some((capability) =>
814
+ capability.includes("calendar"),
815
+ ),
816
+ ),
817
+ "Connect the owner Google account with Calendar access.",
818
+ ),
819
+ buildLifeOpsNode(
820
+ "lifeops:telegram",
821
+ "Telegram",
822
+ "Owner-scoped Telegram account messaging.",
823
+ Boolean(telegramStatus?.connected),
824
+ "Connect the owner Telegram account.",
825
+ ),
826
+ buildLifeOpsNode(
827
+ "lifeops:signal",
828
+ "Signal",
829
+ "Owner-scoped Signal messaging.",
830
+ Boolean(signalStatus?.connected),
831
+ "Pair the owner Signal account.",
832
+ ),
833
+ buildLifeOpsNode(
834
+ "lifeops:discord",
835
+ "Discord",
836
+ "Owner-scoped Discord messaging through the active owner session.",
837
+ Boolean(discordStatus?.connected && discordStatus.available),
838
+ "Connect the owner Discord session.",
839
+ ),
840
+ buildLifeOpsNode(
841
+ "lifeops:github",
842
+ "GitHub",
843
+ "Owner-scoped GitHub access for repositories, issues, and pull requests.",
844
+ githubConnected,
845
+ "Link the owner GitHub account.",
846
+ ),
847
+ ];
848
+
849
+ const calendarConnected = Boolean(
850
+ googleStatus?.connected &&
851
+ [...googleCapabilities].some((capability) =>
852
+ capability.includes("calendar"),
853
+ ),
854
+ );
855
+ const lifeOpsEventNodes: AutomationNodeDescriptor[] = [
856
+ buildLifeOpsEventNode(
857
+ "calendar.event.ended",
858
+ "Calendar event ended",
859
+ "Fires a workflow after a synced calendar event's end time has passed.",
860
+ calendarConnected,
861
+ "Connect the owner Google account with Calendar access.",
862
+ ),
863
+ ];
864
+
865
+ const nodes = [
866
+ ...runtimeActionNodes,
867
+ ...runtimeProviderNodes,
868
+ ...lifeOpsNodes,
869
+ ...lifeOpsEventNodes,
870
+ ].sort((left, right) => {
871
+ if (left.class !== right.class) {
872
+ return left.class.localeCompare(right.class);
873
+ }
874
+ return left.label.localeCompare(right.label);
875
+ });
876
+
877
+ return {
878
+ nodes,
879
+ summary: {
880
+ total: nodes.length,
881
+ enabled: nodes.filter((node) => node.availability === "enabled").length,
882
+ disabled: nodes.filter((node) => node.availability === "disabled").length,
883
+ },
884
+ };
885
+ }
886
+
887
+ export async function handleAutomationsCompatRoutes(
888
+ req: http.IncomingMessage,
889
+ res: http.ServerResponse,
890
+ state: CompatRuntimeState,
891
+ ): Promise<boolean> {
892
+ const method = (req.method ?? "GET").toUpperCase();
893
+ const url = new URL(req.url ?? "/", "http://localhost");
894
+
895
+ if (!url.pathname.startsWith("/api/automations")) {
896
+ return false;
897
+ }
898
+
899
+ if (!(await ensureRouteAuthorized(req, res, state))) {
900
+ return true;
901
+ }
902
+
903
+ if (method === "GET" && url.pathname === "/api/automations") {
904
+ if (!state.current) {
905
+ sendJsonErrorResponse(res, 503, "Agent runtime is not available");
906
+ return true;
907
+ }
908
+ const payload = await buildAutomationListResponse(req, res, state);
909
+ sendJsonResponse(res, 200, payload);
910
+ return true;
911
+ }
912
+
913
+ if (method === "GET" && url.pathname === "/api/automations/nodes") {
914
+ if (!state.current) {
915
+ sendJsonErrorResponse(res, 503, "Agent runtime is not available");
916
+ return true;
917
+ }
918
+ const payload = await buildAutomationNodeCatalog(state);
919
+ sendJsonResponse(res, 200, payload);
920
+ return true;
921
+ }
922
+
923
+ return false;
924
+ }