@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/dist/tool.js ADDED
@@ -0,0 +1,183 @@
1
+ import { CacheStatus } from "./cellaflow/v1/idempotency_pb.js";
2
+ import { StepStatus } from "./cellaflow/v1/common_pb.js";
3
+ import { getContext } from "./context.js";
4
+ import { IdempotencyScope, deriveIdempotencyKey } from "./idempotency.js";
5
+ import { LeaseHeartbeat } from "./lease.js";
6
+ import { deserialize } from "./serialization.js";
7
+ /**
8
+ * Raised when the engine refuses a lease because another agent already owns the
9
+ * graph position this call intends to write to.
10
+ *
11
+ * This is the refusal arriving *before* the side effect, which is the point: the
12
+ * alternative is discovering the divergence at commit time, after the money has
13
+ * moved.
14
+ */
15
+ export class DivergentStepError extends Error {
16
+ cause;
17
+ constructor(message, cause) {
18
+ super(message);
19
+ this.cause = cause;
20
+ this.name = "DivergentStepError";
21
+ }
22
+ }
23
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
24
+ /**
25
+ * Wraps a function so the engine runs it at most once per idempotency key, and
26
+ * so its result survives the process that produced it.
27
+ *
28
+ * A second caller deriving the same key does not run the body. It receives what
29
+ * the first call returned, even if that process has since died.
30
+ *
31
+ * ```ts
32
+ * const chargeCard = tool(
33
+ * async ({ orderId, cents }: { orderId: string; cents: number }) =>
34
+ * gateway.charge(orderId, cents),
35
+ * { toolName: "chargeCard" },
36
+ * );
37
+ *
38
+ * await durableTools({ configurable: { thread_id: "ticket-4417" } }, async () => {
39
+ * await chargeCard({ orderId: "ORD-1", cents: 1999 });
40
+ * });
41
+ * ```
42
+ *
43
+ * Must be called inside {@link durableTools}, which supplies the session.
44
+ */
45
+ export function tool(fn, options = {}) {
46
+ const { idempotencyKey, agentId = "default", scope = IdempotencyScope.SESSION_WIDE, sharedOn, } = options;
47
+ const toolName = options.toolName ?? fn.name;
48
+ if (!toolName) {
49
+ throw new Error("tool() needs a name: it is part of the idempotency key, so two anonymous " +
50
+ "tools would otherwise derive the same key for different work. Pass " +
51
+ "{ toolName: '...' } or use a named function.");
52
+ }
53
+ if (sharedOn !== undefined && scope !== IdempotencyScope.SHARED) {
54
+ throw new Error("sharedOn only applies to IdempotencyScope.SHARED. Under any other scope " +
55
+ "the key already includes the session, so restricting the hash changes " +
56
+ "what deduplicates without making anything converge.");
57
+ }
58
+ if (sharedOn !== undefined && idempotencyKey) {
59
+ throw new Error("sharedOn and idempotencyKey both decide the key. Supply one: an explicit " +
60
+ "key is already the identity of the work.");
61
+ }
62
+ return async function leasedTool(...args) {
63
+ const ctx = getContext();
64
+ // A cache hit may have left this counter ahead of the engine's. Adopt the
65
+ // position it reported before claiming the next sequence, or this commit
66
+ // fails the ordering check and names the wrong step. Deferred to here rather
67
+ // than done on the hit itself so a run that ends on a hit does no extra work.
68
+ ctx.reconcileSequence();
69
+ ctx.sequence += 1;
70
+ const seq = ctx.sequence;
71
+ let ikey = idempotencyKey;
72
+ if (!ikey) {
73
+ const kwargs = sharedOn !== undefined && args.length === 1 && isPlainObject(args[0])
74
+ ? args[0]
75
+ : {};
76
+ if (sharedOn !== undefined && Object.keys(kwargs).length === 0) {
77
+ throw new Error(`Tool '${toolName}' uses sharedOn but was not called with a single ` +
78
+ "object argument. The names in sharedOn are read from that object, " +
79
+ "so there is nothing to select from.");
80
+ }
81
+ ikey = deriveIdempotencyKey({
82
+ sessionId: ctx.sessionId,
83
+ workflowVersion: ctx.workflowVersion,
84
+ stepSequence: seq,
85
+ agentId,
86
+ toolName,
87
+ scope,
88
+ coordinationId: ctx.coordinationId,
89
+ args: sharedOn !== undefined ? [] : args,
90
+ kwargs,
91
+ sharedOn,
92
+ });
93
+ }
94
+ let fencingToken = 0;
95
+ let hb;
96
+ // Arbitrate. Loop because IN_PROGRESS means another worker holds it and the
97
+ // right move is to wait for their result rather than to act.
98
+ for (;;) {
99
+ let resp;
100
+ try {
101
+ resp = await ctx.client.checkIdempotencyCache(agentId, ikey, 0, undefined, ctx.sessionId,
102
+ // Tell the engine where this call intends to write, so a lease at an
103
+ // already-committed position is refused before the body runs rather
104
+ // than after.
105
+ seq);
106
+ }
107
+ catch (err) {
108
+ if (isFailedPrecondition(err)) {
109
+ throw new DivergentStepError(`Step '${toolName}' at sequence ${seq} was refused: another agent ` +
110
+ "already owns this graph position with different inputs.", err);
111
+ }
112
+ throw err;
113
+ }
114
+ if (resp.status === CacheStatus.HIT) {
115
+ // Returns without committing, so record where the engine says the
116
+ // session sits. The next step adopts it.
117
+ if (resp.currentSequence !== undefined) {
118
+ ctx.recordEngineSequence(Number(resp.currentSequence));
119
+ }
120
+ const payload = resp.cachedResult?.outputPayload;
121
+ if (payload && payload.length > 0) {
122
+ const envelope = deserialize(payload);
123
+ return envelope?.result;
124
+ }
125
+ return undefined;
126
+ }
127
+ if (resp.status === CacheStatus.IN_PROGRESS) {
128
+ await sleep(Number(resp.retryAfterMs ?? 1000n));
129
+ continue;
130
+ }
131
+ if (resp.status === CacheStatus.ACQUIRED) {
132
+ fencingToken = Number(resp.fencingToken ?? 0n);
133
+ const intervalMs = Number(resp.heartbeatIntervalMs ?? 5000n);
134
+ hb = new LeaseHeartbeat({
135
+ client: ctx.client,
136
+ agentId,
137
+ idempotencyKey: ikey,
138
+ fencingToken,
139
+ heartbeatIntervalMs: intervalMs,
140
+ });
141
+ hb.start();
142
+ }
143
+ break;
144
+ }
145
+ try {
146
+ const result = await fn(...args);
147
+ await ctx.client.commitStep(ctx.sessionId, seq, toolName, StepStatus.SUCCESS,
148
+ // The envelope is part of the cross-language contract: the Python SDK
149
+ // commits {result} and reads .result back. A bare value here would not
150
+ // interoperate on a shared key.
151
+ { result }, ikey, fencingToken);
152
+ return result;
153
+ }
154
+ catch (err) {
155
+ if (fencingToken > 0) {
156
+ await ctx.client
157
+ .releaseLease(agentId, ikey, fencingToken, "TOOL_ERROR")
158
+ .catch(() => {
159
+ // The lease expires on its own. Losing the release is not worth
160
+ // masking the error that caused it.
161
+ });
162
+ }
163
+ throw err;
164
+ }
165
+ finally {
166
+ await hb?.stop();
167
+ }
168
+ };
169
+ }
170
+ /** `step` and `tool` are the same mechanism, kept distinct for readability. */
171
+ export const step = tool;
172
+ function isPlainObject(v) {
173
+ return typeof v === "object" && v !== null && !Array.isArray(v);
174
+ }
175
+ /**
176
+ * Connect and gRPC surface `FAILED_PRECONDITION` differently depending on
177
+ * transport, so match on the code rather than the class.
178
+ */
179
+ function isFailedPrecondition(err) {
180
+ const code = err?.code;
181
+ return code === 9 || code === "failed_precondition";
182
+ }
183
+ //# sourceMappingURL=tool.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tool.js","sourceRoot":"","sources":["../src/tool.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cellaflow/sdk",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "TypeScript/Node.js SDK for the Cellaflow workflow engine",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -47,7 +47,8 @@
47
47
  "@bufbuild/protobuf": "^1.10.1",
48
48
  "@connectrpc/connect": "^1.7.0",
49
49
  "@connectrpc/connect-node": "^1.7.0",
50
- "@msgpack/msgpack": "^3.1.3"
50
+ "@msgpack/msgpack": "^3.1.3",
51
+ "canonicalize": "^4.0.0"
51
52
  },
52
53
  "devDependencies": {
53
54
  "@bufbuild/buf": "^1.73.0",
package/src/context.ts ADDED
@@ -0,0 +1,137 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import type { CellaflowClient } from "./client.js";
3
+
4
+ /**
5
+ * The session a tool call belongs to, plus the bookkeeping that keeps the local
6
+ * sequence counter aligned with the engine's.
7
+ */
8
+ export class WorkflowContext {
9
+ readonly client: CellaflowClient;
10
+ readonly sessionId: string;
11
+ readonly workflowVersion: string;
12
+ sequence: number;
13
+ /**
14
+ * Names the work several agents are collaborating on: a ticket, a task, a
15
+ * tenant. Only {@link IdempotencyScope.SHARED} reads it, and that scope
16
+ * requires it.
17
+ */
18
+ readonly coordinationId?: string;
19
+
20
+ /**
21
+ * The session position the engine last reported, held from a cache hit until
22
+ * the next step consumes it. See {@link reconcileSequence}.
23
+ */
24
+ private reportedSequence?: number;
25
+
26
+ constructor(init: {
27
+ client: CellaflowClient;
28
+ sessionId: string;
29
+ workflowVersion: string;
30
+ sequence?: number;
31
+ coordinationId?: string;
32
+ }) {
33
+ this.client = init.client;
34
+ this.sessionId = init.sessionId;
35
+ this.workflowVersion = init.workflowVersion;
36
+ this.sequence = init.sequence ?? 0;
37
+ this.coordinationId = init.coordinationId;
38
+ }
39
+
40
+ /**
41
+ * Notes the session position the engine reported alongside a cache hit.
42
+ *
43
+ * Held rather than applied immediately: a run whose last act is a shared tool,
44
+ * which is the common shape, should not pay for bookkeeping it will never use.
45
+ * {@link reconcileSequence} consumes it at the start of the next step.
46
+ */
47
+ recordEngineSequence(sequence: number): void {
48
+ this.reportedSequence = sequence;
49
+ }
50
+
51
+ /**
52
+ * Adopts the position the engine reported on the last cache hit.
53
+ *
54
+ * Every tool call increments this counter, but a cache hit returns *without
55
+ * committing*. The engine's sequence therefore did not advance while the local
56
+ * one did, and the next commit fails the ordering check one step after the
57
+ * real cause.
58
+ *
59
+ * The same-session case survives on a coincidence rather than an invariant: a
60
+ * peer's commit advances the engine by exactly the amount this caller advanced
61
+ * locally, so the two happen to stay equal. Any asymmetry breaks it, such as a
62
+ * hit satisfied from a *different* session, or replicas that reached a shared
63
+ * tool after different numbers of steps.
64
+ *
65
+ * A no-op when the engine reported nothing, which is an older engine predating
66
+ * the field. Behaviour then degrades to the original defect rather than to
67
+ * something new.
68
+ */
69
+ reconcileSequence(): void {
70
+ if (this.reportedSequence !== undefined) {
71
+ this.sequence = this.reportedSequence;
72
+ this.reportedSequence = undefined;
73
+ }
74
+ }
75
+ }
76
+
77
+ const storage = new AsyncLocalStorage<WorkflowContext>();
78
+
79
+ /**
80
+ * Sessions currently open, for frameworks that dispatch a tool off the calling
81
+ * context.
82
+ *
83
+ * `AsyncLocalStorage` follows `await` and `setTimeout`, which covers most
84
+ * frameworks. It does not follow a hop through a worker thread or a native
85
+ * callback that loses the async resource. Where exactly one session is open the
86
+ * context is recoverable from here; where several are, there is nothing to
87
+ * disambiguate them and the caller must bind explicitly.
88
+ */
89
+ const openSessions = new Set<WorkflowContext>();
90
+
91
+ export function registerSession(ctx: WorkflowContext): void {
92
+ openSessions.add(ctx);
93
+ }
94
+
95
+ export function deregisterSession(ctx: WorkflowContext): void {
96
+ openSessions.delete(ctx);
97
+ }
98
+
99
+ /** Runs `fn` with `ctx` as the active context. */
100
+ export function runWithContext<T>(ctx: WorkflowContext, fn: () => T): T {
101
+ return storage.run(ctx, fn);
102
+ }
103
+
104
+ /**
105
+ * Returns the context a tool call belongs to.
106
+ *
107
+ * Falls back to the open-session registry when the async context was lost, but
108
+ * only when a single session is open. Two open sessions and a lost context is
109
+ * ambiguous, and guessing would attribute a side effect to the wrong run.
110
+ */
111
+ export function getContext(): WorkflowContext {
112
+ const ctx = storage.getStore();
113
+ if (ctx) return ctx;
114
+
115
+ if (openSessions.size === 1) {
116
+ return openSessions.values().next().value as WorkflowContext;
117
+ }
118
+
119
+ if (openSessions.size === 0) {
120
+ throw new Error(
121
+ "No CellaFlow session is active. A leased tool must be called inside " +
122
+ "durableTools(...), which is what binds it to a session. Without one " +
123
+ "there is no idempotency key to derive and nothing to lease.",
124
+ );
125
+ }
126
+
127
+ throw new Error(
128
+ `The calling context was lost and ${openSessions.size} sessions are open, so ` +
129
+ "the session this tool belongs to is ambiguous. Bind it explicitly with " +
130
+ "session.bind(() => ...) inside the tool, or open one session at a time.",
131
+ );
132
+ }
133
+
134
+ /** Whether a context is currently reachable, without throwing. */
135
+ export function hasContext(): boolean {
136
+ return storage.getStore() !== undefined || openSessions.size === 1;
137
+ }
package/src/durable.ts ADDED
@@ -0,0 +1,177 @@
1
+ import { createHash } from "node:crypto";
2
+ import { CellaflowClient } from "./client.js";
3
+ import {
4
+ WorkflowContext,
5
+ deregisterSession,
6
+ registerSession,
7
+ runWithContext,
8
+ } from "./context.js";
9
+
10
+ /**
11
+ * Appended to a caller's thread id to derive the tool session, keeping it
12
+ * distinct from whatever session a checkpointer may be using for the same
13
+ * thread.
14
+ */
15
+ const TOOL_SESSION_SUFFIX = "-cellaflow-tools";
16
+
17
+ /**
18
+ * Returns the session id holding `threadId`'s leased tool calls.
19
+ *
20
+ * Deterministic, so a restart derives the same value and the lease taken before
21
+ * a crash is still recognised afterwards.
22
+ *
23
+ * Thread ids are chosen by the application and colons are reserved by the
24
+ * engine's key layout, so an id containing one is hashed rather than rejected:
25
+ * `"user:123"` is an ordinary way to namespace a thread, and refusing it would
26
+ * turn an engine storage detail into a constraint on the caller's naming.
27
+ * Hashing keeps the one property that matters, that the same thread always
28
+ * derives the same session, at the cost of a session id that no longer reads
29
+ * back as the thread's name.
30
+ */
31
+ export function toolSessionId(threadId: string): string {
32
+ if (typeof threadId !== "string" || threadId.length === 0) {
33
+ throw new Error(
34
+ `threadId must be a non-empty string, got ${JSON.stringify(threadId)}. ` +
35
+ "Every thread needs its own id: an empty one would put unrelated runs " +
36
+ "in a single session, where they would deduplicate against each other.",
37
+ );
38
+ }
39
+ if (threadId.includes(":")) {
40
+ const digest = createHash("sha256").update(threadId, "utf8").digest("hex").slice(0, 32);
41
+ return `lgthread-${digest}${TOOL_SESSION_SUFFIX}`;
42
+ }
43
+ return `${threadId}${TOOL_SESSION_SUFFIX}`;
44
+ }
45
+
46
+ /** A LangGraph-style config, or a bare thread id. */
47
+ export type ThreadRef = string | { configurable?: { thread_id?: string } };
48
+
49
+ function threadIdFrom(config: ThreadRef): string {
50
+ if (typeof config === "string") return config;
51
+
52
+ if (typeof config !== "object" || config === null) {
53
+ throw new TypeError(
54
+ "durableTools() needs the config you pass to invoke(), or a thread id " +
55
+ `string; got ${typeof config}. Usage: ` +
56
+ 'durableTools({ configurable: { thread_id: "..." } }, fn).',
57
+ );
58
+ }
59
+
60
+ const threadId = config.configurable?.thread_id;
61
+ if (typeof threadId !== "string" || threadId.length === 0) {
62
+ throw new Error(
63
+ "durableTools() needs a config carrying configurable.thread_id, the same " +
64
+ "one you pass to invoke(). The thread id is what the tool session is " +
65
+ "derived from, so there is nothing to bind the lease to without it.",
66
+ );
67
+ }
68
+ return threadId;
69
+ }
70
+
71
+ export interface DurableToolsOptions {
72
+ workflowId?: string;
73
+ version?: string;
74
+ target?: string;
75
+ secure?: boolean;
76
+ /**
77
+ * Names the work several agents are collaborating on: a ticket, a task, a
78
+ * tenant. Required by {@link IdempotencyScope.SHARED} and ignored otherwise.
79
+ */
80
+ coordinationId?: string;
81
+ }
82
+
83
+ /** The open session, for binding a tool the framework dispatched off-context. */
84
+ export interface DurableSession {
85
+ readonly sessionId: string;
86
+ readonly context: WorkflowContext;
87
+ /**
88
+ * Re-binds the session around `fn`.
89
+ *
90
+ * Needed only when a framework runs a tool somewhere the async context does
91
+ * not reach, and more than one session is open. With a single open session the
92
+ * SDK recovers it without this.
93
+ */
94
+ bind<T>(fn: () => T): T;
95
+ }
96
+
97
+ /**
98
+ * Leases every {@link tool} call made inside `fn`.
99
+ *
100
+ * Not tied to any framework. The contract is a session id, from a LangGraph
101
+ * config or a bare string, and tools invoked while the callback is running.
102
+ *
103
+ * ```ts
104
+ * await durableTools({ configurable: { thread_id: "ticket-4417" } }, async () => {
105
+ * await app.invoke({ ticket: "T-4417" }, config);
106
+ * });
107
+ * ```
108
+ *
109
+ * which is what makes a node's side effect happen at most once across a crash
110
+ * and resume. Without it a tool either finds no session at all, or one whose id
111
+ * is freshly generated per run, which derives a different key each time and so
112
+ * leases nothing across the restart that matters.
113
+ *
114
+ * Positional replay is deliberately not seeded here. Under the default
115
+ * `SESSION_WIDE` scope the derived key does not encode the position, so a
116
+ * resumed call derives the same key and the engine answers from the committed
117
+ * result. Deduplication comes from the idempotency cache, which is
118
+ * position-independent, rather than from a counter that cannot stay aligned when
119
+ * a framework resumes into the middle of a run.
120
+ */
121
+ export async function durableTools<T>(
122
+ config: ThreadRef,
123
+ options: DurableToolsOptions,
124
+ fn: (session: DurableSession) => Promise<T>,
125
+ ): Promise<T>;
126
+ export async function durableTools<T>(
127
+ config: ThreadRef,
128
+ fn: (session: DurableSession) => Promise<T>,
129
+ ): Promise<T>;
130
+ export async function durableTools<T>(
131
+ config: ThreadRef,
132
+ optionsOrFn: DurableToolsOptions | ((session: DurableSession) => Promise<T>),
133
+ maybeFn?: (session: DurableSession) => Promise<T>,
134
+ ): Promise<T> {
135
+ const options: DurableToolsOptions =
136
+ typeof optionsOrFn === "function" ? {} : optionsOrFn;
137
+ const fn = (typeof optionsOrFn === "function" ? optionsOrFn : maybeFn)!;
138
+
139
+ const {
140
+ workflowId = "durable-tools",
141
+ version = "1.0.0",
142
+ target = "localhost:50051",
143
+ secure = false,
144
+ coordinationId,
145
+ } = options;
146
+
147
+ const threadId = threadIdFrom(config);
148
+ const sessionId = toolSessionId(threadId);
149
+
150
+ const client = new CellaflowClient({ target, secure });
151
+ const resp = await client.startSession(workflowId, version, sessionId);
152
+
153
+ const ctx = new WorkflowContext({
154
+ client,
155
+ sessionId: resp.sessionId,
156
+ workflowVersion: resp.version,
157
+ sequence: 0,
158
+ coordinationId,
159
+ });
160
+
161
+ const session: DurableSession = {
162
+ sessionId: ctx.sessionId,
163
+ context: ctx,
164
+ bind: (inner) => runWithContext(ctx, inner),
165
+ };
166
+
167
+ // Registered as well as bound: the async context covers frameworks that
168
+ // dispatch tools on the calling context, and the registry covers those that
169
+ // do not.
170
+ registerSession(ctx);
171
+ try {
172
+ return await runWithContext(ctx, () => fn(session));
173
+ } finally {
174
+ deregisterSession(ctx);
175
+ client.close();
176
+ }
177
+ }