@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/src/index.ts CHANGED
@@ -21,6 +21,7 @@
21
21
  export { query, mutation, action } from "./define";
22
22
  export { v } from "./validators";
23
23
  export { workflow } from "./workflows";
24
+ export type { RawResponse, RawResponseInit } from "./response";
24
25
  export {
25
26
  agent,
26
27
  isAgentDefinition,
@@ -30,6 +31,7 @@ export {
30
31
  export type {
31
32
  AgentDefinition,
32
33
  AgentTool,
34
+ AgentToolRun,
33
35
  AgentCallArgs,
34
36
  AgentResult,
35
37
  } from "./agent";
@@ -80,6 +82,13 @@ export type {
80
82
  ShardTransfer,
81
83
  MemberRow,
82
84
  Workflows,
85
+ WorkflowRunSummary,
86
+ WorkflowRunStatus,
87
+ Audit,
88
+ AuditLog,
89
+ AuditEntry,
90
+ Agents,
91
+ AgentRunOptions,
83
92
  VectorSearchQuery,
84
93
  VectorSearchResult,
85
94
  SearchResult,
@@ -99,6 +108,7 @@ export type {
99
108
  // results (the DNS the end-customer must set).
100
109
  Domains,
101
110
  TenantDomainResult,
111
+ TenantDomainRecord,
102
112
  TenantDomainDns,
103
113
  DomainAvailability,
104
114
  DomainContact,
@@ -0,0 +1,42 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { response } from "./response";
3
+
4
+ describe("ctx.response", () => {
5
+ test("defaults to an empty 200", () => {
6
+ expect(response()).toEqual({ __pylonResponse: 1, status: 200, headers: {}, body: "" });
7
+ });
8
+
9
+ test("contentType sets the content-type header and replaces one in headers", () => {
10
+ const r = response({
11
+ contentType: "text/xml",
12
+ headers: { "Content-Type": "text/plain", "X-Id": "1" },
13
+ body: "<Response/>",
14
+ });
15
+ expect(r.headers).toEqual({ "X-Id": "1", "content-type": "text/xml" });
16
+ expect(r.body).toBe("<Response/>");
17
+ });
18
+
19
+ test("rejects out-of-range and non-integer status", () => {
20
+ for (const status of [99, 101, 600, 200.5, Number.NaN]) {
21
+ expect(() => response({ status })).toThrow("status");
22
+ }
23
+ });
24
+
25
+ test("rejects a body on 204", () => {
26
+ expect(() => response({ status: 204, body: "x" })).toThrow("cannot carry a body");
27
+ expect(response({ status: 204 }).status).toBe(204);
28
+ });
29
+
30
+ test("rejects header injection and invalid names", () => {
31
+ expect(() => response({ headers: { "X-A": "a\r\nSet-Cookie: x=1" } })).toThrow("control");
32
+ expect(() => response({ headers: { "X-A": "café" } })).toThrow("control");
33
+ expect(() => response({ headers: { "X A": "v" } })).toThrow("token");
34
+ expect(() => response({ contentType: "text/xml\r\nX: y" })).toThrow("control");
35
+ });
36
+
37
+ test("rejects headers the server owns, in any case", () => {
38
+ for (const name of ["Content-Length", "set-cookie", "Access-Control-Allow-Origin", "Connection"]) {
39
+ expect(() => response({ headers: { [name]: "v" } })).toThrow("set by the server");
40
+ }
41
+ });
42
+ });
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Raw HTTP responses from webhook actions.
3
+ *
4
+ * An action called through `/api/webhooks/<name>` normally answers with its
5
+ * return value as JSON and status 200. Returning `ctx.response(...)` sends
6
+ * the given status, content type, headers, and body instead. Every other
7
+ * route (`/api/fn/<name>`, `ctx.runAction`) returns the value as ordinary
8
+ * JSON data.
9
+ *
10
+ * The host enforces the same rules (crates/router/src/raw_response.rs), so
11
+ * a hand-built object cannot bypass them. The two header lists must stay in
12
+ * sync.
13
+ */
14
+
15
+ /** What `ctx.response` accepts. */
16
+ export interface RawResponseInit {
17
+ /** HTTP status, 200-599. Default 200. */
18
+ status?: number;
19
+ /** Shorthand for the `Content-Type` header. Default `text/plain; charset=utf-8`. */
20
+ contentType?: string;
21
+ /** Extra response headers. Values must be visible ASCII. */
22
+ headers?: Record<string, string>;
23
+ /** Response body. Default empty. Must be empty for 204, 205, and 304. */
24
+ body?: string;
25
+ }
26
+
27
+ /** The marked value an action returns. Build it with `ctx.response`. */
28
+ export interface RawResponse {
29
+ readonly __pylonResponse: 1;
30
+ readonly status: number;
31
+ readonly headers: Readonly<Record<string, string>>;
32
+ readonly body: string;
33
+ }
34
+
35
+ /** Header names the server sets itself. Compared lowercase. */
36
+ const RESERVED_HEADERS = new Set([
37
+ "connection",
38
+ "content-length",
39
+ "keep-alive",
40
+ "proxy-authenticate",
41
+ "proxy-authorization",
42
+ "proxy-connection",
43
+ "te",
44
+ "trailer",
45
+ "transfer-encoding",
46
+ "upgrade",
47
+ "set-cookie",
48
+ "permissions-policy",
49
+ "referrer-policy",
50
+ "x-content-type-options",
51
+ "x-frame-options",
52
+ "x-xss-protection",
53
+ ]);
54
+ const RESERVED_HEADER_PREFIXES = ["access-control-"];
55
+
56
+ const TOKEN_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
57
+ const VALUE_RE = /^[\t\x20-\x7e]*$/;
58
+
59
+ function checkHeader(name: string, value: unknown): void {
60
+ if (!TOKEN_RE.test(name)) {
61
+ throw new Error(`ctx.response: header name ${JSON.stringify(name)} is not a valid HTTP token`);
62
+ }
63
+ if (typeof value !== "string") {
64
+ throw new Error(`ctx.response: header "${name}" must have a string value`);
65
+ }
66
+ if (!VALUE_RE.test(value)) {
67
+ throw new Error(
68
+ `ctx.response: header "${name}" has a value with control or non-ASCII characters`,
69
+ );
70
+ }
71
+ const lower = name.toLowerCase();
72
+ if (RESERVED_HEADERS.has(lower) || RESERVED_HEADER_PREFIXES.some((p) => lower.startsWith(p))) {
73
+ throw new Error(`ctx.response: header "${name}" is set by the server`);
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Build a raw HTTP response for a webhook action to return.
79
+ *
80
+ * ```ts
81
+ * return ctx.response({ contentType: "text/xml", body: "<Response/>" });
82
+ * ```
83
+ */
84
+ export function response(init: RawResponseInit = {}): RawResponse {
85
+ const status = init.status ?? 200;
86
+ if (!Number.isInteger(status) || status < 200 || status > 599) {
87
+ throw new Error(`ctx.response: status must be an integer from 200 to 599, got ${status}`);
88
+ }
89
+ const body = init.body ?? "";
90
+ if (typeof body !== "string") {
91
+ throw new Error("ctx.response: body must be a string");
92
+ }
93
+ if (body !== "" && (status === 204 || status === 205 || status === 304)) {
94
+ throw new Error(`ctx.response: status ${status} cannot carry a body`);
95
+ }
96
+ const headers: Record<string, string> = {};
97
+ for (const [name, value] of Object.entries(init.headers ?? {})) {
98
+ checkHeader(name, value);
99
+ headers[name] = value;
100
+ }
101
+ if (init.contentType !== undefined) {
102
+ checkHeader("Content-Type", init.contentType);
103
+ for (const name of Object.keys(headers)) {
104
+ if (name.toLowerCase() === "content-type") delete headers[name];
105
+ }
106
+ headers["content-type"] = init.contentType;
107
+ }
108
+ return { __pylonResponse: 1, status, headers, body };
109
+ }
@@ -184,3 +184,88 @@ test("llm.stream routes events by op_id, resolves on result; rooms.broadcast rou
184
184
  // 6. rooms.broadcast surfaced the host's verdict.
185
185
  expect(out.delivered).toBe(true);
186
186
  });
187
+
188
+ const ABORT_SCRIPT = `
189
+ import { buildLlm } from ${JSON.stringify(RUNTIME)};
190
+
191
+ const llm = buildLlm("c_9");
192
+ const seen = [];
193
+ const controller = new AbortController();
194
+
195
+ // Aborted before it starts: rejects without sending a frame.
196
+ const early = await llm
197
+ .stream({ messages: [] }, () => {}, { signal: AbortSignal.abort("stopped") })
198
+ .then(() => "resolved", (e) => e.code);
199
+
200
+ const running = llm
201
+ .stream({ messages: [] }, (e) => {
202
+ seen.push(e.text);
203
+ if (seen.length === 2) controller.abort(new Error("user pressed stop"));
204
+ }, { signal: controller.signal })
205
+ .then(() => ({ code: "resolved" }), (e) => ({ code: e.code, message: e.message }));
206
+
207
+ const out = await running;
208
+ // Give late host frames time to arrive; they must be ignored.
209
+ await new Promise((r) => setTimeout(r, 200));
210
+ console.error("RESULT " + JSON.stringify({ early, out, seen }));
211
+ process.exit(0);
212
+ `;
213
+
214
+ test("an aborted llm.stream rejects with LLM_CANCELLED and sends llm_cancel", async () => {
215
+ const dir = mkdtempSync(join(tmpdir(), "pylon-fn-llm-abort-"));
216
+ const scriptPath = join(dir, "probe.ts");
217
+ writeFileSync(scriptPath, ABORT_SCRIPT);
218
+ const proc = Bun.spawn([process.execPath, scriptPath], {
219
+ stdin: "pipe",
220
+ stdout: "pipe",
221
+ stderr: "pipe",
222
+ });
223
+ const reader = proc.stdout.getReader();
224
+ const decoder = new TextDecoder();
225
+ let buffered = "";
226
+ const frames = () => parseFrames(buffered);
227
+ const readUntil = async (
228
+ pred: (fs: Record<string, unknown>[]) => boolean,
229
+ ) => {
230
+ while (!pred(frames())) {
231
+ const { done, value } = await reader.read();
232
+ if (done) break;
233
+ buffered += decoder.decode(value, { stream: true });
234
+ }
235
+ };
236
+ const send = (msg: Record<string, unknown>) =>
237
+ proc.stdin.write(JSON.stringify(msg) + "\n");
238
+
239
+ await readUntil((fs) => fs.some((f) => f.type === "llm_stream"));
240
+ const streams = frames().filter((f) => f.type === "llm_stream");
241
+ // The pre-aborted stream never reached the host.
242
+ expect(streams).toHaveLength(1);
243
+ const opId = streams[0].op_id as string;
244
+
245
+ send({ type: "llm_event", call_id: "c_9", op_id: opId, event: { type: "text_delta", text: "a" } });
246
+ send({ type: "llm_event", call_id: "c_9", op_id: opId, event: { type: "text_delta", text: "b" } });
247
+ await proc.stdin.flush();
248
+ await readUntil((fs) => fs.some((f) => f.type === "llm_cancel"));
249
+ const cancel = frames().find((f) => f.type === "llm_cancel");
250
+ expect(cancel).toEqual({ type: "llm_cancel", call_id: "c_9", op_id: opId });
251
+
252
+ // Frames the host sent before it saw the cancel are dropped.
253
+ send({ type: "llm_event", call_id: "c_9", op_id: opId, event: { type: "text_delta", text: "c" } });
254
+ send({
255
+ type: "result",
256
+ call_id: "c_9",
257
+ op_id: opId,
258
+ data: { model: "m", content: [], stop_reason: "end_turn", usage: { input_tokens: 0, output_tokens: 0 } },
259
+ });
260
+ await proc.stdin.flush();
261
+
262
+ const stderr = await new Response(proc.stderr).text();
263
+ await proc.exited;
264
+ const line = stderr.split("\n").find((l) => l.includes("RESULT "));
265
+ expect(line).toBeDefined();
266
+ const out = JSON.parse(line!.slice(line!.indexOf("RESULT ") + 7));
267
+ expect(out.early).toBe("LLM_CANCELLED");
268
+ expect(out.out.code).toBe("LLM_CANCELLED");
269
+ expect(out.out.message).toContain("user pressed stop");
270
+ expect(out.seen).toEqual(["a", "b"]);
271
+ });
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,
@@ -46,13 +49,16 @@ import type {
46
49
  AuthInfo,
47
50
  } from "./types";
48
51
  import { normalizeAuthClaims } from "./auth";
52
+ import { AGENT_INVOCATION, isAgentDefinition } from "./agent";
49
53
  import { makeRequireMember } from "./member";
54
+ import { response } from "./response";
50
55
  import { isDevMode } from "./ssr-runtime";
51
56
  import { validateArgs } from "./validators";
52
57
  import { serverBundle } from "./server-bundle";
53
58
  import { fenceStdout } from "./stdout-fence";
54
59
  import { readdirSync } from "fs";
55
- import { join, basename } from "path";
60
+ import { join, basename, resolve } from "path";
61
+ import { pathToFileURL } from "url";
56
62
 
57
63
  // Bun runtime globals this process uses. The runtime executes under Bun, but
58
64
  // consuming apps type-check this source under node/DOM where the `Bun` global
@@ -80,6 +86,9 @@ interface CallMessage {
80
86
  fn_type: "query" | "mutation" | "action";
81
87
  args: Record<string, unknown>;
82
88
  auth: AuthInfo;
89
+ /** Present only when server code started this call with
90
+ * `ctx.agents.run`; the host sets it, never a client. */
91
+ agent?: { context?: Record<string, unknown> | null };
83
92
  }
84
93
 
85
94
  interface ResultMessage {
@@ -388,19 +397,37 @@ function rpcDb(
388
397
  * is alive, however long it runs in total, while one whose provider
389
398
  * goes silent still trips the safety net. The host's own call deadline
390
399
  * remains the real upper bound.
400
+ *
401
+ * When `signal` aborts, the promise rejects with `LLM_CANCELLED` at
402
+ * once and an `llm_cancel` frame tells the host to drop the provider
403
+ * request. Events and the result that arrive after that are ignored.
391
404
  */
392
405
  function rpcStreaming(
393
406
  callId: string,
394
407
  msg: Record<string, unknown>,
395
408
  onEvent: (event: unknown) => void,
409
+ signal?: AbortSignal,
396
410
  ): Promise<unknown> {
397
411
  throwIfCancelled(callId);
412
+ if (signal?.aborted) return Promise.reject(streamCancelledError(signal));
398
413
  const opId = nextOpId(callId);
399
414
  return new Promise((resolve, reject) => {
415
+ const onAbort = () => {
416
+ const pending = pendingRpcs.get(opId);
417
+ if (!pending) return;
418
+ pendingRpcs.delete(opId);
419
+ streamSinks.delete(opId);
420
+ clearTimeout(pending.timeout);
421
+ send({ type: "llm_cancel", call_id: callId, op_id: opId });
422
+ reject(streamCancelledError(signal));
423
+ };
400
424
  const fail = () => {
401
425
  if (pendingRpcs.has(opId)) {
402
426
  pendingRpcs.delete(opId);
403
427
  streamSinks.delete(opId);
428
+ signal?.removeEventListener("abort", onAbort);
429
+ // Nothing waits for this stream any more; stop the provider.
430
+ send({ type: "llm_cancel", call_id: callId, op_id: opId });
404
431
  reject(
405
432
  new Error(
406
433
  `RPC timed out after ${RPC_TIMEOUT_MS}ms with no stream activity (call_id=${callId} op_id=${opId})`,
@@ -411,14 +438,17 @@ function rpcStreaming(
411
438
  const entry = {
412
439
  resolve: (data: unknown) => {
413
440
  streamSinks.delete(opId);
441
+ signal?.removeEventListener("abort", onAbort);
414
442
  resolve(data);
415
443
  },
416
444
  reject: (err: Error) => {
417
445
  streamSinks.delete(opId);
446
+ signal?.removeEventListener("abort", onAbort);
418
447
  reject(err);
419
448
  },
420
449
  timeout: setTimeout(fail, RPC_TIMEOUT_MS),
421
450
  };
451
+ signal?.addEventListener("abort", onAbort, { once: true });
422
452
  pendingRpcs.set(opId, entry);
423
453
  streamSinks.set(opId, (event) => {
424
454
  clearTimeout(entry.timeout);
@@ -436,6 +466,17 @@ function rpcStreaming(
436
466
  });
437
467
  }
438
468
 
469
+ function streamCancelledError(signal?: AbortSignal): Error {
470
+ const reason = signal?.reason;
471
+ const detail =
472
+ reason instanceof Error ? reason.message : reason ? String(reason) : "";
473
+ const err = new Error(
474
+ detail ? `ctx.llm.stream was cancelled: ${detail}` : "ctx.llm.stream was cancelled",
475
+ );
476
+ (err as { code?: string }).code = "LLM_CANCELLED";
477
+ return err;
478
+ }
479
+
439
480
  /**
440
481
  * Tail of the in-order queue of legacy (call_id-keyed) RPCs, per call.
441
482
  * See {@link rpc} for why these serialize.
@@ -840,10 +881,10 @@ function buildScheduler(callId: string): Scheduler {
840
881
  return r.id || "";
841
882
  },
842
883
  async cancel(scheduleId) {
843
- await rpc(callId, {
884
+ return (await rpc(callId, {
844
885
  type: "cancel_schedule",
845
886
  schedule_id: scheduleId,
846
- });
887
+ })) as { cancelled: boolean };
847
888
  },
848
889
  };
849
890
  }
@@ -937,11 +978,13 @@ export function buildLlm(callId: string): Llm {
937
978
  async stream(
938
979
  request: LlmCompleteRequest,
939
980
  onEvent: (event: LlmStreamEvent) => void,
981
+ options?: { signal?: AbortSignal },
940
982
  ): Promise<LlmCompleteResponse> {
941
983
  return (await rpcStreaming(
942
984
  callId,
943
985
  { type: "llm_stream", request },
944
986
  (event) => onEvent(event as LlmStreamEvent),
987
+ options?.signal,
945
988
  )) as LlmCompleteResponse;
946
989
  },
947
990
 
@@ -985,15 +1028,47 @@ export function buildRooms(callId: string): Rooms {
985
1028
  * takes it from there, so `start` returns the instance id immediately.
986
1029
  * Uses the queued call_id-keyed `rpc` (the reply carries no op_id).
987
1030
  */
1031
+ /** Build `ctx.audit` — round-trips `audit_op` frames to the host. */
1032
+ export function buildAudit(callId: string): Audit {
1033
+ return {
1034
+ async log(event) {
1035
+ return (await rpc(callId, {
1036
+ type: "audit_op",
1037
+ op: "log",
1038
+ action: event.action,
1039
+ entity: event.entity ?? null,
1040
+ entity_id: event.entityId ?? null,
1041
+ subject: event.subject ?? null,
1042
+ meta: event.meta ?? null,
1043
+ })) as { id: string };
1044
+ },
1045
+ async list(filter) {
1046
+ return (await rpc(callId, {
1047
+ type: "audit_op",
1048
+ op: "list",
1049
+ action: filter?.action ?? null,
1050
+ entity: filter?.entity ?? null,
1051
+ entity_id: filter?.entityId ?? null,
1052
+ actor: filter?.actor ?? null,
1053
+ tenant: filter?.tenant ?? null,
1054
+ before: filter?.before ?? null,
1055
+ before_id: filter?.beforeId ?? null,
1056
+ limit: filter?.limit ?? null,
1057
+ })) as AuditEntry[];
1058
+ },
1059
+ };
1060
+ }
1061
+
988
1062
  export function buildWorkflows(callId: string): Workflows {
989
1063
  return {
990
- async start(name: string, input?: unknown) {
1064
+ async start(name: string, input?: unknown, opts?: { key?: string }) {
991
1065
  return (await rpc(callId, {
992
1066
  type: "workflow_op",
993
1067
  op: "start",
994
1068
  name,
995
1069
  input: input ?? null,
996
- })) as { id: string };
1070
+ key: opts?.key ?? null,
1071
+ })) as { id: string; created: boolean };
997
1072
  },
998
1073
  async sendEvent(workflowId: string, event: string, data?: unknown) {
999
1074
  return (await rpc(callId, {
@@ -1002,7 +1077,32 @@ export function buildWorkflows(callId: string): Workflows {
1002
1077
  workflow_id: workflowId,
1003
1078
  event,
1004
1079
  data: data ?? null,
1005
- })) as { delivered: boolean };
1080
+ })) as { delivered: boolean; buffered: boolean };
1081
+ },
1082
+ async cancel(workflowId: string, opts?: { reason?: string }) {
1083
+ return (await rpc(callId, {
1084
+ type: "workflow_op",
1085
+ op: "cancel",
1086
+ workflow_id: workflowId,
1087
+ reason: opts?.reason ?? null,
1088
+ })) as { cancelled: boolean };
1089
+ },
1090
+ async get(workflowId: string) {
1091
+ return (await rpc(callId, {
1092
+ type: "workflow_op",
1093
+ op: "get",
1094
+ workflow_id: workflowId,
1095
+ })) as WorkflowRunSummary | null;
1096
+ },
1097
+ async list(filter) {
1098
+ return (await rpc(callId, {
1099
+ type: "workflow_op",
1100
+ op: "list",
1101
+ name: filter?.name ?? null,
1102
+ key: filter?.key ?? null,
1103
+ status: filter?.status ?? null,
1104
+ limit: filter?.limit ?? null,
1105
+ })) as WorkflowRunSummary[];
1006
1106
  },
1007
1107
  };
1008
1108
  }
@@ -1166,6 +1266,7 @@ function buildActionCtx(
1166
1266
  normalizedRequest = {
1167
1267
  method: String(r.method ?? ""),
1168
1268
  path: String(r.path ?? ""),
1269
+ url: String(r.url ?? ""),
1169
1270
  headers: (r.headers as Record<string, string>) ?? {},
1170
1271
  rawBody: String(r.raw_body ?? r.rawBody ?? ""),
1171
1272
  };
@@ -1179,6 +1280,39 @@ function buildActionCtx(
1179
1280
  rooms,
1180
1281
  connections,
1181
1282
  workflows: buildWorkflows(callId),
1283
+ audit: buildAudit(callId),
1284
+ agents: {
1285
+ async run(name, options) {
1286
+ if (!options || typeof options.as?.userId !== "string") {
1287
+ const err = new Error(
1288
+ "ctx.agents.run needs as.userId: the user the run belongs to",
1289
+ );
1290
+ (err as { code?: string }).code = "INVALID_ARGS";
1291
+ throw err;
1292
+ }
1293
+ const context = options.context;
1294
+ if (
1295
+ context !== undefined &&
1296
+ context !== null &&
1297
+ (typeof context !== "object" || Array.isArray(context))
1298
+ ) {
1299
+ const err = new Error("ctx.agents.run: context must be a plain object");
1300
+ (err as { code?: string }).code = "INVALID_ARGS";
1301
+ throw err;
1302
+ }
1303
+ const args: Record<string, unknown> = { input: options.input };
1304
+ if (options.runId !== undefined) args.runId = options.runId;
1305
+ if (options.title !== undefined) args.title = options.title;
1306
+ return rpc(callId, {
1307
+ type: "run_agent",
1308
+ agent: name,
1309
+ args,
1310
+ user_id: options.as.userId,
1311
+ admin: options.as.admin === true,
1312
+ context: options.context ?? null,
1313
+ }) as Promise<any>;
1314
+ },
1315
+ },
1182
1316
  domains: buildDomains(),
1183
1317
  env: process.env as Record<string, string>,
1184
1318
  async runQuery(fnName, args) {
@@ -1214,6 +1348,7 @@ function buildActionCtx(
1214
1348
  }) as Promise<any[]>,
1215
1349
  ),
1216
1350
  request: normalizedRequest,
1351
+ response,
1217
1352
  };
1218
1353
  }
1219
1354
 
@@ -1240,6 +1375,32 @@ registry.set("__pylonMemberLookup", {
1240
1375
  // Handler
1241
1376
  // ---------------------------------------------------------------------------
1242
1377
 
1378
+ /** Why a `ctx.agents.run` target may not run, or null when it may. */
1379
+ function agentInvocationRefusal(
1380
+ def: FnDefinition,
1381
+ rawAuth: AuthInfo,
1382
+ ): { code: string; message: string } | null {
1383
+ if (!isAgentDefinition(def)) {
1384
+ return {
1385
+ code: "AGENT_NOT_FOUND",
1386
+ message: "ctx.agents.run can only run a function defined with agent()",
1387
+ };
1388
+ }
1389
+ const auth = normalizeAuthClaims(rawAuth as unknown as Record<string, unknown>);
1390
+ const mode = (def as { auth?: string }).auth ?? "user";
1391
+ if (mode === "admin" && !auth.isAdmin) {
1392
+ return {
1393
+ code: "FORBIDDEN",
1394
+ message:
1395
+ "this agent is declared auth: \"admin\"; pass as: { userId, admin: true } from an admin caller",
1396
+ };
1397
+ }
1398
+ if ((mode === "user" || mode === "admin") && !auth.userId) {
1399
+ return { code: "UNAUTHENTICATED", message: "the agent needs a user" };
1400
+ }
1401
+ return null;
1402
+ }
1403
+
1243
1404
  async function handleCall(msg: CallMessage): Promise<void> {
1244
1405
  const def = registry.get(msg.fn_name);
1245
1406
 
@@ -1268,6 +1429,18 @@ async function handleCall(msg: CallMessage): Promise<void> {
1268
1429
  return;
1269
1430
  }
1270
1431
 
1432
+ // `ctx.agents.run`: the host checked who may start this and as whom.
1433
+ // What is left is the target: it must be an agent, and the identity
1434
+ // must satisfy the agent's own auth mode, exactly as an HTTP call to
1435
+ // it would have to.
1436
+ if (msg.agent) {
1437
+ const refusal = agentInvocationRefusal(def, msg.auth);
1438
+ if (refusal) {
1439
+ send({ type: "error", call_id: msg.call_id, ...refusal });
1440
+ return;
1441
+ }
1442
+ }
1443
+
1271
1444
  if (def.args) {
1272
1445
  const { valid, errors } = validateArgs(msg.args, def.args);
1273
1446
  if (!valid) {
@@ -1372,6 +1545,7 @@ async function handleCall(msg: CallMessage): Promise<void> {
1372
1545
  rooms,
1373
1546
  connections,
1374
1547
  workflows: buildWorkflows(msg.call_id),
1548
+ audit: buildAudit(msg.call_id),
1375
1549
  error(code, message) {
1376
1550
  const err = new Error(message);
1377
1551
  (err as any).code = code;
@@ -1404,6 +1578,13 @@ async function handleCall(msg: CallMessage): Promise<void> {
1404
1578
  // the switch so all three ctx shapes get it without threading another
1405
1579
  // parameter through each builder.
1406
1580
  (ctx as { signal?: AbortSignal }).signal = abort.signal;
1581
+ if (msg.agent) {
1582
+ // Read by the agent loop only. A symbol key, so nothing a client
1583
+ // sends (args are plain JSON) can set it.
1584
+ (ctx as unknown as Record<symbol, unknown>)[AGENT_INVOCATION] = {
1585
+ context: msg.agent.context ?? null,
1586
+ };
1587
+ }
1407
1588
 
1408
1589
  try {
1409
1590
  const result = await def.handler(ctx, msg.args);
@@ -1496,19 +1677,29 @@ function firstStackFrame(err: unknown): string {
1496
1677
  */
1497
1678
  function listModuleFiles(
1498
1679
  dir: string,
1680
+ opts: { reportMissing?: boolean } = {},
1499
1681
  ): Array<{ name: string; file: string; load: () => Promise<any> }> {
1500
1682
  let files: string[];
1501
1683
  try {
1502
1684
  files = readdirSync(dir).filter(
1503
1685
  (f) => f.endsWith(".ts") || f.endsWith(".js"),
1504
1686
  );
1505
- } catch {
1687
+ } catch (err) {
1688
+ // A missing default directory is normal (see above). Anything else, or
1689
+ // a directory the app named explicitly, means its functions would
1690
+ // silently not register, so say why.
1691
+ const missing = (err as NodeJS.ErrnoException)?.code === "ENOENT";
1692
+ if (!missing || opts.reportMissing) {
1693
+ console.error(`[functions] Cannot read ${dir}:`, err);
1694
+ }
1506
1695
  return [];
1507
1696
  }
1508
1697
  return files.map((file) => ({
1509
1698
  name: basename(file, file.endsWith(".ts") ? ".ts" : ".js"),
1510
1699
  file,
1511
- load: () => import(join(dir, file)),
1700
+ // A file URL: a bare Windows path ("D:\\app\\x.ts") is not a module
1701
+ // specifier everywhere.
1702
+ load: () => import(pathToFileURL(join(dir, file)).href),
1512
1703
  }));
1513
1704
  }
1514
1705
 
@@ -1519,6 +1710,9 @@ async function main() {
1519
1710
  fenceStdout();
1520
1711
 
1521
1712
  const fnDir = process.argv[2] || "./functions";
1713
+ // `resolve`, not `join`: an absolute PYLON_FUNCTIONS_DIR (including a
1714
+ // Windows drive path) must be used as is, not appended to the cwd.
1715
+ const fnDirIsDefault = fnDir === "functions" || fnDir === "./functions";
1522
1716
  const bundle = serverBundle();
1523
1717
 
1524
1718
  const fnSources = bundle
@@ -1527,7 +1721,9 @@ async function main() {
1527
1721
  file: `${name} (bundled)`,
1528
1722
  load: bundle.functions[name],
1529
1723
  }))
1530
- : listModuleFiles(join(process.cwd(), fnDir));
1724
+ : listModuleFiles(resolve(process.cwd(), fnDir), {
1725
+ reportMissing: !fnDirIsDefault,
1726
+ });
1531
1727
 
1532
1728
  const { isAgentDefinition, AGENT_MARKER } = await import("./agent");
1533
1729
  let agentsPresent = false;
@@ -1585,7 +1781,7 @@ async function main() {
1585
1781
  file: `${name} (bundled)`,
1586
1782
  load: bundle.workflows[name],
1587
1783
  }))
1588
- : listModuleFiles(join(process.cwd(), "workflows"));
1784
+ : listModuleFiles(resolve(process.cwd(), "workflows"));
1589
1785
  for (const { file, load } of wfSources) {
1590
1786
  try {
1591
1787
  const mod = await load();