@morlay/session-rdb 0.0.21-alpha.8 → 0.0.21-alpha.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/usage.mjs ADDED
@@ -0,0 +1,152 @@
1
+ //#region src/usage.ts
2
+ const SESSION_USAGE_PATH = "/api/session.usage";
3
+ const ROLLING_DAYS = {
4
+ "7d": 7,
5
+ "30d": 30,
6
+ "90d": 90
7
+ };
8
+ function localDayStart(now) {
9
+ const start = new Date(now);
10
+ start.setHours(0, 0, 0, 0);
11
+ return start;
12
+ }
13
+ /**
14
+ * 范围起点(含),不限时为 undefined。**所有起点都对齐到 host 本地时区的零点**——`day` / `week` 是
15
+ * 今天 / 本周一的零点,`7d` / `30d` / `90d` 是「今天零点往前 N-1 天」(含今天共 N 个自然日)。
16
+ * 对齐的意义:事件级表按毫秒过滤、会话汇总表按本地日过滤,两者因此严格等价。
17
+ * @param range - 语义键。
18
+ * @param now - 当前时刻。
19
+ * @returns 起点毫秒时间戳,或 undefined。
20
+ */
21
+ function resolveUsageSince(range, now) {
22
+ switch (range) {
23
+ case "all": return;
24
+ case "day": return localDayStart(now).getTime();
25
+ case "week": {
26
+ const start = localDayStart(now);
27
+ start.setDate(start.getDate() - (start.getDay() + 6) % 7);
28
+ return start.getTime();
29
+ }
30
+ default: {
31
+ const start = localDayStart(now);
32
+ start.setDate(start.getDate() - (ROLLING_DAYS[range] - 1));
33
+ return start.getTime();
34
+ }
35
+ }
36
+ }
37
+ function parseUsageRange(value) {
38
+ return value === "day" || value === "week" || value === "7d" || value === "30d" || value === "90d" ? value : "all";
39
+ }
40
+ /** 计入活动计数的事件类型(轮次 / 步骤 / 用户输入 / 工具调用)。 */
41
+ const COUNTED_EVENT_TYPES = [
42
+ "turn/start",
43
+ "step/start",
44
+ "user/message",
45
+ "tool/call"
46
+ ];
47
+ /** 按事件类型把计数累加到活动指标上(表里按类型存,读的时候折成四项)。 */
48
+ function addActivityCount(target, type, count) {
49
+ switch (type) {
50
+ case "turn/start":
51
+ target.turns += count;
52
+ return target;
53
+ case "step/start":
54
+ target.steps += count;
55
+ return target;
56
+ case "user/message":
57
+ target.userInputs += count;
58
+ return target;
59
+ case "tool/call":
60
+ target.toolCalls += count;
61
+ return target;
62
+ default: return target;
63
+ }
64
+ }
65
+ /** 毫秒时间戳 → host 本地日(`YYYY-MM-DD`,与 SQL 的 localtime 口径一致)。 */
66
+ function localDayKey(ms) {
67
+ const date = new Date(ms);
68
+ const pad = (value) => String(value).padStart(2, "0");
69
+ return `${String(date.getFullYear())}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
70
+ }
71
+ function emptyTotals() {
72
+ return {
73
+ turns: 0,
74
+ steps: 0,
75
+ userInputs: 0,
76
+ toolCalls: 0,
77
+ inputTokens: 0,
78
+ outputTokens: 0,
79
+ cacheReadTokens: 0,
80
+ reasoningTokens: 0,
81
+ totalTokens: 0
82
+ };
83
+ }
84
+ function addTokenTotals(target, row) {
85
+ target.inputTokens += row.inputTokens;
86
+ target.outputTokens += row.outputTokens;
87
+ target.cacheReadTokens += row.cacheReadTokens;
88
+ target.reasoningTokens += row.reasoningTokens;
89
+ target.totalTokens += row.totalTokens;
90
+ return target;
91
+ }
92
+ function addActivityTotals(target, row) {
93
+ target.turns += row.turns;
94
+ target.steps += row.steps;
95
+ target.userInputs += row.userInputs;
96
+ target.toolCalls += row.toolCalls;
97
+ return target;
98
+ }
99
+ /** 把一行的整套指标累加进目标(后端合并「是否子代理」两组时用)。 */
100
+ function addTotals(target, row) {
101
+ addActivityTotals(target, row);
102
+ addTokenTotals(target, row);
103
+ return target;
104
+ }
105
+ /**
106
+ * 用量统计通道:一次请求回报总览(含 subagent 拆分)、按天 × 模型的桶与按会话的行。
107
+ * 聚合只算被会话引用的事件行——fork 共享行因此只计一次,已删会话留下的孤儿行不计。
108
+ */
109
+ function registerSessionUsage(ctx, persistence) {
110
+ ctx.inject(["webServer", "connection"], (webCtx) => {
111
+ const webServer = webCtx.webServer;
112
+ const connection = webCtx.get("connection");
113
+ return webCtx.effect(() => webServer.register({
114
+ kind: "exact",
115
+ path: SESSION_USAGE_PATH,
116
+ handler: async (req, res) => {
117
+ const rejection = connection.requestRejection(req);
118
+ if (rejection !== void 0) {
119
+ res.writeHead(rejection);
120
+ res.end(rejection === 401 ? "unauthorized" : "forbidden");
121
+ return;
122
+ }
123
+ if (req.method !== "POST") {
124
+ res.writeHead(405, { "content-type": "application/json" });
125
+ res.end(JSON.stringify({ error: "method not allowed" }));
126
+ return;
127
+ }
128
+ const chunks = [];
129
+ for await (const chunk of req) chunks.push(chunk);
130
+ const raw = Buffer.concat(chunks).toString("utf8");
131
+ let envelope = {};
132
+ if (raw !== "") try {
133
+ envelope = JSON.parse(raw);
134
+ } catch {
135
+ res.writeHead(400, { "content-type": "application/json" });
136
+ res.end(JSON.stringify({ error: "request body is not JSON" }));
137
+ return;
138
+ }
139
+ try {
140
+ const report = await persistence.usageReport(resolveUsageSince(parseUsageRange(envelope.range), Date.now()));
141
+ res.writeHead(200, { "content-type": "application/json" });
142
+ res.end(JSON.stringify(report));
143
+ } catch (error) {
144
+ res.writeHead(500, { "content-type": "application/json" });
145
+ res.end(JSON.stringify({ error: error instanceof Error ? error.message : "usage report failed" }));
146
+ }
147
+ }
148
+ }), `session-rdb: ${SESSION_USAGE_PATH} route`);
149
+ });
150
+ }
151
+ //#endregion
152
+ export { COUNTED_EVENT_TYPES, SESSION_USAGE_PATH, addActivityCount, addActivityTotals, addTokenTotals, addTotals, emptyTotals, localDayKey, registerSessionUsage, resolveUsageSince };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morlay/session-rdb",
3
- "version": "0.0.21-alpha.8",
3
+ "version": "0.0.21-alpha.9",
4
4
  "description": "RDB durable session backend for DeepSeek Harness: implements both session-persistence and session-branch (rewind / retry / fork) providers.",
5
5
  "keywords": [
6
6
  "branch",
@@ -21,7 +21,6 @@
21
21
  "dist",
22
22
  "src",
23
23
  "drizzle",
24
- "cordis.patch.yml",
25
24
  "!**/__tests__"
26
25
  ],
27
26
  "type": "module",
@@ -32,49 +31,45 @@
32
31
  "./export": "./dist/export.mjs",
33
32
  "./gc": "./dist/gc.mjs",
34
33
  "./import": "./dist/import.mjs",
35
- "./invariant": "./dist/invariant.mjs",
36
34
  "./storage": "./dist/storage.mjs",
37
35
  "./testing": "./dist/testing.mjs",
38
36
  "./usage": "./dist/usage.mjs",
37
+ "./invariant": "./dist/invariant.mjs",
39
38
  "./package.json": "./package.json",
40
- "./cordis.patch.yml": "./cordis.patch.yml",
41
39
  "./locale/*.json": "./locale/*.json"
42
40
  },
43
41
  "dependencies": {
44
- "@morlay/session-branch": "0.0.10-alpha.8",
42
+ "@morlay/session-branch": "0.0.10-alpha.9",
45
43
  "drizzle-orm": "^1.0.0-rc.5-ab785fc",
46
44
  "fflate": "^0.8.2",
47
45
  "pg": "^8.23.0"
48
46
  },
49
47
  "devDependencies": {
50
- "@deepseek-ai/dsh-compaction": "0.1.7-rc.1",
51
- "@deepseek-ai/dsh-session-projection": "0.1.7-rc.1",
52
- "@deepseek-ai/dsh-session-projection-cache": "0.1.7-rc.1",
53
- "@deepseek-ai/dsh-session-turn-outline": "0.1.7-rc.1",
54
- "@deepseek-ai/dsh-storage": "0.1.7-rc.1",
55
- "@deepseek-ai/dsh-storage-domain": "0.1.7-rc.1",
56
- "@deepseek-ai/dsh-token-meter": "0.1.7-rc.1",
57
- "@deepseek-ai/dsh-workspace": "0.1.7-rc.1",
48
+ "@deepseek-ai/cosmokit": "1.8.5",
49
+ "@deepseek-ai/dsh-compaction": "0.1.7-rc.2",
50
+ "@deepseek-ai/dsh-session-projection": "0.1.7-rc.2",
51
+ "@deepseek-ai/dsh-session-projection-cache": "0.1.7-rc.2",
52
+ "@deepseek-ai/dsh-session-turn-outline": "0.1.7-rc.2",
53
+ "@deepseek-ai/dsh-storage": "0.1.7-rc.2",
54
+ "@deepseek-ai/dsh-storage-domain": "0.1.7-rc.2",
55
+ "@deepseek-ai/dsh-token-meter": "0.1.7-rc.2",
56
+ "@deepseek-ai/dsh-workspace": "0.1.7-rc.2",
58
57
  "@types/pg": "^8.23.1",
59
58
  "drizzle-kit": "1.0.0-rc.5-ab785fc"
60
59
  },
61
60
  "peerDependencies": {
62
- "@deepseek-ai/cordis": "4.0.4",
63
- "@deepseek-ai/dsh-invariants": "0.1.7-rc.1",
64
- "@deepseek-ai/dsh-llm": "0.1.7-rc.1",
65
- "@deepseek-ai/dsh-scope": "0.1.7-rc.1",
66
- "@deepseek-ai/dsh-session": "0.1.7-rc.1",
67
- "@deepseek-ai/dsh-session-format": "0.1.7-rc.1",
68
- "@deepseek-ai/dsh-session-format-catalog": "0.1.7-rc.1",
69
- "@deepseek-ai/dsh-session-persistence": "0.1.7-rc.1",
70
- "@deepseek-ai/dsh-session-query": "0.1.7-rc.1",
71
- "@deepseek-ai/dsh-storage": "0.1.7-rc.1",
72
- "@deepseek-ai/schemastery": "3.18.4"
73
- },
74
- "dsh": {
75
- "bundle": {
76
- "patch": "./cordis.patch.yml"
77
- }
61
+ "@deepseek-ai/cordis": "^4.0.4",
62
+ "@deepseek-ai/cosmokit": "^1.8.5",
63
+ "@deepseek-ai/dsh-invariants": "^0.1.7-rc.2",
64
+ "@deepseek-ai/dsh-llm": "^0.1.7-rc.2",
65
+ "@deepseek-ai/dsh-scope": "^0.1.7-rc.2",
66
+ "@deepseek-ai/dsh-session": "^0.1.7-rc.2",
67
+ "@deepseek-ai/dsh-session-format": "^0.1.7-rc.2",
68
+ "@deepseek-ai/dsh-session-format-catalog": "^0.1.7-rc.2",
69
+ "@deepseek-ai/dsh-session-persistence": "^0.1.7-rc.2",
70
+ "@deepseek-ai/dsh-session-query": "^0.1.7-rc.2",
71
+ "@deepseek-ai/dsh-storage": "^0.1.7-rc.2",
72
+ "@deepseek-ai/schemastery": "^3.18.4"
78
73
  },
79
74
  "inlinedDependencies": {
80
75
  "@jridgewell/sourcemap-codec": "1.6.0",
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Context } from "@deepseek-ai/cordis";
2
+ import type { Volatile } from "@deepseek-ai/cosmokit";
2
3
  import z from "@deepseek-ai/schemastery";
3
4
  import { randomUUID } from "node:crypto";
4
5
  import { Pool } from "pg";
@@ -129,25 +130,21 @@ export interface ProjectionCacheOptions {
129
130
  export type Config =
130
131
  | {
131
132
  type: "sqlite";
132
-
133
133
  path: string;
134
-
135
- journalMode?: JournalMode;
136
-
137
- busyTimeout?: number;
138
-
139
- projectionCache?: ProjectionCacheOptions;
134
+ journalMode?: JournalMode | undefined;
135
+ busyTimeout?: number | undefined;
136
+ projectionCache?: ProjectionCacheOptions | undefined;
140
137
  }
141
138
  | {
142
139
  type: "postgres";
143
-
144
140
  connectionString: string;
145
-
146
- schema?: string;
147
-
148
- projectionCache?: ProjectionCacheOptions;
141
+ schema?: string | undefined;
142
+ projectionCache?: ProjectionCacheOptions | undefined;
149
143
  };
150
144
 
145
+ /** schema 解析之后的形状:整段 Config 是 **volatile 稳定引用**(页面可编辑),读它要过 `.get()`。 */
146
+ export type ResolvedConfig = Volatile<Config>;
147
+
151
148
  export type SessionDeletionErrorCode =
152
149
  | "SESSION_NOT_FOUND"
153
150
  | "SESSION_NOT_ARCHIVED"
@@ -491,40 +488,111 @@ class RdbSessionHandle implements SessionHandle {
491
488
  static readonly LIVE_WRITE_BATCH_MAX_DELAY_MS = 200;
492
489
  }
493
490
 
491
+ /**
492
+ * 本地化说明:`description()` 的类型签名只声明 `string`,而 meta 本身接受 `Dict<string>`
493
+ * (`vendor/schemastery/src/index.ts` 的 `mergeDesc` 就是按字典合并的),所以这里只做一次类型放行。
494
+ */
495
+ const localized = (text: { zh: string; en: string }): string => text as unknown as string;
496
+
497
+ /** 投影 checkpoint 的写回节流:两个分支共用。 */
498
+ const projectionCacheSchema = z
499
+ .object({
500
+ writeEveryEvents: z
501
+ .natural()
502
+ .min(1)
503
+ .default(DEFAULT_PROJECTION_WRITE_EVERY_EVENTS)
504
+ .description(
505
+ localized({
506
+ zh: "每累计这么多事件写回一次投影 checkpoint。",
507
+ en: "Write the projection checkpoint back after this many events.",
508
+ }),
509
+ ),
510
+ writeIntervalMs: z
511
+ .natural()
512
+ .min(1)
513
+ .default(DEFAULT_PROJECTION_WRITE_INTERVAL_MS)
514
+ .description(
515
+ localized({
516
+ zh: "距离上次写回超过这个间隔(毫秒)时也写一次。",
517
+ en: "Also write back when this many ms passed since the last one.",
518
+ }),
519
+ ),
520
+ })
521
+ .default({
522
+ writeEveryEvents: DEFAULT_PROJECTION_WRITE_EVERY_EVENTS,
523
+ writeIntervalMs: DEFAULT_PROJECTION_WRITE_INTERVAL_MS,
524
+ });
525
+
494
526
  export class SessionPersistenceRdb extends SessionPersistence {
495
527
  static inject = ["sessions"];
496
528
 
497
- static Config: z<Config> = z.union([
498
- z.object({
499
- type: z.const("sqlite"),
500
- path: z.string().required(),
501
- journalMode: z.union(["wal", "delete", "truncate", "persist"] as const).default("wal"),
502
- busyTimeout: z.number().step(1).min(0).default(DEFAULT_BUSY_TIMEOUT_MS),
503
- projectionCache: z
504
- .object({
505
- writeEveryEvents: z.natural().min(1).default(DEFAULT_PROJECTION_WRITE_EVERY_EVENTS),
506
- writeIntervalMs: z.natural().min(1).default(DEFAULT_PROJECTION_WRITE_INTERVAL_MS),
507
- })
508
- .default({
509
- writeEveryEvents: DEFAULT_PROJECTION_WRITE_EVERY_EVENTS,
510
- writeIntervalMs: DEFAULT_PROJECTION_WRITE_INTERVAL_MS,
511
- }),
512
- }),
513
- z.object({
514
- type: z.const("postgres"),
515
- connectionString: z.string().required(),
516
- schema: z.string().default("public"),
517
- projectionCache: z
518
- .object({
519
- writeEveryEvents: z.natural().min(1).default(DEFAULT_PROJECTION_WRITE_EVERY_EVENTS),
520
- writeIntervalMs: z.natural().min(1).default(DEFAULT_PROJECTION_WRITE_INTERVAL_MS),
521
- })
522
- .default({
523
- writeEveryEvents: DEFAULT_PROJECTION_WRITE_EVERY_EVENTS,
524
- writeIntervalMs: DEFAULT_PROJECTION_WRITE_INTERVAL_MS,
525
- }),
526
- }),
527
- ]);
529
+ // 显式声明「输入 → 解析后」的形状:整段是 volatile,读它要过 `.get()`。union + volatile 的推断形状与手写类型
530
+ // 在可选性 / 索引签名上不完全对齐,所以这里断言一次,类型面由此固定。
531
+ static Config = z
532
+ .union([
533
+ z.object({
534
+ type: z.const("sqlite"),
535
+ path: z
536
+ .string()
537
+ .required()
538
+ .description(
539
+ localized({
540
+ zh: "SQLite 数据库文件路径;`:memory:` 是内存库(进程结束即丢)。",
541
+ en: "SQLite database file path; `:memory:` keeps it in memory and loses it with the process.",
542
+ }),
543
+ ),
544
+ journalMode: z
545
+ .union(["wal", "delete", "truncate", "persist"] as const)
546
+ .default("wal")
547
+ .description(
548
+ localized({
549
+ zh: "SQLite 日志模式;默认 wal(并发读最好)。",
550
+ en: "SQLite journal mode; `wal` by default (best concurrent reads).",
551
+ }),
552
+ ),
553
+ busyTimeout: z
554
+ .number()
555
+ .step(1)
556
+ .min(0)
557
+ .default(DEFAULT_BUSY_TIMEOUT_MS)
558
+ .description(
559
+ localized({
560
+ zh: "写锁冲突时的忙等超时(毫秒)。",
561
+ en: "Busy timeout in ms while a write lock is contended.",
562
+ }),
563
+ ),
564
+ projectionCache: projectionCacheSchema,
565
+ }),
566
+ z.object({
567
+ type: z.const("postgres"),
568
+ connectionString: z
569
+ .string()
570
+ .required()
571
+ .description(
572
+ localized({
573
+ zh: "PostgreSQL 连接串(含库名与凭证)。",
574
+ en: "PostgreSQL connection string (database and credentials).",
575
+ }),
576
+ ),
577
+ schema: z
578
+ .string()
579
+ .default("public")
580
+ .description(
581
+ localized({
582
+ zh: "库内的 schema 名;默认 public。",
583
+ en: "Schema name inside the database; `public` by default.",
584
+ }),
585
+ ),
586
+ projectionCache: projectionCacheSchema,
587
+ }),
588
+ ])
589
+ .description(
590
+ localized({
591
+ zh: "会话持久化后端:改这里的连接参数会在这一行重挂时生效(Loader 会重装被改的行)。",
592
+ en: "Session persistence backend: edits apply when this row is remounted (the Loader re-installs changed rows).",
593
+ }),
594
+ )
595
+ .volatile() as unknown as z<Config, ResolvedConfig>;
528
596
 
529
597
  override readonly name = "session-rdb";
530
598
 
@@ -541,9 +609,12 @@ export class SessionPersistenceRdb extends SessionPersistence {
541
609
  private readonly liveBuffers = new Map<SessionId, SessionEvent[]>();
542
610
  private readonly liveReady = new Map<SessionId, Promise<void>>();
543
611
 
612
+ /** 本次装配用的配置快照(整段 Config 是 volatile,重挂会给新的)。 */
613
+ readonly config: Config;
614
+
544
615
  constructor(
545
616
  ctx: Context,
546
- public config: Config,
617
+ config: ResolvedConfig,
547
618
 
548
619
  injectedBackend?: Backend,
549
620
  ) {
@@ -551,7 +622,12 @@ export class SessionPersistenceRdb extends SessionPersistence {
551
622
 
552
623
  // 配置就是这一行的 config(cordis.patch.yml / profile patch,或设置页改它);上游 0.1.7 的 settings
553
624
  // 不再提供 namespace section 覆盖,旧 `settings.yaml` 的 `session-rdb` 段由上游一次性导进同 id 的行。
554
- this.backend = injectedBackend ?? createBackend(config);
625
+ //
626
+ // 整段 Config 标了 volatile(页面可编辑),而生效靠 **Loader 重挂这一行**(settings 写完经
627
+ // `reconcileProfilePatches` 让 Loader 重装受影响的 entry):重挂会构造新实例、拿到新快照,所以这里取一次
628
+ // 就够,运行期不必反复读引用。
629
+ this.config = config.get();
630
+ this.backend = injectedBackend ?? createBackend(this.config);
555
631
  this.ready = this.init();
556
632
  this.installLiveRouting(ctx);
557
633
 
package/cordis.patch.yml DELETED
@@ -1,13 +0,0 @@
1
- # @morlay/session-rdb bundle patch.
2
- #
3
- # 在 profile 的 dsh.profile.bundles 中列出本包后,本 patch 在 base 层之后
4
- # 生效,插入 RDB 持久化后端插件行(默认 SQLite,库文件放
5
- # $DSH_HOME/sessions/sessions.sqlite)。用户可在 profile 的 cordis.patch.yml 中
6
- # 按 id 覆盖 config(例如切到 PostgreSQL)。
7
-
8
- - insert:
9
- - id: session-rdb
10
- name: "@morlay/session-rdb"
11
- config:
12
- type: sqlite
13
- path: !!js dshHomePath('sessions', 'sessions.sqlite')