Skip to content
Source
Sign inSign up
Open History to see the last commit

scheduledTask.ts

420 lines15.7 KB

1import * as Schema from "effect/Schema";
2​
3import {
4 CommandId,
5 ForwardCompatibleArray,
6 IsoDateTime,
7 ProjectId,
8 ScheduledTaskId,
9 SecretRef,
10 ThreadId,
11 TrimmedNonEmptyString,
12} from "./baseSchemas.ts";
13import { ModelSelection } from "./modelSelection.ts";
14import {
15 OrchestrationV2Actor,
16 OrchestrationV2CreationSource,
17 OrchestrationV2ThreadLaunchWorkspaceStrategy,
18} from "./orchestrationV2.ts";
19import { ProviderInteractionMode, RuntimeMode } from "./providerPolicy.ts";
20​
21const TimeOfDay = TrimmedNonEmptyString.check(
22 Schema.isPattern(/^([01]?\d|2[0-3]):([0-5]\d)$/),
23).annotate({ description: "Local time as HH:MM, such as 09:30." });
24​
25export const MIN_SCHEDULED_TASK_INTERVAL_MS = 60_000;
26​
27const ScheduledTaskIntervalMs = Schema.Int.check(Schema.isGreaterThan(0));
28​
29const ScheduledTaskIntervalSchedule = Schema.Struct({
30 type: Schema.Literal("interval"),
31 everyMs: ScheduledTaskIntervalMs,
32});
33​
34const ScheduledTaskFixedTimeSchedule = Schema.Struct({
35 type: Schema.Literal("fixed_time"),
36 timeOfDay: TimeOfDay,
37 weekdays: Schema.optional(
38 Schema.Array(
39 Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 6 })).annotate({
40 description: "Weekday. 0 is Sunday, 6 is Saturday.",
41 }),
42 ).annotate({
43 description: "Default: every day.",
44 }),
45 ),
46});
47​
48const ISO_WITH_OFFSET =
49 /^(\d{4})-(\d{2})-(\d{2})T([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/;
50​
51const YEAR_0000 = Date.parse("0000-01-01T00:00:00Z");
52const YEAR_10000 = Date.parse("+010000-01-01T00:00:00Z");
53​
54/** Date.parse rolls impossible days like Feb 30 into the next month, so check the day too. */
55function isIsoWithOffset(value: string): boolean {
56 const match = ISO_WITH_OFFSET.exec(value);
57 // The server stores UTC, which must stay a four-digit year to decode again.
58 const time = Date.parse(value);
59 if (!match || !(time >= YEAR_0000 && time < YEAR_10000)) return false;
60 const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
61 const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
62 const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1];
63 return days !== undefined && day >= 1 && day <= days;
65​
66// Checked by filter, not pattern, to keep the MCP tool schema small.
67const ScheduledTaskOnceSchedule = Schema.Struct({
68 type: Schema.Literal("once"),
69 runAt: TrimmedNonEmptyString.check(
70 Schema.makeFilter(
71 (value) => isIsoWithOffset(value) || "Expected an ISO date and time with an offset.",
72 ),
73 ),
74});
75​
76const ScheduledTaskWebhookSignatureFields = {
77 header: TrimmedNonEmptyString.annotate({
78 description: "Header with the signature, such as x-hub-signature-256.",
79 }),
80 encoding: Schema.Literals(["hex", "base64"]),
81 prefix: Schema.String.annotate({
82 description: "Text before the digest, such as 'sha256='. Empty for none.",
83 }),
84};
85​
86export const ScheduledTaskWebhookSignature = Schema.Struct(
87 ScheduledTaskWebhookSignatureFields,
88).annotate({ description: "HMAC-SHA256 check on the raw body." });
89export type ScheduledTaskWebhookSignature = typeof ScheduledTaskWebhookSignature.Type;
90​
91/** Matches how long the relay holds a request for an offline environment. */
92export const MAX_WEBHOOK_DELIVERY_AGE_MINUTES = 24 * 60;
93​
94const WebhookMaxDeliveryAgeMinutes = Schema.Int.check(
95 Schema.isBetween({ minimum: 1, maximum: MAX_WEBHOOK_DELIVERY_AGE_MINUTES }),
96).annotate({
97 description: "Skip requests held offline over this many minutes. Omit to run all.",
98});
99​
100export function isWebhookDedupeTemplate(template: string): boolean {
101 const placeholders = /\{\{\s*(?:(?:body|headers|query)(?:\.[^{}.\s]+)*|request)\s*\}\}/g;
102 const matches = [...template.matchAll(placeholders)];
103 const remainder = template.replace(placeholders, "");
104 return matches.length > 0 && !remainder.includes("{{") && !remainder.includes("}}");
106​
107const WebhookDedupeTemplate = Schema.String.check(Schema.makeFilter(isWebhookDedupeTemplate));
108​
109const ScheduledTaskWebhookDedupe = Schema.Struct({
110 replayKey: Schema.optional(WebhookDedupeTemplate),
111 coalesceKey: Schema.optional(WebhookDedupeTemplate),
112 coalesceWindowSeconds: Schema.optional(
113 Schema.Int.check(Schema.isBetween({ minimum: 10, maximum: 3600 })),
114 ),
115}).annotate({
116 description: "Replay and coalesce key templates. Window in seconds, default 120.",
117});
118​
119export const MAX_WEBHOOK_FILTERS = 16;
120​
121const WebhookFilterPath = TrimmedNonEmptyString.check(
122 Schema.isPattern(/^(body|headers|query)(\.|$)/),
123);
124​
125/**
126 * Forms edit an `in` list as comma-separated text, so a value that has no
127 * comma and no surrounding whitespace survives that round trip unchanged.
128 */
129const WebhookFilterListValue = Schema.String.check(
130 Schema.isPattern(/^[^,\s](?:[^,]*[^,\s])?$/),
131).annotate({ description: "Non-empty, without commas or surrounding whitespace." });
132​
133export const ScheduledTaskWebhookFilter = Schema.Union([
134 Schema.Struct({
135 path: WebhookFilterPath,
136 op: Schema.Literals(["equals", "notEquals", "startsWith"]),
137 value: Schema.String,
138 }),
139 Schema.Struct({
140 path: WebhookFilterPath,
141 op: Schema.Literal("in"),
142 value: Schema.Array(WebhookFilterListValue).check(Schema.isMinLength(1)),
143 }),
144 Schema.Struct({ path: WebhookFilterPath, op: Schema.Literal("exists") }),
145]).annotate({
146 description:
147 "Path: `headers.x-event` or `body.action`. String compare. Objects pass only 'exists'. A `[]` segment matches any array item. `notEquals` passes only if no item equals.",
148});
149export type ScheduledTaskWebhookFilter = typeof ScheduledTaskWebhookFilter.Type;
150export type ScheduledTaskWebhookFilterOp = ScheduledTaskWebhookFilter["op"];
151​
152const ScheduledTaskWebhookFilters = Schema.Array(ScheduledTaskWebhookFilter)
153 .check(Schema.isMaxLength(MAX_WEBHOOK_FILTERS))
154 .annotate({
155 description: "All must match. Max 16. Omitted: keep stored. [] clears.",
156 });
157​
158const ScheduledTaskWebhookSchedule = Schema.Struct({
159 type: Schema.Literal("webhook"),
160 signature: Schema.NullOr(ScheduledTaskWebhookSignature),
161 maxDeliveryAgeMinutes: Schema.optional(Schema.NullOr(WebhookMaxDeliveryAgeMinutes)),
162 dedupe: Schema.optional(ScheduledTaskWebhookDedupe),
163 filters: Schema.optional(ScheduledTaskWebhookFilters),
164}).annotate({
165 description: "Run on each webhook request.",
166});
167​
168const ScheduledTaskUpsertWebhookSchedule = Schema.Struct({
169 type: Schema.Literal("webhook"),
170 signature: Schema.optional(
171 Schema.NullOr(
172 Schema.Struct({
173 ...ScheduledTaskWebhookSignatureFields,
174 secret: Schema.optional(TrimmedNonEmptyString).annotate({
175 description: "Default: stored secret.",
176 }),
177 secretRef: Schema.optional(SecretRef).annotate({
178 description: "From `request_secret`. Instead of `secret`. Spent by this save.",
179 }),
180 }),
181 ),
182 ).annotate({
183 description: "Omit for URL token only.",
184 }),
185 maxDeliveryAgeMinutes: Schema.optional(Schema.NullOr(WebhookMaxDeliveryAgeMinutes)),
186 dedupe: Schema.optional(ScheduledTaskWebhookDedupe),
187 filters: Schema.optional(ScheduledTaskWebhookFilters),
188}).annotate({
189 description:
190 "Run per webhook request. Prompt can use {{body.path}}, {{headers.name}}, {{query.name}}, {{body}}, {{request}}.",
191});
192​
193/** Accept legacy sub-minute schedules so users can still manage them. */
194export const ScheduledTaskSchedule = Schema.Union([
195 ScheduledTaskIntervalSchedule,
196 ScheduledTaskFixedTimeSchedule,
197 ScheduledTaskOnceSchedule,
198 ScheduledTaskWebhookSchedule,
199]);
200export type ScheduledTaskSchedule = typeof ScheduledTaskSchedule.Type;
201​
202export const ScheduledTaskUpsertSchedule = Schema.Union([
203 Schema.Struct({
204 type: Schema.Literal("interval"),
205 everyMs: ScheduledTaskIntervalMs.check(
206 Schema.isGreaterThanOrEqualTo(MIN_SCHEDULED_TASK_INTERVAL_MS),
207 ).annotate({
208 description: "Min 60000.",
209 }),
210 }),
211 ScheduledTaskFixedTimeSchedule,
212 ScheduledTaskOnceSchedule,
213 ScheduledTaskUpsertWebhookSchedule,
214]);
215export type ScheduledTaskUpsertSchedule = typeof ScheduledTaskUpsertSchedule.Type;
216​
217export const ScheduledTaskRunStatus = Schema.Literals(["never", "running", "succeeded", "failed"]);
218export type ScheduledTaskRunStatus = typeof ScheduledTaskRunStatus.Type;
219​
220export const ScheduledTaskWebhookEndpoint = Schema.Struct({
221 path: TrimmedNonEmptyString,
222 url: Schema.NullOr(TrimmedNonEmptyString),
223 hasSecret: Schema.Boolean,
224});
225export type ScheduledTaskWebhookEndpoint = typeof ScheduledTaskWebhookEndpoint.Type;
226​
227/**
228 * Where runs go. One-thread modes launch their thread on the first run and keep it; `fresh_session`
229 * starts each later run in a new provider session there.
230 */
231export const ScheduledTaskThreadMode = Schema.Literals(["new_thread", "continue", "fresh_session"]);
232export type ScheduledTaskThreadMode = typeof ScheduledTaskThreadMode.Type;
233​
234/** Servers before thread modes omit it; their bound tasks continue the conversation. */
235export function scheduledTaskThreadMode(
236 task: Pick<ScheduledTask, "threadId" | "threadMode">,
237): ScheduledTaskThreadMode {
238 return task.threadMode ?? (task.threadId === null ? "new_thread" : "continue");
240​
241export const ScheduledTask = Schema.Struct({
242 id: ScheduledTaskId,
243 title: TrimmedNonEmptyString,
244 prompt: TrimmedNonEmptyString,
245 enabled: Schema.Boolean,
246 schedule: ScheduledTaskSchedule,
247 projectId: ProjectId,
248 threadId: Schema.NullOr(ThreadId),
249 threadMode: Schema.optional(ScheduledTaskThreadMode),
250 workspaceStrategy: OrchestrationV2ThreadLaunchWorkspaceStrategy,
251 modelSelection: ModelSelection,
252 runtimeMode: RuntimeMode,
253 interactionMode: ProviderInteractionMode,
254 createdBy: OrchestrationV2Actor,
255 creationSource: OrchestrationV2CreationSource,
256 createdAt: IsoDateTime,
257 updatedAt: IsoDateTime,
258 nextRunAt: Schema.NullOr(IsoDateTime),
259 lastRunAt: Schema.NullOr(IsoDateTime),
260 lastRunStatus: ScheduledTaskRunStatus,
261 lastRunError: Schema.NullOr(Schema.String),
262 runCount: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
263 webhook: Schema.optional(ScheduledTaskWebhookEndpoint),
264 /** Why the task cannot run regardless of `enabled`, such as a managed policy refusing webhooks. */
265 blockedByPolicy: Schema.optionalKey(Schema.String),
266});
267export type ScheduledTask = typeof ScheduledTask.Type;
268​
269export const ScheduledTaskListInput = Schema.Struct({});
270export type ScheduledTaskListInput = typeof ScheduledTaskListInput.Type;
271​
272export const ScheduledTaskListResult = Schema.Struct({
273 tasks: ForwardCompatibleArray(ScheduledTask),
274 /** The server's IANA zone for fixed-time schedules. Absent from older servers. */
275 timeZone: Schema.optional(TrimmedNonEmptyString),
276});
277export type ScheduledTaskListResult = typeof ScheduledTaskListResult.Type;
278​
279export const ScheduledTaskUpsertInput = Schema.Struct({
280 id: Schema.optional(ScheduledTaskId),
281 requireExisting: Schema.optional(Schema.Boolean).annotate({
282 description: "Reject the save if the task no longer exists, for edits from a client form.",
283 }),
284 commandId: Schema.optional(CommandId),
285 title: TrimmedNonEmptyString,
286 prompt: TrimmedNonEmptyString,
287 enabled: Schema.Boolean,
288 schedule: ScheduledTaskUpsertSchedule,
289 projectId: ProjectId,
290 threadId: Schema.optional(Schema.NullOr(ThreadId)),
291 /** Omitted: keep the stored mode, or derive it from `threadId` for a new or rebound task. */
292 threadMode: Schema.optional(ScheduledTaskThreadMode),
293 workspaceStrategy: OrchestrationV2ThreadLaunchWorkspaceStrategy,
294 modelSelection: ModelSelection,
295 runtimeMode: RuntimeMode,
296 interactionMode: ProviderInteractionMode,
297 createdBy: Schema.optional(OrchestrationV2Actor),
298 creationSource: Schema.optional(OrchestrationV2CreationSource),
299});
300export type ScheduledTaskUpsertInput = typeof ScheduledTaskUpsertInput.Type;
301​
302export const ScheduledTaskSetEnabledInput = Schema.Struct({
303 id: ScheduledTaskId,
304 enabled: Schema.Boolean,
305});
306export type ScheduledTaskSetEnabledInput = typeof ScheduledTaskSetEnabledInput.Type;
307​
308export const ScheduledTaskDeleteInput = Schema.Struct({
309 id: ScheduledTaskId,
310});
311export type ScheduledTaskDeleteInput = typeof ScheduledTaskDeleteInput.Type;
312​
313export const ScheduledTaskRunNowInput = Schema.Struct({
314 id: ScheduledTaskId,
315});
316export type ScheduledTaskRunNowInput = typeof ScheduledTaskRunNowInput.Type;
317​
318export const ScheduledTaskRotateWebhookTokenInput = Schema.Struct({
319 id: ScheduledTaskId,
320});
321export type ScheduledTaskRotateWebhookTokenInput = typeof ScheduledTaskRotateWebhookTokenInput.Type;
322​
323export const ScheduledTaskWebhookDeliveryId = TrimmedNonEmptyString.pipe(
324 Schema.brand("ScheduledTaskWebhookDeliveryId"),
325);
326export type ScheduledTaskWebhookDeliveryId = typeof ScheduledTaskWebhookDeliveryId.Type;
327​
328export const ScheduledTaskWebhookDeliveryOutcome = Schema.Literals([
329 "accepted",
330 "dispatch_failed",
331 "duplicate",
332 "coalesced",
333 "rejected_signature",
334 "disabled",
335 "rate_limited",
336 "expired",
337 "filtered",
338]);
339export type ScheduledTaskWebhookDeliveryOutcome = typeof ScheduledTaskWebhookDeliveryOutcome.Type;
340​
341export const ScheduledTaskWebhookDeliverySummary = Schema.Struct({
342 id: ScheduledTaskWebhookDeliveryId,
343 taskId: ScheduledTaskId,
344 receivedAt: IsoDateTime,
345 method: TrimmedNonEmptyString,
346 contentType: Schema.NullOr(Schema.String),
347 bodyBytes: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
348 // Any string, so an outcome added later doesn't break a client's whole list.
349 outcome: Schema.Union([ScheduledTaskWebhookDeliveryOutcome, Schema.String]),
350 signatureVerified: Schema.Boolean,
351 pendingWindow: Schema.optional(Schema.Boolean),
352 missingFields: Schema.Array(Schema.String),
353 error: Schema.NullOr(Schema.String),
354});
355export type ScheduledTaskWebhookDeliverySummary = typeof ScheduledTaskWebhookDeliverySummary.Type;
356​
357export const ScheduledTaskWebhookDelivery = Schema.Struct({
358 ...ScheduledTaskWebhookDeliverySummary.fields,
359 query: Schema.String,
360 headers: Schema.Record(Schema.String, Schema.String),
361 body: Schema.String,
362 bodyTruncated: Schema.Boolean,
363 renderedPrompt: Schema.NullOr(Schema.String),
364});
365export type ScheduledTaskWebhookDelivery = typeof ScheduledTaskWebhookDelivery.Type;
366​
367export const ScheduledTaskListWebhookDeliveriesInput = Schema.Struct({
368 id: ScheduledTaskId,
369 /**
370 * Sent by clients that decode any outcome. Shipped clients decode a closed
371 * list and fail on the whole response, so without it the server leaves out
372 * rows with outcomes they don't know.
373 */
374 acceptAnyOutcome: Schema.optional(Schema.Boolean),
375});
376export type ScheduledTaskListWebhookDeliveriesInput =
377 typeof ScheduledTaskListWebhookDeliveriesInput.Type;
378​
379export const ScheduledTaskListWebhookDeliveriesResult = Schema.Struct({
380 deliveries: Schema.Array(ScheduledTaskWebhookDeliverySummary),
381});
382export type ScheduledTaskListWebhookDeliveriesResult =
383 typeof ScheduledTaskListWebhookDeliveriesResult.Type;
384​
385export const ScheduledTaskGetWebhookDeliveryInput = Schema.Struct({
386 id: ScheduledTaskId,
387 deliveryId: ScheduledTaskWebhookDeliveryId,
388 acceptAnyOutcome: Schema.optional(Schema.Boolean),
389});
390export type ScheduledTaskGetWebhookDeliveryInput = typeof ScheduledTaskGetWebhookDeliveryInput.Type;
391​
392export const ScheduledTaskGetWebhookDeliveryResult = Schema.Struct({
393 delivery: ScheduledTaskWebhookDelivery,
394});
395export type ScheduledTaskGetWebhookDeliveryResult =
396 typeof ScheduledTaskGetWebhookDeliveryResult.Type;
397​
398export const ScheduledTaskMutationResult = Schema.Struct({
399 task: ScheduledTask,
400});
401export type ScheduledTaskMutationResult = typeof ScheduledTaskMutationResult.Type;
402​
403export const ScheduledTaskDeleteResult = Schema.Struct({
404 id: ScheduledTaskId,
405});
406export type ScheduledTaskDeleteResult = typeof ScheduledTaskDeleteResult.Type;
407​
408export const ScheduledTaskRunNowResult = Schema.Struct({
409 task: ScheduledTask,
410});
411export type ScheduledTaskRunNowResult = typeof ScheduledTaskRunNowResult.Type;
412​
413export class ScheduledTaskError extends Schema.TaggedError<ScheduledTaskError>()(
414 "ScheduledTaskError",
415 {
416 message: Schema.String,
417 taskId: Schema.optional(ScheduledTaskId),
418 cause: Schema.optional(Schema.Defect()),
419 },
420) {}