@pylonsync/functions 0.4.17 → 0.4.20

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/src/types.ts CHANGED
@@ -156,6 +156,32 @@ export interface DbReader {
156
156
  query: Record<string, unknown>
157
157
  ): Promise<SearchResult>;
158
158
 
159
+ /**
160
+ * Exact k-nearest-neighbor search over a `field.vector(dims)` field.
161
+ * Cosine similarity by default (`metric: "dot" | "l2"` to change);
162
+ * hits come back best-first with the full row on `doc` (vector
163
+ * fields stripped — re-fetch by id if you need the embedding).
164
+ *
165
+ * ```ts
166
+ * const [embedding] = await ctx.llm.embed(["how do I reset my password?"]);
167
+ * const { hits } = await ctx.db.vectorSearch("Doc", {
168
+ * field: "embedding",
169
+ * vector: embedding,
170
+ * limit: 5,
171
+ * filter: { status: "published" }, // equality / IN pre-filter
172
+ * });
173
+ * ```
174
+ *
175
+ * Exact scan, not ANN: every non-NULL embedding is scored. Fine to
176
+ * ~100k rows per entity; past that, a dedicated vector store wins.
177
+ * Throws `VECTOR_FIELD_NOT_FOUND` when `field` isn't a vector field
178
+ * and `INVALID_QUERY` on dimension mismatch or bad filters.
179
+ */
180
+ vectorSearch(
181
+ entity: string,
182
+ query: VectorSearchQuery
183
+ ): Promise<VectorSearchResult>;
184
+
159
185
  /**
160
186
  * Cursor-paginated list. Pass `cursor` from a previous page's `nextCursor`
161
187
  * to continue; pass `null` for the first page.
@@ -196,6 +222,30 @@ export interface SearchResult<T = Record<string, unknown>> {
196
222
  tookMs: number;
197
223
  }
198
224
 
225
+ /** Request shape for [`DbReader.vectorSearch`]. */
226
+ export interface VectorSearchQuery {
227
+ /** The `vector(dims)` field to search. */
228
+ field: string;
229
+ /** Query embedding; length must equal the field's declared dims. */
230
+ vector: number[];
231
+ /** Max hits. Default 10, capped at 200. */
232
+ limit?: number;
233
+ /** Similarity metric. Default "cosine" (higher = closer);
234
+ * "dot" (higher = closer); "l2" (Euclidean distance, lower = closer). */
235
+ metric?: "cosine" | "dot" | "l2";
236
+ /** Equality pre-filter applied in SQL before scoring. A plain value
237
+ * means equality; an array means SQL `IN`; `null` means IS NULL. */
238
+ filter?: Record<string, unknown>;
239
+ }
240
+
241
+ /** Result shape for [`DbReader.vectorSearch`]. */
242
+ export interface VectorSearchResult<T = Record<string, unknown>> {
243
+ /** Best-first hits for the chosen metric. */
244
+ hits: Array<{ id: string; score: number; doc: T }>;
245
+ /** Milliseconds spent scanning + scoring. */
246
+ tookMs: number;
247
+ }
248
+
199
249
  // ---------------------------------------------------------------------------
200
250
  // Database — write operations (extends read)
201
251
  // ---------------------------------------------------------------------------
@@ -422,6 +472,32 @@ export interface Llm {
422
472
  request: LlmCompleteRequest,
423
473
  onEvent: (event: LlmStreamEvent) => void,
424
474
  ): Promise<LlmCompleteResponse>;
475
+
476
+ /**
477
+ * Batch-embed texts via the configured embeddings provider. One
478
+ * embedding per input, in input order. Pair with a
479
+ * `field.vector(dims)` field and `ctx.db.vectorSearch` for
480
+ * retrieval:
481
+ *
482
+ * ```ts
483
+ * const [vec] = await ctx.llm.embed([doc.body]);
484
+ * await ctx.db.update("Doc", doc.id, { embedding: vec });
485
+ * ```
486
+ *
487
+ * The embeddings provider is a separate axis from chat: with
488
+ * `OPENAI_API_KEY` set it defaults to OpenAI
489
+ * `text-embedding-3-small` (1536 dims) even when chat runs
490
+ * Anthropic; `PYLON_EMBEDDINGS_PROVIDER=voyage` + `VOYAGE_API_KEY`
491
+ * selects Voyage `voyage-3.5` (1024 dims).
492
+ * `PYLON_EMBEDDINGS_MODEL` overrides the model.
493
+ *
494
+ * Not available in queries (reactive re-runs would re-bill the
495
+ * provider) — embed in a mutation/action and store the vector.
496
+ * Errors carry `err.code`: `EMBEDDINGS_NOT_CONFIGURED`,
497
+ * `PROVIDER_HTTP_<code>`, `PROVIDER_UNREACHABLE`,
498
+ * `INVALID_REQUEST`.
499
+ */
500
+ embed(input: string[], opts?: { model?: string }): Promise<number[][]>;
425
501
  }
426
502
 
427
503
  /**
@@ -471,6 +547,30 @@ export interface Rooms {
471
547
  ): Promise<{ delivered: boolean }>;
472
548
  }
473
549
 
550
+ /**
551
+ * Durable workflows, driven from app code (`ctx.workflows` on mutations
552
+ * and actions — not queries, whose reactive re-runs would re-start).
553
+ * Workflows are declared in the app's `workflows/` directory; see the
554
+ * `workflow()` export.
555
+ */
556
+ export interface Workflows {
557
+ /**
558
+ * Start a workflow instance by name. Returns immediately with the
559
+ * instance id — the engine's background driver executes the steps.
560
+ */
561
+ start(name: string, input?: unknown): Promise<{ id: string }>;
562
+ /**
563
+ * Deliver an event to an instance paused on
564
+ * `wf.waitForEvent(event)`. Rejects when the instance isn't waiting
565
+ * for that event.
566
+ */
567
+ sendEvent(
568
+ workflowId: string,
569
+ event: string,
570
+ data?: unknown,
571
+ ): Promise<{ delivered: boolean }>;
572
+ }
573
+
474
574
  export interface LlmMessage {
475
575
  role: "user" | "assistant" | "system" | "tool";
476
576
  content: string | LlmContentBlock[];
@@ -700,6 +800,14 @@ export interface QueryCtx<R extends AuthRequirement = "optional"> {
700
800
  requireMember: RequireMember;
701
801
  /** Signed file-download URLs — see {@link Files}. */
702
802
  files: Files;
803
+ /**
804
+ * Fires when the host cancels this call (idle timeout exceeded).
805
+ * Thread it into `fetch(url, { signal: ctx.signal })` or SDK calls so
806
+ * a cancelled call stops its outbound work too — the runtime already
807
+ * makes every later `ctx.*` call throw `CALL_CANCELLED`. Optional
808
+ * because older hosts don't send cancel frames.
809
+ */
810
+ signal?: AbortSignal;
703
811
  }
704
812
 
705
813
  /** Context for mutation handlers (read + write, transactional). */
@@ -716,12 +824,22 @@ export interface MutationCtx<R extends AuthRequirement = "optional"> {
716
824
  rooms: Rooms;
717
825
  /** Per-user OAuth connection registry. */
718
826
  connections: Connections;
827
+ /** Durable workflows: start / deliver events — see {@link Workflows}. */
828
+ workflows: Workflows;
719
829
  /** Signed file-download URLs — see {@link Files}. */
720
830
  files: Files;
721
831
  /** Create a typed error that triggers rollback. */
722
832
  error(code: string, message: string): Error;
723
833
  /** Assert org membership (optionally a role) — see {@link RequireMember}. */
724
834
  requireMember: RequireMember;
835
+ /**
836
+ * Fires when the host cancels this call (idle timeout exceeded).
837
+ * Thread it into `fetch(url, { signal: ctx.signal })` or SDK calls so
838
+ * a cancelled call stops its outbound work too — the runtime already
839
+ * makes every later `ctx.*` call throw `CALL_CANCELLED`. Optional
840
+ * because older hosts don't send cancel frames.
841
+ */
842
+ signal?: AbortSignal;
725
843
  }
726
844
 
727
845
  /** Context for action handlers (external I/O, non-transactional). */
@@ -737,6 +855,8 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
737
855
  rooms: Rooms;
738
856
  /** Per-user OAuth connection registry. */
739
857
  connections: Connections;
858
+ /** Durable workflows: start / deliver events — see {@link Workflows}. */
859
+ workflows: Workflows;
740
860
  /** Environment variables / secrets. */
741
861
  env: Record<string, string>;
742
862
  /** Signed file-download URLs — see {@link Files}. */
@@ -755,6 +875,14 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
755
875
  error(code: string, message: string): Error;
756
876
  /** Assert org membership (optionally a role) — see {@link RequireMember}. */
757
877
  requireMember: RequireMember;
878
+ /**
879
+ * Fires when the host cancels this call (idle timeout exceeded).
880
+ * Thread it into `fetch(url, { signal: ctx.signal })` or SDK calls so
881
+ * a cancelled call stops its outbound work too — the runtime already
882
+ * makes every later `ctx.*` call throw `CALL_CANCELLED`. Optional
883
+ * because older hosts don't send cancel frames.
884
+ */
885
+ signal?: AbortSignal;
758
886
  /**
759
887
  * HTTP request metadata — present only when the action was invoked via
760
888
  * a `defineRoute` HTTP binding. Missing when the action is called from
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Tests for the workflow slice executor (workflows.ts).
3
+ *
4
+ * The replay contract under test, in terms of the Rust engine's
5
+ * request/response protocol:
6
+ * - a step at the current index EXECUTES and yields step_complete
7
+ * - a step below the current index REPLAYS its recorded output
8
+ * - sleep / waitForEvent at the frontier pause the run
9
+ * - a delivered event (recorded as `event:<name>`) resumes with data
10
+ * - falling off the end of the workflow fn yields complete
11
+ * - a throwing step yields fail with the step's name
12
+ * - a nondeterministic replay (missing recorded step) yields fail
13
+ */
14
+ import { describe, expect, test } from "bun:test";
15
+ import {
16
+ executeWorkflowSlice,
17
+ isWorkflowDefinition,
18
+ workflow,
19
+ type WorkflowRunRequest,
20
+ type WorkflowStepResult,
21
+ } from "./workflows";
22
+ import type { ActionCtx } from "./types";
23
+
24
+ // The executor never touches ctx itself — it only passes it through to
25
+ // step closures. A cast-through-unknown stub is enough.
26
+ const ctx = {} as unknown as ActionCtx;
27
+
28
+ function req(
29
+ currentStep: number,
30
+ completed: WorkflowStepResult[],
31
+ input: unknown = { userId: "u1" },
32
+ ): WorkflowRunRequest {
33
+ return {
34
+ workflow_id: "wf_1",
35
+ workflow_name: "onboarding",
36
+ input,
37
+ current_step: currentStep,
38
+ completed_steps: completed,
39
+ };
40
+ }
41
+
42
+ function done(name: string, output: unknown): WorkflowStepResult {
43
+ return { step_id: "s", name, status: "completed", output };
44
+ }
45
+
46
+ const executed: string[] = [];
47
+
48
+ const def = workflow("onboarding", async (wf, _ctx) => {
49
+ const user = await wf.step("load-user", () => {
50
+ executed.push("load-user");
51
+ return { email: "a@b.co" };
52
+ });
53
+ await wf.step("send-welcome", () => {
54
+ executed.push("send-welcome");
55
+ return { sent: true, to: user.email };
56
+ });
57
+ await wf.sleep("24h");
58
+ const confirmation = await wf.waitForEvent<{ ok: boolean }>("confirmed");
59
+ return { finished: true, ok: confirmation.ok };
60
+ });
61
+
62
+ describe("executeWorkflowSlice", () => {
63
+ test("workflow() output passes the loader's shape check", () => {
64
+ expect(isWorkflowDefinition(def)).toBe(true);
65
+ expect(isWorkflowDefinition({ name: "x" })).toBe(false);
66
+ });
67
+
68
+ test("slice 0: executes the first step only", async () => {
69
+ executed.length = 0;
70
+ const res = await executeWorkflowSlice(def, req(0, []), ctx);
71
+ expect(res).toMatchObject({
72
+ action: "step_complete",
73
+ step_name: "load-user",
74
+ output: { email: "a@b.co" },
75
+ });
76
+ expect(executed).toEqual(["load-user"]);
77
+ });
78
+
79
+ test("slice 1: replays step 0's output, executes step 1", async () => {
80
+ executed.length = 0;
81
+ const res = await executeWorkflowSlice(
82
+ def,
83
+ req(1, [done("load-user", { email: "recorded@b.co" })]),
84
+ ctx,
85
+ );
86
+ expect(res).toMatchObject({
87
+ action: "step_complete",
88
+ step_name: "send-welcome",
89
+ // Proof the replay fed the RECORDED output into the live closure.
90
+ output: { sent: true, to: "recorded@b.co" },
91
+ });
92
+ expect(executed).toEqual(["send-welcome"]);
93
+ });
94
+
95
+ test("slice 2: pauses at sleep without re-executing steps", async () => {
96
+ executed.length = 0;
97
+ const res = await executeWorkflowSlice(
98
+ def,
99
+ req(2, [
100
+ done("load-user", { email: "a@b.co" }),
101
+ done("send-welcome", { sent: true }),
102
+ ]),
103
+ ctx,
104
+ );
105
+ expect(res).toEqual({ action: "sleep", duration: "24h" });
106
+ expect(executed).toEqual([]);
107
+ });
108
+
109
+ test("slice 3 (woken): pauses at waitForEvent", async () => {
110
+ const res = await executeWorkflowSlice(
111
+ def,
112
+ req(3, [
113
+ done("load-user", { email: "a@b.co" }),
114
+ done("send-welcome", { sent: true }),
115
+ ]),
116
+ ctx,
117
+ );
118
+ expect(res).toEqual({ action: "wait_event", event: "confirmed" });
119
+ });
120
+
121
+ test("slice 4 (event delivered): completes with the event data", async () => {
122
+ const res = await executeWorkflowSlice(
123
+ def,
124
+ req(4, [
125
+ done("load-user", { email: "a@b.co" }),
126
+ done("send-welcome", { sent: true }),
127
+ done("event:confirmed", { ok: true }),
128
+ ]),
129
+ ctx,
130
+ );
131
+ expect(res).toEqual({
132
+ action: "complete",
133
+ output: { finished: true, ok: true },
134
+ });
135
+ });
136
+
137
+ test("a throwing step fails with its name — engine retry accounting keys on it", async () => {
138
+ const failing = workflow("boom", async (wf) => {
139
+ await wf.step("explode", () => {
140
+ throw new Error("provider 500");
141
+ });
142
+ });
143
+ const res = await executeWorkflowSlice(failing, req(0, []), ctx);
144
+ expect(res).toEqual({
145
+ action: "fail",
146
+ error: "provider 500",
147
+ step_name: "explode",
148
+ });
149
+ });
150
+
151
+ test("nondeterministic replay (missing recorded step) fails loudly", async () => {
152
+ // current_step says 2 steps are done, but only one was recorded
153
+ // under a DIFFERENT name — the author reordered/renamed steps.
154
+ const res = await executeWorkflowSlice(
155
+ def,
156
+ req(2, [done("some-old-name", {})]),
157
+ ctx,
158
+ );
159
+ expect(res.action).toBe("fail");
160
+ expect((res as { error: string }).error).toContain("replay mismatch");
161
+ });
162
+
163
+ test("duplicate step names fail loudly instead of replaying the wrong output", async () => {
164
+ // The replay cache is name-keyed: without the uniqueness check, the
165
+ // second "fetch" would silently replay the FIRST record's output on
166
+ // every later slice — wrong data in a durability primitive.
167
+ const dup = workflow("dup", async (wf) => {
168
+ await wf.step("fetch", () => "A");
169
+ await wf.step("fetch", () => "B");
170
+ });
171
+ const res = await executeWorkflowSlice(
172
+ dup,
173
+ req(1, [done("fetch", "A")]),
174
+ ctx,
175
+ );
176
+ expect(res.action).toBe("fail");
177
+ expect((res as { error: string }).error).toContain("duplicate step name");
178
+ });
179
+
180
+ test("duplicate waitForEvent names fail loudly too", async () => {
181
+ const dup = workflow("dup-ev", async (wf) => {
182
+ await wf.waitForEvent("go");
183
+ await wf.waitForEvent("go");
184
+ });
185
+ const res = await executeWorkflowSlice(
186
+ dup,
187
+ req(1, [done("event:go", { n: 1 })]),
188
+ ctx,
189
+ );
190
+ expect(res.action).toBe("fail");
191
+ expect((res as { error: string }).error).toContain("duplicate event name");
192
+ });
193
+
194
+ test("a stepless workflow completes on its first slice", async () => {
195
+ const trivial = workflow("noop", async (wf) => ({ echoed: wf.input }));
196
+ const res = await executeWorkflowSlice(trivial, req(0, [], 42), ctx);
197
+ expect(res).toEqual({ action: "complete", output: { echoed: 42 } });
198
+ });
199
+ });
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Durable workflows: authoring surface + the per-slice executor.
3
+ *
4
+ * A workflow is a TS function that composes `step` / `sleep` /
5
+ * `waitForEvent` calls. The Rust WorkflowEngine owns the durable state
6
+ * (instance, step results, wake timers — persisted in SQLite); this
7
+ * module executes ONE slice at a time by REPLAY: on every advance the
8
+ * whole workflow function re-runs, already-completed steps return their
9
+ * recorded outputs without executing, and the first not-yet-done
10
+ * primitive either executes (a `step` at the current index) or pauses
11
+ * the run (`sleep` / `waitForEvent`).
12
+ *
13
+ * Determinism contract for authors: the sequence of step/sleep/
14
+ * waitForEvent calls must be identical on every replay for the same
15
+ * input + recorded outputs. Branch on `input` and on step OUTPUTS all
16
+ * you like — never on wall-clock time, randomness, or external state
17
+ * read outside a step.
18
+ *
19
+ * Files live in the app's `workflows/` directory (sibling of
20
+ * `functions/`), one default-exported `workflow(...)` per file. The
21
+ * runtime reports them in its ready handshake; the host registers them
22
+ * and drives execution back through the function pool as an internal
23
+ * `__pylon_workflow_run` action call — so step code runs with a full
24
+ * ActionCtx (ctx.db, ctx.llm, ctx.scheduler, the idle timeout, and
25
+ * cancellation) rather than in a bespoke side-channel process.
26
+ */
27
+
28
+ import type { ActionCtx } from "./types";
29
+
30
+ /** Wire shape of one recorded step — mirrors Rust `StepResult` exactly. */
31
+ export interface WorkflowStepResult {
32
+ step_id: string;
33
+ name: string;
34
+ status: "pending" | "running" | "completed" | "failed" | "skipped";
35
+ output?: unknown;
36
+ error?: string | null;
37
+ started_at?: string | null;
38
+ completed_at?: string | null;
39
+ duration_ms?: number | null;
40
+ retry_count?: number;
41
+ }
42
+
43
+ /** The advance request the Rust engine sends for one slice. */
44
+ export interface WorkflowRunRequest {
45
+ workflow_id: string;
46
+ workflow_name: string;
47
+ input: unknown;
48
+ current_step: number;
49
+ completed_steps: WorkflowStepResult[];
50
+ }
51
+
52
+ /** The verdict of one slice — mirrors Rust `apply_response`'s actions. */
53
+ export type WorkflowRunnerResponse =
54
+ | { action: "step_complete"; step_name: string; output: unknown; duration_ms: number }
55
+ | { action: "sleep"; duration: string }
56
+ | { action: "wait_event"; event: string }
57
+ | { action: "complete"; output: unknown }
58
+ | { action: "fail"; error: string; step_name?: string };
59
+
60
+ /** What a workflow function receives, besides the per-slice ActionCtx. */
61
+ export interface WorkflowRun<TInput = unknown> {
62
+ /** The workflow instance id (stable across slices + restarts). */
63
+ id: string;
64
+ /** The input `start()` was called with. */
65
+ input: TInput;
66
+ /**
67
+ * Run a named step exactly once. On replay a completed step returns
68
+ * its recorded output without executing. Step names must be unique
69
+ * within one workflow run — the replay cache is name-keyed.
70
+ */
71
+ step<T>(name: string, fn: () => Promise<T> | T): Promise<T>;
72
+ /** Pause the workflow for a duration ("30s", "5m", "24h", "7d"). */
73
+ sleep(duration: string): Promise<void>;
74
+ /**
75
+ * Pause until `POST /api/workflows/<id>/event` delivers this event.
76
+ * Resolves with the event's data payload.
77
+ */
78
+ waitForEvent<T = unknown>(eventName: string): Promise<T>;
79
+ }
80
+
81
+ export interface WorkflowDefinition<TInput = unknown> {
82
+ readonly __pylonWorkflow: true;
83
+ name: string;
84
+ description?: string;
85
+ /** Max retries per step before the workflow fails (engine default: 3). */
86
+ maxRetries?: number;
87
+ fn: (wf: WorkflowRun<TInput>, ctx: ActionCtx) => Promise<unknown>;
88
+ }
89
+
90
+ /**
91
+ * Declare a workflow. Default-export the result from a file in the
92
+ * app's `workflows/` directory:
93
+ *
94
+ * ```ts
95
+ * // workflows/onboarding.ts
96
+ * import { workflow } from "@pylonsync/functions";
97
+ *
98
+ * export default workflow("onboarding", async (wf, ctx) => {
99
+ * const user = await wf.step("load-user", () =>
100
+ * ctx.runQuery("getUser", { id: wf.input.userId }),
101
+ * );
102
+ * await wf.step("send-welcome", () =>
103
+ * ctx.email.send({ to: user.email, subject: "Welcome!", text: "..." }),
104
+ * );
105
+ * await wf.sleep("24h");
106
+ * const confirmed = await wf.waitForEvent("email_confirmed");
107
+ * return { done: true, confirmed };
108
+ * });
109
+ * ```
110
+ */
111
+ export function workflow<TInput = unknown>(
112
+ name: string,
113
+ fn: (wf: WorkflowRun<TInput>, ctx: ActionCtx) => Promise<unknown>,
114
+ opts?: { description?: string; maxRetries?: number },
115
+ ): WorkflowDefinition<TInput> {
116
+ return {
117
+ __pylonWorkflow: true,
118
+ name,
119
+ description: opts?.description,
120
+ maxRetries: opts?.maxRetries,
121
+ fn,
122
+ };
123
+ }
124
+
125
+ /** Runtime shape check for a `workflows/` file's default export. */
126
+ export function isWorkflowDefinition(v: unknown): v is WorkflowDefinition {
127
+ const w = v as WorkflowDefinition | undefined;
128
+ return (
129
+ !!w &&
130
+ w.__pylonWorkflow === true &&
131
+ typeof w.name === "string" &&
132
+ typeof w.fn === "function"
133
+ );
134
+ }
135
+
136
+ /** Control-flow sentinel: the slice reached a pause point. */
137
+ class WorkflowPaused extends Error {
138
+ constructor(public response: WorkflowRunnerResponse) {
139
+ super("workflow paused");
140
+ }
141
+ }
142
+
143
+ /** Control-flow sentinel: a step past the current index was reached. */
144
+ class SliceBoundary extends Error {
145
+ constructor(public response: WorkflowRunnerResponse) {
146
+ super("slice boundary");
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Execute one slice of `def` for `request`, returning the engine verdict.
152
+ * Never throws for handler errors — a step failure becomes
153
+ * `{action: "fail"}` so the engine's retry accounting runs.
154
+ */
155
+ export async function executeWorkflowSlice(
156
+ def: WorkflowDefinition,
157
+ request: WorkflowRunRequest,
158
+ ctx: ActionCtx,
159
+ ): Promise<WorkflowRunnerResponse> {
160
+ let index = 0;
161
+ let currentStepName = "";
162
+ // Name uniqueness is enforced, not just documented: the replay cache
163
+ // is name-keyed, so a duplicate step name would silently replay the
164
+ // FIRST record's output into the second call — wrong data in a
165
+ // durability primitive. Same for repeated waitForEvent names.
166
+ const seenNames = new Set<string>();
167
+ const claimName = (kind: "step" | "event", name: string) => {
168
+ const key = `${kind}:${name}`;
169
+ if (seenNames.has(key)) {
170
+ throw new Error(
171
+ `duplicate ${kind} name "${name}" in one workflow run — names must be unique (the replay cache is name-keyed)`,
172
+ );
173
+ }
174
+ seenNames.add(key);
175
+ };
176
+
177
+ const wf: WorkflowRun = {
178
+ id: request.workflow_id,
179
+ input: request.input,
180
+
181
+ async step<T>(name: string, fn: () => Promise<T> | T): Promise<T> {
182
+ claimName("step", name);
183
+ const myIndex = index++;
184
+ if (myIndex < request.current_step) {
185
+ // Replay: return the recorded output. Name-keyed lookup so an
186
+ // author inserting a step ahead of a recorded one fails loudly
187
+ // (missing name) instead of silently reusing the wrong output.
188
+ const done = request.completed_steps.find(
189
+ (s) => s.name === name && s.status === "completed",
190
+ );
191
+ if (!done) {
192
+ throw new Error(
193
+ `workflow replay mismatch: step "${name}" (index ${myIndex}) has no recorded result — ` +
194
+ "the step sequence must be deterministic across replays",
195
+ );
196
+ }
197
+ return done.output as T;
198
+ }
199
+ if (myIndex === request.current_step) {
200
+ currentStepName = name;
201
+ const started = Date.now();
202
+ const output = await fn();
203
+ throw new SliceBoundary({
204
+ action: "step_complete",
205
+ step_name: name,
206
+ output: output ?? null,
207
+ duration_ms: Date.now() - started,
208
+ });
209
+ }
210
+ throw new SliceBoundary({
211
+ action: "fail",
212
+ error: `internal: step "${name}" (index ${myIndex}) reached past the current slice`,
213
+ step_name: name,
214
+ });
215
+ },
216
+
217
+ async sleep(duration: string): Promise<void> {
218
+ const myIndex = index++;
219
+ if (myIndex < request.current_step) return; // already slept
220
+ throw new WorkflowPaused({ action: "sleep", duration });
221
+ },
222
+
223
+ async waitForEvent<T>(eventName: string): Promise<T> {
224
+ claimName("event", eventName);
225
+ const myIndex = index++;
226
+ // A delivered event is recorded as a completed step named
227
+ // `event:<name>` (the Rust engine's send_event writes it).
228
+ const delivered = request.completed_steps.find(
229
+ (s) => s.name === `event:${eventName}` && s.status === "completed",
230
+ );
231
+ if (myIndex < request.current_step && delivered) {
232
+ return delivered.output as T;
233
+ }
234
+ throw new WorkflowPaused({ action: "wait_event", event: eventName });
235
+ },
236
+ };
237
+
238
+ try {
239
+ const output = await def.fn(wf, ctx);
240
+ return { action: "complete", output: output ?? null };
241
+ } catch (err) {
242
+ if (err instanceof SliceBoundary) return err.response;
243
+ if (err instanceof WorkflowPaused) return err.response;
244
+ const message =
245
+ err instanceof Error ? err.message : String(err ?? "unknown error");
246
+ return {
247
+ action: "fail",
248
+ error: message,
249
+ step_name: currentStepName || undefined,
250
+ };
251
+ }
252
+ }