@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,201 @@
1
+ // Input mapper DSL — projects a workflow input from an event envelope.
2
+ //
3
+ // Each `EventFilterDeclaration` carries an optional `input: InputMapper`. At
4
+ // ingest time, after the predicate matches, the mapper builds the actual
5
+ // input value passed to `driver.trigger(target, input, ...)`. Same path
6
+ // roots as the predicate evaluator (`data`, `metadata`, `name`, `emittedAt`).
7
+ //
8
+ // Variants:
9
+ // * `undefined` → pass through `envelope.data`
10
+ // * `{ passthrough: true }` → explicit pass-through of `envelope.data`
11
+ // * `{ path: string }` → workflow input = the resolved path value
12
+ // * `{ object: {...} }` → build an object by projecting each key;
13
+ // each value is itself an InputMapper or a
14
+ // `PathOrLit` for terminal projections
15
+ //
16
+ // Architecture: docs/architecture/workflows-runtime-architecture.md §13.2.
17
+
18
+ import { type PathOrLit, type PredicateEnvelope, resolvePath } from "./predicate.js"
19
+
20
+ // ---- Public types ----
21
+
22
+ export type InputMapper =
23
+ | undefined
24
+ | { passthrough: true }
25
+ | { path: string }
26
+ | { object: Record<string, InputMapper | PathOrLit> }
27
+
28
+ // ---- Public API ----
29
+
30
+ /**
31
+ * Project a workflow input from an event envelope. Mirrors the predicate
32
+ * evaluator's no-throw contract — missing paths produce `undefined` in the
33
+ * output, registration-time linting catches structural errors.
34
+ *
35
+ * Throws `InputMapperError` only on unexpected shape errors (the mapper
36
+ * itself was constructed wrong). Drivers catch and surface this as
37
+ * `IngestMatch.status === "skipped"` with reason `"input_projection_error"`.
38
+ */
39
+ export function projectInput(mapper: InputMapper, envelope: PredicateEnvelope): unknown {
40
+ if (mapper === undefined) return envelope.data
41
+ if (typeof mapper !== "object" || mapper === null) {
42
+ throw new InputMapperError(
43
+ `input mapper must be undefined, {passthrough}, {path}, or {object}, got ${typeof mapper}`,
44
+ )
45
+ }
46
+ if ("passthrough" in mapper) {
47
+ if (mapper.passthrough !== true) {
48
+ throw new InputMapperError(`{ passthrough } must be true`)
49
+ }
50
+ return envelope.data
51
+ }
52
+ if ("path" in mapper) {
53
+ if (typeof mapper.path !== "string" || mapper.path.length === 0) {
54
+ throw new InputMapperError(`{ path } must be a non-empty string`)
55
+ }
56
+ return resolvePath(mapper.path, envelope)
57
+ }
58
+ if ("object" in mapper) {
59
+ if (typeof mapper.object !== "object" || mapper.object === null) {
60
+ throw new InputMapperError(`{ object } must map keys to InputMapper | PathOrLit`)
61
+ }
62
+ const out: Record<string, unknown> = {}
63
+ for (const [key, child] of Object.entries(mapper.object)) {
64
+ out[key] = projectChild(child, envelope)
65
+ }
66
+ return out
67
+ }
68
+ throw new InputMapperError(
69
+ `input mapper must contain one of passthrough | path | object, got keys: ${Object.keys(mapper).join(", ")}`,
70
+ )
71
+ }
72
+
73
+ // ---- Static linter ----
74
+
75
+ export interface InputMapperValidationResult {
76
+ ok: boolean
77
+ errors: string[]
78
+ }
79
+
80
+ export function validateInputMapper(mapper: InputMapper): InputMapperValidationResult {
81
+ const errors: string[] = []
82
+ walkValidate(mapper, errors, [])
83
+ return { ok: errors.length === 0, errors }
84
+ }
85
+
86
+ // ---- Internals ----
87
+
88
+ class InputMapperError extends Error {
89
+ constructor(message: string) {
90
+ super(message)
91
+ this.name = "InputMapperError"
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Resolve one entry inside `{ object: { ... } }`. The value is either a
97
+ * nested mapper (recurse) or a `PathOrLit` (terminal projection).
98
+ */
99
+ function projectChild(child: InputMapper | PathOrLit, envelope: PredicateEnvelope): unknown {
100
+ if (child === undefined) return envelope.data
101
+ if (typeof child !== "object" || child === null) {
102
+ throw new InputMapperError(`nested mapper entry must be an object, got ${typeof child}`)
103
+ }
104
+ // Distinguish PathOrLit (`{ path }` or `{ lit }`) from nested mapper.
105
+ if ("lit" in child) {
106
+ return (child as { lit: unknown }).lit
107
+ }
108
+ // `{ path: "..." }` is shared between PathOrLit and InputMapper — resolve directly.
109
+ if ("path" in child) {
110
+ if (typeof child.path !== "string" || child.path.length === 0) {
111
+ throw new InputMapperError(`nested { path } must be a non-empty string`)
112
+ }
113
+ return resolvePath(child.path, envelope)
114
+ }
115
+ // Otherwise: a nested InputMapper (passthrough / object).
116
+ return projectInput(child as InputMapper, envelope)
117
+ }
118
+
119
+ function walkValidate(mapper: InputMapper, errors: string[], path: string[]): void {
120
+ if (mapper === undefined) return
121
+ if (typeof mapper !== "object" || mapper === null) {
122
+ errors.push(
123
+ `${pathLabel(path)}: input mapper must be undefined or an object, got ${typeof mapper}`,
124
+ )
125
+ return
126
+ }
127
+ const keys = Object.keys(mapper)
128
+ if (keys.length === 0) {
129
+ errors.push(`${pathLabel(path)}: input mapper must specify passthrough | path | object`)
130
+ return
131
+ }
132
+ if ("passthrough" in mapper) {
133
+ if (mapper.passthrough !== true) {
134
+ errors.push(`${pathLabel(path)}: { passthrough } must be true`)
135
+ }
136
+ return
137
+ }
138
+ if ("path" in mapper) {
139
+ if (typeof mapper.path !== "string" || mapper.path.length === 0) {
140
+ errors.push(`${pathLabel(path)}: { path } must be a non-empty string`)
141
+ return
142
+ }
143
+ validatePathRoot(mapper.path, errors, path)
144
+ return
145
+ }
146
+ if ("object" in mapper) {
147
+ if (typeof mapper.object !== "object" || mapper.object === null) {
148
+ errors.push(`${pathLabel(path)}: { object } must map keys to InputMapper | PathOrLit`)
149
+ return
150
+ }
151
+ for (const [k, child] of Object.entries(mapper.object)) {
152
+ validateChild(child, errors, [...path, k])
153
+ }
154
+ return
155
+ }
156
+ errors.push(`${pathLabel(path)}: unknown mapper variant — keys: ${keys.join(", ")}`)
157
+ }
158
+
159
+ function validateChild(child: InputMapper | PathOrLit, errors: string[], path: string[]): void {
160
+ if (child === undefined) return
161
+ if (typeof child !== "object" || child === null) {
162
+ errors.push(`${pathLabel(path)}: nested entry must be an object`)
163
+ return
164
+ }
165
+ if ("lit" in child) {
166
+ const t = typeof (child as { lit: unknown }).lit
167
+ if (
168
+ t !== "string" &&
169
+ t !== "number" &&
170
+ t !== "boolean" &&
171
+ (child as { lit: unknown }).lit !== null
172
+ ) {
173
+ errors.push(`${pathLabel(path)}: { lit } must be string | number | boolean | null`)
174
+ }
175
+ return
176
+ }
177
+ if ("path" in child) {
178
+ if (typeof child.path !== "string" || child.path.length === 0) {
179
+ errors.push(`${pathLabel(path)}: { path } must be a non-empty string`)
180
+ return
181
+ }
182
+ validatePathRoot(child.path, errors, path)
183
+ return
184
+ }
185
+ walkValidate(child as InputMapper, errors, path)
186
+ }
187
+
188
+ function validatePathRoot(path: string, errors: string[], errorPath: string[]): void {
189
+ // Match the predicate path roots exactly so the two DSLs stay aligned.
190
+ const firstSegment = path.split(".")[0] ?? ""
191
+ const root = firstSegment.split("[")[0] ?? ""
192
+ if (root !== "data" && root !== "metadata" && root !== "name" && root !== "emittedAt") {
193
+ errors.push(
194
+ `${pathLabel(errorPath)}: path root "${root}" is not one of data | metadata | name | emittedAt`,
195
+ )
196
+ }
197
+ }
198
+
199
+ function pathLabel(path: string[]): string {
200
+ return path.length === 0 ? "(root)" : path.join(".")
201
+ }
@@ -0,0 +1,97 @@
1
+ // Build a `WorkflowManifest` from collected workflow + event-filter entries.
2
+ //
3
+ // Called once at `createApp()` boot (PR4). The resulting manifest is
4
+ // content-addressed: byte-identical inputs produce byte-identical
5
+ // `versionId`s, so concurrent registration calls don't race meaningfully —
6
+ // the second caller sees the same versionId the first did.
7
+ //
8
+ // Architecture: docs/architecture/workflows-runtime-architecture.md §14.1.
9
+
10
+ import type {
11
+ EventFilterManifestEntry,
12
+ WorkflowManifest,
13
+ WorkflowManifestEntry,
14
+ } from "../protocol/index.js"
15
+ import { canonicalJson, shortHash } from "./payload-hash.js"
16
+ import type { EventFilterRuntimeEntry } from "./registry.js"
17
+
18
+ export interface BuildManifestArgs {
19
+ /** Project / tenant identifier. Single-tenant runtimes pass `"default"`. */
20
+ projectId?: string
21
+ /** Deployment environment. */
22
+ environment: "production" | "preview" | "development"
23
+ /** Workflow definitions collected from modules + plugins. */
24
+ workflows: ReadonlyArray<{
25
+ id: string
26
+ config?: { defaultRuntime?: "edge" | "node"; retry?: unknown; timeout?: unknown }
27
+ }>
28
+ /** Event-filter entries from `getEventFilterRegistry()`. */
29
+ eventFilters: ReadonlyArray<EventFilterRuntimeEntry>
30
+ /** Wall-clock build time, ms-since-epoch. Defaults to `Date.now()`. */
31
+ builtAt?: number
32
+ /** Source-code version of the manifest builder. */
33
+ builderVersion?: string
34
+ }
35
+
36
+ /**
37
+ * Build a deterministic `WorkflowManifest`. Same inputs always produce
38
+ * byte-identical output, including `versionId`.
39
+ *
40
+ * Does NOT write the manifest anywhere — that's the driver's
41
+ * `registerManifest(...)` responsibility. This function is pure.
42
+ */
43
+ export async function buildManifest(args: BuildManifestArgs): Promise<WorkflowManifest> {
44
+ const builtAt = args.builtAt ?? Date.now()
45
+ const builderVersion = args.builderVersion ?? "@voyantjs/workflows@manifest-builder/v1"
46
+ const projectId = args.projectId ?? "default"
47
+
48
+ const workflows: WorkflowManifestEntry[] = args.workflows
49
+ .map((wf) => ({
50
+ id: wf.id,
51
+ version: "v1",
52
+ steps: [],
53
+ schedules: [],
54
+ defaultRuntime: wf.config?.defaultRuntime ?? "edge",
55
+ hasCompensation: false,
56
+ sourceLocation: { file: "<runtime>", line: 0 },
57
+ }))
58
+ .sort((a, b) => a.id.localeCompare(b.id))
59
+
60
+ // Sort filters by id so the canonical form is order-independent.
61
+ const eventFilters: EventFilterManifestEntry[] = args.eventFilters
62
+ .map((entry) => entry.manifest)
63
+ .sort((a, b) => a.id.localeCompare(b.id))
64
+
65
+ const draft: Omit<WorkflowManifest, "versionId"> & { versionId?: string } = {
66
+ schemaVersion: 1,
67
+ projectId,
68
+ builtAt,
69
+ builderVersion,
70
+ capabilities: ["events:v1"],
71
+ workflows,
72
+ eventFilters,
73
+ bindings: {},
74
+ environments: { production: {}, preview: {}, development: {} },
75
+ }
76
+
77
+ // versionId is the cryptographic short hash of the canonical manifest
78
+ // body (excluding builtAt + versionId itself, which are non-load-bearing
79
+ // for content identity).
80
+ const identityBody = {
81
+ schemaVersion: draft.schemaVersion,
82
+ projectId: draft.projectId,
83
+ builderVersion: draft.builderVersion,
84
+ capabilities: draft.capabilities,
85
+ workflows: draft.workflows,
86
+ eventFilters: draft.eventFilters,
87
+ bindings: draft.bindings,
88
+ environments: draft.environments,
89
+ }
90
+ const versionId = await shortHash(identityBody)
91
+ void canonicalJson // referenced via shortHash; keep the import surface stable
92
+
93
+ return {
94
+ ...(draft as Omit<WorkflowManifest, "versionId">),
95
+ versionId,
96
+ }
97
+ }
@@ -0,0 +1,110 @@
1
+ // Canonical JSON + SHA-256 helpers used to derive `payloadHash` ids for
2
+ // `EventFilterRuntimeEntry` and `WorkflowManifest.versionId`.
3
+ //
4
+ // Canonicalization recursively alphabetizes object keys before
5
+ // JSON.stringify. SHA-256 uses Web Crypto (`globalThis.crypto.subtle`),
6
+ // available on Node ≥ 19, all modern browsers, and Cloudflare Workers.
7
+ // Async because Web Crypto's digest is async — callers `await` once
8
+ // at registration time.
9
+ //
10
+ // Architecture: docs/architecture/workflows-runtime-architecture.md §13.3.
11
+
12
+ /**
13
+ * Recursively alphabetize object keys, leaving arrays and primitives intact.
14
+ * `undefined` is converted to `null` so canonical JSON shape is stable
15
+ * (JSON.stringify drops `undefined` from objects).
16
+ */
17
+ export function canonicalize(value: unknown): unknown {
18
+ if (value === undefined) return null
19
+ if (value === null || typeof value !== "object") return value
20
+ if (Array.isArray(value)) {
21
+ return value.map(canonicalize)
22
+ }
23
+ const sorted: Record<string, unknown> = {}
24
+ const keys = Object.keys(value as Record<string, unknown>).sort()
25
+ for (const k of keys) {
26
+ sorted[k] = canonicalize((value as Record<string, unknown>)[k])
27
+ }
28
+ return sorted
29
+ }
30
+
31
+ /**
32
+ * Stable canonical JSON string for `value`. Two values that are deeply
33
+ * equal modulo key order produce identical strings; two values that
34
+ * differ in any way produce different strings. Used as the input to
35
+ * `sha256(...)` for content-derived ids.
36
+ */
37
+ export function canonicalJson(value: unknown): string {
38
+ return JSON.stringify(canonicalize(value))
39
+ }
40
+
41
+ /**
42
+ * SHA-256 hex digest of an arbitrary value (canonicalized first). Async
43
+ * — callers await once during manifest build.
44
+ *
45
+ * @returns lowercase hex string, 64 chars long.
46
+ */
47
+ export async function sha256(value: unknown): Promise<string> {
48
+ const text = canonicalJson(value)
49
+ const bytes = new TextEncoder().encode(text)
50
+ const digest = await getCrypto().subtle.digest("SHA-256", bytes)
51
+ return bytesToHex(new Uint8Array(digest))
52
+ }
53
+
54
+ /**
55
+ * Short content-derived id, used as `EventFilterManifestEntry.payloadHash`
56
+ * and `WorkflowManifest.versionId`. 16 hex chars (~64 bits) — collision
57
+ * space is fine for human-friendly ids in dashboards/logs; the canonical
58
+ * full hash is `sha256(...)` if you need it.
59
+ */
60
+ export async function shortHash(value: unknown): Promise<string> {
61
+ const full = await sha256(value)
62
+ return full.slice(0, 16)
63
+ }
64
+
65
+ /**
66
+ * Derive a stable event id from an envelope when `metadata.eventId` is
67
+ * absent. Mirrors the formula from architecture doc §15.2:
68
+ *
69
+ * `${name}:${emittedAt}:${sha256(canonical(data)).slice(0, 12)}`
70
+ *
71
+ * Same envelope content always produces the same id — concurrent retries
72
+ * of the same external HTTP delivery dedupe at the driver's
73
+ * `${filterId}:${eventId}` idempotency key derivation.
74
+ *
75
+ * Returns a fallback id of the form `evt_<name>_<emittedAt>_<hash12>`
76
+ * (URL-safe; no colons in case the id flows through path segments).
77
+ */
78
+ export async function deriveStableEventId(envelope: {
79
+ name: string
80
+ data: unknown
81
+ emittedAt: string
82
+ }): Promise<string> {
83
+ const dataHash = (await sha256(envelope.data)).slice(0, 12)
84
+ const safeName = envelope.name.replace(/[^a-zA-Z0-9._-]/g, "_")
85
+ const safeAt = envelope.emittedAt.replace(/[^a-zA-Z0-9.]/g, "_")
86
+ return `evt_${safeName}_${safeAt}_${dataHash}`
87
+ }
88
+
89
+ // ---- Internal ----
90
+
91
+ function getCrypto(): Crypto {
92
+ // `globalThis.crypto` is available on Node 19+, Workers, browsers.
93
+ // Any environment older than that needs a polyfill at the consumer level.
94
+ const c = (globalThis as { crypto?: Crypto }).crypto
95
+ if (!c?.subtle) {
96
+ throw new Error(
97
+ "@voyantjs/workflows/events: globalThis.crypto.subtle is required for payload-hash. " +
98
+ "Polyfill via webcrypto on legacy runtimes.",
99
+ )
100
+ }
101
+ return c
102
+ }
103
+
104
+ function bytesToHex(bytes: Uint8Array): string {
105
+ let out = ""
106
+ for (let i = 0; i < bytes.length; i++) {
107
+ out += (bytes[i] ?? 0).toString(16).padStart(2, "0")
108
+ }
109
+ return out
110
+ }