-
Notifications
You must be signed in to change notification settings - Fork 0
Implement SessionMap #24
Description
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-99ordiscord-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(): Returnsundefinedfor expired entries (entry exists butlastAccessMs + 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:
- Write to a temporary file in the same directory (e.g.,
remoteclaw-sessions.json.tmp) - Rename temp file to the final path (atomic on POSIX filesystems)
Graceful Degradation
- Corrupted JSON file:
get()returnsundefined,set()starts fresh (empty store) - Missing directory:
set()creates the directory recursively before writing - Missing file:
get()returnsundefined,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()returnsundefinedfor unknown keyset()+get()round-trip: store and retrieve a session ID- Thread isolation: same
channelId+userIdbut differentthreadId→ different sessions delete()removes an entrydelete()is a no-op for missing key (no error thrown)
Persistence
- Data survives across
SessionMapinstances 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) returnsundefinedonget()
(use a very short TTL like 1ms and wait, or manipulatelastAccessMsdirectly) - Expired entries are evicted on the next
set()call
Resilience
- Corrupted JSON file → graceful recovery:
get()returnsundefined,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:_(whenthreadIdis undefined)
Integration Context
This module is consumed by ChannelBridge (#32):
- On incoming message:
SessionMap.get({ channelId, userId, threadId })→ optionalsessionId - Pass
sessionIdtoAgentRuntime.execute({ sessionId, ... }) - 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.tsexportsSessionMapclass,SessionKeytype,SessionEntrytype - Composite key format:
{channelId}:{userId}:{threadId ?? "_"} - 7-day default TTL with configurable override
-
get()returnsundefinedfor 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