@morlay/session-rdb 0.0.19 → 0.0.21-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -31
- package/dist/artifact.d.mts +25 -65
- package/dist/artifact.mjs +3 -3
- package/dist/{schema-DPcuEh_a.d.mts → backend-DpdtxYpz.d.mts} +360 -107
- package/dist/branch-5JzX9rUq.mjs +390 -0
- package/dist/deletion.d.mts +7 -0
- package/dist/deletion.mjs +2 -0
- package/dist/dist-vIVO6bA-.mjs +1524 -0
- package/dist/import.d.mts +4 -4
- package/dist/import.mjs +214 -1
- package/dist/index.d.mts +2 -3
- package/dist/index.mjs +4 -1630
- package/dist/{log-DO69NQnn.mjs → log-CnYct2Dv.mjs} +37 -82
- package/dist/{sqlite-DYExtbLo.mjs → sqlite-fpvm5Dzs.mjs} +205 -75
- package/dist/src-CWTWV7vx.mjs +1904 -0
- package/dist/storage.d.mts +14 -5
- package/dist/storage.mjs +2 -2
- package/dist/testing.d.mts +2 -26
- package/dist/testing.mjs +10504 -9614
- package/drizzle/postgres/20260918120000_v3_event_usage/migration.sql +14 -0
- package/drizzle/postgres/20260918120000_v3_event_usage/snapshot.json +1309 -0
- package/drizzle/sqlite/20260918120000_v3_event_usage/migration.sql +14 -0
- package/drizzle/sqlite/20260918120000_v3_event_usage/snapshot.json +1031 -0
- package/package.json +29 -30
- package/src/adapters/to-postgres.ts +1 -3
- package/src/adapters/to-sqlite.ts +0 -2
- package/src/adapters/types.ts +0 -3
- package/src/artifact.ts +0 -2
- package/src/backend.ts +31 -2
- package/src/branch.ts +223 -135
- package/src/deletion.ts +87 -0
- package/src/drizzle/postgres-v2.ts +0 -1
- package/src/drizzle/postgres-v3.ts +0 -1
- package/src/drizzle/sqlite-v2.ts +0 -1
- package/src/drizzle/sqlite-v3.ts +0 -1
- package/src/entities/v2/session-events.ts +0 -1
- package/src/entities/v3/event-usage.ts +25 -0
- package/src/entities/v3/events.ts +1 -3
- package/src/entities/v3/index.ts +4 -0
- package/src/entities/v3/session-events.ts +1 -2
- package/src/entities/v3/session-projcache-rows.ts +0 -8
- package/src/entities/v3/sessions.ts +3 -3
- package/src/entities/v3/storage-units.ts +0 -1
- package/src/entities/v3/workspace-sessions.ts +0 -5
- package/src/entities/v3/workspace-state.ts +0 -6
- package/src/entities/v3/workspaces.ts +0 -5
- package/src/export.ts +103 -0
- package/src/gc.ts +76 -0
- package/src/import-storages.ts +4 -37
- package/src/import.ts +54 -31
- package/src/index.ts +150 -114
- package/src/legacy.ts +4 -42
- package/src/log.ts +63 -106
- package/src/postgres.ts +249 -22
- package/src/schema.ts +6 -6
- package/src/session-query.ts +0 -4
- package/src/sqlite.ts +231 -55
- package/src/storage-takeover/index.ts +2 -28
- package/src/storage-takeover/projection-cache.ts +30 -134
- package/src/storage-takeover/repository.ts +20 -59
- package/src/storage-takeover/storage-backend.ts +3 -33
- package/src/storage-takeover/types.ts +14 -56
- package/src/storage.ts +0 -2
- package/src/testing/contract.ts +19 -50
- package/src/testing/coordinator-contract.ts +10 -25
- package/src/testing.ts +2 -2
- package/src/usage.ts +191 -0
- package/dist/import-Bc2QQa5G.mjs +0 -473
- package/dist/index-CCcK9tia.d.mts +0 -270
- package/dist/magic-string.es-BgJoa-3K.mjs +0 -1017
package/src/usage.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type { Context } from "@deepseek-ai/cordis";
|
|
2
|
+
import type { SessionPersistenceRdb } from "./index.ts";
|
|
3
|
+
|
|
4
|
+
export const SESSION_USAGE_PATH = "/api/session.usage";
|
|
5
|
+
|
|
6
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 时间范围的语义键:`all` 不限;`day` / `week` 是**本地自然日 / 自然周**(周一起算);
|
|
10
|
+
* `7d` / `30d` / `90d` 是最近 N 天(滚动窗口)。
|
|
11
|
+
*/
|
|
12
|
+
export type UsageRangeKey = "all" | "day" | "week" | "7d" | "30d" | "90d";
|
|
13
|
+
|
|
14
|
+
const ROLLING_DAYS: Record<"7d" | "30d" | "90d", number> = { "7d": 7, "30d": 30, "90d": 90 };
|
|
15
|
+
|
|
16
|
+
function localDayStart(now: number): Date {
|
|
17
|
+
const start = new Date(now);
|
|
18
|
+
start.setHours(0, 0, 0, 0);
|
|
19
|
+
return start;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 范围起点(含),不限时为 undefined。自然日 / 自然周按 host 的本地时区算,周边界是周一 00:00。
|
|
24
|
+
* @param range - 语义键。
|
|
25
|
+
* @param now - 当前时刻。
|
|
26
|
+
* @returns 起点毫秒时间戳,或 undefined。
|
|
27
|
+
*/
|
|
28
|
+
export function resolveUsageSince(range: UsageRangeKey, now: number): number | undefined {
|
|
29
|
+
switch (range) {
|
|
30
|
+
case "all":
|
|
31
|
+
return undefined;
|
|
32
|
+
case "day":
|
|
33
|
+
return localDayStart(now).getTime();
|
|
34
|
+
case "week": {
|
|
35
|
+
const start = localDayStart(now);
|
|
36
|
+
// getDay(): 0 = 周日;换算成「距离本周一几天」。
|
|
37
|
+
start.setDate(start.getDate() - ((start.getDay() + 6) % 7));
|
|
38
|
+
return start.getTime();
|
|
39
|
+
}
|
|
40
|
+
default:
|
|
41
|
+
return now - ROLLING_DAYS[range] * DAY_MS;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseUsageRange(value: unknown): UsageRangeKey {
|
|
46
|
+
return value === "day" || value === "week" || value === "7d" || value === "30d" || value === "90d"
|
|
47
|
+
? value
|
|
48
|
+
: "all";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 一段用量合计(token 字段直接取事件里模型报的 usage)。 */
|
|
52
|
+
export interface UsageTotals {
|
|
53
|
+
events: number;
|
|
54
|
+
inputTokens: number;
|
|
55
|
+
outputTokens: number;
|
|
56
|
+
cacheReadTokens: number;
|
|
57
|
+
reasoningTokens: number;
|
|
58
|
+
totalTokens: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 一天 × 一个模型 × 是否子代理 的用量桶:总览、按天、按模型都由它折叠。 */
|
|
62
|
+
export interface UsageBucket extends UsageTotals {
|
|
63
|
+
day: string;
|
|
64
|
+
provider: string | null;
|
|
65
|
+
model: string | null;
|
|
66
|
+
subagent: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 一条会话的用量行。 */
|
|
70
|
+
export interface UsageSessionRow extends UsageTotals {
|
|
71
|
+
sessionId: string;
|
|
72
|
+
title: string | null;
|
|
73
|
+
subagent: boolean;
|
|
74
|
+
archived: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 后端的原始聚合结果。 */
|
|
78
|
+
export interface UsageAggregate {
|
|
79
|
+
buckets: UsageBucket[];
|
|
80
|
+
sessions: UsageSessionRow[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 一次统计请求的完整回报。 */
|
|
84
|
+
export interface SessionUsageReport {
|
|
85
|
+
totals: UsageTotals;
|
|
86
|
+
subagent: UsageTotals;
|
|
87
|
+
/** 只被人类会话引用的部分(`totals − subagent`)。 */
|
|
88
|
+
human: UsageTotals;
|
|
89
|
+
buckets: UsageBucket[];
|
|
90
|
+
sessions: UsageSessionRow[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function emptyTotals(): UsageTotals {
|
|
94
|
+
return {
|
|
95
|
+
events: 0,
|
|
96
|
+
inputTokens: 0,
|
|
97
|
+
outputTokens: 0,
|
|
98
|
+
cacheReadTokens: 0,
|
|
99
|
+
reasoningTokens: 0,
|
|
100
|
+
totalTokens: 0,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 折叠一组行(桶或会话行)的用量。 */
|
|
105
|
+
export function sumUsage(rows: readonly UsageTotals[]): UsageTotals {
|
|
106
|
+
const totals = emptyTotals();
|
|
107
|
+
for (const row of rows) {
|
|
108
|
+
totals.events += row.events;
|
|
109
|
+
totals.inputTokens += row.inputTokens;
|
|
110
|
+
totals.outputTokens += row.outputTokens;
|
|
111
|
+
totals.cacheReadTokens += row.cacheReadTokens;
|
|
112
|
+
totals.reasoningTokens += row.reasoningTokens;
|
|
113
|
+
totals.totalTokens += row.totalTokens;
|
|
114
|
+
}
|
|
115
|
+
return totals;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 用量统计通道:一次请求回报总览(含 subagent 拆分)、按天 × 模型的桶与按会话的行。
|
|
120
|
+
* 聚合只算被会话引用的事件行——fork 共享行因此只计一次,已删会话留下的孤儿行不计。
|
|
121
|
+
*/
|
|
122
|
+
export function registerSessionUsage(ctx: Context, persistence: SessionPersistenceRdb): void {
|
|
123
|
+
ctx.inject(["webServer", "connection"] as const, (webCtx) => {
|
|
124
|
+
const webServer = webCtx.webServer as unknown as {
|
|
125
|
+
register(route: {
|
|
126
|
+
kind: "exact";
|
|
127
|
+
path: string;
|
|
128
|
+
handler: (
|
|
129
|
+
req: import("node:http").IncomingMessage,
|
|
130
|
+
res: import("node:http").ServerResponse,
|
|
131
|
+
) => void | Promise<void>;
|
|
132
|
+
}): () => void;
|
|
133
|
+
};
|
|
134
|
+
const connection = webCtx.get("connection") as unknown as {
|
|
135
|
+
requestRejection(request: {
|
|
136
|
+
headers: import("node:http").IncomingHttpHeaders;
|
|
137
|
+
}): number | undefined;
|
|
138
|
+
};
|
|
139
|
+
return webCtx.effect(
|
|
140
|
+
() =>
|
|
141
|
+
webServer.register({
|
|
142
|
+
kind: "exact",
|
|
143
|
+
path: SESSION_USAGE_PATH,
|
|
144
|
+
handler: async (req, res) => {
|
|
145
|
+
const rejection = connection.requestRejection(req);
|
|
146
|
+
if (rejection !== undefined) {
|
|
147
|
+
res.writeHead(rejection);
|
|
148
|
+
res.end(rejection === 401 ? "unauthorized" : "forbidden");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (req.method !== "POST") {
|
|
152
|
+
res.writeHead(405, { "content-type": "application/json" });
|
|
153
|
+
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const chunks: Buffer[] = [];
|
|
157
|
+
for await (const chunk of req) chunks.push(chunk as Buffer);
|
|
158
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
159
|
+
let envelope: { range?: unknown } = {};
|
|
160
|
+
if (raw !== "") {
|
|
161
|
+
try {
|
|
162
|
+
envelope = JSON.parse(raw) as { range?: unknown };
|
|
163
|
+
} catch {
|
|
164
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
165
|
+
res.end(JSON.stringify({ error: "request body is not JSON" }));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
const aggregate = await persistence.usageReport(
|
|
171
|
+
resolveUsageSince(parseUsageRange(envelope.range), Date.now()),
|
|
172
|
+
);
|
|
173
|
+
const totals = sumUsage(aggregate.buckets);
|
|
174
|
+
const subagent = sumUsage(aggregate.buckets.filter((bucket) => bucket.subagent));
|
|
175
|
+
const human = sumUsage(aggregate.buckets.filter((bucket) => !bucket.subagent));
|
|
176
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
177
|
+
res.end(JSON.stringify({ totals, subagent, human, ...aggregate }));
|
|
178
|
+
} catch (error: unknown) {
|
|
179
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
180
|
+
res.end(
|
|
181
|
+
JSON.stringify({
|
|
182
|
+
error: error instanceof Error ? error.message : "usage report failed",
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
}),
|
|
188
|
+
`session-rdb: ${SESSION_USAGE_PATH} route`,
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
}
|
package/dist/import-Bc2QQa5G.mjs
DELETED
|
@@ -1,473 +0,0 @@
|
|
|
1
|
-
import { d as rowToMeta } from "./log-DO69NQnn.mjs";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { SESSION_FORMAT_VERSION, SessionLogOffset } from "@deepseek-ai/dsh-session";
|
|
4
|
-
import { parseSessionFormatLogFilename } from "@deepseek-ai/dsh-session-format";
|
|
5
|
-
import { sessionFormatCatalog } from "@deepseek-ai/dsh-session-format-catalog";
|
|
6
|
-
import { SessionBranch, SessionBranchError, balanceRewindPrefix, buildTimeline } from "@morlay/session-branch";
|
|
7
|
-
import { unzipSync } from "fflate";
|
|
8
|
-
//#region src/branch.ts
|
|
9
|
-
function locateTurnEnd(events, atSeq, mode = "after") {
|
|
10
|
-
const ends = events.filter((event) => event.type === "turn/end").map((event) => event.seq);
|
|
11
|
-
if (atSeq === void 0) {
|
|
12
|
-
const last = ends.at(-1);
|
|
13
|
-
if (last === void 0) throw new SessionBranchError("session has no closed turn", "OPEN_TURN");
|
|
14
|
-
return last;
|
|
15
|
-
}
|
|
16
|
-
if (mode === "before") {
|
|
17
|
-
let boundary = -1;
|
|
18
|
-
for (const seq of ends) if (seq < atSeq) boundary = seq;
|
|
19
|
-
else break;
|
|
20
|
-
return boundary;
|
|
21
|
-
}
|
|
22
|
-
const firstAfter = ends.find((seq) => seq >= atSeq);
|
|
23
|
-
if (firstAfter !== void 0) return firstAfter;
|
|
24
|
-
const lastStart = [...events].reverse().find((event) => event.type === "turn/start");
|
|
25
|
-
if (lastStart !== void 0 && lastStart.seq <= atSeq) throw new SessionBranchError(`anchor ${atSeq} lies inside an open turn`, "OPEN_TURN");
|
|
26
|
-
const last = ends.at(-1);
|
|
27
|
-
if (last === void 0) throw new SessionBranchError("session has no closed turn", "OPEN_TURN");
|
|
28
|
-
return last;
|
|
29
|
-
}
|
|
30
|
-
function renumber(events, offset) {
|
|
31
|
-
return events.map((event, index) => ({
|
|
32
|
-
...event,
|
|
33
|
-
seq: offset + index
|
|
34
|
-
}));
|
|
35
|
-
}
|
|
36
|
-
function mintSessionId() {
|
|
37
|
-
return `session-${randomUUID()}`;
|
|
38
|
-
}
|
|
39
|
-
function truncateLiveSession(session, newLength) {
|
|
40
|
-
const s = session;
|
|
41
|
-
s.log.length = newLength;
|
|
42
|
-
s.eventsSnapshot = void 0;
|
|
43
|
-
s.headerFold = void 0;
|
|
44
|
-
s.headerFoldSeq = 0;
|
|
45
|
-
s.contextFold = void 0;
|
|
46
|
-
s.contextFoldSeq = 0;
|
|
47
|
-
s.derived = [];
|
|
48
|
-
s.derivedNodes = 0;
|
|
49
|
-
s.derivedGeneration = 0;
|
|
50
|
-
s.surfaceManager._state = {
|
|
51
|
-
nodes: [],
|
|
52
|
-
replaceGeneration: 0
|
|
53
|
-
};
|
|
54
|
-
s.surfaceManager._lastProcessedSeq = s.surfaceManager.baseSeq - 1;
|
|
55
|
-
s.surfaceManager._pendingPlan = void 0;
|
|
56
|
-
}
|
|
57
|
-
function replaceLiveSessionLog(session, events) {
|
|
58
|
-
const s = session;
|
|
59
|
-
s.log.length = 0;
|
|
60
|
-
s.log.push(...events);
|
|
61
|
-
s.eventsSnapshot = void 0;
|
|
62
|
-
s.headerFold = void 0;
|
|
63
|
-
s.headerFoldSeq = 0;
|
|
64
|
-
s.contextFold = void 0;
|
|
65
|
-
s.contextFoldSeq = 0;
|
|
66
|
-
s.derived = [];
|
|
67
|
-
s.derivedNodes = 0;
|
|
68
|
-
s.derivedGeneration = 0;
|
|
69
|
-
s.surfaceManager._state = {
|
|
70
|
-
nodes: [],
|
|
71
|
-
replaceGeneration: 0
|
|
72
|
-
};
|
|
73
|
-
s.surfaceManager._lastProcessedSeq = s.surfaceManager.baseSeq - 1;
|
|
74
|
-
s.surfaceManager._pendingPlan = void 0;
|
|
75
|
-
}
|
|
76
|
-
var SessionBranchRdbProvider = class {
|
|
77
|
-
persistence;
|
|
78
|
-
live;
|
|
79
|
-
name = "session-rdb";
|
|
80
|
-
constructor(persistence, live = {
|
|
81
|
-
getSession: () => void 0,
|
|
82
|
-
getAgent: () => void 0,
|
|
83
|
-
flush: async () => true
|
|
84
|
-
}) {
|
|
85
|
-
this.persistence = persistence;
|
|
86
|
-
this.live = live;
|
|
87
|
-
}
|
|
88
|
-
async readBranchPrefix(id, atSeq, mode = "after", signal) {
|
|
89
|
-
const { events } = await this.readRawEvents(id, signal);
|
|
90
|
-
const boundary = locateTurnEnd(events, atSeq, mode);
|
|
91
|
-
return {
|
|
92
|
-
seq: boundary,
|
|
93
|
-
events: events.slice(0, boundary + 1)
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
async readRawEvents(id, signal) {
|
|
97
|
-
const stored = await this.persistence.readLog(id, {}, signal);
|
|
98
|
-
if (stored === void 0) throw new SessionBranchError(`session "${id}" not found`, "SESSION_NOT_FOUND");
|
|
99
|
-
return {
|
|
100
|
-
meta: stored.meta,
|
|
101
|
-
events: stored.events
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
async forkFrom(sourceId, options = {}, signal) {
|
|
105
|
-
signal?.throwIfAborted();
|
|
106
|
-
const { atSeq, anchorMode = "after", seedSuffix = [], childSessionId, meta = {} } = options;
|
|
107
|
-
const source = await this.persistence.readLog(sourceId, {}, signal);
|
|
108
|
-
if (source === void 0) throw new SessionBranchError(`session "${sourceId}" not found`, "SESSION_NOT_FOUND");
|
|
109
|
-
const boundary = locateTurnEnd(source.events, atSeq, anchorMode);
|
|
110
|
-
const prefix = source.events.slice(0, boundary + 1);
|
|
111
|
-
const childId = childSessionId ?? mintSessionId();
|
|
112
|
-
const childMeta = {
|
|
113
|
-
version: SESSION_FORMAT_VERSION,
|
|
114
|
-
id: childId,
|
|
115
|
-
createdAt: meta.createdAt ?? Date.now(),
|
|
116
|
-
...meta.cwd !== void 0 ? { cwd: meta.cwd } : source.meta.cwd !== void 0 ? { cwd: source.meta.cwd } : {},
|
|
117
|
-
parentSession: sourceId,
|
|
118
|
-
isSeeded: true,
|
|
119
|
-
...meta.agentPreset !== void 0 ? { agentPreset: meta.agentPreset } : source.meta.agentPreset !== void 0 ? { agentPreset: source.meta.agentPreset } : {},
|
|
120
|
-
...meta.origin !== void 0 ? { origin: meta.origin } : {},
|
|
121
|
-
...meta.delegationDepth !== void 0 ? { delegationDepth: meta.delegationDepth } : {}
|
|
122
|
-
};
|
|
123
|
-
const seed = [...renumber(prefix, 0), ...renumber(seedSuffix, prefix.length)];
|
|
124
|
-
const internals = this.persistence.internals();
|
|
125
|
-
const sourceRows = await internals.backend.getEventRows(sourceId);
|
|
126
|
-
const sourceEventIds = new Map(sourceRows.map((row) => [row.fSequence, row.fEventId]));
|
|
127
|
-
const reuse = /* @__PURE__ */ new Map();
|
|
128
|
-
for (const event of prefix) {
|
|
129
|
-
const eventId = sourceEventIds.get(event.seq);
|
|
130
|
-
if (eventId !== void 0) reuse.set(event.seq, eventId);
|
|
131
|
-
}
|
|
132
|
-
internals.registerReuseEventIds(childId, reuse);
|
|
133
|
-
const handle = await this.persistence.create(childMeta, { inheritedEventCount: SessionLogOffset(prefix.length) });
|
|
134
|
-
try {
|
|
135
|
-
if (seed.length > 0) await handle.append(seed);
|
|
136
|
-
} finally {
|
|
137
|
-
await handle.close();
|
|
138
|
-
}
|
|
139
|
-
return childId;
|
|
140
|
-
}
|
|
141
|
-
async rewind(id, toBoundary, signal) {
|
|
142
|
-
signal?.throwIfAborted();
|
|
143
|
-
if (!Number.isSafeInteger(toBoundary) || toBoundary < -1) throw new SessionBranchError(`rewind boundary must be a non-negative safe integer, got ${toBoundary}`, "INVALID_BOUNDARY");
|
|
144
|
-
const live = this.live.getSession(id);
|
|
145
|
-
if (live !== void 0) await this.live.flush(live);
|
|
146
|
-
const raw = live === void 0 ? await this.persistence.readLog(id, {}, signal) : void 0;
|
|
147
|
-
if (live === void 0 && raw === void 0) throw new SessionBranchError(`session "${id}" not found`, "SESSION_NOT_FOUND");
|
|
148
|
-
const inspection = live === void 0 ? void 0 : await this.persistence.internals().inspect(id, signal);
|
|
149
|
-
const events = live === void 0 ? raw.events : inspection.events;
|
|
150
|
-
const meta = live === void 0 ? raw.meta : inspection.meta;
|
|
151
|
-
const boundaryEvent = events[toBoundary];
|
|
152
|
-
if (toBoundary === -1) {} else if (boundaryEvent === void 0) throw new SessionBranchError(`rewind boundary ${toBoundary} does not exist in session "${id}"`, "INVALID_BOUNDARY");
|
|
153
|
-
else if (boundaryEvent.type !== "turn/end" && boundaryEvent.type !== "user/message") throw new SessionBranchError(`rewind boundary ${toBoundary} is not a turn/end or user/message (${boundaryEvent.type})`, "INVALID_BOUNDARY");
|
|
154
|
-
const rawKeepLength = toBoundary === -1 ? 0 : boundaryEvent.type === "turn/end" ? toBoundary + 1 : toBoundary;
|
|
155
|
-
const kept = balanceRewindPrefix(events.slice(0, rawKeepLength));
|
|
156
|
-
const keepLength = kept.length;
|
|
157
|
-
const internals = this.persistence.internals();
|
|
158
|
-
const denseBoundary = keepLength - 1;
|
|
159
|
-
const newSeedLength = await internals.backend.transaction(async (tx) => {
|
|
160
|
-
signal?.throwIfAborted();
|
|
161
|
-
const head = await tx.getHead(id);
|
|
162
|
-
if (denseBoundary > head.fHeadSequence) throw new SessionBranchError(`rewind boundary ${toBoundary} is beyond the stored head ${head.fHeadSequence}`, "INVALID_BOUNDARY");
|
|
163
|
-
if (denseBoundary < head.fHeadSequence) {
|
|
164
|
-
await tx.deleteBridgeTail(id, denseBoundary + 1);
|
|
165
|
-
const prev = denseBoundary === -1 ? void 0 : await tx.getPrevBridge(id, denseBoundary);
|
|
166
|
-
if (prev === void 0) await tx.updateHead(id, "", -1);
|
|
167
|
-
else await tx.updateHead(id, prev.fEventId, prev.fSequence);
|
|
168
|
-
}
|
|
169
|
-
const storedSeedLength = await tx.getSeedLength(id);
|
|
170
|
-
let shrunk = storedSeedLength;
|
|
171
|
-
if (storedSeedLength !== null && storedSeedLength > denseBoundary + 1) {
|
|
172
|
-
await tx.updateSeedLength(id, denseBoundary + 1);
|
|
173
|
-
shrunk = denseBoundary + 1;
|
|
174
|
-
}
|
|
175
|
-
await tx.refreshTitle(id);
|
|
176
|
-
await tx.bumpRevision(id);
|
|
177
|
-
return shrunk;
|
|
178
|
-
});
|
|
179
|
-
internals.writeGuard.confirmHead(id, denseBoundary);
|
|
180
|
-
if (live !== void 0) {
|
|
181
|
-
truncateLiveSession(live, keepLength);
|
|
182
|
-
this.live.resetProjections?.(live);
|
|
183
|
-
const agent = this.live.getAgent(id);
|
|
184
|
-
if (agent !== void 0) {
|
|
185
|
-
agent.requestHeaderLogged = false;
|
|
186
|
-
const lastTurn = live.snapshotEvents().findLast((e) => e.type === "turn/start")?.data.turn ?? 0;
|
|
187
|
-
const phase = agent.phase;
|
|
188
|
-
if (phase !== void 0) phase.lastTurn = lastTurn;
|
|
189
|
-
}
|
|
190
|
-
const handle = this.persistence.tracker.writerOf(id);
|
|
191
|
-
if (handle !== void 0) handle.resetAfterRewind(keepLength, newSeedLength === null ? void 0 : newSeedLength);
|
|
192
|
-
agent?.inbox?.clear();
|
|
193
|
-
await this.live.flush(live);
|
|
194
|
-
await this.refreshProjectionCache(live);
|
|
195
|
-
} else await this.refreshProjectionCache({
|
|
196
|
-
id,
|
|
197
|
-
header: meta,
|
|
198
|
-
inheritedEventCount: SessionLogOffset(raw.inheritedEventCount),
|
|
199
|
-
snapshotEvents: () => kept
|
|
200
|
-
});
|
|
201
|
-
const row = await internals.backend.getSession(id);
|
|
202
|
-
if (row === void 0) return {
|
|
203
|
-
header: {
|
|
204
|
-
version: SESSION_FORMAT_VERSION,
|
|
205
|
-
id,
|
|
206
|
-
createdAt: meta.createdAt,
|
|
207
|
-
...meta.cwd !== void 0 ? { cwd: meta.cwd } : {},
|
|
208
|
-
...meta.parentSession !== void 0 ? { parentSession: meta.parentSession } : {},
|
|
209
|
-
isSeeded: meta.isSeeded,
|
|
210
|
-
...meta.origin !== void 0 ? { origin: meta.origin } : {},
|
|
211
|
-
...meta.delegationDepth !== void 0 ? { delegationDepth: meta.delegationDepth } : {},
|
|
212
|
-
...meta.agentPreset !== void 0 ? { agentPreset: meta.agentPreset } : {}
|
|
213
|
-
},
|
|
214
|
-
revision: await internals.readStoredRevision(id) ?? await this.persistence.readStoredRevision(id)
|
|
215
|
-
};
|
|
216
|
-
return {
|
|
217
|
-
header: rowToMeta(row),
|
|
218
|
-
revision: await internals.readStoredRevision(id)
|
|
219
|
-
};
|
|
220
|
-
}
|
|
221
|
-
/**
|
|
222
|
-
* 让持久化投影检查点跟随截断后的 log(见 {@link LiveSessionHooks.refreshProjectionCache})。
|
|
223
|
-
* 缓存是派生数据:刷新失败只让缓存多滞后一轮,不能让 rewind 失败。
|
|
224
|
-
*/
|
|
225
|
-
async refreshProjectionCache(session) {
|
|
226
|
-
if (this.live.refreshProjectionCache === void 0) return;
|
|
227
|
-
try {
|
|
228
|
-
await this.live.refreshProjectionCache(session);
|
|
229
|
-
} catch {}
|
|
230
|
-
}
|
|
231
|
-
};
|
|
232
|
-
var SessionBranchRdb = class extends SessionBranch {
|
|
233
|
-
static inject = ["sessionPersistence", "sessions"];
|
|
234
|
-
constructor(ctx) {
|
|
235
|
-
super(ctx);
|
|
236
|
-
}
|
|
237
|
-
provider = new SessionBranchRdbProvider(this.ctx.sessionPersistence, {
|
|
238
|
-
getSession: (id) => this.ctx.sessions.get(id),
|
|
239
|
-
getAgent: (id) => {
|
|
240
|
-
return this.ctx.get("agents")?.get(id);
|
|
241
|
-
},
|
|
242
|
-
flush: (session) => this.ctx.sessions.flush(session),
|
|
243
|
-
resetProjections: (session) => {
|
|
244
|
-
const registry = this.ctx.get("sessionProjections");
|
|
245
|
-
if (registry?.registrations === void 0) return;
|
|
246
|
-
for (const registration of registry.registrations.values()) registration.cells.delete(session);
|
|
247
|
-
},
|
|
248
|
-
refreshProjectionCache: async (session) => {
|
|
249
|
-
const cache = this.ctx.get("sessionProjectionCache");
|
|
250
|
-
if (cache === void 0) return;
|
|
251
|
-
try {
|
|
252
|
-
await cache.write(session);
|
|
253
|
-
} catch (error) {
|
|
254
|
-
this.ctx.logger.warn(`session-rdb: projection cache refresh after rewind for "${session.id}" failed (cache stays stale): ${String(error)}`);
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
});
|
|
258
|
-
readBranchPrefix(id, atSeq, mode, signal) {
|
|
259
|
-
return this.provider.readBranchPrefix(id, atSeq, mode, signal);
|
|
260
|
-
}
|
|
261
|
-
readRawEvents(id, signal) {
|
|
262
|
-
return this.provider.readRawEvents(id, signal);
|
|
263
|
-
}
|
|
264
|
-
forkFrom(sourceId, options, signal) {
|
|
265
|
-
return this.provider.forkFrom(sourceId, options, signal);
|
|
266
|
-
}
|
|
267
|
-
rewind(id, toBoundary, signal) {
|
|
268
|
-
return this.provider.rewind(id, toBoundary, signal);
|
|
269
|
-
}
|
|
270
|
-
async timeline(sessionId, signal) {
|
|
271
|
-
const persistence = this.ctx.sessionPersistence;
|
|
272
|
-
const snapshots = await persistence.listSnapshots(signal);
|
|
273
|
-
const readOwnEvents = async (id, fromSeq, s) => {
|
|
274
|
-
const live = this.ctx.sessions.get(id);
|
|
275
|
-
if (live !== void 0) return live.snapshotEvents().slice(fromSeq);
|
|
276
|
-
return (await persistence.internals().readFrom(id, fromSeq, s)).events;
|
|
277
|
-
};
|
|
278
|
-
return buildTimeline(snapshots, readOwnEvents, sessionId, signal);
|
|
279
|
-
}
|
|
280
|
-
};
|
|
281
|
-
//#endregion
|
|
282
|
-
//#region src/import.ts
|
|
283
|
-
/** 当前世代产物的文件名(读入侧接受任意 canonical 世代名,见 parseImportZip)。 */
|
|
284
|
-
const SESSION_LOG_ARTIFACT_FILENAME = "session.jsonl";
|
|
285
|
-
const SESSION_IMPORT_PATH = "/api/session.import";
|
|
286
|
-
const MAX_IMPORT_ZIP_BYTES = 67108864;
|
|
287
|
-
function parseJsonlArtifact(content) {
|
|
288
|
-
const lines = content.split("\n");
|
|
289
|
-
if (lines.length === 0 || lines[0] === "") throw new Error("imported session log is empty");
|
|
290
|
-
let header;
|
|
291
|
-
try {
|
|
292
|
-
header = JSON.parse(lines[0]);
|
|
293
|
-
} catch {
|
|
294
|
-
throw new Error("imported session log has an unparsable header line");
|
|
295
|
-
}
|
|
296
|
-
let restore;
|
|
297
|
-
try {
|
|
298
|
-
restore = sessionFormatCatalog.createRestore(header, {
|
|
299
|
-
recovery: "strict",
|
|
300
|
-
validation: "transformed"
|
|
301
|
-
});
|
|
302
|
-
} catch (error) {
|
|
303
|
-
throw new Error(`imported session log has an invalid header line: ${error instanceof Error ? error.message : String(error)}`);
|
|
304
|
-
}
|
|
305
|
-
for (let i = 1; i < lines.length; i++) {
|
|
306
|
-
const line = lines[i];
|
|
307
|
-
if (line === void 0 || line === "") continue;
|
|
308
|
-
let parsed;
|
|
309
|
-
try {
|
|
310
|
-
parsed = JSON.parse(line);
|
|
311
|
-
} catch {
|
|
312
|
-
throw new Error(`imported session log has an unparsable event line at ${i}`);
|
|
313
|
-
}
|
|
314
|
-
try {
|
|
315
|
-
restore.decodeRow(parsed);
|
|
316
|
-
} catch (error) {
|
|
317
|
-
throw new Error(`imported session log has an invalid event line at ${i}: ${error instanceof Error ? error.message : String(error)}`);
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
let artifact;
|
|
321
|
-
try {
|
|
322
|
-
artifact = restore.finish();
|
|
323
|
-
} catch (error) {
|
|
324
|
-
throw new Error(`imported session log is incomplete: ${error instanceof Error ? error.message : String(error)}`);
|
|
325
|
-
}
|
|
326
|
-
const meta = artifact.header;
|
|
327
|
-
const events = artifact.events;
|
|
328
|
-
for (let i = 0; i < events.length; i++) if (events[i].seq !== i) throw new Error(`imported session log seq gap at ${i} (got ${events[i].seq}); import requires a dense log`);
|
|
329
|
-
const inheritedEventCount = Math.min(artifact.inheritedEventCount, events.length);
|
|
330
|
-
return {
|
|
331
|
-
meta: {
|
|
332
|
-
version: SESSION_FORMAT_VERSION,
|
|
333
|
-
id: meta.id,
|
|
334
|
-
createdAt: meta.createdAt,
|
|
335
|
-
...meta.cwd === void 0 ? {} : { cwd: meta.cwd },
|
|
336
|
-
...meta.parentSession === void 0 ? {} : { parentSession: meta.parentSession },
|
|
337
|
-
isSeeded: meta.isSeeded,
|
|
338
|
-
...meta.origin === void 0 ? {} : { origin: meta.origin },
|
|
339
|
-
...meta.delegationDepth === void 0 ? {} : { delegationDepth: meta.delegationDepth },
|
|
340
|
-
...meta.agentPreset === void 0 ? {} : { agentPreset: meta.agentPreset }
|
|
341
|
-
},
|
|
342
|
-
inheritedEventCount: SessionLogOffset(inheritedEventCount),
|
|
343
|
-
events
|
|
344
|
-
};
|
|
345
|
-
}
|
|
346
|
-
function parseImportZip(zip) {
|
|
347
|
-
let entries;
|
|
348
|
-
try {
|
|
349
|
-
entries = unzipSync(zip);
|
|
350
|
-
} catch {
|
|
351
|
-
throw new Error("imported zip is not a valid ZIP archive");
|
|
352
|
-
}
|
|
353
|
-
const artifact = Object.entries(entries).find(([name]) => parseSessionFormatLogFilename(name) !== void 0);
|
|
354
|
-
if (artifact === void 0) throw new Error(`imported zip has no session log artifact (expected ${SESSION_LOG_ARTIFACT_FILENAME} or a canonical session.vN.jsonl)`);
|
|
355
|
-
return parseJsonlArtifact(new TextDecoder().decode(artifact[1]));
|
|
356
|
-
}
|
|
357
|
-
async function stopAgentLoop(ctx, sessionId) {
|
|
358
|
-
const agent = ctx.get("agents")?.get(sessionId);
|
|
359
|
-
if (agent === void 0) return;
|
|
360
|
-
agent.cancel?.({ kind: "user" }, { keepInbox: true });
|
|
361
|
-
await agent.whenIdle();
|
|
362
|
-
}
|
|
363
|
-
async function persistImport(persistence, branch, imported, targetId, sessions, stopLoop) {
|
|
364
|
-
const id = targetId ?? `session-${randomUUID()}`;
|
|
365
|
-
if (targetId !== void 0) {
|
|
366
|
-
if (branch === void 0) throw new Error("sessionBranch service is unavailable");
|
|
367
|
-
if (stopLoop !== void 0) await stopLoop(targetId);
|
|
368
|
-
await branch.rewind(targetId, -1);
|
|
369
|
-
const liveHandle = persistence.tracker.writerOf(targetId);
|
|
370
|
-
if (liveHandle !== void 0) {
|
|
371
|
-
if (imported.events.length > 0) await liveHandle.append(imported.events);
|
|
372
|
-
} else {
|
|
373
|
-
const handle = await persistence.open(targetId, "write");
|
|
374
|
-
try {
|
|
375
|
-
if (imported.events.length > 0) await handle.append(imported.events);
|
|
376
|
-
} finally {
|
|
377
|
-
await handle.close();
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
} else {
|
|
381
|
-
const handle = await persistence.create({
|
|
382
|
-
...imported.meta,
|
|
383
|
-
id
|
|
384
|
-
}, { inheritedEventCount: imported.inheritedEventCount });
|
|
385
|
-
if (imported.events.length > 0) await handle.append(imported.events);
|
|
386
|
-
await handle.close();
|
|
387
|
-
}
|
|
388
|
-
if (targetId !== void 0) {
|
|
389
|
-
const live = sessions?.get(targetId);
|
|
390
|
-
if (live !== void 0) replaceLiveSessionLog(live, imported.events);
|
|
391
|
-
}
|
|
392
|
-
return id;
|
|
393
|
-
}
|
|
394
|
-
function registerSessionImport(ctx, persistence) {
|
|
395
|
-
ctx.inject(["webServer", "connection"], (webCtx) => {
|
|
396
|
-
const webServer = webCtx.webServer;
|
|
397
|
-
const connection = webCtx.get("connection");
|
|
398
|
-
return webCtx.effect(() => webServer.register({
|
|
399
|
-
kind: "exact",
|
|
400
|
-
path: SESSION_IMPORT_PATH,
|
|
401
|
-
handler: async (req, res) => {
|
|
402
|
-
const rejection = connection.requestRejection(req);
|
|
403
|
-
if (rejection !== void 0) {
|
|
404
|
-
res.writeHead(rejection);
|
|
405
|
-
res.end(rejection === 401 ? "unauthorized" : "forbidden");
|
|
406
|
-
return;
|
|
407
|
-
}
|
|
408
|
-
const chunks = [];
|
|
409
|
-
for await (const chunk of req) chunks.push(chunk);
|
|
410
|
-
const body = Buffer.concat(chunks);
|
|
411
|
-
let envelope;
|
|
412
|
-
try {
|
|
413
|
-
envelope = JSON.parse(body.toString("utf8"));
|
|
414
|
-
} catch {
|
|
415
|
-
res.writeHead(400, { "content-type": "application/json" });
|
|
416
|
-
res.end(JSON.stringify({ error: "request body is not JSON" }));
|
|
417
|
-
return;
|
|
418
|
-
}
|
|
419
|
-
if (typeof envelope.zip !== "string" || envelope.zip === "") {
|
|
420
|
-
res.writeHead(400, { "content-type": "application/json" });
|
|
421
|
-
res.end(JSON.stringify({ error: "missing zip field" }));
|
|
422
|
-
return;
|
|
423
|
-
}
|
|
424
|
-
if (envelope.sessionId !== void 0 && (typeof envelope.sessionId !== "string" || envelope.sessionId === "")) {
|
|
425
|
-
res.writeHead(400, { "content-type": "application/json" });
|
|
426
|
-
res.end(JSON.stringify({ error: "sessionId must be a non-empty string" }));
|
|
427
|
-
return;
|
|
428
|
-
}
|
|
429
|
-
let zip;
|
|
430
|
-
try {
|
|
431
|
-
zip = Buffer.from(envelope.zip, "base64");
|
|
432
|
-
} catch {
|
|
433
|
-
res.writeHead(400, { "content-type": "application/json" });
|
|
434
|
-
res.end(JSON.stringify({ error: "zip field is not valid base64" }));
|
|
435
|
-
return;
|
|
436
|
-
}
|
|
437
|
-
if (zip.byteLength > MAX_IMPORT_ZIP_BYTES) {
|
|
438
|
-
res.writeHead(413, { "content-type": "application/json" });
|
|
439
|
-
res.end(JSON.stringify({ error: "imported zip exceeds the size limit" }));
|
|
440
|
-
return;
|
|
441
|
-
}
|
|
442
|
-
let imported;
|
|
443
|
-
try {
|
|
444
|
-
imported = parseImportZip(zip);
|
|
445
|
-
} catch (error) {
|
|
446
|
-
res.writeHead(400, { "content-type": "application/json" });
|
|
447
|
-
res.end(JSON.stringify({ error: error instanceof Error ? error.message : "imported zip is invalid" }));
|
|
448
|
-
return;
|
|
449
|
-
}
|
|
450
|
-
const targetId = typeof envelope.sessionId === "string" ? envelope.sessionId : void 0;
|
|
451
|
-
const branch = webCtx.get("sessionBranch");
|
|
452
|
-
try {
|
|
453
|
-
const sessions = webCtx.get("sessions");
|
|
454
|
-
const id = await persistImport(persistence, branch, imported, targetId, sessions, (sessionId) => stopAgentLoop(webCtx, sessionId));
|
|
455
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
456
|
-
res.end(JSON.stringify({ sessionId: id }));
|
|
457
|
-
} catch (error) {
|
|
458
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
459
|
-
if (targetId !== void 0 && /not found/i.test(message)) {
|
|
460
|
-
res.writeHead(404, { "content-type": "application/json" });
|
|
461
|
-
res.end(JSON.stringify({ error: `session "${targetId}" not found` }));
|
|
462
|
-
return;
|
|
463
|
-
}
|
|
464
|
-
res.writeHead(500, { "content-type": "application/json" });
|
|
465
|
-
res.end(JSON.stringify({ error: error instanceof Error ? error.message : "import failed" }));
|
|
466
|
-
return;
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
}), `session-rdb: ${SESSION_IMPORT_PATH} route`);
|
|
470
|
-
});
|
|
471
|
-
}
|
|
472
|
-
//#endregion
|
|
473
|
-
export { persistImport as a, SessionBranchRdbProvider as c, parseJsonlArtifact as i, locateTurnEnd as l, SESSION_LOG_ARTIFACT_FILENAME as n, registerSessionImport as o, parseImportZip as r, SessionBranchRdb as s, SESSION_IMPORT_PATH as t };
|