dowafu 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -6
- package/README_zh-tw.md +20 -5
- package/dist/cli-args.js +9 -3
- package/dist/cli.js +23 -9
- package/dist/daemon.js +41 -15
- package/dist/db.js +11 -0
- package/dist/doctor.js +8 -3
- package/dist/job.js +48 -2
- package/dist/mcp/protocol.js +32 -3
- package/dist/messages.js +58 -36
- package/dist/output.js +5 -5
- package/dist/prompt.js +7 -5
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -208,7 +208,7 @@ not work: a reviewer's fixed closing line has to match the template the audit ch
|
|
|
208
208
|
against.
|
|
209
209
|
|
|
210
210
|
```bash
|
|
211
|
-
npx degit eyesofkids/dowafu/publish/en#v0.
|
|
211
|
+
npx degit eyesofkids/dowafu/publish/en#v0.6.0 .claude-pack # or publish/zh-tw
|
|
212
212
|
|
|
213
213
|
cd .claude-pack
|
|
214
214
|
TARGET=<your project>
|
|
@@ -244,8 +244,9 @@ There are two transports, and the difference that matters is who starts what.
|
|
|
244
244
|
### Both transports need `dowafu serve`
|
|
245
245
|
|
|
246
246
|
`dowafu mcp` only answers tool calls — **it never runs a reviewer**. The worker that
|
|
247
|
-
actually spends money lives in `dowafu serve`. Without one running, an approved job
|
|
248
|
-
|
|
247
|
+
actually spends money lives in `dowafu serve`. Without one running, an approved job never
|
|
248
|
+
runs — `dispatch_submit`, `dispatch_approve` and `dispatch_status` each print a `⚠` line
|
|
249
|
+
saying so.
|
|
249
250
|
|
|
250
251
|
```
|
|
251
252
|
stdio: your client ──spawn──→ dowafu mcp ──┐
|
|
@@ -255,9 +256,11 @@ stdio: your client ──spawn──→ dowafu mcp ──┐
|
|
|
255
256
|
HTTP: your client ──x-api-key──→ dowafu serve (endpoint and worker, one process)
|
|
256
257
|
```
|
|
257
258
|
|
|
258
|
-
So on stdio you keep `dowafu serve` running in a terminal next to the client.
|
|
259
|
-
|
|
260
|
-
|
|
259
|
+
So on stdio you keep `dowafu serve` running in a terminal next to the client. **Start it
|
|
260
|
+
before you approve.** A daemon only runs approvals made while it is up: when it starts,
|
|
261
|
+
and again when it stops, any job still waiting in `approved` goes back to pending approval,
|
|
262
|
+
so an approval given before the daemon was running has to be given again. `dowafu serve`
|
|
263
|
+
prints how many it sent back.
|
|
261
264
|
|
|
262
265
|
### stdio
|
|
263
266
|
|
|
@@ -353,6 +356,25 @@ With the daemon always up the stdio path needs nothing extra — `dowafu mcp` fi
|
|
|
353
356
|
worker already running. Typing `dowafu serve` yourself will then refuse with
|
|
354
357
|
`daemon already running (pid N)`: that is the guard working, not a fault.
|
|
355
358
|
|
|
359
|
+
### Stopping and restarting
|
|
360
|
+
|
|
361
|
+
`dowafu serve --stop` stops the daemon; `dowafu serve --restart` stops it and starts a new
|
|
362
|
+
one (or just starts one if none is running). When something would be affected, both ask
|
|
363
|
+
first — separately — about:
|
|
364
|
+
|
|
365
|
+
- **jobs still running** — stopping the daemon does not stop a reviewer already in flight.
|
|
366
|
+
It runs to completion, and its results and API cost are still recorded.
|
|
367
|
+
- **jobs approved but not yet started** — they go back to pending approval, so the next
|
|
368
|
+
daemon cannot run them without a fresh approval.
|
|
369
|
+
|
|
370
|
+
With no terminal to answer (an agent calling it, a script), a question counts as "no" and
|
|
371
|
+
nothing is stopped; pass `--yes` to go ahead.
|
|
372
|
+
|
|
373
|
+
If the daemon ignores SIGTERM, `--stop` says so and prints the pid to inspect. Do not reach
|
|
374
|
+
for SIGKILL: a reviewer's worker process can outlive the daemon and still write its results.
|
|
375
|
+
And if the daemon comes straight back after stopping, a service manager (the `KeepAlive` /
|
|
376
|
+
`Restart=always` above) is restarting it — stop it with `launchctl` or `systemctl` instead.
|
|
377
|
+
|
|
356
378
|
### Approval is still a separate step
|
|
357
379
|
|
|
358
380
|
`dispatch_submit` queues a job and stops; nothing is billed until it is approved. Two
|
package/README_zh-tw.md
CHANGED
|
@@ -199,7 +199,7 @@ dowafu providers import <path> # 匯入自己的設定檔
|
|
|
199
199
|
`publish/` 提供兩種語言:`publish/en/` 與 `publish/zh-tw/`。**擇一複製,不可混裝**——審查者的固定收尾句必須與稽核檢查用的範本是同一種語言。
|
|
200
200
|
|
|
201
201
|
```bash
|
|
202
|
-
npx degit eyesofkids/dowafu/publish/zh-tw#v0.
|
|
202
|
+
npx degit eyesofkids/dowafu/publish/zh-tw#v0.6.0 .claude-pack # 或 publish/en
|
|
203
203
|
|
|
204
204
|
cd .claude-pack
|
|
205
205
|
TARGET=<your project>
|
|
@@ -238,8 +238,8 @@ Agent 可以不走 shell,改用 MCP 驅動 `dowafu`。露出五個 tool:`dis
|
|
|
238
238
|
### 兩條路都需要 `dowafu serve`
|
|
239
239
|
|
|
240
240
|
`dowafu mcp` 只回應 tool 呼叫,**它不執行任何 reviewer**。真正花錢的 worker 在
|
|
241
|
-
`dowafu serve` 裡。沒有它,核准過的 job
|
|
242
|
-
|
|
241
|
+
`dowafu serve` 裡。沒有它,核准過的 job 永遠不會執行——`dispatch_submit`、`dispatch_approve`、
|
|
242
|
+
`dispatch_status` 都會印一行 `⚠` 說明這件事。
|
|
243
243
|
|
|
244
244
|
```
|
|
245
245
|
stdio: 你的 client ──spawn──→ dowafu mcp ──┐
|
|
@@ -249,8 +249,9 @@ stdio: 你的 client ──spawn──→ dowafu mcp ──┐
|
|
|
249
249
|
HTTP: 你的 client ──x-api-key──→ dowafu serve (端點與 worker 同一個行程)
|
|
250
250
|
```
|
|
251
251
|
|
|
252
|
-
所以走 stdio 時,要在 client 旁邊另開一個終端機讓 `dowafu serve`
|
|
253
|
-
|
|
252
|
+
所以走 stdio 時,要在 client 旁邊另開一個終端機讓 `dowafu serve` 跑著。**要先起 daemon,再核准。**
|
|
253
|
+
daemon 只執行它在線期間核准的 job:它啟動時、以及停止時,還停在「已核准」的 job 會被退回待核准,
|
|
254
|
+
所以在 daemon 起來之前給的核准要再給一次。`dowafu serve` 會印出退回了幾張。
|
|
254
255
|
|
|
255
256
|
### stdio
|
|
256
257
|
|
|
@@ -339,6 +340,20 @@ daemon 常駐之後,stdio 那條路不需要任何額外動作——`dowafu mc
|
|
|
339
340
|
這時自己再打 `dowafu serve` 會被擋下並回報 `daemon already running (pid N)`:
|
|
340
341
|
**那是守衛在運作,不是故障。**
|
|
341
342
|
|
|
343
|
+
### 停止與重啟
|
|
344
|
+
|
|
345
|
+
`dowafu serve --stop` 停掉 daemon;`dowafu serve --restart` 停掉之後再起一支新的
|
|
346
|
+
(沒在跑的話就直接起)。有東西會受影響時,兩者都會先分開問兩件事:
|
|
347
|
+
|
|
348
|
+
- **還在執行的 job**——停掉 daemon 不會停掉已經在跑的審查者,它會跑完,結果與 API 成本照樣記帳。
|
|
349
|
+
- **已核准但還沒開始的 job**——會退回待核准,下一支 daemon 沒有新的核准就不會跑它們。
|
|
350
|
+
|
|
351
|
+
沒有終端機可以回答時(agent 代為呼叫、腳本),問題一律視為「不要」,什麼都不會停;要繼續就帶 `--yes`。
|
|
352
|
+
|
|
353
|
+
daemon 收到 SIGTERM 卻沒停下來時,`--stop` 會明說並印出要檢查的 pid。**不要改用 SIGKILL**:
|
|
354
|
+
審查者的 worker 行程可能比 daemon 活得久,照樣會寫入結果。停掉之後 daemon 馬上又回來,
|
|
355
|
+
代表有服務管理器(上面的 `KeepAlive`/`Restart=always`)在重拉它——那要用 `launchctl` 或 `systemctl` 停。
|
|
356
|
+
|
|
342
357
|
### 核准仍然是獨立的一步
|
|
343
358
|
|
|
344
359
|
`dispatch_submit` 只把 job 排進佇列就停下,核准之前不會計費。兩種核准方式:在自己的終端機下
|
package/dist/cli-args.js
CHANGED
|
@@ -104,6 +104,7 @@ export function parseArgs(argv, lang) {
|
|
|
104
104
|
let doctorMode = false;
|
|
105
105
|
let serveMode = false;
|
|
106
106
|
let serveStop = false;
|
|
107
|
+
let serveRestart = false;
|
|
107
108
|
let mcpMode = false;
|
|
108
109
|
const numFlag = (name, apply) => {
|
|
109
110
|
flagHandlers[name] = (v) => {
|
|
@@ -159,6 +160,9 @@ export function parseArgs(argv, lang) {
|
|
|
159
160
|
else if (arg === "--stop") {
|
|
160
161
|
serveStop = true;
|
|
161
162
|
}
|
|
163
|
+
else if (arg === "--restart") {
|
|
164
|
+
serveRestart = true;
|
|
165
|
+
}
|
|
162
166
|
else if (arg === "mcp") {
|
|
163
167
|
mcpMode = true;
|
|
164
168
|
}
|
|
@@ -190,16 +194,18 @@ export function parseArgs(argv, lang) {
|
|
|
190
194
|
if (ticketDir !== undefined) {
|
|
191
195
|
throw new DispatchError(m(lang, "tooManyArgs", ticketDir, "--doctor", helpText), 2);
|
|
192
196
|
}
|
|
193
|
-
if (serveStop)
|
|
197
|
+
if (serveStop || serveRestart)
|
|
194
198
|
throw new DispatchError(m(lang, "stopRequiresServe"), 2);
|
|
195
199
|
return { mode: "doctor", options };
|
|
196
200
|
}
|
|
197
201
|
if (serveMode) {
|
|
198
202
|
if (ticketDir !== undefined)
|
|
199
203
|
throw new DispatchError(m(lang, "tooManyArgs", ticketDir, "serve", helpText), 2);
|
|
200
|
-
|
|
204
|
+
if (serveStop && serveRestart)
|
|
205
|
+
throw new DispatchError(m(lang, "stopRestartConflict"), 2);
|
|
206
|
+
return { mode: "serve", options, stop: serveStop, restart: serveRestart };
|
|
201
207
|
}
|
|
202
|
-
if (serveStop)
|
|
208
|
+
if (serveStop || serveRestart)
|
|
203
209
|
throw new DispatchError(m(lang, "stopRequiresServe"), 2);
|
|
204
210
|
if (mcpMode) {
|
|
205
211
|
if (ticketDir !== undefined)
|
package/dist/cli.js
CHANGED
|
@@ -34,7 +34,7 @@ import { m } from "./messages.js";
|
|
|
34
34
|
import { defaultDbPath, openDb } from "./db.js";
|
|
35
35
|
import { issueToken, listTokens, revokeToken } from "./api-token.js";
|
|
36
36
|
import { addAllow, addSpoke, createTicket, formatTicket, hasResults, importTicketDirectory } from "./ticket-store.js";
|
|
37
|
-
import { approveJob, findJobsByIdPrefix, getJob, listPendingApprovalJobs } from "./job.js";
|
|
37
|
+
import { approveJob, countQueueJobs, findJobsByIdPrefix, getJob, listPendingApprovalJobs } from "./job.js";
|
|
38
38
|
import { serve, stopDaemon } from "./daemon.js";
|
|
39
39
|
import { daemonAlive, daemonStatePath } from "./liveness.js";
|
|
40
40
|
import { startStdioServer } from "./mcp/stdio.js";
|
|
@@ -611,6 +611,9 @@ async function main() {
|
|
|
611
611
|
// 工單 key-db §B-3 #6:逐 provider 印出實際生效的是哪一層——doctorDb 開不起來時就沒有
|
|
612
612
|
// DB 可查,keyStatusRows 留空陣列(buildApiKeyValue 對空陣列的降級輸出)。
|
|
613
613
|
let keyStatusRows = [];
|
|
614
|
+
// A database failure must leave --doctor usable. Its JSON contract still has
|
|
615
|
+
// numeric queue fields, so the unavailable DB degrades to zero counts.
|
|
616
|
+
let queue = { pendingApproval: 0, approved: 0, running: 0 };
|
|
614
617
|
// A(gaps #1):整份報表——DB、金鑰、型號白名單、daemon——都要來自**這次真的會用到的**
|
|
615
618
|
// 那個 DB。先前這裡寫死 undefined/defaultDbPath(),於是 `--doctor --db <別的>` 印的
|
|
616
619
|
// 全是預設 DB 的狀況,而使用者指定的那個檔連建都不會建。
|
|
@@ -620,49 +623,60 @@ async function main() {
|
|
|
620
623
|
const providers = loadProviders(doctorDb, messageLang);
|
|
621
624
|
providersResult = { ok: true, providers, models: listModels(doctorDb) };
|
|
622
625
|
keyStatusRows = buildKeyStatusRows(doctorDb);
|
|
626
|
+
queue = countQueueJobs(doctorDb);
|
|
623
627
|
}
|
|
624
628
|
catch (err) {
|
|
625
629
|
const reason = err instanceof DispatchError ? err.message : maskString(String(err));
|
|
626
630
|
providersResult = { ok: false, reason };
|
|
627
631
|
}
|
|
628
|
-
const doctorData = buildDoctorReportData(getCommandName(), providersResult, process.env, undefined, undefined, undefined, daemonAlive(daemonStatePath(doctorDbPath)), keyStatusRows, doctorDbPath);
|
|
632
|
+
const doctorData = buildDoctorReportData(getCommandName(), providersResult, process.env, undefined, undefined, undefined, daemonAlive(daemonStatePath(doctorDbPath)), keyStatusRows, doctorDbPath, queue);
|
|
629
633
|
console.log(parsed.options.json ? JSON.stringify(doctorData) : renderDoctorReport(messageLang, getCommandName(), doctorData));
|
|
630
634
|
return;
|
|
631
635
|
}
|
|
632
636
|
if (parsed.mode === "serve") {
|
|
633
|
-
if (parsed.stop) {
|
|
637
|
+
if (parsed.stop || parsed.restart) {
|
|
634
638
|
const outcome = await stopDaemon(parsed.options.dbPath, {
|
|
635
639
|
force: parsed.options.yes,
|
|
636
640
|
confirmRunningJobs: async (count) => {
|
|
637
641
|
console.log(m(messageLang, "daemonStopRunningWarning", count));
|
|
638
642
|
return confirm(m(messageLang, "daemonStopConfirmPrompt"), process.stdout);
|
|
639
643
|
},
|
|
644
|
+
confirmApprovedJobs: async (count) => {
|
|
645
|
+
console.log(m(messageLang, "daemonStopApprovedWarning", count));
|
|
646
|
+
return confirm(m(messageLang, "daemonStopApprovedConfirmPrompt"), process.stdout);
|
|
647
|
+
},
|
|
640
648
|
});
|
|
641
649
|
switch (outcome.status) {
|
|
642
650
|
case "not_running":
|
|
643
651
|
console.log(m(messageLang, "daemonStopNotRunning", outcome.reason));
|
|
652
|
+
if (parsed.restart)
|
|
653
|
+
break;
|
|
644
654
|
break;
|
|
645
655
|
case "cancelled":
|
|
646
656
|
console.log(m(messageLang, "daemonStopCancelled"));
|
|
647
|
-
|
|
657
|
+
return;
|
|
648
658
|
case "pid_not_dowafu":
|
|
649
659
|
console.log(m(messageLang, "daemonStopPidNotDowafu", outcome.pid));
|
|
650
|
-
|
|
660
|
+
return;
|
|
651
661
|
case "signal_failed":
|
|
652
662
|
console.log(m(messageLang, "daemonStopSignalFailed", outcome.pid));
|
|
653
|
-
|
|
663
|
+
console.log(m(messageLang, "daemonStopApprovedCleaned", outcome.requeuedApprovedJobs));
|
|
664
|
+
return;
|
|
654
665
|
case "stopped":
|
|
655
666
|
console.log(m(messageLang, "daemonStopSucceeded", outcome.pid));
|
|
667
|
+
console.log(m(messageLang, "daemonStopApprovedCleaned", outcome.requeuedApprovedJobs));
|
|
656
668
|
break;
|
|
657
669
|
case "restarted":
|
|
658
670
|
console.log(m(messageLang, "daemonStopRestarted", outcome.pid));
|
|
659
|
-
|
|
671
|
+
return;
|
|
660
672
|
}
|
|
661
|
-
|
|
673
|
+
if (parsed.stop)
|
|
674
|
+
return;
|
|
662
675
|
}
|
|
663
676
|
try {
|
|
664
677
|
const daemon = await serve(parsed.options.dbPath, undefined, parsed.options.httpPort, messageLang);
|
|
665
|
-
console.log(m(messageLang, "daemonStarted"));
|
|
678
|
+
console.log(m(messageLang, parsed.restart ? "daemonRestartStarted" : "daemonStarted"));
|
|
679
|
+
console.log(m(messageLang, "daemonStartApprovedCleaned", daemon.requeuedApprovedJobs));
|
|
666
680
|
console.log(m(messageLang, "httpServerStarted", daemon.httpPort));
|
|
667
681
|
// B-1 #6:端點活著但沒發過 token,任何請求都進不來——失敗長得像成功,必須警告。
|
|
668
682
|
if (!daemon.hasActiveToken)
|
package/dist/daemon.js
CHANGED
|
@@ -2,7 +2,7 @@ import { execFileSync, spawn } from "node:child_process";
|
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import { hasAnyActiveToken } from "./api-token.js";
|
|
4
4
|
import { defaultDbPath, openDb } from "./db.js";
|
|
5
|
-
import { claimNextJob, finishJob, heartbeatJob, reapInterruptedJobs } from "./job.js";
|
|
5
|
+
import { claimNextJob, finishJob, heartbeatJob, reapInterruptedJobs, requeueApprovedJobs } from "./job.js";
|
|
6
6
|
import { DAEMON_STALE_MS, daemonAlive, daemonStatePath, writeHeartbeat } from "./liveness.js";
|
|
7
7
|
import { createHttpServer, DEFAULT_HTTP_PORT } from "./mcp/http.js";
|
|
8
8
|
import { maskString } from "./mask.js";
|
|
@@ -79,9 +79,13 @@ export function workerFailureMessage(lang, code, signal, stderr) {
|
|
|
79
79
|
export function runCliJob(job, dbPath, lang = "en", deps = DEFAULT_RUN_CLI_JOB_DEPS) {
|
|
80
80
|
const sourceMode = fileURLToPath(import.meta.url).endsWith(".ts");
|
|
81
81
|
const cliPath = fileURLToPath(new URL(sourceMode ? "./cli.ts" : "./cli.js", import.meta.url));
|
|
82
|
+
// v5 jobs carry an explicit language; rows queued by an older client keep NULL and retain
|
|
83
|
+
// the daemon-startup fallback. Pass the resolved value to the child so its prompt and
|
|
84
|
+
// stdout use the same language as the daemon's own failure message.
|
|
85
|
+
const jobLang = job.lang ?? lang;
|
|
82
86
|
const args = sourceMode
|
|
83
|
-
? ["--import", "tsx", cliPath, job.ticket, "--yes", "--job-id", job.id, "--db", dbPath]
|
|
84
|
-
: [cliPath, job.ticket, "--yes", "--job-id", job.id, "--db", dbPath];
|
|
87
|
+
? ["--import", "tsx", cliPath, job.ticket, "--yes", "--job-id", job.id, "--db", dbPath, "--lang", jobLang]
|
|
88
|
+
: [cliPath, job.ticket, "--yes", "--job-id", job.id, "--db", dbPath, "--lang", jobLang];
|
|
85
89
|
return new Promise((resolve) => {
|
|
86
90
|
const child = deps.spawn(process.execPath, args, { stdio: ["inherit", "inherit", "pipe"], env: process.env });
|
|
87
91
|
let stderrTail = Buffer.alloc(0);
|
|
@@ -90,12 +94,12 @@ export function runCliJob(job, dbPath, lang = "en", deps = DEFAULT_RUN_CLI_JOB_D
|
|
|
90
94
|
deps.stderr.write(chunk);
|
|
91
95
|
stderrTail = Buffer.concat([stderrTail, chunk]).subarray(-WORKER_STDERR_TAIL_BYTES);
|
|
92
96
|
});
|
|
93
|
-
child.once("error", (err) => resolve({ error: m(
|
|
97
|
+
child.once("error", (err) => resolve({ error: m(jobLang, "daemonWorkerSpawnFailed", maskString(String(err))) }));
|
|
94
98
|
child.once("exit", (code, signal) => {
|
|
95
99
|
if (code === 0)
|
|
96
100
|
resolve({});
|
|
97
101
|
else
|
|
98
|
-
resolve({ error: workerFailureMessage(
|
|
102
|
+
resolve({ error: workerFailureMessage(jobLang, code, signal, stderrTail.toString("utf8")) });
|
|
99
103
|
});
|
|
100
104
|
});
|
|
101
105
|
}
|
|
@@ -108,6 +112,11 @@ const DEFAULT_STOP_DAEMON_DEPS = {
|
|
|
108
112
|
const row = openDb(dbPath).prepare("SELECT COUNT(*) AS count FROM jobs WHERE status = 'running'").get();
|
|
109
113
|
return row.count;
|
|
110
114
|
},
|
|
115
|
+
countApprovedJobs: (dbPath) => {
|
|
116
|
+
const row = openDb(dbPath).prepare("SELECT COUNT(*) AS count FROM jobs WHERE status = 'approved'").get();
|
|
117
|
+
return row.count;
|
|
118
|
+
},
|
|
119
|
+
requeueApprovedJobs: (dbPath) => requeueApprovedJobs(openDb(dbPath)),
|
|
111
120
|
processCommand: (pid) => {
|
|
112
121
|
try {
|
|
113
122
|
return execFileSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
@@ -116,6 +125,15 @@ const DEFAULT_STOP_DAEMON_DEPS = {
|
|
|
116
125
|
return null;
|
|
117
126
|
}
|
|
118
127
|
},
|
|
128
|
+
processExists: (pid) => {
|
|
129
|
+
try {
|
|
130
|
+
process.kill(pid, 0);
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
},
|
|
119
137
|
kill: (pid, signal) => process.kill(pid, signal),
|
|
120
138
|
wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
121
139
|
};
|
|
@@ -128,28 +146,33 @@ export async function stopDaemon(dbPath = defaultDbPath(), options = {}) {
|
|
|
128
146
|
const initial = deps.liveness(statePath);
|
|
129
147
|
if (!initial.alive)
|
|
130
148
|
return { status: "not_running", reason: initial.reason };
|
|
131
|
-
const runningJobs = deps.countRunningJobs(dbPath);
|
|
132
|
-
if (runningJobs > 0 && !options.force) {
|
|
133
|
-
const confirmed = await (options.confirmRunningJobs?.(runningJobs) ?? Promise.resolve(false));
|
|
134
|
-
if (!confirmed)
|
|
135
|
-
return { status: "cancelled", runningJobs };
|
|
136
|
-
}
|
|
137
149
|
if (!isDowafuProcess(deps.processCommand(initial.pid)))
|
|
138
150
|
return { status: "pid_not_dowafu", pid: initial.pid };
|
|
151
|
+
const runningJobs = deps.countRunningJobs(dbPath);
|
|
152
|
+
const approvedJobs = deps.countApprovedJobs(dbPath);
|
|
153
|
+
const confirmedRunning = runningJobs === 0 || options.force || await (options.confirmRunningJobs?.(runningJobs) ?? Promise.resolve(false));
|
|
154
|
+
// Ask independently even when the stop confirmation was declined. A running
|
|
155
|
+
// worker warning and an old-approval cleanup warning describe different risks.
|
|
156
|
+
const confirmedApproved = approvedJobs === 0 || options.force || await (options.confirmApprovedJobs?.(approvedJobs) ?? Promise.resolve(false));
|
|
157
|
+
if (!confirmedRunning || !confirmedApproved)
|
|
158
|
+
return { status: "cancelled", runningJobs, approvedJobs };
|
|
159
|
+
const requeuedApprovedJobs = deps.requeueApprovedJobs(dbPath);
|
|
139
160
|
try {
|
|
140
161
|
deps.kill(initial.pid, "SIGTERM");
|
|
141
162
|
}
|
|
142
163
|
catch {
|
|
143
|
-
return { status: "signal_failed", pid: initial.pid };
|
|
164
|
+
return { status: "signal_failed", pid: initial.pid, requeuedApprovedJobs };
|
|
144
165
|
}
|
|
145
166
|
for (let attempt = 0; attempt < STOP_CHECK_ATTEMPTS; attempt++) {
|
|
146
167
|
await deps.wait(STOP_CHECK_INTERVAL_MS);
|
|
147
|
-
if (!deps.liveness(statePath).alive) {
|
|
168
|
+
if (!deps.liveness(statePath).alive && !deps.processExists(initial.pid)) {
|
|
148
169
|
await deps.wait(STOP_RESTART_WAIT_MS);
|
|
149
|
-
return deps.liveness(statePath).alive
|
|
170
|
+
return deps.liveness(statePath).alive
|
|
171
|
+
? { status: "restarted", pid: initial.pid }
|
|
172
|
+
: { status: "stopped", pid: initial.pid, requeuedApprovedJobs };
|
|
150
173
|
}
|
|
151
174
|
}
|
|
152
|
-
return { status: "signal_failed", pid: initial.pid };
|
|
175
|
+
return { status: "signal_failed", pid: initial.pid, requeuedApprovedJobs };
|
|
153
176
|
}
|
|
154
177
|
function bindHttp(db, dbPath, httpPort) {
|
|
155
178
|
return new Promise((resolve, reject) => {
|
|
@@ -182,7 +205,9 @@ export function serve(dbPath = defaultDbPath(), maxConcurrent = Number(process.e
|
|
|
182
205
|
const db = openDb(dbPath);
|
|
183
206
|
return bindHttp(db, dbPath, httpPort).then((httpServer) => {
|
|
184
207
|
let daemon;
|
|
208
|
+
let requeuedApprovedJobs;
|
|
185
209
|
try {
|
|
210
|
+
requeuedApprovedJobs = requeueApprovedJobs(db);
|
|
186
211
|
daemon = createDaemon(db, { dbPath, maxConcurrent, execute: (job) => runCliJob(job, dbPath, lang) });
|
|
187
212
|
}
|
|
188
213
|
catch (err) {
|
|
@@ -204,6 +229,7 @@ export function serve(dbPath = defaultDbPath(), maxConcurrent = Number(process.e
|
|
|
204
229
|
},
|
|
205
230
|
httpPort: boundPort,
|
|
206
231
|
hasActiveToken: hasAnyActiveToken(db),
|
|
232
|
+
requeuedApprovedJobs,
|
|
207
233
|
};
|
|
208
234
|
});
|
|
209
235
|
}
|
package/dist/db.js
CHANGED
|
@@ -180,6 +180,17 @@ const MIGRATIONS = [
|
|
|
180
180
|
db.exec("ALTER TABLE provider_keys ADD COLUMN last_test_succeeded INTEGER");
|
|
181
181
|
},
|
|
182
182
|
},
|
|
183
|
+
// queue-lang-rerun §A: old queued rows deliberately remain NULL so they keep the daemon's
|
|
184
|
+
// startup-language behavior. Inspect columns even when a downgraded fixture already has
|
|
185
|
+
// user_version 5; SQLite has no ADD COLUMN IF NOT EXISTS form for this migration.
|
|
186
|
+
{
|
|
187
|
+
version: 5,
|
|
188
|
+
up: (db) => {
|
|
189
|
+
const columns = new Set(db.prepare("PRAGMA table_info(jobs)").all().map((column) => column.name));
|
|
190
|
+
if (!columns.has("lang"))
|
|
191
|
+
db.exec("ALTER TABLE jobs ADD COLUMN lang TEXT");
|
|
192
|
+
},
|
|
193
|
+
},
|
|
183
194
|
];
|
|
184
195
|
const LATEST_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
|
185
196
|
function migrate(db, lang) {
|
package/dist/doctor.js
CHANGED
|
@@ -64,6 +64,9 @@ function buildDaemonValue(lang, daemon) {
|
|
|
64
64
|
return m(lang, "doctorDaemonAlive", daemon.pid);
|
|
65
65
|
return m(lang, "doctorDaemonMissing", daemon?.reason ?? "heartbeat_missing");
|
|
66
66
|
}
|
|
67
|
+
function buildQueueValue(lang, queue) {
|
|
68
|
+
return m(lang, "doctorQueueValue", queue.pendingApproval, queue.approved, queue.running);
|
|
69
|
+
}
|
|
67
70
|
function buildConfigDirValue(lang, configDir) {
|
|
68
71
|
if (configDir.source === "unresolved")
|
|
69
72
|
return m(lang, "doctorConfigDirUnresolved");
|
|
@@ -117,7 +120,7 @@ function buildLensValue(lang, lenses) {
|
|
|
117
120
|
const closingNames = lenses.withClosing.length > 0 ? lenses.withClosing.join(joiner) : m(lang, "noneLabel");
|
|
118
121
|
return m(lang, "doctorLensFoundValue", lenses.dirPath, lenses.withClosing.length + lenses.withoutClosing.length, lenses.withClosing.length, closingNames, noClosingSuffix);
|
|
119
122
|
}
|
|
120
|
-
export function buildDoctorReportData(cmd, providers, env = process.env, homedir = os.homedir, probe = DEFAULT_PROBE, cwd = process.cwd(), daemon, keyStatusRows = [], dbPath) {
|
|
123
|
+
export function buildDoctorReportData(cmd, providers, env = process.env, homedir = os.homedir, probe = DEFAULT_PROBE, cwd = process.cwd(), daemon, keyStatusRows = [], dbPath, queue = { pendingApproval: 0, approved: 0, running: 0 }) {
|
|
121
124
|
const dispatchHome = resolveDispatchHome(env, homedir);
|
|
122
125
|
const configDir = dispatchHome === null
|
|
123
126
|
? { source: "unresolved" }
|
|
@@ -149,6 +152,7 @@ export function buildDoctorReportData(cmd, providers, env = process.env, homedir
|
|
|
149
152
|
apiKeys: keyStatusRows.map((row) => ({ ...row })),
|
|
150
153
|
models,
|
|
151
154
|
daemon: daemon ?? { alive: false, reason: "heartbeat_missing" },
|
|
155
|
+
queue: { ...queue },
|
|
152
156
|
lenses: buildLensData(probe, cwd),
|
|
153
157
|
};
|
|
154
158
|
}
|
|
@@ -163,12 +167,13 @@ export function renderDoctorReport(lang, cmd, data) {
|
|
|
163
167
|
m(lang, "doctorApiKeyLine", buildApiKeyValue(lang, data.apiKeys)),
|
|
164
168
|
m(lang, "doctorModelListLine", buildModelListValue(lang, data.models)),
|
|
165
169
|
m(lang, "doctorDaemonLine", buildDaemonValue(lang, data.daemon)),
|
|
170
|
+
m(lang, "doctorQueueLine", buildQueueValue(lang, data.queue)),
|
|
166
171
|
m(lang, "doctorLensLine", buildLensValue(lang, data.lenses)),
|
|
167
172
|
"",
|
|
168
173
|
m(lang, "doctorFooter"),
|
|
169
174
|
];
|
|
170
175
|
return lines.join("\n");
|
|
171
176
|
}
|
|
172
|
-
export function buildDoctorReport(lang, cmd, providers, env = process.env, homedir = os.homedir, probe = DEFAULT_PROBE, cwd = process.cwd(), daemon, keyStatusRows = [], dbPath) {
|
|
173
|
-
return renderDoctorReport(lang, cmd, buildDoctorReportData(cmd, providers, env, homedir, probe, cwd, daemon, keyStatusRows, dbPath));
|
|
177
|
+
export function buildDoctorReport(lang, cmd, providers, env = process.env, homedir = os.homedir, probe = DEFAULT_PROBE, cwd = process.cwd(), daemon, keyStatusRows = [], dbPath, queue = { pendingApproval: 0, approved: 0, running: 0 }) {
|
|
178
|
+
return renderDoctorReport(lang, cmd, buildDoctorReportData(cmd, providers, env, homedir, probe, cwd, daemon, keyStatusRows, dbPath, queue));
|
|
174
179
|
}
|
package/dist/job.js
CHANGED
|
@@ -21,19 +21,65 @@ export function findJobsByIdPrefix(db, prefix) {
|
|
|
21
21
|
export function listPendingApprovalJobs(db) {
|
|
22
22
|
return db.prepare("SELECT * FROM jobs WHERE status = 'pending_approval' ORDER BY created_at, id").all();
|
|
23
23
|
}
|
|
24
|
-
|
|
24
|
+
// Keep this to one indexed aggregate query rather than loading jobs and counting
|
|
25
|
+
// in JavaScript. `idx_jobs_status` covers both the WHERE predicate and status.
|
|
26
|
+
export function countQueueJobs(db) {
|
|
27
|
+
const rows = db
|
|
28
|
+
.prepare(`SELECT status, COUNT(*) AS count
|
|
29
|
+
FROM jobs
|
|
30
|
+
WHERE status IN ('pending_approval', 'approved', 'running')
|
|
31
|
+
GROUP BY status`)
|
|
32
|
+
.all();
|
|
33
|
+
const counts = { pendingApproval: 0, approved: 0, running: 0 };
|
|
34
|
+
for (const row of rows) {
|
|
35
|
+
if (row.status === "pending_approval")
|
|
36
|
+
counts.pendingApproval = Number(row.count);
|
|
37
|
+
else if (row.status === "approved")
|
|
38
|
+
counts.approved = Number(row.count);
|
|
39
|
+
else
|
|
40
|
+
counts.running = Number(row.count);
|
|
41
|
+
}
|
|
42
|
+
return counts;
|
|
43
|
+
}
|
|
44
|
+
export function submitJob(db, ticket, id = randomUUID(), lang = null) {
|
|
25
45
|
if (!db.prepare("SELECT 1 FROM tickets WHERE name = ?").get(ticket))
|
|
26
46
|
throw new Error(`ticket does not exist: ${ticket}`);
|
|
27
47
|
const createdAt = now();
|
|
28
|
-
db.prepare("INSERT INTO jobs (id, ticket, status, created_at) VALUES (?, ?, 'pending_approval', ?)").run(id, ticket, createdAt);
|
|
48
|
+
db.prepare("INSERT INTO jobs (id, ticket, lang, status, created_at) VALUES (?, ?, ?, 'pending_approval', ?)").run(id, ticket, lang, createdAt);
|
|
29
49
|
return getJob(db, id);
|
|
30
50
|
}
|
|
51
|
+
// A dispatch can leave one __summary__ row plus one row per spoke. Count distinct result
|
|
52
|
+
// keys rather than rows so a single run remains one reminder even when its summary is absent
|
|
53
|
+
// or it has many spokes. The UNION covers both historical CLI keys (ticket name) and queued
|
|
54
|
+
// job keys (jobs.id for the same ticket) without double-counting a pathological overlap.
|
|
55
|
+
export function countTicketResultRuns(db, ticket) {
|
|
56
|
+
const row = db
|
|
57
|
+
.prepare(`SELECT COUNT(*) AS count FROM (
|
|
58
|
+
SELECT job_id FROM results WHERE job_id = ? GROUP BY job_id
|
|
59
|
+
UNION
|
|
60
|
+
SELECT r.job_id
|
|
61
|
+
FROM jobs j JOIN results r ON r.job_id = j.id
|
|
62
|
+
WHERE j.ticket = ?
|
|
63
|
+
GROUP BY r.job_id
|
|
64
|
+
)`)
|
|
65
|
+
.get(ticket, ticket);
|
|
66
|
+
return Number(row.count);
|
|
67
|
+
}
|
|
31
68
|
export function approveJob(db, id) {
|
|
32
69
|
return one(db, `UPDATE jobs
|
|
33
70
|
SET status = 'approved', approved_at = ?
|
|
34
71
|
WHERE id = ? AND status = 'pending_approval'
|
|
35
72
|
RETURNING *`, now(), id);
|
|
36
73
|
}
|
|
74
|
+
// A daemon owns only approvals made after it starts. Before a new daemon begins
|
|
75
|
+
// claiming work, return any older approvals to the explicit human-approval gate.
|
|
76
|
+
// Keep this as a single guarded UPDATE: concurrent approval is allowed to remain
|
|
77
|
+
// approved for the new daemon, and no other lifecycle state is touched.
|
|
78
|
+
export function requeueApprovedJobs(db) {
|
|
79
|
+
return Number(db.prepare(`UPDATE jobs
|
|
80
|
+
SET status = 'pending_approval', approved_at = NULL
|
|
81
|
+
WHERE status = 'approved'`).run().changes);
|
|
82
|
+
}
|
|
37
83
|
export function rejectJob(db, id) {
|
|
38
84
|
return one(db, `UPDATE jobs
|
|
39
85
|
SET status = 'rejected', finished_at = ?
|
package/dist/mcp/protocol.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { defaultDbPath, openDb } from "../db.js";
|
|
3
|
-
import { approveJob, findJobsByIdPrefix, getJob, submitJob } from "../job.js";
|
|
3
|
+
import { approveJob, countTicketResultRuns, findJobsByIdPrefix, getJob, submitJob } from "../job.js";
|
|
4
4
|
import { DAEMON_STALE_MS, daemonAlive, daemonStatePath, daemonWarning } from "../liveness.js";
|
|
5
5
|
import { readProgress } from "../progress.js";
|
|
6
6
|
import { m } from "../messages.js";
|
|
@@ -20,7 +20,10 @@ export const TOOLS = [
|
|
|
20
20
|
description: "Queue a DB ticket by name. It remains pending human terminal approval and is not executed by this tool.",
|
|
21
21
|
inputSchema: {
|
|
22
22
|
type: "object",
|
|
23
|
-
properties: {
|
|
23
|
+
properties: {
|
|
24
|
+
ticket: { type: "string", description: "DB ticket name, not a filesystem path" },
|
|
25
|
+
lang: { type: "string", enum: ["en", "zh-tw", "zh"], description: "Language for this queued job's CLI output and spoke prompts" },
|
|
26
|
+
},
|
|
24
27
|
required: ["ticket"],
|
|
25
28
|
},
|
|
26
29
|
},
|
|
@@ -70,6 +73,17 @@ function stringParam(params, name) {
|
|
|
70
73
|
const value = record(params)?.[name];
|
|
71
74
|
return typeof value === "string" && value.length > 0 ? value : null;
|
|
72
75
|
}
|
|
76
|
+
// Keep this acceptance and normalization identical to the CLI's --lang contract without
|
|
77
|
+
// importing cli-args.ts: cli-args imports the MCP HTTP transport, so sharing it here would
|
|
78
|
+
// create a protocol -> CLI args -> HTTP -> protocol runtime cycle.
|
|
79
|
+
function parseSubmitLang(value) {
|
|
80
|
+
const normalized = value.trim().toLowerCase();
|
|
81
|
+
if (normalized === "en")
|
|
82
|
+
return "en";
|
|
83
|
+
if (normalized === "zh-tw" || normalized === "zh")
|
|
84
|
+
return "zh";
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
73
87
|
function keyStatusText(lang, row) {
|
|
74
88
|
if (row.maskedTail === null)
|
|
75
89
|
return m(lang, "keyStatusMissing");
|
|
@@ -122,6 +136,9 @@ export function createProtocol(options) {
|
|
|
122
136
|
for (const { name: ticket } of names) {
|
|
123
137
|
const doc = getTicketDoc(options.db, ticket, options.lang);
|
|
124
138
|
lines.push(m(options.lang, "mcpTicketsRow", ticket, doc.spokes.length));
|
|
139
|
+
const priorRuns = countTicketResultRuns(options.db, ticket);
|
|
140
|
+
if (priorRuns > 0)
|
|
141
|
+
lines.push(` ${m(options.lang, "mcpPriorResultsReminder", priorRuns)}`);
|
|
125
142
|
for (const spoke of doc.spokes) {
|
|
126
143
|
lines.push(m(options.lang, "mcpTicketsSpoke", spoke.agent, spoke.provider, spoke.model, spoke.allow.length));
|
|
127
144
|
}
|
|
@@ -139,11 +156,23 @@ export function createProtocol(options) {
|
|
|
139
156
|
const ticket = stringParam(args, "ticket");
|
|
140
157
|
if (!ticket)
|
|
141
158
|
return text("dispatch_submit requires a non-empty ticket name", true);
|
|
159
|
+
const suppliedLang = record(args)?.lang;
|
|
160
|
+
let jobLang = null;
|
|
161
|
+
if (suppliedLang !== undefined) {
|
|
162
|
+
if (typeof suppliedLang !== "string")
|
|
163
|
+
return text(m(options.lang, "mcpSubmitInvalidLang", "(not a string)", m(options.lang, "availableLangValues")), true);
|
|
164
|
+
jobLang = parseSubmitLang(suppliedLang);
|
|
165
|
+
if (jobLang === null)
|
|
166
|
+
return text(m(options.lang, "mcpSubmitInvalidLang", suppliedLang, m(options.lang, "availableLangValues")), true);
|
|
167
|
+
}
|
|
142
168
|
try {
|
|
143
|
-
const
|
|
169
|
+
const priorRuns = countTicketResultRuns(options.db, ticket);
|
|
170
|
+
const job = submitJob(options.db, ticket, undefined, jobLang);
|
|
144
171
|
const cliPath = path.resolve(process.argv[1] ?? "cli.js");
|
|
145
172
|
const approveCommand = `node ${JSON.stringify(cliPath)} approve ${job.id.slice(0, 8)} --db ${JSON.stringify(options.dbPath)}`;
|
|
146
173
|
const lines = [m(options.lang, "mcpSubmitQueued", job.id), m(options.lang, "mcpApproveCommand", approveCommand), formatTicket(options.db, ticket, options.lang)];
|
|
174
|
+
if (priorRuns > 0)
|
|
175
|
+
lines.push(m(options.lang, "mcpPriorResultsReminder", priorRuns));
|
|
147
176
|
const offline = warning();
|
|
148
177
|
if (offline)
|
|
149
178
|
lines.push(offline);
|
package/dist/messages.js
CHANGED
|
@@ -27,6 +27,18 @@ const MESSAGES = {
|
|
|
27
27
|
dryRunNotice: () => "--dry-run:僅解析/驗證/估算/印報表,未呼叫任何 API。",
|
|
28
28
|
helpText: (cmd) => `用法:${cmd} <ticket-id> [options]
|
|
29
29
|
|
|
30
|
+
子指令(各自有自己的 --help/用法訊息):
|
|
31
|
+
${cmd} ticket ... 建立、匯入、檢視工單
|
|
32
|
+
${cmd} result <id> 印出一次派工的結果(各 spoke 原文與稽核表)
|
|
33
|
+
${cmd} key 設定 provider API key(互動選單)
|
|
34
|
+
${cmd} providers ... 型號白名單與啟用狀態
|
|
35
|
+
${cmd} token ... 入站 HTTP 憑證的發/列/撤銷
|
|
36
|
+
${cmd} approve [jobId] 核准佇列中等待的派工
|
|
37
|
+
${cmd} serve 啟動常駐 daemon(含 MCP 的 HTTP binding)
|
|
38
|
+
${cmd} serve --stop [--yes] 停止常駐 daemon;有執行中的 job 或舊核准 job 時會先確認
|
|
39
|
+
${cmd} serve --restart [--yes] 停止後重新啟動 daemon,會走兩端清理
|
|
40
|
+
${cmd} mcp 以 stdio 提供 MCP server
|
|
41
|
+
|
|
30
42
|
--lang <en|zh-tw> CLI 輸出與 spoke prompt 的語言,預設 en
|
|
31
43
|
--repo-root <dir> 白名單邊界與 .claude/agents 的根,預設 cwd
|
|
32
44
|
--db <path> SQLite 工單與產物資料庫,預設 DISPATCH_HOME/dowafu.db
|
|
@@ -47,18 +59,7 @@ const MESSAGES = {
|
|
|
47
59
|
--yes 略過派工確認。非互動環境(stdin 不是 TTY)沒帶就中止
|
|
48
60
|
--doctor 印出設定自檢(不呼叫 API、不花錢)後結束(exit 0)
|
|
49
61
|
--help, -h 印本說明後結束(exit 0)
|
|
50
|
-
--version, -V 印版本號後結束(exit 0
|
|
51
|
-
|
|
52
|
-
子指令(各自有自己的 --help/用法訊息):
|
|
53
|
-
${cmd} ticket ... 建立、匯入、檢視工單
|
|
54
|
-
${cmd} result <id> 印出一次派工的結果(各 spoke 原文與稽核表)
|
|
55
|
-
${cmd} key 設定 provider API key(互動選單)
|
|
56
|
-
${cmd} providers ... 型號白名單與啟用狀態
|
|
57
|
-
${cmd} token ... 入站 HTTP 憑證的發/列/撤銷
|
|
58
|
-
${cmd} approve [jobId] 核准佇列中等待的派工
|
|
59
|
-
${cmd} serve 啟動常駐 daemon(含 MCP 的 HTTP binding)
|
|
60
|
-
${cmd} serve --stop [--yes] 停止常駐 daemon;有執行中的 job 時會先確認
|
|
61
|
-
${cmd} mcp 以 stdio 提供 MCP server`,
|
|
62
|
+
--version, -V 印版本號後結束(exit 0)`,
|
|
62
63
|
availableLangValues: () => "en、zh-tw、zh",
|
|
63
64
|
availableValuesSuffix: (values) => `(可用值:${values})`,
|
|
64
65
|
numberFlagInvalid: (name, value) => `--${name} 需要數字,收到:${value}`,
|
|
@@ -107,7 +108,8 @@ const MESSAGES = {
|
|
|
107
108
|
mcpJobPrefixAmbiguous: (prefix, count) => `前綴 ${prefix} 命中 ${count} 個 job,請改用完整 id:`,
|
|
108
109
|
jobNotApprovable: (jobId, status) => `job ${jobId} 目前是 ${status},不能核准`,
|
|
109
110
|
jobApproved: (jobId) => `已核准 job:${jobId}`,
|
|
110
|
-
daemonStarted: () => "daemon 已啟動。收到 SIGINT
|
|
111
|
+
daemonStarted: () => "daemon 已啟動。收到 SIGINT 時會乾淨退出。",
|
|
112
|
+
daemonStartApprovedCleaned: (count) => `啟動前已將 ${count} 張舊的已核准 job 退回待核准。`,
|
|
111
113
|
daemonStartFailed: (detail) => `daemon 無法啟動:${detail}`,
|
|
112
114
|
daemonWorkerSpawnFailed: (detail) => `worker 無法啟動:${detail}`,
|
|
113
115
|
daemonWorkerExited: (exitCode) => `worker 以 exit code ${exitCode} 結束`,
|
|
@@ -115,18 +117,23 @@ const MESSAGES = {
|
|
|
115
117
|
daemonWorkerTerminated: (signal) => `worker 被訊號 ${signal} 終止`,
|
|
116
118
|
daemonWorkerTerminatedDetail: (signal, detail) => `worker 被訊號 ${signal} 終止:${detail}`,
|
|
117
119
|
stopRequiresServe: () => "--stop 只能與 serve 一起使用。",
|
|
120
|
+
stopRestartConflict: () => "--stop 與 --restart 不能同時使用。",
|
|
118
121
|
daemonStopRunningWarning: (count) => `目前有 ${count} 張 job 正在執行;停止 daemon 不會停止正在跑的 spoke,它們會繼續跑完,之後結果與 API 成本仍會被認列。`,
|
|
119
122
|
daemonStopConfirmPrompt: () => "確定要停止 daemon 嗎?[y/N] ",
|
|
123
|
+
daemonStopApprovedWarning: (count) => `目前有 ${count} 張已核准 job 尚未執行;停止前會退回待核准,避免下一支 daemon 未經本輪核准就執行。`,
|
|
124
|
+
daemonStopApprovedConfirmPrompt: () => "確定要清理這些已核准 job 嗎?[y/N] ",
|
|
120
125
|
daemonStopCancelled: () => "已取消,daemon 仍在執行。",
|
|
121
126
|
daemonStopNotRunning: (reason) => `daemon 未在執行(${reason})。`,
|
|
122
127
|
daemonStopPidNotDowafu: (pid) => `pid ${pid} 的行程不是 dowafu,為避免誤殺未送出訊號。`,
|
|
123
|
-
daemonStopSignalFailed: (pid) => `已向 pid ${pid}
|
|
128
|
+
daemonStopSignalFailed: (pid) => `已向 pid ${pid} 送出 SIGTERM,但它沒有停止。請手動檢查:ps -p ${pid} -o pid,ppid,command=;確認後可再送 SIGTERM。不要用 SIGKILL,worker 子行程可能仍會繼續寫入。`,
|
|
124
129
|
daemonStopSucceeded: (pid) => `daemon(pid ${pid})已停止。`,
|
|
125
130
|
daemonStopRestarted: (pid) => `daemon(原 pid ${pid})停止後又恢復運行;看起來有 OS 層服務管理在重拉它。要真的停下來請用 launchctl 或 systemctl。`,
|
|
131
|
+
daemonStopApprovedCleaned: (count) => `已將 ${count} 張已核准 job 退回待核准。`,
|
|
132
|
+
daemonRestartStarted: () => "daemon 已重新啟動。",
|
|
126
133
|
doctorDaemonLine: (value) => ` daemon ${value}`,
|
|
127
134
|
doctorDaemonAlive: (pid) => `運行中(pid ${pid})`,
|
|
128
135
|
doctorDaemonMissing: (reason) => `未運行(${reason})`,
|
|
129
|
-
daemonOfflineWarning: (reason) => `⚠ worker daemon 未運行(${reason});job
|
|
136
|
+
daemonOfflineWarning: (reason) => `⚠ worker daemon 未運行(${reason});job 已排入但不會執行。請先起 dowafu serve 再核准——daemon 沒在跑時給的核准,會在它啟動時退回待核准。`,
|
|
130
137
|
mcpTicketsEmpty: () => "沒有可派的 DB 工單。",
|
|
131
138
|
mcpTicketsTitle: () => "可派工單:",
|
|
132
139
|
mcpTicketsRow: (ticket, spokeCount) => `- ${ticket}:${spokeCount} 個 spoke`,
|
|
@@ -136,6 +143,8 @@ const MESSAGES = {
|
|
|
136
143
|
mcpEnabledModelsTitle: () => "已啟用型號白名單:",
|
|
137
144
|
mcpEnabledModelRow: (provider, model) => ` ${provider}/${model}`,
|
|
138
145
|
mcpSubmitQueued: (jobId) => `已排入佇列,job id = ${jobId},狀態 pending_approval。尚未執行,須由人在終端機核准。`,
|
|
146
|
+
mcpSubmitInvalidLang: (value, available) => `dispatch_submit 的 lang 無效:${value}(可用值:${available})。未建立 job。`,
|
|
147
|
+
mcpPriorResultsReminder: (runs) => `提醒:這張工單已有 ${runs} 次結果;這不是錯誤,仍可照常排入佇列。`,
|
|
139
148
|
mcpApproveCommand: (command) => `請在終端機執行:${command}`,
|
|
140
149
|
mcpJobStarted: (at) => `開始於 ${at}`,
|
|
141
150
|
mcpJobFinished: (at) => `結束於 ${at}`,
|
|
@@ -273,8 +282,8 @@ const MESSAGES = {
|
|
|
273
282
|
observationCountCell: (display) => `觀察:${display}`,
|
|
274
283
|
cannotCountObservations: () => "無法計數",
|
|
275
284
|
cannotVerifySectionCell: (passFail) => `無法驗證欄:${passFail}`,
|
|
276
|
-
|
|
277
|
-
|
|
285
|
+
templatePlaceholdersPassCell: () => "佔位符:pass",
|
|
286
|
+
templatePlaceholdersFailCell: (count) => `佔位符:fail(內容可用,但含 ${count} 處佔位符標記,交付前請清理)`,
|
|
278
287
|
auditUnavailable: () => "(無法稽核)",
|
|
279
288
|
summaryHeader: (ticketId) => `# dispatch summary — ${ticketId}
|
|
280
289
|
|
|
@@ -344,8 +353,10 @@ const MESSAGES = {
|
|
|
344
353
|
doctorModelListValue: (enabled, total, mostExpensive) => `啟用 ${enabled}/${total};最貴啟用型號 ${mostExpensive};來源 SQLite DB`,
|
|
345
354
|
doctorModelListLoadFailedValue: (reason) => `無法載入:${reason}`,
|
|
346
355
|
doctorLensLine: (value) => ` lens 定義 ${value}`,
|
|
356
|
+
doctorQueueLine: (value) => ` 佇列 ${value}`,
|
|
357
|
+
doctorQueueValue: (pendingApproval, approved, running) => `待核准 ${pendingApproval};已核准 ${approved};執行中 ${running}`,
|
|
347
358
|
doctorLensFoundValue: (dirPath, total, closingCount, closingNames, noClosingSuffix) => `${dirPath} 找到 ${total} 個定義檔,其中 ${closingCount} 個具備固定收尾句:\n${" ".repeat(14)}${closingNames}${noClosingSuffix}`,
|
|
348
|
-
doctorLensNoClosingSuffix: (count, names) => `\n${" ".repeat(14)}
|
|
359
|
+
doctorLensNoClosingSuffix: (count, names) => `\n${" ".repeat(14)}(另有 ${count} 個不是 lens:${names})`,
|
|
349
360
|
doctorLensDirMissingValue: (dirPath) => `目錄不存在:${dirPath}`,
|
|
350
361
|
doctorFooter: () => "本指令不呼叫任何 API,不會花錢。缺的項目怎麼補,見 README 的〈API keys〉一節。",
|
|
351
362
|
},
|
|
@@ -355,6 +366,18 @@ const MESSAGES = {
|
|
|
355
366
|
dryRunNotice: () => "--dry-run: parsed, validated, estimated, and reported only; no API calls were made.",
|
|
356
367
|
helpText: (cmd) => `Usage: ${cmd} <ticket-id> [options]
|
|
357
368
|
|
|
369
|
+
Subcommands (each has its own usage message):
|
|
370
|
+
${cmd} ticket ... Create, import and inspect tickets
|
|
371
|
+
${cmd} result <id> Print the results of one dispatch (spoke output and audit table)
|
|
372
|
+
${cmd} key Set provider API keys (interactive menu)
|
|
373
|
+
${cmd} providers ... Model whitelist and enabled state
|
|
374
|
+
${cmd} token ... Issue, list and revoke inbound HTTP tokens
|
|
375
|
+
${cmd} approve [jobId] Approve a dispatch waiting in the queue
|
|
376
|
+
${cmd} serve Run the daemon (includes the MCP HTTP binding)
|
|
377
|
+
${cmd} serve --stop [--yes] Stop the daemon; asks first about running or old approved jobs
|
|
378
|
+
${cmd} serve --restart [--yes] Stop then start the daemon, running both cleanup phases
|
|
379
|
+
${cmd} mcp Serve MCP over stdio
|
|
380
|
+
|
|
358
381
|
--lang <en|zh-tw> Language for CLI output and spoke prompts, default en
|
|
359
382
|
--repo-root <dir> Root for the allowlist boundary and .claude/agents, default cwd
|
|
360
383
|
--db <path> SQLite ticket/result database, default DISPATCH_HOME/dowafu.db
|
|
@@ -375,18 +398,7 @@ const MESSAGES = {
|
|
|
375
398
|
--yes Skip the dispatch confirmation. Aborts in non-interactive environments (stdin not a TTY) unless given
|
|
376
399
|
--doctor Print the configuration self-check (no API call, no cost) and exit (exit 0)
|
|
377
400
|
--help, -h Print this help and exit (exit 0)
|
|
378
|
-
--version, -V Print the version and exit (exit 0)
|
|
379
|
-
|
|
380
|
-
Subcommands (each has its own usage message):
|
|
381
|
-
${cmd} ticket ... Create, import and inspect tickets
|
|
382
|
-
${cmd} result <id> Print the results of one dispatch (spoke output and audit table)
|
|
383
|
-
${cmd} key Set provider API keys (interactive menu)
|
|
384
|
-
${cmd} providers ... Model whitelist and enabled state
|
|
385
|
-
${cmd} token ... Issue, list and revoke inbound HTTP tokens
|
|
386
|
-
${cmd} approve [jobId] Approve a dispatch waiting in the queue
|
|
387
|
-
${cmd} serve Run the daemon (includes the MCP HTTP binding)
|
|
388
|
-
${cmd} serve --stop [--yes] Stop the daemon; asks first if jobs are running
|
|
389
|
-
${cmd} mcp Serve MCP over stdio`,
|
|
401
|
+
--version, -V Print the version and exit (exit 0)`,
|
|
390
402
|
availableLangValues: () => "en, zh-tw, zh",
|
|
391
403
|
availableValuesSuffix: (values) => ` (available: ${values})`,
|
|
392
404
|
numberFlagInvalid: (name, value) => `--${name} requires a number, got: ${value}`,
|
|
@@ -433,7 +445,8 @@ Subcommands (each has its own usage message):
|
|
|
433
445
|
mcpJobPrefixAmbiguous: (prefix, count) => `Prefix ${prefix} matches ${count} jobs; use the full id:`,
|
|
434
446
|
jobNotApprovable: (jobId, status) => `Job ${jobId} is ${status} and cannot be approved`,
|
|
435
447
|
jobApproved: (jobId) => `Approved job: ${jobId}`,
|
|
436
|
-
daemonStarted: () => "Daemon started. SIGINT exits cleanly
|
|
448
|
+
daemonStarted: () => "Daemon started. SIGINT exits cleanly.",
|
|
449
|
+
daemonStartApprovedCleaned: (count) => `${count} older approved job(s) returned to pending approval before startup.`,
|
|
437
450
|
daemonStartFailed: (detail) => `Could not start daemon: ${detail}`,
|
|
438
451
|
daemonWorkerSpawnFailed: (detail) => `Could not start worker: ${detail}`,
|
|
439
452
|
daemonWorkerExited: (exitCode) => `worker exited ${exitCode}`,
|
|
@@ -441,18 +454,23 @@ Subcommands (each has its own usage message):
|
|
|
441
454
|
daemonWorkerTerminated: (signal) => `worker terminated by ${signal}`,
|
|
442
455
|
daemonWorkerTerminatedDetail: (signal, detail) => `worker terminated by ${signal}: ${detail}`,
|
|
443
456
|
stopRequiresServe: () => "--stop can only be used with serve.",
|
|
457
|
+
stopRestartConflict: () => "--stop and --restart cannot be used together.",
|
|
444
458
|
daemonStopRunningWarning: (count) => `${count} job(s) are running; stopping the daemon does not stop their active spokes, which continue to completion and will still have their results and API costs recorded.`,
|
|
445
459
|
daemonStopConfirmPrompt: () => "Stop the daemon? [y/N] ",
|
|
460
|
+
daemonStopApprovedWarning: (count) => `${count} approved job(s) have not started; stopping returns them to pending approval so the next daemon cannot run them without a new approval.`,
|
|
461
|
+
daemonStopApprovedConfirmPrompt: () => "Clear these approved jobs? [y/N] ",
|
|
446
462
|
daemonStopCancelled: () => "Cancelled; the daemon is still running.",
|
|
447
463
|
daemonStopNotRunning: (reason) => `Daemon is not running (${reason}).`,
|
|
448
464
|
daemonStopPidNotDowafu: (pid) => `pid ${pid} is not a dowafu process; no signal was sent to avoid killing the wrong process.`,
|
|
449
|
-
daemonStopSignalFailed: (pid) => `
|
|
465
|
+
daemonStopSignalFailed: (pid) => `SIGTERM was sent to pid ${pid}, but it did not stop. Inspect it manually: ps -p ${pid} -o pid,ppid,command=; after confirming, you can send SIGTERM again. Do not use SIGKILL: worker children may still write results.`,
|
|
450
466
|
daemonStopSucceeded: (pid) => `Daemon (pid ${pid}) stopped.`,
|
|
451
467
|
daemonStopRestarted: (pid) => `Daemon (previous pid ${pid}) came back after stopping; an OS-level service manager appears to be restarting it. Use launchctl or systemctl to stop it permanently.`,
|
|
468
|
+
daemonStopApprovedCleaned: (count) => `${count} approved job(s) returned to pending approval.`,
|
|
469
|
+
daemonRestartStarted: () => "Daemon restarted.",
|
|
452
470
|
doctorDaemonLine: (value) => ` Daemon ${value}`,
|
|
453
471
|
doctorDaemonAlive: (pid) => `running (pid ${pid})`,
|
|
454
472
|
doctorDaemonMissing: (reason) => `not running (${reason})`,
|
|
455
|
-
daemonOfflineWarning: (reason) => `⚠ The worker daemon is not running (${reason}); the job is queued but will not execute. Start
|
|
473
|
+
daemonOfflineWarning: (reason) => `⚠ The worker daemon is not running (${reason}); the job is queued but will not execute. Start dowafu serve before approving — approvals given while no daemon is running are returned to pending approval when it starts.`,
|
|
456
474
|
mcpTicketsEmpty: () => "No dispatchable DB tickets.",
|
|
457
475
|
mcpTicketsTitle: () => "Dispatchable tickets:",
|
|
458
476
|
mcpTicketsRow: (ticket, spokeCount) => `- ${ticket}: ${spokeCount} spoke(s)`,
|
|
@@ -462,6 +480,8 @@ Subcommands (each has its own usage message):
|
|
|
462
480
|
mcpEnabledModelsTitle: () => "Enabled model allowlist:",
|
|
463
481
|
mcpEnabledModelRow: (provider, model) => ` ${provider}/${model}`,
|
|
464
482
|
mcpSubmitQueued: (jobId) => `Job queued: ${jobId}; status pending_approval. It has not started and needs terminal approval by a human.`,
|
|
483
|
+
mcpSubmitInvalidLang: (value, available) => `dispatch_submit lang is invalid: ${value} (available values: ${available}). No job was created.`,
|
|
484
|
+
mcpPriorResultsReminder: (runs) => `Reminder: this ticket already has ${runs} result run(s). This is not an error; it can still be queued.`,
|
|
465
485
|
mcpApproveCommand: (command) => `Run this in a terminal: ${command}`,
|
|
466
486
|
mcpJobStarted: (at) => `Started at ${at}`,
|
|
467
487
|
mcpJobFinished: (at) => `Finished at ${at}`,
|
|
@@ -606,8 +626,8 @@ Subcommands (each has its own usage message):
|
|
|
606
626
|
observationCountCell: (display) => `Observations:${display}`,
|
|
607
627
|
cannotCountObservations: () => "uncountable",
|
|
608
628
|
cannotVerifySectionCell: (passFail) => `Cannot-verify section:${passFail}`,
|
|
609
|
-
|
|
610
|
-
|
|
629
|
+
templatePlaceholdersPassCell: () => "Template placeholders:pass",
|
|
630
|
+
templatePlaceholdersFailCell: (count) => `Template placeholders:fail (content is usable, but contains ${count} placeholder marker(s); clean them before delivery)`,
|
|
611
631
|
auditUnavailable: () => "(audit unavailable)",
|
|
612
632
|
summaryHeader: (ticketId) => `# dispatch summary — ${ticketId}
|
|
613
633
|
|
|
@@ -673,8 +693,10 @@ Subcommands (each has its own usage message):
|
|
|
673
693
|
doctorModelListValue: (enabled, total, mostExpensive) => `enabled ${enabled}/${total}; most expensive enabled ${mostExpensive}; source SQLite DB`,
|
|
674
694
|
doctorModelListLoadFailedValue: (reason) => `failed to load: ${reason}`,
|
|
675
695
|
doctorLensLine: (value) => ` Lens defs ${value}`,
|
|
696
|
+
doctorQueueLine: (value) => ` Queue ${value}`,
|
|
697
|
+
doctorQueueValue: (pendingApproval, approved, running) => `pending approval ${pendingApproval}; approved ${approved}; running ${running}`,
|
|
676
698
|
doctorLensFoundValue: (dirPath, total, closingCount, closingNames, noClosingSuffix) => `${dirPath} holds ${total} definition file(s), ${closingCount} with a fixed closing line:\n${" ".repeat(15)}${closingNames}${noClosingSuffix}`,
|
|
677
|
-
doctorLensNoClosingSuffix: (count, names) => `\n${" ".repeat(15)}(${count}
|
|
699
|
+
doctorLensNoClosingSuffix: (count, names) => `\n${" ".repeat(15)}(${count} other agent file(s), not lenses: ${names})`,
|
|
678
700
|
doctorLensDirMissingValue: (dirPath) => `directory not found: ${dirPath}`,
|
|
679
701
|
doctorFooter: () => 'This command calls no API and costs nothing. To fill in what is missing, see "API keys" in the README.',
|
|
680
702
|
},
|
package/dist/output.js
CHANGED
|
@@ -23,12 +23,12 @@ function formatFinishReasonCell(r, lang) {
|
|
|
23
23
|
return null;
|
|
24
24
|
return m(lang, "finishReasonWarningCell", r.finishReason ?? "unknown");
|
|
25
25
|
}
|
|
26
|
-
// 工單
|
|
27
|
-
//
|
|
26
|
+
// 工單 report-format §三 B:污染標記只改稽核欄的 pass/fail 語義,絕不改 SpokeRunResult
|
|
27
|
+
// (其 status 仍由執行器/job 狀態機決定)。失敗格給可操作的總命中數,細節仍在 JSON audit。
|
|
28
28
|
function formatPlaceholdersCell(hits, lang) {
|
|
29
29
|
if (hits.length === 0)
|
|
30
|
-
return m(lang, "
|
|
31
|
-
return
|
|
30
|
+
return m(lang, "templatePlaceholdersPassCell");
|
|
31
|
+
return m(lang, "templatePlaceholdersFailCell", hits.reduce((total, hit) => total + hit.count, 0));
|
|
32
32
|
}
|
|
33
33
|
export function buildSummaryMarkdown(ticketId, results, audits, toolCallAudits, lang) {
|
|
34
34
|
const rows = results.map((r) => {
|
|
@@ -54,7 +54,7 @@ export function buildSummaryMarkdown(ticketId, results, audits, toolCallAudits,
|
|
|
54
54
|
if (a) {
|
|
55
55
|
cells.push(m(lang, "closingLineCell", a.finalLinePass ? "pass" : "fail"),
|
|
56
56
|
// v1.9 §15:null(數不出來)與 0(明確為零)須可區分,不得混印
|
|
57
|
-
m(lang, "observationCountCell", a.observationCount !== null ? String(a.observationCount) : m(lang, "cannotCountObservations")), m(lang, "cannotVerifySectionCell", a.cannotVerifySectionPresent ? "pass" : "fail"),
|
|
57
|
+
m(lang, "observationCountCell", a.observationCount !== null ? String(a.observationCount) : m(lang, "cannotCountObservations")), m(lang, "cannotVerifySectionCell", a.cannotVerifySectionPresent ? "pass" : "fail"), formatPlaceholdersCell(a.templatePlaceholdersFound, lang));
|
|
58
58
|
}
|
|
59
59
|
const auditCell = cells.length > 0 ? cells.join(" / ") : m(lang, "auditUnavailable");
|
|
60
60
|
// plan_fixes_v1.0.md §4:無價目資料須與「估出來是 $0」區分,不能印成空白或 0——
|
package/dist/prompt.js
CHANGED
|
@@ -8,9 +8,10 @@ import path from "node:path";
|
|
|
8
8
|
// 結束,因為稽核靠那句話判斷回報有沒有寫完。翻譯時刻意保留祈使句與「原文是主要依據、
|
|
9
9
|
// 行號是輔助」這兩處措辭:實測顯示 spoke 對句型敏感,描述句會被當成建議略過。
|
|
10
10
|
const REPORT_TEMPLATE_EN = `# Observations
|
|
11
|
-
1.
|
|
11
|
+
1. Example: a retry after a timeout can repeat a write operation.
|
|
12
12
|
Evidence: <file:line, or explicit reasoning>
|
|
13
13
|
Quote: <when citing a file, copy that one line verbatim; write "reasoning" when the evidence is reasoning>
|
|
14
|
+
—— follow this shape for every observation; replace every angle-bracket marker with actual content and do not leave any behind.
|
|
14
15
|
—— when citing a file, the path must match the string in "Allowed reads" character for character; do not abbreviate it or write the filename alone.
|
|
15
16
|
—— **the quote is the primary evidence, the line number is secondary**: the hub locates the
|
|
16
17
|
real position by matching the quote, so quote verbatim. Copy only the line you are sure
|
|
@@ -21,9 +22,10 @@ const REPORT_TEMPLATE_EN = `# Observations
|
|
|
21
22
|
|
|
22
23
|
These are observations and questions. Whether to adopt them is for the hub and the user to decide.`;
|
|
23
24
|
const REPORT_TEMPLATE = `# 觀察
|
|
24
|
-
1.
|
|
25
|
+
1. 範例:逾時後的重試作業可能重複執行一次寫入。
|
|
25
26
|
依據:<檔案:行號 或 明確推理>
|
|
26
27
|
原文:<引用檔案時,逐字複製該處的一行原文;依據為推理時寫「推理」>
|
|
28
|
+
——每條觀察均依此形狀撰寫;所有角括號標記都要換成實際內容,不要保留。
|
|
27
29
|
——引用檔案時,路徑須與「允許讀取」清單中的字串逐字相同,不得縮寫或只寫檔名。
|
|
28
30
|
——**原文是主要依據,行號是輔助**:hub 會用原文比對出實際位置,所以原文必須逐字,
|
|
29
31
|
寧可只複製確定的那一行,也不要憑印象重寫。
|
|
@@ -32,9 +34,9 @@ const REPORT_TEMPLATE = `# 觀察
|
|
|
32
34
|
- <需要但讀不到的檔案,或清單不足之處>;沒有則寫「無」
|
|
33
35
|
|
|
34
36
|
以上為觀察與問題,採用與否由 hub 與使用者裁決。`;
|
|
35
|
-
// 工單
|
|
36
|
-
//
|
|
37
|
-
//
|
|
37
|
+
// 工單 report-format §二:這是「已知污染標記」清單,不再與模板雙向同源。模板現有的尖括號
|
|
38
|
+
// 標記仍須收錄(prompt.test.ts 防漏),而已移除的 <觀察>/<observation> 也必須保留:真實
|
|
39
|
+
// spoke 曾把它們原樣帶進交付物。清單從這裡匯出,audit.ts 不得重打一份字串。
|
|
38
40
|
export const REPORT_PLACEHOLDERS = [
|
|
39
41
|
"<觀察>",
|
|
40
42
|
"<檔案:行號 或 明確推理>",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dowafu",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Send a section of your design doc to external LLMs for review. They read only the files you whitelist, and nothing is billed until you confirm.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"llm",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"type": "module",
|
|
27
27
|
"engines": {
|
|
28
|
-
"node": ">=
|
|
28
|
+
"node": ">=22.13.0"
|
|
29
29
|
},
|
|
30
30
|
"bin": {
|
|
31
31
|
"dowafu": "dist/cli.js"
|