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/lib/core/rollback.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* 如实标记 failed + manualHint(人工补录);
|
|
8
8
|
* - settings 回滚仍走 expectedRevision 乐观锁,避免覆盖导入后用户的新修改。
|
|
9
9
|
*/
|
|
10
|
-
import { resolveFileTarget } from './backup.js';
|
|
10
|
+
import { fsForSection, resolveFileTarget } from './backup.js';
|
|
11
11
|
import { isPatchLayerRel, USER_PATCH_FILE } from './patch-layers.js';
|
|
12
12
|
import { msgOf } from './messages.js';
|
|
13
13
|
/** 逆序补偿单条快照条目;返回 null=成功,否则为失败原因 */
|
|
@@ -75,15 +75,17 @@ async function compensateOne(entry, ctx, store) {
|
|
|
75
75
|
}
|
|
76
76
|
case 'file': {
|
|
77
77
|
try {
|
|
78
|
+
// 门面按分区选(pluginData / pluginAssets 在系统 home 根;见 backup.ts 的 fsForSection)
|
|
79
|
+
const fsFacade = fsForSection(ctx, entry.adapter);
|
|
78
80
|
const abs = resolveFileTarget(ctx, entry.adapter, entry.ref);
|
|
79
81
|
if (entry.existed && entry.copiedTo && store) {
|
|
80
82
|
const data = await store.readBlob(entry.snapshotId ?? '', entry.copiedTo);
|
|
81
|
-
await
|
|
83
|
+
await fsFacade.writeFile(abs, data);
|
|
82
84
|
}
|
|
83
85
|
else if (!entry.existed) {
|
|
84
86
|
// 原文件不存在 → 删除导入写入的文件
|
|
85
|
-
if (await
|
|
86
|
-
await
|
|
87
|
+
if (await fsFacade.exists(abs))
|
|
88
|
+
await fsFacade.remove(abs);
|
|
87
89
|
}
|
|
88
90
|
return null;
|
|
89
91
|
}
|
package/lib/core/types.d.ts
CHANGED
|
@@ -533,6 +533,25 @@ export interface HostContext {
|
|
|
533
533
|
workspace: WorkspaceFacade;
|
|
534
534
|
patchFile: PatchFileFacade;
|
|
535
535
|
fs: FileSystemFacade;
|
|
536
|
+
/**
|
|
537
|
+
* **系统 home 的绝对路径**(宿主解析;缺省 = 进程 `homedir()`)。
|
|
538
|
+
*
|
|
539
|
+
* 为什么需要:一部分第三方插件把数据放在 **$DSH_HOME 之外**(`~/.dsh-wallpaper-engine/`、
|
|
540
|
+
* `~/.mnemon/`、`~/.fastctx/` …),而 `fs` 被**限制在 $DSH_HOME 内**(越界即抛
|
|
541
|
+
* `host.fsPathEscape`)。`pluginData` / `pluginAssets` 两个分区的 ref 因此以**系统 home 为根**
|
|
542
|
+
* (见 `core/backup.ts` 的 `OS_HOME_FILE_SECTIONS`)。
|
|
543
|
+
*
|
|
544
|
+
* 判定纪律:**路径计算**用 `ctx.osHomeDir ?? homedir()`(`~` 的定义就是它,不是猜);
|
|
545
|
+
* **真正的读写**必须经 `homeFs`,缺了它一律显式失败 —— 绝不退回 $DSH_HOME 写错位置。
|
|
546
|
+
*/
|
|
547
|
+
osHomeDir?: string;
|
|
548
|
+
/**
|
|
549
|
+
* 以**系统 home 为根**的文件门面(与 `fs` 同一套方法、同样越界即抛;缺省 undefined)。
|
|
550
|
+
*
|
|
551
|
+
* 只允许 `pluginData` / `pluginAssets` 使用;其余分区一律走 `fs`($DSH_HOME 内)。
|
|
552
|
+
* 缺省 = 宿主未提供(旧宿主)→ 这两个分区**不收集也不写入**,由 adapter 如实报码。
|
|
553
|
+
*/
|
|
554
|
+
homeFs?: FileSystemFacade;
|
|
536
555
|
/**
|
|
537
556
|
* 会话存储端口(issue #45 会话归位;宿主注入对应 `ctx.sessionPersistence`)。
|
|
538
557
|
*
|
|
@@ -155,7 +155,7 @@ export interface ForeignUnitIdParts {
|
|
|
155
155
|
export declare function parseForeignUnitId(id: string): ForeignUnitIdParts | null;
|
|
156
156
|
export declare function isForeignUnitId(id: string): boolean;
|
|
157
157
|
/**
|
|
158
|
-
* DSH 既有单元 id 的命名空间:分区 id(
|
|
158
|
+
* DSH 既有单元 id 的命名空间:分区 id(17 个 SectionId)或 plugin/patch/workspace;
|
|
159
159
|
* 外来源形态(首段 = foreign)与无法识别的前缀一律 **null**。
|
|
160
160
|
*
|
|
161
161
|
* 两个方向都不误判是这条契约的核心(§8.3):`foreign` 不在 SectionId 词表里、也不等于
|
package/lib/foreign/registry.js
CHANGED
|
@@ -194,7 +194,7 @@ export function isForeignUnitId(id) {
|
|
|
194
194
|
return parseForeignUnitId(id) !== null;
|
|
195
195
|
}
|
|
196
196
|
/**
|
|
197
|
-
* DSH 既有单元 id 的命名空间:分区 id(
|
|
197
|
+
* DSH 既有单元 id 的命名空间:分区 id(17 个 SectionId)或 plugin/patch/workspace;
|
|
198
198
|
* 外来源形态(首段 = foreign)与无法识别的前缀一律 **null**。
|
|
199
199
|
*
|
|
200
200
|
* 两个方向都不误判是这条契约的核心(§8.3):`foreign` 不在 SectionId 词表里、也不等于
|
package/lib/index.d.ts
CHANGED
|
@@ -78,7 +78,7 @@ export declare const name = "config-manager";
|
|
|
78
78
|
/** Services required before the engine can mount (present in every profile). */
|
|
79
79
|
export declare const inject: string[];
|
|
80
80
|
/** Plugin version, kept in sync with package.json ("version"). */
|
|
81
|
-
export declare const PLUGIN_VERSION = "0.1.
|
|
81
|
+
export declare const PLUGIN_VERSION = "0.1.76";
|
|
82
82
|
/**
|
|
83
83
|
* Star 引导弹窗指向的 GitHub 仓库(用户引导点 Star 的目标)。
|
|
84
84
|
* 与 package.json 的 repository 字段保持一致;界面不可改(硬编码,参照
|
|
@@ -687,6 +687,10 @@ export declare class ConfigManagerHostContext implements HostContext {
|
|
|
687
687
|
readonly workspace: WorkspaceFacade;
|
|
688
688
|
readonly patchFile: PatchFileFacade;
|
|
689
689
|
readonly fs: FileSystemFacade;
|
|
690
|
+
/** 系统 home(第三方插件数据分区 pluginData / pluginAssets 的根;见 HostContext 的说明) */
|
|
691
|
+
readonly osHomeDir: string;
|
|
692
|
+
/** 以系统 home 为根的文件门面(同样越界即抛;只有上面两个分区能用) */
|
|
693
|
+
readonly homeFs: FileSystemFacade;
|
|
690
694
|
/** 会话存储端口(issue #45 会话归位;对 ctx.sessionPersistence 的薄适配) */
|
|
691
695
|
readonly sessions: SessionStoreFacade;
|
|
692
696
|
/**
|
package/lib/index.js
CHANGED
|
@@ -39,6 +39,7 @@ import { execFile } from 'node:child_process';
|
|
|
39
39
|
import { createWriteStream, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
40
40
|
import fs from 'node:fs/promises';
|
|
41
41
|
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
42
|
+
import { homedir } from 'node:os';
|
|
42
43
|
import { promisify } from 'node:util';
|
|
43
44
|
import * as dshSettings from '@deepseek-ai/dsh-settings';
|
|
44
45
|
import * as dshCredentials from '@deepseek-ai/dsh-credentials';
|
|
@@ -64,6 +65,7 @@ import { registerModelTools } from './core/model-tools.js';
|
|
|
64
65
|
import { msgOf, zhMsg } from './core/messages.js';
|
|
65
66
|
import { engineMsg, getEngineLocale, setEngineLanguageSource, setEngineLocale } from './core/engine-locale.js';
|
|
66
67
|
import { RollbackAutoTracker, RollbackPointEngine, rollbackSessionBindingOf, rollbackTrackedEventOf } from './core/rollback-point.js';
|
|
68
|
+
import { isRollbackEnabled, onRollbackEnabledChange, setRollbackEnabled } from './core/rollback-gate.js';
|
|
67
69
|
// t3:宿主斜杠命令 /rollback(与 HTTP 路由同一道写入门;命令服务惰取,未组合时安静跳过)。
|
|
68
70
|
import { agentSessionFactsOf, registerRollbackCommand } from './core/command-rollback.js';
|
|
69
71
|
import { cleanupAbortedInstall, hasDshBundlePatch, installAnchorFromProfileContext, installErrorFor, installSpecFor, listInstalledPlugins, profileNameFromProfileContext, resolveProcessProfileName, resolveProfileDir, readProfileManifest, runDshPlugin, validateProfileName } from './core/plugin-cli.js';
|
|
@@ -98,6 +100,7 @@ import { SyncSessionStore } from './sync/sync-session.js';
|
|
|
98
100
|
import { AutoSyncScheduler } from './sync/autosync-scheduler.js';
|
|
99
101
|
import { BackupScheduler } from './sync/backup-scheduler.js';
|
|
100
102
|
import { readBackupSchedule } from './sync/backup-schedule-config.js';
|
|
103
|
+
import { readRollbackEnabledSync } from './sync/ui-prefs.js';
|
|
101
104
|
import { AUTO_BACKUP_PREFIX, isValidExportFileName, resolveNonCollidingExportName, writeBackupNote } from './sync/backup-files.js';
|
|
102
105
|
import { DEFAULT_RETENTION_POLICY, selectPruneCandidatesByPolicy } from './sync/retention-policy.js';
|
|
103
106
|
import { selectPruneCandidates } from './core/backup.js';
|
|
@@ -125,13 +128,15 @@ import { parseZip } from './utils/zip.js';
|
|
|
125
128
|
import { isSameOrChild } from './utils/paths.js';
|
|
126
129
|
import { isRecord } from './utils/guards.js';
|
|
127
130
|
import { createLogger, parseLogLevel } from './utils/logger.js';
|
|
131
|
+
// 与 HTTP 路径**同一份**「运行中对话」判据(命令入口绝不能各写一套)
|
|
132
|
+
import { sessionActiveDenied, sessionActiveGateApplies } from './routes/recovery.js';
|
|
128
133
|
/* ---------------------------------------------------------------- identity */
|
|
129
134
|
/** Stable cordis plugin name — must match the cordis.patch.yml row id. */
|
|
130
135
|
export const name = 'config-manager';
|
|
131
136
|
/** Services required before the engine can mount (present in every profile). */
|
|
132
137
|
export const inject = ['settings', 'credentials'];
|
|
133
138
|
/** Plugin version, kept in sync with package.json ("version"). */
|
|
134
|
-
export const PLUGIN_VERSION = '0.1.
|
|
139
|
+
export const PLUGIN_VERSION = '0.1.76';
|
|
135
140
|
/** Plugin own package name — excluded from its own exported plugins list. */
|
|
136
141
|
const PLUGIN_NAME = 'dsh-config-manager';
|
|
137
142
|
/**
|
|
@@ -1779,6 +1784,10 @@ export class ConfigManagerHostContext {
|
|
|
1779
1784
|
workspace;
|
|
1780
1785
|
patchFile;
|
|
1781
1786
|
fs;
|
|
1787
|
+
/** 系统 home(第三方插件数据分区 pluginData / pluginAssets 的根;见 HostContext 的说明) */
|
|
1788
|
+
osHomeDir;
|
|
1789
|
+
/** 以系统 home 为根的文件门面(同样越界即抛;只有上面两个分区能用) */
|
|
1790
|
+
homeFs;
|
|
1782
1791
|
/** 会话存储端口(issue #45 会话归位;对 ctx.sessionPersistence 的薄适配) */
|
|
1783
1792
|
sessions;
|
|
1784
1793
|
/**
|
|
@@ -1854,6 +1863,10 @@ export class ConfigManagerHostContext {
|
|
|
1854
1863
|
this.plugins = new DshPluginsFacade(homeDir, profile, this.patchFile, undefined, this.msg);
|
|
1855
1864
|
this.workspace = new DshWorkspaceFacade(ctx, this.msg);
|
|
1856
1865
|
this.fs = new DshFileSystemFacade(homeDir, this.msg);
|
|
1866
|
+
// 系统 home:一部分第三方插件把数据放在 $DSH_HOME 之外(~/.dsh-wallpaper-engine、~/.mnemon …),
|
|
1867
|
+
// 那两个分区必须换根读写;门面本身与 $DSH_HOME 门面同一实现、同样被限制在它自己的根内。
|
|
1868
|
+
this.osHomeDir = homedir();
|
|
1869
|
+
this.homeFs = new DshFileSystemFacade(this.osHomeDir, this.msg);
|
|
1857
1870
|
this.sessions = new DshSessionStoreFacade(ctx, homeDir, this.msg, this.log);
|
|
1858
1871
|
// F-2:Cordis 的 fetch 服务(DSH 自己在用它转发 /api)。缺省回落进程全局 fetch(Node ≥18 恒有)。
|
|
1859
1872
|
const fetchService = readService(ctx, 'fetch');
|
|
@@ -3928,6 +3941,14 @@ export function apply(ctx, config) {
|
|
|
3928
3941
|
const tmpDir = join(dataDir, 'tmp');
|
|
3929
3942
|
const snapshotsDir = join(dataDir, 'snapshots');
|
|
3930
3943
|
const syncDir = join(dataDir, 'sync');
|
|
3944
|
+
/**
|
|
3945
|
+
* issue #88 / #89:回合自动留点(回滚点)**缺省关闭**。
|
|
3946
|
+
*
|
|
3947
|
+
* 同步读一次偏好(几百字节),因为「关闭」必须在**注册 `session/event` 订阅之前**就定下来 ——
|
|
3948
|
+
* 晚一步注册就等于「订阅了什么都做」(见 src/core/rollback-gate.ts 的三条设计)。
|
|
3949
|
+
* 关闭后本进程不再留点、不再写台账;台账与回退路由本身仍可用(已有回滚点仍能查看 / 回退 / 清理)。
|
|
3950
|
+
*/
|
|
3951
|
+
setRollbackEnabled(readRollbackEnabledSync(syncDir));
|
|
3931
3952
|
const marketDir = join(dataDir, 'market');
|
|
3932
3953
|
// Phase 6:迁移历史审计目录(统一历史引擎;加入 RESERVED_INTERNAL_PREFIXES 防 F23 投毒链)
|
|
3933
3954
|
const historyDir = join(dataDir, MIGRATION_HISTORY_DIR);
|
|
@@ -4252,9 +4273,10 @@ export function apply(ctx, config) {
|
|
|
4252
4273
|
}, 'config-manager: profile runtime heartbeat');
|
|
4253
4274
|
// ------------------------------------------- S1:回合自动留点(宿主真接线)
|
|
4254
4275
|
//
|
|
4255
|
-
// 订阅 `session/event`(@deepseek-ai/dsh-session 的 post-commit fire-and-forget feed
|
|
4256
|
-
//
|
|
4257
|
-
//
|
|
4276
|
+
// 订阅 `session/event`(@deepseek-ai/dsh-session 的 post-commit fire-and-forget feed)。
|
|
4277
|
+
// **留点在 `turn/start` 拍**(点的「工作区态」必须是该回合开始**之前**的样子 —— 这条口径不能动,
|
|
4278
|
+
// 见 rollback-point.ts RollbackAutoTracker.noteEvent);`turn/end` 只更新「缺省边界」,不再拍点。
|
|
4279
|
+
// 没有手动「拍点」入口 —— 留点完全由回合驱动。
|
|
4258
4280
|
//
|
|
4259
4281
|
// 纪律(AGENTS.md 铁律 + captain 要求):**订阅与每个回调都 try/catch 包裹**,任何异常
|
|
4260
4282
|
// 只记 warn:改坏宿主启动 = blocker。事件回调用一条串行链喂给留点器,保证同一会话的
|
|
@@ -4273,7 +4295,9 @@ export function apply(ctx, config) {
|
|
|
4273
4295
|
return { ok: false, code: 'session-fork-unavailable', detail: 'facade has no forkSession' };
|
|
4274
4296
|
}
|
|
4275
4297
|
const result = await forkSession.call(forkPort, sessionId, boundarySeq);
|
|
4276
|
-
|
|
4298
|
+
// 形状加固:`in` 对 null / 原始值会抛 TypeError —— 端口漂移时应当是**结构化失败**,
|
|
4299
|
+
// 而不是一个没人接的异常(宿主版本换形状时这条最先生效)。
|
|
4300
|
+
if (result !== null && typeof result === 'object' && 'childSessionId' in result) {
|
|
4277
4301
|
// warning 一路带到回退结果(引擎落成 rollback.guidance.sessionNotAttached):
|
|
4278
4302
|
// 归位失败**不静默** —— 会话确实回退了,但它会出现在侧栏「未分组」。
|
|
4279
4303
|
return result.warning !== undefined
|
|
@@ -4289,10 +4313,25 @@ export function apply(ctx, config) {
|
|
|
4289
4313
|
// 「会话没有 cwd」这类**形状合法但信息不全**的情况只记一次日志(避免每回合刷屏);
|
|
4290
4314
|
// 该点本身仍带 incomplete=true + workspace.reasonCode='no-chunks',界面能如实展示。
|
|
4291
4315
|
const chunklessLogged = new Set();
|
|
4316
|
+
/** 子代理会话只 warn 一次(见下面 'subagent-session' 分支) */
|
|
4317
|
+
const inertSessionsLogged = new Set();
|
|
4292
4318
|
const listener = (session, event) => {
|
|
4319
|
+
// issue #88/#89 第一道门:关闭时回调什么都不做。正常路径下关闭时压根没有订阅
|
|
4320
|
+
//(detach 已解绑),这里是「解绑与已派发事件之间」的竞态兜底。
|
|
4321
|
+
if (!isRollbackEnabled())
|
|
4322
|
+
return;
|
|
4293
4323
|
try {
|
|
4294
4324
|
const bindingResult = rollbackSessionBindingOf(session);
|
|
4295
4325
|
if (!bindingResult.ok) {
|
|
4326
|
+
if (bindingResult.code === 'subagent-session') {
|
|
4327
|
+
// 子代理会话按设计不留点:**每个会话只记一次**(否则每个事件刷一行 warn),
|
|
4328
|
+
// 但仍然留痕 —— 不让「这个会话为什么没有点」变成无解释的沉默。
|
|
4329
|
+
if (!inertSessionsLogged.has(bindingResult.detail)) {
|
|
4330
|
+
inertSessionsLogged.add(bindingResult.detail);
|
|
4331
|
+
host.log.warn('rollback auto point skipped: ' + bindingResult.code + ' ' + bindingResult.detail);
|
|
4332
|
+
}
|
|
4333
|
+
return;
|
|
4334
|
+
}
|
|
4296
4335
|
// 形状不匹配**不静默**:给显式机器码,宿主日志留痕
|
|
4297
4336
|
host.log.warn('rollback auto point skipped: ' + bindingResult.code + ' ' + bindingResult.detail);
|
|
4298
4337
|
return;
|
|
@@ -4318,17 +4357,42 @@ export function apply(ctx, config) {
|
|
|
4318
4357
|
}
|
|
4319
4358
|
};
|
|
4320
4359
|
let off = null;
|
|
4321
|
-
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
|
|
4327
|
-
|
|
4360
|
+
/**
|
|
4361
|
+
* 订阅 / 解绑成对:开关关闭时**真的解绑**(不是「订阅了什么都不做」—— issue #88 明确要求
|
|
4362
|
+
* 「关闭后不再订阅 session/event」)。attach 幂等;订阅抛错只丢这一个入口,不打断宿主启动。
|
|
4363
|
+
*/
|
|
4364
|
+
const attach = () => {
|
|
4365
|
+
if (off !== null)
|
|
4366
|
+
return;
|
|
4328
4367
|
try {
|
|
4329
|
-
off
|
|
4368
|
+
off = ctx.on('session/event', listener);
|
|
4369
|
+
}
|
|
4370
|
+
catch (error) {
|
|
4371
|
+
off = null;
|
|
4372
|
+
host.log.warn('rollback auto point subscribe failed: ' + (error instanceof Error ? error.message : String(error)));
|
|
4373
|
+
}
|
|
4374
|
+
};
|
|
4375
|
+
const detach = () => {
|
|
4376
|
+
const current = off;
|
|
4377
|
+
off = null;
|
|
4378
|
+
if (current === null)
|
|
4379
|
+
return;
|
|
4380
|
+
try {
|
|
4381
|
+
current();
|
|
4330
4382
|
}
|
|
4331
4383
|
catch { /* 解绑失败不影响卸载 */ }
|
|
4384
|
+
};
|
|
4385
|
+
if (isRollbackEnabled())
|
|
4386
|
+
attach();
|
|
4387
|
+
// 活切换:写偏好的一方改开关,这里即时 attach / detach。事件回调**不读文件** ——
|
|
4388
|
+
// 每回合读一次磁盘正是本 issue 要消灭的开销形态。
|
|
4389
|
+
const unlisten = onRollbackEnabledChange((on) => { if (on)
|
|
4390
|
+
attach();
|
|
4391
|
+
else
|
|
4392
|
+
detach(); });
|
|
4393
|
+
return () => {
|
|
4394
|
+
unlisten();
|
|
4395
|
+
detach();
|
|
4332
4396
|
rollbackTracker.stop();
|
|
4333
4397
|
};
|
|
4334
4398
|
}, 'config-manager: rollback auto points');
|
|
@@ -4368,6 +4432,12 @@ export function apply(ctx, config) {
|
|
|
4368
4432
|
warnings: preview.warnings,
|
|
4369
4433
|
};
|
|
4370
4434
|
}
|
|
4435
|
+
// ①b 与 HTTP 路径**同一道**「运行中对话」门:命令由会话循环派发、日志刚被 append,
|
|
4436
|
+
// `session.active` 通常为真;缺这道门就会出现「HTTP 会 409 拒绝的『运行中改工作区』
|
|
4437
|
+
// 被命令入口放行」—— 写回的文件随即被运行中的步骤覆盖(撕裂态)。
|
|
4438
|
+
if (preview.session.active === true && sessionActiveGateApplies(preview, true, host.safeModeIsBlocked?.() ?? false)) {
|
|
4439
|
+
return sessionActiveDenied(id, preview);
|
|
4440
|
+
}
|
|
4371
4441
|
// ② 真执行:userConfirmed **恒 true** —— 用户敲下 /rollback <id> 就是那次确认
|
|
4372
4442
|
// (命令入口没有第二个确认按钮;其余门全量生效)。
|
|
4373
4443
|
return await rollbackEngine.rewind({
|
package/lib/routes/messages.d.ts
CHANGED
|
@@ -35,6 +35,7 @@ export declare const zh: {
|
|
|
35
35
|
readonly 'route.recoverySessionLayoutPartial': '会话布局归位未全部完成(成功 {applied},失败 {failed},跳过 {skipped})';
|
|
36
36
|
readonly 'route.rollbackPointRewind': '回滚到回滚点:{outcome}';
|
|
37
37
|
readonly 'route.rollbackPointDelete': '删除回滚点 {id}';
|
|
38
|
+
readonly 'route.rollbackPointCleanup': '清理回滚点台账:删除 {records} 个点';
|
|
38
39
|
readonly 'route.recoveryOperationSummary': '恢复操作 {action}';
|
|
39
40
|
};
|
|
40
41
|
export declare const en: Record<keyof typeof zh, string>;
|
package/lib/routes/messages.js
CHANGED
|
@@ -54,6 +54,7 @@ export const zh = {
|
|
|
54
54
|
'route.recoverySessionLayoutPartial': '会话布局归位未全部完成(成功 {applied},失败 {failed},跳过 {skipped})',
|
|
55
55
|
'route.rollbackPointRewind': '回滚到回滚点:{outcome}',
|
|
56
56
|
'route.rollbackPointDelete': '删除回滚点 {id}',
|
|
57
|
+
'route.rollbackPointCleanup': '清理回滚点台账:删除 {records} 个点',
|
|
57
58
|
'route.recoveryOperationSummary': '恢复操作 {action}',
|
|
58
59
|
};
|
|
59
60
|
export const en = {
|
|
@@ -92,6 +93,7 @@ export const en = {
|
|
|
92
93
|
'route.recoverySessionLayoutPartial': 'Session layout relocation did not fully complete ({applied} succeeded, {failed} failed, {skipped} skipped)',
|
|
93
94
|
'route.rollbackPointRewind': 'Rewind to rollback point: {outcome}',
|
|
94
95
|
'route.rollbackPointDelete': 'Deleted rollback point {id}',
|
|
96
|
+
'route.rollbackPointCleanup': 'Cleaned up rollback-point ledger: {records} point(s) deleted',
|
|
95
97
|
'route.recoveryOperationSummary': 'Recovery operation {action}',
|
|
96
98
|
};
|
|
97
99
|
registerMessageCatalog({ id: 'routes-domain', zh, en });
|
package/lib/routes/prefs.js
CHANGED
|
@@ -7,11 +7,30 @@
|
|
|
7
7
|
* path/methods 就是唯一声明处;围栏、方法判定与顶层异常处理由 src/routes/kit.ts 在注册点统一提供。
|
|
8
8
|
*/
|
|
9
9
|
import { endpoint, requireJsonObject, writeJson } from './kit.js';
|
|
10
|
+
import { EnvironmentLockUnavailableError, runWithMutationLock } from '../utils/env-lock.js';
|
|
10
11
|
import { PLUGIN_VERSION, STAR_PROMPT_REPO_URL } from '../index.js';
|
|
11
12
|
import { readUiPrefs, updateUiPrefs } from '../sync/ui-prefs.js';
|
|
12
13
|
import { UpdateChecker, wantsForcedUpdateCheck } from '../core/update-check.js';
|
|
13
14
|
export function prefsRoutes(env) {
|
|
14
|
-
const { syncDir, selfUpdate, withMutationGate, } = env;
|
|
15
|
+
const { host, syncDir, selfUpdate, withMutationGate, } = env;
|
|
16
|
+
/**
|
|
17
|
+
* 偏好写必须与其它写路径**同门**(④A):ui-prefs.json 由 star-prompt / release-notes-prompt /
|
|
18
|
+
* 回滚开关三个端点共写。`updateUiPrefs` 虽是 read→merge→write,但两个端点**并发**时仍会互相覆盖
|
|
19
|
+
* 字段(典型:刚打开的 rollbackEnabled 被另一个端点的旧快照抹掉 ⇒ 重启后功能自己关掉,fail-closed)。
|
|
20
|
+
* 被 SAFE MODE / 环境锁挡住时直接回 423 并返回 null(调用方 return,零写入)。
|
|
21
|
+
*/
|
|
22
|
+
const writePrefsOrBlocked = async (res, patch) => {
|
|
23
|
+
try {
|
|
24
|
+
return await runWithMutationLock(host.mutationLock, { op: 'ui-prefs', target: 'star/release-notes', isBlocked: () => host.safeModeIsBlocked?.() ?? false }, async () => await updateUiPrefs(syncDir, patch));
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (error instanceof EnvironmentLockUnavailableError) {
|
|
28
|
+
writeJson(res, 423, { error: error.message, code: 'mutation-locked' });
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
15
34
|
// 版本更新检查器:每进程一个(进程内缓存 10 分钟;用户点「重新检查」走 ?force=1)。
|
|
16
35
|
// 网络失败/超时/响应畸形一律结构化回传(ok:false + 原因),绝不「失败当最新」。
|
|
17
36
|
const updateChecker = new UpdateChecker(PLUGIN_VERSION);
|
|
@@ -51,7 +70,9 @@ export function prefsRoutes(env) {
|
|
|
51
70
|
if (body['clicked'] === true) {
|
|
52
71
|
patch['starPromptClicked'] = true;
|
|
53
72
|
}
|
|
54
|
-
const next = await
|
|
73
|
+
const next = await writePrefsOrBlocked(res, patch);
|
|
74
|
+
if (next === null)
|
|
75
|
+
return;
|
|
55
76
|
writeJson(res, 200, {
|
|
56
77
|
ok: true,
|
|
57
78
|
firstSeenAt: next.starPromptFirstSeenAt,
|
|
@@ -93,7 +114,9 @@ export function prefsRoutes(env) {
|
|
|
93
114
|
if (body['dismissed'] === true) {
|
|
94
115
|
patch['releaseNotesDismissed'] = true;
|
|
95
116
|
}
|
|
96
|
-
const next = await
|
|
117
|
+
const next = await writePrefsOrBlocked(res, patch);
|
|
118
|
+
if (next === null)
|
|
119
|
+
return;
|
|
97
120
|
writeJson(res, 200, {
|
|
98
121
|
ok: true,
|
|
99
122
|
lastSeenVersion: next.releaseNotesLastSeenVersion,
|
package/lib/routes/recovery.d.ts
CHANGED
|
@@ -6,4 +6,25 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { WebRoute } from './kit.ts';
|
|
8
8
|
import type { RoutesEnv } from './context.ts';
|
|
9
|
+
import type { RollbackPreviewResult, RollbackRewindResult } from '../core/rollback-point.ts';
|
|
10
|
+
/**
|
|
11
|
+
* t8 的硬拒**什么时候该生效**(判定链里的位置,t8 实现说明的选项 ②)。
|
|
12
|
+
*
|
|
13
|
+
* 既有护栏**优先**:确认门没开(`userConfirmed !== true`)、该点本来就不可回退
|
|
14
|
+
* (`preview.ok !== true || preview.canRestore !== true`:protected / record-incomplete /
|
|
15
|
+
* 记录不存在 / 存储不可用)、或 SAFE MODE 正拦着(由锁门给 423)时,**把结论让给引擎自己的
|
|
16
|
+
* 结构化拒绝** —— 那些情形下说「对话正在运行」是误导:用户等多久都退不了。
|
|
17
|
+
* 只有「这次本来能退、只是因为它正在运行而不能动工作区」时,才用 `session-active`。
|
|
18
|
+
*
|
|
19
|
+
* 两道门(R1 预检 / R2 锁内二道保险)共用这一份判据,绝不各写一套。
|
|
20
|
+
*/
|
|
21
|
+
export declare function sessionActiveGateApplies(preview: RollbackPreviewResult, userConfirmed: boolean, safeModeBlocked: boolean): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* 运行中对话的拒绝结果(t8):**形状与引擎的 rewind 结果同构**,两处(路由预检 / 锁内二道保险)
|
|
24
|
+
* 共用一份,界面照常走「结果 → 文案」那条链。`segments` 恒为空 = 一段都没动(零写入)。
|
|
25
|
+
*
|
|
26
|
+
* 判据来源只有一个:**preview 已算出的 `session.active`**(引擎按会话日志 mtime 静默窗算)——
|
|
27
|
+
* 路由**绝不自己 stat 会话日志**(那会变成第二份判据,两处迟早漂移)。
|
|
28
|
+
*/
|
|
29
|
+
export declare function sessionActiveDenied(id: string, preview: RollbackPreviewResult): RollbackRewindResult;
|
|
9
30
|
export declare function recoveryRoutes(env: RoutesEnv): WebRoute[];
|
package/lib/routes/recovery.js
CHANGED
|
@@ -9,7 +9,9 @@ import { endpoint, readJsonBody, writeJson } from './kit.js';
|
|
|
9
9
|
// `msg` 同源(t2 起 this.msg = engineMsg),但它是**活翻译器**,不依赖 env 是否注入取词器。
|
|
10
10
|
import { engineMsg } from './messages.js';
|
|
11
11
|
import { isValidOperationId } from '../core/journal.js';
|
|
12
|
-
import { RollbackPointEngine } from '../core/rollback-point.js';
|
|
12
|
+
import { RollbackLedgerRefusedError, RollbackPointEngine } from '../core/rollback-point.js';
|
|
13
|
+
import { isRollbackEnabled, setRollbackEnabled } from '../core/rollback-gate.js';
|
|
14
|
+
import { readRollbackEnabledSync, updateUiPrefs } from '../sync/ui-prefs.js';
|
|
13
15
|
import { reconcileForkSessions } from '../core/fork-reconcile.js';
|
|
14
16
|
import { EnvironmentLockUnavailableError, runWithMutationLock } from '../utils/env-lock.js';
|
|
15
17
|
import { scanSessionHealth } from '../utils/session-health-scan.js';
|
|
@@ -100,7 +102,7 @@ function rewindTouchesWorkspace(input) {
|
|
|
100
102
|
*
|
|
101
103
|
* 两道门(R1 预检 / R2 锁内二道保险)共用这一份判据,绝不各写一套。
|
|
102
104
|
*/
|
|
103
|
-
function sessionActiveGateApplies(preview, userConfirmed, safeModeBlocked) {
|
|
105
|
+
export function sessionActiveGateApplies(preview, userConfirmed, safeModeBlocked) {
|
|
104
106
|
if (safeModeBlocked)
|
|
105
107
|
return false;
|
|
106
108
|
if (!userConfirmed)
|
|
@@ -114,7 +116,7 @@ function sessionActiveGateApplies(preview, userConfirmed, safeModeBlocked) {
|
|
|
114
116
|
* 判据来源只有一个:**preview 已算出的 `session.active`**(引擎按会话日志 mtime 静默窗算)——
|
|
115
117
|
* 路由**绝不自己 stat 会话日志**(那会变成第二份判据,两处迟早漂移)。
|
|
116
118
|
*/
|
|
117
|
-
function sessionActiveDenied(id, preview) {
|
|
119
|
+
export function sessionActiveDenied(id, preview) {
|
|
118
120
|
return {
|
|
119
121
|
ok: false,
|
|
120
122
|
outcome: 'denied',
|
|
@@ -221,8 +223,14 @@ function forkSeamOf(sessions) {
|
|
|
221
223
|
try {
|
|
222
224
|
const result = await fork.call(sessions, sessionId, boundarySeq);
|
|
223
225
|
const childId = result !== null && typeof result === 'object' ? result.childSessionId : undefined;
|
|
224
|
-
if (typeof childId === 'string' && childId !== '')
|
|
225
|
-
|
|
226
|
+
if (typeof childId === 'string' && childId !== '') {
|
|
227
|
+
// 归位失败(宿主三档都没命中)必须一路带到结果里:丢掉它就会出现「HTTP 入口说已归位、
|
|
228
|
+
// /rollback 命令说没归位」的矛盾(子会话实际停在侧栏「未分组」)。
|
|
229
|
+
const warning = result.warning;
|
|
230
|
+
return typeof warning === 'string' && warning !== ''
|
|
231
|
+
? { ok: true, childSessionId: childId, warning }
|
|
232
|
+
: { ok: true, childSessionId: childId };
|
|
233
|
+
}
|
|
226
234
|
const error = result !== null && typeof result === 'object' ? result.error : undefined;
|
|
227
235
|
return { ok: false, code: 'fork-failed', detail: typeof error === 'string' ? error : 'fork-returned-no-id' };
|
|
228
236
|
}
|
|
@@ -534,6 +542,9 @@ export function recoveryRoutes(env) {
|
|
|
534
542
|
log: (line) => host.log.warn(line),
|
|
535
543
|
...(forkSeam !== undefined ? { fork: forkSeam } : {}),
|
|
536
544
|
});
|
|
545
|
+
// 插件偏好文件与 /sync/ui-prefs 同一份(dataDir/sync/ui-prefs.json);回滚点开关存在那里,
|
|
546
|
+
// 所以这里派生同一个目录,绝不另立第二个偏好文件。
|
|
547
|
+
const syncDir = join(dataDir, 'sync');
|
|
537
548
|
const sub = segments.slice(1);
|
|
538
549
|
const methodNotAllowed = () => { writeJson(res, 405, { error: 'method not allowed', code: 'method-not-allowed' }); };
|
|
539
550
|
const reply = (value) => {
|
|
@@ -559,17 +570,126 @@ export function recoveryRoutes(env) {
|
|
|
559
570
|
* 仍读不到就不加该字段(界面回落显示会话 id),**绝不从首条消息编造标题**。
|
|
560
571
|
*/
|
|
561
572
|
const titled = await applySessionLogTitles(points, join(sessionHealth.homeDir, 'sessions'));
|
|
562
|
-
|
|
573
|
+
// 列表响应里的 storage 也带上开关与台账占用:界面**只读这一个响应**就能画开关、
|
|
574
|
+
// 画占用、并决定聊天入口是否收起(少一次请求,也少一处口径分叉)。
|
|
575
|
+
const storageWithPrefs = {
|
|
576
|
+
...listed.storage,
|
|
577
|
+
rollbackEnabled: readRollbackEnabledSync(syncDir),
|
|
578
|
+
rollbackActive: isRollbackEnabled(),
|
|
579
|
+
};
|
|
580
|
+
writeJson(res, listed.storage.available ? 200 : 503, { ...listed, storage: storageWithPrefs, points: titled });
|
|
563
581
|
return;
|
|
564
582
|
}
|
|
565
583
|
// 存储栈状态(缺失时给结构化组合指引;**不崩**)
|
|
584
|
+
// issue #89 P2:随状态一并回传**台账占用**(记录数 / 字节 / 预算 / 已淘汰数,见 engine.storageStatus 的 ledger)
|
|
585
|
+
// 与开关(rollbackEnabled = 磁盘偏好;rollbackActive = 本进程当前是否真的在留点)。
|
|
566
586
|
if (sub.length === 1 && sub[0] === 'storage') {
|
|
567
587
|
if (req.method !== 'GET') {
|
|
568
588
|
methodNotAllowed();
|
|
569
589
|
return;
|
|
570
590
|
}
|
|
571
591
|
const status = await engine.storageStatus();
|
|
572
|
-
writeJson(res, status.available ? 200 : 503, {
|
|
592
|
+
writeJson(res, status.available ? 200 : 503, {
|
|
593
|
+
ok: status.available,
|
|
594
|
+
rollbackEnabled: readRollbackEnabledSync(syncDir),
|
|
595
|
+
rollbackActive: isRollbackEnabled(),
|
|
596
|
+
...status,
|
|
597
|
+
});
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
// 回合自动留点的总开关(issue #88 / #89)。
|
|
601
|
+
//
|
|
602
|
+
// 为什么要读写两个语义:磁盘偏好(rollbackEnabled)是**用户意图**、随 self 分区进备份;
|
|
603
|
+
// 进程 gate(rollbackActive)是**此刻真正生效**的东西(关闭后订阅已解绑)。两者正常一致,
|
|
604
|
+
// 只在「另一进程改了文件」这类边界上短暂分叉 —— 分歧就如实分开报,不合并成一个布尔。
|
|
605
|
+
//
|
|
606
|
+
// 写路径过 mutation gate:它改的是「宿主会不会每回合写盘」的行为语义,与 rewind / delete 同级
|
|
607
|
+
//(都在同一把锁里,拒绝一律发生在写任何字节之前)。
|
|
608
|
+
if (sub.length === 1 && sub[0] === 'prefs') {
|
|
609
|
+
if (req.method === 'GET') {
|
|
610
|
+
writeJson(res, 200, { ok: true, rollbackEnabled: readRollbackEnabledSync(syncDir), rollbackActive: isRollbackEnabled() });
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
if (req.method !== 'POST') {
|
|
614
|
+
methodNotAllowed();
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
try {
|
|
618
|
+
const body = await readJsonBody(req);
|
|
619
|
+
if (body === undefined) {
|
|
620
|
+
writeJson(res, 400, { ok: false, code: 'invalid-input', error: 'invalid JSON body' });
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
const wanted = body['rollbackEnabled'];
|
|
624
|
+
if (typeof wanted !== 'boolean') {
|
|
625
|
+
writeJson(res, 400, { ok: false, code: 'invalid-input', error: 'rollbackEnabled must be a boolean' });
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
await runWithMutationLock(host.mutationLock, { op: 'rollback-prefs', target: 'rollbackEnabled', isBlocked: () => host.safeModeIsBlocked?.() ?? false }, async () => {
|
|
629
|
+
await updateUiPrefs(syncDir, { rollbackEnabled: wanted });
|
|
630
|
+
// 写盘成功才切进程 gate:写失败却把功能打开,等于「界面说开着、实际不留点」
|
|
631
|
+
setRollbackEnabled(wanted);
|
|
632
|
+
});
|
|
633
|
+
writeJson(res, 200, { ok: true, rollbackEnabled: wanted, rollbackActive: isRollbackEnabled() });
|
|
634
|
+
}
|
|
635
|
+
catch (error) {
|
|
636
|
+
if (error instanceof EnvironmentLockUnavailableError) {
|
|
637
|
+
host.log.warn('mutation lock blocked: op=rollback-prefs reason=' + error.reason);
|
|
638
|
+
writeJson(res, 423, { error: error.message, code: 'mutation-locked' });
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
642
|
+
}
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
// 台账清理(issue #89 P1/P2):把「已经涨上去的占用」收回来。
|
|
646
|
+
//
|
|
647
|
+
// 为什么单独一条子路径而不是并进「磁盘占用清理」:那条清理的候选集被四条不变量钉死
|
|
648
|
+
// (只有可重建区 + 显式勾选的过期导出;snapshots/sync 永不进候选)。回滚点是**数据**不是
|
|
649
|
+
// 可重建区,用户没显式勾选就不能被任何「一键清理」顺走 —— 所以它是自己这条显式入口,
|
|
650
|
+
// 且默认只删自动点(救援点要显式勾选)。语义与单条 delete 一致,只是作用域是整个台账。
|
|
651
|
+
if (sub.length === 1 && sub[0] === 'cleanup') {
|
|
652
|
+
if (req.method !== 'POST') {
|
|
653
|
+
methodNotAllowed();
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
try {
|
|
657
|
+
const body = await readJsonBody(req);
|
|
658
|
+
if (body === undefined) {
|
|
659
|
+
writeJson(res, 400, { ok: false, code: 'invalid-input', error: 'invalid JSON body' });
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
// 与单条 delete 同一条 fail-closed 确认门:必须**恰好** true,缺省不放行
|
|
663
|
+
if (body['userConfirmed'] !== true) {
|
|
664
|
+
writeJson(res, 409, { ok: false, code: 'confirmation-required' });
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
const includeGuards = body['includeGuards'] === true;
|
|
668
|
+
const result = await runWithMutationLock(host.mutationLock, { op: 'rollback-cleanup', target: includeGuards ? 'auto+guard' : 'auto', isBlocked: () => host.safeModeIsBlocked?.() ?? false }, async () => await engine.purgeLedger({ includeGuards }));
|
|
669
|
+
await tryAppendHistory({
|
|
670
|
+
kind: 'recovery',
|
|
671
|
+
result: 'success',
|
|
672
|
+
sections: [],
|
|
673
|
+
source: 'recovery',
|
|
674
|
+
summary: engineMsg('route.rollbackPointCleanup', { records: String(result.removedRecords) }),
|
|
675
|
+
});
|
|
676
|
+
writeJson(res, 200, { ok: true, ...result });
|
|
677
|
+
}
|
|
678
|
+
catch (error) {
|
|
679
|
+
if (error instanceof EnvironmentLockUnavailableError) {
|
|
680
|
+
host.log.warn('mutation lock blocked: op=rollback-cleanup reason=' + error.reason);
|
|
681
|
+
writeJson(res, 423, { error: error.message, code: 'mutation-locked' });
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
// 引擎侧的**显式拒绝**带机器码(存储不可用 / 台账读不出):按码映射 HTTP,
|
|
685
|
+
// 不回落成泛化 500 —— 界面要能说清「是存储栈缺了」还是「台账真读不出来」。
|
|
686
|
+
if (error instanceof RollbackLedgerRefusedError) {
|
|
687
|
+
host.log.warn('rollback cleanup refused: ' + error.code + ' ' + error.message);
|
|
688
|
+
writeJson(res, error.code === 'storage-unavailable' ? 503 : 500, { ok: false, code: error.code, detail: error.message });
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
692
|
+
}
|
|
573
693
|
return;
|
|
574
694
|
}
|
|
575
695
|
// 单条详情(只读:两态摘要 + 完整记录)
|
package/lib/routes/sync.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* path/methods 就是唯一声明处;围栏、方法判定与顶层异常处理由 src/routes/kit.ts 在注册点统一提供。
|
|
6
6
|
*/
|
|
7
7
|
import { endpoint, requireJsonObject, readJsonBody, writeJson } from './kit.js';
|
|
8
|
+
import { EnvironmentLockUnavailableError, runWithMutationLock } from '../utils/env-lock.js';
|
|
8
9
|
import { FileSnapshotStore } from '../core/index.js';
|
|
9
10
|
import { rollback as performRollback } from '../core/rollback.js';
|
|
10
11
|
/**
|
|
@@ -273,10 +274,19 @@ export function syncRoutes(env) {
|
|
|
273
274
|
const body = await requireJsonObject(req);
|
|
274
275
|
try {
|
|
275
276
|
const channel = parseSyncChannel(body['lastSyncChannel']);
|
|
276
|
-
|
|
277
|
+
// ④A:与回滚开关端点**同一道写入门**。ui-prefs.json 由三个端点共写,`updateUiPrefs` 虽是
|
|
278
|
+
// read→merge→write,但两个不同端点并发时仍会互相覆盖字段(例如把刚打开的 rollbackEnabled 抹掉,
|
|
279
|
+
// 重启后功能自己关掉)。同类写串行是既有架构口径。
|
|
280
|
+
await runWithMutationLock(host.mutationLock, { op: 'ui-prefs', target: 'lastSyncChannel', isBlocked: () => host.safeModeIsBlocked?.() ?? false }, async () => {
|
|
281
|
+
await updateUiPrefs(syncDir, { ...(channel !== undefined ? { lastSyncChannel: channel } : {}) });
|
|
282
|
+
});
|
|
277
283
|
writeJson(res, 200, { ok: true, lastSyncChannel: channel });
|
|
278
284
|
}
|
|
279
285
|
catch (error) {
|
|
286
|
+
if (error instanceof EnvironmentLockUnavailableError) {
|
|
287
|
+
writeJson(res, 423, { error: error.message, code: 'mutation-locked' });
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
280
290
|
writeSyncRouteError(res, error);
|
|
281
291
|
}
|
|
282
292
|
}),
|
|
@@ -172,7 +172,7 @@ export interface SectionMeta {
|
|
|
172
172
|
*/
|
|
173
173
|
readonly configSnapshot: boolean;
|
|
174
174
|
}
|
|
175
|
-
/** 分区载荷版本:当前
|
|
175
|
+
/** 分区载荷版本:当前 17 个分区统一为 1(唯一字面量;分区可独立升级时只改对应条目的引用) */
|
|
176
176
|
export declare const SECTION_DATA_VERSION: 1;
|
|
177
177
|
/**
|
|
178
178
|
* 分区注册表(**Record<SectionId, SectionMeta>**:漏注册/写错 id → 编译失败)。
|