@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.
- package/dist/driver.d.ts +237 -0
- package/dist/driver.d.ts.map +1 -0
- package/dist/driver.js +53 -0
- package/dist/events/compile.d.ts +34 -0
- package/dist/events/compile.d.ts.map +1 -0
- package/dist/events/compile.js +204 -0
- package/dist/events/index.d.ts +8 -0
- package/dist/events/index.d.ts.map +1 -0
- package/dist/events/index.js +11 -0
- package/dist/events/input-mapper.d.ts +24 -0
- package/dist/events/input-mapper.d.ts.map +1 -0
- package/dist/events/input-mapper.js +169 -0
- package/dist/events/manifest-builder.d.ts +32 -0
- package/dist/events/manifest-builder.d.ts.map +1 -0
- package/dist/events/manifest-builder.js +66 -0
- package/dist/events/payload-hash.d.ts +46 -0
- package/dist/events/payload-hash.d.ts.map +1 -0
- package/dist/events/payload-hash.js +98 -0
- package/dist/events/predicate.d.ts +77 -0
- package/dist/events/predicate.d.ts.map +1 -0
- package/dist/events/predicate.js +347 -0
- package/dist/events/registry.d.ts +37 -0
- package/dist/events/registry.d.ts.map +1 -0
- package/dist/events/registry.js +47 -0
- package/dist/handler/index.d.ts +8 -0
- package/dist/handler/index.d.ts.map +1 -1
- package/dist/handler/index.js +1 -0
- package/dist/http-ingest.d.ts +54 -0
- package/dist/http-ingest.d.ts.map +1 -0
- package/dist/http-ingest.js +214 -0
- package/dist/protocol/index.d.ts +17 -2
- package/dist/protocol/index.d.ts.map +1 -1
- package/dist/runtime/ctx.d.ts +9 -0
- package/dist/runtime/ctx.d.ts.map +1 -1
- package/dist/runtime/ctx.js +17 -0
- package/dist/runtime/executor.d.ts +7 -0
- package/dist/runtime/executor.d.ts.map +1 -1
- package/dist/runtime/executor.js +1 -0
- package/dist/trigger.d.ts +28 -14
- package/dist/trigger.d.ts.map +1 -1
- package/dist/trigger.js +4 -4
- package/dist/workflow.d.ts +10 -0
- package/dist/workflow.d.ts.map +1 -1
- package/package.json +14 -2
- package/src/driver.ts +277 -0
- package/src/events/compile.ts +268 -0
- package/src/events/index.ts +42 -0
- package/src/events/input-mapper.ts +201 -0
- package/src/events/manifest-builder.ts +97 -0
- package/src/events/payload-hash.ts +110 -0
- package/src/events/predicate.ts +390 -0
- package/src/events/registry.ts +88 -0
- package/src/handler/index.ts +9 -0
- package/src/http-ingest.ts +299 -0
- package/src/protocol/index.ts +17 -2
- package/src/runtime/ctx.ts +29 -0
- package/src/runtime/executor.ts +8 -0
- package/src/trigger.ts +31 -15
- 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
|
+
}
|
package/src/protocol/index.ts
CHANGED
|
@@ -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
|
-
|
|
89
|
-
|
|
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
|
|
package/src/runtime/ctx.ts
CHANGED
|
@@ -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,
|
package/src/runtime/executor.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
input
|
|
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
|
-
|
|
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>(
|
|
133
|
-
|
|
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
|