@axlsdk/axl 0.17.6 → 0.17.8
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/README.md +62 -0
- package/dist/index.cjs +938 -74
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +379 -2
- package/dist/index.d.ts +379 -2
- package/dist/index.js +938 -74
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -1535,7 +1535,8 @@ var GEMINI_PRICING = {
|
|
|
1535
1535
|
"gemini-3-flash-preview": [5e-7, 3e-6],
|
|
1536
1536
|
"gemini-3.1-pro-preview": [2e-6, 12e-6],
|
|
1537
1537
|
"gemini-3.1-flash-lite": [25e-8, 15e-7],
|
|
1538
|
-
"gemini-3.1-flash-lite-preview": [25e-8, 15e-7]
|
|
1538
|
+
"gemini-3.1-flash-lite-preview": [25e-8, 15e-7],
|
|
1539
|
+
"gemini-3.5-flash": [15e-7, 9e-6]
|
|
1539
1540
|
};
|
|
1540
1541
|
var GEMINI_PRICING_KEYS_BY_LENGTH = Object.keys(GEMINI_PRICING).sort(
|
|
1541
1542
|
(a, b) => b.length - a.length
|
|
@@ -1775,12 +1776,21 @@ var GeminiProvider = class {
|
|
|
1775
1776
|
*/
|
|
1776
1777
|
mapMessages(messages) {
|
|
1777
1778
|
const toolCallIdToName = /* @__PURE__ */ new Map();
|
|
1779
|
+
const geminiNativeIds = /* @__PURE__ */ new Set();
|
|
1778
1780
|
for (const msg of messages) {
|
|
1779
1781
|
if (msg.role === "assistant" && msg.tool_calls) {
|
|
1780
1782
|
for (const tc of msg.tool_calls) {
|
|
1781
1783
|
toolCallIdToName.set(tc.id, tc.function.name);
|
|
1782
1784
|
}
|
|
1783
1785
|
}
|
|
1786
|
+
if (msg.role === "assistant") {
|
|
1787
|
+
const rawParts = msg.providerMetadata?.geminiParts;
|
|
1788
|
+
if (rawParts) {
|
|
1789
|
+
for (const p of rawParts) {
|
|
1790
|
+
if (p.functionCall?.id) geminiNativeIds.add(p.functionCall.id);
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1784
1794
|
}
|
|
1785
1795
|
const result = [];
|
|
1786
1796
|
for (const msg of messages) {
|
|
@@ -1821,16 +1831,16 @@ var GeminiProvider = class {
|
|
|
1821
1831
|
} catch {
|
|
1822
1832
|
responseData = { result: msg.content };
|
|
1823
1833
|
}
|
|
1834
|
+
const functionResponse = {
|
|
1835
|
+
name: functionName,
|
|
1836
|
+
response: responseData
|
|
1837
|
+
};
|
|
1838
|
+
if (msg.tool_call_id && geminiNativeIds.has(msg.tool_call_id)) {
|
|
1839
|
+
functionResponse.id = msg.tool_call_id;
|
|
1840
|
+
}
|
|
1824
1841
|
result.push({
|
|
1825
1842
|
role: "user",
|
|
1826
|
-
parts: [
|
|
1827
|
-
{
|
|
1828
|
-
functionResponse: {
|
|
1829
|
-
name: functionName,
|
|
1830
|
-
response: responseData
|
|
1831
|
-
}
|
|
1832
|
-
}
|
|
1833
|
-
]
|
|
1843
|
+
parts: [{ functionResponse }]
|
|
1834
1844
|
});
|
|
1835
1845
|
} else if (msg.role === "user") {
|
|
1836
1846
|
result.push({ role: "user", parts: [{ text: msg.content }] });
|
|
@@ -1898,7 +1908,7 @@ var GeminiProvider = class {
|
|
|
1898
1908
|
content += part.text;
|
|
1899
1909
|
} else if (part.functionCall) {
|
|
1900
1910
|
toolCalls.push({
|
|
1901
|
-
id: `call_${this.callCounter++}`,
|
|
1911
|
+
id: part.functionCall.id || `call_${this.callCounter++}`,
|
|
1902
1912
|
type: "function",
|
|
1903
1913
|
function: {
|
|
1904
1914
|
name: part.functionCall.name,
|
|
@@ -1978,7 +1988,7 @@ var GeminiProvider = class {
|
|
|
1978
1988
|
} else if (part.functionCall) {
|
|
1979
1989
|
yield {
|
|
1980
1990
|
type: "tool_call_delta",
|
|
1981
|
-
id: `call_${this.callCounter++}`,
|
|
1991
|
+
id: part.functionCall.id || `call_${this.callCounter++}`,
|
|
1982
1992
|
name: part.functionCall.name,
|
|
1983
1993
|
arguments: JSON.stringify(part.functionCall.args)
|
|
1984
1994
|
};
|
|
@@ -6082,8 +6092,31 @@ ${summaryResponse.content}`
|
|
|
6082
6092
|
type: "await_human",
|
|
6083
6093
|
data: { channel: options.channel, prompt: options.prompt }
|
|
6084
6094
|
});
|
|
6085
|
-
const
|
|
6095
|
+
const makeAbortError = (reason) => {
|
|
6096
|
+
if (typeof DOMException !== "undefined") {
|
|
6097
|
+
return new DOMException(
|
|
6098
|
+
typeof reason === "string" ? reason : "awaitHuman aborted",
|
|
6099
|
+
"AbortError"
|
|
6100
|
+
);
|
|
6101
|
+
}
|
|
6102
|
+
const err = new Error(typeof reason === "string" ? reason : "awaitHuman aborted");
|
|
6103
|
+
err.name = "AbortError";
|
|
6104
|
+
return err;
|
|
6105
|
+
};
|
|
6106
|
+
const decision = await new Promise((resolve2, reject) => {
|
|
6107
|
+
if (this.signal?.aborted) {
|
|
6108
|
+
reject(makeAbortError(this.signal.reason));
|
|
6109
|
+
return;
|
|
6110
|
+
}
|
|
6086
6111
|
this.pendingDecisions.set(this.executionId, resolve2);
|
|
6112
|
+
const onAbort = () => {
|
|
6113
|
+
const current = this.pendingDecisions.get(this.executionId);
|
|
6114
|
+
if (current === resolve2) {
|
|
6115
|
+
this.pendingDecisions.delete(this.executionId);
|
|
6116
|
+
}
|
|
6117
|
+
reject(makeAbortError(this.signal?.reason));
|
|
6118
|
+
};
|
|
6119
|
+
this.signal?.addEventListener("abort", onAbort, { once: true });
|
|
6087
6120
|
});
|
|
6088
6121
|
this.emitEvent({
|
|
6089
6122
|
type: "await_human_resolved",
|
|
@@ -6708,6 +6741,16 @@ var MemoryStore = class {
|
|
|
6708
6741
|
const result = limit ? sorted.slice(0, limit) : sorted;
|
|
6709
6742
|
return result.map((e) => structuredClone(e));
|
|
6710
6743
|
}
|
|
6744
|
+
async deleteExecution(executionId) {
|
|
6745
|
+
const removed = this.executionHistory.delete(executionId);
|
|
6746
|
+
this.checkpoints.delete(executionId);
|
|
6747
|
+
this.executionStates.delete(executionId);
|
|
6748
|
+
this.streamingEvents.delete(executionId);
|
|
6749
|
+
if (this.decisions.delete(executionId)) {
|
|
6750
|
+
this.persistAwaitHumanState();
|
|
6751
|
+
}
|
|
6752
|
+
return removed;
|
|
6753
|
+
}
|
|
6711
6754
|
// ── Eval History ──────────────────────────────────────────────────
|
|
6712
6755
|
async saveEvalResult(entry) {
|
|
6713
6756
|
this.evalHistory.set(entry.id, structuredClone(entry));
|
|
@@ -6724,6 +6767,30 @@ var MemoryStore = class {
|
|
|
6724
6767
|
async listSessions() {
|
|
6725
6768
|
return [...this.sessions.keys()];
|
|
6726
6769
|
}
|
|
6770
|
+
// ── Streaming events (for state.persist: 'streaming') ────────────────
|
|
6771
|
+
//
|
|
6772
|
+
// MemoryStore's raison d'être is in-process / ephemeral. We implement
|
|
6773
|
+
// the streaming methods so tests and dev workflows behave correctly,
|
|
6774
|
+
// but on a process crash these buffers are lost — same as the rest of
|
|
6775
|
+
// MemoryStore's state. For real crash-survival, use RedisStore or
|
|
6776
|
+
// SQLiteStore.
|
|
6777
|
+
streamingEvents = /* @__PURE__ */ new Map();
|
|
6778
|
+
async appendStreamingEvents(executionId, events) {
|
|
6779
|
+
if (events.length === 0) return;
|
|
6780
|
+
const existing = this.streamingEvents.get(executionId) ?? [];
|
|
6781
|
+
existing.push(...events.map((e) => structuredClone(e)));
|
|
6782
|
+
this.streamingEvents.set(executionId, existing);
|
|
6783
|
+
}
|
|
6784
|
+
async finalizeStreamingEvents(executionId) {
|
|
6785
|
+
this.streamingEvents.delete(executionId);
|
|
6786
|
+
}
|
|
6787
|
+
async listStreamingExecutions() {
|
|
6788
|
+
return [...this.streamingEvents.keys()];
|
|
6789
|
+
}
|
|
6790
|
+
async getStreamingEvents(executionId) {
|
|
6791
|
+
const events = this.streamingEvents.get(executionId) ?? [];
|
|
6792
|
+
return events.map((e) => structuredClone(e));
|
|
6793
|
+
}
|
|
6727
6794
|
// ── Lifecycle ──────────────────────────────────────────────────────
|
|
6728
6795
|
async close() {
|
|
6729
6796
|
}
|
|
@@ -6780,7 +6847,7 @@ function safeJsonParse(data) {
|
|
|
6780
6847
|
}
|
|
6781
6848
|
}
|
|
6782
6849
|
function rowToExecutionInfo(row) {
|
|
6783
|
-
|
|
6850
|
+
const info = {
|
|
6784
6851
|
executionId: row.execution_id,
|
|
6785
6852
|
workflow: row.workflow,
|
|
6786
6853
|
status: row.status,
|
|
@@ -6791,6 +6858,13 @@ function rowToExecutionInfo(row) {
|
|
|
6791
6858
|
error: row.error ?? void 0,
|
|
6792
6859
|
events: safeJsonParse(row.events) ?? []
|
|
6793
6860
|
};
|
|
6861
|
+
if (row.metadata != null) {
|
|
6862
|
+
const parsed = safeJsonParse(row.metadata);
|
|
6863
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
6864
|
+
info.metadata = parsed;
|
|
6865
|
+
}
|
|
6866
|
+
}
|
|
6867
|
+
return info;
|
|
6794
6868
|
}
|
|
6795
6869
|
var SQLiteStore = class {
|
|
6796
6870
|
db;
|
|
@@ -6826,6 +6900,10 @@ var SQLiteStore = class {
|
|
|
6826
6900
|
* numeric step values become stringified names ("0", "1", …) to
|
|
6827
6901
|
* preserve replay continuity for in-flight executions across the
|
|
6828
6902
|
* upgrade boundary.
|
|
6903
|
+
* - v3 (ExecutionInfo.metadata): add `execution_history.metadata`
|
|
6904
|
+
* column (TEXT, nullable, JSON-serialized) so caller-supplied
|
|
6905
|
+
* `ExecuteOptions.metadata` round-trips through history. Existing
|
|
6906
|
+
* rows get `NULL`, deserialized as `metadata: undefined`.
|
|
6829
6907
|
*
|
|
6830
6908
|
* Applied BEFORE `initTables()` so the subsequent `CREATE TABLE
|
|
6831
6909
|
* IF NOT EXISTS` runs against the post-migration column name. Fresh
|
|
@@ -6843,7 +6921,7 @@ var SQLiteStore = class {
|
|
|
6843
6921
|
* transactional re-read costs nothing.
|
|
6844
6922
|
*/
|
|
6845
6923
|
migrate() {
|
|
6846
|
-
const TARGET_VERSION =
|
|
6924
|
+
const TARGET_VERSION = 3;
|
|
6847
6925
|
const current = this.db.pragma("user_version", { simple: true });
|
|
6848
6926
|
if (current >= TARGET_VERSION) return;
|
|
6849
6927
|
this.db.exec("BEGIN IMMEDIATE");
|
|
@@ -6865,6 +6943,12 @@ var SQLiteStore = class {
|
|
|
6865
6943
|
this.db.exec("ALTER TABLE checkpoints RENAME COLUMN step TO name");
|
|
6866
6944
|
}
|
|
6867
6945
|
}
|
|
6946
|
+
if (committed < 3) {
|
|
6947
|
+
const cols = this.db.pragma("table_info(execution_history)");
|
|
6948
|
+
if (cols.length > 0 && !cols.some((c) => c.name === "metadata")) {
|
|
6949
|
+
this.db.exec("ALTER TABLE execution_history ADD COLUMN metadata TEXT");
|
|
6950
|
+
}
|
|
6951
|
+
}
|
|
6868
6952
|
this.db.pragma(`user_version = ${TARGET_VERSION}`);
|
|
6869
6953
|
this.db.exec("COMMIT");
|
|
6870
6954
|
} catch (err) {
|
|
@@ -6927,7 +7011,8 @@ var SQLiteStore = class {
|
|
|
6927
7011
|
completed_at INTEGER,
|
|
6928
7012
|
duration INTEGER NOT NULL DEFAULT 0,
|
|
6929
7013
|
error TEXT,
|
|
6930
|
-
events TEXT NOT NULL
|
|
7014
|
+
events TEXT NOT NULL,
|
|
7015
|
+
metadata TEXT
|
|
6931
7016
|
);
|
|
6932
7017
|
|
|
6933
7018
|
CREATE TABLE IF NOT EXISTS eval_history (
|
|
@@ -7048,7 +7133,7 @@ var SQLiteStore = class {
|
|
|
7048
7133
|
// ── Execution History ────────────────────────────────────────────────────
|
|
7049
7134
|
async saveExecution(execution) {
|
|
7050
7135
|
this.db.prepare(
|
|
7051
|
-
"INSERT OR REPLACE INTO execution_history (execution_id, workflow, status, total_cost, started_at, completed_at, duration, error, events) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
7136
|
+
"INSERT OR REPLACE INTO execution_history (execution_id, workflow, status, total_cost, started_at, completed_at, duration, error, events, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
7052
7137
|
).run(
|
|
7053
7138
|
execution.executionId,
|
|
7054
7139
|
execution.workflow,
|
|
@@ -7058,7 +7143,8 @@ var SQLiteStore = class {
|
|
|
7058
7143
|
execution.completedAt ?? null,
|
|
7059
7144
|
execution.duration,
|
|
7060
7145
|
execution.error ?? null,
|
|
7061
|
-
JSON.stringify(execution.events)
|
|
7146
|
+
JSON.stringify(execution.events),
|
|
7147
|
+
execution.metadata !== void 0 ? JSON.stringify(execution.metadata) : null
|
|
7062
7148
|
);
|
|
7063
7149
|
}
|
|
7064
7150
|
async getExecution(executionId) {
|
|
@@ -7070,6 +7156,16 @@ var SQLiteStore = class {
|
|
|
7070
7156
|
const rows = limit ? this.db.prepare(sql).all(limit) : this.db.prepare(sql).all();
|
|
7071
7157
|
return rows.map(rowToExecutionInfo);
|
|
7072
7158
|
}
|
|
7159
|
+
async deleteExecution(executionId) {
|
|
7160
|
+
const tx = this.db.transaction(() => {
|
|
7161
|
+
const result = this.db.prepare("DELETE FROM execution_history WHERE execution_id = ?").run(executionId);
|
|
7162
|
+
this.db.prepare("DELETE FROM checkpoints WHERE execution_id = ?").run(executionId);
|
|
7163
|
+
this.db.prepare("DELETE FROM execution_state WHERE execution_id = ?").run(executionId);
|
|
7164
|
+
this.db.prepare("DELETE FROM decisions WHERE execution_id = ?").run(executionId);
|
|
7165
|
+
return result.changes > 0;
|
|
7166
|
+
});
|
|
7167
|
+
return tx();
|
|
7168
|
+
}
|
|
7073
7169
|
// ── Eval History ────────────────────────────────────────────────────────
|
|
7074
7170
|
async saveEvalResult(entry) {
|
|
7075
7171
|
this.db.prepare(
|
|
@@ -7948,6 +8044,7 @@ var MemoryManager = class {
|
|
|
7948
8044
|
if (stateStore.saveMemory) {
|
|
7949
8045
|
await stateStore.saveMemory(storeScope, key, value);
|
|
7950
8046
|
} else {
|
|
8047
|
+
warnMissingMemoryMethodsOnce(stateStore);
|
|
7951
8048
|
const storeKey = `memory:${storeScope}:${key}`;
|
|
7952
8049
|
await stateStore.saveSessionMeta(storeKey, "value", value);
|
|
7953
8050
|
if (options?.metadata) {
|
|
@@ -8050,6 +8147,14 @@ var MemoryManager = class {
|
|
|
8050
8147
|
}
|
|
8051
8148
|
}
|
|
8052
8149
|
};
|
|
8150
|
+
var _warnedStoresMissingMemory = /* @__PURE__ */ new WeakSet();
|
|
8151
|
+
function warnMissingMemoryMethodsOnce(stateStore) {
|
|
8152
|
+
if (_warnedStoresMissingMemory.has(stateStore)) return;
|
|
8153
|
+
_warnedStoresMissingMemory.add(stateStore);
|
|
8154
|
+
console.warn(
|
|
8155
|
+
"[axl] ctx.remember() against a StateStore without saveMemory/getMemory/getAllMemory/deleteMemory \u2014 falling back to a sessionMeta-based path that drops metadata and is not enumerable. Implement the four optional memory methods on your StateStore for full fidelity. This warning fires once per offending store."
|
|
8156
|
+
);
|
|
8157
|
+
}
|
|
8053
8158
|
|
|
8054
8159
|
// src/telemetry/noop.ts
|
|
8055
8160
|
var NOOP_SPAN = {
|
|
@@ -8091,11 +8196,141 @@ function hashInput(input) {
|
|
|
8091
8196
|
return (hash >>> 0).toString(16);
|
|
8092
8197
|
}
|
|
8093
8198
|
var DEFAULT_MAX_EVENTS_PER_EXECUTION = 5e4;
|
|
8199
|
+
var DEFAULT_STREAMING_BATCH_SIZE = 100;
|
|
8200
|
+
var DEFAULT_STREAMING_BATCH_INTERVAL = 1e3;
|
|
8201
|
+
var RECOVERED_UNKNOWN_WORKFLOW = "__axl/recovered";
|
|
8202
|
+
var STREAMING_EXCLUDED_TYPES = /* @__PURE__ */ new Set([
|
|
8203
|
+
"token",
|
|
8204
|
+
"partial_object",
|
|
8205
|
+
"string_delta"
|
|
8206
|
+
]);
|
|
8207
|
+
var INTERNAL_METADATA_KEYS = /* @__PURE__ */ new Set([
|
|
8208
|
+
"sessionId",
|
|
8209
|
+
"sessionHistory",
|
|
8210
|
+
"resumeMode"
|
|
8211
|
+
]);
|
|
8212
|
+
function liftPersistedMetadata(metadata) {
|
|
8213
|
+
if (!metadata) return void 0;
|
|
8214
|
+
const filtered = {};
|
|
8215
|
+
let hasAny = false;
|
|
8216
|
+
for (const [k, v] of Object.entries(metadata)) {
|
|
8217
|
+
if (INTERNAL_METADATA_KEYS.has(k)) continue;
|
|
8218
|
+
filtered[k] = v;
|
|
8219
|
+
hasAny = true;
|
|
8220
|
+
}
|
|
8221
|
+
if (!hasAny) return void 0;
|
|
8222
|
+
try {
|
|
8223
|
+
return structuredClone(filtered);
|
|
8224
|
+
} catch {
|
|
8225
|
+
return { ...filtered };
|
|
8226
|
+
}
|
|
8227
|
+
}
|
|
8228
|
+
var StreamingFlusher = class {
|
|
8229
|
+
constructor(store, batchSize, batchInterval) {
|
|
8230
|
+
this.store = store;
|
|
8231
|
+
this.batchSize = batchSize;
|
|
8232
|
+
this.batchInterval = batchInterval;
|
|
8233
|
+
}
|
|
8234
|
+
buffers = /* @__PURE__ */ new Map();
|
|
8235
|
+
timers = /* @__PURE__ */ new Map();
|
|
8236
|
+
/**
|
|
8237
|
+
* Tracks the most recent in-flight `appendStreamingEvents` per execution
|
|
8238
|
+
* so `finalize` can await it. Without this, a size-triggered fire-and-
|
|
8239
|
+
* forget flush whose RPUSH lands AFTER `finalize`'s DEL would resurrect
|
|
8240
|
+
* the buffer + re-register the execution in the streaming-exec-ids set
|
|
8241
|
+
* — a phantom orphan that recoverIncompleteStreams would later mis-recover.
|
|
8242
|
+
*/
|
|
8243
|
+
inflight = /* @__PURE__ */ new Map();
|
|
8244
|
+
append(executionId, event) {
|
|
8245
|
+
if (STREAMING_EXCLUDED_TYPES.has(event.type)) return;
|
|
8246
|
+
let buffer = this.buffers.get(executionId);
|
|
8247
|
+
if (!buffer) {
|
|
8248
|
+
buffer = [];
|
|
8249
|
+
this.buffers.set(executionId, buffer);
|
|
8250
|
+
}
|
|
8251
|
+
buffer.push(event);
|
|
8252
|
+
if (buffer.length >= this.batchSize) {
|
|
8253
|
+
void this.flush(executionId);
|
|
8254
|
+
return;
|
|
8255
|
+
}
|
|
8256
|
+
if (!this.timers.has(executionId)) {
|
|
8257
|
+
const timer = setTimeout(() => {
|
|
8258
|
+
this.timers.delete(executionId);
|
|
8259
|
+
void this.flush(executionId);
|
|
8260
|
+
}, this.batchInterval);
|
|
8261
|
+
if (typeof timer === "object" && timer !== null && "unref" in timer) {
|
|
8262
|
+
timer.unref?.();
|
|
8263
|
+
}
|
|
8264
|
+
this.timers.set(executionId, timer);
|
|
8265
|
+
}
|
|
8266
|
+
}
|
|
8267
|
+
/** Flush this execution's buffer immediately. Awaits any prior in-flight
|
|
8268
|
+
* flush for the same execution so writes land in order. */
|
|
8269
|
+
async flush(executionId) {
|
|
8270
|
+
const buffer = this.buffers.get(executionId);
|
|
8271
|
+
if (!buffer || buffer.length === 0) return;
|
|
8272
|
+
this.buffers.set(executionId, []);
|
|
8273
|
+
const timer = this.timers.get(executionId);
|
|
8274
|
+
if (timer) {
|
|
8275
|
+
clearTimeout(timer);
|
|
8276
|
+
this.timers.delete(executionId);
|
|
8277
|
+
}
|
|
8278
|
+
const prior = this.inflight.get(executionId) ?? Promise.resolve();
|
|
8279
|
+
const next = prior.then(async () => {
|
|
8280
|
+
try {
|
|
8281
|
+
await this.store.appendStreamingEvents?.(executionId, buffer);
|
|
8282
|
+
} catch (err) {
|
|
8283
|
+
console.error(
|
|
8284
|
+
`[axl] StreamingFlusher: failed to append ${buffer.length} events for executionId=${executionId}: ${err instanceof Error ? err.message : String(err)}`
|
|
8285
|
+
);
|
|
8286
|
+
}
|
|
8287
|
+
});
|
|
8288
|
+
this.inflight.set(executionId, next);
|
|
8289
|
+
next.finally(() => {
|
|
8290
|
+
if (this.inflight.get(executionId) === next) {
|
|
8291
|
+
this.inflight.delete(executionId);
|
|
8292
|
+
}
|
|
8293
|
+
});
|
|
8294
|
+
return next;
|
|
8295
|
+
}
|
|
8296
|
+
/** Finalize an execution — flush remaining buffer + release store-side buffer.
|
|
8297
|
+
* Critically, awaits the most recent in-flight `appendStreamingEvents` BEFORE
|
|
8298
|
+
* calling `finalizeStreamingEvents`, so a late-landing RPUSH can't resurrect
|
|
8299
|
+
* the buffer after we've deleted it. */
|
|
8300
|
+
async finalize(executionId) {
|
|
8301
|
+
await this.flush(executionId);
|
|
8302
|
+
const pending = this.inflight.get(executionId);
|
|
8303
|
+
if (pending) {
|
|
8304
|
+
await pending;
|
|
8305
|
+
this.inflight.delete(executionId);
|
|
8306
|
+
}
|
|
8307
|
+
this.buffers.delete(executionId);
|
|
8308
|
+
const timer = this.timers.get(executionId);
|
|
8309
|
+
if (timer) {
|
|
8310
|
+
clearTimeout(timer);
|
|
8311
|
+
this.timers.delete(executionId);
|
|
8312
|
+
}
|
|
8313
|
+
try {
|
|
8314
|
+
await this.store.finalizeStreamingEvents?.(executionId);
|
|
8315
|
+
} catch (err) {
|
|
8316
|
+
console.error(
|
|
8317
|
+
`[axl] StreamingFlusher: failed to finalize executionId=${executionId}: ` + (err instanceof Error ? err.message : String(err))
|
|
8318
|
+
);
|
|
8319
|
+
}
|
|
8320
|
+
}
|
|
8321
|
+
/** Best-effort drain of every pending buffer. Called from `runtime.shutdown()`. */
|
|
8322
|
+
async flushAll() {
|
|
8323
|
+
const ids = [...this.buffers.keys()];
|
|
8324
|
+
await Promise.all(ids.map((id) => this.flush(id)));
|
|
8325
|
+
const pendings = [...this.inflight.values()];
|
|
8326
|
+
await Promise.allSettled(pendings);
|
|
8327
|
+
}
|
|
8328
|
+
};
|
|
8094
8329
|
function isAbortError(err) {
|
|
8095
8330
|
return typeof err === "object" && err !== null && err.name === "AbortError";
|
|
8096
8331
|
}
|
|
8097
8332
|
function pushEventBounded(execInfo, event, cap) {
|
|
8098
|
-
if (
|
|
8333
|
+
if (STREAMING_EXCLUDED_TYPES.has(event.type)) return;
|
|
8099
8334
|
if (cap === Infinity || execInfo.events.length < cap) {
|
|
8100
8335
|
execInfo.events.push(event);
|
|
8101
8336
|
return;
|
|
@@ -8116,6 +8351,36 @@ function pushEventBounded(execInfo, event, cap) {
|
|
|
8116
8351
|
};
|
|
8117
8352
|
}
|
|
8118
8353
|
}
|
|
8354
|
+
function sanitizeMetadataForPersist(metadata) {
|
|
8355
|
+
const cleaned = {};
|
|
8356
|
+
for (const [k, v] of Object.entries(metadata)) {
|
|
8357
|
+
try {
|
|
8358
|
+
cleaned[k] = structuredClone(v);
|
|
8359
|
+
} catch {
|
|
8360
|
+
}
|
|
8361
|
+
}
|
|
8362
|
+
return cleaned;
|
|
8363
|
+
}
|
|
8364
|
+
function boundRecoveredEvents(events, cap) {
|
|
8365
|
+
if (cap === Infinity || events.length <= cap) return events;
|
|
8366
|
+
const truncated = events.slice(0, cap);
|
|
8367
|
+
const last = truncated[truncated.length - 1];
|
|
8368
|
+
const alreadyTruncated = last?.type === "log" && typeof last.data === "object" && last.data !== null && last.data.event === "events_truncated";
|
|
8369
|
+
if (!alreadyTruncated) {
|
|
8370
|
+
truncated[truncated.length - 1] = {
|
|
8371
|
+
type: "log",
|
|
8372
|
+
executionId: events[0]?.executionId ?? "",
|
|
8373
|
+
step: last?.step ?? 0,
|
|
8374
|
+
timestamp: Date.now(),
|
|
8375
|
+
data: {
|
|
8376
|
+
event: "events_truncated",
|
|
8377
|
+
cap,
|
|
8378
|
+
message: `Recovered ExecutionInfo.events truncated at ${cap} entries. Original streaming buffer had ${events.length} events. Raise via config.state.maxEventsPerExecution.`
|
|
8379
|
+
}
|
|
8380
|
+
};
|
|
8381
|
+
}
|
|
8382
|
+
return truncated;
|
|
8383
|
+
}
|
|
8119
8384
|
var costScopeStorage = new import_node_async_hooks2.AsyncLocalStorage();
|
|
8120
8385
|
function forwardAbortSignal(external, internal) {
|
|
8121
8386
|
if (!external) return () => {
|
|
@@ -8162,6 +8427,30 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
|
|
|
8162
8427
|
* `events` field — keeps the log to one line per row instead of one per
|
|
8163
8428
|
* rebuild tick. */
|
|
8164
8429
|
warnedMalformedExecutions = /* @__PURE__ */ new Set();
|
|
8430
|
+
/** Streaming-mode flusher. `undefined` when persist === 'terminal'
|
|
8431
|
+
* (back-compat default — no buffering overhead, no batching timer). */
|
|
8432
|
+
streamingFlusher;
|
|
8433
|
+
/** Execution IDs that are eligible for streaming-buffer durability —
|
|
8434
|
+
* populated by `execute()` / `stream()`. `createContext()` flows are
|
|
8435
|
+
* deliberately NOT added: ad-hoc contexts (tool tests, Studio playground,
|
|
8436
|
+
* evals) have no terminal `persistExecution` path that would finalize the
|
|
8437
|
+
* streaming buffer, so allowing them to write would leave phantom orphan
|
|
8438
|
+
* buffers that `recoverIncompleteStreams()` later mis-recovers as failed
|
|
8439
|
+
* executions. Membership is removed in `persistExecution` once the
|
|
8440
|
+
* canonical save + finalize chain has been scheduled. */
|
|
8441
|
+
streamableExecutionIds = /* @__PURE__ */ new Set();
|
|
8442
|
+
/** Execution IDs deleted by `runtime.deleteExecution()` while still in
|
|
8443
|
+
* flight. `persistExecution` skips its save + cache write when the
|
|
8444
|
+
* executionId is in this set — preventing a workflow that completes after
|
|
8445
|
+
* a delete from resurrecting the (intentionally-removed) row. Entries are
|
|
8446
|
+
* removed once the would-be persist has been short-circuited. */
|
|
8447
|
+
pendingDeletedExecutions = /* @__PURE__ */ new Set();
|
|
8448
|
+
/** In-flight `persistExecution` chains (save → finalize). `shutdown()`
|
|
8449
|
+
* awaits these before closing the StateStore so a workflow whose abort
|
|
8450
|
+
* triggers a persist-during-shutdown isn't racing a closed connection.
|
|
8451
|
+
* The Set is mutated by the chain's own `.finally` so entries clear as
|
|
8452
|
+
* soon as the work settles. */
|
|
8453
|
+
persistInflight = /* @__PURE__ */ new Set();
|
|
8165
8454
|
constructor(config) {
|
|
8166
8455
|
super();
|
|
8167
8456
|
this.config = resolveConfig(config ?? {});
|
|
@@ -8185,6 +8474,19 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
|
|
|
8185
8474
|
embedder: this.config.memory.embedder
|
|
8186
8475
|
});
|
|
8187
8476
|
}
|
|
8477
|
+
if (this.config.state?.persist === "streaming") {
|
|
8478
|
+
if (this.stateStore.appendStreamingEvents) {
|
|
8479
|
+
this.streamingFlusher = new StreamingFlusher(
|
|
8480
|
+
this.stateStore,
|
|
8481
|
+
this.config.state.streamingBatchSize ?? DEFAULT_STREAMING_BATCH_SIZE,
|
|
8482
|
+
this.config.state.streamingBatchInterval ?? DEFAULT_STREAMING_BATCH_INTERVAL
|
|
8483
|
+
);
|
|
8484
|
+
} else {
|
|
8485
|
+
console.warn(
|
|
8486
|
+
`[axl] config.state.persist === 'streaming' but the configured StateStore does not implement appendStreamingEvents. Streaming durability is disabled. Use RedisStore for crash-survival, or implement the optional streaming methods on your custom StateStore.`
|
|
8487
|
+
);
|
|
8488
|
+
}
|
|
8489
|
+
}
|
|
8188
8490
|
}
|
|
8189
8491
|
/**
|
|
8190
8492
|
* Whether `config.trace.redact` is enabled on this runtime. A narrow
|
|
@@ -8308,14 +8610,49 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
|
|
|
8308
8610
|
/**
|
|
8309
8611
|
* Persist a completed/failed execution to the state store (fire-and-forget)
|
|
8310
8612
|
* and move it from the active executions map to the historical cache.
|
|
8613
|
+
*
|
|
8614
|
+
* Resurrection guard: if `runtime.deleteExecution(id)` was called while
|
|
8615
|
+
* this execution was still running, the id will be in
|
|
8616
|
+
* `pendingDeletedExecutions`. In that case we skip the save AND the
|
|
8617
|
+
* historical-cache write, and finalize the streaming buffer (if any) so
|
|
8618
|
+
* the buffer doesn't outlive the delete.
|
|
8311
8619
|
*/
|
|
8312
8620
|
persistExecution(execInfo) {
|
|
8313
|
-
const
|
|
8621
|
+
const id = execInfo.executionId;
|
|
8622
|
+
this.streamableExecutionIds.delete(id);
|
|
8623
|
+
if (this.pendingDeletedExecutions.has(id)) {
|
|
8624
|
+
this.pendingDeletedExecutions.delete(id);
|
|
8625
|
+
this.executions.delete(id);
|
|
8626
|
+
if (this.streamingFlusher) {
|
|
8627
|
+
this.streamingFlusher.finalize(id).catch(() => {
|
|
8628
|
+
});
|
|
8629
|
+
}
|
|
8630
|
+
return;
|
|
8631
|
+
}
|
|
8632
|
+
let snapshot;
|
|
8633
|
+
try {
|
|
8634
|
+
snapshot = structuredClone(execInfo);
|
|
8635
|
+
} catch {
|
|
8636
|
+
snapshot = {
|
|
8637
|
+
...execInfo,
|
|
8638
|
+
events: [...execInfo.events],
|
|
8639
|
+
metadata: execInfo.metadata ? sanitizeMetadataForPersist(execInfo.metadata) : void 0
|
|
8640
|
+
};
|
|
8641
|
+
}
|
|
8314
8642
|
if (this.stateStore.saveExecution) {
|
|
8315
|
-
this.stateStore.saveExecution(snapshot).
|
|
8643
|
+
const chain = this.stateStore.saveExecution(snapshot).then(() => {
|
|
8644
|
+
return this.streamingFlusher?.finalize(id).catch(() => {
|
|
8645
|
+
});
|
|
8646
|
+
}).catch(() => {
|
|
8647
|
+
}).then(() => void 0);
|
|
8648
|
+
this.persistInflight.add(chain);
|
|
8649
|
+
chain.finally(() => this.persistInflight.delete(chain));
|
|
8650
|
+
} else if (this.streamingFlusher) {
|
|
8651
|
+
const chain = this.streamingFlusher.finalize(id).catch(() => {
|
|
8316
8652
|
});
|
|
8653
|
+
this.persistInflight.add(chain);
|
|
8654
|
+
chain.finally(() => this.persistInflight.delete(chain));
|
|
8317
8655
|
}
|
|
8318
|
-
const id = execInfo.executionId;
|
|
8319
8656
|
this.historicalExecutions.set(id, snapshot);
|
|
8320
8657
|
this.executions.delete(id);
|
|
8321
8658
|
}
|
|
@@ -8555,6 +8892,9 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
|
|
|
8555
8892
|
this.abortControllers.set(executionId, controller);
|
|
8556
8893
|
const cleanupAbortForwarder = forwardAbortSignal(options?.signal, controller);
|
|
8557
8894
|
this.registerWithCostScope(executionId);
|
|
8895
|
+
if (this.streamingFlusher) {
|
|
8896
|
+
this.streamableExecutionIds.add(executionId);
|
|
8897
|
+
}
|
|
8558
8898
|
const execInfo = {
|
|
8559
8899
|
executionId,
|
|
8560
8900
|
workflow: name,
|
|
@@ -8562,7 +8902,8 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
|
|
|
8562
8902
|
events: [],
|
|
8563
8903
|
totalCost: 0,
|
|
8564
8904
|
startedAt: Date.now(),
|
|
8565
|
-
duration: 0
|
|
8905
|
+
duration: 0,
|
|
8906
|
+
metadata: liftPersistedMetadata(options?.metadata)
|
|
8566
8907
|
};
|
|
8567
8908
|
this.executions.set(executionId, execInfo);
|
|
8568
8909
|
const sessionHistory = options?.metadata?.sessionHistory ?? void 0;
|
|
@@ -8578,6 +8919,9 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
|
|
|
8578
8919
|
onTrace: (event) => {
|
|
8579
8920
|
pushEventBounded(execInfo, event, this.maxEventsPerExecution);
|
|
8580
8921
|
execInfo.totalCost += eventCostContribution(event);
|
|
8922
|
+
if (this.streamableExecutionIds.has(executionId)) {
|
|
8923
|
+
this.streamingFlusher?.append(executionId, event);
|
|
8924
|
+
}
|
|
8581
8925
|
this.emit("trace", event);
|
|
8582
8926
|
this.outputAxlEvent(event);
|
|
8583
8927
|
if (event.type === "handoff_start") {
|
|
@@ -8656,6 +9000,9 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
|
|
|
8656
9000
|
const validated = workflow2.inputSchema.parse(input);
|
|
8657
9001
|
const sessionHistory = options?.metadata?.sessionHistory ?? void 0;
|
|
8658
9002
|
this.registerWithCostScope(executionId);
|
|
9003
|
+
if (this.streamingFlusher) {
|
|
9004
|
+
this.streamableExecutionIds.add(executionId);
|
|
9005
|
+
}
|
|
8659
9006
|
execInfo = {
|
|
8660
9007
|
executionId,
|
|
8661
9008
|
workflow: name,
|
|
@@ -8663,7 +9010,8 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
|
|
|
8663
9010
|
events: [],
|
|
8664
9011
|
totalCost: 0,
|
|
8665
9012
|
startedAt: Date.now(),
|
|
8666
|
-
duration: 0
|
|
9013
|
+
duration: 0,
|
|
9014
|
+
metadata: liftPersistedMetadata(options?.metadata)
|
|
8667
9015
|
};
|
|
8668
9016
|
this.executions.set(executionId, execInfo);
|
|
8669
9017
|
const wfCtx = new WorkflowContext({
|
|
@@ -8684,6 +9032,9 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
|
|
|
8684
9032
|
onTrace: (event) => {
|
|
8685
9033
|
pushEventBounded(execInfo, event, this.maxEventsPerExecution);
|
|
8686
9034
|
execInfo.totalCost += eventCostContribution(event);
|
|
9035
|
+
if (this.streamableExecutionIds.has(executionId)) {
|
|
9036
|
+
this.streamingFlusher?.append(executionId, event);
|
|
9037
|
+
}
|
|
8687
9038
|
this.emit("trace", event);
|
|
8688
9039
|
this.outputAxlEvent(event);
|
|
8689
9040
|
axlStream._push(event);
|
|
@@ -8823,6 +9174,15 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
|
|
|
8823
9174
|
errors.push(new Error(`${label}: ${err instanceof Error ? err.message : String(err)}`));
|
|
8824
9175
|
}
|
|
8825
9176
|
};
|
|
9177
|
+
if (this.streamingFlusher) {
|
|
9178
|
+
await safeClose("streamingFlusher", () => this.streamingFlusher.flushAll());
|
|
9179
|
+
}
|
|
9180
|
+
if (this.persistInflight.size > 0) {
|
|
9181
|
+
await safeClose(
|
|
9182
|
+
"persistInflight",
|
|
9183
|
+
() => Promise.allSettled([...this.persistInflight]).then(() => void 0)
|
|
9184
|
+
);
|
|
9185
|
+
}
|
|
8826
9186
|
if (this.mcpManager) await safeClose("mcpManager", () => this.mcpManager.shutdown());
|
|
8827
9187
|
if (this.memoryManager) await safeClose("memoryManager", () => this.memoryManager.close());
|
|
8828
9188
|
if (this.stateStore.close) await safeClose("stateStore", () => this.stateStore.close());
|
|
@@ -8833,6 +9193,81 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
|
|
|
8833
9193
|
);
|
|
8834
9194
|
}
|
|
8835
9195
|
}
|
|
9196
|
+
/**
|
|
9197
|
+
* Recover executions whose process died mid-run when `state.persist:
|
|
9198
|
+
* 'streaming'` was configured. Synthesizes a partial `ExecutionInfo`
|
|
9199
|
+
* from the streaming buffer for each — `status: 'failed'`, error
|
|
9200
|
+
* `'process terminated'` — registers them in the historical cache so
|
|
9201
|
+
* they show up in `getExecutions()`, persists them via the store's
|
|
9202
|
+
* `saveExecution`, and finalizes the streaming buffer.
|
|
9203
|
+
*
|
|
9204
|
+
* Idempotent — if the streaming buffer is already gone (or
|
|
9205
|
+
* `listStreamingExecutions` is empty), this is a no-op. Returns the
|
|
9206
|
+
* list of recovered `ExecutionInfo`s for caller introspection.
|
|
9207
|
+
*
|
|
9208
|
+
* Wire this into your process startup AFTER `runtime.getExecutions()`
|
|
9209
|
+
* has lazy-loaded the historical cache. Order matters: completed
|
|
9210
|
+
* executions already in the store should not be overwritten by a
|
|
9211
|
+
* stale streaming buffer that the previous process didn't get to
|
|
9212
|
+
* finalize. The implementation checks the historical store before
|
|
9213
|
+
* synthesizing, so a re-run is safe.
|
|
9214
|
+
*/
|
|
9215
|
+
async recoverIncompleteStreams() {
|
|
9216
|
+
if (!this.stateStore.listStreamingExecutions || !this.stateStore.getStreamingEvents) {
|
|
9217
|
+
return [];
|
|
9218
|
+
}
|
|
9219
|
+
const ids = await this.stateStore.listStreamingExecutions();
|
|
9220
|
+
if (ids.length === 0) return [];
|
|
9221
|
+
await this.getExecutions();
|
|
9222
|
+
const recovered = [];
|
|
9223
|
+
for (const id of ids) {
|
|
9224
|
+
if (this.executions.has(id) || this.streamableExecutionIds.has(id)) {
|
|
9225
|
+
continue;
|
|
9226
|
+
}
|
|
9227
|
+
const existing = this.historicalExecutions.get(id);
|
|
9228
|
+
if (existing) {
|
|
9229
|
+
await this.stateStore.finalizeStreamingEvents?.(id);
|
|
9230
|
+
continue;
|
|
9231
|
+
}
|
|
9232
|
+
const events = await this.stateStore.getStreamingEvents(id);
|
|
9233
|
+
if (events.length === 0) {
|
|
9234
|
+
await this.stateStore.finalizeStreamingEvents?.(id);
|
|
9235
|
+
continue;
|
|
9236
|
+
}
|
|
9237
|
+
const boundedEvents = boundRecoveredEvents(events, this.maxEventsPerExecution);
|
|
9238
|
+
const workflowStartEvent = boundedEvents.find((e) => e.type === "workflow_start");
|
|
9239
|
+
const lastEvent = boundedEvents[boundedEvents.length - 1];
|
|
9240
|
+
const startedAt = workflowStartEvent?.timestamp ?? boundedEvents[0]?.timestamp ?? Date.now();
|
|
9241
|
+
const completedAt = lastEvent.timestamp ?? startedAt;
|
|
9242
|
+
const workflowName = workflowStartEvent?.workflow ?? RECOVERED_UNKNOWN_WORKFLOW;
|
|
9243
|
+
const totalCost = boundedEvents.reduce((sum, e) => sum + eventCostContribution(e), 0);
|
|
9244
|
+
const synthesized = {
|
|
9245
|
+
executionId: id,
|
|
9246
|
+
workflow: workflowName,
|
|
9247
|
+
status: "failed",
|
|
9248
|
+
events: boundedEvents,
|
|
9249
|
+
totalCost,
|
|
9250
|
+
startedAt,
|
|
9251
|
+
completedAt,
|
|
9252
|
+
duration: completedAt - startedAt,
|
|
9253
|
+
error: "process terminated (recovered from streaming buffer)"
|
|
9254
|
+
};
|
|
9255
|
+
if (this.stateStore.saveExecution) {
|
|
9256
|
+
try {
|
|
9257
|
+
await this.stateStore.saveExecution(synthesized);
|
|
9258
|
+
} catch (err) {
|
|
9259
|
+
console.error(
|
|
9260
|
+
`[axl] recoverIncompleteStreams: failed to save synthesized execution ${id}: ${err instanceof Error ? err.message : String(err)}. Streaming buffer left in place for next recovery attempt.`
|
|
9261
|
+
);
|
|
9262
|
+
continue;
|
|
9263
|
+
}
|
|
9264
|
+
}
|
|
9265
|
+
this.historicalExecutions.set(id, synthesized);
|
|
9266
|
+
await this.stateStore.finalizeStreamingEvents?.(id);
|
|
9267
|
+
recovered.push(synthesized);
|
|
9268
|
+
}
|
|
9269
|
+
return recovered;
|
|
9270
|
+
}
|
|
8836
9271
|
/** Abort a running execution by its ID. */
|
|
8837
9272
|
abort(executionId) {
|
|
8838
9273
|
const controller = this.abortControllers.get(executionId);
|
|
@@ -8865,15 +9300,85 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
|
|
|
8865
9300
|
}
|
|
8866
9301
|
this.emit("eval_result", entry);
|
|
8867
9302
|
}
|
|
9303
|
+
/**
|
|
9304
|
+
* Delete an execution from history by id. Removes from the active map (if
|
|
9305
|
+
* still running), the historical cache, the configured StateStore, AND
|
|
9306
|
+
* any execution-scoped side state — pending awaitHuman decisions,
|
|
9307
|
+
* resolver maps, streaming buffer registration, in-flight abort controller.
|
|
9308
|
+
* Returns true if an entry was actually removed from any of the three
|
|
9309
|
+
* primary surfaces (active map, historical cache, store).
|
|
9310
|
+
*
|
|
9311
|
+
* In-flight handling: if the execution is still running, this method
|
|
9312
|
+
* **aborts** it (via the registered abort controller) AND marks the id for
|
|
9313
|
+
* skip-on-persist so the workflow's eventual `workflow_end` doesn't
|
|
9314
|
+
* resurrect the row. The workflow itself terminates normally — callers
|
|
9315
|
+
* iterating the returned `AxlStream` will see the abort flow through. If
|
|
9316
|
+
* you need a hard hand-off (delete completes before the workflow tears
|
|
9317
|
+
* down), call `runtime.abort(id)` and `await` the stream/execute promise
|
|
9318
|
+
* before calling this. `streamableExecutionIds` is cleared eagerly so
|
|
9319
|
+
* events emitted during the wind-down don't re-create the streaming
|
|
9320
|
+
* buffer in the store after the deleteExecution-issued DEL.
|
|
9321
|
+
*
|
|
9322
|
+
* Audit trail: emits `execution_deleted` on the runtime's `EventEmitter`
|
|
9323
|
+
* with `{ executionId, wasActive, hadPendingDecision }` so compliance
|
|
9324
|
+
* apps can subscribe via `runtime.on('execution_deleted', ...)` for a
|
|
9325
|
+
* "who deleted what when" log without wrapping this method. Fires
|
|
9326
|
+
* regardless of whether the id was actually present.
|
|
9327
|
+
*
|
|
9328
|
+
* Use cases: GDPR right-to-be-forgotten, operator-driven cleanup of
|
|
9329
|
+
* specific runs (e.g. a workflow that recorded PII the user requested
|
|
9330
|
+
* scrubbed). For bulk eviction by age, use a `RedisStore` TTL instead
|
|
9331
|
+
* (`defaultTtl` / `ttls.executionHistory`).
|
|
9332
|
+
*/
|
|
9333
|
+
async deleteExecution(id) {
|
|
9334
|
+
const wasActive = this.executions.has(id);
|
|
9335
|
+
const hadPendingDecision = this.pendingDecisionResolvers.has(id);
|
|
9336
|
+
if (wasActive) {
|
|
9337
|
+
this.pendingDeletedExecutions.add(id);
|
|
9338
|
+
this.streamableExecutionIds.delete(id);
|
|
9339
|
+
const controller = this.abortControllers.get(id);
|
|
9340
|
+
if (controller) {
|
|
9341
|
+
controller.abort();
|
|
9342
|
+
}
|
|
9343
|
+
}
|
|
9344
|
+
if (hadPendingDecision) {
|
|
9345
|
+
this.pendingDecisionResolvers.delete(id);
|
|
9346
|
+
}
|
|
9347
|
+
const existing = await this.getExecution(id);
|
|
9348
|
+
const workflow2 = existing?.workflow;
|
|
9349
|
+
const removedFromActive = this.executions.delete(id);
|
|
9350
|
+
const removedFromHistorical = this.historicalExecutions.delete(id);
|
|
9351
|
+
let removedFromStore = false;
|
|
9352
|
+
if (this.stateStore.deleteExecution) {
|
|
9353
|
+
removedFromStore = await this.stateStore.deleteExecution(id);
|
|
9354
|
+
}
|
|
9355
|
+
this.emit("execution_deleted", {
|
|
9356
|
+
executionId: id,
|
|
9357
|
+
workflow: workflow2,
|
|
9358
|
+
wasActive,
|
|
9359
|
+
hadPendingDecision,
|
|
9360
|
+
removed: removedFromActive || removedFromHistorical || removedFromStore
|
|
9361
|
+
});
|
|
9362
|
+
return removedFromActive || removedFromHistorical || removedFromStore;
|
|
9363
|
+
}
|
|
8868
9364
|
/**
|
|
8869
9365
|
* Delete an eval history entry by id. Removes from in-memory cache and
|
|
8870
9366
|
* the configured StateStore. Returns true if an entry was actually removed.
|
|
8871
9367
|
*
|
|
8872
9368
|
* Ensures lazy-loaded history is loaded first so the in-memory cache and
|
|
8873
9369
|
* the store can't drift apart on the deletion path.
|
|
9370
|
+
*
|
|
9371
|
+
* Emits `eval_deleted` on the runtime's `EventEmitter` with
|
|
9372
|
+
* `{ id, eval, removed }`. Fires on every call — including attempts
|
|
9373
|
+
* against unknown ids (`removed: false`) — symmetric to `execution_deleted`.
|
|
9374
|
+
* Studio's eval-trends aggregator subscribes to this event so deleted
|
|
9375
|
+
* runs don't linger in the trends panel for up to 5 minutes (until the
|
|
9376
|
+
* next periodic rebuild).
|
|
8874
9377
|
*/
|
|
8875
9378
|
async deleteEvalResult(id) {
|
|
8876
9379
|
await this.getEvalHistory();
|
|
9380
|
+
const existing = this.evalHistory.find((e) => e.id === id);
|
|
9381
|
+
const evalName = existing?.eval;
|
|
8877
9382
|
const beforeLength = this.evalHistory.length;
|
|
8878
9383
|
this.evalHistory = this.evalHistory.filter((e) => e.id !== id);
|
|
8879
9384
|
const removedFromMemory = this.evalHistory.length < beforeLength;
|
|
@@ -8881,7 +9386,13 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
|
|
|
8881
9386
|
if (this.stateStore.deleteEvalResult) {
|
|
8882
9387
|
removedFromStore = await this.stateStore.deleteEvalResult(id);
|
|
8883
9388
|
}
|
|
8884
|
-
|
|
9389
|
+
const removed = removedFromMemory || removedFromStore;
|
|
9390
|
+
this.emit("eval_deleted", {
|
|
9391
|
+
id,
|
|
9392
|
+
eval: evalName,
|
|
9393
|
+
removed
|
|
9394
|
+
});
|
|
9395
|
+
return removed;
|
|
8885
9396
|
}
|
|
8886
9397
|
/** Get eval result history (most recent first). */
|
|
8887
9398
|
async getEvalHistory() {
|
|
@@ -9471,15 +9982,53 @@ async function* stringStreamFromEvents(source, opts) {
|
|
|
9471
9982
|
|
|
9472
9983
|
// src/state/redis.ts
|
|
9473
9984
|
var RedisStore = class _RedisStore {
|
|
9474
|
-
constructor(client) {
|
|
9985
|
+
constructor(client, keyPrefix, ttls) {
|
|
9475
9986
|
this.client = client;
|
|
9987
|
+
this.keyPrefix = keyPrefix;
|
|
9988
|
+
this.ttls = ttls;
|
|
9989
|
+
}
|
|
9990
|
+
/**
|
|
9991
|
+
* Resolve the TTL for a given storage category. Returns `undefined` when
|
|
9992
|
+
* no TTL applies (category is `null` in resolved config, the value is
|
|
9993
|
+
* non-positive — Redis EXPIRE 0 immediately deletes, which is almost
|
|
9994
|
+
* never intended — or the value is non-finite).
|
|
9995
|
+
*
|
|
9996
|
+
* Non-finite values (`NaN`, `±Infinity`) defensively map to `undefined`
|
|
9997
|
+
* rather than being passed to Redis, where they'd surface as a wire-level
|
|
9998
|
+
* protocol error at the worst possible time (first write under load).
|
|
9999
|
+
*/
|
|
10000
|
+
ttlFor(category) {
|
|
10001
|
+
const resolved = this.ttls[category];
|
|
10002
|
+
if (resolved === null) return void 0;
|
|
10003
|
+
if (!Number.isFinite(resolved) || resolved <= 0) return void 0;
|
|
10004
|
+
return Math.floor(resolved);
|
|
9476
10005
|
}
|
|
9477
10006
|
/**
|
|
9478
10007
|
* Create a connected RedisStore instance.
|
|
9479
10008
|
*
|
|
9480
|
-
*
|
|
10009
|
+
* Accepts either a URL string (back-compat) or a {@link RedisStoreOptions}
|
|
10010
|
+
* object. The object form is required to set a custom `keyPrefix`.
|
|
10011
|
+
*
|
|
10012
|
+
* @example
|
|
10013
|
+
* ```ts
|
|
10014
|
+
* // Default prefix 'axl:'
|
|
10015
|
+
* const store = await RedisStore.create('redis://localhost:6379');
|
|
10016
|
+
*
|
|
10017
|
+
* // Custom prefix for shared-cluster deployments
|
|
10018
|
+
* const store = await RedisStore.create({
|
|
10019
|
+
* url: 'redis://localhost:6379',
|
|
10020
|
+
* keyPrefix: 'axl:prod:',
|
|
10021
|
+
* });
|
|
10022
|
+
* ```
|
|
9481
10023
|
*/
|
|
9482
|
-
static async create(
|
|
10024
|
+
static async create(options) {
|
|
10025
|
+
const opts = typeof options === "string" ? { url: options } : options ?? {};
|
|
10026
|
+
const keyPrefix = opts.keyPrefix ?? "axl:";
|
|
10027
|
+
if (keyPrefix === "") {
|
|
10028
|
+
throw new Error(
|
|
10029
|
+
'RedisStore: keyPrefix cannot be empty string. Omit it for the default "axl:" prefix.'
|
|
10030
|
+
);
|
|
10031
|
+
}
|
|
9483
10032
|
let createClient;
|
|
9484
10033
|
try {
|
|
9485
10034
|
const mod = require("redis");
|
|
@@ -9493,44 +10042,197 @@ var RedisStore = class _RedisStore {
|
|
|
9493
10042
|
if (err instanceof Error && err.message.includes("createClient")) throw err;
|
|
9494
10043
|
throw new Error("redis is required for RedisStore. Install it with: npm install redis");
|
|
9495
10044
|
}
|
|
9496
|
-
const client = url ? createClient({ url }) : createClient();
|
|
10045
|
+
const client = opts.url ? createClient({ url: opts.url }) : createClient();
|
|
9497
10046
|
await client.connect();
|
|
9498
|
-
|
|
10047
|
+
const resolveCategory = (override) => {
|
|
10048
|
+
if (override === null) return null;
|
|
10049
|
+
if (typeof override === "number") return override;
|
|
10050
|
+
return typeof opts.defaultTtl === "number" ? opts.defaultTtl : null;
|
|
10051
|
+
};
|
|
10052
|
+
const resolveStreamingEvents = (override) => {
|
|
10053
|
+
if (override === null || override === void 0) return null;
|
|
10054
|
+
if (typeof override === "number") return override;
|
|
10055
|
+
return null;
|
|
10056
|
+
};
|
|
10057
|
+
const effectiveTtls = {
|
|
10058
|
+
session: resolveCategory(opts.ttls?.session),
|
|
10059
|
+
sessionMeta: resolveCategory(opts.ttls?.sessionMeta),
|
|
10060
|
+
checkpoint: resolveCategory(opts.ttls?.checkpoint),
|
|
10061
|
+
executionState: resolveCategory(opts.ttls?.executionState),
|
|
10062
|
+
executionHistory: resolveCategory(opts.ttls?.executionHistory),
|
|
10063
|
+
evalHistory: resolveCategory(opts.ttls?.evalHistory),
|
|
10064
|
+
memory: resolveCategory(opts.ttls?.memory),
|
|
10065
|
+
streamingEvents: resolveStreamingEvents(opts.ttls?.streamingEvents)
|
|
10066
|
+
};
|
|
10067
|
+
const store = new _RedisStore(client, keyPrefix, effectiveTtls);
|
|
10068
|
+
if (!opts.skipMigration) {
|
|
10069
|
+
await store.backfillExecutionIndex();
|
|
10070
|
+
await store.backfillEvalIndex();
|
|
10071
|
+
}
|
|
10072
|
+
return store;
|
|
10073
|
+
}
|
|
10074
|
+
/**
|
|
10075
|
+
* Backfill the execution-history sorted-set from the legacy ID set.
|
|
10076
|
+
* Idempotent — runs only if the ZSET is missing entries the SET has.
|
|
10077
|
+
* Safe to call manually if you constructed the store with
|
|
10078
|
+
* `skipMigration: true`.
|
|
10079
|
+
*/
|
|
10080
|
+
async backfillExecutionIndex() {
|
|
10081
|
+
await this.backfillHistoryIndex(
|
|
10082
|
+
this.execHistorySetKey(),
|
|
10083
|
+
this.execHistoryZsetKey(),
|
|
10084
|
+
(id) => this.execHistoryKey(id),
|
|
10085
|
+
(raw) => {
|
|
10086
|
+
const exec = JSON.parse(raw);
|
|
10087
|
+
return typeof exec.startedAt === "number" ? { score: exec.startedAt, value: exec.executionId } : null;
|
|
10088
|
+
},
|
|
10089
|
+
"execution-history"
|
|
10090
|
+
);
|
|
10091
|
+
}
|
|
10092
|
+
/** Backfill the eval-history sorted-set. Symmetric to `backfillExecutionIndex`. */
|
|
10093
|
+
async backfillEvalIndex() {
|
|
10094
|
+
await this.backfillHistoryIndex(
|
|
10095
|
+
this.evalHistorySetKey(),
|
|
10096
|
+
this.evalHistoryZsetKey(),
|
|
10097
|
+
(id) => this.evalHistoryKey(id),
|
|
10098
|
+
(raw) => {
|
|
10099
|
+
const entry = JSON.parse(raw);
|
|
10100
|
+
return typeof entry.timestamp === "number" ? { score: entry.timestamp, value: entry.id } : null;
|
|
10101
|
+
},
|
|
10102
|
+
"eval-history"
|
|
10103
|
+
);
|
|
10104
|
+
}
|
|
10105
|
+
/**
|
|
10106
|
+
* Shared implementation for both backfill paths. Reads SET members,
|
|
10107
|
+
* batch-MGETs their data blobs in chunks, and ZADDs each into the new
|
|
10108
|
+
* sorted-set inside a single MULTI per chunk.
|
|
10109
|
+
*/
|
|
10110
|
+
async backfillHistoryIndex(setKey, zsetKey, dataKey, scoreOf, label) {
|
|
10111
|
+
const [setSize, zsetSize] = await Promise.all([
|
|
10112
|
+
this.client.sCard(setKey),
|
|
10113
|
+
this.client.zCard(zsetKey)
|
|
10114
|
+
]);
|
|
10115
|
+
if (zsetSize >= setSize) return;
|
|
10116
|
+
const allIds = await this.client.sMembers(setKey);
|
|
10117
|
+
if (allIds.length === 0) return;
|
|
10118
|
+
const CHUNK_SIZE = 500;
|
|
10119
|
+
let migrated = 0;
|
|
10120
|
+
let skipped = 0;
|
|
10121
|
+
for (let i = 0; i < allIds.length; i += CHUNK_SIZE) {
|
|
10122
|
+
const chunk = allIds.slice(i, i + CHUNK_SIZE);
|
|
10123
|
+
const dataKeys = chunk.map((id) => dataKey(id));
|
|
10124
|
+
const raws = await this.client.mGet(dataKeys);
|
|
10125
|
+
const tx = this.client.multi();
|
|
10126
|
+
let queuedInChunk = 0;
|
|
10127
|
+
for (let j = 0; j < chunk.length; j++) {
|
|
10128
|
+
const raw = raws[j];
|
|
10129
|
+
if (raw == null) {
|
|
10130
|
+
skipped++;
|
|
10131
|
+
continue;
|
|
10132
|
+
}
|
|
10133
|
+
try {
|
|
10134
|
+
const entry = scoreOf(raw);
|
|
10135
|
+
if (entry == null) {
|
|
10136
|
+
skipped++;
|
|
10137
|
+
continue;
|
|
10138
|
+
}
|
|
10139
|
+
tx.zAdd(zsetKey, entry);
|
|
10140
|
+
queuedInChunk++;
|
|
10141
|
+
migrated++;
|
|
10142
|
+
} catch {
|
|
10143
|
+
skipped++;
|
|
10144
|
+
}
|
|
10145
|
+
}
|
|
10146
|
+
if (queuedInChunk > 0) await tx.exec();
|
|
10147
|
+
}
|
|
10148
|
+
if (migrated > 0 || skipped > 0) {
|
|
10149
|
+
console.log(
|
|
10150
|
+
`[axl] RedisStore: backfilled ${migrated} ${label} entries into sorted-set index` + (skipped > 0 ? ` (${skipped} skipped \u2014 missing or malformed)` : "")
|
|
10151
|
+
);
|
|
10152
|
+
}
|
|
9499
10153
|
}
|
|
9500
10154
|
// ── Key helpers ──────────────────────────────────────────────────────
|
|
9501
10155
|
checkpointKey(executionId) {
|
|
9502
|
-
return
|
|
10156
|
+
return `${this.keyPrefix}checkpoint:${executionId}`;
|
|
9503
10157
|
}
|
|
9504
10158
|
sessionKey(sessionId) {
|
|
9505
|
-
return
|
|
10159
|
+
return `${this.keyPrefix}session:${sessionId}`;
|
|
9506
10160
|
}
|
|
9507
10161
|
sessionMetaKey(sessionId) {
|
|
9508
|
-
return
|
|
10162
|
+
return `${this.keyPrefix}session-meta:${sessionId}`;
|
|
10163
|
+
}
|
|
10164
|
+
sessionIdsKey() {
|
|
10165
|
+
return `${this.keyPrefix}session-ids`;
|
|
9509
10166
|
}
|
|
9510
10167
|
decisionsKey() {
|
|
9511
|
-
return
|
|
10168
|
+
return `${this.keyPrefix}decisions`;
|
|
9512
10169
|
}
|
|
9513
10170
|
executionStateKey(executionId) {
|
|
9514
|
-
return
|
|
10171
|
+
return `${this.keyPrefix}exec-state:${executionId}`;
|
|
9515
10172
|
}
|
|
9516
10173
|
pendingExecSetKey() {
|
|
9517
|
-
return
|
|
10174
|
+
return `${this.keyPrefix}pending-executions`;
|
|
9518
10175
|
}
|
|
9519
10176
|
execHistoryKey(executionId) {
|
|
9520
|
-
return
|
|
9521
|
-
}
|
|
10177
|
+
return `${this.keyPrefix}exec-history:${executionId}`;
|
|
10178
|
+
}
|
|
10179
|
+
// NOTE for future implementer of #3 (TTL config): when the executionHistory
|
|
10180
|
+
// and evalHistory data blobs gain TTLs, eviction MUST be driven by
|
|
10181
|
+
// ZREMRANGEBYSCORE on the ZSET below — NOT by SREM on these legacy ID sets.
|
|
10182
|
+
// The slow-path fallback (`listHistoryByZset`) reads from the SET when
|
|
10183
|
+
// `zsetCard < setCard`, so SET-based eviction would race with the fallback
|
|
10184
|
+
// and surface dead IDs whose data has been TTL'd away. ZSET eviction +
|
|
10185
|
+
// dual-write `SREM` is the clean path; doc the assumption then.
|
|
9522
10186
|
execHistorySetKey() {
|
|
9523
|
-
return
|
|
10187
|
+
return `${this.keyPrefix}exec-history-ids`;
|
|
10188
|
+
}
|
|
10189
|
+
/**
|
|
10190
|
+
* Sorted-set index for execution history, scored by `startedAt`. Drives
|
|
10191
|
+
* the O(log N) `listExecutions` fast path; the legacy SET above is kept
|
|
10192
|
+
* as a fallback for users running with `skipMigration: true` before
|
|
10193
|
+
* they've called `backfillExecutionIndex()` manually.
|
|
10194
|
+
*/
|
|
10195
|
+
execHistoryZsetKey() {
|
|
10196
|
+
return `${this.keyPrefix}exec-history-z`;
|
|
9524
10197
|
}
|
|
9525
10198
|
evalHistoryKey(id) {
|
|
9526
|
-
return
|
|
10199
|
+
return `${this.keyPrefix}eval-history:${id}`;
|
|
9527
10200
|
}
|
|
9528
10201
|
evalHistorySetKey() {
|
|
9529
|
-
return
|
|
10202
|
+
return `${this.keyPrefix}eval-history-ids`;
|
|
10203
|
+
}
|
|
10204
|
+
/** Sorted-set index for eval history, scored by `timestamp`. See above. */
|
|
10205
|
+
evalHistoryZsetKey() {
|
|
10206
|
+
return `${this.keyPrefix}eval-history-z`;
|
|
10207
|
+
}
|
|
10208
|
+
memoryKey(scope) {
|
|
10209
|
+
return `${this.keyPrefix}memory:${scope}`;
|
|
10210
|
+
}
|
|
10211
|
+
// ── Streaming event buffer (for `state.persist: 'streaming'`) ─────────
|
|
10212
|
+
// Layout:
|
|
10213
|
+
// `{prefix}exec-events:{id}` — a Redis list. Events are RPUSH'd as
|
|
10214
|
+
// JSON-serialized AxlEvent objects, in batches. The runtime LRANGEs
|
|
10215
|
+
// the whole list on recovery and deletes it via `finalizeStreamingEvents`
|
|
10216
|
+
// once the canonical `executionHistory` blob has been written at
|
|
10217
|
+
// terminal exit.
|
|
10218
|
+
// `{prefix}streaming-exec-ids` — a Redis set tracking which executions
|
|
10219
|
+
// have an active streaming buffer. Used by `listStreamingExecutions`
|
|
10220
|
+
// to find runs whose process died mid-flight (no corresponding
|
|
10221
|
+
// `executionHistory` blob).
|
|
10222
|
+
streamingEventsKey(executionId) {
|
|
10223
|
+
return `${this.keyPrefix}exec-events:${executionId}`;
|
|
10224
|
+
}
|
|
10225
|
+
streamingIdsKey() {
|
|
10226
|
+
return `${this.keyPrefix}streaming-exec-ids`;
|
|
9530
10227
|
}
|
|
9531
10228
|
// ── Checkpoints ──────────────────────────────────────────────────────
|
|
9532
10229
|
async saveCheckpoint(executionId, name, data) {
|
|
9533
|
-
|
|
10230
|
+
const ttl = this.ttlFor("checkpoint");
|
|
10231
|
+
if (ttl === void 0) {
|
|
10232
|
+
await this.client.hSet(this.checkpointKey(executionId), name, JSON.stringify(data));
|
|
10233
|
+
return;
|
|
10234
|
+
}
|
|
10235
|
+
await this.client.multi().hSet(this.checkpointKey(executionId), name, JSON.stringify(data)).expire(this.checkpointKey(executionId), ttl, "NX").exec();
|
|
9534
10236
|
}
|
|
9535
10237
|
async getCheckpoint(executionId, name) {
|
|
9536
10238
|
const raw = await this.client.hGet(this.checkpointKey(executionId), name);
|
|
@@ -9538,20 +10240,24 @@ var RedisStore = class _RedisStore {
|
|
|
9538
10240
|
}
|
|
9539
10241
|
// ── Sessions ─────────────────────────────────────────────────────────
|
|
9540
10242
|
async saveSession(sessionId, history) {
|
|
9541
|
-
|
|
9542
|
-
|
|
10243
|
+
const ttl = this.ttlFor("session");
|
|
10244
|
+
const setOptions = ttl !== void 0 ? { EX: ttl } : void 0;
|
|
10245
|
+
await this.client.multi().set(this.sessionKey(sessionId), JSON.stringify(history), setOptions).sAdd(this.sessionIdsKey(), sessionId).exec();
|
|
9543
10246
|
}
|
|
9544
10247
|
async getSession(sessionId) {
|
|
9545
10248
|
const raw = await this.client.get(this.sessionKey(sessionId));
|
|
9546
10249
|
return raw ? JSON.parse(raw) : [];
|
|
9547
10250
|
}
|
|
9548
10251
|
async deleteSession(sessionId) {
|
|
9549
|
-
await this.client.del(this.sessionKey(sessionId));
|
|
9550
|
-
await this.client.del(this.sessionMetaKey(sessionId));
|
|
9551
|
-
await this.client.sRem("axl:session-ids", sessionId);
|
|
10252
|
+
await this.client.multi().del(this.sessionKey(sessionId)).del(this.sessionMetaKey(sessionId)).sRem(this.sessionIdsKey(), sessionId).exec();
|
|
9552
10253
|
}
|
|
9553
10254
|
async saveSessionMeta(sessionId, key, value) {
|
|
9554
|
-
|
|
10255
|
+
const ttl = this.ttlFor("sessionMeta");
|
|
10256
|
+
if (ttl === void 0) {
|
|
10257
|
+
await this.client.hSet(this.sessionMetaKey(sessionId), key, JSON.stringify(value));
|
|
10258
|
+
return;
|
|
10259
|
+
}
|
|
10260
|
+
await this.client.multi().hSet(this.sessionMetaKey(sessionId), key, JSON.stringify(value)).expire(this.sessionMetaKey(sessionId), ttl).exec();
|
|
9555
10261
|
}
|
|
9556
10262
|
async getSessionMeta(sessionId, key) {
|
|
9557
10263
|
const raw = await this.client.hGet(this.sessionMetaKey(sessionId), key);
|
|
@@ -9571,64 +10277,222 @@ var RedisStore = class _RedisStore {
|
|
|
9571
10277
|
}
|
|
9572
10278
|
// ── Execution State ──────────────────────────────────────────────────
|
|
9573
10279
|
async saveExecutionState(executionId, state) {
|
|
9574
|
-
|
|
10280
|
+
const ttl = this.ttlFor("executionState");
|
|
10281
|
+
const setOptions = ttl !== void 0 ? { EX: ttl } : void 0;
|
|
10282
|
+
const tx = this.client.multi().set(this.executionStateKey(executionId), JSON.stringify(state), setOptions);
|
|
9575
10283
|
if (state.status === "waiting") {
|
|
9576
|
-
|
|
10284
|
+
tx.sAdd(this.pendingExecSetKey(), executionId);
|
|
9577
10285
|
} else {
|
|
9578
|
-
|
|
10286
|
+
tx.sRem(this.pendingExecSetKey(), executionId);
|
|
9579
10287
|
}
|
|
10288
|
+
await tx.exec();
|
|
9580
10289
|
}
|
|
9581
10290
|
async getExecutionState(executionId) {
|
|
9582
10291
|
const raw = await this.client.get(this.executionStateKey(executionId));
|
|
9583
10292
|
return raw ? JSON.parse(raw) : null;
|
|
9584
10293
|
}
|
|
9585
10294
|
async listPendingExecutions() {
|
|
9586
|
-
|
|
10295
|
+
const ids = await this.client.sMembers(this.pendingExecSetKey());
|
|
10296
|
+
if (ids.length === 0) return ids;
|
|
10297
|
+
const keys = ids.map((id) => this.executionStateKey(id));
|
|
10298
|
+
const raws = await this.client.mGet(keys);
|
|
10299
|
+
const live = [];
|
|
10300
|
+
const dead = [];
|
|
10301
|
+
for (let i = 0; i < ids.length; i++) {
|
|
10302
|
+
if (raws[i] != null) {
|
|
10303
|
+
live.push(ids[i]);
|
|
10304
|
+
} else {
|
|
10305
|
+
dead.push(ids[i]);
|
|
10306
|
+
}
|
|
10307
|
+
}
|
|
10308
|
+
if (dead.length > 0) {
|
|
10309
|
+
this.client.sRem(this.pendingExecSetKey(), dead).catch((err) => {
|
|
10310
|
+
console.error(
|
|
10311
|
+
`[axl] RedisStore: failed to prune ${dead.length} stale pending-exec ids: ` + (err instanceof Error ? err.message : String(err))
|
|
10312
|
+
);
|
|
10313
|
+
});
|
|
10314
|
+
}
|
|
10315
|
+
return live;
|
|
10316
|
+
}
|
|
10317
|
+
// ── Memory ──────────────────────────────────────────────────────────
|
|
10318
|
+
//
|
|
10319
|
+
// Layout: `{prefix}memory:{scope}` is a hash. Fields are user-supplied
|
|
10320
|
+
// keys; values are JSON-serialized. Hash-per-scope keeps related entries
|
|
10321
|
+
// together (good cohesion for TTL eviction once #3 lands) and bounds the
|
|
10322
|
+
// keyspace to O(scopes) instead of O(scopes × keys).
|
|
10323
|
+
//
|
|
10324
|
+
// Pre-this-patch, RedisStore had no memory methods, so MemoryManager fell
|
|
10325
|
+
// back to writing memory at synthetic sessionMeta keys
|
|
10326
|
+
// (`{prefix}session-meta:memory:{scope}:{key}` hash field `value`). To
|
|
10327
|
+
// preserve user data on upgrade, `getMemory` checks the legacy location on
|
|
10328
|
+
// miss and migrates forward; `deleteMemory` also cleans the legacy entry.
|
|
10329
|
+
// No legacy support on `getAllMemory` — that method didn't exist before,
|
|
10330
|
+
// so no user can depend on it returning legacy data.
|
|
10331
|
+
async saveMemory(scope, key, value) {
|
|
10332
|
+
const ttl = this.ttlFor("memory");
|
|
10333
|
+
if (ttl === void 0) {
|
|
10334
|
+
await this.client.hSet(this.memoryKey(scope), key, JSON.stringify(value));
|
|
10335
|
+
return;
|
|
10336
|
+
}
|
|
10337
|
+
await this.client.multi().hSet(this.memoryKey(scope), key, JSON.stringify(value)).expire(this.memoryKey(scope), ttl).exec();
|
|
10338
|
+
}
|
|
10339
|
+
async getMemory(scope, key) {
|
|
10340
|
+
const raw = await this.client.hGet(this.memoryKey(scope), key);
|
|
10341
|
+
if (raw != null) return JSON.parse(raw);
|
|
10342
|
+
const legacyRaw = await this.client.hGet(
|
|
10343
|
+
this.sessionMetaKey(`memory:${scope}:${key}`),
|
|
10344
|
+
"value"
|
|
10345
|
+
);
|
|
10346
|
+
if (legacyRaw == null) return null;
|
|
10347
|
+
const inserted = await this.client.hSetNX(this.memoryKey(scope), key, legacyRaw);
|
|
10348
|
+
if (inserted === 0 || inserted === false) {
|
|
10349
|
+
const winner = await this.client.hGet(this.memoryKey(scope), key);
|
|
10350
|
+
if (winner != null) return JSON.parse(winner);
|
|
10351
|
+
return null;
|
|
10352
|
+
}
|
|
10353
|
+
const ttl = this.ttlFor("memory");
|
|
10354
|
+
if (ttl !== void 0) {
|
|
10355
|
+
this.client.expire(this.memoryKey(scope), ttl).catch((err) => {
|
|
10356
|
+
console.error(
|
|
10357
|
+
`[axl] RedisStore: failed to set TTL on migrated memory key ${this.memoryKey(scope)}: ` + (err instanceof Error ? err.message : String(err))
|
|
10358
|
+
);
|
|
10359
|
+
});
|
|
10360
|
+
}
|
|
10361
|
+
return JSON.parse(legacyRaw);
|
|
10362
|
+
}
|
|
10363
|
+
async getAllMemory(scope) {
|
|
10364
|
+
const all = await this.client.hGetAll(this.memoryKey(scope));
|
|
10365
|
+
if (!all) return [];
|
|
10366
|
+
return Object.entries(all).map(([key, raw]) => ({ key, value: JSON.parse(raw) }));
|
|
10367
|
+
}
|
|
10368
|
+
async deleteMemory(scope, key) {
|
|
10369
|
+
await this.client.multi().hDel(this.memoryKey(scope), key).hDel(this.sessionMetaKey(`memory:${scope}:${key}`), "value").exec();
|
|
9587
10370
|
}
|
|
9588
10371
|
// ── Execution History ────────────────────────────────────────────────
|
|
9589
10372
|
async saveExecution(execution) {
|
|
9590
|
-
|
|
9591
|
-
|
|
10373
|
+
const ttl = this.ttlFor("executionHistory");
|
|
10374
|
+
const setOptions = ttl !== void 0 ? { EX: ttl } : void 0;
|
|
10375
|
+
await this.client.multi().sAdd(this.execHistorySetKey(), execution.executionId).set(this.execHistoryKey(execution.executionId), JSON.stringify(execution), setOptions).zAdd(this.execHistoryZsetKey(), {
|
|
10376
|
+
score: execution.startedAt,
|
|
10377
|
+
value: execution.executionId
|
|
10378
|
+
}).exec();
|
|
9592
10379
|
}
|
|
9593
10380
|
async getExecution(executionId) {
|
|
9594
10381
|
const raw = await this.client.get(this.execHistoryKey(executionId));
|
|
9595
10382
|
return raw ? JSON.parse(raw) : null;
|
|
9596
10383
|
}
|
|
9597
10384
|
async listExecutions(limit) {
|
|
9598
|
-
|
|
9599
|
-
|
|
9600
|
-
|
|
9601
|
-
|
|
9602
|
-
|
|
9603
|
-
|
|
9604
|
-
|
|
9605
|
-
|
|
9606
|
-
|
|
10385
|
+
return this.listHistoryByZset(
|
|
10386
|
+
this.execHistorySetKey(),
|
|
10387
|
+
this.execHistoryZsetKey(),
|
|
10388
|
+
(id) => this.execHistoryKey(id),
|
|
10389
|
+
(a, b) => b.startedAt - a.startedAt,
|
|
10390
|
+
limit
|
|
10391
|
+
);
|
|
10392
|
+
}
|
|
10393
|
+
async deleteExecution(executionId) {
|
|
10394
|
+
const [, deletedCount] = await this.client.multi().sRem(this.execHistorySetKey(), executionId).del(this.execHistoryKey(executionId)).zRem(this.execHistoryZsetKey(), executionId).del(this.checkpointKey(executionId)).del(this.executionStateKey(executionId)).sRem(this.pendingExecSetKey(), executionId).del(this.streamingEventsKey(executionId)).sRem(this.streamingIdsKey(), executionId).hDel(this.decisionsKey(), executionId).exec();
|
|
10395
|
+
const deleted = typeof deletedCount === "number" ? deletedCount : 0;
|
|
10396
|
+
return deleted > 0;
|
|
9607
10397
|
}
|
|
9608
10398
|
// ── Eval History ────────────────────────────────────────────────────
|
|
9609
10399
|
async saveEvalResult(entry) {
|
|
9610
|
-
|
|
9611
|
-
|
|
10400
|
+
const ttl = this.ttlFor("evalHistory");
|
|
10401
|
+
const setOptions = ttl !== void 0 ? { EX: ttl } : void 0;
|
|
10402
|
+
await this.client.multi().sAdd(this.evalHistorySetKey(), entry.id).set(this.evalHistoryKey(entry.id), JSON.stringify(entry), setOptions).zAdd(this.evalHistoryZsetKey(), { score: entry.timestamp, value: entry.id }).exec();
|
|
9612
10403
|
}
|
|
9613
10404
|
async listEvalResults(limit) {
|
|
9614
|
-
|
|
10405
|
+
return this.listHistoryByZset(
|
|
10406
|
+
this.evalHistorySetKey(),
|
|
10407
|
+
this.evalHistoryZsetKey(),
|
|
10408
|
+
(id) => this.evalHistoryKey(id),
|
|
10409
|
+
(a, b) => b.timestamp - a.timestamp,
|
|
10410
|
+
limit
|
|
10411
|
+
);
|
|
10412
|
+
}
|
|
10413
|
+
async deleteEvalResult(id) {
|
|
10414
|
+
const [, deletedCount] = await this.client.multi().sRem(this.evalHistorySetKey(), id).del(this.evalHistoryKey(id)).zRem(this.evalHistoryZsetKey(), id).exec();
|
|
10415
|
+
const deleted = typeof deletedCount === "number" ? deletedCount : 0;
|
|
10416
|
+
return deleted > 0;
|
|
10417
|
+
}
|
|
10418
|
+
/**
|
|
10419
|
+
* Shared list-by-sorted-set implementation. Uses ZREVRANGEBYSCORE +
|
|
10420
|
+
* MGET when the ZSET is populated (post-migration). Falls back to the
|
|
10421
|
+
* legacy SET + N×GET + JS-sort path when the ZSET is empty or smaller
|
|
10422
|
+
* than the SET (covers users that constructed with `skipMigration: true`
|
|
10423
|
+
* before calling backfill).
|
|
10424
|
+
*/
|
|
10425
|
+
async listHistoryByZset(setKey, zsetKey, dataKey, sortCmp, limit) {
|
|
10426
|
+
const setSize = await this.client.sCard(setKey);
|
|
10427
|
+
if (setSize === 0) return [];
|
|
10428
|
+
const zsetSize = await this.client.zCard(zsetKey);
|
|
10429
|
+
if (zsetSize >= setSize) {
|
|
10430
|
+
const fetchCount = limit !== void 0 ? Math.max(limit * 2, limit + 5) : void 0;
|
|
10431
|
+
const ids2 = await this.client.zRange(zsetKey, "+inf", "-inf", {
|
|
10432
|
+
BY: "SCORE",
|
|
10433
|
+
REV: true,
|
|
10434
|
+
...fetchCount !== void 0 ? { LIMIT: { offset: 0, count: fetchCount } } : {}
|
|
10435
|
+
});
|
|
10436
|
+
if (ids2.length === 0) return [];
|
|
10437
|
+
const keys = ids2.map(dataKey);
|
|
10438
|
+
const raws = await this.client.mGet(keys);
|
|
10439
|
+
const entries2 = [];
|
|
10440
|
+
for (const raw of raws) {
|
|
10441
|
+
if (raw == null) continue;
|
|
10442
|
+
try {
|
|
10443
|
+
entries2.push(JSON.parse(raw));
|
|
10444
|
+
} catch {
|
|
10445
|
+
}
|
|
10446
|
+
if (limit !== void 0 && entries2.length >= limit) break;
|
|
10447
|
+
}
|
|
10448
|
+
return entries2;
|
|
10449
|
+
}
|
|
10450
|
+
const ids = await this.client.sMembers(setKey);
|
|
9615
10451
|
if (ids.length === 0) return [];
|
|
9616
10452
|
const entries = [];
|
|
9617
10453
|
for (const id of ids) {
|
|
9618
|
-
const raw = await this.client.get(
|
|
9619
|
-
if (raw)
|
|
10454
|
+
const raw = await this.client.get(dataKey(id));
|
|
10455
|
+
if (raw == null) continue;
|
|
10456
|
+
try {
|
|
10457
|
+
entries.push(JSON.parse(raw));
|
|
10458
|
+
} catch {
|
|
10459
|
+
}
|
|
9620
10460
|
}
|
|
9621
|
-
entries.sort(
|
|
10461
|
+
entries.sort(sortCmp);
|
|
9622
10462
|
return limit ? entries.slice(0, limit) : entries;
|
|
9623
10463
|
}
|
|
9624
|
-
async deleteEvalResult(id) {
|
|
9625
|
-
await this.client.sRem(this.evalHistorySetKey(), id);
|
|
9626
|
-
const deleted = await this.client.del(this.evalHistoryKey(id));
|
|
9627
|
-
return deleted > 0;
|
|
9628
|
-
}
|
|
9629
10464
|
// ── Sessions (Studio introspection) ────────────────────────────────────
|
|
9630
10465
|
async listSessions() {
|
|
9631
|
-
return this.client.sMembers(
|
|
10466
|
+
return this.client.sMembers(this.sessionIdsKey());
|
|
10467
|
+
}
|
|
10468
|
+
// ── Streaming events (for state.persist: 'streaming') ──────────────────
|
|
10469
|
+
async appendStreamingEvents(executionId, events) {
|
|
10470
|
+
if (events.length === 0) return;
|
|
10471
|
+
const serialized = events.map((e) => JSON.stringify(e));
|
|
10472
|
+
const ttl = this.ttlFor("streamingEvents");
|
|
10473
|
+
const tx = this.client.multi().rPush(this.streamingEventsKey(executionId), serialized).sAdd(this.streamingIdsKey(), executionId);
|
|
10474
|
+
if (ttl !== void 0) {
|
|
10475
|
+
tx.expire(this.streamingEventsKey(executionId), ttl, "NX");
|
|
10476
|
+
}
|
|
10477
|
+
await tx.exec();
|
|
10478
|
+
}
|
|
10479
|
+
async finalizeStreamingEvents(executionId) {
|
|
10480
|
+
await this.client.multi().del(this.streamingEventsKey(executionId)).sRem(this.streamingIdsKey(), executionId).exec();
|
|
10481
|
+
}
|
|
10482
|
+
async listStreamingExecutions() {
|
|
10483
|
+
return this.client.sMembers(this.streamingIdsKey());
|
|
10484
|
+
}
|
|
10485
|
+
async getStreamingEvents(executionId) {
|
|
10486
|
+
const raws = await this.client.lRange(this.streamingEventsKey(executionId), 0, -1);
|
|
10487
|
+
if (raws.length === 0) return [];
|
|
10488
|
+
const events = [];
|
|
10489
|
+
for (const raw of raws) {
|
|
10490
|
+
try {
|
|
10491
|
+
events.push(JSON.parse(raw));
|
|
10492
|
+
} catch {
|
|
10493
|
+
}
|
|
10494
|
+
}
|
|
10495
|
+
return events;
|
|
9632
10496
|
}
|
|
9633
10497
|
/** Close the Redis connection. */
|
|
9634
10498
|
async close() {
|