@qwen-code/channel-base 0.22.2-preview.1 → 0.22.3
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/README.md +15 -9
- package/dist/AcpBridge.d.ts.map +1 -1
- package/dist/AcpBridge.js +4 -4
- package/dist/AcpBridge.js.map +1 -1
- package/dist/BlockStreamer.d.ts +6 -1
- package/dist/BlockStreamer.d.ts.map +1 -1
- package/dist/BlockStreamer.js +9 -1
- package/dist/BlockStreamer.js.map +1 -1
- package/dist/ChannelAgentBridge.d.ts +17 -0
- package/dist/ChannelAgentBridge.d.ts.map +1 -1
- package/dist/ChannelAgentBridge.js +30 -0
- package/dist/ChannelAgentBridge.js.map +1 -1
- package/dist/ChannelBase.d.ts +30 -1
- package/dist/ChannelBase.d.ts.map +1 -1
- package/dist/ChannelBase.js +416 -58
- package/dist/ChannelBase.js.map +1 -1
- package/dist/DaemonChannelBridge.d.ts +11 -3
- package/dist/DaemonChannelBridge.d.ts.map +1 -1
- package/dist/DaemonChannelBridge.js +243 -64
- package/dist/DaemonChannelBridge.js.map +1 -1
- package/dist/SessionRouter.d.ts +11 -0
- package/dist/SessionRouter.d.ts.map +1 -1
- package/dist/SessionRouter.js +150 -1
- package/dist/SessionRouter.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/named-session-manager.d.ts +89 -0
- package/dist/named-session-manager.d.ts.map +1 -0
- package/dist/named-session-manager.js +695 -0
- package/dist/named-session-manager.js.map +1 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { canonicalizeWorkspacePath } from './paths.js';
|
|
6
|
+
const REGISTRY_VERSION = 1;
|
|
7
|
+
const MAX_OPEN_TASKS = 8;
|
|
8
|
+
const TASK_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$/;
|
|
9
|
+
export class NamedSessionManager {
|
|
10
|
+
channelName;
|
|
11
|
+
cwd;
|
|
12
|
+
canonicalCwd;
|
|
13
|
+
filePath;
|
|
14
|
+
router;
|
|
15
|
+
isBusy;
|
|
16
|
+
now;
|
|
17
|
+
registry;
|
|
18
|
+
ownerOperations = new Map();
|
|
19
|
+
constructor(options) {
|
|
20
|
+
this.channelName = options.channelName;
|
|
21
|
+
this.cwd = options.cwd;
|
|
22
|
+
this.canonicalCwd = canonicalizeWorkspacePath(options.cwd);
|
|
23
|
+
this.filePath = options.filePath;
|
|
24
|
+
this.router = options.router;
|
|
25
|
+
this.isBusy = options.isBusy;
|
|
26
|
+
this.now = options.now ?? Date.now;
|
|
27
|
+
this.registry = this.readRegistry();
|
|
28
|
+
}
|
|
29
|
+
resolve(input, expectedSessionId, reserve) {
|
|
30
|
+
return this.withOwnerLock(input, () => this.resolveLocked(input, expectedSessionId, reserve));
|
|
31
|
+
}
|
|
32
|
+
resolveAfterPreparation(input, prepare, needsSession, reserve) {
|
|
33
|
+
return this.withOwnerLock(input, async () => {
|
|
34
|
+
if ((await prepare()) === false)
|
|
35
|
+
return { status: 'aborted' };
|
|
36
|
+
if (!needsSession())
|
|
37
|
+
return { status: 'bypassed' };
|
|
38
|
+
try {
|
|
39
|
+
return {
|
|
40
|
+
status: 'resolved',
|
|
41
|
+
sessionId: await this.resolveLocked(input, undefined, reserve),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
return { status: 'resolve_error', error };
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
list(input, includeClosed) {
|
|
50
|
+
return this.withOwnerLock(input, async () => {
|
|
51
|
+
const owner = await this.ensureOwner(input, false);
|
|
52
|
+
if (!owner)
|
|
53
|
+
return [];
|
|
54
|
+
return owner.tasks
|
|
55
|
+
.filter((task) => includeClosed || task.status === 'open')
|
|
56
|
+
.map((task) => this.view(owner, task));
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
current(input) {
|
|
60
|
+
return this.withOwnerLock(input, async () => {
|
|
61
|
+
const owner = await this.ensureOwner(input, false);
|
|
62
|
+
if (!owner?.activeTaskName)
|
|
63
|
+
return undefined;
|
|
64
|
+
const task = this.findTask(owner, owner.activeTaskName);
|
|
65
|
+
return task ? this.selection(owner, task) : undefined;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
create(input, name) {
|
|
69
|
+
return this.withOwnerLock(input, async () => {
|
|
70
|
+
this.validateName(name);
|
|
71
|
+
const existingOwner = await this.ensureOwner(input, false);
|
|
72
|
+
const owner = existingOwner ?? this.createOwner(input, null, []);
|
|
73
|
+
if (this.findTask(owner, name)) {
|
|
74
|
+
throw new Error(`Task "${name}" already exists.`);
|
|
75
|
+
}
|
|
76
|
+
if (this.openTaskCount(owner) >= MAX_OPEN_TASKS) {
|
|
77
|
+
throw new Error('You already have eight open tasks. Close one before creating another.');
|
|
78
|
+
}
|
|
79
|
+
this.assertCanLeaveSelected(owner, undefined);
|
|
80
|
+
const timestamp = this.nextTimestamp(owner);
|
|
81
|
+
const target = this.target(input);
|
|
82
|
+
const sessionId = await this.createSession(target, this.cwd, `Could not create task "${name}".`);
|
|
83
|
+
const task = {
|
|
84
|
+
name,
|
|
85
|
+
sessionId,
|
|
86
|
+
cwd: this.cwd,
|
|
87
|
+
isolation: 'shared',
|
|
88
|
+
status: 'open',
|
|
89
|
+
target,
|
|
90
|
+
createdAt: timestamp,
|
|
91
|
+
updatedAt: timestamp,
|
|
92
|
+
lastSelectedAt: timestamp,
|
|
93
|
+
};
|
|
94
|
+
const nextOwner = {
|
|
95
|
+
...owner,
|
|
96
|
+
activeTaskName: name,
|
|
97
|
+
tasks: [...owner.tasks, task],
|
|
98
|
+
};
|
|
99
|
+
try {
|
|
100
|
+
this.commitOwner(nextOwner);
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
await this.router
|
|
104
|
+
.detachManagedSession(sessionId)
|
|
105
|
+
.catch(() => undefined);
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
this.router.activateManagedSession(sessionId, target, this.cwd);
|
|
109
|
+
return this.selection(nextOwner, task);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
use(input, name) {
|
|
113
|
+
return this.withOwnerLock(input, async () => {
|
|
114
|
+
const owner = await this.ensureOwner(input, false);
|
|
115
|
+
if (!owner)
|
|
116
|
+
throw new Error('No named tasks exist in this chat.');
|
|
117
|
+
const task = this.findTask(owner, name);
|
|
118
|
+
if (!task)
|
|
119
|
+
throw new Error(`Task "${name}" was not found.`);
|
|
120
|
+
this.assertCanLeaveSelected(owner, task.name);
|
|
121
|
+
if (this.isBusy(task.sessionId)) {
|
|
122
|
+
throw new Error(`Task "${task.name}" is still running or waiting for permission.`);
|
|
123
|
+
}
|
|
124
|
+
if (task.status === 'closed' &&
|
|
125
|
+
this.openTaskCount(owner) >= MAX_OPEN_TASKS) {
|
|
126
|
+
throw new Error('You already have eight open tasks. Close one before reopening another.');
|
|
127
|
+
}
|
|
128
|
+
const timestamp = this.nextTimestamp(owner);
|
|
129
|
+
const loaded = await this.loadTask(task, `Could not load task "${task.name}". The current task was not changed.`);
|
|
130
|
+
const updatedTask = {
|
|
131
|
+
...task,
|
|
132
|
+
status: 'open',
|
|
133
|
+
updatedAt: timestamp,
|
|
134
|
+
lastSelectedAt: timestamp,
|
|
135
|
+
};
|
|
136
|
+
const nextOwner = this.replaceTask(owner, updatedTask, task.name);
|
|
137
|
+
try {
|
|
138
|
+
this.commitOwner(nextOwner);
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
if (loaded) {
|
|
142
|
+
await this.router
|
|
143
|
+
.detachManagedSession(task.sessionId)
|
|
144
|
+
.catch(() => undefined);
|
|
145
|
+
}
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
this.router.activateManagedSession(task.sessionId, task.target, task.cwd);
|
|
149
|
+
return this.selection(nextOwner, updatedTask);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
close(input, name) {
|
|
153
|
+
return this.withOwnerLock(input, async () => {
|
|
154
|
+
const owner = await this.ensureOwner(input, false);
|
|
155
|
+
if (!owner)
|
|
156
|
+
throw new Error('No named tasks exist in this chat.');
|
|
157
|
+
const task = this.findTask(owner, name);
|
|
158
|
+
if (!task)
|
|
159
|
+
throw new Error(`Task "${name}" was not found.`);
|
|
160
|
+
if (task.status === 'closed') {
|
|
161
|
+
throw new Error(`Task "${task.name}" is already closed.`);
|
|
162
|
+
}
|
|
163
|
+
if (this.isBusy(task.sessionId)) {
|
|
164
|
+
throw new Error(`Task "${task.name}" is still running or waiting for permission.`);
|
|
165
|
+
}
|
|
166
|
+
const wasActive = this.sameName(owner.activeTaskName, task.name);
|
|
167
|
+
const timestamp = this.nextTimestamp(owner);
|
|
168
|
+
const replacement = wasActive
|
|
169
|
+
? owner.tasks
|
|
170
|
+
.filter((candidate) => candidate.status === 'open' &&
|
|
171
|
+
!this.sameName(candidate.name, task.name))
|
|
172
|
+
.sort((a, b) => b.lastSelectedAt - a.lastSelectedAt)[0]
|
|
173
|
+
: undefined;
|
|
174
|
+
let replacementLoaded = false;
|
|
175
|
+
if (replacement) {
|
|
176
|
+
if (this.isBusy(replacement.sessionId)) {
|
|
177
|
+
throw new Error(`Task "${replacement.name}" is still running or waiting for permission.`);
|
|
178
|
+
}
|
|
179
|
+
replacementLoaded = await this.loadTask(replacement, `Could not load fallback task "${replacement.name}". Task "${task.name}" was not closed.`);
|
|
180
|
+
}
|
|
181
|
+
const closedTask = {
|
|
182
|
+
...task,
|
|
183
|
+
status: 'closed',
|
|
184
|
+
updatedAt: timestamp,
|
|
185
|
+
};
|
|
186
|
+
let nextOwner = this.replaceTask(owner, closedTask, wasActive ? (replacement?.name ?? null) : owner.activeTaskName);
|
|
187
|
+
const selectedReplacement = replacement
|
|
188
|
+
? {
|
|
189
|
+
...replacement,
|
|
190
|
+
updatedAt: timestamp,
|
|
191
|
+
lastSelectedAt: timestamp,
|
|
192
|
+
}
|
|
193
|
+
: undefined;
|
|
194
|
+
if (selectedReplacement) {
|
|
195
|
+
nextOwner = this.replaceTask(nextOwner, selectedReplacement, selectedReplacement.name);
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
this.commitOwner(nextOwner);
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
if (replacement && replacementLoaded) {
|
|
202
|
+
await this.router
|
|
203
|
+
.detachManagedSession(replacement.sessionId)
|
|
204
|
+
.catch(() => undefined);
|
|
205
|
+
}
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
if (replacement) {
|
|
209
|
+
this.router.activateManagedSession(replacement.sessionId, replacement.target, replacement.cwd);
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
await this.router.detachManagedSession(task.sessionId);
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
this.commitOwner(owner);
|
|
216
|
+
this.router.forgetManagedSession(task.sessionId);
|
|
217
|
+
await this.loadTask(task, `Task "${task.name}" could not be restored after close failed.`);
|
|
218
|
+
if (wasActive) {
|
|
219
|
+
this.router.activateManagedSession(task.sessionId, task.target, task.cwd);
|
|
220
|
+
}
|
|
221
|
+
throw new Error(`Failed to close task "${task.name}".`, {
|
|
222
|
+
cause: error,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
const activeTask = nextOwner.activeTaskName
|
|
226
|
+
? this.findTask(nextOwner, nextOwner.activeTaskName)
|
|
227
|
+
: undefined;
|
|
228
|
+
return {
|
|
229
|
+
closed: this.view(nextOwner, closedTask),
|
|
230
|
+
...(activeTask ? { active: this.view(nextOwner, activeTask) } : {}),
|
|
231
|
+
};
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
reset(input) {
|
|
235
|
+
return this.withOwnerLock(input, async () => {
|
|
236
|
+
const owner = await this.ensureOwner(input, false);
|
|
237
|
+
if (!owner?.activeTaskName)
|
|
238
|
+
return undefined;
|
|
239
|
+
const task = this.findTask(owner, owner.activeTaskName);
|
|
240
|
+
if (!task || task.status !== 'open')
|
|
241
|
+
return undefined;
|
|
242
|
+
const timestamp = this.nextTimestamp(owner);
|
|
243
|
+
const sessionId = await this.createSession(task.target, task.cwd, `Could not reset task "${task.name}".`);
|
|
244
|
+
const updatedTask = {
|
|
245
|
+
...task,
|
|
246
|
+
sessionId,
|
|
247
|
+
updatedAt: timestamp,
|
|
248
|
+
lastSelectedAt: timestamp,
|
|
249
|
+
};
|
|
250
|
+
const nextOwner = this.replaceTask(owner, updatedTask, task.name);
|
|
251
|
+
try {
|
|
252
|
+
this.commitOwner(nextOwner);
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
await this.router
|
|
256
|
+
.detachManagedSession(sessionId)
|
|
257
|
+
.catch(() => undefined);
|
|
258
|
+
throw error;
|
|
259
|
+
}
|
|
260
|
+
this.router.activateManagedSession(sessionId, updatedTask.target, updatedTask.cwd);
|
|
261
|
+
this.router.forgetManagedSession(task.sessionId);
|
|
262
|
+
return {
|
|
263
|
+
name: task.name,
|
|
264
|
+
previousSessionId: task.sessionId,
|
|
265
|
+
sessionId,
|
|
266
|
+
};
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
async ensureOwner(input, createDefault) {
|
|
270
|
+
const existing = this.getOwner(input);
|
|
271
|
+
if (existing)
|
|
272
|
+
return existing;
|
|
273
|
+
const sessionId = this.router.getSession(this.channelName, input.senderId, input.chatId);
|
|
274
|
+
const routedTarget = sessionId
|
|
275
|
+
? this.router.getTarget(sessionId)
|
|
276
|
+
: undefined;
|
|
277
|
+
if (sessionId &&
|
|
278
|
+
routedTarget &&
|
|
279
|
+
(routedTarget.channelName !== this.channelName ||
|
|
280
|
+
routedTarget.chatId !== input.chatId ||
|
|
281
|
+
routedTarget.senderId !== input.senderId)) {
|
|
282
|
+
this.adoptForeignLegacyRoute(sessionId, routedTarget);
|
|
283
|
+
}
|
|
284
|
+
if (sessionId &&
|
|
285
|
+
routedTarget?.channelName === this.channelName &&
|
|
286
|
+
routedTarget.chatId === input.chatId &&
|
|
287
|
+
routedTarget.senderId === input.senderId) {
|
|
288
|
+
const routedCwd = this.router.getSessionCwd(sessionId);
|
|
289
|
+
if (routedCwd !== undefined && !this.isCurrentCwd(routedCwd)) {
|
|
290
|
+
await this.router
|
|
291
|
+
.detachManagedSession(sessionId)
|
|
292
|
+
.catch(() => undefined);
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
const timestamp = this.nextTimestamp();
|
|
296
|
+
const task = {
|
|
297
|
+
name: 'default',
|
|
298
|
+
sessionId,
|
|
299
|
+
cwd: routedCwd ?? this.cwd,
|
|
300
|
+
isolation: 'shared',
|
|
301
|
+
status: 'open',
|
|
302
|
+
target: routedTarget,
|
|
303
|
+
createdAt: timestamp,
|
|
304
|
+
updatedAt: timestamp,
|
|
305
|
+
lastSelectedAt: timestamp,
|
|
306
|
+
};
|
|
307
|
+
const adopted = this.createOwner(input, task.name, [task]);
|
|
308
|
+
this.commitOwner(adopted);
|
|
309
|
+
return adopted;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (!createDefault)
|
|
313
|
+
return undefined;
|
|
314
|
+
const target = this.target(input);
|
|
315
|
+
const timestamp = this.nextTimestamp();
|
|
316
|
+
const createdSessionId = await this.createSession(target, this.cwd, 'Could not create the default task.');
|
|
317
|
+
const task = {
|
|
318
|
+
name: 'default',
|
|
319
|
+
sessionId: createdSessionId,
|
|
320
|
+
cwd: this.cwd,
|
|
321
|
+
isolation: 'shared',
|
|
322
|
+
status: 'open',
|
|
323
|
+
target,
|
|
324
|
+
createdAt: timestamp,
|
|
325
|
+
updatedAt: timestamp,
|
|
326
|
+
lastSelectedAt: timestamp,
|
|
327
|
+
};
|
|
328
|
+
const created = this.createOwner(input, task.name, [task]);
|
|
329
|
+
try {
|
|
330
|
+
this.commitOwner(created);
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
await this.router
|
|
334
|
+
.detachManagedSession(createdSessionId)
|
|
335
|
+
.catch(() => undefined);
|
|
336
|
+
throw error;
|
|
337
|
+
}
|
|
338
|
+
this.router.activateManagedSession(createdSessionId, target, this.cwd);
|
|
339
|
+
return created;
|
|
340
|
+
}
|
|
341
|
+
async resolveLocked(input, expectedSessionId, reserve) {
|
|
342
|
+
const owner = await this.ensureOwner(input, true);
|
|
343
|
+
if (!owner?.activeTaskName)
|
|
344
|
+
return undefined;
|
|
345
|
+
const task = this.findTask(owner, owner.activeTaskName);
|
|
346
|
+
if (!task || task.status !== 'open') {
|
|
347
|
+
throw new Error('The selected Channel task is unavailable.');
|
|
348
|
+
}
|
|
349
|
+
if (expectedSessionId !== undefined &&
|
|
350
|
+
task.sessionId !== expectedSessionId) {
|
|
351
|
+
return undefined;
|
|
352
|
+
}
|
|
353
|
+
const release = reserve?.(task.sessionId);
|
|
354
|
+
try {
|
|
355
|
+
await this.loadTask(task, `Could not load task "${task.name}". No replacement session was created.`);
|
|
356
|
+
this.router.activateManagedSession(task.sessionId, task.target, task.cwd);
|
|
357
|
+
return task.sessionId;
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
release?.();
|
|
361
|
+
throw error;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
adoptForeignLegacyRoute(sessionId, target) {
|
|
365
|
+
const cwd = this.router.getSessionCwd(sessionId);
|
|
366
|
+
if (target.channelName !== this.channelName ||
|
|
367
|
+
cwd === undefined ||
|
|
368
|
+
!this.isCurrentCwd(cwd)) {
|
|
369
|
+
this.router.forgetManagedSession(sessionId);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const existing = this.getOwner(target);
|
|
373
|
+
if (existing) {
|
|
374
|
+
if (existing.tasks.some((task) => task.sessionId === sessionId))
|
|
375
|
+
return;
|
|
376
|
+
this.router.forgetManagedSession(sessionId);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const timestamp = this.nextTimestamp();
|
|
380
|
+
const task = {
|
|
381
|
+
name: 'default',
|
|
382
|
+
sessionId,
|
|
383
|
+
cwd,
|
|
384
|
+
isolation: 'shared',
|
|
385
|
+
status: 'open',
|
|
386
|
+
target,
|
|
387
|
+
createdAt: timestamp,
|
|
388
|
+
updatedAt: timestamp,
|
|
389
|
+
lastSelectedAt: timestamp,
|
|
390
|
+
};
|
|
391
|
+
this.commitOwner(this.createOwner(target, task.name, [task]));
|
|
392
|
+
}
|
|
393
|
+
assertCanLeaveSelected(owner, nextTaskName) {
|
|
394
|
+
if (!owner.activeTaskName ||
|
|
395
|
+
(nextTaskName && this.sameName(owner.activeTaskName, nextTaskName))) {
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
const selected = this.findTask(owner, owner.activeTaskName);
|
|
399
|
+
if (selected && this.isBusy(selected.sessionId)) {
|
|
400
|
+
throw new Error(`Task "${selected.name}" is still running or waiting for permission.`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
async loadTask(task, failureMessage) {
|
|
404
|
+
try {
|
|
405
|
+
return (await this.router.loadManagedSession(task.sessionId, task.target, task.cwd)).loaded;
|
|
406
|
+
}
|
|
407
|
+
catch (error) {
|
|
408
|
+
throw new Error(failureMessage, { cause: error });
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
async createSession(target, cwd, failureMessage) {
|
|
412
|
+
try {
|
|
413
|
+
return await this.router.createManagedSession(target, cwd);
|
|
414
|
+
}
|
|
415
|
+
catch (error) {
|
|
416
|
+
throw new Error(failureMessage, { cause: error });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
withOwnerLock(input, operation) {
|
|
420
|
+
const key = JSON.stringify([
|
|
421
|
+
this.channelName,
|
|
422
|
+
input.chatId,
|
|
423
|
+
input.senderId,
|
|
424
|
+
]);
|
|
425
|
+
const previous = this.ownerOperations.get(key) ?? Promise.resolve();
|
|
426
|
+
const result = previous.catch(() => undefined).then(operation);
|
|
427
|
+
const tail = result.then(() => undefined, () => undefined);
|
|
428
|
+
this.ownerOperations.set(key, tail);
|
|
429
|
+
return result.finally(() => {
|
|
430
|
+
if (this.ownerOperations.get(key) === tail) {
|
|
431
|
+
this.ownerOperations.delete(key);
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
getOwner(input) {
|
|
436
|
+
const owner = this.registry.owners.find((candidate) => candidate.channelName === this.channelName &&
|
|
437
|
+
candidate.chatId === input.chatId &&
|
|
438
|
+
candidate.senderId === input.senderId);
|
|
439
|
+
return owner ? this.cloneOwner(owner) : undefined;
|
|
440
|
+
}
|
|
441
|
+
createOwner(input, activeTaskName, tasks) {
|
|
442
|
+
return {
|
|
443
|
+
channelName: this.channelName,
|
|
444
|
+
chatId: input.chatId,
|
|
445
|
+
senderId: input.senderId,
|
|
446
|
+
activeTaskName,
|
|
447
|
+
tasks,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
target(input) {
|
|
451
|
+
return {
|
|
452
|
+
channelName: this.channelName,
|
|
453
|
+
senderId: input.senderId,
|
|
454
|
+
chatId: input.chatId,
|
|
455
|
+
...(input.threadId !== undefined ? { threadId: input.threadId } : {}),
|
|
456
|
+
...(input.isGroup !== undefined ? { isGroup: input.isGroup } : {}),
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
findTask(owner, name) {
|
|
460
|
+
const normalized = this.normalizeName(name);
|
|
461
|
+
return owner.tasks.find((task) => this.normalizeName(task.name) === normalized);
|
|
462
|
+
}
|
|
463
|
+
replaceTask(owner, task, activeTaskName) {
|
|
464
|
+
return {
|
|
465
|
+
...owner,
|
|
466
|
+
activeTaskName,
|
|
467
|
+
tasks: owner.tasks.map((candidate) => this.sameName(candidate.name, task.name) ? task : candidate),
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
view(owner, task) {
|
|
471
|
+
return {
|
|
472
|
+
name: task.name,
|
|
473
|
+
status: task.status,
|
|
474
|
+
isolation: task.isolation,
|
|
475
|
+
active: task.status === 'open' &&
|
|
476
|
+
this.sameName(owner.activeTaskName, task.name),
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
selection(owner, task) {
|
|
480
|
+
return { ...this.view(owner, task), sessionId: task.sessionId };
|
|
481
|
+
}
|
|
482
|
+
openTaskCount(owner) {
|
|
483
|
+
return owner.tasks.filter((task) => task.status === 'open').length;
|
|
484
|
+
}
|
|
485
|
+
nextTimestamp(owner) {
|
|
486
|
+
const now = this.now();
|
|
487
|
+
if (!Number.isSafeInteger(now) || now < 0) {
|
|
488
|
+
throw new Error('Invalid named-session clock value.');
|
|
489
|
+
}
|
|
490
|
+
let timestamp = now;
|
|
491
|
+
for (const task of owner?.tasks ?? []) {
|
|
492
|
+
timestamp = Math.max(timestamp, task.createdAt + 1, task.updatedAt + 1, task.lastSelectedAt + 1);
|
|
493
|
+
}
|
|
494
|
+
if (!Number.isSafeInteger(timestamp)) {
|
|
495
|
+
throw new Error('Named-session timestamp limit reached.');
|
|
496
|
+
}
|
|
497
|
+
return timestamp;
|
|
498
|
+
}
|
|
499
|
+
validateName(name) {
|
|
500
|
+
if (!TASK_NAME_PATTERN.test(name)) {
|
|
501
|
+
throw new Error('Task names must be 1-32 ASCII letters, numbers, underscores, or hyphens and start with a letter or number.');
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
normalizeName(name) {
|
|
505
|
+
return name.toLowerCase();
|
|
506
|
+
}
|
|
507
|
+
sameName(a, b) {
|
|
508
|
+
return a !== null && this.normalizeName(a) === this.normalizeName(b);
|
|
509
|
+
}
|
|
510
|
+
cloneOwner(owner) {
|
|
511
|
+
return {
|
|
512
|
+
...owner,
|
|
513
|
+
tasks: owner.tasks.map((task) => ({
|
|
514
|
+
...task,
|
|
515
|
+
target: { ...task.target },
|
|
516
|
+
})),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
commitOwner(owner) {
|
|
520
|
+
const owners = this.registry.owners.map((candidate) => candidate.channelName === owner.channelName &&
|
|
521
|
+
candidate.chatId === owner.chatId &&
|
|
522
|
+
candidate.senderId === owner.senderId
|
|
523
|
+
? owner
|
|
524
|
+
: candidate);
|
|
525
|
+
if (!owners.some((candidate) => candidate.channelName === owner.channelName &&
|
|
526
|
+
candidate.chatId === owner.chatId &&
|
|
527
|
+
candidate.senderId === owner.senderId)) {
|
|
528
|
+
owners.push(owner);
|
|
529
|
+
}
|
|
530
|
+
const next = { version: REGISTRY_VERSION, owners };
|
|
531
|
+
if (!this.isRegistry(next)) {
|
|
532
|
+
throw new Error('Invalid named-session registry update.');
|
|
533
|
+
}
|
|
534
|
+
this.writeRegistry(next);
|
|
535
|
+
this.registry = next;
|
|
536
|
+
}
|
|
537
|
+
readRegistry() {
|
|
538
|
+
if (!existsSync(this.filePath)) {
|
|
539
|
+
return { version: REGISTRY_VERSION, owners: [] };
|
|
540
|
+
}
|
|
541
|
+
let value;
|
|
542
|
+
try {
|
|
543
|
+
value = JSON.parse(readFileSync(this.filePath, 'utf8'));
|
|
544
|
+
}
|
|
545
|
+
catch (error) {
|
|
546
|
+
throw new Error(`Failed to read named-session registry: ${this.filePath}`, {
|
|
547
|
+
cause: error,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
if (!this.isRegistry(value)) {
|
|
551
|
+
if (this.isRegistry(value, false)) {
|
|
552
|
+
const stalePath = `${this.filePath}.stale-${randomUUID()}`;
|
|
553
|
+
try {
|
|
554
|
+
renameSync(this.filePath, stalePath);
|
|
555
|
+
}
|
|
556
|
+
catch (error) {
|
|
557
|
+
throw new Error('Failed to archive stale named-session registry.', {
|
|
558
|
+
cause: error,
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
process.stderr.write('[NamedSessionManager] Archived a stale registry after the channel working directory changed.\n');
|
|
562
|
+
return { version: REGISTRY_VERSION, owners: [] };
|
|
563
|
+
}
|
|
564
|
+
throw new Error(`Invalid named-session registry: ${this.filePath}`);
|
|
565
|
+
}
|
|
566
|
+
return value;
|
|
567
|
+
}
|
|
568
|
+
writeRegistry(registry) {
|
|
569
|
+
const dir = dirname(this.filePath);
|
|
570
|
+
const tempPath = `${this.filePath}.${process.pid}-${randomUUID()}.tmp`;
|
|
571
|
+
try {
|
|
572
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
573
|
+
try {
|
|
574
|
+
chmodSync(dir, 0o700);
|
|
575
|
+
}
|
|
576
|
+
catch {
|
|
577
|
+
// Some filesystems do not implement POSIX modes.
|
|
578
|
+
}
|
|
579
|
+
writeFileSync(tempPath, JSON.stringify(registry, null, 2), {
|
|
580
|
+
encoding: 'utf8',
|
|
581
|
+
mode: 0o600,
|
|
582
|
+
});
|
|
583
|
+
renameSync(tempPath, this.filePath);
|
|
584
|
+
try {
|
|
585
|
+
chmodSync(this.filePath, 0o600);
|
|
586
|
+
}
|
|
587
|
+
catch {
|
|
588
|
+
// Some filesystems do not implement POSIX modes.
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
catch (error) {
|
|
592
|
+
throw new Error('Failed to persist named-session registry.', {
|
|
593
|
+
cause: error,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
finally {
|
|
597
|
+
try {
|
|
598
|
+
rmSync(tempPath, { force: true });
|
|
599
|
+
}
|
|
600
|
+
catch {
|
|
601
|
+
// Best-effort temporary-file cleanup.
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
isRegistry(value, requireCurrentCwd = true) {
|
|
606
|
+
if (!this.isRecord(value) || value['version'] !== REGISTRY_VERSION) {
|
|
607
|
+
return false;
|
|
608
|
+
}
|
|
609
|
+
const owners = value['owners'];
|
|
610
|
+
if (!Array.isArray(owners))
|
|
611
|
+
return false;
|
|
612
|
+
const ownerKeys = new Set();
|
|
613
|
+
const sessionIds = new Set();
|
|
614
|
+
for (const owner of owners) {
|
|
615
|
+
if (!this.isOwner(owner, ownerKeys, sessionIds, requireCurrentCwd)) {
|
|
616
|
+
return false;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return true;
|
|
620
|
+
}
|
|
621
|
+
isOwner(value, ownerKeys, sessionIds, requireCurrentCwd) {
|
|
622
|
+
if (!this.isRecord(value))
|
|
623
|
+
return false;
|
|
624
|
+
const channelName = value['channelName'];
|
|
625
|
+
const chatId = value['chatId'];
|
|
626
|
+
const senderId = value['senderId'];
|
|
627
|
+
const activeTaskName = value['activeTaskName'];
|
|
628
|
+
const tasks = value['tasks'];
|
|
629
|
+
if (channelName !== this.channelName ||
|
|
630
|
+
typeof chatId !== 'string' ||
|
|
631
|
+
typeof senderId !== 'string' ||
|
|
632
|
+
(activeTaskName !== null && typeof activeTaskName !== 'string') ||
|
|
633
|
+
!Array.isArray(tasks)) {
|
|
634
|
+
return false;
|
|
635
|
+
}
|
|
636
|
+
const ownerKey = JSON.stringify([channelName, chatId, senderId]);
|
|
637
|
+
if (ownerKeys.has(ownerKey))
|
|
638
|
+
return false;
|
|
639
|
+
ownerKeys.add(ownerKey);
|
|
640
|
+
const names = new Set();
|
|
641
|
+
let openTasks = 0;
|
|
642
|
+
let activeFound = activeTaskName === null;
|
|
643
|
+
for (const task of tasks) {
|
|
644
|
+
if (!this.isTask(task, channelName, chatId, senderId, requireCurrentCwd)) {
|
|
645
|
+
return false;
|
|
646
|
+
}
|
|
647
|
+
const normalized = this.normalizeName(task.name);
|
|
648
|
+
if (names.has(normalized) || sessionIds.has(task.sessionId))
|
|
649
|
+
return false;
|
|
650
|
+
names.add(normalized);
|
|
651
|
+
sessionIds.add(task.sessionId);
|
|
652
|
+
if (task.status === 'open')
|
|
653
|
+
openTasks++;
|
|
654
|
+
if (activeTaskName !== null &&
|
|
655
|
+
task.status === 'open' &&
|
|
656
|
+
this.sameName(activeTaskName, task.name)) {
|
|
657
|
+
activeFound = true;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return openTasks <= MAX_OPEN_TASKS && activeFound;
|
|
661
|
+
}
|
|
662
|
+
isTask(value, channelName, chatId, senderId, requireCurrentCwd) {
|
|
663
|
+
if (!this.isRecord(value) || !this.isRecord(value['target']))
|
|
664
|
+
return false;
|
|
665
|
+
const target = value['target'];
|
|
666
|
+
return (typeof value['name'] === 'string' &&
|
|
667
|
+
TASK_NAME_PATTERN.test(value['name']) &&
|
|
668
|
+
typeof value['sessionId'] === 'string' &&
|
|
669
|
+
value['sessionId'].length > 0 &&
|
|
670
|
+
typeof value['cwd'] === 'string' &&
|
|
671
|
+
(!requireCurrentCwd || this.isCurrentCwd(value['cwd'])) &&
|
|
672
|
+
value['isolation'] === 'shared' &&
|
|
673
|
+
(value['status'] === 'open' || value['status'] === 'closed') &&
|
|
674
|
+
this.isTimestamp(value['createdAt']) &&
|
|
675
|
+
this.isTimestamp(value['updatedAt']) &&
|
|
676
|
+
this.isTimestamp(value['lastSelectedAt']) &&
|
|
677
|
+
target['channelName'] === channelName &&
|
|
678
|
+
target['chatId'] === chatId &&
|
|
679
|
+
target['senderId'] === senderId &&
|
|
680
|
+
(target['threadId'] === undefined ||
|
|
681
|
+
typeof target['threadId'] === 'string') &&
|
|
682
|
+
(target['isGroup'] === undefined ||
|
|
683
|
+
typeof target['isGroup'] === 'boolean'));
|
|
684
|
+
}
|
|
685
|
+
isCurrentCwd(cwd) {
|
|
686
|
+
return canonicalizeWorkspacePath(cwd) === this.canonicalCwd;
|
|
687
|
+
}
|
|
688
|
+
isTimestamp(value) {
|
|
689
|
+
return (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0);
|
|
690
|
+
}
|
|
691
|
+
isRecord(value) {
|
|
692
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
//# sourceMappingURL=named-session-manager.js.map
|