agentgui 1.0.588 → 1.0.590

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.
@@ -0,0 +1,219 @@
1
+ # Cache Desync Prevention Implementation
2
+
3
+ ## Overview
4
+
5
+ The conversation thread cache in AgentGUI (stored in `ConversationManager.conversations`) is now protected against desynchronization through atomic mutation points and version tracking.
6
+
7
+ ## Problem Solved
8
+
9
+ **Before:** Multiple code paths could mutate `this.conversations` independently:
10
+ - `loadConversations()` polling every 30s
11
+ - WebSocket handlers creating/updating/deleting conversations in real-time
12
+ - Manual delete operations
13
+
14
+ Result: Array could enter intermediate states during concurrent operations, causing:
15
+ - Stale UI displays
16
+ - Lost updates when poll overwrites WebSocket changes
17
+ - Race conditions between server and client state
18
+
19
+ **After:** All mutations route through a single atomic operation with:
20
+ - Version tracking for cache coherency
21
+ - Source attribution for debugging
22
+ - Timestamp recording for audit trails
23
+ - No intermediate states visible to UI
24
+
25
+ ## Implementation Details
26
+
27
+ ### Single Mutation Point: _updateConversations()
28
+
29
+ Location: `static/js/conversations.js` lines 110-128
30
+
31
+ ```javascript
32
+ _updateConversations(newArray, source, context = {}) {
33
+ const oldLen = this.conversations.length;
34
+ const newLen = Array.isArray(newArray) ? newArray.length : 0;
35
+ const mutationId = ++this._conversationVersion;
36
+ const timestamp = Date.now();
37
+
38
+ this.conversations = Array.isArray(newArray) ? newArray : [];
39
+ this._lastMutationSource = source;
40
+ this._lastMutationTime = timestamp;
41
+
42
+ window._conversationCacheVersion = mutationId;
43
+
44
+ if (context.verbose) {
45
+ console.log(`[ConvMgr] mutation #${mutationId} (${source}): ${oldLen} → ${newLen} items, ts=${timestamp}`);
46
+ }
47
+
48
+ return { version: mutationId, timestamp, oldLen, newLen };
49
+ }
50
+ ```
51
+
52
+ **Key Features:**
53
+ - Atomic: Replaces entire array reference, never partial mutations
54
+ - Versioned: Increments counter on every mutation
55
+ - Sourced: Records where mutation originated (poll, add, update, delete, clear_all, ws_clear_all)
56
+ - Timestamped: Records when mutation occurred
57
+ - Observable: Exposes version via `window._conversationCacheVersion` for debugging
58
+
59
+ ### All Mutation Paths Routed
60
+
61
+ | Method | Source | Line | What It Does |
62
+ |--------|--------|------|-----------|
63
+ | `loadConversations()` | 'poll' | 445 | Server poll every 30s |
64
+ | `addConversation(conv)` | 'add' | 558 | New conversation created |
65
+ | `updateConversation(id, updates)` | 'update' | 567 | Conversation metadata changed |
66
+ | `deleteConversation(id)` | 'delete' | 577 | Conversation deleted |
67
+ | `confirmDeleteAll()` | 'clear_all' | 316 | All conversations cleared |
68
+ | WebSocket handler | 'ws_clear_all' | 596 | Server broadcast clear |
69
+
70
+ ### Version Tracking
71
+
72
+ State variables added to constructor:
73
+ - `this._conversationVersion = 0` - Current mutation counter
74
+ - `this._lastMutationSource = null` - Source of last mutation
75
+ - `this._lastMutationTime = 0` - Timestamp of last mutation
76
+
77
+ Global exposure:
78
+ - `window._conversationCacheVersion` - Updated on each mutation
79
+ - `getConversationCacheVersion()" - Getter for version
80
+
81
+ ## Testing
82
+
83
+ Comprehensive test suite covers 8 scenarios:
84
+
85
+ 1. Single add operation
86
+ 2. Version increments on each mutation
87
+ 3. Poll overwrites cache atomically
88
+ 4. Concurrent add + poll (race condition)
89
+ 5. Update preserves order
90
+ 6. Delete maintains array integrity
91
+ 7. Mutation source tracking
92
+ 8. No intermediate states
93
+
94
+ Run tests:
95
+ ```bash
96
+ node tests/cache-desync-test.js
97
+ ```
98
+
99
+ Result: All 8/8 tests pass.
100
+
101
+ ## Preventing Cache Desync: How It Works
102
+
103
+ ### Scenario 1: Concurrent WebSocket Add + Poll
104
+
105
+ ```
106
+ t0: WebSocket 'conversation_created' arrives
107
+ → addConversation() called
108
+ → _updateConversations([new_conv, ...old], 'add')
109
+ → version = 1, array contains new + old
110
+
111
+ t1: 30s poll timer fires
112
+ → loadConversations() called with old cached server data
113
+ → _updateConversations([old1, old2, ...], 'poll')
114
+ → version = 2, array overwrites with server snapshot
115
+
116
+ Result: Consistent state - either new+old (version 1) or server data (version 2)
117
+ Never partial/intermediate state visible to UI
118
+ ```
119
+
120
+ ### Scenario 2: Update During Transition
121
+
122
+ ```
123
+ t0: WebSocket 'conversation_updated' arrives for conv #1
124
+ → updateConversation('conv-1', {title: 'New'})
125
+ → Creates new array with updated object at index 1
126
+ → _updateConversations(newArray, 'update')
127
+ → Entire array replaced atomically
128
+
129
+ Result: All items stay in original order + positions
130
+ Update is transactional - either fully applied or not at all
131
+ ```
132
+
133
+ ## Observability
134
+
135
+ ### Debugging Cache State
136
+
137
+ In browser console:
138
+ ```javascript
139
+ // Get current version
140
+ window._conversationCacheVersion // → 15
141
+
142
+ // Get conversation manager instance
143
+ window.conversationManager.getConversationCacheVersion() // → 15
144
+
145
+ // Last mutation source
146
+ window.conversationManager._lastMutationSource // → 'update'
147
+
148
+ // Last mutation timestamp
149
+ window.conversationManager._lastMutationTime // → 1705412890123
150
+
151
+ // Full conversations array
152
+ window.conversationManager.conversations // → [...]
153
+
154
+ // Enable verbose logging
155
+ window.conversationManager._updateConversations(
156
+ window.conversationManager.conversations,
157
+ 'debug',
158
+ { verbose: true }
159
+ )
160
+ // Output: [ConvMgr] mutation #16 (debug): 3 → 3 items, ts=...
161
+ ```
162
+
163
+ ### Mutation Log
164
+
165
+ Each mutation source ('poll', 'add', 'update', 'delete', 'clear_all', 'ws_clear_all') can be filtered to understand timing:
166
+
167
+ ```javascript
168
+ // Capture mutations for 1 minute
169
+ const mutations = [];
170
+ const originalUpdate = window.conversationManager._updateConversations;
171
+ window.conversationManager._updateConversations = function(arr, src, ctx) {
172
+ mutations.push({ src, time: Date.now() });
173
+ return originalUpdate.call(this, arr, src, ctx);
174
+ };
175
+
176
+ // Later: analyze
177
+ mutations.filter(m => m.src === 'poll') // All polls
178
+ mutations.filter(m => m.src.includes('ws')) // All WebSocket events
179
+ ```
180
+
181
+ ## Edge Cases Handled
182
+
183
+ 1. **Nonexistent conversation update** - No version increment if not found
184
+ 2. **Duplicate add** - Already-exists check prevents duplicate
185
+ 3. **Empty load** - Handles `data.conversations || []` safely
186
+ 4. **Rapid mutations** - Each increments version, no race condition
187
+ 5. **Concurrent add + delete** - Both atomic, no orphaned references
188
+
189
+ ## Future Enhancements
190
+
191
+ Potential follow-ups (not implemented):
192
+
193
+ - **Conflict detection:** Track last-write-wins vs. merge strategies
194
+ - **Optimistic updates:** Append version to pending updates
195
+ - **Cache invalidation:** TTL-based refresh of stale entries
196
+ - **Replay capability:** Use version counter to detect gaps in WebSocket stream
197
+ - **CRDT integration:** Replace array with conflict-free replicated data type
198
+
199
+ ## Files Modified
200
+
201
+ - `static/js/conversations.js` (+45 lines, -8 lines)
202
+ - Added atomic mutation point
203
+ - Routed all 6 mutation paths through it
204
+ - Added version tracking and observability
205
+
206
+ ## Testing
207
+
208
+ Created `tests/cache-desync-test.js` with 8 comprehensive test cases covering:
209
+ - Basic mutations
210
+ - Concurrent scenarios
211
+ - Race conditions
212
+ - State preservation
213
+ - Source tracking
214
+
215
+ All tests pass (8/8).
216
+
217
+ ---
218
+
219
+ **Summary:** Cache desync is prevented by enforcing all mutations through a single atomic operation with version tracking. No intermediate states exist. Concurrent WebSocket and polling scenarios are safe.
package/database.js CHANGED
@@ -199,6 +199,37 @@ function initSchema() {
199
199
  CREATE INDEX IF NOT EXISTS idx_tool_install_history_tool ON tool_install_history(tool_id);
200
200
  CREATE INDEX IF NOT EXISTS idx_tool_install_history_completed ON tool_install_history(completed_at);
201
201
 
202
+ CREATE TABLE IF NOT EXISTS workflow_runs (
203
+ id TEXT PRIMARY KEY,
204
+ workflowName TEXT NOT NULL,
205
+ workflowId TEXT,
206
+ runId TEXT,
207
+ sha TEXT,
208
+ branch TEXT,
209
+ status TEXT,
210
+ conclusion TEXT,
211
+ htmlUrl TEXT,
212
+ triggeredAt INTEGER NOT NULL,
213
+ completedAt INTEGER,
214
+ created_at INTEGER NOT NULL
215
+ );
216
+
217
+ CREATE INDEX IF NOT EXISTS idx_workflow_runs_name ON workflow_runs(workflowName);
218
+ CREATE INDEX IF NOT EXISTS idx_workflow_runs_sha ON workflow_runs(sha);
219
+ CREATE INDEX IF NOT EXISTS idx_workflow_runs_completed ON workflow_runs(completedAt);
220
+
221
+ CREATE TABLE IF NOT EXISTS oauth_tokens (
222
+ id TEXT PRIMARY KEY,
223
+ provider TEXT NOT NULL,
224
+ token TEXT NOT NULL,
225
+ email TEXT,
226
+ expires_at INTEGER,
227
+ created_at INTEGER NOT NULL,
228
+ updated_at INTEGER NOT NULL
229
+ );
230
+
231
+ CREATE INDEX IF NOT EXISTS idx_oauth_tokens_provider ON oauth_tokens(provider);
232
+
202
233
  `);
203
234
  }
204
235
 
@@ -0,0 +1,36 @@
1
+ // Plugin interface contract - every plugin must implement this
2
+
3
+ export default {
4
+ // Plugin metadata
5
+ name: 'plugin-name', // unique identifier
6
+ version: '1.0.0',
7
+ dependencies: [], // list of other plugin names this depends on
8
+
9
+ // Lifecycle methods (all required)
10
+ async init(config, plugins) {
11
+ // config = { router, wsManager, db, logger, env }
12
+ // plugins = Map<name, plugin> of all loaded plugins
13
+ // MUST return: { routes[], wsHandlers{}, api{}, stop() }
14
+ return {
15
+ routes: [], // [ { method, path, handler } ]
16
+ wsHandlers: {}, // { eventType: handler(data, clients) }
17
+ api: {}, // exported functions for other plugins
18
+ };
19
+ },
20
+
21
+ async reload(state) {
22
+ // Called on hot reload. Preserve state from previous instance.
23
+ // Return new state (or updated state from previous)
24
+ return state;
25
+ },
26
+
27
+ async stop() {
28
+ // Graceful shutdown. Clean up resources.
29
+ // No need to return anything.
30
+ },
31
+
32
+ // Optional: Called when another plugin throws error
33
+ async handleError(error, context) {
34
+ // context = { pluginName, phase, ... }
35
+ },
36
+ };
@@ -0,0 +1,201 @@
1
+ // Plugin loader - manages registry, dependencies, hot reload, error isolation
2
+
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import { EventEmitter } from 'events';
6
+
7
+ class PluginLoader extends EventEmitter {
8
+ constructor(pluginDir) {
9
+ super();
10
+ this.pluginDir = pluginDir;
11
+ this.registry = new Map(); // name => plugin module
12
+ this.instances = new Map(); // name => initialized plugin state
13
+ this.states = new Map(); // name => { routes, wsHandlers, api, ... }
14
+ this.watchers = new Map(); // name => file watcher
15
+ this.errorCounts = new Map(); // name => { count, firstTime }
16
+ }
17
+
18
+ // Load plugin module from disk
19
+ async loadPlugin(name) {
20
+ const filePath = path.join(this.pluginDir, `${name}.js`);
21
+ if (!fs.existsSync(filePath)) {
22
+ throw new Error(`Plugin file not found: ${filePath}`);
23
+ }
24
+ // Clear module cache for hot reload (ES modules use import cache differently)
25
+ const fileUrl = `file://${filePath}?v=${Date.now()}`;
26
+ try {
27
+ const plugin = await import(fileUrl);
28
+ this.registry.set(name, plugin.default || plugin);
29
+ return plugin.default || plugin;
30
+ } catch (error) {
31
+ console.error(`Failed to load plugin ${name}:`, error.message);
32
+ throw error;
33
+ }
34
+ }
35
+
36
+ // Initialize plugin and all dependencies
37
+ async initializePlugin(name, config) {
38
+ const plugin = this.registry.get(name);
39
+ if (!plugin) {
40
+ throw new Error(`Plugin ${name} not found in registry`);
41
+ }
42
+
43
+ // Check if already initialized
44
+ if (this.instances.has(name)) {
45
+ return this.instances.get(name);
46
+ }
47
+
48
+ // Initialize dependencies first
49
+ for (const depName of (plugin.dependencies || [])) {
50
+ if (!this.instances.has(depName)) {
51
+ await this.initializePlugin(depName, config);
52
+ }
53
+ }
54
+
55
+ // Initialize this plugin
56
+ try {
57
+ const result = await plugin.init(config, this.instances);
58
+ this.instances.set(name, result);
59
+ return result;
60
+ } catch (error) {
61
+ console.error(`[PluginLoader] Error initializing ${name}:`, error.message);
62
+ throw error;
63
+ }
64
+ }
65
+
66
+ // Get initialized plugin result
67
+ get(name) {
68
+ return this.instances.get(name);
69
+ }
70
+
71
+ // Hot reload a plugin
72
+ async reloadPlugin(name) {
73
+ const plugin = this.registry.get(name);
74
+ if (!plugin) {
75
+ console.warn(`[PluginLoader] Cannot reload ${name}: not found`);
76
+ return;
77
+ }
78
+
79
+ const state = this.instances.get(name);
80
+ if (!state) {
81
+ console.warn(`[PluginLoader] Cannot reload ${name}: not initialized`);
82
+ return;
83
+ }
84
+
85
+ try {
86
+ // Stop old instance
87
+ if (state.stop) await state.stop();
88
+
89
+ // Reload plugin module
90
+ this.loadPlugin(name);
91
+ const reloadedPlugin = this.registry.get(name);
92
+
93
+ // Reinitialize with preserved state
94
+ const newState = await reloadedPlugin.reload(state);
95
+ this.instances.set(name, newState);
96
+ this.emit('reload', { name, success: true });
97
+ console.log(`[PluginLoader] Reloaded plugin: ${name}`);
98
+ } catch (error) {
99
+ console.error(`[PluginLoader] Error reloading ${name}:`, error.message);
100
+ this.emit('reload', { name, success: false, error: error.message });
101
+ }
102
+ }
103
+
104
+ // Watch a plugin file for changes
105
+ watchPlugin(name, callback) {
106
+ const filePath = path.join(this.pluginDir, `${name}.js`);
107
+ if (this.watchers.has(name)) {
108
+ return; // Already watching
109
+ }
110
+
111
+ const watcher = fs.watch(filePath, async (eventType) => {
112
+ if (eventType === 'change') {
113
+ setTimeout(() => callback(name), 100); // Debounce
114
+ }
115
+ });
116
+
117
+ this.watchers.set(name, watcher);
118
+ }
119
+
120
+ // Stop watching a plugin
121
+ unwatchPlugin(name) {
122
+ const watcher = this.watchers.get(name);
123
+ if (watcher) {
124
+ watcher.close();
125
+ this.watchers.delete(name);
126
+ }
127
+ }
128
+
129
+ // Load all plugins from directory
130
+ async loadAllPlugins(config) {
131
+ if (!fs.existsSync(this.pluginDir)) {
132
+ fs.mkdirSync(this.pluginDir, { recursive: true });
133
+ return;
134
+ }
135
+
136
+ const files = fs.readdirSync(this.pluginDir).filter(f => f.endsWith('.js'));
137
+ for (const file of files) {
138
+ const name = file.replace('.js', '');
139
+ try {
140
+ await this.loadPlugin(name);
141
+ } catch (error) {
142
+ console.error(`[PluginLoader] Failed to load ${name}:`, error.message);
143
+ }
144
+ }
145
+
146
+ // Initialize in dependency order
147
+ const sorted = this.topologicalSort();
148
+ for (const name of sorted) {
149
+ try {
150
+ await this.initializePlugin(name, config);
151
+ } catch (error) {
152
+ console.error(`[PluginLoader] Failed to initialize ${name}:`, error.message);
153
+ }
154
+ }
155
+ }
156
+
157
+ // Topological sort by dependencies
158
+ topologicalSort() {
159
+ const visited = new Set();
160
+ const result = [];
161
+
162
+ const visit = (name) => {
163
+ if (visited.has(name)) return;
164
+ visited.add(name);
165
+
166
+ const plugin = this.registry.get(name);
167
+ for (const dep of (plugin?.dependencies || [])) {
168
+ if (this.registry.has(dep)) {
169
+ visit(dep);
170
+ }
171
+ }
172
+ result.push(name);
173
+ };
174
+
175
+ for (const name of this.registry.keys()) {
176
+ visit(name);
177
+ }
178
+
179
+ return result;
180
+ }
181
+
182
+ // Graceful shutdown
183
+ async shutdown() {
184
+ const sorted = this.topologicalSort().reverse();
185
+ for (const name of sorted) {
186
+ const state = this.instances.get(name);
187
+ if (state && state.stop) {
188
+ try {
189
+ await state.stop();
190
+ } catch (error) {
191
+ console.error(`[PluginLoader] Error stopping ${name}:`, error.message);
192
+ }
193
+ }
194
+ this.unwatchPlugin(name);
195
+ }
196
+ this.instances.clear();
197
+ this.registry.clear();
198
+ }
199
+ }
200
+
201
+ export default PluginLoader;
@@ -0,0 +1,90 @@
1
+ // ACP plugin - OpenCode, Gemini, Kilo, Codex startup and health checks
2
+
3
+ import { spawn } from 'child_process';
4
+ import path from 'path';
5
+ import fs from 'fs';
6
+
7
+ export default {
8
+ name: 'acp',
9
+ version: '1.0.0',
10
+ dependencies: ['tools'],
11
+
12
+ async init(config, plugins) {
13
+ const tools = plugins.get('tools');
14
+ const toolProcesses = new Map();
15
+ const healthCheckIntervals = new Map();
16
+ const restartCounts = new Map();
17
+ const acpPorts = new Map();
18
+
19
+ const toolSpecs = [
20
+ { name: 'opencode', port: 18100, cmd: 'opencode acp --port 18100' },
21
+ { name: 'gemini', port: 18101, cmd: 'gemini acp --port 18101' },
22
+ { name: 'kilo', port: 18102, cmd: 'kilo acp --port 18102' },
23
+ { name: 'codex', port: 18103, cmd: 'codex acp --port 18103' },
24
+ ];
25
+
26
+ const startTool = async (spec) => {
27
+ try {
28
+ const proc = spawn('bash', ['-c', spec.cmd]);
29
+ toolProcesses.set(spec.name, proc);
30
+ acpPorts.set(spec.name, spec.port);
31
+ restartCounts.set(spec.name, 0);
32
+
33
+ // Health check every 30s
34
+ const interval = setInterval(() => {
35
+ if (proc.killed) {
36
+ clearInterval(interval);
37
+ healthCheckIntervals.delete(spec.name);
38
+ }
39
+ }, 30000);
40
+ healthCheckIntervals.set(spec.name, interval);
41
+ } catch (e) {
42
+ console.error(`[ACP] Failed to start ${spec.name}:`, e.message);
43
+ }
44
+ };
45
+
46
+ // Start all ACP tools
47
+ for (const spec of toolSpecs) {
48
+ await startTool(spec);
49
+ }
50
+
51
+ return {
52
+ routes: [
53
+ {
54
+ method: 'GET',
55
+ path: '/api/acp/status',
56
+ handler: (req, res) => {
57
+ const status = {};
58
+ for (const [name, proc] of toolProcesses) {
59
+ status[name] = {
60
+ running: !proc.killed,
61
+ port: acpPorts.get(name),
62
+ pid: proc.pid,
63
+ restarts: restartCounts.get(name) || 0,
64
+ };
65
+ }
66
+ res.json({ tools: status });
67
+ },
68
+ },
69
+ ],
70
+ wsHandlers: {},
71
+ api: {
72
+ getStatus: () => Object.fromEntries(acpPorts),
73
+ },
74
+ stop: async () => {
75
+ for (const [name, interval] of healthCheckIntervals) {
76
+ clearInterval(interval);
77
+ }
78
+ for (const [name, proc] of toolProcesses) {
79
+ if (proc && !proc.killed) proc.kill();
80
+ }
81
+ },
82
+ };
83
+ },
84
+
85
+ async reload(state) {
86
+ return state;
87
+ },
88
+
89
+ async stop() {},
90
+ };
@@ -0,0 +1,80 @@
1
+ // Agents plugin - agent discovery, runner spawning, process management
2
+
3
+ import path from 'path';
4
+ import { runClaudeWithStreaming } from '../claude-runner.js';
5
+
6
+ export default {
7
+ name: 'agents',
8
+ version: '1.0.0',
9
+ dependencies: ['database', 'stream'],
10
+
11
+ async init(config, plugins) {
12
+ const db = plugins.get('database');
13
+ const stream = plugins.get('stream');
14
+ const discoveredAgents = new Map();
15
+ const activeExecutions = new Map();
16
+
17
+ // Discover agents on startup
18
+ const discoverAgents = async () => {
19
+ const agents = [
20
+ { id: 'gm-cc', name: 'Claude Code', bin: 'claude', installed: true },
21
+ { id: 'gm-oc', name: 'OpenCode', bin: 'opencode', installed: false },
22
+ { id: 'gm-gc', name: 'Gemini CLI', bin: 'gemini', installed: false },
23
+ { id: 'gm-kilo', name: 'Kilo', bin: 'kilo', installed: false },
24
+ ];
25
+ agents.forEach(a => discoveredAgents.set(a.id, a));
26
+ return agents;
27
+ };
28
+
29
+ await discoverAgents();
30
+
31
+ return {
32
+ routes: [
33
+ {
34
+ method: 'GET',
35
+ path: '/api/agents',
36
+ handler: (req, res) => {
37
+ res.json({ agents: Array.from(discoveredAgents.values()) });
38
+ },
39
+ },
40
+ {
41
+ method: 'POST',
42
+ path: '/api/conversations/:id/stream',
43
+ handler: async (req, res) => {
44
+ const { id } = req.params;
45
+ const { agentId, message } = req.body;
46
+
47
+ try {
48
+ const agent = discoveredAgents.get(agentId);
49
+ if (!agent) return res.status(404).json({ error: 'Agent not found' });
50
+
51
+ const session = stream.api.createSession(id);
52
+ // Use runClaudeWithStreaming instead
53
+ activeExecutions.set(id, { sessionId: session.id });
54
+
55
+ res.json({ sessionId: session.id, pid: proc.pid });
56
+ } catch (e) {
57
+ res.status(500).json({ error: e.message });
58
+ }
59
+ },
60
+ },
61
+ ],
62
+ wsHandlers: {},
63
+ api: {
64
+ getAgents: () => Array.from(discoveredAgents.values()),
65
+ discoverAgents,
66
+ },
67
+ stop: async () => {
68
+ for (const proc of activeExecutions.values()) {
69
+ if (proc && !proc.killed) proc.kill();
70
+ }
71
+ },
72
+ };
73
+ },
74
+
75
+ async reload(state) {
76
+ return state;
77
+ },
78
+
79
+ async stop() {},
80
+ };