feishu-user-plugin 1.3.7 → 1.3.9
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/.claude-plugin/plugin.json +13 -3
- package/CHANGELOG.md +87 -0
- package/README.md +20 -4
- package/package.json +10 -6
- package/proto/lark.proto +10 -0
- package/scripts/capture-feishu-protobuf.js +86 -0
- package/scripts/check-changelog.js +31 -0
- package/scripts/check-docs-sync.js +41 -0
- package/scripts/check-tool-count.js +32 -7
- package/scripts/decode-feishu-protobuf.js +115 -0
- package/scripts/explore-card-protobuf.js +144 -0
- package/scripts/explore-image-minimize.js +163 -0
- package/scripts/generate-release-artifacts.js +318 -0
- package/scripts/probe-feishu-docx.js +203 -0
- package/scripts/sync-server-json.js +71 -0
- package/scripts/sync-team-skills.sh +109 -7
- package/scripts/test-wiki-attach-fallback.js +71 -0
- package/scripts/test-ws-events.js +84 -0
- package/skills/feishu-user-plugin/SKILL.md +77 -5
- package/skills/feishu-user-plugin/references/CLAUDE.md +208 -297
- package/src/auth/cookie.js +30 -0
- package/src/auth/credentials.js +85 -0
- package/src/auth/profile-router.js +248 -0
- package/src/auth/uat.js +231 -0
- package/src/cli.js +86 -42
- package/src/clients/official/base.js +12 -248
- package/src/clients/user.js +19 -31
- package/src/config.js +13 -8
- package/src/events/cursor.js +103 -0
- package/src/events/event-buffer.js +103 -0
- package/src/events/event-log.js +151 -0
- package/src/events/index.js +12 -0
- package/src/events/lockfile.js +126 -0
- package/src/events/owner.js +73 -0
- package/src/events/ws-server.js +156 -0
- package/src/oauth.js +48 -7
- package/src/resolver.js +10 -0
- package/src/server.js +285 -3
- package/src/setup.js +100 -11
- package/src/test-all.js +12 -9
- package/src/test-events-cursor.js +56 -0
- package/src/test-events-lockfile.js +36 -0
- package/src/test-events-log.js +67 -0
- package/src/test-events-owner.js +64 -0
- package/src/test-fixtures/doc-blocks/sample-1.json +1256 -0
- package/src/test-read-doc-markdown.js +61 -0
- package/src/test-switch-profile.js +171 -0
- package/src/tools/_registry.js +1 -0
- package/src/tools/diagnostics.js +10 -3
- package/src/tools/docs.js +93 -3
- package/src/tools/events.js +174 -0
- package/src/tools/messaging-bot.js +2 -3
- package/src/tools/messaging-user.js +23 -14
- package/src/tools/profile.js +43 -7
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// src/events/cursor.js — events.cursor.json + drain protocol.
|
|
2
|
+
//
|
|
3
|
+
// cursor.json schema: { version: 1, file: "events.jsonl", offset: <int> }
|
|
4
|
+
// Atomic write: tmp + rename.
|
|
5
|
+
// Drain: take per-operation mutex, read cursor, read events.jsonl[offset:], advance.
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { withMutex } = require('./lockfile');
|
|
12
|
+
const { readFrom } = require('./event-log');
|
|
13
|
+
|
|
14
|
+
const CURSOR_FILENAME = 'events.cursor.json';
|
|
15
|
+
const CURSOR_LOCK_FILENAME = 'events.cursor.lock';
|
|
16
|
+
|
|
17
|
+
function _cursorPath(dir) { return path.join(dir, CURSOR_FILENAME); }
|
|
18
|
+
function _lockPath(dir) { return path.join(dir, CURSOR_LOCK_FILENAME); }
|
|
19
|
+
|
|
20
|
+
function _readCursor(cursorPath, defaultFileSize) {
|
|
21
|
+
try {
|
|
22
|
+
const raw = fs.readFileSync(cursorPath, 'utf8');
|
|
23
|
+
const parsed = JSON.parse(raw);
|
|
24
|
+
if (!parsed || parsed.version !== 1) throw new Error('bad version');
|
|
25
|
+
if (typeof parsed.file !== 'string' || typeof parsed.offset !== 'number') throw new Error('bad shape');
|
|
26
|
+
return parsed;
|
|
27
|
+
} catch (e) {
|
|
28
|
+
// Conservative reset: skip history (offset = current size), don't replay.
|
|
29
|
+
if (e.code !== 'ENOENT') {
|
|
30
|
+
console.error(`[feishu-user-plugin] cursor.json read failed: ${e.message}; resetting to current EOF.`);
|
|
31
|
+
}
|
|
32
|
+
return { version: 1, file: 'events.jsonl', offset: defaultFileSize };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function _writeCursorAtomic(cursorPath, cursor) {
|
|
37
|
+
const tmpPath = cursorPath + '.tmp.' + process.pid;
|
|
38
|
+
fs.writeFileSync(tmpPath, JSON.stringify(cursor) + '\n', { mode: 0o600 });
|
|
39
|
+
fs.renameSync(tmpPath, cursorPath);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Sanity check: offset in [0, fileSize]. Returns clamped cursor.
|
|
43
|
+
function _sanitize(cursor, fileSize) {
|
|
44
|
+
if (cursor.offset < 0 || cursor.offset > fileSize) {
|
|
45
|
+
console.error(`[feishu-user-plugin] cursor offset ${cursor.offset} out of range [0, ${fileSize}]; resetting to EOF.`);
|
|
46
|
+
return { ...cursor, offset: fileSize };
|
|
47
|
+
}
|
|
48
|
+
return cursor;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Drain: read events from current offset; advance cursor (unless peek).
|
|
52
|
+
//
|
|
53
|
+
// Returns { events, nextOffset, advanced }.
|
|
54
|
+
// `events` is the raw event objects from events.jsonl (filter applied by caller).
|
|
55
|
+
function drain(dir, { peek = false } = {}) {
|
|
56
|
+
const logPath = path.join(dir, 'events.jsonl');
|
|
57
|
+
const curPath = _cursorPath(dir);
|
|
58
|
+
const lockPath = _lockPath(dir);
|
|
59
|
+
|
|
60
|
+
return withMutex(lockPath, () => {
|
|
61
|
+
let stat;
|
|
62
|
+
try { stat = fs.statSync(logPath); } catch (e) {
|
|
63
|
+
if (e.code === 'ENOENT') return { events: [], nextOffset: 0, advanced: false };
|
|
64
|
+
throw e;
|
|
65
|
+
}
|
|
66
|
+
let cursor = _readCursor(curPath, stat.size);
|
|
67
|
+
cursor = _sanitize(cursor, stat.size);
|
|
68
|
+
|
|
69
|
+
const { events, nextOffset } = readFrom(logPath, cursor.offset);
|
|
70
|
+
if (!peek && nextOffset !== cursor.offset) {
|
|
71
|
+
_writeCursorAtomic(curPath, { version: 1, file: 'events.jsonl', offset: nextOffset });
|
|
72
|
+
return { events, nextOffset, advanced: true };
|
|
73
|
+
}
|
|
74
|
+
// Always persist cursor.json on first drain (ENOENT) so subsequent calls
|
|
75
|
+
// don't trigger the conservative-reset-to-EOF path.
|
|
76
|
+
if (!peek) {
|
|
77
|
+
const exists = (() => { try { fs.statSync(curPath); return true; } catch (_) { return false; } })();
|
|
78
|
+
if (!exists) _writeCursorAtomic(curPath, { version: 1, file: 'events.jsonl', offset: nextOffset });
|
|
79
|
+
}
|
|
80
|
+
return { events, nextOffset, advanced: false };
|
|
81
|
+
}, { staleMs: 30_000 });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Read cursor without advancing or locking — for diagnostics.
|
|
85
|
+
function readSnapshot(dir) {
|
|
86
|
+
const logPath = path.join(dir, 'events.jsonl');
|
|
87
|
+
const curPath = _cursorPath(dir);
|
|
88
|
+
let fileSize = 0;
|
|
89
|
+
try { fileSize = fs.statSync(logPath).size; } catch (_) {}
|
|
90
|
+
const cursor = _readCursor(curPath, fileSize);
|
|
91
|
+
return { cursor, fileSize, pending: Math.max(0, fileSize - cursor.offset) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Manual reset (used by force-rotate path).
|
|
95
|
+
function resetCursorTo(dir, offset) {
|
|
96
|
+
const curPath = _cursorPath(dir);
|
|
97
|
+
const lockPath = _lockPath(dir);
|
|
98
|
+
withMutex(lockPath, () => {
|
|
99
|
+
_writeCursorAtomic(curPath, { version: 1, file: 'events.jsonl', offset });
|
|
100
|
+
}, { staleMs: 30_000 });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { drain, readSnapshot, resetCursorTo, CURSOR_FILENAME };
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// src/events/event-buffer.js — in-memory FIFO buffer.
|
|
2
|
+
//
|
|
3
|
+
// v1.3.9: This is now the **disk-full fallback** only. Normal flow writes
|
|
4
|
+
// to events.jsonl via src/events/event-log.js. The owner falls back to this
|
|
5
|
+
// buffer when fs.appendFileSync fails (ENOSPC etc); get_new_events does NOT
|
|
6
|
+
// read from this buffer in the owner-arbitrated path. Pre-v1.3.8 behaviour
|
|
7
|
+
// is preserved when no logPath is configured.
|
|
8
|
+
//
|
|
9
|
+
// (rest of file unchanged)
|
|
10
|
+
//
|
|
11
|
+
// What this owns:
|
|
12
|
+
// - _events: ordered list of events (oldest first)
|
|
13
|
+
// - cap: max retained; oldest dropped when full
|
|
14
|
+
// - push(event): append + trim
|
|
15
|
+
// - drain(filter?): remove and return matching events
|
|
16
|
+
// - peek(filter?): return matching events without removing
|
|
17
|
+
// - size, cap accessors
|
|
18
|
+
|
|
19
|
+
const DEFAULT_CAP = 1000;
|
|
20
|
+
|
|
21
|
+
class EventBuffer {
|
|
22
|
+
constructor({ cap = DEFAULT_CAP } = {}) {
|
|
23
|
+
this._events = [];
|
|
24
|
+
this._cap = Math.max(1, cap | 0);
|
|
25
|
+
this._totalSeen = 0;
|
|
26
|
+
this._totalDropped = 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
push(event) {
|
|
30
|
+
if (!event || typeof event !== 'object') return;
|
|
31
|
+
if (!event._received_at) event._received_at = Math.floor(Date.now() / 1000);
|
|
32
|
+
this._events.push(event);
|
|
33
|
+
this._totalSeen++;
|
|
34
|
+
while (this._events.length > this._cap) {
|
|
35
|
+
this._events.shift();
|
|
36
|
+
this._totalDropped++;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
drain(filter) {
|
|
41
|
+
if (!filter) {
|
|
42
|
+
const out = this._events;
|
|
43
|
+
this._events = [];
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
const fn = this._compileFilter(filter);
|
|
47
|
+
const kept = [];
|
|
48
|
+
const drained = [];
|
|
49
|
+
for (const e of this._events) {
|
|
50
|
+
if (fn(e)) drained.push(e);
|
|
51
|
+
else kept.push(e);
|
|
52
|
+
}
|
|
53
|
+
this._events = kept;
|
|
54
|
+
return drained;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
peek(filter) {
|
|
58
|
+
if (!filter) return [...this._events];
|
|
59
|
+
const fn = this._compileFilter(filter);
|
|
60
|
+
return this._events.filter(fn);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
size() { return this._events.length; }
|
|
64
|
+
cap() { return this._cap; }
|
|
65
|
+
stats() {
|
|
66
|
+
return {
|
|
67
|
+
size: this._events.length,
|
|
68
|
+
cap: this._cap,
|
|
69
|
+
totalSeen: this._totalSeen,
|
|
70
|
+
totalDropped: this._totalDropped,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Filter language (intentionally narrow — extend on demand):
|
|
75
|
+
// { event_type: "im.message.receive_v1" } — exact match on type
|
|
76
|
+
// { chat_id: "oc_zzz" } — extract from event payload
|
|
77
|
+
// { since_seconds: 60 } — only events received in last N sec
|
|
78
|
+
// { event_types: ["a", "b"] } — any of these types
|
|
79
|
+
// Multiple keys = AND.
|
|
80
|
+
_compileFilter(filter) {
|
|
81
|
+
return (e) => {
|
|
82
|
+
if (filter.event_type && e.event_type !== filter.event_type) return false;
|
|
83
|
+
if (filter.event_types && !filter.event_types.includes(e.event_type)) return false;
|
|
84
|
+
if (filter.chat_id) {
|
|
85
|
+
const chatId = this._extractChatId(e);
|
|
86
|
+
if (chatId !== filter.chat_id) return false;
|
|
87
|
+
}
|
|
88
|
+
if (filter.since_seconds) {
|
|
89
|
+
const cutoff = Math.floor(Date.now() / 1000) - filter.since_seconds;
|
|
90
|
+
if ((e._received_at || 0) < cutoff) return false;
|
|
91
|
+
}
|
|
92
|
+
return true;
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
_extractChatId(e) {
|
|
97
|
+
return e?.event?.message?.chat_id
|
|
98
|
+
|| e?.event?.chat_id
|
|
99
|
+
|| null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { EventBuffer, DEFAULT_CAP };
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// src/events/event-log.js — events.jsonl management.
|
|
2
|
+
//
|
|
3
|
+
// Single-writer (owner) appends with `\n`-terminated lines.
|
|
4
|
+
// Multi-reader (any process) seeks from cursor offset to EOF, parses lines,
|
|
5
|
+
// tolerates a trailing partial line.
|
|
6
|
+
//
|
|
7
|
+
// On owner takeover, callers should run repairTail() once before appending
|
|
8
|
+
// to ensure append-only invariant is intact.
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
function ensureLog(logPath) {
|
|
16
|
+
try { fs.mkdirSync(path.dirname(logPath), { recursive: true, mode: 0o700 }); } catch (_) {}
|
|
17
|
+
try { fs.openSync(logPath, 'a'); } catch (_) {}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function appendEvent(logPath, eventObj) {
|
|
21
|
+
const line = JSON.stringify(eventObj) + '\n';
|
|
22
|
+
fs.appendFileSync(logPath, line, { encoding: 'utf8' });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Read from `offset` to EOF; return { events: [...], nextOffset }.
|
|
26
|
+
// Tolerates a trailing partial line (no \n) — partial line not consumed,
|
|
27
|
+
// nextOffset stops at the last full \n.
|
|
28
|
+
function readFrom(logPath, offset) {
|
|
29
|
+
let stat;
|
|
30
|
+
try { stat = fs.statSync(logPath); } catch (e) {
|
|
31
|
+
if (e.code === 'ENOENT') return { events: [], nextOffset: 0, fileSize: 0 };
|
|
32
|
+
throw e;
|
|
33
|
+
}
|
|
34
|
+
const fileSize = stat.size;
|
|
35
|
+
if (offset >= fileSize) return { events: [], nextOffset: offset, fileSize };
|
|
36
|
+
if (offset < 0) offset = 0;
|
|
37
|
+
|
|
38
|
+
const fd = fs.openSync(logPath, 'r');
|
|
39
|
+
try {
|
|
40
|
+
const length = fileSize - offset;
|
|
41
|
+
const buf = Buffer.allocUnsafe(length);
|
|
42
|
+
fs.readSync(fd, buf, 0, length, offset);
|
|
43
|
+
const text = buf.toString('utf8');
|
|
44
|
+
// Find last \n; everything after it is partial.
|
|
45
|
+
const lastNl = text.lastIndexOf('\n');
|
|
46
|
+
if (lastNl < 0) {
|
|
47
|
+
// Entire chunk is partial; no events consumed.
|
|
48
|
+
return { events: [], nextOffset: offset, fileSize };
|
|
49
|
+
}
|
|
50
|
+
const fullText = text.slice(0, lastNl + 1); // include the \n
|
|
51
|
+
const events = [];
|
|
52
|
+
for (const line of fullText.split('\n')) {
|
|
53
|
+
if (!line) continue;
|
|
54
|
+
try { events.push(JSON.parse(line)); } catch (_) { /* skip malformed */ }
|
|
55
|
+
}
|
|
56
|
+
return { events, nextOffset: offset + Buffer.byteLength(fullText, 'utf8'), fileSize };
|
|
57
|
+
} finally {
|
|
58
|
+
fs.closeSync(fd);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Repair: scan the tail for last \n; truncate file there if file ends with
|
|
63
|
+
// non-\n bytes (partial line from a crash).
|
|
64
|
+
function repairTail(logPath, scanBytes = 8192) {
|
|
65
|
+
let stat;
|
|
66
|
+
try { stat = fs.statSync(logPath); } catch (e) {
|
|
67
|
+
if (e.code === 'ENOENT') return { repaired: false, sizeBefore: 0, sizeAfter: 0 };
|
|
68
|
+
throw e;
|
|
69
|
+
}
|
|
70
|
+
if (stat.size === 0) return { repaired: false, sizeBefore: 0, sizeAfter: 0 };
|
|
71
|
+
|
|
72
|
+
const fd = fs.openSync(logPath, 'r+');
|
|
73
|
+
try {
|
|
74
|
+
const readLen = Math.min(scanBytes, stat.size);
|
|
75
|
+
const buf = Buffer.allocUnsafe(readLen);
|
|
76
|
+
fs.readSync(fd, buf, 0, readLen, stat.size - readLen);
|
|
77
|
+
// If the file already ends in \n, no repair needed.
|
|
78
|
+
if (buf[buf.length - 1] === 0x0A) return { repaired: false, sizeBefore: stat.size, sizeAfter: stat.size };
|
|
79
|
+
// Find last \n in the scanned tail.
|
|
80
|
+
let i = buf.length - 1;
|
|
81
|
+
while (i >= 0 && buf[i] !== 0x0A) i--;
|
|
82
|
+
if (i < 0) {
|
|
83
|
+
// No \n in last 8 KB — pathological. Don't truncate (might lose data); leave as is.
|
|
84
|
+
return { repaired: false, sizeBefore: stat.size, sizeAfter: stat.size, warning: 'no \\n in scan window' };
|
|
85
|
+
}
|
|
86
|
+
const truncateAt = stat.size - readLen + i + 1; // +1 to keep the \n
|
|
87
|
+
fs.ftruncateSync(fd, truncateAt);
|
|
88
|
+
return { repaired: true, sizeBefore: stat.size, sizeAfter: truncateAt };
|
|
89
|
+
} finally {
|
|
90
|
+
fs.closeSync(fd);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Defer-rotate. Returns true if rotation happened.
|
|
95
|
+
//
|
|
96
|
+
// Conditions: size > sizeThresholdBytes AND (cursorOffset >= size - 4096) — i.e.,
|
|
97
|
+
// consumer is within 4 KB of EOF.
|
|
98
|
+
function maybeRotate(logPath, cursorOffset, sizeThresholdBytes) {
|
|
99
|
+
let stat;
|
|
100
|
+
try { stat = fs.statSync(logPath); } catch (e) {
|
|
101
|
+
if (e.code === 'ENOENT') return { rotated: false };
|
|
102
|
+
throw e;
|
|
103
|
+
}
|
|
104
|
+
if (stat.size <= sizeThresholdBytes) return { rotated: false, sizeBytes: stat.size };
|
|
105
|
+
if (cursorOffset < stat.size - 4096) {
|
|
106
|
+
return { rotated: false, deferred: true, sizeBytes: stat.size, cursorOffset };
|
|
107
|
+
}
|
|
108
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
109
|
+
const droppedPath = logPath + '.dropped-' + ts;
|
|
110
|
+
fs.renameSync(logPath, droppedPath);
|
|
111
|
+
// Recreate empty events.jsonl.
|
|
112
|
+
fs.openSync(logPath, 'a');
|
|
113
|
+
return { rotated: true, droppedPath, sizeBytes: stat.size };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Force rotate (called when log > hardCap and consumer is too far behind).
|
|
117
|
+
// Drops the current log to .dropped-<ts>, writes a synthetic _rotated event
|
|
118
|
+
// to the new log so consumers see the warning.
|
|
119
|
+
function forceRotate(logPath, prevSize) {
|
|
120
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
121
|
+
const droppedPath = logPath + '.dropped-' + ts;
|
|
122
|
+
try { fs.renameSync(logPath, droppedPath); } catch (e) {
|
|
123
|
+
if (e.code !== 'ENOENT') throw e;
|
|
124
|
+
}
|
|
125
|
+
appendEvent(logPath, {
|
|
126
|
+
event_id: '_rotated',
|
|
127
|
+
ts: ts * 1000,
|
|
128
|
+
profile: '_system',
|
|
129
|
+
payload: { warning: 'force_rotated_log', prev_size: prevSize, dropped_file: path.basename(droppedPath) },
|
|
130
|
+
});
|
|
131
|
+
return { droppedPath };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Cleanup of old .dropped-<ts> files. Keep last `keepDays` worth.
|
|
135
|
+
function cleanupDropped(logPath, keepDays = 7) {
|
|
136
|
+
const dir = path.dirname(logPath);
|
|
137
|
+
const base = path.basename(logPath);
|
|
138
|
+
const cutoffMs = Date.now() - keepDays * 86400_000;
|
|
139
|
+
let entries;
|
|
140
|
+
try { entries = fs.readdirSync(dir); } catch (_) { return; }
|
|
141
|
+
for (const name of entries) {
|
|
142
|
+
if (!name.startsWith(base + '.dropped-')) continue;
|
|
143
|
+
const fp = path.join(dir, name);
|
|
144
|
+
try {
|
|
145
|
+
const stat = fs.statSync(fp);
|
|
146
|
+
if (stat.mtimeMs < cutoffMs) fs.unlinkSync(fp);
|
|
147
|
+
} catch (_) {}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = { ensureLog, appendEvent, readFrom, repairTail, maybeRotate, forceRotate, cleanupDropped };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// src/events/index.js — barrel import for the events subsystem.
|
|
2
|
+
const { EventBuffer, DEFAULT_CAP } = require('./event-buffer');
|
|
3
|
+
const { createWSServer } = require('./ws-server');
|
|
4
|
+
const owner = require('./owner');
|
|
5
|
+
const cursor = require('./cursor');
|
|
6
|
+
const log = require('./event-log');
|
|
7
|
+
const lockfile = require('./lockfile');
|
|
8
|
+
|
|
9
|
+
module.exports = {
|
|
10
|
+
EventBuffer, DEFAULT_CAP, createWSServer,
|
|
11
|
+
owner, cursor, log, lockfile,
|
|
12
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// src/events/lockfile.js — generic O_CREAT|O_EXCL advisory lock.
|
|
2
|
+
//
|
|
3
|
+
// Two flavors:
|
|
4
|
+
// - acquireLongLived(path, info) → { release(), heartbeat() } for owner-style
|
|
5
|
+
// locks (one process holds for the duration of its lifetime, mtime = liveness).
|
|
6
|
+
// Steal: rename old → .stale-<pid>, then EXCL create. Returns null if active.
|
|
7
|
+
// - withMutex(path, fn, { staleMs }) → runs fn() while holding a per-operation
|
|
8
|
+
// mutex. Stale lock files (mtime older than staleMs) are reaped.
|
|
9
|
+
//
|
|
10
|
+
// Both reuse the same `fs.openSync(p, 'wx')` pattern v1.3.5 UAT lock established.
|
|
11
|
+
// No new dep.
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
function _ensureDir(p) {
|
|
19
|
+
try { fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o700 }); } catch (_) {}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function _writeLockBody(fd, info) {
|
|
23
|
+
const body = JSON.stringify({ version: 1, pid: process.pid, start_time: Math.floor(Date.now() / 1000), ...info });
|
|
24
|
+
fs.writeSync(fd, body);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Long-lived (owner) acquisition.
|
|
28
|
+
//
|
|
29
|
+
// Returns { release(), heartbeat() } on success.
|
|
30
|
+
// Returns null if lock active (mtime within staleMs).
|
|
31
|
+
function acquireLongLived(lockPath, { info = {}, staleMs = 60_000 } = {}) {
|
|
32
|
+
_ensureDir(lockPath);
|
|
33
|
+
|
|
34
|
+
// If lock exists, check staleness.
|
|
35
|
+
let stat;
|
|
36
|
+
try { stat = fs.statSync(lockPath); } catch (e) {
|
|
37
|
+
if (e.code !== 'ENOENT') throw e;
|
|
38
|
+
}
|
|
39
|
+
if (stat) {
|
|
40
|
+
const ageMs = Date.now() - stat.mtimeMs;
|
|
41
|
+
if (ageMs < staleMs) return null;
|
|
42
|
+
// Stale — try to steal. Rename out of the way to make room for EXCL create.
|
|
43
|
+
const stolenPath = lockPath + '.stale-' + process.pid + '-' + Date.now();
|
|
44
|
+
try { fs.renameSync(lockPath, stolenPath); } catch (e) {
|
|
45
|
+
// Race: someone else got there first; try again from scratch.
|
|
46
|
+
if (e.code === 'ENOENT') return acquireLongLived(lockPath, { info, staleMs });
|
|
47
|
+
throw e;
|
|
48
|
+
}
|
|
49
|
+
// Schedule cleanup of the stolen file after a moment to avoid disk litter.
|
|
50
|
+
setTimeout(() => { try { fs.unlinkSync(stolenPath); } catch (_) {} }, 5000).unref();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Atomic EXCL create — only one process wins this race.
|
|
54
|
+
let fd;
|
|
55
|
+
try {
|
|
56
|
+
fd = fs.openSync(lockPath, 'wx');
|
|
57
|
+
} catch (e) {
|
|
58
|
+
if (e.code === 'EEXIST') return null; // someone else won
|
|
59
|
+
throw e;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
_writeLockBody(fd, info);
|
|
63
|
+
} finally {
|
|
64
|
+
fs.closeSync(fd);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
release() {
|
|
69
|
+
try { fs.unlinkSync(lockPath); } catch (_) {}
|
|
70
|
+
},
|
|
71
|
+
heartbeat() {
|
|
72
|
+
try {
|
|
73
|
+
const now = new Date();
|
|
74
|
+
fs.utimesSync(lockPath, now, now);
|
|
75
|
+
} catch (e) {
|
|
76
|
+
// If the lock file was stolen, our heartbeat will fail. Caller must
|
|
77
|
+
// detect this via separate check (e.g., re-stat + compare pid in body).
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
return true;
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Per-operation mutex. Synchronous wrapper for short critical sections.
|
|
86
|
+
function withMutex(lockPath, fn, { staleMs = 30_000, retries = 30, retryDelayMs = 100 } = {}) {
|
|
87
|
+
_ensureDir(lockPath);
|
|
88
|
+
|
|
89
|
+
const start = Date.now();
|
|
90
|
+
while (true) {
|
|
91
|
+
// Stale reap.
|
|
92
|
+
try {
|
|
93
|
+
const stat = fs.statSync(lockPath);
|
|
94
|
+
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
95
|
+
try { fs.unlinkSync(lockPath); } catch (_) {}
|
|
96
|
+
}
|
|
97
|
+
} catch (e) {
|
|
98
|
+
if (e.code !== 'ENOENT') throw e;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let fd;
|
|
102
|
+
try {
|
|
103
|
+
fd = fs.openSync(lockPath, 'wx');
|
|
104
|
+
} catch (e) {
|
|
105
|
+
if (e.code !== 'EEXIST') throw e;
|
|
106
|
+
// Wait + retry.
|
|
107
|
+
if (Date.now() - start > staleMs) {
|
|
108
|
+
throw new Error(`withMutex: lock ${lockPath} held longer than ${staleMs}ms`);
|
|
109
|
+
}
|
|
110
|
+
const sleepUntil = Date.now() + retryDelayMs;
|
|
111
|
+
while (Date.now() < sleepUntil) { /* busy wait — short delay */ }
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
fs.writeSync(fd, JSON.stringify({ pid: process.pid, ts: Date.now() }));
|
|
117
|
+
fs.closeSync(fd);
|
|
118
|
+
const result = fn();
|
|
119
|
+
return result;
|
|
120
|
+
} finally {
|
|
121
|
+
try { fs.unlinkSync(lockPath); } catch (_) {}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = { acquireLongLived, withMutex };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// src/events/owner.js — ws-owner.lock acquire / heartbeat / takeover.
|
|
2
|
+
//
|
|
3
|
+
// Wraps lockfile.acquireLongLived for the WS-ownership use case.
|
|
4
|
+
// Exposes an EventEmitter-style interface: 'become_owner', 'lose_owner', 'state_change'.
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const { acquireLongLived } = require('./lockfile');
|
|
11
|
+
|
|
12
|
+
const OWNER_LOCK_FILENAME = 'ws-owner.lock';
|
|
13
|
+
const HEARTBEAT_INTERVAL_MS = 15_000;
|
|
14
|
+
const STALE_MS = 60_000;
|
|
15
|
+
const TAKEOVER_POLL_INTERVAL_MS = 30_000;
|
|
16
|
+
|
|
17
|
+
function _ownerLockPath(dir) { return path.join(dir, OWNER_LOCK_FILENAME); }
|
|
18
|
+
|
|
19
|
+
// Try to claim ownership (or steal if stale + force, or just steal if stale).
|
|
20
|
+
// Returns:
|
|
21
|
+
// { isOwner: true, release(), heartbeat() } if successful
|
|
22
|
+
// { isOwner: false, ownerInfo } otherwise
|
|
23
|
+
function tryClaim(dir, { info = {}, force = false } = {}) {
|
|
24
|
+
const lockPath = _ownerLockPath(dir);
|
|
25
|
+
|
|
26
|
+
if (force) {
|
|
27
|
+
// Force takeover: rename existing out of the way.
|
|
28
|
+
try { fs.renameSync(lockPath, lockPath + '.forced-' + Date.now()); } catch (e) {
|
|
29
|
+
if (e.code !== 'ENOENT') throw e;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const handle = acquireLongLived(lockPath, { info, staleMs: STALE_MS });
|
|
34
|
+
if (!handle) {
|
|
35
|
+
// Read existing lock body for diagnostics.
|
|
36
|
+
let body = null;
|
|
37
|
+
try { body = JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch (_) {}
|
|
38
|
+
let mtimeMs = null;
|
|
39
|
+
try { mtimeMs = fs.statSync(lockPath).mtimeMs; } catch (_) {}
|
|
40
|
+
return { isOwner: false, ownerInfo: { ...body, mtimeMs, last_heartbeat_age_seconds: mtimeMs ? Math.floor((Date.now() - mtimeMs) / 1000) : null } };
|
|
41
|
+
}
|
|
42
|
+
return { isOwner: true, ...handle };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Read current owner info without modifying anything.
|
|
46
|
+
function readOwnerInfo(dir) {
|
|
47
|
+
const lockPath = _ownerLockPath(dir);
|
|
48
|
+
let body = null;
|
|
49
|
+
let mtimeMs = null;
|
|
50
|
+
try {
|
|
51
|
+
body = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
52
|
+
mtimeMs = fs.statSync(lockPath).mtimeMs;
|
|
53
|
+
} catch (_) {}
|
|
54
|
+
if (!body) return { exists: false };
|
|
55
|
+
const ageSec = mtimeMs ? Math.floor((Date.now() - mtimeMs) / 1000) : null;
|
|
56
|
+
return {
|
|
57
|
+
exists: true,
|
|
58
|
+
pid: body.pid,
|
|
59
|
+
start_time: body.start_time,
|
|
60
|
+
mtimeMs,
|
|
61
|
+
last_heartbeat_age_seconds: ageSec,
|
|
62
|
+
alive: ageSec !== null && ageSec * 1000 < STALE_MS,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = {
|
|
67
|
+
tryClaim,
|
|
68
|
+
readOwnerInfo,
|
|
69
|
+
OWNER_LOCK_FILENAME,
|
|
70
|
+
HEARTBEAT_INTERVAL_MS,
|
|
71
|
+
STALE_MS,
|
|
72
|
+
TAKEOVER_POLL_INTERVAL_MS,
|
|
73
|
+
};
|