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/cost.js CHANGED
@@ -18,6 +18,16 @@
18
18
  // inputTokens 的子集(官方對「快取命中」的計費方式是「原生 input 價的一部分改用
19
19
  // 快取價」),要從 inputTokens 扣掉才是「非快取價」該計費的量,否則會把快取部分
20
20
  // 重複計成原價。
21
+ // _docs/dispatch/v0_5_0/facts_output_cap.md §六:65,536(2^16)=實測單輪 max 53,391
22
+ // (deepseek-v4-flash,96.6% 是 reasoning)加約 22% 餘裕,且遠高於 OpenAI 官方建議的
23
+ // 25,000 下限。四家 adapter 共用同一個常數(見 responses.ts/gemini-native.ts/
24
+ // anthropic-messages.ts),改一處三家跟著動——不得在各 adapter 各自寫死字面量。
25
+ export const OUTPUT_TOKEN_CAP = 65_536;
26
+ // 票 cost-cap §D:實測 75 次 spoke 級派工最多只跑到 15 輪(facts_output_cap.md §四),
27
+ // 降到 20 不影響任何一次既有派工;輪數對成本上界是線性的(estimateHardCapUsd)。
28
+ // 放在這裡(不放 cli-args.ts)是為了讓 ticket-store.ts 也能匯入而不與 cli-args.ts 互相
29
+ // 匯入成環——cli-args.ts 經 mcp/http.ts → mcp/protocol.ts 會匯回 ticket-store.ts。
30
+ export const DEFAULT_MAX_TOOL_CALLS = 20;
21
31
  // exhaustive 對照表,缺一支會被 TypeScript 擋下——與 usage.ts 的 usageProviderKeyFor
22
32
  // 同樣理由:不留「其餘」預設分支,逼新增第四家 provider 時必須手動同步。
23
33
  const REASONING_INCLUDED_IN_OUTPUT = {
@@ -52,3 +62,17 @@ export function estimateCostUsd(usage, api, pricing) {
52
62
  (cacheWriteTokens / 1_000_000) * cacheWritePricePerM +
53
63
  (billableOutputTokens / 1_000_000) * pricing.outputPerM);
54
64
  }
65
+ // 票 cost-cap §B:呼叫前的絕對上界,不是預測——與 estimateCostUsd(事後實算)互不相干,
66
+ // 不吃 usage。R = maxToolCalls + 1(最後一輪不呼叫 tool,見 §D/cli-args.ts);
67
+ // 每輪都可能撞到 output 天花板,且最壞情況下每輪重送全部初始 prompt(無 cached 折扣)。
68
+ // 實測顯示這是實際花費的 20–30 倍(facts_output_cap.md §六)——那是上界的本質,不是算錯;
69
+ // 不得為了讓數字好看而調整這支算式,要調的是輪數上限(maxToolCalls)。
70
+ // 缺價目資料時回傳 null,不是 0——與 estimateCostUsd 同一條規矩(§4)。
71
+ export function estimateHardCapUsd(estimatedPromptTokens, maxToolCalls, pricing) {
72
+ if (!pricing)
73
+ return null;
74
+ const rounds = maxToolCalls + 1;
75
+ const outputCapTokens = OUTPUT_TOKEN_CAP * rounds;
76
+ const inputCapTokens = estimatedPromptTokens * rounds;
77
+ return (inputCapTokens / 1_000_000) * pricing.inputPerM + (outputCapTokens / 1_000_000) * pricing.outputPerM;
78
+ }
package/dist/daemon.js ADDED
@@ -0,0 +1,209 @@
1
+ import { execFileSync, spawn } from "node:child_process";
2
+ import { fileURLToPath } from "node:url";
3
+ import { hasAnyActiveToken } from "./api-token.js";
4
+ import { defaultDbPath, openDb } from "./db.js";
5
+ import { claimNextJob, finishJob, heartbeatJob, reapInterruptedJobs } from "./job.js";
6
+ import { DAEMON_STALE_MS, daemonAlive, daemonStatePath, writeHeartbeat } from "./liveness.js";
7
+ import { createHttpServer, DEFAULT_HTTP_PORT } from "./mcp/http.js";
8
+ import { maskString } from "./mask.js";
9
+ import { m } from "./messages.js";
10
+ export const DEFAULT_DAEMON_CONCURRENCY = 2;
11
+ export const DAEMON_TICK_MS = 500;
12
+ export const DAEMON_HEARTBEAT_MS = 5_000;
13
+ // B(gaps #6):reap 先前只在 daemon 啟動那一刻跑一次,所以 daemon 死了又沒人重開,
14
+ // job 會**永遠**停在 running——只讀 status 的輪詢器等不到終態。
15
+ // (既有那條測試的名字寫「不讓**重啟後**狀態永遠卡住」,它保證的正是「有重啟才不卡住」。)
16
+ // 週期跑不會誤傷自己的 job:worker 的心跳每 2 秒寫一次,門檻是 15 秒,中間有 7 倍餘裕。
17
+ export const DAEMON_REAP_MS = 5_000;
18
+ export function resultCost(db, jobId) {
19
+ const row = db.prepare("SELECT SUM(cost_usd) AS cost FROM results WHERE job_id = ?").get(jobId);
20
+ return row.cost;
21
+ }
22
+ export function createDaemon(db, options) {
23
+ const maxConcurrent = options.maxConcurrent ?? DEFAULT_DAEMON_CONCURRENCY;
24
+ if (!Number.isInteger(maxConcurrent) || maxConcurrent < 1)
25
+ throw new Error("daemon concurrency must be a positive integer");
26
+ const statePath = daemonStatePath(options.dbPath);
27
+ const intervals = [];
28
+ const running = new Set();
29
+ const nowMs = options.now ?? Date.now;
30
+ const schedule = options.setInterval ?? setInterval;
31
+ const cancel = options.clearInterval ?? clearInterval;
32
+ const beat = () => writeHeartbeat(statePath, options.pid);
33
+ const run = (job) => {
34
+ running.add(job.id);
35
+ const jobHeartbeat = schedule(() => heartbeatJob(db, job.id), 2_000);
36
+ void options
37
+ .execute(job)
38
+ .then((outcome) => finishJob(db, job.id, { error: outcome.error, costUsd: resultCost(db, job.id) }))
39
+ .catch((err) => finishJob(db, job.id, { error: String(err), costUsd: resultCost(db, job.id) }))
40
+ .finally(() => {
41
+ cancel(jobHeartbeat);
42
+ running.delete(job.id);
43
+ });
44
+ };
45
+ const tick = () => {
46
+ while (running.size < maxConcurrent) {
47
+ const job = claimNextJob(db);
48
+ if (!job)
49
+ break;
50
+ run(job);
51
+ }
52
+ };
53
+ // staleBefore 每次重算——先前是在啟動時算一次的區域變數,週期化之後若沿用那個值,
54
+ // 跑久了門檻會離現在越來越遠,最後等於不再 reap。
55
+ const reap = () => {
56
+ const reapedAt = new Date(nowMs()).toISOString();
57
+ return reapInterruptedJobs(db, new Date(nowMs() - DAEMON_STALE_MS).toISOString(), (jobId) => resultCost(db, jobId), reapedAt);
58
+ };
59
+ reap();
60
+ beat();
61
+ intervals.push(schedule(beat, DAEMON_HEARTBEAT_MS), schedule(tick, DAEMON_TICK_MS), schedule(reap, DAEMON_REAP_MS));
62
+ return {
63
+ tick,
64
+ stop: () => intervals.forEach(cancel),
65
+ running: () => running.size,
66
+ };
67
+ }
68
+ // 8 KiB retains several complete diagnostic lines while bounding daemon memory and DB error
69
+ // rows; CLI failures normally put their actionable summary at the end of stderr.
70
+ export const WORKER_STDERR_TAIL_BYTES = 8 * 1024;
71
+ const DEFAULT_RUN_CLI_JOB_DEPS = { spawn, stderr: process.stderr };
72
+ export function workerFailureMessage(lang, code, signal, stderr) {
73
+ const detail = maskString(stderr.trim());
74
+ if (signal)
75
+ return detail ? m(lang, "daemonWorkerTerminatedDetail", signal, detail) : m(lang, "daemonWorkerTerminated", signal);
76
+ const exitCode = code ?? "unknown";
77
+ return detail ? m(lang, "daemonWorkerExitedDetail", exitCode, detail) : m(lang, "daemonWorkerExited", exitCode);
78
+ }
79
+ export function runCliJob(job, dbPath, lang = "en", deps = DEFAULT_RUN_CLI_JOB_DEPS) {
80
+ const sourceMode = fileURLToPath(import.meta.url).endsWith(".ts");
81
+ const cliPath = fileURLToPath(new URL(sourceMode ? "./cli.ts" : "./cli.js", import.meta.url));
82
+ 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];
85
+ return new Promise((resolve) => {
86
+ const child = deps.spawn(process.execPath, args, { stdio: ["inherit", "inherit", "pipe"], env: process.env });
87
+ let stderrTail = Buffer.alloc(0);
88
+ child.stderr?.on("data", (chunk) => {
89
+ // Preserve the daemon's own stderr/log stream while retaining a bounded tail for jobs.error.
90
+ deps.stderr.write(chunk);
91
+ stderrTail = Buffer.concat([stderrTail, chunk]).subarray(-WORKER_STDERR_TAIL_BYTES);
92
+ });
93
+ child.once("error", (err) => resolve({ error: m(lang, "daemonWorkerSpawnFailed", maskString(String(err))) }));
94
+ child.once("exit", (code, signal) => {
95
+ if (code === 0)
96
+ resolve({});
97
+ else
98
+ resolve({ error: workerFailureMessage(lang, code, signal, stderrTail.toString("utf8")) });
99
+ });
100
+ });
101
+ }
102
+ const STOP_CHECK_INTERVAL_MS = 100;
103
+ const STOP_CHECK_ATTEMPTS = 10;
104
+ const STOP_RESTART_WAIT_MS = 500;
105
+ const DEFAULT_STOP_DAEMON_DEPS = {
106
+ liveness: (statePath) => daemonAlive(statePath),
107
+ countRunningJobs: (dbPath) => {
108
+ const row = openDb(dbPath).prepare("SELECT COUNT(*) AS count FROM jobs WHERE status = 'running'").get();
109
+ return row.count;
110
+ },
111
+ processCommand: (pid) => {
112
+ try {
113
+ return execFileSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
114
+ }
115
+ catch {
116
+ return null;
117
+ }
118
+ },
119
+ kill: (pid, signal) => process.kill(pid, signal),
120
+ wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
121
+ };
122
+ function isDowafuProcess(command) {
123
+ return command !== null && /(?:\bdowafu\b|\bcli\.(?:ts|js)\b)/.test(command);
124
+ }
125
+ export async function stopDaemon(dbPath = defaultDbPath(), options = {}) {
126
+ const deps = options.deps ?? DEFAULT_STOP_DAEMON_DEPS;
127
+ const statePath = daemonStatePath(dbPath);
128
+ const initial = deps.liveness(statePath);
129
+ if (!initial.alive)
130
+ 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
+ if (!isDowafuProcess(deps.processCommand(initial.pid)))
138
+ return { status: "pid_not_dowafu", pid: initial.pid };
139
+ try {
140
+ deps.kill(initial.pid, "SIGTERM");
141
+ }
142
+ catch {
143
+ return { status: "signal_failed", pid: initial.pid };
144
+ }
145
+ for (let attempt = 0; attempt < STOP_CHECK_ATTEMPTS; attempt++) {
146
+ await deps.wait(STOP_CHECK_INTERVAL_MS);
147
+ if (!deps.liveness(statePath).alive) {
148
+ await deps.wait(STOP_RESTART_WAIT_MS);
149
+ return deps.liveness(statePath).alive ? { status: "restarted", pid: initial.pid } : { status: "stopped", pid: initial.pid };
150
+ }
151
+ }
152
+ return { status: "signal_failed", pid: initial.pid };
153
+ }
154
+ function bindHttp(db, dbPath, httpPort) {
155
+ return new Promise((resolve, reject) => {
156
+ // 工單 C §零裁示 1:只綁 127.0.0.1,沒有 --host——公開與否是 tunnel 的事。
157
+ const server = createHttpServer({ db, dbPath });
158
+ const onError = (err) => {
159
+ server.removeListener("listening", onListening);
160
+ reject(err);
161
+ };
162
+ const onListening = () => {
163
+ server.removeListener("error", onError);
164
+ resolve(server);
165
+ };
166
+ server.once("error", onError);
167
+ server.once("listening", onListening);
168
+ server.listen(httpPort, "127.0.0.1");
169
+ });
170
+ }
171
+ // 工單 C §D:HTTP 起不來時,整個 serve 失敗(不降級成「只有 stdio 能用的 daemon」)。
172
+ // worker/心跳只在 HTTP 成功綁定之後才建立,兩者要嘛一起活、要嘛都不起——一個活著但另一個
173
+ // 靜默死掉,正是「失敗長得像成功」的形態,本票明文選擇不要那樣。
174
+ //
175
+ // `serve` 本身刻意不是 async function:daemon-already-running 那個守衛必須維持同步拋出
176
+ // (既有測試 `assert.throws(() => serve(dbPath), ...)` 依賴這一點),只有 HTTP 綁定之後
177
+ // 那段才回傳 Promise。
178
+ export function serve(dbPath = defaultDbPath(), maxConcurrent = Number(process.env.DOWAFU_MAX_CONCURRENT ?? DEFAULT_DAEMON_CONCURRENCY), httpPort = Number(process.env.DOWAFU_HTTP_PORT ?? DEFAULT_HTTP_PORT), lang = "en") {
179
+ const state = daemonAlive(daemonStatePath(dbPath));
180
+ if (state.alive)
181
+ throw new Error(`daemon already running (pid ${state.pid})`);
182
+ const db = openDb(dbPath);
183
+ return bindHttp(db, dbPath, httpPort).then((httpServer) => {
184
+ let daemon;
185
+ try {
186
+ daemon = createDaemon(db, { dbPath, maxConcurrent, execute: (job) => runCliJob(job, dbPath, lang) });
187
+ }
188
+ catch (err) {
189
+ httpServer.close();
190
+ throw err;
191
+ }
192
+ process.on("SIGINT", () => {
193
+ daemon.stop();
194
+ httpServer.close();
195
+ process.exit(0);
196
+ });
197
+ const address = httpServer.address();
198
+ const boundPort = typeof address === "object" && address ? address.port : httpPort;
199
+ return {
200
+ ...daemon,
201
+ stop: () => {
202
+ daemon.stop();
203
+ httpServer.close();
204
+ },
205
+ httpPort: boundPort,
206
+ hasActiveToken: hasAnyActiveToken(db),
207
+ };
208
+ });
209
+ }
package/dist/db.js ADDED
@@ -0,0 +1,219 @@
1
+ // D1: the only module that knows about SQLite. Keep all schema and driver details here.
2
+ import { mkdirSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { resolveDispatchHome } from "./dispatch-home.js";
5
+ import { DispatchError } from "./types.js";
6
+ import { m } from "./messages.js";
7
+ let warningFilterInstalled = false;
8
+ export function installSqliteWarningFilter() {
9
+ if (warningFilterInstalled)
10
+ return;
11
+ warningFilterInstalled = true;
12
+ const listeners = process.listeners("warning");
13
+ process.removeAllListeners("warning");
14
+ process.on("warning", (warning) => {
15
+ if (warning.name === "ExperimentalWarning" && /SQLite/i.test(warning.message))
16
+ return;
17
+ for (const listener of listeners)
18
+ listener(warning);
19
+ });
20
+ }
21
+ // The warning is emitted as node:sqlite is loaded, so the filter must exist before
22
+ // the runtime import (a static import is too early for that ordering).
23
+ installSqliteWarningFilter();
24
+ const { DatabaseSync: DatabaseSyncDriver } = await import("node:sqlite");
25
+ export function defaultDbPath() {
26
+ const home = resolveDispatchHome();
27
+ return process.env.DOWAFU_DB ?? path.join(home ?? process.cwd(), "dowafu.db");
28
+ }
29
+ const MIGRATIONS = [
30
+ {
31
+ version: 1,
32
+ up: (db) => {
33
+ db.exec(`
34
+ CREATE TABLE IF NOT EXISTS tickets (
35
+ name TEXT PRIMARY KEY,
36
+ doc TEXT NOT NULL,
37
+ created_at TEXT NOT NULL
38
+ );
39
+ CREATE TABLE IF NOT EXISTS jobs (
40
+ id TEXT PRIMARY KEY,
41
+ ticket TEXT NOT NULL,
42
+ status TEXT NOT NULL,
43
+ created_at TEXT NOT NULL,
44
+ approved_at TEXT,
45
+ claimed_at TEXT,
46
+ heartbeat_at TEXT,
47
+ finished_at TEXT,
48
+ error TEXT,
49
+ cost_usd REAL
50
+ );
51
+ CREATE TABLE IF NOT EXISTS results (
52
+ job_id TEXT NOT NULL,
53
+ agent TEXT NOT NULL,
54
+ output TEXT,
55
+ request TEXT,
56
+ response TEXT,
57
+ tokens_in INTEGER,
58
+ tokens_out INTEGER,
59
+ cost_usd REAL,
60
+ PRIMARY KEY (job_id, agent)
61
+ );
62
+
63
+ -- providers/models mirror providers.json's two levels instead of flattening them.
64
+ -- Flattening would repeat every provider-level field on each model row, and the
65
+ -- import merge would then have to reconcile rows of the same provider disagreeing.
66
+ -- 'reasoning' and 'pricing_source' stay as JSON text: they are nested config that
67
+ -- is read whole, never filtered on.
68
+ CREATE TABLE IF NOT EXISTS providers (
69
+ name TEXT PRIMARY KEY,
70
+ base_url TEXT NOT NULL,
71
+ api TEXT NOT NULL,
72
+ store INTEGER,
73
+ tool_calling INTEGER NOT NULL,
74
+ reasoning TEXT NOT NULL,
75
+ chars_per_token REAL,
76
+ tpm_limit INTEGER,
77
+ max_spoke_tokens INTEGER,
78
+ pricing_source TEXT,
79
+ updated_at TEXT NOT NULL
80
+ );
81
+ -- One row per model serves both roles providers.json splits across 'models[]' and
82
+ -- 'pricing{}': the row's existence is the whitelist, its price columns are the
83
+ -- pricing, and 'enabled' is the new gate. Price columns stay nullable because a
84
+ -- whitelisted model may have no pricing data — "cannot estimate" and "costs zero"
85
+ -- must remain distinguishable (see cost.ts).
86
+ CREATE TABLE IF NOT EXISTS models (
87
+ provider TEXT NOT NULL,
88
+ model TEXT NOT NULL,
89
+ input_per_m REAL,
90
+ cached_input_per_m REAL,
91
+ cache_write_per_m REAL,
92
+ output_per_m REAL,
93
+ enabled INTEGER NOT NULL DEFAULT 0,
94
+ updated_at TEXT NOT NULL,
95
+ PRIMARY KEY (provider, model)
96
+ );
97
+ -- Inbound HTTP credentials. Only ever compared, never replayed, so the hash is
98
+ -- all we keep. Provider API keys are the opposite case and deliberately do not
99
+ -- share this table: they have to be handed to the vendor verbatim.
100
+ CREATE TABLE IF NOT EXISTS tokens (
101
+ id TEXT PRIMARY KEY,
102
+ hash TEXT NOT NULL,
103
+ label TEXT,
104
+ created_at TEXT NOT NULL,
105
+ revoked_at TEXT
106
+ );
107
+
108
+ CREATE INDEX IF NOT EXISTS idx_jobs_ticket_status ON jobs (ticket, status);
109
+ CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs (status);
110
+ CREATE INDEX IF NOT EXISTS idx_models_enabled ON models (enabled);
111
+ `);
112
+ },
113
+ },
114
+ // T1 (E12): outbound provider API keys. Deliberately NOT the `tokens` table — that one
115
+ // holds *inbound* HTTP credentials and stores only a hash, while these have to be handed
116
+ // back in cleartext to call the provider. Opposite storage requirements, separate tables.
117
+ //
118
+ // Stored in cleartext, and that is the decision on record (decision_mcp.md E12): the win
119
+ // here is operational (set once via CLI, no file to chmod, works on Windows), not security
120
+ // — a SQLite file is as readable as the .env it replaces. Do not add encryption that
121
+ // would only look like protection.
122
+ {
123
+ version: 2,
124
+ up: (db) => {
125
+ db.exec(`
126
+ CREATE TABLE IF NOT EXISTS provider_keys (
127
+ provider TEXT PRIMARY KEY,
128
+ api_key TEXT NOT NULL,
129
+ created_at TEXT NOT NULL,
130
+ updated_at TEXT NOT NULL
131
+ );
132
+ `);
133
+ },
134
+ },
135
+ // B(gaps #6):跑的過程中看得到進度。`results` 每支 spoke 只在**跑完的那一刻**寫一次,
136
+ // 所以單支 spoke 的工單在跑完之前完全沒有訊號——最壞安靜十分鐘,而 skill 只能寫
137
+ // 「安靜不代表當掉」。這張表補的就是那段空白。
138
+ //
139
+ // **每輪 append 一列,不是 update 一列。** 「哪一輪慢」是 `RunLogWriter` 退場時明文記為
140
+ // 降級的四項之一(_archived/log.md §七),逐列才留得住輪級時間戳;更新同一列只留得下
141
+ // 最後一輪。列數上限是 maxToolCalls(20)+1,代價可以忽略。
142
+ //
143
+ // **這張表刻意與 `results` 分開。** `results` 是寫一次的已付費產出,`persistSpokeResultDb`
144
+ // 對「實質不同的重寫」會撞 primary-key constraint 炸出來——那是 D1-hot3 為了
145
+ // 「落檔失敗不得銷毀已付費結果」建的守衛。逐輪寫進度本質上就是「每次都不一樣的寫入」,
146
+ // 混進去會正面撞上它。進度是可丟棄的診斷資料,兩者的耐久性要求本來就不同。
147
+ //
148
+ // 欄位用 `result_id` 而不是比照 `results` 的 `job_id`:那一欄存的其實是
149
+ // `jobId ?? ticketId`(前景直接跑 CLI 時沒有 job),叫 job_id 是既有的名實不符,
150
+ // 新表不跟進。**用同一個值當 key,所以前景那條路也拿得到進度。**
151
+ {
152
+ version: 3,
153
+ up: (db) => {
154
+ db.exec(`
155
+ CREATE TABLE IF NOT EXISTS progress (
156
+ result_id TEXT NOT NULL,
157
+ agent TEXT NOT NULL,
158
+ round INTEGER NOT NULL,
159
+ at TEXT NOT NULL,
160
+ tokens_in INTEGER NOT NULL,
161
+ tokens_out INTEGER NOT NULL,
162
+ has_tool_calls INTEGER NOT NULL,
163
+ PRIMARY KEY (result_id, agent, round)
164
+ );
165
+ `);
166
+ },
167
+ },
168
+ // key-visibility §B:key 本身更新的時間不能冒充「最後驗證」;三個 nullable 欄位把
169
+ // 從未測過、測過成功、測過失敗分開。正常升級只會對 v3 DB 跑一次,但檢查既有欄位,
170
+ // 仍讓中斷後重跑或測試回退的 migration 安全,不把 ALTER TABLE 當成有 IF NOT EXISTS。
171
+ {
172
+ version: 4,
173
+ up: (db) => {
174
+ const columns = new Set(db.prepare("PRAGMA table_info(provider_keys)").all().map((column) => column.name));
175
+ if (!columns.has("last_tested_at"))
176
+ db.exec("ALTER TABLE provider_keys ADD COLUMN last_tested_at TEXT");
177
+ if (!columns.has("last_test_model"))
178
+ db.exec("ALTER TABLE provider_keys ADD COLUMN last_test_model TEXT");
179
+ if (!columns.has("last_test_succeeded"))
180
+ db.exec("ALTER TABLE provider_keys ADD COLUMN last_test_succeeded INTEGER");
181
+ },
182
+ },
183
+ ];
184
+ const LATEST_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
185
+ function migrate(db, lang) {
186
+ const current = Number(db.prepare("PRAGMA user_version").get().user_version);
187
+ // Fail closed on a DB written by a newer dowafu. Carrying on would mean an older binary
188
+ // reading a schema it does not know, which reads as working right up until it does not.
189
+ if (current > LATEST_SCHEMA_VERSION) {
190
+ throw new DispatchError(m(lang, "dbSchemaTooNew", current, LATEST_SCHEMA_VERSION), 2);
191
+ }
192
+ for (const migration of MIGRATIONS) {
193
+ if (migration.version <= current)
194
+ continue;
195
+ db.exec("BEGIN");
196
+ try {
197
+ migration.up(db);
198
+ // PRAGMA takes no bound parameters; the value is an integer literal from MIGRATIONS.
199
+ db.exec(`PRAGMA user_version = ${migration.version}`);
200
+ db.exec("COMMIT");
201
+ }
202
+ catch (err) {
203
+ db.exec("ROLLBACK");
204
+ throw err;
205
+ }
206
+ }
207
+ }
208
+ export function openDb(dbPath = defaultDbPath(), lang = "en") {
209
+ installSqliteWarningFilter();
210
+ if (dbPath !== ":memory:")
211
+ mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });
212
+ const db = new DatabaseSyncDriver(dbPath);
213
+ db.exec("PRAGMA journal_mode = WAL");
214
+ db.exec("PRAGMA busy_timeout = 5000");
215
+ migrate(db, lang);
216
+ return db;
217
+ }
218
+ export { LATEST_SCHEMA_VERSION };
219
+ export const now = () => new Date().toISOString();
@@ -1,12 +1,12 @@
1
- // plan_dispatch_v1.10.md §24.4:API key 的載入順序與位置。ambient process.env 優先
2
- // (CI、一次性覆寫),其次 $DISPATCH_HOME/.env;dotenv 預設不覆寫既有變數,故 ambient
3
- // 優先自然成立,不需額外邏輯。
1
+ // `$DISPATCH_HOME` 的解析。**0.5.0 起本檔不再載入任何 `.env`**(使用者裁示,2026-09-06):
2
+ // API key 的唯一來源是 DB 的 `provider_keys` 表,見 provider-key.ts 的檔頭。
4
3
  //
5
- // 明文禁令:dispatch 不得讀取 cwd `.env`——cwd 是被審專案,`import "dotenv/config"`
6
- // 會把該專案的整份 `.env`(資料庫密碼、第三方 token、webhook secret)載入一個正對三家
7
- // 外部 API 發請求的行程,而 §12 的遮蔽名單認不出那些秘密。此檔取代原本
8
- // `src/cli.ts` 頂部的 `import "dotenv/config"`。
9
- import dotenv from "dotenv";
4
+ // 原本這裡有一支 `loadDispatchEnv`,把 `$DISPATCH_HOME/.env` 灌進 process.env,並為了讓
5
+ // `--doctor` 分辨「ambient 填的」與「.env 填的」而在載入前後各拍一次快照。單一來源之後
6
+ // 那整段連同 dotenv 依賴一起退場——**沒有那段程式,就沒有優先序與來源分辨的問題**。
7
+ //
8
+ // v1.10 §24.4 的禁令(不得讀 cwd 的 `.env`)現在由「根本不依賴 dotenv」保證,比原本的
9
+ // 「只准讀指定路徑」更硬;`dotenv-invariant.test.ts` 的靜態掃描續留,防止有人加回來。
10
10
  import os from "node:os";
11
11
  import path from "node:path";
12
12
  // plan_i18n_v1.3.md §三:這兩個函式現在跑在 parseArgs/--help 之前(見 cli.ts),任何
@@ -28,14 +28,3 @@ export function resolveDispatchHome(env = process.env, homedir = os.homedir) {
28
28
  return null;
29
29
  }
30
30
  }
31
- // 唯一允許呼叫 dotenv 的地方——`path` 一律明確指定為 dispatchHome 下的 `.env`,
32
- // 絕不留白(留白即回退成 dotenv 預設讀 cwd 的 `.env`,正是本函式存在的目的所要杜絕的)。
33
- // 維持 `void` 簽名:讀取失敗一律降級為「沒有設定檔」(見上方註解),呼叫端不需要狀態。
34
- export function loadDispatchEnv(dispatchHome) {
35
- try {
36
- dotenv.config({ path: path.join(dispatchHome, ".env") });
37
- }
38
- catch {
39
- // .env 是目錄/無讀取權限/內容畸形等,全部降級,不中止、不拋。
40
- }
41
- }