@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 +68 -0
- package/README.md +62 -0
- package/dist/context.d.ts +71 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +113 -0
- package/dist/context.js.map +1 -0
- package/dist/durable.d.ts +73 -0
- package/dist/durable.d.ts.map +1 -0
- package/dist/durable.js +84 -0
- package/dist/durable.js.map +1 -0
- package/dist/execution.d.ts +96 -0
- package/dist/execution.d.ts.map +1 -0
- package/dist/execution.js +172 -0
- package/dist/execution.js.map +1 -0
- package/dist/idempotency.d.ts +62 -0
- package/dist/idempotency.d.ts.map +1 -0
- package/dist/idempotency.js +91 -0
- package/dist/idempotency.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/lease.d.ts +50 -0
- package/dist/lease.d.ts.map +1 -0
- package/dist/lease.js +117 -0
- package/dist/lease.js.map +1 -0
- package/dist/tool.d.ts +58 -0
- package/dist/tool.d.ts.map +1 -0
- package/dist/tool.js +183 -0
- package/dist/tool.js.map +1 -0
- package/package.json +3 -2
- package/src/context.ts +137 -0
- package/src/durable.ts +177 -0
- package/src/execution.ts +224 -0
- package/src/idempotency.ts +134 -0
- package/src/index.ts +18 -0
- package/src/lease.ts +145 -0
- package/src/tool.ts +247 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { CellaflowClient } from "./client.js";
|
|
3
|
+
import { CacheStatus } from "./cellaflow/v1/idempotency_pb.js";
|
|
4
|
+
import { LeaseHeartbeat } from "./lease.js";
|
|
5
|
+
const DEFAULT_TTL_MS = 30_000;
|
|
6
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 5_000;
|
|
7
|
+
/** Raised when the requested execution lease cannot be acquired. */
|
|
8
|
+
export class LeaseNotAcquired extends Error {
|
|
9
|
+
constructor(message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "LeaseNotAcquired";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** Raised by `LeaseHandle.check()` once the lease has been lost. */
|
|
15
|
+
export class LeaseLostError extends Error {
|
|
16
|
+
constructor(message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "LeaseLostError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Handle to an active execution lease.
|
|
23
|
+
*
|
|
24
|
+
* Node cannot interrupt a running task the way Python's asyncio can cancel one,
|
|
25
|
+
* so losing a lease here is **cooperative**: the work keeps running until it
|
|
26
|
+
* checks. Two ways to check, and long or irreversible work should use one:
|
|
27
|
+
*
|
|
28
|
+
* - `check()` throws {@link LeaseLostError} once the lease is gone.
|
|
29
|
+
* - `signal` aborts, so it can be handed to `fetch` or any AbortSignal-aware API.
|
|
30
|
+
*/
|
|
31
|
+
export class LeaseHandle {
|
|
32
|
+
fencingToken;
|
|
33
|
+
controller = new AbortController();
|
|
34
|
+
lostDetail;
|
|
35
|
+
constructor(fencingToken) {
|
|
36
|
+
this.fencingToken = fencingToken;
|
|
37
|
+
}
|
|
38
|
+
/** Aborts when the lease is lost. Pass to `fetch`, streams, or your own loops. */
|
|
39
|
+
get signal() {
|
|
40
|
+
return this.controller.signal;
|
|
41
|
+
}
|
|
42
|
+
get isLost() {
|
|
43
|
+
return this.controller.signal.aborted;
|
|
44
|
+
}
|
|
45
|
+
/** @internal */
|
|
46
|
+
markLost(detail) {
|
|
47
|
+
if (this.controller.signal.aborted)
|
|
48
|
+
return;
|
|
49
|
+
this.lostDetail = detail;
|
|
50
|
+
this.controller.abort(new LeaseLostError(`Execution lease was lost: ${detail}`));
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Throws if the lease has been lost.
|
|
54
|
+
*
|
|
55
|
+
* Call this before anything irreversible inside a long block. Nothing else
|
|
56
|
+
* stops the work: losing a lease cannot preempt a running function in Node.
|
|
57
|
+
*/
|
|
58
|
+
check() {
|
|
59
|
+
if (this.isLost) {
|
|
60
|
+
throw new LeaseLostError(`Execution lease was lost: ${this.lostDetail}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const currentLeaseStore = new AsyncLocalStorage();
|
|
65
|
+
/**
|
|
66
|
+
* Returns the lease held by the enclosing block.
|
|
67
|
+
*
|
|
68
|
+
* Lets code inside an {@link executionLease} block, or inside a
|
|
69
|
+
* {@link taskLease} function which has no handle to receive, reach the fencing
|
|
70
|
+
* token to pass downstream.
|
|
71
|
+
*/
|
|
72
|
+
export function currentLease() {
|
|
73
|
+
const handle = currentLeaseStore.getStore();
|
|
74
|
+
if (!handle) {
|
|
75
|
+
throw new Error("No execution lease is active. currentLease() is only valid inside an " +
|
|
76
|
+
"executionLease(...) block or a taskLease(...) function.");
|
|
77
|
+
}
|
|
78
|
+
return handle;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Runs `fn` holding a distributed lock on `key`, renewed by a heartbeat.
|
|
82
|
+
*
|
|
83
|
+
* Only one worker runs the block at a time. If this process dies, the lease
|
|
84
|
+
* stops being renewed and another worker takes it once the TTL elapses, which is
|
|
85
|
+
* the property a plain database lock cannot offer for a holder that hangs
|
|
86
|
+
* without dying.
|
|
87
|
+
*
|
|
88
|
+
* ```ts
|
|
89
|
+
* await executionLease("task:abc-123", { workerId: "worker-1" }, async (lease) => {
|
|
90
|
+
* lease.check();
|
|
91
|
+
* await doTheWork({ signal: lease.signal });
|
|
92
|
+
* });
|
|
93
|
+
* ```
|
|
94
|
+
*
|
|
95
|
+
* Unlike the Python `async_execution_lease`, losing the lease does **not**
|
|
96
|
+
* interrupt `fn`: Node has no task cancellation. The handle exposes `check()`
|
|
97
|
+
* and `signal` so the work can abort itself, and anything irreversible should
|
|
98
|
+
* check first.
|
|
99
|
+
*/
|
|
100
|
+
export async function executionLease(key, options, fn) {
|
|
101
|
+
const { workerId, target = "localhost:50051", secure = false, ttlMs = DEFAULT_TTL_MS, heartbeatIntervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS, onLeaseLost, waitMs = 0, } = options;
|
|
102
|
+
const client = new CellaflowClient({ target, secure });
|
|
103
|
+
let hb;
|
|
104
|
+
let fencingToken = 0;
|
|
105
|
+
try {
|
|
106
|
+
const resp = await client.checkIdempotencyCache(workerId, key, waitMs, ttlMs);
|
|
107
|
+
if (resp.status === CacheStatus.HIT) {
|
|
108
|
+
throw new LeaseNotAcquired(`'${key}' is already committed: the operation it names has completed. An ` +
|
|
109
|
+
"execution lease locks work still to be done, so a committed key means " +
|
|
110
|
+
"this work is finished, not that the lock is busy.");
|
|
111
|
+
}
|
|
112
|
+
if (resp.status !== CacheStatus.ACQUIRED) {
|
|
113
|
+
const holder = resp.currentHolderId ? ` (held by ${resp.currentHolderId})` : "";
|
|
114
|
+
throw new LeaseNotAcquired(`Could not acquire execution lease '${key}'${holder}. Another worker is ` +
|
|
115
|
+
"running it. Raise waitMs to wait for them, or treat this as the " +
|
|
116
|
+
"signal that the work is already in hand.");
|
|
117
|
+
}
|
|
118
|
+
fencingToken = Number(resp.fencingToken ?? 0n);
|
|
119
|
+
const handle = new LeaseHandle(fencingToken);
|
|
120
|
+
hb = new LeaseHeartbeat({
|
|
121
|
+
client,
|
|
122
|
+
agentId: workerId,
|
|
123
|
+
idempotencyKey: key,
|
|
124
|
+
fencingToken,
|
|
125
|
+
heartbeatIntervalMs: Number(resp.heartbeatIntervalMs ?? BigInt(heartbeatIntervalMs)),
|
|
126
|
+
leaseTtlMs: ttlMs,
|
|
127
|
+
onLeaseLost: (detail) => {
|
|
128
|
+
handle.markLost(detail);
|
|
129
|
+
try {
|
|
130
|
+
onLeaseLost?.(detail);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// A caller's callback must not mask the loss itself.
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
hb.start();
|
|
138
|
+
return await currentLeaseStore.run(handle, () => fn(handle));
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
await hb?.stop();
|
|
142
|
+
if (fencingToken > 0) {
|
|
143
|
+
await client
|
|
144
|
+
.releaseLease(workerId, key, fencingToken, "BLOCK_EXIT")
|
|
145
|
+
.catch(() => {
|
|
146
|
+
// The lease expires on its own; a failed release is not worth masking
|
|
147
|
+
// whatever the block was doing.
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
client.close();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Wraps a function so every call runs under an execution lease.
|
|
155
|
+
*
|
|
156
|
+
* The key is derived per call, so a task id argument becomes the lock:
|
|
157
|
+
*
|
|
158
|
+
* ```ts
|
|
159
|
+
* const processOrder = taskLease(
|
|
160
|
+
* async (orderId: string) => { currentLease().check(); await ship(orderId); },
|
|
161
|
+
* { workerId: "worker-1", key: (orderId) => `order:${orderId}` },
|
|
162
|
+
* );
|
|
163
|
+
* ```
|
|
164
|
+
*/
|
|
165
|
+
export function taskLease(fn, options) {
|
|
166
|
+
const { key, ...leaseOptions } = options;
|
|
167
|
+
return async function leasedTask(...args) {
|
|
168
|
+
const resolved = typeof key === "function" ? key(...args) : key;
|
|
169
|
+
return executionLease(resolved, leaseOptions, () => fn(...args));
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
//# sourceMappingURL=execution.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"execution.js","sourceRoot":"","sources":["../src/execution.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How widely a derived idempotency key deduplicates.
|
|
3
|
+
*
|
|
4
|
+
* The numeric values match the Python SDK and the engine's enum. They are part
|
|
5
|
+
* of the wire contract, not an implementation detail.
|
|
6
|
+
*/
|
|
7
|
+
export declare enum IdempotencyScope {
|
|
8
|
+
UNSPECIFIED = 0,
|
|
9
|
+
/** Shared across all agents in the session. The default. */
|
|
10
|
+
SESSION_WIDE = 1,
|
|
11
|
+
/** Isolated to the executing agent. */
|
|
12
|
+
AGENT_PRIVATE = 2,
|
|
13
|
+
/** Isolated to the specific superstep / node. */
|
|
14
|
+
STEP_LOCAL = 3,
|
|
15
|
+
/**
|
|
16
|
+
* Shared across *sessions* within a declared coordination domain.
|
|
17
|
+
*
|
|
18
|
+
* The only scope whose key omits `sessionId`, so agents running different
|
|
19
|
+
* workflows in different sessions deduplicate one shared side effect.
|
|
20
|
+
*/
|
|
21
|
+
SHARED = 4
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Hashes inputs with RFC 8785 Canonical JSON and SHA-256, returning the first
|
|
25
|
+
* 16 bytes hex-encoded.
|
|
26
|
+
*
|
|
27
|
+
* Byte-identical to the Python SDK's `_hash_inputs`, which is what allows a
|
|
28
|
+
* TypeScript agent and a Python agent to converge on one key under
|
|
29
|
+
* {@link IdempotencyScope.SHARED}. Verified against Python reference hashes
|
|
30
|
+
* covering unicode, escapes, nested objects, key reordering and exponent-form
|
|
31
|
+
* numbers. Changing the canonicalisation or the digest length silently stops
|
|
32
|
+
* cross-language deduplication, which fails by repeating the side effect rather
|
|
33
|
+
* than by raising.
|
|
34
|
+
*/
|
|
35
|
+
export declare function hashInputs(args: unknown[], kwargs: Record<string, unknown>): string;
|
|
36
|
+
export interface DeriveKeyOptions {
|
|
37
|
+
sessionId: string;
|
|
38
|
+
workflowVersion: string;
|
|
39
|
+
stepSequence: number;
|
|
40
|
+
agentId: string;
|
|
41
|
+
toolName: string;
|
|
42
|
+
scope: IdempotencyScope;
|
|
43
|
+
coordinationId?: string;
|
|
44
|
+
/** Positional arguments the tool was called with. */
|
|
45
|
+
args?: unknown[];
|
|
46
|
+
/** Named arguments, when the tool takes a single options object. */
|
|
47
|
+
kwargs?: Record<string, unknown>;
|
|
48
|
+
/**
|
|
49
|
+
* Restricts the hash to the named keys of `kwargs`.
|
|
50
|
+
*
|
|
51
|
+
* Heterogeneous agents converge on one side effect precisely when they
|
|
52
|
+
* disagree about everything else, so hashing everything they pass is the one
|
|
53
|
+
* thing guaranteed to keep them apart.
|
|
54
|
+
*
|
|
55
|
+
* JavaScript has no named arguments, so a tool using `sharedOn` must take a
|
|
56
|
+
* single options object; the names are read from it.
|
|
57
|
+
*/
|
|
58
|
+
sharedOn?: readonly string[];
|
|
59
|
+
}
|
|
60
|
+
/** Derives the canonical idempotency key for a step or tool execution. */
|
|
61
|
+
export declare function deriveIdempotencyKey(opts: DeriveKeyOptions): string;
|
|
62
|
+
//# sourceMappingURL=idempotency.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"idempotency.d.ts","sourceRoot":"","sources":["../src/idempotency.ts"],"names":[],"mappings":"AAGA;;;;;GAKG;AACH,oBAAY,gBAAgB;IAC1B,WAAW,IAAI;IACf,4DAA4D;IAC5D,YAAY,IAAI;IAChB,uCAAuC;IACvC,aAAa,IAAI;IACjB,iDAAiD;IACjD,UAAU,IAAI;IACd;;;;;OAKG;IACH,MAAM,IAAI;CACX;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAUnF;AAED,MAAM,WAAW,gBAAgB;IAC/B,SAAS,EAAE,MAAM,CAAC;IAClB,eAAe,EAAE,MAAM,CAAC;IACxB,YAAY,EAAE,MAAM,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,gBAAgB,CAAC;IACxB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,qDAAqD;IACrD,IAAI,CAAC,EAAE,OAAO,EAAE,CAAC;IACjB,oEAAoE;IACpE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC;;;;;;;;;OASG;IACH,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC9B;AAED,0EAA0E;AAC1E,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,gBAAgB,GAAG,MAAM,CAyDnE"}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import canonicalize from "canonicalize";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
/**
|
|
4
|
+
* How widely a derived idempotency key deduplicates.
|
|
5
|
+
*
|
|
6
|
+
* The numeric values match the Python SDK and the engine's enum. They are part
|
|
7
|
+
* of the wire contract, not an implementation detail.
|
|
8
|
+
*/
|
|
9
|
+
export var IdempotencyScope;
|
|
10
|
+
(function (IdempotencyScope) {
|
|
11
|
+
IdempotencyScope[IdempotencyScope["UNSPECIFIED"] = 0] = "UNSPECIFIED";
|
|
12
|
+
/** Shared across all agents in the session. The default. */
|
|
13
|
+
IdempotencyScope[IdempotencyScope["SESSION_WIDE"] = 1] = "SESSION_WIDE";
|
|
14
|
+
/** Isolated to the executing agent. */
|
|
15
|
+
IdempotencyScope[IdempotencyScope["AGENT_PRIVATE"] = 2] = "AGENT_PRIVATE";
|
|
16
|
+
/** Isolated to the specific superstep / node. */
|
|
17
|
+
IdempotencyScope[IdempotencyScope["STEP_LOCAL"] = 3] = "STEP_LOCAL";
|
|
18
|
+
/**
|
|
19
|
+
* Shared across *sessions* within a declared coordination domain.
|
|
20
|
+
*
|
|
21
|
+
* The only scope whose key omits `sessionId`, so agents running different
|
|
22
|
+
* workflows in different sessions deduplicate one shared side effect.
|
|
23
|
+
*/
|
|
24
|
+
IdempotencyScope[IdempotencyScope["SHARED"] = 4] = "SHARED";
|
|
25
|
+
})(IdempotencyScope || (IdempotencyScope = {}));
|
|
26
|
+
/**
|
|
27
|
+
* Hashes inputs with RFC 8785 Canonical JSON and SHA-256, returning the first
|
|
28
|
+
* 16 bytes hex-encoded.
|
|
29
|
+
*
|
|
30
|
+
* Byte-identical to the Python SDK's `_hash_inputs`, which is what allows a
|
|
31
|
+
* TypeScript agent and a Python agent to converge on one key under
|
|
32
|
+
* {@link IdempotencyScope.SHARED}. Verified against Python reference hashes
|
|
33
|
+
* covering unicode, escapes, nested objects, key reordering and exponent-form
|
|
34
|
+
* numbers. Changing the canonicalisation or the digest length silently stops
|
|
35
|
+
* cross-language deduplication, which fails by repeating the side effect rather
|
|
36
|
+
* than by raising.
|
|
37
|
+
*/
|
|
38
|
+
export function hashInputs(args, kwargs) {
|
|
39
|
+
const canon = canonicalize({ args, kwargs });
|
|
40
|
+
if (canon === undefined) {
|
|
41
|
+
throw new TypeError("Tool arguments could not be canonicalised. Arguments contributing to an " +
|
|
42
|
+
"idempotency key must be JSON-representable: no functions, symbols, " +
|
|
43
|
+
"BigInt, undefined, or circular references.");
|
|
44
|
+
}
|
|
45
|
+
return createHash("sha256").update(canon, "utf8").digest("hex").slice(0, 32);
|
|
46
|
+
}
|
|
47
|
+
/** Derives the canonical idempotency key for a step or tool execution. */
|
|
48
|
+
export function deriveIdempotencyKey(opts) {
|
|
49
|
+
const { sessionId, workflowVersion, stepSequence, agentId, toolName, scope, coordinationId, args = [], kwargs = {}, sharedOn, } = opts;
|
|
50
|
+
let inputsHash;
|
|
51
|
+
if (sharedOn !== undefined) {
|
|
52
|
+
const selected = {};
|
|
53
|
+
for (const k of sharedOn) {
|
|
54
|
+
if (Object.prototype.hasOwnProperty.call(kwargs, k))
|
|
55
|
+
selected[k] = kwargs[k];
|
|
56
|
+
}
|
|
57
|
+
inputsHash = hashInputs([], selected);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
inputsHash = hashInputs(args, kwargs);
|
|
61
|
+
}
|
|
62
|
+
if (scope === IdempotencyScope.SHARED) {
|
|
63
|
+
// The only scope that omits sessionId, so agents in different sessions
|
|
64
|
+
// converge on one key. It also omits workflowVersion, because the whole
|
|
65
|
+
// point is that *different* workflows share the operation and they will
|
|
66
|
+
// not be on the same version.
|
|
67
|
+
//
|
|
68
|
+
// coordinationId is what keeps this from being too wide. Without it two
|
|
69
|
+
// unrelated callers of sendEmail({to: X}) would deduplicate, suppressing
|
|
70
|
+
// one of them silently. It is required, and the caller must choose it.
|
|
71
|
+
if (!coordinationId) {
|
|
72
|
+
throw new Error("IdempotencyScope.SHARED requires a coordinationId naming the work being " +
|
|
73
|
+
"shared: a ticket, task, or tenant id. Pass it when opening the session, " +
|
|
74
|
+
'e.g. durableTools(config, { coordinationId: "ticket-4417" }, fn). It has ' +
|
|
75
|
+
"no default: a shared one would deduplicate unrelated callers that happen " +
|
|
76
|
+
"to make the same call.");
|
|
77
|
+
}
|
|
78
|
+
return `shared:${coordinationId}:${toolName}:${inputsHash}`;
|
|
79
|
+
}
|
|
80
|
+
let seqPart = "session_wide";
|
|
81
|
+
let agentPart = "session_wide";
|
|
82
|
+
if (scope === IdempotencyScope.AGENT_PRIVATE) {
|
|
83
|
+
agentPart = agentId;
|
|
84
|
+
}
|
|
85
|
+
else if (scope === IdempotencyScope.STEP_LOCAL) {
|
|
86
|
+
seqPart = String(stepSequence);
|
|
87
|
+
agentPart = agentId;
|
|
88
|
+
}
|
|
89
|
+
return `${sessionId}:${workflowVersion}:${seqPart}:${agentPart}:${toolName}:${inputsHash}`;
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=idempotency.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"idempotency.js","sourceRoot":"","sources":["../src/idempotency.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,4 +2,13 @@ export * from "./client.js";
|
|
|
2
2
|
export * from "./serialization.js";
|
|
3
3
|
export * from "./cellaflow/v1/common_pb.js";
|
|
4
4
|
export * from "./cellaflow/v1/idempotency_pb.js";
|
|
5
|
+
export { IdempotencyScope, deriveIdempotencyKey, hashInputs } from "./idempotency.js";
|
|
6
|
+
export type { DeriveKeyOptions } from "./idempotency.js";
|
|
7
|
+
export { tool, step, DivergentStepError } from "./tool.js";
|
|
8
|
+
export type { ToolOptions } from "./tool.js";
|
|
9
|
+
export { durableTools, toolSessionId } from "./durable.js";
|
|
10
|
+
export type { DurableToolsOptions, DurableSession, ThreadRef } from "./durable.js";
|
|
11
|
+
export { executionLease, taskLease, currentLease, LeaseHandle, LeaseNotAcquired, LeaseLostError, } from "./execution.js";
|
|
12
|
+
export type { ExecutionLeaseOptions } from "./execution.js";
|
|
13
|
+
export { WorkflowContext } from "./context.js";
|
|
5
14
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,cAAc,oBAAoB,CAAC;AACnC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,kCAAkC,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,cAAc,oBAAoB,CAAC;AACnC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,kCAAkC,CAAC;AAMjD,OAAO,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AACtF,YAAY,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACzD,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,kBAAkB,EAAE,MAAM,WAAW,CAAC;AAC3D,YAAY,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC3D,YAAY,EAAE,mBAAmB,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACnF,OAAO,EACL,cAAc,EACd,SAAS,EACT,YAAY,EACZ,WAAW,EACX,gBAAgB,EAChB,cAAc,GACf,MAAM,gBAAgB,CAAC;AACxB,YAAY,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;AAC5D,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -5,4 +5,10 @@ export * from "./cellaflow/v1/idempotency_pb.js";
|
|
|
5
5
|
// Note: internal_pb is intentionally NOT re-exported here. It contains
|
|
6
6
|
// engine-internal types (CacheRecord, LeaseRecord, etc.) that are not part
|
|
7
7
|
// of the public SDK surface.
|
|
8
|
+
// High-level API
|
|
9
|
+
export { IdempotencyScope, deriveIdempotencyKey, hashInputs } from "./idempotency.js";
|
|
10
|
+
export { tool, step, DivergentStepError } from "./tool.js";
|
|
11
|
+
export { durableTools, toolSessionId } from "./durable.js";
|
|
12
|
+
export { executionLease, taskLease, currentLease, LeaseHandle, LeaseNotAcquired, LeaseLostError, } from "./execution.js";
|
|
13
|
+
export { WorkflowContext } from "./context.js";
|
|
8
14
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,cAAc,oBAAoB,CAAC;AACnC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,kCAAkC,CAAC;AACjD,uEAAuE;AACvE,2EAA2E;AAC3E,6BAA6B"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,cAAc,oBAAoB,CAAC;AACnC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,kCAAkC,CAAC;AACjD,uEAAuE;AACvE,2EAA2E;AAC3E,6BAA6B;AAE7B,iBAAiB;AACjB,OAAO,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEtF,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,kBAAkB,EAAE,MAAM,WAAW,CAAC;AAE3D,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAE3D,OAAO,EACL,cAAc,EACd,SAAS,EACT,YAAY,EACZ,WAAW,EACX,gBAAgB,EAChB,cAAc,GACf,MAAM,gBAAgB,CAAC;AAExB,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC"}
|
package/dist/lease.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { CellaflowClient } from "./client.js";
|
|
2
|
+
export interface LeaseHeartbeatOptions {
|
|
3
|
+
client: CellaflowClient;
|
|
4
|
+
agentId: string;
|
|
5
|
+
idempotencyKey: string;
|
|
6
|
+
fencingToken: number;
|
|
7
|
+
/** How often to renew. The engine suggests this on acquisition. */
|
|
8
|
+
heartbeatIntervalMs: number;
|
|
9
|
+
/** How long each renewal extends the lease. Defaults to 3x the interval. */
|
|
10
|
+
extendMs?: number;
|
|
11
|
+
/**
|
|
12
|
+
* How long the lease survives without a confirmed renewal. Loss is declared
|
|
13
|
+
* against this, not against an error count.
|
|
14
|
+
*/
|
|
15
|
+
leaseTtlMs?: number;
|
|
16
|
+
/** Called once when the lease is determined to be lost. */
|
|
17
|
+
onLeaseLost?: (detail: string) => void;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Renews a held lease on an interval until stopped.
|
|
21
|
+
*
|
|
22
|
+
* The engine requires a caller holding a lease to renew it, so this starts as
|
|
23
|
+
* soon as one is acquired and stops in a `finally`.
|
|
24
|
+
*/
|
|
25
|
+
export declare class LeaseHeartbeat {
|
|
26
|
+
private readonly opts;
|
|
27
|
+
private timer?;
|
|
28
|
+
private stopped;
|
|
29
|
+
private lastConfirmed;
|
|
30
|
+
private inFlight?;
|
|
31
|
+
/** Set once the lease is known to be lost. Read by `LeaseHandle.check()`. */
|
|
32
|
+
lost: boolean;
|
|
33
|
+
lostDetail?: string;
|
|
34
|
+
constructor(options: LeaseHeartbeatOptions);
|
|
35
|
+
start(): void;
|
|
36
|
+
stop(): Promise<void>;
|
|
37
|
+
private notifyLost;
|
|
38
|
+
/**
|
|
39
|
+
* True once the lease can no longer be assumed held.
|
|
40
|
+
*
|
|
41
|
+
* A failed renewal is not itself proof of loss: the RPC failing means we could
|
|
42
|
+
* not confirm the lease, while the engine may still hold it until the TTL runs
|
|
43
|
+
* out. Declaring loss on an error count would abort work that still holds a
|
|
44
|
+
* perfectly valid lease, so loss is declared on elapsed time since the last
|
|
45
|
+
* *confirmed* renewal.
|
|
46
|
+
*/
|
|
47
|
+
private ttlExhausted;
|
|
48
|
+
private beat;
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=lease.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lease.d.ts","sourceRoot":"","sources":["../src/lease.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAqBnD,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,eAAe,CAAC;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,cAAc,EAAE,MAAM,CAAC;IACvB,YAAY,EAAE,MAAM,CAAC;IACrB,mEAAmE;IACnE,mBAAmB,EAAE,MAAM,CAAC;IAC5B,4EAA4E;IAC5E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,2DAA2D;IAC3D,WAAW,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;CACxC;AAED;;;;;GAKG;AACH,qBAAa,cAAc;IACzB,OAAO,CAAC,QAAQ,CAAC,IAAI,CACwB;IAC7C,OAAO,CAAC,KAAK,CAAC,CAAiB;IAC/B,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,aAAa,CAAc;IACnC,OAAO,CAAC,QAAQ,CAAC,CAAgB;IAEjC,6EAA6E;IAC7E,IAAI,UAAS;IACb,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB,YAAY,OAAO,EAAE,qBAAqB,EAQzC;IAED,KAAK,IAAI,IAAI,CAYZ;IAEK,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAS1B;IAED,OAAO,CAAC,UAAU;IAYlB;;;;;;;;OAQG;IACH,OAAO,CAAC,YAAY;YAIN,IAAI;CA2BnB"}
|
package/dist/lease.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { RenewFailureReason } from "./cellaflow/v1/idempotency_pb.js";
|
|
2
|
+
/** Why the engine refused to renew, in words a caller can act on. */
|
|
3
|
+
function renewFailureDetail(reason) {
|
|
4
|
+
switch (reason) {
|
|
5
|
+
case RenewFailureReason.EXPIRED:
|
|
6
|
+
return "the lease had already expired";
|
|
7
|
+
case RenewFailureReason.SUPERSEDED:
|
|
8
|
+
return "another worker took the lease";
|
|
9
|
+
case RenewFailureReason.NOT_FOUND:
|
|
10
|
+
return "the engine has no record of the lease";
|
|
11
|
+
case RenewFailureReason.COMPLETED:
|
|
12
|
+
return "the operation was already committed";
|
|
13
|
+
case RenewFailureReason.MAX_LIFETIME_EXCEEDED:
|
|
14
|
+
return "the lease hit its maximum lifetime and was reclaimed";
|
|
15
|
+
default:
|
|
16
|
+
return "the engine refused renewal";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Renews a held lease on an interval until stopped.
|
|
21
|
+
*
|
|
22
|
+
* The engine requires a caller holding a lease to renew it, so this starts as
|
|
23
|
+
* soon as one is acquired and stops in a `finally`.
|
|
24
|
+
*/
|
|
25
|
+
export class LeaseHeartbeat {
|
|
26
|
+
opts;
|
|
27
|
+
timer;
|
|
28
|
+
stopped = false;
|
|
29
|
+
lastConfirmed = Date.now();
|
|
30
|
+
inFlight;
|
|
31
|
+
/** Set once the lease is known to be lost. Read by `LeaseHandle.check()`. */
|
|
32
|
+
lost = false;
|
|
33
|
+
lostDetail;
|
|
34
|
+
constructor(options) {
|
|
35
|
+
const intervalMs = options.heartbeatIntervalMs;
|
|
36
|
+
this.opts = {
|
|
37
|
+
...options,
|
|
38
|
+
heartbeatIntervalMs: intervalMs,
|
|
39
|
+
extendMs: options.extendMs ?? intervalMs * 3,
|
|
40
|
+
leaseTtlMs: options.leaseTtlMs ?? intervalMs * 3,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
start() {
|
|
44
|
+
if (this.timer)
|
|
45
|
+
return;
|
|
46
|
+
this.timer = setInterval(() => {
|
|
47
|
+
// Never overlap renewals: a slow RPC would otherwise queue them and each
|
|
48
|
+
// would extend from a stale view of the lease.
|
|
49
|
+
if (this.inFlight)
|
|
50
|
+
return;
|
|
51
|
+
this.inFlight = this.beat().finally(() => {
|
|
52
|
+
this.inFlight = undefined;
|
|
53
|
+
});
|
|
54
|
+
}, this.opts.heartbeatIntervalMs);
|
|
55
|
+
// Do not hold the event loop open on the heartbeat alone.
|
|
56
|
+
this.timer.unref?.();
|
|
57
|
+
}
|
|
58
|
+
async stop() {
|
|
59
|
+
this.stopped = true;
|
|
60
|
+
if (this.timer) {
|
|
61
|
+
clearInterval(this.timer);
|
|
62
|
+
this.timer = undefined;
|
|
63
|
+
}
|
|
64
|
+
// Let an in-flight renewal settle so it cannot outlive the block that owns
|
|
65
|
+
// the lease and renew something the caller has already released.
|
|
66
|
+
if (this.inFlight)
|
|
67
|
+
await this.inFlight.catch(() => { });
|
|
68
|
+
}
|
|
69
|
+
notifyLost(detail) {
|
|
70
|
+
if (this.lost)
|
|
71
|
+
return;
|
|
72
|
+
this.lost = true;
|
|
73
|
+
this.lostDetail = detail;
|
|
74
|
+
void this.stop();
|
|
75
|
+
try {
|
|
76
|
+
this.opts.onLeaseLost?.(detail);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// A caller's callback must not take down the heartbeat.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* True once the lease can no longer be assumed held.
|
|
84
|
+
*
|
|
85
|
+
* A failed renewal is not itself proof of loss: the RPC failing means we could
|
|
86
|
+
* not confirm the lease, while the engine may still hold it until the TTL runs
|
|
87
|
+
* out. Declaring loss on an error count would abort work that still holds a
|
|
88
|
+
* perfectly valid lease, so loss is declared on elapsed time since the last
|
|
89
|
+
* *confirmed* renewal.
|
|
90
|
+
*/
|
|
91
|
+
ttlExhausted() {
|
|
92
|
+
return Date.now() - this.lastConfirmed >= this.opts.leaseTtlMs;
|
|
93
|
+
}
|
|
94
|
+
async beat() {
|
|
95
|
+
if (this.stopped || this.lost)
|
|
96
|
+
return;
|
|
97
|
+
try {
|
|
98
|
+
const resp = await this.opts.client.renewLease(this.opts.agentId, this.opts.idempotencyKey, this.opts.fencingToken, this.opts.extendMs,
|
|
99
|
+
// Bound the RPC. Without a deadline a black-holed connection parks the
|
|
100
|
+
// renewal forever and the lease silently ages out while we wait on it.
|
|
101
|
+
Math.max(1, Math.ceil(this.opts.heartbeatIntervalMs / 1000)));
|
|
102
|
+
if (!resp.renewed) {
|
|
103
|
+
this.notifyLost(renewFailureDetail(resp.failureReason));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
this.lastConfirmed = Date.now();
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
if (this.ttlExhausted()) {
|
|
110
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
111
|
+
this.notifyLost(`could not be renewed for ${this.opts.leaseTtlMs}ms (last error: ${msg})`);
|
|
112
|
+
}
|
|
113
|
+
// Otherwise: transient. The engine may still be holding it for us.
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
//# sourceMappingURL=lease.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lease.js","sourceRoot":"","sources":["../src/lease.ts"],"names":[],"mappings":"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"}
|
package/dist/tool.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { IdempotencyScope } from "./idempotency.js";
|
|
2
|
+
/**
|
|
3
|
+
* Raised when the engine refuses a lease because another agent already owns the
|
|
4
|
+
* graph position this call intends to write to.
|
|
5
|
+
*
|
|
6
|
+
* This is the refusal arriving *before* the side effect, which is the point: the
|
|
7
|
+
* alternative is discovering the divergence at commit time, after the money has
|
|
8
|
+
* moved.
|
|
9
|
+
*/
|
|
10
|
+
export declare class DivergentStepError extends Error {
|
|
11
|
+
readonly cause?: unknown;
|
|
12
|
+
constructor(message: string, cause?: unknown);
|
|
13
|
+
}
|
|
14
|
+
export interface ToolOptions {
|
|
15
|
+
/**
|
|
16
|
+
* Overrides key derivation entirely. Supply this when the operation's identity
|
|
17
|
+
* is a business fact you already have, such as `charge:${orderId}`.
|
|
18
|
+
*/
|
|
19
|
+
idempotencyKey?: string;
|
|
20
|
+
/** Identifies the calling agent. Read by AGENT_PRIVATE and STEP_LOCAL scopes. */
|
|
21
|
+
agentId?: string;
|
|
22
|
+
/** Defaults to the function's name. Required for anonymous functions. */
|
|
23
|
+
toolName?: string;
|
|
24
|
+
scope?: IdempotencyScope;
|
|
25
|
+
/**
|
|
26
|
+
* Restricts key derivation to these keys of the tool's single object argument.
|
|
27
|
+
*
|
|
28
|
+
* Use when several agents must converge on one side effect while disagreeing
|
|
29
|
+
* about everything else they pass. A tool using this must take one options
|
|
30
|
+
* object, because JavaScript has no named arguments to select from.
|
|
31
|
+
*/
|
|
32
|
+
sharedOn?: readonly string[];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Wraps a function so the engine runs it at most once per idempotency key, and
|
|
36
|
+
* so its result survives the process that produced it.
|
|
37
|
+
*
|
|
38
|
+
* A second caller deriving the same key does not run the body. It receives what
|
|
39
|
+
* the first call returned, even if that process has since died.
|
|
40
|
+
*
|
|
41
|
+
* ```ts
|
|
42
|
+
* const chargeCard = tool(
|
|
43
|
+
* async ({ orderId, cents }: { orderId: string; cents: number }) =>
|
|
44
|
+
* gateway.charge(orderId, cents),
|
|
45
|
+
* { toolName: "chargeCard" },
|
|
46
|
+
* );
|
|
47
|
+
*
|
|
48
|
+
* await durableTools({ configurable: { thread_id: "ticket-4417" } }, async () => {
|
|
49
|
+
* await chargeCard({ orderId: "ORD-1", cents: 1999 });
|
|
50
|
+
* });
|
|
51
|
+
* ```
|
|
52
|
+
*
|
|
53
|
+
* Must be called inside {@link durableTools}, which supplies the session.
|
|
54
|
+
*/
|
|
55
|
+
export declare function tool<A extends unknown[], R>(fn: (...args: A) => R | Promise<R>, options?: ToolOptions): (...args: A) => Promise<R>;
|
|
56
|
+
/** `step` and `tool` are the same mechanism, kept distinct for readability. */
|
|
57
|
+
export declare const step: typeof tool;
|
|
58
|
+
//# sourceMappingURL=tool.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tool.d.ts","sourceRoot":"","sources":["../src/tool.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,gBAAgB,EAAwB,MAAM,kBAAkB,CAAC;AAI1E;;;;;;;GAOG;AACH,qBAAa,kBAAmB,SAAQ,KAAK;IACd,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO;IAArD,YAAY,OAAO,EAAE,MAAM,EAAW,KAAK,CAAC,EAAE,OAAO,EAGpD;CACF;AAED,MAAM,WAAW,WAAW;IAC1B;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,iFAAiF;IACjF,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,yEAAyE;IACzE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,gBAAgB,CAAC;IACzB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC9B;AAID;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,IAAI,CAAC,CAAC,SAAS,OAAO,EAAE,EAAE,CAAC,EACzC,EAAE,EAAE,CAAC,GAAG,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,EAClC,OAAO,GAAE,WAAgB,GACxB,CAAC,GAAG,IAAI,EAAE,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,CAiK5B;AAED,+EAA+E;AAC/E,eAAO,MAAM,IAAI,aAAO,CAAC"}
|