@luckychen1993/pm-mcp 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -5
- package/dist/acceptance-tools.js +6 -6
- package/dist/audit-tools.js +14 -13
- package/dist/audit.js +10 -5
- package/dist/governance-tools.js +11 -11
- package/dist/idempotency.js +427 -0
- package/dist/index-store.js +163 -94
- package/dist/index.js +28 -17
- package/dist/knowledge-tools.js +5 -5
- package/dist/license.js +7 -4
- package/dist/paths.js +35 -0
- package/dist/project-tools.js +4 -4
- package/dist/search.js +2 -2
- package/dist/security.js +15 -6
- package/dist/setup.js +1 -1
- package/dist/store.js +185 -35
- package/dist/task-tools.js +2 -2
- package/dist/tool-base.js +33 -10
- package/dist/tools.js +1 -1
- package/dist/watcher-coordinator.js +182 -0
- package/package.json +1 -1
package/dist/security.js
CHANGED
|
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { loadExtraRules, loadProject, loadSecurity, saveSecurity } from "./store.js";
|
|
3
3
|
import { latestSnapshot } from "./audit.js";
|
|
4
4
|
import { readDirectDeps } from "./scan.js";
|
|
5
|
-
import { ensureFresh, getIndex, iterateFileRows } from "./index-store.js";
|
|
5
|
+
import { ensureFresh, getIndex, iterateFileRows, walkRefresh } from "./index-store.js";
|
|
6
6
|
import { readText } from "./search.js";
|
|
7
7
|
import { now } from "./types.js";
|
|
8
8
|
const SECRET_PATTERNS = [
|
|
@@ -69,7 +69,7 @@ function findSecretRule(value) {
|
|
|
69
69
|
function fingerprint(ruleId, rel, matched) {
|
|
70
70
|
return createHash("sha1").update(ruleId + "|" + rel + "|" + matched).digest("hex").slice(0, 16);
|
|
71
71
|
}
|
|
72
|
-
function scanDetections(root) {
|
|
72
|
+
function scanDetections(root, forceContent = false, indexPrepared = false) {
|
|
73
73
|
const out = [];
|
|
74
74
|
const rules = [...SECRET_PATTERNS, ...DANGEROUS_PATTERNS];
|
|
75
75
|
// 用户自定义规则
|
|
@@ -87,7 +87,8 @@ function scanDetections(root) {
|
|
|
87
87
|
/* 非法自定义规则跳过 */
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
|
-
|
|
90
|
+
if (!indexPrepared)
|
|
91
|
+
ensureFresh(root);
|
|
91
92
|
const db = getIndex(root);
|
|
92
93
|
// .env 类文件存在性检查(是否被 git 提交需人工确认)
|
|
93
94
|
for (const row of iterateFileRows(db, { excludeOversize: false })) {
|
|
@@ -109,7 +110,7 @@ function scanDetections(root) {
|
|
|
109
110
|
continue;
|
|
110
111
|
if (rel.startsWith(".pm/"))
|
|
111
112
|
continue;
|
|
112
|
-
const content = readText(root, rel);
|
|
113
|
+
const content = readText(root, rel, forceContent);
|
|
113
114
|
if (content === null)
|
|
114
115
|
continue;
|
|
115
116
|
const lines = content.split("\n");
|
|
@@ -134,11 +135,19 @@ function scanDetections(root) {
|
|
|
134
135
|
}
|
|
135
136
|
return out;
|
|
136
137
|
}
|
|
138
|
+
/** 昂贵的全仓扫描阶段;调用方可在账本锁外完成,再把脱敏发现提交到账本。 */
|
|
139
|
+
export function prepareSecurityAudit(root, options = {}) {
|
|
140
|
+
const forceIndex = options.forceIndex ?? true;
|
|
141
|
+
const forceContent = options.forceContent ?? true;
|
|
142
|
+
if (forceIndex)
|
|
143
|
+
walkRefresh(root, { forceContent: true });
|
|
144
|
+
return { detections: scanDetections(root, forceContent, forceIndex || options.indexPrepared === true) };
|
|
145
|
+
}
|
|
137
146
|
/** 扫描并更新台账(幂等:重复扫描不产生重复条目) */
|
|
138
|
-
export function auditSecurity(root) {
|
|
147
|
+
export function auditSecurity(root, prepared) {
|
|
139
148
|
const project = loadProject(root);
|
|
140
149
|
const stamp = now();
|
|
141
|
-
const detections = scanDetections(root);
|
|
150
|
+
const detections = prepared?.detections ?? scanDetections(root);
|
|
142
151
|
const security = loadSecurity(root);
|
|
143
152
|
const existing = new Map(security.findings.map((f) => [f.fingerprint, f]));
|
|
144
153
|
const acceptedRefound = [];
|
package/dist/setup.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
|
-
export const PACKAGE_SPEC = "@luckychen1993/pm-mcp@0.1.
|
|
5
|
+
export const PACKAGE_SPEC = "@luckychen1993/pm-mcp@0.1.4";
|
|
6
6
|
export const SERVER_NAME = "pm-mcp";
|
|
7
7
|
const CLIENTS = ["codex", "claude", "zcode", "cursor", "vscode"];
|
|
8
8
|
export function parseSetupArgs(argv) {
|
package/dist/store.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { DatabaseSync } from "node:sqlite";
|
|
3
5
|
import { DebugLogFileSchema, FeaturesFileSchema, FileNotesFileSchema, now, ProjectSchema, RoadmapFileSchema, SecurityFileSchema, SessionsFileSchema, TasksFileSchema, } from "./types.js";
|
|
4
6
|
import { DEBUGLOG_JSON, EXTRA_RULES_FILE, FEATURES_JSON, FILE_NOTES_JSON, PROJECT_JSON, ROADMAP_JSON, SECURITY_JSON, SESSIONS_JSON, TASKS_JSON, pmPath, } from "./paths.js";
|
|
5
7
|
import { ExtraRulesFileSchema } from "./types.js";
|
|
@@ -37,60 +39,208 @@ export function atomicWrite(file, content) {
|
|
|
37
39
|
export function saveJson(file, data) {
|
|
38
40
|
atomicWrite(file, JSON.stringify(data, null, 2) + "\n");
|
|
39
41
|
}
|
|
42
|
+
const FILE_LOCK_TIMEOUT_MS = 3_000;
|
|
43
|
+
const FILE_LOCK_STALE_MS = 10_000;
|
|
44
|
+
const FILE_LOCK_POLL_MS = 20;
|
|
45
|
+
const FILE_LOCK_PROTOCOL = "sqlite-guard-v1";
|
|
46
|
+
const FILE_LOCK_WAIT = new Int32Array(new SharedArrayBuffer(4));
|
|
47
|
+
function readFileLockOwner(lockFile) {
|
|
48
|
+
let raw;
|
|
49
|
+
try {
|
|
50
|
+
raw = fs.readFileSync(lockFile, "utf8");
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (error.code === "ENOENT")
|
|
54
|
+
return null;
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const parsed = JSON.parse(raw);
|
|
59
|
+
if (!Number.isSafeInteger(parsed.pid) || parsed.pid <= 0)
|
|
60
|
+
return null;
|
|
61
|
+
return {
|
|
62
|
+
pid: parsed.pid,
|
|
63
|
+
...(typeof parsed.token === "string" && parsed.token.length > 0 ? { token: parsed.token } : {}),
|
|
64
|
+
...(typeof parsed.created_at === "string" && parsed.created_at.length > 0 ? { created_at: parsed.created_at } : {}),
|
|
65
|
+
...(typeof parsed.protocol === "string" && parsed.protocol.length > 0 ? { protocol: parsed.protocol } : {}),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
const pid = Number(raw.trim());
|
|
70
|
+
return Number.isSafeInteger(pid) && pid > 0 ? { pid } : null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function fileLockOwnerAlive(pid) {
|
|
74
|
+
try {
|
|
75
|
+
process.kill(pid, 0);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
// EPERM proves that the process exists even when signalling it is forbidden.
|
|
80
|
+
return error.code === "EPERM";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function sqliteBusy(error) {
|
|
84
|
+
const candidate = error;
|
|
85
|
+
return candidate.errcode === 5 ||
|
|
86
|
+
(typeof candidate.message === "string" && /database is (?:locked|busy)/i.test(candidate.message));
|
|
87
|
+
}
|
|
88
|
+
function rollbackRecoveryGuard(db) {
|
|
89
|
+
if (!db.isTransaction)
|
|
90
|
+
return;
|
|
91
|
+
try {
|
|
92
|
+
db.exec("ROLLBACK");
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// close() below is the final fail-safe and also rolls an open transaction back.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
40
98
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
99
|
+
* 通用跨进程文件锁。SQLite 写事务覆盖 .lock 的检查、接管、创建、调用方临界区和
|
|
100
|
+
* 释放,从而消除 check-then-rm ABA,并在进程崩溃时由操作系统释放真实所有权。
|
|
101
|
+
* 成功取得同一 guard 后,带当前 protocol 的残留 .lock 可立即核销,即使 PID 已复用;
|
|
102
|
+
* 无 protocol 的旧版锁仍须「超过 staleMs 且 PID 已死」才接管,以兼容滚动升级。
|
|
103
|
+
* 释放必须完整匹配 protocol、PID、token 与 created_at,否则保留并 fail-closed。
|
|
104
|
+
* 共享同一 guardFile 的不同 lockFile 会串行,调用方应只在短元数据事务中共享 guard。
|
|
44
105
|
*/
|
|
45
|
-
export function
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
const
|
|
106
|
+
export function withFileRecoveryLock(lockFile, fn, options = {}) {
|
|
107
|
+
const timeoutMs = options.timeoutMs ?? FILE_LOCK_TIMEOUT_MS;
|
|
108
|
+
const staleMs = options.staleMs ?? FILE_LOCK_STALE_MS;
|
|
109
|
+
const guardFile = options.guardFile ?? path.join(path.dirname(lockFile), ".file-recovery-guard.db");
|
|
110
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || !Number.isFinite(staleMs) || staleMs < 0) {
|
|
111
|
+
throw new RangeError("文件锁 timeoutMs 必须大于 0,staleMs 不得小于 0。");
|
|
112
|
+
}
|
|
113
|
+
fs.mkdirSync(path.dirname(lockFile), { recursive: true });
|
|
114
|
+
fs.mkdirSync(path.dirname(guardFile), { recursive: true });
|
|
115
|
+
const token = randomUUID();
|
|
116
|
+
const createdAt = new Date().toISOString();
|
|
117
|
+
const payload = JSON.stringify({ pid: process.pid, token, created_at: createdAt, protocol: FILE_LOCK_PROTOCOL });
|
|
118
|
+
const deadline = Date.now() + timeoutMs;
|
|
119
|
+
let fileLockHeld = false;
|
|
120
|
+
const acquireFileLock = () => {
|
|
49
121
|
try {
|
|
50
|
-
fs.writeFileSync(lockFile,
|
|
122
|
+
fs.writeFileSync(lockFile, payload, { flag: "wx" });
|
|
51
123
|
return true;
|
|
52
124
|
}
|
|
53
|
-
catch {
|
|
125
|
+
catch (error) {
|
|
126
|
+
if (error.code !== "EEXIST")
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
let stat;
|
|
130
|
+
try {
|
|
131
|
+
stat = fs.statSync(lockFile);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (error.code === "ENOENT")
|
|
135
|
+
return false;
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
const current = readFileLockOwner(lockFile);
|
|
139
|
+
const guardedResidual = current?.protocol === FILE_LOCK_PROTOCOL;
|
|
140
|
+
if (!guardedResidual && (Date.now() - stat.mtimeMs <= staleMs || (current && fileLockOwnerAlive(current.pid)))) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
// Every current-version acquirer and releaser holds the SQLite write transaction
|
|
144
|
+
// here, so no compliant peer can replace .lock between this removal and creation.
|
|
145
|
+
try {
|
|
146
|
+
fs.rmSync(lockFile);
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
if (error.code === "ENOENT")
|
|
150
|
+
return false;
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
fs.writeFileSync(lockFile, payload, { flag: "wx" });
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
if (error.code === "EEXIST")
|
|
159
|
+
return false;
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
const beginGuard = (db, until) => {
|
|
164
|
+
while (Date.now() < until) {
|
|
165
|
+
const remaining = until - Date.now();
|
|
166
|
+
if (remaining <= 0)
|
|
167
|
+
return false;
|
|
168
|
+
db.exec(`PRAGMA busy_timeout=${Math.max(1, Math.min(100, remaining))}`);
|
|
54
169
|
try {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
fs.rmSync(lockFile, { force: true });
|
|
58
|
-
try {
|
|
59
|
-
fs.writeFileSync(lockFile, String(process.pid), { flag: "wx" });
|
|
60
|
-
return true;
|
|
61
|
-
}
|
|
62
|
-
catch {
|
|
63
|
-
return false;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
170
|
+
db.exec("BEGIN IMMEDIATE");
|
|
171
|
+
return true;
|
|
66
172
|
}
|
|
67
|
-
catch {
|
|
68
|
-
|
|
173
|
+
catch (error) {
|
|
174
|
+
if (!sqliteBusy(error))
|
|
175
|
+
throw error;
|
|
176
|
+
const pause = Math.min(FILE_LOCK_POLL_MS, Math.max(0, until - Date.now()));
|
|
177
|
+
if (pause > 0)
|
|
178
|
+
Atomics.wait(FILE_LOCK_WAIT, 0, 0, pause);
|
|
69
179
|
}
|
|
70
|
-
return false;
|
|
71
180
|
}
|
|
181
|
+
return false;
|
|
72
182
|
};
|
|
73
|
-
|
|
74
|
-
for (let i = 0; i < 150 && !held; i++) {
|
|
75
|
-
held = acquire();
|
|
76
|
-
if (!held)
|
|
77
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
|
|
78
|
-
}
|
|
79
|
-
if (!held) {
|
|
80
|
-
throw new Error("账本锁获取超时(3s):另一进程正在写入本项目,请稍后重试。");
|
|
81
|
-
}
|
|
183
|
+
const acquisitionGuard = new DatabaseSync(guardFile);
|
|
82
184
|
try {
|
|
83
|
-
|
|
185
|
+
while (!fileLockHeld && Date.now() < deadline) {
|
|
186
|
+
if (!beginGuard(acquisitionGuard, deadline))
|
|
187
|
+
break;
|
|
188
|
+
try {
|
|
189
|
+
fileLockHeld = acquireFileLock();
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
rollbackRecoveryGuard(acquisitionGuard);
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
if (!fileLockHeld) {
|
|
196
|
+
rollbackRecoveryGuard(acquisitionGuard);
|
|
197
|
+
const pause = Math.min(FILE_LOCK_POLL_MS, Math.max(0, deadline - Date.now()));
|
|
198
|
+
if (pause > 0)
|
|
199
|
+
Atomics.wait(FILE_LOCK_WAIT, 0, 0, pause);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (!fileLockHeld) {
|
|
203
|
+
throw new Error(options.timeoutMessage ?? "文件锁获取超时(3s),请稍后重试。");
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
return fn();
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
try {
|
|
210
|
+
const current = readFileLockOwner(lockFile);
|
|
211
|
+
if (current?.protocol === FILE_LOCK_PROTOCOL &&
|
|
212
|
+
current.pid === process.pid &&
|
|
213
|
+
current.token === token &&
|
|
214
|
+
current.created_at === createdAt) {
|
|
215
|
+
fs.rmSync(lockFile);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
// Ownership cannot be proven: leave the file for guarded recovery.
|
|
220
|
+
}
|
|
221
|
+
}
|
|
84
222
|
}
|
|
85
223
|
finally {
|
|
224
|
+
rollbackRecoveryGuard(acquisitionGuard);
|
|
86
225
|
try {
|
|
87
|
-
|
|
226
|
+
acquisitionGuard.close();
|
|
88
227
|
}
|
|
89
228
|
catch {
|
|
90
|
-
/*
|
|
229
|
+
/* close after rollback is best effort */
|
|
91
230
|
}
|
|
92
231
|
}
|
|
93
232
|
}
|
|
233
|
+
/**
|
|
234
|
+
* 账本专用互斥锁:把「读账本 → 改 → 写」整个事务串行化。活着的长任务不会被误抢,
|
|
235
|
+
* 等待上限 3s,超时明确报错而非静默丢写。
|
|
236
|
+
*/
|
|
237
|
+
export function withLedgerLock(root, fn) {
|
|
238
|
+
const runtimeDirectory = pmPath(root, ".runtime");
|
|
239
|
+
return withFileRecoveryLock(pmPath(root, ".lock"), fn, {
|
|
240
|
+
guardFile: path.join(runtimeDirectory, "ledger-lock.db"),
|
|
241
|
+
timeoutMessage: "账本锁获取超时(3s):另一进程正在写入本项目,请稍后重试。",
|
|
242
|
+
});
|
|
243
|
+
}
|
|
94
244
|
export function loadJson(file, schema, fallback) {
|
|
95
245
|
if (!fs.existsSync(file))
|
|
96
246
|
return fallback;
|
package/dist/task-tools.js
CHANGED
|
@@ -4,7 +4,7 @@ import { foldLines, normSep } from "./budget.js";
|
|
|
4
4
|
import { requireInitialized } from "./paths.js";
|
|
5
5
|
import { loadRoadmap, loadTasks, nextId, saveTasks } from "./store.js";
|
|
6
6
|
import { now } from "./types.js";
|
|
7
|
-
import { budgetLines,
|
|
7
|
+
import { budgetLines, toolR, toolW } from "./tool-base.js";
|
|
8
8
|
const stepSchema = z.object({ text: z.string(), done: z.boolean().optional() });
|
|
9
9
|
export function registerTaskTools(server, root) {
|
|
10
10
|
toolW(server, root, "add_task", "添加任务。长任务务必给 steps(显式步骤清单,断点续做的载体);走了捷径 type=debt 登记(债务账);修 bug type=fix。", {
|
|
@@ -55,7 +55,7 @@ export function registerTaskTools(server, root) {
|
|
|
55
55
|
: "";
|
|
56
56
|
return `✅ 任务 ${id}「${args.title}」已创建(${args.type ?? "feature"}/${args.milestone ?? "无里程碑"})。${tip}`;
|
|
57
57
|
});
|
|
58
|
-
|
|
58
|
+
toolR(server, root, "list_tasks", "列出任务(默认不含 done/cancelled)。可按状态/类型/里程碑/标签过滤。", {
|
|
59
59
|
status: z.enum(["backlog", "todo", "in_progress", "blocked", "done", "cancelled"]).optional(),
|
|
60
60
|
type: z.enum(["feature", "refactor", "fix", "chore", "debt"]).optional(),
|
|
61
61
|
milestone: z.string().optional(),
|
package/dist/tool-base.js
CHANGED
|
@@ -1,21 +1,44 @@
|
|
|
1
|
-
import { loadProject
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import { loadProject } from "./store.js";
|
|
2
|
+
import { IdempotencyKeySchema, runCoalescedRead, runIdempotentWrite, runIdempotentWriteSync } from "./idempotency.js";
|
|
3
|
+
/** 只读工具:跨 Agent/进程合并完全相同的并行请求,短窗复用结果。 */
|
|
4
|
+
export function toolR(server, root, name, description, schema, fn) {
|
|
5
|
+
server.registerTool(name, { description, inputSchema: schema, annotations: { readOnlyHint: true } }, (async (args) => {
|
|
4
6
|
try {
|
|
5
|
-
const
|
|
6
|
-
return { content: [{ type: "text", text }] };
|
|
7
|
+
const result = await runCoalescedRead(root, name, args, fn);
|
|
8
|
+
return { content: [{ type: "text", text: result.text }] };
|
|
7
9
|
}
|
|
8
10
|
catch (error) {
|
|
9
11
|
return { content: [{ type: "text", text: `错误: ${error.message}` }], isError: true };
|
|
10
12
|
}
|
|
11
13
|
}));
|
|
12
14
|
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
const idempotencyDescription = "多 Agent 执行同一业务时传相同 idempotency_key;同键同参数只执行一次,同键不同参数拒绝。";
|
|
16
|
+
function writeSchema(schema) {
|
|
17
|
+
return {
|
|
18
|
+
...schema,
|
|
19
|
+
idempotency_key: IdempotencyKeySchema.optional().describe("跨 Agent 业务幂等键,建议格式 task-id:operation,如 T-123:add-task"),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/** 同步写工具:业务幂等占位 + 跨进程账本锁覆盖完整读-改-写。 */
|
|
23
|
+
export function toolW(server, root, name, description, schema, fn, prepare) {
|
|
24
|
+
server.registerTool(name, { description: `${description} ${idempotencyDescription}`, inputSchema: writeSchema(schema), annotations: { readOnlyHint: false } }, ((args) => {
|
|
25
|
+
try {
|
|
26
|
+
const result = runIdempotentWriteSync(root, name, args, fn, prepare);
|
|
27
|
+
const prefix = result.replayed ? `↩️ 幂等复用 ${result.key}\n` : result.pending ? `⏳ 幂等占位 ${result.key}\n` : "";
|
|
28
|
+
return { content: [{ type: "text", text: prefix + result.text }], ...(result.pending ? { isError: true } : {}) };
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
return { content: [{ type: "text", text: `错误: ${error.message}` }], isError: true };
|
|
32
|
+
}
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
/** 已自带内部锁或异步执行的写工具:先跨进程占位,完成后缓存结果。 */
|
|
36
|
+
export function toolI(server, root, name, description, schema, fn) {
|
|
37
|
+
server.registerTool(name, { description: `${description} ${idempotencyDescription}`, inputSchema: writeSchema(schema), annotations: { readOnlyHint: false } }, (async (args) => {
|
|
16
38
|
try {
|
|
17
|
-
const
|
|
18
|
-
|
|
39
|
+
const result = await runIdempotentWrite(root, name, args, fn);
|
|
40
|
+
const prefix = result.replayed ? `↩️ 幂等复用 ${result.key}\n` : result.pending ? `⏳ 幂等占位 ${result.key}\n` : "";
|
|
41
|
+
return { content: [{ type: "text", text: prefix + result.text }], ...(result.pending ? { isError: true } : {}) };
|
|
19
42
|
}
|
|
20
43
|
catch (error) {
|
|
21
44
|
return { content: [{ type: "text", text: `错误: ${error.message}` }], isError: true };
|
package/dist/tools.js
CHANGED
|
@@ -15,7 +15,7 @@ export function registerAllTools(server, root) {
|
|
|
15
15
|
registerSessionTools(server, root);
|
|
16
16
|
registerSearchTools(server, root);
|
|
17
17
|
registerAuditTools(server, root);
|
|
18
|
-
registerRegistryTools(server);
|
|
18
|
+
registerRegistryTools(server, root);
|
|
19
19
|
registerGovernanceTools(server, root);
|
|
20
20
|
registerAcceptanceTools(server, root);
|
|
21
21
|
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { DatabaseSync } from "node:sqlite";
|
|
4
|
+
const ELECTION_POLL_MS = 1_000;
|
|
5
|
+
const ELECTION_BUSY_MS = 20;
|
|
6
|
+
export function watcherDirtyFile(root) {
|
|
7
|
+
return path.join(path.resolve(root), ".pm", ".runtime", "watcher-dirty");
|
|
8
|
+
}
|
|
9
|
+
export function markWatcherDirty(root) {
|
|
10
|
+
const file = watcherDirtyFile(root);
|
|
11
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
12
|
+
fs.writeFileSync(file, `${new Date().toISOString()}\n`, "utf8");
|
|
13
|
+
}
|
|
14
|
+
export function clearWatcherDirty(root) {
|
|
15
|
+
try {
|
|
16
|
+
fs.rmSync(watcherDirtyFile(root));
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (error.code !== "ENOENT")
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function watcherIsClean(root) {
|
|
24
|
+
return !fs.existsSync(watcherDirtyFile(root));
|
|
25
|
+
}
|
|
26
|
+
function sqliteBusy(error) {
|
|
27
|
+
const candidate = error;
|
|
28
|
+
return candidate.errcode === 5 ||
|
|
29
|
+
(typeof candidate.message === "string" && /database is (?:locked|busy)/i.test(candidate.message));
|
|
30
|
+
}
|
|
31
|
+
function openElection(root) {
|
|
32
|
+
const file = path.join(path.resolve(root), ".pm", ".runtime", "watcher-leader.db");
|
|
33
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
34
|
+
const db = new DatabaseSync(file);
|
|
35
|
+
try {
|
|
36
|
+
db.exec(`PRAGMA busy_timeout=${ELECTION_BUSY_MS}`);
|
|
37
|
+
return db;
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
try {
|
|
41
|
+
db.close();
|
|
42
|
+
}
|
|
43
|
+
catch { /* best effort */ }
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function acquire(db) {
|
|
48
|
+
if (db.isTransaction)
|
|
49
|
+
return true;
|
|
50
|
+
try {
|
|
51
|
+
// Avoid startup DDL so late standbys can join while a leader holds the DB.
|
|
52
|
+
// The transactional header write forces ownership even when an empty DB
|
|
53
|
+
// would otherwise defer materializing the write reservation on this OS.
|
|
54
|
+
db.exec("BEGIN IMMEDIATE");
|
|
55
|
+
db.exec("PRAGMA user_version=1");
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (db.isTransaction) {
|
|
60
|
+
try {
|
|
61
|
+
db.exec("ROLLBACK");
|
|
62
|
+
}
|
|
63
|
+
catch { /* close remains the fail-safe */ }
|
|
64
|
+
}
|
|
65
|
+
if (sqliteBusy(error))
|
|
66
|
+
return false;
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function release(db) {
|
|
71
|
+
if (db.isTransaction)
|
|
72
|
+
db.exec("ROLLBACK");
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Elect exactly one watcher with a dedicated SQLite write transaction held for
|
|
76
|
+
* its lifetime. The OS releases the transaction after a force kill; standbys
|
|
77
|
+
* poll and take over without PID guesses, leases, or stale-file deletion.
|
|
78
|
+
*/
|
|
79
|
+
export function coordinateWatcherLeader(root, activate, report) {
|
|
80
|
+
let election;
|
|
81
|
+
try {
|
|
82
|
+
election = openElection(root);
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
report("leader election unavailable", error);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
let cleanup = null;
|
|
89
|
+
let timer = null;
|
|
90
|
+
let stopped = false;
|
|
91
|
+
let unavailable = false;
|
|
92
|
+
const relinquish = () => {
|
|
93
|
+
try {
|
|
94
|
+
cleanup?.();
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
report("leader cleanup", error);
|
|
98
|
+
}
|
|
99
|
+
cleanup = null;
|
|
100
|
+
try {
|
|
101
|
+
release(election);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
report("leadership release", error);
|
|
105
|
+
unavailable = true;
|
|
106
|
+
try {
|
|
107
|
+
election.close();
|
|
108
|
+
}
|
|
109
|
+
catch { /* OS close releases the transaction */ }
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
const elect = () => {
|
|
113
|
+
if (stopped || unavailable || cleanup)
|
|
114
|
+
return;
|
|
115
|
+
let acquired = false;
|
|
116
|
+
try {
|
|
117
|
+
acquired = acquire(election);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
report("leader election", error);
|
|
121
|
+
unavailable = true;
|
|
122
|
+
stopped = true;
|
|
123
|
+
if (timer)
|
|
124
|
+
clearInterval(timer);
|
|
125
|
+
timer = null;
|
|
126
|
+
try {
|
|
127
|
+
election.close();
|
|
128
|
+
}
|
|
129
|
+
catch { /* best effort */ }
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (!acquired)
|
|
133
|
+
return;
|
|
134
|
+
try {
|
|
135
|
+
const activated = activate();
|
|
136
|
+
if (!activated)
|
|
137
|
+
unavailable = true;
|
|
138
|
+
else
|
|
139
|
+
cleanup = activated;
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
report("leader activation", error);
|
|
143
|
+
}
|
|
144
|
+
if (!cleanup)
|
|
145
|
+
relinquish();
|
|
146
|
+
if (unavailable) {
|
|
147
|
+
if (timer)
|
|
148
|
+
clearInterval(timer);
|
|
149
|
+
timer = null;
|
|
150
|
+
stopped = true;
|
|
151
|
+
try {
|
|
152
|
+
election.close();
|
|
153
|
+
}
|
|
154
|
+
catch { /* best effort */ }
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
elect();
|
|
158
|
+
if (unavailable) {
|
|
159
|
+
try {
|
|
160
|
+
election.close();
|
|
161
|
+
}
|
|
162
|
+
catch { /* best effort */ }
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
timer = setInterval(elect, ELECTION_POLL_MS);
|
|
166
|
+
timer.unref();
|
|
167
|
+
return {
|
|
168
|
+
stop() {
|
|
169
|
+
if (stopped)
|
|
170
|
+
return;
|
|
171
|
+
stopped = true;
|
|
172
|
+
if (timer)
|
|
173
|
+
clearInterval(timer);
|
|
174
|
+
timer = null;
|
|
175
|
+
relinquish();
|
|
176
|
+
try {
|
|
177
|
+
election.close();
|
|
178
|
+
}
|
|
179
|
+
catch { /* best effort */ }
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|