ompclaw 0.3.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/CHANGELOG.md +34 -0
- package/LICENSE +21 -0
- package/NOTICE +10 -0
- package/README.md +152 -0
- package/SECURITY.md +61 -0
- package/config.example.json +45 -0
- package/docs/guide.md +360 -0
- package/docs/rpc-service.md +240 -0
- package/package.json +93 -0
- package/src/api.ts +556 -0
- package/src/gateway-app.ts +393 -0
- package/src/gateway-config.ts +379 -0
- package/src/gateway-core.ts +410 -0
- package/src/gateway-scheduler.ts +425 -0
- package/src/gateway-store.ts +947 -0
- package/src/gateway-tools.ts +443 -0
- package/src/gateway-types.ts +290 -0
- package/src/inbox.ts +77 -0
- package/src/index.ts +13 -0
- package/src/markdown.ts +156 -0
- package/src/outbound.ts +353 -0
- package/src/rpc-cli.ts +408 -0
- package/src/rpc-client.ts +308 -0
- package/src/rpc-config.ts +70 -0
- package/src/rpc-profile.ts +215 -0
- package/src/rpc-protocol.ts +326 -0
- package/src/rpc-runtime.ts +875 -0
- package/src/rpc-service.ts +191 -0
- package/src/rpc-ui.ts +218 -0
- package/src/transports/telegram/adapter.ts +829 -0
- package/src/transports/websocket/adapter.ts +704 -0
- package/src/transports/websocket/protocol.ts +256 -0
- package/src/type-guards.ts +4 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { Cron } from "croner";
|
|
3
|
+
import type { ScheduledJob, ScheduledJobSchedule } from "./gateway-store";
|
|
4
|
+
import type { ConversationAddress, Principal, TransportIdentity } from "./gateway-types";
|
|
5
|
+
|
|
6
|
+
const MAX_JOB_NAME_LENGTH = 120;
|
|
7
|
+
const MAX_JOB_PROMPT_LENGTH = 16_000;
|
|
8
|
+
const MAX_ERROR_LENGTH = 2_000;
|
|
9
|
+
|
|
10
|
+
export interface ScheduledJobContext {
|
|
11
|
+
readonly principal: Principal;
|
|
12
|
+
readonly identity: TransportIdentity;
|
|
13
|
+
readonly address: ConversationAddress;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CreateScheduledJobInput {
|
|
17
|
+
readonly name: string;
|
|
18
|
+
readonly prompt: string;
|
|
19
|
+
readonly at?: string;
|
|
20
|
+
readonly cron?: string;
|
|
21
|
+
readonly timezone?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface UpdateScheduledJobInput {
|
|
25
|
+
readonly id: string;
|
|
26
|
+
readonly name?: string;
|
|
27
|
+
readonly prompt?: string;
|
|
28
|
+
readonly at?: string;
|
|
29
|
+
readonly cron?: string;
|
|
30
|
+
readonly timezone?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface GatewayAutomationControl {
|
|
34
|
+
create(input: CreateScheduledJobInput, context: ScheduledJobContext): ScheduledJob;
|
|
35
|
+
update(input: UpdateScheduledJobInput, context: ScheduledJobContext): ScheduledJob;
|
|
36
|
+
remove(id: string, principalId: string): boolean;
|
|
37
|
+
setEnabled(id: string, principalId: string, enabled: boolean): ScheduledJob;
|
|
38
|
+
runNow(id: string, principalId: string): ScheduledJob;
|
|
39
|
+
get(id: string, principalId: string): ScheduledJob | undefined;
|
|
40
|
+
list(principalId: string): readonly ScheduledJob[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface GatewayScheduledJobStore {
|
|
44
|
+
createScheduledJob(job: ScheduledJob): void;
|
|
45
|
+
updateScheduledJob(job: ScheduledJob): boolean;
|
|
46
|
+
getScheduledJob(id: string, principalId?: string): ScheduledJob | undefined;
|
|
47
|
+
listScheduledJobs(principalId?: string): ScheduledJob[];
|
|
48
|
+
listDueScheduledJobs(now: number, limit?: number): ScheduledJob[];
|
|
49
|
+
deleteScheduledJob(id: string, principalId: string): boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface GatewaySchedulerOptions {
|
|
53
|
+
readonly store: GatewayScheduledJobStore;
|
|
54
|
+
readonly dispatch: (job: ScheduledJob, scheduledFor: number) => Promise<void>;
|
|
55
|
+
readonly enabled?: boolean;
|
|
56
|
+
readonly pollIntervalMs?: number;
|
|
57
|
+
readonly retryDelayMs?: number;
|
|
58
|
+
readonly maxAttempts?: number;
|
|
59
|
+
readonly now?: () => number;
|
|
60
|
+
readonly setTimer?: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
|
61
|
+
readonly clearTimer?: (timer: ReturnType<typeof setTimeout>) => void;
|
|
62
|
+
readonly onPermanentFailure?: (job: ScheduledJob, error: Error) => Promise<void> | void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface GatewaySchedulerLogger {
|
|
66
|
+
info(message: string): void;
|
|
67
|
+
warn(message: string): void;
|
|
68
|
+
error(message: string): void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A scheduled turn waits for the existing interactive turn instead of counting as a failed attempt. */
|
|
72
|
+
export class ScheduledDispatchBusyError extends Error {
|
|
73
|
+
readonly name = "ScheduledDispatchBusyError";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Durable one-shot and cron scheduling, scoped to server-derived gateway principals. */
|
|
77
|
+
export class GatewayScheduler implements GatewayAutomationControl {
|
|
78
|
+
readonly #options: Required<Pick<GatewaySchedulerOptions, "pollIntervalMs" | "retryDelayMs" | "maxAttempts">> & GatewaySchedulerOptions;
|
|
79
|
+
readonly #log: GatewaySchedulerLogger;
|
|
80
|
+
#timer: ReturnType<typeof setTimeout> | undefined;
|
|
81
|
+
#started = false;
|
|
82
|
+
#running = false;
|
|
83
|
+
|
|
84
|
+
constructor(options: GatewaySchedulerOptions, logger: GatewaySchedulerLogger = console) {
|
|
85
|
+
this.#options = {
|
|
86
|
+
...options,
|
|
87
|
+
pollIntervalMs: boundedInteger(options.pollIntervalMs ?? 1_000, "scheduler poll interval", 250, 60_000),
|
|
88
|
+
retryDelayMs: boundedInteger(options.retryDelayMs ?? 15_000, "scheduler retry delay", 1_000, 3_600_000),
|
|
89
|
+
maxAttempts: boundedInteger(options.maxAttempts ?? 3, "scheduler max attempts", 1, 10),
|
|
90
|
+
};
|
|
91
|
+
this.#log = logger;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
start(): void {
|
|
95
|
+
if (this.#started || this.#options.enabled === false) return;
|
|
96
|
+
this.#started = true;
|
|
97
|
+
this.#schedule(0);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
stop(): void {
|
|
101
|
+
this.#started = false;
|
|
102
|
+
if (this.#timer !== undefined) (this.#options.clearTimer ?? clearTimeout)(this.#timer);
|
|
103
|
+
this.#timer = undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
create(input: CreateScheduledJobInput, context: ScheduledJobContext): ScheduledJob {
|
|
107
|
+
validateContext(context);
|
|
108
|
+
const now = this.#now();
|
|
109
|
+
const schedule = parseSchedule(input, now);
|
|
110
|
+
const nextRunAt = nextOccurrence(schedule, now - 1);
|
|
111
|
+
if (nextRunAt === undefined) throw new Error("Schedule has no future occurrence");
|
|
112
|
+
const job: ScheduledJob = {
|
|
113
|
+
id: randomUUID(),
|
|
114
|
+
principalId: context.principal.id,
|
|
115
|
+
identity: context.identity,
|
|
116
|
+
address: context.address,
|
|
117
|
+
name: boundedText(input.name, "job name", MAX_JOB_NAME_LENGTH),
|
|
118
|
+
prompt: boundedText(input.prompt, "job prompt", MAX_JOB_PROMPT_LENGTH),
|
|
119
|
+
schedule,
|
|
120
|
+
enabled: true,
|
|
121
|
+
nextRunAt,
|
|
122
|
+
attemptCount: 0,
|
|
123
|
+
successCount: 0,
|
|
124
|
+
failureCount: 0,
|
|
125
|
+
createdAt: now,
|
|
126
|
+
updatedAt: now,
|
|
127
|
+
};
|
|
128
|
+
this.#options.store.createScheduledJob(job);
|
|
129
|
+
this.#wake();
|
|
130
|
+
return job;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
update(input: UpdateScheduledJobInput, context: ScheduledJobContext): ScheduledJob {
|
|
134
|
+
validateContext(context);
|
|
135
|
+
const id = boundedText(input.id, "job id", 128);
|
|
136
|
+
const current = this.#owned(id, context.principal.id);
|
|
137
|
+
const now = this.#now();
|
|
138
|
+
const replacesSchedule = input.at !== undefined || input.cron !== undefined;
|
|
139
|
+
const changesSchedule = replacesSchedule || input.timezone !== undefined;
|
|
140
|
+
const schedule = replacesSchedule
|
|
141
|
+
? parseSchedule(input, now)
|
|
142
|
+
: input.timezone === undefined
|
|
143
|
+
? current.schedule
|
|
144
|
+
: current.schedule.kind === "cron"
|
|
145
|
+
? parseSchedule({ cron: current.schedule.expression, timezone: input.timezone }, now)
|
|
146
|
+
: (() => { throw new Error("timezone is only valid with cron"); })();
|
|
147
|
+
const nextRunAt = changesSchedule ? nextOccurrence(schedule, now - 1) : current.nextRunAt;
|
|
148
|
+
if (changesSchedule && nextRunAt === undefined) throw new Error("Schedule has no future occurrence");
|
|
149
|
+
const updated: ScheduledJob = {
|
|
150
|
+
...current,
|
|
151
|
+
identity: context.identity,
|
|
152
|
+
address: context.address,
|
|
153
|
+
name: input.name === undefined ? current.name : boundedText(input.name, "job name", MAX_JOB_NAME_LENGTH),
|
|
154
|
+
prompt: input.prompt === undefined ? current.prompt : boundedText(input.prompt, "job prompt", MAX_JOB_PROMPT_LENGTH),
|
|
155
|
+
schedule,
|
|
156
|
+
enabled: changesSchedule ? true : current.enabled,
|
|
157
|
+
...(nextRunAt === undefined ? {} : { nextRunAt }),
|
|
158
|
+
retryAt: undefined,
|
|
159
|
+
attemptCount: 0,
|
|
160
|
+
updatedAt: now,
|
|
161
|
+
};
|
|
162
|
+
if (!this.#options.store.updateScheduledJob(updated)) throw new Error(`Scheduled job ${id} no longer exists`);
|
|
163
|
+
this.#wake();
|
|
164
|
+
return updated;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
remove(id: string, principalId: string): boolean {
|
|
168
|
+
const removed = this.#options.store.deleteScheduledJob(
|
|
169
|
+
boundedText(id, "job id", 128),
|
|
170
|
+
boundedText(principalId, "principal id", 256),
|
|
171
|
+
);
|
|
172
|
+
this.#wake();
|
|
173
|
+
return removed;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
setEnabled(id: string, principalId: string, enabled: boolean): ScheduledJob {
|
|
177
|
+
const current = this.#owned(id, principalId);
|
|
178
|
+
const now = this.#now();
|
|
179
|
+
const nextRunAt = enabled ? nextOccurrence(current.schedule, now - 1) : current.nextRunAt;
|
|
180
|
+
if (enabled && nextRunAt === undefined) throw new Error("One-shot job has already expired; update its schedule before enabling it");
|
|
181
|
+
const updated: ScheduledJob = {
|
|
182
|
+
...current,
|
|
183
|
+
enabled,
|
|
184
|
+
...(nextRunAt === undefined ? {} : { nextRunAt }),
|
|
185
|
+
retryAt: undefined,
|
|
186
|
+
attemptCount: 0,
|
|
187
|
+
updatedAt: now,
|
|
188
|
+
};
|
|
189
|
+
if (!this.#options.store.updateScheduledJob(updated)) throw new Error(`Scheduled job ${current.id} no longer exists`);
|
|
190
|
+
this.#wake();
|
|
191
|
+
return updated;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
runNow(id: string, principalId: string): ScheduledJob {
|
|
195
|
+
const current = this.#owned(id, principalId);
|
|
196
|
+
const now = this.#now();
|
|
197
|
+
const updated: ScheduledJob = {
|
|
198
|
+
...current,
|
|
199
|
+
enabled: true,
|
|
200
|
+
nextRunAt: now,
|
|
201
|
+
retryAt: undefined,
|
|
202
|
+
attemptCount: 0,
|
|
203
|
+
updatedAt: now,
|
|
204
|
+
};
|
|
205
|
+
if (!this.#options.store.updateScheduledJob(updated)) throw new Error(`Scheduled job ${current.id} no longer exists`);
|
|
206
|
+
this.#wake();
|
|
207
|
+
return updated;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
get(id: string, principalId: string): ScheduledJob | undefined {
|
|
211
|
+
return this.#options.store.getScheduledJob(
|
|
212
|
+
boundedText(id, "job id", 128),
|
|
213
|
+
boundedText(principalId, "principal id", 256),
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
list(principalId: string): readonly ScheduledJob[] {
|
|
218
|
+
return this.#options.store.listScheduledJobs(boundedText(principalId, "principal id", 256));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Execute all currently due jobs. Public for deterministic service smoke tests. */
|
|
222
|
+
async runDue(now = this.#now()): Promise<number> {
|
|
223
|
+
if (this.#running) return 0;
|
|
224
|
+
this.#running = true;
|
|
225
|
+
let executed = 0;
|
|
226
|
+
try {
|
|
227
|
+
for (const due of this.#options.store.listDueScheduledJobs(now)) {
|
|
228
|
+
const job = this.#options.store.getScheduledJob(due.id);
|
|
229
|
+
if (job === undefined || !job.enabled || job.nextRunAt === undefined) continue;
|
|
230
|
+
const dueAt = job.retryAt ?? job.nextRunAt;
|
|
231
|
+
if (dueAt > now) continue;
|
|
232
|
+
try {
|
|
233
|
+
await this.#options.dispatch(job, job.nextRunAt);
|
|
234
|
+
this.#recordSuccess(job, now);
|
|
235
|
+
executed += 1;
|
|
236
|
+
} catch (error) {
|
|
237
|
+
if (error instanceof ScheduledDispatchBusyError) {
|
|
238
|
+
this.#recordBusy(job, now);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
await this.#recordFailure(job, error, now);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
} finally {
|
|
245
|
+
this.#running = false;
|
|
246
|
+
}
|
|
247
|
+
return executed;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
#recordSuccess(job: ScheduledJob, now: number): void {
|
|
251
|
+
const nextRunAt = job.schedule.kind === "cron" ? nextOccurrence(job.schedule, now) : undefined;
|
|
252
|
+
const updated: ScheduledJob = {
|
|
253
|
+
...job,
|
|
254
|
+
enabled: nextRunAt !== undefined,
|
|
255
|
+
...(nextRunAt === undefined ? { nextRunAt: undefined } : { nextRunAt }),
|
|
256
|
+
retryAt: undefined,
|
|
257
|
+
attemptCount: 0,
|
|
258
|
+
successCount: job.successCount + 1,
|
|
259
|
+
updatedAt: now,
|
|
260
|
+
lastRunAt: now,
|
|
261
|
+
lastSuccessAt: now,
|
|
262
|
+
lastError: undefined,
|
|
263
|
+
};
|
|
264
|
+
this.#options.store.updateScheduledJob(updated);
|
|
265
|
+
this.#log.info(`Scheduled job ${job.id} completed`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
#recordBusy(job: ScheduledJob, now: number): void {
|
|
269
|
+
this.#options.store.updateScheduledJob({
|
|
270
|
+
...job,
|
|
271
|
+
retryAt: now + Math.min(this.#options.pollIntervalMs, 5_000),
|
|
272
|
+
updatedAt: now,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async #recordFailure(job: ScheduledJob, thrown: unknown, now: number): Promise<void> {
|
|
277
|
+
const error = thrown instanceof Error ? thrown : new Error(String(thrown));
|
|
278
|
+
const attemptCount = job.attemptCount + 1;
|
|
279
|
+
const message = error.message.slice(0, MAX_ERROR_LENGTH);
|
|
280
|
+
if (attemptCount < this.#options.maxAttempts) {
|
|
281
|
+
this.#options.store.updateScheduledJob({
|
|
282
|
+
...job,
|
|
283
|
+
retryAt: now + this.#options.retryDelayMs * attemptCount,
|
|
284
|
+
attemptCount,
|
|
285
|
+
updatedAt: now,
|
|
286
|
+
lastRunAt: now,
|
|
287
|
+
lastError: message,
|
|
288
|
+
});
|
|
289
|
+
this.#log.warn(`Scheduled job ${job.id} attempt ${attemptCount} failed: ${message}`);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const nextRunAt = job.schedule.kind === "cron" ? nextOccurrence(job.schedule, now) : undefined;
|
|
294
|
+
const failed: ScheduledJob = {
|
|
295
|
+
...job,
|
|
296
|
+
enabled: nextRunAt !== undefined,
|
|
297
|
+
...(nextRunAt === undefined ? { nextRunAt: undefined } : { nextRunAt }),
|
|
298
|
+
retryAt: undefined,
|
|
299
|
+
attemptCount: 0,
|
|
300
|
+
failureCount: job.failureCount + 1,
|
|
301
|
+
updatedAt: now,
|
|
302
|
+
lastRunAt: now,
|
|
303
|
+
lastError: message,
|
|
304
|
+
};
|
|
305
|
+
this.#options.store.updateScheduledJob(failed);
|
|
306
|
+
this.#log.error(`Scheduled job ${job.id} exhausted ${this.#options.maxAttempts} attempts: ${message}`);
|
|
307
|
+
try {
|
|
308
|
+
await this.#options.onPermanentFailure?.(failed, error);
|
|
309
|
+
} catch (notifyError) {
|
|
310
|
+
this.#log.warn(`Could not deliver scheduled job failure notice: ${String(notifyError)}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
#owned(id: string, principalId: string): ScheduledJob {
|
|
315
|
+
const jobId = boundedText(id, "job id", 128);
|
|
316
|
+
const owner = boundedText(principalId, "principal id", 256);
|
|
317
|
+
const job = this.#options.store.getScheduledJob(jobId, owner);
|
|
318
|
+
if (job === undefined) throw new Error(`Scheduled job ${jobId} was not found`);
|
|
319
|
+
return job;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
#now(): number {
|
|
323
|
+
const now = (this.#options.now ?? Date.now)();
|
|
324
|
+
if (!Number.isSafeInteger(now)) throw new Error("Scheduler clock must return an integer timestamp");
|
|
325
|
+
return now;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
#wake(): void {
|
|
329
|
+
if (!this.#started) return;
|
|
330
|
+
if (this.#timer !== undefined) (this.#options.clearTimer ?? clearTimeout)(this.#timer);
|
|
331
|
+
this.#schedule(0);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
#schedule(delayMs: number): void {
|
|
335
|
+
if (!this.#started) return;
|
|
336
|
+
this.#timer = (this.#options.setTimer ?? setTimeout)(() => {
|
|
337
|
+
this.#timer = undefined;
|
|
338
|
+
void this.runDue().catch((error) => this.#log.error(`Scheduled job poll failed: ${String(error)}`)).finally(() => {
|
|
339
|
+
this.#schedule(this.#options.pollIntervalMs);
|
|
340
|
+
});
|
|
341
|
+
}, delayMs);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function formatScheduledJob(job: ScheduledJob): string {
|
|
346
|
+
const schedule = job.schedule.kind === "at"
|
|
347
|
+
? `once at ${new Date(job.schedule.at).toISOString()}`
|
|
348
|
+
: `cron ${job.schedule.expression}${job.schedule.timezone === undefined ? "" : ` (${job.schedule.timezone})`}`;
|
|
349
|
+
const next = job.nextRunAt === undefined ? "none" : new Date(job.retryAt ?? job.nextRunAt).toISOString();
|
|
350
|
+
const result = job.lastError === undefined ? "never failed" : `last error: ${job.lastError}`;
|
|
351
|
+
return `${job.enabled ? "enabled" : "disabled"} | ${job.name} | id ${job.id} | ${schedule} | next ${next} | ${job.successCount} succeeded, ${job.failureCount} failed | ${result}`;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function parseSchedule(input: Pick<CreateScheduledJobInput, "at" | "cron" | "timezone">, now: number): ScheduledJobSchedule {
|
|
355
|
+
if ((input.at === undefined) === (input.cron === undefined)) {
|
|
356
|
+
throw new Error("Specify exactly one of at or cron");
|
|
357
|
+
}
|
|
358
|
+
if (input.at !== undefined) {
|
|
359
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2})$/.test(input.at)) {
|
|
360
|
+
throw new Error("job time must be an ISO 8601 date-time with an explicit UTC offset");
|
|
361
|
+
}
|
|
362
|
+
if (input.timezone !== undefined) throw new Error("timezone is only valid with cron");
|
|
363
|
+
const parsed = Date.parse(boundedText(input.at, "job time", 256));
|
|
364
|
+
if (!Number.isSafeInteger(parsed)) throw new Error("job time must be an ISO 8601 date-time");
|
|
365
|
+
return { kind: "at", at: parsed };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const expression = boundedText(input.cron, "cron expression", 256);
|
|
369
|
+
const timezone = input.timezone === undefined ? undefined : boundedText(input.timezone, "cron timezone", 128);
|
|
370
|
+
if (timezone !== undefined) validateTimezone(timezone);
|
|
371
|
+
const schedule: ScheduledJobSchedule = { kind: "cron", expression, ...(timezone === undefined ? {} : { timezone }) };
|
|
372
|
+
try {
|
|
373
|
+
if (nextOccurrence(schedule, now - 1) === undefined) throw new Error("no future occurrence");
|
|
374
|
+
} catch (error) {
|
|
375
|
+
throw new Error(`Invalid cron schedule: ${error instanceof Error ? error.message : String(error)}`);
|
|
376
|
+
}
|
|
377
|
+
return schedule;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function nextOccurrence(schedule: ScheduledJobSchedule, after: number): number | undefined {
|
|
381
|
+
if (schedule.kind === "at") return schedule.at >= after ? schedule.at : undefined;
|
|
382
|
+
const cron = new Cron(schedule.expression, {
|
|
383
|
+
paused: true,
|
|
384
|
+
...(schedule.timezone === undefined ? {} : { timezone: schedule.timezone }),
|
|
385
|
+
});
|
|
386
|
+
const next = cron.nextRun(new Date(after));
|
|
387
|
+
return next?.getTime();
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function validateContext(context: ScheduledJobContext): void {
|
|
391
|
+
boundedText(context.principal.id, "principal id", 256);
|
|
392
|
+
for (const role of context.principal.roles) boundedText(role, "principal role", 128);
|
|
393
|
+
boundedText(context.identity.transport, "identity transport", 128);
|
|
394
|
+
boundedText(context.identity.account, "identity account", 128);
|
|
395
|
+
boundedText(context.identity.subject, "identity subject", 256);
|
|
396
|
+
boundedText(context.address.transport, "address transport", 128);
|
|
397
|
+
boundedText(context.address.account, "address account", 128);
|
|
398
|
+
boundedText(context.address.channel, "address channel", 256);
|
|
399
|
+
if (context.address.thread !== undefined) boundedText(context.address.thread, "address thread", 256);
|
|
400
|
+
if (context.identity.transport !== context.address.transport || context.identity.account !== context.address.account) {
|
|
401
|
+
throw new Error("Scheduled job identity and address must use the same transport account");
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function validateTimezone(timezone: string): void {
|
|
406
|
+
try {
|
|
407
|
+
new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format(new Date(0));
|
|
408
|
+
} catch {
|
|
409
|
+
throw new Error(`Invalid IANA timezone ${timezone}`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function boundedText(value: unknown, label: string, maximum: number): string {
|
|
414
|
+
if (typeof value !== "string" || value.length === 0 || value.length > maximum || value.includes("\0")) {
|
|
415
|
+
throw new Error(`${label} must be a non-empty string of at most ${maximum} characters`);
|
|
416
|
+
}
|
|
417
|
+
return value;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function boundedInteger(value: number, label: string, minimum: number, maximum: number): number {
|
|
421
|
+
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
|
422
|
+
throw new Error(`${label} must be an integer between ${minimum} and ${maximum}`);
|
|
423
|
+
}
|
|
424
|
+
return value;
|
|
425
|
+
}
|