@morlay/session-rdb 0.0.21-alpha.7 → 0.0.21-alpha.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -12
- package/dist/artifact.d.mts +2 -101
- package/dist/artifact.mjs +2 -2
- package/dist/{branch-BjUR6wfe.mjs → branch-D4nVzQQM.mjs} +3 -3
- package/dist/deletion.d.mts +1 -1
- package/dist/deletion.mjs +1 -1
- package/dist/{dist-vIVO6bA-.mjs → dist-DHer1cql.mjs} +14 -7
- package/dist/export.d.mts +8 -0
- package/dist/export.mjs +76 -0
- package/dist/gc.d.mts +8 -0
- package/dist/gc.mjs +51 -0
- package/dist/import.d.mts +1 -1
- package/dist/import.mjs +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +3 -3
- package/dist/{log-qCNkDjh5.mjs → log-BWVFYAh5.mjs} +39 -12
- package/dist/{backend-E0bmTXpz.d.mts → log-CUjKQbOR.d.mts} +222 -39
- package/dist/{sqlite-zcXVyMxr.mjs → sqlite-SCiSnwaz.mjs} +321 -259
- package/dist/{src-B9X8BKyh.mjs → src-BZ1tffd5.mjs} +342 -310
- package/dist/storage.d.mts +20 -7
- package/dist/storage.mjs +2 -2
- package/dist/testing.d.mts +1 -1
- package/dist/testing.mjs +48 -29
- package/dist/usage.d.mts +2 -0
- package/dist/usage.mjs +152 -0
- package/drizzle/postgres/20260923120000_v3_usage_materialized/migration.sql +48 -0
- package/drizzle/sqlite/20260923120000_v3_usage_materialized/migration.sql +48 -0
- package/package.json +25 -30
- package/src/backend.ts +41 -17
- package/src/branch.ts +4 -4
- package/src/drizzle/postgres-v3.ts +2 -0
- package/src/drizzle/sqlite-v3.ts +2 -0
- package/src/entities/v3/event-usage.ts +8 -1
- package/src/entities/v3/index.ts +8 -0
- package/src/entities/v3/session-counts.ts +23 -0
- package/src/entities/v3/session-usage.ts +29 -0
- package/src/index.ts +230 -88
- package/src/log.ts +58 -17
- package/src/postgres.ts +248 -149
- package/src/rows.ts +2 -1
- package/src/schema.ts +4 -0
- package/src/sqlite.ts +263 -124
- package/src/storage-takeover/repository.ts +1 -4
- package/src/usage.ts +10 -6
- package/cordis.patch.yml +0 -13
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { C as postgresTableDefs, T as WriteGuard, a as EVENT_ENCODING, c as eventDimensions, i as DEFAULT_BUSY_TIMEOUT_MS, r as createStorageRepository, t as SqliteBackend, w as toPostgresSchema } from "./sqlite-SCiSnwaz.mjs";
|
|
2
|
+
import { COUNTED_EVENT_TYPES, addActivityCount, addActivityTotals, addTokenTotals, addTotals, emptyTotals, localDayKey, registerSessionUsage } from "./usage.mjs";
|
|
3
|
+
import { S as usageRowOf, _ as sessionConflictRow, b as titleOfEventData, g as sealOwnEvents, h as scanRows, m as rowToMeta, r as needsShapeAdoption, u as repairReadView, v as sessionInsertRow, x as toJsonlArtifact } from "./log-BWVFYAh5.mjs";
|
|
4
|
+
import { c as isLegacyVersion, o as adoptLegacyRows, s as convertLegacyRows, t as SessionBranchRdb } from "./branch-D4nVzQQM.mjs";
|
|
4
5
|
import { registerSessionImport } from "./import.mjs";
|
|
6
|
+
import { registerSessionExport } from "./export.mjs";
|
|
7
|
+
import { registerSessionGc } from "./gc.mjs";
|
|
5
8
|
import z from "@deepseek-ai/schemastery";
|
|
6
9
|
import { randomUUID } from "node:crypto";
|
|
7
10
|
import { Pool } from "pg";
|
|
@@ -14,7 +17,6 @@ import { readdir } from "node:fs/promises";
|
|
|
14
17
|
import { fileURLToPath } from "node:url";
|
|
15
18
|
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
|
16
19
|
import { balanceRewindPrefix } from "@morlay/session-branch";
|
|
17
|
-
import { strToU8, zip } from "fflate";
|
|
18
20
|
import { SessionQueryEngine, SessionQueryError } from "@deepseek-ai/dsh-session-query";
|
|
19
21
|
import { StorageError, storageBackendServiceKey } from "@deepseek-ai/dsh-storage";
|
|
20
22
|
import { Service } from "@deepseek-ai/cordis";
|
|
@@ -54,6 +56,15 @@ function emptyActivity() {
|
|
|
54
56
|
toolCalls: 0
|
|
55
57
|
};
|
|
56
58
|
}
|
|
59
|
+
/** `t_session_counts` 的四项计数折成活动计数(pg 的 sum 回落成字符串)。 */
|
|
60
|
+
function activityTotalsOf(row) {
|
|
61
|
+
return {
|
|
62
|
+
turns: numeric(row["turns"]),
|
|
63
|
+
steps: numeric(row["steps"]),
|
|
64
|
+
userInputs: numeric(row["user_inputs"]),
|
|
65
|
+
toolCalls: numeric(row["tool_calls"])
|
|
66
|
+
};
|
|
67
|
+
}
|
|
57
68
|
/** `IN (...)` 用的类型字面量(与 `COUNTED_EVENT_TYPES` 同源)。 */
|
|
58
69
|
const COUNTED_EVENT_TYPE_SQL = COUNTED_EVENT_TYPES.map((type) => `'${type}'`).join(", ");
|
|
59
70
|
var PostgresBackend = class PostgresBackend {
|
|
@@ -107,9 +118,7 @@ var PostgresBackend = class PostgresBackend {
|
|
|
107
118
|
if (await this.readMeta(this.db, "schema_version") === "2") await this.baselineV2();
|
|
108
119
|
}
|
|
109
120
|
await migrate(this.db, { migrationsFolder: postgresMigrationsDir });
|
|
110
|
-
await this.
|
|
111
|
-
await this.backfillEventCounts();
|
|
112
|
-
await this.backfillEventUsage();
|
|
121
|
+
await this.backfillUsageTables();
|
|
113
122
|
const storeId = await this.db.transaction(async (tx) => {
|
|
114
123
|
await tx.insert(this.tables["t_persistence_state"]).values({
|
|
115
124
|
fSingleton: 1,
|
|
@@ -170,7 +179,7 @@ var PostgresBackend = class PostgresBackend {
|
|
|
170
179
|
getHead: (id) => this.getHead(tx, id),
|
|
171
180
|
getSeedLength: (id) => this.getSeedLength(tx, id),
|
|
172
181
|
updateSeedLength: (id, seedLength) => this.updateSeedLength(tx, id, seedLength),
|
|
173
|
-
insertEvents: (events) => this.insertEvents(tx, events),
|
|
182
|
+
insertEvents: (events, usageRows) => this.insertEvents(tx, events, usageRows),
|
|
174
183
|
insertBridges: (rows) => this.insertBridges(tx, rows),
|
|
175
184
|
updateHead: (id, headEventId, headSequence) => this.updateHead(tx, id, headEventId, headSequence),
|
|
176
185
|
bumpRevision: (id, lastEventAt) => this.bumpRevision(tx, id, lastEventAt),
|
|
@@ -207,14 +216,10 @@ var PostgresBackend = class PostgresBackend {
|
|
|
207
216
|
await exec.update(this.tables["t_sessions"]).set({ fSeedLength: seedLength }).where(eq(this.tables["t_sessions"].fSessionId, id)).execute();
|
|
208
217
|
}
|
|
209
218
|
static INSERT_BATCH_ROWS = 1e3;
|
|
210
|
-
async insertEvents(exec, events) {
|
|
219
|
+
async insertEvents(exec, events, usageRows) {
|
|
211
220
|
if (events.length === 0) return;
|
|
212
221
|
for (let i = 0; i < events.length; i += PostgresBackend.INSERT_BATCH_ROWS) await exec.insert(this.tables["t_events"]).values(events.slice(i, i + PostgresBackend.INSERT_BATCH_ROWS).map((event) => ({ ...event }))).execute();
|
|
213
|
-
|
|
214
|
-
const row = usageRowOf(event);
|
|
215
|
-
return row === void 0 ? [] : [row];
|
|
216
|
-
});
|
|
217
|
-
if (usage.length > 0) await exec.insert(this.tables["t_event_usage"]).values(usage).onConflictDoNothing().execute();
|
|
222
|
+
if (usageRows.length > 0) for (let i = 0; i < usageRows.length; i += PostgresBackend.INSERT_BATCH_ROWS) await exec.insert(this.tables["t_event_usage"]).values(usageRows.slice(i, i + PostgresBackend.INSERT_BATCH_ROWS).map((row) => ({ ...row }))).onConflictDoNothing().execute();
|
|
218
223
|
}
|
|
219
224
|
async insertBridges(exec, rows) {
|
|
220
225
|
if (rows.length === 0) return;
|
|
@@ -284,21 +289,41 @@ var PostgresBackend = class PostgresBackend {
|
|
|
284
289
|
async vacuum() {
|
|
285
290
|
await this.db.execute(sql`VACUUM ANALYZE`);
|
|
286
291
|
}
|
|
287
|
-
/**
|
|
292
|
+
/** 派生统计表的回填:任一表为空即按事件表全量重算(幂等,迁移删表重建后自愈)。 */
|
|
293
|
+
async backfillUsageTables() {
|
|
294
|
+
await this.backfillEventUsage();
|
|
295
|
+
await this.backfillSessionUsage();
|
|
296
|
+
await this.backfillSessionCounts();
|
|
297
|
+
}
|
|
298
|
+
async tableIsEmpty(table) {
|
|
299
|
+
const existing = await this.db.execute(sql.raw(`SELECT count(*) AS n FROM ${table}`));
|
|
300
|
+
return Number(existing.rows[0]?.["n"] ?? 0) === 0;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* 用量行:一条 `assistant/message` 事件行一行,物化本地日与两个归属标记。
|
|
304
|
+
* 本地日按数据库会话时区渲染(部署前提是它与 host 时区一致,`localDayKey` 同口径)。
|
|
305
|
+
*/
|
|
288
306
|
async backfillEventUsage() {
|
|
307
|
+
if (!await this.tableIsEmpty(this.qualifiedTable("t_event_usage"))) return;
|
|
289
308
|
const tEventUsage = this.tables["t_event_usage"];
|
|
290
309
|
const tEvents = this.tables["t_events"];
|
|
291
|
-
const
|
|
292
|
-
|
|
310
|
+
const tSessionEvents = this.tables["t_session_events"];
|
|
311
|
+
const tSessions = this.tables["t_sessions"];
|
|
293
312
|
await this.db.execute(sql`
|
|
294
313
|
INSERT INTO ${tEventUsage} (
|
|
295
|
-
f_event_id, f_created_at, f_provider, f_model,
|
|
314
|
+
f_event_id, f_created_at, f_day, f_provider, f_model, f_referenced, f_subagent,
|
|
296
315
|
f_input_tokens, f_output_tokens, f_cache_read_tokens, f_reasoning_tokens, f_total_tokens)
|
|
297
316
|
SELECT e.f_event_id, e.f_created_at,
|
|
317
|
+
to_char(to_timestamp(e.f_created_at / 1000.0), 'YYYY-MM-DD'),
|
|
298
318
|
coalesce(e.f_data::json #>> '{data,message,source,provider}',
|
|
299
319
|
e.f_data::json #>> '{message,source,provider}'),
|
|
300
320
|
coalesce(e.f_data::json #>> '{data,message,source,model}',
|
|
301
321
|
e.f_data::json #>> '{message,source,model}'),
|
|
322
|
+
(EXISTS (SELECT 1 FROM ${tSessionEvents} rb
|
|
323
|
+
WHERE rb.f_event_id = e.f_event_id))::int,
|
|
324
|
+
(EXISTS (SELECT 1 FROM ${tSessionEvents} sb
|
|
325
|
+
JOIN ${tSessions} ss ON ss.f_session_id = sb.f_session_id
|
|
326
|
+
WHERE sb.f_event_id = e.f_event_id AND ss.f_origin = 'subagent'))::int,
|
|
302
327
|
coalesce((e.f_data::json #>> '{data,usage,inputTokens}')::integer,
|
|
303
328
|
(e.f_data::json #>> '{usage,inputTokens}')::integer, 0),
|
|
304
329
|
coalesce((e.f_data::json #>> '{data,usage,outputTokens}')::integer,
|
|
@@ -314,6 +339,47 @@ var PostgresBackend = class PostgresBackend {
|
|
|
314
339
|
AND (e.f_data::json #> '{data,usage}' IS NOT NULL
|
|
315
340
|
OR e.f_data::json #> '{usage}' IS NOT NULL)
|
|
316
341
|
ON CONFLICT DO NOTHING
|
|
342
|
+
`);
|
|
343
|
+
}
|
|
344
|
+
/** 会话 × 本地日 × 模型的 token 汇总:从桥接行 × 用量行重算(含 fork 继承前缀)。 */
|
|
345
|
+
async backfillSessionUsage() {
|
|
346
|
+
if (!await this.tableIsEmpty(this.qualifiedTable("t_session_usage"))) return;
|
|
347
|
+
const tSessionUsage = this.tables["t_session_usage"];
|
|
348
|
+
const tSessionEvents = this.tables["t_session_events"];
|
|
349
|
+
const tEventUsage = this.tables["t_event_usage"];
|
|
350
|
+
await this.db.execute(sql`
|
|
351
|
+
INSERT INTO ${tSessionUsage} (
|
|
352
|
+
f_session_id, f_day, f_provider, f_model,
|
|
353
|
+
f_input_tokens, f_output_tokens, f_cache_read_tokens, f_reasoning_tokens, f_total_tokens)
|
|
354
|
+
SELECT b.f_session_id, u.f_day, coalesce(u.f_provider, ''), coalesce(u.f_model, ''),
|
|
355
|
+
sum(u.f_input_tokens), sum(u.f_output_tokens), sum(u.f_cache_read_tokens),
|
|
356
|
+
sum(u.f_reasoning_tokens), sum(u.f_total_tokens)
|
|
357
|
+
FROM ${tSessionEvents} b
|
|
358
|
+
JOIN ${tEventUsage} u ON u.f_event_id = b.f_event_id
|
|
359
|
+
GROUP BY 1, 2, 3, 4
|
|
360
|
+
ON CONFLICT DO NOTHING
|
|
361
|
+
`);
|
|
362
|
+
}
|
|
363
|
+
/** 会话 × 本地日的活动计数:从桥接行 × 事件类型重算。 */
|
|
364
|
+
async backfillSessionCounts() {
|
|
365
|
+
if (!await this.tableIsEmpty(this.qualifiedTable("t_session_counts"))) return;
|
|
366
|
+
const tSessionCounts = this.tables["t_session_counts"];
|
|
367
|
+
const tSessionEvents = this.tables["t_session_events"];
|
|
368
|
+
const tEvents = this.tables["t_events"];
|
|
369
|
+
await this.db.execute(sql`
|
|
370
|
+
INSERT INTO ${tSessionCounts} (
|
|
371
|
+
f_session_id, f_day, f_turns, f_steps, f_user_inputs, f_tool_calls)
|
|
372
|
+
SELECT b.f_session_id,
|
|
373
|
+
to_char(to_timestamp(e.f_created_at / 1000.0), 'YYYY-MM-DD'),
|
|
374
|
+
sum(CASE WHEN e.f_type = 'turn/start' THEN 1 ELSE 0 END),
|
|
375
|
+
sum(CASE WHEN e.f_type = 'step/start' THEN 1 ELSE 0 END),
|
|
376
|
+
sum(CASE WHEN e.f_type = 'user/message' THEN 1 ELSE 0 END),
|
|
377
|
+
sum(CASE WHEN e.f_type = 'tool/call' THEN 1 ELSE 0 END)
|
|
378
|
+
FROM ${tSessionEvents} b
|
|
379
|
+
JOIN ${tEvents} e ON e.f_event_id = b.f_event_id
|
|
380
|
+
WHERE e.f_type IN (${sql.raw(COUNTED_EVENT_TYPE_SQL)})
|
|
381
|
+
GROUP BY 1, 2
|
|
382
|
+
ON CONFLICT DO NOTHING
|
|
317
383
|
`);
|
|
318
384
|
}
|
|
319
385
|
/** 用量聚合:与 SQLite 侧同形,读 `t_event_usage`,数值列回来是字符串。 */
|
|
@@ -366,85 +432,78 @@ var PostgresBackend = class PostgresBackend {
|
|
|
366
432
|
total: numeric(counted.rows[0]?.["n"] ?? 0)
|
|
367
433
|
};
|
|
368
434
|
}
|
|
435
|
+
/**
|
|
436
|
+
* 用量聚合:与 SQLite 侧同形——只读三张派生统计表(用量行给去重口径的总量与「天 × 模型」桶,
|
|
437
|
+
* 两张会话汇总表给按会话的行),数值列回来是字符串。物化列让这里不做 `EXISTS`、不回连事件表。
|
|
438
|
+
*/
|
|
369
439
|
async usageReport(sinceMs) {
|
|
370
|
-
const usageSince = sinceMs === void 0 ? sql`` : sql` AND
|
|
371
|
-
const
|
|
440
|
+
const usageSince = sinceMs === void 0 ? sql`` : sql` AND f_created_at >= ${sinceMs}`;
|
|
441
|
+
const dayFilter = (column) => sinceMs === void 0 ? sql`` : sql` AND ${sql.raw(column)} >= ${localDayKey(sinceMs)}`;
|
|
372
442
|
const tEventUsage = this.tables["t_event_usage"];
|
|
373
|
-
const
|
|
443
|
+
const tSessionUsage = this.tables["t_session_usage"];
|
|
444
|
+
const tSessionCounts = this.tables["t_session_counts"];
|
|
374
445
|
const tSessions = this.tables["t_sessions"];
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
JOIN ${tSessions} ss ON ss.f_session_id = sb.f_session_id
|
|
381
|
-
WHERE sb.f_session_id = c.f_session_id AND ss.f_origin = 'subagent')`;
|
|
382
|
-
const hasUsageSession = (column) => sql`EXISTS (SELECT 1 FROM ${tSessionEvents} ub
|
|
383
|
-
JOIN ${tEventUsage} uu ON uu.f_event_id = ub.f_event_id
|
|
384
|
-
WHERE ub.f_session_id = ${sql.raw(column)})`;
|
|
385
|
-
const tokenColumns = sql.raw(`sum(u.f_input_tokens) AS input_tokens,
|
|
386
|
-
sum(u.f_output_tokens) AS output_tokens,
|
|
387
|
-
sum(u.f_cache_read_tokens) AS cache_read_tokens,
|
|
388
|
-
sum(u.f_reasoning_tokens) AS reasoning_tokens,
|
|
389
|
-
sum(u.f_total_tokens) AS total_tokens`);
|
|
446
|
+
const tokenColumns = (alias) => sql.raw(`sum(${alias}.f_input_tokens) AS input_tokens,
|
|
447
|
+
sum(${alias}.f_output_tokens) AS output_tokens,
|
|
448
|
+
sum(${alias}.f_cache_read_tokens) AS cache_read_tokens,
|
|
449
|
+
sum(${alias}.f_reasoning_tokens) AS reasoning_tokens,
|
|
450
|
+
sum(${alias}.f_total_tokens) AS total_tokens`);
|
|
390
451
|
const buckets = await this.db.execute(sql`
|
|
391
|
-
SELECT
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
${tokenColumns}
|
|
396
|
-
FROM ${tEventUsage}
|
|
397
|
-
WHERE
|
|
452
|
+
SELECT a.f_day AS day,
|
|
453
|
+
a.f_provider AS provider,
|
|
454
|
+
a.f_model AS model,
|
|
455
|
+
(a.f_subagent = 1) AS subagent,
|
|
456
|
+
${tokenColumns("a")}
|
|
457
|
+
FROM ${tEventUsage} a
|
|
458
|
+
WHERE a.f_referenced = 1${usageSince}
|
|
398
459
|
GROUP BY 1, 2, 3, 4
|
|
399
460
|
`);
|
|
400
461
|
const tokenScope = await this.db.execute(sql`
|
|
401
|
-
SELECT
|
|
402
|
-
${tokenColumns}
|
|
403
|
-
FROM ${tEventUsage}
|
|
404
|
-
WHERE
|
|
462
|
+
SELECT (a.f_subagent = 1) AS subagent,
|
|
463
|
+
${tokenColumns("a")}
|
|
464
|
+
FROM ${tEventUsage} a
|
|
465
|
+
WHERE a.f_referenced = 1${usageSince}
|
|
405
466
|
GROUP BY 1
|
|
406
467
|
`);
|
|
407
468
|
const activityScope = await this.db.execute(sql`
|
|
408
|
-
SELECT
|
|
409
|
-
c.
|
|
410
|
-
sum(c.
|
|
411
|
-
|
|
469
|
+
SELECT (s.f_origin = 'subagent') AS subagent,
|
|
470
|
+
sum(c.f_turns) AS turns,
|
|
471
|
+
sum(c.f_steps) AS steps,
|
|
472
|
+
sum(c.f_user_inputs) AS user_inputs,
|
|
473
|
+
sum(c.f_tool_calls) AS tool_calls
|
|
474
|
+
FROM ${tSessionCounts} c
|
|
412
475
|
JOIN ${tSessions} s ON s.f_session_id = c.f_session_id
|
|
413
|
-
WHERE ${
|
|
414
|
-
GROUP BY 1
|
|
476
|
+
WHERE EXISTS (SELECT 1 FROM ${tSessionUsage} ub WHERE ub.f_session_id = c.f_session_id)${dayFilter("c.f_day")}
|
|
477
|
+
GROUP BY 1
|
|
415
478
|
`);
|
|
416
479
|
const tokenSessions = await this.db.execute(sql`
|
|
417
|
-
SELECT
|
|
480
|
+
SELECT u.f_session_id AS session_id,
|
|
418
481
|
s.f_title AS title,
|
|
419
482
|
(s.f_origin = 'subagent') AS subagent,
|
|
420
483
|
(s.f_archived_at IS NOT NULL) AS archived,
|
|
421
|
-
${tokenColumns}
|
|
422
|
-
FROM ${
|
|
423
|
-
JOIN ${
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
GROUP BY b.f_session_id, s.f_title, s.f_origin, s.f_archived_at
|
|
484
|
+
${tokenColumns("u")}
|
|
485
|
+
FROM ${tSessionUsage} u
|
|
486
|
+
JOIN ${tSessions} s ON s.f_session_id = u.f_session_id
|
|
487
|
+
WHERE 1 = 1${dayFilter("u.f_day")}
|
|
488
|
+
GROUP BY u.f_session_id, s.f_title, s.f_origin, s.f_archived_at
|
|
427
489
|
`);
|
|
428
490
|
const activitySessions = await this.db.execute(sql`
|
|
429
491
|
SELECT c.f_session_id AS session_id,
|
|
430
|
-
c.
|
|
431
|
-
sum(c.
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
492
|
+
sum(c.f_turns) AS turns,
|
|
493
|
+
sum(c.f_steps) AS steps,
|
|
494
|
+
sum(c.f_user_inputs) AS user_inputs,
|
|
495
|
+
sum(c.f_tool_calls) AS tool_calls
|
|
496
|
+
FROM ${tSessionCounts} c
|
|
497
|
+
WHERE 1 = 1${dayFilter("c.f_day")}
|
|
498
|
+
GROUP BY c.f_session_id
|
|
435
499
|
`);
|
|
436
500
|
const human = emptyTotals();
|
|
437
501
|
const subagent = emptyTotals();
|
|
438
502
|
for (const row of tokenScope.rows) addTokenTotals(row["subagent"] === true ? subagent : human, tokenTotalsOf(row));
|
|
439
|
-
for (const row of activityScope.rows)
|
|
503
|
+
for (const row of activityScope.rows) addActivityTotals(row["subagent"] === true ? subagent : human, activityTotalsOf(row));
|
|
440
504
|
const totals = addTotals(addTotals(emptyTotals(), human), subagent);
|
|
441
505
|
const activityBySession = /* @__PURE__ */ new Map();
|
|
442
|
-
for (const row of activitySessions.rows)
|
|
443
|
-
const key = String(row["session_id"]);
|
|
444
|
-
const target = activityBySession.get(key) ?? emptyActivity();
|
|
445
|
-
addActivityCount(target, String(row["f_type"]), numeric(row["n"]));
|
|
446
|
-
activityBySession.set(key, target);
|
|
447
|
-
}
|
|
506
|
+
for (const row of activitySessions.rows) activityBySession.set(String(row["session_id"]), activityTotalsOf(row));
|
|
448
507
|
return {
|
|
449
508
|
totals,
|
|
450
509
|
subagent,
|
|
@@ -466,76 +525,100 @@ var PostgresBackend = class PostgresBackend {
|
|
|
466
525
|
}))
|
|
467
526
|
};
|
|
468
527
|
}
|
|
469
|
-
/** 表名限定到配置的 schema
|
|
528
|
+
/** 表名限定到配置的 schema。 */
|
|
470
529
|
qualifiedTable(name) {
|
|
471
530
|
const schema = this.options.schema ?? "public";
|
|
472
531
|
return schema === "public" ? name : `"${schema}".${name}`;
|
|
473
532
|
}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
const tEvents = this.tables["t_events"];
|
|
493
|
-
await this.db.execute(sql`
|
|
494
|
-
INSERT INTO ${sql.raw(table)} (f_session_id, f_day, f_type, f_count)
|
|
495
|
-
SELECT ${tSessionEvents}.f_session_id,
|
|
496
|
-
to_char(to_timestamp(${tEvents}.f_created_at / 1000.0), 'YYYY-MM-DD'),
|
|
497
|
-
${tEvents}.f_type,
|
|
498
|
-
count(*)
|
|
499
|
-
FROM ${tSessionEvents}
|
|
500
|
-
JOIN ${tEvents} ON ${tEvents}.f_event_id = ${tSessionEvents}.f_event_id
|
|
501
|
-
WHERE ${tEvents}.f_type IN (${sql.raw(COUNTED_EVENT_TYPE_SQL)})
|
|
502
|
-
GROUP BY 1, 2, 3
|
|
503
|
-
ON CONFLICT DO NOTHING
|
|
504
|
-
`);
|
|
533
|
+
/** token 用量旁路累加:派生表 `t_session_usage`,不在写事务里(失败可丢,表可销毁重建)。 */
|
|
534
|
+
async incrementSessionUsage(id, buckets) {
|
|
535
|
+
if (buckets.length === 0) return;
|
|
536
|
+
const tSessionUsage = this.tables["t_session_usage"];
|
|
537
|
+
for (const bucket of buckets) await this.db.execute(sql`
|
|
538
|
+
INSERT INTO ${tSessionUsage} (
|
|
539
|
+
f_session_id, f_day, f_provider, f_model,
|
|
540
|
+
f_input_tokens, f_output_tokens, f_cache_read_tokens, f_reasoning_tokens, f_total_tokens)
|
|
541
|
+
VALUES (${id}, ${bucket.day}, ${bucket.provider ?? ""}, ${bucket.model ?? ""},
|
|
542
|
+
${bucket.inputTokens}, ${bucket.outputTokens}, ${bucket.cacheReadTokens},
|
|
543
|
+
${bucket.reasoningTokens}, ${bucket.totalTokens})
|
|
544
|
+
ON CONFLICT (f_session_id, f_day, f_provider, f_model) DO UPDATE SET
|
|
545
|
+
f_input_tokens = ${tSessionUsage}.f_input_tokens + EXCLUDED.f_input_tokens,
|
|
546
|
+
f_output_tokens = ${tSessionUsage}.f_output_tokens + EXCLUDED.f_output_tokens,
|
|
547
|
+
f_cache_read_tokens = ${tSessionUsage}.f_cache_read_tokens + EXCLUDED.f_cache_read_tokens,
|
|
548
|
+
f_reasoning_tokens = ${tSessionUsage}.f_reasoning_tokens + EXCLUDED.f_reasoning_tokens,
|
|
549
|
+
f_total_tokens = ${tSessionUsage}.f_total_tokens + EXCLUDED.f_total_tokens
|
|
550
|
+
`);
|
|
505
551
|
}
|
|
506
|
-
/**
|
|
507
|
-
async
|
|
552
|
+
/** 活动计数旁路累加:派生表 `t_session_counts`,同上(四项计数一次落)。 */
|
|
553
|
+
async incrementSessionCounts(id, buckets) {
|
|
508
554
|
if (buckets.length === 0) return;
|
|
509
|
-
const
|
|
555
|
+
const tSessionCounts = this.tables["t_session_counts"];
|
|
510
556
|
for (const bucket of buckets) await this.db.execute(sql`
|
|
511
|
-
INSERT INTO ${
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
557
|
+
INSERT INTO ${tSessionCounts} (
|
|
558
|
+
f_session_id, f_day, f_turns, f_steps, f_user_inputs, f_tool_calls)
|
|
559
|
+
VALUES (${id}, ${bucket.day}, ${bucket.turns}, ${bucket.steps},
|
|
560
|
+
${bucket.userInputs}, ${bucket.toolCalls})
|
|
561
|
+
ON CONFLICT (f_session_id, f_day) DO UPDATE SET
|
|
562
|
+
f_turns = ${tSessionCounts}.f_turns + EXCLUDED.f_turns,
|
|
563
|
+
f_steps = ${tSessionCounts}.f_steps + EXCLUDED.f_steps,
|
|
564
|
+
f_user_inputs = ${tSessionCounts}.f_user_inputs + EXCLUDED.f_user_inputs,
|
|
565
|
+
f_tool_calls = ${tSessionCounts}.f_tool_calls + EXCLUDED.f_tool_calls
|
|
515
566
|
`);
|
|
516
567
|
}
|
|
517
|
-
/**
|
|
518
|
-
async
|
|
519
|
-
const
|
|
568
|
+
/** 按会话重算两张汇总表(rewind / fork 之后):先删该会话的行,再从事件表重算。 */
|
|
569
|
+
async rebuildSessionStats(id) {
|
|
570
|
+
const tSessionUsage = this.tables["t_session_usage"];
|
|
571
|
+
const tSessionCounts = this.tables["t_session_counts"];
|
|
520
572
|
const tSessionEvents = this.tables["t_session_events"];
|
|
573
|
+
const tEventUsage = this.tables["t_event_usage"];
|
|
521
574
|
const tEvents = this.tables["t_events"];
|
|
522
|
-
await this.db.execute(sql`DELETE FROM ${
|
|
575
|
+
await this.db.execute(sql`DELETE FROM ${tSessionUsage} WHERE f_session_id = ${id}`);
|
|
576
|
+
await this.db.execute(sql`
|
|
577
|
+
INSERT INTO ${tSessionUsage} (
|
|
578
|
+
f_session_id, f_day, f_provider, f_model,
|
|
579
|
+
f_input_tokens, f_output_tokens, f_cache_read_tokens, f_reasoning_tokens, f_total_tokens)
|
|
580
|
+
SELECT b.f_session_id, u.f_day, coalesce(u.f_provider, ''), coalesce(u.f_model, ''),
|
|
581
|
+
sum(u.f_input_tokens), sum(u.f_output_tokens), sum(u.f_cache_read_tokens),
|
|
582
|
+
sum(u.f_reasoning_tokens), sum(u.f_total_tokens)
|
|
583
|
+
FROM ${tSessionEvents} b
|
|
584
|
+
JOIN ${tEventUsage} u ON u.f_event_id = b.f_event_id
|
|
585
|
+
WHERE b.f_session_id = ${id}
|
|
586
|
+
GROUP BY 1, 2, 3, 4
|
|
587
|
+
`);
|
|
588
|
+
await this.db.execute(sql`DELETE FROM ${tSessionCounts} WHERE f_session_id = ${id}`);
|
|
523
589
|
await this.db.execute(sql`
|
|
524
|
-
INSERT INTO ${
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
590
|
+
INSERT INTO ${tSessionCounts} (
|
|
591
|
+
f_session_id, f_day, f_turns, f_steps, f_user_inputs, f_tool_calls)
|
|
592
|
+
SELECT b.f_session_id,
|
|
593
|
+
to_char(to_timestamp(e.f_created_at / 1000.0), 'YYYY-MM-DD'),
|
|
594
|
+
sum(CASE WHEN e.f_type = 'turn/start' THEN 1 ELSE 0 END),
|
|
595
|
+
sum(CASE WHEN e.f_type = 'step/start' THEN 1 ELSE 0 END),
|
|
596
|
+
sum(CASE WHEN e.f_type = 'user/message' THEN 1 ELSE 0 END),
|
|
597
|
+
sum(CASE WHEN e.f_type = 'tool/call' THEN 1 ELSE 0 END)
|
|
598
|
+
FROM ${tSessionEvents} b
|
|
599
|
+
JOIN ${tEvents} e ON e.f_event_id = b.f_event_id
|
|
600
|
+
WHERE b.f_session_id = ${id} AND e.f_type IN (${sql.raw(COUNTED_EVENT_TYPE_SQL)})
|
|
601
|
+
GROUP BY 1, 2
|
|
534
602
|
`);
|
|
535
603
|
}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
604
|
+
/** 全量重算用量行的引用标记(rewind / fork / 会话删除之后)。 */
|
|
605
|
+
async refreshEventUsageFlags() {
|
|
606
|
+
const tEventUsage = this.tables["t_event_usage"];
|
|
607
|
+
const tSessionEvents = this.tables["t_session_events"];
|
|
608
|
+
const tSessions = this.tables["t_sessions"];
|
|
609
|
+
await this.db.execute(sql`
|
|
610
|
+
UPDATE ${tEventUsage} SET
|
|
611
|
+
f_referenced = (EXISTS (SELECT 1 FROM ${tSessionEvents} rb
|
|
612
|
+
WHERE rb.f_event_id = ${tEventUsage}.f_event_id))::int,
|
|
613
|
+
f_subagent = (EXISTS (SELECT 1 FROM ${tSessionEvents} sb
|
|
614
|
+
JOIN ${tSessions} ss ON ss.f_session_id = sb.f_session_id
|
|
615
|
+
WHERE sb.f_event_id = ${tEventUsage}.f_event_id
|
|
616
|
+
AND ss.f_origin = 'subagent'))::int
|
|
617
|
+
`);
|
|
618
|
+
}
|
|
619
|
+
async deleteSessionStats(id) {
|
|
620
|
+
await this.db.execute(sql`DELETE FROM ${this.tables["t_session_usage"]} WHERE f_session_id = ${id}`);
|
|
621
|
+
await this.db.execute(sql`DELETE FROM ${this.tables["t_session_counts"]} WHERE f_session_id = ${id}`);
|
|
539
622
|
}
|
|
540
623
|
async getPrevBridge(exec, id, sequence) {
|
|
541
624
|
return (await exec.select({
|
|
@@ -622,130 +705,6 @@ function registerSessionDeletion(ctx, persistence) {
|
|
|
622
705
|
});
|
|
623
706
|
}
|
|
624
707
|
//#endregion
|
|
625
|
-
//#region src/export.ts
|
|
626
|
-
const SESSION_EXPORT_PATH = "/api/session.export";
|
|
627
|
-
/** 文件名只保留安全字符:会话 id 来自请求体,会写进 Content-Disposition。 */
|
|
628
|
-
function safeFilename(sessionId) {
|
|
629
|
-
return sessionId.replace(/[^A-Za-z0-9._-]/gu, "_");
|
|
630
|
-
}
|
|
631
|
-
/** fflate 只给回调式异步 API(同步变体被 node/no-sync 禁止)。 */
|
|
632
|
-
function zipBytes(files) {
|
|
633
|
-
return new Promise((resolve, reject) => {
|
|
634
|
-
zip(files, (error, data) => {
|
|
635
|
-
if (error === null) resolve(data);
|
|
636
|
-
else reject(error);
|
|
637
|
-
});
|
|
638
|
-
});
|
|
639
|
-
}
|
|
640
|
-
/** 导出通道:直接把会话日志打成 zip 响应体,与导入通道读同一份 artifact。 */
|
|
641
|
-
function registerSessionExport(ctx, persistence) {
|
|
642
|
-
ctx.inject(["webServer", "connection"], (webCtx) => {
|
|
643
|
-
const webServer = webCtx.webServer;
|
|
644
|
-
const connection = webCtx.get("connection");
|
|
645
|
-
return webCtx.effect(() => webServer.register({
|
|
646
|
-
kind: "exact",
|
|
647
|
-
path: SESSION_EXPORT_PATH,
|
|
648
|
-
handler: async (req, res) => {
|
|
649
|
-
const rejection = connection.requestRejection(req);
|
|
650
|
-
if (rejection !== void 0) {
|
|
651
|
-
res.writeHead(rejection);
|
|
652
|
-
res.end(rejection === 401 ? "unauthorized" : "forbidden");
|
|
653
|
-
return;
|
|
654
|
-
}
|
|
655
|
-
if (req.method !== "POST") {
|
|
656
|
-
res.writeHead(405, { "content-type": "application/json" });
|
|
657
|
-
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
658
|
-
return;
|
|
659
|
-
}
|
|
660
|
-
const chunks = [];
|
|
661
|
-
for await (const chunk of req) chunks.push(chunk);
|
|
662
|
-
let envelope;
|
|
663
|
-
try {
|
|
664
|
-
envelope = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
665
|
-
} catch {
|
|
666
|
-
res.writeHead(400, { "content-type": "application/json" });
|
|
667
|
-
res.end(JSON.stringify({ error: "request body is not JSON" }));
|
|
668
|
-
return;
|
|
669
|
-
}
|
|
670
|
-
if (typeof envelope.sessionId !== "string" || envelope.sessionId === "") {
|
|
671
|
-
res.writeHead(400, { "content-type": "application/json" });
|
|
672
|
-
res.end(JSON.stringify({ error: "missing sessionId field" }));
|
|
673
|
-
return;
|
|
674
|
-
}
|
|
675
|
-
const sessionId = envelope.sessionId;
|
|
676
|
-
try {
|
|
677
|
-
const raw = await persistence.readRaw(sessionId);
|
|
678
|
-
if (raw === void 0) {
|
|
679
|
-
res.writeHead(404, { "content-type": "application/json" });
|
|
680
|
-
res.end(JSON.stringify({ error: `session "${sessionId}" not found` }));
|
|
681
|
-
return;
|
|
682
|
-
}
|
|
683
|
-
const zipFile = await zipBytes({ [raw.filename]: strToU8(raw.content) });
|
|
684
|
-
res.writeHead(200, {
|
|
685
|
-
"content-type": "application/zip",
|
|
686
|
-
"content-length": String(zipFile.byteLength),
|
|
687
|
-
"content-disposition": `attachment; filename="${safeFilename(sessionId)}.zip"`
|
|
688
|
-
});
|
|
689
|
-
res.end(Buffer.from(zipFile));
|
|
690
|
-
} catch (error) {
|
|
691
|
-
res.writeHead(500, { "content-type": "application/json" });
|
|
692
|
-
res.end(JSON.stringify({ error: error instanceof Error ? error.message : "session export failed" }));
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
}), `session-rdb: ${SESSION_EXPORT_PATH} route`);
|
|
696
|
-
});
|
|
697
|
-
}
|
|
698
|
-
//#endregion
|
|
699
|
-
//#region src/gc.ts
|
|
700
|
-
const SESSION_GC_PATH = "/api/session.gc";
|
|
701
|
-
/** 让所有运行中的 agent 先退场:GC 要重写事件表,运行中的写路径必须停下来。 */
|
|
702
|
-
async function stopRunningAgents(ctx) {
|
|
703
|
-
const running = ctx.get("agents")?.list() ?? [];
|
|
704
|
-
for (const agent of running) agent.cancel?.({ kind: "user" }, { keepInbox: true });
|
|
705
|
-
await Promise.all(running.map((agent) => agent.whenIdle()));
|
|
706
|
-
return running.length;
|
|
707
|
-
}
|
|
708
|
-
/** GC 通道:停 agent → 回收孤儿事件行 → VACUUM,一次请求完成。 */
|
|
709
|
-
function registerSessionGc(ctx, persistence) {
|
|
710
|
-
ctx.inject(["webServer", "connection"], (webCtx) => {
|
|
711
|
-
const webServer = webCtx.webServer;
|
|
712
|
-
const connection = webCtx.get("connection");
|
|
713
|
-
return webCtx.effect(() => webServer.register({
|
|
714
|
-
kind: "exact",
|
|
715
|
-
path: SESSION_GC_PATH,
|
|
716
|
-
handler: async (req, res) => {
|
|
717
|
-
const rejection = connection.requestRejection(req);
|
|
718
|
-
if (rejection !== void 0) {
|
|
719
|
-
res.writeHead(rejection);
|
|
720
|
-
res.end(rejection === 401 ? "unauthorized" : "forbidden");
|
|
721
|
-
return;
|
|
722
|
-
}
|
|
723
|
-
if (req.method !== "POST") {
|
|
724
|
-
res.writeHead(405, { "content-type": "application/json" });
|
|
725
|
-
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
726
|
-
return;
|
|
727
|
-
}
|
|
728
|
-
try {
|
|
729
|
-
const stoppedAgents = await stopRunningAgents(webCtx);
|
|
730
|
-
const orphanSessions = await persistence.collectOrphanSessions();
|
|
731
|
-
const orphanEvents = await persistence.collectOrphans();
|
|
732
|
-
await persistence.vacuum();
|
|
733
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
734
|
-
res.end(JSON.stringify({
|
|
735
|
-
orphanSessions,
|
|
736
|
-
orphanEvents,
|
|
737
|
-
stoppedAgents,
|
|
738
|
-
vacuumed: true
|
|
739
|
-
}));
|
|
740
|
-
} catch (error) {
|
|
741
|
-
res.writeHead(500, { "content-type": "application/json" });
|
|
742
|
-
res.end(JSON.stringify({ error: error instanceof Error ? error.message : "gc failed" }));
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
}), `session-rdb: ${SESSION_GC_PATH} route`);
|
|
746
|
-
});
|
|
747
|
-
}
|
|
748
|
-
//#endregion
|
|
749
708
|
//#region src/rows.ts
|
|
750
709
|
/**
|
|
751
710
|
* 管理面的会话列表:**我们自己的**一条路由(完整语料,含归档),与官方 `session/list` 分开。
|
|
@@ -1467,38 +1426,62 @@ var RdbSessionHandle = class RdbSessionHandle {
|
|
|
1467
1426
|
}
|
|
1468
1427
|
static LIVE_WRITE_BATCH_MAX_DELAY_MS = 200;
|
|
1469
1428
|
};
|
|
1429
|
+
/**
|
|
1430
|
+
* 本地化说明:`description()` 的类型签名只声明 `string`,而 meta 本身接受 `Dict<string>`
|
|
1431
|
+
* (`vendor/schemastery/src/index.ts` 的 `mergeDesc` 就是按字典合并的),所以这里只做一次类型放行。
|
|
1432
|
+
*/
|
|
1433
|
+
const localized = (text) => text;
|
|
1434
|
+
/** 投影 checkpoint 的写回节流:两个分支共用。 */
|
|
1435
|
+
const projectionCacheSchema = z.object({
|
|
1436
|
+
writeEveryEvents: z.natural().min(1).default(DEFAULT_PROJECTION_WRITE_EVERY_EVENTS).description(localized({
|
|
1437
|
+
zh: "每累计这么多事件写回一次投影 checkpoint。",
|
|
1438
|
+
en: "Write the projection checkpoint back after this many events."
|
|
1439
|
+
})),
|
|
1440
|
+
writeIntervalMs: z.natural().min(1).default(DEFAULT_PROJECTION_WRITE_INTERVAL_MS).description(localized({
|
|
1441
|
+
zh: "距离上次写回超过这个间隔(毫秒)时也写一次。",
|
|
1442
|
+
en: "Also write back when this many ms passed since the last one."
|
|
1443
|
+
}))
|
|
1444
|
+
}).default({
|
|
1445
|
+
writeEveryEvents: DEFAULT_PROJECTION_WRITE_EVERY_EVENTS,
|
|
1446
|
+
writeIntervalMs: DEFAULT_PROJECTION_WRITE_INTERVAL_MS
|
|
1447
|
+
});
|
|
1470
1448
|
var SessionPersistenceRdb = class extends SessionPersistence {
|
|
1471
|
-
config;
|
|
1472
1449
|
static inject = ["sessions"];
|
|
1473
1450
|
static Config = z.union([z.object({
|
|
1474
1451
|
type: z.const("sqlite"),
|
|
1475
|
-
path: z.string().required()
|
|
1452
|
+
path: z.string().required().description(localized({
|
|
1453
|
+
zh: "SQLite 数据库文件路径;`:memory:` 是内存库(进程结束即丢)。",
|
|
1454
|
+
en: "SQLite database file path; `:memory:` keeps it in memory and loses it with the process."
|
|
1455
|
+
})),
|
|
1476
1456
|
journalMode: z.union([
|
|
1477
1457
|
"wal",
|
|
1478
1458
|
"delete",
|
|
1479
1459
|
"truncate",
|
|
1480
1460
|
"persist"
|
|
1481
|
-
]).default("wal")
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1461
|
+
]).default("wal").description(localized({
|
|
1462
|
+
zh: "SQLite 日志模式;默认 wal(并发读最好)。",
|
|
1463
|
+
en: "SQLite journal mode; `wal` by default (best concurrent reads)."
|
|
1464
|
+
})),
|
|
1465
|
+
busyTimeout: z.number().step(1).min(0).default(DEFAULT_BUSY_TIMEOUT_MS).description(localized({
|
|
1466
|
+
zh: "写锁冲突时的忙等超时(毫秒)。",
|
|
1467
|
+
en: "Busy timeout in ms while a write lock is contended."
|
|
1468
|
+
})),
|
|
1469
|
+
projectionCache: projectionCacheSchema
|
|
1490
1470
|
}), z.object({
|
|
1491
1471
|
type: z.const("postgres"),
|
|
1492
|
-
connectionString: z.string().required()
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
})])
|
|
1472
|
+
connectionString: z.string().required().description(localized({
|
|
1473
|
+
zh: "PostgreSQL 连接串(含库名与凭证)。",
|
|
1474
|
+
en: "PostgreSQL connection string (database and credentials)."
|
|
1475
|
+
})),
|
|
1476
|
+
schema: z.string().default("public").description(localized({
|
|
1477
|
+
zh: "库内的 schema 名;默认 public。",
|
|
1478
|
+
en: "Schema name inside the database; `public` by default."
|
|
1479
|
+
})),
|
|
1480
|
+
projectionCache: projectionCacheSchema
|
|
1481
|
+
})]).description(localized({
|
|
1482
|
+
zh: "会话持久化后端:改这里的连接参数会在这一行重挂时生效(Loader 会重装被改的行)。",
|
|
1483
|
+
en: "Session persistence backend: edits apply when this row is remounted (the Loader re-installs changed rows)."
|
|
1484
|
+
})).volatile();
|
|
1502
1485
|
name = "session-rdb";
|
|
1503
1486
|
tracker = new RdbBackendTracker(this.name);
|
|
1504
1487
|
backend;
|
|
@@ -1508,10 +1491,12 @@ var SessionPersistenceRdb = class extends SessionPersistence {
|
|
|
1508
1491
|
reuseEventIds = /* @__PURE__ */ new Map();
|
|
1509
1492
|
liveBuffers = /* @__PURE__ */ new Map();
|
|
1510
1493
|
liveReady = /* @__PURE__ */ new Map();
|
|
1494
|
+
/** 本次装配用的配置快照(整段 Config 是 volatile,重挂会给新的)。 */
|
|
1495
|
+
config;
|
|
1511
1496
|
constructor(ctx, config, injectedBackend) {
|
|
1512
1497
|
super(ctx);
|
|
1513
|
-
this.config = config;
|
|
1514
|
-
this.backend = injectedBackend ?? createBackend(config);
|
|
1498
|
+
this.config = config.get();
|
|
1499
|
+
this.backend = injectedBackend ?? createBackend(this.config);
|
|
1515
1500
|
this.ready = this.init();
|
|
1516
1501
|
this.installLiveRouting(ctx);
|
|
1517
1502
|
new SessionBranchRdb(this.ctx);
|
|
@@ -1657,7 +1642,7 @@ var SessionPersistenceRdb = class extends SessionPersistence {
|
|
|
1657
1642
|
this.liveFailures.delete(id);
|
|
1658
1643
|
this.liveDropWarned.delete(id);
|
|
1659
1644
|
this.reuseEventIds.delete(id);
|
|
1660
|
-
await this.
|
|
1645
|
+
await this.dropSessionStats(id);
|
|
1661
1646
|
}
|
|
1662
1647
|
/** GC 通道:回收已无桥接行引用的事件行(孤儿),返回删除行数。 */
|
|
1663
1648
|
async collectOrphans() {
|
|
@@ -1676,7 +1661,7 @@ var SessionPersistenceRdb = class extends SessionPersistence {
|
|
|
1676
1661
|
this.liveFailures.delete(id);
|
|
1677
1662
|
this.liveDropWarned.delete(id);
|
|
1678
1663
|
this.reuseEventIds.delete(id);
|
|
1679
|
-
await this.
|
|
1664
|
+
await this.dropSessionStats(id);
|
|
1680
1665
|
}
|
|
1681
1666
|
return deleted;
|
|
1682
1667
|
}
|
|
@@ -1727,9 +1712,11 @@ var SessionPersistenceRdb = class extends SessionPersistence {
|
|
|
1727
1712
|
async appendBatch(meta, inheritedEventCount, events, tornTruncateTo) {
|
|
1728
1713
|
await this.ready;
|
|
1729
1714
|
if (events.length === 0) return false;
|
|
1715
|
+
sealOwnEvents(events);
|
|
1730
1716
|
validateStoredEvents(meta, [...events]);
|
|
1731
1717
|
const reuse = this.reuseEventIds.get(meta.id);
|
|
1732
1718
|
let confirmedHead = -1;
|
|
1719
|
+
let usageRows = [];
|
|
1733
1720
|
await this.backend.transaction(async (tx) => {
|
|
1734
1721
|
if (tornTruncateTo !== void 0) {
|
|
1735
1722
|
await tx.deleteBridgeTail(meta.id, tornTruncateTo);
|
|
@@ -1743,18 +1730,19 @@ var SessionPersistenceRdb = class extends SessionPersistence {
|
|
|
1743
1730
|
}, randomUUID());
|
|
1744
1731
|
const head = await tx.getHead(meta.id);
|
|
1745
1732
|
this.writeGuard.assertNoConcurrentWriter(meta.id, head.fHeadSequence);
|
|
1746
|
-
const
|
|
1733
|
+
const appended = await appendEventTail(tx, meta, events, {
|
|
1747
1734
|
parentId: head.fHeadEventId,
|
|
1748
1735
|
nextSeq: head.fHeadSequence + 1
|
|
1749
1736
|
}, reuse);
|
|
1750
|
-
|
|
1737
|
+
usageRows = appended.usageRows;
|
|
1738
|
+
await tx.updateHead(meta.id, appended.headEventId, appended.headSequence);
|
|
1751
1739
|
await tx.bumpRevision(meta.id, maxEventTime(events));
|
|
1752
|
-
confirmedHead = headSequence;
|
|
1740
|
+
confirmedHead = appended.headSequence;
|
|
1753
1741
|
});
|
|
1754
1742
|
if (reuse !== void 0) this.reuseEventIds.delete(meta.id);
|
|
1755
1743
|
this.writeGuard.confirmHead(meta.id, confirmedHead);
|
|
1756
1744
|
this.tracker.materialized(meta.id);
|
|
1757
|
-
await this.
|
|
1745
|
+
await this.recordSessionStats(meta.id, events, usageRows);
|
|
1758
1746
|
return true;
|
|
1759
1747
|
}
|
|
1760
1748
|
async readLog(id, options = {}, signal) {
|
|
@@ -1792,7 +1780,7 @@ var SessionPersistenceRdb = class extends SessionPersistence {
|
|
|
1792
1780
|
};
|
|
1793
1781
|
}
|
|
1794
1782
|
const { preserved, tornFrom } = scanRows(eventRows, options.fromSeq ?? 0);
|
|
1795
|
-
if (
|
|
1783
|
+
if (needsShapeAdoption(preserved)) {
|
|
1796
1784
|
const adopted = adoptLegacyRows(row, eventRows);
|
|
1797
1785
|
return {
|
|
1798
1786
|
meta: adopted.meta,
|
|
@@ -1817,32 +1805,43 @@ var SessionPersistenceRdb = class extends SessionPersistence {
|
|
|
1817
1805
|
};
|
|
1818
1806
|
}
|
|
1819
1807
|
/**
|
|
1820
|
-
*
|
|
1821
|
-
*
|
|
1808
|
+
* 统计衍生表的**旁路累加**:不在写事务里、失败只 warn——两张会话汇总表都是可销毁重建的
|
|
1809
|
+
* 派生表,丢几次累加不影响可用性(需要时用 `rebuildSessionStats` 重算)。
|
|
1810
|
+
* token 走写路径已折好的用量行,活动计数走本批事件类型。
|
|
1822
1811
|
*/
|
|
1823
|
-
async
|
|
1824
|
-
const
|
|
1825
|
-
if (
|
|
1826
|
-
|
|
1827
|
-
|
|
1812
|
+
async recordSessionStats(id, events, usageRows) {
|
|
1813
|
+
const usageBuckets = sessionUsageBuckets(usageRows);
|
|
1814
|
+
if (usageBuckets.length > 0) try {
|
|
1815
|
+
await this.backend.incrementSessionUsage(id, usageBuckets);
|
|
1816
|
+
} catch (error) {
|
|
1817
|
+
this.ctx.logger.warn(`session-rdb: token usage for "${id}" not updated (${describeError(error)})`);
|
|
1818
|
+
}
|
|
1819
|
+
const countBuckets = sessionCountBuckets(events);
|
|
1820
|
+
if (countBuckets.length > 0) try {
|
|
1821
|
+
await this.backend.incrementSessionCounts(id, countBuckets);
|
|
1828
1822
|
} catch (error) {
|
|
1829
1823
|
this.ctx.logger.warn(`session-rdb: activity counts for "${id}" not updated (${describeError(error)})`);
|
|
1830
1824
|
}
|
|
1831
1825
|
}
|
|
1832
|
-
/**
|
|
1833
|
-
|
|
1826
|
+
/**
|
|
1827
|
+
* 重算一个会话的统计(rewind / fork 之后;best-effort,同 `recordSessionStats`):
|
|
1828
|
+
* 两张汇总表按会话重算,用量行的引用标记全量重算(引用可能跨会话消失)。
|
|
1829
|
+
*/
|
|
1830
|
+
async rebuildSessionStats(id) {
|
|
1834
1831
|
try {
|
|
1835
|
-
await this.backend.
|
|
1832
|
+
await this.backend.rebuildSessionStats(id);
|
|
1833
|
+
await this.backend.refreshEventUsageFlags();
|
|
1836
1834
|
} catch (error) {
|
|
1837
|
-
this.ctx.logger.warn(`session-rdb:
|
|
1835
|
+
this.ctx.logger.warn(`session-rdb: session stats for "${id}" not rebuilt (${describeError(error)})`);
|
|
1838
1836
|
}
|
|
1839
1837
|
}
|
|
1840
|
-
/**
|
|
1841
|
-
async
|
|
1838
|
+
/** 会话删除时清掉它的汇总行,并重算用量行的引用标记(被删会话引用的行要立刻退出统计)。 */
|
|
1839
|
+
async dropSessionStats(id) {
|
|
1842
1840
|
try {
|
|
1843
|
-
await this.backend.
|
|
1841
|
+
await this.backend.deleteSessionStats(id);
|
|
1842
|
+
await this.backend.refreshEventUsageFlags();
|
|
1844
1843
|
} catch (error) {
|
|
1845
|
-
this.ctx.logger.warn(`session-rdb:
|
|
1844
|
+
this.ctx.logger.warn(`session-rdb: session stats for "${id}" not deleted (${describeError(error)})`);
|
|
1846
1845
|
}
|
|
1847
1846
|
}
|
|
1848
1847
|
async rewriteMigratedLog(id, log) {
|
|
@@ -1855,13 +1854,14 @@ var SessionPersistenceRdb = class extends SessionPersistence {
|
|
|
1855
1854
|
meta: log.meta,
|
|
1856
1855
|
inheritedEventCount: SessionLogOffset(log.inheritedEventCount)
|
|
1857
1856
|
}, randomUUID());
|
|
1858
|
-
const
|
|
1857
|
+
const appended = await appendEventTail(tx, log.meta, log.events, {
|
|
1859
1858
|
parentId: "",
|
|
1860
1859
|
nextSeq: 0
|
|
1861
1860
|
});
|
|
1862
|
-
await tx.updateHead(id, headEventId, headSequence);
|
|
1861
|
+
await tx.updateHead(id, appended.headEventId, appended.headSequence);
|
|
1863
1862
|
await tx.bumpRevision(id, maxEventTime(log.events));
|
|
1864
1863
|
});
|
|
1864
|
+
await this.rebuildSessionStats(id);
|
|
1865
1865
|
}
|
|
1866
1866
|
async listSnapshots(signal) {
|
|
1867
1867
|
signal?.throwIfAborted();
|
|
@@ -2091,19 +2091,46 @@ function seedCoversPrefix(seed, prefix) {
|
|
|
2091
2091
|
return seedEvent !== void 0 && JSON.stringify(seedEvent) === JSON.stringify(event);
|
|
2092
2092
|
});
|
|
2093
2093
|
}
|
|
2094
|
-
/**
|
|
2095
|
-
function
|
|
2094
|
+
/** 一批事件折成活动计数的桶:只数四种类型,按本地日聚合成四项。 */
|
|
2095
|
+
function sessionCountBuckets(events) {
|
|
2096
2096
|
const counted = /* @__PURE__ */ new Map();
|
|
2097
2097
|
for (const event of events) {
|
|
2098
2098
|
if (!COUNTED_EVENT_TYPES.includes(event.type)) continue;
|
|
2099
2099
|
const day = localDayKey(event.time);
|
|
2100
|
-
const
|
|
2101
|
-
const bucket = counted.get(key) ?? {
|
|
2100
|
+
const bucket = counted.get(day) ?? {
|
|
2102
2101
|
day,
|
|
2103
|
-
|
|
2104
|
-
|
|
2102
|
+
turns: 0,
|
|
2103
|
+
steps: 0,
|
|
2104
|
+
userInputs: 0,
|
|
2105
|
+
toolCalls: 0
|
|
2105
2106
|
};
|
|
2106
|
-
bucket.
|
|
2107
|
+
addActivityCount(bucket, event.type, 1);
|
|
2108
|
+
counted.set(day, bucket);
|
|
2109
|
+
}
|
|
2110
|
+
return [...counted.values()];
|
|
2111
|
+
}
|
|
2112
|
+
/** 一批用量行折成 token 桶:按「本地日 × 模型」聚合(模型未知落空串,与表口径一致)。 */
|
|
2113
|
+
function sessionUsageBuckets(usageRows) {
|
|
2114
|
+
const counted = /* @__PURE__ */ new Map();
|
|
2115
|
+
for (const row of usageRows) {
|
|
2116
|
+
const provider = row.fProvider ?? "";
|
|
2117
|
+
const model = row.fModel ?? "";
|
|
2118
|
+
const key = `${row.fDay}#${provider}#${model}`;
|
|
2119
|
+
const bucket = counted.get(key) ?? {
|
|
2120
|
+
day: row.fDay,
|
|
2121
|
+
provider,
|
|
2122
|
+
model,
|
|
2123
|
+
inputTokens: 0,
|
|
2124
|
+
outputTokens: 0,
|
|
2125
|
+
cacheReadTokens: 0,
|
|
2126
|
+
reasoningTokens: 0,
|
|
2127
|
+
totalTokens: 0
|
|
2128
|
+
};
|
|
2129
|
+
bucket.inputTokens += row.fInputTokens;
|
|
2130
|
+
bucket.outputTokens += row.fOutputTokens;
|
|
2131
|
+
bucket.cacheReadTokens += row.fCacheReadTokens;
|
|
2132
|
+
bucket.reasoningTokens += row.fReasoningTokens;
|
|
2133
|
+
bucket.totalTokens += row.fTotalTokens;
|
|
2107
2134
|
counted.set(key, bucket);
|
|
2108
2135
|
}
|
|
2109
2136
|
return [...counted.values()];
|
|
@@ -2135,6 +2162,7 @@ async function appendEventTail(tx, meta, events, anchor, reuse) {
|
|
|
2135
2162
|
let parentId = anchor.parentId;
|
|
2136
2163
|
let nextSeq = anchor.nextSeq;
|
|
2137
2164
|
const eventRows = [];
|
|
2165
|
+
const usageRows = [];
|
|
2138
2166
|
const bridgeRows = [];
|
|
2139
2167
|
for (const event of events) {
|
|
2140
2168
|
const reusedId = reuse?.get(event.seq);
|
|
@@ -2142,7 +2170,7 @@ async function appendEventTail(tx, meta, events, anchor, reuse) {
|
|
|
2142
2170
|
if (reusedId === void 0) {
|
|
2143
2171
|
const { kind, role, name, actionId } = eventDimensions(event);
|
|
2144
2172
|
const { data, surfaceOp: _surfaceOp, sourceEventSeqs: _sourceEventSeqs, ...envelope } = event;
|
|
2145
|
-
|
|
2173
|
+
const row = {
|
|
2146
2174
|
fEventId: eventId,
|
|
2147
2175
|
fParentId: parentId,
|
|
2148
2176
|
fType: event.type,
|
|
@@ -2156,7 +2184,10 @@ async function appendEventTail(tx, meta, events, anchor, reuse) {
|
|
|
2156
2184
|
data
|
|
2157
2185
|
}),
|
|
2158
2186
|
fCreatedAt: event.time
|
|
2159
|
-
}
|
|
2187
|
+
};
|
|
2188
|
+
eventRows.push(row);
|
|
2189
|
+
const usageRow = usageRowOf(row, meta.origin === "subagent");
|
|
2190
|
+
if (usageRow !== void 0) usageRows.push(usageRow);
|
|
2160
2191
|
}
|
|
2161
2192
|
const surfaceOp = event.surfaceOp === void 0 ? null : JSON.stringify(event.surfaceOp);
|
|
2162
2193
|
bridgeRows.push({
|
|
@@ -2168,12 +2199,13 @@ async function appendEventTail(tx, meta, events, anchor, reuse) {
|
|
|
2168
2199
|
parentId = eventId;
|
|
2169
2200
|
nextSeq++;
|
|
2170
2201
|
}
|
|
2171
|
-
if (eventRows.length > 0) await tx.insertEvents(eventRows);
|
|
2202
|
+
if (eventRows.length > 0) await tx.insertEvents(eventRows, usageRows);
|
|
2172
2203
|
await tx.insertBridges(bridgeRows);
|
|
2173
2204
|
if (events.some((event) => event.type === "session/title")) await tx.refreshTitle(meta.id);
|
|
2174
2205
|
return {
|
|
2175
2206
|
headEventId: parentId,
|
|
2176
|
-
headSequence: nextSeq - 1
|
|
2207
|
+
headSequence: nextSeq - 1,
|
|
2208
|
+
usageRows
|
|
2177
2209
|
};
|
|
2178
2210
|
}
|
|
2179
2211
|
//#endregion
|