dsh-config-manager 0.1.76 → 0.1.78
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/lib/adapters/plugin-data-declared.d.ts +12 -0
- package/lib/adapters/plugin-data-declared.js +57 -0
- package/lib/adapters/plugin-data.d.ts +4 -6
- package/lib/adapters/plugin-data.js +8 -36
- package/lib/adapters/plugins.d.ts +16 -0
- package/lib/adapters/plugins.js +223 -16
- package/lib/client.d.ts +104 -9
- package/lib/client.js +1800 -718
- package/lib/core/analyzer.js +3 -1
- package/lib/core/backup.d.ts +9 -0
- package/lib/core/backup.js +16 -1
- package/lib/core/messages.d.ts +11 -0
- package/lib/core/messages.js +22 -0
- package/lib/core/plugin-cli.d.ts +26 -0
- package/lib/core/plugin-cli.js +62 -2
- package/lib/core/rollback-attribution.d.ts +81 -0
- package/lib/core/rollback-attribution.js +236 -0
- package/lib/core/rollback-merge.d.ts +22 -0
- package/lib/core/rollback-merge.js +203 -0
- package/lib/core/rollback-point.d.ts +114 -0
- package/lib/core/rollback-point.js +534 -17
- package/lib/core/types.d.ts +28 -0
- package/lib/index.d.ts +26 -3
- package/lib/index.js +129 -16
- package/lib/routes/recovery.js +11 -0
- package/lib/schema/section-registry.js +5 -5
- package/lib/schema/types.d.ts +14 -0
- package/lib/ui/badge-tone.d.ts +16 -0
- package/lib/ui/badge-tone.js +20 -0
- package/lib/ui/i18n.d.ts +4 -4
- package/lib/ui/i18n.js +8 -8
- package/lib/ui/rollback-view.d.ts +95 -0
- package/lib/ui/rollback-view.js +55 -0
- package/lib/ui/selection-model.d.ts +140 -1
- package/lib/ui/selection-model.js +241 -9
- package/package.json +1 -1
- package/src/adapters/plugin-data-declared.ts +60 -0
- package/src/adapters/plugin-data.ts +7 -33
- package/src/adapters/plugins.test.ts +162 -0
- package/src/adapters/plugins.ts +236 -17
- package/src/adapters/units.test.ts +26 -0
- package/src/client/chat/ChatEntryView.tsx +147 -46
- package/src/client/chat/chat-entry-view.test.ts +277 -1
- package/src/client/chat/chat-entry-view.ts +98 -4
- package/src/client/chat/chat-node-registration.ts +71 -0
- package/src/client/chat/chat-slots.ts +30 -12
- package/src/client/common/ContentPicker.tsx +581 -175
- package/src/client/common/host-primitives.ts +64 -1
- package/src/client/common/ui.tsx +17 -3
- package/src/client/config-manager.module.css +630 -397
- package/src/client/css-scale-guard.test.ts +41 -3
- package/src/client/css-token-guard.test.ts +10 -4
- package/src/client/export/ExportView.tsx +2 -0
- package/src/client/index.ts +15 -3
- package/src/client/locales.ts +54 -10
- package/src/client/recovery/RollbackCard.tsx +56 -2
- package/src/client/recovery/RollbackDiffPane.tsx +5 -0
- package/src/client/recovery/RollbackFileChanges.tsx +46 -2
- package/src/client/recovery/recovery-locales.ts +22 -0
- package/src/client/recovery/rollback-file-changes.test.ts +1 -1
- package/src/core/analyzer.ts +3 -1
- package/src/core/backup.ts +16 -1
- package/src/core/messages.ts +22 -0
- package/src/core/plugin-cli.fs.test.ts +65 -3
- package/src/core/plugin-cli.ts +55 -2
- package/src/core/rollback-attribution.test.ts +120 -0
- package/src/core/rollback-attribution.ts +267 -0
- package/src/core/rollback-merge.test.ts +133 -0
- package/src/core/rollback-merge.ts +213 -0
- package/src/core/rollback-point.test.ts +412 -0
- package/src/core/rollback-point.ts +560 -20
- package/src/core/types.ts +29 -0
- package/src/index.facade.test.ts +60 -0
- package/src/index.ts +133 -19
- package/src/routes/recovery.ts +11 -0
- package/src/schema/section-registry.ts +5 -5
- package/src/schema/types.ts +14 -0
- package/src/ui/badge-tone.test.ts +23 -0
- package/src/ui/badge-tone.ts +27 -0
- package/src/ui/i18n.ts +8 -8
- package/src/ui/rollback-view.test.ts +27 -0
- package/src/ui/rollback-view.ts +139 -0
- package/src/ui/selection-model.test.ts +240 -2
- package/src/ui/selection-model.ts +349 -1
package/src/core/types.ts
CHANGED
|
@@ -147,6 +147,25 @@ export interface ExportUnit {
|
|
|
147
147
|
* 只下发**事实**,不下发文案 —— 界面按当前语言显示(与 DSH 同口径),绝不用 uuid 冒充标题。
|
|
148
148
|
*/
|
|
149
149
|
blank?: boolean;
|
|
150
|
+
/**
|
|
151
|
+
* 该插件**声明的第三方数据区域**(只有 \`plugin:<包名>\` 单元可能有值;导出选择器「按插件看」用)。
|
|
152
|
+
*
|
|
153
|
+
* 用途:选择器要在**不读任何数据目录**的前提下画出「插件 → 数据目录 / 大文件」的树骨架 ——
|
|
154
|
+
* 区域清单来自来源注册表(内置来源表 + 插件自述 dsh.dataDirs),是静态知识,零额外 I/O;
|
|
155
|
+
* 体积与「实际有没有内容」等该分区的清单到货后再填。
|
|
156
|
+
*
|
|
157
|
+
* 语义:**纯展示**,不参与勾选/导出契约(契约只认 id)。这里是「注册表说它应该在哪儿」,
|
|
158
|
+
* **不是**「磁盘上确实有」—— 界面必须把两者分开渲染(unread / 无内容 / 已读到)。
|
|
159
|
+
*/
|
|
160
|
+
relatedAreas?: readonly PluginAreaRef[];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 插件数据区域引用(pluginData / pluginAssets;只读展示用,不参与勾选契约)。 */
|
|
164
|
+
export interface PluginAreaRef {
|
|
165
|
+
section: 'pluginData' | 'pluginAssets';
|
|
166
|
+
/** home 相对 POSIX 路径 —— 就是该分区单元的 id 后缀(\`pluginData:.mnemon/runtime\` 的末段) */
|
|
167
|
+
relPath: string;
|
|
168
|
+
role: 'config' | 'assets';
|
|
150
169
|
}
|
|
151
170
|
|
|
152
171
|
export interface ValidationResult {
|
|
@@ -215,6 +234,16 @@ export interface PluginInfo {
|
|
|
215
234
|
inBundles?: string[];
|
|
216
235
|
/** 声明依赖 spec(profile package.json dependencies 原样);文件视图来自 readInstalled */
|
|
217
236
|
spec?: string;
|
|
237
|
+
/**
|
|
238
|
+
* 只在 `dsh.profile.bundles` 里声明、`dependencies` 里没有该包,且本 profile 的
|
|
239
|
+
* `node_modules` 里也没有它的落盘副本(issue #95)。
|
|
240
|
+
*
|
|
241
|
+
* DSH 对这类层从**自己的安装位置**解析(`boot-safety.ts` 的「@deepseek-ai/* 由 DSH 从它自己的
|
|
242
|
+
* 安装位置解析」与 `RESCUE_KEEP_BUNDLE_PREFIXES` 同一口径),因此导入端**不得**把它当
|
|
243
|
+
* registry 依赖去 `dsh plugin add`:那会把 npm dist-tag `latest` 的副本装进目标 profile,
|
|
244
|
+
* 与 host 的版本线脱节(issue #95 的真机故障)。只能把名字声明进目标机的 bundles。
|
|
245
|
+
*/
|
|
246
|
+
bundlesOnly?: boolean;
|
|
218
247
|
}
|
|
219
248
|
|
|
220
249
|
export interface PluginsFacade {
|
package/src/index.facade.test.ts
CHANGED
|
@@ -427,6 +427,66 @@ test('回退 fork:原会话自己就不在任何工作区时,按**子会话
|
|
|
427
427
|
)
|
|
428
428
|
assert.deepEqual(membersOf(project), ['someone-else', 'child-cwd'], '只对同目录的那一条调 attachSession')
|
|
429
429
|
assert.deepEqual(membersOf(otherDir), [], '不同目录的工作区一个字节都不许写')
|
|
430
|
+
|
|
431
|
+
test('回退 fork(2026-10-17 真机):子会话还没进活会话服务 ⇒ 按**落盘 header** 的 cwd 归位(与 DSH 自己的 readSessionHeader 同源回落)', async () => {
|
|
432
|
+
const project = workspaceFixture({ id: 'ws-a', path: 'D:/proj/a', sessionIds: [] })
|
|
433
|
+
const facade = new DshSessionStoreFacade(storeCtx({
|
|
434
|
+
sessionController: { fork: async () => ({ sessionId: 'child-stored' }) },
|
|
435
|
+
// 活会话服务里**没有**这个子会话(fork 刚返回、索引还没就绪)——修复前这里直接 cwd-unavailable
|
|
436
|
+
sessions: { get: () => undefined },
|
|
437
|
+
sessionPersistence: { list: async () => [{ header: { id: 'child-stored', cwd: 'D:/proj/a' } }] },
|
|
438
|
+
workspaceRegistry: { list: () => [project] },
|
|
439
|
+
}), 'C:/home/.dsh', zhMsg, silentLogger(), async (p) => canonicalPath(p))
|
|
440
|
+
|
|
441
|
+
assert.deepEqual(
|
|
442
|
+
await facade.forkSession('parent-nowhere', 5),
|
|
443
|
+
{ childSessionId: 'child-stored' },
|
|
444
|
+
'落盘 header 读得到 cwd ⇒ 必须归位(修复前报 workspace-attach-failed:cwd-unavailable ⇒ 侧栏「未分组」)',
|
|
445
|
+
)
|
|
446
|
+
assert.deepEqual(membersOf(project), ['child-stored'])
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
test('回退 fork(2026-10-17 真机):子会话 header 读不出 cwd ⇒ 用**父会话 cwd** 归位(DSH 的 fork 逐字继承父 header.cwd)', async () => {
|
|
450
|
+
const project = workspaceFixture({ id: 'ws-a', path: 'D:/proj/a', sessionIds: [] })
|
|
451
|
+
const facade = new DshSessionStoreFacade(storeCtx({
|
|
452
|
+
sessionController: { fork: async () => ({ sessionId: 'child-nocwd' }) },
|
|
453
|
+
sessions: {
|
|
454
|
+
get: (id: string) => {
|
|
455
|
+
if (id === 'child-nocwd') return { header: { id } }
|
|
456
|
+
if (id === 'parent-1') return { header: { id, cwd: 'D:/proj/a' } }
|
|
457
|
+
return undefined
|
|
458
|
+
},
|
|
459
|
+
},
|
|
460
|
+
workspaceRegistry: { list: () => [project] },
|
|
461
|
+
}), 'C:/home/.dsh', zhMsg, silentLogger(), async (p) => canonicalPath(p))
|
|
462
|
+
|
|
463
|
+
assert.deepEqual(await facade.forkSession('parent-1', 5), { childSessionId: 'child-nocwd' })
|
|
464
|
+
assert.deepEqual(membersOf(project), ['child-nocwd'], '父 cwd 与工作区同目录 ⇒ 必须归位(同源事实,不是猜测)')
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
test('回退 fork:两条 cwd 来源都拿不到 ⇒ 如实失败,并把**具体理由**落一行可查的归位诊断(桌面版拿不到宿主 stdout)', async () => {
|
|
468
|
+
const home = mkdtempSync(join(tmpdir(), 'dcm-attach-diag-'))
|
|
469
|
+
try {
|
|
470
|
+
const project = workspaceFixture({ id: 'ws-a', path: 'D:/proj/a', sessionIds: [] })
|
|
471
|
+
const facade = new DshSessionStoreFacade(storeCtx({
|
|
472
|
+
sessionController: { fork: async () => ({ sessionId: 'child-lost' }) },
|
|
473
|
+
sessions: { get: () => undefined },
|
|
474
|
+
workspaceRegistry: { list: () => [project] },
|
|
475
|
+
}), home, zhMsg, silentLogger(), async (p) => canonicalPath(p))
|
|
476
|
+
|
|
477
|
+
assert.deepEqual(await facade.forkSession('parent-nowhere', 5), {
|
|
478
|
+
childSessionId: 'child-lost',
|
|
479
|
+
warning: 'workspace-attach-failed:cwd-unavailable',
|
|
480
|
+
})
|
|
481
|
+
const log = readFileSync(join(home, 'dsh-config-manager', 'logs', 'rewind-workspace-attach.jsonl'), 'utf8')
|
|
482
|
+
assert.match(log, /"childId":"child-lost"/)
|
|
483
|
+
assert.match(log, /"reason":"cwd-unavailable"/)
|
|
484
|
+
assert.match(log, /child\(/, 'detail 必须带子会话那一侧的读取细节,光有理由码不够排查')
|
|
485
|
+
} finally {
|
|
486
|
+
rmSync(home, { recursive: true, force: true })
|
|
487
|
+
}
|
|
488
|
+
})
|
|
489
|
+
|
|
430
490
|
})
|
|
431
491
|
|
|
432
492
|
test('t10 ③ 档(修复前必红):cwd 与工作区 path 仅大小写/分隔符/短名不同 —— 由 DSH 的 resolveByPath 判同一目录后仍归位', async () => {
|
package/src/index.ts
CHANGED
|
@@ -39,6 +39,7 @@ import { randomBytes } from 'node:crypto'
|
|
|
39
39
|
import { execFile } from 'node:child_process'
|
|
40
40
|
import { createWriteStream, existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
41
41
|
import fs from 'node:fs/promises'
|
|
42
|
+
import path from 'node:path'
|
|
42
43
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
43
44
|
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
44
45
|
import { homedir } from 'node:os'
|
|
@@ -78,7 +79,8 @@ import { RunRegistry, type RunState } from './core/run-registry.ts'
|
|
|
78
79
|
import { registerModelTools } from './core/model-tools.ts'
|
|
79
80
|
import { msgOf, zhMsg } from './core/messages.ts'
|
|
80
81
|
import { engineMsg, getEngineLocale, setEngineLanguageSource, setEngineLocale } from './core/engine-locale.ts'
|
|
81
|
-
import { RollbackAutoTracker, RollbackPointEngine, rollbackSessionBindingOf, rollbackTrackedEventOf } from './core/rollback-point.ts'
|
|
82
|
+
import { RollbackAutoTracker, RollbackPointEngine, ROLLBACK_POINTS_DIR_NAME, rollbackSessionBindingOf, rollbackTrackedEventOf } from './core/rollback-point.ts'
|
|
83
|
+
import { ROLLBACK_ATTRIBUTION_FILE_NAME, RollbackWriteAttribution } from './core/rollback-attribution.ts'
|
|
82
84
|
import { isRollbackEnabled, onRollbackEnabledChange, setRollbackEnabled } from './core/rollback-gate.ts'
|
|
83
85
|
import type { RollbackForkCarrier, RollbackRewindResult } from './core/rollback-point.ts'
|
|
84
86
|
// t3:宿主斜杠命令 /rollback(与 HTTP 路由同一道写入门;命令服务惰取,未组合时安静跳过)。
|
|
@@ -179,7 +181,7 @@ export const name = 'config-manager'
|
|
|
179
181
|
export const inject = ['settings', 'credentials']
|
|
180
182
|
|
|
181
183
|
/** Plugin version, kept in sync with package.json ("version"). */
|
|
182
|
-
export const PLUGIN_VERSION = '0.1.
|
|
184
|
+
export const PLUGIN_VERSION = '0.1.78'
|
|
183
185
|
|
|
184
186
|
/** Plugin own package name — excluded from its own exported plugins list. */
|
|
185
187
|
const PLUGIN_NAME = 'dsh-config-manager'
|
|
@@ -807,12 +809,13 @@ type ListedSessions =
|
|
|
807
809
|
| { ok: false; reason: 'unavailable' | 'shape-unknown'; detail: string }
|
|
808
810
|
|
|
809
811
|
/** 列举会话存储的用途(内部枚举;渲染时必须经字典键映射 —— **绝不把裸枚举塞进文案**)。 */
|
|
810
|
-
type SessionStorePurpose = 'reindexSessionHeader' | 'parentRelations'
|
|
812
|
+
type SessionStorePurpose = 'reindexSessionHeader' | 'parentRelations' | 'rewindWorkspaceAttach'
|
|
811
813
|
|
|
812
814
|
/** 用途 → 字典键(本仓纪律:文案只做「原因 → 字典键」映射,禁止渲染裸枚举)。 */
|
|
813
815
|
const SESSION_STORE_PURPOSE_KEY: Record<SessionStorePurpose, string> = {
|
|
814
816
|
reindexSessionHeader: 'host.sessionStorePurposeReindex',
|
|
815
817
|
parentRelations: 'host.sessionStorePurposeParentRelations',
|
|
818
|
+
rewindWorkspaceAttach: 'host.sessionStorePurposeRewindAttach',
|
|
816
819
|
}
|
|
817
820
|
|
|
818
821
|
/**
|
|
@@ -897,6 +900,16 @@ export interface WorkspaceAttachFailure {
|
|
|
897
900
|
/** 一次 `attachSession` 尝试(只表达「写调用是否成功」,最终判据在 {@link DshSessionStoreFacade} 的复核里)。 */
|
|
898
901
|
type AttachAttempt = { ok: true } | { ok: false; reason: 'attach-session-missing' | 'attach-rejected'; detail?: string }
|
|
899
902
|
|
|
903
|
+
/**
|
|
904
|
+
* 会话 cwd 的读取结果(**多源**:活会话 → 宿主会话存储列举)。
|
|
905
|
+
*
|
|
906
|
+
* `source` 只用于诊断与日志(live = `ctx.get('sessions').get(id).header`;stored = `sessionPersistence.list()`),
|
|
907
|
+
* 调用方**不得**据此改变归位判据 —— 两个来源读的都是 DSH 自己写下的同一个 header.cwd。
|
|
908
|
+
*/
|
|
909
|
+
type SessionCwdLookup =
|
|
910
|
+
| { ok: true; cwd: string; source: 'live' | 'stored' }
|
|
911
|
+
| { ok: false; detail: string }
|
|
912
|
+
|
|
900
913
|
/** 一次工作区归位的结果(成功/失败都带**具体**理由)。 */
|
|
901
914
|
export type WorkspaceAttachOutcome =
|
|
902
915
|
| { ok: true; reason: WorkspaceAttachSuccessReason; workspaceId?: string }
|
|
@@ -905,6 +918,20 @@ export type WorkspaceAttachOutcome =
|
|
|
905
918
|
/** 归位失败时给调用方的 warning 串(稳定前缀 + 具体理由码)。 */
|
|
906
919
|
export const WORKSPACE_ATTACH_FAILED_PREFIX = 'workspace-attach-failed'
|
|
907
920
|
|
|
921
|
+
/**
|
|
922
|
+
* 归位失败后的重试次数与间隔(毫秒)。
|
|
923
|
+
*
|
|
924
|
+
* 为什么需要(真机 2026-10-17/10-12):fork 返回时子会话刚由 `agents.create` 建好,
|
|
925
|
+
* DSH 侧的会话索引 / 持久化列举可能还没就绪 —— 与「目录根本不存在」这类终结性失败不同,
|
|
926
|
+
* 这是**时序性**失败。归位本身幂等(`attachSession` 自查成员名单、`mutate` 空操作即不改写),
|
|
927
|
+
* 因此重试零副作用;不重试则子会话永久停在侧栏「未分组」。
|
|
928
|
+
*/
|
|
929
|
+
const WORKSPACE_ATTACH_ATTEMPTS = 2
|
|
930
|
+
const WORKSPACE_ATTACH_RETRY_DELAY_MS = 200
|
|
931
|
+
|
|
932
|
+
/** 归位诊断文件的相对路径(相对插件数据根;只在**失败**时追加一行 JSON)。 */
|
|
933
|
+
const WORKSPACE_ATTACH_DIAGNOSTIC_REL = 'logs/rewind-workspace-attach.jsonl'
|
|
934
|
+
|
|
908
935
|
/**
|
|
909
936
|
* DSH 官方 fork 在「子会话已创建、但 `workspace.attachSession` 失败」时抛的错误码。
|
|
910
937
|
*
|
|
@@ -975,6 +1002,11 @@ export class DshSessionStoreFacade implements SessionStoreFacade {
|
|
|
975
1002
|
private readonly ctx: Context
|
|
976
1003
|
/** DSH 缺省会话根(配置改过时只影响快路径命中率,慢路径仍正确) */
|
|
977
1004
|
private readonly root: string
|
|
1005
|
+
/**
|
|
1006
|
+
* 插件数据根(`<DSH_HOME>/dsh-config-manager`)—— **只**用于归位失败的诊断落盘。
|
|
1007
|
+
* 权威状态仍在 DSH(工作区注册表 / 会话存储),这里绝不旁路写任何 DSH 数据。
|
|
1008
|
+
*/
|
|
1009
|
+
private readonly dataDir: string
|
|
978
1010
|
/**
|
|
979
1011
|
* 父子关系的短 TTL 缓存(见 `PARENT_RELATIONS_CACHE_TTL_MS`;同 key 并发合并成一次列举)。
|
|
980
1012
|
* F-1 后**只缓存成功结果**:列举失败/形状识别失败会让 `parentRelations()` 抛错,
|
|
@@ -1001,6 +1033,7 @@ export class DshSessionStoreFacade implements SessionStoreFacade {
|
|
|
1001
1033
|
constructor(ctx: Context, homeDir: string, msg: MsgFunc = zhMsg, log?: Logger, realpath?: (path: string) => Promise<string>) {
|
|
1002
1034
|
this.ctx = ctx
|
|
1003
1035
|
this.root = join(homeDir, 'sessions')
|
|
1036
|
+
this.dataDir = join(homeDir, 'dsh-config-manager')
|
|
1004
1037
|
this.log = log ?? createLogger({ level: 'warn' })
|
|
1005
1038
|
this.msgFunc = msg
|
|
1006
1039
|
this.realpath = realpath ?? (async (value: string): Promise<string> => await fs.realpath(value))
|
|
@@ -1059,7 +1092,15 @@ export class DshSessionStoreFacade implements SessionStoreFacade {
|
|
|
1059
1092
|
childId = recovered
|
|
1060
1093
|
}
|
|
1061
1094
|
// 工作区归位:官方已归位 → 只复核;官方没归位(或 attach 抛错被上面救回)→ 本兜底写;写后一律复核。
|
|
1062
|
-
|
|
1095
|
+
//
|
|
1096
|
+
// 失败要重试(真机 2026-10-17):fork 返回时子会话刚建好,DSH 侧的索引/持久化列举可能还没就绪,
|
|
1097
|
+
// 属**时序性**失败;三档判定与 attach 本身幂等,重试零副作用。不重试的代价是子会话永久停在
|
|
1098
|
+
// 侧栏「未分组」(用户 2026-10-11 起的反馈)。
|
|
1099
|
+
let outcome = await this.attachChildToParentWorkspace(sessionId, childId)
|
|
1100
|
+
for (let attempt = 1; attempt < WORKSPACE_ATTACH_ATTEMPTS && !outcome.ok; attempt += 1) {
|
|
1101
|
+
await new Promise<void>((resolve) => { setTimeout(resolve, WORKSPACE_ATTACH_RETRY_DELAY_MS) })
|
|
1102
|
+
outcome = await this.attachChildToParentWorkspace(sessionId, childId)
|
|
1103
|
+
}
|
|
1063
1104
|
if (outcome.ok) {
|
|
1064
1105
|
this.log.info(
|
|
1065
1106
|
'rewind: child session filed into workspace: ' + childId + ' (' + outcome.reason + (outcome.workspaceId !== undefined ? ' ' + outcome.workspaceId : '') + ')',
|
|
@@ -1067,10 +1108,9 @@ export class DshSessionStoreFacade implements SessionStoreFacade {
|
|
|
1067
1108
|
return { childSessionId: childId }
|
|
1068
1109
|
}
|
|
1069
1110
|
// 归位失败**带具体理由**(绝不静默):日志留痕 + warning 串交给调用方如实上屏。
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
)
|
|
1111
|
+
//
|
|
1112
|
+
// 桌面版 DSH 拿不到宿主 stdout ⇒ 只有理由码还不够排查「为什么进未分组」,故再落一行诊断。
|
|
1113
|
+
await this.recordAttachDiagnostic({ parentId: sessionId, childId, outcome })
|
|
1074
1114
|
const warning = workspaceAttachWarningOf(outcome)
|
|
1075
1115
|
return warning === undefined ? { childSessionId: childId } : { childSessionId: childId, warning }
|
|
1076
1116
|
}
|
|
@@ -1139,11 +1179,25 @@ export class DshSessionStoreFacade implements SessionStoreFacade {
|
|
|
1139
1179
|
attachFailure ??= attempt
|
|
1140
1180
|
}
|
|
1141
1181
|
// ③ 同目录:cwd 与某工作区 path 指向同一目录(目录同一性由 DSH 判定,见方法注释)
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1182
|
+
//
|
|
1183
|
+
// cwd 取**多源候选**(真机 2026-10-17):旧实现只看子会话的活 header,拿不到就直接
|
|
1184
|
+
// `cwd-unavailable` ⇒ 三档全落空 ⇒ 子会话停在「未分组」。两条候选都来自 DSH 自己写下的 header,
|
|
1185
|
+
// 不是猜测:① 子会话(活 / 落盘 header);② 父会话 —— DSH 的 fork 逐字继承父 cwd
|
|
1186
|
+
// (`SessionCommandController.fork`:`meta.cwd = source.header.cwd`,见 dsh-api-session-controller)。
|
|
1187
|
+
const candidates: { cwd: string; source: string }[] = []
|
|
1188
|
+
const missing: string[] = []
|
|
1189
|
+
const childLookup = await this.sessionCwdOf(childId)
|
|
1190
|
+
if (childLookup.ok) candidates.push({ cwd: childLookup.cwd, source: 'child:' + childLookup.source })
|
|
1191
|
+
else missing.push('child(' + childLookup.detail + ')')
|
|
1192
|
+
const parentLookup = await this.sessionCwdOf(parentId)
|
|
1193
|
+
if (parentLookup.ok) {
|
|
1194
|
+
// 与已入列候选同目录时不重复(同目录再 attach 一次只是白跑一次 list())
|
|
1195
|
+
if (!candidates.some((c) => foldWorkspacePath(c.cwd) === foldWorkspacePath(parentLookup.cwd))) {
|
|
1196
|
+
candidates.push({ cwd: parentLookup.cwd, source: 'parent:' + parentLookup.source })
|
|
1197
|
+
}
|
|
1198
|
+
} else missing.push('parent(' + parentLookup.detail + ')')
|
|
1199
|
+
for (const candidate of candidates) {
|
|
1200
|
+
const resolution = await this.resolveWorkspaceByCwd(registry, records, candidate.cwd)
|
|
1147
1201
|
if (resolution.records !== undefined && resolution.records.length > 0) {
|
|
1148
1202
|
for (const record of resolution.records) {
|
|
1149
1203
|
const attempt = await this.attachChildTo(record, childId)
|
|
@@ -1151,9 +1205,14 @@ export class DshSessionStoreFacade implements SessionStoreFacade {
|
|
|
1151
1205
|
attachFailure ??= attempt
|
|
1152
1206
|
}
|
|
1153
1207
|
} else {
|
|
1154
|
-
|
|
1208
|
+
// 目录不存在 / 不属于任何工作区:按候选来源标注,便于诊断区分「读到的是谁」
|
|
1209
|
+
const failure = resolution.failure
|
|
1210
|
+
if (failure !== undefined) {
|
|
1211
|
+
cwdFailure ??= { ok: false, reason: failure.reason, detail: candidate.source + ':' + (failure.detail ?? '') }
|
|
1212
|
+
}
|
|
1155
1213
|
}
|
|
1156
1214
|
}
|
|
1215
|
+
if (candidates.length === 0) cwdFailure = { ok: false, reason: 'cwd-unavailable', detail: missing.join(';') }
|
|
1157
1216
|
return attachFailure ?? cwdFailure ?? { ok: false, reason: 'no-workspace-for-cwd' }
|
|
1158
1217
|
} catch (error) {
|
|
1159
1218
|
const detail = errorMessage(error)
|
|
@@ -1331,10 +1390,52 @@ export class DshSessionStoreFacade implements SessionStoreFacade {
|
|
|
1331
1390
|
return out
|
|
1332
1391
|
}
|
|
1333
1392
|
|
|
1334
|
-
/**
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1393
|
+
/**
|
|
1394
|
+
* 会话 cwd 的**多源**读取(活会话 → 宿主会话存储列举)。
|
|
1395
|
+
*
|
|
1396
|
+
* 为什么不能只读活会话(真机 2026-10-17 根因之一):旧实现只看 `ctx.get('sessions').get(id)`,
|
|
1397
|
+
* 拿不到就整体放弃归位 ⇒ fork 出来的子会话停在侧栏「未分组」。而 DSH 自己的读法有**磁盘回落**
|
|
1398
|
+
* (`dsh-workspace` 的 `readSessionHeader`:活会话 → `sessionPersistence.list()` 的 header 缓存),
|
|
1399
|
+
* 本方法与该回落同源,绝不猜路径、绝不拿父会话目录以外的任何东西凑数。
|
|
1400
|
+
*
|
|
1401
|
+
* 失败时返回**具体细节**(哪个来源、为什么),由调用方拼进诊断;绝不静默成 undefined。
|
|
1402
|
+
*/
|
|
1403
|
+
private async sessionCwdOf(sessionId: string): Promise<SessionCwdLookup> {
|
|
1404
|
+
const live = this.liveSessionHeader(sessionId)?.['cwd']
|
|
1405
|
+
if (typeof live === 'string' && live !== '') return { ok: true, cwd: live, source: 'live' }
|
|
1406
|
+
const store = readService<SessionListService>(this.ctx, 'sessionPersistence')
|
|
1407
|
+
if (store === undefined) return { ok: false, detail: 'no-live-cwd;session-persistence-unavailable' }
|
|
1408
|
+
const listed = await this.listSessionElements(store, 'rewindWorkspaceAttach')
|
|
1409
|
+
if (!listed.ok) return { ok: false, detail: 'no-live-cwd;' + listed.reason }
|
|
1410
|
+
const view = listed.items.find((item) => item.id === sessionId)
|
|
1411
|
+
if (view === undefined) return { ok: false, detail: 'no-live-cwd;not-in-stored-sessions' }
|
|
1412
|
+
const raw = isRecord(view.raw) ? view.raw['cwd'] : undefined
|
|
1413
|
+
if (typeof raw === 'string' && raw !== '') return { ok: true, cwd: raw, source: 'stored' }
|
|
1414
|
+
return { ok: false, detail: 'no-live-cwd;stored-header-has-no-cwd' }
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
/**
|
|
1418
|
+
* 归位失败的**诊断落盘**(一行 JSON,追加写)。
|
|
1419
|
+
*
|
|
1420
|
+
* 为什么需要:桌面版 DSH 的宿主 stdout 不落盘 ⇒ 用户界面上只有「未挂回原工作区」这句,
|
|
1421
|
+
* 具体理由(registry 拿不到 / attach 被拒 / cwd 读不到 / 复核不过)谁都看不到,
|
|
1422
|
+
* 「为什么会进未分组」于是长期不可排查。这里只记 id、理由码与路径来源,**不含任何凭据**。
|
|
1423
|
+
* 落盘失败绝不升级成回退失败(诊断是旁路,不是回退的一部分)。
|
|
1424
|
+
*/
|
|
1425
|
+
private async recordAttachDiagnostic(entry: { parentId: string; childId: string; outcome: WorkspaceAttachFailure }): Promise<void> {
|
|
1426
|
+
try {
|
|
1427
|
+
const target = join(this.dataDir, WORKSPACE_ATTACH_DIAGNOSTIC_REL)
|
|
1428
|
+
await fs.mkdir(dirname(target), { recursive: true })
|
|
1429
|
+
await fs.appendFile(target, JSON.stringify({
|
|
1430
|
+
at: new Date().toISOString(),
|
|
1431
|
+
parentId: entry.parentId,
|
|
1432
|
+
childId: entry.childId,
|
|
1433
|
+
reason: entry.outcome.reason,
|
|
1434
|
+
...(entry.outcome.detail !== undefined ? { detail: entry.outcome.detail } : {}),
|
|
1435
|
+
}) + '\n')
|
|
1436
|
+
} catch {
|
|
1437
|
+
// 诊断写不进去(只读盘 / 权限 / 配额):静默 —— 回退本身的结论不受影响。
|
|
1438
|
+
}
|
|
1338
1439
|
}
|
|
1339
1440
|
|
|
1340
1441
|
/**
|
|
@@ -4644,10 +4745,19 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
4644
4745
|
// 不为了这一处调用去改公共接口;端口上确实有 forkSession(DshSessionStoreFacade 实现)才走
|
|
4645
4746
|
// 真 fork,否则如实结构化 `session-fork-unavailable`(绝不静默降级)。
|
|
4646
4747
|
const forkPort = host.sessions as unknown as RollbackForkCarrier
|
|
4748
|
+
// 写入归属台账(2026-10-17 定案 B):只记「哪个会话的哪次**写类**工具调用写了哪个路径」。
|
|
4749
|
+
// 惰性读盘 + best-effort 落盘:读不到 / 写不进只让归属退化成「未判定」——
|
|
4750
|
+
// 既不影响留点与回退,也绝不会把「不知道」当成「别的会话写的」(见 rollback-attribution.ts)。
|
|
4751
|
+
const rollbackAttribution = new RollbackWriteAttribution({
|
|
4752
|
+
file: path.join(dataDir, ROLLBACK_POINTS_DIR_NAME, ROLLBACK_ATTRIBUTION_FILE_NAME),
|
|
4753
|
+
warn: (line) => host.log.warn(line),
|
|
4754
|
+
})
|
|
4755
|
+
void rollbackAttribution.load()
|
|
4647
4756
|
const rollbackEngine = new RollbackPointEngine({
|
|
4648
4757
|
dataDir,
|
|
4649
4758
|
homeDir,
|
|
4650
4759
|
log: (line) => host.log.warn(line),
|
|
4760
|
+
attribution: rollbackAttribution,
|
|
4651
4761
|
fork: async ({ sessionId, boundarySeq }) => {
|
|
4652
4762
|
const forkSession = forkPort.forkSession
|
|
4653
4763
|
if (typeof forkSession !== 'function') {
|
|
@@ -4666,7 +4776,11 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
4666
4776
|
return { ok: false as const, code: 'fork-failed' as const, detail: result.error }
|
|
4667
4777
|
},
|
|
4668
4778
|
})
|
|
4669
|
-
const rollbackTracker = new RollbackAutoTracker({
|
|
4779
|
+
const rollbackTracker = new RollbackAutoTracker({
|
|
4780
|
+
engine: rollbackEngine,
|
|
4781
|
+
log: (line) => host.log.warn(line),
|
|
4782
|
+
attribution: rollbackAttribution,
|
|
4783
|
+
})
|
|
4670
4784
|
ctx.effect(() => {
|
|
4671
4785
|
let chain: Promise<unknown> = Promise.resolve()
|
|
4672
4786
|
// 「会话没有 cwd」这类**形状合法但信息不全**的情况只记一次日志(避免每回合刷屏);
|
package/src/routes/recovery.ts
CHANGED
|
@@ -733,6 +733,17 @@ export function recoveryRoutes(env: RoutesEnv): WebRoute[] {
|
|
|
733
733
|
// A(用户 2026-10-11 定案):「回到某点之前」要连文件一起退回去 —— 默认把**回退点之后新建**的路径删掉;
|
|
734
734
|
// 调用方显式传 false 才保留老口径(只报告 leftovers,不删任何东西)。
|
|
735
735
|
rewindInput.removeCreated = body['removeCreated'] !== false
|
|
736
|
+
// 路径级选择性恢复(2026-10-17 定案 A):缺省 = 全量口径(一字不变)。
|
|
737
|
+
// 给了数组就只恢复这些路径;**空数组**视为无效输入(400)—— 「一个都没勾」应当由
|
|
738
|
+
// 界面拦住,而不是让引擎把整次回退悄悄变成 noop。
|
|
739
|
+
if (body['onlyPaths'] !== undefined) {
|
|
740
|
+
const raw = body['onlyPaths']
|
|
741
|
+
if (!Array.isArray(raw) || raw.length === 0 || raw.some((item) => typeof item !== 'string' || item === '')) {
|
|
742
|
+
writeJson(res, 400, { ok: false, code: 'invalid-input', error: 'onlyPaths must be a non-empty array of non-empty strings' })
|
|
743
|
+
return
|
|
744
|
+
}
|
|
745
|
+
rewindInput.onlyPaths = raw as string[]
|
|
746
|
+
}
|
|
736
747
|
if (body['preRewindGuard'] === 'off' || body['preRewindGuard'] === 'require' || body['preRewindGuard'] === 'warn') {
|
|
737
748
|
rewindInput.guardPolicy = body['preRewindGuard']
|
|
738
749
|
}
|
|
@@ -227,7 +227,7 @@ export const SECTION_REGISTRY: Record<SectionId, SectionMeta> = {
|
|
|
227
227
|
},
|
|
228
228
|
plugins: {
|
|
229
229
|
id: 'plugins',
|
|
230
|
-
displayName: '
|
|
230
|
+
displayName: 'Plugin list',
|
|
231
231
|
group: 'extensions',
|
|
232
232
|
payload: {
|
|
233
233
|
kind: 'array', zipPath: 'plugins/plugins.json', key: 'plugins',
|
|
@@ -324,7 +324,7 @@ export const SECTION_REGISTRY: Record<SectionId, SectionMeta> = {
|
|
|
324
324
|
},
|
|
325
325
|
pluginFiles: {
|
|
326
326
|
id: 'pluginFiles',
|
|
327
|
-
displayName:
|
|
327
|
+
displayName: 'Plugin config files',
|
|
328
328
|
group: 'extensions',
|
|
329
329
|
payload: { kind: 'files', filePrefix: 'plugin-files/' },
|
|
330
330
|
dataVersion: SECTION_DATA_VERSION,
|
|
@@ -377,7 +377,7 @@ export const SECTION_REGISTRY: Record<SectionId, SectionMeta> = {
|
|
|
377
377
|
},
|
|
378
378
|
self: {
|
|
379
379
|
id: 'self',
|
|
380
|
-
displayName:
|
|
380
|
+
displayName: 'Config Manager settings',
|
|
381
381
|
group: 'extensions',
|
|
382
382
|
payload: { kind: 'files', filePrefix: 'self/' },
|
|
383
383
|
dataVersion: SECTION_DATA_VERSION,
|
|
@@ -390,7 +390,7 @@ export const SECTION_REGISTRY: Record<SectionId, SectionMeta> = {
|
|
|
390
390
|
},
|
|
391
391
|
pluginData: {
|
|
392
392
|
id: 'pluginData',
|
|
393
|
-
displayName:
|
|
393
|
+
displayName: 'Plugin data dirs',
|
|
394
394
|
group: 'extensions',
|
|
395
395
|
payload: { kind: 'files', filePrefix: 'plugin-data/' },
|
|
396
396
|
dataVersion: SECTION_DATA_VERSION,
|
|
@@ -406,7 +406,7 @@ export const SECTION_REGISTRY: Record<SectionId, SectionMeta> = {
|
|
|
406
406
|
},
|
|
407
407
|
pluginAssets: {
|
|
408
408
|
id: 'pluginAssets',
|
|
409
|
-
displayName:
|
|
409
|
+
displayName: 'Plugin large files',
|
|
410
410
|
group: 'extensions',
|
|
411
411
|
payload: { kind: 'files', filePrefix: 'plugin-assets/' },
|
|
412
412
|
dataVersion: SECTION_DATA_VERSION,
|
package/src/schema/types.ts
CHANGED
|
@@ -113,6 +113,20 @@ export interface PluginEntry {
|
|
|
113
113
|
* 否则按裸包名走 npm 最新版(官方机制)。
|
|
114
114
|
*/
|
|
115
115
|
spec?: string;
|
|
116
|
+
/**
|
|
117
|
+
* 该层只在 profile 的 `dsh.profile.bundles` 里声明、`dependencies` 里没有它,且该 profile 的
|
|
118
|
+
* `node_modules` 里也**没有**它的落盘副本(issue #95,导出侧写入)。
|
|
119
|
+
*
|
|
120
|
+
* 语义:这不是一个「装在本 profile 里的包」,而是由 DSH 从**自己的安装位置**解析的层
|
|
121
|
+
* (与 `boot-safety.ts` / `RESCUE_KEEP_BUNDLE_PREFIXES` 同口径)。导入端对这类条目
|
|
122
|
+
* **只把名字声明进目标机的 `dsh.profile.bundles`,绝不走 npm 安装通道**——按裸包名安装会让
|
|
123
|
+
* 目标机拿到 `dist-tags.latest` 的副本(2026-10 真机:host 0.2.1-rc.2 + 包 0.2.1-alpha.2
|
|
124
|
+
* → session-controller 一直 pending → 新建会话 service-unavailable)。
|
|
125
|
+
*
|
|
126
|
+
* 缺省(旧备份没有该字段)= 由 `spec` / `version` 形状推断,见
|
|
127
|
+
* `src/core/plugin-cli.ts` 的 `isBundlesOnlyEntry`。
|
|
128
|
+
*/
|
|
129
|
+
bundlesOnly?: boolean;
|
|
116
130
|
isBundle: boolean;
|
|
117
131
|
inBundles: string[];
|
|
118
132
|
enabled: boolean;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* badge-tone 单测:四态 → 原生 Tag tone 的映射必须稳定(React 壳只装配,语义在这一层可测)。
|
|
3
|
+
*/
|
|
4
|
+
import test from 'node:test'
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
import { BADGE_TONE, badgeTone, type BadgeKind } from './badge-tone.ts'
|
|
7
|
+
|
|
8
|
+
test('badge-tone:四态各自映射到原生 Tag 的 tone', () => {
|
|
9
|
+
assert.equal(badgeTone('ok'), 'success')
|
|
10
|
+
assert.equal(badgeTone('warn'), 'warning')
|
|
11
|
+
assert.equal(badgeTone('error'), 'danger')
|
|
12
|
+
assert.equal(badgeTone('info'), 'info')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('badge-tone:映射表覆盖全部四态且 tone 都是原生调色板里的值', () => {
|
|
16
|
+
const kinds: BadgeKind[] = ['info', 'ok', 'warn', 'error']
|
|
17
|
+
const palette = new Set(['outline', 'solid', 'neutral', 'quiet', 'success', 'info', 'warning', 'danger'])
|
|
18
|
+
for (const k of kinds) {
|
|
19
|
+
assert.ok(BADGE_TONE[k] !== undefined, k + ' 缺少映射')
|
|
20
|
+
assert.ok(palette.has(BADGE_TONE[k]), BADGE_TONE[k] + ' 不在原生 Tag 调色板内')
|
|
21
|
+
}
|
|
22
|
+
assert.equal(Object.keys(BADGE_TONE).length, kinds.length)
|
|
23
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 徽章四态 → DSH 原生 Tag tone 的映射(**唯一一份**)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独成模块:徽章现在优先渲染 DSH 自带的 `Tag`(@deepseek-ai/dsh-client-ui-primitives,
|
|
5
|
+
* 与宿主同一颗组件),它只有 tone 语义、没有 kind 语义;映射关系是纯函数,放这里可被 node 单测覆盖,
|
|
6
|
+
* 也让 React 壳保持「只装配」。老宿主拿不到原生包时回落本仓库的 `.badge[data-kind]` 样式,
|
|
7
|
+
* 两条路径共用同一套 kind → tone 语义,不允许各自漂移。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** 插件侧的四态徽章语义。 */
|
|
11
|
+
export type BadgeKind = 'info' | 'ok' | 'warn' | 'error'
|
|
12
|
+
|
|
13
|
+
/** DSH 原生 Tag 的调色板选项(与其 .d.ts 逐字同形)。 */
|
|
14
|
+
export type DshTagTone = 'outline' | 'solid' | 'neutral' | 'quiet' | 'success' | 'info' | 'warning' | 'danger'
|
|
15
|
+
|
|
16
|
+
/** 四态 → 原生 tone(ok=success / warn=warning / error=danger / info=info)。 */
|
|
17
|
+
export const BADGE_TONE: Readonly<Record<BadgeKind, DshTagTone>> = {
|
|
18
|
+
ok: 'success',
|
|
19
|
+
info: 'info',
|
|
20
|
+
warn: 'warning',
|
|
21
|
+
error: 'danger',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 取某个四态对应的原生 tone;未知输入按 info 处理(调用方给的是字面量联合,此为防御)。 */
|
|
25
|
+
export function badgeTone(kind: BadgeKind): DshTagTone {
|
|
26
|
+
return BADGE_TONE[kind] ?? 'info'
|
|
27
|
+
}
|
package/src/ui/i18n.ts
CHANGED
|
@@ -548,10 +548,10 @@ export const uiZh = {
|
|
|
548
548
|
'export.category.settings': 'DSH 全局设置(namespace 分区,redacted)',
|
|
549
549
|
'export.category.providers': 'LLM Provider / Model / 默认模型 / BaseURL',
|
|
550
550
|
'export.category.plugins': '已安装插件清单与启用状态(不含二进制)',
|
|
551
|
-
'export.category.pluginFiles': '
|
|
552
|
-
'export.category.self': '
|
|
553
|
-
'export.category.pluginData': '
|
|
554
|
-
'export.category.pluginAssets': '
|
|
551
|
+
'export.category.pluginFiles': '插件放在 ~/.dsh 根下的配置文件(白名单 + plugin-config/ 目录,整文件复制)',
|
|
552
|
+
'export.category.self': '配置管理器自身的设置(同步/自动同步/分区选择/UI 偏好/市场;sync-*.json 等,不含凭据值)',
|
|
553
|
+
'export.category.pluginData': '插件在系统主目录下的数据(如 ~/.dsh-wallpaper-engine、~/.mnemon 的配置与状态;可再生成缓存绝不收录)',
|
|
554
|
+
'export.category.pluginAssets': '插件的大体积文件(上传的壁纸、本地数据库等;默认不勾选)',
|
|
555
555
|
'export.category.mcp': 'MCP 服务器组合配置(需重启生效)',
|
|
556
556
|
'export.category.prompts': 'System Prompt / Plan Mode 提示',
|
|
557
557
|
'export.category.skills': '用户技能文件(~/.dsh/skills)',
|
|
@@ -1097,10 +1097,10 @@ export const uiEn: Record<UiTextKey, string> = {
|
|
|
1097
1097
|
'export.category.settings': 'Global DSH settings (namespace sections, redacted)',
|
|
1098
1098
|
'export.category.providers': 'LLM providers / models / default model / base URL',
|
|
1099
1099
|
'export.category.plugins': 'Installed plugin list and enablement (binaries excluded)',
|
|
1100
|
-
'export.category.pluginFiles': "
|
|
1101
|
-
'export.category.self': "
|
|
1102
|
-
'export.category.pluginData': "
|
|
1103
|
-
'export.category.pluginAssets': "
|
|
1100
|
+
'export.category.pluginFiles': "Plugin config files at the ~/.dsh root (allowlist + plugin-config/, copied whole)",
|
|
1101
|
+
'export.category.self': "Config Manager's own settings (sync / autosync / section selection / UI prefs / market; sync-*.json etc., no credential values)",
|
|
1102
|
+
'export.category.pluginData': "Plugin data under the system home directory (e.g. the config/state of ~/.dsh-wallpaper-engine and ~/.mnemon; regenerable caches are never included)",
|
|
1103
|
+
'export.category.pluginAssets': "Plugins' large files (uploaded wallpapers, local databases, ...; not selected by default)",
|
|
1104
1104
|
'export.category.mcp': 'MCP server composition (restart required)',
|
|
1105
1105
|
'export.category.prompts': 'System prompt / Plan Mode prompts',
|
|
1106
1106
|
'export.category.skills': 'Your skill files (~/.dsh/skills)',
|
|
@@ -834,3 +834,30 @@ test('issue #88:rollbackEntryVisible —— false 收起 / true 显示 / 缺
|
|
|
834
834
|
})
|
|
835
835
|
|
|
836
836
|
|
|
837
|
+
|
|
838
|
+
/* ------------------------------------------------------------------ A+B:逐文件选择的界面模型 */
|
|
839
|
+
|
|
840
|
+
test('A/B 选择模型:默认排除别的对话改过的文件;全选时不传 onlyPaths', async () => {
|
|
841
|
+
const { rollbackSelectionRows, rollbackDefaultSelection, rollbackOnlyPaths, rollbackSelectionBlocksSubmit } = await import('./rollback-view.ts')
|
|
842
|
+
const preview = {
|
|
843
|
+
ok: true, id: 'rp-1', storage: { available: true },
|
|
844
|
+
workspace: {
|
|
845
|
+
files: [
|
|
846
|
+
{ path: 'C:/ws/a.txt', status: 'overwrite', added: 1, removed: 1, truncated: false },
|
|
847
|
+
{ path: 'C:/ws/b.txt', status: 'overwrite', added: null, removed: null, truncated: true, foreign: true, foreignSessionId: 'session-x' },
|
|
848
|
+
],
|
|
849
|
+
},
|
|
850
|
+
} as unknown as import('./rollback-view.ts').RollbackPreviewResult
|
|
851
|
+
const rows = rollbackSelectionRows(preview)
|
|
852
|
+
assert.equal(rows.length, 2)
|
|
853
|
+
assert.equal(rows[1]?.foreign, true)
|
|
854
|
+
assert.equal(rows[1]?.foreignSessionId, 'session-x')
|
|
855
|
+
const fallback = rollbackDefaultSelection(rows)
|
|
856
|
+
assert.deepEqual(fallback, ['C:/ws/a.txt'], '默认只勾非 foreign 的文件')
|
|
857
|
+
assert.deepEqual(rollbackOnlyPaths(rows, fallback), ['C:/ws/a.txt'])
|
|
858
|
+
assert.equal(rollbackOnlyPaths(rows, rows.map((row) => row.path)), null, '全选 ⇒ null(不传字段,走既有全量口径)')
|
|
859
|
+
assert.equal(rollbackSelectionBlocksSubmit(rows, []), true, '一个都没勾必须拦住(宿主侧空数组是 400)')
|
|
860
|
+
assert.equal(rollbackSelectionBlocksSubmit(rows, fallback), false)
|
|
861
|
+
assert.deepEqual(rollbackSelectionRows(null), [])
|
|
862
|
+
assert.deepEqual(rollbackSelectionRows({ workspace: { files: 'nope' } } as unknown as import('./rollback-view.ts').RollbackPreviewResult), [])
|
|
863
|
+
})
|