botmux 2.23.2 → 2.23.3-canary.2
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/adapters/backend/tmux-backend.d.ts +1 -0
- package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
- package/dist/adapters/backend/tmux-backend.js +23 -0
- package/dist/adapters/backend/tmux-backend.js.map +1 -1
- package/dist/adapters/backend/tmux-pipe-backend.d.ts +1 -0
- package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -1
- package/dist/adapters/backend/tmux-pipe-backend.js +25 -0
- package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -1
- package/dist/bot-registry.d.ts +26 -0
- package/dist/bot-registry.d.ts.map +1 -1
- package/dist/bot-registry.js +21 -0
- package/dist/bot-registry.js.map +1 -1
- package/dist/core/command-handler.d.ts.map +1 -1
- package/dist/core/command-handler.js +12 -10
- package/dist/core/command-handler.js.map +1 -1
- package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
- package/dist/core/dashboard-ipc-server.js +73 -53
- package/dist/core/dashboard-ipc-server.js.map +1 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +108 -11
- package/dist/daemon.js.map +1 -1
- package/dist/dashboard/web/app.js +3 -0
- package/dist/dashboard/web/app.js.map +1 -1
- package/dist/dashboard/web/bot-defaults.d.ts +2 -0
- package/dist/dashboard/web/bot-defaults.d.ts.map +1 -0
- package/dist/dashboard/web/bot-defaults.js +201 -0
- package/dist/dashboard/web/bot-defaults.js.map +1 -0
- package/dist/dashboard-web/app.js +113 -72
- package/dist/dashboard-web/index.html +1 -0
- package/dist/dashboard-web/style.css +12 -0
- package/dist/dashboard.js +43 -0
- package/dist/dashboard.js.map +1 -1
- package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
- package/dist/im/lark/event-dispatcher.js +13 -3
- package/dist/im/lark/event-dispatcher.js.map +1 -1
- package/dist/services/chat-first-seen-store.d.ts +18 -3
- package/dist/services/chat-first-seen-store.d.ts.map +1 -1
- package/dist/services/chat-first-seen-store.js +20 -14
- package/dist/services/chat-first-seen-store.js.map +1 -1
- package/dist/services/group-creator.d.ts +23 -0
- package/dist/services/group-creator.d.ts.map +1 -0
- package/dist/services/group-creator.js +75 -0
- package/dist/services/group-creator.js.map +1 -0
- package/dist/services/oncall-store.d.ts +79 -5
- package/dist/services/oncall-store.d.ts.map +1 -1
- package/dist/services/oncall-store.js +243 -43
- package/dist/services/oncall-store.js.map +1 -1
- package/dist/utils/bot-mention-dedup.d.ts +11 -0
- package/dist/utils/bot-mention-dedup.d.ts.map +1 -1
- package/dist/utils/bot-mention-dedup.js +18 -0
- package/dist/utils/bot-mention-dedup.js.map +1 -1
- package/dist/utils/file-lock.d.ts +2 -0
- package/dist/utils/file-lock.d.ts.map +1 -0
- package/dist/utils/file-lock.js +114 -0
- package/dist/utils/file-lock.js.map +1 -0
- package/package.json +1 -1
|
@@ -6,49 +6,89 @@
|
|
|
6
6
|
* Permission model is intentionally simple: anyone in the bot's allowedUsers
|
|
7
7
|
* can bind/unbind/edit (enforced at the call sites — daemon command handler
|
|
8
8
|
* + dashboard token gate). No per-chat owner list.
|
|
9
|
+
*
|
|
10
|
+
* Multi-process safety: 12 daemon processes + 1 dashboard process all share
|
|
11
|
+
* a single `bots.json`. Every write path goes through `withFileLock(path)`
|
|
12
|
+
* so a burst of concurrent auto-binds (each daemon sees a new chat for its
|
|
13
|
+
* own bot at roughly the same time) doesn't lose updates via read-modify-
|
|
14
|
+
* write race. The lock is also re-acquired around the read so the modify
|
|
15
|
+
* step always works against the latest on-disk snapshot.
|
|
9
16
|
*/
|
|
10
|
-
import {
|
|
17
|
+
import { promises as fsp } from 'node:fs';
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
11
19
|
import { getBot, getLoadedConfigPath } from '../bot-registry.js';
|
|
20
|
+
import { withFileLock } from '../utils/file-lock.js';
|
|
12
21
|
import { logger } from '../utils/logger.js';
|
|
13
|
-
function
|
|
14
|
-
const
|
|
15
|
-
if (!path)
|
|
16
|
-
throw new Error('Bot config path unknown — cannot persist oncall bindings');
|
|
17
|
-
const raw = JSON.parse(readFileSync(path, 'utf-8'));
|
|
22
|
+
async function readRawConfig(path) {
|
|
23
|
+
const raw = JSON.parse(await fsp.readFile(path, 'utf-8'));
|
|
18
24
|
if (!Array.isArray(raw))
|
|
19
25
|
throw new Error(`Config file is not a JSON array: ${path}`);
|
|
20
|
-
return
|
|
26
|
+
return raw;
|
|
21
27
|
}
|
|
22
|
-
function
|
|
23
|
-
|
|
28
|
+
async function writeRawConfigAtomic(path, raw) {
|
|
29
|
+
const tmp = path + '.tmp.' + process.pid;
|
|
30
|
+
await fsp.writeFile(tmp, JSON.stringify(raw, null, 2) + '\n', 'utf-8');
|
|
31
|
+
await fsp.rename(tmp, path);
|
|
24
32
|
}
|
|
25
33
|
function findEntryIndex(raw, larkAppId) {
|
|
26
34
|
return raw.findIndex((e) => e?.larkAppId === larkAppId);
|
|
27
35
|
}
|
|
36
|
+
function requireConfigPath() {
|
|
37
|
+
const p = getLoadedConfigPath();
|
|
38
|
+
if (!p)
|
|
39
|
+
throw new Error('Bot config path unknown — cannot persist oncall bindings');
|
|
40
|
+
return p;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Run a read-modify-write critical section against the bot config file under
|
|
44
|
+
* a cross-process lock. `mutate` runs against the freshest on-disk snapshot
|
|
45
|
+
* and decides what to write back; returning `undefined` means "no write".
|
|
46
|
+
*/
|
|
47
|
+
async function rmwBotEntry(larkAppId, mutate) {
|
|
48
|
+
const path = requireConfigPath();
|
|
49
|
+
return withFileLock(path, async () => {
|
|
50
|
+
const raw = await readRawConfig(path);
|
|
51
|
+
const idx = findEntryIndex(raw, larkAppId);
|
|
52
|
+
if (idx < 0)
|
|
53
|
+
return { ok: false, reason: 'bot_not_in_config' };
|
|
54
|
+
const entry = raw[idx];
|
|
55
|
+
const out = mutate(entry, raw);
|
|
56
|
+
if (out && typeof out === 'object' && 'write' in out) {
|
|
57
|
+
const wrap = out;
|
|
58
|
+
if (wrap.write)
|
|
59
|
+
await writeRawConfigAtomic(path, raw);
|
|
60
|
+
return { ok: true, result: wrap.result };
|
|
61
|
+
}
|
|
62
|
+
await writeRawConfigAtomic(path, raw);
|
|
63
|
+
return { ok: true, result: out };
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
// ─── Manual binding ───────────────────────────────────────────────────────
|
|
28
67
|
/**
|
|
29
68
|
* Upsert an oncall binding. Returns whether it was newly created.
|
|
30
69
|
*/
|
|
31
|
-
export function bindOncall(larkAppId, chatId, workingDir) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const next = { chatId, workingDir };
|
|
36
|
-
const { path, raw } = loadRawConfig();
|
|
37
|
-
const idx = findEntryIndex(raw, larkAppId);
|
|
38
|
-
if (idx < 0)
|
|
39
|
-
return { ok: false, reason: 'bot_not_in_config' };
|
|
40
|
-
const cur = Array.isArray(raw[idx].oncallChats) ? raw[idx].oncallChats : [];
|
|
41
|
-
const curIdx = cur.findIndex((c) => c?.chatId === chatId);
|
|
42
|
-
if (curIdx >= 0) {
|
|
43
|
-
// Replace wholesale — strips legacy fields (e.g. `owners`) so bots.json
|
|
44
|
-
// converges on the current schema rather than carrying dead keys.
|
|
45
|
-
cur[curIdx] = next;
|
|
70
|
+
export async function bindOncall(larkAppId, chatId, workingDir) {
|
|
71
|
+
let bot;
|
|
72
|
+
try {
|
|
73
|
+
bot = getBot(larkAppId);
|
|
46
74
|
}
|
|
47
|
-
|
|
48
|
-
|
|
75
|
+
catch {
|
|
76
|
+
return { ok: false, reason: 'bot_not_registered' };
|
|
49
77
|
}
|
|
50
|
-
|
|
51
|
-
|
|
78
|
+
const next = { chatId, workingDir };
|
|
79
|
+
const r = await rmwBotEntry(larkAppId, (entry) => {
|
|
80
|
+
const cur = Array.isArray(entry.oncallChats) ? entry.oncallChats : [];
|
|
81
|
+
const curIdx = cur.findIndex((c) => c?.chatId === chatId);
|
|
82
|
+
const created = curIdx < 0;
|
|
83
|
+
if (created)
|
|
84
|
+
cur.push(next);
|
|
85
|
+
else
|
|
86
|
+
cur[curIdx] = next; // wholesale replace strips legacy keys
|
|
87
|
+
entry.oncallChats = cur;
|
|
88
|
+
return { write: true, result: { created } };
|
|
89
|
+
});
|
|
90
|
+
if (!r.ok)
|
|
91
|
+
return { ok: false, reason: r.reason };
|
|
52
92
|
// Keep in-memory config in sync
|
|
53
93
|
const inMem = (bot.config.oncallChats ??= []);
|
|
54
94
|
const memIdx = inMem.findIndex(c => c.chatId === chatId);
|
|
@@ -57,25 +97,50 @@ export function bindOncall(larkAppId, chatId, workingDir) {
|
|
|
57
97
|
else
|
|
58
98
|
inMem.push(next);
|
|
59
99
|
logger.info(`[oncall:${larkAppId}] bind chat=${chatId} dir=${workingDir}`);
|
|
60
|
-
return { ok: true, entry: next, created:
|
|
100
|
+
return { ok: true, entry: next, created: r.result.created };
|
|
61
101
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
102
|
+
/**
|
|
103
|
+
* Unbind oncall for `chatId` and ALWAYS write a tombstone into
|
|
104
|
+
* `defaultOncallAutoboundChats`. The tombstone protects against the case
|
|
105
|
+
* where a user manually fiddled with a chat (bound then unbound, or just
|
|
106
|
+
* unbound) and we then mis-classify it as "new" on the next observation
|
|
107
|
+
* and re-auto-bind. Treating unbind as "default's one shot is spent" is
|
|
108
|
+
* symmetric with auto-bind already adding to the same list.
|
|
109
|
+
*
|
|
110
|
+
* Idempotent: never errors on "not bound". `wasBound` reports whether an
|
|
111
|
+
* existing binding was actually removed so callers can phrase UI text
|
|
112
|
+
* accordingly (the Lark `/oncall unbind` command still wants to say "未绑定"
|
|
113
|
+
* vs "已解绑").
|
|
114
|
+
*/
|
|
115
|
+
export async function unbindOncall(larkAppId, chatId) {
|
|
116
|
+
let bot;
|
|
117
|
+
try {
|
|
118
|
+
bot = getBot(larkAppId);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return { ok: false, reason: 'bot_not_registered' };
|
|
122
|
+
}
|
|
123
|
+
const r = await rmwBotEntry(larkAppId, (entry) => {
|
|
124
|
+
const cur = Array.isArray(entry.oncallChats) ? entry.oncallChats : [];
|
|
125
|
+
const wasBound = cur.some(c => c?.chatId === chatId);
|
|
126
|
+
entry.oncallChats = cur.filter((c) => c?.chatId !== chatId);
|
|
127
|
+
const tomb = Array.isArray(entry.defaultOncallAutoboundChats)
|
|
128
|
+
? entry.defaultOncallAutoboundChats : [];
|
|
129
|
+
if (!tomb.includes(chatId))
|
|
130
|
+
tomb.push(chatId);
|
|
131
|
+
entry.defaultOncallAutoboundChats = tomb;
|
|
132
|
+
return { write: true, result: { wasBound } };
|
|
133
|
+
});
|
|
134
|
+
if (!r.ok)
|
|
135
|
+
return { ok: false, reason: r.reason };
|
|
74
136
|
if (bot.config.oncallChats) {
|
|
75
137
|
bot.config.oncallChats = bot.config.oncallChats.filter(c => c.chatId !== chatId);
|
|
76
138
|
}
|
|
77
|
-
|
|
78
|
-
|
|
139
|
+
const inMemTomb = (bot.config.defaultOncallAutoboundChats ??= []);
|
|
140
|
+
if (!inMemTomb.includes(chatId))
|
|
141
|
+
inMemTomb.push(chatId);
|
|
142
|
+
logger.info(`[oncall:${larkAppId}] unbind chat=${chatId} wasBound=${r.result.wasBound} (tombstoned)`);
|
|
143
|
+
return { ok: true, wasBound: r.result.wasBound };
|
|
79
144
|
}
|
|
80
145
|
export function getOncallStatus(larkAppId, chatId) {
|
|
81
146
|
// Defensive: dashboard callers may probe with an app id whose bot isn't
|
|
@@ -91,4 +156,139 @@ export function getOncallStatus(larkAppId, chatId) {
|
|
|
91
156
|
}
|
|
92
157
|
return bot.config.oncallChats?.find(c => c.chatId === chatId);
|
|
93
158
|
}
|
|
159
|
+
// ─── Per-bot defaultOncall ───────────────────────────────────────────────
|
|
160
|
+
/** Read the current defaultOncall config + autobound list for a bot. Used by
|
|
161
|
+
* the dashboard GET route and by the daemon's auto-bind judge. Sync because
|
|
162
|
+
* it only reads the in-memory snapshot — file-level consistency comes from
|
|
163
|
+
* the daemon never racing with itself on reads. */
|
|
164
|
+
export function getBotDefaultOncall(larkAppId) {
|
|
165
|
+
let bot;
|
|
166
|
+
try {
|
|
167
|
+
bot = getBot(larkAppId);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return { defaultOncall: undefined, autoboundChats: [] };
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
defaultOncall: bot.config.defaultOncall,
|
|
174
|
+
autoboundChats: [...(bot.config.defaultOncallAutoboundChats ?? [])],
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Persist a defaultOncall change for the given bot. The dashboard PUT route
|
|
179
|
+
* is the only authorized caller — `since` is server-side authoritative so the
|
|
180
|
+
* frontend can't backdate the cut-off and accidentally include existing chats.
|
|
181
|
+
*
|
|
182
|
+
* `since` is stamped on every enabled save, not just the first transition.
|
|
183
|
+
* This matches the dashboard copy/requirement and prevents a later workingDir
|
|
184
|
+
* edit from reaching chats that were first observed before that edit.
|
|
185
|
+
*
|
|
186
|
+
* When disabled with an empty `workingDir`, the prior workingDir is preserved
|
|
187
|
+
* so the UI can round-trip (toggle off → toggle back on) without forcing the
|
|
188
|
+
* user to retype the path. Disable with a non-empty workingDir overwrites.
|
|
189
|
+
*/
|
|
190
|
+
export async function updateBotDefaultOncall(larkAppId, patch) {
|
|
191
|
+
let bot;
|
|
192
|
+
try {
|
|
193
|
+
bot = getBot(larkAppId);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return { ok: false, reason: 'bot_not_registered' };
|
|
197
|
+
}
|
|
198
|
+
let next = null;
|
|
199
|
+
const r = await rmwBotEntry(larkAppId, (entry) => {
|
|
200
|
+
const prior = entry.defaultOncall;
|
|
201
|
+
// Cut-off line: every enabled save re-stamps so a workingDir edit while
|
|
202
|
+
// enabled doesn't reach back to chats observed under the old setting.
|
|
203
|
+
const nextSince = patch.enabled ? Date.now() : (prior?.since ?? 0);
|
|
204
|
+
const trimmed = (patch.workingDir ?? '').trim();
|
|
205
|
+
const resolvedWorkingDir = patch.enabled
|
|
206
|
+
? trimmed
|
|
207
|
+
// Disabled + empty input → keep the prior path so the toggle is round-
|
|
208
|
+
// trippable. Disabled + explicit non-empty → user is replacing it.
|
|
209
|
+
: (trimmed || prior?.workingDir || '');
|
|
210
|
+
next = {
|
|
211
|
+
enabled: !!patch.enabled,
|
|
212
|
+
workingDir: resolvedWorkingDir,
|
|
213
|
+
since: nextSince,
|
|
214
|
+
};
|
|
215
|
+
entry.defaultOncall = next;
|
|
216
|
+
return { write: true, result: next };
|
|
217
|
+
});
|
|
218
|
+
if (!r.ok)
|
|
219
|
+
return { ok: false, reason: r.reason };
|
|
220
|
+
bot.config.defaultOncall = next;
|
|
221
|
+
logger.info(`[oncall:${larkAppId}] defaultOncall ${next.enabled ? 'enabled' : 'disabled'} ` +
|
|
222
|
+
`(workingDir=${next.workingDir || '∅'}, since=${next.since})`);
|
|
223
|
+
return { ok: true, defaultOncall: next };
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Auto-bind a chat as part of the defaultOncall flow. Atomically:
|
|
227
|
+
* 1. RE-CHECK tombstone + existing binding against the freshest on-disk
|
|
228
|
+
* snapshot. The daemon's fast-path tombstone check is informational —
|
|
229
|
+
* if a concurrent `unbindOncall` wrote a tombstone between then and
|
|
230
|
+
* now, the lock-internal view sees it and we skip.
|
|
231
|
+
* 2. Upsert the oncallChats entry (same shape as manual bindOncall).
|
|
232
|
+
* 3. Append chatId to defaultOncallAutoboundChats (idempotent).
|
|
233
|
+
*
|
|
234
|
+
* Returns `skipped: 'tombstoned'` when the lock-internal tombstone check
|
|
235
|
+
* trips, `skipped: 'already_bound'` when another writer (manual bind by
|
|
236
|
+
* the user, or a sibling daemon) bound the chat between the fast-path read
|
|
237
|
+
* and the lock acquisition. Neither is an error.
|
|
238
|
+
*/
|
|
239
|
+
export async function autoBindOncallFromDefault(larkAppId, chatId, workingDir) {
|
|
240
|
+
let bot;
|
|
241
|
+
try {
|
|
242
|
+
bot = getBot(larkAppId);
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return { ok: false, reason: 'bot_not_registered' };
|
|
246
|
+
}
|
|
247
|
+
const next = { chatId, workingDir };
|
|
248
|
+
const r = await rmwBotEntry(larkAppId, (entry) => {
|
|
249
|
+
// Authoritative re-check #1: tombstone wins. If a concurrent unbind or
|
|
250
|
+
// earlier autoBind wrote one, the user has effectively opted out — never
|
|
251
|
+
// overwrite that decision from the auto-bind path.
|
|
252
|
+
const tomb = Array.isArray(entry.defaultOncallAutoboundChats)
|
|
253
|
+
? entry.defaultOncallAutoboundChats : [];
|
|
254
|
+
if (tomb.includes(chatId)) {
|
|
255
|
+
return { write: false, result: { kind: 'skipped', reason: 'tombstoned' } };
|
|
256
|
+
}
|
|
257
|
+
// Authoritative re-check #2: existing binding wins. Could be from
|
|
258
|
+
// a sibling daemon, a manual /oncall bind, or a dashboard PUT racing
|
|
259
|
+
// with us. We never overwrite an existing binding with the default —
|
|
260
|
+
// the user's explicit choice (or a sibling's earlier auto-bind to its
|
|
261
|
+
// own default) is authoritative.
|
|
262
|
+
const cur = Array.isArray(entry.oncallChats) ? entry.oncallChats : [];
|
|
263
|
+
if (cur.some(c => c?.chatId === chatId)) {
|
|
264
|
+
return { write: false, result: { kind: 'skipped', reason: 'already_bound' } };
|
|
265
|
+
}
|
|
266
|
+
cur.push(next);
|
|
267
|
+
entry.oncallChats = cur;
|
|
268
|
+
tomb.push(chatId);
|
|
269
|
+
entry.defaultOncallAutoboundChats = tomb;
|
|
270
|
+
return { write: true, result: { kind: 'bound', created: true } };
|
|
271
|
+
});
|
|
272
|
+
if (!r.ok)
|
|
273
|
+
return { ok: false, reason: r.reason };
|
|
274
|
+
if (r.result.kind === 'skipped') {
|
|
275
|
+
return { ok: true, skipped: r.result.reason };
|
|
276
|
+
}
|
|
277
|
+
// Sync in-memory
|
|
278
|
+
const inMem = (bot.config.oncallChats ??= []);
|
|
279
|
+
const memIdx = inMem.findIndex(c => c.chatId === chatId);
|
|
280
|
+
if (memIdx >= 0)
|
|
281
|
+
inMem[memIdx] = next;
|
|
282
|
+
else
|
|
283
|
+
inMem.push(next);
|
|
284
|
+
const inMemAutobound = (bot.config.defaultOncallAutoboundChats ??= []);
|
|
285
|
+
if (!inMemAutobound.includes(chatId))
|
|
286
|
+
inMemAutobound.push(chatId);
|
|
287
|
+
logger.info(`[oncall:${larkAppId}] auto-bind (default) chat=${chatId} dir=${workingDir}`);
|
|
288
|
+
return { ok: true, entry: next, created: r.result.created };
|
|
289
|
+
}
|
|
290
|
+
// Test helper — read raw bots.json synchronously. Not for production use.
|
|
291
|
+
export function _readRawConfigSyncForTesting(path) {
|
|
292
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
293
|
+
}
|
|
94
294
|
//# sourceMappingURL=oncall-store.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"oncall-store.js","sourceRoot":"","sources":["../../src/services/oncall-store.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"oncall-store.js","sourceRoot":"","sources":["../../src/services/oncall-store.ts"],"names":[],"mappings":"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"}
|
|
@@ -21,6 +21,17 @@ export declare function markBotMentionMessageHandled(messageId: string | undefin
|
|
|
21
21
|
/** Did we already route this messageId? Returns true if the other path got
|
|
22
22
|
* there first within the TTL window. */
|
|
23
23
|
export declare function isBotMentionMessageHandled(messageId: string | undefined): boolean;
|
|
24
|
+
/** Atomic check-and-set: claim this messageId for processing. Returns `true`
|
|
25
|
+
* iff the caller is the first to claim within the TTL window — only that
|
|
26
|
+
* caller may enqueue the message.
|
|
27
|
+
*
|
|
28
|
+
* Use this instead of `isBotMentionMessageHandled` + `markBotMentionMessageHandled`
|
|
29
|
+
* whenever there is an `await` between the two: JS is single-threaded but the
|
|
30
|
+
* yield lets the OTHER dedup path slip in, pass its own check, mark, and
|
|
31
|
+
* enqueue — we'd then re-mark (no-op) and enqueue a second time. `tryClaim`
|
|
32
|
+
* collapses both halves into one synchronous step so the two paths can't both
|
|
33
|
+
* win, regardless of who runs first. */
|
|
34
|
+
export declare function tryClaimBotMentionMessage(messageId: string | undefined): boolean;
|
|
24
35
|
/** Test seam — drop everything. */
|
|
25
36
|
export declare function _resetForTest(): void;
|
|
26
37
|
//# sourceMappingURL=bot-mention-dedup.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bot-mention-dedup.d.ts","sourceRoot":"","sources":["../../src/utils/bot-mention-dedup.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAMH;uDACuD;AACvD,wBAAgB,4BAA4B,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAIhF;AAED;yCACyC;AACzC,wBAAgB,0BAA0B,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CASjF;AAED,mCAAmC;AACnC,wBAAgB,aAAa,IAAI,IAAI,CAEpC"}
|
|
1
|
+
{"version":3,"file":"bot-mention-dedup.d.ts","sourceRoot":"","sources":["../../src/utils/bot-mention-dedup.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAMH;uDACuD;AACvD,wBAAgB,4BAA4B,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAIhF;AAED;yCACyC;AACzC,wBAAgB,0BAA0B,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CASjF;AAED;;;;;;;;;yCASyC;AACzC,wBAAgB,yBAAyB,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAKhF;AAED,mCAAmC;AACnC,wBAAgB,aAAa,IAAI,IAAI,CAEpC"}
|
|
@@ -39,6 +39,24 @@ export function isBotMentionMessageHandled(messageId) {
|
|
|
39
39
|
}
|
|
40
40
|
return true;
|
|
41
41
|
}
|
|
42
|
+
/** Atomic check-and-set: claim this messageId for processing. Returns `true`
|
|
43
|
+
* iff the caller is the first to claim within the TTL window — only that
|
|
44
|
+
* caller may enqueue the message.
|
|
45
|
+
*
|
|
46
|
+
* Use this instead of `isBotMentionMessageHandled` + `markBotMentionMessageHandled`
|
|
47
|
+
* whenever there is an `await` between the two: JS is single-threaded but the
|
|
48
|
+
* yield lets the OTHER dedup path slip in, pass its own check, mark, and
|
|
49
|
+
* enqueue — we'd then re-mark (no-op) and enqueue a second time. `tryClaim`
|
|
50
|
+
* collapses both halves into one synchronous step so the two paths can't both
|
|
51
|
+
* win, regardless of who runs first. */
|
|
52
|
+
export function tryClaimBotMentionMessage(messageId) {
|
|
53
|
+
if (!messageId)
|
|
54
|
+
return false;
|
|
55
|
+
if (isBotMentionMessageHandled(messageId))
|
|
56
|
+
return false;
|
|
57
|
+
markBotMentionMessageHandled(messageId);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
42
60
|
/** Test seam — drop everything. */
|
|
43
61
|
export function _resetForTest() {
|
|
44
62
|
seen.clear();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bot-mention-dedup.js","sourceRoot":"","sources":["../../src/utils/bot-mention-dedup.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,MAAM,MAAM,GAAG,MAAM,CAAC;AAEtB,MAAM,IAAI,GAAG,IAAI,GAAG,EAAkB,CAAC,CAAC,mCAAmC;AAE3E;uDACuD;AACvD,MAAM,UAAU,4BAA4B,CAAC,SAA6B;IACxE,IAAI,CAAC,SAAS;QAAE,OAAO;IACvB,EAAE,EAAE,CAAC;IACL,IAAI,CAAC,GAAG,CAAC,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,MAAM,CAAC,CAAC;AAC3C,CAAC;AAED;yCACyC;AACzC,MAAM,UAAU,0BAA0B,CAAC,SAA6B;IACtE,IAAI,CAAC,SAAS;QAAE,OAAO,KAAK,CAAC;IAC7B,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;IACtC,IAAI,SAAS,KAAK,SAAS;QAAE,OAAO,KAAK,CAAC;IAC1C,IAAI,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC;QAC3B,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;QACvB,OAAO,KAAK,CAAC;IACf,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,mCAAmC;AACnC,MAAM,UAAU,aAAa;IAC3B,IAAI,CAAC,KAAK,EAAE,CAAC;AACf,CAAC;AAED,SAAS,EAAE;IACT,oEAAoE;IACpE,IAAI,IAAI,CAAC,IAAI,GAAG,EAAE;QAAE,OAAO;IAC3B,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IACvB,KAAK,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,IAAI,EAAE,CAAC;QAC1B,IAAI,CAAC,GAAG,GAAG;YAAE,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;IAC9B,CAAC;AACH,CAAC"}
|
|
1
|
+
{"version":3,"file":"bot-mention-dedup.js","sourceRoot":"","sources":["../../src/utils/bot-mention-dedup.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,MAAM,MAAM,GAAG,MAAM,CAAC;AAEtB,MAAM,IAAI,GAAG,IAAI,GAAG,EAAkB,CAAC,CAAC,mCAAmC;AAE3E;uDACuD;AACvD,MAAM,UAAU,4BAA4B,CAAC,SAA6B;IACxE,IAAI,CAAC,SAAS;QAAE,OAAO;IACvB,EAAE,EAAE,CAAC;IACL,IAAI,CAAC,GAAG,CAAC,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,MAAM,CAAC,CAAC;AAC3C,CAAC;AAED;yCACyC;AACzC,MAAM,UAAU,0BAA0B,CAAC,SAA6B;IACtE,IAAI,CAAC,SAAS;QAAE,OAAO,KAAK,CAAC;IAC7B,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;IACtC,IAAI,SAAS,KAAK,SAAS;QAAE,OAAO,KAAK,CAAC;IAC1C,IAAI,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC;QAC3B,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;QACvB,OAAO,KAAK,CAAC;IACf,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;;;;;;yCASyC;AACzC,MAAM,UAAU,yBAAyB,CAAC,SAA6B;IACrE,IAAI,CAAC,SAAS;QAAE,OAAO,KAAK,CAAC;IAC7B,IAAI,0BAA0B,CAAC,SAAS,CAAC;QAAE,OAAO,KAAK,CAAC;IACxD,4BAA4B,CAAC,SAAS,CAAC,CAAC;IACxC,OAAO,IAAI,CAAC;AACd,CAAC;AAED,mCAAmC;AACnC,MAAM,UAAU,aAAa;IAC3B,IAAI,CAAC,KAAK,EAAE,CAAC;AACf,CAAC;AAED,SAAS,EAAE;IACT,oEAAoE;IACpE,IAAI,IAAI,CAAC,IAAI,GAAG,EAAE;QAAE,OAAO;IAC3B,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IACvB,KAAK,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,IAAI,EAAE,CAAC;QAC1B,IAAI,CAAC,GAAG,GAAG;YAAE,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;IAC9B,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"file-lock.d.ts","sourceRoot":"","sources":["../../src/utils/file-lock.ts"],"names":[],"mappings":"AAsCA,wBAAsB,YAAY,CAAC,CAAC,EAAE,UAAU,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CA6D1F"}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-process advisory lock for a single file. Used to serialize
|
|
3
|
+
* read-modify-write of shared JSON config (e.g. `bots.json` from multiple
|
|
4
|
+
* daemon processes + the dashboard).
|
|
5
|
+
*
|
|
6
|
+
* Acquisition: atomic `open(path + '.lock', 'wx')`. The filesystem makes
|
|
7
|
+
* O_CREAT|O_EXCL atomic, so exactly one waiter wins.
|
|
8
|
+
*
|
|
9
|
+
* Stale-break: a holder that crashes mid-section leaves the lock file
|
|
10
|
+
* behind with its dead PID. To reclaim it we use the atomic POSIX
|
|
11
|
+
* `rename(lock, lock.stale-<random>)`: rename succeeds for exactly ONE
|
|
12
|
+
* caller (the source has to exist), so only ONE waiter is the rightful
|
|
13
|
+
* stale-breaker. Everyone else gets ENOENT and loops back to acquire.
|
|
14
|
+
* This avoids the classic "two waiters both unlink, one deletes the other's
|
|
15
|
+
* just-acquired live lock" race that read+unlink-based schemes have.
|
|
16
|
+
*
|
|
17
|
+
* Not reentrant. Don't nest `withFileLock` calls on the same path within
|
|
18
|
+
* the same process — the inner call would wait MAX_WAIT_MS and then time
|
|
19
|
+
* out. (We could allow reentrancy via PID-equal check, but our callers
|
|
20
|
+
* don't need it and the equality check would re-open the stale-break race.)
|
|
21
|
+
*/
|
|
22
|
+
import { promises as fsp } from 'node:fs';
|
|
23
|
+
import { randomBytes } from 'node:crypto';
|
|
24
|
+
import { logger } from './logger.js';
|
|
25
|
+
const MAX_WAIT_MS = 5_000;
|
|
26
|
+
const RETRY_BASE_MS = 25;
|
|
27
|
+
// Minimum age before we'll consider stale-breaking a lock with a dead PID.
|
|
28
|
+
// Prevents racing on freshly-acquired locks where the holder might not have
|
|
29
|
+
// finished writing its PID file yet.
|
|
30
|
+
const MIN_STALE_AGE_MS = 100;
|
|
31
|
+
async function isPidAlive(pid) {
|
|
32
|
+
if (!pid)
|
|
33
|
+
return false;
|
|
34
|
+
if (pid === process.pid)
|
|
35
|
+
return true;
|
|
36
|
+
try {
|
|
37
|
+
process.kill(pid, 0);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export async function withFileLock(targetPath, fn) {
|
|
45
|
+
const lockPath = targetPath + '.lock';
|
|
46
|
+
const start = Date.now();
|
|
47
|
+
while (true) {
|
|
48
|
+
try {
|
|
49
|
+
const fh = await fsp.open(lockPath, 'wx');
|
|
50
|
+
await fh.writeFile(String(process.pid));
|
|
51
|
+
await fh.close();
|
|
52
|
+
try {
|
|
53
|
+
return await fn();
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
try {
|
|
57
|
+
await fsp.unlink(lockPath);
|
|
58
|
+
}
|
|
59
|
+
catch { /* already gone, tolerate */ }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
if (e.code !== 'EEXIST')
|
|
64
|
+
throw e;
|
|
65
|
+
// EEXIST: someone holds the lock. Check whether it's stale (dead PID
|
|
66
|
+
// + old enough). If so, attempt an atomic rename — POSIX guarantees
|
|
67
|
+
// exactly one caller succeeds.
|
|
68
|
+
let holder = 0;
|
|
69
|
+
let lockAgeMs = Infinity;
|
|
70
|
+
try {
|
|
71
|
+
const [raw, stat] = await Promise.all([
|
|
72
|
+
fsp.readFile(lockPath, 'utf-8'),
|
|
73
|
+
fsp.stat(lockPath),
|
|
74
|
+
]);
|
|
75
|
+
holder = parseInt(raw, 10) || 0;
|
|
76
|
+
lockAgeMs = Date.now() - stat.mtimeMs;
|
|
77
|
+
}
|
|
78
|
+
catch (re) {
|
|
79
|
+
if (re.code === 'ENOENT')
|
|
80
|
+
continue; // released between EEXIST and read
|
|
81
|
+
throw re;
|
|
82
|
+
}
|
|
83
|
+
const breakable = holder
|
|
84
|
+
&& lockAgeMs >= MIN_STALE_AGE_MS
|
|
85
|
+
&& !(await isPidAlive(holder));
|
|
86
|
+
if (breakable) {
|
|
87
|
+
// Atomic rename: only ONE caller wins. The winner is responsible
|
|
88
|
+
// for cleaning up the stale carcass; losers get ENOENT and retry
|
|
89
|
+
// the lock acquisition on the next iteration.
|
|
90
|
+
const stalePath = `${lockPath}.stale.${process.pid}.${randomBytes(4).toString('hex')}`;
|
|
91
|
+
try {
|
|
92
|
+
await fsp.rename(lockPath, stalePath);
|
|
93
|
+
logger.warn(`[file-lock] broke stale lock at ${lockPath} (dead pid ${holder}, age ${lockAgeMs}ms)`);
|
|
94
|
+
try {
|
|
95
|
+
await fsp.unlink(stalePath);
|
|
96
|
+
}
|
|
97
|
+
catch { /* tolerate */ }
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
catch (renameErr) {
|
|
101
|
+
if (renameErr.code === 'ENOENT')
|
|
102
|
+
continue; // another waiter beat us
|
|
103
|
+
throw renameErr;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (Date.now() - start > MAX_WAIT_MS) {
|
|
107
|
+
throw new Error(`file-lock timeout waiting for ${lockPath} ` +
|
|
108
|
+
`(held by pid ${holder || '?'}, age ${Math.round(lockAgeMs)}ms)`);
|
|
109
|
+
}
|
|
110
|
+
await new Promise(r => setTimeout(r, RETRY_BASE_MS + Math.random() * RETRY_BASE_MS));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
//# sourceMappingURL=file-lock.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"file-lock.js","sourceRoot":"","sources":["../../src/utils/file-lock.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED