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
|
@@ -12,6 +12,12 @@
|
|
|
12
12
|
* 互不干扰;同一时刻至多执行一个通道的 runOnce(runs.register('autosync') 全局防重,
|
|
13
13
|
* 避免两个引擎并发写本地配置),另一通道的定时触发在本轮结束后自然补跑。
|
|
14
14
|
*
|
|
15
|
+
* 「真·自动」(VS Code 式)两条即时通道(realtime=true 时生效):
|
|
16
|
+
* - **本地改动即时上行**:SyncWatcher 监听本机可同步分区(watch-scope.ts 的清单,已排除
|
|
17
|
+
* sessions/、sync-autosync.json 等自写回声路径)→ 去抖后 trigger(channel,'local-change');
|
|
18
|
+
* - **远端改动即时下行**:每 remoteCheckMs(缺省 60s,可 15s–1h)廉价探测一次远端是否有新快照
|
|
19
|
+
* → trigger(channel,'remote-poll')。定时器 interval 只作兜底。
|
|
20
|
+
*
|
|
15
21
|
* 核心逻辑(runOnce):
|
|
16
22
|
* - 读该通道配置;若 !enabled → return
|
|
17
23
|
* - runs.register('autosync') 防重复;同 kind running → 跳过
|
|
@@ -19,11 +25,16 @@
|
|
|
19
25
|
* → 记 skipped(未配置) → return
|
|
20
26
|
* - Phase A: engine.merge() 三方合并 → 判定 needsReview(冲突/缺失依赖/Install/Error)
|
|
21
27
|
* - 冲突 → 只挡**下行**:跳过 Phase B(绝不写本地)+ 记 conflictedSections[],
|
|
22
|
-
* 但**继续**走到 Phase C
|
|
28
|
+
* 但**继续**走到 Phase C(issue #79:远端冲突不该连坐「把本机配置推上去当备份」)
|
|
23
29
|
* - Phase B: 无冲突 → 按 risk.ts 的 classifyMergePlan 分流(audit-sync sync-F5):
|
|
24
|
-
*
|
|
30
|
+
* 低风险自动应用;medium 仅当 autoApplyTier='medium'(缺省)自动应用;
|
|
31
|
+
* high/未注册/冲突项**不自动应用**并进 conflictedSections(待人工确认,绝不静默)
|
|
25
32
|
* → engine.applyMergePlan(autoApply) 写入本地
|
|
26
|
-
* - Phase C: 完整双向 → engine.push()
|
|
33
|
+
* - Phase C: 完整双向 → engine.push() 上传。两处策略:
|
|
34
|
+
* ① 无冲突且推送被远端推进挡下 → skipped/partial + skipReason='conflict'(等人工);
|
|
35
|
+
* ② conflictPolicy='local'(缺省)且本轮有冲突 → **不写本地**,改 engine.push({force:true})
|
|
36
|
+
* 把本机版本推成新的远端最新(被覆盖的远端旧版本仍在远端历史快照里可回滚),
|
|
37
|
+
* 状态记 partial + skipReason='conflict-kept-local'(绝不谎报 success)。
|
|
27
38
|
* - 收尾:写该通道 autosync-config(lastRunAt, lastRunStatus, consecutiveFailures,
|
|
28
39
|
* lastRunHistoryId, lastRunMessage/跳过原因 —— 每轮覆盖上一轮,绝不残留旧错误)
|
|
29
40
|
*
|
|
@@ -34,10 +45,12 @@
|
|
|
34
45
|
import crypto from 'node:crypto';
|
|
35
46
|
import { withMutationLock, LOCK_BLOCK_MESSAGE } from '../utils/env-lock.js';
|
|
36
47
|
import { engineMsg } from './messages.js';
|
|
37
|
-
import { readAutosyncConfig, writeAutosyncConfig } from './autosync-config.js';
|
|
48
|
+
import { readAutosyncConfig, writeAutosyncConfig, clampRemoteCheckMs, DEFAULT_AUTOSYNC_REALTIME, DEFAULT_AUTOSYNC_REMOTE_CHECK_MS, DEFAULT_AUTOSYNC_AUTO_APPLY_TIER, DEFAULT_AUTOSYNC_CONFLICT_POLICY, } from './autosync-config.js';
|
|
38
49
|
import { readSyncConfigFor, isGitConfig, isWebDavConfig, isS3Config, isGistConfig, SYNC_CHANNELS } from './sync-config.js';
|
|
39
|
-
import { readSyncHistory, appendAutosyncEntry } from './sync-history.js';
|
|
50
|
+
import { readSyncHistory, appendAutosyncEntry, markAutosyncNotified } from './sync-history.js';
|
|
40
51
|
import { classifyMergePlan } from './risk.js';
|
|
52
|
+
import { SyncWatcher } from './sync-watcher.js';
|
|
53
|
+
import { buildWatchTargets } from './watch-scope.js';
|
|
41
54
|
/** 间隔 → ms 换算(§4.3) */
|
|
42
55
|
export function intervalToMs(interval) {
|
|
43
56
|
const table = {
|
|
@@ -85,6 +98,22 @@ export class AutoSyncScheduler {
|
|
|
85
98
|
lockCtxForJournal = null;
|
|
86
99
|
/** 每通道一个定时器(git/webdav 各自 enabled 时独立排期)。 */
|
|
87
100
|
timers = new Map();
|
|
101
|
+
/** realtime 通道的远端轮询定时器:与 interval 定时器**分开**,否则每次轮询都会重置兜底间隔。 */
|
|
102
|
+
remoteTimers = new Map();
|
|
103
|
+
/** 本地改动监听(只有 enabled && realtime 的通道才挂;全关 → null)。 */
|
|
104
|
+
watcher = null;
|
|
105
|
+
/** 当前需要监听的通道(setupWatchers 计算)。 */
|
|
106
|
+
realtimeChannels = [];
|
|
107
|
+
/** 运行期到达的触发:本轮收尾后立刻补跑(绝不并发写本地配置,也绝不丢)。 */
|
|
108
|
+
pendingTrigger = null;
|
|
109
|
+
/** 上次开始运行的时刻(trigger 的最小间隔合并用)。 */
|
|
110
|
+
lastRunAtMs = new Map();
|
|
111
|
+
/** 因最小间隔被推迟的触发(每通道至多一个)。 */
|
|
112
|
+
gapTimers = new Map();
|
|
113
|
+
homeDir;
|
|
114
|
+
profile;
|
|
115
|
+
makeWatcher;
|
|
116
|
+
minTriggerGapMs;
|
|
88
117
|
stopped = false;
|
|
89
118
|
running = false;
|
|
90
119
|
constructor(opts) {
|
|
@@ -105,6 +134,10 @@ export class AutoSyncScheduler {
|
|
|
105
134
|
this.phase3Recovery = opts.phase3Recovery;
|
|
106
135
|
this.detectRemoteNew = opts.detectRemoteNew ?? defaultDetectRemoteNew;
|
|
107
136
|
this.detectLocalChange = opts.detectLocalChange ?? defaultDetectLocalChange;
|
|
137
|
+
this.homeDir = opts.homeDir;
|
|
138
|
+
this.profile = opts.profile;
|
|
139
|
+
this.makeWatcher = opts.makeWatcher ?? defaultMakeWatcher;
|
|
140
|
+
this.minTriggerGapMs = opts.minTriggerGapMs ?? 3000;
|
|
108
141
|
}
|
|
109
142
|
/** 追加一条自动同步历史(自动带上触发通道;0x 安全:绝不覆盖调用方显式 transport)。 */
|
|
110
143
|
async appendHistory(channel, entry) {
|
|
@@ -117,12 +150,22 @@ export class AutoSyncScheduler {
|
|
|
117
150
|
this.refreshTimers();
|
|
118
151
|
void this.startupRuns();
|
|
119
152
|
}
|
|
120
|
-
/**
|
|
153
|
+
/** 停止:清全部定时器、拆掉文件监听、标记不再调度;正在执行的任务允许自然结束。 */
|
|
121
154
|
stop() {
|
|
122
155
|
this.stopped = true;
|
|
123
156
|
for (const timer of this.timers.values())
|
|
124
157
|
this.clearTimer(timer);
|
|
125
158
|
this.timers.clear();
|
|
159
|
+
for (const timer of this.remoteTimers.values())
|
|
160
|
+
this.clearTimer(timer);
|
|
161
|
+
this.remoteTimers.clear();
|
|
162
|
+
for (const timer of this.gapTimers.values())
|
|
163
|
+
this.clearTimer(timer);
|
|
164
|
+
this.gapTimers.clear();
|
|
165
|
+
this.pendingTrigger = null;
|
|
166
|
+
this.watcher?.stop();
|
|
167
|
+
this.watcher = null;
|
|
168
|
+
this.realtimeChannels = [];
|
|
126
169
|
}
|
|
127
170
|
/** 重新加载配置(路由 POST /sync/autosync 后调用;重排所有通道定时器)。 */
|
|
128
171
|
async reload() {
|
|
@@ -131,6 +174,40 @@ export class AutoSyncScheduler {
|
|
|
131
174
|
this.refreshTimers();
|
|
132
175
|
}
|
|
133
176
|
refreshTimers() {
|
|
177
|
+
this.scheduleIntervalTimers();
|
|
178
|
+
this.scheduleRemoteTimers();
|
|
179
|
+
void this.setupWatchers();
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* 外部触发一次运行(本地改动 / 远端探测 / 另一通道让位)。
|
|
183
|
+
*
|
|
184
|
+
* - 正在跑 → 记 pendingTrigger,本轮收尾后**立刻补跑**(不并发写本地配置,也绝不丢);
|
|
185
|
+
* - 距上次开始不足 minTriggerGapMs → 合并成一次(连续保存只跑一轮,每通道至多一个定时器)。
|
|
186
|
+
*/
|
|
187
|
+
trigger(channel, reason = 'manual') {
|
|
188
|
+
if (this.stopped)
|
|
189
|
+
return;
|
|
190
|
+
if (this.running) {
|
|
191
|
+
this.pendingTrigger = { channel, reason };
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const waitMs = this.minTriggerGapMs - (this.now().getTime() - (this.lastRunAtMs.get(channel) ?? 0));
|
|
195
|
+
if (waitMs > 0) {
|
|
196
|
+
if (this.gapTimers.has(channel))
|
|
197
|
+
return;
|
|
198
|
+
const timer = this.setTimer(() => {
|
|
199
|
+
this.gapTimers.delete(channel);
|
|
200
|
+
this.trigger(channel, reason);
|
|
201
|
+
}, waitMs);
|
|
202
|
+
this.gapTimers.set(channel, timer);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
void this.runOnce(channel, { reason }).catch((err) => {
|
|
206
|
+
this.host.log.error(`自动同步触发失败(${channel}/${reason})`, { error: err instanceof Error ? err.message : String(err) });
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
/** 兜底间隔定时器:每个 enabled 通道按各自 interval 排一次性定时器,触发后只重排自己。 */
|
|
210
|
+
scheduleIntervalTimers() {
|
|
134
211
|
for (const timer of this.timers.values())
|
|
135
212
|
this.clearTimer(timer);
|
|
136
213
|
this.timers.clear();
|
|
@@ -146,21 +223,86 @@ export class AutoSyncScheduler {
|
|
|
146
223
|
this.timers.delete(channel);
|
|
147
224
|
if (this.stopped)
|
|
148
225
|
return;
|
|
149
|
-
void this.runOnce(channel)
|
|
226
|
+
void this.runOnce(channel, { reason: 'interval' })
|
|
150
227
|
.catch((err) => {
|
|
151
228
|
this.host.log.error(`自动同步定时触发失败(${channel})`, { error: err instanceof Error ? err.message : String(err) });
|
|
152
229
|
})
|
|
153
230
|
.then(() => {
|
|
154
|
-
//
|
|
155
|
-
// 内部重读配置,若期间被关闭(enabled=false)则不再排期。
|
|
231
|
+
// 只重排 interval 定时器(远端轮询有自己的节奏,绝不互相重置)
|
|
156
232
|
if (!this.stopped)
|
|
157
|
-
this.
|
|
233
|
+
this.scheduleIntervalTimers();
|
|
158
234
|
});
|
|
159
235
|
}, ms);
|
|
160
236
|
this.timers.set(channel, timer);
|
|
161
237
|
}).catch(() => { });
|
|
162
238
|
}
|
|
163
239
|
}
|
|
240
|
+
/**
|
|
241
|
+
* realtime 通道的远端轮询:按 remoteCheckMs(缺省 60s)排一次性定时器。
|
|
242
|
+
* 轮询只是「叫醒一轮 runOnce」—— 是否真的下载仍由 detectRemoteNew 判定,
|
|
243
|
+
* 所以哪怕每 60s 叫醒一次,无变化时也只会记一条 upToDate 而不会反复传输。
|
|
244
|
+
*/
|
|
245
|
+
scheduleRemoteTimers() {
|
|
246
|
+
for (const timer of this.remoteTimers.values())
|
|
247
|
+
this.clearTimer(timer);
|
|
248
|
+
this.remoteTimers.clear();
|
|
249
|
+
const channels = SYNC_CHANNELS;
|
|
250
|
+
for (const channel of channels) {
|
|
251
|
+
void this.readConfig(channel).then((cfg) => {
|
|
252
|
+
if (this.stopped || !cfg.enabled || !(cfg.realtime ?? DEFAULT_AUTOSYNC_REALTIME))
|
|
253
|
+
return;
|
|
254
|
+
const ms = clampRemoteCheckMs(cfg.remoteCheckMs ?? DEFAULT_AUTOSYNC_REMOTE_CHECK_MS);
|
|
255
|
+
const timer = this.setTimer(() => {
|
|
256
|
+
this.remoteTimers.delete(channel);
|
|
257
|
+
if (this.stopped)
|
|
258
|
+
return;
|
|
259
|
+
this.trigger(channel, 'remote-poll');
|
|
260
|
+
// 固定节奏:轮询不等本轮跑完就排下一次(trigger 自己负责合并/排队)
|
|
261
|
+
if (!this.stopped)
|
|
262
|
+
this.scheduleRemoteTimers();
|
|
263
|
+
}, ms);
|
|
264
|
+
this.remoteTimers.set(channel, timer);
|
|
265
|
+
}).catch(() => { });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/** 按「enabled && realtime」重算监听集合:需要就挂、全关就拆(绝不留下空转句柄)。 */
|
|
269
|
+
async setupWatchers() {
|
|
270
|
+
const realtime = [];
|
|
271
|
+
for (const channel of SYNC_CHANNELS) {
|
|
272
|
+
try {
|
|
273
|
+
const cfg = await this.readConfig(channel);
|
|
274
|
+
if (cfg.enabled && (cfg.realtime ?? DEFAULT_AUTOSYNC_REALTIME))
|
|
275
|
+
realtime.push(channel);
|
|
276
|
+
}
|
|
277
|
+
catch { /* 读配置失败 → 该通道不挂监听 */ }
|
|
278
|
+
}
|
|
279
|
+
this.realtimeChannels = realtime;
|
|
280
|
+
if (realtime.length === 0) {
|
|
281
|
+
this.watcher?.stop();
|
|
282
|
+
this.watcher = null;
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (this.homeDir === undefined || this.makeWatcher === undefined)
|
|
286
|
+
return; // 未接线(测试)→ 只靠定时器
|
|
287
|
+
if (this.watcher === null) {
|
|
288
|
+
this.watcher = this.makeWatcher({
|
|
289
|
+
homeDir: this.homeDir,
|
|
290
|
+
profile: this.profile,
|
|
291
|
+
log: this.host.log,
|
|
292
|
+
onFlush: (changed) => this.onLocalChange(changed),
|
|
293
|
+
});
|
|
294
|
+
this.watcher.start();
|
|
295
|
+
this.host.log.info(`自动同步已开启本地改动监听(${realtime.join('/')})`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/** watcher 回调:任一批改动 → 让所有 realtime 通道各跑一轮(各自有变化检测,不会空转传输)。 */
|
|
299
|
+
onLocalChange(changed) {
|
|
300
|
+
if (this.stopped || changed.length === 0)
|
|
301
|
+
return;
|
|
302
|
+
this.host.log.debug(`检测到本地配置改动(${changed.length} 个路径)→ 触发自动同步`);
|
|
303
|
+
for (const channel of this.realtimeChannels)
|
|
304
|
+
this.trigger(channel, 'local-change');
|
|
305
|
+
}
|
|
164
306
|
/** 启动触发下载合并(每个 enabled 通道;受各自 startupMinIntervalMs 阈值约束)。 */
|
|
165
307
|
async startupRuns() {
|
|
166
308
|
// 通道清单唯一来源 = SYNC_CHANNELS(此前同一数组在本文件写了两遍:漏改一处即某通道永不排期)
|
|
@@ -174,7 +316,7 @@ export class AutoSyncScheduler {
|
|
|
174
316
|
this.host.log.info(`自动同步启动触发跳过(${channel}):距上次运行未达阈值`);
|
|
175
317
|
continue;
|
|
176
318
|
}
|
|
177
|
-
await this.runOnce(channel, { startup: true });
|
|
319
|
+
await this.runOnce(channel, { startup: true, reason: 'startup' });
|
|
178
320
|
}
|
|
179
321
|
catch (err) {
|
|
180
322
|
this.host.log.error(`启动触发下载合并失败(${channel})`, { error: err instanceof Error ? err.message : String(err) });
|
|
@@ -187,6 +329,16 @@ export class AutoSyncScheduler {
|
|
|
187
329
|
* @param opts.startup - true 表示启动触发变体(只做 Phase A+B,不做 Phase C push)
|
|
188
330
|
*/
|
|
189
331
|
async runOnce(channel, opts = {}) {
|
|
332
|
+
const result = await this.runOnceInternal(channel, opts);
|
|
333
|
+
// 收尾后补跑:运行期到达的触发(watcher 连续保存 / 远端轮询 / 定时器到期)不会丢。
|
|
334
|
+
// 放在内部实现之外(而不是 finally 里)是为了让锁与 run 账**完全释放之后**才开始下一轮。
|
|
335
|
+
const pending = this.pendingTrigger;
|
|
336
|
+
this.pendingTrigger = null;
|
|
337
|
+
if (pending !== null && !this.stopped)
|
|
338
|
+
this.trigger(pending.channel, pending.reason);
|
|
339
|
+
return result;
|
|
340
|
+
}
|
|
341
|
+
async runOnceInternal(channel, opts = {}) {
|
|
190
342
|
if (this.running)
|
|
191
343
|
return { status: 'skipped', direction: 'none', skipReason: 'running', historyId: '', consecutiveFailures: 0 };
|
|
192
344
|
const cfg = await this.readConfig(channel);
|
|
@@ -194,6 +346,9 @@ export class AutoSyncScheduler {
|
|
|
194
346
|
return { status: 'skipped', direction: 'none', skipReason: 'disabled', historyId: '', consecutiveFailures: cfg.consecutiveFailures };
|
|
195
347
|
}
|
|
196
348
|
this.running = true;
|
|
349
|
+
this.lastRunAtMs.set(channel, this.now().getTime());
|
|
350
|
+
// 本轮会写本机配置(apply)→ 暂停监听,别把自己的写入当成「用户改了配置」(否则自激循环)。
|
|
351
|
+
this.watcher?.suspend();
|
|
197
352
|
const nowIso = this.now().toISOString();
|
|
198
353
|
const historyId = `autosync-${crypto.randomUUID()}`;
|
|
199
354
|
// runs 防重复:同 kind running → 跳过(内部语义,不打搅用户)
|
|
@@ -302,8 +457,13 @@ export class AutoSyncScheduler {
|
|
|
302
457
|
// 加密快照只通过手动推送/拉取产生与消费;自动同步仅处理普通快照。
|
|
303
458
|
// list 失败宽容处理:视为无加密快照,继续正常流程(网络问题由后续链路暴露为 failed)。
|
|
304
459
|
let encryptedRemote = false;
|
|
460
|
+
// 本轮远端列表:加密快照检查与「远端是否有新快照」共用同一次 list。
|
|
461
|
+
// 每次 list 都是一次真实网络往返(git = `pull --ff-only`,webdav = PROPFIND/GET),
|
|
462
|
+
// 缺省 60s 轮询下多 list 一次 = 一天白跑 1440 次往返。
|
|
463
|
+
let remoteMetas = null;
|
|
305
464
|
try {
|
|
306
465
|
const metas = await engine.listSnapshots();
|
|
466
|
+
remoteMetas = metas;
|
|
307
467
|
const latestMeta = metas.length > 0 ? metas[metas.length - 1] : null;
|
|
308
468
|
encryptedRemote = latestMeta !== null && latestMeta.manifest.encrypted === true;
|
|
309
469
|
}
|
|
@@ -326,7 +486,8 @@ export class AutoSyncScheduler {
|
|
|
326
486
|
// - remoteNew:远端是否出现比本地祖先更新的快照 → 决定是否做下载合并(Phase A);
|
|
327
487
|
// - localDirty:本地 portable 配置相对基线是否真的变了 → 决定是否上传(Phase C)。
|
|
328
488
|
// 定时器只是兜底轮询;真正驱动是这两个「变化」信号。
|
|
329
|
-
|
|
489
|
+
// 列表拿不到(网络失败)时 remoteMetas 为 null → 检测自己再 list 一次,行为与改造前一致
|
|
490
|
+
const remoteNew = await this.detectRemoteNew(engine, remoteMetas ?? undefined);
|
|
330
491
|
const localDirty = await this.detectLocalChange(engine);
|
|
331
492
|
// 两端都无变化 → 什么都不做,记为 upToDate(不重复拉取/不空转)。
|
|
332
493
|
if (!remoteNew && !localDirty) {
|
|
@@ -378,11 +539,15 @@ export class AutoSyncScheduler {
|
|
|
378
539
|
// 风险分级(audit-sync sync-F5)照旧只在无冲突时执行。
|
|
379
540
|
if (conflictedSections.length === 0) {
|
|
380
541
|
// 无冲突:按 risk.ts 的风险分级契约分流(audit-sync sync-F5)。
|
|
381
|
-
//
|
|
382
|
-
//
|
|
383
|
-
//
|
|
542
|
+
// autoApplyTier(缺省 'medium')= 「真·自动同步所有改动」的开关:low 档只自动落
|
|
543
|
+
// 低风险分区(settings/ui/providers/prompts/skills/agentPresets/agentInstructions/self),
|
|
544
|
+
// medium 档连 plugins / mcp 这两个中风险分区也自动落盘;**high 档永不自动**
|
|
545
|
+
// (类型上限即 medium:secrets/credentialsStatus/pluginData/pluginFiles 根本不在同步面内)。
|
|
384
546
|
// firstSync 恒 false:真正的首次同步没有祖先基线,merge 已把两侧差异判成 conflict → 走人工确认。
|
|
385
|
-
const apply = classifyMergePlan(mergePlan, {
|
|
547
|
+
const apply = classifyMergePlan(mergePlan, {
|
|
548
|
+
firstSync: false,
|
|
549
|
+
maxAutoTier: cfg.autoApplyTier ?? DEFAULT_AUTOSYNC_AUTO_APPLY_TIER,
|
|
550
|
+
});
|
|
386
551
|
// 待审项(medium/high:决策明确但不该自动应用)必须可见 —— 进历史 conflictedSections,
|
|
387
552
|
// 绝不静默丢弃;真正的冲突已在上方记入 conflictedSections。
|
|
388
553
|
deferredSections = apply.review.map((s) => s.id);
|
|
@@ -424,14 +589,45 @@ export class AutoSyncScheduler {
|
|
|
424
589
|
// Phase C: push 上传(完整双向)—— 仅当本地真有改动才上传(§3.1);startup 变体不上传。
|
|
425
590
|
if (!opts.startup && localDirty) {
|
|
426
591
|
try {
|
|
592
|
+
/**
|
|
593
|
+
* 冲突策略(conflictPolicy,缺省 'local'):
|
|
594
|
+
* - 'ask':不强制,推送会被引擎的「远端已推进」前置检测挡下 → skipped/partial(conflict);
|
|
595
|
+
* - 'local':**不写本地**(下行被冲突挡下),但把本机版本强制推成新的远端最新。
|
|
596
|
+
* force 只跳过「基线是否落后」这一道检查,仍走正常快照上传与基线记录;
|
|
597
|
+
* 被覆盖的远端旧版本留在远端历史快照里,可手动回滚。
|
|
598
|
+
*/
|
|
599
|
+
const forcePush = conflictedSections.length > 0 && (cfg.conflictPolicy ?? DEFAULT_AUTOSYNC_CONFLICT_POLICY) === 'local';
|
|
427
600
|
// Phase C: 完整双向 → engine.push() 上传。P0-A:外部 push 包 intent journal。
|
|
428
|
-
const rawPush = async () => engine.push();
|
|
601
|
+
const rawPush = async () => engine.push(forcePush ? { force: true } : {});
|
|
429
602
|
const pushReport = (this.phase3Recovery !== undefined && this.lockCtxForJournal !== null)
|
|
430
603
|
? (await this.phase3Recovery.runExternalIntent({
|
|
431
604
|
operationType: 'autosync-push', lockCtx: this.lockCtxForJournal,
|
|
432
605
|
intent: { adapter: 'sync', ref: channel, kind: 'Push' }, fn: rawPush,
|
|
433
606
|
})).result
|
|
434
607
|
: await rawPush();
|
|
608
|
+
if (!pushReport.ok && pushReport.conflict === true) {
|
|
609
|
+
// 引擎前置检测拒绝写入:远端已被别的设备推进 —— 这是**有冲突待处理**,不是通道/凭据故障。
|
|
610
|
+
// 绝不记 failed(那会累加连续失败计数并对用户发通知,把「需要用『检查远程合并』处理」
|
|
611
|
+
// 谎报成「同步坏了」);错误摘要保留引擎给出的说明(用户能看见到底为什么没推上去)。
|
|
612
|
+
const message = pushReport.message ?? engineMsg('sync.autosyncPushFailed');
|
|
613
|
+
const conflictResult = {
|
|
614
|
+
status: conflictedSections.length > 0 ? 'partial' : 'skipped',
|
|
615
|
+
direction: appliedSections.length ? 'both' : 'push',
|
|
616
|
+
skipReason: 'conflict', error: message, historyId,
|
|
617
|
+
appliedSections,
|
|
618
|
+
...(conflictedSections.length > 0 ? { conflictedSections } : {}),
|
|
619
|
+
consecutiveFailures: 0,
|
|
620
|
+
};
|
|
621
|
+
await this.appendHistory(channel, {
|
|
622
|
+
direction: appliedSections.length ? 'both' : 'push', status: conflictResult.status,
|
|
623
|
+
skipReason: 'conflict', error: message,
|
|
624
|
+
appliedSections,
|
|
625
|
+
...(conflictedSections.length > 0 ? { conflictedSections } : {}),
|
|
626
|
+
createdAt: nowIso, failureCountAtRun: 0,
|
|
627
|
+
});
|
|
628
|
+
await this.writeFinalConfig(cfg, conflictResult, nowIso, historyId, channel);
|
|
629
|
+
return conflictResult;
|
|
630
|
+
}
|
|
435
631
|
if (!pushReport.ok) {
|
|
436
632
|
const error = pushReport.message ?? engineMsg('sync.autosyncPushFailed');
|
|
437
633
|
const result = {
|
|
@@ -450,22 +646,34 @@ export class AutoSyncScheduler {
|
|
|
450
646
|
}
|
|
451
647
|
// issue #79:上行成功但下行被冲突挡下 → partial(绝不把「半个方向完成」报成 success,
|
|
452
648
|
// 也绝不因为它报成 failed:网络与凭据都是好的,只是远端有内容要用户确认)。
|
|
649
|
+
// conflictPolicy='local' 时冲突已被「本机覆盖远端」消解,但远端那一版确实被盖掉了 →
|
|
650
|
+
// 同样记 partial,skipReason 换成 conflict-kept-local(界面据此说清「本机已覆盖远端」)。
|
|
651
|
+
const keptLocal = forcePush && conflictedSections.length > 0;
|
|
453
652
|
const partial = conflictedSections.length > 0;
|
|
653
|
+
const skipReason = keptLocal
|
|
654
|
+
? 'conflict-kept-local'
|
|
655
|
+
: partial
|
|
656
|
+
? 'conflict'
|
|
657
|
+
: undefined;
|
|
658
|
+
const reportedConflicted = keptLocal
|
|
659
|
+
? conflictedSections
|
|
660
|
+
: partial
|
|
661
|
+
? conflictedSections
|
|
662
|
+
: deferredSections;
|
|
454
663
|
const result = {
|
|
455
664
|
status: partial ? 'partial' : 'success',
|
|
456
665
|
direction: appliedSections.length ? 'both' : 'push',
|
|
457
666
|
appliedSections, pushedSnapshotId: pushReport.snapshotId, historyId,
|
|
458
|
-
...(
|
|
459
|
-
...(
|
|
460
|
-
...(partial ? { conflictedSections } : {}),
|
|
667
|
+
...(skipReason !== undefined ? { skipReason } : {}),
|
|
668
|
+
...(reportedConflicted.length > 0 ? { conflictedSections: reportedConflicted } : {}),
|
|
461
669
|
consecutiveFailures: 0,
|
|
462
670
|
};
|
|
463
671
|
await this.appendHistory(channel, {
|
|
464
672
|
direction: appliedSections.length ? 'both' : 'push',
|
|
465
673
|
status: partial ? 'partial' : 'success',
|
|
466
674
|
appliedSections, pushedSnapshotId: pushReport.snapshotId,
|
|
467
|
-
...(
|
|
468
|
-
...(
|
|
675
|
+
...(skipReason !== undefined ? { skipReason } : {}),
|
|
676
|
+
...(reportedConflicted.length > 0 ? { conflictedSections: reportedConflicted } : {}),
|
|
469
677
|
createdAt: nowIso, failureCountAtRun: 0,
|
|
470
678
|
});
|
|
471
679
|
await this.writeFinalConfig(cfg, result, nowIso, historyId, channel);
|
|
@@ -533,6 +741,7 @@ export class AutoSyncScheduler {
|
|
|
533
741
|
}
|
|
534
742
|
finally {
|
|
535
743
|
this.running = false;
|
|
744
|
+
this.watcher?.resume();
|
|
536
745
|
// 收尾 RunRegistry:不 finish 会让 autosync 的 running 记录滞留,期间任何再次 runOnce
|
|
537
746
|
// 都会 register('autosync') → RunConflictError → 永远 skip(conflict),后台同步就此停摆。
|
|
538
747
|
// 同一收敛点也必须在**主 try 之外**的早退路径上调用(见 finishRun 的注释)。
|
|
@@ -556,7 +765,8 @@ export class AutoSyncScheduler {
|
|
|
556
765
|
*/
|
|
557
766
|
async writeFinalConfig(base, result, nowIso, historyId, channel) {
|
|
558
767
|
const skipReason = result.status === 'failed' ? undefined : result.skipReason;
|
|
559
|
-
const
|
|
768
|
+
const countsConflicts = skipReason === 'conflict' || skipReason === 'conflict-kept-local';
|
|
769
|
+
const skipCount = !countsConflicts || result.conflictedSections === undefined
|
|
560
770
|
? undefined
|
|
561
771
|
: result.conflictedSections.length;
|
|
562
772
|
await this.writeConfig(channel, {
|
|
@@ -570,22 +780,22 @@ export class AutoSyncScheduler {
|
|
|
570
780
|
lastRunSkipCount: skipCount,
|
|
571
781
|
});
|
|
572
782
|
}
|
|
573
|
-
/**
|
|
783
|
+
/**
|
|
784
|
+
* 连续失败 ≥ 3 → 通知(host.log.warn)。
|
|
785
|
+
*
|
|
786
|
+
* issue #105:通知的持久痕迹只留历史 entry 的 notifiedAt。
|
|
787
|
+
* 此前这里把通知文案写进 autosync-config.lastRunMessage —— 那是「本轮错误原文」的字段,
|
|
788
|
+
* 一个字段两种语义,界面就无法判断该显示什么;而且 entry.notifiedAt 只在内存对象上改、
|
|
789
|
+
* 从未落盘(sync-history 当时只有 append),通知事实其实没留下。现在两者都走一处:
|
|
790
|
+
* 通知文案只进日志,notifiedAt 落盘到历史记录。
|
|
791
|
+
*/
|
|
574
792
|
maybeNotify(failures, historyId, nowIso, channel) {
|
|
575
|
-
if (failures
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
entry.notifiedAt = nowIso;
|
|
582
|
-
await this.writeConfig(channel, {
|
|
583
|
-
...(await this.readConfig(channel)),
|
|
584
|
-
lastRunMessage: engineMsg('sync.autosyncConsecutiveFailureNotified', { count: failures }),
|
|
585
|
-
});
|
|
586
|
-
}
|
|
587
|
-
}).catch(() => { });
|
|
588
|
-
}
|
|
793
|
+
if (failures < 3)
|
|
794
|
+
return;
|
|
795
|
+
this.host.log.warn(`自动同步连续失败 ${failures} 次,请检查仓库配置/凭据(${channel})`);
|
|
796
|
+
// readHistory 走注入的 readHistoryFn(测试可替换),写入仍是原子落盘
|
|
797
|
+
void markAutosyncNotified(this.syncDir, nowIso, nowIso, { readHistory: this.readHistoryFn })
|
|
798
|
+
.catch(() => { });
|
|
589
799
|
}
|
|
590
800
|
}
|
|
591
801
|
/**
|
|
@@ -636,12 +846,13 @@ export function syncIsConfigured(cfg) {
|
|
|
636
846
|
/**
|
|
637
847
|
* 缺省远端新快照检测(§3.2):engine 实现了 hasNewRemoteSnapshot() → 调用;
|
|
638
848
|
* 否则(测试 mock 未实现)保守返回 true(假设有新生,保持旧行为=每次都尝试拉取)。
|
|
849
|
+
* `listed` 透传给引擎:本轮已 list 过就直接复用,不再多跑一次网络往返。
|
|
639
850
|
*/
|
|
640
|
-
async function defaultDetectRemoteNew(engine) {
|
|
851
|
+
async function defaultDetectRemoteNew(engine, listed) {
|
|
641
852
|
const fn = engine.hasNewRemoteSnapshot;
|
|
642
853
|
if (typeof fn === 'function') {
|
|
643
854
|
try {
|
|
644
|
-
return await fn.call(engine);
|
|
855
|
+
return await fn.call(engine, listed);
|
|
645
856
|
}
|
|
646
857
|
catch {
|
|
647
858
|
return true; // 检测失败保守视为有新生,避免漏拉
|
|
@@ -653,6 +864,18 @@ async function defaultDetectRemoteNew(engine) {
|
|
|
653
864
|
* 缺省本地变化检测(§3.1):engine 实现了 hasLocalChanges() → 调用;
|
|
654
865
|
* 否则(测试 mock 未实现)保守返回 true(假设有本地改动,保持旧行为=每次都尝试上传)。
|
|
655
866
|
*/
|
|
867
|
+
/**
|
|
868
|
+
* 缺省 watcher 工厂 = 真实 fs.watch(SyncWatcher)。
|
|
869
|
+
* targets 由 watch-scope 的静态清单派生(纯函数、不读磁盘),profile 只影响 profiles/ 下的路径。
|
|
870
|
+
*/
|
|
871
|
+
function defaultMakeWatcher(opts) {
|
|
872
|
+
return new SyncWatcher({
|
|
873
|
+
homeDir: opts.homeDir,
|
|
874
|
+
targets: buildWatchTargets({ homeDir: opts.homeDir, profile: opts.profile }),
|
|
875
|
+
log: opts.log,
|
|
876
|
+
onFlush: opts.onFlush,
|
|
877
|
+
});
|
|
878
|
+
}
|
|
656
879
|
async function defaultDetectLocalChange(engine) {
|
|
657
880
|
const fn = engine.hasLocalChanges;
|
|
658
881
|
if (typeof fn === 'function') {
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import type { MsgFunc } from '../../core/msg-types.ts';
|
|
2
2
|
import { SyncTransportError } from '../transport.ts';
|
|
3
|
-
import type { SyncRetryOptions, SyncSnapshot, SyncSnapshotMeta, SyncTransport, SyncTransportErrorOptions } from '../transport.ts';
|
|
3
|
+
import type { SyncProbeResult, SyncRetryOptions, SyncSnapshot, SyncSnapshotMeta, SyncTransport, SyncTransportErrorOptions } from '../transport.ts';
|
|
4
4
|
/** 缺省 GitHub API 根 */
|
|
5
5
|
export declare const GIST_DEFAULT_API_BASE_URL = "https://api.github.com";
|
|
6
6
|
/** 缺省文件前缀 */
|
|
7
7
|
export declare const GIST_DEFAULT_FILE_PREFIX = "dsh-sync";
|
|
8
|
+
/**
|
|
9
|
+
* **gist 通道不做条件写**(issue #110,2026-10 实测):`PATCH /gists/{id}` 对**任何**条件请求头
|
|
10
|
+
* 都直接回 400 —— `Conditional request headers are not allowed in unsafe requests unless supported
|
|
11
|
+
* by the endpoint`,与 ETag 强弱无关(同一请求去掉 If-Match 即 200)。故本通道的 PATCH 一律不带
|
|
12
|
+
* `If-Match`,也**没有**写前并发保护;要并发保护请用 git / webdav / s3 通道(那里的索引写是条件写)。
|
|
13
|
+
*/
|
|
8
14
|
/** 请求选项:headers / body / 覆盖默认超时(ms;0 = 不超时) */
|
|
9
15
|
export interface GistRequestOptions {
|
|
10
16
|
headers?: Record<string, string>;
|
|
@@ -61,6 +67,12 @@ export declare class GistTransport implements SyncTransport {
|
|
|
61
67
|
download(id: string): Promise<SyncSnapshot>;
|
|
62
68
|
/** 删除远端快照(不存在视为成功):PATCH 置 null + 写回摘除后的索引。 */
|
|
63
69
|
delete(id: string): Promise<void>;
|
|
70
|
+
/**
|
|
71
|
+
* 连通性探针(cloud-sync 借鉴项 ②③):令牌有效性(GET /user)→ gist 可读 → 快照数 →
|
|
72
|
+
* 归属提示(gist 里已有不属于本前缀的文件时给出 warning 级 hint,绝不自动删别人的文件)。
|
|
73
|
+
* 只读:不打任何 PATCH。
|
|
74
|
+
*/
|
|
75
|
+
probe(): Promise<SyncProbeResult>;
|
|
64
76
|
private assertSafeId;
|
|
65
77
|
private gistUrl;
|
|
66
78
|
private snapshotFileName;
|
|
@@ -70,6 +82,11 @@ export declare class GistTransport implements SyncTransport {
|
|
|
70
82
|
private send;
|
|
71
83
|
/** 读 gist(含全部文件条目);404 → 明确「gist 不存在或无权访问」 */
|
|
72
84
|
private readGistFiles;
|
|
85
|
+
/**
|
|
86
|
+
* 读 gist 全量文件。响应 ETag **不用**:gist 的 PATCH 端点不接受任何条件请求头(issue #110 实测),
|
|
87
|
+
* 读到强/弱 ETag 都无处可用,故这里不再解析。
|
|
88
|
+
*/
|
|
89
|
+
private readGist;
|
|
73
90
|
/**
|
|
74
91
|
* 取某个文件的内容:gist 载荷里直接有就用它;被 GitHub 截断(\`truncated\`)或内容缺失时
|
|
75
92
|
* 回落 \`raw_url\` 重新下载(跨源跳转剥离 Authorization —— gh 的 raw 地址本身不可猜)。
|
|
@@ -152,7 +152,7 @@ export class GistTransport {
|
|
|
152
152
|
this.assertSafeId(snapshot.id);
|
|
153
153
|
const token = await this.tokenOnce();
|
|
154
154
|
const meta = computeSnapshotMeta(snapshot);
|
|
155
|
-
const files = await this.
|
|
155
|
+
const { files } = await this.readGist(token);
|
|
156
156
|
const idxRaw = await this.readFileText(files, this.indexFileName, token);
|
|
157
157
|
const idxBefore = idxRaw === null ? [] : this.parseIndex(idxRaw, this.gistUrl(), token);
|
|
158
158
|
const existing = idxBefore.find((m) => m.id === snapshot.id);
|
|
@@ -196,7 +196,7 @@ export class GistTransport {
|
|
|
196
196
|
async delete(id) {
|
|
197
197
|
this.assertSafeId(id);
|
|
198
198
|
const token = await this.tokenOnce();
|
|
199
|
-
const files = await this.
|
|
199
|
+
const { files } = await this.readGist(token);
|
|
200
200
|
const idxRaw = await this.readFileText(files, this.indexFileName, token);
|
|
201
201
|
const idx = idxRaw === null ? [] : this.parseIndex(idxRaw, this.gistUrl(), token);
|
|
202
202
|
const fileName = this.snapshotFileName(id);
|
|
@@ -216,6 +216,36 @@ export class GistTransport {
|
|
|
216
216
|
}
|
|
217
217
|
}
|
|
218
218
|
/* ---------------- 内部实现 ---------------- */
|
|
219
|
+
/**
|
|
220
|
+
* 连通性探针(cloud-sync 借鉴项 ②③):令牌有效性(GET /user)→ gist 可读 → 快照数 →
|
|
221
|
+
* 归属提示(gist 里已有不属于本前缀的文件时给出 warning 级 hint,绝不自动删别人的文件)。
|
|
222
|
+
* 只读:不打任何 PATCH。
|
|
223
|
+
*/
|
|
224
|
+
async probe() {
|
|
225
|
+
const token = await this.tokenOnce();
|
|
226
|
+
const userUrl = this.o.apiBaseUrl + '/user';
|
|
227
|
+
const me = await this.send('GET', userUrl, token);
|
|
228
|
+
if (me.status === 401 || me.status === 403) {
|
|
229
|
+
const body = await this.safeText(me);
|
|
230
|
+
throw new GistTransportError(await this.failText('GET', userUrl, me, token, body), this.classify(me.status, body));
|
|
231
|
+
}
|
|
232
|
+
if (!me.ok) {
|
|
233
|
+
const body = await this.safeText(me);
|
|
234
|
+
throw new GistTransportError(await this.failText('GET', userUrl, me, token, body), this.classify(me.status, body));
|
|
235
|
+
}
|
|
236
|
+
const { files } = await this.readGist(token);
|
|
237
|
+
const idxRaw = await this.readFileText(files, this.indexFileName, token);
|
|
238
|
+
const count = idxRaw === null ? 0 : this.parseIndex(idxRaw, this.gistUrl(), token).length;
|
|
239
|
+
const prefix = this.o.filePrefix + '-';
|
|
240
|
+
const foreign = Object.keys(files).filter((name) => !name.startsWith(prefix)).length;
|
|
241
|
+
return {
|
|
242
|
+
message: this.o.msg('sync.gist.probeOk', { count: String(count) }),
|
|
243
|
+
snapshotCount: count,
|
|
244
|
+
...(foreign > 0
|
|
245
|
+
? { hint: this.o.msg('sync.gist.probeForeignContent', { count: String(foreign), prefix: this.o.filePrefix }) }
|
|
246
|
+
: {}),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
219
249
|
assertSafeId(id) {
|
|
220
250
|
if (typeof id !== 'string' || !SAFE_ID_RE.test(id) || RESERVED_IDS.has(id)) {
|
|
221
251
|
throw new GistTransportError(this.o.msg('sync.gist.invalidSnapshotId', { id: JSON.stringify(id) }));
|
|
@@ -245,6 +275,8 @@ export class GistTransport {
|
|
|
245
275
|
'content-type': 'application/json',
|
|
246
276
|
'X-GitHub-Api-Version': GITHUB_API_VERSION,
|
|
247
277
|
...(token !== '' ? { authorization: 'Bearer ' + token } : {}),
|
|
278
|
+
// 附加请求头(本通道当前不注入条件头:gist 端点拒绝任何条件请求头,见文首说明)
|
|
279
|
+
...(opts.headers ?? {}),
|
|
248
280
|
};
|
|
249
281
|
try {
|
|
250
282
|
return await this.o.request(method, url, {
|
|
@@ -265,6 +297,13 @@ export class GistTransport {
|
|
|
265
297
|
}
|
|
266
298
|
/** 读 gist(含全部文件条目);404 → 明确「gist 不存在或无权访问」 */
|
|
267
299
|
async readGistFiles(token) {
|
|
300
|
+
return (await this.readGist(token)).files;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* 读 gist 全量文件。响应 ETag **不用**:gist 的 PATCH 端点不接受任何条件请求头(issue #110 实测),
|
|
304
|
+
* 读到强/弱 ETag 都无处可用,故这里不再解析。
|
|
305
|
+
*/
|
|
306
|
+
async readGist(token) {
|
|
268
307
|
const url = this.gistUrl();
|
|
269
308
|
const res = await this.send('GET', url, token);
|
|
270
309
|
if (res.status === 404) {
|
|
@@ -285,7 +324,7 @@ export class GistTransport {
|
|
|
285
324
|
if (files === null || typeof files !== 'object' || Array.isArray(files)) {
|
|
286
325
|
throw new GistTransportError(this.o.msg('sync.gist.indexInvalid', { url, err: 'files missing' }), { kind: 'protocol', retryable: false });
|
|
287
326
|
}
|
|
288
|
-
return files;
|
|
327
|
+
return { files: files };
|
|
289
328
|
}
|
|
290
329
|
/**
|
|
291
330
|
* 取某个文件的内容:gist 载荷里直接有就用它;被 GitHub 截断(\`truncated\`)或内容缺失时
|
|
@@ -16,6 +16,8 @@ export declare const gistZh: {
|
|
|
16
16
|
readonly 'sync.gist.timeout': '{method} {url} 请求超时({timeout}ms)';
|
|
17
17
|
readonly 'sync.gist.indexInvalid': '{url} 解析失败或结构非法: {err}';
|
|
18
18
|
readonly 'sync.gist.snapshotInvalid': '快照 {id} 解析失败或结构非法: {err}';
|
|
19
|
+
readonly 'sync.gist.probeOk': '连接正常(Gist 可读写,远端 {count} 份快照)';
|
|
20
|
+
readonly 'sync.gist.probeForeignContent': '该 Gist 里已有 {count} 个不属于本插件的文件:请确认这是你的同步专用 Gist(前缀 {prefix}),否则可能覆盖别人的数据';
|
|
19
21
|
};
|
|
20
22
|
export declare const gistEn: Record<keyof typeof gistZh, string>;
|
|
21
23
|
/** 缺省(zh)Gist 通道翻译器。 */
|
|
@@ -22,6 +22,9 @@ export const gistZh = {
|
|
|
22
22
|
'sync.gist.timeout': '{method} {url} 请求超时({timeout}ms)',
|
|
23
23
|
'sync.gist.indexInvalid': '{url} 解析失败或结构非法: {err}',
|
|
24
24
|
'sync.gist.snapshotInvalid': '快照 {id} 解析失败或结构非法: {err}',
|
|
25
|
+
// ---- cloud-sync 借鉴项 ②③:探针 / 归属提示(①条件写已按 issue #110 移除,见 gist-transport.ts)----
|
|
26
|
+
'sync.gist.probeOk': '连接正常(Gist 可读写,远端 {count} 份快照)',
|
|
27
|
+
'sync.gist.probeForeignContent': '该 Gist 里已有 {count} 个不属于本插件的文件:请确认这是你的同步专用 Gist(前缀 {prefix}),否则可能覆盖别人的数据',
|
|
25
28
|
};
|
|
26
29
|
export const gistEn = {
|
|
27
30
|
'sync.gist.gistIdRequired': 'gistId must be a non-empty string (create a (secret) gist on GitHub and paste its id)',
|
|
@@ -40,6 +43,8 @@ export const gistEn = {
|
|
|
40
43
|
'sync.gist.timeout': '{method} {url} request timed out ({timeout}ms)',
|
|
41
44
|
'sync.gist.indexInvalid': '{url} failed to parse or is structurally invalid: {err}',
|
|
42
45
|
'sync.gist.snapshotInvalid': 'Snapshot {id} failed to parse or is structurally invalid: {err}',
|
|
46
|
+
'sync.gist.probeOk': 'Connection OK (gist readable and writable, {count} remote snapshots)',
|
|
47
|
+
'sync.gist.probeForeignContent': 'This gist already contains {count} files that are not ours: confirm it is your dedicated sync gist (prefix {prefix}) or you may overwrite someone else’s data',
|
|
43
48
|
};
|
|
44
49
|
/** 缺省(zh)Gist 通道翻译器。 */
|
|
45
50
|
export const gistMsg = makeCatalogMsg(gistZh, gistEn, 'zh');
|