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.
- package/docs/openapi.json +1 -1
- package/package.json +2 -2
- package/public/assets/{MessageBubble-DRrxfjyT.js → MessageBubble-Cl6ufPHw.js} +2 -2
- package/public/assets/MessageBubble-Cl6ufPHw.js.map +1 -0
- package/public/assets/{PlanGraphPanel-Dp2eYazz.js → PlanGraphPanel-BnfjmkQ8.js} +2 -2
- package/public/assets/{PlanGraphPanel-Dp2eYazz.js.map → PlanGraphPanel-BnfjmkQ8.js.map} +1 -1
- package/public/assets/{activity--t0U2yzq.js → activity-aFxvGLtd.js} +2 -2
- package/public/assets/{activity--t0U2yzq.js.map → activity-aFxvGLtd.js.map} +1 -1
- package/public/assets/{agent-profiles-BMtJYzxn.js → agent-profiles-B4mleUDz.js} +2 -2
- package/public/assets/{agent-profiles-BMtJYzxn.js.map → agent-profiles-B4mleUDz.js.map} +1 -1
- package/public/assets/{agents-Ck9AePlc.js → agents-DW5djToQ.js} +2 -2
- package/public/assets/{agents-Ck9AePlc.js.map → agents-DW5djToQ.js.map} +1 -1
- package/public/assets/{analytics-CzfK387L.js → analytics-CRi6thtg.js} +2 -2
- package/public/assets/{analytics-CzfK387L.js.map → analytics-CRi6thtg.js.map} +1 -1
- package/public/assets/{automation-Cgw8vtE8.js → automation-Do4OpauI.js} +2 -2
- package/public/assets/{automation-Cgw8vtE8.js.map → automation-Do4OpauI.js.map} +1 -1
- package/public/assets/{branch-state-badge-DnEEPtOP.js → branch-state-badge-D3W3w7zz.js} +2 -2
- package/public/assets/{branch-state-badge-DnEEPtOP.js.map → branch-state-badge-D3W3w7zz.js.map} +1 -1
- package/public/assets/{channels-Dc3L2MQs.js → channels-BytdR9NS.js} +2 -2
- package/public/assets/{channels-Dc3L2MQs.js.map → channels-BytdR9NS.js.map} +1 -1
- package/public/assets/{chat-Fc6iJ7sa.js → chat-CvaQ2czL.js} +2 -2
- package/public/assets/{chat-Fc6iJ7sa.js.map → chat-CvaQ2czL.js.map} +1 -1
- package/public/assets/{connectors-Dc2ttGWs.js → connectors-BLQDp8Bc.js} +2 -2
- package/public/assets/{connectors-Dc2ttGWs.js.map → connectors-BLQDp8Bc.js.map} +1 -1
- package/public/assets/{display-DzNvOu1j.js → display-B06I5sqa.js} +3 -3
- package/public/assets/display-B06I5sqa.js.map +1 -0
- package/public/assets/{formatted-body-impl-Bg-gx7rG.js → formatted-body-impl-0Lf8e1Ic.js} +2 -2
- package/public/assets/{formatted-body-impl-Bg-gx7rG.js.map → formatted-body-impl-0Lf8e1Ic.js.map} +1 -1
- package/public/assets/{index-BeDncjN4.js → index-C3su8ezq.js} +6 -6
- package/public/assets/{index-BeDncjN4.js.map → index-C3su8ezq.js.map} +1 -1
- package/public/assets/{insights-c08aW-7n.js → insights-64uExH1E.js} +2 -2
- package/public/assets/{insights-c08aW-7n.js.map → insights-64uExH1E.js.map} +1 -1
- package/public/assets/{integrations-BwpgcqnF.js → integrations-HyC1EaIp.js} +2 -2
- package/public/assets/{integrations-BwpgcqnF.js.map → integrations-HyC1EaIp.js.map} +1 -1
- package/public/assets/{maintenance-CTKP0kWB.js → maintenance-EVBpKH07.js} +2 -2
- package/public/assets/{maintenance-CTKP0kWB.js.map → maintenance-EVBpKH07.js.map} +1 -1
- package/public/assets/{managed-agents-BofCxo8d.js → managed-agents-BLZPxGuw.js} +2 -2
- package/public/assets/{managed-agents-BofCxo8d.js.map → managed-agents-BLZPxGuw.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-CQ-6BIb8.js → markdown-preview-impl-Bg6rCWYN.js} +2 -2
- package/public/assets/{markdown-preview-impl-CQ-6BIb8.js.map → markdown-preview-impl-Bg6rCWYN.js.map} +1 -1
- package/public/assets/{memory-DvfXjMoi.js → memory-Bjr8bgjj.js} +2 -2
- package/public/assets/{memory-DvfXjMoi.js.map → memory-Bjr8bgjj.js.map} +1 -1
- package/public/assets/{messages-C9VsPzZ_.js → messages-CRYw1F4z.js} +2 -2
- package/public/assets/{messages-C9VsPzZ_.js.map → messages-CRYw1F4z.js.map} +1 -1
- package/public/assets/{orchestrators-MffVueeE.js → orchestrators-D-co1vdp.js} +2 -2
- package/public/assets/{orchestrators-MffVueeE.js.map → orchestrators-D-co1vdp.js.map} +1 -1
- package/public/assets/{overview-BSVajefM.js → overview-Ddy5czri.js} +2 -2
- package/public/assets/{overview-BSVajefM.js.map → overview-Ddy5czri.js.map} +1 -1
- package/public/assets/{pairs-B5711-Cs.js → pairs-CFVM8uHL.js} +2 -2
- package/public/assets/{pairs-B5711-Cs.js.map → pairs-CFVM8uHL.js.map} +1 -1
- package/public/assets/{security-DkUwK9Vj.js → security-Df4_76bh.js} +2 -2
- package/public/assets/{security-DkUwK9Vj.js.map → security-Df4_76bh.js.map} +1 -1
- package/public/assets/{select-DrM2RRiT.js → select-Cq3BBnL9.js} +2 -2
- package/public/assets/{select-DrM2RRiT.js.map → select-Cq3BBnL9.js.map} +1 -1
- package/public/assets/{settings-DdKCb-vy.js → settings-DOLEYXZL.js} +2 -2
- package/public/assets/{settings-DdKCb-vy.js.map → settings-DOLEYXZL.js.map} +1 -1
- package/public/assets/{store-iTiheuTD.js → store-C1E8jmNE.js} +4 -4
- package/public/assets/store-C1E8jmNE.js.map +1 -0
- package/public/assets/{tasks-e1y1PFfO.js → tasks-BcsmPi9_.js} +2 -2
- package/public/assets/{tasks-e1y1PFfO.js.map → tasks-BcsmPi9_.js.map} +1 -1
- package/public/assets/{teams-BUcxoGC_.js → teams-BQrV3eKI.js} +2 -2
- package/public/assets/{teams-BUcxoGC_.js.map → teams-BQrV3eKI.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-CmZjQOB2.js → terminal-viewer-impl-Dle04qpH.js} +2 -2
- package/public/assets/{terminal-viewer-impl-CmZjQOB2.js.map → terminal-viewer-impl-Dle04qpH.js.map} +1 -1
- package/public/assets/{work-queue-CXBQx43n.js → work-queue-BLhTLg4o.js} +2 -2
- package/public/assets/{work-queue-CXBQx43n.js.map → work-queue-BLhTLg4o.js.map} +1 -1
- package/public/assets/{workspaces-D2EfZh9a.js → workspaces-4NCNTmRQ.js} +3 -3
- package/public/assets/{workspaces-D2EfZh9a.js.map → workspaces-4NCNTmRQ.js.map} +1 -1
- package/public/index.html +3 -3
- package/scripts/orchestrator-spawn-smoke.ts +87 -67
- package/src/agent-ref.ts +8 -6
- package/src/automations/constants.ts +12 -0
- package/src/automations/cron.ts +129 -0
- package/src/automations/crud.ts +84 -0
- package/src/automations/dispatch.ts +275 -0
- package/src/automations/index.ts +5 -0
- package/src/automations/reconcile.ts +245 -0
- package/src/automations/storage.ts +181 -0
- package/src/automations/types.ts +16 -0
- package/src/automations/validation.ts +140 -0
- package/src/automations.ts +1 -1045
- package/src/db/activity.ts +1 -1
- package/src/db/agent-predicates.ts +9 -0
- package/src/db/agent-search.ts +2 -2
- package/src/db/agents.ts +13 -41
- package/src/db/artifacts.ts +8 -8
- package/src/db/channel-identity.ts +38 -0
- package/src/db/channels.ts +37 -61
- package/src/db/continuation-archives.ts +4 -4
- package/src/db/delivery.ts +75 -30
- package/src/db/inbox.ts +1 -1
- package/src/db/integrations.ts +4 -4
- package/src/db/issue-lifecycle.ts +6 -6
- package/src/db/mappers.ts +1 -2
- package/src/db/merge-lease.ts +3 -3
- package/src/db/message-claims.ts +16 -0
- package/src/db/message-queries.ts +8 -0
- package/src/db/message-reads.ts +157 -18
- package/src/db/messages.ts +23 -48
- package/src/db/notification-subscriptions.ts +2 -2
- package/src/db/orchestrators.ts +4 -4
- package/src/db/pair-maintenance.ts +9 -0
- package/src/db/pairs.ts +10 -18
- package/src/db/plans.ts +1 -1
- package/src/db/policy-runtime.ts +14 -0
- package/src/db/project-entries.ts +1 -1
- package/src/db/projects.ts +2 -2
- package/src/db/shared.ts +7 -0
- package/src/db/stats.ts +2 -2
- package/src/db/task-core.ts +18 -0
- package/src/db/task-maintenance.ts +24 -0
- package/src/db/tasks.ts +5 -17
- package/src/db/teams.ts +3 -3
- package/src/db/workspaces.ts +6 -6
- package/src/issue-tracker/github.ts +3 -3
- package/src/maintenance/constants.ts +35 -0
- package/src/maintenance/events.ts +32 -0
- package/src/maintenance/index.ts +7 -0
- package/src/maintenance/jobs.ts +340 -0
- package/src/maintenance/orphaned-sessions.ts +86 -0
- package/src/maintenance/scheduler.ts +231 -0
- package/src/maintenance/teams.ts +65 -0
- package/src/maintenance/types.ts +31 -0
- package/src/maintenance/workspaces.ts +251 -0
- package/src/maintenance.ts +1 -1099
- package/src/mcp/identity.ts +131 -0
- package/src/mcp/index.ts +208 -0
- package/src/mcp/tools-messaging.ts +459 -0
- package/src/mcp/tools-spawn.ts +145 -0
- package/src/mcp/tools-workspace.ts +227 -0
- package/src/mcp/types.ts +24 -0
- package/src/mcp/validation.ts +104 -0
- package/src/mcp.ts +1 -1319
- package/src/notification-router.ts +2 -2
- package/src/notification-rules.ts +2 -2
- package/src/project-ingest.ts +3 -3
- package/src/project-instructions.ts +1 -1
- package/src/provider-config-store.ts +3 -3
- package/src/provider-state.ts +1 -1
- package/src/reviewer-pipeline.ts +1 -1
- package/src/self-resume.ts +3 -7
- package/src/services/agent-action.ts +2 -2
- package/src/services/issue-activity.ts +0 -4
- package/src/services/issue-associations.ts +2 -2
- package/src/services/issue-lifecycle.ts +7 -7
- package/src/services/managed-running.ts +1 -1
- package/src/services/parity-harness.ts +3 -3
- package/src/services/plan-graph.ts +3 -3
- package/src/services/send-message.ts +16 -12
- package/src/services/shutdown-agent.ts +2 -2
- package/src/services/team-templates.ts +2 -2
- package/src/services/team.ts +2 -2
- package/src/settings/modules.ts +1 -1
- package/src/settings/registry.ts +3 -3
- package/src/spawn-command.ts +1 -1
- package/src/sse.ts +0 -4
- package/src/workspace-actions.ts +1 -1
- package/src/workspace-probe.ts +3 -7
- package/public/assets/MessageBubble-DRrxfjyT.js.map +0 -1
- package/public/assets/display-DzNvOu1j.js.map +0 -1
- 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
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
+
}
|