dsh-supercrew 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/bin/supercrew.mjs +10 -7
- package/crew-watch.mjs +65 -0
- package/crew.mjs +103 -6
- package/package.json +9 -5
- package/panel.mjs +247 -14
- package/selftest.mjs +197 -0
- package/worker-brief.md +56 -0
package/README.md
CHANGED
package/bin/supercrew.mjs
CHANGED
|
@@ -32,6 +32,7 @@ const ROOT = path.resolve(HERE, '..');
|
|
|
32
32
|
const MAP = {
|
|
33
33
|
panel: ['panel.mjs', null],
|
|
34
34
|
watch: ['crew-watch.mjs', 'loop'],
|
|
35
|
+
work: ['crew.mjs', 'work'],
|
|
35
36
|
add: ['crew.mjs', 'add'],
|
|
36
37
|
run: ['crew.mjs', 'run'],
|
|
37
38
|
list: ['crew.mjs', 'list'],
|
|
@@ -42,6 +43,7 @@ const MAP = {
|
|
|
42
43
|
once: ['crew-watch.mjs', 'once'],
|
|
43
44
|
loop: ['crew-watch.mjs', 'loop'],
|
|
44
45
|
reply: ['crew-watch.mjs', 'reply'],
|
|
46
|
+
selftest: ['selftest.mjs', null],
|
|
45
47
|
};
|
|
46
48
|
|
|
47
49
|
const argv = process.argv.slice(2);
|
|
@@ -52,21 +54,22 @@ if (!cmd || cmd === '-h' || cmd === '--help') {
|
|
|
52
54
|
console.log(`supercrew —— 工单制团队系统
|
|
53
55
|
|
|
54
56
|
supercrew panel 起看板(默认 3090)
|
|
55
|
-
supercrew watch
|
|
56
|
-
supercrew
|
|
57
|
+
supercrew watch 守着入口收需求、派活、回话
|
|
58
|
+
supercrew work "<活>" 工作区的活:开单并立刻派出去(可加 --cwd <目录>)
|
|
59
|
+
supercrew add "<需求>" 只开单,不派(可加 --cwd <目录>)
|
|
57
60
|
supercrew run [--all] 派工
|
|
58
61
|
supercrew list 看所有工单
|
|
59
62
|
supercrew requeue 把卡住的工单放回队列
|
|
60
|
-
supercrew scan | judge
|
|
61
|
-
supercrew selftest
|
|
63
|
+
supercrew scan | judge 只看入口有什么 / 门卫怎么判
|
|
64
|
+
supercrew selftest 装完先跑这个:连「worker 能不能正确读写中文」一起测
|
|
62
65
|
|
|
63
66
|
环境变量见 README。`);
|
|
64
67
|
process.exit(0);
|
|
65
68
|
}
|
|
66
69
|
|
|
67
|
-
if (cmd === 'selftest') {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
+
if (cmd === 'selftest' && extra.includes('--quick')) {
|
|
71
|
+
// 只查环境与进程,不真派 worker —— 快
|
|
72
|
+
cmd = 'selftest';
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
const entry = MAP[cmd];
|
package/crew-watch.mjs
CHANGED
|
@@ -93,6 +93,31 @@ export function stripTrigger(text) {
|
|
|
93
93
|
return text.replace(new RegExp(TRIGGER.source, 'g'), '').replace(/^[\s::,,.。!!??-]+/, '').trim();
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
// 心跳:让别人(看板)看得出"值守还活着、它刚扫过什么"。
|
|
97
|
+
// 没有这个的话,看板上「没有在干的单」和「值守已经死了」长得一模一样。
|
|
98
|
+
const HEARTBEAT = path.join(
|
|
99
|
+
process.env.CREW_HOME || path.join(process.env.USERPROFILE || '.', '.dsh-supercrew'),
|
|
100
|
+
'watch-heartbeat.json');
|
|
101
|
+
|
|
102
|
+
function bumpHeartbeat(patch = {}) {
|
|
103
|
+
let h = {};
|
|
104
|
+
try { h = JSON.parse(fs.readFileSync(HEARTBEAT, 'utf8')); } catch { h = {}; }
|
|
105
|
+
const now = new Date().toISOString();
|
|
106
|
+
h.at = now;
|
|
107
|
+
h.pid = process.pid;
|
|
108
|
+
h.rounds = (h.rounds || 0) + (patch.round ? 1 : 0);
|
|
109
|
+
h.uptimeSec = h.startedAt ? Math.round((Date.parse(now) - Date.parse(h.startedAt)) / 1000) : 0;
|
|
110
|
+
if (!h.startedAt) h.startedAt = now;
|
|
111
|
+
// 累计计数(看板顶部那几个数字)
|
|
112
|
+
for (const k of ['seen', 'opened', 'blocked', 'failed']) {
|
|
113
|
+
if (patch[k]) h[k] = (h[k] || 0) + patch[k];
|
|
114
|
+
}
|
|
115
|
+
if (patch.recent) h.recent = patch.recent.slice(0, 12);
|
|
116
|
+
if (patch.note) h.note = patch.note;
|
|
117
|
+
try { saveJson(HEARTBEAT, h); } catch { /* 心跳写不进去不该影响值守 */ }
|
|
118
|
+
return h;
|
|
119
|
+
}
|
|
120
|
+
|
|
96
121
|
function seenSet() { return new Set(loadJson(SEEN, [])); }
|
|
97
122
|
function markSeen(keys) {
|
|
98
123
|
const s = seenSet();
|
|
@@ -164,6 +189,23 @@ export async function intake({ dryRun = false, concurrency = 4 } = {}) {
|
|
|
164
189
|
await Promise.all(lanes);
|
|
165
190
|
|
|
166
191
|
if (!dryRun) markSeen(found.map(seenKey));
|
|
192
|
+
|
|
193
|
+
// 记心跳:这一轮扫到几条、开了几单、拦下几条、判过的都长什么样
|
|
194
|
+
if (!dryRun) {
|
|
195
|
+
const recent = [
|
|
196
|
+
...made.map((t) => ({ text: (t.judged && t.judged.original) || t.body, isTask: true, title: t.title })),
|
|
197
|
+
...skipped.map((s) => ({ text: s.text, isTask: false, why: s.why })),
|
|
198
|
+
].slice(0, 12);
|
|
199
|
+
bumpHeartbeat({
|
|
200
|
+
round: true,
|
|
201
|
+
seen: found.length,
|
|
202
|
+
opened: made.length,
|
|
203
|
+
blocked: skipped.length,
|
|
204
|
+
failed: failed.length,
|
|
205
|
+
recent,
|
|
206
|
+
note: found.length ? '' : '这一轮没人叫我',
|
|
207
|
+
});
|
|
208
|
+
}
|
|
167
209
|
return { made, skipped, failed };
|
|
168
210
|
}
|
|
169
211
|
|
|
@@ -261,19 +303,42 @@ async function main() {
|
|
|
261
303
|
}
|
|
262
304
|
} else if (cmd === 'loop') {
|
|
263
305
|
console.log('守着…… Ctrl+C 退出');
|
|
306
|
+
bumpHeartbeat({ note: '值守起来了' }); // 先写一条,让看板立刻看到它还活着
|
|
264
307
|
for (;;) {
|
|
265
308
|
try {
|
|
309
|
+
// 先接管「开了但没人派」的单 —— 从看板输入框、CLI 丢进来的活都落在 queued,
|
|
310
|
+
// 以前必须有人去点卡片上的按钮才会跑,所以看起来像「它一点不干」。
|
|
311
|
+
// CREW_AUTO_RUN=0 可以关掉这个行为(只想手动派的时候)。
|
|
312
|
+
if (process.env.CREW_AUTO_RUN !== '0') {
|
|
313
|
+
const queued = loadAllTickets().filter((t) => t.state === 'queued');
|
|
314
|
+
for (const q of queued) {
|
|
315
|
+
console.log(`⏳ 接管待办 ${q.id} ${q.title}`);
|
|
316
|
+
bumpHeartbeat({ note: `接管待办 ${q.id}:${q.title}` });
|
|
317
|
+
const done = await runTicket(q.id);
|
|
318
|
+
if (!done) { console.log(' (被别的进程抢走了)'); continue; }
|
|
319
|
+
console.log(` → ${done.state} ${(done.result || done.error || '').slice(0, 120)}`);
|
|
320
|
+
if (done.chatKey) {
|
|
321
|
+
const rr = await reply(done);
|
|
322
|
+
console.log(` 结果回传:${rr.status}`);
|
|
323
|
+
}
|
|
324
|
+
bumpHeartbeat({ note: `${q.id} ${done.state}` });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
266
328
|
const r = await intake();
|
|
267
329
|
if (r.made.length || r.failed.length) showJudged(r);
|
|
268
330
|
for (const t of r.made) {
|
|
269
331
|
console.log(`▶ ${t.id} ${t.title}`);
|
|
332
|
+
bumpHeartbeat({ note: `正在干 ${t.id}:${t.title}` });
|
|
270
333
|
const done = await runTicket(t.id);
|
|
271
334
|
if (!done) { console.log(' (被别的进程抢走了)'); continue; }
|
|
272
335
|
const rr = await reply(done);
|
|
273
336
|
console.log(` 结果回传:${rr.status}${rr.status >= 300 ? ` —— ${String(rr.body).slice(0, 120)}` : ''}`);
|
|
337
|
+
bumpHeartbeat({ note: `${t.id} 干完,结果回传 ${rr.status}` });
|
|
274
338
|
}
|
|
275
339
|
} catch (e) {
|
|
276
340
|
console.error('这一轮出错:', e.message);
|
|
341
|
+
bumpHeartbeat({ note: `这一轮出错:${e.message}` });
|
|
277
342
|
}
|
|
278
343
|
await new Promise((r) => setTimeout(r, 15000));
|
|
279
344
|
}
|
package/crew.mjs
CHANGED
|
@@ -255,7 +255,24 @@ function modelPatchFile() {
|
|
|
255
255
|
}
|
|
256
256
|
|
|
257
257
|
// ── 派工:起一个 worker(headless 一次性会话)─────────────────────────────
|
|
258
|
-
|
|
258
|
+
// liveFile:worker 一边跑一边把输出追加进去,看板靠它显示「它现在在干什么」。
|
|
259
|
+
// 不给就只把结果留在内存里(老行为)。
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* 把「这台机器的常识」拼在活前面。
|
|
263
|
+
* 不拼的话 worker 会用 PowerShell 的 Get-Content 读中文文件,踩进 GBK 乱码里绕圈
|
|
264
|
+
* (实测一次「统计 .mjs 行数」的活,它花五分钟在推理编码问题,最后没产出)。
|
|
265
|
+
*/
|
|
266
|
+
function withBrief(body) {
|
|
267
|
+
try {
|
|
268
|
+
const brief = fs.readFileSync(path.join(__dirname, 'worker-brief.md'), 'utf8');
|
|
269
|
+
return `${brief}\n\n---\n\n# 你这次的活\n\n${body}`;
|
|
270
|
+
} catch {
|
|
271
|
+
return body;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function runWorkerOnce(ticket, onSpawn, liveFile, onWarn) {
|
|
259
276
|
return new Promise((resolve) => {
|
|
260
277
|
const dsh = findDsh();
|
|
261
278
|
const patches = [PATCH];
|
|
@@ -264,7 +281,7 @@ function runWorkerOnce(ticket, onSpawn) {
|
|
|
264
281
|
|
|
265
282
|
const args = ['--profile', WORKER_PROFILE];
|
|
266
283
|
for (const p of patches) args.push('--patch', p);
|
|
267
|
-
args.push('--permission-mode', 'danger-full-access', '--approval-policy', 'never', ticket.body);
|
|
284
|
+
args.push('--permission-mode', 'danger-full-access', '--approval-policy', 'never', withBrief(ticket.body));
|
|
268
285
|
|
|
269
286
|
const started = Date.now();
|
|
270
287
|
const L = resolveLaunch(dsh);
|
|
@@ -289,12 +306,55 @@ function runWorkerOnce(ticket, onSpawn) {
|
|
|
289
306
|
|
|
290
307
|
let out = '', err = '', killed = false;
|
|
291
308
|
|
|
309
|
+
// 实时日志:先写个头(含 pid 与开始时间),之后逐块追加。
|
|
310
|
+
// 写失败只忽略 —— 看不到实时输出,但活照干。
|
|
311
|
+
if (liveFile) {
|
|
312
|
+
try {
|
|
313
|
+
fs.writeFileSync(liveFile,
|
|
314
|
+
`# ${ticket.id}\n# cwd: ${ticket.cwd}\n# pid: ${child.pid}\n# started: ${new Date().toISOString()}\n\n`,
|
|
315
|
+
'utf8');
|
|
316
|
+
} catch { /* 忽略 */ }
|
|
317
|
+
}
|
|
318
|
+
const feed = (s) => {
|
|
319
|
+
if (!liveFile) return;
|
|
320
|
+
try { fs.appendFileSync(liveFile, s, 'utf8'); } catch { /* 忽略 */ }
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
// ── 绕圈检测 ────────────────────────────────────────────────────────
|
|
324
|
+
// 最常见的一个坑:worker 用 PowerShell 读中文文件,GBK 解码出乱码之后
|
|
325
|
+
// 开始长篇推理「到底是 UTF-8 还是行数算错了」,几分钟过去什么产出都没有。
|
|
326
|
+
// 一在输出里看到典型乱码就把这张单标上 warn,看板顶一条黄牌 ——
|
|
327
|
+
// 这样别人装上跑出问题,不用翻日志也知道卡在哪。
|
|
328
|
+
const MOJIBAKE = /鈥|锟斤拷|锟|\uFFFD\uFFFD/;
|
|
329
|
+
let mojibake = false;
|
|
330
|
+
const checkMojibake = (s) => {
|
|
331
|
+
if (mojibake || !MOJIBAKE.test(s)) return;
|
|
332
|
+
mojibake = true;
|
|
333
|
+
feed('\n[crew] ⚠️ 输出里出现编码乱码(鈥…)—— worker 八成在用 shell 读中文文件,'
|
|
334
|
+
+ '而不是 read 工具。它接下来的推理很可能都在绕这个坑。\n');
|
|
335
|
+
if (typeof onWarn === 'function') {
|
|
336
|
+
try {
|
|
337
|
+
onWarn('worker 读中文时踩了编码坑(可能用了 shell 而不是 read 工具)');
|
|
338
|
+
} catch { /* 标注失败不影响干活 */ }
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
// stderr 是 worker 的思考过程(headless 把推理写那儿),stdout 是结果。
|
|
342
|
+
// 只在第一次出现 stderr 时插一条分隔线 —— 给每个 chunk 都加前缀的话,
|
|
343
|
+
// chunk 边界会把一句话切碎,看起来像 [stderr] xxx[stderr] yyy。
|
|
344
|
+
let sawErr = false;
|
|
345
|
+
const feedErr = (s) => {
|
|
346
|
+
if (!liveFile) return;
|
|
347
|
+
if (!sawErr) { sawErr = true; feed('\n—— worker 的思考(stderr)——\n'); }
|
|
348
|
+
feed(s);
|
|
349
|
+
};
|
|
350
|
+
|
|
292
351
|
// 超时保护:worker 自己卡住时(实测常见于它起的浏览器进程没退出)不能让工单
|
|
293
352
|
// 永远挂在 working。超时就整棵进程树杀掉。默认 10 分钟,CREW_TIMEOUT_MS 可调。
|
|
294
353
|
const TIMEOUT = Number(process.env.CREW_TIMEOUT_MS || 10 * 60 * 1000);
|
|
295
354
|
const timer = setTimeout(() => {
|
|
296
355
|
killed = true;
|
|
297
356
|
err += `\n[crew] worker 超过 ${Math.round(TIMEOUT / 1000)} 秒仍未结束,已强杀整棵进程树`;
|
|
357
|
+
feed(`\n[crew] 超过 ${Math.round(TIMEOUT / 1000)} 秒未结束,超时强杀\n`);
|
|
298
358
|
try {
|
|
299
359
|
spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true });
|
|
300
360
|
} catch {
|
|
@@ -306,8 +366,8 @@ function runWorkerOnce(ticket, onSpawn) {
|
|
|
306
366
|
child.stdout.setEncoding('utf8');
|
|
307
367
|
child.stderr.setEncoding('utf8');
|
|
308
368
|
const cap = (s) => (s.length > MAX_OUT ? s.slice(0, MAX_OUT) + `\n…(超过 ${MAX_OUT} 字节已截断)` : s);
|
|
309
|
-
child.stdout.on('data', (d) => { if (out.length < MAX_OUT) out += d; });
|
|
310
|
-
child.stderr.on('data', (d) => { if (err.length < MAX_OUT) err += d; });
|
|
369
|
+
child.stdout.on('data', (d) => { checkMojibake(d); if (out.length < MAX_OUT) out += d; feed(d); });
|
|
370
|
+
child.stderr.on('data', (d) => { checkMojibake(d); if (err.length < MAX_OUT) err += d; feedErr(d); });
|
|
311
371
|
|
|
312
372
|
child.on('error', (e) => { clearTimeout(timer); resolve({ rc: -1, out: cap(out), err: cap(err + '\n' + e.message), seconds: 0, pid: child.pid }); });
|
|
313
373
|
child.on('close', (rc) => { clearTimeout(timer); resolve({ rc, out: cap(out), err: cap(err), seconds: (Date.now() - started) / 1000, pid: child.pid }); });
|
|
@@ -344,6 +404,14 @@ export async function runTicket(id, { force = false } = {}) {
|
|
|
344
404
|
} catch (e) {
|
|
345
405
|
log(`记 pid 失败(不影响干活): ${e.message}`);
|
|
346
406
|
}
|
|
407
|
+
}, path.join(DIRS.runs, `${t.id}.live.log`), (msg) => {
|
|
408
|
+
// 绕圈检测报上来的问题 → 落进工单,看板顶上会显示一条黄牌
|
|
409
|
+
try {
|
|
410
|
+
const cur = loadOne(id);
|
|
411
|
+
cur.warn = msg;
|
|
412
|
+
cur.updatedAt = new Date().toISOString();
|
|
413
|
+
save(cur);
|
|
414
|
+
} catch { /* 标注失败不影响干活 */ }
|
|
347
415
|
});
|
|
348
416
|
|
|
349
417
|
// 日志名带上时间戳,避免并发重试互相覆盖(attempts 只在本进程内递增)
|
|
@@ -496,6 +564,34 @@ async function main() {
|
|
|
496
564
|
t.finishedAt = new Date().toISOString();
|
|
497
565
|
save(t);
|
|
498
566
|
console.log(`${id} → ${t.state}`);
|
|
567
|
+
} else if (cmd === 'work') {
|
|
568
|
+
// 「工作区的活」:主会话(我)接到活之后,用这个把活交给团队系统去干。
|
|
569
|
+
// 默认工作目录就是当前目录,来源记成 workspace,开完单立刻派出去。
|
|
570
|
+
const body = positional.join(' ').trim();
|
|
571
|
+
if (!body) {
|
|
572
|
+
console.error('用法:node crew.mjs work "<活>" [--cwd <目录>] [--title <标题>] [--no-run]');
|
|
573
|
+
process.exitCode = 2;
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
const cwd = typeof opts.cwd === 'string' ? opts.cwd : process.cwd();
|
|
577
|
+
const t = addTicket({
|
|
578
|
+
body,
|
|
579
|
+
cwd,
|
|
580
|
+
title: typeof opts.title === 'string' ? opts.title : '',
|
|
581
|
+
source: 'workspace',
|
|
582
|
+
});
|
|
583
|
+
console.log(`${t.id} 已开工单(来源 workspace)`);
|
|
584
|
+
console.log(` 活:${t.title}`);
|
|
585
|
+
console.log(` 工作目录:${t.cwd}`);
|
|
586
|
+
if (opts['no-run']) {
|
|
587
|
+
console.log(' (--no-run:只开单,等值守或你自己派)');
|
|
588
|
+
} else {
|
|
589
|
+
console.log(' 派出去干活……');
|
|
590
|
+
const done = await runTicket(t.id);
|
|
591
|
+
if (!done) { console.log(' (被别的进程抢走了)'); return; }
|
|
592
|
+
console.log(` ${done.state === 'done' ? '✔ 干完了' : '✘ 失败了'}`);
|
|
593
|
+
console.log(` ${String(done.state === 'done' ? done.result : done.error || '').slice(0, 800)}`);
|
|
594
|
+
}
|
|
499
595
|
} else if (cmd === 'list') {
|
|
500
596
|
const all = loadAll();
|
|
501
597
|
if (!all.length) console.log('(还没有工单)');
|
|
@@ -506,8 +602,9 @@ async function main() {
|
|
|
506
602
|
if (!fs.existsSync(tpath(id))) { console.error(`没有这张工单: ${id}`); process.exitCode = 1; return; }
|
|
507
603
|
console.log(JSON.stringify(loadOne(id), null, 2));
|
|
508
604
|
} else {
|
|
509
|
-
console.log('用法:node crew.mjs <add|run|list|show|note|done|requeue> ...');
|
|
510
|
-
console.log('
|
|
605
|
+
console.log('用法:node crew.mjs <work|add|run|list|show|note|done|requeue> ...');
|
|
606
|
+
console.log(' work "<活>" [--cwd <目录>] [--title <标题>] [--no-run] 工作区的活:开单并立刻派出去');
|
|
607
|
+
console.log(' add "<需求>" [--cwd <目录>] [--title <标题>] [--source <来源>] 只开单,不派');
|
|
511
608
|
console.log(' run [--all] 派出去(起 headless worker)');
|
|
512
609
|
console.log(' list | show <id> 看工单');
|
|
513
610
|
console.log(' note <id> "<备注>" 给工单记一笔');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-supercrew",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "给 DeepSeek Harness 装一个团队:收需求 → 门卫判断 → 开工单 → 派独立 worker 干活 → 结果回报。模型可配(默认用你当前在用的,免费池当备选),不依赖 tmux。",
|
|
@@ -43,7 +43,9 @@
|
|
|
43
43
|
"README.md",
|
|
44
44
|
"INSTALL.md",
|
|
45
45
|
"DESIGN.md",
|
|
46
|
-
"LICENSE"
|
|
46
|
+
"LICENSE",
|
|
47
|
+
"selftest.mjs",
|
|
48
|
+
"worker-brief.md"
|
|
47
49
|
],
|
|
48
50
|
"publishConfig": {
|
|
49
51
|
"access": "public"
|
|
@@ -51,8 +53,10 @@
|
|
|
51
53
|
"scripts": {
|
|
52
54
|
"panel": "node panel.mjs",
|
|
53
55
|
"watch": "node crew-watch.mjs loop",
|
|
54
|
-
"selftest": "node
|
|
55
|
-
"check": "node --check crew.mjs && node --check crew-watch.mjs && node --check panel.mjs && node --check lib/index.js && node --check lib/router.mjs && node --check bin/supercrew.mjs"
|
|
56
|
+
"selftest": "node selftest.mjs",
|
|
57
|
+
"check": "node --check crew.mjs && node --check crew-watch.mjs && node --check panel.mjs && node --check lib/index.js && node --check lib/router.mjs && node --check bin/supercrew.mjs",
|
|
58
|
+
"selftest:quick": "node selftest.mjs --quick",
|
|
59
|
+
"work": "node crew.mjs work"
|
|
56
60
|
},
|
|
57
61
|
"repository": {
|
|
58
62
|
"type": "git",
|
|
@@ -62,4 +66,4 @@
|
|
|
62
66
|
"bugs": {
|
|
63
67
|
"url": "https://github.com/wesqaz111/dsh-supercrew/issues"
|
|
64
68
|
}
|
|
65
|
-
}
|
|
69
|
+
}
|
package/panel.mjs
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* crew-panel —— 团队系统的脸
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* 一个本地看板:待办 / 在干 / 已完成 / 失败。
|
|
6
|
+
* 能新建工单、能把待办的派出去,**点开一张单能看它现在在干什么**(实时输出)。
|
|
7
7
|
*
|
|
8
8
|
* 用法:node panel.mjs [--port=3090] (--port 3090 也认)
|
|
9
9
|
* 环境变量:CREW_PANEL_PORT、CREW_HOME、CREW_WORKDIR
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* - 派工能不能重复,靠的是内核的文件锁(crew.mjs 的 acquireLock),不是前端按钮。
|
|
14
14
|
* 按钮的禁用只是让人看出来"已经派出去了"。
|
|
15
15
|
* - 工单标题和结果会进 HTML,esc 必须连引号一起转义(属性里也能注入)。
|
|
16
|
+
* - 实时输出读的是 <CREW_HOME>/runs/<id>.live.log(worker 边跑边追加)。
|
|
17
|
+
* 只读尾部,别把整个大日志塞进浏览器。
|
|
16
18
|
*/
|
|
17
19
|
import http from 'node:http';
|
|
18
20
|
import fs from 'node:fs';
|
|
@@ -28,10 +30,35 @@ function argPort() {
|
|
|
28
30
|
return Number(process.env.CREW_PANEL_PORT || 3090);
|
|
29
31
|
}
|
|
30
32
|
const PORT = argPort();
|
|
33
|
+
const HOME = process.env.CREW_HOME || path.join(process.env.USERPROFILE || process.env.HOME || '.', '.dsh-supercrew');
|
|
34
|
+
const RUNS = path.join(HOME, 'runs');
|
|
31
35
|
|
|
32
36
|
// 正在跑的工单 id —— 只用于给前端一个即时反馈,真正的防重复在内核的锁里
|
|
33
37
|
const running = new Set();
|
|
34
38
|
|
|
39
|
+
/** 读实时日志的尾部;文件不在/读不动就回空。 */
|
|
40
|
+
function tail(file, maxBytes = 24 * 1024) {
|
|
41
|
+
try {
|
|
42
|
+
const st = fs.statSync(file);
|
|
43
|
+
if (!st.isFile() || st.size === 0) return '';
|
|
44
|
+
const from = Math.max(0, st.size - maxBytes);
|
|
45
|
+
const fd = fs.openSync(file, 'r');
|
|
46
|
+
try {
|
|
47
|
+
const buf = Buffer.alloc(Math.min(maxBytes, st.size - from));
|
|
48
|
+
fs.readSync(fd, buf, 0, buf.length, from);
|
|
49
|
+
return buf.toString('utf8');
|
|
50
|
+
} finally { fs.closeSync(fd); }
|
|
51
|
+
} catch { return ''; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 日志里最后一条有内容的行(给卡片做「最新动态」预览)。 */
|
|
55
|
+
function lastLine(file) {
|
|
56
|
+
const t = tail(file, 8 * 1024);
|
|
57
|
+
if (!t) return '';
|
|
58
|
+
const lines = t.split(/\r?\n/).filter((l) => l.trim() && !l.startsWith('#'));
|
|
59
|
+
return lines.length ? lines[lines.length - 1].slice(0, 200) : '';
|
|
60
|
+
}
|
|
61
|
+
|
|
35
62
|
const HTML = String.raw`<!doctype html>
|
|
36
63
|
<html lang="zh-CN"><head>
|
|
37
64
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
@@ -46,11 +73,26 @@ const HTML = String.raw`<!doctype html>
|
|
|
46
73
|
h1{margin:0;font-size:19px;font-weight:600;letter-spacing:.3px}
|
|
47
74
|
.sub{color:var(--dim);font-size:12.5px}
|
|
48
75
|
.err{color:var(--failed);font-size:12.5px}
|
|
76
|
+
.warn{margin-top:6px;padding:5px 8px;border-radius:6px;font-size:11.5px;line-height:1.45;
|
|
77
|
+
color:#ffd98a;background:rgba(255,179,71,.10);border:1px solid rgba(255,179,71,.30)}
|
|
78
|
+
/* 值守状态条:让人一眼看出「它在盯着」还是「它死了」 */
|
|
79
|
+
.watch{margin:8px 26px 0;display:flex;align-items:center;gap:9px;flex-wrap:wrap;
|
|
80
|
+
font-size:12.5px;color:#c3cbdd}
|
|
81
|
+
.watch .dot{width:9px;height:9px;border-radius:50%;background:#5c6478;flex:none}
|
|
82
|
+
.watch .dot.on{background:var(--done);box-shadow:0 0 8px rgba(67,209,158,.7);
|
|
83
|
+
animation:pulse 2s ease-in-out infinite}
|
|
84
|
+
.watch .dot.off{background:var(--failed)}
|
|
85
|
+
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
|
|
86
|
+
.watch .dim{color:var(--dim)}
|
|
87
|
+
.watch.recent{margin-top:4px;padding-left:35px;flex-direction:column;align-items:flex-start;gap:2px}
|
|
88
|
+
.watch.recent .j{font-size:12px;color:var(--dim);white-space:nowrap;overflow:hidden;
|
|
89
|
+
text-overflow:ellipsis;max-width:min(1100px,92vw)}
|
|
90
|
+
.watch.recent .j b{color:#8fd6b4;font-weight:600}
|
|
91
|
+
.watch.recent .j i{color:#ffb347;font-style:normal}
|
|
49
92
|
.bar{margin:10px 26px 0;display:flex;gap:8px;flex-wrap:wrap}
|
|
50
93
|
input,button,textarea{font:inherit;color:var(--fg);background:#11151f;border:1px solid var(--line);
|
|
51
94
|
border-radius:9px;padding:9px 12px;outline:none}
|
|
52
95
|
input{flex:1;min-width:240px}
|
|
53
|
-
textarea{width:100%;min-height:64px;resize:vertical}
|
|
54
96
|
button{cursor:pointer;background:#1d2334;transition:.15s}
|
|
55
97
|
button:hover{background:#252c42;border-color:#37405a}
|
|
56
98
|
button:disabled{opacity:.5;cursor:default}
|
|
@@ -61,17 +103,35 @@ const HTML = String.raw`<!doctype html>
|
|
|
61
103
|
.col h2{margin:2px 4px 12px;font-size:13px;font-weight:600;color:var(--dim);letter-spacing:.5px;
|
|
62
104
|
display:flex;justify-content:space-between}
|
|
63
105
|
.card{background:var(--card);border:1px solid var(--line);border-radius:11px;padding:11px 12px;
|
|
64
|
-
margin-bottom:9px;border-left:3px solid var(--queued)}
|
|
106
|
+
margin-bottom:9px;border-left:3px solid var(--queued);cursor:pointer}
|
|
107
|
+
.card:hover{border-color:#37405a}
|
|
65
108
|
.card[data-s=working]{border-left-color:var(--working)}
|
|
66
109
|
.card[data-s=done]{border-left-color:var(--done)}
|
|
67
110
|
.card[data-s=failed]{border-left-color:var(--failed)}
|
|
68
111
|
.card .t{font-weight:600;margin-bottom:3px;word-break:break-word}
|
|
69
112
|
.card .m{color:var(--dim);font-size:12px;display:flex;gap:8px;flex-wrap:wrap}
|
|
113
|
+
.card .live{margin-top:6px;font:11.5px/1.45 Consolas,monospace;color:#8fd6b4;
|
|
114
|
+
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;opacity:.9}
|
|
70
115
|
.card .r{margin-top:7px;padding-top:7px;border-top:1px dashed var(--line);color:#c9d2e6;
|
|
71
116
|
font-size:12.5px;white-space:pre-wrap;max-height:150px;overflow:auto}
|
|
72
117
|
.card button{margin-top:8px;padding:5px 10px;font-size:12px}
|
|
73
118
|
.empty{color:#5c6478;font-size:12.5px;padding:6px 4px}
|
|
74
|
-
|
|
119
|
+
/* 右侧抽屉:看单张工单的实时输出 */
|
|
120
|
+
#drawer{position:fixed;top:0;right:0;width:min(720px,60vw);height:100vh;background:#0e1119;
|
|
121
|
+
border-left:1px solid var(--line);box-shadow:-20px 0 60px rgba(0,0,0,.5);
|
|
122
|
+
transform:translateX(102%);transition:transform .22s ease;display:flex;flex-direction:column;z-index:9}
|
|
123
|
+
#drawer.open{transform:none}
|
|
124
|
+
#drawer .dh{padding:18px 20px 12px;border-bottom:1px solid var(--line)}
|
|
125
|
+
#drawer .dh h2{margin:0 0 6px;font-size:16px}
|
|
126
|
+
#drawer .dh .m{color:var(--dim);font-size:12px;display:flex;gap:10px;flex-wrap:wrap}
|
|
127
|
+
#drawer .dh button{position:absolute;top:16px;right:18px;padding:4px 10px}
|
|
128
|
+
#log{flex:1;overflow:auto;margin:0;padding:16px 20px;font:12px/1.6 Consolas,"Cascadia Mono",monospace;
|
|
129
|
+
white-space:pre-wrap;word-break:break-word;color:#c6d0e4;background:#0a0d14}
|
|
130
|
+
#log .err{color:#ff9aa4}
|
|
131
|
+
#drawer .df{padding:10px 20px;border-top:1px solid var(--line);color:var(--dim);font-size:12px;
|
|
132
|
+
display:flex;justify-content:space-between;gap:10px}
|
|
133
|
+
@media(max-width:1000px){.board{grid-template-columns:repeat(2,minmax(0,1fr))}
|
|
134
|
+
#drawer{width:100vw}}
|
|
75
135
|
</style></head>
|
|
76
136
|
<body>
|
|
77
137
|
<header>
|
|
@@ -80,6 +140,13 @@ const HTML = String.raw`<!doctype html>
|
|
|
80
140
|
<span class="err" id="err"></span>
|
|
81
141
|
</header>
|
|
82
142
|
|
|
143
|
+
<div class="watch" id="watch">
|
|
144
|
+
<span class="dot" id="w-dot"></span>
|
|
145
|
+
<span id="w-text">值守状态读取中…</span>
|
|
146
|
+
<span class="dim" id="w-counts"></span>
|
|
147
|
+
</div>
|
|
148
|
+
<div class="watch recent" id="w-recent"></div>
|
|
149
|
+
|
|
83
150
|
<div class="bar">
|
|
84
151
|
<input id="req" placeholder="丢一个需求进来,比如:把图片按日期整理成文件夹">
|
|
85
152
|
<button class="primary" onclick="add()">开工单</button>
|
|
@@ -88,6 +155,16 @@ const HTML = String.raw`<!doctype html>
|
|
|
88
155
|
|
|
89
156
|
<div class="board" id="board"></div>
|
|
90
157
|
|
|
158
|
+
<aside id="drawer">
|
|
159
|
+
<div class="dh">
|
|
160
|
+
<button onclick="closeDrawer()">关闭</button>
|
|
161
|
+
<h2 id="d-title">—</h2>
|
|
162
|
+
<div class="m" id="d-meta"></div>
|
|
163
|
+
</div>
|
|
164
|
+
<pre id="log">(还没有输出)</pre>
|
|
165
|
+
<div class="df"><span id="d-foot"></span><span id="d-auto">每 1 秒刷新</span></div>
|
|
166
|
+
</aside>
|
|
167
|
+
|
|
91
168
|
<script>
|
|
92
169
|
const COLS = [['queued','待办'],['working','在干'],['done','已完成'],['failed','失败']];
|
|
93
170
|
// 引号也要转:卡片是拼字符串进 HTML 的,id/state 会落在属性里
|
|
@@ -98,6 +175,45 @@ const giveUp = (e) => {
|
|
|
98
175
|
document.getElementById('err').textContent = '看板读不到数据:' + (e && e.message ? e.message : e);
|
|
99
176
|
};
|
|
100
177
|
|
|
178
|
+
let OPEN = null; // 抽屉里正看哪张单
|
|
179
|
+
let openTimer = null;
|
|
180
|
+
|
|
181
|
+
// 值守状态:它是这套系统里唯一「一直在跑」的东西,得让人看得见
|
|
182
|
+
async function loadWatch(){
|
|
183
|
+
let h;
|
|
184
|
+
try {
|
|
185
|
+
const r = await fetch('/api/heartbeat');
|
|
186
|
+
h = await r.json();
|
|
187
|
+
} catch { return; }
|
|
188
|
+
const dot = document.getElementById('w-dot');
|
|
189
|
+
const txt = document.getElementById('w-text');
|
|
190
|
+
const cnt = document.getElementById('w-counts');
|
|
191
|
+
const rec = document.getElementById('w-recent');
|
|
192
|
+
|
|
193
|
+
if (!h || !h.at) {
|
|
194
|
+
dot.className = 'dot off';
|
|
195
|
+
txt.textContent = '值守没在跑' + (h && h.note ? ':' + h.note : '');
|
|
196
|
+
cnt.textContent = '';
|
|
197
|
+
rec.innerHTML = '';
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const age = h.ageSec == null ? '?' : h.ageSec;
|
|
201
|
+
dot.className = 'dot ' + (h.alive ? 'on' : 'off');
|
|
202
|
+
txt.innerHTML = h.alive
|
|
203
|
+
? '值守在盯着(' + age + ' 秒前扫过 · pid ' + (h.pid || '?') + ')'
|
|
204
|
+
: '值守的<b>心跳停了</b>(最后 ' + age + ' 秒前 · pid ' + (h.pid || '?') + ')';
|
|
205
|
+
if (h.note) txt.innerHTML += ' · ' + esc(h.note);
|
|
206
|
+
cnt.textContent = '累计:叫我 ' + (h.seen || 0) + ' 条 · 开单 ' + (h.opened || 0)
|
|
207
|
+
+ ' 张 · 拦下闲聊 ' + (h.blocked || 0) + ' 条 · 第 ' + (h.rounds || 0) + ' 轮';
|
|
208
|
+
rec.innerHTML = (h.recent || []).slice(0, 6).map(function(x){
|
|
209
|
+
return '<div class="j">' + (x.isTask ? '<b>✔ 派活</b>' : '<i>· 拦下</i>') + ' '
|
|
210
|
+
+ esc(String(x.text || '').slice(0, 90))
|
|
211
|
+
+ (x.isTask ? (x.title ? ' → ' + esc(x.title) : '')
|
|
212
|
+
: (x.why ? ' ← ' + esc(x.why) : ''))
|
|
213
|
+
+ '</div>';
|
|
214
|
+
}).join('');
|
|
215
|
+
}
|
|
216
|
+
|
|
101
217
|
async function load(){
|
|
102
218
|
let list;
|
|
103
219
|
try {
|
|
@@ -111,9 +227,11 @@ async function load(){
|
|
|
111
227
|
|
|
112
228
|
const by = {queued:[],working:[],done:[],failed:[]};
|
|
113
229
|
for (const t of list) { (by[t.state] || by.queued).push(t); }
|
|
230
|
+
const busy = list.filter(t => t.state === 'working').length;
|
|
114
231
|
document.getElementById('stat').textContent =
|
|
115
|
-
'共 ' + list.length + ' 张工单 · 待办 ' + by.queued.length + ' · 在干 ' +
|
|
116
|
-
+ ' · 完成 ' + by.done.length + ' · 失败 ' + by.failed.length
|
|
232
|
+
'共 ' + list.length + ' 张工单 · 待办 ' + by.queued.length + ' · 在干 ' + busy
|
|
233
|
+
+ ' · 完成 ' + by.done.length + ' · 失败 ' + by.failed.length
|
|
234
|
+
+ (busy ? ' · 有活正在干' : '');
|
|
117
235
|
const board = document.getElementById('board');
|
|
118
236
|
board.innerHTML = COLS.map(function(pair){
|
|
119
237
|
const k = pair[0], label = pair[1];
|
|
@@ -132,14 +250,27 @@ function card(t){
|
|
|
132
250
|
+ (busy ? '在干…' : '派出去') + '</button>'
|
|
133
251
|
: '';
|
|
134
252
|
const reply = t.replyError ? '<div class="m" style="color:var(--failed)">回传失败:' + esc(String(t.replyError).slice(0,120)) + '</div>' : '';
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
253
|
+
// 绕圈黄牌:worker 在读中文/编码上踩坑时被 crew.mjs 标出来,外人不用翻日志就知道问题在哪
|
|
254
|
+
const warn = t.warn ? '<div class="warn">⚠️ ' + esc(String(t.warn).slice(0,160)) + '</div>' : '';
|
|
255
|
+
// 在干的单,把「它现在在干什么」的最后一行显示出来
|
|
256
|
+
const live = (busy && t.liveTail) ? '<div class="live">▸ ' + esc(t.liveTail) + '</div>' : '';
|
|
257
|
+
const secs = busy && t.pid ? '<span>pid ' + esc(t.pid) + '</span>' : '';
|
|
258
|
+
return '<article class="card" data-s="' + esc(t.state) + '" data-id="' + esc(t.id) + '">'
|
|
259
|
+
+ '<div class="t">' + esc(t.title) + '</div>'
|
|
260
|
+
+ '<div class="m"><span>' + esc(t.id) + '</span><span>' + when + '</span>' + secs
|
|
261
|
+
+ '<span>' + esc(t.source || '') + '</span></div>'
|
|
262
|
+
+ live + warn + body + reply + go + '</article>';
|
|
138
263
|
}
|
|
139
264
|
|
|
140
265
|
document.addEventListener('click', function(e){
|
|
141
|
-
const
|
|
142
|
-
if (
|
|
266
|
+
const go = e.target && e.target.closest ? e.target.closest('button.go') : null;
|
|
267
|
+
if (go) {
|
|
268
|
+
e.stopPropagation();
|
|
269
|
+
if (!go.disabled) { go.disabled = true; go.textContent = '在干…'; run(go.dataset.id); }
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const c = e.target && e.target.closest ? e.target.closest('.card') : null;
|
|
273
|
+
if (c) openDrawer(c.dataset.id);
|
|
143
274
|
});
|
|
144
275
|
|
|
145
276
|
async function add(){
|
|
@@ -169,8 +300,56 @@ async function run(id){
|
|
|
169
300
|
load();
|
|
170
301
|
}
|
|
171
302
|
|
|
303
|
+
// ── 抽屉:一张单的实时输出 ────────────────────────────────────────────
|
|
304
|
+
function openDrawer(id){
|
|
305
|
+
OPEN = id;
|
|
306
|
+
document.getElementById('drawer').classList.add('open');
|
|
307
|
+
document.getElementById('log').textContent = '(读取中…)';
|
|
308
|
+
tick();
|
|
309
|
+
if (openTimer) clearInterval(openTimer);
|
|
310
|
+
openTimer = setInterval(tick, 1000);
|
|
311
|
+
}
|
|
312
|
+
function closeDrawer(){
|
|
313
|
+
OPEN = null;
|
|
314
|
+
document.getElementById('drawer').classList.remove('open');
|
|
315
|
+
if (openTimer) { clearInterval(openTimer); openTimer = null; }
|
|
316
|
+
}
|
|
317
|
+
async function tick(){
|
|
318
|
+
if (!OPEN) return;
|
|
319
|
+
let j;
|
|
320
|
+
try {
|
|
321
|
+
const r = await fetch('/api/live/' + encodeURIComponent(OPEN));
|
|
322
|
+
j = await r.json();
|
|
323
|
+
if (!r.ok) throw new Error(j && j.error ? j.error : ('HTTP ' + r.status));
|
|
324
|
+
} catch (e) {
|
|
325
|
+
document.getElementById('d-foot').textContent = '读不到:' + e.message;
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
document.getElementById('d-title').textContent = j.title || j.id;
|
|
329
|
+
const meta = ['状态 ' + j.state, '来源 ' + (j.source || '—')];
|
|
330
|
+
if (j.pid) meta.push('pid ' + j.pid);
|
|
331
|
+
if (j.seconds != null) meta.push('已跑 ' + j.seconds + ' 秒');
|
|
332
|
+
if (j.attempts) meta.push('第 ' + j.attempts + ' 次');
|
|
333
|
+
if (j.cwd) meta.push(j.cwd);
|
|
334
|
+
document.getElementById('d-meta').textContent = meta.join(' · ');
|
|
335
|
+
|
|
336
|
+
const pre = document.getElementById('log');
|
|
337
|
+
const txt = j.log || '(还没有输出)';
|
|
338
|
+
const atBottom = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 40;
|
|
339
|
+
pre.textContent = txt;
|
|
340
|
+
if (atBottom) pre.scrollTop = pre.scrollHeight; // 用户往上翻了就别硬拽回来
|
|
341
|
+
document.getElementById('d-foot').textContent =
|
|
342
|
+
j.state === 'working' ? '正在干…' : (j.state === 'done' ? '已完成' : (j.state === 'failed' ? '失败' : '待办'));
|
|
343
|
+
document.getElementById('d-auto').textContent = j.state === 'working' ? '每 1 秒刷新' : '已结束(停在最后状态)';
|
|
344
|
+
}
|
|
345
|
+
|
|
172
346
|
load();
|
|
173
347
|
setInterval(load, 5000);
|
|
348
|
+
loadWatch();
|
|
349
|
+
setInterval(loadWatch, 3000);
|
|
350
|
+
// ?open=<id> 直接打开那张单的实时输出(方便分享链接、也方便截图)
|
|
351
|
+
const _sp = new URLSearchParams(location.search);
|
|
352
|
+
if (_sp.get('open')) openDrawer(_sp.get('open'));
|
|
174
353
|
</script></body></html>`;
|
|
175
354
|
|
|
176
355
|
const server = http.createServer(async (req, res) => {
|
|
@@ -185,9 +364,52 @@ const server = http.createServer(async (req, res) => {
|
|
|
185
364
|
return res.end(HTML);
|
|
186
365
|
}
|
|
187
366
|
|
|
367
|
+
if (req.method === 'GET' && url.pathname === '/api/heartbeat') {
|
|
368
|
+
// 值守的心跳:没有它的话,「没在干的单」和「值守已经死了」在看板上长得一样
|
|
369
|
+
let h = null;
|
|
370
|
+
try { h = JSON.parse(fs.readFileSync(path.join(HOME, 'watch-heartbeat.json'), 'utf8')); }
|
|
371
|
+
catch { h = null; }
|
|
372
|
+
if (!h) return json({ alive: false, note: '值守还没写过心跳(没起来过,或 CREW_HOME 不对)' });
|
|
373
|
+
const ageSec = h.at ? Math.round((Date.now() - Date.parse(h.at)) / 1000) : null;
|
|
374
|
+
// 心跳超过 40 秒没更新 = 值守大概死了(它 15 秒一轮)
|
|
375
|
+
return json({
|
|
376
|
+
...h,
|
|
377
|
+
ageSec,
|
|
378
|
+
alive: ageSec != null && ageSec < 40,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
188
382
|
if (req.method === 'GET' && url.pathname === '/api/tickets') {
|
|
189
383
|
// loadAllTickets 自己会跳过坏文件;真抛错也让这里回 500 + 原因,而不是整个看板卡住
|
|
190
|
-
|
|
384
|
+
const list = loadAllTickets();
|
|
385
|
+
// 正在跑的单,附一行「它现在在干什么」给卡片预览
|
|
386
|
+
for (const t of list) {
|
|
387
|
+
if (t.state === 'working') {
|
|
388
|
+
t.liveTail = lastLine(path.join(RUNS, `${t.id}.live.log`)) || '';
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return json(list);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (req.method === 'GET' && url.pathname.startsWith('/api/live/')) {
|
|
395
|
+
const id = decodeURIComponent(url.pathname.slice('/api/live/'.length));
|
|
396
|
+
if (!/^[A-Za-z0-9_-]+$/.test(id)) return json({ error: 'id 不合法' }, 400);
|
|
397
|
+
const t = loadAllTickets().find((x) => x.id === id);
|
|
398
|
+
if (!t) return json({ error: '没有这张工单' }, 404);
|
|
399
|
+
const started = t.startedAt ? Date.parse(t.startedAt)
|
|
400
|
+
: (t.updatedAt ? Date.parse(t.updatedAt) : null);
|
|
401
|
+
const endRef = t.state === 'working' ? Date.now()
|
|
402
|
+
: (t.finishedAt ? Date.parse(t.finishedAt)
|
|
403
|
+
: (t.updatedAt ? Date.parse(t.updatedAt) : null));
|
|
404
|
+
return json({
|
|
405
|
+
id: t.id, title: t.title, state: t.state, source: t.source, cwd: t.cwd,
|
|
406
|
+
pid: t.state === 'working' ? t.pid : null,
|
|
407
|
+
attempts: t.attempts,
|
|
408
|
+
seconds: started && endRef ? Math.max(0, Math.round((endRef - started) / 1000)) : null,
|
|
409
|
+
result: t.result, error: t.error, replyError: t.replyError, warn: t.warn || null,
|
|
410
|
+
log: tail(path.join(RUNS, `${t.id}.live.log`)) || (
|
|
411
|
+
t.result ? '## 结果\n' + t.result : (t.error ? '## 错误\n' + t.error : '')),
|
|
412
|
+
});
|
|
191
413
|
}
|
|
192
414
|
|
|
193
415
|
if (req.method === 'POST' && url.pathname === '/api/add') {
|
|
@@ -202,11 +424,22 @@ const server = http.createServer(async (req, res) => {
|
|
|
202
424
|
cwd: b.cwd || process.env.CREW_WORKDIR || process.cwd(),
|
|
203
425
|
source: 'panel',
|
|
204
426
|
});
|
|
427
|
+
// 开完单就直接派出去 —— 以前还得再去点卡片上的按钮,
|
|
428
|
+
// 不点它就一直是「待办」,看着就像「它一点不干」。
|
|
429
|
+
// 想手动派就传 autoRun:false。
|
|
430
|
+
if (b.autoRun !== false && !running.has(t.id)) {
|
|
431
|
+
running.add(t.id);
|
|
432
|
+
runTicket(t.id)
|
|
433
|
+
.then((done) => { if (done) console.log(`[panel] ${t.id} → ${done.state}`); })
|
|
434
|
+
.catch((e) => console.error(`[panel] ${t.id} 自动派工出错:${e.message}`))
|
|
435
|
+
.finally(() => running.delete(t.id));
|
|
436
|
+
}
|
|
205
437
|
return json(t);
|
|
206
438
|
}
|
|
207
439
|
|
|
208
440
|
if (req.method === 'POST' && url.pathname.startsWith('/api/run/')) {
|
|
209
441
|
const id = decodeURIComponent(url.pathname.slice('/api/run/'.length));
|
|
442
|
+
if (!/^[A-Za-z0-9_-]+$/.test(id)) return json({ error: 'id 不合法' }, 400);
|
|
210
443
|
if (running.has(id)) return json({ started: false, reason: '这张工单已经在派了' });
|
|
211
444
|
running.add(id);
|
|
212
445
|
// 后台跑:同步等会把浏览器请求挂住好几分钟,而且拿不到中途状态
|
|
@@ -225,5 +458,5 @@ const server = http.createServer(async (req, res) => {
|
|
|
225
458
|
|
|
226
459
|
server.listen(PORT, '127.0.0.1', () => {
|
|
227
460
|
console.log(`团队看板:http://127.0.0.1:${PORT}/`);
|
|
228
|
-
console.log(`数据目录:${
|
|
461
|
+
console.log(`数据目录:${HOME}`);
|
|
229
462
|
});
|
package/selftest.mjs
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* supercrew selftest —— 装完跑一次,看这台机器上能不能正常干活。
|
|
4
|
+
*
|
|
5
|
+
* 它不只检查「进程能不能起来」,而是**真的让一个 worker 去读一个含中文的文件**,
|
|
6
|
+
* 看它读出来的是不是正确的中文。这是最容易出问题、又最难被发现的地方:
|
|
7
|
+
* worker 若用 shell 读文件,中文会变乱码(鈥斺€?),活就全废了,而用户不会知道。
|
|
8
|
+
*
|
|
9
|
+
* 用法:node selftest.mjs [--quick] [--cwd <目录>]
|
|
10
|
+
* --quick 只查环境与进程,不真派 worker(几秒出结果)
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
// selftest.mjs 就在仓库根目录,所以 HERE 本身就是 ROOT
|
|
20
|
+
const ROOT = HERE;
|
|
21
|
+
const ARGS = process.argv.slice(2);
|
|
22
|
+
const QUICK = ARGS.includes('--quick');
|
|
23
|
+
const ci = ARGS.indexOf('--cwd');
|
|
24
|
+
const WORKDIR = ci >= 0 && ARGS[ci + 1] ? ARGS[ci + 1] : fs.mkdtempSync(path.join(os.tmpdir(), 'crew-self-'));
|
|
25
|
+
|
|
26
|
+
// 一段中文样本:含标点、破折号、数字,正好覆盖那些会被编码搞坏的东西
|
|
27
|
+
const SAMPLE = '中文自检样本:破折号——全角引号「引号」·数字 1234567890·英文 mixed text';
|
|
28
|
+
const SAMPLE_TITLE = '自检样本.md';
|
|
29
|
+
|
|
30
|
+
const results = [];
|
|
31
|
+
function check(name, ok, detail) {
|
|
32
|
+
results.push({ name, ok, detail: detail || '' });
|
|
33
|
+
console.log(` ${ok === true ? '✓' : ok === false ? '✗' : '·'} ${name}${detail ? ' —— ' + detail : ''}`);
|
|
34
|
+
return ok;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function run(cmd, args, opts = {}) {
|
|
38
|
+
return new Promise((resolve) => {
|
|
39
|
+
const p = spawn(cmd, args, { windowsHide: true, ...opts });
|
|
40
|
+
let out = '', err = '';
|
|
41
|
+
p.stdout.on('data', (d) => { out += d; });
|
|
42
|
+
p.stderr.on('data', (d) => { err += d; });
|
|
43
|
+
p.on('error', (e) => resolve({ rc: -1, out, err: String(e.message) }));
|
|
44
|
+
p.on('close', (rc) => resolve({ rc, out, err }));
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 找 dsh 启动器(同 crew.mjs 的逻辑,这里只做必要的一份) */
|
|
49
|
+
function findDsh() {
|
|
50
|
+
const cands = [];
|
|
51
|
+
const add = (p) => p && cands.push(p);
|
|
52
|
+
if (process.env.CREW_DSH) add(process.env.CREW_DSH);
|
|
53
|
+
for (const base of [process.env.LOCALAPPDATA, path.join(os.homedir(), 'AppData', 'Local')]) {
|
|
54
|
+
if (base) add(path.join(base, 'Programs', 'DeepSeek Harness', 'resources', 'runtime', 'cli', 'bin', 'dsh.cmd'));
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
for (const u of fs.readdirSync('C:\\Users')) {
|
|
58
|
+
add(path.join('C:\\Users', u, 'AppData', 'Local', 'Programs', 'DeepSeek Harness',
|
|
59
|
+
'resources', 'runtime', 'cli', 'bin', 'dsh.cmd'));
|
|
60
|
+
}
|
|
61
|
+
} catch { /* 没有权限就算了 */ }
|
|
62
|
+
return cands.find((p) => { try { return fs.existsSync(p); } catch { return false; } }) || null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function resolveLaunch(dshCmd) {
|
|
66
|
+
const raw = fs.readFileSync(dshCmd, 'utf8');
|
|
67
|
+
const m = raw.match(/"([^"]*DeepSeek Harness\.exe)"\s+--expose-internals\s+"([^"]+)"/i);
|
|
68
|
+
if (!m) return null;
|
|
69
|
+
const dir = path.dirname(dshCmd);
|
|
70
|
+
let exe = m[1].replace(/%~dp0/gi, '');
|
|
71
|
+
let cli = m[2].replace(/%~dp0/gi, '');
|
|
72
|
+
if (!path.isAbsolute(exe)) exe = path.resolve(dir, exe);
|
|
73
|
+
if (!path.isAbsolute(cli)) cli = path.resolve(dir, cli);
|
|
74
|
+
return { exe, cli };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 有没有典型的中文乱码 */
|
|
78
|
+
const MOJIBAKE = /鈥|锟斤拷|锟|\uFFFD\uFFFD/;
|
|
79
|
+
|
|
80
|
+
console.log('supercrew 自检\n');
|
|
81
|
+
|
|
82
|
+
// ── 1. 环境 ───────────────────────────────────────────────────────────────
|
|
83
|
+
console.log('[1] 环境');
|
|
84
|
+
check('node 版本', Number(process.versions.node.split('.')[0]) >= 22, `v${process.versions.node}`);
|
|
85
|
+
const dsh = findDsh();
|
|
86
|
+
check('找得到 dsh 启动器', !!dsh, dsh || '没找到(可以用 CREW_DSH 指定)');
|
|
87
|
+
const L = dsh ? resolveLaunch(dsh) : null;
|
|
88
|
+
check('dsh 启动器能解析', !!L, L ? L.exe : '解析失败');
|
|
89
|
+
|
|
90
|
+
const workerProfile = process.env.CREW_WORKER_PROFILE || 'headless';
|
|
91
|
+
const dshHome = process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
|
|
92
|
+
check(`worker profile「${workerProfile}」存在`,
|
|
93
|
+
fs.existsSync(path.join(dshHome, 'profiles', workerProfile)),
|
|
94
|
+
path.join(dshHome, 'profiles', workerProfile));
|
|
95
|
+
|
|
96
|
+
const patch = process.env.CREW_PATCH || path.join(ROOT, 'worker-models.yml');
|
|
97
|
+
check('模型 patch 文件在', fs.existsSync(patch), patch);
|
|
98
|
+
const brief = path.join(ROOT, 'worker-brief.md');
|
|
99
|
+
check('worker 常识文件在', fs.existsSync(brief), brief);
|
|
100
|
+
|
|
101
|
+
// ── 2. 看板与值守 ─────────────────────────────────────────────────────────
|
|
102
|
+
console.log('\n[2] 看板与值守');
|
|
103
|
+
const port = Number(process.env.CREW_PANEL_PORT || 3090);
|
|
104
|
+
async function http(pathname) {
|
|
105
|
+
try {
|
|
106
|
+
const r = await fetch(`http://127.0.0.1:${port}${pathname}`, { signal: AbortSignal.timeout(5000) });
|
|
107
|
+
return { ok: r.ok, body: await r.text() };
|
|
108
|
+
} catch (e) { return { ok: false, body: String(e.message) }; }
|
|
109
|
+
}
|
|
110
|
+
const hb = await http('/api/heartbeat');
|
|
111
|
+
try {
|
|
112
|
+
const h = JSON.parse(hb.body);
|
|
113
|
+
check('看板在线且值守有心跳', hb.ok && h.alive === true,
|
|
114
|
+
hb.ok ? `最后心跳 ${h.ageSec ?? '?'} 秒前` : hb.body.slice(0, 60));
|
|
115
|
+
} catch {
|
|
116
|
+
check('看板在线且值守有心跳', false, hb.body.slice(0, 60) || '看板没响应');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (QUICK) {
|
|
120
|
+
console.log('\n(--quick:跳过真正派 worker 的测试)');
|
|
121
|
+
} else {
|
|
122
|
+
// ── 3. worker 能不能正确读中文(最重要的一项)────────────────────────────
|
|
123
|
+
console.log('\n[3] 派一个 worker 读中文文件(这是最容易坏的地方)');
|
|
124
|
+
fs.mkdirSync(WORKDIR, { recursive: true });
|
|
125
|
+
const sampleFile = path.join(WORKDIR, SAMPLE_TITLE);
|
|
126
|
+
fs.writeFileSync(sampleFile, SAMPLE, 'utf8');
|
|
127
|
+
|
|
128
|
+
if (!L) {
|
|
129
|
+
check('worker 读中文', false, '没有可用的 dsh,跳过');
|
|
130
|
+
} else {
|
|
131
|
+
const task = `读一下 ${sampleFile} 这个文件,把它里面的内容一字不差地原样输出出来。`
|
|
132
|
+
+ `只输出文件内容本身,不要加任何解释、不要加代码块。`;
|
|
133
|
+
const argv = ['--profile', workerProfile];
|
|
134
|
+
if (fs.existsSync(patch)) argv.push('--patch', patch);
|
|
135
|
+
argv.push('--permission-mode', 'danger-full-access', '--approval-policy', 'never', task);
|
|
136
|
+
|
|
137
|
+
const t0 = Date.now();
|
|
138
|
+
const r = await run(L.exe, ['--expose-internals', L.cli, ...argv], {
|
|
139
|
+
cwd: WORKDIR,
|
|
140
|
+
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
|
|
141
|
+
});
|
|
142
|
+
const secs = Math.round((Date.now() - t0) / 1000);
|
|
143
|
+
const got = (r.out || '').trim();
|
|
144
|
+
|
|
145
|
+
if (r.rc !== 0) {
|
|
146
|
+
check('worker 读中文', false, `worker 退出码 ${r.rc}(${secs} 秒):${(r.err || '').trim().split('\n').slice(-2).join(' ').slice(0, 160)}`);
|
|
147
|
+
} else if (MOJIBAKE.test(got) || MOJIBAKE.test(r.err)) {
|
|
148
|
+
check('worker 读中文', false,
|
|
149
|
+
`读出来是乱码 —— 它在用 shell 读文件。样本:${got.slice(0, 80)}`);
|
|
150
|
+
} else if (got.includes('自检样本') && got.includes('破折号')) {
|
|
151
|
+
check('worker 读中文', true, `${secs} 秒,读回来的中文是对的`);
|
|
152
|
+
} else {
|
|
153
|
+
check('worker 读中文', false, `${secs} 秒,但内容对不上:${got.slice(0, 100)}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── 4. worker 能不能正确写中文 ──────────────────────────────────────────
|
|
158
|
+
console.log('\n[4] 派一个 worker 写中文文件');
|
|
159
|
+
if (!L) {
|
|
160
|
+
check('worker 写中文', false, '没有可用的 dsh,跳过');
|
|
161
|
+
} else {
|
|
162
|
+
const outFile = path.join(WORKDIR, '自检输出.md');
|
|
163
|
+
const task2 = `把这句话一字不差地写进文件 ${outFile}(UTF-8 编码):\n${SAMPLE}\n`
|
|
164
|
+
+ `写完只回答「已写入」四个字。`;
|
|
165
|
+
const argv2 = ['--profile', workerProfile];
|
|
166
|
+
if (fs.existsSync(patch)) argv2.push('--patch', patch);
|
|
167
|
+
argv2.push('--permission-mode', 'danger-full-access', '--approval-policy', 'never', task2);
|
|
168
|
+
|
|
169
|
+
const t0 = Date.now();
|
|
170
|
+
const r2 = await run(L.exe, ['--expose-internals', L.cli, ...argv2], {
|
|
171
|
+
cwd: WORKDIR,
|
|
172
|
+
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
|
|
173
|
+
});
|
|
174
|
+
const secs2 = Math.round((Date.now() - t0) / 1000);
|
|
175
|
+
if (!fs.existsSync(outFile)) {
|
|
176
|
+
check('worker 写中文', false, `${secs2} 秒,但文件没生成:${(r2.err || '').trim().split('\n').slice(-2).join(' ').slice(0, 140)}`);
|
|
177
|
+
} else {
|
|
178
|
+
const written = fs.readFileSync(outFile, 'utf8');
|
|
179
|
+
if (MOJIBAKE.test(written)) check('worker 写中文', false, `写进去的是乱码:${written.slice(0, 80)}`);
|
|
180
|
+
else if (written.includes('自检样本') && written.includes('破折号')) check('worker 写中文', true, `${secs2} 秒,落盘的中文是对的`);
|
|
181
|
+
else check('worker 写中文', false, `内容对不上:${written.slice(0, 100)}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── 5. 结论 ───────────────────────────────────────────────────────────────
|
|
187
|
+
const bad = results.filter((r) => r.ok === false);
|
|
188
|
+
console.log('\n' + '─'.repeat(50));
|
|
189
|
+
if (!bad.length) {
|
|
190
|
+
console.log(`全部通过(${results.length} 项)。这套东西在这台机器上能正常干活。`);
|
|
191
|
+
process.exit(0);
|
|
192
|
+
}
|
|
193
|
+
console.log(`${bad.length} 项没过:`);
|
|
194
|
+
for (const b of bad) console.log(` ✗ ${b.name} —— ${b.detail}`);
|
|
195
|
+
console.log('\n中文读/写那两项没过的话:worker 在用 shell 读文件(GBK 乱码)。');
|
|
196
|
+
console.log('先确认 worker-brief.md 在包目录里、并且已随任务一起发给 worker。');
|
|
197
|
+
process.exit(1);
|
package/worker-brief.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# 干活之前,先看这一页
|
|
2
|
+
|
|
3
|
+
你是这个团队系统里的 worker(一次性的干活会话)。这一页是本机的规矩,照做能省掉大量绕圈。
|
|
4
|
+
|
|
5
|
+
## 一、读文件:用 read 工具,不要用 shell 命令
|
|
6
|
+
|
|
7
|
+
你手上有 **read / glob / grep 这些文件工具**,它们会自动处理编码。**读文件就用它们。**
|
|
8
|
+
|
|
9
|
+
不要用 shell 去读文件 —— 尤其不要用 PowerShell 的 `Get-Content`:这台机器上它默认按
|
|
10
|
+
GBK 解码,读 UTF-8 的中文文件会变乱码(`鈥斺€?` 这种),**而且行数也会算错**
|
|
11
|
+
(GBK 解码器会把换行字节当成多字节字符的一部分吃掉,几行会并成一行)。
|
|
12
|
+
|
|
13
|
+
只有 read 工具做不了的事(数行数、批量统计),才用 **python**:
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
# 数行数
|
|
17
|
+
python -c "print(sum(1 for _ in open(r'D:\path\file.mjs', encoding='utf-8')))"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Python 在 `D:\Python312\python.exe`(**不在 PATH,要写全路径**)。
|
|
21
|
+
|
|
22
|
+
## 二、开工前先自检(30 秒)
|
|
23
|
+
|
|
24
|
+
动手之前,先确认你能正确读到中文:用 **read 工具**读一个含中文的文件,看一眼中文是不是正常的。
|
|
25
|
+
|
|
26
|
+
若显示成 `鈥斺€?` 这类乱码,说明你走错路了 —— 换回 read 工具,或者用 python 指定 `encoding='utf-8'`。
|
|
27
|
+
|
|
28
|
+
这 30 秒能省掉十分钟的绕圈。
|
|
29
|
+
|
|
30
|
+
## 三、找文件 / 搜内容
|
|
31
|
+
|
|
32
|
+
- 找文件用 **glob**,搜内容用 **grep**(它们不碰 shell,编码是稳的)。
|
|
33
|
+
- 万不得已用 shell 时:不要用 `Select-String` 搜中文(同上面的理由)。
|
|
34
|
+
|
|
35
|
+
## 四、写文件
|
|
36
|
+
|
|
37
|
+
产出**一定要落盘**到指定的文件名。用工具写;用 shell 写就用 python 并显式 `encoding='utf-8'`。
|
|
38
|
+
|
|
39
|
+
## 五、路径
|
|
40
|
+
|
|
41
|
+
- 用**绝对路径**,别依赖当前目录。
|
|
42
|
+
- python 字符串里用 `r'D:\...'` 原始字符串,别写裸反斜杠。
|
|
43
|
+
|
|
44
|
+
## 六、干活的原则
|
|
45
|
+
|
|
46
|
+
1. 先看一眼目标长什么样,再动手 —— 别猜。
|
|
47
|
+
2. 同一个问题试两次不通就**换办法**,别在环境上死磕。
|
|
48
|
+
3. 有产出就落盘,别只留在推理里。
|
|
49
|
+
4. 最后用一两句话说明:干了什么、产出在哪个文件。
|
|
50
|
+
|
|
51
|
+
## 七、卡住了怎么办
|
|
52
|
+
|
|
53
|
+
- 命令报错 → 换 read/glob/grep 或 python 做同一件事,而不是反复调 shell 参数。
|
|
54
|
+
- 读文件乱码 → 你一定走了 shell,改用 read 工具。
|
|
55
|
+
- **超过 3 分钟没有任何产出** → 停下来,写清楚你卡在哪、试过什么,然后结束。
|
|
56
|
+
留一条说得清的失败说明,比继续绕圈有用得多。
|