@voyantjs/workflows 0.28.3 → 0.29.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.
Files changed (59) hide show
  1. package/dist/driver.d.ts +237 -0
  2. package/dist/driver.d.ts.map +1 -0
  3. package/dist/driver.js +53 -0
  4. package/dist/events/compile.d.ts +34 -0
  5. package/dist/events/compile.d.ts.map +1 -0
  6. package/dist/events/compile.js +204 -0
  7. package/dist/events/index.d.ts +8 -0
  8. package/dist/events/index.d.ts.map +1 -0
  9. package/dist/events/index.js +11 -0
  10. package/dist/events/input-mapper.d.ts +24 -0
  11. package/dist/events/input-mapper.d.ts.map +1 -0
  12. package/dist/events/input-mapper.js +169 -0
  13. package/dist/events/manifest-builder.d.ts +32 -0
  14. package/dist/events/manifest-builder.d.ts.map +1 -0
  15. package/dist/events/manifest-builder.js +66 -0
  16. package/dist/events/payload-hash.d.ts +46 -0
  17. package/dist/events/payload-hash.d.ts.map +1 -0
  18. package/dist/events/payload-hash.js +98 -0
  19. package/dist/events/predicate.d.ts +77 -0
  20. package/dist/events/predicate.d.ts.map +1 -0
  21. package/dist/events/predicate.js +347 -0
  22. package/dist/events/registry.d.ts +37 -0
  23. package/dist/events/registry.d.ts.map +1 -0
  24. package/dist/events/registry.js +47 -0
  25. package/dist/handler/index.d.ts +8 -0
  26. package/dist/handler/index.d.ts.map +1 -1
  27. package/dist/handler/index.js +1 -0
  28. package/dist/http-ingest.d.ts +54 -0
  29. package/dist/http-ingest.d.ts.map +1 -0
  30. package/dist/http-ingest.js +214 -0
  31. package/dist/protocol/index.d.ts +17 -2
  32. package/dist/protocol/index.d.ts.map +1 -1
  33. package/dist/runtime/ctx.d.ts +9 -0
  34. package/dist/runtime/ctx.d.ts.map +1 -1
  35. package/dist/runtime/ctx.js +17 -0
  36. package/dist/runtime/executor.d.ts +7 -0
  37. package/dist/runtime/executor.d.ts.map +1 -1
  38. package/dist/runtime/executor.js +1 -0
  39. package/dist/trigger.d.ts +28 -14
  40. package/dist/trigger.d.ts.map +1 -1
  41. package/dist/trigger.js +4 -4
  42. package/dist/workflow.d.ts +10 -0
  43. package/dist/workflow.d.ts.map +1 -1
  44. package/package.json +14 -2
  45. package/src/driver.ts +277 -0
  46. package/src/events/compile.ts +268 -0
  47. package/src/events/index.ts +42 -0
  48. package/src/events/input-mapper.ts +201 -0
  49. package/src/events/manifest-builder.ts +97 -0
  50. package/src/events/payload-hash.ts +110 -0
  51. package/src/events/predicate.ts +390 -0
  52. package/src/events/registry.ts +88 -0
  53. package/src/handler/index.ts +9 -0
  54. package/src/http-ingest.ts +299 -0
  55. package/src/protocol/index.ts +17 -2
  56. package/src/runtime/ctx.ts +29 -0
  57. package/src/runtime/executor.ts +8 -0
  58. package/src/trigger.ts +31 -15
  59. package/src/workflow.ts +11 -0
@@ -0,0 +1,299 @@
1
+ // Optional HTTP ingest adapter — mounts `/api/manifests` and `/api/events`
2
+ // on a Hono-shaped app, forwarding into a `WorkflowDriver`.
3
+ //
4
+ // Self-host Mode 2 deployments mount this when external emitters need to
5
+ // fire events into the runtime (storefront BFF, third-party webhooks,
6
+ // sibling-process pairs across machines). voyant-cloud always mounts it
7
+ // at its HTTP boundary.
8
+ //
9
+ // Transport-agnostic: takes a minimal `HttpAppLike` interface so the SDK
10
+ // stays a leaf package (no `hono` dep). `@voyantjs/voyant-hono`'s `Hono`
11
+ // instance satisfies the shape via TypeScript structural compat.
12
+ //
13
+ // Architecture: docs/architecture/workflows-runtime-architecture.md §15.4.
14
+
15
+ import type { IngestEventArgs, WorkflowDriver } from "./driver.js"
16
+ import type { EnvironmentName } from "./types.js"
17
+
18
+ const ALLOWED_ENVS = new Set<EnvironmentName>(["production", "preview", "development"])
19
+
20
+ // ---- Public types ----
21
+
22
+ /**
23
+ * Minimum interface a Hono-shaped app exposes that we use. `app.post(...)`
24
+ * and `app.get(...)` register handlers; the handler signature mirrors
25
+ * Hono's `Context`-style callback for portability — we only read the
26
+ * request body and request params via the framework's response helpers.
27
+ */
28
+ export interface HttpAppLike {
29
+ post(path: string, handler: HttpHandler): unknown
30
+ get(path: string, handler: HttpHandler): unknown
31
+ }
32
+
33
+ /**
34
+ * Minimum context shape we read off Hono. Restricted to body parsing,
35
+ * route params, and JSON response helpers.
36
+ */
37
+ export interface HttpContextLike {
38
+ req: {
39
+ json(): Promise<unknown>
40
+ param(name: string): string | undefined
41
+ header(name: string): string | undefined
42
+ raw: Request
43
+ }
44
+ json(body: unknown, status?: number): Response
45
+ text(body: string, status?: number): Response
46
+ status(code: number): unknown
47
+ }
48
+
49
+ export type HttpHandler = (ctx: HttpContextLike) => Promise<Response> | Response
50
+
51
+ export interface MountHttpIngestAdapterOptions {
52
+ /**
53
+ * Driver the adapter forwards into. Typically the same instance
54
+ * `createApp({ workflows: { driver } })` constructed.
55
+ */
56
+ driver: WorkflowDriver
57
+ /** Mount path. Defaults to `"/api/workflows"`. */
58
+ basePath?: string
59
+ /**
60
+ * Optional auth check. Receives the original `Request` and returns
61
+ * `void` on success / throws on failure. Reuse
62
+ * `createBearerVerifier(...)` from `@voyantjs/workflows/auth` for the
63
+ * canonical bearer-token shape.
64
+ */
65
+ verifyRequest?: (req: Request) => void | Promise<void>
66
+ }
67
+
68
+ // ---- Mount ----
69
+
70
+ /**
71
+ * Mount the adapter onto a Hono-shaped app. Registers:
72
+ *
73
+ * POST {basePath}/events → driver.ingestEvent
74
+ * POST {basePath}/manifests → driver.registerManifest
75
+ * GET {basePath}/manifests/:env → driver.getManifest
76
+ *
77
+ * Returns the mounted base path so callers can log it.
78
+ */
79
+ export function mountHttpIngestAdapter(
80
+ app: HttpAppLike,
81
+ opts: MountHttpIngestAdapterOptions,
82
+ ): string {
83
+ const base = (opts.basePath ?? "/api/workflows").replace(/\/$/, "")
84
+
85
+ app.post(`${base}/events`, async (ctx) => {
86
+ if (opts.verifyRequest) {
87
+ try {
88
+ await opts.verifyRequest(ctx.req.raw)
89
+ } catch (err) {
90
+ return ctx.json({ error: "unauthorized", message: errMessage(err) }, 401)
91
+ }
92
+ }
93
+
94
+ let raw: unknown
95
+ try {
96
+ raw = await ctx.req.json()
97
+ } catch (err) {
98
+ return ctx.json({ error: "invalid_json", message: errMessage(err) }, 400)
99
+ }
100
+ const validation = validateIngestBody(raw)
101
+ if (!validation.ok) return ctx.json(validation.error, 400)
102
+
103
+ const args: IngestEventArgs = {
104
+ environment: validation.body.environment,
105
+ envelope: validation.body.envelope,
106
+ idempotencyKey: validation.body.idempotencyKey,
107
+ }
108
+ const result = await opts.driver.ingestEvent(args)
109
+ if (!result.ok && result.reason === "manifest_not_registered") {
110
+ return ctx.json(result, 200)
111
+ }
112
+ if (!result.ok) {
113
+ return ctx.json(result, 502)
114
+ }
115
+ return ctx.json(result, 200)
116
+ })
117
+
118
+ app.post(`${base}/manifests`, async (ctx) => {
119
+ if (opts.verifyRequest) {
120
+ try {
121
+ await opts.verifyRequest(ctx.req.raw)
122
+ } catch (err) {
123
+ return ctx.json({ error: "unauthorized", message: errMessage(err) }, 401)
124
+ }
125
+ }
126
+ let raw: unknown
127
+ try {
128
+ raw = await ctx.req.json()
129
+ } catch (err) {
130
+ return ctx.json({ error: "invalid_json", message: errMessage(err) }, 400)
131
+ }
132
+ const validation = validateRegisterBody(raw)
133
+ if (!validation.ok) return ctx.json(validation.error, 400)
134
+ try {
135
+ const result = await opts.driver.registerManifest({
136
+ environment: validation.body.environment,
137
+ manifest: validation.body.manifest as never, // structurally compatible
138
+ })
139
+ return ctx.json({ ok: true, versionId: result.versionId }, 200)
140
+ } catch (err) {
141
+ return ctx.json({ error: "register_failed", message: errMessage(err) }, 500)
142
+ }
143
+ })
144
+
145
+ app.get(`${base}/manifests/:env`, async (ctx) => {
146
+ if (opts.verifyRequest) {
147
+ try {
148
+ await opts.verifyRequest(ctx.req.raw)
149
+ } catch (err) {
150
+ return ctx.json({ error: "unauthorized", message: errMessage(err) }, 401)
151
+ }
152
+ }
153
+ const env = ctx.req.param("env")
154
+ if (!env || !ALLOWED_ENVS.has(env as EnvironmentName)) {
155
+ return ctx.json(
156
+ {
157
+ error: "invalid_environment",
158
+ message: `environment must be one of ${[...ALLOWED_ENVS].join(", ")}`,
159
+ },
160
+ 400,
161
+ )
162
+ }
163
+ const manifest = await opts.driver.getManifest({ environment: env as EnvironmentName })
164
+ if (!manifest) {
165
+ return ctx.json({ error: "not_found", environment: env }, 404)
166
+ }
167
+ return ctx.json({ environment: env, versionId: manifest.versionId, manifest }, 200)
168
+ })
169
+
170
+ return base
171
+ }
172
+
173
+ // ---- Validation ----
174
+
175
+ interface IngestBody {
176
+ environment: EnvironmentName
177
+ envelope: {
178
+ name: string
179
+ data: unknown
180
+ metadata?: Record<string, unknown> & { eventId?: string }
181
+ emittedAt: string
182
+ }
183
+ idempotencyKey?: string
184
+ }
185
+
186
+ function validateIngestBody(
187
+ raw: unknown,
188
+ ): { ok: true; body: IngestBody } | { ok: false; error: { error: string; message: string } } {
189
+ if (typeof raw !== "object" || raw === null) {
190
+ return { ok: false, error: { error: "invalid_body", message: "expected JSON object" } }
191
+ }
192
+ const r = raw as Record<string, unknown>
193
+ if (typeof r.environment !== "string" || !ALLOWED_ENVS.has(r.environment as EnvironmentName)) {
194
+ return {
195
+ ok: false,
196
+ error: {
197
+ error: "invalid_body",
198
+ message: `"environment" must be one of ${[...ALLOWED_ENVS].join(", ")}`,
199
+ },
200
+ }
201
+ }
202
+ if (typeof r.envelope !== "object" || r.envelope === null) {
203
+ return { ok: false, error: { error: "invalid_body", message: '"envelope" must be an object' } }
204
+ }
205
+ const envelope = r.envelope as Record<string, unknown>
206
+ if (typeof envelope.name !== "string" || envelope.name.length === 0) {
207
+ return {
208
+ ok: false,
209
+ error: { error: "invalid_body", message: '"envelope.name" must be a non-empty string' },
210
+ }
211
+ }
212
+ if (typeof envelope.emittedAt !== "string" || envelope.emittedAt.length === 0) {
213
+ return {
214
+ ok: false,
215
+ error: {
216
+ error: "invalid_body",
217
+ message: '"envelope.emittedAt" must be an ISO timestamp string',
218
+ },
219
+ }
220
+ }
221
+ if (
222
+ envelope.metadata !== undefined &&
223
+ (typeof envelope.metadata !== "object" || envelope.metadata === null)
224
+ ) {
225
+ return {
226
+ ok: false,
227
+ error: {
228
+ error: "invalid_body",
229
+ message: '"envelope.metadata" must be an object when supplied',
230
+ },
231
+ }
232
+ }
233
+ if (r.idempotencyKey !== undefined && typeof r.idempotencyKey !== "string") {
234
+ return {
235
+ ok: false,
236
+ error: { error: "invalid_body", message: '"idempotencyKey" must be a string when supplied' },
237
+ }
238
+ }
239
+ return {
240
+ ok: true,
241
+ body: {
242
+ environment: r.environment as EnvironmentName,
243
+ envelope: {
244
+ name: envelope.name,
245
+ data: envelope.data,
246
+ metadata: envelope.metadata as Record<string, unknown> | undefined,
247
+ emittedAt: envelope.emittedAt,
248
+ },
249
+ idempotencyKey: r.idempotencyKey as string | undefined,
250
+ },
251
+ }
252
+ }
253
+
254
+ interface RegisterBody {
255
+ environment: EnvironmentName
256
+ manifest: Record<string, unknown> & { versionId: string }
257
+ }
258
+
259
+ function validateRegisterBody(
260
+ raw: unknown,
261
+ ): { ok: true; body: RegisterBody } | { ok: false; error: { error: string; message: string } } {
262
+ if (typeof raw !== "object" || raw === null) {
263
+ return { ok: false, error: { error: "invalid_body", message: "expected JSON object" } }
264
+ }
265
+ const r = raw as Record<string, unknown>
266
+ if (typeof r.environment !== "string" || !ALLOWED_ENVS.has(r.environment as EnvironmentName)) {
267
+ return {
268
+ ok: false,
269
+ error: {
270
+ error: "invalid_body",
271
+ message: `"environment" must be one of ${[...ALLOWED_ENVS].join(", ")}`,
272
+ },
273
+ }
274
+ }
275
+ if (typeof r.manifest !== "object" || r.manifest === null) {
276
+ return { ok: false, error: { error: "invalid_body", message: '"manifest" must be an object' } }
277
+ }
278
+ const manifest = r.manifest as Record<string, unknown>
279
+ if (typeof manifest.versionId !== "string" || manifest.versionId.length === 0) {
280
+ return {
281
+ ok: false,
282
+ error: {
283
+ error: "invalid_body",
284
+ message: '"manifest.versionId" must be a non-empty string',
285
+ },
286
+ }
287
+ }
288
+ return {
289
+ ok: true,
290
+ body: {
291
+ environment: r.environment as EnvironmentName,
292
+ manifest: manifest as Record<string, unknown> & { versionId: string },
293
+ },
294
+ }
295
+ }
296
+
297
+ function errMessage(err: unknown): string {
298
+ return err instanceof Error ? err.message : String(err)
299
+ }
@@ -83,11 +83,26 @@ export interface ManifestSchedule {
83
83
  }
84
84
 
85
85
  export interface EventFilterManifestEntry {
86
+ /** Stable id derived from `payloadHash` of the canonicalized declaration. */
86
87
  id: string
88
+ /** Event name the filter targets — matches `EventEnvelope.name`. */
87
89
  eventType: string
88
- scope?: string
89
- matchExpression?: string
90
+ /**
91
+ * Optional structured `where` predicate. When absent, every event of the
92
+ * matching `eventType` fires the target workflow. Concrete shape lives in
93
+ * `@voyantjs/workflows/events` (`PredicateExpr`); the protocol declares
94
+ * it as an opaque object so old orchestrators that don't understand the
95
+ * shape don't have to evaluate it.
96
+ */
97
+ where?: unknown
98
+ /**
99
+ * Optional input mapper. When absent, the workflow input = `envelope.data`.
100
+ * Concrete shape lives in `@voyantjs/workflows/events` (`InputMapper`).
101
+ */
102
+ input?: unknown
103
+ /** Content-derived hash of the canonicalized declaration. */
90
104
  payloadHash: string
105
+ /** Workflow id this filter triggers. */
91
106
  targetWorkflowId: string
92
107
  }
93
108
 
@@ -3,6 +3,7 @@
3
3
  // The executor owns the waitpoint-pending queue and the callbacks
4
4
  // into the orchestrator; ctx is a thin shell that delegates.
5
5
 
6
+ import type { ServiceResolver } from "../driver.js"
6
7
  import type { SerializedError } from "../protocol/index.js"
7
8
  import type { Duration, RetryPolicy, WaitpointKind } from "../types.js"
8
9
  import type {
@@ -132,10 +133,37 @@ export interface CtxBuildArgs {
132
133
  random: () => number
133
134
  /** Mutated as ctx.setRetry is called; each step option inherits. */
134
135
  retryOverride: { current: RetryPolicy | undefined }
136
+ /**
137
+ * Read-only service resolver exposed as `ctx.services`. When unset,
138
+ * `ctx.services.resolve(...)` throws with a clear message — workflows
139
+ * that don't need shared services keep working without configuration.
140
+ * Wired by the framework through `StepHandlerDeps.services` →
141
+ * `ExecuteWorkflowStepRequest.services` → here.
142
+ */
143
+ services?: ServiceResolver
144
+ }
145
+
146
+ /**
147
+ * Default resolver used when no container is plumbed through. Throws on
148
+ * `resolve(...)` so failures are visible at the call site instead of
149
+ * returning undefined; `has(...)` returns `false` so optional-dep
150
+ * patterns work cleanly.
151
+ */
152
+ const NO_OP_SERVICE_RESOLVER: ServiceResolver = {
153
+ resolve<T>(name: string): T {
154
+ throw new Error(
155
+ `ctx.services.resolve("${name}"): no service container is wired into this workflow runtime. ` +
156
+ `Pass { services } to the driver factory (e.g. via createApp({ workflows: { driver } }))`,
157
+ )
158
+ },
159
+ has() {
160
+ return false
161
+ },
135
162
  }
136
163
 
137
164
  export function buildCtx(args: CtxBuildArgs): WorkflowContext<unknown> {
138
165
  const { env, journal, callbacks, clock, random, retryOverride } = args
166
+ const services = args.services ?? NO_OP_SERVICE_RESOLVER
139
167
 
140
168
  // Per-ctx client-id counter. Reset on each ctx (= each invocation),
141
169
  // which means ids are stable relative to body execution order.
@@ -694,6 +722,7 @@ export function buildCtx(args: CtxBuildArgs): WorkflowContext<unknown> {
694
722
  organization: env.organization,
695
723
  invocationCount: callbacks.invocationCount,
696
724
  signal: callbacks.abortSignal,
725
+ services,
697
726
  step,
698
727
  sleep,
699
728
  waitForEvent,
@@ -132,6 +132,13 @@ export interface ExecuteWorkflowStepRequest {
132
132
  * array so the at-end delivery keeps working.
133
133
  */
134
134
  onStreamChunk?: (chunk: StreamChunk) => void
135
+ /**
136
+ * Optional read-only service resolver, surfaced to the workflow body as
137
+ * `ctx.services`. Wired by the framework through
138
+ * `StepHandlerDeps.services` → here. When unset, `ctx.services.resolve(...)`
139
+ * throws with a clear message — see `runtime/ctx.ts`.
140
+ */
141
+ services?: import("../driver.js").ServiceResolver
135
142
  }
136
143
 
137
144
  export type ExecuteWorkflowStepResponse =
@@ -285,6 +292,7 @@ export async function executeWorkflowStep(
285
292
  clock,
286
293
  random,
287
294
  retryOverride,
295
+ services: req.services,
288
296
  })
289
297
 
290
298
  try {
package/src/trigger.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // Authoritative contract in docs/sdk-surface.md §6.
3
3
 
4
4
  import type { Duration, EnvironmentName, RunStatus } from "./types.js"
5
- import type { EnvironmentContext, WorkflowHandle } from "./workflow.js"
5
+ import type { WorkflowHandle } from "./workflow.js"
6
6
 
7
7
  // ---- workflows.* ----
8
8
 
@@ -112,28 +112,44 @@ export const workflows: WorkflowsClient = new Proxy({} as WorkflowsClient, {
112
112
 
113
113
  // ---- trigger.on ----
114
114
 
115
- export interface EventFilterHandle {
116
- readonly id: string
117
- readonly event: string
118
- }
115
+ import { compileAndRegister } from "./events/compile.js"
116
+ import type { InputMapper } from "./events/input-mapper.js"
117
+ import type { PredicateExpr } from "./events/predicate.js"
118
+ import type { EventFilterRuntimeEntry } from "./events/registry.js"
119
119
 
120
+ /**
121
+ * Declarative binding from an event name to a target workflow. Authors call
122
+ * `trigger.on(eventName, declaration)` at module-load time; the framework
123
+ * collects the entries via the process-local registry (see
124
+ * `./events/registry.js`) and ships them in the manifest.
125
+ *
126
+ * `where` and `input` are structured DSLs (no callbacks) so the runtime
127
+ * can evaluate them anywhere — in-process for self-host, server-side for
128
+ * managed deployments. The previous `match` callback is no longer
129
+ * supported; registration throws if it's set.
130
+ */
120
131
  export interface EventFilterDeclaration<T> {
121
132
  target: WorkflowHandle<T, unknown>
122
- match?: (payload: T, ctx: { environment: EnvironmentContext; project: { id: string } }) => boolean
123
- scope?: string
124
- input?: (payload: T) => unknown
133
+ /** Structured predicate; see `@voyantjs/workflows/events` `PredicateExpr`. */
134
+ where?: PredicateExpr
135
+ /** Structured input projection; see `@voyantjs/workflows/events` `InputMapper`. */
136
+ input?: InputMapper
125
137
  }
126
138
 
127
139
  export interface TriggerApi {
128
- on<T = unknown>(event: string, filter: EventFilterDeclaration<T>): EventFilterHandle
140
+ /**
141
+ * Register an event filter targeting `event`. Returns the
142
+ * {@link EventFilterRuntimeEntry} so authors can drop it directly into
143
+ * `Module.eventFilters` / `Plugin.eventFilters` — the entry structurally
144
+ * satisfies core's `EventFilterDescriptor` (matching `id` + `eventType`)
145
+ * and carries the manifest payload `createApp()` needs to register with
146
+ * the driver.
147
+ */
148
+ on<T = unknown>(event: string, filter: EventFilterDeclaration<T>): EventFilterRuntimeEntry
129
149
  }
130
150
 
131
151
  export const trigger: TriggerApi = {
132
- on<T>(_event: string, _filter: EventFilterDeclaration<T>): EventFilterHandle {
133
- throw new Error(
134
- "@voyantjs/workflows: trigger.on() must be collected by `voyant workflows build` and " +
135
- "registered with the orchestrator at deploy time; it has no runtime behavior when " +
136
- "called directly. See docs/sdk-surface.md §6.2.",
137
- )
152
+ on<T>(event: string, filter: EventFilterDeclaration<T>): EventFilterRuntimeEntry {
153
+ return compileAndRegister(event, filter)
138
154
  },
139
155
  }
package/src/workflow.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // Authoritative contract in docs/sdk-surface.md §2–§3.
3
3
 
4
4
  import type { Condition } from "./conditions.js"
5
+ import type { ServiceResolver } from "./driver.js"
5
6
  import type {
6
7
  Duration,
7
8
  EnvironmentName,
@@ -158,6 +159,16 @@ export interface WorkflowContext<_TInput = unknown> {
158
159
  readonly invocationCount: number
159
160
  readonly signal: AbortSignal
160
161
 
162
+ /**
163
+ * Read-only view of the framework's service container. Step bodies resolve
164
+ * shared services (db, indexer, etc.) via `ctx.services.resolve("name")`.
165
+ * The framework wires this from `createApp()`'s `ModuleContainer` through
166
+ * `StepHandlerDeps.services`. When no container is plumbed (driver not
167
+ * configured with `services`, or in raw orchestrator tests), `resolve(...)`
168
+ * throws with a clear message and `has(...)` returns `false`.
169
+ */
170
+ readonly services: ServiceResolver
171
+
161
172
  step: StepApi
162
173
  sleep: (duration: Duration) => Promise<void>
163
174
  waitForEvent: WaitForEventApi