opencode-collaboration 0.2.3
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/LICENSE +194 -0
- package/README.md +224 -0
- package/README.zh-CN.md +224 -0
- package/commands/list-agents.md +8 -0
- package/commands/peers-inbox.md +8 -0
- package/commands/peers-name.md +8 -0
- package/commands/peers-outbox.md +8 -0
- package/commands/peers.md +8 -0
- package/dist/commands.d.ts +29 -0
- package/dist/commands.js +95 -0
- package/dist/config.d.ts +31 -0
- package/dist/config.js +50 -0
- package/dist/delivery.d.ts +42 -0
- package/dist/delivery.js +177 -0
- package/dist/feedback.d.ts +8 -0
- package/dist/feedback.js +40 -0
- package/dist/format.d.ts +32 -0
- package/dist/format.js +107 -0
- package/dist/gating.d.ts +4 -0
- package/dist/gating.js +16 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.js +410 -0
- package/dist/listener.d.ts +37 -0
- package/dist/listener.js +335 -0
- package/dist/outbox.d.ts +12 -0
- package/dist/outbox.js +110 -0
- package/dist/permissions.d.ts +47 -0
- package/dist/permissions.js +194 -0
- package/dist/queue.d.ts +89 -0
- package/dist/queue.js +824 -0
- package/dist/registry.d.ts +70 -0
- package/dist/registry.js +308 -0
- package/dist/sender.d.ts +27 -0
- package/dist/sender.js +139 -0
- package/dist/session-runtime.d.ts +40 -0
- package/dist/session-runtime.js +355 -0
- package/dist/session-tracker.d.ts +16 -0
- package/dist/session-tracker.js +39 -0
- package/dist/tools/peers-tools.d.ts +26 -0
- package/dist/tools/peers-tools.js +173 -0
- package/dist/transport.d.ts +20 -0
- package/dist/transport.js +46 -0
- package/dist/tui.d.ts +3 -0
- package/dist/tui.js +228 -0
- package/dist/types.d.ts +162 -0
- package/dist/types.js +1 -0
- package/package.json +93 -0
package/dist/queue.js
ADDED
|
@@ -0,0 +1,824 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Endpoint-scoped durable delivery queue and held inbox.
|
|
3
|
+
*/
|
|
4
|
+
import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeSync, } from "node:fs";
|
|
5
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
6
|
+
import { dirname, join, resolve } from "node:path";
|
|
7
|
+
const COMPLETED_DEDUPE_RETENTION_MS = 86_400_000;
|
|
8
|
+
const LOCK_STALE_MS = 30_000;
|
|
9
|
+
const LOCK_WAIT_MS = 5_000;
|
|
10
|
+
const LOCK_POLL_MS = 10;
|
|
11
|
+
export function stableSpoolEndpointId(directory) {
|
|
12
|
+
const digest = createHash("sha256").update(`workspace-v1\0${resolve(directory)}`).digest("hex");
|
|
13
|
+
return `workspace-${digest.slice(0, 24)}`;
|
|
14
|
+
}
|
|
15
|
+
export function stableSessionEndpointId(sessionId) {
|
|
16
|
+
const digest = createHash("sha256").update(`session-v1\0${sessionId}`).digest("hex");
|
|
17
|
+
return `session-${digest.slice(0, 24)}`;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* True when the session's durable spool holds any records — undelivered
|
|
21
|
+
* (queued/held/inflight) or delivered (done, retained 24h for dedupe).
|
|
22
|
+
* Startup adopts exactly these sessions (plus snapshot-busy ones): pending
|
|
23
|
+
* work must resume, and done records must load or a post-restart retry would
|
|
24
|
+
* deliver a duplicate. Everything else stays unpublished until real activity.
|
|
25
|
+
*/
|
|
26
|
+
export function hasSpoolRecords(config, sessionId) {
|
|
27
|
+
const spoolDir = join(config.spoolDir, stableSessionEndpointId(sessionId));
|
|
28
|
+
for (const state of ["queued", "held", "inflight", "done"]) {
|
|
29
|
+
try {
|
|
30
|
+
if (readdirSync(join(spoolDir, state)).some((file) => file.endsWith(".json")))
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// missing state dir — no records of this state
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
export function createSessionMessageQueue(opts) {
|
|
40
|
+
return MessageQueue({
|
|
41
|
+
endpointId: stableSessionEndpointId(opts.sessionId),
|
|
42
|
+
maxQueue: opts.config.maxQueue,
|
|
43
|
+
maxHeld: opts.config.maxHeld,
|
|
44
|
+
heldExpiryMs: opts.config.heldExpiryMs,
|
|
45
|
+
inboxFile: opts.config.inboxFile,
|
|
46
|
+
logger: opts.logger,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
export function createProcessMessageQueue(opts) {
|
|
50
|
+
return MessageQueue({
|
|
51
|
+
endpointId: stableSpoolEndpointId(opts.directory),
|
|
52
|
+
maxQueue: opts.config.maxQueue,
|
|
53
|
+
maxHeld: opts.config.maxHeld,
|
|
54
|
+
heldExpiryMs: opts.config.heldExpiryMs,
|
|
55
|
+
inboxFile: opts.config.inboxFile,
|
|
56
|
+
logger: opts.logger,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
function migrationStateFiles(spoolDir, state) {
|
|
60
|
+
try {
|
|
61
|
+
return readdirSync(join(spoolDir, state)).filter((file) => file.endsWith(".json")).sort();
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
if (err.code === "ENOENT")
|
|
65
|
+
return [];
|
|
66
|
+
throw err;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function syncMigrationDirectory(directory) {
|
|
70
|
+
if (process.platform === "win32")
|
|
71
|
+
return;
|
|
72
|
+
const fd = openSync(directory, "r");
|
|
73
|
+
try {
|
|
74
|
+
fsyncSync(fd);
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
closeSync(fd);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function ensureMigrationDirectory(directory) {
|
|
81
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
82
|
+
chmodSync(directory, 0o700);
|
|
83
|
+
}
|
|
84
|
+
function persistMigrationRecord(target, record) {
|
|
85
|
+
const directory = dirname(target);
|
|
86
|
+
ensureMigrationDirectory(directory);
|
|
87
|
+
const temporary = join(directory, `.${process.pid}.${randomBytes(8).toString("hex")}.tmp`);
|
|
88
|
+
const fd = openSync(temporary, "wx", 0o600);
|
|
89
|
+
try {
|
|
90
|
+
writeSync(fd, JSON.stringify(record));
|
|
91
|
+
fsyncSync(fd);
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
closeSync(fd);
|
|
95
|
+
}
|
|
96
|
+
chmodSync(temporary, 0o600);
|
|
97
|
+
renameSync(temporary, target);
|
|
98
|
+
syncMigrationDirectory(directory);
|
|
99
|
+
}
|
|
100
|
+
function readSequence(path) {
|
|
101
|
+
try {
|
|
102
|
+
const record = JSON.parse(readFileSync(path, "utf8"));
|
|
103
|
+
return typeof record.value === "number" && Number.isSafeInteger(record.value) ? record.value : null;
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
if (err.code === "ENOENT")
|
|
107
|
+
return null;
|
|
108
|
+
throw err;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function sameFileContents(first, second) {
|
|
112
|
+
try {
|
|
113
|
+
return readFileSync(first, "utf8") === readFileSync(second, "utf8");
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Move Task 1's directory-scoped spool into Task 2's compatibility session.
|
|
121
|
+
* Individual renames are atomic and the operation is restart-safe. Both
|
|
122
|
+
* endpoint locks are acquired in endpoint-ID order so concurrent recovery
|
|
123
|
+
* cannot deadlock or race normal queue state transitions.
|
|
124
|
+
*/
|
|
125
|
+
export async function migrateWorkspaceSpool(opts) {
|
|
126
|
+
const sourceEndpointId = stableSpoolEndpointId(opts.directory);
|
|
127
|
+
const targetEndpointId = stableSessionEndpointId(opts.targetSessionId);
|
|
128
|
+
const result = {
|
|
129
|
+
migrated: 0,
|
|
130
|
+
deduplicated: 0,
|
|
131
|
+
quarantined: 0,
|
|
132
|
+
sourceEndpointId,
|
|
133
|
+
targetEndpointId,
|
|
134
|
+
};
|
|
135
|
+
if (sourceEndpointId === targetEndpointId)
|
|
136
|
+
return result;
|
|
137
|
+
const sourceDir = join(dirname(opts.config.inboxFile), "spool", sourceEndpointId);
|
|
138
|
+
const targetDir = join(dirname(opts.config.inboxFile), "spool", targetEndpointId);
|
|
139
|
+
if (!existsSync(sourceDir))
|
|
140
|
+
return result;
|
|
141
|
+
const sourceHasState = ['queued', 'held', 'inflight', 'done']
|
|
142
|
+
.some((state) => migrationStateFiles(sourceDir, state).length > 0);
|
|
143
|
+
if (!sourceHasState && !existsSync(join(sourceDir, "sequence")))
|
|
144
|
+
return result;
|
|
145
|
+
const queueOptions = {
|
|
146
|
+
maxQueue: opts.config.maxQueue,
|
|
147
|
+
maxHeld: opts.config.maxHeld,
|
|
148
|
+
heldExpiryMs: opts.config.heldExpiryMs,
|
|
149
|
+
inboxFile: opts.config.inboxFile,
|
|
150
|
+
logger: opts.logger,
|
|
151
|
+
};
|
|
152
|
+
const queues = new Map([
|
|
153
|
+
[sourceEndpointId, MessageQueue({ ...queueOptions, endpointId: sourceEndpointId })],
|
|
154
|
+
[targetEndpointId, MessageQueue({ ...queueOptions, endpointId: targetEndpointId })],
|
|
155
|
+
]);
|
|
156
|
+
const [firstId, secondId] = [sourceEndpointId, targetEndpointId].sort();
|
|
157
|
+
const collisions = [];
|
|
158
|
+
queues.get(firstId).withExclusiveLock(() => {
|
|
159
|
+
queues.get(secondId).withExclusiveLock(() => {
|
|
160
|
+
for (const state of ["queued", "held", "inflight", "done"]) {
|
|
161
|
+
for (const file of migrationStateFiles(sourceDir, state)) {
|
|
162
|
+
const source = join(sourceDir, state, file);
|
|
163
|
+
const targetLocations = ["queued", "held", "inflight", "done"]
|
|
164
|
+
.map((targetState) => join(targetDir, targetState, file))
|
|
165
|
+
.filter((path) => existsSync(path));
|
|
166
|
+
if (targetLocations.length === 0) {
|
|
167
|
+
const target = join(targetDir, state, file);
|
|
168
|
+
renameSync(source, target);
|
|
169
|
+
syncMigrationDirectory(join(sourceDir, state));
|
|
170
|
+
syncMigrationDirectory(join(targetDir, state));
|
|
171
|
+
result.migrated++;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (targetLocations.length === 1 && targetLocations[0] === join(targetDir, state, file) && sameFileContents(source, targetLocations[0])) {
|
|
175
|
+
unlinkSync(source);
|
|
176
|
+
syncMigrationDirectory(join(sourceDir, state));
|
|
177
|
+
result.deduplicated++;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const base = join(targetDir, "migration-quarantine", sourceEndpointId, state);
|
|
181
|
+
ensureMigrationDirectory(base);
|
|
182
|
+
let quarantine = join(base, file);
|
|
183
|
+
if (existsSync(quarantine) && !sameFileContents(source, quarantine)) {
|
|
184
|
+
const digest = createHash("sha256").update(readFileSync(source)).digest("hex").slice(0, 16);
|
|
185
|
+
quarantine = join(base, `${file.slice(0, -5)}.${digest}.json`);
|
|
186
|
+
}
|
|
187
|
+
if (existsSync(quarantine) && sameFileContents(source, quarantine)) {
|
|
188
|
+
unlinkSync(source);
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
renameSync(source, quarantine);
|
|
192
|
+
syncMigrationDirectory(base);
|
|
193
|
+
}
|
|
194
|
+
syncMigrationDirectory(join(sourceDir, state));
|
|
195
|
+
result.quarantined++;
|
|
196
|
+
collisions.push({ state, file, quarantine });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const sourceSequencePath = join(sourceDir, "sequence");
|
|
200
|
+
const targetSequencePath = join(targetDir, "sequence");
|
|
201
|
+
const sourceSequence = readSequence(sourceSequencePath);
|
|
202
|
+
const targetSequence = readSequence(targetSequencePath);
|
|
203
|
+
if (sourceSequence !== null) {
|
|
204
|
+
const mergedSequence = Math.max(sourceSequence, targetSequence ?? 0);
|
|
205
|
+
if (targetSequence !== mergedSequence)
|
|
206
|
+
persistMigrationRecord(targetSequencePath, { value: mergedSequence });
|
|
207
|
+
unlinkSync(sourceSequencePath);
|
|
208
|
+
syncMigrationDirectory(sourceDir);
|
|
209
|
+
}
|
|
210
|
+
persistMigrationRecord(join(targetDir, ".migrations", `${sourceEndpointId}.json`), {
|
|
211
|
+
version: 1,
|
|
212
|
+
sourceEndpointId,
|
|
213
|
+
targetEndpointId,
|
|
214
|
+
completedAt: Date.now(),
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
for (const collision of collisions) {
|
|
219
|
+
await opts.logger("warn", "workspace spool record quarantined during session migration", {
|
|
220
|
+
sourceEndpointId,
|
|
221
|
+
targetEndpointId,
|
|
222
|
+
state: collision.state,
|
|
223
|
+
file: collision.file,
|
|
224
|
+
quarantine: collision.quarantine,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
await opts.logger("info", "workspace spool migration completed", { ...result });
|
|
228
|
+
return result;
|
|
229
|
+
}
|
|
230
|
+
export function MessageQueue(opts) {
|
|
231
|
+
let queue = [];
|
|
232
|
+
let held = [];
|
|
233
|
+
const spoolDir = join(dirname(opts.inboxFile), "spool", opts.endpointId ?? "legacy");
|
|
234
|
+
const heldExpiryMs = opts.heldExpiryMs ?? 300_000;
|
|
235
|
+
const debounceMs = opts.debounceMs ?? 1_000;
|
|
236
|
+
const recentContent = new Map();
|
|
237
|
+
const lockTicketsDir = join(spoolDir, ".lock-tickets");
|
|
238
|
+
const sequenceFile = join(spoolDir, "sequence");
|
|
239
|
+
function ensureEndpointDirectories() {
|
|
240
|
+
mkdirSync(spoolDir, { recursive: true, mode: 0o700 });
|
|
241
|
+
chmodSync(spoolDir, 0o700);
|
|
242
|
+
for (const state of ["queued", "held", "inflight", "done"]) {
|
|
243
|
+
const directory = join(spoolDir, state);
|
|
244
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
245
|
+
chmodSync(directory, 0o700);
|
|
246
|
+
}
|
|
247
|
+
mkdirSync(lockTicketsDir, { recursive: true, mode: 0o700 });
|
|
248
|
+
chmodSync(lockTicketsDir, 0o700);
|
|
249
|
+
}
|
|
250
|
+
function removeClaim(path) {
|
|
251
|
+
try {
|
|
252
|
+
unlinkSync(path);
|
|
253
|
+
syncDirectory(lockTicketsDir);
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
if (err.code !== "ENOENT")
|
|
257
|
+
throw err;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function createClaim(path, record) {
|
|
261
|
+
const fd = openSync(path, "wx", 0o600);
|
|
262
|
+
try {
|
|
263
|
+
writeSync(fd, JSON.stringify(record));
|
|
264
|
+
fsyncSync(fd);
|
|
265
|
+
}
|
|
266
|
+
catch (err) {
|
|
267
|
+
try {
|
|
268
|
+
closeSync(fd);
|
|
269
|
+
}
|
|
270
|
+
finally {
|
|
271
|
+
removeClaim(path);
|
|
272
|
+
}
|
|
273
|
+
throw err;
|
|
274
|
+
}
|
|
275
|
+
try {
|
|
276
|
+
closeSync(fd);
|
|
277
|
+
syncDirectory(lockTicketsDir);
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
removeClaim(path);
|
|
281
|
+
throw err;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
function claimFiles() {
|
|
285
|
+
return readdirSync(lockTicketsDir).filter((file) => /^choosing-[a-f0-9]{32}\.json$/.test(file) || /^ticket-\d{16}-[a-f0-9]{32}\.json$/.test(file));
|
|
286
|
+
}
|
|
287
|
+
function recoverStaleClaims() {
|
|
288
|
+
for (const file of claimFiles()) {
|
|
289
|
+
const path = join(lockTicketsDir, file);
|
|
290
|
+
try {
|
|
291
|
+
if (Date.now() - statSync(path).mtimeMs > LOCK_STALE_MS)
|
|
292
|
+
removeClaim(path);
|
|
293
|
+
}
|
|
294
|
+
catch (err) {
|
|
295
|
+
if (err.code !== "ENOENT")
|
|
296
|
+
throw err;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
function ticketNumber(file) {
|
|
301
|
+
const match = /^ticket-(\d{16})-[a-f0-9]{32}\.json$/.exec(file);
|
|
302
|
+
return match ? Number(match[1]) : null;
|
|
303
|
+
}
|
|
304
|
+
// Filesystem bakery lock: every doorway/queue entry has a unique path that
|
|
305
|
+
// is never reused, so stale cleanup and release can only unlink the exact
|
|
306
|
+
// claim they observed or created. Choosing entries prevent a late contender
|
|
307
|
+
// from publishing a lower ticket after another contender has entered.
|
|
308
|
+
function acquireLockTicket(deadline) {
|
|
309
|
+
const token = randomBytes(16).toString("hex");
|
|
310
|
+
const choosingPath = join(lockTicketsDir, `choosing-${token}.json`);
|
|
311
|
+
let ticketPath = null;
|
|
312
|
+
createClaim(choosingPath, { kind: "choosing", token, pid: process.pid, createdAt: Date.now() });
|
|
313
|
+
try {
|
|
314
|
+
recoverStaleClaims();
|
|
315
|
+
const highest = claimFiles().reduce((max, file) => Math.max(max, ticketNumber(file) ?? 0), 0);
|
|
316
|
+
if (Date.now() >= deadline)
|
|
317
|
+
throw new Error(`timed out acquiring message spool lock: ${lockTicketsDir}`);
|
|
318
|
+
const ticket = highest + 1;
|
|
319
|
+
ticketPath = join(lockTicketsDir, `ticket-${String(ticket).padStart(16, "0")}-${token}.json`);
|
|
320
|
+
createClaim(ticketPath, { kind: "ticket", ticket, token, pid: process.pid, createdAt: Date.now() });
|
|
321
|
+
}
|
|
322
|
+
finally {
|
|
323
|
+
removeClaim(choosingPath);
|
|
324
|
+
}
|
|
325
|
+
for (;;) {
|
|
326
|
+
recoverStaleClaims();
|
|
327
|
+
if (!existsSync(ticketPath)) {
|
|
328
|
+
throw new Error(`message spool lock claim expired before admission: ${ticketPath}`);
|
|
329
|
+
}
|
|
330
|
+
if (Date.now() >= deadline) {
|
|
331
|
+
removeClaim(ticketPath);
|
|
332
|
+
throw new Error(`timed out acquiring message spool lock: ${lockTicketsDir}`);
|
|
333
|
+
}
|
|
334
|
+
const claims = claimFiles();
|
|
335
|
+
const choosing = claims.some((file) => file.startsWith("choosing-"));
|
|
336
|
+
const tickets = claims
|
|
337
|
+
.flatMap((file) => {
|
|
338
|
+
const ticket = ticketNumber(file);
|
|
339
|
+
return ticket === null ? [] : [{ file, ticket }];
|
|
340
|
+
})
|
|
341
|
+
.sort((a, b) => a.ticket - b.ticket || (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
|
|
342
|
+
if (!choosing && tickets[0]?.file === ticketPath.slice(lockTicketsDir.length + 1))
|
|
343
|
+
return ticketPath;
|
|
344
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, LOCK_POLL_MS);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
function withEndpointLock(operation) {
|
|
348
|
+
ensureEndpointDirectories();
|
|
349
|
+
const ticketPath = acquireLockTicket(Date.now() + LOCK_WAIT_MS);
|
|
350
|
+
try {
|
|
351
|
+
return operation();
|
|
352
|
+
}
|
|
353
|
+
finally {
|
|
354
|
+
removeClaim(ticketPath);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
function stateFileCount(state) {
|
|
358
|
+
return stateRecords(state).length;
|
|
359
|
+
}
|
|
360
|
+
function stateRecords(state) {
|
|
361
|
+
const directory = join(spoolDir, state);
|
|
362
|
+
let files = [];
|
|
363
|
+
try {
|
|
364
|
+
files = readdirSync(directory).filter((file) => file.endsWith(".json"));
|
|
365
|
+
}
|
|
366
|
+
catch (err) {
|
|
367
|
+
if (err.code !== "ENOENT") {
|
|
368
|
+
void opts.logger("warn", "failed to read message spool directory", { state, error: String(err) });
|
|
369
|
+
}
|
|
370
|
+
return [];
|
|
371
|
+
}
|
|
372
|
+
const records = [];
|
|
373
|
+
for (const file of files) {
|
|
374
|
+
try {
|
|
375
|
+
const record = JSON.parse(readFileSync(join(directory, file), "utf8"));
|
|
376
|
+
if (record?.version !== 2 ||
|
|
377
|
+
record.state !== state ||
|
|
378
|
+
!record.message?.id ||
|
|
379
|
+
!record.message.from?.instanceId ||
|
|
380
|
+
!Number.isFinite(record.message.sentAt) ||
|
|
381
|
+
(state === "held" && !Number.isFinite(record.message.expiresAt)) ||
|
|
382
|
+
(state === "done" && !record.ack)) {
|
|
383
|
+
throw new Error("invalid spool record");
|
|
384
|
+
}
|
|
385
|
+
records.push(record);
|
|
386
|
+
}
|
|
387
|
+
catch (err) {
|
|
388
|
+
void opts.logger("warn", "skipping malformed message spool record", { state, file, error: String(err) });
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return records.sort((a, b) => {
|
|
392
|
+
const aOrder = a.sequence ?? a.acceptedAt ?? a.message.sentAt;
|
|
393
|
+
const bOrder = b.sequence ?? b.acceptedAt ?? b.message.sentAt;
|
|
394
|
+
return aOrder - bOrder;
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
function nextSequence() {
|
|
398
|
+
let current = 0;
|
|
399
|
+
try {
|
|
400
|
+
const stored = JSON.parse(readFileSync(sequenceFile, "utf8"));
|
|
401
|
+
if (typeof stored.value === "number" && Number.isSafeInteger(stored.value))
|
|
402
|
+
current = stored.value;
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
// The first accepted record starts the sequence.
|
|
406
|
+
}
|
|
407
|
+
const value = current + 1;
|
|
408
|
+
persistRecord(sequenceFile, { value });
|
|
409
|
+
return value;
|
|
410
|
+
}
|
|
411
|
+
function readRecord(state, message) {
|
|
412
|
+
try {
|
|
413
|
+
const record = JSON.parse(readFileSync(join(spoolDir, state, `${recordName(message)}.json`), "utf8"));
|
|
414
|
+
if (record?.version !== 2 ||
|
|
415
|
+
record.state !== state ||
|
|
416
|
+
record.message?.id !== message.id ||
|
|
417
|
+
record.message.from?.instanceId !== message.from.instanceId ||
|
|
418
|
+
(state === "done" && !record.ack))
|
|
419
|
+
return null;
|
|
420
|
+
return record;
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
function refreshHeld() {
|
|
427
|
+
held = stateRecords("held").map((record) => record.message);
|
|
428
|
+
}
|
|
429
|
+
function refreshQueue() {
|
|
430
|
+
queue = stateRecords("queued").map((record) => record.message);
|
|
431
|
+
}
|
|
432
|
+
function recordName(msg) {
|
|
433
|
+
return createHash("sha256").update(`${msg.from.instanceId}\0${msg.id}`).digest("hex");
|
|
434
|
+
}
|
|
435
|
+
function queuedFile(msg) {
|
|
436
|
+
return join(spoolDir, "queued", `${recordName(msg)}.json`);
|
|
437
|
+
}
|
|
438
|
+
function inflightFile(msg) {
|
|
439
|
+
return join(spoolDir, "inflight", `${recordName(msg)}.json`);
|
|
440
|
+
}
|
|
441
|
+
function heldFile(msg) {
|
|
442
|
+
return join(spoolDir, "held", `${recordName(msg)}.json`);
|
|
443
|
+
}
|
|
444
|
+
function doneFile(msg) {
|
|
445
|
+
return join(spoolDir, "done", `${recordName(msg)}.json`);
|
|
446
|
+
}
|
|
447
|
+
function existingState(msg) {
|
|
448
|
+
for (const state of ["queued", "held", "inflight", "done"]) {
|
|
449
|
+
if (readRecord(state, msg))
|
|
450
|
+
return state;
|
|
451
|
+
}
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
function contentKey(msg) {
|
|
455
|
+
return createHash("sha256").update(`${msg.from.instanceId}\0${msg.text}`).digest("hex");
|
|
456
|
+
}
|
|
457
|
+
function locallyDebounced(msg) {
|
|
458
|
+
const seenAt = recentContent.get(contentKey(msg));
|
|
459
|
+
return typeof seenAt === "number" && seenAt > Date.now() - debounceMs;
|
|
460
|
+
}
|
|
461
|
+
function noteContent(msg) {
|
|
462
|
+
recentContent.set(contentKey(msg), Date.now());
|
|
463
|
+
}
|
|
464
|
+
function recentContentRecord(msg) {
|
|
465
|
+
const cutoff = Date.now() - debounceMs;
|
|
466
|
+
for (const state of ["queued", "held", "inflight", "done"]) {
|
|
467
|
+
for (const record of stateRecords(state)) {
|
|
468
|
+
const acceptedAt = record.acceptedAt ?? record.heldAt ?? record.message.sentAt;
|
|
469
|
+
if (acceptedAt > cutoff &&
|
|
470
|
+
record.message.id !== msg.id &&
|
|
471
|
+
record.message.from.instanceId === msg.from.instanceId &&
|
|
472
|
+
record.message.text === msg.text) {
|
|
473
|
+
return record;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
function ensureSpoolDirectory(directory) {
|
|
480
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
481
|
+
chmodSync(spoolDir, 0o700);
|
|
482
|
+
chmodSync(directory, 0o700);
|
|
483
|
+
}
|
|
484
|
+
function syncDirectory(directory) {
|
|
485
|
+
if (process.platform === "win32")
|
|
486
|
+
return;
|
|
487
|
+
const dirFd = openSync(directory, "r");
|
|
488
|
+
try {
|
|
489
|
+
fsyncSync(dirFd);
|
|
490
|
+
}
|
|
491
|
+
finally {
|
|
492
|
+
closeSync(dirFd);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
function persistRecord(target, record) {
|
|
496
|
+
const directory = dirname(target);
|
|
497
|
+
ensureSpoolDirectory(directory);
|
|
498
|
+
const temporary = join(directory, `.${process.pid}.${Date.now()}.tmp`);
|
|
499
|
+
const fd = openSync(temporary, "w", 0o600);
|
|
500
|
+
try {
|
|
501
|
+
writeSync(fd, JSON.stringify(record));
|
|
502
|
+
fsyncSync(fd);
|
|
503
|
+
}
|
|
504
|
+
finally {
|
|
505
|
+
closeSync(fd);
|
|
506
|
+
}
|
|
507
|
+
chmodSync(temporary, 0o600);
|
|
508
|
+
renameSync(temporary, target);
|
|
509
|
+
syncDirectory(directory);
|
|
510
|
+
}
|
|
511
|
+
function persistQueued(msg) {
|
|
512
|
+
persistRecord(queuedFile(msg), {
|
|
513
|
+
version: 2,
|
|
514
|
+
state: "queued",
|
|
515
|
+
message: msg,
|
|
516
|
+
acceptedAt: Date.now(),
|
|
517
|
+
sequence: nextSequence(),
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
function acknowledgement(message, status) {
|
|
521
|
+
return {
|
|
522
|
+
version: 2,
|
|
523
|
+
messageId: message.id,
|
|
524
|
+
fromEndpointId: message.from.instanceId,
|
|
525
|
+
toEndpointId: opts.endpointId ?? "legacy",
|
|
526
|
+
status,
|
|
527
|
+
acknowledgedAt: Date.now(),
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
function finish(message, sourceState, status) {
|
|
531
|
+
const sourceRecord = readRecord(sourceState, message);
|
|
532
|
+
if (!sourceRecord) {
|
|
533
|
+
const prior = readRecord("done", message)?.ack;
|
|
534
|
+
if (prior)
|
|
535
|
+
return prior;
|
|
536
|
+
throw new Error(`missing ${sourceState} spool record for message ${message.id}`);
|
|
537
|
+
}
|
|
538
|
+
const ack = acknowledgement(message, status);
|
|
539
|
+
persistRecord(doneFile(message), {
|
|
540
|
+
...sourceRecord,
|
|
541
|
+
state: "done",
|
|
542
|
+
message,
|
|
543
|
+
ack,
|
|
544
|
+
});
|
|
545
|
+
const source = join(spoolDir, sourceState, `${recordName(message)}.json`);
|
|
546
|
+
unlinkSync(source);
|
|
547
|
+
syncDirectory(dirname(source));
|
|
548
|
+
return ack;
|
|
549
|
+
}
|
|
550
|
+
function persistDuplicateLocked(message, duplicateOf) {
|
|
551
|
+
const ack = acknowledgement(message, "duplicate");
|
|
552
|
+
persistRecord(doneFile(message), {
|
|
553
|
+
version: 2,
|
|
554
|
+
state: "done",
|
|
555
|
+
message,
|
|
556
|
+
ack,
|
|
557
|
+
acceptedAt: Date.now(),
|
|
558
|
+
sequence: nextSequence(),
|
|
559
|
+
duplicateOfMessageId: duplicateOf.message.id,
|
|
560
|
+
});
|
|
561
|
+
return ack;
|
|
562
|
+
}
|
|
563
|
+
function pick(which, limit = Infinity) {
|
|
564
|
+
if (limit <= 0)
|
|
565
|
+
return [];
|
|
566
|
+
if (which === "all") {
|
|
567
|
+
const out = held.slice(0, limit);
|
|
568
|
+
held = held.slice(out.length);
|
|
569
|
+
return out;
|
|
570
|
+
}
|
|
571
|
+
const idx = which - 1;
|
|
572
|
+
if (idx < 0 || idx >= held.length)
|
|
573
|
+
return [];
|
|
574
|
+
return held.splice(idx, 1);
|
|
575
|
+
}
|
|
576
|
+
function expireHeldRecordsLocked() {
|
|
577
|
+
refreshHeld();
|
|
578
|
+
const now = Date.now();
|
|
579
|
+
const expired = held.filter((message) => message.expiresAt <= now);
|
|
580
|
+
held = held.filter((message) => message.expiresAt > now);
|
|
581
|
+
return expired.map((message) => finish(message, "held", "expired"));
|
|
582
|
+
}
|
|
583
|
+
async function expireHeldRecords() {
|
|
584
|
+
return withEndpointLock(expireHeldRecordsLocked);
|
|
585
|
+
}
|
|
586
|
+
const instance = {
|
|
587
|
+
enqueue(msg) {
|
|
588
|
+
return withEndpointLock(() => {
|
|
589
|
+
if (existingState(msg))
|
|
590
|
+
return false;
|
|
591
|
+
const duplicate = recentContentRecord(msg);
|
|
592
|
+
if (duplicate || locallyDebounced(msg)) {
|
|
593
|
+
persistDuplicateLocked(msg, duplicate ?? {
|
|
594
|
+
version: 2,
|
|
595
|
+
state: "queued",
|
|
596
|
+
message: msg,
|
|
597
|
+
});
|
|
598
|
+
return false;
|
|
599
|
+
}
|
|
600
|
+
if (stateFileCount("queued") + stateFileCount("inflight") >= opts.maxQueue)
|
|
601
|
+
return false;
|
|
602
|
+
persistQueued(msg);
|
|
603
|
+
noteContent(msg);
|
|
604
|
+
queue.push(msg);
|
|
605
|
+
return true;
|
|
606
|
+
});
|
|
607
|
+
},
|
|
608
|
+
drain() {
|
|
609
|
+
return withEndpointLock(() => {
|
|
610
|
+
const records = stateRecords("queued");
|
|
611
|
+
const out = records.map((record) => record.message);
|
|
612
|
+
for (const record of records) {
|
|
613
|
+
const message = record.message;
|
|
614
|
+
persistRecord(inflightFile(message), { ...record, state: "inflight", message });
|
|
615
|
+
unlinkSync(queuedFile(message));
|
|
616
|
+
syncDirectory(dirname(queuedFile(message)));
|
|
617
|
+
}
|
|
618
|
+
queue = [];
|
|
619
|
+
return out;
|
|
620
|
+
});
|
|
621
|
+
},
|
|
622
|
+
async complete(messages) {
|
|
623
|
+
return withEndpointLock(() => messages.map((message) => finish(message, "inflight", "delivered")));
|
|
624
|
+
},
|
|
625
|
+
async requeue(messages) {
|
|
626
|
+
withEndpointLock(() => {
|
|
627
|
+
for (const message of messages) {
|
|
628
|
+
const record = readRecord("inflight", message);
|
|
629
|
+
if (!record)
|
|
630
|
+
continue;
|
|
631
|
+
persistRecord(queuedFile(message), { ...record, state: "queued", message });
|
|
632
|
+
unlinkSync(inflightFile(message));
|
|
633
|
+
syncDirectory(dirname(inflightFile(message)));
|
|
634
|
+
}
|
|
635
|
+
refreshQueue();
|
|
636
|
+
});
|
|
637
|
+
},
|
|
638
|
+
duplicateAcknowledgement(msg) {
|
|
639
|
+
const record = readRecord("done", msg);
|
|
640
|
+
return record?.ack?.status === "duplicate" ? record.ack : null;
|
|
641
|
+
},
|
|
642
|
+
existingStatus(msg) {
|
|
643
|
+
switch (existingState(msg)) {
|
|
644
|
+
case "queued":
|
|
645
|
+
case "inflight":
|
|
646
|
+
return "queued";
|
|
647
|
+
case "held":
|
|
648
|
+
return "held";
|
|
649
|
+
case "done": {
|
|
650
|
+
return readRecord("done", msg)?.ack?.status ?? null;
|
|
651
|
+
}
|
|
652
|
+
default:
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
},
|
|
656
|
+
isDebounced(msg) {
|
|
657
|
+
return withEndpointLock(() => {
|
|
658
|
+
if (existingState(msg))
|
|
659
|
+
return false;
|
|
660
|
+
const duplicate = recentContentRecord(msg);
|
|
661
|
+
if (!duplicate && !locallyDebounced(msg))
|
|
662
|
+
return false;
|
|
663
|
+
persistDuplicateLocked(msg, duplicate ?? { version: 2, state: "queued", message: msg });
|
|
664
|
+
return true;
|
|
665
|
+
});
|
|
666
|
+
},
|
|
667
|
+
async refuse(msg) {
|
|
668
|
+
return withEndpointLock(() => {
|
|
669
|
+
const priorState = existingState(msg);
|
|
670
|
+
if (priorState === "done")
|
|
671
|
+
return readRecord("done", msg).ack;
|
|
672
|
+
if (priorState)
|
|
673
|
+
return acknowledgement(msg, "duplicate");
|
|
674
|
+
const ack = acknowledgement(msg, "refused");
|
|
675
|
+
persistRecord(doneFile(msg), {
|
|
676
|
+
version: 2,
|
|
677
|
+
state: "done",
|
|
678
|
+
message: msg,
|
|
679
|
+
ack,
|
|
680
|
+
acceptedAt: Date.now(),
|
|
681
|
+
sequence: nextSequence(),
|
|
682
|
+
});
|
|
683
|
+
return ack;
|
|
684
|
+
});
|
|
685
|
+
},
|
|
686
|
+
pending() {
|
|
687
|
+
return [...queue];
|
|
688
|
+
},
|
|
689
|
+
size() {
|
|
690
|
+
return queue.length;
|
|
691
|
+
},
|
|
692
|
+
async hold(msg) {
|
|
693
|
+
return withEndpointLock(() => {
|
|
694
|
+
if (existingState(msg))
|
|
695
|
+
return false;
|
|
696
|
+
const duplicate = recentContentRecord(msg);
|
|
697
|
+
if (duplicate || locallyDebounced(msg)) {
|
|
698
|
+
persistDuplicateLocked(msg, duplicate ?? { version: 2, state: "held", message: msg });
|
|
699
|
+
return false;
|
|
700
|
+
}
|
|
701
|
+
if (stateFileCount("held") >= opts.maxHeld)
|
|
702
|
+
return false;
|
|
703
|
+
const heldAt = Date.now();
|
|
704
|
+
const heldMessage = { ...msg, heldAt, expiresAt: heldAt + heldExpiryMs };
|
|
705
|
+
persistRecord(heldFile(msg), {
|
|
706
|
+
version: 2,
|
|
707
|
+
state: "held",
|
|
708
|
+
message: heldMessage,
|
|
709
|
+
heldAt,
|
|
710
|
+
expiresAt: heldMessage.expiresAt,
|
|
711
|
+
acceptedAt: heldAt,
|
|
712
|
+
sequence: nextSequence(),
|
|
713
|
+
});
|
|
714
|
+
noteContent(msg);
|
|
715
|
+
held.push(heldMessage);
|
|
716
|
+
return true;
|
|
717
|
+
});
|
|
718
|
+
},
|
|
719
|
+
held() {
|
|
720
|
+
return [...held];
|
|
721
|
+
},
|
|
722
|
+
expireHeld: expireHeldRecords,
|
|
723
|
+
pendingAcknowledgements() {
|
|
724
|
+
return withEndpointLock(() => stateRecords("done")
|
|
725
|
+
.filter((record) => record.ack && !record.ackSentAt)
|
|
726
|
+
.map((record) => record.ack));
|
|
727
|
+
},
|
|
728
|
+
async markAcknowledgementSent(ack) {
|
|
729
|
+
withEndpointLock(() => {
|
|
730
|
+
const record = stateRecords("done").find((candidate) => candidate.ack?.messageId === ack.messageId &&
|
|
731
|
+
candidate.ack.fromEndpointId === ack.fromEndpointId &&
|
|
732
|
+
candidate.ack.toEndpointId === ack.toEndpointId);
|
|
733
|
+
if (!record)
|
|
734
|
+
return;
|
|
735
|
+
persistRecord(doneFile(record.message), { ...record, ackSentAt: Date.now() });
|
|
736
|
+
});
|
|
737
|
+
},
|
|
738
|
+
async acceptHeld(which) {
|
|
739
|
+
return withEndpointLock(() => {
|
|
740
|
+
expireHeldRecordsLocked();
|
|
741
|
+
refreshHeld();
|
|
742
|
+
const used = stateFileCount("queued") + stateFileCount("inflight");
|
|
743
|
+
const accepted = pick(which, Math.max(0, opts.maxQueue - used));
|
|
744
|
+
for (const msg of accepted) {
|
|
745
|
+
const { heldAt: _heldAt, expiresAt: _expiresAt, ...message } = msg;
|
|
746
|
+
const record = readRecord("held", msg);
|
|
747
|
+
persistRecord(queuedFile(message), {
|
|
748
|
+
...(record ?? { version: 2, acceptedAt: Date.now(), sequence: nextSequence() }),
|
|
749
|
+
state: "queued",
|
|
750
|
+
message,
|
|
751
|
+
heldAt: undefined,
|
|
752
|
+
expiresAt: undefined,
|
|
753
|
+
ack: undefined,
|
|
754
|
+
});
|
|
755
|
+
unlinkSync(heldFile(msg));
|
|
756
|
+
syncDirectory(dirname(heldFile(msg)));
|
|
757
|
+
}
|
|
758
|
+
refreshQueue();
|
|
759
|
+
refreshHeld();
|
|
760
|
+
return accepted;
|
|
761
|
+
});
|
|
762
|
+
},
|
|
763
|
+
async dropHeld(which) {
|
|
764
|
+
return withEndpointLock(() => {
|
|
765
|
+
expireHeldRecordsLocked();
|
|
766
|
+
refreshHeld();
|
|
767
|
+
const dropped = pick(which);
|
|
768
|
+
for (const message of dropped)
|
|
769
|
+
finish(message, "held", "dropped");
|
|
770
|
+
refreshHeld();
|
|
771
|
+
return dropped.length;
|
|
772
|
+
});
|
|
773
|
+
},
|
|
774
|
+
async loadHeld() {
|
|
775
|
+
withEndpointLock(() => {
|
|
776
|
+
const inflightDir = join(spoolDir, "inflight");
|
|
777
|
+
for (const record of stateRecords("inflight")) {
|
|
778
|
+
const message = record.message;
|
|
779
|
+
if (!readRecord("done", message)) {
|
|
780
|
+
persistRecord(queuedFile(message), { ...record, state: "queued", message });
|
|
781
|
+
}
|
|
782
|
+
unlinkSync(inflightFile(message));
|
|
783
|
+
syncDirectory(inflightDir);
|
|
784
|
+
}
|
|
785
|
+
const cutoff = Date.now() - COMPLETED_DEDUPE_RETENTION_MS;
|
|
786
|
+
for (const record of stateRecords("done")) {
|
|
787
|
+
if (typeof record.ack?.acknowledgedAt === "number" && record.ack.acknowledgedAt <= cutoff) {
|
|
788
|
+
unlinkSync(doneFile(record.message));
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
refreshQueue();
|
|
792
|
+
refreshHeld();
|
|
793
|
+
try {
|
|
794
|
+
renameSync(opts.inboxFile, `${opts.inboxFile}.legacy-${Date.now()}`);
|
|
795
|
+
}
|
|
796
|
+
catch (err) {
|
|
797
|
+
if (err.code !== "ENOENT") {
|
|
798
|
+
void opts.logger("warn", "failed to archive legacy inbox", { error: String(err) });
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
});
|
|
802
|
+
},
|
|
803
|
+
withExclusiveLock(operation) {
|
|
804
|
+
return withEndpointLock(operation);
|
|
805
|
+
},
|
|
806
|
+
};
|
|
807
|
+
return instance;
|
|
808
|
+
}
|
|
809
|
+
/** Per-key sliding-window rate limiter. */
|
|
810
|
+
export function RateLimiter(limitPerMin) {
|
|
811
|
+
const hits = new Map();
|
|
812
|
+
return (key) => {
|
|
813
|
+
const now = Date.now();
|
|
814
|
+
const windowStart = now - 60_000;
|
|
815
|
+
const arr = (hits.get(key) ?? []).filter((t) => t > windowStart);
|
|
816
|
+
if (arr.length >= limitPerMin) {
|
|
817
|
+
hits.set(key, arr);
|
|
818
|
+
return false;
|
|
819
|
+
}
|
|
820
|
+
arr.push(now);
|
|
821
|
+
hits.set(key, arr);
|
|
822
|
+
return true;
|
|
823
|
+
};
|
|
824
|
+
}
|