agent-relay-server 0.88.3 → 0.89.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/display-CWMHll1J.js.map +1 -1
- package/src/branch-landed.ts +2 -2
- package/src/config.ts +7 -0
- package/src/db/index.ts +1 -0
- package/src/db/migrations.ts +36 -0
- package/src/db/scheduled-timers.ts +297 -0
- package/src/db/schema.ts +31 -0
- package/src/index.ts +2 -0
- package/src/mcp/index.ts +5 -0
- package/src/mcp/tools-messaging.ts +22 -2
- package/src/mcp/tools-scheduler.ts +135 -0
- package/src/services/scheduler.ts +303 -0
- package/src/services/send-message.ts +19 -18
- package/src/token-db.ts +5 -5
package/src/db/migrations.ts
CHANGED
|
@@ -881,4 +881,40 @@ export function applyMigrations(): void {
|
|
|
881
881
|
getDb().run("DROP INDEX IF EXISTS idx_terminal_events_agent");
|
|
882
882
|
getDb().run("CREATE INDEX IF NOT EXISTS idx_terminal_events_agent ON terminal_events(agent_id, epoch, created_at DESC)");
|
|
883
883
|
|
|
884
|
+
// #361 — persistent Relay scheduler. Keep this append-only at the end: older DBs may
|
|
885
|
+
// have reached this point through many schema vintages, and the table references messages.
|
|
886
|
+
getDb().run(`
|
|
887
|
+
CREATE TABLE IF NOT EXISTS scheduled_timers (
|
|
888
|
+
id TEXT PRIMARY KEY,
|
|
889
|
+
target TEXT NOT NULL,
|
|
890
|
+
created_by TEXT NOT NULL,
|
|
891
|
+
created_by_kind TEXT NOT NULL,
|
|
892
|
+
created_by_scopes TEXT NOT NULL DEFAULT '[]',
|
|
893
|
+
created_by_constraints TEXT,
|
|
894
|
+
delivery_from TEXT NOT NULL,
|
|
895
|
+
created_at INTEGER NOT NULL,
|
|
896
|
+
next_run_at INTEGER NOT NULL,
|
|
897
|
+
schedule_kind TEXT NOT NULL,
|
|
898
|
+
schedule_def TEXT NOT NULL,
|
|
899
|
+
subject TEXT,
|
|
900
|
+
body TEXT NOT NULL,
|
|
901
|
+
payload TEXT NOT NULL DEFAULT '{}',
|
|
902
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
903
|
+
run_count INTEGER NOT NULL DEFAULT 0,
|
|
904
|
+
max_runs INTEGER,
|
|
905
|
+
expires_at INTEGER,
|
|
906
|
+
last_run_at INTEGER,
|
|
907
|
+
last_error TEXT,
|
|
908
|
+
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
|
909
|
+
idempotency_key TEXT,
|
|
910
|
+
correlation_id TEXT NOT NULL,
|
|
911
|
+
last_message_id INTEGER REFERENCES messages(id),
|
|
912
|
+
claimed_by TEXT,
|
|
913
|
+
claim_expires_at INTEGER
|
|
914
|
+
)
|
|
915
|
+
`);
|
|
916
|
+
getDb().run("CREATE INDEX IF NOT EXISTS idx_scheduled_timers_due ON scheduled_timers(enabled, next_run_at, claim_expires_at)");
|
|
917
|
+
getDb().run("CREATE INDEX IF NOT EXISTS idx_scheduled_timers_creator ON scheduled_timers(created_by, enabled, next_run_at)");
|
|
918
|
+
getDb().run("CREATE INDEX IF NOT EXISTS idx_scheduled_timers_correlation ON scheduled_timers(correlation_id)");
|
|
919
|
+
|
|
884
920
|
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { getDb } from "./connection.ts";
|
|
3
|
+
import type { TokenConstraints } from "../types";
|
|
4
|
+
|
|
5
|
+
export type TimerScheduleKind = "once" | "interval";
|
|
6
|
+
|
|
7
|
+
export interface TimerScheduleDef {
|
|
8
|
+
afterMs?: number;
|
|
9
|
+
at?: string;
|
|
10
|
+
everyMs?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ScheduledTimer {
|
|
14
|
+
id: string;
|
|
15
|
+
target: string;
|
|
16
|
+
createdBy: string;
|
|
17
|
+
createdByKind: string;
|
|
18
|
+
createdByScopes: string[];
|
|
19
|
+
createdByConstraints?: TokenConstraints;
|
|
20
|
+
deliveryFrom: string;
|
|
21
|
+
createdAt: number;
|
|
22
|
+
nextRunAt: number;
|
|
23
|
+
scheduleKind: TimerScheduleKind;
|
|
24
|
+
scheduleDef: TimerScheduleDef;
|
|
25
|
+
subject?: string;
|
|
26
|
+
body: string;
|
|
27
|
+
payload: Record<string, unknown>;
|
|
28
|
+
enabled: boolean;
|
|
29
|
+
runCount: number;
|
|
30
|
+
maxRuns?: number;
|
|
31
|
+
expiresAt?: number;
|
|
32
|
+
lastRunAt?: number;
|
|
33
|
+
lastError?: string;
|
|
34
|
+
consecutiveFailures: number;
|
|
35
|
+
idempotencyKey?: string;
|
|
36
|
+
correlationId: string;
|
|
37
|
+
lastMessageId?: number;
|
|
38
|
+
claimedBy?: string;
|
|
39
|
+
claimExpiresAt?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface ScheduledTimerRow {
|
|
43
|
+
id: string;
|
|
44
|
+
target: string;
|
|
45
|
+
created_by: string;
|
|
46
|
+
created_by_kind: string;
|
|
47
|
+
created_by_scopes: string;
|
|
48
|
+
created_by_constraints: string | null;
|
|
49
|
+
delivery_from: string;
|
|
50
|
+
created_at: number;
|
|
51
|
+
next_run_at: number;
|
|
52
|
+
schedule_kind: string;
|
|
53
|
+
schedule_def: string;
|
|
54
|
+
subject: string | null;
|
|
55
|
+
body: string;
|
|
56
|
+
payload: string;
|
|
57
|
+
enabled: number;
|
|
58
|
+
run_count: number;
|
|
59
|
+
max_runs: number | null;
|
|
60
|
+
expires_at: number | null;
|
|
61
|
+
last_run_at: number | null;
|
|
62
|
+
last_error: string | null;
|
|
63
|
+
consecutive_failures: number;
|
|
64
|
+
idempotency_key: string | null;
|
|
65
|
+
correlation_id: string;
|
|
66
|
+
last_message_id: number | null;
|
|
67
|
+
claimed_by: string | null;
|
|
68
|
+
claim_expires_at: number | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface CreateScheduledTimerRowInput {
|
|
72
|
+
target: string;
|
|
73
|
+
createdBy: string;
|
|
74
|
+
createdByKind: string;
|
|
75
|
+
createdByScopes: string[];
|
|
76
|
+
createdByConstraints?: TokenConstraints;
|
|
77
|
+
deliveryFrom: string;
|
|
78
|
+
nextRunAt: number;
|
|
79
|
+
scheduleKind: TimerScheduleKind;
|
|
80
|
+
scheduleDef: TimerScheduleDef;
|
|
81
|
+
subject?: string;
|
|
82
|
+
body: string;
|
|
83
|
+
payload?: Record<string, unknown>;
|
|
84
|
+
maxRuns?: number;
|
|
85
|
+
expiresAt?: number;
|
|
86
|
+
idempotencyKey?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function insertScheduledTimer(input: CreateScheduledTimerRowInput, now = Date.now()): ScheduledTimer {
|
|
90
|
+
const id = randomUUID();
|
|
91
|
+
const correlationId = `timer:${id}`;
|
|
92
|
+
getDb().query(`
|
|
93
|
+
INSERT INTO scheduled_timers (
|
|
94
|
+
id, target, created_by, created_by_kind, created_by_scopes, created_by_constraints, delivery_from,
|
|
95
|
+
created_at, next_run_at, schedule_kind, schedule_def, subject, body, payload, enabled,
|
|
96
|
+
run_count, max_runs, expires_at, idempotency_key, correlation_id
|
|
97
|
+
)
|
|
98
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, ?, ?, ?, ?)
|
|
99
|
+
`).run(
|
|
100
|
+
id,
|
|
101
|
+
input.target,
|
|
102
|
+
input.createdBy,
|
|
103
|
+
input.createdByKind,
|
|
104
|
+
JSON.stringify(input.createdByScopes),
|
|
105
|
+
input.createdByConstraints ? JSON.stringify(input.createdByConstraints) : null,
|
|
106
|
+
input.deliveryFrom,
|
|
107
|
+
now,
|
|
108
|
+
input.nextRunAt,
|
|
109
|
+
input.scheduleKind,
|
|
110
|
+
JSON.stringify(input.scheduleDef),
|
|
111
|
+
input.subject ?? null,
|
|
112
|
+
input.body,
|
|
113
|
+
JSON.stringify(input.payload ?? {}),
|
|
114
|
+
input.maxRuns ?? null,
|
|
115
|
+
input.expiresAt ?? null,
|
|
116
|
+
input.idempotencyKey ?? null,
|
|
117
|
+
correlationId,
|
|
118
|
+
);
|
|
119
|
+
return getScheduledTimer(id)!;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function getScheduledTimer(id: string): ScheduledTimer | null {
|
|
123
|
+
const row = getDb().query("SELECT * FROM scheduled_timers WHERE id = ?").get(id) as ScheduledTimerRow | undefined;
|
|
124
|
+
return row ? rowToTimer(row) : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function listScheduledTimers(input: {
|
|
128
|
+
createdBy?: string;
|
|
129
|
+
includeDisabled?: boolean;
|
|
130
|
+
limit?: number;
|
|
131
|
+
} = {}): ScheduledTimer[] {
|
|
132
|
+
const where: string[] = [];
|
|
133
|
+
const params: Array<string | number> = [];
|
|
134
|
+
if (input.createdBy) {
|
|
135
|
+
where.push("created_by = ?");
|
|
136
|
+
params.push(input.createdBy);
|
|
137
|
+
}
|
|
138
|
+
if (!input.includeDisabled) where.push("enabled = 1");
|
|
139
|
+
const limit = Math.min(Math.max(input.limit ?? 100, 1), 500);
|
|
140
|
+
const sql = `SELECT * FROM scheduled_timers${where.length ? ` WHERE ${where.join(" AND ")}` : ""} ORDER BY next_run_at ASC, created_at ASC LIMIT ?`;
|
|
141
|
+
const rows = getDb().query(sql).all(...params, limit) as ScheduledTimerRow[];
|
|
142
|
+
return rows.map(rowToTimer);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function countActiveScheduledTimers(createdBy: string): number {
|
|
146
|
+
const row = getDb()
|
|
147
|
+
.query("SELECT count(*) AS count FROM scheduled_timers WHERE created_by = ? AND enabled = 1")
|
|
148
|
+
.get(createdBy) as { count: number };
|
|
149
|
+
return row.count;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function listDueScheduledTimerIds(now: number, limit = 25): string[] {
|
|
153
|
+
const rows = getDb().query(`
|
|
154
|
+
SELECT id FROM scheduled_timers
|
|
155
|
+
WHERE enabled = 1
|
|
156
|
+
AND next_run_at <= ?
|
|
157
|
+
AND (claim_expires_at IS NULL OR claim_expires_at <= ?)
|
|
158
|
+
ORDER BY next_run_at ASC, created_at ASC
|
|
159
|
+
LIMIT ?
|
|
160
|
+
`).all(now, now, limit) as Array<{ id: string }>;
|
|
161
|
+
return rows.map((row) => row.id);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function claimScheduledTimer(id: string, owner: string, now: number, leaseMs: number): ScheduledTimer | null {
|
|
165
|
+
const claimExpiresAt = now + leaseMs;
|
|
166
|
+
const result = getDb().query(`
|
|
167
|
+
UPDATE scheduled_timers
|
|
168
|
+
SET claimed_by = ?, claim_expires_at = ?
|
|
169
|
+
WHERE id = ?
|
|
170
|
+
AND enabled = 1
|
|
171
|
+
AND next_run_at <= ?
|
|
172
|
+
AND (claim_expires_at IS NULL OR claim_expires_at <= ?)
|
|
173
|
+
`).run(owner, claimExpiresAt, id, now, now);
|
|
174
|
+
return result.changes === 0 ? null : getScheduledTimer(id);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function completeScheduledTimerRun(input: {
|
|
178
|
+
timer: ScheduledTimer;
|
|
179
|
+
messageId: number;
|
|
180
|
+
now: number;
|
|
181
|
+
}): ScheduledTimer {
|
|
182
|
+
const runCount = input.timer.runCount + 1;
|
|
183
|
+
const everyMs = input.timer.scheduleKind === "interval" ? input.timer.scheduleDef.everyMs : undefined;
|
|
184
|
+
const candidateNextRunAt = everyMs ? Math.max(input.now + everyMs, input.timer.nextRunAt + everyMs) : null;
|
|
185
|
+
const reachedMaxRuns = input.timer.maxRuns !== undefined && runCount >= input.timer.maxRuns;
|
|
186
|
+
const expiredBeforeNext = input.timer.expiresAt !== undefined &&
|
|
187
|
+
(candidateNextRunAt === null || candidateNextRunAt > input.timer.expiresAt);
|
|
188
|
+
const enabled = Boolean(candidateNextRunAt && !reachedMaxRuns && !expiredBeforeNext);
|
|
189
|
+
|
|
190
|
+
getDb().query(`
|
|
191
|
+
UPDATE scheduled_timers
|
|
192
|
+
SET run_count = ?,
|
|
193
|
+
last_run_at = ?,
|
|
194
|
+
last_error = NULL,
|
|
195
|
+
consecutive_failures = 0,
|
|
196
|
+
last_message_id = ?,
|
|
197
|
+
next_run_at = COALESCE(?, next_run_at),
|
|
198
|
+
enabled = ?,
|
|
199
|
+
claimed_by = NULL,
|
|
200
|
+
claim_expires_at = NULL
|
|
201
|
+
WHERE id = ?
|
|
202
|
+
`).run(runCount, input.now, input.messageId, candidateNextRunAt, enabled ? 1 : 0, input.timer.id);
|
|
203
|
+
return getScheduledTimer(input.timer.id)!;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function recordScheduledTimerFailure(input: {
|
|
207
|
+
id: string;
|
|
208
|
+
error: string;
|
|
209
|
+
now: number;
|
|
210
|
+
retryMs: number;
|
|
211
|
+
maxFailures: number;
|
|
212
|
+
}): ScheduledTimer | null {
|
|
213
|
+
const current = getScheduledTimer(input.id);
|
|
214
|
+
if (!current) return null;
|
|
215
|
+
const failures = current.consecutiveFailures + 1;
|
|
216
|
+
const enabled = failures < input.maxFailures;
|
|
217
|
+
getDb().query(`
|
|
218
|
+
UPDATE scheduled_timers
|
|
219
|
+
SET last_error = ?,
|
|
220
|
+
consecutive_failures = ?,
|
|
221
|
+
next_run_at = ?,
|
|
222
|
+
enabled = ?,
|
|
223
|
+
claimed_by = NULL,
|
|
224
|
+
claim_expires_at = NULL
|
|
225
|
+
WHERE id = ?
|
|
226
|
+
`).run(input.error, failures, input.now + input.retryMs, enabled ? 1 : 0, input.id);
|
|
227
|
+
return getScheduledTimer(input.id);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function cancelScheduledTimer(id: string, now = Date.now()): ScheduledTimer | null {
|
|
231
|
+
const result = getDb().query(`
|
|
232
|
+
UPDATE scheduled_timers
|
|
233
|
+
SET enabled = 0, claimed_by = NULL, claim_expires_at = NULL, last_error = COALESCE(last_error, 'cancelled')
|
|
234
|
+
WHERE id = ? AND enabled = 1
|
|
235
|
+
`).run(id);
|
|
236
|
+
if (result.changes === 0) return getScheduledTimer(id);
|
|
237
|
+
return getScheduledTimer(id);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function disableScheduledTimer(id: string, reason: string): ScheduledTimer | null {
|
|
241
|
+
getDb().query(`
|
|
242
|
+
UPDATE scheduled_timers
|
|
243
|
+
SET enabled = 0, last_error = ?, claimed_by = NULL, claim_expires_at = NULL
|
|
244
|
+
WHERE id = ?
|
|
245
|
+
`).run(reason, id);
|
|
246
|
+
return getScheduledTimer(id);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function rowToTimer(row: ScheduledTimerRow): ScheduledTimer {
|
|
250
|
+
return {
|
|
251
|
+
id: row.id,
|
|
252
|
+
target: row.target,
|
|
253
|
+
createdBy: row.created_by,
|
|
254
|
+
createdByKind: row.created_by_kind,
|
|
255
|
+
createdByScopes: parseStringArray(row.created_by_scopes),
|
|
256
|
+
createdByConstraints: parseRecord(row.created_by_constraints) as TokenConstraints | undefined,
|
|
257
|
+
deliveryFrom: row.delivery_from,
|
|
258
|
+
createdAt: row.created_at,
|
|
259
|
+
nextRunAt: row.next_run_at,
|
|
260
|
+
scheduleKind: row.schedule_kind === "interval" ? "interval" : "once",
|
|
261
|
+
scheduleDef: parseRecord(row.schedule_def) as TimerScheduleDef,
|
|
262
|
+
subject: row.subject ?? undefined,
|
|
263
|
+
body: row.body,
|
|
264
|
+
payload: parseRecord(row.payload) ?? {},
|
|
265
|
+
enabled: row.enabled === 1,
|
|
266
|
+
runCount: row.run_count,
|
|
267
|
+
maxRuns: row.max_runs ?? undefined,
|
|
268
|
+
expiresAt: row.expires_at ?? undefined,
|
|
269
|
+
lastRunAt: row.last_run_at ?? undefined,
|
|
270
|
+
lastError: row.last_error ?? undefined,
|
|
271
|
+
consecutiveFailures: row.consecutive_failures,
|
|
272
|
+
idempotencyKey: row.idempotency_key ?? undefined,
|
|
273
|
+
correlationId: row.correlation_id,
|
|
274
|
+
lastMessageId: row.last_message_id ?? undefined,
|
|
275
|
+
claimedBy: row.claimed_by ?? undefined,
|
|
276
|
+
claimExpiresAt: row.claim_expires_at ?? undefined,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function parseRecord(raw: string | null): Record<string, unknown> | undefined {
|
|
281
|
+
if (!raw) return undefined;
|
|
282
|
+
try {
|
|
283
|
+
const parsed = JSON.parse(raw);
|
|
284
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : undefined;
|
|
285
|
+
} catch {
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function parseStringArray(raw: string): string[] {
|
|
291
|
+
try {
|
|
292
|
+
const parsed = JSON.parse(raw);
|
|
293
|
+
return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : [];
|
|
294
|
+
} catch {
|
|
295
|
+
return [];
|
|
296
|
+
}
|
|
297
|
+
}
|
package/src/db/schema.ts
CHANGED
|
@@ -164,6 +164,37 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
164
164
|
-- per candidate row (O(n^2)): ~8.6s at 4k rows, which blew the 5s Stop-hook
|
|
165
165
|
-- timeout and wedged turns in "busy" (#199).
|
|
166
166
|
CREATE INDEX IF NOT EXISTS idx_msg_reply_to ON messages(reply_to, from_agent);
|
|
167
|
+
CREATE TABLE IF NOT EXISTS scheduled_timers (
|
|
168
|
+
id TEXT PRIMARY KEY,
|
|
169
|
+
target TEXT NOT NULL,
|
|
170
|
+
created_by TEXT NOT NULL,
|
|
171
|
+
created_by_kind TEXT NOT NULL,
|
|
172
|
+
created_by_scopes TEXT NOT NULL DEFAULT '[]',
|
|
173
|
+
created_by_constraints TEXT,
|
|
174
|
+
delivery_from TEXT NOT NULL,
|
|
175
|
+
created_at INTEGER NOT NULL,
|
|
176
|
+
next_run_at INTEGER NOT NULL,
|
|
177
|
+
schedule_kind TEXT NOT NULL,
|
|
178
|
+
schedule_def TEXT NOT NULL,
|
|
179
|
+
subject TEXT,
|
|
180
|
+
body TEXT NOT NULL,
|
|
181
|
+
payload TEXT NOT NULL DEFAULT '{}',
|
|
182
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
183
|
+
run_count INTEGER NOT NULL DEFAULT 0,
|
|
184
|
+
max_runs INTEGER,
|
|
185
|
+
expires_at INTEGER,
|
|
186
|
+
last_run_at INTEGER,
|
|
187
|
+
last_error TEXT,
|
|
188
|
+
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
|
189
|
+
idempotency_key TEXT,
|
|
190
|
+
correlation_id TEXT NOT NULL,
|
|
191
|
+
last_message_id INTEGER REFERENCES messages(id),
|
|
192
|
+
claimed_by TEXT,
|
|
193
|
+
claim_expires_at INTEGER
|
|
194
|
+
);
|
|
195
|
+
CREATE INDEX IF NOT EXISTS idx_scheduled_timers_due ON scheduled_timers(enabled, next_run_at, claim_expires_at);
|
|
196
|
+
CREATE INDEX IF NOT EXISTS idx_scheduled_timers_creator ON scheduled_timers(created_by, enabled, next_run_at);
|
|
197
|
+
CREATE INDEX IF NOT EXISTS idx_scheduled_timers_correlation ON scheduled_timers(correlation_id);
|
|
167
198
|
CREATE TABLE IF NOT EXISTS teams (
|
|
168
199
|
id TEXT PRIMARY KEY,
|
|
169
200
|
coordinator_id TEXT,
|
package/src/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { startConnectorStatusPoller } from "./connectors";
|
|
|
13
13
|
import { installNotificationSubscriptionRule } from "./notification-subscription-rules";
|
|
14
14
|
import { installQuotaAdvisoryHandler } from "./quota-advisory";
|
|
15
15
|
import { startPlanLiveBindings } from "./services/plan-live-bindings";
|
|
16
|
+
import { startRelayScheduler } from "./services/scheduler";
|
|
16
17
|
import { resolve, sep } from "path";
|
|
17
18
|
import { gzipSync, brotliCompressSync, constants as zlibConstants } from "node:zlib";
|
|
18
19
|
import {
|
|
@@ -85,6 +86,7 @@ function startServer(): void {
|
|
|
85
86
|
reconcileRelayVersion();
|
|
86
87
|
startConnectorStatusPoller();
|
|
87
88
|
startMaintenanceScheduler();
|
|
89
|
+
startRelayScheduler();
|
|
88
90
|
|
|
89
91
|
setInterval(() => {
|
|
90
92
|
emitAutomationReconciliations(reconcileAutomationRuns());
|
package/src/mcp/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type { JsonRpcId, JsonRpcRequest, McpAuthContext, ToolDefinition } from "
|
|
|
18
18
|
import { positiveIdOrUndefined, recordField, stringField } from "./validation";
|
|
19
19
|
import { IDENTITY_TOOLS, hasAnyScope, mcpAuthContext, relayCompactAndResume, relayRecall, relayWhoami } from "./identity";
|
|
20
20
|
import { MESSAGING_TOOLS, relayAgentCrashReport, relayAgentStatus, relayAttachArtifact, relayFindAgents, relayGetMessage, relayGetThread, relayReply, relaySendMessage, relayUploadArtifact } from "./tools-messaging";
|
|
21
|
+
import { SCHEDULER_TOOLS, relayCancelTimer, relayListTimers, relayScheduleTimer } from "./tools-scheduler";
|
|
21
22
|
import { SPAWN_TOOLS, relayMyAgents, relayShutdownAgent, relaySpawnAgent, relaySpawnTargets } from "./tools-spawn";
|
|
22
23
|
import { WORKSPACE_TOOLS, callerIsolatedWorkspace, relayWorkspaceList, relayWorkspaceMutation, relayWorkspaceStatus } from "./tools-workspace";
|
|
23
24
|
|
|
@@ -25,6 +26,7 @@ const MCP_PROTOCOL_VERSION = "2024-11-05";
|
|
|
25
26
|
const MCP_BODY_OVERHEAD_BYTES = 16 * 1024;
|
|
26
27
|
const TOOLS: ToolDefinition[] = [
|
|
27
28
|
...MESSAGING_TOOLS,
|
|
29
|
+
...SCHEDULER_TOOLS,
|
|
28
30
|
...IDENTITY_TOOLS,
|
|
29
31
|
...SPAWN_TOOLS,
|
|
30
32
|
AGENT_ACTION_TOOL,
|
|
@@ -121,6 +123,9 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
|
|
|
121
123
|
else if (name === "relay_reply") result = relayReply(auth, args);
|
|
122
124
|
else if (name === "relay_get_message") result = relayGetMessage(args);
|
|
123
125
|
else if (name === "relay_get_thread") result = relayGetThread(args);
|
|
126
|
+
else if (name === "relay_schedule_timer") result = relayScheduleTimer(auth, args);
|
|
127
|
+
else if (name === "relay_list_timers") result = relayListTimers(auth, args);
|
|
128
|
+
else if (name === "relay_cancel_timer") result = relayCancelTimer(auth, args);
|
|
124
129
|
else if (name === "relay_upload_artifact") result = await relayUploadArtifact(auth, args);
|
|
125
130
|
else if (name === "relay_attach_artifact") result = relayAttachArtifact(auth, args);
|
|
126
131
|
else if (name === "relay_agent_status") result = relayAgentStatus(args);
|
|
@@ -30,11 +30,12 @@ import { McpAuthError, McpNotFoundError } from "../mcp-errors";
|
|
|
30
30
|
import { authContextFromMcp } from "../services/auth-context";
|
|
31
31
|
import { ServiceAuthError } from "../services/errors";
|
|
32
32
|
import { sendMessageService } from "../services/send-message";
|
|
33
|
+
import { scheduleTimer } from "../services/scheduler";
|
|
33
34
|
import { optionalEnum } from "../validation";
|
|
34
35
|
import { isRecord } from "agent-relay-sdk";
|
|
35
36
|
import type { ArtifactKind, ArtifactSensitivity, AttachmentRef, Message, SendMessageInput } from "../types";
|
|
36
37
|
import type { McpAuthContext, ToolDefinition } from "./types";
|
|
37
|
-
import { callerAgentId } from "./identity";
|
|
38
|
+
import { callerAgentId, hasScope } from "./identity";
|
|
38
39
|
import { enumField, optionalAttachments, optionalBoolean, optionalFutureTimestamp, optionalNonNegativeInt, optionalPositiveInt, optionalRecord, optionalString, positiveId, recordField, stringField } from "./validation";
|
|
39
40
|
|
|
40
41
|
const VALID_ARTIFACT_KINDS = ["image", "audio", "video", "document", "archive", "other"] as const;
|
|
@@ -57,6 +58,7 @@ export const MESSAGING_TOOLS: ToolDefinition[] = [
|
|
|
57
58
|
channel: { type: "string" },
|
|
58
59
|
claimable: { type: "boolean" },
|
|
59
60
|
idempotencyKey: { type: "string" },
|
|
61
|
+
deliveryTime: { type: "string", description: "Optional future ISO timestamp. When set, relay_send_message creates a one-shot durable timer instead of sending immediately. Requires schedule:write." },
|
|
60
62
|
attachments: { type: "array", items: { type: "object" } },
|
|
61
63
|
payload: { type: "object" },
|
|
62
64
|
meta: { type: "object" },
|
|
@@ -221,9 +223,27 @@ function runMcpSend(auth: McpAuthContext, input: SendMessageInput): Message & {
|
|
|
221
223
|
}
|
|
222
224
|
}
|
|
223
225
|
|
|
224
|
-
export function relaySendMessage(auth: McpAuthContext, args: Record<string, unknown>): Message & { delivery: DeliveryReceipt } {
|
|
226
|
+
export function relaySendMessage(auth: McpAuthContext, args: Record<string, unknown>): (Message & { delivery: DeliveryReceipt }) | Record<string, unknown> {
|
|
225
227
|
const attachments = optionalAttachments(args.attachments);
|
|
226
228
|
const payload = payloadWithAttachments(optionalRecord(args.payload, "payload"), attachments);
|
|
229
|
+
const deliveryTime = optionalString(args.deliveryTime, "deliveryTime", 80);
|
|
230
|
+
if (deliveryTime) {
|
|
231
|
+
if (!hasScope(auth, "schedule:write")) throw new McpAuthError("deliveryTime requires schedule:write");
|
|
232
|
+
try {
|
|
233
|
+
const timer = scheduleTimer({
|
|
234
|
+
target: stringField(args.to, "to", { required: true, max: 200 }),
|
|
235
|
+
at: deliveryTime,
|
|
236
|
+
body: stringField(args.body, "body", { required: true, maxBytes: MAX_BODY_BYTES }),
|
|
237
|
+
subject: optionalString(args.subject, "subject", 200),
|
|
238
|
+
payload,
|
|
239
|
+
idempotencyKey: optionalString(args.idempotencyKey, "idempotencyKey", 240),
|
|
240
|
+
}, authContextFromMcp({ kind: auth.kind, actor: auth.actor, scopes: auth.scopes, component: auth.component }));
|
|
241
|
+
return { scheduled: true, timer };
|
|
242
|
+
} catch (e) {
|
|
243
|
+
if (e instanceof ServiceAuthError) throw new McpAuthError(e.message);
|
|
244
|
+
throw e;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
227
247
|
const input: SendMessageInput = {
|
|
228
248
|
from: optionalString(args.from, "from", 200) ?? "",
|
|
229
249
|
to: stringField(args.to, "to", { required: true, max: 200 }),
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { McpAuthError } from "../mcp-errors";
|
|
2
|
+
import { authContextFromMcp } from "../services/auth-context";
|
|
3
|
+
import { ServiceAuthError } from "../services/errors";
|
|
4
|
+
import { cancelTimer, listTimers, scheduleTimer, type ScheduleTimerInput } from "../services/scheduler";
|
|
5
|
+
import type { ScheduledTimer } from "../db";
|
|
6
|
+
import type { McpAuthContext, ToolDefinition } from "./types";
|
|
7
|
+
import { optionalBoolean, optionalPositiveInt, optionalRecord, optionalString, stringField } from "./validation";
|
|
8
|
+
|
|
9
|
+
export const SCHEDULER_TOOLS: ToolDefinition[] = [
|
|
10
|
+
{
|
|
11
|
+
name: "relay_schedule_timer",
|
|
12
|
+
description: "Create a durable Relay timer that wakes a target by enqueueing a normal Relay message when due. Supports one-shot afterMs/at and fixed recurring everyMs. RRULE is intentionally deferred.",
|
|
13
|
+
requiredScopes: ["schedule:write"],
|
|
14
|
+
inputSchema: {
|
|
15
|
+
type: "object",
|
|
16
|
+
properties: {
|
|
17
|
+
target: { type: "string", description: "Defaults to me. Supports agent id, policy:name, label:x, channel targets, and other normal Relay targets." },
|
|
18
|
+
afterMs: { type: "integer", minimum: 1 },
|
|
19
|
+
at: { type: "string", description: "Future ISO timestamp." },
|
|
20
|
+
everyMs: { type: "integer", minimum: 1 },
|
|
21
|
+
subject: { type: "string" },
|
|
22
|
+
body: { type: "string" },
|
|
23
|
+
payload: { type: "object" },
|
|
24
|
+
maxRuns: { type: "integer", minimum: 1 },
|
|
25
|
+
expiresAt: { oneOf: [{ type: "string" }, { type: "integer", minimum: 1 }] },
|
|
26
|
+
idempotencyKey: { type: "string" },
|
|
27
|
+
},
|
|
28
|
+
required: ["body"],
|
|
29
|
+
additionalProperties: false,
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "relay_list_timers",
|
|
34
|
+
description: "List your durable Relay timers. Admin callers may pass createdBy to inspect another creator.",
|
|
35
|
+
requiredScopes: ["schedule:write"],
|
|
36
|
+
inputSchema: {
|
|
37
|
+
type: "object",
|
|
38
|
+
properties: {
|
|
39
|
+
includeDisabled: { type: "boolean" },
|
|
40
|
+
limit: { type: "integer", minimum: 1, maximum: 500 },
|
|
41
|
+
createdBy: { type: "string" },
|
|
42
|
+
},
|
|
43
|
+
additionalProperties: false,
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: "relay_cancel_timer",
|
|
48
|
+
description: "Cancel one of your durable Relay timers. Admin callers may cancel any timer.",
|
|
49
|
+
requiredScopes: ["schedule:write"],
|
|
50
|
+
inputSchema: {
|
|
51
|
+
type: "object",
|
|
52
|
+
properties: {
|
|
53
|
+
timerId: { type: "string" },
|
|
54
|
+
},
|
|
55
|
+
required: ["timerId"],
|
|
56
|
+
additionalProperties: false,
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
export function relayScheduleTimer(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
|
|
62
|
+
return withAuthErrors(() => ({
|
|
63
|
+
timer: serializeTimer(scheduleTimer(cleanScheduleInput(args), authContextFromMcp(authContextPayload(auth)))),
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function relayListTimers(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
|
|
68
|
+
return withAuthErrors(() => {
|
|
69
|
+
const timers = listTimers(authContextFromMcp(authContextPayload(auth)), {
|
|
70
|
+
includeDisabled: optionalBoolean(args.includeDisabled, "includeDisabled"),
|
|
71
|
+
limit: optionalPositiveInt(args.limit, "limit"),
|
|
72
|
+
createdBy: optionalString(args.createdBy, "createdBy", 200),
|
|
73
|
+
});
|
|
74
|
+
return { timers: timers.map(serializeTimer), count: timers.length };
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function relayCancelTimer(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
|
|
79
|
+
return withAuthErrors(() => ({
|
|
80
|
+
timer: serializeTimer(cancelTimer(stringField(args.timerId, "timerId", { required: true, max: 120 }), authContextFromMcp(authContextPayload(auth)))),
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function cleanScheduleInput(args: Record<string, unknown>): ScheduleTimerInput {
|
|
85
|
+
return {
|
|
86
|
+
target: optionalString(args.target, "target", 200),
|
|
87
|
+
afterMs: optionalPositiveInt(args.afterMs, "afterMs"),
|
|
88
|
+
at: optionalString(args.at, "at", 80),
|
|
89
|
+
everyMs: optionalPositiveInt(args.everyMs, "everyMs"),
|
|
90
|
+
subject: optionalString(args.subject, "subject", 200),
|
|
91
|
+
body: stringField(args.body, "body", { required: true }),
|
|
92
|
+
payload: optionalRecord(args.payload, "payload"),
|
|
93
|
+
maxRuns: optionalPositiveInt(args.maxRuns, "maxRuns"),
|
|
94
|
+
expiresAt: typeof args.expiresAt === "number" ? args.expiresAt : optionalString(args.expiresAt, "expiresAt", 80),
|
|
95
|
+
idempotencyKey: optionalString(args.idempotencyKey, "idempotencyKey", 240),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function authContextPayload(auth: McpAuthContext): Parameters<typeof authContextFromMcp>[0] {
|
|
100
|
+
return { kind: auth.kind, actor: auth.actor, scopes: auth.scopes, component: auth.component };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function serializeTimer(timer: ScheduledTimer): Record<string, unknown> {
|
|
104
|
+
return {
|
|
105
|
+
id: timer.id,
|
|
106
|
+
target: timer.target,
|
|
107
|
+
createdBy: timer.createdBy,
|
|
108
|
+
createdAt: timer.createdAt,
|
|
109
|
+
nextRunAt: timer.nextRunAt,
|
|
110
|
+
scheduleKind: timer.scheduleKind,
|
|
111
|
+
scheduleDef: timer.scheduleDef,
|
|
112
|
+
subject: timer.subject,
|
|
113
|
+
body: timer.body,
|
|
114
|
+
payload: timer.payload,
|
|
115
|
+
enabled: timer.enabled,
|
|
116
|
+
runCount: timer.runCount,
|
|
117
|
+
maxRuns: timer.maxRuns,
|
|
118
|
+
expiresAt: timer.expiresAt,
|
|
119
|
+
lastRunAt: timer.lastRunAt,
|
|
120
|
+
lastError: timer.lastError,
|
|
121
|
+
consecutiveFailures: timer.consecutiveFailures,
|
|
122
|
+
idempotencyKey: timer.idempotencyKey,
|
|
123
|
+
correlationId: timer.correlationId,
|
|
124
|
+
lastMessageId: timer.lastMessageId,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function withAuthErrors<T>(fn: () => T): T {
|
|
129
|
+
try {
|
|
130
|
+
return fn();
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (error instanceof ServiceAuthError) throw new McpAuthError(error.message);
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
}
|