Files
Codeman/dist/state-store.js
T

822 lines
31 KiB
JavaScript

/**
* @fileoverview Persistent JSON state storage for Codeman.
*
* Persists application state with debounced writes (500ms) to prevent excessive disk I/O.
* State is split into two files:
* - `~/.codeman/state.json` — main app state (sessions, tasks, config, global stats)
* - `~/.codeman/state-inner.json` — Ralph loop state per session (changes rapidly)
*
* Key exports:
* - `StateStore` class — singleton store with circuit breaker for save failures
* - `getStore(filePath?)` — factory/singleton accessor
*
* Key methods: `getState()`, `getSessions()`, `setSession()`, `getConfig()`,
* `setConfig()`, `getGlobalStats()`, `getAggregateStats()`, `getTokenStats()`,
* `getDailyStats()`, `getRalphState()`, `setRalphState()`, `save()`, `saveNow()`
*
* Auto-migrates legacy `~/.claudeman/` → `~/.codeman/` on first load.
*
* @dependencies types (AppState, RalphSessionState, GlobalStats, TokenStats),
* utils (Debouncer, MAX_SESSION_TOKENS)
* @consumedby session-manager, ralph-loop, web/server, respawn-controller,
* hooks-config, and most subsystems
*
* @module state-store
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync, copyFileSync } from 'node:fs';
import { writeFile, rename, unlink, copyFile, access } from 'node:fs/promises';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { createInitialState, createInitialRalphSessionState, createInitialGlobalStats, } from './types.js';
import { Debouncer, MAX_SESSION_TOKENS } from './utils/index.js';
/** Debounce delay for batching state writes (ms) */
const SAVE_DEBOUNCE_MS = 500;
/**
* Persistent JSON state storage with debounced writes.
*
* State is automatically loaded on construction and saved with 500ms
* debouncing to batch rapid updates into single disk writes.
*
* @example
* ```typescript
* const store = new StateStore();
*
* // Read state
* const sessions = store.getState().sessions;
*
* // Modify and save
* store.getState().sessions[id] = sessionState;
* store.save(); // Debounced - won't write immediately
*
* // Force immediate write
* store.saveNow();
* ```
*/
/** Maximum consecutive save failures before circuit breaker opens */
const MAX_CONSECUTIVE_FAILURES = 3;
export class StateStore {
state;
filePath;
saveDeb = new Debouncer(SAVE_DEBOUNCE_MS);
dirty = false;
dirtySessions = new Set();
cachedSessionJsons = new Map();
// Inner state storage (separate from main state to reduce write frequency)
ralphStates = new Map();
ralphStatePath;
ralphStateSaveDeb = new Debouncer(SAVE_DEBOUNCE_MS);
ralphStateDirty = false;
// Circuit breaker for save failures (prevents hammering disk on persistent errors)
consecutiveSaveFailures = 0;
circuitBreakerOpen = false;
// Guard against concurrent saveNowAsync() calls (debounce can race with in-flight write)
_saveInFlight = null;
constructor(filePath) {
// Migrate legacy data directory (~/.claudeman → ~/.codeman)
if (!filePath) {
const legacyDir = join(homedir(), '.claudeman');
const newDir = join(homedir(), '.codeman');
if (existsSync(legacyDir) && !existsSync(newDir)) {
console.log(`[state-store] Migrating data directory: ${legacyDir} → ${newDir}`);
renameSync(legacyDir, newDir);
}
const legacyCasesDir = join(homedir(), 'claudeman-cases');
const newCasesDir = join(homedir(), 'codeman-cases');
if (existsSync(legacyCasesDir) && !existsSync(newCasesDir)) {
console.log(`[state-store] Migrating cases directory: ${legacyCasesDir} → ${newCasesDir}`);
renameSync(legacyCasesDir, newCasesDir);
}
}
this.filePath = filePath || join(homedir(), '.codeman', 'state.json');
this.ralphStatePath = this.filePath.replace('.json', '-inner.json');
this.state = this.load();
this.state.config.stateFilePath = this.filePath;
// Pre-populate session cache for loaded state
for (const [id, session] of Object.entries(this.state.sessions)) {
this.cachedSessionJsons.set(id, JSON.stringify(session));
}
this.loadRalphStates();
}
_mergeWithInitialState(parsed) {
const initial = createInitialState();
return {
...initial,
...parsed,
sessions: { ...parsed.sessions },
tasks: { ...parsed.tasks },
ralphLoop: { ...initial.ralphLoop, ...parsed.ralphLoop },
config: { ...initial.config, ...parsed.config },
};
}
_resetCircuitBreaker() {
this.consecutiveSaveFailures = 0;
if (this.circuitBreakerOpen) {
console.log('[StateStore] Circuit breaker CLOSED - save succeeded');
this.circuitBreakerOpen = false;
}
}
ensureDir() {
const dir = dirname(this.filePath);
if (!existsSync(dir)) {
// Use restrictive permissions (0o700) - owner only can read/write/traverse
// State files may contain sensitive session data
mkdirSync(dir, { recursive: true, mode: 0o700 });
}
}
load() {
// Try main file first, then .bak fallback
for (const path of [this.filePath, this.filePath + '.bak']) {
try {
if (existsSync(path)) {
const data = readFileSync(path, 'utf-8');
const parsed = JSON.parse(data);
const result = this._mergeWithInitialState(parsed);
if (path !== this.filePath) {
console.warn(`[StateStore] Recovered state from backup: ${path}`);
}
return result;
}
}
catch (err) {
console.error(`Failed to load state from ${path}:`, err);
}
}
return createInitialState();
}
/**
* Schedules a debounced save.
* Multiple calls within 500ms are batched into a single disk write.
* Uses async I/O to avoid blocking the event loop.
*/
save() {
this.dirty = true;
if (this.saveDeb.isPending)
return; // Already scheduled
this.saveDeb.schedule(() => {
this.saveNowAsync().catch((err) => {
console.error('[StateStore] Async save failed:', err);
});
});
}
/**
* Async version of saveNow — used by the debounced save() path.
* Uses non-blocking fs.promises to avoid blocking the event loop during
* the debounced write cycle. For synchronous shutdown flush, use saveNow().
*
* Guards against concurrent execution: if a save is already in flight,
* waits for it to complete then re-checks dirty flag before starting another.
*/
async saveNowAsync() {
if (this._saveInFlight) {
await this._saveInFlight;
// After waiting, re-check if still dirty (the previous save may have handled it)
if (!this.dirty)
return;
}
this._saveInFlight = this._doSaveAsync();
try {
await this._saveInFlight;
}
finally {
this._saveInFlight = null;
}
}
/**
* Assemble JSON string with incremental per-session caching.
* Only dirty sessions are re-serialized; clean sessions use cached JSON fragments.
*/
assembleStateJson() {
this.updateDirtySessionCache();
// Build sessions object from cached fragments
const sessionParts = [];
for (const [id, session] of Object.entries(this.state.sessions)) {
let json = this.cachedSessionJsons.get(id);
if (!json) {
// Session not in cache (loaded from disk or set via direct state mutation)
json = JSON.stringify(session);
this.cachedSessionJsons.set(id, json);
}
sessionParts.push(`${JSON.stringify(id)}:${json}`);
}
this.pruneStaleCacheEntries();
return this.buildPartialJson(sessionParts);
}
updateDirtySessionCache() {
// Re-serialize dirty sessions and update cache
for (const id of this.dirtySessions) {
const session = this.state.sessions[id];
if (session) {
this.cachedSessionJsons.set(id, JSON.stringify(session));
}
else {
this.cachedSessionJsons.delete(id);
}
}
this.dirtySessions.clear();
}
pruneStaleCacheEntries() {
// Prune stale cache entries (sessions removed via direct state mutation)
if (this.cachedSessionJsons.size > Object.keys(this.state.sessions).length) {
for (const cachedId of this.cachedSessionJsons.keys()) {
if (!(cachedId in this.state.sessions)) {
this.cachedSessionJsons.delete(cachedId);
}
}
}
}
buildPartialJson(sessionParts) {
// Build final JSON: sessions from cache, everything else re-serialized (tiny)
const sessionsJson = `{${sessionParts.join(',')}}`;
// Serialize non-session fields individually (they're small)
const parts = [
`"sessions":${sessionsJson}`,
`"tasks":${JSON.stringify(this.state.tasks)}`,
`"ralphLoop":${JSON.stringify(this.state.ralphLoop)}`,
`"config":${JSON.stringify(this.state.config)}`,
];
// Optional fields
if (this.state.globalStats) {
parts.push(`"globalStats":${JSON.stringify(this.state.globalStats)}`);
}
if (this.state.tokenStats) {
parts.push(`"tokenStats":${JSON.stringify(this.state.tokenStats)}`);
}
return `{${parts.join(',')}}`;
}
serializeState() {
try {
return this.assembleStateJson();
}
catch (assembleErr) {
// Fallback to full serialization if incremental assembly fails
console.warn('[StateStore] assembleStateJson failed, falling back to full serialize:', assembleErr);
this.cachedSessionJsons.clear();
this.dirtySessions.clear();
try {
return JSON.stringify(this.state);
}
catch (err) {
console.error('[StateStore] Failed to serialize state (circular reference or invalid data):', err);
this.consecutiveSaveFailures++;
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly');
this.circuitBreakerOpen = true;
}
return null;
}
}
}
async _doSaveAsync() {
this.saveDeb.cancel();
if (!this.dirty) {
return;
}
// Circuit breaker: stop attempting writes after too many failures
if (this.circuitBreakerOpen) {
console.warn('[StateStore] Circuit breaker open - skipping save (too many consecutive failures)');
return;
}
this.ensureDir();
const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
const backupPath = this.filePath + '.bak';
// Step 1: Serialize state (validates it's JSON-safe)
const json = this.serializeState();
if (json === null)
return;
// Clear dirty flag BEFORE async I/O so mutations during write re-set it.
// The state snapshot is already captured in `json` above.
this.dirty = false;
// Step 2: Create backup via file copy (async, no read+parse+write)
try {
await access(this.filePath);
await copyFile(this.filePath, backupPath);
}
catch {
// Backup failed or file doesn't exist yet - continue with write
}
// Step 3: Atomic write: write to temp file, then rename (async)
try {
await writeFile(tempPath, json, 'utf-8');
await rename(tempPath, this.filePath);
this._resetCircuitBreaker();
}
catch (err) {
console.error('[StateStore] Failed to write state file:', err);
// Re-mark dirty so the data is retried on the next save cycle
this.dirty = true;
this.consecutiveSaveFailures++;
// Try to clean up temp file on error
try {
await unlink(tempPath);
}
catch {
// Temp file may not exist
}
// Check circuit breaker threshold
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
console.error('[StateStore] Circuit breaker OPEN - writes failing repeatedly');
this.circuitBreakerOpen = true;
}
}
}
/**
* Synchronous immediate write to disk using atomic write pattern.
* Used by flushAll() during shutdown when async is not appropriate.
* Prefer saveNowAsync() for normal operation.
*/
saveNow() {
this.saveDeb.cancel();
if (!this.dirty) {
return;
}
if (this.circuitBreakerOpen) {
console.warn('[StateStore] Circuit breaker open - skipping save (too many consecutive failures)');
return;
}
this.ensureDir();
const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
const backupPath = this.filePath + '.bak';
const json = this.serializeState();
if (json === null)
return;
// Backup via atomic copy (avoids reading entire file into memory)
try {
if (existsSync(this.filePath)) {
copyFileSync(this.filePath, backupPath);
}
}
catch {
// Backup failed - continue with write
}
try {
writeFileSync(tempPath, json, 'utf-8');
renameSync(tempPath, this.filePath);
// Clear dirty flag only AFTER successful write
this.dirty = false;
this._resetCircuitBreaker();
}
catch (err) {
console.error('[StateStore] Failed to write state file:', err);
this.consecutiveSaveFailures++;
try {
if (existsSync(tempPath))
unlinkSync(tempPath);
}
catch {
/* ignore */
}
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
console.error('[StateStore] Circuit breaker OPEN - writes failing repeatedly');
this.circuitBreakerOpen = true;
}
}
}
/**
* Attempt to recover state from backup file.
* Call this if main state file is corrupt.
*/
recoverFromBackup() {
const backupPath = this.filePath + '.bak';
try {
if (existsSync(backupPath)) {
const backupContent = readFileSync(backupPath, 'utf-8');
const parsed = JSON.parse(backupContent);
this.state = this._mergeWithInitialState(parsed);
console.log('[StateStore] Successfully recovered state from backup');
// Reset circuit breaker after successful recovery
this.circuitBreakerOpen = false;
this.consecutiveSaveFailures = 0;
return true;
}
}
catch (err) {
console.error('[StateStore] Failed to recover from backup:', err);
}
return false;
}
/**
* Reset the circuit breaker (for manual intervention).
*/
resetCircuitBreaker() {
this.circuitBreakerOpen = false;
this.consecutiveSaveFailures = 0;
console.log('[StateStore] Circuit breaker manually reset');
}
/** Flushes any pending main state save. Call before shutdown. */
flush() {
this.saveNow();
}
/** Returns the full application state object. */
getState() {
return this.state;
}
/** Returns all session states keyed by session ID. */
getSessions() {
return this.state.sessions;
}
/** Returns a session state by ID, or null if not found. */
getSession(id) {
return this.state.sessions[id] ?? null;
}
/** Sets a session state and triggers a debounced save. */
setSession(id, session) {
this.state.sessions[id] = session;
this.dirtySessions.add(id);
this.save();
}
/** Removes a session state and triggers a debounced save. */
removeSession(id) {
delete this.state.sessions[id];
this.cachedSessionJsons.delete(id);
this.dirtySessions.delete(id);
this.save();
}
/**
* Cleans up stale sessions from state that don't have corresponding active sessions.
* @param activeSessionIds - Set of currently active session IDs
* @returns Number of sessions cleaned up
*/
cleanupStaleSessions(activeSessionIds) {
const allSessionIds = Object.keys(this.state.sessions);
const cleaned = [];
for (const sessionId of allSessionIds) {
if (!activeSessionIds.has(sessionId)) {
const name = this.state.sessions[sessionId]?.name;
cleaned.push({ id: sessionId, name });
delete this.state.sessions[sessionId];
this.cachedSessionJsons.delete(sessionId);
this.dirtySessions.delete(sessionId);
// Also clean up Ralph state for this session
this.ralphStates.delete(sessionId);
}
}
if (cleaned.length > 0) {
console.log(`[StateStore] Cleaned up ${cleaned.length} stale session(s) from state`);
this.save();
}
return { count: cleaned.length, cleaned };
}
/** Returns all task states keyed by task ID. */
getTasks() {
return this.state.tasks;
}
/** Returns a task state by ID, or null if not found. */
getTask(id) {
return this.state.tasks[id] ?? null;
}
/** Sets a task state and triggers a debounced save. */
setTask(id, task) {
this.state.tasks[id] = task;
this.save();
}
/** Removes a task state and triggers a debounced save. */
removeTask(id) {
delete this.state.tasks[id];
this.save();
}
/** Returns the Ralph Loop state. */
getRalphLoopState() {
return this.state.ralphLoop;
}
/** Updates Ralph Loop state (partial merge) and triggers a debounced save. */
setRalphLoopState(ralphLoop) {
this.state.ralphLoop = { ...this.state.ralphLoop, ...ralphLoop };
this.save();
}
// ========== Orchestrator Loop State Methods ==========
/** Returns the orchestrator loop state, or null if never initialized. */
getOrchestratorState() {
return this.state.orchestrator ?? null;
}
/** Updates orchestrator loop state (partial merge) and triggers a debounced save. */
setOrchestratorState(orchestrator) {
if (this.state.orchestrator) {
this.state.orchestrator = { ...this.state.orchestrator, ...orchestrator };
}
else {
// First initialization — caller must provide full state
this.state.orchestrator = orchestrator;
}
this.save();
}
/** Clears orchestrator state and triggers a debounced save. */
clearOrchestratorState() {
this.state.orchestrator = undefined;
this.save();
}
/** Returns the application configuration. */
getConfig() {
return this.state.config;
}
/** Updates configuration (partial merge) and triggers a debounced save. */
setConfig(config) {
this.state.config = { ...this.state.config, ...config };
this.save();
}
/** Resets all state to initial values and saves immediately. */
reset() {
this.state = createInitialState();
this.state.config.stateFilePath = this.filePath;
this.ralphStates.clear();
this.cachedSessionJsons.clear();
this.dirtySessions.clear();
this.saveNow(); // Immediate save for reset operations
this.saveRalphStatesNow();
}
// ========== Global Stats Methods ==========
/** Returns global stats, creating initial stats if needed. */
getGlobalStats() {
if (!this.state.globalStats) {
this.state.globalStats = createInitialGlobalStats();
}
return this.state.globalStats;
}
/**
* Adds tokens and cost to global stats.
* Call when a session is deleted to preserve its usage in lifetime stats.
*/
addToGlobalStats(inputTokens, outputTokens, cost) {
// Sanity check: reject absurdly large values
if (inputTokens > MAX_SESSION_TOKENS || outputTokens > MAX_SESSION_TOKENS) {
console.warn(`[StateStore] Rejected absurd global stats: input=${inputTokens}, output=${outputTokens}`);
return;
}
// Reject negative values
if (inputTokens < 0 || outputTokens < 0 || cost < 0) {
console.warn(`[StateStore] Rejected negative global stats: input=${inputTokens}, output=${outputTokens}, cost=${cost}`);
return;
}
const stats = this.getGlobalStats();
stats.totalInputTokens += inputTokens;
stats.totalOutputTokens += outputTokens;
stats.totalCost += cost;
stats.lastUpdatedAt = Date.now();
this.save();
}
/** Increments the total sessions created counter. */
incrementSessionsCreated() {
const stats = this.getGlobalStats();
stats.totalSessionsCreated += 1;
stats.lastUpdatedAt = Date.now();
this.save();
}
/**
* Returns aggregate stats combining global (deleted sessions) + active sessions.
* @param activeSessions Map of active session states
*/
getAggregateStats(activeSessions) {
const global = this.getGlobalStats();
let activeInput = 0;
let activeOutput = 0;
let activeCost = 0;
let activeCount = 0;
for (const session of Object.values(activeSessions)) {
activeInput += session.inputTokens ?? 0;
activeOutput += session.outputTokens ?? 0;
activeCost += session.totalCost ?? 0;
activeCount++;
}
return {
totalInputTokens: global.totalInputTokens + activeInput,
totalOutputTokens: global.totalOutputTokens + activeOutput,
totalCost: global.totalCost + activeCost,
totalSessionsCreated: global.totalSessionsCreated,
activeSessionsCount: activeCount,
};
}
// ========== Token Stats Methods (Daily Tracking) ==========
/** Maximum days to keep in daily history */
static MAX_DAILY_HISTORY = 30;
/**
* Get or initialize token stats from state.
*/
getTokenStats() {
if (!this.state.tokenStats) {
this.state.tokenStats = {
daily: [],
lastUpdated: Date.now(),
};
}
return this.state.tokenStats;
}
/**
* Get today's date string in YYYY-MM-DD format.
*/
getTodayDateString() {
const now = new Date();
return now.toISOString().split('T')[0];
}
/**
* Calculate estimated cost from tokens using Claude Opus pricing.
* Input: $15/M tokens, Output: $75/M tokens
*/
calculateEstimatedCost(inputTokens, outputTokens) {
const inputCost = (inputTokens / 1000000) * 15;
const outputCost = (outputTokens / 1000000) * 75;
return inputCost + outputCost;
}
// Track unique sessions per day for accurate session count
dailySessionIds = new Set();
dailySessionDate = '';
/**
* Record token usage for today.
* Accumulates tokens to today's entry, creating it if needed.
* @param inputTokens Input tokens to add
* @param outputTokens Output tokens to add
* @param sessionId Optional session ID for unique session counting
*/
recordDailyUsage(inputTokens, outputTokens, sessionId) {
if (inputTokens <= 0 && outputTokens <= 0)
return;
// Sanity check: reject absurdly large values (max 1M tokens per recording)
// Claude's context window is ~200k, so 1M per recording is already very generous
const MAX_TOKENS_PER_RECORDING = 1_000_000;
if (inputTokens > MAX_TOKENS_PER_RECORDING || outputTokens > MAX_TOKENS_PER_RECORDING) {
console.warn(`[StateStore] Rejected absurd token values: input=${inputTokens}, output=${outputTokens}`);
return;
}
const stats = this.getTokenStats();
const today = this.getTodayDateString();
// Reset daily session tracking on date change
if (this.dailySessionDate !== today) {
this.dailySessionIds.clear();
this.dailySessionDate = today;
}
// Find or create today's entry
let todayEntry = stats.daily.find((e) => e.date === today);
if (!todayEntry) {
todayEntry = {
date: today,
inputTokens: 0,
outputTokens: 0,
estimatedCost: 0,
sessions: 0,
};
stats.daily.unshift(todayEntry); // Add to front (most recent first)
}
// Accumulate tokens
todayEntry.inputTokens += inputTokens;
todayEntry.outputTokens += outputTokens;
todayEntry.estimatedCost = this.calculateEstimatedCost(todayEntry.inputTokens, todayEntry.outputTokens);
// Only increment session count for unique sessions
if (sessionId && !this.dailySessionIds.has(sessionId)) {
this.dailySessionIds.add(sessionId);
todayEntry.sessions = this.dailySessionIds.size;
}
// Prune old entries (keep last 30 days)
if (stats.daily.length > StateStore.MAX_DAILY_HISTORY) {
stats.daily = stats.daily.slice(0, StateStore.MAX_DAILY_HISTORY);
}
stats.lastUpdated = Date.now();
this.save();
}
/**
* Get daily stats for display.
* @param days Number of days to return (default: 30)
* @returns Array of daily entries, most recent first
*/
getDailyStats(days = 30) {
const stats = this.getTokenStats();
return stats.daily.slice(0, days);
}
// ========== Inner State Methods (Ralph Loop tracking) ==========
loadRalphStates() {
try {
if (existsSync(this.ralphStatePath)) {
const data = readFileSync(this.ralphStatePath, 'utf-8');
const parsed = JSON.parse(data);
for (const [sessionId, state] of Object.entries(parsed)) {
this.ralphStates.set(sessionId, state);
}
}
}
catch (err) {
console.error('Failed to load inner states:', err);
}
}
// Debounced save for inner states
saveRalphStates() {
this.ralphStateDirty = true;
if (this.ralphStateSaveDeb.isPending)
return; // Already scheduled
this.ralphStateSaveDeb.schedule(() => {
this.saveRalphStatesNow();
});
}
/**
* Immediate save for inner states using atomic write pattern.
* Writes to temp file first, then renames to prevent corruption on crash.
*/
saveRalphStatesNow() {
this.ralphStateSaveDeb.cancel();
if (!this.ralphStateDirty) {
return;
}
// Clear dirty flag only on success to enable retry on failure
this.ensureDir();
const data = Object.fromEntries(this.ralphStates);
// Atomic write: write to temp file, then rename (atomic on POSIX)
const tempPath = this.ralphStatePath + '.tmp';
let json;
try {
json = JSON.stringify(data);
}
catch (err) {
console.error('[StateStore] Failed to serialize Ralph state (circular reference or invalid data):', err);
// Keep dirty flag true for retry - don't throw, let caller continue
return;
}
try {
writeFileSync(tempPath, json, 'utf-8');
renameSync(tempPath, this.ralphStatePath);
// Success - clear dirty flag
this.ralphStateDirty = false;
}
catch (err) {
console.error('[StateStore] Failed to write Ralph state file:', err);
// Keep dirty flag true for retry on next save
// Try to clean up temp file on error
try {
if (existsSync(tempPath)) {
unlinkSync(tempPath);
}
}
catch (cleanupErr) {
console.warn('[StateStore] Failed to cleanup temp file during Ralph state save error:', cleanupErr);
}
// Don't throw - let caller continue, retry on next save
}
}
/** Returns inner state for a session, or null if not found. */
getRalphState(sessionId) {
return this.ralphStates.get(sessionId) ?? null;
}
/** Sets inner state for a session and triggers a debounced save. */
setRalphState(sessionId, state) {
this.ralphStates.set(sessionId, state);
this.saveRalphStates();
}
/**
* Updates inner state for a session (partial merge).
* Creates initial state if none exists.
* @returns The updated inner state.
*/
updateRalphState(sessionId, updates) {
let state = this.ralphStates.get(sessionId);
if (!state) {
state = createInitialRalphSessionState(sessionId);
}
state = { ...state, ...updates, lastUpdated: Date.now() };
this.ralphStates.set(sessionId, state);
this.saveRalphStates();
return state;
}
/** Removes inner state for a session and triggers a debounced save. */
removeRalphState(sessionId) {
if (this.ralphStates.has(sessionId)) {
this.ralphStates.delete(sessionId);
this.saveRalphStates();
}
}
/** Returns a copy of all inner states as a Map. */
getAllRalphStates() {
return new Map(this.ralphStates);
}
/** Flushes all pending saves (main and inner state). Call before shutdown. */
flushAll() {
// Save both states, catching errors to ensure both are attempted
let mainError = null;
let ralphError = null;
try {
this.saveNow();
}
catch (err) {
mainError = err;
console.error('[StateStore] Error flushing main state:', err);
}
try {
this.saveRalphStatesNow();
}
catch (err) {
ralphError = err;
console.error('[StateStore] Error flushing Ralph state:', err);
}
// Log summary if any errors occurred
if (mainError || ralphError) {
console.warn('[StateStore] flushAll completed with errors - some state may not be persisted');
}
}
}
// Singleton instance
let storeInstance = null;
/**
* Gets or creates the singleton StateStore instance.
* @param filePath Optional custom file path (only used on first call).
*/
export function getStore(filePath) {
if (!storeInstance) {
storeInstance = new StateStore(filePath);
}
return storeInstance;
}
//# sourceMappingURL=state-store.js.map