@leonhardthust/dsh-decision-engine 1.9.1 → 1.10.0

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.
@@ -1,5 +1,6 @@
1
1
  /**
2
- * 决策明细存储(`decisions.jsonl`)——「决策历史」标签页的数据源。
2
+ * 决策明细存储(`logs/` 下的分片;`single` 模式沿用 `decisions.jsonl`)
3
+ * ——「决策历史」标签页的数据源。
3
4
  *
4
5
  * ## 为什么不写进 telemetry.jsonl
5
6
  *
@@ -15,7 +16,34 @@
15
16
  * 所以另立一个文件。两个文件各管一件事:
16
17
  *
17
18
  * - `telemetry.jsonl` 对外口径的决策**结论**(谁、哪家、什么裁决、多少 token)
18
- * - `decisions.jsonl` 本地的决策**明细**(问了什么、答了什么、给谁做的)
19
+ * - `logs/*.log` 本地的决策**明细**(问了什么、答了什么、给谁做的)
20
+ *
21
+ * ## 分文件与保留(T3)
22
+ *
23
+ * 明细体量长起来之后,单个 `decisions.jsonl` 有两个硬问题:
24
+ *
25
+ * 1. **读一次要跨全文件**——按会话查历史得从头扫,越用越慢;
26
+ * 2. **轮转把不同会话混在一起**——文件按体积切,一个会话的记录会被切开,
27
+ * 清理时也无法只删「很久以前的某个会话」。
28
+ *
29
+ * 因此明细改写到 `logs/` 子目录,按配置分片:
30
+ *
31
+ * - `date`(默认)`YYYY-MM-DD.log`:最常用、也最符合「今天的日志」这个问法;
32
+ * - `session` `<会话名>__<id 前 8>.log`:按会话隔离,删一个会话的日志不影响别人;
33
+ * - `workspace` `<工作区名>.log`:按项目归档;
34
+ * - `single` `decisions.jsonl`:老行为,给不想让文件变多的用户留的退路。
35
+ *
36
+ * 保留策略(`cleanupOnce()`,启动时一次 + 每 6 小时一次)先按**文件名里的
37
+ * 日期**(解析不出来才用 mtime)删掉超过保留天数的分片,再按 mtime 从旧到新
38
+ * 删到总量上限。**永远跳过今天的分片、当前活跃分片与队列里待写的分片**——
39
+ * 清理删掉正在写的文件,比不清理坏得多。所有 I/O 失败只 warn:清理是旁路。
40
+ *
41
+ * ## 紧凑格式
42
+ *
43
+ * 明细里最占体积的是重复字段:授权点的 `modelEvaluation` 在一条记录里存了
44
+ * 三份,守卫的问题集定义每条都带一份。`lib/decision-log.js` 的
45
+ * `compactRecord()` 去掉逐字节相同的副本、把常量问题集折叠成引用、省略
46
+ * 默认空值;**所有读取路径先 `expandRecord()`** 还原,界面看到的形状不变。
19
47
  *
20
48
  * ## 记录体为什么带 sessionId
21
49
  *
@@ -52,15 +80,41 @@
52
80
  */
53
81
 
54
82
  import { randomUUID } from "node:crypto";
55
- import { chmod, mkdir, open, rename, stat, unlink } from "node:fs/promises";
83
+ import { appendFile, chmod, mkdir, open, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
56
84
  import { dirname, join } from "node:path";
57
85
 
58
- /** 明细文件名(与 `telemetry.jsonl` / `stats.jsonl` 同目录)。 */
86
+ import {
87
+ DEFAULT_DATE_SEPARATOR,
88
+ DEFAULT_RETENTION_DAYS,
89
+ DEFAULT_SPLIT_MODE,
90
+ DEFAULT_TOTAL_MAX_BYTES,
91
+ SESSION_ID_SEGMENT_LENGTH,
92
+ compactRecord,
93
+ dateKeyOf,
94
+ expandRecord,
95
+ normalizeDateSeparator,
96
+ normalizeSplitMode,
97
+ parseShardDate,
98
+ rotatedNameOf,
99
+ sanitizeSegment,
100
+ shardFileName,
101
+ } from "./decision-log.js";
102
+
103
+ /**
104
+ * `single` 模式的文件名,也是旧版本唯一使用的文件名(迁移来源)。
105
+ * 分片模式下明细写在 `logs/` 里,不再是这个名字。
106
+ */
59
107
  export const DECISIONS_FILE_NAME = "decisions.jsonl";
60
108
 
61
109
  /** 轮转后的旧文件(覆盖式,只留一代)。 */
62
110
  export const DECISIONS_ROTATED_NAME = "decisions.jsonl.1";
63
111
 
112
+ /** 所有分片所在的子目录名:与 config.yml / 标记文件分开,清理时不会误伤它们。 */
113
+ export const DECISIONS_LOGS_DIR_NAME = "logs";
114
+
115
+ /** 旧 `decisions.jsonl` 一次性迁移的标记文件名。 */
116
+ export const MIGRATION_MARKER_NAME = "decisions-log-migrated";
117
+
64
118
  /** 记录体版本。加字段不升版本(读端按缺省值兜),改语义才升。 */
65
119
  export const DECISION_RECORD_VERSION = 1;
66
120
 
@@ -402,22 +456,27 @@ export function recordIdOf(entry) {
402
456
  *
403
457
  * 用法:
404
458
  *
405
- * const store = new DecisionStore({ dir, getEnabled, getMaxBytes });
406
- * store.record(buildDecisionRecord({ … })); // 不 await,异步落盘
459
+ * const store = new DecisionStore({ dir, getEnabled, getMaxBytes, getSplitMode });
460
+ * store.record(buildDecisionRecord({ … }), { sessionName, workspaceName }); // 不 await
407
461
  * const page = await store.readForSession(sessionId, { limit: 200 });
408
462
  */
409
463
  export class DecisionStore {
410
464
  /**
411
465
  * @param options.dir - 插件数据目录(`~/.dsh/dsh-decision-engine/`)。
412
- * @param options.fileName - 文件名,默认 `decisions.jsonl`。
466
+ * @param options.fileName - `single` 模式的文件名,默认 `decisions.jsonl`。
413
467
  * @param options.logger - 宿主 logger;写盘失败时用它警告。
414
468
  * @param options.getEnabled - 返回「决策历史」开关。**关闭时不写任何东西**:
415
- * 不建文件、不占磁盘、也不采集内容。用取值函数而不是定值,配置改了
416
- * 立刻生效(与插件其余配置的处理方式一致)。**这是唯一的开关**:开启时
417
- * 记录一定带内容,内容只在超过长度上限时才由 `truncateContent()` 退回
418
- * `capture: 'meta'`。
419
- * @param options.getMaxBytes - 返回文件体积上限。
469
+ * 不建目录、不建文件、不占磁盘、也不采集内容。用取值函数而不是定值,
470
+ * 配置改了立刻生效(与插件其余配置的处理方式一致)。**这是唯一的开关**:
471
+ * 开启时记录一定带内容,内容只在超过长度上限时才由 `truncateContent()`
472
+ * 退回 `capture: 'meta'`。
473
+ * @param options.getMaxBytes - 返回**单个分片**的体积上限(到顶轮转)。
420
474
  * @param options.getMaxContentChars - 返回单条内容长度上限。
475
+ * @param options.getSplitMode - 返回分文件模式(single/date/session/workspace)。
476
+ * @param options.getDateSeparator - 返回日期分隔符(- / _ / .)。
477
+ * @param options.getRetentionDays - 返回保留天数;0 = 不按天删。
478
+ * @param options.getTotalMaxBytes - 返回 `logs/` 的总量上限(清理用)。
479
+ * @param options.getTimeZone - 返回 IANA 时区;留空用系统时区。
421
480
  * @param options.maxQueue - 待写队列上限;超过丢最旧的,防止磁盘挂起时吃内存。
422
481
  */
423
482
  constructor(options = {}) {
@@ -431,8 +490,23 @@ export class DecisionStore {
431
490
  typeof options.getMaxContentChars === "function"
432
491
  ? options.getMaxContentChars
433
492
  : () => DEFAULT_MAX_CONTENT_CHARS;
493
+ this.getSplitMode =
494
+ typeof options.getSplitMode === "function" ? options.getSplitMode : () => DEFAULT_SPLIT_MODE;
495
+ this.getDateSeparator =
496
+ typeof options.getDateSeparator === "function"
497
+ ? options.getDateSeparator
498
+ : () => DEFAULT_DATE_SEPARATOR;
499
+ this.getRetentionDays =
500
+ typeof options.getRetentionDays === "function"
501
+ ? options.getRetentionDays
502
+ : () => DEFAULT_RETENTION_DAYS;
503
+ this.getTotalMaxBytes =
504
+ typeof options.getTotalMaxBytes === "function"
505
+ ? options.getTotalMaxBytes
506
+ : () => DEFAULT_TOTAL_MAX_BYTES;
507
+ this.getTimeZone = typeof options.getTimeZone === "function" ? options.getTimeZone : () => undefined;
434
508
  this.maxQueue = Number.isFinite(options.maxQueue) ? Math.max(16, options.maxQueue) : 4096;
435
- /** 待写队列(已脱敏的整行文本)。 */
509
+ /** 待写队列:元素是 `{ target, line }`,目标分片在入队时就定了。 */
436
510
  this.queue = [];
437
511
  /** 正在进行的写盘任务。 */
438
512
  this.pending = null;
@@ -454,20 +528,52 @@ export class DecisionStore {
454
528
  this.dropped = 0;
455
529
  /** 轮转次数(诊断用)。 */
456
530
  this.rotated = 0;
457
- /** 自上次轮转以来已追加的字节数。 */
458
- this.appendedBytes = 0;
531
+ /** 每个分片自上次校准 / 轮转以来已追加的字节数:`target → bytes`。 */
532
+ this.appendedBytesMap = new Map();
533
+ /** 已经用真实文件大小校准过 baseline 的分片(避免每条记录一次 stat)。 */
534
+ this.baselined = new Set();
535
+ /**
536
+ * 最近一次实际写入的分片名。
537
+ *
538
+ * `path` getter 与清理的「活跃分片」都认它:清理绝不能删掉正在写的文件。
539
+ */
540
+ this.activeTarget = "";
459
541
  /** 是否已经确认过文件权限(避免每条记录一次 chmod)。 */
460
542
  this.permissionSettled = false;
543
+ /** 最近一次清理的结果(`storageReport()` 的数据源)。 */
544
+ this.storageState = {
545
+ dir: this.logsDir,
546
+ splitMode: this.splitMode(),
547
+ files: 0,
548
+ bytes: 0,
549
+ removed: 0,
550
+ removedBytes: 0,
551
+ lastCleanup: "",
552
+ retentionDays: this.retentionDays(),
553
+ totalMaxBytes: this.totalMaxBytes(),
554
+ enabled: this.enabled(),
555
+ error: "",
556
+ };
557
+ }
558
+
559
+ /** 所有分片所在的目录。 */
560
+ get logsDir() {
561
+ return join(this.dir, DECISIONS_LOGS_DIR_NAME);
461
562
  }
462
563
 
463
- /** 明细文件的绝对路径。 */
564
+ /**
565
+ * 当前主分片的绝对路径。
566
+ *
567
+ * 还没写过任何记录时按**此刻**算一个(日期模式 → 今天的分片),这样设置页
568
+ * 与排查脚本能直接拿到「接下来会写哪个文件」,而不是一个空串。
569
+ */
464
570
  get path() {
465
- return join(this.dir, this.fileName);
571
+ return join(this.logsDir, this.activeTarget || this.mainShardName());
466
572
  }
467
573
 
468
- /** 轮转后旧文件的绝对路径。 */
574
+ /** 当前主分片轮转后的绝对路径。 */
469
575
  get rotatePath() {
470
- return join(this.dir, DECISIONS_ROTATED_NAME);
576
+ return join(this.logsDir, rotatedNameOf(this.activeTarget || this.mainShardName()));
471
577
  }
472
578
 
473
579
  /** 开关是否开启。 */
@@ -479,16 +585,101 @@ export class DecisionStore {
479
585
  }
480
586
  }
481
587
 
588
+ /** 当前分文件模式(现读配置)。 */
589
+ splitMode() {
590
+ try {
591
+ return normalizeSplitMode(this.getSplitMode());
592
+ } catch {
593
+ return DEFAULT_SPLIT_MODE;
594
+ }
595
+ }
596
+
597
+ /** 当前日期分隔符(现读配置)。 */
598
+ dateSeparator() {
599
+ try {
600
+ return normalizeDateSeparator(this.getDateSeparator());
601
+ } catch {
602
+ return DEFAULT_DATE_SEPARATOR;
603
+ }
604
+ }
605
+
606
+ /** 当前保留天数:夹在 0..3650;坏值退回默认 30。 */
607
+ retentionDays() {
608
+ try {
609
+ const value = Number(this.getRetentionDays());
610
+ if (!Number.isFinite(value)) return DEFAULT_RETENTION_DAYS;
611
+ return Math.min(3650, Math.max(0, Math.floor(value)));
612
+ } catch {
613
+ return DEFAULT_RETENTION_DAYS;
614
+ }
615
+ }
616
+
617
+ /** 当前总量上限(字节)。 */
618
+ totalMaxBytes() {
619
+ try {
620
+ const value = Number(this.getTotalMaxBytes());
621
+ return Number.isFinite(value) && value > 0 ? value : DEFAULT_TOTAL_MAX_BYTES;
622
+ } catch {
623
+ return DEFAULT_TOTAL_MAX_BYTES;
624
+ }
625
+ }
626
+
627
+ /** 当前时区;留空 = 系统时区(由 decision-log.js 里的 Intl 兜底)。 */
628
+ timeZone() {
629
+ try {
630
+ const value = this.getTimeZone();
631
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
632
+ } catch {
633
+ return undefined;
634
+ }
635
+ }
636
+
637
+ /** 按配置与此刻算出主分片名(没写过记录时的 `path`)。 */
638
+ mainShardName() {
639
+ return shardFileName({
640
+ splitMode: this.splitMode(),
641
+ fileName: this.fileName,
642
+ separator: this.dateSeparator(),
643
+ timeZone: this.timeZone(),
644
+ ts: new Date().toISOString(),
645
+ });
646
+ }
647
+
648
+ /**
649
+ * 按**记录自己的 ts** 决定它该落到哪个分片。
650
+ *
651
+ * 用记录 ts 而不是「写盘时刻」:夜里的记录可能因为队列滞后到第二天才落盘,
652
+ * 按写盘时刻定位会让它出现在错误的日期文件里(对账时最难解释的一种错)。
653
+ *
654
+ * @param ts - 记录时间。
655
+ * @param meta - `{ sessionId, sessionName, workspaceName }`。
656
+ * @returns 分片文件名。
657
+ */
658
+ targetFor(ts, meta = {}) {
659
+ return shardFileName({
660
+ splitMode: this.splitMode(),
661
+ fileName: this.fileName,
662
+ separator: this.dateSeparator(),
663
+ timeZone: this.timeZone(),
664
+ ts,
665
+ sessionId: meta.sessionId,
666
+ sessionName: meta.sessionName,
667
+ workspaceName: meta.workspaceName,
668
+ });
669
+ }
670
+
482
671
  /**
483
672
  * 记一条决策明细。
484
673
  *
485
674
  * **不返回 Promise**:调用方不该 await 它。写盘在后台串行进行,决策路径
486
- * 上的额外开销只有一次 `JSON.stringify` 与一次 `push`。
675
+ * 上的额外开销只有一次紧凑化 + `JSON.stringify` 与一次 `push`。
487
676
  *
488
677
  * @param record - `buildDecisionRecord()` 造出的记录。
678
+ * @param meta - `{ sessionName, workspaceName }`(session 模式的命名依据;
679
+ * 会话 id 始终取自记录本身,meta 里只放展示名)。
489
680
  * @returns 是否入队(开关关闭、内容为空、序列化失败都返回 false)。
490
681
  */
491
- record(record) {
682
+ record(record, meta = {}) {
492
683
  if (!this.enabled()) return false;
493
684
  if (record === null || typeof record !== "object") return false;
494
685
  const seq = this.seq + 1;
@@ -516,18 +707,32 @@ export class DecisionStore {
516
707
  try {
517
708
  // 脱敏放在**序列化之后**:模式表针对的是 JSON 文本形态(`"apiKey": "…"`),
518
709
  // 对结构化对象做替换既难写又容易漏。
519
- line = maskSecrets(JSON.stringify(finalRecord));
710
+ //
711
+ // 紧凑化放在序列化**之前**:它删的是结构化重复字段(见 decision-log.js),
712
+ // 读端 `expandRecord()` 会原样还原,网络与界面都看不到差别。
713
+ line = maskSecrets(JSON.stringify(compactRecord(finalRecord)));
520
714
  } catch {
521
715
  // 循环引用之类的问题不该把调用方带崩——明细是旁路。
522
716
  this.failed += 1;
523
717
  return false;
524
718
  }
525
719
  this.seq += 1;
720
+ let target;
721
+ try {
722
+ target = this.targetFor(finalRecord.ts, {
723
+ sessionId: finalRecord.sessionId,
724
+ sessionName: meta?.sessionName,
725
+ workspaceName: meta?.workspaceName,
726
+ });
727
+ } catch {
728
+ // 分片名算不出来(不该发生)时退回主分片:宁可写错文件,不要丢记录。
729
+ target = this.mainShardName();
730
+ }
526
731
  if (this.queue.length >= this.maxQueue) {
527
732
  this.queue.shift();
528
733
  this.dropped += 1;
529
734
  }
530
- this.queue.push(`${line}\n`);
735
+ this.queue.push({ target, line: `${line}\n` });
531
736
  this.schedule();
532
737
  return true;
533
738
  }
@@ -549,56 +754,84 @@ export class DecisionStore {
549
754
  this.pending.catch(() => {});
550
755
  }
551
756
 
552
- /** 把队列里的条目写进文件;到体积上限时先轮转。 */
757
+ /** 把队列里的条目按目标分片写进文件;到体积上限时先轮转。 */
553
758
  async drain() {
554
759
  await Promise.resolve();
555
760
  while (this.queue.length > 0) {
556
761
  const batch = this.queue.splice(0, this.queue.length);
557
- const payload = batch.join("");
558
- try {
559
- await mkdir(dirname(this.path), { recursive: true });
560
- await this.ensureSizeBaseline();
561
- const limit = this.maxBytes();
562
- /**
563
- * 一批可能很大(调用方连续记了 200 条 → 一批 60 KB),**整批写会
564
- * 直接冲过上限**:轮转判断发生在写之前,而写之前文件还是空的,
565
- * 于是「追加字节数 + 这一批 > 上限」永远不成立,上限形同虚设。
566
- *
567
- * 因此把这一批按上限切成若干片,逐片写。片与片之间才可能触发
568
- * 轮转,于是「文件有多大」真正受上限约束。
569
- */
570
- for (const chunk of splitByBytes(batch, limit)) {
571
- // 写之前:文件已接近上限就先轮转,让新内容从一个空文件开始。
572
- // `appendedBytes > 0` 是「文件还空着就不轮转」——对着一个不存在的
573
- // 文件 rename 会失败,那只是噪音。
574
- if (this.appendedBytes > 0 && this.appendedBytes + chunk.length > limit) {
575
- await this.rotate();
576
- }
577
- const handle = await open(this.path, "a");
578
- try {
579
- await handle.write(chunk);
580
- } finally {
581
- await handle.close().catch(() => {});
582
- }
583
- this.appendedBytes += chunk.length;
584
- }
585
- this.written += batch.length;
586
- if (!this.permissionSettled) {
587
- this.permissionSettled = true;
588
- // 明细里可能有会话正文与命令片段,权限与 config.yml 一致保持 600。
589
- await chmod(this.path, 0o600).catch(() => {});
762
+ /**
763
+ * 按目标分组再写,而不是整批写一个文件:一批记录可能跨天(或跨会话),
764
+ * 整批写会让它们全落到「这批第一条」的那个分片里——日志里就会出现
765
+ * 「今天的文件里有昨天的记录」这种最难自查的错位。
766
+ */
767
+ const groups = new Map();
768
+ for (const item of batch) {
769
+ const lines = groups.get(item.target);
770
+ if (lines === undefined) groups.set(item.target, [item.line]);
771
+ else lines.push(item.line);
772
+ }
773
+ for (const [target, lines] of groups) {
774
+ try {
775
+ await this.appendTarget(target, lines);
776
+ } catch (error) {
777
+ // 写盘失败:丢这一组,记一次警告。**不重试**——重试会堆积,
778
+ // 而明细失败绝不该拖住决策。
779
+ this.failed += lines.length;
780
+ this.logger?.warn?.(
781
+ `dsh-decision-engine: 写入决策明细失败(${error instanceof Error ? error.message : String(error)})`,
782
+ );
590
783
  }
591
- } catch (error) {
592
- // 写盘失败:丢这批,记一次警告。**不重试**——重试会堆积,
593
- // 而明细失败绝不该拖住决策。
594
- this.failed += batch.length;
595
- this.logger?.warn?.(
596
- `dsh-decision-engine: 写入决策明细失败(${error instanceof Error ? error.message : String(error)})`,
597
- );
598
784
  }
599
785
  }
600
786
  }
601
787
 
788
+ /**
789
+ * 把一个目标的若干行追加进文件;必要时按单文件上限轮转。
790
+ *
791
+ * 一批可能很大(调用方连续记了 200 条 → 一批 60 KB),**整批写会直接
792
+ * 冲过上限**:轮转判断发生在写之前,而写之前文件还是空的,于是
793
+ * 「追加字节数 + 这一批 > 上限」永远不成立,上限形同虚设。因此按上限
794
+ * 切成若干片逐片写,片与片之间才可能触发轮转。
795
+ *
796
+ * @param target - 分片文件名。
797
+ * @param lines - 已脱敏的整行文本(每行自带 `\n`)。
798
+ */
799
+ async appendTarget(target, lines) {
800
+ await mkdir(this.logsDir, { recursive: true });
801
+ await this.ensureSizeBaseline(target);
802
+ const limit = this.maxBytes();
803
+ for (const chunk of splitByBytes(lines, limit)) {
804
+ const bytes = Buffer.byteLength(chunk, "utf8");
805
+ // `appendedBytes > 0` 是「文件还空着就不轮转」——对着一个不存在的
806
+ // 文件 rename 会失败,那只是噪音。
807
+ if (this.appendedBytesOf(target) > 0 && this.appendedBytesOf(target) + bytes > limit) {
808
+ await this.rotateTarget(target);
809
+ }
810
+ const handle = await open(join(this.logsDir, target), "a");
811
+ try {
812
+ await handle.write(chunk);
813
+ } finally {
814
+ await handle.close().catch(() => {});
815
+ }
816
+ this.appendedBytesMap.set(target, this.appendedBytesOf(target) + bytes);
817
+ this.activeTarget = target;
818
+ }
819
+ this.written += lines.length;
820
+ if (!this.permissionSettled) {
821
+ this.permissionSettled = true;
822
+ // 明细里可能有会话正文与命令片段,权限与 config.yml 一致保持 600。
823
+ // 只对**本批最后写的那个文件**设一次:这是「首次落盘」的粗粒度保险,
824
+ // 而不是逐条 chmod 的精确权限管理。
825
+ await chmod(join(this.logsDir, this.activeTarget || target), 0o600).catch(() => {});
826
+ }
827
+ }
828
+
829
+ /** 某分片已追加的字节数(0 = 还没写过/刚轮转过)。 */
830
+ appendedBytesOf(target) {
831
+ const value = this.appendedBytesMap.get(target);
832
+ return Number.isFinite(value) ? value : 0;
833
+ }
834
+
602
835
  /** 体积上限(现读配置)。 */
603
836
  maxBytes() {
604
837
  try {
@@ -610,31 +843,36 @@ export class DecisionStore {
610
843
  }
611
844
 
612
845
  /**
613
- * 首次写盘前用真实文件大小初始化「已追加字节」。
846
+ * 首次写某个分片前用真实文件大小初始化「已追加字节」。
614
847
  *
615
- * 为什么需要:进程刚启动时 `appendedBytes` 是 0,而磁盘上的文件可能已经
616
- * 有几十 MB(升级前累积的)。不校准的话要再写满一整个上限才轮转。
848
+ * 为什么需要:进程刚启动时计数是 0,而磁盘上的分片可能已经有几十 MB
849
+ * (升级前 / 上次运行累积的)。不校准的话要再写满一整个上限才轮转。
617
850
  */
618
- async ensureSizeBaseline() {
619
- if (this.permissionSettled) return;
851
+ async ensureSizeBaseline(target) {
852
+ if (this.baselined.has(target)) return;
853
+ this.baselined.add(target);
620
854
  try {
621
- const info = await stat(this.path);
622
- this.appendedBytes = info.size;
855
+ const info = await stat(join(this.logsDir, target));
856
+ this.appendedBytesMap.set(target, info.size);
623
857
  } catch {
624
- this.appendedBytes = 0;
858
+ this.appendedBytesMap.set(target, 0);
625
859
  }
626
860
  }
627
861
 
628
862
  /**
629
- * 把当前文件挪成 `.1`,覆盖上一代。
863
+ * 把某个分片挪成 `.1`,覆盖上一代。
630
864
  *
631
865
  * 只留一代而不是多代:这个页面的用途是「看会话里刚才发生了什么」,
632
866
  * 不是归档。多代备份会让磁盘占用变成上限的整数倍,而收益近似为零。
867
+ * 轮转名跟着模式走:`2026-11-15.log` → `2026-11-15.1.log`,
868
+ * `decisions.jsonl` → `decisions.jsonl.1`。
633
869
  */
634
- async rotate() {
870
+ async rotateTarget(target) {
871
+ const path = join(this.logsDir, target);
872
+ const rotated = join(this.logsDir, rotatedNameOf(target));
635
873
  try {
636
- await unlink(this.rotatePath).catch(() => {});
637
- await rename(this.path, this.rotatePath);
874
+ await unlink(rotated).catch(() => {});
875
+ await rename(path, rotated);
638
876
  this.rotated += 1;
639
877
  } catch (error) {
640
878
  this.logger?.warn?.(
@@ -642,21 +880,180 @@ export class DecisionStore {
642
880
  );
643
881
  } finally {
644
882
  // 无论成功与否都把计数清零:失败时继续往老文件写也不会无限跑偏。
645
- this.appendedBytes = 0;
883
+ this.appendedBytesMap.set(target, 0);
884
+ }
885
+ }
886
+
887
+ /** 路径是否存在(只判断存在性,不区分文件/目录)。 */
888
+ async pathExists(path) {
889
+ try {
890
+ await stat(path);
891
+ return true;
892
+ } catch {
893
+ return false;
646
894
  }
647
895
  }
648
896
 
897
+ /**
898
+ * 列出 `logs/` 下的所有分片(只认 `.log`)。
899
+ *
900
+ * 为什么只认 `.log`:这个目录里还可能有迁移标记、`.migrated` 备份与
901
+ * 临时文件;清理与读取都不该碰它们。`decisions.jsonl` 是「迁移前」的
902
+ * 旧文件,由读取路径单独兜底,不参与日志目录的枚举。
903
+ *
904
+ * @returns `{ name, path, size, mtimeMs, date }[]`;目录不存在时返回空数组。
905
+ */
906
+ async shardFiles() {
907
+ let names;
908
+ try {
909
+ names = await readdir(this.logsDir);
910
+ } catch {
911
+ return [];
912
+ }
913
+ const files = [];
914
+ for (const name of names) {
915
+ if (!name.endsWith(".log")) continue;
916
+ const path = join(this.logsDir, name);
917
+ let info;
918
+ try {
919
+ info = await stat(path);
920
+ } catch {
921
+ continue;
922
+ }
923
+ if (!info.isFile()) continue;
924
+ files.push({
925
+ name,
926
+ path,
927
+ size: info.size,
928
+ mtimeMs: Number.isFinite(info.mtimeMs) ? info.mtimeMs : 0,
929
+ date: parseShardDate(name),
930
+ });
931
+ }
932
+ return files;
933
+ }
934
+
935
+ /**
936
+ * 按模式收集「该读哪些分片」,并读出它们的尾部行。
937
+ *
938
+ * 读取顺序与配额:
939
+ *
940
+ * - `date`:按文件名日期**从新到旧**,同一天先读主文件再读 `.1`(`.1`
941
+ * 是轮转出去的、更旧),边读边扣 `scanLines` 配额;
942
+ * - `session`:按 mtime 从新到旧匹配 `__<id8>` 后缀;
943
+ * - `workspace`:读该目录全部分片(按 mtime 从新到旧);
944
+ * - `single`:主文件 + `.1`。
945
+ *
946
+ * **不丢历史的兜底链**(模式特定分片一个都没有时逐级退让):
947
+ *
948
+ * 1. 本模式下没有匹配 → 读 `logs/` 里单一文件模式的 `decisions.jsonl`;
949
+ * 2. 还没有 → 读**旧格式**根目录的 `decisions.jsonl` / `.1`(用户可能
950
+ * 刚升级、迁移还没跑或失败);
951
+ * 3. 仍没有 → 读 `logs/` 全部分片(用户切换过模式:例如从 session 切到
952
+ * date,旧记录在别的命名里;最终仍按 sessionId 严格过滤,不会串会话)。
953
+ *
954
+ * @param sessionId - 目标会话 id。
955
+ * @param scanLines - 最多倒着扫多少行。
956
+ * @returns `{ files, lines, scanned, hitCap }`:`lines` 已按时间从旧到新。
957
+ */
958
+ async collectLines(sessionId, scanLines) {
959
+ const mode = this.splitMode();
960
+ const todayKey = dateKeyOf(new Date().toISOString(), this.timeZone());
961
+ const all = await this.shardFiles();
962
+ /** @type {{ name: string, path: string }[]} */
963
+ let files = [];
964
+ if (mode === "single") {
965
+ files = await this.existingFiles([
966
+ join(this.logsDir, rotatedNameOf(this.fileName)),
967
+ join(this.logsDir, this.fileName),
968
+ ]);
969
+ } else if (mode === "date") {
970
+ // 只认「今天及更早」的日期分片:未来日期的文件(改过系统时钟、手工拷贝)
971
+ // 若混进来,会排在最前面并吃掉全部扫描配额,让真正的今天记录读不到。
972
+ files = all
973
+ .filter((file) => file.date !== null && file.date.key <= todayKey)
974
+ .sort((left, right) => {
975
+ if (left.date.key !== right.date.key) return left.date.key < right.date.key ? 1 : -1;
976
+ // 同一天:主文件比 `.1` 新(轮转把旧内容挪到了 `.1`)。
977
+ return left.date.rotation - right.date.rotation;
978
+ })
979
+ .map((file) => ({ name: file.name, path: file.path }));
980
+ } else if (mode === "session") {
981
+ const suffix = sessionIdSuffix(sessionId);
982
+ const pattern = new RegExp(`__${escapeRegExp(suffix)}(?:\\.\\d+)?\\.log$`);
983
+ files = all
984
+ .filter((file) => pattern.test(file.name))
985
+ .sort((left, right) => right.mtimeMs - left.mtimeMs)
986
+ .map((file) => ({ name: file.name, path: file.path }));
987
+ } else {
988
+ files = all
989
+ .sort((left, right) => right.mtimeMs - left.mtimeMs)
990
+ .map((file) => ({ name: file.name, path: file.path }));
991
+ }
992
+ if (files.length === 0 && mode !== "single") {
993
+ files = await this.existingFiles([
994
+ join(this.logsDir, rotatedNameOf(this.fileName)),
995
+ join(this.logsDir, this.fileName),
996
+ ]);
997
+ }
998
+ if (files.length === 0) {
999
+ // 旧格式:不迁移也能看历史(迁移把旧文件改名成 `.migrated` 之后
1000
+ // 这里自然读不到,不会重复计数)。
1001
+ files = await this.existingFiles([
1002
+ join(this.dir, DECISIONS_ROTATED_NAME),
1003
+ join(this.dir, this.fileName),
1004
+ ]);
1005
+ }
1006
+ if (files.length === 0) {
1007
+ // 切换过模式:旧记录可能在别的命名的分片里。仍按 sessionId 严格过滤,
1008
+ // 因此读到别的会话的记录是不可能的,最多多扫几行。
1009
+ files = all
1010
+ .sort((left, right) => right.mtimeMs - left.mtimeMs)
1011
+ .map((file) => ({ name: file.name, path: file.path }));
1012
+ }
1013
+ const lines = [];
1014
+ /** 真正读过的分片(配额用完后就不再读更旧的那些)。 */
1015
+ const readFiles = [];
1016
+ let scanned = 0;
1017
+ let hitCap = false;
1018
+ for (const file of files) {
1019
+ const remaining = scanLines - scanned;
1020
+ if (remaining <= 0) {
1021
+ hitCap = true;
1022
+ break;
1023
+ }
1024
+ const part = await readTailLines(file.path, remaining);
1025
+ readFiles.push(file.path);
1026
+ scanned += part.length;
1027
+ if (part.length >= remaining) hitCap = true;
1028
+ // 后读的文件更新:放到已读内容后面,最终仍是时间升序。
1029
+ lines.unshift(...part);
1030
+ }
1031
+ return { files: readFiles, lines, scanned, hitCap };
1032
+ }
1033
+
1034
+ /** 过滤出真实存在的文件(顺序与入参一致)。 */
1035
+ async existingFiles(paths) {
1036
+ const files = [];
1037
+ for (const path of paths) {
1038
+ if (await this.pathExists(path)) {
1039
+ files.push({ name: path.split(/[\\/]/).pop() ?? path, path });
1040
+ }
1041
+ }
1042
+ return files;
1043
+ }
1044
+
649
1045
  /**
650
1046
  * 读某个会话的决策明细(时间升序)。
651
1047
  *
652
- * 先读当前文件尾部;不够再读轮转过的 `.1`(更早的那些)。**永远整读**:
653
- * 扫描行数有上限 `DEFAULT_SCAN_LINES`,返回条数有上限 `MAX_ENTRIES_PER_QUERY`。
1048
+ * 跨分片读:按当前模式收集最近的分片,倒着扫到 `scanLines` 配额为止。
1049
+ * 返回条数有上限 `MAX_ENTRIES_PER_QUERY`。
654
1050
  *
655
1051
  * @param sessionId - 会话 id;空串直接返回空页(不可归属的记录不属于任何人)。
656
1052
  * @param options.limit - 返回多少条(默认 200,上限 500)。
657
1053
  * @param options.scanLines - 最多倒着扫多少行(默认 4000)。
658
- * @returns `{ entries, total, scanned, truncated, file }`。
659
- * `total` 是扫描窗口内**该会话**的记录数,可能大于 `entries.length`。
1054
+ * @returns `{ entries, total, scanned, truncated, file, files, enabled }`。
1055
+ * `total` 是扫描窗口内**该会话**的记录数,可能大于 `entries.length`;
1056
+ * `files` 是本次实际读取的分片(从新到旧),供界面显示「记录在哪」。
660
1057
  */
661
1058
  async readForSession(sessionId, options = {}) {
662
1059
  const id = typeof sessionId === "string" ? sessionId.trim() : "";
@@ -673,17 +1070,13 @@ export class DecisionStore {
673
1070
  scanned: 0,
674
1071
  truncated: false,
675
1072
  file: this.path,
1073
+ files: [],
676
1074
  enabled: this.enabled(),
677
1075
  };
678
1076
  if (id.length === 0) return empty;
679
1077
 
680
- const current = await readTailLines(this.path, scanLines);
681
- let lines = current;
682
- let scannedHitCap = current.length >= scanLines;
683
- if (current.length < scanLines) {
684
- const older = await readTailLines(this.rotatePath, scanLines - current.length);
685
- lines = [...older, ...current];
686
- }
1078
+ const collected = await this.collectLines(id, scanLines);
1079
+ const lines = collected.lines;
687
1080
 
688
1081
  /**
689
1082
  * 预筛:先用子串判断这行**有没有可能**属于目标会话,命中了才 `JSON.parse`。
@@ -692,10 +1085,9 @@ export class DecisionStore {
692
1085
  * 完整 JSON.parse 再丢掉,白花 90% 以上的解析开销。
693
1086
  *
694
1087
  * 为什么这是安全的(不是启发式):`JSON.stringify` 只会把会话 id 里的
695
- * 引号、反斜杠与控制字符转义,其余字符原样保留。因此只要 id 本身不含
696
- * 这些需要转义的字符,磁盘上那一行的 `"sessionId":"<id>"` 片段必然
697
- * **逐字**包含 id;反过来,包含 id 子串的行仍要经过下面的严格比较,
698
- * 预筛只可能漏掉不可能匹配的行,不会放过不匹配的行。
1088
+ * 引号、反斜杠与控制字符转义,其余字符原样保留;紧凑格式**不省略**
1089
+ * `sessionId`,所以磁盘上那一行的 `"sessionId":"<id>"` 片段必然逐字包含
1090
+ * id。反过来,包含 id 子串的行仍要经过下面的严格比较。
699
1091
  *
700
1092
  * id 含转义字符时(极罕见)直接退化成「全部解析」,与旧行为一致。
701
1093
  */
@@ -712,8 +1104,10 @@ export class DecisionStore {
712
1104
  continue;
713
1105
  }
714
1106
  if (entry === null || typeof entry !== "object") continue;
715
- if (typeof entry.sessionId !== "string" || entry.sessionId !== id) continue;
716
- matched.push(entry);
1107
+ // 所有读取路径都必须先展开:紧凑格式不是「另一种记录」,只是磁盘形态。
1108
+ const expanded = expandRecord(entry);
1109
+ if (typeof expanded.sessionId !== "string" || expanded.sessionId !== id) continue;
1110
+ matched.push(expanded);
717
1111
  }
718
1112
  // 按**时间**排序,不按 `seq`。
719
1113
  //
@@ -751,13 +1145,265 @@ export class DecisionStore {
751
1145
  return {
752
1146
  entries,
753
1147
  total: matched.length,
754
- scanned: lines.length,
755
- truncated: matched.length > limit || scannedHitCap,
756
- file: this.path,
1148
+ scanned: collected.scanned,
1149
+ truncated: matched.length > limit || collected.hitCap,
1150
+ file: collected.files[0] ?? this.path,
1151
+ files: collected.files,
1152
+ enabled: this.enabled(),
1153
+ };
1154
+ }
1155
+
1156
+ /**
1157
+ * 清理过期分片(启动时一次 + 每 6 小时一次)。
1158
+ *
1159
+ * 两轮,顺序不能反:
1160
+ *
1161
+ * 1. **按保留天数**:日期从文件名解析(分片名里的日期就是记录日期,
1162
+ * 比 mtime 可信——拷贝/恢复会把 mtime 改掉);解析不出日期才退回
1163
+ * mtime。早于「今天 − 保留天数」的删掉;`0` = 这一轮不删。
1164
+ * 2. **按总量上限**:仍超上限就按 mtime 从旧到新删,直到不超。
1165
+ *
1166
+ * 两道保险:**绝不删今天的分片**(用户正在看的很可能就是它)与
1167
+ * **绝不删活跃分片**(当前主分片 / 本进程写过 / 队列里待写)。清理删掉
1168
+ * 正在写的文件比不清理坏得多——轻则丢记录,重则在 Windows 上写失败。
1169
+ *
1170
+ * 任何 I/O 异常只 warn:清理是旁路,失败不该影响插件启动。开关关闭时
1171
+ * 直接返回(不建目录、不动磁盘)。
1172
+ *
1173
+ * @returns 本次清理的报告(同时存进 `storageReport()`)。
1174
+ */
1175
+ async cleanupOnce() {
1176
+ const report = {
1177
+ dir: this.logsDir,
1178
+ splitMode: this.splitMode(),
1179
+ files: 0,
1180
+ bytes: 0,
1181
+ removed: 0,
1182
+ removedBytes: 0,
1183
+ lastCleanup: new Date().toISOString(),
1184
+ retentionDays: this.retentionDays(),
1185
+ totalMaxBytes: this.totalMaxBytes(),
1186
+ enabled: this.enabled(),
1187
+ error: "",
1188
+ };
1189
+ if (!report.enabled) {
1190
+ this.storageState = report;
1191
+ return report;
1192
+ }
1193
+ try {
1194
+ const entries = await this.shardFiles();
1195
+ const todayKey = dateKeyOf(new Date().toISOString(), this.timeZone());
1196
+ const active = this.activeTargetNames();
1197
+ const retention = report.retentionDays;
1198
+ const cutoffKey =
1199
+ retention > 0
1200
+ ? dateKeyOf(Date.now() - retention * 24 * 60 * 60 * 1000, this.timeZone())
1201
+ : "";
1202
+ const kept = [];
1203
+ for (const entry of entries) {
1204
+ const key = entry.date === null ? dateKeyOf(entry.mtimeMs, this.timeZone()) : entry.date.key;
1205
+ const isToday = key === todayKey;
1206
+ const isActive = active.has(entry.name);
1207
+ if (retention > 0 && key.length > 0 && cutoffKey.length > 0 && key < cutoffKey && !isToday && !isActive) {
1208
+ try {
1209
+ await unlink(entry.path);
1210
+ report.removed += 1;
1211
+ report.removedBytes += entry.size;
1212
+ continue;
1213
+ } catch (error) {
1214
+ // 删不掉(权限 / 被别的进程占用)不影响其它文件。
1215
+ this.logger?.warn?.(
1216
+ `dsh-decision-engine: 清理过期决策日志失败(${entry.name}:${error instanceof Error ? error.message : String(error)})`,
1217
+ );
1218
+ }
1219
+ }
1220
+ kept.push({ ...entry, key });
1221
+ }
1222
+ let total = kept.reduce((sum, item) => sum + item.size, 0);
1223
+ const maxTotal = report.totalMaxBytes;
1224
+ if (total > maxTotal) {
1225
+ const candidates = kept
1226
+ .filter((item) => item.key !== todayKey && !active.has(item.name))
1227
+ .sort((left, right) => left.mtimeMs - right.mtimeMs);
1228
+ for (const item of candidates) {
1229
+ if (total <= maxTotal) break;
1230
+ try {
1231
+ await unlink(item.path);
1232
+ total -= item.size;
1233
+ report.removed += 1;
1234
+ report.removedBytes += item.size;
1235
+ const index = kept.findIndex((keptItem) => keptItem.name === item.name);
1236
+ if (index >= 0) kept.splice(index, 1);
1237
+ } catch (error) {
1238
+ this.logger?.warn?.(
1239
+ `dsh-decision-engine: 按总量清理决策日志失败(${item.name}:${error instanceof Error ? error.message : String(error)})`,
1240
+ );
1241
+ }
1242
+ }
1243
+ }
1244
+ report.files = kept.length;
1245
+ report.bytes = total;
1246
+ } catch (error) {
1247
+ report.error = error instanceof Error ? error.message : String(error);
1248
+ this.logger?.warn?.(`dsh-decision-engine: 清理决策日志失败(${report.error})`);
1249
+ }
1250
+ this.storageState = report;
1251
+ return report;
1252
+ }
1253
+
1254
+ /** 活跃分片名集合:当前主分片 + 本进程写过的 + 队列里待写的。 */
1255
+ activeTargetNames() {
1256
+ const active = new Set();
1257
+ const current = this.activeTarget || this.mainShardName();
1258
+ if (typeof current === "string" && current.length > 0) active.add(current);
1259
+ for (const target of this.appendedBytesMap.keys()) active.add(target);
1260
+ for (const item of this.queue) if (typeof item.target === "string") active.add(item.target);
1261
+ return active;
1262
+ }
1263
+
1264
+ /** 最近一次清理的报告(同步,供 `/status` 回显;未清理过时是零值)。 */
1265
+ storageReport() {
1266
+ return {
1267
+ ...this.storageState,
1268
+ dir: this.logsDir,
1269
+ splitMode: this.splitMode(),
1270
+ retentionDays: this.retentionDays(),
1271
+ totalMaxBytes: this.totalMaxBytes(),
757
1272
  enabled: this.enabled(),
758
1273
  };
759
1274
  }
760
1275
 
1276
+ /**
1277
+ * 把旧的 `decisions.jsonl` / `.1` **一次性**迁移到 `logs/` 的日期分片。
1278
+ *
1279
+ * 触发条件很窄:`logs/` 里**还没有任何分片**且旧文件存在。满足这两条说明
1280
+ * 用户是从旧版本升上来的第一次启动——迁移后旧文件改名成 `.migrated`
1281
+ * (**保留不删**,用户仍能直接打开核对),并写标记文件记住「做过」。
1282
+ *
1283
+ * 为什么条件是「没有分片」而不是「目录不存在」:上一次迁移可能在 `mkdir`
1284
+ * 之后、写分片之前失败(磁盘满、权限),留下一个**空目录**。按「目录不存在」
1285
+ * 判断的话,那次失败就永久关死了迁移——旧文件还在,却再也不会被搬。空目录
1286
+ * 说明新布局并没有真的开始过,继续迁移才是对的。
1287
+ *
1288
+ * 幂等性靠三条,任何一条成立都不再产生副作用:
1289
+ *
1290
+ * 1. 标记文件存在 → 直接返回 0;
1291
+ * 2. `logs/` 里已有分片(说明新布局已经启用过)→ 写标记、返回 0;
1292
+ * 3. 旧文件不存在 → 写标记、返回 0(下次启动不再重扫)。
1293
+ *
1294
+ * 失败时**不写标记**且旧文件不动:下次启动会重试,读取路径也仍能读到旧
1295
+ * 文件(`collectLines()` 的兜底)。代价是失败前已写入的分片可能重复——
1296
+ * 但迁移失败是极端情况,而「丢历史」比「可能重复」严重得多。
1297
+ *
1298
+ * @returns 迁移的记录条数;0 表示没有可迁移的(已迁移过 / 失败 / 文件不存在)。
1299
+ */
1300
+ async migrateLegacyOnce() {
1301
+ const markerPath = join(this.dir, MIGRATION_MARKER_NAME);
1302
+ if (await this.pathExists(markerPath)) return 0;
1303
+ // 开关关闭时不迁移,也不写标记:用户重新打开后仍会触发一次迁移。
1304
+ if (!this.enabled()) return 0;
1305
+ if (await this.pathExists(this.logsDir)) {
1306
+ const existing = await this.shardFiles();
1307
+ if (existing.length > 0) {
1308
+ await this.writeMarker(markerPath);
1309
+ return 0;
1310
+ }
1311
+ }
1312
+ const legacyPath = join(this.dir, DECISIONS_FILE_NAME);
1313
+ const rotatedPath = join(this.dir, DECISIONS_ROTATED_NAME);
1314
+ const hasLegacy = await this.pathExists(legacyPath);
1315
+ const hasRotated = await this.pathExists(rotatedPath);
1316
+ if (!hasLegacy && !hasRotated) {
1317
+ await this.writeMarker(markerPath);
1318
+ return 0;
1319
+ }
1320
+ /** 旧数据:`.1` 是更早的一代,先读它,保证分片内顺序仍是时间顺序。 */
1321
+ const records = [];
1322
+ for (const path of [hasRotated ? rotatedPath : "", hasLegacy ? legacyPath : ""]) {
1323
+ if (path.length === 0) continue;
1324
+ let raw;
1325
+ try {
1326
+ raw = await readFile(path, "utf8");
1327
+ } catch (error) {
1328
+ this.logger?.warn?.(
1329
+ `dsh-decision-engine: 读取旧决策明细失败(${path}:${error instanceof Error ? error.message : String(error)})`,
1330
+ );
1331
+ continue;
1332
+ }
1333
+ for (const line of raw.split("\n")) {
1334
+ const trimmed = line.trim();
1335
+ if (trimmed.length === 0) continue;
1336
+ try {
1337
+ const parsed = JSON.parse(trimmed);
1338
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) records.push(parsed);
1339
+ } catch {
1340
+ // 坏行跳过:迁移不能因为一行半行就整体失败。
1341
+ }
1342
+ }
1343
+ }
1344
+ if (records.length > 0) {
1345
+ const groups = new Map();
1346
+ for (const record of records) {
1347
+ const compacted = compactRecord(record);
1348
+ let target;
1349
+ try {
1350
+ target = shardFileName({
1351
+ splitMode: "date",
1352
+ separator: this.dateSeparator(),
1353
+ timeZone: this.timeZone(),
1354
+ ts: compacted.ts,
1355
+ });
1356
+ } catch {
1357
+ target = this.mainShardName();
1358
+ }
1359
+ const lines = groups.get(target);
1360
+ if (lines === undefined) groups.set(target, [`${JSON.stringify(compacted)}\n`]);
1361
+ else lines.push(`${JSON.stringify(compacted)}\n`);
1362
+ }
1363
+ try {
1364
+ await mkdir(this.logsDir, { recursive: true });
1365
+ for (const [target, lines] of groups) {
1366
+ await appendFile(join(this.logsDir, target), lines.join(""), "utf8");
1367
+ }
1368
+ } catch (error) {
1369
+ // 不写标记、不改名:下次启动重试,旧文件仍可读。
1370
+ this.logger?.warn?.(
1371
+ `dsh-decision-engine: 迁移旧决策明细失败(${error instanceof Error ? error.message : String(error)})`,
1372
+ );
1373
+ return 0;
1374
+ }
1375
+ }
1376
+ // 改名保留而不是删除:用户仍可打开 `.migrated` 核对迁移前的原始内容。
1377
+ await this.renameLegacyFile(legacyPath);
1378
+ await this.renameLegacyFile(rotatedPath);
1379
+ await this.writeMarker(markerPath);
1380
+ return records.length;
1381
+ }
1382
+
1383
+ /** 把旧明细改名成 `.migrated`(目标已存在时先删旧的);失败只 warn。 */
1384
+ async renameLegacyFile(path) {
1385
+ if (!(await this.pathExists(path))) return;
1386
+ const target = `${path}.migrated`;
1387
+ try {
1388
+ await unlink(target).catch(() => {});
1389
+ await rename(path, target);
1390
+ } catch (error) {
1391
+ this.logger?.warn?.(
1392
+ `dsh-decision-engine: 旧决策明细改名失败(${path}:${error instanceof Error ? error.message : String(error)})`,
1393
+ );
1394
+ }
1395
+ }
1396
+
1397
+ /** 写一次性迁移标记;写失败不算失败(下次会重扫,幂等)。 */
1398
+ async writeMarker(markerPath) {
1399
+ try {
1400
+ await mkdir(this.dir, { recursive: true });
1401
+ await writeFile(markerPath, new Date().toISOString(), "utf8");
1402
+ } catch {
1403
+ /* 标记写不了就下次再来 */
1404
+ }
1405
+ }
1406
+
761
1407
  /**
762
1408
  * 等待队列里的内容全部落盘。
763
1409
  *
@@ -772,3 +1418,16 @@ export class DecisionStore {
772
1418
  return this.written;
773
1419
  }
774
1420
  }
1421
+
1422
+ /** 会话 id 在文件名里的那一段(与 `shardFileName()` 完全同一套规则)。 */
1423
+ function sessionIdSuffix(sessionId) {
1424
+ const id = typeof sessionId === "string" ? sessionId.trim() : "";
1425
+ return sanitizeSegment(id.length > 0 ? id.slice(0, SESSION_ID_SEGMENT_LENGTH) : "unknown", {
1426
+ maxLength: SESSION_ID_SEGMENT_LENGTH,
1427
+ });
1428
+ }
1429
+
1430
+ /** 正则转义(会话 id 段拼进正则前必须转义:`~` 之外的字符都可能出现)。 */
1431
+ function escapeRegExp(text) {
1432
+ return String(text).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1433
+ }