@cellaflow/sdk 0.7.0 → 0.7.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,68 @@
1
+ # Changelog
2
+
3
+ All notable changes to the CellaFlow TypeScript SDK.
4
+
5
+ ## 0.7.1
6
+
7
+ ### Added
8
+
9
+ - **The high-level API.** `0.7.0` shipped the transport only: `CellaflowClient` and
10
+ the generated types. Everything above it now exists, matching the Python SDK's
11
+ surface.
12
+
13
+ - `tool` / `step` — wraps a function so the engine runs it at most once per
14
+ idempotency key, and so its result survives the process that produced it. A
15
+ second caller deriving the same key receives what the first returned, even if
16
+ that process has since died.
17
+ - `durableTools` — opens the session a leased tool belongs to, from a LangGraph
18
+ config or a bare thread id. Callback-based rather than a context manager.
19
+ - `executionLease` / `taskLease` — a distributed lock with liveness
20
+ heartbeating, so a holder that dies stops renewing and another worker takes
21
+ over once the TTL elapses.
22
+ - `currentLease`, `LeaseHandle`, `LeaseNotAcquired`, `LeaseLostError`.
23
+ - `deriveIdempotencyKey`, `hashInputs`, `IdempotencyScope`, `toolSessionId`,
24
+ `WorkflowContext`, `DivergentStepError`.
25
+
26
+ - **Cross-language coordination.** `IdempotencyScope.SHARED` now works between
27
+ runtimes. Key derivation is byte-identical to the Python SDK: RFC 8785 canonical
28
+ JSON, SHA-256, first 16 bytes. Verified against Python reference hashes covering
29
+ unicode, escapes, nested objects, key reordering and exponent-form numbers, and
30
+ end to end against a running engine, where a Python agent and a TypeScript
31
+ agent proposing different amounts for the same refund converge on one.
32
+
33
+ This was not possible on `0.7.0`: without exported key derivation, a TypeScript
34
+ caller could not reach the same key without reimplementing the canonicalisation
35
+ by hand and getting it exactly right.
36
+
37
+ ### Notes
38
+
39
+ - Purely additive. Nothing was removed or changed, so `^0.7.0` picks this up and
40
+ existing code continues to work untouched.
41
+
42
+ - **Two places this cannot match the Python SDK**, documented in the API rather
43
+ than silently approximated:
44
+
45
+ - Python's `async_execution_lease` cancels the calling task when the lease is
46
+ lost. Node has no task cancellation, so `LeaseHandle` exposes `check()` and
47
+ an `AbortSignal` and losing a lease is cooperative. Anything irreversible
48
+ inside a long block should check first.
49
+ - Python binds `*args`/`**kwargs` against the function signature so `shared_on`
50
+ can select named parameters. JavaScript has no named arguments, so `sharedOn`
51
+ selects keys from a single object argument, and a tool using it must take one.
52
+
53
+ - **Not included:** the LangGraph checkpointer. It needs `@langchain/langgraph`
54
+ as a peer dependency, and the Python implementation has a known defect under
55
+ concurrent read-modify-write that should be fixed before it is copied into a
56
+ second language.
57
+
58
+ - The Python and TypeScript SDKs will be aligned at `0.8.0` on the next release.
59
+ Until then, matching version numbers do not imply matching feature sets:
60
+ Python `0.7.0` had the high-level API and TypeScript `0.7.0` did not.
61
+
62
+ ## 0.7.0
63
+
64
+ ### Added
65
+
66
+ - Initial TypeScript/Node.js SDK. `CellaflowClient` over Connect RPC, covering
67
+ `startSession`, `commitStep`, `getGraph`, `checkIdempotencyCache`, `renewLease`
68
+ and `releaseLease`, with MessagePack serialization for state payloads.
package/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # @cellaflow/sdk
2
+
3
+ The official TypeScript/Node.js SDK for the Cellaflow workflow engine.
4
+
5
+ Cellaflow is the concurrency and durability layer for AI agent execution. It makes concurrent AI agent state transitions and external actions safe despite crashes, retries, and stale state.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install @cellaflow/sdk
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```typescript
16
+ import { CellaflowClient, StepStatus } from "@cellaflow/sdk";
17
+
18
+ // 1. Initialize the client
19
+ const client = new CellaflowClient({
20
+ target: "localhost:50051",
21
+ secure: false, // Set to true if your engine is behind HTTPS/TLS
22
+ });
23
+
24
+ async function main() {
25
+ // 2. Start a workflow session
26
+ const session = await client.startSession("my-agent-workflow", "1.0");
27
+ const sessionId = session.sessionId;
28
+
29
+ console.log("Started session:", sessionId);
30
+
31
+ // 3. Commit a step to the session
32
+ const result = await client.commitStep(
33
+ sessionId,
34
+ 1, // Step sequence
35
+ "fetch_data", // Step name
36
+ StepStatus.COMPLETED, // Outcome
37
+ { data: "example" } // Payload (auto-serialized via MessagePack)
38
+ );
39
+
40
+ console.log("Step committed.");
41
+
42
+ // 4. Retrieve the session graph
43
+ const [graph, nextCursor] = await client.getGraph(sessionId);
44
+ console.log("Graph:", graph);
45
+ }
46
+
47
+ main().catch(console.error);
48
+ ```
49
+
50
+ ## Features
51
+
52
+ - **Built on Connect-ES**: Uses HTTP/2 for high performance and strict gRPC semantics.
53
+ - **Safe Serialization**: Strictly uses MessagePack for state payloads to mitigate Remote Code Execution (RCE) risks associated with unvalidated JSON parsing.
54
+ - **Idempotency Locks**: Built-in methods for leasing and fencing distributed locks (`checkIdempotencyCache`, `renewLease`, `releaseLease`).
55
+
56
+ ## Requirements
57
+
58
+ - Node.js 18.0.0 or higher.
59
+
60
+ ## License
61
+
62
+ Apache 2.0
@@ -0,0 +1,71 @@
1
+ import type { CellaflowClient } from "./client.js";
2
+ /**
3
+ * The session a tool call belongs to, plus the bookkeeping that keeps the local
4
+ * sequence counter aligned with the engine's.
5
+ */
6
+ export declare class WorkflowContext {
7
+ readonly client: CellaflowClient;
8
+ readonly sessionId: string;
9
+ readonly workflowVersion: string;
10
+ sequence: number;
11
+ /**
12
+ * Names the work several agents are collaborating on: a ticket, a task, a
13
+ * tenant. Only {@link IdempotencyScope.SHARED} reads it, and that scope
14
+ * requires it.
15
+ */
16
+ readonly coordinationId?: string;
17
+ /**
18
+ * The session position the engine last reported, held from a cache hit until
19
+ * the next step consumes it. See {@link reconcileSequence}.
20
+ */
21
+ private reportedSequence?;
22
+ constructor(init: {
23
+ client: CellaflowClient;
24
+ sessionId: string;
25
+ workflowVersion: string;
26
+ sequence?: number;
27
+ coordinationId?: string;
28
+ });
29
+ /**
30
+ * Notes the session position the engine reported alongside a cache hit.
31
+ *
32
+ * Held rather than applied immediately: a run whose last act is a shared tool,
33
+ * which is the common shape, should not pay for bookkeeping it will never use.
34
+ * {@link reconcileSequence} consumes it at the start of the next step.
35
+ */
36
+ recordEngineSequence(sequence: number): void;
37
+ /**
38
+ * Adopts the position the engine reported on the last cache hit.
39
+ *
40
+ * Every tool call increments this counter, but a cache hit returns *without
41
+ * committing*. The engine's sequence therefore did not advance while the local
42
+ * one did, and the next commit fails the ordering check one step after the
43
+ * real cause.
44
+ *
45
+ * The same-session case survives on a coincidence rather than an invariant: a
46
+ * peer's commit advances the engine by exactly the amount this caller advanced
47
+ * locally, so the two happen to stay equal. Any asymmetry breaks it, such as a
48
+ * hit satisfied from a *different* session, or replicas that reached a shared
49
+ * tool after different numbers of steps.
50
+ *
51
+ * A no-op when the engine reported nothing, which is an older engine predating
52
+ * the field. Behaviour then degrades to the original defect rather than to
53
+ * something new.
54
+ */
55
+ reconcileSequence(): void;
56
+ }
57
+ export declare function registerSession(ctx: WorkflowContext): void;
58
+ export declare function deregisterSession(ctx: WorkflowContext): void;
59
+ /** Runs `fn` with `ctx` as the active context. */
60
+ export declare function runWithContext<T>(ctx: WorkflowContext, fn: () => T): T;
61
+ /**
62
+ * Returns the context a tool call belongs to.
63
+ *
64
+ * Falls back to the open-session registry when the async context was lost, but
65
+ * only when a single session is open. Two open sessions and a lost context is
66
+ * ambiguous, and guessing would attribute a side effect to the wrong run.
67
+ */
68
+ export declare function getContext(): WorkflowContext;
69
+ /** Whether a context is currently reachable, without throwing. */
70
+ export declare function hasContext(): boolean;
71
+ //# sourceMappingURL=context.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAEnD;;;GAGG;AACH,qBAAa,eAAe;IAC1B,QAAQ,CAAC,MAAM,EAAE,eAAe,CAAC;IACjC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IAEjC;;;OAGG;IACH,OAAO,CAAC,gBAAgB,CAAC,CAAS;IAElC,YAAY,IAAI,EAAE;QAChB,MAAM,EAAE,eAAe,CAAC;QACxB,SAAS,EAAE,MAAM,CAAC;QAClB,eAAe,EAAE,MAAM,CAAC;QACxB,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,cAAc,CAAC,EAAE,MAAM,CAAC;KACzB,EAMA;IAED;;;;;;OAMG;IACH,oBAAoB,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAE3C;IAED;;;;;;;;;;;;;;;;;OAiBG;IACH,iBAAiB,IAAI,IAAI,CAKxB;CACF;AAgBD,wBAAgB,eAAe,CAAC,GAAG,EAAE,eAAe,GAAG,IAAI,CAE1D;AAED,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,eAAe,GAAG,IAAI,CAE5D;AAED,kDAAkD;AAClD,wBAAgB,cAAc,CAAC,CAAC,EAAE,GAAG,EAAE,eAAe,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,CAEtE;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,IAAI,eAAe,CAqB5C;AAED,kEAAkE;AAClE,wBAAgB,UAAU,IAAI,OAAO,CAEpC"}
@@ -0,0 +1,113 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ /**
3
+ * The session a tool call belongs to, plus the bookkeeping that keeps the local
4
+ * sequence counter aligned with the engine's.
5
+ */
6
+ export class WorkflowContext {
7
+ client;
8
+ sessionId;
9
+ workflowVersion;
10
+ sequence;
11
+ /**
12
+ * Names the work several agents are collaborating on: a ticket, a task, a
13
+ * tenant. Only {@link IdempotencyScope.SHARED} reads it, and that scope
14
+ * requires it.
15
+ */
16
+ coordinationId;
17
+ /**
18
+ * The session position the engine last reported, held from a cache hit until
19
+ * the next step consumes it. See {@link reconcileSequence}.
20
+ */
21
+ reportedSequence;
22
+ constructor(init) {
23
+ this.client = init.client;
24
+ this.sessionId = init.sessionId;
25
+ this.workflowVersion = init.workflowVersion;
26
+ this.sequence = init.sequence ?? 0;
27
+ this.coordinationId = init.coordinationId;
28
+ }
29
+ /**
30
+ * Notes the session position the engine reported alongside a cache hit.
31
+ *
32
+ * Held rather than applied immediately: a run whose last act is a shared tool,
33
+ * which is the common shape, should not pay for bookkeeping it will never use.
34
+ * {@link reconcileSequence} consumes it at the start of the next step.
35
+ */
36
+ recordEngineSequence(sequence) {
37
+ this.reportedSequence = sequence;
38
+ }
39
+ /**
40
+ * Adopts the position the engine reported on the last cache hit.
41
+ *
42
+ * Every tool call increments this counter, but a cache hit returns *without
43
+ * committing*. The engine's sequence therefore did not advance while the local
44
+ * one did, and the next commit fails the ordering check one step after the
45
+ * real cause.
46
+ *
47
+ * The same-session case survives on a coincidence rather than an invariant: a
48
+ * peer's commit advances the engine by exactly the amount this caller advanced
49
+ * locally, so the two happen to stay equal. Any asymmetry breaks it, such as a
50
+ * hit satisfied from a *different* session, or replicas that reached a shared
51
+ * tool after different numbers of steps.
52
+ *
53
+ * A no-op when the engine reported nothing, which is an older engine predating
54
+ * the field. Behaviour then degrades to the original defect rather than to
55
+ * something new.
56
+ */
57
+ reconcileSequence() {
58
+ if (this.reportedSequence !== undefined) {
59
+ this.sequence = this.reportedSequence;
60
+ this.reportedSequence = undefined;
61
+ }
62
+ }
63
+ }
64
+ const storage = new AsyncLocalStorage();
65
+ /**
66
+ * Sessions currently open, for frameworks that dispatch a tool off the calling
67
+ * context.
68
+ *
69
+ * `AsyncLocalStorage` follows `await` and `setTimeout`, which covers most
70
+ * frameworks. It does not follow a hop through a worker thread or a native
71
+ * callback that loses the async resource. Where exactly one session is open the
72
+ * context is recoverable from here; where several are, there is nothing to
73
+ * disambiguate them and the caller must bind explicitly.
74
+ */
75
+ const openSessions = new Set();
76
+ export function registerSession(ctx) {
77
+ openSessions.add(ctx);
78
+ }
79
+ export function deregisterSession(ctx) {
80
+ openSessions.delete(ctx);
81
+ }
82
+ /** Runs `fn` with `ctx` as the active context. */
83
+ export function runWithContext(ctx, fn) {
84
+ return storage.run(ctx, fn);
85
+ }
86
+ /**
87
+ * Returns the context a tool call belongs to.
88
+ *
89
+ * Falls back to the open-session registry when the async context was lost, but
90
+ * only when a single session is open. Two open sessions and a lost context is
91
+ * ambiguous, and guessing would attribute a side effect to the wrong run.
92
+ */
93
+ export function getContext() {
94
+ const ctx = storage.getStore();
95
+ if (ctx)
96
+ return ctx;
97
+ if (openSessions.size === 1) {
98
+ return openSessions.values().next().value;
99
+ }
100
+ if (openSessions.size === 0) {
101
+ throw new Error("No CellaFlow session is active. A leased tool must be called inside " +
102
+ "durableTools(...), which is what binds it to a session. Without one " +
103
+ "there is no idempotency key to derive and nothing to lease.");
104
+ }
105
+ throw new Error(`The calling context was lost and ${openSessions.size} sessions are open, so ` +
106
+ "the session this tool belongs to is ambiguous. Bind it explicitly with " +
107
+ "session.bind(() => ...) inside the tool, or open one session at a time.");
108
+ }
109
+ /** Whether a context is currently reachable, without throwing. */
110
+ export function hasContext() {
111
+ return storage.getStore() !== undefined || openSessions.size === 1;
112
+ }
113
+ //# sourceMappingURL=context.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context.js","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAGrD;;;GAGG;AACH,MAAM,OAAO,eAAe;IACjB,MAAM,CAAkB;IACxB,SAAS,CAAS;IAClB,eAAe,CAAS;IACjC,QAAQ,CAAS;IACjB;;;;OAIG;IACM,cAAc,CAAU;IAEjC;;;OAGG;IACK,gBAAgB,CAAU;IAElC,YAAY,IAMX;QACC,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC;QAC1B,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,SAAS,CAAC;QAChC,IAAI,CAAC,eAAe,GAAG,IAAI,CAAC,eAAe,CAAC;QAC5C,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC,QAAQ,IAAI,CAAC,CAAC;QACnC,IAAI,CAAC,cAAc,GAAG,IAAI,CAAC,cAAc,CAAC;IAC5C,CAAC;IAED;;;;;;OAMG;IACH,oBAAoB,CAAC,QAAgB;QACnC,IAAI,CAAC,gBAAgB,GAAG,QAAQ,CAAC;IACnC,CAAC;IAED;;;;;;;;;;;;;;;;;OAiBG;IACH,iBAAiB;QACf,IAAI,IAAI,CAAC,gBAAgB,KAAK,SAAS,EAAE,CAAC;YACxC,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC,gBAAgB,CAAC;YACtC,IAAI,CAAC,gBAAgB,GAAG,SAAS,CAAC;QACpC,CAAC;IACH,CAAC;CACF;AAED,MAAM,OAAO,GAAG,IAAI,iBAAiB,EAAmB,CAAC;AAEzD;;;;;;;;;GASG;AACH,MAAM,YAAY,GAAG,IAAI,GAAG,EAAmB,CAAC;AAEhD,MAAM,UAAU,eAAe,CAAC,GAAoB;IAClD,YAAY,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;AACxB,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAC,GAAoB;IACpD,YAAY,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;AAC3B,CAAC;AAED,kDAAkD;AAClD,MAAM,UAAU,cAAc,CAAI,GAAoB,EAAE,EAAW;IACjE,OAAO,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;AAC9B,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,UAAU;IACxB,MAAM,GAAG,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC;IAC/B,IAAI,GAAG;QAAE,OAAO,GAAG,CAAC;IAEpB,IAAI,YAAY,CAAC,IAAI,KAAK,CAAC,EAAE,CAAC;QAC5B,OAAO,YAAY,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC,KAAwB,CAAC;IAC/D,CAAC;IAED,IAAI,YAAY,CAAC,IAAI,KAAK,CAAC,EAAE,CAAC;QAC5B,MAAM,IAAI,KAAK,CACb,sEAAsE;YACpE,sEAAsE;YACtE,6DAA6D,CAChE,CAAC;IACJ,CAAC;IAED,MAAM,IAAI,KAAK,CACb,oCAAoC,YAAY,CAAC,IAAI,yBAAyB;QAC5E,yEAAyE;QACzE,yEAAyE,CAC5E,CAAC;AACJ,CAAC;AAED,kEAAkE;AAClE,MAAM,UAAU,UAAU;IACxB,OAAO,OAAO,CAAC,QAAQ,EAAE,KAAK,SAAS,IAAI,YAAY,CAAC,IAAI,KAAK,CAAC,CAAC;AACrE,CAAC"}
@@ -0,0 +1,73 @@
1
+ import { WorkflowContext } from "./context.js";
2
+ /**
3
+ * Returns the session id holding `threadId`'s leased tool calls.
4
+ *
5
+ * Deterministic, so a restart derives the same value and the lease taken before
6
+ * a crash is still recognised afterwards.
7
+ *
8
+ * Thread ids are chosen by the application and colons are reserved by the
9
+ * engine's key layout, so an id containing one is hashed rather than rejected:
10
+ * `"user:123"` is an ordinary way to namespace a thread, and refusing it would
11
+ * turn an engine storage detail into a constraint on the caller's naming.
12
+ * Hashing keeps the one property that matters, that the same thread always
13
+ * derives the same session, at the cost of a session id that no longer reads
14
+ * back as the thread's name.
15
+ */
16
+ export declare function toolSessionId(threadId: string): string;
17
+ /** A LangGraph-style config, or a bare thread id. */
18
+ export type ThreadRef = string | {
19
+ configurable?: {
20
+ thread_id?: string;
21
+ };
22
+ };
23
+ export interface DurableToolsOptions {
24
+ workflowId?: string;
25
+ version?: string;
26
+ target?: string;
27
+ secure?: boolean;
28
+ /**
29
+ * Names the work several agents are collaborating on: a ticket, a task, a
30
+ * tenant. Required by {@link IdempotencyScope.SHARED} and ignored otherwise.
31
+ */
32
+ coordinationId?: string;
33
+ }
34
+ /** The open session, for binding a tool the framework dispatched off-context. */
35
+ export interface DurableSession {
36
+ readonly sessionId: string;
37
+ readonly context: WorkflowContext;
38
+ /**
39
+ * Re-binds the session around `fn`.
40
+ *
41
+ * Needed only when a framework runs a tool somewhere the async context does
42
+ * not reach, and more than one session is open. With a single open session the
43
+ * SDK recovers it without this.
44
+ */
45
+ bind<T>(fn: () => T): T;
46
+ }
47
+ /**
48
+ * Leases every {@link tool} call made inside `fn`.
49
+ *
50
+ * Not tied to any framework. The contract is a session id, from a LangGraph
51
+ * config or a bare string, and tools invoked while the callback is running.
52
+ *
53
+ * ```ts
54
+ * await durableTools({ configurable: { thread_id: "ticket-4417" } }, async () => {
55
+ * await app.invoke({ ticket: "T-4417" }, config);
56
+ * });
57
+ * ```
58
+ *
59
+ * which is what makes a node's side effect happen at most once across a crash
60
+ * and resume. Without it a tool either finds no session at all, or one whose id
61
+ * is freshly generated per run, which derives a different key each time and so
62
+ * leases nothing across the restart that matters.
63
+ *
64
+ * Positional replay is deliberately not seeded here. Under the default
65
+ * `SESSION_WIDE` scope the derived key does not encode the position, so a
66
+ * resumed call derives the same key and the engine answers from the committed
67
+ * result. Deduplication comes from the idempotency cache, which is
68
+ * position-independent, rather than from a counter that cannot stay aligned when
69
+ * a framework resumes into the middle of a run.
70
+ */
71
+ export declare function durableTools<T>(config: ThreadRef, options: DurableToolsOptions, fn: (session: DurableSession) => Promise<T>): Promise<T>;
72
+ export declare function durableTools<T>(config: ThreadRef, fn: (session: DurableSession) => Promise<T>): Promise<T>;
73
+ //# sourceMappingURL=durable.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"durable.d.ts","sourceRoot":"","sources":["../src/durable.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,eAAe,EAIhB,MAAM,cAAc,CAAC;AAStB;;;;;;;;;;;;;GAaG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAatD;AAED,qDAAqD;AACrD,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG;IAAE,YAAY,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,CAAC;AAwB3E,MAAM,WAAW,mBAAmB;IAClC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,iFAAiF;AACjF,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,OAAO,EAAE,eAAe,CAAC;IAClC;;;;;;OAMG;IACH,IAAI,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC;CACzB;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAsB,YAAY,CAAC,CAAC,EAClC,MAAM,EAAE,SAAS,EACjB,OAAO,EAAE,mBAAmB,EAC5B,EAAE,EAAE,CAAC,OAAO,EAAE,cAAc,KAAK,OAAO,CAAC,CAAC,CAAC,GAC1C,OAAO,CAAC,CAAC,CAAC,CAAC;AACd,wBAAsB,YAAY,CAAC,CAAC,EAClC,MAAM,EAAE,SAAS,EACjB,EAAE,EAAE,CAAC,OAAO,EAAE,cAAc,KAAK,OAAO,CAAC,CAAC,CAAC,GAC1C,OAAO,CAAC,CAAC,CAAC,CAAC"}
@@ -0,0 +1,84 @@
1
+ import { createHash } from "node:crypto";
2
+ import { CellaflowClient } from "./client.js";
3
+ import { WorkflowContext, deregisterSession, registerSession, runWithContext, } from "./context.js";
4
+ /**
5
+ * Appended to a caller's thread id to derive the tool session, keeping it
6
+ * distinct from whatever session a checkpointer may be using for the same
7
+ * thread.
8
+ */
9
+ const TOOL_SESSION_SUFFIX = "-cellaflow-tools";
10
+ /**
11
+ * Returns the session id holding `threadId`'s leased tool calls.
12
+ *
13
+ * Deterministic, so a restart derives the same value and the lease taken before
14
+ * a crash is still recognised afterwards.
15
+ *
16
+ * Thread ids are chosen by the application and colons are reserved by the
17
+ * engine's key layout, so an id containing one is hashed rather than rejected:
18
+ * `"user:123"` is an ordinary way to namespace a thread, and refusing it would
19
+ * turn an engine storage detail into a constraint on the caller's naming.
20
+ * Hashing keeps the one property that matters, that the same thread always
21
+ * derives the same session, at the cost of a session id that no longer reads
22
+ * back as the thread's name.
23
+ */
24
+ export function toolSessionId(threadId) {
25
+ if (typeof threadId !== "string" || threadId.length === 0) {
26
+ throw new Error(`threadId must be a non-empty string, got ${JSON.stringify(threadId)}. ` +
27
+ "Every thread needs its own id: an empty one would put unrelated runs " +
28
+ "in a single session, where they would deduplicate against each other.");
29
+ }
30
+ if (threadId.includes(":")) {
31
+ const digest = createHash("sha256").update(threadId, "utf8").digest("hex").slice(0, 32);
32
+ return `lgthread-${digest}${TOOL_SESSION_SUFFIX}`;
33
+ }
34
+ return `${threadId}${TOOL_SESSION_SUFFIX}`;
35
+ }
36
+ function threadIdFrom(config) {
37
+ if (typeof config === "string")
38
+ return config;
39
+ if (typeof config !== "object" || config === null) {
40
+ throw new TypeError("durableTools() needs the config you pass to invoke(), or a thread id " +
41
+ `string; got ${typeof config}. Usage: ` +
42
+ 'durableTools({ configurable: { thread_id: "..." } }, fn).');
43
+ }
44
+ const threadId = config.configurable?.thread_id;
45
+ if (typeof threadId !== "string" || threadId.length === 0) {
46
+ throw new Error("durableTools() needs a config carrying configurable.thread_id, the same " +
47
+ "one you pass to invoke(). The thread id is what the tool session is " +
48
+ "derived from, so there is nothing to bind the lease to without it.");
49
+ }
50
+ return threadId;
51
+ }
52
+ export async function durableTools(config, optionsOrFn, maybeFn) {
53
+ const options = typeof optionsOrFn === "function" ? {} : optionsOrFn;
54
+ const fn = (typeof optionsOrFn === "function" ? optionsOrFn : maybeFn);
55
+ const { workflowId = "durable-tools", version = "1.0.0", target = "localhost:50051", secure = false, coordinationId, } = options;
56
+ const threadId = threadIdFrom(config);
57
+ const sessionId = toolSessionId(threadId);
58
+ const client = new CellaflowClient({ target, secure });
59
+ const resp = await client.startSession(workflowId, version, sessionId);
60
+ const ctx = new WorkflowContext({
61
+ client,
62
+ sessionId: resp.sessionId,
63
+ workflowVersion: resp.version,
64
+ sequence: 0,
65
+ coordinationId,
66
+ });
67
+ const session = {
68
+ sessionId: ctx.sessionId,
69
+ context: ctx,
70
+ bind: (inner) => runWithContext(ctx, inner),
71
+ };
72
+ // Registered as well as bound: the async context covers frameworks that
73
+ // dispatch tools on the calling context, and the registry covers those that
74
+ // do not.
75
+ registerSession(ctx);
76
+ try {
77
+ return await runWithContext(ctx, () => fn(session));
78
+ }
79
+ finally {
80
+ deregisterSession(ctx);
81
+ client.close();
82
+ }
83
+ }
84
+ //# sourceMappingURL=durable.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"durable.js","sourceRoot":"","sources":["../src/durable.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,96 @@
1
+ /** Raised when the requested execution lease cannot be acquired. */
2
+ export declare class LeaseNotAcquired extends Error {
3
+ constructor(message: string);
4
+ }
5
+ /** Raised by `LeaseHandle.check()` once the lease has been lost. */
6
+ export declare class LeaseLostError extends Error {
7
+ constructor(message: string);
8
+ }
9
+ /**
10
+ * Handle to an active execution lease.
11
+ *
12
+ * Node cannot interrupt a running task the way Python's asyncio can cancel one,
13
+ * so losing a lease here is **cooperative**: the work keeps running until it
14
+ * checks. Two ways to check, and long or irreversible work should use one:
15
+ *
16
+ * - `check()` throws {@link LeaseLostError} once the lease is gone.
17
+ * - `signal` aborts, so it can be handed to `fetch` or any AbortSignal-aware API.
18
+ */
19
+ export declare class LeaseHandle {
20
+ readonly fencingToken: number;
21
+ private readonly controller;
22
+ private lostDetail?;
23
+ constructor(fencingToken: number);
24
+ /** Aborts when the lease is lost. Pass to `fetch`, streams, or your own loops. */
25
+ get signal(): AbortSignal;
26
+ get isLost(): boolean;
27
+ /** @internal */
28
+ markLost(detail: string): void;
29
+ /**
30
+ * Throws if the lease has been lost.
31
+ *
32
+ * Call this before anything irreversible inside a long block. Nothing else
33
+ * stops the work: losing a lease cannot preempt a running function in Node.
34
+ */
35
+ check(): void;
36
+ }
37
+ /**
38
+ * Returns the lease held by the enclosing block.
39
+ *
40
+ * Lets code inside an {@link executionLease} block, or inside a
41
+ * {@link taskLease} function which has no handle to receive, reach the fencing
42
+ * token to pass downstream.
43
+ */
44
+ export declare function currentLease(): LeaseHandle;
45
+ export interface ExecutionLeaseOptions {
46
+ /** Identifies this worker. Sent as `agentId`. */
47
+ workerId: string;
48
+ target?: string;
49
+ secure?: boolean;
50
+ ttlMs?: number;
51
+ heartbeatIntervalMs?: number;
52
+ /** Notified when the lease is lost. A notification, not a substitute for checking. */
53
+ onLeaseLost?: (detail: string) => void;
54
+ /**
55
+ * How long to wait for a lease another worker is holding. Defaults to 0, which
56
+ * fails immediately with {@link LeaseNotAcquired}.
57
+ */
58
+ waitMs?: number;
59
+ }
60
+ /**
61
+ * Runs `fn` holding a distributed lock on `key`, renewed by a heartbeat.
62
+ *
63
+ * Only one worker runs the block at a time. If this process dies, the lease
64
+ * stops being renewed and another worker takes it once the TTL elapses, which is
65
+ * the property a plain database lock cannot offer for a holder that hangs
66
+ * without dying.
67
+ *
68
+ * ```ts
69
+ * await executionLease("task:abc-123", { workerId: "worker-1" }, async (lease) => {
70
+ * lease.check();
71
+ * await doTheWork({ signal: lease.signal });
72
+ * });
73
+ * ```
74
+ *
75
+ * Unlike the Python `async_execution_lease`, losing the lease does **not**
76
+ * interrupt `fn`: Node has no task cancellation. The handle exposes `check()`
77
+ * and `signal` so the work can abort itself, and anything irreversible should
78
+ * check first.
79
+ */
80
+ export declare function executionLease<T>(key: string, options: ExecutionLeaseOptions, fn: (lease: LeaseHandle) => Promise<T>): Promise<T>;
81
+ /**
82
+ * Wraps a function so every call runs under an execution lease.
83
+ *
84
+ * The key is derived per call, so a task id argument becomes the lock:
85
+ *
86
+ * ```ts
87
+ * const processOrder = taskLease(
88
+ * async (orderId: string) => { currentLease().check(); await ship(orderId); },
89
+ * { workerId: "worker-1", key: (orderId) => `order:${orderId}` },
90
+ * );
91
+ * ```
92
+ */
93
+ export declare function taskLease<A extends unknown[], R>(fn: (...args: A) => Promise<R>, options: ExecutionLeaseOptions & {
94
+ key: string | ((...args: A) => string);
95
+ }): (...args: A) => Promise<R>;
96
+ //# sourceMappingURL=execution.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"execution.d.ts","sourceRoot":"","sources":["../src/execution.ts"],"names":[],"mappings":"AAQA,oEAAoE;AACpE,qBAAa,gBAAiB,SAAQ,KAAK;IACzC,YAAY,OAAO,EAAE,MAAM,EAG1B;CACF;AAED,oEAAoE;AACpE,qBAAa,cAAe,SAAQ,KAAK;IACvC,YAAY,OAAO,EAAE,MAAM,EAG1B;CACF;AAED;;;;;;;;;GASG;AACH,qBAAa,WAAW;IACtB,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAyB;IACpD,OAAO,CAAC,UAAU,CAAC,CAAS;IAE5B,YAAY,YAAY,EAAE,MAAM,EAE/B;IAED,kFAAkF;IAClF,IAAI,MAAM,IAAI,WAAW,CAExB;IAED,IAAI,MAAM,IAAI,OAAO,CAEpB;IAED,gBAAgB;IAChB,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAI7B;IAED;;;;;OAKG;IACH,KAAK,IAAI,IAAI,CAIZ;CACF;AAID;;;;;;GAMG;AACH,wBAAgB,YAAY,IAAI,WAAW,CAS1C;AAED,MAAM,WAAW,qBAAqB;IACpC,iDAAiD;IACjD,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,sFAAsF;IACtF,WAAW,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACvC;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAsB,cAAc,CAAC,CAAC,EACpC,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,qBAAqB,EAC9B,EAAE,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,OAAO,CAAC,CAAC,CAAC,GACrC,OAAO,CAAC,CAAC,CAAC,CAoEZ;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,SAAS,CAAC,CAAC,SAAS,OAAO,EAAE,EAAE,CAAC,EAC9C,EAAE,EAAE,CAAC,GAAG,IAAI,EAAE,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,EAC9B,OAAO,EAAE,qBAAqB,GAAG;IAAE,GAAG,EAAE,MAAM,GAAG,CAAC,CAAC,GAAG,IAAI,EAAE,CAAC,KAAK,MAAM,CAAC,CAAA;CAAE,GAC1E,CAAC,GAAG,IAAI,EAAE,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,CAM5B"}