dsh-manage-session 2.0.3 → 2.0.4
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 +33 -33
- package/CHANGELOG.ko.md +33 -33
- package/CHANGELOG.md +33 -33
- package/INSTALL.ja.md +164 -164
- package/INSTALL.ko.md +164 -164
- package/INSTALL.md +164 -164
- package/INSTALL.zh.md +164 -164
- package/LICENSE +21 -21
- package/README.en.md +76 -62
- package/README.ja.md +53 -39
- package/README.ko.md +52 -38
- package/README.md +18 -4
- package/cordis.patch.yml +19 -19
- package/dsh.plugin.json +2 -2
- package/locale/de.json +6 -6
- package/locale/en.json +6 -6
- package/locale/es.json +6 -6
- package/locale/fr.json +6 -6
- package/locale/it.json +6 -6
- package/locale/ja.json +6 -6
- package/locale/ko.json +6 -6
- package/locale/ru.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +9 -9
- package/screenshots.json +3 -3
- package/src/client/bridge-scope.ts +202 -202
- package/src/client/card.tsx +154 -154
- package/src/client/host-api.ts +61 -61
- package/src/client/index.ts +594 -594
- package/src/client/locales.ts +893 -893
- package/src/client/manage-locales.ts +178 -178
- package/src/client/manage.tsx +430 -430
- package/src/client/panel.tsx +131 -131
- package/src/config.ts +43 -43
- package/src/host/health/attribution.ts +118 -118
- package/src/host/health/lossless.ts +128 -128
- package/src/host/health/source-kind.ts +350 -350
- package/src/host/history/archive-source.ts +250 -250
- package/src/host/history/archive.ts +249 -249
- package/src/host/history/favorites.ts +71 -71
- package/src/host/history/purge.ts +370 -370
- package/src/host/registry/archive-registry.ts +317 -317
- package/src/host/settings-bridge.ts +222 -222
|
@@ -1,350 +1,350 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 插件署名格式(source kind)体检门与转换。
|
|
3
|
-
*
|
|
4
|
-
* ## 背景:会话格式 v4 的署名要求
|
|
5
|
-
*
|
|
6
|
-
* 宿主 0.1.7-rc.1 起(会话格式 v4)废弃裸 `source: { kind: 'plugin', plugin: '<name>' }`
|
|
7
|
-
* 的署名方式,要求每条插件写入的消息使用生产者自有 kind(`plugin:<name>`)。
|
|
8
|
-
* 校验失败时**整轮失败**(SessionFormatError),不是丢一条消息。
|
|
9
|
-
* 修复模式(与已适配的 aegis、dsh-better-sidebar 一致):
|
|
10
|
-
* `{ kind: 'plugin', plugin: '<name>' }` → `{ kind: 'plugin:<name>' }`(删除 plugin 字段)。
|
|
11
|
-
*
|
|
12
|
-
* ## 版本依赖表(各格式代次依赖的宿主固定版本与处置策略)
|
|
13
|
-
*
|
|
14
|
-
* | 格式代次 | 宿主版本线 | 旧署名行是否合法 | 处置策略 |
|
|
15
|
-
* |---|---|---|---|
|
|
16
|
-
* | v1 | 未考证(早期线) | 合法 | 不动(leave-asis) |
|
|
17
|
-
* | v2 | 未考证 | 合法 | 不动 |
|
|
18
|
-
* | v3 | 0.1.6 时代(待考证) | 合法 | 不动:宿主 v3→v4 迁移负责,插件越权改写属过度操作 |
|
|
19
|
-
* | v4 | ≥ 0.1.7-rc.1(已实锤)| **拒绝**(SessionFormatError) | 检测 + 可选转换(本模块) |
|
|
20
|
-
*
|
|
21
|
-
* 判定依据是**日志 header 的 version 字段**(`decode.ts` 已解析为 `SessionLogRead.header`),
|
|
22
|
-
* 不猜文件名、不猜宿主安装版本:同一份磁盘语料可能混有多条版本线的产物,
|
|
23
|
-
* 每份日志按它自己的 header 代次走对应的策略线。
|
|
24
|
-
*
|
|
25
|
-
* ## 红线(与 repair.ts 的红线关系)
|
|
26
|
-
*
|
|
27
|
-
* repair.ts 的红线是「禁止改写会话日志」。本模块的转换是**经用户显式触发的唯一例外**:
|
|
28
|
-
* 只改 `source.kind` / `source.plugin` 两个署名字段,其余字节原样保留;改前整文件备份;
|
|
29
|
-
* 日志完整性不合格(撕裂尾帧 / 解码失败 / seq 缺口)或代次不在 v4 线时**拒绝执行**。
|
|
30
|
-
* 这三道闸缺一不可,否则就退化成「静默改历史数据」。
|
|
31
|
-
*/
|
|
32
|
-
import { copyFileSync, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
33
|
-
import zlib from 'node:zlib'
|
|
34
|
-
import { join } from 'node:path'
|
|
35
|
-
import { homedir } from 'node:os'
|
|
36
|
-
import { scanZstdFrames, type SessionLogRead } from './decode.ts'
|
|
37
|
-
import type { GateLevel } from './gates.ts'
|
|
38
|
-
|
|
39
|
-
/** v4 线起点的宿主版本(会话格式 v4 引入 producer-owned source kind)。 */
|
|
40
|
-
export const V4_HOST_MIN = '0.1.7-rc.1'
|
|
41
|
-
|
|
42
|
-
/** 旧署名(v3 形态):`kind === 'plugin'` 且带独立 `plugin` 字段。 */
|
|
43
|
-
export interface LegacySource {
|
|
44
|
-
kind: 'plugin'
|
|
45
|
-
plugin: string
|
|
46
|
-
form?: unknown
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** 一份日志里的旧署名事实。 */
|
|
50
|
-
export interface SourceKindFacts {
|
|
51
|
-
/** 旧署名事件总数。 */
|
|
52
|
-
legacyCount: number
|
|
53
|
-
/** 按生产者插件名统计。 */
|
|
54
|
-
byPlugin: Record<string, number>
|
|
55
|
-
/** 命中样例(seq + 插件名),最多 5 条,供证据展示。 */
|
|
56
|
-
examples: { seq: number; plugin: string }[]
|
|
57
|
-
/** 日志 header 的格式代次(无从读取时 undefined)。 */
|
|
58
|
-
headerVersion?: number
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** 从解码后的日志提取旧署名事实(只读)。 */
|
|
62
|
-
export function readSourceKindFacts(log: SessionLogRead): SourceKindFacts {
|
|
63
|
-
const byPlugin: Record<string, number> = {}
|
|
64
|
-
const examples: { seq: number; plugin: string }[] = []
|
|
65
|
-
for (const event of log.events) {
|
|
66
|
-
// 署名出现在事件 data.source(消息类事件的归属信息);打包行已由解码层展开。
|
|
67
|
-
const data = event.data as { source?: unknown } | undefined
|
|
68
|
-
const source = data?.source as LegacySource | undefined
|
|
69
|
-
if (
|
|
70
|
-
source === undefined || typeof source !== 'object' ||
|
|
71
|
-
source.kind !== 'plugin' || typeof source.plugin !== 'string' || source.plugin === ''
|
|
72
|
-
) continue
|
|
73
|
-
byPlugin[source.plugin] = (byPlugin[source.plugin] ?? 0) + 1
|
|
74
|
-
if (examples.length < 5) examples.push({ seq: event.seq, plugin: source.plugin })
|
|
75
|
-
}
|
|
76
|
-
const headerVersion = typeof log.header?.['version'] === 'number' ? log.header['version'] as number : undefined
|
|
77
|
-
const legacyCount = Object.values(byPlugin).reduce((sum, n) => sum + n, 0)
|
|
78
|
-
return {
|
|
79
|
-
legacyCount,
|
|
80
|
-
byPlugin,
|
|
81
|
-
examples,
|
|
82
|
-
...(headerVersion === undefined ? {} : { headerVersion }),
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* gate 5:插件署名格式(v4 线要求 producer-owned kind)。
|
|
88
|
-
*
|
|
89
|
-
* 档位判据(有据才抬档):
|
|
90
|
-
* - header.version ≥ 4 且观测到旧署名行 → `warn`:v4 校验拒绝这种署名;存量行让
|
|
91
|
-
* 未适配插件的读回判断(`kind === 'plugin' && plugin === name`)失配,转换可消除;
|
|
92
|
-
* - header.version < 4 → `ok`:v3 及更早线旧行合法,宿主迁移负责,**不动**(防过度操作);
|
|
93
|
-
* - header.version 无从读取 → `skipped`:无从判定该走哪条版本线。
|
|
94
|
-
* @param facts - 旧署名事实;undefined 表示未提供(无日志)。
|
|
95
|
-
*/
|
|
96
|
-
export function gateSourceKind(facts: SourceKindFacts | undefined): {
|
|
97
|
-
id: 'source-kind'
|
|
98
|
-
level: GateLevel
|
|
99
|
-
evidence: string
|
|
100
|
-
detail?: Record<string, unknown>
|
|
101
|
-
} {
|
|
102
|
-
if (facts === undefined) {
|
|
103
|
-
return { id: 'source-kind', level: 'skipped', evidence: '未提供日志读取结果,本门无从判定' }
|
|
104
|
-
}
|
|
105
|
-
const version = facts.headerVersion
|
|
106
|
-
const plugins = Object.entries(facts.byPlugin)
|
|
107
|
-
const detail = {
|
|
108
|
-
headerVersion: version,
|
|
109
|
-
legacyCount: facts.legacyCount,
|
|
110
|
-
byPlugin: facts.byPlugin,
|
|
111
|
-
examples: facts.examples,
|
|
112
|
-
v4HostMin: V4_HOST_MIN,
|
|
113
|
-
}
|
|
114
|
-
if (version === undefined) {
|
|
115
|
-
return { id: 'source-kind', level: 'skipped', evidence: '日志 header 无 version 字段,无从判定格式代次线', detail }
|
|
116
|
-
}
|
|
117
|
-
if (version < 4) {
|
|
118
|
-
return {
|
|
119
|
-
id: 'source-kind',
|
|
120
|
-
level: 'ok',
|
|
121
|
-
evidence: `格式代次 v${version}(v4 之前的版本线):旧署名行合法,宿主 v3→v4 迁移负责,不做转换` +
|
|
122
|
-
(plugins.length > 0 ? `(观测到 ${plugins.reduce((s, [, n]) => s + n, 0)} 行旧署名,按策略不动)` : ''),
|
|
123
|
-
detail,
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
if (plugins.length === 0) {
|
|
127
|
-
return { id: 'source-kind', level: 'ok', evidence: `格式代次 v${version}:全部插件署名均为 producer-owned kind(plugin:<name>)`, detail }
|
|
128
|
-
}
|
|
129
|
-
const summary = plugins.map(([name, count]) => `${name}×${count}`).join('、')
|
|
130
|
-
return {
|
|
131
|
-
id: 'source-kind',
|
|
132
|
-
level: 'warn',
|
|
133
|
-
evidence: `格式代次 v${version} 但存在 ${facts.legacyCount} 行旧署名(${summary}):宿主 v4 拒绝该形态,未适配插件读回判断也会失配;可执行署名转换`,
|
|
134
|
-
detail,
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** 转换结果。 */
|
|
139
|
-
export interface SourceKindMigrationOutcome {
|
|
140
|
-
ok: boolean
|
|
141
|
-
sessionId: string
|
|
142
|
-
/** 被改写的日志路径。 */
|
|
143
|
-
path: string
|
|
144
|
-
/** 备份路径(改写前的完整副本;ok 且 changed 时必有)。 */
|
|
145
|
-
backup?: string
|
|
146
|
-
/** 改写的行数。 */
|
|
147
|
-
changedRows?: number
|
|
148
|
-
/** 按生产者插件名统计的改写行数。 */
|
|
149
|
-
byPlugin?: Record<string, number>
|
|
150
|
-
/**
|
|
151
|
-
* 按生产者名统计的**跳过**行数(第一方名,宿主迁移职权,见
|
|
152
|
-
* `isFirstPartyLegacyProducer`)。跳过是如实上报,不是静默丢弃。
|
|
153
|
-
*/
|
|
154
|
-
skippedFirstParty?: Record<string, number>
|
|
155
|
-
error?: string
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* 从旧署名对象构造新署名:`{ kind: 'plugin', plugin: name, …rest }` → `{ kind: 'plugin:name', …rest }`。
|
|
160
|
-
* 非 legacy(缺 plugin 字段 / kind 不符)返回 undefined,调用方跳过该行。
|
|
161
|
-
*
|
|
162
|
-
* ⚠️ 只对**第三方**生产者名成立(第四轮审计 ST1):宿主 `producerKind`
|
|
163
|
-
* (dsh-session-format-v3-to-v4@0.1.7-rc.2 lib/index.js:88-96)对第一方名走改名表
|
|
164
|
-
* 或同名裸 kind,本函数无条件加前缀会对那 30 个名字写出宿主永不产出的 kind。
|
|
165
|
-
* 第一方名由 `FIRST_PARTY_RENAMED_PRODUCERS` / `FIRST_PARTY_SAME_NAME_PRODUCERS`
|
|
166
|
-
* 判定,`migrateSessionSourceKind` 命中即跳过并如实上报(宿主迁移职权,不代转换)。
|
|
167
|
-
*/
|
|
168
|
-
export function migrateLegacySource(source: unknown): Record<string, unknown> | undefined {
|
|
169
|
-
if (typeof source !== 'object' || source === null) return undefined
|
|
170
|
-
const record = source as Record<string, unknown>
|
|
171
|
-
if (record['kind'] !== 'plugin' || typeof record['plugin'] !== 'string' || record['plugin'] === '') return undefined
|
|
172
|
-
const { plugin, ...rest } = record
|
|
173
|
-
return { ...rest, kind: `plugin:${plugin as string}` }
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* 宿主 v3→v4 迁移会**改名**的第一方生产者(出处:dsh-session-format-v3-to-v4@
|
|
178
|
-
* 0.1.7-rc.2 lib/index.js:51-57 `RENAMED_PRODUCERS`,逐字照录)。
|
|
179
|
-
*/
|
|
180
|
-
export const FIRST_PARTY_RENAMED_PRODUCERS: Readonly<Record<string, string>> = Object.freeze({
|
|
181
|
-
'compact': 'compact-checkpoint',
|
|
182
|
-
'tools-code-mode': 'ptc-mode',
|
|
183
|
-
'tools-ptc': 'ptc-mode',
|
|
184
|
-
'dsh-compaction-basic': 'compact-basic',
|
|
185
|
-
'@deepseek-ai/dsh-system-prompt': 'runtime-context',
|
|
186
|
-
})
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* 宿主迁移后**保留同名裸 kind** 的第一方生产者(出处:同上 lib/index.js:59-84
|
|
190
|
-
* `RELEASED_SAME_NAME_PRODUCERS`,逐字照录;注释明言限第一方)。
|
|
191
|
-
* 注:`@deepseek-ai/dsh-system-prompt` 还有 role 敏感分支(role=system →
|
|
192
|
-
* `system-prompt`),steward 无 role 上下文、结构性不可复刻——这正是第一方名
|
|
193
|
-
* 一律跳过、交还宿主迁移的核心理由之一。
|
|
194
|
-
*/
|
|
195
|
-
export const FIRST_PARTY_SAME_NAME_PRODUCERS: ReadonlySet<string> = new Set([
|
|
196
|
-
'agent-instructions',
|
|
197
|
-
'session-reference',
|
|
198
|
-
'team-message',
|
|
199
|
-
'goal',
|
|
200
|
-
'skill-invocation',
|
|
201
|
-
'skill-catalog',
|
|
202
|
-
'coordinator',
|
|
203
|
-
'subagent-report',
|
|
204
|
-
'subagent-settled',
|
|
205
|
-
'webhook',
|
|
206
|
-
'agent-message',
|
|
207
|
-
'model-selection',
|
|
208
|
-
'plan-mode',
|
|
209
|
-
'time-context',
|
|
210
|
-
'tmux-context',
|
|
211
|
-
'user-approval',
|
|
212
|
-
'repeat-tool-reminder',
|
|
213
|
-
'tool-cordis',
|
|
214
|
-
'cordis-host-runner',
|
|
215
|
-
'tool-goal',
|
|
216
|
-
'tool-jobs',
|
|
217
|
-
'hooks-codex',
|
|
218
|
-
'hooks-claude-code',
|
|
219
|
-
'schedule',
|
|
220
|
-
'dsh-session-title-llm',
|
|
221
|
-
])
|
|
222
|
-
|
|
223
|
-
/** 是否第一方生产者名(宿主迁移职权,steward 不代转换——防归因静默损坏)。 */
|
|
224
|
-
export function isFirstPartyLegacyProducer(plugin: string): boolean {
|
|
225
|
-
return Object.hasOwn(FIRST_PARTY_RENAMED_PRODUCERS, plugin) || FIRST_PARTY_SAME_NAME_PRODUCERS.has(plugin)
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* 转换一个会话日志里的旧署名行(v4 线专用;先备份,只动署名字段)。
|
|
230
|
-
*
|
|
231
|
-
* 执行闸(任一不满足即拒绝,不写盘):
|
|
232
|
-
* 1. 日志可整体解码且无完整性问题(撕裂尾帧 / 解码失败 / seq 缺口);
|
|
233
|
-
* 2. header.version ≥ 4(v3 及更早的旧行按契约由宿主迁移,不动);
|
|
234
|
-
* 3. 确有旧署名行(没有则空手而归,不做无谓重压缩)。
|
|
235
|
-
* @param sessionId - 会话 id(用于结果标注)。
|
|
236
|
-
* @param logPath - 当前代日志路径(必须 .zstd 结尾)。
|
|
237
|
-
* @param log - 已解码的日志(复用体检结果,闸 1/2 在其上判定)。
|
|
238
|
-
* @param dshHome - DSH home(备份不依赖它,参数保留与其它处置一致的面)。
|
|
239
|
-
* @param now - 时间源(备份后缀,测试可控)。
|
|
240
|
-
*/
|
|
241
|
-
export function migrateSessionSourceKind(
|
|
242
|
-
sessionId: string,
|
|
243
|
-
logPath: string,
|
|
244
|
-
log: SessionLogRead,
|
|
245
|
-
dshHome?: string,
|
|
246
|
-
now: () => number = Date.now,
|
|
247
|
-
): SourceKindMigrationOutcome {
|
|
248
|
-
void dshHome
|
|
249
|
-
const failure = (error: string): SourceKindMigrationOutcome => ({ ok: false, sessionId, path: logPath, error })
|
|
250
|
-
if (!logPath.endsWith('.zstd')) return failure(`非 zstd 产物,拒绝处理:${logPath}`)
|
|
251
|
-
if (log.issues.length > 0) return failure(`日志存在 ${log.issues.length} 条完整性问题,拒绝改写(先解决 log-integrity 门)`)
|
|
252
|
-
if (log.tornStart !== undefined) return failure('存在撕裂尾帧,拒绝改写(先让宿主完成提交或处置 log-integrity 门)')
|
|
253
|
-
const headerVersion = typeof log.header?.['version'] === 'number' ? log.header['version'] as number : undefined
|
|
254
|
-
if (headerVersion === undefined) return failure('日志 header 无 version 字段,无法确认格式代次线,拒绝改写')
|
|
255
|
-
if (headerVersion < 4) {
|
|
256
|
-
return failure(`格式代次 v${headerVersion}:旧署名在该版本线合法,宿主迁移负责,不做转换(防过度操作)`)
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// 逐行重写:只重序列化发生变化的行,其余行保持原字节(避免无谓的全量格式漂移)。
|
|
260
|
-
let original: string
|
|
261
|
-
try {
|
|
262
|
-
original = readPlaintext(logPath)
|
|
263
|
-
} catch (err) {
|
|
264
|
-
return failure(`读取日志失败:${String(err instanceof Error ? err.message : err)}`)
|
|
265
|
-
}
|
|
266
|
-
const lines = original.split('\n')
|
|
267
|
-
let changedRows = 0
|
|
268
|
-
const byPlugin: Record<string, number> = {}
|
|
269
|
-
const skippedFirstParty: Record<string, number> = {}
|
|
270
|
-
const rewritten = lines.map((line, index) => {
|
|
271
|
-
if (index === 0 || line.trim() === '') return line
|
|
272
|
-
let parsed: unknown
|
|
273
|
-
try {
|
|
274
|
-
parsed = JSON.parse(line)
|
|
275
|
-
} catch {
|
|
276
|
-
return line // 非法行交给 log-integrity 门报,这里不添乱
|
|
277
|
-
}
|
|
278
|
-
if (typeof parsed !== 'object' || parsed === null) return line
|
|
279
|
-
const record = parsed as Record<string, unknown>
|
|
280
|
-
const data = record['data'] as Record<string, unknown> | undefined
|
|
281
|
-
if (data === undefined || typeof data !== 'object') return line
|
|
282
|
-
const legacy = data['source'] as Record<string, unknown> | undefined
|
|
283
|
-
if (
|
|
284
|
-
legacy === undefined || typeof legacy !== 'object' ||
|
|
285
|
-
legacy['kind'] !== 'plugin' || typeof legacy['plugin'] !== 'string' || legacy['plugin'] === ''
|
|
286
|
-
) return line
|
|
287
|
-
const plugin = legacy['plugin'] as string
|
|
288
|
-
// ST1:第一方名跳过——宿主 producerKind 对它们走改名表/同名裸 kind(还有
|
|
289
|
-
// role 敏感分支),无条件加前缀会写出宿主永不产出的 kind,静默损坏归因。
|
|
290
|
-
// 跳过并如实上报,交还宿主迁移(防过度操作红线)。
|
|
291
|
-
if (isFirstPartyLegacyProducer(plugin)) {
|
|
292
|
-
skippedFirstParty[plugin] = (skippedFirstParty[plugin] ?? 0) + 1
|
|
293
|
-
return line
|
|
294
|
-
}
|
|
295
|
-
const migrated = migrateLegacySource(legacy)
|
|
296
|
-
if (migrated === undefined) return line
|
|
297
|
-
data['source'] = migrated
|
|
298
|
-
changedRows += 1
|
|
299
|
-
byPlugin[plugin] = (byPlugin[plugin] ?? 0) + 1
|
|
300
|
-
return JSON.stringify(record)
|
|
301
|
-
})
|
|
302
|
-
if (changedRows === 0) {
|
|
303
|
-
const skippedTotal = Object.values(skippedFirstParty).reduce((sum, n) => sum + n, 0)
|
|
304
|
-
return {
|
|
305
|
-
ok: true,
|
|
306
|
-
sessionId,
|
|
307
|
-
path: logPath,
|
|
308
|
-
changedRows: 0,
|
|
309
|
-
byPlugin: {},
|
|
310
|
-
...(skippedTotal > 0 ? { skippedFirstParty } : {}),
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
// 备份 → 原子落盘:先写临时文件再 rename,任何中断下原文件要么原样要么完整新态,
|
|
315
|
-
// 且备份副本始终存在可回退。
|
|
316
|
-
const backup = `${logPath}.pre-sourcemigrate-${now()}`
|
|
317
|
-
try {
|
|
318
|
-
copyFileSync(logPath, backup)
|
|
319
|
-
const compressed = zlib.zstdCompressSync(Buffer.from(rewritten.join('\n'), 'utf8'))
|
|
320
|
-
const tmp = `${logPath}.sourcemigrate-tmp`
|
|
321
|
-
writeFileSync(tmp, compressed)
|
|
322
|
-
renameSync(tmp, logPath)
|
|
323
|
-
return {
|
|
324
|
-
ok: true,
|
|
325
|
-
sessionId,
|
|
326
|
-
path: logPath,
|
|
327
|
-
backup,
|
|
328
|
-
changedRows,
|
|
329
|
-
byPlugin,
|
|
330
|
-
...(Object.keys(skippedFirstParty).length > 0 ? { skippedFirstParty } : {}),
|
|
331
|
-
}
|
|
332
|
-
} catch (err) {
|
|
333
|
-
return failure(`转换落盘失败(原文件未动,备份${existsSync(backup) ? '已生成' : '未生成'}):${String(err instanceof Error ? err.message : err)}`)
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
/** 读出整份 zstd 日志的明文;失败时抛出(调用方拒绝改写并如实上报)。 */
|
|
338
|
-
function readPlaintext(logPath: string): string {
|
|
339
|
-
const bytes = readFileSync(logPath)
|
|
340
|
-
const { frames, tornStart } = scanZstdFrames(bytes)
|
|
341
|
-
if (tornStart !== undefined) throw new Error('存在撕裂尾帧,拒绝改写')
|
|
342
|
-
const parts: Buffer[] = []
|
|
343
|
-
for (const frame of frames) {
|
|
344
|
-
parts.push(zlib.zstdDecompressSync(bytes.subarray(frame.start, frame.end)))
|
|
345
|
-
}
|
|
346
|
-
return Buffer.concat(parts).toString('utf8')
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
/** 备份文件的后缀约定(purge/清理工具可据此识别并一并处置)。 */
|
|
350
|
-
export const SOURCE_MIGRATE_BACKUP_SUFFIX = '.pre-sourcemigrate-'
|
|
1
|
+
/**
|
|
2
|
+
* 插件署名格式(source kind)体检门与转换。
|
|
3
|
+
*
|
|
4
|
+
* ## 背景:会话格式 v4 的署名要求
|
|
5
|
+
*
|
|
6
|
+
* 宿主 0.1.7-rc.1 起(会话格式 v4)废弃裸 `source: { kind: 'plugin', plugin: '<name>' }`
|
|
7
|
+
* 的署名方式,要求每条插件写入的消息使用生产者自有 kind(`plugin:<name>`)。
|
|
8
|
+
* 校验失败时**整轮失败**(SessionFormatError),不是丢一条消息。
|
|
9
|
+
* 修复模式(与已适配的 aegis、dsh-better-sidebar 一致):
|
|
10
|
+
* `{ kind: 'plugin', plugin: '<name>' }` → `{ kind: 'plugin:<name>' }`(删除 plugin 字段)。
|
|
11
|
+
*
|
|
12
|
+
* ## 版本依赖表(各格式代次依赖的宿主固定版本与处置策略)
|
|
13
|
+
*
|
|
14
|
+
* | 格式代次 | 宿主版本线 | 旧署名行是否合法 | 处置策略 |
|
|
15
|
+
* |---|---|---|---|
|
|
16
|
+
* | v1 | 未考证(早期线) | 合法 | 不动(leave-asis) |
|
|
17
|
+
* | v2 | 未考证 | 合法 | 不动 |
|
|
18
|
+
* | v3 | 0.1.6 时代(待考证) | 合法 | 不动:宿主 v3→v4 迁移负责,插件越权改写属过度操作 |
|
|
19
|
+
* | v4 | ≥ 0.1.7-rc.1(已实锤)| **拒绝**(SessionFormatError) | 检测 + 可选转换(本模块) |
|
|
20
|
+
*
|
|
21
|
+
* 判定依据是**日志 header 的 version 字段**(`decode.ts` 已解析为 `SessionLogRead.header`),
|
|
22
|
+
* 不猜文件名、不猜宿主安装版本:同一份磁盘语料可能混有多条版本线的产物,
|
|
23
|
+
* 每份日志按它自己的 header 代次走对应的策略线。
|
|
24
|
+
*
|
|
25
|
+
* ## 红线(与 repair.ts 的红线关系)
|
|
26
|
+
*
|
|
27
|
+
* repair.ts 的红线是「禁止改写会话日志」。本模块的转换是**经用户显式触发的唯一例外**:
|
|
28
|
+
* 只改 `source.kind` / `source.plugin` 两个署名字段,其余字节原样保留;改前整文件备份;
|
|
29
|
+
* 日志完整性不合格(撕裂尾帧 / 解码失败 / seq 缺口)或代次不在 v4 线时**拒绝执行**。
|
|
30
|
+
* 这三道闸缺一不可,否则就退化成「静默改历史数据」。
|
|
31
|
+
*/
|
|
32
|
+
import { copyFileSync, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
33
|
+
import zlib from 'node:zlib'
|
|
34
|
+
import { join } from 'node:path'
|
|
35
|
+
import { homedir } from 'node:os'
|
|
36
|
+
import { scanZstdFrames, type SessionLogRead } from './decode.ts'
|
|
37
|
+
import type { GateLevel } from './gates.ts'
|
|
38
|
+
|
|
39
|
+
/** v4 线起点的宿主版本(会话格式 v4 引入 producer-owned source kind)。 */
|
|
40
|
+
export const V4_HOST_MIN = '0.1.7-rc.1'
|
|
41
|
+
|
|
42
|
+
/** 旧署名(v3 形态):`kind === 'plugin'` 且带独立 `plugin` 字段。 */
|
|
43
|
+
export interface LegacySource {
|
|
44
|
+
kind: 'plugin'
|
|
45
|
+
plugin: string
|
|
46
|
+
form?: unknown
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 一份日志里的旧署名事实。 */
|
|
50
|
+
export interface SourceKindFacts {
|
|
51
|
+
/** 旧署名事件总数。 */
|
|
52
|
+
legacyCount: number
|
|
53
|
+
/** 按生产者插件名统计。 */
|
|
54
|
+
byPlugin: Record<string, number>
|
|
55
|
+
/** 命中样例(seq + 插件名),最多 5 条,供证据展示。 */
|
|
56
|
+
examples: { seq: number; plugin: string }[]
|
|
57
|
+
/** 日志 header 的格式代次(无从读取时 undefined)。 */
|
|
58
|
+
headerVersion?: number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 从解码后的日志提取旧署名事实(只读)。 */
|
|
62
|
+
export function readSourceKindFacts(log: SessionLogRead): SourceKindFacts {
|
|
63
|
+
const byPlugin: Record<string, number> = {}
|
|
64
|
+
const examples: { seq: number; plugin: string }[] = []
|
|
65
|
+
for (const event of log.events) {
|
|
66
|
+
// 署名出现在事件 data.source(消息类事件的归属信息);打包行已由解码层展开。
|
|
67
|
+
const data = event.data as { source?: unknown } | undefined
|
|
68
|
+
const source = data?.source as LegacySource | undefined
|
|
69
|
+
if (
|
|
70
|
+
source === undefined || typeof source !== 'object' ||
|
|
71
|
+
source.kind !== 'plugin' || typeof source.plugin !== 'string' || source.plugin === ''
|
|
72
|
+
) continue
|
|
73
|
+
byPlugin[source.plugin] = (byPlugin[source.plugin] ?? 0) + 1
|
|
74
|
+
if (examples.length < 5) examples.push({ seq: event.seq, plugin: source.plugin })
|
|
75
|
+
}
|
|
76
|
+
const headerVersion = typeof log.header?.['version'] === 'number' ? log.header['version'] as number : undefined
|
|
77
|
+
const legacyCount = Object.values(byPlugin).reduce((sum, n) => sum + n, 0)
|
|
78
|
+
return {
|
|
79
|
+
legacyCount,
|
|
80
|
+
byPlugin,
|
|
81
|
+
examples,
|
|
82
|
+
...(headerVersion === undefined ? {} : { headerVersion }),
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* gate 5:插件署名格式(v4 线要求 producer-owned kind)。
|
|
88
|
+
*
|
|
89
|
+
* 档位判据(有据才抬档):
|
|
90
|
+
* - header.version ≥ 4 且观测到旧署名行 → `warn`:v4 校验拒绝这种署名;存量行让
|
|
91
|
+
* 未适配插件的读回判断(`kind === 'plugin' && plugin === name`)失配,转换可消除;
|
|
92
|
+
* - header.version < 4 → `ok`:v3 及更早线旧行合法,宿主迁移负责,**不动**(防过度操作);
|
|
93
|
+
* - header.version 无从读取 → `skipped`:无从判定该走哪条版本线。
|
|
94
|
+
* @param facts - 旧署名事实;undefined 表示未提供(无日志)。
|
|
95
|
+
*/
|
|
96
|
+
export function gateSourceKind(facts: SourceKindFacts | undefined): {
|
|
97
|
+
id: 'source-kind'
|
|
98
|
+
level: GateLevel
|
|
99
|
+
evidence: string
|
|
100
|
+
detail?: Record<string, unknown>
|
|
101
|
+
} {
|
|
102
|
+
if (facts === undefined) {
|
|
103
|
+
return { id: 'source-kind', level: 'skipped', evidence: '未提供日志读取结果,本门无从判定' }
|
|
104
|
+
}
|
|
105
|
+
const version = facts.headerVersion
|
|
106
|
+
const plugins = Object.entries(facts.byPlugin)
|
|
107
|
+
const detail = {
|
|
108
|
+
headerVersion: version,
|
|
109
|
+
legacyCount: facts.legacyCount,
|
|
110
|
+
byPlugin: facts.byPlugin,
|
|
111
|
+
examples: facts.examples,
|
|
112
|
+
v4HostMin: V4_HOST_MIN,
|
|
113
|
+
}
|
|
114
|
+
if (version === undefined) {
|
|
115
|
+
return { id: 'source-kind', level: 'skipped', evidence: '日志 header 无 version 字段,无从判定格式代次线', detail }
|
|
116
|
+
}
|
|
117
|
+
if (version < 4) {
|
|
118
|
+
return {
|
|
119
|
+
id: 'source-kind',
|
|
120
|
+
level: 'ok',
|
|
121
|
+
evidence: `格式代次 v${version}(v4 之前的版本线):旧署名行合法,宿主 v3→v4 迁移负责,不做转换` +
|
|
122
|
+
(plugins.length > 0 ? `(观测到 ${plugins.reduce((s, [, n]) => s + n, 0)} 行旧署名,按策略不动)` : ''),
|
|
123
|
+
detail,
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (plugins.length === 0) {
|
|
127
|
+
return { id: 'source-kind', level: 'ok', evidence: `格式代次 v${version}:全部插件署名均为 producer-owned kind(plugin:<name>)`, detail }
|
|
128
|
+
}
|
|
129
|
+
const summary = plugins.map(([name, count]) => `${name}×${count}`).join('、')
|
|
130
|
+
return {
|
|
131
|
+
id: 'source-kind',
|
|
132
|
+
level: 'warn',
|
|
133
|
+
evidence: `格式代次 v${version} 但存在 ${facts.legacyCount} 行旧署名(${summary}):宿主 v4 拒绝该形态,未适配插件读回判断也会失配;可执行署名转换`,
|
|
134
|
+
detail,
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 转换结果。 */
|
|
139
|
+
export interface SourceKindMigrationOutcome {
|
|
140
|
+
ok: boolean
|
|
141
|
+
sessionId: string
|
|
142
|
+
/** 被改写的日志路径。 */
|
|
143
|
+
path: string
|
|
144
|
+
/** 备份路径(改写前的完整副本;ok 且 changed 时必有)。 */
|
|
145
|
+
backup?: string
|
|
146
|
+
/** 改写的行数。 */
|
|
147
|
+
changedRows?: number
|
|
148
|
+
/** 按生产者插件名统计的改写行数。 */
|
|
149
|
+
byPlugin?: Record<string, number>
|
|
150
|
+
/**
|
|
151
|
+
* 按生产者名统计的**跳过**行数(第一方名,宿主迁移职权,见
|
|
152
|
+
* `isFirstPartyLegacyProducer`)。跳过是如实上报,不是静默丢弃。
|
|
153
|
+
*/
|
|
154
|
+
skippedFirstParty?: Record<string, number>
|
|
155
|
+
error?: string
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 从旧署名对象构造新署名:`{ kind: 'plugin', plugin: name, …rest }` → `{ kind: 'plugin:name', …rest }`。
|
|
160
|
+
* 非 legacy(缺 plugin 字段 / kind 不符)返回 undefined,调用方跳过该行。
|
|
161
|
+
*
|
|
162
|
+
* ⚠️ 只对**第三方**生产者名成立(第四轮审计 ST1):宿主 `producerKind`
|
|
163
|
+
* (dsh-session-format-v3-to-v4@0.1.7-rc.2 lib/index.js:88-96)对第一方名走改名表
|
|
164
|
+
* 或同名裸 kind,本函数无条件加前缀会对那 30 个名字写出宿主永不产出的 kind。
|
|
165
|
+
* 第一方名由 `FIRST_PARTY_RENAMED_PRODUCERS` / `FIRST_PARTY_SAME_NAME_PRODUCERS`
|
|
166
|
+
* 判定,`migrateSessionSourceKind` 命中即跳过并如实上报(宿主迁移职权,不代转换)。
|
|
167
|
+
*/
|
|
168
|
+
export function migrateLegacySource(source: unknown): Record<string, unknown> | undefined {
|
|
169
|
+
if (typeof source !== 'object' || source === null) return undefined
|
|
170
|
+
const record = source as Record<string, unknown>
|
|
171
|
+
if (record['kind'] !== 'plugin' || typeof record['plugin'] !== 'string' || record['plugin'] === '') return undefined
|
|
172
|
+
const { plugin, ...rest } = record
|
|
173
|
+
return { ...rest, kind: `plugin:${plugin as string}` }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 宿主 v3→v4 迁移会**改名**的第一方生产者(出处:dsh-session-format-v3-to-v4@
|
|
178
|
+
* 0.1.7-rc.2 lib/index.js:51-57 `RENAMED_PRODUCERS`,逐字照录)。
|
|
179
|
+
*/
|
|
180
|
+
export const FIRST_PARTY_RENAMED_PRODUCERS: Readonly<Record<string, string>> = Object.freeze({
|
|
181
|
+
'compact': 'compact-checkpoint',
|
|
182
|
+
'tools-code-mode': 'ptc-mode',
|
|
183
|
+
'tools-ptc': 'ptc-mode',
|
|
184
|
+
'dsh-compaction-basic': 'compact-basic',
|
|
185
|
+
'@deepseek-ai/dsh-system-prompt': 'runtime-context',
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 宿主迁移后**保留同名裸 kind** 的第一方生产者(出处:同上 lib/index.js:59-84
|
|
190
|
+
* `RELEASED_SAME_NAME_PRODUCERS`,逐字照录;注释明言限第一方)。
|
|
191
|
+
* 注:`@deepseek-ai/dsh-system-prompt` 还有 role 敏感分支(role=system →
|
|
192
|
+
* `system-prompt`),steward 无 role 上下文、结构性不可复刻——这正是第一方名
|
|
193
|
+
* 一律跳过、交还宿主迁移的核心理由之一。
|
|
194
|
+
*/
|
|
195
|
+
export const FIRST_PARTY_SAME_NAME_PRODUCERS: ReadonlySet<string> = new Set([
|
|
196
|
+
'agent-instructions',
|
|
197
|
+
'session-reference',
|
|
198
|
+
'team-message',
|
|
199
|
+
'goal',
|
|
200
|
+
'skill-invocation',
|
|
201
|
+
'skill-catalog',
|
|
202
|
+
'coordinator',
|
|
203
|
+
'subagent-report',
|
|
204
|
+
'subagent-settled',
|
|
205
|
+
'webhook',
|
|
206
|
+
'agent-message',
|
|
207
|
+
'model-selection',
|
|
208
|
+
'plan-mode',
|
|
209
|
+
'time-context',
|
|
210
|
+
'tmux-context',
|
|
211
|
+
'user-approval',
|
|
212
|
+
'repeat-tool-reminder',
|
|
213
|
+
'tool-cordis',
|
|
214
|
+
'cordis-host-runner',
|
|
215
|
+
'tool-goal',
|
|
216
|
+
'tool-jobs',
|
|
217
|
+
'hooks-codex',
|
|
218
|
+
'hooks-claude-code',
|
|
219
|
+
'schedule',
|
|
220
|
+
'dsh-session-title-llm',
|
|
221
|
+
])
|
|
222
|
+
|
|
223
|
+
/** 是否第一方生产者名(宿主迁移职权,steward 不代转换——防归因静默损坏)。 */
|
|
224
|
+
export function isFirstPartyLegacyProducer(plugin: string): boolean {
|
|
225
|
+
return Object.hasOwn(FIRST_PARTY_RENAMED_PRODUCERS, plugin) || FIRST_PARTY_SAME_NAME_PRODUCERS.has(plugin)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 转换一个会话日志里的旧署名行(v4 线专用;先备份,只动署名字段)。
|
|
230
|
+
*
|
|
231
|
+
* 执行闸(任一不满足即拒绝,不写盘):
|
|
232
|
+
* 1. 日志可整体解码且无完整性问题(撕裂尾帧 / 解码失败 / seq 缺口);
|
|
233
|
+
* 2. header.version ≥ 4(v3 及更早的旧行按契约由宿主迁移,不动);
|
|
234
|
+
* 3. 确有旧署名行(没有则空手而归,不做无谓重压缩)。
|
|
235
|
+
* @param sessionId - 会话 id(用于结果标注)。
|
|
236
|
+
* @param logPath - 当前代日志路径(必须 .zstd 结尾)。
|
|
237
|
+
* @param log - 已解码的日志(复用体检结果,闸 1/2 在其上判定)。
|
|
238
|
+
* @param dshHome - DSH home(备份不依赖它,参数保留与其它处置一致的面)。
|
|
239
|
+
* @param now - 时间源(备份后缀,测试可控)。
|
|
240
|
+
*/
|
|
241
|
+
export function migrateSessionSourceKind(
|
|
242
|
+
sessionId: string,
|
|
243
|
+
logPath: string,
|
|
244
|
+
log: SessionLogRead,
|
|
245
|
+
dshHome?: string,
|
|
246
|
+
now: () => number = Date.now,
|
|
247
|
+
): SourceKindMigrationOutcome {
|
|
248
|
+
void dshHome
|
|
249
|
+
const failure = (error: string): SourceKindMigrationOutcome => ({ ok: false, sessionId, path: logPath, error })
|
|
250
|
+
if (!logPath.endsWith('.zstd')) return failure(`非 zstd 产物,拒绝处理:${logPath}`)
|
|
251
|
+
if (log.issues.length > 0) return failure(`日志存在 ${log.issues.length} 条完整性问题,拒绝改写(先解决 log-integrity 门)`)
|
|
252
|
+
if (log.tornStart !== undefined) return failure('存在撕裂尾帧,拒绝改写(先让宿主完成提交或处置 log-integrity 门)')
|
|
253
|
+
const headerVersion = typeof log.header?.['version'] === 'number' ? log.header['version'] as number : undefined
|
|
254
|
+
if (headerVersion === undefined) return failure('日志 header 无 version 字段,无法确认格式代次线,拒绝改写')
|
|
255
|
+
if (headerVersion < 4) {
|
|
256
|
+
return failure(`格式代次 v${headerVersion}:旧署名在该版本线合法,宿主迁移负责,不做转换(防过度操作)`)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// 逐行重写:只重序列化发生变化的行,其余行保持原字节(避免无谓的全量格式漂移)。
|
|
260
|
+
let original: string
|
|
261
|
+
try {
|
|
262
|
+
original = readPlaintext(logPath)
|
|
263
|
+
} catch (err) {
|
|
264
|
+
return failure(`读取日志失败:${String(err instanceof Error ? err.message : err)}`)
|
|
265
|
+
}
|
|
266
|
+
const lines = original.split('\n')
|
|
267
|
+
let changedRows = 0
|
|
268
|
+
const byPlugin: Record<string, number> = {}
|
|
269
|
+
const skippedFirstParty: Record<string, number> = {}
|
|
270
|
+
const rewritten = lines.map((line, index) => {
|
|
271
|
+
if (index === 0 || line.trim() === '') return line
|
|
272
|
+
let parsed: unknown
|
|
273
|
+
try {
|
|
274
|
+
parsed = JSON.parse(line)
|
|
275
|
+
} catch {
|
|
276
|
+
return line // 非法行交给 log-integrity 门报,这里不添乱
|
|
277
|
+
}
|
|
278
|
+
if (typeof parsed !== 'object' || parsed === null) return line
|
|
279
|
+
const record = parsed as Record<string, unknown>
|
|
280
|
+
const data = record['data'] as Record<string, unknown> | undefined
|
|
281
|
+
if (data === undefined || typeof data !== 'object') return line
|
|
282
|
+
const legacy = data['source'] as Record<string, unknown> | undefined
|
|
283
|
+
if (
|
|
284
|
+
legacy === undefined || typeof legacy !== 'object' ||
|
|
285
|
+
legacy['kind'] !== 'plugin' || typeof legacy['plugin'] !== 'string' || legacy['plugin'] === ''
|
|
286
|
+
) return line
|
|
287
|
+
const plugin = legacy['plugin'] as string
|
|
288
|
+
// ST1:第一方名跳过——宿主 producerKind 对它们走改名表/同名裸 kind(还有
|
|
289
|
+
// role 敏感分支),无条件加前缀会写出宿主永不产出的 kind,静默损坏归因。
|
|
290
|
+
// 跳过并如实上报,交还宿主迁移(防过度操作红线)。
|
|
291
|
+
if (isFirstPartyLegacyProducer(plugin)) {
|
|
292
|
+
skippedFirstParty[plugin] = (skippedFirstParty[plugin] ?? 0) + 1
|
|
293
|
+
return line
|
|
294
|
+
}
|
|
295
|
+
const migrated = migrateLegacySource(legacy)
|
|
296
|
+
if (migrated === undefined) return line
|
|
297
|
+
data['source'] = migrated
|
|
298
|
+
changedRows += 1
|
|
299
|
+
byPlugin[plugin] = (byPlugin[plugin] ?? 0) + 1
|
|
300
|
+
return JSON.stringify(record)
|
|
301
|
+
})
|
|
302
|
+
if (changedRows === 0) {
|
|
303
|
+
const skippedTotal = Object.values(skippedFirstParty).reduce((sum, n) => sum + n, 0)
|
|
304
|
+
return {
|
|
305
|
+
ok: true,
|
|
306
|
+
sessionId,
|
|
307
|
+
path: logPath,
|
|
308
|
+
changedRows: 0,
|
|
309
|
+
byPlugin: {},
|
|
310
|
+
...(skippedTotal > 0 ? { skippedFirstParty } : {}),
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// 备份 → 原子落盘:先写临时文件再 rename,任何中断下原文件要么原样要么完整新态,
|
|
315
|
+
// 且备份副本始终存在可回退。
|
|
316
|
+
const backup = `${logPath}.pre-sourcemigrate-${now()}`
|
|
317
|
+
try {
|
|
318
|
+
copyFileSync(logPath, backup)
|
|
319
|
+
const compressed = zlib.zstdCompressSync(Buffer.from(rewritten.join('\n'), 'utf8'))
|
|
320
|
+
const tmp = `${logPath}.sourcemigrate-tmp`
|
|
321
|
+
writeFileSync(tmp, compressed)
|
|
322
|
+
renameSync(tmp, logPath)
|
|
323
|
+
return {
|
|
324
|
+
ok: true,
|
|
325
|
+
sessionId,
|
|
326
|
+
path: logPath,
|
|
327
|
+
backup,
|
|
328
|
+
changedRows,
|
|
329
|
+
byPlugin,
|
|
330
|
+
...(Object.keys(skippedFirstParty).length > 0 ? { skippedFirstParty } : {}),
|
|
331
|
+
}
|
|
332
|
+
} catch (err) {
|
|
333
|
+
return failure(`转换落盘失败(原文件未动,备份${existsSync(backup) ? '已生成' : '未生成'}):${String(err instanceof Error ? err.message : err)}`)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** 读出整份 zstd 日志的明文;失败时抛出(调用方拒绝改写并如实上报)。 */
|
|
338
|
+
function readPlaintext(logPath: string): string {
|
|
339
|
+
const bytes = readFileSync(logPath)
|
|
340
|
+
const { frames, tornStart } = scanZstdFrames(bytes)
|
|
341
|
+
if (tornStart !== undefined) throw new Error('存在撕裂尾帧,拒绝改写')
|
|
342
|
+
const parts: Buffer[] = []
|
|
343
|
+
for (const frame of frames) {
|
|
344
|
+
parts.push(zlib.zstdDecompressSync(bytes.subarray(frame.start, frame.end)))
|
|
345
|
+
}
|
|
346
|
+
return Buffer.concat(parts).toString('utf8')
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** 备份文件的后缀约定(purge/清理工具可据此识别并一并处置)。 */
|
|
350
|
+
export const SOURCE_MIGRATE_BACKUP_SUFFIX = '.pre-sourcemigrate-'
|