agent-relay-server 0.88.3 → 0.89.1

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.
@@ -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
+ }