@morlay/session-rdb 0.0.21-alpha.5 → 0.0.21-alpha.6
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 +53 -27
- package/dist/artifact.d.mts +27 -2
- package/dist/artifact.mjs +2 -2
- package/dist/{backend-a0xDrUFG.d.mts → backend-E0bmTXpz.d.mts} +233 -27
- package/dist/{branch-Dlmbkfjj.mjs → branch-BjUR6wfe.mjs} +31 -15
- package/dist/deletion.d.mts +1 -1
- package/dist/deletion.mjs +1 -1
- package/dist/import.d.mts +1 -1
- package/dist/import.mjs +2 -3
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +4 -4
- package/dist/{log-C-rbdFwK.mjs → log-qCNkDjh5.mjs} +93 -22
- package/dist/{sqlite-C3LHLzYW.mjs → sqlite-zcXVyMxr.mjs} +85 -10
- package/dist/{src-Bn0038Sp.mjs → src-B9X8BKyh.mjs} +210 -38
- package/dist/storage.d.mts +2 -1
- package/dist/storage.mjs +1 -1
- package/dist/testing.d.mts +2 -11
- package/dist/testing.mjs +2 -19
- package/drizzle/postgres/20260921075900_v3_agent_preset/migration.sql +1 -0
- package/drizzle/postgres/20260922090001_v3_pinned_seq/migration.sql +2 -0
- package/drizzle/postgres/20260922093001_v3_last_event_at/migration.sql +10 -0
- package/drizzle/sqlite/20260921075855_v3_agent_preset/migration.sql +7 -0
- package/drizzle/sqlite/20260921075855_v3_agent_preset/snapshot.json +1041 -0
- package/drizzle/sqlite/20260922090000_v3_pinned_seq/migration.sql +4 -0
- package/drizzle/sqlite/20260922093000_v3_last_event_at/migration.sql +13 -0
- package/package.json +21 -21
- package/src/backend.ts +46 -1
- package/src/branch.ts +28 -11
- package/src/drizzle/postgres-v3.ts +1 -0
- package/src/drizzle/sqlite-v3.ts +1 -0
- package/src/entities/v3/event-usage.ts +5 -5
- package/src/entities/v3/sessions.ts +5 -0
- package/src/import.ts +3 -3
- package/src/index.ts +56 -22
- package/src/legacy.ts +14 -3
- package/src/log.ts +107 -22
- package/src/postgres.ts +65 -5
- package/src/rows.ts +125 -0
- package/src/schema.ts +3 -2
- package/src/session-query.ts +27 -1
- package/src/sqlite.ts +71 -3
- package/src/storage-takeover/projection-cache.ts +40 -15
- package/src/storage-takeover/repository.ts +34 -0
- package/src/storage-takeover/storage-backend.ts +3 -1
- package/src/testing/coordinator-contract.ts +2 -1
- package/src/testing.ts +0 -1
- package/src/write-guard.ts +5 -0
- package/src/testing/helpers.ts +0 -20
package/src/log.ts
CHANGED
|
@@ -30,6 +30,7 @@ export function rowToMeta(row: SessionRow): SessionHeader {
|
|
|
30
30
|
isSeeded: row.fSeedLength !== null,
|
|
31
31
|
...(row.fOrigin !== null ? { origin: row.fOrigin as "subagent" } : {}),
|
|
32
32
|
...(row.fDelegationDepth === null ? {} : { delegationDepth: row.fDelegationDepth }),
|
|
33
|
+
...(row.fAgentPreset === null ? {} : { agentPreset: row.fAgentPreset }),
|
|
33
34
|
};
|
|
34
35
|
}
|
|
35
36
|
|
|
@@ -47,6 +48,7 @@ export function sessionInsertRow(
|
|
|
47
48
|
fSeedLength: number | null;
|
|
48
49
|
fOrigin: string | null;
|
|
49
50
|
fDelegationDepth: number | null;
|
|
51
|
+
fAgentPreset: string | null;
|
|
50
52
|
fIncarnation: string;
|
|
51
53
|
fRevision: number;
|
|
52
54
|
} {
|
|
@@ -62,6 +64,7 @@ export function sessionInsertRow(
|
|
|
62
64
|
fSeedLength: meta.isSeeded ? storage.inheritedEventCount : null,
|
|
63
65
|
fOrigin: meta.origin ?? null,
|
|
64
66
|
fDelegationDepth: meta.delegationDepth ?? null,
|
|
67
|
+
fAgentPreset: meta.agentPreset ?? null,
|
|
65
68
|
fIncarnation: incarnation,
|
|
66
69
|
fRevision: 0,
|
|
67
70
|
};
|
|
@@ -75,6 +78,7 @@ export function sessionConflictRow(storage: SessionStorageMetadata): {
|
|
|
75
78
|
fSeedLength: number | null;
|
|
76
79
|
fOrigin: string | null;
|
|
77
80
|
fDelegationDepth: number | null;
|
|
81
|
+
fAgentPreset: string | null;
|
|
78
82
|
} {
|
|
79
83
|
const meta = storage.meta;
|
|
80
84
|
return {
|
|
@@ -85,6 +89,7 @@ export function sessionConflictRow(storage: SessionStorageMetadata): {
|
|
|
85
89
|
fSeedLength: meta.isSeeded ? storage.inheritedEventCount : null,
|
|
86
90
|
fOrigin: meta.origin ?? null,
|
|
87
91
|
fDelegationDepth: meta.delegationDepth ?? null,
|
|
92
|
+
fAgentPreset: meta.agentPreset ?? null,
|
|
88
93
|
};
|
|
89
94
|
}
|
|
90
95
|
|
|
@@ -206,32 +211,23 @@ function isDeepEqualJson(a: unknown, b: unknown): boolean {
|
|
|
206
211
|
}
|
|
207
212
|
|
|
208
213
|
function toolResultRewriteContentOnly(original: SessionEvent, replacement: SessionEvent): boolean {
|
|
214
|
+
// V4 起 `content` 就是结果内容本身(工具身份在消息顶层的 `toolCallId`),所以「只改结果」的
|
|
215
|
+
// 重写等于「除了 `content` 之外逐字相同」。
|
|
209
216
|
const originalData = original.data as Record<string, unknown>;
|
|
210
217
|
const replacementData = replacement.data as Record<string, unknown>;
|
|
211
218
|
const originalMessage = originalData["message"] as { content?: unknown } | undefined;
|
|
212
219
|
const replacementMessage = replacementData["message"] as { content?: unknown } | undefined;
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
:
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
content: [{ ...(originalContent[0] as Record<string, unknown>), content: null }],
|
|
225
|
-
},
|
|
226
|
-
};
|
|
227
|
-
const replacementRest = {
|
|
228
|
-
...replacementData,
|
|
229
|
-
message: {
|
|
230
|
-
...replacementMessage,
|
|
231
|
-
content: [{ ...(replacementContent[0] as Record<string, unknown>), content: null }],
|
|
232
|
-
},
|
|
233
|
-
};
|
|
234
|
-
return isDeepEqualJson(originalRest, replacementRest);
|
|
220
|
+
if (!Array.isArray(originalMessage?.content) || !Array.isArray(replacementMessage?.content)) {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
const rest = (data: Record<string, unknown>, message: Record<string, unknown>): unknown => ({
|
|
224
|
+
...data,
|
|
225
|
+
message: { ...message, content: null },
|
|
226
|
+
});
|
|
227
|
+
return isDeepEqualJson(
|
|
228
|
+
rest(originalData, originalMessage as Record<string, unknown>),
|
|
229
|
+
rest(replacementData, replacementMessage as Record<string, unknown>),
|
|
230
|
+
);
|
|
235
231
|
}
|
|
236
232
|
|
|
237
233
|
export function findSurfaceRepairs(events: readonly SessionEvent[]): {
|
|
@@ -464,6 +460,95 @@ export function renameLegacyPtcEvents(events: SessionEvent[]): void {
|
|
|
464
460
|
}
|
|
465
461
|
}
|
|
466
462
|
|
|
463
|
+
/**
|
|
464
|
+
* 把**旧代(v3 及更早)的消息形状**归一到当前格式(v4):迁移链(严格)拒绝那些形状时,读路径仍要
|
|
465
|
+
* 能把它们读出来——只动字段与标记,不改坐标、不改内容。
|
|
466
|
+
*
|
|
467
|
+
* 三件事:
|
|
468
|
+
* - `system/message` 的 source:`{ kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' }` → `{ kind: 'system-prompt' }`
|
|
469
|
+
* (v4 起 system 消息只认这个 kind);
|
|
470
|
+
* - `tool/result` 的消息:v3 是 user 角色、结果块包在 `content[0]`;v4 是 `role: 'tool'` + 顶层
|
|
471
|
+
* `toolCallId` + `content` 就是结果块;
|
|
472
|
+
* - 本仓库自己写过、上游已删除的事件类型(见 `LEGACY_OWN_EVENT_TYPES`)标 `ignorable: true`——v4 的
|
|
473
|
+
* 校验对未知类型只接受可忽略信封。
|
|
474
|
+
*
|
|
475
|
+
* 只认这张白名单:**其它未知类型保持 fail loud**(那是「数据来自更新的 harness」的信号,不能吞掉)。
|
|
476
|
+
*/
|
|
477
|
+
export function normalizeToCurrentShape(events: SessionEvent[]): void {
|
|
478
|
+
for (const event of events) {
|
|
479
|
+
const type = (event as { type: string }).type;
|
|
480
|
+
if (LEGACY_OWN_EVENT_TYPES.has(type)) {
|
|
481
|
+
(event as { ignorable?: true }).ignorable = true;
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
if (type === "system/message") {
|
|
485
|
+
const message = (event.data as unknown as Record<string, unknown>)["message"];
|
|
486
|
+
liftSystemMessageSource(message);
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
if (type === "tool/result") {
|
|
490
|
+
const message = (event.data as unknown as Record<string, unknown>)["message"];
|
|
491
|
+
liftToolResultMessage(message);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function liftSystemMessageSource(message: unknown): void {
|
|
497
|
+
if (typeof message !== "object" || message === null) return;
|
|
498
|
+
const record = message as Record<string, unknown>;
|
|
499
|
+
const source = record["source"];
|
|
500
|
+
if (typeof source !== "object" || source === null) return;
|
|
501
|
+
if ((source as Record<string, unknown>)["kind"] !== "plugin") return;
|
|
502
|
+
record["source"] = { kind: "system-prompt" };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function liftToolResultMessage(message: unknown): void {
|
|
506
|
+
if (typeof message !== "object" || message === null) return;
|
|
507
|
+
const record = message as Record<string, unknown>;
|
|
508
|
+
if (record["role"] === "tool" && typeof record["toolCallId"] === "string") return;
|
|
509
|
+
const source = record["source"] as Record<string, unknown> | undefined;
|
|
510
|
+
const block = Array.isArray(record["content"]) ? record["content"][0] : undefined;
|
|
511
|
+
if (typeof block !== "object" || block === null) return;
|
|
512
|
+
const lifted = block as Record<string, unknown>;
|
|
513
|
+
const callId = lifted["toolCallId"] ?? source?.["callId"];
|
|
514
|
+
if (typeof callId !== "string") return;
|
|
515
|
+
record["role"] = "tool";
|
|
516
|
+
record["toolCallId"] = callId;
|
|
517
|
+
record["content"] = Array.isArray(lifted["content"]) ? lifted["content"] : [];
|
|
518
|
+
if (typeof lifted["isError"] === "boolean") record["isError"] = lifted["isError"];
|
|
519
|
+
record["source"] = { kind: "tool", callId };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* 本仓库写过、后来连同机制一起删除的事件类型:读的时候标 `ignorable: true` 让校验跳过它们。
|
|
524
|
+
* 新增历史类型时登记到这里——**不要**用「所有未知类型」代替(那会掩盖更新版本的未知事件)。
|
|
525
|
+
*/
|
|
526
|
+
export const LEGACY_OWN_EVENT_TYPES: ReadonlySet<string> = new Set(["session-branch/version"]);
|
|
527
|
+
|
|
528
|
+
/** 行里是否还带着旧代消息形状(`session-branch/version` 这类自造事件也算)——写路径曾把回退视图的结果
|
|
529
|
+
* 以当前版本号落库,所以**版本号不可信**,读之前要按内容判一次。 */
|
|
530
|
+
export function hasLegacyShape(events: readonly SessionEvent[]): boolean {
|
|
531
|
+
for (const event of events) {
|
|
532
|
+
const type = (event as { type: string }).type;
|
|
533
|
+
if (LEGACY_OWN_EVENT_TYPES.has(type)) return true;
|
|
534
|
+
if (type === "system/message") {
|
|
535
|
+
const message = (event.data as unknown as Record<string, unknown>)["message"] as
|
|
536
|
+
| Record<string, unknown>
|
|
537
|
+
| undefined;
|
|
538
|
+
const source = message?.["source"] as Record<string, unknown> | undefined;
|
|
539
|
+
if (source?.["kind"] === "plugin") return true;
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
if (type === "tool/result") {
|
|
543
|
+
const message = (event.data as unknown as Record<string, unknown>)["message"] as
|
|
544
|
+
| Record<string, unknown>
|
|
545
|
+
| undefined;
|
|
546
|
+
if (message !== undefined && message["role"] !== "tool") return true;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return false;
|
|
550
|
+
}
|
|
551
|
+
|
|
467
552
|
export function repairReadView(events: SessionEvent[]): void {
|
|
468
553
|
repairAssistantSettlement(events);
|
|
469
554
|
repairRequestHeaders(events);
|
package/src/postgres.ts
CHANGED
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
type EventCountBucket,
|
|
14
14
|
type EventInsert,
|
|
15
15
|
type EventRow,
|
|
16
|
+
type SessionListRowsPage,
|
|
17
|
+
type SessionListRowsQuery,
|
|
16
18
|
type SessionRow,
|
|
17
19
|
} from "./backend.ts";
|
|
18
20
|
import { toPostgresSchema } from "./adapters/index.ts";
|
|
@@ -283,7 +285,7 @@ export class PostgresBackend implements Backend {
|
|
|
283
285
|
insertBridges: (rows) => this.insertBridges(tx, rows),
|
|
284
286
|
updateHead: (id, headEventId, headSequence) =>
|
|
285
287
|
this.updateHead(tx, id, headEventId, headSequence),
|
|
286
|
-
bumpRevision: (id) => this.bumpRevision(tx, id),
|
|
288
|
+
bumpRevision: (id, lastEventAt) => this.bumpRevision(tx, id, lastEventAt),
|
|
287
289
|
refreshTitle: (id) => this.refreshTitle(tx, id),
|
|
288
290
|
deleteBridgeTail: (id, fromSequence) => this.deleteBridgeTail(tx, id, fromSequence),
|
|
289
291
|
getPrevBridge: (id, sequence) => this.getPrevBridge(tx, id, sequence),
|
|
@@ -425,11 +427,18 @@ export class PostgresBackend implements Backend {
|
|
|
425
427
|
private async bumpRevision(
|
|
426
428
|
exec: PgAsyncDatabase<NodePgQueryResultHKT>,
|
|
427
429
|
id: SessionId,
|
|
430
|
+
lastEventAt?: number,
|
|
428
431
|
): Promise<void> {
|
|
432
|
+
const sessions = this.tables["t_sessions"];
|
|
429
433
|
await exec
|
|
430
|
-
.update(
|
|
431
|
-
.set({
|
|
432
|
-
|
|
434
|
+
.update(sessions)
|
|
435
|
+
.set({
|
|
436
|
+
fRevision: sql`${sessions.fRevision} + 1`,
|
|
437
|
+
...(lastEventAt === undefined
|
|
438
|
+
? {}
|
|
439
|
+
: { fLastEventAt: sql`GREATEST(COALESCE(${sessions.fLastEventAt}, 0), ${lastEventAt})` }),
|
|
440
|
+
})
|
|
441
|
+
.where(eq(sessions.fSessionId, id))
|
|
433
442
|
.execute();
|
|
434
443
|
}
|
|
435
444
|
|
|
@@ -598,6 +607,57 @@ export class PostgresBackend implements Backend {
|
|
|
598
607
|
}
|
|
599
608
|
|
|
600
609
|
/** 用量聚合:与 SQLite 侧同形,读 `t_event_usage`,数值列回来是字符串。 */
|
|
610
|
+
async listSessionRows(query: SessionListRowsQuery = {}): Promise<SessionListRowsPage> {
|
|
611
|
+
const needle = query.query?.trim().toLowerCase() ?? "";
|
|
612
|
+
const like = `%${needle}%`;
|
|
613
|
+
const includeSubagents = query.includeSubagents === true;
|
|
614
|
+
const limit = query.limit ?? 20;
|
|
615
|
+
const offset = query.offset ?? 0;
|
|
616
|
+
const tSessions = this.tables["t_sessions"];
|
|
617
|
+
const tWorkspaces = this.tables["t_workspaces"];
|
|
618
|
+
const tWorkspaceSessions = this.tables["t_workspace_sessions"];
|
|
619
|
+
// 归属可能有多行(一个会话可在多个工作区):取 position 最小的那条,避免放大结果行数。
|
|
620
|
+
const workspaceTitle = sql`(SELECT w.f_title FROM ${tWorkspaceSessions} b
|
|
621
|
+
JOIN ${tWorkspaces} w ON w.f_workspace_id = b.f_workspace_id
|
|
622
|
+
WHERE b.f_session_id = s.f_session_id
|
|
623
|
+
ORDER BY b.f_position LIMIT 1)`;
|
|
624
|
+
const where = sql`WHERE (${needle} = ''
|
|
625
|
+
OR lower(COALESCE(s.f_title, '')) LIKE ${like}
|
|
626
|
+
OR lower(COALESCE(${workspaceTitle}, '')) LIKE ${like})
|
|
627
|
+
AND (${includeSubagents} OR s.f_origin IS DISTINCT FROM 'subagent')`;
|
|
628
|
+
const rows = (await this.db.execute(sql`
|
|
629
|
+
SELECT s.f_session_id AS session_id,
|
|
630
|
+
s.f_title AS title,
|
|
631
|
+
s.f_origin AS origin,
|
|
632
|
+
s.f_cwd AS cwd,
|
|
633
|
+
s.f_created_at AS created_at,
|
|
634
|
+
(s.f_archived_at IS NOT NULL) AS archived,
|
|
635
|
+
COALESCE(s.f_last_event_at, s.f_created_at) AS updated_at,
|
|
636
|
+
${workspaceTitle} AS workspace
|
|
637
|
+
FROM ${tSessions} s
|
|
638
|
+
${where}
|
|
639
|
+
ORDER BY updated_at DESC, s.f_session_id
|
|
640
|
+
LIMIT ${limit} OFFSET ${offset}
|
|
641
|
+
`)) as unknown as { rows: Array<Record<string, unknown>> };
|
|
642
|
+
const counted = (await this.db.execute(sql`
|
|
643
|
+
SELECT count(*) AS n FROM ${tSessions} s ${where}
|
|
644
|
+
`)) as unknown as { rows: Array<Record<string, unknown>> };
|
|
645
|
+
return {
|
|
646
|
+
items: rows.rows.map((row) => ({
|
|
647
|
+
sessionId: typeof row["session_id"] === "string" ? row["session_id"] : String(row["session_id"]),
|
|
648
|
+
title: typeof row["title"] === "string" ? row["title"] : null,
|
|
649
|
+
origin: typeof row["origin"] === "string" ? row["origin"] : null,
|
|
650
|
+
cwd: typeof row["cwd"] === "string" ? row["cwd"] : null,
|
|
651
|
+
createdAt: numeric(row["created_at"]),
|
|
652
|
+
updatedAt: numeric(row["updated_at"]),
|
|
653
|
+
archived: row["archived"] === true,
|
|
654
|
+
subagent: row["origin"] === "subagent",
|
|
655
|
+
workspace: typeof row["workspace"] === "string" ? row["workspace"] : null,
|
|
656
|
+
})),
|
|
657
|
+
total: numeric(counted.rows[0]?.["n"] ?? 0),
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
601
661
|
async usageReport(sinceMs?: number): Promise<UsageAggregate> {
|
|
602
662
|
const usageSince = sinceMs === undefined ? sql`` : sql` AND u.f_created_at >= ${sinceMs}`;
|
|
603
663
|
// 活动计数走派生表的「本地日」列(与桶的本地日口径一致)。
|
|
@@ -703,7 +763,7 @@ export class PostgresBackend implements Backend {
|
|
|
703
763
|
...tokenTotalsOf(row),
|
|
704
764
|
})),
|
|
705
765
|
sessions: tokenSessions.rows.map((row) => ({
|
|
706
|
-
sessionId: String(row["session_id"]),
|
|
766
|
+
sessionId: typeof row["session_id"] === "string" ? row["session_id"] : String(row["session_id"]),
|
|
707
767
|
title: text(row["title"]),
|
|
708
768
|
subagent: row["subagent"] === true,
|
|
709
769
|
archived: row["archived"] === true,
|
package/src/rows.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { Context } from "@deepseek-ai/cordis";
|
|
2
|
+
import type { Backend } from "./backend.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 管理面的会话列表:**我们自己的**一条路由(完整语料,含归档),与官方 `session/list` 分开。
|
|
6
|
+
*
|
|
7
|
+
* 官方那条(`session/list` → `ctx.sessionQuery.listSessions`)按部署策略默认排除已归档——那是给上游
|
|
8
|
+
* UI 用的。归档集的管理动作要有完整列表,所以这里单独给一条:标题直接从会话列取,最后活动时间用
|
|
9
|
+
* 该会话最后一个事件的时间。
|
|
10
|
+
*/
|
|
11
|
+
export const SESSION_ROWS_PATH = "/api/session.rows";
|
|
12
|
+
|
|
13
|
+
export interface SessionRowsItem {
|
|
14
|
+
sessionId: string;
|
|
15
|
+
title: string | null;
|
|
16
|
+
/** 会话 header 的 origin(`subagent` 表示子代理会话)。 */
|
|
17
|
+
origin: string | null;
|
|
18
|
+
cwd: string | null;
|
|
19
|
+
createdAt: number;
|
|
20
|
+
/** 最后一个事件的时间(无事件时等于 `createdAt`)。 */
|
|
21
|
+
updatedAt: number;
|
|
22
|
+
archived: boolean;
|
|
23
|
+
/** 所属工作区标题;不属于任何工作区时为 null。 */
|
|
24
|
+
workspace: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 请求:搜索、子代理过滤与分页都在后端做——前端分页等于每次拉全量,数据一多就崩。 */
|
|
28
|
+
export interface SessionRowsRequest {
|
|
29
|
+
/** 匹配标题或所属工作区标题(大小写不敏感)。 */
|
|
30
|
+
query?: string;
|
|
31
|
+
includeSubagents?: boolean;
|
|
32
|
+
page?: number;
|
|
33
|
+
pageSize?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SessionRowsValue {
|
|
37
|
+
items: SessionRowsItem[];
|
|
38
|
+
/** 过滤后的总数(分页前),前端据此算页数。 */
|
|
39
|
+
total: number;
|
|
40
|
+
page: number;
|
|
41
|
+
pageSize: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 一页的条数上限:超过就按上限截断(这个接口给页面用,不是导出通道)。 */
|
|
45
|
+
const MAX_PAGE_SIZE = 200;
|
|
46
|
+
const DEFAULT_PAGE_SIZE = 20;
|
|
47
|
+
|
|
48
|
+
export function registerSessionRows(ctx: Context, backend: Backend): void {
|
|
49
|
+
ctx.inject(["webServer", "connection"] as const, (webCtx) => {
|
|
50
|
+
const webServer = webCtx.webServer as unknown as {
|
|
51
|
+
register(route: {
|
|
52
|
+
kind: "exact";
|
|
53
|
+
path: string;
|
|
54
|
+
handler: (
|
|
55
|
+
req: import("node:http").IncomingMessage,
|
|
56
|
+
res: import("node:http").ServerResponse,
|
|
57
|
+
) => void | Promise<void>;
|
|
58
|
+
}): () => void;
|
|
59
|
+
};
|
|
60
|
+
const connection = webCtx.get("connection") as unknown as {
|
|
61
|
+
requestRejection(request: {
|
|
62
|
+
headers: import("node:http").IncomingHttpHeaders;
|
|
63
|
+
}): number | undefined;
|
|
64
|
+
};
|
|
65
|
+
return webCtx.effect(
|
|
66
|
+
() =>
|
|
67
|
+
webServer.register({
|
|
68
|
+
kind: "exact",
|
|
69
|
+
path: SESSION_ROWS_PATH,
|
|
70
|
+
handler: async (req, res) => {
|
|
71
|
+
const rejection = connection.requestRejection(req);
|
|
72
|
+
if (rejection !== undefined) {
|
|
73
|
+
res.writeHead(rejection);
|
|
74
|
+
res.end(rejection === 401 ? "unauthorized" : "forbidden");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (req.method !== "POST") {
|
|
78
|
+
res.writeHead(405, { "content-type": "application/json" });
|
|
79
|
+
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const chunks: Buffer[] = [];
|
|
83
|
+
for await (const chunk of req) chunks.push(chunk as Buffer);
|
|
84
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
85
|
+
let envelope: SessionRowsRequest = {};
|
|
86
|
+
if (raw !== "") {
|
|
87
|
+
try {
|
|
88
|
+
envelope = JSON.parse(raw) as SessionRowsRequest;
|
|
89
|
+
} catch {
|
|
90
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
91
|
+
res.end(JSON.stringify({ error: "request body is not JSON" }));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const page = Number.isSafeInteger(envelope.page) && (envelope.page ?? 0) > 0 ? envelope.page! : 1;
|
|
96
|
+
const requested = envelope.pageSize;
|
|
97
|
+
const pageSize =
|
|
98
|
+
Number.isSafeInteger(requested) && (requested ?? 0) > 0
|
|
99
|
+
? Math.min(requested!, MAX_PAGE_SIZE)
|
|
100
|
+
: DEFAULT_PAGE_SIZE;
|
|
101
|
+
try {
|
|
102
|
+
const { items, total } = await backend.listSessionRows({
|
|
103
|
+
...(envelope.query === undefined ? {} : { query: envelope.query }),
|
|
104
|
+
...(envelope.includeSubagents === undefined
|
|
105
|
+
? {}
|
|
106
|
+
: { includeSubagents: envelope.includeSubagents }),
|
|
107
|
+
limit: pageSize,
|
|
108
|
+
offset: (page - 1) * pageSize,
|
|
109
|
+
});
|
|
110
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
111
|
+
res.end(JSON.stringify({ items, total, page, pageSize } satisfies SessionRowsValue));
|
|
112
|
+
} catch (error: unknown) {
|
|
113
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
114
|
+
res.end(
|
|
115
|
+
JSON.stringify({
|
|
116
|
+
error: error instanceof Error ? error.message : "session rows failed",
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
}),
|
|
122
|
+
"session-rdb: session rows route",
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
}
|
package/src/schema.ts
CHANGED
|
@@ -160,8 +160,9 @@ export function eventDimensions(event: SessionEvent): {
|
|
|
160
160
|
case "assistant/message":
|
|
161
161
|
return { kind, role: "assistant", name: "", actionId: "" };
|
|
162
162
|
case "tool/result": {
|
|
163
|
-
|
|
164
|
-
|
|
163
|
+
// V4 起结果消息自己带 `toolCallId`(不再包在 content 的一个 `tool-result` 块里)。
|
|
164
|
+
const message = data["message"] as { toolCallId?: string } | undefined;
|
|
165
|
+
return { kind, role: "tool", name: "", actionId: message?.toolCallId ?? "" };
|
|
165
166
|
}
|
|
166
167
|
case "tool/call":
|
|
167
168
|
return {
|
package/src/session-query.ts
CHANGED
|
@@ -1,11 +1,37 @@
|
|
|
1
1
|
import type { Context } from "@deepseek-ai/cordis";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
SessionQueryEngine,
|
|
4
|
+
SessionQueryError,
|
|
5
|
+
type Config,
|
|
6
|
+
type SessionRecord,
|
|
7
|
+
} from "@deepseek-ai/dsh-session-query";
|
|
3
8
|
|
|
4
9
|
export class SessionQueryRdb extends SessionQueryEngine {
|
|
5
10
|
constructor(ctx: Context, config: Config = {}) {
|
|
6
11
|
super(ctx, config);
|
|
7
12
|
}
|
|
8
13
|
|
|
14
|
+
/**
|
|
15
|
+
* 官方列表(`session/list`)**默认不含已归档**:归档是「收起来」的会话,列表与搜索都不该把它混进
|
|
16
|
+
* 日常集合(上游 client 再用 workspace registry 的归档集做「只看归档」的派生)。
|
|
17
|
+
*
|
|
18
|
+
* 完整语料(含归档)走我们自己的管理面路由 `SESSION_ROWS_PATH`——两条路分开,不在一处混。
|
|
19
|
+
*/
|
|
20
|
+
override async listSessions(signal?: AbortSignal): Promise<SessionRecord[]> {
|
|
21
|
+
const records = await super.listSessions(signal);
|
|
22
|
+
const archived = this.archivedSessionIds();
|
|
23
|
+
if (archived.size === 0) return records;
|
|
24
|
+
return records.filter((record) => !archived.has(String(record.header.id)));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 归档集合来自 workspace registry;服务未就绪时保守返回空集(宁可多给,不可漏掉可见会话)。 */
|
|
28
|
+
private archivedSessionIds(): ReadonlySet<string> {
|
|
29
|
+
const registry = this.ctx.get("workspaceRegistry" as never) as unknown as
|
|
30
|
+
| { archivedSessionIds?: readonly string[] }
|
|
31
|
+
| undefined;
|
|
32
|
+
return new Set<string>(registry?.archivedSessionIds ?? []);
|
|
33
|
+
}
|
|
34
|
+
|
|
9
35
|
override async searchSessions(): Promise<never> {
|
|
10
36
|
throw searchDisabled();
|
|
11
37
|
}
|
package/src/sqlite.ts
CHANGED
|
@@ -14,6 +14,9 @@ import {
|
|
|
14
14
|
type EventCountBucket,
|
|
15
15
|
type EventInsert,
|
|
16
16
|
type EventRow,
|
|
17
|
+
type SessionListRowRecord,
|
|
18
|
+
type SessionListRowsPage,
|
|
19
|
+
type SessionListRowsQuery,
|
|
17
20
|
type SessionRow,
|
|
18
21
|
} from "./backend.ts";
|
|
19
22
|
import { sessionConflictRow, sessionInsertRow, titleOfEventData, usageRowOf } from "./log.ts";
|
|
@@ -139,6 +142,20 @@ function backfillEventUsage(db: DatabaseSync): void {
|
|
|
139
142
|
OR json_extract(e.f_data, '$.usage') IS NOT NULL)`);
|
|
140
143
|
}
|
|
141
144
|
|
|
145
|
+
function sessionListRowOf(row: Record<string, unknown>): SessionListRowRecord {
|
|
146
|
+
return {
|
|
147
|
+
sessionId: typeof row["session_id"] === "string" ? row["session_id"] : String(row["session_id"]),
|
|
148
|
+
title: typeof row["title"] === "string" ? row["title"] : null,
|
|
149
|
+
origin: typeof row["origin"] === "string" ? row["origin"] : null,
|
|
150
|
+
cwd: typeof row["cwd"] === "string" ? row["cwd"] : null,
|
|
151
|
+
createdAt: Number(row["created_at"]),
|
|
152
|
+
updatedAt: Number(row["updated_at"]),
|
|
153
|
+
archived: row["archived"] === 1 || row["archived"] === true,
|
|
154
|
+
subagent: row["origin"] === "subagent",
|
|
155
|
+
workspace: typeof row["workspace"] === "string" ? row["workspace"] : null,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
142
159
|
/** SQL 的缺失求和是 NULL:没有 usage 字段的行使该字段计 0。 */
|
|
143
160
|
function tokenTotalsOf(row: {
|
|
144
161
|
input_tokens: number | null;
|
|
@@ -495,7 +512,7 @@ export class SqliteBackend implements Backend {
|
|
|
495
512
|
insertEvents: (events) => this.insertEvents(events),
|
|
496
513
|
insertBridges: (rows) => this.insertBridges(rows),
|
|
497
514
|
updateHead: (id, headEventId, headSequence) => this.updateHead(id, headEventId, headSequence),
|
|
498
|
-
bumpRevision: (id) => this.bumpRevision(id),
|
|
515
|
+
bumpRevision: (id, lastEventAt) => this.bumpRevision(id, lastEventAt),
|
|
499
516
|
refreshTitle: (id) => this.refreshTitle(id),
|
|
500
517
|
deleteBridgeTail: (id, fromSequence) => this.deleteBridgeTail(id, fromSequence),
|
|
501
518
|
getPrevBridge: (id, sequence) => this.getPrevBridge(id, sequence),
|
|
@@ -595,10 +612,15 @@ export class SqliteBackend implements Backend {
|
|
|
595
612
|
.run();
|
|
596
613
|
}
|
|
597
614
|
|
|
598
|
-
private async bumpRevision(id: SessionId): Promise<void> {
|
|
615
|
+
private async bumpRevision(id: SessionId, lastEventAt?: number): Promise<void> {
|
|
599
616
|
this.db
|
|
600
617
|
.update(tSessions)
|
|
601
|
-
.set({
|
|
618
|
+
.set({
|
|
619
|
+
fRevision: sql`${tSessions.fRevision} + 1`,
|
|
620
|
+
...(lastEventAt === undefined
|
|
621
|
+
? {}
|
|
622
|
+
: { fLastEventAt: sql`MAX(COALESCE(${tSessions.fLastEventAt}, 0), ${lastEventAt})` }),
|
|
623
|
+
})
|
|
602
624
|
.where(eq(tSessions.fSessionId, id))
|
|
603
625
|
.run();
|
|
604
626
|
}
|
|
@@ -692,6 +714,52 @@ export class SqliteBackend implements Backend {
|
|
|
692
714
|
* 沿 `t_events` 的事件类型数——两者限定在**有 token 用量的会话**里,时间范围各自按自己的
|
|
693
715
|
* `f_created_at`(同一时刻写入,口径一致)。
|
|
694
716
|
*/
|
|
717
|
+
async listSessionRows(query: SessionListRowsQuery = {}): Promise<SessionListRowsPage> {
|
|
718
|
+
const needle = query.query?.trim().toLowerCase() ?? "";
|
|
719
|
+
const like = `%${needle}%`;
|
|
720
|
+
// 排序键用物化列:`f_last_event_at` 由写路径维护、老数据由迁移回填,没有事件的会话回落 createdAt。
|
|
721
|
+
// 早先这里对每行算一次「该会话最后事件时间」的相关子查询——243 行要 4.2s,分页也救不了(排序要求
|
|
722
|
+
// 全表先算出来)。
|
|
723
|
+
const where = `WHERE (? = '' OR lower(COALESCE(s.f_title, '')) LIKE ? OR lower(COALESCE(w.f_title, '')) LIKE ?)
|
|
724
|
+
AND (? = 1 OR s.f_origin IS NOT 'subagent')`;
|
|
725
|
+
const filterParams = [needle, like, like, query.includeSubagents === true ? 1 : 0];
|
|
726
|
+
const limit = query.limit ?? 20;
|
|
727
|
+
const offset = query.offset ?? 0;
|
|
728
|
+
const rows = this.db.$client
|
|
729
|
+
.prepare(
|
|
730
|
+
`SELECT s.f_session_id AS session_id,
|
|
731
|
+
s.f_title AS title,
|
|
732
|
+
s.f_origin AS origin,
|
|
733
|
+
s.f_cwd AS cwd,
|
|
734
|
+
s.f_created_at AS created_at,
|
|
735
|
+
(s.f_archived_at IS NOT NULL) AS archived,
|
|
736
|
+
COALESCE(s.f_last_event_at, s.f_created_at) AS updated_at,
|
|
737
|
+
w.f_title AS workspace
|
|
738
|
+
FROM t_sessions s
|
|
739
|
+
LEFT JOIN t_workspaces w ON w.f_workspace_id = (
|
|
740
|
+
SELECT b.f_workspace_id FROM t_workspace_sessions b
|
|
741
|
+
WHERE b.f_session_id = s.f_session_id ORDER BY b.f_position LIMIT 1)
|
|
742
|
+
${where}
|
|
743
|
+
ORDER BY updated_at DESC, s.f_session_id
|
|
744
|
+
LIMIT ? OFFSET ?`,
|
|
745
|
+
)
|
|
746
|
+
.all(...filterParams, limit, offset) as unknown as Array<Record<string, unknown>>;
|
|
747
|
+
const counted = this.db.$client
|
|
748
|
+
.prepare(
|
|
749
|
+
`SELECT count(*) AS n
|
|
750
|
+
FROM t_sessions s
|
|
751
|
+
LEFT JOIN t_workspaces w ON w.f_workspace_id = (
|
|
752
|
+
SELECT b.f_workspace_id FROM t_workspace_sessions b
|
|
753
|
+
WHERE b.f_session_id = s.f_session_id ORDER BY b.f_position LIMIT 1)
|
|
754
|
+
${where}`,
|
|
755
|
+
)
|
|
756
|
+
.get(...filterParams) as { n: number };
|
|
757
|
+
return {
|
|
758
|
+
items: rows.map((row) => sessionListRowOf(row)),
|
|
759
|
+
total: Number(counted.n),
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
|
|
695
763
|
async usageReport(sinceMs?: number): Promise<UsageAggregate> {
|
|
696
764
|
const usageSince = sinceMs === undefined ? "" : " AND u.f_created_at >= ?";
|
|
697
765
|
const params = sinceMs === undefined ? [] : [sinceMs];
|