@sanqianx/claude-ai-workbench 0.13.2 → 0.13.4
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/LICENSE +201 -201
- package/README.md +156 -156
- package/apps/agent-terminal/app.css +652 -613
- package/apps/agent-terminal/app.js +143 -67
- package/apps/agent-terminal/conversation-composer.js +44 -0
- package/apps/agent-terminal/index.html +172 -170
- package/apps/agent-terminal/panel-host-proof.html +152 -152
- package/apps/agent-terminal/panel-proof-content.html +41 -41
- package/apps/agent-terminal/right-workspace-dock.css +189 -159
- package/apps/agent-terminal/right-workspace-dock.js +652 -521
- package/apps/agent-terminal/user-question-card.js +68 -0
- package/apps/ai-profile-settings-host/index.html +25 -25
- package/apps/ai-profile-settings-host/page.css +1 -1
- package/apps/ai-profile-settings-host/page.js +113 -113
- package/apps/minimal-test-host/README.md +15 -15
- package/apps/minimal-test-host/host.css +2 -2
- package/apps/minimal-test-host/host.js +35 -35
- package/apps/minimal-test-host/index.html +58 -58
- package/apps/minimal-test-host/optional-mock/fault-injection.js +15 -15
- package/docs/sdk/FILE_LAYOUT.md +31 -31
- package/docs/sdk/INTEGRATION.md +126 -126
- package/docs/sdk/README.md +21 -21
- package/docs/sdk/SESSION_EVENTS.md +15 -13
- package/docs/sdk/openapi.yaml +16 -1
- package/package.json +1 -1
- package/packages/client/README.md +29 -29
- package/packages/client/index.d.ts +122 -122
- package/packages/client/index.js +260 -260
- package/packages/client/package.json +2 -2
- package/packages/contracts/README.md +9 -9
- package/packages/contracts/index.d.ts +519 -517
- package/packages/contracts/index.js +312 -312
- package/packages/contracts/package.json +1 -1
- package/packages/core/README.md +8 -8
- package/packages/core/index.d.ts +76 -76
- package/packages/core/index.js +222 -222
- package/packages/core/package.json +2 -2
- package/packages/server/README.md +17 -17
- package/packages/server/bin/agent-terminal-server.js +42 -42
- package/packages/server/bin/claude-workbench.js +225 -225
- package/packages/server/index.d.ts +171 -171
- package/packages/server/index.js +230 -230
- package/packages/server/lib/agent-discovery.js +133 -133
- package/packages/server/lib/agent-registry.js +143 -143
- package/packages/server/lib/agent-spawn-env.js +17 -17
- package/packages/server/lib/attachment-store.js +78 -78
- package/packages/server/lib/bridges/app-server-bridge.js +373 -373
- package/packages/server/lib/bridges/opencode-server-bridge.js +506 -506
- package/packages/server/lib/capture-settings-store.js +35 -35
- package/packages/server/lib/claude-history-recovery.js +87 -87
- package/packages/server/lib/cli-session-recovery.js +28 -28
- package/packages/server/lib/context-resolver.js +55 -55
- package/packages/server/lib/conversation-capture.js +51 -51
- package/packages/server/lib/delegation-phase-store.js +357 -357
- package/packages/server/lib/delegation-store.js +474 -468
- package/packages/server/lib/diagnostics.js +32 -32
- package/packages/server/lib/drivers/cli-json-runner.js +974 -974
- package/packages/server/lib/drivers/codex-driver.js +1461 -1442
- package/packages/server/lib/drivers/opencode-driver.js +1554 -1554
- package/packages/server/lib/drivers/zcode-driver.js +1250 -1250
- package/packages/server/lib/drivers/zcode-profile-runtime.js +71 -71
- package/packages/server/lib/event-stream.js +138 -138
- package/packages/server/lib/folder-picker.js +623 -623
- package/packages/server/lib/http-handler.js +339 -339
- package/packages/server/lib/json-file.js +26 -26
- package/packages/server/lib/knowledge-context.js +33 -33
- package/packages/server/lib/model-detection.js +295 -295
- package/packages/server/lib/parent-context.js +44 -0
- package/packages/server/lib/profile-store.js +283 -283
- package/packages/server/lib/project-store.js +102 -102
- package/packages/server/lib/runtime-loader.js +120 -120
- package/packages/server/lib/session-archive-store.js +53 -53
- package/packages/server/lib/session-history.js +612 -612
- package/packages/server/lib/system-doctor.js +125 -125
- package/packages/server/lib/ui-preferences-store.js +48 -48
- package/packages/server/lib/user-questions.js +67 -0
- package/packages/server/lib/workbench-service.js +1696 -1675
- package/packages/server/package.json +2 -2
- package/packages/ui/README.md +30 -30
- package/packages/ui/ai-profile-settings.d.ts +45 -45
- package/packages/ui/ai-profile-settings.js +283 -283
- package/packages/ui/claude-terminal.d.ts +31 -31
- package/packages/ui/claude-terminal.js +343 -343
- package/packages/ui/index.d.ts +18 -18
- package/packages/ui/index.js +51 -51
- package/packages/ui/package.json +2 -2
- package/packages/ui/theme.css +13 -13
- package/src/assets/claude-prompts.json +21 -21
- package/src/backend/http/ai-profile-routes.js +61 -61
- package/src/backend/http/claude-routes.js +170 -170
- package/src/backend/http/node-helpers.js +45 -45
- package/src/backend/index.js +21 -21
- package/src/backend/lib/ai-adapter.js +144 -144
- package/src/backend/lib/ai-profile-store.js +259 -259
- package/src/backend/lib/ai-vendor-presets.js +83 -83
- package/src/backend/lib/ai-workspace.js +140 -140
- package/src/backend/lib/automation-config.js +253 -253
- package/src/backend/lib/claude-cli-runner.js +2408 -2392
- package/src/backend/lib/data-dir.js +169 -169
- package/src/backend/lib/llm-client.js +176 -176
- package/src/backend/lib/model-context-windows.js +82 -82
- package/src/backend/lib/native-task-control.js +217 -217
- package/src/backend/lib/native-task-history-index.js +475 -475
- package/src/backend/lib/native-task-history-reader.js +526 -526
- package/src/backend/lib/native-task-messages.js +457 -457
- package/src/backend/lib/native-task-registry.js +325 -325
- package/src/backend/lib/native-task-runtime.js +242 -242
- package/src/backend/lib/native-task-trace.js +440 -440
- package/src/backend/lib/native-task-transcript.js +393 -393
- package/src/backend/lib/prompt-registry.js +166 -166
- package/src/backend/lib/runtime-config.js +121 -121
- package/src/backend/lib/sdk-session-stream.js +143 -143
- package/src/backend/lib/sdk-turn-stream.js +16 -16
- package/src/backend/lib/store-scope.js +131 -131
- package/src/backend/lib/task-identity.js +104 -104
- package/src/backend/lib/task-state-projection.js +58 -58
- package/src/index.js +7 -7
- package/src/sdk-wave-a.ts +1 -1
- package/src/sdk-wave-b.ts +1 -1
- package/src/sdk-wave-c.ts +1 -1
- package/src/sdk-wave-d.ts +1 -1
- package/src/sdk-wave-e.ts +1 -1
|
@@ -1,1675 +1,1696 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const fs = require('fs');
|
|
4
|
-
const path = require('path');
|
|
5
|
-
const crypto = require('node:crypto');
|
|
6
|
-
const {
|
|
7
|
-
normalizeContext, normalizeAttachments, normalizeAgentId, normalizeEffort, cleanId, PERMISSION_MODES, AGENTS,
|
|
8
|
-
} = require('../../contracts');
|
|
9
|
-
const { snapshotEvents, subscribeNormalized } = require('./event-stream');
|
|
10
|
-
const { SessionHistory } = require('./session-history');
|
|
11
|
-
const {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const {
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
if (
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (!
|
|
58
|
-
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
this.
|
|
75
|
-
this.
|
|
76
|
-
this.
|
|
77
|
-
this.
|
|
78
|
-
this.
|
|
79
|
-
this.
|
|
80
|
-
this.
|
|
81
|
-
this.
|
|
82
|
-
this.
|
|
83
|
-
this.
|
|
84
|
-
this.
|
|
85
|
-
this.
|
|
86
|
-
this.
|
|
87
|
-
this.
|
|
88
|
-
this.
|
|
89
|
-
this.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
// delegation
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
this.
|
|
115
|
-
this.
|
|
116
|
-
this.
|
|
117
|
-
this.
|
|
118
|
-
this.
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
this.
|
|
126
|
-
this.
|
|
127
|
-
this.
|
|
128
|
-
this.
|
|
129
|
-
this.
|
|
130
|
-
|
|
131
|
-
this.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
this.
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
const
|
|
244
|
-
this.
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
this.
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
const
|
|
282
|
-
this.
|
|
283
|
-
this.
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
const
|
|
292
|
-
this.
|
|
293
|
-
this.
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
this.
|
|
315
|
-
this.
|
|
316
|
-
|
|
317
|
-
this.
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
const
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
if (this.
|
|
351
|
-
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
const
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
//
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
this.
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
//
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
if (
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
return
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
return
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
const
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
//
|
|
508
|
-
|
|
509
|
-
//
|
|
510
|
-
|
|
511
|
-
//
|
|
512
|
-
// and
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
//
|
|
541
|
-
//
|
|
542
|
-
//
|
|
543
|
-
//
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
//
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
const
|
|
562
|
-
if (!
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
//
|
|
572
|
-
//
|
|
573
|
-
//
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
lastDeltaSequence.set(id,
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
expected
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
//
|
|
617
|
-
//
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
this.histories.
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
//
|
|
679
|
-
|
|
680
|
-
//
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
//
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
//
|
|
711
|
-
//
|
|
712
|
-
//
|
|
713
|
-
//
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
//
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
const
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
//
|
|
745
|
-
//
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
const
|
|
751
|
-
if (
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
const
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
//
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
this.
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
//
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
const
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
//
|
|
824
|
-
//
|
|
825
|
-
//
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
//
|
|
862
|
-
//
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
}
|
|
906
|
-
if (phases.
|
|
907
|
-
return { phase: 'child-
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
//
|
|
978
|
-
//
|
|
979
|
-
//
|
|
980
|
-
//
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
const
|
|
989
|
-
if (!
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
const
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
if (
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
});
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
});
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
this.
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
})
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
//
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
});
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
}
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
}
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
}
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
const
|
|
1225
|
-
if (
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
const
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
this.
|
|
1246
|
-
if (
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
const
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
const
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
const
|
|
1264
|
-
this.
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
const
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
this.
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
const
|
|
1303
|
-
if (!
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
const
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
const
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
this.
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
const
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
this.
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
const
|
|
1368
|
-
|
|
1369
|
-
this.
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
const
|
|
1392
|
-
if (
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
return this.destroyPromise;
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
}
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
if (
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
}
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
}
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
const {
|
|
7
|
+
normalizeContext, normalizeAttachments, normalizeAgentId, normalizeEffort, cleanId, PERMISSION_MODES, AGENTS,
|
|
8
|
+
} = require('../../contracts');
|
|
9
|
+
const { snapshotEvents, subscribeNormalized } = require('./event-stream');
|
|
10
|
+
const { SessionHistory } = require('./session-history');
|
|
11
|
+
const { parentContext } = require('./parent-context');
|
|
12
|
+
const PARENT_CONTEXT = Symbol('trusted parent conversation snapshot');
|
|
13
|
+
const { recoverClaudeHistory } = require('./claude-history-recovery');
|
|
14
|
+
// The history reader and task identity load from the same legacy runtime
|
|
15
|
+
// tree as every other runtime module: loadLegacyRuntime binds to the owning
|
|
16
|
+
// distribution, so a packaged server loads them from its own legacy tree and
|
|
17
|
+
// a missing file fails the public entry instead of borrowing source code.
|
|
18
|
+
const { loadLegacyRuntime } = require('./runtime-loader');
|
|
19
|
+
const { createNativeTaskHistoryReader } = loadLegacyRuntime('native-task-history-reader');
|
|
20
|
+
const { createNativeTaskRef } = loadLegacyRuntime('task-identity');
|
|
21
|
+
|
|
22
|
+
const ACTIVE_STATES = new Set(['created', 'spawning', 'running', 'thinking', 'tool-running', 'pending-permission']);
|
|
23
|
+
|
|
24
|
+
// Native fact rows are durable-by-contract: they go through the receipt
|
|
25
|
+
// path (write + fsync) so nothing is ever built on facts that still live
|
|
26
|
+
// only in memory. Plain chat events keep the batched append path.
|
|
27
|
+
const NATIVE_FACT_TYPES = new Set(['claude/native-trace', 'claude/native-task-state', 'claude/native-task-transcript']);
|
|
28
|
+
|
|
29
|
+
const DELTA_TYPES = new Set(['claude/text-delta', 'claude/thinking-delta']);
|
|
30
|
+
|
|
31
|
+
// The same native fact as the committed row, ignoring fields that
|
|
32
|
+
// legitimately differ between a journaled row and an in-memory replay of it.
|
|
33
|
+
function canonicalNativeFact(event) {
|
|
34
|
+
const ordered = {};
|
|
35
|
+
for (const key of Object.keys(event || {}).sort()) {
|
|
36
|
+
if (key === 'at' || key === 'sequence' || key === 'sessionId' || key === 'contextId') continue;
|
|
37
|
+
ordered[key] = event[key];
|
|
38
|
+
}
|
|
39
|
+
return JSON.stringify(ordered);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The earliest buffered same-id delta sequence a merged durable row is
|
|
43
|
+
// proven to cover. Rows written since compaction kept provenance carry
|
|
44
|
+
// compactFrom and are trusted as-is; older archives only admit the coverage
|
|
45
|
+
// reading when a sequence gap exists AND the buffer's own fragments form a
|
|
46
|
+
// strict prefix of the merged text — otherwise the row is a continuation
|
|
47
|
+
// delta and must be appended, not substituted.
|
|
48
|
+
function compactionCoverage(buffered, durable, lastBufferedSequence) {
|
|
49
|
+
const declared = Number(durable && durable.compactFrom);
|
|
50
|
+
if (Number.isSafeInteger(declared) && declared > 0) return declared;
|
|
51
|
+
const sequence = Number(durable && durable.sequence);
|
|
52
|
+
if (!Number.isSafeInteger(sequence) || sequence <= 0) return null;
|
|
53
|
+
if (!Number.isFinite(lastBufferedSequence) || sequence <= lastBufferedSequence + 1) return null;
|
|
54
|
+
let prefix = '';
|
|
55
|
+
let earliest = null;
|
|
56
|
+
for (const event of buffered) {
|
|
57
|
+
if (!event || event.type !== durable.type || event.id !== durable.id) continue;
|
|
58
|
+
const own = Number(event.sequence);
|
|
59
|
+
if (!Number.isSafeInteger(own) || own <= 0 || own >= sequence) continue;
|
|
60
|
+
prefix += String(event.text || '');
|
|
61
|
+
if (earliest === null || own < earliest) earliest = own;
|
|
62
|
+
}
|
|
63
|
+
if (earliest === null || prefix.length === 0) return null;
|
|
64
|
+
const text = String(durable.text || '');
|
|
65
|
+
return text.length > prefix.length && text.startsWith(prefix) ? earliest : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The health identity accepts only a canonical persisted store id; nothing
|
|
69
|
+
// volatile (port, PID, epoch) or malformed may pose as a store scope.
|
|
70
|
+
const STORE_SCOPE_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
71
|
+
|
|
72
|
+
class WorkbenchService {
|
|
73
|
+
constructor(options) {
|
|
74
|
+
this.runner = options.runner;
|
|
75
|
+
this.diagnostics = options.diagnostics || null;
|
|
76
|
+
this.knowledge = options.knowledge || null;
|
|
77
|
+
this.pendingInputs = new Map();
|
|
78
|
+
this.knowledgeBySession = new Map();
|
|
79
|
+
this.profiles = options.profiles;
|
|
80
|
+
this.contexts = options.contexts;
|
|
81
|
+
this.attachments = options.attachments;
|
|
82
|
+
this.projects = options.projects || null;
|
|
83
|
+
this.sessionArchive = options.sessionArchive || null;
|
|
84
|
+
this.uiPreferences = options.uiPreferences || null;
|
|
85
|
+
this.captureSettings = options.captureSettings || null;
|
|
86
|
+
this.conversationCapture = options.conversationCapture || null;
|
|
87
|
+
this.onCaptureSettingsSaved = options.onCaptureSettingsSaved || null;
|
|
88
|
+
this.agentDiscovery = options.agentDiscovery || null;
|
|
89
|
+
this.resolveRuntimeExecutable = options.resolveRuntimeExecutable || null;
|
|
90
|
+
this.modelContextWindows = options.modelContextWindows || null;
|
|
91
|
+
this.modelDetection = options.modelDetection || null;
|
|
92
|
+
// Trusted delegation stores: the immutable reservation ledger plus the
|
|
93
|
+
// write-once phase/claim journal. Null when the host wired none — the
|
|
94
|
+
// delegation APIs then answer explicit unavailability instead of
|
|
95
|
+
// pretending to run commands.
|
|
96
|
+
this.delegations = options.delegations || null;
|
|
97
|
+
// Per-service runtime generation: fresh for every WorkbenchService
|
|
98
|
+
// instance, constant for its lifetime, never persisted anywhere.
|
|
99
|
+
this.runtimeEpoch = crypto.randomUUID();
|
|
100
|
+
const injectedIdentity = options.storeIdentity || null;
|
|
101
|
+
const storeScope = injectedIdentity && injectedIdentity.available === true && typeof injectedIdentity.storeScope === 'string'
|
|
102
|
+
&& STORE_SCOPE_PATTERN.test(injectedIdentity.storeScope)
|
|
103
|
+
? injectedIdentity.storeScope
|
|
104
|
+
: null;
|
|
105
|
+
this.taskIdentity = Object.freeze({
|
|
106
|
+
storeScope,
|
|
107
|
+
runtimeEpoch: this.runtimeEpoch,
|
|
108
|
+
available: storeScope !== null,
|
|
109
|
+
errorCode: !storeScope && injectedIdentity ? injectedIdentity.errorCode || 'STORE_SCOPE_UNAVAILABLE' : null,
|
|
110
|
+
});
|
|
111
|
+
this.apiPrefix = String(options.apiPrefix || '/api/claude-workbench/v1').replace(/\/$/, '');
|
|
112
|
+
// Read from the synced manifest — a hardcoded literal here shipped 0.3.0
|
|
113
|
+
// in every /health response long after the actual release moved on.
|
|
114
|
+
this.version = options.version || require('../package.json').version;
|
|
115
|
+
this.maxSessions = Number(options.maxSessions || 50);
|
|
116
|
+
this.maxConcurrentRuns = Number(options.maxConcurrentRuns || 4);
|
|
117
|
+
this.testWorkspace = path.resolve(options.testWorkspace);
|
|
118
|
+
this.auditFile = options.auditFile;
|
|
119
|
+
this.historyRoot = options.historyRoot || path.join(path.dirname(this.auditFile || this.testWorkspace), 'session-history');
|
|
120
|
+
this.histories = new Map();
|
|
121
|
+
// Per-session native-fact committer closures, cached so every attach
|
|
122
|
+
// passes the SAME function reference: the runner's binding contract
|
|
123
|
+
// treats a different reference under the same identity as an owner
|
|
124
|
+
// conflict, and a naive closure-per-call would break idempotence.
|
|
125
|
+
this.nativeCommitters = new Map();
|
|
126
|
+
this.turnMonitors = new Map();
|
|
127
|
+
this.sessionAgents = new Map();
|
|
128
|
+
this.sessionRevision = 0;
|
|
129
|
+
this.sessionChangeLog = [];
|
|
130
|
+
this.sessionChangeListeners = new Set();
|
|
131
|
+
this.sessionSignatures = new Map();
|
|
132
|
+
const archiveRetentionDays = options.archiveRetentionDays === undefined ? 7 : Number(options.archiveRetentionDays);
|
|
133
|
+
this.archiveRetentionMs = Number.isFinite(archiveRetentionDays) && archiveRetentionDays > 0
|
|
134
|
+
? archiveRetentionDays * 24 * 60 * 60 * 1000
|
|
135
|
+
: 0;
|
|
136
|
+
this.archiveSweeper = null;
|
|
137
|
+
// Archived sessions past the retention window are purged on boot and
|
|
138
|
+
// once a day for the lifetime of the process.
|
|
139
|
+
if (this.sessionArchive && this.archiveRetentionMs > 0) {
|
|
140
|
+
this.purgeExpiredArchivedSessions();
|
|
141
|
+
this.archiveSweeper = setInterval(() => {
|
|
142
|
+
try { this.purgeExpiredArchivedSessions(); } catch { /* retried next sweep */ }
|
|
143
|
+
}, Number(options.archiveSweepIntervalMs || 24 * 60 * 60 * 1000));
|
|
144
|
+
if (typeof this.archiveSweeper.unref === 'function') this.archiveSweeper.unref();
|
|
145
|
+
}
|
|
146
|
+
fs.mkdirSync(this.testWorkspace, { recursive: true });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
health() {
|
|
150
|
+
// One observation point: both counters must come from the same
|
|
151
|
+
// listSessions snapshot, or a mid-mutation probe disagrees with itself
|
|
152
|
+
// and pays the full store scan twice.
|
|
153
|
+
const sessions = this.runner.listSessions({});
|
|
154
|
+
return {
|
|
155
|
+
status: 'ok', version: this.version, apiVersion: 'v1',
|
|
156
|
+
activeProcesses: this._activeCount(sessions), sessions: sessions.length,
|
|
157
|
+
time: new Date().toISOString(),
|
|
158
|
+
taskIdentity: { ...this.taskIdentity },
|
|
159
|
+
...(this.conversationCapture ? { conversationCapture: { ...this.conversationCapture.status(), enabled: this.captureSettings?.read().terminalConversations === true } } : {}),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async runtime() {
|
|
164
|
+
let executable = null;
|
|
165
|
+
try { executable = await (this.resolveRuntimeExecutable ? this.resolveRuntimeExecutable() : this.runner.findClaudeExecutableForSdk()); } catch {}
|
|
166
|
+
const command = executable && (executable.cmd || executable);
|
|
167
|
+
return { runtime: 'claude-code', available: Boolean(command), executable: command ? path.basename(String(command)) : null, transport: 'agent-sdk' };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Live availability probing when a discovery provider is wired in,
|
|
171
|
+
// otherwise fall back to what the injected runner can see.
|
|
172
|
+
listAgents() {
|
|
173
|
+
if (this.agentDiscovery) {
|
|
174
|
+
return { agents: Object.values(this.agentDiscovery()), scan: this.agentDiscovery.snapshot?.() };
|
|
175
|
+
}
|
|
176
|
+
let claudeAvailable = false;
|
|
177
|
+
try { const executable = this.runner.findClaudeExecutableForSdk(); claudeAvailable = Boolean(typeof executable === 'string' ? executable : executable?.cmd); } catch {}
|
|
178
|
+
return {
|
|
179
|
+
agents: Object.keys(AGENTS).map(id => {
|
|
180
|
+
const agent = AGENTS[id];
|
|
181
|
+
return {
|
|
182
|
+
id: agent.id, label: agent.label, transport: agent.transport,
|
|
183
|
+
authModes: [...agent.authModes],
|
|
184
|
+
capabilities: { ...agent.capabilities, generationParams: [...agent.capabilities.generationParams] },
|
|
185
|
+
available: id === 'claude-code' ? claudeAvailable : false,
|
|
186
|
+
version: null, reason: '',
|
|
187
|
+
};
|
|
188
|
+
}),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
refreshAgents() {
|
|
193
|
+
if (this.agentDiscovery?.refresh) void this.agentDiscovery.refresh().catch(() => {});
|
|
194
|
+
return this.listAgents();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
listProfiles() { return { profiles: this.profiles.list() }; }
|
|
198
|
+
getProfile(id) { return this.profiles.get(id); }
|
|
199
|
+
saveProfile(input, id) { return this.profiles.save(input, id); }
|
|
200
|
+
deleteProfile(id) { return this.profiles.delete(id); }
|
|
201
|
+
setCredential(id, credential) { return this.profiles.setCredential(id, credential); }
|
|
202
|
+
|
|
203
|
+
getCaptureSettings() {
|
|
204
|
+
if (!this.captureSettings) return { schema: 'capture-settings/v1', terminalConversations: false };
|
|
205
|
+
return this.captureSettings.read();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
saveCaptureSettings(input = {}) {
|
|
209
|
+
if (!this.captureSettings) {
|
|
210
|
+
throw Object.assign(new Error('capture settings are not wired into this service'), { status: 501 });
|
|
211
|
+
}
|
|
212
|
+
const saved = this.captureSettings.save(input);
|
|
213
|
+
// Push the new scope into the driver immediately: sessions started after
|
|
214
|
+
// this call carry the matching AI_CODING_EVENT_BRIDGE_CAPTURE env.
|
|
215
|
+
if (typeof this.onCaptureSettingsSaved === 'function') this.onCaptureSettingsSaved(saved.terminalConversations);
|
|
216
|
+
return saved;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---- projects ----
|
|
220
|
+
listProjects() {
|
|
221
|
+
this._requireProjects();
|
|
222
|
+
return { projects: this.projects.list() };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
getProject(id) {
|
|
226
|
+
this._requireProjects();
|
|
227
|
+
const projectId = cleanId(id, 'projectId');
|
|
228
|
+
const project = this.projects.get(projectId);
|
|
229
|
+
if (!project) throw Object.assign(new Error('project not found'), { status: 404 });
|
|
230
|
+
return project;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
createProject(input = {}) {
|
|
234
|
+
this._requireProjects();
|
|
235
|
+
const project = this.projects.create(input);
|
|
236
|
+
this.contexts.register(project.id, project.rootPath);
|
|
237
|
+
this._audit('project-create', { projectId: project.id, rootPath: project.rootPath });
|
|
238
|
+
return project;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
renameProject(id, input = {}) {
|
|
242
|
+
this._requireProjects();
|
|
243
|
+
const projectId = cleanId(id, 'projectId');
|
|
244
|
+
if (input.defaultAiProfileId) this.profiles.runtimeProfile(input.defaultAiProfileId);
|
|
245
|
+
const project = this.projects.update(projectId, input);
|
|
246
|
+
this._audit('project-rename', { projectId, name: project.name });
|
|
247
|
+
return project;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
removeProject(id, options = {}) {
|
|
251
|
+
this._requireProjects();
|
|
252
|
+
const projectId = cleanId(id, 'projectId');
|
|
253
|
+
if (!this.projects.get(projectId)) throw Object.assign(new Error('project not found'), { status: 404 });
|
|
254
|
+
const keepSessions = options.keepSessions === true;
|
|
255
|
+
for (const session of this.runner.listSessions({ projectSlug: projectId })) {
|
|
256
|
+
if (ACTIVE_STATES.has(session.state) || session.backgroundTaskCount > 0) try { this.runner.abort(session.sessionId); } catch {}
|
|
257
|
+
if (!keepSessions) {
|
|
258
|
+
let deleted = false;
|
|
259
|
+
if (typeof this.runner.deleteSession === 'function') {
|
|
260
|
+
try { deleted = this.runner.deleteSession(session.sessionId) !== false; } catch {}
|
|
261
|
+
}
|
|
262
|
+
this.sessionArchive && this.sessionArchive.forget(session.sessionId);
|
|
263
|
+
this.knowledge?.forget(session.sessionId);
|
|
264
|
+
if (deleted) { this._removeHistory(session.sessionId); this._notifySessionDeleted(session); }
|
|
265
|
+
}
|
|
266
|
+
this.sessionAgents.delete(session.sessionId);
|
|
267
|
+
}
|
|
268
|
+
this.projects.remove(projectId);
|
|
269
|
+
this.contexts.unregister && this.contexts.unregister(projectId);
|
|
270
|
+
this._audit('project-remove', { projectId, keepSessions });
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
_requireProjects() {
|
|
275
|
+
if (!this.projects) throw Object.assign(new Error('project workspace support is not enabled'), { status: 501 });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ---- session archive ----
|
|
279
|
+
archiveSession(sessionId) {
|
|
280
|
+
this._requireArchive();
|
|
281
|
+
const id = cleanId(sessionId, 'sessionId');
|
|
282
|
+
if (!this.runner.getState(id)) throw Object.assign(new Error('session not found'), { status: 404 });
|
|
283
|
+
const record = this.sessionArchive.archive(id);
|
|
284
|
+
this._notifySessionChange(id);
|
|
285
|
+
this._audit('session-archive', { sessionId: id });
|
|
286
|
+
return { sessionId: id, ...record };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
restoreSession(sessionId) {
|
|
290
|
+
this._requireArchive();
|
|
291
|
+
const id = cleanId(sessionId, 'sessionId');
|
|
292
|
+
if (!this.runner.getState(id)) throw Object.assign(new Error('session not found'), { status: 404 });
|
|
293
|
+
const restored = this.sessionArchive.restore(id);
|
|
294
|
+
this._notifySessionChange(id);
|
|
295
|
+
this._audit('session-restore', { sessionId: id });
|
|
296
|
+
return { sessionId: id, restored };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Archived sessions never restored within the retention window are deleted
|
|
300
|
+
// outright: the runner record goes first (best effort) and the archive mark
|
|
301
|
+
// only once the record is gone — a failed delete keeps the session hidden
|
|
302
|
+
// and retries on the next sweep instead of resurfacing unarchived.
|
|
303
|
+
purgeExpiredArchivedSessions() {
|
|
304
|
+
if (!this.sessionArchive || !(this.archiveRetentionMs > 0)) return [];
|
|
305
|
+
const purged = [];
|
|
306
|
+
for (const sessionId of this.sessionArchive.expired(this.archiveRetentionMs)) {
|
|
307
|
+
const previous = this.runner.getState(sessionId);
|
|
308
|
+
let deleted = true;
|
|
309
|
+
if (typeof this.runner.deleteSession === 'function') {
|
|
310
|
+
try { this.runner.deleteSession(sessionId); } catch { deleted = false; }
|
|
311
|
+
}
|
|
312
|
+
this.sessionAgents.delete(sessionId);
|
|
313
|
+
if (deleted) {
|
|
314
|
+
if (previous) this._notifySessionDeleted(previous);
|
|
315
|
+
this._removeHistory(sessionId);
|
|
316
|
+
this.knowledge?.forget(sessionId);
|
|
317
|
+
this.sessionArchive.forget(sessionId);
|
|
318
|
+
purged.push(sessionId);
|
|
319
|
+
this._audit('archive-expired', { sessionId });
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return purged;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
getUiPreferences() {
|
|
326
|
+
if (!this.uiPreferences) throw Object.assign(new Error('ui preferences support is not enabled'), { status: 501 });
|
|
327
|
+
return this.uiPreferences.read();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
saveUiPreferences(input = {}) {
|
|
331
|
+
if (!this.uiPreferences) throw Object.assign(new Error('ui preferences support is not enabled'), { status: 501 });
|
|
332
|
+
return this.uiPreferences.save(input);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
_requireArchive() {
|
|
336
|
+
if (!this.sessionArchive) throw Object.assign(new Error('session archive support is not enabled'), { status: 501 });
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
listSessions(contextId, options = {}) {
|
|
340
|
+
const id = cleanId(contextId, 'contextId');
|
|
341
|
+
const includeArchived = Boolean(options.includeArchived);
|
|
342
|
+
const all = this.runner.listSessions({ projectSlug: id });
|
|
343
|
+
const visible = includeArchived || !this.sessionArchive
|
|
344
|
+
? all
|
|
345
|
+
: all.filter(session => !this.sessionArchive.isArchived(session.sessionId));
|
|
346
|
+
return visible.map((session, index) => this._summary(session, index === 0));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
startSession(contextInput, aiProfileId, options = {}) {
|
|
350
|
+
if (this.destroyed) throw Object.assign(new Error('workbench is shut down'), { status: 503 });
|
|
351
|
+
const context = normalizeContext(contextInput);
|
|
352
|
+
if (this.runner.listSessions({}).length >= this.maxSessions) {
|
|
353
|
+
throw Object.assign(new Error('session limit reached'), { status: 429 });
|
|
354
|
+
}
|
|
355
|
+
const resolved = this.contexts.resolve(context);
|
|
356
|
+
const project = this.projects && this.projects.get(context.workspaceRef);
|
|
357
|
+
const agentId = normalizeAgentId(options.agentId || context.agentId || project && project.defaultAgentId);
|
|
358
|
+
const effort = normalizeEffort(options.effort);
|
|
359
|
+
// null explicitly selects the agent's login; omission inherits the project.
|
|
360
|
+
const profileChoice = aiProfileId !== undefined ? aiProfileId
|
|
361
|
+
: context.aiProfileId !== undefined ? context.aiProfileId : project && project.defaultAiProfileId;
|
|
362
|
+
const profileId = profileChoice ? cleanId(profileChoice, 'aiProfileId') : null;
|
|
363
|
+
const inheritsProject = aiProfileId === undefined && context.aiProfileId === undefined;
|
|
364
|
+
const selectedModel = options.model !== undefined ? options.model
|
|
365
|
+
: context.model !== undefined ? context.model : inheritsProject && project ? project.defaultModel : undefined;
|
|
366
|
+
const profile = this._runtimeProfile(profileId, selectedModel);
|
|
367
|
+
const started = this.runner.startChatSession({
|
|
368
|
+
slug: context.contextId,
|
|
369
|
+
projectPath: resolved.workspacePath,
|
|
370
|
+
kbPath: resolved.workspacePath,
|
|
371
|
+
// Persisted so later delegations can prove this session's trusted
|
|
372
|
+
// workspace without guessing from the current UI project.
|
|
373
|
+
workspaceRef: context.workspaceRef,
|
|
374
|
+
aiProfile: profile,
|
|
375
|
+
permissionMode: options.permissionMode || profile.permissionMode || 'default',
|
|
376
|
+
agentId,
|
|
377
|
+
effort,
|
|
378
|
+
});
|
|
379
|
+
// CLI drivers recycle numeric ids after a restart; a fresh session must
|
|
380
|
+
// not inherit the archived flag of an unrelated predecessor that happened
|
|
381
|
+
// to hold the same id.
|
|
382
|
+
if (this.sessionArchive && this.sessionArchive.isArchived(started.sessionId)) {
|
|
383
|
+
this.sessionArchive.forget(started.sessionId);
|
|
384
|
+
}
|
|
385
|
+
this.sessionAgents.set(started.sessionId, agentId);
|
|
386
|
+
// The native-task scope is bound from trusted service identity at
|
|
387
|
+
// session creation, before any input can arrive.
|
|
388
|
+
this._attachNativeTasks(started.sessionId);
|
|
389
|
+
const session = this.runner.getSession(started.sessionId);
|
|
390
|
+
if (session) {
|
|
391
|
+
session.selectedModel = profile.mainModel || null;
|
|
392
|
+
session.metadata = { ...(session.metadata || {}), inputOrigin: normalizeInputOrigin(options.inputOrigin) };
|
|
393
|
+
}
|
|
394
|
+
this._trackHistory(started.sessionId);
|
|
395
|
+
this._notifySessionChange(started.sessionId);
|
|
396
|
+
this._audit('session-start', { sessionId: started.sessionId, contextId: context.contextId, profileId, agentId });
|
|
397
|
+
return this._summary(this.runner.getState(started.sessionId), true);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
loadSession(sessionId) {
|
|
401
|
+
const id = cleanId(sessionId, 'sessionId');
|
|
402
|
+
const state = this.runner.getState(id);
|
|
403
|
+
if (!state) throw Object.assign(new Error('session not found'), { status: 404 });
|
|
404
|
+
// An existing session binds its fixed native-task scope on first Service
|
|
405
|
+
// attach; it can never switch to a different one afterwards.
|
|
406
|
+
this._attachNativeTasks(id);
|
|
407
|
+
const usage = this.runner.getSessionTokenUsage(id);
|
|
408
|
+
return {
|
|
409
|
+
session: this._summary(state, true),
|
|
410
|
+
events: snapshotEvents(this.runner, id),
|
|
411
|
+
permission: state.pendingPermission || null,
|
|
412
|
+
permissionMode: state.permissionMode || 'default',
|
|
413
|
+
tokenUsage: { ...usage, hasUsage: Boolean(usage && (usage.used || usage.inputTokens || usage.outputTokens)) },
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Trusted-scope injection: the store scope, the runtime epoch and the
|
|
418
|
+
// durable-fact committer come from the frozen service task identity only -
|
|
419
|
+
// browser input and session options never reach them. Without a usable
|
|
420
|
+
// store identity the native-task feature stays explicitly unavailable
|
|
421
|
+
// while chat and history continue unchanged.
|
|
422
|
+
_attachNativeTasks(sessionId) {
|
|
423
|
+
if (!sessionId || !this.taskIdentity.available || !this.taskIdentity.storeScope) return false;
|
|
424
|
+
if (typeof this.runner.attachNativeTaskRuntime !== 'function') return false;
|
|
425
|
+
let committer = this.nativeCommitters.get(sessionId);
|
|
426
|
+
if (!committer) {
|
|
427
|
+
committer = this._nativeFactCommitter(sessionId);
|
|
428
|
+
this.nativeCommitters.set(sessionId, committer);
|
|
429
|
+
}
|
|
430
|
+
try {
|
|
431
|
+
const result = this.runner.attachNativeTaskRuntime(sessionId, {
|
|
432
|
+
storeScope: this.taskIdentity.storeScope,
|
|
433
|
+
runtimeEpoch: this.taskIdentity.runtimeEpoch,
|
|
434
|
+
commitNativeFact: committer,
|
|
435
|
+
});
|
|
436
|
+
return Boolean(result && result.attached);
|
|
437
|
+
} catch {
|
|
438
|
+
return false;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// The one trusted synchronous committer the runner's publish gate calls
|
|
443
|
+
// BEFORE a native fact reaches any buffer, listener or persisted
|
|
444
|
+
// snapshot. It goes through the sequence-validated durable receipt path
|
|
445
|
+
// (write + fsync), never a raw file bypass: a duplicate of an
|
|
446
|
+
// already-committed same-sequence fact is an idempotent success, a
|
|
447
|
+
// same-sequence disagreement is an explicit conflict failure, and a
|
|
448
|
+
// history that cannot even be attached is a fixed unavailable phase.
|
|
449
|
+
_nativeFactCommitter(sessionId) {
|
|
450
|
+
return event => {
|
|
451
|
+
try {
|
|
452
|
+
const history = this._history(sessionId);
|
|
453
|
+
const sequence = Number(event && event.sequence);
|
|
454
|
+
if (!Number.isSafeInteger(sequence) || sequence <= 0) {
|
|
455
|
+
return { ok: false, phase: 'sequence', error: 'native fact is not sequence-stamped' };
|
|
456
|
+
}
|
|
457
|
+
if (sequence <= history.highWater) {
|
|
458
|
+
const known = history.durableRow(sequence);
|
|
459
|
+
if (known && canonicalNativeFact(known) === canonicalNativeFact(event)) {
|
|
460
|
+
return { ok: true, phase: 'already-committed', sequence, highWater: history.highWater };
|
|
461
|
+
}
|
|
462
|
+
return {
|
|
463
|
+
ok: false, phase: 'conflict',
|
|
464
|
+
error: `native fact at sequence ${sequence} conflicts with the committed row`,
|
|
465
|
+
highWater: history.highWater,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
return history.appendDurable([event]);
|
|
469
|
+
} catch {
|
|
470
|
+
return { ok: false, phase: 'history-unavailable', error: 'session history is not attachable' };
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Unbind this Service's own committer (same epoch + same closure
|
|
476
|
+
// reference); a different or second instance is refused by the runner.
|
|
477
|
+
_detachNativeTasks(sessionId) {
|
|
478
|
+
const committer = this.nativeCommitters.get(sessionId) || null;
|
|
479
|
+
this.nativeCommitters.delete(sessionId);
|
|
480
|
+
if (typeof this.runner.detachNativeTaskRuntime !== 'function') return;
|
|
481
|
+
try {
|
|
482
|
+
this.runner.detachNativeTaskRuntime(sessionId, {
|
|
483
|
+
runtimeEpoch: this.taskIdentity.runtimeEpoch,
|
|
484
|
+
commitNativeFact: committer,
|
|
485
|
+
});
|
|
486
|
+
} catch { /* binding already gone */ }
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
_history(id) {
|
|
490
|
+
if (this.histories.has(id)) return this.histories.get(id).history;
|
|
491
|
+
const session = this.runner.getSession(id);
|
|
492
|
+
const state = this.runner.getState(id);
|
|
493
|
+
if (!session || !state) throw Object.assign(new Error('session not found'), { status: 404 });
|
|
494
|
+
const first = session.outputBuffer?.[0];
|
|
495
|
+
const history = new SessionHistory({ file: this._historyFile(id), sessionId: id, startedAt: state.startedAt,
|
|
496
|
+
contextId: state.projectSlug, incomplete: Number(first?.sequence) > 1
|
|
497
|
+
|| Number(session.turns) > (session.outputBuffer || []).filter(event => event.type === 'claude/user-prompt').length,
|
|
498
|
+
onError: error => this.diagnostics?.error('session.history.write.failed', '会话历史保存失败。', { phase: 'history', error: { code: error.code || 'HISTORY_WRITE_FAILED', message: '无法写入会话历史。' }, context: { sessionId: id } }),
|
|
499
|
+
});
|
|
500
|
+
this._reconcileHistory(id, history, session);
|
|
501
|
+
const unsubscribe = this.runner.subscribe(id, event => {
|
|
502
|
+
if (NATIVE_FACT_TYPES.has(event.type)) {
|
|
503
|
+
const sequence = Number(event.sequence);
|
|
504
|
+
if (Number.isSafeInteger(sequence) && sequence > 0 && sequence <= history.highWater) {
|
|
505
|
+
const known = history.durableRow(sequence);
|
|
506
|
+
if (known && canonicalNativeFact(known) === canonicalNativeFact(event)) {
|
|
507
|
+
// Idempotent replay of a committed fact - including the very
|
|
508
|
+
// fact the publish gate's committer just landed: no second
|
|
509
|
+
// write, but the session-change notification must still fire.
|
|
510
|
+
} else {
|
|
511
|
+
// A same-sequence fact is never blindly treated as "already
|
|
512
|
+
// written" and never overwrites the committed row: either the
|
|
513
|
+
// numbered row is missing or it disagrees with the replayed fact,
|
|
514
|
+
// and either way the conflict is an explicit diagnosis.
|
|
515
|
+
this.diagnostics?.error('session.history.durable.conflict', '原生任务事实与持久化历史同序号冲突;拒绝改写,原文件已保留。', {
|
|
516
|
+
phase: 'history', error: { code: 'HISTORY_DURABLE_CONFLICT', message: `native fact at sequence ${sequence} conflicts with the committed row` },
|
|
517
|
+
context: { sessionId: id, sequence, present: Boolean(known) },
|
|
518
|
+
});
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
} else {
|
|
522
|
+
// Fallback for native facts that flowed ungated (a runner without
|
|
523
|
+
// a bound committer): the receipt is diagnosed, never faked.
|
|
524
|
+
const receipt = history.appendDurable([event]);
|
|
525
|
+
if (!receipt.ok) this.diagnostics?.error('session.history.durable.failed', '原生任务事实未能持久化;任务历史不可用,原文件已保留。', {
|
|
526
|
+
phase: 'history', error: { code: `HISTORY_DURABLE_${String(receipt.phase).toUpperCase()}`, message: String(receipt.error || 'durable append failed') },
|
|
527
|
+
context: { sessionId: id, highWater: receipt.highWater, pendingCount: receipt.pendingCount ?? null },
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
} else history.append(event);
|
|
531
|
+
this._notifySessionChange(id, event);
|
|
532
|
+
});
|
|
533
|
+
this.histories.set(id, { history, unsubscribe });
|
|
534
|
+
return history;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Forward-only reconciliation between the runner snapshot counter and the
|
|
538
|
+
// durable journal, run once when a session's history is attached and
|
|
539
|
+
// therefore before any new sequence is minted for it. The old behavior
|
|
540
|
+
// reset a newer durable high-water mark back to an older snapshot count,
|
|
541
|
+
// which both silently dropped fresh events (sequence at or below the reset
|
|
542
|
+
// mark) and re-minted sequences the file already contained. Now both
|
|
543
|
+
// sides adopt max(snapshot, durable) and the losing side is repaired or
|
|
544
|
+
// explicitly marked instead of being rewritten: durable-ahead merges the
|
|
545
|
+
// missing durable tail into the replay buffer so subscribers can be
|
|
546
|
+
// served the gap; snapshot-ahead replays the contiguous in-memory prefix
|
|
547
|
+
// through the durable receipt path, and a hole that cannot be
|
|
548
|
+
// reconstructed is recorded as a recovery gap — never faked as complete.
|
|
549
|
+
_reconcileHistory(id, history, session) {
|
|
550
|
+
if (!history.exists || !Number.isFinite(session.eventSequence)) return;
|
|
551
|
+
const buffered = Array.isArray(session.outputBuffer) ? session.outputBuffer : [];
|
|
552
|
+
let bufferedMax = 0;
|
|
553
|
+
for (const event of buffered) {
|
|
554
|
+
const own = Number(event && event.sequence);
|
|
555
|
+
if (Number.isFinite(own) && own > 0 && own > bufferedMax) bufferedMax = own;
|
|
556
|
+
}
|
|
557
|
+
if (history.highWater > session.eventSequence) {
|
|
558
|
+
const tail = history.durableTail({ afterSequence: bufferedMax });
|
|
559
|
+
const lastDeltaSequence = new Map();
|
|
560
|
+
for (let i = buffered.length - 1; i >= 0; i--) {
|
|
561
|
+
const event = buffered[i];
|
|
562
|
+
if (!event || !DELTA_TYPES.has(event.type)) continue;
|
|
563
|
+
const id = event.id ?? null;
|
|
564
|
+
if (!lastDeltaSequence.has(id)) lastDeltaSequence.set(id, Number(event.sequence) || 0);
|
|
565
|
+
}
|
|
566
|
+
for (const event of tail.events) {
|
|
567
|
+
if (event && DELTA_TYPES.has(event.type)) {
|
|
568
|
+
const id = event.id ?? null;
|
|
569
|
+
const coveredFrom = compactionCoverage(buffered, event, lastDeltaSequence.get(id) || 0);
|
|
570
|
+
if (coveredFrom !== null) {
|
|
571
|
+
// A durable compaction subsumes fragments the replay buffer
|
|
572
|
+
// still holds in part: the merged row replaces them instead of
|
|
573
|
+
// concatenating onto them, so no client's cached prefix is
|
|
574
|
+
// duplicated and a cursor below the merged row resyncs
|
|
575
|
+
// explicitly instead of receiving the whole fragment as a delta.
|
|
576
|
+
for (let i = buffered.length - 1; i >= 0; i--) {
|
|
577
|
+
const held = buffered[i];
|
|
578
|
+
if (held && DELTA_TYPES.has(held.type) && held.id === event.id && Number(held.sequence) >= coveredFrom) buffered.splice(i, 1);
|
|
579
|
+
}
|
|
580
|
+
lastDeltaSequence.set(id, Number(event.sequence) || 0);
|
|
581
|
+
} else if (Number.isFinite(Number(event.sequence))) {
|
|
582
|
+
lastDeltaSequence.set(id, Math.max(lastDeltaSequence.get(id) || 0, Number(event.sequence)));
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
buffered.push(event);
|
|
586
|
+
while (buffered.length > 5000) buffered.shift();
|
|
587
|
+
}
|
|
588
|
+
session.eventSequence = Math.max(session.eventSequence, history.highWater);
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
if (session.eventSequence > history.highWater) {
|
|
592
|
+
const above = [];
|
|
593
|
+
for (const event of buffered) {
|
|
594
|
+
const own = Number(event && event.sequence);
|
|
595
|
+
if (Number.isFinite(own) && own > history.highWater) above.push([own, event]);
|
|
596
|
+
}
|
|
597
|
+
above.sort((a, b) => a[0] - b[0]);
|
|
598
|
+
const catchUp = [];
|
|
599
|
+
let expected = history.highWater + 1;
|
|
600
|
+
for (const [sequence, event] of above) {
|
|
601
|
+
if (sequence !== expected) break;
|
|
602
|
+
catchUp.push(event);
|
|
603
|
+
expected += 1;
|
|
604
|
+
}
|
|
605
|
+
if (catchUp.length) {
|
|
606
|
+
const receipt = history.appendDurable(catchUp);
|
|
607
|
+
if (!receipt.ok) {
|
|
608
|
+
this.diagnostics?.error('session.history.reconcile.failed', '崩溃边界恢复未能补写会话历史;原文件已保留。', {
|
|
609
|
+
phase: 'history', error: { code: `HISTORY_DURABLE_${String(receipt.phase).toUpperCase()}`, message: String(receipt.error || 'durable append failed') },
|
|
610
|
+
context: { sessionId: id, highWater: receipt.highWater },
|
|
611
|
+
});
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
if (history.highWater < session.eventSequence) {
|
|
616
|
+
// The gap marker is journaled, not kept in RAM: a restart must keep
|
|
617
|
+
// reporting the hole instead of presenting a healed-looking archive.
|
|
618
|
+
// A failed marker receipt is explicit and stays retryable — the
|
|
619
|
+
// next reconcile rewrites it instead of skipping it.
|
|
620
|
+
const recorded = history.recordRecoveryGap(history.highWater + 1, session.eventSequence);
|
|
621
|
+
if (!recorded.ok) {
|
|
622
|
+
this.diagnostics?.error('session.history.recovery.record.failed', '缺口标记未能持久化;原文件已保留,保持可重试。', {
|
|
623
|
+
phase: 'history', error: { code: `HISTORY_DURABLE_${String(recorded.phase).toUpperCase()}`, message: String(recorded.error || 'gap marker write failed') },
|
|
624
|
+
context: { sessionId: id, from: history.highWater + 1, to: session.eventSequence },
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
this.diagnostics?.error('session.history.recovery.gap', '会话历史存在无法重建的序号缺口;历史读取将显式标注。', {
|
|
628
|
+
phase: 'history', error: { code: 'HISTORY_RECOVERY_GAP', message: '快照与持久化历史之间存在缺口,不能宣称完整。' },
|
|
629
|
+
context: { sessionId: id, from: history.highWater + 1, to: session.eventSequence },
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
_trackHistory(id) {
|
|
636
|
+
try { return this._history(id); }
|
|
637
|
+
catch (error) {
|
|
638
|
+
this.diagnostics?.error('session.history.read.failed', '会话历史读取失败;实时会话仍可使用。', {
|
|
639
|
+
phase: 'history', error: { code: error.code || 'HISTORY_READ_FAILED', message: '会话历史不可用,原文件已保留。' }, context: { sessionId: id },
|
|
640
|
+
});
|
|
641
|
+
return null;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
loadHistory(sessionId, options = {}) {
|
|
646
|
+
const id = cleanId(sessionId, 'sessionId');
|
|
647
|
+
try {
|
|
648
|
+
const history = this._history(id);
|
|
649
|
+
const state = this.runner.getState(id);
|
|
650
|
+
if (history.incomplete && !ACTIVE_STATES.has(state.state) && this._summary(state, false).agentId === 'claude-code') {
|
|
651
|
+
const session = this.runner.getSession(id);
|
|
652
|
+
const recovered = recoverClaudeHistory(session);
|
|
653
|
+
if (recovered) history.seedRecovered(recovered, Number(session.eventSequence) || history.highWater);
|
|
654
|
+
}
|
|
655
|
+
return history.read(options);
|
|
656
|
+
} catch (error) {
|
|
657
|
+
if (![400, 404].includes(error.status)) {
|
|
658
|
+
this.diagnostics?.error('session.history.read.failed', '会话历史读取失败,原文件已保留。', {
|
|
659
|
+
phase: 'history', error: { code: error.code || 'HISTORY_READ_FAILED', message: '无法读取完整会话历史。' }, context: { sessionId: id },
|
|
660
|
+
});
|
|
661
|
+
error.status = 503;
|
|
662
|
+
}
|
|
663
|
+
throw error;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
_removeHistory(id) {
|
|
668
|
+
this.knowledgeBySession.delete(id);
|
|
669
|
+
this._detachNativeTasks(id);
|
|
670
|
+
const record = this.histories.get(id);
|
|
671
|
+
record?.unsubscribe(); record?.history.close();
|
|
672
|
+
this.histories.delete(id);
|
|
673
|
+
try { fs.unlinkSync(this._historyFile(id)); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
_historyFile(id) { return path.join(this.historyRoot, `${crypto.createHash('sha256').update(id).digest('hex')}.jsonl`); }
|
|
677
|
+
|
|
678
|
+
// ---- native task history (read-only over the session's own journal) ----
|
|
679
|
+
|
|
680
|
+
// The fixed scope every native-task read is bound to: the frozen trusted
|
|
681
|
+
// store identity plus the session's real project and id. Nothing in it is
|
|
682
|
+
// client-derived; the client only ever echoes it back for comparison.
|
|
683
|
+
_nativeTaskScope(state) {
|
|
684
|
+
return { storeScope: this.taskIdentity.storeScope, contextId: state.projectSlug, parentSessionId: state.sessionId };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
_requireNativeTaskScope() {
|
|
688
|
+
if (!this.taskIdentity.available || !this.taskIdentity.storeScope) {
|
|
689
|
+
throw Object.assign(new Error('native task history is unavailable: the trusted store scope is not configured'), {
|
|
690
|
+
status: 503, code: this.taskIdentity.errorCode || 'STORE_SCOPE_UNAVAILABLE',
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
return this.taskIdentity.storeScope;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// The reader file always comes from the service's own session-history
|
|
697
|
+
// instance; recovery seeds never run on this path, and a failure keeps the
|
|
698
|
+
// original file and surfaces as an explicit unavailable answer.
|
|
699
|
+
_nativeHistoryFor(id) {
|
|
700
|
+
try {
|
|
701
|
+
return this._history(id);
|
|
702
|
+
} catch (error) {
|
|
703
|
+
if (error && (error.status === 400 || error.status === 404)) throw error;
|
|
704
|
+
throw Object.assign(new Error('native task history is not readable; the original history file is preserved'), {
|
|
705
|
+
status: 503, code: error && error.code ? error.code : 'HISTORY_READ_FAILED',
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// A journal that has proven a write failure is never served as complete
|
|
711
|
+
// history: the newest facts may still live only in memory, so a page off
|
|
712
|
+
// the disk would silently omit them. Mirrors the SessionHistory read gate
|
|
713
|
+
// with a fixed safe message; the latched code token travels as the code.
|
|
714
|
+
// The same hole exists whenever ANY native fact is still pending behind a
|
|
715
|
+
// durable barrier - a recovery gap whose marker is not yet on disk, or a
|
|
716
|
+
// failed batch still awaiting its close-time marker: while the barrier
|
|
717
|
+
// head exists the native surfaces refuse with one generic pending code
|
|
718
|
+
// instead of serving the journal as complete. Ordinary chat reads are
|
|
719
|
+
// untouched, and a cleared barrier restores these reads.
|
|
720
|
+
_requireDurableJournal(history) {
|
|
721
|
+
if (history.error) {
|
|
722
|
+
throw Object.assign(new Error('会话历史写入失败,原文件已保留;原生任务历史暂不可用。'), { status: 503, code: history.error });
|
|
723
|
+
}
|
|
724
|
+
if (history.durableBarrier) {
|
|
725
|
+
throw Object.assign(new Error('尚有未提交历史事实,原生任务历史暂不可用。'), { status: 503, code: 'HISTORY_FACTS_PENDING' });
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// The journal may know a recovery gap the on-disk scan can no longer see
|
|
730
|
+
// (the marker row disappeared after attach). The reader's own verdict
|
|
731
|
+
// stays untouched; the service-known hole is merged in, never dropped.
|
|
732
|
+
_nativeGapOverlay(history, page) {
|
|
733
|
+
const gap = history.recoveryGap;
|
|
734
|
+
if (!gap || page.incomplete.includes('history-gap')) return page;
|
|
735
|
+
const items = [{ code: 'service-recovery-gap', source: 'service', from: gap.from, to: gap.to }].concat(page.diagnostics.items);
|
|
736
|
+
const total = page.diagnostics.total + 1;
|
|
737
|
+
return {
|
|
738
|
+
...page,
|
|
739
|
+
incomplete: [...page.incomplete, 'history-gap'],
|
|
740
|
+
diagnostics: { items: items.slice(0, 20), total, truncated: total > 20 },
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// GET /sessions/:id/native-tasks - list observed native background tasks
|
|
745
|
+
// for the session out of its durable history. Pure read: no input is
|
|
746
|
+
// dispatched and no live handle is created; task rows are historical
|
|
747
|
+
// observations, never claims that anything is running or controllable.
|
|
748
|
+
async listNativeTasks(sessionId, query = {}) {
|
|
749
|
+
const id = cleanId(sessionId, 'sessionId');
|
|
750
|
+
const state = this.runner.getState(id);
|
|
751
|
+
if (!state) throw Object.assign(new Error('session not found'), { status: 404 });
|
|
752
|
+
const storeScope = this._requireNativeTaskScope();
|
|
753
|
+
if (typeof query.storeScope !== 'string' || query.storeScope === '') {
|
|
754
|
+
throw Object.assign(new Error('storeScope is required'), { status: 400 });
|
|
755
|
+
}
|
|
756
|
+
if (!STORE_SCOPE_PATTERN.test(query.storeScope)) {
|
|
757
|
+
throw Object.assign(new Error('storeScope is invalid'), { status: 400 });
|
|
758
|
+
}
|
|
759
|
+
if (typeof query.contextId !== 'string' || query.contextId === '') {
|
|
760
|
+
throw Object.assign(new Error('contextId is required'), { status: 400 });
|
|
761
|
+
}
|
|
762
|
+
const contextId = cleanId(query.contextId, 'contextId');
|
|
763
|
+
if (query.storeScope !== storeScope || contextId !== state.projectSlug) {
|
|
764
|
+
throw Object.assign(new Error('native task scope does not match this session; resync from the task list'), {
|
|
765
|
+
status: 409, code: 'SCOPE_MISMATCH', resync: true,
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
const history = this._nativeHistoryFor(id);
|
|
769
|
+
this._requireDurableJournal(history);
|
|
770
|
+
const scope = this._nativeTaskScope(state);
|
|
771
|
+
const reader = createNativeTaskHistoryReader({ file: history.file, scope });
|
|
772
|
+
let page;
|
|
773
|
+
try { page = await reader.listTasks(); }
|
|
774
|
+
catch (error) { if (error && error.status === 409) error.resync = true; throw error; }
|
|
775
|
+
return { ...this._nativeGapOverlay(history, page), scope: { ...scope } };
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// POST /sessions/:id/native-tasks/history - page one bound task's events
|
|
779
|
+
// out of the same durable history. Pure read: no dispatch, no stop, no
|
|
780
|
+
// re-minted sequences, no SSE replay.
|
|
781
|
+
async loadNativeTaskHistory(sessionId, input = {}) {
|
|
782
|
+
const id = cleanId(sessionId, 'sessionId');
|
|
783
|
+
const state = this.runner.getState(id);
|
|
784
|
+
if (!state) throw Object.assign(new Error('session not found'), { status: 404 });
|
|
785
|
+
this._requireNativeTaskScope();
|
|
786
|
+
const ref = createNativeTaskRef(input && input.taskRef);
|
|
787
|
+
if (!ref) throw Object.assign(new Error('taskRef is not a valid native-task reference'), { status: 400, code: 'INVALID_TASK_REF' });
|
|
788
|
+
// The whole reference is checked against the real session scope before
|
|
789
|
+
// any journal is constructed or read, so a foreign-scope ref is a scope
|
|
790
|
+
// conflict even when the target file is missing or corrupt.
|
|
791
|
+
const scope = this._nativeTaskScope(state);
|
|
792
|
+
if (ref.storeScope !== scope.storeScope || ref.contextId !== scope.contextId || ref.parentSessionId !== scope.parentSessionId) {
|
|
793
|
+
throw Object.assign(new Error('taskRef scope does not match this session; resync from the task list'), {
|
|
794
|
+
status: 409, code: 'SCOPE_MISMATCH', resync: true,
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
const history = this._nativeHistoryFor(id);
|
|
798
|
+
this._requireDurableJournal(history);
|
|
799
|
+
const reader = createNativeTaskHistoryReader({ file: history.file, scope });
|
|
800
|
+
let page;
|
|
801
|
+
try { page = await reader.readTaskEvents(ref, { cursor: input.cursor, limit: input.limit }); }
|
|
802
|
+
catch (error) { if (error && error.status === 409) error.resync = true; throw error; }
|
|
803
|
+
return { ...this._nativeGapOverlay(history, page), taskRef: { ...ref }, scope: { ...scope } };
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// ---- delegated sessions (trusted Workbench delegation commands) ----
|
|
807
|
+
|
|
808
|
+
_requireDelegations() {
|
|
809
|
+
if (!this.delegations || !this.delegations.ledger || !this.delegations.phases) {
|
|
810
|
+
throw Object.assign(new Error('delegated sessions are unavailable: the delegation ledger is not wired'), {
|
|
811
|
+
status: 503, code: 'DELEGATION_UNAVAILABLE',
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
_delegationSupported(agentId) {
|
|
817
|
+
if (typeof this.runner.supportsDelegatedSessions === 'function') {
|
|
818
|
+
return this.runner.supportsDelegatedSessions(agentId) === true;
|
|
819
|
+
}
|
|
820
|
+
return typeof this.runner.startDelegatedSession === 'function';
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// The trusted workspace a delegated child inherits. Preference order: the
|
|
824
|
+
// parent's own persisted workspaceRef re-resolved through the real resolver,
|
|
825
|
+
// then — for sessions recorded before workspaceRef existed — a UNIQUE
|
|
826
|
+
// real-path match against the registered workspaces. Ambiguous or
|
|
827
|
+
// unresolvable is an explicit 409; the current UI project is never picked.
|
|
828
|
+
_delegationWorkspace(parentState) {
|
|
829
|
+
const parentSession = this.runner.getSession(parentState.sessionId);
|
|
830
|
+
const ref = parentSession && typeof parentSession.workspaceRef === 'string' && parentSession.workspaceRef
|
|
831
|
+
? parentSession.workspaceRef : null;
|
|
832
|
+
if (ref) {
|
|
833
|
+
try {
|
|
834
|
+
const resolved = this.contexts.resolve({ contextId: parentState.projectSlug, workspaceRef: ref });
|
|
835
|
+
return { ref, rootPath: resolved.workspacePath };
|
|
836
|
+
} catch {
|
|
837
|
+
throw Object.assign(new Error('the parent workspace is no longer registered; no delegation was issued'), {
|
|
838
|
+
status: 409, code: 'DELEGATION_WORKSPACE_UNRESOLVED',
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
const recordedPath = parentSession && typeof parentSession.projectPath === 'string' && parentSession.projectPath
|
|
843
|
+
? parentSession.projectPath : null;
|
|
844
|
+
if (!recordedPath) {
|
|
845
|
+
throw Object.assign(new Error('the parent session has no trusted workspace record; no delegation was issued'), {
|
|
846
|
+
status: 409, code: 'DELEGATION_WORKSPACE_UNRESOLVED',
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
const matches = this.contexts.findByRealPath(recordedPath);
|
|
850
|
+
if (matches.length === 1) return { ref: matches[0], rootPath: recordedPath };
|
|
851
|
+
if (matches.length === 0) {
|
|
852
|
+
throw Object.assign(new Error('the parent workspace does not match any registered workspace; no delegation was issued'), {
|
|
853
|
+
status: 409, code: 'DELEGATION_WORKSPACE_UNRESOLVED',
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
throw Object.assign(new Error('the parent workspace matches multiple registered workspaces; no delegation was issued'), {
|
|
857
|
+
status: 409, code: 'DELEGATION_WORKSPACE_AMBIGUOUS',
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Canonical command hash over EVERY choice that affects execution: the
|
|
862
|
+
// normalized text, the agent/profile/model/permission/effort selection and
|
|
863
|
+
// the resolved workspace and context mode. Only the command hash is stored;
|
|
864
|
+
// opt-in parent context is frozen separately on the private reservation.
|
|
865
|
+
_delegationPayloadHash(command) {
|
|
866
|
+
const canonical = JSON.stringify({
|
|
867
|
+
schema: 'workbench-delegation-command/v1',
|
|
868
|
+
text: command.text,
|
|
869
|
+
agentId: command.agentId,
|
|
870
|
+
aiProfileId: command.aiProfileId,
|
|
871
|
+
model: command.model,
|
|
872
|
+
permissionMode: command.permissionMode,
|
|
873
|
+
effort: command.effort || null,
|
|
874
|
+
workspaceRef: command.workspaceRef,
|
|
875
|
+
contextId: command.contextId,
|
|
876
|
+
parentSessionId: command.parentSessionId,
|
|
877
|
+
...(command.contextMode ? { contextMode: command.contextMode } : {}),
|
|
878
|
+
});
|
|
879
|
+
return crypto.createHash('sha256').update(canonical, 'utf8').digest('hex');
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
_delegationDispatchState(record) {
|
|
883
|
+
const phases = this.delegations.phases.stateOf(record.delegationId);
|
|
884
|
+
const live = this.delegations.phases.isClaimLive(record.delegationId);
|
|
885
|
+
if (phases.outcome) {
|
|
886
|
+
const known = phases.outcome.outcome === 'dispatched';
|
|
887
|
+
return {
|
|
888
|
+
phase: known ? 'dispatched' : 'dispatch-unknown',
|
|
889
|
+
outcome: known ? 'dispatched' : 'unknown',
|
|
890
|
+
dispatch: { state: phases.outcome.outcome, attemptId: phases.outcome.attemptId, at: phases.outcome.at },
|
|
891
|
+
claim: phases.claim,
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
if (phases.claim) {
|
|
895
|
+
if (live) {
|
|
896
|
+
return { phase: 'dispatching', outcome: 'pending', dispatch: { state: 'dispatching', attemptId: phases.claim.attemptId }, claim: phases.claim };
|
|
897
|
+
}
|
|
898
|
+
// A claim nobody in this Service can still settle: the model receipt is
|
|
899
|
+
// unprovable, so the honest verdict is dispatch-unknown.
|
|
900
|
+
return {
|
|
901
|
+
phase: 'dispatch-unknown', outcome: 'unknown',
|
|
902
|
+
dispatch: { state: 'dispatch-unknown', attemptId: phases.claim.attemptId, reason: 'owner-unreachable' },
|
|
903
|
+
claim: phases.claim,
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
if (phases.childCreated) {
|
|
907
|
+
return { phase: 'child-created', outcome: 'not-dispatched', dispatch: { state: 'awaiting-dispatch' }, claim: null };
|
|
908
|
+
}
|
|
909
|
+
if (phases.childCreateFailed) {
|
|
910
|
+
return { phase: 'child-create-failed', outcome: 'not-started', dispatch: { state: 'not-dispatched', code: phases.childCreateFailed.code }, claim: null };
|
|
911
|
+
}
|
|
912
|
+
return { phase: 'reserved', outcome: 'not-started', dispatch: { state: 'not-dispatched' }, claim: null };
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Real child status straight from the runner. A missing child is reported
|
|
916
|
+
// as the explicit dangling fact it is — reads never resurrect it.
|
|
917
|
+
_delegationChild(record) {
|
|
918
|
+
const state = this.runner.getState(record.childSessionId);
|
|
919
|
+
if (!state) return { sessionId: record.childSessionId, present: false, state: 'missing' };
|
|
920
|
+
const summary = this._summary(state, ACTIVE_STATES.has(state.state));
|
|
921
|
+
return {
|
|
922
|
+
sessionId: record.childSessionId,
|
|
923
|
+
present: true,
|
|
924
|
+
state: state.state,
|
|
925
|
+
agentId: summary.agentId,
|
|
926
|
+
model: summary.model || null,
|
|
927
|
+
active: summary.active,
|
|
928
|
+
turns: summary.turns,
|
|
929
|
+
archived: summary.archived,
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
_delegationView(record) {
|
|
934
|
+
const status = this._delegationDispatchState(record);
|
|
935
|
+
return {
|
|
936
|
+
delegation: {
|
|
937
|
+
kind: 'delegated-session',
|
|
938
|
+
storeScope: record.scope.storeScope,
|
|
939
|
+
contextId: record.scope.contextId,
|
|
940
|
+
parentSessionId: record.scope.parentSessionId,
|
|
941
|
+
delegationId: record.delegationId,
|
|
942
|
+
childSessionId: record.childSessionId,
|
|
943
|
+
workspaceRef: record.workspace.ref,
|
|
944
|
+
requestId: record.requestId,
|
|
945
|
+
issuedAt: record.issuedAt,
|
|
946
|
+
source: record.source,
|
|
947
|
+
basis: record.basis,
|
|
948
|
+
sdkRunIdClaimed: false,
|
|
949
|
+
inputCausalityClaimed: false,
|
|
950
|
+
initiator: { type: 'external-api', authenticatedAs: null },
|
|
951
|
+
selection: { profileId: record.selection.profileId, model: record.selection.model },
|
|
952
|
+
},
|
|
953
|
+
phase: status.phase,
|
|
954
|
+
outcome: status.outcome,
|
|
955
|
+
dispatch: status.dispatch,
|
|
956
|
+
child: this._delegationChild(record),
|
|
957
|
+
...(record.context ? { context: { mode: record.context.mode, truncated: record.context.truncated } } : {}),
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
_adoptDelegatedChild(childSessionId, agentId) {
|
|
962
|
+
this.sessionAgents.set(childSessionId, agentId);
|
|
963
|
+
this._attachNativeTasks(childSessionId);
|
|
964
|
+
this._trackHistory(childSessionId);
|
|
965
|
+
this._notifySessionChange(childSessionId);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
_recordDelegationFailure(record, code) {
|
|
969
|
+
try {
|
|
970
|
+
this.delegations.phases.recordChildCreateFailed({ delegationId: record.delegationId, code });
|
|
971
|
+
} catch {
|
|
972
|
+
// The failure fact is diagnostics; the explicit error answer below is
|
|
973
|
+
// already the honest result and the reservation stays untouched.
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// POST /sessions/:parentId/delegations — issue one delegation command.
|
|
978
|
+
// Frozen order (W1-DELEGATION-DECISIONS): validate → reserve (atomic fsync,
|
|
979
|
+
// idempotent by requestId, payload change = 409) → trusted child factory
|
|
980
|
+
// with the reserved identity (relation durable before anything else) →
|
|
981
|
+
// durable dispatch claim (exactly one owner) → send → durable outcome. Any
|
|
982
|
+
// unknown after the claim is reported as dispatch-unknown and never
|
|
983
|
+
// auto-re-input. accepted means the Workbench accepted the command, not
|
|
984
|
+
// that a model proved receipt.
|
|
985
|
+
async createDelegation(parentIdRaw, input = {}) {
|
|
986
|
+
this._requireDelegations();
|
|
987
|
+
const storeScope = this._requireNativeTaskScope();
|
|
988
|
+
const parentId = cleanId(parentIdRaw, 'sessionId');
|
|
989
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
990
|
+
throw Object.assign(new Error('delegation command must be an object'), { status: 400, code: 'DELEGATION_COMMAND_INVALID' });
|
|
991
|
+
}
|
|
992
|
+
const scopeInput = input.scope;
|
|
993
|
+
if (!scopeInput || typeof scopeInput !== 'object' || Array.isArray(scopeInput)
|
|
994
|
+
|| Object.keys(scopeInput).length !== 2 || !('storeScope' in scopeInput) || !('contextId' in scopeInput)) {
|
|
995
|
+
throw Object.assign(new Error('scope must be an object with exactly storeScope and contextId; parentSessionId comes from the route'), {
|
|
996
|
+
status: 400, code: 'DELEGATION_SCOPE_INVALID',
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
if (typeof scopeInput.storeScope !== 'string' || !STORE_SCOPE_PATTERN.test(scopeInput.storeScope) || scopeInput.storeScope !== storeScope) {
|
|
1000
|
+
throw Object.assign(new Error('scope.storeScope does not match this workbench; resync from health'), {
|
|
1001
|
+
status: 409, code: 'DELEGATION_SCOPE_MISMATCH', resync: true,
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
const contextId = cleanId(scopeInput.contextId, 'contextId');
|
|
1005
|
+
if (typeof input.requestId !== 'string' || !input.requestId.length || input.requestId.length > 256
|
|
1006
|
+
|| /[\u0000-\u001f\u007f]/.test(input.requestId)) {
|
|
1007
|
+
throw Object.assign(new Error('requestId must be a non-empty string without control characters'), {
|
|
1008
|
+
status: 400, code: 'DELEGATION_REQUEST_INVALID',
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
const requestId = input.requestId;
|
|
1012
|
+
if (input.contextMode !== undefined && input.contextMode !== 'parent-snapshot') {
|
|
1013
|
+
throw Object.assign(new Error('unsupported context mode'), { status: 400, code: 'DELEGATION_CONTEXT_INVALID' });
|
|
1014
|
+
}
|
|
1015
|
+
const text = typeof input.text === 'string' ? input.text.trim() : '';
|
|
1016
|
+
if (!text || text.length > 200000) {
|
|
1017
|
+
throw Object.assign(new Error('text must be a non-empty command of at most 200000 characters'), {
|
|
1018
|
+
status: 400, code: 'DELEGATION_TEXT_INVALID',
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
const agentId = normalizeAgentId(input.agentId === undefined || input.agentId === null || input.agentId === '' ? undefined : input.agentId);
|
|
1022
|
+
const effort = input.effort === undefined || input.effort === null || input.effort === '' ? undefined : normalizeEffort(input.effort);
|
|
1023
|
+
const permissionMode = input.permissionMode === undefined || input.permissionMode === null || input.permissionMode === ''
|
|
1024
|
+
? 'default' : input.permissionMode;
|
|
1025
|
+
if (!PERMISSION_MODES.includes(permissionMode)) {
|
|
1026
|
+
throw Object.assign(new Error('permissionMode is invalid'), { status: 400, code: 'DELEGATION_SELECTION_REJECTED' });
|
|
1027
|
+
}
|
|
1028
|
+
if (input.metadata !== undefined || input.inputOrigin !== undefined) {
|
|
1029
|
+
throw Object.assign(new Error('caller metadata does not grant trusted relations; omit metadata and inputOrigin'), {
|
|
1030
|
+
status: 400, code: 'DELEGATION_CALLER_IDENTITY_REJECTED',
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const parentState = this.runner.getState(parentId);
|
|
1035
|
+
if (!parentState) throw Object.assign(new Error('parent session not found'), { status: 404, code: 'DELEGATION_PARENT_NOT_FOUND' });
|
|
1036
|
+
if (parentState.projectSlug !== contextId) {
|
|
1037
|
+
throw Object.assign(new Error('scope.contextId does not match the parent session; resync from the session list'), {
|
|
1038
|
+
status: 409, code: 'DELEGATION_SCOPE_MISMATCH', resync: true,
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
if (!this._delegationSupported(agentId)) {
|
|
1042
|
+
throw Object.assign(new Error(`agent "${agentId}" cannot run delegated sessions with a server-reserved identity`), {
|
|
1043
|
+
status: 501, code: 'DELEGATION_AGENT_UNSUPPORTED',
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
// The selection resolves NOW so the payload hash covers the concrete
|
|
1047
|
+
// profile/model the child will run with (unknown profile → 404 here).
|
|
1048
|
+
const aiProfileId = input.aiProfileId === undefined ? (parentState.aiProfileId || null)
|
|
1049
|
+
: input.aiProfileId === null ? null : cleanId(input.aiProfileId, 'aiProfileId');
|
|
1050
|
+
const profile = this._runtimeProfile(aiProfileId, input.model === undefined ? undefined : input.model);
|
|
1051
|
+
const model = profile.mainModel || null;
|
|
1052
|
+
const workspace = this._delegationWorkspace(parentState);
|
|
1053
|
+
if (this.runner.listSessions({}).length >= this.maxSessions) {
|
|
1054
|
+
throw Object.assign(new Error('session limit reached'), { status: 429, code: 'DELEGATION_SESSION_LIMIT' });
|
|
1055
|
+
}
|
|
1056
|
+
const payloadHash = this._delegationPayloadHash({
|
|
1057
|
+
text, agentId, aiProfileId, model, permissionMode, effort,
|
|
1058
|
+
workspaceRef: workspace.ref, contextId, parentSessionId: parentId,
|
|
1059
|
+
contextMode: input.contextMode,
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
const record = this.delegations.ledger.reserve({
|
|
1063
|
+
scope: { storeScope, contextId, parentSessionId: parentId },
|
|
1064
|
+
requestId,
|
|
1065
|
+
payloadHash,
|
|
1066
|
+
workspace: { ref: workspace.ref, rootPath: workspace.rootPath, resolvedFrom: 'parent-session-record' },
|
|
1067
|
+
selection: { profileId: aiProfileId, model },
|
|
1068
|
+
...(input.contextMode ? { context: () => {
|
|
1069
|
+
this._history(parentId).flush();
|
|
1070
|
+
const events = [];
|
|
1071
|
+
let before;
|
|
1072
|
+
do {
|
|
1073
|
+
const page = this.loadHistory(parentId, { before, limit: 100 });
|
|
1074
|
+
if (page.incomplete) throw Object.assign(new Error('parent conversation history is incomplete'), { status: 409, code: 'PARENT_CONTEXT_INCOMPLETE' });
|
|
1075
|
+
events.unshift(...page.events);
|
|
1076
|
+
before = page.nextCursor || null;
|
|
1077
|
+
} while (before);
|
|
1078
|
+
return parentContext(events, text);
|
|
1079
|
+
} } : {}),
|
|
1080
|
+
});
|
|
1081
|
+
this._audit('delegation-reserve', {
|
|
1082
|
+
delegationId: record.delegationId, parentSessionId: parentId, contextId, childSessionId: record.childSessionId,
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
// Once a dispatch claim exists the input may already have reached a
|
|
1086
|
+
// model: never re-create, never re-send — report the durable truth.
|
|
1087
|
+
const prior = this._delegationDispatchState(record);
|
|
1088
|
+
if (prior.claim) return { accepted: true, ...this._delegationView(record) };
|
|
1089
|
+
|
|
1090
|
+
if (typeof this.runner.startDelegatedSession !== 'function') {
|
|
1091
|
+
throw Object.assign(new Error(`agent "${agentId}" cannot run delegated sessions with a server-reserved identity`), {
|
|
1092
|
+
status: 501, code: 'DELEGATION_AGENT_UNSUPPORTED',
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
let started;
|
|
1096
|
+
try {
|
|
1097
|
+
started = this.runner.startDelegatedSession({
|
|
1098
|
+
agentId,
|
|
1099
|
+
reservedSessionId: record.childSessionId,
|
|
1100
|
+
delegation: {
|
|
1101
|
+
delegationId: record.delegationId,
|
|
1102
|
+
parentSessionId: parentId,
|
|
1103
|
+
storeScope,
|
|
1104
|
+
contextId,
|
|
1105
|
+
},
|
|
1106
|
+
slug: contextId,
|
|
1107
|
+
projectPath: workspace.rootPath,
|
|
1108
|
+
kbPath: workspace.rootPath,
|
|
1109
|
+
workspaceRef: workspace.ref,
|
|
1110
|
+
aiProfile: profile,
|
|
1111
|
+
permissionMode,
|
|
1112
|
+
...(effort ? { effort } : {}),
|
|
1113
|
+
});
|
|
1114
|
+
} catch (error) {
|
|
1115
|
+
const code = error && typeof error.code === 'string' && error.code.startsWith('DELEGATION_')
|
|
1116
|
+
? error.code : 'DELEGATION_CHILD_CREATE_FAILED';
|
|
1117
|
+
this._recordDelegationFailure(record, code);
|
|
1118
|
+
if (error && error.status) throw error;
|
|
1119
|
+
throw Object.assign(new Error('the delegated child session could not be created; no model input was sent'), {
|
|
1120
|
+
status: 500, code,
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
if (!started || started.sessionId !== record.childSessionId) {
|
|
1124
|
+
this._recordDelegationFailure(record, 'DELEGATION_CHILD_IDENTITY_MISMATCH');
|
|
1125
|
+
throw Object.assign(new Error('the driver did not honor the reserved child identity; no model input was sent'), {
|
|
1126
|
+
status: 500, code: 'DELEGATION_CHILD_IDENTITY_MISMATCH',
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
this._adoptDelegatedChild(record.childSessionId, agentId);
|
|
1130
|
+
this.delegations.phases.recordChildCreated({
|
|
1131
|
+
delegationId: record.delegationId,
|
|
1132
|
+
childSessionId: record.childSessionId,
|
|
1133
|
+
workspaceRef: workspace.ref,
|
|
1134
|
+
});
|
|
1135
|
+
this._notifySessionChange(record.childSessionId);
|
|
1136
|
+
|
|
1137
|
+
// Guards mirroring send()'s own pre-input checks: a refusal here happens
|
|
1138
|
+
// provably before any input, so no claim is written and the reservation
|
|
1139
|
+
// simply reports awaiting-dispatch.
|
|
1140
|
+
const childState = this.runner.getState(record.childSessionId);
|
|
1141
|
+
if (!childState) {
|
|
1142
|
+
this._recordDelegationFailure(record, 'DELEGATION_CHILD_CREATE_FAILED');
|
|
1143
|
+
throw Object.assign(new Error('the delegated child session could not be created; no model input was sent'), {
|
|
1144
|
+
status: 500, code: 'DELEGATION_CHILD_CREATE_FAILED',
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
if (ACTIVE_STATES.has(childState.state) || this.pendingInputs.has(record.childSessionId)) {
|
|
1148
|
+
throw Object.assign(new Error('the delegated child already has an active turn; the command stays reserved'), {
|
|
1149
|
+
status: 409, code: 'DELEGATION_CHILD_BUSY',
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
if (this._activeCount() >= this.maxConcurrentRuns) {
|
|
1153
|
+
throw Object.assign(new Error('runtime concurrency limit reached'), { status: 429, code: 'DELEGATION_CONCURRENCY_LIMIT' });
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
// Durable dispatch claim BEFORE the send: exactly one owner across
|
|
1157
|
+
// Services and processes; a lost claim never sends.
|
|
1158
|
+
let claim;
|
|
1159
|
+
try {
|
|
1160
|
+
claim = this.delegations.phases.claimDispatch({
|
|
1161
|
+
delegationId: record.delegationId,
|
|
1162
|
+
attemptId: crypto.randomUUID(),
|
|
1163
|
+
ownerPid: process.pid,
|
|
1164
|
+
ownerEpoch: this.runtimeEpoch,
|
|
1165
|
+
});
|
|
1166
|
+
} catch (error) {
|
|
1167
|
+
throw Object.assign(new Error('the dispatch attempt could not be recorded; no model input was sent'), {
|
|
1168
|
+
status: 503, code: error && typeof error.code === 'string' ? error.code : 'DELEGATION_CLAIM_FAILED',
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
if (!claim.claimed) {
|
|
1172
|
+
return { accepted: true, ...this._delegationView(record) };
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
let outcome = 'dispatched';
|
|
1176
|
+
let failureCode = null;
|
|
1177
|
+
try {
|
|
1178
|
+
await this.send(record.childSessionId, text, {
|
|
1179
|
+
[PARENT_CONTEXT]: record.context ? record.context.text : '',
|
|
1180
|
+
inputOrigin: 'agent-delegation',
|
|
1181
|
+
permissionMode,
|
|
1182
|
+
...(effort ? { effort } : {}),
|
|
1183
|
+
});
|
|
1184
|
+
} catch (error) {
|
|
1185
|
+
outcome = 'dispatch-unknown';
|
|
1186
|
+
failureCode = error && typeof error.code === 'string' ? error.code : null;
|
|
1187
|
+
}
|
|
1188
|
+
try {
|
|
1189
|
+
this.delegations.phases.recordOutcome({
|
|
1190
|
+
delegationId: record.delegationId,
|
|
1191
|
+
attemptId: claim.claim.attemptId,
|
|
1192
|
+
outcome,
|
|
1193
|
+
});
|
|
1194
|
+
} catch {
|
|
1195
|
+
// The receipt could not be proven on disk after a possibly accepted
|
|
1196
|
+
// input: the only honest durable verdict is dispatch-unknown.
|
|
1197
|
+
outcome = 'dispatch-unknown';
|
|
1198
|
+
this.delegations.phases.abandonLiveClaim({
|
|
1199
|
+
delegationId: record.delegationId,
|
|
1200
|
+
attemptId: claim.claim.attemptId,
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
this._audit('delegation-dispatch', { delegationId: record.delegationId, childSessionId: record.childSessionId, outcome });
|
|
1204
|
+
const view = this._delegationView(record);
|
|
1205
|
+
if (failureCode && outcome === 'dispatch-unknown') view.dispatch.errorCode = failureCode;
|
|
1206
|
+
return { accepted: true, ...view };
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// GET /sessions/:parentId/delegations — trusted relations, durable command
|
|
1210
|
+
// facts and the real child status. Pure read: no session is created, no
|
|
1211
|
+
// input is sent, nothing is refreshed back to life.
|
|
1212
|
+
listDelegations(parentIdRaw, query = {}) {
|
|
1213
|
+
this._requireDelegations();
|
|
1214
|
+
const storeScope = this._requireNativeTaskScope();
|
|
1215
|
+
const parentId = cleanId(parentIdRaw, 'sessionId');
|
|
1216
|
+
const parentState = this.runner.getState(parentId);
|
|
1217
|
+
if (!parentState) throw Object.assign(new Error('parent session not found'), { status: 404, code: 'DELEGATION_PARENT_NOT_FOUND' });
|
|
1218
|
+
if (typeof query.storeScope !== 'string' || !STORE_SCOPE_PATTERN.test(query.storeScope)) {
|
|
1219
|
+
throw Object.assign(new Error('storeScope is invalid'), { status: 400, code: 'DELEGATION_SCOPE_INVALID' });
|
|
1220
|
+
}
|
|
1221
|
+
if (typeof query.contextId !== 'string' || !query.contextId) {
|
|
1222
|
+
throw Object.assign(new Error('contextId is required'), { status: 400, code: 'DELEGATION_SCOPE_INVALID' });
|
|
1223
|
+
}
|
|
1224
|
+
const contextId = cleanId(query.contextId, 'contextId');
|
|
1225
|
+
if (query.storeScope !== storeScope || contextId !== parentState.projectSlug) {
|
|
1226
|
+
throw Object.assign(new Error('delegation scope does not match this session; resync from the session list'), {
|
|
1227
|
+
status: 409, code: 'DELEGATION_SCOPE_MISMATCH', resync: true,
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
const records = this.delegations.ledger.list({ storeScope, contextId, parentSessionId: parentId });
|
|
1231
|
+
return {
|
|
1232
|
+
scope: { storeScope, contextId, parentSessionId: parentId },
|
|
1233
|
+
delegations: records.map(record => this._delegationView(record)),
|
|
1234
|
+
};
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
subscribe(sessionId, afterSequence, onEvent) {
|
|
1238
|
+
const id = cleanId(sessionId, 'sessionId');
|
|
1239
|
+
this._attachNativeTasks(id);
|
|
1240
|
+
this._trackHistory(id);
|
|
1241
|
+
return subscribeNormalized(this.runner, id, Number(afterSequence || 0), onEvent);
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
_notifySessionChange(id, event) {
|
|
1245
|
+
const state = this.runner.getState(id);
|
|
1246
|
+
if (!state) return;
|
|
1247
|
+
const session = this.runner.getSession(id);
|
|
1248
|
+
if (event?.type === 'claude/user-prompt' && event.text && session && !session.title) session.title = event.text.replace(/\s+/g, ' ').slice(0, 80);
|
|
1249
|
+
const summary = this._summary(state, ACTIVE_STATES.has(state.state));
|
|
1250
|
+
if (session?.title) summary.title = session.title;
|
|
1251
|
+
const signature = JSON.stringify(summary);
|
|
1252
|
+
if (this.sessionSignatures.get(id) === signature) return;
|
|
1253
|
+
this.sessionSignatures.set(id, signature);
|
|
1254
|
+
const change = { type: 'workbench/session-changed', revision: ++this.sessionRevision, contextId: summary.contextId, session: summary };
|
|
1255
|
+
this.sessionChangeLog.push(change);
|
|
1256
|
+
if (this.sessionChangeLog.length > 1000) this.sessionChangeLog.shift();
|
|
1257
|
+
for (const listener of this.sessionChangeListeners) if (!listener.contextId || listener.contextId === summary.contextId) {
|
|
1258
|
+
try { listener.callback(change); } catch { /* disconnected subscriber */ }
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
_notifySessionDeleted(state) {
|
|
1263
|
+
const summary = this._summary(state, false);
|
|
1264
|
+
this.sessionSignatures.delete(state.sessionId);
|
|
1265
|
+
const change = { type: 'workbench/session-changed', deleted: true, revision: ++this.sessionRevision, contextId: summary.contextId, session: summary };
|
|
1266
|
+
this.sessionChangeLog.push(change);
|
|
1267
|
+
if (this.sessionChangeLog.length > 1000) this.sessionChangeLog.shift();
|
|
1268
|
+
for (const listener of this.sessionChangeListeners) if (!listener.contextId || listener.contextId === summary.contextId) {
|
|
1269
|
+
try { listener.callback(change); } catch { /* disconnected */ }
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
subscribeSessionChanges(contextId, afterRevision, callback) {
|
|
1274
|
+
const id = contextId === '*' ? null : cleanId(contextId, 'contextId');
|
|
1275
|
+
const sessions = this.runner.listSessions(id ? { projectSlug: id } : {});
|
|
1276
|
+
for (const session of sessions) this._trackHistory(session.sessionId);
|
|
1277
|
+
const after = Math.max(0, Number(afterRevision) || 0);
|
|
1278
|
+
const first = this.sessionChangeLog[0]?.revision || 0;
|
|
1279
|
+
if (after > this.sessionRevision || after && first > after + 1) {
|
|
1280
|
+
callback({ type: 'workbench/resync', revision: this.sessionRevision, contextId: id });
|
|
1281
|
+
} else if (after) {
|
|
1282
|
+
for (const change of this.sessionChangeLog) if (change.revision > after && (!id || change.session.contextId === id)) callback(change);
|
|
1283
|
+
} else callback({ type: 'workbench/sessions-snapshot', revision: this.sessionRevision, contextId: id, sessions: id ? this.listSessions(id, { includeArchived: true }) : sessions.map(s => this._summary(s, ACTIVE_STATES.has(s.state))) });
|
|
1284
|
+
const listener = { contextId: id, callback };
|
|
1285
|
+
this.sessionChangeListeners.add(listener);
|
|
1286
|
+
return () => this.sessionChangeListeners.delete(listener);
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
async listCommands(sessionId) {
|
|
1290
|
+
const id = cleanId(sessionId, 'sessionId');
|
|
1291
|
+
const state = this.runner.getState(id);
|
|
1292
|
+
if (!state) throw Object.assign(new Error('session not found'), { status: 404 });
|
|
1293
|
+
if (typeof this.runner.listSupportedCommands !== 'function') return { commands: [] };
|
|
1294
|
+
const profile = this._profileForSession(id, state);
|
|
1295
|
+
const commands = await this.runner.listSupportedCommands(id, profile);
|
|
1296
|
+
return { commands: Array.isArray(commands) ? commands : [] };
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
readAttachment(sessionId, attachmentId) {
|
|
1300
|
+
const id = cleanId(sessionId, 'sessionId');
|
|
1301
|
+
if (!this.runner.getState(id)) throw Object.assign(new Error('session not found'), { status: 404 });
|
|
1302
|
+
const result = this.attachments && this.attachments.read(id, attachmentId);
|
|
1303
|
+
if (!result) throw Object.assign(new Error('attachment not found'), { status: 404 });
|
|
1304
|
+
return result;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
async send(sessionId, text, options = {}, attachmentInput = []) {
|
|
1308
|
+
if (this.destroyed) throw Object.assign(new Error('workbench is shut down'), { status: 503 });
|
|
1309
|
+
const id = cleanId(sessionId, 'sessionId');
|
|
1310
|
+
const state = this.runner.getState(id);
|
|
1311
|
+
if (!state) throw Object.assign(new Error('session not found'), { status: 404 });
|
|
1312
|
+
// A direct input can be the first Service contact for an existing or
|
|
1313
|
+
// restored session: the trusted native-task scope binds before any SDK
|
|
1314
|
+
// input can stream, without relying on an earlier snapshot request.
|
|
1315
|
+
this._attachNativeTasks(id);
|
|
1316
|
+
if (ACTIVE_STATES.has(state.state) || this.pendingInputs.has(id)) {
|
|
1317
|
+
throw Object.assign(new Error('session already has an active turn'), { status: 409 });
|
|
1318
|
+
}
|
|
1319
|
+
if (this._activeCount() - (state.backgroundTaskCount > 0 ? 1 : 0) >= this.maxConcurrentRuns) {
|
|
1320
|
+
throw Object.assign(new Error('runtime concurrency limit reached'), { status: 429 });
|
|
1321
|
+
}
|
|
1322
|
+
const value = String(text || '').trim();
|
|
1323
|
+
const attachments = normalizeAttachments(attachmentInput);
|
|
1324
|
+
if (!value && !attachments.length) throw Object.assign(new Error('text or attachments are required'), { status: 400 });
|
|
1325
|
+
if (value.length > 200000) throw Object.assign(new Error('input exceeds 200000 characters'), { status: 413 });
|
|
1326
|
+
const profile = this._profileForSession(id, state);
|
|
1327
|
+
const session = this.runner.getSession(id);
|
|
1328
|
+
this._trackHistory(id);
|
|
1329
|
+
const request = { aborted: false };
|
|
1330
|
+
this.pendingInputs.set(id, request);
|
|
1331
|
+
try {
|
|
1332
|
+
const knowledge = this.knowledge ? await this.knowledge.retrieve(session.projectPath || session.kbPath || state.projectPath, value, options?.knowledgeScope, { sessionId: id }) : null;
|
|
1333
|
+
if (request.aborted) throw Object.assign(new Error('task cancelled before agent launch'), { status: 409 });
|
|
1334
|
+
if (knowledge) this.knowledgeBySession.set(id, knowledge);
|
|
1335
|
+
if (knowledge) this.knowledge.remember?.(id, knowledge);
|
|
1336
|
+
const previousEventCount = session && session.outputBuffer ? session.outputBuffer.length : 0;
|
|
1337
|
+
const captureTurn = this.conversationCapture && this.captureSettings?.read().terminalConversations ? {
|
|
1338
|
+
projectPath: session.projectPath || session.kbPath, source: this._summary(state, false).agentId,
|
|
1339
|
+
sessionId: id, turnId: `turn_${crypto.randomUUID()}`, inputOrigin: session.metadata?.inputOrigin === 'agent-delegation' ? 'agent-delegation' : normalizeInputOrigin(options?.inputOrigin || session.metadata?.inputOrigin),
|
|
1340
|
+
} : null;
|
|
1341
|
+
if (captureTurn) this._capture(captureTurn, 'user_prompt', value);
|
|
1342
|
+
const stored = this.attachments ? this.attachments.saveMany(id, attachments) : attachments;
|
|
1343
|
+
const displayAttachments = stored.map(item => ({
|
|
1344
|
+
id: item.id, name: item.name, mediaType: item.mediaType, size: item.size,
|
|
1345
|
+
width: item.width, height: item.height,
|
|
1346
|
+
url: `${this.apiPrefix}/sessions/${encodeURIComponent(id)}/attachments/${encodeURIComponent(item.id)}`,
|
|
1347
|
+
}));
|
|
1348
|
+
const runtimeAttachments = attachments.map((item, index) => ({ ...displayAttachments[index], data: item.data }));
|
|
1349
|
+
const inputOrigin = session.metadata?.inputOrigin === 'agent-delegation' ? 'agent-delegation' : normalizeInputOrigin(options?.inputOrigin || session.metadata?.inputOrigin);
|
|
1350
|
+
try { await this.runner.sendInput(id, value, profile, { ...(options || {}), parentContext: options[PARENT_CONTEXT] || '', inputOrigin, knowledgeContext: knowledge?.prompt || '', attachments: runtimeAttachments, displayAttachments }); }
|
|
1351
|
+
catch (error) { this._runtimeFailure(id, error); if (captureTurn) this._capture(captureTurn, 'session_end', undefined, 'failed'); throw error; }
|
|
1352
|
+
this._watchTurn(id, previousEventCount, captureTurn);
|
|
1353
|
+
this._audit('input', { sessionId: id, contextId: state.projectSlug, characters: value.length, attachments: attachments.length, attachmentBytes: attachments.reduce((sum, item) => sum + item.size, 0) });
|
|
1354
|
+
return { accepted: true, sessionId: id, state: this.runner.getState(id)?.state, knowledge: knowledge ? { ...knowledge, prompt: undefined } : null };
|
|
1355
|
+
} finally { this.pendingInputs.delete(id); }
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
knowledgeContext(sessionId) {
|
|
1359
|
+
const id = cleanId(sessionId, 'sessionId');
|
|
1360
|
+
if (!this.runner.getState(id)) throw Object.assign(new Error('session not found'), { status: 404 });
|
|
1361
|
+
const value = this.knowledgeBySession.get(id) || this.knowledge?.saved?.(id);
|
|
1362
|
+
return value ? { ...value, prompt: undefined } : { status: 'not-retrieved', references: [] };
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
resolvePermission(sessionId, requestId, decision) {
|
|
1366
|
+
const id = cleanId(sessionId, 'sessionId');
|
|
1367
|
+
const rid = cleanId(requestId, 'requestId');
|
|
1368
|
+
this._trackHistory(id);
|
|
1369
|
+
const result = this.runner.resolvePermission(id, rid, decision || {});
|
|
1370
|
+
this._audit('permission-resolve', { sessionId: id, requestId: rid, allow: decision && decision.allow === true });
|
|
1371
|
+
return result;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
abort(sessionId, reason = 'user-abort') {
|
|
1375
|
+
const id = cleanId(sessionId, 'sessionId');
|
|
1376
|
+
if (!this.runner.getState(id)) throw Object.assign(new Error('session not found'), { status: 404 });
|
|
1377
|
+
this._trackHistory(id);
|
|
1378
|
+
if (this.pendingInputs.has(id)) this.pendingInputs.get(id).aborted = true;
|
|
1379
|
+
this.runner.abort(id, reason);
|
|
1380
|
+
// A net for runners that emit no aborted event: normally the monitor
|
|
1381
|
+
// already released itself on that event, having recorded session_end once.
|
|
1382
|
+
this._clearTurnMonitor(id);
|
|
1383
|
+
this._audit('abort', { sessionId: id, reason });
|
|
1384
|
+
return { accepted: true, sessionId: id };
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
setPermissionMode(sessionId, mode) {
|
|
1388
|
+
const id = cleanId(sessionId, 'sessionId');
|
|
1389
|
+
if (!PERMISSION_MODES.includes(mode)) throw Object.assign(new Error('invalid permission mode'), { status: 400 });
|
|
1390
|
+
this._trackHistory(id);
|
|
1391
|
+
const session = this.runner.getSession(id);
|
|
1392
|
+
if (!session) throw Object.assign(new Error('session not found'), { status: 404 });
|
|
1393
|
+
session.permissionMode = mode;
|
|
1394
|
+
this._audit('permission-mode', { sessionId: id, mode });
|
|
1395
|
+
return { sessionId: id, permissionMode: mode };
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
// Rebind an existing session to another AI profile/model (or back to the
|
|
1399
|
+
// agent's own login with aiProfileId null). The session and its history
|
|
1400
|
+
// survive; the next turn simply runs under the new selection.
|
|
1401
|
+
updateSessionSelection(sessionId, aiProfileId, model) {
|
|
1402
|
+
const id = cleanId(sessionId, 'sessionId');
|
|
1403
|
+
const state = this.runner.getState(id);
|
|
1404
|
+
if (!state) throw Object.assign(new Error('session not found'), { status: 404 });
|
|
1405
|
+
if (ACTIVE_STATES.has(state.state) || state.backgroundTaskCount > 0) {
|
|
1406
|
+
throw Object.assign(new Error('session already has an active turn'), { status: 409 });
|
|
1407
|
+
}
|
|
1408
|
+
this._trackHistory(id);
|
|
1409
|
+
// _runtimeProfile resolves and validates the profile (404 unknown id,
|
|
1410
|
+
// 400 disabled/missing credential/invalid model) exactly like startSession.
|
|
1411
|
+
const profileId = aiProfileId ? cleanId(aiProfileId, 'aiProfileId') : null;
|
|
1412
|
+
const profile = this._runtimeProfile(profileId, model === undefined ? undefined : model);
|
|
1413
|
+
if (typeof this.runner.updateSelection === 'function') {
|
|
1414
|
+
this.runner.updateSelection(id, profile, profile.mainModel || null);
|
|
1415
|
+
} else {
|
|
1416
|
+
const session = this.runner.getSession(id);
|
|
1417
|
+
if (!session) throw Object.assign(new Error('session not found'), { status: 404 });
|
|
1418
|
+
session.aiProfileId = profileId;
|
|
1419
|
+
session.selectedModel = profile.mainModel || null;
|
|
1420
|
+
}
|
|
1421
|
+
this._audit('selection-update', { sessionId: id, contextId: state.projectSlug, profileId, model: profile.mainModel || null });
|
|
1422
|
+
return this._summary(this.runner.getState(id), true);
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
async testProfile(profileId) {
|
|
1426
|
+
const id = cleanId(profileId, 'profileId');
|
|
1427
|
+
const profile = this.profiles.runtimeProfile(id);
|
|
1428
|
+
return this._runProfileTest(profile, id);
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
async testProfileDraft(input = {}) {
|
|
1432
|
+
if (!input || typeof input !== 'object' || Array.isArray(input) || !input.profile) {
|
|
1433
|
+
throw Object.assign(new Error('profile is required'), { status: 400 });
|
|
1434
|
+
}
|
|
1435
|
+
const profile = this.profiles.runtimeDraft(input.profile, input.credential);
|
|
1436
|
+
return this._runProfileTest(profile, profile.id);
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
// Detect the models a provider draft can actually use (endpoint model list
|
|
1440
|
+
// joined with models.dev capability metadata). Uses the same draft+credential
|
|
1441
|
+
// resolution as testProfileDraft, so a blank credential falls back to the
|
|
1442
|
+
// stored secret of an already-saved profile.
|
|
1443
|
+
async detectProfileModels(input = {}) {
|
|
1444
|
+
if (!input || typeof input !== 'object' || Array.isArray(input) || !input.profile) {
|
|
1445
|
+
throw Object.assign(new Error('profile is required'), { status: 400 });
|
|
1446
|
+
}
|
|
1447
|
+
if (!this.modelDetection) {
|
|
1448
|
+
throw Object.assign(new Error('model detection is unavailable'), { status: 501 });
|
|
1449
|
+
}
|
|
1450
|
+
// Detection precedes model configuration, so an empty model list is fine here.
|
|
1451
|
+
const profile = this.profiles.runtimeDraft(input.profile, input.credential, { requireModel: false });
|
|
1452
|
+
const result = await this.modelDetection.detectModels({ profile });
|
|
1453
|
+
this._audit('profile-detect-models', { profileId: profile.id, count: result.models.length, sources: Object.keys(result.sources) });
|
|
1454
|
+
return result;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
async _runProfileTest(profile, profileId) {
|
|
1458
|
+
const id = cleanId(profileId, 'profileId');
|
|
1459
|
+
const contextId = `profile-test-${Date.now().toString(36)}`;
|
|
1460
|
+
const startedAt = Date.now();
|
|
1461
|
+
const started = this.runner.startChatSession({
|
|
1462
|
+
slug: contextId, projectPath: this.testWorkspace, kbPath: this.testWorkspace,
|
|
1463
|
+
aiProfile: profile, permissionMode: 'default',
|
|
1464
|
+
});
|
|
1465
|
+
let firstEventMs = null;
|
|
1466
|
+
let preview = '';
|
|
1467
|
+
let unsubscribe = null;
|
|
1468
|
+
try {
|
|
1469
|
+
const outcome = await new Promise((resolve, reject) => {
|
|
1470
|
+
const timeout = setTimeout(() => reject(new Error('Claude Code profile test timed out')), Math.min(profile.timeoutMs, 300000));
|
|
1471
|
+
unsubscribe = this.runner.subscribe(started.sessionId, event => {
|
|
1472
|
+
if (firstEventMs == null && event.type !== 'claude/session-ready') firstEventMs = Date.now() - startedAt;
|
|
1473
|
+
if (event.type === 'claude/result') {
|
|
1474
|
+
clearTimeout(timeout);
|
|
1475
|
+
if (event.isError) {
|
|
1476
|
+
reject(new Error(String(event.result || event.message || event.error || 'Claude Code returned an error result')));
|
|
1477
|
+
} else {
|
|
1478
|
+
preview = String(event.result || event.text || '').slice(0, 500);
|
|
1479
|
+
resolve(event);
|
|
1480
|
+
}
|
|
1481
|
+
} else if (event.type === 'claude/error') {
|
|
1482
|
+
clearTimeout(timeout); reject(new Error(String(event.message || event.error || 'Claude Code runtime error')));
|
|
1483
|
+
} else if (event.type === 'claude/aborted') {
|
|
1484
|
+
clearTimeout(timeout); reject(new Error('Claude Code profile test was aborted'));
|
|
1485
|
+
}
|
|
1486
|
+
});
|
|
1487
|
+
this.runner.sendInput(started.sessionId, 'Reply with exactly: WORKBENCH_RUNTIME_OK', profile, { permissionMode: 'default' }).catch(error => {
|
|
1488
|
+
clearTimeout(timeout); reject(error);
|
|
1489
|
+
});
|
|
1490
|
+
});
|
|
1491
|
+
const state = this.runner.getState(started.sessionId);
|
|
1492
|
+
return {
|
|
1493
|
+
ok: true, runtime: 'claude-code', runtimeVerified: true, profileId: id,
|
|
1494
|
+
model: outcome.model || state && state.model || profile.models.default,
|
|
1495
|
+
firstEventMs: firstEventMs == null ? Date.now() - startedAt : firstEventMs,
|
|
1496
|
+
durationMs: Date.now() - startedAt, resultPreview: redact(preview),
|
|
1497
|
+
};
|
|
1498
|
+
} finally {
|
|
1499
|
+
try { unsubscribe && unsubscribe(); } catch {}
|
|
1500
|
+
try { this.runner.abort(started.sessionId); } catch {}
|
|
1501
|
+
try { this.runner.deleteSession(started.sessionId); } catch {}
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
destroy() {
|
|
1506
|
+
if (this.destroyPromise) return this.destroyPromise;
|
|
1507
|
+
this.destroyed = true;
|
|
1508
|
+
for (const request of this.pendingInputs.values()) request.aborted = true;
|
|
1509
|
+
this.sessionChangeListeners.clear();
|
|
1510
|
+
this.agentDiscovery?.close?.();
|
|
1511
|
+
this.conversationCapture?.close();
|
|
1512
|
+
if (this.archiveSweeper) { clearInterval(this.archiveSweeper); this.archiveSweeper = null; }
|
|
1513
|
+
for (const sessionId of this.turnMonitors.keys()) this._clearTurnMonitor(sessionId);
|
|
1514
|
+
for (const session of this.runner.listSessions({})) {
|
|
1515
|
+
if (ACTIVE_STATES.has(session.state)) try { this.runner.abort(session.sessionId); } catch {}
|
|
1516
|
+
}
|
|
1517
|
+
this.destroyPromise = Promise.resolve(this.runner.destroy?.()).finally(() => {
|
|
1518
|
+
this.sessionChangeListeners.clear();
|
|
1519
|
+
for (const { history, unsubscribe } of this.histories.values()) { unsubscribe(); history.close(); }
|
|
1520
|
+
this.histories.clear();
|
|
1521
|
+
// Closing the Service unbinds only its OWN committer bindings: the
|
|
1522
|
+
// runner marks those runtimes non-sticky unavailable (rebindable by a
|
|
1523
|
+
// successor), while bindings owned by another instance are refused
|
|
1524
|
+
// there and stay untouched.
|
|
1525
|
+
for (const sessionId of [...this.nativeCommitters.keys()]) this._detachNativeTasks(sessionId);
|
|
1526
|
+
});
|
|
1527
|
+
return this.destroyPromise;
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
_runtimeProfile(profileId, selectedModel) {
|
|
1531
|
+
const profile = profileId ? this.profiles.runtimeProfile(profileId)
|
|
1532
|
+
: { id: null, models: { default: '' }, timeoutMs: 300000 };
|
|
1533
|
+
const model = selectedModel == null ? profile.models.default : String(selectedModel).trim();
|
|
1534
|
+
if (model.length > 128 || /[\r\n\x00]/.test(model)) throw Object.assign(new Error('model is invalid'), { status: 400 });
|
|
1535
|
+
return { ...profile, models: { ...profile.models, default: model }, mainModel: model };
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
_profileForSession(id, state) {
|
|
1539
|
+
const session = this.runner.getSession(id);
|
|
1540
|
+
const model = session && Object.prototype.hasOwnProperty.call(session, 'selectedModel')
|
|
1541
|
+
? session.selectedModel : state.selectedModel || state.model;
|
|
1542
|
+
return this._runtimeProfile(state.aiProfileId, model);
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
_summary(session, active) {
|
|
1546
|
+
const agentId = session.agentId
|
|
1547
|
+
|| (typeof this.runner.agentOf === 'function' ? this.runner.agentOf(session.sessionId) : null)
|
|
1548
|
+
|| this.sessionAgents.get(session.sessionId)
|
|
1549
|
+
|| 'claude-code';
|
|
1550
|
+
return {
|
|
1551
|
+
sessionId: session.sessionId,
|
|
1552
|
+
contextId: session.projectSlug,
|
|
1553
|
+
projectId: session.projectSlug,
|
|
1554
|
+
agentId,
|
|
1555
|
+
title: session.title || `Session ${String(session.sessionId).slice(-8)}`,
|
|
1556
|
+
active: Boolean(active), state: session.state, model: session.model || null,
|
|
1557
|
+
aiProfileId: session.aiProfileId || null, permissionMode: session.permissionMode || 'default',
|
|
1558
|
+
selectedModel: session.selectedModel || this.runner.getSession(session.sessionId)?.selectedModel || null,
|
|
1559
|
+
authSource: session.aiProfileId ? 'profile-injected' : 'agent-owned',
|
|
1560
|
+
providerName: session.aiProfileId ? this.profiles.get(session.aiProfileId)?.name || 'Unavailable provider' : AGENTS[agentId].label,
|
|
1561
|
+
archived: Boolean(this.sessionArchive && this.sessionArchive.isArchived(session.sessionId)),
|
|
1562
|
+
turns: Number(session.turns || 0), startedAt: session.startedAt,
|
|
1563
|
+
tasks: Array.isArray(session.tasks) ? session.tasks : [],
|
|
1564
|
+
backgroundTaskCount: Number(session.backgroundTaskCount || 0),
|
|
1565
|
+
// W-1: prefer the driver's live activity marker (refreshed at turn
|
|
1566
|
+
// end); endedAt/startAt only predate it for legacy records.
|
|
1567
|
+
updatedAt: session.updatedAt || session.endedAt || session.startedAt, connectionState: 'connected',
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
// Approximate context occupancy for the capacity bubble. Token totals come
|
|
1572
|
+
// from the driver; the per-category split is a characters/4 estimate over
|
|
1573
|
+
// the session event log and is explicitly flagged as such.
|
|
1574
|
+
contextUsage(sessionId) {
|
|
1575
|
+
const id = cleanId(sessionId, 'sessionId');
|
|
1576
|
+
const state = this.runner.getState(id);
|
|
1577
|
+
if (!state) throw Object.assign(new Error('session not found'), { status: 404 });
|
|
1578
|
+
const usage = this.runner.getSessionTokenUsage(id) || {};
|
|
1579
|
+
const contextWindow = this._contextWindowFor(state.model, this._sessionProfileContextWindow(id, state));
|
|
1580
|
+
const breakdown = this._estimateBreakdown(id);
|
|
1581
|
+
const used = Number(usage.used || usage.inputTokens || 0) || 0;
|
|
1582
|
+
return {
|
|
1583
|
+
sessionId: id,
|
|
1584
|
+
agentId: this._summary(state, false).agentId,
|
|
1585
|
+
model: state.model || null,
|
|
1586
|
+
contextWindow,
|
|
1587
|
+
used,
|
|
1588
|
+
percent: contextWindow ? Math.round((used / contextWindow) * 1000) / 10 : null,
|
|
1589
|
+
inputTokens: Number(usage.inputTokens || 0) || 0,
|
|
1590
|
+
outputTokens: Number(usage.outputTokens || 0) || 0,
|
|
1591
|
+
breakdown,
|
|
1592
|
+
estimated: true,
|
|
1593
|
+
};
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
_sessionProfileContextWindow(sessionId, state) {
|
|
1597
|
+
try {
|
|
1598
|
+
const profile = this._profileForSession(sessionId, state);
|
|
1599
|
+
return profile ? Number(profile.contextWindow) || 0 : 0;
|
|
1600
|
+
} catch { return 0; }
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
_contextWindowFor(model, profileContextWindow) {
|
|
1604
|
+
if (!model && !profileContextWindow) return 200000;
|
|
1605
|
+
if (this.modelContextWindows && typeof this.modelContextWindows.resolveContextWindow === 'function') {
|
|
1606
|
+
try {
|
|
1607
|
+
// The profile store defaults contextWindow to 200000; that default must
|
|
1608
|
+
// not shadow the recognition table (e.g. MiniMax → 1M). Only an explicit
|
|
1609
|
+
// non-default override wins over auto-detection.
|
|
1610
|
+
const explicit = Number(profileContextWindow) > 0 && Number(profileContextWindow) !== 200000 ? Number(profileContextWindow) : undefined;
|
|
1611
|
+
const resolved = this.modelContextWindows.resolveContextWindow({ profileContextWindow: explicit, model, fallback: 200000 });
|
|
1612
|
+
if (resolved) return resolved;
|
|
1613
|
+
} catch { /* fall through to the default */ }
|
|
1614
|
+
}
|
|
1615
|
+
return Number(profileContextWindow) > 0 ? Number(profileContextWindow) : 200000;
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
_estimateBreakdown(sessionId) {
|
|
1619
|
+
const session = this.runner.getSession(sessionId);
|
|
1620
|
+
const buckets = { messages: 0, tools: 0, other: 0 };
|
|
1621
|
+
if (!session || !Array.isArray(session.outputBuffer)) return buckets;
|
|
1622
|
+
for (const event of session.outputBuffer) {
|
|
1623
|
+
const text = String(event.text || event.result || event.output || '');
|
|
1624
|
+
const tokens = Math.ceil(text.length / 4);
|
|
1625
|
+
if (event.type === 'claude/user-prompt' || event.type === 'claude/text-delta') buckets.messages += tokens;
|
|
1626
|
+
else if (event.type === 'claude/tool-use' || event.type === 'claude/tool-result') buckets.tools += tokens;
|
|
1627
|
+
else buckets.other += tokens;
|
|
1628
|
+
}
|
|
1629
|
+
return buckets;
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
_activeCount(sessions = null) { const list = Array.isArray(sessions) ? sessions : this.runner.listSessions({}); const active = new Set(list.filter(item => ACTIVE_STATES.has(item.state) || item.backgroundTaskCount > 0).map(item => item.sessionId)); return active.size + [...this.pendingInputs.keys()].filter(id => !active.has(id)).length; }
|
|
1633
|
+
_capture(turn, eventType, content, outcome) {
|
|
1634
|
+
try { this.conversationCapture.enqueue(turn, eventType, content, outcome); }
|
|
1635
|
+
catch { this._audit('capture-gap', { sessionId: turn.sessionId, code: 'CAPTURE_QUEUE_UNAVAILABLE' }); }
|
|
1636
|
+
}
|
|
1637
|
+
_watchTurn(sessionId, previousEventCount, captureTurn) {
|
|
1638
|
+
this._clearTurnMonitor(sessionId);
|
|
1639
|
+
let seen = 0;
|
|
1640
|
+
let unsubscribe = null;
|
|
1641
|
+
let completed = false;
|
|
1642
|
+
let responseText = '';
|
|
1643
|
+
let released = false;
|
|
1644
|
+
// The monitor owns capture and failure diagnostics on its own; there is no
|
|
1645
|
+
// whole-task timer anymore, so keeping it must not depend on one.
|
|
1646
|
+
const release = () => {
|
|
1647
|
+
if (released) return;
|
|
1648
|
+
released = true;
|
|
1649
|
+
if (unsubscribe) { try { unsubscribe(); } catch {} }
|
|
1650
|
+
this.turnMonitors.delete(sessionId);
|
|
1651
|
+
};
|
|
1652
|
+
unsubscribe = this.runner.subscribe(sessionId, event => {
|
|
1653
|
+
if (released) return;
|
|
1654
|
+
seen += 1;
|
|
1655
|
+
if (seen <= previousEventCount) return;
|
|
1656
|
+
if (event?.parentToolUseId) return;
|
|
1657
|
+
if (event?.type === 'claude/text-delta') responseText += String(event.text || '');
|
|
1658
|
+
if (['claude/result', 'claude/turn-end', 'claude/error', 'claude/aborted'].includes(event && event.type)) {
|
|
1659
|
+
if (event.type === 'claude/error' || event.type === 'claude/result' && event.isError) this._runtimeFailure(sessionId, new Error(String(event.message || event.error || event.result || 'Agent 运行失败。')));
|
|
1660
|
+
if (captureTurn && !completed) {
|
|
1661
|
+
completed = true;
|
|
1662
|
+
if (event.type === 'claude/result' && !event.isError && typeof event.result === 'string' && event.result) this._capture(captureTurn, 'assistant_response', event.result);
|
|
1663
|
+
if (event.type === 'claude/turn-end' && !event.exitCode && responseText) this._capture(captureTurn, 'assistant_response', responseText);
|
|
1664
|
+
this._capture(captureTurn, 'session_end', undefined, event.type === 'claude/result' && !event.isError || event.type === 'claude/turn-end' && !event.exitCode ? 'completed' : 'failed');
|
|
1665
|
+
}
|
|
1666
|
+
release();
|
|
1667
|
+
}
|
|
1668
|
+
});
|
|
1669
|
+
// The synchronous replay may deliver the terminal event while
|
|
1670
|
+
// `unsubscribe` is still unassigned, so release() could not detach yet.
|
|
1671
|
+
if (released) { try { unsubscribe(); } catch {} }
|
|
1672
|
+
else this.turnMonitors.set(sessionId, unsubscribe);
|
|
1673
|
+
}
|
|
1674
|
+
_clearTurnMonitor(sessionId) {
|
|
1675
|
+
const unsubscribe = this.turnMonitors.get(sessionId);
|
|
1676
|
+
if (unsubscribe) try { unsubscribe(); } catch {}
|
|
1677
|
+
this.turnMonitors.delete(sessionId);
|
|
1678
|
+
}
|
|
1679
|
+
_runtimeFailure(sessionId, error) {
|
|
1680
|
+
const state = this.runner.getState(sessionId);
|
|
1681
|
+
this.diagnostics?.error('agent.runtime.failed', 'Agent 会话运行失败。', { projectId: /^project-[A-Za-z0-9._-]+$/.test(state?.projectSlug || '') ? state.projectSlug : undefined, runId: sessionId, phase: 'runtime', error, context: { agent: this.runner.agentOf?.(sessionId) || this.sessionAgents.get(sessionId) || state?.agentId || 'claude-code', sessionId } });
|
|
1682
|
+
}
|
|
1683
|
+
_audit(action, detail) {
|
|
1684
|
+
if (!this.auditFile) return;
|
|
1685
|
+
fs.mkdirSync(path.dirname(this.auditFile), { recursive: true });
|
|
1686
|
+
fs.appendFileSync(this.auditFile, `${JSON.stringify({ at: new Date().toISOString(), action, ...detail })}\n`, 'utf8');
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
function redact(value) {
|
|
1691
|
+
return String(value || '').replace(/(?:sk|key|token)-[a-zA-Z0-9._-]{8,}/gi, '[REDACTED]');
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
function normalizeInputOrigin(value) { return ['human', 'agent-delegation'].includes(value) ? value : 'unknown'; }
|
|
1695
|
+
|
|
1696
|
+
module.exports = { WorkbenchService, ACTIVE_STATES, redact };
|