@voyantjs/workflows-orchestrator-cloudflare 0.85.4 → 0.87.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.
- package/README.md +4 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/schedule-handler.d.ts +18 -0
- package/dist/schedule-handler.d.ts.map +1 -1
- package/dist/schedule-handler.js +54 -15
- package/dist/schedule-state-store.d.ts +22 -0
- package/dist/schedule-state-store.d.ts.map +1 -0
- package/dist/schedule-state-store.js +74 -0
- package/dist/worker.d.ts +6 -0
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +109 -1
- package/package.json +3 -3
- package/src/index.ts +6 -0
- package/src/schedule-handler.ts +85 -18
- package/src/schedule-state-store.ts +132 -0
- package/src/worker.ts +134 -1
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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":"
|
|
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"}
|
package/dist/schedule-handler.js
CHANGED
|
@@ -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,
|
|
4
|
-
//
|
|
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 =
|
|
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 (!
|
|
13
|
+
if (!isAllowedEnvironment(environment)) {
|
|
19
14
|
return json(400, {
|
|
20
15
|
error: "invalid_environment",
|
|
21
|
-
message: `environment must be one of ${
|
|
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
|
|
32
|
-
|
|
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" }`.
|
package/dist/worker.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.87.1",
|
|
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.
|
|
30
|
-
"@voyantjs/workflows": "0.
|
|
29
|
+
"@voyantjs/workflows-orchestrator": "0.87.1",
|
|
30
|
+
"@voyantjs/workflows": "0.87.1"
|
|
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,
|
package/src/schedule-handler.ts
CHANGED
|
@@ -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,
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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 (!
|
|
85
|
+
if (!isAllowedEnvironment(environment)) {
|
|
66
86
|
return json(400, {
|
|
67
87
|
error: "invalid_environment",
|
|
68
|
-
message: `environment must be one of ${
|
|
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
|
|
82
|
-
|
|
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
|
|
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
|
-
|
|
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,
|