@qwen-code/channel-base 0.19.8 → 0.19.9-preview.0
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/dist/AcpBridge.d.ts +1 -0
- package/dist/AcpBridge.js +34 -2
- package/dist/AcpBridge.js.map +1 -1
- package/dist/ChannelAgentBridge.d.ts +8 -2
- package/dist/ChannelBase.d.ts +19 -5
- package/dist/ChannelBase.js +389 -98
- package/dist/ChannelBase.js.map +1 -1
- package/dist/ChannelWebhookTask.d.ts +29 -0
- package/dist/ChannelWebhookTask.js +54 -0
- package/dist/ChannelWebhookTask.js.map +1 -0
- package/dist/DaemonChannelBridge.d.ts +15 -2
- package/dist/DaemonChannelBridge.js +129 -13
- package/dist/DaemonChannelBridge.js.map +1 -1
- package/dist/SessionRouter.d.ts +35 -2
- package/dist/SessionRouter.js +426 -70
- package/dist/SessionRouter.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/sanitize.d.ts +7 -0
- package/dist/sanitize.js +1 -1
- package/dist/sanitize.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
package/dist/SessionRouter.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { existsSync, readFileSync,
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
2
3
|
import process from 'node:process';
|
|
3
4
|
import { sanitizeLogText } from './sanitize.js';
|
|
4
5
|
export class SessionRouter {
|
|
@@ -7,16 +8,22 @@ export class SessionRouter {
|
|
|
7
8
|
toCwd = new Map(); // session ID → cwd
|
|
8
9
|
creatingSessions = new Map();
|
|
9
10
|
sessionLoadWindows = new Set();
|
|
11
|
+
liveSessionIds = new Set();
|
|
12
|
+
routeTokens = new Map();
|
|
13
|
+
lifecycleGeneration = 0;
|
|
10
14
|
bridge;
|
|
11
15
|
defaultCwd;
|
|
12
16
|
defaultScope;
|
|
13
17
|
channelScopes = new Map();
|
|
18
|
+
channelApprovalModes = new Map();
|
|
14
19
|
persistPath;
|
|
15
|
-
|
|
20
|
+
recoveryMode;
|
|
21
|
+
constructor(bridge, defaultCwd, scope = 'user', persistPath, options = {}) {
|
|
16
22
|
this.bridge = bridge;
|
|
17
23
|
this.defaultCwd = defaultCwd;
|
|
18
24
|
this.defaultScope = scope;
|
|
19
25
|
this.persistPath = persistPath;
|
|
26
|
+
this.recoveryMode = options.recoveryMode ?? 'eager';
|
|
20
27
|
}
|
|
21
28
|
/** Replace the bridge instance (used after crash recovery restart). */
|
|
22
29
|
setBridge(bridge) {
|
|
@@ -26,6 +33,14 @@ export class SessionRouter {
|
|
|
26
33
|
setChannelScope(channelName, scope) {
|
|
27
34
|
this.channelScopes.set(channelName, scope);
|
|
28
35
|
}
|
|
36
|
+
setChannelApprovalMode(channelName, approvalMode) {
|
|
37
|
+
if (approvalMode) {
|
|
38
|
+
this.channelApprovalModes.set(channelName, approvalMode);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
this.channelApprovalModes.delete(channelName);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
29
44
|
routingKey(channelName, senderId, chatId, threadId) {
|
|
30
45
|
const scope = this.channelScopes.get(channelName) || this.defaultScope;
|
|
31
46
|
switch (scope) {
|
|
@@ -38,66 +53,186 @@ export class SessionRouter {
|
|
|
38
53
|
return `${channelName}:${senderId}:${chatId}`;
|
|
39
54
|
}
|
|
40
55
|
}
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
56
|
+
sessionOptions(channelName) {
|
|
57
|
+
const approvalMode = this.channelApprovalModes.get(channelName);
|
|
58
|
+
return approvalMode ? { approvalMode } : undefined;
|
|
59
|
+
}
|
|
60
|
+
async resolve(channelName, senderId, chatId, threadId, cwd, isGroup, options) {
|
|
61
|
+
const key = this.routingKey(channelName, senderId, chatId, options?.routingThreadId ?? threadId);
|
|
62
|
+
const input = {
|
|
63
|
+
channelName,
|
|
64
|
+
senderId,
|
|
65
|
+
chatId,
|
|
66
|
+
threadId,
|
|
67
|
+
cwd: cwd || this.defaultCwd,
|
|
68
|
+
isGroup,
|
|
69
|
+
};
|
|
70
|
+
let failedWaits = 0;
|
|
44
71
|
for (;;) {
|
|
45
72
|
const existing = this.toSession.get(key);
|
|
46
|
-
if (existing) {
|
|
73
|
+
if (existing && this.isLive(existing)) {
|
|
47
74
|
this.promoteTargetToGroup(existing, isGroup);
|
|
48
75
|
return existing;
|
|
49
76
|
}
|
|
50
77
|
const creating = this.creatingSessions.get(key);
|
|
51
78
|
if (creating) {
|
|
52
79
|
try {
|
|
53
|
-
const sessionId = await creating;
|
|
80
|
+
const sessionId = await creating.promise;
|
|
81
|
+
try {
|
|
82
|
+
this.assertOperationResultCurrent(key, sessionId, creating);
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
this.scheduleDiscardInvalidatedSession(sessionId, creating);
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
54
88
|
this.promoteTargetToGroup(sessionId, isGroup);
|
|
55
89
|
return sessionId;
|
|
56
90
|
}
|
|
57
|
-
catch (
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (creating.invalidationError) {
|
|
93
|
+
throw creating.invalidationError;
|
|
94
|
+
}
|
|
58
95
|
if (this.creatingSessions.get(key) === creating) {
|
|
59
96
|
this.creatingSessions.delete(key);
|
|
60
97
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
98
|
+
this.releaseRouteToken(key, creating);
|
|
99
|
+
failedWaits++;
|
|
100
|
+
if (failedWaits > 3)
|
|
101
|
+
throw error;
|
|
65
102
|
continue;
|
|
66
103
|
}
|
|
67
104
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
105
|
+
const operation = this.createSessionOperation(key, {
|
|
106
|
+
channelName: input.channelName,
|
|
107
|
+
senderId: input.senderId,
|
|
108
|
+
chatId: input.chatId,
|
|
109
|
+
threadId: input.threadId,
|
|
110
|
+
isGroup: input.isGroup,
|
|
111
|
+
}, (currentOperation) => existing
|
|
112
|
+
? this.loadOrReplaceSession(key, existing, input, currentOperation)
|
|
113
|
+
: this.createAndStoreSession(key, input, currentOperation));
|
|
114
|
+
this.creatingSessions.set(key, operation);
|
|
115
|
+
try {
|
|
116
|
+
const sessionId = await operation.promise;
|
|
73
117
|
try {
|
|
74
|
-
|
|
75
|
-
this.toSession.set(key, sessionId);
|
|
76
|
-
this.toTarget.set(sessionId, {
|
|
77
|
-
channelName,
|
|
78
|
-
senderId,
|
|
79
|
-
chatId,
|
|
80
|
-
threadId,
|
|
81
|
-
isGroup,
|
|
82
|
-
});
|
|
83
|
-
this.toCwd.set(sessionId, sessionCwd);
|
|
84
|
-
this.persist();
|
|
85
|
-
return sessionId;
|
|
118
|
+
this.assertOperationResultCurrent(key, sessionId, operation);
|
|
86
119
|
}
|
|
87
|
-
|
|
88
|
-
this.
|
|
120
|
+
catch (error) {
|
|
121
|
+
this.scheduleDiscardInvalidatedSession(sessionId, operation);
|
|
122
|
+
throw error;
|
|
89
123
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
try {
|
|
93
|
-
return await created;
|
|
124
|
+
this.promoteTargetToGroup(sessionId, isGroup);
|
|
125
|
+
return sessionId;
|
|
94
126
|
}
|
|
95
127
|
finally {
|
|
96
|
-
if (this.creatingSessions.get(key) ===
|
|
128
|
+
if (this.creatingSessions.get(key) === operation) {
|
|
97
129
|
this.creatingSessions.delete(key);
|
|
98
130
|
}
|
|
131
|
+
this.releaseRouteToken(key, operation);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
isLive(sessionId) {
|
|
136
|
+
return this.recoveryMode === 'eager' || this.liveSessionIds.has(sessionId);
|
|
137
|
+
}
|
|
138
|
+
async createAndStoreSession(key, input, operation) {
|
|
139
|
+
const loadWindow = this.beginSessionLoad();
|
|
140
|
+
try {
|
|
141
|
+
const sessionId = await this.createLiveSession(input.cwd, loadWindow, key, this.sessionOptions(input.channelName), operation);
|
|
142
|
+
try {
|
|
143
|
+
this.assertOperationCurrent(operation);
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
this.scheduleDiscardInvalidatedSession(sessionId, operation);
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
this.toSession.set(key, sessionId);
|
|
150
|
+
this.toTarget.set(sessionId, {
|
|
151
|
+
channelName: input.channelName,
|
|
152
|
+
senderId: input.senderId,
|
|
153
|
+
chatId: input.chatId,
|
|
154
|
+
threadId: input.threadId,
|
|
155
|
+
isGroup: input.isGroup,
|
|
156
|
+
});
|
|
157
|
+
this.toCwd.set(sessionId, input.cwd);
|
|
158
|
+
this.liveSessionIds.add(sessionId);
|
|
159
|
+
this.persist();
|
|
160
|
+
return sessionId;
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
this.endSessionLoad(loadWindow);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async loadOrReplaceSession(key, savedSessionId, input, operation) {
|
|
167
|
+
const savedCwd = this.toCwd.get(savedSessionId) ?? input.cwd;
|
|
168
|
+
const loadWindow = this.beginSessionLoad();
|
|
169
|
+
try {
|
|
170
|
+
try {
|
|
171
|
+
const loadedSessionId = await this.bridge.loadSession(savedSessionId, savedCwd, this.sessionOptions(input.channelName), operation);
|
|
172
|
+
try {
|
|
173
|
+
this.assertOperationCurrent(operation);
|
|
174
|
+
if (this.toSession.get(key) !== savedSessionId) {
|
|
175
|
+
this.invalidateOperation(operation);
|
|
176
|
+
this.assertOperationCurrent(operation);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
this.scheduleDiscardInvalidatedSession(loadedSessionId, operation);
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
if (typeof loadedSessionId !== 'string' ||
|
|
184
|
+
loadedSessionId.length === 0 ||
|
|
185
|
+
loadWindow.delete(loadedSessionId)) {
|
|
186
|
+
throw new Error('Invalid or dead restored session ID');
|
|
187
|
+
}
|
|
188
|
+
if (loadedSessionId !== savedSessionId) {
|
|
189
|
+
const target = this.toTarget.get(savedSessionId);
|
|
190
|
+
this.deleteByKey(key);
|
|
191
|
+
this.toSession.set(key, loadedSessionId);
|
|
192
|
+
if (target)
|
|
193
|
+
this.toTarget.set(loadedSessionId, target);
|
|
194
|
+
this.toCwd.set(loadedSessionId, savedCwd);
|
|
195
|
+
this.persist();
|
|
196
|
+
}
|
|
197
|
+
this.liveSessionIds.add(loadedSessionId);
|
|
198
|
+
return loadedSessionId;
|
|
199
|
+
}
|
|
200
|
+
catch (loadError) {
|
|
201
|
+
this.assertOperationCurrent(operation);
|
|
202
|
+
try {
|
|
203
|
+
const replacement = await this.createLiveSession(input.cwd, loadWindow, key, this.sessionOptions(input.channelName), operation);
|
|
204
|
+
try {
|
|
205
|
+
this.assertOperationCurrent(operation);
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
this.scheduleDiscardInvalidatedSession(replacement, operation);
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
this.deleteByKey(key);
|
|
212
|
+
this.toSession.set(key, replacement);
|
|
213
|
+
this.toTarget.set(replacement, {
|
|
214
|
+
channelName: input.channelName,
|
|
215
|
+
senderId: input.senderId,
|
|
216
|
+
chatId: input.chatId,
|
|
217
|
+
threadId: input.threadId,
|
|
218
|
+
isGroup: input.isGroup,
|
|
219
|
+
});
|
|
220
|
+
this.toCwd.set(replacement, input.cwd);
|
|
221
|
+
this.liveSessionIds.add(replacement);
|
|
222
|
+
this.persist();
|
|
223
|
+
process.stderr.write(`[SessionRouter] Replaced unavailable session ${sanitizeLogText(savedSessionId, 128)} for key ${sanitizeLogText(key, 256)} after load failed: ${sanitizeLogText(loadError instanceof Error ? loadError.message : String(loadError), 512)}\n`);
|
|
224
|
+
return replacement;
|
|
225
|
+
}
|
|
226
|
+
catch (createError) {
|
|
227
|
+
this.assertOperationCurrent(operation);
|
|
228
|
+
process.stderr.write(`[SessionRouter] Failed to load session ${sanitizeLogText(savedSessionId, 128)} for key ${sanitizeLogText(key, 256)} (${sanitizeLogText(loadError instanceof Error ? loadError.message : String(loadError), 512)}) and failed to create a replacement (${sanitizeLogText(createError instanceof Error ? createError.message : String(createError), 512)})\n`);
|
|
229
|
+
throw createError;
|
|
230
|
+
}
|
|
99
231
|
}
|
|
100
232
|
}
|
|
233
|
+
finally {
|
|
234
|
+
this.endSessionLoad(loadWindow);
|
|
235
|
+
}
|
|
101
236
|
}
|
|
102
237
|
getTarget(sessionId) {
|
|
103
238
|
return this.toTarget.get(sessionId);
|
|
@@ -132,6 +267,7 @@ export class SessionRouter {
|
|
|
132
267
|
const scope = this.channelScopes.get(channelName) || this.defaultScope;
|
|
133
268
|
if (chatId) {
|
|
134
269
|
const key = this.routingKey(channelName, senderId, chatId, threadId);
|
|
270
|
+
this.invalidateRouteOperation(key);
|
|
135
271
|
const sessionId = this.deleteByKey(key);
|
|
136
272
|
if (sessionId)
|
|
137
273
|
removedIds.push(sessionId);
|
|
@@ -145,11 +281,18 @@ export class SessionRouter {
|
|
|
145
281
|
const target = this.toTarget.get(mappedSessionId);
|
|
146
282
|
if (target?.channelName === channelName &&
|
|
147
283
|
target.senderId === senderId) {
|
|
284
|
+
this.invalidateRouteOperation(k);
|
|
148
285
|
const sessionId = this.deleteByKey(k);
|
|
149
286
|
if (sessionId)
|
|
150
287
|
removedIds.push(sessionId);
|
|
151
288
|
}
|
|
152
289
|
}
|
|
290
|
+
for (const [key, operation] of [...this.creatingSessions]) {
|
|
291
|
+
if (operation.target.channelName === channelName &&
|
|
292
|
+
operation.target.senderId === senderId) {
|
|
293
|
+
this.invalidateRouteOperation(key);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
153
296
|
}
|
|
154
297
|
if (removedIds.length > 0)
|
|
155
298
|
this.persist();
|
|
@@ -160,6 +303,7 @@ export class SessionRouter {
|
|
|
160
303
|
let removed = false;
|
|
161
304
|
for (const [key, mappedSessionId] of [...this.toSession.entries()]) {
|
|
162
305
|
if (mappedSessionId === sessionId) {
|
|
306
|
+
this.invalidateRouteOperation(key);
|
|
163
307
|
this.toSession.delete(key);
|
|
164
308
|
removed = true;
|
|
165
309
|
}
|
|
@@ -170,6 +314,7 @@ export class SessionRouter {
|
|
|
170
314
|
if (this.toCwd.delete(sessionId)) {
|
|
171
315
|
removed = true;
|
|
172
316
|
}
|
|
317
|
+
this.liveSessionIds.delete(sessionId);
|
|
173
318
|
if (!removed && this.sessionLoadWindows.size > 0) {
|
|
174
319
|
for (const loadWindow of this.sessionLoadWindows) {
|
|
175
320
|
loadWindow.add(sessionId);
|
|
@@ -180,6 +325,17 @@ export class SessionRouter {
|
|
|
180
325
|
}
|
|
181
326
|
return removed;
|
|
182
327
|
}
|
|
328
|
+
handleSessionDied(sessionId) {
|
|
329
|
+
if (this.recoveryMode === 'eager') {
|
|
330
|
+
return this.removeSessionId(sessionId);
|
|
331
|
+
}
|
|
332
|
+
const known = this.toTarget.has(sessionId);
|
|
333
|
+
this.liveSessionIds.delete(sessionId);
|
|
334
|
+
for (const loadWindow of this.sessionLoadWindows) {
|
|
335
|
+
loadWindow.add(sessionId);
|
|
336
|
+
}
|
|
337
|
+
return known;
|
|
338
|
+
}
|
|
183
339
|
deleteByKey(key) {
|
|
184
340
|
const sessionId = this.toSession.get(key);
|
|
185
341
|
if (!sessionId)
|
|
@@ -187,6 +343,7 @@ export class SessionRouter {
|
|
|
187
343
|
this.toSession.delete(key);
|
|
188
344
|
this.toTarget.delete(sessionId);
|
|
189
345
|
this.toCwd.delete(sessionId);
|
|
346
|
+
this.liveSessionIds.delete(sessionId);
|
|
190
347
|
return sessionId;
|
|
191
348
|
}
|
|
192
349
|
promoteTargetToGroup(sessionId, isGroup) {
|
|
@@ -209,46 +366,72 @@ export class SessionRouter {
|
|
|
209
366
|
}
|
|
210
367
|
return entries;
|
|
211
368
|
}
|
|
369
|
+
restoreRoutes() {
|
|
370
|
+
if (this.recoveryMode !== 'lazy') {
|
|
371
|
+
throw new Error('restoreRoutes requires lazy recovery mode');
|
|
372
|
+
}
|
|
373
|
+
const persisted = this.readPersistedEntries();
|
|
374
|
+
if (!persisted)
|
|
375
|
+
return { restored: 0, dropped: 0 };
|
|
376
|
+
this.dispose();
|
|
377
|
+
let restored = 0;
|
|
378
|
+
for (const [key, entry] of Object.entries(persisted.entries)) {
|
|
379
|
+
this.toSession.set(key, entry.sessionId);
|
|
380
|
+
this.toTarget.set(entry.sessionId, entry.target);
|
|
381
|
+
this.toCwd.set(entry.sessionId, entry.cwd);
|
|
382
|
+
restored++;
|
|
383
|
+
}
|
|
384
|
+
if (persisted.dropped > 0)
|
|
385
|
+
this.persist();
|
|
386
|
+
return { restored, dropped: persisted.dropped };
|
|
387
|
+
}
|
|
212
388
|
/**
|
|
213
389
|
* Restore session mappings from a previous bridge.
|
|
214
390
|
* Called after bridge restart — attempts loadSession for each saved mapping.
|
|
215
391
|
* Failed loads are dropped (new session on next message).
|
|
216
392
|
*/
|
|
217
393
|
async restoreSessions() {
|
|
218
|
-
const
|
|
219
|
-
if (!
|
|
220
|
-
return { restored: 0, failed: 0 };
|
|
221
|
-
}
|
|
222
|
-
let entries;
|
|
223
|
-
try {
|
|
224
|
-
entries = JSON.parse(readFileSync(persistPath, 'utf-8'));
|
|
225
|
-
}
|
|
226
|
-
catch (err) {
|
|
227
|
-
const reason = err instanceof Error ? err.message : String(err);
|
|
228
|
-
process.stderr.write(`[SessionRouter] Corrupted persist file at ${sanitizeLogText(persistPath, 1024)}: ${sanitizeLogText(reason, 512)}\n`);
|
|
394
|
+
const persisted = this.readPersistedEntries();
|
|
395
|
+
if (!persisted)
|
|
229
396
|
return { restored: 0, failed: 0 };
|
|
230
|
-
|
|
397
|
+
const entries = persisted.entries;
|
|
398
|
+
const restoreGeneration = this.lifecycleGeneration;
|
|
231
399
|
let restored = 0;
|
|
232
400
|
let failed = 0;
|
|
233
|
-
let changed =
|
|
401
|
+
let changed = persisted.dropped > 0;
|
|
234
402
|
const reservations = new Map();
|
|
403
|
+
for (const key of persisted.droppedKeys) {
|
|
404
|
+
this.deleteByKey(key);
|
|
405
|
+
}
|
|
235
406
|
// Reserve every persisted key up front so inbound messages during restart
|
|
236
407
|
// wait for restore instead of returning stale IDs or creating duplicates.
|
|
237
408
|
for (const key of Object.keys(entries)) {
|
|
238
409
|
this.deleteByKey(key);
|
|
239
410
|
const reservation = this.createSessionReservation();
|
|
240
411
|
reservation.promise.catch(() => undefined);
|
|
241
|
-
this.
|
|
242
|
-
|
|
412
|
+
const operation = this.createSessionOperation(key, entries[key].target, () => reservation.promise);
|
|
413
|
+
operation.promise.catch(() => undefined);
|
|
414
|
+
this.creatingSessions.set(key, operation);
|
|
415
|
+
reservations.set(key, { reservation, operation });
|
|
243
416
|
}
|
|
244
417
|
const loadWindow = this.beginSessionLoad();
|
|
245
418
|
try {
|
|
246
419
|
for (const [key, entry] of Object.entries(entries)) {
|
|
247
|
-
const
|
|
248
|
-
if (!
|
|
420
|
+
const reserved = reservations.get(key);
|
|
421
|
+
if (!reserved)
|
|
249
422
|
continue;
|
|
423
|
+
const { reservation, operation } = reserved;
|
|
250
424
|
try {
|
|
251
|
-
|
|
425
|
+
this.assertOperationCurrent(operation);
|
|
426
|
+
const options = this.sessionOptions(entry.target.channelName);
|
|
427
|
+
const sessionId = await this.bridge.loadSession(entry.sessionId, entry.cwd, options, operation);
|
|
428
|
+
try {
|
|
429
|
+
this.assertOperationCurrent(operation);
|
|
430
|
+
}
|
|
431
|
+
catch (error) {
|
|
432
|
+
this.scheduleDiscardInvalidatedSession(sessionId, operation);
|
|
433
|
+
throw error;
|
|
434
|
+
}
|
|
252
435
|
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
253
436
|
throw new Error('Invalid restored session ID');
|
|
254
437
|
}
|
|
@@ -273,9 +456,10 @@ export class SessionRouter {
|
|
|
273
456
|
changed = true;
|
|
274
457
|
}
|
|
275
458
|
finally {
|
|
276
|
-
if (this.creatingSessions.get(key) ===
|
|
459
|
+
if (this.creatingSessions.get(key) === operation) {
|
|
277
460
|
this.creatingSessions.delete(key);
|
|
278
461
|
}
|
|
462
|
+
this.releaseRouteToken(key, operation);
|
|
279
463
|
}
|
|
280
464
|
}
|
|
281
465
|
}
|
|
@@ -283,18 +467,27 @@ export class SessionRouter {
|
|
|
283
467
|
this.endSessionLoad(loadWindow);
|
|
284
468
|
}
|
|
285
469
|
// Update persist file to only include successfully restored sessions
|
|
286
|
-
if (changed) {
|
|
470
|
+
if (changed && restoreGeneration === this.lifecycleGeneration) {
|
|
287
471
|
this.persist();
|
|
288
472
|
}
|
|
289
473
|
return { restored, failed };
|
|
290
474
|
}
|
|
291
|
-
|
|
292
|
-
|
|
475
|
+
dispose() {
|
|
476
|
+
this.lifecycleGeneration++;
|
|
477
|
+
for (const operation of this.creatingSessions.values()) {
|
|
478
|
+
this.invalidateOperation(operation);
|
|
479
|
+
}
|
|
293
480
|
this.toSession.clear();
|
|
294
481
|
this.toTarget.clear();
|
|
295
482
|
this.toCwd.clear();
|
|
296
483
|
this.creatingSessions.clear();
|
|
297
484
|
this.sessionLoadWindows.clear();
|
|
485
|
+
this.liveSessionIds.clear();
|
|
486
|
+
this.routeTokens.clear();
|
|
487
|
+
}
|
|
488
|
+
/** Clear in-memory state and delete persist file. Used on clean shutdown. */
|
|
489
|
+
clearAll() {
|
|
490
|
+
this.dispose();
|
|
298
491
|
if (this.persistPath && existsSync(this.persistPath)) {
|
|
299
492
|
try {
|
|
300
493
|
unlinkSync(this.persistPath);
|
|
@@ -304,32 +497,128 @@ export class SessionRouter {
|
|
|
304
497
|
}
|
|
305
498
|
}
|
|
306
499
|
}
|
|
500
|
+
readPersistedEntries() {
|
|
501
|
+
const persistPath = this.persistPath;
|
|
502
|
+
if (!persistPath || !existsSync(persistPath))
|
|
503
|
+
return undefined;
|
|
504
|
+
let parsed;
|
|
505
|
+
try {
|
|
506
|
+
parsed = JSON.parse(readFileSync(persistPath, 'utf-8'));
|
|
507
|
+
}
|
|
508
|
+
catch (error) {
|
|
509
|
+
const quarantinePath = `${persistPath}.corrupt-${Date.now()}`;
|
|
510
|
+
try {
|
|
511
|
+
renameSync(persistPath, quarantinePath);
|
|
512
|
+
}
|
|
513
|
+
catch {
|
|
514
|
+
// Keep startup available even if quarantine itself fails.
|
|
515
|
+
}
|
|
516
|
+
process.stderr.write(`[SessionRouter] Corrupted persist file at ${sanitizeLogText(persistPath, 1024)}: ${sanitizeLogText(error instanceof Error ? error.message : String(error), 512)}\n`);
|
|
517
|
+
return undefined;
|
|
518
|
+
}
|
|
519
|
+
if (typeof parsed !== 'object' ||
|
|
520
|
+
parsed === null ||
|
|
521
|
+
Array.isArray(parsed)) {
|
|
522
|
+
const quarantinePath = `${persistPath}.corrupt-${Date.now()}`;
|
|
523
|
+
try {
|
|
524
|
+
renameSync(persistPath, quarantinePath);
|
|
525
|
+
}
|
|
526
|
+
catch {
|
|
527
|
+
// Keep startup available even if quarantine itself fails.
|
|
528
|
+
}
|
|
529
|
+
process.stderr.write(`[SessionRouter] Invalid route store at ${sanitizeLogText(persistPath, 1024)}: expected an object\n`);
|
|
530
|
+
return undefined;
|
|
531
|
+
}
|
|
532
|
+
const entries = {};
|
|
533
|
+
const droppedKeys = [];
|
|
534
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
535
|
+
if (this.isPersistedEntry(value))
|
|
536
|
+
entries[key] = value;
|
|
537
|
+
else
|
|
538
|
+
droppedKeys.push(key);
|
|
539
|
+
}
|
|
540
|
+
return { entries, dropped: droppedKeys.length, droppedKeys };
|
|
541
|
+
}
|
|
542
|
+
isPersistedEntry(value) {
|
|
543
|
+
if (typeof value !== 'object' || value === null)
|
|
544
|
+
return false;
|
|
545
|
+
const entry = value;
|
|
546
|
+
const target = entry['target'];
|
|
547
|
+
if (typeof target !== 'object' || target === null)
|
|
548
|
+
return false;
|
|
549
|
+
const typedTarget = target;
|
|
550
|
+
return (typeof entry['sessionId'] === 'string' &&
|
|
551
|
+
entry['sessionId'].length > 0 &&
|
|
552
|
+
typeof entry['cwd'] === 'string' &&
|
|
553
|
+
entry['cwd'].length > 0 &&
|
|
554
|
+
typeof typedTarget['channelName'] === 'string' &&
|
|
555
|
+
typeof typedTarget['senderId'] === 'string' &&
|
|
556
|
+
typeof typedTarget['chatId'] === 'string' &&
|
|
557
|
+
(typedTarget['threadId'] === undefined ||
|
|
558
|
+
typeof typedTarget['threadId'] === 'string') &&
|
|
559
|
+
(typedTarget['isGroup'] === undefined ||
|
|
560
|
+
typeof typedTarget['isGroup'] === 'boolean'));
|
|
561
|
+
}
|
|
307
562
|
persist() {
|
|
308
563
|
if (!this.persistPath)
|
|
309
564
|
return;
|
|
310
565
|
const data = {};
|
|
311
566
|
for (const [key, sessionId] of this.toSession) {
|
|
312
567
|
const target = this.toTarget.get(sessionId);
|
|
313
|
-
if (target)
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
}
|
|
568
|
+
if (!target)
|
|
569
|
+
continue;
|
|
570
|
+
data[key] = {
|
|
571
|
+
sessionId,
|
|
572
|
+
target,
|
|
573
|
+
cwd: this.toCwd.get(sessionId) ?? this.defaultCwd,
|
|
574
|
+
};
|
|
320
575
|
}
|
|
576
|
+
const dir = dirname(this.persistPath);
|
|
577
|
+
const tempPath = join(dir, `${Date.now()}-${process.pid}-${Math.random().toString(16).slice(2)}.tmp`);
|
|
321
578
|
try {
|
|
322
|
-
|
|
579
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
580
|
+
try {
|
|
581
|
+
chmodSync(dir, 0o700);
|
|
582
|
+
}
|
|
583
|
+
catch {
|
|
584
|
+
// Windows and some filesystems do not implement POSIX modes.
|
|
585
|
+
}
|
|
586
|
+
writeFileSync(tempPath, JSON.stringify(data, null, 2), {
|
|
587
|
+
encoding: 'utf-8',
|
|
588
|
+
mode: 0o600,
|
|
589
|
+
});
|
|
590
|
+
renameSync(tempPath, this.persistPath);
|
|
591
|
+
try {
|
|
592
|
+
chmodSync(this.persistPath, 0o600);
|
|
593
|
+
}
|
|
594
|
+
catch {
|
|
595
|
+
// Windows and some filesystems do not implement POSIX modes.
|
|
596
|
+
}
|
|
323
597
|
}
|
|
324
|
-
catch {
|
|
325
|
-
|
|
598
|
+
catch (error) {
|
|
599
|
+
process.stderr.write(`[SessionRouter] Failed to persist routes at ${sanitizeLogText(this.persistPath, 1024)}: ${sanitizeLogText(error instanceof Error ? error.message : String(error), 512)}\n`);
|
|
600
|
+
}
|
|
601
|
+
finally {
|
|
602
|
+
try {
|
|
603
|
+
rmSync(tempPath, { force: true });
|
|
604
|
+
}
|
|
605
|
+
catch {
|
|
606
|
+
// best-effort temp cleanup
|
|
607
|
+
}
|
|
326
608
|
}
|
|
327
609
|
}
|
|
328
|
-
async createLiveSession(cwd, loadWindow, routingKey) {
|
|
610
|
+
async createLiveSession(cwd, loadWindow, routingKey, options, operation) {
|
|
329
611
|
const maxAttempts = 2;
|
|
330
612
|
let lastDeadSessionId;
|
|
331
613
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
332
|
-
const sessionId = await this.bridge.newSession(cwd);
|
|
614
|
+
const sessionId = await this.bridge.newSession(cwd, options, operation);
|
|
615
|
+
try {
|
|
616
|
+
this.assertOperationCurrent(operation);
|
|
617
|
+
}
|
|
618
|
+
catch (error) {
|
|
619
|
+
this.scheduleDiscardInvalidatedSession(sessionId, operation);
|
|
620
|
+
throw error;
|
|
621
|
+
}
|
|
333
622
|
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
334
623
|
throw new Error('Invalid session ID from bridge');
|
|
335
624
|
}
|
|
@@ -345,6 +634,73 @@ export class SessionRouter {
|
|
|
345
634
|
this.sessionLoadWindows.add(loadWindow);
|
|
346
635
|
return loadWindow;
|
|
347
636
|
}
|
|
637
|
+
createSessionOperation(key, target, run) {
|
|
638
|
+
let routeToken = this.routeTokens.get(key);
|
|
639
|
+
if (!routeToken) {
|
|
640
|
+
routeToken = {};
|
|
641
|
+
this.routeTokens.set(key, routeToken);
|
|
642
|
+
}
|
|
643
|
+
const operation = {
|
|
644
|
+
promise: Promise.resolve(''),
|
|
645
|
+
target,
|
|
646
|
+
lifecycleGeneration: this.lifecycleGeneration,
|
|
647
|
+
routeToken,
|
|
648
|
+
};
|
|
649
|
+
operation.promise = Promise.resolve()
|
|
650
|
+
.then(() => run(operation))
|
|
651
|
+
.catch((error) => {
|
|
652
|
+
this.assertOperationCurrent(operation);
|
|
653
|
+
throw error;
|
|
654
|
+
});
|
|
655
|
+
return operation;
|
|
656
|
+
}
|
|
657
|
+
invalidateRouteOperation(key) {
|
|
658
|
+
this.routeTokens.delete(key);
|
|
659
|
+
const operation = this.creatingSessions.get(key);
|
|
660
|
+
if (!operation)
|
|
661
|
+
return;
|
|
662
|
+
this.invalidateOperation(operation);
|
|
663
|
+
this.creatingSessions.delete(key);
|
|
664
|
+
}
|
|
665
|
+
invalidateOperation(operation) {
|
|
666
|
+
operation.invalidationError ??= new Error('Session route operation was invalidated');
|
|
667
|
+
}
|
|
668
|
+
assertOperationCurrent(operation) {
|
|
669
|
+
if (operation.lifecycleGeneration !== this.lifecycleGeneration) {
|
|
670
|
+
this.invalidateOperation(operation);
|
|
671
|
+
}
|
|
672
|
+
if (operation.invalidationError) {
|
|
673
|
+
throw operation.invalidationError;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
assertOperationResultCurrent(key, sessionId, operation) {
|
|
677
|
+
if (operation.routeToken !== this.routeTokens.get(key)) {
|
|
678
|
+
this.invalidateOperation(operation);
|
|
679
|
+
}
|
|
680
|
+
if (this.toSession.get(key) !== sessionId) {
|
|
681
|
+
this.invalidateOperation(operation);
|
|
682
|
+
}
|
|
683
|
+
this.assertOperationCurrent(operation);
|
|
684
|
+
}
|
|
685
|
+
releaseRouteToken(key, operation) {
|
|
686
|
+
if (this.routeTokens.get(key) === operation.routeToken &&
|
|
687
|
+
!this.toSession.has(key) &&
|
|
688
|
+
!this.creatingSessions.has(key)) {
|
|
689
|
+
this.routeTokens.delete(key);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
scheduleDiscardInvalidatedSession(sessionId, operation) {
|
|
693
|
+
if ([...this.toSession.values()].includes(sessionId))
|
|
694
|
+
return;
|
|
695
|
+
try {
|
|
696
|
+
void this.bridge
|
|
697
|
+
.discardSession?.(sessionId, operation)
|
|
698
|
+
.catch(() => undefined);
|
|
699
|
+
}
|
|
700
|
+
catch {
|
|
701
|
+
// Best-effort cleanup must not replace the terminal invalidation.
|
|
702
|
+
}
|
|
703
|
+
}
|
|
348
704
|
createSessionReservation() {
|
|
349
705
|
let resolveReservation;
|
|
350
706
|
let rejectReservation;
|