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