dsh-panel-control 0.3.15 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/client.js +1 -6
- package/index.js +726 -804
- package/package.json +1 -1
- package/scripts/host-smoke.mjs +13 -1
package/index.js
CHANGED
|
@@ -1,804 +1,726 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Host half of dsh-panel-control.
|
|
3
|
-
*
|
|
4
|
-
* The client half manages the panel-navigation area of the sidebar. Everything
|
|
5
|
-
* it needs is local except one thing: "auto classify" asks a model to file the
|
|
6
|
-
* entries into groups, and only the Host can reach a model.
|
|
7
|
-
*
|
|
8
|
-
* So this half does exactly one job: it registers one POST route under the
|
|
9
|
-
* /api carrier every DSH Host already serves, and answers it with a single
|
|
10
|
-
* non-streaming completion from the profile's default model. The Client calls
|
|
11
|
-
* it through `ctx.connection.rpc.call`, so authentication, the request
|
|
12
|
-
* envelope and the response envelope stay the Host's own, not ours.
|
|
13
|
-
*
|
|
14
|
-
* Nothing is streamed back: the caller needs a small JSON object, and the reply
|
|
15
|
-
* is one short generation (a few hundred output tokens at most).
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import { promises as fs } from 'node:fs';
|
|
19
|
-
import os from 'node:os';
|
|
20
|
-
import path from 'node:path';
|
|
21
|
-
import { fileURLToPath } from 'node:url';
|
|
22
|
-
|
|
23
|
-
const PACKAGE = 'dsh-panel-control';
|
|
24
|
-
const ENDPOINT = 'dsh-panel-control/classify';
|
|
25
|
-
const ROUTE_PATH = `/api/${ENDPOINT}`;
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Where the Client's settings live.
|
|
29
|
-
*
|
|
30
|
-
* `localStorage` is not good enough for this: the renderer's origin is the
|
|
31
|
-
* Host's loopback address, and its port changes between app launches, so every
|
|
32
|
-
* restart lands on a fresh, empty storage area and the sidebar silently falls
|
|
33
|
-
* back to registration order. A file under the profile's data directory
|
|
34
|
-
* survives restarts, upgrades and origin changes alike.
|
|
35
|
-
*/
|
|
36
|
-
// Tests (and anyone sandboxing this plugin) point DPC_STATE_DIR somewhere else;
|
|
37
|
-
// the smoke harness writes here, and it must not touch the real preferences or
|
|
38
|
-
// the real log that support questions get asked about.
|
|
39
|
-
const STATE_DIR = process.env.DPC_STATE_DIR
|
|
40
|
-
? path.resolve(process.env.DPC_STATE_DIR)
|
|
41
|
-
: path.join(os.homedir(), '.dsh', 'panel-control');
|
|
42
|
-
const PREFS_FILE = path.join(STATE_DIR, 'prefs.json');
|
|
43
|
-
const HOST_LOG = path.join(STATE_DIR, 'host.log');
|
|
44
|
-
const CLIENT_LOG = path.join(STATE_DIR, 'client.log');
|
|
45
|
-
const MAX_PREFS_BYTES = 256 * 1024;
|
|
46
|
-
const LOG_KEEP_BYTES = 256 * 1024;
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
function
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
const
|
|
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
|
-
const
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
const
|
|
485
|
-
return {
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
*
|
|
498
|
-
*
|
|
499
|
-
*
|
|
500
|
-
*
|
|
501
|
-
*
|
|
502
|
-
*/
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
const
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
const
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
const
|
|
590
|
-
if (
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
//
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
if (
|
|
691
|
-
logHost(
|
|
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
|
-
* that the caller can retry on a later tick. */
|
|
728
|
-
function mount(scope) {
|
|
729
|
-
const connection = serviceOf(scope, 'connection');
|
|
730
|
-
if (!connection || !connection.fetch || typeof connection.fetch.register !== 'function') return null;
|
|
731
|
-
return connection.fetch.register({
|
|
732
|
-
path: ROUTE_PATH,
|
|
733
|
-
methods: ['POST'],
|
|
734
|
-
requestBody: 'buffered',
|
|
735
|
-
fetch: (request) => handle(scope, request),
|
|
736
|
-
});
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
export function apply(ctx) {
|
|
740
|
-
let disposed = false;
|
|
741
|
-
let dispose = null;
|
|
742
|
-
let retry = null;
|
|
743
|
-
let attempts = 0;
|
|
744
|
-
|
|
745
|
-
logHost('apply: host half starting');
|
|
746
|
-
|
|
747
|
-
const attach = (scope) => {
|
|
748
|
-
if (disposed || dispose) return;
|
|
749
|
-
// A hot swap replaces the services themselves: a reference cached from the
|
|
750
|
-
// previous apply would answer with the old graph.
|
|
751
|
-
clientModules = null;
|
|
752
|
-
attempts += 1;
|
|
753
|
-
try {
|
|
754
|
-
dispose = mount(scope);
|
|
755
|
-
} catch (error) {
|
|
756
|
-
console.error(`[${PACKAGE}] failed to register ${ROUTE_PATH}`, error);
|
|
757
|
-
logHost(`mount failed: ${describeError(error)}`);
|
|
758
|
-
}
|
|
759
|
-
if (dispose) {
|
|
760
|
-
logHost(`route mounted at ${ROUTE_PATH} (attempt ${attempts})`);
|
|
761
|
-
logInventoryOnApply(scope);
|
|
762
|
-
serveCurrentRevision(scope);
|
|
763
|
-
const startedAt = Date.now();
|
|
764
|
-
// A timer callback that throws is fatal for the Host: contain it here and
|
|
765
|
-
// again inside the async body.
|
|
766
|
-
const watchdog = setTimeout(() => {
|
|
767
|
-
void watchFirstClientBoot(startedAt).catch((error) =>
|
|
768
|
-
logHost(`client boot watchdog failed: ${describeError(error)}`),
|
|
769
|
-
);
|
|
770
|
-
}, 30_000);
|
|
771
|
-
if (typeof watchdog.unref === 'function') watchdog.unref();
|
|
772
|
-
return;
|
|
773
|
-
}
|
|
774
|
-
if (!disposed) retry = setTimeout(() => attach(scope), 1000);
|
|
775
|
-
};
|
|
776
|
-
|
|
777
|
-
try {
|
|
778
|
-
if (typeof ctx.inject === 'function') ctx.inject(['connection'], attach);
|
|
779
|
-
else attach(ctx);
|
|
780
|
-
} catch (error) {
|
|
781
|
-
console.error(`[${PACKAGE}] host half could not start`, error);
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
// Cordis calls this on unload: drop the route and any pending retry.
|
|
785
|
-
if (typeof ctx.effect === 'function') {
|
|
786
|
-
ctx.effect(
|
|
787
|
-
() => () => {
|
|
788
|
-
disposed = true;
|
|
789
|
-
if (retry) clearTimeout(retry);
|
|
790
|
-
retry = null;
|
|
791
|
-
if (typeof dispose === 'function') {
|
|
792
|
-
try {
|
|
793
|
-
dispose();
|
|
794
|
-
} catch {
|
|
795
|
-
/* the Host already tore the route down */
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
dispose = null;
|
|
799
|
-
releaseServeCurrent();
|
|
800
|
-
},
|
|
801
|
-
`${PACKAGE}: classification route`,
|
|
802
|
-
);
|
|
803
|
-
}
|
|
804
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Host half of dsh-panel-control.
|
|
3
|
+
*
|
|
4
|
+
* The client half manages the panel-navigation area of the sidebar. Everything
|
|
5
|
+
* it needs is local except one thing: "auto classify" asks a model to file the
|
|
6
|
+
* entries into groups, and only the Host can reach a model.
|
|
7
|
+
*
|
|
8
|
+
* So this half does exactly one job: it registers one POST route under the
|
|
9
|
+
* /api carrier every DSH Host already serves, and answers it with a single
|
|
10
|
+
* non-streaming completion from the profile's default model. The Client calls
|
|
11
|
+
* it through `ctx.connection.rpc.call`, so authentication, the request
|
|
12
|
+
* envelope and the response envelope stay the Host's own, not ours.
|
|
13
|
+
*
|
|
14
|
+
* Nothing is streamed back: the caller needs a small JSON object, and the reply
|
|
15
|
+
* is one short generation (a few hundred output tokens at most).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { promises as fs } from 'node:fs';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
|
|
23
|
+
const PACKAGE = 'dsh-panel-control';
|
|
24
|
+
const ENDPOINT = 'dsh-panel-control/classify';
|
|
25
|
+
const ROUTE_PATH = `/api/${ENDPOINT}`;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Where the Client's settings live.
|
|
29
|
+
*
|
|
30
|
+
* `localStorage` is not good enough for this: the renderer's origin is the
|
|
31
|
+
* Host's loopback address, and its port changes between app launches, so every
|
|
32
|
+
* restart lands on a fresh, empty storage area and the sidebar silently falls
|
|
33
|
+
* back to registration order. A file under the profile's data directory
|
|
34
|
+
* survives restarts, upgrades and origin changes alike.
|
|
35
|
+
*/
|
|
36
|
+
// Tests (and anyone sandboxing this plugin) point DPC_STATE_DIR somewhere else;
|
|
37
|
+
// the smoke harness writes here, and it must not touch the real preferences or
|
|
38
|
+
// the real log that support questions get asked about.
|
|
39
|
+
const STATE_DIR = process.env.DPC_STATE_DIR
|
|
40
|
+
? path.resolve(process.env.DPC_STATE_DIR)
|
|
41
|
+
: path.join(os.homedir(), '.dsh', 'panel-control');
|
|
42
|
+
const PREFS_FILE = path.join(STATE_DIR, 'prefs.json');
|
|
43
|
+
const HOST_LOG = path.join(STATE_DIR, 'host.log');
|
|
44
|
+
const CLIENT_LOG = path.join(STATE_DIR, 'client.log');
|
|
45
|
+
const MAX_PREFS_BYTES = 256 * 1024;
|
|
46
|
+
const LOG_KEEP_BYTES = 256 * 1024;
|
|
47
|
+
|
|
48
|
+
/** Identifies one Host process, so a log spanning several launches shows which
|
|
49
|
+
* apply/mount lines belong together. */
|
|
50
|
+
const BOOT = Math.random().toString(36).slice(2, 8);
|
|
51
|
+
|
|
52
|
+
/** Route limits: this endpoint spends model tokens, so only local callers and
|
|
53
|
+
* only a small body are accepted. */
|
|
54
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
55
|
+
const MAX_ENTRIES = 64;
|
|
56
|
+
const MAX_GROUPS = 4;
|
|
57
|
+
const MAX_OUTPUT_TOKENS = 700;
|
|
58
|
+
const TIMEOUT_MS = 45_000;
|
|
59
|
+
|
|
60
|
+
/* ------------------------------------------------------------------ helpers */
|
|
61
|
+
|
|
62
|
+
function isRecord(value) {
|
|
63
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isLoopbackAuthority(authority) {
|
|
67
|
+
if (typeof authority !== 'string' || authority === '') return false;
|
|
68
|
+
try {
|
|
69
|
+
const url = new URL(`http://${authority}`);
|
|
70
|
+
if (url.username || url.password || url.pathname !== '/' || url.search || url.hash) return false;
|
|
71
|
+
const hostname = url.hostname.replace(/\.$/, '');
|
|
72
|
+
return hostname === 'localhost' || hostname === '::1' || hostname === '[::1]' || /^127\.\d+\.\d+\.\d+$/.test(hostname);
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Only the page the Host itself served may call this route.
|
|
80
|
+
*
|
|
81
|
+
* "Host and Origin are both loopback" is not enough: every dev server, every
|
|
82
|
+
* other app's webview and every local playground on this machine is also
|
|
83
|
+
* loopback, so that check let them rewrite the user's layout and spend model
|
|
84
|
+
* tokens. The discriminator is the port: the page is same-origin only when its
|
|
85
|
+
* origin carries the very authority this request arrived on.
|
|
86
|
+
*/
|
|
87
|
+
function isOwnRequest(request) {
|
|
88
|
+
const host = request.headers.get('host');
|
|
89
|
+
if (!isLoopbackAuthority(host)) return false;
|
|
90
|
+
// A browser labels where the request came from; anything but same-origin (or
|
|
91
|
+
// a non-browser caller) is a different page and has no business here.
|
|
92
|
+
const site = request.headers.get('sec-fetch-site');
|
|
93
|
+
if (site !== null && site !== 'same-origin' && site !== 'none') return false;
|
|
94
|
+
const origin = request.headers.get('origin');
|
|
95
|
+
if (origin === null) return true;
|
|
96
|
+
try {
|
|
97
|
+
const url = new URL(origin);
|
|
98
|
+
if (url.host === host) return true;
|
|
99
|
+
// The desktop shell serves the page from a custom protocol: that page is
|
|
100
|
+
// still the Host's own, even though its origin is not an http authority.
|
|
101
|
+
return url.protocol === 'dsh-app:' || url.protocol === 'file:';
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function reply(rpcId, result) {
|
|
108
|
+
// Connection's client requires `error.details`, even when empty.
|
|
109
|
+
const value =
|
|
110
|
+
result && result.ok === false
|
|
111
|
+
? { ...result, error: { ...result.error, details: (result.error && result.error.details) || {} } }
|
|
112
|
+
: result;
|
|
113
|
+
return Response.json({ type: 'server-response', rpcId, result: value });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function refusal(code, message) {
|
|
117
|
+
return { ok: false, error: { code, message } };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function describeError(error) {
|
|
121
|
+
if (error && typeof error.message === 'string' && error.message) return error.message;
|
|
122
|
+
try {
|
|
123
|
+
return JSON.stringify(error);
|
|
124
|
+
} catch {
|
|
125
|
+
return String(error);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* --------------------------------------------------------------- local state */
|
|
130
|
+
|
|
131
|
+
async function ensureStateDir() {
|
|
132
|
+
await fs.mkdir(STATE_DIR, { recursive: true });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function appendLine(file, line) {
|
|
136
|
+
try {
|
|
137
|
+
await ensureStateDir();
|
|
138
|
+
await fs.appendFile(file, `${new Date().toISOString()} [${BOOT}] ${line}\n`, 'utf8');
|
|
139
|
+
const stat = await fs.stat(file);
|
|
140
|
+
if (stat.size > LOG_KEEP_BYTES) {
|
|
141
|
+
const text = await fs.readFile(file, 'utf8');
|
|
142
|
+
await fs.writeFile(file, text.slice(-Math.floor(LOG_KEEP_BYTES / 2)), 'utf8');
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
/* Logging is a convenience: it must never break a request. */
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const logHost = (line) => {
|
|
150
|
+
void appendLine(HOST_LOG, line);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const logClient = (line) => {
|
|
154
|
+
void appendLine(CLIENT_LOG, line);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
async function readPrefsFile() {
|
|
158
|
+
try {
|
|
159
|
+
const text = await fs.readFile(PREFS_FILE, 'utf8');
|
|
160
|
+
const parsed = JSON.parse(text);
|
|
161
|
+
return isRecord(parsed) ? parsed : null;
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (error && error.code === 'ENOENT') return null;
|
|
164
|
+
await logHost(`prefs read failed: ${describeError(error)}`);
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function writePrefsFileNow(prefs) {
|
|
170
|
+
if (!isRecord(prefs)) throw new Error('preferences must be an object');
|
|
171
|
+
const text = JSON.stringify(prefs);
|
|
172
|
+
if (text.length > MAX_PREFS_BYTES) throw new Error('preferences are too large');
|
|
173
|
+
await ensureStateDir();
|
|
174
|
+
const temporary = `${PREFS_FILE}.tmp`;
|
|
175
|
+
await fs.writeFile(temporary, text, 'utf8');
|
|
176
|
+
await fs.rename(temporary, PREFS_FILE);
|
|
177
|
+
return text.length;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* One writer at a time.
|
|
182
|
+
*
|
|
183
|
+
* Two windows (or a retry racing the debounce) otherwise both write the same
|
|
184
|
+
* `.tmp` path: one rename wins, the other fails with EPERM/EEXIST and that save
|
|
185
|
+
* is lost even though the caller was told nothing. A promise chain costs nothing
|
|
186
|
+
* here -- saves are rare and small -- and keeps the last write the one on disk.
|
|
187
|
+
*/
|
|
188
|
+
let writeChain = Promise.resolve();
|
|
189
|
+
|
|
190
|
+
function writePrefsFile(prefs) {
|
|
191
|
+
const next = writeChain.then(
|
|
192
|
+
() => writePrefsFileNow(prefs),
|
|
193
|
+
() => writePrefsFileNow(prefs),
|
|
194
|
+
);
|
|
195
|
+
// Keep the chain alive after a rejection: the caller still gets the error.
|
|
196
|
+
writeChain = next.catch(() => {});
|
|
197
|
+
return next;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/* ------------------------------------------------------------------- prompt */
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* One prompt, no chat history: the model answers a single question about a
|
|
204
|
+
* short list. Everything it needs is inside the message, and the reply is
|
|
205
|
+
* parsed defensively because a model can always answer with prose.
|
|
206
|
+
*/
|
|
207
|
+
function buildPrompt(draft) {
|
|
208
|
+
const language = draft.locale === 'zh' ? '中文' : 'English';
|
|
209
|
+
const groupNames = (draft.groups || []).map((group) => group.name).filter(Boolean);
|
|
210
|
+
const entries = draft.entries.map((entry) => ({ id: entry.id, label: entry.label }));
|
|
211
|
+
return [
|
|
212
|
+
`You are filing the entry list of a developer tool's sidebar into a few named sections.`,
|
|
213
|
+
`Answer in ${language}.`,
|
|
214
|
+
``,
|
|
215
|
+
`Existing section names (reuse them when they fit; you may add new ones):`,
|
|
216
|
+
groupNames.length ? JSON.stringify(groupNames) : '[] (none yet)',
|
|
217
|
+
``,
|
|
218
|
+
`Entries to file, as JSON:`,
|
|
219
|
+
JSON.stringify(entries),
|
|
220
|
+
``,
|
|
221
|
+
`Rules:`,
|
|
222
|
+
`- Produce 2 to ${MAX_GROUPS} sections; every entry appears in exactly one section.`,
|
|
223
|
+
`- Section names are short: 2-4 Chinese characters, or 1-3 English words.`,
|
|
224
|
+
`- Group by what the entries are for, not by their order.`,
|
|
225
|
+
`- Answer with a single JSON object and nothing else: {"groups":[{"name":"...","ids":["..."]}]}`,
|
|
226
|
+
`- No prose, no markdown fence, no comments.`,
|
|
227
|
+
].join('\n');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Pull the first JSON object out of a model reply (tolerates a code fence). */
|
|
231
|
+
function extractJson(text) {
|
|
232
|
+
const raw = typeof text === 'string' ? text : '';
|
|
233
|
+
const start = raw.indexOf('{');
|
|
234
|
+
const end = raw.lastIndexOf('}');
|
|
235
|
+
if (start < 0 || end <= start) return null;
|
|
236
|
+
try {
|
|
237
|
+
return JSON.parse(raw.slice(start, end + 1));
|
|
238
|
+
} catch {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Keep only the answer this caller can use: known ids, at most MAX_GROUPS
|
|
244
|
+
* sections, short names, no duplicates. */
|
|
245
|
+
function sanitizeAnswer(answer, draft) {
|
|
246
|
+
if (!isRecord(answer) || !Array.isArray(answer.groups)) return null;
|
|
247
|
+
const known = new Set(draft.entries.map((entry) => entry.id));
|
|
248
|
+
const used = new Set();
|
|
249
|
+
const groups = [];
|
|
250
|
+
for (const group of answer.groups) {
|
|
251
|
+
if (!isRecord(group) || !Array.isArray(group.ids)) continue;
|
|
252
|
+
const name = typeof group.name === 'string' ? group.name.trim().slice(0, 24) : '';
|
|
253
|
+
const ids = [];
|
|
254
|
+
for (const id of group.ids) {
|
|
255
|
+
if (typeof id !== 'string' || !known.has(id) || used.has(id)) continue;
|
|
256
|
+
used.add(id);
|
|
257
|
+
ids.push(id);
|
|
258
|
+
}
|
|
259
|
+
if (ids.length === 0) continue;
|
|
260
|
+
groups.push({ name: name || `#${groups.length + 1}`, ids });
|
|
261
|
+
if (groups.length >= MAX_GROUPS) break;
|
|
262
|
+
}
|
|
263
|
+
if (groups.length === 0) return null;
|
|
264
|
+
return { groups };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/* --------------------------------------------------------------------- call */
|
|
268
|
+
|
|
269
|
+
function serviceOf(scope, key) {
|
|
270
|
+
try {
|
|
271
|
+
if (scope && typeof scope.get === 'function') {
|
|
272
|
+
const value = scope.get(key);
|
|
273
|
+
if (value !== undefined && value !== null) return value;
|
|
274
|
+
}
|
|
275
|
+
} catch {
|
|
276
|
+
/* a service that is not available yet reads as missing, not as a crash */
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
return scope ? scope[key] : undefined;
|
|
280
|
+
} catch {
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function currentModel(scope) {
|
|
286
|
+
const defaults = serviceOf(scope, 'agentDefaultModel');
|
|
287
|
+
if (defaults && typeof defaults.currentSelection === 'function') {
|
|
288
|
+
try {
|
|
289
|
+
const selection = defaults.currentSelection();
|
|
290
|
+
if (selection && typeof selection.provider === 'string' && typeof selection.model === 'string') {
|
|
291
|
+
return selection;
|
|
292
|
+
}
|
|
293
|
+
} catch {
|
|
294
|
+
/* fall through: the LLM service may still know a default */
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** One non-streaming completion: the joined text of a single stream. */
|
|
301
|
+
async function complete(scope, prompt, signal) {
|
|
302
|
+
const llm = serviceOf(scope, 'llm');
|
|
303
|
+
if (!llm || typeof llm !== 'object') throw new Error('the Host LLM service is not available');
|
|
304
|
+
const selection = currentModel(scope);
|
|
305
|
+
if (!selection) throw new Error('no default model is configured for this profile');
|
|
306
|
+
|
|
307
|
+
const messages = [{ role: 'user', content: [{ type: 'text', text: prompt }] }];
|
|
308
|
+
const request = {
|
|
309
|
+
provider: selection.provider,
|
|
310
|
+
model: selection.model,
|
|
311
|
+
messages,
|
|
312
|
+
maxTokens: MAX_OUTPUT_TOKENS,
|
|
313
|
+
};
|
|
314
|
+
// Filing labels needs no deliberation, and a thinking budget would eat the
|
|
315
|
+
// whole output allowance before a single character of the answer is emitted.
|
|
316
|
+
request.reasoningEffort = 'off';
|
|
317
|
+
|
|
318
|
+
let text = '';
|
|
319
|
+
let finish = null;
|
|
320
|
+
const seen = new Set();
|
|
321
|
+
const consume = async (stream) => {
|
|
322
|
+
for await (const chunk of stream) {
|
|
323
|
+
if (!chunk || typeof chunk !== 'object') continue;
|
|
324
|
+
seen.add(String(chunk.type));
|
|
325
|
+
if (chunk.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text;
|
|
326
|
+
else if (chunk.type === 'finish') finish = chunk;
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// `prepareCall` validates the route and freezes the resolved config; the
|
|
331
|
+
// prepared stream only accepts exactly that config, so hand it back as-is.
|
|
332
|
+
let prepared = null;
|
|
333
|
+
if (typeof llm.prepareCall === 'function') prepared = await llm.prepareCall(request, signal);
|
|
334
|
+
if (prepared && typeof prepared.stream === 'function') await consume(prepared.stream(prepared.config));
|
|
335
|
+
else await consume(llm.stream(request));
|
|
336
|
+
|
|
337
|
+
if (finish && isRecord(finish.reason)) {
|
|
338
|
+
const kind = finish.reason.kind;
|
|
339
|
+
if (kind && kind !== 'stop' && kind !== 'max-tokens') throw new Error(`the model stopped early (${kind})`);
|
|
340
|
+
}
|
|
341
|
+
return { text, model: `${selection.provider}/${selection.model}`, seen: Array.from(seen).join('+') };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/* ------------------------------------------------------------------ handler */
|
|
345
|
+
|
|
346
|
+
async function classify(scope, payload, signal) {
|
|
347
|
+
if (!isRecord(payload)) return refusal('bad-request', 'The request payload is not an object.');
|
|
348
|
+
const entries = [];
|
|
349
|
+
for (const entry of (Array.isArray(payload.entries) ? payload.entries : []).slice(0, MAX_ENTRIES)) {
|
|
350
|
+
if (!isRecord(entry) || typeof entry.id !== 'string' || entry.id === '') continue;
|
|
351
|
+
entries.push({
|
|
352
|
+
id: entry.id,
|
|
353
|
+
label: typeof entry.label === 'string' && entry.label !== '' ? entry.label.slice(0, 80) : entry.id,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
if (entries.length === 0) return refusal('bad-request', 'There is nothing to file: the entry list is empty.');
|
|
357
|
+
|
|
358
|
+
const groups = [];
|
|
359
|
+
for (const group of (Array.isArray(payload.groups) ? payload.groups : []).slice(0, MAX_GROUPS)) {
|
|
360
|
+
if (!isRecord(group) || typeof group.name !== 'string' || group.name.trim() === '') continue;
|
|
361
|
+
groups.push({ name: group.name.trim().slice(0, 24) });
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const draft = { entries, groups, locale: payload.locale === 'zh' ? 'zh' : 'en' };
|
|
365
|
+
|
|
366
|
+
const deadline = new AbortController();
|
|
367
|
+
const timer = setTimeout(() => deadline.abort(), TIMEOUT_MS);
|
|
368
|
+
const mergedSignal = () => {
|
|
369
|
+
if (!signal) return deadline.signal;
|
|
370
|
+
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.any === 'function') {
|
|
371
|
+
return AbortSignal.any([signal, deadline.signal]);
|
|
372
|
+
}
|
|
373
|
+
return deadline.signal;
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
try {
|
|
377
|
+
const answer = await complete(scope, buildPrompt(draft), mergedSignal());
|
|
378
|
+
const parsed = sanitizeAnswer(extractJson(answer.text), draft);
|
|
379
|
+
if (!parsed) {
|
|
380
|
+
logHost(`classify: unusable answer (${answer.seen}, ${answer.text.length} chars)`);
|
|
381
|
+
return refusal('unparsable-answer', 'The model did not answer with usable JSON.');
|
|
382
|
+
}
|
|
383
|
+
const filed = new Set(parsed.groups.flatMap((group) => group.ids));
|
|
384
|
+
logHost(`classify: ${parsed.groups.length} groups over ${filed.size}/${entries.length} entries (${answer.model})`);
|
|
385
|
+
return {
|
|
386
|
+
ok: true,
|
|
387
|
+
value: {
|
|
388
|
+
groups: parsed.groups,
|
|
389
|
+
unfiled: entries.filter((entry) => !filed.has(entry.id)).map((entry) => entry.id),
|
|
390
|
+
model: answer.model,
|
|
391
|
+
},
|
|
392
|
+
};
|
|
393
|
+
} catch (error) {
|
|
394
|
+
const aborted = deadline.signal.aborted || (signal && signal.aborted);
|
|
395
|
+
return refusal(aborted ? 'timeout' : 'model-failed', describeError(error));
|
|
396
|
+
} finally {
|
|
397
|
+
clearTimeout(timer);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/* -------------------------------------------------------------------- route */
|
|
402
|
+
|
|
403
|
+
async function handle(scope, request) {
|
|
404
|
+
if (!isOwnRequest(request)) return new Response('forbidden', { status: 403 });
|
|
405
|
+
if (request.method !== 'POST') return new Response('method not allowed', { status: 405 });
|
|
406
|
+
const contentType = request.headers.get('content-type');
|
|
407
|
+
if (!contentType || contentType.split(';', 1)[0].trim().toLowerCase() !== 'application/json') {
|
|
408
|
+
return new Response('content type must be application/json', { status: 415 });
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Read the text first: `request.json()` would parse the whole body before the
|
|
412
|
+
// size check could refuse it, which is exactly what the limit exists to stop.
|
|
413
|
+
let raw;
|
|
414
|
+
try {
|
|
415
|
+
raw = await request.text();
|
|
416
|
+
} catch {
|
|
417
|
+
return new Response('body is unreadable', { status: 400 });
|
|
418
|
+
}
|
|
419
|
+
if (raw.length > MAX_BODY_BYTES) return new Response('body too large', { status: 413 });
|
|
420
|
+
let message;
|
|
421
|
+
try {
|
|
422
|
+
message = JSON.parse(raw);
|
|
423
|
+
} catch {
|
|
424
|
+
return new Response('body is not JSON', { status: 400 });
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const rpcId = isRecord(message) && typeof message.rpcId === 'string' ? message.rpcId : 'invalid-request';
|
|
428
|
+
if (
|
|
429
|
+
!isRecord(message) ||
|
|
430
|
+
message.type !== 'client-request' ||
|
|
431
|
+
typeof message.rpcId !== 'string' ||
|
|
432
|
+
message.method !== ENDPOINT ||
|
|
433
|
+
!isRecord(message.payload)
|
|
434
|
+
) {
|
|
435
|
+
return reply(rpcId, refusal('gateway/bad-request', 'This is not a panel-control request.'));
|
|
436
|
+
}
|
|
437
|
+
const call = message.payload;
|
|
438
|
+
const method = typeof call.method === 'string' ? call.method : '';
|
|
439
|
+
|
|
440
|
+
try {
|
|
441
|
+
if (method === 'classify') return reply(rpcId, await classify(scope, call.payload, request.signal));
|
|
442
|
+
if (method === 'load') return reply(rpcId, await loadPreferences());
|
|
443
|
+
if (method === 'save') return reply(rpcId, await savePreferences(call.payload));
|
|
444
|
+
if (method === 'inventory') return reply(rpcId, inventorySnapshot(scope));
|
|
445
|
+
if (method === 'report') {
|
|
446
|
+
const detail = isRecord(call.payload) ? call.payload : {};
|
|
447
|
+
const text = JSON.stringify(detail).slice(0, 4_000);
|
|
448
|
+
logClient(`${typeof detail.kind === 'string' ? detail.kind : 'report'} ${text}`);
|
|
449
|
+
return reply(rpcId, { ok: true, value: { logged: true } });
|
|
450
|
+
}
|
|
451
|
+
return reply(rpcId, refusal('unknown-method', `Unknown method: ${String(call.method)}`));
|
|
452
|
+
} catch (error) {
|
|
453
|
+
logHost(`request ${method || '?'} failed: ${describeError(error)}`);
|
|
454
|
+
return reply(rpcId, refusal('handler-failed', describeError(error)));
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function loadPreferences() {
|
|
459
|
+
const prefs = await readPrefsFile();
|
|
460
|
+
return { ok: true, value: { prefs, file: PREFS_FILE } };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async function savePreferences(payload) {
|
|
464
|
+
if (!isRecord(payload) || !isRecord(payload.prefs)) {
|
|
465
|
+
return refusal('bad-request', 'A preferences object is required.');
|
|
466
|
+
}
|
|
467
|
+
const bytes = await writePrefsFile(payload.prefs);
|
|
468
|
+
return { ok: true, value: { saved: true, bytes } };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* What the Host is serving to pages right now, taken from the same module graph
|
|
473
|
+
* that builds `window.__DSH_BOOT__`. Comparing revisions is the only reliable
|
|
474
|
+
* way to tell "this page booted with an older plugin list": the page cannot
|
|
475
|
+
* read the Host's list, and the profile manifest says nothing about which
|
|
476
|
+
* plugins actually made it into the served graph.
|
|
477
|
+
*/
|
|
478
|
+
let clientModules = null;
|
|
479
|
+
|
|
480
|
+
function inventorySnapshot(scope) {
|
|
481
|
+
if (!clientModules) clientModules = serviceOf(scope, 'clientModules');
|
|
482
|
+
const graph = clientModules && typeof clientModules.graph === 'function' ? clientModules.graph() : null;
|
|
483
|
+
if (!graph || typeof graph.rev !== 'string') return { ok: false, error: { message: 'The plugin graph is not readable.' } };
|
|
484
|
+
const entries = Array.isArray(graph.entries) ? graph.entries : [];
|
|
485
|
+
return {
|
|
486
|
+
ok: true,
|
|
487
|
+
value: {
|
|
488
|
+
rev: graph.rev,
|
|
489
|
+
release: typeof graph.release === 'string' ? graph.release : null,
|
|
490
|
+
ids: entries.map((entry) => String(entry && entry.id)).filter(Boolean),
|
|
491
|
+
},
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Write one line per apply that answers the only question a cold start leaves
|
|
497
|
+
* open: did this package even reach the Host's plugin graph?
|
|
498
|
+
*
|
|
499
|
+
* The page can only load what the graph advertises, so "missing from the list"
|
|
500
|
+
* and "listed but never fetched" have completely different fixes. One line,
|
|
501
|
+
* once per apply, settles it from `host.log` alone.
|
|
502
|
+
*/
|
|
503
|
+
function logInventoryOnApply(scope) {
|
|
504
|
+
try {
|
|
505
|
+
const snapshot = inventorySnapshot(scope);
|
|
506
|
+
if (!snapshot.ok) {
|
|
507
|
+
logHost(`graph: unreadable (${String(snapshot.error && snapshot.error.message)})`);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
const value = snapshot.value;
|
|
511
|
+
const listed = value.ids.includes(PACKAGE);
|
|
512
|
+
logHost(
|
|
513
|
+
`graph: ${listed ? 'lists' : 'does not list'} ${PACKAGE} · rev ${value.rev} · ` +
|
|
514
|
+
`${value.ids.length} client entr${value.ids.length === 1 ? 'y' : 'ies'}`,
|
|
515
|
+
);
|
|
516
|
+
if (listed) void probeOwnBundle(scope);
|
|
517
|
+
} catch (error) {
|
|
518
|
+
logHost(`graph: probe failed (${describeError(error)})`);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Serve the current revision when a page asks for one this process has retired.
|
|
524
|
+
*
|
|
525
|
+
* The registry addresses every bundle by a revision derived from the file's
|
|
526
|
+
* mtime, ctime and size, and it answers only the revisions it read at
|
|
527
|
+
* composition time ("versioned code is immutable"). A page keeps the list it
|
|
528
|
+
* booted with, so as soon as this package's file is rewritten - by an editor, by
|
|
529
|
+
* git normalising line endings, or by the retry nudge that used to live here -
|
|
530
|
+
* the page's revision stops existing and its request becomes a 404 that nothing
|
|
531
|
+
* reports: the plugin is simply absent from the sidebar.
|
|
532
|
+
*
|
|
533
|
+
* This wraps the registry's own resource lookup for this one package. A 404 for
|
|
534
|
+
* a stale revision is retried at the revision the Host holds now, and the page
|
|
535
|
+
* gets the bytes it needs; everything else passes straight through, and the
|
|
536
|
+
* patch is undone on teardown.
|
|
537
|
+
*/
|
|
538
|
+
const SERVE_CURRENT = String(process.env.DPC_SERVE_CURRENT || '').toLowerCase() !== 'off';
|
|
539
|
+
let servePatched = null;
|
|
540
|
+
let serveLoggedAt = 0;
|
|
541
|
+
/**
|
|
542
|
+
* Bytes this Host has already served, kept by revision.
|
|
543
|
+
*
|
|
544
|
+
* The registry serves a revision only while it is the current one. A page
|
|
545
|
+
* (or a page rebuilt by another tool) keeps asking for the revision it booted
|
|
546
|
+
* with, so the copy we handed out is the only thing that can answer it later.
|
|
547
|
+
* Five revisions is a few hundred kilobytes and covers a run of edits.
|
|
548
|
+
*/
|
|
549
|
+
const servedBytes = new Map();
|
|
550
|
+
const SERVED_BYTES_KEPT = 5;
|
|
551
|
+
/** Requests to log per Host process: enough to see a boot, not a chatty stream. */
|
|
552
|
+
let bundleAsked = 0;
|
|
553
|
+
const BUNDLE_ASK_LIMIT = 8;
|
|
554
|
+
|
|
555
|
+
function revisionOf(url) {
|
|
556
|
+
const match = /[?&]rev=([A-Za-z0-9._-]+)/.exec(String(url || ''));
|
|
557
|
+
return match ? match[1] : null;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function serveCurrentRevision(scope) {
|
|
561
|
+
if (!SERVE_CURRENT || servePatched) return;
|
|
562
|
+
const modules = clientModules || serviceOf(scope, 'clientModules');
|
|
563
|
+
if (!modules || typeof modules.bundleResource !== 'function') return;
|
|
564
|
+
const original = modules.bundleResource.bind(modules);
|
|
565
|
+
servePatched = modules;
|
|
566
|
+
modules.bundleResource = async function bundleResourceWithFallback(method, url) {
|
|
567
|
+
const answer = await original(method, url);
|
|
568
|
+
if (String(url || '').includes(PACKAGE) && bundleAsked < BUNDLE_ASK_LIMIT) {
|
|
569
|
+
bundleAsked += 1;
|
|
570
|
+
logHost(`bundle: asked rev ${String(revisionOf(url))} -> ${answer && answer.status}`);
|
|
571
|
+
}
|
|
572
|
+
if (answer && answer.status === 200 && method === 'GET' && String(url || '').includes(PACKAGE)) {
|
|
573
|
+
const rev = revisionOf(url);
|
|
574
|
+
if (rev && typeof answer.body === 'string') {
|
|
575
|
+
servedBytes.delete(rev);
|
|
576
|
+
servedBytes.set(rev, answer);
|
|
577
|
+
while (servedBytes.size > SERVED_BYTES_KEPT) {
|
|
578
|
+
servedBytes.delete(servedBytes.keys().next().value);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
if (answer && answer.status !== 404) return answer;
|
|
583
|
+
try {
|
|
584
|
+
const requested = String(url || '');
|
|
585
|
+
if (!requested.includes(PACKAGE)) return answer;
|
|
586
|
+
// A revision this Host already handed out outranks the current one: the
|
|
587
|
+
// page booted with it, and its list will keep naming it.
|
|
588
|
+
const asked = revisionOf(requested);
|
|
589
|
+
const remembered = asked ? servedBytes.get(asked) : undefined;
|
|
590
|
+
if (remembered !== undefined) {
|
|
591
|
+
if (Date.now() - serveLoggedAt > 30_000) {
|
|
592
|
+
serveLoggedAt = Date.now();
|
|
593
|
+
logHost(
|
|
594
|
+
`served ${PACKAGE} again at revision ${asked}: the page still runs the list it booted with`,
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
return method === 'HEAD' ? { ...remembered, body: undefined } : remembered;
|
|
598
|
+
}
|
|
599
|
+
const graph = typeof modules.graph === 'function' ? modules.graph() : null;
|
|
600
|
+
const rows = graph && Array.isArray(graph.entries) ? graph.entries : [];
|
|
601
|
+
const entry = rows.find((row) => row && row.id === PACKAGE);
|
|
602
|
+
if (!entry || typeof entry.url !== 'string') return answer;
|
|
603
|
+
const retryUrl = entry.url.startsWith('/') ? entry.url : `/${entry.url}`;
|
|
604
|
+
if (retryUrl === requested) return answer;
|
|
605
|
+
const retried = await original(method, retryUrl);
|
|
606
|
+
if (!retried || retried.status !== 200) return answer;
|
|
607
|
+
if (Date.now() - serveLoggedAt > 30_000) {
|
|
608
|
+
serveLoggedAt = Date.now();
|
|
609
|
+
logHost(
|
|
610
|
+
`served ${PACKAGE} at the current revision: the page asked for a retired one ` +
|
|
611
|
+
'(its plugin list predates the last write to client.js)',
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
return retried;
|
|
615
|
+
} catch (error) {
|
|
616
|
+
logHost(`stale revision fallback failed: ${describeError(error)}`);
|
|
617
|
+
return answer;
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function releaseServeCurrent() {
|
|
623
|
+
const modules = servePatched;
|
|
624
|
+
servePatched = null;
|
|
625
|
+
if (!modules) return;
|
|
626
|
+
try {
|
|
627
|
+
delete modules.bundleResource;
|
|
628
|
+
} catch {
|
|
629
|
+
/* a frozen service keeps the wrapper; it only ever adds a retry */
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Ask the Host to serve this package's own bundle, the way the page would.
|
|
635
|
+
*
|
|
636
|
+
* A row can be advertised and still be unservable: the registry answers only
|
|
637
|
+
* the revisions it read at composition time, and everything else is a 404 that
|
|
638
|
+
* the page drops without a word. Runs the same request the browser would make.
|
|
639
|
+
*/
|
|
640
|
+
async function probeOwnBundle(scope) {
|
|
641
|
+
try {
|
|
642
|
+
const modules = clientModules || serviceOf(scope, 'clientModules');
|
|
643
|
+
const graph = modules && typeof modules.graph === 'function' ? modules.graph() : null;
|
|
644
|
+
const entries = graph && Array.isArray(graph.entries) ? graph.entries : [];
|
|
645
|
+
const entry = entries.find((row) => row && row.id === PACKAGE);
|
|
646
|
+
if (!entry || typeof entry.url !== 'string' || typeof modules.fetchBundle !== 'function') return;
|
|
647
|
+
const response = await modules.fetchBundle({ method: 'GET', url: entry.url });
|
|
648
|
+
logHost(
|
|
649
|
+
`graph: own bundle at rev ${String(entry.rev)} answers ${response.status}` +
|
|
650
|
+
(response.status === 200 ? '' : ' — the page cannot load a bundle the Host will not serve'),
|
|
651
|
+
);
|
|
652
|
+
} catch (error) {
|
|
653
|
+
logHost(`graph: own bundle probe failed (${describeError(error)})`);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** Register the route; returns null while `connection` is not available yet so
|
|
658
|
+
* that the caller can retry on a later tick. */
|
|
659
|
+
function mount(scope) {
|
|
660
|
+
const connection = serviceOf(scope, 'connection');
|
|
661
|
+
if (!connection || !connection.fetch || typeof connection.fetch.register !== 'function') return null;
|
|
662
|
+
return connection.fetch.register({
|
|
663
|
+
path: ROUTE_PATH,
|
|
664
|
+
methods: ['POST'],
|
|
665
|
+
requestBody: 'buffered',
|
|
666
|
+
fetch: (request) => handle(scope, request),
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
export function apply(ctx) {
|
|
671
|
+
let disposed = false;
|
|
672
|
+
let dispose = null;
|
|
673
|
+
let retry = null;
|
|
674
|
+
let attempts = 0;
|
|
675
|
+
|
|
676
|
+
logHost('apply: host half starting');
|
|
677
|
+
|
|
678
|
+
const attach = (scope) => {
|
|
679
|
+
if (disposed || dispose) return;
|
|
680
|
+
// A hot swap replaces the services themselves: a reference cached from the
|
|
681
|
+
// previous apply would answer with the old graph.
|
|
682
|
+
clientModules = null;
|
|
683
|
+
attempts += 1;
|
|
684
|
+
try {
|
|
685
|
+
dispose = mount(scope);
|
|
686
|
+
} catch (error) {
|
|
687
|
+
console.error(`[${PACKAGE}] failed to register ${ROUTE_PATH}`, error);
|
|
688
|
+
logHost(`mount failed: ${describeError(error)}`);
|
|
689
|
+
}
|
|
690
|
+
if (dispose) {
|
|
691
|
+
logHost(`route mounted at ${ROUTE_PATH} (attempt ${attempts})`);
|
|
692
|
+
logInventoryOnApply(scope);
|
|
693
|
+
serveCurrentRevision(scope);
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
if (!disposed) retry = setTimeout(() => attach(scope), 1000);
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
try {
|
|
700
|
+
if (typeof ctx.inject === 'function') ctx.inject(['connection'], attach);
|
|
701
|
+
else attach(ctx);
|
|
702
|
+
} catch (error) {
|
|
703
|
+
console.error(`[${PACKAGE}] host half could not start`, error);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Cordis calls this on unload: drop the route and any pending retry.
|
|
707
|
+
if (typeof ctx.effect === 'function') {
|
|
708
|
+
ctx.effect(
|
|
709
|
+
() => () => {
|
|
710
|
+
disposed = true;
|
|
711
|
+
if (retry) clearTimeout(retry);
|
|
712
|
+
retry = null;
|
|
713
|
+
if (typeof dispose === 'function') {
|
|
714
|
+
try {
|
|
715
|
+
dispose();
|
|
716
|
+
} catch {
|
|
717
|
+
/* the Host already tore the route down */
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
dispose = null;
|
|
721
|
+
releaseServeCurrent();
|
|
722
|
+
},
|
|
723
|
+
`${PACKAGE}: classification route`,
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
}
|