@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/dist/define.d.ts +21 -11
- package/dist/index.d.ts +3 -1
- package/dist/runtime.d.ts +9 -1
- package/dist/types.d.ts +126 -0
- package/dist/workflows.d.ts +128 -0
- package/package.json +1 -1
- package/src/define.ts +21 -11
- package/src/index.ts +13 -0
- package/src/runtime-cancel.test.ts +137 -0
- package/src/runtime-vector.test.ts +87 -0
- package/src/runtime-workflows.test.ts +263 -0
- package/src/runtime.ts +201 -1
- package/src/types.ts +128 -0
- package/src/workflows.test.ts +199 -0
- package/src/workflows.ts +252 -0
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
|
+
});
|
package/src/workflows.ts
ADDED
|
@@ -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
|
+
}
|