@voyantjs/workflows-orchestrator-cloudflare 0.28.3 → 0.30.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 +23 -11
- package/dist/cloudflare-edge-driver.d.ts +49 -0
- package/dist/cloudflare-edge-driver.d.ts.map +1 -0
- package/dist/cloudflare-edge-driver.js +317 -0
- package/dist/dispatchers.d.ts +87 -0
- package/dist/dispatchers.d.ts.map +1 -0
- package/dist/dispatchers.js +83 -0
- package/dist/do-store.d.ts.map +1 -1
- package/dist/do-store.js +12 -0
- package/dist/durable-object.d.ts +13 -6
- package/dist/durable-object.d.ts.map +1 -1
- package/dist/durable-object.js +13 -4
- package/dist/event-handler.d.ts +23 -0
- package/dist/event-handler.d.ts.map +1 -0
- package/dist/event-handler.js +241 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -6
- package/dist/manifest-handler.d.ts +16 -0
- package/dist/manifest-handler.d.ts.map +1 -0
- package/dist/manifest-handler.js +92 -0
- package/dist/manifest-kv-store.d.ts +59 -0
- package/dist/manifest-kv-store.d.ts.map +1 -0
- package/dist/manifest-kv-store.js +134 -0
- package/dist/types.d.ts +7 -15
- package/dist/types.d.ts.map +1 -1
- package/dist/worker.d.ts +19 -0
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +41 -0
- package/package.json +3 -3
- package/src/cloudflare-edge-driver.ts +435 -0
- package/src/dispatchers.ts +162 -0
- package/src/do-store.ts +13 -0
- package/src/durable-object.ts +30 -9
- package/src/event-handler.ts +302 -0
- package/src/index.ts +32 -8
- package/src/manifest-handler.ts +113 -0
- package/src/manifest-kv-store.ts +186 -0
- package/src/types.ts +7 -19
- package/src/worker.ts +64 -0
- package/dist/dispatch-handler.d.ts +0 -20
- package/dist/dispatch-handler.d.ts.map +0 -1
- package/dist/dispatch-handler.js +0 -31
- package/src/dispatch-handler.ts +0 -51
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// HTTP handler for `POST /api/events` — the synchronous event-ingest
|
|
2
|
+
// endpoint. Loads the registered manifest from KV, runs the pure
|
|
3
|
+
// `routeEvent` from `@voyantjs/workflows-orchestrator`, and forwards
|
|
4
|
+
// each match into the existing `/trigger` DO surface with a derived
|
|
5
|
+
// idempotencyKey.
|
|
6
|
+
//
|
|
7
|
+
// Response shape mirrors `IngestEventResponse` from
|
|
8
|
+
// `@voyantjs/workflows/driver`:
|
|
9
|
+
//
|
|
10
|
+
// { ok: true, eventId, matches: [...] }
|
|
11
|
+
// { ok: false, reason: "manifest_not_registered" | ... }
|
|
12
|
+
//
|
|
13
|
+
// Architecture: docs/architecture/workflows-runtime-architecture.md §15.
|
|
14
|
+
|
|
15
|
+
import { deriveStableEventId } from "@voyantjs/workflows/events"
|
|
16
|
+
import type { WorkflowManifest } from "@voyantjs/workflows/protocol"
|
|
17
|
+
import { routeEvent } from "@voyantjs/workflows-orchestrator"
|
|
18
|
+
|
|
19
|
+
import type { CfManifestStore } from "./manifest-kv-store.js"
|
|
20
|
+
import type { DurableObjectNamespaceLike } from "./worker.js"
|
|
21
|
+
|
|
22
|
+
const ALLOWED_ENVS = new Set(["production", "preview", "development"])
|
|
23
|
+
|
|
24
|
+
export interface EventHandlerDeps<Id = unknown> {
|
|
25
|
+
/** KV-backed manifest store (read-only path here). */
|
|
26
|
+
manifestStore: CfManifestStore
|
|
27
|
+
/** DO namespace used to forward each match to the run DO. */
|
|
28
|
+
runDO: DurableObjectNamespaceLike<Id>
|
|
29
|
+
/** id generator for new triggers; defaults to `run_<random>`. */
|
|
30
|
+
idGenerator?: () => string
|
|
31
|
+
/** Injectable clock. */
|
|
32
|
+
now?: () => number
|
|
33
|
+
/** Tenant metadata stamped on every triggered run. */
|
|
34
|
+
tenantMeta?: {
|
|
35
|
+
tenantId: string
|
|
36
|
+
projectId: string
|
|
37
|
+
organizationId: string
|
|
38
|
+
tenantScript?: string
|
|
39
|
+
}
|
|
40
|
+
/** Optional logger. */
|
|
41
|
+
logger?: (level: "info" | "warn" | "error", msg: string, data?: object) => void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const DEFAULT_TENANT_META = {
|
|
45
|
+
tenantId: "default",
|
|
46
|
+
projectId: "default",
|
|
47
|
+
organizationId: "default",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface IngestEnvelope {
|
|
51
|
+
name: string
|
|
52
|
+
data: unknown
|
|
53
|
+
metadata?: Record<string, unknown> & { eventId?: string }
|
|
54
|
+
emittedAt: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface IngestRequestBody {
|
|
58
|
+
environment: string
|
|
59
|
+
envelope: IngestEnvelope
|
|
60
|
+
idempotencyKey?: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function handleIngestEvent<Id>(
|
|
64
|
+
req: Request,
|
|
65
|
+
deps: EventHandlerDeps<Id>,
|
|
66
|
+
): Promise<Response> {
|
|
67
|
+
// Body parse + validate.
|
|
68
|
+
let raw: unknown
|
|
69
|
+
try {
|
|
70
|
+
raw = await req.json()
|
|
71
|
+
} catch (err) {
|
|
72
|
+
return json(400, {
|
|
73
|
+
error: "invalid_json",
|
|
74
|
+
message: err instanceof Error ? err.message : String(err),
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
const validation = validateBody(raw)
|
|
78
|
+
if (!validation.ok) return json(400, validation.error)
|
|
79
|
+
const body = validation.body
|
|
80
|
+
|
|
81
|
+
// Manifest lookup.
|
|
82
|
+
const manifestEnvelope = await deps.manifestStore.getCurrent(body.environment)
|
|
83
|
+
if (!manifestEnvelope) {
|
|
84
|
+
return json(200, {
|
|
85
|
+
ok: false,
|
|
86
|
+
reason: "manifest_not_registered",
|
|
87
|
+
message: `No manifest is registered for environment "${body.environment}".`,
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
const manifest = manifestEnvelope.manifest as unknown as WorkflowManifest
|
|
91
|
+
|
|
92
|
+
// Event id derivation — use the caller-stamped one when present, fall
|
|
93
|
+
// back to a content-derived id so external callers without a forwarder
|
|
94
|
+
// still get sensible idempotency.
|
|
95
|
+
const eventId = body.envelope.metadata?.eventId ?? (await deriveStableEventId(body.envelope))
|
|
96
|
+
|
|
97
|
+
// Route through the manifest's filters.
|
|
98
|
+
const routed = routeEvent({
|
|
99
|
+
manifest,
|
|
100
|
+
envelope: {
|
|
101
|
+
name: body.envelope.name,
|
|
102
|
+
data: body.envelope.data,
|
|
103
|
+
metadata: body.envelope.metadata,
|
|
104
|
+
emittedAt: body.envelope.emittedAt,
|
|
105
|
+
},
|
|
106
|
+
eventId,
|
|
107
|
+
idempotencyOverride: body.idempotencyKey,
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// Forward each match into the existing /trigger DO route.
|
|
111
|
+
const matches: unknown[] = []
|
|
112
|
+
let anyTriggered = false
|
|
113
|
+
let anyFailed = false
|
|
114
|
+
const tenantMeta = deps.tenantMeta ?? DEFAULT_TENANT_META
|
|
115
|
+
|
|
116
|
+
for (const entry of routed) {
|
|
117
|
+
if (entry.status === "skipped") {
|
|
118
|
+
matches.push({
|
|
119
|
+
filterId: entry.filterId,
|
|
120
|
+
status: "skipped",
|
|
121
|
+
reason: entry.reason,
|
|
122
|
+
details: entry.details,
|
|
123
|
+
})
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const runId = `idem-${entry.targetWorkflowId}-${entry.idempotencyKey}`
|
|
128
|
+
const triggerPayload = {
|
|
129
|
+
runId,
|
|
130
|
+
workflowId: entry.targetWorkflowId,
|
|
131
|
+
workflowVersion: "v1",
|
|
132
|
+
input: entry.input,
|
|
133
|
+
tenantMeta,
|
|
134
|
+
environment: body.environment,
|
|
135
|
+
idempotencyKey: entry.idempotencyKey,
|
|
136
|
+
triggeredBy: {
|
|
137
|
+
kind: "event" as const,
|
|
138
|
+
eventId,
|
|
139
|
+
eventType: body.envelope.name,
|
|
140
|
+
filterId: entry.filterId,
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const forward = new Request("https://do-internal/trigger", {
|
|
146
|
+
method: "POST",
|
|
147
|
+
headers: { "content-type": "application/json" },
|
|
148
|
+
body: JSON.stringify(triggerPayload),
|
|
149
|
+
})
|
|
150
|
+
const id = deps.runDO.idFromName(runId)
|
|
151
|
+
const stub = deps.runDO.get(id)
|
|
152
|
+
const resp = await stub.fetch(forward)
|
|
153
|
+
if (resp.status >= 200 && resp.status < 300) {
|
|
154
|
+
matches.push({
|
|
155
|
+
filterId: entry.filterId,
|
|
156
|
+
targetWorkflowId: entry.targetWorkflowId,
|
|
157
|
+
runId,
|
|
158
|
+
idempotencyKey: entry.idempotencyKey,
|
|
159
|
+
status: "queued",
|
|
160
|
+
})
|
|
161
|
+
anyTriggered = true
|
|
162
|
+
} else {
|
|
163
|
+
const errBody = await safeReadText(resp)
|
|
164
|
+
deps.logger?.("error", "trigger DO failed", {
|
|
165
|
+
status: resp.status,
|
|
166
|
+
body: errBody.slice(0, 256),
|
|
167
|
+
})
|
|
168
|
+
matches.push({
|
|
169
|
+
filterId: entry.filterId,
|
|
170
|
+
targetWorkflowId: entry.targetWorkflowId,
|
|
171
|
+
status: "error",
|
|
172
|
+
reason: `do_returned_${resp.status}`,
|
|
173
|
+
})
|
|
174
|
+
anyFailed = true
|
|
175
|
+
}
|
|
176
|
+
} catch (err) {
|
|
177
|
+
deps.logger?.("error", "trigger forward threw", {
|
|
178
|
+
error: err instanceof Error ? err.message : String(err),
|
|
179
|
+
})
|
|
180
|
+
matches.push({
|
|
181
|
+
filterId: entry.filterId,
|
|
182
|
+
targetWorkflowId: entry.targetWorkflowId,
|
|
183
|
+
status: "error",
|
|
184
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
185
|
+
})
|
|
186
|
+
anyFailed = true
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (matches.length > 0 && !anyTriggered && anyFailed) {
|
|
191
|
+
return json(502, {
|
|
192
|
+
ok: false,
|
|
193
|
+
reason: "trigger_failed_for_all_matches",
|
|
194
|
+
message: "every matched filter failed to trigger",
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return json(200, { ok: true, eventId, matches })
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---- Validation ----
|
|
202
|
+
|
|
203
|
+
function validateBody(
|
|
204
|
+
raw: unknown,
|
|
205
|
+
):
|
|
206
|
+
| { ok: true; body: IngestRequestBody }
|
|
207
|
+
| { ok: false; error: { error: string; message: string } } {
|
|
208
|
+
if (typeof raw !== "object" || raw === null) {
|
|
209
|
+
return { ok: false, error: { error: "invalid_body", message: "expected JSON object" } }
|
|
210
|
+
}
|
|
211
|
+
const r = raw as Record<string, unknown>
|
|
212
|
+
|
|
213
|
+
if (typeof r.environment !== "string" || !ALLOWED_ENVS.has(r.environment)) {
|
|
214
|
+
return {
|
|
215
|
+
ok: false,
|
|
216
|
+
error: {
|
|
217
|
+
error: "invalid_body",
|
|
218
|
+
message: `"environment" must be one of ${[...ALLOWED_ENVS].join(", ")}`,
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (typeof r.envelope !== "object" || r.envelope === null) {
|
|
223
|
+
return {
|
|
224
|
+
ok: false,
|
|
225
|
+
error: { error: "invalid_body", message: '"envelope" must be an object' },
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const envelope = r.envelope as Record<string, unknown>
|
|
229
|
+
if (typeof envelope.name !== "string" || envelope.name.length === 0) {
|
|
230
|
+
return {
|
|
231
|
+
ok: false,
|
|
232
|
+
error: { error: "invalid_body", message: '"envelope.name" must be a non-empty string' },
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (typeof envelope.emittedAt !== "string" || envelope.emittedAt.length === 0) {
|
|
236
|
+
return {
|
|
237
|
+
ok: false,
|
|
238
|
+
error: {
|
|
239
|
+
error: "invalid_body",
|
|
240
|
+
message: '"envelope.emittedAt" must be an ISO timestamp string',
|
|
241
|
+
},
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (
|
|
245
|
+
envelope.metadata !== undefined &&
|
|
246
|
+
(typeof envelope.metadata !== "object" || envelope.metadata === null)
|
|
247
|
+
) {
|
|
248
|
+
return {
|
|
249
|
+
ok: false,
|
|
250
|
+
error: {
|
|
251
|
+
error: "invalid_body",
|
|
252
|
+
message: '"envelope.metadata" must be an object when supplied',
|
|
253
|
+
},
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (r.idempotencyKey !== undefined && typeof r.idempotencyKey !== "string") {
|
|
257
|
+
return {
|
|
258
|
+
ok: false,
|
|
259
|
+
error: { error: "invalid_body", message: '"idempotencyKey" must be a string when supplied' },
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
ok: true,
|
|
264
|
+
body: {
|
|
265
|
+
environment: r.environment,
|
|
266
|
+
envelope: {
|
|
267
|
+
name: envelope.name,
|
|
268
|
+
data: envelope.data,
|
|
269
|
+
metadata: envelope.metadata as Record<string, unknown> | undefined,
|
|
270
|
+
emittedAt: envelope.emittedAt,
|
|
271
|
+
},
|
|
272
|
+
idempotencyKey: r.idempotencyKey as string | undefined,
|
|
273
|
+
},
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ---- Internal helpers ----
|
|
278
|
+
|
|
279
|
+
// Fallback id derivation lives in `@voyantjs/workflows/events`'s
|
|
280
|
+
// `deriveStableEventId` and is used inline above — content-derived so
|
|
281
|
+
// external callers (HTTP retries, third-party webhooks) dedupe naturally
|
|
282
|
+
// across re-deliveries (architecture doc §15.2).
|
|
283
|
+
|
|
284
|
+
async function safeReadText(resp: Response): Promise<string> {
|
|
285
|
+
try {
|
|
286
|
+
return await resp.text()
|
|
287
|
+
} catch {
|
|
288
|
+
return ""
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function json(status: number, body: unknown): Response {
|
|
293
|
+
return new Response(JSON.stringify(body), {
|
|
294
|
+
status,
|
|
295
|
+
headers: {
|
|
296
|
+
"content-type": "application/json; charset=utf-8",
|
|
297
|
+
"access-control-allow-origin": "*",
|
|
298
|
+
"access-control-allow-methods": "GET,POST,OPTIONS",
|
|
299
|
+
"access-control-allow-headers": "content-type, x-voyant-protocol",
|
|
300
|
+
},
|
|
301
|
+
})
|
|
302
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// @voyantjs/workflows-orchestrator-cloudflare
|
|
2
2
|
//
|
|
3
3
|
// Cloudflare Worker + Durable Object adapter for @voyantjs/workflows-orchestrator.
|
|
4
|
-
// Composes the protocol-agnostic state machine
|
|
5
|
-
//
|
|
4
|
+
// Composes the protocol-agnostic state machine with a DO-backed run
|
|
5
|
+
// store and a pluggable step dispatcher (`StepDispatcher`).
|
|
6
6
|
//
|
|
7
7
|
// Typical wrangler.jsonc layout:
|
|
8
8
|
//
|
|
@@ -15,14 +15,20 @@
|
|
|
15
15
|
// { "name": "WORKFLOW_RUN_DO", "class_name": "WorkflowRunDO" }
|
|
16
16
|
// ]
|
|
17
17
|
// },
|
|
18
|
-
// "dispatch_namespaces": [
|
|
19
|
-
// { "binding": "DISPATCHER", "namespace": "voyant-tenants" }
|
|
20
|
-
// ],
|
|
21
18
|
// "migrations": [
|
|
22
19
|
// { "tag": "v1", "new_sqlite_classes": ["WorkflowRunDO"] }
|
|
23
20
|
// ]
|
|
24
21
|
// }
|
|
25
22
|
//
|
|
23
|
+
// Pick a dispatcher in your DO's `deps()` based on where workflow
|
|
24
|
+
// code lives:
|
|
25
|
+
// * createInlineDispatcher — same Worker as the orchestrator
|
|
26
|
+
// * createServiceBindingDispatcher — sibling Worker via service binding
|
|
27
|
+
// * createHttpDispatcher — arbitrary HTTP endpoint
|
|
28
|
+
//
|
|
29
|
+
// Hosted multi-tenant providers implement custom dispatchers in their
|
|
30
|
+
// own deployment code.
|
|
31
|
+
//
|
|
26
32
|
// See docs/runtime-protocol.md §2 and docs/design.md §6 for the
|
|
27
33
|
// design this adapter implements.
|
|
28
34
|
|
|
@@ -33,15 +39,33 @@ export {
|
|
|
33
39
|
createCfContainerStepRunner,
|
|
34
40
|
} from "./cf-container-runner.js"
|
|
35
41
|
export {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
} from "./
|
|
42
|
+
type CloudflareEdgeDriverOptions,
|
|
43
|
+
createCloudflareEdgeDriver,
|
|
44
|
+
} from "./cloudflare-edge-driver.js"
|
|
45
|
+
export {
|
|
46
|
+
createHttpDispatcher,
|
|
47
|
+
createInlineDispatcher,
|
|
48
|
+
createServiceBindingDispatcher,
|
|
49
|
+
type HttpDispatcherOptions,
|
|
50
|
+
type ServiceBindingDispatcherOptions,
|
|
51
|
+
type ServiceBindingLike,
|
|
52
|
+
type StepDispatcher,
|
|
53
|
+
type StepDispatcherContext,
|
|
54
|
+
} from "./dispatchers.js"
|
|
39
55
|
export { createDurableObjectRunStore } from "./do-store.js"
|
|
40
56
|
export {
|
|
41
57
|
type DurableObjectDeps,
|
|
42
58
|
handleDurableObjectAlarm,
|
|
43
59
|
handleDurableObjectRequest,
|
|
44
60
|
} from "./durable-object.js"
|
|
61
|
+
export {
|
|
62
|
+
type CfManifestEnvelope,
|
|
63
|
+
type CfManifestStore,
|
|
64
|
+
type CreateKvManifestStoreOptions,
|
|
65
|
+
createInMemoryKv,
|
|
66
|
+
createKvManifestStore,
|
|
67
|
+
type KvNamespaceLike,
|
|
68
|
+
} from "./manifest-kv-store.js"
|
|
45
69
|
export {
|
|
46
70
|
createR2Presigner,
|
|
47
71
|
type PresignArgs,
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// HTTP handlers for `/api/manifests*`. Mounted by the worker before the
|
|
2
|
+
// existing `/api/runs/*` routes; both share the same auth.
|
|
3
|
+
//
|
|
4
|
+
// POST /api/manifests { environment, manifest } → { ok, versionId }
|
|
5
|
+
// GET /api/manifests/:env → manifest | 404
|
|
6
|
+
//
|
|
7
|
+
// Architecture: docs/architecture/workflows-runtime-architecture.md §8.2.
|
|
8
|
+
|
|
9
|
+
import type { CfManifestStore } from "./manifest-kv-store.js"
|
|
10
|
+
|
|
11
|
+
const ALLOWED_ENVS = new Set(["production", "preview", "development"])
|
|
12
|
+
|
|
13
|
+
export interface ManifestHandlerDeps {
|
|
14
|
+
manifestStore: CfManifestStore
|
|
15
|
+
logger?: (level: "info" | "warn" | "error", msg: string, data?: object) => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Handle `POST /api/manifests`. Body: `{ environment, manifest }`.
|
|
20
|
+
* `manifest.versionId` is the registered key. Returns `{ ok: true, versionId }`.
|
|
21
|
+
*/
|
|
22
|
+
export async function handleRegisterManifest(
|
|
23
|
+
req: Request,
|
|
24
|
+
deps: ManifestHandlerDeps,
|
|
25
|
+
): Promise<Response> {
|
|
26
|
+
let body: unknown
|
|
27
|
+
try {
|
|
28
|
+
body = await req.json()
|
|
29
|
+
} catch (err) {
|
|
30
|
+
return json(400, {
|
|
31
|
+
error: "invalid_json",
|
|
32
|
+
message: err instanceof Error ? err.message : String(err),
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
if (typeof body !== "object" || body === null) {
|
|
36
|
+
return json(400, { error: "invalid_body", message: "expected JSON object" })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const { environment, manifest } = body as {
|
|
40
|
+
environment?: unknown
|
|
41
|
+
manifest?: unknown
|
|
42
|
+
}
|
|
43
|
+
if (typeof environment !== "string" || !ALLOWED_ENVS.has(environment)) {
|
|
44
|
+
return json(400, {
|
|
45
|
+
error: "invalid_body",
|
|
46
|
+
message: `"environment" must be one of ${[...ALLOWED_ENVS].join(", ")}`,
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
if (typeof manifest !== "object" || manifest === null) {
|
|
50
|
+
return json(400, { error: "invalid_body", message: '"manifest" must be an object' })
|
|
51
|
+
}
|
|
52
|
+
const versionId = (manifest as { versionId?: unknown }).versionId
|
|
53
|
+
if (typeof versionId !== "string" || versionId.length === 0) {
|
|
54
|
+
return json(400, {
|
|
55
|
+
error: "invalid_body",
|
|
56
|
+
message: '"manifest.versionId" must be a non-empty string',
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const result = await deps.manifestStore.registerManifest({
|
|
62
|
+
environment,
|
|
63
|
+
versionId,
|
|
64
|
+
manifest: manifest as Record<string, unknown>,
|
|
65
|
+
})
|
|
66
|
+
return json(200, { ok: true, versionId: result.versionId })
|
|
67
|
+
} catch (err) {
|
|
68
|
+
deps.logger?.("error", "manifest registration failed", {
|
|
69
|
+
environment,
|
|
70
|
+
versionId,
|
|
71
|
+
error: err instanceof Error ? err.message : String(err),
|
|
72
|
+
})
|
|
73
|
+
return json(500, {
|
|
74
|
+
error: "register_failed",
|
|
75
|
+
message: err instanceof Error ? err.message : String(err),
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Handle `GET /api/manifests/:env`. Returns the current manifest envelope
|
|
82
|
+
* or 404 if no manifest is registered.
|
|
83
|
+
*/
|
|
84
|
+
export async function handleGetManifest(
|
|
85
|
+
environment: string,
|
|
86
|
+
deps: ManifestHandlerDeps,
|
|
87
|
+
): Promise<Response> {
|
|
88
|
+
if (!ALLOWED_ENVS.has(environment)) {
|
|
89
|
+
return json(400, {
|
|
90
|
+
error: "invalid_environment",
|
|
91
|
+
message: `environment must be one of ${[...ALLOWED_ENVS].join(", ")}`,
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
const envelope = await deps.manifestStore.getCurrent(environment)
|
|
95
|
+
if (!envelope) {
|
|
96
|
+
return json(404, { error: "not_found", environment })
|
|
97
|
+
}
|
|
98
|
+
return json(200, envelope)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---- Internal ----
|
|
102
|
+
|
|
103
|
+
function json(status: number, body: unknown): Response {
|
|
104
|
+
return new Response(JSON.stringify(body), {
|
|
105
|
+
status,
|
|
106
|
+
headers: {
|
|
107
|
+
"content-type": "application/json; charset=utf-8",
|
|
108
|
+
"access-control-allow-origin": "*",
|
|
109
|
+
"access-control-allow-methods": "GET,POST,OPTIONS",
|
|
110
|
+
"access-control-allow-headers": "content-type, x-voyant-protocol",
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// KV-backed manifest store for the Cloudflare orchestrator.
|
|
2
|
+
//
|
|
3
|
+
// Mirrors the Mode 2 ManifestStore contract from
|
|
4
|
+
// `@voyantjs/workflows-orchestrator-node`'s `createPostgresManifestStore`,
|
|
5
|
+
// just against KV instead of Postgres. Both are consumed by their
|
|
6
|
+
// respective driver factories — the orchestrator's `WorkflowDriver`
|
|
7
|
+
// shape is identical.
|
|
8
|
+
//
|
|
9
|
+
// Layout in KV:
|
|
10
|
+
//
|
|
11
|
+
// manifest:<environment>:<versionId> → JSON-serialized manifest
|
|
12
|
+
// manifest:<environment>:current → versionId of the active manifest
|
|
13
|
+
//
|
|
14
|
+
// Idempotent: same `(environment, versionId)` overwrite is fine.
|
|
15
|
+
// Latest N versions retained via `pruneToVersions(env, n)`. KV is
|
|
16
|
+
// eventually consistent (~60s globally), which is acceptable for the
|
|
17
|
+
// manifest read path (manifests change at deploy boundaries, not per
|
|
18
|
+
// event).
|
|
19
|
+
|
|
20
|
+
// ---- Public types ----
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Structural view of a `WorkflowManifest` envelope. Mirrors the shape
|
|
24
|
+
* `@voyantjs/workflows-orchestrator-node`'s manifest store uses, declared
|
|
25
|
+
* locally so this package stays free of the Mode 2 dep.
|
|
26
|
+
*/
|
|
27
|
+
export interface CfManifestEnvelope {
|
|
28
|
+
environment: string
|
|
29
|
+
versionId: string
|
|
30
|
+
manifest: Record<string, unknown>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CfManifestStore {
|
|
34
|
+
registerManifest(envelope: CfManifestEnvelope): Promise<{ versionId: string }>
|
|
35
|
+
getCurrent(environment: string): Promise<CfManifestEnvelope | null>
|
|
36
|
+
pruneToVersions(environment: string, keep: number): Promise<{ deleted: number }>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Subset of the CF KV namespace API we need. Declared structurally so
|
|
41
|
+
* tests can pass an in-memory fake without depending on
|
|
42
|
+
* `@cloudflare/workers-types`.
|
|
43
|
+
*/
|
|
44
|
+
export interface KvNamespaceLike {
|
|
45
|
+
get(key: string): Promise<string | null>
|
|
46
|
+
put(key: string, value: string, opts?: { metadata?: unknown }): Promise<void>
|
|
47
|
+
delete(key: string): Promise<void>
|
|
48
|
+
list(opts?: { prefix?: string; limit?: number; cursor?: string }): Promise<{
|
|
49
|
+
keys: Array<{ name: string; metadata?: unknown }>
|
|
50
|
+
list_complete?: boolean
|
|
51
|
+
cursor?: string
|
|
52
|
+
}>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface CreateKvManifestStoreOptions {
|
|
56
|
+
/** KV namespace binding from the worker's env. */
|
|
57
|
+
kv: KvNamespaceLike
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---- Public factory ----
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build a KV-backed `CfManifestStore`. Stateless — every call hits KV.
|
|
64
|
+
*/
|
|
65
|
+
export function createKvManifestStore(opts: CreateKvManifestStoreOptions): CfManifestStore {
|
|
66
|
+
const kv = opts.kv
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
async registerManifest(envelope) {
|
|
70
|
+
const versionKey = manifestVersionKey(envelope.environment, envelope.versionId)
|
|
71
|
+
const currentKey = manifestCurrentKey(envelope.environment)
|
|
72
|
+
|
|
73
|
+
// Idempotent overwrite — same body produces the same byte content
|
|
74
|
+
// because manifests are content-addressed (versionId derives from
|
|
75
|
+
// a sha256 of the canonicalized manifest in the SDK).
|
|
76
|
+
await kv.put(versionKey, JSON.stringify(envelope.manifest))
|
|
77
|
+
await kv.put(currentKey, envelope.versionId)
|
|
78
|
+
|
|
79
|
+
return { versionId: envelope.versionId }
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
async getCurrent(environment) {
|
|
83
|
+
const currentKey = manifestCurrentKey(environment)
|
|
84
|
+
const versionId = await kv.get(currentKey)
|
|
85
|
+
if (!versionId) return null
|
|
86
|
+
|
|
87
|
+
const versionKey = manifestVersionKey(environment, versionId)
|
|
88
|
+
const raw = await kv.get(versionKey)
|
|
89
|
+
if (!raw) return null
|
|
90
|
+
|
|
91
|
+
let parsed: Record<string, unknown>
|
|
92
|
+
try {
|
|
93
|
+
parsed = JSON.parse(raw) as Record<string, unknown>
|
|
94
|
+
} catch {
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
environment,
|
|
99
|
+
versionId,
|
|
100
|
+
manifest: parsed,
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
async pruneToVersions(environment, keep) {
|
|
105
|
+
if (keep < 1) {
|
|
106
|
+
throw new Error(`pruneToVersions: keep must be >= 1, got ${keep}`)
|
|
107
|
+
}
|
|
108
|
+
// List every version key for this environment; sort so we can drop
|
|
109
|
+
// older entries. Lexicographic sort is fine because the SDK's
|
|
110
|
+
// versionId is a hex string of the same length, and KV's natural
|
|
111
|
+
// order is also lexicographic. For deterministic semantics we
|
|
112
|
+
// additionally fetch the `current` pointer and always keep that.
|
|
113
|
+
const prefix = `manifest:${environment}:`
|
|
114
|
+
const list = await kv.list({ prefix, limit: 1000 })
|
|
115
|
+
const versionKeys = list.keys.map((k) => k.name).filter((name) => !name.endsWith(":current"))
|
|
116
|
+
|
|
117
|
+
// Sort newest-first (lexicographic descending).
|
|
118
|
+
versionKeys.sort((a, b) => (a < b ? 1 : a > b ? -1 : 0))
|
|
119
|
+
|
|
120
|
+
const currentVersion = await kv.get(manifestCurrentKey(environment))
|
|
121
|
+
const currentKey = currentVersion
|
|
122
|
+
? manifestVersionKey(environment, currentVersion)
|
|
123
|
+
: undefined
|
|
124
|
+
|
|
125
|
+
const keepers = new Set<string>()
|
|
126
|
+
if (currentKey) keepers.add(currentKey)
|
|
127
|
+
for (const k of versionKeys) {
|
|
128
|
+
if (keepers.size >= keep) break
|
|
129
|
+
keepers.add(k)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let deleted = 0
|
|
133
|
+
for (const k of versionKeys) {
|
|
134
|
+
if (keepers.has(k)) continue
|
|
135
|
+
await kv.delete(k)
|
|
136
|
+
deleted++
|
|
137
|
+
}
|
|
138
|
+
return { deleted }
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---- Key helpers ----
|
|
144
|
+
|
|
145
|
+
function manifestVersionKey(environment: string, versionId: string): string {
|
|
146
|
+
return `manifest:${environment}:${versionId}`
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function manifestCurrentKey(environment: string): string {
|
|
150
|
+
return `manifest:${environment}:current`
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---- In-memory KV fake (test-only) ----
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Tiny in-memory implementation of `KvNamespaceLike` for tests + the CF
|
|
157
|
+
* compliance suite run that doesn't go through wrangler. Mirrors the
|
|
158
|
+
* subset of CF KV semantics we use (list returns matching prefix in
|
|
159
|
+
* lexicographic order; get returns null for missing keys).
|
|
160
|
+
*/
|
|
161
|
+
export function createInMemoryKv(): KvNamespaceLike {
|
|
162
|
+
const map = new Map<string, string>()
|
|
163
|
+
return {
|
|
164
|
+
async get(key) {
|
|
165
|
+
return map.has(key) ? (map.get(key) as string) : null
|
|
166
|
+
},
|
|
167
|
+
async put(key, value) {
|
|
168
|
+
map.set(key, value)
|
|
169
|
+
},
|
|
170
|
+
async delete(key) {
|
|
171
|
+
map.delete(key)
|
|
172
|
+
},
|
|
173
|
+
async list(opts) {
|
|
174
|
+
const prefix = opts?.prefix ?? ""
|
|
175
|
+
const limit = opts?.limit ?? 1000
|
|
176
|
+
const matching = [...map.keys()]
|
|
177
|
+
.filter((k) => k.startsWith(prefix))
|
|
178
|
+
.sort()
|
|
179
|
+
.slice(0, limit)
|
|
180
|
+
return {
|
|
181
|
+
keys: matching.map((name) => ({ name })),
|
|
182
|
+
list_complete: true,
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
}
|
|
186
|
+
}
|