@pylonsync/functions 0.20.0 → 0.22.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.
@@ -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
  /**
@@ -494,10 +508,16 @@ export interface Llm {
494
508
  * if (res.stop_reason === "tool_use") { ...run tools, loop... }
495
509
  * ```
496
510
  *
497
- * Streaming does NOT extend the function's call deadline — it is an
498
- * absolute wall clock from invocation (PYLON_FN_CALL_TIMEOUT, 30s
499
- * default). A long agent run must declare its own `timeout` on the
500
- * function def.
511
+ * The function's call timeout (PYLON_FN_CALL_TIMEOUT, 30s default, or
512
+ * the function's own `timeout`) is an idle timeout. A running stream
513
+ * counts as activity, but no call outlives 10× its timeout, so a long
514
+ * agent run should still declare its own `timeout`.
515
+ *
516
+ * Pass `{ signal }` to stop a stream early. When the signal aborts,
517
+ * the promise rejects with `err.code === "LLM_CANCELLED"` and the
518
+ * server closes the provider connection, so the model stops
519
+ * generating (and billing) tokens. A stream still running when the
520
+ * function returns or times out is cancelled the same way.
501
521
  *
502
522
  * Same errors and same gating as {@link Llm.complete} — including
503
523
  * the model allowlist, so streaming can't be used to reach a model
@@ -506,6 +526,7 @@ export interface Llm {
506
526
  stream(
507
527
  request: LlmCompleteRequest,
508
528
  onEvent: (event: LlmStreamEvent) => void,
529
+ options?: { signal?: AbortSignal },
509
530
  ): Promise<LlmCompleteResponse>;
510
531
 
511
532
  /**
@@ -604,18 +625,162 @@ export interface Workflows {
604
625
  /**
605
626
  * Start a workflow instance by name. Returns immediately with the
606
627
  * instance id — the engine's background driver executes the steps.
628
+ *
629
+ * With `key` (for example a lead id), at most one non-terminal run
630
+ * exists per workflow name and key. If one exists, no new run starts
631
+ * and its id is returned with `created: false`. A finished run frees
632
+ * the key.
607
633
  */
608
- start(name: string, input?: unknown): Promise<{ id: string }>;
634
+ start(
635
+ name: string,
636
+ input?: unknown,
637
+ opts?: { key?: string },
638
+ ): Promise<{ id: string; created: boolean }>;
609
639
  /**
610
- * Deliver an event to an instance paused on
611
- * `wf.waitForEvent(event)`. Rejects when the instance isn't waiting
612
- * for that event.
640
+ * Send an event to a run. If the run is waiting for it
641
+ * (`wf.waitForEvent(event)`), the run resumes and `delivered` is true.
642
+ * Otherwise the event is buffered (`buffered: true`) and the run's next
643
+ * `waitForEvent(event)` consumes it. Rejects when the run has finished
644
+ * or already holds 100 buffered events. Events sent without data
645
+ * arrive as `{}`.
613
646
  */
614
647
  sendEvent(
615
648
  workflowId: string,
616
649
  event: string,
617
650
  data?: unknown,
618
- ): Promise<{ delivered: boolean }>;
651
+ ): Promise<{ delivered: boolean; buffered: boolean }>;
652
+ /**
653
+ * Cancel a run. No further step runs. A step already in progress may
654
+ * finish, but its result is discarded. Resolves `cancelled: false` when
655
+ * the run had already finished. Rejects for an unknown id.
656
+ */
657
+ cancel(
658
+ workflowId: string,
659
+ opts?: { reason?: string },
660
+ ): Promise<{ cancelled: boolean }>;
661
+ /** Read one run, or null if the id is unknown. */
662
+ get(workflowId: string): Promise<WorkflowRunSummary | null>;
663
+ /**
664
+ * List runs, newest first. `status: "active"` matches every
665
+ * non-terminal status. `limit` defaults to 100 (max 1000).
666
+ *
667
+ * ```ts
668
+ * // Seller texted STOP: end every cadence for the lead.
669
+ * const runs = await ctx.workflows.list({ key: leadId, status: "active" });
670
+ * for (const run of runs) {
671
+ * await ctx.workflows.cancel(run.id, { reason: "STOP" });
672
+ * }
673
+ * ```
674
+ */
675
+ list(filter?: {
676
+ name?: string;
677
+ key?: string;
678
+ status?: WorkflowRunStatus | "active";
679
+ limit?: number;
680
+ }): Promise<WorkflowRunSummary[]>;
681
+ }
682
+
683
+ /**
684
+ * The application audit log (`ctx.audit` on mutations and actions).
685
+ * Append-only, stored with the auth audit events.
686
+ */
687
+ export interface AuditLog {
688
+ /**
689
+ * Record an event. The actor and tenant come from the caller's session.
690
+ * `action` is a short dotted name (`lead.export`, `script.update`) and
691
+ * is stored as `app.<action>`. `meta` values are stored as strings
692
+ * (non-strings as JSON text); keep secrets out of it.
693
+ *
694
+ * Inside a mutation the event is written after the mutation commits and
695
+ * dropped if it rolls back. Rejects with `AUDIT_WRITE_FAILED` when an
696
+ * action's event could not be stored.
697
+ */
698
+ log(event: {
699
+ action: string;
700
+ entity?: string;
701
+ entityId?: string;
702
+ /** The user the event is about, when not the actor. */
703
+ subject?: string;
704
+ meta?: Record<string, unknown>;
705
+ }): Promise<{ id: string }>;
706
+ }
707
+
708
+ /**
709
+ * `ctx.audit` on actions: {@link AuditLog.log} plus reads. Mutations get
710
+ * only `log`: in a Postgres mutation the transaction holds a connection,
711
+ * and a read would need a second.
712
+ */
713
+ export interface Audit extends AuditLog {
714
+ /**
715
+ * Read events, newest first. A caller with a tenant reads that tenant's
716
+ * events; a caller without one reads only events it performed. Admin
717
+ * callers may pass `tenant` or read all tenants. `action` matches
718
+ * `lead.export` as `app.lead.export`; framework actions
719
+ * (`entity.update`, `retention.delete`) and auth action names match as
720
+ * given. `limit` defaults to 100 (max 1000). To page,
721
+ * pass `beforeId` set to the last event's `id`. `before` (unix seconds)
722
+ * limits by time.
723
+ */
724
+ list(filter?: {
725
+ entity?: string;
726
+ entityId?: string;
727
+ actor?: string;
728
+ action?: string;
729
+ tenant?: string;
730
+ before?: number;
731
+ beforeId?: string;
732
+ limit?: number;
733
+ }): Promise<AuditEntry[]>;
734
+ }
735
+
736
+ export interface AuditEntry {
737
+ id: string;
738
+ /** Unix seconds. */
739
+ createdAt: number;
740
+ /** `app.<action>`, `entity.insert|update|delete`, `retention.delete`, or an auth action. */
741
+ action: string;
742
+ actor: string | null;
743
+ subject: string | null;
744
+ tenant: string | null;
745
+ entity: string | null;
746
+ entityId: string | null;
747
+ ip: string | null;
748
+ success: boolean;
749
+ reason: string | null;
750
+ /** For `entity.*` events, `fields` lists the changed field names. */
751
+ meta: Record<string, string>;
752
+ }
753
+
754
+ export type WorkflowRunStatus =
755
+ | "pending"
756
+ | "running"
757
+ | "sleeping"
758
+ | "waiting"
759
+ | "completed"
760
+ | "failed"
761
+ | "cancelled";
762
+
763
+ /** A workflow run without its step history. Times are unix seconds. */
764
+ export interface WorkflowRunSummary {
765
+ id: string;
766
+ name: string;
767
+ key: string | null;
768
+ status: WorkflowRunStatus;
769
+ input: unknown;
770
+ output: unknown;
771
+ error: string | null;
772
+ cancelReason: string | null;
773
+ /** Event name the run is waiting for, when `status` is "waiting". */
774
+ waitingFor: string | null;
775
+ /** When the current wait times out, if it has a timeout. */
776
+ waitDeadline: number | null;
777
+ /** When the current sleep ends. */
778
+ wakeAt: number | null;
779
+ /** Events sent but not yet consumed by a `waitForEvent`. */
780
+ bufferedEvents: number;
781
+ createdAt: number | null;
782
+ startedAt: number | null;
783
+ completedAt: number | null;
619
784
  }
620
785
 
621
786
  export interface LlmMessage {
@@ -1029,6 +1194,8 @@ export interface MutationCtx<R extends AuthRequirement = "optional"> {
1029
1194
  connections: Connections;
1030
1195
  /** Durable workflows: start / deliver events — see {@link Workflows}. */
1031
1196
  workflows: Workflows;
1197
+ /** Application audit log (write only in mutations) — see {@link AuditLog}. */
1198
+ audit: AuditLog;
1032
1199
  /** Signed file-download URLs — see {@link Files}. */
1033
1200
  files: Files;
1034
1201
  /** Shard tickets, reads, and inputs after the commit — see {@link Shards}. */
@@ -1063,8 +1230,18 @@ export interface TenantDomainResult {
1063
1230
  status: string;
1064
1231
  /** Whether the hostname + TLS certificate are both active. */
1065
1232
  active?: boolean;
1066
- /** The single CNAME the customer points their hostname at. */
1067
- cnameTarget: string;
1233
+ /**
1234
+ * True for an apex domain (`acme.com`). An apex cannot be a CNAME, so the
1235
+ * customer adds A/AAAA records pointing at the app instead.
1236
+ */
1237
+ apex?: boolean;
1238
+ /**
1239
+ * Every DNS record the customer adds, for both kinds of domain: a CNAME
1240
+ * (subdomain) or A/AAAA (apex), plus ownership and certificate records.
1241
+ */
1242
+ records?: TenantDomainRecord[];
1243
+ /** The CNAME the customer points a subdomain at; null for an apex. */
1244
+ cnameTarget: string | null;
1068
1245
  /** TXT record proving domain ownership before DNS cutover (or null). */
1069
1246
  ownership: TenantDomainDns | null;
1070
1247
  /** TXT records that issue the DV certificate. */
@@ -1073,6 +1250,14 @@ export interface TenantDomainResult {
1073
1250
  errors: string[];
1074
1251
  }
1075
1252
 
1253
+ export interface TenantDomainRecord {
1254
+ type: "A" | "AAAA" | "CNAME" | "TXT";
1255
+ name: string;
1256
+ value: string;
1257
+ /** What the record is for, to show the customer. */
1258
+ purpose: string;
1259
+ }
1260
+
1076
1261
  /**
1077
1262
  * Platform domains: attach / register / detach custom domains for your app's
1078
1263
  * OWN end-customers, so each tenant can reach your app on their own hostname.
@@ -1185,6 +1370,48 @@ export interface Domains {
1185
1370
  ): Promise<RegisteredDomainResult>;
1186
1371
  }
1187
1372
 
1373
+ /** Options for {@link Agents.run}. */
1374
+ export interface AgentRunOptions {
1375
+ /** The message for this turn. */
1376
+ input: string;
1377
+ /** Continue this run. It must belong to `as.userId` and this agent. */
1378
+ runId?: string;
1379
+ /** Display title, stored on a new run. */
1380
+ title?: string;
1381
+ /**
1382
+ * The user the run belongs to and runs as. The run shows up in that
1383
+ * user's synced `AgentRun`/`AgentMessage` rows. `admin: true` also
1384
+ * gives the run admin rights (needed for an agent declared
1385
+ * `auth: "admin"`), and is allowed only when the caller is an admin.
1386
+ */
1387
+ as: { userId: string; admin?: boolean };
1388
+ /**
1389
+ * JSON the agent's tool handlers receive as `run.context` (their third
1390
+ * argument), for example the conversation a webhook turn belongs to.
1391
+ * Stored on the run as `AgentRun.context`, so later turns of the run
1392
+ * see it too. The run's owner can read it; do not put secrets in it.
1393
+ */
1394
+ context?: Record<string, unknown>;
1395
+ }
1396
+
1397
+ /**
1398
+ * Run an `agent()` from server code. See {@link ActionCtx.agents}.
1399
+ */
1400
+ export interface Agents {
1401
+ /**
1402
+ * Run agent `name` (its file name in `functions/`) as `options.as`.
1403
+ * Resolves with the agent's result once the turn finishes, like a
1404
+ * `streamFn` call's final `result` event. The agent's tokens are not
1405
+ * streamed to this caller; they are in the run's `AgentMessage` rows.
1406
+ *
1407
+ * Only server code can do this: the calling action must be
1408
+ * `internal: true` (jobs, workflows, scheduled work) or run with admin
1409
+ * rights (an admin session, or a webhook after `ctx.auth.elevate`).
1410
+ * Otherwise it throws `AGENT_RUN_FORBIDDEN`.
1411
+ */
1412
+ run(name: string, options: AgentRunOptions): Promise<import("./agent").AgentResult>;
1413
+ }
1414
+
1188
1415
  export interface ActionCtx<R extends AuthRequirement = "optional"> {
1189
1416
  auth: AuthInfo<R>;
1190
1417
  stream: Stream;
@@ -1199,6 +1426,10 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
1199
1426
  connections: Connections;
1200
1427
  /** Durable workflows: start / deliver events — see {@link Workflows}. */
1201
1428
  workflows: Workflows;
1429
+ /** Application audit log — see {@link Audit}. */
1430
+ audit: Audit;
1431
+ /** Run an `agent()` as a named user from server code — see {@link Agents}. */
1432
+ agents: Agents;
1202
1433
  /** Attach / register custom domains for your app's OWN end-customers
1203
1434
  * (platform domains) — see {@link Domains}. Cloud-only. */
1204
1435
  domains: Domains;
@@ -1249,13 +1480,35 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
1249
1480
  * ```
1250
1481
  */
1251
1482
  request?: RequestInfo;
1483
+ /**
1484
+ * Build a raw HTTP response. When an action called through
1485
+ * `/api/webhooks/<name>` returns it, the route sends this status,
1486
+ * content type, headers, and body instead of JSON. Other routes return
1487
+ * it as ordinary JSON data. Throws on an invalid status or header.
1488
+ *
1489
+ * ```ts
1490
+ * // Twilio expects TwiML, not JSON.
1491
+ * return ctx.response({ contentType: "text/xml", body: "<Response/>" });
1492
+ * ```
1493
+ */
1494
+ response(init?: RawResponseInit): RawResponse;
1252
1495
  }
1253
1496
 
1254
1497
  /** HTTP request metadata available on an action's ctx when invoked via an
1255
1498
  * HTTP route binding. Header names are lowercased. */
1256
1499
  export interface RequestInfo {
1257
1500
  method: string;
1501
+ /** Request path with the query string, as received. */
1258
1502
  path: string;
1503
+ /**
1504
+ * The full URL the client requested: scheme, host, path, and query.
1505
+ * Behind a proxy, the host comes from `X-Forwarded-Host` or `Host`
1506
+ * only when it is trusted (loopback, the `PYLON_PUBLIC_URL` host,
1507
+ * `PYLON_CANONICAL_HOST`, a `PYLON_TRUSTED_HOSTS` entry, or one of the
1508
+ * app's domains); otherwise the `PYLON_PUBLIC_URL` origin is used. Use
1509
+ * it to verify providers that sign the whole URL, such as Twilio.
1510
+ */
1511
+ url: string;
1259
1512
  headers: Record<string, string>;
1260
1513
  rawBody: string;
1261
1514
  }
@@ -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 () => {