@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
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire-shape tests for the vector surfaces (runtime.ts):
|
|
3
|
+
*
|
|
4
|
+
* 1. `ctx.db.vectorSearch` emits `{type:"db", op:"vector_search"}`
|
|
5
|
+
* frames with the query on `data` and the unsafe_op / ssr_read
|
|
6
|
+
* flags the Rust policy gate keys on.
|
|
7
|
+
* 2. `ctx.llm.embed` emits `{type:"llm_embed", request:{input,model}}`
|
|
8
|
+
* with an op_id so concurrent embeds demux.
|
|
9
|
+
*
|
|
10
|
+
* Same child-process harness as runtime-db.test.ts — runtime.ts runs
|
|
11
|
+
* main() on import, so the builders are exercised in a probe script
|
|
12
|
+
* whose emitted NDJSON frames are the assertion target.
|
|
13
|
+
*/
|
|
14
|
+
import { expect, test } from "bun:test";
|
|
15
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
const RUNTIME = join(import.meta.dir, "runtime.ts");
|
|
20
|
+
|
|
21
|
+
const SCRIPT = `
|
|
22
|
+
import { buildDbReader, buildLlm } from ${JSON.stringify(RUNTIME)};
|
|
23
|
+
|
|
24
|
+
const reader = buildDbReader("c_r");
|
|
25
|
+
const llm = buildLlm("c_l");
|
|
26
|
+
|
|
27
|
+
// No host replies — don't await; the emitted frames are the target.
|
|
28
|
+
reader.vectorSearch("Doc", {
|
|
29
|
+
field: "embedding",
|
|
30
|
+
vector: [1, 0, 0],
|
|
31
|
+
limit: 5,
|
|
32
|
+
metric: "l2",
|
|
33
|
+
filter: { kind: "a" },
|
|
34
|
+
}).catch(() => {});
|
|
35
|
+
reader.unsafe.vectorSearch("Doc", { field: "embedding", vector: [1] }).catch(() => {});
|
|
36
|
+
llm.embed(["hello", "world"], { model: "text-embedding-3-large" }).catch(() => {});
|
|
37
|
+
|
|
38
|
+
setTimeout(() => process.exit(0), 300);
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
test("vectorSearch + llm.embed emit the exact wire shapes the host parses", async () => {
|
|
42
|
+
const dir = mkdtempSync(join(tmpdir(), "pylon-fn-vec-"));
|
|
43
|
+
const scriptPath = join(dir, "probe.ts");
|
|
44
|
+
writeFileSync(scriptPath, SCRIPT);
|
|
45
|
+
|
|
46
|
+
const proc = Bun.spawn(["bun", scriptPath], {
|
|
47
|
+
stdin: "pipe",
|
|
48
|
+
stdout: "pipe",
|
|
49
|
+
stderr: "pipe",
|
|
50
|
+
});
|
|
51
|
+
const [stdout] = await Promise.all([
|
|
52
|
+
new Response(proc.stdout).text(),
|
|
53
|
+
proc.exited,
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
const frames = stdout
|
|
57
|
+
.split("\n")
|
|
58
|
+
.filter((l) => l.trim().startsWith("{"))
|
|
59
|
+
.map((l) => JSON.parse(l) as Record<string, any>);
|
|
60
|
+
|
|
61
|
+
const vs = frames.filter((f) => f.type === "db" && f.op === "vector_search");
|
|
62
|
+
expect(vs.length).toBe(2);
|
|
63
|
+
const safe = vs.find((f) => f.unsafe_op === false)!;
|
|
64
|
+
expect(safe).toMatchObject({
|
|
65
|
+
entity: "Doc",
|
|
66
|
+
ssr_read: false,
|
|
67
|
+
data: {
|
|
68
|
+
field: "embedding",
|
|
69
|
+
vector: [1, 0, 0],
|
|
70
|
+
limit: 5,
|
|
71
|
+
metric: "l2",
|
|
72
|
+
filter: { kind: "a" },
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
expect(safe.op_id).toBeDefined();
|
|
76
|
+
const unsafe = vs.find((f) => f.unsafe_op === true)!;
|
|
77
|
+
expect(unsafe.data.field).toBe("embedding");
|
|
78
|
+
|
|
79
|
+
const embed = frames.find((f) => f.type === "llm_embed")!;
|
|
80
|
+
expect(embed).toBeDefined();
|
|
81
|
+
expect(embed.call_id).toBe("c_l");
|
|
82
|
+
expect(embed.op_id).toBeDefined();
|
|
83
|
+
expect(embed.request).toMatchObject({
|
|
84
|
+
input: ["hello", "world"],
|
|
85
|
+
model: "text-embedding-3-large",
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end protocol test for workflow execution through the REAL
|
|
3
|
+
* runtime (runtime.ts child process) — the seam the 2026-08 audit
|
|
4
|
+
* flagged: the old workflow surface type-checked everywhere and
|
|
5
|
+
* executed nowhere.
|
|
6
|
+
*
|
|
7
|
+
* The contract under test, wire-for-wire what the Rust host does:
|
|
8
|
+
*
|
|
9
|
+
* 1. A `workflows/` dir next to the functions dir is discovered at
|
|
10
|
+
* boot; the ready handshake declares each workflow AND registers
|
|
11
|
+
* the internal `__pylon_workflow_run` action.
|
|
12
|
+
* 2. A `call` frame for `__pylon_workflow_run` with the engine's
|
|
13
|
+
* advance request executes ONE slice and returns the runner
|
|
14
|
+
* verdict as a normal `return` frame.
|
|
15
|
+
* 3. Step code runs with a live ActionCtx — proven by a step that
|
|
16
|
+
* round-trips `ctx.runQuery` through the host (this test) before
|
|
17
|
+
* producing its output.
|
|
18
|
+
*/
|
|
19
|
+
import { expect, test } from "bun:test";
|
|
20
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
|
|
24
|
+
const RUNTIME = join(import.meta.dir, "runtime.ts");
|
|
25
|
+
const WORKFLOWS_MOD = join(import.meta.dir, "workflows.ts");
|
|
26
|
+
|
|
27
|
+
const WORKFLOW_FILE = `
|
|
28
|
+
import { workflow } from ${JSON.stringify(WORKFLOWS_MOD)};
|
|
29
|
+
|
|
30
|
+
export default workflow("greet", async (wf, ctx) => {
|
|
31
|
+
const user = await wf.step("load-user", () =>
|
|
32
|
+
ctx.runQuery("getUser", { id: wf.input.userId }),
|
|
33
|
+
);
|
|
34
|
+
const greeting = await wf.step("compose", () => \`Hello \${user.name}\`);
|
|
35
|
+
return { greeting };
|
|
36
|
+
});
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
function parseFrames(text: string): Record<string, unknown>[] {
|
|
40
|
+
return text
|
|
41
|
+
.split("\n")
|
|
42
|
+
.filter((l) => l.trim().startsWith("{"))
|
|
43
|
+
.map((l) => JSON.parse(l) as Record<string, unknown>);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const START_ACTION = `
|
|
47
|
+
export default {
|
|
48
|
+
type: "action",
|
|
49
|
+
auth: "user",
|
|
50
|
+
handler: async (ctx, args) => {
|
|
51
|
+
const started = await ctx.workflows.start("greet", { userId: args.userId });
|
|
52
|
+
return { workflowId: started.id };
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
test("workflows/ dir boots, declares in ready, and slices execute with a live ctx", async () => {
|
|
58
|
+
// App root: one action (exercises ctx.workflows.start) + one workflow.
|
|
59
|
+
const appDir = mkdtempSync(join(tmpdir(), "pylon-wf-e2e-"));
|
|
60
|
+
mkdirSync(join(appDir, "functions"));
|
|
61
|
+
writeFileSync(join(appDir, "functions", "kickoff.ts"), START_ACTION);
|
|
62
|
+
mkdirSync(join(appDir, "workflows"));
|
|
63
|
+
writeFileSync(join(appDir, "workflows", "greet.ts"), WORKFLOW_FILE);
|
|
64
|
+
|
|
65
|
+
// Spawn the real runtime exactly as the host does: cwd = app root,
|
|
66
|
+
// argv[2] = functions dir.
|
|
67
|
+
const proc = Bun.spawn([process.execPath, "run", RUNTIME, "functions"], {
|
|
68
|
+
cwd: appDir,
|
|
69
|
+
stdin: "pipe",
|
|
70
|
+
stdout: "pipe",
|
|
71
|
+
stderr: "pipe",
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const reader = proc.stdout.getReader();
|
|
75
|
+
const decoder = new TextDecoder();
|
|
76
|
+
let buffered = "";
|
|
77
|
+
const frames: Record<string, unknown>[] = [];
|
|
78
|
+
async function readUntil(
|
|
79
|
+
pred: (fs: Record<string, unknown>[]) => boolean,
|
|
80
|
+
): Promise<void> {
|
|
81
|
+
while (!pred(frames)) {
|
|
82
|
+
const { done, value } = await reader.read();
|
|
83
|
+
if (done) throw new Error(`child exited early; frames: ${buffered}`);
|
|
84
|
+
buffered += decoder.decode(value, { stream: true });
|
|
85
|
+
frames.length = 0;
|
|
86
|
+
frames.push(...parseFrames(buffered));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const send = (msg: Record<string, unknown>) =>
|
|
90
|
+
proc.stdin.write(JSON.stringify(msg) + "\n");
|
|
91
|
+
|
|
92
|
+
// 1. Ready declares the workflow + the implicit runner action.
|
|
93
|
+
await readUntil((fs) => fs.some((f) => f.type === "ready"));
|
|
94
|
+
const ready = frames.find((f) => f.type === "ready") as {
|
|
95
|
+
functions: Array<{ name: string; internal: boolean; auth: string }>;
|
|
96
|
+
workflows: Array<{
|
|
97
|
+
name: string;
|
|
98
|
+
description: string;
|
|
99
|
+
max_retries: number | null;
|
|
100
|
+
}>;
|
|
101
|
+
};
|
|
102
|
+
expect(ready.workflows).toEqual([
|
|
103
|
+
{ name: "greet", description: "", max_retries: null },
|
|
104
|
+
]);
|
|
105
|
+
const runFn = ready.functions.find((f) => f.name === "__pylon_workflow_run");
|
|
106
|
+
expect(runFn).toBeDefined();
|
|
107
|
+
expect(runFn!.internal).toBe(true);
|
|
108
|
+
expect(runFn!.auth).toBe("admin");
|
|
109
|
+
|
|
110
|
+
// 2. Slice 0: executes "load-user", whose body calls ctx.runQuery —
|
|
111
|
+
// answer it like the host's RunFn arm would.
|
|
112
|
+
send({
|
|
113
|
+
type: "call",
|
|
114
|
+
call_id: "c_1",
|
|
115
|
+
fn_name: "__pylon_workflow_run",
|
|
116
|
+
fn_type: "action",
|
|
117
|
+
auth: { user_id: null, is_admin: true },
|
|
118
|
+
args: {
|
|
119
|
+
workflow_id: "wf_1",
|
|
120
|
+
workflow_name: "greet",
|
|
121
|
+
input: { userId: "u1" },
|
|
122
|
+
current_step: 0,
|
|
123
|
+
completed_steps: [],
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
await readUntil((fs) => fs.some((f) => f.type === "run_fn"));
|
|
127
|
+
const runReq = frames.find((f) => f.type === "run_fn") as {
|
|
128
|
+
call_id: string;
|
|
129
|
+
fn_name: string;
|
|
130
|
+
args: unknown;
|
|
131
|
+
};
|
|
132
|
+
expect(runReq.fn_name).toBe("getUser");
|
|
133
|
+
expect(runReq.args).toEqual({ id: "u1" });
|
|
134
|
+
send({
|
|
135
|
+
type: "result",
|
|
136
|
+
call_id: runReq.call_id,
|
|
137
|
+
data: { id: "u1", name: "Ada" },
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await readUntil((fs) =>
|
|
141
|
+
fs.some((f) => f.type === "return" && f.call_id === "c_1"),
|
|
142
|
+
);
|
|
143
|
+
const slice0 = frames.find(
|
|
144
|
+
(f) => f.type === "return" && f.call_id === "c_1",
|
|
145
|
+
) as { value: Record<string, unknown> };
|
|
146
|
+
expect(slice0.value).toMatchObject({
|
|
147
|
+
action: "step_complete",
|
|
148
|
+
step_name: "load-user",
|
|
149
|
+
output: { id: "u1", name: "Ada" },
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// 3. Slice 1: replays load-user from the recorded output (NO run_fn
|
|
153
|
+
// round-trip this time), executes "compose".
|
|
154
|
+
send({
|
|
155
|
+
type: "call",
|
|
156
|
+
call_id: "c_2",
|
|
157
|
+
fn_name: "__pylon_workflow_run",
|
|
158
|
+
fn_type: "action",
|
|
159
|
+
auth: { user_id: null, is_admin: true },
|
|
160
|
+
args: {
|
|
161
|
+
workflow_id: "wf_1",
|
|
162
|
+
workflow_name: "greet",
|
|
163
|
+
input: { userId: "u1" },
|
|
164
|
+
current_step: 1,
|
|
165
|
+
completed_steps: [
|
|
166
|
+
{
|
|
167
|
+
step_id: "step_0",
|
|
168
|
+
name: "load-user",
|
|
169
|
+
status: "completed",
|
|
170
|
+
output: { id: "u1", name: "Ada" },
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
await readUntil((fs) =>
|
|
176
|
+
fs.some((f) => f.type === "return" && f.call_id === "c_2"),
|
|
177
|
+
);
|
|
178
|
+
const slice1 = frames.find(
|
|
179
|
+
(f) => f.type === "return" && f.call_id === "c_2",
|
|
180
|
+
) as { value: Record<string, unknown> };
|
|
181
|
+
expect(slice1.value).toMatchObject({
|
|
182
|
+
action: "step_complete",
|
|
183
|
+
step_name: "compose",
|
|
184
|
+
output: "Hello Ada",
|
|
185
|
+
});
|
|
186
|
+
// Replay must not have re-fetched the user.
|
|
187
|
+
expect(frames.filter((f) => f.type === "run_fn")).toHaveLength(1);
|
|
188
|
+
|
|
189
|
+
// 4. Slice 2: both steps replay; the workflow completes.
|
|
190
|
+
send({
|
|
191
|
+
type: "call",
|
|
192
|
+
call_id: "c_3",
|
|
193
|
+
fn_name: "__pylon_workflow_run",
|
|
194
|
+
fn_type: "action",
|
|
195
|
+
auth: { user_id: null, is_admin: true },
|
|
196
|
+
args: {
|
|
197
|
+
workflow_id: "wf_1",
|
|
198
|
+
workflow_name: "greet",
|
|
199
|
+
input: { userId: "u1" },
|
|
200
|
+
current_step: 2,
|
|
201
|
+
completed_steps: [
|
|
202
|
+
{
|
|
203
|
+
step_id: "step_0",
|
|
204
|
+
name: "load-user",
|
|
205
|
+
status: "completed",
|
|
206
|
+
output: { id: "u1", name: "Ada" },
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
step_id: "step_1",
|
|
210
|
+
name: "compose",
|
|
211
|
+
status: "completed",
|
|
212
|
+
output: "Hello Ada",
|
|
213
|
+
},
|
|
214
|
+
],
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
await readUntil((fs) =>
|
|
218
|
+
fs.some((f) => f.type === "return" && f.call_id === "c_3"),
|
|
219
|
+
);
|
|
220
|
+
const slice2 = frames.find(
|
|
221
|
+
(f) => f.type === "return" && f.call_id === "c_3",
|
|
222
|
+
) as { value: Record<string, unknown> };
|
|
223
|
+
expect(slice2.value).toEqual({
|
|
224
|
+
action: "complete",
|
|
225
|
+
output: { greeting: "Hello Ada" },
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// 5. App code starts a workflow: the kickoff action's
|
|
229
|
+
// ctx.workflows.start emits a workflow_op frame; answer it as the
|
|
230
|
+
// host's engine hook would and confirm the id flows back out.
|
|
231
|
+
send({
|
|
232
|
+
type: "call",
|
|
233
|
+
call_id: "c_4",
|
|
234
|
+
fn_name: "kickoff",
|
|
235
|
+
fn_type: "action",
|
|
236
|
+
auth: { user_id: "u1", is_admin: false },
|
|
237
|
+
args: { userId: "u1" },
|
|
238
|
+
});
|
|
239
|
+
await readUntil((fs) => fs.some((f) => f.type === "workflow_op"));
|
|
240
|
+
const wfOp = frames.find((f) => f.type === "workflow_op") as {
|
|
241
|
+
call_id: string;
|
|
242
|
+
op: string;
|
|
243
|
+
name: string;
|
|
244
|
+
input: unknown;
|
|
245
|
+
};
|
|
246
|
+
expect(wfOp).toMatchObject({
|
|
247
|
+
call_id: "c_4",
|
|
248
|
+
op: "start",
|
|
249
|
+
name: "greet",
|
|
250
|
+
input: { userId: "u1" },
|
|
251
|
+
});
|
|
252
|
+
send({ type: "result", call_id: "c_4", data: { id: "wf_777" } });
|
|
253
|
+
await readUntil((fs) =>
|
|
254
|
+
fs.some((f) => f.type === "return" && f.call_id === "c_4"),
|
|
255
|
+
);
|
|
256
|
+
const kicked = frames.find(
|
|
257
|
+
(f) => f.type === "return" && f.call_id === "c_4",
|
|
258
|
+
) as { value: Record<string, unknown> };
|
|
259
|
+
expect(kicked.value).toEqual({ workflowId: "wf_777" });
|
|
260
|
+
|
|
261
|
+
proc.kill();
|
|
262
|
+
await proc.exited;
|
|
263
|
+
}, 30_000);
|
package/src/runtime.ts
CHANGED
|
@@ -29,6 +29,7 @@ import type {
|
|
|
29
29
|
LlmCompleteResponse,
|
|
30
30
|
LlmStreamEvent,
|
|
31
31
|
Rooms,
|
|
32
|
+
Workflows,
|
|
32
33
|
Connections,
|
|
33
34
|
QueryCtx,
|
|
34
35
|
MutationCtx,
|
|
@@ -188,6 +189,36 @@ const pendingRpcs = new Map<
|
|
|
188
189
|
*/
|
|
189
190
|
const streamSinks = new Map<string, (event: unknown) => void>();
|
|
190
191
|
|
|
192
|
+
/**
|
|
193
|
+
* Calls the host cancelled (idle-timeout exceeded), keyed by call_id →
|
|
194
|
+
* reason. The host stops listening the moment it sends `cancel` — its
|
|
195
|
+
* demux route is gone — so the goals here are to stop the handler from
|
|
196
|
+
* doing further work, not to answer:
|
|
197
|
+
* - the call's AbortController fires (ctx.signal → fetch/LLM SDKs stop),
|
|
198
|
+
* - its in-flight RPC promises reject,
|
|
199
|
+
* - its FUTURE RPCs throw CALL_CANCELLED (this is what actually stops
|
|
200
|
+
* a typical handler at its next ctx.db/ctx.scheduler touch),
|
|
201
|
+
* - its return/error frames are suppressed (the host would drop them
|
|
202
|
+
* as unknown-route frames anyway; suppressing skips the wasted send).
|
|
203
|
+
* Entries expire after CANCEL_TOMBSTONE_MS so a late cancel for an
|
|
204
|
+
* already-finished call can't leak forever.
|
|
205
|
+
*/
|
|
206
|
+
const cancelledCalls = new Map<string, string>();
|
|
207
|
+
const callAborts = new Map<string, AbortController>();
|
|
208
|
+
const CANCEL_TOMBSTONE_MS = 5 * 60_000;
|
|
209
|
+
|
|
210
|
+
function cancelledError(callId: string): Error {
|
|
211
|
+
const reason = cancelledCalls.get(callId) ?? "cancelled by host";
|
|
212
|
+
const err = new Error(`call cancelled by host: ${reason}`);
|
|
213
|
+
(err as { code?: string }).code = "CALL_CANCELLED";
|
|
214
|
+
return err;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Throw when the host has cancelled this call — RPC entry gate. */
|
|
218
|
+
function throwIfCancelled(callId: string): void {
|
|
219
|
+
if (cancelledCalls.has(callId)) throw cancelledError(callId);
|
|
220
|
+
}
|
|
221
|
+
|
|
191
222
|
let opSeq = 0;
|
|
192
223
|
function nextOpId(callId: string): string {
|
|
193
224
|
opSeq += 1;
|
|
@@ -320,6 +351,23 @@ function dispatch(line: string): void {
|
|
|
320
351
|
error: err?.message || String(err),
|
|
321
352
|
});
|
|
322
353
|
});
|
|
354
|
+
} else if (msg.type === "cancel") {
|
|
355
|
+
// Host cancelled one call (idle timeout). See cancelledCalls above.
|
|
356
|
+
const c = msg as unknown as { call_id: string; reason?: string };
|
|
357
|
+
cancelledCalls.set(c.call_id, c.reason ?? "cancelled by host");
|
|
358
|
+
setTimeout(() => cancelledCalls.delete(c.call_id), CANCEL_TOMBSTONE_MS);
|
|
359
|
+
callAborts.get(c.call_id)?.abort(cancelledError(c.call_id));
|
|
360
|
+
for (const [key, pending] of pendingRpcs) {
|
|
361
|
+
if (key === c.call_id || key.startsWith(`${c.call_id}#`)) {
|
|
362
|
+
pendingRpcs.delete(key);
|
|
363
|
+
streamSinks.delete(key);
|
|
364
|
+
clearTimeout(pending.timeout);
|
|
365
|
+
pending.reject(cancelledError(c.call_id));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
console.error(
|
|
369
|
+
`[functions] host cancelled call ${c.call_id}: ${c.reason ?? "(no reason)"}`,
|
|
370
|
+
);
|
|
323
371
|
} else if (msg.type === "llm_event") {
|
|
324
372
|
const ev = msg as unknown as {
|
|
325
373
|
call_id: string;
|
|
@@ -357,6 +405,7 @@ function rpcDb(
|
|
|
357
405
|
callId: string,
|
|
358
406
|
msg: Record<string, unknown>,
|
|
359
407
|
): Promise<unknown> {
|
|
408
|
+
throwIfCancelled(callId);
|
|
360
409
|
const opId = nextOpId(callId);
|
|
361
410
|
return new Promise((resolve, reject) => {
|
|
362
411
|
const timeout = setTimeout(() => {
|
|
@@ -390,6 +439,7 @@ function rpcStreaming(
|
|
|
390
439
|
msg: Record<string, unknown>,
|
|
391
440
|
onEvent: (event: unknown) => void,
|
|
392
441
|
): Promise<unknown> {
|
|
442
|
+
throwIfCancelled(callId);
|
|
393
443
|
const opId = nextOpId(callId);
|
|
394
444
|
return new Promise((resolve, reject) => {
|
|
395
445
|
const fail = () => {
|
|
@@ -459,6 +509,7 @@ const rpcQueues = new Map<string, Promise<unknown>>();
|
|
|
459
509
|
* the rest of the queue.
|
|
460
510
|
*/
|
|
461
511
|
function rpc(callId: string, msg: Record<string, unknown>): Promise<unknown> {
|
|
512
|
+
throwIfCancelled(callId);
|
|
462
513
|
const prev = rpcQueues.get(callId) ?? Promise.resolve();
|
|
463
514
|
const run = prev
|
|
464
515
|
.then(
|
|
@@ -638,6 +689,16 @@ function buildReaderOps(
|
|
|
638
689
|
ssr_read: ssrRead,
|
|
639
690
|
})) as any;
|
|
640
691
|
},
|
|
692
|
+
async vectorSearch(entity, query) {
|
|
693
|
+
return (await rpcDb(callId, {
|
|
694
|
+
type: "db",
|
|
695
|
+
op: "vector_search",
|
|
696
|
+
entity,
|
|
697
|
+
data: query,
|
|
698
|
+
unsafe_op: unsafeOp,
|
|
699
|
+
ssr_read: ssrRead,
|
|
700
|
+
})) as any;
|
|
701
|
+
},
|
|
641
702
|
};
|
|
642
703
|
}
|
|
643
704
|
|
|
@@ -864,6 +925,18 @@ export function buildLlm(callId: string): Llm {
|
|
|
864
925
|
(event) => onEvent(event as LlmStreamEvent),
|
|
865
926
|
)) as LlmCompleteResponse;
|
|
866
927
|
},
|
|
928
|
+
|
|
929
|
+
async embed(
|
|
930
|
+
input: string[],
|
|
931
|
+
opts?: { model?: string },
|
|
932
|
+
): Promise<number[][]> {
|
|
933
|
+
// op_id-keyed rpc so concurrent embeds in one handler demux.
|
|
934
|
+
const resp = (await rpcDb(callId, {
|
|
935
|
+
type: "llm_embed",
|
|
936
|
+
request: { input, model: opts?.model },
|
|
937
|
+
})) as { embeddings: number[][] };
|
|
938
|
+
return resp.embeddings;
|
|
939
|
+
},
|
|
867
940
|
};
|
|
868
941
|
}
|
|
869
942
|
|
|
@@ -886,6 +959,35 @@ export function buildRooms(callId: string): Rooms {
|
|
|
886
959
|
};
|
|
887
960
|
}
|
|
888
961
|
|
|
962
|
+
/**
|
|
963
|
+
* Build `ctx.workflows` — start a durable workflow or deliver an event
|
|
964
|
+
* to a waiting one, from mutation/action code. Round-trips a
|
|
965
|
+
* `workflow_op` frame to the host's WorkflowEngine; the engine's driver
|
|
966
|
+
* takes it from there, so `start` returns the instance id immediately.
|
|
967
|
+
* Uses the queued call_id-keyed `rpc` (the reply carries no op_id).
|
|
968
|
+
*/
|
|
969
|
+
export function buildWorkflows(callId: string): Workflows {
|
|
970
|
+
return {
|
|
971
|
+
async start(name: string, input?: unknown) {
|
|
972
|
+
return (await rpc(callId, {
|
|
973
|
+
type: "workflow_op",
|
|
974
|
+
op: "start",
|
|
975
|
+
name,
|
|
976
|
+
input: input ?? null,
|
|
977
|
+
})) as { id: string };
|
|
978
|
+
},
|
|
979
|
+
async sendEvent(workflowId: string, event: string, data?: unknown) {
|
|
980
|
+
return (await rpc(callId, {
|
|
981
|
+
type: "workflow_op",
|
|
982
|
+
op: "send_event",
|
|
983
|
+
workflow_id: workflowId,
|
|
984
|
+
event,
|
|
985
|
+
data: data ?? null,
|
|
986
|
+
})) as { delivered: boolean };
|
|
987
|
+
},
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
|
|
889
991
|
/**
|
|
890
992
|
* Build the connection registry that round-trips through the host.
|
|
891
993
|
* Each method emits a `{type:"connection", op:"..."}` message;
|
|
@@ -983,6 +1085,7 @@ function buildActionCtx(
|
|
|
983
1085
|
llm,
|
|
984
1086
|
rooms,
|
|
985
1087
|
connections,
|
|
1088
|
+
workflows: buildWorkflows(callId),
|
|
986
1089
|
env: process.env as Record<string, string>,
|
|
987
1090
|
async runQuery(fnName, args) {
|
|
988
1091
|
return rpc(callId, {
|
|
@@ -1083,6 +1186,12 @@ async function handleCall(msg: CallMessage): Promise<void> {
|
|
|
1083
1186
|
}
|
|
1084
1187
|
}
|
|
1085
1188
|
|
|
1189
|
+
// Cooperative cancellation. The host's `cancel` frame aborts this
|
|
1190
|
+
// controller; ctx.signal lets a handler thread it into fetch / SDK
|
|
1191
|
+
// calls so a cancelled call stops burning tokens, not just replies.
|
|
1192
|
+
const abort = new AbortController();
|
|
1193
|
+
callAborts.set(msg.call_id, abort);
|
|
1194
|
+
|
|
1086
1195
|
const stream = buildStream(msg.call_id);
|
|
1087
1196
|
const scheduler = buildScheduler(msg.call_id);
|
|
1088
1197
|
const email = buildEmail(msg.call_id);
|
|
@@ -1163,6 +1272,7 @@ async function handleCall(msg: CallMessage): Promise<void> {
|
|
|
1163
1272
|
llm,
|
|
1164
1273
|
rooms,
|
|
1165
1274
|
connections,
|
|
1275
|
+
workflows: buildWorkflows(msg.call_id),
|
|
1166
1276
|
error(code, message) {
|
|
1167
1277
|
const err = new Error(message);
|
|
1168
1278
|
(err as any).code = code;
|
|
@@ -1190,14 +1300,25 @@ async function handleCall(msg: CallMessage): Promise<void> {
|
|
|
1190
1300
|
break;
|
|
1191
1301
|
}
|
|
1192
1302
|
|
|
1303
|
+
// Expose the cancellation signal on every ctx variant. Assigned after
|
|
1304
|
+
// the switch so all three ctx shapes get it without threading another
|
|
1305
|
+
// parameter through each builder.
|
|
1306
|
+
(ctx as { signal?: AbortSignal }).signal = abort.signal;
|
|
1307
|
+
|
|
1193
1308
|
try {
|
|
1194
1309
|
const result = await def.handler(ctx, msg.args);
|
|
1310
|
+
if (cancelledCalls.has(msg.call_id)) return;
|
|
1195
1311
|
send({
|
|
1196
1312
|
type: "return",
|
|
1197
1313
|
call_id: msg.call_id,
|
|
1198
1314
|
value: result ?? null,
|
|
1199
1315
|
});
|
|
1200
1316
|
} catch (err: any) {
|
|
1317
|
+
// A cancelled call's host stopped listening — its demux route is
|
|
1318
|
+
// gone, so any frame we send is dropped unread. Skip the send (and
|
|
1319
|
+
// the redaction logging: CALL_CANCELLED is the expected way a
|
|
1320
|
+
// cancelled handler unwinds, not an app error worth a stack trace).
|
|
1321
|
+
if (cancelledCalls.has(msg.call_id)) return;
|
|
1201
1322
|
// Redact. Handler errors historically shipped raw `err.message` to the
|
|
1202
1323
|
// caller, which leaked DB error text, stack-trace-looking strings, and
|
|
1203
1324
|
// internal concurrency-invariant messages. Authors can still surface a
|
|
@@ -1239,6 +1360,8 @@ async function handleCall(msg: CallMessage): Promise<void> {
|
|
|
1239
1360
|
message: `Internal handler error${devDetail}`,
|
|
1240
1361
|
});
|
|
1241
1362
|
}
|
|
1363
|
+
} finally {
|
|
1364
|
+
callAborts.delete(msg.call_id);
|
|
1242
1365
|
}
|
|
1243
1366
|
}
|
|
1244
1367
|
|
|
@@ -1304,6 +1427,83 @@ async function main() {
|
|
|
1304
1427
|
}
|
|
1305
1428
|
}
|
|
1306
1429
|
|
|
1430
|
+
// Workflows: scan the app's workflows/ dir (sibling of functions/).
|
|
1431
|
+
// Each file default-exports a `workflow(...)`. Declared names ride the
|
|
1432
|
+
// ready handshake so the host registers them with its WorkflowEngine;
|
|
1433
|
+
// execution comes back through the implicit internal action below.
|
|
1434
|
+
const { isWorkflowDefinition, executeWorkflowSlice } = await import(
|
|
1435
|
+
"./workflows"
|
|
1436
|
+
);
|
|
1437
|
+
type WorkflowDefinition = import("./workflows").WorkflowDefinition;
|
|
1438
|
+
const workflowRegistry = new Map<string, WorkflowDefinition>();
|
|
1439
|
+
const wfDir = join(process.cwd(), "workflows");
|
|
1440
|
+
let wfFiles: string[] = [];
|
|
1441
|
+
try {
|
|
1442
|
+
wfFiles = readdirSync(wfDir).filter(
|
|
1443
|
+
(f) => f.endsWith(".ts") || f.endsWith(".js"),
|
|
1444
|
+
);
|
|
1445
|
+
} catch {
|
|
1446
|
+
// No workflows/ directory — the common case; declare nothing.
|
|
1447
|
+
}
|
|
1448
|
+
for (const file of wfFiles) {
|
|
1449
|
+
try {
|
|
1450
|
+
const mod = await import(join(wfDir, file));
|
|
1451
|
+
const def = mod.default;
|
|
1452
|
+
if (isWorkflowDefinition(def)) {
|
|
1453
|
+
if (workflowRegistry.has(def.name)) {
|
|
1454
|
+
console.error(
|
|
1455
|
+
`[workflows] duplicate workflow name "${def.name}" (${file}) — keeping the first`,
|
|
1456
|
+
);
|
|
1457
|
+
continue;
|
|
1458
|
+
}
|
|
1459
|
+
workflowRegistry.set(def.name, def);
|
|
1460
|
+
} else {
|
|
1461
|
+
console.error(
|
|
1462
|
+
`[workflows] ${file} has no default-exported workflow(...) — skipped`,
|
|
1463
|
+
);
|
|
1464
|
+
}
|
|
1465
|
+
} catch (err) {
|
|
1466
|
+
console.error(`[workflows] Failed to load ${file}:`, err);
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
if (workflowRegistry.size > 0) {
|
|
1470
|
+
// The host drives every workflow slice through this internal action,
|
|
1471
|
+
// so step code gets a full ActionCtx (db, llm, scheduler, idle
|
|
1472
|
+
// timeout, cancellation). admin auth + internal: only the host's
|
|
1473
|
+
// engine hook may invoke it. timeout 600: one slice is one step —
|
|
1474
|
+
// long AGENT steps stream (extending the idle budget); a silent 10
|
|
1475
|
+
// minutes is a hang.
|
|
1476
|
+
registry.set("__pylon_workflow_run", {
|
|
1477
|
+
type: "action",
|
|
1478
|
+
internal: true,
|
|
1479
|
+
auth: "admin",
|
|
1480
|
+
timeout: 600,
|
|
1481
|
+
handler: async (ctx: unknown, args: unknown) => {
|
|
1482
|
+
const req = args as import("./workflows").WorkflowRunRequest;
|
|
1483
|
+
const def = workflowRegistry.get(req.workflow_name);
|
|
1484
|
+
if (!def) {
|
|
1485
|
+
return {
|
|
1486
|
+
action: "fail",
|
|
1487
|
+
error: `workflow "${req.workflow_name}" is not registered in this runtime`,
|
|
1488
|
+
};
|
|
1489
|
+
}
|
|
1490
|
+
return executeWorkflowSlice(
|
|
1491
|
+
def,
|
|
1492
|
+
req,
|
|
1493
|
+
ctx as import("./types").ActionCtx,
|
|
1494
|
+
);
|
|
1495
|
+
},
|
|
1496
|
+
} as unknown as FnDefinition);
|
|
1497
|
+
}
|
|
1498
|
+
const workflows = Array.from(workflowRegistry.values()).map((w) => ({
|
|
1499
|
+
name: w.name,
|
|
1500
|
+
description: w.description ?? "",
|
|
1501
|
+
max_retries:
|
|
1502
|
+
typeof w.maxRetries === "number" && w.maxRetries >= 0
|
|
1503
|
+
? Math.floor(w.maxRetries)
|
|
1504
|
+
: null,
|
|
1505
|
+
}));
|
|
1506
|
+
|
|
1307
1507
|
const functions = Array.from(registry.entries()).map(([name, def]) => ({
|
|
1308
1508
|
name,
|
|
1309
1509
|
fn_type: def.type,
|
|
@@ -1326,7 +1526,7 @@ async function main() {
|
|
|
1326
1526
|
? Math.floor(def.timeout)
|
|
1327
1527
|
: null,
|
|
1328
1528
|
}));
|
|
1329
|
-
send({ type: "ready", functions });
|
|
1529
|
+
send({ type: "ready", functions, workflows });
|
|
1330
1530
|
|
|
1331
1531
|
// Belt-and-suspenders against orphaning: if the host dies in a way that
|
|
1332
1532
|
// somehow leaves our stdin open, we'll have been REPARENTED — our ppid
|