@dsh-ft/dsh-fft-settings 0.0.0-stage → 0.5.1

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.
Files changed (132) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +395 -2
  3. package/cordis.patch.yml +18 -0
  4. package/docs/compatibility-dsh-0.1.6-alpha.1.md +58 -0
  5. package/docs/extension-service-design.md +351 -0
  6. package/docs/extension-service-sequences.html +545 -0
  7. package/docs/ftshare-settings-er.html +139 -0
  8. package/docs/ftshare-settings-er.svg +113 -0
  9. package/docs/ftshare-settings-results-er.html +148 -0
  10. package/docs/ftshare-settings-results-er.svg +122 -0
  11. package/docs/kline-integration.md +258 -0
  12. package/lib/agent.d.ts +24 -0
  13. package/lib/agent.js +26 -0
  14. package/lib/brand.d.ts +53 -0
  15. package/lib/brand.js +50 -0
  16. package/lib/client.d.ts +8 -0
  17. package/lib/client.js +806 -0
  18. package/lib/client.js.map +1 -0
  19. package/lib/contract.d.ts +14 -0
  20. package/lib/contract.js +14 -0
  21. package/lib/core/client-transport.d.ts +15 -0
  22. package/lib/core/client-transport.js +31 -0
  23. package/lib/core/contract.d.ts +62 -0
  24. package/lib/core/contract.js +28 -0
  25. package/lib/core/module-registry.d.ts +54 -0
  26. package/lib/core/module-registry.js +68 -0
  27. package/lib/core/rpc-core.d.ts +69 -0
  28. package/lib/core/rpc-core.js +88 -0
  29. package/lib/core/shell.d.ts +59 -0
  30. package/lib/core/shell.js +28 -0
  31. package/lib/core/styles.d.ts +31 -0
  32. package/lib/core/styles.js +43 -0
  33. package/lib/core/transport.d.ts +19 -0
  34. package/lib/core/transport.js +80 -0
  35. package/lib/default-preset.d.ts +52 -0
  36. package/lib/default-preset.js +121 -0
  37. package/lib/features/api-key/card.d.ts +24 -0
  38. package/lib/features/api-key/card.js +39 -0
  39. package/lib/features/api-key/client-api.d.ts +4 -0
  40. package/lib/features/api-key/client-api.js +11 -0
  41. package/lib/features/api-key/contract.d.ts +69 -0
  42. package/lib/features/api-key/contract.js +24 -0
  43. package/lib/features/api-key/host.d.ts +17 -0
  44. package/lib/features/api-key/host.js +32 -0
  45. package/lib/features/api-key/index.d.ts +13 -0
  46. package/lib/features/api-key/index.js +12 -0
  47. package/lib/features/api-key/logging.d.ts +15 -0
  48. package/lib/features/api-key/logging.js +1 -0
  49. package/lib/features/api-key/model.d.ts +24 -0
  50. package/lib/features/api-key/model.js +105 -0
  51. package/lib/features/api-key/probe.d.ts +17 -0
  52. package/lib/features/api-key/probe.js +148 -0
  53. package/lib/features/api-key/rpc.d.ts +4 -0
  54. package/lib/features/api-key/rpc.js +17 -0
  55. package/lib/features/api-key/service.d.ts +21 -0
  56. package/lib/features/api-key/service.js +181 -0
  57. package/lib/features/api-key/styles.d.ts +9 -0
  58. package/lib/features/api-key/styles.js +24 -0
  59. package/lib/features/logs/archive.d.ts +87 -0
  60. package/lib/features/logs/archive.js +156 -0
  61. package/lib/features/logs/card.d.ts +22 -0
  62. package/lib/features/logs/card.js +107 -0
  63. package/lib/features/logs/client-api.d.ts +4 -0
  64. package/lib/features/logs/client-api.js +11 -0
  65. package/lib/features/logs/collector.d.ts +91 -0
  66. package/lib/features/logs/collector.js +258 -0
  67. package/lib/features/logs/config.d.ts +70 -0
  68. package/lib/features/logs/config.js +97 -0
  69. package/lib/features/logs/contract.d.ts +130 -0
  70. package/lib/features/logs/contract.js +67 -0
  71. package/lib/features/logs/export.d.ts +92 -0
  72. package/lib/features/logs/export.js +212 -0
  73. package/lib/features/logs/format.d.ts +42 -0
  74. package/lib/features/logs/format.js +172 -0
  75. package/lib/features/logs/home.d.ts +18 -0
  76. package/lib/features/logs/home.js +39 -0
  77. package/lib/features/logs/host.d.ts +19 -0
  78. package/lib/features/logs/host.js +139 -0
  79. package/lib/features/logs/index.d.ts +12 -0
  80. package/lib/features/logs/index.js +12 -0
  81. package/lib/features/logs/rpc.d.ts +6 -0
  82. package/lib/features/logs/rpc.js +27 -0
  83. package/lib/features/logs/styles.d.ts +9 -0
  84. package/lib/features/logs/styles.js +13 -0
  85. package/lib/guidance.d.ts +41 -0
  86. package/lib/guidance.js +88 -0
  87. package/lib/index.d.ts +67 -0
  88. package/lib/index.js +50 -0
  89. package/lib/rpc.d.ts +10 -0
  90. package/lib/rpc.js +10 -0
  91. package/locale/en.json +6 -0
  92. package/locale/zh.json +6 -0
  93. package/package.json +108 -4
  94. package/src/agent.ts +31 -0
  95. package/src/brand.ts +79 -0
  96. package/src/client.tsx +21 -0
  97. package/src/contract.ts +14 -0
  98. package/src/core/client-transport.ts +37 -0
  99. package/src/core/contract.ts +57 -0
  100. package/src/core/module-registry.ts +102 -0
  101. package/src/core/rpc-core.ts +107 -0
  102. package/src/core/shell.tsx +81 -0
  103. package/src/core/styles.ts +43 -0
  104. package/src/core/transport.ts +82 -0
  105. package/src/default-preset.ts +151 -0
  106. package/src/features/api-key/card.tsx +77 -0
  107. package/src/features/api-key/client-api.ts +12 -0
  108. package/src/features/api-key/contract.ts +63 -0
  109. package/src/features/api-key/host.ts +36 -0
  110. package/src/features/api-key/index.ts +13 -0
  111. package/src/features/api-key/logging.ts +15 -0
  112. package/src/features/api-key/model.ts +63 -0
  113. package/src/features/api-key/probe.ts +125 -0
  114. package/src/features/api-key/rpc.ts +18 -0
  115. package/src/features/api-key/service.ts +157 -0
  116. package/src/features/api-key/styles.ts +24 -0
  117. package/src/features/logs/archive.ts +230 -0
  118. package/src/features/logs/card.tsx +169 -0
  119. package/src/features/logs/client-api.ts +20 -0
  120. package/src/features/logs/collector.ts +313 -0
  121. package/src/features/logs/config.ts +115 -0
  122. package/src/features/logs/contract.ts +149 -0
  123. package/src/features/logs/export.ts +274 -0
  124. package/src/features/logs/format.ts +188 -0
  125. package/src/features/logs/home.ts +43 -0
  126. package/src/features/logs/host.ts +153 -0
  127. package/src/features/logs/index.ts +12 -0
  128. package/src/features/logs/rpc.ts +29 -0
  129. package/src/features/logs/styles.ts +13 -0
  130. package/src/guidance.ts +114 -0
  131. package/src/index.ts +59 -0
  132. package/src/rpc.ts +10 -0
@@ -0,0 +1,313 @@
1
+ /**
2
+ * FT 运行日志采集器(Host 侧,不依赖 cordis,便于单测)。
3
+ *
4
+ * 职责:接收 cordis exporter 的记录 → 节流合并 → 脱敏 → 追加 `$DSH_HOME/logs/ft/current.log`,
5
+ * 超限轮转、保留 `keep` 份;写失败 / 轮转 / 丢弃一律自报 stderr,绝不抛回业务插件。
6
+ */
7
+
8
+ import { appendFile, mkdir, readdir, rename, stat, unlink } from 'node:fs/promises'
9
+ import { join } from 'node:path'
10
+ import {
11
+ LOGS_CURRENT_FILE,
12
+ LOGS_FILE_SUFFIX,
13
+ LOGS_LINE_MAX_BYTES,
14
+ LOGS_MAX_PENDING_KEYS,
15
+ LOGS_REPEAT_FIELD,
16
+ LOGS_ROTATED_PREFIX,
17
+ LOGS_SECRET_TTL_MS,
18
+ LOGS_THROTTLE_WINDOW_MS,
19
+ LOG_LEVEL_THRESHOLD,
20
+ type LogLevelName,
21
+ } from './contract.js'
22
+ import { byteLength, formatLogLine, formatLogMessage, formatRotationFileName, redactSecrets, truncateLine } from './format.js'
23
+
24
+ /** 采集器接受的最小记录形状;cordis 的 `Message` 结构上满足它。 */
25
+ export interface CollectorRecord {
26
+ readonly ts: number
27
+ readonly type: LogLevelName
28
+ readonly name: string
29
+ readonly args: readonly unknown[]
30
+ }
31
+
32
+ export interface CollectorOptions {
33
+ /** 落盘目录,通常是 `$DSH_HOME/logs/ft`。 */
34
+ readonly directory: string
35
+ readonly level: LogLevelName
36
+ readonly maxBytes: number
37
+ readonly keep: number
38
+ /** 已知密钥(如当前 FTShare Key)的延时提供者;取不到时按空处理。 */
39
+ readonly secrets?: () => Promise<readonly string[]>
40
+ readonly fileName?: string
41
+ /** 注入时钟,便于测试。 */
42
+ readonly now?: () => number
43
+ /** 注入定时器;返回取消函数。默认 `setTimeout().unref()`,不阻止进程退出。 */
44
+ readonly schedule?: (run: () => void, delayMs: number) => () => void
45
+ /** 自报通道;默认写 stderr。日志系统不能静默失败。 */
46
+ readonly selfReport?: (message: string) => void
47
+ }
48
+
49
+ export interface CollectorSnapshot {
50
+ readonly filePath: string
51
+ readonly fileBytes: number
52
+ readonly records: number
53
+ readonly dropped: number
54
+ readonly rotations: number
55
+ readonly writeFailures: number
56
+ readonly firstTs?: number
57
+ readonly lastTs?: number
58
+ readonly byName: Readonly<Record<string, number>>
59
+ }
60
+
61
+ interface PendingEntry {
62
+ /** 已格式化(未脱敏、未加重复次数)的单行。 */
63
+ readonly body: string
64
+ readonly name: string
65
+ readonly ts: number
66
+ count: number
67
+ firstTs: number
68
+ }
69
+
70
+ const defaultSchedule = (run: () => void, delayMs: number): (() => void) => {
71
+ const timer = setTimeout(run, delayMs)
72
+ timer.unref?.()
73
+ return () => clearTimeout(timer)
74
+ }
75
+
76
+ const defaultSelfReport = (message: string): void => {
77
+ try {
78
+ process.stderr.write(`[ft-logs] ${message}\n`)
79
+ } catch {
80
+ // 连 stderr 都写不进去时只能放弃自报,绝不能因此影响业务插件。
81
+ }
82
+ }
83
+
84
+ /** 常驻采集器;生命周期由调用方(cordis `ctx.effect`)负责关闭。 */
85
+ export class FtLogCollector {
86
+ readonly filePath: string
87
+ readonly threshold: number
88
+ private readonly directory: string
89
+ private readonly options: CollectorOptions
90
+ private readonly now: () => number
91
+ private readonly schedule: (run: () => void, delayMs: number) => () => void
92
+ private readonly report: (message: string) => void
93
+ private readonly pending = new Map<string, PendingEntry>()
94
+ private readonly byName = new Map<string, number>()
95
+ private chain: Promise<void> = Promise.resolve()
96
+ private ready: Promise<void> | undefined
97
+ private cancelTimer: (() => void) | undefined
98
+ private timerDeadline = 0
99
+ private bytes = 0
100
+ private records = 0
101
+ private dropped = 0
102
+ private rotations = 0
103
+ private writeFailures = 0
104
+ private firstTs: number | undefined
105
+ private lastTs: number | undefined
106
+ private cachedSecrets: readonly string[] = []
107
+ private secretsAt = 0
108
+ private closed = false
109
+
110
+ constructor(options: CollectorOptions) {
111
+ this.options = options
112
+ this.directory = options.directory
113
+ this.filePath = join(options.directory, options.fileName ?? LOGS_CURRENT_FILE)
114
+ this.threshold = LOG_LEVEL_THRESHOLD[options.level]
115
+ this.now = options.now ?? Date.now
116
+ this.schedule = options.schedule ?? defaultSchedule
117
+ this.report = options.selfReport ?? defaultSelfReport
118
+ }
119
+
120
+ /** 接收一条 cordis 记录:同 key 在 10 秒窗口内合并为一行并累计重复次数。 */
121
+ append(record: CollectorRecord): void {
122
+ if (this.closed) return
123
+ // 级别门槛在 cordis 侧已经过滤过一次,这里再兜一层:即使 exporter 的 levels 配错也不会写进文件。
124
+ if (LOG_LEVEL_THRESHOLD[record.type] > this.threshold) return
125
+ // 采集器绝不能把异常抛回业务插件的 logger 调用。
126
+ try {
127
+ const at = this.now()
128
+ // key 不含时间戳:同一消息在不同毫秒打出也必须合并。
129
+ const text = formatLogMessage(record.args)
130
+ const key = `${record.type}\u0000${record.name}\u0000${text}`
131
+ const existing = this.pending.get(key)
132
+ if (existing !== undefined && at - existing.firstTs < LOGS_THROTTLE_WINDOW_MS) {
133
+ existing.count += 1
134
+ return
135
+ }
136
+ this.sweep(at)
137
+ if (this.pending.size >= LOGS_MAX_PENDING_KEYS) this.dropOldest()
138
+ this.pending.set(key, { body: formatLogLine(record), name: record.name, ts: record.ts, count: 1, firstTs: at })
139
+ this.armTimer(at)
140
+ } catch (error) {
141
+ this.writeFailures += 1
142
+ this.report(`格式化日志失败:${error instanceof Error ? error.message : String(error)};累计失败 ${this.writeFailures} 次`)
143
+ }
144
+ }
145
+
146
+ /** 注册时回填 cordis 内置缓冲里剩余的 `error`/`info`(`warn`/`debug` 已丢,不可恢复)。 */
147
+ async backfill(records: readonly CollectorRecord[]): Promise<void> {
148
+ for (const record of records) this.append(record)
149
+ await this.flush()
150
+ }
151
+
152
+ /** 把队列全部落盘(导出前调用),等待写入链结束。 */
153
+ async flush(): Promise<void> {
154
+ this.cancel()
155
+ const entries = [...this.pending.values()]
156
+ this.pending.clear()
157
+ for (const entry of entries) this.enqueue(entry)
158
+ await this.ready
159
+ await this.chain
160
+ }
161
+
162
+ /** 关闭句柄与队列;之后 `append` 变为空操作。 */
163
+ async dispose(): Promise<void> {
164
+ await this.flush()
165
+ this.closed = true
166
+ this.cancel()
167
+ }
168
+
169
+ stats(): CollectorSnapshot {
170
+ return {
171
+ filePath: this.filePath,
172
+ fileBytes: this.bytes,
173
+ records: this.records,
174
+ dropped: this.dropped,
175
+ rotations: this.rotations,
176
+ writeFailures: this.writeFailures,
177
+ firstTs: this.firstTs,
178
+ lastTs: this.lastTs,
179
+ byName: Object.fromEntries(this.byName),
180
+ }
181
+ }
182
+
183
+ /** 当前已知密钥(带 TTL 缓存,避免每次写盘都解析凭据)。 */
184
+ private async currentSecrets(): Promise<readonly string[]> {
185
+ if (this.options.secrets === undefined) return []
186
+ const at = this.now()
187
+ if (this.secretsAt !== 0 && at - this.secretsAt < LOGS_SECRET_TTL_MS) return this.cachedSecrets
188
+ try {
189
+ this.cachedSecrets = (await this.options.secrets()).filter(secret => secret.length >= 6)
190
+ } catch {
191
+ // 取不到密钥时退化为纯模式脱敏,绝不能因此阻断写入。
192
+ this.cachedSecrets = []
193
+ }
194
+ this.secretsAt = at
195
+ return this.cachedSecrets
196
+ }
197
+
198
+ private armTimer(at: number): void {
199
+ const earliest = Math.min(...[...this.pending.values()].map(entry => entry.firstTs))
200
+ const deadline = earliest + LOGS_THROTTLE_WINDOW_MS
201
+ if (this.cancelTimer !== undefined && this.timerDeadline <= deadline) return
202
+ this.cancel()
203
+ this.timerDeadline = deadline
204
+ this.cancelTimer = this.schedule(() => {
205
+ this.cancelTimer = undefined
206
+ this.sweep(this.now())
207
+ }, Math.max(0, deadline - at))
208
+ }
209
+
210
+ private cancel(): void {
211
+ this.cancelTimer?.()
212
+ this.cancelTimer = undefined
213
+ this.timerDeadline = 0
214
+ }
215
+
216
+ /** 把窗口已过期的待写条目交给写入链(保持插入顺序)。 */
217
+ private sweep(at: number): void {
218
+ for (const [key, entry] of [...this.pending]) {
219
+ if (at - entry.firstTs < LOGS_THROTTLE_WINDOW_MS) continue
220
+ this.pending.delete(key)
221
+ this.enqueue(entry)
222
+ }
223
+ }
224
+
225
+ private dropOldest(): void {
226
+ const oldest = this.pending.keys().next()
227
+ if (oldest.done === true) return
228
+ const entry = this.pending.get(oldest.value)
229
+ this.pending.delete(oldest.value)
230
+ this.dropped += entry?.count ?? 1
231
+ this.report(`待写队列超过 ${LOGS_MAX_PENDING_KEYS} 条,丢弃最旧记录;累计丢弃 ${this.dropped} 条`)
232
+ }
233
+
234
+ private enqueue(entry: PendingEntry): void {
235
+ this.chain = this.chain.then(async () => {
236
+ if (this.closed) return
237
+ try {
238
+ const secrets = await this.currentSecrets()
239
+ const repeat = entry.count > 1 ? ` ${LOGS_REPEAT_FIELD}=${entry.count}` : ''
240
+ const line = truncateLine(redactSecrets(entry.body, secrets) + repeat, LOGS_LINE_MAX_BYTES)
241
+ await this.write(line, entry)
242
+ } catch (error) {
243
+ this.writeFailures += 1
244
+ this.report(`写入 ${this.filePath} 失败:${error instanceof Error ? error.message : String(error)};累计失败 ${this.writeFailures} 次`)
245
+ }
246
+ })
247
+ }
248
+
249
+ private async write(line: string, entry: PendingEntry): Promise<void> {
250
+ this.ready ??= mkdir(this.directory, { recursive: true }).then(async () => {
251
+ try {
252
+ this.bytes = (await stat(this.filePath)).size
253
+ } catch {
254
+ this.bytes = 0
255
+ }
256
+ })
257
+ await this.ready
258
+ const payload = `${line}\n`
259
+ const size = byteLength(payload)
260
+ if (this.bytes > 0 && this.bytes + size > this.options.maxBytes) await this.rotate()
261
+ await appendFile(this.filePath, payload, 'utf8')
262
+ this.bytes += size
263
+ this.records += 1
264
+ this.firstTs = this.firstTs === undefined ? entry.ts : Math.min(this.firstTs, entry.ts)
265
+ this.lastTs = this.lastTs === undefined ? entry.ts : Math.max(this.lastTs, entry.ts)
266
+ this.byName.set(entry.name, (this.byName.get(entry.name) ?? 0) + 1)
267
+ }
268
+
269
+ private async rotate(): Promise<void> {
270
+ const target = await this.uniqueRotationPath()
271
+ try {
272
+ await rename(this.filePath, target)
273
+ } catch (error) {
274
+ const code = (error as { code?: string }).code
275
+ if (code !== 'ENOENT') throw error
276
+ }
277
+ this.bytes = 0
278
+ this.rotations += 1
279
+ this.report(`日志轮转:${target}(保留最近 ${this.options.keep} 份)`)
280
+ await this.prune()
281
+ }
282
+
283
+ /** 同一毫秒内可能连续轮转两次(小 maxBytes + 突发),文件名必须唯一,否则会互相覆盖。 */
284
+ private async uniqueRotationPath(): Promise<string> {
285
+ const base = formatRotationFileName(this.now())
286
+ for (let suffix = 1; suffix < 1_000; suffix += 1) {
287
+ const candidate = join(this.directory, suffix === 1 ? base : base.replace(/\.log$/, `-${suffix}.log`))
288
+ try {
289
+ await stat(candidate)
290
+ } catch {
291
+ return candidate
292
+ }
293
+ }
294
+ return join(this.directory, base)
295
+ }
296
+
297
+ /** 保留策略按修改时间(而不是文件名)排序:同一毫秒连转时的 `-N` 后缀不影响新旧判断。 */
298
+ private async prune(): Promise<void> {
299
+ const names = (await readdir(this.directory))
300
+ .filter(name => name.startsWith(LOGS_ROTATED_PREFIX) && name.endsWith(LOGS_FILE_SUFFIX))
301
+ const stamped = await Promise.all(names.map(async name => {
302
+ const path = join(this.directory, name)
303
+ try {
304
+ return { path, mtime: (await stat(path)).mtimeMs }
305
+ } catch {
306
+ return { path, mtime: 0 }
307
+ }
308
+ }))
309
+ stamped.sort((left, right) => left.mtime - right.mtime)
310
+ const excess = stamped.slice(0, Math.max(0, stamped.length - this.options.keep))
311
+ for (const item of excess) await unlink(item.path).catch(() => undefined)
312
+ }
313
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * `logs` feature 配置(规格 §10):`level` / `maxBytes` / `keep` / `sources`,全部有安全默认值,
3
+ * 不配置也能正常挂载。
4
+ *
5
+ * 子 schema 归 feature 所有:本文件导出 `logs` 这一片的原生 Schemastery schema 与默认值,
6
+ * 插件入口 `src/index.ts` 只负责把它组合进插件的顶层 `Config`。新增 feature 的配置同理,
7
+ * 不要往这里塞别人的字段。
8
+ *
9
+ * `logsConfigSchema` 必须是**原生 Schemastery schema**,不能用任何手写的 Standard Schema 冒充:
10
+ * DSH 配置收集器的判定是
11
+ * `Reflect.get(value, Symbol.for('schemastery')) === true && typeof Reflect.get(value, 'type') === 'string'`
12
+ * (`packages/boot/app-boot/src/config-schema/native.ts`)。不满足会得到
13
+ * `entry.status='unsupported'` 与一条 `Config is not a native Schemastery schema` 的
14
+ * **error** 诊断、`x-cordis.complete: false`,`logs.*` 进不了 profile 的配置 schema 与配置编辑器。
15
+ * 运行时 cordis 只要求 Standard Schema(`vendor/cordis/src/fiber.ts` 的 `resolveConfig`
16
+ * 读 `Config['~standard'].validate`),所以能跑不等于能加载。
17
+ *
18
+ * 校验失败时 cordis 抛 `ValidationError`、**插件整体不挂载**:写错 `level` 或给出越界数值
19
+ * 都会让插件消失,这是刻意的(配置错误要响,而不是静默回退到别的级别)。
20
+ * 下面的 `parseLogsConfig` / `resolveLogsConfig` 只是给绕过 cordis 校验的调用方(测试、
21
+ * 直接调用 `apply`)准备的**内部兜底**:容错、夹紧数值,不是配置的真相来源,
22
+ * 也不参与 DSH 的 schema 收集。
23
+ */
24
+
25
+ import z from '@deepseek-ai/schemastery'
26
+ import { LOGS_CONFIG_LEVELS, type LogsConfigLevel } from './contract.js'
27
+
28
+ export interface LogsConfig {
29
+ /** `warn`(默认,error+info+warn)|`debug`(含 debug)。不提供 `info` 档,见 `./contract.ts`。 */
30
+ level: LogsConfigLevel
31
+ /** 单文件上限,超过即轮转。 */
32
+ maxBytes: number
33
+ /** 轮转文件保留份数。 */
34
+ keep: number
35
+ /** 导出时一并打包的额外来源:文件路径或目录(目录取最近的 `*.log`)。 */
36
+ sources: string[]
37
+ }
38
+
39
+ export const DEFAULT_LOGS_CONFIG: LogsConfig = {
40
+ level: 'warn',
41
+ maxBytes: 8 * 1024 * 1024,
42
+ keep: 3,
43
+ sources: [],
44
+ }
45
+
46
+ const MIN_MAX_BYTES = 64 * 1024
47
+ const MAX_MAX_BYTES = 1024 * 1024 * 1024
48
+ const MAX_KEEP = 20
49
+
50
+ /** 每次返回新对象,避免默认值里的 `sources` 数组被调用方改写。 */
51
+ function createDefaultLogsConfig(): LogsConfig {
52
+ return { ...DEFAULT_LOGS_CONFIG, sources: [] }
53
+ }
54
+
55
+ /**
56
+ * `logs` 这一片的原生 Schemastery schema(`src/index.ts` 的顶层 `Config` 组合它)。
57
+ *
58
+ * 两点不可省:
59
+ * 1. `.default()` 必须带默认值对象 —— 无参写法在 Schemastery 里不生效;
60
+ * 2. 无配置行时 cordis 传的是 `{}` 而不是 `undefined`,靠 `validate` 补齐全部默认值
61
+ * (`validate(undefined)` 与 `validate({})` 结果一致)。因此不要在 `apply` 的参数默认值上
62
+ * 兜底,见 `index.ts`。
63
+ */
64
+ export const logsConfigSchema = z.object({
65
+ level: z.union(LOGS_CONFIG_LEVELS).default(DEFAULT_LOGS_CONFIG.level),
66
+ maxBytes: z.number().min(MIN_MAX_BYTES).max(MAX_MAX_BYTES).default(DEFAULT_LOGS_CONFIG.maxBytes),
67
+ keep: z.number().min(0).max(MAX_KEEP).default(DEFAULT_LOGS_CONFIG.keep),
68
+ sources: z.array(z.string()).default([]),
69
+ }).default(createDefaultLogsConfig())
70
+
71
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
72
+ typeof value === 'object' && value !== null && !Array.isArray(value)
73
+
74
+ function clampNumber(value: unknown, fallback: number, min: number, max: number): number {
75
+ const parsed = typeof value === 'string' && value.trim() !== '' ? Number(value) : value
76
+ if (typeof parsed !== 'number' || !Number.isFinite(parsed)) return fallback
77
+ return Math.min(max, Math.max(min, Math.trunc(parsed)))
78
+ }
79
+
80
+ /**
81
+ * 内部兜底解析:非法组合不抛错,回落到默认值/夹紧;`level` 写错才报 issue
82
+ * (写错级别等于静默丢日志)。入参是整个插件配置,只取 `input.logs` 这一片;
83
+ * 只在绕过 cordis 校验时使用。
84
+ */
85
+ export function parseLogsConfig(input: unknown): { value: LogsConfig } | { issues: { message: string }[] } {
86
+ if (input === undefined || input === null || input === false) return { value: createDefaultLogsConfig() }
87
+ if (!isRecord(input)) return { issues: [{ message: '插件配置必须是对象' }] }
88
+ const raw = isRecord(input.logs) ? input.logs : {}
89
+ const level = raw.level ?? DEFAULT_LOGS_CONFIG.level
90
+ if (typeof level !== 'string' || !(LOGS_CONFIG_LEVELS as readonly string[]).includes(level)) {
91
+ return { issues: [{ message: `logs.level 必须是 ${LOGS_CONFIG_LEVELS.join(' / ')} 之一` }] }
92
+ }
93
+ const sources = Array.isArray(raw.sources)
94
+ ? raw.sources.filter((item): item is string => typeof item === 'string' && item.trim() !== '')
95
+ : []
96
+ return {
97
+ value: {
98
+ level: level as LogsConfigLevel,
99
+ maxBytes: clampNumber(raw.maxBytes, DEFAULT_LOGS_CONFIG.maxBytes, MIN_MAX_BYTES, MAX_MAX_BYTES),
100
+ keep: clampNumber(raw.keep, DEFAULT_LOGS_CONFIG.keep, 0, MAX_KEEP),
101
+ sources,
102
+ },
103
+ }
104
+ }
105
+
106
+ /** 取值:解析结果永远是完整配置,调用方不再做空值判断。 */
107
+ export function resolveLogsConfig(input: unknown): LogsConfig {
108
+ const parsed = parseLogsConfig(input)
109
+ if ('issues' in parsed) {
110
+ // 兜底路径不阻断挂载:回到默认级别,避免“插件起不来 = 一行日志都没有”。
111
+ // (真正生效的配置由原生 Schema 校验,写错会在这里之前就让插件不挂载。)
112
+ return createDefaultLogsConfig()
113
+ }
114
+ return parsed.value
115
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * `logs` 命名空间的共享契约:只有常量与类型,浏览器半边可以安全导入。
3
+ *
4
+ * 采集器(Host 常驻)与导出器(设置页)通过文件解耦;本文件是两者唯一的共同语言。
5
+ */
6
+
7
+ /**
8
+ * 卡片在 `ftshare.settings.card` 槽位里的 id 与 order。
9
+ *
10
+ * 归 feature 所有(与业务契约同一份来源):卡片组件与浏览器侧的守卫测试都读它,
11
+ * order 20 排在 API Key 卡(10)之后。
12
+ */
13
+ export const LOGS_CARD_ID = 'logs'
14
+ export const LOGS_CARD_ORDER = 20
15
+
16
+ /** 显式登记的 RPC 方法;未登记的方法一律 `not_found`。 */
17
+ export const LOGS_METHODS = ['stats', 'preview', 'export'] as const
18
+ export type LogsMethod = typeof LOGS_METHODS[number]
19
+
20
+ export type LogLevelName = 'error' | 'info' | 'warn' | 'debug'
21
+
22
+ /**
23
+ * 配置级别 → cordis `levels.default` 数值。
24
+ *
25
+ * cordis 的 `levels` 是**接收上限**而不是下限:`targetLevel < level` 才丢弃。
26
+ * 因此 `warn`(2) 收 error/info/warn,`debug`(3) 收全部,`info`(1) 会把 `warn` 丢掉。
27
+ */
28
+ export const LOG_LEVEL_THRESHOLD: Record<LogLevelName, number> = { error: 0, info: 1, warn: 2, debug: 3 }
29
+ export const LOG_LEVEL_NAMES = ['warn', 'info', 'debug', 'error'] as const
30
+
31
+ /**
32
+ * 可配置的级别(规格 §10)。
33
+ *
34
+ * 不提供 `info` 档:`levels` 是接收上限,`info`(1) 会把 `warn`(2) 一起丢掉,与规格 §6
35
+ * 「失败日志不低于 warn」冲突;`error`(0) 同样不成立(等于只剩 error)。允许值收敛为
36
+ * `warn | debug`,档位越界由 Schemastery 直接拒绝(插件不挂载),不做静默夹紧。
37
+ */
38
+ export const LOGS_CONFIG_LEVELS = ['warn', 'debug'] as const
39
+ export type LogsConfigLevel = typeof LOGS_CONFIG_LEVELS[number]
40
+
41
+ /** 落盘位置:`$DSH_HOME/logs/ft/`。 */
42
+ export const LOGS_DIR_SEGMENTS = ['logs', 'ft'] as const
43
+ export const LOGS_CURRENT_FILE = 'current.log'
44
+ export const LOGS_ROTATED_PREFIX = 'ft-'
45
+ export const LOGS_FILE_SUFFIX = '.log'
46
+
47
+ export const LOGS_LINE_MAX_BYTES = 8 * 1024
48
+ export const LOGS_TRUNCATED_MARK = '...[已截断]'
49
+ export const LOGS_REPEAT_FIELD = '重复次数'
50
+ export const LOGS_THROTTLE_WINDOW_MS = 10_000
51
+ export const LOGS_MAX_PENDING_KEYS = 2_000
52
+ export const LOGS_MAX_FIELD_LENGTH = 2_000
53
+ export const LOGS_MAX_FIELD_DEPTH = 3
54
+
55
+ export const LOGS_DEFAULT_PREVIEW_LINES = 80
56
+ export const LOGS_MAX_PREVIEW_LINES = 200
57
+ export const LOGS_PREVIEW_MAX_BYTES = 256 * 1024
58
+
59
+ /** 导出体积上限(规格 §9):单来源 2 MiB、合计 8 MiB、目录最多 20 个 `*.log`。 */
60
+ export const LOGS_SOURCE_MAX_BYTES = 2 * 1024 * 1024
61
+ export const LOGS_EXPORT_MAX_BYTES = 8 * 1024 * 1024
62
+ export const LOGS_MAX_SOURCE_FILES = 20
63
+
64
+ export const LOGS_EXPORT_FILE_PREFIX = 'ft-runtime-'
65
+ export const LOGS_SECRET_TTL_MS = 5_000
66
+
67
+ /**
68
+ * 诊断包路由(规格 §9 / §12 决策 3):`/api` 载体上的第二条精确路由,归 `logs` feature 所有。
69
+ *
70
+ * 卡片用 `<a download>` 直接指向它:浏览器同源 GET 自带凭据,不需要 fetch/Blob,
71
+ * `/api` 前缀的鉴权由载体负责,本 feature 不重复鉴权、也不读任何请求参数。
72
+ * 它随 owner fiber 释放(`ctx.effect` + `register` 返回的 disposer)。
73
+ */
74
+ export const LOGS_ARCHIVE_ROUTE = '/api/ftshare-settings/logs.zip' as const
75
+ /** 归档内固定条目名(`manifest.json` 永远排第一)。 */
76
+ export const LOGS_ARCHIVE_MANIFEST = 'manifest.json'
77
+ export const LOGS_ARCHIVE_LOG = 'ft-runtime.log'
78
+ /** 额外来源原文的目录前缀:`sources/<n>-<basename>`。 */
79
+ export const LOGS_ARCHIVE_SOURCE_DIR = 'sources/'
80
+
81
+ /** 导出 header 里的来源段落分隔标记。 */
82
+ export const LOGS_SECTION_RULE = '====='
83
+
84
+ /** 一条来源(文件或目录)在 Host 侧的存在性快照。 */
85
+ export interface LogsSourceState {
86
+ readonly path: string
87
+ readonly kind: 'file' | 'directory' | 'missing'
88
+ readonly bytes?: number
89
+ readonly files?: number
90
+ }
91
+
92
+ /** 导出物 header 里每个来源的统计。 */
93
+ export interface LogsSourceSummary {
94
+ readonly label: string
95
+ readonly path: string
96
+ readonly lines: number
97
+ readonly bytes: number
98
+ readonly firstAt?: string
99
+ readonly lastAt?: string
100
+ readonly truncated: boolean
101
+ readonly missing?: boolean
102
+ }
103
+
104
+ /** 采集器运行时统计;时间一律是北京时间 ISO 字符串。 */
105
+ export interface LogsStats {
106
+ readonly file: string
107
+ readonly fileBytes: number
108
+ readonly level: LogLevelName
109
+ readonly maxBytes: number
110
+ readonly keep: number
111
+ readonly records: number
112
+ readonly dropped: number
113
+ readonly rotations: number
114
+ readonly writeFailures: number
115
+ readonly firstAt?: string
116
+ readonly lastAt?: string
117
+ readonly byName: Readonly<Record<string, number>>
118
+ readonly sources: readonly LogsSourceState[]
119
+ }
120
+
121
+ export interface LogsPreview {
122
+ readonly lines: readonly string[]
123
+ readonly bytes: number
124
+ readonly truncated: boolean
125
+ readonly fileBytes: number
126
+ }
127
+
128
+ export interface LogsExport {
129
+ readonly fileName: string
130
+ readonly text: string
131
+ readonly bytes: number
132
+ readonly truncated: boolean
133
+ readonly sources: readonly LogsSourceSummary[]
134
+ }
135
+
136
+ /** 浏览器可用的只读日志接口。 */
137
+ export interface LogsPublicApi {
138
+ stats(): Promise<LogsStats>
139
+ preview(lines?: number): Promise<LogsPreview>
140
+ /** 先刷盘,再把主日志与配置的 sources 拼成单文件文本。 */
141
+ export(): Promise<LogsExport>
142
+ }
143
+
144
+ /** Host 服务:比浏览器多出本地路径与刷盘/释放句柄的能力。 */
145
+ export interface LogsSettings extends LogsPublicApi {
146
+ readonly filePath: string
147
+ flush(): Promise<void>
148
+ dispose(): Promise<void>
149
+ }