@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/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
|
+
});
|
package/src/response.ts
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
1784
|
+
: listModuleFiles(resolve(process.cwd(), "workflows"));
|
|
1589
1785
|
for (const { file, load } of wfSources) {
|
|
1590
1786
|
try {
|
|
1591
1787
|
const mod = await load();
|