@pylonsync/functions 0.4.16 → 0.4.19
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 +51 -0
- package/dist/workflows.d.ts +128 -0
- package/package.json +1 -1
- package/src/define.ts +21 -11
- package/src/index.ts +9 -0
- package/src/runtime-cancel.test.ts +137 -0
- package/src/runtime-workflows.test.ts +263 -0
- package/src/runtime.ts +179 -1
- package/src/types.ts +52 -0
- package/src/workflows.test.ts +199 -0
- package/src/workflows.ts +252 -0
|
@@ -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(
|
|
@@ -886,6 +937,35 @@ export function buildRooms(callId: string): Rooms {
|
|
|
886
937
|
};
|
|
887
938
|
}
|
|
888
939
|
|
|
940
|
+
/**
|
|
941
|
+
* Build `ctx.workflows` — start a durable workflow or deliver an event
|
|
942
|
+
* to a waiting one, from mutation/action code. Round-trips a
|
|
943
|
+
* `workflow_op` frame to the host's WorkflowEngine; the engine's driver
|
|
944
|
+
* takes it from there, so `start` returns the instance id immediately.
|
|
945
|
+
* Uses the queued call_id-keyed `rpc` (the reply carries no op_id).
|
|
946
|
+
*/
|
|
947
|
+
export function buildWorkflows(callId: string): Workflows {
|
|
948
|
+
return {
|
|
949
|
+
async start(name: string, input?: unknown) {
|
|
950
|
+
return (await rpc(callId, {
|
|
951
|
+
type: "workflow_op",
|
|
952
|
+
op: "start",
|
|
953
|
+
name,
|
|
954
|
+
input: input ?? null,
|
|
955
|
+
})) as { id: string };
|
|
956
|
+
},
|
|
957
|
+
async sendEvent(workflowId: string, event: string, data?: unknown) {
|
|
958
|
+
return (await rpc(callId, {
|
|
959
|
+
type: "workflow_op",
|
|
960
|
+
op: "send_event",
|
|
961
|
+
workflow_id: workflowId,
|
|
962
|
+
event,
|
|
963
|
+
data: data ?? null,
|
|
964
|
+
})) as { delivered: boolean };
|
|
965
|
+
},
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
|
|
889
969
|
/**
|
|
890
970
|
* Build the connection registry that round-trips through the host.
|
|
891
971
|
* Each method emits a `{type:"connection", op:"..."}` message;
|
|
@@ -983,6 +1063,7 @@ function buildActionCtx(
|
|
|
983
1063
|
llm,
|
|
984
1064
|
rooms,
|
|
985
1065
|
connections,
|
|
1066
|
+
workflows: buildWorkflows(callId),
|
|
986
1067
|
env: process.env as Record<string, string>,
|
|
987
1068
|
async runQuery(fnName, args) {
|
|
988
1069
|
return rpc(callId, {
|
|
@@ -1083,6 +1164,12 @@ async function handleCall(msg: CallMessage): Promise<void> {
|
|
|
1083
1164
|
}
|
|
1084
1165
|
}
|
|
1085
1166
|
|
|
1167
|
+
// Cooperative cancellation. The host's `cancel` frame aborts this
|
|
1168
|
+
// controller; ctx.signal lets a handler thread it into fetch / SDK
|
|
1169
|
+
// calls so a cancelled call stops burning tokens, not just replies.
|
|
1170
|
+
const abort = new AbortController();
|
|
1171
|
+
callAborts.set(msg.call_id, abort);
|
|
1172
|
+
|
|
1086
1173
|
const stream = buildStream(msg.call_id);
|
|
1087
1174
|
const scheduler = buildScheduler(msg.call_id);
|
|
1088
1175
|
const email = buildEmail(msg.call_id);
|
|
@@ -1163,6 +1250,7 @@ async function handleCall(msg: CallMessage): Promise<void> {
|
|
|
1163
1250
|
llm,
|
|
1164
1251
|
rooms,
|
|
1165
1252
|
connections,
|
|
1253
|
+
workflows: buildWorkflows(msg.call_id),
|
|
1166
1254
|
error(code, message) {
|
|
1167
1255
|
const err = new Error(message);
|
|
1168
1256
|
(err as any).code = code;
|
|
@@ -1190,14 +1278,25 @@ async function handleCall(msg: CallMessage): Promise<void> {
|
|
|
1190
1278
|
break;
|
|
1191
1279
|
}
|
|
1192
1280
|
|
|
1281
|
+
// Expose the cancellation signal on every ctx variant. Assigned after
|
|
1282
|
+
// the switch so all three ctx shapes get it without threading another
|
|
1283
|
+
// parameter through each builder.
|
|
1284
|
+
(ctx as { signal?: AbortSignal }).signal = abort.signal;
|
|
1285
|
+
|
|
1193
1286
|
try {
|
|
1194
1287
|
const result = await def.handler(ctx, msg.args);
|
|
1288
|
+
if (cancelledCalls.has(msg.call_id)) return;
|
|
1195
1289
|
send({
|
|
1196
1290
|
type: "return",
|
|
1197
1291
|
call_id: msg.call_id,
|
|
1198
1292
|
value: result ?? null,
|
|
1199
1293
|
});
|
|
1200
1294
|
} catch (err: any) {
|
|
1295
|
+
// A cancelled call's host stopped listening — its demux route is
|
|
1296
|
+
// gone, so any frame we send is dropped unread. Skip the send (and
|
|
1297
|
+
// the redaction logging: CALL_CANCELLED is the expected way a
|
|
1298
|
+
// cancelled handler unwinds, not an app error worth a stack trace).
|
|
1299
|
+
if (cancelledCalls.has(msg.call_id)) return;
|
|
1201
1300
|
// Redact. Handler errors historically shipped raw `err.message` to the
|
|
1202
1301
|
// caller, which leaked DB error text, stack-trace-looking strings, and
|
|
1203
1302
|
// internal concurrency-invariant messages. Authors can still surface a
|
|
@@ -1239,6 +1338,8 @@ async function handleCall(msg: CallMessage): Promise<void> {
|
|
|
1239
1338
|
message: `Internal handler error${devDetail}`,
|
|
1240
1339
|
});
|
|
1241
1340
|
}
|
|
1341
|
+
} finally {
|
|
1342
|
+
callAborts.delete(msg.call_id);
|
|
1242
1343
|
}
|
|
1243
1344
|
}
|
|
1244
1345
|
|
|
@@ -1304,6 +1405,83 @@ async function main() {
|
|
|
1304
1405
|
}
|
|
1305
1406
|
}
|
|
1306
1407
|
|
|
1408
|
+
// Workflows: scan the app's workflows/ dir (sibling of functions/).
|
|
1409
|
+
// Each file default-exports a `workflow(...)`. Declared names ride the
|
|
1410
|
+
// ready handshake so the host registers them with its WorkflowEngine;
|
|
1411
|
+
// execution comes back through the implicit internal action below.
|
|
1412
|
+
const { isWorkflowDefinition, executeWorkflowSlice } = await import(
|
|
1413
|
+
"./workflows"
|
|
1414
|
+
);
|
|
1415
|
+
type WorkflowDefinition = import("./workflows").WorkflowDefinition;
|
|
1416
|
+
const workflowRegistry = new Map<string, WorkflowDefinition>();
|
|
1417
|
+
const wfDir = join(process.cwd(), "workflows");
|
|
1418
|
+
let wfFiles: string[] = [];
|
|
1419
|
+
try {
|
|
1420
|
+
wfFiles = readdirSync(wfDir).filter(
|
|
1421
|
+
(f) => f.endsWith(".ts") || f.endsWith(".js"),
|
|
1422
|
+
);
|
|
1423
|
+
} catch {
|
|
1424
|
+
// No workflows/ directory — the common case; declare nothing.
|
|
1425
|
+
}
|
|
1426
|
+
for (const file of wfFiles) {
|
|
1427
|
+
try {
|
|
1428
|
+
const mod = await import(join(wfDir, file));
|
|
1429
|
+
const def = mod.default;
|
|
1430
|
+
if (isWorkflowDefinition(def)) {
|
|
1431
|
+
if (workflowRegistry.has(def.name)) {
|
|
1432
|
+
console.error(
|
|
1433
|
+
`[workflows] duplicate workflow name "${def.name}" (${file}) — keeping the first`,
|
|
1434
|
+
);
|
|
1435
|
+
continue;
|
|
1436
|
+
}
|
|
1437
|
+
workflowRegistry.set(def.name, def);
|
|
1438
|
+
} else {
|
|
1439
|
+
console.error(
|
|
1440
|
+
`[workflows] ${file} has no default-exported workflow(...) — skipped`,
|
|
1441
|
+
);
|
|
1442
|
+
}
|
|
1443
|
+
} catch (err) {
|
|
1444
|
+
console.error(`[workflows] Failed to load ${file}:`, err);
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
if (workflowRegistry.size > 0) {
|
|
1448
|
+
// The host drives every workflow slice through this internal action,
|
|
1449
|
+
// so step code gets a full ActionCtx (db, llm, scheduler, idle
|
|
1450
|
+
// timeout, cancellation). admin auth + internal: only the host's
|
|
1451
|
+
// engine hook may invoke it. timeout 600: one slice is one step —
|
|
1452
|
+
// long AGENT steps stream (extending the idle budget); a silent 10
|
|
1453
|
+
// minutes is a hang.
|
|
1454
|
+
registry.set("__pylon_workflow_run", {
|
|
1455
|
+
type: "action",
|
|
1456
|
+
internal: true,
|
|
1457
|
+
auth: "admin",
|
|
1458
|
+
timeout: 600,
|
|
1459
|
+
handler: async (ctx: unknown, args: unknown) => {
|
|
1460
|
+
const req = args as import("./workflows").WorkflowRunRequest;
|
|
1461
|
+
const def = workflowRegistry.get(req.workflow_name);
|
|
1462
|
+
if (!def) {
|
|
1463
|
+
return {
|
|
1464
|
+
action: "fail",
|
|
1465
|
+
error: `workflow "${req.workflow_name}" is not registered in this runtime`,
|
|
1466
|
+
};
|
|
1467
|
+
}
|
|
1468
|
+
return executeWorkflowSlice(
|
|
1469
|
+
def,
|
|
1470
|
+
req,
|
|
1471
|
+
ctx as import("./types").ActionCtx,
|
|
1472
|
+
);
|
|
1473
|
+
},
|
|
1474
|
+
} as unknown as FnDefinition);
|
|
1475
|
+
}
|
|
1476
|
+
const workflows = Array.from(workflowRegistry.values()).map((w) => ({
|
|
1477
|
+
name: w.name,
|
|
1478
|
+
description: w.description ?? "",
|
|
1479
|
+
max_retries:
|
|
1480
|
+
typeof w.maxRetries === "number" && w.maxRetries >= 0
|
|
1481
|
+
? Math.floor(w.maxRetries)
|
|
1482
|
+
: null,
|
|
1483
|
+
}));
|
|
1484
|
+
|
|
1307
1485
|
const functions = Array.from(registry.entries()).map(([name, def]) => ({
|
|
1308
1486
|
name,
|
|
1309
1487
|
fn_type: def.type,
|
|
@@ -1326,7 +1504,7 @@ async function main() {
|
|
|
1326
1504
|
? Math.floor(def.timeout)
|
|
1327
1505
|
: null,
|
|
1328
1506
|
}));
|
|
1329
|
-
send({ type: "ready", functions });
|
|
1507
|
+
send({ type: "ready", functions, workflows });
|
|
1330
1508
|
|
|
1331
1509
|
// Belt-and-suspenders against orphaning: if the host dies in a way that
|
|
1332
1510
|
// somehow leaves our stdin open, we'll have been REPARENTED — our ppid
|
package/src/types.ts
CHANGED
|
@@ -471,6 +471,30 @@ export interface Rooms {
|
|
|
471
471
|
): Promise<{ delivered: boolean }>;
|
|
472
472
|
}
|
|
473
473
|
|
|
474
|
+
/**
|
|
475
|
+
* Durable workflows, driven from app code (`ctx.workflows` on mutations
|
|
476
|
+
* and actions — not queries, whose reactive re-runs would re-start).
|
|
477
|
+
* Workflows are declared in the app's `workflows/` directory; see the
|
|
478
|
+
* `workflow()` export.
|
|
479
|
+
*/
|
|
480
|
+
export interface Workflows {
|
|
481
|
+
/**
|
|
482
|
+
* Start a workflow instance by name. Returns immediately with the
|
|
483
|
+
* instance id — the engine's background driver executes the steps.
|
|
484
|
+
*/
|
|
485
|
+
start(name: string, input?: unknown): Promise<{ id: string }>;
|
|
486
|
+
/**
|
|
487
|
+
* Deliver an event to an instance paused on
|
|
488
|
+
* `wf.waitForEvent(event)`. Rejects when the instance isn't waiting
|
|
489
|
+
* for that event.
|
|
490
|
+
*/
|
|
491
|
+
sendEvent(
|
|
492
|
+
workflowId: string,
|
|
493
|
+
event: string,
|
|
494
|
+
data?: unknown,
|
|
495
|
+
): Promise<{ delivered: boolean }>;
|
|
496
|
+
}
|
|
497
|
+
|
|
474
498
|
export interface LlmMessage {
|
|
475
499
|
role: "user" | "assistant" | "system" | "tool";
|
|
476
500
|
content: string | LlmContentBlock[];
|
|
@@ -700,6 +724,14 @@ export interface QueryCtx<R extends AuthRequirement = "optional"> {
|
|
|
700
724
|
requireMember: RequireMember;
|
|
701
725
|
/** Signed file-download URLs — see {@link Files}. */
|
|
702
726
|
files: Files;
|
|
727
|
+
/**
|
|
728
|
+
* Fires when the host cancels this call (idle timeout exceeded).
|
|
729
|
+
* Thread it into `fetch(url, { signal: ctx.signal })` or SDK calls so
|
|
730
|
+
* a cancelled call stops its outbound work too — the runtime already
|
|
731
|
+
* makes every later `ctx.*` call throw `CALL_CANCELLED`. Optional
|
|
732
|
+
* because older hosts don't send cancel frames.
|
|
733
|
+
*/
|
|
734
|
+
signal?: AbortSignal;
|
|
703
735
|
}
|
|
704
736
|
|
|
705
737
|
/** Context for mutation handlers (read + write, transactional). */
|
|
@@ -716,12 +748,22 @@ export interface MutationCtx<R extends AuthRequirement = "optional"> {
|
|
|
716
748
|
rooms: Rooms;
|
|
717
749
|
/** Per-user OAuth connection registry. */
|
|
718
750
|
connections: Connections;
|
|
751
|
+
/** Durable workflows: start / deliver events — see {@link Workflows}. */
|
|
752
|
+
workflows: Workflows;
|
|
719
753
|
/** Signed file-download URLs — see {@link Files}. */
|
|
720
754
|
files: Files;
|
|
721
755
|
/** Create a typed error that triggers rollback. */
|
|
722
756
|
error(code: string, message: string): Error;
|
|
723
757
|
/** Assert org membership (optionally a role) — see {@link RequireMember}. */
|
|
724
758
|
requireMember: RequireMember;
|
|
759
|
+
/**
|
|
760
|
+
* Fires when the host cancels this call (idle timeout exceeded).
|
|
761
|
+
* Thread it into `fetch(url, { signal: ctx.signal })` or SDK calls so
|
|
762
|
+
* a cancelled call stops its outbound work too — the runtime already
|
|
763
|
+
* makes every later `ctx.*` call throw `CALL_CANCELLED`. Optional
|
|
764
|
+
* because older hosts don't send cancel frames.
|
|
765
|
+
*/
|
|
766
|
+
signal?: AbortSignal;
|
|
725
767
|
}
|
|
726
768
|
|
|
727
769
|
/** Context for action handlers (external I/O, non-transactional). */
|
|
@@ -737,6 +779,8 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
|
|
|
737
779
|
rooms: Rooms;
|
|
738
780
|
/** Per-user OAuth connection registry. */
|
|
739
781
|
connections: Connections;
|
|
782
|
+
/** Durable workflows: start / deliver events — see {@link Workflows}. */
|
|
783
|
+
workflows: Workflows;
|
|
740
784
|
/** Environment variables / secrets. */
|
|
741
785
|
env: Record<string, string>;
|
|
742
786
|
/** Signed file-download URLs — see {@link Files}. */
|
|
@@ -755,6 +799,14 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
|
|
|
755
799
|
error(code: string, message: string): Error;
|
|
756
800
|
/** Assert org membership (optionally a role) — see {@link RequireMember}. */
|
|
757
801
|
requireMember: RequireMember;
|
|
802
|
+
/**
|
|
803
|
+
* Fires when the host cancels this call (idle timeout exceeded).
|
|
804
|
+
* Thread it into `fetch(url, { signal: ctx.signal })` or SDK calls so
|
|
805
|
+
* a cancelled call stops its outbound work too — the runtime already
|
|
806
|
+
* makes every later `ctx.*` call throw `CALL_CANCELLED`. Optional
|
|
807
|
+
* because older hosts don't send cancel frames.
|
|
808
|
+
*/
|
|
809
|
+
signal?: AbortSignal;
|
|
758
810
|
/**
|
|
759
811
|
* HTTP request metadata — present only when the action was invoked via
|
|
760
812
|
* a `defineRoute` HTTP binding. Missing when the action is called from
|