@morlay/session-rdb 0.0.21-alpha.2 → 0.0.21-alpha.4

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/testing.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { a as truncateLiveSession } from "./branch-5JzX9rUq.mjs";
1
+ import { a as truncateLiveSession } from "./branch-Dlmbkfjj.mjs";
2
2
  import { SessionAlreadyExistsError, SessionAlreadyOwnedError, SessionFormatUnsupportedError, SessionHandleClosedError, SessionPersistenceNotFoundError, SessionReadOnlyError } from "@deepseek-ai/dsh-session-persistence";
3
3
  import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq } from "@deepseek-ai/dsh-session";
4
4
  import { Context } from "@deepseek-ai/cordis";
@@ -23,7 +23,7 @@ var EmptySettings = class extends SettingsProvider {
23
23
  }
24
24
  };
25
25
  //#endregion
26
- //#region ../../../node_modules/.pnpm/vitest@5.0.1_@opentelemetry+api@1.9.1_@types+node@26.6.1_happy-dom@20.14.5_jsdom@30.1.0_a01b6ab7ee56d44cea1102049009c3c3/node_modules/vitest/dist/chunks/tinyrainbow.Ht9iggcq.js
26
+ //#region ../../../node_modules/.pnpm/vitest@5.0.1_@opentelemetry+api@1.9.1_@types+node@26.6.2_happy-dom@20.14.5_jsdom@30.1.0_35ad368e4c4a4b8f279ac875fcf2bfae/node_modules/vitest/dist/chunks/tinyrainbow.Ht9iggcq.js
27
27
  var b$1 = {
28
28
  reset: [0, 0],
29
29
  bold: [
@@ -106,7 +106,7 @@ function C({ force: e } = {}) {
106
106
  }
107
107
  var y$1 = C();
108
108
  //#endregion
109
- //#region ../../../node_modules/.pnpm/vitest@5.0.1_@opentelemetry+api@1.9.1_@types+node@26.6.1_happy-dom@20.14.5_jsdom@30.1.0_a01b6ab7ee56d44cea1102049009c3c3/node_modules/vitest/dist/chunks/index.M2dsQ_UQ.js
109
+ //#region ../../../node_modules/.pnpm/vitest@5.0.1_@opentelemetry+api@1.9.1_@types+node@26.6.2_happy-dom@20.14.5_jsdom@30.1.0_35ad368e4c4a4b8f279ac875fcf2bfae/node_modules/vitest/dist/chunks/index.M2dsQ_UQ.js
110
110
  function _mergeNamespaces$1(n, m) {
111
111
  m.forEach(function(e) {
112
112
  e && typeof e !== "string" && !Array.isArray(e) && Object.keys(e).forEach(function(k) {
@@ -1005,7 +1005,7 @@ const plugins = {
1005
1005
  Error: ErrorPlugin
1006
1006
  };
1007
1007
  //#endregion
1008
- //#region ../../../node_modules/.pnpm/vitest@5.0.1_@opentelemetry+api@1.9.1_@types+node@26.6.1_happy-dom@20.14.5_jsdom@30.1.0_a01b6ab7ee56d44cea1102049009c3c3/node_modules/vitest/dist/chunks/display.pkpxlVcY.js
1008
+ //#region ../../../node_modules/.pnpm/vitest@5.0.1_@opentelemetry+api@1.9.1_@types+node@26.6.2_happy-dom@20.14.5_jsdom@30.1.0_35ad368e4c4a4b8f279ac875fcf2bfae/node_modules/vitest/dist/chunks/display.pkpxlVcY.js
1009
1009
  const { AsymmetricMatcher: AsymmetricMatcher$3, DOMCollection: DOMCollection$2, DOMElement: DOMElement$2, Immutable: Immutable$2, ReactElement: ReactElement$2, ReactTestComponent: ReactTestComponent$2 } = plugins;
1010
1010
  const PLUGINS$2 = [
1011
1011
  ReactTestComponent$2,
@@ -1466,7 +1466,7 @@ const isAbsolute = function(p) {
1466
1466
  return _IS_ABSOLUTE_RE.test(p);
1467
1467
  };
1468
1468
  //#endregion
1469
- //#region ../../../node_modules/.pnpm/vitest@5.0.1_@opentelemetry+api@1.9.1_@types+node@26.6.1_happy-dom@20.14.5_jsdom@30.1.0_a01b6ab7ee56d44cea1102049009c3c3/node_modules/vitest/dist/chunks/source-map.BH0bbrs9.js
1469
+ //#region ../../../node_modules/.pnpm/vitest@5.0.1_@opentelemetry+api@1.9.1_@types+node@26.6.2_happy-dom@20.14.5_jsdom@30.1.0_35ad368e4c4a4b8f279ac875fcf2bfae/node_modules/vitest/dist/chunks/source-map.BH0bbrs9.js
1470
1470
  var commonjsGlobal = typeof globalThis !== "undefined" ? globalThis : typeof window !== "undefined" ? window : typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : {};
1471
1471
  function getDefaultExportFromCjs(x) {
1472
1472
  return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, "default") ? x["default"] : x;
@@ -2249,7 +2249,7 @@ function getOriginalPosition(map, needle) {
2249
2249
  return result;
2250
2250
  }
2251
2251
  //#endregion
2252
- //#region ../../../node_modules/.pnpm/vitest@5.0.1_@opentelemetry+api@1.9.1_@types+node@26.6.1_happy-dom@20.14.5_jsdom@30.1.0_a01b6ab7ee56d44cea1102049009c3c3/node_modules/vitest/dist/task-utils.js
2252
+ //#region ../../../node_modules/.pnpm/vitest@5.0.1_@opentelemetry+api@1.9.1_@types+node@26.6.2_happy-dom@20.14.5_jsdom@30.1.0_35ad368e4c4a4b8f279ac875fcf2bfae/node_modules/vitest/dist/task-utils.js
2253
2253
  /* @__NO_SIDE_EFFECTS__ */
2254
2254
  function getNames(task) {
2255
2255
  const names = [task.name];
@@ -2266,7 +2266,7 @@ function createTaskName(names, separator = " > ") {
2266
2266
  return names.filter((name) => name !== void 0).join(separator);
2267
2267
  }
2268
2268
  //#endregion
2269
- //#region ../../../node_modules/.pnpm/vitest@5.0.1_@opentelemetry+api@1.9.1_@types+node@26.6.1_happy-dom@20.14.5_jsdom@30.1.0_a01b6ab7ee56d44cea1102049009c3c3/node_modules/vitest/dist/chunks/run.C5UmxDPh.js
2269
+ //#region ../../../node_modules/.pnpm/vitest@5.0.1_@opentelemetry+api@1.9.1_@types+node@26.6.2_happy-dom@20.14.5_jsdom@30.1.0_35ad368e4c4a4b8f279ac875fcf2bfae/node_modules/vitest/dist/chunks/run.C5UmxDPh.js
2270
2270
  /**
2271
2271
  * Diff Match and Patch
2272
2272
  * Copyright 2018 The diff-match-patch Authors.
@@ -4917,7 +4917,7 @@ async function finishSendTasksUpdate(runner) {
4917
4917
  await Promise.all(pendingTasksUpdates);
4918
4918
  }
4919
4919
  //#endregion
4920
- //#region ../../../node_modules/.pnpm/vitest@5.0.1_@opentelemetry+api@1.9.1_@types+node@26.6.1_happy-dom@20.14.5_jsdom@30.1.0_a01b6ab7ee56d44cea1102049009c3c3/node_modules/vitest/dist/chunks/utils.DYj33du9.js
4920
+ //#region ../../../node_modules/.pnpm/vitest@5.0.1_@opentelemetry+api@1.9.1_@types+node@26.6.2_happy-dom@20.14.5_jsdom@30.1.0_35ad368e4c4a4b8f279ac875fcf2bfae/node_modules/vitest/dist/chunks/utils.DYj33du9.js
4921
4921
  const NAME_WORKER_STATE = "__vitest_worker__";
4922
4922
  function getWorkerState() {
4923
4923
  const workerState = globalThis[NAME_WORKER_STATE];
@@ -4943,7 +4943,7 @@ function resetModules(modules, resetMocks = false) {
4943
4943
  });
4944
4944
  }
4945
4945
  //#endregion
4946
- //#region ../../../node_modules/.pnpm/vitest@5.0.1_@opentelemetry+api@1.9.1_@types+node@26.6.1_happy-dom@20.14.5_jsdom@30.1.0_a01b6ab7ee56d44cea1102049009c3c3/node_modules/vitest/dist/chunks/spy.DQ0ZsPbi.js
4946
+ //#region ../../../node_modules/.pnpm/vitest@5.0.1_@opentelemetry+api@1.9.1_@types+node@26.6.2_happy-dom@20.14.5_jsdom@30.1.0_35ad368e4c4a4b8f279ac875fcf2bfae/node_modules/vitest/dist/chunks/spy.DQ0ZsPbi.js
4947
4947
  function isMockFunction(fn) {
4948
4948
  return typeof fn === "function" && "_isMockFunction" in fn && fn._isMockFunction === true;
4949
4949
  }
@@ -8339,7 +8339,7 @@ function use(fn) {
8339
8339
  }
8340
8340
  __name(use, "use");
8341
8341
  //#endregion
8342
- //#region ../../../node_modules/.pnpm/vitest@5.0.1_@opentelemetry+api@1.9.1_@types+node@26.6.1_happy-dom@20.14.5_jsdom@30.1.0_a01b6ab7ee56d44cea1102049009c3c3/node_modules/vitest/dist/chunks/plugins.Cigb0uSy.js
8342
+ //#region ../../../node_modules/.pnpm/vitest@5.0.1_@opentelemetry+api@1.9.1_@types+node@26.6.2_happy-dom@20.14.5_jsdom@30.1.0_35ad368e4c4a4b8f279ac875fcf2bfae/node_modules/vitest/dist/chunks/plugins.Cigb0uSy.js
8343
8343
  const serialize$1 = (val, config, indentation, depth, refs, printer) => {
8344
8344
  const name = val.getMockName();
8345
8345
  const nameString = name === "vi.fn()" ? "" : ` ${name}`;
@@ -8372,7 +8372,7 @@ function getSerializers() {
8372
8372
  return PLUGINS;
8373
8373
  }
8374
8374
  //#endregion
8375
- //#region ../../../node_modules/.pnpm/vitest@5.0.1_@opentelemetry+api@1.9.1_@types+node@26.6.1_happy-dom@20.14.5_jsdom@30.1.0_a01b6ab7ee56d44cea1102049009c3c3/node_modules/vitest/dist/chunks/offset.Dy-5Fdfn.js
8375
+ //#region ../../../node_modules/.pnpm/vitest@5.0.1_@opentelemetry+api@1.9.1_@types+node@26.6.2_happy-dom@20.14.5_jsdom@30.1.0_35ad368e4c4a4b8f279ac875fcf2bfae/node_modules/vitest/dist/chunks/offset.Dy-5Fdfn.js
8376
8376
  const lineSplitRE = /\r?\n/;
8377
8377
  function positionToOffset(source, lineNumber, columnNumber) {
8378
8378
  const lines = source.split(lineSplitRE);
@@ -1,6 +1,6 @@
1
1
  CREATE TABLE "t_event_usage" (
2
2
  "f_event_id" text PRIMARY KEY NOT NULL,
3
- "f_created_at" integer NOT NULL,
3
+ "f_created_at" bigint NOT NULL,
4
4
  "f_provider" text,
5
5
  "f_model" text,
6
6
  "f_input_tokens" integer DEFAULT 0 NOT NULL,
@@ -1,6 +1,6 @@
1
1
  CREATE TABLE `t_event_usage` (
2
2
  `f_event_id` text PRIMARY KEY NOT NULL,
3
- `f_created_at` integer NOT NULL,
3
+ `f_created_at` bigint NOT NULL,
4
4
  `f_provider` text,
5
5
  `f_model` text,
6
6
  `f_input_tokens` integer DEFAULT 0 NOT NULL,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morlay/session-rdb",
3
- "version": "0.0.21-alpha.2",
3
+ "version": "0.0.21-alpha.4",
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",
@@ -39,35 +39,35 @@
39
39
  "./cordis.patch.yml": "./cordis.patch.yml"
40
40
  },
41
41
  "dependencies": {
42
- "@morlay/session-branch": "^0.0.10-alpha.2",
42
+ "@morlay/session-branch": "0.0.10-alpha.4",
43
43
  "drizzle-orm": "^1.0.0-rc.5-ab785fc",
44
44
  "fflate": "^0.8.2",
45
45
  "pg": "^8.23.0"
46
46
  },
47
47
  "devDependencies": {
48
- "@deepseek-ai/dsh-session-projection": "^0.1.6-alpha.2",
49
- "@deepseek-ai/dsh-session-projection-cache": "^0.1.6-alpha.2",
50
- "@deepseek-ai/dsh-session-turn-outline": "^0.1.6-alpha.2",
51
- "@deepseek-ai/dsh-storage": "^0.1.6-alpha.2",
52
- "@deepseek-ai/dsh-storage-domain": "^0.1.6-alpha.2",
53
- "@deepseek-ai/dsh-token-meter": "^0.1.6-alpha.2",
54
- "@deepseek-ai/dsh-workspace": "^0.1.6-alpha.2",
48
+ "@deepseek-ai/dsh-session-projection": "0.1.6-alpha.2",
49
+ "@deepseek-ai/dsh-session-projection-cache": "0.1.6-alpha.2",
50
+ "@deepseek-ai/dsh-session-turn-outline": "0.1.6-alpha.2",
51
+ "@deepseek-ai/dsh-storage": "0.1.6-alpha.2",
52
+ "@deepseek-ai/dsh-storage-domain": "0.1.6-alpha.2",
53
+ "@deepseek-ai/dsh-token-meter": "0.1.6-alpha.2",
54
+ "@deepseek-ai/dsh-workspace": "0.1.6-alpha.2",
55
55
  "@types/pg": "^8.23.1",
56
56
  "drizzle-kit": "1.0.0-rc.5-ab785fc"
57
57
  },
58
58
  "peerDependencies": {
59
- "@deepseek-ai/cordis": "^4.0.2",
60
- "@deepseek-ai/dsh-invariants": "^0.1.6-alpha.2",
61
- "@deepseek-ai/dsh-llm": "^0.1.6-alpha.2",
62
- "@deepseek-ai/dsh-scope": "^0.1.6-alpha.2",
63
- "@deepseek-ai/dsh-session": "^0.1.6-alpha.2",
64
- "@deepseek-ai/dsh-session-format": "^0.1.6-alpha.2",
65
- "@deepseek-ai/dsh-session-format-catalog": "^0.1.6-alpha.2",
66
- "@deepseek-ai/dsh-session-persistence": "^0.1.6-alpha.2",
67
- "@deepseek-ai/dsh-session-query": "^0.1.6-alpha.2",
68
- "@deepseek-ai/dsh-settings": "^0.1.6-alpha.2",
69
- "@deepseek-ai/dsh-storage": "^0.1.6-alpha.2",
70
- "@deepseek-ai/schemastery": "^3.18.2"
59
+ "@deepseek-ai/cordis": "4.0.2",
60
+ "@deepseek-ai/dsh-invariants": "0.1.6-alpha.2",
61
+ "@deepseek-ai/dsh-llm": "0.1.6-alpha.2",
62
+ "@deepseek-ai/dsh-scope": "0.1.6-alpha.2",
63
+ "@deepseek-ai/dsh-session": "0.1.6-alpha.2",
64
+ "@deepseek-ai/dsh-session-format": "0.1.6-alpha.2",
65
+ "@deepseek-ai/dsh-session-format-catalog": "0.1.6-alpha.2",
66
+ "@deepseek-ai/dsh-session-persistence": "0.1.6-alpha.2",
67
+ "@deepseek-ai/dsh-session-query": "0.1.6-alpha.2",
68
+ "@deepseek-ai/dsh-settings": "0.1.6-alpha.2",
69
+ "@deepseek-ai/dsh-storage": "0.1.6-alpha.2",
70
+ "@deepseek-ai/schemastery": "3.18.2"
71
71
  },
72
72
  "dsh": {
73
73
  "bundle": {
package/src/backend.ts CHANGED
@@ -97,6 +97,13 @@ export interface BackendTx {
97
97
  deleteSessions(ids: SessionId[]): Promise<number>;
98
98
  }
99
99
 
100
+ /** 活动计数的一个桶:本地日 + 事件类型 + 计数。 */
101
+ export interface EventCountBucket {
102
+ day: string;
103
+ type: string;
104
+ count: number;
105
+ }
106
+
100
107
  export interface Backend {
101
108
  readonly kind: "sqlite" | "postgres";
102
109
 
@@ -129,11 +136,29 @@ export interface Backend {
129
136
  listOrphanSubagentSessions(): Promise<SessionId[]>;
130
137
 
131
138
  /**
132
- * 用量统计的原始聚合:按天×模型×subagent 的桶与按会话的行(事件行去重、排除孤儿)。
139
+ * 用量统计的原始聚合:token 用量读 `t_event_usage`、活动计数读派生表 `t_event_counts`,
140
+ * 加按天×模型的桶与按会话的行(事件行去重、排除孤儿)。
133
141
  * @param sinceMs - 只算该时刻(含)之后的事件行;省略即全量。
134
142
  */
135
143
  usageReport(sinceMs?: number): Promise<UsageAggregate>;
136
144
 
145
+ /**
146
+ * 活动计数**旁路累加**(派生表 `t_event_counts`,不参与写事务、失败可丢——表可销毁重建):
147
+ * 每个 (会话, 本地日, 事件类型) 记一行计数。
148
+ * @param id - 会话 id。
149
+ * @param buckets - 本批要累加的 (day, type, count)。
150
+ */
151
+ incrementEventCounts(id: SessionId, buckets: readonly EventCountBucket[]): Promise<void>;
152
+
153
+ /**
154
+ * 按会话从事件表重算活动计数(rewind / fork 之后调用):先删该会话的行再重算。
155
+ * @param id - 会话 id。
156
+ */
157
+ rebuildEventCounts(id: SessionId): Promise<void>;
158
+
159
+ /** 删掉一个会话的活动计数行(会话删除时)。 */
160
+ deleteEventCounts(id: SessionId): Promise<void>;
161
+
137
162
  /** 回收空间与统计(含 VACUUM);不得在事务内执行。 */
138
163
  vacuum(): Promise<void>;
139
164
 
package/src/branch.ts CHANGED
@@ -11,7 +11,6 @@ import {
11
11
  SessionBranch,
12
12
  SessionBranchError,
13
13
  balanceRewindPrefix,
14
- buildTimeline,
15
14
  rewindKeepLength,
16
15
  type BranchAnchorMode,
17
16
  type BranchBoundary,
@@ -295,6 +294,8 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
295
294
  } finally {
296
295
  await handle.close();
297
296
  }
297
+ // 子会话继承了前缀事件:活动计数按子会话重算(派生表,best-effort)。
298
+ await this.persistence.rebuildEventCounts(childId);
298
299
  return childId;
299
300
  }
300
301
 
@@ -425,6 +426,9 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
425
426
  });
426
427
  }
427
428
 
429
+ // 截断后该会话的事件集合变了:活动计数跟着重算(派生表,best-effort)。
430
+ await this.persistence.rebuildEventCounts(id);
431
+
428
432
  return { header: rowToMeta(row), revision: (await internals.readStoredRevision(id))! };
429
433
  }
430
434
 
@@ -596,18 +600,6 @@ export class SessionBranchRdb extends SessionBranch {
596
600
  ): Promise<SessionPersistenceSnapshot> {
597
601
  return this.provider.rewind(id, toBoundary, signal);
598
602
  }
599
-
600
- async timeline(sessionId: SessionId, signal?: AbortSignal) {
601
- const persistence = this.ctx.sessionPersistence as SessionPersistenceRdb;
602
- const snapshots = await persistence.listSnapshots(signal);
603
-
604
- const readOwnEvents = async (id: SessionId, fromSeq: number, s?: AbortSignal) => {
605
- const live = this.ctx.sessions.get(id);
606
- if (live !== undefined) return live.snapshotEvents().slice(fromSeq);
607
- return (await persistence.internals().readFrom(id, fromSeq, s)).events;
608
- };
609
- return buildTimeline(snapshots, readOwnEvents, sessionId, signal);
610
- }
611
603
  }
612
604
 
613
605
  export default SessionBranchRdb;
@@ -12,7 +12,8 @@ export const eventUsage: TableDef = {
12
12
  primaryKey: true,
13
13
  references: { table: "t_events", column: "f_event_id", onDelete: "cascade" },
14
14
  },
15
- f_created_at: { type: "integer", notNull: true },
15
+ // 毫秒时间戳:SQLite 的 integer 是 64 位,PG 的 integer 只有 int4(21 亿上限)——必须是 bigint。
16
+ f_created_at: { type: "bigint", notNull: true },
16
17
  f_provider: { type: "text" },
17
18
  f_model: { type: "text" },
18
19
  f_input_tokens: { type: "integer", notNull: true },
package/src/index.ts CHANGED
@@ -39,7 +39,12 @@ import {
39
39
  type SurfaceEventType,
40
40
  } from "@deepseek-ai/dsh-session";
41
41
  import { sessionFormatLogFilename } from "@deepseek-ai/dsh-session-format";
42
- import { type Backend, type BackendTx, type EventInsert } from "./backend.ts";
42
+ import {
43
+ type Backend,
44
+ type BackendTx,
45
+ type EventCountBucket,
46
+ type EventInsert,
47
+ } from "./backend.ts";
43
48
  import { WriteGuard } from "./write-guard.ts";
44
49
  import { repairReadView, rowToMeta, scanRows, toJsonlArtifact } from "./log.ts";
45
50
  import {
@@ -56,7 +61,7 @@ import { registerSessionImport } from "./import.ts";
56
61
  import { registerSessionDeletion } from "./deletion.ts";
57
62
  import { registerSessionExport } from "./export.ts";
58
63
  import { registerSessionGc } from "./gc.ts";
59
- import { registerSessionUsage } from "./usage.ts";
64
+ import { COUNTED_EVENT_TYPES, localDayKey, registerSessionUsage } from "./usage.ts";
60
65
  import type { UsageAggregate } from "./usage.ts";
61
66
  import { SessionQueryRdb } from "./session-query.ts";
62
67
  import { adoptLegacyRows, convertLegacyRows, isLegacyVersion } from "./legacy.ts";
@@ -742,7 +747,10 @@ export class SessionPersistenceRdb extends SessionPersistence {
742
747
  });
743
748
  this.liveBuffers.delete(id);
744
749
  this.liveReady.delete(id);
750
+ this.liveFailures.delete(id);
751
+ this.liveDropWarned.delete(id);
745
752
  this.reuseEventIds.delete(id);
753
+ await this.deleteEventCounts(id);
746
754
  }
747
755
 
748
756
  /** GC 通道:回收已无桥接行引用的事件行(孤儿),返回删除行数。 */
@@ -763,7 +771,10 @@ export class SessionPersistenceRdb extends SessionPersistence {
763
771
  for (const id of deletable) {
764
772
  this.liveBuffers.delete(id);
765
773
  this.liveReady.delete(id);
774
+ this.liveFailures.delete(id);
775
+ this.liveDropWarned.delete(id);
766
776
  this.reuseEventIds.delete(id);
777
+ await this.deleteEventCounts(id);
767
778
  }
768
779
  return deleted;
769
780
  }
@@ -872,6 +883,7 @@ export class SessionPersistenceRdb extends SessionPersistence {
872
883
 
873
884
  this.writeGuard.confirmHead(meta.id, confirmedHead);
874
885
  this.tracker.materialized(meta.id);
886
+ await this.recordEventCounts(meta.id, events);
875
887
  return true;
876
888
  }
877
889
 
@@ -951,6 +963,44 @@ export class SessionPersistenceRdb extends SessionPersistence {
951
963
  };
952
964
  }
953
965
 
966
+ /**
967
+ * 活动计数**旁路累加**:不在写事务里、失败只 warn——`t_event_counts` 是可销毁重建的派生表,
968
+ * 丢几次累加不影响可用性(需要时用 `rebuildEventCounts` 重算)。
969
+ */
970
+ private async recordEventCounts(id: SessionId, events: readonly SessionEvent[]): Promise<void> {
971
+ const buckets = eventCountBuckets(events);
972
+ if (buckets.length === 0) return;
973
+ try {
974
+ await this.backend.incrementEventCounts(id, buckets);
975
+ } catch (error: unknown) {
976
+ this.ctx.logger.warn(
977
+ `session-rdb: activity counts for "${id}" not updated (${describeError(error)})`,
978
+ );
979
+ }
980
+ }
981
+
982
+ /** 重算一个会话的活动计数(rewind / fork 之后;best-effort,同 `recordEventCounts`)。 */
983
+ async rebuildEventCounts(id: SessionId): Promise<void> {
984
+ try {
985
+ await this.backend.rebuildEventCounts(id);
986
+ } catch (error: unknown) {
987
+ this.ctx.logger.warn(
988
+ `session-rdb: activity counts for "${id}" not rebuilt (${describeError(error)})`,
989
+ );
990
+ }
991
+ }
992
+
993
+ /** 会话删除时清掉它的活动计数行(best-effort)。 */
994
+ private async deleteEventCounts(id: SessionId): Promise<void> {
995
+ try {
996
+ await this.backend.deleteEventCounts(id);
997
+ } catch (error: unknown) {
998
+ this.ctx.logger.warn(
999
+ `session-rdb: activity counts for "${id}" not deleted (${describeError(error)})`,
1000
+ );
1001
+ }
1002
+ }
1003
+
954
1004
  private async rewriteMigratedLog(
955
1005
  id: SessionId,
956
1006
  log: { meta: SessionHeader; inheritedEventCount: number; events: SessionEvent[] },
@@ -1033,31 +1083,31 @@ export class SessionPersistenceRdb extends SessionPersistence {
1033
1083
  id: SessionId,
1034
1084
  signal?: AbortSignal,
1035
1085
  ): Promise<import("@deepseek-ai/dsh-session-persistence").SessionInspection> {
1036
- const handle = await this.open(id, "read", signal === undefined ? undefined : { signal });
1037
- try {
1038
- const { events } = await handle.read(
1039
- 0,
1040
- undefined,
1041
- signal === undefined ? undefined : { signal },
1042
- );
1043
- const row = await this.backend.getSession(id);
1044
- if (row === undefined) throw new SessionPersistenceNotFoundError(id);
1045
-
1046
- if (isLegacyVersion(row.fVersion)) {
1086
+ signal?.throwIfAborted();
1087
+ await this.ready;
1088
+ signal?.throwIfAborted();
1089
+ // 一条读取路径:load 的语义就是「读一次完整快照」,不再经 handle 面再读一遍
1090
+ // (原先 open(read) 读一次 + handle.read 又读一次,等于把最贵的读付两遍)。
1091
+ const log = await this.readLog(id, {}, signal);
1092
+ if (log === undefined) {
1093
+ // 未落库的 live 会话没有磁盘视图:与 handle.read 的 detached 口径一致,返回空日志。
1094
+ const pending = this.tracker.pendingOf(id);
1095
+ if (pending !== undefined) {
1047
1096
  return {
1048
- meta: handle.header,
1049
- inheritedEventCount: handle.inheritedEventCount,
1050
- events,
1097
+ meta: pending.header,
1098
+ inheritedEventCount: SessionLogOffset(pending.inheritedEventCount),
1099
+ events: [],
1051
1100
  };
1052
1101
  }
1053
- return {
1054
- meta: rowToMeta(row),
1055
- inheritedEventCount: SessionLogOffset(row.fSeedLength ?? 0),
1056
- events,
1057
- };
1058
- } finally {
1059
- await handle.close();
1102
+ throw new SessionPersistenceNotFoundError(id);
1060
1103
  }
1104
+ repairReadView(log.events);
1105
+ validateStoredEvents(log.meta, log.events);
1106
+ return {
1107
+ meta: log.meta,
1108
+ inheritedEventCount: SessionLogOffset(log.inheritedEventCount),
1109
+ events: log.events,
1110
+ };
1061
1111
  }
1062
1112
 
1063
1113
  async append(id: SessionId, events: readonly SessionEvent[]): Promise<void> {
@@ -1123,27 +1173,41 @@ export class SessionPersistenceRdb extends SessionPersistence {
1123
1173
  );
1124
1174
  }
1125
1175
 
1176
+ private readonly liveFailures = new Map<SessionId, Error>();
1177
+ private readonly liveDropWarned = new Set<SessionId>();
1178
+
1179
+ // live 会话的持久化初始化失败是**终态**(cwd / 继承前缀冲突、id 碰撞、后端打不开都不自愈):
1180
+ // 记账一条 error、丢掉已缓冲的事件,之后该会话的事件不再进内存缓冲(否则表现为
1181
+ // 「聊了一整轮、重启后全丢,内存里还留一份无界副本」),flush 把失败抛回上游。
1182
+ private watchLiveReady(ctx: Context, session: Session, ready: Promise<void>): void {
1183
+ void ready.catch((error: unknown) => {
1184
+ const failure =
1185
+ error instanceof Error
1186
+ ? error
1187
+ : new Error(
1188
+ typeof error === "string" ? error : `live session "${session.id}" persistence failed`,
1189
+ );
1190
+ this.liveFailures.set(session.id, failure);
1191
+ this.liveBuffers.delete(session.id);
1192
+ ctx.logger.error(
1193
+ `session-rdb: live session "${session.id}" persistence unavailable (${failure.message}); its events will not be persisted`,
1194
+ );
1195
+ });
1196
+ }
1197
+
1126
1198
  private installLiveRouting(ctx: Context): void {
1127
1199
  ctx.on("session/created", (session: Session) => {
1128
1200
  this.liveBuffers.set(session.id, []);
1129
1201
  const ready = this.ensureLiveHandle(session);
1130
1202
  this.liveReady.set(session.id, ready);
1131
- void ready.catch((error: unknown) => {
1132
- ctx.logger.warn(
1133
- `session-rdb: live session "${session.id}" persistence init failed: ${String(error)}`,
1134
- );
1135
- });
1203
+ this.watchLiveReady(ctx, session, ready);
1136
1204
  });
1137
1205
 
1138
1206
  for (const session of ctx.sessions.list()) {
1139
1207
  this.liveBuffers.set(session.id, []);
1140
1208
  const ready = this.ensureLiveHandle(session);
1141
1209
  this.liveReady.set(session.id, ready);
1142
- void ready.catch((error: unknown) => {
1143
- ctx.logger.warn(
1144
- `session-rdb: live session "${session.id}" persistence init failed: ${String(error)}`,
1145
- );
1146
- });
1210
+ this.watchLiveReady(ctx, session, ready);
1147
1211
  }
1148
1212
  ctx.on("session/event", (session: Session, event: SessionEvent) => {
1149
1213
  const handle = this.tracker.writerOf(session.id);
@@ -1155,9 +1219,20 @@ export class SessionPersistenceRdb extends SessionPersistence {
1155
1219
  });
1156
1220
  return;
1157
1221
  }
1222
+ if (this.liveFailures.has(session.id)) {
1223
+ if (!this.liveDropWarned.has(session.id)) {
1224
+ this.liveDropWarned.add(session.id);
1225
+ ctx.logger.warn(
1226
+ `session-rdb: dropping events for live session "${session.id}" because persistence failed earlier`,
1227
+ );
1228
+ }
1229
+ return;
1230
+ }
1158
1231
  this.liveBuffers.get(session.id)?.push(structuredClone(event));
1159
1232
  });
1160
1233
  ctx.on("session/flush", (session: Session) => {
1234
+ const failure = this.liveFailures.get(session.id);
1235
+ if (failure !== undefined) return Promise.reject(failure);
1161
1236
  const handle = this.tracker.writerOf(session.id);
1162
1237
  if (handle === undefined) {
1163
1238
  const ready = this.liveReady.get(session.id);
@@ -1174,6 +1249,8 @@ export class SessionPersistenceRdb extends SessionPersistence {
1174
1249
  const ready = this.liveReady.get(session.id);
1175
1250
  this.liveBuffers.delete(session.id);
1176
1251
  this.liveReady.delete(session.id);
1252
+ this.liveFailures.delete(session.id);
1253
+ this.liveDropWarned.delete(session.id);
1177
1254
  const closeHandle = (): void => {
1178
1255
  const handle = this.tracker.writerOf(session.id);
1179
1256
  if (handle === undefined) return;
@@ -1193,6 +1270,10 @@ export class SessionPersistenceRdb extends SessionPersistence {
1193
1270
  ctx.effect(
1194
1271
  () => async () => {
1195
1272
  await Promise.allSettled(this.liveReady.values());
1273
+ this.liveBuffers.clear();
1274
+ this.liveReady.clear();
1275
+ this.liveFailures.clear();
1276
+ this.liveDropWarned.clear();
1196
1277
  await this.tracker.closeAll();
1197
1278
 
1198
1279
  await this.close();
@@ -1259,6 +1340,24 @@ function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly Sessio
1259
1340
  );
1260
1341
  }
1261
1342
 
1343
+ /** 一批事件折成活动计数的桶:只数四种类型,按本地日聚合。 */
1344
+ function eventCountBuckets(events: readonly SessionEvent[]): EventCountBucket[] {
1345
+ const counted = new Map<string, EventCountBucket>();
1346
+ for (const event of events) {
1347
+ if (!(COUNTED_EVENT_TYPES as readonly string[]).includes(event.type)) continue;
1348
+ const day = localDayKey(event.time);
1349
+ const key = `${day}#${event.type}`;
1350
+ const bucket = counted.get(key) ?? { day, type: event.type, count: 0 };
1351
+ bucket.count += 1;
1352
+ counted.set(key, bucket);
1353
+ }
1354
+ return [...counted.values()];
1355
+ }
1356
+
1357
+ function describeError(error: unknown): string {
1358
+ return error instanceof Error ? error.message : "unknown error";
1359
+ }
1360
+
1262
1361
  function createBackend(config: Config): Backend {
1263
1362
  if (config.type === "sqlite") {
1264
1363
  return new SqliteBackend({
package/src/log.ts CHANGED
@@ -127,7 +127,40 @@ const SURFACE_EVENT_TYPES = new Set([
127
127
 
128
128
  const METERING_EVENT_TYPES = new Set(["compaction/summary", "compaction/prune"]);
129
129
 
130
+ /**
131
+ * 升序的 surface 事件 seq 索引:读视图修复里的溯源(replace 的 `sourceEventSeqs` 与
132
+ * metering 的 `shadowedSeqs`)共用这一份,避免每个 replace 各扫一遍全量事件。
133
+ * 前提是输入按 seq 有序(`readLog` 与 live snapshot 都如此)。
134
+ */
135
+ function surfaceSeqIndex(events: readonly SessionEvent[]): number[] {
136
+ const seqs: number[] = [];
137
+ for (const event of events) {
138
+ if (SURFACE_EVENT_TYPES.has(event.type)) seqs.push(event.seq);
139
+ }
140
+ return seqs;
141
+ }
142
+
143
+ /** `[startSeq, endSeq]` 内的 surface seq(含端点),二分定位后切片。 */
144
+ function surfaceSeqsInRange(seqs: readonly number[], startSeq: number, endSeq: number): number[] {
145
+ let low = 0;
146
+ let high = seqs.length;
147
+ while (low < high) {
148
+ const mid = (low + high) >> 1;
149
+ if (seqs[mid]! < startSeq) low = mid + 1;
150
+ else high = mid;
151
+ }
152
+ const from = low;
153
+ high = seqs.length;
154
+ while (low < high) {
155
+ const mid = (low + high) >> 1;
156
+ if (seqs[mid]! <= endSeq) low = mid + 1;
157
+ else high = mid;
158
+ }
159
+ return seqs.slice(from, low);
160
+ }
161
+
130
162
  export function recomputeReplaceProvenance(events: SessionEvent[]): void {
163
+ const seqs = surfaceSeqIndex(events);
131
164
  for (let i = 0; i < events.length; i++) {
132
165
  const event = events[i]!;
133
166
  const raw = event as unknown as { surfaceOp?: unknown; sourceEventSeqs?: number[] };
@@ -145,17 +178,7 @@ export function recomputeReplaceProvenance(events: SessionEvent[]): void {
145
178
  raw.sourceEventSeqs = meteringData.shadowedSeqs;
146
179
  continue;
147
180
  }
148
- const refs: number[] = [];
149
- for (const candidate of events) {
150
- if (
151
- candidate.seq >= startSeq &&
152
- candidate.seq <= endSeq &&
153
- SURFACE_EVENT_TYPES.has(candidate.type)
154
- ) {
155
- refs.push(candidate.seq);
156
- }
157
- }
158
- raw.sourceEventSeqs = refs;
181
+ raw.sourceEventSeqs = surfaceSeqsInRange(seqs, startSeq, endSeq);
159
182
  }
160
183
  }
161
184
 
@@ -359,6 +382,7 @@ export function repairRequestHeaders(events: SessionEvent[]): void {
359
382
  }
360
383
 
361
384
  export function syncMeteringRanges(events: SessionEvent[]): void {
385
+ const seqs = surfaceSeqIndex(events);
362
386
  for (let i = 1; i < events.length; i++) {
363
387
  const metering = events[i - 1]!;
364
388
  if (!METERING_EVENT_TYPES.has(metering.type)) continue;
@@ -372,14 +396,7 @@ export function syncMeteringRanges(events: SessionEvent[]): void {
372
396
  };
373
397
  if (data.shadowedRange?.start === startSeq && data.shadowedRange.end === endSeq) continue;
374
398
  data.shadowedRange = { start: startSeq, end: endSeq };
375
- data.shadowedSeqs = events
376
- .filter(
377
- (candidate) =>
378
- candidate.seq >= startSeq &&
379
- candidate.seq <= endSeq &&
380
- SURFACE_EVENT_TYPES.has(candidate.type),
381
- )
382
- .map((candidate) => candidate.seq);
399
+ data.shadowedSeqs = surfaceSeqsInRange(seqs, startSeq, endSeq);
383
400
  }
384
401
  }
385
402