@yeaft/webchat-agent 1.0.490 → 1.0.491
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/index.js +1 -1
- package/local-runtime/server/handlers/agent-file-terminal.js +62 -34
- package/local-runtime/server/handlers/client-misc.js +3 -0
- package/local-runtime/server/handlers/client-workbench.js +95 -2
- package/local-runtime/server/workbench-correlation.js +185 -20
- package/local-runtime/server/workbench-route.js +14 -0
- package/local-runtime/server/ws-client.js +17 -13
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +82 -82
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +1 -1
- package/package.json +1 -1
- package/terminal.js +8 -2
package/index.js
CHANGED
|
@@ -177,7 +177,7 @@ async function detectCapabilities() {
|
|
|
177
177
|
// agent build can speak plaintext WS frames. New servers see this and
|
|
178
178
|
// flip `agent.encryptOutbound = false`, stopping outbound encryption
|
|
179
179
|
// to this peer. Old servers ignore the unknown capability token.
|
|
180
|
-
const capabilities = ['background_tasks', 'file_editor', 'ping_session', 'plaintext-ok', 'workbench_session_routes', 'session_history_search', 'session_history_outline', 'session_history_window_prefetch', 'file_reference_resolution', 'yeaft_plugins', 'yeaft_managed_skills', 'settings_request_correlation'];
|
|
180
|
+
const capabilities = ['background_tasks', 'file_editor', 'ping_session', 'plaintext-ok', 'workbench_session_routes', 'workbench_request_correlation', 'workbench_terminal_cleanup_fence', 'session_history_search', 'session_history_outline', 'session_history_window_prefetch', 'file_reference_resolution', 'yeaft_plugins', 'yeaft_managed_skills', 'settings_request_correlation'];
|
|
181
181
|
capabilities.push(getAgentUpgradeCapability());
|
|
182
182
|
if (isWorkCenterEnabled()) {
|
|
183
183
|
capabilities.push('work_center', 'work_center_message_v2');
|
|
@@ -9,14 +9,19 @@ import {
|
|
|
9
9
|
clearAgentDirCache,
|
|
10
10
|
} from '../ws-utils.js';
|
|
11
11
|
import {
|
|
12
|
+
agentSupportsWorkbenchRequestCorrelation,
|
|
13
|
+
agentSupportsWorkbenchTerminalCleanupFence,
|
|
12
14
|
currentWorkbenchWorkspaceGeneration,
|
|
13
15
|
workbenchRouteKeyFromConversationId,
|
|
14
16
|
} from '../workbench-route.js';
|
|
15
17
|
import {
|
|
18
|
+
consumeLegacyWorkbenchRequest,
|
|
16
19
|
consumeWorkbenchRequest,
|
|
17
20
|
deleteWorkbenchTerminalOwner,
|
|
18
21
|
getWorkbenchTerminalOwner,
|
|
22
|
+
isLegacyWorkbenchRequestQuarantined,
|
|
19
23
|
registerWorkbenchTerminalOwner,
|
|
24
|
+
workbenchTerminalCleanupMessage,
|
|
20
25
|
} from '../workbench-correlation.js';
|
|
21
26
|
|
|
22
27
|
function stripAgentRouting(msg) {
|
|
@@ -50,6 +55,28 @@ async function sendToPendingClient(agentId, msg, pending) {
|
|
|
50
55
|
return true;
|
|
51
56
|
}
|
|
52
57
|
|
|
58
|
+
function consumeRouteResponse(agentId, agent, msg, routeKey) {
|
|
59
|
+
if (msg._workbenchRequestId) {
|
|
60
|
+
return consumeWorkbenchRequest({
|
|
61
|
+
agentId,
|
|
62
|
+
requestId: msg._workbenchRequestId,
|
|
63
|
+
responseType: msg.type,
|
|
64
|
+
routeKey,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
const agentRecord = agents.get(agentId) || agent;
|
|
68
|
+
if (agentSupportsWorkbenchRequestCorrelation(agentRecord)) return null;
|
|
69
|
+
return consumeLegacyWorkbenchRequest({
|
|
70
|
+
agentId,
|
|
71
|
+
responseType: msg.type,
|
|
72
|
+
routeKey,
|
|
73
|
+
userId: msg._requestUserId,
|
|
74
|
+
clientId: msg._requestClientId || null,
|
|
75
|
+
publicRequestId: typeof msg.requestId === 'string' ? msg.requestId : null,
|
|
76
|
+
terminalId: msg.terminalId || null,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
53
80
|
async function forwardLegacyResponse(agentId, msg) {
|
|
54
81
|
const visible = { ...stripAgentRouting(msg), agentId };
|
|
55
82
|
const agent = agents.get(agentId);
|
|
@@ -83,25 +110,25 @@ function cacheBinaryPreview(msg) {
|
|
|
83
110
|
};
|
|
84
111
|
}
|
|
85
112
|
|
|
86
|
-
async function handleTerminalResponse(agentId, msg, routeKey) {
|
|
113
|
+
async function handleTerminalResponse(agentId, agent, msg, routeKey) {
|
|
87
114
|
const terminalId = msg.terminalId || null;
|
|
88
115
|
if (msg.type === 'terminal_created') {
|
|
89
|
-
const pending =
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
responseType: msg.type,
|
|
93
|
-
routeKey,
|
|
94
|
-
});
|
|
95
|
-
if (!pending) {
|
|
116
|
+
const pending = consumeRouteResponse(agentId, agent, msg, routeKey);
|
|
117
|
+
const quarantined = isLegacyWorkbenchRequestQuarantined(pending);
|
|
118
|
+
if (!pending || quarantined) {
|
|
96
119
|
const agentRecord = agents.get(agentId);
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
120
|
+
const cleanup = quarantined ? pending : {
|
|
121
|
+
requestId: msg._workbenchRequestId || null,
|
|
122
|
+
conversationId: msg.conversationId,
|
|
123
|
+
routeKey,
|
|
124
|
+
workspaceGeneration: msg.workbenchWorkspaceGeneration,
|
|
125
|
+
terminalId,
|
|
126
|
+
};
|
|
127
|
+
const closeMessage = workbenchTerminalCleanupMessage(cleanup);
|
|
128
|
+
if (agentRecord
|
|
129
|
+
&& closeMessage
|
|
130
|
+
&& agentSupportsWorkbenchTerminalCleanupFence(agentRecord)) {
|
|
131
|
+
await sendToAgent(agentRecord, closeMessage);
|
|
105
132
|
}
|
|
106
133
|
return;
|
|
107
134
|
}
|
|
@@ -117,16 +144,18 @@ async function handleTerminalResponse(agentId, msg, routeKey) {
|
|
|
117
144
|
|
|
118
145
|
// Create errors carry the one-shot create correlation even though a
|
|
119
146
|
// terminal-id reservation already exists. Consume and release it first.
|
|
120
|
-
if (msg.type === 'terminal_error'
|
|
121
|
-
const pending =
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
});
|
|
147
|
+
if (msg.type === 'terminal_error') {
|
|
148
|
+
const pending = consumeRouteResponse(agentId, agent, msg, routeKey);
|
|
149
|
+
// Explicit opaque ids and quarantined legacy replies must never fall
|
|
150
|
+
// through to terminal ownership. A quarantine tombstone describes the
|
|
151
|
+
// expired create, not any same-id replacement owner.
|
|
152
|
+
if (isLegacyWorkbenchRequestQuarantined(pending)) return;
|
|
127
153
|
if (pending?.terminalId) deleteWorkbenchTerminalOwner(agentId, pending.terminalId);
|
|
128
|
-
if (pending?.routeKey === routeKey)
|
|
129
|
-
|
|
154
|
+
if (pending?.routeKey === routeKey) {
|
|
155
|
+
await sendToPendingClient(agentId, msg, pending);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (msg._workbenchRequestId) return;
|
|
130
159
|
}
|
|
131
160
|
|
|
132
161
|
const owner = terminalId ? getWorkbenchTerminalOwner(agentId, terminalId) : null;
|
|
@@ -148,13 +177,8 @@ async function handleAgentDirectoryPickerResponse(agentId, msg) {
|
|
|
148
177
|
await sendToPendingClient(agentId, msg, pending);
|
|
149
178
|
}
|
|
150
179
|
|
|
151
|
-
async function handleOneShotResponse(agentId, msg, routeKey) {
|
|
152
|
-
const pending =
|
|
153
|
-
agentId,
|
|
154
|
-
requestId: msg._workbenchRequestId,
|
|
155
|
-
responseType: msg.type,
|
|
156
|
-
routeKey,
|
|
157
|
-
});
|
|
180
|
+
async function handleOneShotResponse(agentId, agent, msg, routeKey) {
|
|
181
|
+
const pending = consumeRouteResponse(agentId, agent, msg, routeKey);
|
|
158
182
|
if (!pending) return;
|
|
159
183
|
const currentGeneration = currentWorkbenchWorkspaceGeneration({
|
|
160
184
|
route: pending.route,
|
|
@@ -192,8 +216,8 @@ export async function handleAgentFileTerminal(agentId, agent, rawMsg) {
|
|
|
192
216
|
}
|
|
193
217
|
|
|
194
218
|
if (routeKey) {
|
|
195
|
-
if (terminalTypes.has(msg.type)) await handleTerminalResponse(agentId, msg, routeKey);
|
|
196
|
-
else await handleOneShotResponse(agentId, msg, routeKey);
|
|
219
|
+
if (terminalTypes.has(msg.type)) await handleTerminalResponse(agentId, agent, msg, routeKey);
|
|
220
|
+
else await handleOneShotResponse(agentId, agent, msg, routeKey);
|
|
197
221
|
return true;
|
|
198
222
|
}
|
|
199
223
|
|
|
@@ -204,6 +228,10 @@ export async function handleAgentFileTerminal(agentId, agent, rawMsg) {
|
|
|
204
228
|
return true;
|
|
205
229
|
}
|
|
206
230
|
|
|
231
|
+
// Opaque correlation ids are Server-owned. A response carrying one cannot
|
|
232
|
+
// downgrade to legacy routing when its conversation is invalid or stale.
|
|
233
|
+
if (msg._workbenchRequestId) return true;
|
|
234
|
+
|
|
207
235
|
// `_workbench:` is reserved for Server-authored route conversations. An
|
|
208
236
|
// invalid or cross-Agent value is not a legacy conversation.
|
|
209
237
|
if (typeof msg.conversationId === 'string' && msg.conversationId.startsWith('_workbench:')) {
|
|
@@ -190,6 +190,9 @@ export async function handleClientMisc(clientId, client, msg, checkAgentAccess)
|
|
|
190
190
|
conversationId: resolved.conversationId || msg.conversationId || client.currentConversation,
|
|
191
191
|
workbenchRouteKey: resolved.routeKey,
|
|
192
192
|
workbenchWorkspaceGeneration: resolved.workspaceGeneration,
|
|
193
|
+
restoreRequestId: typeof msg.restoreRequestId === 'string'
|
|
194
|
+
? msg.restoreRequestId
|
|
195
|
+
: null,
|
|
193
196
|
openFiles: saved?.files || [],
|
|
194
197
|
activeIndex: saved?.activeIndex || 0
|
|
195
198
|
});
|
|
@@ -4,7 +4,10 @@ import {
|
|
|
4
4
|
sendToWebClient, forwardToAgent,
|
|
5
5
|
verifyConversationOwnership, getCachedDir
|
|
6
6
|
} from '../ws-utils.js';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
agentSupportsWorkbenchRequestCorrelation,
|
|
9
|
+
resolveWorkbenchRequest,
|
|
10
|
+
} from '../workbench-route.js';
|
|
8
11
|
import {
|
|
9
12
|
deleteWorkbenchRequest,
|
|
10
13
|
getWorkbenchTerminalOwner,
|
|
@@ -61,6 +64,82 @@ const RESPONSE_TYPES = Object.freeze({
|
|
|
61
64
|
upload_to_dir: ['file_op_result'],
|
|
62
65
|
});
|
|
63
66
|
|
|
67
|
+
const TIMEOUT_RESPONSE_TYPES = Object.freeze({
|
|
68
|
+
terminal_create: 'terminal_error',
|
|
69
|
+
read_file: 'file_content',
|
|
70
|
+
resolve_file_references: 'file_references_resolved',
|
|
71
|
+
write_file: 'file_saved',
|
|
72
|
+
list_directory: 'directory_listing',
|
|
73
|
+
git_status: 'git_status_result',
|
|
74
|
+
git_diff: 'git_diff_result',
|
|
75
|
+
git_add: 'git_op_result',
|
|
76
|
+
git_reset: 'git_op_result',
|
|
77
|
+
git_restore: 'git_op_result',
|
|
78
|
+
git_commit: 'git_op_result',
|
|
79
|
+
git_push: 'git_op_result',
|
|
80
|
+
file_search: 'file_search_result',
|
|
81
|
+
create_file: 'file_op_result',
|
|
82
|
+
delete_files: 'file_op_result',
|
|
83
|
+
move_files: 'file_op_result',
|
|
84
|
+
copy_files: 'file_op_result',
|
|
85
|
+
upload_to_dir: 'file_op_result',
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const FILE_OPERATIONS = Object.freeze({
|
|
89
|
+
create_file: 'create',
|
|
90
|
+
delete_files: 'delete',
|
|
91
|
+
move_files: 'move',
|
|
92
|
+
copy_files: 'copy',
|
|
93
|
+
upload_to_dir: 'upload',
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
function workbenchTimeoutResponse({ agentId, msg, resolved }) {
|
|
97
|
+
const type = TIMEOUT_RESPONSE_TYPES[msg.type];
|
|
98
|
+
if (!type) return null;
|
|
99
|
+
const error = 'Workbench request timed out';
|
|
100
|
+
const response = {
|
|
101
|
+
type,
|
|
102
|
+
agentId,
|
|
103
|
+
conversationId: resolved.conversationId,
|
|
104
|
+
workbenchRouteKey: resolved.routeKey,
|
|
105
|
+
workbenchWorkspaceGeneration: resolved.workspaceGeneration,
|
|
106
|
+
...(typeof msg.requestId === 'string' ? { requestId: msg.requestId } : {}),
|
|
107
|
+
error,
|
|
108
|
+
};
|
|
109
|
+
if (msg.type === 'terminal_create') {
|
|
110
|
+
return { ...response, terminalId: msg.terminalId || null, message: error };
|
|
111
|
+
}
|
|
112
|
+
if (msg.type === 'read_file') {
|
|
113
|
+
return { ...response, filePath: msg.filePath };
|
|
114
|
+
}
|
|
115
|
+
if (msg.type === 'resolve_file_references') {
|
|
116
|
+
return { ...response, references: [] };
|
|
117
|
+
}
|
|
118
|
+
if (msg.type === 'write_file') {
|
|
119
|
+
return { ...response, filePath: msg.filePath, requestedFilePath: msg.filePath };
|
|
120
|
+
}
|
|
121
|
+
if (msg.type === 'list_directory') {
|
|
122
|
+
return { ...response, dirPath: msg.dirPath, entries: [] };
|
|
123
|
+
}
|
|
124
|
+
if (msg.type === 'git_status') {
|
|
125
|
+
return { ...response, files: [] };
|
|
126
|
+
}
|
|
127
|
+
if (msg.type === 'git_diff') {
|
|
128
|
+
return { ...response, diff: '' };
|
|
129
|
+
}
|
|
130
|
+
if (msg.type.startsWith('git_')) {
|
|
131
|
+
return { ...response, success: false, operation: msg.type.slice(4) };
|
|
132
|
+
}
|
|
133
|
+
if (msg.type === 'file_search') {
|
|
134
|
+
return { ...response, query: msg.query, results: [] };
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
...response,
|
|
138
|
+
success: false,
|
|
139
|
+
operation: FILE_OPERATIONS[msg.type] || msg.type,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
64
143
|
function canonicalWorkbenchMessage(msg, resolved, { canonicalWorkDir = false } = {}) {
|
|
65
144
|
const {
|
|
66
145
|
_requestUserId: _ignoredUserId,
|
|
@@ -93,6 +172,7 @@ function correlateWorkbenchRequest({ agentId, clientId, client, msg, resolved, c
|
|
|
93
172
|
}
|
|
94
173
|
const expectedResponseTypes = RESPONSE_TYPES[msg.type];
|
|
95
174
|
if (!expectedResponseTypes) return canonical;
|
|
175
|
+
const supportsRequestCorrelation = agentSupportsWorkbenchRequestCorrelation(agents.get(agentId));
|
|
96
176
|
const registration = {
|
|
97
177
|
agentId,
|
|
98
178
|
clientId,
|
|
@@ -106,6 +186,12 @@ function correlateWorkbenchRequest({ agentId, clientId, client, msg, resolved, c
|
|
|
106
186
|
expectedResponseTypes,
|
|
107
187
|
publicRequestId: typeof msg.requestId === 'string' ? msg.requestId : null,
|
|
108
188
|
terminalId: msg.terminalId || null,
|
|
189
|
+
allowLegacyCorrelation: !supportsRequestCorrelation,
|
|
190
|
+
onTimeout: async () => {
|
|
191
|
+
if (!client.authenticated || client.userId !== registration.userId) return;
|
|
192
|
+
const response = workbenchTimeoutResponse({ agentId, msg, resolved });
|
|
193
|
+
if (response) await sendToWebClient(client, response);
|
|
194
|
+
},
|
|
109
195
|
};
|
|
110
196
|
const requestId = registerWorkbenchRequest(registration);
|
|
111
197
|
if (!requestId) return null;
|
|
@@ -114,7 +200,14 @@ function correlateWorkbenchRequest({ agentId, clientId, client, msg, resolved, c
|
|
|
114
200
|
deleteWorkbenchRequest({ agentId, requestId });
|
|
115
201
|
return null;
|
|
116
202
|
}
|
|
117
|
-
return {
|
|
203
|
+
return {
|
|
204
|
+
...canonical,
|
|
205
|
+
_workbenchRequestId: requestId,
|
|
206
|
+
...(!supportsRequestCorrelation ? {
|
|
207
|
+
_requestUserId: client.userId,
|
|
208
|
+
_requestClientId: clientId,
|
|
209
|
+
} : {}),
|
|
210
|
+
};
|
|
118
211
|
}
|
|
119
212
|
|
|
120
213
|
async function forwardCorrelatedWorkbenchRequest({ agentId, clientId, client, msg, resolved, canonical }) {
|
|
@@ -4,8 +4,31 @@ const REQUEST_TTL_MS = 2 * 60 * 1000;
|
|
|
4
4
|
const MAX_PENDING = 4096;
|
|
5
5
|
|
|
6
6
|
const pendingRequests = new Map();
|
|
7
|
+
const expiredRequests = new Map();
|
|
7
8
|
const terminalOwners = new Map();
|
|
8
9
|
|
|
10
|
+
export const LEGACY_WORKBENCH_REQUEST_QUARANTINED = Symbol(
|
|
11
|
+
'legacy-workbench-request-quarantined',
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
export function isLegacyWorkbenchRequestQuarantined(value) {
|
|
15
|
+
return value?.quarantine === LEGACY_WORKBENCH_REQUEST_QUARANTINED;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function workbenchTerminalCleanupMessage(owner) {
|
|
19
|
+
if (!owner?.terminalId || !owner?.conversationId || !owner?.routeKey
|
|
20
|
+
|| !owner?.workspaceGeneration) return null;
|
|
21
|
+
const requestId = owner.requestId || owner.pendingRequestId || null;
|
|
22
|
+
return {
|
|
23
|
+
type: 'terminal_close',
|
|
24
|
+
conversationId: owner.conversationId,
|
|
25
|
+
terminalId: owner.terminalId,
|
|
26
|
+
workbenchRouteKey: owner.routeKey,
|
|
27
|
+
workbenchWorkspaceGeneration: owner.workspaceGeneration,
|
|
28
|
+
...(requestId ? { _workbenchRequestId: requestId } : {}),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
9
32
|
function requestKey(agentId, requestId) {
|
|
10
33
|
return `${String(agentId || '')}\u0000${String(requestId || '')}`;
|
|
11
34
|
}
|
|
@@ -22,11 +45,56 @@ function releasePendingTerminalReservation(pending) {
|
|
|
22
45
|
if (owner?.pendingRequestId === pending.requestId) terminalOwners.delete(key);
|
|
23
46
|
}
|
|
24
47
|
|
|
48
|
+
function pruneExpiredRequests(now = Date.now()) {
|
|
49
|
+
for (const [key, expired] of expiredRequests) {
|
|
50
|
+
if (expired?.expiresAt > now) continue;
|
|
51
|
+
expiredRequests.delete(key);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function quarantineExpiredRequest(key, pending, now = Date.now()) {
|
|
56
|
+
if (!pending?.allowLegacyCorrelation) return;
|
|
57
|
+
pruneExpiredRequests(now);
|
|
58
|
+
while (expiredRequests.size >= MAX_PENDING) {
|
|
59
|
+
const oldest = expiredRequests.keys().next().value;
|
|
60
|
+
if (oldest == null) break;
|
|
61
|
+
expiredRequests.delete(oldest);
|
|
62
|
+
}
|
|
63
|
+
expiredRequests.set(key, {
|
|
64
|
+
agentId: pending.agentId,
|
|
65
|
+
requestId: pending.requestId,
|
|
66
|
+
conversationId: pending.conversationId,
|
|
67
|
+
workspaceGeneration: pending.workspaceGeneration,
|
|
68
|
+
responseTypes: new Set(pending.expectedResponseTypes),
|
|
69
|
+
routeKey: pending.routeKey,
|
|
70
|
+
userId: pending.userId,
|
|
71
|
+
clientId: pending.clientId,
|
|
72
|
+
publicRequestId: pending.publicRequestId,
|
|
73
|
+
terminalId: pending.terminalId,
|
|
74
|
+
expiresAt: now + REQUEST_TTL_MS,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function expirePending(key, pending, reason = 'timeout') {
|
|
79
|
+
if (pendingRequests.get(key) !== pending) return false;
|
|
80
|
+
pendingRequests.delete(key);
|
|
81
|
+
if (pending.timeout) clearTimeout(pending.timeout);
|
|
82
|
+
pending.timeout = null;
|
|
83
|
+
releasePendingTerminalReservation(pending);
|
|
84
|
+
quarantineExpiredRequest(key, pending);
|
|
85
|
+
if (typeof pending?.onTimeout === 'function') {
|
|
86
|
+
Promise.resolve(pending.onTimeout(pending, reason)).catch(error => {
|
|
87
|
+
console.error('[Server] Failed to report Workbench request timeout:', error);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
25
93
|
function prune(now = Date.now()) {
|
|
94
|
+
pruneExpiredRequests(now);
|
|
26
95
|
for (const [key, pending] of pendingRequests) {
|
|
27
96
|
if (pending && pending.expiresAt > now) continue;
|
|
28
|
-
|
|
29
|
-
releasePendingTerminalReservation(pending);
|
|
97
|
+
expirePending(key, pending);
|
|
30
98
|
}
|
|
31
99
|
}
|
|
32
100
|
|
|
@@ -43,6 +111,8 @@ export function registerWorkbenchRequest({
|
|
|
43
111
|
expectedResponseTypes,
|
|
44
112
|
publicRequestId = null,
|
|
45
113
|
terminalId = null,
|
|
114
|
+
allowLegacyCorrelation = false,
|
|
115
|
+
onTimeout = null,
|
|
46
116
|
}) {
|
|
47
117
|
if (!agentId || !clientId || !userId || !routeKey || !conversationId
|
|
48
118
|
|| !workspaceGeneration || !requestType) return null;
|
|
@@ -54,9 +124,7 @@ export function registerWorkbenchRequest({
|
|
|
54
124
|
while (pendingRequests.size >= MAX_PENDING) {
|
|
55
125
|
const oldest = pendingRequests.keys().next().value;
|
|
56
126
|
if (oldest == null) break;
|
|
57
|
-
|
|
58
|
-
pendingRequests.delete(oldest);
|
|
59
|
-
releasePendingTerminalReservation(evicted);
|
|
127
|
+
expirePending(oldest, pendingRequests.get(oldest), 'capacity');
|
|
60
128
|
}
|
|
61
129
|
const requestId = randomUUID();
|
|
62
130
|
const key = requestKey(agentId, requestId);
|
|
@@ -74,20 +142,26 @@ export function registerWorkbenchRequest({
|
|
|
74
142
|
expectedResponseTypes: new Set(expected),
|
|
75
143
|
publicRequestId,
|
|
76
144
|
terminalId,
|
|
145
|
+
allowLegacyCorrelation: allowLegacyCorrelation === true,
|
|
146
|
+
onTimeout,
|
|
77
147
|
expiresAt: Date.now() + REQUEST_TTL_MS,
|
|
78
148
|
timeout: null,
|
|
79
149
|
};
|
|
80
150
|
pending.timeout = setTimeout(() => {
|
|
81
|
-
|
|
82
|
-
if (current !== pending) return;
|
|
83
|
-
pendingRequests.delete(key);
|
|
84
|
-
releasePendingTerminalReservation(pending);
|
|
151
|
+
expirePending(key, pending);
|
|
85
152
|
}, REQUEST_TTL_MS);
|
|
86
153
|
pending.timeout.unref?.();
|
|
87
154
|
pendingRequests.set(key, pending);
|
|
88
155
|
return requestId;
|
|
89
156
|
}
|
|
90
157
|
|
|
158
|
+
function consumePending(key, pending) {
|
|
159
|
+
pendingRequests.delete(key);
|
|
160
|
+
if (pending.timeout) clearTimeout(pending.timeout);
|
|
161
|
+
pending.timeout = null;
|
|
162
|
+
return pending;
|
|
163
|
+
}
|
|
164
|
+
|
|
91
165
|
export function consumeWorkbenchRequest({ agentId, requestId, responseType, routeKey = null }) {
|
|
92
166
|
if (!agentId || !requestId || !responseType) return null;
|
|
93
167
|
prune();
|
|
@@ -95,17 +169,90 @@ export function consumeWorkbenchRequest({ agentId, requestId, responseType, rout
|
|
|
95
169
|
const pending = pendingRequests.get(key);
|
|
96
170
|
if (!pending || !pending.expectedResponseTypes.has(responseType)) return null;
|
|
97
171
|
if (routeKey && pending.routeKey !== routeKey) return null;
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
172
|
+
return consumePending(key, pending);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Correlate responses from the short-lived Agent release window that supported
|
|
177
|
+
* Session Workbench routes but did not echo `_workbenchRequestId`. Agent fields
|
|
178
|
+
* only filter Server-owned pending records; they never directly select a Web
|
|
179
|
+
* client. Ambiguous matches are intentionally left pending to time out.
|
|
180
|
+
*/
|
|
181
|
+
function matchesLegacyCorrelation(record, {
|
|
182
|
+
agentId,
|
|
183
|
+
responseType,
|
|
184
|
+
routeKey,
|
|
185
|
+
userId,
|
|
186
|
+
clientId,
|
|
187
|
+
publicRequestId,
|
|
188
|
+
terminalId,
|
|
189
|
+
}, responseTypes) {
|
|
190
|
+
if (record.agentId !== agentId
|
|
191
|
+
|| record.routeKey !== routeKey
|
|
192
|
+
|| record.userId !== userId
|
|
193
|
+
|| !responseTypes?.has(responseType)) return false;
|
|
194
|
+
if (clientId && record.clientId !== clientId) return false;
|
|
195
|
+
if (publicRequestId && record.publicRequestId !== publicRequestId) return false;
|
|
196
|
+
if (terminalId && record.terminalId !== terminalId) return false;
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function consumeLegacyWorkbenchRequest({
|
|
201
|
+
agentId,
|
|
202
|
+
responseType,
|
|
203
|
+
routeKey,
|
|
204
|
+
userId,
|
|
205
|
+
clientId = null,
|
|
206
|
+
publicRequestId = null,
|
|
207
|
+
terminalId = null,
|
|
208
|
+
}) {
|
|
209
|
+
if (!agentId || !responseType || !routeKey || !userId) return null;
|
|
210
|
+
const correlation = {
|
|
211
|
+
agentId,
|
|
212
|
+
responseType,
|
|
213
|
+
routeKey,
|
|
214
|
+
userId,
|
|
215
|
+
clientId,
|
|
216
|
+
publicRequestId,
|
|
217
|
+
terminalId,
|
|
218
|
+
};
|
|
219
|
+
prune();
|
|
220
|
+
for (const [key, expired] of expiredRequests) {
|
|
221
|
+
if (!matchesLegacyCorrelation(expired, correlation, expired.responseTypes)) continue;
|
|
222
|
+
// A public request id identifies the expired request exactly, so one late
|
|
223
|
+
// response can retire its tombstone. Without one, old and retried responses
|
|
224
|
+
// are indistinguishable; keep the quarantine until TTL rather than risk
|
|
225
|
+
// consuming a newer pending request.
|
|
226
|
+
if (publicRequestId) expiredRequests.delete(key);
|
|
227
|
+
return {
|
|
228
|
+
quarantine: LEGACY_WORKBENCH_REQUEST_QUARANTINED,
|
|
229
|
+
agentId: expired.agentId,
|
|
230
|
+
requestId: expired.requestId,
|
|
231
|
+
conversationId: expired.conversationId,
|
|
232
|
+
routeKey: expired.routeKey,
|
|
233
|
+
workspaceGeneration: expired.workspaceGeneration,
|
|
234
|
+
terminalId: expired.terminalId,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
const matches = [];
|
|
238
|
+
for (const [key, pending] of pendingRequests) {
|
|
239
|
+
if (!matchesLegacyCorrelation(pending, correlation, pending.expectedResponseTypes)) continue;
|
|
240
|
+
matches.push([key, pending]);
|
|
241
|
+
if (matches.length > 1) return null;
|
|
242
|
+
}
|
|
243
|
+
if (matches.length !== 1) return null;
|
|
244
|
+
return consumePending(matches[0][0], matches[0][1]);
|
|
102
245
|
}
|
|
103
246
|
|
|
104
247
|
export function deleteWorkbenchRequest({ agentId, requestId }) {
|
|
105
248
|
const key = requestKey(agentId, requestId);
|
|
106
249
|
const pending = pendingRequests.get(key);
|
|
107
250
|
const deleted = pendingRequests.delete(key);
|
|
108
|
-
if (deleted)
|
|
251
|
+
if (deleted) {
|
|
252
|
+
if (pending.timeout) clearTimeout(pending.timeout);
|
|
253
|
+
pending.timeout = null;
|
|
254
|
+
releasePendingTerminalReservation(pending);
|
|
255
|
+
}
|
|
109
256
|
return deleted;
|
|
110
257
|
}
|
|
111
258
|
|
|
@@ -144,17 +291,25 @@ export function deleteWorkbenchTerminalOwner(agentId, terminalId) {
|
|
|
144
291
|
|
|
145
292
|
export function clearWorkbenchCorrelationsForClient(clientId) {
|
|
146
293
|
if (!clientId) return [];
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
releasePendingTerminalReservation(pending);
|
|
151
|
-
}
|
|
294
|
+
// Capture terminal cleanup capabilities before releasing pending creates.
|
|
295
|
+
// releasePendingTerminalReservation() removes the matching owner, but the
|
|
296
|
+
// disconnect path still needs its exact create token to cancel Agent work.
|
|
152
297
|
const terminals = [];
|
|
153
298
|
for (const [key, owner] of terminalOwners) {
|
|
154
299
|
if (owner?.clientId !== clientId) continue;
|
|
155
300
|
terminals.push(owner);
|
|
156
301
|
terminalOwners.delete(key);
|
|
157
302
|
}
|
|
303
|
+
for (const [key, pending] of pendingRequests) {
|
|
304
|
+
if (pending?.clientId !== clientId) continue;
|
|
305
|
+
pendingRequests.delete(key);
|
|
306
|
+
if (pending.timeout) clearTimeout(pending.timeout);
|
|
307
|
+
pending.timeout = null;
|
|
308
|
+
releasePendingTerminalReservation(pending);
|
|
309
|
+
}
|
|
310
|
+
for (const [key, expired] of expiredRequests) {
|
|
311
|
+
if (expired?.clientId === clientId) expiredRequests.delete(key);
|
|
312
|
+
}
|
|
158
313
|
return terminals;
|
|
159
314
|
}
|
|
160
315
|
|
|
@@ -163,16 +318,26 @@ export function clearWorkbenchCorrelationsForAgent(agentId) {
|
|
|
163
318
|
for (const [key, pending] of pendingRequests) {
|
|
164
319
|
if (pending?.agentId !== agentId) continue;
|
|
165
320
|
pendingRequests.delete(key);
|
|
321
|
+
if (pending.timeout) clearTimeout(pending.timeout);
|
|
322
|
+
pending.timeout = null;
|
|
166
323
|
releasePendingTerminalReservation(pending);
|
|
167
324
|
}
|
|
325
|
+
for (const [key, expired] of expiredRequests) {
|
|
326
|
+
if (expired?.agentId === agentId) expiredRequests.delete(key);
|
|
327
|
+
}
|
|
168
328
|
for (const [key, owner] of terminalOwners) {
|
|
169
329
|
if (owner?.agentId === agentId) terminalOwners.delete(key);
|
|
170
330
|
}
|
|
171
331
|
}
|
|
172
332
|
|
|
173
333
|
export function __testResetWorkbenchCorrelations() {
|
|
174
|
-
for (const pending of pendingRequests.values())
|
|
334
|
+
for (const pending of pendingRequests.values()) {
|
|
335
|
+
if (pending.timeout) clearTimeout(pending.timeout);
|
|
336
|
+
pending.timeout = null;
|
|
337
|
+
releasePendingTerminalReservation(pending);
|
|
338
|
+
}
|
|
175
339
|
pendingRequests.clear();
|
|
340
|
+
expiredRequests.clear();
|
|
176
341
|
terminalOwners.clear();
|
|
177
342
|
}
|
|
178
343
|
|
|
@@ -4,6 +4,20 @@ import { sessionDb } from './db/session-db.js';
|
|
|
4
4
|
import { yeaftSessionDb } from './db/yeaft-session-db.js';
|
|
5
5
|
|
|
6
6
|
export const WORKBENCH_SESSION_ROUTE_CAPABILITY = 'workbench_session_routes';
|
|
7
|
+
export const WORKBENCH_REQUEST_CORRELATION_CAPABILITY = 'workbench_request_correlation';
|
|
8
|
+
export const WORKBENCH_TERMINAL_CLEANUP_FENCE_CAPABILITY = 'workbench_terminal_cleanup_fence';
|
|
9
|
+
|
|
10
|
+
function agentHasCapability(agent, capability) {
|
|
11
|
+
return Array.isArray(agent?.capabilities) && agent.capabilities.includes(capability);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function agentSupportsWorkbenchRequestCorrelation(agent) {
|
|
15
|
+
return agentHasCapability(agent, WORKBENCH_REQUEST_CORRELATION_CAPABILITY);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function agentSupportsWorkbenchTerminalCleanupFence(agent) {
|
|
19
|
+
return agentHasCapability(agent, WORKBENCH_TERMINAL_CLEANUP_FENCE_CAPABILITY);
|
|
20
|
+
}
|
|
7
21
|
|
|
8
22
|
const PROVIDERS = new Set(['yeaft', 'claude-code', 'copilot']);
|
|
9
23
|
const SCOPES = new Set(['main', 'files-folder-picker', 'git-folder-picker']);
|
|
@@ -17,7 +17,11 @@ import {
|
|
|
17
17
|
BROWSER_RUNTIME_SETUP_PROTOCOL,
|
|
18
18
|
WORKBENCH_ROUTE_PROTOCOL,
|
|
19
19
|
} from './client-protocol.js';
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
clearWorkbenchCorrelationsForClient,
|
|
22
|
+
workbenchTerminalCleanupMessage,
|
|
23
|
+
} from './workbench-correlation.js';
|
|
24
|
+
import { agentSupportsWorkbenchTerminalCleanupFence } from './workbench-route.js';
|
|
21
25
|
import { clearBrowserRuntimeForClient } from './browser-runtime-routes.js';
|
|
22
26
|
import {
|
|
23
27
|
parseMessage, sendToWebClient, sendToAgent,
|
|
@@ -30,6 +34,17 @@ import { handleClientMisc } from './handlers/client-misc.js';
|
|
|
30
34
|
import { clearWorkCenterRequestsForClient, handleClientWorkCenter } from './handlers/client-work-center.js';
|
|
31
35
|
import { recordPerfTraceEvent } from './perf-trace.js';
|
|
32
36
|
|
|
37
|
+
export function cleanupWorkbenchForDisconnectedClient(clientId) {
|
|
38
|
+
const ownedTerminals = clearWorkbenchCorrelationsForClient(clientId);
|
|
39
|
+
for (const owner of ownedTerminals) {
|
|
40
|
+
const agent = agents.get(owner.agentId);
|
|
41
|
+
const closeMessage = workbenchTerminalCleanupMessage(owner);
|
|
42
|
+
if (!agent || !closeMessage || !agentSupportsWorkbenchTerminalCleanupFence(agent)) continue;
|
|
43
|
+
void sendToAgent(agent, closeMessage)
|
|
44
|
+
.catch(error => console.warn('[Workbench] PTY disconnect cleanup failed:', error.message));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
33
48
|
export function handleWebConnection(ws, url, req = {}) {
|
|
34
49
|
const clientId = randomUUID();
|
|
35
50
|
|
|
@@ -203,18 +218,7 @@ export function handleWebConnection(ws, url, req = {}) {
|
|
|
203
218
|
},
|
|
204
219
|
}).catch(error => console.warn('[BrowserRuntime] peer disconnect cleanup failed:', error.message));
|
|
205
220
|
}
|
|
206
|
-
|
|
207
|
-
for (const owner of ownedTerminals) {
|
|
208
|
-
const agent = agents.get(owner.agentId);
|
|
209
|
-
if (!agent) continue;
|
|
210
|
-
void sendToAgent(agent, {
|
|
211
|
-
type: 'terminal_close',
|
|
212
|
-
terminalId: owner.terminalId,
|
|
213
|
-
conversationId: owner.conversationId,
|
|
214
|
-
workbenchRouteKey: owner.routeKey,
|
|
215
|
-
workbenchWorkspaceGeneration: owner.workspaceGeneration,
|
|
216
|
-
}).catch(error => console.warn('[Workbench] PTY disconnect cleanup failed:', error.message));
|
|
217
|
-
}
|
|
221
|
+
cleanupWorkbenchForDisconnectedClient(clientId);
|
|
218
222
|
webClients.delete(clientId);
|
|
219
223
|
console.log(`Web client disconnected: ${clientId}`);
|
|
220
224
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"1.0.
|
|
1
|
+
{"version":"1.0.491"}
|