dsh-manage-session 0.3.3 → 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.
- package/CHANGELOG.ja.md +242 -188
- package/CHANGELOG.ko.md +242 -189
- package/CHANGELOG.md +341 -261
- package/INSTALL.ja.md +179 -164
- package/INSTALL.ko.md +179 -164
- package/INSTALL.md +179 -164
- package/INSTALL.zh.md +179 -164
- package/LICENSE +21 -21
- package/README.de.md +120 -0
- package/README.en.md +12 -0
- package/README.es.md +120 -0
- package/README.fr.md +120 -0
- package/README.it.md +120 -0
- package/README.ja.md +12 -0
- package/README.ko.md +12 -0
- package/README.md +14 -0
- package/README.ru.md +120 -0
- package/cordis.patch.yml +11 -11
- package/dsh.plugin.json +22 -22
- package/lib/client.js +623 -35
- package/lib/index.d.ts +123 -8
- package/lib/index.js +1147 -815
- package/package.json +125 -122
- package/src/client/card.tsx +9 -12
- package/src/client/health-panel.tsx +51 -0
- package/src/client/history-panel.tsx +319 -319
- package/src/client/host-api.ts +14 -1
- package/src/client/index.ts +150 -30
- package/src/client/locales.ts +491 -1
- package/src/config.ts +33 -33
- package/src/host/health/attribution.ts +118 -118
- package/src/host/health/gates.ts +4 -1
- package/src/host/health/lossless.ts +128 -128
- package/src/host/health/source-kind.ts +350 -0
- package/src/host/history/archive-source.ts +202 -202
- package/src/host/history/archive.ts +194 -191
- package/src/host/history/purge.ts +370 -323
- package/src/index.ts +56 -48
|
@@ -1,118 +1,118 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 归属:把投影 key 指回产出它的插件包。
|
|
3
|
-
*
|
|
4
|
-
* 做法是静态扫描 profile 的 `node_modules`:`<pkg>/lib/index.js` 里
|
|
5
|
-
* `sessionProjections.register({ key: 'xxx' })`(或其定义工厂)会命中
|
|
6
|
-
* `key:\s*['"](\w+)['"]` 正则;据此建立 `投影 key → 包名` 索引。
|
|
7
|
-
* `@deepseek-ai/*` 视为内置(core);扫不到时如实返回 `unknown`,禁止臆测。
|
|
8
|
-
*/
|
|
9
|
-
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
10
|
-
import { homedir } from 'node:os'
|
|
11
|
-
import { join } from 'node:path'
|
|
12
|
-
import type { GateAttribution } from './gates.ts'
|
|
13
|
-
|
|
14
|
-
/** 一条归属记录。 */
|
|
15
|
-
export interface ProjectionOwner {
|
|
16
|
-
key: string
|
|
17
|
-
/** 包名;内置包以 `@deepseek-ai/` 前缀判定。 */
|
|
18
|
-
package: string
|
|
19
|
-
/** 'core' 表示内置包,'plugin' 表示第三方插件。 */
|
|
20
|
-
kind: 'core' | 'plugin'
|
|
21
|
-
/** 命中文件(相对 node_modules)。 */
|
|
22
|
-
entry: string
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** 默认扫描根:profile 的 node_modules。 */
|
|
26
|
-
export function defaultProfileNodeModules(dshHome?: string): string {
|
|
27
|
-
return join(dshHome ?? join(homedir(), '.dsh'), 'profiles', 'web', 'node_modules')
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** 收集候选包目录(含 @scope/pkg 一层)。 */
|
|
31
|
-
function candidatePackages(root: string): { name: string; dir: string }[] {
|
|
32
|
-
const out: { name: string; dir: string }[] = []
|
|
33
|
-
if (!existsSync(root)) return out
|
|
34
|
-
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
35
|
-
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
|
|
36
|
-
if (entry.name.startsWith('.')) continue
|
|
37
|
-
const dir = join(root, entry.name)
|
|
38
|
-
if (entry.name.startsWith('@')) {
|
|
39
|
-
let scoped: string[] = []
|
|
40
|
-
try {
|
|
41
|
-
scoped = readdirSync(dir)
|
|
42
|
-
} catch { continue }
|
|
43
|
-
for (const child of scoped) {
|
|
44
|
-
out.push({ name: `${entry.name}/${child}`, dir: join(dir, child) })
|
|
45
|
-
}
|
|
46
|
-
continue
|
|
47
|
-
}
|
|
48
|
-
out.push({ name: entry.name, dir })
|
|
49
|
-
}
|
|
50
|
-
return out
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** 单个入口文件的最大扫描字节数(超大 bundle 直接跳过,避免无谓内存与耗时)。 */
|
|
54
|
-
const MAX_SCAN_BYTES = 12 << 20
|
|
55
|
-
|
|
56
|
-
/** 扫描若干候选入口文件里出现的投影 key。 */
|
|
57
|
-
function keysInFile(file: string): string[] {
|
|
58
|
-
if (!existsSync(file)) return []
|
|
59
|
-
let text: string
|
|
60
|
-
try {
|
|
61
|
-
if (statSync(file).size > MAX_SCAN_BYTES) return []
|
|
62
|
-
text = readFileSync(file, 'utf8')
|
|
63
|
-
} catch {
|
|
64
|
-
return []
|
|
65
|
-
}
|
|
66
|
-
const keys = new Set<string>()
|
|
67
|
-
const pattern = /key:\s*['"]([A-Za-z_$][\w$]*)['"]/g
|
|
68
|
-
let match: RegExpExecArray | null
|
|
69
|
-
while ((match = pattern.exec(text)) !== null) {
|
|
70
|
-
const key = match[1]
|
|
71
|
-
if (key !== undefined && key !== '') keys.add(key)
|
|
72
|
-
}
|
|
73
|
-
return [...keys]
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* 建立投影 key → 包 的索引。
|
|
78
|
-
* @param nodeModulesRoot - profile 的 node_modules 根。
|
|
79
|
-
* @returns 归属记录(同一 key 多包命中时保留首个并排序稳定)。
|
|
80
|
-
*/
|
|
81
|
-
export function buildProjectionOwnerIndex(nodeModulesRoot: string): ProjectionOwner[] {
|
|
82
|
-
const records: ProjectionOwner[] = []
|
|
83
|
-
const seen = new Set<string>()
|
|
84
|
-
for (const pack of candidatePackages(nodeModulesRoot)) {
|
|
85
|
-
const lib = join(pack.dir, 'lib')
|
|
86
|
-
let entries: string[] = []
|
|
87
|
-
try {
|
|
88
|
-
if (!statSync(lib).isDirectory()) continue
|
|
89
|
-
entries = readdirSync(lib).filter(name => name.endsWith('.js'))
|
|
90
|
-
} catch {
|
|
91
|
-
continue
|
|
92
|
-
}
|
|
93
|
-
for (const name of entries) {
|
|
94
|
-
const file = join(lib, name)
|
|
95
|
-
for (const key of keysInFile(file)) {
|
|
96
|
-
if (seen.has(key)) continue
|
|
97
|
-
seen.add(key)
|
|
98
|
-
records.push({
|
|
99
|
-
key,
|
|
100
|
-
package: pack.name,
|
|
101
|
-
kind: pack.name.startsWith('@deepseek-ai/') ? 'core' : 'plugin',
|
|
102
|
-
entry: `${pack.name}/lib/${name}`,
|
|
103
|
-
})
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
return records.sort((left, right) => left.key.localeCompare(right.key))
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** 索引查询器。 */
|
|
111
|
-
export function createAttributor(index: readonly ProjectionOwner[]): (projection: string) => GateAttribution | undefined {
|
|
112
|
-
const byKey = new Map(index.map(record => [record.key, record]))
|
|
113
|
-
return (projection: string) => {
|
|
114
|
-
const owner = byKey.get(projection)
|
|
115
|
-
if (owner === undefined) return { projection, package: 'unknown' }
|
|
116
|
-
return { projection, package: owner.package }
|
|
117
|
-
}
|
|
118
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* 归属:把投影 key 指回产出它的插件包。
|
|
3
|
+
*
|
|
4
|
+
* 做法是静态扫描 profile 的 `node_modules`:`<pkg>/lib/index.js` 里
|
|
5
|
+
* `sessionProjections.register({ key: 'xxx' })`(或其定义工厂)会命中
|
|
6
|
+
* `key:\s*['"](\w+)['"]` 正则;据此建立 `投影 key → 包名` 索引。
|
|
7
|
+
* `@deepseek-ai/*` 视为内置(core);扫不到时如实返回 `unknown`,禁止臆测。
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
10
|
+
import { homedir } from 'node:os'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import type { GateAttribution } from './gates.ts'
|
|
13
|
+
|
|
14
|
+
/** 一条归属记录。 */
|
|
15
|
+
export interface ProjectionOwner {
|
|
16
|
+
key: string
|
|
17
|
+
/** 包名;内置包以 `@deepseek-ai/` 前缀判定。 */
|
|
18
|
+
package: string
|
|
19
|
+
/** 'core' 表示内置包,'plugin' 表示第三方插件。 */
|
|
20
|
+
kind: 'core' | 'plugin'
|
|
21
|
+
/** 命中文件(相对 node_modules)。 */
|
|
22
|
+
entry: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 默认扫描根:profile 的 node_modules。 */
|
|
26
|
+
export function defaultProfileNodeModules(dshHome?: string): string {
|
|
27
|
+
return join(dshHome ?? join(homedir(), '.dsh'), 'profiles', 'web', 'node_modules')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 收集候选包目录(含 @scope/pkg 一层)。 */
|
|
31
|
+
function candidatePackages(root: string): { name: string; dir: string }[] {
|
|
32
|
+
const out: { name: string; dir: string }[] = []
|
|
33
|
+
if (!existsSync(root)) return out
|
|
34
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
35
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
|
|
36
|
+
if (entry.name.startsWith('.')) continue
|
|
37
|
+
const dir = join(root, entry.name)
|
|
38
|
+
if (entry.name.startsWith('@')) {
|
|
39
|
+
let scoped: string[] = []
|
|
40
|
+
try {
|
|
41
|
+
scoped = readdirSync(dir)
|
|
42
|
+
} catch { continue }
|
|
43
|
+
for (const child of scoped) {
|
|
44
|
+
out.push({ name: `${entry.name}/${child}`, dir: join(dir, child) })
|
|
45
|
+
}
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
out.push({ name: entry.name, dir })
|
|
49
|
+
}
|
|
50
|
+
return out
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 单个入口文件的最大扫描字节数(超大 bundle 直接跳过,避免无谓内存与耗时)。 */
|
|
54
|
+
const MAX_SCAN_BYTES = 12 << 20
|
|
55
|
+
|
|
56
|
+
/** 扫描若干候选入口文件里出现的投影 key。 */
|
|
57
|
+
function keysInFile(file: string): string[] {
|
|
58
|
+
if (!existsSync(file)) return []
|
|
59
|
+
let text: string
|
|
60
|
+
try {
|
|
61
|
+
if (statSync(file).size > MAX_SCAN_BYTES) return []
|
|
62
|
+
text = readFileSync(file, 'utf8')
|
|
63
|
+
} catch {
|
|
64
|
+
return []
|
|
65
|
+
}
|
|
66
|
+
const keys = new Set<string>()
|
|
67
|
+
const pattern = /key:\s*['"]([A-Za-z_$][\w$]*)['"]/g
|
|
68
|
+
let match: RegExpExecArray | null
|
|
69
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
70
|
+
const key = match[1]
|
|
71
|
+
if (key !== undefined && key !== '') keys.add(key)
|
|
72
|
+
}
|
|
73
|
+
return [...keys]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 建立投影 key → 包 的索引。
|
|
78
|
+
* @param nodeModulesRoot - profile 的 node_modules 根。
|
|
79
|
+
* @returns 归属记录(同一 key 多包命中时保留首个并排序稳定)。
|
|
80
|
+
*/
|
|
81
|
+
export function buildProjectionOwnerIndex(nodeModulesRoot: string): ProjectionOwner[] {
|
|
82
|
+
const records: ProjectionOwner[] = []
|
|
83
|
+
const seen = new Set<string>()
|
|
84
|
+
for (const pack of candidatePackages(nodeModulesRoot)) {
|
|
85
|
+
const lib = join(pack.dir, 'lib')
|
|
86
|
+
let entries: string[] = []
|
|
87
|
+
try {
|
|
88
|
+
if (!statSync(lib).isDirectory()) continue
|
|
89
|
+
entries = readdirSync(lib).filter(name => name.endsWith('.js'))
|
|
90
|
+
} catch {
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
93
|
+
for (const name of entries) {
|
|
94
|
+
const file = join(lib, name)
|
|
95
|
+
for (const key of keysInFile(file)) {
|
|
96
|
+
if (seen.has(key)) continue
|
|
97
|
+
seen.add(key)
|
|
98
|
+
records.push({
|
|
99
|
+
key,
|
|
100
|
+
package: pack.name,
|
|
101
|
+
kind: pack.name.startsWith('@deepseek-ai/') ? 'core' : 'plugin',
|
|
102
|
+
entry: `${pack.name}/lib/${name}`,
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return records.sort((left, right) => left.key.localeCompare(right.key))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 索引查询器。 */
|
|
111
|
+
export function createAttributor(index: readonly ProjectionOwner[]): (projection: string) => GateAttribution | undefined {
|
|
112
|
+
const byKey = new Map(index.map(record => [record.key, record]))
|
|
113
|
+
return (projection: string) => {
|
|
114
|
+
const owner = byKey.get(projection)
|
|
115
|
+
if (owner === undefined) return { projection, package: 'unknown' }
|
|
116
|
+
return { projection, package: owner.package }
|
|
117
|
+
}
|
|
118
|
+
}
|
package/src/host/health/gates.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
type SessionPriority,
|
|
17
17
|
} from './generation.ts'
|
|
18
18
|
import { firstLosslessViolation, type LosslessViolation } from './lossless.ts'
|
|
19
|
+
import { gateSourceKind, readSourceKindFacts } from './source-kind.ts'
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* gate 严重级。
|
|
@@ -45,7 +46,7 @@ export interface GateAttribution {
|
|
|
45
46
|
|
|
46
47
|
/** 一个 gate 的结果。 */
|
|
47
48
|
export interface GateResult {
|
|
48
|
-
id: 'generation' | 'log-integrity' | 'projection-cache' | 'lossless-json' | 'cold-read'
|
|
49
|
+
id: 'generation' | 'log-integrity' | 'projection-cache' | 'lossless-json' | 'cold-read' | 'source-kind'
|
|
49
50
|
level: GateLevel
|
|
50
51
|
evidence: string
|
|
51
52
|
attribution?: GateAttribution
|
|
@@ -402,6 +403,8 @@ export function buildSessionReport(context: GateContext): SessionHealthReport {
|
|
|
402
403
|
gates.push(gateProjectionCache(readProjectionCache(context.sessionId, tail.lastSeq, context.dshHome)))
|
|
403
404
|
gates.push(gateLosslessJson(context.projectionState, context.attribute))
|
|
404
405
|
gates.push(gateColdRead(tail))
|
|
406
|
+
// 署名格式门:v4 线要求 producer-owned source kind(详见 source-kind.ts 的版本依赖表)。
|
|
407
|
+
gates.push(gateSourceKind(readSourceKindFacts(log)))
|
|
405
408
|
}
|
|
406
409
|
// 总判聚合:fail > warn > ok。`skipped` 有意不参与——它是「本门无从判定」的
|
|
407
410
|
// 中性档,既非通过也非异常,抬升总判会把冷态会话全变成「注意」(实测噪声源)。
|
|
@@ -1,128 +1,128 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 无损 JSON 判定 —— 本次事故的核心 gate 判据。
|
|
3
|
-
*
|
|
4
|
-
* 判定口径与宿主 `@deepseek-ai/dsh-util-values` 的 `isJsonValue` 一致:
|
|
5
|
-
* 普通(或 null)原型的对象、可枚举的字符串键、有限数(含拒绝 -0)、非稀疏数组、
|
|
6
|
-
* 无环;undefined / NaN / ±Infinity / 函数 / Symbol / BigInt 一律视为不可无损序列化。
|
|
7
|
-
*
|
|
8
|
-
* 通过宿主 `assertJsonArgs` / 投影缓存写入的数据都会先过这道口径,所以投影**状态**
|
|
9
|
-
* 一旦含上述值,`api-session/added` 推送与检查点写入都会失败(症状错位的根因)。
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
/** 首个违规点位。 */
|
|
13
|
-
export interface LosslessViolation {
|
|
14
|
-
/** 到违规值的属性路径,例如 `.activeStep.lastSettled.actualTokens`。 */
|
|
15
|
-
path: string
|
|
16
|
-
/** 违规原因的可读描述。 */
|
|
17
|
-
reason:
|
|
18
|
-
| 'undefined'
|
|
19
|
-
| 'NaN'
|
|
20
|
-
| 'Infinity'
|
|
21
|
-
| '-Infinity'
|
|
22
|
-
| '-0'
|
|
23
|
-
| 'function'
|
|
24
|
-
| 'symbol'
|
|
25
|
-
| 'bigint'
|
|
26
|
-
| 'sparse-array-hole'
|
|
27
|
-
| 'non-plain-prototype'
|
|
28
|
-
| 'non-enumerable-or-symbol-key'
|
|
29
|
-
| 'cycle'
|
|
30
|
-
/** 违规处的实际类型(便于人工核对)。 */
|
|
31
|
-
actual: string
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** 单个值是否可无损 JSON 序列化。 */
|
|
35
|
-
export function isLossless(value: unknown): boolean {
|
|
36
|
-
return firstLosslessViolation(value) === undefined
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** 判定一个普通对象是否「普通原型」(Object.prototype 或 null)。 */
|
|
40
|
-
function isPlainObjectPrototype(value: object): boolean {
|
|
41
|
-
const prototype = Object.getPrototypeOf(value) as object | null
|
|
42
|
-
return prototype === null || prototype === Object.prototype
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* 找出第一个破坏无损 JSON 的值;全部合规时返回 undefined。
|
|
47
|
-
* @param value - 待判定的值(通常是投影状态)。
|
|
48
|
-
* @param limit - 最多访问的节点数(防御超大对象)。
|
|
49
|
-
* @returns 首个违规点位或 undefined。
|
|
50
|
-
*/
|
|
51
|
-
export function firstLosslessViolation(value: unknown, limit = 200_000): LosslessViolation | undefined {
|
|
52
|
-
const ancestors = new Set<object>()
|
|
53
|
-
const stack: { node: unknown; path: string; leave?: boolean }[] = [{ node: value, path: '' }]
|
|
54
|
-
let visited = 0
|
|
55
|
-
while (stack.length > 0) {
|
|
56
|
-
const task = stack.pop()
|
|
57
|
-
if (task === undefined) break
|
|
58
|
-
if (task.leave === true) {
|
|
59
|
-
ancestors.delete(task.node as object)
|
|
60
|
-
continue
|
|
61
|
-
}
|
|
62
|
-
if (visited >= limit) return { path: task.path, reason: 'cycle', actual: `node-limit(${limit})` }
|
|
63
|
-
visited += 1
|
|
64
|
-
const node = task.node
|
|
65
|
-
if (node === undefined) return { path: task.path, reason: 'undefined', actual: 'undefined' }
|
|
66
|
-
if (node === null) continue
|
|
67
|
-
switch (typeof node) {
|
|
68
|
-
case 'boolean':
|
|
69
|
-
case 'string':
|
|
70
|
-
continue
|
|
71
|
-
case 'number':
|
|
72
|
-
if (Number.isNaN(node)) return { path: task.path, reason: 'NaN', actual: 'NaN' }
|
|
73
|
-
if (node === Infinity) return { path: task.path, reason: 'Infinity', actual: 'Infinity' }
|
|
74
|
-
if (node === -Infinity) return { path: task.path, reason: '-Infinity', actual: '-Infinity' }
|
|
75
|
-
if (Object.is(node, -0)) return { path: task.path, reason: '-0', actual: '-0' }
|
|
76
|
-
continue
|
|
77
|
-
case 'function':
|
|
78
|
-
return { path: task.path, reason: 'function', actual: 'function' }
|
|
79
|
-
case 'symbol':
|
|
80
|
-
return { path: task.path, reason: 'symbol', actual: 'symbol' }
|
|
81
|
-
case 'bigint':
|
|
82
|
-
return { path: task.path, reason: 'bigint', actual: 'bigint' }
|
|
83
|
-
default:
|
|
84
|
-
break
|
|
85
|
-
}
|
|
86
|
-
const object = node as object
|
|
87
|
-
if (ancestors.has(object)) return { path: task.path, reason: 'cycle', actual: 'object-cycle' }
|
|
88
|
-
if (Array.isArray(object)) {
|
|
89
|
-
if (!isPlainArray(object)) return { path: task.path, reason: 'non-plain-prototype', actual: 'array-subclass' }
|
|
90
|
-
// 先按升序找出最低位的洞(报告口径确定),再倒序入栈以获得左→右的访问顺序。
|
|
91
|
-
for (let index = 0; index < object.length; index += 1) {
|
|
92
|
-
if (!Object.prototype.hasOwnProperty.call(object, index)) {
|
|
93
|
-
return { path: `${task.path}[${index}]`, reason: 'sparse-array-hole', actual: 'hole' }
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
ancestors.add(object)
|
|
97
|
-
stack.push({ node: object, path: task.path, leave: true })
|
|
98
|
-
for (let index = object.length - 1; index >= 0; index -= 1) {
|
|
99
|
-
stack.push({ node: object[index], path: `${task.path}[${index}]` })
|
|
100
|
-
}
|
|
101
|
-
continue
|
|
102
|
-
}
|
|
103
|
-
if (!isPlainObjectPrototype(object)) {
|
|
104
|
-
const name = (object as { constructor?: { name?: string } }).constructor?.name ?? 'unknown'
|
|
105
|
-
return { path: task.path, reason: 'non-plain-prototype', actual: name }
|
|
106
|
-
}
|
|
107
|
-
const keys = Reflect.ownKeys(object)
|
|
108
|
-
for (const key of keys) {
|
|
109
|
-
if (typeof key !== 'string' || !Object.prototype.propertyIsEnumerable.call(object, key)) {
|
|
110
|
-
return { path: `${task.path}.${String(key)}`, reason: 'non-enumerable-or-symbol-key', actual: typeof key }
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
ancestors.add(object)
|
|
114
|
-
stack.push({ node: object, path: task.path, leave: true })
|
|
115
|
-
for (let index = keys.length - 1; index >= 0; index -= 1) {
|
|
116
|
-
const key = keys[index]
|
|
117
|
-
if (key === undefined || typeof key !== 'string') continue
|
|
118
|
-
stack.push({ node: (object as Record<string, unknown>)[key], path: `${task.path}.${key}` })
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
return undefined
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** 数组是否为普通数组(排除子类)。 */
|
|
125
|
-
function isPlainArray(value: readonly unknown[]): boolean {
|
|
126
|
-
const prototype = Object.getPrototypeOf(value) as object | null
|
|
127
|
-
return prototype === null || prototype === Array.prototype
|
|
128
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* 无损 JSON 判定 —— 本次事故的核心 gate 判据。
|
|
3
|
+
*
|
|
4
|
+
* 判定口径与宿主 `@deepseek-ai/dsh-util-values` 的 `isJsonValue` 一致:
|
|
5
|
+
* 普通(或 null)原型的对象、可枚举的字符串键、有限数(含拒绝 -0)、非稀疏数组、
|
|
6
|
+
* 无环;undefined / NaN / ±Infinity / 函数 / Symbol / BigInt 一律视为不可无损序列化。
|
|
7
|
+
*
|
|
8
|
+
* 通过宿主 `assertJsonArgs` / 投影缓存写入的数据都会先过这道口径,所以投影**状态**
|
|
9
|
+
* 一旦含上述值,`api-session/added` 推送与检查点写入都会失败(症状错位的根因)。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** 首个违规点位。 */
|
|
13
|
+
export interface LosslessViolation {
|
|
14
|
+
/** 到违规值的属性路径,例如 `.activeStep.lastSettled.actualTokens`。 */
|
|
15
|
+
path: string
|
|
16
|
+
/** 违规原因的可读描述。 */
|
|
17
|
+
reason:
|
|
18
|
+
| 'undefined'
|
|
19
|
+
| 'NaN'
|
|
20
|
+
| 'Infinity'
|
|
21
|
+
| '-Infinity'
|
|
22
|
+
| '-0'
|
|
23
|
+
| 'function'
|
|
24
|
+
| 'symbol'
|
|
25
|
+
| 'bigint'
|
|
26
|
+
| 'sparse-array-hole'
|
|
27
|
+
| 'non-plain-prototype'
|
|
28
|
+
| 'non-enumerable-or-symbol-key'
|
|
29
|
+
| 'cycle'
|
|
30
|
+
/** 违规处的实际类型(便于人工核对)。 */
|
|
31
|
+
actual: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 单个值是否可无损 JSON 序列化。 */
|
|
35
|
+
export function isLossless(value: unknown): boolean {
|
|
36
|
+
return firstLosslessViolation(value) === undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 判定一个普通对象是否「普通原型」(Object.prototype 或 null)。 */
|
|
40
|
+
function isPlainObjectPrototype(value: object): boolean {
|
|
41
|
+
const prototype = Object.getPrototypeOf(value) as object | null
|
|
42
|
+
return prototype === null || prototype === Object.prototype
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 找出第一个破坏无损 JSON 的值;全部合规时返回 undefined。
|
|
47
|
+
* @param value - 待判定的值(通常是投影状态)。
|
|
48
|
+
* @param limit - 最多访问的节点数(防御超大对象)。
|
|
49
|
+
* @returns 首个违规点位或 undefined。
|
|
50
|
+
*/
|
|
51
|
+
export function firstLosslessViolation(value: unknown, limit = 200_000): LosslessViolation | undefined {
|
|
52
|
+
const ancestors = new Set<object>()
|
|
53
|
+
const stack: { node: unknown; path: string; leave?: boolean }[] = [{ node: value, path: '' }]
|
|
54
|
+
let visited = 0
|
|
55
|
+
while (stack.length > 0) {
|
|
56
|
+
const task = stack.pop()
|
|
57
|
+
if (task === undefined) break
|
|
58
|
+
if (task.leave === true) {
|
|
59
|
+
ancestors.delete(task.node as object)
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
if (visited >= limit) return { path: task.path, reason: 'cycle', actual: `node-limit(${limit})` }
|
|
63
|
+
visited += 1
|
|
64
|
+
const node = task.node
|
|
65
|
+
if (node === undefined) return { path: task.path, reason: 'undefined', actual: 'undefined' }
|
|
66
|
+
if (node === null) continue
|
|
67
|
+
switch (typeof node) {
|
|
68
|
+
case 'boolean':
|
|
69
|
+
case 'string':
|
|
70
|
+
continue
|
|
71
|
+
case 'number':
|
|
72
|
+
if (Number.isNaN(node)) return { path: task.path, reason: 'NaN', actual: 'NaN' }
|
|
73
|
+
if (node === Infinity) return { path: task.path, reason: 'Infinity', actual: 'Infinity' }
|
|
74
|
+
if (node === -Infinity) return { path: task.path, reason: '-Infinity', actual: '-Infinity' }
|
|
75
|
+
if (Object.is(node, -0)) return { path: task.path, reason: '-0', actual: '-0' }
|
|
76
|
+
continue
|
|
77
|
+
case 'function':
|
|
78
|
+
return { path: task.path, reason: 'function', actual: 'function' }
|
|
79
|
+
case 'symbol':
|
|
80
|
+
return { path: task.path, reason: 'symbol', actual: 'symbol' }
|
|
81
|
+
case 'bigint':
|
|
82
|
+
return { path: task.path, reason: 'bigint', actual: 'bigint' }
|
|
83
|
+
default:
|
|
84
|
+
break
|
|
85
|
+
}
|
|
86
|
+
const object = node as object
|
|
87
|
+
if (ancestors.has(object)) return { path: task.path, reason: 'cycle', actual: 'object-cycle' }
|
|
88
|
+
if (Array.isArray(object)) {
|
|
89
|
+
if (!isPlainArray(object)) return { path: task.path, reason: 'non-plain-prototype', actual: 'array-subclass' }
|
|
90
|
+
// 先按升序找出最低位的洞(报告口径确定),再倒序入栈以获得左→右的访问顺序。
|
|
91
|
+
for (let index = 0; index < object.length; index += 1) {
|
|
92
|
+
if (!Object.prototype.hasOwnProperty.call(object, index)) {
|
|
93
|
+
return { path: `${task.path}[${index}]`, reason: 'sparse-array-hole', actual: 'hole' }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
ancestors.add(object)
|
|
97
|
+
stack.push({ node: object, path: task.path, leave: true })
|
|
98
|
+
for (let index = object.length - 1; index >= 0; index -= 1) {
|
|
99
|
+
stack.push({ node: object[index], path: `${task.path}[${index}]` })
|
|
100
|
+
}
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
if (!isPlainObjectPrototype(object)) {
|
|
104
|
+
const name = (object as { constructor?: { name?: string } }).constructor?.name ?? 'unknown'
|
|
105
|
+
return { path: task.path, reason: 'non-plain-prototype', actual: name }
|
|
106
|
+
}
|
|
107
|
+
const keys = Reflect.ownKeys(object)
|
|
108
|
+
for (const key of keys) {
|
|
109
|
+
if (typeof key !== 'string' || !Object.prototype.propertyIsEnumerable.call(object, key)) {
|
|
110
|
+
return { path: `${task.path}.${String(key)}`, reason: 'non-enumerable-or-symbol-key', actual: typeof key }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
ancestors.add(object)
|
|
114
|
+
stack.push({ node: object, path: task.path, leave: true })
|
|
115
|
+
for (let index = keys.length - 1; index >= 0; index -= 1) {
|
|
116
|
+
const key = keys[index]
|
|
117
|
+
if (key === undefined || typeof key !== 'string') continue
|
|
118
|
+
stack.push({ node: (object as Record<string, unknown>)[key], path: `${task.path}.${key}` })
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return undefined
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 数组是否为普通数组(排除子类)。 */
|
|
125
|
+
function isPlainArray(value: readonly unknown[]): boolean {
|
|
126
|
+
const prototype = Object.getPrototypeOf(value) as object | null
|
|
127
|
+
return prototype === null || prototype === Array.prototype
|
|
128
|
+
}
|