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
@@ -20,6 +20,8 @@ type Agent = {
20
20
  createdAt?: number;
21
21
  };
22
22
 
23
+ type ApprovalMode = "open" | "guarded" | "read-only";
24
+
23
25
  const args = process.argv.slice(2);
24
26
  let relayUrl = process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850";
25
27
  let orchestratorId = process.env.AGENT_RELAY_SMOKE_ORCHESTRATOR || "";
@@ -92,6 +94,26 @@ function findSpawnedAgent(agents: Agent[], provider: string, label: string, star
92
94
  .sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0))[0] || null;
93
95
  }
94
96
 
97
+ function approvalModesForProvider(provider: string): ApprovalMode[] {
98
+ if (provider === "claude") return ["open", "guarded", "read-only"];
99
+ return ["guarded"];
100
+ }
101
+
102
+ function promptForCase(provider: string, approvalMode: ApprovalMode, label: string, nonce: string): { prompt?: string; nonceLabel?: string } {
103
+ if (provider !== "claude") return {};
104
+ if (approvalMode === "guarded") return {};
105
+ if (approvalMode === "read-only") {
106
+ return {
107
+ prompt: `Agent Relay read-only spawn smoke. As your VERY FIRST action, use the Bash tool to run exactly: agent-relay /message label:${label} "${nonce}". Then wait for shutdown — do nothing else.`,
108
+ nonceLabel: "Bash relay CLI",
109
+ };
110
+ }
111
+ return {
112
+ prompt: `Agent Relay spawn smoke. As your VERY FIRST action, use the agent-relay MCP tool relay_send_message to send to "label:${label}" with body exactly "${nonce}". Then wait for shutdown — do nothing else.`,
113
+ nonceLabel: "relay MCP tool",
114
+ };
115
+ }
116
+
95
117
  const orchestrators = await api<Orchestrator[]>("GET", "/orchestrators");
96
118
  // Default selection prefers the orchestrator on the same host as the relay (the
97
119
  // gate runs there): a local spawn is faster and avoids cross-host teardown
@@ -123,79 +145,77 @@ for (const provider of providers) {
123
145
  continue;
124
146
  }
125
147
 
126
- const label = `smoke-${provider}-${Date.now()}`;
127
- const startedAt = Date.now();
128
- // #352: for providers seeded with an initial prompt, assert the spawn prompt was actually
129
- // DELIVERED and the first turn RAN by making the prompt's first action a relay tool call
130
- // (send a nonce message) and waiting for that nonce to land in the relay feed. Registration
131
- // alone (the old smoke) happens via the plugin regardless of prompt delivery, so it never
132
- // caught the silently-dropped spawn prompt. The nonce is the end-to-end proof.
133
- const nonce = `SMOKE-NONCE-${provider}-${Date.now()}-${Math.round(Math.random() * 1e9).toString(36)}`;
134
- const promptProvider = provider === "claude";
135
- const prompt = `Agent Relay spawn smoke. As your VERY FIRST action, use the agent-relay MCP tool relay_send_message to send to "label:${label}" with body exactly "${nonce}". Then wait for shutdown — do nothing else.`;
136
- console.log(`spawn ${provider} via ${orchestrator.id}`);
137
- await api("POST", `/agents/spawn`, {
138
- provider,
139
- orchestratorId: orchestrator.id,
140
- cwd,
141
- label,
142
- // Prompt-seeded providers must run UNATTENDED: under guarded mode the first-turn relay tool
143
- // call would block on a dashboard approval that never comes here. Autonomous workers spawn
144
- // open for the same reason. (Approval mode is orthogonal to prompt delivery, which is the
145
- // regression under test.)
146
- approvalMode: promptProvider ? "open" : "guarded",
147
- ...(promptProvider ? { prompt } : {}),
148
- });
149
-
150
- let agent: Agent | undefined;
151
- let managed: { tmuxSession: string } | undefined;
152
- try {
153
- agent = await waitFor(`waiting for ${provider} agent registration`, async () => {
154
- const agents = await api<Agent[]>("GET", "/agents");
155
- return findSpawnedAgent(agents, provider, label, startedAt);
148
+ for (const approvalMode of approvalModesForProvider(provider)) {
149
+ const label = `smoke-${provider}-${approvalMode}-${Date.now()}`;
150
+ const startedAt = Date.now();
151
+ // #352: for providers seeded with an initial prompt, assert the spawn prompt was actually
152
+ // DELIVERED and the first turn RAN by making the prompt's first action send a nonce message
153
+ // and waiting for that nonce to land in the relay feed. Registration alone happens via the
154
+ // plugin regardless of prompt delivery, so it never catches silently dropped spawn prompts.
155
+ const nonce = `SMOKE-NONCE-${provider}-${approvalMode}-${Date.now()}-${Math.round(Math.random() * 1e9).toString(36)}`;
156
+ const promptCase = promptForCase(provider, approvalMode, label, nonce);
157
+ console.log(`spawn ${provider} ${approvalMode} via ${orchestrator.id}`);
158
+ await api("POST", `/agents/spawn`, {
159
+ provider,
160
+ orchestratorId: orchestrator.id,
161
+ cwd,
162
+ label,
163
+ approvalMode,
164
+ ...(promptCase.prompt ? { prompt: promptCase.prompt } : {}),
156
165
  });
157
- console.log(`registered ${provider}: ${agent.id}`);
158
166
 
159
- managed = await waitFor(`waiting for ${provider} managed session`, async () => {
160
- const latest = (await api<Orchestrator[]>("GET", "/orchestrators")).find((orch) => orch.id === orchestrator.id);
161
- return latest?.managedAgents?.find((entry) => entry.label === label || entry.tmuxSession.includes(label)) || null;
162
- });
167
+ let agent: Agent | undefined;
168
+ let managed: { tmuxSession: string } | undefined;
169
+ try {
170
+ agent = await waitFor(`waiting for ${provider} ${approvalMode} agent registration`, async () => {
171
+ const agents = await api<Agent[]>("GET", "/agents");
172
+ return findSpawnedAgent(agents, provider, label, startedAt);
173
+ });
174
+ console.log(`registered ${provider} ${approvalMode}: ${agent.id}`);
163
175
 
164
- // Hard-assert the seeded first turn ran and reached relay (the prompt-delivery regression guard).
165
- if (promptProvider) {
166
- await waitFor(`waiting for ${provider} spawn-prompt first turn (nonce relay tool call)`, async () => {
167
- const feed = await api<Array<{ body?: string }>>("GET", "/messages?limit=80");
168
- return feed.some((m) => (m.body || "").includes(nonce)) ? true : null;
176
+ managed = await waitFor(`waiting for ${provider} ${approvalMode} managed session`, async () => {
177
+ const latest = (await api<Orchestrator[]>("GET", "/orchestrators")).find((orch) => orch.id === orchestrator.id);
178
+ return latest?.managedAgents?.find((entry) => entry.label === label || entry.tmuxSession.includes(label)) || null;
169
179
  });
170
- console.log(`spawn-prompt delivered + first turn called relay ${provider}: ${nonce}`);
171
- }
172
- } finally {
173
- // Always tear down a failed assertion (e.g. a re-introduced prompt-drop) must NOT leak a
174
- // managed agent on the host running the gate. Best-effort: teardown errors don't mask the
175
- // original failure.
176
- if (agent) {
177
- const id = agent.id;
178
- await api("POST", `/agents/${encodeURIComponent(id)}/actions`, { action: "shutdown" }).catch(() => {});
179
- await waitFor(`waiting for ${provider} agent cleanup`, async () => {
180
- const current = await apiOptional<Agent>("GET", `/agents/${encodeURIComponent(id)}`);
181
- return !current || (current.status === "offline" && current.ready === false) ? true : null;
182
- }).catch(() => {});
183
- await apiOptional("DELETE", `/agents/${encodeURIComponent(id)}`).catch(() => {});
184
- if (managed) {
185
- const tmuxSession = managed.tmuxSession;
186
- await api("POST", `/orchestrators/${encodeURIComponent(orchestrator.id)}/actions`, {
187
- action: "shutdown",
188
- agentId: id,
189
- tmuxSession,
190
- reason: "smoke-cleanup",
191
- }).catch(() => {});
192
- await waitFor(`waiting for ${provider} managed session cleanup`, async () => {
193
- const latest = (await api<Orchestrator[]>("GET", "/orchestrators")).find((orch) => orch.id === orchestrator.id);
194
- const stillManaged = latest?.managedAgents?.some((entry) => entry.tmuxSession === tmuxSession || entry.agentId === id);
195
- return stillManaged ? null : true;
180
+
181
+ // Hard-assert prompt-seeded cases ran and reached relay. Guarded Claude remains
182
+ // registration-only here: forcing a Bash/MCP first turn would intentionally block on
183
+ // the known unattended approval path.
184
+ if (promptCase.nonceLabel) {
185
+ await waitFor(`waiting for ${provider} ${approvalMode} spawn-prompt first turn (${promptCase.nonceLabel})`, async () => {
186
+ const feed = await api<Array<{ body?: string }>>("GET", "/messages?limit=80");
187
+ return feed.some((m) => (m.body || "").includes(nonce)) ? true : null;
188
+ });
189
+ console.log(`spawn-prompt delivered + first turn called relay ${provider} ${approvalMode}: ${nonce}`);
190
+ }
191
+ } finally {
192
+ // Always tear down — a failed assertion (e.g. a re-introduced prompt-drop) must NOT leak a
193
+ // managed agent on the host running the gate. Best-effort: teardown errors don't mask the
194
+ // original failure.
195
+ if (agent) {
196
+ const id = agent.id;
197
+ await api("POST", `/agents/${encodeURIComponent(id)}/actions`, { action: "shutdown" }).catch(() => {});
198
+ await waitFor(`waiting for ${provider} ${approvalMode} agent cleanup`, async () => {
199
+ const current = await apiOptional<Agent>("GET", `/agents/${encodeURIComponent(id)}`);
200
+ return !current || (current.status === "offline" && current.ready === false) ? true : null;
196
201
  }).catch(() => {});
202
+ await apiOptional("DELETE", `/agents/${encodeURIComponent(id)}`).catch(() => {});
203
+ if (managed) {
204
+ const tmuxSession = managed.tmuxSession;
205
+ await api("POST", `/orchestrators/${encodeURIComponent(orchestrator.id)}/actions`, {
206
+ action: "shutdown",
207
+ agentId: id,
208
+ tmuxSession,
209
+ reason: "smoke-cleanup",
210
+ }).catch(() => {});
211
+ await waitFor(`waiting for ${provider} ${approvalMode} managed session cleanup`, async () => {
212
+ const latest = (await api<Orchestrator[]>("GET", "/orchestrators")).find((orch) => orch.id === orchestrator.id);
213
+ const stillManaged = latest?.managedAgents?.some((entry) => entry.tmuxSession === tmuxSession || entry.agentId === id);
214
+ return stillManaged ? null : true;
215
+ }).catch(() => {});
216
+ }
217
+ console.log(`shutdown ${provider} ${approvalMode}: ${label}`);
197
218
  }
198
- console.log(`shutdown ${provider}: ${label}`);
199
219
  }
200
220
  }
201
221
  }
package/src/agent-ref.ts CHANGED
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { STALE_TTL_MS } from "./config";
15
15
  import type { AgentCard, Message, TokenConstraints } from "./types";
16
+ import { targetMatchesAgentIdentity } from "agent-relay-sdk/agent-target";
16
17
 
17
18
  interface ResolveOptions {
18
19
  /** Exclude this agent id from matches (e.g. the requester, when pairing). */
@@ -150,12 +151,13 @@ export function resolveAgentRef(ref: string, agents: AgentCard[], opts: ResolveO
150
151
 
151
152
  export function targetMatchesAgent(target: string, agentId: string, agent?: AgentCard | null): boolean {
152
153
  if (!agent) return false;
153
- if (target === agentId || target === "broadcast") return true;
154
- if (target.startsWith("tag:") && agent.tags.includes(target.slice(4))) return true;
155
- if (target.startsWith("cap:") && agent.capabilities.includes(target.slice(4))) return true;
156
- if (target.startsWith("label:") && agent.label === target.slice(6)) return true;
157
- if (target.startsWith("team:") && agent.teamId === target.slice(5)) return true;
158
- return false;
154
+ return targetMatchesAgentIdentity(target, {
155
+ id: agentId,
156
+ label: agent.label,
157
+ tags: agent.tags,
158
+ capabilities: agent.capabilities,
159
+ teamId: agent.teamId,
160
+ });
159
161
  }
160
162
 
161
163
  export function messageMatchesAgent(msg: Message, agentId: string, agent?: AgentCard | null): boolean {
@@ -0,0 +1,12 @@
1
+ import type { AutomationCatchUpPolicy, AutomationConcurrencyPolicy, AutomationRunStatus } from "../types";
2
+
3
+ export const AUTOMATION_KIND = "scheduled_task";
4
+ export const DEFAULT_TIMEZONE = "UTC";
5
+ export const DEFAULT_CATCH_UP: AutomationCatchUpPolicy = "skip";
6
+ export const DEFAULT_CONCURRENCY: AutomationConcurrencyPolicy = "skip";
7
+ export const BLOCKING_RUN_STATUSES = new Set<AutomationRunStatus>(["dispatching", "waiting_agent", "running"]);
8
+ export const OPEN_RUN_STATUSES = new Set<AutomationRunStatus>(["scheduled", "dispatching", "waiting_agent", "running"]);
9
+ export const CLOSED_TASK_STATUS = new Set(["done", "failed", "canceled"]);
10
+ export const MAX_CRON_SCAN_MINUTES = 366 * 24 * 60;
11
+ export const MIN_RUNTIME_BUDGET_MS = 60_000;
12
+ export const MAX_RUNTIME_BUDGET_MS = 24 * 60 * 60 * 1000;
@@ -0,0 +1,129 @@
1
+ import { ValidationError } from "../db";
2
+ import { MAX_CRON_SCAN_MINUTES } from "./constants";
3
+
4
+ export function validateCron(expression: string, timezone: string): void {
5
+ parseCron(expression);
6
+ localParts(Date.now(), timezone);
7
+ }
8
+
9
+ export function nextScheduledAt(expression: string, timezone: string, afterMs: number): number {
10
+ const cron = parseCron(expression);
11
+ let cursor = Math.floor(afterMs / 60_000) * 60_000 + 60_000;
12
+ for (let i = 0; i < MAX_CRON_SCAN_MINUTES; i += 1) {
13
+ const parts = localParts(cursor, timezone);
14
+ if (cronMatches(cron, parts)) return cursor;
15
+ cursor += 60_000;
16
+ }
17
+ throw new ValidationError("schedule has no matching time in the next year");
18
+ }
19
+
20
+ interface CronSpec {
21
+ minute: Set<number>;
22
+ hour: Set<number>;
23
+ dayOfMonth: Set<number>;
24
+ month: Set<number>;
25
+ dayOfWeek: Set<number>;
26
+ domAny: boolean;
27
+ dowAny: boolean;
28
+ }
29
+
30
+ interface LocalTimeParts {
31
+ minute: number;
32
+ hour: number;
33
+ dayOfMonth: number;
34
+ month: number;
35
+ dayOfWeek: number;
36
+ }
37
+
38
+ function parseCron(expression: string): CronSpec {
39
+ const parts = expression.trim().split(/\s+/);
40
+ if (parts.length !== 5) throw new ValidationError("schedule must be a 5-field cron expression");
41
+ const [minute, hour, dom, month, dow] = parts as [string, string, string, string, string];
42
+ return {
43
+ minute: parseCronField(minute, 0, 59, "minute"),
44
+ hour: parseCronField(hour, 0, 23, "hour"),
45
+ dayOfMonth: parseCronField(dom, 1, 31, "dayOfMonth"),
46
+ month: parseCronField(month, 1, 12, "month"),
47
+ dayOfWeek: parseCronField(dow.replace(/\b7\b/g, "0"), 0, 6, "dayOfWeek"),
48
+ domAny: dom === "*",
49
+ dowAny: dow === "*",
50
+ };
51
+ }
52
+
53
+ function parseCronField(field: string, min: number, max: number, name: string): Set<number> {
54
+ const values = new Set<number>();
55
+ for (const rawPart of field.split(",")) {
56
+ const part = rawPart.trim();
57
+ if (!part) throw new ValidationError(`${name} cron field is empty`);
58
+ const [rangePart, stepRaw] = part.split("/");
59
+ const step = stepRaw === undefined ? 1 : Number(stepRaw);
60
+ if (!Number.isInteger(step) || step <= 0) throw new ValidationError(`${name} cron step must be a positive integer`);
61
+ let start: number;
62
+ let end: number;
63
+ if (rangePart === "*") {
64
+ start = min;
65
+ end = max;
66
+ } else if (rangePart?.includes("-")) {
67
+ const [startRaw, endRaw] = rangePart.split("-");
68
+ start = Number(startRaw);
69
+ end = Number(endRaw);
70
+ } else {
71
+ start = Number(rangePart);
72
+ end = start;
73
+ }
74
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < min || end > max || start > end) {
75
+ throw new ValidationError(`${name} cron field is out of range`);
76
+ }
77
+ for (let value = start; value <= end; value += step) values.add(value);
78
+ }
79
+ return values;
80
+ }
81
+
82
+ // Authoritative localized run date for a dispatched automation task (#480). Formats the
83
+ // scheduled instant in the automation's timezone via the same Intl machinery as cron eval, so
84
+ // scheduled output ("today is ...") never depends on a long-lived agent's stale session context.
85
+ export function formatRunDate(timestamp: number, timezone: string): { iso: string; weekday: string; line: string } {
86
+ const dtf = new Intl.DateTimeFormat("en-US", {
87
+ timeZone: timezone,
88
+ year: "numeric",
89
+ month: "2-digit",
90
+ day: "2-digit",
91
+ weekday: "long",
92
+ });
93
+ const parts = Object.fromEntries(dtf.formatToParts(new Date(timestamp)).map((part) => [part.type, part.value]));
94
+ const iso = `${parts.year}-${parts.month}-${parts.day}`;
95
+ const weekday = parts.weekday ?? "";
96
+ return { iso, weekday, line: `${weekday}, ${iso} (${timezone})` };
97
+ }
98
+
99
+ function localParts(timestamp: number, timezone: string): LocalTimeParts {
100
+ const dtf = new Intl.DateTimeFormat("en-US", {
101
+ timeZone: timezone,
102
+ minute: "numeric",
103
+ hour: "numeric",
104
+ hourCycle: "h23",
105
+ day: "numeric",
106
+ month: "numeric",
107
+ weekday: "short",
108
+ });
109
+ const parts = Object.fromEntries(dtf.formatToParts(new Date(timestamp)).map((part) => [part.type, part.value]));
110
+ const dow = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].indexOf(parts.weekday ?? "");
111
+ if (dow < 0) throw new ValidationError(`invalid timezone: ${timezone}`);
112
+ return {
113
+ minute: Number(parts.minute),
114
+ hour: Number(parts.hour),
115
+ dayOfMonth: Number(parts.day),
116
+ month: Number(parts.month),
117
+ dayOfWeek: dow,
118
+ };
119
+ }
120
+
121
+ function cronMatches(cron: CronSpec, parts: LocalTimeParts): boolean {
122
+ if (!cron.minute.has(parts.minute) || !cron.hour.has(parts.hour) || !cron.month.has(parts.month)) return false;
123
+ const domMatch = cron.dayOfMonth.has(parts.dayOfMonth);
124
+ const dowMatch = cron.dayOfWeek.has(parts.dayOfWeek);
125
+ if (cron.domAny && cron.dowAny) return true;
126
+ if (cron.domAny) return dowMatch;
127
+ if (cron.dowAny) return domMatch;
128
+ return domMatch || dowMatch;
129
+ }
@@ -0,0 +1,84 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { getOrchestrator, ValidationError } from "../db";
3
+ import { AUTOMATION_KIND } from "./constants";
4
+ import { nextScheduledAt } from "./cron";
5
+ import { db, ensureAutomationTables, getAutomation } from "./storage";
6
+ import { normalizeCreateInput } from "./validation";
7
+ import type { Automation, CreateAutomationInput, UpdateAutomationInput } from "../types";
8
+
9
+ export function createAutomation(input: CreateAutomationInput, now = Date.now()): Automation {
10
+ ensureAutomationTables();
11
+ const normalized = normalizeCreateInput(input);
12
+ if (!getOrchestrator(normalized.orchestratorId)) throw new ValidationError(`orchestrator ${normalized.orchestratorId} not found`);
13
+ const id = randomUUID();
14
+ const nextRunAt = normalized.enabled ? nextScheduledAt(normalized.schedule, normalized.timezone, now) : undefined;
15
+ db().query(`
16
+ 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)
17
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
18
+ `).run(
19
+ id,
20
+ AUTOMATION_KIND,
21
+ normalized.name,
22
+ normalized.description ?? null,
23
+ normalized.enabled ? 1 : 0,
24
+ normalized.schedule,
25
+ normalized.timezone,
26
+ nextRunAt ?? null,
27
+ normalized.catchUpPolicy,
28
+ normalized.concurrencyPolicy,
29
+ normalized.orchestratorId,
30
+ JSON.stringify(normalized.targetPolicy),
31
+ JSON.stringify(normalized.taskTemplate),
32
+ now,
33
+ now,
34
+ );
35
+ return getAutomation(id)!;
36
+ }
37
+
38
+ export function updateAutomation(id: string, input: UpdateAutomationInput, now = Date.now()): Automation | null {
39
+ ensureAutomationTables();
40
+ const existing = getAutomation(id);
41
+ if (!existing) return null;
42
+ const next: CreateAutomationInput = {
43
+ kind: AUTOMATION_KIND,
44
+ name: input.name ?? existing.name,
45
+ description: input.description === null ? undefined : input.description ?? existing.description,
46
+ enabled: input.enabled ?? existing.enabled,
47
+ schedule: input.schedule ?? existing.schedule,
48
+ timezone: input.timezone ?? existing.timezone,
49
+ catchUpPolicy: input.catchUpPolicy ?? existing.catchUpPolicy,
50
+ concurrencyPolicy: input.concurrencyPolicy ?? existing.concurrencyPolicy,
51
+ orchestratorId: input.orchestratorId ?? existing.orchestratorId,
52
+ targetPolicy: input.targetPolicy ?? existing.targetPolicy,
53
+ taskTemplate: input.taskTemplate ?? existing.taskTemplate,
54
+ };
55
+ const normalized = normalizeCreateInput(next);
56
+ if (!getOrchestrator(normalized.orchestratorId)) throw new ValidationError(`orchestrator ${normalized.orchestratorId} not found`);
57
+ const nextRunAt = normalized.enabled ? nextScheduledAt(normalized.schedule, normalized.timezone, now) : undefined;
58
+ db().query(`
59
+ UPDATE automations
60
+ SET name = ?, description = ?, enabled = ?, schedule = ?, timezone = ?, next_run_at = ?,
61
+ catch_up_policy = ?, concurrency_policy = ?, orchestrator_id = ?, target_policy = ?, task_template = ?, updated_at = ?
62
+ WHERE id = ?
63
+ `).run(
64
+ normalized.name,
65
+ normalized.description ?? null,
66
+ normalized.enabled ? 1 : 0,
67
+ normalized.schedule,
68
+ normalized.timezone,
69
+ nextRunAt ?? null,
70
+ normalized.catchUpPolicy,
71
+ normalized.concurrencyPolicy,
72
+ normalized.orchestratorId,
73
+ JSON.stringify(normalized.targetPolicy),
74
+ JSON.stringify(normalized.taskTemplate),
75
+ now,
76
+ id,
77
+ );
78
+ return getAutomation(id)!;
79
+ }
80
+
81
+ export function deleteAutomation(id: string): boolean {
82
+ ensureAutomationTables();
83
+ return db().query("DELETE FROM automations WHERE id = ?").run(id).changes > 0;
84
+ }