@wfm-cn/dsh-storage 0.0.0-stage → 0.2.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 +128 -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 +676 -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/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 +126 -3
- 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/notify.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 同步中断/恢复通知 (Task 6): the host side records toast notifications on
|
|
3
|
+
* HealthMonitor transitions; the browser client (src/client/toast.tsx) polls
|
|
4
|
+
* them through the status bridge and renders a React portal independent of
|
|
5
|
+
* the settings panel. The monitor already guarantees transitions only
|
|
6
|
+
* (same-state results never re-fire), so each record is a real state change.
|
|
7
|
+
*/
|
|
8
|
+
const MAX_TOASTS = 20;
|
|
9
|
+
let nextId = 1;
|
|
10
|
+
const toasts = [];
|
|
11
|
+
/** 中断 toast: good → bad. */
|
|
12
|
+
export function notifyGoodToBad(lastError) {
|
|
13
|
+
return push({
|
|
14
|
+
kind: 'interrupted',
|
|
15
|
+
// No "点击查看" suffix: the toast's click only dismisses — the DSH host
|
|
16
|
+
// exposes no navigation API reachable from the body-mounted portal, so
|
|
17
|
+
// the copy must not promise a click-through.
|
|
18
|
+
text: `⚠️ 会话同步中断:${lastError ?? '未知错误'}`,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
/** 恢复 toast: bad → good, with a backfill hint for the gap. */
|
|
22
|
+
export function notifyBadToGood() {
|
|
23
|
+
return push({
|
|
24
|
+
kind: 'recovered',
|
|
25
|
+
text: '✅ 会话同步已恢复',
|
|
26
|
+
hint: '如中断期间有新会话,可在设置面板点「补传」补齐',
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
function push(record) {
|
|
30
|
+
const toast = { ...record, id: nextId++, createdAt: Date.now() };
|
|
31
|
+
toasts.push(toast);
|
|
32
|
+
if (toasts.length > MAX_TOASTS)
|
|
33
|
+
toasts.splice(0, toasts.length - MAX_TOASTS);
|
|
34
|
+
return toast;
|
|
35
|
+
}
|
|
36
|
+
/** Records newer than `sinceId` (polling cursor for the client). */
|
|
37
|
+
export function drainToasts(sinceId = 0) {
|
|
38
|
+
return toasts.filter((toast) => toast.id > sinceId);
|
|
39
|
+
}
|
|
40
|
+
/** Test/reset hook: drop all history. */
|
|
41
|
+
export function clearToasts() {
|
|
42
|
+
toasts.length = 0;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Wire the toast records onto a monitor's transitions. Good→bad strictly
|
|
46
|
+
* means ok → down (a config error was never "good"); bad→good covers
|
|
47
|
+
* down → ok and config-error → ok.
|
|
48
|
+
*/
|
|
49
|
+
export function wireNotifications(monitor) {
|
|
50
|
+
return monitor.onTransition((to, from, status) => {
|
|
51
|
+
if (to === 'down' && from === 'ok')
|
|
52
|
+
notifyGoodToBad(status?.lastError);
|
|
53
|
+
else if (to === 'ok' && (from === 'down' || from === 'config-error'))
|
|
54
|
+
notifyBadToGood();
|
|
55
|
+
});
|
|
56
|
+
}
|
package/lib/projector.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Projects dsh session events onto MessageRow / SessionRow.
|
|
3
|
+
*
|
|
4
|
+
* Event shapes verified against the dsh persistence catalog
|
|
5
|
+
* (https://deepseek-harness.github.io/deepseek-harness/reference/persistence-catalog)
|
|
6
|
+
* and the @deepseek-ai/dsh-llm message types:
|
|
7
|
+
* - envelope: `{ type, seq, time, data }` — payload lives under `data`.
|
|
8
|
+
* - `user/message` and `assistant/message` produce rows; `tool/result` updates
|
|
9
|
+
* the matching assistant row's toolCalls instead of producing a tool row.
|
|
10
|
+
* - token usage (V3 log): `assistant/message.data.usage` is the settled
|
|
11
|
+
* step's accounting; a failed/cancelled attempt commits `assistant/attempt`
|
|
12
|
+
* instead, with the adapter's usage chunk preserved as a raw record in its
|
|
13
|
+
* embedded compact stream (recovered in usageSampleOf). `turn/end` carries
|
|
14
|
+
* none — it only serves as a rollup checkpoint.
|
|
15
|
+
* - compaction summaries enter the surface as a `user/message` with
|
|
16
|
+
* `surfaceOp: replace`, so they flow through the user path unchanged.
|
|
17
|
+
*/
|
|
18
|
+
import { randomUUID } from 'node:crypto';
|
|
19
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
20
|
+
/** Visible text of a message: its `text` blocks, unwrapping tool-result blocks. */
|
|
21
|
+
function textOf(message) {
|
|
22
|
+
const blocks = Array.isArray(message?.content) ? message.content : [];
|
|
23
|
+
return blocks
|
|
24
|
+
.map((b) => {
|
|
25
|
+
if (b?.type === 'text')
|
|
26
|
+
return b.text ?? '';
|
|
27
|
+
if (b?.type === 'tool-result' && Array.isArray(b.content))
|
|
28
|
+
return textOf({ content: b.content });
|
|
29
|
+
return '';
|
|
30
|
+
})
|
|
31
|
+
.join('');
|
|
32
|
+
}
|
|
33
|
+
/** Reasoning blocks of an assistant message, in their original order. */
|
|
34
|
+
function thoughtsOf(message) {
|
|
35
|
+
const blocks = Array.isArray(message?.content) ? message.content : [];
|
|
36
|
+
const replay = message?.source?.replayState?.blocks;
|
|
37
|
+
const thoughts = blocks
|
|
38
|
+
.map((b, index) => {
|
|
39
|
+
if (b?.type !== 'reasoning' || !b.text)
|
|
40
|
+
return null;
|
|
41
|
+
const metadata = Array.isArray(replay) ? replay[index] : undefined;
|
|
42
|
+
const signature = metadata?.type === 'reasoning'
|
|
43
|
+
? (metadata.signature ?? metadata.thoughtSignature)
|
|
44
|
+
: undefined;
|
|
45
|
+
return {
|
|
46
|
+
content: b.text,
|
|
47
|
+
...(typeof signature === 'string' ? { thoughtSignature: signature } : {}),
|
|
48
|
+
};
|
|
49
|
+
})
|
|
50
|
+
.filter((thought) => thought !== null);
|
|
51
|
+
return thoughts.length ? thoughts : undefined;
|
|
52
|
+
}
|
|
53
|
+
function toolPartsOf(message) {
|
|
54
|
+
const blocks = Array.isArray(message?.content) ? message.content : [];
|
|
55
|
+
const calls = blocks.filter((b) => b?.type === 'tool-call');
|
|
56
|
+
return calls.length > 0 ? calls : undefined;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Map one session event to zero or one message row. Returns null for events
|
|
60
|
+
* that should not be persisted as standalone rows (log-only events: chunks,
|
|
61
|
+
* turn/step lifecycle, approvals, ...).
|
|
62
|
+
*/
|
|
63
|
+
export function projectEvent(_session, event, sessionId) {
|
|
64
|
+
const data = event?.data ?? {};
|
|
65
|
+
const base = {
|
|
66
|
+
sessionId,
|
|
67
|
+
historyId: null,
|
|
68
|
+
agentId: 'main',
|
|
69
|
+
createdAt: new Date(event?.time ?? Date.now()),
|
|
70
|
+
};
|
|
71
|
+
switch (event?.type) {
|
|
72
|
+
case 'user/message': {
|
|
73
|
+
// data IS the UserMessage.
|
|
74
|
+
const message = data;
|
|
75
|
+
return {
|
|
76
|
+
...base,
|
|
77
|
+
id: message.id ?? randomUUID(),
|
|
78
|
+
type: 'user',
|
|
79
|
+
content: textOf(message),
|
|
80
|
+
metadata: { event: event.type, seq: event.seq, message },
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
case 'assistant/message': {
|
|
84
|
+
const message = data.message;
|
|
85
|
+
return {
|
|
86
|
+
...base,
|
|
87
|
+
id: message?.id ?? randomUUID(),
|
|
88
|
+
type: 'model',
|
|
89
|
+
content: textOf(message),
|
|
90
|
+
thoughts: thoughtsOf(message),
|
|
91
|
+
model: message?.source?.model ?? undefined,
|
|
92
|
+
tokens: data.usage ?? undefined,
|
|
93
|
+
toolCalls: toolPartsOf(message),
|
|
94
|
+
metadata: {
|
|
95
|
+
event: event.type,
|
|
96
|
+
seq: event.seq,
|
|
97
|
+
...(message?.source ? { source: message.source } : {}),
|
|
98
|
+
...(data.interrupted ? { interrupted: true } : {}),
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
default:
|
|
103
|
+
// Log-only events (assistant/attempt, turn/step lifecycle, approvals,
|
|
104
|
+
// compaction markers, ...) are not standalone rows; usage rolls up into
|
|
105
|
+
// SessionRow via usageSampleOf (assistant/message and assistant/attempt).
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** Identify the assistant tool call answered by either supported result shape. */
|
|
110
|
+
export function toolResultCallId(event) {
|
|
111
|
+
const message = event?.data?.message;
|
|
112
|
+
const first = message?.content?.[0];
|
|
113
|
+
const callId = message?.toolCallId ??
|
|
114
|
+
message?.source?.callId ??
|
|
115
|
+
(first?.type === 'tool-result' ? first.toolCallId : undefined);
|
|
116
|
+
return typeof callId === 'string' ? callId : undefined;
|
|
117
|
+
}
|
|
118
|
+
/** Update the persisted assistant call identified by a tool result. */
|
|
119
|
+
export function mergeToolResult(event, rows) {
|
|
120
|
+
const message = event?.data?.message;
|
|
121
|
+
const content = Array.isArray(message?.content) ? message.content : [];
|
|
122
|
+
const legacy = content.length === 1 && content[0]?.type === 'tool-result' ? content[0] : undefined;
|
|
123
|
+
const callId = toolResultCallId(event);
|
|
124
|
+
if (!callId)
|
|
125
|
+
return null;
|
|
126
|
+
const result = legacy ??
|
|
127
|
+
(message
|
|
128
|
+
? {
|
|
129
|
+
type: 'tool-result',
|
|
130
|
+
toolCallId: callId,
|
|
131
|
+
content,
|
|
132
|
+
...(typeof message.isError === 'boolean' ? { isError: message.isError } : {}),
|
|
133
|
+
}
|
|
134
|
+
: null);
|
|
135
|
+
const model = [...rows]
|
|
136
|
+
.reverse()
|
|
137
|
+
.find((row) => row.type === 'model' &&
|
|
138
|
+
Array.isArray(row.toolCalls) &&
|
|
139
|
+
row.toolCalls.some((call) => call?.id === callId));
|
|
140
|
+
if (!model)
|
|
141
|
+
return null;
|
|
142
|
+
return {
|
|
143
|
+
...model,
|
|
144
|
+
toolCalls: model.toolCalls.map((call) => call?.id === callId
|
|
145
|
+
? {
|
|
146
|
+
...call,
|
|
147
|
+
result,
|
|
148
|
+
error: event.data.error
|
|
149
|
+
? { name: event.data.error.name, code: event.data.error.code }
|
|
150
|
+
: undefined,
|
|
151
|
+
}
|
|
152
|
+
: call),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Extract one model call's usage, keyed by its durable event sequence.
|
|
157
|
+
*
|
|
158
|
+
* V3 session log (dsh-session@0.1.6-alpha.2):
|
|
159
|
+
* - `assistant/message.data.usage` — the settled call's accounting;
|
|
160
|
+
* - `assistant/attempt` — a failed/retried/cancelled call commits no surface
|
|
161
|
+
* message; the adapter's usage chunk survives as a raw `chunk` record in the
|
|
162
|
+
* embedded compact `stream` (deltas are packed, but usage is a raw type).
|
|
163
|
+
*
|
|
164
|
+
* Calls on one `(turn, step)` are additive: a retry is another billed model
|
|
165
|
+
* call, not a progressive snapshot. Only redelivery of the same event sequence
|
|
166
|
+
* is a duplicate. Totals include the cache buckets: cacheRead/cacheWrite are
|
|
167
|
+
* billed in their own buckets (observed cacheReadTokens ≫ inputTokens in dsh
|
|
168
|
+
* session logs — they are not subsets of inputTokens).
|
|
169
|
+
*/
|
|
170
|
+
export function usageSampleOf(event) {
|
|
171
|
+
const total = (u) => ({
|
|
172
|
+
input: (u?.inputTokens ?? 0) + (u?.cacheReadTokens ?? 0) + (u?.cacheWriteTokens ?? 0),
|
|
173
|
+
output: u?.outputTokens ?? 0,
|
|
174
|
+
});
|
|
175
|
+
let data;
|
|
176
|
+
let usage;
|
|
177
|
+
if (event?.type === 'assistant/message') {
|
|
178
|
+
data = event.data;
|
|
179
|
+
usage = data?.usage;
|
|
180
|
+
}
|
|
181
|
+
else if (event?.type === 'assistant/attempt') {
|
|
182
|
+
data = event.data;
|
|
183
|
+
usage = (Array.isArray(data?.stream) ? data.stream : []).find((r) => r?.type === 'chunk' && r.chunk?.type === 'usage')?.chunk?.usage;
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
if (!usage || !Number.isSafeInteger(event?.seq))
|
|
189
|
+
return null;
|
|
190
|
+
return { key: String(event.seq), sample: total(usage) };
|
|
191
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host→browser bridge for the settings panel: exposes the dshStorageStatus
|
|
3
|
+
* service over the DSH connection generic-RPC channel `/dsh-storage`.
|
|
4
|
+
*
|
|
5
|
+
* Pattern verified against the installed reference plugins (ds-harness-remote
|
|
6
|
+
* `registerControlRoute`, dsh-video-skin settings.section client): the browser
|
|
7
|
+
* client calls `ctx.connection.rpc.call('/dsh-storage', endpoint, payload)`,
|
|
8
|
+
* which POSTs `{type:'client-request', rpcId, method, payload}` to
|
|
9
|
+
* `/<channel>/<endpoint>` and expects `{type:'server-response', rpcId,
|
|
10
|
+
* result:{ok:true,value}|{ok:false,error:{code,message,details}}}`. When the
|
|
11
|
+
* webServer service is available we serve that path with a prefix route
|
|
12
|
+
* (guarded by `connection.requestRejection` for authentication); otherwise we
|
|
13
|
+
* fall back to `connection.rpc.handle` for in-process carriers. Both halves
|
|
14
|
+
* stay optional (`ctx.get`), so profiles without a web carrier simply skip
|
|
15
|
+
* the bridge.
|
|
16
|
+
*/
|
|
17
|
+
import { syncConfigProblem } from './config.js';
|
|
18
|
+
export const STATUS_CHANNEL = '/dsh-storage';
|
|
19
|
+
const ENDPOINT_SEGMENT = /^[A-Za-z0-9_$.-]+$/;
|
|
20
|
+
function badRequest(message) {
|
|
21
|
+
return { ok: false, error: { code: 'gateway/bad-request', message, details: {} } };
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Guard for cfg-taking endpoints. Rejections name the offending field (via
|
|
25
|
+
* syncConfigProblem) so a cleared/NaN port surfaces as 'cfg.port must be…',
|
|
26
|
+
* not a misleading generic 'requires a cfg object'.
|
|
27
|
+
*/
|
|
28
|
+
function cfgGuard(endpoint, cfg) {
|
|
29
|
+
const problem = syncConfigProblem(cfg);
|
|
30
|
+
return problem === undefined
|
|
31
|
+
? { cfg: cfg }
|
|
32
|
+
: { error: { code: 'gateway/bad-request', message: `${endpoint}: ${problem}`, details: {} } };
|
|
33
|
+
}
|
|
34
|
+
/** Dispatch one endpoint; never throws — failures become ok:false results. */
|
|
35
|
+
async function dispatch(service, endpoint, payload) {
|
|
36
|
+
const body = (typeof payload === 'object' && payload !== null ? payload : {});
|
|
37
|
+
try {
|
|
38
|
+
switch (endpoint) {
|
|
39
|
+
case 'snapshot':
|
|
40
|
+
return { ok: true, value: service.snapshot() };
|
|
41
|
+
case 'testConnection': {
|
|
42
|
+
const guarded = cfgGuard('testConnection', body.cfg);
|
|
43
|
+
if ('error' in guarded)
|
|
44
|
+
return { ok: false, error: guarded.error };
|
|
45
|
+
return { ok: true, value: await service.testConnection(guarded.cfg) };
|
|
46
|
+
}
|
|
47
|
+
case 'createTables': {
|
|
48
|
+
const guarded = cfgGuard('createTables', body.cfg);
|
|
49
|
+
if ('error' in guarded)
|
|
50
|
+
return { ok: false, error: guarded.error };
|
|
51
|
+
return { ok: true, value: await service.createTables(guarded.cfg) };
|
|
52
|
+
}
|
|
53
|
+
case 'getConfig':
|
|
54
|
+
return { ok: true, value: service.getConfig() };
|
|
55
|
+
case 'saveConfig': {
|
|
56
|
+
const guarded = cfgGuard('saveConfig', body.cfg);
|
|
57
|
+
if ('error' in guarded)
|
|
58
|
+
return { ok: false, error: guarded.error };
|
|
59
|
+
return { ok: true, value: service.saveConfig(guarded.cfg) };
|
|
60
|
+
}
|
|
61
|
+
case 'backfill':
|
|
62
|
+
return { ok: true, value: await service.backfill() };
|
|
63
|
+
case 'toasts':
|
|
64
|
+
if (body.sinceId !== undefined && typeof body.sinceId !== 'number')
|
|
65
|
+
return badRequest('toasts sinceId must be a number');
|
|
66
|
+
return { ok: true, value: service.toasts({ sinceId: body.sinceId }) };
|
|
67
|
+
default:
|
|
68
|
+
return badRequest(`unknown endpoint ${JSON.stringify(endpoint)}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
return {
|
|
73
|
+
ok: false,
|
|
74
|
+
error: {
|
|
75
|
+
code: 'gateway/internal',
|
|
76
|
+
message: error instanceof Error ? error.message : String(error),
|
|
77
|
+
details: {},
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function endpointFromPath(pathname) {
|
|
83
|
+
if (!pathname.startsWith(`${STATUS_CHANNEL}/`))
|
|
84
|
+
return undefined;
|
|
85
|
+
const endpoint = pathname.slice(STATUS_CHANNEL.length + 1);
|
|
86
|
+
if (!ENDPOINT_SEGMENT.test(endpoint))
|
|
87
|
+
return undefined;
|
|
88
|
+
return endpoint;
|
|
89
|
+
}
|
|
90
|
+
function parseClientRequest(value) {
|
|
91
|
+
if (typeof value !== 'object' || value === null)
|
|
92
|
+
return undefined;
|
|
93
|
+
const v = value;
|
|
94
|
+
if (v.type !== 'client-request' || typeof v.rpcId !== 'string' || typeof v.method !== 'string')
|
|
95
|
+
return undefined;
|
|
96
|
+
return { type: 'client-request', rpcId: v.rpcId, method: v.method, payload: v.payload };
|
|
97
|
+
}
|
|
98
|
+
function writeJson(res, status, body) {
|
|
99
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
100
|
+
res.end(JSON.stringify(body));
|
|
101
|
+
}
|
|
102
|
+
function fullResponse(rpcId, result) {
|
|
103
|
+
return { type: 'server-response', rpcId, result };
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Register the /dsh-storage channel. Wire order mirrors ds-harness-remote's
|
|
107
|
+
* control route: an authenticated webServer prefix route when both services
|
|
108
|
+
* allow it, otherwise connection.rpc.handle for logical carriers.
|
|
109
|
+
*/
|
|
110
|
+
export function registerStatusRoute(ctx, service) {
|
|
111
|
+
const connection = ctx.get('connection');
|
|
112
|
+
if (!connection)
|
|
113
|
+
return undefined;
|
|
114
|
+
const handler = (endpoint, payload) => dispatch(service, endpoint, payload);
|
|
115
|
+
const webServer = ctx.get('webServer');
|
|
116
|
+
if (webServer !== undefined && connection.requestRejection !== undefined) {
|
|
117
|
+
const dispose = webServer.register({
|
|
118
|
+
kind: 'prefix',
|
|
119
|
+
path: STATUS_CHANNEL,
|
|
120
|
+
handler: async (req, res) => {
|
|
121
|
+
const rejection = connection.requestRejection?.(req);
|
|
122
|
+
if (rejection !== undefined) {
|
|
123
|
+
res.writeHead(rejection);
|
|
124
|
+
res.end(rejection === 401 ? 'unauthorized' : 'forbidden');
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const endpoint = endpointFromPath(new URL(req.url ?? '/', 'http://dsh.internal').pathname);
|
|
128
|
+
if (req.method !== 'POST' || endpoint === undefined) {
|
|
129
|
+
writeJson(res, 404, 'not found');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const contentType = Array.isArray(req.headers?.['content-type'])
|
|
133
|
+
? req.headers['content-type'][0]
|
|
134
|
+
: req.headers?.['content-type'];
|
|
135
|
+
if ((contentType ?? '').split(';', 1)[0]?.trim().toLowerCase() !== 'application/json') {
|
|
136
|
+
writeJson(res, 415, 'content type must be application/json');
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
let body;
|
|
140
|
+
try {
|
|
141
|
+
const chunks = [];
|
|
142
|
+
for await (const chunk of req) {
|
|
143
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
144
|
+
}
|
|
145
|
+
body = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
writeJson(res, 400, 'body is not JSON');
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const message = parseClientRequest(body);
|
|
152
|
+
if (message === undefined || message.method !== endpoint) {
|
|
153
|
+
writeJson(res, 200, fullResponse('invalid-request', badRequest('invalid client-request message')));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
writeJson(res, 200, fullResponse(message.rpcId, await handler(endpoint, message.payload)));
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
res.writeHead(500);
|
|
161
|
+
res.end(`handler failure: ${String(error)}`);
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
return () => void dispose();
|
|
166
|
+
}
|
|
167
|
+
const rpcHandle = connection.rpc?.handle;
|
|
168
|
+
if (rpcHandle === undefined)
|
|
169
|
+
return undefined;
|
|
170
|
+
const dispose = rpcHandle(STATUS_CHANNEL, handler, { authority: 'loopback' });
|
|
171
|
+
return () => void dispose();
|
|
172
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MySQL/PostgreSQL/SQLite/SQL Server backend via Prisma 7. The clients are
|
|
3
|
+
* pre-generated at package build time (`pnpm generate`, see
|
|
4
|
+
* scripts/prisma-generate.mjs) from the schema variants in prisma/ and
|
|
5
|
+
* shipped compiled in lib/generated — consumers never run `prisma generate`.
|
|
6
|
+
*
|
|
7
|
+
* Prisma 7 requires a driver adapter per database; `@prisma/client` and the
|
|
8
|
+
* adapter for your provider are optional peer dependencies (sqlite uses
|
|
9
|
+
* `@prisma/adapter-libsql` — no native build scripts, unlike
|
|
10
|
+
* `@prisma/adapter-better-sqlite3`). Table creation
|
|
11
|
+
* (`prisma db push` / migrations) uses the shipped schema variant with the
|
|
12
|
+
* connection URL in the user's prisma.config.ts.
|
|
13
|
+
*/
|
|
14
|
+
import type { MessageRow, SessionRow, StorageBackend } from '../types.js';
|
|
15
|
+
export type DatabaseProvider = 'mysql' | 'postgresql' | 'sqlite' | 'sqlserver';
|
|
16
|
+
export interface DatabaseBackendConfig {
|
|
17
|
+
provider: DatabaseProvider;
|
|
18
|
+
/** Full connection URL, e.g. mysql://user:pass@host:3306/dbname, file:/data/dsh.db */
|
|
19
|
+
url: string;
|
|
20
|
+
/**
|
|
21
|
+
* IANA-named offset for the mariadb driver's `timezone` option (e.g.
|
|
22
|
+
* '+08:00'), applied when writing DATETIME columns. Only used by mysql.
|
|
23
|
+
*/
|
|
24
|
+
timezone?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Device identifier stamped on every ai_messages / ai_chat_histories row
|
|
27
|
+
* this backend writes, so several machines can share one database.
|
|
28
|
+
* Falls back to the OS hostname when unset.
|
|
29
|
+
*/
|
|
30
|
+
device?: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Parse mysql://user:password@host[:port]/database into discrete connection
|
|
34
|
+
* options for the mariadb driver adapter. Passing the URI string itself
|
|
35
|
+
* pool-times-out in production (verified against a real MySQL 8.4);
|
|
36
|
+
* discrete host/port/user/password/database options work. Components are
|
|
37
|
+
* percent-decoded — credentials with @ : / arrive encoded in the URL.
|
|
38
|
+
*/
|
|
39
|
+
export declare function parseMysqlUrl(url: string): {
|
|
40
|
+
host: string;
|
|
41
|
+
port?: number;
|
|
42
|
+
user: string;
|
|
43
|
+
password: string;
|
|
44
|
+
database: string;
|
|
45
|
+
};
|
|
46
|
+
export declare function createAdapter(config: DatabaseBackendConfig): Promise<unknown>;
|
|
47
|
+
export declare class DatabaseBackend implements StorageBackend {
|
|
48
|
+
private config;
|
|
49
|
+
readonly name = "database";
|
|
50
|
+
private prisma;
|
|
51
|
+
constructor(config: DatabaseBackendConfig);
|
|
52
|
+
/** Device identifier stamped on rows written through this backend. */
|
|
53
|
+
private get device();
|
|
54
|
+
private client;
|
|
55
|
+
/** Load the pre-generated client for the configured provider (seam for tests). */
|
|
56
|
+
protected loadClient(): Promise<any>;
|
|
57
|
+
init(): Promise<void>;
|
|
58
|
+
/** JSON column value, text-serialized on SQL Server (no Prisma Json type). */
|
|
59
|
+
private jsonField;
|
|
60
|
+
private toDbData;
|
|
61
|
+
private writeMessage;
|
|
62
|
+
upsertMessage(row: MessageRow): Promise<void>;
|
|
63
|
+
readMessages(sessionId: string, messageId: string): Promise<MessageRow[]>;
|
|
64
|
+
readToolCallMessages(sessionId: string, callId: string): Promise<MessageRow[]>;
|
|
65
|
+
private readRows;
|
|
66
|
+
readSession(sessionId: string): Promise<SessionRow | null>;
|
|
67
|
+
/**
|
|
68
|
+
* Max created_at per session over active message rows — the database-side
|
|
69
|
+
* watermark for backfill gap detection (sessions with newer local logs
|
|
70
|
+
* have missing events).
|
|
71
|
+
*/
|
|
72
|
+
latestPerSession(): Promise<Map<string, Date>>;
|
|
73
|
+
private writeSession;
|
|
74
|
+
upsertSession(row: SessionRow): Promise<void>;
|
|
75
|
+
upsertEvent(message: MessageRow | null, session: SessionRow): Promise<void>;
|
|
76
|
+
archiveSession(sessionId: string): Promise<string | null>;
|
|
77
|
+
close(): Promise<void>;
|
|
78
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { StorageBackend } from './types.js';
|
|
2
|
+
/** Exactly the event types the live tap mirrors (see index.ts session/event). */
|
|
3
|
+
export declare const MIRROR_EVENT_TYPES: readonly ["user/message", "assistant/message", "assistant/attempt", "tool/result", "turn/end", "session/title", "compaction/end"];
|
|
4
|
+
interface MirrorEvent {
|
|
5
|
+
type: string;
|
|
6
|
+
seq?: number;
|
|
7
|
+
time?: number;
|
|
8
|
+
data?: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
/** Metadata key for the usage high-water mark — shared with the live tap. */
|
|
11
|
+
export declare const LAST_USAGE_KEY = "dsh-storage:lastUsage";
|
|
12
|
+
/** One session with missing events: replay starts at `fromSeq` (inclusive). */
|
|
13
|
+
export interface BackfillGap {
|
|
14
|
+
sessionId: string;
|
|
15
|
+
fromSeq: number;
|
|
16
|
+
}
|
|
17
|
+
export interface BackfillSummary {
|
|
18
|
+
/** Sessions whose events were replayed (excludes reported errors). */
|
|
19
|
+
sessions: number;
|
|
20
|
+
/** Message rows written by this run. */
|
|
21
|
+
replayed: number;
|
|
22
|
+
/** Events already present in the database (idempotent skips). */
|
|
23
|
+
skipped: number;
|
|
24
|
+
/** Per-session failures — one entry per session that could not be replayed. */
|
|
25
|
+
errors: string[];
|
|
26
|
+
}
|
|
27
|
+
interface LogFile {
|
|
28
|
+
file: string;
|
|
29
|
+
mtime: Date;
|
|
30
|
+
}
|
|
31
|
+
interface SessionLog extends LogFile {
|
|
32
|
+
sessionId: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Read one session log as text. Plain files are read directly; `.zstd` files
|
|
36
|
+
* are decompressed through the first available system binary.
|
|
37
|
+
*/
|
|
38
|
+
export declare function readSessionLogText(file: string, binaries?: readonly string[]): Promise<string>;
|
|
39
|
+
/** Parse JSONL lines into event objects; malformed lines are skipped. */
|
|
40
|
+
export declare function parseSessionEvents(text: string): MirrorEvent[];
|
|
41
|
+
/**
|
|
42
|
+
* Enumerate session logs. Real DSH layout is
|
|
43
|
+
* `<sessionsDir>/<workspace-dir>/<session-id>/session.v4.jsonl[.zstd]`
|
|
44
|
+
* (two levels); a log directly under a child directory is also accepted so
|
|
45
|
+
* a flattened sessions dir keeps working.
|
|
46
|
+
*/
|
|
47
|
+
export declare function listSessionLogs(sessionsDir: string): Promise<SessionLog[]>;
|
|
48
|
+
/**
|
|
49
|
+
* Select sessions whose latest local activity (log mtime) is newer than the
|
|
50
|
+
* database's last mirrored activity. Sessions absent from the database are
|
|
51
|
+
* selected for a full replay (fromSeq 0) — but only when their log holds at
|
|
52
|
+
* least one row-producing event; a log-only session mirrors no rows and its
|
|
53
|
+
* "gap" would never converge. For known sessions `fromSeq` is the sequence
|
|
54
|
+
* of the first row-producing event newer than the DB watermark.
|
|
55
|
+
*/
|
|
56
|
+
export declare function computeGap(sessionsDir: string, dbLatestPerSession: Map<string, Date>): Promise<BackfillGap[]>;
|
|
57
|
+
/** computeGap + replayEvents in one call, for the panel button. */
|
|
58
|
+
export declare function backfillAll(sessionsDir: string, backend: StorageBackend & {
|
|
59
|
+
latestPerSession?(): Promise<Map<string, Date>>;
|
|
60
|
+
}): Promise<BackfillSummary>;
|
|
61
|
+
/**
|
|
62
|
+
* Replay the missing events of each gap through the projector into the
|
|
63
|
+
* backend. Idempotent: message rows whose deterministic id already exists
|
|
64
|
+
* are skipped without error, and the session rollup is never double-counted.
|
|
65
|
+
*/
|
|
66
|
+
export declare function replayEvents(gaps: BackfillGap[], sessionsDir: string, backend: StorageBackend): Promise<BackfillSummary>;
|
|
67
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
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
|
+
export {};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings-panel component for dsh-storage (会话存储镜像).
|
|
3
|
+
*
|
|
4
|
+
* Authored against the reference client plugins (dsh-pet / dsh-video-skin):
|
|
5
|
+
* the browser half receives React from the host module loader inside the
|
|
6
|
+
* __ModuleLoader__ factory, so the component is built through a factory that
|
|
7
|
+
* takes the React module — no module-scope React import survives into the
|
|
8
|
+
* bundle. All RPC goes through the injected `client` (connection rpc; see
|
|
9
|
+
* src/client/index.ts), which keeps this file render-pure and testable with
|
|
10
|
+
* a fake client.
|
|
11
|
+
*/
|
|
12
|
+
import type { SyncConfig } from '../config.js';
|
|
13
|
+
import type { BackfillSummary } from '../backfill.js';
|
|
14
|
+
/** Shapes mirrored from the host (type-only imports — erased in the bundle). */
|
|
15
|
+
export interface HealthStatus {
|
|
16
|
+
state: 'ok' | 'down' | 'disabled' | 'config-error';
|
|
17
|
+
lastOkAt?: number;
|
|
18
|
+
lastError?: string;
|
|
19
|
+
retryCount: number;
|
|
20
|
+
mirroredTotal?: number;
|
|
21
|
+
}
|
|
22
|
+
export type TestResult = 'ok' | 'no-tables' | `fail:${string}`;
|
|
23
|
+
/** Promise-based panel API; the client entry implements it over connection rpc. */
|
|
24
|
+
export interface StorageClient {
|
|
25
|
+
snapshot(): Promise<HealthStatus>;
|
|
26
|
+
testConnection(cfg: SyncConfig): Promise<TestResult>;
|
|
27
|
+
createTables(cfg: SyncConfig): Promise<'ok' | `fail:${string}`>;
|
|
28
|
+
getConfig(): Promise<{
|
|
29
|
+
config: SyncConfig;
|
|
30
|
+
error?: 'parse' | 'missing-field';
|
|
31
|
+
}>;
|
|
32
|
+
saveConfig(cfg: SyncConfig): Promise<unknown>;
|
|
33
|
+
backfill(): Promise<BackfillSummary>;
|
|
34
|
+
}
|
|
35
|
+
export declare const DEFAULT_TIMEZONES: string[];
|
|
36
|
+
/** Mirrors config.ts isValidPort — kept local so no node builtins enter the browser bundle. */
|
|
37
|
+
export declare function isValidPort(value: unknown): value is number;
|
|
38
|
+
/**
|
|
39
|
+
* The cfg object to persist: the port is sent only when it is a valid
|
|
40
|
+
* integer — a cleared/garbage port field is omitted so the RPC rejects it
|
|
41
|
+
* with an honest per-field error instead of building a host:NaN URL.
|
|
42
|
+
*/
|
|
43
|
+
export declare function buildSaveConfig(cfg: SyncConfig): SyncConfig;
|
|
44
|
+
/** Structural React surface the factory needs (satisfied by react 18). */
|
|
45
|
+
export interface ReactLike {
|
|
46
|
+
createElement: any;
|
|
47
|
+
useState: <S>(initial: S) => [S, (value: S | ((prev: S) => S)) => void];
|
|
48
|
+
useEffect: (effect: () => void | (() => void), deps?: unknown[]) => void;
|
|
49
|
+
}
|
|
50
|
+
export type Tone = 'ok' | 'warn' | 'bad' | 'idle';
|
|
51
|
+
/** Status-light copy per HealthStatus.state. */
|
|
52
|
+
export declare function statusView(status: HealthStatus): {
|
|
53
|
+
tone: Tone;
|
|
54
|
+
label: string;
|
|
55
|
+
details: string[];
|
|
56
|
+
};
|
|
57
|
+
/** Three-state connection-test copy: green ok / yellow no-tables / red fail. */
|
|
58
|
+
export declare function testResultView(result: TestResult): {
|
|
59
|
+
tone: Tone;
|
|
60
|
+
label: string;
|
|
61
|
+
};
|
|
62
|
+
export declare function createResultView(result: 'ok' | `fail:${string}`): {
|
|
63
|
+
tone: Tone;
|
|
64
|
+
label: string;
|
|
65
|
+
};
|
|
66
|
+
/** 补传 summary copy: green when clean, yellow when some sessions failed. */
|
|
67
|
+
export declare function backfillResultView(summary: BackfillSummary): {
|
|
68
|
+
tone: Tone;
|
|
69
|
+
label: string;
|
|
70
|
+
};
|
|
71
|
+
export interface PanelProps {
|
|
72
|
+
client: StorageClient;
|
|
73
|
+
/** Status poll interval; default 30s per the task brief. */
|
|
74
|
+
pollMs?: number;
|
|
75
|
+
}
|
|
76
|
+
export declare function makeStoragePanel(React: ReactLike): {
|
|
77
|
+
(props: PanelProps): any;
|
|
78
|
+
displayName: string;
|
|
79
|
+
};
|