@voyantjs/workflows-orchestrator-cloudflare 0.86.0 → 0.88.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/README.md CHANGED
@@ -65,6 +65,10 @@ export class WorkflowRunDO implements DurableObject {
65
65
  |---|---|
66
66
  | `POST /api/runs` | Trigger a new run. Body: `{ workflowId, workflowVersion, input, tenantMeta, runId? }`. |
67
67
  | `GET /api/runs/:id` | Fetch the current `RunRecord`. |
68
+ | `POST /api/manifests` | Register a workflow manifest for an environment when `manifestStore` is configured. |
69
+ | `GET /api/manifests/:env` | Fetch the current workflow manifest for an environment. |
70
+ | `GET /api/schedules/:env` | List manifest schedules with computed `nextRunAt`; when `scheduleStateStore` is configured, rows also include `lastFireAt`, `lastRunId`, `lastError`, `lockedUntil`, and `lastSuccessfulRunAt`. |
71
+ | `POST /api/events` | Route an event through the registered manifest and trigger matching workflows. |
68
72
  | `POST /api/runs/:id/resume` | Start a new run from a failed parent run. Body: `{ input?, workflowId?, resumeFromStep?, seedResults?, runId?, tags?, triggeredByUserId? }`. |
69
73
  | `POST /api/runs/:id/events` | Inject an `EVENT` waitpoint resolution. |
70
74
  | `POST /api/runs/:id/signals` | Inject a `SIGNAL` waitpoint resolution. |
package/dist/index.d.ts CHANGED
@@ -7,6 +7,7 @@ export { type DurableObjectDeps, handleDurableObjectAlarm, handleDurableObjectRe
7
7
  export { type CfManifestEnvelope, type CfManifestStore, type CreateKvManifestStoreOptions, createInMemoryKv, createKvManifestStore, type KvNamespaceLike, } from "./manifest-kv-store.js";
8
8
  export { createR2Presigner, type PresignArgs, type R2PresignerOptions, } from "./r2-sign.js";
9
9
  export { handleGetSchedules, type ScheduleHandlerDeps, type ScheduleListResponse, type ScheduleSummary, } from "./schedule-handler.js";
10
+ export { type CfScheduleStateStore, type CreateKvScheduleStateStoreOptions, createKvScheduleStateStore, type ScheduleStateRecord, } from "./schedule-state-store.js";
10
11
  export * from "./types.js";
11
12
  export { type DurableObjectNamespaceLike, handleWorkerRequest, type WorkerFetchDeps, } from "./worker.js";
12
13
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAkCA,OAAO,EACL,KAAK,cAAc,EACnB,KAAK,qBAAqB,EAC1B,KAAK,sBAAsB,EAC3B,2BAA2B,GAC5B,MAAM,0BAA0B,CAAA;AACjC,OAAO,EACL,KAAK,2BAA2B,EAChC,0BAA0B,GAC3B,MAAM,6BAA6B,CAAA;AACpC,OAAO,EACL,KAAK,sBAAsB,EAC3B,KAAK,0BAA0B,EAC/B,4BAA4B,EAC5B,mCAAmC,GACpC,MAAM,8BAA8B,CAAA;AACrC,OAAO,EACL,oBAAoB,EACpB,sBAAsB,EACtB,8BAA8B,EAC9B,KAAK,qBAAqB,EAC1B,KAAK,+BAA+B,EACpC,KAAK,kBAAkB,EACvB,KAAK,cAAc,EACnB,KAAK,qBAAqB,GAC3B,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAAE,2BAA2B,EAAE,MAAM,eAAe,CAAA;AAC3D,OAAO,EACL,KAAK,iBAAiB,EACtB,wBAAwB,EACxB,0BAA0B,GAC3B,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EACL,KAAK,kBAAkB,EACvB,KAAK,eAAe,EACpB,KAAK,4BAA4B,EACjC,gBAAgB,EAChB,qBAAqB,EACrB,KAAK,eAAe,GACrB,MAAM,wBAAwB,CAAA;AAC/B,OAAO,EACL,iBAAiB,EACjB,KAAK,WAAW,EAChB,KAAK,kBAAkB,GACxB,MAAM,cAAc,CAAA;AACrB,OAAO,EACL,kBAAkB,EAClB,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EACzB,KAAK,eAAe,GACrB,MAAM,uBAAuB,CAAA;AAC9B,cAAc,YAAY,CAAA;AAC1B,OAAO,EACL,KAAK,0BAA0B,EAC/B,mBAAmB,EACnB,KAAK,eAAe,GACrB,MAAM,aAAa,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAkCA,OAAO,EACL,KAAK,cAAc,EACnB,KAAK,qBAAqB,EAC1B,KAAK,sBAAsB,EAC3B,2BAA2B,GAC5B,MAAM,0BAA0B,CAAA;AACjC,OAAO,EACL,KAAK,2BAA2B,EAChC,0BAA0B,GAC3B,MAAM,6BAA6B,CAAA;AACpC,OAAO,EACL,KAAK,sBAAsB,EAC3B,KAAK,0BAA0B,EAC/B,4BAA4B,EAC5B,mCAAmC,GACpC,MAAM,8BAA8B,CAAA;AACrC,OAAO,EACL,oBAAoB,EACpB,sBAAsB,EACtB,8BAA8B,EAC9B,KAAK,qBAAqB,EAC1B,KAAK,+BAA+B,EACpC,KAAK,kBAAkB,EACvB,KAAK,cAAc,EACnB,KAAK,qBAAqB,GAC3B,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAAE,2BAA2B,EAAE,MAAM,eAAe,CAAA;AAC3D,OAAO,EACL,KAAK,iBAAiB,EACtB,wBAAwB,EACxB,0BAA0B,GAC3B,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EACL,KAAK,kBAAkB,EACvB,KAAK,eAAe,EACpB,KAAK,4BAA4B,EACjC,gBAAgB,EAChB,qBAAqB,EACrB,KAAK,eAAe,GACrB,MAAM,wBAAwB,CAAA;AAC/B,OAAO,EACL,iBAAiB,EACjB,KAAK,WAAW,EAChB,KAAK,kBAAkB,GACxB,MAAM,cAAc,CAAA;AACrB,OAAO,EACL,kBAAkB,EAClB,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EACzB,KAAK,eAAe,GACrB,MAAM,uBAAuB,CAAA;AAC9B,OAAO,EACL,KAAK,oBAAoB,EACzB,KAAK,iCAAiC,EACtC,0BAA0B,EAC1B,KAAK,mBAAmB,GACzB,MAAM,2BAA2B,CAAA;AAClC,cAAc,YAAY,CAAA;AAC1B,OAAO,EACL,KAAK,0BAA0B,EAC/B,mBAAmB,EACnB,KAAK,eAAe,GACrB,MAAM,aAAa,CAAA"}
package/dist/index.js CHANGED
@@ -40,5 +40,6 @@ export { handleDurableObjectAlarm, handleDurableObjectRequest, } from "./durable
40
40
  export { createInMemoryKv, createKvManifestStore, } from "./manifest-kv-store.js";
41
41
  export { createR2Presigner, } from "./r2-sign.js";
42
42
  export { handleGetSchedules, } from "./schedule-handler.js";
43
+ export { createKvScheduleStateStore, } from "./schedule-state-store.js";
43
44
  export * from "./types.js";
44
45
  export { handleWorkerRequest, } from "./worker.js";
@@ -1,5 +1,6 @@
1
1
  import type { ManifestSchedule } from "@voyantjs/workflows/protocol";
2
2
  import type { CfManifestStore } from "./manifest-kv-store.js";
3
+ import type { CfScheduleStateStore } from "./schedule-state-store.js";
3
4
  export interface ScheduleHandlerDeps {
4
5
  manifestStore: CfManifestStore;
5
6
  /**
@@ -12,6 +13,11 @@ export interface ScheduleHandlerDeps {
12
13
  * UIs can ignore the field.
13
14
  */
14
15
  schedulesEnabledByEnv?: boolean;
16
+ /**
17
+ * Optional state store populated by the runtime scheduler/control plane.
18
+ * When present, each schedule row includes last-fire/run/error fields.
19
+ */
20
+ scheduleStateStore?: CfScheduleStateStore;
15
21
  now?: () => number;
16
22
  logger?: (level: "info" | "warn" | "error", msg: string, data?: object) => void;
17
23
  }
@@ -28,6 +34,18 @@ export interface ScheduleSummary {
28
34
  */
29
35
  enabled: boolean;
30
36
  disabledReason?: "registration_disabled" | "env_filtered";
37
+ /** Epoch millis of the last scheduler dispatch attempt, when known. */
38
+ lastFireAt?: number | null;
39
+ /** Run id produced by the last scheduler dispatch attempt, when known. */
40
+ lastRunId?: string | null;
41
+ /** Last scheduler dispatch/lock error, when known. */
42
+ lastError?: string | null;
43
+ /** Epoch millis until which this schedule is locked, when known. */
44
+ lockedUntil?: number | null;
45
+ /** Epoch millis of the last successful scheduled run, when known. */
46
+ lastSuccessfulRunAt?: number | null;
47
+ /** Epoch millis when the persisted scheduler state was last updated. */
48
+ stateUpdatedAt?: number | null;
31
49
  }
32
50
  export interface ScheduleListResponse {
33
51
  environment: string;
@@ -1 +1 @@
1
- {"version":3,"file":"schedule-handler.d.ts","sourceRoot":"","sources":["../src/schedule-handler.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,gBAAgB,EAAoB,MAAM,8BAA8B,CAAA;AAGtF,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AAI7D,MAAM,WAAW,mBAAmB;IAClC,aAAa,EAAE,eAAe,CAAA;IAC9B;;;;;;;;OAQG;IACH,qBAAqB,CAAC,EAAE,OAAO,CAAA;IAC/B,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;IAClB,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;CAChF;AAED,MAAM,WAAW,eAAe;IAC9B,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,EAAE,MAAM,CAAA;IAClB,QAAQ,EAAE,gBAAgB,CAAA;IAC1B,wEAAwE;IACxE,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB;;;;OAIG;IACH,OAAO,EAAE,OAAO,CAAA;IAChB,cAAc,CAAC,EAAE,uBAAuB,GAAG,cAAc,CAAA;CAC1D;AAED,MAAM,WAAW,oBAAoB;IACnC,WAAW,EAAE,MAAM,CAAA;IACnB,SAAS,EAAE,MAAM,CAAA;IACjB,qBAAqB,CAAC,EAAE,OAAO,CAAA;IAC/B,IAAI,EAAE,eAAe,EAAE,CAAA;CACxB;AAED;;;GAGG;AACH,wBAAsB,kBAAkB,CACtC,WAAW,EAAE,MAAM,EACnB,IAAI,EAAE,mBAAmB,GACxB,OAAO,CAAC,QAAQ,CAAC,CAoEnB"}
1
+ {"version":3,"file":"schedule-handler.d.ts","sourceRoot":"","sources":["../src/schedule-handler.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAA;AAGpE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AAC7D,OAAO,KAAK,EAAE,oBAAoB,EAAuB,MAAM,2BAA2B,CAAA;AAW1F,MAAM,WAAW,mBAAmB;IAClC,aAAa,EAAE,eAAe,CAAA;IAC9B;;;;;;;;OAQG;IACH,qBAAqB,CAAC,EAAE,OAAO,CAAA;IAC/B;;;OAGG;IACH,kBAAkB,CAAC,EAAE,oBAAoB,CAAA;IACzC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;IAClB,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;CAChF;AAED,MAAM,WAAW,eAAe;IAC9B,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,EAAE,MAAM,CAAA;IAClB,QAAQ,EAAE,gBAAgB,CAAA;IAC1B,wEAAwE;IACxE,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB;;;;OAIG;IACH,OAAO,EAAE,OAAO,CAAA;IAChB,cAAc,CAAC,EAAE,uBAAuB,GAAG,cAAc,CAAA;IACzD,uEAAuE;IACvE,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,0EAA0E;IAC1E,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,sDAAsD;IACtD,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,oEAAoE;IACpE,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,qEAAqE;IACrE,mBAAmB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACnC,wEAAwE;IACxE,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAC/B;AAED,MAAM,WAAW,oBAAoB;IACnC,WAAW,EAAE,MAAM,CAAA;IACnB,SAAS,EAAE,MAAM,CAAA;IACjB,qBAAqB,CAAC,EAAE,OAAO,CAAA;IAC/B,IAAI,EAAE,eAAe,EAAE,CAAA;CACxB;AAED;;;GAGG;AACH,wBAAsB,kBAAkB,CACtC,WAAW,EAAE,MAAM,EACnB,IAAI,EAAE,mBAAmB,GACxB,OAAO,CAAC,QAAQ,CAAC,CAkFnB"}
@@ -1,24 +1,19 @@
1
1
  // HTTP handler for `/api/schedules/:env`. Reads the registered manifest
2
2
  // for the requested environment, projects each workflow's schedule blocks
3
- // into a flat list, and computes `nextRun` per entry via the same cron /
4
- // every / at logic the live scheduler uses.
5
- //
6
- // Aggregate "lastRun" is intentionally out of scope here — runs in the
7
- // Cloudflare orchestrator live in per-run Durable Objects, indexed by
8
- // runId, with no list-by-workflow path. The UI is expected to fetch
9
- // last-run state separately from the template-side `workflow-runs`
10
- // admin API (`/v1/admin/workflow-runs?workflowName=…&limit=1`).
3
+ // into a flat list, computes `nextRun` per entry via the same cron / every /
4
+ // at logic the live scheduler uses, and optionally merges persisted scheduler
5
+ // dispatch state when a control plane provides it.
11
6
  import { computeNextFire } from "@voyantjs/workflows-orchestrator";
12
- const ALLOWED_ENVS = new Set(["production", "preview", "development"]);
7
+ const ALLOWED_ENVS = ["production", "preview", "development"];
13
8
  /**
14
9
  * Handle `GET /api/schedules/:env`. Returns the projected schedule list
15
10
  * for the current manifest, or 404 when no manifest is registered.
16
11
  */
17
12
  export async function handleGetSchedules(environment, deps) {
18
- if (!ALLOWED_ENVS.has(environment)) {
13
+ if (!isAllowedEnvironment(environment)) {
19
14
  return json(400, {
20
15
  error: "invalid_environment",
21
- message: `environment must be one of ${[...ALLOWED_ENVS].join(", ")}`,
16
+ message: `environment must be one of ${ALLOWED_ENVS.join(", ")}`,
22
17
  });
23
18
  }
24
19
  const envelope = await deps.manifestStore.getCurrent(environment);
@@ -26,11 +21,9 @@ export async function handleGetSchedules(environment, deps) {
26
21
  return json(404, { error: "not_found", environment });
27
22
  }
28
23
  const now = deps.now ? deps.now() : Date.now();
29
- const manifest = envelope.manifest;
30
24
  const data = [];
31
- for (const workflow of manifest.workflows ?? []) {
32
- const schedules = Array.isArray(workflow.schedules) ? workflow.schedules : [];
33
- schedules.forEach((schedule, index) => {
25
+ for (const workflow of readManifestWorkflows(envelope.manifest)) {
26
+ workflow.schedules.forEach((schedule, index) => {
34
27
  const scheduleId = `${envelope.versionId}:${workflow.id}:${schedule.name ?? index}`;
35
28
  const registrationDisabled = schedule.enabled === false;
36
29
  const envFiltered = Array.isArray(schedule.environments)
@@ -67,6 +60,22 @@ export async function handleGetSchedules(environment, deps) {
67
60
  });
68
61
  });
69
62
  }
63
+ if (deps.scheduleStateStore) {
64
+ const scheduleIds = data.map((row) => row.scheduleId);
65
+ let states = new Map();
66
+ try {
67
+ states = await deps.scheduleStateStore.getStates(environment, scheduleIds);
68
+ }
69
+ catch (err) {
70
+ deps.logger?.("warn", "schedules: cannot load scheduler state", {
71
+ environment,
72
+ error: err instanceof Error ? err.message : String(err),
73
+ });
74
+ }
75
+ for (const row of data) {
76
+ attachState(row, states.get(row.scheduleId));
77
+ }
78
+ }
70
79
  const response = {
71
80
  environment,
72
81
  versionId: envelope.versionId,
@@ -77,6 +86,33 @@ export async function handleGetSchedules(environment, deps) {
77
86
  };
78
87
  return json(200, response);
79
88
  }
89
+ function isAllowedEnvironment(value) {
90
+ return ALLOWED_ENVS.includes(value);
91
+ }
92
+ function readManifestWorkflows(manifest) {
93
+ const workflows = manifest.workflows;
94
+ if (!Array.isArray(workflows))
95
+ return [];
96
+ return workflows.flatMap((workflow) => {
97
+ if (!isRecord(workflow) || typeof workflow.id !== "string")
98
+ return [];
99
+ const schedules = Array.isArray(workflow.schedules)
100
+ ? workflow.schedules.filter(isManifestSchedule)
101
+ : [];
102
+ return [{ id: workflow.id, schedules }];
103
+ });
104
+ }
105
+ function isManifestSchedule(value) {
106
+ return isRecord(value);
107
+ }
108
+ function attachState(row, state) {
109
+ row.lastFireAt = state?.lastFireAt ?? null;
110
+ row.lastRunId = state?.lastRunId ?? null;
111
+ row.lastError = state?.lastError ?? null;
112
+ row.lockedUntil = state?.lockedUntil ?? null;
113
+ row.lastSuccessfulRunAt = state?.lastSuccessfulRunAt ?? null;
114
+ row.stateUpdatedAt = state?.updatedAt ?? null;
115
+ }
80
116
  function json(status, body) {
81
117
  return new Response(JSON.stringify(body), {
82
118
  status,
@@ -88,3 +124,6 @@ function json(status, body) {
88
124
  },
89
125
  });
90
126
  }
127
+ function isRecord(value) {
128
+ return typeof value === "object" && value !== null;
129
+ }
@@ -0,0 +1,22 @@
1
+ import type { KvNamespaceLike } from "./manifest-kv-store.js";
2
+ export interface ScheduleStateRecord {
3
+ scheduleId: string;
4
+ workflowId?: string;
5
+ environment?: string;
6
+ versionId?: string;
7
+ lastFireAt?: number | null;
8
+ lastRunId?: string | null;
9
+ lastError?: string | null;
10
+ lockedUntil?: number | null;
11
+ lastSuccessfulRunAt?: number | null;
12
+ updatedAt?: number | null;
13
+ }
14
+ export interface CfScheduleStateStore {
15
+ getStates(environment: string, scheduleIds: readonly string[]): Promise<Map<string, ScheduleStateRecord>>;
16
+ putState(environment: string, state: ScheduleStateRecord): Promise<void>;
17
+ }
18
+ export interface CreateKvScheduleStateStoreOptions {
19
+ kv: KvNamespaceLike;
20
+ }
21
+ export declare function createKvScheduleStateStore(opts: CreateKvScheduleStateStoreOptions): CfScheduleStateStore;
22
+ //# sourceMappingURL=schedule-state-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schedule-state-store.d.ts","sourceRoot":"","sources":["../src/schedule-state-store.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AAE7D,MAAM,WAAW,mBAAmB;IAClC,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,mBAAmB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACnC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAC1B;AAED,MAAM,WAAW,oBAAoB;IACnC,SAAS,CACP,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,SAAS,MAAM,EAAE,GAC7B,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,mBAAmB,CAAC,CAAC,CAAA;IAC5C,QAAQ,CAAC,WAAW,EAAE,MAAM,EAAE,KAAK,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CACzE;AAeD,MAAM,WAAW,iCAAiC;IAChD,EAAE,EAAE,eAAe,CAAA;CACpB;AAED,wBAAgB,0BAA0B,CACxC,IAAI,EAAE,iCAAiC,GACtC,oBAAoB,CAyBtB"}
@@ -0,0 +1,74 @@
1
+ // KV-backed persisted state for workflow schedule dispatches.
2
+ //
3
+ // The manifest store answers "what schedules are registered?". This store
4
+ // answers "what has the scheduler actually attempted recently?", keyed by
5
+ // the stable schedule id emitted by manifestScheduleSources / schedule-handler.
6
+ export function createKvScheduleStateStore(opts) {
7
+ const kv = opts.kv;
8
+ return {
9
+ async getStates(environment, scheduleIds) {
10
+ const out = new Map();
11
+ await Promise.all(scheduleIds.map(async (scheduleId) => {
12
+ const raw = await kv.get(scheduleStateKey(environment, scheduleId));
13
+ if (!raw)
14
+ return;
15
+ const parsed = parseScheduleState(raw);
16
+ if (!parsed || parsed.scheduleId !== scheduleId)
17
+ return;
18
+ out.set(scheduleId, parsed);
19
+ }));
20
+ return out;
21
+ },
22
+ async putState(environment, state) {
23
+ await kv.put(scheduleStateKey(environment, state.scheduleId), JSON.stringify(normalizeScheduleState(environment, state)));
24
+ },
25
+ };
26
+ }
27
+ function scheduleStateKey(environment, scheduleId) {
28
+ return `schedule-state:${environment}:${encodeURIComponent(scheduleId)}`;
29
+ }
30
+ function parseScheduleState(raw) {
31
+ try {
32
+ const parsed = JSON.parse(raw);
33
+ if (!isRecord(parsed) || typeof parsed.scheduleId !== "string")
34
+ return null;
35
+ const state = {
36
+ scheduleId: parsed.scheduleId,
37
+ workflowId: typeof parsed.workflowId === "string" ? parsed.workflowId : undefined,
38
+ versionId: typeof parsed.versionId === "string" ? parsed.versionId : undefined,
39
+ lastFireAt: parsed.lastFireAt,
40
+ lastRunId: parsed.lastRunId,
41
+ lastError: parsed.lastError,
42
+ lockedUntil: parsed.lockedUntil,
43
+ lastSuccessfulRunAt: parsed.lastSuccessfulRunAt,
44
+ updatedAt: parsed.updatedAt,
45
+ };
46
+ return normalizeScheduleState(typeof parsed.environment === "string" ? parsed.environment : undefined, state);
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ function normalizeScheduleState(environment, state) {
53
+ return {
54
+ scheduleId: state.scheduleId,
55
+ ...(typeof state.workflowId === "string" ? { workflowId: state.workflowId } : {}),
56
+ ...(environment !== undefined ? { environment } : {}),
57
+ ...(typeof state.versionId === "string" ? { versionId: state.versionId } : {}),
58
+ lastFireAt: nullableFiniteNumber(state.lastFireAt),
59
+ lastRunId: nullableString(state.lastRunId),
60
+ lastError: nullableString(state.lastError),
61
+ lockedUntil: nullableFiniteNumber(state.lockedUntil),
62
+ lastSuccessfulRunAt: nullableFiniteNumber(state.lastSuccessfulRunAt),
63
+ updatedAt: nullableFiniteNumber(state.updatedAt),
64
+ };
65
+ }
66
+ function nullableFiniteNumber(value) {
67
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
68
+ }
69
+ function nullableString(value) {
70
+ return typeof value === "string" && value.length > 0 ? value : null;
71
+ }
72
+ function isRecord(value) {
73
+ return typeof value === "object" && value !== null;
74
+ }
package/dist/worker.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { CfManifestStore } from "./manifest-kv-store.js";
2
+ import type { CfScheduleStateStore } from "./schedule-state-store.js";
2
3
  /**
3
4
  * Minimal shape of a DO namespace. `idFromName` returns an opaque id;
4
5
  * `get(id)` returns a stub with `fetch` (matching the CF DO API).
@@ -38,6 +39,11 @@ export interface WorkerFetchDeps<Id = unknown> {
38
39
  * When omitted, the schedules response leaves the flag out.
39
40
  */
40
41
  schedulesEnabledByEnv?: boolean;
42
+ /**
43
+ * Optional persisted scheduler execution state read by
44
+ * `/api/schedules/:env`.
45
+ */
46
+ scheduleStateStore?: CfScheduleStateStore;
41
47
  /**
42
48
  * Tenant metadata stamped on event-triggered runs. Defaults to
43
49
  * `{ tenantId: "default", projectId: "default", organizationId: "default" }`.
@@ -1 +1 @@
1
- {"version":3,"file":"worker.d.ts","sourceRoot":"","sources":["../src/worker.ts"],"names":[],"mappings":"AAuBA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AAS7D;;;;GAIG;AACH,MAAM,WAAW,0BAA0B,CAAC,EAAE,GAAG,OAAO;IACtD,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,EAAE,CAAA;IAC5B,GAAG,CAAC,EAAE,EAAE,EAAE,GAAG;QAAE,KAAK,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;KAAE,CAAA;CACxD;AAED,MAAM,WAAW,eAAe,CAAC,EAAE,GAAG,OAAO;IAC3C,KAAK,EAAE,0BAA0B,CAAC,EAAE,CAAC,CAAA;IACrC;;;OAGG;IACH,aAAa,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACtD,uBAAuB;IACvB,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;IAC/E,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,MAAM,CAAA;IAC1B,0CAA0C;IAC1C,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;IAClB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,eAAe,CAAA;IAC/B;;;;;;OAMG;IACH,qBAAqB,CAAC,EAAE,OAAO,CAAA;IAC/B;;;;;OAKG;IACH,UAAU,CAAC,EAAE;QACX,QAAQ,EAAE,MAAM,CAAA;QAChB,SAAS,EAAE,MAAM,CAAA;QACjB,cAAc,EAAE,MAAM,CAAA;QACtB,YAAY,CAAC,EAAE,MAAM,CAAA;KACtB,CAAA;CACF;AAED,wBAAsB,mBAAmB,CAAC,EAAE,EAC1C,GAAG,EAAE,OAAO,EACZ,IAAI,EAAE,eAAe,CAAC,EAAE,CAAC,GACxB,OAAO,CAAC,QAAQ,CAAC,CAmNnB"}
1
+ {"version":3,"file":"worker.d.ts","sourceRoot":"","sources":["../src/worker.ts"],"names":[],"mappings":"AAuBA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AAE7D,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAA;AAQrE;;;;GAIG;AACH,MAAM,WAAW,0BAA0B,CAAC,EAAE,GAAG,OAAO;IACtD,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,EAAE,CAAA;IAC5B,GAAG,CAAC,EAAE,EAAE,EAAE,GAAG;QAAE,KAAK,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;KAAE,CAAA;CACxD;AAED,MAAM,WAAW,eAAe,CAAC,EAAE,GAAG,OAAO;IAC3C,KAAK,EAAE,0BAA0B,CAAC,EAAE,CAAC,CAAA;IACrC;;;OAGG;IACH,aAAa,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACtD,uBAAuB;IACvB,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;IAC/E,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,MAAM,CAAA;IAC1B,0CAA0C;IAC1C,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;IAClB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,eAAe,CAAA;IAC/B;;;;;;OAMG;IACH,qBAAqB,CAAC,EAAE,OAAO,CAAA;IAC/B;;;OAGG;IACH,kBAAkB,CAAC,EAAE,oBAAoB,CAAA;IACzC;;;;;OAKG;IACH,UAAU,CAAC,EAAE;QACX,QAAQ,EAAE,MAAM,CAAA;QAChB,SAAS,EAAE,MAAM,CAAA;QACjB,cAAc,EAAE,MAAM,CAAA;QACtB,YAAY,CAAC,EAAE,MAAM,CAAA;KACtB,CAAA;CACF;AAED,wBAAsB,mBAAmB,CAAC,EAAE,EAC1C,GAAG,EAAE,OAAO,EACZ,IAAI,EAAE,eAAe,CAAC,EAAE,CAAC,GACxB,OAAO,CAAC,QAAQ,CAAC,CA4OnB"}
package/dist/worker.js CHANGED
@@ -71,6 +71,7 @@ export async function handleWorkerRequest(req, deps) {
71
71
  return handleGetSchedules(env, {
72
72
  manifestStore: deps.manifestStore,
73
73
  schedulesEnabledByEnv: deps.schedulesEnabledByEnv,
74
+ scheduleStateStore: deps.scheduleStateStore,
74
75
  now: deps.now,
75
76
  logger: deps.logger,
76
77
  });
@@ -107,7 +108,32 @@ export async function handleWorkerRequest(req, deps) {
107
108
  headers: { "content-type": "application/json" },
108
109
  body: JSON.stringify({ ...publicPayload, runId }),
109
110
  });
110
- return forwardToRunDO(runId, forward, deps);
111
+ const fireAt = deps.now ? deps.now() : Date.now();
112
+ const scheduleTrigger = getScheduleTrigger(publicPayload);
113
+ try {
114
+ const triggerRes = await forwardToRunDO(runId, forward, deps);
115
+ const runResult = triggerRes.ok ? await safeResponseJson(triggerRes.clone()) : undefined;
116
+ const runError = triggerRes.ok
117
+ ? failedRunError(runResult)
118
+ : `do_returned_${triggerRes.status}`;
119
+ await recordScheduleDispatch(deps, {
120
+ scheduleTrigger,
121
+ runId: triggerRes.ok ? runId : null,
122
+ fireAt,
123
+ error: runError,
124
+ lastSuccessfulRunAt: completedRunAt(runResult),
125
+ });
126
+ return triggerRes;
127
+ }
128
+ catch (err) {
129
+ await recordScheduleDispatch(deps, {
130
+ scheduleTrigger,
131
+ runId: null,
132
+ fireAt,
133
+ error: errMsg(err),
134
+ });
135
+ throw err;
136
+ }
111
137
  }
112
138
  // Everything below operates on a specific runId.
113
139
  const runMatch = url.pathname.match(/^\/api\/runs\/([^/]+)(\/.+)?$/);
@@ -368,6 +394,88 @@ function sanitizePublicTriggerPayload(payload) {
368
394
  const { initialJournal: _initialJournal, initialMetadataAppliedCount: _initialMetadataAppliedCount, timeoutMs: _timeoutMs, ...publicPayload } = payload;
369
395
  return publicPayload;
370
396
  }
397
+ function getScheduleTrigger(payload) {
398
+ const triggeredBy = payload.triggeredBy;
399
+ const environment = payload.environment ?? "development";
400
+ if (typeof triggeredBy !== "object" ||
401
+ triggeredBy === null ||
402
+ !("kind" in triggeredBy) ||
403
+ triggeredBy.kind !== "schedule" ||
404
+ !("scheduleId" in triggeredBy) ||
405
+ typeof triggeredBy.scheduleId !== "string" ||
406
+ triggeredBy.scheduleId.length === 0 ||
407
+ (environment !== "production" && environment !== "preview" && environment !== "development")) {
408
+ return null;
409
+ }
410
+ return {
411
+ environment,
412
+ scheduleId: triggeredBy.scheduleId,
413
+ };
414
+ }
415
+ async function recordScheduleDispatch(deps, args) {
416
+ if (!deps.scheduleStateStore || !args.scheduleTrigger)
417
+ return;
418
+ try {
419
+ const existing = (await deps.scheduleStateStore.getStates(args.scheduleTrigger.environment, [
420
+ args.scheduleTrigger.scheduleId,
421
+ ])).get(args.scheduleTrigger.scheduleId);
422
+ await deps.scheduleStateStore.putState(args.scheduleTrigger.environment, {
423
+ ...existing,
424
+ scheduleId: args.scheduleTrigger.scheduleId,
425
+ environment: args.scheduleTrigger.environment,
426
+ lastFireAt: args.fireAt,
427
+ lastRunId: args.runId,
428
+ lastError: args.error,
429
+ ...(args.lastSuccessfulRunAt !== undefined
430
+ ? { lastSuccessfulRunAt: args.lastSuccessfulRunAt }
431
+ : {}),
432
+ updatedAt: deps.now ? deps.now() : Date.now(),
433
+ });
434
+ }
435
+ catch (err) {
436
+ deps.logger?.("warn", "schedules: cannot persist scheduler dispatch state", {
437
+ environment: args.scheduleTrigger.environment,
438
+ scheduleId: args.scheduleTrigger.scheduleId,
439
+ error: errMsg(err),
440
+ });
441
+ }
442
+ }
443
+ async function safeResponseJson(response) {
444
+ try {
445
+ return await response.json();
446
+ }
447
+ catch {
448
+ return undefined;
449
+ }
450
+ }
451
+ function completedRunAt(value) {
452
+ if (typeof value === "object" &&
453
+ value !== null &&
454
+ "status" in value &&
455
+ value.status === "completed" &&
456
+ "completedAt" in value &&
457
+ typeof value.completedAt === "number" &&
458
+ Number.isFinite(value.completedAt)) {
459
+ return value.completedAt;
460
+ }
461
+ return undefined;
462
+ }
463
+ function failedRunError(value) {
464
+ if (typeof value !== "object" || value === null || !("status" in value))
465
+ return null;
466
+ const status = value.status;
467
+ if (status !== "failed" && status !== "compensation_failed")
468
+ return null;
469
+ const error = "error" in value ? value.error : undefined;
470
+ if (typeof error === "object" &&
471
+ error !== null &&
472
+ "message" in error &&
473
+ typeof error.message === "string" &&
474
+ error.message.length > 0) {
475
+ return error.message;
476
+ }
477
+ return `run_${status}`;
478
+ }
371
479
  function json(status, body) {
372
480
  return new Response(JSON.stringify(body), {
373
481
  status,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voyantjs/workflows-orchestrator-cloudflare",
3
- "version": "0.86.0",
3
+ "version": "0.88.0",
4
4
  "description": "Cloudflare Worker + Durable Object adapter for @voyantjs/workflows-orchestrator. Dispatches workflow-step requests to tenant Workers via a Workers-for-Platforms dispatch namespace.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -26,8 +26,8 @@
26
26
  "NOTICE"
27
27
  ],
28
28
  "dependencies": {
29
- "@voyantjs/workflows-orchestrator": "0.86.0",
30
- "@voyantjs/workflows": "0.86.0"
29
+ "@voyantjs/workflows-orchestrator": "0.88.0",
30
+ "@voyantjs/workflows": "0.88.0"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@cloudflare/vitest-pool-workers": "^0.15.1",
package/src/index.ts CHANGED
@@ -83,6 +83,12 @@ export {
83
83
  type ScheduleListResponse,
84
84
  type ScheduleSummary,
85
85
  } from "./schedule-handler.js"
86
+ export {
87
+ type CfScheduleStateStore,
88
+ type CreateKvScheduleStateStoreOptions,
89
+ createKvScheduleStateStore,
90
+ type ScheduleStateRecord,
91
+ } from "./schedule-state-store.js"
86
92
  export * from "./types.js"
87
93
  export {
88
94
  type DurableObjectNamespaceLike,
@@ -1,20 +1,23 @@
1
1
  // HTTP handler for `/api/schedules/:env`. Reads the registered manifest
2
2
  // for the requested environment, projects each workflow's schedule blocks
3
- // into a flat list, and computes `nextRun` per entry via the same cron /
4
- // every / at logic the live scheduler uses.
5
- //
6
- // Aggregate "lastRun" is intentionally out of scope here — runs in the
7
- // Cloudflare orchestrator live in per-run Durable Objects, indexed by
8
- // runId, with no list-by-workflow path. The UI is expected to fetch
9
- // last-run state separately from the template-side `workflow-runs`
10
- // admin API (`/v1/admin/workflow-runs?workflowName=…&limit=1`).
11
-
12
- import type { ManifestSchedule, WorkflowManifest } from "@voyantjs/workflows/protocol"
3
+ // into a flat list, computes `nextRun` per entry via the same cron / every /
4
+ // at logic the live scheduler uses, and optionally merges persisted scheduler
5
+ // dispatch state when a control plane provides it.
6
+
7
+ import type { ManifestSchedule } from "@voyantjs/workflows/protocol"
13
8
  import { computeNextFire } from "@voyantjs/workflows-orchestrator"
14
9
 
15
10
  import type { CfManifestStore } from "./manifest-kv-store.js"
11
+ import type { CfScheduleStateStore, ScheduleStateRecord } from "./schedule-state-store.js"
12
+
13
+ const ALLOWED_ENVS = ["production", "preview", "development"] as const
16
14
 
17
- const ALLOWED_ENVS = new Set(["production", "preview", "development"])
15
+ type AllowedEnvironment = (typeof ALLOWED_ENVS)[number]
16
+
17
+ interface ManifestWorkflowScheduleEntry {
18
+ id: string
19
+ schedules: ManifestSchedule[]
20
+ }
18
21
 
19
22
  export interface ScheduleHandlerDeps {
20
23
  manifestStore: CfManifestStore
@@ -28,6 +31,11 @@ export interface ScheduleHandlerDeps {
28
31
  * UIs can ignore the field.
29
32
  */
30
33
  schedulesEnabledByEnv?: boolean
34
+ /**
35
+ * Optional state store populated by the runtime scheduler/control plane.
36
+ * When present, each schedule row includes last-fire/run/error fields.
37
+ */
38
+ scheduleStateStore?: CfScheduleStateStore
31
39
  now?: () => number
32
40
  logger?: (level: "info" | "warn" | "error", msg: string, data?: object) => void
33
41
  }
@@ -45,6 +53,18 @@ export interface ScheduleSummary {
45
53
  */
46
54
  enabled: boolean
47
55
  disabledReason?: "registration_disabled" | "env_filtered"
56
+ /** Epoch millis of the last scheduler dispatch attempt, when known. */
57
+ lastFireAt?: number | null
58
+ /** Run id produced by the last scheduler dispatch attempt, when known. */
59
+ lastRunId?: string | null
60
+ /** Last scheduler dispatch/lock error, when known. */
61
+ lastError?: string | null
62
+ /** Epoch millis until which this schedule is locked, when known. */
63
+ lockedUntil?: number | null
64
+ /** Epoch millis of the last successful scheduled run, when known. */
65
+ lastSuccessfulRunAt?: number | null
66
+ /** Epoch millis when the persisted scheduler state was last updated. */
67
+ stateUpdatedAt?: number | null
48
68
  }
49
69
 
50
70
  export interface ScheduleListResponse {
@@ -62,10 +82,10 @@ export async function handleGetSchedules(
62
82
  environment: string,
63
83
  deps: ScheduleHandlerDeps,
64
84
  ): Promise<Response> {
65
- if (!ALLOWED_ENVS.has(environment)) {
85
+ if (!isAllowedEnvironment(environment)) {
66
86
  return json(400, {
67
87
  error: "invalid_environment",
68
- message: `environment must be one of ${[...ALLOWED_ENVS].join(", ")}`,
88
+ message: `environment must be one of ${ALLOWED_ENVS.join(", ")}`,
69
89
  })
70
90
  }
71
91
 
@@ -75,16 +95,14 @@ export async function handleGetSchedules(
75
95
  }
76
96
 
77
97
  const now = deps.now ? deps.now() : Date.now()
78
- const manifest = envelope.manifest as unknown as WorkflowManifest
79
98
  const data: ScheduleSummary[] = []
80
99
 
81
- for (const workflow of manifest.workflows ?? []) {
82
- const schedules = Array.isArray(workflow.schedules) ? workflow.schedules : []
83
- schedules.forEach((schedule, index) => {
100
+ for (const workflow of readManifestWorkflows(envelope.manifest)) {
101
+ workflow.schedules.forEach((schedule, index) => {
84
102
  const scheduleId = `${envelope.versionId}:${workflow.id}:${schedule.name ?? index}`
85
103
  const registrationDisabled = schedule.enabled === false
86
104
  const envFiltered = Array.isArray(schedule.environments)
87
- ? !schedule.environments.includes(environment as never)
105
+ ? !schedule.environments.includes(environment)
88
106
  : false
89
107
 
90
108
  let nextRunAt: number | null = null
@@ -120,6 +138,22 @@ export async function handleGetSchedules(
120
138
  })
121
139
  }
122
140
 
141
+ if (deps.scheduleStateStore) {
142
+ const scheduleIds = data.map((row) => row.scheduleId)
143
+ let states = new Map<string, ScheduleStateRecord>()
144
+ try {
145
+ states = await deps.scheduleStateStore.getStates(environment, scheduleIds)
146
+ } catch (err) {
147
+ deps.logger?.("warn", "schedules: cannot load scheduler state", {
148
+ environment,
149
+ error: err instanceof Error ? err.message : String(err),
150
+ })
151
+ }
152
+ for (const row of data) {
153
+ attachState(row, states.get(row.scheduleId))
154
+ }
155
+ }
156
+
123
157
  const response: ScheduleListResponse = {
124
158
  environment,
125
159
  versionId: envelope.versionId,
@@ -131,6 +165,35 @@ export async function handleGetSchedules(
131
165
  return json(200, response)
132
166
  }
133
167
 
168
+ function isAllowedEnvironment(value: string): value is AllowedEnvironment {
169
+ return ALLOWED_ENVS.includes(value as AllowedEnvironment)
170
+ }
171
+
172
+ function readManifestWorkflows(manifest: Record<string, unknown>): ManifestWorkflowScheduleEntry[] {
173
+ const workflows = manifest.workflows
174
+ if (!Array.isArray(workflows)) return []
175
+ return workflows.flatMap((workflow): ManifestWorkflowScheduleEntry[] => {
176
+ if (!isRecord(workflow) || typeof workflow.id !== "string") return []
177
+ const schedules = Array.isArray(workflow.schedules)
178
+ ? workflow.schedules.filter(isManifestSchedule)
179
+ : []
180
+ return [{ id: workflow.id, schedules }]
181
+ })
182
+ }
183
+
184
+ function isManifestSchedule(value: unknown): value is ManifestSchedule {
185
+ return isRecord(value)
186
+ }
187
+
188
+ function attachState(row: ScheduleSummary, state: ScheduleStateRecord | undefined): void {
189
+ row.lastFireAt = state?.lastFireAt ?? null
190
+ row.lastRunId = state?.lastRunId ?? null
191
+ row.lastError = state?.lastError ?? null
192
+ row.lockedUntil = state?.lockedUntil ?? null
193
+ row.lastSuccessfulRunAt = state?.lastSuccessfulRunAt ?? null
194
+ row.stateUpdatedAt = state?.updatedAt ?? null
195
+ }
196
+
134
197
  function json(status: number, body: unknown): Response {
135
198
  return new Response(JSON.stringify(body), {
136
199
  status,
@@ -142,3 +205,7 @@ function json(status: number, body: unknown): Response {
142
205
  },
143
206
  })
144
207
  }
208
+
209
+ function isRecord(value: unknown): value is Record<string, unknown> {
210
+ return typeof value === "object" && value !== null
211
+ }
@@ -0,0 +1,132 @@
1
+ // KV-backed persisted state for workflow schedule dispatches.
2
+ //
3
+ // The manifest store answers "what schedules are registered?". This store
4
+ // answers "what has the scheduler actually attempted recently?", keyed by
5
+ // the stable schedule id emitted by manifestScheduleSources / schedule-handler.
6
+
7
+ import type { KvNamespaceLike } from "./manifest-kv-store.js"
8
+
9
+ export interface ScheduleStateRecord {
10
+ scheduleId: string
11
+ workflowId?: string
12
+ environment?: string
13
+ versionId?: string
14
+ lastFireAt?: number | null
15
+ lastRunId?: string | null
16
+ lastError?: string | null
17
+ lockedUntil?: number | null
18
+ lastSuccessfulRunAt?: number | null
19
+ updatedAt?: number | null
20
+ }
21
+
22
+ export interface CfScheduleStateStore {
23
+ getStates(
24
+ environment: string,
25
+ scheduleIds: readonly string[],
26
+ ): Promise<Map<string, ScheduleStateRecord>>
27
+ putState(environment: string, state: ScheduleStateRecord): Promise<void>
28
+ }
29
+
30
+ interface RawScheduleStateRecord {
31
+ scheduleId: string
32
+ workflowId?: unknown
33
+ environment?: unknown
34
+ versionId?: unknown
35
+ lastFireAt?: unknown
36
+ lastRunId?: unknown
37
+ lastError?: unknown
38
+ lockedUntil?: unknown
39
+ lastSuccessfulRunAt?: unknown
40
+ updatedAt?: unknown
41
+ }
42
+
43
+ export interface CreateKvScheduleStateStoreOptions {
44
+ kv: KvNamespaceLike
45
+ }
46
+
47
+ export function createKvScheduleStateStore(
48
+ opts: CreateKvScheduleStateStoreOptions,
49
+ ): CfScheduleStateStore {
50
+ const kv = opts.kv
51
+
52
+ return {
53
+ async getStates(environment, scheduleIds) {
54
+ const out = new Map<string, ScheduleStateRecord>()
55
+ await Promise.all(
56
+ scheduleIds.map(async (scheduleId) => {
57
+ const raw = await kv.get(scheduleStateKey(environment, scheduleId))
58
+ if (!raw) return
59
+ const parsed = parseScheduleState(raw)
60
+ if (!parsed || parsed.scheduleId !== scheduleId) return
61
+ out.set(scheduleId, parsed)
62
+ }),
63
+ )
64
+ return out
65
+ },
66
+
67
+ async putState(environment, state) {
68
+ await kv.put(
69
+ scheduleStateKey(environment, state.scheduleId),
70
+ JSON.stringify(normalizeScheduleState(environment, state)),
71
+ )
72
+ },
73
+ }
74
+ }
75
+
76
+ function scheduleStateKey(environment: string, scheduleId: string): string {
77
+ return `schedule-state:${environment}:${encodeURIComponent(scheduleId)}`
78
+ }
79
+
80
+ function parseScheduleState(raw: string): ScheduleStateRecord | null {
81
+ try {
82
+ const parsed = JSON.parse(raw) as unknown
83
+ if (!isRecord(parsed) || typeof parsed.scheduleId !== "string") return null
84
+ const state: RawScheduleStateRecord = {
85
+ scheduleId: parsed.scheduleId,
86
+ workflowId: typeof parsed.workflowId === "string" ? parsed.workflowId : undefined,
87
+ versionId: typeof parsed.versionId === "string" ? parsed.versionId : undefined,
88
+ lastFireAt: parsed.lastFireAt,
89
+ lastRunId: parsed.lastRunId,
90
+ lastError: parsed.lastError,
91
+ lockedUntil: parsed.lockedUntil,
92
+ lastSuccessfulRunAt: parsed.lastSuccessfulRunAt,
93
+ updatedAt: parsed.updatedAt,
94
+ }
95
+ return normalizeScheduleState(
96
+ typeof parsed.environment === "string" ? parsed.environment : undefined,
97
+ state,
98
+ )
99
+ } catch {
100
+ return null
101
+ }
102
+ }
103
+
104
+ function normalizeScheduleState(
105
+ environment: string | undefined,
106
+ state: RawScheduleStateRecord,
107
+ ): ScheduleStateRecord {
108
+ return {
109
+ scheduleId: state.scheduleId,
110
+ ...(typeof state.workflowId === "string" ? { workflowId: state.workflowId } : {}),
111
+ ...(environment !== undefined ? { environment } : {}),
112
+ ...(typeof state.versionId === "string" ? { versionId: state.versionId } : {}),
113
+ lastFireAt: nullableFiniteNumber(state.lastFireAt),
114
+ lastRunId: nullableString(state.lastRunId),
115
+ lastError: nullableString(state.lastError),
116
+ lockedUntil: nullableFiniteNumber(state.lockedUntil),
117
+ lastSuccessfulRunAt: nullableFiniteNumber(state.lastSuccessfulRunAt),
118
+ updatedAt: nullableFiniteNumber(state.updatedAt),
119
+ }
120
+ }
121
+
122
+ function nullableFiniteNumber(value: unknown): number | null {
123
+ return typeof value === "number" && Number.isFinite(value) ? value : null
124
+ }
125
+
126
+ function nullableString(value: unknown): string | null {
127
+ return typeof value === "string" && value.length > 0 ? value : null
128
+ }
129
+
130
+ function isRecord(value: unknown): value is Record<string, unknown> {
131
+ return typeof value === "object" && value !== null
132
+ }
package/src/worker.ts CHANGED
@@ -23,6 +23,7 @@ import { handleIngestEvent } from "./event-handler.js"
23
23
  import { handleGetManifest, handleRegisterManifest } from "./manifest-handler.js"
24
24
  import type { CfManifestStore } from "./manifest-kv-store.js"
25
25
  import { handleGetSchedules } from "./schedule-handler.js"
26
+ import type { CfScheduleStateStore } from "./schedule-state-store.js"
26
27
 
27
28
  const DEFAULT_TENANT_META = {
28
29
  tenantId: "default",
@@ -68,6 +69,11 @@ export interface WorkerFetchDeps<Id = unknown> {
68
69
  * When omitted, the schedules response leaves the flag out.
69
70
  */
70
71
  schedulesEnabledByEnv?: boolean
72
+ /**
73
+ * Optional persisted scheduler execution state read by
74
+ * `/api/schedules/:env`.
75
+ */
76
+ scheduleStateStore?: CfScheduleStateStore
71
77
  /**
72
78
  * Tenant metadata stamped on event-triggered runs. Defaults to
73
79
  * `{ tenantId: "default", projectId: "default", organizationId: "default" }`.
@@ -139,6 +145,7 @@ export async function handleWorkerRequest<Id>(
139
145
  return handleGetSchedules(env, {
140
146
  manifestStore: deps.manifestStore,
141
147
  schedulesEnabledByEnv: deps.schedulesEnabledByEnv,
148
+ scheduleStateStore: deps.scheduleStateStore,
142
149
  now: deps.now,
143
150
  logger: deps.logger,
144
151
  })
@@ -176,7 +183,31 @@ export async function handleWorkerRequest<Id>(
176
183
  headers: { "content-type": "application/json" },
177
184
  body: JSON.stringify({ ...publicPayload, runId }),
178
185
  })
179
- return forwardToRunDO(runId, forward, deps)
186
+ const fireAt = deps.now ? deps.now() : Date.now()
187
+ const scheduleTrigger = getScheduleTrigger(publicPayload)
188
+ try {
189
+ const triggerRes = await forwardToRunDO(runId, forward, deps)
190
+ const runResult = triggerRes.ok ? await safeResponseJson(triggerRes.clone()) : undefined
191
+ const runError = triggerRes.ok
192
+ ? failedRunError(runResult)
193
+ : `do_returned_${triggerRes.status}`
194
+ await recordScheduleDispatch(deps, {
195
+ scheduleTrigger,
196
+ runId: triggerRes.ok ? runId : null,
197
+ fireAt,
198
+ error: runError,
199
+ lastSuccessfulRunAt: completedRunAt(runResult),
200
+ })
201
+ return triggerRes
202
+ } catch (err) {
203
+ await recordScheduleDispatch(deps, {
204
+ scheduleTrigger,
205
+ runId: null,
206
+ fireAt,
207
+ error: errMsg(err),
208
+ })
209
+ throw err
210
+ }
180
211
  }
181
212
 
182
213
  // Everything below operates on a specific runId.
@@ -501,6 +532,108 @@ function sanitizePublicTriggerPayload(payload: Record<string, unknown>): Record<
501
532
  return publicPayload
502
533
  }
503
534
 
535
+ function getScheduleTrigger(payload: Record<string, unknown>): {
536
+ environment: "production" | "preview" | "development"
537
+ scheduleId: string
538
+ } | null {
539
+ const triggeredBy = payload.triggeredBy
540
+ const environment = payload.environment ?? "development"
541
+ if (
542
+ typeof triggeredBy !== "object" ||
543
+ triggeredBy === null ||
544
+ !("kind" in triggeredBy) ||
545
+ triggeredBy.kind !== "schedule" ||
546
+ !("scheduleId" in triggeredBy) ||
547
+ typeof triggeredBy.scheduleId !== "string" ||
548
+ triggeredBy.scheduleId.length === 0 ||
549
+ (environment !== "production" && environment !== "preview" && environment !== "development")
550
+ ) {
551
+ return null
552
+ }
553
+ return {
554
+ environment,
555
+ scheduleId: triggeredBy.scheduleId,
556
+ }
557
+ }
558
+
559
+ async function recordScheduleDispatch<Id>(
560
+ deps: WorkerFetchDeps<Id>,
561
+ args: {
562
+ scheduleTrigger: ReturnType<typeof getScheduleTrigger>
563
+ runId: string | null
564
+ fireAt: number
565
+ error: string | null
566
+ lastSuccessfulRunAt?: number
567
+ },
568
+ ): Promise<void> {
569
+ if (!deps.scheduleStateStore || !args.scheduleTrigger) return
570
+ try {
571
+ const existing = (
572
+ await deps.scheduleStateStore.getStates(args.scheduleTrigger.environment, [
573
+ args.scheduleTrigger.scheduleId,
574
+ ])
575
+ ).get(args.scheduleTrigger.scheduleId)
576
+ await deps.scheduleStateStore.putState(args.scheduleTrigger.environment, {
577
+ ...existing,
578
+ scheduleId: args.scheduleTrigger.scheduleId,
579
+ environment: args.scheduleTrigger.environment,
580
+ lastFireAt: args.fireAt,
581
+ lastRunId: args.runId,
582
+ lastError: args.error,
583
+ ...(args.lastSuccessfulRunAt !== undefined
584
+ ? { lastSuccessfulRunAt: args.lastSuccessfulRunAt }
585
+ : {}),
586
+ updatedAt: deps.now ? deps.now() : Date.now(),
587
+ })
588
+ } catch (err) {
589
+ deps.logger?.("warn", "schedules: cannot persist scheduler dispatch state", {
590
+ environment: args.scheduleTrigger.environment,
591
+ scheduleId: args.scheduleTrigger.scheduleId,
592
+ error: errMsg(err),
593
+ })
594
+ }
595
+ }
596
+
597
+ async function safeResponseJson(response: Response): Promise<unknown> {
598
+ try {
599
+ return await response.json()
600
+ } catch {
601
+ return undefined
602
+ }
603
+ }
604
+
605
+ function completedRunAt(value: unknown): number | undefined {
606
+ if (
607
+ typeof value === "object" &&
608
+ value !== null &&
609
+ "status" in value &&
610
+ value.status === "completed" &&
611
+ "completedAt" in value &&
612
+ typeof value.completedAt === "number" &&
613
+ Number.isFinite(value.completedAt)
614
+ ) {
615
+ return value.completedAt
616
+ }
617
+ return undefined
618
+ }
619
+
620
+ function failedRunError(value: unknown): string | null {
621
+ if (typeof value !== "object" || value === null || !("status" in value)) return null
622
+ const status = value.status
623
+ if (status !== "failed" && status !== "compensation_failed") return null
624
+ const error = "error" in value ? value.error : undefined
625
+ if (
626
+ typeof error === "object" &&
627
+ error !== null &&
628
+ "message" in error &&
629
+ typeof error.message === "string" &&
630
+ error.message.length > 0
631
+ ) {
632
+ return error.message
633
+ }
634
+ return `run_${status}`
635
+ }
636
+
504
637
  function json(status: number, body: unknown): Response {
505
638
  return new Response(JSON.stringify(body), {
506
639
  status,