@voyantjs/workflows-orchestrator-cloudflare 0.38.0 → 0.39.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 +5 -0
- package/dist/durable-object.d.ts.map +1 -1
- package/dist/durable-object.js +3 -0
- package/dist/types.d.ts +12 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +163 -2
- package/package.json +3 -3
- package/src/durable-object.ts +3 -0
- package/src/types.ts +12 -1
- package/src/worker.ts +210 -3
package/README.md
CHANGED
|
@@ -65,6 +65,7 @@ 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/runs/:id/resume` | Start a new run from a failed parent run. Body: `{ input?, workflowId?, resumeFromStep?, seedResults?, runId?, tags?, triggeredByUserId? }`. |
|
|
68
69
|
| `POST /api/runs/:id/events` | Inject an `EVENT` waitpoint resolution. |
|
|
69
70
|
| `POST /api/runs/:id/signals` | Inject a `SIGNAL` waitpoint resolution. |
|
|
70
71
|
| `POST /api/runs/:id/tokens/:tokenId` | Inject a `MANUAL` (token) waitpoint resolution. |
|
|
@@ -73,6 +74,10 @@ export class WorkflowRunDO implements DurableObject {
|
|
|
73
74
|
Injection bodies are `{ eventType, payload? }` / `{ name, payload? }`
|
|
74
75
|
/ `{ payload? }` respectively.
|
|
75
76
|
|
|
77
|
+
If the parent id on `/api/runs/:id/resume` is not stored in this
|
|
78
|
+
orchestrator, pass `workflowId`, `resumeFromStep`, and `seedResults`
|
|
79
|
+
to resume from an external workflow-runs parent.
|
|
80
|
+
|
|
76
81
|
## Durable Object model
|
|
77
82
|
|
|
78
83
|
One DO per run, keyed by `idFromName(runId)`. The DO's transactional
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"durable-object.d.ts","sourceRoot":"","sources":["../src/durable-object.ts"],"names":[],"mappings":"AAmBA,OAAO,EACL,uBAAuB,EACvB,gBAAgB,EAKhB,KAAK,SAAS,EAEf,MAAM,kCAAkC,CAAA;AAEzC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAA;AAEtD,OAAO,KAAK,EAGV,wBAAwB,EAGzB,MAAM,YAAY,CAAA;AACnB,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,aAAa,CAAA;AAE7D,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,wBAAwB,CAAA;IACjC;;;;;;;;;;;OAWG;IACH,UAAU,EAAE,cAAc,CAAA;IAC1B,aAAa,CAAC,EAAE,0BAA0B,CAAA;IAC1C,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB;AAcD,wBAAsB,0BAA0B,CAC9C,GAAG,EAAE,OAAO,EACZ,IAAI,EAAE,iBAAiB,GACtB,OAAO,CAAC,QAAQ,CAAC,
|
|
1
|
+
{"version":3,"file":"durable-object.d.ts","sourceRoot":"","sources":["../src/durable-object.ts"],"names":[],"mappings":"AAmBA,OAAO,EACL,uBAAuB,EACvB,gBAAgB,EAKhB,KAAK,SAAS,EAEf,MAAM,kCAAkC,CAAA;AAEzC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAA;AAEtD,OAAO,KAAK,EAGV,wBAAwB,EAGzB,MAAM,YAAY,CAAA;AACnB,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,aAAa,CAAA;AAE7D,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,wBAAwB,CAAA;IACjC;;;;;;;;;;;OAWG;IACH,UAAU,EAAE,cAAc,CAAA;IAC1B,aAAa,CAAC,EAAE,0BAA0B,CAAA;IAC1C,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB;AAcD,wBAAsB,0BAA0B,CAC9C,GAAG,EAAE,OAAO,EACZ,IAAI,EAAE,iBAAiB,GACtB,OAAO,CAAC,QAAQ,CAAC,CAkFnB;AAED;;;;;GAKG;AACH,wBAAsB,wBAAwB,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAyCrF;AAyFD,YAAY,EAAE,SAAS,EAAE,CAAA;AAEzB,OAAO,EAAE,uBAAuB,EAAE,gBAAgB,EAAE,CAAA"}
|
package/dist/durable-object.js
CHANGED
|
@@ -51,6 +51,9 @@ export async function handleDurableObjectRequest(req, deps) {
|
|
|
51
51
|
? new Date(payload.delay.wakeAt)
|
|
52
52
|
: payload.delay,
|
|
53
53
|
priority: payload.priority,
|
|
54
|
+
initialJournal: payload.initialJournal,
|
|
55
|
+
initialMetadataAppliedCount: payload.initialMetadataAppliedCount,
|
|
56
|
+
timeoutMs: payload.timeoutMs,
|
|
54
57
|
}, { store, handler, now: deps.now });
|
|
55
58
|
await reconcileAlarm(record, store, deps);
|
|
56
59
|
return json(200, record);
|
package/dist/types.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Duration, EnvironmentName, RunTrigger } from "@voyantjs/workflows";
|
|
2
|
-
import type { WaitpointInjection } from "@voyantjs/workflows-orchestrator";
|
|
2
|
+
import type { JournalSlice, WaitpointInjection } from "@voyantjs/workflows-orchestrator";
|
|
3
3
|
/**
|
|
4
4
|
* Subset of Cloudflare's `DurableObjectStorage` we actually use.
|
|
5
5
|
* Keyed JSON blobs; no transactional guarantees beyond what a single
|
|
@@ -59,6 +59,17 @@ export interface TriggerPayload {
|
|
|
59
59
|
};
|
|
60
60
|
priority?: number;
|
|
61
61
|
triggeredBy?: RunTrigger;
|
|
62
|
+
/**
|
|
63
|
+
* Optional journal seed for failed-step resume / replay flows.
|
|
64
|
+
* Steps already present in the journal are replayed from their
|
|
65
|
+
* stored results, so the workflow starts doing new work at the
|
|
66
|
+
* first unseeded step.
|
|
67
|
+
*/
|
|
68
|
+
initialJournal?: JournalSlice;
|
|
69
|
+
/** Cursor paired with `initialJournal.metadataState` for resume replay dedupe. */
|
|
70
|
+
initialMetadataAppliedCount?: number;
|
|
71
|
+
/** Compute-time budget in ms; copied from parent runs during resume. */
|
|
72
|
+
timeoutMs?: number;
|
|
62
73
|
concurrencyLease?: ConcurrencyLease;
|
|
63
74
|
}
|
|
64
75
|
export interface ConcurrencyLease {
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,QAAQ,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAA;AAChF,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,kCAAkC,CAAA;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,QAAQ,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAA;AAChF,OAAO,KAAK,EAAE,YAAY,EAAE,kBAAkB,EAAE,MAAM,kCAAkC,CAAA;AAExF;;;;;;;;;GASG;AACH,MAAM,WAAW,wBAAwB;IACvC,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC,CAAA;IAC3C,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC5C,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IACrC,IAAI,CAAC,CAAC,EAAE,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,CAAA;IAC/E,8DAA8D;IAC9D,QAAQ,CAAC,IAAI,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;IACnC,4DAA4D;IAC5D,QAAQ,CAAC,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACxC,gCAAgC;IAChC,WAAW,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;CAC9B;AAED,uDAAuD;AACvD,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,SAAS,GAAG,QAAQ,GAAG,QAAQ,GAAG,KAAK,CAAA;IAC3C,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,uCAAuC;AACvC,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,kBAAkB,CAAA;CAC9B;AAED,uBAAuB;AACvB,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,MAAM,CAAA;IAClB,eAAe,EAAE,MAAM,CAAA;IACvB,KAAK,EAAE,OAAO,CAAA;IACd,UAAU,EAAE;QACV,QAAQ,EAAE,MAAM,CAAA;QAChB,SAAS,EAAE,MAAM,CAAA;QACjB,cAAc,EAAE,MAAM,CAAA;QACtB;;;;WAIG;QACH,YAAY,CAAC,EAAE,MAAM,CAAA;KACtB,CAAA;IACD,WAAW,CAAC,EAAE,YAAY,GAAG,SAAS,GAAG,aAAa,CAAA;IACtD,IAAI,CAAC,EAAE,MAAM,EAAE,CAAA;IACf,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,KAAK,CAAC,EAAE,QAAQ,GAAG;QAAE,MAAM,EAAE,MAAM,CAAA;KAAE,CAAA;IACrC,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,WAAW,CAAC,EAAE,UAAU,CAAA;IACxB;;;;;OAKG;IACH,cAAc,CAAC,EAAE,YAAY,CAAA;IAC7B,kFAAkF;IAClF,2BAA2B,CAAC,EAAE,MAAM,CAAA;IACpC,wEAAwE;IACxE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,gBAAgB,CAAC,EAAE,gBAAgB,CAAA;CACpC;AAED,MAAM,WAAW,gBAAgB;IAC/B,WAAW,EAAE,eAAe,CAAA;IAC5B,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;CACd;AAED,sBAAsB;AACtB,MAAM,WAAW,aAAa;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAED;;;GAGG;AACH,MAAM,WAAW,UAAU,CAAC,WAAW,GAAG,OAAO;IAC/C,8FAA8F;IAC9F,eAAe,EAAE,WAAW,CAAA;CAC7B"}
|
package/dist/worker.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"worker.d.ts","sourceRoot":"","sources":["../src/worker.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"worker.d.ts","sourceRoot":"","sources":["../src/worker.ts"],"names":[],"mappings":"AAuBA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AAQ7D;;;;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;;;;OAIG;IACH,aAAa,CAAC,EAAE,eAAe,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,CAoMnB"}
|
package/dist/worker.js
CHANGED
|
@@ -6,12 +6,19 @@
|
|
|
6
6
|
// Routes (all JSON bodies):
|
|
7
7
|
// POST /api/runs → trigger a run
|
|
8
8
|
// GET /api/runs/:id → fetch a run
|
|
9
|
+
// POST /api/runs/:id/resume → start a new run from a failed parent step
|
|
9
10
|
// POST /api/runs/:id/signals → inject a SIGNAL waitpoint
|
|
10
11
|
// POST /api/runs/:id/events → inject an EVENT waitpoint
|
|
11
12
|
// POST /api/runs/:id/tokens/:token → inject a MANUAL (token) waitpoint
|
|
12
13
|
// POST /api/runs/:id/cancel → cancel a run
|
|
14
|
+
import { buildResumeJournal, buildSeededResumeJournal, } from "@voyantjs/workflows-orchestrator";
|
|
13
15
|
import { handleIngestEvent } from "./event-handler.js";
|
|
14
16
|
import { handleGetManifest, handleRegisterManifest } from "./manifest-handler.js";
|
|
17
|
+
const DEFAULT_TENANT_META = {
|
|
18
|
+
tenantId: "default",
|
|
19
|
+
projectId: "default",
|
|
20
|
+
organizationId: "default",
|
|
21
|
+
};
|
|
15
22
|
export async function handleWorkerRequest(req, deps) {
|
|
16
23
|
const url = new URL(req.url);
|
|
17
24
|
if (req.method === "OPTIONS") {
|
|
@@ -79,10 +86,11 @@ export async function handleWorkerRequest(req, deps) {
|
|
|
79
86
|
return json(400, { error: "invalid_json", message: errMsg(err) });
|
|
80
87
|
}
|
|
81
88
|
const runId = typeof payload.runId === "string" ? payload.runId : defaultRunId(deps);
|
|
89
|
+
const publicPayload = sanitizePublicTriggerPayload(payload);
|
|
82
90
|
const forward = new Request(`https://do-internal/trigger`, {
|
|
83
91
|
method: "POST",
|
|
84
92
|
headers: { "content-type": "application/json" },
|
|
85
|
-
body: JSON.stringify({ ...
|
|
93
|
+
body: JSON.stringify({ ...publicPayload, runId }),
|
|
86
94
|
});
|
|
87
95
|
return forwardToRunDO(runId, forward, deps);
|
|
88
96
|
}
|
|
@@ -107,6 +115,76 @@ export async function handleWorkerRequest(req, deps) {
|
|
|
107
115
|
body: JSON.stringify(body),
|
|
108
116
|
}), deps);
|
|
109
117
|
}
|
|
118
|
+
if (req.method === "POST" && tail === "/resume") {
|
|
119
|
+
const body = await safeJson(req);
|
|
120
|
+
if (isErrorBody(body))
|
|
121
|
+
return json(400, body);
|
|
122
|
+
const parsed = parseResumeRunBody(body);
|
|
123
|
+
if ("error" in parsed)
|
|
124
|
+
return json(400, parsed);
|
|
125
|
+
const parent = await fetchRunRecord(runId, deps);
|
|
126
|
+
if ("error" in parent)
|
|
127
|
+
return parent.error;
|
|
128
|
+
const workflowId = parsed.body.workflowId ?? parent.record?.workflowId;
|
|
129
|
+
if (!workflowId) {
|
|
130
|
+
return json(404, {
|
|
131
|
+
error: "resume_failed",
|
|
132
|
+
message: `parent run "${runId}" not found; pass workflowId, resumeFromStep, ` +
|
|
133
|
+
"and seedResults to resume from an external workflow-runs parent",
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
let resumeSeed;
|
|
137
|
+
try {
|
|
138
|
+
resumeSeed = parent.record
|
|
139
|
+
? buildResumeJournal({
|
|
140
|
+
parent: parent.record,
|
|
141
|
+
resumeFromStep: parsed.body.resumeFromStep,
|
|
142
|
+
seedResults: parsed.body.seedResults,
|
|
143
|
+
now: deps.now,
|
|
144
|
+
})
|
|
145
|
+
: buildSeededResumeJournal({
|
|
146
|
+
parentRunId: runId,
|
|
147
|
+
resumeFromStep: requireExternalResumeFromStep(parsed.body.resumeFromStep),
|
|
148
|
+
seedResults: requireExternalSeedResults(parsed.body.seedResults),
|
|
149
|
+
now: deps.now,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
return json(400, { error: "resume_failed", message: errMsg(err) });
|
|
154
|
+
}
|
|
155
|
+
const nextRunId = parsed.body.runId ?? defaultRunId(deps);
|
|
156
|
+
const triggerRes = await forwardToRunDO(nextRunId, new Request("https://do-internal/trigger", {
|
|
157
|
+
method: "POST",
|
|
158
|
+
headers: { "content-type": "application/json" },
|
|
159
|
+
body: JSON.stringify({
|
|
160
|
+
workflowId,
|
|
161
|
+
workflowVersion: parent.record?.workflowVersion ?? "local",
|
|
162
|
+
input: parsed.body.hasInput ? parsed.body.input : parent.record?.input,
|
|
163
|
+
tenantMeta: parent.record?.tenantMeta ?? deps.tenantMeta ?? DEFAULT_TENANT_META,
|
|
164
|
+
environment: parent.record?.environment,
|
|
165
|
+
tags: mergeTags(parent.record?.tags, [
|
|
166
|
+
"resume:true",
|
|
167
|
+
`parentRunId:${parent.record?.id ?? runId}`,
|
|
168
|
+
...(parsed.body.tags ?? []),
|
|
169
|
+
]),
|
|
170
|
+
runId: nextRunId,
|
|
171
|
+
triggeredBy: parsed.body.triggeredByUserId === undefined || parsed.body.triggeredByUserId === null
|
|
172
|
+
? { kind: "api" }
|
|
173
|
+
: { kind: "api", actor: parsed.body.triggeredByUserId },
|
|
174
|
+
timeoutMs: parent.record?.timeoutMs,
|
|
175
|
+
initialJournal: resumeSeed.journal,
|
|
176
|
+
initialMetadataAppliedCount: resumeSeed.metadataAppliedCount,
|
|
177
|
+
}),
|
|
178
|
+
}), deps);
|
|
179
|
+
if (!triggerRes.ok)
|
|
180
|
+
return triggerRes;
|
|
181
|
+
const saved = (await triggerRes.json());
|
|
182
|
+
return json(200, {
|
|
183
|
+
saved,
|
|
184
|
+
parentRunId: parent.record?.id ?? runId,
|
|
185
|
+
resumeFromStep: resumeSeed.resumeFromStep,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
110
188
|
// Waitpoint injections: events, signals, tokens.
|
|
111
189
|
const body = await safeJson(req);
|
|
112
190
|
if (isErrorBody(body))
|
|
@@ -150,6 +228,55 @@ function parseInjection(tail, body) {
|
|
|
150
228
|
}
|
|
151
229
|
return { error: "route_not_found", message: `unknown path suffix ${tail}` };
|
|
152
230
|
}
|
|
231
|
+
function parseResumeRunBody(body) {
|
|
232
|
+
if (!isPlainObject(body)) {
|
|
233
|
+
return { error: "invalid_body", message: "request body must be an object" };
|
|
234
|
+
}
|
|
235
|
+
if (body.resumeFromStep !== undefined && typeof body.resumeFromStep !== "string") {
|
|
236
|
+
return { error: "invalid_body", message: "`resumeFromStep` must be a string when provided" };
|
|
237
|
+
}
|
|
238
|
+
if (body.workflowId !== undefined && typeof body.workflowId !== "string") {
|
|
239
|
+
return { error: "invalid_body", message: "`workflowId` must be a string when provided" };
|
|
240
|
+
}
|
|
241
|
+
if (body.runId !== undefined && typeof body.runId !== "string") {
|
|
242
|
+
return { error: "invalid_body", message: "`runId` must be a string when provided" };
|
|
243
|
+
}
|
|
244
|
+
if (body.triggeredByUserId !== undefined &&
|
|
245
|
+
body.triggeredByUserId !== null &&
|
|
246
|
+
typeof body.triggeredByUserId !== "string") {
|
|
247
|
+
return {
|
|
248
|
+
error: "invalid_body",
|
|
249
|
+
message: "`triggeredByUserId` must be a string or null when provided",
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
if (body.tags !== undefined && !isStringArray(body.tags)) {
|
|
253
|
+
return { error: "invalid_body", message: "`tags` must be an array of strings when provided" };
|
|
254
|
+
}
|
|
255
|
+
if (body.seedResults !== undefined && !isPlainObject(body.seedResults)) {
|
|
256
|
+
return { error: "invalid_body", message: "`seedResults` must be an object when provided" };
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
body: {
|
|
260
|
+
hasInput: Object.hasOwn(body, "input"),
|
|
261
|
+
input: body.input,
|
|
262
|
+
workflowId: body.workflowId,
|
|
263
|
+
resumeFromStep: body.resumeFromStep,
|
|
264
|
+
seedResults: body.seedResults,
|
|
265
|
+
runId: body.runId,
|
|
266
|
+
tags: body.tags,
|
|
267
|
+
triggeredByUserId: body.triggeredByUserId,
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
async function fetchRunRecord(runId, deps) {
|
|
272
|
+
const res = await forwardToRunDO(runId, new Request("https://do-internal/get", { method: "GET" }), deps);
|
|
273
|
+
if (res.status === 404) {
|
|
274
|
+
return {};
|
|
275
|
+
}
|
|
276
|
+
if (!res.ok)
|
|
277
|
+
return { error: res };
|
|
278
|
+
return { record: (await res.json()) };
|
|
279
|
+
}
|
|
153
280
|
async function forwardToRunDO(runId, req, deps) {
|
|
154
281
|
const id = deps.runDO.idFromName(runId);
|
|
155
282
|
const stub = deps.runDO.get(id);
|
|
@@ -179,12 +306,42 @@ async function safeJson(req) {
|
|
|
179
306
|
if (text.length === 0)
|
|
180
307
|
return {};
|
|
181
308
|
try {
|
|
182
|
-
|
|
309
|
+
const parsed = JSON.parse(text);
|
|
310
|
+
if (!isPlainObject(parsed)) {
|
|
311
|
+
return { error: "invalid_body", message: "request body must be an object" };
|
|
312
|
+
}
|
|
313
|
+
return parsed;
|
|
183
314
|
}
|
|
184
315
|
catch (err) {
|
|
185
316
|
return { error: "invalid_json", message: errMsg(err) };
|
|
186
317
|
}
|
|
187
318
|
}
|
|
319
|
+
function isPlainObject(value) {
|
|
320
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
321
|
+
}
|
|
322
|
+
function isStringArray(value) {
|
|
323
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
324
|
+
}
|
|
325
|
+
function mergeTags(...groups) {
|
|
326
|
+
const tags = new Set();
|
|
327
|
+
for (const group of groups) {
|
|
328
|
+
for (const tag of group ?? [])
|
|
329
|
+
tags.add(tag);
|
|
330
|
+
}
|
|
331
|
+
return Array.from(tags);
|
|
332
|
+
}
|
|
333
|
+
function requireExternalResumeFromStep(resumeFromStep) {
|
|
334
|
+
if (!resumeFromStep) {
|
|
335
|
+
throw new Error("resumeFromStep is required when the parent run is not stored by this worker");
|
|
336
|
+
}
|
|
337
|
+
return resumeFromStep;
|
|
338
|
+
}
|
|
339
|
+
function requireExternalSeedResults(seedResults) {
|
|
340
|
+
if (!seedResults) {
|
|
341
|
+
throw new Error("seedResults is required when the parent run is not stored by this worker");
|
|
342
|
+
}
|
|
343
|
+
return seedResults;
|
|
344
|
+
}
|
|
188
345
|
function corsHeaders(methods) {
|
|
189
346
|
return {
|
|
190
347
|
"access-control-allow-origin": "*",
|
|
@@ -192,6 +349,10 @@ function corsHeaders(methods) {
|
|
|
192
349
|
"access-control-allow-headers": "content-type, x-voyant-protocol",
|
|
193
350
|
};
|
|
194
351
|
}
|
|
352
|
+
function sanitizePublicTriggerPayload(payload) {
|
|
353
|
+
const { initialJournal: _initialJournal, initialMetadataAppliedCount: _initialMetadataAppliedCount, timeoutMs: _timeoutMs, ...publicPayload } = payload;
|
|
354
|
+
return publicPayload;
|
|
355
|
+
}
|
|
195
356
|
function json(status, body) {
|
|
196
357
|
return new Response(JSON.stringify(body), {
|
|
197
358
|
status,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voyantjs/workflows-orchestrator-cloudflare",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.39.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.
|
|
30
|
-
"@voyantjs/workflows": "0.
|
|
29
|
+
"@voyantjs/workflows-orchestrator": "0.39.0",
|
|
30
|
+
"@voyantjs/workflows": "0.39.0"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@cloudflare/vitest-pool-workers": "^0.15.1",
|
package/src/durable-object.ts
CHANGED
|
@@ -102,6 +102,9 @@ export async function handleDurableObjectRequest(
|
|
|
102
102
|
? new Date(payload.delay.wakeAt)
|
|
103
103
|
: payload.delay,
|
|
104
104
|
priority: payload.priority,
|
|
105
|
+
initialJournal: payload.initialJournal,
|
|
106
|
+
initialMetadataAppliedCount: payload.initialMetadataAppliedCount,
|
|
107
|
+
timeoutMs: payload.timeoutMs,
|
|
105
108
|
},
|
|
106
109
|
{ store, handler, now: deps.now },
|
|
107
110
|
)
|
package/src/types.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// shape is enough, and tests can pass plain objects.
|
|
4
4
|
|
|
5
5
|
import type { Duration, EnvironmentName, RunTrigger } from "@voyantjs/workflows"
|
|
6
|
-
import type { WaitpointInjection } from "@voyantjs/workflows-orchestrator"
|
|
6
|
+
import type { JournalSlice, WaitpointInjection } from "@voyantjs/workflows-orchestrator"
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Subset of Cloudflare's `DurableObjectStorage` we actually use.
|
|
@@ -62,6 +62,17 @@ export interface TriggerPayload {
|
|
|
62
62
|
delay?: Duration | { wakeAt: number }
|
|
63
63
|
priority?: number
|
|
64
64
|
triggeredBy?: RunTrigger
|
|
65
|
+
/**
|
|
66
|
+
* Optional journal seed for failed-step resume / replay flows.
|
|
67
|
+
* Steps already present in the journal are replayed from their
|
|
68
|
+
* stored results, so the workflow starts doing new work at the
|
|
69
|
+
* first unseeded step.
|
|
70
|
+
*/
|
|
71
|
+
initialJournal?: JournalSlice
|
|
72
|
+
/** Cursor paired with `initialJournal.metadataState` for resume replay dedupe. */
|
|
73
|
+
initialMetadataAppliedCount?: number
|
|
74
|
+
/** Compute-time budget in ms; copied from parent runs during resume. */
|
|
75
|
+
timeoutMs?: number
|
|
65
76
|
concurrencyLease?: ConcurrencyLease
|
|
66
77
|
}
|
|
67
78
|
|
package/src/worker.ts
CHANGED
|
@@ -6,17 +6,29 @@
|
|
|
6
6
|
// Routes (all JSON bodies):
|
|
7
7
|
// POST /api/runs → trigger a run
|
|
8
8
|
// GET /api/runs/:id → fetch a run
|
|
9
|
+
// POST /api/runs/:id/resume → start a new run from a failed parent step
|
|
9
10
|
// POST /api/runs/:id/signals → inject a SIGNAL waitpoint
|
|
10
11
|
// POST /api/runs/:id/events → inject an EVENT waitpoint
|
|
11
12
|
// POST /api/runs/:id/tokens/:token → inject a MANUAL (token) waitpoint
|
|
12
13
|
// POST /api/runs/:id/cancel → cancel a run
|
|
13
14
|
|
|
14
|
-
import
|
|
15
|
+
import {
|
|
16
|
+
buildResumeJournal,
|
|
17
|
+
buildSeededResumeJournal,
|
|
18
|
+
type RunRecord,
|
|
19
|
+
type WaitpointInjection,
|
|
20
|
+
} from "@voyantjs/workflows-orchestrator"
|
|
15
21
|
|
|
16
22
|
import { handleIngestEvent } from "./event-handler.js"
|
|
17
23
|
import { handleGetManifest, handleRegisterManifest } from "./manifest-handler.js"
|
|
18
24
|
import type { CfManifestStore } from "./manifest-kv-store.js"
|
|
19
25
|
|
|
26
|
+
const DEFAULT_TENANT_META = {
|
|
27
|
+
tenantId: "default",
|
|
28
|
+
projectId: "default",
|
|
29
|
+
organizationId: "default",
|
|
30
|
+
}
|
|
31
|
+
|
|
20
32
|
/**
|
|
21
33
|
* Minimal shape of a DO namespace. `idFromName` returns an opaque id;
|
|
22
34
|
* `get(id)` returns a stub with `fetch` (matching the CF DO API).
|
|
@@ -133,10 +145,11 @@ export async function handleWorkerRequest<Id>(
|
|
|
133
145
|
return json(400, { error: "invalid_json", message: errMsg(err) })
|
|
134
146
|
}
|
|
135
147
|
const runId = typeof payload.runId === "string" ? payload.runId : defaultRunId(deps)
|
|
148
|
+
const publicPayload = sanitizePublicTriggerPayload(payload)
|
|
136
149
|
const forward = new Request(`https://do-internal/trigger`, {
|
|
137
150
|
method: "POST",
|
|
138
151
|
headers: { "content-type": "application/json" },
|
|
139
|
-
body: JSON.stringify({ ...
|
|
152
|
+
body: JSON.stringify({ ...publicPayload, runId }),
|
|
140
153
|
})
|
|
141
154
|
return forwardToRunDO(runId, forward, deps)
|
|
142
155
|
}
|
|
@@ -168,6 +181,82 @@ export async function handleWorkerRequest<Id>(
|
|
|
168
181
|
)
|
|
169
182
|
}
|
|
170
183
|
|
|
184
|
+
if (req.method === "POST" && tail === "/resume") {
|
|
185
|
+
const body = await safeJson(req)
|
|
186
|
+
if (isErrorBody(body)) return json(400, body)
|
|
187
|
+
const parsed = parseResumeRunBody(body)
|
|
188
|
+
if ("error" in parsed) return json(400, parsed)
|
|
189
|
+
|
|
190
|
+
const parent = await fetchRunRecord(runId, deps)
|
|
191
|
+
if ("error" in parent) return parent.error
|
|
192
|
+
|
|
193
|
+
const workflowId = parsed.body.workflowId ?? parent.record?.workflowId
|
|
194
|
+
if (!workflowId) {
|
|
195
|
+
return json(404, {
|
|
196
|
+
error: "resume_failed",
|
|
197
|
+
message:
|
|
198
|
+
`parent run "${runId}" not found; pass workflowId, resumeFromStep, ` +
|
|
199
|
+
"and seedResults to resume from an external workflow-runs parent",
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let resumeSeed: ReturnType<typeof buildResumeJournal>
|
|
204
|
+
try {
|
|
205
|
+
resumeSeed = parent.record
|
|
206
|
+
? buildResumeJournal({
|
|
207
|
+
parent: parent.record,
|
|
208
|
+
resumeFromStep: parsed.body.resumeFromStep,
|
|
209
|
+
seedResults: parsed.body.seedResults,
|
|
210
|
+
now: deps.now,
|
|
211
|
+
})
|
|
212
|
+
: buildSeededResumeJournal({
|
|
213
|
+
parentRunId: runId,
|
|
214
|
+
resumeFromStep: requireExternalResumeFromStep(parsed.body.resumeFromStep),
|
|
215
|
+
seedResults: requireExternalSeedResults(parsed.body.seedResults),
|
|
216
|
+
now: deps.now,
|
|
217
|
+
})
|
|
218
|
+
} catch (err) {
|
|
219
|
+
return json(400, { error: "resume_failed", message: errMsg(err) })
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const nextRunId = parsed.body.runId ?? defaultRunId(deps)
|
|
223
|
+
const triggerRes = await forwardToRunDO(
|
|
224
|
+
nextRunId,
|
|
225
|
+
new Request("https://do-internal/trigger", {
|
|
226
|
+
method: "POST",
|
|
227
|
+
headers: { "content-type": "application/json" },
|
|
228
|
+
body: JSON.stringify({
|
|
229
|
+
workflowId,
|
|
230
|
+
workflowVersion: parent.record?.workflowVersion ?? "local",
|
|
231
|
+
input: parsed.body.hasInput ? parsed.body.input : parent.record?.input,
|
|
232
|
+
tenantMeta: parent.record?.tenantMeta ?? deps.tenantMeta ?? DEFAULT_TENANT_META,
|
|
233
|
+
environment: parent.record?.environment,
|
|
234
|
+
tags: mergeTags(parent.record?.tags, [
|
|
235
|
+
"resume:true",
|
|
236
|
+
`parentRunId:${parent.record?.id ?? runId}`,
|
|
237
|
+
...(parsed.body.tags ?? []),
|
|
238
|
+
]),
|
|
239
|
+
runId: nextRunId,
|
|
240
|
+
triggeredBy:
|
|
241
|
+
parsed.body.triggeredByUserId === undefined || parsed.body.triggeredByUserId === null
|
|
242
|
+
? { kind: "api" }
|
|
243
|
+
: { kind: "api", actor: parsed.body.triggeredByUserId },
|
|
244
|
+
timeoutMs: parent.record?.timeoutMs,
|
|
245
|
+
initialJournal: resumeSeed.journal,
|
|
246
|
+
initialMetadataAppliedCount: resumeSeed.metadataAppliedCount,
|
|
247
|
+
}),
|
|
248
|
+
}),
|
|
249
|
+
deps,
|
|
250
|
+
)
|
|
251
|
+
if (!triggerRes.ok) return triggerRes
|
|
252
|
+
const saved = (await triggerRes.json()) as RunRecord
|
|
253
|
+
return json(200, {
|
|
254
|
+
saved,
|
|
255
|
+
parentRunId: parent.record?.id ?? runId,
|
|
256
|
+
resumeFromStep: resumeSeed.resumeFromStep,
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
|
|
171
260
|
// Waitpoint injections: events, signals, tokens.
|
|
172
261
|
const body = await safeJson(req)
|
|
173
262
|
if (isErrorBody(body)) return json(400, body)
|
|
@@ -221,6 +310,78 @@ function parseInjection(
|
|
|
221
310
|
return { error: "route_not_found", message: `unknown path suffix ${tail}` }
|
|
222
311
|
}
|
|
223
312
|
|
|
313
|
+
function parseResumeRunBody(body: Record<string, unknown>):
|
|
314
|
+
| {
|
|
315
|
+
body: {
|
|
316
|
+
hasInput: boolean
|
|
317
|
+
input?: unknown
|
|
318
|
+
workflowId?: string
|
|
319
|
+
resumeFromStep?: string
|
|
320
|
+
seedResults?: Record<string, unknown>
|
|
321
|
+
runId?: string
|
|
322
|
+
tags?: string[]
|
|
323
|
+
triggeredByUserId?: string | null
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
| { error: string; message: string } {
|
|
327
|
+
if (!isPlainObject(body)) {
|
|
328
|
+
return { error: "invalid_body", message: "request body must be an object" }
|
|
329
|
+
}
|
|
330
|
+
if (body.resumeFromStep !== undefined && typeof body.resumeFromStep !== "string") {
|
|
331
|
+
return { error: "invalid_body", message: "`resumeFromStep` must be a string when provided" }
|
|
332
|
+
}
|
|
333
|
+
if (body.workflowId !== undefined && typeof body.workflowId !== "string") {
|
|
334
|
+
return { error: "invalid_body", message: "`workflowId` must be a string when provided" }
|
|
335
|
+
}
|
|
336
|
+
if (body.runId !== undefined && typeof body.runId !== "string") {
|
|
337
|
+
return { error: "invalid_body", message: "`runId` must be a string when provided" }
|
|
338
|
+
}
|
|
339
|
+
if (
|
|
340
|
+
body.triggeredByUserId !== undefined &&
|
|
341
|
+
body.triggeredByUserId !== null &&
|
|
342
|
+
typeof body.triggeredByUserId !== "string"
|
|
343
|
+
) {
|
|
344
|
+
return {
|
|
345
|
+
error: "invalid_body",
|
|
346
|
+
message: "`triggeredByUserId` must be a string or null when provided",
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if (body.tags !== undefined && !isStringArray(body.tags)) {
|
|
350
|
+
return { error: "invalid_body", message: "`tags` must be an array of strings when provided" }
|
|
351
|
+
}
|
|
352
|
+
if (body.seedResults !== undefined && !isPlainObject(body.seedResults)) {
|
|
353
|
+
return { error: "invalid_body", message: "`seedResults` must be an object when provided" }
|
|
354
|
+
}
|
|
355
|
+
return {
|
|
356
|
+
body: {
|
|
357
|
+
hasInput: Object.hasOwn(body, "input"),
|
|
358
|
+
input: body.input,
|
|
359
|
+
workflowId: body.workflowId,
|
|
360
|
+
resumeFromStep: body.resumeFromStep,
|
|
361
|
+
seedResults: body.seedResults as Record<string, unknown> | undefined,
|
|
362
|
+
runId: body.runId,
|
|
363
|
+
tags: body.tags as string[] | undefined,
|
|
364
|
+
triggeredByUserId: body.triggeredByUserId as string | null | undefined,
|
|
365
|
+
},
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function fetchRunRecord<Id>(
|
|
370
|
+
runId: string,
|
|
371
|
+
deps: WorkerFetchDeps<Id>,
|
|
372
|
+
): Promise<{ record?: RunRecord } | { error: Response }> {
|
|
373
|
+
const res = await forwardToRunDO(
|
|
374
|
+
runId,
|
|
375
|
+
new Request("https://do-internal/get", { method: "GET" }),
|
|
376
|
+
deps,
|
|
377
|
+
)
|
|
378
|
+
if (res.status === 404) {
|
|
379
|
+
return {}
|
|
380
|
+
}
|
|
381
|
+
if (!res.ok) return { error: res }
|
|
382
|
+
return { record: (await res.json()) as RunRecord }
|
|
383
|
+
}
|
|
384
|
+
|
|
224
385
|
async function forwardToRunDO<Id>(
|
|
225
386
|
runId: string,
|
|
226
387
|
req: Request,
|
|
@@ -255,12 +416,48 @@ async function safeJson(
|
|
|
255
416
|
const text = await req.text()
|
|
256
417
|
if (text.length === 0) return {}
|
|
257
418
|
try {
|
|
258
|
-
|
|
419
|
+
const parsed = JSON.parse(text) as unknown
|
|
420
|
+
if (!isPlainObject(parsed)) {
|
|
421
|
+
return { error: "invalid_body", message: "request body must be an object" }
|
|
422
|
+
}
|
|
423
|
+
return parsed
|
|
259
424
|
} catch (err) {
|
|
260
425
|
return { error: "invalid_json", message: errMsg(err) }
|
|
261
426
|
}
|
|
262
427
|
}
|
|
263
428
|
|
|
429
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
430
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function isStringArray(value: unknown): value is string[] {
|
|
434
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === "string")
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function mergeTags(...groups: ReadonlyArray<ReadonlyArray<string> | undefined>): string[] {
|
|
438
|
+
const tags = new Set<string>()
|
|
439
|
+
for (const group of groups) {
|
|
440
|
+
for (const tag of group ?? []) tags.add(tag)
|
|
441
|
+
}
|
|
442
|
+
return Array.from(tags)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function requireExternalResumeFromStep(resumeFromStep: string | undefined): string {
|
|
446
|
+
if (!resumeFromStep) {
|
|
447
|
+
throw new Error("resumeFromStep is required when the parent run is not stored by this worker")
|
|
448
|
+
}
|
|
449
|
+
return resumeFromStep
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function requireExternalSeedResults(
|
|
453
|
+
seedResults: Record<string, unknown> | undefined,
|
|
454
|
+
): Record<string, unknown> {
|
|
455
|
+
if (!seedResults) {
|
|
456
|
+
throw new Error("seedResults is required when the parent run is not stored by this worker")
|
|
457
|
+
}
|
|
458
|
+
return seedResults
|
|
459
|
+
}
|
|
460
|
+
|
|
264
461
|
function corsHeaders(methods: string): Record<string, string> {
|
|
265
462
|
return {
|
|
266
463
|
"access-control-allow-origin": "*",
|
|
@@ -269,6 +466,16 @@ function corsHeaders(methods: string): Record<string, string> {
|
|
|
269
466
|
}
|
|
270
467
|
}
|
|
271
468
|
|
|
469
|
+
function sanitizePublicTriggerPayload(payload: Record<string, unknown>): Record<string, unknown> {
|
|
470
|
+
const {
|
|
471
|
+
initialJournal: _initialJournal,
|
|
472
|
+
initialMetadataAppliedCount: _initialMetadataAppliedCount,
|
|
473
|
+
timeoutMs: _timeoutMs,
|
|
474
|
+
...publicPayload
|
|
475
|
+
} = payload
|
|
476
|
+
return publicPayload
|
|
477
|
+
}
|
|
478
|
+
|
|
272
479
|
function json(status: number, body: unknown): Response {
|
|
273
480
|
return new Response(JSON.stringify(body), {
|
|
274
481
|
status,
|