@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.cjs CHANGED
@@ -6082,8 +6082,31 @@ ${summaryResponse.content}`
6082
6082
  type: "await_human",
6083
6083
  data: { channel: options.channel, prompt: options.prompt }
6084
6084
  });
6085
- const decision = await new Promise((resolve2) => {
6085
+ const makeAbortError = (reason) => {
6086
+ if (typeof DOMException !== "undefined") {
6087
+ return new DOMException(
6088
+ typeof reason === "string" ? reason : "awaitHuman aborted",
6089
+ "AbortError"
6090
+ );
6091
+ }
6092
+ const err = new Error(typeof reason === "string" ? reason : "awaitHuman aborted");
6093
+ err.name = "AbortError";
6094
+ return err;
6095
+ };
6096
+ const decision = await new Promise((resolve2, reject) => {
6097
+ if (this.signal?.aborted) {
6098
+ reject(makeAbortError(this.signal.reason));
6099
+ return;
6100
+ }
6086
6101
  this.pendingDecisions.set(this.executionId, resolve2);
6102
+ const onAbort = () => {
6103
+ const current = this.pendingDecisions.get(this.executionId);
6104
+ if (current === resolve2) {
6105
+ this.pendingDecisions.delete(this.executionId);
6106
+ }
6107
+ reject(makeAbortError(this.signal?.reason));
6108
+ };
6109
+ this.signal?.addEventListener("abort", onAbort, { once: true });
6087
6110
  });
6088
6111
  this.emitEvent({
6089
6112
  type: "await_human_resolved",
@@ -6708,6 +6731,16 @@ var MemoryStore = class {
6708
6731
  const result = limit ? sorted.slice(0, limit) : sorted;
6709
6732
  return result.map((e) => structuredClone(e));
6710
6733
  }
6734
+ async deleteExecution(executionId) {
6735
+ const removed = this.executionHistory.delete(executionId);
6736
+ this.checkpoints.delete(executionId);
6737
+ this.executionStates.delete(executionId);
6738
+ this.streamingEvents.delete(executionId);
6739
+ if (this.decisions.delete(executionId)) {
6740
+ this.persistAwaitHumanState();
6741
+ }
6742
+ return removed;
6743
+ }
6711
6744
  // ── Eval History ──────────────────────────────────────────────────
6712
6745
  async saveEvalResult(entry) {
6713
6746
  this.evalHistory.set(entry.id, structuredClone(entry));
@@ -6724,6 +6757,30 @@ var MemoryStore = class {
6724
6757
  async listSessions() {
6725
6758
  return [...this.sessions.keys()];
6726
6759
  }
6760
+ // ── Streaming events (for state.persist: 'streaming') ────────────────
6761
+ //
6762
+ // MemoryStore's raison d'être is in-process / ephemeral. We implement
6763
+ // the streaming methods so tests and dev workflows behave correctly,
6764
+ // but on a process crash these buffers are lost — same as the rest of
6765
+ // MemoryStore's state. For real crash-survival, use RedisStore or
6766
+ // SQLiteStore.
6767
+ streamingEvents = /* @__PURE__ */ new Map();
6768
+ async appendStreamingEvents(executionId, events) {
6769
+ if (events.length === 0) return;
6770
+ const existing = this.streamingEvents.get(executionId) ?? [];
6771
+ existing.push(...events.map((e) => structuredClone(e)));
6772
+ this.streamingEvents.set(executionId, existing);
6773
+ }
6774
+ async finalizeStreamingEvents(executionId) {
6775
+ this.streamingEvents.delete(executionId);
6776
+ }
6777
+ async listStreamingExecutions() {
6778
+ return [...this.streamingEvents.keys()];
6779
+ }
6780
+ async getStreamingEvents(executionId) {
6781
+ const events = this.streamingEvents.get(executionId) ?? [];
6782
+ return events.map((e) => structuredClone(e));
6783
+ }
6727
6784
  // ── Lifecycle ──────────────────────────────────────────────────────
6728
6785
  async close() {
6729
6786
  }
@@ -6780,7 +6837,7 @@ function safeJsonParse(data) {
6780
6837
  }
6781
6838
  }
6782
6839
  function rowToExecutionInfo(row) {
6783
- return {
6840
+ const info = {
6784
6841
  executionId: row.execution_id,
6785
6842
  workflow: row.workflow,
6786
6843
  status: row.status,
@@ -6791,6 +6848,13 @@ function rowToExecutionInfo(row) {
6791
6848
  error: row.error ?? void 0,
6792
6849
  events: safeJsonParse(row.events) ?? []
6793
6850
  };
6851
+ if (row.metadata != null) {
6852
+ const parsed = safeJsonParse(row.metadata);
6853
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
6854
+ info.metadata = parsed;
6855
+ }
6856
+ }
6857
+ return info;
6794
6858
  }
6795
6859
  var SQLiteStore = class {
6796
6860
  db;
@@ -6826,6 +6890,10 @@ var SQLiteStore = class {
6826
6890
  * numeric step values become stringified names ("0", "1", …) to
6827
6891
  * preserve replay continuity for in-flight executions across the
6828
6892
  * upgrade boundary.
6893
+ * - v3 (ExecutionInfo.metadata): add `execution_history.metadata`
6894
+ * column (TEXT, nullable, JSON-serialized) so caller-supplied
6895
+ * `ExecuteOptions.metadata` round-trips through history. Existing
6896
+ * rows get `NULL`, deserialized as `metadata: undefined`.
6829
6897
  *
6830
6898
  * Applied BEFORE `initTables()` so the subsequent `CREATE TABLE
6831
6899
  * IF NOT EXISTS` runs against the post-migration column name. Fresh
@@ -6843,7 +6911,7 @@ var SQLiteStore = class {
6843
6911
  * transactional re-read costs nothing.
6844
6912
  */
6845
6913
  migrate() {
6846
- const TARGET_VERSION = 2;
6914
+ const TARGET_VERSION = 3;
6847
6915
  const current = this.db.pragma("user_version", { simple: true });
6848
6916
  if (current >= TARGET_VERSION) return;
6849
6917
  this.db.exec("BEGIN IMMEDIATE");
@@ -6865,6 +6933,12 @@ var SQLiteStore = class {
6865
6933
  this.db.exec("ALTER TABLE checkpoints RENAME COLUMN step TO name");
6866
6934
  }
6867
6935
  }
6936
+ if (committed < 3) {
6937
+ const cols = this.db.pragma("table_info(execution_history)");
6938
+ if (cols.length > 0 && !cols.some((c) => c.name === "metadata")) {
6939
+ this.db.exec("ALTER TABLE execution_history ADD COLUMN metadata TEXT");
6940
+ }
6941
+ }
6868
6942
  this.db.pragma(`user_version = ${TARGET_VERSION}`);
6869
6943
  this.db.exec("COMMIT");
6870
6944
  } catch (err) {
@@ -6927,7 +7001,8 @@ var SQLiteStore = class {
6927
7001
  completed_at INTEGER,
6928
7002
  duration INTEGER NOT NULL DEFAULT 0,
6929
7003
  error TEXT,
6930
- events TEXT NOT NULL
7004
+ events TEXT NOT NULL,
7005
+ metadata TEXT
6931
7006
  );
6932
7007
 
6933
7008
  CREATE TABLE IF NOT EXISTS eval_history (
@@ -7048,7 +7123,7 @@ var SQLiteStore = class {
7048
7123
  // ── Execution History ────────────────────────────────────────────────────
7049
7124
  async saveExecution(execution) {
7050
7125
  this.db.prepare(
7051
- "INSERT OR REPLACE INTO execution_history (execution_id, workflow, status, total_cost, started_at, completed_at, duration, error, events) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
7126
+ "INSERT OR REPLACE INTO execution_history (execution_id, workflow, status, total_cost, started_at, completed_at, duration, error, events, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
7052
7127
  ).run(
7053
7128
  execution.executionId,
7054
7129
  execution.workflow,
@@ -7058,7 +7133,8 @@ var SQLiteStore = class {
7058
7133
  execution.completedAt ?? null,
7059
7134
  execution.duration,
7060
7135
  execution.error ?? null,
7061
- JSON.stringify(execution.events)
7136
+ JSON.stringify(execution.events),
7137
+ execution.metadata !== void 0 ? JSON.stringify(execution.metadata) : null
7062
7138
  );
7063
7139
  }
7064
7140
  async getExecution(executionId) {
@@ -7070,6 +7146,16 @@ var SQLiteStore = class {
7070
7146
  const rows = limit ? this.db.prepare(sql).all(limit) : this.db.prepare(sql).all();
7071
7147
  return rows.map(rowToExecutionInfo);
7072
7148
  }
7149
+ async deleteExecution(executionId) {
7150
+ const tx = this.db.transaction(() => {
7151
+ const result = this.db.prepare("DELETE FROM execution_history WHERE execution_id = ?").run(executionId);
7152
+ this.db.prepare("DELETE FROM checkpoints WHERE execution_id = ?").run(executionId);
7153
+ this.db.prepare("DELETE FROM execution_state WHERE execution_id = ?").run(executionId);
7154
+ this.db.prepare("DELETE FROM decisions WHERE execution_id = ?").run(executionId);
7155
+ return result.changes > 0;
7156
+ });
7157
+ return tx();
7158
+ }
7073
7159
  // ── Eval History ────────────────────────────────────────────────────────
7074
7160
  async saveEvalResult(entry) {
7075
7161
  this.db.prepare(
@@ -7948,6 +8034,7 @@ var MemoryManager = class {
7948
8034
  if (stateStore.saveMemory) {
7949
8035
  await stateStore.saveMemory(storeScope, key, value);
7950
8036
  } else {
8037
+ warnMissingMemoryMethodsOnce(stateStore);
7951
8038
  const storeKey = `memory:${storeScope}:${key}`;
7952
8039
  await stateStore.saveSessionMeta(storeKey, "value", value);
7953
8040
  if (options?.metadata) {
@@ -8050,6 +8137,14 @@ var MemoryManager = class {
8050
8137
  }
8051
8138
  }
8052
8139
  };
8140
+ var _warnedStoresMissingMemory = /* @__PURE__ */ new WeakSet();
8141
+ function warnMissingMemoryMethodsOnce(stateStore) {
8142
+ if (_warnedStoresMissingMemory.has(stateStore)) return;
8143
+ _warnedStoresMissingMemory.add(stateStore);
8144
+ console.warn(
8145
+ "[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."
8146
+ );
8147
+ }
8053
8148
 
8054
8149
  // src/telemetry/noop.ts
8055
8150
  var NOOP_SPAN = {
@@ -8091,11 +8186,141 @@ function hashInput(input) {
8091
8186
  return (hash >>> 0).toString(16);
8092
8187
  }
8093
8188
  var DEFAULT_MAX_EVENTS_PER_EXECUTION = 5e4;
8189
+ var DEFAULT_STREAMING_BATCH_SIZE = 100;
8190
+ var DEFAULT_STREAMING_BATCH_INTERVAL = 1e3;
8191
+ var RECOVERED_UNKNOWN_WORKFLOW = "__axl/recovered";
8192
+ var STREAMING_EXCLUDED_TYPES = /* @__PURE__ */ new Set([
8193
+ "token",
8194
+ "partial_object",
8195
+ "string_delta"
8196
+ ]);
8197
+ var INTERNAL_METADATA_KEYS = /* @__PURE__ */ new Set([
8198
+ "sessionId",
8199
+ "sessionHistory",
8200
+ "resumeMode"
8201
+ ]);
8202
+ function liftPersistedMetadata(metadata) {
8203
+ if (!metadata) return void 0;
8204
+ const filtered = {};
8205
+ let hasAny = false;
8206
+ for (const [k, v] of Object.entries(metadata)) {
8207
+ if (INTERNAL_METADATA_KEYS.has(k)) continue;
8208
+ filtered[k] = v;
8209
+ hasAny = true;
8210
+ }
8211
+ if (!hasAny) return void 0;
8212
+ try {
8213
+ return structuredClone(filtered);
8214
+ } catch {
8215
+ return { ...filtered };
8216
+ }
8217
+ }
8218
+ var StreamingFlusher = class {
8219
+ constructor(store, batchSize, batchInterval) {
8220
+ this.store = store;
8221
+ this.batchSize = batchSize;
8222
+ this.batchInterval = batchInterval;
8223
+ }
8224
+ buffers = /* @__PURE__ */ new Map();
8225
+ timers = /* @__PURE__ */ new Map();
8226
+ /**
8227
+ * Tracks the most recent in-flight `appendStreamingEvents` per execution
8228
+ * so `finalize` can await it. Without this, a size-triggered fire-and-
8229
+ * forget flush whose RPUSH lands AFTER `finalize`'s DEL would resurrect
8230
+ * the buffer + re-register the execution in the streaming-exec-ids set
8231
+ * — a phantom orphan that recoverIncompleteStreams would later mis-recover.
8232
+ */
8233
+ inflight = /* @__PURE__ */ new Map();
8234
+ append(executionId, event) {
8235
+ if (STREAMING_EXCLUDED_TYPES.has(event.type)) return;
8236
+ let buffer = this.buffers.get(executionId);
8237
+ if (!buffer) {
8238
+ buffer = [];
8239
+ this.buffers.set(executionId, buffer);
8240
+ }
8241
+ buffer.push(event);
8242
+ if (buffer.length >= this.batchSize) {
8243
+ void this.flush(executionId);
8244
+ return;
8245
+ }
8246
+ if (!this.timers.has(executionId)) {
8247
+ const timer = setTimeout(() => {
8248
+ this.timers.delete(executionId);
8249
+ void this.flush(executionId);
8250
+ }, this.batchInterval);
8251
+ if (typeof timer === "object" && timer !== null && "unref" in timer) {
8252
+ timer.unref?.();
8253
+ }
8254
+ this.timers.set(executionId, timer);
8255
+ }
8256
+ }
8257
+ /** Flush this execution's buffer immediately. Awaits any prior in-flight
8258
+ * flush for the same execution so writes land in order. */
8259
+ async flush(executionId) {
8260
+ const buffer = this.buffers.get(executionId);
8261
+ if (!buffer || buffer.length === 0) return;
8262
+ this.buffers.set(executionId, []);
8263
+ const timer = this.timers.get(executionId);
8264
+ if (timer) {
8265
+ clearTimeout(timer);
8266
+ this.timers.delete(executionId);
8267
+ }
8268
+ const prior = this.inflight.get(executionId) ?? Promise.resolve();
8269
+ const next = prior.then(async () => {
8270
+ try {
8271
+ await this.store.appendStreamingEvents?.(executionId, buffer);
8272
+ } catch (err) {
8273
+ console.error(
8274
+ `[axl] StreamingFlusher: failed to append ${buffer.length} events for executionId=${executionId}: ${err instanceof Error ? err.message : String(err)}`
8275
+ );
8276
+ }
8277
+ });
8278
+ this.inflight.set(executionId, next);
8279
+ next.finally(() => {
8280
+ if (this.inflight.get(executionId) === next) {
8281
+ this.inflight.delete(executionId);
8282
+ }
8283
+ });
8284
+ return next;
8285
+ }
8286
+ /** Finalize an execution — flush remaining buffer + release store-side buffer.
8287
+ * Critically, awaits the most recent in-flight `appendStreamingEvents` BEFORE
8288
+ * calling `finalizeStreamingEvents`, so a late-landing RPUSH can't resurrect
8289
+ * the buffer after we've deleted it. */
8290
+ async finalize(executionId) {
8291
+ await this.flush(executionId);
8292
+ const pending = this.inflight.get(executionId);
8293
+ if (pending) {
8294
+ await pending;
8295
+ this.inflight.delete(executionId);
8296
+ }
8297
+ this.buffers.delete(executionId);
8298
+ const timer = this.timers.get(executionId);
8299
+ if (timer) {
8300
+ clearTimeout(timer);
8301
+ this.timers.delete(executionId);
8302
+ }
8303
+ try {
8304
+ await this.store.finalizeStreamingEvents?.(executionId);
8305
+ } catch (err) {
8306
+ console.error(
8307
+ `[axl] StreamingFlusher: failed to finalize executionId=${executionId}: ` + (err instanceof Error ? err.message : String(err))
8308
+ );
8309
+ }
8310
+ }
8311
+ /** Best-effort drain of every pending buffer. Called from `runtime.shutdown()`. */
8312
+ async flushAll() {
8313
+ const ids = [...this.buffers.keys()];
8314
+ await Promise.all(ids.map((id) => this.flush(id)));
8315
+ const pendings = [...this.inflight.values()];
8316
+ await Promise.allSettled(pendings);
8317
+ }
8318
+ };
8094
8319
  function isAbortError(err) {
8095
8320
  return typeof err === "object" && err !== null && err.name === "AbortError";
8096
8321
  }
8097
8322
  function pushEventBounded(execInfo, event, cap) {
8098
- if (event.type === "token" || event.type === "partial_object") return;
8323
+ if (STREAMING_EXCLUDED_TYPES.has(event.type)) return;
8099
8324
  if (cap === Infinity || execInfo.events.length < cap) {
8100
8325
  execInfo.events.push(event);
8101
8326
  return;
@@ -8116,6 +8341,36 @@ function pushEventBounded(execInfo, event, cap) {
8116
8341
  };
8117
8342
  }
8118
8343
  }
8344
+ function sanitizeMetadataForPersist(metadata) {
8345
+ const cleaned = {};
8346
+ for (const [k, v] of Object.entries(metadata)) {
8347
+ try {
8348
+ cleaned[k] = structuredClone(v);
8349
+ } catch {
8350
+ }
8351
+ }
8352
+ return cleaned;
8353
+ }
8354
+ function boundRecoveredEvents(events, cap) {
8355
+ if (cap === Infinity || events.length <= cap) return events;
8356
+ const truncated = events.slice(0, cap);
8357
+ const last = truncated[truncated.length - 1];
8358
+ const alreadyTruncated = last?.type === "log" && typeof last.data === "object" && last.data !== null && last.data.event === "events_truncated";
8359
+ if (!alreadyTruncated) {
8360
+ truncated[truncated.length - 1] = {
8361
+ type: "log",
8362
+ executionId: events[0]?.executionId ?? "",
8363
+ step: last?.step ?? 0,
8364
+ timestamp: Date.now(),
8365
+ data: {
8366
+ event: "events_truncated",
8367
+ cap,
8368
+ message: `Recovered ExecutionInfo.events truncated at ${cap} entries. Original streaming buffer had ${events.length} events. Raise via config.state.maxEventsPerExecution.`
8369
+ }
8370
+ };
8371
+ }
8372
+ return truncated;
8373
+ }
8119
8374
  var costScopeStorage = new import_node_async_hooks2.AsyncLocalStorage();
8120
8375
  function forwardAbortSignal(external, internal) {
8121
8376
  if (!external) return () => {
@@ -8162,6 +8417,30 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
8162
8417
  * `events` field — keeps the log to one line per row instead of one per
8163
8418
  * rebuild tick. */
8164
8419
  warnedMalformedExecutions = /* @__PURE__ */ new Set();
8420
+ /** Streaming-mode flusher. `undefined` when persist === 'terminal'
8421
+ * (back-compat default — no buffering overhead, no batching timer). */
8422
+ streamingFlusher;
8423
+ /** Execution IDs that are eligible for streaming-buffer durability —
8424
+ * populated by `execute()` / `stream()`. `createContext()` flows are
8425
+ * deliberately NOT added: ad-hoc contexts (tool tests, Studio playground,
8426
+ * evals) have no terminal `persistExecution` path that would finalize the
8427
+ * streaming buffer, so allowing them to write would leave phantom orphan
8428
+ * buffers that `recoverIncompleteStreams()` later mis-recovers as failed
8429
+ * executions. Membership is removed in `persistExecution` once the
8430
+ * canonical save + finalize chain has been scheduled. */
8431
+ streamableExecutionIds = /* @__PURE__ */ new Set();
8432
+ /** Execution IDs deleted by `runtime.deleteExecution()` while still in
8433
+ * flight. `persistExecution` skips its save + cache write when the
8434
+ * executionId is in this set — preventing a workflow that completes after
8435
+ * a delete from resurrecting the (intentionally-removed) row. Entries are
8436
+ * removed once the would-be persist has been short-circuited. */
8437
+ pendingDeletedExecutions = /* @__PURE__ */ new Set();
8438
+ /** In-flight `persistExecution` chains (save → finalize). `shutdown()`
8439
+ * awaits these before closing the StateStore so a workflow whose abort
8440
+ * triggers a persist-during-shutdown isn't racing a closed connection.
8441
+ * The Set is mutated by the chain's own `.finally` so entries clear as
8442
+ * soon as the work settles. */
8443
+ persistInflight = /* @__PURE__ */ new Set();
8165
8444
  constructor(config) {
8166
8445
  super();
8167
8446
  this.config = resolveConfig(config ?? {});
@@ -8185,6 +8464,19 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
8185
8464
  embedder: this.config.memory.embedder
8186
8465
  });
8187
8466
  }
8467
+ if (this.config.state?.persist === "streaming") {
8468
+ if (this.stateStore.appendStreamingEvents) {
8469
+ this.streamingFlusher = new StreamingFlusher(
8470
+ this.stateStore,
8471
+ this.config.state.streamingBatchSize ?? DEFAULT_STREAMING_BATCH_SIZE,
8472
+ this.config.state.streamingBatchInterval ?? DEFAULT_STREAMING_BATCH_INTERVAL
8473
+ );
8474
+ } else {
8475
+ console.warn(
8476
+ `[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.`
8477
+ );
8478
+ }
8479
+ }
8188
8480
  }
8189
8481
  /**
8190
8482
  * Whether `config.trace.redact` is enabled on this runtime. A narrow
@@ -8308,14 +8600,49 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
8308
8600
  /**
8309
8601
  * Persist a completed/failed execution to the state store (fire-and-forget)
8310
8602
  * and move it from the active executions map to the historical cache.
8603
+ *
8604
+ * Resurrection guard: if `runtime.deleteExecution(id)` was called while
8605
+ * this execution was still running, the id will be in
8606
+ * `pendingDeletedExecutions`. In that case we skip the save AND the
8607
+ * historical-cache write, and finalize the streaming buffer (if any) so
8608
+ * the buffer doesn't outlive the delete.
8311
8609
  */
8312
8610
  persistExecution(execInfo) {
8313
- const snapshot = structuredClone(execInfo);
8611
+ const id = execInfo.executionId;
8612
+ this.streamableExecutionIds.delete(id);
8613
+ if (this.pendingDeletedExecutions.has(id)) {
8614
+ this.pendingDeletedExecutions.delete(id);
8615
+ this.executions.delete(id);
8616
+ if (this.streamingFlusher) {
8617
+ this.streamingFlusher.finalize(id).catch(() => {
8618
+ });
8619
+ }
8620
+ return;
8621
+ }
8622
+ let snapshot;
8623
+ try {
8624
+ snapshot = structuredClone(execInfo);
8625
+ } catch {
8626
+ snapshot = {
8627
+ ...execInfo,
8628
+ events: [...execInfo.events],
8629
+ metadata: execInfo.metadata ? sanitizeMetadataForPersist(execInfo.metadata) : void 0
8630
+ };
8631
+ }
8314
8632
  if (this.stateStore.saveExecution) {
8315
- this.stateStore.saveExecution(snapshot).catch(() => {
8633
+ const chain = this.stateStore.saveExecution(snapshot).then(() => {
8634
+ return this.streamingFlusher?.finalize(id).catch(() => {
8635
+ });
8636
+ }).catch(() => {
8637
+ }).then(() => void 0);
8638
+ this.persistInflight.add(chain);
8639
+ chain.finally(() => this.persistInflight.delete(chain));
8640
+ } else if (this.streamingFlusher) {
8641
+ const chain = this.streamingFlusher.finalize(id).catch(() => {
8316
8642
  });
8643
+ this.persistInflight.add(chain);
8644
+ chain.finally(() => this.persistInflight.delete(chain));
8317
8645
  }
8318
- const id = execInfo.executionId;
8319
8646
  this.historicalExecutions.set(id, snapshot);
8320
8647
  this.executions.delete(id);
8321
8648
  }
@@ -8555,6 +8882,9 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
8555
8882
  this.abortControllers.set(executionId, controller);
8556
8883
  const cleanupAbortForwarder = forwardAbortSignal(options?.signal, controller);
8557
8884
  this.registerWithCostScope(executionId);
8885
+ if (this.streamingFlusher) {
8886
+ this.streamableExecutionIds.add(executionId);
8887
+ }
8558
8888
  const execInfo = {
8559
8889
  executionId,
8560
8890
  workflow: name,
@@ -8562,7 +8892,8 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
8562
8892
  events: [],
8563
8893
  totalCost: 0,
8564
8894
  startedAt: Date.now(),
8565
- duration: 0
8895
+ duration: 0,
8896
+ metadata: liftPersistedMetadata(options?.metadata)
8566
8897
  };
8567
8898
  this.executions.set(executionId, execInfo);
8568
8899
  const sessionHistory = options?.metadata?.sessionHistory ?? void 0;
@@ -8578,6 +8909,9 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
8578
8909
  onTrace: (event) => {
8579
8910
  pushEventBounded(execInfo, event, this.maxEventsPerExecution);
8580
8911
  execInfo.totalCost += eventCostContribution(event);
8912
+ if (this.streamableExecutionIds.has(executionId)) {
8913
+ this.streamingFlusher?.append(executionId, event);
8914
+ }
8581
8915
  this.emit("trace", event);
8582
8916
  this.outputAxlEvent(event);
8583
8917
  if (event.type === "handoff_start") {
@@ -8656,6 +8990,9 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
8656
8990
  const validated = workflow2.inputSchema.parse(input);
8657
8991
  const sessionHistory = options?.metadata?.sessionHistory ?? void 0;
8658
8992
  this.registerWithCostScope(executionId);
8993
+ if (this.streamingFlusher) {
8994
+ this.streamableExecutionIds.add(executionId);
8995
+ }
8659
8996
  execInfo = {
8660
8997
  executionId,
8661
8998
  workflow: name,
@@ -8663,7 +9000,8 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
8663
9000
  events: [],
8664
9001
  totalCost: 0,
8665
9002
  startedAt: Date.now(),
8666
- duration: 0
9003
+ duration: 0,
9004
+ metadata: liftPersistedMetadata(options?.metadata)
8667
9005
  };
8668
9006
  this.executions.set(executionId, execInfo);
8669
9007
  const wfCtx = new WorkflowContext({
@@ -8684,6 +9022,9 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
8684
9022
  onTrace: (event) => {
8685
9023
  pushEventBounded(execInfo, event, this.maxEventsPerExecution);
8686
9024
  execInfo.totalCost += eventCostContribution(event);
9025
+ if (this.streamableExecutionIds.has(executionId)) {
9026
+ this.streamingFlusher?.append(executionId, event);
9027
+ }
8687
9028
  this.emit("trace", event);
8688
9029
  this.outputAxlEvent(event);
8689
9030
  axlStream._push(event);
@@ -8823,6 +9164,15 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
8823
9164
  errors.push(new Error(`${label}: ${err instanceof Error ? err.message : String(err)}`));
8824
9165
  }
8825
9166
  };
9167
+ if (this.streamingFlusher) {
9168
+ await safeClose("streamingFlusher", () => this.streamingFlusher.flushAll());
9169
+ }
9170
+ if (this.persistInflight.size > 0) {
9171
+ await safeClose(
9172
+ "persistInflight",
9173
+ () => Promise.allSettled([...this.persistInflight]).then(() => void 0)
9174
+ );
9175
+ }
8826
9176
  if (this.mcpManager) await safeClose("mcpManager", () => this.mcpManager.shutdown());
8827
9177
  if (this.memoryManager) await safeClose("memoryManager", () => this.memoryManager.close());
8828
9178
  if (this.stateStore.close) await safeClose("stateStore", () => this.stateStore.close());
@@ -8833,6 +9183,81 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
8833
9183
  );
8834
9184
  }
8835
9185
  }
9186
+ /**
9187
+ * Recover executions whose process died mid-run when `state.persist:
9188
+ * 'streaming'` was configured. Synthesizes a partial `ExecutionInfo`
9189
+ * from the streaming buffer for each — `status: 'failed'`, error
9190
+ * `'process terminated'` — registers them in the historical cache so
9191
+ * they show up in `getExecutions()`, persists them via the store's
9192
+ * `saveExecution`, and finalizes the streaming buffer.
9193
+ *
9194
+ * Idempotent — if the streaming buffer is already gone (or
9195
+ * `listStreamingExecutions` is empty), this is a no-op. Returns the
9196
+ * list of recovered `ExecutionInfo`s for caller introspection.
9197
+ *
9198
+ * Wire this into your process startup AFTER `runtime.getExecutions()`
9199
+ * has lazy-loaded the historical cache. Order matters: completed
9200
+ * executions already in the store should not be overwritten by a
9201
+ * stale streaming buffer that the previous process didn't get to
9202
+ * finalize. The implementation checks the historical store before
9203
+ * synthesizing, so a re-run is safe.
9204
+ */
9205
+ async recoverIncompleteStreams() {
9206
+ if (!this.stateStore.listStreamingExecutions || !this.stateStore.getStreamingEvents) {
9207
+ return [];
9208
+ }
9209
+ const ids = await this.stateStore.listStreamingExecutions();
9210
+ if (ids.length === 0) return [];
9211
+ await this.getExecutions();
9212
+ const recovered = [];
9213
+ for (const id of ids) {
9214
+ if (this.executions.has(id) || this.streamableExecutionIds.has(id)) {
9215
+ continue;
9216
+ }
9217
+ const existing = this.historicalExecutions.get(id);
9218
+ if (existing) {
9219
+ await this.stateStore.finalizeStreamingEvents?.(id);
9220
+ continue;
9221
+ }
9222
+ const events = await this.stateStore.getStreamingEvents(id);
9223
+ if (events.length === 0) {
9224
+ await this.stateStore.finalizeStreamingEvents?.(id);
9225
+ continue;
9226
+ }
9227
+ const boundedEvents = boundRecoveredEvents(events, this.maxEventsPerExecution);
9228
+ const workflowStartEvent = boundedEvents.find((e) => e.type === "workflow_start");
9229
+ const lastEvent = boundedEvents[boundedEvents.length - 1];
9230
+ const startedAt = workflowStartEvent?.timestamp ?? boundedEvents[0]?.timestamp ?? Date.now();
9231
+ const completedAt = lastEvent.timestamp ?? startedAt;
9232
+ const workflowName = workflowStartEvent?.workflow ?? RECOVERED_UNKNOWN_WORKFLOW;
9233
+ const totalCost = boundedEvents.reduce((sum, e) => sum + eventCostContribution(e), 0);
9234
+ const synthesized = {
9235
+ executionId: id,
9236
+ workflow: workflowName,
9237
+ status: "failed",
9238
+ events: boundedEvents,
9239
+ totalCost,
9240
+ startedAt,
9241
+ completedAt,
9242
+ duration: completedAt - startedAt,
9243
+ error: "process terminated (recovered from streaming buffer)"
9244
+ };
9245
+ if (this.stateStore.saveExecution) {
9246
+ try {
9247
+ await this.stateStore.saveExecution(synthesized);
9248
+ } catch (err) {
9249
+ console.error(
9250
+ `[axl] recoverIncompleteStreams: failed to save synthesized execution ${id}: ${err instanceof Error ? err.message : String(err)}. Streaming buffer left in place for next recovery attempt.`
9251
+ );
9252
+ continue;
9253
+ }
9254
+ }
9255
+ this.historicalExecutions.set(id, synthesized);
9256
+ await this.stateStore.finalizeStreamingEvents?.(id);
9257
+ recovered.push(synthesized);
9258
+ }
9259
+ return recovered;
9260
+ }
8836
9261
  /** Abort a running execution by its ID. */
8837
9262
  abort(executionId) {
8838
9263
  const controller = this.abortControllers.get(executionId);
@@ -8865,15 +9290,85 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
8865
9290
  }
8866
9291
  this.emit("eval_result", entry);
8867
9292
  }
9293
+ /**
9294
+ * Delete an execution from history by id. Removes from the active map (if
9295
+ * still running), the historical cache, the configured StateStore, AND
9296
+ * any execution-scoped side state — pending awaitHuman decisions,
9297
+ * resolver maps, streaming buffer registration, in-flight abort controller.
9298
+ * Returns true if an entry was actually removed from any of the three
9299
+ * primary surfaces (active map, historical cache, store).
9300
+ *
9301
+ * In-flight handling: if the execution is still running, this method
9302
+ * **aborts** it (via the registered abort controller) AND marks the id for
9303
+ * skip-on-persist so the workflow's eventual `workflow_end` doesn't
9304
+ * resurrect the row. The workflow itself terminates normally — callers
9305
+ * iterating the returned `AxlStream` will see the abort flow through. If
9306
+ * you need a hard hand-off (delete completes before the workflow tears
9307
+ * down), call `runtime.abort(id)` and `await` the stream/execute promise
9308
+ * before calling this. `streamableExecutionIds` is cleared eagerly so
9309
+ * events emitted during the wind-down don't re-create the streaming
9310
+ * buffer in the store after the deleteExecution-issued DEL.
9311
+ *
9312
+ * Audit trail: emits `execution_deleted` on the runtime's `EventEmitter`
9313
+ * with `{ executionId, wasActive, hadPendingDecision }` so compliance
9314
+ * apps can subscribe via `runtime.on('execution_deleted', ...)` for a
9315
+ * "who deleted what when" log without wrapping this method. Fires
9316
+ * regardless of whether the id was actually present.
9317
+ *
9318
+ * Use cases: GDPR right-to-be-forgotten, operator-driven cleanup of
9319
+ * specific runs (e.g. a workflow that recorded PII the user requested
9320
+ * scrubbed). For bulk eviction by age, use a `RedisStore` TTL instead
9321
+ * (`defaultTtl` / `ttls.executionHistory`).
9322
+ */
9323
+ async deleteExecution(id) {
9324
+ const wasActive = this.executions.has(id);
9325
+ const hadPendingDecision = this.pendingDecisionResolvers.has(id);
9326
+ if (wasActive) {
9327
+ this.pendingDeletedExecutions.add(id);
9328
+ this.streamableExecutionIds.delete(id);
9329
+ const controller = this.abortControllers.get(id);
9330
+ if (controller) {
9331
+ controller.abort();
9332
+ }
9333
+ }
9334
+ if (hadPendingDecision) {
9335
+ this.pendingDecisionResolvers.delete(id);
9336
+ }
9337
+ const existing = await this.getExecution(id);
9338
+ const workflow2 = existing?.workflow;
9339
+ const removedFromActive = this.executions.delete(id);
9340
+ const removedFromHistorical = this.historicalExecutions.delete(id);
9341
+ let removedFromStore = false;
9342
+ if (this.stateStore.deleteExecution) {
9343
+ removedFromStore = await this.stateStore.deleteExecution(id);
9344
+ }
9345
+ this.emit("execution_deleted", {
9346
+ executionId: id,
9347
+ workflow: workflow2,
9348
+ wasActive,
9349
+ hadPendingDecision,
9350
+ removed: removedFromActive || removedFromHistorical || removedFromStore
9351
+ });
9352
+ return removedFromActive || removedFromHistorical || removedFromStore;
9353
+ }
8868
9354
  /**
8869
9355
  * Delete an eval history entry by id. Removes from in-memory cache and
8870
9356
  * the configured StateStore. Returns true if an entry was actually removed.
8871
9357
  *
8872
9358
  * Ensures lazy-loaded history is loaded first so the in-memory cache and
8873
9359
  * the store can't drift apart on the deletion path.
9360
+ *
9361
+ * Emits `eval_deleted` on the runtime's `EventEmitter` with
9362
+ * `{ id, eval, removed }`. Fires on every call — including attempts
9363
+ * against unknown ids (`removed: false`) — symmetric to `execution_deleted`.
9364
+ * Studio's eval-trends aggregator subscribes to this event so deleted
9365
+ * runs don't linger in the trends panel for up to 5 minutes (until the
9366
+ * next periodic rebuild).
8874
9367
  */
8875
9368
  async deleteEvalResult(id) {
8876
9369
  await this.getEvalHistory();
9370
+ const existing = this.evalHistory.find((e) => e.id === id);
9371
+ const evalName = existing?.eval;
8877
9372
  const beforeLength = this.evalHistory.length;
8878
9373
  this.evalHistory = this.evalHistory.filter((e) => e.id !== id);
8879
9374
  const removedFromMemory = this.evalHistory.length < beforeLength;
@@ -8881,7 +9376,13 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
8881
9376
  if (this.stateStore.deleteEvalResult) {
8882
9377
  removedFromStore = await this.stateStore.deleteEvalResult(id);
8883
9378
  }
8884
- return removedFromMemory || removedFromStore;
9379
+ const removed = removedFromMemory || removedFromStore;
9380
+ this.emit("eval_deleted", {
9381
+ id,
9382
+ eval: evalName,
9383
+ removed
9384
+ });
9385
+ return removed;
8885
9386
  }
8886
9387
  /** Get eval result history (most recent first). */
8887
9388
  async getEvalHistory() {
@@ -9471,15 +9972,53 @@ async function* stringStreamFromEvents(source, opts) {
9471
9972
 
9472
9973
  // src/state/redis.ts
9473
9974
  var RedisStore = class _RedisStore {
9474
- constructor(client) {
9975
+ constructor(client, keyPrefix, ttls) {
9475
9976
  this.client = client;
9977
+ this.keyPrefix = keyPrefix;
9978
+ this.ttls = ttls;
9979
+ }
9980
+ /**
9981
+ * Resolve the TTL for a given storage category. Returns `undefined` when
9982
+ * no TTL applies (category is `null` in resolved config, the value is
9983
+ * non-positive — Redis EXPIRE 0 immediately deletes, which is almost
9984
+ * never intended — or the value is non-finite).
9985
+ *
9986
+ * Non-finite values (`NaN`, `±Infinity`) defensively map to `undefined`
9987
+ * rather than being passed to Redis, where they'd surface as a wire-level
9988
+ * protocol error at the worst possible time (first write under load).
9989
+ */
9990
+ ttlFor(category) {
9991
+ const resolved = this.ttls[category];
9992
+ if (resolved === null) return void 0;
9993
+ if (!Number.isFinite(resolved) || resolved <= 0) return void 0;
9994
+ return Math.floor(resolved);
9476
9995
  }
9477
9996
  /**
9478
9997
  * Create a connected RedisStore instance.
9479
9998
  *
9480
- * @param url - Redis connection URL (e.g. `redis://localhost:6379`). Defaults to `redis://localhost:6379`.
9999
+ * Accepts either a URL string (back-compat) or a {@link RedisStoreOptions}
10000
+ * object. The object form is required to set a custom `keyPrefix`.
10001
+ *
10002
+ * @example
10003
+ * ```ts
10004
+ * // Default prefix 'axl:'
10005
+ * const store = await RedisStore.create('redis://localhost:6379');
10006
+ *
10007
+ * // Custom prefix for shared-cluster deployments
10008
+ * const store = await RedisStore.create({
10009
+ * url: 'redis://localhost:6379',
10010
+ * keyPrefix: 'axl:prod:',
10011
+ * });
10012
+ * ```
9481
10013
  */
9482
- static async create(url) {
10014
+ static async create(options) {
10015
+ const opts = typeof options === "string" ? { url: options } : options ?? {};
10016
+ const keyPrefix = opts.keyPrefix ?? "axl:";
10017
+ if (keyPrefix === "") {
10018
+ throw new Error(
10019
+ 'RedisStore: keyPrefix cannot be empty string. Omit it for the default "axl:" prefix.'
10020
+ );
10021
+ }
9483
10022
  let createClient;
9484
10023
  try {
9485
10024
  const mod = require("redis");
@@ -9493,44 +10032,197 @@ var RedisStore = class _RedisStore {
9493
10032
  if (err instanceof Error && err.message.includes("createClient")) throw err;
9494
10033
  throw new Error("redis is required for RedisStore. Install it with: npm install redis");
9495
10034
  }
9496
- const client = url ? createClient({ url }) : createClient();
10035
+ const client = opts.url ? createClient({ url: opts.url }) : createClient();
9497
10036
  await client.connect();
9498
- return new _RedisStore(client);
10037
+ const resolveCategory = (override) => {
10038
+ if (override === null) return null;
10039
+ if (typeof override === "number") return override;
10040
+ return typeof opts.defaultTtl === "number" ? opts.defaultTtl : null;
10041
+ };
10042
+ const resolveStreamingEvents = (override) => {
10043
+ if (override === null || override === void 0) return null;
10044
+ if (typeof override === "number") return override;
10045
+ return null;
10046
+ };
10047
+ const effectiveTtls = {
10048
+ session: resolveCategory(opts.ttls?.session),
10049
+ sessionMeta: resolveCategory(opts.ttls?.sessionMeta),
10050
+ checkpoint: resolveCategory(opts.ttls?.checkpoint),
10051
+ executionState: resolveCategory(opts.ttls?.executionState),
10052
+ executionHistory: resolveCategory(opts.ttls?.executionHistory),
10053
+ evalHistory: resolveCategory(opts.ttls?.evalHistory),
10054
+ memory: resolveCategory(opts.ttls?.memory),
10055
+ streamingEvents: resolveStreamingEvents(opts.ttls?.streamingEvents)
10056
+ };
10057
+ const store = new _RedisStore(client, keyPrefix, effectiveTtls);
10058
+ if (!opts.skipMigration) {
10059
+ await store.backfillExecutionIndex();
10060
+ await store.backfillEvalIndex();
10061
+ }
10062
+ return store;
10063
+ }
10064
+ /**
10065
+ * Backfill the execution-history sorted-set from the legacy ID set.
10066
+ * Idempotent — runs only if the ZSET is missing entries the SET has.
10067
+ * Safe to call manually if you constructed the store with
10068
+ * `skipMigration: true`.
10069
+ */
10070
+ async backfillExecutionIndex() {
10071
+ await this.backfillHistoryIndex(
10072
+ this.execHistorySetKey(),
10073
+ this.execHistoryZsetKey(),
10074
+ (id) => this.execHistoryKey(id),
10075
+ (raw) => {
10076
+ const exec = JSON.parse(raw);
10077
+ return typeof exec.startedAt === "number" ? { score: exec.startedAt, value: exec.executionId } : null;
10078
+ },
10079
+ "execution-history"
10080
+ );
10081
+ }
10082
+ /** Backfill the eval-history sorted-set. Symmetric to `backfillExecutionIndex`. */
10083
+ async backfillEvalIndex() {
10084
+ await this.backfillHistoryIndex(
10085
+ this.evalHistorySetKey(),
10086
+ this.evalHistoryZsetKey(),
10087
+ (id) => this.evalHistoryKey(id),
10088
+ (raw) => {
10089
+ const entry = JSON.parse(raw);
10090
+ return typeof entry.timestamp === "number" ? { score: entry.timestamp, value: entry.id } : null;
10091
+ },
10092
+ "eval-history"
10093
+ );
10094
+ }
10095
+ /**
10096
+ * Shared implementation for both backfill paths. Reads SET members,
10097
+ * batch-MGETs their data blobs in chunks, and ZADDs each into the new
10098
+ * sorted-set inside a single MULTI per chunk.
10099
+ */
10100
+ async backfillHistoryIndex(setKey, zsetKey, dataKey, scoreOf, label) {
10101
+ const [setSize, zsetSize] = await Promise.all([
10102
+ this.client.sCard(setKey),
10103
+ this.client.zCard(zsetKey)
10104
+ ]);
10105
+ if (zsetSize >= setSize) return;
10106
+ const allIds = await this.client.sMembers(setKey);
10107
+ if (allIds.length === 0) return;
10108
+ const CHUNK_SIZE = 500;
10109
+ let migrated = 0;
10110
+ let skipped = 0;
10111
+ for (let i = 0; i < allIds.length; i += CHUNK_SIZE) {
10112
+ const chunk = allIds.slice(i, i + CHUNK_SIZE);
10113
+ const dataKeys = chunk.map((id) => dataKey(id));
10114
+ const raws = await this.client.mGet(dataKeys);
10115
+ const tx = this.client.multi();
10116
+ let queuedInChunk = 0;
10117
+ for (let j = 0; j < chunk.length; j++) {
10118
+ const raw = raws[j];
10119
+ if (raw == null) {
10120
+ skipped++;
10121
+ continue;
10122
+ }
10123
+ try {
10124
+ const entry = scoreOf(raw);
10125
+ if (entry == null) {
10126
+ skipped++;
10127
+ continue;
10128
+ }
10129
+ tx.zAdd(zsetKey, entry);
10130
+ queuedInChunk++;
10131
+ migrated++;
10132
+ } catch {
10133
+ skipped++;
10134
+ }
10135
+ }
10136
+ if (queuedInChunk > 0) await tx.exec();
10137
+ }
10138
+ if (migrated > 0 || skipped > 0) {
10139
+ console.log(
10140
+ `[axl] RedisStore: backfilled ${migrated} ${label} entries into sorted-set index` + (skipped > 0 ? ` (${skipped} skipped \u2014 missing or malformed)` : "")
10141
+ );
10142
+ }
9499
10143
  }
9500
10144
  // ── Key helpers ──────────────────────────────────────────────────────
9501
10145
  checkpointKey(executionId) {
9502
- return `axl:checkpoint:${executionId}`;
10146
+ return `${this.keyPrefix}checkpoint:${executionId}`;
9503
10147
  }
9504
10148
  sessionKey(sessionId) {
9505
- return `axl:session:${sessionId}`;
10149
+ return `${this.keyPrefix}session:${sessionId}`;
9506
10150
  }
9507
10151
  sessionMetaKey(sessionId) {
9508
- return `axl:session-meta:${sessionId}`;
10152
+ return `${this.keyPrefix}session-meta:${sessionId}`;
10153
+ }
10154
+ sessionIdsKey() {
10155
+ return `${this.keyPrefix}session-ids`;
9509
10156
  }
9510
10157
  decisionsKey() {
9511
- return "axl:decisions";
10158
+ return `${this.keyPrefix}decisions`;
9512
10159
  }
9513
10160
  executionStateKey(executionId) {
9514
- return `axl:exec-state:${executionId}`;
10161
+ return `${this.keyPrefix}exec-state:${executionId}`;
9515
10162
  }
9516
10163
  pendingExecSetKey() {
9517
- return "axl:pending-executions";
10164
+ return `${this.keyPrefix}pending-executions`;
9518
10165
  }
9519
10166
  execHistoryKey(executionId) {
9520
- return `axl:exec-history:${executionId}`;
9521
- }
10167
+ return `${this.keyPrefix}exec-history:${executionId}`;
10168
+ }
10169
+ // NOTE for future implementer of #3 (TTL config): when the executionHistory
10170
+ // and evalHistory data blobs gain TTLs, eviction MUST be driven by
10171
+ // ZREMRANGEBYSCORE on the ZSET below — NOT by SREM on these legacy ID sets.
10172
+ // The slow-path fallback (`listHistoryByZset`) reads from the SET when
10173
+ // `zsetCard < setCard`, so SET-based eviction would race with the fallback
10174
+ // and surface dead IDs whose data has been TTL'd away. ZSET eviction +
10175
+ // dual-write `SREM` is the clean path; doc the assumption then.
9522
10176
  execHistorySetKey() {
9523
- return "axl:exec-history-ids";
10177
+ return `${this.keyPrefix}exec-history-ids`;
10178
+ }
10179
+ /**
10180
+ * Sorted-set index for execution history, scored by `startedAt`. Drives
10181
+ * the O(log N) `listExecutions` fast path; the legacy SET above is kept
10182
+ * as a fallback for users running with `skipMigration: true` before
10183
+ * they've called `backfillExecutionIndex()` manually.
10184
+ */
10185
+ execHistoryZsetKey() {
10186
+ return `${this.keyPrefix}exec-history-z`;
9524
10187
  }
9525
10188
  evalHistoryKey(id) {
9526
- return `axl:eval-history:${id}`;
10189
+ return `${this.keyPrefix}eval-history:${id}`;
9527
10190
  }
9528
10191
  evalHistorySetKey() {
9529
- return "axl:eval-history-ids";
10192
+ return `${this.keyPrefix}eval-history-ids`;
10193
+ }
10194
+ /** Sorted-set index for eval history, scored by `timestamp`. See above. */
10195
+ evalHistoryZsetKey() {
10196
+ return `${this.keyPrefix}eval-history-z`;
10197
+ }
10198
+ memoryKey(scope) {
10199
+ return `${this.keyPrefix}memory:${scope}`;
10200
+ }
10201
+ // ── Streaming event buffer (for `state.persist: 'streaming'`) ─────────
10202
+ // Layout:
10203
+ // `{prefix}exec-events:{id}` — a Redis list. Events are RPUSH'd as
10204
+ // JSON-serialized AxlEvent objects, in batches. The runtime LRANGEs
10205
+ // the whole list on recovery and deletes it via `finalizeStreamingEvents`
10206
+ // once the canonical `executionHistory` blob has been written at
10207
+ // terminal exit.
10208
+ // `{prefix}streaming-exec-ids` — a Redis set tracking which executions
10209
+ // have an active streaming buffer. Used by `listStreamingExecutions`
10210
+ // to find runs whose process died mid-flight (no corresponding
10211
+ // `executionHistory` blob).
10212
+ streamingEventsKey(executionId) {
10213
+ return `${this.keyPrefix}exec-events:${executionId}`;
10214
+ }
10215
+ streamingIdsKey() {
10216
+ return `${this.keyPrefix}streaming-exec-ids`;
9530
10217
  }
9531
10218
  // ── Checkpoints ──────────────────────────────────────────────────────
9532
10219
  async saveCheckpoint(executionId, name, data) {
9533
- await this.client.hSet(this.checkpointKey(executionId), name, JSON.stringify(data));
10220
+ const ttl = this.ttlFor("checkpoint");
10221
+ if (ttl === void 0) {
10222
+ await this.client.hSet(this.checkpointKey(executionId), name, JSON.stringify(data));
10223
+ return;
10224
+ }
10225
+ await this.client.multi().hSet(this.checkpointKey(executionId), name, JSON.stringify(data)).expire(this.checkpointKey(executionId), ttl, "NX").exec();
9534
10226
  }
9535
10227
  async getCheckpoint(executionId, name) {
9536
10228
  const raw = await this.client.hGet(this.checkpointKey(executionId), name);
@@ -9538,20 +10230,24 @@ var RedisStore = class _RedisStore {
9538
10230
  }
9539
10231
  // ── Sessions ─────────────────────────────────────────────────────────
9540
10232
  async saveSession(sessionId, history) {
9541
- await this.client.set(this.sessionKey(sessionId), JSON.stringify(history));
9542
- await this.client.sAdd("axl:session-ids", sessionId);
10233
+ const ttl = this.ttlFor("session");
10234
+ const setOptions = ttl !== void 0 ? { EX: ttl } : void 0;
10235
+ await this.client.multi().set(this.sessionKey(sessionId), JSON.stringify(history), setOptions).sAdd(this.sessionIdsKey(), sessionId).exec();
9543
10236
  }
9544
10237
  async getSession(sessionId) {
9545
10238
  const raw = await this.client.get(this.sessionKey(sessionId));
9546
10239
  return raw ? JSON.parse(raw) : [];
9547
10240
  }
9548
10241
  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);
10242
+ await this.client.multi().del(this.sessionKey(sessionId)).del(this.sessionMetaKey(sessionId)).sRem(this.sessionIdsKey(), sessionId).exec();
9552
10243
  }
9553
10244
  async saveSessionMeta(sessionId, key, value) {
9554
- await this.client.hSet(this.sessionMetaKey(sessionId), key, JSON.stringify(value));
10245
+ const ttl = this.ttlFor("sessionMeta");
10246
+ if (ttl === void 0) {
10247
+ await this.client.hSet(this.sessionMetaKey(sessionId), key, JSON.stringify(value));
10248
+ return;
10249
+ }
10250
+ await this.client.multi().hSet(this.sessionMetaKey(sessionId), key, JSON.stringify(value)).expire(this.sessionMetaKey(sessionId), ttl).exec();
9555
10251
  }
9556
10252
  async getSessionMeta(sessionId, key) {
9557
10253
  const raw = await this.client.hGet(this.sessionMetaKey(sessionId), key);
@@ -9571,64 +10267,222 @@ var RedisStore = class _RedisStore {
9571
10267
  }
9572
10268
  // ── Execution State ──────────────────────────────────────────────────
9573
10269
  async saveExecutionState(executionId, state) {
9574
- await this.client.set(this.executionStateKey(executionId), JSON.stringify(state));
10270
+ const ttl = this.ttlFor("executionState");
10271
+ const setOptions = ttl !== void 0 ? { EX: ttl } : void 0;
10272
+ const tx = this.client.multi().set(this.executionStateKey(executionId), JSON.stringify(state), setOptions);
9575
10273
  if (state.status === "waiting") {
9576
- await this.client.sAdd(this.pendingExecSetKey(), executionId);
10274
+ tx.sAdd(this.pendingExecSetKey(), executionId);
9577
10275
  } else {
9578
- await this.client.sRem(this.pendingExecSetKey(), executionId);
10276
+ tx.sRem(this.pendingExecSetKey(), executionId);
9579
10277
  }
10278
+ await tx.exec();
9580
10279
  }
9581
10280
  async getExecutionState(executionId) {
9582
10281
  const raw = await this.client.get(this.executionStateKey(executionId));
9583
10282
  return raw ? JSON.parse(raw) : null;
9584
10283
  }
9585
10284
  async listPendingExecutions() {
9586
- return this.client.sMembers(this.pendingExecSetKey());
10285
+ const ids = await this.client.sMembers(this.pendingExecSetKey());
10286
+ if (ids.length === 0) return ids;
10287
+ const keys = ids.map((id) => this.executionStateKey(id));
10288
+ const raws = await this.client.mGet(keys);
10289
+ const live = [];
10290
+ const dead = [];
10291
+ for (let i = 0; i < ids.length; i++) {
10292
+ if (raws[i] != null) {
10293
+ live.push(ids[i]);
10294
+ } else {
10295
+ dead.push(ids[i]);
10296
+ }
10297
+ }
10298
+ if (dead.length > 0) {
10299
+ this.client.sRem(this.pendingExecSetKey(), dead).catch((err) => {
10300
+ console.error(
10301
+ `[axl] RedisStore: failed to prune ${dead.length} stale pending-exec ids: ` + (err instanceof Error ? err.message : String(err))
10302
+ );
10303
+ });
10304
+ }
10305
+ return live;
10306
+ }
10307
+ // ── Memory ──────────────────────────────────────────────────────────
10308
+ //
10309
+ // Layout: `{prefix}memory:{scope}` is a hash. Fields are user-supplied
10310
+ // keys; values are JSON-serialized. Hash-per-scope keeps related entries
10311
+ // together (good cohesion for TTL eviction once #3 lands) and bounds the
10312
+ // keyspace to O(scopes) instead of O(scopes × keys).
10313
+ //
10314
+ // Pre-this-patch, RedisStore had no memory methods, so MemoryManager fell
10315
+ // back to writing memory at synthetic sessionMeta keys
10316
+ // (`{prefix}session-meta:memory:{scope}:{key}` hash field `value`). To
10317
+ // preserve user data on upgrade, `getMemory` checks the legacy location on
10318
+ // miss and migrates forward; `deleteMemory` also cleans the legacy entry.
10319
+ // No legacy support on `getAllMemory` — that method didn't exist before,
10320
+ // so no user can depend on it returning legacy data.
10321
+ async saveMemory(scope, key, value) {
10322
+ const ttl = this.ttlFor("memory");
10323
+ if (ttl === void 0) {
10324
+ await this.client.hSet(this.memoryKey(scope), key, JSON.stringify(value));
10325
+ return;
10326
+ }
10327
+ await this.client.multi().hSet(this.memoryKey(scope), key, JSON.stringify(value)).expire(this.memoryKey(scope), ttl).exec();
10328
+ }
10329
+ async getMemory(scope, key) {
10330
+ const raw = await this.client.hGet(this.memoryKey(scope), key);
10331
+ if (raw != null) return JSON.parse(raw);
10332
+ const legacyRaw = await this.client.hGet(
10333
+ this.sessionMetaKey(`memory:${scope}:${key}`),
10334
+ "value"
10335
+ );
10336
+ if (legacyRaw == null) return null;
10337
+ const inserted = await this.client.hSetNX(this.memoryKey(scope), key, legacyRaw);
10338
+ if (inserted === 0 || inserted === false) {
10339
+ const winner = await this.client.hGet(this.memoryKey(scope), key);
10340
+ if (winner != null) return JSON.parse(winner);
10341
+ return null;
10342
+ }
10343
+ const ttl = this.ttlFor("memory");
10344
+ if (ttl !== void 0) {
10345
+ this.client.expire(this.memoryKey(scope), ttl).catch((err) => {
10346
+ console.error(
10347
+ `[axl] RedisStore: failed to set TTL on migrated memory key ${this.memoryKey(scope)}: ` + (err instanceof Error ? err.message : String(err))
10348
+ );
10349
+ });
10350
+ }
10351
+ return JSON.parse(legacyRaw);
10352
+ }
10353
+ async getAllMemory(scope) {
10354
+ const all = await this.client.hGetAll(this.memoryKey(scope));
10355
+ if (!all) return [];
10356
+ return Object.entries(all).map(([key, raw]) => ({ key, value: JSON.parse(raw) }));
10357
+ }
10358
+ async deleteMemory(scope, key) {
10359
+ await this.client.multi().hDel(this.memoryKey(scope), key).hDel(this.sessionMetaKey(`memory:${scope}:${key}`), "value").exec();
9587
10360
  }
9588
10361
  // ── Execution History ────────────────────────────────────────────────
9589
10362
  async saveExecution(execution) {
9590
- await this.client.sAdd(this.execHistorySetKey(), execution.executionId);
9591
- await this.client.set(this.execHistoryKey(execution.executionId), JSON.stringify(execution));
10363
+ const ttl = this.ttlFor("executionHistory");
10364
+ const setOptions = ttl !== void 0 ? { EX: ttl } : void 0;
10365
+ await this.client.multi().sAdd(this.execHistorySetKey(), execution.executionId).set(this.execHistoryKey(execution.executionId), JSON.stringify(execution), setOptions).zAdd(this.execHistoryZsetKey(), {
10366
+ score: execution.startedAt,
10367
+ value: execution.executionId
10368
+ }).exec();
9592
10369
  }
9593
10370
  async getExecution(executionId) {
9594
10371
  const raw = await this.client.get(this.execHistoryKey(executionId));
9595
10372
  return raw ? JSON.parse(raw) : null;
9596
10373
  }
9597
10374
  async listExecutions(limit) {
9598
- const ids = await this.client.sMembers(this.execHistorySetKey());
9599
- if (ids.length === 0) return [];
9600
- const entries = [];
9601
- for (const id of ids) {
9602
- const raw = await this.client.get(this.execHistoryKey(id));
9603
- if (raw) entries.push(JSON.parse(raw));
9604
- }
9605
- entries.sort((a, b) => b.startedAt - a.startedAt);
9606
- return limit ? entries.slice(0, limit) : entries;
10375
+ return this.listHistoryByZset(
10376
+ this.execHistorySetKey(),
10377
+ this.execHistoryZsetKey(),
10378
+ (id) => this.execHistoryKey(id),
10379
+ (a, b) => b.startedAt - a.startedAt,
10380
+ limit
10381
+ );
10382
+ }
10383
+ async deleteExecution(executionId) {
10384
+ 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();
10385
+ const deleted = typeof deletedCount === "number" ? deletedCount : 0;
10386
+ return deleted > 0;
9607
10387
  }
9608
10388
  // ── Eval History ────────────────────────────────────────────────────
9609
10389
  async saveEvalResult(entry) {
9610
- await this.client.sAdd(this.evalHistorySetKey(), entry.id);
9611
- await this.client.set(this.evalHistoryKey(entry.id), JSON.stringify(entry));
10390
+ const ttl = this.ttlFor("evalHistory");
10391
+ const setOptions = ttl !== void 0 ? { EX: ttl } : void 0;
10392
+ 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
10393
  }
9613
10394
  async listEvalResults(limit) {
9614
- const ids = await this.client.sMembers(this.evalHistorySetKey());
10395
+ return this.listHistoryByZset(
10396
+ this.evalHistorySetKey(),
10397
+ this.evalHistoryZsetKey(),
10398
+ (id) => this.evalHistoryKey(id),
10399
+ (a, b) => b.timestamp - a.timestamp,
10400
+ limit
10401
+ );
10402
+ }
10403
+ async deleteEvalResult(id) {
10404
+ const [, deletedCount] = await this.client.multi().sRem(this.evalHistorySetKey(), id).del(this.evalHistoryKey(id)).zRem(this.evalHistoryZsetKey(), id).exec();
10405
+ const deleted = typeof deletedCount === "number" ? deletedCount : 0;
10406
+ return deleted > 0;
10407
+ }
10408
+ /**
10409
+ * Shared list-by-sorted-set implementation. Uses ZREVRANGEBYSCORE +
10410
+ * MGET when the ZSET is populated (post-migration). Falls back to the
10411
+ * legacy SET + N×GET + JS-sort path when the ZSET is empty or smaller
10412
+ * than the SET (covers users that constructed with `skipMigration: true`
10413
+ * before calling backfill).
10414
+ */
10415
+ async listHistoryByZset(setKey, zsetKey, dataKey, sortCmp, limit) {
10416
+ const setSize = await this.client.sCard(setKey);
10417
+ if (setSize === 0) return [];
10418
+ const zsetSize = await this.client.zCard(zsetKey);
10419
+ if (zsetSize >= setSize) {
10420
+ const fetchCount = limit !== void 0 ? Math.max(limit * 2, limit + 5) : void 0;
10421
+ const ids2 = await this.client.zRange(zsetKey, "+inf", "-inf", {
10422
+ BY: "SCORE",
10423
+ REV: true,
10424
+ ...fetchCount !== void 0 ? { LIMIT: { offset: 0, count: fetchCount } } : {}
10425
+ });
10426
+ if (ids2.length === 0) return [];
10427
+ const keys = ids2.map(dataKey);
10428
+ const raws = await this.client.mGet(keys);
10429
+ const entries2 = [];
10430
+ for (const raw of raws) {
10431
+ if (raw == null) continue;
10432
+ try {
10433
+ entries2.push(JSON.parse(raw));
10434
+ } catch {
10435
+ }
10436
+ if (limit !== void 0 && entries2.length >= limit) break;
10437
+ }
10438
+ return entries2;
10439
+ }
10440
+ const ids = await this.client.sMembers(setKey);
9615
10441
  if (ids.length === 0) return [];
9616
10442
  const entries = [];
9617
10443
  for (const id of ids) {
9618
- const raw = await this.client.get(this.evalHistoryKey(id));
9619
- if (raw) entries.push(JSON.parse(raw));
10444
+ const raw = await this.client.get(dataKey(id));
10445
+ if (raw == null) continue;
10446
+ try {
10447
+ entries.push(JSON.parse(raw));
10448
+ } catch {
10449
+ }
9620
10450
  }
9621
- entries.sort((a, b) => b.timestamp - a.timestamp);
10451
+ entries.sort(sortCmp);
9622
10452
  return limit ? entries.slice(0, limit) : entries;
9623
10453
  }
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
10454
  // ── Sessions (Studio introspection) ────────────────────────────────────
9630
10455
  async listSessions() {
9631
- return this.client.sMembers("axl:session-ids");
10456
+ return this.client.sMembers(this.sessionIdsKey());
10457
+ }
10458
+ // ── Streaming events (for state.persist: 'streaming') ──────────────────
10459
+ async appendStreamingEvents(executionId, events) {
10460
+ if (events.length === 0) return;
10461
+ const serialized = events.map((e) => JSON.stringify(e));
10462
+ const ttl = this.ttlFor("streamingEvents");
10463
+ const tx = this.client.multi().rPush(this.streamingEventsKey(executionId), serialized).sAdd(this.streamingIdsKey(), executionId);
10464
+ if (ttl !== void 0) {
10465
+ tx.expire(this.streamingEventsKey(executionId), ttl, "NX");
10466
+ }
10467
+ await tx.exec();
10468
+ }
10469
+ async finalizeStreamingEvents(executionId) {
10470
+ await this.client.multi().del(this.streamingEventsKey(executionId)).sRem(this.streamingIdsKey(), executionId).exec();
10471
+ }
10472
+ async listStreamingExecutions() {
10473
+ return this.client.sMembers(this.streamingIdsKey());
10474
+ }
10475
+ async getStreamingEvents(executionId) {
10476
+ const raws = await this.client.lRange(this.streamingEventsKey(executionId), 0, -1);
10477
+ if (raws.length === 0) return [];
10478
+ const events = [];
10479
+ for (const raw of raws) {
10480
+ try {
10481
+ events.push(JSON.parse(raw));
10482
+ } catch {
10483
+ }
10484
+ }
10485
+ return events;
9632
10486
  }
9633
10487
  /** Close the Redis connection. */
9634
10488
  async close() {