agentgui 1.0.64 → 1.0.66
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/.prd +54 -0
- package/CLAUDE.md +176 -0
- package/lib/claude-runner.js +71 -0
- package/lib/database-service.ts +388 -0
- package/lib/machines.ts +593 -0
- package/lib/schemas.ts +213 -0
- package/lib/sync-service.ts +340 -0
- package/lib/types.ts +245 -0
- package/package.json +1 -1
- package/server.js +73 -279
- package/static/app.js +205 -1584
- package/static/styles.css +135 -0
- package/conversation-sync.js +0 -196
- package/state-manager.js +0 -360
- package/state-validator.js +0 -150
- package/static/sync-manager.js +0 -273
- package/stream-handler.js +0 -106
package/static/styles.css
CHANGED
|
@@ -1690,3 +1690,138 @@ p code {
|
|
|
1690
1690
|
border-radius: 4px;
|
|
1691
1691
|
line-height: 1.6;
|
|
1692
1692
|
}
|
|
1693
|
+
|
|
1694
|
+
/* Claude Execution Blocks - Full message structure rendering */
|
|
1695
|
+
.execution-blocks {
|
|
1696
|
+
display: flex;
|
|
1697
|
+
flex-direction: column;
|
|
1698
|
+
gap: 0.75rem;
|
|
1699
|
+
width: 100%;
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
.message-block {
|
|
1703
|
+
padding: 0.75rem 1rem;
|
|
1704
|
+
border-radius: 0.5rem;
|
|
1705
|
+
background: var(--bg-secondary);
|
|
1706
|
+
border: 1px solid var(--border-color);
|
|
1707
|
+
word-wrap: break-word;
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
.block-text {
|
|
1711
|
+
color: var(--text-primary);
|
|
1712
|
+
white-space: pre-wrap;
|
|
1713
|
+
line-height: 1.5;
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
.block-tool-use {
|
|
1717
|
+
background: rgba(59, 130, 246, 0.05);
|
|
1718
|
+
border: 1px solid rgba(59, 130, 246, 0.2);
|
|
1719
|
+
border-radius: 0.5rem;
|
|
1720
|
+
padding: 0.75rem;
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
.tool-name {
|
|
1724
|
+
color: var(--color-info);
|
|
1725
|
+
font-weight: 600;
|
|
1726
|
+
display: block;
|
|
1727
|
+
margin-bottom: 0.5rem;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
.tool-input {
|
|
1731
|
+
background: var(--bg-tertiary);
|
|
1732
|
+
border-radius: 0.375rem;
|
|
1733
|
+
padding: 0.5rem;
|
|
1734
|
+
overflow-x: auto;
|
|
1735
|
+
font-size: 0.8rem;
|
|
1736
|
+
font-family: 'Courier New', monospace;
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
.tool-input pre {
|
|
1740
|
+
margin: 0;
|
|
1741
|
+
color: var(--text-secondary);
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
.block-tool-result {
|
|
1745
|
+
background: rgba(16, 185, 129, 0.05);
|
|
1746
|
+
border: 1px solid rgba(16, 185, 129, 0.2);
|
|
1747
|
+
border-radius: 0.5rem;
|
|
1748
|
+
padding: 0.75rem;
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
.block-tool-result strong {
|
|
1752
|
+
color: var(--color-success);
|
|
1753
|
+
display: block;
|
|
1754
|
+
margin-bottom: 0.5rem;
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
.tool-result {
|
|
1758
|
+
background: var(--bg-tertiary);
|
|
1759
|
+
border-radius: 0.375rem;
|
|
1760
|
+
padding: 0.5rem;
|
|
1761
|
+
overflow-x: auto;
|
|
1762
|
+
font-size: 0.8rem;
|
|
1763
|
+
font-family: 'Courier New', monospace;
|
|
1764
|
+
max-height: 300px;
|
|
1765
|
+
overflow-y: auto;
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
.tool-result pre {
|
|
1769
|
+
margin: 0;
|
|
1770
|
+
color: var(--text-secondary);
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
.block-file-op {
|
|
1774
|
+
background: rgba(245, 158, 11, 0.05);
|
|
1775
|
+
border: 1px solid rgba(245, 158, 11, 0.2);
|
|
1776
|
+
border-radius: 0.5rem;
|
|
1777
|
+
padding: 0.75rem;
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
.file-action {
|
|
1781
|
+
color: var(--color-warning);
|
|
1782
|
+
font-weight: 600;
|
|
1783
|
+
display: block;
|
|
1784
|
+
margin-bottom: 0.5rem;
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
.file-path {
|
|
1788
|
+
color: var(--text-secondary);
|
|
1789
|
+
font-size: 0.875rem;
|
|
1790
|
+
font-family: 'Courier New', monospace;
|
|
1791
|
+
margin-bottom: 0.5rem;
|
|
1792
|
+
padding: 0.25rem 0.5rem;
|
|
1793
|
+
background: var(--bg-tertiary);
|
|
1794
|
+
border-radius: 0.25rem;
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
.file-content {
|
|
1798
|
+
background: var(--bg-tertiary);
|
|
1799
|
+
border-radius: 0.375rem;
|
|
1800
|
+
padding: 0.5rem;
|
|
1801
|
+
overflow-x: auto;
|
|
1802
|
+
font-size: 0.8rem;
|
|
1803
|
+
font-family: 'Courier New', monospace;
|
|
1804
|
+
max-height: 200px;
|
|
1805
|
+
overflow-y: auto;
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
.file-content pre {
|
|
1809
|
+
margin: 0;
|
|
1810
|
+
color: var(--text-secondary);
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
.block-unknown {
|
|
1814
|
+
background: var(--bg-tertiary);
|
|
1815
|
+
border-radius: 0.375rem;
|
|
1816
|
+
padding: 0.5rem;
|
|
1817
|
+
overflow-x: auto;
|
|
1818
|
+
font-size: 0.75rem;
|
|
1819
|
+
font-family: 'Courier New', monospace;
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
.message-content {
|
|
1823
|
+
color: var(--text-primary);
|
|
1824
|
+
white-space: pre-wrap;
|
|
1825
|
+
word-wrap: break-word;
|
|
1826
|
+
line-height: 1.5;
|
|
1827
|
+
}
|
package/conversation-sync.js
DELETED
|
@@ -1,196 +0,0 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
import os from 'os';
|
|
4
|
-
import { queries } from './database.js';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* ConversationSync - Watches for changes to Claude Code conversation files
|
|
8
|
-
* and keeps the database synchronized with the latest versions
|
|
9
|
-
*/
|
|
10
|
-
export class ConversationSync {
|
|
11
|
-
constructor() {
|
|
12
|
-
this.watchers = new Map();
|
|
13
|
-
this.lastSync = new Map();
|
|
14
|
-
this.syncInterval = 5000; // Check for changes every 5 seconds
|
|
15
|
-
this.isRunning = false;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Start watching for conversation file changes
|
|
20
|
-
*/
|
|
21
|
-
start() {
|
|
22
|
-
if (this.isRunning) return;
|
|
23
|
-
this.isRunning = true;
|
|
24
|
-
|
|
25
|
-
// Watch for changes to sessions-index.json files
|
|
26
|
-
this.watchProjectDirectory();
|
|
27
|
-
|
|
28
|
-
// Periodic sync check
|
|
29
|
-
this.syncCheckInterval = setInterval(() => {
|
|
30
|
-
this.checkForUpdates();
|
|
31
|
-
}, this.syncInterval);
|
|
32
|
-
|
|
33
|
-
console.log('[ConversationSync] Started watching for conversation changes');
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Stop watching for changes
|
|
38
|
-
*/
|
|
39
|
-
stop() {
|
|
40
|
-
if (!this.isRunning) return;
|
|
41
|
-
this.isRunning = false;
|
|
42
|
-
|
|
43
|
-
// Clear all watchers
|
|
44
|
-
for (const [, watcher] of this.watchers) {
|
|
45
|
-
watcher.close();
|
|
46
|
-
}
|
|
47
|
-
this.watchers.clear();
|
|
48
|
-
|
|
49
|
-
// Clear interval
|
|
50
|
-
if (this.syncCheckInterval) {
|
|
51
|
-
clearInterval(this.syncCheckInterval);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
console.log('[ConversationSync] Stopped watching for conversation changes');
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Watch the .claude/projects directory for changes
|
|
59
|
-
*/
|
|
60
|
-
watchProjectDirectory() {
|
|
61
|
-
const projectsDir = path.join(os.homedir(), '.claude', 'projects');
|
|
62
|
-
if (!fs.existsSync(projectsDir)) {
|
|
63
|
-
console.log('[ConversationSync] Projects directory does not exist:', projectsDir);
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
try {
|
|
68
|
-
const watcher = fs.watch(projectsDir, { recursive: true }, (eventType, filename) => {
|
|
69
|
-
if (filename && filename.endsWith('sessions-index.json')) {
|
|
70
|
-
this.handleFileChange(projectsDir, filename);
|
|
71
|
-
}
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
this.watchers.set(projectsDir, watcher);
|
|
75
|
-
console.log('[ConversationSync] Watching:', projectsDir);
|
|
76
|
-
} catch (err) {
|
|
77
|
-
console.error('[ConversationSync] Error setting up file watcher:', err.message);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Handle changes to sessions-index.json files
|
|
83
|
-
*/
|
|
84
|
-
handleFileChange(projectsDir, filename) {
|
|
85
|
-
const fullPath = path.join(projectsDir, filename);
|
|
86
|
-
|
|
87
|
-
// Debounce rapid changes
|
|
88
|
-
if (this.lastSync.has(fullPath)) {
|
|
89
|
-
const lastTime = this.lastSync.get(fullPath);
|
|
90
|
-
if (Date.now() - lastTime < 1000) {
|
|
91
|
-
return; // Skip if changed within last second
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
this.lastSync.set(fullPath, Date.now());
|
|
96
|
-
this.syncConversationFile(fullPath);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Sync a specific sessions-index.json file
|
|
101
|
-
*/
|
|
102
|
-
syncConversationFile(indexPath) {
|
|
103
|
-
try {
|
|
104
|
-
if (!fs.existsSync(indexPath)) return;
|
|
105
|
-
|
|
106
|
-
const index = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
|
|
107
|
-
const entries = index.entries || [];
|
|
108
|
-
let synced = 0;
|
|
109
|
-
let updated = 0;
|
|
110
|
-
|
|
111
|
-
for (const entry of entries) {
|
|
112
|
-
const existing = queries.getConversationByExternalId('claude-code', entry.sessionId);
|
|
113
|
-
|
|
114
|
-
if (existing) {
|
|
115
|
-
// Check if conversation has been updated
|
|
116
|
-
const existingModified = new Date(existing.modified).getTime();
|
|
117
|
-
const newModified = new Date(entry.modified).getTime();
|
|
118
|
-
|
|
119
|
-
if (newModified > existingModified) {
|
|
120
|
-
// Update with new information
|
|
121
|
-
queries.updateConversation(existing.id, {
|
|
122
|
-
title: entry.summary || entry.firstPrompt || `Conversation ${entry.sessionId.slice(0, 8)}`,
|
|
123
|
-
messageCount: entry.messageCount || 0,
|
|
124
|
-
modified: newModified
|
|
125
|
-
});
|
|
126
|
-
updated++;
|
|
127
|
-
}
|
|
128
|
-
} else {
|
|
129
|
-
// New conversation - import it
|
|
130
|
-
const conversation = {
|
|
131
|
-
externalId: entry.sessionId,
|
|
132
|
-
agentType: 'claude-code',
|
|
133
|
-
title: entry.summary || entry.firstPrompt || `Conversation ${entry.sessionId.slice(0, 8)}`,
|
|
134
|
-
firstPrompt: entry.firstPrompt,
|
|
135
|
-
messageCount: entry.messageCount || 0,
|
|
136
|
-
created: new Date(entry.created).getTime(),
|
|
137
|
-
modified: new Date(entry.modified).getTime(),
|
|
138
|
-
projectPath: entry.projectPath,
|
|
139
|
-
gitBranch: entry.gitBranch,
|
|
140
|
-
sourcePath: entry.fullPath,
|
|
141
|
-
source: 'imported'
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
queries.createImportedConversation(conversation);
|
|
145
|
-
synced++;
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
if (synced > 0 || updated > 0) {
|
|
150
|
-
console.log(`[ConversationSync] File: ${path.basename(indexPath)} - synced: ${synced}, updated: ${updated}`);
|
|
151
|
-
}
|
|
152
|
-
} catch (err) {
|
|
153
|
-
console.error('[ConversationSync] Error syncing file:', indexPath, err.message);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Periodic check for any missed updates
|
|
159
|
-
*/
|
|
160
|
-
checkForUpdates() {
|
|
161
|
-
try {
|
|
162
|
-
const projectsDir = path.join(os.homedir(), '.claude', 'projects');
|
|
163
|
-
if (!fs.existsSync(projectsDir)) return;
|
|
164
|
-
|
|
165
|
-
const projects = fs.readdirSync(projectsDir);
|
|
166
|
-
|
|
167
|
-
for (const projectName of projects) {
|
|
168
|
-
const indexPath = path.join(projectsDir, projectName, 'sessions-index.json');
|
|
169
|
-
if (fs.existsSync(indexPath)) {
|
|
170
|
-
// Check file's modification time
|
|
171
|
-
const stat = fs.statSync(indexPath);
|
|
172
|
-
const lastModified = stat.mtime.getTime();
|
|
173
|
-
const lastSyncTime = this.lastSync.get(indexPath) || 0;
|
|
174
|
-
|
|
175
|
-
if (lastModified > lastSyncTime) {
|
|
176
|
-
this.syncConversationFile(indexPath);
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
} catch (err) {
|
|
181
|
-
console.error('[ConversationSync] Error during periodic check:', err.message);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Singleton instance
|
|
187
|
-
let syncInstance = null;
|
|
188
|
-
|
|
189
|
-
export function getConversationSync() {
|
|
190
|
-
if (!syncInstance) {
|
|
191
|
-
syncInstance = new ConversationSync();
|
|
192
|
-
}
|
|
193
|
-
return syncInstance;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
export default ConversationSync;
|
package/state-manager.js
DELETED
|
@@ -1,360 +0,0 @@
|
|
|
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
|
-
}
|