dowafu 0.4.0 → 0.5.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 +236 -66
- package/README_zh-tw.md +216 -66
- package/dist/adapters/anthropic-messages.js +5 -3
- package/dist/adapters/gemini-native.js +13 -5
- package/dist/adapters/responses.js +5 -1
- package/dist/api-token.js +54 -0
- package/dist/cli-args.js +45 -3
- package/dist/cli.js +680 -101
- package/dist/cost.js +24 -0
- package/dist/daemon.js +209 -0
- package/dist/db.js +219 -0
- package/dist/dispatch-home.js +8 -19
- package/dist/doctor.js +105 -45
- package/dist/gate.js +9 -4
- package/dist/job.js +97 -0
- package/dist/liveness.js +39 -0
- package/dist/mcp/http.js +178 -0
- package/dist/mcp/protocol.js +308 -0
- package/dist/mcp/stdio.js +36 -0
- package/dist/messages.js +338 -52
- package/dist/output.js +91 -104
- package/dist/progress.js +36 -0
- package/dist/provider-key.js +84 -0
- package/dist/providers-store.js +165 -0
- package/dist/providers.js +18 -6
- package/dist/report.js +38 -14
- package/dist/runner.js +18 -0
- package/dist/ticket-store.js +195 -0
- package/dist/ticket.js +22 -1
- package/dist/validate.js +58 -21
- package/package.json +1 -2
- package/providers.json +12 -10
package/dist/cli.js
CHANGED
|
@@ -4,14 +4,18 @@
|
|
|
4
4
|
// 皆在任何 API 呼叫之前。引數解析、說明文字、事件格式化拆到 cli-args.ts(純函式,
|
|
5
5
|
// 供單元測試);此檔只負責串接與 I/O。
|
|
6
6
|
import path from "node:path";
|
|
7
|
+
import { readFile } from "node:fs/promises";
|
|
8
|
+
import { realpathSync } from "node:fs";
|
|
7
9
|
import { createInterface } from "node:readline/promises";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
8
11
|
import { DispatchError } from "./types.js";
|
|
12
|
+
import { loadProviders } from "./providers.js";
|
|
9
13
|
import { loadTicket } from "./ticket.js";
|
|
10
|
-
import { loadProviders, PROVIDERS_FORMAT_VERSION } from "./providers.js";
|
|
11
14
|
import { resolveSpokes } from "./validate.js";
|
|
12
15
|
import { estimateTokens, estimateAllowlistTokens, estimateSequentialRead, checkGateOne } from "./gate.js";
|
|
13
16
|
import { buildFirstUserText, buildSystemPrompt } from "./prompt.js";
|
|
14
17
|
import { buildReport, effectiveCap, effectiveCharsPerToken } from "./report.js";
|
|
18
|
+
import { checkGitignore } from "./gitignore-check.js";
|
|
15
19
|
import { runSpoke } from "./runner.js";
|
|
16
20
|
import { Semaphore } from "./semaphore.js";
|
|
17
21
|
import { createResponsesAdapter } from "./adapters/responses.js";
|
|
@@ -19,48 +23,267 @@ import { createGeminiAdapter } from "./adapters/gemini-native.js";
|
|
|
19
23
|
import { createAnthropicAdapter } from "./adapters/anthropic-messages.js";
|
|
20
24
|
import { auditSpoke } from "./audit.js";
|
|
21
25
|
import { auditToolCalls } from "./tool-call-audit.js";
|
|
22
|
-
import {
|
|
26
|
+
import { persistSpokeResultDb, readResults, writeSummaryDb } from "./output.js";
|
|
27
|
+
import { recordRound } from "./progress.js";
|
|
23
28
|
import { registerSecrets, maskString, maskDeep } from "./mask.js";
|
|
24
|
-
import {
|
|
25
|
-
import { resolveDispatchHome, loadDispatchEnv } from "./dispatch-home.js";
|
|
26
|
-
import { buildDoctorReport } from "./doctor.js";
|
|
29
|
+
import { buildDoctorReportData, renderDoctorReport } from "./doctor.js";
|
|
27
30
|
import { bundledProvidersPath, getPackageVersion, getCommandName } from "./pkg-info.js";
|
|
28
|
-
import { checkGitignore } from "./gitignore-check.js";
|
|
29
31
|
import { buildJsonPayload, buildJsonPlan } from "./json-output.js";
|
|
30
32
|
import { parseArgs, formatEvent, resolveLang, resolveFallbackLang, buildStdoutSummary } from "./cli-args.js";
|
|
31
33
|
import { m } from "./messages.js";
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
34
|
+
import { defaultDbPath, openDb } from "./db.js";
|
|
35
|
+
import { issueToken, listTokens, revokeToken } from "./api-token.js";
|
|
36
|
+
import { addAllow, addSpoke, createTicket, formatTicket, hasResults, importTicketDirectory } from "./ticket-store.js";
|
|
37
|
+
import { approveJob, findJobsByIdPrefix, getJob, listPendingApprovalJobs } from "./job.js";
|
|
38
|
+
import { serve, stopDaemon } from "./daemon.js";
|
|
39
|
+
import { daemonAlive, daemonStatePath } from "./liveness.js";
|
|
40
|
+
import { startStdioServer } from "./mcp/stdio.js";
|
|
41
|
+
import { isModelEnabled, listModels, mergeProviders, readProvidersFile, seedIfEmpty, setModelEnabled } from "./providers-store.js";
|
|
42
|
+
import { buildKeyStatusRows, getProviderKey, listKnownProviderNames, listProviderKeys, maskTail, removeProviderKey, recordProviderKeyTest, setProviderKey, } from "./provider-key.js";
|
|
43
|
+
// §29 規格十:窮盡式分派,不留「其餘落到 gemini」的 fallback——那會讓未來第四家 provider
|
|
44
|
+
// 靜默走錯 adapter。switch 缺 case 時 TypeScript 因「不是每條路徑都回傳值」編譯失敗。
|
|
45
|
+
function buildAdapterForConfig(providerConfig, apiKey, lang) {
|
|
46
|
+
switch (providerConfig.api) {
|
|
42
47
|
case "responses":
|
|
43
48
|
return createResponsesAdapter({
|
|
44
|
-
baseURL:
|
|
49
|
+
baseURL: providerConfig.baseURL,
|
|
45
50
|
apiKey,
|
|
46
|
-
store:
|
|
47
|
-
reasoning:
|
|
48
|
-
lang
|
|
51
|
+
store: providerConfig.store,
|
|
52
|
+
reasoning: providerConfig.reasoning,
|
|
53
|
+
lang,
|
|
49
54
|
});
|
|
50
55
|
case "gemini-native":
|
|
51
|
-
return createGeminiAdapter({ apiKey, baseURL:
|
|
56
|
+
return createGeminiAdapter({ apiKey, baseURL: providerConfig.baseURL, lang });
|
|
52
57
|
case "anthropic-messages":
|
|
53
58
|
return createAnthropicAdapter({
|
|
54
|
-
baseURL:
|
|
59
|
+
baseURL: providerConfig.baseURL,
|
|
55
60
|
apiKey,
|
|
56
|
-
reasoning:
|
|
57
|
-
lang
|
|
61
|
+
reasoning: providerConfig.reasoning,
|
|
62
|
+
lang,
|
|
58
63
|
});
|
|
59
64
|
}
|
|
60
65
|
}
|
|
66
|
+
// 工單 key-db §B:API key 的唯一來源是 DB 的 `provider_keys` 表(0.5.0 起不讀環境變數,
|
|
67
|
+
// 使用者裁示 2026-09-06,理由見 provider-key.ts 檔頭)。resolveSpokes 更早的那道齊備檢查
|
|
68
|
+
// 現在收 hasApiKey 回呼、問的是同一張表,兩處不會分岔。
|
|
69
|
+
function createAdapterFor(spoke, db) {
|
|
70
|
+
const apiKey = getProviderKey(db, spoke.provider)?.apiKey;
|
|
71
|
+
if (!apiKey) {
|
|
72
|
+
// resolveSpokes 已檢查過,此處為型別窄化防禦。用 spoke.lang 而非 langRaw/options.lang——
|
|
73
|
+
// 它是 ResolvedSpoke 上已經過 resolveLang 判定的權威值,與 run-level lang 同一個來源。
|
|
74
|
+
throw new DispatchError(m(spoke.lang, "apiKeyMissing", spoke.provider), 2);
|
|
75
|
+
}
|
|
76
|
+
return buildAdapterForConfig(spoke.providerConfig, apiKey, spoke.lang);
|
|
77
|
+
}
|
|
61
78
|
function makeLogger(json) {
|
|
62
79
|
return { info: (msg) => (json ? console.error(msg) : console.log(msg)) };
|
|
63
80
|
}
|
|
81
|
+
// 熱修補(票 B 驗收):候選清單要印幾碼才夠分辨。最少 8 碼(維持原本的簡短),
|
|
82
|
+
// 但若在候選之間仍有重複就一路加長到能區分為止;全等時退回完整 id。
|
|
83
|
+
function shortestDistinctIdWidth(ids) {
|
|
84
|
+
const longest = Math.max(0, ...ids.map((id) => id.length));
|
|
85
|
+
for (let width = 8; width < longest; width++) {
|
|
86
|
+
if (new Set(ids.map((id) => id.slice(0, width))).size === ids.length)
|
|
87
|
+
return width;
|
|
88
|
+
}
|
|
89
|
+
return longest;
|
|
90
|
+
}
|
|
91
|
+
// 熱修補(票 A 驗收缺口二):所有經 CLI 開啟的 DB 都在空表時播種一次出貨檔。
|
|
92
|
+
// 通知走 **stderr**——stdout 要留給 --json 與 MCP 的 JSON-RPC,多印一行會壞掉契約。
|
|
93
|
+
//
|
|
94
|
+
// 工單 key-db §D:這是唯一開 DB 的地方,所以也是唯一需要重新註冊遮罩名單的地方——
|
|
95
|
+
// DB 裡存著的每一把 provider key,不管這次會不會被用到,全部餵進 registerSecrets。
|
|
96
|
+
// registerSecrets 是覆寫不是累加(mask.ts 的陷阱,見票 §D 引用的 v0_5_0/_archived/log.md §十四),
|
|
97
|
+
// 這裡若只餵 DB 那份會蓋掉 main() 頂部已經註冊過的 env 來源,因此兩份要一起餵。
|
|
98
|
+
function openDbSeeded(dbPath, lang) {
|
|
99
|
+
const db = openDb(dbPath, lang);
|
|
100
|
+
if (seedIfEmpty(db, lang))
|
|
101
|
+
console.error(m(lang, "providersSeeded", bundledProvidersPath()));
|
|
102
|
+
registerSecrets(listProviderKeys(db).map((key) => key.apiKey));
|
|
103
|
+
return db;
|
|
104
|
+
}
|
|
105
|
+
// 工單 status-stdin §B:使用者裁示(2026-09-07)——D1 把工單本體移進 DB 之後,
|
|
106
|
+
// `ticket-store.ts` 的 createTicket/addSpoke 本來就吃字串,問題只在這裡(CLI)先把
|
|
107
|
+
// 內容寫成暫存檔、CLI 再讀那個檔,等於繞了一圈。`-` 代表從 stdin 讀本文,與路徑寫法
|
|
108
|
+
// 共用同一個位置(`<shared-file>`/`<body-file>`),add-spoke 選用的 `[effort]` 因此
|
|
109
|
+
// 不受影響,仍然在同一個位置解析。
|
|
110
|
+
//
|
|
111
|
+
// 匯出是為了讓「stdin 是 TTY」這個守衛能被直接單元測試:真的 TTY 在自動化測試裡無法
|
|
112
|
+
// 可靠模擬(沒有 pty),而 cli.ts 這個模組本身在被 import 的當下就會執行 main(),
|
|
113
|
+
// 不能拿來 import 後再呼叫——所以守衛本身必須是一個不依賴模組載入副作用、可以單獨
|
|
114
|
+
// 呼叫驗證的純函式。
|
|
115
|
+
export function assertStdinPiped(isTTY, lang) {
|
|
116
|
+
if (isTTY)
|
|
117
|
+
throw new DispatchError(m(lang, "ticketStdinRequiresPipe"), 2);
|
|
118
|
+
}
|
|
119
|
+
async function readBodyOrStdin(pathOrDash, lang) {
|
|
120
|
+
if (pathOrDash !== "-")
|
|
121
|
+
return readFile(pathOrDash, "utf8");
|
|
122
|
+
// 先檢查再讀:TTY 情況下 for-await 會掛住等鍵盤輸入,必須在那之前就中止(不卡住)。
|
|
123
|
+
assertStdinPiped(process.stdin.isTTY, lang);
|
|
124
|
+
const chunks = [];
|
|
125
|
+
for await (const chunk of process.stdin)
|
|
126
|
+
chunks.push(chunk);
|
|
127
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
128
|
+
}
|
|
129
|
+
async function ticketCommand(argv, lang) {
|
|
130
|
+
const command = getCommandName();
|
|
131
|
+
const args = [...argv];
|
|
132
|
+
let dbPath;
|
|
133
|
+
let repoRoot = process.cwd();
|
|
134
|
+
for (let i = 0; i < args.length; i++) {
|
|
135
|
+
if (args[i] === "--db") {
|
|
136
|
+
dbPath = args[i + 1];
|
|
137
|
+
args.splice(i, 2);
|
|
138
|
+
i--;
|
|
139
|
+
}
|
|
140
|
+
else if (args[i] === "--repo-root") {
|
|
141
|
+
repoRoot = path.resolve(args[i + 1] ?? "");
|
|
142
|
+
args.splice(i, 2);
|
|
143
|
+
i--;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const [action, ...rest] = args;
|
|
147
|
+
if (!action || action === "--help" || action === "-h") {
|
|
148
|
+
console.log(m(lang, "ticketHelp", command));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const db = openDbSeeded(dbPath, lang);
|
|
152
|
+
switch (action) {
|
|
153
|
+
case "create": {
|
|
154
|
+
const [name, sharedPath] = rest;
|
|
155
|
+
if (!name || !sharedPath)
|
|
156
|
+
throw new DispatchError(m(lang, "ticketHelp", command), 2);
|
|
157
|
+
createTicket(db, name, await readBodyOrStdin(sharedPath, lang), lang);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
case "add-spoke": {
|
|
161
|
+
const [name, agent, provider, model, bodyPath, effort] = rest;
|
|
162
|
+
if (!name || !agent || !provider || !model || !bodyPath)
|
|
163
|
+
throw new DispatchError(m(lang, "ticketHelp", command), 2);
|
|
164
|
+
addSpoke(db, name, { agent, provider, model, body: await readBodyOrStdin(bodyPath, lang), effort }, lang);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
case "add-allow": {
|
|
168
|
+
const [name, agent, rel] = rest;
|
|
169
|
+
if (!name || !agent || !rel)
|
|
170
|
+
throw new DispatchError(m(lang, "ticketHelp", command), 2);
|
|
171
|
+
await addAllow(db, name, agent, rel, repoRoot, lang);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
case "import": {
|
|
175
|
+
const [dir, suppliedName] = rest;
|
|
176
|
+
if (!dir)
|
|
177
|
+
throw new DispatchError(m(lang, "ticketHelp", command), 2);
|
|
178
|
+
await importTicketDirectory(db, dir, suppliedName ?? path.basename(path.resolve(dir)), repoRoot, lang);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
case "show": {
|
|
182
|
+
const [name] = rest;
|
|
183
|
+
if (!name)
|
|
184
|
+
throw new DispatchError(m(lang, "ticketHelp", command), 2);
|
|
185
|
+
console.log(formatTicket(db, name, lang));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
default:
|
|
189
|
+
throw new DispatchError(m(lang, "ticketHelp", command), 2);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
async function providersCommand(argv, lang) {
|
|
193
|
+
const args = [...argv];
|
|
194
|
+
let dbPath;
|
|
195
|
+
for (let i = 0; i < args.length; i++) {
|
|
196
|
+
if (args[i] === "--db") {
|
|
197
|
+
dbPath = args[i + 1];
|
|
198
|
+
args.splice(i, 2);
|
|
199
|
+
i--;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const [action, ...rest] = args;
|
|
203
|
+
const command = getCommandName();
|
|
204
|
+
if (!action || action === "--help" || action === "-h") {
|
|
205
|
+
console.log(m(lang, "providersHelp", command));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const db = openDbSeeded(dbPath, lang);
|
|
209
|
+
if (action === "list") {
|
|
210
|
+
const models = listModels(db);
|
|
211
|
+
if (models.length === 0)
|
|
212
|
+
console.log(m(lang, "providersListEmpty"));
|
|
213
|
+
for (const model of models)
|
|
214
|
+
console.log(m(lang, "providersListRow", model.provider, model.model, model.inputPerM?.toString() ?? "—", model.outputPerM?.toString() ?? "—", model.enabled));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (action === "import") {
|
|
218
|
+
const [filePath] = rest;
|
|
219
|
+
if (!filePath)
|
|
220
|
+
throw new DispatchError(m(lang, "providersHelp", command), 2);
|
|
221
|
+
mergeProviders(db, await readProvidersFile(path.resolve(filePath), lang), path.resolve(filePath) === bundledProvidersPath());
|
|
222
|
+
console.log(m(lang, "providersImported", filePath));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (action === "enable" || action === "disable") {
|
|
226
|
+
const [first, second, third] = rest;
|
|
227
|
+
if (!first || third !== undefined)
|
|
228
|
+
throw new DispatchError(m(lang, "providersHelp", command), 2);
|
|
229
|
+
setModelEnabled(db, first, second, action === "enable", lang);
|
|
230
|
+
console.log(m(lang, action === "enable" ? "providersEnabled" : "providersDisabled", second ?? first));
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
throw new DispatchError(m(lang, "providersHelp", command), 2);
|
|
234
|
+
}
|
|
235
|
+
// 工單 C §B-2:入站 HTTP 憑證的發/驗/撤銷 CLI 面積。與 providers/ticket 子指令同一種
|
|
236
|
+
// 「--db 先剝離、其餘走一個小 switch」形狀。
|
|
237
|
+
async function tokenCommand(argv, lang) {
|
|
238
|
+
const command = getCommandName();
|
|
239
|
+
const args = [...argv];
|
|
240
|
+
let dbPath;
|
|
241
|
+
for (let i = 0; i < args.length; i++) {
|
|
242
|
+
if (args[i] === "--db") {
|
|
243
|
+
dbPath = args[i + 1];
|
|
244
|
+
args.splice(i, 2);
|
|
245
|
+
i--;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const [action, ...rest] = args;
|
|
249
|
+
if (!action || action === "--help" || action === "-h") {
|
|
250
|
+
console.log(m(lang, "tokenHelp", command));
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const db = openDbSeeded(dbPath, lang);
|
|
254
|
+
switch (action) {
|
|
255
|
+
case "issue": {
|
|
256
|
+
let label;
|
|
257
|
+
for (let i = 0; i < rest.length; i++) {
|
|
258
|
+
if (rest[i] === "--label")
|
|
259
|
+
label = rest[i + 1];
|
|
260
|
+
}
|
|
261
|
+
const { id, plaintext } = issueToken(db, label);
|
|
262
|
+
console.log(m(lang, "tokenIssued", id, plaintext));
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
case "list": {
|
|
266
|
+
const tokens = listTokens(db);
|
|
267
|
+
if (tokens.length === 0) {
|
|
268
|
+
console.log(m(lang, "tokenListEmpty"));
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
for (const t of tokens)
|
|
272
|
+
console.log(m(lang, "tokenListRow", t.id, t.label ?? "—", t.created_at, t.revoked_at !== null));
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
case "revoke": {
|
|
276
|
+
const [id] = rest;
|
|
277
|
+
if (!id)
|
|
278
|
+
throw new DispatchError(m(lang, "tokenHelp", command), 2);
|
|
279
|
+
revokeToken(db, id, lang);
|
|
280
|
+
console.log(m(lang, "tokenRevoked", id));
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
default:
|
|
284
|
+
throw new DispatchError(m(lang, "tokenHelp", command), 2);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
64
287
|
async function confirm(message, output) {
|
|
65
288
|
// 非互動環境(agent 經 shell 呼叫時 stdin 不是 TTY)沒有人能回答這個提示。
|
|
66
289
|
// fail closed:先前這裡 return true,等於唯一的付費閘門是操作文件裡的一句話;
|
|
@@ -72,20 +295,304 @@ async function confirm(message, output) {
|
|
|
72
295
|
rl.close();
|
|
73
296
|
return answer.trim().toLowerCase() === "y";
|
|
74
297
|
}
|
|
298
|
+
// 工單 key-db §C-1 之1:貼上時不回顯,但這是唯一需要逐字元收字的地方——readline/promises
|
|
299
|
+
// 的 question() 沒有隱藏輸入的選項。用 raw mode 自行收字元,僅在 Enter 時換行,不逐字回顯;
|
|
300
|
+
// Backspace 可修正,Ctrl+C 直接離開(不留在 raw mode)。
|
|
301
|
+
async function readSecret(prompt) {
|
|
302
|
+
process.stdout.write(prompt);
|
|
303
|
+
return new Promise((resolve) => {
|
|
304
|
+
const stdin = process.stdin;
|
|
305
|
+
const wasRaw = stdin.isRaw;
|
|
306
|
+
stdin.setRawMode?.(true);
|
|
307
|
+
stdin.resume();
|
|
308
|
+
stdin.setEncoding("utf8");
|
|
309
|
+
let value = "";
|
|
310
|
+
const cleanup = () => {
|
|
311
|
+
stdin.removeListener("data", onData);
|
|
312
|
+
stdin.setRawMode?.(wasRaw ?? false);
|
|
313
|
+
stdin.pause();
|
|
314
|
+
};
|
|
315
|
+
const onData = (chunk) => {
|
|
316
|
+
for (const char of chunk) {
|
|
317
|
+
if (char === "\n" || char === "\r") {
|
|
318
|
+
cleanup();
|
|
319
|
+
process.stdout.write("\n");
|
|
320
|
+
resolve(value);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (char === "\u0003") {
|
|
324
|
+
// Ctrl+C:離開前恢復終端機模式,不留在 raw mode。
|
|
325
|
+
cleanup();
|
|
326
|
+
process.stdout.write("\n");
|
|
327
|
+
process.exit(130);
|
|
328
|
+
}
|
|
329
|
+
if (char === "\u007f" || char === "\b") {
|
|
330
|
+
value = value.slice(0, -1);
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
value += char;
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
stdin.on("data", onData);
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
function assertKnownProvider(db, provider, lang) {
|
|
340
|
+
const known = listKnownProviderNames(db);
|
|
341
|
+
if (!known.includes(provider)) {
|
|
342
|
+
throw new DispatchError(m(lang, "keyUnknownProvider", provider, known.join(", ")), 2);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
// `dowafu key`(互動選單)、`key list` 共用的狀態字串。0.5.0 起 DB 是唯一來源,
|
|
346
|
+
// 所以「有沒有設定」就等於 maskedTail 是不是 null——沒有來源欄位要顯示。
|
|
347
|
+
function keyStatusText(lang, row) {
|
|
348
|
+
if (row.maskedTail === null)
|
|
349
|
+
return m(lang, "keyStatusMissing");
|
|
350
|
+
const configured = m(lang, "keyStatusDb", row.maskedTail, row.updatedAt ? row.updatedAt.slice(0, 10) : "");
|
|
351
|
+
if (row.lastTestSucceeded === null || row.lastTestedAt === null || row.lastTestModel === null)
|
|
352
|
+
return configured + m(lang, "keyStatusUntested");
|
|
353
|
+
return configured + m(lang, row.lastTestSucceeded ? "keyStatusTestSucceeded" : "keyStatusTestFailed", row.lastTestModel, row.lastTestedAt.slice(0, 10));
|
|
354
|
+
}
|
|
355
|
+
// key-visibility §B-0:把付費邊界抽成可注入 sender。此函式不做 console I/O,測試可用假
|
|
356
|
+
// sender 同時覆核成功與失敗寫回 DB;正式呼叫仍由 runKeyTest 保留原本的確認與輸出責任。
|
|
357
|
+
export async function executeKeyTest(db, provider, lang, send) {
|
|
358
|
+
const stored = getProviderKey(db, provider);
|
|
359
|
+
if (!stored)
|
|
360
|
+
throw new DispatchError(m(lang, "keyTestKeyMissing", provider), 2);
|
|
361
|
+
const providers = loadProviders(db, lang);
|
|
362
|
+
const providerConfig = providers[provider];
|
|
363
|
+
if (!providerConfig)
|
|
364
|
+
throw new DispatchError(m(lang, "keyUnknownProvider", provider, Object.keys(providers).join(", ")), 2);
|
|
365
|
+
const candidates = listModels(db).filter((model) => model.provider === provider && model.enabled);
|
|
366
|
+
if (candidates.length === 0)
|
|
367
|
+
throw new DispatchError(m(lang, "keyTestNoEnabledModel", provider), 2);
|
|
368
|
+
const model = candidates[0].model;
|
|
369
|
+
try {
|
|
370
|
+
await send({ providerConfig, apiKey: stored.apiKey, model, lang });
|
|
371
|
+
recordProviderKeyTest(db, provider, model, true);
|
|
372
|
+
return { model, succeeded: true };
|
|
373
|
+
}
|
|
374
|
+
catch (err) {
|
|
375
|
+
const detail = maskString(String(err));
|
|
376
|
+
recordProviderKeyTest(db, provider, model, false);
|
|
377
|
+
return { model, succeeded: false, detail };
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
async function runKeyTest(db, provider, lang) {
|
|
381
|
+
console.log(m(lang, "keyTestCostWarning", provider));
|
|
382
|
+
const ok = await confirm(m(lang, "keyTestConfirmPrompt"), process.stdout);
|
|
383
|
+
if (!ok) {
|
|
384
|
+
console.log(m(lang, "keyTestCancelled"));
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
const outcome = await executeKeyTest(db, provider, lang, async ({ providerConfig, apiKey, model, lang: testLang }) => {
|
|
388
|
+
const adapter = buildAdapterForConfig(providerConfig, apiKey, testLang);
|
|
389
|
+
await adapter.send({ systemPrompt: "", turns: [{ role: "user", text: "ping" }] }, { model, effort: providerConfig.reasoning.default, enableTools: false });
|
|
390
|
+
});
|
|
391
|
+
if (outcome.succeeded) {
|
|
392
|
+
console.log(m(lang, "keyTestSucceeded", provider, outcome.model));
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
throw new DispatchError(m(lang, "keyTestFailed", provider, outcome.detail), 2);
|
|
396
|
+
}
|
|
397
|
+
// 工單 key-db §C:`dowafu key`(免參數,互動)/`key list`/`key rm`/`key test`。
|
|
398
|
+
// 裁示 1/3:不吃命令列參數、不留非互動的寫入路徑——互動選單在 stdin 不是 TTY 時必須
|
|
399
|
+
// 明確拒絕,不得掛住等輸入、不得讀管道內容。`list`/`rm`/`test` 不受此限(C-3)。
|
|
400
|
+
async function keyCommand(argv, lang) {
|
|
401
|
+
const command = getCommandName();
|
|
402
|
+
const args = [...argv];
|
|
403
|
+
let dbPath;
|
|
404
|
+
for (let i = 0; i < args.length; i++) {
|
|
405
|
+
if (args[i] === "--db") {
|
|
406
|
+
dbPath = args[i + 1];
|
|
407
|
+
args.splice(i, 2);
|
|
408
|
+
i--;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
const [action, ...rest] = args;
|
|
412
|
+
if (action === "--help" || action === "-h") {
|
|
413
|
+
console.log(m(lang, "keyHelp", command));
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
const db = openDbSeeded(dbPath, lang);
|
|
417
|
+
if (action === "list") {
|
|
418
|
+
const rows = buildKeyStatusRows(db);
|
|
419
|
+
for (const row of rows)
|
|
420
|
+
console.log(m(lang, "keyListRow", row.provider, keyStatusText(lang, row)));
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (action === "rm") {
|
|
424
|
+
const [provider] = rest;
|
|
425
|
+
if (!provider)
|
|
426
|
+
throw new DispatchError(m(lang, "keyHelp", command), 2);
|
|
427
|
+
assertKnownProvider(db, provider, lang);
|
|
428
|
+
const removed = removeProviderKey(db, provider);
|
|
429
|
+
console.log(m(lang, removed ? "keyRemoved" : "keyRemovedNotFound", provider));
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (action === "test") {
|
|
433
|
+
const [provider] = rest;
|
|
434
|
+
if (!provider)
|
|
435
|
+
throw new DispatchError(m(lang, "keyTestHelp", command), 2);
|
|
436
|
+
assertKnownProvider(db, provider, lang);
|
|
437
|
+
await runKeyTest(db, provider, lang);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (action !== undefined)
|
|
441
|
+
throw new DispatchError(m(lang, "keyHelp", command), 2);
|
|
442
|
+
// 免參數:互動選單(裁示 1:一個指令 + 選單選 provider + 貼上;裁示 3:不留非互動路徑)。
|
|
443
|
+
if (!process.stdin.isTTY) {
|
|
444
|
+
throw new DispatchError(m(lang, "keyInteractiveRequiresTty"), 2);
|
|
445
|
+
}
|
|
446
|
+
const rows = buildKeyStatusRows(db);
|
|
447
|
+
console.log(m(lang, "keyMenuHeader"));
|
|
448
|
+
const width = Math.max(...rows.map((row) => row.provider.length));
|
|
449
|
+
rows.forEach((row, i) => {
|
|
450
|
+
console.log(m(lang, "keyMenuRow", i + 1, row.provider.padEnd(width), keyStatusText(lang, row)));
|
|
451
|
+
});
|
|
452
|
+
const menuRl = createInterface({ input: process.stdin, output: process.stdout });
|
|
453
|
+
const choiceRaw = (await menuRl.question(m(lang, "keyMenuPrompt", rows.length))).trim();
|
|
454
|
+
menuRl.close();
|
|
455
|
+
if (choiceRaw.toLowerCase() === "q") {
|
|
456
|
+
console.log(m(lang, "keyMenuQuit"));
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
const choice = Number(choiceRaw);
|
|
460
|
+
if (!Number.isInteger(choice) || choice < 1 || choice > rows.length) {
|
|
461
|
+
throw new DispatchError(m(lang, "keyMenuInvalidChoice"), 2);
|
|
462
|
+
}
|
|
463
|
+
const provider = rows[choice - 1].provider;
|
|
464
|
+
// C-1 要求 1:前後空白要 trim——貼上最常見的失敗是帶了尾隨空白或只貼到一半。
|
|
465
|
+
const secret = (await readSecret(m(lang, "keyPastePrompt", provider))).trim();
|
|
466
|
+
if (!secret) {
|
|
467
|
+
console.log(m(lang, "keyPasteEmpty"));
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const record = setProviderKey(db, provider, secret);
|
|
471
|
+
console.log(m(lang, "keySavedTail", maskTail(record.apiKey)));
|
|
472
|
+
// §D:這把剛存進 DB 的值必須立刻進遮罩名單——下面「要不要立即驗證」若真的呼叫 API,
|
|
473
|
+
// 這把已經在 secretValues 裡,不會原樣寫進任何錯誤訊息。
|
|
474
|
+
registerSecrets(listProviderKeys(db).map((k) => k.apiKey));
|
|
475
|
+
// 裁示 2:不直接驗,存完問一句要不要立即驗;預設 N,跑之前明講會花錢(runKeyTest 自己會問)。
|
|
476
|
+
const verifyRl = createInterface({ input: process.stdin, output: process.stdout });
|
|
477
|
+
const verifyAnswer = (await verifyRl.question(m(lang, "keyVerifyPrompt", provider))).trim().toLowerCase();
|
|
478
|
+
verifyRl.close();
|
|
479
|
+
if (verifyAnswer === "y") {
|
|
480
|
+
await runKeyTest(db, provider, lang);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
75
483
|
async function main() {
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
// 故不用 `import "dotenv/config"`(那會讀 cwd),改為明確指定 dispatchHome 下的路徑。
|
|
80
|
-
const dispatchHome = resolveDispatchHome();
|
|
81
|
-
if (dispatchHome !== null)
|
|
82
|
-
loadDispatchEnv(dispatchHome);
|
|
83
|
-
registerSecrets(SECRET_ENV_VARS.map((k) => process.env[k]));
|
|
484
|
+
// 0.5.0 起 main() 開頭不再做任何 key 相關的載入:沒有 `.env`、沒有環境變數來源
|
|
485
|
+
// (見 dispatch-home.ts 檔頭)。`$DISPATCH_HOME` 仍是 DB 的預設落點,但由 defaultDbPath()
|
|
486
|
+
// 自己解析;遮罩的註冊移到各個持有 db handle 的路徑上(openDbSeeded 之後)。
|
|
84
487
|
// plan_i18n_impl_tickets T3:parseArgs 自己的解析期錯誤、以及 --help 本身的輸出,都發生在
|
|
85
488
|
// resolveLang 判定出權威語言之前——這裡只能用「DISPATCH_LANG 有效就用它,否則內建預設 en」
|
|
86
489
|
// 這個不會拋例外的簡化判定(resolveFallbackLang,見 cli-args.ts)。--lang 旗標的值在這個
|
|
87
490
|
// 時間點還沒被驗證,不查它(v1.3 §二之2 #5/#6:help/version 的語言不受 --lang 影響)。
|
|
88
491
|
const messageLang = resolveFallbackLang(process.env.DISPATCH_LANG);
|
|
492
|
+
if (process.argv[2] === "ticket") {
|
|
493
|
+
await ticketCommand(process.argv.slice(3), messageLang);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (process.argv[2] === "providers") {
|
|
497
|
+
await providersCommand(process.argv.slice(3), messageLang);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
if (process.argv[2] === "token") {
|
|
501
|
+
await tokenCommand(process.argv.slice(3), messageLang);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (process.argv[2] === "key") {
|
|
505
|
+
await keyCommand(process.argv.slice(3), messageLang);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
if (process.argv[2] === "result") {
|
|
509
|
+
const args = process.argv.slice(3);
|
|
510
|
+
const positional = [];
|
|
511
|
+
let dbPath;
|
|
512
|
+
for (let i = 0; i < args.length; i++) {
|
|
513
|
+
if (args[i] === "--db")
|
|
514
|
+
dbPath = args[++i];
|
|
515
|
+
else
|
|
516
|
+
positional.push(args[i]);
|
|
517
|
+
}
|
|
518
|
+
if (positional.length !== 1)
|
|
519
|
+
throw new DispatchError(m(messageLang, "resultHelp", getCommandName()), 2);
|
|
520
|
+
const db = openDbSeeded(dbPath, messageLang);
|
|
521
|
+
const requestedId = positional[0];
|
|
522
|
+
// `result` has three accepted identifiers. Resolve an exact job id first, then an
|
|
523
|
+
// exact ticket id, and only then a job-id prefix: exact identifiers must never be
|
|
524
|
+
// reinterpreted as a shorter prefix when their text happens to collide.
|
|
525
|
+
let resultId = requestedId;
|
|
526
|
+
const exactJob = getJob(db, requestedId);
|
|
527
|
+
if (exactJob) {
|
|
528
|
+
resultId = exactJob.id;
|
|
529
|
+
}
|
|
530
|
+
else if (!db.prepare("SELECT 1 FROM tickets WHERE name = ?").get(requestedId)) {
|
|
531
|
+
const candidates = findJobsByIdPrefix(db, requestedId);
|
|
532
|
+
if (candidates.length > 1) {
|
|
533
|
+
console.log(m(messageLang, "resultPrefixAmbiguous", requestedId, candidates.length));
|
|
534
|
+
const idWidth = shortestDistinctIdWidth(candidates.map((job) => job.id));
|
|
535
|
+
for (const job of candidates)
|
|
536
|
+
console.log(m(messageLang, "resultCandidate", job.id.slice(0, idWidth), job.ticket, job.status));
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
if (candidates.length === 1)
|
|
540
|
+
resultId = candidates[0].id;
|
|
541
|
+
}
|
|
542
|
+
// 驗收熱修補:失敗 spoke 的字樣先前固定英文(readResults 的 lang 有預設值而這裡沒傳),
|
|
543
|
+
// 中文模式下會印出一段英文——違反「對使用者的字串一律走 messages.ts 中英兩套」。
|
|
544
|
+
const lines = readResults(db, resultId, messageLang);
|
|
545
|
+
if (lines.length === 0)
|
|
546
|
+
throw new DispatchError(m(messageLang, "resultNotFound", requestedId), 2);
|
|
547
|
+
console.log(lines.join("\n\n"));
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
if (process.argv[2] === "approve") {
|
|
551
|
+
const args = process.argv.slice(3);
|
|
552
|
+
const positional = [];
|
|
553
|
+
let dbPath;
|
|
554
|
+
for (let i = 0; i < args.length; i++) {
|
|
555
|
+
if (args[i] === "--db")
|
|
556
|
+
dbPath = args[++i];
|
|
557
|
+
else
|
|
558
|
+
positional.push(args[i]);
|
|
559
|
+
}
|
|
560
|
+
if (positional.length > 1)
|
|
561
|
+
throw new DispatchError(m(messageLang, "approveHelp", getCommandName()), 2);
|
|
562
|
+
const db = openDbSeeded(dbPath, messageLang);
|
|
563
|
+
const prefix = positional[0];
|
|
564
|
+
const pending = prefix === undefined ? listPendingApprovalJobs(db) : findJobsByIdPrefix(db, prefix);
|
|
565
|
+
if (prefix === undefined && pending.length === 0) {
|
|
566
|
+
console.log(m(messageLang, "approveNoPending"));
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (pending.length > 1) {
|
|
570
|
+
if (prefix === undefined)
|
|
571
|
+
console.log(m(messageLang, "approveMultiplePending", pending.length));
|
|
572
|
+
else
|
|
573
|
+
console.log(m(messageLang, "approvePrefixAmbiguous", prefix, pending.length));
|
|
574
|
+
// 熱修補:候選清單原本一律截到 8 碼,而歧義前綴的每一筆前 8 碼**必然相同**——
|
|
575
|
+
// 訊息叫人「給長一點的 id」,列出來的卻正好是分不出來的那一段,照著做不到。
|
|
576
|
+
// 改為取「足以在本次候選中互相區分」的長度,最少仍是 8。
|
|
577
|
+
const idWidth = shortestDistinctIdWidth(pending.map((job) => job.id));
|
|
578
|
+
for (const job of pending)
|
|
579
|
+
console.log(m(messageLang, "approvePendingCandidate", job.id.slice(0, idWidth), job.ticket, job.created_at));
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
const before = pending[0];
|
|
583
|
+
if (!before)
|
|
584
|
+
throw new DispatchError(m(messageLang, "jobNotFound", prefix ?? ""), 2);
|
|
585
|
+
// Render the complete paid-work summary before changing the gate state.
|
|
586
|
+
// A corrupted job therefore stays pending instead of becoming approved with
|
|
587
|
+
// no information shown to the human operator.
|
|
588
|
+
const ticketSummary = formatTicket(db, before.ticket, messageLang);
|
|
589
|
+
console.log(ticketSummary);
|
|
590
|
+
const job = approveJob(db, before.id);
|
|
591
|
+
if (!job)
|
|
592
|
+
throw new DispatchError(m(messageLang, "jobNotApprovable", before.id, before.status), 2);
|
|
593
|
+
console.log(m(messageLang, "jobApproved", job.id));
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
89
596
|
const parsed = parseArgs(process.argv.slice(2), messageLang);
|
|
90
597
|
if (parsed.mode === "help") {
|
|
91
598
|
console.log(m(messageLang, "helpText", getCommandName()));
|
|
@@ -101,18 +608,77 @@ async function main() {
|
|
|
101
608
|
// §一之6:型號白名單走既有的 loadProviders/bundledProvidersPath,載入失敗印失敗原因、
|
|
102
609
|
// 不中止(doctor 本身 exit 一律 0,見下方 buildDoctorReport 的呼叫沒有任何 throw 路徑)。
|
|
103
610
|
let providersResult;
|
|
611
|
+
// 工單 key-db §B-3 #6:逐 provider 印出實際生效的是哪一層——doctorDb 開不起來時就沒有
|
|
612
|
+
// DB 可查,keyStatusRows 留空陣列(buildApiKeyValue 對空陣列的降級輸出)。
|
|
613
|
+
let keyStatusRows = [];
|
|
614
|
+
// A(gaps #1):整份報表——DB、金鑰、型號白名單、daemon——都要來自**這次真的會用到的**
|
|
615
|
+
// 那個 DB。先前這裡寫死 undefined/defaultDbPath(),於是 `--doctor --db <別的>` 印的
|
|
616
|
+
// 全是預設 DB 的狀況,而使用者指定的那個檔連建都不會建。
|
|
617
|
+
const doctorDbPath = parsed.options.dbPath ?? defaultDbPath();
|
|
104
618
|
try {
|
|
105
|
-
const
|
|
106
|
-
|
|
619
|
+
const doctorDb = openDbSeeded(doctorDbPath, messageLang);
|
|
620
|
+
const providers = loadProviders(doctorDb, messageLang);
|
|
621
|
+
providersResult = { ok: true, providers, models: listModels(doctorDb) };
|
|
622
|
+
keyStatusRows = buildKeyStatusRows(doctorDb);
|
|
107
623
|
}
|
|
108
624
|
catch (err) {
|
|
109
625
|
const reason = err instanceof DispatchError ? err.message : maskString(String(err));
|
|
110
626
|
providersResult = { ok: false, reason };
|
|
111
627
|
}
|
|
112
|
-
|
|
628
|
+
const doctorData = buildDoctorReportData(getCommandName(), providersResult, process.env, undefined, undefined, undefined, daemonAlive(daemonStatePath(doctorDbPath)), keyStatusRows, doctorDbPath);
|
|
629
|
+
console.log(parsed.options.json ? JSON.stringify(doctorData) : renderDoctorReport(messageLang, getCommandName(), doctorData));
|
|
113
630
|
return;
|
|
114
631
|
}
|
|
115
|
-
|
|
632
|
+
if (parsed.mode === "serve") {
|
|
633
|
+
if (parsed.stop) {
|
|
634
|
+
const outcome = await stopDaemon(parsed.options.dbPath, {
|
|
635
|
+
force: parsed.options.yes,
|
|
636
|
+
confirmRunningJobs: async (count) => {
|
|
637
|
+
console.log(m(messageLang, "daemonStopRunningWarning", count));
|
|
638
|
+
return confirm(m(messageLang, "daemonStopConfirmPrompt"), process.stdout);
|
|
639
|
+
},
|
|
640
|
+
});
|
|
641
|
+
switch (outcome.status) {
|
|
642
|
+
case "not_running":
|
|
643
|
+
console.log(m(messageLang, "daemonStopNotRunning", outcome.reason));
|
|
644
|
+
break;
|
|
645
|
+
case "cancelled":
|
|
646
|
+
console.log(m(messageLang, "daemonStopCancelled"));
|
|
647
|
+
break;
|
|
648
|
+
case "pid_not_dowafu":
|
|
649
|
+
console.log(m(messageLang, "daemonStopPidNotDowafu", outcome.pid));
|
|
650
|
+
break;
|
|
651
|
+
case "signal_failed":
|
|
652
|
+
console.log(m(messageLang, "daemonStopSignalFailed", outcome.pid));
|
|
653
|
+
break;
|
|
654
|
+
case "stopped":
|
|
655
|
+
console.log(m(messageLang, "daemonStopSucceeded", outcome.pid));
|
|
656
|
+
break;
|
|
657
|
+
case "restarted":
|
|
658
|
+
console.log(m(messageLang, "daemonStopRestarted", outcome.pid));
|
|
659
|
+
break;
|
|
660
|
+
}
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
try {
|
|
664
|
+
const daemon = await serve(parsed.options.dbPath, undefined, parsed.options.httpPort, messageLang);
|
|
665
|
+
console.log(m(messageLang, "daemonStarted"));
|
|
666
|
+
console.log(m(messageLang, "httpServerStarted", daemon.httpPort));
|
|
667
|
+
// B-1 #6:端點活著但沒發過 token,任何請求都進不來——失敗長得像成功,必須警告。
|
|
668
|
+
if (!daemon.hasActiveToken)
|
|
669
|
+
console.log(m(messageLang, "httpNoTokensWarning"));
|
|
670
|
+
}
|
|
671
|
+
catch (err) {
|
|
672
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
673
|
+
throw new DispatchError(m(messageLang, "daemonStartFailed", detail), 2);
|
|
674
|
+
}
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
if (parsed.mode === "mcp") {
|
|
678
|
+
startStdioServer({ dbPath: parsed.options.dbPath, lang: messageLang });
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
const { ticketDir: ticketName, options } = parsed;
|
|
116
682
|
const log = makeLogger(options.json);
|
|
117
683
|
// plan_i18n_v1.3.md §二:--lang > DISPATCH_LANG > 內建預設,含九項組合判定
|
|
118
684
|
// (fail closed,無效值 exit 2)。help/version 已在上面短路,不會走到這裡。
|
|
@@ -124,22 +690,20 @@ async function main() {
|
|
|
124
690
|
// v1.10 §9:--repo-root 只影響白名單邊界、.claude/agents 位置、_docs/ 拒絕判定;
|
|
125
691
|
// 工單目錄仍相對 cwd 解析,不要求位於 repoRoot 內(見 resolveSpokes 呼叫處)。
|
|
126
692
|
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
127
|
-
const ticketId =
|
|
693
|
+
const ticketId = ticketName;
|
|
694
|
+
const db = openDbSeeded(options.dbPath, lang);
|
|
128
695
|
// §10 步驟 1–2:工單解析+允許清單存在性(loadTicket/resolveSpokes 內部 fail closed)
|
|
129
|
-
const ticket = await loadTicket(
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const providers = await loadProviders(providersPath, lang);
|
|
137
|
-
// §10 步驟 4–5:effort 值域、API key 齊備、models 白名單(resolveSpokes 內部)
|
|
138
|
-
const spokes = await resolveSpokes(ticket, providers, repoRoot, lang);
|
|
696
|
+
const ticket = await loadTicket(db, ticketName, lang);
|
|
697
|
+
const providersSource = { kind: "database" };
|
|
698
|
+
const providers = loadProviders(db, lang);
|
|
699
|
+
// §10 步驟 4–5:effort 值域、API key 齊備、models 白名單(resolveSpokes 內部)。
|
|
700
|
+
// 兩個回呼都是必填:validate.ts 不持有 db handle,「這個型號開著嗎」與「這個 provider
|
|
701
|
+
// 有沒有 key」都由這裡接上——漏傳編不過(typecheck 涵蓋測試檔)。
|
|
702
|
+
const spokes = await resolveSpokes(ticket, providers, repoRoot, lang, (provider, model) => isModelEnabled(db, provider, model), (provider) => getProviderKey(db, provider) !== undefined);
|
|
139
703
|
// §10 步驟 6/§14 閘門一:呼叫前估算
|
|
140
704
|
const estimates = spokes.map((spoke) => {
|
|
141
705
|
const systemPrompt = buildSystemPrompt(spoke.agentBody, spoke.lang);
|
|
142
|
-
const firstUserText = buildFirstUserText(
|
|
706
|
+
const firstUserText = buildFirstUserText(ticket.ticketDir, spoke.agent, spoke.allowedReadsRelative, repoRoot, spoke.lang);
|
|
143
707
|
const charsPerToken = effectiveCharsPerToken(spoke, options);
|
|
144
708
|
return {
|
|
145
709
|
agent: spoke.agent,
|
|
@@ -152,15 +716,18 @@ async function main() {
|
|
|
152
716
|
// 報表與 --json plan 共用,不重複讀檔。
|
|
153
717
|
const allowlistEstimates = spokes.map((spoke) => ({
|
|
154
718
|
agent: spoke.agent,
|
|
155
|
-
estimatedTokens: estimateAllowlistTokens(spoke.
|
|
719
|
+
estimatedTokens: estimateAllowlistTokens(spoke.allowedReadsContent ?? [], effectiveCharsPerToken(spoke, options)),
|
|
156
720
|
fileCount: spoke.allowedReadsResolved.length,
|
|
157
|
-
sequential: estimateSequentialRead(spoke.
|
|
721
|
+
sequential: estimateSequentialRead(spoke.allowedReadsContent ?? [], effectiveCharsPerToken(spoke, options)),
|
|
158
722
|
}));
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
723
|
+
const resultId = options.jobId ?? ticketId;
|
|
724
|
+
const outDir = `db:${resultId}`;
|
|
725
|
+
// 票 cost-cap §E #1:outDir(`db:<id>`)是假路徑,git 永遠判不出結果——舊碼因此寫死
|
|
726
|
+
// "unknown" 並印出編出來的理由(「not a git repo, or git unavailable」,沒有人去檢查)。
|
|
727
|
+
// 產物已搬進 DB(v1.11 §24.5),真正該檢查的是資料庫檔案本身的位置:`--db` 可以指到
|
|
728
|
+
// repo 裡面,這樣才問得出「派工產物會不會被誤 commit」這個問題原本想問的事。
|
|
729
|
+
const resolvedDbPath = options.dbPath ?? defaultDbPath();
|
|
730
|
+
const gitignoreStatus = checkGitignore(resolvedDbPath);
|
|
164
731
|
// v1.11 §25:plan 在此刻(步驟 6 之後)已完全確定,與是否實際執行無關——dry-run/
|
|
165
732
|
// cancelled/executed 三種 mode 的 --json 輸出皆含同一份 plan。
|
|
166
733
|
const plan = buildJsonPlan(spokes, estimates, allowlistEstimates, options);
|
|
@@ -182,15 +749,16 @@ async function main() {
|
|
|
182
749
|
repoRoot,
|
|
183
750
|
providersSource,
|
|
184
751
|
gitignoreStatus,
|
|
752
|
+
dbPath: resolvedDbPath,
|
|
185
753
|
reviewTextChars: ticket.shared.reviewText.length,
|
|
186
754
|
strayHeadings: ticket.shared.strayHeadings,
|
|
187
755
|
}, lang);
|
|
188
756
|
log.info(report);
|
|
189
757
|
// 護欄的預告:乾跑不受影響(它不寫任何東西),但先講,免得實跑才發現被擋。
|
|
190
|
-
const
|
|
758
|
+
const alreadyRun = hasResults(db, resultId);
|
|
191
759
|
if (options.dryRun) {
|
|
192
|
-
if (
|
|
193
|
-
log.info("\n" + m(lang, "
|
|
760
|
+
if (alreadyRun)
|
|
761
|
+
log.info("\n" + m(lang, "resultsExistDryRunWarning", resultId));
|
|
194
762
|
log.info("\n" + m(lang, "dryRunNotice"));
|
|
195
763
|
if (options.json) {
|
|
196
764
|
console.log(JSON.stringify(maskDeep(buildPayload("dry-run", [], new Map(), new Map(), 0))));
|
|
@@ -200,9 +768,9 @@ async function main() {
|
|
|
200
768
|
// 擋在 confirm 之前:不要先問「要不要花錢」再中止。exitCode 5 是「派工前被守則擋下、
|
|
201
769
|
// 未花任何錢」,與 3(成本閘門超限)分開,才分得出是哪一種擋。
|
|
202
770
|
// **刻意不提供 --overwrite 之類的旗標**:有旗標,「加上去」就會變成最便宜的滿足方式,
|
|
203
|
-
// 那正是 --yes
|
|
204
|
-
if (
|
|
205
|
-
throw new DispatchError(m(lang, "
|
|
771
|
+
// 那正是 --yes 已經示範過的路。要覆蓋,由使用者自己把那筆結果從 DB 刪掉。
|
|
772
|
+
if (alreadyRun) {
|
|
773
|
+
throw new DispatchError(m(lang, "resultsExistAbort", resultId), 5);
|
|
206
774
|
}
|
|
207
775
|
if (!options.yes) {
|
|
208
776
|
const ok = await confirm(m(lang, "confirmPrompt"), options.json ? process.stderr : process.stdout);
|
|
@@ -214,27 +782,21 @@ async function main() {
|
|
|
214
782
|
return;
|
|
215
783
|
}
|
|
216
784
|
}
|
|
217
|
-
let outDirReady = true;
|
|
218
|
-
try {
|
|
219
|
-
await ensureOutDir(outDir);
|
|
220
|
-
}
|
|
221
|
-
catch (err) {
|
|
222
|
-
outDirReady = false;
|
|
223
|
-
console.error(m(lang, "outDirNotWritable", outDir));
|
|
224
|
-
console.error(maskString(String(err)));
|
|
225
|
-
}
|
|
226
|
-
const runLog = outDirReady ? new RunLogWriter(path.join(outDir, "run.jsonl"), lang) : null;
|
|
227
785
|
const semaphore = new Semaphore(options.concurrency);
|
|
228
786
|
const onEvent = (event) => {
|
|
229
787
|
log.info(formatEvent(event, lang));
|
|
230
|
-
|
|
788
|
+
// B(gaps #6):這串事件的第二個消費者。`RunLogWriter` 曾經是它(寫 run.jsonl),
|
|
789
|
+
// 票 B 退場之後 DB 這側就沒人接,於是跑的過程中完全沒有可查的進度。
|
|
790
|
+
// recordRound 自己吞掉所有例外——進度查不到,也不該弄死一次已付費的派工。
|
|
791
|
+
if (event.type === "round")
|
|
792
|
+
recordRound(db, resultId, event.agent, event.round, event.usage, event.hasToolCalls);
|
|
231
793
|
};
|
|
232
794
|
// §10 步驟 8:信號量控制發起,Promise.allSettled 只負責收尾
|
|
233
795
|
const settled = await Promise.allSettled(spokes.map(async (spoke) => {
|
|
234
796
|
await semaphore.acquire();
|
|
235
797
|
try {
|
|
236
|
-
const adapter = createAdapterFor(spoke);
|
|
237
|
-
const result = await runSpoke(spoke, adapter, ticketDir, {
|
|
798
|
+
const adapter = createAdapterFor(spoke, db);
|
|
799
|
+
const result = await runSpoke(spoke, adapter, ticket.ticketDir, {
|
|
238
800
|
repoRoot,
|
|
239
801
|
timeoutMs: options.timeoutSec * 1000,
|
|
240
802
|
retries: options.retries,
|
|
@@ -250,8 +812,13 @@ async function main() {
|
|
|
250
812
|
});
|
|
251
813
|
// §13(一):每支 spoke 完成即落檔,不等 Promise.allSettled——多 spoke 並行、
|
|
252
814
|
// 其中一支慢很多時,快的那支已付費的產出不因慢的還在跑而暴露在中斷風險下。
|
|
253
|
-
|
|
254
|
-
|
|
815
|
+
try {
|
|
816
|
+
persistSpokeResultDb(db, resultId, result);
|
|
817
|
+
}
|
|
818
|
+
catch (err) {
|
|
819
|
+
// 落檔失敗不得把已付費 result 變成 allSettled 的 rejected 假結果;結算後的
|
|
820
|
+
// 重試迴圈會再以同一份真實 result 補寫。仍須以 stderr 留痕,不能靜默吞掉。
|
|
821
|
+
console.error(m(lang, "dbResultPersistenceFailed", result.agent, maskString(String(err))));
|
|
255
822
|
}
|
|
256
823
|
return result;
|
|
257
824
|
}
|
|
@@ -262,8 +829,8 @@ async function main() {
|
|
|
262
829
|
const results = settled.map((s, i) => {
|
|
263
830
|
if (s.status === "fulfilled")
|
|
264
831
|
return s.value;
|
|
265
|
-
// §13:
|
|
266
|
-
//
|
|
832
|
+
// §13:runSpoke 會把已知 spoke 錯誤收斂為 status:"failed";這裡仍保留給 adapter
|
|
833
|
+
// 建立等 runSpoke 之外的例外。每支的 DB 落檔錯誤已在上方接住並保留真實 result。
|
|
267
834
|
const spoke = spokes[i];
|
|
268
835
|
return {
|
|
269
836
|
agent: spoke.agent,
|
|
@@ -304,21 +871,11 @@ async function main() {
|
|
|
304
871
|
}));
|
|
305
872
|
// §10 步驟 10:落檔 + stdout 摘要。這裡的迴圈定位已改變(v2.4 §13 規格二):
|
|
306
873
|
// per-spoke 落檔已在 spokes.map 內完成,此處是「落檔失敗的重試」,不再是
|
|
307
|
-
// rejected 分支的兜底——真實 result
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
await writeSummary(outDir, ticketId, results, audits, toolCallAudits, lang);
|
|
313
|
-
await runLog?.flush();
|
|
314
|
-
log.info("\n" + m(lang, "outDirWritten", outDir));
|
|
315
|
-
}
|
|
316
|
-
else {
|
|
317
|
-
// 落檔目錄不可寫時,不讓已付費的結果消失——完整結果改印到 stderr(json 模式下
|
|
318
|
-
// stdout 仍須維持只有最後那個單一 JSON 物件的契約,不能把這份診斷用資料混進去)。
|
|
319
|
-
log.info("\n" + m(lang, "outDirFallbackStderr"));
|
|
320
|
-
console.error(JSON.stringify(maskDeep(results), null, 2));
|
|
321
|
-
}
|
|
874
|
+
// rejected 分支的兜底——真實 result 的完全相同重寫由 DB helper 視為冪等。
|
|
875
|
+
for (const r of results)
|
|
876
|
+
persistSpokeResultDb(db, resultId, r);
|
|
877
|
+
writeSummaryDb(db, resultId, results, audits, toolCallAudits, lang);
|
|
878
|
+
log.info("\n" + m(lang, "resultsSaved", resultId));
|
|
322
879
|
const allFailed = results.every((r) => r.status === "failed");
|
|
323
880
|
const exitCode = allFailed ? 4 : 0;
|
|
324
881
|
if (allFailed) {
|
|
@@ -331,12 +888,34 @@ async function main() {
|
|
|
331
888
|
log.info("\n" + buildStdoutSummary(results, lang));
|
|
332
889
|
}
|
|
333
890
|
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
891
|
+
// 熱修補(票 key-visibility 驗收):這個守衛存在的理由是 B-0——測試要 import
|
|
892
|
+
// `executeKeyTest` 而不觸發 main()。但它原本用 `path.resolve` 比對,而 **resolve 不解析
|
|
893
|
+
// symlink**,`import.meta.url` 卻是 Node 解析過的真實路徑。於是經由 symlink 呼叫時兩邊
|
|
894
|
+
// 永遠不相等,main() 不執行——**CLI 靜默什麼都不做,還 exit 0**。
|
|
895
|
+
//
|
|
896
|
+
// 那正是 `npm install -g dowafu`(README 教的安裝方式)的形態:npm 在 node_modules/.bin
|
|
897
|
+
// 建的是 symlink。daemon 與 MCP stdio 都用絕對真實路徑 spawn,pnpm 的 shim 傳的又是帶 `..`
|
|
898
|
+
// 的相對路徑(resolve 後恰好相等),所以三個現有驗證面全都測不到它。
|
|
899
|
+
//
|
|
900
|
+
// realpathSync 會 throw(檔案不存在、權限不足),那時退回原本的 resolve 比對——
|
|
901
|
+
// 判斷不出來就寧可執行,不要靜默不執行。
|
|
902
|
+
function isDirectExecution(entry) {
|
|
903
|
+
const self = fileURLToPath(import.meta.url);
|
|
904
|
+
try {
|
|
905
|
+
return realpathSync(entry) === self;
|
|
906
|
+
}
|
|
907
|
+
catch {
|
|
908
|
+
return path.resolve(entry) === self;
|
|
339
909
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
910
|
+
}
|
|
911
|
+
if (process.argv[1] && isDirectExecution(process.argv[1])) {
|
|
912
|
+
main().catch((err) => {
|
|
913
|
+
if (err instanceof DispatchError) {
|
|
914
|
+
console.error(maskString(err.message));
|
|
915
|
+
process.exitCode = err.exitCode;
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
console.error(maskString(String(err)));
|
|
919
|
+
process.exitCode = 1;
|
|
920
|
+
});
|
|
921
|
+
}
|