@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
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
|
package/dist/tool.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tool.js","sourceRoot":"","sources":["../src/tool.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAC;AAC/D,OAAO,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AACzD,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC1C,OAAO,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AAC1E,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAC5C,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAEjD;;;;;;;GAOG;AACH,MAAM,OAAO,kBAAmB,SAAQ,KAAK;IACL,KAAK;IAA3C,YAAY,OAAe,EAAW,KAAe;QACnD,KAAK,CAAC,OAAO,CAAC,CAAC;qBADqB,KAAK;QAEzC,IAAI,CAAC,IAAI,GAAG,oBAAoB,CAAC;IACnC,CAAC;CACF;AAuBD,MAAM,KAAK,GAAG,CAAC,EAAU,EAAE,EAAE,CAAC,IAAI,OAAO,CAAO,CAAC,CAAC,EAAE,EAAE,CAAC,UAAU,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC;AAE1E;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,UAAU,IAAI,CAClB,EAAkC,EAClC,OAAO,GAAgB,EAAE;IAEzB,MAAM,EACJ,cAAc,EACd,OAAO,GAAG,SAAS,EACnB,KAAK,GAAG,gBAAgB,CAAC,YAAY,EACrC,QAAQ,GACT,GAAG,OAAO,CAAC;IAEZ,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,IAAI,EAAE,CAAC,IAAI,CAAC;IAC7C,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,MAAM,IAAI,KAAK,CACb,2EAA2E;YACzE,qEAAqE;YACrE,8CAA8C,CACjD,CAAC;IACJ,CAAC;IACD,IAAI,QAAQ,KAAK,SAAS,IAAI,KAAK,KAAK,gBAAgB,CAAC,MAAM,EAAE,CAAC;QAChE,MAAM,IAAI,KAAK,CACb,0EAA0E;YACxE,wEAAwE;YACxE,qDAAqD,CACxD,CAAC;IACJ,CAAC;IACD,IAAI,QAAQ,KAAK,SAAS,IAAI,cAAc,EAAE,CAAC;QAC7C,MAAM,IAAI,KAAK,CACb,2EAA2E;YACzE,0CAA0C,CAC7C,CAAC;IACJ,CAAC;IAED,OAAO,KAAK,UAAU,UAAU,CAAC,GAAG,IAAO;QACzC,MAAM,GAAG,GAAG,UAAU,EAAE,CAAC;QAEzB,0EAA0E;QAC1E,yEAAyE;QACzE,6EAA6E;QAC7E,8EAA8E;QAC9E,GAAG,CAAC,iBAAiB,EAAE,CAAC;QACxB,GAAG,CAAC,QAAQ,IAAI,CAAC,CAAC;QAClB,MAAM,GAAG,GAAG,GAAG,CAAC,QAAQ,CAAC;QAEzB,IAAI,IAAI,GAAG,cAAc,CAAC;QAC1B,IAAI,CAAC,IAAI,EAAE,CAAC;YACV,MAAM,MAAM,GACV,QAAQ,KAAK,SAAS,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,IAAI,aAAa,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;gBACnE,CAAC,CAAE,IAAI,CAAC,CAAC,CAA6B;gBACtC,CAAC,CAAC,EAAE,CAAC;YACT,IAAI,QAAQ,KAAK,SAAS,IAAI,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;gBAC/D,MAAM,IAAI,KAAK,CACb,SAAS,QAAQ,mDAAmD;oBAClE,oEAAoE;oBACpE,qCAAqC,CACxC,CAAC;YACJ,CAAC;YACD,IAAI,GAAG,oBAAoB,CAAC;gBAC1B,SAAS,EAAE,GAAG,CAAC,SAAS;gBACxB,eAAe,EAAE,GAAG,CAAC,eAAe;gBACpC,YAAY,EAAE,GAAG;gBACjB,OAAO;gBACP,QAAQ;gBACR,KAAK;gBACL,cAAc,EAAE,GAAG,CAAC,cAAc;gBAClC,IAAI,EAAE,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI;gBACxC,MAAM;gBACN,QAAQ;aACT,CAAC,CAAC;QACL,CAAC;QAED,IAAI,YAAY,GAAG,CAAC,CAAC;QACrB,IAAI,EAA8B,CAAC;QAEnC,4EAA4E;QAC5E,6DAA6D;QAC7D,SAAS,CAAC;YACR,IAAI,IAAI,CAAC;YACT,IAAI,CAAC;gBACH,IAAI,GAAG,MAAM,GAAG,CAAC,MAAM,CAAC,qBAAqB,CAC3C,OAAO,EACP,IAAI,EACJ,CAAC,EACD,SAAS,EACT,GAAG,CAAC,SAAS;gBACb,qEAAqE;gBACrE,oEAAoE;gBACpE,cAAc;gBACd,GAAG,CACJ,CAAC;YACJ,CAAC;YAAC,OAAO,GAAG,EAAE,CAAC;gBACb,IAAI,oBAAoB,CAAC,GAAG,CAAC,EAAE,CAAC;oBAC9B,MAAM,IAAI,kBAAkB,CAC1B,SAAS,QAAQ,iBAAiB,GAAG,8BAA8B;wBACjE,yDAAyD,EAC3D,GAAG,CACJ,CAAC;gBACJ,CAAC;gBACD,MAAM,GAAG,CAAC;YACZ,CAAC;YAED,IAAI,IAAI,CAAC,MAAM,KAAK,WAAW,CAAC,GAAG,EAAE,CAAC;gBACpC,kEAAkE;gBAClE,yCAAyC;gBACzC,IAAI,IAAI,CAAC,eAAe,KAAK,SAAS,EAAE,CAAC;oBACvC,GAAG,CAAC,oBAAoB,CAAC,MAAM,CAAC,IAAI,CAAC,eAAe,CAAC,CAAC,CAAC;gBACzD,CAAC;gBACD,MAAM,OAAO,GAAG,IAAI,CAAC,YAAY,EAAE,aAAa,CAAC;gBACjD,IAAI,OAAO,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;oBAClC,MAAM,QAAQ,GAAG,WAAW,CAAC,OAAO,CAAmB,CAAC;oBACxD,OAAO,QAAQ,EAAE,MAAW,CAAC;gBAC/B,CAAC;gBACD,OAAO,SAAc,CAAC;YACxB,CAAC;YAED,IAAI,IAAI,CAAC,MAAM,KAAK,WAAW,CAAC,WAAW,EAAE,CAAC;gBAC5C,MAAM,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,YAAY,IAAI,KAAK,CAAC,CAAC,CAAC;gBAChD,SAAS;YACX,CAAC;YAED,IAAI,IAAI,CAAC,MAAM,KAAK,WAAW,CAAC,QAAQ,EAAE,CAAC;gBACzC,YAAY,GAAG,MAAM,CAAC,IAAI,CAAC,YAAY,IAAI,EAAE,CAAC,CAAC;gBAC/C,MAAM,UAAU,GAAG,MAAM,CAAC,IAAI,CAAC,mBAAmB,IAAI,KAAK,CAAC,CAAC;gBAC7D,EAAE,GAAG,IAAI,cAAc,CAAC;oBACtB,MAAM,EAAE,GAAG,CAAC,MAAM;oBAClB,OAAO;oBACP,cAAc,EAAE,IAAI;oBACpB,YAAY;oBACZ,mBAAmB,EAAE,UAAU;iBAChC,CAAC,CAAC;gBACH,EAAE,CAAC,KAAK,EAAE,CAAC;YACb,CAAC;YACD,MAAM;QACR,CAAC;QAED,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,EAAE,CAAC,GAAG,IAAI,CAAC,CAAC;YACjC,MAAM,GAAG,CAAC,MAAM,CAAC,UAAU,CACzB,GAAG,CAAC,SAAS,EACb,GAAG,EACH,QAAQ,EACR,UAAU,CAAC,OAAO;YAClB,sEAAsE;YACtE,uEAAuE;YACvE,gCAAgC;YAChC,EAAE,MAAM,EAAE,EACV,IAAI,EACJ,YAAY,CACb,CAAC;YACF,OAAO,MAAM,CAAC;QAChB,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,IAAI,YAAY,GAAG,CAAC,EAAE,CAAC;gBACrB,MAAM,GAAG,CAAC,MAAM;qBACb,YAAY,CAAC,OAAO,EAAE,IAAI,EAAE,YAAY,EAAE,YAAY,CAAC;qBACvD,KAAK,CAAC,GAAG,EAAE;oBACV,gEAAgE;oBAChE,oCAAoC;gBACtC,CAAC,CAAC,CAAC;YACP,CAAC;YACD,MAAM,GAAG,CAAC;QACZ,CAAC;gBAAS,CAAC;YACT,MAAM,EAAE,EAAE,IAAI,EAAE,CAAC;QACnB,CAAC;IACH,CAAC,CAAC;AACJ,CAAC;AAED,+EAA+E;AAC/E,MAAM,CAAC,MAAM,IAAI,GAAG,IAAI,CAAC;AAEzB,SAAS,aAAa,CAAC,CAAU;IAC/B,OAAO,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;AAClE,CAAC;AAED;;;GAGG;AACH,SAAS,oBAAoB,CAAC,GAAY;IACxC,MAAM,IAAI,GAAI,GAAsC,EAAE,IAAI,CAAC;IAC3D,OAAO,IAAI,KAAK,CAAC,IAAI,IAAI,KAAK,qBAAqB,CAAC;AACtD,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cellaflow/sdk",
|
|
3
|
-
"version": "0.7.
|
|
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
|
+
}
|