dsh-manage-session 0.3.3 → 0.4.8
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 +19 -0
- package/CHANGELOG.ko.md +18 -0
- package/CHANGELOG.md +40 -0
- package/dsh.plugin.json +2 -2
- package/lib/client.js +167 -33
- package/lib/index.d.ts +123 -8
- package/lib/index.js +1147 -815
- package/package.json +125 -122
- package/src/client/card.tsx +150 -153
- package/src/client/health-panel.tsx +51 -0
- package/src/client/host-api.ts +14 -1
- package/src/client/index.ts +337 -234
- package/src/client/locales.ts +15 -0
- package/src/host/health/gates.ts +4 -1
- package/src/host/health/source-kind.ts +350 -0
- package/src/host/history/archive.ts +4 -1
- package/src/host/history/purge.ts +51 -4
- package/src/index.ts +56 -48
package/src/client/locales.ts
CHANGED
|
@@ -21,6 +21,8 @@ export const zh = {
|
|
|
21
21
|
'card.readonly': '当前会话无法修改设置(只读挂载)。',
|
|
22
22
|
'card.unavailable': '设置服务不可用,无法读取配置。',
|
|
23
23
|
|
|
24
|
+
'family.title': '起子插件设置',
|
|
25
|
+
|
|
24
26
|
'panel.title': '会话管家',
|
|
25
27
|
'panel.tab.history': '养老院',
|
|
26
28
|
'panel.tab.health': '体检',
|
|
@@ -72,6 +74,7 @@ export const zh = {
|
|
|
72
74
|
'health.gate.projection-cache': '投影缓存',
|
|
73
75
|
'health.gate.lossless-json': '无损 JSON',
|
|
74
76
|
'health.gate.cold-read': '可接续性',
|
|
77
|
+
'health.gate.source-kind': '插件署名',
|
|
75
78
|
'health.attribution': '归属',
|
|
76
79
|
'health.field': '字段',
|
|
77
80
|
'health.unknownOwner': '归属未知',
|
|
@@ -80,6 +83,11 @@ export const zh = {
|
|
|
80
83
|
'health.phase.discharge': '③ 出院',
|
|
81
84
|
'health.repair': '执行可逆处置',
|
|
82
85
|
'health.repairing': '处置中…',
|
|
86
|
+
'health.sourceMigrate': '转换旧署名',
|
|
87
|
+
'health.sourceMigrating': '转换中…',
|
|
88
|
+
'health.sourceMigrated': '已转换 {n} 行旧署名(备份:{backup})',
|
|
89
|
+
'health.sourceMigrateNone': '没有需要转换的旧署名行',
|
|
90
|
+
'health.sourceMigrateFail': '转换失败:{error}',
|
|
83
91
|
'health.before': '处置前',
|
|
84
92
|
'health.after': '处置后',
|
|
85
93
|
'health.quarantined': '已隔离投影缓存记录(重启 DSH 后重折叠)',
|
|
@@ -112,6 +120,7 @@ export const en: Record<LocaleKey, string> = {
|
|
|
112
120
|
'card.health.desc': 'Checkup → Prescription → Discharge. When off, health routes and the tab are gone.',
|
|
113
121
|
'card.readonly': 'This session cannot edit settings (read-only mount).',
|
|
114
122
|
'card.unavailable': 'Settings service unavailable; configuration cannot be read.',
|
|
123
|
+
'family.title': 'Screwdriver plugin settings',
|
|
115
124
|
|
|
116
125
|
'panel.title': 'Session Steward',
|
|
117
126
|
'panel.tab.history': 'Retirement Home',
|
|
@@ -161,6 +170,7 @@ export const en: Record<LocaleKey, string> = {
|
|
|
161
170
|
'health.gate.projection-cache': 'Projection cache',
|
|
162
171
|
'health.gate.lossless-json': 'Lossless JSON',
|
|
163
172
|
'health.gate.cold-read': 'Continuability',
|
|
173
|
+
'health.gate.source-kind': 'Plugin signature',
|
|
164
174
|
'health.attribution': 'Owner',
|
|
165
175
|
'health.field': 'Field',
|
|
166
176
|
'health.unknownOwner': 'owner unknown',
|
|
@@ -169,6 +179,11 @@ export const en: Record<LocaleKey, string> = {
|
|
|
169
179
|
'health.phase.discharge': '3) Discharge',
|
|
170
180
|
'health.repair': 'Apply reversible repair',
|
|
171
181
|
'health.repairing': 'Repairing…',
|
|
182
|
+
'health.sourceMigrate': 'Migrate legacy signatures',
|
|
183
|
+
'health.sourceMigrating': 'Migrating…',
|
|
184
|
+
'health.sourceMigrated': 'Migrated {n} legacy signature rows (backup: {backup})',
|
|
185
|
+
'health.sourceMigrateNone': 'No legacy signature rows to migrate',
|
|
186
|
+
'health.sourceMigrateFail': 'Migration failed: {error}',
|
|
172
187
|
'health.before': 'Before',
|
|
173
188
|
'health.after': 'After',
|
|
174
189
|
'health.quarantined': 'Projection cache quarantined (refolds after a DSH restart)',
|
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
|
// 中性档,既非通过也非异常,抬升总判会把冷态会话全变成「注意」(实测噪声源)。
|
|
@@ -0,0 +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-'
|
|
@@ -27,6 +27,8 @@ export interface StewardHistoryRow {
|
|
|
27
27
|
bytes: number
|
|
28
28
|
/** 投影缓存占用字节数。 */
|
|
29
29
|
cacheBytes: number
|
|
30
|
+
/** 署名转换备份(`*.pre-sourcemigrate-<ts>`)占用字节数——`bytes` 的子集,单列防误读为日志体积。 */
|
|
31
|
+
backupBytes: number
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
/** 列表结果。 */
|
|
@@ -105,7 +107,7 @@ export async function listHistory(
|
|
|
105
107
|
}
|
|
106
108
|
const items: StewardHistoryRow[] = ids.map((sessionId) => {
|
|
107
109
|
const title = titles.get(sessionId) ?? titleFromProjectionCache(sessionId)
|
|
108
|
-
return { sessionId, title, cwd: '', updatedAt: 0, bytes: 0, cacheBytes: 0 }
|
|
110
|
+
return { sessionId, title, cwd: '', updatedAt: 0, bytes: 0, cacheBytes: 0, backupBytes: 0 }
|
|
109
111
|
})
|
|
110
112
|
// 按行的磁盘占用:**以单元为单位**算,不给一个总量糊弄(实测单条可从 0 到 23 MB)。
|
|
111
113
|
// 未提供 dshHome 时保持 0 —— 调方拿不到主目录就不猜路径。
|
|
@@ -117,6 +119,7 @@ export async function listHistory(
|
|
|
117
119
|
if (entry === undefined) continue
|
|
118
120
|
item.bytes = entry.bytes
|
|
119
121
|
item.cacheBytes = entry.cacheBytes
|
|
122
|
+
item.backupBytes = entry.backupBytes
|
|
120
123
|
}
|
|
121
124
|
} catch { /* 体积是尽力而为,读不到不影响列表本身 */ }
|
|
122
125
|
}
|
|
@@ -25,20 +25,57 @@
|
|
|
25
25
|
import { existsSync, readdirSync, rmSync, statSync } from 'node:fs'
|
|
26
26
|
import { basename, join, relative, resolve, sep } from 'node:path'
|
|
27
27
|
import { editWorkspaceDocument } from './archive-source.ts'
|
|
28
|
+
import { SOURCE_MIGRATE_BACKUP_SUFFIX } from '../health/source-kind.ts'
|
|
28
29
|
|
|
29
30
|
/** 一个归档会话在磁盘上的实体与占用。 */
|
|
30
31
|
export interface StewardSessionUsage {
|
|
31
32
|
sessionId: string
|
|
32
33
|
/** 转录目录绝对路径(不存在时缺省)。 */
|
|
33
34
|
dir?: string
|
|
34
|
-
/**
|
|
35
|
+
/** 转录目录占用字节数(**含**署名转换备份;备份单列在 `backupBytes`)。 */
|
|
35
36
|
bytes: number
|
|
37
|
+
/**
|
|
38
|
+
* 署名转换备份(`*.pre-sourcemigrate-<ts>`,见 `SOURCE_MIGRATE_BACKUP_SUFFIX`)
|
|
39
|
+
* 占用字节数 —— 是 `bytes` 的子集,单列出来免得被当作日志体积误读。
|
|
40
|
+
*/
|
|
41
|
+
backupBytes: number
|
|
42
|
+
/** 备份文件个数。 */
|
|
43
|
+
backupCount: number
|
|
36
44
|
/** 投影缓存文件绝对路径(不存在时缺省)。 */
|
|
37
45
|
cacheFile?: string
|
|
38
46
|
/** 投影缓存占用字节数。 */
|
|
39
47
|
cacheBytes: number
|
|
40
48
|
}
|
|
41
49
|
|
|
50
|
+
/** 是否署名转换备份文件(`*.pre-sourcemigrate-<ts>`)。 */
|
|
51
|
+
export function isSourceMigrateBackupName(name: string): boolean {
|
|
52
|
+
return name.includes(SOURCE_MIGRATE_BACKUP_SUFFIX)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 列出一个转录目录里的署名转换备份(只扫顶层:备份与日志同目录落盘)。
|
|
57
|
+
* @returns 备份绝对路径与合计字节数;目录不存在时为空。
|
|
58
|
+
*/
|
|
59
|
+
export function backupFilesIn(dir: string): { files: string[]; bytes: number } {
|
|
60
|
+
const files: string[] = []
|
|
61
|
+
let bytes = 0
|
|
62
|
+
let entries
|
|
63
|
+
try {
|
|
64
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
65
|
+
} catch {
|
|
66
|
+
return { files, bytes }
|
|
67
|
+
}
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
if (!entry.isFile() || !isSourceMigrateBackupName(entry.name)) continue
|
|
70
|
+
const path = join(dir, entry.name)
|
|
71
|
+
files.push(path)
|
|
72
|
+
try {
|
|
73
|
+
bytes += statSync(path).size
|
|
74
|
+
} catch { /* 竞态:文件刚被删 */ }
|
|
75
|
+
}
|
|
76
|
+
return { files, bytes }
|
|
77
|
+
}
|
|
78
|
+
|
|
42
79
|
/** 转录根目录(`<dshHome>/sessions`)。 */
|
|
43
80
|
export function sessionsRootFor(dshHome: string): string {
|
|
44
81
|
return join(dshHome, 'sessions')
|
|
@@ -136,11 +173,15 @@ export function locateSessionUsage(ids: readonly string[], dshHome: string): Map
|
|
|
136
173
|
const out = new Map<string, StewardSessionUsage>()
|
|
137
174
|
for (const sessionId of ids) {
|
|
138
175
|
const key = dirKey(sessionId)
|
|
139
|
-
const usage: StewardSessionUsage = { sessionId, bytes: 0, cacheBytes: 0 }
|
|
176
|
+
const usage: StewardSessionUsage = { sessionId, bytes: 0, backupBytes: 0, backupCount: 0, cacheBytes: 0 }
|
|
140
177
|
const dir = dirs.get(key)
|
|
141
178
|
if (dir !== undefined) {
|
|
142
179
|
usage.dir = dir
|
|
143
180
|
usage.bytes = dirSize(dir)
|
|
181
|
+
// 备份单列(是 bytes 的子集):统计路不把备份当日志体积误报,清理路据此如实上报。
|
|
182
|
+
const backups = backupFilesIn(dir)
|
|
183
|
+
usage.backupBytes = backups.bytes
|
|
184
|
+
usage.backupCount = backups.files.length
|
|
144
185
|
}
|
|
145
186
|
// 缓存文件名可能带前缀也可能不带 —— 两个候选都试。
|
|
146
187
|
for (const name of [`${sessionId}.json`, `${key}.json`]) {
|
|
@@ -178,8 +219,10 @@ export interface StewardHistoryPurgeResult {
|
|
|
178
219
|
ok: boolean
|
|
179
220
|
/** 成功清理的会话数。 */
|
|
180
221
|
purged?: number
|
|
181
|
-
/** 释放的字节数(转录 +
|
|
222
|
+
/** 释放的字节数(转录 + 投影缓存;含随目录删除的署名转换备份)。 */
|
|
182
223
|
freedBytes?: number
|
|
224
|
+
/** 一并删除的署名转换备份(`*.pre-sourcemigrate-<ts>`)个数。 */
|
|
225
|
+
backupsRemoved?: number
|
|
183
226
|
/** 逐条失败原因;不阻断其余条目。 */
|
|
184
227
|
failures?: { sessionId: string; reason: string }[]
|
|
185
228
|
requiresRestart?: boolean
|
|
@@ -266,6 +309,7 @@ export function purgeHistory(
|
|
|
266
309
|
const failures: { sessionId: string; reason: string }[] = []
|
|
267
310
|
let purged = 0
|
|
268
311
|
let freedBytes = 0
|
|
312
|
+
let backupsRemoved = 0
|
|
269
313
|
|
|
270
314
|
for (const sessionId of ids) {
|
|
271
315
|
const entry = usage.get(sessionId)
|
|
@@ -277,6 +321,8 @@ export function purgeHistory(
|
|
|
277
321
|
failures.push({ sessionId, reason: `拒绝删除越界路径:${entry.dir}` })
|
|
278
322
|
continue
|
|
279
323
|
}
|
|
324
|
+
// 整目录删除已连带备份;这里只是**先点个数**,让结果如实上报「备份一并处置了」。
|
|
325
|
+
backupsRemoved += entry.backupCount
|
|
280
326
|
try {
|
|
281
327
|
rmSync(entry.dir, { recursive: true, force: true })
|
|
282
328
|
entryFreed += entry.bytes
|
|
@@ -312,11 +358,12 @@ export function purgeHistory(
|
|
|
312
358
|
freedBytes += entryFreed
|
|
313
359
|
}
|
|
314
360
|
|
|
315
|
-
log?.(`archive purge: cleared ${purged} of ${ids.length}; freed ${(freedBytes / 1048576).toFixed(1)}MB; failures ${failures.length}`)
|
|
361
|
+
log?.(`archive purge: cleared ${purged} of ${ids.length}; freed ${(freedBytes / 1048576).toFixed(1)}MB; backups ${backupsRemoved}; failures ${failures.length}`)
|
|
316
362
|
return {
|
|
317
363
|
ok: true,
|
|
318
364
|
purged,
|
|
319
365
|
freedBytes,
|
|
366
|
+
backupsRemoved,
|
|
320
367
|
requiresRestart: true,
|
|
321
368
|
...(failures.length === 0 ? {} : { failures }),
|
|
322
369
|
}
|