@axlsdk/axl 0.27.1 → 0.27.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -348,7 +348,7 @@ var sharedGuard = typeof installedGuard === "object" && installedGuard !== null
348
348
  var buildPath = typeof __AXL_BUILD_PATH__ === "string" ? __AXL_BUILD_PATH__ : "<source>";
349
349
  var thisCopy = {
350
350
  path: buildPath.startsWith("file:") ? fileURLToPath(buildPath) : buildPath,
351
- version: true ? "0.27.1" : "unknown"
351
+ version: true ? "0.27.2" : "unknown"
352
352
  };
353
353
  function warnCrossCopy(other, joined) {
354
354
  if (other === thisCopy) return;
@@ -18814,6 +18814,21 @@ function codePointRank(unit) {
18814
18814
  import { writeFileSync, readFileSync, existsSync, mkdirSync, unlinkSync } from "fs";
18815
18815
  import { join } from "path";
18816
18816
  import { tmpdir } from "os";
18817
+
18818
+ // src/state/session-update.ts
18819
+ async function updateSessionThroughOverrides(store, storeName, sessionId, update) {
18820
+ warnDiagnosticOnce(
18821
+ `session_update_overridden\0${storeName}`,
18822
+ `${storeName}: this subclass overrides saveSession or getSession, so session turns go through them, and overlapping turns from different instances can overwrite each other. Override updateSession too to keep that protection.`,
18823
+ void 0,
18824
+ "docs/api-reference.md#several-app-instances-sharing-a-session"
18825
+ );
18826
+ const stored = await store.getSession(sessionId);
18827
+ const next = update(stored.length > 0 ? stored : null);
18828
+ if (next !== null) await store.saveSession(sessionId, next);
18829
+ }
18830
+
18831
+ // src/state/memory.ts
18817
18832
  function compareOldestFirst(a, b) {
18818
18833
  return compareExecutionsNewestFirst(b, a);
18819
18834
  }
@@ -18830,7 +18845,7 @@ function resolveMaxExecutions(value) {
18830
18845
  }
18831
18846
  var AWAIT_HUMAN_TEMP_DIR = join(tmpdir(), "axl-memory-store");
18832
18847
  var AWAIT_HUMAN_TEMP_FILE = join(AWAIT_HUMAN_TEMP_DIR, "await-human-state.json");
18833
- var MemoryStore = class {
18848
+ var MemoryStore = class _MemoryStore {
18834
18849
  checkpoints = /* @__PURE__ */ new Map();
18835
18850
  sessions = /* @__PURE__ */ new Map();
18836
18851
  sessionMeta = /* @__PURE__ */ new Map();
@@ -18882,6 +18897,14 @@ var MemoryStore = class {
18882
18897
  async saveSession(sessionId, history) {
18883
18898
  this.sessions.set(sessionId, structuredClone(normalizePersistedSessionHistory(history)));
18884
18899
  }
18900
+ async updateSession(sessionId, update) {
18901
+ if (this.saveSession !== _MemoryStore.prototype.saveSession || this.getSession !== _MemoryStore.prototype.getSession) {
18902
+ return updateSessionThroughOverrides(this, "MemoryStore", sessionId, update);
18903
+ }
18904
+ const stored = this.sessions.get(sessionId);
18905
+ const next = update(stored ? structuredClone(stored) : null);
18906
+ if (next !== null) await this.saveSession(sessionId, next);
18907
+ }
18885
18908
  async getSession(sessionId) {
18886
18909
  const history = this.sessions.get(sessionId);
18887
18910
  return history ? structuredClone(history) : [];
@@ -19305,7 +19328,7 @@ function rowToExecutionInfo(row) {
19305
19328
  }
19306
19329
  return normalizeStoredExecution(info);
19307
19330
  }
19308
- var SQLiteStore = class {
19331
+ var SQLiteStore = class _SQLiteStore {
19309
19332
  db;
19310
19333
  ttls;
19311
19334
  now;
@@ -19539,6 +19562,17 @@ var SQLiteStore = class {
19539
19562
  );
19540
19563
  stmt.run(sessionId, JSON.stringify(normalizePersistedSessionHistory(history)));
19541
19564
  }
19565
+ async updateSession(sessionId, update) {
19566
+ if (this.saveSession !== _SQLiteStore.prototype.saveSession || this.getSession !== _SQLiteStore.prototype.getSession) {
19567
+ return updateSessionThroughOverrides(this, "SQLiteStore", sessionId, update);
19568
+ }
19569
+ this.db.transaction(() => {
19570
+ const row = this.db.prepare("SELECT history FROM sessions WHERE session_id = ?").get(sessionId);
19571
+ const next = update(row ? safeJsonParse(row.history) ?? [] : null);
19572
+ if (next === null) return;
19573
+ this.db.prepare("INSERT OR REPLACE INTO sessions (session_id, history) VALUES (?, ?)").run(sessionId, JSON.stringify(normalizePersistedSessionHistory(next)));
19574
+ }).immediate();
19575
+ }
19542
19576
  async getSession(sessionId) {
19543
19577
  const stmt = this.db.prepare("SELECT history FROM sessions WHERE session_id = ?");
19544
19578
  const row = stmt.get(sessionId);
@@ -19873,10 +19907,10 @@ var Session = class _Session {
19873
19907
  );
19874
19908
  }
19875
19909
  async sendImpl(workflowName, input, signal, events) {
19876
- const { history, metadata } = await this.prepareHistory(input);
19910
+ const { history, metadata, base } = await this.prepareHistory(input);
19877
19911
  try {
19878
19912
  const result = await this.runtime.execute(workflowName, input, { metadata, signal, events });
19879
- await this.commitHistory(history, result);
19913
+ await this.commitHistory(history, base, result);
19880
19914
  return result;
19881
19915
  } finally {
19882
19916
  clearSessionInput(history);
@@ -19892,6 +19926,8 @@ var Session = class _Session {
19892
19926
  async prepareHistory(input) {
19893
19927
  const preparedInput = prepareSessionInput(input);
19894
19928
  const history = await this.store.getSession(this.sessionId);
19929
+ const storedJson = JSON.stringify(history);
19930
+ const hadHistory = history.length > 0;
19895
19931
  let cachedSummary = await this.store.getSessionMeta(this.sessionId, "summaryCache");
19896
19932
  const maxMessages = this.options.history?.maxMessages;
19897
19933
  if (maxMessages && history.length > maxMessages) {
@@ -19906,13 +19942,17 @@ var Session = class _Session {
19906
19942
  summaryModel,
19907
19943
  cachedSummary ? { previousSummary: cachedSummary } : void 0
19908
19944
  );
19909
- await this.store.saveSessionMeta(this.sessionId, "summaryCache", summary);
19945
+ const stored = JSON.stringify(await this.store.getSession(this.sessionId));
19946
+ if (stored === storedJson) {
19947
+ await this.store.saveSessionMeta(this.sessionId, "summaryCache", summary);
19948
+ }
19910
19949
  cachedSummary = summary;
19911
19950
  }
19912
19951
  const trimmed = history.slice(-maxMessages);
19913
19952
  history.length = 0;
19914
19953
  history.push(...trimmed.map(withoutAnthropicThinking));
19915
19954
  }
19955
+ const base = { storedJson, hadHistory, firstOwnMessage: history.length };
19916
19956
  const userMessage = { role: "user", content: preparedInput.content };
19917
19957
  history.push(userMessage);
19918
19958
  if (this.options.deduplicateInput !== false && preparedInput.normalizedInput !== void 0) {
@@ -19926,13 +19966,13 @@ var Session = class _Session {
19926
19966
  // agent-scoped ask-summary records the runtime would otherwise cache.
19927
19967
  ...this.options.persist === false ? { sessionPersist: false } : {}
19928
19968
  };
19929
- return { history, metadata };
19969
+ return { history, metadata, base };
19930
19970
  }
19931
19971
  /** Push the assistant reply (if `executeAgentCall` didn't already) and
19932
19972
  * persist. Shared by `sendImpl` and `streamImpl`. The fallback assistant
19933
19973
  * push has no agent context and so leaves `agent` undefined — see the
19934
19974
  * `ChatMessage.agent` field doc. */
19935
- async commitHistory(history, result) {
19975
+ async commitHistory(history, base, result) {
19936
19976
  const lastMsg = history[history.length - 1];
19937
19977
  if (!(lastMsg && lastMsg.role === "assistant")) {
19938
19978
  history.push({
@@ -19940,8 +19980,35 @@ var Session = class _Session {
19940
19980
  content: typeof result === "string" ? result : JSON.stringify(result)
19941
19981
  });
19942
19982
  }
19943
- if (this.options.persist !== false) {
19983
+ if (this.options.persist === false) return;
19984
+ if (!this.store.updateSession) {
19944
19985
  await this.store.saveSession(this.sessionId, history);
19986
+ return;
19987
+ }
19988
+ let outcome = "saved";
19989
+ await this.store.updateSession(this.sessionId, (current) => {
19990
+ if (current === null) {
19991
+ outcome = base.hadHistory ? "discarded" : "saved";
19992
+ return base.hadHistory ? null : history;
19993
+ }
19994
+ if (JSON.stringify(current) === base.storedJson) {
19995
+ outcome = "saved";
19996
+ return history;
19997
+ }
19998
+ outcome = "merged";
19999
+ return [...current, ...history.slice(base.firstOwnMessage).map(withoutAnthropicThinking)];
20000
+ });
20001
+ if (outcome === "merged") this.notify("session_turns_merged");
20002
+ else if (outcome === "discarded") this.notify("session_turn_discarded");
20003
+ }
20004
+ /** The turn is already saved, so a listener that throws can't be allowed to fail it. */
20005
+ notify(event) {
20006
+ try {
20007
+ this.runtime.emit(event, { sessionId: this.sessionId });
20008
+ } catch (err) {
20009
+ console.error(
20010
+ `[axl] A ${event} listener failed: ${err instanceof Error ? err.message : String(err)}`
20011
+ );
19945
20012
  }
19946
20013
  }
19947
20014
  async stream(workflowName, input, options) {
@@ -19969,10 +20036,12 @@ var Session = class _Session {
19969
20036
  }
19970
20037
  async streamImpl(workflowName, input, resolveReady, rejectReady, signal, events) {
19971
20038
  let history;
20039
+ let base;
19972
20040
  let axlStream;
19973
20041
  try {
19974
20042
  const prepared = await this.prepareHistory(input);
19975
20043
  history = prepared.history;
20044
+ base = prepared.base;
19976
20045
  axlStream = this.runtime.stream(workflowName, input, {
19977
20046
  metadata: prepared.metadata,
19978
20047
  signal,
@@ -19986,7 +20055,7 @@ var Session = class _Session {
19986
20055
  resolveReady(axlStream);
19987
20056
  try {
19988
20057
  const completion = axlStream.promise.then(
19989
- (result) => this.commitHistory(history, result).catch((err) => {
20058
+ (result) => this.commitHistory(history, base, result).catch((err) => {
19990
20059
  const error = err instanceof Error ? err.message : String(err);
19991
20060
  try {
19992
20061
  this.runtime.emit("error", {
@@ -24545,6 +24614,7 @@ async function* stringStreamFromEvents(source, opts) {
24545
24614
  }
24546
24615
 
24547
24616
  // src/state/redis.ts
24617
+ import { createHash as createHash2 } from "crypto";
24548
24618
  var PRUNE_HISTORY_INDEX_SCRIPT = `
24549
24619
  local now = tonumber(ARGV[1])
24550
24620
  local removed = 0
@@ -24564,6 +24634,34 @@ for i = 2, #ARGV do
24564
24634
  end
24565
24635
  return removed
24566
24636
  `;
24637
+ var PRUNE_SESSION_IDS_SCRIPT = `
24638
+ local removed = {}
24639
+ for i, id in ipairs(ARGV) do
24640
+ if redis.call('EXISTS', KEYS[i * 2]) == 0 then
24641
+ redis.call('SREM', KEYS[1], id)
24642
+ redis.call('DEL', KEYS[i * 2 + 1])
24643
+ removed[#removed + 1] = id
24644
+ end
24645
+ end
24646
+ return removed
24647
+ `;
24648
+ var SESSION_ID_BATCH = 100;
24649
+ var SESSION_SWEEP_COUNT = 50;
24650
+ var UPDATE_SESSION_SCRIPT = `
24651
+ local stored = redis.call('GET', KEYS[1])
24652
+ local actual = stored and redis.sha1hex(stored) or ''
24653
+ if actual ~= ARGV[1] then
24654
+ return 0
24655
+ end
24656
+ if ARGV[3] ~= '' then
24657
+ redis.call('SET', KEYS[1], ARGV[2], 'EX', ARGV[3])
24658
+ else
24659
+ redis.call('SET', KEYS[1], ARGV[2])
24660
+ end
24661
+ redis.call('SADD', KEYS[2], ARGV[4])
24662
+ return 1
24663
+ `;
24664
+ var UPDATE_SESSION_ATTEMPTS = 10;
24567
24665
  function compareMembers(a, b) {
24568
24666
  return Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
24569
24667
  }
@@ -24585,7 +24683,6 @@ function abandonConnection(client) {
24585
24683
  client.destroy?.();
24586
24684
  } catch {
24587
24685
  }
24588
- client.unref?.();
24589
24686
  }
24590
24687
  function errorMessage(error) {
24591
24688
  return error instanceof Error ? error.message : String(error);
@@ -24614,18 +24711,106 @@ function connectionEventReporter(client, onConnectionEvent) {
24614
24711
  }
24615
24712
  };
24616
24713
  }
24714
+ var LOOP_STALL_TOLERANCE_MS = 100;
24715
+ var LOOP_STALL_GRACE_MS = 1e3;
24716
+ function watchResponses(client, timeoutMs, reportConnectionEvent) {
24717
+ const checkEveryMs = timeoutMs / 3;
24718
+ let timer;
24719
+ let stopped = false;
24720
+ const schedule = (run, delayMs) => {
24721
+ timer = setTimeout(run, delayMs);
24722
+ timer.unref?.();
24723
+ };
24724
+ const check = () => {
24725
+ if (client.isReady === false) {
24726
+ schedule(check, checkEveryMs);
24727
+ return;
24728
+ }
24729
+ let settled = false;
24730
+ const onReply = () => {
24731
+ if (settled || stopped) return;
24732
+ settled = true;
24733
+ clearTimeout(timer);
24734
+ schedule(check, checkEveryMs);
24735
+ };
24736
+ const expireAfter = (delayMs, mayWaitForBlockedLoop) => {
24737
+ const dueAt = Date.now() + delayMs;
24738
+ schedule(() => {
24739
+ if (settled || stopped) return;
24740
+ if (mayWaitForBlockedLoop && Date.now() - dueAt > LOOP_STALL_TOLERANCE_MS) {
24741
+ expireAfter(LOOP_STALL_GRACE_MS, false);
24742
+ return;
24743
+ }
24744
+ reset();
24745
+ }, delayMs);
24746
+ };
24747
+ const reset = () => {
24748
+ settled = true;
24749
+ if (client.isReady === false) {
24750
+ schedule(check, checkEveryMs);
24751
+ return;
24752
+ }
24753
+ client.destroy?.();
24754
+ client.connect().catch(() => {
24755
+ });
24756
+ reportConnectionEvent({
24757
+ type: "error",
24758
+ error: new Error(`Redis did not answer within ${timeoutMs}ms; reconnecting.`)
24759
+ });
24760
+ schedule(check, checkEveryMs);
24761
+ };
24762
+ client.ping().then(onReply, onReply);
24763
+ expireAfter(timeoutMs, true);
24764
+ };
24765
+ schedule(check, checkEveryMs);
24766
+ return () => {
24767
+ stopped = true;
24768
+ clearTimeout(timer);
24769
+ };
24770
+ }
24771
+ async function connectWithin(connecting, timeoutMs) {
24772
+ let timer;
24773
+ try {
24774
+ await Promise.race([
24775
+ connecting,
24776
+ new Promise((_resolve, reject) => {
24777
+ timer = setTimeout(
24778
+ () => reject(
24779
+ new AxlError(
24780
+ "REDIS_UNAVAILABLE",
24781
+ `RedisStore: Redis did not finish connecting within ${timeoutMs}ms.`
24782
+ )
24783
+ ),
24784
+ timeoutMs
24785
+ );
24786
+ })
24787
+ ]);
24788
+ } finally {
24789
+ clearTimeout(timer);
24790
+ }
24791
+ }
24617
24792
  async function connectClient(createClient, opts) {
24618
- let established = false;
24793
+ const inBackground = opts.waitForConnection === false;
24794
+ let established = inBackground;
24795
+ let everFailed = false;
24796
+ const responseTimeoutMs = opts.responseTimeoutMs;
24619
24797
  const client = createClient({
24620
24798
  ...opts.url ? { url: opts.url } : {},
24621
24799
  // A command accepted just before a drop rejects instead of waiting for
24622
24800
  // the reconnect.
24623
24801
  disableOfflineQueue: true,
24624
- socket: { reconnectStrategy: (retries) => established ? reconnectDelayMs(retries) : false }
24802
+ socket: {
24803
+ reconnectStrategy: (retries) => established ? reconnectDelayMs(retries) : false,
24804
+ // Bounds a reconnect's handshake, where the response watch doesn't run.
24805
+ // Twice the timeout so that on an open connection the watch acts first:
24806
+ // this timer fires after any long event-loop block, which the watch allows for.
24807
+ ...responseTimeoutMs !== void 0 ? { socketTimeout: responseTimeoutMs * 2 } : {}
24808
+ }
24625
24809
  });
24626
24810
  const reportConnectionEvent = connectionEventReporter(client, opts.onConnectionEvent);
24627
24811
  client.on("error", (error) => {
24628
24812
  if (!established || client.isOpen === false) return;
24813
+ everFailed = true;
24629
24814
  reportConnectionEvent({
24630
24815
  type: "error",
24631
24816
  error: error instanceof Error ? error : new Error(String(error))
@@ -24637,12 +24822,32 @@ async function connectClient(createClient, opts) {
24637
24822
  client.on("ready", () => {
24638
24823
  if (!established) {
24639
24824
  established = true;
24640
- } else if (client.isOpen !== false) {
24825
+ } else if (client.isOpen !== false && (!inBackground || everFailed)) {
24641
24826
  reportConnectionEvent({ type: "reconnected" });
24642
24827
  }
24643
24828
  });
24644
- await client.connect();
24645
- return client;
24829
+ if (inBackground) {
24830
+ client.connect().catch(() => {
24831
+ });
24832
+ return {
24833
+ client,
24834
+ stopResponseWatch: watchResponses(client, responseTimeoutMs, reportConnectionEvent)
24835
+ };
24836
+ }
24837
+ if (responseTimeoutMs === void 0) {
24838
+ await client.connect();
24839
+ return { client };
24840
+ }
24841
+ try {
24842
+ await connectWithin(client.connect(), responseTimeoutMs);
24843
+ } catch (error) {
24844
+ abandonConnection(client);
24845
+ throw error;
24846
+ }
24847
+ return {
24848
+ client,
24849
+ stopResponseWatch: watchResponses(client, responseTimeoutMs, reportConnectionEvent)
24850
+ };
24646
24851
  }
24647
24852
  var RedisStore = class _RedisStore {
24648
24853
  constructor(connection, keyPrefix, ttls) {
@@ -24650,13 +24855,19 @@ var RedisStore = class _RedisStore {
24650
24855
  this.keyPrefix = keyPrefix;
24651
24856
  this.ttls = ttls;
24652
24857
  }
24858
+ /** Set when `close()` starts, while the connection is still open. */
24859
+ closing = false;
24860
+ /** Where the next save's check of listed sessions picks up. */
24861
+ sessionSweepCursor = "0";
24862
+ /** Overlapping saves would all check the same page, so only one checks at a time. */
24863
+ sessionSweepRunning = false;
24653
24864
  /**
24654
24865
  * Whether the store can send commands right now, for a health check or for
24655
24866
  * telling an outage from other failures when a call rejects. `false` while
24656
24867
  * reconnecting and after `close()`.
24657
24868
  */
24658
24869
  get isConnected() {
24659
- return this.connection.isOpen !== false && this.connection.isReady !== false;
24870
+ return !this.closing && this.connection.isOpen !== false && this.connection.isReady !== false;
24660
24871
  }
24661
24872
  /**
24662
24873
  * The client for sending a command, failing immediately while disconnected.
@@ -24670,6 +24881,7 @@ var RedisStore = class _RedisStore {
24670
24881
  * "The client is closed" rejection.
24671
24882
  */
24672
24883
  get client() {
24884
+ if (this.closing) throw new Error("The client is closed");
24673
24885
  if (this.connection.isOpen !== false && this.connection.isReady === false) {
24674
24886
  throw new AxlError(
24675
24887
  "REDIS_UNAVAILABLE",
@@ -24685,6 +24897,8 @@ var RedisStore = class _RedisStore {
24685
24897
  * instance needs no constructor state.
24686
24898
  */
24687
24899
  prunesInFlight;
24900
+ /** Stops the `responseTimeoutMs` health checks; unset when the option is off. */
24901
+ stopResponseWatch;
24688
24902
  /**
24689
24903
  * Supports `listExecutions(limit, { cursor })`. A getter rather than a
24690
24904
  * field so it lives on the prototype.
@@ -24734,6 +24948,17 @@ var RedisStore = class _RedisStore {
24734
24948
  'RedisStore: keyPrefix cannot be empty string. Omit it for the default "axl:" prefix.'
24735
24949
  );
24736
24950
  }
24951
+ const responseTimeoutMs = opts.responseTimeoutMs;
24952
+ if (responseTimeoutMs !== void 0 && !(Number.isFinite(responseTimeoutMs) && responseTimeoutMs > 0)) {
24953
+ throw new Error(
24954
+ `RedisStore: responseTimeoutMs must be a positive number of milliseconds, got ${responseTimeoutMs}.`
24955
+ );
24956
+ }
24957
+ if (opts.waitForConnection === false && responseTimeoutMs === void 0) {
24958
+ throw new Error(
24959
+ "RedisStore: waitForConnection: false requires responseTimeoutMs, so a server that accepts the connection but never answers cannot leave it connecting forever."
24960
+ );
24961
+ }
24737
24962
  let createClient;
24738
24963
  try {
24739
24964
  const mod = __require("redis");
@@ -24747,7 +24972,7 @@ var RedisStore = class _RedisStore {
24747
24972
  if (err instanceof Error && err.message.includes("createClient")) throw err;
24748
24973
  throw new Error("redis is required for RedisStore. Install it with: npm install redis");
24749
24974
  }
24750
- const client = await connectClient(createClient, opts);
24975
+ const { client, stopResponseWatch } = await connectClient(createClient, opts);
24751
24976
  const resolveCategory = (override) => {
24752
24977
  if (override === null) return null;
24753
24978
  if (typeof override === "number") return override;
@@ -24769,18 +24994,48 @@ var RedisStore = class _RedisStore {
24769
24994
  streamingEvents: resolveStreamingEvents(opts.ttls?.streamingEvents)
24770
24995
  };
24771
24996
  const store = new _RedisStore(client, keyPrefix, effectiveTtls);
24997
+ store.stopResponseWatch = stopResponseWatch;
24772
24998
  warnIfRedisExecutionRetentionUnset(opts);
24999
+ if (opts.waitForConnection === false) {
25000
+ if (!opts.skipMigration) store.backfillOnceConnected(client);
25001
+ return store;
25002
+ }
24773
25003
  if (!opts.skipMigration) {
24774
25004
  try {
24775
25005
  await store.backfillExecutionIndex();
24776
25006
  await store.backfillEvalIndex();
24777
25007
  } catch (error) {
25008
+ stopResponseWatch?.();
24778
25009
  abandonConnection(client);
24779
25010
  throw error;
24780
25011
  }
24781
25012
  }
24782
25013
  return store;
24783
25014
  }
25015
+ /**
25016
+ * Run the startup backfill when a connection becomes ready, until it succeeds.
25017
+ * Nothing is waiting on it, and listings work without it, so a failure is
25018
+ * reported and the next connection tries again.
25019
+ */
25020
+ backfillOnceConnected(client) {
25021
+ let state = "waiting";
25022
+ client.on("ready", () => {
25023
+ if (state !== "waiting") return;
25024
+ state = "running";
25025
+ this.backfillExecutionIndex().then(() => this.backfillEvalIndex()).then(
25026
+ () => {
25027
+ state = "done";
25028
+ },
25029
+ (err) => {
25030
+ state = "waiting";
25031
+ if (client.isReady === false || client.isOpen === false) return;
25032
+ console.warn(
25033
+ `[axl] RedisStore: the startup index backfill failed (${errorMessage(err)}). Listings stay correct meanwhile.`
25034
+ );
25035
+ }
25036
+ );
25037
+ });
25038
+ }
24784
25039
  /**
24785
25040
  * Backfill the execution-history sorted-set from the legacy ID set.
24786
25041
  * Idempotent — runs only if the ZSET is missing entries the SET has.
@@ -24788,36 +25043,30 @@ var RedisStore = class _RedisStore {
24788
25043
  * `skipMigration: true`.
24789
25044
  */
24790
25045
  async backfillExecutionIndex() {
24791
- await this.backfillHistoryIndex(
24792
- this.execHistorySetKey(),
24793
- this.execHistoryZsetKey(),
24794
- (id) => this.execHistoryKey(id),
24795
- (raw) => {
24796
- const exec = JSON.parse(raw);
24797
- return typeof exec.startedAt === "number" ? { score: exec.startedAt, value: exec.executionId } : null;
24798
- },
24799
- "execution-history"
24800
- );
25046
+ await this.backfillHistoryIndex(this.historyIndex("execution"), (raw) => {
25047
+ const exec = JSON.parse(raw);
25048
+ return typeof exec.startedAt === "number" ? { score: exec.startedAt, value: exec.executionId } : null;
25049
+ });
24801
25050
  }
24802
25051
  /** Backfill the eval-history sorted-set. Symmetric to `backfillExecutionIndex`. */
24803
25052
  async backfillEvalIndex() {
24804
- await this.backfillHistoryIndex(
24805
- this.evalHistorySetKey(),
24806
- this.evalHistoryZsetKey(),
24807
- (id) => this.evalHistoryKey(id),
24808
- (raw) => {
24809
- const entry = JSON.parse(raw);
24810
- return typeof entry.timestamp === "number" ? { score: entry.timestamp, value: entry.id } : null;
24811
- },
24812
- "eval-history"
24813
- );
25053
+ await this.backfillHistoryIndex(this.historyIndex("eval"), (raw) => {
25054
+ const entry = JSON.parse(raw);
25055
+ return typeof entry.timestamp === "number" ? { score: entry.timestamp, value: entry.id } : null;
25056
+ });
24814
25057
  }
24815
25058
  /**
24816
25059
  * Shared implementation for both backfill paths. Reads SET members,
24817
25060
  * batch-MGETs their data blobs in chunks, and ZADDs each into the new
24818
25061
  * sorted-set inside a single MULTI per chunk.
25062
+ *
25063
+ * Ids with no data blob are removed from the id set. Left there, the set stays
25064
+ * larger than the sorted set, so every startup repeats this walk and listing
25065
+ * stays on its slow path.
24819
25066
  */
24820
- async backfillHistoryIndex(setKey, zsetKey, dataKey, scoreOf, label) {
25067
+ async backfillHistoryIndex(index, scoreOf) {
25068
+ const setKey = index.ids;
25069
+ const zsetKey = index.time;
24821
25070
  const [setSize, zsetSize] = await Promise.all([
24822
25071
  this.client.sCard(setKey),
24823
25072
  this.client.zCard(zsetKey)
@@ -24828,9 +25077,10 @@ var RedisStore = class _RedisStore {
24828
25077
  const CHUNK_SIZE = 500;
24829
25078
  let migrated = 0;
24830
25079
  let skipped = 0;
25080
+ const missing = [];
24831
25081
  for (let i = 0; i < allIds.length; i += CHUNK_SIZE) {
24832
25082
  const chunk = allIds.slice(i, i + CHUNK_SIZE);
24833
- const dataKeys = chunk.map((id) => dataKey(id));
25083
+ const dataKeys = chunk.map((id) => index.dataKey(id));
24834
25084
  const raws = await this.client.mGet(dataKeys);
24835
25085
  const tx = this.client.multi();
24836
25086
  let queuedInChunk = 0;
@@ -24838,6 +25088,7 @@ var RedisStore = class _RedisStore {
24838
25088
  const raw = raws[j];
24839
25089
  if (raw == null) {
24840
25090
  skipped++;
25091
+ missing.push(chunk[j]);
24841
25092
  continue;
24842
25093
  }
24843
25094
  try {
@@ -24855,9 +25106,14 @@ var RedisStore = class _RedisStore {
24855
25106
  }
24856
25107
  if (queuedInChunk > 0) await tx.exec();
24857
25108
  }
25109
+ if (missing.length > 0) {
25110
+ await this.runPruneScript(index, missing).catch(
25111
+ (err) => this.warnPruneFailed(index, err)
25112
+ );
25113
+ }
24858
25114
  if (migrated > 0 || skipped > 0) {
24859
25115
  console.log(
24860
- `[axl] RedisStore: backfilled ${migrated} ${label} entries into sorted-set index` + (skipped > 0 ? ` (${skipped} skipped \u2014 missing or malformed)` : "")
25116
+ `[axl] RedisStore: backfilled ${migrated} ${index.label} entries into sorted-set index` + (skipped > 0 ? ` (${skipped} skipped \u2014 missing or malformed)` : "")
24861
25117
  );
24862
25118
  }
24863
25119
  }
@@ -24983,6 +25239,51 @@ var RedisStore = class _RedisStore {
24983
25239
  JSON.stringify(normalizePersistedSessionHistory(history)),
24984
25240
  setOptions
24985
25241
  ).sAdd(this.sessionIdsKey(), sessionId).exec();
25242
+ await this.sweepSessionIds();
25243
+ }
25244
+ async updateSession(sessionId, update) {
25245
+ if (this.saveSession !== _RedisStore.prototype.saveSession || this.getSession !== _RedisStore.prototype.getSession) {
25246
+ return updateSessionThroughOverrides(this, "RedisStore", sessionId, update);
25247
+ }
25248
+ const key = this.sessionKey(sessionId);
25249
+ const ttl = this.ttlFor("session");
25250
+ for (let attempt = 0; attempt < UPDATE_SESSION_ATTEMPTS; attempt++) {
25251
+ const raw = await this.client.get(key);
25252
+ const next = update(raw ? JSON.parse(raw) : null);
25253
+ if (next === null) return;
25254
+ let written;
25255
+ try {
25256
+ written = await this.client.eval(UPDATE_SESSION_SCRIPT, {
25257
+ keys: [key, this.sessionIdsKey()],
25258
+ arguments: [
25259
+ raw === null ? "" : createHash2("sha1").update(raw).digest("hex"),
25260
+ JSON.stringify(normalizePersistedSessionHistory(next)),
25261
+ ttl === void 0 ? "" : String(ttl),
25262
+ sessionId
25263
+ ]
25264
+ });
25265
+ } catch (err) {
25266
+ if (!errorMessage(err).includes("NOPERM")) throw err;
25267
+ warnDiagnosticOnce(
25268
+ "redis_update_session_denied",
25269
+ "RedisStore: saving a session turn safely runs a Lua script, which this Redis user may not run, so it was saved without that protection. Overlapping turns from different instances can overwrite each other until the user may run EVAL.",
25270
+ void 0,
25271
+ "docs/api-reference.md#concurrency-and-races"
25272
+ );
25273
+ await this.saveSession(sessionId, next);
25274
+ return;
25275
+ }
25276
+ if (Number(written) === 1) {
25277
+ await this.sweepSessionIds();
25278
+ return;
25279
+ }
25280
+ const backoffMs = Math.min(20 * 2 ** attempt, 200);
25281
+ await new Promise((resolve2) => setTimeout(resolve2, Math.random() * backoffMs));
25282
+ }
25283
+ throw new AxlError(
25284
+ "SESSION_CONFLICT",
25285
+ `RedisStore: gave up saving session "${sessionId}" after ${UPDATE_SESSION_ATTEMPTS} conflicting writes from other instances.`
25286
+ );
24986
25287
  }
24987
25288
  async getSession(sessionId) {
24988
25289
  const raw = await this.client.get(this.sessionKey(sessionId));
@@ -25384,17 +25685,20 @@ var RedisStore = class _RedisStore {
25384
25685
  try {
25385
25686
  await this.pruneHistory(index);
25386
25687
  } catch (err) {
25387
- if (this.connection.isReady === false) return;
25388
- warnDiagnosticOnce(
25389
- `redis_history_prune_failed\0${index.kind}`,
25390
- `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.`,
25391
- void 0,
25392
- "docs/api-reference.md#redisstore-options"
25393
- );
25688
+ this.warnPruneFailed(index, err);
25394
25689
  } finally {
25395
25690
  inFlight.delete(index.kind);
25396
25691
  }
25397
25692
  }
25693
+ warnPruneFailed(index, err) {
25694
+ if (!this.isConnected) return;
25695
+ warnDiagnosticOnce(
25696
+ `redis_history_prune_failed\0${index.kind}`,
25697
+ `RedisStore: removing expired ${index.label} index entries failed (${err instanceof Error ? err.message : String(err)}). The cleanup is tried again later, and listings stay correct meanwhile. The cleanup runs a Lua script, so the Redis user needs permission to run EVAL.`,
25698
+ void 0,
25699
+ "docs/api-reference.md#redisstore-options"
25700
+ );
25701
+ }
25398
25702
  async pruneHistory(index) {
25399
25703
  const candidates = await this.client.zRange(index.expiry, "-inf", Date.now(), {
25400
25704
  BY: "SCORE",
@@ -25549,7 +25853,69 @@ var RedisStore = class _RedisStore {
25549
25853
  }
25550
25854
  // ── Sessions (Studio introspection) ────────────────────────────────────
25551
25855
  async listSessions() {
25552
- return this.client.sMembers(this.sessionIdsKey());
25856
+ const live = /* @__PURE__ */ new Set();
25857
+ let cursor = "0";
25858
+ do {
25859
+ const page = await this.client.sScan(this.sessionIdsKey(), cursor, {
25860
+ COUNT: SESSION_ID_BATCH
25861
+ });
25862
+ cursor = page.cursor;
25863
+ const removed = new Set(await this.pruneSessionIds(page.members));
25864
+ for (const id of page.members) if (!removed.has(id)) live.add(id);
25865
+ } while (cursor !== "0");
25866
+ return [...live];
25867
+ }
25868
+ /**
25869
+ * Check a few listed sessions after a save, so ids of expired sessions go
25870
+ * away for apps that never list. Sessions without a TTL don't expire, so
25871
+ * there's nothing to do. The save has already succeeded, so failing here
25872
+ * can't fail it.
25873
+ */
25874
+ async sweepSessionIds() {
25875
+ if (this.ttlFor("session") === void 0 || this.sessionSweepRunning) return;
25876
+ this.sessionSweepRunning = true;
25877
+ try {
25878
+ const page = await this.client.sScan(this.sessionIdsKey(), this.sessionSweepCursor, {
25879
+ COUNT: SESSION_SWEEP_COUNT
25880
+ });
25881
+ this.sessionSweepCursor = page.cursor;
25882
+ await this.pruneSessionIds(page.members);
25883
+ } catch (err) {
25884
+ this.warnSessionPruneFailed(err);
25885
+ } finally {
25886
+ this.sessionSweepRunning = false;
25887
+ }
25888
+ }
25889
+ /** Returns the ids removed. Without permission to run the script, removes none and warns. */
25890
+ async pruneSessionIds(ids) {
25891
+ const removed = [];
25892
+ for (let start = 0; start < ids.length; start += SESSION_ID_BATCH) {
25893
+ const batch = ids.slice(start, start + SESSION_ID_BATCH);
25894
+ try {
25895
+ const result = await this.client.eval(PRUNE_SESSION_IDS_SCRIPT, {
25896
+ keys: [
25897
+ this.sessionIdsKey(),
25898
+ ...batch.flatMap((id) => [this.sessionKey(id), this.sessionMetaKey(id)])
25899
+ ],
25900
+ arguments: batch
25901
+ });
25902
+ removed.push(...result);
25903
+ } catch (err) {
25904
+ if (!errorMessage(err).includes("NOPERM")) throw err;
25905
+ this.warnSessionPruneFailed(err);
25906
+ return removed;
25907
+ }
25908
+ }
25909
+ return removed;
25910
+ }
25911
+ warnSessionPruneFailed(err) {
25912
+ if (!this.isConnected) return;
25913
+ warnDiagnosticOnce(
25914
+ "redis_session_ids_prune_failed",
25915
+ `RedisStore: removing expired session ids failed (${errorMessage(err)}), so listSessions can return sessions that have expired. The cleanup runs a Lua script, so the Redis user needs permission to run EVAL.`,
25916
+ void 0,
25917
+ "docs/api-reference.md#redisstore-options"
25918
+ );
25553
25919
  }
25554
25920
  // ── Streaming events (for state.persist: 'streaming') ──────────────────
25555
25921
  async appendStreamingEvents(executionId, events) {
@@ -25590,30 +25956,33 @@ var RedisStore = class _RedisStore {
25590
25956
  * better-sqlite3's `close()` are both no-ops the second time; node-redis
25591
25957
  * instead rejects with "The client is closed", so absorb that here.
25592
25958
  *
25593
- * Bounded, so a shutdown can't hang on Redis. `QUIT` lets queued commands
25594
- * finish on a healthy connection, but node-redis never settles it if the
25595
- * socket closes first, and a stalled server may never answer it. While
25596
- * disconnected it couldn't be sent at all.
25959
+ * Bounded, so a shutdown can't hang on Redis. Replies arrive in order, so a
25960
+ * reply to this PING means every earlier command has finished.
25961
+ *
25962
+ * Not `quit()`: it marks the client closed before the server answers, and
25963
+ * `destroy()` then throws without touching the socket, so a stalled server's
25964
+ * socket could never be closed. There's no other public way to close it. A
25965
+ * closed socket is all Redis needs to clean up, so nothing is lost.
25597
25966
  */
25598
25967
  async close() {
25968
+ this.closing = true;
25969
+ this.stopResponseWatch?.();
25599
25970
  if (this.connection.isOpen === false) return;
25600
- if (this.connection.isReady === false) {
25601
- abandonConnection(this.connection);
25602
- return;
25603
- }
25604
- let timer;
25605
- try {
25606
- await Promise.race([
25607
- this.connection.quit(),
25608
- new Promise((_resolve, reject) => {
25609
- timer = setTimeout(() => reject(new Error("QUIT timed out")), CLOSE_TIMEOUT_MS);
25610
- })
25611
- ]);
25612
- } catch {
25613
- abandonConnection(this.connection);
25614
- } finally {
25615
- clearTimeout(timer);
25971
+ if (this.connection.isReady !== false) {
25972
+ let timer;
25973
+ try {
25974
+ await Promise.race([
25975
+ this.connection.ping(),
25976
+ new Promise((_resolve, reject) => {
25977
+ timer = setTimeout(() => reject(new Error("PING timed out")), CLOSE_TIMEOUT_MS);
25978
+ })
25979
+ ]);
25980
+ } catch {
25981
+ } finally {
25982
+ clearTimeout(timer);
25983
+ }
25616
25984
  }
25985
+ abandonConnection(this.connection);
25617
25986
  }
25618
25987
  async deleteCheckpoints(executionId) {
25619
25988
  await this.client.del(this.checkpointKey(executionId));