@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.
@@ -0,0 +1,427 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import { z } from "zod";
6
+ import { atomicWrite, withFileRecoveryLock, withLedgerLock } from "./store.js";
7
+ import { ensurePmRuntimeIgnored, pmPath, registryFile } from "./paths.js";
8
+ export const IdempotencyKeySchema = z.string()
9
+ .trim()
10
+ .min(1)
11
+ .max(128)
12
+ .regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/, "仅允许 ASCII 字母、数字、点、下划线、冒号和连字符");
13
+ const OperationRecordSchema = z.object({
14
+ schema_version: z.literal(1),
15
+ key: z.string().min(1).max(256),
16
+ explicit: z.boolean(),
17
+ tool: z.string().min(1),
18
+ args_sha256: z.string().regex(/^[a-f0-9]{64}$/),
19
+ mode: z.enum(["read", "write"]),
20
+ status: z.enum(["pending", "completed", "uncertain"]),
21
+ owner_pid: z.number().int().positive(),
22
+ owner_token: z.string().uuid(),
23
+ created_at: z.string().datetime({ offset: true }),
24
+ updated_at: z.string().datetime({ offset: true }),
25
+ result: z.string().nullable(),
26
+ });
27
+ const AUTO_REPLAY_MS = 1_000;
28
+ const DEFAULT_PENDING_LEASE_MS = 60 * 60_000;
29
+ const DEFAULT_WRITE_WAIT_MS = 3_000;
30
+ const MAX_CLOCK_SKEW_MS = 5_000;
31
+ const SYNC_WAIT = new Int32Array(new SharedArrayBuffer(4));
32
+ let readWaitMs = 30_000;
33
+ let pendingLeaseMs = DEFAULT_PENDING_LEASE_MS;
34
+ let writeWaitMs = DEFAULT_WRITE_WAIT_MS;
35
+ function sha256(value) {
36
+ return createHash("sha256").update(value).digest("hex");
37
+ }
38
+ function canonical(value) {
39
+ if (Array.isArray(value))
40
+ return value.map(canonical);
41
+ if (value && typeof value === "object") {
42
+ return Object.fromEntries(Object.entries(value)
43
+ .filter(([, item]) => item !== undefined)
44
+ .sort(([left], [right]) => left.localeCompare(right))
45
+ .map(([key, item]) => [key, canonical(item)]));
46
+ }
47
+ return value;
48
+ }
49
+ export function operationArgsHash(args) {
50
+ return sha256(JSON.stringify(canonical(args)));
51
+ }
52
+ export function splitIdempotencyArgs(args) {
53
+ if (!args || typeof args !== "object" || Array.isArray(args))
54
+ return { businessArgs: args };
55
+ const { idempotency_key: rawKey, ...business } = args;
56
+ const explicitKey = rawKey === undefined ? undefined : IdempotencyKeySchema.parse(rawKey);
57
+ return { businessArgs: business, ...(explicitKey ? { explicitKey } : {}) };
58
+ }
59
+ function processAlive(pid) {
60
+ try {
61
+ process.kill(pid, 0);
62
+ return true;
63
+ }
64
+ catch (error) {
65
+ return error.code === "EPERM";
66
+ }
67
+ }
68
+ function operationDirectory(root) {
69
+ let canonical = path.resolve(root);
70
+ try {
71
+ canonical = fs.realpathSync.native(canonical);
72
+ }
73
+ catch { /* init_project may not exist yet */ }
74
+ ensurePmRuntimeIgnored(canonical);
75
+ return pmPath(canonical, ".runtime", "idempotency");
76
+ }
77
+ function revisionFile(root) {
78
+ return path.join(operationDirectory(root), "revision");
79
+ }
80
+ function readRevision(root) {
81
+ try {
82
+ return fs.readFileSync(revisionFile(root), "utf8").trim() || "0";
83
+ }
84
+ catch {
85
+ return "0";
86
+ }
87
+ }
88
+ function bumpRevision(root) {
89
+ fs.mkdirSync(operationDirectory(root), { recursive: true });
90
+ atomicWrite(revisionFile(root), randomUUID() + "\n");
91
+ }
92
+ function operationFile(root, key) {
93
+ return path.join(operationDirectory(root), `${sha256(key)}.json`);
94
+ }
95
+ function legacyOperationFiles(root, key) {
96
+ const resolved = path.resolve(root);
97
+ let real = resolved;
98
+ try {
99
+ real = fs.realpathSync.native(resolved);
100
+ }
101
+ catch { /* keep resolved */ }
102
+ const identities = new Set([resolved, real]);
103
+ if (process.platform === "win32") {
104
+ identities.add(resolved.toLowerCase());
105
+ identities.add(resolved.toUpperCase());
106
+ identities.add(real.toLowerCase());
107
+ identities.add(real.toUpperCase());
108
+ }
109
+ const homes = new Set([path.dirname(registryFile()), path.join(os.homedir(), ".pm-mcp")]);
110
+ const name = `${sha256(key)}.json`;
111
+ return [...homes].flatMap((home) => [...identities].map((identity) => path.join(home, "idempotency", sha256(identity), name)));
112
+ }
113
+ function readRecord(file) {
114
+ if (!fs.existsSync(file))
115
+ return null;
116
+ let raw;
117
+ try {
118
+ raw = JSON.parse(fs.readFileSync(file, "utf8"));
119
+ }
120
+ catch (error) {
121
+ throw new Error(`幂等记录损坏,拒绝继续写入: ${file}: ${error instanceof Error ? error.message : String(error)}`);
122
+ }
123
+ const parsed = OperationRecordSchema.safeParse(raw);
124
+ if (!parsed.success)
125
+ throw new Error(`幂等记录 schema 无效,拒绝继续写入: ${file}`);
126
+ return parsed.data;
127
+ }
128
+ function migrateLegacyRecord(root, key, currentFile) {
129
+ const legacy = [...new Set(legacyOperationFiles(root, key))].filter((file) => fs.existsSync(file));
130
+ if (legacy.length === 0)
131
+ return null;
132
+ const records = legacy.map((file) => readRecord(file));
133
+ const first = JSON.stringify(records[0]);
134
+ if (records.some((record) => JSON.stringify(record) !== first)) {
135
+ throw new Error(`检测到多个不一致的 v0.1.3 幂等记录,拒绝自动迁移业务键: ${key}`);
136
+ }
137
+ atomicWrite(currentFile, JSON.stringify(OperationRecordSchema.parse(records[0]), null, 2) + "\n");
138
+ return records[0];
139
+ }
140
+ function pendingLeaseExpired(record) {
141
+ const age = Date.now() - Date.parse(record.updated_at);
142
+ return !Number.isFinite(age) || age < -MAX_CLOCK_SKEW_MS || age >= pendingLeaseMs;
143
+ }
144
+ function withOperationLock(file, fn) {
145
+ return withFileRecoveryLock(`${file}.lock`, fn, {
146
+ timeoutMessage: "幂等键锁获取超时(3s),请稍后重试同一 idempotency_key。",
147
+ });
148
+ }
149
+ function claim(root, tool, args, mode, explicitKey) {
150
+ const argsHash = operationArgsHash(args);
151
+ const key = explicitKey ?? `auto:${mode}:${tool}:${argsHash}`;
152
+ const file = operationFile(root, key);
153
+ fs.mkdirSync(path.dirname(file), { recursive: true });
154
+ return withOperationLock(file, () => {
155
+ const existing = readRecord(file) ?? migrateLegacyRecord(root, key, file);
156
+ if (existing) {
157
+ if (existing.key !== key || existing.tool !== tool || existing.mode !== mode || existing.args_sha256 !== argsHash) {
158
+ throw new Error(`idempotency_key 冲突:${key} 已绑定其他工具或参数,拒绝误复用。`);
159
+ }
160
+ if (existing.status === "completed") {
161
+ const age = Date.now() - Date.parse(existing.updated_at);
162
+ if (existing.explicit ||
163
+ (mode === "write" && age <= AUTO_REPLAY_MS))
164
+ return { kind: "replay", record: existing };
165
+ }
166
+ else if (existing.status === "uncertain") {
167
+ return { kind: "uncertain", record: existing };
168
+ }
169
+ else {
170
+ const expired = pendingLeaseExpired(existing);
171
+ const ownerAlive = processAlive(existing.owner_pid);
172
+ if (mode === "write") {
173
+ if (!expired && ownerAlive)
174
+ return { kind: "pending", file, record: existing };
175
+ // A write may already have changed external or ledger state. Neither an
176
+ // expired lease nor a dead/reused PID proves that replay is safe.
177
+ existing.status = "uncertain";
178
+ existing.updated_at = new Date().toISOString();
179
+ atomicWrite(file, JSON.stringify(OperationRecordSchema.parse(existing), null, 2) + "\n");
180
+ return { kind: "uncertain", record: existing };
181
+ }
182
+ // Reads are side-effect-free by contract. Keep a real active lease, but
183
+ // replace an expired or dead-owner reservation with a fresh execution.
184
+ if (!expired && ownerAlive)
185
+ return { kind: "pending", file, record: existing };
186
+ }
187
+ }
188
+ const timestamp = new Date().toISOString();
189
+ const record = {
190
+ schema_version: 1,
191
+ key,
192
+ explicit: explicitKey !== undefined,
193
+ tool,
194
+ args_sha256: argsHash,
195
+ mode,
196
+ status: "pending",
197
+ owner_pid: process.pid,
198
+ owner_token: randomUUID(),
199
+ created_at: timestamp,
200
+ updated_at: timestamp,
201
+ result: null,
202
+ };
203
+ atomicWrite(file, JSON.stringify(OperationRecordSchema.parse(record), null, 2) + "\n");
204
+ return { kind: "execute", file, record };
205
+ });
206
+ }
207
+ function complete(file, reservation, result) {
208
+ withOperationLock(file, () => {
209
+ const current = readRecord(file);
210
+ if (!current || current.owner_token !== reservation.owner_token || current.status !== "pending") {
211
+ throw new Error(`幂等记录所有权变化,拒绝把结果写到其他操作: ${reservation.key}`);
212
+ }
213
+ current.status = "completed";
214
+ current.result = result;
215
+ current.updated_at = new Date().toISOString();
216
+ atomicWrite(file, JSON.stringify(OperationRecordSchema.parse(current), null, 2) + "\n");
217
+ });
218
+ }
219
+ function markUncertain(file, reservation) {
220
+ try {
221
+ withOperationLock(file, () => {
222
+ const current = readRecord(file);
223
+ if (current?.owner_token !== reservation.owner_token || current.status !== "pending")
224
+ return;
225
+ current.status = "uncertain";
226
+ current.updated_at = new Date().toISOString();
227
+ atomicWrite(file, JSON.stringify(OperationRecordSchema.parse(current), null, 2) + "\n");
228
+ });
229
+ }
230
+ catch {
231
+ // Preserve the business error. The pending explicit key remains fail-closed.
232
+ }
233
+ }
234
+ function abandon(file, reservation) {
235
+ try {
236
+ withOperationLock(file, () => {
237
+ const current = readRecord(file);
238
+ if (current?.owner_token === reservation.owner_token && current.status === "pending")
239
+ fs.rmSync(file, { force: true });
240
+ });
241
+ }
242
+ catch {
243
+ // Preserve the original operation error. A dead-owner retry can recover later.
244
+ }
245
+ }
246
+ function replay(record) {
247
+ const uncertain = record.status === "uncertain" || (record.status === "pending" && record.mode === "write" &&
248
+ (!processAlive(record.owner_pid) || pendingLeaseExpired(record)));
249
+ return {
250
+ text: record.result ?? (uncertain
251
+ ? `⚠️ 业务结果不确定,已禁止自动重放:${record.key}。请核对目标账本后决定是否使用新的业务键。`
252
+ : `⏳ 相同业务正在由另一 Agent 执行:${record.key}`),
253
+ replayed: record.status === "completed",
254
+ pending: record.status !== "completed",
255
+ uncertain,
256
+ key: record.key,
257
+ };
258
+ }
259
+ function currentWriteSettlement(file, reservation) {
260
+ const current = readRecord(file);
261
+ if (!current || current.owner_token !== reservation.owner_token)
262
+ return null;
263
+ if (current.status !== "pending")
264
+ return current;
265
+ if (!processAlive(current.owner_pid) || pendingLeaseExpired(current))
266
+ return null;
267
+ return undefined;
268
+ }
269
+ function waitForWriteSync(file, reservation) {
270
+ const deadline = Date.now() + writeWaitMs;
271
+ while (Date.now() < deadline) {
272
+ const current = currentWriteSettlement(file, reservation);
273
+ if (current !== undefined)
274
+ return current;
275
+ Atomics.wait(SYNC_WAIT, 0, 0, Math.min(25, Math.max(0, deadline - Date.now())));
276
+ }
277
+ return null;
278
+ }
279
+ async function waitForWrite(file, reservation) {
280
+ const deadline = Date.now() + writeWaitMs;
281
+ while (Date.now() < deadline) {
282
+ const current = currentWriteSettlement(file, reservation);
283
+ if (current !== undefined)
284
+ return current;
285
+ await new Promise((resolve) => setTimeout(resolve, Math.min(25, Math.max(0, deadline - Date.now()))));
286
+ }
287
+ return null;
288
+ }
289
+ export function runIdempotentWriteSync(root, tool, args, fn, prepare) {
290
+ const { businessArgs, explicitKey } = splitIdempotencyArgs(args);
291
+ let reservation = claim(root, tool, businessArgs, "write", explicitKey);
292
+ if (reservation.kind === "pending") {
293
+ const settled = waitForWriteSync(reservation.file, reservation.record);
294
+ if (settled)
295
+ return replay(settled);
296
+ reservation = claim(root, tool, businessArgs, "write", explicitKey);
297
+ }
298
+ if (reservation.kind !== "execute")
299
+ return replay(reservation.record);
300
+ let handlerStarted = false;
301
+ try {
302
+ const prepared = prepare?.(businessArgs);
303
+ const text = withLedgerLock(root, () => {
304
+ handlerStarted = true;
305
+ const value = fn(businessArgs, prepared);
306
+ idempotencyFault("after-business");
307
+ bumpRevision(root);
308
+ idempotencyFault("after-revision");
309
+ return value;
310
+ });
311
+ idempotencyFault("before-complete");
312
+ complete(reservation.file, reservation.record, text);
313
+ return { text, replayed: false, pending: false, uncertain: false, key: reservation.record.key };
314
+ }
315
+ catch (error) {
316
+ if (handlerStarted)
317
+ markUncertain(reservation.file, reservation.record);
318
+ else
319
+ abandon(reservation.file, reservation.record);
320
+ throw error;
321
+ }
322
+ }
323
+ export async function runIdempotentWrite(root, tool, args, fn) {
324
+ const { businessArgs, explicitKey } = splitIdempotencyArgs(args);
325
+ let reservation = claim(root, tool, businessArgs, "write", explicitKey);
326
+ if (reservation.kind === "pending") {
327
+ const settled = await waitForWrite(reservation.file, reservation.record);
328
+ if (settled)
329
+ return replay(settled);
330
+ reservation = claim(root, tool, businessArgs, "write", explicitKey);
331
+ }
332
+ if (reservation.kind !== "execute")
333
+ return replay(reservation.record);
334
+ let handlerStarted = false;
335
+ try {
336
+ handlerStarted = true;
337
+ const text = await fn(businessArgs);
338
+ idempotencyFault("after-business");
339
+ bumpRevision(root);
340
+ idempotencyFault("after-revision");
341
+ idempotencyFault("before-complete");
342
+ complete(reservation.file, reservation.record, text);
343
+ return { text, replayed: false, pending: false, uncertain: false, key: reservation.record.key };
344
+ }
345
+ catch (error) {
346
+ if (handlerStarted)
347
+ markUncertain(reservation.file, reservation.record);
348
+ else
349
+ abandon(reservation.file, reservation.record);
350
+ throw error;
351
+ }
352
+ }
353
+ async function waitForRead(file, record) {
354
+ const deadline = Date.now() + readWaitMs;
355
+ while (Date.now() < deadline) {
356
+ await new Promise((resolve) => setTimeout(resolve, 25));
357
+ const current = readRecord(file);
358
+ if (!current || current.owner_token !== record.owner_token)
359
+ return null;
360
+ if (current.status === "completed")
361
+ return current;
362
+ if (!processAlive(current.owner_pid))
363
+ return null;
364
+ }
365
+ return null;
366
+ }
367
+ export async function runCoalescedRead(root, tool, args, fn) {
368
+ // Read-only calls before project initialization must not create .pm state.
369
+ if (!fs.existsSync(pmPath(root, "project.json"))) {
370
+ const text = await fn(args);
371
+ return { text, replayed: false, pending: false, uncertain: false, key: "uncached:uninitialized" };
372
+ }
373
+ const readClaim = () => claim(root, tool, { revision: readRevision(root), args }, "read");
374
+ let reservation = readClaim();
375
+ if (reservation.kind === "replay")
376
+ return replay(reservation.record);
377
+ if (reservation.kind === "uncertain")
378
+ throw new Error("并行读取状态不确定;请稍后重试。");
379
+ if (reservation.kind === "pending") {
380
+ const completed = await waitForRead(reservation.file, reservation.record);
381
+ if (completed)
382
+ return replay(completed);
383
+ reservation = readClaim();
384
+ if (reservation.kind === "pending")
385
+ throw new Error(`并行读取等待超过 ${readWaitMs}ms,leader 仍在执行;请稍后重试。`);
386
+ if (reservation.kind === "uncertain")
387
+ throw new Error("并行读取状态不确定;请稍后重试。");
388
+ if (reservation.kind === "replay")
389
+ return replay(reservation.record);
390
+ }
391
+ if (reservation.kind !== "execute")
392
+ throw new Error("并行读取未获得执行权;请稍后重试。");
393
+ try {
394
+ const text = await fn(args);
395
+ complete(reservation.file, reservation.record, text);
396
+ return { text, replayed: false, pending: false, uncertain: false, key: reservation.record.key };
397
+ }
398
+ catch (error) {
399
+ abandon(reservation.file, reservation.record);
400
+ throw error;
401
+ }
402
+ }
403
+ let faultHook;
404
+ function idempotencyFault(stage) {
405
+ faultHook?.(stage);
406
+ }
407
+ /** 仅供故障注入测试;生产调用方不应设置。 */
408
+ export function setIdempotencyFaultHookForTests(hook) {
409
+ faultHook = hook;
410
+ }
411
+ /** 仅供故障注入测试,传 undefined 恢复生产默认值。 */
412
+ export function setReadWaitMsForTests(value) {
413
+ readWaitMs = value ?? 30_000;
414
+ }
415
+ /** 仅供 PID 复用与 lease 故障注入测试;传 undefined 恢复生产默认值。 */
416
+ export function setPendingLeaseMsForTests(value) {
417
+ if (value !== undefined && (!Number.isFinite(value) || value <= 0)) {
418
+ throw new RangeError("pending lease 必须大于 0ms。");
419
+ }
420
+ pendingLeaseMs = value ?? DEFAULT_PENDING_LEASE_MS;
421
+ }
422
+ /** 仅供 follower 等待边界测试;传 undefined 恢复生产默认值。 */
423
+ export function setWriteWaitMsForTests(value) {
424
+ if (value !== undefined && (!Number.isFinite(value) || value < 0))
425
+ throw new RangeError("write wait 不得小于 0ms。");
426
+ writeWaitMs = value ?? DEFAULT_WRITE_WAIT_MS;
427
+ }