@morlay/session-rdb 0.1.1 → 0.1.3
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/README.md +17 -166
- package/dist/artifact.d.mts +2 -2
- package/dist/artifact.mjs +3 -2
- package/dist/{branch-D4nVzQQM.mjs → branch-aiXPmnKC.mjs} +1 -8
- package/dist/deletion.d.mts +3 -4
- package/dist/deletion.mjs +2 -1
- package/dist/export.d.mts +3 -5
- package/dist/export.mjs +1 -4
- package/dist/gc.d.mts +3 -5
- package/dist/gc.mjs +1 -3
- package/dist/import.d.mts +7 -8
- package/dist/import.mjs +2 -7
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +4 -3
- package/dist/invariant.d.mts +4 -5
- package/dist/{log-BWVFYAh5.mjs → log-DYxdjJ-l.mjs} +1 -52
- package/dist/{log-CUjKQbOR.d.mts → log-DYyxRh7j.d.mts} +2216 -294
- package/dist/rolldown-runtime-EAsCxrDo.mjs +14 -0
- package/dist/routes-DGBlBE_I.mjs +10 -0
- package/dist/{sqlite-SCiSnwaz.mjs → sqlite-YCr2-Fov.mjs} +24 -62
- package/dist/{src-BZ1tffd5.mjs → src-BmogreEz.mjs} +59 -97
- package/dist/storage.d.mts +6 -26
- package/dist/storage.mjs +1 -1
- package/dist/testing.d.mts +7 -7
- package/dist/testing.mjs +2 -4
- package/dist/usage.d.mts +1 -1
- package/dist/usage.mjs +1 -17
- package/package.json +2 -2
- package/src/backend.ts +35 -54
- package/src/branch.ts +3 -5
- package/src/deletion.ts +2 -1
- package/src/entities/v3/event-usage.ts +3 -6
- package/src/entities/v3/session-counts.ts +3 -5
- package/src/entities/v3/session-usage.ts +3 -5
- package/src/export.ts +5 -4
- package/src/gc.ts +4 -3
- package/src/import.ts +5 -6
- package/src/index.ts +47 -45
- package/src/legacy.ts +3 -5
- package/src/log.ts +28 -53
- package/src/postgres.ts +70 -61
- package/src/routes.ts +16 -0
- package/src/rows.ts +11 -15
- package/src/session-query.ts +3 -7
- package/src/sqlite.ts +70 -58
- package/src/storage-takeover/projection-cache.ts +6 -8
- package/src/storage-takeover/repository.ts +2 -2
- package/src/storage-takeover/types.ts +1 -1
- package/src/usage.ts +22 -34
- package/src/write-guard.ts +1 -1
package/src/log.ts
CHANGED
|
@@ -124,8 +124,12 @@ export function rowToEvent(row: EventRow): SessionEvent {
|
|
|
124
124
|
} as SessionEvent;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
// 与上游 `SURFACE_EVENT_TYPES`(`…/core/session/src/surface.ts`)同一集合:`developer/message` 承载
|
|
128
|
+
// 工具清单的增删通知,同样是模型可见的 surface 事件,漏掉它会把它的 `surfaceOp` 当「非 surface 事件带的
|
|
129
|
+
// 标记」清掉,随后的持久化校验直接拒绝整条会话(agent-loop 在工具集变化时会写这种事件)。
|
|
127
130
|
const SURFACE_EVENT_TYPES = new Set([
|
|
128
131
|
"system/message",
|
|
132
|
+
"developer/message",
|
|
129
133
|
"user/message",
|
|
130
134
|
"assistant/message",
|
|
131
135
|
"tool/result",
|
|
@@ -133,11 +137,9 @@ const SURFACE_EVENT_TYPES = new Set([
|
|
|
133
137
|
|
|
134
138
|
const METERING_EVENT_TYPES = new Set(["compaction/summary", "compaction/prune"]);
|
|
135
139
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
* 前提是输入按 seq 有序(`readLog` 与 live snapshot 都如此)。
|
|
140
|
-
*/
|
|
140
|
+
// 升序的 surface 事件 seq 索引:读视图修复里的溯源(replace 的 `sourceEventSeqs` 与
|
|
141
|
+
// metering 的 `shadowedSeqs`)共用这一份,避免每个 replace 各扫一遍全量事件。
|
|
142
|
+
// 前提是输入按 seq 有序(`readLog` 与 live snapshot 都如此)。
|
|
141
143
|
function surfaceSeqIndex(events: readonly SessionEvent[]): number[] {
|
|
142
144
|
const seqs: number[] = [];
|
|
143
145
|
for (const event of events) {
|
|
@@ -146,7 +148,7 @@ function surfaceSeqIndex(events: readonly SessionEvent[]): number[] {
|
|
|
146
148
|
return seqs;
|
|
147
149
|
}
|
|
148
150
|
|
|
149
|
-
|
|
151
|
+
// `[startSeq, endSeq]` 内的 surface seq(含端点),二分定位后切片。
|
|
150
152
|
function surfaceSeqsInRange(seqs: readonly number[], startSeq: number, endSeq: number): number[] {
|
|
151
153
|
let low = 0;
|
|
152
154
|
let high = seqs.length;
|
|
@@ -461,20 +463,11 @@ export function renameLegacyPtcEvents(events: SessionEvent[]): void {
|
|
|
461
463
|
}
|
|
462
464
|
}
|
|
463
465
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
* - `system/message` 的 source:`{ kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' }` → `{ kind: 'system-prompt' }`
|
|
470
|
-
* (v4 起 system 消息只认这个 kind);
|
|
471
|
-
* - `tool/result` 的消息:v3 是 user 角色、结果块包在 `content[0]`;v4 是 `role: 'tool'` + 顶层
|
|
472
|
-
* `toolCallId` + `content` 就是结果块;
|
|
473
|
-
* - **本仓库自造、上游不认识的事件类型**(见 `OWN_EVENT_TYPES`:历史遗留的与我们当前在写的)标
|
|
474
|
-
* `ignorable: true`——v4 的校验对未知类型只接受可忽略信封。
|
|
475
|
-
*
|
|
476
|
-
* 只认这张白名单:**其它未知类型保持 fail loud**(那是「数据来自更新的 harness」的信号,不能吞掉)。
|
|
477
|
-
*/
|
|
466
|
+
// 把**旧代(v3 及更早)的消息形状**归一到当前格式(v4):迁移链(严格)拒绝那些形状时,读路径仍要能把它们
|
|
467
|
+
// 读出来——只动字段与标记,不改坐标、不改内容。三件事:`system/message` 的 source 去掉 plugin 形状(v4 只认
|
|
468
|
+
// `{ kind: 'system-prompt' }`)、`tool/result` 改成 `role: 'tool'` + 顶层 `toolCallId`、`OWN_EVENT_TYPES` 里的
|
|
469
|
+
// 事件补 `ignorable: true` 信封。
|
|
470
|
+
// 只认这张白名单:**其它未知类型保持 fail loud**(那是「数据来自更新的 harness」的信号,不能吞掉)。
|
|
478
471
|
export function normalizeToCurrentShape(events: SessionEvent[]): void {
|
|
479
472
|
for (const event of events) {
|
|
480
473
|
const type = (event as { type: string }).type;
|
|
@@ -520,15 +513,9 @@ function liftToolResultMessage(message: unknown): void {
|
|
|
520
513
|
record["source"] = { kind: "tool", callId };
|
|
521
514
|
}
|
|
522
515
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
* 上游的持久化校验(`validateStoredEvents`)只放行「已知类型」或「带可忽略信封的未知类型」,我们的类型
|
|
527
|
-
* 按构造就在已知表之外——不补的话连**写入**都会被拒(`appendBatch` 里那道校验)。上游 `Session.append`
|
|
528
|
-
* 没有承载这个信封的口子(第三个参数只服务 surface 事件),所以这一笔由持久化层补:canonical log 里的
|
|
529
|
-
* 形状不变,落库的那一份带上信封。
|
|
530
|
-
* @param events - 即将落库的批次(`materializeAppendBatch` 的 JSON 快照副本,可写)。
|
|
531
|
-
*/
|
|
516
|
+
// 落库前给**本仓库自造的事件类型**补上 `ignorable: true` 信封:上游持久化校验(`validateStoredEvents`)只放行
|
|
517
|
+
// 「已知类型」或「带可忽略信封的未知类型」,不补连写入都会被拒。`Session.append` 没有承载信封的口子,所以这一笔
|
|
518
|
+
// 由持久化层补——canonical log 的形状不变,落库的那一份带上信封;`events` 是即将落库的批次(可写)。
|
|
532
519
|
export function sealOwnEvents(events: readonly SessionEvent[]): void {
|
|
533
520
|
for (const event of events) {
|
|
534
521
|
if (OWN_EVENT_TYPES.has((event as { type: string }).type)) {
|
|
@@ -537,25 +524,19 @@ export function sealOwnEvents(events: readonly SessionEvent[]): void {
|
|
|
537
524
|
}
|
|
538
525
|
}
|
|
539
526
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
* - **当前在写**:本仓库的插件自己声明的事件类型(`session-mode/selected`)——上游的
|
|
547
|
-
* `KNOWN_SESSION_EVENT_TYPES` 由上游仓库的声明生成,外部插件的事件按构造就在它之外。
|
|
548
|
-
*
|
|
549
|
-
* 新增自造事件类型时**必须**登记到这里,否则读路径 fail loud(真回归:`session-mode/selected` 漏登记,
|
|
550
|
-
* 会话打不开)。**不要**用「所有未知类型」代替(那会掩盖更新版本的未知事件——那种必须拒读)。
|
|
551
|
-
*/
|
|
527
|
+
// 本仓库自造、上游不认识的事件类型:读的时候标 `ignorable: true`,v4 的持久化校验才肯跳过它们
|
|
528
|
+
// (`validateStoredEvents` 只放行「已知类型」或「带可忽略信封的未知类型」)。两类都在表里:既有日志里会出现、
|
|
529
|
+
// 但本仓库不再产出的 `session-branch/version`,与当前在写的 `session-mode/selected`(上游已知类型表由上游仓库
|
|
530
|
+
// 的声明生成,外部插件的事件按构造就在它之外)。
|
|
531
|
+
// 新增自造事件类型时**必须**登记到这里,否则读路径 fail loud。**不要**用「所有未知类型」代替(那会掩盖更新
|
|
532
|
+
// 版本的未知事件——那种必须拒读)。
|
|
552
533
|
export const OWN_EVENT_TYPES: ReadonlySet<string> = new Set([
|
|
553
534
|
"session-branch/version",
|
|
554
535
|
"session-mode/selected",
|
|
555
536
|
]);
|
|
556
537
|
|
|
557
|
-
|
|
558
|
-
|
|
538
|
+
// 这批事件是否需要按当前形状归一(旧代消息形状,或尚未标 `ignorable` 的自造事件类型)——既有日志里可能有以
|
|
539
|
+
// 当前版本号落库的回退视图结果,所以**版本号不可信**,读之前要按内容判一次。
|
|
559
540
|
export function needsShapeAdoption(events: readonly SessionEvent[]): boolean {
|
|
560
541
|
for (const event of events) {
|
|
561
542
|
const type = (event as { type: string }).type;
|
|
@@ -745,7 +726,7 @@ export function titleOfEventData(data: string): string | undefined {
|
|
|
745
726
|
}
|
|
746
727
|
}
|
|
747
728
|
|
|
748
|
-
|
|
729
|
+
// 用量行的列(`t_event_usage`):读侧维度(本地日与两个归属标记)在写入时一起物化。
|
|
749
730
|
export interface EventUsageRow {
|
|
750
731
|
fEventId: string;
|
|
751
732
|
fCreatedAt: number;
|
|
@@ -769,15 +750,9 @@ function textField(value: unknown): string | null {
|
|
|
769
750
|
return typeof value === "string" && value !== "" ? value : null;
|
|
770
751
|
}
|
|
771
752
|
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
* 读侧维度一起物化:本地日由事件时间算,`f_referenced` 直接 1(这一批事件行与桥接行同事务,
|
|
776
|
-
* 提交即被引用),`f_subagent` 取写入它的会话是否子代理。
|
|
777
|
-
* @param event - 待写入的事件行。
|
|
778
|
-
* @param subagent - 写入它的会话是否 `origin === 'subagent'`。
|
|
779
|
-
* @returns 用量行,或该事件没有用量时的 undefined。
|
|
780
|
-
*/
|
|
753
|
+
// 一条事件行的用量(只有带 `usage` 的 `assistant/message` 才有;没有用量时为 undefined):写路径据此记录
|
|
754
|
+
// `t_event_usage`,统计因此不必逐行解析 JSON,两种 `f_data` 结构(带信封 / 老格式)都认。读侧维度一起物化:
|
|
755
|
+
// 本地日由事件时间算,`f_referenced` 直接 1(这一批事件行与桥接行同事务),`f_subagent` 取写入它的会话是否子代理。
|
|
781
756
|
export function usageRowOf(
|
|
782
757
|
event: {
|
|
783
758
|
fEventId: string;
|
package/src/postgres.ts
CHANGED
|
@@ -38,7 +38,10 @@ const postgresMigrationsDir = fileURLToPath(new URL("../drizzle/postgres/", impo
|
|
|
38
38
|
|
|
39
39
|
const pgWriteQueues = new Map<string, Promise<void>>();
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
// 引用标记的分批重算:一次绑定的参数有上限,按块走(与 sqlite 侧同值)。
|
|
42
|
+
const REFRESH_FLAG_CHUNK = 400;
|
|
43
|
+
|
|
44
|
+
// 按介质(连接串 + schema)串行化写事务,与 SQLite 侧的 `enqueueSqliteTx` 同形。
|
|
42
45
|
function enqueuePgWrite<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
|
43
46
|
const tail = pgWriteQueues.get(key) ?? Promise.resolve();
|
|
44
47
|
const run = tail.then(fn);
|
|
@@ -52,18 +55,18 @@ function enqueuePgWrite<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
|
|
52
55
|
return run;
|
|
53
56
|
}
|
|
54
57
|
|
|
55
|
-
|
|
58
|
+
// pg 的 text 列窄化:非字符串(含 null)都不当作文本。
|
|
56
59
|
function text(value: unknown): string | null {
|
|
57
60
|
return typeof value === "string" ? value : null;
|
|
58
61
|
}
|
|
59
62
|
|
|
60
|
-
|
|
63
|
+
// pg 的 count/sum 回落成字符串:统一转成有限数。
|
|
61
64
|
function numeric(value: unknown): number {
|
|
62
65
|
const parsed = typeof value === "number" ? value : Number(value ?? 0);
|
|
63
66
|
return Number.isFinite(parsed) ? parsed : 0;
|
|
64
67
|
}
|
|
65
68
|
|
|
66
|
-
|
|
69
|
+
// SQL 的缺失求和是 NULL:没有 usage 字段的行使该字段计 0。
|
|
67
70
|
function tokenTotalsOf(row: Record<string, unknown>): UsageTokenTotals {
|
|
68
71
|
return {
|
|
69
72
|
inputTokens: numeric(row["input_tokens"]),
|
|
@@ -78,7 +81,7 @@ function emptyActivity(): UsageActivityTotals {
|
|
|
78
81
|
return { turns: 0, steps: 0, userInputs: 0, toolCalls: 0 };
|
|
79
82
|
}
|
|
80
83
|
|
|
81
|
-
|
|
84
|
+
// `t_session_counts` 的四项计数折成活动计数(pg 的 sum 回落成字符串)。
|
|
82
85
|
function activityTotalsOf(row: Record<string, unknown>): UsageActivityTotals {
|
|
83
86
|
return {
|
|
84
87
|
turns: numeric(row["turns"]),
|
|
@@ -88,7 +91,7 @@ function activityTotalsOf(row: Record<string, unknown>): UsageActivityTotals {
|
|
|
88
91
|
};
|
|
89
92
|
}
|
|
90
93
|
|
|
91
|
-
|
|
94
|
+
// `IN (...)` 用的类型字面量(与 `COUNTED_EVENT_TYPES` 同源)。
|
|
92
95
|
const COUNTED_EVENT_TYPE_SQL = COUNTED_EVENT_TYPES.map((type) => `'${type}'`).join(", ");
|
|
93
96
|
|
|
94
97
|
export interface PostgresBackendOptions {
|
|
@@ -124,10 +127,9 @@ export class PostgresBackend implements Backend {
|
|
|
124
127
|
});
|
|
125
128
|
|
|
126
129
|
this.opened.catch(() => {});
|
|
127
|
-
// 写事务按介质串行(与 SQLite
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
// (宿主 + 库 + schema)——同一进程内多实例连同一库时也共用同一条队列。
|
|
130
|
+
// 写事务按介质串行(与 SQLite 侧 `enqueueSqliteTx` 同形):并发 `writeAtomically` 会互相覆盖实例级的
|
|
131
|
+
// `txOverride`,让语句落进别人的事务或退化成 autocommit;介质身份用 `identityBase`(宿主 + 库 + schema),
|
|
132
|
+
// 同一进程内多实例连同一库时共用同一条队列。
|
|
131
133
|
const writeQueueKey = options.identityBase;
|
|
132
134
|
this.storage = createStorageRepository({
|
|
133
135
|
db: () => this.opened.then(() => this.txOverride ?? this.db),
|
|
@@ -486,26 +488,22 @@ export class PostgresBackend implements Backend {
|
|
|
486
488
|
exec: PgAsyncDatabase<NodePgQueryResultHKT>,
|
|
487
489
|
id: SessionId,
|
|
488
490
|
fromSequence: number,
|
|
489
|
-
): Promise<
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
),
|
|
497
|
-
)
|
|
498
|
-
.execute();
|
|
491
|
+
): Promise<readonly string[]> {
|
|
492
|
+
const table = this.tables["t_session_events"];
|
|
493
|
+
const result = (await exec.execute(sql`
|
|
494
|
+
DELETE FROM ${table} WHERE f_session_id = ${id} AND f_sequence >= ${fromSequence}
|
|
495
|
+
RETURNING f_event_id
|
|
496
|
+
`)) as unknown as { rows: Array<{ f_event_id: string }> };
|
|
497
|
+
return result.rows.map((row) => row.f_event_id);
|
|
499
498
|
}
|
|
500
499
|
|
|
501
500
|
private async deleteSession(
|
|
502
501
|
exec: PgAsyncDatabase<NodePgQueryResultHKT>,
|
|
503
502
|
id: SessionId,
|
|
504
|
-
): Promise<
|
|
505
|
-
await exec
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
.execute();
|
|
503
|
+
): Promise<readonly string[]> {
|
|
504
|
+
const result = (await exec.execute(sql`
|
|
505
|
+
DELETE FROM ${this.tables["t_session_events"]} WHERE f_session_id = ${id} RETURNING f_event_id
|
|
506
|
+
`)) as unknown as { rows: Array<{ f_event_id: string }> };
|
|
509
507
|
await exec
|
|
510
508
|
.delete(this.tables["t_workspace_sessions"])
|
|
511
509
|
.where(eq(this.tables["t_workspace_sessions"].fSessionId, id))
|
|
@@ -518,9 +516,10 @@ export class PostgresBackend implements Backend {
|
|
|
518
516
|
.delete(this.tables["t_sessions"])
|
|
519
517
|
.where(eq(this.tables["t_sessions"].fSessionId, id))
|
|
520
518
|
.execute();
|
|
519
|
+
return result.rows.map((row) => row.f_event_id);
|
|
521
520
|
}
|
|
522
521
|
|
|
523
|
-
|
|
522
|
+
// 事件行可能被多个会话共享(fork 派生),所以孤儿只能在全库范围内判定。
|
|
524
523
|
async collectOrphans(): Promise<number> {
|
|
525
524
|
const tEvents = this.tables["t_events"];
|
|
526
525
|
const tEventUsage = this.tables["t_event_usage"];
|
|
@@ -547,7 +546,7 @@ export class PostgresBackend implements Backend {
|
|
|
547
546
|
return result.rowCount ?? 0;
|
|
548
547
|
}
|
|
549
548
|
|
|
550
|
-
|
|
549
|
+
// 父会话被删后留下的 subagent 会话:父已不在表里,或本来就没有父。
|
|
551
550
|
async listOrphanSubagentSessions(): Promise<SessionId[]> {
|
|
552
551
|
const tSessions = this.tables["t_sessions"];
|
|
553
552
|
const result = (await this.db.execute(sql`
|
|
@@ -562,12 +561,16 @@ export class PostgresBackend implements Backend {
|
|
|
562
561
|
private async deleteSessions(
|
|
563
562
|
exec: PgAsyncDatabase<NodePgQueryResultHKT>,
|
|
564
563
|
ids: SessionId[],
|
|
565
|
-
): Promise<number> {
|
|
566
|
-
if (ids.length === 0) return 0;
|
|
567
|
-
await exec
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
564
|
+
): Promise<{ deleted: number; eventIds: readonly string[] }> {
|
|
565
|
+
if (ids.length === 0) return { deleted: 0, eventIds: [] };
|
|
566
|
+
const removed = (await exec.execute(sql`
|
|
567
|
+
DELETE FROM ${this.tables["t_session_events"]}
|
|
568
|
+
WHERE f_session_id IN (${sql.join(
|
|
569
|
+
ids.map((id) => sql`${id}`),
|
|
570
|
+
sql`, `,
|
|
571
|
+
)})
|
|
572
|
+
RETURNING f_event_id
|
|
573
|
+
`)) as unknown as { rows: Array<{ f_event_id: string }> };
|
|
571
574
|
await exec
|
|
572
575
|
.delete(this.tables["t_workspace_sessions"])
|
|
573
576
|
.where(inArray(this.tables["t_workspace_sessions"].fSessionId, ids))
|
|
@@ -580,14 +583,17 @@ export class PostgresBackend implements Backend {
|
|
|
580
583
|
.delete(this.tables["t_sessions"])
|
|
581
584
|
.where(inArray(this.tables["t_sessions"].fSessionId, ids))
|
|
582
585
|
.execute()) as unknown as { rowCount?: number | null };
|
|
583
|
-
return
|
|
586
|
+
return {
|
|
587
|
+
deleted: Number(result.rowCount ?? 0),
|
|
588
|
+
eventIds: removed.rows.map((row) => row.f_event_id),
|
|
589
|
+
};
|
|
584
590
|
}
|
|
585
591
|
|
|
586
592
|
async vacuum(): Promise<void> {
|
|
587
593
|
await this.db.execute(sql`VACUUM ANALYZE`);
|
|
588
594
|
}
|
|
589
595
|
|
|
590
|
-
|
|
596
|
+
// 派生统计表的回填:任一表为空即按事件表全量重算(幂等,迁移删表重建后自愈)。
|
|
591
597
|
private async backfillUsageTables(): Promise<void> {
|
|
592
598
|
// 顺序有依赖:用量行(带物化列)先落,两张会话汇总表再从它 / 事件表重算。
|
|
593
599
|
await this.backfillEventUsage();
|
|
@@ -602,10 +608,8 @@ export class PostgresBackend implements Backend {
|
|
|
602
608
|
return Number(existing.rows[0]?.["n"] ?? 0) === 0;
|
|
603
609
|
}
|
|
604
610
|
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
* 本地日按数据库会话时区渲染(部署前提是它与 host 时区一致,`localDayKey` 同口径)。
|
|
608
|
-
*/
|
|
611
|
+
// 用量行:一条 `assistant/message` 事件行一行,物化本地日与两个归属标记。
|
|
612
|
+
// 本地日按数据库会话时区渲染(部署前提是它与 host 时区一致,`localDayKey` 同口径)。
|
|
609
613
|
private async backfillEventUsage(): Promise<void> {
|
|
610
614
|
if (!(await this.tableIsEmpty(this.qualifiedTable("t_event_usage")))) return;
|
|
611
615
|
const tEventUsage = this.tables["t_event_usage"];
|
|
@@ -645,7 +649,7 @@ export class PostgresBackend implements Backend {
|
|
|
645
649
|
`);
|
|
646
650
|
}
|
|
647
651
|
|
|
648
|
-
|
|
652
|
+
// 会话 × 本地日 × 模型的 token 汇总:从桥接行 × 用量行重算(含 fork 继承前缀)。
|
|
649
653
|
private async backfillSessionUsage(): Promise<void> {
|
|
650
654
|
if (!(await this.tableIsEmpty(this.qualifiedTable("t_session_usage")))) return;
|
|
651
655
|
const tSessionUsage = this.tables["t_session_usage"];
|
|
@@ -665,7 +669,7 @@ export class PostgresBackend implements Backend {
|
|
|
665
669
|
`);
|
|
666
670
|
}
|
|
667
671
|
|
|
668
|
-
|
|
672
|
+
// 会话 × 本地日的活动计数:从桥接行 × 事件类型重算。
|
|
669
673
|
private async backfillSessionCounts(): Promise<void> {
|
|
670
674
|
if (!(await this.tableIsEmpty(this.qualifiedTable("t_session_counts")))) return;
|
|
671
675
|
const tSessionCounts = this.tables["t_session_counts"];
|
|
@@ -688,7 +692,7 @@ export class PostgresBackend implements Backend {
|
|
|
688
692
|
`);
|
|
689
693
|
}
|
|
690
694
|
|
|
691
|
-
|
|
695
|
+
// 用量聚合:与 SQLite 侧同形,读 `t_event_usage`,数值列回来是字符串。
|
|
692
696
|
async listSessionRows(query: SessionListRowsQuery = {}): Promise<SessionListRowsPage> {
|
|
693
697
|
const needle = query.query?.trim().toLowerCase() ?? "";
|
|
694
698
|
const like = `%${needle}%`;
|
|
@@ -741,10 +745,8 @@ export class PostgresBackend implements Backend {
|
|
|
741
745
|
};
|
|
742
746
|
}
|
|
743
747
|
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
* 两张会话汇总表给按会话的行),数值列回来是字符串。物化列让这里不做 `EXISTS`、不回连事件表。
|
|
747
|
-
*/
|
|
748
|
+
// 用量聚合:与 SQLite 侧同形——只读三张派生统计表(用量行给去重口径的总量与「天 × 模型」桶,
|
|
749
|
+
// 两张会话汇总表给按会话的行),数值列回来是字符串。物化列让这里不做 `EXISTS`、不回连事件表。
|
|
748
750
|
async usageReport(sinceMs?: number): Promise<UsageAggregate> {
|
|
749
751
|
const usageSince = sinceMs === undefined ? sql`` : sql` AND f_created_at >= ${sinceMs}`;
|
|
750
752
|
const dayFilter = (column: string): ReturnType<typeof sql> =>
|
|
@@ -846,13 +848,13 @@ export class PostgresBackend implements Backend {
|
|
|
846
848
|
};
|
|
847
849
|
}
|
|
848
850
|
|
|
849
|
-
|
|
851
|
+
// 表名限定到配置的 schema。
|
|
850
852
|
private qualifiedTable(name: string): string {
|
|
851
853
|
const schema = this.options.schema ?? "public";
|
|
852
854
|
return schema === "public" ? name : `"${schema}".${name}`;
|
|
853
855
|
}
|
|
854
856
|
|
|
855
|
-
|
|
857
|
+
// token 用量旁路累加:派生表 `t_session_usage`,不在写事务里(失败可丢,表可销毁重建)。
|
|
856
858
|
async incrementSessionUsage(
|
|
857
859
|
id: SessionId,
|
|
858
860
|
buckets: readonly SessionUsageBucket[],
|
|
@@ -877,7 +879,7 @@ export class PostgresBackend implements Backend {
|
|
|
877
879
|
}
|
|
878
880
|
}
|
|
879
881
|
|
|
880
|
-
|
|
882
|
+
// 活动计数旁路累加:派生表 `t_session_counts`,同上(四项计数一次落)。
|
|
881
883
|
async incrementSessionCounts(
|
|
882
884
|
id: SessionId,
|
|
883
885
|
buckets: readonly SessionCountBucket[],
|
|
@@ -899,7 +901,7 @@ export class PostgresBackend implements Backend {
|
|
|
899
901
|
}
|
|
900
902
|
}
|
|
901
903
|
|
|
902
|
-
|
|
904
|
+
// 按会话重算两张汇总表(rewind / fork 之后):先删该会话的行,再从事件表重算。
|
|
903
905
|
async rebuildSessionStats(id: SessionId): Promise<void> {
|
|
904
906
|
const tSessionUsage = this.tables["t_session_usage"];
|
|
905
907
|
const tSessionCounts = this.tables["t_session_counts"];
|
|
@@ -936,20 +938,27 @@ export class PostgresBackend implements Backend {
|
|
|
936
938
|
`);
|
|
937
939
|
}
|
|
938
940
|
|
|
939
|
-
|
|
940
|
-
async refreshEventUsageFlags(): Promise<void> {
|
|
941
|
+
// 重算这些用量行的引用标记(会话删除 / 日志重写之后;撤回与 fork 都不该动统计)。
|
|
942
|
+
async refreshEventUsageFlags(eventIds: readonly string[]): Promise<void> {
|
|
941
943
|
const tEventUsage = this.tables["t_event_usage"];
|
|
942
944
|
const tSessionEvents = this.tables["t_session_events"];
|
|
943
945
|
const tSessions = this.tables["t_sessions"];
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
946
|
+
for (let at = 0; at < eventIds.length; at += REFRESH_FLAG_CHUNK) {
|
|
947
|
+
const chunk = eventIds.slice(at, at + REFRESH_FLAG_CHUNK);
|
|
948
|
+
await this.db.execute(sql`
|
|
949
|
+
UPDATE ${tEventUsage} SET
|
|
950
|
+
f_referenced = (EXISTS (SELECT 1 FROM ${tSessionEvents} rb
|
|
951
|
+
WHERE rb.f_event_id = ${tEventUsage}.f_event_id))::int,
|
|
952
|
+
f_subagent = (EXISTS (SELECT 1 FROM ${tSessionEvents} sb
|
|
953
|
+
JOIN ${tSessions} ss ON ss.f_session_id = sb.f_session_id
|
|
954
|
+
WHERE sb.f_event_id = ${tEventUsage}.f_event_id
|
|
955
|
+
AND ss.f_origin = 'subagent'))::int
|
|
956
|
+
WHERE f_event_id IN (${sql.join(
|
|
957
|
+
chunk.map((id) => sql`${id}`),
|
|
958
|
+
sql`, `,
|
|
959
|
+
)})
|
|
960
|
+
`);
|
|
961
|
+
}
|
|
953
962
|
}
|
|
954
963
|
|
|
955
964
|
async deleteSessionStats(id: SessionId): Promise<void> {
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// 本仓库会话管理面 HTTP 路径的 home:整族挂在同一前缀下,与上游的 `/api` 面(connection 的 prefix 路由)
|
|
2
|
+
// 分开。同路径的 exact 路由会遮蔽上游同名路由的 GET/HEAD 通道(官方会话日志下载因此 405),
|
|
3
|
+
// 见 ADR-管理面HTTP路由前缀与上游分家。
|
|
4
|
+
export const MORLAY_SESSION_API_PREFIX = "/api/morlay/v1/session";
|
|
5
|
+
|
|
6
|
+
export const SESSION_ROWS_PATH = `${MORLAY_SESSION_API_PREFIX}/rows`;
|
|
7
|
+
|
|
8
|
+
export const SESSION_EXPORT_PATH = `${MORLAY_SESSION_API_PREFIX}/export`;
|
|
9
|
+
|
|
10
|
+
export const SESSION_IMPORT_PATH = `${MORLAY_SESSION_API_PREFIX}/import`;
|
|
11
|
+
|
|
12
|
+
export const SESSION_DELETE_PATH = `${MORLAY_SESSION_API_PREFIX}/delete`;
|
|
13
|
+
|
|
14
|
+
export const SESSION_GC_PATH = `${MORLAY_SESSION_API_PREFIX}/gc`;
|
|
15
|
+
|
|
16
|
+
export const SESSION_USAGE_PATH = `${MORLAY_SESSION_API_PREFIX}/usage`;
|
package/src/rows.ts
CHANGED
|
@@ -1,32 +1,28 @@
|
|
|
1
1
|
import type { Context } from "@deepseek-ai/cordis";
|
|
2
2
|
import type { Backend } from "./backend.ts";
|
|
3
|
+
import { SESSION_ROWS_PATH } from "./routes.ts";
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
* 官方那条(`session/list` → `ctx.sessionQuery.listSessions`)按部署策略默认排除已归档——那是给上游
|
|
8
|
-
* UI 用的。归档集的管理动作要有完整列表,所以这里单独给一条:标题直接从会话列取,最后活动时间用
|
|
9
|
-
* 该会话最后一个事件的时间。
|
|
10
|
-
*/
|
|
11
|
-
export const SESSION_ROWS_PATH = "/api/session.rows";
|
|
5
|
+
// 管理面的会话列表路由:完整语料(含归档)+ 标题 + 最后活动时间,与官方 `session/list` 分开
|
|
6
|
+
// (见 ADR-会话列表两条路)。
|
|
7
|
+
export { SESSION_ROWS_PATH };
|
|
12
8
|
|
|
13
9
|
export interface SessionRowsItem {
|
|
14
10
|
sessionId: string;
|
|
15
11
|
title: string | null;
|
|
16
|
-
|
|
12
|
+
// 会话 header 的 origin(`subagent` 表示子代理会话)。
|
|
17
13
|
origin: string | null;
|
|
18
14
|
cwd: string | null;
|
|
19
15
|
createdAt: number;
|
|
20
|
-
|
|
16
|
+
// 最后一个事件的时间(无事件时等于 `createdAt`)。
|
|
21
17
|
updatedAt: number;
|
|
22
18
|
archived: boolean;
|
|
23
|
-
|
|
19
|
+
// 所属工作区标题;不属于任何工作区时为 null。
|
|
24
20
|
workspace: string | null;
|
|
25
21
|
}
|
|
26
22
|
|
|
27
|
-
|
|
23
|
+
// 请求:搜索、子代理过滤与分页都在后端做——前端分页等于每次拉全量,数据一多就崩。
|
|
28
24
|
export interface SessionRowsRequest {
|
|
29
|
-
|
|
25
|
+
// 匹配标题或所属工作区标题(大小写不敏感)。
|
|
30
26
|
query?: string;
|
|
31
27
|
includeSubagents?: boolean;
|
|
32
28
|
page?: number;
|
|
@@ -35,13 +31,13 @@ export interface SessionRowsRequest {
|
|
|
35
31
|
|
|
36
32
|
export interface SessionRowsValue {
|
|
37
33
|
items: SessionRowsItem[];
|
|
38
|
-
|
|
34
|
+
// 过滤后的总数(分页前),前端据此算页数。
|
|
39
35
|
total: number;
|
|
40
36
|
page: number;
|
|
41
37
|
pageSize: number;
|
|
42
38
|
}
|
|
43
39
|
|
|
44
|
-
|
|
40
|
+
// 一页的条数上限:超过就按上限截断(这个接口给页面用,不是导出通道)。
|
|
45
41
|
const MAX_PAGE_SIZE = 200;
|
|
46
42
|
const DEFAULT_PAGE_SIZE = 20;
|
|
47
43
|
|
package/src/session-query.ts
CHANGED
|
@@ -11,12 +11,8 @@ export class SessionQueryRdb extends SessionQueryEngine {
|
|
|
11
11
|
super(ctx, config);
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
* 日常集合(上游 client 再用 workspace registry 的归档集做「只看归档」的派生)。
|
|
17
|
-
*
|
|
18
|
-
* 完整语料(含归档)走我们自己的管理面路由 `SESSION_ROWS_PATH`——两条路分开,不在一处混。
|
|
19
|
-
*/
|
|
14
|
+
// 官方列表(`session/list`)**默认不含已归档**:归档是「收起来」的会话,上游 client 用 workspace registry
|
|
15
|
+
// 的归档集做「只看归档」的派生;完整语料走管理面路由 `SESSION_ROWS_PATH`。
|
|
20
16
|
override async listSessions(signal?: AbortSignal): Promise<SessionRecord[]> {
|
|
21
17
|
const records = await super.listSessions(signal);
|
|
22
18
|
const archived = this.archivedSessionIds();
|
|
@@ -24,7 +20,7 @@ export class SessionQueryRdb extends SessionQueryEngine {
|
|
|
24
20
|
return records.filter((record) => !archived.has(String(record.header.id)));
|
|
25
21
|
}
|
|
26
22
|
|
|
27
|
-
|
|
23
|
+
// 归档集合来自 workspace registry;服务未就绪时保守返回空集(宁可多给,不可漏掉可见会话)。
|
|
28
24
|
private archivedSessionIds(): ReadonlySet<string> {
|
|
29
25
|
const registry = this.ctx.get("workspaceRegistry" as never) as unknown as
|
|
30
26
|
| { archivedSessionIds?: readonly string[] }
|