@wfm-cn/dsh-storage 0.0.0-stage → 0.3.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/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +137 -2
- package/cordis.patch.yml +3 -0
- package/lib/backends/database.js +578 -0
- package/lib/backfill.js +331 -0
- package/lib/client/index.js +79 -0
- package/lib/client/panel.js +299 -0
- package/lib/client/toast.js +66 -0
- package/lib/client.js +1 -0
- package/lib/compaction.js +108 -0
- package/lib/config.js +171 -0
- package/lib/ddl.js +323 -0
- package/lib/generated/mysql/browser.js +17 -0
- package/lib/generated/mysql/client.js +34 -0
- package/lib/generated/mysql/commonInputTypes.js +10 -0
- package/lib/generated/mysql/enums.js +10 -0
- package/lib/generated/mysql/internal/class.js +49 -0
- package/lib/generated/mysql/internal/prismaNamespace.js +146 -0
- package/lib/generated/mysql/internal/prismaNamespaceBrowser.js +117 -0
- package/lib/generated/mysql/models/AiChatHistory.js +1 -0
- package/lib/generated/mysql/models/AiMessage.js +1 -0
- package/lib/generated/mysql/models.js +1 -0
- package/lib/generated/postgresql/browser.js +17 -0
- package/lib/generated/postgresql/client.js +34 -0
- package/lib/generated/postgresql/commonInputTypes.js +10 -0
- package/lib/generated/postgresql/enums.js +10 -0
- package/lib/generated/postgresql/internal/class.js +49 -0
- package/lib/generated/postgresql/internal/prismaNamespace.js +131 -0
- package/lib/generated/postgresql/internal/prismaNamespaceBrowser.js +102 -0
- package/lib/generated/postgresql/models/AiChatHistory.js +1 -0
- package/lib/generated/postgresql/models/AiMessage.js +1 -0
- package/lib/generated/postgresql/models.js +1 -0
- package/lib/generated/sqlite/browser.js +17 -0
- package/lib/generated/sqlite/client.js +34 -0
- package/lib/generated/sqlite/commonInputTypes.js +10 -0
- package/lib/generated/sqlite/enums.js +10 -0
- package/lib/generated/sqlite/internal/class.js +49 -0
- package/lib/generated/sqlite/internal/prismaNamespace.js +128 -0
- package/lib/generated/sqlite/internal/prismaNamespaceBrowser.js +99 -0
- package/lib/generated/sqlite/models/AiChatHistory.js +1 -0
- package/lib/generated/sqlite/models/AiMessage.js +1 -0
- package/lib/generated/sqlite/models.js +1 -0
- package/lib/generated/sqlserver/browser.js +17 -0
- package/lib/generated/sqlserver/client.js +34 -0
- package/lib/generated/sqlserver/commonInputTypes.js +10 -0
- package/lib/generated/sqlserver/enums.js +10 -0
- package/lib/generated/sqlserver/internal/class.js +49 -0
- package/lib/generated/sqlserver/internal/prismaNamespace.js +119 -0
- package/lib/generated/sqlserver/internal/prismaNamespaceBrowser.js +90 -0
- package/lib/generated/sqlserver/models/AiChatHistory.js +1 -0
- package/lib/generated/sqlserver/models/AiMessage.js +1 -0
- package/lib/generated/sqlserver/models.js +1 -0
- package/lib/health.js +180 -0
- package/lib/index.js +681 -0
- package/lib/memory-tools.js +365 -0
- package/lib/notify.js +56 -0
- package/lib/projector.js +191 -0
- package/lib/status-route.js +172 -0
- package/lib/types/backends/database.d.ts +78 -0
- package/lib/types/backfill.d.ts +67 -0
- package/lib/types/client/index.d.ts +12 -0
- package/lib/types/client/panel.d.ts +79 -0
- package/lib/types/client/toast.d.ts +38 -0
- package/lib/types/compaction.d.ts +3 -0
- package/lib/types/config.d.ts +52 -0
- package/lib/types/ddl.d.ts +49 -0
- package/lib/types/generated/mysql/browser.d.ts +14 -0
- package/lib/types/generated/mysql/client.d.ts +33 -0
- package/lib/types/generated/mysql/commonInputTypes.d.ts +396 -0
- package/lib/types/generated/mysql/enums.d.ts +1 -0
- package/lib/types/generated/mysql/internal/class.d.ts +143 -0
- package/lib/types/generated/mysql/internal/prismaNamespace.d.ts +709 -0
- package/lib/types/generated/mysql/internal/prismaNamespaceBrowser.d.ts +112 -0
- package/lib/types/generated/mysql/models/AiChatHistory.d.ts +1258 -0
- package/lib/types/generated/mysql/models/AiMessage.d.ts +1359 -0
- package/lib/types/generated/mysql/models.d.ts +3 -0
- package/lib/types/generated/postgresql/browser.d.ts +14 -0
- package/lib/types/generated/postgresql/client.d.ts +33 -0
- package/lib/types/generated/postgresql/commonInputTypes.d.ts +392 -0
- package/lib/types/generated/postgresql/enums.d.ts +1 -0
- package/lib/types/generated/postgresql/internal/class.d.ts +143 -0
- package/lib/types/generated/postgresql/internal/prismaNamespace.d.ts +728 -0
- package/lib/types/generated/postgresql/internal/prismaNamespaceBrowser.d.ts +95 -0
- package/lib/types/generated/postgresql/models/AiChatHistory.d.ts +1373 -0
- package/lib/types/generated/postgresql/models/AiMessage.d.ts +1496 -0
- package/lib/types/generated/postgresql/models.d.ts +3 -0
- package/lib/types/generated/sqlite/browser.d.ts +14 -0
- package/lib/types/generated/sqlite/client.d.ts +33 -0
- package/lib/types/generated/sqlite/commonInputTypes.d.ts +373 -0
- package/lib/types/generated/sqlite/enums.d.ts +1 -0
- package/lib/types/generated/sqlite/internal/class.d.ts +143 -0
- package/lib/types/generated/sqlite/internal/prismaNamespace.d.ts +705 -0
- package/lib/types/generated/sqlite/internal/prismaNamespaceBrowser.d.ts +92 -0
- package/lib/types/generated/sqlite/models/AiChatHistory.d.ts +1371 -0
- package/lib/types/generated/sqlite/models/AiMessage.d.ts +1493 -0
- package/lib/types/generated/sqlite/models.d.ts +3 -0
- package/lib/types/generated/sqlserver/browser.d.ts +14 -0
- package/lib/types/generated/sqlserver/client.d.ts +33 -0
- package/lib/types/generated/sqlserver/commonInputTypes.d.ts +333 -0
- package/lib/types/generated/sqlserver/enums.d.ts +1 -0
- package/lib/types/generated/sqlserver/internal/class.d.ts +143 -0
- package/lib/types/generated/sqlserver/internal/prismaNamespace.d.ts +669 -0
- package/lib/types/generated/sqlserver/internal/prismaNamespaceBrowser.d.ts +80 -0
- package/lib/types/generated/sqlserver/models/AiChatHistory.d.ts +1257 -0
- package/lib/types/generated/sqlserver/models/AiMessage.d.ts +1375 -0
- package/lib/types/generated/sqlserver/models.d.ts +3 -0
- package/lib/types/health.d.ts +52 -0
- package/lib/types/index.d.ts +91 -0
- package/lib/types/memory-tools.d.ts +168 -0
- package/lib/types/notify.d.ts +35 -0
- package/lib/types/projector.d.ts +51 -0
- package/lib/types/status-route.d.ts +48 -0
- package/lib/types/types.d.ts +58 -0
- package/lib/types.js +8 -0
- package/package.json +127 -4
- package/preview.png +0 -0
- package/prisma/schema.mysql.prisma +78 -0
- package/prisma/schema.postgresql.prisma +70 -0
- package/prisma/schema.sqlite.prisma +70 -0
- package/prisma/schema.sqlserver.prisma +76 -0
package/lib/backfill.js
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 断档补传 (gap backfill): after the mirror was down for a while, local
|
|
3
|
+
* session logs (`$DSH_HOME/sessions/<workspace-dir>/<session-id>/session.v4
|
|
4
|
+
* .jsonl` or its `.zstd` twin) hold events that never reached the database.
|
|
5
|
+
*
|
|
6
|
+
* computeGap picks the sessions whose latest local activity (file mtime) is
|
|
7
|
+
* newer than what the database already has; replayEvents re-feeds the missing
|
|
8
|
+
* events through the same projector the live tap uses, with idempotent
|
|
9
|
+
* semantics — rows whose deterministic id already exists are skipped, so
|
|
10
|
+
* running a backfill twice never duplicates data.
|
|
11
|
+
*
|
|
12
|
+
* zstd: fflate/gzip inflate cannot decode zstd frames — decompression shells
|
|
13
|
+
* out to the system `zstd` binary (`zstd -dc`, falling back to `unzstd` /
|
|
14
|
+
* `zstdcat`). When none of them exists the read fails with a clear error
|
|
15
|
+
* naming the requirement (verified on macOS: /opt/homebrew/bin/zstd).
|
|
16
|
+
*/
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { mergeToolResult, projectEvent, toolResultCallId, usageSampleOf } from './projector.js';
|
|
21
|
+
/** Exactly the event types the live tap mirrors (see index.ts session/event). */
|
|
22
|
+
export const MIRROR_EVENT_TYPES = [
|
|
23
|
+
'user/message',
|
|
24
|
+
'assistant/message',
|
|
25
|
+
'assistant/attempt',
|
|
26
|
+
'tool/result',
|
|
27
|
+
'turn/end',
|
|
28
|
+
'session/title',
|
|
29
|
+
'compaction/end',
|
|
30
|
+
];
|
|
31
|
+
/**
|
|
32
|
+
* Events that produce or update message rows. The database-side watermark is
|
|
33
|
+
* the newest message row, so gap detection keys on these; log-only events
|
|
34
|
+
* (turn/end, session/title, ...) ride along from `fromSeq` during replay but
|
|
35
|
+
* cannot themselves indicate missing rows — otherwise a trailing title event
|
|
36
|
+
* would keep its session permanently "gapped".
|
|
37
|
+
*/
|
|
38
|
+
const ROW_EVENT_TYPES = ['user/message', 'assistant/message', 'tool/result'];
|
|
39
|
+
/** Metadata key for the usage high-water mark — shared with the live tap. */
|
|
40
|
+
export const LAST_USAGE_KEY = 'dsh-storage:lastUsage';
|
|
41
|
+
/* ------------------------------------------------------------------ *
|
|
42
|
+
* Session log reading (plain / zstd)
|
|
43
|
+
* ------------------------------------------------------------------ */
|
|
44
|
+
const LOG_BASENAME = 'session.v4.jsonl';
|
|
45
|
+
/** Marker: the candidate binary is not installed on this machine. */
|
|
46
|
+
class BinaryMissingError extends Error {
|
|
47
|
+
}
|
|
48
|
+
function runDecompressor(binary, file) {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
const child = spawn(binary, ['-dc', file], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
51
|
+
const chunks = [];
|
|
52
|
+
let stderr = '';
|
|
53
|
+
child.stdout.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
|
54
|
+
child.stderr.on('data', (chunk) => {
|
|
55
|
+
stderr += chunk.toString('utf-8');
|
|
56
|
+
});
|
|
57
|
+
child.on('error', (error) => {
|
|
58
|
+
reject(error.code === 'ENOENT' ? new BinaryMissingError() : error);
|
|
59
|
+
});
|
|
60
|
+
child.on('close', (code) => {
|
|
61
|
+
if (code === 0)
|
|
62
|
+
resolve(Buffer.concat(chunks));
|
|
63
|
+
else
|
|
64
|
+
reject(new Error(`${binary} -dc exited with ${code}: ${stderr.trim()}`));
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Read one session log as text. Plain files are read directly; `.zstd` files
|
|
70
|
+
* are decompressed through the first available system binary.
|
|
71
|
+
*/
|
|
72
|
+
export async function readSessionLogText(file, binaries = ['zstd', 'unzstd', 'zstdcat']) {
|
|
73
|
+
if (!file.endsWith('.zstd'))
|
|
74
|
+
return readFile(file, 'utf-8');
|
|
75
|
+
for (const binary of binaries) {
|
|
76
|
+
try {
|
|
77
|
+
return (await runDecompressor(binary, file)).toString('utf-8');
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (!(error instanceof BinaryMissingError))
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
throw new Error(`cannot read ${file}: no zstd binary found (tried ${binaries.join(', ')}) — install zstd to backfill compressed sessions`);
|
|
85
|
+
}
|
|
86
|
+
/** Parse JSONL lines into event objects; malformed lines are skipped. */
|
|
87
|
+
export function parseSessionEvents(text) {
|
|
88
|
+
const events = [];
|
|
89
|
+
for (const raw of text.split('\n')) {
|
|
90
|
+
const line = raw.trim();
|
|
91
|
+
if (!line)
|
|
92
|
+
continue;
|
|
93
|
+
try {
|
|
94
|
+
const parsed = JSON.parse(line);
|
|
95
|
+
if (parsed && typeof parsed === 'object' && typeof parsed.type === 'string')
|
|
96
|
+
events.push(parsed);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// Torn tail lines (a crash mid-write) must not kill the replay.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return events;
|
|
103
|
+
}
|
|
104
|
+
function isMirrorEvent(event) {
|
|
105
|
+
return MIRROR_EVENT_TYPES.includes(event?.type);
|
|
106
|
+
}
|
|
107
|
+
/** The log file inside `dir` when one exists: plain first, then `.zstd`. */
|
|
108
|
+
async function logIn(dir) {
|
|
109
|
+
for (const name of [LOG_BASENAME, `${LOG_BASENAME}.zstd`]) {
|
|
110
|
+
const file = join(dir, name);
|
|
111
|
+
try {
|
|
112
|
+
const info = await stat(file);
|
|
113
|
+
if (info.isFile())
|
|
114
|
+
return { file, mtime: info.mtime };
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// try the next filename
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Enumerate session logs. Real DSH layout is
|
|
124
|
+
* `<sessionsDir>/<workspace-dir>/<session-id>/session.v4.jsonl[.zstd]`
|
|
125
|
+
* (two levels); a log directly under a child directory is also accepted so
|
|
126
|
+
* a flattened sessions dir keeps working.
|
|
127
|
+
*/
|
|
128
|
+
export async function listSessionLogs(sessionsDir) {
|
|
129
|
+
let entries;
|
|
130
|
+
try {
|
|
131
|
+
entries = await readdir(sessionsDir, { withFileTypes: true });
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
const logs = [];
|
|
137
|
+
for (const entry of entries) {
|
|
138
|
+
if (!entry.isDirectory())
|
|
139
|
+
continue;
|
|
140
|
+
const workspaceDir = join(sessionsDir, entry.name);
|
|
141
|
+
const direct = await logIn(workspaceDir);
|
|
142
|
+
if (direct) {
|
|
143
|
+
logs.push({ sessionId: entry.name, ...direct });
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
let sessionDirs;
|
|
147
|
+
try {
|
|
148
|
+
sessionDirs = await readdir(workspaceDir, { withFileTypes: true });
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
for (const session of sessionDirs) {
|
|
154
|
+
if (!session.isDirectory())
|
|
155
|
+
continue;
|
|
156
|
+
const log = await logIn(join(workspaceDir, session.name));
|
|
157
|
+
if (log)
|
|
158
|
+
logs.push({ sessionId: session.name, ...log });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return logs;
|
|
162
|
+
}
|
|
163
|
+
/* ------------------------------------------------------------------ *
|
|
164
|
+
* Gap computation
|
|
165
|
+
* ------------------------------------------------------------------ */
|
|
166
|
+
/**
|
|
167
|
+
* Select sessions whose latest local activity (log mtime) is newer than the
|
|
168
|
+
* database's last mirrored activity. Sessions absent from the database are
|
|
169
|
+
* selected for a full replay (fromSeq 0) — but only when their log holds at
|
|
170
|
+
* least one row-producing event; a log-only session mirrors no rows and its
|
|
171
|
+
* "gap" would never converge. For known sessions `fromSeq` is the sequence
|
|
172
|
+
* of the first row-producing event newer than the DB watermark.
|
|
173
|
+
*/
|
|
174
|
+
export async function computeGap(sessionsDir, dbLatestPerSession) {
|
|
175
|
+
const gaps = [];
|
|
176
|
+
for (const log of await listSessionLogs(sessionsDir)) {
|
|
177
|
+
const dbDate = dbLatestPerSession.get(log.sessionId);
|
|
178
|
+
// Watermark filter FIRST, on stat() data alone: non-candidates must
|
|
179
|
+
// never pay for a (potentially zstd-decompressing) log read — a stale
|
|
180
|
+
// or unreadable log of an up-to-date session is silently skipped.
|
|
181
|
+
if (dbDate && log.mtime.getTime() <= dbDate.getTime())
|
|
182
|
+
continue;
|
|
183
|
+
const events = parseSessionEvents(await readSessionLogText(log.file));
|
|
184
|
+
const rowEvents = events.filter((event) => ROW_EVENT_TYPES.includes(event?.type));
|
|
185
|
+
if (!dbDate) {
|
|
186
|
+
if (rowEvents.length > 0)
|
|
187
|
+
gaps.push({ sessionId: log.sessionId, fromSeq: 0 });
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const dbTime = dbDate.getTime();
|
|
191
|
+
const firstMissing = rowEvents.find((event) => typeof event.time === 'number' && event.time > dbTime);
|
|
192
|
+
if (!firstMissing)
|
|
193
|
+
continue; // nothing row-backed is missing
|
|
194
|
+
gaps.push({
|
|
195
|
+
sessionId: log.sessionId,
|
|
196
|
+
fromSeq: typeof firstMissing.seq === 'number' ? firstMissing.seq : 0,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
return gaps;
|
|
200
|
+
}
|
|
201
|
+
/** computeGap + replayEvents in one call, for the panel button. */
|
|
202
|
+
export async function backfillAll(sessionsDir, backend) {
|
|
203
|
+
await backend.init?.();
|
|
204
|
+
const dbLatest = (await backend.latestPerSession?.()) ?? new Map();
|
|
205
|
+
return replayEvents(await computeGap(sessionsDir, dbLatest), sessionsDir, backend);
|
|
206
|
+
}
|
|
207
|
+
/* ------------------------------------------------------------------ *
|
|
208
|
+
* Replay
|
|
209
|
+
* ------------------------------------------------------------------ */
|
|
210
|
+
/** Shape-guarded restore of the usage sample from stored metadata. */
|
|
211
|
+
function usageSampleOfMetadata(value) {
|
|
212
|
+
const v = value;
|
|
213
|
+
return v &&
|
|
214
|
+
typeof v.key === 'string' &&
|
|
215
|
+
typeof v.input === 'number' &&
|
|
216
|
+
typeof v.output === 'number'
|
|
217
|
+
? { key: v.key, input: v.input, output: v.output }
|
|
218
|
+
: undefined;
|
|
219
|
+
}
|
|
220
|
+
/** Whether the stored row already carries this call's result. */
|
|
221
|
+
function hasResult(rows, callId) {
|
|
222
|
+
return rows.some((row) => Array.isArray(row.toolCalls) &&
|
|
223
|
+
row.toolCalls.some((call) => call?.id === callId && 'result' in call));
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Replay the missing events of each gap through the projector into the
|
|
227
|
+
* backend. Idempotent: message rows whose deterministic id already exists
|
|
228
|
+
* are skipped without error, and the session rollup is never double-counted.
|
|
229
|
+
*/
|
|
230
|
+
export async function replayEvents(gaps, sessionsDir, backend) {
|
|
231
|
+
const summary = { sessions: 0, replayed: 0, skipped: 0, errors: [] };
|
|
232
|
+
await backend.init?.();
|
|
233
|
+
const logs = new Map((await listSessionLogs(sessionsDir)).map((log) => [log.sessionId, log]));
|
|
234
|
+
for (const gap of gaps) {
|
|
235
|
+
const log = logs.get(gap.sessionId);
|
|
236
|
+
if (!log) {
|
|
237
|
+
summary.errors.push(`${gap.sessionId}: session log not found under ${sessionsDir}`);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
summary.sessions++;
|
|
241
|
+
try {
|
|
242
|
+
await replayOne(gap, log.file, backend, summary);
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
summary.errors.push(`${gap.sessionId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return summary;
|
|
249
|
+
}
|
|
250
|
+
async function replayOne(gap, file, backend, summary) {
|
|
251
|
+
const events = parseSessionEvents(await readSessionLogText(file)).filter((event) => isMirrorEvent(event) && typeof event.seq === 'number' && event.seq >= gap.fromSeq);
|
|
252
|
+
// Seed the rollup from the stored row, exactly like the live tap on resume.
|
|
253
|
+
const stored = (await backend.readSession?.(gap.sessionId)) ?? null;
|
|
254
|
+
const accum = {
|
|
255
|
+
messageCount: stored?.messageCount ?? 0,
|
|
256
|
+
totalTokens: stored?.totalTokens ?? 0,
|
|
257
|
+
title: stored?.title ?? undefined,
|
|
258
|
+
firstMessageAt: stored?.firstMessageAt ?? undefined,
|
|
259
|
+
lastMessageAt: stored?.lastMessageAt ?? undefined,
|
|
260
|
+
metadata: { ...(stored?.metadata ?? {}) },
|
|
261
|
+
usageSample: usageSampleOfMetadata(stored?.metadata?.[LAST_USAGE_KEY]),
|
|
262
|
+
};
|
|
263
|
+
for (const event of events) {
|
|
264
|
+
let row = null;
|
|
265
|
+
if (event.type === 'tool/result') {
|
|
266
|
+
const callId = toolResultCallId(event);
|
|
267
|
+
const rows = callId ? await backend.readToolCallMessages(gap.sessionId, callId) : [];
|
|
268
|
+
if (callId && rows.length > 0 && hasResult(rows, callId)) {
|
|
269
|
+
summary.skipped++; // already merged on a previous replay
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
row = mergeToolResult(event, rows);
|
|
273
|
+
if (row) {
|
|
274
|
+
await backend.upsertMessage(row);
|
|
275
|
+
summary.replayed++;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
row = projectEvent(null, event, gap.sessionId);
|
|
281
|
+
if (row) {
|
|
282
|
+
const existing = await backend.readMessages(gap.sessionId, row.id);
|
|
283
|
+
if (existing.length > 0) {
|
|
284
|
+
summary.skipped++; // deterministic id already stored
|
|
285
|
+
row = null;
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
accum.messageCount += 1;
|
|
289
|
+
accum.firstMessageAt ??= row.createdAt;
|
|
290
|
+
accum.lastMessageAt = row.createdAt;
|
|
291
|
+
summary.replayed++;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
// Usage rollup with the same redelivery-dedup key as the live tap.
|
|
296
|
+
let usageDelta = 0;
|
|
297
|
+
const sampled = usageSampleOf(event);
|
|
298
|
+
if (sampled) {
|
|
299
|
+
const sequence = Number(sampled.key);
|
|
300
|
+
const previous = Number(accum.usageSample?.key);
|
|
301
|
+
const duplicate = Number.isSafeInteger(sequence) && Number.isSafeInteger(previous) && sequence <= previous;
|
|
302
|
+
if (!duplicate) {
|
|
303
|
+
usageDelta = sampled.sample.input + sampled.sample.output;
|
|
304
|
+
accum.usageSample = { key: sampled.key, ...sampled.sample };
|
|
305
|
+
accum.totalTokens += usageDelta;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (event.type === 'session/title' && typeof event.data?.title === 'string') {
|
|
309
|
+
accum.title = event.data.title;
|
|
310
|
+
}
|
|
311
|
+
if (row || usageDelta !== 0 || event.type === 'turn/end' || event.type === 'session/title') {
|
|
312
|
+
const metadata = {
|
|
313
|
+
...accum.metadata,
|
|
314
|
+
...(accum.usageSample ? { [LAST_USAGE_KEY]: accum.usageSample } : {}),
|
|
315
|
+
};
|
|
316
|
+
const sessionRow = {
|
|
317
|
+
sessionId: gap.sessionId,
|
|
318
|
+
title: accum.title ?? null,
|
|
319
|
+
messageCount: accum.messageCount,
|
|
320
|
+
totalTokens: accum.totalTokens,
|
|
321
|
+
firstMessageAt: accum.firstMessageAt ?? null,
|
|
322
|
+
lastMessageAt: accum.lastMessageAt ?? null,
|
|
323
|
+
...(Object.keys(metadata).length > 0 ? { metadata } : {}),
|
|
324
|
+
};
|
|
325
|
+
if (row)
|
|
326
|
+
await backend.upsertEvent(row, sessionRow);
|
|
327
|
+
else
|
|
328
|
+
await backend.upsertSession(sessionRow);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser half of dsh-storage: registers the settings panel into the DSH
|
|
3
|
+
* settings page (`settings.section` slot) and reaches the host plugin's
|
|
4
|
+
* dshStorageStatus service through the connection generic-RPC channel
|
|
5
|
+
* /dsh-storage (registered host-side in src/status-route.ts).
|
|
6
|
+
*
|
|
7
|
+
* Module shape follows the reference client plugins (dsh-pet, dsh-video-skin,
|
|
8
|
+
* ds-harness-remote): a `window.__ModuleLoader__.load({ id, factory })` call
|
|
9
|
+
* whose factory receives the page's `require` — React comes from the host
|
|
10
|
+
* page, never bundled. Built by esbuild into the single-file lib/client.js.
|
|
11
|
+
*/
|
|
12
|
+
import { makeStoragePanel } from './panel.js';
|
|
13
|
+
import { makeToastHost } from './toast.js';
|
|
14
|
+
const MODULE_ID = '@wfm-cn/dsh-storage';
|
|
15
|
+
const STATUS_CHANNEL = '/dsh-storage';
|
|
16
|
+
function unwrap(envelope) {
|
|
17
|
+
if (envelope && envelope.ok === true)
|
|
18
|
+
return envelope.value;
|
|
19
|
+
const message = envelope?.error?.message || '请求失败';
|
|
20
|
+
throw new Error(message);
|
|
21
|
+
}
|
|
22
|
+
function makeFactory() {
|
|
23
|
+
return (require) => {
|
|
24
|
+
const React = require('react');
|
|
25
|
+
const StoragePanel = makeStoragePanel(React);
|
|
26
|
+
const ToastHost = makeToastHost(React, require('react-dom'));
|
|
27
|
+
/** Client backed by the page's connection service. */
|
|
28
|
+
function makeClient(ctx) {
|
|
29
|
+
const call = (endpoint, payload = {}) => ctx.connection.rpc.call(STATUS_CHANNEL, endpoint, payload).then(unwrap);
|
|
30
|
+
return {
|
|
31
|
+
snapshot: () => call('snapshot'),
|
|
32
|
+
testConnection: (cfg) => call('testConnection', { cfg }),
|
|
33
|
+
createTables: (cfg) => call('createTables', { cfg }),
|
|
34
|
+
getConfig: () => call('getConfig'),
|
|
35
|
+
saveConfig: (cfg) => call('saveConfig', { cfg }),
|
|
36
|
+
backfill: () => call('backfill'),
|
|
37
|
+
toasts: (payload) => call('toasts', payload),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Toast portal (Task 6): mounted on document.body at client entry —
|
|
42
|
+
* independent of the settings section, so sync-interruption /
|
|
43
|
+
* recovery notifications appear even when the panel is closed.
|
|
44
|
+
*/
|
|
45
|
+
function mountToasts(client) {
|
|
46
|
+
if (typeof document === 'undefined')
|
|
47
|
+
return;
|
|
48
|
+
const clientApi = require('react-dom/client');
|
|
49
|
+
if (!clientApi || typeof clientApi.createRoot !== 'function')
|
|
50
|
+
return;
|
|
51
|
+
const host = document.createElement('div');
|
|
52
|
+
host.className = 'dsh-storage-toast-root';
|
|
53
|
+
document.body.appendChild(host);
|
|
54
|
+
const root = clientApi.createRoot(host);
|
|
55
|
+
root.render(React.createElement(ToastHost, { client }));
|
|
56
|
+
}
|
|
57
|
+
function apply(ctx) {
|
|
58
|
+
const client = makeClient(ctx);
|
|
59
|
+
mountToasts(client);
|
|
60
|
+
if (!ctx.slots || typeof ctx.slots.inject !== 'function')
|
|
61
|
+
return;
|
|
62
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
63
|
+
name: 'settings.section',
|
|
64
|
+
id: 'dsh-storage',
|
|
65
|
+
order: 60,
|
|
66
|
+
label: () => '会话存储镜像',
|
|
67
|
+
inject: () => ({ client }),
|
|
68
|
+
}, StoragePanel));
|
|
69
|
+
}
|
|
70
|
+
const module = { exports: {} };
|
|
71
|
+
module.exports.apply = apply;
|
|
72
|
+
module.exports.inject = ['slots', 'connection'];
|
|
73
|
+
return module.exports;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const loader = globalThis.__ModuleLoader__;
|
|
77
|
+
if (typeof loader === 'object' && loader !== null && typeof loader.load === 'function') {
|
|
78
|
+
loader.load({ id: MODULE_ID, factory: makeFactory() });
|
|
79
|
+
}
|