agentgui 1.0.32 → 1.0.34
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/DIAGNOSTICS.md +61 -0
- package/IMPLEMENTATION_CHECKLIST.md +287 -0
- package/STATE_MACHINE_SUMMARY.md +172 -0
- package/database.js +43 -32
- package/package.json +1 -1
- package/server.js +223 -70
- package/state-manager.js +360 -0
- package/test-state-manager.js +55 -0
package/state-manager.js
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StateManager - Explicit state machine for all prompt processing
|
|
3
|
+
* Ensures predictable, auditable state transitions with no surprises
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class StateManager {
|
|
7
|
+
// Valid session states
|
|
8
|
+
static STATES = {
|
|
9
|
+
PENDING: 'pending',
|
|
10
|
+
ACQUIRING_ACP: 'acquiring_acp',
|
|
11
|
+
ACP_ACQUIRED: 'acp_acquired',
|
|
12
|
+
SENDING_PROMPT: 'sending_prompt',
|
|
13
|
+
PROCESSING: 'processing',
|
|
14
|
+
COMPLETED: 'completed',
|
|
15
|
+
ERROR: 'error',
|
|
16
|
+
TIMEOUT: 'timeout',
|
|
17
|
+
CANCELLED: 'cancelled'
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Valid state transitions - only these are allowed
|
|
21
|
+
static VALID_TRANSITIONS = {
|
|
22
|
+
[this.STATES.PENDING]: [
|
|
23
|
+
this.STATES.ACQUIRING_ACP,
|
|
24
|
+
this.STATES.CANCELLED
|
|
25
|
+
],
|
|
26
|
+
[this.STATES.ACQUIRING_ACP]: [
|
|
27
|
+
this.STATES.ACP_ACQUIRED,
|
|
28
|
+
this.STATES.ERROR,
|
|
29
|
+
this.STATES.TIMEOUT,
|
|
30
|
+
this.STATES.CANCELLED
|
|
31
|
+
],
|
|
32
|
+
[this.STATES.ACP_ACQUIRED]: [
|
|
33
|
+
this.STATES.SENDING_PROMPT,
|
|
34
|
+
this.STATES.ERROR,
|
|
35
|
+
this.STATES.TIMEOUT,
|
|
36
|
+
this.STATES.CANCELLED
|
|
37
|
+
],
|
|
38
|
+
[this.STATES.SENDING_PROMPT]: [
|
|
39
|
+
this.STATES.PROCESSING,
|
|
40
|
+
this.STATES.ERROR,
|
|
41
|
+
this.STATES.TIMEOUT,
|
|
42
|
+
this.STATES.CANCELLED
|
|
43
|
+
],
|
|
44
|
+
[this.STATES.PROCESSING]: [
|
|
45
|
+
this.STATES.COMPLETED,
|
|
46
|
+
this.STATES.ERROR,
|
|
47
|
+
this.STATES.TIMEOUT,
|
|
48
|
+
this.STATES.CANCELLED
|
|
49
|
+
],
|
|
50
|
+
[this.STATES.COMPLETED]: [],
|
|
51
|
+
[this.STATES.ERROR]: [],
|
|
52
|
+
[this.STATES.TIMEOUT]: [],
|
|
53
|
+
[this.STATES.CANCELLED]: []
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
constructor(sessionId, conversationId, messageId, timeout = 120000) {
|
|
57
|
+
this.sessionId = sessionId;
|
|
58
|
+
this.conversationId = conversationId;
|
|
59
|
+
this.messageId = messageId;
|
|
60
|
+
this.timeout = timeout;
|
|
61
|
+
|
|
62
|
+
// State tracking
|
|
63
|
+
this.state = this.constructor.STATES.PENDING;
|
|
64
|
+
this.previousState = null;
|
|
65
|
+
this.stateHistory = [{ state: this.state, timestamp: Date.now(), reason: 'initialized' }];
|
|
66
|
+
|
|
67
|
+
// Data tracking
|
|
68
|
+
this.data = {
|
|
69
|
+
acpConnectionTime: null,
|
|
70
|
+
promptSentTime: null,
|
|
71
|
+
responseReceivedTime: null,
|
|
72
|
+
fullText: '',
|
|
73
|
+
blocks: [],
|
|
74
|
+
error: null,
|
|
75
|
+
stackTrace: null
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// Promise resolution
|
|
79
|
+
this.promiseResolve = null;
|
|
80
|
+
this.promiseReject = null;
|
|
81
|
+
this.completionPromise = new Promise((resolve, reject) => {
|
|
82
|
+
this.promiseResolve = resolve;
|
|
83
|
+
this.promiseReject = reject;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Start timeout
|
|
87
|
+
this.startTimeout();
|
|
88
|
+
|
|
89
|
+
console.log(`[StateManager] Session ${sessionId} initialized (timeout: ${timeout}ms)`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Transition to a new state with validation
|
|
94
|
+
* @param {string} newState - Target state
|
|
95
|
+
* @param {object} data - State-specific data
|
|
96
|
+
* @throws {Error} If transition is invalid
|
|
97
|
+
*/
|
|
98
|
+
transition(newState, data = {}) {
|
|
99
|
+
const validTransitions = this.constructor.VALID_TRANSITIONS[this.state] || [];
|
|
100
|
+
|
|
101
|
+
if (!validTransitions.includes(newState)) {
|
|
102
|
+
const error = `Invalid state transition: ${this.state} → ${newState}. Valid: [${validTransitions.join(', ')}]`;
|
|
103
|
+
console.error(`[StateManager] ${error}`);
|
|
104
|
+
throw new Error(error);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
this.previousState = this.state;
|
|
108
|
+
this.state = newState;
|
|
109
|
+
|
|
110
|
+
// Record transition
|
|
111
|
+
this.stateHistory.push({
|
|
112
|
+
state: newState,
|
|
113
|
+
timestamp: Date.now(),
|
|
114
|
+
reason: data.reason || 'manual transition',
|
|
115
|
+
details: data.details || {}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Update data
|
|
119
|
+
Object.assign(this.data, data.data || {});
|
|
120
|
+
|
|
121
|
+
// Log transition
|
|
122
|
+
const duration = this.stateHistory.length > 1
|
|
123
|
+
? Date.now() - this.stateHistory[this.stateHistory.length - 2].timestamp
|
|
124
|
+
: 0;
|
|
125
|
+
|
|
126
|
+
console.log(`[StateManager] ${this.sessionId} transitioned: ${this.previousState} → ${newState} (+${duration}ms) | ${data.reason || ''}`);
|
|
127
|
+
|
|
128
|
+
// Handle terminal states
|
|
129
|
+
if (newState === this.constructor.STATES.COMPLETED) {
|
|
130
|
+
this.completeSuccess(data.data);
|
|
131
|
+
} else if (newState === this.constructor.STATES.ERROR) {
|
|
132
|
+
this.completeError(data.data?.error, data.data?.stackTrace);
|
|
133
|
+
} else if (newState === this.constructor.STATES.TIMEOUT) {
|
|
134
|
+
this.completeError('Operation timeout', data.data?.stackTrace);
|
|
135
|
+
} else if (newState === this.constructor.STATES.CANCELLED) {
|
|
136
|
+
this.completeError('Operation cancelled', null);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Start timeout watchdog
|
|
142
|
+
*/
|
|
143
|
+
startTimeout() {
|
|
144
|
+
this.timeoutHandle = setTimeout(() => {
|
|
145
|
+
if (![
|
|
146
|
+
this.constructor.STATES.COMPLETED,
|
|
147
|
+
this.constructor.STATES.ERROR,
|
|
148
|
+
this.constructor.STATES.CANCELLED,
|
|
149
|
+
this.constructor.STATES.TIMEOUT
|
|
150
|
+
].includes(this.state)) {
|
|
151
|
+
console.error(`[StateManager] ${this.sessionId} TIMEOUT after ${this.timeout}ms in state: ${this.state}`);
|
|
152
|
+
this.transition(this.constructor.STATES.TIMEOUT, {
|
|
153
|
+
reason: 'timeout watchdog fired',
|
|
154
|
+
data: { error: 'Operation exceeded timeout', timeout: this.timeout }
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}, this.timeout);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Cancel the timeout
|
|
162
|
+
*/
|
|
163
|
+
cancelTimeout() {
|
|
164
|
+
if (this.timeoutHandle) {
|
|
165
|
+
clearTimeout(this.timeoutHandle);
|
|
166
|
+
this.timeoutHandle = null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Mark as successfully completed
|
|
172
|
+
*/
|
|
173
|
+
completeSuccess(data) {
|
|
174
|
+
this.cancelTimeout();
|
|
175
|
+
this.data = { ...this.data, ...data };
|
|
176
|
+
if (this.promiseResolve) {
|
|
177
|
+
this.promiseResolve({ state: this.state, data: this.data });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Mark as failed
|
|
183
|
+
*/
|
|
184
|
+
completeError(error, stackTrace) {
|
|
185
|
+
this.cancelTimeout();
|
|
186
|
+
this.data.error = error;
|
|
187
|
+
this.data.stackTrace = stackTrace;
|
|
188
|
+
if (this.promiseReject) {
|
|
189
|
+
this.promiseReject(new Error(`Session failed: ${error}`));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Get current state
|
|
195
|
+
*/
|
|
196
|
+
getState() {
|
|
197
|
+
return this.state;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Get full state history
|
|
202
|
+
*/
|
|
203
|
+
getHistory() {
|
|
204
|
+
return this.stateHistory;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Get human-readable summary
|
|
209
|
+
*/
|
|
210
|
+
getSummary() {
|
|
211
|
+
const duration = this.stateHistory[this.stateHistory.length - 1].timestamp - this.stateHistory[0].timestamp;
|
|
212
|
+
return {
|
|
213
|
+
sessionId: this.sessionId,
|
|
214
|
+
conversationId: this.conversationId,
|
|
215
|
+
messageId: this.messageId,
|
|
216
|
+
state: this.state,
|
|
217
|
+
previousState: this.previousState,
|
|
218
|
+
duration: `${duration}ms`,
|
|
219
|
+
historyLength: this.stateHistory.length,
|
|
220
|
+
history: this.stateHistory.map(h => `${h.timestamp - this.stateHistory[0].timestamp}ms: ${h.state} (${h.reason})`),
|
|
221
|
+
data: {
|
|
222
|
+
fullTextLength: this.data.fullText.length,
|
|
223
|
+
blocksCount: this.data.blocks.length,
|
|
224
|
+
error: this.data.error,
|
|
225
|
+
hasStackTrace: !!this.data.stackTrace
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Wait for completion
|
|
232
|
+
*/
|
|
233
|
+
async waitForCompletion() {
|
|
234
|
+
return this.completionPromise;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Check if session is in a terminal state
|
|
239
|
+
*/
|
|
240
|
+
isTerminal() {
|
|
241
|
+
return [
|
|
242
|
+
this.constructor.STATES.COMPLETED,
|
|
243
|
+
this.constructor.STATES.ERROR,
|
|
244
|
+
this.constructor.STATES.TIMEOUT,
|
|
245
|
+
this.constructor.STATES.CANCELLED
|
|
246
|
+
].includes(this.state);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Check if session is in a running state
|
|
251
|
+
*/
|
|
252
|
+
isRunning() {
|
|
253
|
+
return !this.isTerminal();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Assert session is in specific state
|
|
258
|
+
*/
|
|
259
|
+
assertState(expectedState) {
|
|
260
|
+
if (this.state !== expectedState) {
|
|
261
|
+
throw new Error(`Expected state ${expectedState}, got ${this.state}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Assert session can transition to state
|
|
267
|
+
*/
|
|
268
|
+
assertCanTransition(targetState) {
|
|
269
|
+
const validTransitions = this.constructor.VALID_TRANSITIONS[this.state] || [];
|
|
270
|
+
if (!validTransitions.includes(targetState)) {
|
|
271
|
+
throw new Error(`Cannot transition from ${this.state} to ${targetState}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export class SessionStateStore {
|
|
277
|
+
constructor() {
|
|
278
|
+
this.sessions = new Map(); // sessionId -> StateManager
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
create(sessionId, conversationId, messageId, timeout) {
|
|
282
|
+
const stateManager = new StateManager(sessionId, conversationId, messageId, timeout);
|
|
283
|
+
this.sessions.set(sessionId, stateManager);
|
|
284
|
+
return stateManager;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
get(sessionId) {
|
|
288
|
+
return this.sessions.get(sessionId);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
getOrThrow(sessionId) {
|
|
292
|
+
const manager = this.sessions.get(sessionId);
|
|
293
|
+
if (!manager) {
|
|
294
|
+
throw new Error(`Session ${sessionId} not found in state store`);
|
|
295
|
+
}
|
|
296
|
+
return manager;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
remove(sessionId) {
|
|
300
|
+
const manager = this.sessions.get(sessionId);
|
|
301
|
+
if (manager) {
|
|
302
|
+
manager.cancelTimeout();
|
|
303
|
+
this.sessions.delete(sessionId);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
getAll() {
|
|
308
|
+
return Array.from(this.sessions.values());
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
getAllActive() {
|
|
312
|
+
return this.getAll().filter(m => m.isRunning());
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
getAllTerminal() {
|
|
316
|
+
return this.getAll().filter(m => m.isTerminal());
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Get diagnostic summary of all sessions
|
|
321
|
+
*/
|
|
322
|
+
getDiagnostics() {
|
|
323
|
+
const active = this.getAllActive();
|
|
324
|
+
const terminal = this.getAllTerminal();
|
|
325
|
+
return {
|
|
326
|
+
timestamp: new Date().toISOString(),
|
|
327
|
+
activeSessions: active.length,
|
|
328
|
+
terminalSessions: terminal.length,
|
|
329
|
+
totalSessions: this.sessions.size,
|
|
330
|
+
active: active.map(m => ({
|
|
331
|
+
sessionId: m.sessionId,
|
|
332
|
+
state: m.state,
|
|
333
|
+
uptime: Date.now() - m.stateHistory[0].timestamp
|
|
334
|
+
})),
|
|
335
|
+
recentTerminal: terminal.slice(-5).map(m => m.getSummary())
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Cleanup old terminal sessions (older than ttl)
|
|
341
|
+
*/
|
|
342
|
+
cleanup(ttl = 3600000) {
|
|
343
|
+
const now = Date.now();
|
|
344
|
+
const toDelete = [];
|
|
345
|
+
|
|
346
|
+
for (const [sessionId, manager] of this.sessions) {
|
|
347
|
+
if (manager.isTerminal()) {
|
|
348
|
+
const age = now - manager.stateHistory[manager.stateHistory.length - 1].timestamp;
|
|
349
|
+
if (age > ttl) {
|
|
350
|
+
toDelete.push(sessionId);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
toDelete.forEach(sessionId => this.remove(sessionId));
|
|
356
|
+
if (toDelete.length > 0) {
|
|
357
|
+
console.log(`[SessionStateStore] Cleaned up ${toDelete.length} old sessions`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { StateManager, SessionStateStore } from './state-manager.js';
|
|
2
|
+
|
|
3
|
+
console.log('Testing StateManager...\n');
|
|
4
|
+
|
|
5
|
+
// Create a session
|
|
6
|
+
const store = new SessionStateStore();
|
|
7
|
+
const session = store.create('sess-123', 'conv-456', 'msg-789', 5000);
|
|
8
|
+
|
|
9
|
+
console.log(`Initial state: ${session.getState()}`);
|
|
10
|
+
|
|
11
|
+
// Test transitions
|
|
12
|
+
try {
|
|
13
|
+
session.transition(session.constructor.STATES.ACQUIRING_ACP, {
|
|
14
|
+
reason: 'Starting ACP connection',
|
|
15
|
+
data: {}
|
|
16
|
+
});
|
|
17
|
+
console.log(`After 1st transition: ${session.getState()}`);
|
|
18
|
+
|
|
19
|
+
session.transition(session.constructor.STATES.ACP_ACQUIRED, {
|
|
20
|
+
reason: 'ACP connected',
|
|
21
|
+
data: { acpConnectionTime: Date.now() }
|
|
22
|
+
});
|
|
23
|
+
console.log(`After 2nd transition: ${session.getState()}`);
|
|
24
|
+
|
|
25
|
+
session.transition(session.constructor.STATES.SENDING_PROMPT, {
|
|
26
|
+
reason: 'Sending to ACP',
|
|
27
|
+
data: {}
|
|
28
|
+
});
|
|
29
|
+
console.log(`After 3rd transition: ${session.getState()}`);
|
|
30
|
+
|
|
31
|
+
session.transition(session.constructor.STATES.PROCESSING, {
|
|
32
|
+
reason: 'Processing response',
|
|
33
|
+
data: {}
|
|
34
|
+
});
|
|
35
|
+
console.log(`After 4th transition: ${session.getState()}`);
|
|
36
|
+
|
|
37
|
+
session.transition(session.constructor.STATES.COMPLETED, {
|
|
38
|
+
reason: 'Done!',
|
|
39
|
+
data: { fullText: 'Hello world' }
|
|
40
|
+
});
|
|
41
|
+
console.log(`After final transition: ${session.getState()}`);
|
|
42
|
+
|
|
43
|
+
console.log('\n✅ All transitions successful!\n');
|
|
44
|
+
console.log('State history:');
|
|
45
|
+
session.getHistory().forEach((h, i) => {
|
|
46
|
+
console.log(` ${i}: ${h.state} @ ${h.timestamp} - ${h.reason}`);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
console.log('\nSummary:');
|
|
50
|
+
console.log(JSON.stringify(session.getSummary(), null, 2));
|
|
51
|
+
|
|
52
|
+
} catch (err) {
|
|
53
|
+
console.error(`❌ Error: ${err.message}`);
|
|
54
|
+
}
|
|
55
|
+
|