aicodeman 0.2.8
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/LICENSE +21 -0
- package/README.md +403 -0
- package/dist/ai-checker-base.d.ts +175 -0
- package/dist/ai-checker-base.d.ts.map +1 -0
- package/dist/ai-checker-base.js +424 -0
- package/dist/ai-checker-base.js.map +1 -0
- package/dist/ai-idle-checker.d.ts +53 -0
- package/dist/ai-idle-checker.d.ts.map +1 -0
- package/dist/ai-idle-checker.js +141 -0
- package/dist/ai-idle-checker.js.map +1 -0
- package/dist/ai-plan-checker.d.ts +52 -0
- package/dist/ai-plan-checker.d.ts.map +1 -0
- package/dist/ai-plan-checker.js +103 -0
- package/dist/ai-plan-checker.js.map +1 -0
- package/dist/bash-tool-parser.d.ts +191 -0
- package/dist/bash-tool-parser.d.ts.map +1 -0
- package/dist/bash-tool-parser.js +598 -0
- package/dist/bash-tool-parser.js.map +1 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +460 -0
- package/dist/cli.js.map +1 -0
- package/dist/config/buffer-limits.d.ts +59 -0
- package/dist/config/buffer-limits.d.ts.map +1 -0
- package/dist/config/buffer-limits.js +74 -0
- package/dist/config/buffer-limits.js.map +1 -0
- package/dist/config/map-limits.d.ts +40 -0
- package/dist/config/map-limits.d.ts.map +1 -0
- package/dist/config/map-limits.js +52 -0
- package/dist/config/map-limits.js.map +1 -0
- package/dist/file-stream-manager.d.ts +148 -0
- package/dist/file-stream-manager.d.ts.map +1 -0
- package/dist/file-stream-manager.js +351 -0
- package/dist/file-stream-manager.js.map +1 -0
- package/dist/hooks-config.d.ts +31 -0
- package/dist/hooks-config.d.ts.map +1 -0
- package/dist/hooks-config.js +115 -0
- package/dist/hooks-config.js.map +1 -0
- package/dist/image-watcher.d.ts +86 -0
- package/dist/image-watcher.d.ts.map +1 -0
- package/dist/image-watcher.js +275 -0
- package/dist/image-watcher.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +54 -0
- package/dist/index.js.map +1 -0
- package/dist/mux-factory.d.ts +13 -0
- package/dist/mux-factory.d.ts.map +1 -0
- package/dist/mux-factory.js +19 -0
- package/dist/mux-factory.js.map +1 -0
- package/dist/mux-interface.d.ts +145 -0
- package/dist/mux-interface.d.ts.map +1 -0
- package/dist/mux-interface.js +9 -0
- package/dist/mux-interface.js.map +1 -0
- package/dist/plan-orchestrator.d.ts +123 -0
- package/dist/plan-orchestrator.d.ts.map +1 -0
- package/dist/plan-orchestrator.js +500 -0
- package/dist/plan-orchestrator.js.map +1 -0
- package/dist/prompts/index.d.ts +9 -0
- package/dist/prompts/index.d.ts.map +1 -0
- package/dist/prompts/index.js +9 -0
- package/dist/prompts/index.js.map +1 -0
- package/dist/prompts/planner.d.ts +14 -0
- package/dist/prompts/planner.d.ts.map +1 -0
- package/dist/prompts/planner.js +83 -0
- package/dist/prompts/planner.js.map +1 -0
- package/dist/prompts/research-agent.d.ts +10 -0
- package/dist/prompts/research-agent.d.ts.map +1 -0
- package/dist/prompts/research-agent.js +143 -0
- package/dist/prompts/research-agent.js.map +1 -0
- package/dist/push-store.d.ts +41 -0
- package/dist/push-store.d.ts.map +1 -0
- package/dist/push-store.js +168 -0
- package/dist/push-store.js.map +1 -0
- package/dist/ralph-config.d.ts +67 -0
- package/dist/ralph-config.d.ts.map +1 -0
- package/dist/ralph-config.js +134 -0
- package/dist/ralph-config.js.map +1 -0
- package/dist/ralph-loop.d.ts +124 -0
- package/dist/ralph-loop.d.ts.map +1 -0
- package/dist/ralph-loop.js +418 -0
- package/dist/ralph-loop.js.map +1 -0
- package/dist/ralph-tracker.d.ts +1081 -0
- package/dist/ralph-tracker.d.ts.map +1 -0
- package/dist/ralph-tracker.js +3343 -0
- package/dist/ralph-tracker.js.map +1 -0
- package/dist/respawn-controller.d.ts +1182 -0
- package/dist/respawn-controller.d.ts.map +1 -0
- package/dist/respawn-controller.js +2754 -0
- package/dist/respawn-controller.js.map +1 -0
- package/dist/run-summary.d.ts +123 -0
- package/dist/run-summary.d.ts.map +1 -0
- package/dist/run-summary.js +325 -0
- package/dist/run-summary.js.map +1 -0
- package/dist/session-lifecycle-log.d.ts +36 -0
- package/dist/session-lifecycle-log.d.ts.map +1 -0
- package/dist/session-lifecycle-log.js +101 -0
- package/dist/session-lifecycle-log.js.map +1 -0
- package/dist/session-manager.d.ts +97 -0
- package/dist/session-manager.d.ts.map +1 -0
- package/dist/session-manager.js +224 -0
- package/dist/session-manager.js.map +1 -0
- package/dist/session.d.ts +686 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +2025 -0
- package/dist/session.js.map +1 -0
- package/dist/state-store.d.ts +189 -0
- package/dist/state-store.d.ts.map +1 -0
- package/dist/state-store.js +730 -0
- package/dist/state-store.js.map +1 -0
- package/dist/subagent-watcher.d.ts +345 -0
- package/dist/subagent-watcher.d.ts.map +1 -0
- package/dist/subagent-watcher.js +1469 -0
- package/dist/subagent-watcher.js.map +1 -0
- package/dist/task-queue.d.ts +108 -0
- package/dist/task-queue.d.ts.map +1 -0
- package/dist/task-queue.js +235 -0
- package/dist/task-queue.js.map +1 -0
- package/dist/task-tracker.d.ts +306 -0
- package/dist/task-tracker.d.ts.map +1 -0
- package/dist/task-tracker.js +488 -0
- package/dist/task-tracker.js.map +1 -0
- package/dist/task.d.ts +73 -0
- package/dist/task.d.ts.map +1 -0
- package/dist/task.js +177 -0
- package/dist/task.js.map +1 -0
- package/dist/team-watcher.d.ts +53 -0
- package/dist/team-watcher.d.ts.map +1 -0
- package/dist/team-watcher.js +313 -0
- package/dist/team-watcher.js.map +1 -0
- package/dist/templates/case-template.md +461 -0
- package/dist/templates/claude-md.d.ts +26 -0
- package/dist/templates/claude-md.d.ts.map +1 -0
- package/dist/templates/claude-md.js +74 -0
- package/dist/templates/claude-md.js.map +1 -0
- package/dist/tmux-manager.d.ts +181 -0
- package/dist/tmux-manager.d.ts.map +1 -0
- package/dist/tmux-manager.js +1405 -0
- package/dist/tmux-manager.js.map +1 -0
- package/dist/transcript-watcher.d.ts +110 -0
- package/dist/transcript-watcher.d.ts.map +1 -0
- package/dist/transcript-watcher.js +338 -0
- package/dist/transcript-watcher.js.map +1 -0
- package/dist/tunnel-manager.d.ts +54 -0
- package/dist/tunnel-manager.d.ts.map +1 -0
- package/dist/tunnel-manager.js +251 -0
- package/dist/tunnel-manager.js.map +1 -0
- package/dist/types.d.ts +1139 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +215 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/buffer-accumulator.d.ts +111 -0
- package/dist/utils/buffer-accumulator.d.ts.map +1 -0
- package/dist/utils/buffer-accumulator.js +172 -0
- package/dist/utils/buffer-accumulator.js.map +1 -0
- package/dist/utils/claude-cli-resolver.d.ts +26 -0
- package/dist/utils/claude-cli-resolver.d.ts.map +1 -0
- package/dist/utils/claude-cli-resolver.js +78 -0
- package/dist/utils/claude-cli-resolver.js.map +1 -0
- package/dist/utils/cleanup-manager.d.ts +165 -0
- package/dist/utils/cleanup-manager.d.ts.map +1 -0
- package/dist/utils/cleanup-manager.js +274 -0
- package/dist/utils/cleanup-manager.js.map +1 -0
- package/dist/utils/index.d.ts +19 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +19 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/lru-map.d.ts +140 -0
- package/dist/utils/lru-map.d.ts.map +1 -0
- package/dist/utils/lru-map.js +234 -0
- package/dist/utils/lru-map.js.map +1 -0
- package/dist/utils/nice-wrapper.d.ts +13 -0
- package/dist/utils/nice-wrapper.d.ts.map +1 -0
- package/dist/utils/nice-wrapper.js +17 -0
- package/dist/utils/nice-wrapper.js.map +1 -0
- package/dist/utils/opencode-cli-resolver.d.ts +21 -0
- package/dist/utils/opencode-cli-resolver.d.ts.map +1 -0
- package/dist/utils/opencode-cli-resolver.js +67 -0
- package/dist/utils/opencode-cli-resolver.js.map +1 -0
- package/dist/utils/regex-patterns.d.ts +64 -0
- package/dist/utils/regex-patterns.d.ts.map +1 -0
- package/dist/utils/regex-patterns.js +74 -0
- package/dist/utils/regex-patterns.js.map +1 -0
- package/dist/utils/stale-expiration-map.d.ts +159 -0
- package/dist/utils/stale-expiration-map.d.ts.map +1 -0
- package/dist/utils/stale-expiration-map.js +277 -0
- package/dist/utils/stale-expiration-map.js.map +1 -0
- package/dist/utils/string-similarity.d.ts +108 -0
- package/dist/utils/string-similarity.d.ts.map +1 -0
- package/dist/utils/string-similarity.js +189 -0
- package/dist/utils/string-similarity.js.map +1 -0
- package/dist/utils/token-validation.d.ts +39 -0
- package/dist/utils/token-validation.d.ts.map +1 -0
- package/dist/utils/token-validation.js +59 -0
- package/dist/utils/token-validation.js.map +1 -0
- package/dist/utils/type-safety.d.ts +33 -0
- package/dist/utils/type-safety.d.ts.map +1 -0
- package/dist/utils/type-safety.js +35 -0
- package/dist/utils/type-safety.js.map +1 -0
- package/dist/web/public/app.js +491 -0
- package/dist/web/public/app.js.br +0 -0
- package/dist/web/public/app.js.gz +0 -0
- package/dist/web/public/index.html +1675 -0
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/manifest.json +8 -0
- package/dist/web/public/mobile.css +1 -0
- package/dist/web/public/mobile.css.br +0 -0
- package/dist/web/public/mobile.css.gz +0 -0
- package/dist/web/public/ralph-wizard.js +1037 -0
- package/dist/web/public/ralph-wizard.js.br +0 -0
- package/dist/web/public/ralph-wizard.js.gz +0 -0
- package/dist/web/public/styles.css +1 -0
- package/dist/web/public/styles.css.br +0 -0
- package/dist/web/public/styles.css.gz +0 -0
- package/dist/web/public/sw.js +67 -0
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/public/upload.html +155 -0
- package/dist/web/public/upload.html.br +0 -0
- package/dist/web/public/upload.html.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js +1 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js.br +0 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js +1 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js.br +0 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js +2 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.br +0 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm.css +209 -0
- package/dist/web/public/vendor/xterm.css.br +0 -0
- package/dist/web/public/vendor/xterm.css.gz +0 -0
- package/dist/web/public/vendor/xterm.min.js +9 -0
- package/dist/web/public/vendor/xterm.min.js.br +0 -0
- package/dist/web/public/vendor/xterm.min.js.gz +0 -0
- package/dist/web/schemas.d.ts +479 -0
- package/dist/web/schemas.d.ts.map +1 -0
- package/dist/web/schemas.js +448 -0
- package/dist/web/schemas.js.map +1 -0
- package/dist/web/server.d.ts +207 -0
- package/dist/web/server.d.ts.map +1 -0
- package/dist/web/server.js +5784 -0
- package/dist/web/server.js.map +1 -0
- package/package.json +110 -0
- package/scripts/postinstall.js +390 -0
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Persistent JSON state storage for Codeman.
|
|
3
|
+
*
|
|
4
|
+
* This module provides the StateStore class which persists application state
|
|
5
|
+
* to `~/.codeman/state.json` with debounced writes to prevent excessive disk I/O.
|
|
6
|
+
*
|
|
7
|
+
* State is split into two files:
|
|
8
|
+
* - `state.json`: Main app state (sessions, tasks, config)
|
|
9
|
+
* - `state-inner.json`: Inner loop state (todos, Ralph loop state per session)
|
|
10
|
+
*
|
|
11
|
+
* The separation reduces write frequency since Ralph state changes rapidly
|
|
12
|
+
* during Ralph Wiggum loops.
|
|
13
|
+
*
|
|
14
|
+
* @module state-store
|
|
15
|
+
*/
|
|
16
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync, copyFileSync } from 'node:fs';
|
|
17
|
+
import { writeFile, rename, unlink, copyFile, access } from 'node:fs/promises';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
19
|
+
import { dirname, join } from 'node:path';
|
|
20
|
+
import { createInitialState, createInitialRalphSessionState, createInitialGlobalStats, } from './types.js';
|
|
21
|
+
import { MAX_SESSION_TOKENS } from './utils/index.js';
|
|
22
|
+
/** Debounce delay for batching state writes (ms) */
|
|
23
|
+
const SAVE_DEBOUNCE_MS = 500;
|
|
24
|
+
/**
|
|
25
|
+
* Persistent JSON state storage with debounced writes.
|
|
26
|
+
*
|
|
27
|
+
* State is automatically loaded on construction and saved with 500ms
|
|
28
|
+
* debouncing to batch rapid updates into single disk writes.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```typescript
|
|
32
|
+
* const store = new StateStore();
|
|
33
|
+
*
|
|
34
|
+
* // Read state
|
|
35
|
+
* const sessions = store.getState().sessions;
|
|
36
|
+
*
|
|
37
|
+
* // Modify and save
|
|
38
|
+
* store.getState().sessions[id] = sessionState;
|
|
39
|
+
* store.save(); // Debounced - won't write immediately
|
|
40
|
+
*
|
|
41
|
+
* // Force immediate write
|
|
42
|
+
* store.saveNow();
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
/** Maximum consecutive save failures before circuit breaker opens */
|
|
46
|
+
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
47
|
+
export class StateStore {
|
|
48
|
+
state;
|
|
49
|
+
filePath;
|
|
50
|
+
saveTimeout = null;
|
|
51
|
+
dirty = false;
|
|
52
|
+
// Inner state storage (separate from main state to reduce write frequency)
|
|
53
|
+
ralphStates = new Map();
|
|
54
|
+
ralphStatePath;
|
|
55
|
+
ralphStateSaveTimeout = null;
|
|
56
|
+
ralphStateDirty = false;
|
|
57
|
+
// Circuit breaker for save failures (prevents hammering disk on persistent errors)
|
|
58
|
+
consecutiveSaveFailures = 0;
|
|
59
|
+
circuitBreakerOpen = false;
|
|
60
|
+
// Guard against concurrent saveNowAsync() calls (debounce can race with in-flight write)
|
|
61
|
+
_saveInFlight = null;
|
|
62
|
+
constructor(filePath) {
|
|
63
|
+
// Migrate legacy data directory (~/.claudeman → ~/.codeman)
|
|
64
|
+
if (!filePath) {
|
|
65
|
+
const legacyDir = join(homedir(), '.claudeman');
|
|
66
|
+
const newDir = join(homedir(), '.codeman');
|
|
67
|
+
if (existsSync(legacyDir) && !existsSync(newDir)) {
|
|
68
|
+
console.log(`[state-store] Migrating data directory: ${legacyDir} → ${newDir}`);
|
|
69
|
+
renameSync(legacyDir, newDir);
|
|
70
|
+
}
|
|
71
|
+
const legacyCasesDir = join(homedir(), 'claudeman-cases');
|
|
72
|
+
const newCasesDir = join(homedir(), 'codeman-cases');
|
|
73
|
+
if (existsSync(legacyCasesDir) && !existsSync(newCasesDir)) {
|
|
74
|
+
console.log(`[state-store] Migrating cases directory: ${legacyCasesDir} → ${newCasesDir}`);
|
|
75
|
+
renameSync(legacyCasesDir, newCasesDir);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
this.filePath = filePath || join(homedir(), '.codeman', 'state.json');
|
|
79
|
+
this.ralphStatePath = this.filePath.replace('.json', '-inner.json');
|
|
80
|
+
this.state = this.load();
|
|
81
|
+
this.state.config.stateFilePath = this.filePath;
|
|
82
|
+
this.loadRalphStates();
|
|
83
|
+
}
|
|
84
|
+
ensureDir() {
|
|
85
|
+
const dir = dirname(this.filePath);
|
|
86
|
+
if (!existsSync(dir)) {
|
|
87
|
+
// Use restrictive permissions (0o700) - owner only can read/write/traverse
|
|
88
|
+
// State files may contain sensitive session data
|
|
89
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
load() {
|
|
93
|
+
// Try main file first, then .bak fallback
|
|
94
|
+
for (const path of [this.filePath, this.filePath + '.bak']) {
|
|
95
|
+
try {
|
|
96
|
+
if (existsSync(path)) {
|
|
97
|
+
const data = readFileSync(path, 'utf-8');
|
|
98
|
+
const parsed = JSON.parse(data);
|
|
99
|
+
const initial = createInitialState();
|
|
100
|
+
const result = {
|
|
101
|
+
...initial,
|
|
102
|
+
...parsed,
|
|
103
|
+
sessions: { ...parsed.sessions },
|
|
104
|
+
tasks: { ...parsed.tasks },
|
|
105
|
+
ralphLoop: { ...initial.ralphLoop, ...parsed.ralphLoop },
|
|
106
|
+
config: { ...initial.config, ...parsed.config },
|
|
107
|
+
};
|
|
108
|
+
if (path !== this.filePath) {
|
|
109
|
+
console.warn(`[StateStore] Recovered state from backup: ${path}`);
|
|
110
|
+
}
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
console.error(`Failed to load state from ${path}:`, err);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return createInitialState();
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Schedules a debounced save.
|
|
122
|
+
* Multiple calls within 500ms are batched into a single disk write.
|
|
123
|
+
* Uses async I/O to avoid blocking the event loop.
|
|
124
|
+
*/
|
|
125
|
+
save() {
|
|
126
|
+
this.dirty = true;
|
|
127
|
+
if (this.saveTimeout) {
|
|
128
|
+
return; // Already scheduled
|
|
129
|
+
}
|
|
130
|
+
this.saveTimeout = setTimeout(() => {
|
|
131
|
+
this.saveNowAsync().catch((err) => {
|
|
132
|
+
console.error('[StateStore] Async save failed:', err);
|
|
133
|
+
});
|
|
134
|
+
}, SAVE_DEBOUNCE_MS);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Async version of saveNow — used by the debounced save() path.
|
|
138
|
+
* Uses non-blocking fs.promises to avoid blocking the event loop during
|
|
139
|
+
* the debounced write cycle. For synchronous shutdown flush, use saveNow().
|
|
140
|
+
*
|
|
141
|
+
* Guards against concurrent execution: if a save is already in flight,
|
|
142
|
+
* waits for it to complete then re-checks dirty flag before starting another.
|
|
143
|
+
*/
|
|
144
|
+
async saveNowAsync() {
|
|
145
|
+
if (this._saveInFlight) {
|
|
146
|
+
await this._saveInFlight;
|
|
147
|
+
// After waiting, re-check if still dirty (the previous save may have handled it)
|
|
148
|
+
if (!this.dirty)
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
this._saveInFlight = this._doSaveAsync();
|
|
152
|
+
try {
|
|
153
|
+
await this._saveInFlight;
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
this._saveInFlight = null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
async _doSaveAsync() {
|
|
160
|
+
if (this.saveTimeout) {
|
|
161
|
+
clearTimeout(this.saveTimeout);
|
|
162
|
+
this.saveTimeout = null;
|
|
163
|
+
}
|
|
164
|
+
if (!this.dirty) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
// Circuit breaker: stop attempting writes after too many failures
|
|
168
|
+
if (this.circuitBreakerOpen) {
|
|
169
|
+
console.warn('[StateStore] Circuit breaker open - skipping save (too many consecutive failures)');
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
this.ensureDir();
|
|
173
|
+
const tempPath = this.filePath + '.tmp';
|
|
174
|
+
const backupPath = this.filePath + '.bak';
|
|
175
|
+
let json;
|
|
176
|
+
// Step 1: Serialize state (validates it's JSON-safe)
|
|
177
|
+
try {
|
|
178
|
+
json = JSON.stringify(this.state);
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
console.error('[StateStore] Failed to serialize state (circular reference or invalid data):', err);
|
|
182
|
+
this.consecutiveSaveFailures++;
|
|
183
|
+
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
184
|
+
console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly');
|
|
185
|
+
this.circuitBreakerOpen = true;
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
// Clear dirty flag BEFORE async I/O so mutations during write re-set it.
|
|
190
|
+
// The state snapshot is already captured in `json` above.
|
|
191
|
+
this.dirty = false;
|
|
192
|
+
// Step 2: Create backup via file copy (async, no read+parse+write)
|
|
193
|
+
try {
|
|
194
|
+
await access(this.filePath);
|
|
195
|
+
await copyFile(this.filePath, backupPath);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
// Backup failed or file doesn't exist yet - continue with write
|
|
199
|
+
}
|
|
200
|
+
// Step 3: Atomic write: write to temp file, then rename (async)
|
|
201
|
+
try {
|
|
202
|
+
await writeFile(tempPath, json, 'utf-8');
|
|
203
|
+
await rename(tempPath, this.filePath);
|
|
204
|
+
this.consecutiveSaveFailures = 0;
|
|
205
|
+
if (this.circuitBreakerOpen) {
|
|
206
|
+
console.log('[StateStore] Circuit breaker CLOSED - save succeeded');
|
|
207
|
+
this.circuitBreakerOpen = false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
console.error('[StateStore] Failed to write state file:', err);
|
|
212
|
+
// Re-mark dirty so the data is retried on the next save cycle
|
|
213
|
+
this.dirty = true;
|
|
214
|
+
this.consecutiveSaveFailures++;
|
|
215
|
+
// Try to clean up temp file on error
|
|
216
|
+
try {
|
|
217
|
+
await unlink(tempPath);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// Temp file may not exist
|
|
221
|
+
}
|
|
222
|
+
// Check circuit breaker threshold
|
|
223
|
+
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
224
|
+
console.error('[StateStore] Circuit breaker OPEN - writes failing repeatedly');
|
|
225
|
+
this.circuitBreakerOpen = true;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Synchronous immediate write to disk using atomic write pattern.
|
|
231
|
+
* Used by flushAll() during shutdown when async is not appropriate.
|
|
232
|
+
* Prefer saveNowAsync() for normal operation.
|
|
233
|
+
*/
|
|
234
|
+
saveNow() {
|
|
235
|
+
if (this.saveTimeout) {
|
|
236
|
+
clearTimeout(this.saveTimeout);
|
|
237
|
+
this.saveTimeout = null;
|
|
238
|
+
}
|
|
239
|
+
if (!this.dirty) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (this.circuitBreakerOpen) {
|
|
243
|
+
console.warn('[StateStore] Circuit breaker open - skipping save (too many consecutive failures)');
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
this.ensureDir();
|
|
247
|
+
const tempPath = this.filePath + '.tmp';
|
|
248
|
+
const backupPath = this.filePath + '.bak';
|
|
249
|
+
let json;
|
|
250
|
+
try {
|
|
251
|
+
json = JSON.stringify(this.state);
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
console.error('[StateStore] Failed to serialize state (circular reference or invalid data):', err);
|
|
255
|
+
this.consecutiveSaveFailures++;
|
|
256
|
+
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
257
|
+
console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly');
|
|
258
|
+
this.circuitBreakerOpen = true;
|
|
259
|
+
}
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
// Backup via atomic copy (avoids reading entire file into memory)
|
|
263
|
+
try {
|
|
264
|
+
if (existsSync(this.filePath)) {
|
|
265
|
+
copyFileSync(this.filePath, backupPath);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
// Backup failed - continue with write
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
writeFileSync(tempPath, json, 'utf-8');
|
|
273
|
+
renameSync(tempPath, this.filePath);
|
|
274
|
+
// Clear dirty flag only AFTER successful write
|
|
275
|
+
this.dirty = false;
|
|
276
|
+
this.consecutiveSaveFailures = 0;
|
|
277
|
+
if (this.circuitBreakerOpen) {
|
|
278
|
+
console.log('[StateStore] Circuit breaker CLOSED - save succeeded');
|
|
279
|
+
this.circuitBreakerOpen = false;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
catch (err) {
|
|
283
|
+
console.error('[StateStore] Failed to write state file:', err);
|
|
284
|
+
this.consecutiveSaveFailures++;
|
|
285
|
+
try {
|
|
286
|
+
if (existsSync(tempPath))
|
|
287
|
+
unlinkSync(tempPath);
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
/* ignore */
|
|
291
|
+
}
|
|
292
|
+
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
293
|
+
console.error('[StateStore] Circuit breaker OPEN - writes failing repeatedly');
|
|
294
|
+
this.circuitBreakerOpen = true;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Attempt to recover state from backup file.
|
|
300
|
+
* Call this if main state file is corrupt.
|
|
301
|
+
*/
|
|
302
|
+
recoverFromBackup() {
|
|
303
|
+
const backupPath = this.filePath + '.bak';
|
|
304
|
+
try {
|
|
305
|
+
if (existsSync(backupPath)) {
|
|
306
|
+
const backupContent = readFileSync(backupPath, 'utf-8');
|
|
307
|
+
const parsed = JSON.parse(backupContent);
|
|
308
|
+
const initial = createInitialState();
|
|
309
|
+
this.state = {
|
|
310
|
+
...initial,
|
|
311
|
+
...parsed,
|
|
312
|
+
sessions: { ...parsed.sessions },
|
|
313
|
+
tasks: { ...parsed.tasks },
|
|
314
|
+
ralphLoop: { ...initial.ralphLoop, ...parsed.ralphLoop },
|
|
315
|
+
config: { ...initial.config, ...parsed.config },
|
|
316
|
+
};
|
|
317
|
+
console.log('[StateStore] Successfully recovered state from backup');
|
|
318
|
+
// Reset circuit breaker after successful recovery
|
|
319
|
+
this.circuitBreakerOpen = false;
|
|
320
|
+
this.consecutiveSaveFailures = 0;
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
console.error('[StateStore] Failed to recover from backup:', err);
|
|
326
|
+
}
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Reset the circuit breaker (for manual intervention).
|
|
331
|
+
*/
|
|
332
|
+
resetCircuitBreaker() {
|
|
333
|
+
this.circuitBreakerOpen = false;
|
|
334
|
+
this.consecutiveSaveFailures = 0;
|
|
335
|
+
console.log('[StateStore] Circuit breaker manually reset');
|
|
336
|
+
}
|
|
337
|
+
/** Flushes any pending main state save. Call before shutdown. */
|
|
338
|
+
flush() {
|
|
339
|
+
this.saveNow();
|
|
340
|
+
}
|
|
341
|
+
/** Returns the full application state object. */
|
|
342
|
+
getState() {
|
|
343
|
+
return this.state;
|
|
344
|
+
}
|
|
345
|
+
/** Returns all session states keyed by session ID. */
|
|
346
|
+
getSessions() {
|
|
347
|
+
return this.state.sessions;
|
|
348
|
+
}
|
|
349
|
+
/** Returns a session state by ID, or null if not found. */
|
|
350
|
+
getSession(id) {
|
|
351
|
+
return this.state.sessions[id] ?? null;
|
|
352
|
+
}
|
|
353
|
+
/** Sets a session state and triggers a debounced save. */
|
|
354
|
+
setSession(id, session) {
|
|
355
|
+
this.state.sessions[id] = session;
|
|
356
|
+
this.save();
|
|
357
|
+
}
|
|
358
|
+
/** Removes a session state and triggers a debounced save. */
|
|
359
|
+
removeSession(id) {
|
|
360
|
+
delete this.state.sessions[id];
|
|
361
|
+
this.save();
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Cleans up stale sessions from state that don't have corresponding active sessions.
|
|
365
|
+
* @param activeSessionIds - Set of currently active session IDs
|
|
366
|
+
* @returns Number of sessions cleaned up
|
|
367
|
+
*/
|
|
368
|
+
cleanupStaleSessions(activeSessionIds) {
|
|
369
|
+
const allSessionIds = Object.keys(this.state.sessions);
|
|
370
|
+
const cleaned = [];
|
|
371
|
+
for (const sessionId of allSessionIds) {
|
|
372
|
+
if (!activeSessionIds.has(sessionId)) {
|
|
373
|
+
const name = this.state.sessions[sessionId]?.name;
|
|
374
|
+
cleaned.push({ id: sessionId, name });
|
|
375
|
+
delete this.state.sessions[sessionId];
|
|
376
|
+
// Also clean up Ralph state for this session
|
|
377
|
+
this.ralphStates.delete(sessionId);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (cleaned.length > 0) {
|
|
381
|
+
console.log(`[StateStore] Cleaned up ${cleaned.length} stale session(s) from state`);
|
|
382
|
+
this.save();
|
|
383
|
+
}
|
|
384
|
+
return { count: cleaned.length, cleaned };
|
|
385
|
+
}
|
|
386
|
+
/** Returns all task states keyed by task ID. */
|
|
387
|
+
getTasks() {
|
|
388
|
+
return this.state.tasks;
|
|
389
|
+
}
|
|
390
|
+
/** Returns a task state by ID, or null if not found. */
|
|
391
|
+
getTask(id) {
|
|
392
|
+
return this.state.tasks[id] ?? null;
|
|
393
|
+
}
|
|
394
|
+
/** Sets a task state and triggers a debounced save. */
|
|
395
|
+
setTask(id, task) {
|
|
396
|
+
this.state.tasks[id] = task;
|
|
397
|
+
this.save();
|
|
398
|
+
}
|
|
399
|
+
/** Removes a task state and triggers a debounced save. */
|
|
400
|
+
removeTask(id) {
|
|
401
|
+
delete this.state.tasks[id];
|
|
402
|
+
this.save();
|
|
403
|
+
}
|
|
404
|
+
/** Returns the Ralph Loop state. */
|
|
405
|
+
getRalphLoopState() {
|
|
406
|
+
return this.state.ralphLoop;
|
|
407
|
+
}
|
|
408
|
+
/** Updates Ralph Loop state (partial merge) and triggers a debounced save. */
|
|
409
|
+
setRalphLoopState(ralphLoop) {
|
|
410
|
+
this.state.ralphLoop = { ...this.state.ralphLoop, ...ralphLoop };
|
|
411
|
+
this.save();
|
|
412
|
+
}
|
|
413
|
+
/** Returns the application configuration. */
|
|
414
|
+
getConfig() {
|
|
415
|
+
return this.state.config;
|
|
416
|
+
}
|
|
417
|
+
/** Updates configuration (partial merge) and triggers a debounced save. */
|
|
418
|
+
setConfig(config) {
|
|
419
|
+
this.state.config = { ...this.state.config, ...config };
|
|
420
|
+
this.save();
|
|
421
|
+
}
|
|
422
|
+
/** Resets all state to initial values and saves immediately. */
|
|
423
|
+
reset() {
|
|
424
|
+
this.state = createInitialState();
|
|
425
|
+
this.state.config.stateFilePath = this.filePath;
|
|
426
|
+
this.ralphStates.clear();
|
|
427
|
+
this.saveNow(); // Immediate save for reset operations
|
|
428
|
+
this.saveRalphStatesNow();
|
|
429
|
+
}
|
|
430
|
+
// ========== Global Stats Methods ==========
|
|
431
|
+
/** Returns global stats, creating initial stats if needed. */
|
|
432
|
+
getGlobalStats() {
|
|
433
|
+
if (!this.state.globalStats) {
|
|
434
|
+
this.state.globalStats = createInitialGlobalStats();
|
|
435
|
+
}
|
|
436
|
+
return this.state.globalStats;
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Adds tokens and cost to global stats.
|
|
440
|
+
* Call when a session is deleted to preserve its usage in lifetime stats.
|
|
441
|
+
*/
|
|
442
|
+
addToGlobalStats(inputTokens, outputTokens, cost) {
|
|
443
|
+
// Sanity check: reject absurdly large values
|
|
444
|
+
if (inputTokens > MAX_SESSION_TOKENS || outputTokens > MAX_SESSION_TOKENS) {
|
|
445
|
+
console.warn(`[StateStore] Rejected absurd global stats: input=${inputTokens}, output=${outputTokens}`);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
// Reject negative values
|
|
449
|
+
if (inputTokens < 0 || outputTokens < 0 || cost < 0) {
|
|
450
|
+
console.warn(`[StateStore] Rejected negative global stats: input=${inputTokens}, output=${outputTokens}, cost=${cost}`);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
const stats = this.getGlobalStats();
|
|
454
|
+
stats.totalInputTokens += inputTokens;
|
|
455
|
+
stats.totalOutputTokens += outputTokens;
|
|
456
|
+
stats.totalCost += cost;
|
|
457
|
+
stats.lastUpdatedAt = Date.now();
|
|
458
|
+
this.save();
|
|
459
|
+
}
|
|
460
|
+
/** Increments the total sessions created counter. */
|
|
461
|
+
incrementSessionsCreated() {
|
|
462
|
+
const stats = this.getGlobalStats();
|
|
463
|
+
stats.totalSessionsCreated += 1;
|
|
464
|
+
stats.lastUpdatedAt = Date.now();
|
|
465
|
+
this.save();
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Returns aggregate stats combining global (deleted sessions) + active sessions.
|
|
469
|
+
* @param activeSessions Map of active session states
|
|
470
|
+
*/
|
|
471
|
+
getAggregateStats(activeSessions) {
|
|
472
|
+
const global = this.getGlobalStats();
|
|
473
|
+
let activeInput = 0;
|
|
474
|
+
let activeOutput = 0;
|
|
475
|
+
let activeCost = 0;
|
|
476
|
+
let activeCount = 0;
|
|
477
|
+
for (const session of Object.values(activeSessions)) {
|
|
478
|
+
activeInput += session.inputTokens ?? 0;
|
|
479
|
+
activeOutput += session.outputTokens ?? 0;
|
|
480
|
+
activeCost += session.totalCost ?? 0;
|
|
481
|
+
activeCount++;
|
|
482
|
+
}
|
|
483
|
+
return {
|
|
484
|
+
totalInputTokens: global.totalInputTokens + activeInput,
|
|
485
|
+
totalOutputTokens: global.totalOutputTokens + activeOutput,
|
|
486
|
+
totalCost: global.totalCost + activeCost,
|
|
487
|
+
totalSessionsCreated: global.totalSessionsCreated,
|
|
488
|
+
activeSessionsCount: activeCount,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
// ========== Token Stats Methods (Daily Tracking) ==========
|
|
492
|
+
/** Maximum days to keep in daily history */
|
|
493
|
+
static MAX_DAILY_HISTORY = 30;
|
|
494
|
+
/**
|
|
495
|
+
* Get or initialize token stats from state.
|
|
496
|
+
*/
|
|
497
|
+
getTokenStats() {
|
|
498
|
+
if (!this.state.tokenStats) {
|
|
499
|
+
this.state.tokenStats = {
|
|
500
|
+
daily: [],
|
|
501
|
+
lastUpdated: Date.now(),
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
return this.state.tokenStats;
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Get today's date string in YYYY-MM-DD format.
|
|
508
|
+
*/
|
|
509
|
+
getTodayDateString() {
|
|
510
|
+
const now = new Date();
|
|
511
|
+
return now.toISOString().split('T')[0];
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Calculate estimated cost from tokens using Claude Opus pricing.
|
|
515
|
+
* Input: $15/M tokens, Output: $75/M tokens
|
|
516
|
+
*/
|
|
517
|
+
calculateEstimatedCost(inputTokens, outputTokens) {
|
|
518
|
+
const inputCost = (inputTokens / 1000000) * 15;
|
|
519
|
+
const outputCost = (outputTokens / 1000000) * 75;
|
|
520
|
+
return inputCost + outputCost;
|
|
521
|
+
}
|
|
522
|
+
// Track unique sessions per day for accurate session count
|
|
523
|
+
dailySessionIds = new Set();
|
|
524
|
+
dailySessionDate = '';
|
|
525
|
+
/**
|
|
526
|
+
* Record token usage for today.
|
|
527
|
+
* Accumulates tokens to today's entry, creating it if needed.
|
|
528
|
+
* @param inputTokens Input tokens to add
|
|
529
|
+
* @param outputTokens Output tokens to add
|
|
530
|
+
* @param sessionId Optional session ID for unique session counting
|
|
531
|
+
*/
|
|
532
|
+
recordDailyUsage(inputTokens, outputTokens, sessionId) {
|
|
533
|
+
if (inputTokens <= 0 && outputTokens <= 0)
|
|
534
|
+
return;
|
|
535
|
+
// Sanity check: reject absurdly large values (max 1M tokens per recording)
|
|
536
|
+
// Claude's context window is ~200k, so 1M per recording is already very generous
|
|
537
|
+
const MAX_TOKENS_PER_RECORDING = 1_000_000;
|
|
538
|
+
if (inputTokens > MAX_TOKENS_PER_RECORDING || outputTokens > MAX_TOKENS_PER_RECORDING) {
|
|
539
|
+
console.warn(`[StateStore] Rejected absurd token values: input=${inputTokens}, output=${outputTokens}`);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
const stats = this.getTokenStats();
|
|
543
|
+
const today = this.getTodayDateString();
|
|
544
|
+
// Reset daily session tracking on date change
|
|
545
|
+
if (this.dailySessionDate !== today) {
|
|
546
|
+
this.dailySessionIds.clear();
|
|
547
|
+
this.dailySessionDate = today;
|
|
548
|
+
}
|
|
549
|
+
// Find or create today's entry
|
|
550
|
+
let todayEntry = stats.daily.find((e) => e.date === today);
|
|
551
|
+
if (!todayEntry) {
|
|
552
|
+
todayEntry = {
|
|
553
|
+
date: today,
|
|
554
|
+
inputTokens: 0,
|
|
555
|
+
outputTokens: 0,
|
|
556
|
+
estimatedCost: 0,
|
|
557
|
+
sessions: 0,
|
|
558
|
+
};
|
|
559
|
+
stats.daily.unshift(todayEntry); // Add to front (most recent first)
|
|
560
|
+
}
|
|
561
|
+
// Accumulate tokens
|
|
562
|
+
todayEntry.inputTokens += inputTokens;
|
|
563
|
+
todayEntry.outputTokens += outputTokens;
|
|
564
|
+
todayEntry.estimatedCost = this.calculateEstimatedCost(todayEntry.inputTokens, todayEntry.outputTokens);
|
|
565
|
+
// Only increment session count for unique sessions
|
|
566
|
+
if (sessionId && !this.dailySessionIds.has(sessionId)) {
|
|
567
|
+
this.dailySessionIds.add(sessionId);
|
|
568
|
+
todayEntry.sessions = this.dailySessionIds.size;
|
|
569
|
+
}
|
|
570
|
+
// Prune old entries (keep last 30 days)
|
|
571
|
+
if (stats.daily.length > StateStore.MAX_DAILY_HISTORY) {
|
|
572
|
+
stats.daily = stats.daily.slice(0, StateStore.MAX_DAILY_HISTORY);
|
|
573
|
+
}
|
|
574
|
+
stats.lastUpdated = Date.now();
|
|
575
|
+
this.save();
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Get daily stats for display.
|
|
579
|
+
* @param days Number of days to return (default: 30)
|
|
580
|
+
* @returns Array of daily entries, most recent first
|
|
581
|
+
*/
|
|
582
|
+
getDailyStats(days = 30) {
|
|
583
|
+
const stats = this.getTokenStats();
|
|
584
|
+
return stats.daily.slice(0, days);
|
|
585
|
+
}
|
|
586
|
+
// ========== Inner State Methods (Ralph Loop tracking) ==========
|
|
587
|
+
loadRalphStates() {
|
|
588
|
+
try {
|
|
589
|
+
if (existsSync(this.ralphStatePath)) {
|
|
590
|
+
const data = readFileSync(this.ralphStatePath, 'utf-8');
|
|
591
|
+
const parsed = JSON.parse(data);
|
|
592
|
+
for (const [sessionId, state] of Object.entries(parsed)) {
|
|
593
|
+
this.ralphStates.set(sessionId, state);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
catch (err) {
|
|
598
|
+
console.error('Failed to load inner states:', err);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
// Debounced save for inner states
|
|
602
|
+
saveRalphStates() {
|
|
603
|
+
this.ralphStateDirty = true;
|
|
604
|
+
if (this.ralphStateSaveTimeout) {
|
|
605
|
+
return; // Already scheduled
|
|
606
|
+
}
|
|
607
|
+
this.ralphStateSaveTimeout = setTimeout(() => {
|
|
608
|
+
this.saveRalphStatesNow();
|
|
609
|
+
}, SAVE_DEBOUNCE_MS);
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Immediate save for inner states using atomic write pattern.
|
|
613
|
+
* Writes to temp file first, then renames to prevent corruption on crash.
|
|
614
|
+
*/
|
|
615
|
+
saveRalphStatesNow() {
|
|
616
|
+
if (this.ralphStateSaveTimeout) {
|
|
617
|
+
clearTimeout(this.ralphStateSaveTimeout);
|
|
618
|
+
this.ralphStateSaveTimeout = null;
|
|
619
|
+
}
|
|
620
|
+
if (!this.ralphStateDirty) {
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
// Clear dirty flag only on success to enable retry on failure
|
|
624
|
+
this.ensureDir();
|
|
625
|
+
const data = Object.fromEntries(this.ralphStates);
|
|
626
|
+
// Atomic write: write to temp file, then rename (atomic on POSIX)
|
|
627
|
+
const tempPath = this.ralphStatePath + '.tmp';
|
|
628
|
+
let json;
|
|
629
|
+
try {
|
|
630
|
+
json = JSON.stringify(data);
|
|
631
|
+
}
|
|
632
|
+
catch (err) {
|
|
633
|
+
console.error('[StateStore] Failed to serialize Ralph state (circular reference or invalid data):', err);
|
|
634
|
+
// Keep dirty flag true for retry - don't throw, let caller continue
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
try {
|
|
638
|
+
writeFileSync(tempPath, json, 'utf-8');
|
|
639
|
+
renameSync(tempPath, this.ralphStatePath);
|
|
640
|
+
// Success - clear dirty flag
|
|
641
|
+
this.ralphStateDirty = false;
|
|
642
|
+
}
|
|
643
|
+
catch (err) {
|
|
644
|
+
console.error('[StateStore] Failed to write Ralph state file:', err);
|
|
645
|
+
// Keep dirty flag true for retry on next save
|
|
646
|
+
// Try to clean up temp file on error
|
|
647
|
+
try {
|
|
648
|
+
if (existsSync(tempPath)) {
|
|
649
|
+
unlinkSync(tempPath);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
catch (cleanupErr) {
|
|
653
|
+
console.warn('[StateStore] Failed to cleanup temp file during Ralph state save error:', cleanupErr);
|
|
654
|
+
}
|
|
655
|
+
// Don't throw - let caller continue, retry on next save
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
/** Returns inner state for a session, or null if not found. */
|
|
659
|
+
getRalphState(sessionId) {
|
|
660
|
+
return this.ralphStates.get(sessionId) ?? null;
|
|
661
|
+
}
|
|
662
|
+
/** Sets inner state for a session and triggers a debounced save. */
|
|
663
|
+
setRalphState(sessionId, state) {
|
|
664
|
+
this.ralphStates.set(sessionId, state);
|
|
665
|
+
this.saveRalphStates();
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Updates inner state for a session (partial merge).
|
|
669
|
+
* Creates initial state if none exists.
|
|
670
|
+
* @returns The updated inner state.
|
|
671
|
+
*/
|
|
672
|
+
updateRalphState(sessionId, updates) {
|
|
673
|
+
let state = this.ralphStates.get(sessionId);
|
|
674
|
+
if (!state) {
|
|
675
|
+
state = createInitialRalphSessionState(sessionId);
|
|
676
|
+
}
|
|
677
|
+
state = { ...state, ...updates, lastUpdated: Date.now() };
|
|
678
|
+
this.ralphStates.set(sessionId, state);
|
|
679
|
+
this.saveRalphStates();
|
|
680
|
+
return state;
|
|
681
|
+
}
|
|
682
|
+
/** Removes inner state for a session and triggers a debounced save. */
|
|
683
|
+
removeRalphState(sessionId) {
|
|
684
|
+
if (this.ralphStates.has(sessionId)) {
|
|
685
|
+
this.ralphStates.delete(sessionId);
|
|
686
|
+
this.saveRalphStates();
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
/** Returns a copy of all inner states as a Map. */
|
|
690
|
+
getAllRalphStates() {
|
|
691
|
+
return new Map(this.ralphStates);
|
|
692
|
+
}
|
|
693
|
+
/** Flushes all pending saves (main and inner state). Call before shutdown. */
|
|
694
|
+
flushAll() {
|
|
695
|
+
// Save both states, catching errors to ensure both are attempted
|
|
696
|
+
let mainError = null;
|
|
697
|
+
let ralphError = null;
|
|
698
|
+
try {
|
|
699
|
+
this.saveNow();
|
|
700
|
+
}
|
|
701
|
+
catch (err) {
|
|
702
|
+
mainError = err;
|
|
703
|
+
console.error('[StateStore] Error flushing main state:', err);
|
|
704
|
+
}
|
|
705
|
+
try {
|
|
706
|
+
this.saveRalphStatesNow();
|
|
707
|
+
}
|
|
708
|
+
catch (err) {
|
|
709
|
+
ralphError = err;
|
|
710
|
+
console.error('[StateStore] Error flushing Ralph state:', err);
|
|
711
|
+
}
|
|
712
|
+
// Log summary if any errors occurred
|
|
713
|
+
if (mainError || ralphError) {
|
|
714
|
+
console.warn('[StateStore] flushAll completed with errors - some state may not be persisted');
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
// Singleton instance
|
|
719
|
+
let storeInstance = null;
|
|
720
|
+
/**
|
|
721
|
+
* Gets or creates the singleton StateStore instance.
|
|
722
|
+
* @param filePath Optional custom file path (only used on first call).
|
|
723
|
+
*/
|
|
724
|
+
export function getStore(filePath) {
|
|
725
|
+
if (!storeInstance) {
|
|
726
|
+
storeInstance = new StateStore(filePath);
|
|
727
|
+
}
|
|
728
|
+
return storeInstance;
|
|
729
|
+
}
|
|
730
|
+
//# sourceMappingURL=state-store.js.map
|