agent-relay-server 0.62.2 → 0.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +2 -2
  3. package/public/assets/{MessageBubble-DRrxfjyT.js → MessageBubble-Cl6ufPHw.js} +2 -2
  4. package/public/assets/MessageBubble-Cl6ufPHw.js.map +1 -0
  5. package/public/assets/{PlanGraphPanel-Dp2eYazz.js → PlanGraphPanel-BnfjmkQ8.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-Dp2eYazz.js.map → PlanGraphPanel-BnfjmkQ8.js.map} +1 -1
  7. package/public/assets/{activity--t0U2yzq.js → activity-aFxvGLtd.js} +2 -2
  8. package/public/assets/{activity--t0U2yzq.js.map → activity-aFxvGLtd.js.map} +1 -1
  9. package/public/assets/{agent-profiles-BMtJYzxn.js → agent-profiles-B4mleUDz.js} +2 -2
  10. package/public/assets/{agent-profiles-BMtJYzxn.js.map → agent-profiles-B4mleUDz.js.map} +1 -1
  11. package/public/assets/{agents-Ck9AePlc.js → agents-DW5djToQ.js} +2 -2
  12. package/public/assets/{agents-Ck9AePlc.js.map → agents-DW5djToQ.js.map} +1 -1
  13. package/public/assets/{analytics-CzfK387L.js → analytics-CRi6thtg.js} +2 -2
  14. package/public/assets/{analytics-CzfK387L.js.map → analytics-CRi6thtg.js.map} +1 -1
  15. package/public/assets/{automation-Cgw8vtE8.js → automation-Do4OpauI.js} +2 -2
  16. package/public/assets/{automation-Cgw8vtE8.js.map → automation-Do4OpauI.js.map} +1 -1
  17. package/public/assets/{branch-state-badge-DnEEPtOP.js → branch-state-badge-D3W3w7zz.js} +2 -2
  18. package/public/assets/{branch-state-badge-DnEEPtOP.js.map → branch-state-badge-D3W3w7zz.js.map} +1 -1
  19. package/public/assets/{channels-Dc3L2MQs.js → channels-BytdR9NS.js} +2 -2
  20. package/public/assets/{channels-Dc3L2MQs.js.map → channels-BytdR9NS.js.map} +1 -1
  21. package/public/assets/{chat-Fc6iJ7sa.js → chat-CvaQ2czL.js} +2 -2
  22. package/public/assets/{chat-Fc6iJ7sa.js.map → chat-CvaQ2czL.js.map} +1 -1
  23. package/public/assets/{connectors-Dc2ttGWs.js → connectors-BLQDp8Bc.js} +2 -2
  24. package/public/assets/{connectors-Dc2ttGWs.js.map → connectors-BLQDp8Bc.js.map} +1 -1
  25. package/public/assets/{display-DzNvOu1j.js → display-B06I5sqa.js} +3 -3
  26. package/public/assets/display-B06I5sqa.js.map +1 -0
  27. package/public/assets/{formatted-body-impl-Bg-gx7rG.js → formatted-body-impl-0Lf8e1Ic.js} +2 -2
  28. package/public/assets/{formatted-body-impl-Bg-gx7rG.js.map → formatted-body-impl-0Lf8e1Ic.js.map} +1 -1
  29. package/public/assets/{index-BeDncjN4.js → index-C3su8ezq.js} +6 -6
  30. package/public/assets/{index-BeDncjN4.js.map → index-C3su8ezq.js.map} +1 -1
  31. package/public/assets/{insights-c08aW-7n.js → insights-64uExH1E.js} +2 -2
  32. package/public/assets/{insights-c08aW-7n.js.map → insights-64uExH1E.js.map} +1 -1
  33. package/public/assets/{integrations-BwpgcqnF.js → integrations-HyC1EaIp.js} +2 -2
  34. package/public/assets/{integrations-BwpgcqnF.js.map → integrations-HyC1EaIp.js.map} +1 -1
  35. package/public/assets/{maintenance-CTKP0kWB.js → maintenance-EVBpKH07.js} +2 -2
  36. package/public/assets/{maintenance-CTKP0kWB.js.map → maintenance-EVBpKH07.js.map} +1 -1
  37. package/public/assets/{managed-agents-BofCxo8d.js → managed-agents-BLZPxGuw.js} +2 -2
  38. package/public/assets/{managed-agents-BofCxo8d.js.map → managed-agents-BLZPxGuw.js.map} +1 -1
  39. package/public/assets/{markdown-preview-impl-CQ-6BIb8.js → markdown-preview-impl-Bg6rCWYN.js} +2 -2
  40. package/public/assets/{markdown-preview-impl-CQ-6BIb8.js.map → markdown-preview-impl-Bg6rCWYN.js.map} +1 -1
  41. package/public/assets/{memory-DvfXjMoi.js → memory-Bjr8bgjj.js} +2 -2
  42. package/public/assets/{memory-DvfXjMoi.js.map → memory-Bjr8bgjj.js.map} +1 -1
  43. package/public/assets/{messages-C9VsPzZ_.js → messages-CRYw1F4z.js} +2 -2
  44. package/public/assets/{messages-C9VsPzZ_.js.map → messages-CRYw1F4z.js.map} +1 -1
  45. package/public/assets/{orchestrators-MffVueeE.js → orchestrators-D-co1vdp.js} +2 -2
  46. package/public/assets/{orchestrators-MffVueeE.js.map → orchestrators-D-co1vdp.js.map} +1 -1
  47. package/public/assets/{overview-BSVajefM.js → overview-Ddy5czri.js} +2 -2
  48. package/public/assets/{overview-BSVajefM.js.map → overview-Ddy5czri.js.map} +1 -1
  49. package/public/assets/{pairs-B5711-Cs.js → pairs-CFVM8uHL.js} +2 -2
  50. package/public/assets/{pairs-B5711-Cs.js.map → pairs-CFVM8uHL.js.map} +1 -1
  51. package/public/assets/{security-DkUwK9Vj.js → security-Df4_76bh.js} +2 -2
  52. package/public/assets/{security-DkUwK9Vj.js.map → security-Df4_76bh.js.map} +1 -1
  53. package/public/assets/{select-DrM2RRiT.js → select-Cq3BBnL9.js} +2 -2
  54. package/public/assets/{select-DrM2RRiT.js.map → select-Cq3BBnL9.js.map} +1 -1
  55. package/public/assets/{settings-DdKCb-vy.js → settings-DOLEYXZL.js} +2 -2
  56. package/public/assets/{settings-DdKCb-vy.js.map → settings-DOLEYXZL.js.map} +1 -1
  57. package/public/assets/{store-iTiheuTD.js → store-C1E8jmNE.js} +4 -4
  58. package/public/assets/store-C1E8jmNE.js.map +1 -0
  59. package/public/assets/{tasks-e1y1PFfO.js → tasks-BcsmPi9_.js} +2 -2
  60. package/public/assets/{tasks-e1y1PFfO.js.map → tasks-BcsmPi9_.js.map} +1 -1
  61. package/public/assets/{teams-BUcxoGC_.js → teams-BQrV3eKI.js} +2 -2
  62. package/public/assets/{teams-BUcxoGC_.js.map → teams-BQrV3eKI.js.map} +1 -1
  63. package/public/assets/{terminal-viewer-impl-CmZjQOB2.js → terminal-viewer-impl-Dle04qpH.js} +2 -2
  64. package/public/assets/{terminal-viewer-impl-CmZjQOB2.js.map → terminal-viewer-impl-Dle04qpH.js.map} +1 -1
  65. package/public/assets/{work-queue-CXBQx43n.js → work-queue-BLhTLg4o.js} +2 -2
  66. package/public/assets/{work-queue-CXBQx43n.js.map → work-queue-BLhTLg4o.js.map} +1 -1
  67. package/public/assets/{workspaces-D2EfZh9a.js → workspaces-4NCNTmRQ.js} +3 -3
  68. package/public/assets/{workspaces-D2EfZh9a.js.map → workspaces-4NCNTmRQ.js.map} +1 -1
  69. package/public/index.html +3 -3
  70. package/scripts/orchestrator-spawn-smoke.ts +87 -67
  71. package/src/agent-ref.ts +8 -6
  72. package/src/automations/constants.ts +12 -0
  73. package/src/automations/cron.ts +129 -0
  74. package/src/automations/crud.ts +84 -0
  75. package/src/automations/dispatch.ts +275 -0
  76. package/src/automations/index.ts +5 -0
  77. package/src/automations/reconcile.ts +245 -0
  78. package/src/automations/storage.ts +181 -0
  79. package/src/automations/types.ts +16 -0
  80. package/src/automations/validation.ts +140 -0
  81. package/src/automations.ts +1 -1045
  82. package/src/db/activity.ts +1 -1
  83. package/src/db/agent-predicates.ts +9 -0
  84. package/src/db/agent-search.ts +2 -2
  85. package/src/db/agents.ts +13 -41
  86. package/src/db/artifacts.ts +8 -8
  87. package/src/db/channel-identity.ts +38 -0
  88. package/src/db/channels.ts +37 -61
  89. package/src/db/continuation-archives.ts +4 -4
  90. package/src/db/delivery.ts +75 -30
  91. package/src/db/inbox.ts +1 -1
  92. package/src/db/integrations.ts +4 -4
  93. package/src/db/issue-lifecycle.ts +6 -6
  94. package/src/db/mappers.ts +1 -2
  95. package/src/db/merge-lease.ts +3 -3
  96. package/src/db/message-claims.ts +16 -0
  97. package/src/db/message-queries.ts +8 -0
  98. package/src/db/message-reads.ts +157 -18
  99. package/src/db/messages.ts +23 -48
  100. package/src/db/notification-subscriptions.ts +2 -2
  101. package/src/db/orchestrators.ts +4 -4
  102. package/src/db/pair-maintenance.ts +9 -0
  103. package/src/db/pairs.ts +10 -18
  104. package/src/db/plans.ts +1 -1
  105. package/src/db/policy-runtime.ts +14 -0
  106. package/src/db/project-entries.ts +1 -1
  107. package/src/db/projects.ts +2 -2
  108. package/src/db/shared.ts +7 -0
  109. package/src/db/stats.ts +2 -2
  110. package/src/db/task-core.ts +18 -0
  111. package/src/db/task-maintenance.ts +24 -0
  112. package/src/db/tasks.ts +5 -17
  113. package/src/db/teams.ts +3 -3
  114. package/src/db/workspaces.ts +6 -6
  115. package/src/issue-tracker/github.ts +3 -3
  116. package/src/maintenance/constants.ts +35 -0
  117. package/src/maintenance/events.ts +32 -0
  118. package/src/maintenance/index.ts +7 -0
  119. package/src/maintenance/jobs.ts +340 -0
  120. package/src/maintenance/orphaned-sessions.ts +86 -0
  121. package/src/maintenance/scheduler.ts +231 -0
  122. package/src/maintenance/teams.ts +65 -0
  123. package/src/maintenance/types.ts +31 -0
  124. package/src/maintenance/workspaces.ts +251 -0
  125. package/src/maintenance.ts +1 -1099
  126. package/src/mcp/identity.ts +131 -0
  127. package/src/mcp/index.ts +208 -0
  128. package/src/mcp/tools-messaging.ts +459 -0
  129. package/src/mcp/tools-spawn.ts +145 -0
  130. package/src/mcp/tools-workspace.ts +227 -0
  131. package/src/mcp/types.ts +24 -0
  132. package/src/mcp/validation.ts +104 -0
  133. package/src/mcp.ts +1 -1319
  134. package/src/notification-router.ts +2 -2
  135. package/src/notification-rules.ts +2 -2
  136. package/src/project-ingest.ts +3 -3
  137. package/src/project-instructions.ts +1 -1
  138. package/src/provider-config-store.ts +3 -3
  139. package/src/provider-state.ts +1 -1
  140. package/src/reviewer-pipeline.ts +1 -1
  141. package/src/self-resume.ts +3 -7
  142. package/src/services/agent-action.ts +2 -2
  143. package/src/services/issue-activity.ts +0 -4
  144. package/src/services/issue-associations.ts +2 -2
  145. package/src/services/issue-lifecycle.ts +7 -7
  146. package/src/services/managed-running.ts +1 -1
  147. package/src/services/parity-harness.ts +3 -3
  148. package/src/services/plan-graph.ts +3 -3
  149. package/src/services/send-message.ts +16 -12
  150. package/src/services/shutdown-agent.ts +2 -2
  151. package/src/services/team-templates.ts +2 -2
  152. package/src/services/team.ts +2 -2
  153. package/src/settings/modules.ts +1 -1
  154. package/src/settings/registry.ts +3 -3
  155. package/src/spawn-command.ts +1 -1
  156. package/src/sse.ts +0 -4
  157. package/src/workspace-actions.ts +1 -1
  158. package/src/workspace-probe.ts +3 -7
  159. package/public/assets/MessageBubble-DRrxfjyT.js.map +0 -1
  160. package/public/assets/display-DzNvOu1j.js.map +0 -1
  161. package/public/assets/store-iTiheuTD.js.map +0 -1
@@ -1,1045 +1 @@
1
- import { randomUUID } from "node:crypto";
2
- import { parseJson } from "./utils";
3
- import {
4
- getAgent,
5
- getDb,
6
- getOrchestrator,
7
- getTask,
8
- ingestIntegrationEvent,
9
- listAgents,
10
- listOrchestrators,
11
- sendMessage,
12
- updateTaskStatus,
13
- ValidationError,
14
- } from "./db";
15
- import { createCommand } from "./commands-db";
16
- import { cleanEnum, cleanString, cleanStringArray, optionalEnum } from "./validation";
17
- import { getAgentProfile, getSpawnPolicy } from "./config-store";
18
- import { buildSpawnCommand, resolveSpawnModelParams } from "./spawn-command";
19
- import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog"; import { effectiveProviderCatalogList } from "./provider-catalog-store";
20
- import { errMessage, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
21
- import { runnerRuntimeTokenEnv } from "./runtime-tokens";
22
- import type {
23
- AgentCard,
24
- Automation,
25
- AutomationCatchUpPolicy,
26
- AutomationConcurrencyPolicy,
27
- AutomationRun,
28
- AutomationRunStatus,
29
- AutomationRuntimeBudget,
30
- AutomationTargetPolicy,
31
- AutomationTaskTemplate,
32
- Command,
33
- CreateAutomationInput,
34
- ManagedAgent,
35
- Message,
36
- Orchestrator,
37
- Task,
38
- TaskSeverity,
39
- UpdateAutomationInput,
40
- } from "./types";
41
- const AUTOMATION_KIND = "scheduled_task";
42
- const DEFAULT_TIMEZONE = "UTC", DEFAULT_CATCH_UP: AutomationCatchUpPolicy = "skip";
43
- const DEFAULT_CONCURRENCY: AutomationConcurrencyPolicy = "skip";
44
- const BLOCKING_RUN_STATUSES = new Set<AutomationRunStatus>(["dispatching", "waiting_agent", "running"]);
45
- const OPEN_RUN_STATUSES = new Set<AutomationRunStatus>(["scheduled", "dispatching", "waiting_agent", "running"]);
46
- const CLOSED_TASK_STATUS = new Set(["done", "failed", "canceled"]);
47
- const MAX_CRON_SCAN_MINUTES = 366 * 24 * 60;
48
- const MIN_RUNTIME_BUDGET_MS = 60_000;
49
- const MAX_RUNTIME_BUDGET_MS = 24 * 60 * 60 * 1000;
50
-
51
- let initializedDb: unknown = null;
52
- export interface AutomationDispatchResult {
53
- automation: Automation;
54
- run: AutomationRun;
55
- task?: Task;
56
- message?: Message;
57
- command?: Command;
58
- }
59
-
60
- export interface AutomationReconcileResult {
61
- run: AutomationRun;
62
- task?: Task;
63
- message?: Message;
64
- command?: Command;
65
- }
66
- function db() {
67
- return getDb();
68
- }
69
- function ensureAutomationTables(): void {
70
- const current = db();
71
- if (initializedDb === current) return;
72
- initializedDb = current;
73
- }
74
-
75
- function rowToAutomation(row: any): Automation {
76
- return {
77
- id: row.id,
78
- kind: row.kind,
79
- name: row.name,
80
- description: row.description ?? undefined,
81
- enabled: row.enabled === 1,
82
- schedule: row.schedule,
83
- timezone: row.timezone,
84
- nextRunAt: row.next_run_at ?? undefined,
85
- catchUpPolicy: row.catch_up_policy,
86
- concurrencyPolicy: row.concurrency_policy,
87
- orchestratorId: row.orchestrator_id,
88
- targetPolicy: parseJson<AutomationTargetPolicy>(row.target_policy, { mode: "existing_agent", selector: {}, ifNoMatch: "fail" }),
89
- taskTemplate: parseJson<AutomationTaskTemplate>(row.task_template, { title: row.name, body: "" }),
90
- createdAt: row.created_at,
91
- updatedAt: row.updated_at,
92
- };
93
- }
94
-
95
- function rowToAutomationRun(row: any): AutomationRun {
96
- return {
97
- id: row.id,
98
- automationId: row.automation_id,
99
- status: row.status,
100
- scheduledFor: row.scheduled_for,
101
- startedAt: row.started_at ?? undefined,
102
- finishedAt: row.finished_at ?? undefined,
103
- orchestratorId: row.orchestrator_id,
104
- targetAgentId: row.target_agent_id ?? undefined,
105
- spawnedAgentId: row.spawned_agent_id ?? undefined,
106
- taskId: row.task_id ?? undefined,
107
- messageId: row.message_id ?? undefined,
108
- error: row.error ?? undefined,
109
- result: parseJson<Record<string, unknown> | undefined>(row.result, undefined),
110
- meta: parseJson<Record<string, unknown>>(row.meta, {}),
111
- createdAt: row.created_at,
112
- updatedAt: row.updated_at,
113
- shutdownRequestedAt: row.shutdown_requested_at ?? undefined,
114
- };
115
- }
116
-
117
- function cleanBool(value: unknown, field: string): boolean | undefined {
118
- if (value === undefined || value === null) return undefined;
119
- if (typeof value !== "boolean") throw new ValidationError(`${field} must be a boolean`);
120
- return value;
121
- }
122
-
123
- function cleanMeta(value: unknown, field = "metadata"): Record<string, unknown> | undefined {
124
- if (value === undefined || value === null) return undefined;
125
- if (typeof value !== "object" || Array.isArray(value)) throw new ValidationError(`${field} must be an object`);
126
- if (JSON.stringify(value).length > 65_536) throw new ValidationError(`${field} is too large`);
127
- return value as Record<string, unknown>;
128
- }
129
-
130
- function normalizeTaskTemplate(value: unknown): AutomationTaskTemplate {
131
- if (typeof value !== "object" || value === null || Array.isArray(value)) throw new ValidationError("taskTemplate must be an object");
132
- const input = value as Record<string, unknown>;
133
- return {
134
- title: cleanString(input.title, "taskTemplate.title", { required: true, max: 240 })!,
135
- body: cleanString(input.body, "taskTemplate.body", { required: true, max: 200_000 })!,
136
- severity: optionalEnum(input.severity, "taskTemplate.severity", ["info", "warning", "critical"] as const, "info") as TaskSeverity,
137
- dedupeKey: cleanString(input.dedupeKey, "taskTemplate.dedupeKey", { max: 240 }),
138
- externalUrl: cleanString(input.externalUrl, "taskTemplate.externalUrl", { max: 1000 }),
139
- metadata: cleanMeta(input.metadata, "taskTemplate.metadata"),
140
- };
141
- }
142
-
143
- function normalizeRuntimeBudget(input: Record<string, unknown>): AutomationRuntimeBudget | undefined {
144
- const rawBudget = input.runtimeBudget;
145
- let budgetInput: Record<string, unknown> | undefined;
146
- if (rawBudget !== undefined && rawBudget !== null) {
147
- if (typeof rawBudget !== "object" || Array.isArray(rawBudget)) throw new ValidationError("targetPolicy.runtimeBudget must be an object");
148
- budgetInput = rawBudget as Record<string, unknown>;
149
- } else if (typeof input.shutdownAfterMs === "number" && input.shutdownAfterMs > 0) {
150
- budgetInput = { maxRuntimeMs: input.shutdownAfterMs };
151
- }
152
- if (!budgetInput) return undefined;
153
-
154
- const maxRuntimeMsRaw = budgetInput.maxRuntimeMs;
155
- if (!Number.isSafeInteger(maxRuntimeMsRaw)) {
156
- throw new ValidationError("targetPolicy.runtimeBudget.maxRuntimeMs must be between 60000 and 86400000");
157
- }
158
- const maxRuntimeMs = maxRuntimeMsRaw as number;
159
- if (maxRuntimeMs < MIN_RUNTIME_BUDGET_MS || maxRuntimeMs > MAX_RUNTIME_BUDGET_MS) {
160
- throw new ValidationError("targetPolicy.runtimeBudget.maxRuntimeMs must be between 60000 and 86400000");
161
- }
162
- const warnAtMsRaw = budgetInput.warnAtMs;
163
- if (warnAtMsRaw !== undefined && !Number.isSafeInteger(warnAtMsRaw)) {
164
- throw new ValidationError("targetPolicy.runtimeBudget.warnAtMs must be less than maxRuntimeMs");
165
- }
166
- const warnAtMs = warnAtMsRaw as number | undefined;
167
- if (warnAtMs !== undefined && (warnAtMs < 0 || warnAtMs >= maxRuntimeMs)) {
168
- throw new ValidationError("targetPolicy.runtimeBudget.warnAtMs must be less than maxRuntimeMs");
169
- }
170
- return {
171
- maxRuntimeMs,
172
- warnAtMs,
173
- warningMessage: cleanString(budgetInput.warningMessage, "targetPolicy.runtimeBudget.warningMessage", { max: 4000 }),
174
- };
175
- }
176
-
177
- function normalizeTargetPolicy(value: unknown): AutomationTargetPolicy {
178
- if (typeof value !== "object" || value === null || Array.isArray(value)) throw new ValidationError("targetPolicy must be an object");
179
- const input = value as Record<string, unknown>;
180
- const mode = cleanEnum(input.mode, "targetPolicy.mode", ["existing_agent", "on_demand_agent"] as const);
181
- if (mode === "existing_agent") {
182
- const selectorInput = (typeof input.selector === "object" && input.selector !== null && !Array.isArray(input.selector))
183
- ? input.selector as Record<string, unknown>
184
- : {};
185
- return {
186
- mode,
187
- selector: {
188
- provider: cleanString(selectorInput.provider, "targetPolicy.selector.provider", { max: 40 }) as "claude" | "codex" | undefined,
189
- label: cleanString(selectorInput.label, "targetPolicy.selector.label", { max: 120 }),
190
- tags: cleanStringArray(selectorInput.tags, "targetPolicy.selector.tags", { itemMax: 100 }),
191
- capabilities: cleanStringArray(selectorInput.capabilities, "targetPolicy.selector.capabilities", { itemMax: 100 }),
192
- },
193
- ifNoMatch: optionalEnum(input.ifNoMatch, "targetPolicy.ifNoMatch", ["fail", "spawn"] as const, "fail"),
194
- };
195
- }
196
- const provider = optionalEnum(input.provider, "targetPolicy.provider", ["claude", "codex"] as const, "codex");
197
- const model = cleanString(input.model, "targetPolicy.model", { max: 120 });
198
- const effort = cleanString(input.effort, "targetPolicy.effort", { max: 120 }) as ProviderEffort | undefined;
199
- const profile = cleanString(input.profile, "targetPolicy.profile", { max: 120 });
200
- if (profile && !getAgentProfile(profile)) throw new ValidationError("agent profile not found");
201
- try {
202
- resolveProviderSelection({ provider, model, effort }, effectiveProviderCatalogList());
203
- } catch (error) {
204
- throw new ValidationError(errMessage(error));
205
- }
206
- return {
207
- mode,
208
- provider,
209
- model,
210
- effort,
211
- cwd: cleanString(input.cwd, "targetPolicy.cwd", { max: 500 }),
212
- workspaceMode: optionalEnum(input.workspaceMode, "targetPolicy.workspaceMode", VALID_WORKSPACE_MODES, "inherit"),
213
- profile,
214
- approvalMode: optionalEnum(input.approvalMode, "targetPolicy.approvalMode", ["open", "guarded", "read-only"] as const, "guarded"),
215
- keepAlive: cleanBool(input.keepAlive, "targetPolicy.keepAlive") ?? false,
216
- runtimeBudget: normalizeRuntimeBudget(input),
217
- shutdownAfterMs: typeof input.shutdownAfterMs === "number" && Number.isSafeInteger(input.shutdownAfterMs) && input.shutdownAfterMs >= 0
218
- ? input.shutdownAfterMs
219
- : undefined,
220
- };
221
- }
222
-
223
- function normalizeCreateInput(input: CreateAutomationInput): Required<Omit<CreateAutomationInput, "description">> & { description?: string } {
224
- const schedule = cleanString(input.schedule, "schedule", { required: true, max: 120 })!;
225
- const timezone = cleanString(input.timezone, "timezone", { max: 80 }) ?? DEFAULT_TIMEZONE;
226
- validateCron(schedule, timezone);
227
- return {
228
- kind: AUTOMATION_KIND,
229
- name: cleanString(input.name, "name", { required: true, max: 160 })!,
230
- description: cleanString(input.description, "description", { max: 2000 }),
231
- enabled: cleanBool(input.enabled, "enabled") ?? true,
232
- schedule,
233
- timezone,
234
- catchUpPolicy: optionalEnum(input.catchUpPolicy, "catchUpPolicy", ["skip", "run_once", "run_all"] as const, DEFAULT_CATCH_UP),
235
- concurrencyPolicy: optionalEnum(input.concurrencyPolicy, "concurrencyPolicy", ["skip", "queue", "replace"] as const, DEFAULT_CONCURRENCY),
236
- orchestratorId: cleanString(input.orchestratorId, "orchestratorId", { required: true, max: 160 })!,
237
- targetPolicy: normalizeTargetPolicy(input.targetPolicy),
238
- taskTemplate: normalizeTaskTemplate(input.taskTemplate),
239
- };
240
- }
241
-
242
- export function createAutomation(input: CreateAutomationInput, now = Date.now()): Automation {
243
- ensureAutomationTables();
244
- const normalized = normalizeCreateInput(input);
245
- if (!getOrchestrator(normalized.orchestratorId)) throw new ValidationError(`orchestrator ${normalized.orchestratorId} not found`);
246
- const id = randomUUID();
247
- const nextRunAt = normalized.enabled ? nextScheduledAt(normalized.schedule, normalized.timezone, now) : undefined;
248
- db().query(`
249
- INSERT INTO automations (id, kind, name, description, enabled, schedule, timezone, next_run_at, catch_up_policy, concurrency_policy, orchestrator_id, target_policy, task_template, created_at, updated_at)
250
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
251
- `).run(
252
- id,
253
- AUTOMATION_KIND,
254
- normalized.name,
255
- normalized.description ?? null,
256
- normalized.enabled ? 1 : 0,
257
- normalized.schedule,
258
- normalized.timezone,
259
- nextRunAt ?? null,
260
- normalized.catchUpPolicy,
261
- normalized.concurrencyPolicy,
262
- normalized.orchestratorId,
263
- JSON.stringify(normalized.targetPolicy),
264
- JSON.stringify(normalized.taskTemplate),
265
- now,
266
- now,
267
- );
268
- return getAutomation(id)!;
269
- }
270
-
271
- export function updateAutomation(id: string, input: UpdateAutomationInput, now = Date.now()): Automation | null {
272
- ensureAutomationTables();
273
- const existing = getAutomation(id);
274
- if (!existing) return null;
275
- const next: CreateAutomationInput = {
276
- kind: AUTOMATION_KIND,
277
- name: input.name ?? existing.name,
278
- description: input.description === null ? undefined : input.description ?? existing.description,
279
- enabled: input.enabled ?? existing.enabled,
280
- schedule: input.schedule ?? existing.schedule,
281
- timezone: input.timezone ?? existing.timezone,
282
- catchUpPolicy: input.catchUpPolicy ?? existing.catchUpPolicy,
283
- concurrencyPolicy: input.concurrencyPolicy ?? existing.concurrencyPolicy,
284
- orchestratorId: input.orchestratorId ?? existing.orchestratorId,
285
- targetPolicy: input.targetPolicy ?? existing.targetPolicy,
286
- taskTemplate: input.taskTemplate ?? existing.taskTemplate,
287
- };
288
- const normalized = normalizeCreateInput(next);
289
- if (!getOrchestrator(normalized.orchestratorId)) throw new ValidationError(`orchestrator ${normalized.orchestratorId} not found`);
290
- const nextRunAt = normalized.enabled ? nextScheduledAt(normalized.schedule, normalized.timezone, now) : undefined;
291
- db().query(`
292
- UPDATE automations
293
- SET name = ?, description = ?, enabled = ?, schedule = ?, timezone = ?, next_run_at = ?,
294
- catch_up_policy = ?, concurrency_policy = ?, orchestrator_id = ?, target_policy = ?, task_template = ?, updated_at = ?
295
- WHERE id = ?
296
- `).run(
297
- normalized.name,
298
- normalized.description ?? null,
299
- normalized.enabled ? 1 : 0,
300
- normalized.schedule,
301
- normalized.timezone,
302
- nextRunAt ?? null,
303
- normalized.catchUpPolicy,
304
- normalized.concurrencyPolicy,
305
- normalized.orchestratorId,
306
- JSON.stringify(normalized.targetPolicy),
307
- JSON.stringify(normalized.taskTemplate),
308
- now,
309
- id,
310
- );
311
- return getAutomation(id)!;
312
- }
313
-
314
- export function deleteAutomation(id: string): boolean {
315
- ensureAutomationTables();
316
- return db().query("DELETE FROM automations WHERE id = ?").run(id).changes > 0;
317
- }
318
-
319
- export function getAutomation(id: string): Automation | null {
320
- ensureAutomationTables();
321
- const row = db().query("SELECT * FROM automations WHERE id = ?").get(id) as any;
322
- return row ? rowToAutomation(row) : null;
323
- }
324
-
325
- export function listAutomations(): Automation[] {
326
- ensureAutomationTables();
327
- return (db().query("SELECT * FROM automations ORDER BY enabled DESC, next_run_at IS NULL, next_run_at ASC, name COLLATE NOCASE").all() as any[])
328
- .map(rowToAutomation);
329
- }
330
-
331
- export function listAutomationRuns(filter: { automationId?: string; status?: string; limit?: number } = {}): AutomationRun[] {
332
- ensureAutomationTables();
333
- const clauses: string[] = [];
334
- const params: (string | number)[] = [];
335
- if (filter.automationId) {
336
- clauses.push("automation_id = ?");
337
- params.push(filter.automationId);
338
- }
339
- if (filter.status) {
340
- clauses.push("status = ?");
341
- params.push(filter.status);
342
- }
343
- const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
344
- params.push(filter.limit ?? 100);
345
- return (db().query(`SELECT * FROM automation_runs ${where} ORDER BY created_at DESC LIMIT ?`).all(...params) as any[])
346
- .map(rowToAutomationRun);
347
- }
348
-
349
- function getAutomationRun(id: string): AutomationRun | null {
350
- ensureAutomationTables();
351
- const row = db().query("SELECT * FROM automation_runs WHERE id = ?").get(id) as any;
352
- return row ? rowToAutomationRun(row) : null;
353
- }
354
-
355
- export function runDueAutomations(now = Date.now()): AutomationDispatchResult[] {
356
- ensureAutomationTables();
357
- const results = dispatchQueuedAutomationRuns(now);
358
- const due = (db().query("SELECT * FROM automations WHERE enabled = 1 AND next_run_at IS NOT NULL AND next_run_at <= ? ORDER BY next_run_at ASC LIMIT 25").all(now) as any[])
359
- .map(rowToAutomation);
360
- for (const automation of due) {
361
- if (hasActiveRun(automation.id)) {
362
- if (automation.concurrencyPolicy === "replace") cancelActiveRuns(automation.id, now, "Replaced by newer scheduled run");
363
- else {
364
- if (automation.concurrencyPolicy === "queue") enqueueAutomationRun(automation, automation.nextRunAt ?? now, now);
365
- rescheduleAutomation(automation, now);
366
- continue;
367
- }
368
- }
369
- results.push(dispatchAutomation(automation, automation.nextRunAt ?? now, now));
370
- rescheduleAutomation(automation, now);
371
- }
372
- return results;
373
- }
374
-
375
- export function runAutomationNow(id: string, now = Date.now()): AutomationDispatchResult | null {
376
- ensureAutomationTables();
377
- const automation = getAutomation(id);
378
- if (!automation) return null;
379
- if (hasActiveRun(automation.id)) throw new ValidationError("automation already has an active run");
380
- return dispatchAutomation(automation, now, now);
381
- }
382
-
383
- export function reconcileAutomationRuns(now = Date.now()): AutomationReconcileResult[] {
384
- ensureAutomationTables();
385
- const rows = db().query(`
386
- SELECT * FROM automation_runs
387
- WHERE status IN ('scheduled', 'dispatching', 'waiting_agent', 'running') AND task_id IS NOT NULL
388
- ORDER BY created_at ASC
389
- `).all() as any[];
390
- const results: AutomationReconcileResult[] = [];
391
- for (const row of rows) {
392
- const run = rowToAutomationRun(row);
393
- const task = run.taskId ? getTask(run.taskId) : null;
394
- if (!task) continue;
395
-
396
- const runtimeMeta = automationRunRuntimeMeta(run, task);
397
- const hasRuntimeMetaChange = hasMetaChanges(run.meta, runtimeMeta);
398
- const nextOpenStatus: AutomationRunStatus = task.status === "open" ? "waiting_agent" : "running";
399
- if ((task.claimedBy && task.claimedBy !== run.targetAgentId) || run.status !== nextOpenStatus || hasRuntimeMetaChange) {
400
- updateRun(run.id, {
401
- status: nextOpenStatus,
402
- targetAgentId: task.claimedBy ?? run.targetAgentId,
403
- spawnedAgentId: run.spawnedAgentId ?? task.claimedBy,
404
- meta: hasRuntimeMetaChange ? { ...run.meta, ...runtimeMeta } : undefined,
405
- }, now);
406
- }
407
-
408
- if (!CLOSED_TASK_STATUS.has(task.status)) {
409
- const updated = getAutomationRun(run.id);
410
- if (!updated) continue;
411
- const timedOut = maybeTimeoutRuntimeBudget(updated, task, now);
412
- if (timedOut) {
413
- results.push(timedOut);
414
- continue;
415
- }
416
- const warning = maybeWarnRuntimeBudget(updated, task, now);
417
- results.push({ run: getAutomationRun(run.id) ?? updated, task, message: warning });
418
- continue;
419
- }
420
-
421
- const status: AutomationRunStatus = task.status === "done" ? "succeeded" : task.status === "canceled" ? "canceled" : "failed";
422
- updateRun(run.id, {
423
- status,
424
- finishedAt: now,
425
- result: { taskStatus: task.status, result: task.result ?? null },
426
- targetAgentId: task.claimedBy ?? run.targetAgentId,
427
- spawnedAgentId: run.spawnedAgentId ?? task.claimedBy,
428
- meta: hasMetaChanges(run.meta, runtimeMeta) ? { ...run.meta, ...runtimeMeta } : undefined,
429
- }, now);
430
- const updated = getAutomationRun(run.id)!;
431
- const command = maybeShutdownOnDemandAgent(updated, task, now);
432
- results.push({ run: getAutomationRun(run.id)!, task, command });
433
- }
434
- return results;
435
- }
436
-
437
- function automationRunRuntimeMeta(run: AutomationRun, task: Task): Record<string, unknown> {
438
- const agentId = task.claimedBy ?? run.targetAgentId ?? run.spawnedAgentId;
439
- if (!agentId) return {};
440
- const agent = getAgent(agentId);
441
- const managed = listOrchestrators()
442
- .flatMap((orchestrator) => orchestrator.managedAgents.map((managedAgent) => ({ orchestrator, managedAgent })))
443
- .find(({ managedAgent }) => managedAgent.agentId === agentId);
444
- const agentMeta = agent?.meta;
445
- return compactMeta({
446
- runtimeAgentId: agentId,
447
- runtimeAgentStatus: agent?.status,
448
- runtimeAgentReady: agent?.ready,
449
- provider: stringMeta(agentMeta, "provider") ?? managed?.managedAgent.provider,
450
- profile: stringMeta(agentMeta, "profile") ?? managed?.managedAgent.profile,
451
- cwd: stringMeta(agentMeta, "cwd") ?? managed?.managedAgent.cwd,
452
- label: agent?.label ?? stringMeta(agentMeta, "label") ?? managed?.managedAgent.label,
453
- sessionName: stringMeta(agentMeta, "sessionName") ?? managed?.managedAgent.sessionName,
454
- tmuxSession: stringMeta(agentMeta, "tmuxSession") ?? managed?.managedAgent.tmuxSession,
455
- terminalSession: stringMeta(agentMeta, "terminalSession") ?? managed?.managedAgent.terminalSession,
456
- orchestratorId: managed?.orchestrator.id ?? run.orchestratorId,
457
- });
458
- }
459
-
460
- function compactMeta(input: Record<string, unknown>): Record<string, unknown> {
461
- return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined && value !== null && value !== ""));
462
- }
463
-
464
- function stringMeta(meta: Record<string, unknown> | undefined, key: string): string | undefined {
465
- const value = meta?.[key];
466
- return typeof value === "string" && value.length > 0 ? value : undefined;
467
- }
468
-
469
- function hasMetaChanges(current: Record<string, unknown>, patch: Record<string, unknown>): boolean {
470
- return Object.entries(patch).some(([key, value]) => current[key] !== value);
471
- }
472
-
473
- function dispatchAutomation(automation: Automation, scheduledFor: number, now: number): AutomationDispatchResult {
474
- const run = insertRun(automation, scheduledFor, now);
475
- return dispatchAutomationRun(automation, run, now);
476
- }
477
-
478
- function dispatchAutomationRun(automation: Automation, run: AutomationRun, now: number): AutomationDispatchResult {
479
- updateRun(run.id, { status: "dispatching" }, now);
480
- try {
481
- const orchestrator = requireOnlineOrchestrator(automation.orchestratorId);
482
- if (automation.targetPolicy.mode === "existing_agent") {
483
- const agent = resolveExistingAgent(automation.targetPolicy, orchestrator);
484
- if (!agent && automation.targetPolicy.ifNoMatch !== "spawn") throw new ValidationError("no matching managed agent for automation");
485
- if (agent) {
486
- const durableTarget = durableExistingAgentTarget(automation.targetPolicy);
487
- return createRunTask(automation, run, durableTarget, now, { targetMode: "existing_agent", resolvedAgentId: agent.id });
488
- }
489
- const fallbackPolicy: AutomationTargetPolicy = { mode: "on_demand_agent", provider: orchestrator.providers[0] ?? "codex", keepAlive: false };
490
- return dispatchOnDemandAutomation(automation, run, orchestrator, fallbackPolicy, now);
491
- }
492
- return dispatchOnDemandAutomation(automation, run, orchestrator, automation.targetPolicy, now);
493
- } catch (e) {
494
- updateRun(run.id, {
495
- status: "failed",
496
- finishedAt: now,
497
- error: errMessage(e),
498
- }, now);
499
- return { automation, run: getAutomationRun(run.id)! };
500
- }
501
- }
502
-
503
- function dispatchOnDemandAutomation(
504
- automation: Automation,
505
- run: AutomationRun,
506
- orchestrator: Orchestrator,
507
- policy: Extract<AutomationTargetPolicy, { mode: "on_demand_agent" }>,
508
- now: number,
509
- ): AutomationDispatchResult {
510
- if (!orchestrator.providers.includes(policy.provider)) throw new ValidationError(`orchestrator ${orchestrator.id} does not have provider available: ${policy.provider}`);
511
- const agentProfile = policy.profile ? getAgentProfile(policy.profile)?.value : undefined;
512
- const label = automationRunLabel(automation.id, run.id);
513
- const command = createCommand({
514
- type: "agent.spawn",
515
- source: "automation",
516
- target: orchestrator.agentId,
517
- params: buildSpawnCommand({
518
- provider: policy.provider,
519
- modelParams: resolveSpawnModelParams(policy.provider, policy.model, policy.effort),
520
- profile: policy.profile,
521
- agentProfile,
522
- cwd: policy.cwd || orchestrator.baseDir,
523
- workspaceMode: policy.workspaceMode ?? "inherit",
524
- label,
525
- approvalMode: policy.approvalMode ?? "guarded",
526
- requestedBy: "automation",
527
- requestedAt: now,
528
- automationId: automation.id,
529
- automationRunId: run.id,
530
- env: runnerRuntimeTokenEnv({
531
- orchestratorId: orchestrator.id,
532
- cwd: policy.cwd || orchestrator.baseDir,
533
- provider: policy.provider,
534
- label,
535
- createdBy: "automation",
536
- profile: policy.profile,
537
- }),
538
- }),
539
- });
540
- const result = createRunTask(automation, run, `label:${label}`, now, {
541
- targetMode: "on_demand_agent",
542
- provider: policy.provider,
543
- label,
544
- profile: policy.profile,
545
- workspaceMode: policy.workspaceMode ?? "inherit",
546
- });
547
- updateRun(run.id, {
548
- status: "waiting_agent",
549
- meta: { ...result.run.meta, onDemandLabel: label, spawnCommandId: command.id, workspaceMode: policy.workspaceMode ?? "inherit" },
550
- }, now);
551
- return { ...result, run: getAutomationRun(run.id)!, command };
552
- }
553
-
554
- function createRunTask(
555
- automation: Automation,
556
- run: AutomationRun,
557
- target: string,
558
- now: number,
559
- meta: Record<string, unknown>,
560
- ): AutomationDispatchResult {
561
- const template = automation.taskTemplate;
562
- // Inject the authoritative dispatch-time run date (#480). A long-lived managed agent's
563
- // session-start day awareness goes stale across midnight, so it can't be trusted to derive
564
- // "today" — the briefing emitted "Monday Jun 16" when it ran on a Tuesday. The scheduled
565
- // instant + timezone are authoritative here; surface them so the task never depends on the
566
- // model's recollection. Templates may place it via {{runDate}}/{{runWeekday}}/{{runDateLine}};
567
- // otherwise we prepend a Run date line so every scheduled task carries it.
568
- const runDate = formatRunDate(run.scheduledFor, automation.timezone);
569
- const tokens: Record<string, string> = { runDate: runDate.iso, runWeekday: runDate.weekday, runDateLine: runDate.line };
570
- const fill = (text: string) => text.replace(/\{\{\s*(runDate|runWeekday|runDateLine)\s*\}\}/g, (_match, key) => tokens[key] ?? "");
571
- const usesRunDateToken = /\{\{\s*(runDate|runWeekday|runDateLine)\s*\}\}/.test(`${template.title}\n${template.body}`);
572
- const title = fill(template.title);
573
- const body = usesRunDateToken ? fill(template.body) : `Run date: ${runDate.line}.\n\n${template.body}`;
574
- const result = ingestIntegrationEvent({
575
- source: "automation",
576
- type: "scheduled_task",
577
- severity: template.severity ?? "info",
578
- title,
579
- body,
580
- target,
581
- channel: "automation",
582
- // #479 — automation tasks complete via the task-status lifecycle (claim → done), never a
583
- // `/reply`. A reply obligation here is unsatisfiable and re-injects the task into every
584
- // successor session when a restart-prone managed agent (e.g. the daily telegram briefing)
585
- // mints a new agent id, re-firing the scheduled work off-schedule. Dispatch without one.
586
- replyExpected: false,
587
- dedupeKey: template.dedupeKey ? `${template.dedupeKey}:${run.id}` : `automation:${automation.id}:run:${run.id}`,
588
- externalUrl: template.externalUrl,
589
- metadata: {
590
- ...(template.metadata ?? {}),
591
- automationId: automation.id,
592
- automationRunId: run.id,
593
- orchestratorId: automation.orchestratorId,
594
- runDate: runDate.iso,
595
- runWeekday: runDate.weekday,
596
- runTimezone: automation.timezone,
597
- ...meta,
598
- },
599
- }, "automation");
600
- updateRun(run.id, {
601
- status: meta.targetMode === "on_demand_agent" ? "waiting_agent" : "running",
602
- startedAt: now,
603
- targetAgentId: typeof meta.resolvedAgentId === "string"
604
- ? meta.resolvedAgentId
605
- : target.startsWith("label:") || target.startsWith("cap:")
606
- ? undefined
607
- : target,
608
- taskId: result.task.id,
609
- messageId: result.message?.id,
610
- meta,
611
- }, now);
612
- return { automation, run: getAutomationRun(run.id)!, task: result.task, message: result.message };
613
- }
614
-
615
- function durableExistingAgentTarget(policy: Extract<AutomationTargetPolicy, { mode: "existing_agent" }>): string {
616
- const label = policy.selector.label?.trim();
617
- if (label) return `label:${label}`;
618
- const cap = policy.selector.capabilities?.find((item) => item.trim().length > 0)?.trim();
619
- if (cap) return `cap:${cap}`;
620
- throw new ValidationError("existing-agent automation target selector must include a label or capability");
621
- }
622
-
623
- function insertRun(automation: Automation, scheduledFor: number, now: number): AutomationRun {
624
- const id = randomUUID();
625
- db().query(`
626
- INSERT INTO automation_runs (id, automation_id, status, scheduled_for, orchestrator_id, meta, created_at, updated_at)
627
- VALUES (?, ?, 'dispatching', ?, ?, '{}', ?, ?)
628
- `).run(id, automation.id, scheduledFor, automation.orchestratorId, now, now);
629
- return getAutomationRun(id)!;
630
- }
631
-
632
- function enqueueAutomationRun(automation: Automation, scheduledFor: number, now: number): AutomationRun {
633
- const existing = db().query("SELECT * FROM automation_runs WHERE automation_id = ? AND status = 'scheduled' AND scheduled_for = ?")
634
- .get(automation.id, scheduledFor) as any;
635
- if (existing) return rowToAutomationRun(existing);
636
- const id = randomUUID();
637
- db().query(`
638
- INSERT INTO automation_runs (id, automation_id, status, scheduled_for, orchestrator_id, meta, created_at, updated_at)
639
- VALUES (?, ?, 'scheduled', ?, ?, '{}', ?, ?)
640
- `).run(id, automation.id, scheduledFor, automation.orchestratorId, now, now);
641
- return getAutomationRun(id)!;
642
- }
643
-
644
- function dispatchQueuedAutomationRuns(now: number): AutomationDispatchResult[] {
645
- const queued = (db().query(`
646
- SELECT r.* FROM automation_runs r
647
- JOIN automations a ON a.id = r.automation_id
648
- WHERE r.status = 'scheduled' AND a.enabled = 1
649
- ORDER BY r.scheduled_for ASC
650
- LIMIT 25
651
- `).all() as any[]).map(rowToAutomationRun);
652
- const results: AutomationDispatchResult[] = [];
653
- for (const run of queued) {
654
- const automation = getAutomation(run.automationId);
655
- if (!automation || hasActiveRun(automation.id)) continue;
656
- results.push(dispatchAutomationRun(automation, run, now));
657
- }
658
- return results;
659
- }
660
-
661
- function updateRun(id: string, input: {
662
- status?: AutomationRunStatus;
663
- startedAt?: number;
664
- finishedAt?: number;
665
- targetAgentId?: string;
666
- spawnedAgentId?: string;
667
- taskId?: number;
668
- messageId?: number;
669
- error?: string;
670
- result?: Record<string, unknown>;
671
- meta?: Record<string, unknown>;
672
- shutdownRequestedAt?: number;
673
- }, now: number): void {
674
- const current = getAutomationRun(id);
675
- const meta = input.meta !== undefined ? input.meta : current?.meta;
676
- db().query(`
677
- UPDATE automation_runs
678
- SET status = COALESCE(?, status),
679
- started_at = COALESCE(?, started_at),
680
- finished_at = COALESCE(?, finished_at),
681
- target_agent_id = COALESCE(?, target_agent_id),
682
- spawned_agent_id = COALESCE(?, spawned_agent_id),
683
- task_id = COALESCE(?, task_id),
684
- message_id = COALESCE(?, message_id),
685
- control_message_id = control_message_id,
686
- error = COALESCE(?, error),
687
- result = COALESCE(?, result),
688
- meta = COALESCE(?, meta),
689
- shutdown_requested_at = COALESCE(?, shutdown_requested_at),
690
- updated_at = ?
691
- WHERE id = ?
692
- `).run(
693
- input.status ?? null,
694
- input.startedAt ?? null,
695
- input.finishedAt ?? null,
696
- input.targetAgentId ?? null,
697
- input.spawnedAgentId ?? null,
698
- input.taskId ?? null,
699
- input.messageId ?? null,
700
- input.error ?? null,
701
- input.result ? JSON.stringify(input.result) : null,
702
- meta ? JSON.stringify(meta) : null,
703
- input.shutdownRequestedAt ?? null,
704
- now,
705
- id,
706
- );
707
- }
708
-
709
- function hasActiveRun(automationId: string): boolean {
710
- const placeholders = [...BLOCKING_RUN_STATUSES].map(() => "?").join(",");
711
- const row = db().query(`SELECT id FROM automation_runs WHERE automation_id = ? AND status IN (${placeholders}) LIMIT 1`)
712
- .get(automationId, ...BLOCKING_RUN_STATUSES) as any;
713
- return Boolean(row);
714
- }
715
-
716
- function cancelActiveRuns(automationId: string, now: number, reason: string): void {
717
- const placeholders = [...OPEN_RUN_STATUSES].map(() => "?").join(",");
718
- db().query(`UPDATE automation_runs SET status = 'canceled', finished_at = ?, error = ?, updated_at = ? WHERE automation_id = ? AND status IN (${placeholders})`)
719
- .run(now, reason, now, automationId, ...OPEN_RUN_STATUSES);
720
- }
721
-
722
- function rescheduleAutomation(automation: Automation, now: number): void {
723
- const next = automation.enabled ? nextScheduledAt(automation.schedule, automation.timezone, now) : undefined;
724
- db().query("UPDATE automations SET next_run_at = ?, updated_at = ? WHERE id = ?").run(next ?? null, now, automation.id);
725
- }
726
-
727
- function requireOnlineOrchestrator(orchestratorId: string): Orchestrator {
728
- const orchestrator = getOrchestrator(orchestratorId);
729
- if (!orchestrator) throw new ValidationError(`orchestrator ${orchestratorId} not found`);
730
- if (orchestrator.status !== "online") throw new ValidationError(`orchestrator ${orchestratorId} is ${orchestrator.status}`);
731
- return orchestrator;
732
- }
733
-
734
- function resolveExistingAgent(policy: Extract<AutomationTargetPolicy, { mode: "existing_agent" }>, orchestrator: Orchestrator): AgentCard | null {
735
- const managedAgentsById = new Map(orchestrator.managedAgents.map((agent) => [agent.agentId, agent]));
736
- const managedIds = new Set(managedAgentsById.keys());
737
- const candidates = listAgents()
738
- .filter((agent) => agent.id !== "user" && agent.id !== "system" && agent.kind !== "channel" && agent.kind !== "orchestrator")
739
- .filter((agent) => agent.ready && agent.status !== "offline")
740
- .filter((agent) => managedIds.size === 0 || managedIds.has(agent.id))
741
- .filter((agent) => automationAgentMatchesSelector(agent, policy.selector, managedAgentsById.get(agent.id)));
742
- candidates.sort((a, b) => b.lastSeen - a.lastSeen);
743
- return candidates[0] ?? null;
744
- }
745
-
746
- function automationAgentMatchesSelector(
747
- agent: AgentCard,
748
- selector: Extract<AutomationTargetPolicy, { mode: "existing_agent" }>["selector"],
749
- managedAgent?: ManagedAgent,
750
- ): boolean {
751
- const policyName = managedAgent?.policyName ?? (typeof agent.meta?.policyName === "string" ? agent.meta.policyName : undefined);
752
- const spawnPolicy = policyName ? getSpawnPolicy(policyName)?.value : undefined;
753
- const tags = new Set([...agent.tags, ...stringArray((managedAgent as { tags?: unknown } | undefined)?.tags), ...(spawnPolicy?.tags ?? [])]);
754
- const capabilities = new Set([
755
- ...agent.capabilities,
756
- ...stringArray((managedAgent as { capabilities?: unknown } | undefined)?.capabilities),
757
- ...(spawnPolicy?.capabilities ?? []),
758
- ]);
759
- const label = agent.label ?? managedAgent?.label ?? spawnPolicy?.label;
760
- const provider = typeof agent.meta?.provider === "string" ? agent.meta.provider : managedAgent?.provider ?? spawnPolicy?.provider;
761
- if (selector.provider) {
762
- if (provider !== selector.provider && !tags.has(selector.provider)) return false;
763
- }
764
- if (selector.label && label !== selector.label) return false;
765
- if (selector.tags?.length && !selector.tags.every((tag) => tags.has(tag))) return false;
766
- if (selector.capabilities?.length && !selector.capabilities.every((cap) => capabilities.has(cap))) return false;
767
- return true;
768
- }
769
-
770
- function stringArray(value: unknown): string[] {
771
- return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.length > 0) : [];
772
- }
773
-
774
- function runtimeBudgetForRun(run: AutomationRun): AutomationRuntimeBudget | undefined {
775
- const automation = getAutomation(run.automationId);
776
- if (!automation || automation.targetPolicy.mode !== "on_demand_agent") return undefined;
777
- return automation.targetPolicy.runtimeBudget;
778
- }
779
-
780
- function runtimeDeadline(run: AutomationRun, budget: AutomationRuntimeBudget): number | undefined {
781
- return run.startedAt ? run.startedAt + budget.maxRuntimeMs : undefined;
782
- }
783
-
784
- function runtimeWarningAt(run: AutomationRun, budget: AutomationRuntimeBudget): number | undefined {
785
- if (budget.warnAtMs === undefined || !run.startedAt) return undefined;
786
- return run.startedAt + budget.warnAtMs;
787
- }
788
-
789
- function maybeWarnRuntimeBudget(run: AutomationRun, task: Task, now: number): Message | undefined {
790
- if (run.meta.runtimeBudgetWarnedAt) return undefined;
791
- const budget = runtimeBudgetForRun(run);
792
- if (!budget) return undefined;
793
- const warnAt = runtimeWarningAt(run, budget);
794
- const deadline = runtimeDeadline(run, budget);
795
- if (warnAt === undefined || deadline === undefined || now < warnAt || now >= deadline) return undefined;
796
- const target = runtimeBudgetMessageTarget(run, task);
797
- if (!target) return undefined;
798
- const remainingMinutes = Math.max(1, Math.ceil((deadline - now) / 60_000));
799
- const message = sendMessage({
800
- from: "system",
801
- to: target,
802
- kind: "control",
803
- subject: "Automation runtime budget warning",
804
- body: budget.warningMessage ?? `Runtime budget warning: this automation run will be shut down in about ${remainingMinutes} minute${remainingMinutes === 1 ? "" : "s"}. Finish current work, commit and push anything worth keeping, then stop cleanly before the deadline.`,
805
- payload: {
806
- automationId: run.automationId,
807
- automationRunId: run.id,
808
- taskId: task.id,
809
- runtimeBudgetWarning: true,
810
- maxRuntimeMs: budget.maxRuntimeMs,
811
- warnAtMs: budget.warnAtMs,
812
- deadlineAt: deadline,
813
- },
814
- meta: {
815
- source: "automation",
816
- automationId: run.automationId,
817
- automationRunId: run.id,
818
- runtimeBudgetWarning: true,
819
- },
820
- });
821
- updateRun(run.id, {
822
- meta: {
823
- ...run.meta,
824
- runtimeBudgetWarnedAt: now,
825
- runtimeBudgetWarningMessageId: message.id,
826
- runtimeBudgetDeadlineAt: deadline,
827
- },
828
- }, now);
829
- return message;
830
- }
831
-
832
- function maybeTimeoutRuntimeBudget(run: AutomationRun, task: Task, now: number): AutomationReconcileResult | undefined {
833
- const budget = runtimeBudgetForRun(run);
834
- if (!budget) return undefined;
835
- const deadline = runtimeDeadline(run, budget);
836
- if (deadline === undefined || now < deadline) return undefined;
837
-
838
- const agentId = task.claimedBy ?? run.targetAgentId ?? run.spawnedAgentId;
839
- const taskResult = updateTaskStatus(task.id, {
840
- status: "canceled",
841
- agentId,
842
- result: "Automation runtime budget elapsed",
843
- body: "Automation runtime budget elapsed",
844
- metadata: {
845
- automationId: run.automationId,
846
- automationRunId: run.id,
847
- runtimeBudgetElapsed: true,
848
- runtimeBudgetTimedOut: true,
849
- maxRuntimeMs: budget.maxRuntimeMs,
850
- deadlineAt: deadline,
851
- },
852
- });
853
- const updatedTask = taskResult.task ?? getTask(task.id) ?? task;
854
- const command = requestOnDemandAgentShutdown(run, updatedTask, now, "runtime-budget-timeout");
855
- updateRun(run.id, {
856
- status: "timed_out",
857
- finishedAt: now,
858
- error: "Automation runtime budget elapsed",
859
- result: {
860
- taskStatus: updatedTask.status,
861
- runtimeBudgetElapsed: true,
862
- runtimeBudgetTimedOut: true,
863
- maxRuntimeMs: budget.maxRuntimeMs,
864
- deadlineAt: deadline,
865
- },
866
- targetAgentId: agentId,
867
- spawnedAgentId: run.spawnedAgentId ?? agentId,
868
- shutdownRequestedAt: command ? now : undefined,
869
- meta: {
870
- ...run.meta,
871
- runtimeBudgetElapsedAt: now,
872
- runtimeBudgetTimedOutAt: now,
873
- runtimeBudgetDeadlineAt: deadline,
874
- ...(command ? { shutdownCommandId: command.id } : {}),
875
- },
876
- }, now);
877
- return { run: getAutomationRun(run.id)!, task: getTask(task.id) ?? updatedTask, command };
878
- }
879
-
880
- function runtimeBudgetMessageTarget(run: AutomationRun, task: Task): string | undefined {
881
- if (task.claimedBy) return task.claimedBy;
882
- if (run.targetAgentId) return run.targetAgentId;
883
- if (run.spawnedAgentId) return run.spawnedAgentId;
884
- if (typeof run.meta.onDemandLabel === "string" && run.meta.onDemandLabel) return `label:${run.meta.onDemandLabel}`;
885
- return undefined;
886
- }
887
-
888
- function maybeShutdownOnDemandAgent(run: AutomationRun, task: Task, now: number): Command | undefined {
889
- if (run.shutdownRequestedAt) return undefined;
890
- const automation = getAutomation(run.automationId);
891
- if (!automation || automation.targetPolicy.mode !== "on_demand_agent" || automation.targetPolicy.keepAlive) return undefined;
892
- const command = requestOnDemandAgentShutdown(run, task, now, "task-complete");
893
- if (command) updateRun(run.id, { shutdownRequestedAt: now, meta: { ...run.meta, shutdownCommandId: command.id } }, now);
894
- return command;
895
- }
896
-
897
- function requestOnDemandAgentShutdown(run: AutomationRun, task: Task, now: number, reason: string): Command | undefined {
898
- const agentId = task.claimedBy ?? run.targetAgentId ?? run.spawnedAgentId;
899
- if (!agentId) return undefined;
900
- return createCommand({
901
- type: "agent.shutdown",
902
- source: "automation",
903
- target: agentId,
904
- params: {
905
- action: "shutdown",
906
- agentId,
907
- requestedBy: "automation",
908
- requestedAt: now,
909
- automationId: run.automationId,
910
- automationRunId: run.id,
911
- reason,
912
- },
913
- });
914
- }
915
-
916
- function automationRunLabel(automationId: string, runId: string): string {
917
- return `automation-${automationId.slice(0, 8)}-${runId.slice(0, 8)}`;
918
- }
919
-
920
- function validateCron(expression: string, timezone: string): void {
921
- parseCron(expression);
922
- localParts(Date.now(), timezone);
923
- }
924
-
925
- function nextScheduledAt(expression: string, timezone: string, afterMs: number): number {
926
- const cron = parseCron(expression);
927
- let cursor = Math.floor(afterMs / 60_000) * 60_000 + 60_000;
928
- for (let i = 0; i < MAX_CRON_SCAN_MINUTES; i += 1) {
929
- const parts = localParts(cursor, timezone);
930
- if (cronMatches(cron, parts)) return cursor;
931
- cursor += 60_000;
932
- }
933
- throw new ValidationError("schedule has no matching time in the next year");
934
- }
935
-
936
- interface CronSpec {
937
- minute: Set<number>;
938
- hour: Set<number>;
939
- dayOfMonth: Set<number>;
940
- month: Set<number>;
941
- dayOfWeek: Set<number>;
942
- domAny: boolean;
943
- dowAny: boolean;
944
- }
945
-
946
- interface LocalTimeParts {
947
- minute: number;
948
- hour: number;
949
- dayOfMonth: number;
950
- month: number;
951
- dayOfWeek: number;
952
- }
953
-
954
- function parseCron(expression: string): CronSpec {
955
- const parts = expression.trim().split(/\s+/);
956
- if (parts.length !== 5) throw new ValidationError("schedule must be a 5-field cron expression");
957
- const [minute, hour, dom, month, dow] = parts as [string, string, string, string, string];
958
- return {
959
- minute: parseCronField(minute, 0, 59, "minute"),
960
- hour: parseCronField(hour, 0, 23, "hour"),
961
- dayOfMonth: parseCronField(dom, 1, 31, "dayOfMonth"),
962
- month: parseCronField(month, 1, 12, "month"),
963
- dayOfWeek: parseCronField(dow.replace(/\b7\b/g, "0"), 0, 6, "dayOfWeek"),
964
- domAny: dom === "*",
965
- dowAny: dow === "*",
966
- };
967
- }
968
-
969
- function parseCronField(field: string, min: number, max: number, name: string): Set<number> {
970
- const values = new Set<number>();
971
- for (const rawPart of field.split(",")) {
972
- const part = rawPart.trim();
973
- if (!part) throw new ValidationError(`${name} cron field is empty`);
974
- const [rangePart, stepRaw] = part.split("/");
975
- const step = stepRaw === undefined ? 1 : Number(stepRaw);
976
- if (!Number.isInteger(step) || step <= 0) throw new ValidationError(`${name} cron step must be a positive integer`);
977
- let start: number;
978
- let end: number;
979
- if (rangePart === "*") {
980
- start = min;
981
- end = max;
982
- } else if (rangePart?.includes("-")) {
983
- const [startRaw, endRaw] = rangePart.split("-");
984
- start = Number(startRaw);
985
- end = Number(endRaw);
986
- } else {
987
- start = Number(rangePart);
988
- end = start;
989
- }
990
- if (!Number.isInteger(start) || !Number.isInteger(end) || start < min || end > max || start > end) {
991
- throw new ValidationError(`${name} cron field is out of range`);
992
- }
993
- for (let value = start; value <= end; value += step) values.add(value);
994
- }
995
- return values;
996
- }
997
-
998
- // Authoritative localized run date for a dispatched automation task (#480). Formats the
999
- // scheduled instant in the automation's timezone via the same Intl machinery as cron eval, so
1000
- // scheduled output ("today is …") never depends on a long-lived agent's stale session context.
1001
- function formatRunDate(timestamp: number, timezone: string): { iso: string; weekday: string; line: string } {
1002
- const dtf = new Intl.DateTimeFormat("en-US", {
1003
- timeZone: timezone,
1004
- year: "numeric",
1005
- month: "2-digit",
1006
- day: "2-digit",
1007
- weekday: "long",
1008
- });
1009
- const parts = Object.fromEntries(dtf.formatToParts(new Date(timestamp)).map((part) => [part.type, part.value]));
1010
- const iso = `${parts.year}-${parts.month}-${parts.day}`;
1011
- const weekday = parts.weekday ?? "";
1012
- return { iso, weekday, line: `${weekday}, ${iso} (${timezone})` };
1013
- }
1014
-
1015
- function localParts(timestamp: number, timezone: string): LocalTimeParts {
1016
- const dtf = new Intl.DateTimeFormat("en-US", {
1017
- timeZone: timezone,
1018
- minute: "numeric",
1019
- hour: "numeric",
1020
- hourCycle: "h23",
1021
- day: "numeric",
1022
- month: "numeric",
1023
- weekday: "short",
1024
- });
1025
- const parts = Object.fromEntries(dtf.formatToParts(new Date(timestamp)).map((part) => [part.type, part.value]));
1026
- const dow = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].indexOf(parts.weekday ?? "");
1027
- if (dow < 0) throw new ValidationError(`invalid timezone: ${timezone}`);
1028
- return {
1029
- minute: Number(parts.minute),
1030
- hour: Number(parts.hour),
1031
- dayOfMonth: Number(parts.day),
1032
- month: Number(parts.month),
1033
- dayOfWeek: dow,
1034
- };
1035
- }
1036
-
1037
- function cronMatches(cron: CronSpec, parts: LocalTimeParts): boolean {
1038
- if (!cron.minute.has(parts.minute) || !cron.hour.has(parts.hour) || !cron.month.has(parts.month)) return false;
1039
- const domMatch = cron.dayOfMonth.has(parts.dayOfMonth);
1040
- const dowMatch = cron.dayOfWeek.has(parts.dayOfWeek);
1041
- if (cron.domAny && cron.dowAny) return true;
1042
- if (cron.domAny) return dowMatch;
1043
- if (cron.dowAny) return domMatch;
1044
- return domMatch || dowMatch;
1045
- }
1
+ export * from "./automations/index";