cc-viewer 1.6.293 → 1.6.295
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/cli.js +7 -2
- package/dist/assets/App-Br-u2TKk.js +2 -0
- package/dist/assets/App-eFrjLzF_.css +1 -0
- package/dist/assets/{MdxEditorPanel-Cf01KF6Z.js → MdxEditorPanel-Cy4egsQx.js} +1 -1
- package/dist/assets/{Mobile-BJlGkvAP.js → Mobile-ZHF74GQs.js} +1 -1
- package/dist/assets/{_baseUniq-CPUnJ5bQ.js → _baseUniq-r3p3rodd.js} +1 -1
- package/dist/assets/{arc-WhuJ-oY5.js → arc-CjTV5gxc.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-CWx77Yhd.js → architectureDiagram-Q4EWVU46-BqzjXpCq.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-D7AQLCoj.js → blockDiagram-DXYQGD6D-CLyFfeHh.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-BoPHNqCF.js → c4Diagram-AHTNJAMY-BaO-0tuc.js} +1 -1
- package/dist/assets/{channel-B9Ja6Xkc.js → channel-yOyhvOLV.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-B-b0RYab.js → chunk-4BX2VUAB-CMTnvZkS.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-BK_V34yf.js → chunk-4TB4RGXK-QI41m9WP.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-D-kMbu-2.js → chunk-55IACEB6-C4ZO8bM3.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-CEtSkZzd.js → chunk-EDXVE4YY-Bo8P4o65.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-BXa_7Pn3.js → chunk-FMBD7UC4-CTHLGcHh.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-tvM_OApS.js → chunk-OYMX7WX6-D0OHxKGd.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-DrEmcVHf.js → chunk-QZHKN3VN-CoYnjUpS.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-D2M9T_R5.js → chunk-YZCP3GAM-BY71mTXM.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-C9o5ip5q.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-C9o5ip5q.js +1 -0
- package/dist/assets/clone-GDqN3kwT.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-H7bkwu5F.js → cose-bilkent-S5V4N54A-DUNsA_MT.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-DKXEGN18.js → dagre-KV5264BT-BzlT2Exr.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-DZFhwpI3.js → diagram-5BDNPKRD-CiqQK3Ci.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-Crg9GlIk.js → diagram-G4DWMVQ6-BciK18tQ.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-B8Qn1fKP.js → diagram-MMDJMWI5-C1WH1vfU.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-BHE1LjtY.js → diagram-TYMM5635-CR5RzJ6u.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-BaEqFWLd.js → erDiagram-SMLLAGMA-NJQKXu51.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-b2ukTawV.js → flowDiagram-DWJPFMVM-Cjx5t_1H.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-D5quyFgK.js → ganttDiagram-T4ZO3ILL-YFTDBBiU.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-BE1H5_fN.js → gitGraphDiagram-UUTBAWPF-C2muKahz.js} +1 -1
- package/dist/assets/{graph-D_JLoOax.js → graph-I1olozIg.js} +1 -1
- package/dist/assets/{index-Cx8bk0Tp.js → index-7vxIrUNA.js} +1 -1
- package/dist/assets/{index-BDUs32pN.css → index-Be9T-kDq.css} +1 -1
- package/dist/assets/{index-CtrY6gFZ.js → index-C1RNAzAB.js} +1 -1
- package/dist/assets/{index-CQrdpZQb.js → index-Cf4FBg-V.js} +1 -1
- package/dist/assets/{index-B8UmlA4F.js → index-D-HPuqxB.js} +1 -1
- package/dist/assets/{index-k0AH8cvI.js → index-D2QUxu18.js} +1 -1
- package/dist/assets/index-DMuCrfTo.js +2 -0
- package/dist/assets/{index-DiZ9CErG.js → index-DhzoJ5wE.js} +1 -1
- package/dist/assets/{index-CWjqMDrs.js → index-fhI0i2p3.js} +1 -1
- package/dist/assets/{infoDiagram-42DDH7IO-DQKlrVkw.js → infoDiagram-42DDH7IO-C9bza97c.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-BchFlpPc.js → ishikawaDiagram-UXIWVN3A-BtZGipfW.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-Dg1mt4df.js → journeyDiagram-VCZTEJTY-CKTp590c.js} +1 -1
- package/dist/assets/{jszip.min-LIb2SFoK.js → jszip.min-DDU-_oA-.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-226va2PS.js → kanban-definition-6JOO6SKY-BHLNWfr5.js} +1 -1
- package/dist/assets/{layout-rSa8rcPi.js → layout-DBmqcl9N.js} +1 -1
- package/dist/assets/{linear-BeARi8nH.js → linear-Br9n7mCI.js} +1 -1
- package/dist/assets/{mermaid.core-CDgdx9l7.js → mermaid.core-BV3ugHFm.js} +2 -2
- package/dist/assets/{min-B9yebCuj.js → min-D-YA3MGY.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-C3apVbdg.js → mindmap-definition-QFDTVHPH-CzrYj3cB.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-xjOQoQeL.js → pieDiagram-DEJITSTG-BAvtfiT3.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-Dq8x_VN2.js → quadrantDiagram-34T5L4WZ-i4zhnBJq.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-CLmO1Gai.js → requirementDiagram-MS252O5E-Cb2wX9Sk.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-BuUP1Eqq.js → sankeyDiagram-XADWPNL6-CcpbP6z5.js} +1 -1
- package/dist/assets/seqResourceLoaders-C7X23dCJ.js +2 -0
- package/dist/assets/{seqResourceLoaders-DWKAvGtj.css → seqResourceLoaders-De_-fYhE.css} +2 -2
- package/dist/assets/{sequenceDiagram-FGHM5R23-B18koU20.js → sequenceDiagram-FGHM5R23-BcbUxMmI.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-Cj57OCcO.js → stateDiagram-FHFEXIEX-CpIa1qoO.js} +1 -1
- package/dist/assets/{stateDiagram-v2-QKLJ7IA2-C01a2p--.js → stateDiagram-v2-QKLJ7IA2-d3GoyW9S.js} +1 -1
- package/dist/assets/{timeline-definition-GMOUNBTQ-cOlsEN_F.js → timeline-definition-GMOUNBTQ-BfQPSOuT.js} +1 -1
- package/dist/assets/{vendor-antd-DqFS7Zj9.js → vendor-antd-Bur5ZxWE.js} +1 -1
- package/dist/assets/{vendor-codemirror-B_pF4DrA.js → vendor-codemirror-Si44UqBp.js} +1 -1
- package/dist/assets/{vendor-mdxeditor-B_IrHcWH.js → vendor-mdxeditor-Cco3AQJS.js} +2 -2
- package/dist/assets/{vendor-qrcode-C4PneAS5.js → vendor-qrcode-Dn3GYC4l.js} +1 -1
- package/dist/assets/{vendor-virtuoso-CEGeJyDP.js → vendor-virtuoso-CW9EqKMt.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-BCjdwiDk.js → vennDiagram-DHZGUBPP-hTgiYDQL.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-CRmLlBwn.js → wardley-RL74JXVD-ByDpAPp1.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-BJYVDJ4F.js → wardleyDiagram-NUSXRM2D-D7LJTuWq.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-el5C4S1Z.js → xychartDiagram-5P7HB3ND-MW_KOomO.js} +1 -1
- package/dist/index.html +5 -5
- package/findcc.js +3 -3
- package/package.json +1 -1
- package/server/i18n.js +224 -8
- package/server/interceptor.js +21 -18
- package/server/lib/adapters/dingtalk-adapter.js +69 -0
- package/server/lib/adapters/discord-adapter.js +44 -1
- package/server/lib/adapters/feishu-adapter.js +56 -0
- package/server/lib/adapters/wecom-adapter.js +4 -0
- package/server/lib/ask-store.js +19 -90
- package/server/lib/async-file-lock.js +123 -0
- package/server/lib/async-write-queue.js +131 -0
- package/server/lib/git-diff.js +4 -1
- package/server/lib/im-bridge-core.js +178 -21
- package/server/lib/im-claude-md.js +37 -1
- package/server/lib/im-config.js +11 -6
- package/server/lib/im-process-manager.js +1 -1
- package/server/lib/im-senders.js +73 -0
- package/server/lib/jsonl-archive.js +0 -1
- package/server/lib/log-watcher.js +224 -177
- package/server/lib/plugin-manager.js +1 -1
- package/server/lib/updater.js +4 -2
- package/server/pty-manager.js +1 -1
- package/server/routes/ask-perm.js +2 -2
- package/server/routes/dingtalk.js +2 -0
- package/server/routes/files-fs.js +4 -4
- package/server/routes/im.js +117 -3
- package/server/routes/project-meta.js +18 -1
- package/server/routes/skills.js +180 -165
- package/server/routes/workspaces.js +7 -10
- package/server/server.js +23 -20
- package/server/workspace-registry.js +9 -53
- package/dist/assets/App-DRvRd96X.css +0 -1
- package/dist/assets/App-OM2oqZRW.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-CCwGJXEA.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-CCwGJXEA.js +0 -1
- package/dist/assets/clone-BuQbTPQO.js +0 -1
- package/dist/assets/index-CnWSVlWW.js +0 -2
- package/dist/assets/seqResourceLoaders-BZ6M3Jb-.js +0 -2
|
@@ -99,6 +99,25 @@ const feishuAdapter = {
|
|
|
99
99
|
// Feishu long-connection has no app-level inbound ACK (the SDK handles transport acking).
|
|
100
100
|
ack() { /* no-op */ },
|
|
101
101
|
|
|
102
|
+
// 解析发送者姓名 + 头像(供「对话记录」展示)。事件只带 open_id,姓名/头像需查通讯录:
|
|
103
|
+
// 复用已建好的 sendClient(自带 tenant_access_token 缓存)调 contact.v3.user.get。
|
|
104
|
+
// 需应用具备「读取通讯录」相关 scope;无权限/外部用户/失败 → null,由 bridge 静默降级。
|
|
105
|
+
async resolveSender(cfg, senderId, ctx) {
|
|
106
|
+
if (!senderId) return null;
|
|
107
|
+
const client = ctx.store.sendClient;
|
|
108
|
+
if (!client?.contact?.v3?.user?.get) return null;
|
|
109
|
+
try {
|
|
110
|
+
const r = await client.contact.v3.user.get({
|
|
111
|
+
path: { user_id: senderId },
|
|
112
|
+
params: { user_id_type: 'open_id' },
|
|
113
|
+
});
|
|
114
|
+
if (r && typeof r.code === 'number' && r.code !== 0) return null;
|
|
115
|
+
const u = r?.data?.user || {};
|
|
116
|
+
const avatar = u.avatar?.avatar_240 || u.avatar?.avatar_72 || u.avatar?.avatar_origin || null;
|
|
117
|
+
return { name: u.name || null, avatar };
|
|
118
|
+
} catch { return null; }
|
|
119
|
+
},
|
|
120
|
+
|
|
102
121
|
async sendOne(cfg, target, content, ctx) {
|
|
103
122
|
const client = ctx.store.sendClient;
|
|
104
123
|
if (!client) throw new Error('feishu send client not initialized');
|
|
@@ -111,6 +130,43 @@ const feishuAdapter = {
|
|
|
111
130
|
}
|
|
112
131
|
},
|
|
113
132
|
|
|
133
|
+
async sendAckCard(cfg, target, statusText, ctx) {
|
|
134
|
+
const client = ctx.store.sendClient;
|
|
135
|
+
if (!client) throw new Error('feishu send client not initialized');
|
|
136
|
+
const card = {
|
|
137
|
+
config: { wide_screen_mode: true },
|
|
138
|
+
header: { title: { tag: 'plain_text', content: 'Claude' }, template: 'blue' },
|
|
139
|
+
elements: [{ tag: 'div', text: { tag: 'lark_md', content: statusText } }],
|
|
140
|
+
};
|
|
141
|
+
const r = await client.im.v1.message.create({
|
|
142
|
+
params: { receive_id_type: target.receiveIdType },
|
|
143
|
+
data: { receive_id: target.receiveId, msg_type: 'interactive', content: JSON.stringify(card) },
|
|
144
|
+
});
|
|
145
|
+
if (r && typeof r.code === 'number' && r.code !== 0) {
|
|
146
|
+
throw new Error(`sendAckCard ${r.code}: ${r.msg || 'failed'}`);
|
|
147
|
+
}
|
|
148
|
+
return { messageId: r?.data?.message_id };
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
async updateAckCard(cfg, target, handle, content, status, ctx) {
|
|
152
|
+
try {
|
|
153
|
+
const client = ctx.store.sendClient;
|
|
154
|
+
if (!client) return false;
|
|
155
|
+
const templateMap = { done: 'green', interrupted: 'orange', error: 'red' };
|
|
156
|
+
const card = {
|
|
157
|
+
config: { wide_screen_mode: true },
|
|
158
|
+
header: { title: { tag: 'plain_text', content: 'Claude' }, template: templateMap[status] || 'blue' },
|
|
159
|
+
elements: [{ tag: 'div', text: { tag: 'lark_md', content } }],
|
|
160
|
+
};
|
|
161
|
+
const r = await client.im.v1.message.patch({
|
|
162
|
+
path: { message_id: handle.messageId },
|
|
163
|
+
data: { content: JSON.stringify(card) },
|
|
164
|
+
});
|
|
165
|
+
if (r && typeof r.code === 'number' && r.code !== 0) return false;
|
|
166
|
+
return true;
|
|
167
|
+
} catch { return false; }
|
|
168
|
+
},
|
|
169
|
+
|
|
114
170
|
async testConnection(cfg, ctx) {
|
|
115
171
|
try {
|
|
116
172
|
const r = await ctx.fetch(tokenHost(cfg.region) + TOKEN_PATH, {
|
|
@@ -61,6 +61,10 @@ const wecomAdapter = {
|
|
|
61
61
|
hasCreds(cfg) { return !!(cfg.botId && cfg.secret); },
|
|
62
62
|
statusFields(cfg) { return { botIdTail: cfg?.botId?.slice(-4) || '' }; },
|
|
63
63
|
|
|
64
|
+
// 发送者姓名/头像:v1 不实现 resolveSender —— 智能机器人长连接凭证(botId+secret)拿不到
|
|
65
|
+
// 企业通讯录的 corp_access_token,`/cgi-bin/user/get` 不可达。故 WeCom 发送者在「对话记录」里
|
|
66
|
+
// 降级为默认头像 + senderId(不报错、不阻断)。后续如引入管理员级凭证再补 resolveSender。
|
|
67
|
+
|
|
64
68
|
async connect(cfg, hooks, ctx) {
|
|
65
69
|
const mod = await loadSdk();
|
|
66
70
|
const WSClient = resolveWSClient(mod);
|
package/server/lib/ask-store.js
CHANGED
|
@@ -9,10 +9,11 @@
|
|
|
9
9
|
// - 启动时 hydrate 出的 entry 的 res 字段为 null(旧连接已死),
|
|
10
10
|
// 等待新的 ask-bridge 重新 POST 同 toolUseId 时复用(已在 server.js:2727 实现)
|
|
11
11
|
// 或浏览器通过 /api/pending-asks 拉取展示。
|
|
12
|
-
import { readFileSync, writeFileSync,
|
|
12
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
14
|
import { randomBytes } from 'node:crypto';
|
|
15
15
|
import { renameSyncWithRetry } from './file-api.js';
|
|
16
|
+
import { withFileLockAsync } from './async-file-lock.js';
|
|
16
17
|
import { LOG_DIR } from '../../findcc.js';
|
|
17
18
|
|
|
18
19
|
const SCHEMA_VERSION = 1;
|
|
@@ -24,80 +25,8 @@ let _loggedPersistError = false;
|
|
|
24
25
|
function getStoreFile() { return join(LOG_DIR, 'ask-store.json'); }
|
|
25
26
|
function getLockFile() { return join(LOG_DIR, 'ask-store.lock'); }
|
|
26
27
|
|
|
27
|
-
function sleep(ms) {
|
|
28
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// Lock body 含 owner pid,让其它进程精确判断 owner 是否仍活着 —— 避免 mtime-only stale
|
|
32
|
-
// 判定在 Electron 多 Tab + 慢盘场景下误删活跃锁(持锁方 fn 跑 >5s 时旧实现会被偷锁)。
|
|
33
|
-
// Body 不可读(老格式 / openSync 与 writeSync 之间的瞬间 race)时退回 mtime 阈值兜底。
|
|
34
|
-
function readLockOwnerPid(path) {
|
|
35
|
-
try {
|
|
36
|
-
const raw = readFileSync(path, 'utf-8');
|
|
37
|
-
if (!raw) return null;
|
|
38
|
-
const obj = JSON.parse(raw);
|
|
39
|
-
if (obj && Number.isInteger(obj.pid)) return obj.pid;
|
|
40
|
-
} catch {}
|
|
41
|
-
return null;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function isPidAlive(pid) {
|
|
45
|
-
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
46
|
-
try {
|
|
47
|
-
process.kill(pid, 0);
|
|
48
|
-
return true;
|
|
49
|
-
} catch (err) {
|
|
50
|
-
// ESRCH = 进程不存在;EPERM = 存在但不同 user(仍算活)
|
|
51
|
-
return err && err.code === 'EPERM';
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function isLockStale(path, mtimeFallbackMs) {
|
|
56
|
-
const pid = readLockOwnerPid(path);
|
|
57
|
-
if (pid !== null) {
|
|
58
|
-
// 自己 pid 见到 = 必然 stale(理论不可能:try/finally 保证 unlink;
|
|
59
|
-
// 若发生 → 视作可回收,避免 2 秒死锁)
|
|
60
|
-
if (pid === process.pid) return true;
|
|
61
|
-
return !isPidAlive(pid);
|
|
62
|
-
}
|
|
63
|
-
try {
|
|
64
|
-
const stats = statSync(path);
|
|
65
|
-
return Date.now() - stats.mtimeMs > mtimeFallbackMs;
|
|
66
|
-
} catch {
|
|
67
|
-
return false;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
28
|
function withLock(fn) {
|
|
72
|
-
|
|
73
|
-
const deadline = Date.now() + 2000;
|
|
74
|
-
const STALE_THRESHOLD = 5000;
|
|
75
|
-
while (true) {
|
|
76
|
-
try {
|
|
77
|
-
const fd = openSync(getLockFile(), 'wx');
|
|
78
|
-
try {
|
|
79
|
-
writeSync(fd, JSON.stringify({ pid: process.pid, ts: Date.now() }));
|
|
80
|
-
} finally {
|
|
81
|
-
closeSync(fd);
|
|
82
|
-
}
|
|
83
|
-
break;
|
|
84
|
-
} catch (err) {
|
|
85
|
-
if (err?.code === 'EEXIST') {
|
|
86
|
-
if (Date.now() < deadline) {
|
|
87
|
-
if (isLockStale(getLockFile(), STALE_THRESHOLD)) {
|
|
88
|
-
try { unlinkSync(getLockFile()); } catch {}
|
|
89
|
-
continue;
|
|
90
|
-
}
|
|
91
|
-
sleep(25);
|
|
92
|
-
continue;
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
throw err;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
try { return fn(); } finally {
|
|
99
|
-
try { unlinkSync(getLockFile()); } catch {}
|
|
100
|
-
}
|
|
29
|
+
return withFileLockAsync(getLockFile(), fn, { ensureDir: LOG_DIR });
|
|
101
30
|
}
|
|
102
31
|
|
|
103
32
|
/**
|
|
@@ -187,11 +116,11 @@ export function saveAskStore(entries) {
|
|
|
187
116
|
* last-write-wins 覆盖错乱(A 选 X 落盘 → B 选 Y 又落 → ask-bridge 最终拿 Y 而 A UI 显 X)。
|
|
188
117
|
* Returns true if this call wrote, false if noop'd by existing terminal state.
|
|
189
118
|
*/
|
|
190
|
-
export function markAnswered(id, answers) {
|
|
119
|
+
export async function markAnswered(id, answers) {
|
|
191
120
|
if (!id || typeof id !== 'string') return false;
|
|
192
121
|
if (!answers || typeof answers !== 'object') return false;
|
|
193
122
|
try {
|
|
194
|
-
return withLock(() => {
|
|
123
|
+
return await withLock(() => {
|
|
195
124
|
const all = loadAskStore();
|
|
196
125
|
const existing = all[id];
|
|
197
126
|
if (existing && (existing.status === 'answered' || existing.status === 'cancelled')) {
|
|
@@ -211,10 +140,10 @@ export function markAnswered(id, answers) {
|
|
|
211
140
|
} catch { return false; }
|
|
212
141
|
}
|
|
213
142
|
|
|
214
|
-
export function markCancelled(id, reason) {
|
|
143
|
+
export async function markCancelled(id, reason) {
|
|
215
144
|
if (!id || typeof id !== 'string') return false;
|
|
216
145
|
try {
|
|
217
|
-
return withLock(() => {
|
|
146
|
+
return await withLock(() => {
|
|
218
147
|
const all = loadAskStore();
|
|
219
148
|
const existing = all[id];
|
|
220
149
|
if (existing && (existing.status === 'answered' || existing.status === 'cancelled')) {
|
|
@@ -242,10 +171,10 @@ export function markCancelled(id, reason) {
|
|
|
242
171
|
* 替代旧的 "无条件 consume + setEntry 写回" 双 withLock 模式 —— 那种模式的两次锁
|
|
243
172
|
* 中间窗口会被 markAnswered 命中 → setEntry 把答案覆盖回 pending。
|
|
244
173
|
*/
|
|
245
|
-
export function consumeIfFinal(id) {
|
|
174
|
+
export async function consumeIfFinal(id) {
|
|
246
175
|
if (!id || typeof id !== 'string') return null;
|
|
247
176
|
try {
|
|
248
|
-
return withLock(() => {
|
|
177
|
+
return await withLock(() => {
|
|
249
178
|
const all = loadAskStore();
|
|
250
179
|
const entry = all[id];
|
|
251
180
|
if (!entry) return null;
|
|
@@ -265,10 +194,10 @@ export function consumeIfFinal(id) {
|
|
|
265
194
|
* truly want "read + delete regardless of status"). New short-poll GET handler
|
|
266
195
|
* uses consumeIfFinal instead — see server.js GET /api/ask-hook/:id/result.
|
|
267
196
|
*/
|
|
268
|
-
export function consume(id) {
|
|
197
|
+
export async function consume(id) {
|
|
269
198
|
if (!id || typeof id !== 'string') return null;
|
|
270
199
|
try {
|
|
271
|
-
return withLock(() => {
|
|
200
|
+
return await withLock(() => {
|
|
272
201
|
const all = loadAskStore();
|
|
273
202
|
const entry = all[id];
|
|
274
203
|
if (!entry) return null;
|
|
@@ -289,11 +218,11 @@ export function consume(id) {
|
|
|
289
218
|
* setEntry 不能把 status 倒回 pending —— 否则 setImmediate 排队的延迟 _persistAskEntry
|
|
290
219
|
* 或重 POST 的 placeholder 会覆盖真实终态,导致 ask-bridge 短轮询永远拿不到答案。
|
|
291
220
|
*/
|
|
292
|
-
export function setEntry(id, fields) {
|
|
221
|
+
export async function setEntry(id, fields) {
|
|
293
222
|
if (!id || typeof id !== 'string') return;
|
|
294
223
|
if (!fields || !Array.isArray(fields.questions)) return;
|
|
295
224
|
try {
|
|
296
|
-
withLock(() => {
|
|
225
|
+
await withLock(() => {
|
|
297
226
|
const all = loadAskStore();
|
|
298
227
|
const existing = all[id];
|
|
299
228
|
if (existing && (existing.status === 'answered' || existing.status === 'cancelled')) {
|
|
@@ -312,10 +241,10 @@ export function setEntry(id, fields) {
|
|
|
312
241
|
}
|
|
313
242
|
}
|
|
314
243
|
|
|
315
|
-
export function deleteEntry(id) {
|
|
244
|
+
export async function deleteEntry(id) {
|
|
316
245
|
if (!id || typeof id !== 'string') return;
|
|
317
246
|
try {
|
|
318
|
-
withLock(() => {
|
|
247
|
+
await withLock(() => {
|
|
319
248
|
const all = loadAskStore();
|
|
320
249
|
if (!(id in all)) return;
|
|
321
250
|
delete all[id];
|
|
@@ -328,9 +257,9 @@ export function deleteEntry(id) {
|
|
|
328
257
|
* Replace the entire store atomically. Used on server startup to drop entries
|
|
329
258
|
* older than maxAgeMs (24h-class staleness sweep) without N round-trips.
|
|
330
259
|
*/
|
|
331
|
-
export function replaceAll(entries) {
|
|
260
|
+
export async function replaceAll(entries) {
|
|
332
261
|
try {
|
|
333
|
-
withLock(() => saveAskStore(entries));
|
|
262
|
+
await withLock(() => saveAskStore(entries));
|
|
334
263
|
} catch {}
|
|
335
264
|
}
|
|
336
265
|
|
|
@@ -341,9 +270,9 @@ export function replaceAll(entries) {
|
|
|
341
270
|
* Stale 判定用 max(createdAt, answeredAt):刚 answered 的老 entry(createdAt 旧但
|
|
342
271
|
* answeredAt 新)必须保留,否则 ask-bridge 短轮询拿不到答案。
|
|
343
272
|
*/
|
|
344
|
-
export function pruneStale(maxAgeMs = 24 * 60 * 60 * 1000) {
|
|
273
|
+
export async function pruneStale(maxAgeMs = 24 * 60 * 60 * 1000) {
|
|
345
274
|
try {
|
|
346
|
-
return withLock(() => {
|
|
275
|
+
return await withLock(() => {
|
|
347
276
|
const all = loadAskStore();
|
|
348
277
|
const cutoff = Date.now() - maxAgeMs;
|
|
349
278
|
const survivors = {};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Async file lock — 替代 Atomics.wait 阻塞锁
|
|
2
|
+
// 使用 fs.promises.open('wx') 原子创建 + setTimeout 异步重试
|
|
3
|
+
// 同进程内通过 Promise 链串行化,跨进程通过文件锁互斥
|
|
4
|
+
|
|
5
|
+
import { open, stat, unlink, readFile } from 'node:fs/promises';
|
|
6
|
+
import { mkdirSync } from 'node:fs';
|
|
7
|
+
|
|
8
|
+
// 同进程内的 Promise 互斥锁(按 lockPath 分组)
|
|
9
|
+
const _inProcessLocks = new Map();
|
|
10
|
+
|
|
11
|
+
function isPidAlive(pid) {
|
|
12
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
13
|
+
try {
|
|
14
|
+
process.kill(pid, 0);
|
|
15
|
+
return true;
|
|
16
|
+
} catch (err) {
|
|
17
|
+
return err && err.code === 'EPERM';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function readLockOwnerPid(path) {
|
|
22
|
+
try {
|
|
23
|
+
const raw = await readFile(path, 'utf-8');
|
|
24
|
+
if (!raw) return null;
|
|
25
|
+
const obj = JSON.parse(raw);
|
|
26
|
+
if (obj && Number.isInteger(obj.pid)) return obj.pid;
|
|
27
|
+
} catch {}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function isLockStale(path, mtimeFallbackMs) {
|
|
32
|
+
const pid = await readLockOwnerPid(path);
|
|
33
|
+
if (pid !== null) {
|
|
34
|
+
// 同进程的锁由 _inProcessLocks 管理,这里看到 own pid 说明是上次崩溃残留
|
|
35
|
+
if (pid === process.pid) return true;
|
|
36
|
+
return !isPidAlive(pid);
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const stats = await stat(path);
|
|
40
|
+
return Date.now() - stats.mtimeMs > mtimeFallbackMs;
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sleep(ms) {
|
|
47
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function _acquireFileLock(lockPath, opts) {
|
|
51
|
+
const deadline = Date.now() + (opts.deadline ?? 2000);
|
|
52
|
+
const retryMs = opts.retryMs ?? 25;
|
|
53
|
+
const staleThresholdMs = opts.staleThresholdMs ?? 5000;
|
|
54
|
+
const writePid = opts.writePid !== false;
|
|
55
|
+
|
|
56
|
+
while (true) {
|
|
57
|
+
try {
|
|
58
|
+
const fh = await open(lockPath, 'wx');
|
|
59
|
+
if (writePid) {
|
|
60
|
+
await fh.writeFile(JSON.stringify({ pid: process.pid, ts: Date.now() }));
|
|
61
|
+
}
|
|
62
|
+
await fh.close();
|
|
63
|
+
return;
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (err?.code === 'EEXIST') {
|
|
66
|
+
if (Date.now() < deadline) {
|
|
67
|
+
if (await isLockStale(lockPath, staleThresholdMs)) {
|
|
68
|
+
try { await unlink(lockPath); } catch {}
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
await sleep(retryMs);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
// deadline exceeded — only break if lock is stale (dead PID or old mtime)
|
|
75
|
+
if (await isLockStale(lockPath, staleThresholdMs)) {
|
|
76
|
+
try { await unlink(lockPath); } catch {}
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
throw new Error(`Lock acquisition timeout: ${lockPath} (held by live process)`);
|
|
80
|
+
|
|
81
|
+
}
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {string} lockPath - 锁文件路径
|
|
89
|
+
* @param {() => any | Promise<any>} fn - 持锁期间执行的函数
|
|
90
|
+
* @param {object} [opts]
|
|
91
|
+
* @param {number} [opts.deadline] - 最长等待时间 (ms),默认 2000
|
|
92
|
+
* @param {number} [opts.retryMs] - 重试间隔 (ms),默认 25
|
|
93
|
+
* @param {number} [opts.staleThresholdMs] - mtime 过期阈值 (ms),默认 5000
|
|
94
|
+
* @param {boolean} [opts.writePid] - 锁文件中写入 PID 信息,默认 true
|
|
95
|
+
* @param {string} [opts.ensureDir] - 若提供,在获取锁前确保此目录存在
|
|
96
|
+
*/
|
|
97
|
+
export async function withFileLockAsync(lockPath, fn, opts = {}) {
|
|
98
|
+
if (opts.ensureDir) {
|
|
99
|
+
try { mkdirSync(opts.ensureDir, { recursive: true }); } catch {}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 同进程串行化:排队等待前一个持锁操作完成
|
|
103
|
+
const prev = _inProcessLocks.get(lockPath) || Promise.resolve();
|
|
104
|
+
let resolve;
|
|
105
|
+
const next = new Promise(r => { resolve = r; });
|
|
106
|
+
_inProcessLocks.set(lockPath, next);
|
|
107
|
+
|
|
108
|
+
await prev;
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
await _acquireFileLock(lockPath, opts);
|
|
112
|
+
try {
|
|
113
|
+
return await fn();
|
|
114
|
+
} finally {
|
|
115
|
+
try { await unlink(lockPath); } catch {}
|
|
116
|
+
}
|
|
117
|
+
} finally {
|
|
118
|
+
resolve();
|
|
119
|
+
if (_inProcessLocks.get(lockPath) === next) {
|
|
120
|
+
_inProcessLocks.delete(lockPath);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Async Write Queue — 非阻塞追加写入,替代 appendFileSync
|
|
2
|
+
// 单写者模式保证写入顺序,进程退出时回退同步写入保证不丢数据
|
|
3
|
+
|
|
4
|
+
import { appendFile } from 'node:fs/promises';
|
|
5
|
+
import { appendFileSync } from 'node:fs';
|
|
6
|
+
|
|
7
|
+
const HIGH_WATER_MARK = 50 * 1024 * 1024; // 50MB — 超过此值降级为同步写入
|
|
8
|
+
|
|
9
|
+
export class AsyncWriteQueue {
|
|
10
|
+
/**
|
|
11
|
+
* @param {string|(() => string)} pathOrGetter - 文件路径或返回路径的函数(支持动态路径)
|
|
12
|
+
* @param {object} [opts]
|
|
13
|
+
* @param {boolean} [opts.syncMode] - 强制同步模式
|
|
14
|
+
*/
|
|
15
|
+
constructor(pathOrGetter, opts = {}) {
|
|
16
|
+
this._pathOrGetter = pathOrGetter;
|
|
17
|
+
this._queue = []; // { path: string, data: string, onDone?: Function }[]
|
|
18
|
+
this._pendingBytes = 0;
|
|
19
|
+
this._draining = false;
|
|
20
|
+
this._drainPromise = null;
|
|
21
|
+
this._closed = false;
|
|
22
|
+
this._flushResolvers = []; // resolve() callbacks waiting for flush()
|
|
23
|
+
this._syncMode = opts.syncMode || !!process.env.CCV_SYNC_WRITES;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
_getPath() {
|
|
27
|
+
return typeof this._pathOrGetter === 'function' ? this._pathOrGetter() : this._pathOrGetter;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
get filePath() { return this._getPath(); }
|
|
31
|
+
get pendingBytes() { return this._pendingBytes; }
|
|
32
|
+
|
|
33
|
+
append(data, onDone) {
|
|
34
|
+
if (this._closed) return;
|
|
35
|
+
const path = this._getPath();
|
|
36
|
+
if (!path) {
|
|
37
|
+
if (onDone) try { onDone(); } catch {}
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (this._syncMode || this._pendingBytes >= HIGH_WATER_MARK) {
|
|
42
|
+
try { appendFileSync(path, data); } catch {}
|
|
43
|
+
if (onDone) try { onDone(); } catch {}
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const byteLen = Buffer.byteLength(data);
|
|
48
|
+
this._queue.push({ path, data, onDone });
|
|
49
|
+
this._pendingBytes += byteLen;
|
|
50
|
+
this._scheduleDrain();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async flush() {
|
|
54
|
+
if (this._queue.length === 0 && !this._draining) return;
|
|
55
|
+
return new Promise(resolve => {
|
|
56
|
+
this._flushResolvers.push(resolve);
|
|
57
|
+
this._scheduleDrain();
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async close() {
|
|
62
|
+
this._closed = true;
|
|
63
|
+
// 等待 in-flight 异步 drain 完成,防止退出时丢失正在写入的数据
|
|
64
|
+
if (this._drainPromise) {
|
|
65
|
+
try { await this._drainPromise; } catch {}
|
|
66
|
+
}
|
|
67
|
+
// 同步兜底:排空 drain 完成后可能新入队的剩余项
|
|
68
|
+
this._drainSync();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Synchronous drain for process exit — guarantees no data loss
|
|
72
|
+
_drainSync() {
|
|
73
|
+
while (this._queue.length > 0) {
|
|
74
|
+
const item = this._queue.shift();
|
|
75
|
+
try {
|
|
76
|
+
appendFileSync(item.path, item.data);
|
|
77
|
+
if (item.onDone) item.onDone();
|
|
78
|
+
} catch {}
|
|
79
|
+
}
|
|
80
|
+
this._pendingBytes = 0;
|
|
81
|
+
for (const resolve of this._flushResolvers) resolve();
|
|
82
|
+
this._flushResolvers.length = 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
_scheduleDrain() {
|
|
86
|
+
if (this._draining) return;
|
|
87
|
+
this._draining = true;
|
|
88
|
+
// queueMicrotask 批量收集同 tick 的 append 调用
|
|
89
|
+
queueMicrotask(() => { this._drainPromise = this._drain(); });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async _drain() {
|
|
93
|
+
while (this._queue.length > 0) {
|
|
94
|
+
// Group by path — entries for the same file are batched together
|
|
95
|
+
const batch = this._queue.splice(0);
|
|
96
|
+
const byPath = new Map();
|
|
97
|
+
for (const item of batch) {
|
|
98
|
+
if (!byPath.has(item.path)) byPath.set(item.path, []);
|
|
99
|
+
byPath.get(item.path).push(item);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (const [path, items] of byPath) {
|
|
103
|
+
const callbacks = [];
|
|
104
|
+
let combined = '';
|
|
105
|
+
for (const item of items) {
|
|
106
|
+
combined += item.data;
|
|
107
|
+
if (item.onDone) callbacks.push(item.onDone);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
await appendFile(path, combined);
|
|
112
|
+
} catch {}
|
|
113
|
+
for (const cb of callbacks) {
|
|
114
|
+
try { cb(); } catch {}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let totalBytes = 0;
|
|
119
|
+
for (const [, items] of byPath) {
|
|
120
|
+
for (const item of items) totalBytes += Buffer.byteLength(item.data);
|
|
121
|
+
}
|
|
122
|
+
this._pendingBytes -= totalBytes;
|
|
123
|
+
if (this._pendingBytes < 0) this._pendingBytes = 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
this._draining = false;
|
|
127
|
+
|
|
128
|
+
for (const resolve of this._flushResolvers) resolve();
|
|
129
|
+
this._flushResolvers.length = 0;
|
|
130
|
+
}
|
|
131
|
+
}
|
package/server/lib/git-diff.js
CHANGED
|
@@ -3,7 +3,10 @@ import { join, sep } from 'node:path';
|
|
|
3
3
|
import { execFile } from 'node:child_process';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
// Windows:从无控制台的 worker node.exe 启动 git.exe 会弹可见控制台窗口,
|
|
7
|
+
// 包装层统一默认 windowsHide(POSIX no-op),覆盖本文件全部 git 调用。
|
|
8
|
+
const _execFileAsyncRaw = promisify(execFile);
|
|
9
|
+
const execFileAsync = (cmd, args, opts) => _execFileAsyncRaw(cmd, args, { windowsHide: true, ...opts });
|
|
7
10
|
|
|
8
11
|
const UNTRACKED_MAX_BYTES = 5 * 1024 * 1024;
|
|
9
12
|
const BINARY_PROBE_BYTES = 8192;
|