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