dsh-config-manager 0.1.72 → 0.1.76
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/README.md +40 -4
- package/README.zh-CN.md +40 -4
- package/lib/adapters/index.d.ts +1 -0
- package/lib/adapters/index.js +5 -0
- package/lib/adapters/messages.d.ts +3 -0
- package/lib/adapters/messages.js +7 -0
- package/lib/adapters/plugin-data.d.ts +114 -0
- package/lib/adapters/plugin-data.js +309 -0
- package/lib/adapters/self.d.ts +1 -1
- package/lib/adapters/self.js +2 -1
- package/lib/adapters/test-helpers.d.ts +9 -0
- package/lib/adapters/test-helpers.js +14 -0
- package/lib/client.d.ts +103 -10
- package/lib/client.js +10748 -10119
- package/lib/core/analyzer.d.ts +11 -2
- package/lib/core/analyzer.js +31 -8
- package/lib/core/backup-plan.d.ts +10 -0
- package/lib/core/backup-plan.js +21 -3
- package/lib/core/backup.d.ts +20 -1
- package/lib/core/backup.js +48 -3
- package/lib/core/command-rollback.d.ts +6 -2
- package/lib/core/command-rollback.js +6 -2
- package/lib/core/messages-core.d.ts +2 -0
- package/lib/core/messages-core.js +4 -0
- package/lib/core/messages.d.ts +2 -1
- package/lib/core/messages.js +4 -2
- package/lib/core/plugin-data-registry.d.ts +124 -0
- package/lib/core/plugin-data-registry.js +198 -0
- package/lib/core/rollback-gate.d.ts +33 -0
- package/lib/core/rollback-gate.js +57 -0
- package/lib/core/rollback-ledger.d.ts +274 -0
- package/lib/core/rollback-ledger.js +1400 -0
- package/lib/core/rollback-point.d.ts +406 -5
- package/lib/core/rollback-point.js +1453 -306
- package/lib/core/rollback.js +6 -4
- package/lib/core/types.d.ts +19 -0
- package/lib/foreign/registry.d.ts +1 -1
- package/lib/foreign/registry.js +1 -1
- package/lib/index.d.ts +5 -1
- package/lib/index.js +83 -13
- package/lib/routes/messages.d.ts +1 -0
- package/lib/routes/messages.js +2 -0
- package/lib/routes/prefs.js +26 -3
- package/lib/routes/recovery.d.ts +21 -0
- package/lib/routes/recovery.js +127 -7
- package/lib/routes/sync.js +11 -1
- package/lib/schema/section-registry.d.ts +1 -1
- package/lib/schema/section-registry.js +32 -3
- package/lib/schema/types.d.ts +1 -1
- package/lib/sync/messages.d.ts +1 -0
- package/lib/sync/messages.js +3 -0
- package/lib/sync/sync-engine.d.ts +21 -7
- package/lib/sync/sync-engine.js +108 -18
- package/lib/sync/ui-prefs.d.ts +22 -0
- package/lib/sync/ui-prefs.js +28 -0
- package/lib/ui/diff-view.d.ts +10 -0
- package/lib/ui/diff-view.js +17 -0
- package/lib/ui/export-flow.js +2 -0
- package/lib/ui/i18n.d.ts +5 -3
- package/lib/ui/i18n.js +10 -6
- package/lib/ui/rollback-view.d.ts +59 -0
- package/lib/ui/rollback-view.js +14 -0
- package/lib/ui/test-helpers.js +2 -1
- package/lib/utils/bundle-scan.d.ts +5 -3
- package/lib/utils/bundle-scan.js +5 -3
- package/lib/utils/line-diff.d.ts +7 -1
- package/lib/utils/line-diff.js +8 -2
- package/package.json +1 -1
- package/src/adapters/index.ts +5 -0
- package/src/adapters/messages.ts +7 -0
- package/src/adapters/plugin-data.test.ts +225 -0
- package/src/adapters/plugin-data.ts +351 -0
- package/src/adapters/self.ts +2 -1
- package/src/adapters/test-helpers.ts +15 -0
- package/src/client/chat/ChatEntryView.tsx +59 -10
- package/src/client/chat/RollbackDiffTab.tsx +49 -2
- package/src/client/chat/RollbackFileIcon.tsx +7 -45
- package/src/client/chat/RollbackReviewDialog.tsx +20 -4
- package/src/client/chat/chat-entry-view.test.ts +164 -1
- package/src/client/chat/chat-entry-view.ts +127 -10
- package/src/client/chat/chat-node-registration.ts +40 -6
- package/src/client/chat/chat-point-window.ts +11 -1
- package/src/client/chat/rollback-diff-tab.ts +13 -3
- package/src/client/chat/rollback-entry-view.ts +5 -0
- package/src/client/chat/sidebar-slots.ts +24 -2
- package/src/client/common/DiffTable.tsx +54 -29
- package/src/client/common/client-route-parity.test.ts +6 -0
- package/src/client/common/host-primitives.ts +78 -0
- package/src/client/common/routes.ts +10 -0
- package/src/client/common/section-labels.test.ts +1 -0
- package/src/client/common/section-labels.ts +2 -0
- package/src/client/config-manager.module.css +138 -14
- package/src/client/environment/maintenance/DiskUsageCard.tsx +20 -3
- package/src/client/index.ts +54 -2
- package/src/client/library/RestorePlanView.tsx +0 -1
- package/src/client/locales.ts +8 -6
- package/src/client/recovery/RollbackCard.tsx +201 -7
- package/src/client/recovery/RollbackDiffPane.tsx +26 -8
- package/src/client/recovery/recovery-api.ts +28 -0
- package/src/client/recovery/recovery-locales.ts +36 -6
- package/src/client/recovery/rollback-enabled.ts +41 -0
- package/src/client/recovery/rollback-file-changes.test.ts +77 -0
- package/src/client/sync/PushPreviewCard.tsx +57 -0
- package/src/client/sync/SyncPanel.tsx +2 -37
- package/src/client/sync/SyncSettingsView.test.ts +3 -0
- package/src/client/sync/sync-push-preview.test.ts +14 -0
- package/src/client/sync/sync-view.test.ts +1 -1
- package/src/client/sync/sync-view.ts +9 -1
- package/src/core/analyzer.ts +31 -9
- package/src/core/backup-plan.test.ts +20 -5
- package/src/core/backup-plan.ts +22 -3
- package/src/core/backup.ts +50 -4
- package/src/core/command-rollback.ts +6 -2
- package/src/core/messages-core.ts +4 -0
- package/src/core/messages.ts +4 -2
- package/src/core/plugin-data-registry.test.ts +113 -0
- package/src/core/plugin-data-registry.ts +261 -0
- package/src/core/rollback-gate.test.ts +66 -0
- package/src/core/rollback-gate.ts +61 -0
- package/src/core/rollback-ledger.test.ts +655 -0
- package/src/core/rollback-ledger.ts +1430 -0
- package/src/core/rollback-point.test.ts +866 -41
- package/src/core/rollback-point.ts +1582 -291
- package/src/core/rollback.ts +5 -3
- package/src/core/types.ts +19 -0
- package/src/foreign/registry.test.ts +1 -1
- package/src/foreign/registry.ts +1 -1
- package/src/index.ts +73 -10
- package/src/routes/messages.ts +2 -0
- package/src/routes/prefs.ts +31 -2
- package/src/routes/recovery.ts +122 -7
- package/src/routes/sync.ts +15 -1
- package/src/schema/manifest-schema.test.ts +4 -4
- package/src/schema/registry.test.ts +9 -2
- package/src/schema/section-registry.ts +32 -3
- package/src/schema/types.ts +2 -1
- package/src/sync/messages.ts +3 -0
- package/src/sync/sync-credentials.test.ts +119 -0
- package/src/sync/sync-engine.ts +106 -18
- package/src/sync/sync-preview-push.test.ts +158 -0
- package/src/sync/transport.test.ts +1 -0
- package/src/sync/ui-prefs.test.ts +44 -1
- package/src/sync/ui-prefs.ts +39 -0
- package/src/ui/diff-view.test.ts +9 -1
- package/src/ui/diff-view.ts +22 -0
- package/src/ui/export-flow.ts +2 -0
- package/src/ui/i18n.ts +10 -6
- package/src/ui/rollback-view.test.ts +17 -0
- package/src/ui/rollback-view.ts +66 -0
- package/src/ui/test-helpers.ts +2 -1
- package/src/utils/bundle-scan.ts +5 -3
- package/src/utils/line-diff.test.ts +12 -0
- package/src/utils/line-diff.ts +9 -3
package/src/core/rollback.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* 如实标记 failed + manualHint(人工补录);
|
|
8
8
|
* - settings 回滚仍走 expectedRevision 乐观锁,避免覆盖导入后用户的新修改。
|
|
9
9
|
*/
|
|
10
|
-
import { resolveFileTarget } from './backup.ts';
|
|
10
|
+
import { fsForSection, resolveFileTarget } from './backup.ts';
|
|
11
11
|
import { isPatchLayerRel, USER_PATCH_FILE } from './patch-layers.ts';
|
|
12
12
|
import { msgOf } from './messages.ts';
|
|
13
13
|
import type {
|
|
@@ -88,13 +88,15 @@ async function compensateOne(
|
|
|
88
88
|
}
|
|
89
89
|
case 'file': {
|
|
90
90
|
try {
|
|
91
|
+
// 门面按分区选(pluginData / pluginAssets 在系统 home 根;见 backup.ts 的 fsForSection)
|
|
92
|
+
const fsFacade = fsForSection(ctx, entry.adapter);
|
|
91
93
|
const abs = resolveFileTarget(ctx, entry.adapter, entry.ref);
|
|
92
94
|
if (entry.existed && entry.copiedTo && store) {
|
|
93
95
|
const data = await store.readBlob(entry.snapshotId ?? '', entry.copiedTo);
|
|
94
|
-
await
|
|
96
|
+
await fsFacade.writeFile(abs, data);
|
|
95
97
|
} else if (!entry.existed) {
|
|
96
98
|
// 原文件不存在 → 删除导入写入的文件
|
|
97
|
-
if (await
|
|
99
|
+
if (await fsFacade.exists(abs)) await fsFacade.remove(abs);
|
|
98
100
|
}
|
|
99
101
|
return null;
|
|
100
102
|
} catch (err) {
|
package/src/core/types.ts
CHANGED
|
@@ -525,6 +525,25 @@ export interface HostContext {
|
|
|
525
525
|
workspace: WorkspaceFacade;
|
|
526
526
|
patchFile: PatchFileFacade;
|
|
527
527
|
fs: FileSystemFacade;
|
|
528
|
+
/**
|
|
529
|
+
* **系统 home 的绝对路径**(宿主解析;缺省 = 进程 `homedir()`)。
|
|
530
|
+
*
|
|
531
|
+
* 为什么需要:一部分第三方插件把数据放在 **$DSH_HOME 之外**(`~/.dsh-wallpaper-engine/`、
|
|
532
|
+
* `~/.mnemon/`、`~/.fastctx/` …),而 `fs` 被**限制在 $DSH_HOME 内**(越界即抛
|
|
533
|
+
* `host.fsPathEscape`)。`pluginData` / `pluginAssets` 两个分区的 ref 因此以**系统 home 为根**
|
|
534
|
+
* (见 `core/backup.ts` 的 `OS_HOME_FILE_SECTIONS`)。
|
|
535
|
+
*
|
|
536
|
+
* 判定纪律:**路径计算**用 `ctx.osHomeDir ?? homedir()`(`~` 的定义就是它,不是猜);
|
|
537
|
+
* **真正的读写**必须经 `homeFs`,缺了它一律显式失败 —— 绝不退回 $DSH_HOME 写错位置。
|
|
538
|
+
*/
|
|
539
|
+
osHomeDir?: string;
|
|
540
|
+
/**
|
|
541
|
+
* 以**系统 home 为根**的文件门面(与 `fs` 同一套方法、同样越界即抛;缺省 undefined)。
|
|
542
|
+
*
|
|
543
|
+
* 只允许 `pluginData` / `pluginAssets` 使用;其余分区一律走 `fs`($DSH_HOME 内)。
|
|
544
|
+
* 缺省 = 宿主未提供(旧宿主)→ 这两个分区**不收集也不写入**,由 adapter 如实报码。
|
|
545
|
+
*/
|
|
546
|
+
homeFs?: FileSystemFacade;
|
|
528
547
|
/**
|
|
529
548
|
* 会话存储端口(issue #45 会话归位;宿主注入对应 `ctx.sessionPersistence`)。
|
|
530
549
|
*
|
|
@@ -207,7 +207,7 @@ test('契约:外来源单元 id → foreign:<source>:<section>:<unit>,且不
|
|
|
207
207
|
test('契约:外来源前缀与 SectionId 无重叠(按构造互斥,不是靠约定)', () => {
|
|
208
208
|
assert.equal(FOREIGN_UNIT_ID_PREFIX, 'foreign');
|
|
209
209
|
assert.ok(!DSH_UNIT_ID_NAMESPACES.includes(FOREIGN_UNIT_ID_PREFIX));
|
|
210
|
-
//
|
|
210
|
+
// 每个 SectionId 都不能等于 foreign——用真实 DSH 形态反证
|
|
211
211
|
for (const { ns } of DSH_UNIT_IDS) {
|
|
212
212
|
assert.notEqual(ns, FOREIGN_UNIT_ID_PREFIX, ns);
|
|
213
213
|
}
|
package/src/foreign/registry.ts
CHANGED
|
@@ -336,7 +336,7 @@ export function isForeignUnitId(id: string): boolean {
|
|
|
336
336
|
}
|
|
337
337
|
|
|
338
338
|
/**
|
|
339
|
-
* DSH 既有单元 id 的命名空间:分区 id(
|
|
339
|
+
* DSH 既有单元 id 的命名空间:分区 id(17 个 SectionId)或 plugin/patch/workspace;
|
|
340
340
|
* 外来源形态(首段 = foreign)与无法识别的前缀一律 **null**。
|
|
341
341
|
*
|
|
342
342
|
* 两个方向都不误判是这条契约的核心(§8.3):`foreign` 不在 SectionId 词表里、也不等于
|
package/src/index.ts
CHANGED
|
@@ -41,6 +41,7 @@ import { createWriteStream, existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
|
41
41
|
import fs from 'node:fs/promises'
|
|
42
42
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
43
43
|
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
44
|
+
import { homedir } from 'node:os'
|
|
44
45
|
import { promisify } from 'node:util'
|
|
45
46
|
|
|
46
47
|
import type { Context } from '@deepseek-ai/cordis'
|
|
@@ -78,6 +79,7 @@ import { registerModelTools } from './core/model-tools.ts'
|
|
|
78
79
|
import { msgOf, zhMsg } from './core/messages.ts'
|
|
79
80
|
import { engineMsg, getEngineLocale, setEngineLanguageSource, setEngineLocale } from './core/engine-locale.ts'
|
|
80
81
|
import { RollbackAutoTracker, RollbackPointEngine, rollbackSessionBindingOf, rollbackTrackedEventOf } from './core/rollback-point.ts'
|
|
82
|
+
import { isRollbackEnabled, onRollbackEnabledChange, setRollbackEnabled } from './core/rollback-gate.ts'
|
|
81
83
|
import type { RollbackForkCarrier, RollbackRewindResult } from './core/rollback-point.ts'
|
|
82
84
|
// t3:宿主斜杠命令 /rollback(与 HTTP 路由同一道写入门;命令服务惰取,未组合时安静跳过)。
|
|
83
85
|
import { agentSessionFactsOf, registerRollbackCommand } from './core/command-rollback.ts'
|
|
@@ -122,6 +124,7 @@ import { SyncSessionStore } from './sync/sync-session.ts'
|
|
|
122
124
|
import { AutoSyncScheduler } from './sync/autosync-scheduler.ts'
|
|
123
125
|
import { BackupScheduler } from './sync/backup-scheduler.ts'
|
|
124
126
|
import { readBackupSchedule } from './sync/backup-schedule-config.ts'
|
|
127
|
+
import { readRollbackEnabledSync } from './sync/ui-prefs.ts'
|
|
125
128
|
import { AUTO_BACKUP_PREFIX, isValidExportFileName, resolveNonCollidingExportName, writeBackupNote } from './sync/backup-files.ts'
|
|
126
129
|
import { DEFAULT_RETENTION_POLICY, selectPruneCandidatesByPolicy } from './sync/retention-policy.ts'
|
|
127
130
|
import type { RetentionPolicy } from './sync/retention-policy.ts'
|
|
@@ -164,6 +167,8 @@ import { parseZip } from './utils/zip.ts'
|
|
|
164
167
|
import { isSameOrChild } from './utils/paths.ts'
|
|
165
168
|
import { isRecord } from './utils/guards.ts'
|
|
166
169
|
import { createLogger, parseLogLevel, type Logger } from './utils/logger.ts'
|
|
170
|
+
// 与 HTTP 路径**同一份**「运行中对话」判据(命令入口绝不能各写一套)
|
|
171
|
+
import { sessionActiveDenied, sessionActiveGateApplies } from './routes/recovery.ts'
|
|
167
172
|
|
|
168
173
|
/* ---------------------------------------------------------------- identity */
|
|
169
174
|
|
|
@@ -174,7 +179,7 @@ export const name = 'config-manager'
|
|
|
174
179
|
export const inject = ['settings', 'credentials']
|
|
175
180
|
|
|
176
181
|
/** Plugin version, kept in sync with package.json ("version"). */
|
|
177
|
-
export const PLUGIN_VERSION = '0.1.
|
|
182
|
+
export const PLUGIN_VERSION = '0.1.76'
|
|
178
183
|
|
|
179
184
|
/** Plugin own package name — excluded from its own exported plugins list. */
|
|
180
185
|
const PLUGIN_NAME = 'dsh-config-manager'
|
|
@@ -2006,6 +2011,10 @@ export class ConfigManagerHostContext implements HostContext {
|
|
|
2006
2011
|
readonly workspace: WorkspaceFacade
|
|
2007
2012
|
readonly patchFile: PatchFileFacade
|
|
2008
2013
|
readonly fs: FileSystemFacade
|
|
2014
|
+
/** 系统 home(第三方插件数据分区 pluginData / pluginAssets 的根;见 HostContext 的说明) */
|
|
2015
|
+
readonly osHomeDir: string
|
|
2016
|
+
/** 以系统 home 为根的文件门面(同样越界即抛;只有上面两个分区能用) */
|
|
2017
|
+
readonly homeFs: FileSystemFacade
|
|
2009
2018
|
/** 会话存储端口(issue #45 会话归位;对 ctx.sessionPersistence 的薄适配) */
|
|
2010
2019
|
readonly sessions: SessionStoreFacade
|
|
2011
2020
|
/**
|
|
@@ -2083,6 +2092,10 @@ export class ConfigManagerHostContext implements HostContext {
|
|
|
2083
2092
|
this.plugins = new DshPluginsFacade(homeDir, profile, this.patchFile, undefined, this.msg)
|
|
2084
2093
|
this.workspace = new DshWorkspaceFacade(ctx, this.msg)
|
|
2085
2094
|
this.fs = new DshFileSystemFacade(homeDir, this.msg)
|
|
2095
|
+
// 系统 home:一部分第三方插件把数据放在 $DSH_HOME 之外(~/.dsh-wallpaper-engine、~/.mnemon …),
|
|
2096
|
+
// 那两个分区必须换根读写;门面本身与 $DSH_HOME 门面同一实现、同样被限制在它自己的根内。
|
|
2097
|
+
this.osHomeDir = homedir()
|
|
2098
|
+
this.homeFs = new DshFileSystemFacade(this.osHomeDir, this.msg)
|
|
2086
2099
|
this.sessions = new DshSessionStoreFacade(ctx, homeDir, this.msg, this.log)
|
|
2087
2100
|
// F-2:Cordis 的 fetch 服务(DSH 自己在用它转发 /api)。缺省回落进程全局 fetch(Node ≥18 恒有)。
|
|
2088
2101
|
const fetchService = readService<unknown>(ctx, 'fetch')
|
|
@@ -4299,6 +4312,14 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
4299
4312
|
const tmpDir = join(dataDir, 'tmp')
|
|
4300
4313
|
const snapshotsDir = join(dataDir, 'snapshots')
|
|
4301
4314
|
const syncDir = join(dataDir, 'sync')
|
|
4315
|
+
/**
|
|
4316
|
+
* issue #88 / #89:回合自动留点(回滚点)**缺省关闭**。
|
|
4317
|
+
*
|
|
4318
|
+
* 同步读一次偏好(几百字节),因为「关闭」必须在**注册 `session/event` 订阅之前**就定下来 ——
|
|
4319
|
+
* 晚一步注册就等于「订阅了什么都做」(见 src/core/rollback-gate.ts 的三条设计)。
|
|
4320
|
+
* 关闭后本进程不再留点、不再写台账;台账与回退路由本身仍可用(已有回滚点仍能查看 / 回退 / 清理)。
|
|
4321
|
+
*/
|
|
4322
|
+
setRollbackEnabled(readRollbackEnabledSync(syncDir))
|
|
4302
4323
|
const marketDir = join(dataDir, 'market')
|
|
4303
4324
|
// Phase 6:迁移历史审计目录(统一历史引擎;加入 RESERVED_INTERNAL_PREFIXES 防 F23 投毒链)
|
|
4304
4325
|
const historyDir = join(dataDir, MIGRATION_HISTORY_DIR)
|
|
@@ -4611,9 +4632,10 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
4611
4632
|
}, 'config-manager: profile runtime heartbeat')
|
|
4612
4633
|
// ------------------------------------------- S1:回合自动留点(宿主真接线)
|
|
4613
4634
|
//
|
|
4614
|
-
// 订阅 `session/event`(@deepseek-ai/dsh-session 的 post-commit fire-and-forget feed
|
|
4615
|
-
//
|
|
4616
|
-
//
|
|
4635
|
+
// 订阅 `session/event`(@deepseek-ai/dsh-session 的 post-commit fire-and-forget feed)。
|
|
4636
|
+
// **留点在 `turn/start` 拍**(点的「工作区态」必须是该回合开始**之前**的样子 —— 这条口径不能动,
|
|
4637
|
+
// 见 rollback-point.ts RollbackAutoTracker.noteEvent);`turn/end` 只更新「缺省边界」,不再拍点。
|
|
4638
|
+
// 没有手动「拍点」入口 —— 留点完全由回合驱动。
|
|
4617
4639
|
//
|
|
4618
4640
|
// 纪律(AGENTS.md 铁律 + captain 要求):**订阅与每个回调都 try/catch 包裹**,任何异常
|
|
4619
4641
|
// 只记 warn:改坏宿主启动 = blocker。事件回调用一条串行链喂给留点器,保证同一会话的
|
|
@@ -4632,7 +4654,9 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
4632
4654
|
return { ok: false as const, code: 'session-fork-unavailable' as const, detail: 'facade has no forkSession' }
|
|
4633
4655
|
}
|
|
4634
4656
|
const result = await forkSession.call(forkPort, sessionId, boundarySeq)
|
|
4635
|
-
|
|
4657
|
+
// 形状加固:`in` 对 null / 原始值会抛 TypeError —— 端口漂移时应当是**结构化失败**,
|
|
4658
|
+
// 而不是一个没人接的异常(宿主版本换形状时这条最先生效)。
|
|
4659
|
+
if (result !== null && typeof result === 'object' && 'childSessionId' in result) {
|
|
4636
4660
|
// warning 一路带到回退结果(引擎落成 rollback.guidance.sessionNotAttached):
|
|
4637
4661
|
// 归位失败**不静默** —— 会话确实回退了,但它会出现在侧栏「未分组」。
|
|
4638
4662
|
return result.warning !== undefined
|
|
@@ -4648,10 +4672,24 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
4648
4672
|
// 「会话没有 cwd」这类**形状合法但信息不全**的情况只记一次日志(避免每回合刷屏);
|
|
4649
4673
|
// 该点本身仍带 incomplete=true + workspace.reasonCode='no-chunks',界面能如实展示。
|
|
4650
4674
|
const chunklessLogged = new Set<string>()
|
|
4675
|
+
/** 子代理会话只 warn 一次(见下面 'subagent-session' 分支) */
|
|
4676
|
+
const inertSessionsLogged = new Set<string>()
|
|
4651
4677
|
const listener = (session: unknown, event: unknown): void => {
|
|
4678
|
+
// issue #88/#89 第一道门:关闭时回调什么都不做。正常路径下关闭时压根没有订阅
|
|
4679
|
+
//(detach 已解绑),这里是「解绑与已派发事件之间」的竞态兜底。
|
|
4680
|
+
if (!isRollbackEnabled()) return
|
|
4652
4681
|
try {
|
|
4653
4682
|
const bindingResult = rollbackSessionBindingOf(session)
|
|
4654
4683
|
if (!bindingResult.ok) {
|
|
4684
|
+
if (bindingResult.code === 'subagent-session') {
|
|
4685
|
+
// 子代理会话按设计不留点:**每个会话只记一次**(否则每个事件刷一行 warn),
|
|
4686
|
+
// 但仍然留痕 —— 不让「这个会话为什么没有点」变成无解释的沉默。
|
|
4687
|
+
if (!inertSessionsLogged.has(bindingResult.detail)) {
|
|
4688
|
+
inertSessionsLogged.add(bindingResult.detail)
|
|
4689
|
+
host.log.warn('rollback auto point skipped: ' + bindingResult.code + ' ' + bindingResult.detail)
|
|
4690
|
+
}
|
|
4691
|
+
return
|
|
4692
|
+
}
|
|
4655
4693
|
// 形状不匹配**不静默**:给显式机器码,宿主日志留痕
|
|
4656
4694
|
host.log.warn('rollback auto point skipped: ' + bindingResult.code + ' ' + bindingResult.detail)
|
|
4657
4695
|
return
|
|
@@ -4676,13 +4714,32 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
4676
4714
|
}
|
|
4677
4715
|
}
|
|
4678
4716
|
let off: (() => boolean) | null = null
|
|
4679
|
-
|
|
4680
|
-
|
|
4681
|
-
|
|
4682
|
-
|
|
4717
|
+
/**
|
|
4718
|
+
* 订阅 / 解绑成对:开关关闭时**真的解绑**(不是「订阅了什么都不做」—— issue #88 明确要求
|
|
4719
|
+
* 「关闭后不再订阅 session/event」)。attach 幂等;订阅抛错只丢这一个入口,不打断宿主启动。
|
|
4720
|
+
*/
|
|
4721
|
+
const attach = (): void => {
|
|
4722
|
+
if (off !== null) return
|
|
4723
|
+
try {
|
|
4724
|
+
off = ctx.on('session/event', listener)
|
|
4725
|
+
} catch (error) {
|
|
4726
|
+
off = null
|
|
4727
|
+
host.log.warn('rollback auto point subscribe failed: ' + (error instanceof Error ? error.message : String(error)))
|
|
4728
|
+
}
|
|
4729
|
+
}
|
|
4730
|
+
const detach = (): void => {
|
|
4731
|
+
const current = off
|
|
4732
|
+
off = null
|
|
4733
|
+
if (current === null) return
|
|
4734
|
+
try { current() } catch { /* 解绑失败不影响卸载 */ }
|
|
4683
4735
|
}
|
|
4736
|
+
if (isRollbackEnabled()) attach()
|
|
4737
|
+
// 活切换:写偏好的一方改开关,这里即时 attach / detach。事件回调**不读文件** ——
|
|
4738
|
+
// 每回合读一次磁盘正是本 issue 要消灭的开销形态。
|
|
4739
|
+
const unlisten = onRollbackEnabledChange((on) => { if (on) attach(); else detach() })
|
|
4684
4740
|
return () => {
|
|
4685
|
-
|
|
4741
|
+
unlisten()
|
|
4742
|
+
detach()
|
|
4686
4743
|
rollbackTracker.stop()
|
|
4687
4744
|
}
|
|
4688
4745
|
}, 'config-manager: rollback auto points')
|
|
@@ -4725,6 +4782,12 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
4725
4782
|
warnings: preview.warnings,
|
|
4726
4783
|
} satisfies RollbackRewindResult
|
|
4727
4784
|
}
|
|
4785
|
+
// ①b 与 HTTP 路径**同一道**「运行中对话」门:命令由会话循环派发、日志刚被 append,
|
|
4786
|
+
// `session.active` 通常为真;缺这道门就会出现「HTTP 会 409 拒绝的『运行中改工作区』
|
|
4787
|
+
// 被命令入口放行」—— 写回的文件随即被运行中的步骤覆盖(撕裂态)。
|
|
4788
|
+
if (preview.session.active === true && sessionActiveGateApplies(preview, true, host.safeModeIsBlocked?.() ?? false)) {
|
|
4789
|
+
return sessionActiveDenied(id, preview)
|
|
4790
|
+
}
|
|
4728
4791
|
// ② 真执行:userConfirmed **恒 true** —— 用户敲下 /rollback <id> 就是那次确认
|
|
4729
4792
|
// (命令入口没有第二个确认按钮;其余门全量生效)。
|
|
4730
4793
|
return await rollbackEngine.rewind({
|
package/src/routes/messages.ts
CHANGED
|
@@ -56,6 +56,7 @@ export const zh = {
|
|
|
56
56
|
'route.recoverySessionLayoutPartial': '会话布局归位未全部完成(成功 {applied},失败 {failed},跳过 {skipped})',
|
|
57
57
|
'route.rollbackPointRewind': '回滚到回滚点:{outcome}',
|
|
58
58
|
'route.rollbackPointDelete': '删除回滚点 {id}',
|
|
59
|
+
'route.rollbackPointCleanup': '清理回滚点台账:删除 {records} 个点',
|
|
59
60
|
'route.recoveryOperationSummary': '恢复操作 {action}',
|
|
60
61
|
} as const;
|
|
61
62
|
|
|
@@ -95,6 +96,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
95
96
|
'route.recoverySessionLayoutPartial': 'Session layout relocation did not fully complete ({applied} succeeded, {failed} failed, {skipped} skipped)',
|
|
96
97
|
'route.rollbackPointRewind': 'Rewind to rollback point: {outcome}',
|
|
97
98
|
'route.rollbackPointDelete': 'Deleted rollback point {id}',
|
|
99
|
+
'route.rollbackPointCleanup': 'Cleaned up rollback-point ledger: {records} point(s) deleted',
|
|
98
100
|
'route.recoveryOperationSummary': 'Recovery operation {action}',
|
|
99
101
|
};
|
|
100
102
|
|
package/src/routes/prefs.ts
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
* path/methods 就是唯一声明处;围栏、方法判定与顶层异常处理由 src/routes/kit.ts 在注册点统一提供。
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import type { ServerResponse } from 'node:http'
|
|
10
11
|
import { endpoint, requireJsonObject, writeJson } from './kit.ts'
|
|
12
|
+
import { EnvironmentLockUnavailableError, runWithMutationLock } from '../utils/env-lock.ts'
|
|
11
13
|
import type { WebRoute } from './kit.ts'
|
|
12
14
|
import type { RoutesEnv } from './context.ts'
|
|
13
15
|
import { PLUGIN_VERSION, STAR_PROMPT_REPO_URL } from '../index.ts'
|
|
@@ -16,10 +18,35 @@ import { UpdateChecker, wantsForcedUpdateCheck } from '../core/update-check.ts'
|
|
|
16
18
|
|
|
17
19
|
export function prefsRoutes(env: RoutesEnv): WebRoute[] {
|
|
18
20
|
const {
|
|
21
|
+
host,
|
|
19
22
|
syncDir,
|
|
20
23
|
selfUpdate,
|
|
21
24
|
withMutationGate,
|
|
22
25
|
} = env
|
|
26
|
+
/**
|
|
27
|
+
* 偏好写必须与其它写路径**同门**(④A):ui-prefs.json 由 star-prompt / release-notes-prompt /
|
|
28
|
+
* 回滚开关三个端点共写。`updateUiPrefs` 虽是 read→merge→write,但两个端点**并发**时仍会互相覆盖
|
|
29
|
+
* 字段(典型:刚打开的 rollbackEnabled 被另一个端点的旧快照抹掉 ⇒ 重启后功能自己关掉,fail-closed)。
|
|
30
|
+
* 被 SAFE MODE / 环境锁挡住时直接回 423 并返回 null(调用方 return,零写入)。
|
|
31
|
+
*/
|
|
32
|
+
const writePrefsOrBlocked = async (
|
|
33
|
+
res: ServerResponse,
|
|
34
|
+
patch: Parameters<typeof updateUiPrefs>[1],
|
|
35
|
+
): Promise<Awaited<ReturnType<typeof updateUiPrefs>> | null> => {
|
|
36
|
+
try {
|
|
37
|
+
return await runWithMutationLock(
|
|
38
|
+
host.mutationLock,
|
|
39
|
+
{ op: 'ui-prefs', target: 'star/release-notes', isBlocked: () => host.safeModeIsBlocked?.() ?? false },
|
|
40
|
+
async () => await updateUiPrefs(syncDir, patch),
|
|
41
|
+
)
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error instanceof EnvironmentLockUnavailableError) {
|
|
44
|
+
writeJson(res, 423, { error: error.message, code: 'mutation-locked' })
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
throw error
|
|
48
|
+
}
|
|
49
|
+
}
|
|
23
50
|
// 版本更新检查器:每进程一个(进程内缓存 10 分钟;用户点「重新检查」走 ?force=1)。
|
|
24
51
|
// 网络失败/超时/响应畸形一律结构化回传(ok:false + 原因),绝不「失败当最新」。
|
|
25
52
|
const updateChecker = new UpdateChecker(PLUGIN_VERSION)
|
|
@@ -58,7 +85,8 @@ export function prefsRoutes(env: RoutesEnv): WebRoute[] {
|
|
|
58
85
|
if (body['clicked'] === true) {
|
|
59
86
|
patch['starPromptClicked'] = true
|
|
60
87
|
}
|
|
61
|
-
const next = await
|
|
88
|
+
const next = await writePrefsOrBlocked(res, patch)
|
|
89
|
+
if (next === null) return
|
|
62
90
|
writeJson(res, 200, {
|
|
63
91
|
ok: true,
|
|
64
92
|
firstSeenAt: next.starPromptFirstSeenAt,
|
|
@@ -98,7 +126,8 @@ export function prefsRoutes(env: RoutesEnv): WebRoute[] {
|
|
|
98
126
|
if (body['dismissed'] === true) {
|
|
99
127
|
patch['releaseNotesDismissed'] = true
|
|
100
128
|
}
|
|
101
|
-
const next = await
|
|
129
|
+
const next = await writePrefsOrBlocked(res, patch)
|
|
130
|
+
if (next === null) return
|
|
102
131
|
writeJson(res, 200, {
|
|
103
132
|
ok: true,
|
|
104
133
|
lastSeenVersion: next.releaseNotesLastSeenVersion,
|
package/src/routes/recovery.ts
CHANGED
|
@@ -12,7 +12,9 @@ import { engineMsg } from './messages.ts'
|
|
|
12
12
|
import type { WebRoute } from './kit.ts'
|
|
13
13
|
import type { RoutesEnv } from './context.ts'
|
|
14
14
|
import { isValidOperationId } from '../core/journal.ts'
|
|
15
|
-
import { RollbackPointEngine } from '../core/rollback-point.ts'
|
|
15
|
+
import { RollbackLedgerRefusedError, RollbackPointEngine } from '../core/rollback-point.ts'
|
|
16
|
+
import { isRollbackEnabled, setRollbackEnabled } from '../core/rollback-gate.ts'
|
|
17
|
+
import { readRollbackEnabledSync, updateUiPrefs } from '../sync/ui-prefs.ts'
|
|
16
18
|
import type { RollbackForkSeam, RollbackPreviewResult, RollbackRewindInput, RollbackRewindResult } from '../core/rollback-point.ts'
|
|
17
19
|
import { reconcileForkSessions, type ForkReconcileChild, type ForkReconcileOutcome } from '../core/fork-reconcile.ts'
|
|
18
20
|
import { EnvironmentLockUnavailableError, runWithMutationLock } from '../utils/env-lock.ts'
|
|
@@ -102,7 +104,7 @@ function rewindTouchesWorkspace(input: { mode?: string; segments?: readonly stri
|
|
|
102
104
|
*
|
|
103
105
|
* 两道门(R1 预检 / R2 锁内二道保险)共用这一份判据,绝不各写一套。
|
|
104
106
|
*/
|
|
105
|
-
function sessionActiveGateApplies(
|
|
107
|
+
export function sessionActiveGateApplies(
|
|
106
108
|
preview: RollbackPreviewResult,
|
|
107
109
|
userConfirmed: boolean,
|
|
108
110
|
safeModeBlocked: boolean,
|
|
@@ -119,7 +121,7 @@ function sessionActiveGateApplies(
|
|
|
119
121
|
* 判据来源只有一个:**preview 已算出的 `session.active`**(引擎按会话日志 mtime 静默窗算)——
|
|
120
122
|
* 路由**绝不自己 stat 会话日志**(那会变成第二份判据,两处迟早漂移)。
|
|
121
123
|
*/
|
|
122
|
-
function sessionActiveDenied(id: string, preview: RollbackPreviewResult): RollbackRewindResult {
|
|
124
|
+
export function sessionActiveDenied(id: string, preview: RollbackPreviewResult): RollbackRewindResult {
|
|
123
125
|
return {
|
|
124
126
|
ok: false,
|
|
125
127
|
outcome: 'denied',
|
|
@@ -219,12 +221,19 @@ function forkSeamOf(sessions: unknown): RollbackForkSeam | undefined {
|
|
|
219
221
|
if (sessions === null || typeof sessions !== 'object') return undefined
|
|
220
222
|
const candidate = (sessions as { forkSession?: unknown }).forkSession
|
|
221
223
|
if (typeof candidate !== 'function') return undefined
|
|
222
|
-
const fork = candidate as (sessionId: string, boundarySeq: number) => Promise<{ childSessionId: string } | { error: string }>
|
|
224
|
+
const fork = candidate as (sessionId: string, boundarySeq: number) => Promise<{ childSessionId: string; warning?: string } | { error: string }>
|
|
223
225
|
return async ({ sessionId, boundarySeq }) => {
|
|
224
226
|
try {
|
|
225
227
|
const result = await fork.call(sessions, sessionId, boundarySeq)
|
|
226
228
|
const childId = result !== null && typeof result === 'object' ? (result as { childSessionId?: unknown }).childSessionId : undefined
|
|
227
|
-
if (typeof childId === 'string' && childId !== '')
|
|
229
|
+
if (typeof childId === 'string' && childId !== '') {
|
|
230
|
+
// 归位失败(宿主三档都没命中)必须一路带到结果里:丢掉它就会出现「HTTP 入口说已归位、
|
|
231
|
+
// /rollback 命令说没归位」的矛盾(子会话实际停在侧栏「未分组」)。
|
|
232
|
+
const warning = (result as { warning?: unknown }).warning
|
|
233
|
+
return typeof warning === 'string' && warning !== ''
|
|
234
|
+
? { ok: true, childSessionId: childId, warning }
|
|
235
|
+
: { ok: true, childSessionId: childId }
|
|
236
|
+
}
|
|
228
237
|
const error = result !== null && typeof result === 'object' ? (result as { error?: unknown }).error : undefined
|
|
229
238
|
return { ok: false, code: 'fork-failed', detail: typeof error === 'string' ? error : 'fork-returned-no-id' }
|
|
230
239
|
} catch (err) {
|
|
@@ -528,6 +537,9 @@ export function recoveryRoutes(env: RoutesEnv): WebRoute[] {
|
|
|
528
537
|
log: (line) => host.log.warn(line),
|
|
529
538
|
...(forkSeam !== undefined ? { fork: forkSeam } : {}),
|
|
530
539
|
})
|
|
540
|
+
// 插件偏好文件与 /sync/ui-prefs 同一份(dataDir/sync/ui-prefs.json);回滚点开关存在那里,
|
|
541
|
+
// 所以这里派生同一个目录,绝不另立第二个偏好文件。
|
|
542
|
+
const syncDir = join(dataDir, 'sync')
|
|
531
543
|
const sub = segments.slice(1)
|
|
532
544
|
const methodNotAllowed = (): void => { writeJson(res, 405, { error: 'method not allowed', code: 'method-not-allowed' }) }
|
|
533
545
|
const reply = (value: { ok?: boolean; code?: string; outcome?: string }): void => {
|
|
@@ -553,14 +565,117 @@ export function recoveryRoutes(env: RoutesEnv): WebRoute[] {
|
|
|
553
565
|
* 仍读不到就不加该字段(界面回落显示会话 id),**绝不从首条消息编造标题**。
|
|
554
566
|
*/
|
|
555
567
|
const titled = await applySessionLogTitles(points, join(sessionHealth.homeDir, 'sessions'))
|
|
556
|
-
|
|
568
|
+
// 列表响应里的 storage 也带上开关与台账占用:界面**只读这一个响应**就能画开关、
|
|
569
|
+
// 画占用、并决定聊天入口是否收起(少一次请求,也少一处口径分叉)。
|
|
570
|
+
const storageWithPrefs = {
|
|
571
|
+
...listed.storage,
|
|
572
|
+
rollbackEnabled: readRollbackEnabledSync(syncDir),
|
|
573
|
+
rollbackActive: isRollbackEnabled(),
|
|
574
|
+
}
|
|
575
|
+
writeJson(res, listed.storage.available ? 200 : 503, { ...listed, storage: storageWithPrefs, points: titled })
|
|
557
576
|
return
|
|
558
577
|
}
|
|
559
578
|
// 存储栈状态(缺失时给结构化组合指引;**不崩**)
|
|
579
|
+
// issue #89 P2:随状态一并回传**台账占用**(记录数 / 字节 / 预算 / 已淘汰数,见 engine.storageStatus 的 ledger)
|
|
580
|
+
// 与开关(rollbackEnabled = 磁盘偏好;rollbackActive = 本进程当前是否真的在留点)。
|
|
560
581
|
if (sub.length === 1 && sub[0] === 'storage') {
|
|
561
582
|
if (req.method !== 'GET') { methodNotAllowed(); return }
|
|
562
583
|
const status = await engine.storageStatus()
|
|
563
|
-
writeJson(res, status.available ? 200 : 503, {
|
|
584
|
+
writeJson(res, status.available ? 200 : 503, {
|
|
585
|
+
ok: status.available,
|
|
586
|
+
rollbackEnabled: readRollbackEnabledSync(syncDir),
|
|
587
|
+
rollbackActive: isRollbackEnabled(),
|
|
588
|
+
...status,
|
|
589
|
+
})
|
|
590
|
+
return
|
|
591
|
+
}
|
|
592
|
+
// 回合自动留点的总开关(issue #88 / #89)。
|
|
593
|
+
//
|
|
594
|
+
// 为什么要读写两个语义:磁盘偏好(rollbackEnabled)是**用户意图**、随 self 分区进备份;
|
|
595
|
+
// 进程 gate(rollbackActive)是**此刻真正生效**的东西(关闭后订阅已解绑)。两者正常一致,
|
|
596
|
+
// 只在「另一进程改了文件」这类边界上短暂分叉 —— 分歧就如实分开报,不合并成一个布尔。
|
|
597
|
+
//
|
|
598
|
+
// 写路径过 mutation gate:它改的是「宿主会不会每回合写盘」的行为语义,与 rewind / delete 同级
|
|
599
|
+
//(都在同一把锁里,拒绝一律发生在写任何字节之前)。
|
|
600
|
+
if (sub.length === 1 && sub[0] === 'prefs') {
|
|
601
|
+
if (req.method === 'GET') {
|
|
602
|
+
writeJson(res, 200, { ok: true, rollbackEnabled: readRollbackEnabledSync(syncDir), rollbackActive: isRollbackEnabled() })
|
|
603
|
+
return
|
|
604
|
+
}
|
|
605
|
+
if (req.method !== 'POST') { methodNotAllowed(); return }
|
|
606
|
+
try {
|
|
607
|
+
const body = await readJsonBody(req)
|
|
608
|
+
if (body === undefined) { writeJson(res, 400, { ok: false, code: 'invalid-input', error: 'invalid JSON body' }); return }
|
|
609
|
+
const wanted = body['rollbackEnabled']
|
|
610
|
+
if (typeof wanted !== 'boolean') {
|
|
611
|
+
writeJson(res, 400, { ok: false, code: 'invalid-input', error: 'rollbackEnabled must be a boolean' })
|
|
612
|
+
return
|
|
613
|
+
}
|
|
614
|
+
await runWithMutationLock(
|
|
615
|
+
host.mutationLock,
|
|
616
|
+
{ op: 'rollback-prefs', target: 'rollbackEnabled', isBlocked: () => host.safeModeIsBlocked?.() ?? false },
|
|
617
|
+
async () => {
|
|
618
|
+
await updateUiPrefs(syncDir, { rollbackEnabled: wanted })
|
|
619
|
+
// 写盘成功才切进程 gate:写失败却把功能打开,等于「界面说开着、实际不留点」
|
|
620
|
+
setRollbackEnabled(wanted)
|
|
621
|
+
},
|
|
622
|
+
)
|
|
623
|
+
writeJson(res, 200, { ok: true, rollbackEnabled: wanted, rollbackActive: isRollbackEnabled() })
|
|
624
|
+
} catch (error) {
|
|
625
|
+
if (error instanceof EnvironmentLockUnavailableError) {
|
|
626
|
+
host.log.warn('mutation lock blocked: op=rollback-prefs reason=' + error.reason)
|
|
627
|
+
writeJson(res, 423, { error: error.message, code: 'mutation-locked' })
|
|
628
|
+
return
|
|
629
|
+
}
|
|
630
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
631
|
+
}
|
|
632
|
+
return
|
|
633
|
+
}
|
|
634
|
+
// 台账清理(issue #89 P1/P2):把「已经涨上去的占用」收回来。
|
|
635
|
+
//
|
|
636
|
+
// 为什么单独一条子路径而不是并进「磁盘占用清理」:那条清理的候选集被四条不变量钉死
|
|
637
|
+
// (只有可重建区 + 显式勾选的过期导出;snapshots/sync 永不进候选)。回滚点是**数据**不是
|
|
638
|
+
// 可重建区,用户没显式勾选就不能被任何「一键清理」顺走 —— 所以它是自己这条显式入口,
|
|
639
|
+
// 且默认只删自动点(救援点要显式勾选)。语义与单条 delete 一致,只是作用域是整个台账。
|
|
640
|
+
if (sub.length === 1 && sub[0] === 'cleanup') {
|
|
641
|
+
if (req.method !== 'POST') { methodNotAllowed(); return }
|
|
642
|
+
try {
|
|
643
|
+
const body = await readJsonBody(req)
|
|
644
|
+
if (body === undefined) { writeJson(res, 400, { ok: false, code: 'invalid-input', error: 'invalid JSON body' }); return }
|
|
645
|
+
// 与单条 delete 同一条 fail-closed 确认门:必须**恰好** true,缺省不放行
|
|
646
|
+
if (body['userConfirmed'] !== true) {
|
|
647
|
+
writeJson(res, 409, { ok: false, code: 'confirmation-required' })
|
|
648
|
+
return
|
|
649
|
+
}
|
|
650
|
+
const includeGuards = body['includeGuards'] === true
|
|
651
|
+
const result = await runWithMutationLock(
|
|
652
|
+
host.mutationLock,
|
|
653
|
+
{ op: 'rollback-cleanup', target: includeGuards ? 'auto+guard' : 'auto', isBlocked: () => host.safeModeIsBlocked?.() ?? false },
|
|
654
|
+
async () => await engine.purgeLedger({ includeGuards }),
|
|
655
|
+
)
|
|
656
|
+
await tryAppendHistory({
|
|
657
|
+
kind: 'recovery',
|
|
658
|
+
result: 'success',
|
|
659
|
+
sections: [],
|
|
660
|
+
source: 'recovery',
|
|
661
|
+
summary: engineMsg('route.rollbackPointCleanup', { records: String(result.removedRecords) }),
|
|
662
|
+
})
|
|
663
|
+
writeJson(res, 200, { ok: true, ...result })
|
|
664
|
+
} catch (error) {
|
|
665
|
+
if (error instanceof EnvironmentLockUnavailableError) {
|
|
666
|
+
host.log.warn('mutation lock blocked: op=rollback-cleanup reason=' + error.reason)
|
|
667
|
+
writeJson(res, 423, { error: error.message, code: 'mutation-locked' })
|
|
668
|
+
return
|
|
669
|
+
}
|
|
670
|
+
// 引擎侧的**显式拒绝**带机器码(存储不可用 / 台账读不出):按码映射 HTTP,
|
|
671
|
+
// 不回落成泛化 500 —— 界面要能说清「是存储栈缺了」还是「台账真读不出来」。
|
|
672
|
+
if (error instanceof RollbackLedgerRefusedError) {
|
|
673
|
+
host.log.warn('rollback cleanup refused: ' + error.code + ' ' + error.message)
|
|
674
|
+
writeJson(res, error.code === 'storage-unavailable' ? 503 : 500, { ok: false, code: error.code, detail: error.message })
|
|
675
|
+
return
|
|
676
|
+
}
|
|
677
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
678
|
+
}
|
|
564
679
|
return
|
|
565
680
|
}
|
|
566
681
|
// 单条详情(只读:两态摘要 + 完整记录)
|
package/src/routes/sync.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { endpoint, requireJsonObject, readJsonBody, writeJson } from './kit.ts'
|
|
9
|
+
import { EnvironmentLockUnavailableError, runWithMutationLock } from '../utils/env-lock.ts'
|
|
9
10
|
import type { WebRoute } from './kit.ts'
|
|
10
11
|
import type { RoutesEnv } from './context.ts'
|
|
11
12
|
import { FileSnapshotStore } from '../core/index.ts'
|
|
@@ -320,9 +321,22 @@ export function syncRoutes(env: RoutesEnv): WebRoute[] {
|
|
|
320
321
|
const body = await requireJsonObject(req)
|
|
321
322
|
try {
|
|
322
323
|
const channel: UiPrefsChannel | undefined = parseSyncChannel(body['lastSyncChannel'])
|
|
323
|
-
|
|
324
|
+
// ④A:与回滚开关端点**同一道写入门**。ui-prefs.json 由三个端点共写,`updateUiPrefs` 虽是
|
|
325
|
+
// read→merge→write,但两个不同端点并发时仍会互相覆盖字段(例如把刚打开的 rollbackEnabled 抹掉,
|
|
326
|
+
// 重启后功能自己关掉)。同类写串行是既有架构口径。
|
|
327
|
+
await runWithMutationLock(
|
|
328
|
+
host.mutationLock,
|
|
329
|
+
{ op: 'ui-prefs', target: 'lastSyncChannel', isBlocked: () => host.safeModeIsBlocked?.() ?? false },
|
|
330
|
+
async () => {
|
|
331
|
+
await updateUiPrefs(syncDir, { ...(channel !== undefined ? { lastSyncChannel: channel } : {}) })
|
|
332
|
+
},
|
|
333
|
+
)
|
|
324
334
|
writeJson(res, 200, { ok: true, lastSyncChannel: channel })
|
|
325
335
|
} catch (error) {
|
|
336
|
+
if (error instanceof EnvironmentLockUnavailableError) {
|
|
337
|
+
writeJson(res, 423, { error: error.message, code: 'mutation-locked' })
|
|
338
|
+
return
|
|
339
|
+
}
|
|
326
340
|
writeSyncRouteError(res, error)
|
|
327
341
|
}
|
|
328
342
|
}),
|
|
@@ -281,7 +281,7 @@ test('K-04 x-* 扩展键仅作为根级文档说明存在,且分区 id 表与
|
|
|
281
281
|
assert.ok(xKeys.includes('x-semantic-constraints'), '应显式记录 JSON Schema 无法表达的语义约束');
|
|
282
282
|
assert.deepEqual(
|
|
283
283
|
SCHEMA['x-section-ids'], [...SECTION_IDS],
|
|
284
|
-
'schema
|
|
284
|
+
'schema 记录的分区 id 必须与 SECTION_IDS 完全一致(防止分区增删后文档漂移)',
|
|
285
285
|
);
|
|
286
286
|
assert.equal(SCHEMA['x-format-version'], 'Bundle Format v1');
|
|
287
287
|
assert.equal(SCHEMA['x-schema-version-constant'], CURRENT_SCHEMA_VERSION);
|
|
@@ -322,10 +322,10 @@ test('S-01 真实 buildManifest 产物满足 schema 声明的全部必需字段
|
|
|
322
322
|
assert.deepEqual(errorPaths(m), [], '且必须零 validateManifest error');
|
|
323
323
|
});
|
|
324
324
|
|
|
325
|
-
test('S-02 sections
|
|
325
|
+
test('S-02 sections 恒含全部已知分区 id(规格 §2.5),值均为布尔', () => {
|
|
326
326
|
const m = baselineManifest();
|
|
327
327
|
const keys = Object.keys(m.sections).sort();
|
|
328
|
-
assert.equal(SECTION_IDS.length,
|
|
328
|
+
assert.equal(SECTION_IDS.length, 17, 'SectionId 共 17 个(含无 adapter 的 secrets)');
|
|
329
329
|
assert.deepEqual(keys, [...SECTION_IDS].sort(), 'sections 键集合必须等于 SECTION_IDS');
|
|
330
330
|
for (const key of keys) {
|
|
331
331
|
assert.equal(typeof m.sections[key as (typeof SECTION_IDS)[number]], 'boolean', `sections.${key} 必须是布尔`);
|
|
@@ -333,7 +333,7 @@ test('S-02 sections 恒含全部 15 个已知分区 id(规格 §2.5),值
|
|
|
333
333
|
assert.deepEqual(validateAgainstSchema(SCHEMA, m), [], '全量 sections 必须满足 schema');
|
|
334
334
|
});
|
|
335
335
|
|
|
336
|
-
test('S-03
|
|
336
|
+
test('S-03 全部分区置 true 的 manifest 仍满足 schema(sections 是开放布尔映射)', () => {
|
|
337
337
|
const all = sectionsAllFalse();
|
|
338
338
|
for (const id of SECTION_IDS) all[id] = true;
|
|
339
339
|
const m = buildManifest({
|
|
@@ -47,6 +47,9 @@ const HISTORICAL_SECTION_IDS: readonly SectionId[] = [
|
|
|
47
47
|
'settings', 'ui', 'providers', 'plugins', 'mcp', 'prompts',
|
|
48
48
|
'skills', 'agentPresets', 'agentInstructions', 'workspaces', 'pluginFiles',
|
|
49
49
|
'credentialsStatus', 'secrets', 'sessions', 'self',
|
|
50
|
+
// 2026-10-14 追加:第三方插件数据(pluginData 配置类 / pluginAssets 大资产类)——
|
|
51
|
+
// 历史字面量到此为止的部分逐字未改,新分区只允许**追加**在末尾。
|
|
52
|
+
'pluginData', 'pluginAssets',
|
|
50
53
|
];
|
|
51
54
|
const HISTORICAL_JSON_PATHS: ReadonlyArray<readonly [SectionId, string]> = [
|
|
52
55
|
['settings', 'config/settings.json'],
|
|
@@ -65,16 +68,18 @@ const HISTORICAL_FILE_PREFIXES: ReadonlyArray<readonly [SectionId, string]> = [
|
|
|
65
68
|
['pluginFiles', 'plugin-files/'],
|
|
66
69
|
['sessions', 'sessions/'],
|
|
67
70
|
['self', 'self/'],
|
|
71
|
+
['pluginData', 'plugin-data/'],
|
|
72
|
+
['pluginAssets', 'plugin-assets/'],
|
|
68
73
|
];
|
|
69
74
|
|
|
70
75
|
/* ---------------- 行为等价(顺序 + 内容) ---------------- */
|
|
71
76
|
|
|
72
77
|
test('registry: SECTION_IDS 与历史数组逐项一致(顺序 = applyOrder 升序)', () => {
|
|
73
78
|
assert.deepEqual([...SECTION_IDS], [...HISTORICAL_SECTION_IDS]);
|
|
74
|
-
assert.equal(SECTION_IDS.length,
|
|
79
|
+
assert.equal(SECTION_IDS.length, 17);
|
|
75
80
|
const orders = SECTION_IDS.map((id) => SECTION_REGISTRY[id].applyOrder);
|
|
76
81
|
assert.deepEqual(orders, [...orders].sort((a, b) => a - b), 'applyOrder 必须与 SECTION_IDS 顺序一致');
|
|
77
|
-
assert.deepEqual(orders, Array.from({ length:
|
|
82
|
+
assert.deepEqual(orders, Array.from({ length: 17 }, (_, i) => i + 1), 'applyOrder 连续且从 1 起');
|
|
78
83
|
});
|
|
79
84
|
|
|
80
85
|
test('registry: SECTION_JSON_PATHS / SECTION_FILE_PREFIXES 与历史字面量逐项一致(含键顺序)', () => {
|
|
@@ -219,6 +224,8 @@ const HISTORICAL_RISK_TIERS: Readonly<Record<string, SectionRiskTier>> = {
|
|
|
219
224
|
skills: 'low', agentPresets: 'low', agentInstructions: 'low',
|
|
220
225
|
pluginFiles: 'high', sessions: 'high', self: 'low',
|
|
221
226
|
credentialsStatus: 'high', secrets: 'high',
|
|
227
|
+
// 第三方插件数据:内容是任意文件(可能含凭据),且换机后位置可能不存在 → high
|
|
228
|
+
pluginData: 'high', pluginAssets: 'high',
|
|
222
229
|
};
|
|
223
230
|
|
|
224
231
|
test('registry: 每个分区都声明 riskTier,且与改造前的分级逐项一致(行为等价)', () => {
|