@tinycloud/courier 0.2.0-beta.41 → 0.2.0-beta.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,640 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { decodeTime, encodeTime } from 'ulid';
4
+ import { isRecord } from '../a2a.js';
5
+ import { CourierError, errInfo } from '../errors.js';
6
+ import { k } from '../keys.js';
7
+ import { courierNode } from '../node.js';
8
+ import { loadProfile, profileDir } from '../profile.js';
9
+ import { getJson, listAll } from '../store.js';
10
+ import { hiddenTextProblem, textVerdict } from '../text.js';
11
+ import { jsonLineLog } from '../wake.js';
12
+ import { FLAG_TABLE } from './flags.js';
13
+ import { policyOf, loadPeers } from './peers.js';
14
+ import { acquireLock, ensureRealDir, readState, writeNew, writeState } from './queue.js';
15
+ const STATE_FILE = 'intake-state.json';
16
+ const LOCK_FILE = 'intake.lock';
17
+ const HOUR_MS = 60 * 60_000;
18
+ const DAY_MS = 24 * HOUR_MS;
19
+ /** A message ULID more than this far ahead of local time is not trusted; it is retried on a later cycle. */
20
+ const MAX_SKEW_MS = 10 * 60_000;
21
+ /** Mirror ids whose ULID time is older than this are dropped at selection: no fetch, no marker. */
22
+ const STALE_MS = 7 * DAY_MS;
23
+ /** Queue records older than STALE_MS move to done/; done/ entries older than this are pruned. */
24
+ const DONE_TTL_MS = 14 * DAY_MS;
25
+ /** Overflow is retired once older than the stale horizon: a message that old would be stale on admission anyway. */
26
+ const OVERFLOW_TTL_MS = STALE_MS;
27
+ /** Overflow is capped by count and by bytes; the oldest sent messages are dropped first. */
28
+ const OVERFLOW_MAX_FILES = 200;
29
+ const OVERFLOW_MAX_BYTES = 1024 * 1024;
30
+ /** Mirror ids taken per peer per cycle. The cursor walks the mirror cyclically, so every id is reached within a lap. */
31
+ const MIRROR_BATCH = 200;
32
+ /** Queue moves, done prunes, overflow retirements and cap retirements per directory per pass: bounds cleanup IO per cycle. */
33
+ const JANITOR_BATCH = 500;
34
+ /** Inbound admissions are counted per peer over this window. */
35
+ const RATE_WINDOW_MS = HOUR_MS;
36
+ /** Queued text is capped at this many UTF-16 units; longer text is flagged `too-long`. */
37
+ export const MAX_QUEUE_CHARS = 8000;
38
+ /** Queued text is also capped at 16 KiB of UTF-8 (cut at a code-point boundary) and flagged `size` when cut. */
39
+ const MAX_QUEUE_BYTES = 16 * 1024;
40
+ const RATE_TEXT = 'This peer went over its inbound rate limit; further messages this hour are held outside the queue.';
41
+ /** A ULID's first character is 0-7: its 48-bit timestamp cannot exceed 2^48-1. Larger ids overflow decodeTime. */
42
+ const ULID_RE = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
43
+ const SAFE_ID = /^[0-9A-Za-z_-]{1,64}$/;
44
+ const MSG_KEY = /\/threads\/([^/]+)\/msg\/([^/]+)\.json$/;
45
+ const LINK = /(?:https?|ftp):\/\/|\bwww\.[a-z0-9-]+\./i;
46
+ const ENCODED = /[A-Za-z0-9+\/=]{64,}/;
47
+ const REQUEST = /(?:^|[.!?\n]\s*|\bplease\s+)(?:run|merge|send|open|forward|delete|reveal|deploy)\b/i;
48
+ const SECRET_REQUEST = /\b(?:share|send|give|paste|reveal|tell|show|post)\b[^.!?\n]{0,40}\b(?:keys?|tokens?|passwords?|credentials?|secrets?|seed phrase)\b/i;
49
+ const INSTRUCTION_PHRASES = [
50
+ 'ignore previous',
51
+ 'ignore all previous',
52
+ 'disregard previous',
53
+ 'forget your instructions',
54
+ 'you are now',
55
+ 'new instructions',
56
+ 'system prompt',
57
+ 'override your',
58
+ ];
59
+ /** Cuts `text` to at most `maxBytes` of UTF-8 at a code-point boundary. */
60
+ export function capUtf8(text, maxBytes) {
61
+ if (Buffer.byteLength(text, 'utf8') <= maxBytes)
62
+ return { text, truncated: false };
63
+ let out = '';
64
+ let bytes = 0;
65
+ for (const ch of text) {
66
+ const n = Buffer.byteLength(ch, 'utf8');
67
+ if (bytes + n > maxBytes)
68
+ break;
69
+ out += ch;
70
+ bytes += n;
71
+ }
72
+ return { text: out, truncated: true };
73
+ }
74
+ /** Maps the verdict and the cheap text detectors to flag codes from FLAG_TABLE, in table order. */
75
+ export function flagsFor(input) {
76
+ const found = new Set();
77
+ if (!input.verdict.allowed && input.verdict.reason === 'too-long')
78
+ found.add('too-long');
79
+ if (hiddenTextProblem(input.text, true) || (!input.verdict.allowed && input.verdict.reason !== 'too-long'))
80
+ found.add('unicode');
81
+ const lower = input.text.toLowerCase();
82
+ if (LINK.test(input.text))
83
+ found.add('link');
84
+ if (INSTRUCTION_PHRASES.some((phrase) => lower.includes(phrase)))
85
+ found.add('instruction');
86
+ if (REQUEST.test(input.text))
87
+ found.add('request');
88
+ if (SECRET_REQUEST.test(input.text))
89
+ found.add('secret-request');
90
+ if (ENCODED.test(input.text))
91
+ found.add('encoded');
92
+ if (input.overRate)
93
+ found.add('rate');
94
+ if (input.truncated)
95
+ found.add('size');
96
+ return Object.keys(FLAG_TABLE).filter((flag) => found.has(flag));
97
+ }
98
+ /**
99
+ * Admits one arrival against the peer's receipt window. Only admitted arrivals are recorded, so an over-cap peer cannot
100
+ * extend its own window. Receipts are kept while they are inside the window relative to the current reading, including
101
+ * receipts later than it, so a clock that steps back does not reopen the cap.
102
+ */
103
+ function admit(receipts, id, at, cap) {
104
+ const recent = receipts.filter((r) => r.at > at - RATE_WINDOW_MS);
105
+ if (recent.length >= cap)
106
+ return { admitted: false, remaining: 0, receipts: recent.slice(-cap) };
107
+ return { admitted: true, remaining: cap - recent.length - 1, receipts: [...recent, { id, at }].slice(-cap) };
108
+ }
109
+ /** Peers whose receipts were reconciled against disk for one loaded state. A persist failure clears its peer. */
110
+ const reconciledPeers = new WeakMap();
111
+ function reconciledSet(state) {
112
+ let set = reconciledPeers.get(state);
113
+ if (!set) {
114
+ set = new Set();
115
+ reconciledPeers.set(state, set);
116
+ }
117
+ return set;
118
+ }
119
+ /**
120
+ * Stored receipts merged with this peer's ULID records in queue/ and done/ inside the window, keyed by msgId. An admission
121
+ * counts once even when both its stored receipt and its record exist. A queued record whose state write was lost still
122
+ * counts against the cap, and so does a record the owner has archived to done/. Files whose mtime is older than the window
123
+ * cannot hold a receipt inside it, so they are skipped before being read.
124
+ */
125
+ function reconcileReceipts(input, peer, receipts, cap) {
126
+ const now = input.now();
127
+ const byId = new Map();
128
+ for (const r of receipts)
129
+ if (!byId.has(r.id))
130
+ byId.set(r.id, r);
131
+ for (const root of [input.queueRoot, input.doneRoot]) {
132
+ const dir = path.join(root, peer);
133
+ let names;
134
+ try {
135
+ names = fs.readdirSync(dir);
136
+ }
137
+ catch (error) {
138
+ if (error.code === 'ENOENT')
139
+ continue;
140
+ throw error;
141
+ }
142
+ for (const name of names) {
143
+ if (!name.endsWith('.json'))
144
+ continue;
145
+ const id = name.slice(0, -'.json'.length);
146
+ if (!ULID_RE.test(id) || byId.has(id))
147
+ continue;
148
+ const file = path.join(dir, name);
149
+ let stat;
150
+ try {
151
+ stat = fs.lstatSync(file);
152
+ }
153
+ catch {
154
+ continue;
155
+ }
156
+ if (!stat.isFile() || stat.mtimeMs < now - RATE_WINDOW_MS - 60_000)
157
+ continue;
158
+ let record;
159
+ try {
160
+ record = JSON.parse(fs.readFileSync(file, 'utf8'));
161
+ }
162
+ catch {
163
+ continue;
164
+ }
165
+ if (!isRecord(record) || typeof record.receivedAt !== 'string')
166
+ continue;
167
+ const at = Date.parse(record.receivedAt);
168
+ if (Number.isFinite(at))
169
+ byId.set(id, { id, at });
170
+ }
171
+ }
172
+ return [...byId.values()].filter((r) => r.at > now - RATE_WINDOW_MS).sort((a, b) => a.at - b.at).slice(-cap);
173
+ }
174
+ function persist(input, peer) {
175
+ try {
176
+ writeState(input.stateDir, path.join(input.stateDir, STATE_FILE), input.state);
177
+ }
178
+ catch (error) {
179
+ reconciledSet(input.state).delete(peer);
180
+ throw error;
181
+ }
182
+ }
183
+ /** True when `name` already exists for the peer in the queue, done, or overflow store. Symlinks count as present. */
184
+ function presentIn(input, peer, name) {
185
+ for (const root of [input.queueRoot, input.doneRoot, input.overflowRoot]) {
186
+ try {
187
+ fs.lstatSync(path.join(root, peer, name));
188
+ return true;
189
+ }
190
+ catch (error) {
191
+ if (error.code !== 'ENOENT')
192
+ throw error;
193
+ }
194
+ }
195
+ return false;
196
+ }
197
+ /** True when something exists at `file`, a symlink included. Only a missing name reads as absent. */
198
+ function pathExists(file) {
199
+ try {
200
+ fs.lstatSync(file);
201
+ return true;
202
+ }
203
+ catch (error) {
204
+ if (error.code === 'ENOENT')
205
+ return false;
206
+ throw error;
207
+ }
208
+ }
209
+ /** Writes a done marker for one message id. Returns false when a marker already exists. */
210
+ function markDone(input, peer, msgId, outcome) {
211
+ const dir = path.join(input.doneRoot, peer);
212
+ ensureRealDir(dir, 0o700);
213
+ const marker = { v: 1, msgId, peerId: peer, outcome, markedAt: new Date(input.now()).toISOString() };
214
+ return writeNew(dir, `${msgId}.json`, `${JSON.stringify(marker)}\n`, 0o600);
215
+ }
216
+ /** Message rows in a peer's mirror listing. Rows whose ids are not ULIDs are logged as `bad-id` and skipped. */
217
+ function mirrorMessages(handle, keys, log) {
218
+ const messages = [];
219
+ for (const key of keys) {
220
+ const match = MSG_KEY.exec(key);
221
+ if (!match)
222
+ continue; // thread meta.json and other non-message rows are not messages
223
+ const [, threadId, msgId] = match;
224
+ if (!ULID_RE.test(threadId) || !ULID_RE.test(msgId)) {
225
+ log('intake-skip', { peer: handle, code: 'bad-id' });
226
+ continue;
227
+ }
228
+ messages.push({ key, threadId, msgId });
229
+ }
230
+ return messages;
231
+ }
232
+ /**
233
+ * Writes the rate record for this peer's current hour, once per hour. The record name is `rate-<unixHour>`, created
234
+ * exclusively, so a second overflow in the same hour writes nothing. Returns whether it was created.
235
+ */
236
+ function writeRateRecord(input, peer, senderName, tier, at) {
237
+ const msgId = `rate-${Math.floor(at / RATE_WINDOW_MS)}`;
238
+ const dir = path.join(input.queueRoot, peer);
239
+ ensureRealDir(dir, 0o2750);
240
+ const record = {
241
+ v: 1,
242
+ msgId,
243
+ peerId: peer,
244
+ threadId: 'rate',
245
+ sender: { id: peer, displayName: senderName },
246
+ receivedAt: new Date(at).toISOString(),
247
+ text: RATE_TEXT,
248
+ cheapChecks: {
249
+ textVerdict: textVerdict(RATE_TEXT, 'multiline', MAX_QUEUE_CHARS),
250
+ tier,
251
+ peerRatePerHourRemaining: 0,
252
+ flags: ['rate'],
253
+ },
254
+ };
255
+ if (presentIn(input, peer, `${msgId}.json`))
256
+ return false;
257
+ const created = writeNew(dir, `${msgId}.json`, `${JSON.stringify(record)}\n`, 0o640);
258
+ if (created)
259
+ input.log('peer-over-rate', { peer, msgId });
260
+ return created;
261
+ }
262
+ /**
263
+ * The next `cap` ids after `cursor` in ascending order, wrapping to the smallest ids once the end is passed.
264
+ * Ids below `floor` are dropped before selection, so they never use up the cap. Keeps two bounded sorted lists,
265
+ * so memory is O(cap) however large the mirror is.
266
+ */
267
+ function pickBatch(items, cursor, cap, floor) {
268
+ if (cap < 1)
269
+ return [];
270
+ const after = [];
271
+ const wrapped = [];
272
+ const insertSorted = (list, item) => {
273
+ if (list.length === cap && item.msgId >= list[cap - 1].msgId)
274
+ return;
275
+ let i = list.length;
276
+ while (i > 0 && list[i - 1].msgId > item.msgId)
277
+ i--;
278
+ list.splice(i, 0, item);
279
+ if (list.length > cap)
280
+ list.pop();
281
+ };
282
+ for (const item of items) {
283
+ if (item.msgId < floor)
284
+ continue;
285
+ if (item.msgId > cursor)
286
+ insertSorted(after, item);
287
+ else
288
+ insertSorted(wrapped, item);
289
+ }
290
+ return [...after, ...wrapped].slice(0, cap);
291
+ }
292
+ async function intakePeer(input, peer) {
293
+ const policy = policyOf(input.peers, peer.handle);
294
+ const ps = Object.hasOwn(input.state.peers, peer.handle) ? input.state.peers[peer.handle] : { receipts: [] };
295
+ input.state.peers[peer.handle] = ps;
296
+ const reconciled = reconciledSet(input.state);
297
+ if (!reconciled.has(peer.handle)) {
298
+ ps.receipts = reconcileReceipts(input, peer.handle, ps.receipts, policy.ratePerHour);
299
+ reconciled.add(peer.handle);
300
+ }
301
+ const senderName = typeof peer.label === 'string' && textVerdict(peer.label, 'summary').allowed ? peer.label : peer.handle;
302
+ const keys = await listAll(input.kv, `${k.mirrorPrefix}${peer.handle}/threads/`);
303
+ const all = mirrorMessages(peer.handle, keys, input.log);
304
+ const cap = input.batch ?? MIRROR_BATCH;
305
+ const floor = encodeTime(input.now() - STALE_MS, 10) + '0'.repeat(16);
306
+ const picked = pickBatch(all, ps.cursor ?? '', cap, floor);
307
+ let written = 0;
308
+ let future = 0;
309
+ let overflowed = false;
310
+ try {
311
+ for (const c of picked) {
312
+ try {
313
+ if (presentIn(input, peer.handle, `${c.msgId}.json`))
314
+ continue;
315
+ const at = input.now();
316
+ let sentAt;
317
+ try {
318
+ sentAt = decodeTime(c.msgId);
319
+ }
320
+ catch {
321
+ input.log('intake-skip', { peer: peer.handle, code: 'bad-id' });
322
+ markDone(input, peer.handle, c.msgId, 'bad-id');
323
+ continue;
324
+ }
325
+ if (sentAt > at + MAX_SKEW_MS) {
326
+ future++; // no marker: the id may be inside the skew on a later cycle
327
+ continue;
328
+ }
329
+ const got = await getJson(input.kv, c.key);
330
+ if (!got || !isRecord(got.value) || got.value.messageId !== c.msgId || got.value.contextId !== c.threadId) {
331
+ input.log('intake-skip', { peer: peer.handle, code: 'mismatch' });
332
+ markDone(input, peer.handle, c.msgId, 'mismatch');
333
+ continue;
334
+ }
335
+ const { admitted, remaining, receipts } = admit(ps.receipts, c.msgId, at, policy.ratePerHour);
336
+ const parts = Array.isArray(got.value.parts) ? got.value.parts : [];
337
+ const texts = [];
338
+ for (const part of parts)
339
+ if (isRecord(part) && typeof part.text === 'string')
340
+ texts.push(part.text);
341
+ const text = texts.join('\n');
342
+ const verdict = textVerdict(text, 'multiline', MAX_QUEUE_CHARS);
343
+ const capped = capUtf8(text, MAX_QUEUE_BYTES);
344
+ const flags = flagsFor({ text, verdict, overRate: !admitted, truncated: capped.truncated });
345
+ const record = {
346
+ v: 1,
347
+ msgId: c.msgId,
348
+ peerId: peer.handle,
349
+ threadId: c.threadId,
350
+ sender: { id: peer.handle, displayName: senderName },
351
+ receivedAt: new Date(at).toISOString(),
352
+ text: capped.text,
353
+ cheapChecks: { textVerdict: verdict, tier: policy.tier, peerRatePerHourRemaining: remaining, flags },
354
+ };
355
+ const body = `${JSON.stringify(record)}\n`;
356
+ if (admitted) {
357
+ const dir = path.join(input.queueRoot, peer.handle);
358
+ ensureRealDir(dir, 0o2750);
359
+ const name = `${c.msgId}.json`;
360
+ let published;
361
+ try {
362
+ published = writeNew(dir, name, body, 0o640);
363
+ }
364
+ catch (error) {
365
+ // The link can succeed before the directory fsync fails. A record that is visible is admitted, so it counts.
366
+ if (pathExists(path.join(dir, name)))
367
+ ps.receipts = receipts;
368
+ throw error;
369
+ }
370
+ if (published) {
371
+ ps.receipts = receipts;
372
+ persist(input, peer.handle);
373
+ written++;
374
+ input.log('intake-queued', { peer: peer.handle, msgId: c.msgId, tier: policy.tier, flags });
375
+ }
376
+ }
377
+ else {
378
+ const dir = path.join(input.overflowRoot, peer.handle);
379
+ ensureRealDir(dir, 0o700);
380
+ writeNew(dir, `${c.msgId}.json`, body, 0o600);
381
+ overflowed = true;
382
+ ps.receipts = receipts;
383
+ persist(input, peer.handle);
384
+ if (writeRateRecord(input, peer.handle, senderName, policy.tier, at))
385
+ written++;
386
+ }
387
+ }
388
+ catch (error) {
389
+ input.log('intake-error', { peer: peer.handle, msgId: c.msgId, code: errInfo(error).code ?? 'error' });
390
+ }
391
+ }
392
+ }
393
+ finally {
394
+ // Every picked id counts as traversed, failed or not, so one bad id cannot hold the cursor in place.
395
+ if (picked.length > 0)
396
+ ps.cursor = picked[picked.length - 1].msgId;
397
+ if (overflowed)
398
+ capOverflow(input, peer.handle);
399
+ persist(input, peer.handle);
400
+ }
401
+ if (future > 0)
402
+ input.log('intake-skip', { peer: peer.handle, code: 'stale', direction: 'future', count: future });
403
+ return written;
404
+ }
405
+ /** Names of the JSON files in `dir`, ascending. ULID names sort by time. */
406
+ function jsonNames(dir) {
407
+ return fs
408
+ .readdirSync(dir)
409
+ .filter((name) => name.endsWith('.json'))
410
+ .sort();
411
+ }
412
+ /**
413
+ * Time a queue, done or overflow entry belongs to: the ULID's time, or the start of the hour for a `rate-<unixHour>` record.
414
+ * Undefined for any other name.
415
+ */
416
+ function nameTime(name) {
417
+ const base = name.slice(0, -'.json'.length);
418
+ if (ULID_RE.test(base))
419
+ return decodeTime(base);
420
+ const rate = /^rate-(\d+)$/.exec(base);
421
+ return rate ? Number(rate[1]) * RATE_WINDOW_MS : undefined;
422
+ }
423
+ /**
424
+ * Retires one overflow file. The done marker is written before the unlink, so a retired id is not admitted again when the
425
+ * cursor laps. Markers live until the id is DONE_TTL_MS old, far past the 7-day floor below which ids are never fetched.
426
+ */
427
+ function retireOverflow(input, peer, name) {
428
+ const msgId = name.slice(0, -'.json'.length);
429
+ if (ULID_RE.test(msgId))
430
+ markDone(input, peer, msgId, 'overflow');
431
+ fs.unlinkSync(path.join(input.overflowRoot, peer, name));
432
+ }
433
+ /**
434
+ * Retires the oldest overflow files of one peer until the count and byte caps hold, at most JANITOR_BATCH per call. The
435
+ * byte cap is checked only once the count cap holds, so only the files that remain are sized.
436
+ */
437
+ function capOverflow(input, peer) {
438
+ const dir = path.join(input.overflowRoot, peer);
439
+ const names = jsonNames(dir);
440
+ const excess = Math.min(Math.max(0, names.length - OVERFLOW_MAX_FILES), JANITOR_BATCH);
441
+ for (const name of names.slice(0, excess))
442
+ retireOverflow(input, peer, name);
443
+ const kept = names.slice(excess);
444
+ if (kept.length > OVERFLOW_MAX_FILES)
445
+ return;
446
+ const sizes = kept.map((name) => fs.statSync(path.join(dir, name)).size);
447
+ let bytes = sizes.reduce((sum, size) => sum + size, 0);
448
+ for (let i = 0; i < kept.length && bytes > OVERFLOW_MAX_BYTES; i++) {
449
+ retireOverflow(input, peer, kept[i]);
450
+ bytes -= sizes[i];
451
+ }
452
+ }
453
+ /** Names of the peer directories directly under `root`. Entries whose names fail SAFE_ID are not peers. */
454
+ function peerDirs(root) {
455
+ return fs
456
+ .readdirSync(root, { withFileTypes: true })
457
+ .filter((entry) => entry.isDirectory() && SAFE_ID.test(entry.name))
458
+ .map((entry) => entry.name);
459
+ }
460
+ /**
461
+ * One bounded pass per directory per cycle. Age comes from the file name, so unexpired entries are not read. Queue records
462
+ * older than 7 days move to done/. done/ entries older than 14 days are pruned. Overflow entries older than 7 days are
463
+ * retired with a marker, then the overflow caps apply. Names that are neither ULIDs nor rate records are left in place.
464
+ */
465
+ function runJanitor(input) {
466
+ const now = input.now();
467
+ for (const peer of peerDirs(input.queueRoot)) {
468
+ const dir = path.join(input.queueRoot, peer);
469
+ let budget = JANITOR_BATCH;
470
+ for (const name of jsonNames(dir)) {
471
+ const at = nameTime(name);
472
+ if (at === undefined || !(at < now - STALE_MS))
473
+ continue;
474
+ if (budget-- <= 0)
475
+ break;
476
+ const file = path.join(dir, name);
477
+ const body = fs.readFileSync(file, 'utf8');
478
+ try {
479
+ if (!isRecord(JSON.parse(body)))
480
+ continue;
481
+ }
482
+ catch {
483
+ continue;
484
+ }
485
+ const doneDir = path.join(input.doneRoot, peer);
486
+ ensureRealDir(doneDir, 0o700);
487
+ writeNew(doneDir, name, body, 0o600);
488
+ fs.unlinkSync(file);
489
+ }
490
+ }
491
+ for (const peer of peerDirs(input.doneRoot)) {
492
+ const dir = path.join(input.doneRoot, peer);
493
+ let budget = JANITOR_BATCH;
494
+ for (const name of jsonNames(dir)) {
495
+ const at = nameTime(name);
496
+ if (at === undefined || !(at < now - DONE_TTL_MS))
497
+ continue;
498
+ if (budget-- <= 0)
499
+ break;
500
+ fs.unlinkSync(path.join(dir, name));
501
+ }
502
+ }
503
+ for (const peer of peerDirs(input.overflowRoot)) {
504
+ const dir = path.join(input.overflowRoot, peer);
505
+ let budget = JANITOR_BATCH;
506
+ for (const name of jsonNames(dir)) {
507
+ const at = nameTime(name);
508
+ if (at === undefined || !(at < now - OVERFLOW_TTL_MS))
509
+ continue;
510
+ if (budget-- <= 0)
511
+ break;
512
+ retireOverflow(input, peer, name);
513
+ }
514
+ capOverflow(input, peer);
515
+ }
516
+ }
517
+ /** One pass: janitor, then every paired peer's mirror. Returns how many new queue files were written (rate records included). */
518
+ export async function intakeCycle(input) {
519
+ for (const id of Object.keys(input.state.peers)) {
520
+ if (!Object.hasOwn(input.profile.peers, id))
521
+ delete input.state.peers[id];
522
+ }
523
+ runJanitor(input);
524
+ let written = 0;
525
+ for (const peer of Object.values(input.profile.peers)) {
526
+ if (peer.status !== 'paired')
527
+ continue;
528
+ if (!SAFE_ID.test(peer.handle)) {
529
+ input.log('intake-skip', { code: 'bad-handle' });
530
+ continue;
531
+ }
532
+ try {
533
+ written += await intakePeer(input, peer);
534
+ }
535
+ catch (error) {
536
+ input.log('intake-error', { peer: peer.handle, code: errInfo(error).code ?? 'error' });
537
+ }
538
+ }
539
+ return written;
540
+ }
541
+ /**
542
+ * Marks every mirrored message of each paired peer as done (outcome `init`), so the first real intake skips existing
543
+ * history. Refuses before writing anything when a paired peer already has a done/ entry, unless `force`. Existing markers
544
+ * are not rewritten. Returns the number of markers written per peer.
545
+ */
546
+ export async function initFromNow(input, force) {
547
+ const paired = Object.values(input.profile.peers).filter((peer) => peer.status === 'paired' && SAFE_ID.test(peer.handle));
548
+ const hasDone = paired.some((peer) => {
549
+ const dir = path.join(input.doneRoot, peer.handle);
550
+ return fs.existsSync(dir) && fs.readdirSync(dir).length > 0;
551
+ });
552
+ if (hasDone && !force)
553
+ throw new CourierError('init-exists', 'done/ already has entries for a paired peer; pass --force to mark again');
554
+ const marked = new Map();
555
+ for (const peer of paired) {
556
+ const keys = await listAll(input.kv, `${k.mirrorPrefix}${peer.handle}/threads/`);
557
+ let count = 0;
558
+ for (const c of mirrorMessages(peer.handle, keys, input.log)) {
559
+ if (markDone(input, peer.handle, c.msgId, 'init'))
560
+ count++;
561
+ }
562
+ marked.set(peer.handle, count);
563
+ input.log('intake-init', { peer: peer.handle, marked: count });
564
+ }
565
+ return marked;
566
+ }
567
+ function intervalSeconds(raw) {
568
+ if (raw === undefined)
569
+ return 60;
570
+ const n = /^\d+$/.test(raw) ? Number(raw) : NaN;
571
+ if (!(n >= 5 && n <= 3600))
572
+ throw new CourierError('usage', '--interval must be an integer from 5 to 3600');
573
+ return n;
574
+ }
575
+ export async function cmdIntake(args) {
576
+ const base = path.join(profileDir(), 'intake');
577
+ const queueRoot = args.get('queue-dir') ?? path.join(base, 'queue');
578
+ const doneRoot = args.get('done-dir') ?? path.join(base, 'done');
579
+ const overflowRoot = args.get('overflow-dir') ?? path.join(base, 'overflow');
580
+ const stateDir = args.get('state-dir') ?? path.join(base, 'state');
581
+ const peersFile = args.get('peers-file') ?? path.join(base, 'peers.json');
582
+ const once = args.has('once');
583
+ const intervalMs = intervalSeconds(args.get('interval')) * 1000;
584
+ const log = jsonLineLog();
585
+ ensureRealDir(stateDir, 0o700);
586
+ ensureRealDir(queueRoot, 0o750);
587
+ ensureRealDir(doneRoot, 0o700);
588
+ ensureRealDir(overflowRoot, 0o700);
589
+ const release = acquireLock(path.join(stateDir, LOCK_FILE));
590
+ try {
591
+ if (args.has('init-from-now')) {
592
+ const profile = loadProfile();
593
+ const node = await courierNode(profile);
594
+ await initFromNow({ kv: node.kv, profile, doneRoot, now: Date.now, log }, args.has('force'));
595
+ return;
596
+ }
597
+ const state = readState(path.join(stateDir, STATE_FILE));
598
+ let peers = loadPeers(peersFile);
599
+ log('intake-start', { once, intervalSeconds: intervalMs / 1000 });
600
+ for (;;) {
601
+ try {
602
+ const profile = loadProfile();
603
+ const node = await courierNode(profile);
604
+ try {
605
+ peers = loadPeers(peersFile);
606
+ }
607
+ catch (error) {
608
+ log('intake-error', { code: errInfo(error).code ?? 'bad-peers' });
609
+ }
610
+ const queued = await intakeCycle({
611
+ kv: node.kv,
612
+ profile,
613
+ peers,
614
+ queueRoot,
615
+ doneRoot,
616
+ overflowRoot,
617
+ stateDir,
618
+ state,
619
+ now: Date.now,
620
+ log,
621
+ });
622
+ log('intake-cycle', { queued });
623
+ }
624
+ catch (error) {
625
+ log('intake-error', { code: errInfo(error).code ?? 'error' });
626
+ if (once)
627
+ throw error;
628
+ }
629
+ if (once)
630
+ return;
631
+ await new Promise((resolve) => {
632
+ setTimeout(resolve, intervalMs);
633
+ });
634
+ }
635
+ }
636
+ finally {
637
+ release();
638
+ }
639
+ }
640
+ //# sourceMappingURL=intake.js.map