dsh-issue2pr 0.3.0 → 0.3.1
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 +16 -12
- package/client.js +3701 -3617
- package/index.js +862 -721
- package/lib/assistant.js +3 -3
- package/lib/{pipeline.js → core/pipeline.js} +55 -4
- package/lib/{stageConfig.js → core/stageConfig.js} +24 -2
- package/lib/{agents.js → delegate/agents.js} +1 -1
- package/lib/delegate/delegateVerify.js +125 -0
- package/lib/infra/repoState.js +59 -0
- package/lib/stages/helpers.js +3 -3
- package/lib/stages/p1-issue-analyzer.js +2 -2
- package/lib/stages/p10-failure.js +2 -2
- package/lib/stages/p11-pr-builder.js +2 -2
- package/lib/stages/p2-search.js +2 -2
- package/lib/stages/p3-code-understanding.js +2 -2
- package/lib/stages/p4-hypothesis.js +2 -2
- package/lib/stages/p5-planner.js +2 -2
- package/lib/stages/p6-coder.js +4 -4
- package/lib/stages/p7-patch.js +12 -3
- package/lib/stages/p8-test-runner.js +2 -2
- package/lib/stages/p9-reviewer.js +2 -2
- package/package.json +1 -1
- /package/lib/{store.js → core/store.js} +0 -0
- /package/lib/{connections.js → infra/connections.js} +0 -0
- /package/lib/{llm.js → infra/llm.js} +0 -0
package/index.js
CHANGED
|
@@ -1,721 +1,862 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-issue2pr — node 半:REST API + 驱动循环(随 dsh web 同生共死)。
|
|
3
|
-
* 路由与 spec §7 对齐:projects / runs(嵌套)/ review / rollback / tree / artifact。
|
|
4
|
-
* 数据读写一律走 lib/store.js 与 lib/pipeline.js,不重复造轮子。
|
|
5
|
-
*/
|
|
6
|
-
import { join } from "node:path";
|
|
7
|
-
import { existsSync, readdirSync, mkdirSync, readFileSync } from "node:fs";
|
|
8
|
-
import { execFile } from "node:child_process";
|
|
9
|
-
import {
|
|
10
|
-
defaultDataRoot, saveProject, loadProject, listProjects,
|
|
11
|
-
createRun, runDirOf, readArtifact, listRunTree, rmTree, loadUiState, saveUiState,
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
if (parts[2] === "
|
|
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
|
-
if (!
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
}
|
|
493
|
-
return sendJson(res,
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
//
|
|
498
|
-
if (!parts[4] && m === "
|
|
499
|
-
const
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
const
|
|
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
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
if (!
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
}
|
|
590
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1
|
+
/**
|
|
2
|
+
* dsh-issue2pr — node 半:REST API + 驱动循环(随 dsh web 同生共死)。
|
|
3
|
+
* 路由与 spec §7 对齐:projects / runs(嵌套)/ review / rollback / tree / artifact。
|
|
4
|
+
* 数据读写一律走 lib/core/store.js 与 lib/core/pipeline.js,不重复造轮子。
|
|
5
|
+
*/
|
|
6
|
+
import { join, relative, sep } from "node:path";
|
|
7
|
+
import { existsSync, readdirSync, mkdirSync, readFileSync } from "node:fs";
|
|
8
|
+
import { execFile } from "node:child_process";
|
|
9
|
+
import {
|
|
10
|
+
defaultDataRoot, saveProject, loadProject, listProjects,
|
|
11
|
+
createRun, runDirOf, readArtifact, listRunTree, rmTree, loadUiState, saveUiState,
|
|
12
|
+
writeArtifact, timestamp,
|
|
13
|
+
} from "./lib/core/store.js";
|
|
14
|
+
import { initRun, saveRun, loadRun, advance, applyReview, isGate, STAGES, MAIN_FLOW } from "./lib/core/pipeline.js";
|
|
15
|
+
import { verifyDelegateResult } from "./lib/delegate/delegateVerify.js";
|
|
16
|
+
import { makeLlm, routeInfo } from "./lib/infra/llm.js";
|
|
17
|
+
import { buildExecutors } from "./lib/stages/index.js";
|
|
18
|
+
import { rollbackLedger } from "./lib/stages/p7-patch.js";
|
|
19
|
+
import { killExternal, resolveClaudeBin } from "./lib/stages/p6-coder.js";
|
|
20
|
+
import { discoverAgents, testAgentGate, realRunWhich, realRunNpmPrefix, realRunVersion } from "./lib/delegate/agents.js";
|
|
21
|
+
import { readTriggerText, logEvent } from "./lib/stages/helpers.js";
|
|
22
|
+
import {
|
|
23
|
+
loadConnections, upsertConnection, deleteConnection, normalizeConnection,
|
|
24
|
+
matchConnection, injectGitCredentials, redactUrl, maskToken, hostOf, CONNECTION_KINDS,
|
|
25
|
+
} from "./lib/infra/connections.js";
|
|
26
|
+
import { STAGE_DEFS, stageCfgOf, stageDelegated, delegateReady, purgeDelegateArtifacts, routeOverridesOf, DEFAULT_LLM_TIMEOUT_MS, DEFAULT_TEST_TIMEOUT_MS } from "./lib/core/stageConfig.js";
|
|
27
|
+
import { baseRepoDir, runRepoDir, ensureWorktree, removeWorktree, resetRepoClean } from "./lib/infra/repoState.js";
|
|
28
|
+
import { ASSISTANT_SYSTEM_HEAD, buildAssistantContext } from "./lib/assistant.js";
|
|
29
|
+
|
|
30
|
+
export const name = "dsh-issue2pr";
|
|
31
|
+
export const inject = ["webServer", "llm"];
|
|
32
|
+
|
|
33
|
+
// —— 测试注入(仅本插件测试用):覆盖内部默认值 dataRoot / executors ——
|
|
34
|
+
let __testHooks = null;
|
|
35
|
+
export function __setTestHooks(hooks) { __testHooks = hooks || null; }
|
|
36
|
+
|
|
37
|
+
export function sendJson(res, code, obj) {
|
|
38
|
+
const data = Buffer.from(JSON.stringify(obj), "utf8");
|
|
39
|
+
// no-store:run 状态高频轮询,任何浏览器/代理缓存都会让停止/删除「看起来没生效」
|
|
40
|
+
res.writeHead(code, { "Content-Type": "application/json; charset=utf-8", "Content-Length": data.length, "Cache-Control": "no-store" });
|
|
41
|
+
res.end(data);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function readBody(req) {
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
const chunks = [];
|
|
47
|
+
req.on("data", (c) => chunks.push(c));
|
|
48
|
+
req.on("end", () => { try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}")); } catch (e) { reject(e); } });
|
|
49
|
+
req.on("error", reject);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// —— 执行器解析:测试注入缺省 = 立即 approved 的空执行器;生产走 buildExecutors() ——
|
|
54
|
+
function executorsOf() {
|
|
55
|
+
if (__testHooks && __testHooks.executors) {
|
|
56
|
+
const map = {};
|
|
57
|
+
for (const s of STAGES) map[s.id] = __testHooks.executors[s.id] || (async () => ({}));
|
|
58
|
+
return map;
|
|
59
|
+
}
|
|
60
|
+
return buildExecutors();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// —— rcx 组装(简报指定形态);project 每次刷新,保证读取最新配置 ——
|
|
64
|
+
// llm 的事件钩子绑定到 rcx 本身(读 run.current 得到当前阶段),LLM 调用自动进 trace/events.jsonl
|
|
65
|
+
// stageCfgOf:按阶段取合并后的配置(阶段执行器读提示词/委托;llm 读路由覆盖),
|
|
66
|
+
// 每次调用现读 project 与 run.current,配置修改在下一阶段即时生效
|
|
67
|
+
function buildRcx(ctx, root, runDir, run) {
|
|
68
|
+
const rcx = {
|
|
69
|
+
runDir, run,
|
|
70
|
+
project: loadProject(root, run.project),
|
|
71
|
+
repoDir: runRepoDir(root, run.project, run.id), // per-Run worktree(drive 开工时确保存在,失败兜底基线 repo)
|
|
72
|
+
trigger: run.trigger,
|
|
73
|
+
llm: null,
|
|
74
|
+
p6Mode: run.p6Mode,
|
|
75
|
+
executors: executorsOf(),
|
|
76
|
+
log() {},
|
|
77
|
+
};
|
|
78
|
+
// 连接配置用 getter 现读盘:Run 进行中新增/修改连接,下一阶段即生效(与 project 同策略)
|
|
79
|
+
Object.defineProperty(rcx, "connections", { get: () => loadConnections(root) });
|
|
80
|
+
rcx.stageCfgOf = (stageId) => stageCfgOf(rcx.project, stageId || (rcx.run && rcx.run.current));
|
|
81
|
+
rcx.llm = makeLlm(ctx, (ev) => logEvent(rcx, ev), () => routeOverridesOf(rcx));
|
|
82
|
+
return rcx;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// —— 单 run 一把锁防并发;rcx 跨 drive/review 共享(打回意见 reviewComment 跨循环传递) ——
|
|
86
|
+
const runSessions = new Map();
|
|
87
|
+
|
|
88
|
+
function sessionFor(runDir) {
|
|
89
|
+
if (!runSessions.has(runDir)) runSessions.set(runDir, { lock: Promise.resolve(), rcx: null });
|
|
90
|
+
return runSessions.get(runDir);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// —— 项目级互斥(A3 修复):同项目的 Run 串行推进 ——
|
|
94
|
+
// per-Run worktree 已让并发 Run 的仓库隔离;worktree 创建失败的兜底路径(共用基线 repo)与
|
|
95
|
+
// 基线 repo 的 clone/reset 管理操作仍需互斥。锁按需创建、空闲自动回收,等待不因前任失败而中断。
|
|
96
|
+
const projectLocks = new Map();
|
|
97
|
+
function withProjectLock(key, fn) {
|
|
98
|
+
const prev = projectLocks.get(key) || Promise.resolve();
|
|
99
|
+
const task = prev.then(() => fn());
|
|
100
|
+
const tail = task.then(() => {}, () => {});
|
|
101
|
+
projectLocks.set(key, tail);
|
|
102
|
+
tail.then(() => { if (projectLocks.get(key) === tail) projectLocks.delete(key); });
|
|
103
|
+
return task;
|
|
104
|
+
}
|
|
105
|
+
function projectKeyOf(root, runDir) {
|
|
106
|
+
try {
|
|
107
|
+
const rel = relative(join(root, "projects"), runDir);
|
|
108
|
+
const slug = String(rel).split(sep)[0];
|
|
109
|
+
if (slug && !slug.startsWith("..")) return join(root, "projects", slug);
|
|
110
|
+
} catch { /* 兜底 */ }
|
|
111
|
+
return runDir;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// —— 仓库克隆:repo 目录不存在时 git clone --depth 1 主仓库(幂等) ——
|
|
115
|
+
// https 地址若匹配到「Git 托管连接」则注入凭据(私有仓库可克隆);ssh/scp 形态走本机密钥不注入。
|
|
116
|
+
// 注入后的 URL 绝不落事件/错误信息(redactUrl 脱敏)。
|
|
117
|
+
function ensureRepo(root, project, rcx) {
|
|
118
|
+
const repoDir = baseRepoDir(root, project.slug);
|
|
119
|
+
if (existsSync(join(repoDir, ".git"))) return Promise.resolve(repoDir);
|
|
120
|
+
if (!project.repos || !project.repos.length) return Promise.reject(new Error("项目未配置仓库"));
|
|
121
|
+
mkdirSync(repoDir, { recursive: true });
|
|
122
|
+
const uri0 = typeof project.repos[0] === "string" ? project.repos[0] : project.repos[0].uri;
|
|
123
|
+
const conn = matchConnection(uri0, loadConnections(root));
|
|
124
|
+
const uri = injectGitCredentials(uri0, conn);
|
|
125
|
+
const t0 = Date.now();
|
|
126
|
+
logEvent(rcx, {
|
|
127
|
+
kind: "git", name: "git clone --depth 1 " + redactUrl(uri0),
|
|
128
|
+
detail: "克隆主仓库到本地 repo/" + (conn ? `(已注入 ${CONNECTION_KINDS[conn.kind].label} 连接凭据)` : ""),
|
|
129
|
+
});
|
|
130
|
+
return new Promise((resolve, reject) => {
|
|
131
|
+
execFile("git", ["clone", "--depth", "1", uri, repoDir], { stdio: "pipe", timeout: 120000, windowsHide: true }, (err, stdout, stderr) => {
|
|
132
|
+
if (err) {
|
|
133
|
+
const stderrS = redactUrl(String(stderr || err.message));
|
|
134
|
+
logEvent(rcx, { kind: "git", name: "git clone 失败", detail: stderrS, ms: Date.now() - t0, ok: false });
|
|
135
|
+
const host = hostOf(uri0);
|
|
136
|
+
const hint = !conn && host ? `(若为私有仓库,请在项目页「Git 托管连接」配置 ${host} 的凭据)` : "";
|
|
137
|
+
reject(new Error("git clone 失败" + hint + ": " + stderrS));
|
|
138
|
+
} else {
|
|
139
|
+
logEvent(rcx, { kind: "git", name: "git clone 完成", detail: redactUrl(uri0), ms: Date.now() - t0 });
|
|
140
|
+
resolve(repoDir);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// —— 快速失败:把运行中的 run 置为 failed(阶段状态同步落盘),供克隆失败等开工前错误使用 ——
|
|
147
|
+
export function failRun(runDir, stageId, message) {
|
|
148
|
+
const run = loadRun(runDir);
|
|
149
|
+
if (!run || run.status !== "running") return run;
|
|
150
|
+
run.status = "failed";
|
|
151
|
+
const st = run.stages[stageId || run.current || "P1"];
|
|
152
|
+
if (st) { st.status = "failed"; st.error = message; }
|
|
153
|
+
saveRun(runDir, run);
|
|
154
|
+
return run;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// —— 失败分析(P10)结果写回 run.json:此前只落 09-failure-analysis.json 产物,run.json 不记,
|
|
158
|
+
// UI 轮询状态机无从得知"失败已分析/建议动作"。此处读产物合并进 run.failureAnalysis(尽力而为,不阻断主流程)。
|
|
159
|
+
function recordFailureAnalysis(runDir) {
|
|
160
|
+
try {
|
|
161
|
+
const run = loadRun(runDir);
|
|
162
|
+
if (!run || run.status !== "failed") return;
|
|
163
|
+
const out = JSON.parse(readFileSync(join(runDir, "09-failure-analysis.json"), "utf8"));
|
|
164
|
+
if (!out || !out.category) return;
|
|
165
|
+
run.failureAnalysis = { category: out.category, detail: out.detail || "", action: out.action || "", at: new Date().toISOString() };
|
|
166
|
+
saveRun(runDir, run);
|
|
167
|
+
} catch { /* 产物缺失/损坏时静默跳过 */ }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 推进循环:仅 run.status==="running" 时调用 advance(awaiting_review 停手等 applyReview);
|
|
171
|
+
// 阶段失败自动调 P10 executor 写分类产物后停(v1:不自动 replan)。
|
|
172
|
+
function drive(ctx, root, runDir) {
|
|
173
|
+
const s = sessionFor(runDir);
|
|
174
|
+
// 单 Run 锁(防同 Run 并发推进)之内再套项目锁(防同项目 Run 交错写仓库)—— A3 修复
|
|
175
|
+
const task = s.lock.then(() => withProjectLock(projectKeyOf(root, runDir), async () => {
|
|
176
|
+
// 确保仓库已克隆(幂等:已存在则跳过)。克隆失败 = 流水线无法开工:
|
|
177
|
+
// 快速失败写入 run.json 并走 P10 分类,而不是让 P2 拿空仓库产出垃圾候选。
|
|
178
|
+
// 测试钩子注入执行器时跳过真实 clone / worktree(用例使用假仓库地址)。
|
|
179
|
+
const initRun = loadRun(runDir);
|
|
180
|
+
if (!__testHooks && initRun && initRun.status === "running") {
|
|
181
|
+
const project = loadProject(root, initRun.project);
|
|
182
|
+
if (project) {
|
|
183
|
+
const rcx0 = s.rcx || (s.rcx = buildRcx(ctx, root, runDir, initRun));
|
|
184
|
+
try {
|
|
185
|
+
await ensureRepo(root, project, rcx0);
|
|
186
|
+
// A3:每 Run 独立 worktree(同项目并发 Run 互不污染;创建失败兜底共用基线 repo,靠项目锁串行保安全)
|
|
187
|
+
rcx0.repoDir = await ensureWorktree(root, project.slug, initRun.id, (ev) => logEvent(rcx0, ev));
|
|
188
|
+
// A1:即将执行 P2-P6 时把工作区重置回 HEAD 基线 —— 清掉上一轮已应用补丁与未跟踪残留
|
|
189
|
+
//(P7 应用前自身也会 reset,双保险;P8-P11 不 reset,需要保留 P7 已应用的补丁状态)
|
|
190
|
+
const nextId = MAIN_FLOW.find((id) => initRun.stages[id] && initRun.stages[id].status !== "approved");
|
|
191
|
+
if (nextId && MAIN_FLOW.indexOf(nextId) <= MAIN_FLOW.indexOf("P6")) {
|
|
192
|
+
await resetRepoClean(rcx0.repoDir);
|
|
193
|
+
logEvent(rcx0, { kind: "git", name: "工作区基线重置", detail: nextId + " 执行前 git reset --hard + git clean -fd(清除上一轮补丁/未跟踪残留)" });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
catch (e) {
|
|
197
|
+
const msg = "仓库准备失败: " + String((e && e.message) || e);
|
|
198
|
+
const run = failRun(runDir, initRun.current, msg);
|
|
199
|
+
if (run) {
|
|
200
|
+
const rcx = s.rcx;
|
|
201
|
+
rcx.run = run;
|
|
202
|
+
try {
|
|
203
|
+
await rcx.executors.P10({ ...rcx, failure: { stage: run.current, error: msg } });
|
|
204
|
+
recordFailureAnalysis(runDir);
|
|
205
|
+
} catch { /* P10 自身失败不阻断主流程 */ }
|
|
206
|
+
}
|
|
207
|
+
ctx.logger?.warn?.("issue2pr: " + msg);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
for (;;) {
|
|
213
|
+
const run = loadRun(runDir);
|
|
214
|
+
if (!run) {
|
|
215
|
+
// run.json 不在了 = 运行中被删除:清掉执行器可能重建的孤儿产物目录
|
|
216
|
+
try { rmTree(runDir); } catch { /* 尽力清理 */ }
|
|
217
|
+
runSessions.delete(runDir);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
// A4 修正:停在 awaiting_review 且属"委外等待 + 无人工门"(全自动模式)时,
|
|
221
|
+
// 挂一个就绪监听:拿到委外结果 → 机器验证 → 验证 ok 自动放行流转
|
|
222
|
+
if (run.status !== "running") { maybeWatchDelegate(ctx, root, runDir, run); return; }
|
|
223
|
+
const rcx = s.rcx || (s.rcx = buildRcx(ctx, root, runDir, run));
|
|
224
|
+
rcx.run = run; // 刷新为最新落盘状态(reviewComment 保留在 rcx 上)
|
|
225
|
+
rcx.project = loadProject(root, run.project) || rcx.project; // 配置页改动下一阶段生效
|
|
226
|
+
await advance(rcx);
|
|
227
|
+
if (run.status === "failed") {
|
|
228
|
+
try {
|
|
229
|
+
await rcx.executors.P10({ ...rcx, failure: { stage: run.current, error: run.stages[run.current]?.error } });
|
|
230
|
+
recordFailureAnalysis(runDir);
|
|
231
|
+
} catch { /* P10 自身失败不阻断主流程 */ }
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}));
|
|
236
|
+
s.lock = task.then(() => {}, () => {}); // 失败不污染锁链
|
|
237
|
+
return s.lock;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// —— A4 修正:全自动模式的委外产物就绪监听(拿到结果 + 验证 ok 才自动流转)——
|
|
241
|
+
// 触发条件:drive 停在 awaiting_review,当前阶段是委托阶段且无人工门(reviewMode=auto,
|
|
242
|
+
// 或 key-only 下未设门的委托阶段)。有人工门(every / key-only 的门阶段)不自动放行,
|
|
243
|
+
// 验证由 applyReview 的人工"通过"动作触发(同一验证函数,同一口径)。
|
|
244
|
+
// 语义:拿到委外结果 → verifyDelegateResult(结构 + 对 HEAD 基线的应用性演练)→
|
|
245
|
+
// 验证 ok → 自动放行(落 auto-approve 复核记录,机器决策可审计)+ 继续推进;
|
|
246
|
+
// 验证不过 → 容错窗口内继续等待(外部会话可能还在写产物);连续
|
|
247
|
+
// DELEGATE_VERIFY_MAX_FAILS 次不过(产物稳定存在但不可用)→ Run 显式失败。
|
|
248
|
+
export const DELEGATE_WATCH_INTERVAL_MS =
|
|
249
|
+
Number(process.env.ISSUE2PR_DELEGATE_WATCH_MS) > 0 ? Number(process.env.ISSUE2PR_DELEGATE_WATCH_MS) : 5000;
|
|
250
|
+
export const DELEGATE_VERIFY_MAX_FAILS =
|
|
251
|
+
Number(process.env.ISSUE2PR_DELEGATE_VERIFY_MAX_FAILS) > 0 ? Number(process.env.ISSUE2PR_DELEGATE_VERIFY_MAX_FAILS) : 3;
|
|
252
|
+
|
|
253
|
+
function stopDelegateWatch(runDir) {
|
|
254
|
+
const s = runSessions.get(runDir);
|
|
255
|
+
if (s && s.watch) { clearInterval(s.watch); s.watch = null; }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function maybeWatchDelegate(ctx, root, runDir, run) {
|
|
259
|
+
if (!run || run.status !== "awaiting_review") return;
|
|
260
|
+
const s = sessionFor(runDir);
|
|
261
|
+
if (s.watch) return; // 已在监听
|
|
262
|
+
const rcx = s.rcx || (s.rcx = buildRcx(ctx, root, runDir, run));
|
|
263
|
+
rcx.run = run;
|
|
264
|
+
rcx.project = loadProject(root, run.project) || rcx.project;
|
|
265
|
+
if (!stageDelegated(rcx, run.current) || isGate(run, run.current)) return; // 人工门交人工
|
|
266
|
+
s.watch = setInterval(async () => {
|
|
267
|
+
const r = await delegateWatchTick(ctx, root, runDir);
|
|
268
|
+
if (r !== "waiting" && r !== "verify-fail") stopDelegateWatch(runDir);
|
|
269
|
+
if (r === "advanced") drive(ctx, root, runDir);
|
|
270
|
+
}, DELEGATE_WATCH_INTERVAL_MS);
|
|
271
|
+
s.watch.unref?.(); // 不阻止进程退出(测试 / 停服场景)
|
|
272
|
+
ctx.logger?.info?.("issue2pr: 全自动模式监听委外产物就绪(每 " + DELEGATE_WATCH_INTERVAL_MS + "ms 验证一次)");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 单次监听推进(导出供测试直接驱动)。
|
|
276
|
+
// 返回: idle(非委托等待态) | gate(人工门,交人工) | waiting(未拿到委外结果)
|
|
277
|
+
// | verify-fail(验证未过,仍在容错窗口) | advanced(验证过 → 已自动放行) | failed(连续不过 → Run 显式失败)
|
|
278
|
+
export async function delegateWatchTick(ctx, root, runDir) {
|
|
279
|
+
const run = loadRun(runDir);
|
|
280
|
+
if (!run || run.status !== "awaiting_review") return "idle";
|
|
281
|
+
const id = run.current;
|
|
282
|
+
const s = sessionFor(runDir);
|
|
283
|
+
const rcx = s.rcx || (s.rcx = buildRcx(ctx, root, runDir, run));
|
|
284
|
+
rcx.run = run;
|
|
285
|
+
rcx.project = loadProject(root, run.project) || rcx.project;
|
|
286
|
+
if (!stageDelegated(rcx, id)) return "idle";
|
|
287
|
+
if (isGate(run, id)) return "gate"; // 复核模式中途改为有人工门 → 交人工
|
|
288
|
+
if (!delegateReady(runDir, id)) return "waiting"; // 还没拿到委外结果
|
|
289
|
+
const v = await verifyDelegateResult(rcx, id);
|
|
290
|
+
logEvent(rcx, { kind: "stage", name: id + " 委外产物自动验证" + (v.ok ? "通过" : "未通过"),
|
|
291
|
+
detail: v.ok
|
|
292
|
+
? "结构完整" + (v.rehearsal ? ",对 HEAD 基线应用性演练通过(" + v.patches + " 份补丁)" : "")
|
|
293
|
+
: v.errors.join(";"),
|
|
294
|
+
ok: v.ok });
|
|
295
|
+
if (v.ok) {
|
|
296
|
+
// 自动放行:落 auto-approve 复核记录(审计可见这是机器决策,非人工)
|
|
297
|
+
delete run.delegateVerifyFails;
|
|
298
|
+
const st = run.stages[id];
|
|
299
|
+
st.status = "approved"; st.finishedAt = new Date().toISOString();
|
|
300
|
+
run.status = "running";
|
|
301
|
+
writeArtifact(runDir, `reviews/${timestamp()}-auto-approve-${id}.json`, JSON.stringify({
|
|
302
|
+
stage: id, decision: "approve", auto: true,
|
|
303
|
+
comment: "全自动模式:委外产物就绪且机器验证通过,自动放行",
|
|
304
|
+
verification: { ok: true, patches: v.patches, rehearsal: v.rehearsal },
|
|
305
|
+
at: new Date().toISOString(),
|
|
306
|
+
}, null, 2));
|
|
307
|
+
saveRun(runDir, run);
|
|
308
|
+
return "advanced";
|
|
309
|
+
}
|
|
310
|
+
run.delegateVerifyFails = (run.delegateVerifyFails || 0) + 1;
|
|
311
|
+
if (run.delegateVerifyFails < DELEGATE_VERIFY_MAX_FAILS) {
|
|
312
|
+
saveRun(runDir, run); // 计数落盘:外部会话可能仍在写产物,给容错窗口
|
|
313
|
+
return "verify-fail";
|
|
314
|
+
}
|
|
315
|
+
// 连续 N 次不过:产物稳定存在但不可用 → 显式失败(不无限等待,更不空手放行)
|
|
316
|
+
delete run.delegateVerifyFails;
|
|
317
|
+
const st = run.stages[id];
|
|
318
|
+
const msg = id + " 委外产物验证连续 " + DELEGATE_VERIFY_MAX_FAILS + " 次未通过: " + v.errors.join(";")
|
|
319
|
+
+ ";请回退该阶段重跑,或修正外部产物";
|
|
320
|
+
st.status = "failed"; st.error = msg;
|
|
321
|
+
run.status = "failed";
|
|
322
|
+
saveRun(runDir, run);
|
|
323
|
+
rcx.run = run;
|
|
324
|
+
try {
|
|
325
|
+
await rcx.executors.P10({ ...rcx, failure: { stage: id, error: msg } });
|
|
326
|
+
recordFailureAnalysis(runDir);
|
|
327
|
+
} catch { /* P10 自身失败不阻断主流程 */ }
|
|
328
|
+
return "failed";
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// 启动恢复:dsh 进程重启后,盘上遗留 status==="running" 的 Run 已无驱动循环,
|
|
332
|
+
// 置为 stopped(阶段状态同步),避免 UI 永远显示「运行中」。可用「重跑」续跑。
|
|
333
|
+
export function recoverInterruptedRuns(root) {
|
|
334
|
+
const projectsDir = join(root, "projects");
|
|
335
|
+
if (!existsSync(projectsDir)) return;
|
|
336
|
+
for (const slug of readdirSync(projectsDir)) {
|
|
337
|
+
const runsDir = join(projectsDir, slug, "runs");
|
|
338
|
+
if (!existsSync(runsDir)) continue;
|
|
339
|
+
for (const id of readdirSync(runsDir)) {
|
|
340
|
+
const runDir = join(runsDir, id);
|
|
341
|
+
const run = loadRun(runDir);
|
|
342
|
+
if (!run || run.status !== "running") continue;
|
|
343
|
+
const st = run.stages[run.current];
|
|
344
|
+
if (st && st.status === "running") st.status = "stopped";
|
|
345
|
+
run.status = "stopped";
|
|
346
|
+
run.interruptedAt = new Date().toISOString();
|
|
347
|
+
saveRun(runDir, run);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function handleApi(ctx, root, req, res) {
|
|
353
|
+
const url = new URL(req.url, "http://localhost");
|
|
354
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
355
|
+
const m = req.method;
|
|
356
|
+
try {
|
|
357
|
+
if (m === "GET" && parts.join("/") === "issue2pr/api/ping") {
|
|
358
|
+
return sendJson(res, 200, { ok: true, plugin: "dsh-issue2pr" });
|
|
359
|
+
}
|
|
360
|
+
// —— 阶段默认值与能力表(配置页数据源:默认提示词 / 可配能力 / 默认超时) ——
|
|
361
|
+
if (m === "GET" && parts.join("/") === "issue2pr/api/stage-defaults") {
|
|
362
|
+
return sendJson(res, 200, {
|
|
363
|
+
ok: true,
|
|
364
|
+
defaults: {
|
|
365
|
+
llmTimeoutMs: DEFAULT_LLM_TIMEOUT_MS, testTimeoutMs: DEFAULT_TEST_TIMEOUT_MS,
|
|
366
|
+
maxTokens: 8192,
|
|
367
|
+
stages: Object.fromEntries(Object.entries(STAGE_DEFS).map(([id, def]) => [id, {
|
|
368
|
+
name: def.name, desc: def.desc, caps: def.caps, prompts: def.prompts,
|
|
369
|
+
params: Object.keys(def.params || {}).length ? def.params : undefined,
|
|
370
|
+
delegateSpec: def.caps.delegate ? def.delegateSpec : undefined,
|
|
371
|
+
}])),
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
if (parts[0] !== "issue2pr" || parts[1] !== "api") {
|
|
376
|
+
return sendJson(res, 404, { ok: false, message: "not found" });
|
|
377
|
+
}
|
|
378
|
+
// —— /issue2pr/api/ui-state:UI 偏好兜底存储(宿主重启丢 localStorage 时恢复选中记忆) ——
|
|
379
|
+
if (parts[2] === "ui-state" && !parts[3]) {
|
|
380
|
+
if (m === "GET") return sendJson(res, 200, { ok: true, state: loadUiState(root) });
|
|
381
|
+
if (m === "POST") {
|
|
382
|
+
const body = await readBody(req);
|
|
383
|
+
const prev = loadUiState(root);
|
|
384
|
+
// lastProject:只认本字段;slug 走既有白名单形态,null/空 = 清除
|
|
385
|
+
const lp = body && Object.prototype.hasOwnProperty.call(body, "lastProject") ? body.lastProject : prev.lastProject;
|
|
386
|
+
// lastRunBySlug:按项目记最近选中的 Run(宿主标签切换会销毁重建插件 webview,选中现场以此恢复);
|
|
387
|
+
// 按键合并:值合法则覆盖,null = 清除该项目的记忆,非法条目忽略
|
|
388
|
+
const lrPrev = (prev.lastRunBySlug && typeof prev.lastRunBySlug === "object") ? { ...prev.lastRunBySlug } : {};
|
|
389
|
+
if (body && body.lastRunBySlug && typeof body.lastRunBySlug === "object") {
|
|
390
|
+
for (const [s, rid] of Object.entries(body.lastRunBySlug)) {
|
|
391
|
+
if (!/^[a-z0-9-]+$/.test(s)) continue;
|
|
392
|
+
if (rid === null) delete lrPrev[s];
|
|
393
|
+
else if (/^\d{8}-\d{6}-[a-z0-9-]+$/.test(String(rid))) lrPrev[s] = String(rid);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
// 智能助手面板尺寸(跨软件重启兜底):整数且在合法范围才更新,非法值忽略保留旧值
|
|
397
|
+
const intIn = (v, lo, hi) => (Number.isInteger(v) && v >= lo && v <= hi) ? v : null;
|
|
398
|
+
const state = {
|
|
399
|
+
...prev,
|
|
400
|
+
lastProject: (typeof lp === "string" && /^[a-z0-9-]+$/.test(lp)) ? lp : null,
|
|
401
|
+
lastRunBySlug: lrPrev,
|
|
402
|
+
aiW: (body ? intIn(body.aiW, 240, 760) : null) ?? prev.aiW,
|
|
403
|
+
aiH: (body ? intIn(body.aiH, 200, 1800) : null) ?? prev.aiH,
|
|
404
|
+
aiR: (body ? intIn(body.aiR, 0, 4000) : null) ?? prev.aiR,
|
|
405
|
+
aiT: (body ? intIn(body.aiT, 44, 2000) : null) ?? prev.aiT,
|
|
406
|
+
aiTop: (body ? intIn(body.aiTop, 44, 600) : null) ?? prev.aiTop,
|
|
407
|
+
};
|
|
408
|
+
saveUiState(root, state);
|
|
409
|
+
return sendJson(res, 200, { ok: true, state });
|
|
410
|
+
}
|
|
411
|
+
return sendJson(res, 404, { ok: false, message: "not found" });
|
|
412
|
+
}
|
|
413
|
+
// —— /issue2pr/api/connections:Git 托管连接(GitHub/GitLab/CodeArts 凭据,全局共享,所有项目复用) ——
|
|
414
|
+
// token 明文存于 <dataRoot>/connections.json(与本机 GITHUB_TOKEN 环境变量同级安全);返回给 UI 一律脱敏。
|
|
415
|
+
if (parts[2] === "connections") {
|
|
416
|
+
const masked = (c) => ({ ...c, token: maskToken(c.token) });
|
|
417
|
+
if (!parts[3] && m === "GET") {
|
|
418
|
+
return sendJson(res, 200, { ok: true, connections: loadConnections(root).map(masked) });
|
|
419
|
+
}
|
|
420
|
+
if (!parts[3] && m === "POST") {
|
|
421
|
+
const body = await readBody(req);
|
|
422
|
+
try { return sendJson(res, 200, { ok: true, connection: masked(upsertConnection(root, body)) }); }
|
|
423
|
+
catch (e) { return sendJson(res, 400, { ok: false, message: (e && e.message) || String(e) }); }
|
|
424
|
+
}
|
|
425
|
+
if (parts[3] && !parts[4] && m === "DELETE") {
|
|
426
|
+
const removed = deleteConnection(root, decodeURIComponent(parts[3]));
|
|
427
|
+
if (!removed) return sendJson(res, 404, { ok: false, message: "连接不存在" });
|
|
428
|
+
return sendJson(res, 200, { ok: true });
|
|
429
|
+
}
|
|
430
|
+
// —— 连接探活:github/gitlab 调 /user 回显账号名;支持未保存前直接测输入值(添加表单用) ——
|
|
431
|
+
if (parts[3] === "test" && m === "POST") {
|
|
432
|
+
const body = await readBody(req);
|
|
433
|
+
let conn = null;
|
|
434
|
+
if (body && body.token && body.kind) {
|
|
435
|
+
try { conn = normalizeConnection(body, loadConnections(root)); }
|
|
436
|
+
catch (e) { return sendJson(res, 400, { ok: false, message: (e && e.message) || String(e) }); }
|
|
437
|
+
} else {
|
|
438
|
+
conn = loadConnections(root).find((c) => c.id === (body && body.id)) || null;
|
|
439
|
+
if (!conn) return sendJson(res, 404, { ok: false, message: "连接不存在" });
|
|
440
|
+
}
|
|
441
|
+
if (conn.kind === "codearts") {
|
|
442
|
+
return sendJson(res, 200, { ok: false, message: "CodeArts 无账号探活 API,请在下方仓库地址行点「测试」用真实仓库验证连通" });
|
|
443
|
+
}
|
|
444
|
+
const api = conn.kind === "github" ? `https://api.${conn.host}/user` : `https://${conn.host}/api/v4/user`;
|
|
445
|
+
try {
|
|
446
|
+
const resp = await fetch(api, {
|
|
447
|
+
headers: { "User-Agent": "dsh-issue2pr", Authorization: "Bearer " + conn.token },
|
|
448
|
+
signal: AbortSignal.timeout(10000),
|
|
449
|
+
});
|
|
450
|
+
if (!resp.ok) return sendJson(res, 200, { ok: false, message: "凭据无效 (HTTP " + resp.status + ")" });
|
|
451
|
+
const data = await resp.json();
|
|
452
|
+
return sendJson(res, 200, { ok: true, account: data.login || data.username || "" });
|
|
453
|
+
} catch (e) { return sendJson(res, 200, { ok: false, message: "请求失败: " + String((e && e.message) || e) }); }
|
|
454
|
+
}
|
|
455
|
+
// —— 真实仓库连通性:匹配连接注入凭据后 git ls-remote(三类托管通用;CodeArts 的唯一探活手段) ——
|
|
456
|
+
if (parts[3] === "test-repo" && m === "POST") {
|
|
457
|
+
const body = await readBody(req);
|
|
458
|
+
const uri = typeof body?.uri === "string" ? body.uri.trim() : "";
|
|
459
|
+
if (!uri) return sendJson(res, 400, { ok: false, message: "uri 必填" });
|
|
460
|
+
const conn = matchConnection(uri, loadConnections(root));
|
|
461
|
+
const injected = injectGitCredentials(uri, conn);
|
|
462
|
+
const runGit = __testHooks?.runGit || ((args, opts, cb) => execFile("git", args, opts, cb));
|
|
463
|
+
const t0 = Date.now();
|
|
464
|
+
runGit(["ls-remote", "--heads", injected], { timeout: 15000, windowsHide: true }, (err, stdout, stderr) => {
|
|
465
|
+
if (err) {
|
|
466
|
+
return sendJson(res, 200, {
|
|
467
|
+
ok: false, matched: conn ? conn.id : null,
|
|
468
|
+
message: "不可达: " + redactUrl(String(stderr || err.message)).slice(0, 300),
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
const heads = String(stdout).trim().split("\n").filter(Boolean).length;
|
|
472
|
+
sendJson(res, 200, {
|
|
473
|
+
ok: true, matched: conn ? conn.id : null, ms: Date.now() - t0,
|
|
474
|
+
message: "可达(" + heads + " 个分支" + (conn ? "" : ",匿名访问,未匹配连接") + ")",
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
return sendJson(res, 404, { ok: false, message: "not found" });
|
|
480
|
+
}
|
|
481
|
+
// —— /issue2pr/api/agents/discover:委外智能体(claude CLI)多方式发现 ——
|
|
482
|
+
// 五种来源去重合并:项目配置 > 环境变量 > 常见安装位置 > npm 全局目录 > PATH 查找;
|
|
483
|
+
// ?slug= 带项目时把该项目配置的 claudeBin 列为首位候选。测试钩子环境未注入 runNpmPrefix
|
|
484
|
+
// 时跳过 npm 来源(避免单测真跑 npm config get prefix)。
|
|
485
|
+
if (parts[2] === "agents" && parts[3] === "discover" && !parts[4] && m === "GET") {
|
|
486
|
+
const slugQ = url.searchParams.get("slug");
|
|
487
|
+
const project = (slugQ && /^[a-z0-9-]+$/.test(slugQ)) ? loadProject(root, slugQ) : null;
|
|
488
|
+
const out = await discoverAgents({
|
|
489
|
+
cfgBin: (project && project.stageConfig && project.stageConfig.P6 && project.stageConfig.P6.params && project.stageConfig.P6.params.claudeBin) || "",
|
|
490
|
+
runWhich: __testHooks?.runWhich || realRunWhich,
|
|
491
|
+
runNpmPrefix: __testHooks ? __testHooks.runNpmPrefix : realRunNpmPrefix,
|
|
492
|
+
});
|
|
493
|
+
return sendJson(res, 200, { ok: true, agents: out.agents, resolved: out.resolved });
|
|
494
|
+
}
|
|
495
|
+
// —— /issue2pr/api/agents/test:委外智能体测试门禁 ——
|
|
496
|
+
// 三步:定位 → --version → headless 认证微任务(真实极小调用,403 IP 白名单/未登录在此拦截)。
|
|
497
|
+
// body {bin?: string, timeoutMs?: 10000..180000};ok=false 也回 200(与 connections/test 同约定)。
|
|
498
|
+
if (parts[2] === "agents" && parts[3] === "test" && !parts[4] && m === "POST") {
|
|
499
|
+
const body = await readBody(req);
|
|
500
|
+
const bin = typeof body?.bin === "string" ? body.bin.trim() : "";
|
|
501
|
+
const t = Number(body?.timeoutMs);
|
|
502
|
+
const timeoutMs = Number.isFinite(t) && t >= 10000 && t <= 180000 ? t : 120000;
|
|
503
|
+
const gate = await testAgentGate({ bin, timeoutMs, runners: (__testHooks && __testHooks.agentProbes) || {} });
|
|
504
|
+
return sendJson(res, 200, { ok: gate.ok, gate });
|
|
505
|
+
}
|
|
506
|
+
// —— /issue2pr/api/preflight:环境健康探测(git 二进制 / claude CLI / LLM 默认路由与来源) ——
|
|
507
|
+
// 纯只读探测:git --version、claudeBin 解析 + 存在性/PATH 校验、resolveRoute 现算;不发真实 LLM 请求。
|
|
508
|
+
if (parts[2] === "preflight" && !parts[3] && m === "GET") {
|
|
509
|
+
const runGit = __testHooks?.runGit || ((args, opts, cb) => execFile("git", args, opts, cb));
|
|
510
|
+
const runWhich = __testHooks?.runWhich
|
|
511
|
+
|| ((args, opts, cb) => execFile(process.platform === "win32" ? "where" : "which", args, opts, cb));
|
|
512
|
+
const slugQ = url.searchParams.get("slug");
|
|
513
|
+
const project = (slugQ && /^[a-z0-9-]+$/.test(slugQ)) ? loadProject(root, slugQ) : null;
|
|
514
|
+
const claudeBin = resolveClaudeBin(project?.stageConfig?.P6?.params?.claudeBin || "");
|
|
515
|
+
const [git, claude] = await Promise.all([
|
|
516
|
+
new Promise((r) => runGit(["--version"], { timeout: 5000 }, (err, stdout) =>
|
|
517
|
+
r(err ? { ok: false, message: String(err.message || err) } : { ok: true, version: String(stdout).trim() }))),
|
|
518
|
+
new Promise((r) => {
|
|
519
|
+
if (existsSync(claudeBin)) return r({ ok: true, path: claudeBin });
|
|
520
|
+
if (/^claude(\.cmd|\.exe)?$/i.test(claudeBin)) {
|
|
521
|
+
// 兜底裸名"claude":靠 where/which 验证是否在 PATH(显式配置的完整路径则不必,存在性即结论)
|
|
522
|
+
return runWhich([claudeBin], { timeout: 5000 }, (err, stdout) => {
|
|
523
|
+
const hit = !err && String(stdout).trim();
|
|
524
|
+
r(hit ? { ok: true, path: String(stdout).trim().split(/\r?\n/)[0] } : { ok: false, path: claudeBin });
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
r({ ok: false, path: claudeBin });
|
|
528
|
+
}),
|
|
529
|
+
]);
|
|
530
|
+
const info = routeInfo(ctx, {});
|
|
531
|
+
const overrides = {};
|
|
532
|
+
if (project?.stageConfig) {
|
|
533
|
+
for (const id of Object.keys(STAGE_DEFS)) {
|
|
534
|
+
const cfg = project.stageConfig[id];
|
|
535
|
+
if (cfg && cfg.provider && cfg.model) overrides[id] = { provider: cfg.provider, model: cfg.model };
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return sendJson(res, 200, { ok: true, preflight: { git, claude, llm: { ...info.route, source: info.source, overrides } } });
|
|
539
|
+
}
|
|
540
|
+
// —— /issue2pr/api/check-local:本地触发源存在性(项目页触发源行内即时提示) ——
|
|
541
|
+
if (parts[2] === "check-local" && !parts[3] && m === "POST") {
|
|
542
|
+
const body = await readBody(req);
|
|
543
|
+
const p = typeof body?.path === "string" ? body.path.trim() : "";
|
|
544
|
+
if (!p) return sendJson(res, 400, { ok: false, message: "path 必填" });
|
|
545
|
+
return sendJson(res, 200, { ok: true, exists: existsSync(p) });
|
|
546
|
+
}
|
|
547
|
+
// —— /issue2pr/api/assistant/ask:悬浮智能助手(LLM 走宿主 ctx.llm,流式 JSONL 响应) ——
|
|
548
|
+
// body {question, history:[{role,text}], focus:{nav,slug,runId}};每行 {"delta"} … 末行 {"done":true} 或 {"error"}
|
|
549
|
+
// 上下文每次现读 buildAssistantContext(数据最新);客户端断开(close)即 abort 生成
|
|
550
|
+
if (parts[2] === "assistant" && parts[3] === "ask" && m === "POST") {
|
|
551
|
+
const body = await readBody(req);
|
|
552
|
+
const question = typeof body?.question === "string" ? body.question.trim() : "";
|
|
553
|
+
if (!question || question.length > 4000) return sendJson(res, 400, { ok: false, message: "question 必填且不超过 4000 字" });
|
|
554
|
+
const history = (Array.isArray(body?.history) ? body.history : [])
|
|
555
|
+
.filter((x) => x && (x.role === "user" || x.role === "assistant") && typeof x.text === "string" && x.text.trim())
|
|
556
|
+
.slice(-12).map((x) => ({ role: x.role, text: x.text.slice(0, 4000) }));
|
|
557
|
+
const focus = body?.focus && typeof body.focus === "object" ? body.focus : {};
|
|
558
|
+
const fSlug = typeof focus.slug === "string" && /^[a-z0-9-]+$/.test(focus.slug) ? focus.slug : null;
|
|
559
|
+
const fRunId = typeof focus.runId === "string" && /^\d{8}-\d{6}-[a-z0-9-]+$/.test(focus.runId) ? focus.runId : null;
|
|
560
|
+
|
|
561
|
+
const system = ASSISTANT_SYSTEM_HEAD + "\n\n" + buildAssistantContext(root, { nav: focus.nav, slug: fSlug, runId: fRunId });
|
|
562
|
+
const messages = [...history, { role: "user", text: question }];
|
|
563
|
+
|
|
564
|
+
let finished = false;
|
|
565
|
+
const ac = new AbortController();
|
|
566
|
+
req.on("close", () => { if (!finished) ac.abort(); });
|
|
567
|
+
// 响应已开始流式写入后不能再走 sendJson(外层 catch 的 500 会二次 writeHead),
|
|
568
|
+
// 因此端点内部消化一切错误:已写头则补 {"error"} 行,未写头前出错仍可 sendJson
|
|
569
|
+
let headerSent = false;
|
|
570
|
+
try {
|
|
571
|
+
const llm = makeLlm(ctx, null, () => ({}));
|
|
572
|
+
await llm.streamText({
|
|
573
|
+
system, messages, maxTokens: 8192, signal: ac.signal,
|
|
574
|
+
onDelta: (d) => {
|
|
575
|
+
if (!headerSent) {
|
|
576
|
+
res.writeHead(200, { "Content-Type": "application/x-ndjson; charset=utf-8", "Cache-Control": "no-store", "Transfer-Encoding": "chunked" });
|
|
577
|
+
headerSent = true;
|
|
578
|
+
}
|
|
579
|
+
res.write(JSON.stringify({ delta: d }) + "\n");
|
|
580
|
+
},
|
|
581
|
+
});
|
|
582
|
+
if (!headerSent) {
|
|
583
|
+
res.writeHead(200, { "Content-Type": "application/x-ndjson; charset=utf-8", "Cache-Control": "no-store", "Transfer-Encoding": "chunked" });
|
|
584
|
+
headerSent = true;
|
|
585
|
+
}
|
|
586
|
+
res.write(JSON.stringify({ done: true }) + "\n");
|
|
587
|
+
} catch (e) {
|
|
588
|
+
if (!headerSent) return sendJson(res, 500, { ok: false, message: (e && e.message) || String(e) });
|
|
589
|
+
try { res.write(JSON.stringify({ error: String((e && e.message) || e) }) + "\n"); } catch { /* 连接已断 */ }
|
|
590
|
+
} finally {
|
|
591
|
+
finished = true;
|
|
592
|
+
res.end();
|
|
593
|
+
}
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
if (parts[2] !== "projects") {
|
|
597
|
+
return sendJson(res, 404, { ok: false, message: "not found" });
|
|
598
|
+
}
|
|
599
|
+
const slug = parts[3];
|
|
600
|
+
const p = parts[4];
|
|
601
|
+
|
|
602
|
+
// —— /issue2pr/api/projects(集合,无 slug) ——
|
|
603
|
+
if (!slug) {
|
|
604
|
+
if (m === "GET") return sendJson(res, 200, { ok: true, projects: listProjects(root) });
|
|
605
|
+
if (m === "POST") {
|
|
606
|
+
const body = await readBody(req);
|
|
607
|
+
// —— 委外智能体保存门禁(兜底):p6Mode=claude 时 claude CLI 必须可运行(--version 快检) ——
|
|
608
|
+
// 认证级校验(403 IP 白名单等)由项目页「测试门禁」真实微任务完成;此处拦路径写错/未安装。
|
|
609
|
+
// 测试钩子环境未注入 agentProbes 时跳过(既有单测不依赖本机 claude)。
|
|
610
|
+
if (body && body.p6Mode === "claude" && (!__testHooks || __testHooks.agentProbes)) {
|
|
611
|
+
const bin = resolveClaudeBin((body.stageConfig && body.stageConfig.P6 && body.stageConfig.P6.params && body.stageConfig.P6.params.claudeBin) || "");
|
|
612
|
+
const runV = __testHooks?.agentProbes?.runVersion || realRunVersion;
|
|
613
|
+
const r = await new Promise((res2) => runV(bin, {}, (err) => res2({ err })));
|
|
614
|
+
if (r.err) {
|
|
615
|
+
return sendJson(res, 400, { ok: false, message: "委外智能体门禁未通过:claude CLI 无法运行(" + String((r.err && r.err.message) || r.err).slice(0, 200) + ")。请在「项目」页 P6 执行模式选「委托 Claude Code」,用委外智能体卡片选择可用安装或修正路径,通过测试门禁后再保存。" });
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
try { saveProject(root, body); }
|
|
619
|
+
catch (e) { return sendJson(res, 400, { ok: false, message: (e && e.message) || String(e) }); }
|
|
620
|
+
return sendJson(res, 200, { ok: true, project: body });
|
|
621
|
+
}
|
|
622
|
+
return sendJson(res, 404, { ok: false, message: "not found" });
|
|
623
|
+
}
|
|
624
|
+
if (!/^[a-z0-9-]+$/.test(slug)) return sendJson(res, 400, { ok: false, message: "非法 slug" });
|
|
625
|
+
|
|
626
|
+
// —— 删除项目(整目录:project.json + runs + repo 克隆);?confirm=slug 防误删 ——
|
|
627
|
+
if (!parts[4] && m === "DELETE") {
|
|
628
|
+
const dir = join(root, "projects", slug);
|
|
629
|
+
if (!existsSync(dir)) return sendJson(res, 404, { ok: false, message: "项目不存在" });
|
|
630
|
+
if (url.searchParams.get("confirm") !== slug) return sendJson(res, 400, { ok: false, message: "缺少 confirm=slug 确认参数" });
|
|
631
|
+
// 运行中/待复核的 Run 先落 stopped(驱动循环下一圈读盘即退出;目录随后整体删除)
|
|
632
|
+
const runsDir = join(dir, "runs");
|
|
633
|
+
let stopped = 0;
|
|
634
|
+
if (existsSync(runsDir)) {
|
|
635
|
+
for (const id of readdirSync(runsDir)) {
|
|
636
|
+
const rd = join(runsDir, id);
|
|
637
|
+
const r = loadRun(rd);
|
|
638
|
+
if (r && (r.status === "running" || r.status === "awaiting_review")) {
|
|
639
|
+
r.status = "stopped";
|
|
640
|
+
if (r.stages[r.current] && r.stages[r.current].status === "running") r.stages[r.current].status = "stopped";
|
|
641
|
+
saveRun(rd, r);
|
|
642
|
+
stopDelegateWatch(rd);
|
|
643
|
+
runSessions.delete(rd);
|
|
644
|
+
killExternal(rd);
|
|
645
|
+
stopped += 1;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
try { rmTree(dir); }
|
|
650
|
+
catch (e) { return sendJson(res, 500, { ok: false, message: "删除失败: " + String((e && e.message) || e) }); }
|
|
651
|
+
return sendJson(res, 200, { ok: true, message: stopped > 0 ? `项目已删除(含 ${stopped} 个进行中的 Run,已一并停止移除)` : "项目已删除" });
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const project = loadProject(root, slug);
|
|
655
|
+
const runsDir = join(root, "projects", slug, "runs");
|
|
656
|
+
|
|
657
|
+
// —— /issue2pr/api/projects/:slug/runs(集合) ——
|
|
658
|
+
if (p === "runs" && !parts[5]) {
|
|
659
|
+
if (m === "GET") {
|
|
660
|
+
// 扫 runs/*/run.json,出摘要
|
|
661
|
+
const runs = existsSync(runsDir)
|
|
662
|
+
? readdirSync(runsDir).map((id) => {
|
|
663
|
+
const r = loadRun(join(runsDir, id));
|
|
664
|
+
return r ? { id: r.id, status: r.status, current: r.current, trigger: r.trigger, createdAt: r.createdAt } : null;
|
|
665
|
+
}).filter(Boolean).sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))
|
|
666
|
+
: [];
|
|
667
|
+
return sendJson(res, 200, { ok: true, runs });
|
|
668
|
+
}
|
|
669
|
+
if (m === "POST") {
|
|
670
|
+
if (!project) return sendJson(res, 404, { ok: false, message: "项目不存在" });
|
|
671
|
+
const body = await readBody(req);
|
|
672
|
+
const { kind, uri } = body || {};
|
|
673
|
+
if ((kind !== "issue" && kind !== "requirement") || typeof uri !== "string" || !uri.trim()) {
|
|
674
|
+
return sendJson(res, 400, { ok: false, message: "kind 仅允许 issue|requirement 且 uri 必填" });
|
|
675
|
+
}
|
|
676
|
+
const trigger = { kind, uri };
|
|
677
|
+
let text;
|
|
678
|
+
try { text = await readTriggerText({ trigger, connections: loadConnections(root) }); } // 读触发文本(连接凭据优先于环境变量)
|
|
679
|
+
catch (e) { return sendJson(res, 400, { ok: false, message: (e && e.message) || String(e) }); }
|
|
680
|
+
let created;
|
|
681
|
+
try { created = createRun(root, slug, trigger); } // 同秒同触发源重复发起 → Run 已存在 → 409
|
|
682
|
+
catch (e) { return sendJson(res, 409, { ok: false, message: (e && e.message) || String(e) }); }
|
|
683
|
+
const { runId, runDir } = created;
|
|
684
|
+
const run = initRun({ runId, slug, trigger: { ...trigger, text }, reviewMode: project.reviewMode, p6Mode: project.p6Mode });
|
|
685
|
+
run.status = "running"; // 发起即进入运行态(drive 只在 running 时推进)
|
|
686
|
+
saveRun(runDir, run);
|
|
687
|
+
sendJson(res, 200, { ok: true, runId });
|
|
688
|
+
setImmediate(() => drive(ctx, root, runDir)); // 同步返回后异步推进
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
return sendJson(res, 404, { ok: false, message: "not found" });
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// —— /issue2pr/api/projects/:slug/runs/:runId[/action] ——
|
|
695
|
+
if (p === "runs" && parts[5]) {
|
|
696
|
+
const runId = parts[5];
|
|
697
|
+
let runDir;
|
|
698
|
+
try { runDir = runDirOf(root, slug, runId); }
|
|
699
|
+
catch (e) { return sendJson(res, 400, { ok: false, message: (e && e.message) || String(e) }); }
|
|
700
|
+
const action = parts[6];
|
|
701
|
+
|
|
702
|
+
if (!action && m === "GET") {
|
|
703
|
+
const run = loadRun(runDir);
|
|
704
|
+
if (!run) return sendJson(res, 404, { ok: false, message: "run 不存在" });
|
|
705
|
+
if (run.p6Mode === "session" || run.p6Mode === "claude") run.externalProgress = externalProgress(runDir);
|
|
706
|
+
return sendJson(res, 200, run); // 直接吐 run.json(session/claude 模式附带外部执行进度)
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// —— 停止:不再推进(当前正在执行的阶段跑完即停;异步执行器不阻塞本请求) ——
|
|
710
|
+
if (action === "stop" && m === "POST") {
|
|
711
|
+
const run = loadRun(runDir);
|
|
712
|
+
if (!run) return sendJson(res, 404, { ok: false, message: "run 不存在" });
|
|
713
|
+
if (run.status !== "running" && run.status !== "awaiting_review") {
|
|
714
|
+
return sendJson(res, 400, { ok: false, message: "仅运行中/待复核的 Run 可停止(当前: " + run.status + ")" });
|
|
715
|
+
}
|
|
716
|
+
run.status = "stopped";
|
|
717
|
+
if (run.stages[run.current] && run.stages[run.current].status === "running") {
|
|
718
|
+
run.stages[run.current].status = "stopped";
|
|
719
|
+
}
|
|
720
|
+
saveRun(runDir, run);
|
|
721
|
+
killExternal(runDir); // claude 委托在跑时一并终止进程树,避免孤儿进程继续写仓库
|
|
722
|
+
stopDelegateWatch(runDir); // 委外就绪监听一并停止
|
|
723
|
+
return sendJson(res, 200, { ok: true, message: "已停止(当前阶段执行完即停)" });
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// —— 回退重跑:指定阶段及其后全部置为 pending,从该阶段重新推进 ——
|
|
727
|
+
if (action === "rerun" && m === "POST") {
|
|
728
|
+
const run = loadRun(runDir);
|
|
729
|
+
if (!run) return sendJson(res, 404, { ok: false, message: "run 不存在" });
|
|
730
|
+
if (run.status === "running") return sendJson(res, 400, { ok: false, message: "运行中的 Run 请先停止再回退" });
|
|
731
|
+
const body = await readBody(req);
|
|
732
|
+
const stage = String(body?.stage || "");
|
|
733
|
+
const ids = STAGES.map((s) => s.id);
|
|
734
|
+
if (!ids.includes(stage)) return sendJson(res, 400, { ok: false, message: "非法阶段: " + stage });
|
|
735
|
+
const project0 = loadProject(root, slug);
|
|
736
|
+
const shimRcx = project0 ? { run, project: project0, stageCfgOf: (id) => stageCfgOf(project0, id) } : null;
|
|
737
|
+
for (const s of STAGES) {
|
|
738
|
+
const idx = ids.indexOf(s.id);
|
|
739
|
+
if (idx >= ids.indexOf(stage)) {
|
|
740
|
+
run.stages[s.id] = { status: "pending", attempts: run.stages[s.id]?.attempts || 0 };
|
|
741
|
+
// A2 修复:被重置的委托阶段清空旧外部产物 —— 否则 delegateReady 误判就绪、P7 应用过期 patch
|
|
742
|
+
if (shimRcx && stageDelegated(shimRcx, s.id)) purgeDelegateArtifacts(runDir, s.id);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
run.current = stage;
|
|
746
|
+
run.status = "running";
|
|
747
|
+
saveRun(runDir, run);
|
|
748
|
+
stopDelegateWatch(runDir); // 旧监听作废(阶段已重置、产物已清场,等新产物重新就绪)
|
|
749
|
+
sendJson(res, 200, { ok: true, message: "已从 " + stage + " 重跑" });
|
|
750
|
+
setImmediate(() => drive(ctx, root, runDir));
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// —— 删除:整目录移除(先落一个 stopped 状态让推进循环退出;孤儿产物由驱动循环兜底清理) ——
|
|
755
|
+
if (!action && m === "DELETE") {
|
|
756
|
+
if (!existsSync(runDir)) return sendJson(res, 404, { ok: false, message: "run 不存在" });
|
|
757
|
+
const run = loadRun(runDir);
|
|
758
|
+
if (run && (run.status === "running" || run.status === "awaiting_review")) {
|
|
759
|
+
run.status = "stopped";
|
|
760
|
+
saveRun(runDir, run); // 推进循环下一圈 loadRun 读到 stopped 即退出
|
|
761
|
+
}
|
|
762
|
+
stopDelegateWatch(runDir); // 停委外就绪监听,再丢弃会话
|
|
763
|
+
runSessions.delete(runDir); // 丢弃共享 rcx(reviewComment 等),防复活
|
|
764
|
+
killExternal(runDir);
|
|
765
|
+
try { rmTree(runDir); }
|
|
766
|
+
catch (e) { return sendJson(res, 500, { ok: false, message: "删除失败: " + String((e && e.message) || e) }); }
|
|
767
|
+
removeWorktree(root, slug, runId).catch(() => {}); // 尽力清理 per-Run worktree(孤儿可由 prune 收敛)
|
|
768
|
+
return sendJson(res, 200, { ok: true, message: "已删除" });
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
if (action === "review" && m === "POST") {
|
|
772
|
+
const run = loadRun(runDir);
|
|
773
|
+
if (!run) return sendJson(res, 404, { ok: false, message: "run 不存在" });
|
|
774
|
+
const s = sessionFor(runDir);
|
|
775
|
+
const rcx = s.rcx || (s.rcx = buildRcx(ctx, root, runDir, run));
|
|
776
|
+
rcx.run = run;
|
|
777
|
+
const body = await readBody(req);
|
|
778
|
+
const [ok, msg] = await applyReview(rcx, { decision: body?.decision, comment: body?.comment });
|
|
779
|
+
if (!ok) return sendJson(res, 400, { ok: false, message: msg });
|
|
780
|
+
stopDelegateWatch(runDir); // 人工已决策,监听退出(避免与人工决策赛跑)
|
|
781
|
+
sendJson(res, 200, { ok: true, message: msg });
|
|
782
|
+
if (run.status === "running") setImmediate(() => drive(ctx, root, runDir)); // approve/reject 后继续推进
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
if (action === "rollback" && m === "POST") {
|
|
787
|
+
const run = loadRun(runDir);
|
|
788
|
+
// 运行中/待复核时禁止回滚:避免推进循环或后续阶段在半撤销的仓库上继续工作
|
|
789
|
+
if (run && (run.status === "running" || run.status === "awaiting_review")) {
|
|
790
|
+
return sendJson(res, 400, { ok: false, message: "Run 运行中/待复核,请先停止再回滚" });
|
|
791
|
+
}
|
|
792
|
+
const body = await readBody(req);
|
|
793
|
+
const lineNo = Number(body?.lineNo);
|
|
794
|
+
if (!Number.isInteger(lineNo) || lineNo < 0) return sendJson(res, 400, { ok: false, message: "lineNo 必须是合法行号" });
|
|
795
|
+
const wtRepo = runRepoDir(root, slug, runId); // A3:补丁应用在 per-Run worktree,回滚也要对着它
|
|
796
|
+
const repoPath = existsSync(join(wtRepo, ".git")) ? wtRepo : baseRepoDir(root, slug);
|
|
797
|
+
try {
|
|
798
|
+
await rollbackLedger(runDir, repoPath, lineNo);
|
|
799
|
+
} catch (e) { return sendJson(res, 400, { ok: false, message: "回滚失败: " + String((e && e.message) || e) }); }
|
|
800
|
+
return sendJson(res, 200, { ok: true });
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// —— 在系统文件管理器中打开 run 产物目录 ——
|
|
804
|
+
if (action === "open" && m === "POST") {
|
|
805
|
+
if (!existsSync(runDir)) return sendJson(res, 404, { ok: false, message: "run 不存在" });
|
|
806
|
+
// opener 可注入(测试传空函数,避免 npm test 真弹资源管理器)
|
|
807
|
+
const openerFn = __testHooks?.opener || execFile;
|
|
808
|
+
const opener = process.platform === "win32" ? "explorer" : process.platform === "darwin" ? "open" : "xdg-open";
|
|
809
|
+
openerFn(opener, [runDir], { timeout: 10000 }, (err) => {
|
|
810
|
+
// explorer 常返回非 0 成功码,不据此报错
|
|
811
|
+
if (err && process.platform !== "win32") ctx.logger?.warn?.("issue2pr: 打开目录失败: " + err.message);
|
|
812
|
+
});
|
|
813
|
+
return sendJson(res, 200, { ok: true, message: "已请求打开产物目录" });
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
if (action === "tree" && m === "GET") {
|
|
817
|
+
if (!existsSync(runDir)) return sendJson(res, 404, { ok: false, message: "run 不存在" });
|
|
818
|
+
return sendJson(res, 200, { ok: true, files: listRunTree(runDir) });
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
if (action === "artifact" && m === "GET") {
|
|
822
|
+
const rel = url.searchParams.get("path") || "";
|
|
823
|
+
let text;
|
|
824
|
+
try { text = readArtifact(runDir, rel); } // safeJoin 防 ..
|
|
825
|
+
catch (e) { return sendJson(res, 400, { ok: false, message: (e && e.message) || String(e) }); }
|
|
826
|
+
if (text == null) return sendJson(res, 404, { ok: false, message: "产物不存在: " + rel });
|
|
827
|
+
if (Buffer.byteLength(text, "utf8") > 200 * 1024) return sendJson(res, 400, { ok: false, message: "文件超过 200KB 上限" });
|
|
828
|
+
return sendJson(res, 200, { ok: true, text });
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
return sendJson(res, 404, { ok: false, message: "not found" });
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
sendJson(res, 404, { ok: false, message: "not found" });
|
|
835
|
+
} catch (e) {
|
|
836
|
+
sendJson(res, 500, { ok: false, message: "出错: " + String((e && e.message) || e) });
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// session 模式外部执行进度(每次现算不落盘;UI 3s 轮询本接口自动刷新)
|
|
841
|
+
// tasks 取自 P5 任务图节点数,patches 为 06-implementation/patches/*.diff 计数,report 即 coder-report.json
|
|
842
|
+
function externalProgress(runDir) {
|
|
843
|
+
const dir = join(runDir, "06-implementation", "patches");
|
|
844
|
+
let patches = 0;
|
|
845
|
+
if (existsSync(dir)) patches = readdirSync(dir).filter((f) => f.endsWith(".diff")).length;
|
|
846
|
+
let tasks = null;
|
|
847
|
+
try { tasks = JSON.parse(readFileSync(join(runDir, "05-task-graph.json"), "utf8")).nodes.length; } catch { /* 任务图缺失时只报 patch 数 */ }
|
|
848
|
+
return { patches, tasks, report: existsSync(join(runDir, "06-implementation", "coder-report.json")) };
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
export function apply(ctx, config = {}) {
|
|
852
|
+
const root = __testHooks?.dataRoot || config.dataRoot || defaultDataRoot();
|
|
853
|
+
// 启动恢复:把上次进程退出时遗留的 running Run 置为 stopped(仅测试钩子注入时跳过,交由用例自行构造)
|
|
854
|
+
if (!__testHooks?.dataRoot) {
|
|
855
|
+
try { recoverInterruptedRuns(root); }
|
|
856
|
+
catch (e) { ctx.logger?.warn?.("issue2pr: 启动恢复失败: " + String((e && e.message) || e)); }
|
|
857
|
+
}
|
|
858
|
+
ctx.effect(() => ctx.webServer.register({
|
|
859
|
+
kind: "prefix", path: "/issue2pr", handler: (req, res) => handleApi(ctx, root, req, res),
|
|
860
|
+
}), "issue2pr: api routes");
|
|
861
|
+
ctx.logger?.info?.(`issue2pr: API ready at /issue2pr/api/projects/* (dataRoot=${root})`);
|
|
862
|
+
}
|