@axlsdk/axl 0.17.6 → 0.17.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -5914,8 +5914,31 @@ ${summaryResponse.content}`
5914
5914
  type: "await_human",
5915
5915
  data: { channel: options.channel, prompt: options.prompt }
5916
5916
  });
5917
- const decision = await new Promise((resolve2) => {
5917
+ const makeAbortError = (reason) => {
5918
+ if (typeof DOMException !== "undefined") {
5919
+ return new DOMException(
5920
+ typeof reason === "string" ? reason : "awaitHuman aborted",
5921
+ "AbortError"
5922
+ );
5923
+ }
5924
+ const err = new Error(typeof reason === "string" ? reason : "awaitHuman aborted");
5925
+ err.name = "AbortError";
5926
+ return err;
5927
+ };
5928
+ const decision = await new Promise((resolve2, reject) => {
5929
+ if (this.signal?.aborted) {
5930
+ reject(makeAbortError(this.signal.reason));
5931
+ return;
5932
+ }
5918
5933
  this.pendingDecisions.set(this.executionId, resolve2);
5934
+ const onAbort = () => {
5935
+ const current = this.pendingDecisions.get(this.executionId);
5936
+ if (current === resolve2) {
5937
+ this.pendingDecisions.delete(this.executionId);
5938
+ }
5939
+ reject(makeAbortError(this.signal?.reason));
5940
+ };
5941
+ this.signal?.addEventListener("abort", onAbort, { once: true });
5919
5942
  });
5920
5943
  this.emitEvent({
5921
5944
  type: "await_human_resolved",
@@ -6540,6 +6563,16 @@ var MemoryStore = class {
6540
6563
  const result = limit ? sorted.slice(0, limit) : sorted;
6541
6564
  return result.map((e) => structuredClone(e));
6542
6565
  }
6566
+ async deleteExecution(executionId) {
6567
+ const removed = this.executionHistory.delete(executionId);
6568
+ this.checkpoints.delete(executionId);
6569
+ this.executionStates.delete(executionId);
6570
+ this.streamingEvents.delete(executionId);
6571
+ if (this.decisions.delete(executionId)) {
6572
+ this.persistAwaitHumanState();
6573
+ }
6574
+ return removed;
6575
+ }
6543
6576
  // ── Eval History ──────────────────────────────────────────────────
6544
6577
  async saveEvalResult(entry) {
6545
6578
  this.evalHistory.set(entry.id, structuredClone(entry));
@@ -6556,6 +6589,30 @@ var MemoryStore = class {
6556
6589
  async listSessions() {
6557
6590
  return [...this.sessions.keys()];
6558
6591
  }
6592
+ // ── Streaming events (for state.persist: 'streaming') ────────────────
6593
+ //
6594
+ // MemoryStore's raison d'être is in-process / ephemeral. We implement
6595
+ // the streaming methods so tests and dev workflows behave correctly,
6596
+ // but on a process crash these buffers are lost — same as the rest of
6597
+ // MemoryStore's state. For real crash-survival, use RedisStore or
6598
+ // SQLiteStore.
6599
+ streamingEvents = /* @__PURE__ */ new Map();
6600
+ async appendStreamingEvents(executionId, events) {
6601
+ if (events.length === 0) return;
6602
+ const existing = this.streamingEvents.get(executionId) ?? [];
6603
+ existing.push(...events.map((e) => structuredClone(e)));
6604
+ this.streamingEvents.set(executionId, existing);
6605
+ }
6606
+ async finalizeStreamingEvents(executionId) {
6607
+ this.streamingEvents.delete(executionId);
6608
+ }
6609
+ async listStreamingExecutions() {
6610
+ return [...this.streamingEvents.keys()];
6611
+ }
6612
+ async getStreamingEvents(executionId) {
6613
+ const events = this.streamingEvents.get(executionId) ?? [];
6614
+ return events.map((e) => structuredClone(e));
6615
+ }
6559
6616
  // ── Lifecycle ──────────────────────────────────────────────────────
6560
6617
  async close() {
6561
6618
  }
@@ -6612,7 +6669,7 @@ function safeJsonParse(data) {
6612
6669
  }
6613
6670
  }
6614
6671
  function rowToExecutionInfo(row) {
6615
- return {
6672
+ const info = {
6616
6673
  executionId: row.execution_id,
6617
6674
  workflow: row.workflow,
6618
6675
  status: row.status,
@@ -6623,6 +6680,13 @@ function rowToExecutionInfo(row) {
6623
6680
  error: row.error ?? void 0,
6624
6681
  events: safeJsonParse(row.events) ?? []
6625
6682
  };
6683
+ if (row.metadata != null) {
6684
+ const parsed = safeJsonParse(row.metadata);
6685
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
6686
+ info.metadata = parsed;
6687
+ }
6688
+ }
6689
+ return info;
6626
6690
  }
6627
6691
  var SQLiteStore = class {
6628
6692
  db;
@@ -6658,6 +6722,10 @@ var SQLiteStore = class {
6658
6722
  * numeric step values become stringified names ("0", "1", …) to
6659
6723
  * preserve replay continuity for in-flight executions across the
6660
6724
  * upgrade boundary.
6725
+ * - v3 (ExecutionInfo.metadata): add `execution_history.metadata`
6726
+ * column (TEXT, nullable, JSON-serialized) so caller-supplied
6727
+ * `ExecuteOptions.metadata` round-trips through history. Existing
6728
+ * rows get `NULL`, deserialized as `metadata: undefined`.
6661
6729
  *
6662
6730
  * Applied BEFORE `initTables()` so the subsequent `CREATE TABLE
6663
6731
  * IF NOT EXISTS` runs against the post-migration column name. Fresh
@@ -6675,7 +6743,7 @@ var SQLiteStore = class {
6675
6743
  * transactional re-read costs nothing.
6676
6744
  */
6677
6745
  migrate() {
6678
- const TARGET_VERSION = 2;
6746
+ const TARGET_VERSION = 3;
6679
6747
  const current = this.db.pragma("user_version", { simple: true });
6680
6748
  if (current >= TARGET_VERSION) return;
6681
6749
  this.db.exec("BEGIN IMMEDIATE");
@@ -6697,6 +6765,12 @@ var SQLiteStore = class {
6697
6765
  this.db.exec("ALTER TABLE checkpoints RENAME COLUMN step TO name");
6698
6766
  }
6699
6767
  }
6768
+ if (committed < 3) {
6769
+ const cols = this.db.pragma("table_info(execution_history)");
6770
+ if (cols.length > 0 && !cols.some((c) => c.name === "metadata")) {
6771
+ this.db.exec("ALTER TABLE execution_history ADD COLUMN metadata TEXT");
6772
+ }
6773
+ }
6700
6774
  this.db.pragma(`user_version = ${TARGET_VERSION}`);
6701
6775
  this.db.exec("COMMIT");
6702
6776
  } catch (err) {
@@ -6759,7 +6833,8 @@ var SQLiteStore = class {
6759
6833
  completed_at INTEGER,
6760
6834
  duration INTEGER NOT NULL DEFAULT 0,
6761
6835
  error TEXT,
6762
- events TEXT NOT NULL
6836
+ events TEXT NOT NULL,
6837
+ metadata TEXT
6763
6838
  );
6764
6839
 
6765
6840
  CREATE TABLE IF NOT EXISTS eval_history (
@@ -6880,7 +6955,7 @@ var SQLiteStore = class {
6880
6955
  // ── Execution History ────────────────────────────────────────────────────
6881
6956
  async saveExecution(execution) {
6882
6957
  this.db.prepare(
6883
- "INSERT OR REPLACE INTO execution_history (execution_id, workflow, status, total_cost, started_at, completed_at, duration, error, events) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
6958
+ "INSERT OR REPLACE INTO execution_history (execution_id, workflow, status, total_cost, started_at, completed_at, duration, error, events, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
6884
6959
  ).run(
6885
6960
  execution.executionId,
6886
6961
  execution.workflow,
@@ -6890,7 +6965,8 @@ var SQLiteStore = class {
6890
6965
  execution.completedAt ?? null,
6891
6966
  execution.duration,
6892
6967
  execution.error ?? null,
6893
- JSON.stringify(execution.events)
6968
+ JSON.stringify(execution.events),
6969
+ execution.metadata !== void 0 ? JSON.stringify(execution.metadata) : null
6894
6970
  );
6895
6971
  }
6896
6972
  async getExecution(executionId) {
@@ -6902,6 +6978,16 @@ var SQLiteStore = class {
6902
6978
  const rows = limit ? this.db.prepare(sql).all(limit) : this.db.prepare(sql).all();
6903
6979
  return rows.map(rowToExecutionInfo);
6904
6980
  }
6981
+ async deleteExecution(executionId) {
6982
+ const tx = this.db.transaction(() => {
6983
+ const result = this.db.prepare("DELETE FROM execution_history WHERE execution_id = ?").run(executionId);
6984
+ this.db.prepare("DELETE FROM checkpoints WHERE execution_id = ?").run(executionId);
6985
+ this.db.prepare("DELETE FROM execution_state WHERE execution_id = ?").run(executionId);
6986
+ this.db.prepare("DELETE FROM decisions WHERE execution_id = ?").run(executionId);
6987
+ return result.changes > 0;
6988
+ });
6989
+ return tx();
6990
+ }
6905
6991
  // ── Eval History ────────────────────────────────────────────────────────
6906
6992
  async saveEvalResult(entry) {
6907
6993
  this.db.prepare(
@@ -7780,6 +7866,7 @@ var MemoryManager = class {
7780
7866
  if (stateStore.saveMemory) {
7781
7867
  await stateStore.saveMemory(storeScope, key, value);
7782
7868
  } else {
7869
+ warnMissingMemoryMethodsOnce(stateStore);
7783
7870
  const storeKey = `memory:${storeScope}:${key}`;
7784
7871
  await stateStore.saveSessionMeta(storeKey, "value", value);
7785
7872
  if (options?.metadata) {
@@ -7882,6 +7969,14 @@ var MemoryManager = class {
7882
7969
  }
7883
7970
  }
7884
7971
  };
7972
+ var _warnedStoresMissingMemory = /* @__PURE__ */ new WeakSet();
7973
+ function warnMissingMemoryMethodsOnce(stateStore) {
7974
+ if (_warnedStoresMissingMemory.has(stateStore)) return;
7975
+ _warnedStoresMissingMemory.add(stateStore);
7976
+ console.warn(
7977
+ "[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."
7978
+ );
7979
+ }
7885
7980
 
7886
7981
  // src/telemetry/noop.ts
7887
7982
  var NOOP_SPAN = {
@@ -7923,11 +8018,141 @@ function hashInput(input) {
7923
8018
  return (hash >>> 0).toString(16);
7924
8019
  }
7925
8020
  var DEFAULT_MAX_EVENTS_PER_EXECUTION = 5e4;
8021
+ var DEFAULT_STREAMING_BATCH_SIZE = 100;
8022
+ var DEFAULT_STREAMING_BATCH_INTERVAL = 1e3;
8023
+ var RECOVERED_UNKNOWN_WORKFLOW = "__axl/recovered";
8024
+ var STREAMING_EXCLUDED_TYPES = /* @__PURE__ */ new Set([
8025
+ "token",
8026
+ "partial_object",
8027
+ "string_delta"
8028
+ ]);
8029
+ var INTERNAL_METADATA_KEYS = /* @__PURE__ */ new Set([
8030
+ "sessionId",
8031
+ "sessionHistory",
8032
+ "resumeMode"
8033
+ ]);
8034
+ function liftPersistedMetadata(metadata) {
8035
+ if (!metadata) return void 0;
8036
+ const filtered = {};
8037
+ let hasAny = false;
8038
+ for (const [k, v] of Object.entries(metadata)) {
8039
+ if (INTERNAL_METADATA_KEYS.has(k)) continue;
8040
+ filtered[k] = v;
8041
+ hasAny = true;
8042
+ }
8043
+ if (!hasAny) return void 0;
8044
+ try {
8045
+ return structuredClone(filtered);
8046
+ } catch {
8047
+ return { ...filtered };
8048
+ }
8049
+ }
8050
+ var StreamingFlusher = class {
8051
+ constructor(store, batchSize, batchInterval) {
8052
+ this.store = store;
8053
+ this.batchSize = batchSize;
8054
+ this.batchInterval = batchInterval;
8055
+ }
8056
+ buffers = /* @__PURE__ */ new Map();
8057
+ timers = /* @__PURE__ */ new Map();
8058
+ /**
8059
+ * Tracks the most recent in-flight `appendStreamingEvents` per execution
8060
+ * so `finalize` can await it. Without this, a size-triggered fire-and-
8061
+ * forget flush whose RPUSH lands AFTER `finalize`'s DEL would resurrect
8062
+ * the buffer + re-register the execution in the streaming-exec-ids set
8063
+ * — a phantom orphan that recoverIncompleteStreams would later mis-recover.
8064
+ */
8065
+ inflight = /* @__PURE__ */ new Map();
8066
+ append(executionId, event) {
8067
+ if (STREAMING_EXCLUDED_TYPES.has(event.type)) return;
8068
+ let buffer = this.buffers.get(executionId);
8069
+ if (!buffer) {
8070
+ buffer = [];
8071
+ this.buffers.set(executionId, buffer);
8072
+ }
8073
+ buffer.push(event);
8074
+ if (buffer.length >= this.batchSize) {
8075
+ void this.flush(executionId);
8076
+ return;
8077
+ }
8078
+ if (!this.timers.has(executionId)) {
8079
+ const timer = setTimeout(() => {
8080
+ this.timers.delete(executionId);
8081
+ void this.flush(executionId);
8082
+ }, this.batchInterval);
8083
+ if (typeof timer === "object" && timer !== null && "unref" in timer) {
8084
+ timer.unref?.();
8085
+ }
8086
+ this.timers.set(executionId, timer);
8087
+ }
8088
+ }
8089
+ /** Flush this execution's buffer immediately. Awaits any prior in-flight
8090
+ * flush for the same execution so writes land in order. */
8091
+ async flush(executionId) {
8092
+ const buffer = this.buffers.get(executionId);
8093
+ if (!buffer || buffer.length === 0) return;
8094
+ this.buffers.set(executionId, []);
8095
+ const timer = this.timers.get(executionId);
8096
+ if (timer) {
8097
+ clearTimeout(timer);
8098
+ this.timers.delete(executionId);
8099
+ }
8100
+ const prior = this.inflight.get(executionId) ?? Promise.resolve();
8101
+ const next = prior.then(async () => {
8102
+ try {
8103
+ await this.store.appendStreamingEvents?.(executionId, buffer);
8104
+ } catch (err) {
8105
+ console.error(
8106
+ `[axl] StreamingFlusher: failed to append ${buffer.length} events for executionId=${executionId}: ${err instanceof Error ? err.message : String(err)}`
8107
+ );
8108
+ }
8109
+ });
8110
+ this.inflight.set(executionId, next);
8111
+ next.finally(() => {
8112
+ if (this.inflight.get(executionId) === next) {
8113
+ this.inflight.delete(executionId);
8114
+ }
8115
+ });
8116
+ return next;
8117
+ }
8118
+ /** Finalize an execution — flush remaining buffer + release store-side buffer.
8119
+ * Critically, awaits the most recent in-flight `appendStreamingEvents` BEFORE
8120
+ * calling `finalizeStreamingEvents`, so a late-landing RPUSH can't resurrect
8121
+ * the buffer after we've deleted it. */
8122
+ async finalize(executionId) {
8123
+ await this.flush(executionId);
8124
+ const pending = this.inflight.get(executionId);
8125
+ if (pending) {
8126
+ await pending;
8127
+ this.inflight.delete(executionId);
8128
+ }
8129
+ this.buffers.delete(executionId);
8130
+ const timer = this.timers.get(executionId);
8131
+ if (timer) {
8132
+ clearTimeout(timer);
8133
+ this.timers.delete(executionId);
8134
+ }
8135
+ try {
8136
+ await this.store.finalizeStreamingEvents?.(executionId);
8137
+ } catch (err) {
8138
+ console.error(
8139
+ `[axl] StreamingFlusher: failed to finalize executionId=${executionId}: ` + (err instanceof Error ? err.message : String(err))
8140
+ );
8141
+ }
8142
+ }
8143
+ /** Best-effort drain of every pending buffer. Called from `runtime.shutdown()`. */
8144
+ async flushAll() {
8145
+ const ids = [...this.buffers.keys()];
8146
+ await Promise.all(ids.map((id) => this.flush(id)));
8147
+ const pendings = [...this.inflight.values()];
8148
+ await Promise.allSettled(pendings);
8149
+ }
8150
+ };
7926
8151
  function isAbortError(err) {
7927
8152
  return typeof err === "object" && err !== null && err.name === "AbortError";
7928
8153
  }
7929
8154
  function pushEventBounded(execInfo, event, cap) {
7930
- if (event.type === "token" || event.type === "partial_object") return;
8155
+ if (STREAMING_EXCLUDED_TYPES.has(event.type)) return;
7931
8156
  if (cap === Infinity || execInfo.events.length < cap) {
7932
8157
  execInfo.events.push(event);
7933
8158
  return;
@@ -7948,6 +8173,36 @@ function pushEventBounded(execInfo, event, cap) {
7948
8173
  };
7949
8174
  }
7950
8175
  }
8176
+ function sanitizeMetadataForPersist(metadata) {
8177
+ const cleaned = {};
8178
+ for (const [k, v] of Object.entries(metadata)) {
8179
+ try {
8180
+ cleaned[k] = structuredClone(v);
8181
+ } catch {
8182
+ }
8183
+ }
8184
+ return cleaned;
8185
+ }
8186
+ function boundRecoveredEvents(events, cap) {
8187
+ if (cap === Infinity || events.length <= cap) return events;
8188
+ const truncated = events.slice(0, cap);
8189
+ const last = truncated[truncated.length - 1];
8190
+ const alreadyTruncated = last?.type === "log" && typeof last.data === "object" && last.data !== null && last.data.event === "events_truncated";
8191
+ if (!alreadyTruncated) {
8192
+ truncated[truncated.length - 1] = {
8193
+ type: "log",
8194
+ executionId: events[0]?.executionId ?? "",
8195
+ step: last?.step ?? 0,
8196
+ timestamp: Date.now(),
8197
+ data: {
8198
+ event: "events_truncated",
8199
+ cap,
8200
+ message: `Recovered ExecutionInfo.events truncated at ${cap} entries. Original streaming buffer had ${events.length} events. Raise via config.state.maxEventsPerExecution.`
8201
+ }
8202
+ };
8203
+ }
8204
+ return truncated;
8205
+ }
7951
8206
  var costScopeStorage = new AsyncLocalStorage2();
7952
8207
  function forwardAbortSignal(external, internal) {
7953
8208
  if (!external) return () => {
@@ -7994,6 +8249,30 @@ var AxlRuntime = class extends EventEmitter2 {
7994
8249
  * `events` field — keeps the log to one line per row instead of one per
7995
8250
  * rebuild tick. */
7996
8251
  warnedMalformedExecutions = /* @__PURE__ */ new Set();
8252
+ /** Streaming-mode flusher. `undefined` when persist === 'terminal'
8253
+ * (back-compat default — no buffering overhead, no batching timer). */
8254
+ streamingFlusher;
8255
+ /** Execution IDs that are eligible for streaming-buffer durability —
8256
+ * populated by `execute()` / `stream()`. `createContext()` flows are
8257
+ * deliberately NOT added: ad-hoc contexts (tool tests, Studio playground,
8258
+ * evals) have no terminal `persistExecution` path that would finalize the
8259
+ * streaming buffer, so allowing them to write would leave phantom orphan
8260
+ * buffers that `recoverIncompleteStreams()` later mis-recovers as failed
8261
+ * executions. Membership is removed in `persistExecution` once the
8262
+ * canonical save + finalize chain has been scheduled. */
8263
+ streamableExecutionIds = /* @__PURE__ */ new Set();
8264
+ /** Execution IDs deleted by `runtime.deleteExecution()` while still in
8265
+ * flight. `persistExecution` skips its save + cache write when the
8266
+ * executionId is in this set — preventing a workflow that completes after
8267
+ * a delete from resurrecting the (intentionally-removed) row. Entries are
8268
+ * removed once the would-be persist has been short-circuited. */
8269
+ pendingDeletedExecutions = /* @__PURE__ */ new Set();
8270
+ /** In-flight `persistExecution` chains (save → finalize). `shutdown()`
8271
+ * awaits these before closing the StateStore so a workflow whose abort
8272
+ * triggers a persist-during-shutdown isn't racing a closed connection.
8273
+ * The Set is mutated by the chain's own `.finally` so entries clear as
8274
+ * soon as the work settles. */
8275
+ persistInflight = /* @__PURE__ */ new Set();
7997
8276
  constructor(config) {
7998
8277
  super();
7999
8278
  this.config = resolveConfig(config ?? {});
@@ -8017,6 +8296,19 @@ var AxlRuntime = class extends EventEmitter2 {
8017
8296
  embedder: this.config.memory.embedder
8018
8297
  });
8019
8298
  }
8299
+ if (this.config.state?.persist === "streaming") {
8300
+ if (this.stateStore.appendStreamingEvents) {
8301
+ this.streamingFlusher = new StreamingFlusher(
8302
+ this.stateStore,
8303
+ this.config.state.streamingBatchSize ?? DEFAULT_STREAMING_BATCH_SIZE,
8304
+ this.config.state.streamingBatchInterval ?? DEFAULT_STREAMING_BATCH_INTERVAL
8305
+ );
8306
+ } else {
8307
+ console.warn(
8308
+ `[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.`
8309
+ );
8310
+ }
8311
+ }
8020
8312
  }
8021
8313
  /**
8022
8314
  * Whether `config.trace.redact` is enabled on this runtime. A narrow
@@ -8140,14 +8432,49 @@ var AxlRuntime = class extends EventEmitter2 {
8140
8432
  /**
8141
8433
  * Persist a completed/failed execution to the state store (fire-and-forget)
8142
8434
  * and move it from the active executions map to the historical cache.
8435
+ *
8436
+ * Resurrection guard: if `runtime.deleteExecution(id)` was called while
8437
+ * this execution was still running, the id will be in
8438
+ * `pendingDeletedExecutions`. In that case we skip the save AND the
8439
+ * historical-cache write, and finalize the streaming buffer (if any) so
8440
+ * the buffer doesn't outlive the delete.
8143
8441
  */
8144
8442
  persistExecution(execInfo) {
8145
- const snapshot = structuredClone(execInfo);
8443
+ const id = execInfo.executionId;
8444
+ this.streamableExecutionIds.delete(id);
8445
+ if (this.pendingDeletedExecutions.has(id)) {
8446
+ this.pendingDeletedExecutions.delete(id);
8447
+ this.executions.delete(id);
8448
+ if (this.streamingFlusher) {
8449
+ this.streamingFlusher.finalize(id).catch(() => {
8450
+ });
8451
+ }
8452
+ return;
8453
+ }
8454
+ let snapshot;
8455
+ try {
8456
+ snapshot = structuredClone(execInfo);
8457
+ } catch {
8458
+ snapshot = {
8459
+ ...execInfo,
8460
+ events: [...execInfo.events],
8461
+ metadata: execInfo.metadata ? sanitizeMetadataForPersist(execInfo.metadata) : void 0
8462
+ };
8463
+ }
8146
8464
  if (this.stateStore.saveExecution) {
8147
- this.stateStore.saveExecution(snapshot).catch(() => {
8465
+ const chain = this.stateStore.saveExecution(snapshot).then(() => {
8466
+ return this.streamingFlusher?.finalize(id).catch(() => {
8467
+ });
8468
+ }).catch(() => {
8469
+ }).then(() => void 0);
8470
+ this.persistInflight.add(chain);
8471
+ chain.finally(() => this.persistInflight.delete(chain));
8472
+ } else if (this.streamingFlusher) {
8473
+ const chain = this.streamingFlusher.finalize(id).catch(() => {
8148
8474
  });
8475
+ this.persistInflight.add(chain);
8476
+ chain.finally(() => this.persistInflight.delete(chain));
8149
8477
  }
8150
- const id = execInfo.executionId;
8151
8478
  this.historicalExecutions.set(id, snapshot);
8152
8479
  this.executions.delete(id);
8153
8480
  }
@@ -8387,6 +8714,9 @@ var AxlRuntime = class extends EventEmitter2 {
8387
8714
  this.abortControllers.set(executionId, controller);
8388
8715
  const cleanupAbortForwarder = forwardAbortSignal(options?.signal, controller);
8389
8716
  this.registerWithCostScope(executionId);
8717
+ if (this.streamingFlusher) {
8718
+ this.streamableExecutionIds.add(executionId);
8719
+ }
8390
8720
  const execInfo = {
8391
8721
  executionId,
8392
8722
  workflow: name,
@@ -8394,7 +8724,8 @@ var AxlRuntime = class extends EventEmitter2 {
8394
8724
  events: [],
8395
8725
  totalCost: 0,
8396
8726
  startedAt: Date.now(),
8397
- duration: 0
8727
+ duration: 0,
8728
+ metadata: liftPersistedMetadata(options?.metadata)
8398
8729
  };
8399
8730
  this.executions.set(executionId, execInfo);
8400
8731
  const sessionHistory = options?.metadata?.sessionHistory ?? void 0;
@@ -8410,6 +8741,9 @@ var AxlRuntime = class extends EventEmitter2 {
8410
8741
  onTrace: (event) => {
8411
8742
  pushEventBounded(execInfo, event, this.maxEventsPerExecution);
8412
8743
  execInfo.totalCost += eventCostContribution(event);
8744
+ if (this.streamableExecutionIds.has(executionId)) {
8745
+ this.streamingFlusher?.append(executionId, event);
8746
+ }
8413
8747
  this.emit("trace", event);
8414
8748
  this.outputAxlEvent(event);
8415
8749
  if (event.type === "handoff_start") {
@@ -8488,6 +8822,9 @@ var AxlRuntime = class extends EventEmitter2 {
8488
8822
  const validated = workflow2.inputSchema.parse(input);
8489
8823
  const sessionHistory = options?.metadata?.sessionHistory ?? void 0;
8490
8824
  this.registerWithCostScope(executionId);
8825
+ if (this.streamingFlusher) {
8826
+ this.streamableExecutionIds.add(executionId);
8827
+ }
8491
8828
  execInfo = {
8492
8829
  executionId,
8493
8830
  workflow: name,
@@ -8495,7 +8832,8 @@ var AxlRuntime = class extends EventEmitter2 {
8495
8832
  events: [],
8496
8833
  totalCost: 0,
8497
8834
  startedAt: Date.now(),
8498
- duration: 0
8835
+ duration: 0,
8836
+ metadata: liftPersistedMetadata(options?.metadata)
8499
8837
  };
8500
8838
  this.executions.set(executionId, execInfo);
8501
8839
  const wfCtx = new WorkflowContext({
@@ -8516,6 +8854,9 @@ var AxlRuntime = class extends EventEmitter2 {
8516
8854
  onTrace: (event) => {
8517
8855
  pushEventBounded(execInfo, event, this.maxEventsPerExecution);
8518
8856
  execInfo.totalCost += eventCostContribution(event);
8857
+ if (this.streamableExecutionIds.has(executionId)) {
8858
+ this.streamingFlusher?.append(executionId, event);
8859
+ }
8519
8860
  this.emit("trace", event);
8520
8861
  this.outputAxlEvent(event);
8521
8862
  axlStream._push(event);
@@ -8655,6 +8996,15 @@ var AxlRuntime = class extends EventEmitter2 {
8655
8996
  errors.push(new Error(`${label}: ${err instanceof Error ? err.message : String(err)}`));
8656
8997
  }
8657
8998
  };
8999
+ if (this.streamingFlusher) {
9000
+ await safeClose("streamingFlusher", () => this.streamingFlusher.flushAll());
9001
+ }
9002
+ if (this.persistInflight.size > 0) {
9003
+ await safeClose(
9004
+ "persistInflight",
9005
+ () => Promise.allSettled([...this.persistInflight]).then(() => void 0)
9006
+ );
9007
+ }
8658
9008
  if (this.mcpManager) await safeClose("mcpManager", () => this.mcpManager.shutdown());
8659
9009
  if (this.memoryManager) await safeClose("memoryManager", () => this.memoryManager.close());
8660
9010
  if (this.stateStore.close) await safeClose("stateStore", () => this.stateStore.close());
@@ -8665,6 +9015,81 @@ var AxlRuntime = class extends EventEmitter2 {
8665
9015
  );
8666
9016
  }
8667
9017
  }
9018
+ /**
9019
+ * Recover executions whose process died mid-run when `state.persist:
9020
+ * 'streaming'` was configured. Synthesizes a partial `ExecutionInfo`
9021
+ * from the streaming buffer for each — `status: 'failed'`, error
9022
+ * `'process terminated'` — registers them in the historical cache so
9023
+ * they show up in `getExecutions()`, persists them via the store's
9024
+ * `saveExecution`, and finalizes the streaming buffer.
9025
+ *
9026
+ * Idempotent — if the streaming buffer is already gone (or
9027
+ * `listStreamingExecutions` is empty), this is a no-op. Returns the
9028
+ * list of recovered `ExecutionInfo`s for caller introspection.
9029
+ *
9030
+ * Wire this into your process startup AFTER `runtime.getExecutions()`
9031
+ * has lazy-loaded the historical cache. Order matters: completed
9032
+ * executions already in the store should not be overwritten by a
9033
+ * stale streaming buffer that the previous process didn't get to
9034
+ * finalize. The implementation checks the historical store before
9035
+ * synthesizing, so a re-run is safe.
9036
+ */
9037
+ async recoverIncompleteStreams() {
9038
+ if (!this.stateStore.listStreamingExecutions || !this.stateStore.getStreamingEvents) {
9039
+ return [];
9040
+ }
9041
+ const ids = await this.stateStore.listStreamingExecutions();
9042
+ if (ids.length === 0) return [];
9043
+ await this.getExecutions();
9044
+ const recovered = [];
9045
+ for (const id of ids) {
9046
+ if (this.executions.has(id) || this.streamableExecutionIds.has(id)) {
9047
+ continue;
9048
+ }
9049
+ const existing = this.historicalExecutions.get(id);
9050
+ if (existing) {
9051
+ await this.stateStore.finalizeStreamingEvents?.(id);
9052
+ continue;
9053
+ }
9054
+ const events = await this.stateStore.getStreamingEvents(id);
9055
+ if (events.length === 0) {
9056
+ await this.stateStore.finalizeStreamingEvents?.(id);
9057
+ continue;
9058
+ }
9059
+ const boundedEvents = boundRecoveredEvents(events, this.maxEventsPerExecution);
9060
+ const workflowStartEvent = boundedEvents.find((e) => e.type === "workflow_start");
9061
+ const lastEvent = boundedEvents[boundedEvents.length - 1];
9062
+ const startedAt = workflowStartEvent?.timestamp ?? boundedEvents[0]?.timestamp ?? Date.now();
9063
+ const completedAt = lastEvent.timestamp ?? startedAt;
9064
+ const workflowName = workflowStartEvent?.workflow ?? RECOVERED_UNKNOWN_WORKFLOW;
9065
+ const totalCost = boundedEvents.reduce((sum, e) => sum + eventCostContribution(e), 0);
9066
+ const synthesized = {
9067
+ executionId: id,
9068
+ workflow: workflowName,
9069
+ status: "failed",
9070
+ events: boundedEvents,
9071
+ totalCost,
9072
+ startedAt,
9073
+ completedAt,
9074
+ duration: completedAt - startedAt,
9075
+ error: "process terminated (recovered from streaming buffer)"
9076
+ };
9077
+ if (this.stateStore.saveExecution) {
9078
+ try {
9079
+ await this.stateStore.saveExecution(synthesized);
9080
+ } catch (err) {
9081
+ console.error(
9082
+ `[axl] recoverIncompleteStreams: failed to save synthesized execution ${id}: ${err instanceof Error ? err.message : String(err)}. Streaming buffer left in place for next recovery attempt.`
9083
+ );
9084
+ continue;
9085
+ }
9086
+ }
9087
+ this.historicalExecutions.set(id, synthesized);
9088
+ await this.stateStore.finalizeStreamingEvents?.(id);
9089
+ recovered.push(synthesized);
9090
+ }
9091
+ return recovered;
9092
+ }
8668
9093
  /** Abort a running execution by its ID. */
8669
9094
  abort(executionId) {
8670
9095
  const controller = this.abortControllers.get(executionId);
@@ -8697,15 +9122,85 @@ var AxlRuntime = class extends EventEmitter2 {
8697
9122
  }
8698
9123
  this.emit("eval_result", entry);
8699
9124
  }
9125
+ /**
9126
+ * Delete an execution from history by id. Removes from the active map (if
9127
+ * still running), the historical cache, the configured StateStore, AND
9128
+ * any execution-scoped side state — pending awaitHuman decisions,
9129
+ * resolver maps, streaming buffer registration, in-flight abort controller.
9130
+ * Returns true if an entry was actually removed from any of the three
9131
+ * primary surfaces (active map, historical cache, store).
9132
+ *
9133
+ * In-flight handling: if the execution is still running, this method
9134
+ * **aborts** it (via the registered abort controller) AND marks the id for
9135
+ * skip-on-persist so the workflow's eventual `workflow_end` doesn't
9136
+ * resurrect the row. The workflow itself terminates normally — callers
9137
+ * iterating the returned `AxlStream` will see the abort flow through. If
9138
+ * you need a hard hand-off (delete completes before the workflow tears
9139
+ * down), call `runtime.abort(id)` and `await` the stream/execute promise
9140
+ * before calling this. `streamableExecutionIds` is cleared eagerly so
9141
+ * events emitted during the wind-down don't re-create the streaming
9142
+ * buffer in the store after the deleteExecution-issued DEL.
9143
+ *
9144
+ * Audit trail: emits `execution_deleted` on the runtime's `EventEmitter`
9145
+ * with `{ executionId, wasActive, hadPendingDecision }` so compliance
9146
+ * apps can subscribe via `runtime.on('execution_deleted', ...)` for a
9147
+ * "who deleted what when" log without wrapping this method. Fires
9148
+ * regardless of whether the id was actually present.
9149
+ *
9150
+ * Use cases: GDPR right-to-be-forgotten, operator-driven cleanup of
9151
+ * specific runs (e.g. a workflow that recorded PII the user requested
9152
+ * scrubbed). For bulk eviction by age, use a `RedisStore` TTL instead
9153
+ * (`defaultTtl` / `ttls.executionHistory`).
9154
+ */
9155
+ async deleteExecution(id) {
9156
+ const wasActive = this.executions.has(id);
9157
+ const hadPendingDecision = this.pendingDecisionResolvers.has(id);
9158
+ if (wasActive) {
9159
+ this.pendingDeletedExecutions.add(id);
9160
+ this.streamableExecutionIds.delete(id);
9161
+ const controller = this.abortControllers.get(id);
9162
+ if (controller) {
9163
+ controller.abort();
9164
+ }
9165
+ }
9166
+ if (hadPendingDecision) {
9167
+ this.pendingDecisionResolvers.delete(id);
9168
+ }
9169
+ const existing = await this.getExecution(id);
9170
+ const workflow2 = existing?.workflow;
9171
+ const removedFromActive = this.executions.delete(id);
9172
+ const removedFromHistorical = this.historicalExecutions.delete(id);
9173
+ let removedFromStore = false;
9174
+ if (this.stateStore.deleteExecution) {
9175
+ removedFromStore = await this.stateStore.deleteExecution(id);
9176
+ }
9177
+ this.emit("execution_deleted", {
9178
+ executionId: id,
9179
+ workflow: workflow2,
9180
+ wasActive,
9181
+ hadPendingDecision,
9182
+ removed: removedFromActive || removedFromHistorical || removedFromStore
9183
+ });
9184
+ return removedFromActive || removedFromHistorical || removedFromStore;
9185
+ }
8700
9186
  /**
8701
9187
  * Delete an eval history entry by id. Removes from in-memory cache and
8702
9188
  * the configured StateStore. Returns true if an entry was actually removed.
8703
9189
  *
8704
9190
  * Ensures lazy-loaded history is loaded first so the in-memory cache and
8705
9191
  * the store can't drift apart on the deletion path.
9192
+ *
9193
+ * Emits `eval_deleted` on the runtime's `EventEmitter` with
9194
+ * `{ id, eval, removed }`. Fires on every call — including attempts
9195
+ * against unknown ids (`removed: false`) — symmetric to `execution_deleted`.
9196
+ * Studio's eval-trends aggregator subscribes to this event so deleted
9197
+ * runs don't linger in the trends panel for up to 5 minutes (until the
9198
+ * next periodic rebuild).
8706
9199
  */
8707
9200
  async deleteEvalResult(id) {
8708
9201
  await this.getEvalHistory();
9202
+ const existing = this.evalHistory.find((e) => e.id === id);
9203
+ const evalName = existing?.eval;
8709
9204
  const beforeLength = this.evalHistory.length;
8710
9205
  this.evalHistory = this.evalHistory.filter((e) => e.id !== id);
8711
9206
  const removedFromMemory = this.evalHistory.length < beforeLength;
@@ -8713,7 +9208,13 @@ var AxlRuntime = class extends EventEmitter2 {
8713
9208
  if (this.stateStore.deleteEvalResult) {
8714
9209
  removedFromStore = await this.stateStore.deleteEvalResult(id);
8715
9210
  }
8716
- return removedFromMemory || removedFromStore;
9211
+ const removed = removedFromMemory || removedFromStore;
9212
+ this.emit("eval_deleted", {
9213
+ id,
9214
+ eval: evalName,
9215
+ removed
9216
+ });
9217
+ return removed;
8717
9218
  }
8718
9219
  /** Get eval result history (most recent first). */
8719
9220
  async getEvalHistory() {
@@ -9303,15 +9804,53 @@ async function* stringStreamFromEvents(source, opts) {
9303
9804
 
9304
9805
  // src/state/redis.ts
9305
9806
  var RedisStore = class _RedisStore {
9306
- constructor(client) {
9807
+ constructor(client, keyPrefix, ttls) {
9307
9808
  this.client = client;
9809
+ this.keyPrefix = keyPrefix;
9810
+ this.ttls = ttls;
9811
+ }
9812
+ /**
9813
+ * Resolve the TTL for a given storage category. Returns `undefined` when
9814
+ * no TTL applies (category is `null` in resolved config, the value is
9815
+ * non-positive — Redis EXPIRE 0 immediately deletes, which is almost
9816
+ * never intended — or the value is non-finite).
9817
+ *
9818
+ * Non-finite values (`NaN`, `±Infinity`) defensively map to `undefined`
9819
+ * rather than being passed to Redis, where they'd surface as a wire-level
9820
+ * protocol error at the worst possible time (first write under load).
9821
+ */
9822
+ ttlFor(category) {
9823
+ const resolved = this.ttls[category];
9824
+ if (resolved === null) return void 0;
9825
+ if (!Number.isFinite(resolved) || resolved <= 0) return void 0;
9826
+ return Math.floor(resolved);
9308
9827
  }
9309
9828
  /**
9310
9829
  * Create a connected RedisStore instance.
9311
9830
  *
9312
- * @param url - Redis connection URL (e.g. `redis://localhost:6379`). Defaults to `redis://localhost:6379`.
9831
+ * Accepts either a URL string (back-compat) or a {@link RedisStoreOptions}
9832
+ * object. The object form is required to set a custom `keyPrefix`.
9833
+ *
9834
+ * @example
9835
+ * ```ts
9836
+ * // Default prefix 'axl:'
9837
+ * const store = await RedisStore.create('redis://localhost:6379');
9838
+ *
9839
+ * // Custom prefix for shared-cluster deployments
9840
+ * const store = await RedisStore.create({
9841
+ * url: 'redis://localhost:6379',
9842
+ * keyPrefix: 'axl:prod:',
9843
+ * });
9844
+ * ```
9313
9845
  */
9314
- static async create(url) {
9846
+ static async create(options) {
9847
+ const opts = typeof options === "string" ? { url: options } : options ?? {};
9848
+ const keyPrefix = opts.keyPrefix ?? "axl:";
9849
+ if (keyPrefix === "") {
9850
+ throw new Error(
9851
+ 'RedisStore: keyPrefix cannot be empty string. Omit it for the default "axl:" prefix.'
9852
+ );
9853
+ }
9315
9854
  let createClient;
9316
9855
  try {
9317
9856
  const mod = __require("redis");
@@ -9325,44 +9864,197 @@ var RedisStore = class _RedisStore {
9325
9864
  if (err instanceof Error && err.message.includes("createClient")) throw err;
9326
9865
  throw new Error("redis is required for RedisStore. Install it with: npm install redis");
9327
9866
  }
9328
- const client = url ? createClient({ url }) : createClient();
9867
+ const client = opts.url ? createClient({ url: opts.url }) : createClient();
9329
9868
  await client.connect();
9330
- return new _RedisStore(client);
9869
+ const resolveCategory = (override) => {
9870
+ if (override === null) return null;
9871
+ if (typeof override === "number") return override;
9872
+ return typeof opts.defaultTtl === "number" ? opts.defaultTtl : null;
9873
+ };
9874
+ const resolveStreamingEvents = (override) => {
9875
+ if (override === null || override === void 0) return null;
9876
+ if (typeof override === "number") return override;
9877
+ return null;
9878
+ };
9879
+ const effectiveTtls = {
9880
+ session: resolveCategory(opts.ttls?.session),
9881
+ sessionMeta: resolveCategory(opts.ttls?.sessionMeta),
9882
+ checkpoint: resolveCategory(opts.ttls?.checkpoint),
9883
+ executionState: resolveCategory(opts.ttls?.executionState),
9884
+ executionHistory: resolveCategory(opts.ttls?.executionHistory),
9885
+ evalHistory: resolveCategory(opts.ttls?.evalHistory),
9886
+ memory: resolveCategory(opts.ttls?.memory),
9887
+ streamingEvents: resolveStreamingEvents(opts.ttls?.streamingEvents)
9888
+ };
9889
+ const store = new _RedisStore(client, keyPrefix, effectiveTtls);
9890
+ if (!opts.skipMigration) {
9891
+ await store.backfillExecutionIndex();
9892
+ await store.backfillEvalIndex();
9893
+ }
9894
+ return store;
9895
+ }
9896
+ /**
9897
+ * Backfill the execution-history sorted-set from the legacy ID set.
9898
+ * Idempotent — runs only if the ZSET is missing entries the SET has.
9899
+ * Safe to call manually if you constructed the store with
9900
+ * `skipMigration: true`.
9901
+ */
9902
+ async backfillExecutionIndex() {
9903
+ await this.backfillHistoryIndex(
9904
+ this.execHistorySetKey(),
9905
+ this.execHistoryZsetKey(),
9906
+ (id) => this.execHistoryKey(id),
9907
+ (raw) => {
9908
+ const exec = JSON.parse(raw);
9909
+ return typeof exec.startedAt === "number" ? { score: exec.startedAt, value: exec.executionId } : null;
9910
+ },
9911
+ "execution-history"
9912
+ );
9913
+ }
9914
+ /** Backfill the eval-history sorted-set. Symmetric to `backfillExecutionIndex`. */
9915
+ async backfillEvalIndex() {
9916
+ await this.backfillHistoryIndex(
9917
+ this.evalHistorySetKey(),
9918
+ this.evalHistoryZsetKey(),
9919
+ (id) => this.evalHistoryKey(id),
9920
+ (raw) => {
9921
+ const entry = JSON.parse(raw);
9922
+ return typeof entry.timestamp === "number" ? { score: entry.timestamp, value: entry.id } : null;
9923
+ },
9924
+ "eval-history"
9925
+ );
9926
+ }
9927
+ /**
9928
+ * Shared implementation for both backfill paths. Reads SET members,
9929
+ * batch-MGETs their data blobs in chunks, and ZADDs each into the new
9930
+ * sorted-set inside a single MULTI per chunk.
9931
+ */
9932
+ async backfillHistoryIndex(setKey, zsetKey, dataKey, scoreOf, label) {
9933
+ const [setSize, zsetSize] = await Promise.all([
9934
+ this.client.sCard(setKey),
9935
+ this.client.zCard(zsetKey)
9936
+ ]);
9937
+ if (zsetSize >= setSize) return;
9938
+ const allIds = await this.client.sMembers(setKey);
9939
+ if (allIds.length === 0) return;
9940
+ const CHUNK_SIZE = 500;
9941
+ let migrated = 0;
9942
+ let skipped = 0;
9943
+ for (let i = 0; i < allIds.length; i += CHUNK_SIZE) {
9944
+ const chunk = allIds.slice(i, i + CHUNK_SIZE);
9945
+ const dataKeys = chunk.map((id) => dataKey(id));
9946
+ const raws = await this.client.mGet(dataKeys);
9947
+ const tx = this.client.multi();
9948
+ let queuedInChunk = 0;
9949
+ for (let j = 0; j < chunk.length; j++) {
9950
+ const raw = raws[j];
9951
+ if (raw == null) {
9952
+ skipped++;
9953
+ continue;
9954
+ }
9955
+ try {
9956
+ const entry = scoreOf(raw);
9957
+ if (entry == null) {
9958
+ skipped++;
9959
+ continue;
9960
+ }
9961
+ tx.zAdd(zsetKey, entry);
9962
+ queuedInChunk++;
9963
+ migrated++;
9964
+ } catch {
9965
+ skipped++;
9966
+ }
9967
+ }
9968
+ if (queuedInChunk > 0) await tx.exec();
9969
+ }
9970
+ if (migrated > 0 || skipped > 0) {
9971
+ console.log(
9972
+ `[axl] RedisStore: backfilled ${migrated} ${label} entries into sorted-set index` + (skipped > 0 ? ` (${skipped} skipped \u2014 missing or malformed)` : "")
9973
+ );
9974
+ }
9331
9975
  }
9332
9976
  // ── Key helpers ──────────────────────────────────────────────────────
9333
9977
  checkpointKey(executionId) {
9334
- return `axl:checkpoint:${executionId}`;
9978
+ return `${this.keyPrefix}checkpoint:${executionId}`;
9335
9979
  }
9336
9980
  sessionKey(sessionId) {
9337
- return `axl:session:${sessionId}`;
9981
+ return `${this.keyPrefix}session:${sessionId}`;
9338
9982
  }
9339
9983
  sessionMetaKey(sessionId) {
9340
- return `axl:session-meta:${sessionId}`;
9984
+ return `${this.keyPrefix}session-meta:${sessionId}`;
9985
+ }
9986
+ sessionIdsKey() {
9987
+ return `${this.keyPrefix}session-ids`;
9341
9988
  }
9342
9989
  decisionsKey() {
9343
- return "axl:decisions";
9990
+ return `${this.keyPrefix}decisions`;
9344
9991
  }
9345
9992
  executionStateKey(executionId) {
9346
- return `axl:exec-state:${executionId}`;
9993
+ return `${this.keyPrefix}exec-state:${executionId}`;
9347
9994
  }
9348
9995
  pendingExecSetKey() {
9349
- return "axl:pending-executions";
9996
+ return `${this.keyPrefix}pending-executions`;
9350
9997
  }
9351
9998
  execHistoryKey(executionId) {
9352
- return `axl:exec-history:${executionId}`;
9353
- }
9999
+ return `${this.keyPrefix}exec-history:${executionId}`;
10000
+ }
10001
+ // NOTE for future implementer of #3 (TTL config): when the executionHistory
10002
+ // and evalHistory data blobs gain TTLs, eviction MUST be driven by
10003
+ // ZREMRANGEBYSCORE on the ZSET below — NOT by SREM on these legacy ID sets.
10004
+ // The slow-path fallback (`listHistoryByZset`) reads from the SET when
10005
+ // `zsetCard < setCard`, so SET-based eviction would race with the fallback
10006
+ // and surface dead IDs whose data has been TTL'd away. ZSET eviction +
10007
+ // dual-write `SREM` is the clean path; doc the assumption then.
9354
10008
  execHistorySetKey() {
9355
- return "axl:exec-history-ids";
10009
+ return `${this.keyPrefix}exec-history-ids`;
10010
+ }
10011
+ /**
10012
+ * Sorted-set index for execution history, scored by `startedAt`. Drives
10013
+ * the O(log N) `listExecutions` fast path; the legacy SET above is kept
10014
+ * as a fallback for users running with `skipMigration: true` before
10015
+ * they've called `backfillExecutionIndex()` manually.
10016
+ */
10017
+ execHistoryZsetKey() {
10018
+ return `${this.keyPrefix}exec-history-z`;
9356
10019
  }
9357
10020
  evalHistoryKey(id) {
9358
- return `axl:eval-history:${id}`;
10021
+ return `${this.keyPrefix}eval-history:${id}`;
9359
10022
  }
9360
10023
  evalHistorySetKey() {
9361
- return "axl:eval-history-ids";
10024
+ return `${this.keyPrefix}eval-history-ids`;
10025
+ }
10026
+ /** Sorted-set index for eval history, scored by `timestamp`. See above. */
10027
+ evalHistoryZsetKey() {
10028
+ return `${this.keyPrefix}eval-history-z`;
10029
+ }
10030
+ memoryKey(scope) {
10031
+ return `${this.keyPrefix}memory:${scope}`;
10032
+ }
10033
+ // ── Streaming event buffer (for `state.persist: 'streaming'`) ─────────
10034
+ // Layout:
10035
+ // `{prefix}exec-events:{id}` — a Redis list. Events are RPUSH'd as
10036
+ // JSON-serialized AxlEvent objects, in batches. The runtime LRANGEs
10037
+ // the whole list on recovery and deletes it via `finalizeStreamingEvents`
10038
+ // once the canonical `executionHistory` blob has been written at
10039
+ // terminal exit.
10040
+ // `{prefix}streaming-exec-ids` — a Redis set tracking which executions
10041
+ // have an active streaming buffer. Used by `listStreamingExecutions`
10042
+ // to find runs whose process died mid-flight (no corresponding
10043
+ // `executionHistory` blob).
10044
+ streamingEventsKey(executionId) {
10045
+ return `${this.keyPrefix}exec-events:${executionId}`;
10046
+ }
10047
+ streamingIdsKey() {
10048
+ return `${this.keyPrefix}streaming-exec-ids`;
9362
10049
  }
9363
10050
  // ── Checkpoints ──────────────────────────────────────────────────────
9364
10051
  async saveCheckpoint(executionId, name, data) {
9365
- await this.client.hSet(this.checkpointKey(executionId), name, JSON.stringify(data));
10052
+ const ttl = this.ttlFor("checkpoint");
10053
+ if (ttl === void 0) {
10054
+ await this.client.hSet(this.checkpointKey(executionId), name, JSON.stringify(data));
10055
+ return;
10056
+ }
10057
+ await this.client.multi().hSet(this.checkpointKey(executionId), name, JSON.stringify(data)).expire(this.checkpointKey(executionId), ttl, "NX").exec();
9366
10058
  }
9367
10059
  async getCheckpoint(executionId, name) {
9368
10060
  const raw = await this.client.hGet(this.checkpointKey(executionId), name);
@@ -9370,20 +10062,24 @@ var RedisStore = class _RedisStore {
9370
10062
  }
9371
10063
  // ── Sessions ─────────────────────────────────────────────────────────
9372
10064
  async saveSession(sessionId, history) {
9373
- await this.client.set(this.sessionKey(sessionId), JSON.stringify(history));
9374
- await this.client.sAdd("axl:session-ids", sessionId);
10065
+ const ttl = this.ttlFor("session");
10066
+ const setOptions = ttl !== void 0 ? { EX: ttl } : void 0;
10067
+ await this.client.multi().set(this.sessionKey(sessionId), JSON.stringify(history), setOptions).sAdd(this.sessionIdsKey(), sessionId).exec();
9375
10068
  }
9376
10069
  async getSession(sessionId) {
9377
10070
  const raw = await this.client.get(this.sessionKey(sessionId));
9378
10071
  return raw ? JSON.parse(raw) : [];
9379
10072
  }
9380
10073
  async deleteSession(sessionId) {
9381
- await this.client.del(this.sessionKey(sessionId));
9382
- await this.client.del(this.sessionMetaKey(sessionId));
9383
- await this.client.sRem("axl:session-ids", sessionId);
10074
+ await this.client.multi().del(this.sessionKey(sessionId)).del(this.sessionMetaKey(sessionId)).sRem(this.sessionIdsKey(), sessionId).exec();
9384
10075
  }
9385
10076
  async saveSessionMeta(sessionId, key, value) {
9386
- await this.client.hSet(this.sessionMetaKey(sessionId), key, JSON.stringify(value));
10077
+ const ttl = this.ttlFor("sessionMeta");
10078
+ if (ttl === void 0) {
10079
+ await this.client.hSet(this.sessionMetaKey(sessionId), key, JSON.stringify(value));
10080
+ return;
10081
+ }
10082
+ await this.client.multi().hSet(this.sessionMetaKey(sessionId), key, JSON.stringify(value)).expire(this.sessionMetaKey(sessionId), ttl).exec();
9387
10083
  }
9388
10084
  async getSessionMeta(sessionId, key) {
9389
10085
  const raw = await this.client.hGet(this.sessionMetaKey(sessionId), key);
@@ -9403,64 +10099,222 @@ var RedisStore = class _RedisStore {
9403
10099
  }
9404
10100
  // ── Execution State ──────────────────────────────────────────────────
9405
10101
  async saveExecutionState(executionId, state) {
9406
- await this.client.set(this.executionStateKey(executionId), JSON.stringify(state));
10102
+ const ttl = this.ttlFor("executionState");
10103
+ const setOptions = ttl !== void 0 ? { EX: ttl } : void 0;
10104
+ const tx = this.client.multi().set(this.executionStateKey(executionId), JSON.stringify(state), setOptions);
9407
10105
  if (state.status === "waiting") {
9408
- await this.client.sAdd(this.pendingExecSetKey(), executionId);
10106
+ tx.sAdd(this.pendingExecSetKey(), executionId);
9409
10107
  } else {
9410
- await this.client.sRem(this.pendingExecSetKey(), executionId);
10108
+ tx.sRem(this.pendingExecSetKey(), executionId);
9411
10109
  }
10110
+ await tx.exec();
9412
10111
  }
9413
10112
  async getExecutionState(executionId) {
9414
10113
  const raw = await this.client.get(this.executionStateKey(executionId));
9415
10114
  return raw ? JSON.parse(raw) : null;
9416
10115
  }
9417
10116
  async listPendingExecutions() {
9418
- return this.client.sMembers(this.pendingExecSetKey());
10117
+ const ids = await this.client.sMembers(this.pendingExecSetKey());
10118
+ if (ids.length === 0) return ids;
10119
+ const keys = ids.map((id) => this.executionStateKey(id));
10120
+ const raws = await this.client.mGet(keys);
10121
+ const live = [];
10122
+ const dead = [];
10123
+ for (let i = 0; i < ids.length; i++) {
10124
+ if (raws[i] != null) {
10125
+ live.push(ids[i]);
10126
+ } else {
10127
+ dead.push(ids[i]);
10128
+ }
10129
+ }
10130
+ if (dead.length > 0) {
10131
+ this.client.sRem(this.pendingExecSetKey(), dead).catch((err) => {
10132
+ console.error(
10133
+ `[axl] RedisStore: failed to prune ${dead.length} stale pending-exec ids: ` + (err instanceof Error ? err.message : String(err))
10134
+ );
10135
+ });
10136
+ }
10137
+ return live;
10138
+ }
10139
+ // ── Memory ──────────────────────────────────────────────────────────
10140
+ //
10141
+ // Layout: `{prefix}memory:{scope}` is a hash. Fields are user-supplied
10142
+ // keys; values are JSON-serialized. Hash-per-scope keeps related entries
10143
+ // together (good cohesion for TTL eviction once #3 lands) and bounds the
10144
+ // keyspace to O(scopes) instead of O(scopes × keys).
10145
+ //
10146
+ // Pre-this-patch, RedisStore had no memory methods, so MemoryManager fell
10147
+ // back to writing memory at synthetic sessionMeta keys
10148
+ // (`{prefix}session-meta:memory:{scope}:{key}` hash field `value`). To
10149
+ // preserve user data on upgrade, `getMemory` checks the legacy location on
10150
+ // miss and migrates forward; `deleteMemory` also cleans the legacy entry.
10151
+ // No legacy support on `getAllMemory` — that method didn't exist before,
10152
+ // so no user can depend on it returning legacy data.
10153
+ async saveMemory(scope, key, value) {
10154
+ const ttl = this.ttlFor("memory");
10155
+ if (ttl === void 0) {
10156
+ await this.client.hSet(this.memoryKey(scope), key, JSON.stringify(value));
10157
+ return;
10158
+ }
10159
+ await this.client.multi().hSet(this.memoryKey(scope), key, JSON.stringify(value)).expire(this.memoryKey(scope), ttl).exec();
10160
+ }
10161
+ async getMemory(scope, key) {
10162
+ const raw = await this.client.hGet(this.memoryKey(scope), key);
10163
+ if (raw != null) return JSON.parse(raw);
10164
+ const legacyRaw = await this.client.hGet(
10165
+ this.sessionMetaKey(`memory:${scope}:${key}`),
10166
+ "value"
10167
+ );
10168
+ if (legacyRaw == null) return null;
10169
+ const inserted = await this.client.hSetNX(this.memoryKey(scope), key, legacyRaw);
10170
+ if (inserted === 0 || inserted === false) {
10171
+ const winner = await this.client.hGet(this.memoryKey(scope), key);
10172
+ if (winner != null) return JSON.parse(winner);
10173
+ return null;
10174
+ }
10175
+ const ttl = this.ttlFor("memory");
10176
+ if (ttl !== void 0) {
10177
+ this.client.expire(this.memoryKey(scope), ttl).catch((err) => {
10178
+ console.error(
10179
+ `[axl] RedisStore: failed to set TTL on migrated memory key ${this.memoryKey(scope)}: ` + (err instanceof Error ? err.message : String(err))
10180
+ );
10181
+ });
10182
+ }
10183
+ return JSON.parse(legacyRaw);
10184
+ }
10185
+ async getAllMemory(scope) {
10186
+ const all = await this.client.hGetAll(this.memoryKey(scope));
10187
+ if (!all) return [];
10188
+ return Object.entries(all).map(([key, raw]) => ({ key, value: JSON.parse(raw) }));
10189
+ }
10190
+ async deleteMemory(scope, key) {
10191
+ await this.client.multi().hDel(this.memoryKey(scope), key).hDel(this.sessionMetaKey(`memory:${scope}:${key}`), "value").exec();
9419
10192
  }
9420
10193
  // ── Execution History ────────────────────────────────────────────────
9421
10194
  async saveExecution(execution) {
9422
- await this.client.sAdd(this.execHistorySetKey(), execution.executionId);
9423
- await this.client.set(this.execHistoryKey(execution.executionId), JSON.stringify(execution));
10195
+ const ttl = this.ttlFor("executionHistory");
10196
+ const setOptions = ttl !== void 0 ? { EX: ttl } : void 0;
10197
+ await this.client.multi().sAdd(this.execHistorySetKey(), execution.executionId).set(this.execHistoryKey(execution.executionId), JSON.stringify(execution), setOptions).zAdd(this.execHistoryZsetKey(), {
10198
+ score: execution.startedAt,
10199
+ value: execution.executionId
10200
+ }).exec();
9424
10201
  }
9425
10202
  async getExecution(executionId) {
9426
10203
  const raw = await this.client.get(this.execHistoryKey(executionId));
9427
10204
  return raw ? JSON.parse(raw) : null;
9428
10205
  }
9429
10206
  async listExecutions(limit) {
9430
- const ids = await this.client.sMembers(this.execHistorySetKey());
9431
- if (ids.length === 0) return [];
9432
- const entries = [];
9433
- for (const id of ids) {
9434
- const raw = await this.client.get(this.execHistoryKey(id));
9435
- if (raw) entries.push(JSON.parse(raw));
9436
- }
9437
- entries.sort((a, b) => b.startedAt - a.startedAt);
9438
- return limit ? entries.slice(0, limit) : entries;
10207
+ return this.listHistoryByZset(
10208
+ this.execHistorySetKey(),
10209
+ this.execHistoryZsetKey(),
10210
+ (id) => this.execHistoryKey(id),
10211
+ (a, b) => b.startedAt - a.startedAt,
10212
+ limit
10213
+ );
10214
+ }
10215
+ async deleteExecution(executionId) {
10216
+ 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();
10217
+ const deleted = typeof deletedCount === "number" ? deletedCount : 0;
10218
+ return deleted > 0;
9439
10219
  }
9440
10220
  // ── Eval History ────────────────────────────────────────────────────
9441
10221
  async saveEvalResult(entry) {
9442
- await this.client.sAdd(this.evalHistorySetKey(), entry.id);
9443
- await this.client.set(this.evalHistoryKey(entry.id), JSON.stringify(entry));
10222
+ const ttl = this.ttlFor("evalHistory");
10223
+ const setOptions = ttl !== void 0 ? { EX: ttl } : void 0;
10224
+ 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();
9444
10225
  }
9445
10226
  async listEvalResults(limit) {
9446
- const ids = await this.client.sMembers(this.evalHistorySetKey());
10227
+ return this.listHistoryByZset(
10228
+ this.evalHistorySetKey(),
10229
+ this.evalHistoryZsetKey(),
10230
+ (id) => this.evalHistoryKey(id),
10231
+ (a, b) => b.timestamp - a.timestamp,
10232
+ limit
10233
+ );
10234
+ }
10235
+ async deleteEvalResult(id) {
10236
+ const [, deletedCount] = await this.client.multi().sRem(this.evalHistorySetKey(), id).del(this.evalHistoryKey(id)).zRem(this.evalHistoryZsetKey(), id).exec();
10237
+ const deleted = typeof deletedCount === "number" ? deletedCount : 0;
10238
+ return deleted > 0;
10239
+ }
10240
+ /**
10241
+ * Shared list-by-sorted-set implementation. Uses ZREVRANGEBYSCORE +
10242
+ * MGET when the ZSET is populated (post-migration). Falls back to the
10243
+ * legacy SET + N×GET + JS-sort path when the ZSET is empty or smaller
10244
+ * than the SET (covers users that constructed with `skipMigration: true`
10245
+ * before calling backfill).
10246
+ */
10247
+ async listHistoryByZset(setKey, zsetKey, dataKey, sortCmp, limit) {
10248
+ const setSize = await this.client.sCard(setKey);
10249
+ if (setSize === 0) return [];
10250
+ const zsetSize = await this.client.zCard(zsetKey);
10251
+ if (zsetSize >= setSize) {
10252
+ const fetchCount = limit !== void 0 ? Math.max(limit * 2, limit + 5) : void 0;
10253
+ const ids2 = await this.client.zRange(zsetKey, "+inf", "-inf", {
10254
+ BY: "SCORE",
10255
+ REV: true,
10256
+ ...fetchCount !== void 0 ? { LIMIT: { offset: 0, count: fetchCount } } : {}
10257
+ });
10258
+ if (ids2.length === 0) return [];
10259
+ const keys = ids2.map(dataKey);
10260
+ const raws = await this.client.mGet(keys);
10261
+ const entries2 = [];
10262
+ for (const raw of raws) {
10263
+ if (raw == null) continue;
10264
+ try {
10265
+ entries2.push(JSON.parse(raw));
10266
+ } catch {
10267
+ }
10268
+ if (limit !== void 0 && entries2.length >= limit) break;
10269
+ }
10270
+ return entries2;
10271
+ }
10272
+ const ids = await this.client.sMembers(setKey);
9447
10273
  if (ids.length === 0) return [];
9448
10274
  const entries = [];
9449
10275
  for (const id of ids) {
9450
- const raw = await this.client.get(this.evalHistoryKey(id));
9451
- if (raw) entries.push(JSON.parse(raw));
10276
+ const raw = await this.client.get(dataKey(id));
10277
+ if (raw == null) continue;
10278
+ try {
10279
+ entries.push(JSON.parse(raw));
10280
+ } catch {
10281
+ }
9452
10282
  }
9453
- entries.sort((a, b) => b.timestamp - a.timestamp);
10283
+ entries.sort(sortCmp);
9454
10284
  return limit ? entries.slice(0, limit) : entries;
9455
10285
  }
9456
- async deleteEvalResult(id) {
9457
- await this.client.sRem(this.evalHistorySetKey(), id);
9458
- const deleted = await this.client.del(this.evalHistoryKey(id));
9459
- return deleted > 0;
9460
- }
9461
10286
  // ── Sessions (Studio introspection) ────────────────────────────────────
9462
10287
  async listSessions() {
9463
- return this.client.sMembers("axl:session-ids");
10288
+ return this.client.sMembers(this.sessionIdsKey());
10289
+ }
10290
+ // ── Streaming events (for state.persist: 'streaming') ──────────────────
10291
+ async appendStreamingEvents(executionId, events) {
10292
+ if (events.length === 0) return;
10293
+ const serialized = events.map((e) => JSON.stringify(e));
10294
+ const ttl = this.ttlFor("streamingEvents");
10295
+ const tx = this.client.multi().rPush(this.streamingEventsKey(executionId), serialized).sAdd(this.streamingIdsKey(), executionId);
10296
+ if (ttl !== void 0) {
10297
+ tx.expire(this.streamingEventsKey(executionId), ttl, "NX");
10298
+ }
10299
+ await tx.exec();
10300
+ }
10301
+ async finalizeStreamingEvents(executionId) {
10302
+ await this.client.multi().del(this.streamingEventsKey(executionId)).sRem(this.streamingIdsKey(), executionId).exec();
10303
+ }
10304
+ async listStreamingExecutions() {
10305
+ return this.client.sMembers(this.streamingIdsKey());
10306
+ }
10307
+ async getStreamingEvents(executionId) {
10308
+ const raws = await this.client.lRange(this.streamingEventsKey(executionId), 0, -1);
10309
+ if (raws.length === 0) return [];
10310
+ const events = [];
10311
+ for (const raw of raws) {
10312
+ try {
10313
+ events.push(JSON.parse(raw));
10314
+ } catch {
10315
+ }
10316
+ }
10317
+ return events;
9464
10318
  }
9465
10319
  /** Close the Redis connection. */
9466
10320
  async close() {