@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.cjs CHANGED
@@ -602,7 +602,7 @@ var sharedGuard = typeof installedGuard === "object" && installedGuard !== null
602
602
  var buildPath = typeof __AXL_BUILD_PATH__ === "string" ? __AXL_BUILD_PATH__ : "<source>";
603
603
  var thisCopy = {
604
604
  path: buildPath.startsWith("file:") ? (0, import_node_url.fileURLToPath)(buildPath) : buildPath,
605
- version: true ? "0.26.0" : "unknown"
605
+ version: true ? "0.27.0" : "unknown"
606
606
  };
607
607
  function warnCrossCopy(other, joined) {
608
608
  if (other === thisCopy) return;
@@ -12869,8 +12869,17 @@ function detectDroppedRefinements(schema) {
12869
12869
  var DOCS_URL = "docs/observability.md#schema-diagnostics";
12870
12870
  var MAX_WARNED_KEYS = 2048;
12871
12871
  var warnedDiagnosticKeys = /* @__PURE__ */ new Set();
12872
+ var silencedDepth = 0;
12873
+ function withDiagnosticsSilenced(fn) {
12874
+ silencedDepth++;
12875
+ try {
12876
+ return fn();
12877
+ } finally {
12878
+ silencedDepth--;
12879
+ }
12880
+ }
12872
12881
  function warnDiagnosticOnce(key, message, silent, docs = DOCS_URL) {
12873
- if (silent || process.env.AXL_DIAGNOSTICS_SILENT === "true") return;
12882
+ if (silent || silencedDepth > 0 || process.env.AXL_DIAGNOSTICS_SILENT === "true") return;
12874
12883
  if (warnedDiagnosticKeys.has(key)) return;
12875
12884
  if (warnedDiagnosticKeys.size >= MAX_WARNED_KEYS) warnedDiagnosticKeys.clear();
12876
12885
  warnedDiagnosticKeys.add(key);
@@ -18934,10 +18943,135 @@ var import_node_async_hooks4 = require("async_hooks");
18934
18943
  var import_node_fs3 = require("fs");
18935
18944
  var import_node_path3 = require("path");
18936
18945
 
18946
+ // src/state/retention.ts
18947
+ var PRUNE_BATCH = 100;
18948
+ var DEFAULT_MEMORY_MAX_EXECUTIONS = 1e3;
18949
+ function warnExecutionRetentionUnset(storeName) {
18950
+ warnDiagnosticOnce(
18951
+ `execution_retention_unset\0${storeName}`,
18952
+ `${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.`,
18953
+ void 0,
18954
+ "docs/migration/execution-history-retention.md#silencing-the-retention-warning"
18955
+ );
18956
+ }
18957
+ function resolveTtl(storeName, category, value) {
18958
+ if (value === void 0 || value === null) return null;
18959
+ if (typeof value !== "number") {
18960
+ throw new AxlError(
18961
+ "INVALID_CONFIG",
18962
+ `${storeName}: ttls.${category} must be a positive number of seconds or null; got ${typeof value}`
18963
+ );
18964
+ }
18965
+ if (!Number.isFinite(value) || value <= 0) {
18966
+ throw new AxlError(
18967
+ "INVALID_CONFIG",
18968
+ `${storeName}: ttls.${category} must be a positive number of seconds or null; got ${value}`
18969
+ );
18970
+ }
18971
+ return value;
18972
+ }
18973
+ function resolveHistoryTtls(storeName, ttls) {
18974
+ if (ttls !== void 0 && (ttls === null || typeof ttls !== "object")) {
18975
+ throw new AxlError(
18976
+ "INVALID_CONFIG",
18977
+ `${storeName}: ttls must be an object; got ${ttls === null ? "null" : typeof ttls}`
18978
+ );
18979
+ }
18980
+ return {
18981
+ executionHistory: resolveTtl(storeName, "executionHistory", ttls?.executionHistory),
18982
+ evalHistory: resolveTtl(storeName, "evalHistory", ttls?.evalHistory)
18983
+ };
18984
+ }
18985
+ function expiryFrom(now, ttlSeconds) {
18986
+ return ttlSeconds === null ? null : now + Math.round(ttlSeconds * 1e3);
18987
+ }
18988
+ function isExpired(expiresAt, now) {
18989
+ return expiresAt !== null && now >= expiresAt;
18990
+ }
18991
+ var DEFAULT_RETENTION_BATCH_SIZE = 500;
18992
+ function resolveHistoryRetentionOptions(storeName, options) {
18993
+ const method = `${storeName}.applyHistoryRetention()`;
18994
+ if (options === null || typeof options !== "object") {
18995
+ throw new AxlError("INVALID_ARGUMENT", `${method} options must be an object`);
18996
+ }
18997
+ const { kind, batchSize, cursor } = options;
18998
+ if (kind !== "executions" && kind !== "evals") {
18999
+ throw new AxlError(
19000
+ "INVALID_ARGUMENT",
19001
+ `${method} kind must be 'executions' or 'evals'; got ${String(kind)}`
19002
+ );
19003
+ }
19004
+ if (batchSize !== void 0 && !(typeof batchSize === "number" && Number.isSafeInteger(batchSize) && batchSize > 0)) {
19005
+ throw new AxlError(
19006
+ "INVALID_ARGUMENT",
19007
+ `${method} batchSize must be a positive integer; got ${String(batchSize)}`
19008
+ );
19009
+ }
19010
+ let parsedCursor;
19011
+ if (cursor !== void 0 && cursor !== null) {
19012
+ const candidate = cursor;
19013
+ if (typeof candidate !== "object" || typeof candidate.time !== "number" || !Number.isFinite(candidate.time) || typeof candidate.id !== "string") {
19014
+ throw new AxlError(
19015
+ "INVALID_ARGUMENT",
19016
+ `${method} cursor must be the { time, id } cursor returned by the previous call`
19017
+ );
19018
+ }
19019
+ parsedCursor = { time: candidate.time, id: candidate.id };
19020
+ }
19021
+ return {
19022
+ kind,
19023
+ batchSize: batchSize ?? DEFAULT_RETENTION_BATCH_SIZE,
19024
+ cursor: parsedCursor
19025
+ };
19026
+ }
19027
+ function retentionForUntimedRecord(lastSavedAt, ttlSeconds, now) {
19028
+ const expiresAt = lastSavedAt + Math.round(ttlSeconds * 1e3);
19029
+ return isExpired(expiresAt, now) ? { expired: true } : { expired: false, expiresAt };
19030
+ }
19031
+ function executionLastSavedAt(record) {
19032
+ return typeof record.completedAt === "number" && Number.isFinite(record.completedAt) ? record.completedAt : record.startedAt;
19033
+ }
19034
+ function compareExecutionsNewestFirst(a, b) {
19035
+ if (a.startedAt !== b.startedAt) return b.startedAt - a.startedAt;
19036
+ return compareCodePoints(b.executionId, a.executionId);
19037
+ }
19038
+ function compareRetentionPositions(a, b) {
19039
+ if (a.time !== b.time) return a.time - b.time;
19040
+ return compareCodePoints(a.id, b.id);
19041
+ }
19042
+ function compareCodePoints(a, b) {
19043
+ const len = Math.min(a.length, b.length);
19044
+ for (let i = 0; i < len; i++) {
19045
+ const x = a.charCodeAt(i);
19046
+ const y = b.charCodeAt(i);
19047
+ if (x !== y) return codePointRank(x) - codePointRank(y);
19048
+ }
19049
+ return a.length - b.length;
19050
+ }
19051
+ function codePointRank(unit) {
19052
+ if (unit >= 57344) return unit - 2048;
19053
+ if (unit >= 55296) return unit + 8192;
19054
+ return unit;
19055
+ }
19056
+
18937
19057
  // src/state/memory.ts
18938
19058
  var import_node_fs = require("fs");
18939
19059
  var import_node_path = require("path");
18940
19060
  var import_node_os = require("os");
19061
+ function compareOldestFirst(a, b) {
19062
+ return compareExecutionsNewestFirst(b, a);
19063
+ }
19064
+ function resolveMaxExecutions(value) {
19065
+ if (value === void 0) return DEFAULT_MEMORY_MAX_EXECUTIONS;
19066
+ if (value === Infinity) return Infinity;
19067
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
19068
+ throw new AxlError(
19069
+ "INVALID_CONFIG",
19070
+ `MemoryStore: maxExecutions must be a positive integer or Infinity; got ${typeof value === "number" ? value : typeof value}`
19071
+ );
19072
+ }
19073
+ return value;
19074
+ }
18941
19075
  var AWAIT_HUMAN_TEMP_DIR = (0, import_node_path.join)((0, import_node_os.tmpdir)(), "axl-memory-store");
18942
19076
  var AWAIT_HUMAN_TEMP_FILE = (0, import_node_path.join)(AWAIT_HUMAN_TEMP_DIR, "await-human-state.json");
18943
19077
  var MemoryStore = class {
@@ -18947,9 +19081,32 @@ var MemoryStore = class {
18947
19081
  decisions = /* @__PURE__ */ new Map();
18948
19082
  executionStates = /* @__PURE__ */ new Map();
18949
19083
  memories = /* @__PURE__ */ new Map();
19084
+ /**
19085
+ * Iteration order is last-save order: a save deletes and re-inserts its key.
19086
+ * With one retention per store and a clock that does not run backwards, that
19087
+ * is also expiry order, which lets the prune stop at the first live record.
19088
+ */
18950
19089
  executionHistory = /* @__PURE__ */ new Map();
19090
+ /** Every key in `executionHistory`, sorted oldest first in listing order. */
19091
+ executionOrder = [];
19092
+ /**
19093
+ * Iteration order is first-write order (a re-save keeps the key's position
19094
+ * and its expiry), which is also expiry order for the same reason as above.
19095
+ */
18951
19096
  evalHistory = /* @__PURE__ */ new Map();
18952
- constructor() {
19097
+ ttls;
19098
+ maxExecutions;
19099
+ now;
19100
+ constructor(options) {
19101
+ this.ttls = resolveHistoryTtls("MemoryStore", options?.ttls);
19102
+ this.maxExecutions = resolveMaxExecutions(options?.maxExecutions);
19103
+ if (options?.now !== void 0 && typeof options.now !== "function") {
19104
+ throw new AxlError(
19105
+ "INVALID_CONFIG",
19106
+ "MemoryStore: now must be a function returning epoch milliseconds"
19107
+ );
19108
+ }
19109
+ this.now = options?.now ?? (() => Date.now());
18953
19110
  this.loadPersistedState();
18954
19111
  }
18955
19112
  async saveCheckpoint(executionId, name, data) {
@@ -19046,23 +19203,43 @@ var MemoryStore = class {
19046
19203
  }
19047
19204
  // ── Execution History ──────────────────────────────────────────────
19048
19205
  async saveExecution(execution) {
19049
- const normalized = normalizeStoredExecution({
19050
- ...execution,
19051
- eventSchemaVersion: getExecutionEventSchemaVersion(execution)
19206
+ const now = this.now();
19207
+ const normalized = structuredClone(
19208
+ normalizeStoredExecution({
19209
+ ...execution,
19210
+ eventSchemaVersion: getExecutionEventSchemaVersion(execution)
19211
+ })
19212
+ );
19213
+ this.removeExecutionRecord(normalized.executionId);
19214
+ this.executionHistory.set(normalized.executionId, {
19215
+ record: normalized,
19216
+ expiresAt: expiryFrom(now, this.ttls.executionHistory)
19052
19217
  });
19053
- this.executionHistory.set(execution.executionId, structuredClone(normalized));
19218
+ this.insertExecutionOrder(normalized);
19219
+ this.pruneExpiredExecutions(now);
19220
+ this.evictOverflowExecutions();
19054
19221
  }
19055
19222
  async getExecution(executionId) {
19056
- const exec = this.executionHistory.get(executionId);
19057
- return exec ? normalizeStoredExecution(structuredClone(exec)) : null;
19058
- }
19059
- async listExecutions(limit) {
19060
- const sorted = [...this.executionHistory.values()].sort((a, b) => b.startedAt - a.startedAt);
19061
- const result = limit ? sorted.slice(0, limit) : sorted;
19062
- return result.map((e) => normalizeStoredExecution(structuredClone(e)));
19223
+ const stored = this.executionHistory.get(executionId);
19224
+ if (!stored || isExpired(stored.expiresAt, this.now())) return null;
19225
+ return normalizeStoredExecution(structuredClone(stored.record));
19226
+ }
19227
+ /** Supports `listExecutions(limit, { cursor })`. */
19228
+ historyPaging = true;
19229
+ async listExecutions(limit, options) {
19230
+ const now = this.now();
19231
+ const max = limit ? limit : Infinity;
19232
+ const result = [];
19233
+ const start = options?.cursor !== void 0 ? this.executionOrderLowerBound(options.cursor) - 1 : this.executionOrder.length - 1;
19234
+ for (let i = start; i >= 0 && result.length < max; i--) {
19235
+ const stored = this.executionHistory.get(this.executionOrder[i].executionId);
19236
+ if (isExpired(stored.expiresAt, now)) continue;
19237
+ result.push(normalizeStoredExecution(structuredClone(stored.record)));
19238
+ }
19239
+ return result;
19063
19240
  }
19064
19241
  async deleteExecution(executionId) {
19065
- const removedHistory = this.executionHistory.delete(executionId);
19242
+ const removedHistory = this.removeExecutionRecord(executionId);
19066
19243
  const removedCheckpoints = this.checkpoints.delete(executionId);
19067
19244
  const removedState = this.executionStates.delete(executionId);
19068
19245
  const removedStreaming = this.streamingEvents.delete(executionId);
@@ -19073,26 +19250,193 @@ var MemoryStore = class {
19073
19250
  return removedHistory || removedCheckpoints || removedState || removedStreaming || removedDecision;
19074
19251
  }
19075
19252
  // ── Eval History ──────────────────────────────────────────────────
19253
+ /**
19254
+ * Write an eval history row. Retention counts from the row's first write:
19255
+ * re-saving a live row keeps its expiry (and its position in expiry order).
19256
+ * A row that has already expired is replaced as a new one.
19257
+ */
19076
19258
  async saveEvalResult(entry) {
19077
- this.evalHistory.set(entry.id, structuredClone(entry));
19259
+ const now = this.now();
19260
+ const existing = this.evalHistory.get(entry.id);
19261
+ if (existing && !isExpired(existing.expiresAt, now)) {
19262
+ this.evalHistory.set(entry.id, {
19263
+ entry: structuredClone(entry),
19264
+ expiresAt: existing.expiresAt
19265
+ });
19266
+ } else {
19267
+ this.evalHistory.delete(entry.id);
19268
+ this.evalHistory.set(entry.id, {
19269
+ entry: structuredClone(entry),
19270
+ expiresAt: expiryFrom(now, this.ttls.evalHistory)
19271
+ });
19272
+ }
19273
+ this.pruneExpiredEvals(now);
19078
19274
  }
19079
- /** Update-only write: see `StateStore.updateEvalResult`. */
19275
+ /** Update-only write: see `StateStore.updateEvalResult`. Never changes retention. */
19080
19276
  async updateEvalResult(entry) {
19081
- if (!this.evalHistory.has(entry.id)) return false;
19082
- this.evalHistory.set(entry.id, structuredClone(entry));
19277
+ const existing = this.evalHistory.get(entry.id);
19278
+ if (!existing || isExpired(existing.expiresAt, this.now())) return false;
19279
+ this.evalHistory.set(entry.id, {
19280
+ entry: structuredClone(entry),
19281
+ expiresAt: existing.expiresAt
19282
+ });
19083
19283
  return true;
19084
19284
  }
19085
19285
  async listEvalResults(limit) {
19086
- const sorted = [...this.evalHistory.values()].sort((a, b) => b.timestamp - a.timestamp);
19286
+ const now = this.now();
19287
+ const sorted = [...this.evalHistory.values()].filter((stored) => !isExpired(stored.expiresAt, now)).map((stored) => stored.entry).sort((a, b) => b.timestamp - a.timestamp);
19087
19288
  const result = limit ? sorted.slice(0, limit) : sorted;
19088
19289
  return result.map((e) => structuredClone(e));
19089
19290
  }
19090
19291
  async deleteEvalResult(id) {
19091
19292
  return this.evalHistory.delete(id);
19092
19293
  }
19093
- /** In-memory history never expires on its own — only existence is meaningful. */
19294
+ /** Existence plus the expiry stored at the row's first write, if any. */
19094
19295
  async getEvalRetention(id) {
19095
- return { exists: this.evalHistory.has(id) };
19296
+ const stored = this.evalHistory.get(id);
19297
+ if (!stored || isExpired(stored.expiresAt, this.now())) return { exists: false };
19298
+ return stored.expiresAt === null ? { exists: true } : { exists: true, expiresAt: stored.expiresAt };
19299
+ }
19300
+ // ── History maintenance ───────────────────────────────────────────
19301
+ /**
19302
+ * See `StateStore.applyHistoryRetention`. Every record this store holds was
19303
+ * saved under the retention it was constructed with, so in practice a run
19304
+ * finds nothing to expire or arm; the method exists so the same maintenance
19305
+ * code works against every built-in store. There is no separate index, so
19306
+ * `indexed` and `deadIndexEntries` are always `0`.
19307
+ *
19308
+ * An armed record keeps its place in last-save order, which is also where
19309
+ * its expiry (last save + retention) belongs, so the prune-on-save order
19310
+ * still holds.
19311
+ */
19312
+ async applyHistoryRetention(options) {
19313
+ const { kind, batchSize, cursor } = resolveHistoryRetentionOptions("MemoryStore", options);
19314
+ const now = this.now();
19315
+ let expired = 0;
19316
+ let armed = 0;
19317
+ let positions;
19318
+ if (kind === "executions") {
19319
+ let start = 0;
19320
+ if (cursor !== void 0) {
19321
+ const key = { startedAt: cursor.time, executionId: cursor.id };
19322
+ start = this.executionOrderLowerBound(key);
19323
+ const at = this.executionOrder[start];
19324
+ if (at !== void 0 && compareOldestFirst(at, key) === 0) start++;
19325
+ }
19326
+ positions = this.executionOrder.slice(start, start + batchSize + 1).map((k) => ({ time: k.startedAt, id: k.executionId }));
19327
+ } else {
19328
+ 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);
19329
+ }
19330
+ const batch = positions.slice(0, batchSize);
19331
+ const ttl = kind === "executions" ? this.ttls.executionHistory : this.ttls.evalHistory;
19332
+ if (ttl !== null) {
19333
+ for (const { id } of batch) {
19334
+ if (kind === "executions") {
19335
+ const stored = this.executionHistory.get(id);
19336
+ if (!stored || stored.expiresAt !== null) continue;
19337
+ const decision = retentionForUntimedRecord(executionLastSavedAt(stored.record), ttl, now);
19338
+ if (decision.expired) {
19339
+ this.expireExecutionRecord(id);
19340
+ expired++;
19341
+ } else {
19342
+ stored.expiresAt = decision.expiresAt;
19343
+ armed++;
19344
+ }
19345
+ } else {
19346
+ const stored = this.evalHistory.get(id);
19347
+ if (!stored || stored.expiresAt !== null) continue;
19348
+ const decision = retentionForUntimedRecord(stored.entry.timestamp, ttl, now);
19349
+ if (decision.expired) {
19350
+ this.evalHistory.delete(id);
19351
+ expired++;
19352
+ } else {
19353
+ stored.expiresAt = decision.expiresAt;
19354
+ armed++;
19355
+ }
19356
+ }
19357
+ }
19358
+ }
19359
+ return {
19360
+ expired,
19361
+ armed,
19362
+ indexed: 0,
19363
+ deadIndexEntries: 0,
19364
+ cursor: positions.length > batchSize ? batch[batch.length - 1] : null
19365
+ };
19366
+ }
19367
+ // ── History retention internals ───────────────────────────────────
19368
+ /** Index of the first order key not older than `key` (binary search). */
19369
+ executionOrderLowerBound(key) {
19370
+ let lo = 0;
19371
+ let hi = this.executionOrder.length;
19372
+ while (lo < hi) {
19373
+ const mid = lo + hi >>> 1;
19374
+ if (compareOldestFirst(this.executionOrder[mid], key) < 0) lo = mid + 1;
19375
+ else hi = mid;
19376
+ }
19377
+ return lo;
19378
+ }
19379
+ insertExecutionOrder(record) {
19380
+ const key = { startedAt: record.startedAt, executionId: record.executionId };
19381
+ this.executionOrder.splice(this.executionOrderLowerBound(key), 0, key);
19382
+ }
19383
+ /** Remove a history record and its listing-index key. Returns whether one existed. */
19384
+ removeExecutionRecord(executionId) {
19385
+ const stored = this.executionHistory.get(executionId);
19386
+ if (!stored) return false;
19387
+ this.executionHistory.delete(executionId);
19388
+ const index = this.executionOrderLowerBound(stored.record);
19389
+ if (this.executionOrder[index]?.executionId === executionId) {
19390
+ this.executionOrder.splice(index, 1);
19391
+ }
19392
+ return true;
19393
+ }
19394
+ /**
19395
+ * A run leaving history through retention (expiry or `maxExecutions`) takes
19396
+ * its leftover per-run data with it: checkpoints (kept today when a run
19397
+ * fails), saved execution state and any streaming buffer. Pending decisions
19398
+ * are not touched. The runtime never writes execution state, and a run is
19399
+ * only saved to history once it has finished, so nothing still running
19400
+ * depends on what is removed here.
19401
+ */
19402
+ expireExecutionRecord(executionId) {
19403
+ this.removeExecutionRecord(executionId);
19404
+ this.checkpoints.delete(executionId);
19405
+ const state = this.executionStates.get(executionId);
19406
+ this.executionStates.delete(executionId);
19407
+ this.streamingEvents.delete(executionId);
19408
+ if (state?.status === "waiting") this.persistAwaitHumanState();
19409
+ }
19410
+ /**
19411
+ * Remove up to `PRUNE_BATCH` expired execution records, oldest save first.
19412
+ * Stops at the first live record: Map order is last-save order, which is
19413
+ * expiry order while the clock moves forward. If the clock steps back, a
19414
+ * few expired records wait for a later save; reads still filter them.
19415
+ */
19416
+ pruneExpiredExecutions(now) {
19417
+ if (this.ttls.executionHistory === null) return;
19418
+ let removed = 0;
19419
+ for (const [id, stored] of this.executionHistory) {
19420
+ if (removed >= PRUNE_BATCH || !isExpired(stored.expiresAt, now)) break;
19421
+ this.expireExecutionRecord(id);
19422
+ removed++;
19423
+ }
19424
+ }
19425
+ /** Evict the oldest records in listing order until the count bound holds. */
19426
+ evictOverflowExecutions() {
19427
+ while (this.executionHistory.size > this.maxExecutions) {
19428
+ this.expireExecutionRecord(this.executionOrder[0].executionId);
19429
+ }
19430
+ }
19431
+ /** Remove up to `PRUNE_BATCH` expired eval rows, oldest first write first. */
19432
+ pruneExpiredEvals(now) {
19433
+ if (this.ttls.evalHistory === null) return;
19434
+ let removed = 0;
19435
+ for (const [id, stored] of this.evalHistory) {
19436
+ if (removed >= PRUNE_BATCH || !isExpired(stored.expiresAt, now)) break;
19437
+ this.evalHistory.delete(id);
19438
+ removed++;
19439
+ }
19096
19440
  }
19097
19441
  // ── Sessions (Studio introspection) ─────────────────────────────────
19098
19442
  async listSessions() {
@@ -19170,6 +19514,7 @@ var MemoryStore = class {
19170
19514
  };
19171
19515
 
19172
19516
  // src/state/sqlite.ts
19517
+ var LIVE = "(expires_at IS NULL OR expires_at > ?)";
19173
19518
  function safeJsonParse(data) {
19174
19519
  try {
19175
19520
  return JSON.parse(data);
@@ -19206,7 +19551,17 @@ function rowToExecutionInfo(row) {
19206
19551
  }
19207
19552
  var SQLiteStore = class {
19208
19553
  db;
19209
- constructor(filePath) {
19554
+ ttls;
19555
+ now;
19556
+ constructor(filePath, options) {
19557
+ this.ttls = resolveHistoryTtls("SQLiteStore", options?.ttls);
19558
+ if (options?.now !== void 0 && typeof options.now !== "function") {
19559
+ throw new AxlError(
19560
+ "INVALID_CONFIG",
19561
+ "SQLiteStore: now must be a function returning epoch milliseconds"
19562
+ );
19563
+ }
19564
+ this.now = options?.now ?? (() => Date.now());
19210
19565
  let BetterSqlite3;
19211
19566
  try {
19212
19567
  BetterSqlite3 = require("better-sqlite3");
@@ -19219,6 +19574,7 @@ var SQLiteStore = class {
19219
19574
  this.db.pragma("journal_mode = WAL");
19220
19575
  this.migrate();
19221
19576
  this.initTables();
19577
+ if (options?.ttls?.executionHistory === void 0) warnExecutionRetentionUnset("SQLiteStore");
19222
19578
  }
19223
19579
  /**
19224
19580
  * Idempotent schema migration. Tracks version via `PRAGMA user_version`
@@ -19246,6 +19602,11 @@ var SQLiteStore = class {
19246
19602
  * Existing rows remain `NULL`, the documented v1 sentinel.
19247
19603
  * - v5 (observation completeness): add `execution_history.observation`.
19248
19604
  * Existing rows remain `NULL`, meaning they predate the contract.
19605
+ * - v6 (history retention): add nullable `expires_at` (epoch ms) to
19606
+ * `execution_history` and `eval_history`. Existing rows remain `NULL`
19607
+ * (never expire), so an upgrade deletes nothing. The single-column
19608
+ * `started_at` index is replaced by `(started_at, execution_id)`, which
19609
+ * serves the tie-safe listing order.
19249
19610
  *
19250
19611
  * Applied BEFORE `initTables()` so the subsequent `CREATE TABLE
19251
19612
  * IF NOT EXISTS` runs against the post-migration column name. Fresh
@@ -19263,7 +19624,7 @@ var SQLiteStore = class {
19263
19624
  * transactional re-read costs nothing.
19264
19625
  */
19265
19626
  migrate() {
19266
- const TARGET_VERSION = 5;
19627
+ const TARGET_VERSION = 6;
19267
19628
  const current = this.db.pragma("user_version", { simple: true });
19268
19629
  if (current >= TARGET_VERSION) return;
19269
19630
  this.db.exec("BEGIN IMMEDIATE");
@@ -19303,6 +19664,15 @@ var SQLiteStore = class {
19303
19664
  this.db.exec("ALTER TABLE execution_history ADD COLUMN observation TEXT");
19304
19665
  }
19305
19666
  }
19667
+ if (committed < 6) {
19668
+ for (const table of ["execution_history", "eval_history"]) {
19669
+ const cols = this.db.pragma(`table_info(${table})`);
19670
+ if (cols.length > 0 && !cols.some((c) => c.name === "expires_at")) {
19671
+ this.db.exec(`ALTER TABLE ${table} ADD COLUMN expires_at INTEGER`);
19672
+ }
19673
+ }
19674
+ this.db.exec("DROP INDEX IF EXISTS idx_exec_history_started");
19675
+ }
19306
19676
  this.db.pragma(`user_version = ${TARGET_VERSION}`);
19307
19677
  this.db.exec("COMMIT");
19308
19678
  } catch (err) {
@@ -19368,18 +19738,28 @@ var SQLiteStore = class {
19368
19738
  events TEXT NOT NULL,
19369
19739
  metadata TEXT,
19370
19740
  event_schema_version INTEGER,
19371
- observation TEXT
19741
+ observation TEXT,
19742
+ expires_at INTEGER
19372
19743
  );
19373
19744
 
19374
19745
  CREATE TABLE IF NOT EXISTS eval_history (
19375
19746
  id TEXT PRIMARY KEY,
19376
19747
  eval_name TEXT NOT NULL,
19377
19748
  timestamp INTEGER NOT NULL,
19378
- data TEXT NOT NULL
19749
+ data TEXT NOT NULL,
19750
+ expires_at INTEGER
19379
19751
  );
19380
19752
 
19381
- CREATE INDEX IF NOT EXISTS idx_exec_history_started ON execution_history (started_at DESC);
19753
+ CREATE INDEX IF NOT EXISTS idx_exec_history_started_id
19754
+ ON execution_history (started_at DESC, execution_id DESC);
19755
+ CREATE INDEX IF NOT EXISTS idx_exec_history_expires ON execution_history (expires_at);
19382
19756
  CREATE INDEX IF NOT EXISTS idx_eval_history_timestamp ON eval_history (timestamp DESC);
19757
+ CREATE INDEX IF NOT EXISTS idx_eval_history_expires ON eval_history (expires_at);
19758
+
19759
+ -- Superseded by idx_exec_history_started_id in v6. Dropped here as well as in
19760
+ -- migrate(): a pre-v6 process reopening a v6 file re-creates it, and
19761
+ -- migrate() never runs again once user_version is 6.
19762
+ DROP INDEX IF EXISTS idx_exec_history_started;
19383
19763
  `);
19384
19764
  }
19385
19765
  // ── Checkpoints ────────────────────────────────────────────────────────
@@ -19492,30 +19872,71 @@ var SQLiteStore = class {
19492
19872
  ...execution,
19493
19873
  eventSchemaVersion: getExecutionEventSchemaVersion(execution)
19494
19874
  });
19875
+ const now = this.now();
19876
+ this.db.transaction(() => {
19877
+ this.db.prepare(
19878
+ "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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
19879
+ ).run(
19880
+ normalized.executionId,
19881
+ normalized.workflow,
19882
+ normalized.status,
19883
+ normalized.totalCost,
19884
+ normalized.startedAt,
19885
+ normalized.completedAt ?? null,
19886
+ normalized.duration,
19887
+ normalized.error ?? null,
19888
+ JSON.stringify(normalized.events),
19889
+ normalized.metadata !== void 0 ? JSON.stringify(normalized.metadata) : null,
19890
+ normalized.eventSchemaVersion,
19891
+ normalized.observation !== void 0 ? JSON.stringify(normalized.observation) : null,
19892
+ expiryFrom(now, this.ttls.executionHistory)
19893
+ );
19894
+ this.pruneExpiredExecutions(now);
19895
+ })();
19896
+ }
19897
+ /**
19898
+ * Remove up to `PRUNE_BATCH` expired execution-history rows, together with
19899
+ * each run's leftover checkpoints and saved execution state, inside the
19900
+ * caller's transaction. Pending decisions are not touched. The runtime
19901
+ * never writes execution state, and a run is only saved to history once it
19902
+ * has finished, so nothing still running depends on what is removed here.
19903
+ */
19904
+ pruneExpiredExecutions(now) {
19905
+ const ids = this.db.prepare(
19906
+ "SELECT execution_id FROM execution_history WHERE expires_at <= ? ORDER BY expires_at LIMIT ?"
19907
+ ).all(now, PRUNE_BATCH).map((row) => row.execution_id);
19908
+ if (ids.length === 0) return;
19909
+ const idList = JSON.stringify(ids);
19910
+ for (const table of ["execution_history", "checkpoints", "execution_state"]) {
19911
+ this.db.prepare(`DELETE FROM ${table} WHERE execution_id IN (SELECT value FROM json_each(?))`).run(idList);
19912
+ }
19913
+ }
19914
+ /** Remove up to `PRUNE_BATCH` expired eval-history rows (caller's transaction). */
19915
+ pruneExpiredEvals(now) {
19495
19916
  this.db.prepare(
19496
- "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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
19497
- ).run(
19498
- normalized.executionId,
19499
- normalized.workflow,
19500
- normalized.status,
19501
- normalized.totalCost,
19502
- normalized.startedAt,
19503
- normalized.completedAt ?? null,
19504
- normalized.duration,
19505
- normalized.error ?? null,
19506
- JSON.stringify(normalized.events),
19507
- normalized.metadata !== void 0 ? JSON.stringify(normalized.metadata) : null,
19508
- normalized.eventSchemaVersion,
19509
- normalized.observation !== void 0 ? JSON.stringify(normalized.observation) : null
19510
- );
19917
+ "DELETE FROM eval_history WHERE rowid IN (SELECT rowid FROM eval_history WHERE expires_at <= ? ORDER BY expires_at LIMIT ?)"
19918
+ ).run(now, PRUNE_BATCH);
19511
19919
  }
19512
19920
  async getExecution(executionId) {
19513
- const row = this.db.prepare("SELECT * FROM execution_history WHERE execution_id = ?").get(executionId);
19921
+ const row = this.db.prepare(`SELECT * FROM execution_history WHERE execution_id = ? AND ${LIVE}`).get(executionId, this.now());
19514
19922
  return row ? rowToExecutionInfo(row) : null;
19515
19923
  }
19516
- async listExecutions(limit) {
19517
- const sql = limit ? "SELECT * FROM execution_history ORDER BY started_at DESC LIMIT ?" : "SELECT * FROM execution_history ORDER BY started_at DESC";
19518
- const rows = limit ? this.db.prepare(sql).all(limit) : this.db.prepare(sql).all();
19924
+ /** Supports `listExecutions(limit, { cursor })`. */
19925
+ historyPaging = true;
19926
+ async listExecutions(limit, options) {
19927
+ const cursor = options?.cursor;
19928
+ const params = [this.now()];
19929
+ let sql = `SELECT * FROM execution_history WHERE ${LIVE}`;
19930
+ if (cursor !== void 0) {
19931
+ sql += " AND (started_at < ? OR (started_at = ? AND execution_id < ?))";
19932
+ params.push(cursor.startedAt, cursor.startedAt, cursor.executionId);
19933
+ }
19934
+ sql += " ORDER BY started_at DESC, execution_id DESC";
19935
+ if (limit) {
19936
+ sql += " LIMIT ?";
19937
+ params.push(limit);
19938
+ }
19939
+ const rows = this.db.prepare(sql).all(...params);
19519
19940
  return rows.map(rowToExecutionInfo);
19520
19941
  }
19521
19942
  async deleteExecution(executionId) {
@@ -19529,24 +19950,38 @@ var SQLiteStore = class {
19529
19950
  return tx();
19530
19951
  }
19531
19952
  // ── Eval History ────────────────────────────────────────────────────────
19953
+ /**
19954
+ * Write an eval history row. Retention counts from the row's first write:
19955
+ * re-saving a live row keeps its `expires_at` (including none, for a row
19956
+ * written without retention). A row that has already expired is replaced
19957
+ * as a new one.
19958
+ */
19532
19959
  async saveEvalResult(entry) {
19533
- this.db.prepare(
19534
- "INSERT OR REPLACE INTO eval_history (id, eval_name, timestamp, data) VALUES (?, ?, ?, ?)"
19535
- ).run(entry.id, entry.eval, entry.timestamp, JSON.stringify(entry.data));
19960
+ const now = this.now();
19961
+ this.db.transaction(() => {
19962
+ const existing = this.db.prepare(`SELECT expires_at FROM eval_history WHERE id = ? AND ${LIVE}`).get(entry.id, now);
19963
+ const expiresAt = existing ? existing.expires_at : expiryFrom(now, this.ttls.evalHistory);
19964
+ this.db.prepare(
19965
+ "INSERT OR REPLACE INTO eval_history (id, eval_name, timestamp, data, expires_at) VALUES (?, ?, ?, ?, ?)"
19966
+ ).run(entry.id, entry.eval, entry.timestamp, JSON.stringify(entry.data), expiresAt);
19967
+ this.pruneExpiredEvals(now);
19968
+ })();
19536
19969
  }
19537
19970
  /**
19538
19971
  * Update-only write: see `StateStore.updateEvalResult`. `UPDATE ... WHERE id`
19539
- * touches nothing when the row is gone, where `INSERT OR REPLACE` would
19540
- * resurrect it — and SQLite history has no expiry, so a resurrection here is
19541
- * permanent.
19972
+ * touches nothing when the row is gone or expired, where `INSERT OR REPLACE`
19973
+ * would resurrect it. `expires_at` is left as it was.
19542
19974
  */
19543
19975
  async updateEvalResult(entry) {
19544
- 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);
19976
+ const result = this.db.prepare(
19977
+ `UPDATE eval_history SET eval_name = ?, timestamp = ?, data = ? WHERE id = ? AND ${LIVE}`
19978
+ ).run(entry.eval, entry.timestamp, JSON.stringify(entry.data), entry.id, this.now());
19545
19979
  return result.changes > 0;
19546
19980
  }
19547
19981
  async listEvalResults(limit) {
19548
- const sql = limit ? "SELECT * FROM eval_history ORDER BY timestamp DESC LIMIT ?" : "SELECT * FROM eval_history ORDER BY timestamp DESC";
19549
- const rows = limit ? this.db.prepare(sql).all(limit) : this.db.prepare(sql).all();
19982
+ const base = `SELECT * FROM eval_history WHERE ${LIVE} ORDER BY timestamp DESC`;
19983
+ const now = this.now();
19984
+ const rows = limit ? this.db.prepare(`${base} LIMIT ?`).all(now, limit) : this.db.prepare(base).all(now);
19550
19985
  return rows.map((r) => ({
19551
19986
  id: r.id,
19552
19987
  eval: r.eval_name,
@@ -19558,10 +19993,75 @@ var SQLiteStore = class {
19558
19993
  const result = this.db.prepare("DELETE FROM eval_history WHERE id = ?").run(id);
19559
19994
  return result.changes > 0;
19560
19995
  }
19561
- /** SQLite history has no automatic expiry — only existence is meaningful. */
19996
+ /** Existence plus the expiry stored at the row's first write, if any. */
19562
19997
  async getEvalRetention(id) {
19563
- const row = this.db.prepare("SELECT 1 FROM eval_history WHERE id = ?").get(id);
19564
- return { exists: row !== void 0 };
19998
+ const row = this.db.prepare(`SELECT expires_at FROM eval_history WHERE id = ? AND ${LIVE}`).get(id, this.now());
19999
+ if (!row) return { exists: false };
20000
+ return row.expires_at === null ? { exists: true } : { exists: true, expiresAt: row.expires_at };
20001
+ }
20002
+ // ── History maintenance ─────────────────────────────────────────────────
20003
+ /**
20004
+ * See `StateStore.applyHistoryRetention`. One batch runs in one immediate
20005
+ * transaction, so no other writer can change a row between this call
20006
+ * reading it and acting on it. Only rows whose `expires_at` is `NULL` are
20007
+ * touched. An expired execution takes its checkpoints and saved execution
20008
+ * state with it, as a normal expiry does. There is no separate index, so
20009
+ * `indexed` and `deadIndexEntries` are always `0`.
20010
+ */
20011
+ async applyHistoryRetention(options) {
20012
+ const { kind, batchSize, cursor } = resolveHistoryRetentionOptions("SQLiteStore", options);
20013
+ const ttl = kind === "executions" ? this.ttls.executionHistory : this.ttls.evalHistory;
20014
+ const table = kind === "executions" ? { name: "execution_history", id: "execution_id", time: "started_at" } : { name: "eval_history", id: "id", time: "timestamp" };
20015
+ const lastSaved = kind === "executions" ? "completed_at" : "NULL";
20016
+ const now = this.now();
20017
+ return this.db.transaction(() => {
20018
+ const params = [];
20019
+ let sql = `SELECT ${table.id} AS id, ${table.time} AS time, ${lastSaved} AS last_saved, expires_at FROM ${table.name}`;
20020
+ if (cursor !== void 0) {
20021
+ sql += ` WHERE ${table.time} > ? OR (${table.time} = ? AND ${table.id} > ?)`;
20022
+ params.push(cursor.time, cursor.time, cursor.id);
20023
+ }
20024
+ sql += ` ORDER BY ${table.time}, ${table.id} LIMIT ?`;
20025
+ params.push(batchSize + 1);
20026
+ const rows = this.db.prepare(sql).all(...params);
20027
+ const batch = rows.slice(0, batchSize);
20028
+ const toExpire = [];
20029
+ let armed = 0;
20030
+ if (ttl !== null) {
20031
+ const arm = this.db.prepare(
20032
+ `UPDATE ${table.name} SET expires_at = ? WHERE ${table.id} = ? AND expires_at IS NULL`
20033
+ );
20034
+ for (const row of batch) {
20035
+ if (row.expires_at !== null) continue;
20036
+ const lastSavedAt = kind === "executions" ? executionLastSavedAt({ startedAt: row.time, completedAt: row.last_saved }) : row.time;
20037
+ const decision = retentionForUntimedRecord(lastSavedAt, ttl, now);
20038
+ if (decision.expired) toExpire.push(row.id);
20039
+ else armed += arm.run(decision.expiresAt, row.id).changes;
20040
+ }
20041
+ }
20042
+ let expired = 0;
20043
+ if (toExpire.length > 0) {
20044
+ const idList = JSON.stringify(toExpire);
20045
+ expired = this.db.prepare(
20046
+ `DELETE FROM ${table.name} WHERE expires_at IS NULL AND ${table.id} IN (SELECT value FROM json_each(?))`
20047
+ ).run(idList).changes;
20048
+ if (kind === "executions") {
20049
+ for (const side of ["checkpoints", "execution_state"]) {
20050
+ this.db.prepare(
20051
+ `DELETE FROM ${side} WHERE execution_id IN (SELECT value FROM json_each(?))`
20052
+ ).run(idList);
20053
+ }
20054
+ }
20055
+ }
20056
+ const last = batch[batch.length - 1];
20057
+ return {
20058
+ expired,
20059
+ armed,
20060
+ indexed: 0,
20061
+ deadIndexEntries: 0,
20062
+ cursor: rows.length > batchSize ? { time: last.time, id: last.id } : null
20063
+ };
20064
+ }).immediate();
19565
20065
  }
19566
20066
  // ── Sessions (Studio introspection) ────────────────────────────────────
19567
20067
  async listSessions() {
@@ -20901,6 +21401,9 @@ function hashInput(input) {
20901
21401
  }
20902
21402
  var DEFAULT_MAX_EVENTS_PER_EXECUTION = 5e4;
20903
21403
  var DEFAULT_STREAMING_BATCH_SIZE = 100;
21404
+ var DEFAULT_RECENT_EXECUTIONS_CACHE_SIZE = 1e3;
21405
+ var LARGE_EXECUTION_LISTING = 1e3;
21406
+ var EXECUTION_SCAN_PAGE_SIZE = 100;
20904
21407
  var DEFAULT_STREAMING_BATCH_INTERVAL = 1e3;
20905
21408
  var DEFAULT_ARTIFACT_SWEEP_MS = 6e4;
20906
21409
  var DEFAULT_ARTIFACT_LEASE_MS = 3e5;
@@ -21113,6 +21616,78 @@ function assertNoRemovedObservationCallbacks(options) {
21113
21616
  `runtime.createContext() no longer accepts ${removed.join(", ")}. Observe ctx.events or use runtime.stream() instead. See docs/migration/stream-first-observation.md.`
21114
21617
  );
21115
21618
  }
21619
+ var RecentRunsCache = class {
21620
+ constructor(capacity) {
21621
+ this.capacity = capacity;
21622
+ }
21623
+ entries = /* @__PURE__ */ new Map();
21624
+ get size() {
21625
+ return this.entries.size;
21626
+ }
21627
+ get(executionId) {
21628
+ const entry = this.entries.get(executionId);
21629
+ if (entry) {
21630
+ this.entries.delete(executionId);
21631
+ this.entries.set(executionId, entry);
21632
+ }
21633
+ return entry;
21634
+ }
21635
+ set(executionId, execution) {
21636
+ this.entries.delete(executionId);
21637
+ this.entries.set(executionId, execution);
21638
+ while (this.entries.size > this.capacity) {
21639
+ const oldest = this.entries.keys().next().value;
21640
+ this.entries.delete(oldest);
21641
+ }
21642
+ }
21643
+ delete(executionId) {
21644
+ return this.entries.delete(executionId);
21645
+ }
21646
+ values() {
21647
+ return [...this.entries.values()];
21648
+ }
21649
+ };
21650
+ function resolveRecentExecutionsCacheSize(requested) {
21651
+ if (requested === void 0) return DEFAULT_RECENT_EXECUTIONS_CACHE_SIZE;
21652
+ if (typeof requested !== "number" || !Number.isInteger(requested) || requested < 0) {
21653
+ throw new AxlError(
21654
+ "INVALID_CONFIG",
21655
+ `config.state.recentExecutionsCacheSize must be a non-negative integer; got ${String(requested)}`
21656
+ );
21657
+ }
21658
+ return requested;
21659
+ }
21660
+ function parseGetExecutionsOptions(options) {
21661
+ if (options === void 0) return {};
21662
+ if (options === null || typeof options !== "object") {
21663
+ throw new AxlError(
21664
+ "INVALID_ARGUMENT",
21665
+ `getExecutions() options must be an object; got ${options === null ? "null" : typeof options}`
21666
+ );
21667
+ }
21668
+ const { limit, cursor } = options;
21669
+ if (limit !== void 0 && limit !== Infinity && !(typeof limit === "number" && Number.isInteger(limit) && limit > 0)) {
21670
+ throw new AxlError(
21671
+ "INVALID_ARGUMENT",
21672
+ `getExecutions() limit must be a positive integer; got ${String(limit)}`
21673
+ );
21674
+ }
21675
+ let parsedCursor;
21676
+ if (cursor !== void 0) {
21677
+ const candidate = cursor;
21678
+ if (candidate === null || typeof candidate !== "object" || typeof candidate.startedAt !== "number" || !Number.isFinite(candidate.startedAt) || typeof candidate.executionId !== "string") {
21679
+ throw new AxlError(
21680
+ "INVALID_ARGUMENT",
21681
+ "getExecutions() cursor must be { startedAt: number; executionId: string }, built from the last item of the previous page"
21682
+ );
21683
+ }
21684
+ parsedCursor = { startedAt: candidate.startedAt, executionId: candidate.executionId };
21685
+ }
21686
+ return {
21687
+ ...limit === void 0 || limit === Infinity ? {} : { limit },
21688
+ ...parsedCursor ? { cursor: parsedCursor } : {}
21689
+ };
21690
+ }
21116
21691
  var costScopeStorage = new import_node_async_hooks4.AsyncLocalStorage();
21117
21692
  function forwardAbortSignal(external, internal) {
21118
21693
  if (!external) return () => {
@@ -21159,8 +21734,6 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
21159
21734
  mcpManager;
21160
21735
  memoryManager;
21161
21736
  spanManager = new NoopSpanManager();
21162
- historicalExecutions = /* @__PURE__ */ new Map();
21163
- historicalExecutionsLoadPromise = null;
21164
21737
  /** Per-session in-process serialization. Each entry is a chain of pending
21165
21738
  * work for that session id — `Session.send` / `Session.stream` await the
21166
21739
  * prior entry before reading history, so concurrent calls produce a
@@ -21196,12 +21769,34 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
21196
21769
  * a delete from resurrecting the (intentionally-removed) row. Entries are
21197
21770
  * removed once the would-be persist has been short-circuited. */
21198
21771
  pendingDeletedExecutions = /* @__PURE__ */ new Set();
21199
- /** In-flight `persistExecution` chains (save → finalize). `shutdown()`
21200
- * awaits these before closing the StateStore so a workflow whose abort
21772
+ /** In-flight `persistExecution` chains (save → finalize) and crash-
21773
+ * recovery saves. `shutdown()` awaits these before closing the StateStore so a workflow whose abort
21201
21774
  * triggers a persist-during-shutdown isn't racing a closed connection.
21202
21775
  * The Set is mutated by the chain's own `.finally` so entries clear as
21203
21776
  * soon as the work settles. */
21204
21777
  persistInflight = /* @__PURE__ */ new Set();
21778
+ /** Finished runs whose history save has not settled, keyed by execution
21779
+ * id. Reads serve the snapshot until the save lands, so a run is visible
21780
+ * the moment it finishes (read-your-writes). `deleteExecution()` awaits
21781
+ * the entry before deleting from the store, so a save that lands late
21782
+ * cannot re-create a deleted run. Each entry removes itself when its save
21783
+ * settles (success or failure), but only if it is still the current entry
21784
+ * for that id, so the map holds only in-flight saves. */
21785
+ pendingSaves = /* @__PURE__ */ new Map();
21786
+ /** Recently finished runs, kept only when the store cannot save, list, or
21787
+ * fetch execution history itself (and the cache size is not `0`).
21788
+ * `undefined` for every built-in store: execution history is read through
21789
+ * the store on every call and never cached here. */
21790
+ recentRuns;
21791
+ /** Execution ids `recoverIncompleteStreams()` is working on, each with a
21792
+ * promise that settles when recovery is done with that id.
21793
+ * `deleteExecution()` awaits it, so a recovered record cannot land after
21794
+ * the delete. */
21795
+ recoveringExecutions = /* @__PURE__ */ new Map();
21796
+ /** Execution ids with a `deleteExecution()` in progress, with a count of
21797
+ * concurrent deletes. Recovery skips them: their store sweep removes the
21798
+ * streaming buffer it would otherwise turn back into a record. */
21799
+ deletingExecutions = /* @__PURE__ */ new Map();
21205
21800
  /** Resolved diagnostic artifact backend, when `diagnostics.artifacts` is set. */
21206
21801
  artifactStore;
21207
21802
  artifactLeaseMs = DEFAULT_ARTIFACT_LEASE_MS;
@@ -21235,6 +21830,13 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
21235
21830
  this.providerRegistry = new ProviderRegistry();
21236
21831
  this.transcriptionProviderRegistry = new TranscriptionProviderRegistry();
21237
21832
  this.stateStore = this.createStateStore();
21833
+ const recentRunsSize = resolveRecentExecutionsCacheSize(
21834
+ this.config.state?.recentExecutionsCacheSize
21835
+ );
21836
+ const storeKeepsHistory = Boolean(
21837
+ this.stateStore.saveExecution && this.stateStore.listExecutions
21838
+ );
21839
+ this.recentRuns = !storeKeepsHistory && recentRunsSize > 0 ? new RecentRunsCache(recentRunsSize) : void 0;
21238
21840
  const requested = this.config.state?.maxEventsPerExecution;
21239
21841
  if (requested === void 0) {
21240
21842
  this.maxEventsPerExecution = DEFAULT_MAX_EVENTS_PER_EXECUTION;
@@ -21640,11 +22242,15 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
21640
22242
  const storeOption = this.config.state?.store ?? "memory";
21641
22243
  if (typeof storeOption !== "string") return storeOption;
21642
22244
  switch (storeOption) {
21643
- case "sqlite":
21644
- return new SQLiteStore(this.config.state?.sqlite?.path ?? "./data/axl.db");
22245
+ case "sqlite": {
22246
+ const create = () => new SQLiteStore(this.config.state?.sqlite?.path ?? "./data/axl.db", {
22247
+ ttls: this.config.state?.sqlite?.ttls
22248
+ });
22249
+ return this.config.diagnostics?.silent ? withDiagnosticsSilenced(create) : create();
22250
+ }
21645
22251
  case "memory":
21646
22252
  default:
21647
- return new MemoryStore();
22253
+ return new MemoryStore(this.config.state?.memory);
21648
22254
  }
21649
22255
  }
21650
22256
  /**
@@ -21732,13 +22338,15 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
21732
22338
  }
21733
22339
  /**
21734
22340
  * Persist a completed/failed execution to the state store (fire-and-forget)
21735
- * and move it from the active executions map to the historical cache.
22341
+ * and remove it from the active executions map. Until the save settles the
22342
+ * run is served from `pendingSaves`; afterwards from the store (or, for a
22343
+ * store that cannot keep history, from `recentRuns`).
21736
22344
  *
21737
22345
  * Resurrection guard: if `runtime.deleteExecution(id)` was called while
21738
22346
  * this execution was still running, the id will be in
21739
- * `pendingDeletedExecutions`. In that case we skip the save AND the
21740
- * historical-cache write, and finalize the streaming buffer (if any) so
21741
- * the buffer doesn't outlive the delete.
22347
+ * `pendingDeletedExecutions`. In that case we skip the save AND every
22348
+ * in-process record of the run, and finalize the streaming buffer (if any)
22349
+ * so the buffer doesn't outlive the delete.
21742
22350
  */
21743
22351
  persistExecution(execInfo) {
21744
22352
  const id = execInfo.executionId;
@@ -21769,14 +22377,19 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
21769
22377
  }).catch(() => {
21770
22378
  }).then(() => void 0);
21771
22379
  this.persistInflight.add(chain);
21772
- chain.finally(() => this.persistInflight.delete(chain));
22380
+ const pending = { snapshot, settled: chain };
22381
+ this.pendingSaves.set(id, pending);
22382
+ chain.finally(() => {
22383
+ this.persistInflight.delete(chain);
22384
+ if (this.pendingSaves.get(id) === pending) this.pendingSaves.delete(id);
22385
+ });
21773
22386
  } else if (this.streamingFlusher) {
21774
22387
  const chain = this.streamingFlusher.finalize(id).catch(() => {
21775
22388
  });
21776
22389
  this.persistInflight.add(chain);
21777
22390
  chain.finally(() => this.persistInflight.delete(chain));
21778
22391
  }
21779
- this.historicalExecutions.set(id, snapshot);
22392
+ this.recentRuns?.set(id, snapshot);
21780
22393
  this.executions.delete(id);
21781
22394
  }
21782
22395
  /** Register a workflow with the runtime. */
@@ -21902,7 +22515,6 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
21902
22515
  }
21903
22516
  return result;
21904
22517
  }
21905
- /** Get all execution info (running + completed + historical). */
21906
22518
  /** Normalize an execution loaded from a `StateStore` so the
21907
22519
  * historical event union holds for downstream consumers
21908
22520
  * (Studio aggregators, REST routes, redaction). Custom or legacy stores
@@ -21922,27 +22534,78 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
21922
22534
  }
21923
22535
  return normalizeStoredExecution(exec);
21924
22536
  }
21925
- async getExecutions() {
21926
- if (!this.historicalExecutionsLoadPromise && this.stateStore.listExecutions) {
21927
- this.historicalExecutionsLoadPromise = this.stateStore.listExecutions().then((stored) => {
21928
- for (const exec of stored) {
21929
- if (!this.executions.has(exec.executionId) && !this.historicalExecutions.has(exec.executionId)) {
21930
- this.historicalExecutions.set(exec.executionId, this.normalizeStoredExecution(exec));
21931
- }
21932
- }
21933
- }).catch((error) => {
21934
- this.historicalExecutionsLoadPromise = null;
21935
- throw error;
21936
- });
21937
- }
21938
- if (this.historicalExecutionsLoadPromise) {
21939
- await this.historicalExecutionsLoadPromise;
21940
- }
21941
- const merged = new Map(this.historicalExecutions);
21942
- for (const [id, exec] of this.executions) {
21943
- merged.set(id, exec);
22537
+ /**
22538
+ * List executions, newest first: running runs, runs whose save is still in
22539
+ * flight, and the history the state store holds. Ordered by `startedAt`,
22540
+ * then `executionId`, both descending.
22541
+ *
22542
+ * With no options, returns every retained run. Pass `limit` for one page,
22543
+ * and `cursor` (built from the last item of the previous page) for the
22544
+ * page after it. Each call reads through the store, so runs finished or
22545
+ * deleted by other runtimes sharing the store are reflected immediately.
22546
+ * A call without `limit` that returns more than 1 000 runs warns once per
22547
+ * process (silenced by `diagnostics.silent`); pass `limit: Infinity` to ask
22548
+ * for everything without the warning.
22549
+ *
22550
+ * @throws {AxlError} `INVALID_ARGUMENT` for a `limit` that is not a positive
22551
+ * integer (`Infinity` means no limit) or a malformed `cursor`.
22552
+ */
22553
+ async getExecutions(options) {
22554
+ const { limit, cursor } = parseGetExecutionsOptions(options);
22555
+ const isAfterCursor = (exec) => cursor === void 0 || compareExecutionsNewestFirst(exec, cursor) > 0;
22556
+ const live = new Map(this.executions);
22557
+ for (const [id, { snapshot }] of this.pendingSaves) {
22558
+ if (!live.has(id)) live.set(id, snapshot);
22559
+ }
22560
+ const recent = this.recentRunsServeListing() ? this.recentRuns.values() : [];
22561
+ const stored = await this.listStoredExecutions({
22562
+ limit,
22563
+ cursor,
22564
+ overlap: live.size + recent.length
22565
+ });
22566
+ const merged = /* @__PURE__ */ new Map();
22567
+ for (const [id, exec] of live) {
22568
+ if (isAfterCursor(exec)) merged.set(id, exec);
22569
+ }
22570
+ for (const exec of stored) {
22571
+ const id = exec.executionId;
22572
+ if (!live.has(id) && !merged.has(id)) merged.set(id, exec);
22573
+ }
22574
+ for (const exec of recent) {
22575
+ const id = exec.executionId;
22576
+ if (!live.has(id) && !merged.has(id) && isAfterCursor(exec)) merged.set(id, exec);
22577
+ }
22578
+ const sorted = [...merged.values()].sort(compareExecutionsNewestFirst);
22579
+ if (limit !== void 0) return sorted.slice(0, limit);
22580
+ if (options?.limit === void 0 && sorted.length > LARGE_EXECUTION_LISTING) {
22581
+ warnDiagnosticOnce(
22582
+ "large_execution_listing",
22583
+ `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.`,
22584
+ this.config.diagnostics?.silent,
22585
+ "docs/migration/execution-history-retention.md#page-through-large-histories"
22586
+ );
21944
22587
  }
21945
- return [...merged.values()].sort((a, b) => b.startedAt - a.startedAt);
22588
+ return sorted;
22589
+ }
22590
+ /**
22591
+ * Read one page of execution history from the store: the records strictly
22592
+ * after `cursor`, newest first, at least `limit + overlap` of them when the
22593
+ * store holds that many (all of them when `limit` is unset).
22594
+ *
22595
+ * Only a store that declares `historyPaging` is handed the cursor. Any
22596
+ * other store is listed in full and paged here: filtering the output of a
22597
+ * store that ignored the cursor would return its first page again, and an
22598
+ * empty page after that.
22599
+ */
22600
+ async listStoredExecutions(page) {
22601
+ const store = this.stateStore;
22602
+ if (!store.listExecutions) return [];
22603
+ const wanted = page.limit === void 0 ? void 0 : page.limit + page.overlap;
22604
+ const rows = store.historyPaging === true ? await store.listExecutions(wanted, { cursor: page.cursor }) : await store.listExecutions();
22605
+ const cursor = page.cursor;
22606
+ const after = cursor === void 0 ? rows : rows.filter((row) => compareExecutionsNewestFirst(row, cursor) > 0);
22607
+ const window = wanted === void 0 ? after : [...after].sort(compareExecutionsNewestFirst).slice(0, wanted);
22608
+ return window.map((row) => this.normalizeStoredExecution(row));
21946
22609
  }
21947
22610
  /**
21948
22611
  * Create a WorkflowContext for ad-hoc use (evals, tool testing, prototyping).
@@ -22373,20 +23036,25 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
22373
23036
  * Recover executions whose process died mid-run when `state.persist:
22374
23037
  * 'streaming'` was configured. Synthesizes a partial `ExecutionInfo`
22375
23038
  * from the streaming buffer for each — `status: 'failed'`, error
22376
- * `'process terminated'` — registers them in the historical cache so
22377
- * they show up in `getExecutions()`, persists them via the store's
22378
- * `saveExecution`, and finalizes the streaming buffer.
23039
+ * `'process terminated'` — persists them via the store's
23040
+ * `saveExecution` (so they show up in `getExecutions()`), and finalizes
23041
+ * the streaming buffer.
22379
23042
  *
22380
23043
  * Idempotent — if the streaming buffer is already gone (or
22381
23044
  * `listStreamingExecutions` is empty), this is a no-op. Returns the
22382
23045
  * list of recovered `ExecutionInfo`s for caller introspection.
22383
23046
  *
22384
- * Wire this into your process startup AFTER `runtime.getExecutions()`
22385
- * has lazy-loaded the historical cache. Order matters: completed
22386
- * executions already in the store should not be overwritten by a
22387
- * stale streaming buffer that the previous process didn't get to
22388
- * finalize. The implementation checks the historical store before
22389
- * synthesizing, so a re-run is safe.
23047
+ * A run whose final record already exists is never recovered: for each
23048
+ * buffered id this asks the store's `getExecution(id)` and, when a record
23049
+ * is there, only drops the leftover buffer. It does not list history, so
23050
+ * its cost does not grow with the size of the store. A store without
23051
+ * `getExecution` cannot be checked, and every buffered run is recovered.
23052
+ *
23053
+ * Wire this into process startup, before the process accepts new work:
23054
+ * a run live in another process looks the same as a crashed one.
23055
+ * `deleteExecution(id)` called while recovery is working on that id waits
23056
+ * for recovery to finish with it, so the recovered record cannot outlive
23057
+ * the delete.
22390
23058
  */
22391
23059
  async recoverIncompleteStreams() {
22392
23060
  if (!this.stateStore.listStreamingExecutions || !this.stateStore.getStreamingEvents) {
@@ -22394,73 +23062,119 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
22394
23062
  }
22395
23063
  const ids = await this.stateStore.listStreamingExecutions();
22396
23064
  if (ids.length === 0) return [];
22397
- await this.getExecutions();
22398
23065
  const recovered = [];
22399
23066
  for (const id of ids) {
22400
23067
  if (this.executions.has(id) || this.streamableExecutionIds.has(id)) {
22401
23068
  continue;
22402
23069
  }
22403
- const existing = this.historicalExecutions.get(id);
22404
- if (existing) {
22405
- await this.stateStore.finalizeStreamingEvents?.(id);
22406
- continue;
22407
- }
22408
- const events = await this.stateStore.getStreamingEvents(id);
22409
- if (events.length === 0) {
22410
- await this.stateStore.finalizeStreamingEvents?.(id);
22411
- continue;
22412
- }
22413
- const eventSchemaVersion = getEventSchemaVersion(events[0]);
22414
- for (const event of events) {
22415
- if (getEventSchemaVersion(event) !== eventSchemaVersion) {
22416
- throw new Error(`Streaming buffer ${id} contains mixed event schema versions`);
22417
- }
23070
+ if (this.deletingExecutions.has(id) || this.recoveringExecutions.has(id)) continue;
23071
+ let finishRecovery;
23072
+ this.recoveringExecutions.set(
23073
+ id,
23074
+ new Promise((resolve2) => {
23075
+ finishRecovery = resolve2;
23076
+ })
23077
+ );
23078
+ try {
23079
+ const result = await this.recoverStreamingExecution(id);
23080
+ if (result) recovered.push(result);
23081
+ } finally {
23082
+ this.recoveringExecutions.delete(id);
23083
+ finishRecovery();
22418
23084
  }
22419
- const boundedEvents = boundRecoveredEvents(events, this.maxEventsPerExecution);
22420
- const workflowStartEvent = boundedEvents.find((e) => e.type === "workflow_start");
22421
- const lastEvent = boundedEvents[boundedEvents.length - 1];
22422
- const startedAt = workflowStartEvent?.timestamp ?? boundedEvents[0]?.timestamp ?? Date.now();
22423
- const completedAt = lastEvent.timestamp ?? startedAt;
22424
- const workflowName = workflowStartEvent?.workflow ?? RECOVERED_UNKNOWN_WORKFLOW;
22425
- const totalCost = boundedEvents.reduce((sum, e) => sum + eventCostContribution(e), 0);
22426
- const unpriced = boundedEvents.some(isUnpricedLeaf);
22427
- const executionBase = {
22428
- executionId: id,
22429
- workflow: workflowName,
22430
- status: "failed",
22431
- events: boundedEvents,
22432
- totalCost,
22433
- unpriced,
22434
- startedAt,
22435
- completedAt,
22436
- duration: completedAt - startedAt,
22437
- error: "process terminated (recovered from streaming buffer)"
22438
- };
22439
- const synthesized = eventSchemaVersion === 2 ? {
22440
- ...executionBase,
22441
- eventSchemaVersion: 2,
22442
- events: boundedEvents,
22443
- observation: { complete: false, reason: "process_interrupted" }
22444
- } : {
22445
- ...executionBase,
22446
- eventSchemaVersion: 1,
22447
- events: boundedEvents
23085
+ }
23086
+ return recovered;
23087
+ }
23088
+ /** Recover one buffered execution. Returns the synthesized record, or
23089
+ * `undefined` when there was nothing to recover. */
23090
+ async recoverStreamingExecution(id) {
23091
+ if (!this.stateStore.getStreamingEvents) return void 0;
23092
+ const existing = await this.stateStore.getExecution?.(id);
23093
+ if (existing) {
23094
+ await this.stateStore.finalizeStreamingEvents?.(id);
23095
+ return void 0;
23096
+ }
23097
+ const events = await this.stateStore.getStreamingEvents(id);
23098
+ if (events.length === 0) {
23099
+ await this.stateStore.finalizeStreamingEvents?.(id);
23100
+ return void 0;
23101
+ }
23102
+ const eventSchemaVersion = getEventSchemaVersion(events[0]);
23103
+ for (const event of events) {
23104
+ if (getEventSchemaVersion(event) !== eventSchemaVersion) {
23105
+ throw new Error(`Streaming buffer ${id} contains mixed event schema versions`);
23106
+ }
23107
+ }
23108
+ const boundedEvents = boundRecoveredEvents(events, this.maxEventsPerExecution);
23109
+ const workflowStartEvent = boundedEvents.find((e) => e.type === "workflow_start");
23110
+ const lastEvent = boundedEvents[boundedEvents.length - 1];
23111
+ const startedAt = workflowStartEvent?.timestamp ?? boundedEvents[0]?.timestamp ?? Date.now();
23112
+ const completedAt = lastEvent.timestamp ?? startedAt;
23113
+ const workflowName = workflowStartEvent?.workflow ?? RECOVERED_UNKNOWN_WORKFLOW;
23114
+ const totalCost = boundedEvents.reduce((sum, e) => sum + eventCostContribution(e), 0);
23115
+ const unpriced = boundedEvents.some(isUnpricedLeaf);
23116
+ const executionBase = {
23117
+ executionId: id,
23118
+ workflow: workflowName,
23119
+ status: "failed",
23120
+ events: boundedEvents,
23121
+ totalCost,
23122
+ unpriced,
23123
+ startedAt,
23124
+ completedAt,
23125
+ duration: completedAt - startedAt,
23126
+ error: "process terminated (recovered from streaming buffer)"
23127
+ };
23128
+ const synthesized = eventSchemaVersion === 2 ? {
23129
+ ...executionBase,
23130
+ eventSchemaVersion: 2,
23131
+ events: boundedEvents,
23132
+ observation: { complete: false, reason: "process_interrupted" }
23133
+ } : {
23134
+ ...executionBase,
23135
+ eventSchemaVersion: 1,
23136
+ events: boundedEvents
23137
+ };
23138
+ const saveExecution = this.stateStore.saveExecution;
23139
+ if (saveExecution) {
23140
+ const save = (async () => saveExecution.call(this.stateStore, synthesized))();
23141
+ const pending = {
23142
+ snapshot: synthesized,
23143
+ settled: save.then(
23144
+ () => void 0,
23145
+ () => void 0
23146
+ )
22448
23147
  };
22449
- if (this.stateStore.saveExecution) {
22450
- try {
22451
- await this.stateStore.saveExecution(synthesized);
22452
- } catch (err) {
22453
- console.error(
22454
- `[axl] recoverIncompleteStreams: failed to save synthesized execution ${id}: ${err instanceof Error ? err.message : String(err)}. Streaming buffer left in place for next recovery attempt.`
22455
- );
22456
- continue;
22457
- }
23148
+ this.pendingSaves.set(id, pending);
23149
+ this.persistInflight.add(pending.settled);
23150
+ try {
23151
+ await save;
23152
+ } catch (err) {
23153
+ console.error(
23154
+ `[axl] recoverIncompleteStreams: failed to save synthesized execution ${id}: ${err instanceof Error ? err.message : String(err)}. Streaming buffer left in place for next recovery attempt.`
23155
+ );
23156
+ return void 0;
23157
+ } finally {
23158
+ this.persistInflight.delete(pending.settled);
23159
+ if (this.pendingSaves.get(id) === pending) this.pendingSaves.delete(id);
22458
23160
  }
22459
- this.historicalExecutions.set(id, synthesized);
22460
- await this.stateStore.finalizeStreamingEvents?.(id);
22461
- recovered.push(synthesized);
22462
23161
  }
22463
- return recovered;
23162
+ this.recentRuns?.set(id, synthesized);
23163
+ const finalize = Promise.resolve(this.stateStore.finalizeStreamingEvents?.(id)).then(
23164
+ () => void 0,
23165
+ (err) => {
23166
+ console.error(
23167
+ `[axl] recoverIncompleteStreams: saved execution ${id} but failed to drop its streaming buffer: ${err instanceof Error ? err.message : String(err)}. The next recovery removes it.`
23168
+ );
23169
+ }
23170
+ );
23171
+ this.persistInflight.add(finalize);
23172
+ try {
23173
+ await finalize;
23174
+ } finally {
23175
+ this.persistInflight.delete(finalize);
23176
+ }
23177
+ return synthesized;
22464
23178
  }
22465
23179
  /** Abort a running execution by its ID. */
22466
23180
  abort(executionId) {
@@ -22470,21 +23184,57 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
22470
23184
  this.abortControllers.delete(executionId);
22471
23185
  }
22472
23186
  }
22473
- /** Get execution details by ID. */
23187
+ /**
23188
+ * Get execution details by ID. Looks in order at running runs, runs whose
23189
+ * save is still in flight, the state store (its `getExecution`, or a scan
23190
+ * of its listing when it has `listExecutions` but no `getExecution`), and
23191
+ * (only for a store that cannot list or save history) the recent-runs
23192
+ * cache. Nothing read from the store is cached, so a run deleted or
23193
+ * expired elsewhere is gone on the next call, and every run
23194
+ * `getExecutions()` lists can be fetched here.
23195
+ */
22474
23196
  async getExecution(executionId) {
22475
- const inMemory = this.executions.get(executionId);
22476
- if (inMemory) return inMemory;
22477
- const cached = this.historicalExecutions.get(executionId);
22478
- if (cached) return cached;
22479
- if (this.stateStore.getExecution) {
22480
- const stored = await this.stateStore.getExecution(executionId);
22481
- if (stored) {
22482
- const normalized = this.normalizeStoredExecution(stored);
22483
- this.historicalExecutions.set(executionId, normalized);
22484
- return normalized;
22485
- }
23197
+ const active = this.executions.get(executionId);
23198
+ if (active) return active;
23199
+ const pending = this.pendingSaves.get(executionId);
23200
+ if (pending) return pending.snapshot;
23201
+ const stored = this.stateStore.getExecution ? await this.stateStore.getExecution(executionId) : await this.findInStoreListing(executionId);
23202
+ if (stored) return this.normalizeStoredExecution(stored);
23203
+ return this.recentRunsServeReads() ? this.recentRuns.get(executionId) : void 0;
23204
+ }
23205
+ /**
23206
+ * By-id read for a store that lists but cannot fetch by id. A paging store
23207
+ * is scanned page by page with the cursor, stopping at the match; any
23208
+ * other store is listed once in full. Returns `null` when the store cannot
23209
+ * list or does not hold the run.
23210
+ */
23211
+ async findInStoreListing(executionId) {
23212
+ const store = this.stateStore;
23213
+ if (!store.listExecutions) return null;
23214
+ if (store.historyPaging !== true) {
23215
+ return (await store.listExecutions()).find((e) => e.executionId === executionId) ?? null;
22486
23216
  }
22487
- return void 0;
23217
+ let cursor;
23218
+ for (; ; ) {
23219
+ const page = await store.listExecutions(EXECUTION_SCAN_PAGE_SIZE, { cursor });
23220
+ const found = page.find((e) => e.executionId === executionId);
23221
+ if (found) return found;
23222
+ if (page.length === 0) return null;
23223
+ const last = [...page].sort(compareExecutionsNewestFirst)[page.length - 1];
23224
+ if (cursor && compareExecutionsNewestFirst(last, cursor) <= 0) return null;
23225
+ cursor = { startedAt: last.startedAt, executionId: last.executionId };
23226
+ }
23227
+ }
23228
+ /** The recent-runs cache stands in for listing when the store cannot list
23229
+ * runs, or never receives them because it cannot save. */
23230
+ recentRunsServeListing() {
23231
+ return this.recentRuns !== void 0 && (!this.stateStore.saveExecution || !this.stateStore.listExecutions);
23232
+ }
23233
+ /** The recent-runs cache stands in for by-id reads when the store cannot
23234
+ * fetch or list runs (it exists only then), or never receives them because
23235
+ * it cannot save. */
23236
+ recentRunsServeReads() {
23237
+ return this.recentRuns !== void 0 && (!this.stateStore.saveExecution || !this.stateStore.getExecution);
22488
23238
  }
22489
23239
  /** Save an eval result to history. */
22490
23240
  async saveEvalResult(entry) {
@@ -22517,11 +23267,16 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
22517
23267
  }
22518
23268
  /**
22519
23269
  * Delete an execution from history by id. Removes from the active map (if
22520
- * still running), the historical cache, the configured StateStore, AND
23270
+ * still running), the recent-runs cache, the configured StateStore, AND
22521
23271
  * any execution-scoped side state — pending awaitHuman decisions,
22522
23272
  * resolver maps, streaming buffer registration, in-flight abort controller.
22523
- * Returns true if an entry was actually removed from any of the three
22524
- * primary surfaces (active map, historical cache, store).
23273
+ * Returns true if the run was running, had a save in flight, or was
23274
+ * removed from the recent-runs cache or the store.
23275
+ *
23276
+ * If the run has already finished but its history save has not landed
23277
+ * yet, this method waits for that save (whether it succeeds or fails)
23278
+ * before deleting, so the save cannot re-create the deleted record. It
23279
+ * likewise waits for `recoverIncompleteStreams()` to finish with the id.
22525
23280
  *
22526
23281
  * In-flight handling: if the execution is still running, this method
22527
23282
  * **aborts** it (via the registered abort controller) AND marks the id for
@@ -22546,7 +23301,18 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
22546
23301
  * (`defaultTtl` / `ttls.executionHistory`).
22547
23302
  */
22548
23303
  async deleteExecution(id) {
23304
+ this.deletingExecutions.set(id, (this.deletingExecutions.get(id) ?? 0) + 1);
23305
+ try {
23306
+ return await this.deleteExecutionWhileMarked(id);
23307
+ } finally {
23308
+ const remaining = (this.deletingExecutions.get(id) ?? 1) - 1;
23309
+ if (remaining > 0) this.deletingExecutions.set(id, remaining);
23310
+ else this.deletingExecutions.delete(id);
23311
+ }
23312
+ }
23313
+ async deleteExecutionWhileMarked(id) {
22549
23314
  const wasActive = this.executions.has(id);
23315
+ const hadPendingSave = this.pendingSaves.has(id);
22550
23316
  const pendingDecisionResolver = this.pendingDecisionResolvers.get(id);
22551
23317
  const hadPendingDecision = pendingDecisionResolver !== void 0;
22552
23318
  if (wasActive) {
@@ -22564,22 +23330,25 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
22564
23330
  this.pendingDecisionResolvers.delete(id);
22565
23331
  }
22566
23332
  }
23333
+ await this.pendingSaves.get(id)?.settled;
23334
+ await this.recoveringExecutions.get(id);
22567
23335
  const existing = await this.getExecution(id);
22568
23336
  const workflow2 = existing?.workflow;
22569
23337
  const removedFromActive = this.executions.delete(id);
22570
- const removedFromHistorical = this.historicalExecutions.delete(id);
23338
+ const removedFromRecent = this.recentRuns?.delete(id) ?? false;
22571
23339
  let removedFromStore = false;
22572
23340
  if (this.stateStore.deleteExecution) {
22573
23341
  removedFromStore = await this.stateStore.deleteExecution(id);
22574
23342
  }
23343
+ const removed = removedFromActive || hadPendingSave || removedFromRecent || removedFromStore;
22575
23344
  this.emit("execution_deleted", {
22576
23345
  executionId: id,
22577
23346
  workflow: workflow2,
22578
23347
  wasActive,
22579
23348
  hadPendingDecision,
22580
- removed: removedFromActive || removedFromHistorical || removedFromStore
23349
+ removed
22581
23350
  });
22582
- return removedFromActive || removedFromHistorical || removedFromStore;
23351
+ return removed;
22583
23352
  }
22584
23353
  /**
22585
23354
  * Delete an eval history entry by id. Removes from in-memory cache and
@@ -23997,12 +24766,57 @@ async function* stringStreamFromEvents(source, opts) {
23997
24766
  }
23998
24767
 
23999
24768
  // src/state/redis.ts
24769
+ var PRUNE_HISTORY_INDEX_SCRIPT = `
24770
+ local now = tonumber(ARGV[1])
24771
+ local removed = 0
24772
+ for i = 2, #ARGV do
24773
+ local id = ARGV[i]
24774
+ local pttl = redis.call('PTTL', KEYS[i + 2])
24775
+ if pttl == -2 then
24776
+ redis.call('SREM', KEYS[1], id)
24777
+ redis.call('ZREM', KEYS[2], id)
24778
+ redis.call('ZREM', KEYS[3], id)
24779
+ removed = removed + 1
24780
+ elseif pttl == -1 then
24781
+ redis.call('ZREM', KEYS[3], id)
24782
+ else
24783
+ redis.call('ZADD', KEYS[3], string.format('%d', now + pttl), id)
24784
+ end
24785
+ end
24786
+ return removed
24787
+ `;
24788
+ function compareMembers(a, b) {
24789
+ return Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
24790
+ }
24791
+ function compareNewestFirst(a, b) {
24792
+ if (a.score !== b.score) return b.score - a.score;
24793
+ return compareMembers(b.id, a.id);
24794
+ }
24795
+ function warnIfRedisExecutionRetentionUnset(opts) {
24796
+ if (opts.ttls?.executionHistory === void 0 && typeof opts.defaultTtl !== "number") {
24797
+ warnExecutionRetentionUnset("RedisStore");
24798
+ }
24799
+ }
24000
24800
  var RedisStore = class _RedisStore {
24001
24801
  constructor(client, keyPrefix, ttls) {
24002
24802
  this.client = client;
24003
24803
  this.keyPrefix = keyPrefix;
24004
24804
  this.ttls = ttls;
24005
24805
  }
24806
+ /**
24807
+ * History kinds with a prune running in this process. A save skips its
24808
+ * prune while another one of the same kind is in flight: the running one
24809
+ * already covers the oldest expired entries. Created lazily so the
24810
+ * instance needs no constructor state.
24811
+ */
24812
+ prunesInFlight;
24813
+ /**
24814
+ * Supports `listExecutions(limit, { cursor })`. A getter rather than a
24815
+ * field so it lives on the prototype.
24816
+ */
24817
+ get historyPaging() {
24818
+ return true;
24819
+ }
24006
24820
  /**
24007
24821
  * Resolve the TTL for a given storage category. Returns `undefined` when
24008
24822
  * no TTL applies (category is `null` in resolved config, the value is
@@ -24081,6 +24895,7 @@ var RedisStore = class _RedisStore {
24081
24895
  streamingEvents: resolveStreamingEvents(opts.ttls?.streamingEvents)
24082
24896
  };
24083
24897
  const store = new _RedisStore(client, keyPrefix, effectiveTtls);
24898
+ warnIfRedisExecutionRetentionUnset(opts);
24084
24899
  if (!opts.skipMigration) {
24085
24900
  await store.backfillExecutionIndex();
24086
24901
  await store.backfillEvalIndex();
@@ -24192,13 +25007,9 @@ var RedisStore = class _RedisStore {
24192
25007
  execHistoryKey(executionId) {
24193
25008
  return `${this.keyPrefix}exec-history:${executionId}`;
24194
25009
  }
24195
- // NOTE for future implementer of #3 (TTL config): when the executionHistory
24196
- // and evalHistory data blobs gain TTLs, eviction MUST be driven by
24197
- // ZREMRANGEBYSCORE on the ZSET below — NOT by SREM on these legacy ID sets.
24198
- // The slow-path fallback (`listHistoryByZset`) reads from the SET when
24199
- // `zsetCard < setCard`, so SET-based eviction would race with the fallback
24200
- // and surface dead IDs whose data has been TTL'd away. ZSET eviction +
24201
- // dual-write `SREM` is the clean path; doc the assumption then.
25010
+ // The legacy id set and the sorted set below always change together (save,
25011
+ // delete, prune), so the slow-path guard `ZCARD >= SCARD` in
25012
+ // `listHistoryByZset` and backfill stays meaningful.
24202
25013
  execHistorySetKey() {
24203
25014
  return `${this.keyPrefix}exec-history-ids`;
24204
25015
  }
@@ -24221,6 +25032,36 @@ var RedisStore = class _RedisStore {
24221
25032
  evalHistoryZsetKey() {
24222
25033
  return `${this.keyPrefix}eval-history-z`;
24223
25034
  }
25035
+ /** Keys and blob parsing for one kind of history. See {@link HistoryIndex}. */
25036
+ historyIndex(kind) {
25037
+ if (kind === "execution") {
25038
+ return {
25039
+ kind,
25040
+ label: "execution-history",
25041
+ ids: this.execHistorySetKey(),
25042
+ time: this.execHistoryZsetKey(),
25043
+ expiry: `${this.keyPrefix}exec-history-exp`,
25044
+ dataKey: (id) => this.execHistoryKey(id),
25045
+ lastSavedAtOf: (raw) => {
25046
+ const record = JSON.parse(raw);
25047
+ return typeof record.startedAt === "number" ? executionLastSavedAt(record) : null;
25048
+ }
25049
+ };
25050
+ }
25051
+ return {
25052
+ kind,
25053
+ label: "eval-history",
25054
+ ids: this.evalHistorySetKey(),
25055
+ time: this.evalHistoryZsetKey(),
25056
+ expiry: `${this.keyPrefix}eval-history-exp`,
25057
+ dataKey: (id) => this.evalHistoryKey(id),
25058
+ // Eval retention counts from the first write, which is the row's timestamp.
25059
+ lastSavedAtOf: (raw) => {
25060
+ const timestamp = JSON.parse(raw).timestamp;
25061
+ return typeof timestamp === "number" ? timestamp : null;
25062
+ }
25063
+ };
25064
+ }
24224
25065
  memoryKey(scope) {
24225
25066
  return `${this.keyPrefix}memory:${scope}`;
24226
25067
  }
@@ -24392,31 +25233,35 @@ var RedisStore = class _RedisStore {
24392
25233
  async saveExecution(execution) {
24393
25234
  const ttl = this.ttlFor("executionHistory");
24394
25235
  const setOptions = ttl !== void 0 ? { EX: ttl } : void 0;
25236
+ const index = this.historyIndex("execution");
25237
+ const id = execution.executionId;
24395
25238
  const storedExecution = normalizeStoredExecution({
24396
25239
  ...execution,
24397
25240
  eventSchemaVersion: getExecutionEventSchemaVersion(execution)
24398
25241
  });
24399
- await this.client.multi().sAdd(this.execHistorySetKey(), execution.executionId).set(this.execHistoryKey(execution.executionId), JSON.stringify(storedExecution), setOptions).zAdd(this.execHistoryZsetKey(), {
24400
- score: execution.startedAt,
24401
- value: execution.executionId
24402
- }).exec();
25242
+ 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 });
25243
+ if (ttl !== void 0) tx.zAdd(index.expiry, { score: Date.now() + ttl * 1e3, value: id });
25244
+ else tx.zRem(index.expiry, id);
25245
+ await tx.exec();
25246
+ await this.pruneHistoryAfterSave(index);
24403
25247
  }
24404
25248
  async getExecution(executionId) {
24405
25249
  const raw = await this.client.get(this.execHistoryKey(executionId));
24406
25250
  return raw ? normalizeStoredExecution(JSON.parse(raw)) : null;
24407
25251
  }
24408
- async listExecutions(limit) {
25252
+ async listExecutions(limit, options) {
25253
+ const cursor = options?.cursor;
24409
25254
  const executions = await this.listHistoryByZset(
24410
- this.execHistorySetKey(),
24411
- this.execHistoryZsetKey(),
24412
- (id) => this.execHistoryKey(id),
24413
- (a, b) => b.startedAt - a.startedAt,
24414
- limit
25255
+ this.historyIndex("execution"),
25256
+ (e) => ({ score: e.startedAt, id: e.executionId }),
25257
+ limit,
25258
+ cursor !== void 0 ? { score: cursor.startedAt, id: cursor.executionId } : void 0
24415
25259
  );
24416
25260
  return executions.map(normalizeStoredExecution);
24417
25261
  }
24418
25262
  async deleteExecution(executionId) {
24419
- 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();
25263
+ const index = this.historyIndex("execution");
25264
+ 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();
24420
25265
  return results.some((result) => typeof result === "number" && result > 0);
24421
25266
  }
24422
25267
  // ── Eval History ────────────────────────────────────────────────────
@@ -24441,18 +25286,31 @@ var RedisStore = class _RedisStore {
24441
25286
  * lifetime, so the race costs at most one window's length, never the window
24442
25287
  * itself. It also carries no Redis version floor, where `KEEPTTL` needs 6.0.
24443
25288
  *
24444
- * The index entries carry no TTL and age out lazily, as with `saveExecution`.
25289
+ * As with `saveExecution`, a timed row is also scored in the expiry sorted
25290
+ * set (by the expiry it carries, so a re-save keeps its score), an untimed
25291
+ * one is taken out of it, and a bounded prune follows the save.
24445
25292
  */
24446
25293
  async saveEvalResult(entry) {
24447
25294
  const ttl = this.ttlFor("evalHistory");
24448
- const key = this.evalHistoryKey(entry.id);
25295
+ const index = this.historyIndex("eval");
25296
+ const key = index.dataKey(entry.id);
24449
25297
  let setOptions;
25298
+ let expiresAt;
24450
25299
  if (ttl !== void 0) {
24451
25300
  const remaining = await this.client.pTTL(key);
24452
- if (remaining === -2) setOptions = { EX: ttl };
24453
- else if (remaining > 0) setOptions = { PX: remaining };
25301
+ if (remaining === -2) {
25302
+ setOptions = { EX: ttl };
25303
+ expiresAt = Date.now() + ttl * 1e3;
25304
+ } else if (remaining > 0) {
25305
+ setOptions = { PX: remaining };
25306
+ expiresAt = Date.now() + remaining;
25307
+ }
24454
25308
  }
24455
- 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();
25309
+ 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 });
25310
+ if (expiresAt !== void 0) tx.zAdd(index.expiry, { score: expiresAt, value: entry.id });
25311
+ else tx.zRem(index.expiry, entry.id);
25312
+ await tx.exec();
25313
+ await this.pruneHistoryAfterSave(index);
24456
25314
  }
24457
25315
  /**
24458
25316
  * Update-only write: see `StateStore.updateEvalResult`.
@@ -24492,15 +25350,14 @@ var RedisStore = class _RedisStore {
24492
25350
  }
24493
25351
  async listEvalResults(limit) {
24494
25352
  return this.listHistoryByZset(
24495
- this.evalHistorySetKey(),
24496
- this.evalHistoryZsetKey(),
24497
- (id) => this.evalHistoryKey(id),
24498
- (a, b) => b.timestamp - a.timestamp,
25353
+ this.historyIndex("eval"),
25354
+ (e) => ({ score: e.timestamp, id: e.id }),
24499
25355
  limit
24500
25356
  );
24501
25357
  }
24502
25358
  async deleteEvalResult(id) {
24503
- const [, deletedCount] = await this.client.multi().sRem(this.evalHistorySetKey(), id).del(this.evalHistoryKey(id)).zRem(this.evalHistoryZsetKey(), id).exec();
25359
+ const index = this.historyIndex("eval");
25360
+ const [, deletedCount] = await this.client.multi().sRem(index.ids, id).del(index.dataKey(id)).zRem(index.time, id).zRem(index.expiry, id).exec();
24504
25361
  const deleted = typeof deletedCount === "number" ? deletedCount : 0;
24505
25362
  return deleted > 0;
24506
25363
  }
@@ -24524,50 +25381,291 @@ var RedisStore = class _RedisStore {
24524
25381
  return { exists: true, expiresAt: Date.now() + ttl };
24525
25382
  }
24526
25383
  /**
24527
- * Shared list-by-sorted-set implementation. Uses ZREVRANGEBYSCORE +
24528
- * MGET when the ZSET is populated (post-migration). Falls back to the
24529
- * legacy SET + N×GET + JS-sort path when the ZSET is empty or smaller
24530
- * than the SET (covers users that constructed with `skipMigration: true`
24531
- * before calling backfill).
25384
+ * Shared list-by-sorted-set implementation, newest first by
25385
+ * `(score DESC, member DESC)`, starting strictly after `cursor` when given.
25386
+ *
25387
+ * Fast path (the time sorted set is at least as large as the id set): walk
25388
+ * the sorted set in chunks and MGET each chunk's blobs. Index entries whose
25389
+ * blob is gone (expired, or deleted before the prune caught up) are skipped,
25390
+ * and the walk keeps reading until it has `limit` live records or the set
25391
+ * runs out, however many dead entries are in the way. Each chunk restarts
25392
+ * at the last member read, by score and member, rather than at an offset,
25393
+ * so entries added or removed meanwhile cannot shift the walk.
25394
+ *
25395
+ * Slow path (the time sorted set is behind the id set: backfill has not
25396
+ * run): read every id with GET, then sort and apply the cursor in process.
24532
25397
  */
24533
- async listHistoryByZset(setKey, zsetKey, dataKey, sortCmp, limit) {
24534
- const setSize = await this.client.sCard(setKey);
25398
+ async listHistoryByZset(index, positionOf, limit, cursor) {
25399
+ const setSize = await this.client.sCard(index.ids);
24535
25400
  if (setSize === 0) return [];
24536
- const zsetSize = await this.client.zCard(zsetKey);
24537
- if (zsetSize >= setSize) {
24538
- const fetchCount = limit !== void 0 ? Math.max(limit * 2, limit + 5) : void 0;
24539
- const ids2 = await this.client.zRange(zsetKey, "+inf", "-inf", {
25401
+ const zsetSize = await this.client.zCard(index.time);
25402
+ const max = limit ? limit : void 0;
25403
+ if (zsetSize < setSize) {
25404
+ const ids = await this.client.sMembers(index.ids);
25405
+ const entries = [];
25406
+ for (const id of ids) {
25407
+ const raw = await this.client.get(index.dataKey(id));
25408
+ if (raw == null) continue;
25409
+ try {
25410
+ entries.push(JSON.parse(raw));
25411
+ } catch {
25412
+ }
25413
+ }
25414
+ const ordered = entries.filter((e) => cursor === void 0 || compareNewestFirst(cursor, positionOf(e)) < 0).sort((a, b) => compareNewestFirst(positionOf(a), positionOf(b)));
25415
+ return max !== void 0 ? ordered.slice(0, max) : ordered;
25416
+ }
25417
+ const results = [];
25418
+ const seen = /* @__PURE__ */ new Set();
25419
+ let bound = cursor;
25420
+ let rangeMax = cursor !== void 0 ? cursor.score : "+inf";
25421
+ let anchor;
25422
+ let grow = 1;
25423
+ const read = (offset, count2) => (
25424
+ // ZRANGE BY SCORE REV: start is the highest score, stop the lowest.
25425
+ this.client.zRangeWithScores(index.time, rangeMax, "-inf", {
24540
25426
  BY: "SCORE",
24541
25427
  REV: true,
24542
- ...fetchCount !== void 0 ? { LIMIT: { offset: 0, count: fetchCount } } : {}
25428
+ ...count2 !== void 0 ? { LIMIT: { offset, count: count2 } } : {}
25429
+ })
25430
+ );
25431
+ for (; ; ) {
25432
+ const remaining = max !== void 0 ? max - results.length : void 0;
25433
+ const want = remaining !== void 0 ? Math.max(remaining * 2, remaining + 5) * grow : void 0;
25434
+ let offset = 0;
25435
+ let count2 = want;
25436
+ let members;
25437
+ if (anchor !== void 0 && want !== void 0) {
25438
+ offset = anchor.pos;
25439
+ count2 = want + 1;
25440
+ members = await read(offset, count2);
25441
+ if (members[0]?.value !== anchor.id) {
25442
+ offset = 0;
25443
+ count2 = anchor.pos + 1 + want;
25444
+ members = await read(offset, count2);
25445
+ }
25446
+ } else {
25447
+ members = await read(offset, count2);
25448
+ }
25449
+ const fresh = members.filter(
25450
+ (m) => !seen.has(m.value) && (bound === void 0 || compareNewestFirst(bound, { score: m.score, id: m.value }) < 0)
25451
+ );
25452
+ if (fresh.length > 0) {
25453
+ const raws = await this.client.mGet(fresh.map((m) => index.dataKey(m.value)));
25454
+ for (let i = 0; i < fresh.length; i++) {
25455
+ seen.add(fresh[i].value);
25456
+ const raw = raws[i];
25457
+ if (raw == null) continue;
25458
+ try {
25459
+ results.push(JSON.parse(raw));
25460
+ } catch {
25461
+ continue;
25462
+ }
25463
+ if (max !== void 0 && results.length >= max) return results;
25464
+ }
25465
+ const lastFresh = fresh[fresh.length - 1];
25466
+ bound = { score: lastFresh.score, id: lastFresh.value };
25467
+ grow = 1;
25468
+ } else {
25469
+ grow *= 2;
25470
+ }
25471
+ if (count2 === void 0 || members.length < count2) return results;
25472
+ const last = members[members.length - 1];
25473
+ if (last.score === rangeMax) {
25474
+ anchor = { id: last.value, score: last.score, pos: offset + members.length - 1 };
25475
+ } else {
25476
+ rangeMax = last.score;
25477
+ const before = members.filter((m) => m.score === last.score).length - 1;
25478
+ anchor = { id: last.value, score: last.score, pos: before };
25479
+ }
25480
+ }
25481
+ }
25482
+ // ── History index cleanup ──────────────────────────────────────────────
25483
+ //
25484
+ // Index members cannot carry a TTL, so when a history record expires its
25485
+ // entries in the id set and the time sorted set stay behind. Every history
25486
+ // save runs one bounded prune of its kind: it reads up to PRUNE_BATCH ids
25487
+ // whose expiry has passed from the expiry sorted set, then runs
25488
+ // PRUNE_HISTORY_INDEX_SCRIPT over them. The script checks each record's blob
25489
+ // and updates the indexes in one atomic step, so a save or delete landing
25490
+ // between the read and the script cannot be undone by it: a record saved
25491
+ // again is seen live and stays listed, and a deleted one is already gone.
25492
+ // The script only removes index entries and re-scores expiry entries; it
25493
+ // never adds a listing entry or writes a blob, so it cannot bring back
25494
+ // deleted data. Several instances pruning at once is safe.
25495
+ /**
25496
+ * Run one prune of `index`'s kind after a save. A prune failure does not
25497
+ * fail the save, whose MULTI already committed the record: it is reported
25498
+ * once per process and the next save of that kind tries again. That also
25499
+ * covers a Redis user whose ACL denies `EVAL`.
25500
+ */
25501
+ async pruneHistoryAfterSave(index) {
25502
+ const inFlight = this.prunesInFlight ??= /* @__PURE__ */ new Set();
25503
+ if (inFlight.has(index.kind)) return;
25504
+ inFlight.add(index.kind);
25505
+ try {
25506
+ await this.pruneHistory(index);
25507
+ } catch (err) {
25508
+ warnDiagnosticOnce(
25509
+ `redis_history_prune_failed\0${index.kind}`,
25510
+ `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.`,
25511
+ void 0,
25512
+ "docs/api-reference.md#redisstore-options"
25513
+ );
25514
+ } finally {
25515
+ inFlight.delete(index.kind);
25516
+ }
25517
+ }
25518
+ async pruneHistory(index) {
25519
+ const candidates = await this.client.zRange(index.expiry, "-inf", Date.now(), {
25520
+ BY: "SCORE",
25521
+ LIMIT: { offset: 0, count: PRUNE_BATCH }
25522
+ });
25523
+ if (candidates.length > 0) await this.runPruneScript(index, candidates);
25524
+ }
25525
+ /**
25526
+ * Run {@link PRUNE_HISTORY_INDEX_SCRIPT} over `ids`, at most PRUNE_BATCH
25527
+ * per call so one script never blocks the server for long.
25528
+ */
25529
+ async runPruneScript(index, ids) {
25530
+ for (let start = 0; start < ids.length; start += PRUNE_BATCH) {
25531
+ const chunk = ids.slice(start, start + PRUNE_BATCH);
25532
+ await this.client.eval(PRUNE_HISTORY_INDEX_SCRIPT, {
25533
+ keys: [index.ids, index.time, index.expiry, ...chunk.map((id) => index.dataKey(id))],
25534
+ arguments: [String(Date.now()), ...chunk]
24543
25535
  });
24544
- if (ids2.length === 0) return [];
24545
- const keys = ids2.map(dataKey);
24546
- const raws = await this.client.mGet(keys);
24547
- const entries2 = [];
24548
- for (const raw of raws) {
24549
- if (raw == null) continue;
25536
+ }
25537
+ }
25538
+ // ── History maintenance ────────────────────────────────────────────────
25539
+ /**
25540
+ * See `StateStore.applyHistoryRetention`. One call:
25541
+ *
25542
+ * 1. On the first call of a walk (no cursor), backfills the time sorted
25543
+ * set from the id set if it is behind (see `backfillExecutionIndex`).
25544
+ * 2. Reads the next `batchSize` ids from the time sorted set, oldest first
25545
+ * by `(score, member)`, strictly after the cursor. The read restarts at
25546
+ * the cursor's position by value, never by offset.
25547
+ * 3. MGETs their blobs and reads each live blob's PTTL.
25548
+ * 4. In one MULTI, for each live blob:
25549
+ * - a TTL (`PTTL > 0`): `ZADD NX` to the expiry set at now + PTTL. A
25550
+ * record already in the expiry set keeps its entry (`indexed` counts
25551
+ * only new entries).
25552
+ * - no TTL (`-1`), with a retention configured: `PEXPIREAT … NX` at last
25553
+ * save + retention. `NX` applies only to a key that still has no TTL,
25554
+ * so a record re-saved meanwhile keeps the fresh window its save gave
25555
+ * it. A window that has already passed uses a timestamp in the past,
25556
+ * which makes Redis delete the key, again only if it is still untimed.
25557
+ * 5. Adds each newly armed record to the expiry set (`ZADD NX`).
25558
+ * 6. Runs the prune script (`PRUNE_HISTORY_INDEX_SCRIPT`) over ids whose
25559
+ * blob is gone and ids just deleted in step 4. It checks each blob
25560
+ * atomically, so a record re-saved at any point stays listed and a
25561
+ * deleted one is never re-indexed.
25562
+ *
25563
+ * Blobs are never written. A record deleted while the call runs can leave
25564
+ * an entry in the expiry set only; the next prune removes it.
25565
+ */
25566
+ async applyHistoryRetention(options) {
25567
+ const { kind, batchSize, cursor } = resolveHistoryRetentionOptions("RedisStore", options);
25568
+ const index = this.historyIndex(kind === "executions" ? "execution" : "eval");
25569
+ const ttl = this.ttlFor(kind === "executions" ? "executionHistory" : "evalHistory");
25570
+ if (cursor === void 0) {
25571
+ if (kind === "executions") await this.backfillExecutionIndex();
25572
+ else await this.backfillEvalIndex();
25573
+ }
25574
+ const positions = await this.readRetentionBatch(index, cursor, batchSize + 1);
25575
+ const batch = positions.slice(0, batchSize);
25576
+ const result = {
25577
+ expired: 0,
25578
+ armed: 0,
25579
+ indexed: 0,
25580
+ deadIndexEntries: 0,
25581
+ cursor: positions.length > batchSize ? { time: batch[batch.length - 1].score, id: batch[batch.length - 1].value } : null
25582
+ };
25583
+ if (batch.length === 0) return result;
25584
+ const ids = batch.map((m) => m.value);
25585
+ const raws = await this.client.mGet(ids.map((id) => index.dataKey(id)));
25586
+ const dead = ids.filter((_, i) => raws[i] == null);
25587
+ const present = ids.flatMap((id, i) => {
25588
+ const raw = raws[i];
25589
+ return raw == null ? [] : [{ id, raw }];
25590
+ });
25591
+ const pttls = await Promise.all(present.map((r) => this.client.pTTL(index.dataKey(r.id))));
25592
+ const now = Date.now();
25593
+ const tx = this.client.multi();
25594
+ const queued = [];
25595
+ for (let i = 0; i < present.length; i++) {
25596
+ const { id, raw } = present[i];
25597
+ const pttl = pttls[i];
25598
+ if (pttl === -2) {
25599
+ dead.push(id);
25600
+ } else if (pttl > 0) {
25601
+ tx.zAdd(index.expiry, { score: now + pttl, value: id }, { condition: "NX" });
25602
+ queued.push({ action: "index", id });
25603
+ } else if (pttl === -1 && ttl !== void 0) {
25604
+ let lastSavedAt;
24550
25605
  try {
24551
- entries2.push(JSON.parse(raw));
25606
+ lastSavedAt = index.lastSavedAtOf(raw);
24552
25607
  } catch {
25608
+ lastSavedAt = null;
25609
+ }
25610
+ if (lastSavedAt === null) continue;
25611
+ const decision = retentionForUntimedRecord(lastSavedAt, ttl, now);
25612
+ if (decision.expired) {
25613
+ tx.pExpireAt(index.dataKey(id), 1, "NX");
25614
+ queued.push({ action: "expire", id });
25615
+ } else {
25616
+ tx.pExpireAt(index.dataKey(id), decision.expiresAt, "NX");
25617
+ queued.push({ action: "arm", id, expiresAt: decision.expiresAt });
24553
25618
  }
24554
- if (limit !== void 0 && entries2.length >= limit) break;
24555
25619
  }
24556
- return entries2;
24557
25620
  }
24558
- const ids = await this.client.sMembers(setKey);
24559
- if (ids.length === 0) return [];
24560
- const entries = [];
24561
- for (const id of ids) {
24562
- const raw = await this.client.get(dataKey(id));
24563
- if (raw == null) continue;
24564
- try {
24565
- entries.push(JSON.parse(raw));
24566
- } catch {
25621
+ const expiredIds = [];
25622
+ const armed = [];
25623
+ if (queued.length > 0) {
25624
+ const replies = await tx.exec();
25625
+ for (let i = 0; i < queued.length; i++) {
25626
+ if (Number(replies[i]) !== 1) continue;
25627
+ const entry = queued[i];
25628
+ if (entry.action === "index") result.indexed++;
25629
+ else if (entry.action === "expire") expiredIds.push(entry.id);
25630
+ else armed.push(entry);
24567
25631
  }
24568
25632
  }
24569
- entries.sort(sortCmp);
24570
- return limit ? entries.slice(0, limit) : entries;
25633
+ if (armed.length > 0) {
25634
+ const indexTx = this.client.multi();
25635
+ for (const { id, expiresAt } of armed) {
25636
+ indexTx.zAdd(index.expiry, { score: expiresAt, value: id }, { condition: "NX" });
25637
+ }
25638
+ await indexTx.exec();
25639
+ }
25640
+ result.armed = armed.length;
25641
+ result.expired = expiredIds.length;
25642
+ result.deadIndexEntries = dead.length;
25643
+ if (dead.length > 0 || expiredIds.length > 0) {
25644
+ await this.runPruneScript(index, [...dead, ...expiredIds]);
25645
+ }
25646
+ return result;
25647
+ }
25648
+ /**
25649
+ * Up to `count` members of the time sorted set strictly after `cursor`,
25650
+ * oldest first by `(score, member)`. The members sharing the cursor's score
25651
+ * are read in full and filtered by member; the rest start just above that
25652
+ * score. Neither read uses an offset, so entries removed or added
25653
+ * meanwhile cannot shift the walk.
25654
+ */
25655
+ async readRetentionBatch(index, cursor, count2) {
25656
+ if (cursor === void 0) {
25657
+ return this.client.zRangeWithScores(index.time, "-inf", "+inf", {
25658
+ BY: "SCORE",
25659
+ LIMIT: { offset: 0, count: count2 }
25660
+ });
25661
+ }
25662
+ const ties = (await this.client.zRangeWithScores(index.time, cursor.time, cursor.time, { BY: "SCORE" })).filter((m) => compareMembers(m.value, cursor.id) > 0);
25663
+ if (ties.length >= count2) return ties.slice(0, count2);
25664
+ const rest = await this.client.zRangeWithScores(index.time, `(${cursor.time}`, "+inf", {
25665
+ BY: "SCORE",
25666
+ LIMIT: { offset: 0, count: count2 - ties.length }
25667
+ });
25668
+ return [...ties, ...rest];
24571
25669
  }
24572
25670
  // ── Sessions (Studio introspection) ────────────────────────────────────
24573
25671
  async listSessions() {