dsh-config-manager 0.1.78 → 0.1.80
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/credentials.d.ts +6 -2
- package/lib/adapters/credentials.js +19 -3
- package/lib/adapters/file-collection.js +2 -1
- package/lib/adapters/mcp.d.ts +48 -2
- package/lib/adapters/mcp.js +265 -3
- package/lib/adapters/messages.d.ts +3 -0
- package/lib/adapters/messages.js +7 -0
- package/lib/adapters/plugin-files.js +2 -1
- package/lib/adapters/plugins.js +105 -20
- package/lib/adapters/pnpm-workspace.d.ts +32 -0
- package/lib/adapters/pnpm-workspace.js +113 -2
- package/lib/adapters/prompts.d.ts +6 -2
- package/lib/adapters/prompts.js +17 -2
- package/lib/adapters/providers.d.ts +19 -2
- package/lib/adapters/providers.js +59 -3
- package/lib/adapters/settings.d.ts +6 -2
- package/lib/adapters/settings.js +13 -2
- package/lib/adapters/skills.d.ts +25 -1
- package/lib/adapters/skills.js +60 -0
- package/lib/adapters/ui.d.ts +6 -2
- package/lib/adapters/ui.js +13 -2
- package/lib/adapters/units.d.ts +18 -0
- package/lib/adapters/units.js +39 -0
- package/lib/adapters/workspaces.js +2 -1
- package/lib/client.d.ts +401 -112
- package/lib/client.js +2899 -1525
- package/lib/core/backup.js +20 -0
- package/lib/core/mcp-connector-store.d.ts +159 -0
- package/lib/core/mcp-connector-store.js +445 -0
- package/lib/core/messages-core.d.ts +2 -0
- package/lib/core/messages-core.js +4 -0
- package/lib/core/messages.d.ts +20 -0
- package/lib/core/messages.js +44 -0
- package/lib/core/phase3-host.js +18 -1
- package/lib/core/plugin-cli.d.ts +22 -2
- package/lib/core/plugin-cli.js +22 -3
- package/lib/core/recovery-orchestrator.d.ts +3 -1
- package/lib/core/recovery-orchestrator.js +75 -16
- package/lib/core/rollback-activity.d.ts +89 -0
- package/lib/core/rollback-activity.js +350 -0
- package/lib/core/rollback-ledger.d.ts +0 -9
- package/lib/core/rollback-ledger.js +37 -0
- package/lib/core/rollback-merge.d.ts +22 -2
- package/lib/core/rollback-merge.js +63 -13
- package/lib/core/rollback-point.d.ts +93 -0
- package/lib/core/rollback-point.js +420 -101
- package/lib/core/rollback-undo-journal.d.ts +123 -0
- package/lib/core/rollback-undo-journal.js +517 -0
- package/lib/core/self-update.js +2 -4
- package/lib/core/text-fidelity.d.ts +89 -0
- package/lib/core/text-fidelity.js +176 -0
- package/lib/core/types.d.ts +12 -3
- package/lib/core/update-check.js +2 -4
- package/lib/core/validator.d.ts +9 -0
- package/lib/core/validator.js +12 -0
- package/lib/foreign/bundle.js +42 -3
- package/lib/foreign/claude-code.js +358 -26
- package/lib/foreign/claude-sessions.d.ts +51 -8
- package/lib/foreign/claude-sessions.js +111 -13
- package/lib/foreign/read-claude-code.d.ts +33 -4
- package/lib/foreign/read-claude-code.js +446 -65
- package/lib/foreign/read-claude-plugins.d.ts +36 -0
- package/lib/foreign/read-claude-plugins.js +403 -0
- package/lib/foreign/registry.js +25 -2
- package/lib/foreign/truth-table.js +6 -4
- package/lib/foreign/types.d.ts +170 -2
- package/lib/index.d.ts +14 -5
- package/lib/index.js +60 -16
- package/lib/market/git-file-writer.js +7 -1
- package/lib/profiles/dsh-profile-launcher.d.ts +20 -0
- package/lib/profiles/dsh-profile-launcher.js +58 -1
- package/lib/profiles/dsh-profile-shared.d.ts +8 -2
- package/lib/profiles/dsh-profile-shared.js +8 -2
- package/lib/routes/foreign.js +8 -1
- package/lib/routes/index.d.ts +1 -1
- package/lib/routes/kit.d.ts +2 -2
- package/lib/routes/sync.js +173 -9
- package/lib/schema/manifest.d.ts +6 -1
- package/lib/schema/manifest.js +55 -0
- package/lib/schema/types.d.ts +47 -2
- package/lib/sync/ancestor.d.ts +22 -0
- package/lib/sync/ancestor.js +66 -2
- package/lib/sync/autosync-config.d.ts +73 -0
- package/lib/sync/autosync-config.js +89 -0
- package/lib/sync/autosync-scheduler.d.ts +80 -6
- package/lib/sync/autosync-scheduler.js +263 -40
- package/lib/sync/gist/gist-transport.d.ts +18 -1
- package/lib/sync/gist/gist-transport.js +42 -3
- package/lib/sync/gist/messages.d.ts +2 -0
- package/lib/sync/gist/messages.js +5 -0
- package/lib/sync/git/git-transport.d.ts +42 -3
- package/lib/sync/git/git-transport.js +146 -3
- package/lib/sync/merge.d.ts +47 -14
- package/lib/sync/merge.js +242 -222
- package/lib/sync/messages.d.ts +8 -0
- package/lib/sync/messages.js +19 -0
- package/lib/sync/risk.d.ts +19 -2
- package/lib/sync/risk.js +10 -3
- package/lib/sync/s3/messages.d.ts +6 -0
- package/lib/sync/s3/messages.js +13 -0
- package/lib/sync/s3/s3-transport.d.ts +9 -1
- package/lib/sync/s3/s3-transport.js +78 -6
- package/lib/sync/s3/sigv4.js +11 -2
- package/lib/sync/sync-engine.d.ts +212 -8
- package/lib/sync/sync-engine.js +514 -58
- package/lib/sync/sync-history.d.ts +11 -0
- package/lib/sync/sync-history.js +20 -0
- package/lib/sync/sync-state.d.ts +43 -0
- package/lib/sync/sync-state.js +44 -0
- package/lib/sync/sync-watcher.d.ts +76 -0
- package/lib/sync/sync-watcher.js +202 -0
- package/lib/sync/text-merge.d.ts +85 -0
- package/lib/sync/text-merge.js +513 -0
- package/lib/sync/transport.d.ts +48 -0
- package/lib/sync/transport.js +12 -0
- package/lib/sync/watch-scope.d.ts +38 -0
- package/lib/sync/watch-scope.js +95 -0
- package/lib/sync/webdav/webdav-transport.d.ts +16 -2
- package/lib/sync/webdav/webdav-transport.js +128 -11
- package/lib/ui/foreign-view.js +10 -0
- package/lib/ui/i18n.d.ts +9 -0
- package/lib/ui/i18n.js +22 -0
- package/lib/ui/rollback-view.d.ts +12 -0
- package/lib/ui/rollback-view.js +2 -0
- package/lib/ui/sync-settings-view.d.ts +47 -0
- package/lib/ui/sync-settings-view.js +24 -0
- package/lib/ui/types.d.ts +21 -0
- package/lib/utils/env-lock.d.ts +1 -0
- package/lib/utils/env-lock.js +7 -4
- package/package.json +6 -6
- package/src/adapters/credentials.ts +22 -4
- package/src/adapters/file-collection.ts +2 -1
- package/src/adapters/mcp.test.ts +235 -1
- package/src/adapters/mcp.ts +276 -5
- package/src/adapters/messages.ts +7 -0
- package/src/adapters/plugin-files.ts +2 -1
- package/src/adapters/plugins.test.ts +176 -2
- package/src/adapters/plugins.ts +107 -21
- package/src/adapters/pnpm-workspace.test.ts +89 -1
- package/src/adapters/pnpm-workspace.ts +127 -2
- package/src/adapters/prompts.ts +20 -3
- package/src/adapters/providers.ts +62 -4
- package/src/adapters/settings.ts +16 -3
- package/src/adapters/skills.test.ts +141 -0
- package/src/adapters/skills.ts +58 -2
- package/src/adapters/ui.ts +16 -3
- package/src/adapters/units-entries.test.ts +190 -0
- package/src/adapters/units.ts +50 -0
- package/src/adapters/workspaces.ts +2 -1
- package/src/client/about/PluginUpdateDialog.tsx +4 -2
- package/src/client/about/ReleaseNotesDialog.tsx +4 -2
- package/src/client/chat/RollbackReviewDialog.tsx +3 -2
- package/src/client/common/Skeleton.tsx +32 -0
- package/src/client/common/api-base.test.ts +36 -0
- package/src/client/common/http.test.ts +12 -1
- package/src/client/common/http.ts +8 -2
- package/src/client/common/routes.ts +46 -3
- package/src/client/config-manager.module.css +321 -11
- package/src/client/home/HomePanel.tsx +14 -6
- package/src/client/import/ForeignImportView.tsx +3 -1
- package/src/client/import/import-wizard-steps.tsx +2 -1
- package/src/client/library/ArtifactRow.tsx +12 -2
- package/src/client/library/LibraryActions.tsx +6 -3
- package/src/client/library/LibraryPanel.tsx +21 -3
- package/src/client/library/RestorePlanView.tsx +4 -2
- package/src/client/locales.ts +3 -0
- package/src/client/market/market-api.test.ts +3 -2
- package/src/client/recovery/RecoveryPanel.tsx +54 -7
- package/src/client/recovery/RollbackCard.tsx +7 -2
- package/src/client/recovery/RollbackDiffPane.tsx +3 -2
- package/src/client/recovery/recovery-locales.ts +20 -2
- package/src/client/recovery/recovery-view.test.ts +39 -1
- package/src/client/recovery/recovery-view.ts +23 -1
- package/src/client/run-store.test.ts +1 -1
- package/src/client/run-store.ts +4 -0
- package/src/client/sync/AutosyncCard.tsx +49 -6
- package/src/client/sync/MergeModal.tsx +220 -0
- package/src/client/sync/MergePreviewView.tsx +306 -0
- package/src/client/sync/SyncActivityBar.tsx +31 -0
- package/src/client/sync/SyncConfirmView.tsx +2 -1
- package/src/client/sync/SyncPanel.tsx +130 -87
- package/src/client/sync/SyncProbeRow.tsx +162 -0
- package/src/client/sync/SyncSettingsView.test.ts +27 -2
- package/src/client/sync/WorkDirCard.tsx +44 -6
- package/src/client/sync/history-model.ts +4 -1
- package/src/client/sync/merge-view.test.ts +248 -0
- package/src/client/sync/merge-view.ts +398 -0
- package/src/client/sync/probe-view.test.ts +90 -0
- package/src/client/sync/probe-view.ts +63 -0
- package/src/client/sync/remote-device-view.test.ts +35 -0
- package/src/client/sync/remote-device-view.ts +25 -0
- package/src/client/sync/sync-api.test.ts +5 -3
- package/src/client/sync/sync-api.ts +212 -2
- package/src/client/sync/sync-entry.test.ts +78 -0
- package/src/client/sync/sync-entry.ts +53 -0
- package/src/client/sync/sync-locales.ts +234 -2
- package/src/client/sync/sync-view.test.ts +18 -1
- package/src/client/sync/sync-view.ts +20 -0
- package/src/core/backup.test.ts +41 -0
- package/src/core/backup.ts +20 -0
- package/src/core/mcp-connector-store.test.ts +265 -0
- package/src/core/mcp-connector-store.ts +516 -0
- package/src/core/messages-core.ts +4 -0
- package/src/core/messages.ts +46 -0
- package/src/core/phase3-host.ts +18 -1
- package/src/core/phase3-production-integration.test.ts +4 -0
- package/src/core/plugin-cli.test.ts +26 -0
- package/src/core/plugin-cli.ts +30 -4
- package/src/core/recovery-orchestrator.test.ts +104 -5
- package/src/core/recovery-orchestrator.ts +74 -17
- package/src/core/rollback-activity.test.ts +311 -0
- package/src/core/rollback-activity.ts +367 -0
- package/src/core/rollback-cross-session.test.ts +694 -0
- package/src/core/rollback-interrupted.test.ts +137 -0
- package/src/core/rollback-ledger.ts +32 -0
- package/src/core/rollback-merge.test.ts +130 -0
- package/src/core/rollback-merge.ts +78 -14
- package/src/core/rollback-point.ts +468 -91
- package/src/core/rollback-undo-journal.test.ts +508 -0
- package/src/core/rollback-undo-journal.ts +571 -0
- package/src/core/self-update.ts +2 -5
- package/src/core/text-fidelity.test.ts +189 -0
- package/src/core/text-fidelity.ts +197 -0
- package/src/core/types.ts +11 -3
- package/src/core/update-check.ts +2 -5
- package/src/core/validator.ts +14 -0
- package/src/foreign/bundle-origin.test.ts +265 -0
- package/src/foreign/bundle.ts +45 -5
- package/src/foreign/claude-code.test.ts +268 -0
- package/src/foreign/claude-code.ts +406 -25
- package/src/foreign/claude-plugins.test.ts +234 -0
- package/src/foreign/claude-sessions.test.ts +141 -0
- package/src/foreign/claude-sessions.ts +179 -23
- package/src/foreign/file-budget.test.ts +4 -1
- package/src/foreign/read-claude-code.test.ts +320 -0
- package/src/foreign/read-claude-code.ts +490 -71
- package/src/foreign/read-claude-plugins.ts +465 -0
- package/src/foreign/registry.ts +25 -2
- package/src/foreign/session-ir.test.ts +65 -0
- package/src/foreign/truth-table.test.ts +81 -0
- package/src/foreign/truth-table.ts +6 -3
- package/src/foreign/types.ts +162 -2
- package/src/index.facade.test.ts +26 -4
- package/src/index.ts +76 -19
- package/src/market/git-file-writer.test.ts +4 -0
- package/src/market/git-file-writer.ts +7 -1
- package/src/profiles/dsh-profile-launcher.test.ts +68 -3
- package/src/profiles/dsh-profile-launcher.ts +56 -1
- package/src/profiles/dsh-profile-shared.ts +8 -2
- package/src/routes/foreign.ts +8 -1
- package/src/routes/index.ts +1 -1
- package/src/routes/kit.ts +2 -2
- package/src/routes/sync.ts +176 -9
- package/src/schema/manifest-schema.test.ts +87 -2
- package/src/schema/manifest.ts +57 -1
- package/src/schema/types.ts +45 -2
- package/src/sync/ancestor.ts +76 -2
- package/src/sync/autosync-config.test.ts +73 -1
- package/src/sync/autosync-config.ts +124 -0
- package/src/sync/autosync-risk-tier.test.ts +38 -2
- package/src/sync/autosync-scheduler.test.ts +264 -12
- package/src/sync/autosync-scheduler.ts +296 -44
- package/src/sync/gist/gist-conditional-write.test.ts +203 -0
- package/src/sync/gist/gist-transport.ts +53 -5
- package/src/sync/gist/messages.ts +5 -0
- package/src/sync/git/git-probe.test.ts +80 -0
- package/src/sync/git/git-transport.test.ts +185 -0
- package/src/sync/git/git-transport.ts +178 -5
- package/src/sync/merge.test.ts +188 -55
- package/src/sync/merge.ts +282 -224
- package/src/sync/messages.ts +19 -0
- package/src/sync/risk.ts +29 -5
- package/src/sync/s3/messages.ts +13 -0
- package/src/sync/s3/s3-conditional-write.test.ts +330 -0
- package/src/sync/s3/s3-transport.test.ts +2 -1
- package/src/sync/s3/s3-transport.ts +87 -9
- package/src/sync/s3/sigv4.test.ts +4 -1
- package/src/sync/s3/sigv4.ts +11 -2
- package/src/sync/sync-device-identity.test.ts +267 -0
- package/src/sync/sync-engine.test.ts +410 -19
- package/src/sync/sync-engine.ts +602 -57
- package/src/sync/sync-history.test.ts +16 -1
- package/src/sync/sync-history.ts +26 -0
- package/src/sync/sync-state.test.ts +64 -0
- package/src/sync/sync-state.ts +79 -0
- package/src/sync/sync-watcher.test.ts +197 -0
- package/src/sync/sync-watcher.ts +246 -0
- package/src/sync/text-merge.test.ts +358 -0
- package/src/sync/text-merge.ts +554 -0
- package/src/sync/transport.ts +57 -0
- package/src/sync/watch-scope.test.ts +122 -0
- package/src/sync/watch-scope.ts +120 -0
- package/src/sync/webdav/webdav-conditional-write.test.ts +360 -0
- package/src/sync/webdav/webdav-transport.ts +137 -14
- package/src/ui/foreign-view.test.ts +34 -0
- package/src/ui/foreign-view.ts +10 -0
- package/src/ui/i18n.ts +22 -0
- package/src/ui/rollback-view.ts +14 -0
- package/src/ui/sync-settings-view.test.ts +28 -1
- package/src/ui/sync-settings-view.ts +43 -0
- package/src/ui/types.ts +22 -0
- package/src/utils/env-lock.ts +8 -5
package/src/schema/types.ts
CHANGED
|
@@ -32,6 +32,42 @@ export interface EncryptionInfo {
|
|
|
32
32
|
version: number;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* **外部来源包**的溯源块(manifest.foreign,2026-10-18 新增,纯 additive)。
|
|
37
|
+
*
|
|
38
|
+
* 为什么需要它:外部来源(Claude Code / Hermes / …)合成的包是**标准 bundle v1**,
|
|
39
|
+
* 落盘后与 DSH 自产备份**逐字同构** —— 事后无法复核「这包从哪来」。本块把来源标识、
|
|
40
|
+
* 读取位置、项目根、插件元数据与跳过聚合写进 manifest,供用户与界面核对。
|
|
41
|
+
*
|
|
42
|
+
* 三条纪律(与 src/foreign/types.ts 的 ForeignOrigin 同口径):
|
|
43
|
+
* ① **只放标识与计数,绝不放值**(凭据只有引用名,诊断只有机器码,无一字配置内容);
|
|
44
|
+
* ② 向后兼容:旧包没有该字段 ⇒ 缺省即合法;本字段的存在**绝不能**让旧/新包被拒;
|
|
45
|
+
* ③ 缺字段即「不知道」,绝不填默认值假装知道。
|
|
46
|
+
*/
|
|
47
|
+
export interface ManifestForeignOrigin {
|
|
48
|
+
/** 来源 agent id(稳定机器标识,如 claude-code / hermes;不随文案变化) */
|
|
49
|
+
agent: string;
|
|
50
|
+
/** 实际读取的位置(相对 home 的 POSIX 路径;不含任何值) */
|
|
51
|
+
locations?: string[];
|
|
52
|
+
/** 来源侧版本标记(读不到即省略) */
|
|
53
|
+
sourceVersion?: string;
|
|
54
|
+
/** 被扫描的项目根绝对路径(仅溯源,供用户核对;绝不进任何分区载荷) */
|
|
55
|
+
projects?: string[];
|
|
56
|
+
/** 插件清单(名称 / 版本 / 市场 / 来源仓库 / ref / 组件种类;**不含凭据**) */
|
|
57
|
+
plugins?: {
|
|
58
|
+
name: string;
|
|
59
|
+
version?: string;
|
|
60
|
+
marketplace?: string;
|
|
61
|
+
repo?: string;
|
|
62
|
+
ref?: string;
|
|
63
|
+
components?: string[];
|
|
64
|
+
}[];
|
|
65
|
+
/** 跳过与诊断汇总(机器码 + 条数,**不含任何值**) */
|
|
66
|
+
skipped?: { code: string; count: number }[];
|
|
67
|
+
/** 需要用户在目标机补录的凭据引用名(只有名字,**没有任何值**) */
|
|
68
|
+
credentialRefs?: string[];
|
|
69
|
+
}
|
|
70
|
+
|
|
35
71
|
/** 每个导出 ZIP 根部的 manifest.json */
|
|
36
72
|
export interface Manifest {
|
|
37
73
|
schemaVersion: number;
|
|
@@ -48,6 +84,12 @@ export interface Manifest {
|
|
|
48
84
|
* 宽容读取:非字符串/缺失一律按「未知」处理(不重定基,不报错)。
|
|
49
85
|
*/
|
|
50
86
|
sourceHome?: string;
|
|
87
|
+
/**
|
|
88
|
+
* **外部来源包的溯源块**(见 ManifestForeignOrigin);DSH 自产备份没有该字段。
|
|
89
|
+
*
|
|
90
|
+
* 宽容读取:缺省 = 旧包(合法);存在但形状/字段类型不对 = 仅 warning(绝不因此拒绝导入)。
|
|
91
|
+
*/
|
|
92
|
+
foreign?: ManifestForeignOrigin;
|
|
51
93
|
sections: Record<SectionId, boolean>;
|
|
52
94
|
security: {
|
|
53
95
|
containsSecrets: boolean;
|
|
@@ -109,8 +151,9 @@ export interface PluginEntry {
|
|
|
109
151
|
/**
|
|
110
152
|
* 声明依赖 spec(profile package.json dependencies 原样),如 ^0.3.6、
|
|
111
153
|
* github:csyangwen/dsh-memory-evolve、file:/link: 等。
|
|
112
|
-
*
|
|
113
|
-
*
|
|
154
|
+
* 导入时的安装目标由 `core/plugin-cli.ts` 的 `pluginInstallTarget()` 统一推导(issue #101):
|
|
155
|
+
* 非 registry 来源按此 spec 原样安装;`version` 是具体 semver 时按 `<name>@<version>` 精确安装;
|
|
156
|
+
* 其余 spec 按 `<name>@<spec>`;都没有才退回裸包名(npm 最新版)。
|
|
114
157
|
*/
|
|
115
158
|
spec?: string;
|
|
116
159
|
/**
|
package/src/sync/ancestor.ts
CHANGED
|
@@ -8,7 +8,9 @@ import { engineMsg } from './messages.ts';
|
|
|
8
8
|
import type { SnapshotFs } from './fs.ts';
|
|
9
9
|
import { readSnapshotFromDir, writeSnapshotToDir } from './layout.ts';
|
|
10
10
|
import { SNAPSHOT_MANIFEST_FILE } from './layout.ts';
|
|
11
|
+
import { isEncryptedSections } from './transport.ts';
|
|
11
12
|
import type { SyncSnapshot } from './transport.ts';
|
|
13
|
+
import { parseJsonSafe, stringifyJsonSafe } from '../utils/json.ts';
|
|
12
14
|
|
|
13
15
|
/** 默认保留的祖先快照数量(最旧超出则被裁剪)。 */
|
|
14
16
|
export const DEFAULT_ANCESTOR_KEEP = 10;
|
|
@@ -23,7 +25,7 @@ export async function loadAncestor(
|
|
|
23
25
|
throw new Error(engineMsg('sync.ancestorSnapshotIdRequired'));
|
|
24
26
|
}
|
|
25
27
|
const dir = joinFs(localSnapshotsDir, snapshotId);
|
|
26
|
-
return await
|
|
28
|
+
return await readAncestorSnapshot(dir, fsx);
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
/**
|
|
@@ -43,12 +45,84 @@ export async function tryLoadAncestor(
|
|
|
43
45
|
): Promise<SyncSnapshot | undefined> {
|
|
44
46
|
if (typeof snapshotId !== 'string' || snapshotId === '') return undefined;
|
|
45
47
|
try {
|
|
46
|
-
return await
|
|
48
|
+
return await readAncestorSnapshot(joinFs(localSnapshotsDir, snapshotId), fsx);
|
|
47
49
|
} catch {
|
|
48
50
|
return undefined;
|
|
49
51
|
}
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
/**
|
|
55
|
+
* 加密祖先副本的密文载荷文件名(issue #107)。
|
|
56
|
+
* 加密快照无法平铺成明文 JSON 分区,因此加密祖先目录 = manifest.json + 这一个密文载荷文件。
|
|
57
|
+
*/
|
|
58
|
+
export const ENCRYPTED_ANCESTOR_FILE = 'sections.encrypted.json';
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 读祖先副本目录:manifest.encrypted=true → 读密文单载荷;否则按散文件布局读回明文。
|
|
62
|
+
*
|
|
63
|
+
* 为什么要在这里分派(而不是改 layout.readSnapshotFromDir):散文件布局对加密快照是**结构性拒绝**
|
|
64
|
+
* (layoutEncryptedDirUnsupported,git 通道的密文单文件布局依赖它);祖先副本是本地缓存目录,
|
|
65
|
+
* 允许有自己的加密形态,且不该让「本地缓存」把明文布局的契约搞松。
|
|
66
|
+
* 读不出 / 形态不符一律抛错 —— 是否降级为 undefined 由 tryLoadAncestor 决定。
|
|
67
|
+
*/
|
|
68
|
+
export async function readAncestorSnapshot(
|
|
69
|
+
dir: string,
|
|
70
|
+
fsx: SnapshotFs = createSnapshotFs(),
|
|
71
|
+
): Promise<SyncSnapshot> {
|
|
72
|
+
const manifestAbs = joinFs(dir, SNAPSHOT_MANIFEST_FILE);
|
|
73
|
+
if (!(await fsx.exists(manifestAbs))) {
|
|
74
|
+
throw new Error(engineMsg('sync.layoutManifestMissing', { file: SNAPSHOT_MANIFEST_FILE, dir }));
|
|
75
|
+
}
|
|
76
|
+
const parsed = parseJsonSafe(Buffer.from(await fsx.readFile(manifestAbs)).toString('utf8'));
|
|
77
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
78
|
+
throw new Error(engineMsg('sync.layoutManifestNotObject'));
|
|
79
|
+
}
|
|
80
|
+
const m = parsed as Record<string, unknown>;
|
|
81
|
+
const manifest = m['manifest'];
|
|
82
|
+
if (manifest !== null && typeof manifest === 'object' && !Array.isArray(manifest)
|
|
83
|
+
&& (manifest as Record<string, unknown>)['encrypted'] === true) {
|
|
84
|
+
const payloadAbs = joinFs(dir, ENCRYPTED_ANCESTOR_FILE);
|
|
85
|
+
if (!(await fsx.exists(payloadAbs))) {
|
|
86
|
+
throw new Error(engineMsg('sync.layoutManifestMissing', { file: ENCRYPTED_ANCESTOR_FILE, dir }));
|
|
87
|
+
}
|
|
88
|
+
const payload = parseJsonSafe(Buffer.from(await fsx.readFile(payloadAbs)).toString('utf8'));
|
|
89
|
+
if (!isEncryptedSections(payload)) {
|
|
90
|
+
throw new Error(engineMsg('sync.layoutManifestMissingFields'));
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
id: typeof m['id'] === 'string' ? m['id'] : '',
|
|
94
|
+
createdAt: typeof m['createdAt'] === 'string' ? m['createdAt'] : '',
|
|
95
|
+
manifest: manifest as SyncSnapshot['manifest'],
|
|
96
|
+
sections: payload,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return await readSnapshotFromDir(dir, fsx);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 把**加密**祖先副本写入本地目录(issue #107)。
|
|
104
|
+
*
|
|
105
|
+
* 磁盘上只有 manifest.json(manifest.encrypted=true,sectionHashes 为空)+ 单个 AES-GCM 密文载荷;
|
|
106
|
+
* 密码绝不落盘(载荷里只有 salt/iv/authTag 与密文,与远端加密快照同口径)。
|
|
107
|
+
* 目录里有 manifest.json ⇒ 仍被 listAncestors / pruneAncestors 当成普通祖先副本,保留策略不变。
|
|
108
|
+
*/
|
|
109
|
+
export async function writeEncryptedAncestor(
|
|
110
|
+
localSnapshotsDir: string,
|
|
111
|
+
snapshot: SyncSnapshot,
|
|
112
|
+
fsx: SnapshotFs = createSnapshotFs(),
|
|
113
|
+
): Promise<void> {
|
|
114
|
+
if (!isEncryptedSections(snapshot.sections)) {
|
|
115
|
+
// 结构性断言:明文快照必须走 writeAncestor(散文件布局),不得落进密文单载荷形态
|
|
116
|
+
throw new Error(engineMsg('sync.layoutEncryptedDirUnsupported'));
|
|
117
|
+
}
|
|
118
|
+
const dir = joinFs(localSnapshotsDir, snapshot.id);
|
|
119
|
+
await fsx.mkdir(dir);
|
|
120
|
+
const manifest = { id: snapshot.id, createdAt: snapshot.createdAt, manifest: snapshot.manifest, sectionHashes: {} };
|
|
121
|
+
const enc = new TextEncoder();
|
|
122
|
+
await fsx.writeFile(joinFs(dir, SNAPSHOT_MANIFEST_FILE), enc.encode(stringifyJsonSafe(manifest, { space: 2 })));
|
|
123
|
+
await fsx.writeFile(joinFs(dir, ENCRYPTED_ANCESTOR_FILE), enc.encode(stringifyJsonSafe(snapshot.sections, { space: 2 })));
|
|
124
|
+
}
|
|
125
|
+
|
|
52
126
|
/** 把合并后的快照写入本地祖先副本目录(覆盖同名 id)。 */
|
|
53
127
|
export async function writeAncestor(
|
|
54
128
|
localSnapshotsDir: string,
|
|
@@ -11,7 +11,7 @@ import path from 'node:path';
|
|
|
11
11
|
import {
|
|
12
12
|
readAutosyncConfig, writeAutosyncConfig, readAllAutosyncConfigs, AUTOSYNC_CONFIG_FILE,
|
|
13
13
|
AUTOSYNC_CONFIG_SCHEMA_VERSION, DEFAULT_AUTOSYNC_INTERVAL,
|
|
14
|
-
DEFAULT_STARTUP_MIN_INTERVAL_MS,
|
|
14
|
+
DEFAULT_STARTUP_MIN_INTERVAL_MS, lastRunFailureMessage, clearLastRunResult, shouldRunOnEnable,
|
|
15
15
|
} from './autosync-config.ts';
|
|
16
16
|
|
|
17
17
|
test('writeAutosyncConfig + readAutosyncConfig:写入 → 读回字段一致(git 通道)', async () => {
|
|
@@ -145,3 +145,75 @@ test('v1 迁移:顶层单通道字段 → git 通道(webdav 缺省)', asyn
|
|
|
145
145
|
assert.equal(git2.interval, '12h');
|
|
146
146
|
} finally { await fs.rm(dir, { recursive: true, force: true }); }
|
|
147
147
|
});
|
|
148
|
+
/* ------------------------------------------------- issue #105:旧运行结果不得被当成结论 */
|
|
149
|
+
|
|
150
|
+
test('lastRunFailureMessage:只有 failed 才回传错误原文(issue #105)', () => {
|
|
151
|
+
// failed + 有原文 → 原样回传
|
|
152
|
+
assert.equal(
|
|
153
|
+
lastRunFailureMessage({ lastRunStatus: 'failed', lastRunMessage: 'git pull 失败: 认证失败' }),
|
|
154
|
+
'git pull 失败: 认证失败',
|
|
155
|
+
);
|
|
156
|
+
// 其余状态一律 undefined —— 这正是「卡片挂着十天前旧错误」的根因
|
|
157
|
+
for (const status of ['success', 'skipped', 'partial'] as const) {
|
|
158
|
+
assert.equal(
|
|
159
|
+
lastRunFailureMessage({ lastRunStatus: status, lastRunMessage: '上一轮认证失败' }),
|
|
160
|
+
undefined,
|
|
161
|
+
`${status} 不得回传上一轮的旧错误`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
// 空串 / 缺字段 / 从未运行 → 都不得渲染
|
|
165
|
+
assert.equal(lastRunFailureMessage({ lastRunStatus: 'failed', lastRunMessage: '' }), undefined);
|
|
166
|
+
assert.equal(lastRunFailureMessage({ lastRunStatus: 'failed' }), undefined);
|
|
167
|
+
assert.equal(lastRunFailureMessage({}), undefined);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('clearLastRunResult:配置变更清空上一轮结论,但保留「上次跑过什么」(issue #105)', async () => {
|
|
171
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-autosync-clear-'));
|
|
172
|
+
try {
|
|
173
|
+
await writeAutosyncConfig(dir, 'git', {
|
|
174
|
+
enabled: true,
|
|
175
|
+
interval: '15m',
|
|
176
|
+
startupMinIntervalMs: 300000,
|
|
177
|
+
consecutiveFailures: 4,
|
|
178
|
+
lastRunAt: '2026-08-17T10:00:00.000Z',
|
|
179
|
+
lastRunStatus: 'failed',
|
|
180
|
+
lastRunMessage: '认证失败',
|
|
181
|
+
lastRunSkipReason: 'conflict',
|
|
182
|
+
lastRunSkipCount: 2,
|
|
183
|
+
lastRunHistoryId: 'hist-9',
|
|
184
|
+
});
|
|
185
|
+
const cleared = clearLastRunResult(await readAutosyncConfig(dir, 'git'));
|
|
186
|
+
assert.equal(cleared.lastRunMessage, undefined);
|
|
187
|
+
assert.equal(cleared.lastRunSkipReason, undefined);
|
|
188
|
+
assert.equal(cleared.lastRunSkipCount, undefined);
|
|
189
|
+
// 配置与「上次运行事实」必须保留:改间隔不等于抹掉历史
|
|
190
|
+
assert.equal(cleared.enabled, true);
|
|
191
|
+
assert.equal(cleared.interval, '15m');
|
|
192
|
+
assert.equal(cleared.lastRunAt, '2026-08-17T10:00:00.000Z');
|
|
193
|
+
assert.equal(cleared.lastRunStatus, 'failed');
|
|
194
|
+
assert.equal(cleared.consecutiveFailures, 4);
|
|
195
|
+
// 落盘后旧结论真的不在文件里(不是只在内存里清掉)
|
|
196
|
+
await writeAutosyncConfig(dir, 'git', cleared);
|
|
197
|
+
const raw = JSON.parse(await fs.readFile(path.join(dir, AUTOSYNC_CONFIG_FILE), 'utf8'));
|
|
198
|
+
assert.equal(raw.channels.git.lastRunMessage, undefined);
|
|
199
|
+
assert.equal(raw.channels.git.lastRunSkipReason, undefined);
|
|
200
|
+
assert.equal(raw.channels.git.lastRunSkipCount, undefined);
|
|
201
|
+
assert.equal(raw.channels.git.lastRunStatus, 'failed');
|
|
202
|
+
} finally { await fs.rm(dir, { recursive: true, force: true }); }
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('issue #105 建议 4:shouldRunOnEnable —— 只有「关→开」且已过启动冷却才立刻跑一轮', () => {
|
|
206
|
+
const now = Date.parse('2026-10-21T21:00:00.000Z');
|
|
207
|
+
const base = { enabled: true, lastRunAt: '2026-10-01T10:00:00.000Z', startupMinIntervalMs: 300_000 };
|
|
208
|
+
// 关 → 开,上次运行是十天前:立刻跑
|
|
209
|
+
assert.equal(shouldRunOnEnable({ enabled: false }, base, now), true);
|
|
210
|
+
// 本来就开着(只是改间隔/阈值):不跑
|
|
211
|
+
assert.equal(shouldRunOnEnable({ enabled: true }, base, now), false);
|
|
212
|
+
// 这次是关掉:不跑
|
|
213
|
+
assert.equal(shouldRunOnEnable({ enabled: false }, { ...base, enabled: false }, now), false);
|
|
214
|
+
// 刚跑过(冷却窗口内):不跑,避免连点把通道打爆
|
|
215
|
+
assert.equal(shouldRunOnEnable({ enabled: false }, { ...base, lastRunAt: '2026-10-21T20:58:00.000Z' }, now), false);
|
|
216
|
+
// 从未跑过 / 时间读不出:当作「很久没跑」→ 跑
|
|
217
|
+
assert.equal(shouldRunOnEnable({ enabled: false }, { ...base, lastRunAt: undefined }, now), true);
|
|
218
|
+
assert.equal(shouldRunOnEnable({ enabled: false }, { ...base, lastRunAt: 'garbage' }, now), true);
|
|
219
|
+
});
|
|
@@ -34,6 +34,38 @@ export const DEFAULT_AUTOSYNC_INTERVAL: AutosyncInterval = '30m';
|
|
|
34
34
|
/** 缺省启动触发最小间隔阈值(5 分钟,防频繁重启反复同步) */
|
|
35
35
|
export const DEFAULT_STARTUP_MIN_INTERVAL_MS = 5 * 60 * 1000;
|
|
36
36
|
|
|
37
|
+
/** 缺省开启「本地改动即时触发」(真·自动同步;关闭后退回纯定时器) */
|
|
38
|
+
export const DEFAULT_AUTOSYNC_REALTIME = true;
|
|
39
|
+
|
|
40
|
+
/** 缺省远端轮询间隔(realtime 开启时与本地监听并行:本地改动即时上行,远端改动最迟 1 分钟内下行) */
|
|
41
|
+
export const DEFAULT_AUTOSYNC_REMOTE_CHECK_MS = 60 * 1000;
|
|
42
|
+
|
|
43
|
+
/** 远端轮询间隔下限(15 s):再短只是空转 fetch,收益为零 */
|
|
44
|
+
export const MIN_AUTOSYNC_REMOTE_CHECK_MS = 15 * 1000;
|
|
45
|
+
|
|
46
|
+
/** 远端轮询间隔上限(60 min):比这更慢就不该叫「实时」了 */
|
|
47
|
+
export const MAX_AUTOSYNC_REMOTE_CHECK_MS = 60 * 60 * 1000;
|
|
48
|
+
|
|
49
|
+
/** 自动应用的最高风险等级(low = 只有低风险分区自动落盘;medium = 中风险 plugins/mcp 也自动落盘) */
|
|
50
|
+
export type AutosyncAutoApplyTier = 'low' | 'medium';
|
|
51
|
+
|
|
52
|
+
/** 缺省自动应用等级:medium(用户拍板:plugins/mcp 的中风险改动也要真自动,否则「同步所有改动」是假的) */
|
|
53
|
+
export const DEFAULT_AUTOSYNC_AUTO_APPLY_TIER: AutosyncAutoApplyTier = 'medium';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 双侧都改(真冲突)时的自动裁决策略:
|
|
57
|
+
* - 'ask' :保留冲突待人工裁决(改造前的唯一行为);
|
|
58
|
+
* - 'local' :缺省 —— **保留本机并推上去**(VS Code 风格):本机版本成为新的远端最新,
|
|
59
|
+
* 被覆盖的远端版本仍留在远端历史快照里,随时可回滚。
|
|
60
|
+
*
|
|
61
|
+
* 刻意**不提供** 'remote':采用远端需要走 applyMergeResolutions,那条路径会整体应用所有
|
|
62
|
+
* 可决断分区、绕过 risk.ts 的风险分级(audit-sync sync-F5),与「分区逐档放行」冲突。
|
|
63
|
+
*/
|
|
64
|
+
export type AutosyncConflictPolicy = 'ask' | 'local';
|
|
65
|
+
|
|
66
|
+
/** 缺省冲突策略:local(保留本机并推上去) */
|
|
67
|
+
export const DEFAULT_AUTOSYNC_CONFLICT_POLICY: AutosyncConflictPolicy = 'local';
|
|
68
|
+
|
|
37
69
|
/** 最近一次自动同步执行状态 */
|
|
38
70
|
export type AutosyncRunStatus = 'success' | 'skipped' | 'failed' | 'partial';
|
|
39
71
|
|
|
@@ -45,6 +77,21 @@ export interface AutosyncConfig {
|
|
|
45
77
|
interval: AutosyncInterval;
|
|
46
78
|
/** 重启触发的「自动下载合并」最小间隔阈值(ms) */
|
|
47
79
|
startupMinIntervalMs: number;
|
|
80
|
+
/**
|
|
81
|
+
* 真·自动同步:本地文件改动后即时触发一次(缺省 true)。
|
|
82
|
+
*
|
|
83
|
+
* false = 退回改造前的纯定时器语义(等 interval 到点);保留这个开关是因为
|
|
84
|
+
* 「监听整棵配置目录」在某些环境(网络盘、超大 skills 树)可能带来额外 I/O。
|
|
85
|
+
*
|
|
86
|
+
* 可选:**读取层(parseV1Channel)恒补缺省值**,旧文件/旧夹具不需要带这个字段。
|
|
87
|
+
*/
|
|
88
|
+
realtime?: boolean;
|
|
89
|
+
/** realtime 开启时的远端轮询间隔(ms;下限 15 s、上限 60 min,缺省 60 s) */
|
|
90
|
+
remoteCheckMs?: number;
|
|
91
|
+
/** 自动应用的最高风险等级(缺省 medium) */
|
|
92
|
+
autoApplyTier?: AutosyncAutoApplyTier;
|
|
93
|
+
/** 双侧都改时的自动裁决策略(缺省 local:保留本机并推上去) */
|
|
94
|
+
conflictPolicy?: AutosyncConflictPolicy;
|
|
48
95
|
/** 连续失败计数(用于通知判定) */
|
|
49
96
|
consecutiveFailures: number;
|
|
50
97
|
/** 最近一次自动同步执行时间(ISO-8601 UTC);''/undefined = 从未执行 */
|
|
@@ -77,9 +124,19 @@ export function defaultAutosyncConfig(): AutosyncConfig {
|
|
|
77
124
|
interval: DEFAULT_AUTOSYNC_INTERVAL,
|
|
78
125
|
startupMinIntervalMs: DEFAULT_STARTUP_MIN_INTERVAL_MS,
|
|
79
126
|
consecutiveFailures: 0,
|
|
127
|
+
realtime: DEFAULT_AUTOSYNC_REALTIME,
|
|
128
|
+
remoteCheckMs: DEFAULT_AUTOSYNC_REMOTE_CHECK_MS,
|
|
129
|
+
autoApplyTier: DEFAULT_AUTOSYNC_AUTO_APPLY_TIER,
|
|
130
|
+
conflictPolicy: DEFAULT_AUTOSYNC_CONFLICT_POLICY,
|
|
80
131
|
};
|
|
81
132
|
}
|
|
82
133
|
|
|
134
|
+
/** 远端轮询间隔夹取(非数字 / 越界一律回落到边界值,绝不写入 NaN)。 */
|
|
135
|
+
export function clampRemoteCheckMs(value: number): number {
|
|
136
|
+
if (!Number.isFinite(value)) return DEFAULT_AUTOSYNC_REMOTE_CHECK_MS;
|
|
137
|
+
return Math.min(MAX_AUTOSYNC_REMOTE_CHECK_MS, Math.max(MIN_AUTOSYNC_REMOTE_CHECK_MS, Math.round(value)));
|
|
138
|
+
}
|
|
139
|
+
|
|
83
140
|
/** 从 v1 顶层字段(缺 schemaVersion 或 schemaVersion=1)解析单通道配置;非法字段回退缺省。 */
|
|
84
141
|
function parseV1Channel(obj: Record<string, unknown>): AutosyncConfig {
|
|
85
142
|
const cfg = defaultAutosyncConfig();
|
|
@@ -91,6 +148,13 @@ function parseV1Channel(obj: Record<string, unknown>): AutosyncConfig {
|
|
|
91
148
|
if (typeof obj['consecutiveFailures'] === 'number' && Number.isFinite(obj['consecutiveFailures']) && obj['consecutiveFailures'] >= 0) {
|
|
92
149
|
cfg.consecutiveFailures = obj['consecutiveFailures'];
|
|
93
150
|
}
|
|
151
|
+
// 真·自动同步的三个新字段:缺失(旧文件)→ 缺省值,等于升级后立刻自动生效(用户已拍板)
|
|
152
|
+
if (typeof obj['realtime'] === 'boolean') cfg.realtime = obj['realtime'];
|
|
153
|
+
if (typeof obj['remoteCheckMs'] === 'number') cfg.remoteCheckMs = clampRemoteCheckMs(obj['remoteCheckMs']);
|
|
154
|
+
if (obj['autoApplyTier'] === 'low' || obj['autoApplyTier'] === 'medium') cfg.autoApplyTier = obj['autoApplyTier'];
|
|
155
|
+
if (obj['conflictPolicy'] === 'ask' || obj['conflictPolicy'] === 'local') {
|
|
156
|
+
cfg.conflictPolicy = obj['conflictPolicy'];
|
|
157
|
+
}
|
|
94
158
|
if (typeof obj['lastRunAt'] === 'string' && obj['lastRunAt'] !== '') cfg.lastRunAt = obj['lastRunAt'];
|
|
95
159
|
if (typeof obj['lastRunStatus'] === 'string' && (obj['lastRunStatus'] === 'success' || obj['lastRunStatus'] === 'skipped' || obj['lastRunStatus'] === 'failed' || obj['lastRunStatus'] === 'partial')) {
|
|
96
160
|
cfg.lastRunStatus = obj['lastRunStatus'];
|
|
@@ -168,6 +232,62 @@ export async function writeAutosyncConfig(dir: string, channel: SyncTransportTyp
|
|
|
168
232
|
await atomicWriteFile(target, data, { mode: 0o600 });
|
|
169
233
|
}
|
|
170
234
|
|
|
235
|
+
/**
|
|
236
|
+
* 可展示的「本轮失败原因」(issue #105)。
|
|
237
|
+
*
|
|
238
|
+
* 判据只有一条:**本轮状态真的是 failed**,才允许把 lastRunMessage 当成错误文案回传。
|
|
239
|
+
* 此前状态路由无条件回传 lastRunMessage,而 POST /sync/autosync 又会把读到的旧配置原样
|
|
240
|
+
* 写回 —— 于是「十天前那次失败的错误原文」会被一次纯粹改间隔的保存坐实,卡片上继续挂着
|
|
241
|
+
* 早已不成立的错误,用户据此去查一个不存在的故障(真机现场:认证已修好,界面仍报认证失败)。
|
|
242
|
+
*
|
|
243
|
+
* 返回 undefined = 界面不得渲染任何 lastRunMessage 单元格。
|
|
244
|
+
*/
|
|
245
|
+
export function lastRunFailureMessage(cfg: Pick<AutosyncConfig, 'lastRunStatus' | 'lastRunMessage'>): string | undefined {
|
|
246
|
+
if (cfg.lastRunStatus !== 'failed') return undefined;
|
|
247
|
+
const message = cfg.lastRunMessage;
|
|
248
|
+
return message === undefined || message === '' ? undefined : message;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* 清空「上一轮运行结果」三字段(issue #105:配置变更 ≠ 一轮运行结果)。
|
|
253
|
+
*
|
|
254
|
+
* POST /sync/autosync 是**配置**写入而不是运行,写完必须把上一轮的结论清掉,否则旧结论
|
|
255
|
+
* 会被当成当前结论继续展示。保留 lastRunAt/lastRunStatus 与连续失败计数:它们描述的是
|
|
256
|
+
* 「上一次真的跑过什么」,不会因为改了间隔就变成假话。
|
|
257
|
+
*/
|
|
258
|
+
export function clearLastRunResult(cfg: AutosyncConfig): AutosyncConfig {
|
|
259
|
+
const next: AutosyncConfig = { ...cfg };
|
|
260
|
+
delete next.lastRunMessage;
|
|
261
|
+
delete next.lastRunSkipReason;
|
|
262
|
+
delete next.lastRunSkipCount;
|
|
263
|
+
return next;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* 勾选「自动同步」后是否该立刻跑一轮(issue #105 建议 4)。
|
|
268
|
+
*
|
|
269
|
+
* 三种情况返回 false:
|
|
270
|
+
* - 本来就已经开着:这次 POST 只是改间隔 / 调阈值,不是「打开」动作;
|
|
271
|
+
* - 打开后仍在 startupMinIntervalMs 冷却窗口内:刚跑过,别连点把通道打爆;
|
|
272
|
+
* - 这次是把开关关掉:关掉当然不跑。
|
|
273
|
+
* `lastRunAt` 缺失或读不出(NaN)→ 当作「很久没跑过」,该跑。
|
|
274
|
+
*
|
|
275
|
+
* 判据与 `autosync-scheduler.ts` 的 `shouldTriggerStartupRun` 一致;这里不能直接 import 它
|
|
276
|
+
* —— 调度器依赖本模块,反向引用会成环。
|
|
277
|
+
*/
|
|
278
|
+
export function shouldRunOnEnable(
|
|
279
|
+
prev: Pick<AutosyncConfig, 'enabled'>,
|
|
280
|
+
next: Pick<AutosyncConfig, 'enabled' | 'lastRunAt' | 'startupMinIntervalMs'>,
|
|
281
|
+
nowMs: number = Date.now(),
|
|
282
|
+
): boolean {
|
|
283
|
+
if (!next.enabled || prev.enabled) return false
|
|
284
|
+
const lastRunAt = next.lastRunAt
|
|
285
|
+
if (lastRunAt === undefined || lastRunAt === '') return true
|
|
286
|
+
const lastMs = Date.parse(lastRunAt)
|
|
287
|
+
if (Number.isNaN(lastMs)) return true
|
|
288
|
+
return nowMs - lastMs >= next.startupMinIntervalMs
|
|
289
|
+
}
|
|
290
|
+
|
|
171
291
|
/** AutosyncConfig → 文件载荷(可选运行字段仅在非空时写入,保持文件干净)。 */
|
|
172
292
|
function toChannelPayload(cfg: AutosyncConfig): Record<string, unknown> {
|
|
173
293
|
const payload: Record<string, unknown> = {
|
|
@@ -175,6 +295,10 @@ function toChannelPayload(cfg: AutosyncConfig): Record<string, unknown> {
|
|
|
175
295
|
interval: cfg.interval,
|
|
176
296
|
startupMinIntervalMs: cfg.startupMinIntervalMs,
|
|
177
297
|
consecutiveFailures: cfg.consecutiveFailures,
|
|
298
|
+
realtime: cfg.realtime ?? DEFAULT_AUTOSYNC_REALTIME,
|
|
299
|
+
remoteCheckMs: clampRemoteCheckMs(cfg.remoteCheckMs ?? DEFAULT_AUTOSYNC_REMOTE_CHECK_MS),
|
|
300
|
+
autoApplyTier: cfg.autoApplyTier ?? DEFAULT_AUTOSYNC_AUTO_APPLY_TIER,
|
|
301
|
+
conflictPolicy: cfg.conflictPolicy ?? DEFAULT_AUTOSYNC_CONFLICT_POLICY,
|
|
178
302
|
};
|
|
179
303
|
if (cfg.lastRunAt !== undefined && cfg.lastRunAt !== '') payload['lastRunAt'] = cfg.lastRunAt;
|
|
180
304
|
if (cfg.lastRunStatus !== undefined) payload['lastRunStatus'] = cfg.lastRunStatus;
|
|
@@ -24,7 +24,7 @@ function section(id: string, decision: MergeSectionResult['decision']): MergeSec
|
|
|
24
24
|
|
|
25
25
|
const GIT_CFG: SyncConfig = { schemaVersion: 2, transport: 'git', git: { repoUrl: 'git@github.com:foo/bar.git' } };
|
|
26
26
|
|
|
27
|
-
test('sync-F5
|
|
27
|
+
test('sync-F5:缺省 autoApplyTier=medium → plugins(中风险)自动落盘,不再挂起等人工', async () => {
|
|
28
28
|
const cfg: AutosyncConfig = { enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0 };
|
|
29
29
|
const entries: AutosyncHistoryEntry[] = [];
|
|
30
30
|
const appliedPlans: string[][] = [];
|
|
@@ -53,7 +53,43 @@ test('sync-F5:自动同步不得应用 medium 风险分区(plugins),且
|
|
|
53
53
|
|
|
54
54
|
const result = await scheduler.runOnce('git');
|
|
55
55
|
|
|
56
|
-
assert.deepEqual(appliedPlans, [['settings']], 'medium
|
|
56
|
+
assert.deepEqual(appliedPlans, [['settings', 'plugins']], '缺省 medium 档:settings(low) 与 plugins(medium) 都可自动应用');
|
|
57
|
+
assert.ok(!entries.some((e) => (e.conflictedSections ?? []).length > 0), '没有分区被扣下 → 不该产生待确认记录');
|
|
58
|
+
assert.equal(result.status, 'success', '自动落盘中风险分区仍是一轮成功');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('sync-F5:autoApplyTier=low → medium 风险分区(plugins)仍必须留给人工确认并进历史 conflictedSections', async () => {
|
|
62
|
+
const cfg: AutosyncConfig = {
|
|
63
|
+
enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0,
|
|
64
|
+
autoApplyTier: 'low',
|
|
65
|
+
};
|
|
66
|
+
const entries: AutosyncHistoryEntry[] = [];
|
|
67
|
+
const appliedPlans: string[][] = [];
|
|
68
|
+
const engine = {
|
|
69
|
+
merge: async (): Promise<MergePlan> => ({ sections: [section('settings', 'useRemote'), section('plugins', 'useRemote')] }),
|
|
70
|
+
applyMergePlan: async (plan: { autoApply: MergeSectionResult[] }) => {
|
|
71
|
+
appliedPlans.push(plan.autoApply.map((s) => String(s.id)));
|
|
72
|
+
return { ok: true, applied: plan.autoApply.map((s) => s.id), restoreId: 'r1', rolledBack: false, review: [], warnings: [] };
|
|
73
|
+
},
|
|
74
|
+
push: async () => ({ ok: true, snapshotId: 'snap-1', sections: [] as never, warnings: [] }),
|
|
75
|
+
};
|
|
76
|
+
const scheduler = new AutoSyncScheduler({
|
|
77
|
+
syncDir: '/tmp',
|
|
78
|
+
host: { log: nullLogger() },
|
|
79
|
+
makeSyncEngine: () => engine as unknown as SyncEngine,
|
|
80
|
+
msg: (k: string) => k,
|
|
81
|
+
runs: new RunRegistry(),
|
|
82
|
+
now: () => new Date(1_000_000_000_000),
|
|
83
|
+
readConfig: async () => cfg,
|
|
84
|
+
writeConfig: async () => undefined,
|
|
85
|
+
readSyncConfigFn: async () => GIT_CFG,
|
|
86
|
+
readHistoryFn: async () => ({ schemaVersion: 1, autosyncEntries: entries, updatedAt: '' }),
|
|
87
|
+
appendHistoryFn: async (e) => { entries.push(e); },
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const result = await scheduler.runOnce('git');
|
|
91
|
+
|
|
92
|
+
assert.deepEqual(appliedPlans, [['settings']], 'low 档下 medium 风险分区(plugins)绝不能被自动应用');
|
|
57
93
|
const withSections = entries.find((e) => (e.conflictedSections ?? []).length > 0);
|
|
58
94
|
assert.ok(withSections !== undefined, '被扣下的分区必须写进历史(可见,不静默)');
|
|
59
95
|
assert.deepEqual(withSections!.conflictedSections, ['plugins']);
|