dsh-workspace-plus 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +107 -572
- package/cordis.patch.yml +33 -1
- package/lib/client.js +448 -11
- package/lib/index.js +63 -6
- package/lib/notice/click-host.cs +515 -0
- package/lib/notice/index.js +114 -20
- package/lib/notice/launcher.js +455 -0
- package/lib/notice/win32.js +26 -2
- package/lib/sessionlog/icon.svg +19 -0
- package/lib/sessionlog/index.js +636 -0
- package/lib/sessionlog/locale/en.json +6 -0
- package/lib/sessionlog/locale/zh.json +6 -0
- package/lib/sessionlog/package.json +6 -0
- package/lib/sessionlog/redact.js +142 -0
- package/lib/sessionlog/settings.js +436 -0
- package/lib/sessionlog/wire.js +235 -0
- package/package.json +11 -3
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-workspace-plus / sessionlog — host half.
|
|
3
|
+
*
|
|
4
|
+
* DSH ships an "在使用官方模型 API 时上传 Session Log" preference whose whole
|
|
5
|
+
* delivery path hangs off the **official DeepSeek adapter**: the host plugin
|
|
6
|
+
* `@deepseek-ai/dsh-session-log-deepseek` registers a `dsh_session_log` field on
|
|
7
|
+
* the `deepseekLlmApiExtensions` registry, and only `dsh-llm-deepseek`'s
|
|
8
|
+
* `/messages` request ever carries it. A user whose active model provider is
|
|
9
|
+
* something else therefore uploads nothing at all — there is no request to
|
|
10
|
+
* attach the field to.
|
|
11
|
+
*
|
|
12
|
+
* This sub-feature reproduces that behaviour and closes the gap. It has two
|
|
13
|
+
* independent channels, both gated by one switch that ships **OFF**:
|
|
14
|
+
*
|
|
15
|
+
* 1. **OTLP session-log channel (primary).** It observes the canonical session
|
|
16
|
+
* stream (`session/created`, `session/event`) and hands one record per
|
|
17
|
+
* event to its own byte-bounded channel created through the public
|
|
18
|
+
* `ctx.otel.createSessionLogReporter(...)`. That transport is owned by
|
|
19
|
+
* DSH, carries batching/retry/shutdown, and — crucially — does not care
|
|
20
|
+
* which model adapter is in play. It posts to the DeepSeek-operated
|
|
21
|
+
* collector the base profile already uses for its own session telemetry.
|
|
22
|
+
* 2. **`dsh_session_log` request field (parity fallback).** When the official
|
|
23
|
+
* DeepSeek adapter is the one performing the request *and* no other plugin
|
|
24
|
+
* already owns that field (in a shipped profile the built-in package does),
|
|
25
|
+
* this half contributes the field in the shipped v1 wire format. It is a
|
|
26
|
+
* fallback for compositions that drop the built-in package, not a second
|
|
27
|
+
* copy of the primary channel.
|
|
28
|
+
*
|
|
29
|
+
* Nothing here imports anything but Node builtins and sibling files, and the
|
|
30
|
+
* whole thing reads no credentials: the collector endpoint is a public ingest
|
|
31
|
+
* URL, and the only identity attached is the Harness home's anonymous UUID.
|
|
32
|
+
*
|
|
33
|
+
* @module dsh-workspace-plus/sessionlog
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { readFileSync } from 'node:fs'
|
|
37
|
+
import { dirname, join } from 'node:path'
|
|
38
|
+
import { fileURLToPath } from 'node:url'
|
|
39
|
+
import {
|
|
40
|
+
ENDPOINT_ENV,
|
|
41
|
+
MAX_REQUEST_BYTES_CEILING,
|
|
42
|
+
MAX_SHUTDOWN_TIMEOUT_MILLIS,
|
|
43
|
+
SETTING_KEYS,
|
|
44
|
+
anonymousUserId,
|
|
45
|
+
coerceBoolean,
|
|
46
|
+
coerceCount,
|
|
47
|
+
composeSettings,
|
|
48
|
+
defaultSettings,
|
|
49
|
+
normalizeEndpoint,
|
|
50
|
+
normalizeSettings,
|
|
51
|
+
readStore,
|
|
52
|
+
resolveEndpoint,
|
|
53
|
+
storePath,
|
|
54
|
+
telemetryKillSwitch,
|
|
55
|
+
uploadBlockedBy,
|
|
56
|
+
writeStore,
|
|
57
|
+
} from './settings.js'
|
|
58
|
+
import {
|
|
59
|
+
REQUEST_FIELD,
|
|
60
|
+
SCOPE_NAME,
|
|
61
|
+
buildRequestField,
|
|
62
|
+
sessionLogRecord,
|
|
63
|
+
} from './wire.js'
|
|
64
|
+
import { detectCredentialShapes } from './redact.js'
|
|
65
|
+
|
|
66
|
+
/** Loader row module name; must stay in step with the `./sessionlog` export. */
|
|
67
|
+
export const name = 'dsh-workspace-plus/sessionlog'
|
|
68
|
+
|
|
69
|
+
/** Same-origin JSON route the browser half reads and writes. */
|
|
70
|
+
export const API_PATH = '/dsh-sessionlog/api'
|
|
71
|
+
|
|
72
|
+
/** Request-body ceiling, matching the plugin's other two routes. */
|
|
73
|
+
const MAX_BODY_BYTES = 64 * 1024
|
|
74
|
+
|
|
75
|
+
/** `service.name` reported to the collector, matching DSH's own telemetry. */
|
|
76
|
+
export const SERVICE_NAME = 'deepseek-harness'
|
|
77
|
+
|
|
78
|
+
/** Where the request-field channel currently stands, for the settings page. */
|
|
79
|
+
export const REQUEST_FIELD_STATES = Object.freeze(['active', 'taken', 'unavailable', 'disabled'])
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Read this package's version. `files` publishes `lib/`, so the root manifest
|
|
83
|
+
* is always two directories up in an installed bundle.
|
|
84
|
+
*
|
|
85
|
+
* @returns the version string, or `'0.0.0'` when it cannot be read.
|
|
86
|
+
*/
|
|
87
|
+
export function pluginVersion() {
|
|
88
|
+
try {
|
|
89
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
90
|
+
const manifest = JSON.parse(readFileSync(join(here, '..', '..', 'package.json'), 'utf8'))
|
|
91
|
+
return typeof manifest.version === 'string' && manifest.version !== '' ? manifest.version : '0.0.0'
|
|
92
|
+
} catch {
|
|
93
|
+
return '0.0.0'
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Host entry point.
|
|
99
|
+
*
|
|
100
|
+
* @param ctx - Cordis host context.
|
|
101
|
+
* @param config - the loader row's `config:` block (deployment defaults).
|
|
102
|
+
*/
|
|
103
|
+
export function apply(ctx, config) {
|
|
104
|
+
const file = storePath()
|
|
105
|
+
const base = normalizeSettings(config)
|
|
106
|
+
const loaded = readStore(file)
|
|
107
|
+
let overrides = loaded.overrides
|
|
108
|
+
let accepted = loaded.accepted
|
|
109
|
+
if (loaded.warning !== null) ctx.logger?.warn?.(`[${name}] ${loaded.warning}`)
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Effective settings, cached. `report()` runs on the session-event firehose,
|
|
113
|
+
* so recomposing (and re-parsing the endpoint URL) per event would put
|
|
114
|
+
* avoidable work on the append path; `refresh()` is the one invalidation
|
|
115
|
+
* point, called at load and after every accepted settings write.
|
|
116
|
+
*/
|
|
117
|
+
let effective = composeSettings(base, overrides)
|
|
118
|
+
let effectiveEndpoint = resolveEndpoint(effective, process.env)
|
|
119
|
+
|
|
120
|
+
/** @returns the cached effective settings. */
|
|
121
|
+
const current = () => effective
|
|
122
|
+
|
|
123
|
+
/** Recompute the cached settings after a write. */
|
|
124
|
+
function refresh() {
|
|
125
|
+
effective = composeSettings(base, overrides)
|
|
126
|
+
effectiveEndpoint = resolveEndpoint(effective, process.env)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Per-session high-water mark of what this process has already handed over. */
|
|
130
|
+
const cursor = new WeakMap()
|
|
131
|
+
|
|
132
|
+
/** Observable counters surfaced on the settings page. */
|
|
133
|
+
const stats = {
|
|
134
|
+
startedAt: Date.now(),
|
|
135
|
+
events: 0,
|
|
136
|
+
sessions: 0,
|
|
137
|
+
lastEventAt: null,
|
|
138
|
+
lastAcceptAt: null,
|
|
139
|
+
lastError: null,
|
|
140
|
+
lastErrorAt: null,
|
|
141
|
+
/** Credential shapes scrubbed out of outbound events, and when the last one was. */
|
|
142
|
+
redactions: 0,
|
|
143
|
+
lastRedactionAt: null,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const recordError = (message) => {
|
|
147
|
+
stats.lastError = message
|
|
148
|
+
stats.lastErrorAt = Date.now()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// --- Channel 1: the provider-independent OTLP session-log channel ---------
|
|
152
|
+
|
|
153
|
+
/** The `otel` service, once the composition provides it. */
|
|
154
|
+
let otelScope = null
|
|
155
|
+
/** The channel this plugin owns; `null` while uploading is off or unavailable. */
|
|
156
|
+
let reporter = null
|
|
157
|
+
/** Endpoint the live channel was built for, so a change rebuilds it. */
|
|
158
|
+
let reporterEndpoint = null
|
|
159
|
+
/** Where the request-field channel stands; the page renders this. */
|
|
160
|
+
let requestFieldState = 'disabled'
|
|
161
|
+
/** Why the request-field channel is not active, when it is not. */
|
|
162
|
+
let requestFieldError = null
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Drain and release the current channel. The channel is detached first so a
|
|
166
|
+
* concurrent `ensureReporter` can never hand work to a channel that is on its
|
|
167
|
+
* way out.
|
|
168
|
+
*/
|
|
169
|
+
async function shutdownReporter() {
|
|
170
|
+
const active = reporter
|
|
171
|
+
reporter = null
|
|
172
|
+
reporterEndpoint = null
|
|
173
|
+
if (active === null) return
|
|
174
|
+
const deadline = current().shutdownTimeoutMillis
|
|
175
|
+
let timer
|
|
176
|
+
try {
|
|
177
|
+
await Promise.race([
|
|
178
|
+
Promise.resolve(active.shutdown()),
|
|
179
|
+
new Promise((_resolve, reject) => {
|
|
180
|
+
timer = setTimeout(() => {
|
|
181
|
+
try {
|
|
182
|
+
active.stopPending?.()
|
|
183
|
+
} catch {
|
|
184
|
+
/* the channel is being abandoned; nothing useful to do with this */
|
|
185
|
+
}
|
|
186
|
+
reject(new Error(`provider shutdown exceeded ${deadline}ms`))
|
|
187
|
+
}, deadline)
|
|
188
|
+
}),
|
|
189
|
+
])
|
|
190
|
+
} catch (error) {
|
|
191
|
+
const message = String((error && error.message) || error)
|
|
192
|
+
ctx.logger?.warn?.(`[${name}] upload channel shutdown: ${message}`)
|
|
193
|
+
} finally {
|
|
194
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Materialize the channel for the current settings, reusing it when neither
|
|
200
|
+
* enablement nor endpoint changed.
|
|
201
|
+
*
|
|
202
|
+
* @returns the channel to report into, or `null` when uploading is off or
|
|
203
|
+
* `ctx.otel` is not mounted.
|
|
204
|
+
*/
|
|
205
|
+
function ensureReporter() {
|
|
206
|
+
const settings = current()
|
|
207
|
+
if (uploadBlockedBy(settings) !== null) {
|
|
208
|
+
void shutdownReporter()
|
|
209
|
+
return null
|
|
210
|
+
}
|
|
211
|
+
if (otelScope === null || typeof otelScope.otel?.createSessionLogReporter !== 'function') return null
|
|
212
|
+
const endpoint = effectiveEndpoint
|
|
213
|
+
if (reporter !== null && reporterEndpoint === endpoint) return reporter
|
|
214
|
+
void shutdownReporter()
|
|
215
|
+
try {
|
|
216
|
+
reporter = otelScope.otel.createSessionLogReporter({
|
|
217
|
+
scope: { name: SCOPE_NAME, version: pluginVersion() },
|
|
218
|
+
exporter: { url: endpoint, compression: 'gzip', timeoutMillis: 1000 },
|
|
219
|
+
processor: {
|
|
220
|
+
scheduledDelayMillis: 10000,
|
|
221
|
+
maxQueueSize: 2048,
|
|
222
|
+
maxExportBatchSize: 2048,
|
|
223
|
+
exportTimeoutMillis: 1500,
|
|
224
|
+
},
|
|
225
|
+
maxRequestBytes: settings.maxRequestBytes,
|
|
226
|
+
resourceAttributes: {
|
|
227
|
+
'service.name': SERVICE_NAME,
|
|
228
|
+
'service.version': typeof process.env.DSH_CLIENT_VERSION === 'string' && process.env.DSH_CLIENT_VERSION !== '' ? process.env.DSH_CLIENT_VERSION : 'unknown',
|
|
229
|
+
'user.id': anonymousUserId(),
|
|
230
|
+
'dsh.plugin': name,
|
|
231
|
+
},
|
|
232
|
+
onFailure: (message, error) => {
|
|
233
|
+
recordError(`${String(message)}${error === undefined ? '' : `: ${String((error && error.message) || error)}`}`)
|
|
234
|
+
ctx.logger?.warn?.(`[${name}] ${message}`, error)
|
|
235
|
+
},
|
|
236
|
+
})
|
|
237
|
+
reporterEndpoint = endpoint
|
|
238
|
+
ctx.logger?.info?.(`[${name}] 会话日志上传已开启 → ${endpoint}`)
|
|
239
|
+
} catch (error) {
|
|
240
|
+
const message = String((error && error.message) || error)
|
|
241
|
+
recordError(`创建上传通道失败:${message}`)
|
|
242
|
+
ctx.logger?.warn?.(`[${name}] 创建上传通道失败:${message}`)
|
|
243
|
+
reporter = null
|
|
244
|
+
reporterEndpoint = null
|
|
245
|
+
return null
|
|
246
|
+
}
|
|
247
|
+
return reporter
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Hand one canonical event to the live channel, advancing the in-process
|
|
252
|
+
* cursor only after the handoff succeeded.
|
|
253
|
+
*
|
|
254
|
+
* @param session - the owning Session.
|
|
255
|
+
* @param event - the canonical event.
|
|
256
|
+
*/
|
|
257
|
+
function report(session, event) {
|
|
258
|
+
if (event === null || typeof event !== 'object' || typeof event.type !== 'string') return
|
|
259
|
+
if (uploadBlockedBy(current()) !== null) return
|
|
260
|
+
const active = ensureReporter()
|
|
261
|
+
if (active === null) return
|
|
262
|
+
const seq = Number(event.seq)
|
|
263
|
+
if (!Number.isSafeInteger(seq)) return
|
|
264
|
+
const seen = cursor.get(session)
|
|
265
|
+
if (typeof seen === 'number' && seq <= seen) return
|
|
266
|
+
try {
|
|
267
|
+
// Redaction happens inside the wire layer, so no outbound path can skip it.
|
|
268
|
+
// Counting here is the only way the settings page can prove it is working.
|
|
269
|
+
const shapes = detectCredentialShapes(event.data)
|
|
270
|
+
if (shapes.length > 0) {
|
|
271
|
+
stats.redactions += shapes.length
|
|
272
|
+
stats.lastRedactionAt = Date.now()
|
|
273
|
+
}
|
|
274
|
+
active.reportSessionLog(sessionLogRecord(session, event))
|
|
275
|
+
cursor.set(session, seq)
|
|
276
|
+
stats.events += 1
|
|
277
|
+
stats.lastEventAt = Date.now()
|
|
278
|
+
} catch (error) {
|
|
279
|
+
recordError(String((error && error.message) || error))
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Replay the canonical log from one offset through the same path a live event
|
|
285
|
+
* takes, so ordering and de-duplication stay identical.
|
|
286
|
+
*
|
|
287
|
+
* @param session - the Session to replay.
|
|
288
|
+
* @param fromSeq - inclusive first sequence to send.
|
|
289
|
+
*/
|
|
290
|
+
function replay(session, fromSeq) {
|
|
291
|
+
if (typeof session.snapshotEvents !== 'function') return
|
|
292
|
+
for (const event of session.snapshotEvents(fromSeq)) report(session, event)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Start following one Session. A Session this process has never seen begins
|
|
297
|
+
* either at its first lifecycle event (the default) or at sequence 0 when
|
|
298
|
+
* history upload is on.
|
|
299
|
+
*
|
|
300
|
+
* @param session - the live Session.
|
|
301
|
+
*/
|
|
302
|
+
function adoptLive(session) {
|
|
303
|
+
if (cursor.has(session)) return
|
|
304
|
+
const settings = current()
|
|
305
|
+
if (uploadBlockedBy(settings) !== null) return
|
|
306
|
+
const first = Number(session.firstLifecycleSeq ?? 0)
|
|
307
|
+
const start = settings.includeHistory === true ? 0 : first
|
|
308
|
+
cursor.set(session, start - 1)
|
|
309
|
+
stats.sessions += 1
|
|
310
|
+
replay(session, start)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Sweep the Sessions that are already live. This is what makes turning the
|
|
315
|
+
* switch on mid-run behave like a restart: sessions created before the plugin
|
|
316
|
+
* (or before the switch) are picked up instead of silently skipped.
|
|
317
|
+
*/
|
|
318
|
+
function adoptExisting() {
|
|
319
|
+
const settings = current()
|
|
320
|
+
if (uploadBlockedBy(settings) !== null) return
|
|
321
|
+
if (typeof ctx.sessions?.list !== 'function') return
|
|
322
|
+
for (const session of ctx.sessions.list()) {
|
|
323
|
+
if (cursor.has(session)) continue
|
|
324
|
+
const start = settings.includeHistory === true ? 0 : Number(session.seq)
|
|
325
|
+
cursor.set(session, start - 1)
|
|
326
|
+
stats.sessions += 1
|
|
327
|
+
replay(session, start)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Re-evaluate enablement after a settings write. */
|
|
332
|
+
function afterSettingsChange() {
|
|
333
|
+
if (uploadBlockedBy(current()) === null) {
|
|
334
|
+
ensureReporter()
|
|
335
|
+
adoptExisting()
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
void shutdownReporter()
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
ctx.inject(['otel'], (scope) => {
|
|
342
|
+
otelScope = scope
|
|
343
|
+
afterSettingsChange()
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
// --- Channel 2: the `dsh_session_log` request field -----------------------
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Highest sequence the provider already accepted for this exact Session
|
|
350
|
+
* format generation, or `-1` before any accepted request. A mismatch in
|
|
351
|
+
* format generation discards the watermark rather than resending a range the
|
|
352
|
+
* provider would reinterpret.
|
|
353
|
+
*
|
|
354
|
+
* @param session - the Session the request carries.
|
|
355
|
+
* @returns the exclusive lower bound for the next upload.
|
|
356
|
+
*/
|
|
357
|
+
function watermarkFor(session) {
|
|
358
|
+
const entry = accepted[String(session.id)]
|
|
359
|
+
if (entry === undefined) return -1
|
|
360
|
+
if (entry.formatVersion !== Number(session.header?.version ?? 0)) return -1
|
|
361
|
+
return entry.throughSeq
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Prepare the request field for one official DeepSeek request. Returning
|
|
366
|
+
* `undefined` means "this request carries no field", which is the honest
|
|
367
|
+
* answer whenever uploading is off, the Session is not live, or nothing new
|
|
368
|
+
* fits the byte ceiling.
|
|
369
|
+
*
|
|
370
|
+
* @param request - the adapter's prepared request facts.
|
|
371
|
+
* @returns `{ value, accept }`, or `undefined`.
|
|
372
|
+
*/
|
|
373
|
+
function prepareRequestField(request) {
|
|
374
|
+
const settings = current()
|
|
375
|
+
if (uploadBlockedBy(settings) !== null || settings.requestField !== true) return undefined
|
|
376
|
+
if (request === null || typeof request !== 'object' || request.sessionId === undefined) return undefined
|
|
377
|
+
const session = ctx.sessions?.get?.(request.sessionId)
|
|
378
|
+
if (session === undefined || session === null) return undefined
|
|
379
|
+
const built = buildRequestField({
|
|
380
|
+
session,
|
|
381
|
+
afterSeq: watermarkFor(session),
|
|
382
|
+
maxBytes: settings.maxRequestBytes,
|
|
383
|
+
})
|
|
384
|
+
if (built === undefined) return undefined
|
|
385
|
+
return {
|
|
386
|
+
value: built.value,
|
|
387
|
+
accept: () => {
|
|
388
|
+
accepted = {
|
|
389
|
+
...accepted,
|
|
390
|
+
[String(session.id)]: {
|
|
391
|
+
formatVersion: Number(session.header?.version ?? 0),
|
|
392
|
+
throughSeq: built.throughSeq,
|
|
393
|
+
},
|
|
394
|
+
}
|
|
395
|
+
const written = writeStore(file, { overrides, accepted })
|
|
396
|
+
if (!written.ok) {
|
|
397
|
+
recordError(`保存接受水位失败:${written.message}`)
|
|
398
|
+
ctx.logger?.warn?.(`[${name}] 保存接受水位失败:${written.message}`)
|
|
399
|
+
}
|
|
400
|
+
stats.lastAcceptAt = Date.now()
|
|
401
|
+
},
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
ctx.inject(['deepseekLlmApiExtensions'], (scope) => {
|
|
406
|
+
const registry = scope.deepseekLlmApiExtensions
|
|
407
|
+
if (registry === undefined || typeof registry.register !== 'function') {
|
|
408
|
+
requestFieldState = 'unavailable'
|
|
409
|
+
requestFieldError = '当前组合里没有 deepseekLlmApiExtensions 服务'
|
|
410
|
+
return
|
|
411
|
+
}
|
|
412
|
+
// The shipped `@deepseek-ai/dsh-session-log-deepseek` registers this exact
|
|
413
|
+
// field at load time, and the registry throws on a duplicate. Detect the
|
|
414
|
+
// owner first so a shipped profile never turns this fallback into a load
|
|
415
|
+
// failure; the primary OTLP channel covers those profiles anyway.
|
|
416
|
+
if (registry.providers?.has?.(REQUEST_FIELD) === true) {
|
|
417
|
+
requestFieldState = 'taken'
|
|
418
|
+
requestFieldError = `${REQUEST_FIELD} 已由其它插件提供(通常是 @deepseek-ai/dsh-session-log-deepseek);请求字段这一路自动跳过`
|
|
419
|
+
ctx.logger?.info?.(`[${name}] ${requestFieldError}`)
|
|
420
|
+
return
|
|
421
|
+
}
|
|
422
|
+
try {
|
|
423
|
+
scope.effect(
|
|
424
|
+
() => registry.register(REQUEST_FIELD, { prepare: prepareRequestField }),
|
|
425
|
+
`${name}: register(${REQUEST_FIELD})`,
|
|
426
|
+
)
|
|
427
|
+
requestFieldState = 'active'
|
|
428
|
+
requestFieldError = null
|
|
429
|
+
} catch (error) {
|
|
430
|
+
requestFieldState = 'unavailable'
|
|
431
|
+
requestFieldError = String((error && error.message) || error)
|
|
432
|
+
ctx.logger?.warn?.(`[${name}] 注册 ${REQUEST_FIELD} 失败:${requestFieldError}`)
|
|
433
|
+
}
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
// --- Follow the canonical session stream ---------------------------------
|
|
437
|
+
|
|
438
|
+
ctx.inject(['sessions'], (scope) => {
|
|
439
|
+
scope.on('session/created', (session) => {
|
|
440
|
+
try {
|
|
441
|
+
adoptLive(session)
|
|
442
|
+
} catch (error) {
|
|
443
|
+
recordError(String((error && error.message) || error))
|
|
444
|
+
}
|
|
445
|
+
})
|
|
446
|
+
scope.on('session/event', (session, event) => {
|
|
447
|
+
// Every listener runs inside Cordis' stop-on-throw emit, so a failure
|
|
448
|
+
// here would starve later subscribers; report() already contains its own.
|
|
449
|
+
try {
|
|
450
|
+
report(session, event)
|
|
451
|
+
} catch (error) {
|
|
452
|
+
recordError(String((error && error.message) || error))
|
|
453
|
+
}
|
|
454
|
+
})
|
|
455
|
+
scope.on('session/disposed', (session) => {
|
|
456
|
+
cursor.delete(session)
|
|
457
|
+
})
|
|
458
|
+
adoptExisting()
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
// --- The browser half's same-origin JSON endpoint ------------------------
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Everything the settings page renders.
|
|
465
|
+
*
|
|
466
|
+
* @returns a JSON-safe snapshot.
|
|
467
|
+
*/
|
|
468
|
+
const snapshot = () => {
|
|
469
|
+
const settings = current()
|
|
470
|
+
const fromEnv = typeof process.env[ENDPOINT_ENV] === 'string' ? process.env[ENDPOINT_ENV].trim() : ''
|
|
471
|
+
return {
|
|
472
|
+
ok: true,
|
|
473
|
+
settings: { ...settings },
|
|
474
|
+
defaults: defaultSettings(),
|
|
475
|
+
effectiveEndpoint,
|
|
476
|
+
endpointSource: settings.endpoint !== '' ? 'settings' : fromEnv !== '' ? 'environment' : 'default',
|
|
477
|
+
// A process-level opt-out outranks this page's switch; the page says so
|
|
478
|
+
// rather than pretending the switch it just flipped did nothing.
|
|
479
|
+
killSwitch: telemetryKillSwitch(),
|
|
480
|
+
blockedReason: uploadBlockedBy(settings),
|
|
481
|
+
channel: {
|
|
482
|
+
otlpAvailable: otelScope !== null,
|
|
483
|
+
reporting: reporter !== null,
|
|
484
|
+
requestField: requestFieldState,
|
|
485
|
+
requestFieldError,
|
|
486
|
+
},
|
|
487
|
+
storePath: file,
|
|
488
|
+
stats: { ...stats },
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Validate and apply one settings patch. Every value is checked before
|
|
494
|
+
* anything is written, and a value equal to the bundle default drops its
|
|
495
|
+
* override instead of pinning it — otherwise a deployment that shipped
|
|
496
|
+
* `enabled: true` could never be switched off from the page.
|
|
497
|
+
*
|
|
498
|
+
* @param patch - the requested keys.
|
|
499
|
+
* @returns `{ ok, message, settings }`.
|
|
500
|
+
*/
|
|
501
|
+
function applyPatch(patch) {
|
|
502
|
+
if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
503
|
+
return { ok: false, message: 'patch 必须是对象', settings: current() }
|
|
504
|
+
}
|
|
505
|
+
const next = { ...overrides }
|
|
506
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
507
|
+
if (!SETTING_KEYS.includes(key)) return { ok: false, message: `未知设置项:${key}`, settings: current() }
|
|
508
|
+
if (key === 'endpoint') {
|
|
509
|
+
const normalized = normalizeEndpoint(value)
|
|
510
|
+
if (normalized.warning !== null) return { ok: false, message: normalized.warning, settings: current() }
|
|
511
|
+
if (normalized.value === base.endpoint) delete next[key]
|
|
512
|
+
else next[key] = normalized.value
|
|
513
|
+
continue
|
|
514
|
+
}
|
|
515
|
+
if (key === 'maxRequestBytes' || key === 'shutdownTimeoutMillis') {
|
|
516
|
+
const number = typeof value === 'number' ? value : Number(String(value ?? '').trim())
|
|
517
|
+
if (!Number.isFinite(number) || number <= 0) {
|
|
518
|
+
return { ok: false, message: `${key} 必须是正数`, settings: current() }
|
|
519
|
+
}
|
|
520
|
+
const ceiling = key === 'maxRequestBytes' ? MAX_REQUEST_BYTES_CEILING : MAX_SHUTDOWN_TIMEOUT_MILLIS
|
|
521
|
+
const coerced = coerceCount(value, NaN, ceiling)
|
|
522
|
+
if (!Number.isFinite(coerced)) return { ok: false, message: `${key} 不是合法数值`, settings: current() }
|
|
523
|
+
if (coerced === base[key]) delete next[key]
|
|
524
|
+
else next[key] = coerced
|
|
525
|
+
continue
|
|
526
|
+
}
|
|
527
|
+
const coerced = coerceBoolean(value, undefined)
|
|
528
|
+
if (typeof coerced !== 'boolean') {
|
|
529
|
+
return { ok: false, message: `${key} 必须是布尔值`, settings: current() }
|
|
530
|
+
}
|
|
531
|
+
if (coerced === base[key]) delete next[key]
|
|
532
|
+
else next[key] = coerced
|
|
533
|
+
}
|
|
534
|
+
overrides = next
|
|
535
|
+
const written = writeStore(file, { overrides, accepted })
|
|
536
|
+
if (!written.ok) return { ok: false, message: written.message, settings: current() }
|
|
537
|
+
refresh()
|
|
538
|
+
afterSettingsChange()
|
|
539
|
+
return { ok: true, message: '设置已保存', settings: current() }
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
543
|
+
webCtx.effect(
|
|
544
|
+
() =>
|
|
545
|
+
webCtx.webServer.register({
|
|
546
|
+
kind: 'exact',
|
|
547
|
+
path: API_PATH,
|
|
548
|
+
handler: async (req, res) => {
|
|
549
|
+
const connection = ctx.get('connection')
|
|
550
|
+
const admission = connection?.admit?.(req)
|
|
551
|
+
if (admission !== undefined && 'rejection' in admission) {
|
|
552
|
+
res.writeHead(admission.rejection)
|
|
553
|
+
res.end(admission.rejection === 401 ? 'unauthorized' : 'forbidden')
|
|
554
|
+
return
|
|
555
|
+
}
|
|
556
|
+
const send = (status, payload) => {
|
|
557
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
558
|
+
res.end(JSON.stringify(payload))
|
|
559
|
+
}
|
|
560
|
+
try {
|
|
561
|
+
if (req.method === 'GET') {
|
|
562
|
+
send(200, snapshot())
|
|
563
|
+
return
|
|
564
|
+
}
|
|
565
|
+
if (req.method !== 'POST') {
|
|
566
|
+
send(405, { ok: false, message: 'method not allowed' })
|
|
567
|
+
return
|
|
568
|
+
}
|
|
569
|
+
const chunks = []
|
|
570
|
+
let bytes = 0
|
|
571
|
+
for await (const chunk of req) {
|
|
572
|
+
bytes += chunk.length
|
|
573
|
+
if (bytes > MAX_BODY_BYTES) {
|
|
574
|
+
// Drain before answering: a 413 that abandons the body can be
|
|
575
|
+
// swallowed by the socket teardown instead of reaching the page.
|
|
576
|
+
for await (const rest of req) void rest
|
|
577
|
+
send(413, { ok: false, message: 'request body too large', ...snapshot() })
|
|
578
|
+
return
|
|
579
|
+
}
|
|
580
|
+
chunks.push(chunk)
|
|
581
|
+
}
|
|
582
|
+
let payload
|
|
583
|
+
try {
|
|
584
|
+
payload = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
585
|
+
} catch {
|
|
586
|
+
send(400, { ok: false, message: 'body is not JSON', ...snapshot() })
|
|
587
|
+
return
|
|
588
|
+
}
|
|
589
|
+
if (payload?.op === 'patch') {
|
|
590
|
+
const result = applyPatch(payload.patch)
|
|
591
|
+
send(result.ok ? 200 : 400, { ...snapshot(), ok: result.ok, message: result.message })
|
|
592
|
+
return
|
|
593
|
+
}
|
|
594
|
+
if (payload?.op === 'reset') {
|
|
595
|
+
overrides = {}
|
|
596
|
+
const written = writeStore(file, { overrides, accepted })
|
|
597
|
+
if (!written.ok) {
|
|
598
|
+
send(500, { ok: false, message: written.message, ...snapshot() })
|
|
599
|
+
return
|
|
600
|
+
}
|
|
601
|
+
refresh()
|
|
602
|
+
afterSettingsChange()
|
|
603
|
+
send(200, { ...snapshot(), message: '已恢复默认' })
|
|
604
|
+
return
|
|
605
|
+
}
|
|
606
|
+
send(400, { ok: false, message: `unknown op: ${String(payload?.op)}`, ...snapshot() })
|
|
607
|
+
} catch (error) {
|
|
608
|
+
const message = String((error && error.message) || error)
|
|
609
|
+
recordError(message)
|
|
610
|
+
ctx.logger?.warn?.(`[${name}] ${message}`)
|
|
611
|
+
send(500, { ok: false, message })
|
|
612
|
+
}
|
|
613
|
+
},
|
|
614
|
+
}),
|
|
615
|
+
`${name}: ${API_PATH} route`,
|
|
616
|
+
)
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
// --- Teardown -------------------------------------------------------------
|
|
620
|
+
|
|
621
|
+
// The channel is owned by this fiber: unloading the row drains queued records
|
|
622
|
+
// up to the configured deadline instead of dropping them outright. The async
|
|
623
|
+
// disposer is awaited by Cordis, exactly like the shipped session-telemetry
|
|
624
|
+
// backend's own teardown.
|
|
625
|
+
ctx.effect(
|
|
626
|
+
() => () => shutdownReporter(),
|
|
627
|
+
`${name}: upload channel teardown`,
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
const settings = current()
|
|
631
|
+
ctx.logger?.info?.(
|
|
632
|
+
`[${name}] 已加载:上传${settings.enabled === true ? '已开启' : '默认关闭'}(${resolveEndpoint(settings, process.env)})`,
|
|
633
|
+
)
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
export { EVENT_BODY, EVENT_NAME, REQUEST_FIELD, SCOPE_NAME } from './wire.js'
|