@pylonsync/functions 0.4.17 → 0.4.19
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/define.d.ts +21 -11
- package/dist/index.d.ts +3 -1
- package/dist/runtime.d.ts +9 -1
- package/dist/types.d.ts +51 -0
- package/dist/workflows.d.ts +128 -0
- package/package.json +1 -1
- package/src/define.ts +21 -11
- package/src/index.ts +9 -0
- package/src/runtime-cancel.test.ts +137 -0
- package/src/runtime-workflows.test.ts +263 -0
- package/src/runtime.ts +179 -1
- package/src/types.ts +52 -0
- package/src/workflows.test.ts +199 -0
- package/src/workflows.ts +252 -0
package/dist/define.d.ts
CHANGED
|
@@ -41,18 +41,28 @@ interface CommonDef<TSchema extends ValidatorSchema | undefined = ValidatorSchem
|
|
|
41
41
|
*/
|
|
42
42
|
internal?: boolean;
|
|
43
43
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* event loop) isn't respawned out from under the work.
|
|
44
|
+
* IDLE-timeout SECONDS for this function: how long it may go without
|
|
45
|
+
* producing any activity (a stream chunk, a `ctx.db` op, an LLM
|
|
46
|
+
* event) before the host cancels the call. Defaults to
|
|
47
|
+
* `PYLON_FN_CALL_TIMEOUT` (30s). Activity restarts the budget, so a
|
|
48
|
+
* streaming agent run stays alive as long as it keeps producing; a
|
|
49
|
+
* silent hang is cancelled at the budget. Total lifetime is capped at
|
|
50
|
+
* 10× this value however chatty the call is.
|
|
52
51
|
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
52
|
+
* Cancellation is per-call: the handler's next `ctx.*` call throws
|
|
53
|
+
* `CALL_CANCELLED` and `ctx.signal` aborts — other in-flight calls on
|
|
54
|
+
* the same worker are untouched. CAVEAT: the database transaction
|
|
55
|
+
* rolls back on cancel, but non-ctx work already in flight (a `fetch`,
|
|
56
|
+
* a payment SDK call) runs to completion unless you pass
|
|
57
|
+
* `{ signal: ctx.signal }` — a cancelled call can otherwise leave an
|
|
58
|
+
* external effect committed with its DB writes rolled back. Thread
|
|
59
|
+
* ctx.signal into every external call that must not outlive the
|
|
60
|
+
* handler. Raise the timeout for legitimately long
|
|
61
|
+
* SILENT work (a single slow external call, synchronous CPU work);
|
|
62
|
+
* this also lifts the runtime's wedge backstop for the worker while
|
|
63
|
+
* such a call is in flight, so a busy-but-progressing worker (e.g.
|
|
64
|
+
* one doing synchronous canvas/image work that blocks the event loop)
|
|
65
|
+
* isn't respawned out from under the work.
|
|
56
66
|
*/
|
|
57
67
|
timeout?: number;
|
|
58
68
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
*/
|
|
20
20
|
export { query, mutation, action } from "./define";
|
|
21
21
|
export { v } from "./validators";
|
|
22
|
+
export { workflow } from "./workflows";
|
|
23
|
+
export type { WorkflowDefinition, WorkflowRun, WorkflowRunRequest, WorkflowRunnerResponse, WorkflowStepResult, } from "./workflows";
|
|
22
24
|
export { resetDb, installTestIsolation } from "./testing";
|
|
23
25
|
export { slugifyName, availableSlug } from "./slugify";
|
|
24
26
|
export type { SsrResponse, SsrCookieOptions, SsrMetadata, Sitemap, SitemapEntry, Robots, RobotsRule, } from "./ssr-runtime";
|
|
25
|
-
export type { QueryCtx, MutationCtx, ActionCtx, DbReader, DbWriter, Stream, Scheduler, AuthInfo, AuthMode, AuthRequirement, FnDefinition, Validator, AnyValidator, ValidatorSchema, InferValidator, InferArgs, RequireMember, RequireMemberOptions, MemberRow, Llm, LlmMessage, LlmContentBlock, LlmTool, LlmCompleteRequest, LlmCompleteResponse, LlmStreamEvent, Rooms, } from "./types";
|
|
27
|
+
export type { QueryCtx, MutationCtx, ActionCtx, DbReader, DbWriter, Stream, Scheduler, AuthInfo, AuthMode, AuthRequirement, FnDefinition, Validator, AnyValidator, ValidatorSchema, InferValidator, InferArgs, RequireMember, RequireMemberOptions, MemberRow, Workflows, Llm, LlmMessage, LlmContentBlock, LlmTool, LlmCompleteRequest, LlmCompleteResponse, LlmStreamEvent, Rooms, } from "./types";
|
package/dist/runtime.d.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* each ctx.db / ctx.scheduler / ctx.runMutation call), so the map never
|
|
15
15
|
* needs to queue multiple RPCs per call_id.
|
|
16
16
|
*/
|
|
17
|
-
import type { DbReader, DbWriter, Llm, Rooms } from "./types";
|
|
17
|
+
import type { DbReader, DbWriter, Llm, Rooms, Workflows } from "./types";
|
|
18
18
|
export declare function buildDbReader(callId: string, ssrRead?: boolean): DbReader;
|
|
19
19
|
export declare function buildSsrFnCaller(callId: string): (name: string, args?: Record<string, unknown>) => Promise<unknown>;
|
|
20
20
|
export declare function buildDbWriter(callId: string): DbWriter;
|
|
@@ -39,3 +39,11 @@ export declare function buildLlm(callId: string): Llm;
|
|
|
39
39
|
* on subscribers identically.
|
|
40
40
|
*/
|
|
41
41
|
export declare function buildRooms(callId: string): Rooms;
|
|
42
|
+
/**
|
|
43
|
+
* Build `ctx.workflows` — start a durable workflow or deliver an event
|
|
44
|
+
* to a waiting one, from mutation/action code. Round-trips a
|
|
45
|
+
* `workflow_op` frame to the host's WorkflowEngine; the engine's driver
|
|
46
|
+
* takes it from there, so `start` returns the instance id immediately.
|
|
47
|
+
* Uses the queued call_id-keyed `rpc` (the reply carries no op_id).
|
|
48
|
+
*/
|
|
49
|
+
export declare function buildWorkflows(callId: string): Workflows;
|
package/dist/types.d.ts
CHANGED
|
@@ -392,6 +392,29 @@ export interface Rooms {
|
|
|
392
392
|
delivered: boolean;
|
|
393
393
|
}>;
|
|
394
394
|
}
|
|
395
|
+
/**
|
|
396
|
+
* Durable workflows, driven from app code (`ctx.workflows` on mutations
|
|
397
|
+
* and actions — not queries, whose reactive re-runs would re-start).
|
|
398
|
+
* Workflows are declared in the app's `workflows/` directory; see the
|
|
399
|
+
* `workflow()` export.
|
|
400
|
+
*/
|
|
401
|
+
export interface Workflows {
|
|
402
|
+
/**
|
|
403
|
+
* Start a workflow instance by name. Returns immediately with the
|
|
404
|
+
* instance id — the engine's background driver executes the steps.
|
|
405
|
+
*/
|
|
406
|
+
start(name: string, input?: unknown): Promise<{
|
|
407
|
+
id: string;
|
|
408
|
+
}>;
|
|
409
|
+
/**
|
|
410
|
+
* Deliver an event to an instance paused on
|
|
411
|
+
* `wf.waitForEvent(event)`. Rejects when the instance isn't waiting
|
|
412
|
+
* for that event.
|
|
413
|
+
*/
|
|
414
|
+
sendEvent(workflowId: string, event: string, data?: unknown): Promise<{
|
|
415
|
+
delivered: boolean;
|
|
416
|
+
}>;
|
|
417
|
+
}
|
|
395
418
|
export interface LlmMessage {
|
|
396
419
|
role: "user" | "assistant" | "system" | "tool";
|
|
397
420
|
content: string | LlmContentBlock[];
|
|
@@ -607,6 +630,14 @@ export interface QueryCtx<R extends AuthRequirement = "optional"> {
|
|
|
607
630
|
requireMember: RequireMember;
|
|
608
631
|
/** Signed file-download URLs — see {@link Files}. */
|
|
609
632
|
files: Files;
|
|
633
|
+
/**
|
|
634
|
+
* Fires when the host cancels this call (idle timeout exceeded).
|
|
635
|
+
* Thread it into `fetch(url, { signal: ctx.signal })` or SDK calls so
|
|
636
|
+
* a cancelled call stops its outbound work too — the runtime already
|
|
637
|
+
* makes every later `ctx.*` call throw `CALL_CANCELLED`. Optional
|
|
638
|
+
* because older hosts don't send cancel frames.
|
|
639
|
+
*/
|
|
640
|
+
signal?: AbortSignal;
|
|
610
641
|
}
|
|
611
642
|
/** Context for mutation handlers (read + write, transactional). */
|
|
612
643
|
export interface MutationCtx<R extends AuthRequirement = "optional"> {
|
|
@@ -622,12 +653,22 @@ export interface MutationCtx<R extends AuthRequirement = "optional"> {
|
|
|
622
653
|
rooms: Rooms;
|
|
623
654
|
/** Per-user OAuth connection registry. */
|
|
624
655
|
connections: Connections;
|
|
656
|
+
/** Durable workflows: start / deliver events — see {@link Workflows}. */
|
|
657
|
+
workflows: Workflows;
|
|
625
658
|
/** Signed file-download URLs — see {@link Files}. */
|
|
626
659
|
files: Files;
|
|
627
660
|
/** Create a typed error that triggers rollback. */
|
|
628
661
|
error(code: string, message: string): Error;
|
|
629
662
|
/** Assert org membership (optionally a role) — see {@link RequireMember}. */
|
|
630
663
|
requireMember: RequireMember;
|
|
664
|
+
/**
|
|
665
|
+
* Fires when the host cancels this call (idle timeout exceeded).
|
|
666
|
+
* Thread it into `fetch(url, { signal: ctx.signal })` or SDK calls so
|
|
667
|
+
* a cancelled call stops its outbound work too — the runtime already
|
|
668
|
+
* makes every later `ctx.*` call throw `CALL_CANCELLED`. Optional
|
|
669
|
+
* because older hosts don't send cancel frames.
|
|
670
|
+
*/
|
|
671
|
+
signal?: AbortSignal;
|
|
631
672
|
}
|
|
632
673
|
/** Context for action handlers (external I/O, non-transactional). */
|
|
633
674
|
export interface ActionCtx<R extends AuthRequirement = "optional"> {
|
|
@@ -642,6 +683,8 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
|
|
|
642
683
|
rooms: Rooms;
|
|
643
684
|
/** Per-user OAuth connection registry. */
|
|
644
685
|
connections: Connections;
|
|
686
|
+
/** Durable workflows: start / deliver events — see {@link Workflows}. */
|
|
687
|
+
workflows: Workflows;
|
|
645
688
|
/** Environment variables / secrets. */
|
|
646
689
|
env: Record<string, string>;
|
|
647
690
|
/** Signed file-download URLs — see {@link Files}. */
|
|
@@ -654,6 +697,14 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
|
|
|
654
697
|
error(code: string, message: string): Error;
|
|
655
698
|
/** Assert org membership (optionally a role) — see {@link RequireMember}. */
|
|
656
699
|
requireMember: RequireMember;
|
|
700
|
+
/**
|
|
701
|
+
* Fires when the host cancels this call (idle timeout exceeded).
|
|
702
|
+
* Thread it into `fetch(url, { signal: ctx.signal })` or SDK calls so
|
|
703
|
+
* a cancelled call stops its outbound work too — the runtime already
|
|
704
|
+
* makes every later `ctx.*` call throw `CALL_CANCELLED`. Optional
|
|
705
|
+
* because older hosts don't send cancel frames.
|
|
706
|
+
*/
|
|
707
|
+
signal?: AbortSignal;
|
|
657
708
|
/**
|
|
658
709
|
* HTTP request metadata — present only when the action was invoked via
|
|
659
710
|
* a `defineRoute` HTTP binding. Missing when the action is called from
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable workflows: authoring surface + the per-slice executor.
|
|
3
|
+
*
|
|
4
|
+
* A workflow is a TS function that composes `step` / `sleep` /
|
|
5
|
+
* `waitForEvent` calls. The Rust WorkflowEngine owns the durable state
|
|
6
|
+
* (instance, step results, wake timers — persisted in SQLite); this
|
|
7
|
+
* module executes ONE slice at a time by REPLAY: on every advance the
|
|
8
|
+
* whole workflow function re-runs, already-completed steps return their
|
|
9
|
+
* recorded outputs without executing, and the first not-yet-done
|
|
10
|
+
* primitive either executes (a `step` at the current index) or pauses
|
|
11
|
+
* the run (`sleep` / `waitForEvent`).
|
|
12
|
+
*
|
|
13
|
+
* Determinism contract for authors: the sequence of step/sleep/
|
|
14
|
+
* waitForEvent calls must be identical on every replay for the same
|
|
15
|
+
* input + recorded outputs. Branch on `input` and on step OUTPUTS all
|
|
16
|
+
* you like — never on wall-clock time, randomness, or external state
|
|
17
|
+
* read outside a step.
|
|
18
|
+
*
|
|
19
|
+
* Files live in the app's `workflows/` directory (sibling of
|
|
20
|
+
* `functions/`), one default-exported `workflow(...)` per file. The
|
|
21
|
+
* runtime reports them in its ready handshake; the host registers them
|
|
22
|
+
* and drives execution back through the function pool as an internal
|
|
23
|
+
* `__pylon_workflow_run` action call — so step code runs with a full
|
|
24
|
+
* ActionCtx (ctx.db, ctx.llm, ctx.scheduler, the idle timeout, and
|
|
25
|
+
* cancellation) rather than in a bespoke side-channel process.
|
|
26
|
+
*/
|
|
27
|
+
import type { ActionCtx } from "./types";
|
|
28
|
+
/** Wire shape of one recorded step — mirrors Rust `StepResult` exactly. */
|
|
29
|
+
export interface WorkflowStepResult {
|
|
30
|
+
step_id: string;
|
|
31
|
+
name: string;
|
|
32
|
+
status: "pending" | "running" | "completed" | "failed" | "skipped";
|
|
33
|
+
output?: unknown;
|
|
34
|
+
error?: string | null;
|
|
35
|
+
started_at?: string | null;
|
|
36
|
+
completed_at?: string | null;
|
|
37
|
+
duration_ms?: number | null;
|
|
38
|
+
retry_count?: number;
|
|
39
|
+
}
|
|
40
|
+
/** The advance request the Rust engine sends for one slice. */
|
|
41
|
+
export interface WorkflowRunRequest {
|
|
42
|
+
workflow_id: string;
|
|
43
|
+
workflow_name: string;
|
|
44
|
+
input: unknown;
|
|
45
|
+
current_step: number;
|
|
46
|
+
completed_steps: WorkflowStepResult[];
|
|
47
|
+
}
|
|
48
|
+
/** The verdict of one slice — mirrors Rust `apply_response`'s actions. */
|
|
49
|
+
export type WorkflowRunnerResponse = {
|
|
50
|
+
action: "step_complete";
|
|
51
|
+
step_name: string;
|
|
52
|
+
output: unknown;
|
|
53
|
+
duration_ms: number;
|
|
54
|
+
} | {
|
|
55
|
+
action: "sleep";
|
|
56
|
+
duration: string;
|
|
57
|
+
} | {
|
|
58
|
+
action: "wait_event";
|
|
59
|
+
event: string;
|
|
60
|
+
} | {
|
|
61
|
+
action: "complete";
|
|
62
|
+
output: unknown;
|
|
63
|
+
} | {
|
|
64
|
+
action: "fail";
|
|
65
|
+
error: string;
|
|
66
|
+
step_name?: string;
|
|
67
|
+
};
|
|
68
|
+
/** What a workflow function receives, besides the per-slice ActionCtx. */
|
|
69
|
+
export interface WorkflowRun<TInput = unknown> {
|
|
70
|
+
/** The workflow instance id (stable across slices + restarts). */
|
|
71
|
+
id: string;
|
|
72
|
+
/** The input `start()` was called with. */
|
|
73
|
+
input: TInput;
|
|
74
|
+
/**
|
|
75
|
+
* Run a named step exactly once. On replay a completed step returns
|
|
76
|
+
* its recorded output without executing. Step names must be unique
|
|
77
|
+
* within one workflow run — the replay cache is name-keyed.
|
|
78
|
+
*/
|
|
79
|
+
step<T>(name: string, fn: () => Promise<T> | T): Promise<T>;
|
|
80
|
+
/** Pause the workflow for a duration ("30s", "5m", "24h", "7d"). */
|
|
81
|
+
sleep(duration: string): Promise<void>;
|
|
82
|
+
/**
|
|
83
|
+
* Pause until `POST /api/workflows/<id>/event` delivers this event.
|
|
84
|
+
* Resolves with the event's data payload.
|
|
85
|
+
*/
|
|
86
|
+
waitForEvent<T = unknown>(eventName: string): Promise<T>;
|
|
87
|
+
}
|
|
88
|
+
export interface WorkflowDefinition<TInput = unknown> {
|
|
89
|
+
readonly __pylonWorkflow: true;
|
|
90
|
+
name: string;
|
|
91
|
+
description?: string;
|
|
92
|
+
/** Max retries per step before the workflow fails (engine default: 3). */
|
|
93
|
+
maxRetries?: number;
|
|
94
|
+
fn: (wf: WorkflowRun<TInput>, ctx: ActionCtx) => Promise<unknown>;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Declare a workflow. Default-export the result from a file in the
|
|
98
|
+
* app's `workflows/` directory:
|
|
99
|
+
*
|
|
100
|
+
* ```ts
|
|
101
|
+
* // workflows/onboarding.ts
|
|
102
|
+
* import { workflow } from "@pylonsync/functions";
|
|
103
|
+
*
|
|
104
|
+
* export default workflow("onboarding", async (wf, ctx) => {
|
|
105
|
+
* const user = await wf.step("load-user", () =>
|
|
106
|
+
* ctx.runQuery("getUser", { id: wf.input.userId }),
|
|
107
|
+
* );
|
|
108
|
+
* await wf.step("send-welcome", () =>
|
|
109
|
+
* ctx.email.send({ to: user.email, subject: "Welcome!", text: "..." }),
|
|
110
|
+
* );
|
|
111
|
+
* await wf.sleep("24h");
|
|
112
|
+
* const confirmed = await wf.waitForEvent("email_confirmed");
|
|
113
|
+
* return { done: true, confirmed };
|
|
114
|
+
* });
|
|
115
|
+
* ```
|
|
116
|
+
*/
|
|
117
|
+
export declare function workflow<TInput = unknown>(name: string, fn: (wf: WorkflowRun<TInput>, ctx: ActionCtx) => Promise<unknown>, opts?: {
|
|
118
|
+
description?: string;
|
|
119
|
+
maxRetries?: number;
|
|
120
|
+
}): WorkflowDefinition<TInput>;
|
|
121
|
+
/** Runtime shape check for a `workflows/` file's default export. */
|
|
122
|
+
export declare function isWorkflowDefinition(v: unknown): v is WorkflowDefinition;
|
|
123
|
+
/**
|
|
124
|
+
* Execute one slice of `def` for `request`, returning the engine verdict.
|
|
125
|
+
* Never throws for handler errors — a step failure becomes
|
|
126
|
+
* `{action: "fail"}` so the engine's retry accounting runs.
|
|
127
|
+
*/
|
|
128
|
+
export declare function executeWorkflowSlice(def: WorkflowDefinition, request: WorkflowRunRequest, ctx: ActionCtx): Promise<WorkflowRunnerResponse>;
|
package/package.json
CHANGED
package/src/define.ts
CHANGED
|
@@ -56,18 +56,28 @@ interface CommonDef<
|
|
|
56
56
|
*/
|
|
57
57
|
internal?: boolean;
|
|
58
58
|
/**
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* event loop) isn't respawned out from under the work.
|
|
59
|
+
* IDLE-timeout SECONDS for this function: how long it may go without
|
|
60
|
+
* producing any activity (a stream chunk, a `ctx.db` op, an LLM
|
|
61
|
+
* event) before the host cancels the call. Defaults to
|
|
62
|
+
* `PYLON_FN_CALL_TIMEOUT` (30s). Activity restarts the budget, so a
|
|
63
|
+
* streaming agent run stays alive as long as it keeps producing; a
|
|
64
|
+
* silent hang is cancelled at the budget. Total lifetime is capped at
|
|
65
|
+
* 10× this value however chatty the call is.
|
|
67
66
|
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
67
|
+
* Cancellation is per-call: the handler's next `ctx.*` call throws
|
|
68
|
+
* `CALL_CANCELLED` and `ctx.signal` aborts — other in-flight calls on
|
|
69
|
+
* the same worker are untouched. CAVEAT: the database transaction
|
|
70
|
+
* rolls back on cancel, but non-ctx work already in flight (a `fetch`,
|
|
71
|
+
* a payment SDK call) runs to completion unless you pass
|
|
72
|
+
* `{ signal: ctx.signal }` — a cancelled call can otherwise leave an
|
|
73
|
+
* external effect committed with its DB writes rolled back. Thread
|
|
74
|
+
* ctx.signal into every external call that must not outlive the
|
|
75
|
+
* handler. Raise the timeout for legitimately long
|
|
76
|
+
* SILENT work (a single slow external call, synchronous CPU work);
|
|
77
|
+
* this also lifts the runtime's wedge backstop for the worker while
|
|
78
|
+
* such a call is in flight, so a busy-but-progressing worker (e.g.
|
|
79
|
+
* one doing synchronous canvas/image work that blocks the event loop)
|
|
80
|
+
* isn't respawned out from under the work.
|
|
71
81
|
*/
|
|
72
82
|
timeout?: number;
|
|
73
83
|
}
|
package/src/index.ts
CHANGED
|
@@ -20,6 +20,14 @@
|
|
|
20
20
|
|
|
21
21
|
export { query, mutation, action } from "./define";
|
|
22
22
|
export { v } from "./validators";
|
|
23
|
+
export { workflow } from "./workflows";
|
|
24
|
+
export type {
|
|
25
|
+
WorkflowDefinition,
|
|
26
|
+
WorkflowRun,
|
|
27
|
+
WorkflowRunRequest,
|
|
28
|
+
WorkflowRunnerResponse,
|
|
29
|
+
WorkflowStepResult,
|
|
30
|
+
} from "./workflows";
|
|
23
31
|
export { resetDb, installTestIsolation } from "./testing";
|
|
24
32
|
export { slugifyName, availableSlug } from "./slugify";
|
|
25
33
|
// SSR page response controller — pages/layouts receive `response` in
|
|
@@ -54,6 +62,7 @@ export type {
|
|
|
54
62
|
RequireMember,
|
|
55
63
|
RequireMemberOptions,
|
|
56
64
|
MemberRow,
|
|
65
|
+
Workflows,
|
|
57
66
|
// LLM + realtime surfaces. A handler writing an agent tool loop
|
|
58
67
|
// builds its own message array and branches on stream events, so
|
|
59
68
|
// these have to be nameable from app code.
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for host-initiated call cancellation (runtime.ts `cancel` frames).
|
|
3
|
+
*
|
|
4
|
+
* The contract under test:
|
|
5
|
+
*
|
|
6
|
+
* 1. A `cancel` frame for a call REJECTS that call's in-flight RPC
|
|
7
|
+
* promises with code CALL_CANCELLED (the handler's `await ctx.db.*`
|
|
8
|
+
* unwinds instead of hanging on a reply that will never come).
|
|
9
|
+
* 2. Any LATER ctx RPC from the cancelled call throws CALL_CANCELLED —
|
|
10
|
+
* this is what actually stops a typical handler mid-loop.
|
|
11
|
+
* 3. A different call on the same runner is untouched: its RPCs still
|
|
12
|
+
* round-trip normally. This is the whole point of the change — the
|
|
13
|
+
* host used to kill the entire child on one call's timeout.
|
|
14
|
+
*
|
|
15
|
+
* Same child-process harness as runtime-llm-stream.test.ts: runtime.ts
|
|
16
|
+
* runs main() on import, so we drive the REAL dispatcher over NDJSON on
|
|
17
|
+
* stdin/stdout exactly as the Rust host would.
|
|
18
|
+
*/
|
|
19
|
+
import { expect, test } from "bun:test";
|
|
20
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
|
|
24
|
+
const RUNTIME = join(import.meta.dir, "runtime.ts");
|
|
25
|
+
|
|
26
|
+
const SCRIPT = `
|
|
27
|
+
import { buildDbReader } from ${JSON.stringify(RUNTIME)};
|
|
28
|
+
|
|
29
|
+
const victim = buildDbReader("c_1");
|
|
30
|
+
const bystander = buildDbReader("c_2");
|
|
31
|
+
|
|
32
|
+
const out = {};
|
|
33
|
+
|
|
34
|
+
// In-flight RPC on the call the host will cancel.
|
|
35
|
+
const victimFirst = victim.get("User", "u1").then(
|
|
36
|
+
() => ({ outcome: "resolved" }),
|
|
37
|
+
(e) => ({ outcome: "rejected", code: e.code }),
|
|
38
|
+
);
|
|
39
|
+
// Concurrent RPC on an UNRELATED call — must complete normally.
|
|
40
|
+
const bystanderGet = bystander.get("User", "u2").then(
|
|
41
|
+
(data) => ({ outcome: "resolved", data }),
|
|
42
|
+
(e) => ({ outcome: "rejected", code: e.code }),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
out.victimFirst = await victimFirst;
|
|
46
|
+
out.bystander = await bystanderGet;
|
|
47
|
+
|
|
48
|
+
// A LATER RPC from the cancelled call must throw synchronously.
|
|
49
|
+
try {
|
|
50
|
+
await victim.get("User", "u3");
|
|
51
|
+
out.victimSecond = { outcome: "resolved" };
|
|
52
|
+
} catch (e) {
|
|
53
|
+
out.victimSecond = { outcome: "rejected", code: e.code };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
console.error("RESULT " + JSON.stringify(out));
|
|
57
|
+
process.exit(0);
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
function parseFrames(text: string): Record<string, unknown>[] {
|
|
61
|
+
return text
|
|
62
|
+
.split("\n")
|
|
63
|
+
.filter((l) => l.trim().startsWith("{"))
|
|
64
|
+
.map((l) => JSON.parse(l) as Record<string, unknown>);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
test("cancel rejects the call's RPCs, poisons later ones, and spares co-tenant calls", async () => {
|
|
68
|
+
const dir = mkdtempSync(join(tmpdir(), "pylon-fn-cancel-"));
|
|
69
|
+
const scriptPath = join(dir, "probe.ts");
|
|
70
|
+
writeFileSync(scriptPath, SCRIPT);
|
|
71
|
+
|
|
72
|
+
const proc = Bun.spawn([process.execPath, scriptPath], {
|
|
73
|
+
stdin: "pipe",
|
|
74
|
+
stdout: "pipe",
|
|
75
|
+
stderr: "pipe",
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Wait for both db frames (c_1's and c_2's) so we know their op_ids.
|
|
79
|
+
const reader = proc.stdout.getReader();
|
|
80
|
+
const decoder = new TextDecoder();
|
|
81
|
+
let buffered = "";
|
|
82
|
+
let requests: Record<string, unknown>[] = [];
|
|
83
|
+
const wanted = (fs: Record<string, unknown>[]) =>
|
|
84
|
+
fs.filter((f) => f.type === "db").length >= 2;
|
|
85
|
+
while (!wanted(requests)) {
|
|
86
|
+
const { done, value } = await reader.read();
|
|
87
|
+
if (done) break;
|
|
88
|
+
buffered += decoder.decode(value, { stream: true });
|
|
89
|
+
requests = parseFrames(buffered);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const dbFrames = requests.filter((f) => f.type === "db");
|
|
93
|
+
const victimOp = dbFrames.find((f) => f.call_id === "c_1")?.op_id as string;
|
|
94
|
+
const bystanderOp = dbFrames.find((f) => f.call_id === "c_2")
|
|
95
|
+
?.op_id as string;
|
|
96
|
+
expect(victimOp).toBeTruthy();
|
|
97
|
+
expect(bystanderOp).toBeTruthy();
|
|
98
|
+
|
|
99
|
+
const send = (msg: Record<string, unknown>) =>
|
|
100
|
+
proc.stdin.write(JSON.stringify(msg) + "\n");
|
|
101
|
+
|
|
102
|
+
// Cancel c_1 while its RPC is in flight; answer c_2 normally.
|
|
103
|
+
send({ type: "cancel", call_id: "c_1", reason: "idle timeout 30s exceeded" });
|
|
104
|
+
send({
|
|
105
|
+
type: "result",
|
|
106
|
+
call_id: "c_2",
|
|
107
|
+
op_id: bystanderOp,
|
|
108
|
+
data: { id: "u2", name: "Bystander" },
|
|
109
|
+
});
|
|
110
|
+
await proc.stdin.flush();
|
|
111
|
+
|
|
112
|
+
const stderr = await new Response(proc.stderr).text();
|
|
113
|
+
await proc.exited;
|
|
114
|
+
|
|
115
|
+
const line = stderr.split("\n").find((l) => l.includes("RESULT "));
|
|
116
|
+
expect(line).toBeDefined();
|
|
117
|
+
const out = JSON.parse(line!.slice(line!.indexOf("RESULT ") + 7));
|
|
118
|
+
|
|
119
|
+
// 1. The in-flight RPC rejected with the cancellation code — no hang,
|
|
120
|
+
// no generic RPC-timeout 60s later.
|
|
121
|
+
expect(out.victimFirst).toEqual({
|
|
122
|
+
outcome: "rejected",
|
|
123
|
+
code: "CALL_CANCELLED",
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// 2. The later RPC threw immediately with the same code.
|
|
127
|
+
expect(out.victimSecond).toEqual({
|
|
128
|
+
outcome: "rejected",
|
|
129
|
+
code: "CALL_CANCELLED",
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// 3. The co-tenant call was untouched.
|
|
133
|
+
expect(out.bystander).toEqual({
|
|
134
|
+
outcome: "resolved",
|
|
135
|
+
data: { id: "u2", name: "Bystander" },
|
|
136
|
+
});
|
|
137
|
+
});
|