@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.
- package/dist/agent.d.ts +27 -6
- package/dist/define.d.ts +2 -1
- package/dist/index.d.ts +3 -2
- package/dist/response.d.ts +39 -0
- package/dist/runtime.d.ts +3 -1
- package/dist/ssr-runtime.d.ts +7 -0
- package/dist/types.d.ts +260 -13
- package/dist/workflows.d.ts +19 -2
- package/package.json +1 -1
- package/src/agent-internals.ts +5 -0
- package/src/agent.test.ts +205 -0
- package/src/agent.ts +220 -99
- package/src/define.ts +2 -1
- package/src/index.ts +10 -0
- package/src/response.test.ts +42 -0
- package/src/response.ts +109 -0
- package/src/runtime-llm-stream.test.ts +85 -0
- package/src/runtime.ts +206 -10
- package/src/ssr-runtime.test.ts +25 -0
- package/src/ssr-runtime.ts +35 -3
- package/src/types.ts +266 -13
- package/src/workflows.test.ts +74 -7
- package/src/workflows.ts +81 -19
package/src/ssr-runtime.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -1092,7 +1092,39 @@ function readSocialImageMeta(relPath: string): {
|
|
|
1092
1092
|
return { type, width, height, v };
|
|
1093
1093
|
}
|
|
1094
1094
|
|
|
1095
|
-
|
|
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 =
|
|
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 (
|
|
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
|
-
/**
|
|
374
|
-
|
|
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
|
-
*
|
|
498
|
-
*
|
|
499
|
-
*
|
|
500
|
-
*
|
|
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(
|
|
634
|
+
start(
|
|
635
|
+
name: string,
|
|
636
|
+
input?: unknown,
|
|
637
|
+
opts?: { key?: string },
|
|
638
|
+
): Promise<{ id: string; created: boolean }>;
|
|
609
639
|
/**
|
|
610
|
-
*
|
|
611
|
-
* `wf.waitForEvent(event)
|
|
612
|
-
*
|
|
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
|
-
/**
|
|
1067
|
-
|
|
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
|
}
|
package/src/workflows.test.ts
CHANGED
|
@@ -177,18 +177,85 @@ describe("executeWorkflowSlice", () => {
|
|
|
177
177
|
expect((res as { error: string }).error).toContain("duplicate step name");
|
|
178
178
|
});
|
|
179
179
|
|
|
180
|
-
test("
|
|
181
|
-
const
|
|
182
|
-
await wf.waitForEvent("
|
|
183
|
-
await wf.waitForEvent("
|
|
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
|
-
|
|
187
|
-
req(
|
|
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("
|
|
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 () => {
|