@axlsdk/axl 0.26.0 → 0.27.0

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
@@ -348,7 +348,7 @@ var sharedGuard = typeof installedGuard === "object" && installedGuard !== null
348
348
  var buildPath = typeof __AXL_BUILD_PATH__ === "string" ? __AXL_BUILD_PATH__ : "<source>";
349
349
  var thisCopy = {
350
350
  path: buildPath.startsWith("file:") ? fileURLToPath(buildPath) : buildPath,
351
- version: true ? "0.26.0" : "unknown"
351
+ version: true ? "0.27.0" : "unknown"
352
352
  };
353
353
  function warnCrossCopy(other, joined) {
354
354
  if (other === thisCopy) return;
@@ -12615,8 +12615,17 @@ function detectDroppedRefinements(schema) {
12615
12615
  var DOCS_URL = "docs/observability.md#schema-diagnostics";
12616
12616
  var MAX_WARNED_KEYS = 2048;
12617
12617
  var warnedDiagnosticKeys = /* @__PURE__ */ new Set();
12618
+ var silencedDepth = 0;
12619
+ function withDiagnosticsSilenced(fn) {
12620
+ silencedDepth++;
12621
+ try {
12622
+ return fn();
12623
+ } finally {
12624
+ silencedDepth--;
12625
+ }
12626
+ }
12618
12627
  function warnDiagnosticOnce(key, message, silent, docs = DOCS_URL) {
12619
- if (silent || process.env.AXL_DIAGNOSTICS_SILENT === "true") return;
12628
+ if (silent || silencedDepth > 0 || process.env.AXL_DIAGNOSTICS_SILENT === "true") return;
12620
12629
  if (warnedDiagnosticKeys.has(key)) return;
12621
12630
  if (warnedDiagnosticKeys.size >= MAX_WARNED_KEYS) warnedDiagnosticKeys.clear();
12622
12631
  warnedDiagnosticKeys.add(key);
@@ -18680,10 +18689,135 @@ import { AsyncLocalStorage as AsyncLocalStorage4 } from "async_hooks";
18680
18689
  import { appendFileSync, mkdirSync as mkdirSync2 } from "fs";
18681
18690
  import { dirname } from "path";
18682
18691
 
18692
+ // src/state/retention.ts
18693
+ var PRUNE_BATCH = 100;
18694
+ var DEFAULT_MEMORY_MAX_EXECUTIONS = 1e3;
18695
+ function warnExecutionRetentionUnset(storeName) {
18696
+ warnDiagnosticOnce(
18697
+ `execution_retention_unset\0${storeName}`,
18698
+ `${storeName} keeps execution history forever because no retention is set; set ttls.executionHistory${storeName === "RedisStore" ? " (or defaultTtl)" : ""} to a number of seconds, or ttls.executionHistory to null to keep it forever without this warning.`,
18699
+ void 0,
18700
+ "docs/migration/execution-history-retention.md#silencing-the-retention-warning"
18701
+ );
18702
+ }
18703
+ function resolveTtl(storeName, category, value) {
18704
+ if (value === void 0 || value === null) return null;
18705
+ if (typeof value !== "number") {
18706
+ throw new AxlError(
18707
+ "INVALID_CONFIG",
18708
+ `${storeName}: ttls.${category} must be a positive number of seconds or null; got ${typeof value}`
18709
+ );
18710
+ }
18711
+ if (!Number.isFinite(value) || value <= 0) {
18712
+ throw new AxlError(
18713
+ "INVALID_CONFIG",
18714
+ `${storeName}: ttls.${category} must be a positive number of seconds or null; got ${value}`
18715
+ );
18716
+ }
18717
+ return value;
18718
+ }
18719
+ function resolveHistoryTtls(storeName, ttls) {
18720
+ if (ttls !== void 0 && (ttls === null || typeof ttls !== "object")) {
18721
+ throw new AxlError(
18722
+ "INVALID_CONFIG",
18723
+ `${storeName}: ttls must be an object; got ${ttls === null ? "null" : typeof ttls}`
18724
+ );
18725
+ }
18726
+ return {
18727
+ executionHistory: resolveTtl(storeName, "executionHistory", ttls?.executionHistory),
18728
+ evalHistory: resolveTtl(storeName, "evalHistory", ttls?.evalHistory)
18729
+ };
18730
+ }
18731
+ function expiryFrom(now, ttlSeconds) {
18732
+ return ttlSeconds === null ? null : now + Math.round(ttlSeconds * 1e3);
18733
+ }
18734
+ function isExpired(expiresAt, now) {
18735
+ return expiresAt !== null && now >= expiresAt;
18736
+ }
18737
+ var DEFAULT_RETENTION_BATCH_SIZE = 500;
18738
+ function resolveHistoryRetentionOptions(storeName, options) {
18739
+ const method = `${storeName}.applyHistoryRetention()`;
18740
+ if (options === null || typeof options !== "object") {
18741
+ throw new AxlError("INVALID_ARGUMENT", `${method} options must be an object`);
18742
+ }
18743
+ const { kind, batchSize, cursor } = options;
18744
+ if (kind !== "executions" && kind !== "evals") {
18745
+ throw new AxlError(
18746
+ "INVALID_ARGUMENT",
18747
+ `${method} kind must be 'executions' or 'evals'; got ${String(kind)}`
18748
+ );
18749
+ }
18750
+ if (batchSize !== void 0 && !(typeof batchSize === "number" && Number.isSafeInteger(batchSize) && batchSize > 0)) {
18751
+ throw new AxlError(
18752
+ "INVALID_ARGUMENT",
18753
+ `${method} batchSize must be a positive integer; got ${String(batchSize)}`
18754
+ );
18755
+ }
18756
+ let parsedCursor;
18757
+ if (cursor !== void 0 && cursor !== null) {
18758
+ const candidate = cursor;
18759
+ if (typeof candidate !== "object" || typeof candidate.time !== "number" || !Number.isFinite(candidate.time) || typeof candidate.id !== "string") {
18760
+ throw new AxlError(
18761
+ "INVALID_ARGUMENT",
18762
+ `${method} cursor must be the { time, id } cursor returned by the previous call`
18763
+ );
18764
+ }
18765
+ parsedCursor = { time: candidate.time, id: candidate.id };
18766
+ }
18767
+ return {
18768
+ kind,
18769
+ batchSize: batchSize ?? DEFAULT_RETENTION_BATCH_SIZE,
18770
+ cursor: parsedCursor
18771
+ };
18772
+ }
18773
+ function retentionForUntimedRecord(lastSavedAt, ttlSeconds, now) {
18774
+ const expiresAt = lastSavedAt + Math.round(ttlSeconds * 1e3);
18775
+ return isExpired(expiresAt, now) ? { expired: true } : { expired: false, expiresAt };
18776
+ }
18777
+ function executionLastSavedAt(record) {
18778
+ return typeof record.completedAt === "number" && Number.isFinite(record.completedAt) ? record.completedAt : record.startedAt;
18779
+ }
18780
+ function compareExecutionsNewestFirst(a, b) {
18781
+ if (a.startedAt !== b.startedAt) return b.startedAt - a.startedAt;
18782
+ return compareCodePoints(b.executionId, a.executionId);
18783
+ }
18784
+ function compareRetentionPositions(a, b) {
18785
+ if (a.time !== b.time) return a.time - b.time;
18786
+ return compareCodePoints(a.id, b.id);
18787
+ }
18788
+ function compareCodePoints(a, b) {
18789
+ const len = Math.min(a.length, b.length);
18790
+ for (let i = 0; i < len; i++) {
18791
+ const x = a.charCodeAt(i);
18792
+ const y = b.charCodeAt(i);
18793
+ if (x !== y) return codePointRank(x) - codePointRank(y);
18794
+ }
18795
+ return a.length - b.length;
18796
+ }
18797
+ function codePointRank(unit) {
18798
+ if (unit >= 57344) return unit - 2048;
18799
+ if (unit >= 55296) return unit + 8192;
18800
+ return unit;
18801
+ }
18802
+
18683
18803
  // src/state/memory.ts
18684
18804
  import { writeFileSync, readFileSync, existsSync, mkdirSync, unlinkSync } from "fs";
18685
18805
  import { join } from "path";
18686
18806
  import { tmpdir } from "os";
18807
+ function compareOldestFirst(a, b) {
18808
+ return compareExecutionsNewestFirst(b, a);
18809
+ }
18810
+ function resolveMaxExecutions(value) {
18811
+ if (value === void 0) return DEFAULT_MEMORY_MAX_EXECUTIONS;
18812
+ if (value === Infinity) return Infinity;
18813
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
18814
+ throw new AxlError(
18815
+ "INVALID_CONFIG",
18816
+ `MemoryStore: maxExecutions must be a positive integer or Infinity; got ${typeof value === "number" ? value : typeof value}`
18817
+ );
18818
+ }
18819
+ return value;
18820
+ }
18687
18821
  var AWAIT_HUMAN_TEMP_DIR = join(tmpdir(), "axl-memory-store");
18688
18822
  var AWAIT_HUMAN_TEMP_FILE = join(AWAIT_HUMAN_TEMP_DIR, "await-human-state.json");
18689
18823
  var MemoryStore = class {
@@ -18693,9 +18827,32 @@ var MemoryStore = class {
18693
18827
  decisions = /* @__PURE__ */ new Map();
18694
18828
  executionStates = /* @__PURE__ */ new Map();
18695
18829
  memories = /* @__PURE__ */ new Map();
18830
+ /**
18831
+ * Iteration order is last-save order: a save deletes and re-inserts its key.
18832
+ * With one retention per store and a clock that does not run backwards, that
18833
+ * is also expiry order, which lets the prune stop at the first live record.
18834
+ */
18696
18835
  executionHistory = /* @__PURE__ */ new Map();
18836
+ /** Every key in `executionHistory`, sorted oldest first in listing order. */
18837
+ executionOrder = [];
18838
+ /**
18839
+ * Iteration order is first-write order (a re-save keeps the key's position
18840
+ * and its expiry), which is also expiry order for the same reason as above.
18841
+ */
18697
18842
  evalHistory = /* @__PURE__ */ new Map();
18698
- constructor() {
18843
+ ttls;
18844
+ maxExecutions;
18845
+ now;
18846
+ constructor(options) {
18847
+ this.ttls = resolveHistoryTtls("MemoryStore", options?.ttls);
18848
+ this.maxExecutions = resolveMaxExecutions(options?.maxExecutions);
18849
+ if (options?.now !== void 0 && typeof options.now !== "function") {
18850
+ throw new AxlError(
18851
+ "INVALID_CONFIG",
18852
+ "MemoryStore: now must be a function returning epoch milliseconds"
18853
+ );
18854
+ }
18855
+ this.now = options?.now ?? (() => Date.now());
18699
18856
  this.loadPersistedState();
18700
18857
  }
18701
18858
  async saveCheckpoint(executionId, name, data) {
@@ -18792,23 +18949,43 @@ var MemoryStore = class {
18792
18949
  }
18793
18950
  // ── Execution History ──────────────────────────────────────────────
18794
18951
  async saveExecution(execution) {
18795
- const normalized = normalizeStoredExecution({
18796
- ...execution,
18797
- eventSchemaVersion: getExecutionEventSchemaVersion(execution)
18952
+ const now = this.now();
18953
+ const normalized = structuredClone(
18954
+ normalizeStoredExecution({
18955
+ ...execution,
18956
+ eventSchemaVersion: getExecutionEventSchemaVersion(execution)
18957
+ })
18958
+ );
18959
+ this.removeExecutionRecord(normalized.executionId);
18960
+ this.executionHistory.set(normalized.executionId, {
18961
+ record: normalized,
18962
+ expiresAt: expiryFrom(now, this.ttls.executionHistory)
18798
18963
  });
18799
- this.executionHistory.set(execution.executionId, structuredClone(normalized));
18964
+ this.insertExecutionOrder(normalized);
18965
+ this.pruneExpiredExecutions(now);
18966
+ this.evictOverflowExecutions();
18800
18967
  }
18801
18968
  async getExecution(executionId) {
18802
- const exec = this.executionHistory.get(executionId);
18803
- return exec ? normalizeStoredExecution(structuredClone(exec)) : null;
18804
- }
18805
- async listExecutions(limit) {
18806
- const sorted = [...this.executionHistory.values()].sort((a, b) => b.startedAt - a.startedAt);
18807
- const result = limit ? sorted.slice(0, limit) : sorted;
18808
- return result.map((e) => normalizeStoredExecution(structuredClone(e)));
18969
+ const stored = this.executionHistory.get(executionId);
18970
+ if (!stored || isExpired(stored.expiresAt, this.now())) return null;
18971
+ return normalizeStoredExecution(structuredClone(stored.record));
18972
+ }
18973
+ /** Supports `listExecutions(limit, { cursor })`. */
18974
+ historyPaging = true;
18975
+ async listExecutions(limit, options) {
18976
+ const now = this.now();
18977
+ const max = limit ? limit : Infinity;
18978
+ const result = [];
18979
+ const start = options?.cursor !== void 0 ? this.executionOrderLowerBound(options.cursor) - 1 : this.executionOrder.length - 1;
18980
+ for (let i = start; i >= 0 && result.length < max; i--) {
18981
+ const stored = this.executionHistory.get(this.executionOrder[i].executionId);
18982
+ if (isExpired(stored.expiresAt, now)) continue;
18983
+ result.push(normalizeStoredExecution(structuredClone(stored.record)));
18984
+ }
18985
+ return result;
18809
18986
  }
18810
18987
  async deleteExecution(executionId) {
18811
- const removedHistory = this.executionHistory.delete(executionId);
18988
+ const removedHistory = this.removeExecutionRecord(executionId);
18812
18989
  const removedCheckpoints = this.checkpoints.delete(executionId);
18813
18990
  const removedState = this.executionStates.delete(executionId);
18814
18991
  const removedStreaming = this.streamingEvents.delete(executionId);
@@ -18819,26 +18996,193 @@ var MemoryStore = class {
18819
18996
  return removedHistory || removedCheckpoints || removedState || removedStreaming || removedDecision;
18820
18997
  }
18821
18998
  // ── Eval History ──────────────────────────────────────────────────
18999
+ /**
19000
+ * Write an eval history row. Retention counts from the row's first write:
19001
+ * re-saving a live row keeps its expiry (and its position in expiry order).
19002
+ * A row that has already expired is replaced as a new one.
19003
+ */
18822
19004
  async saveEvalResult(entry) {
18823
- this.evalHistory.set(entry.id, structuredClone(entry));
19005
+ const now = this.now();
19006
+ const existing = this.evalHistory.get(entry.id);
19007
+ if (existing && !isExpired(existing.expiresAt, now)) {
19008
+ this.evalHistory.set(entry.id, {
19009
+ entry: structuredClone(entry),
19010
+ expiresAt: existing.expiresAt
19011
+ });
19012
+ } else {
19013
+ this.evalHistory.delete(entry.id);
19014
+ this.evalHistory.set(entry.id, {
19015
+ entry: structuredClone(entry),
19016
+ expiresAt: expiryFrom(now, this.ttls.evalHistory)
19017
+ });
19018
+ }
19019
+ this.pruneExpiredEvals(now);
18824
19020
  }
18825
- /** Update-only write: see `StateStore.updateEvalResult`. */
19021
+ /** Update-only write: see `StateStore.updateEvalResult`. Never changes retention. */
18826
19022
  async updateEvalResult(entry) {
18827
- if (!this.evalHistory.has(entry.id)) return false;
18828
- this.evalHistory.set(entry.id, structuredClone(entry));
19023
+ const existing = this.evalHistory.get(entry.id);
19024
+ if (!existing || isExpired(existing.expiresAt, this.now())) return false;
19025
+ this.evalHistory.set(entry.id, {
19026
+ entry: structuredClone(entry),
19027
+ expiresAt: existing.expiresAt
19028
+ });
18829
19029
  return true;
18830
19030
  }
18831
19031
  async listEvalResults(limit) {
18832
- const sorted = [...this.evalHistory.values()].sort((a, b) => b.timestamp - a.timestamp);
19032
+ const now = this.now();
19033
+ const sorted = [...this.evalHistory.values()].filter((stored) => !isExpired(stored.expiresAt, now)).map((stored) => stored.entry).sort((a, b) => b.timestamp - a.timestamp);
18833
19034
  const result = limit ? sorted.slice(0, limit) : sorted;
18834
19035
  return result.map((e) => structuredClone(e));
18835
19036
  }
18836
19037
  async deleteEvalResult(id) {
18837
19038
  return this.evalHistory.delete(id);
18838
19039
  }
18839
- /** In-memory history never expires on its own — only existence is meaningful. */
19040
+ /** Existence plus the expiry stored at the row's first write, if any. */
18840
19041
  async getEvalRetention(id) {
18841
- return { exists: this.evalHistory.has(id) };
19042
+ const stored = this.evalHistory.get(id);
19043
+ if (!stored || isExpired(stored.expiresAt, this.now())) return { exists: false };
19044
+ return stored.expiresAt === null ? { exists: true } : { exists: true, expiresAt: stored.expiresAt };
19045
+ }
19046
+ // ── History maintenance ───────────────────────────────────────────
19047
+ /**
19048
+ * See `StateStore.applyHistoryRetention`. Every record this store holds was
19049
+ * saved under the retention it was constructed with, so in practice a run
19050
+ * finds nothing to expire or arm; the method exists so the same maintenance
19051
+ * code works against every built-in store. There is no separate index, so
19052
+ * `indexed` and `deadIndexEntries` are always `0`.
19053
+ *
19054
+ * An armed record keeps its place in last-save order, which is also where
19055
+ * its expiry (last save + retention) belongs, so the prune-on-save order
19056
+ * still holds.
19057
+ */
19058
+ async applyHistoryRetention(options) {
19059
+ const { kind, batchSize, cursor } = resolveHistoryRetentionOptions("MemoryStore", options);
19060
+ const now = this.now();
19061
+ let expired = 0;
19062
+ let armed = 0;
19063
+ let positions;
19064
+ if (kind === "executions") {
19065
+ let start = 0;
19066
+ if (cursor !== void 0) {
19067
+ const key = { startedAt: cursor.time, executionId: cursor.id };
19068
+ start = this.executionOrderLowerBound(key);
19069
+ const at = this.executionOrder[start];
19070
+ if (at !== void 0 && compareOldestFirst(at, key) === 0) start++;
19071
+ }
19072
+ positions = this.executionOrder.slice(start, start + batchSize + 1).map((k) => ({ time: k.startedAt, id: k.executionId }));
19073
+ } else {
19074
+ positions = [...this.evalHistory.values()].map((stored) => ({ time: stored.entry.timestamp, id: stored.entry.id })).filter((p) => cursor === void 0 || compareRetentionPositions(cursor, p) < 0).sort(compareRetentionPositions).slice(0, batchSize + 1);
19075
+ }
19076
+ const batch = positions.slice(0, batchSize);
19077
+ const ttl = kind === "executions" ? this.ttls.executionHistory : this.ttls.evalHistory;
19078
+ if (ttl !== null) {
19079
+ for (const { id } of batch) {
19080
+ if (kind === "executions") {
19081
+ const stored = this.executionHistory.get(id);
19082
+ if (!stored || stored.expiresAt !== null) continue;
19083
+ const decision = retentionForUntimedRecord(executionLastSavedAt(stored.record), ttl, now);
19084
+ if (decision.expired) {
19085
+ this.expireExecutionRecord(id);
19086
+ expired++;
19087
+ } else {
19088
+ stored.expiresAt = decision.expiresAt;
19089
+ armed++;
19090
+ }
19091
+ } else {
19092
+ const stored = this.evalHistory.get(id);
19093
+ if (!stored || stored.expiresAt !== null) continue;
19094
+ const decision = retentionForUntimedRecord(stored.entry.timestamp, ttl, now);
19095
+ if (decision.expired) {
19096
+ this.evalHistory.delete(id);
19097
+ expired++;
19098
+ } else {
19099
+ stored.expiresAt = decision.expiresAt;
19100
+ armed++;
19101
+ }
19102
+ }
19103
+ }
19104
+ }
19105
+ return {
19106
+ expired,
19107
+ armed,
19108
+ indexed: 0,
19109
+ deadIndexEntries: 0,
19110
+ cursor: positions.length > batchSize ? batch[batch.length - 1] : null
19111
+ };
19112
+ }
19113
+ // ── History retention internals ───────────────────────────────────
19114
+ /** Index of the first order key not older than `key` (binary search). */
19115
+ executionOrderLowerBound(key) {
19116
+ let lo = 0;
19117
+ let hi = this.executionOrder.length;
19118
+ while (lo < hi) {
19119
+ const mid = lo + hi >>> 1;
19120
+ if (compareOldestFirst(this.executionOrder[mid], key) < 0) lo = mid + 1;
19121
+ else hi = mid;
19122
+ }
19123
+ return lo;
19124
+ }
19125
+ insertExecutionOrder(record) {
19126
+ const key = { startedAt: record.startedAt, executionId: record.executionId };
19127
+ this.executionOrder.splice(this.executionOrderLowerBound(key), 0, key);
19128
+ }
19129
+ /** Remove a history record and its listing-index key. Returns whether one existed. */
19130
+ removeExecutionRecord(executionId) {
19131
+ const stored = this.executionHistory.get(executionId);
19132
+ if (!stored) return false;
19133
+ this.executionHistory.delete(executionId);
19134
+ const index = this.executionOrderLowerBound(stored.record);
19135
+ if (this.executionOrder[index]?.executionId === executionId) {
19136
+ this.executionOrder.splice(index, 1);
19137
+ }
19138
+ return true;
19139
+ }
19140
+ /**
19141
+ * A run leaving history through retention (expiry or `maxExecutions`) takes
19142
+ * its leftover per-run data with it: checkpoints (kept today when a run
19143
+ * fails), saved execution state and any streaming buffer. Pending decisions
19144
+ * are not touched. The runtime never writes execution state, and a run is
19145
+ * only saved to history once it has finished, so nothing still running
19146
+ * depends on what is removed here.
19147
+ */
19148
+ expireExecutionRecord(executionId) {
19149
+ this.removeExecutionRecord(executionId);
19150
+ this.checkpoints.delete(executionId);
19151
+ const state = this.executionStates.get(executionId);
19152
+ this.executionStates.delete(executionId);
19153
+ this.streamingEvents.delete(executionId);
19154
+ if (state?.status === "waiting") this.persistAwaitHumanState();
19155
+ }
19156
+ /**
19157
+ * Remove up to `PRUNE_BATCH` expired execution records, oldest save first.
19158
+ * Stops at the first live record: Map order is last-save order, which is
19159
+ * expiry order while the clock moves forward. If the clock steps back, a
19160
+ * few expired records wait for a later save; reads still filter them.
19161
+ */
19162
+ pruneExpiredExecutions(now) {
19163
+ if (this.ttls.executionHistory === null) return;
19164
+ let removed = 0;
19165
+ for (const [id, stored] of this.executionHistory) {
19166
+ if (removed >= PRUNE_BATCH || !isExpired(stored.expiresAt, now)) break;
19167
+ this.expireExecutionRecord(id);
19168
+ removed++;
19169
+ }
19170
+ }
19171
+ /** Evict the oldest records in listing order until the count bound holds. */
19172
+ evictOverflowExecutions() {
19173
+ while (this.executionHistory.size > this.maxExecutions) {
19174
+ this.expireExecutionRecord(this.executionOrder[0].executionId);
19175
+ }
19176
+ }
19177
+ /** Remove up to `PRUNE_BATCH` expired eval rows, oldest first write first. */
19178
+ pruneExpiredEvals(now) {
19179
+ if (this.ttls.evalHistory === null) return;
19180
+ let removed = 0;
19181
+ for (const [id, stored] of this.evalHistory) {
19182
+ if (removed >= PRUNE_BATCH || !isExpired(stored.expiresAt, now)) break;
19183
+ this.evalHistory.delete(id);
19184
+ removed++;
19185
+ }
18842
19186
  }
18843
19187
  // ── Sessions (Studio introspection) ─────────────────────────────────
18844
19188
  async listSessions() {
@@ -18916,6 +19260,7 @@ var MemoryStore = class {
18916
19260
  };
18917
19261
 
18918
19262
  // src/state/sqlite.ts
19263
+ var LIVE = "(expires_at IS NULL OR expires_at > ?)";
18919
19264
  function safeJsonParse(data) {
18920
19265
  try {
18921
19266
  return JSON.parse(data);
@@ -18952,7 +19297,17 @@ function rowToExecutionInfo(row) {
18952
19297
  }
18953
19298
  var SQLiteStore = class {
18954
19299
  db;
18955
- constructor(filePath) {
19300
+ ttls;
19301
+ now;
19302
+ constructor(filePath, options) {
19303
+ this.ttls = resolveHistoryTtls("SQLiteStore", options?.ttls);
19304
+ if (options?.now !== void 0 && typeof options.now !== "function") {
19305
+ throw new AxlError(
19306
+ "INVALID_CONFIG",
19307
+ "SQLiteStore: now must be a function returning epoch milliseconds"
19308
+ );
19309
+ }
19310
+ this.now = options?.now ?? (() => Date.now());
18956
19311
  let BetterSqlite3;
18957
19312
  try {
18958
19313
  BetterSqlite3 = __require("better-sqlite3");
@@ -18965,6 +19320,7 @@ var SQLiteStore = class {
18965
19320
  this.db.pragma("journal_mode = WAL");
18966
19321
  this.migrate();
18967
19322
  this.initTables();
19323
+ if (options?.ttls?.executionHistory === void 0) warnExecutionRetentionUnset("SQLiteStore");
18968
19324
  }
18969
19325
  /**
18970
19326
  * Idempotent schema migration. Tracks version via `PRAGMA user_version`
@@ -18992,6 +19348,11 @@ var SQLiteStore = class {
18992
19348
  * Existing rows remain `NULL`, the documented v1 sentinel.
18993
19349
  * - v5 (observation completeness): add `execution_history.observation`.
18994
19350
  * Existing rows remain `NULL`, meaning they predate the contract.
19351
+ * - v6 (history retention): add nullable `expires_at` (epoch ms) to
19352
+ * `execution_history` and `eval_history`. Existing rows remain `NULL`
19353
+ * (never expire), so an upgrade deletes nothing. The single-column
19354
+ * `started_at` index is replaced by `(started_at, execution_id)`, which
19355
+ * serves the tie-safe listing order.
18995
19356
  *
18996
19357
  * Applied BEFORE `initTables()` so the subsequent `CREATE TABLE
18997
19358
  * IF NOT EXISTS` runs against the post-migration column name. Fresh
@@ -19009,7 +19370,7 @@ var SQLiteStore = class {
19009
19370
  * transactional re-read costs nothing.
19010
19371
  */
19011
19372
  migrate() {
19012
- const TARGET_VERSION = 5;
19373
+ const TARGET_VERSION = 6;
19013
19374
  const current = this.db.pragma("user_version", { simple: true });
19014
19375
  if (current >= TARGET_VERSION) return;
19015
19376
  this.db.exec("BEGIN IMMEDIATE");
@@ -19049,6 +19410,15 @@ var SQLiteStore = class {
19049
19410
  this.db.exec("ALTER TABLE execution_history ADD COLUMN observation TEXT");
19050
19411
  }
19051
19412
  }
19413
+ if (committed < 6) {
19414
+ for (const table of ["execution_history", "eval_history"]) {
19415
+ const cols = this.db.pragma(`table_info(${table})`);
19416
+ if (cols.length > 0 && !cols.some((c) => c.name === "expires_at")) {
19417
+ this.db.exec(`ALTER TABLE ${table} ADD COLUMN expires_at INTEGER`);
19418
+ }
19419
+ }
19420
+ this.db.exec("DROP INDEX IF EXISTS idx_exec_history_started");
19421
+ }
19052
19422
  this.db.pragma(`user_version = ${TARGET_VERSION}`);
19053
19423
  this.db.exec("COMMIT");
19054
19424
  } catch (err) {
@@ -19114,18 +19484,28 @@ var SQLiteStore = class {
19114
19484
  events TEXT NOT NULL,
19115
19485
  metadata TEXT,
19116
19486
  event_schema_version INTEGER,
19117
- observation TEXT
19487
+ observation TEXT,
19488
+ expires_at INTEGER
19118
19489
  );
19119
19490
 
19120
19491
  CREATE TABLE IF NOT EXISTS eval_history (
19121
19492
  id TEXT PRIMARY KEY,
19122
19493
  eval_name TEXT NOT NULL,
19123
19494
  timestamp INTEGER NOT NULL,
19124
- data TEXT NOT NULL
19495
+ data TEXT NOT NULL,
19496
+ expires_at INTEGER
19125
19497
  );
19126
19498
 
19127
- CREATE INDEX IF NOT EXISTS idx_exec_history_started ON execution_history (started_at DESC);
19499
+ CREATE INDEX IF NOT EXISTS idx_exec_history_started_id
19500
+ ON execution_history (started_at DESC, execution_id DESC);
19501
+ CREATE INDEX IF NOT EXISTS idx_exec_history_expires ON execution_history (expires_at);
19128
19502
  CREATE INDEX IF NOT EXISTS idx_eval_history_timestamp ON eval_history (timestamp DESC);
19503
+ CREATE INDEX IF NOT EXISTS idx_eval_history_expires ON eval_history (expires_at);
19504
+
19505
+ -- Superseded by idx_exec_history_started_id in v6. Dropped here as well as in
19506
+ -- migrate(): a pre-v6 process reopening a v6 file re-creates it, and
19507
+ -- migrate() never runs again once user_version is 6.
19508
+ DROP INDEX IF EXISTS idx_exec_history_started;
19129
19509
  `);
19130
19510
  }
19131
19511
  // ── Checkpoints ────────────────────────────────────────────────────────
@@ -19238,30 +19618,71 @@ var SQLiteStore = class {
19238
19618
  ...execution,
19239
19619
  eventSchemaVersion: getExecutionEventSchemaVersion(execution)
19240
19620
  });
19621
+ const now = this.now();
19622
+ this.db.transaction(() => {
19623
+ this.db.prepare(
19624
+ "INSERT OR REPLACE INTO execution_history (execution_id, workflow, status, total_cost, started_at, completed_at, duration, error, events, metadata, event_schema_version, observation, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
19625
+ ).run(
19626
+ normalized.executionId,
19627
+ normalized.workflow,
19628
+ normalized.status,
19629
+ normalized.totalCost,
19630
+ normalized.startedAt,
19631
+ normalized.completedAt ?? null,
19632
+ normalized.duration,
19633
+ normalized.error ?? null,
19634
+ JSON.stringify(normalized.events),
19635
+ normalized.metadata !== void 0 ? JSON.stringify(normalized.metadata) : null,
19636
+ normalized.eventSchemaVersion,
19637
+ normalized.observation !== void 0 ? JSON.stringify(normalized.observation) : null,
19638
+ expiryFrom(now, this.ttls.executionHistory)
19639
+ );
19640
+ this.pruneExpiredExecutions(now);
19641
+ })();
19642
+ }
19643
+ /**
19644
+ * Remove up to `PRUNE_BATCH` expired execution-history rows, together with
19645
+ * each run's leftover checkpoints and saved execution state, inside the
19646
+ * caller's transaction. Pending decisions are not touched. The runtime
19647
+ * never writes execution state, and a run is only saved to history once it
19648
+ * has finished, so nothing still running depends on what is removed here.
19649
+ */
19650
+ pruneExpiredExecutions(now) {
19651
+ const ids = this.db.prepare(
19652
+ "SELECT execution_id FROM execution_history WHERE expires_at <= ? ORDER BY expires_at LIMIT ?"
19653
+ ).all(now, PRUNE_BATCH).map((row) => row.execution_id);
19654
+ if (ids.length === 0) return;
19655
+ const idList = JSON.stringify(ids);
19656
+ for (const table of ["execution_history", "checkpoints", "execution_state"]) {
19657
+ this.db.prepare(`DELETE FROM ${table} WHERE execution_id IN (SELECT value FROM json_each(?))`).run(idList);
19658
+ }
19659
+ }
19660
+ /** Remove up to `PRUNE_BATCH` expired eval-history rows (caller's transaction). */
19661
+ pruneExpiredEvals(now) {
19241
19662
  this.db.prepare(
19242
- "INSERT OR REPLACE INTO execution_history (execution_id, workflow, status, total_cost, started_at, completed_at, duration, error, events, metadata, event_schema_version, observation) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
19243
- ).run(
19244
- normalized.executionId,
19245
- normalized.workflow,
19246
- normalized.status,
19247
- normalized.totalCost,
19248
- normalized.startedAt,
19249
- normalized.completedAt ?? null,
19250
- normalized.duration,
19251
- normalized.error ?? null,
19252
- JSON.stringify(normalized.events),
19253
- normalized.metadata !== void 0 ? JSON.stringify(normalized.metadata) : null,
19254
- normalized.eventSchemaVersion,
19255
- normalized.observation !== void 0 ? JSON.stringify(normalized.observation) : null
19256
- );
19663
+ "DELETE FROM eval_history WHERE rowid IN (SELECT rowid FROM eval_history WHERE expires_at <= ? ORDER BY expires_at LIMIT ?)"
19664
+ ).run(now, PRUNE_BATCH);
19257
19665
  }
19258
19666
  async getExecution(executionId) {
19259
- const row = this.db.prepare("SELECT * FROM execution_history WHERE execution_id = ?").get(executionId);
19667
+ const row = this.db.prepare(`SELECT * FROM execution_history WHERE execution_id = ? AND ${LIVE}`).get(executionId, this.now());
19260
19668
  return row ? rowToExecutionInfo(row) : null;
19261
19669
  }
19262
- async listExecutions(limit) {
19263
- const sql = limit ? "SELECT * FROM execution_history ORDER BY started_at DESC LIMIT ?" : "SELECT * FROM execution_history ORDER BY started_at DESC";
19264
- const rows = limit ? this.db.prepare(sql).all(limit) : this.db.prepare(sql).all();
19670
+ /** Supports `listExecutions(limit, { cursor })`. */
19671
+ historyPaging = true;
19672
+ async listExecutions(limit, options) {
19673
+ const cursor = options?.cursor;
19674
+ const params = [this.now()];
19675
+ let sql = `SELECT * FROM execution_history WHERE ${LIVE}`;
19676
+ if (cursor !== void 0) {
19677
+ sql += " AND (started_at < ? OR (started_at = ? AND execution_id < ?))";
19678
+ params.push(cursor.startedAt, cursor.startedAt, cursor.executionId);
19679
+ }
19680
+ sql += " ORDER BY started_at DESC, execution_id DESC";
19681
+ if (limit) {
19682
+ sql += " LIMIT ?";
19683
+ params.push(limit);
19684
+ }
19685
+ const rows = this.db.prepare(sql).all(...params);
19265
19686
  return rows.map(rowToExecutionInfo);
19266
19687
  }
19267
19688
  async deleteExecution(executionId) {
@@ -19275,24 +19696,38 @@ var SQLiteStore = class {
19275
19696
  return tx();
19276
19697
  }
19277
19698
  // ── Eval History ────────────────────────────────────────────────────────
19699
+ /**
19700
+ * Write an eval history row. Retention counts from the row's first write:
19701
+ * re-saving a live row keeps its `expires_at` (including none, for a row
19702
+ * written without retention). A row that has already expired is replaced
19703
+ * as a new one.
19704
+ */
19278
19705
  async saveEvalResult(entry) {
19279
- this.db.prepare(
19280
- "INSERT OR REPLACE INTO eval_history (id, eval_name, timestamp, data) VALUES (?, ?, ?, ?)"
19281
- ).run(entry.id, entry.eval, entry.timestamp, JSON.stringify(entry.data));
19706
+ const now = this.now();
19707
+ this.db.transaction(() => {
19708
+ const existing = this.db.prepare(`SELECT expires_at FROM eval_history WHERE id = ? AND ${LIVE}`).get(entry.id, now);
19709
+ const expiresAt = existing ? existing.expires_at : expiryFrom(now, this.ttls.evalHistory);
19710
+ this.db.prepare(
19711
+ "INSERT OR REPLACE INTO eval_history (id, eval_name, timestamp, data, expires_at) VALUES (?, ?, ?, ?, ?)"
19712
+ ).run(entry.id, entry.eval, entry.timestamp, JSON.stringify(entry.data), expiresAt);
19713
+ this.pruneExpiredEvals(now);
19714
+ })();
19282
19715
  }
19283
19716
  /**
19284
19717
  * Update-only write: see `StateStore.updateEvalResult`. `UPDATE ... WHERE id`
19285
- * touches nothing when the row is gone, where `INSERT OR REPLACE` would
19286
- * resurrect it — and SQLite history has no expiry, so a resurrection here is
19287
- * permanent.
19718
+ * touches nothing when the row is gone or expired, where `INSERT OR REPLACE`
19719
+ * would resurrect it. `expires_at` is left as it was.
19288
19720
  */
19289
19721
  async updateEvalResult(entry) {
19290
- const result = this.db.prepare("UPDATE eval_history SET eval_name = ?, timestamp = ?, data = ? WHERE id = ?").run(entry.eval, entry.timestamp, JSON.stringify(entry.data), entry.id);
19722
+ const result = this.db.prepare(
19723
+ `UPDATE eval_history SET eval_name = ?, timestamp = ?, data = ? WHERE id = ? AND ${LIVE}`
19724
+ ).run(entry.eval, entry.timestamp, JSON.stringify(entry.data), entry.id, this.now());
19291
19725
  return result.changes > 0;
19292
19726
  }
19293
19727
  async listEvalResults(limit) {
19294
- const sql = limit ? "SELECT * FROM eval_history ORDER BY timestamp DESC LIMIT ?" : "SELECT * FROM eval_history ORDER BY timestamp DESC";
19295
- const rows = limit ? this.db.prepare(sql).all(limit) : this.db.prepare(sql).all();
19728
+ const base = `SELECT * FROM eval_history WHERE ${LIVE} ORDER BY timestamp DESC`;
19729
+ const now = this.now();
19730
+ const rows = limit ? this.db.prepare(`${base} LIMIT ?`).all(now, limit) : this.db.prepare(base).all(now);
19296
19731
  return rows.map((r) => ({
19297
19732
  id: r.id,
19298
19733
  eval: r.eval_name,
@@ -19304,10 +19739,75 @@ var SQLiteStore = class {
19304
19739
  const result = this.db.prepare("DELETE FROM eval_history WHERE id = ?").run(id);
19305
19740
  return result.changes > 0;
19306
19741
  }
19307
- /** SQLite history has no automatic expiry — only existence is meaningful. */
19742
+ /** Existence plus the expiry stored at the row's first write, if any. */
19308
19743
  async getEvalRetention(id) {
19309
- const row = this.db.prepare("SELECT 1 FROM eval_history WHERE id = ?").get(id);
19310
- return { exists: row !== void 0 };
19744
+ const row = this.db.prepare(`SELECT expires_at FROM eval_history WHERE id = ? AND ${LIVE}`).get(id, this.now());
19745
+ if (!row) return { exists: false };
19746
+ return row.expires_at === null ? { exists: true } : { exists: true, expiresAt: row.expires_at };
19747
+ }
19748
+ // ── History maintenance ─────────────────────────────────────────────────
19749
+ /**
19750
+ * See `StateStore.applyHistoryRetention`. One batch runs in one immediate
19751
+ * transaction, so no other writer can change a row between this call
19752
+ * reading it and acting on it. Only rows whose `expires_at` is `NULL` are
19753
+ * touched. An expired execution takes its checkpoints and saved execution
19754
+ * state with it, as a normal expiry does. There is no separate index, so
19755
+ * `indexed` and `deadIndexEntries` are always `0`.
19756
+ */
19757
+ async applyHistoryRetention(options) {
19758
+ const { kind, batchSize, cursor } = resolveHistoryRetentionOptions("SQLiteStore", options);
19759
+ const ttl = kind === "executions" ? this.ttls.executionHistory : this.ttls.evalHistory;
19760
+ const table = kind === "executions" ? { name: "execution_history", id: "execution_id", time: "started_at" } : { name: "eval_history", id: "id", time: "timestamp" };
19761
+ const lastSaved = kind === "executions" ? "completed_at" : "NULL";
19762
+ const now = this.now();
19763
+ return this.db.transaction(() => {
19764
+ const params = [];
19765
+ let sql = `SELECT ${table.id} AS id, ${table.time} AS time, ${lastSaved} AS last_saved, expires_at FROM ${table.name}`;
19766
+ if (cursor !== void 0) {
19767
+ sql += ` WHERE ${table.time} > ? OR (${table.time} = ? AND ${table.id} > ?)`;
19768
+ params.push(cursor.time, cursor.time, cursor.id);
19769
+ }
19770
+ sql += ` ORDER BY ${table.time}, ${table.id} LIMIT ?`;
19771
+ params.push(batchSize + 1);
19772
+ const rows = this.db.prepare(sql).all(...params);
19773
+ const batch = rows.slice(0, batchSize);
19774
+ const toExpire = [];
19775
+ let armed = 0;
19776
+ if (ttl !== null) {
19777
+ const arm = this.db.prepare(
19778
+ `UPDATE ${table.name} SET expires_at = ? WHERE ${table.id} = ? AND expires_at IS NULL`
19779
+ );
19780
+ for (const row of batch) {
19781
+ if (row.expires_at !== null) continue;
19782
+ const lastSavedAt = kind === "executions" ? executionLastSavedAt({ startedAt: row.time, completedAt: row.last_saved }) : row.time;
19783
+ const decision = retentionForUntimedRecord(lastSavedAt, ttl, now);
19784
+ if (decision.expired) toExpire.push(row.id);
19785
+ else armed += arm.run(decision.expiresAt, row.id).changes;
19786
+ }
19787
+ }
19788
+ let expired = 0;
19789
+ if (toExpire.length > 0) {
19790
+ const idList = JSON.stringify(toExpire);
19791
+ expired = this.db.prepare(
19792
+ `DELETE FROM ${table.name} WHERE expires_at IS NULL AND ${table.id} IN (SELECT value FROM json_each(?))`
19793
+ ).run(idList).changes;
19794
+ if (kind === "executions") {
19795
+ for (const side of ["checkpoints", "execution_state"]) {
19796
+ this.db.prepare(
19797
+ `DELETE FROM ${side} WHERE execution_id IN (SELECT value FROM json_each(?))`
19798
+ ).run(idList);
19799
+ }
19800
+ }
19801
+ }
19802
+ const last = batch[batch.length - 1];
19803
+ return {
19804
+ expired,
19805
+ armed,
19806
+ indexed: 0,
19807
+ deadIndexEntries: 0,
19808
+ cursor: rows.length > batchSize ? { time: last.time, id: last.id } : null
19809
+ };
19810
+ }).immediate();
19311
19811
  }
19312
19812
  // ── Sessions (Studio introspection) ────────────────────────────────────
19313
19813
  async listSessions() {
@@ -20656,6 +21156,9 @@ function hashInput(input) {
20656
21156
  }
20657
21157
  var DEFAULT_MAX_EVENTS_PER_EXECUTION = 5e4;
20658
21158
  var DEFAULT_STREAMING_BATCH_SIZE = 100;
21159
+ var DEFAULT_RECENT_EXECUTIONS_CACHE_SIZE = 1e3;
21160
+ var LARGE_EXECUTION_LISTING = 1e3;
21161
+ var EXECUTION_SCAN_PAGE_SIZE = 100;
20659
21162
  var DEFAULT_STREAMING_BATCH_INTERVAL = 1e3;
20660
21163
  var DEFAULT_ARTIFACT_SWEEP_MS = 6e4;
20661
21164
  var DEFAULT_ARTIFACT_LEASE_MS = 3e5;
@@ -20868,6 +21371,78 @@ function assertNoRemovedObservationCallbacks(options) {
20868
21371
  `runtime.createContext() no longer accepts ${removed.join(", ")}. Observe ctx.events or use runtime.stream() instead. See docs/migration/stream-first-observation.md.`
20869
21372
  );
20870
21373
  }
21374
+ var RecentRunsCache = class {
21375
+ constructor(capacity) {
21376
+ this.capacity = capacity;
21377
+ }
21378
+ entries = /* @__PURE__ */ new Map();
21379
+ get size() {
21380
+ return this.entries.size;
21381
+ }
21382
+ get(executionId) {
21383
+ const entry = this.entries.get(executionId);
21384
+ if (entry) {
21385
+ this.entries.delete(executionId);
21386
+ this.entries.set(executionId, entry);
21387
+ }
21388
+ return entry;
21389
+ }
21390
+ set(executionId, execution) {
21391
+ this.entries.delete(executionId);
21392
+ this.entries.set(executionId, execution);
21393
+ while (this.entries.size > this.capacity) {
21394
+ const oldest = this.entries.keys().next().value;
21395
+ this.entries.delete(oldest);
21396
+ }
21397
+ }
21398
+ delete(executionId) {
21399
+ return this.entries.delete(executionId);
21400
+ }
21401
+ values() {
21402
+ return [...this.entries.values()];
21403
+ }
21404
+ };
21405
+ function resolveRecentExecutionsCacheSize(requested) {
21406
+ if (requested === void 0) return DEFAULT_RECENT_EXECUTIONS_CACHE_SIZE;
21407
+ if (typeof requested !== "number" || !Number.isInteger(requested) || requested < 0) {
21408
+ throw new AxlError(
21409
+ "INVALID_CONFIG",
21410
+ `config.state.recentExecutionsCacheSize must be a non-negative integer; got ${String(requested)}`
21411
+ );
21412
+ }
21413
+ return requested;
21414
+ }
21415
+ function parseGetExecutionsOptions(options) {
21416
+ if (options === void 0) return {};
21417
+ if (options === null || typeof options !== "object") {
21418
+ throw new AxlError(
21419
+ "INVALID_ARGUMENT",
21420
+ `getExecutions() options must be an object; got ${options === null ? "null" : typeof options}`
21421
+ );
21422
+ }
21423
+ const { limit, cursor } = options;
21424
+ if (limit !== void 0 && limit !== Infinity && !(typeof limit === "number" && Number.isInteger(limit) && limit > 0)) {
21425
+ throw new AxlError(
21426
+ "INVALID_ARGUMENT",
21427
+ `getExecutions() limit must be a positive integer; got ${String(limit)}`
21428
+ );
21429
+ }
21430
+ let parsedCursor;
21431
+ if (cursor !== void 0) {
21432
+ const candidate = cursor;
21433
+ if (candidate === null || typeof candidate !== "object" || typeof candidate.startedAt !== "number" || !Number.isFinite(candidate.startedAt) || typeof candidate.executionId !== "string") {
21434
+ throw new AxlError(
21435
+ "INVALID_ARGUMENT",
21436
+ "getExecutions() cursor must be { startedAt: number; executionId: string }, built from the last item of the previous page"
21437
+ );
21438
+ }
21439
+ parsedCursor = { startedAt: candidate.startedAt, executionId: candidate.executionId };
21440
+ }
21441
+ return {
21442
+ ...limit === void 0 || limit === Infinity ? {} : { limit },
21443
+ ...parsedCursor ? { cursor: parsedCursor } : {}
21444
+ };
21445
+ }
20871
21446
  var costScopeStorage = new AsyncLocalStorage4();
20872
21447
  function forwardAbortSignal(external, internal) {
20873
21448
  if (!external) return () => {
@@ -20914,8 +21489,6 @@ var AxlRuntime = class extends EventEmitter2 {
20914
21489
  mcpManager;
20915
21490
  memoryManager;
20916
21491
  spanManager = new NoopSpanManager();
20917
- historicalExecutions = /* @__PURE__ */ new Map();
20918
- historicalExecutionsLoadPromise = null;
20919
21492
  /** Per-session in-process serialization. Each entry is a chain of pending
20920
21493
  * work for that session id — `Session.send` / `Session.stream` await the
20921
21494
  * prior entry before reading history, so concurrent calls produce a
@@ -20951,12 +21524,34 @@ var AxlRuntime = class extends EventEmitter2 {
20951
21524
  * a delete from resurrecting the (intentionally-removed) row. Entries are
20952
21525
  * removed once the would-be persist has been short-circuited. */
20953
21526
  pendingDeletedExecutions = /* @__PURE__ */ new Set();
20954
- /** In-flight `persistExecution` chains (save → finalize). `shutdown()`
20955
- * awaits these before closing the StateStore so a workflow whose abort
21527
+ /** In-flight `persistExecution` chains (save → finalize) and crash-
21528
+ * recovery saves. `shutdown()` awaits these before closing the StateStore so a workflow whose abort
20956
21529
  * triggers a persist-during-shutdown isn't racing a closed connection.
20957
21530
  * The Set is mutated by the chain's own `.finally` so entries clear as
20958
21531
  * soon as the work settles. */
20959
21532
  persistInflight = /* @__PURE__ */ new Set();
21533
+ /** Finished runs whose history save has not settled, keyed by execution
21534
+ * id. Reads serve the snapshot until the save lands, so a run is visible
21535
+ * the moment it finishes (read-your-writes). `deleteExecution()` awaits
21536
+ * the entry before deleting from the store, so a save that lands late
21537
+ * cannot re-create a deleted run. Each entry removes itself when its save
21538
+ * settles (success or failure), but only if it is still the current entry
21539
+ * for that id, so the map holds only in-flight saves. */
21540
+ pendingSaves = /* @__PURE__ */ new Map();
21541
+ /** Recently finished runs, kept only when the store cannot save, list, or
21542
+ * fetch execution history itself (and the cache size is not `0`).
21543
+ * `undefined` for every built-in store: execution history is read through
21544
+ * the store on every call and never cached here. */
21545
+ recentRuns;
21546
+ /** Execution ids `recoverIncompleteStreams()` is working on, each with a
21547
+ * promise that settles when recovery is done with that id.
21548
+ * `deleteExecution()` awaits it, so a recovered record cannot land after
21549
+ * the delete. */
21550
+ recoveringExecutions = /* @__PURE__ */ new Map();
21551
+ /** Execution ids with a `deleteExecution()` in progress, with a count of
21552
+ * concurrent deletes. Recovery skips them: their store sweep removes the
21553
+ * streaming buffer it would otherwise turn back into a record. */
21554
+ deletingExecutions = /* @__PURE__ */ new Map();
20960
21555
  /** Resolved diagnostic artifact backend, when `diagnostics.artifacts` is set. */
20961
21556
  artifactStore;
20962
21557
  artifactLeaseMs = DEFAULT_ARTIFACT_LEASE_MS;
@@ -20990,6 +21585,13 @@ var AxlRuntime = class extends EventEmitter2 {
20990
21585
  this.providerRegistry = new ProviderRegistry();
20991
21586
  this.transcriptionProviderRegistry = new TranscriptionProviderRegistry();
20992
21587
  this.stateStore = this.createStateStore();
21588
+ const recentRunsSize = resolveRecentExecutionsCacheSize(
21589
+ this.config.state?.recentExecutionsCacheSize
21590
+ );
21591
+ const storeKeepsHistory = Boolean(
21592
+ this.stateStore.saveExecution && this.stateStore.listExecutions
21593
+ );
21594
+ this.recentRuns = !storeKeepsHistory && recentRunsSize > 0 ? new RecentRunsCache(recentRunsSize) : void 0;
20993
21595
  const requested = this.config.state?.maxEventsPerExecution;
20994
21596
  if (requested === void 0) {
20995
21597
  this.maxEventsPerExecution = DEFAULT_MAX_EVENTS_PER_EXECUTION;
@@ -21395,11 +21997,15 @@ var AxlRuntime = class extends EventEmitter2 {
21395
21997
  const storeOption = this.config.state?.store ?? "memory";
21396
21998
  if (typeof storeOption !== "string") return storeOption;
21397
21999
  switch (storeOption) {
21398
- case "sqlite":
21399
- return new SQLiteStore(this.config.state?.sqlite?.path ?? "./data/axl.db");
22000
+ case "sqlite": {
22001
+ const create = () => new SQLiteStore(this.config.state?.sqlite?.path ?? "./data/axl.db", {
22002
+ ttls: this.config.state?.sqlite?.ttls
22003
+ });
22004
+ return this.config.diagnostics?.silent ? withDiagnosticsSilenced(create) : create();
22005
+ }
21400
22006
  case "memory":
21401
22007
  default:
21402
- return new MemoryStore();
22008
+ return new MemoryStore(this.config.state?.memory);
21403
22009
  }
21404
22010
  }
21405
22011
  /**
@@ -21487,13 +22093,15 @@ var AxlRuntime = class extends EventEmitter2 {
21487
22093
  }
21488
22094
  /**
21489
22095
  * Persist a completed/failed execution to the state store (fire-and-forget)
21490
- * and move it from the active executions map to the historical cache.
22096
+ * and remove it from the active executions map. Until the save settles the
22097
+ * run is served from `pendingSaves`; afterwards from the store (or, for a
22098
+ * store that cannot keep history, from `recentRuns`).
21491
22099
  *
21492
22100
  * Resurrection guard: if `runtime.deleteExecution(id)` was called while
21493
22101
  * this execution was still running, the id will be in
21494
- * `pendingDeletedExecutions`. In that case we skip the save AND the
21495
- * historical-cache write, and finalize the streaming buffer (if any) so
21496
- * the buffer doesn't outlive the delete.
22102
+ * `pendingDeletedExecutions`. In that case we skip the save AND every
22103
+ * in-process record of the run, and finalize the streaming buffer (if any)
22104
+ * so the buffer doesn't outlive the delete.
21497
22105
  */
21498
22106
  persistExecution(execInfo) {
21499
22107
  const id = execInfo.executionId;
@@ -21524,14 +22132,19 @@ var AxlRuntime = class extends EventEmitter2 {
21524
22132
  }).catch(() => {
21525
22133
  }).then(() => void 0);
21526
22134
  this.persistInflight.add(chain);
21527
- chain.finally(() => this.persistInflight.delete(chain));
22135
+ const pending = { snapshot, settled: chain };
22136
+ this.pendingSaves.set(id, pending);
22137
+ chain.finally(() => {
22138
+ this.persistInflight.delete(chain);
22139
+ if (this.pendingSaves.get(id) === pending) this.pendingSaves.delete(id);
22140
+ });
21528
22141
  } else if (this.streamingFlusher) {
21529
22142
  const chain = this.streamingFlusher.finalize(id).catch(() => {
21530
22143
  });
21531
22144
  this.persistInflight.add(chain);
21532
22145
  chain.finally(() => this.persistInflight.delete(chain));
21533
22146
  }
21534
- this.historicalExecutions.set(id, snapshot);
22147
+ this.recentRuns?.set(id, snapshot);
21535
22148
  this.executions.delete(id);
21536
22149
  }
21537
22150
  /** Register a workflow with the runtime. */
@@ -21657,7 +22270,6 @@ var AxlRuntime = class extends EventEmitter2 {
21657
22270
  }
21658
22271
  return result;
21659
22272
  }
21660
- /** Get all execution info (running + completed + historical). */
21661
22273
  /** Normalize an execution loaded from a `StateStore` so the
21662
22274
  * historical event union holds for downstream consumers
21663
22275
  * (Studio aggregators, REST routes, redaction). Custom or legacy stores
@@ -21677,27 +22289,78 @@ var AxlRuntime = class extends EventEmitter2 {
21677
22289
  }
21678
22290
  return normalizeStoredExecution(exec);
21679
22291
  }
21680
- async getExecutions() {
21681
- if (!this.historicalExecutionsLoadPromise && this.stateStore.listExecutions) {
21682
- this.historicalExecutionsLoadPromise = this.stateStore.listExecutions().then((stored) => {
21683
- for (const exec of stored) {
21684
- if (!this.executions.has(exec.executionId) && !this.historicalExecutions.has(exec.executionId)) {
21685
- this.historicalExecutions.set(exec.executionId, this.normalizeStoredExecution(exec));
21686
- }
21687
- }
21688
- }).catch((error) => {
21689
- this.historicalExecutionsLoadPromise = null;
21690
- throw error;
21691
- });
21692
- }
21693
- if (this.historicalExecutionsLoadPromise) {
21694
- await this.historicalExecutionsLoadPromise;
21695
- }
21696
- const merged = new Map(this.historicalExecutions);
21697
- for (const [id, exec] of this.executions) {
21698
- merged.set(id, exec);
22292
+ /**
22293
+ * List executions, newest first: running runs, runs whose save is still in
22294
+ * flight, and the history the state store holds. Ordered by `startedAt`,
22295
+ * then `executionId`, both descending.
22296
+ *
22297
+ * With no options, returns every retained run. Pass `limit` for one page,
22298
+ * and `cursor` (built from the last item of the previous page) for the
22299
+ * page after it. Each call reads through the store, so runs finished or
22300
+ * deleted by other runtimes sharing the store are reflected immediately.
22301
+ * A call without `limit` that returns more than 1 000 runs warns once per
22302
+ * process (silenced by `diagnostics.silent`); pass `limit: Infinity` to ask
22303
+ * for everything without the warning.
22304
+ *
22305
+ * @throws {AxlError} `INVALID_ARGUMENT` for a `limit` that is not a positive
22306
+ * integer (`Infinity` means no limit) or a malformed `cursor`.
22307
+ */
22308
+ async getExecutions(options) {
22309
+ const { limit, cursor } = parseGetExecutionsOptions(options);
22310
+ const isAfterCursor = (exec) => cursor === void 0 || compareExecutionsNewestFirst(exec, cursor) > 0;
22311
+ const live = new Map(this.executions);
22312
+ for (const [id, { snapshot }] of this.pendingSaves) {
22313
+ if (!live.has(id)) live.set(id, snapshot);
22314
+ }
22315
+ const recent = this.recentRunsServeListing() ? this.recentRuns.values() : [];
22316
+ const stored = await this.listStoredExecutions({
22317
+ limit,
22318
+ cursor,
22319
+ overlap: live.size + recent.length
22320
+ });
22321
+ const merged = /* @__PURE__ */ new Map();
22322
+ for (const [id, exec] of live) {
22323
+ if (isAfterCursor(exec)) merged.set(id, exec);
22324
+ }
22325
+ for (const exec of stored) {
22326
+ const id = exec.executionId;
22327
+ if (!live.has(id) && !merged.has(id)) merged.set(id, exec);
22328
+ }
22329
+ for (const exec of recent) {
22330
+ const id = exec.executionId;
22331
+ if (!live.has(id) && !merged.has(id) && isAfterCursor(exec)) merged.set(id, exec);
22332
+ }
22333
+ const sorted = [...merged.values()].sort(compareExecutionsNewestFirst);
22334
+ if (limit !== void 0) return sorted.slice(0, limit);
22335
+ if (options?.limit === void 0 && sorted.length > LARGE_EXECUTION_LISTING) {
22336
+ warnDiagnosticOnce(
22337
+ "large_execution_listing",
22338
+ `runtime.getExecutions() without a limit returned ${sorted.length} runs and reads the whole history on every call; pass { limit, cursor } to read it a page at a time.`,
22339
+ this.config.diagnostics?.silent,
22340
+ "docs/migration/execution-history-retention.md#page-through-large-histories"
22341
+ );
21699
22342
  }
21700
- return [...merged.values()].sort((a, b) => b.startedAt - a.startedAt);
22343
+ return sorted;
22344
+ }
22345
+ /**
22346
+ * Read one page of execution history from the store: the records strictly
22347
+ * after `cursor`, newest first, at least `limit + overlap` of them when the
22348
+ * store holds that many (all of them when `limit` is unset).
22349
+ *
22350
+ * Only a store that declares `historyPaging` is handed the cursor. Any
22351
+ * other store is listed in full and paged here: filtering the output of a
22352
+ * store that ignored the cursor would return its first page again, and an
22353
+ * empty page after that.
22354
+ */
22355
+ async listStoredExecutions(page) {
22356
+ const store = this.stateStore;
22357
+ if (!store.listExecutions) return [];
22358
+ const wanted = page.limit === void 0 ? void 0 : page.limit + page.overlap;
22359
+ const rows = store.historyPaging === true ? await store.listExecutions(wanted, { cursor: page.cursor }) : await store.listExecutions();
22360
+ const cursor = page.cursor;
22361
+ const after = cursor === void 0 ? rows : rows.filter((row) => compareExecutionsNewestFirst(row, cursor) > 0);
22362
+ const window = wanted === void 0 ? after : [...after].sort(compareExecutionsNewestFirst).slice(0, wanted);
22363
+ return window.map((row) => this.normalizeStoredExecution(row));
21701
22364
  }
21702
22365
  /**
21703
22366
  * Create a WorkflowContext for ad-hoc use (evals, tool testing, prototyping).
@@ -22128,20 +22791,25 @@ var AxlRuntime = class extends EventEmitter2 {
22128
22791
  * Recover executions whose process died mid-run when `state.persist:
22129
22792
  * 'streaming'` was configured. Synthesizes a partial `ExecutionInfo`
22130
22793
  * from the streaming buffer for each — `status: 'failed'`, error
22131
- * `'process terminated'` — registers them in the historical cache so
22132
- * they show up in `getExecutions()`, persists them via the store's
22133
- * `saveExecution`, and finalizes the streaming buffer.
22794
+ * `'process terminated'` — persists them via the store's
22795
+ * `saveExecution` (so they show up in `getExecutions()`), and finalizes
22796
+ * the streaming buffer.
22134
22797
  *
22135
22798
  * Idempotent — if the streaming buffer is already gone (or
22136
22799
  * `listStreamingExecutions` is empty), this is a no-op. Returns the
22137
22800
  * list of recovered `ExecutionInfo`s for caller introspection.
22138
22801
  *
22139
- * Wire this into your process startup AFTER `runtime.getExecutions()`
22140
- * has lazy-loaded the historical cache. Order matters: completed
22141
- * executions already in the store should not be overwritten by a
22142
- * stale streaming buffer that the previous process didn't get to
22143
- * finalize. The implementation checks the historical store before
22144
- * synthesizing, so a re-run is safe.
22802
+ * A run whose final record already exists is never recovered: for each
22803
+ * buffered id this asks the store's `getExecution(id)` and, when a record
22804
+ * is there, only drops the leftover buffer. It does not list history, so
22805
+ * its cost does not grow with the size of the store. A store without
22806
+ * `getExecution` cannot be checked, and every buffered run is recovered.
22807
+ *
22808
+ * Wire this into process startup, before the process accepts new work:
22809
+ * a run live in another process looks the same as a crashed one.
22810
+ * `deleteExecution(id)` called while recovery is working on that id waits
22811
+ * for recovery to finish with it, so the recovered record cannot outlive
22812
+ * the delete.
22145
22813
  */
22146
22814
  async recoverIncompleteStreams() {
22147
22815
  if (!this.stateStore.listStreamingExecutions || !this.stateStore.getStreamingEvents) {
@@ -22149,73 +22817,119 @@ var AxlRuntime = class extends EventEmitter2 {
22149
22817
  }
22150
22818
  const ids = await this.stateStore.listStreamingExecutions();
22151
22819
  if (ids.length === 0) return [];
22152
- await this.getExecutions();
22153
22820
  const recovered = [];
22154
22821
  for (const id of ids) {
22155
22822
  if (this.executions.has(id) || this.streamableExecutionIds.has(id)) {
22156
22823
  continue;
22157
22824
  }
22158
- const existing = this.historicalExecutions.get(id);
22159
- if (existing) {
22160
- await this.stateStore.finalizeStreamingEvents?.(id);
22161
- continue;
22162
- }
22163
- const events = await this.stateStore.getStreamingEvents(id);
22164
- if (events.length === 0) {
22165
- await this.stateStore.finalizeStreamingEvents?.(id);
22166
- continue;
22167
- }
22168
- const eventSchemaVersion = getEventSchemaVersion(events[0]);
22169
- for (const event of events) {
22170
- if (getEventSchemaVersion(event) !== eventSchemaVersion) {
22171
- throw new Error(`Streaming buffer ${id} contains mixed event schema versions`);
22172
- }
22825
+ if (this.deletingExecutions.has(id) || this.recoveringExecutions.has(id)) continue;
22826
+ let finishRecovery;
22827
+ this.recoveringExecutions.set(
22828
+ id,
22829
+ new Promise((resolve2) => {
22830
+ finishRecovery = resolve2;
22831
+ })
22832
+ );
22833
+ try {
22834
+ const result = await this.recoverStreamingExecution(id);
22835
+ if (result) recovered.push(result);
22836
+ } finally {
22837
+ this.recoveringExecutions.delete(id);
22838
+ finishRecovery();
22173
22839
  }
22174
- const boundedEvents = boundRecoveredEvents(events, this.maxEventsPerExecution);
22175
- const workflowStartEvent = boundedEvents.find((e) => e.type === "workflow_start");
22176
- const lastEvent = boundedEvents[boundedEvents.length - 1];
22177
- const startedAt = workflowStartEvent?.timestamp ?? boundedEvents[0]?.timestamp ?? Date.now();
22178
- const completedAt = lastEvent.timestamp ?? startedAt;
22179
- const workflowName = workflowStartEvent?.workflow ?? RECOVERED_UNKNOWN_WORKFLOW;
22180
- const totalCost = boundedEvents.reduce((sum, e) => sum + eventCostContribution(e), 0);
22181
- const unpriced = boundedEvents.some(isUnpricedLeaf);
22182
- const executionBase = {
22183
- executionId: id,
22184
- workflow: workflowName,
22185
- status: "failed",
22186
- events: boundedEvents,
22187
- totalCost,
22188
- unpriced,
22189
- startedAt,
22190
- completedAt,
22191
- duration: completedAt - startedAt,
22192
- error: "process terminated (recovered from streaming buffer)"
22193
- };
22194
- const synthesized = eventSchemaVersion === 2 ? {
22195
- ...executionBase,
22196
- eventSchemaVersion: 2,
22197
- events: boundedEvents,
22198
- observation: { complete: false, reason: "process_interrupted" }
22199
- } : {
22200
- ...executionBase,
22201
- eventSchemaVersion: 1,
22202
- events: boundedEvents
22840
+ }
22841
+ return recovered;
22842
+ }
22843
+ /** Recover one buffered execution. Returns the synthesized record, or
22844
+ * `undefined` when there was nothing to recover. */
22845
+ async recoverStreamingExecution(id) {
22846
+ if (!this.stateStore.getStreamingEvents) return void 0;
22847
+ const existing = await this.stateStore.getExecution?.(id);
22848
+ if (existing) {
22849
+ await this.stateStore.finalizeStreamingEvents?.(id);
22850
+ return void 0;
22851
+ }
22852
+ const events = await this.stateStore.getStreamingEvents(id);
22853
+ if (events.length === 0) {
22854
+ await this.stateStore.finalizeStreamingEvents?.(id);
22855
+ return void 0;
22856
+ }
22857
+ const eventSchemaVersion = getEventSchemaVersion(events[0]);
22858
+ for (const event of events) {
22859
+ if (getEventSchemaVersion(event) !== eventSchemaVersion) {
22860
+ throw new Error(`Streaming buffer ${id} contains mixed event schema versions`);
22861
+ }
22862
+ }
22863
+ const boundedEvents = boundRecoveredEvents(events, this.maxEventsPerExecution);
22864
+ const workflowStartEvent = boundedEvents.find((e) => e.type === "workflow_start");
22865
+ const lastEvent = boundedEvents[boundedEvents.length - 1];
22866
+ const startedAt = workflowStartEvent?.timestamp ?? boundedEvents[0]?.timestamp ?? Date.now();
22867
+ const completedAt = lastEvent.timestamp ?? startedAt;
22868
+ const workflowName = workflowStartEvent?.workflow ?? RECOVERED_UNKNOWN_WORKFLOW;
22869
+ const totalCost = boundedEvents.reduce((sum, e) => sum + eventCostContribution(e), 0);
22870
+ const unpriced = boundedEvents.some(isUnpricedLeaf);
22871
+ const executionBase = {
22872
+ executionId: id,
22873
+ workflow: workflowName,
22874
+ status: "failed",
22875
+ events: boundedEvents,
22876
+ totalCost,
22877
+ unpriced,
22878
+ startedAt,
22879
+ completedAt,
22880
+ duration: completedAt - startedAt,
22881
+ error: "process terminated (recovered from streaming buffer)"
22882
+ };
22883
+ const synthesized = eventSchemaVersion === 2 ? {
22884
+ ...executionBase,
22885
+ eventSchemaVersion: 2,
22886
+ events: boundedEvents,
22887
+ observation: { complete: false, reason: "process_interrupted" }
22888
+ } : {
22889
+ ...executionBase,
22890
+ eventSchemaVersion: 1,
22891
+ events: boundedEvents
22892
+ };
22893
+ const saveExecution = this.stateStore.saveExecution;
22894
+ if (saveExecution) {
22895
+ const save = (async () => saveExecution.call(this.stateStore, synthesized))();
22896
+ const pending = {
22897
+ snapshot: synthesized,
22898
+ settled: save.then(
22899
+ () => void 0,
22900
+ () => void 0
22901
+ )
22203
22902
  };
22204
- if (this.stateStore.saveExecution) {
22205
- try {
22206
- await this.stateStore.saveExecution(synthesized);
22207
- } catch (err) {
22208
- console.error(
22209
- `[axl] recoverIncompleteStreams: failed to save synthesized execution ${id}: ${err instanceof Error ? err.message : String(err)}. Streaming buffer left in place for next recovery attempt.`
22210
- );
22211
- continue;
22212
- }
22903
+ this.pendingSaves.set(id, pending);
22904
+ this.persistInflight.add(pending.settled);
22905
+ try {
22906
+ await save;
22907
+ } catch (err) {
22908
+ console.error(
22909
+ `[axl] recoverIncompleteStreams: failed to save synthesized execution ${id}: ${err instanceof Error ? err.message : String(err)}. Streaming buffer left in place for next recovery attempt.`
22910
+ );
22911
+ return void 0;
22912
+ } finally {
22913
+ this.persistInflight.delete(pending.settled);
22914
+ if (this.pendingSaves.get(id) === pending) this.pendingSaves.delete(id);
22213
22915
  }
22214
- this.historicalExecutions.set(id, synthesized);
22215
- await this.stateStore.finalizeStreamingEvents?.(id);
22216
- recovered.push(synthesized);
22217
22916
  }
22218
- return recovered;
22917
+ this.recentRuns?.set(id, synthesized);
22918
+ const finalize = Promise.resolve(this.stateStore.finalizeStreamingEvents?.(id)).then(
22919
+ () => void 0,
22920
+ (err) => {
22921
+ console.error(
22922
+ `[axl] recoverIncompleteStreams: saved execution ${id} but failed to drop its streaming buffer: ${err instanceof Error ? err.message : String(err)}. The next recovery removes it.`
22923
+ );
22924
+ }
22925
+ );
22926
+ this.persistInflight.add(finalize);
22927
+ try {
22928
+ await finalize;
22929
+ } finally {
22930
+ this.persistInflight.delete(finalize);
22931
+ }
22932
+ return synthesized;
22219
22933
  }
22220
22934
  /** Abort a running execution by its ID. */
22221
22935
  abort(executionId) {
@@ -22225,21 +22939,57 @@ var AxlRuntime = class extends EventEmitter2 {
22225
22939
  this.abortControllers.delete(executionId);
22226
22940
  }
22227
22941
  }
22228
- /** Get execution details by ID. */
22942
+ /**
22943
+ * Get execution details by ID. Looks in order at running runs, runs whose
22944
+ * save is still in flight, the state store (its `getExecution`, or a scan
22945
+ * of its listing when it has `listExecutions` but no `getExecution`), and
22946
+ * (only for a store that cannot list or save history) the recent-runs
22947
+ * cache. Nothing read from the store is cached, so a run deleted or
22948
+ * expired elsewhere is gone on the next call, and every run
22949
+ * `getExecutions()` lists can be fetched here.
22950
+ */
22229
22951
  async getExecution(executionId) {
22230
- const inMemory = this.executions.get(executionId);
22231
- if (inMemory) return inMemory;
22232
- const cached = this.historicalExecutions.get(executionId);
22233
- if (cached) return cached;
22234
- if (this.stateStore.getExecution) {
22235
- const stored = await this.stateStore.getExecution(executionId);
22236
- if (stored) {
22237
- const normalized = this.normalizeStoredExecution(stored);
22238
- this.historicalExecutions.set(executionId, normalized);
22239
- return normalized;
22240
- }
22952
+ const active = this.executions.get(executionId);
22953
+ if (active) return active;
22954
+ const pending = this.pendingSaves.get(executionId);
22955
+ if (pending) return pending.snapshot;
22956
+ const stored = this.stateStore.getExecution ? await this.stateStore.getExecution(executionId) : await this.findInStoreListing(executionId);
22957
+ if (stored) return this.normalizeStoredExecution(stored);
22958
+ return this.recentRunsServeReads() ? this.recentRuns.get(executionId) : void 0;
22959
+ }
22960
+ /**
22961
+ * By-id read for a store that lists but cannot fetch by id. A paging store
22962
+ * is scanned page by page with the cursor, stopping at the match; any
22963
+ * other store is listed once in full. Returns `null` when the store cannot
22964
+ * list or does not hold the run.
22965
+ */
22966
+ async findInStoreListing(executionId) {
22967
+ const store = this.stateStore;
22968
+ if (!store.listExecutions) return null;
22969
+ if (store.historyPaging !== true) {
22970
+ return (await store.listExecutions()).find((e) => e.executionId === executionId) ?? null;
22241
22971
  }
22242
- return void 0;
22972
+ let cursor;
22973
+ for (; ; ) {
22974
+ const page = await store.listExecutions(EXECUTION_SCAN_PAGE_SIZE, { cursor });
22975
+ const found = page.find((e) => e.executionId === executionId);
22976
+ if (found) return found;
22977
+ if (page.length === 0) return null;
22978
+ const last = [...page].sort(compareExecutionsNewestFirst)[page.length - 1];
22979
+ if (cursor && compareExecutionsNewestFirst(last, cursor) <= 0) return null;
22980
+ cursor = { startedAt: last.startedAt, executionId: last.executionId };
22981
+ }
22982
+ }
22983
+ /** The recent-runs cache stands in for listing when the store cannot list
22984
+ * runs, or never receives them because it cannot save. */
22985
+ recentRunsServeListing() {
22986
+ return this.recentRuns !== void 0 && (!this.stateStore.saveExecution || !this.stateStore.listExecutions);
22987
+ }
22988
+ /** The recent-runs cache stands in for by-id reads when the store cannot
22989
+ * fetch or list runs (it exists only then), or never receives them because
22990
+ * it cannot save. */
22991
+ recentRunsServeReads() {
22992
+ return this.recentRuns !== void 0 && (!this.stateStore.saveExecution || !this.stateStore.getExecution);
22243
22993
  }
22244
22994
  /** Save an eval result to history. */
22245
22995
  async saveEvalResult(entry) {
@@ -22272,11 +23022,16 @@ var AxlRuntime = class extends EventEmitter2 {
22272
23022
  }
22273
23023
  /**
22274
23024
  * Delete an execution from history by id. Removes from the active map (if
22275
- * still running), the historical cache, the configured StateStore, AND
23025
+ * still running), the recent-runs cache, the configured StateStore, AND
22276
23026
  * any execution-scoped side state — pending awaitHuman decisions,
22277
23027
  * resolver maps, streaming buffer registration, in-flight abort controller.
22278
- * Returns true if an entry was actually removed from any of the three
22279
- * primary surfaces (active map, historical cache, store).
23028
+ * Returns true if the run was running, had a save in flight, or was
23029
+ * removed from the recent-runs cache or the store.
23030
+ *
23031
+ * If the run has already finished but its history save has not landed
23032
+ * yet, this method waits for that save (whether it succeeds or fails)
23033
+ * before deleting, so the save cannot re-create the deleted record. It
23034
+ * likewise waits for `recoverIncompleteStreams()` to finish with the id.
22280
23035
  *
22281
23036
  * In-flight handling: if the execution is still running, this method
22282
23037
  * **aborts** it (via the registered abort controller) AND marks the id for
@@ -22301,7 +23056,18 @@ var AxlRuntime = class extends EventEmitter2 {
22301
23056
  * (`defaultTtl` / `ttls.executionHistory`).
22302
23057
  */
22303
23058
  async deleteExecution(id) {
23059
+ this.deletingExecutions.set(id, (this.deletingExecutions.get(id) ?? 0) + 1);
23060
+ try {
23061
+ return await this.deleteExecutionWhileMarked(id);
23062
+ } finally {
23063
+ const remaining = (this.deletingExecutions.get(id) ?? 1) - 1;
23064
+ if (remaining > 0) this.deletingExecutions.set(id, remaining);
23065
+ else this.deletingExecutions.delete(id);
23066
+ }
23067
+ }
23068
+ async deleteExecutionWhileMarked(id) {
22304
23069
  const wasActive = this.executions.has(id);
23070
+ const hadPendingSave = this.pendingSaves.has(id);
22305
23071
  const pendingDecisionResolver = this.pendingDecisionResolvers.get(id);
22306
23072
  const hadPendingDecision = pendingDecisionResolver !== void 0;
22307
23073
  if (wasActive) {
@@ -22319,22 +23085,25 @@ var AxlRuntime = class extends EventEmitter2 {
22319
23085
  this.pendingDecisionResolvers.delete(id);
22320
23086
  }
22321
23087
  }
23088
+ await this.pendingSaves.get(id)?.settled;
23089
+ await this.recoveringExecutions.get(id);
22322
23090
  const existing = await this.getExecution(id);
22323
23091
  const workflow2 = existing?.workflow;
22324
23092
  const removedFromActive = this.executions.delete(id);
22325
- const removedFromHistorical = this.historicalExecutions.delete(id);
23093
+ const removedFromRecent = this.recentRuns?.delete(id) ?? false;
22326
23094
  let removedFromStore = false;
22327
23095
  if (this.stateStore.deleteExecution) {
22328
23096
  removedFromStore = await this.stateStore.deleteExecution(id);
22329
23097
  }
23098
+ const removed = removedFromActive || hadPendingSave || removedFromRecent || removedFromStore;
22330
23099
  this.emit("execution_deleted", {
22331
23100
  executionId: id,
22332
23101
  workflow: workflow2,
22333
23102
  wasActive,
22334
23103
  hadPendingDecision,
22335
- removed: removedFromActive || removedFromHistorical || removedFromStore
23104
+ removed
22336
23105
  });
22337
- return removedFromActive || removedFromHistorical || removedFromStore;
23106
+ return removed;
22338
23107
  }
22339
23108
  /**
22340
23109
  * Delete an eval history entry by id. Removes from in-memory cache and
@@ -23752,12 +24521,57 @@ async function* stringStreamFromEvents(source, opts) {
23752
24521
  }
23753
24522
 
23754
24523
  // src/state/redis.ts
24524
+ var PRUNE_HISTORY_INDEX_SCRIPT = `
24525
+ local now = tonumber(ARGV[1])
24526
+ local removed = 0
24527
+ for i = 2, #ARGV do
24528
+ local id = ARGV[i]
24529
+ local pttl = redis.call('PTTL', KEYS[i + 2])
24530
+ if pttl == -2 then
24531
+ redis.call('SREM', KEYS[1], id)
24532
+ redis.call('ZREM', KEYS[2], id)
24533
+ redis.call('ZREM', KEYS[3], id)
24534
+ removed = removed + 1
24535
+ elseif pttl == -1 then
24536
+ redis.call('ZREM', KEYS[3], id)
24537
+ else
24538
+ redis.call('ZADD', KEYS[3], string.format('%d', now + pttl), id)
24539
+ end
24540
+ end
24541
+ return removed
24542
+ `;
24543
+ function compareMembers(a, b) {
24544
+ return Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
24545
+ }
24546
+ function compareNewestFirst(a, b) {
24547
+ if (a.score !== b.score) return b.score - a.score;
24548
+ return compareMembers(b.id, a.id);
24549
+ }
24550
+ function warnIfRedisExecutionRetentionUnset(opts) {
24551
+ if (opts.ttls?.executionHistory === void 0 && typeof opts.defaultTtl !== "number") {
24552
+ warnExecutionRetentionUnset("RedisStore");
24553
+ }
24554
+ }
23755
24555
  var RedisStore = class _RedisStore {
23756
24556
  constructor(client, keyPrefix, ttls) {
23757
24557
  this.client = client;
23758
24558
  this.keyPrefix = keyPrefix;
23759
24559
  this.ttls = ttls;
23760
24560
  }
24561
+ /**
24562
+ * History kinds with a prune running in this process. A save skips its
24563
+ * prune while another one of the same kind is in flight: the running one
24564
+ * already covers the oldest expired entries. Created lazily so the
24565
+ * instance needs no constructor state.
24566
+ */
24567
+ prunesInFlight;
24568
+ /**
24569
+ * Supports `listExecutions(limit, { cursor })`. A getter rather than a
24570
+ * field so it lives on the prototype.
24571
+ */
24572
+ get historyPaging() {
24573
+ return true;
24574
+ }
23761
24575
  /**
23762
24576
  * Resolve the TTL for a given storage category. Returns `undefined` when
23763
24577
  * no TTL applies (category is `null` in resolved config, the value is
@@ -23836,6 +24650,7 @@ var RedisStore = class _RedisStore {
23836
24650
  streamingEvents: resolveStreamingEvents(opts.ttls?.streamingEvents)
23837
24651
  };
23838
24652
  const store = new _RedisStore(client, keyPrefix, effectiveTtls);
24653
+ warnIfRedisExecutionRetentionUnset(opts);
23839
24654
  if (!opts.skipMigration) {
23840
24655
  await store.backfillExecutionIndex();
23841
24656
  await store.backfillEvalIndex();
@@ -23947,13 +24762,9 @@ var RedisStore = class _RedisStore {
23947
24762
  execHistoryKey(executionId) {
23948
24763
  return `${this.keyPrefix}exec-history:${executionId}`;
23949
24764
  }
23950
- // NOTE for future implementer of #3 (TTL config): when the executionHistory
23951
- // and evalHistory data blobs gain TTLs, eviction MUST be driven by
23952
- // ZREMRANGEBYSCORE on the ZSET below — NOT by SREM on these legacy ID sets.
23953
- // The slow-path fallback (`listHistoryByZset`) reads from the SET when
23954
- // `zsetCard < setCard`, so SET-based eviction would race with the fallback
23955
- // and surface dead IDs whose data has been TTL'd away. ZSET eviction +
23956
- // dual-write `SREM` is the clean path; doc the assumption then.
24765
+ // The legacy id set and the sorted set below always change together (save,
24766
+ // delete, prune), so the slow-path guard `ZCARD >= SCARD` in
24767
+ // `listHistoryByZset` and backfill stays meaningful.
23957
24768
  execHistorySetKey() {
23958
24769
  return `${this.keyPrefix}exec-history-ids`;
23959
24770
  }
@@ -23976,6 +24787,36 @@ var RedisStore = class _RedisStore {
23976
24787
  evalHistoryZsetKey() {
23977
24788
  return `${this.keyPrefix}eval-history-z`;
23978
24789
  }
24790
+ /** Keys and blob parsing for one kind of history. See {@link HistoryIndex}. */
24791
+ historyIndex(kind) {
24792
+ if (kind === "execution") {
24793
+ return {
24794
+ kind,
24795
+ label: "execution-history",
24796
+ ids: this.execHistorySetKey(),
24797
+ time: this.execHistoryZsetKey(),
24798
+ expiry: `${this.keyPrefix}exec-history-exp`,
24799
+ dataKey: (id) => this.execHistoryKey(id),
24800
+ lastSavedAtOf: (raw) => {
24801
+ const record = JSON.parse(raw);
24802
+ return typeof record.startedAt === "number" ? executionLastSavedAt(record) : null;
24803
+ }
24804
+ };
24805
+ }
24806
+ return {
24807
+ kind,
24808
+ label: "eval-history",
24809
+ ids: this.evalHistorySetKey(),
24810
+ time: this.evalHistoryZsetKey(),
24811
+ expiry: `${this.keyPrefix}eval-history-exp`,
24812
+ dataKey: (id) => this.evalHistoryKey(id),
24813
+ // Eval retention counts from the first write, which is the row's timestamp.
24814
+ lastSavedAtOf: (raw) => {
24815
+ const timestamp = JSON.parse(raw).timestamp;
24816
+ return typeof timestamp === "number" ? timestamp : null;
24817
+ }
24818
+ };
24819
+ }
23979
24820
  memoryKey(scope) {
23980
24821
  return `${this.keyPrefix}memory:${scope}`;
23981
24822
  }
@@ -24147,31 +24988,35 @@ var RedisStore = class _RedisStore {
24147
24988
  async saveExecution(execution) {
24148
24989
  const ttl = this.ttlFor("executionHistory");
24149
24990
  const setOptions = ttl !== void 0 ? { EX: ttl } : void 0;
24991
+ const index = this.historyIndex("execution");
24992
+ const id = execution.executionId;
24150
24993
  const storedExecution = normalizeStoredExecution({
24151
24994
  ...execution,
24152
24995
  eventSchemaVersion: getExecutionEventSchemaVersion(execution)
24153
24996
  });
24154
- await this.client.multi().sAdd(this.execHistorySetKey(), execution.executionId).set(this.execHistoryKey(execution.executionId), JSON.stringify(storedExecution), setOptions).zAdd(this.execHistoryZsetKey(), {
24155
- score: execution.startedAt,
24156
- value: execution.executionId
24157
- }).exec();
24997
+ const tx = this.client.multi().sAdd(index.ids, id).set(index.dataKey(id), JSON.stringify(storedExecution), setOptions).zAdd(index.time, { score: execution.startedAt, value: id });
24998
+ if (ttl !== void 0) tx.zAdd(index.expiry, { score: Date.now() + ttl * 1e3, value: id });
24999
+ else tx.zRem(index.expiry, id);
25000
+ await tx.exec();
25001
+ await this.pruneHistoryAfterSave(index);
24158
25002
  }
24159
25003
  async getExecution(executionId) {
24160
25004
  const raw = await this.client.get(this.execHistoryKey(executionId));
24161
25005
  return raw ? normalizeStoredExecution(JSON.parse(raw)) : null;
24162
25006
  }
24163
- async listExecutions(limit) {
25007
+ async listExecutions(limit, options) {
25008
+ const cursor = options?.cursor;
24164
25009
  const executions = await this.listHistoryByZset(
24165
- this.execHistorySetKey(),
24166
- this.execHistoryZsetKey(),
24167
- (id) => this.execHistoryKey(id),
24168
- (a, b) => b.startedAt - a.startedAt,
24169
- limit
25010
+ this.historyIndex("execution"),
25011
+ (e) => ({ score: e.startedAt, id: e.executionId }),
25012
+ limit,
25013
+ cursor !== void 0 ? { score: cursor.startedAt, id: cursor.executionId } : void 0
24170
25014
  );
24171
25015
  return executions.map(normalizeStoredExecution);
24172
25016
  }
24173
25017
  async deleteExecution(executionId) {
24174
- const results = 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();
25018
+ const index = this.historyIndex("execution");
25019
+ const results = await this.client.multi().sRem(index.ids, executionId).del(index.dataKey(executionId)).zRem(index.time, executionId).zRem(index.expiry, 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();
24175
25020
  return results.some((result) => typeof result === "number" && result > 0);
24176
25021
  }
24177
25022
  // ── Eval History ────────────────────────────────────────────────────
@@ -24196,18 +25041,31 @@ var RedisStore = class _RedisStore {
24196
25041
  * lifetime, so the race costs at most one window's length, never the window
24197
25042
  * itself. It also carries no Redis version floor, where `KEEPTTL` needs 6.0.
24198
25043
  *
24199
- * The index entries carry no TTL and age out lazily, as with `saveExecution`.
25044
+ * As with `saveExecution`, a timed row is also scored in the expiry sorted
25045
+ * set (by the expiry it carries, so a re-save keeps its score), an untimed
25046
+ * one is taken out of it, and a bounded prune follows the save.
24200
25047
  */
24201
25048
  async saveEvalResult(entry) {
24202
25049
  const ttl = this.ttlFor("evalHistory");
24203
- const key = this.evalHistoryKey(entry.id);
25050
+ const index = this.historyIndex("eval");
25051
+ const key = index.dataKey(entry.id);
24204
25052
  let setOptions;
25053
+ let expiresAt;
24205
25054
  if (ttl !== void 0) {
24206
25055
  const remaining = await this.client.pTTL(key);
24207
- if (remaining === -2) setOptions = { EX: ttl };
24208
- else if (remaining > 0) setOptions = { PX: remaining };
25056
+ if (remaining === -2) {
25057
+ setOptions = { EX: ttl };
25058
+ expiresAt = Date.now() + ttl * 1e3;
25059
+ } else if (remaining > 0) {
25060
+ setOptions = { PX: remaining };
25061
+ expiresAt = Date.now() + remaining;
25062
+ }
24209
25063
  }
24210
- await this.client.multi().sAdd(this.evalHistorySetKey(), entry.id).set(key, JSON.stringify(entry), setOptions).zAdd(this.evalHistoryZsetKey(), { score: entry.timestamp, value: entry.id }).exec();
25064
+ const tx = this.client.multi().sAdd(index.ids, entry.id).set(key, JSON.stringify(entry), setOptions).zAdd(index.time, { score: entry.timestamp, value: entry.id });
25065
+ if (expiresAt !== void 0) tx.zAdd(index.expiry, { score: expiresAt, value: entry.id });
25066
+ else tx.zRem(index.expiry, entry.id);
25067
+ await tx.exec();
25068
+ await this.pruneHistoryAfterSave(index);
24211
25069
  }
24212
25070
  /**
24213
25071
  * Update-only write: see `StateStore.updateEvalResult`.
@@ -24247,15 +25105,14 @@ var RedisStore = class _RedisStore {
24247
25105
  }
24248
25106
  async listEvalResults(limit) {
24249
25107
  return this.listHistoryByZset(
24250
- this.evalHistorySetKey(),
24251
- this.evalHistoryZsetKey(),
24252
- (id) => this.evalHistoryKey(id),
24253
- (a, b) => b.timestamp - a.timestamp,
25108
+ this.historyIndex("eval"),
25109
+ (e) => ({ score: e.timestamp, id: e.id }),
24254
25110
  limit
24255
25111
  );
24256
25112
  }
24257
25113
  async deleteEvalResult(id) {
24258
- const [, deletedCount] = await this.client.multi().sRem(this.evalHistorySetKey(), id).del(this.evalHistoryKey(id)).zRem(this.evalHistoryZsetKey(), id).exec();
25114
+ const index = this.historyIndex("eval");
25115
+ const [, deletedCount] = await this.client.multi().sRem(index.ids, id).del(index.dataKey(id)).zRem(index.time, id).zRem(index.expiry, id).exec();
24259
25116
  const deleted = typeof deletedCount === "number" ? deletedCount : 0;
24260
25117
  return deleted > 0;
24261
25118
  }
@@ -24279,50 +25136,291 @@ var RedisStore = class _RedisStore {
24279
25136
  return { exists: true, expiresAt: Date.now() + ttl };
24280
25137
  }
24281
25138
  /**
24282
- * Shared list-by-sorted-set implementation. Uses ZREVRANGEBYSCORE +
24283
- * MGET when the ZSET is populated (post-migration). Falls back to the
24284
- * legacy SET + N×GET + JS-sort path when the ZSET is empty or smaller
24285
- * than the SET (covers users that constructed with `skipMigration: true`
24286
- * before calling backfill).
25139
+ * Shared list-by-sorted-set implementation, newest first by
25140
+ * `(score DESC, member DESC)`, starting strictly after `cursor` when given.
25141
+ *
25142
+ * Fast path (the time sorted set is at least as large as the id set): walk
25143
+ * the sorted set in chunks and MGET each chunk's blobs. Index entries whose
25144
+ * blob is gone (expired, or deleted before the prune caught up) are skipped,
25145
+ * and the walk keeps reading until it has `limit` live records or the set
25146
+ * runs out, however many dead entries are in the way. Each chunk restarts
25147
+ * at the last member read, by score and member, rather than at an offset,
25148
+ * so entries added or removed meanwhile cannot shift the walk.
25149
+ *
25150
+ * Slow path (the time sorted set is behind the id set: backfill has not
25151
+ * run): read every id with GET, then sort and apply the cursor in process.
24287
25152
  */
24288
- async listHistoryByZset(setKey, zsetKey, dataKey, sortCmp, limit) {
24289
- const setSize = await this.client.sCard(setKey);
25153
+ async listHistoryByZset(index, positionOf, limit, cursor) {
25154
+ const setSize = await this.client.sCard(index.ids);
24290
25155
  if (setSize === 0) return [];
24291
- const zsetSize = await this.client.zCard(zsetKey);
24292
- if (zsetSize >= setSize) {
24293
- const fetchCount = limit !== void 0 ? Math.max(limit * 2, limit + 5) : void 0;
24294
- const ids2 = await this.client.zRange(zsetKey, "+inf", "-inf", {
25156
+ const zsetSize = await this.client.zCard(index.time);
25157
+ const max = limit ? limit : void 0;
25158
+ if (zsetSize < setSize) {
25159
+ const ids = await this.client.sMembers(index.ids);
25160
+ const entries = [];
25161
+ for (const id of ids) {
25162
+ const raw = await this.client.get(index.dataKey(id));
25163
+ if (raw == null) continue;
25164
+ try {
25165
+ entries.push(JSON.parse(raw));
25166
+ } catch {
25167
+ }
25168
+ }
25169
+ const ordered = entries.filter((e) => cursor === void 0 || compareNewestFirst(cursor, positionOf(e)) < 0).sort((a, b) => compareNewestFirst(positionOf(a), positionOf(b)));
25170
+ return max !== void 0 ? ordered.slice(0, max) : ordered;
25171
+ }
25172
+ const results = [];
25173
+ const seen = /* @__PURE__ */ new Set();
25174
+ let bound = cursor;
25175
+ let rangeMax = cursor !== void 0 ? cursor.score : "+inf";
25176
+ let anchor;
25177
+ let grow = 1;
25178
+ const read = (offset, count2) => (
25179
+ // ZRANGE BY SCORE REV: start is the highest score, stop the lowest.
25180
+ this.client.zRangeWithScores(index.time, rangeMax, "-inf", {
24295
25181
  BY: "SCORE",
24296
25182
  REV: true,
24297
- ...fetchCount !== void 0 ? { LIMIT: { offset: 0, count: fetchCount } } : {}
25183
+ ...count2 !== void 0 ? { LIMIT: { offset, count: count2 } } : {}
25184
+ })
25185
+ );
25186
+ for (; ; ) {
25187
+ const remaining = max !== void 0 ? max - results.length : void 0;
25188
+ const want = remaining !== void 0 ? Math.max(remaining * 2, remaining + 5) * grow : void 0;
25189
+ let offset = 0;
25190
+ let count2 = want;
25191
+ let members;
25192
+ if (anchor !== void 0 && want !== void 0) {
25193
+ offset = anchor.pos;
25194
+ count2 = want + 1;
25195
+ members = await read(offset, count2);
25196
+ if (members[0]?.value !== anchor.id) {
25197
+ offset = 0;
25198
+ count2 = anchor.pos + 1 + want;
25199
+ members = await read(offset, count2);
25200
+ }
25201
+ } else {
25202
+ members = await read(offset, count2);
25203
+ }
25204
+ const fresh = members.filter(
25205
+ (m) => !seen.has(m.value) && (bound === void 0 || compareNewestFirst(bound, { score: m.score, id: m.value }) < 0)
25206
+ );
25207
+ if (fresh.length > 0) {
25208
+ const raws = await this.client.mGet(fresh.map((m) => index.dataKey(m.value)));
25209
+ for (let i = 0; i < fresh.length; i++) {
25210
+ seen.add(fresh[i].value);
25211
+ const raw = raws[i];
25212
+ if (raw == null) continue;
25213
+ try {
25214
+ results.push(JSON.parse(raw));
25215
+ } catch {
25216
+ continue;
25217
+ }
25218
+ if (max !== void 0 && results.length >= max) return results;
25219
+ }
25220
+ const lastFresh = fresh[fresh.length - 1];
25221
+ bound = { score: lastFresh.score, id: lastFresh.value };
25222
+ grow = 1;
25223
+ } else {
25224
+ grow *= 2;
25225
+ }
25226
+ if (count2 === void 0 || members.length < count2) return results;
25227
+ const last = members[members.length - 1];
25228
+ if (last.score === rangeMax) {
25229
+ anchor = { id: last.value, score: last.score, pos: offset + members.length - 1 };
25230
+ } else {
25231
+ rangeMax = last.score;
25232
+ const before = members.filter((m) => m.score === last.score).length - 1;
25233
+ anchor = { id: last.value, score: last.score, pos: before };
25234
+ }
25235
+ }
25236
+ }
25237
+ // ── History index cleanup ──────────────────────────────────────────────
25238
+ //
25239
+ // Index members cannot carry a TTL, so when a history record expires its
25240
+ // entries in the id set and the time sorted set stay behind. Every history
25241
+ // save runs one bounded prune of its kind: it reads up to PRUNE_BATCH ids
25242
+ // whose expiry has passed from the expiry sorted set, then runs
25243
+ // PRUNE_HISTORY_INDEX_SCRIPT over them. The script checks each record's blob
25244
+ // and updates the indexes in one atomic step, so a save or delete landing
25245
+ // between the read and the script cannot be undone by it: a record saved
25246
+ // again is seen live and stays listed, and a deleted one is already gone.
25247
+ // The script only removes index entries and re-scores expiry entries; it
25248
+ // never adds a listing entry or writes a blob, so it cannot bring back
25249
+ // deleted data. Several instances pruning at once is safe.
25250
+ /**
25251
+ * Run one prune of `index`'s kind after a save. A prune failure does not
25252
+ * fail the save, whose MULTI already committed the record: it is reported
25253
+ * once per process and the next save of that kind tries again. That also
25254
+ * covers a Redis user whose ACL denies `EVAL`.
25255
+ */
25256
+ async pruneHistoryAfterSave(index) {
25257
+ const inFlight = this.prunesInFlight ??= /* @__PURE__ */ new Set();
25258
+ if (inFlight.has(index.kind)) return;
25259
+ inFlight.add(index.kind);
25260
+ try {
25261
+ await this.pruneHistory(index);
25262
+ } catch (err) {
25263
+ warnDiagnosticOnce(
25264
+ `redis_history_prune_failed\0${index.kind}`,
25265
+ `RedisStore: removing expired ${index.label} index entries failed (${err instanceof Error ? err.message : String(err)}). The record was saved; the next save retries the cleanup. Listings stay correct meanwhile. The cleanup runs a Lua script, so the Redis user needs permission to run EVAL.`,
25266
+ void 0,
25267
+ "docs/api-reference.md#redisstore-options"
25268
+ );
25269
+ } finally {
25270
+ inFlight.delete(index.kind);
25271
+ }
25272
+ }
25273
+ async pruneHistory(index) {
25274
+ const candidates = await this.client.zRange(index.expiry, "-inf", Date.now(), {
25275
+ BY: "SCORE",
25276
+ LIMIT: { offset: 0, count: PRUNE_BATCH }
25277
+ });
25278
+ if (candidates.length > 0) await this.runPruneScript(index, candidates);
25279
+ }
25280
+ /**
25281
+ * Run {@link PRUNE_HISTORY_INDEX_SCRIPT} over `ids`, at most PRUNE_BATCH
25282
+ * per call so one script never blocks the server for long.
25283
+ */
25284
+ async runPruneScript(index, ids) {
25285
+ for (let start = 0; start < ids.length; start += PRUNE_BATCH) {
25286
+ const chunk = ids.slice(start, start + PRUNE_BATCH);
25287
+ await this.client.eval(PRUNE_HISTORY_INDEX_SCRIPT, {
25288
+ keys: [index.ids, index.time, index.expiry, ...chunk.map((id) => index.dataKey(id))],
25289
+ arguments: [String(Date.now()), ...chunk]
24298
25290
  });
24299
- if (ids2.length === 0) return [];
24300
- const keys = ids2.map(dataKey);
24301
- const raws = await this.client.mGet(keys);
24302
- const entries2 = [];
24303
- for (const raw of raws) {
24304
- if (raw == null) continue;
25291
+ }
25292
+ }
25293
+ // ── History maintenance ────────────────────────────────────────────────
25294
+ /**
25295
+ * See `StateStore.applyHistoryRetention`. One call:
25296
+ *
25297
+ * 1. On the first call of a walk (no cursor), backfills the time sorted
25298
+ * set from the id set if it is behind (see `backfillExecutionIndex`).
25299
+ * 2. Reads the next `batchSize` ids from the time sorted set, oldest first
25300
+ * by `(score, member)`, strictly after the cursor. The read restarts at
25301
+ * the cursor's position by value, never by offset.
25302
+ * 3. MGETs their blobs and reads each live blob's PTTL.
25303
+ * 4. In one MULTI, for each live blob:
25304
+ * - a TTL (`PTTL > 0`): `ZADD NX` to the expiry set at now + PTTL. A
25305
+ * record already in the expiry set keeps its entry (`indexed` counts
25306
+ * only new entries).
25307
+ * - no TTL (`-1`), with a retention configured: `PEXPIREAT … NX` at last
25308
+ * save + retention. `NX` applies only to a key that still has no TTL,
25309
+ * so a record re-saved meanwhile keeps the fresh window its save gave
25310
+ * it. A window that has already passed uses a timestamp in the past,
25311
+ * which makes Redis delete the key, again only if it is still untimed.
25312
+ * 5. Adds each newly armed record to the expiry set (`ZADD NX`).
25313
+ * 6. Runs the prune script (`PRUNE_HISTORY_INDEX_SCRIPT`) over ids whose
25314
+ * blob is gone and ids just deleted in step 4. It checks each blob
25315
+ * atomically, so a record re-saved at any point stays listed and a
25316
+ * deleted one is never re-indexed.
25317
+ *
25318
+ * Blobs are never written. A record deleted while the call runs can leave
25319
+ * an entry in the expiry set only; the next prune removes it.
25320
+ */
25321
+ async applyHistoryRetention(options) {
25322
+ const { kind, batchSize, cursor } = resolveHistoryRetentionOptions("RedisStore", options);
25323
+ const index = this.historyIndex(kind === "executions" ? "execution" : "eval");
25324
+ const ttl = this.ttlFor(kind === "executions" ? "executionHistory" : "evalHistory");
25325
+ if (cursor === void 0) {
25326
+ if (kind === "executions") await this.backfillExecutionIndex();
25327
+ else await this.backfillEvalIndex();
25328
+ }
25329
+ const positions = await this.readRetentionBatch(index, cursor, batchSize + 1);
25330
+ const batch = positions.slice(0, batchSize);
25331
+ const result = {
25332
+ expired: 0,
25333
+ armed: 0,
25334
+ indexed: 0,
25335
+ deadIndexEntries: 0,
25336
+ cursor: positions.length > batchSize ? { time: batch[batch.length - 1].score, id: batch[batch.length - 1].value } : null
25337
+ };
25338
+ if (batch.length === 0) return result;
25339
+ const ids = batch.map((m) => m.value);
25340
+ const raws = await this.client.mGet(ids.map((id) => index.dataKey(id)));
25341
+ const dead = ids.filter((_, i) => raws[i] == null);
25342
+ const present = ids.flatMap((id, i) => {
25343
+ const raw = raws[i];
25344
+ return raw == null ? [] : [{ id, raw }];
25345
+ });
25346
+ const pttls = await Promise.all(present.map((r) => this.client.pTTL(index.dataKey(r.id))));
25347
+ const now = Date.now();
25348
+ const tx = this.client.multi();
25349
+ const queued = [];
25350
+ for (let i = 0; i < present.length; i++) {
25351
+ const { id, raw } = present[i];
25352
+ const pttl = pttls[i];
25353
+ if (pttl === -2) {
25354
+ dead.push(id);
25355
+ } else if (pttl > 0) {
25356
+ tx.zAdd(index.expiry, { score: now + pttl, value: id }, { condition: "NX" });
25357
+ queued.push({ action: "index", id });
25358
+ } else if (pttl === -1 && ttl !== void 0) {
25359
+ let lastSavedAt;
24305
25360
  try {
24306
- entries2.push(JSON.parse(raw));
25361
+ lastSavedAt = index.lastSavedAtOf(raw);
24307
25362
  } catch {
25363
+ lastSavedAt = null;
25364
+ }
25365
+ if (lastSavedAt === null) continue;
25366
+ const decision = retentionForUntimedRecord(lastSavedAt, ttl, now);
25367
+ if (decision.expired) {
25368
+ tx.pExpireAt(index.dataKey(id), 1, "NX");
25369
+ queued.push({ action: "expire", id });
25370
+ } else {
25371
+ tx.pExpireAt(index.dataKey(id), decision.expiresAt, "NX");
25372
+ queued.push({ action: "arm", id, expiresAt: decision.expiresAt });
24308
25373
  }
24309
- if (limit !== void 0 && entries2.length >= limit) break;
24310
25374
  }
24311
- return entries2;
24312
25375
  }
24313
- const ids = await this.client.sMembers(setKey);
24314
- if (ids.length === 0) return [];
24315
- const entries = [];
24316
- for (const id of ids) {
24317
- const raw = await this.client.get(dataKey(id));
24318
- if (raw == null) continue;
24319
- try {
24320
- entries.push(JSON.parse(raw));
24321
- } catch {
25376
+ const expiredIds = [];
25377
+ const armed = [];
25378
+ if (queued.length > 0) {
25379
+ const replies = await tx.exec();
25380
+ for (let i = 0; i < queued.length; i++) {
25381
+ if (Number(replies[i]) !== 1) continue;
25382
+ const entry = queued[i];
25383
+ if (entry.action === "index") result.indexed++;
25384
+ else if (entry.action === "expire") expiredIds.push(entry.id);
25385
+ else armed.push(entry);
24322
25386
  }
24323
25387
  }
24324
- entries.sort(sortCmp);
24325
- return limit ? entries.slice(0, limit) : entries;
25388
+ if (armed.length > 0) {
25389
+ const indexTx = this.client.multi();
25390
+ for (const { id, expiresAt } of armed) {
25391
+ indexTx.zAdd(index.expiry, { score: expiresAt, value: id }, { condition: "NX" });
25392
+ }
25393
+ await indexTx.exec();
25394
+ }
25395
+ result.armed = armed.length;
25396
+ result.expired = expiredIds.length;
25397
+ result.deadIndexEntries = dead.length;
25398
+ if (dead.length > 0 || expiredIds.length > 0) {
25399
+ await this.runPruneScript(index, [...dead, ...expiredIds]);
25400
+ }
25401
+ return result;
25402
+ }
25403
+ /**
25404
+ * Up to `count` members of the time sorted set strictly after `cursor`,
25405
+ * oldest first by `(score, member)`. The members sharing the cursor's score
25406
+ * are read in full and filtered by member; the rest start just above that
25407
+ * score. Neither read uses an offset, so entries removed or added
25408
+ * meanwhile cannot shift the walk.
25409
+ */
25410
+ async readRetentionBatch(index, cursor, count2) {
25411
+ if (cursor === void 0) {
25412
+ return this.client.zRangeWithScores(index.time, "-inf", "+inf", {
25413
+ BY: "SCORE",
25414
+ LIMIT: { offset: 0, count: count2 }
25415
+ });
25416
+ }
25417
+ const ties = (await this.client.zRangeWithScores(index.time, cursor.time, cursor.time, { BY: "SCORE" })).filter((m) => compareMembers(m.value, cursor.id) > 0);
25418
+ if (ties.length >= count2) return ties.slice(0, count2);
25419
+ const rest = await this.client.zRangeWithScores(index.time, `(${cursor.time}`, "+inf", {
25420
+ BY: "SCORE",
25421
+ LIMIT: { offset: 0, count: count2 - ties.length }
25422
+ });
25423
+ return [...ties, ...rest];
24326
25424
  }
24327
25425
  // ── Sessions (Studio introspection) ────────────────────────────────────
24328
25426
  async listSessions() {