@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.
@@ -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