@pylonsync/functions 0.19.1 → 0.21.0

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/src/runtime.ts CHANGED
@@ -33,6 +33,9 @@ import type {
33
33
  LlmStreamEvent,
34
34
  Rooms,
35
35
  Workflows,
36
+ WorkflowRunSummary,
37
+ Audit,
38
+ AuditEntry,
36
39
  Connections,
37
40
  Domains,
38
41
  TenantDomainResult,
@@ -47,6 +50,7 @@ import type {
47
50
  } from "./types";
48
51
  import { normalizeAuthClaims } from "./auth";
49
52
  import { makeRequireMember } from "./member";
53
+ import { response } from "./response";
50
54
  import { isDevMode } from "./ssr-runtime";
51
55
  import { validateArgs } from "./validators";
52
56
  import { serverBundle } from "./server-bundle";
@@ -840,10 +844,10 @@ function buildScheduler(callId: string): Scheduler {
840
844
  return r.id || "";
841
845
  },
842
846
  async cancel(scheduleId) {
843
- await rpc(callId, {
847
+ return (await rpc(callId, {
844
848
  type: "cancel_schedule",
845
849
  schedule_id: scheduleId,
846
- });
850
+ })) as { cancelled: boolean };
847
851
  },
848
852
  };
849
853
  }
@@ -985,15 +989,47 @@ export function buildRooms(callId: string): Rooms {
985
989
  * takes it from there, so `start` returns the instance id immediately.
986
990
  * Uses the queued call_id-keyed `rpc` (the reply carries no op_id).
987
991
  */
992
+ /** Build `ctx.audit` — round-trips `audit_op` frames to the host. */
993
+ export function buildAudit(callId: string): Audit {
994
+ return {
995
+ async log(event) {
996
+ return (await rpc(callId, {
997
+ type: "audit_op",
998
+ op: "log",
999
+ action: event.action,
1000
+ entity: event.entity ?? null,
1001
+ entity_id: event.entityId ?? null,
1002
+ subject: event.subject ?? null,
1003
+ meta: event.meta ?? null,
1004
+ })) as { id: string };
1005
+ },
1006
+ async list(filter) {
1007
+ return (await rpc(callId, {
1008
+ type: "audit_op",
1009
+ op: "list",
1010
+ action: filter?.action ?? null,
1011
+ entity: filter?.entity ?? null,
1012
+ entity_id: filter?.entityId ?? null,
1013
+ actor: filter?.actor ?? null,
1014
+ tenant: filter?.tenant ?? null,
1015
+ before: filter?.before ?? null,
1016
+ before_id: filter?.beforeId ?? null,
1017
+ limit: filter?.limit ?? null,
1018
+ })) as AuditEntry[];
1019
+ },
1020
+ };
1021
+ }
1022
+
988
1023
  export function buildWorkflows(callId: string): Workflows {
989
1024
  return {
990
- async start(name: string, input?: unknown) {
1025
+ async start(name: string, input?: unknown, opts?: { key?: string }) {
991
1026
  return (await rpc(callId, {
992
1027
  type: "workflow_op",
993
1028
  op: "start",
994
1029
  name,
995
1030
  input: input ?? null,
996
- })) as { id: string };
1031
+ key: opts?.key ?? null,
1032
+ })) as { id: string; created: boolean };
997
1033
  },
998
1034
  async sendEvent(workflowId: string, event: string, data?: unknown) {
999
1035
  return (await rpc(callId, {
@@ -1002,7 +1038,32 @@ export function buildWorkflows(callId: string): Workflows {
1002
1038
  workflow_id: workflowId,
1003
1039
  event,
1004
1040
  data: data ?? null,
1005
- })) as { delivered: boolean };
1041
+ })) as { delivered: boolean; buffered: boolean };
1042
+ },
1043
+ async cancel(workflowId: string, opts?: { reason?: string }) {
1044
+ return (await rpc(callId, {
1045
+ type: "workflow_op",
1046
+ op: "cancel",
1047
+ workflow_id: workflowId,
1048
+ reason: opts?.reason ?? null,
1049
+ })) as { cancelled: boolean };
1050
+ },
1051
+ async get(workflowId: string) {
1052
+ return (await rpc(callId, {
1053
+ type: "workflow_op",
1054
+ op: "get",
1055
+ workflow_id: workflowId,
1056
+ })) as WorkflowRunSummary | null;
1057
+ },
1058
+ async list(filter) {
1059
+ return (await rpc(callId, {
1060
+ type: "workflow_op",
1061
+ op: "list",
1062
+ name: filter?.name ?? null,
1063
+ key: filter?.key ?? null,
1064
+ status: filter?.status ?? null,
1065
+ limit: filter?.limit ?? null,
1066
+ })) as WorkflowRunSummary[];
1006
1067
  },
1007
1068
  };
1008
1069
  }
@@ -1166,6 +1227,7 @@ function buildActionCtx(
1166
1227
  normalizedRequest = {
1167
1228
  method: String(r.method ?? ""),
1168
1229
  path: String(r.path ?? ""),
1230
+ url: String(r.url ?? ""),
1169
1231
  headers: (r.headers as Record<string, string>) ?? {},
1170
1232
  rawBody: String(r.raw_body ?? r.rawBody ?? ""),
1171
1233
  };
@@ -1179,6 +1241,7 @@ function buildActionCtx(
1179
1241
  rooms,
1180
1242
  connections,
1181
1243
  workflows: buildWorkflows(callId),
1244
+ audit: buildAudit(callId),
1182
1245
  domains: buildDomains(),
1183
1246
  env: process.env as Record<string, string>,
1184
1247
  async runQuery(fnName, args) {
@@ -1214,6 +1277,7 @@ function buildActionCtx(
1214
1277
  }) as Promise<any[]>,
1215
1278
  ),
1216
1279
  request: normalizedRequest,
1280
+ response,
1217
1281
  };
1218
1282
  }
1219
1283
 
@@ -1372,6 +1436,7 @@ async function handleCall(msg: CallMessage): Promise<void> {
1372
1436
  rooms,
1373
1437
  connections,
1374
1438
  workflows: buildWorkflows(msg.call_id),
1439
+ audit: buildAudit(msg.call_id),
1375
1440
  error(code, message) {
1376
1441
  const err = new Error(message);
1377
1442
  (err as any).code = code;
@@ -20,6 +20,7 @@ import {
20
20
  makeReadTrackingProxy,
21
21
  makeRevocableReadTrackingProxy,
22
22
  jsonClone,
23
+ isLoopbackHost,
23
24
  } from "./ssr-runtime";
24
25
 
25
26
  describe("resolveOrigin — Host-header allowlist (cache-poisoning fence)", () => {
@@ -904,3 +905,27 @@ describe("script-escaping + secure cookies", () => {
904
905
  }
905
906
  });
906
907
  });
908
+
909
+ describe("isLoopbackHost — exact loopback match", () => {
910
+ test("accepts loopback names and addresses with an optional port", () => {
911
+ for (const h of ["localhost", "localhost:4321", "127.0.0.1", "127.1.2.3:80", "0.0.0.0:4321", "::1", "[::1]", "[::1]:4321"]) {
912
+ expect(isLoopbackHost(h)).toBe(true);
913
+ }
914
+ });
915
+
916
+ test("rejects hosts that only start like loopback", () => {
917
+ for (const h of ["localhost.attacker.io", "127.attacker.test", "0.0.0.0.attacker.net", "localhostx", "[::1].evil.com", "localhost:80x", "128.0.0.1", "127.0.0.256", "127.000.000.001", "0:0:0:0:0:0:0:1", "localhost.", ""]) {
918
+ expect(isLoopbackHost(h)).toBe(false);
919
+ }
920
+ });
921
+
922
+ test("a look-alike Host is not trusted for the origin or a redirect", () => {
923
+ expect(
924
+ resolveOrigin({ host: "localhost.attacker.io", publicUrl: "https://app.example.com" }),
925
+ ).toBe("https://app.example.com");
926
+ expect(
927
+ isSafeRedirect("https://localhost.attacker.io/phish", { publicUrl: "https://app.example.com" }),
928
+ ).toBe(false);
929
+ expect(isSafeRedirect("http://localhost:4321/ok", {})).toBe(true);
930
+ });
931
+ });
@@ -1092,7 +1092,39 @@ function readSocialImageMeta(relPath: string): {
1092
1092
  return { type, width, height, v };
1093
1093
  }
1094
1094
 
1095
- const LOOPBACK_HOST = /^(localhost|127\.|\[?::1|0\.0\.0\.0)/;
1095
+ /** Loopback host? `host` is a lowercase authority (`host[:port]`,
1096
+ * `[ipv6]:port`). True for exactly `localhost`, dotted 127.0.0.0/8
1097
+ * without leading zeros, `::1`, or `0.0.0.0`, with an optional numeric
1098
+ * port. The match is exact, so
1099
+ * `localhost.example.com` is not loopback. Mirrors `is_loopback_host` in
1100
+ * crates/router/src/public_url.rs. Exported for tests. */
1101
+ export function isLoopbackHost(host: string): boolean {
1102
+ let name: string;
1103
+ const bracketed = /^\[([^\]]*)\](?::(\d{1,5}))?$/.exec(host);
1104
+ if (bracketed) {
1105
+ name = bracketed[1];
1106
+ } else if (host.startsWith("[")) {
1107
+ return false;
1108
+ } else {
1109
+ const colons = host.split(":").length - 1;
1110
+ if (colons === 1) {
1111
+ const [h, port] = host.split(":");
1112
+ if (!/^\d{1,5}$/.test(port)) return false;
1113
+ name = h;
1114
+ } else {
1115
+ name = host;
1116
+ }
1117
+ }
1118
+ if (name === "localhost" || name === "::1" || name === "0.0.0.0") return true;
1119
+ // Dotted IPv4 without leading zeros (matches Rust's Ipv4Addr parser).
1120
+ const octet = "(0|[1-9]\\d{0,2})";
1121
+ const v4 = new RegExp(`^${octet}\\.${octet}\\.${octet}\\.${octet}$`).exec(name);
1122
+ if (v4) {
1123
+ const octets = v4.slice(1).map(Number);
1124
+ return octets.every((o) => o <= 255) && octets[0] === 127;
1125
+ }
1126
+ return false;
1127
+ }
1096
1128
 
1097
1129
  /** Normalize a bare host or a full URL down to a lowercase `host` (host:port).
1098
1130
  * Returns "" for unparseable input. */
@@ -1135,7 +1167,7 @@ export function resolveOrigin(opts: {
1135
1167
  add(opts.publicUrl || "");
1136
1168
  add(opts.canonicalHost || "");
1137
1169
  for (const x of (opts.trustedHostsCsv || "").split(",")) add(x);
1138
- const isLoopback = LOOPBACK_HOST.test(host);
1170
+ const isLoopback = isLoopbackHost(host);
1139
1171
  if (isLoopback || allow.has(host)) {
1140
1172
  // Off-loopback (prod) we ALWAYS use https and never honor the request's
1141
1173
  // X-Forwarded-Proto. The SSR cache is keyed only by host (not proto), so
@@ -1201,7 +1233,7 @@ export function isSafeRedirect(
1201
1233
  }
1202
1234
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
1203
1235
  const host = parsed.host.toLowerCase();
1204
- if (LOOPBACK_HOST.test(host)) return true;
1236
+ if (isLoopbackHost(host)) return true;
1205
1237
  const allow = new Set<string>();
1206
1238
  const add = (v: string) => {
1207
1239
  const h = hostOf(v);
package/src/types.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Type definitions for the function system.
3
3
  */
4
+ import type { RawResponse, RawResponseInit } from "./response";
4
5
 
5
6
  // ---------------------------------------------------------------------------
6
7
  // Auth
@@ -370,8 +371,21 @@ export interface Scheduler {
370
371
  args: Record<string, unknown>
371
372
  ): Promise<string>;
372
373
 
373
- /** Cancel a previously scheduled function. */
374
- cancel(scheduleId: string): Promise<void>;
374
+ /**
375
+ * Cancel a scheduled function that has not started. Pass the id
376
+ * `runAfter` or `runAt` returned.
377
+ *
378
+ * Resolves `{ cancelled: true }` when the job will not run, and
379
+ * `{ cancelled: false }` when the id is unknown or the job already
380
+ * started or finished (a running job is not interrupted). Rejects with
381
+ * `SCHEDULE_CANCEL_FAILED` when the cancel could not be saved; the job
382
+ * then still runs.
383
+ *
384
+ * Inside a mutation, the cancel commits or rolls back with the
385
+ * mutation: if the mutation throws, the job stays scheduled. From an
386
+ * action, it takes effect at once.
387
+ */
388
+ cancel(scheduleId: string): Promise<{ cancelled: boolean }>;
375
389
  }
376
390
 
377
391
  /**
@@ -604,18 +618,162 @@ export interface Workflows {
604
618
  /**
605
619
  * Start a workflow instance by name. Returns immediately with the
606
620
  * instance id — the engine's background driver executes the steps.
621
+ *
622
+ * With `key` (for example a lead id), at most one non-terminal run
623
+ * exists per workflow name and key. If one exists, no new run starts
624
+ * and its id is returned with `created: false`. A finished run frees
625
+ * the key.
607
626
  */
608
- start(name: string, input?: unknown): Promise<{ id: string }>;
627
+ start(
628
+ name: string,
629
+ input?: unknown,
630
+ opts?: { key?: string },
631
+ ): Promise<{ id: string; created: boolean }>;
609
632
  /**
610
- * Deliver an event to an instance paused on
611
- * `wf.waitForEvent(event)`. Rejects when the instance isn't waiting
612
- * for that event.
633
+ * Send an event to a run. If the run is waiting for it
634
+ * (`wf.waitForEvent(event)`), the run resumes and `delivered` is true.
635
+ * Otherwise the event is buffered (`buffered: true`) and the run's next
636
+ * `waitForEvent(event)` consumes it. Rejects when the run has finished
637
+ * or already holds 100 buffered events. Events sent without data
638
+ * arrive as `{}`.
613
639
  */
614
640
  sendEvent(
615
641
  workflowId: string,
616
642
  event: string,
617
643
  data?: unknown,
618
- ): Promise<{ delivered: boolean }>;
644
+ ): Promise<{ delivered: boolean; buffered: boolean }>;
645
+ /**
646
+ * Cancel a run. No further step runs. A step already in progress may
647
+ * finish, but its result is discarded. Resolves `cancelled: false` when
648
+ * the run had already finished. Rejects for an unknown id.
649
+ */
650
+ cancel(
651
+ workflowId: string,
652
+ opts?: { reason?: string },
653
+ ): Promise<{ cancelled: boolean }>;
654
+ /** Read one run, or null if the id is unknown. */
655
+ get(workflowId: string): Promise<WorkflowRunSummary | null>;
656
+ /**
657
+ * List runs, newest first. `status: "active"` matches every
658
+ * non-terminal status. `limit` defaults to 100 (max 1000).
659
+ *
660
+ * ```ts
661
+ * // Seller texted STOP: end every cadence for the lead.
662
+ * const runs = await ctx.workflows.list({ key: leadId, status: "active" });
663
+ * for (const run of runs) {
664
+ * await ctx.workflows.cancel(run.id, { reason: "STOP" });
665
+ * }
666
+ * ```
667
+ */
668
+ list(filter?: {
669
+ name?: string;
670
+ key?: string;
671
+ status?: WorkflowRunStatus | "active";
672
+ limit?: number;
673
+ }): Promise<WorkflowRunSummary[]>;
674
+ }
675
+
676
+ /**
677
+ * The application audit log (`ctx.audit` on mutations and actions).
678
+ * Append-only, stored with the auth audit events.
679
+ */
680
+ export interface AuditLog {
681
+ /**
682
+ * Record an event. The actor and tenant come from the caller's session.
683
+ * `action` is a short dotted name (`lead.export`, `script.update`) and
684
+ * is stored as `app.<action>`. `meta` values are stored as strings
685
+ * (non-strings as JSON text); keep secrets out of it.
686
+ *
687
+ * Inside a mutation the event is written after the mutation commits and
688
+ * dropped if it rolls back. Rejects with `AUDIT_WRITE_FAILED` when an
689
+ * action's event could not be stored.
690
+ */
691
+ log(event: {
692
+ action: string;
693
+ entity?: string;
694
+ entityId?: string;
695
+ /** The user the event is about, when not the actor. */
696
+ subject?: string;
697
+ meta?: Record<string, unknown>;
698
+ }): Promise<{ id: string }>;
699
+ }
700
+
701
+ /**
702
+ * `ctx.audit` on actions: {@link AuditLog.log} plus reads. Mutations get
703
+ * only `log`: in a Postgres mutation the transaction holds a connection,
704
+ * and a read would need a second.
705
+ */
706
+ export interface Audit extends AuditLog {
707
+ /**
708
+ * Read events, newest first. A caller with a tenant reads that tenant's
709
+ * events; a caller without one reads only events it performed. Admin
710
+ * callers may pass `tenant` or read all tenants. `action` matches
711
+ * `lead.export` as `app.lead.export`; framework actions
712
+ * (`entity.update`, `retention.delete`) and auth action names match as
713
+ * given. `limit` defaults to 100 (max 1000). To page,
714
+ * pass `beforeId` set to the last event's `id`. `before` (unix seconds)
715
+ * limits by time.
716
+ */
717
+ list(filter?: {
718
+ entity?: string;
719
+ entityId?: string;
720
+ actor?: string;
721
+ action?: string;
722
+ tenant?: string;
723
+ before?: number;
724
+ beforeId?: string;
725
+ limit?: number;
726
+ }): Promise<AuditEntry[]>;
727
+ }
728
+
729
+ export interface AuditEntry {
730
+ id: string;
731
+ /** Unix seconds. */
732
+ createdAt: number;
733
+ /** `app.<action>`, `entity.insert|update|delete`, `retention.delete`, or an auth action. */
734
+ action: string;
735
+ actor: string | null;
736
+ subject: string | null;
737
+ tenant: string | null;
738
+ entity: string | null;
739
+ entityId: string | null;
740
+ ip: string | null;
741
+ success: boolean;
742
+ reason: string | null;
743
+ /** For `entity.*` events, `fields` lists the changed field names. */
744
+ meta: Record<string, string>;
745
+ }
746
+
747
+ export type WorkflowRunStatus =
748
+ | "pending"
749
+ | "running"
750
+ | "sleeping"
751
+ | "waiting"
752
+ | "completed"
753
+ | "failed"
754
+ | "cancelled";
755
+
756
+ /** A workflow run without its step history. Times are unix seconds. */
757
+ export interface WorkflowRunSummary {
758
+ id: string;
759
+ name: string;
760
+ key: string | null;
761
+ status: WorkflowRunStatus;
762
+ input: unknown;
763
+ output: unknown;
764
+ error: string | null;
765
+ cancelReason: string | null;
766
+ /** Event name the run is waiting for, when `status` is "waiting". */
767
+ waitingFor: string | null;
768
+ /** When the current wait times out, if it has a timeout. */
769
+ waitDeadline: number | null;
770
+ /** When the current sleep ends. */
771
+ wakeAt: number | null;
772
+ /** Events sent but not yet consumed by a `waitForEvent`. */
773
+ bufferedEvents: number;
774
+ createdAt: number | null;
775
+ startedAt: number | null;
776
+ completedAt: number | null;
619
777
  }
620
778
 
621
779
  export interface LlmMessage {
@@ -1029,6 +1187,8 @@ export interface MutationCtx<R extends AuthRequirement = "optional"> {
1029
1187
  connections: Connections;
1030
1188
  /** Durable workflows: start / deliver events — see {@link Workflows}. */
1031
1189
  workflows: Workflows;
1190
+ /** Application audit log (write only in mutations) — see {@link AuditLog}. */
1191
+ audit: AuditLog;
1032
1192
  /** Signed file-download URLs — see {@link Files}. */
1033
1193
  files: Files;
1034
1194
  /** Shard tickets, reads, and inputs after the commit — see {@link Shards}. */
@@ -1063,8 +1223,18 @@ export interface TenantDomainResult {
1063
1223
  status: string;
1064
1224
  /** Whether the hostname + TLS certificate are both active. */
1065
1225
  active?: boolean;
1066
- /** The single CNAME the customer points their hostname at. */
1067
- cnameTarget: string;
1226
+ /**
1227
+ * True for an apex domain (`acme.com`). An apex cannot be a CNAME, so the
1228
+ * customer adds A/AAAA records pointing at the app instead.
1229
+ */
1230
+ apex?: boolean;
1231
+ /**
1232
+ * Every DNS record the customer adds, for both kinds of domain: a CNAME
1233
+ * (subdomain) or A/AAAA (apex), plus ownership and certificate records.
1234
+ */
1235
+ records?: TenantDomainRecord[];
1236
+ /** The CNAME the customer points a subdomain at; null for an apex. */
1237
+ cnameTarget: string | null;
1068
1238
  /** TXT record proving domain ownership before DNS cutover (or null). */
1069
1239
  ownership: TenantDomainDns | null;
1070
1240
  /** TXT records that issue the DV certificate. */
@@ -1073,6 +1243,14 @@ export interface TenantDomainResult {
1073
1243
  errors: string[];
1074
1244
  }
1075
1245
 
1246
+ export interface TenantDomainRecord {
1247
+ type: "A" | "AAAA" | "CNAME" | "TXT";
1248
+ name: string;
1249
+ value: string;
1250
+ /** What the record is for, to show the customer. */
1251
+ purpose: string;
1252
+ }
1253
+
1076
1254
  /**
1077
1255
  * Platform domains: attach / register / detach custom domains for your app's
1078
1256
  * OWN end-customers, so each tenant can reach your app on their own hostname.
@@ -1199,6 +1377,8 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
1199
1377
  connections: Connections;
1200
1378
  /** Durable workflows: start / deliver events — see {@link Workflows}. */
1201
1379
  workflows: Workflows;
1380
+ /** Application audit log — see {@link Audit}. */
1381
+ audit: Audit;
1202
1382
  /** Attach / register custom domains for your app's OWN end-customers
1203
1383
  * (platform domains) — see {@link Domains}. Cloud-only. */
1204
1384
  domains: Domains;
@@ -1249,13 +1429,35 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
1249
1429
  * ```
1250
1430
  */
1251
1431
  request?: RequestInfo;
1432
+ /**
1433
+ * Build a raw HTTP response. When an action called through
1434
+ * `/api/webhooks/<name>` returns it, the route sends this status,
1435
+ * content type, headers, and body instead of JSON. Other routes return
1436
+ * it as ordinary JSON data. Throws on an invalid status or header.
1437
+ *
1438
+ * ```ts
1439
+ * // Twilio expects TwiML, not JSON.
1440
+ * return ctx.response({ contentType: "text/xml", body: "<Response/>" });
1441
+ * ```
1442
+ */
1443
+ response(init?: RawResponseInit): RawResponse;
1252
1444
  }
1253
1445
 
1254
1446
  /** HTTP request metadata available on an action's ctx when invoked via an
1255
1447
  * HTTP route binding. Header names are lowercased. */
1256
1448
  export interface RequestInfo {
1257
1449
  method: string;
1450
+ /** Request path with the query string, as received. */
1258
1451
  path: string;
1452
+ /**
1453
+ * The full URL the client requested: scheme, host, path, and query.
1454
+ * Behind a proxy, the host comes from `X-Forwarded-Host` or `Host`
1455
+ * only when it is trusted (loopback, the `PYLON_PUBLIC_URL` host,
1456
+ * `PYLON_CANONICAL_HOST`, a `PYLON_TRUSTED_HOSTS` entry, or one of the
1457
+ * app's domains); otherwise the `PYLON_PUBLIC_URL` origin is used. Use
1458
+ * it to verify providers that sign the whole URL, such as Twilio.
1459
+ */
1460
+ url: string;
1259
1461
  headers: Record<string, string>;
1260
1462
  rawBody: string;
1261
1463
  }
@@ -177,18 +177,85 @@ describe("executeWorkflowSlice", () => {
177
177
  expect((res as { error: string }).error).toContain("duplicate step name");
178
178
  });
179
179
 
180
- test("duplicate waitForEvent names fail loudly too", async () => {
181
- const dup = workflow("dup-ev", async (wf) => {
182
- await wf.waitForEvent("go");
183
- await wf.waitForEvent("go");
180
+ test("the same event name can be awaited repeatedly; each wait replays in order", async () => {
181
+ const cadence = workflow("cadence", async (wf) => {
182
+ const first = await wf.waitForEvent<{ n: number }>("reply");
183
+ const second = await wf.waitForEvent<{ n: number }>("reply");
184
+ return { first: first.n, second: second.n };
184
185
  });
186
+ const paused = await executeWorkflowSlice(
187
+ cadence,
188
+ req(1, [done("event:reply", { n: 1 })]),
189
+ ctx,
190
+ );
191
+ expect(paused).toEqual({ action: "wait_event", event: "reply" });
185
192
  const res = await executeWorkflowSlice(
186
- dup,
187
- req(1, [done("event:go", { n: 1 })]),
193
+ cadence,
194
+ req(2, [done("event:reply", { n: 1 }), done("event:reply", { n: 2 })]),
188
195
  ctx,
189
196
  );
197
+ expect(res).toEqual({ action: "complete", output: { first: 1, second: 2 } });
198
+ });
199
+
200
+ test("waitForEvent with a timeout sends the timeout and resolves null on timeout", async () => {
201
+ const lead = workflow("lead", async (wf) => {
202
+ await wf.step("send-sms", () => "sent");
203
+ const reply = await wf.waitForEvent("seller_replied", { timeout: "60s" });
204
+ if (reply === null) {
205
+ await wf.step("call", () => "called");
206
+ return "called";
207
+ }
208
+ return "replied";
209
+ });
210
+ const paused = await executeWorkflowSlice(lead, req(1, [done("send-sms", "sent")]), ctx);
211
+ expect(paused).toEqual({ action: "wait_event", event: "seller_replied", timeout: "60s" });
212
+
213
+ const timedOut = await executeWorkflowSlice(
214
+ lead,
215
+ req(2, [done("send-sms", "sent"), done("timeout:seller_replied", null)]),
216
+ ctx,
217
+ );
218
+ expect(timedOut).toMatchObject({ action: "step_complete", step_name: "call" });
219
+
220
+ const replied = await executeWorkflowSlice(
221
+ lead,
222
+ req(2, [done("send-sms", "sent"), done("event:seller_replied", { body: "hi" })]),
223
+ ctx,
224
+ );
225
+ expect(replied).toEqual({ action: "complete", output: "replied" });
226
+ });
227
+
228
+ test("an invalid timeout or sleep duration fails the slice", async () => {
229
+ const badWait = workflow("bad-wait", async (wf) => {
230
+ await wf.waitForEvent("x", { timeout: "soon" });
231
+ });
232
+ const res = await executeWorkflowSlice(badWait, req(0, []), ctx);
233
+ expect(res.action).toBe("fail");
234
+ expect((res as { error: string }).error).toContain("invalid duration");
235
+
236
+ const badSleep = workflow("bad-sleep", async (wf) => {
237
+ await wf.sleep("5 minutes");
238
+ });
239
+ const res2 = await executeWorkflowSlice(badSleep, req(0, []), ctx);
240
+ expect(res2.action).toBe("fail");
241
+ });
242
+
243
+ test("a replayed wait with no record fails loudly", async () => {
244
+ const w = workflow("w", async (wf) => {
245
+ await wf.waitForEvent("x");
246
+ });
247
+ const res = await executeWorkflowSlice(w, req(1, [done("other", 1)]), ctx);
248
+ expect(res.action).toBe("fail");
249
+ expect((res as { error: string }).error).toContain("replay mismatch");
250
+ });
251
+
252
+ test("step names that collide with event records are rejected", async () => {
253
+ const w = workflow("w", async (wf) => {
254
+ await wf.step("event:x", () => 1);
255
+ });
256
+ const res = await executeWorkflowSlice(w, req(0, []), ctx);
190
257
  expect(res.action).toBe("fail");
191
- expect((res as { error: string }).error).toContain("duplicate event name");
258
+ expect((res as { error: string }).error).toContain("reserved");
192
259
  });
193
260
 
194
261
  test("a stepless workflow completes on its first slice", async () => {