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/dist/output.js CHANGED
@@ -1,106 +1,6 @@
1
- // plan_dispatch_v1.4.md §12/§16:落檔與 run.jsonl。寫入序列化——前一次 appendFile 未
2
- // resolve 前不發下一次;呼叫平行,寫入序列。所有落檔內容在寫入前經 maskDeep 遮蔽(§12)。
3
- import { appendFile, mkdir, readdir, writeFile } from "node:fs/promises";
4
- import path from "node:path";
1
+ // D1:Spoke 結果只寫進 SQLite 的 results 欄位;所有落庫內容在寫入前經 maskDeep 遮蔽。
5
2
  import { maskDeep, maskString } from "./mask.js";
6
3
  import { m } from "./messages.js";
7
- export class RunLogWriter {
8
- filePath;
9
- lang;
10
- queue = Promise.resolve();
11
- constructor(filePath, lang) {
12
- this.filePath = filePath;
13
- this.lang = lang;
14
- }
15
- // plan_dispatch_v2.4.md §12(一):ts 語意為「append 被呼叫的當下」,即事件發生時刻,
16
- // 故在此(方法本體)取時間,不得延後到 queue 的 then callback 內——落盤時間會被檔案
17
- // 系統阻塞與 queue 排隊污染,失去診斷「哪一輪慢」的價值。ts 於 maskDeep 之後加入,
18
- // 避免遮蔽邏輯誤傷時間字串。
19
- append(event) {
20
- const ts = new Date().toISOString();
21
- const masked = maskDeep(event);
22
- const line = JSON.stringify({ ...masked, ts }) + "\n";
23
- // §12(二):單次 appendFile 失敗只影響該行,不得毒化 queue——否則其後每一次
24
- // append 都不會執行,且 flush() 也會 rejected,等於一次瞬時寫入失敗讓該次派工
25
- // 其餘所有事件全部遺失。失敗以 stderr 留痕,不靜默吞掉。
26
- this.queue = this.queue.then(() => appendFile(this.filePath, line, "utf8")).catch((err) => {
27
- console.error(m(this.lang, "runLogWriteFailed", maskString(String(err))));
28
- });
29
- }
30
- async flush() {
31
- await this.queue;
32
- }
33
- }
34
- export async function ensureOutDir(outDir) {
35
- await mkdir(path.join(outDir, "raw"), { recursive: true });
36
- // §13:「同一工單重跑會覆蓋同名輸出目錄——刻意行為」。其餘產物(summary.md、
37
- // <agent>.md、raw/*.json)走 writeFile 自然覆蓋,唯獨 run.jsonl 走 appendFile
38
- // ——§12 的 append 是為**單次執行內**的中斷安全,不是跨次累積。不在此清空的話,
39
- // 重跑會把上一次的事件留著,而 toolCalls[] 正是偵測「零讀取」的唯一依據
40
- // (issue_log_v2.0.md 2026-08-07:hub 驗收時據此數錯 9/22 vs 實際 7/20)。
41
- await writeFile(path.join(outDir, "run.jsonl"), "", "utf8");
42
- }
43
- // 上面 §13 那條「重跑會覆蓋同名輸出目錄」的前提是**重跑的人知道自己在覆蓋什麼**。
44
- // 四格 host 對測(2026-08-13)打掉了那個前提:hub 會在派工前自己 rm -rf 掉輸出目錄,
45
- // 沒有人被問過,而那底下是已經付過錢的產物。覆蓋本身不改(此處語意維持不變),改成
46
- // 由 cli.ts 在**還沒呼叫任何 API 的地方**擋下來——目錄非空就中止,並要求換 ticket-id。
47
- // 讀不到目錄(不存在、或權限不足)一律回 false:不存在本來就該放行,權限不足則交給
48
- // 後面的 ensureOutDir 報 outDirNotWritable,兩者的訊息各自比這裡準確。
49
- export async function outDirHasArtifacts(outDir) {
50
- try {
51
- return (await readdir(outDir)).length > 0;
52
- }
53
- catch {
54
- return false;
55
- }
56
- }
57
- // §13:raw 一律無條件覆寫。v2.4 實作期間曾加過「空 raw 不覆蓋磁碟上的非空內容」守衛
58
- // (由 v2.4 §20 的一條測試要求逼出來),驗收時撤除:它破壞了 §13「同一工單重跑會覆蓋
59
- // 同名輸出目錄」——重跑時該 spoke 若零 raw,上一次的內容會留著冒充本次證據,與
60
- // 21bbd83 修掉的 run.jsonl 污染同型,而 raw/ 是「當時究竟送了什麼出去」的唯一證據。
61
- // 「防禦性空 result 覆寫已付費內容」那條路徑的保證,由 persistSpokeResult 的
62
- // 「例外不外傳」單獨承擔——例外不外傳之後,results[i] 永遠是 runSpoke 的真實回傳值。
63
- export async function writeRawFiles(outDir, agent, result) {
64
- await writeFile(path.join(outDir, "raw", `${agent}.request.json`), JSON.stringify(maskDeep(result.rawRequests), null, 2), "utf8");
65
- await writeFile(path.join(outDir, "raw", `${agent}.response.json`), JSON.stringify(maskDeep(result.rawResponses), null, 2), "utf8");
66
- // plan_fixes_v1.0.md §6:先前失敗時 request/response 兩份都是 [](兩者只記成功輪次),
67
- // 中斷或失敗時完全沒有線索。獨立於上面兩份之外——不動既有「僅成功輪次」的語意,
68
- // 逐次錯誤(含重試中途、非終局那次)另開一份,空陣列=未撞過任何錯誤。
69
- await writeFile(path.join(outDir, "raw", `${agent}.errors.json`), JSON.stringify(maskDeep(result.rawErrors), null, 2), "utf8");
70
- }
71
- // §13:「原文產出」欄——failed 無產出;其餘(含 truncated:*)皆落檔,即使不完整,
72
- // 因為那是已經付過錢的內容。
73
- export async function writeSpokeText(outDir, agent, result, lang) {
74
- if (result.status === "failed")
75
- return;
76
- // issue_log_v2.5.md 待修 #7:本函式一度是唯一沒有遮蔽的落檔路徑——`raw/` 與 `run.jsonl`
77
- // 都經 maskDeep,只有這裡直寫 finalText。spoke 讀的是被審專案的原始碼,那裡若有硬編金鑰,
78
- // 它可以照抄進「原文」欄(§16 模板要求逐字複製),於是秘密落到一個不經遮蔽的檔案裡。
79
- const content = maskString(result.finalText ?? m(lang, "noFullReportAvailable", result.status));
80
- await writeFile(path.join(outDir, `${agent}.md`), content, "utf8");
81
- }
82
- // plan_dispatch_v2.4.md §13(一):每支 spoke 完成即落檔的落地點。落檔例外不得外傳——
83
- // 落檔失敗不得使呼叫端的 promise rejected、不得改變該 spoke 的 status(落檔是產物持久化,
84
- // 不是執行結果),但必須留下痕跡:寫入 result.errors[] 並經 onError 回報。
85
- // writeSpokeText、writeRawFiles 各自包住例外,其一失敗不影響另一個執行。
86
- export async function persistSpokeResult(outDir, result, onError, lang) {
87
- try {
88
- await writeSpokeText(outDir, result.agent, result, lang);
89
- }
90
- catch (err) {
91
- const msg = m(lang, "persistTextFailed", result.agent, maskString(String(err)));
92
- result.errors.push(msg);
93
- onError(msg);
94
- }
95
- try {
96
- await writeRawFiles(outDir, result.agent, result);
97
- }
98
- catch (err) {
99
- const msg = m(lang, "persistRawFailed", result.agent, maskString(String(err)));
100
- result.errors.push(msg);
101
- onError(msg);
102
- }
103
- }
104
4
  function formatStoreCell(store) {
105
5
  return store === "false" ? "false" : store;
106
6
  }
@@ -114,6 +14,15 @@ function formatStatusCell(r, lang) {
114
14
  const flag = r.budgetTrigger === "reasoning_round" ? m(lang, "anomalySpikeFlag") : "";
115
15
  return `${r.status}(${flag}${label})`;
116
16
  }
17
+ // 工單 status-stdin §A-2:只在 provider 自己明確回報了非正常結束(finishReasonRaw 非
18
+ // null,且 normalizeFinishReason 沒把它正規化成 "stop")時才印;正常結束不多印一欄。
19
+ // finishReason/finishReasonRaw 在這個條件下必然相等(usage.ts 的 STOP_VALUES 之外一律
20
+ // 原樣保留),印 finishReason 即可,不必額外帶 raw。
21
+ function formatFinishReasonCell(r, lang) {
22
+ if (r.finishReasonRaw === null || r.finishReason === "stop")
23
+ return null;
24
+ return m(lang, "finishReasonWarningCell", r.finishReason ?? "unknown");
25
+ }
117
26
  // 工單 X1 v1.1 §二:模板佔位符若被留在回報裡,列出是哪幾個、各幾次;沒有命中維持既有
118
27
  // 風格印「無」。
119
28
  function formatPlaceholdersCell(hits, lang) {
@@ -135,6 +44,10 @@ export function buildSummaryMarkdown(ticketId, results, audits, toolCallAudits,
135
44
  if (t?.zeroSourceRead) {
136
45
  cells.push(m(lang, "zeroSourceReadWarning", t.allowedReadsCount));
137
46
  }
47
+ const finishReasonCell = formatFinishReasonCell(r, lang);
48
+ if (finishReasonCell !== null) {
49
+ cells.push(finishReasonCell);
50
+ }
138
51
  if (t) {
139
52
  cells.push(m(lang, "toolCallStats", t.total, t.allowed, t.rejected));
140
53
  }
@@ -153,7 +66,81 @@ export function buildSummaryMarkdown(ticketId, results, audits, toolCallAudits,
153
66
  ${rows.join("\n")}
154
67
  `;
155
68
  }
156
- export async function writeSummary(outDir, ticketId, results, audits, toolCallAudits, lang) {
157
- const md = buildSummaryMarkdown(ticketId, results, audits, toolCallAudits, lang);
158
- await writeFile(path.join(outDir, "summary.md"), md, "utf8");
69
+ // D1 DB persistence. `results` deliberately has no opaque blob column: the three
70
+ // existing artifact families map to output/request/response and remain inspectable.
71
+ export function persistSpokeResultDb(db, ticket, result) {
72
+ const output = result.status === "failed" ? null : maskString(result.finalText ?? "");
73
+ const request = JSON.stringify(maskDeep({ requests: result.rawRequests, errors: result.rawErrors }));
74
+ const response = JSON.stringify(maskDeep({ responses: result.rawResponses, result }));
75
+ try {
76
+ db.prepare(`INSERT INTO results (job_id, agent, output, request, response, tokens_in, tokens_out, cost_usd)
77
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(ticket, result.agent, output, request, response, result.usage.inputTokens, result.usage.outputTokens, result.costUsd);
78
+ }
79
+ catch (err) {
80
+ // cli.ts intentionally persists the exact same in-memory result twice: immediately
81
+ // after a spoke, then once more in its post-settlement retry loop. Keep that
82
+ // idempotent case, but let every materially different duplicate surface as SQLite's
83
+ // constraint error instead of silently replacing paid output.
84
+ const existing = db.prepare(`SELECT output, request, response, tokens_in, tokens_out, cost_usd
85
+ FROM results WHERE job_id = ? AND agent = ?`).get(ticket, result.agent);
86
+ if (existing
87
+ && existing.output === output
88
+ && existing.request === request
89
+ && existing.response === response
90
+ && existing.tokens_in === result.usage.inputTokens
91
+ && existing.tokens_out === result.usage.outputTokens
92
+ && existing.cost_usd === result.costUsd)
93
+ return;
94
+ throw err;
95
+ }
96
+ }
97
+ export function writeSummaryDb(db, ticket, results, audits, toolCallAudits, lang) {
98
+ const summary = buildSummaryMarkdown(ticket, results, audits, toolCallAudits, lang);
99
+ db.prepare(`INSERT INTO results (job_id, agent, output, request, response, tokens_in, tokens_out, cost_usd)
100
+ VALUES (?, '__summary__', ?, NULL, NULL, NULL, NULL, NULL)`).run(ticket, maskString(summary));
101
+ }
102
+ // 票 skill-refresh:把「讀回一次派工的結果」抽成 CLI 與 MCP 共用的唯一實作。
103
+ // D1 之後產物只在 DB,而在此之前**只有 MCP 的 dispatch_result 讀得回來**——走 CLI 直接
104
+ // 派工的人沒有對應指令,實測中的 Claude Code 因此自己去開 sqlite3(v0_5_0/_archived/log.md §八,當時記成
105
+ // 「client 又 shell out 了」的行為觀察,沒認出那是缺一個指令)。
106
+ //
107
+ // 兩個呼叫端共用同一支,是因為這批已經撞過三次「兩個呼叫端只改了一個」(票 B 的候選清單、
108
+ // 票 C 的 DOWAFU_HTTP_PORT、cost-cap 的 report/ticket-store)。
109
+ //
110
+ // `job_id` 欄存的是 resultId:CLI 直接派工時等於 ticket 名稱,走 daemon 佇列時是 job 的
111
+ // UUID。故先照原樣查一次,查不到再把它當成 ticket 名稱去找最近一筆完成的 job——兩條派工
112
+ // 路徑的使用者都能用同一個指令拿回結果。
113
+ // 工單 status-stdin §A-1:output IS NULL 是既有的失敗訊號(見 persistSpokeResultDb——
114
+ // 只有 status === "failed" 才寫 NULL,見上方 output 變數的三元運算)。先前這裡直接印
115
+ // `${agent}:\n`,付了錢卻拿到空段落的東西,跟「這支什麼都沒說」分不出來。lang 預設 en:
116
+ // 目前兩個呼叫端(cli.ts 的 `result` 子指令、mcp/protocol.ts 的 dispatch_result)都不在
117
+ // 本票的擁有清單內,改不了它們的呼叫點;預設值讓既有呼叫維持可編譯、行為只在「失敗且無
118
+ // 產出」這個先前本來就講不清楚的邊界上變化,成功的輸出逐字不受影響(見下方 map)。
119
+ export function readResults(db, ticketOrJobId, lang = "en") {
120
+ const select = db.prepare("SELECT agent, output FROM results WHERE job_id = ? ORDER BY agent");
121
+ let rows = select.all(ticketOrJobId);
122
+ if (rows.length === 0) {
123
+ const latest = db
124
+ // 驗收熱修補(票 skill-docs):先前只找 `completed`,於是**失敗的佇列 job 用 ticket-id
125
+ // 查不到**——而那正是最想看結果的時候(實測回「No results found」)。
126
+ // failed 與 interrupted 一樣有已付費的 results 可讀:interrupted 是「跑到一半行程死了」,
127
+ // 先完成的那幾支已經花過錢也已經落檔,把它們擋在外面與「落檔失敗不得銷毀已付費結果」
128
+ // (D1-hot3)那條的立場相反。running 不列入:那是還在跑,回半截會被讀成最終答案。
129
+ .prepare("SELECT id FROM jobs WHERE ticket = ? AND status IN ('completed', 'failed', 'interrupted') ORDER BY finished_at DESC LIMIT 1")
130
+ .get(ticketOrJobId);
131
+ if (latest)
132
+ rows = select.all(latest.id);
133
+ }
134
+ return rows.map((row) => `${row.agent}:\n${row.output ?? m(lang, "noFullReportAvailable", "failed")}`);
135
+ }
136
+ // 工單 status-stdin §A-3:dispatch_status 對 completed/failed 的 job 要能講出成敗分佈
137
+ // ——「三支死一支」先前完全看不出來。沿用 §A-1 同一個 NULL 訊號(agent != '__summary__':
138
+ // 稽核表本身的那一列不是 spoke,不計入分佈),不重算、不新增欄位。與 readResults 不同,
139
+ // 這裡的呼叫端(protocol.ts 的 dispatch_status)已經用 resolveJob 解出確切的 job.id,
140
+ // 不需要 readResults 那套「查無結果就回頭找 ticket 名稱」的雙路查詢。
141
+ export function readResultOutcomes(db, jobId) {
142
+ const rows = db
143
+ .prepare("SELECT agent, output FROM results WHERE job_id = ? AND agent != '__summary__' ORDER BY agent")
144
+ .all(jobId);
145
+ return rows.map((row) => ({ agent: row.agent, failed: row.output === null }));
159
146
  }
@@ -0,0 +1,36 @@
1
+ // **絕不讓進度寫入弄死一次已付費的派工。** 這張表是可丟棄的診斷資料,而呼叫端正在跑的是
2
+ // 花了錢的東西——任何 SQLite 例外都在這裡吞掉,不往上拋。同一個 (result_id, agent, round)
3
+ // 重寫直接覆蓋:進度不是已付費產出,沒有 `results` 那條「實質不同的重寫要炸出來」的顧慮
4
+ // (那條守的是 D1-hot3 的「落檔失敗不得銷毀已付費結果」,見 output.ts)。
5
+ export function recordRound(db, resultId, agent, round, usage, hasToolCalls, now = () => new Date().toISOString()) {
6
+ try {
7
+ db.prepare(`INSERT INTO progress (result_id, agent, round, at, tokens_in, tokens_out, has_tool_calls)
8
+ VALUES (?, ?, ?, ?, ?, ?, ?)
9
+ ON CONFLICT(result_id, agent, round) DO UPDATE SET
10
+ at = excluded.at, tokens_in = excluded.tokens_in,
11
+ tokens_out = excluded.tokens_out, has_tool_calls = excluded.has_tool_calls`).run(resultId, agent, round, now(), usage.inputTokens, usage.outputTokens, hasToolCalls ? 1 : 0);
12
+ }
13
+ catch {
14
+ // 吞掉:見上方。進度查不到比派工中斷好。
15
+ }
16
+ }
17
+ // 每支 spoke 一列摘要,依 agent 排序(不是插入序——不同機器上的列序不該讓輸出長得不一樣)。
18
+ export function readProgress(db, resultId) {
19
+ return db
20
+ .prepare(`SELECT agent,
21
+ count(*) AS rounds,
22
+ max(round) AS last_round,
23
+ max(at) AS last_at,
24
+ sum(tokens_in) AS tokens_in,
25
+ sum(tokens_out) AS tokens_out
26
+ FROM progress WHERE result_id = ?
27
+ GROUP BY agent ORDER BY agent`)
28
+ .all(resultId).map((row) => ({
29
+ agent: row.agent,
30
+ rounds: Number(row.rounds),
31
+ lastRound: Number(row.last_round),
32
+ lastAt: row.last_at,
33
+ tokensIn: Number(row.tokens_in),
34
+ tokensOut: Number(row.tokens_out),
35
+ }));
36
+ }
@@ -0,0 +1,84 @@
1
+ // 工單 key-db(E12):provider 金鑰的 DB 存取。**DB 是唯一來源。**
2
+ //
3
+ // `provider_keys` 表由 T1(hub)建好(db.ts migration version 2),本檔不碰 schema。
4
+ // 與 src/api-token.ts 刻意不共用任何機制:那張表只存 hash(入站憑證,只比對不重放),
5
+ // 這裡存明文(出站,得原樣交給廠商)。方向相反,見票 §零之「⚠️ 命名」一節。
6
+ //
7
+ // **0.5.0 起不再從環境變數或 `$DISPATCH_HOME/.env` 讀 API key**(使用者裁示,2026-09-06)。
8
+ // 初版做過「ambient env > .env > DB」三層優先序,連帶要在 dotenv 載入當下記錄「哪些鍵是
9
+ // 我填的」才分辨得出來源,`--doctor` 也得逐層顯示。那整串複雜度是多來源自己養出來的
10
+ // ——**收緊成單一來源之後,優先序、來源分辨、各種組合的測試全部不存在**。
11
+ // 舊版把 key 放在 .env 的人,升級後請改用 `dowafu key` 重設一次;缺 key 的錯誤訊息會講。
12
+ import { now } from "./db.js";
13
+ function toProviderKeyRecord(row) {
14
+ return {
15
+ provider: row.provider,
16
+ apiKey: row.api_key,
17
+ createdAt: row.created_at,
18
+ updatedAt: row.updated_at,
19
+ lastTestedAt: row.last_tested_at,
20
+ lastTestModel: row.last_test_model,
21
+ lastTestSucceeded: row.last_test_succeeded === null ? null : row.last_test_succeeded === 1,
22
+ };
23
+ }
24
+ export function getProviderKey(db, provider) {
25
+ const row = db
26
+ .prepare("SELECT provider, api_key, created_at, updated_at, last_tested_at, last_test_model, last_test_succeeded FROM provider_keys WHERE provider = ?")
27
+ .get(provider);
28
+ return row ? toProviderKeyRecord(row) : undefined;
29
+ }
30
+ export function listProviderKeys(db) {
31
+ return db
32
+ .prepare("SELECT provider, api_key, created_at, updated_at, last_tested_at, last_test_model, last_test_succeeded FROM provider_keys ORDER BY provider")
33
+ .all().map(toProviderKeyRecord);
34
+ }
35
+ // A-2 #1:重複 set 同一個 provider 是更新不是新增一列,updated_at 要跟著動;created_at
36
+ // 只在第一次寫入時落地。呼叫端負責 trim;本檔不對內容做任何格式判斷(A-1:明文存、不驗證)。
37
+ export function setProviderKey(db, provider, apiKey) {
38
+ const timestamp = now();
39
+ db.prepare(`INSERT INTO provider_keys (provider, api_key, created_at, updated_at) VALUES (?, ?, ?, ?)
40
+ ON CONFLICT(provider) DO UPDATE SET
41
+ api_key = excluded.api_key,
42
+ updated_at = excluded.updated_at,
43
+ -- A test belongs to the key value, not merely its provider. Re-pasting the same
44
+ -- value keeps the result useful; a different value must return to the untested state.
45
+ last_tested_at = CASE WHEN provider_keys.api_key <> excluded.api_key THEN NULL ELSE provider_keys.last_tested_at END,
46
+ last_test_model = CASE WHEN provider_keys.api_key <> excluded.api_key THEN NULL ELSE provider_keys.last_test_model END,
47
+ last_test_succeeded = CASE WHEN provider_keys.api_key <> excluded.api_key THEN NULL ELSE provider_keys.last_test_succeeded END`).run(provider, apiKey, timestamp, timestamp);
48
+ return getProviderKey(db, provider);
49
+ }
50
+ // A-2 #3:真的刪掉那一列(不是標記刪除),回傳是否真的刪到東西。
51
+ export function removeProviderKey(db, provider) {
52
+ const result = db.prepare("DELETE FROM provider_keys WHERE provider = ?").run(provider);
53
+ return Number(result.changes) > 0;
54
+ }
55
+ // key-visibility §B:不論 API 驗證成功或失敗都留下結果。這裡刻意不更新 updated_at:那欄
56
+ // 是 key 值最後被設定的時間,不能混進一個只讀驗證的時間。
57
+ export function recordProviderKeyTest(db, provider, model, succeeded) {
58
+ db.prepare("UPDATE provider_keys SET last_tested_at = ?, last_test_model = ?, last_test_succeeded = ? WHERE provider = ?").run(now(), model, succeeded ? 1 : 0, provider);
59
+ }
60
+ // §C-1 之2:provider 清單從 DB 的 `providers` 表來(票 A 已把 provider 進 DB),不是硬編
61
+ // 一份四家的清單——新增 provider 時選單自動跟上。
62
+ export function listKnownProviderNames(db) {
63
+ return db.prepare("SELECT name FROM providers ORDER BY name").all().map((row) => row.name);
64
+ }
65
+ // 票 §E 點 3:遮罩尾碼會洩漏最後 4 碼——刻意的取捨(讓人認得出貼對沒),文件必須寫出來,
66
+ // 不是安全機制。
67
+ export function maskTail(apiKey) {
68
+ return `…${apiKey.slice(-4)}`;
69
+ }
70
+ // `dowafu key`(互動選單)、`key list`、`--doctor` 三個輸出面共用同一份判定,避免各自
71
+ // 判斷一次而漂移。單一來源之後「有沒有設定」就等於 maskedTail 是不是 null,不需要來源欄位。
72
+ export function buildKeyStatusRows(db) {
73
+ return listKnownProviderNames(db).map((provider) => {
74
+ const stored = getProviderKey(db, provider);
75
+ return {
76
+ provider,
77
+ maskedTail: stored ? maskTail(stored.apiKey) : null,
78
+ updatedAt: stored ? stored.updatedAt : null,
79
+ lastTestedAt: stored ? stored.lastTestedAt : null,
80
+ lastTestModel: stored ? stored.lastTestModel : null,
81
+ lastTestSucceeded: stored ? stored.lastTestSucceeded : null,
82
+ };
83
+ });
84
+ }
@@ -0,0 +1,165 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { now } from "./db.js";
4
+ import { DispatchError } from "./types.js";
5
+ import { m } from "./messages.js";
6
+ import { parseProvidersFile } from "./providers.js";
7
+ import { bundledProvidersPath } from "./pkg-info.js";
8
+ const DEFAULT_ENABLED = new Set([
9
+ "gpt-5.6-luna",
10
+ "deepseek-v4-flash",
11
+ "gemini-3.1-flash-lite",
12
+ "gemini-3.5-flash-lite",
13
+ "claude-haiku-4-5-20251001",
14
+ ]);
15
+ // 解析與讀檔分開:播種必須是同步的(protocol.ts/stdio.ts 都是同步,
16
+ // 讓它們變 async 會一路波及到還沒寫的 HTTP binding),而 import 走 async 讀檔。
17
+ // 兩條路共用同一份解析,避免「兩種入口兩種嚴格度」。
18
+ function parseSourceText(text, lang) {
19
+ let raw;
20
+ try {
21
+ raw = JSON.parse(text);
22
+ }
23
+ catch (err) {
24
+ throw new DispatchError(m(lang, "providersFileInvalidJson", err.message), 2);
25
+ }
26
+ const providers = parseProvidersFile(raw, lang);
27
+ const entries = raw;
28
+ const pricingSources = {};
29
+ for (const name of Object.keys(providers)) {
30
+ const source = entries[name];
31
+ pricingSources[name] = source.pricingSource ?? null;
32
+ }
33
+ return { providers, pricingSources };
34
+ }
35
+ export async function readProvidersFile(filePath, lang) {
36
+ let text;
37
+ try {
38
+ text = await readFile(filePath, "utf8");
39
+ }
40
+ catch {
41
+ throw new DispatchError(m(lang, "missingProviders", filePath), 2);
42
+ }
43
+ return parseSourceText(text, lang);
44
+ }
45
+ // 熱修補(票 A 驗收缺口二):空表要自動播種出貨檔。
46
+ //
47
+ // 驗收發現全新安裝完全跑不動——models 表是空的,addSpoke 回「白名單為空」,
48
+ // 而使用者得自己找出 bundled providers.json 的路徑(在全域安裝裡帶版本號與相依雜湊,
49
+ // report.ts 的註解就寫著「猜不到」)。更糟的是 bundled 旗標用路徑完全相等判定,
50
+ // 所以複製一份自己的副本再 import 會得到「12 個型號全關」,而且沒有任何提示。
51
+ //
52
+ // hub 原本的裁示是「只由 providers import 明確觸發,不做啟動時自動偵測」——那句話
53
+ // 講的是**合流**(升級後蓋掉價目,會在使用者不知情時改掉計費基準),不是**首次播種**
54
+ // (空表放進出貨資料,沒有覆蓋任何東西)。措辭沒分開,故此處修正:只在**完全空表**時播種。
55
+ //
56
+ // 回傳是否真的播了種——呼叫端要據此印出通知。**播種不得靜默**。
57
+ export function seedIfEmpty(db, lang) {
58
+ const count = db.prepare("SELECT count(*) AS c FROM models").get().c;
59
+ if (Number(count) > 0)
60
+ return false;
61
+ const filePath = bundledProvidersPath();
62
+ let text;
63
+ try {
64
+ text = readFileSync(filePath, "utf8");
65
+ }
66
+ catch {
67
+ throw new DispatchError(m(lang, "missingProviders", filePath), 2);
68
+ }
69
+ // bundled = true:出貨檔才套用 DEFAULT_ENABLED 那五個。
70
+ mergeProviders(db, parseSourceText(text, lang), true);
71
+ return true;
72
+ }
73
+ function json(value) {
74
+ return JSON.stringify(value);
75
+ }
76
+ export function mergeProviders(db, source, bundled) {
77
+ const timestamp = now();
78
+ db.exec("BEGIN");
79
+ try {
80
+ for (const [name, config] of Object.entries(source.providers)) {
81
+ db.prepare(`INSERT INTO providers (name, base_url, api, store, tool_calling, reasoning, chars_per_token, tpm_limit, max_spoke_tokens, pricing_source, updated_at)
82
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
83
+ ON CONFLICT(name) DO UPDATE SET base_url=excluded.base_url, api=excluded.api, store=excluded.store,
84
+ tool_calling=excluded.tool_calling, reasoning=excluded.reasoning, chars_per_token=excluded.chars_per_token,
85
+ tpm_limit=excluded.tpm_limit, max_spoke_tokens=excluded.max_spoke_tokens, pricing_source=excluded.pricing_source,
86
+ updated_at=excluded.updated_at`)
87
+ .run(name, config.baseURL, config.api, config.store === null ? null : 0, config.toolCalling ? 1 : 0, json(config.reasoning), config.charsPerToken, config.tpmLimit, config.maxSpokeTokens, json(source.pricingSources[name]), timestamp);
88
+ for (const model of config.models) {
89
+ const price = config.pricing?.[model];
90
+ const old = db.prepare("SELECT enabled FROM models WHERE provider = ? AND model = ?").get(name, model);
91
+ const enabled = old ? old.enabled : bundled && DEFAULT_ENABLED.has(model) ? 1 : 0;
92
+ db.prepare(`INSERT INTO models (provider, model, input_per_m, cached_input_per_m, cache_write_per_m, output_per_m, enabled, updated_at)
93
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
94
+ ON CONFLICT(provider, model) DO UPDATE SET input_per_m=excluded.input_per_m, cached_input_per_m=excluded.cached_input_per_m,
95
+ cache_write_per_m=excluded.cache_write_per_m, output_per_m=excluded.output_per_m, enabled=models.enabled, updated_at=excluded.updated_at`)
96
+ .run(name, model, price?.inputPerM ?? null, price?.cachedInputPerM ?? null, price?.cacheWritePerM ?? null, price?.outputPerM ?? null, enabled, timestamp);
97
+ }
98
+ }
99
+ db.exec("COMMIT");
100
+ }
101
+ catch (err) {
102
+ db.exec("ROLLBACK");
103
+ throw err;
104
+ }
105
+ }
106
+ export function loadProviders(db, lang) {
107
+ const providers = {};
108
+ const rows = db.prepare("SELECT name, base_url, api, store, tool_calling, reasoning, chars_per_token, tpm_limit, max_spoke_tokens, pricing_source FROM providers ORDER BY name").all();
109
+ const models = listModels(db);
110
+ for (const row of rows) {
111
+ const name = String(row.name);
112
+ let reasoning;
113
+ let source;
114
+ try {
115
+ reasoning = JSON.parse(String(row.reasoning));
116
+ source = row.pricing_source === null ? null : JSON.parse(String(row.pricing_source));
117
+ }
118
+ catch {
119
+ throw new DispatchError(m(lang, "providersDbCorrupt", name), 2);
120
+ }
121
+ const own = models.filter((model) => model.provider === name);
122
+ if (own.length === 0)
123
+ throw new DispatchError(m(lang, "providersDbCorrupt", name), 2);
124
+ const pricing = {};
125
+ for (const model of own) {
126
+ if (model.inputPerM !== undefined && model.outputPerM !== undefined) {
127
+ pricing[model.model] = {
128
+ inputPerM: model.inputPerM,
129
+ outputPerM: model.outputPerM,
130
+ ...(model.cachedInputPerM !== undefined ? { cachedInputPerM: model.cachedInputPerM } : {}),
131
+ ...(model.cacheWritePerM !== undefined ? { cacheWritePerM: model.cacheWritePerM } : {}),
132
+ };
133
+ }
134
+ }
135
+ const asOf = source && typeof source === "object" && typeof source.asOf === "string"
136
+ ? source.asOf : undefined;
137
+ providers[name] = {
138
+ baseURL: String(row.base_url), api: row.api, store: row.store === null ? null : false,
139
+ toolCalling: Number(row.tool_calling) === 1, reasoning, models: own.map((model) => model.model),
140
+ charsPerToken: row.chars_per_token === null ? null : Number(row.chars_per_token),
141
+ tpmLimit: row.tpm_limit === null ? null : Number(row.tpm_limit),
142
+ maxSpokeTokens: row.max_spoke_tokens === null ? null : Number(row.max_spoke_tokens),
143
+ ...(Object.keys(pricing).length > 0 ? { pricing } : {}), ...(asOf ? { pricingAsOf: asOf } : {}),
144
+ };
145
+ }
146
+ return providers;
147
+ }
148
+ export function listModels(db) {
149
+ return db.prepare("SELECT provider, model, input_per_m, cached_input_per_m, cache_write_per_m, output_per_m, enabled FROM models ORDER BY provider, model").all()
150
+ .map((row) => ({ provider: String(row.provider), model: String(row.model), inputPerM: row.input_per_m === null ? undefined : Number(row.input_per_m), cachedInputPerM: row.cached_input_per_m === null ? undefined : Number(row.cached_input_per_m), cacheWritePerM: row.cache_write_per_m === null ? undefined : Number(row.cache_write_per_m), outputPerM: row.output_per_m === null ? undefined : Number(row.output_per_m), enabled: Number(row.enabled) === 1 }));
151
+ }
152
+ export function isModelEnabled(db, provider, model) {
153
+ return db.prepare("SELECT enabled FROM models WHERE provider = ? AND model = ?").get(provider, model)?.enabled === 1;
154
+ }
155
+ export function setModelEnabled(db, providerOrModel, maybeModel, enabled, lang) {
156
+ const matches = maybeModel === undefined
157
+ ? db.prepare("SELECT provider, model FROM models WHERE model = ? ORDER BY provider").all(providerOrModel)
158
+ : db.prepare("SELECT provider, model FROM models WHERE provider = ? AND model = ?").all(providerOrModel, maybeModel);
159
+ const model = maybeModel ?? providerOrModel;
160
+ if (matches.length === 0)
161
+ throw new DispatchError(m(lang, "providerModelNotFound", model), 2);
162
+ if (matches.length > 1)
163
+ throw new DispatchError(m(lang, "providerModelAmbiguous", model, matches.map((item) => item.provider).join(", ")), 2);
164
+ db.prepare("UPDATE models SET enabled = ?, updated_at = ? WHERE provider = ? AND model = ?").run(enabled ? 1 : 0, now(), matches[0].provider, matches[0].model);
165
+ }
package/dist/providers.js CHANGED
@@ -1,6 +1,7 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { loadProviders as loadProvidersFromDb } from "./providers-store.js";
1
3
  // plan_dispatch_v1.4.md §5:providers.json 載入與驗證。fail closed——api 欄缺失或
2
4
  // store:true 即中止(exit 2),不套用預設值繼續跑(§10 步驟 3)。
3
- import { readFile } from "node:fs/promises";
4
5
  import { DispatchError, } from "./types.js";
5
6
  import { m } from "./messages.js";
6
7
  function parseReasoning(raw, providerName, lang) {
@@ -44,7 +45,7 @@ function parsePositiveNumber(v, label, lang) {
44
45
  }
45
46
  return v;
46
47
  }
47
- function parsePricing(raw, providerName, lang) {
48
+ function parsePricing(raw, providerName, models, lang) {
48
49
  if (raw === undefined)
49
50
  return undefined;
50
51
  if (!raw || typeof raw !== "object") {
@@ -52,6 +53,9 @@ function parsePricing(raw, providerName, lang) {
52
53
  }
53
54
  const out = {};
54
55
  for (const [model, entry] of Object.entries(raw)) {
56
+ if (!models.includes(model)) {
57
+ throw new DispatchError(m(lang, "pricingModelNotWhitelisted", providerName, model), 2);
58
+ }
55
59
  if (!entry || typeof entry !== "object") {
56
60
  throw new DispatchError(m(lang, "pricingModelNotObject", providerName, model), 2);
57
61
  }
@@ -96,8 +100,12 @@ function parseProviderConfig(name, raw, lang) {
96
100
  if (typeof r.baseURL !== "string" || r.baseURL.length === 0) {
97
101
  throw new DispatchError(m(lang, "providerBaseUrlMissing", name), 2);
98
102
  }
99
- // §5:models 白名單。未填(或非陣列)視為空陣列 = 不做型號檢查。
100
- const models = Array.isArray(r.models) ? r.models.filter((v) => typeof v === "string") : [];
103
+ // A-1 addendum: models is the fail-closed model boundary. Missing, empty, and
104
+ // non-string entries must not silently turn into an unrestricted provider.
105
+ if (!Array.isArray(r.models) || r.models.length === 0 || !r.models.every((v) => typeof v === "string" && v.length > 0)) {
106
+ throw new DispatchError(m(lang, "providerModelsRequired", name), 2);
107
+ }
108
+ const models = r.models;
101
109
  // §5:charsPerToken。未填時用 CLI 全域值——保留 null,不在此處套 1.0(CLI 端才知道
102
110
  // 使用者傳入的 --chars-per-token 是多少)。
103
111
  if (r.charsPerToken !== undefined && (typeof r.charsPerToken !== "number" || !(r.charsPerToken > 0))) {
@@ -114,7 +122,7 @@ function parseProviderConfig(name, raw, lang) {
114
122
  charsPerToken,
115
123
  tpmLimit: typeof r.tpmLimit === "number" ? r.tpmLimit : null,
116
124
  maxSpokeTokens: typeof r.maxSpokeTokens === "number" ? r.maxSpokeTokens : null,
117
- pricing: parsePricing(r.pricing, name, lang),
125
+ pricing: parsePricing(r.pricing, name, models, lang),
118
126
  pricingAsOf: parsePricingAsOf(r.pricingSource),
119
127
  };
120
128
  }
@@ -137,7 +145,7 @@ export function parseProvidersFile(json, lang) {
137
145
  }
138
146
  return out;
139
147
  }
140
- export async function loadProviders(filePath, lang) {
148
+ export async function loadProvidersFile(filePath, lang) {
141
149
  let text;
142
150
  try {
143
151
  text = await readFile(filePath, "utf8");
@@ -154,6 +162,10 @@ export async function loadProviders(filePath, lang) {
154
162
  }
155
163
  return parseProvidersFile(json, lang);
156
164
  }
165
+ // Runtime authority is SQLite. The bundled file remains an explicit import source only.
166
+ export function loadProviders(db, lang) {
167
+ return loadProvidersFromDb(db, lang);
168
+ }
157
169
  export function getProviderConfig(providers, name, lang) {
158
170
  const config = providers[name];
159
171
  if (!config) {