Skip to content

Implement SessionMap #24

@alexey-pelykh

Description

@alexey-pelykh

Summary

Implement a file-backed session store that maps channel conversations to CLI runtime session IDs, enabling session continuity across messaging channels. This is consumed by ChannelBridge (#32) to resume agent sessions via the --resume / --session flags.

File: src/middleware/session-map.ts
Test: src/middleware/session-map.test.ts
Depends on: types module (PR #4, merged)

Purpose

When a user sends a message on Telegram/Discord/Slack/etc., ChannelBridge needs to determine whether this is a new conversation or a continuation. SessionMap provides this mapping:

Channel message arrives
  → SessionMap.get(key)        // key = channelId:userId:threadId
  → Found & not expired?       // Yes → pass sessionId to runtime
                                // No  → runtime creates new session
  → Runtime returns sessionId
  → SessionMap.set(key, sessionId)  // persist for future messages

API Surface

/** Composite key for session lookup. */
export type SessionKey = {
  channelId: string;
  userId: string;
  threadId?: string | undefined;
};

/** Stored session entry. */
export type SessionEntry = {
  /** CLI runtime session ID (e.g., Claude session UUID, Codex thread ID). */
  sessionId: string;
  /** Epoch milliseconds of last access (set/get). */
  lastAccessMs: number;
};

/**
 * File-backed session store mapping channel conversations to CLI runtime session IDs.
 *
 * Design decisions:
 * - No in-memory cache: every get/set/delete reads from and writes to disk
 * - Correctness over performance for the expected low-frequency session lookup pattern
 * - Lazy TTL eviction: expired entries are invisible on get(), evicted on next set()
 * - Atomic writes via write-to-temp + rename pattern
 */
export class SessionMap {
  /**
   * @param directory - Directory where the session file is stored
   * @param ttlMs - Time-to-live in milliseconds (default: 7 days = 604_800_000)
   */
  constructor(directory: string, ttlMs?: number);

  /** Get session ID for a channel conversation. Returns undefined if not found or expired. */
  get(key: SessionKey): Promise<string | undefined>;

  /** Store or update a session ID for a channel conversation. Updates lastAccessMs. */
  set(key: SessionKey, sessionId: string): Promise<void>;

  /** Delete a session entry. No-op if key doesn't exist. */
  delete(key: SessionKey): Promise<void>;
}

Implementation Details

Key Composition

Composite key format: {channelId}:{userId}:{threadId ?? "_"}

  • Enables per-user, per-thread session continuity
  • Underscore _ sentinel for threadless conversations (e.g., DMs without threading)
  • Example: telegram-12345:user-42:thread-99 or discord-67890:user-7:_

Storage Format

Single JSON file: remoteclaw-sessions.json in the configured directory.

{
  "telegram-12345:user-42:thread-99": {
    "sessionId": "sess_abc123",
    "lastAccessMs": 1740000000000
  },
  "discord-67890:user-7:_": {
    "sessionId": "thread_xyz",
    "lastAccessMs": 1740000000000
  }
}

TTL Expiration (Lazy Eviction)

  • Default TTL: 7 days (604,800,000 ms)
  • get(): Returns undefined for expired entries (entry exists but lastAccessMs + ttlMs < now)
  • set(): Evicts ALL expired entries before writing the new/updated entry
  • No background reaper: Lazy eviction only — the file may contain expired entries until the next set() call

Atomic Writes

To prevent corruption from interrupted writes:

  1. Write to a temporary file in the same directory (e.g., remoteclaw-sessions.json.tmp)
  2. Rename temp file to the final path (atomic on POSIX filesystems)

Graceful Degradation

  • Corrupted JSON file: get() returns undefined, set() starts fresh (empty store)
  • Missing directory: set() creates the directory recursively before writing
  • Missing file: get() returns undefined, set() creates the file

Disk I/O Pattern

Every operation reads from disk and writes to disk (for set/delete). No in-memory cache. This prioritizes correctness and simplicity over performance, which is appropriate for the expected low-frequency session lookup pattern (one lookup per incoming channel message).

Test Requirements

Test file: src/middleware/session-map.test.ts

Use real temp directories (fs.mkdtempSync) for all tests, cleaned up in afterEach.

CRUD Operations

  • get() returns undefined for unknown key
  • set() + get() round-trip: store and retrieve a session ID
  • Thread isolation: same channelId + userId but different threadId → different sessions
  • delete() removes an entry
  • delete() is a no-op for missing key (no error thrown)

Persistence

  • Data survives across SessionMap instances using the same directory
    (create instance A, set(), create instance B with same dir, get() returns the value)

TTL Expiration

  • Entry with old lastAccessMs (beyond TTL) returns undefined on get()
    (use a very short TTL like 1ms and wait, or manipulate lastAccessMs directly)
  • Expired entries are evicted on the next set() call

Resilience

  • Corrupted JSON file → graceful recovery: get() returns undefined, set() can write new data
  • Missing directory → set() creates it and succeeds

Key Composition

  • Verify key format: channelId:userId:threadId
  • Verify threadless key: channelId:userId:_ (when threadId is undefined)

Integration Context

This module is consumed by ChannelBridge (#32):

  1. On incoming message: SessionMap.get({ channelId, userId, threadId }) → optional sessionId
  2. Pass sessionId to AgentRuntime.execute({ sessionId, ... })
  3. After execution: SessionMap.set(key, result.sessionId) to persist the returned session ID

Cron jobs (cron session analysis analysis) may also use SessionMap, with threadId set to the cron job ID for session isolation.

Acceptance Criteria

  • src/middleware/session-map.ts exports SessionMap class, SessionKey type, SessionEntry type
  • Composite key format: {channelId}:{userId}:{threadId ?? "_"}
  • 7-day default TTL with configurable override
  • get() returns undefined for unknown, expired, or corrupted entries
  • set() evicts all expired entries, writes atomically (temp + rename)
  • delete() removes entry, no-op for missing key
  • Corrupted JSON file → graceful recovery (no throw)
  • Missing directory → created on set()
  • No in-memory cache — every operation hits disk
  • All tests pass: npx vitest run src/middleware/session-map.test.ts
  • Full suite passes: npx vitest run

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions