agent-relay-server 0.62.3 → 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/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 +7 -12
- 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 +26 -9
- package/src/db/messages.ts +17 -41
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { errMessage } from "agent-relay-sdk";
|
|
2
|
+
import { createCommand } from "../commands-db";
|
|
3
|
+
import { getAgentProfile, getSpawnPolicy } from "../config-store";
|
|
4
|
+
import { getOrchestrator, ingestIntegrationEvent, listAgents, ValidationError } from "../db";
|
|
5
|
+
import { runnerRuntimeTokenEnv } from "../runtime-tokens";
|
|
6
|
+
import { buildSpawnCommand, resolveSpawnModelParams } from "../spawn-command";
|
|
7
|
+
import { nextScheduledAt, formatRunDate } from "./cron";
|
|
8
|
+
import {
|
|
9
|
+
cancelActiveRuns,
|
|
10
|
+
db,
|
|
11
|
+
enqueueAutomationRun,
|
|
12
|
+
ensureAutomationTables,
|
|
13
|
+
getAutomation,
|
|
14
|
+
getAutomationRun,
|
|
15
|
+
hasActiveRun,
|
|
16
|
+
insertRun,
|
|
17
|
+
listAutomations,
|
|
18
|
+
rowToAutomation,
|
|
19
|
+
rowToAutomationRun,
|
|
20
|
+
updateRun,
|
|
21
|
+
rescheduleAutomation as persistNextRun,
|
|
22
|
+
} from "./storage";
|
|
23
|
+
import type { AutomationDispatchResult } from "./types";
|
|
24
|
+
import type { AgentCard, Automation, AutomationTargetPolicy, ManagedAgent, Orchestrator } from "../types";
|
|
25
|
+
|
|
26
|
+
export function runDueAutomations(now = Date.now()): AutomationDispatchResult[] {
|
|
27
|
+
ensureAutomationTables();
|
|
28
|
+
const results = dispatchQueuedAutomationRuns(now);
|
|
29
|
+
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[])
|
|
30
|
+
.map(rowToAutomation);
|
|
31
|
+
for (const automation of due) {
|
|
32
|
+
if (hasActiveRun(automation.id)) {
|
|
33
|
+
if (automation.concurrencyPolicy === "replace") cancelActiveRuns(automation.id, now, "Replaced by newer scheduled run");
|
|
34
|
+
else {
|
|
35
|
+
if (automation.concurrencyPolicy === "queue") enqueueAutomationRun(automation, automation.nextRunAt ?? now, now);
|
|
36
|
+
rescheduleAutomation(automation, now);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
results.push(dispatchAutomation(automation, automation.nextRunAt ?? now, now));
|
|
41
|
+
rescheduleAutomation(automation, now);
|
|
42
|
+
}
|
|
43
|
+
return results;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function runAutomationNow(id: string, now = Date.now()): AutomationDispatchResult | null {
|
|
47
|
+
ensureAutomationTables();
|
|
48
|
+
const automation = getAutomation(id);
|
|
49
|
+
if (!automation) return null;
|
|
50
|
+
if (hasActiveRun(automation.id)) throw new ValidationError("automation already has an active run");
|
|
51
|
+
return dispatchAutomation(automation, now, now);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function dispatchAutomation(automation: Automation, scheduledFor: number, now: number): AutomationDispatchResult {
|
|
55
|
+
const run = insertRun(automation, scheduledFor, now);
|
|
56
|
+
return dispatchAutomationRun(automation, run, now);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function dispatchAutomationRun(automation: Automation, run: ReturnType<typeof getAutomationRun> & NonNullable<unknown>, now: number): AutomationDispatchResult {
|
|
60
|
+
updateRun(run.id, { status: "dispatching" }, now);
|
|
61
|
+
try {
|
|
62
|
+
const orchestrator = requireOnlineOrchestrator(automation.orchestratorId);
|
|
63
|
+
if (automation.targetPolicy.mode === "existing_agent") {
|
|
64
|
+
const agent = resolveExistingAgent(automation.targetPolicy, orchestrator);
|
|
65
|
+
if (!agent && automation.targetPolicy.ifNoMatch !== "spawn") throw new ValidationError("no matching managed agent for automation");
|
|
66
|
+
if (agent) {
|
|
67
|
+
const durableTarget = durableExistingAgentTarget(automation.targetPolicy);
|
|
68
|
+
return createRunTask(automation, run, durableTarget, now, { targetMode: "existing_agent", resolvedAgentId: agent.id });
|
|
69
|
+
}
|
|
70
|
+
const fallbackPolicy: AutomationTargetPolicy = { mode: "on_demand_agent", provider: orchestrator.providers[0] ?? "codex", keepAlive: false };
|
|
71
|
+
return dispatchOnDemandAutomation(automation, run, orchestrator, fallbackPolicy, now);
|
|
72
|
+
}
|
|
73
|
+
return dispatchOnDemandAutomation(automation, run, orchestrator, automation.targetPolicy, now);
|
|
74
|
+
} catch (e) {
|
|
75
|
+
updateRun(run.id, {
|
|
76
|
+
status: "failed",
|
|
77
|
+
finishedAt: now,
|
|
78
|
+
error: errMessage(e),
|
|
79
|
+
}, now);
|
|
80
|
+
return { automation, run: getAutomationRun(run.id)! };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function dispatchOnDemandAutomation(
|
|
85
|
+
automation: Automation,
|
|
86
|
+
run: ReturnType<typeof getAutomationRun> & NonNullable<unknown>,
|
|
87
|
+
orchestrator: Orchestrator,
|
|
88
|
+
policy: Extract<AutomationTargetPolicy, { mode: "on_demand_agent" }>,
|
|
89
|
+
now: number,
|
|
90
|
+
): AutomationDispatchResult {
|
|
91
|
+
if (!orchestrator.providers.includes(policy.provider)) throw new ValidationError(`orchestrator ${orchestrator.id} does not have provider available: ${policy.provider}`);
|
|
92
|
+
const agentProfile = policy.profile ? getAgentProfile(policy.profile)?.value : undefined;
|
|
93
|
+
const label = automationRunLabel(automation.id, run.id);
|
|
94
|
+
const command = createCommand({
|
|
95
|
+
type: "agent.spawn",
|
|
96
|
+
source: "automation",
|
|
97
|
+
target: orchestrator.agentId,
|
|
98
|
+
params: buildSpawnCommand({
|
|
99
|
+
provider: policy.provider,
|
|
100
|
+
modelParams: resolveSpawnModelParams(policy.provider, policy.model, policy.effort),
|
|
101
|
+
profile: policy.profile,
|
|
102
|
+
agentProfile,
|
|
103
|
+
cwd: policy.cwd || orchestrator.baseDir,
|
|
104
|
+
workspaceMode: policy.workspaceMode ?? "inherit",
|
|
105
|
+
label,
|
|
106
|
+
approvalMode: policy.approvalMode ?? "guarded",
|
|
107
|
+
requestedBy: "automation",
|
|
108
|
+
requestedAt: now,
|
|
109
|
+
automationId: automation.id,
|
|
110
|
+
automationRunId: run.id,
|
|
111
|
+
env: runnerRuntimeTokenEnv({
|
|
112
|
+
orchestratorId: orchestrator.id,
|
|
113
|
+
cwd: policy.cwd || orchestrator.baseDir,
|
|
114
|
+
provider: policy.provider,
|
|
115
|
+
label,
|
|
116
|
+
createdBy: "automation",
|
|
117
|
+
profile: policy.profile,
|
|
118
|
+
}),
|
|
119
|
+
}),
|
|
120
|
+
});
|
|
121
|
+
const result = createRunTask(automation, run, `label:${label}`, now, {
|
|
122
|
+
targetMode: "on_demand_agent",
|
|
123
|
+
provider: policy.provider,
|
|
124
|
+
label,
|
|
125
|
+
profile: policy.profile,
|
|
126
|
+
workspaceMode: policy.workspaceMode ?? "inherit",
|
|
127
|
+
});
|
|
128
|
+
updateRun(run.id, {
|
|
129
|
+
status: "waiting_agent",
|
|
130
|
+
meta: { ...result.run.meta, onDemandLabel: label, spawnCommandId: command.id, workspaceMode: policy.workspaceMode ?? "inherit" },
|
|
131
|
+
}, now);
|
|
132
|
+
return { ...result, run: getAutomationRun(run.id)!, command };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function createRunTask(
|
|
136
|
+
automation: Automation,
|
|
137
|
+
run: ReturnType<typeof getAutomationRun> & NonNullable<unknown>,
|
|
138
|
+
target: string,
|
|
139
|
+
now: number,
|
|
140
|
+
meta: Record<string, unknown>,
|
|
141
|
+
): AutomationDispatchResult {
|
|
142
|
+
const template = automation.taskTemplate;
|
|
143
|
+
// Inject the authoritative dispatch-time run date (#480). A long-lived managed agent's
|
|
144
|
+
// session-start day awareness goes stale across midnight, so it can't be trusted to derive
|
|
145
|
+
// "today" - the briefing emitted "Monday Jun 16" when it ran on a Tuesday. The scheduled
|
|
146
|
+
// instant + timezone are authoritative here; surface them so the task never depends on the
|
|
147
|
+
// model's recollection. Templates may place it via {{runDate}}/{{runWeekday}}/{{runDateLine}};
|
|
148
|
+
// otherwise we prepend a Run date line so every scheduled task carries it.
|
|
149
|
+
const runDate = formatRunDate(run.scheduledFor, automation.timezone);
|
|
150
|
+
const tokens: Record<string, string> = { runDate: runDate.iso, runWeekday: runDate.weekday, runDateLine: runDate.line };
|
|
151
|
+
const fill = (text: string) => text.replace(/\{\{\s*(runDate|runWeekday|runDateLine)\s*\}\}/g, (_match, key) => tokens[key] ?? "");
|
|
152
|
+
const usesRunDateToken = /\{\{\s*(runDate|runWeekday|runDateLine)\s*\}\}/.test(`${template.title}\n${template.body}`);
|
|
153
|
+
const title = fill(template.title);
|
|
154
|
+
const body = usesRunDateToken ? fill(template.body) : `Run date: ${runDate.line}.\n\n${template.body}`;
|
|
155
|
+
const result = ingestIntegrationEvent({
|
|
156
|
+
source: "automation",
|
|
157
|
+
type: "scheduled_task",
|
|
158
|
+
severity: template.severity ?? "info",
|
|
159
|
+
title,
|
|
160
|
+
body,
|
|
161
|
+
target,
|
|
162
|
+
channel: "automation",
|
|
163
|
+
// #479 - automation tasks complete via the task-status lifecycle (claim -> done), never a
|
|
164
|
+
// `/reply`. A reply obligation here is unsatisfiable and re-injects the task into every
|
|
165
|
+
// successor session when a restart-prone managed agent (e.g. the daily telegram briefing)
|
|
166
|
+
// mints a new agent id, re-firing the scheduled work off-schedule. Dispatch without one.
|
|
167
|
+
replyExpected: false,
|
|
168
|
+
dedupeKey: template.dedupeKey ? `${template.dedupeKey}:${run.id}` : `automation:${automation.id}:run:${run.id}`,
|
|
169
|
+
externalUrl: template.externalUrl,
|
|
170
|
+
metadata: {
|
|
171
|
+
...(template.metadata ?? {}),
|
|
172
|
+
automationId: automation.id,
|
|
173
|
+
automationRunId: run.id,
|
|
174
|
+
orchestratorId: automation.orchestratorId,
|
|
175
|
+
runDate: runDate.iso,
|
|
176
|
+
runWeekday: runDate.weekday,
|
|
177
|
+
runTimezone: automation.timezone,
|
|
178
|
+
...meta,
|
|
179
|
+
},
|
|
180
|
+
}, "automation");
|
|
181
|
+
updateRun(run.id, {
|
|
182
|
+
status: meta.targetMode === "on_demand_agent" ? "waiting_agent" : "running",
|
|
183
|
+
startedAt: now,
|
|
184
|
+
targetAgentId: typeof meta.resolvedAgentId === "string"
|
|
185
|
+
? meta.resolvedAgentId
|
|
186
|
+
: target.startsWith("label:") || target.startsWith("cap:")
|
|
187
|
+
? undefined
|
|
188
|
+
: target,
|
|
189
|
+
taskId: result.task.id,
|
|
190
|
+
messageId: result.message?.id,
|
|
191
|
+
meta,
|
|
192
|
+
}, now);
|
|
193
|
+
return { automation, run: getAutomationRun(run.id)!, task: result.task, message: result.message };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function durableExistingAgentTarget(policy: Extract<AutomationTargetPolicy, { mode: "existing_agent" }>): string {
|
|
197
|
+
const label = policy.selector.label?.trim();
|
|
198
|
+
if (label) return `label:${label}`;
|
|
199
|
+
const cap = policy.selector.capabilities?.find((item) => item.trim().length > 0)?.trim();
|
|
200
|
+
if (cap) return `cap:${cap}`;
|
|
201
|
+
throw new ValidationError("existing-agent automation target selector must include a label or capability");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function dispatchQueuedAutomationRuns(now: number): AutomationDispatchResult[] {
|
|
205
|
+
const queued = (db().query(`
|
|
206
|
+
SELECT r.* FROM automation_runs r
|
|
207
|
+
JOIN automations a ON a.id = r.automation_id
|
|
208
|
+
WHERE r.status = 'scheduled' AND a.enabled = 1
|
|
209
|
+
ORDER BY r.scheduled_for ASC
|
|
210
|
+
LIMIT 25
|
|
211
|
+
`).all() as any[]).map(rowToAutomationRun);
|
|
212
|
+
const results: AutomationDispatchResult[] = [];
|
|
213
|
+
for (const run of queued) {
|
|
214
|
+
const automation = getAutomation(run.automationId);
|
|
215
|
+
if (!automation || hasActiveRun(automation.id)) continue;
|
|
216
|
+
results.push(dispatchAutomationRun(automation, run, now));
|
|
217
|
+
}
|
|
218
|
+
return results;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function rescheduleAutomation(automation: Automation, now: number): void {
|
|
222
|
+
const next = automation.enabled ? nextScheduledAt(automation.schedule, automation.timezone, now) : undefined;
|
|
223
|
+
persistNextRun(automation, next, now);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function requireOnlineOrchestrator(orchestratorId: string): Orchestrator {
|
|
227
|
+
const orchestrator = getOrchestrator(orchestratorId);
|
|
228
|
+
if (!orchestrator) throw new ValidationError(`orchestrator ${orchestratorId} not found`);
|
|
229
|
+
if (orchestrator.status !== "online") throw new ValidationError(`orchestrator ${orchestratorId} is ${orchestrator.status}`);
|
|
230
|
+
return orchestrator;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function resolveExistingAgent(policy: Extract<AutomationTargetPolicy, { mode: "existing_agent" }>, orchestrator: Orchestrator): AgentCard | null {
|
|
234
|
+
const managedAgentsById = new Map(orchestrator.managedAgents.map((agent) => [agent.agentId, agent]));
|
|
235
|
+
const managedIds = new Set(managedAgentsById.keys());
|
|
236
|
+
const candidates = listAgents()
|
|
237
|
+
.filter((agent) => agent.id !== "user" && agent.id !== "system" && agent.kind !== "channel" && agent.kind !== "orchestrator")
|
|
238
|
+
.filter((agent) => agent.ready && agent.status !== "offline")
|
|
239
|
+
.filter((agent) => managedIds.size === 0 || managedIds.has(agent.id))
|
|
240
|
+
.filter((agent) => automationAgentMatchesSelector(agent, policy.selector, managedAgentsById.get(agent.id)));
|
|
241
|
+
candidates.sort((a, b) => b.lastSeen - a.lastSeen);
|
|
242
|
+
return candidates[0] ?? null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function automationAgentMatchesSelector(
|
|
246
|
+
agent: AgentCard,
|
|
247
|
+
selector: Extract<AutomationTargetPolicy, { mode: "existing_agent" }>["selector"],
|
|
248
|
+
managedAgent?: ManagedAgent,
|
|
249
|
+
): boolean {
|
|
250
|
+
const policyName = managedAgent?.policyName ?? (typeof agent.meta?.policyName === "string" ? agent.meta.policyName : undefined);
|
|
251
|
+
const spawnPolicy = policyName ? getSpawnPolicy(policyName)?.value : undefined;
|
|
252
|
+
const tags = new Set([...agent.tags, ...stringArray((managedAgent as { tags?: unknown } | undefined)?.tags), ...(spawnPolicy?.tags ?? [])]);
|
|
253
|
+
const capabilities = new Set([
|
|
254
|
+
...agent.capabilities,
|
|
255
|
+
...stringArray((managedAgent as { capabilities?: unknown } | undefined)?.capabilities),
|
|
256
|
+
...(spawnPolicy?.capabilities ?? []),
|
|
257
|
+
]);
|
|
258
|
+
const label = agent.label ?? managedAgent?.label ?? spawnPolicy?.label;
|
|
259
|
+
const provider = typeof agent.meta?.provider === "string" ? agent.meta.provider : managedAgent?.provider ?? spawnPolicy?.provider;
|
|
260
|
+
if (selector.provider) {
|
|
261
|
+
if (provider !== selector.provider && !tags.has(selector.provider)) return false;
|
|
262
|
+
}
|
|
263
|
+
if (selector.label && label !== selector.label) return false;
|
|
264
|
+
if (selector.tags?.length && !selector.tags.every((tag) => tags.has(tag))) return false;
|
|
265
|
+
if (selector.capabilities?.length && !selector.capabilities.every((cap) => capabilities.has(cap))) return false;
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function stringArray(value: unknown): string[] {
|
|
270
|
+
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.length > 0) : [];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function automationRunLabel(automationId: string, runId: string): string {
|
|
274
|
+
return `automation-${automationId.slice(0, 8)}-${runId.slice(0, 8)}`;
|
|
275
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type { AutomationDispatchResult, AutomationReconcileResult } from "./types";
|
|
2
|
+
export { createAutomation, deleteAutomation, updateAutomation } from "./crud";
|
|
3
|
+
export { getAutomation, listAutomationRuns, listAutomations } from "./storage";
|
|
4
|
+
export { runAutomationNow, runDueAutomations } from "./dispatch";
|
|
5
|
+
export { reconcileAutomationRuns } from "./reconcile";
|