@botcord/daemon 0.2.58 → 0.2.59
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/diagnostics.d.ts +1 -0
- package/dist/diagnostics.js +35 -6
- package/dist/gateway/channels/wechat.js +26 -2
- package/dist/gateway/dispatcher.d.ts +3 -0
- package/dist/gateway/dispatcher.js +186 -27
- package/dist/index.js +9 -3
- package/dist/log.d.ts +9 -0
- package/dist/log.js +89 -1
- package/package.json +1 -1
- package/src/__tests__/diagnostics.test.ts +37 -1
- package/src/__tests__/log.test.ts +28 -1
- package/src/__tests__/wechat-channel.test.ts +47 -0
- package/src/diagnostics.ts +36 -6
- package/src/gateway/__tests__/dispatcher.test.ts +52 -0
- package/src/gateway/channels/wechat.ts +29 -2
- package/src/gateway/dispatcher.ts +212 -26
- package/src/index.ts +9 -3
- package/src/log.ts +100 -1
|
@@ -205,6 +205,10 @@ interface QueueState {
|
|
|
205
205
|
serialWorkerActive: boolean;
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
+
interface DeferredMultimodalEntry extends BufferedSerialEntry {
|
|
209
|
+
queuedAt: number;
|
|
210
|
+
}
|
|
211
|
+
|
|
208
212
|
/**
|
|
209
213
|
* Gateway dispatcher: consumes `GatewayInboundEnvelope` and drives a runtime
|
|
210
214
|
* turn per message, respecting queue mode, trust level, streaming, and
|
|
@@ -233,6 +237,7 @@ export class Dispatcher {
|
|
|
233
237
|
private readonly resolveHubUrl?: (accountId: string) => string | undefined;
|
|
234
238
|
private readonly transcript: TranscriptWriter;
|
|
235
239
|
private readonly queues: Map<string, QueueState> = new Map();
|
|
240
|
+
private readonly deferredMultimodal: Map<string, DeferredMultimodalEntry[]> = new Map();
|
|
236
241
|
/**
|
|
237
242
|
* Last `/hub/typing` ping timestamp per (accountId, conversationId).
|
|
238
243
|
* Used to debounce cancel-previous bursts so we don't trip Hub's 20/min
|
|
@@ -286,6 +291,11 @@ export class Dispatcher {
|
|
|
286
291
|
return;
|
|
287
292
|
}
|
|
288
293
|
|
|
294
|
+
const managed = this.managedRoutes ? Array.from(this.managedRoutes.values()) : undefined;
|
|
295
|
+
const route = resolveRoute(msg, this.config, managed);
|
|
296
|
+
const mode = resolveQueueMode(route, msg.conversation.kind);
|
|
297
|
+
const queueKey = buildQueueKey(msg);
|
|
298
|
+
|
|
289
299
|
// Pre-skip: empty/whitespace text.
|
|
290
300
|
const rawText = typeof msg.text === "string" ? msg.text.trim() : "";
|
|
291
301
|
if (!rawText) {
|
|
@@ -298,28 +308,87 @@ export class Dispatcher {
|
|
|
298
308
|
// turnId and write the inbound transcript record.
|
|
299
309
|
const turnId = randomUUID();
|
|
300
310
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
311
|
+
// Multimodal-only arrivals (files/images without sender-authored text)
|
|
312
|
+
// should not wake the runtime on their own. Ack them, record the inbound
|
|
313
|
+
// event, and prepend them to the next text-bearing turn for this queue.
|
|
314
|
+
if (isMultimodalOnlyMessage(msg)) {
|
|
315
|
+
await this.safeAck(envelope);
|
|
316
|
+
this.emitInbound(turnId, msg);
|
|
317
|
+
this.deferMultimodal(queueKey, { route, msg, channel, turnId, queuedAt: Date.now() });
|
|
318
|
+
this.log.info("dispatcher: deferred multimodal-only inbound", {
|
|
319
|
+
agentId: msg.accountId,
|
|
320
|
+
roomId: msg.conversation.id,
|
|
321
|
+
topicId: msg.conversation.threadId ?? null,
|
|
322
|
+
turnId,
|
|
323
|
+
messageId: msg.id,
|
|
324
|
+
senderId: msg.sender.id,
|
|
325
|
+
senderKind: msg.sender.kind,
|
|
326
|
+
mode,
|
|
327
|
+
queueKey,
|
|
328
|
+
});
|
|
329
|
+
if (this.onInbound) {
|
|
330
|
+
try {
|
|
331
|
+
await this.onInbound(msg);
|
|
332
|
+
} catch (err) {
|
|
333
|
+
this.log.warn("dispatcher: onInbound threw — continuing", {
|
|
334
|
+
messageId: msg.id,
|
|
335
|
+
error: err instanceof Error ? err.message : String(err),
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const deferred = this.takeDeferredMultimodal(queueKey);
|
|
343
|
+
let dispatchMsg = msg;
|
|
344
|
+
let dispatchTurnId: string = turnId;
|
|
345
|
+
let dispatchRoute = route;
|
|
346
|
+
let dispatchChannel = channel;
|
|
347
|
+
let text = rawText;
|
|
348
|
+
let mergedFromDeferredTurnIds: string[] = [];
|
|
349
|
+
if (deferred.length > 0) {
|
|
350
|
+
const merged = this.mergeSerialBuffer(
|
|
351
|
+
[...deferred, { route, msg, channel, turnId }],
|
|
352
|
+
queueKey,
|
|
353
|
+
);
|
|
354
|
+
if (merged) {
|
|
355
|
+
dispatchMsg = merged.msg;
|
|
356
|
+
dispatchTurnId = merged.turnId;
|
|
357
|
+
dispatchRoute = merged.route;
|
|
358
|
+
dispatchChannel = merged.channel;
|
|
359
|
+
text = merged.text;
|
|
360
|
+
mergedFromDeferredTurnIds = deferred.map((e) => e.turnId);
|
|
361
|
+
for (const entry of deferred) {
|
|
362
|
+
this.transcript.write({
|
|
363
|
+
ts: nowIso(),
|
|
364
|
+
kind: "dropped",
|
|
365
|
+
turnId: entry.turnId,
|
|
366
|
+
agentId: entry.msg.accountId,
|
|
367
|
+
roomId: entry.msg.conversation.id,
|
|
368
|
+
topicId: entry.msg.conversation.threadId ?? null,
|
|
369
|
+
reason: "batch_merged",
|
|
370
|
+
supersededBy: dispatchTurnId,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
305
375
|
|
|
306
376
|
// Compose the final user-turn text only for cancel-previous mode, where
|
|
307
377
|
// the dispatcher consumes the pre-composed text directly. Serial mode
|
|
308
378
|
// re-runs the composer at drain time on the merged message (so it sees
|
|
309
379
|
// the full coalesced batch instead of any single arrival), so calling
|
|
310
380
|
// the composer here would just be redundant work.
|
|
311
|
-
let text = rawText;
|
|
312
381
|
let composeFailedError: string | undefined;
|
|
313
382
|
if (mode === "cancel-previous" && this.composeUserTurn) {
|
|
314
383
|
try {
|
|
315
|
-
const composed = this.composeUserTurn(
|
|
384
|
+
const composed = this.composeUserTurn(dispatchMsg);
|
|
316
385
|
if (typeof composed === "string" && composed.length > 0) {
|
|
317
386
|
text = composed;
|
|
318
387
|
}
|
|
319
388
|
} catch (err) {
|
|
320
389
|
composeFailedError = err instanceof Error ? err.message : String(err);
|
|
321
390
|
this.log.warn("dispatcher: composeUserTurn threw — using raw text", {
|
|
322
|
-
messageId:
|
|
391
|
+
messageId: dispatchMsg.id,
|
|
323
392
|
error: composeFailedError,
|
|
324
393
|
});
|
|
325
394
|
}
|
|
@@ -364,28 +433,28 @@ export class Dispatcher {
|
|
|
364
433
|
if (this.attentionGate) {
|
|
365
434
|
let wake = true;
|
|
366
435
|
try {
|
|
367
|
-
const result = this.attentionGate(
|
|
436
|
+
const result = this.attentionGate(dispatchMsg);
|
|
368
437
|
wake = result instanceof Promise ? await result : result;
|
|
369
438
|
} catch (err) {
|
|
370
439
|
this.log.warn("dispatcher: attentionGate threw — waking", {
|
|
371
|
-
messageId:
|
|
440
|
+
messageId: dispatchMsg.id,
|
|
372
441
|
error: err instanceof Error ? err.message : String(err),
|
|
373
442
|
});
|
|
374
443
|
wake = true;
|
|
375
444
|
}
|
|
376
445
|
if (!wake) {
|
|
377
446
|
this.log.debug("dispatcher skip turn: attention policy", {
|
|
378
|
-
messageId:
|
|
379
|
-
accountId:
|
|
380
|
-
conversationId:
|
|
447
|
+
messageId: dispatchMsg.id,
|
|
448
|
+
accountId: dispatchMsg.accountId,
|
|
449
|
+
conversationId: dispatchMsg.conversation.id,
|
|
381
450
|
});
|
|
382
451
|
this.transcript.write({
|
|
383
452
|
ts: nowIso(),
|
|
384
453
|
kind: "attention_skipped",
|
|
385
|
-
turnId,
|
|
386
|
-
agentId:
|
|
387
|
-
roomId:
|
|
388
|
-
topicId:
|
|
454
|
+
turnId: dispatchTurnId,
|
|
455
|
+
agentId: dispatchMsg.accountId,
|
|
456
|
+
roomId: dispatchMsg.conversation.id,
|
|
457
|
+
topicId: dispatchMsg.conversation.threadId ?? null,
|
|
389
458
|
reason: "attention_gate_false",
|
|
390
459
|
});
|
|
391
460
|
return;
|
|
@@ -396,19 +465,35 @@ export class Dispatcher {
|
|
|
396
465
|
this.transcript.write({
|
|
397
466
|
ts: nowIso(),
|
|
398
467
|
kind: "compose_failed",
|
|
399
|
-
turnId,
|
|
400
|
-
agentId:
|
|
401
|
-
roomId:
|
|
402
|
-
topicId:
|
|
468
|
+
turnId: dispatchTurnId,
|
|
469
|
+
agentId: dispatchMsg.accountId,
|
|
470
|
+
roomId: dispatchMsg.conversation.id,
|
|
471
|
+
topicId: dispatchMsg.conversation.threadId ?? null,
|
|
403
472
|
error: composeFailedError,
|
|
404
473
|
fallback: "raw_text",
|
|
405
474
|
});
|
|
406
475
|
}
|
|
407
476
|
|
|
408
477
|
if (mode === "cancel-previous") {
|
|
409
|
-
await this.runCancelPrevious(
|
|
478
|
+
await this.runCancelPrevious(
|
|
479
|
+
queueKey,
|
|
480
|
+
dispatchRoute,
|
|
481
|
+
text,
|
|
482
|
+
dispatchMsg,
|
|
483
|
+
dispatchChannel,
|
|
484
|
+
dispatchTurnId,
|
|
485
|
+
mergedFromDeferredTurnIds,
|
|
486
|
+
);
|
|
410
487
|
} else {
|
|
411
|
-
await this.runSerial(
|
|
488
|
+
await this.runSerial(
|
|
489
|
+
queueKey,
|
|
490
|
+
dispatchRoute,
|
|
491
|
+
text,
|
|
492
|
+
dispatchMsg,
|
|
493
|
+
dispatchChannel,
|
|
494
|
+
dispatchTurnId,
|
|
495
|
+
mergedFromDeferredTurnIds,
|
|
496
|
+
);
|
|
412
497
|
}
|
|
413
498
|
}
|
|
414
499
|
|
|
@@ -452,6 +537,37 @@ export class Dispatcher {
|
|
|
452
537
|
return q;
|
|
453
538
|
}
|
|
454
539
|
|
|
540
|
+
private deferMultimodal(queueKey: string, entry: DeferredMultimodalEntry): void {
|
|
541
|
+
const list = this.deferredMultimodal.get(queueKey) ?? [];
|
|
542
|
+
list.push(entry);
|
|
543
|
+
while (list.length > MAX_BATCH_BUFFER_ENTRIES) {
|
|
544
|
+
const dropped = list.shift()!;
|
|
545
|
+
this.log.warn("dispatcher: deferred multimodal buffer overflow — dropped oldest", {
|
|
546
|
+
queueKey,
|
|
547
|
+
droppedMessageId: dropped.msg.id,
|
|
548
|
+
bufferCap: MAX_BATCH_BUFFER_ENTRIES,
|
|
549
|
+
});
|
|
550
|
+
this.transcript.write({
|
|
551
|
+
ts: nowIso(),
|
|
552
|
+
kind: "dropped",
|
|
553
|
+
turnId: dropped.turnId,
|
|
554
|
+
agentId: dropped.msg.accountId,
|
|
555
|
+
roomId: dropped.msg.conversation.id,
|
|
556
|
+
topicId: dropped.msg.conversation.threadId ?? null,
|
|
557
|
+
reason: "queue_overflow",
|
|
558
|
+
supersededBy: null,
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
this.deferredMultimodal.set(queueKey, list);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private takeDeferredMultimodal(queueKey: string): DeferredMultimodalEntry[] {
|
|
565
|
+
const list = this.deferredMultimodal.get(queueKey);
|
|
566
|
+
if (!list || list.length === 0) return [];
|
|
567
|
+
this.deferredMultimodal.delete(queueKey);
|
|
568
|
+
return list;
|
|
569
|
+
}
|
|
570
|
+
|
|
455
571
|
private async runCancelPrevious(
|
|
456
572
|
queueKey: string,
|
|
457
573
|
route: GatewayRoute,
|
|
@@ -459,6 +575,7 @@ export class Dispatcher {
|
|
|
459
575
|
msg: GatewayInboundEnvelope["message"],
|
|
460
576
|
channel: ChannelAdapter,
|
|
461
577
|
turnId: string,
|
|
578
|
+
mergedFromTurnIds: string[] = [],
|
|
462
579
|
): Promise<void> {
|
|
463
580
|
const q = this.getQueue(queueKey);
|
|
464
581
|
// Bump the generation on every arrival. Older arrivals still awaiting
|
|
@@ -518,7 +635,7 @@ export class Dispatcher {
|
|
|
518
635
|
});
|
|
519
636
|
return;
|
|
520
637
|
}
|
|
521
|
-
await this.runTurn(queueKey, route, text, msg, channel, turnId,
|
|
638
|
+
await this.runTurn(queueKey, route, text, msg, channel, turnId, mergedFromTurnIds);
|
|
522
639
|
}
|
|
523
640
|
|
|
524
641
|
/**
|
|
@@ -546,6 +663,7 @@ export class Dispatcher {
|
|
|
546
663
|
msg: GatewayInboundEnvelope["message"],
|
|
547
664
|
channel: ChannelAdapter,
|
|
548
665
|
turnId: string,
|
|
666
|
+
mergedFromTurnIds: string[] = [],
|
|
549
667
|
): Promise<void> {
|
|
550
668
|
const q = this.getQueue(queueKey);
|
|
551
669
|
q.serialBuffer.push({ route, msg, channel, turnId });
|
|
@@ -591,8 +709,10 @@ export class Dispatcher {
|
|
|
591
709
|
});
|
|
592
710
|
}
|
|
593
711
|
}
|
|
594
|
-
const
|
|
595
|
-
drained.length > 1
|
|
712
|
+
const mergedTurnIds =
|
|
713
|
+
drained.length > 1
|
|
714
|
+
? [...mergedFromTurnIds, ...drained.slice(0, -1).map((e) => e.turnId)]
|
|
715
|
+
: mergedFromTurnIds;
|
|
596
716
|
await this.runTurn(
|
|
597
717
|
queueKey,
|
|
598
718
|
merged.route,
|
|
@@ -600,7 +720,7 @@ export class Dispatcher {
|
|
|
600
720
|
merged.msg,
|
|
601
721
|
merged.channel,
|
|
602
722
|
merged.turnId,
|
|
603
|
-
|
|
723
|
+
mergedTurnIds,
|
|
604
724
|
);
|
|
605
725
|
}
|
|
606
726
|
} finally {
|
|
@@ -681,8 +801,13 @@ export class Dispatcher {
|
|
|
681
801
|
const latestRaw = (latest.msg.raw as Record<string, unknown> | null | undefined) ?? {};
|
|
682
802
|
const mergedRaw = { ...latestRaw, batch: items };
|
|
683
803
|
const anyMentioned = entries.some((e) => e.msg.mentioned === true);
|
|
804
|
+
const mergedText = entries
|
|
805
|
+
.map((e) => (typeof e.msg.text === "string" ? e.msg.text.trim() : ""))
|
|
806
|
+
.filter((s) => s.length > 0)
|
|
807
|
+
.join("\n");
|
|
684
808
|
const mergedMsg: GatewayInboundEnvelope["message"] = {
|
|
685
809
|
...latest.msg,
|
|
810
|
+
...(mergedText ? { text: mergedText } : {}),
|
|
686
811
|
mentioned: anyMentioned,
|
|
687
812
|
raw: mergedRaw,
|
|
688
813
|
};
|
|
@@ -1527,6 +1652,67 @@ function isBotCordChannel(channel: ChannelAdapter): boolean {
|
|
|
1527
1652
|
return channel.type === "botcord" || channel.id === "botcord";
|
|
1528
1653
|
}
|
|
1529
1654
|
|
|
1655
|
+
function isMultimodalOnlyMessage(msg: GatewayInboundEnvelope["message"]): boolean {
|
|
1656
|
+
if (!hasMultimodalContent(msg.raw)) return false;
|
|
1657
|
+
return !hasAuthoredText(msg.raw);
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
function hasAuthoredText(raw: unknown): boolean {
|
|
1661
|
+
if (!raw || typeof raw !== "object") return false;
|
|
1662
|
+
const obj = raw as Record<string, unknown>;
|
|
1663
|
+
const batch = obj.batch;
|
|
1664
|
+
if (Array.isArray(batch)) return batch.some((item) => hasAuthoredText(item));
|
|
1665
|
+
|
|
1666
|
+
if (typeof obj.text === "string" && obj.text.trim().length > 0) {
|
|
1667
|
+
// BotCord's /hub/inbox `text` may be synthesized from attachment metadata
|
|
1668
|
+
// when payload text is empty, so prefer envelope payload below when present.
|
|
1669
|
+
if (!obj.envelope || typeof obj.envelope !== "object") return true;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
const envelope = obj.envelope as Record<string, unknown> | undefined;
|
|
1673
|
+
const payload = envelope?.payload as Record<string, unknown> | undefined;
|
|
1674
|
+
if (payload) {
|
|
1675
|
+
for (const key of ["text", "body", "message"]) {
|
|
1676
|
+
const value = payload[key];
|
|
1677
|
+
if (typeof value === "string" && value.trim().length > 0) return true;
|
|
1678
|
+
}
|
|
1679
|
+
return false;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
const itemList = obj.item_list;
|
|
1683
|
+
if (Array.isArray(itemList)) {
|
|
1684
|
+
return itemList.some((item) => {
|
|
1685
|
+
if (!item || typeof item !== "object") return false;
|
|
1686
|
+
const textItem = (item as { text_item?: { text?: unknown } }).text_item;
|
|
1687
|
+
return typeof textItem?.text === "string" && textItem.text.trim().length > 0;
|
|
1688
|
+
});
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
return typeof obj.text === "string" && obj.text.trim().length > 0;
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
function hasMultimodalContent(raw: unknown): boolean {
|
|
1695
|
+
if (!raw || typeof raw !== "object") return false;
|
|
1696
|
+
const obj = raw as Record<string, unknown>;
|
|
1697
|
+
const batch = obj.batch;
|
|
1698
|
+
if (Array.isArray(batch)) return batch.some((item) => hasMultimodalContent(item));
|
|
1699
|
+
|
|
1700
|
+
const envelope = obj.envelope as Record<string, unknown> | undefined;
|
|
1701
|
+
const payload = envelope?.payload as Record<string, unknown> | undefined;
|
|
1702
|
+
const attachments = payload?.attachments;
|
|
1703
|
+
if (Array.isArray(attachments) && attachments.length > 0) return true;
|
|
1704
|
+
|
|
1705
|
+
const itemList = obj.item_list;
|
|
1706
|
+
if (Array.isArray(itemList)) {
|
|
1707
|
+
return itemList.some((item) => {
|
|
1708
|
+
if (!item || typeof item !== "object") return false;
|
|
1709
|
+
return (item as { type?: unknown }).type !== 1;
|
|
1710
|
+
});
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
return false;
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1530
1716
|
function resolveQueueMode(
|
|
1531
1717
|
route: GatewayRoute,
|
|
1532
1718
|
kind: "direct" | "group",
|
package/src/index.ts
CHANGED
|
@@ -132,9 +132,12 @@ Commands:
|
|
|
132
132
|
route list
|
|
133
133
|
route remove --room <rm_xxx>|--prefix <rm_xxx>
|
|
134
134
|
config Print resolved config
|
|
135
|
-
doctor [--json] [--bundle]
|
|
135
|
+
doctor [--json] [--bundle] [--full-log] Scan local runtimes (${ADAPTER_LIST});
|
|
136
136
|
--bundle also writes a zip under
|
|
137
|
-
~/.botcord/diagnostics
|
|
137
|
+
~/.botcord/diagnostics/. Bundles
|
|
138
|
+
daemon.log plus the latest 5 rotated
|
|
139
|
+
logs by default; --full-log bundles
|
|
140
|
+
all retained rotated logs.
|
|
138
141
|
memory get [--agent <ag_xxx>] [--json] Show current working memory
|
|
139
142
|
memory set [--agent <ag_xxx>] --goal <text>
|
|
140
143
|
Pin/update the agent's work goal
|
|
@@ -168,6 +171,7 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
168
171
|
"follow",
|
|
169
172
|
"json",
|
|
170
173
|
"bundle",
|
|
174
|
+
"full-log",
|
|
171
175
|
"help",
|
|
172
176
|
"h",
|
|
173
177
|
"mentioned",
|
|
@@ -1347,7 +1351,9 @@ const fsFileReader: DoctorFileReader = {
|
|
|
1347
1351
|
|
|
1348
1352
|
async function cmdDoctor(args: ParsedArgs): Promise<void> {
|
|
1349
1353
|
if (args.flags.bundle === true) {
|
|
1350
|
-
const bundle = await createDiagnosticBundle(
|
|
1354
|
+
const bundle = await createDiagnosticBundle({
|
|
1355
|
+
includeAllLogs: args.flags["full-log"] === true,
|
|
1356
|
+
});
|
|
1351
1357
|
if (args.flags.json === true) {
|
|
1352
1358
|
console.log(JSON.stringify({ bundle }, null, 2));
|
|
1353
1359
|
return;
|
package/src/log.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { appendFileSync, mkdirSync } from "node:fs";
|
|
1
|
+
import { appendFileSync, mkdirSync, readdirSync, renameSync, statSync, unlinkSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
5
|
const LOG_DIR = path.join(homedir(), ".botcord", "logs");
|
|
6
6
|
const LOG_FILE = path.join(LOG_DIR, "daemon.log");
|
|
7
|
+
const LOG_ROTATE_MAX_BYTES = 10 * 1024 * 1024;
|
|
8
|
+
const LOG_ROTATE_KEEP = 20;
|
|
7
9
|
|
|
8
10
|
let inited = false;
|
|
9
11
|
function ensureDir(): void {
|
|
@@ -18,6 +20,14 @@ function ensureDir(): void {
|
|
|
18
20
|
|
|
19
21
|
type Level = "info" | "warn" | "error" | "debug";
|
|
20
22
|
|
|
23
|
+
export interface LogFileEntry {
|
|
24
|
+
path: string;
|
|
25
|
+
name: string;
|
|
26
|
+
sizeBytes: number;
|
|
27
|
+
mtimeMs: number;
|
|
28
|
+
active: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
21
31
|
function formatValue(value: unknown): string {
|
|
22
32
|
if (value instanceof Error) return JSON.stringify(value.stack ?? value.message);
|
|
23
33
|
if (typeof value === "string") return JSON.stringify(value);
|
|
@@ -44,10 +54,99 @@ export function formatLogLine(
|
|
|
44
54
|
return detail ? `${prefix} ${detail} ${suffix}` : `${prefix} ${suffix}`;
|
|
45
55
|
}
|
|
46
56
|
|
|
57
|
+
function rotatedName(file: string, date = new Date()): string {
|
|
58
|
+
const stamp = date.toISOString().replace(/[:.]/g, "-");
|
|
59
|
+
return `${file}.${stamp}.${process.pid}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function listDaemonLogFiles(logFile = LOG_FILE): LogFileEntry[] {
|
|
63
|
+
const dir = path.dirname(logFile);
|
|
64
|
+
const base = path.basename(logFile);
|
|
65
|
+
const entries: LogFileEntry[] = [];
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const st = statSync(logFile);
|
|
69
|
+
if (st.isFile()) {
|
|
70
|
+
entries.push({
|
|
71
|
+
path: logFile,
|
|
72
|
+
name: base,
|
|
73
|
+
sizeBytes: st.size,
|
|
74
|
+
mtimeMs: st.mtimeMs,
|
|
75
|
+
active: true,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
// no active log
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let names: string[] = [];
|
|
83
|
+
try {
|
|
84
|
+
names = readdirSync(dir);
|
|
85
|
+
} catch {
|
|
86
|
+
return entries;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const name of names) {
|
|
90
|
+
if (!name.startsWith(`${base}.`)) continue;
|
|
91
|
+
const file = path.join(dir, name);
|
|
92
|
+
try {
|
|
93
|
+
const st = statSync(file);
|
|
94
|
+
if (!st.isFile()) continue;
|
|
95
|
+
entries.push({
|
|
96
|
+
path: file,
|
|
97
|
+
name,
|
|
98
|
+
sizeBytes: st.size,
|
|
99
|
+
mtimeMs: st.mtimeMs,
|
|
100
|
+
active: false,
|
|
101
|
+
});
|
|
102
|
+
} catch {
|
|
103
|
+
// ignore disappearing files
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return entries.sort((a, b) => {
|
|
108
|
+
if (a.active !== b.active) return a.active ? -1 : 1;
|
|
109
|
+
return b.mtimeMs - a.mtimeMs || b.name.localeCompare(a.name);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function rotateLogIfNeeded(
|
|
114
|
+
logFile = LOG_FILE,
|
|
115
|
+
nextBytes = 0,
|
|
116
|
+
maxBytes = LOG_ROTATE_MAX_BYTES,
|
|
117
|
+
keep = LOG_ROTATE_KEEP,
|
|
118
|
+
): void {
|
|
119
|
+
let currentSize = 0;
|
|
120
|
+
try {
|
|
121
|
+
const st = statSync(logFile);
|
|
122
|
+
if (!st.isFile()) return;
|
|
123
|
+
currentSize = st.size;
|
|
124
|
+
} catch {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (currentSize + nextBytes <= maxBytes) return;
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
renameSync(logFile, rotatedName(logFile));
|
|
131
|
+
} catch {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const rotated = listDaemonLogFiles(logFile).filter((entry) => !entry.active);
|
|
136
|
+
for (const entry of rotated.slice(Math.max(0, keep))) {
|
|
137
|
+
try {
|
|
138
|
+
unlinkSync(entry.path);
|
|
139
|
+
} catch {
|
|
140
|
+
// best-effort cleanup
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
47
145
|
function write(level: Level, msg: string, fields?: Record<string, unknown>): void {
|
|
48
146
|
ensureDir();
|
|
49
147
|
const line = formatLogLine(level, msg, fields);
|
|
50
148
|
try {
|
|
149
|
+
rotateLogIfNeeded(LOG_FILE, Buffer.byteLength(line) + 1);
|
|
51
150
|
appendFileSync(LOG_FILE, line + "\n", { mode: 0o600 });
|
|
52
151
|
} catch {
|
|
53
152
|
// ignore log write errors
|