@twinsearth/fang-dsh-plugin 0.0.0-stage → 3.3.5

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.
Files changed (147) hide show
  1. package/CLA.md +79 -0
  2. package/LICENSE-COMMERCIAL +78 -0
  3. package/LICENSE-CORE +661 -0
  4. package/LICENSE-SDK +21 -0
  5. package/LICENSES/cordis-MIT.txt +39 -0
  6. package/NOTICE +95 -0
  7. package/README.md +158 -2
  8. package/client.js +7287 -0
  9. package/cordis.patch.yml +163 -0
  10. package/defaults/umbrella-rules.yaml +113 -0
  11. package/docs/APPROVAL-DRILL.md +42 -0
  12. package/docs/CONTRACT-INTEROP.md +77 -0
  13. package/docs/CRASH-SAFETY.md +69 -0
  14. package/docs/DECISION-RELAY.md +46 -0
  15. package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
  16. package/docs/FORK-SEED.md +64 -0
  17. package/docs/MAIN-CITY-GATE.md +58 -0
  18. package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
  19. package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
  20. package/docs/PM-DECISION-POLICY.md +61 -0
  21. package/docs/WORKFLOW-UI.md +86 -0
  22. package/icon.svg +8 -0
  23. package/locale/en.json +6 -0
  24. package/locale/zh.json +6 -0
  25. package/package.json +71 -4
  26. package/sdk/README.md +65 -0
  27. package/sdk/examples/status-badge/README.md +16 -0
  28. package/sdk/examples/status-badge/client.js +60 -0
  29. package/sdk/examples/status-badge/cordis.patch.yml +7 -0
  30. package/sdk/examples/status-badge/index.js +12 -0
  31. package/sdk/examples/status-badge/package.json +24 -0
  32. package/sdk/templates/plugin/README.md +36 -0
  33. package/sdk/templates/plugin/client.js +35 -0
  34. package/sdk/templates/plugin/cordis.patch.yml +8 -0
  35. package/sdk/templates/plugin/icon.svg +5 -0
  36. package/sdk/templates/plugin/index.js +23 -0
  37. package/sdk/templates/plugin/locale/en.json +6 -0
  38. package/sdk/templates/plugin/package.json +36 -0
  39. package/src/compose.mjs +67 -0
  40. package/src/contract/VENDOR.json +16 -0
  41. package/src/contract/client.js +93 -0
  42. package/src/contract/constants.js +94 -0
  43. package/src/contract/envelope.js +83 -0
  44. package/src/contract/index.js +20 -0
  45. package/src/contract/negotiate.js +106 -0
  46. package/src/contract/relay.js +223 -0
  47. package/src/contract/server.js +77 -0
  48. package/src/contract/transports.js +319 -0
  49. package/src/index.mjs +11 -0
  50. package/src/kernel/core.mjs +100 -0
  51. package/src/kernel/extensions.mjs +61 -0
  52. package/src/kernel/fsx.mjs +349 -0
  53. package/src/kernel/index.mjs +21 -0
  54. package/src/kernel/ledger.mjs +94 -0
  55. package/src/kernel/paths.mjs +182 -0
  56. package/src/kernel/role.mjs +68 -0
  57. package/src/kernel/rounds.mjs +172 -0
  58. package/src/kernel/schema.mjs +5 -0
  59. package/src/kernel/types.mjs +266 -0
  60. package/src/kernel/vendor/cosmokit.mjs +421 -0
  61. package/src/kernel/vendor/schemastery.mjs +638 -0
  62. package/src/lib/adapter-client.mjs +383 -0
  63. package/src/lib/adapter-protocol.mjs +243 -0
  64. package/src/lib/ai-secret-store.mjs +202 -0
  65. package/src/lib/approval-drill.mjs +111 -0
  66. package/src/lib/boot-guard.mjs +186 -0
  67. package/src/lib/branch-store.mjs +84 -0
  68. package/src/lib/branch.mjs +74 -0
  69. package/src/lib/bridge-client.mjs +133 -0
  70. package/src/lib/broker-adapter.mjs +67 -0
  71. package/src/lib/broker.mjs +45 -0
  72. package/src/lib/business-producers.mjs +109 -0
  73. package/src/lib/command-cache.mjs +209 -0
  74. package/src/lib/compat.mjs +170 -0
  75. package/src/lib/composer-journal.mjs +128 -0
  76. package/src/lib/container/index.mjs +806 -0
  77. package/src/lib/decision-events.mjs +129 -0
  78. package/src/lib/decision-ingest-client.mjs +41 -0
  79. package/src/lib/decision-outbox.mjs +129 -0
  80. package/src/lib/decision-policy.mjs +52 -0
  81. package/src/lib/default-essence.mjs +61 -0
  82. package/src/lib/desktop-launch.mjs +138 -0
  83. package/src/lib/essence.mjs +1211 -0
  84. package/src/lib/fork-client.mjs +410 -0
  85. package/src/lib/fork-config-lock.mjs +166 -0
  86. package/src/lib/fork-seed.mjs +304 -0
  87. package/src/lib/fork-sessions.mjs +183 -0
  88. package/src/lib/fork-supervision-settings.mjs +58 -0
  89. package/src/lib/fork-supervisor.mjs +122 -0
  90. package/src/lib/fs-guard.mjs +28 -0
  91. package/src/lib/http-auth.mjs +162 -0
  92. package/src/lib/idle-audit.mjs +178 -0
  93. package/src/lib/interaction-poke.mjs +61 -0
  94. package/src/lib/main-gate.mjs +582 -0
  95. package/src/lib/mirror-etag.mjs +92 -0
  96. package/src/lib/mirror-official-source.mjs +93 -0
  97. package/src/lib/mirror.mjs +478 -0
  98. package/src/lib/mother-link.mjs +156 -0
  99. package/src/lib/mutex.mjs +31 -0
  100. package/src/lib/permissions.mjs +115 -0
  101. package/src/lib/pnpm-store.mjs +189 -0
  102. package/src/lib/preferences.mjs +212 -0
  103. package/src/lib/producer-contract.mjs +162 -0
  104. package/src/lib/reflow.mjs +41 -0
  105. package/src/lib/remote.mjs +408 -0
  106. package/src/lib/restart-contract.mjs +752 -0
  107. package/src/lib/roots.mjs +30 -0
  108. package/src/lib/rule-registry.mjs +1127 -0
  109. package/src/lib/runtime-capture.mjs +307 -0
  110. package/src/lib/semaphore.mjs +34 -0
  111. package/src/lib/session-bundle.mjs +138 -0
  112. package/src/lib/session-format.mjs +672 -0
  113. package/src/lib/sse-parser.mjs +102 -0
  114. package/src/lib/ssrf-guard.mjs +170 -0
  115. package/src/lib/status-store.mjs +257 -0
  116. package/src/lib/task-handle.mjs +622 -0
  117. package/src/lib/ui-actions.mjs +55 -0
  118. package/src/lib/umbrella.mjs +1661 -0
  119. package/src/lib/upgrade-coordinator.mjs +107 -0
  120. package/src/lib/upgrade-status.mjs +43 -0
  121. package/src/plugins/adapter.mjs +1468 -0
  122. package/src/plugins/approver.mjs +1210 -0
  123. package/src/plugins/auditor.mjs +330 -0
  124. package/src/plugins/buffer-keeper.mjs +522 -0
  125. package/src/plugins/decision-center.mjs +2581 -0
  126. package/src/plugins/forker.mjs +1610 -0
  127. package/src/plugins/healer.mjs +702 -0
  128. package/src/plugins/hot-reloader.mjs +274 -0
  129. package/src/plugins/imports.mjs +247 -0
  130. package/src/plugins/orchestrator.mjs +3979 -0
  131. package/src/plugins/restart-guard.mjs +1022 -0
  132. package/src/plugins/router.mjs +714 -0
  133. package/src/plugins/scorer.mjs +661 -0
  134. package/src/plugins/session-bridge.mjs +166 -0
  135. package/src/plugins/session-catalog.mjs +771 -0
  136. package/src/plugins/session-router.mjs +125 -0
  137. package/src/plugins/session-sync.mjs +171 -0
  138. package/src/plugins/umbrella.mjs +419 -0
  139. package/tools/approval-drill.mjs +21 -0
  140. package/tools/boot-guard.mjs +95 -0
  141. package/tools/decision-ingest.mjs +53 -0
  142. package/tools/desktop-launch.mjs +71 -0
  143. package/tools/fangctl.mjs +425 -0
  144. package/tools/gate/main-city.mjs +204 -0
  145. package/tools/mcp/server.mjs +121 -0
  146. package/tools/mcp/tools.mjs +65 -0
  147. package/tools/recover-queue.mjs +133 -0
@@ -0,0 +1,92 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // dsh-Fang v3.0.1 — 母体镜像状态的条件 GET(ETag / If-None-Match,零依赖)。
3
+ //
4
+ // 目的:60 秒轮询在状态未变化时不传输完整体。
5
+ // ETag 只由“稳定字段”计算:diskUsageBytes 每次请求都在漂移(日志/临时文件),
6
+ // 必须排除,否则永远拿不到 304;客户端在 304 时沿用上一份状态(磁盘占用可能是几十秒前的值),
7
+ // 用户点「刷新」走 force 路径拿最新值。
8
+
9
+ import { sha256 } from '../kernel/index.mjs'
10
+
11
+ /**
12
+ * 计算状态 ETag(强校验器,带引号)。
13
+ * @param {Record<string, any>} status
14
+ * @returns {string}
15
+ */
16
+ export function computeMirrorStatusEtag(status) {
17
+ const source = status !== null && typeof status === 'object' ? status : {}
18
+ const meta = source.meta !== null && typeof source.meta === 'object' ? source.meta : {}
19
+ const backups = Array.isArray(source.backups) ? source.backups : []
20
+ const stable = {
21
+ ok: source.ok !== false,
22
+ currentVersion: source.currentVersion ?? null,
23
+ officialLatest: source.officialLatest ?? null,
24
+ updateAvailable: source.updateAvailable === true,
25
+ meta: {
26
+ version: meta.version ?? null,
27
+ backupIntervalHours: meta.backupIntervalHours ?? null,
28
+ lastBackupAt: meta.lastBackupAt ?? null,
29
+ nextBackupAt: meta.nextBackupAt ?? null,
30
+ },
31
+ backups: backups.map(entry => ({
32
+ id: entry?.id ?? null,
33
+ version: entry?.version ?? null,
34
+ kind: entry?.kind ?? null,
35
+ bucket: entry?.bucket ?? null,
36
+ createdAt: entry?.createdAt ?? null,
37
+ sizeBytes: entry?.sizeBytes ?? null,
38
+ })),
39
+ activeEssenceCount: source.activeEssenceCount ?? 0,
40
+ activeSessionCount: source.activeSessionCount ?? 0,
41
+ // diskUsageBytes 刻意排除:持续漂移字段
42
+ }
43
+ return `"${sha256(JSON.stringify(stable)).slice(0, 24)}"`
44
+ }
45
+
46
+ /**
47
+ * If-None-Match 匹配判断:支持逗号多值、`*`、弱校验前缀 `W/`(强比较语义下也接受弱值)。
48
+ * @param {string|string[]|undefined} header
49
+ * @param {string} etag 服务端计算的 ETag(带引号)
50
+ * @returns {boolean}
51
+ */
52
+ export function ifNoneMatchMatches(header, etag) {
53
+ if (typeof header !== 'string') return false
54
+ const candidates = header.split(',')
55
+ const target = String(etag).trim()
56
+ const normalizedTarget = target.startsWith('W/') ? target.slice(2) : target
57
+ for (const raw of candidates) {
58
+ const candidate = raw.trim()
59
+ if (candidate.length === 0) continue
60
+ if (candidate === '*') return true
61
+ const normalized = candidate.startsWith('W/') ? candidate.slice(2) : candidate
62
+ if (normalized === normalizedTarget) return true
63
+ }
64
+ return false
65
+ }
66
+
67
+ /**
68
+ * 构造镜像状态响应(200 / 304)。
69
+ * @param {Record<string, any>} status
70
+ * @param {string|string[]|undefined} ifNoneMatch
71
+ * @returns {{statusCode:number, headers:Record<string,string|number>, body:string|null, etag:string, notModified:boolean}}
72
+ */
73
+ export function buildMirrorStatusResponse(status, ifNoneMatch) {
74
+ const etag = computeMirrorStatusEtag(status)
75
+ const notModified = ifNoneMatchMatches(ifNoneMatch, etag)
76
+ const headers = {
77
+ ETag: etag,
78
+ 'Cache-Control': 'no-cache, must-revalidate',
79
+ 'Content-Type': 'application/json; charset=utf-8',
80
+ }
81
+ if (notModified) return { statusCode: 304, headers, body: null, etag, notModified: true }
82
+ const body = JSON.stringify(status)
83
+ return {
84
+ statusCode: 200,
85
+ headers: { ...headers, 'Content-Length': Buffer.byteLength(body) },
86
+ body,
87
+ etag,
88
+ notModified: false,
89
+ }
90
+ }
91
+
92
+ export const _internals = Object.freeze({ stableFields: (status) => { const m = status?.meta ?? {}; return [status?.currentVersion, m.version, m.lastBackupAt, Array.isArray(status?.backups) ? status.backups.length : 0] } })
@@ -0,0 +1,93 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ //
4
+ // mirror-official-source — 最小可用的「官方源 adapter」(manifest 协议 v1)。
5
+ //
6
+ // 协议:
7
+ // GET <baseUrl>/manifest.json
8
+ // {
9
+ // "version": "3.2.0",
10
+ // "files": {
11
+ // "package.json": "{\"name\":\"dsh-profile\"}",
12
+ // "cordis.patch.yml": "insert: []\n"
13
+ // }
14
+ // }
15
+ // download(version, staging):校验 version 后把 files 写到 staging(防路径逃逸)。
16
+ //
17
+ // 远程必须 HTTPS(loopback 允许 http)。MirrorManager 现在会 await
18
+ // getLatestVersion()/download(),因此 adapter 可以是 async。
19
+
20
+ function isLoopbackHost(hostname) {
21
+ return /^(localhost|127\.0\.0\.1|\[::1\]|::1)$/i.test(String(hostname))
22
+ }
23
+
24
+ export function validateOfficialSourceUrl(baseUrl) {
25
+ let endpoint = null
26
+ try { endpoint = new URL(String(baseUrl ?? '').trim()) } catch { return { ok: false, reason: 'base-url-required' } }
27
+ if (!/^https?:$/.test(endpoint.protocol)) return { ok: false, reason: 'base-url-required' }
28
+ if (endpoint.username || endpoint.password) return { ok: false, reason: 'base-url-userinfo-forbidden' }
29
+ if (!isLoopbackHost(endpoint.hostname) && endpoint.protocol !== 'https:') return { ok: false, reason: 'https-required-for-remote-endpoint' }
30
+ return { ok: true, url: endpoint.toString().replace(/\/+$/, '') }
31
+ }
32
+
33
+ /** 只允许 staging 内的相对路径;拒绝绝对路径、`..`、NUL。 */
34
+ export function safeRelativePath(relative) {
35
+ const raw = String(relative ?? '')
36
+ if (!raw || raw.includes('\0')) return null
37
+ if (/^[A-Za-z]:/.test(raw) || raw.startsWith('\\\\') || raw.startsWith('//')) return null
38
+ const parts = raw.replace(/\\/g, '/').split('/')
39
+ if (parts.some((part) => part === '..')) return null
40
+ if (parts.some((part) => part === '')) return null
41
+ return parts.join('/')
42
+ }
43
+
44
+ export function createManifestOfficialSource({ baseUrl, fetchImpl = globalThis.fetch, headers = {}, token = '' } = {}) {
45
+ const checked = validateOfficialSourceUrl(baseUrl)
46
+ if (!checked.ok) throw new Error(`official source: ${checked.reason}`)
47
+ if (typeof fetchImpl !== 'function') throw new TypeError('official source: fetchImpl required')
48
+ const manifestUrl = `${checked.url}/manifest.json`
49
+
50
+ async function fetchManifest() {
51
+ const auth = token ? { authorization: `Bearer ${token}` } : {}
52
+ const response = await fetchImpl(manifestUrl, { headers: { accept: 'application/json', ...auth, ...headers }, redirect: 'error' })
53
+ if (!response.ok) throw new Error(`official source manifest: http-${response.status}`)
54
+ const text = await response.text()
55
+ let manifest = null
56
+ try { manifest = JSON.parse(text) } catch { throw new Error('official source manifest: bad-json') }
57
+ const version = String(manifest?.version ?? '').trim()
58
+ if (!version) throw new Error('official source manifest: version-required')
59
+ const files = manifest?.files && typeof manifest.files === 'object' && !Array.isArray(manifest.files) ? manifest.files : null
60
+ if (!files) throw new Error('official source manifest: files-required')
61
+ return { version, files }
62
+ }
63
+
64
+ return {
65
+ baseUrl: checked.url,
66
+ async getLatestVersion() {
67
+ return (await fetchManifest()).version
68
+ },
69
+ async download(version, stagingDir) {
70
+ const manifest = await fetchManifest()
71
+ if (manifest.version !== String(version)) {
72
+ throw new Error(`official source version mismatch: manifest=${manifest.version} requested=${version}`)
73
+ }
74
+ const root = String(stagingDir ?? '')
75
+ if (!root) throw new Error('official source download: staging required')
76
+ const fs = await import('node:fs')
77
+ const path = await import('node:path')
78
+ fs.mkdirSync(root, { recursive: true })
79
+ const rootResolved = path.resolve(root)
80
+ for (const [relative, content] of Object.entries(manifest.files)) {
81
+ const safe = safeRelativePath(relative)
82
+ if (!safe) throw new Error(`official source download: unsafe path ${relative}`)
83
+ const target = path.resolve(rootResolved, safe)
84
+ if (target !== rootResolved && !target.startsWith(rootResolved + path.sep)) {
85
+ throw new Error(`official source download: path escape ${relative}`)
86
+ }
87
+ fs.mkdirSync(path.dirname(target), { recursive: true })
88
+ fs.writeFileSync(target, String(content ?? ''), 'utf8')
89
+ }
90
+ return { version: manifest.version, files: Object.keys(manifest.files).length }
91
+ },
92
+ }
93
+ }
@@ -0,0 +1,478 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ // dsh-Fang v2.11.0 — 母体镜像管理(零依赖)。
4
+ //
5
+ // 目录(文件夹 1):
6
+ // <mirrorRoot>/
7
+ // active/ 正在运行的镜像内容(唯一可被升级/回退替换的层)
8
+ // backup/<id>/ 自动备份(按 backupIntervalHours 滚动)
9
+ // restore/<id>/ 手动恢复点
10
+ // backups.json 备份索引(id/label/createdAt/sizeBytes/version/kind/bucket)
11
+ // meta.json version/installedAt/backupIntervalHours/lastBackupAt/nextBackupAt
12
+ // mirror-locked.json 锁定插件清单(由 EssenceManager 读取;可选)
13
+ //
14
+ // 语义:
15
+ // * 备份 = 复制 active/ 到 backup|restore/<id>/,索引与 meta 同步;
16
+ // * 恢复 = 先自动备份当前 active/,再用快照替换 active/,最后更新 meta.version;
17
+ // * 升级 = 官方源取最新版 → 自动备份 → 下载到 staging → 原子替换 → 失败自动回退;
18
+ // 未配置官方源时 upgrade 显式返回 official-source-unavailable(绝不假装成功)。
19
+
20
+ import fs from 'node:fs'
21
+ import path from 'node:path'
22
+
23
+ import {
24
+ ensureDir,
25
+ exists,
26
+ readJson,
27
+ writeJsonAtomic,
28
+ sha256,
29
+ copyTree,
30
+ removeTree,
31
+ listFiles,
32
+ } from '../kernel/index.mjs'
33
+
34
+ export const MIRROR_VERSION = '1.0.0'
35
+
36
+ export const BACKUP_INTERVAL_HOURS = Object.freeze([6, 12, 24])
37
+ export const DEFAULT_BACKUP_INTERVAL_HOURS = 24
38
+ export const BACKUP_BUCKET = Object.freeze({ AUTO: 'backup', MANUAL: 'restore' })
39
+ export const UPGRADE_STAGE = Object.freeze({
40
+ CHECKING: 'checking', BACKING_UP: 'backing-up', DOWNLOADING: 'downloading',
41
+ SWAPPING: 'swapping', FINALIZING: 'finalizing', DONE: 'done', FAILED: 'failed',
42
+ })
43
+
44
+ const HOUR_MS = 3_600_000
45
+ const BACKUP_ID_RE = /^\d{10,16}-[a-z0-9]{4,12}$/
46
+
47
+ /** @param {unknown} value */
48
+ function normalizeIntervalHours(value) {
49
+ const hours = Number(value)
50
+ return BACKUP_INTERVAL_HOURS.includes(hours) ? hours : DEFAULT_BACKUP_INTERVAL_HOURS
51
+ }
52
+
53
+ /** @param {number} bytes */
54
+ function directorySize(bytes) {
55
+ return Number.isFinite(bytes) && bytes > 0 ? bytes : 0
56
+ }
57
+
58
+ /**
59
+ * 计算目录大小(递归)。
60
+ * @param {string} dir
61
+ * @returns {number}
62
+ */
63
+ function dirSize(dir) {
64
+ if (!exists(dir)) return 0
65
+ let total = 0
66
+ for (const relative of listFiles(dir, { max: 200000 })) {
67
+ try { total += fs.statSync(path.join(dir, relative)).size } catch { /* 文件在遍历中被删:忽略 */ }
68
+ }
69
+ return total
70
+ }
71
+
72
+ /**
73
+ * 递归复制目录(不跟随符号链接;目标必须不存在或为空)。
74
+ * @param {string} from @param {string} to
75
+ */
76
+ function copyDir(from, to) {
77
+ if (!exists(from)) throw new Error(`mirror: source not found: ${from}`)
78
+ ensureDir(to)
79
+ copyTree(from, to)
80
+ }
81
+
82
+ export class MirrorManager {
83
+ /**
84
+ * @param {{mirrorRoot: string, officialSource?: {getLatestVersion:Function, download:Function}|null,
85
+ * logger?: {info?:Function, warn?:Function}, now?: () => number,
86
+ * usageProvider?: () => {activeEssenceCount?: number, activeSessionCount?: number}}} options
87
+ */
88
+ constructor(options) {
89
+ this.mirrorRoot = String(options.mirrorRoot ?? '')
90
+ if (this.mirrorRoot.length === 0) throw new Error('MirrorManager: mirrorRoot required')
91
+ this.officialSource = options.officialSource ?? null
92
+ this.logger = options.logger ?? {}
93
+ this.now = typeof options.now === 'function' ? options.now : () => Date.now()
94
+ this.usageProvider = typeof options.usageProvider === 'function' ? options.usageProvider : null
95
+ // v3.0.9:升级成功后的副作用钩子(由协调器注入;异步触发,不阻塞 upgrade 返回)
96
+ this.postUpgradeHook = typeof options.postUpgradeHook === 'function' ? options.postUpgradeHook : null
97
+ // v3.0.3:客户端升级后的兼容性门禁;失败时保持子插件关闭(默认策略)
98
+ this.compatGate = typeof options.compatGate === 'function' ? options.compatGate : null
99
+ this.onCompatResult = typeof options.onCompatResult === 'function' ? options.onCompatResult : null
100
+ }
101
+
102
+ activeDir() { return path.join(this.mirrorRoot, 'active') }
103
+ backupDir() { return path.join(this.mirrorRoot, 'backup') }
104
+ restoreDir() { return path.join(this.mirrorRoot, 'restore') }
105
+ metaPath() { return path.join(this.mirrorRoot, 'meta.json') }
106
+ indexPath() { return path.join(this.mirrorRoot, 'backups.json') }
107
+
108
+ /** 初始化目录与 meta(幂等;不覆盖已有 meta)。 */
109
+ ensureLayout() {
110
+ ensureDir(this.activeDir())
111
+ ensureDir(this.backupDir())
112
+ ensureDir(this.restoreDir())
113
+ if (!exists(this.metaPath())) {
114
+ this.writeMeta({
115
+ version: this.detectActiveVersion(),
116
+ installedAt: this.now(),
117
+ backupIntervalHours: DEFAULT_BACKUP_INTERVAL_HOURS,
118
+ lastBackupAt: null,
119
+ nextBackupAt: null,
120
+ })
121
+ }
122
+ if (!exists(this.indexPath())) writeJsonAtomic(this.indexPath(), [])
123
+ return this.getStatus()
124
+ }
125
+
126
+ /** @returns {{version:string, installedAt:number, backupIntervalHours:number, lastBackupAt:number|null, nextBackupAt:number|null}} */
127
+ readMeta() {
128
+ const raw = readJson(this.metaPath(), {})
129
+ return {
130
+ version: String(raw.version ?? 'unknown'),
131
+ installedAt: Number(raw.installedAt) || 0,
132
+ backupIntervalHours: normalizeIntervalHours(raw.backupIntervalHours),
133
+ lastBackupAt: raw.lastBackupAt === null || raw.lastBackupAt === undefined ? null : Number(raw.lastBackupAt),
134
+ nextBackupAt: raw.nextBackupAt === null || raw.nextBackupAt === undefined ? null : Number(raw.nextBackupAt),
135
+ }
136
+ }
137
+
138
+ /** @param {ReturnType<MirrorManager['readMeta']>} meta */
139
+ writeMeta(meta) {
140
+ ensureDir(this.mirrorRoot)
141
+ writeJsonAtomic(this.metaPath(), meta)
142
+ }
143
+
144
+ /** @returns {Array<{id:string,label:string|null,createdAt:number,sizeBytes:number,version:string,kind:string,bucket:string}>} */
145
+ listBackups() {
146
+ const list = readJson(this.indexPath(), [])
147
+ if (!Array.isArray(list)) return []
148
+ return list
149
+ .filter(entry => entry !== null && typeof entry === 'object')
150
+ .map(entry => ({
151
+ id: String(entry.id ?? ''),
152
+ label: entry.label === undefined ? null : entry.label,
153
+ createdAt: Number(entry.createdAt) || 0,
154
+ sizeBytes: directorySize(Number(entry.sizeBytes) || 0),
155
+ version: String(entry.version ?? 'unknown'),
156
+ kind: entry.kind === 'manual' ? 'manual' : 'auto',
157
+ bucket: entry.bucket === BACKUP_BUCKET.MANUAL ? BACKUP_BUCKET.MANUAL : BACKUP_BUCKET.AUTO,
158
+ }))
159
+ .filter(entry => entry.id.length > 0)
160
+ .sort((a, b) => b.createdAt - a.createdAt)
161
+ }
162
+
163
+ /** 当前镜像版本(无法判断时 unknown)。 */
164
+ detectActiveVersion() {
165
+ const version = readJson(path.join(this.activeDir(), 'version.json'), null)
166
+ if (version !== null && typeof version === 'object' && typeof version.version === 'string') return version.version
167
+ try { return String(readJson(this.metaPath(), {}).version ?? 'unknown') } catch { return 'unknown' }
168
+ }
169
+
170
+ /**
171
+ * 完整状态(供 UI / 路由)。
172
+ * @returns {Record<string, unknown>}
173
+ */
174
+ getStatus() {
175
+ this.ensureLayoutWithoutRecursion()
176
+ const meta = this.readMeta()
177
+ let latest = null
178
+ if (this.officialSource !== null && typeof this.officialSource.getLatestVersion === 'function') {
179
+ try { latest = String(this.officialSource.getLatestVersion() ?? null) } catch { latest = null }
180
+ }
181
+ const usage = this.usageProvider === null ? {} : (this.usageProvider() ?? {})
182
+ return {
183
+ ok: true,
184
+ meta,
185
+ currentVersion: meta.version,
186
+ officialLatest: latest,
187
+ updateAvailable: latest !== null && latest !== meta.version,
188
+ backups: this.listBackups(),
189
+ activeEssenceCount: Number(usage.activeEssenceCount ?? 0) || 0,
190
+ activeSessionCount: Number(usage.activeSessionCount ?? 0) || 0,
191
+ diskUsageBytes: dirSize(this.mirrorRoot),
192
+ }
193
+ }
194
+
195
+ /** getStatus 内部调用;只补齐目录,避免递归。 */
196
+ ensureLayoutWithoutRecursion() {
197
+ ensureDir(this.backupDir())
198
+ ensureDir(this.restoreDir())
199
+ if (!exists(this.indexPath())) writeJsonAtomic(this.indexPath(), [])
200
+ if (!exists(this.metaPath())) {
201
+ this.writeMeta({
202
+ version: this.detectActiveVersion(), installedAt: this.now(),
203
+ backupIntervalHours: DEFAULT_BACKUP_INTERVAL_HOURS, lastBackupAt: null, nextBackupAt: null,
204
+ })
205
+ }
206
+ }
207
+
208
+ /**
209
+ * 备份当前 active 到 backup|restore/<id>/。
210
+ * @param {{label?: string, bucket?: 'backup'|'restore'}} [options]
211
+ * @returns {Record<string, unknown>} 备份条目
212
+ */
213
+ backup(options = {}) {
214
+ this.ensureLayout()
215
+ const bucket = options.bucket === BACKUP_BUCKET.MANUAL || options.bucket === 'restore'
216
+ ? BACKUP_BUCKET.MANUAL
217
+ : BACKUP_BUCKET.AUTO
218
+ const meta = this.readMeta()
219
+ const createdAt = this.now()
220
+ const id = `${String(createdAt).padStart(13, '0')}-${sha256(`${createdAt}:${this.mirrorRoot}:${Math.random()}`).slice(0, 8)}`
221
+ const target = path.join(bucket === BACKUP_BUCKET.MANUAL ? this.restoreDir() : this.backupDir(), id)
222
+ if (exists(target)) throw new Error(`mirror: backup id collision: ${id}`)
223
+ copyDir(this.activeDir(), target)
224
+ const entry = {
225
+ id,
226
+ label: typeof options.label === 'string' && options.label.trim().length > 0 ? options.label.trim() : null,
227
+ createdAt,
228
+ sizeBytes: dirSize(target),
229
+ version: meta.version,
230
+ kind: bucket === BACKUP_BUCKET.MANUAL ? 'manual' : 'auto',
231
+ bucket,
232
+ }
233
+ const index = this.listBackups()
234
+ index.unshift(entry)
235
+ writeJsonAtomic(this.indexPath(), index)
236
+ this.writeMeta({
237
+ ...meta,
238
+ lastBackupAt: createdAt,
239
+ nextBackupAt: createdAt + meta.backupIntervalHours * HOUR_MS,
240
+ })
241
+ this.logger?.info?.(`[mirror] backup ${bucket} ${id} (${entry.sizeBytes} bytes)`)
242
+ return entry
243
+ }
244
+
245
+ /**
246
+ * 恢复某个备份:默认先自动备份当前 active,再替换,更新 meta.version。
247
+ * @param {string} backupId
248
+ * @returns {{ok:boolean, from?:string, to?:string, preBackupId?:string, reason?:string}}
249
+ */
250
+ restore(backupId) {
251
+ return this.restoreSnapshot(backupId, { preBackup: true })
252
+ }
253
+
254
+ /**
255
+ * 恢复实现;升级失败时的内部回滚不再额外备份(升级前备份已存在)。
256
+ * @param {string} backupId
257
+ * @param {{preBackup?: boolean}} [options]
258
+ * @returns {{ok:boolean, from?:string, to?:string, preBackupId?:string, reason?:string}}
259
+ */
260
+ restoreSnapshot(backupId, options = {}) {
261
+ const preBackup = options.preBackup !== false
262
+ this.ensureLayout()
263
+ const id = this.assertBackupId(backupId)
264
+ const entry = this.listBackups().find(item => item.id === id)
265
+ if (entry === undefined) return { ok: false, reason: 'backup-not-found' }
266
+ const source = path.join(entry.bucket === BACKUP_BUCKET.MANUAL ? this.restoreDir() : this.backupDir(), id)
267
+ if (!exists(source)) return { ok: false, reason: 'backup-content-missing', backupId: id }
268
+ const meta = this.readMeta()
269
+ const pre = preBackup ? this.backup({ label: `pre-restore-${id}`, bucket: BACKUP_BUCKET.AUTO }) : null
270
+ try {
271
+ this.swapActive(source)
272
+ this.writeMeta({ ...meta, version: entry.version, installedAt: this.now(), lastBackupAt: this.now() })
273
+ this.logger?.info?.(`[mirror] restored ${id} (${meta.version} → ${entry.version})`)
274
+ return {
275
+ ok: true, from: meta.version, to: entry.version,
276
+ ...(pre === null ? {} : { preBackupId: pre.id }),
277
+ }
278
+ } catch (error) {
279
+ return {
280
+ ok: false, reason: String(error instanceof Error ? error.message : error),
281
+ backupId: id, ...(pre === null ? {} : { preBackupId: pre.id }),
282
+ }
283
+ }
284
+ }
285
+
286
+ /** @param {string} backupId @returns {{ok:boolean, removed?:boolean, reason?:string}} */
287
+ deleteBackup(backupId) {
288
+ const id = this.assertBackupId(backupId)
289
+ const entry = this.listBackups().find(item => item.id === id)
290
+ if (entry === undefined) return { ok: false, reason: 'backup-not-found' }
291
+ removeTree(path.join(entry.bucket === BACKUP_BUCKET.MANUAL ? this.restoreDir() : this.backupDir(), id))
292
+ writeJsonAtomic(this.indexPath(), this.listBackups().filter(item => item.id !== id))
293
+ return { ok: true, removed: true }
294
+ }
295
+
296
+ /** @param {number} hours 6 | 12 | 24 */
297
+ setBackupInterval(hours) {
298
+ const next = Number(hours)
299
+ if (!BACKUP_INTERVAL_HOURS.includes(next)) {
300
+ return { ok: false, reason: `invalid interval: ${hours} (allowed: ${BACKUP_INTERVAL_HOURS.join(',')})` }
301
+ }
302
+ this.ensureLayout()
303
+ const meta = this.readMeta()
304
+ const last = meta.lastBackupAt ?? this.now()
305
+ this.writeMeta({ ...meta, backupIntervalHours: next, nextBackupAt: last + next * HOUR_MS })
306
+ return { ok: true, backupIntervalHours: next }
307
+ }
308
+
309
+ /** 到期自动备份(由 orchestrator 的定时器调用;now 可注入便于测试)。 */
310
+ maybeAutoBackup(now = this.now()) {
311
+ this.ensureLayoutWithoutRecursion()
312
+ const meta = this.readMeta()
313
+ if (meta.nextBackupAt === null) {
314
+ const last = meta.lastBackupAt ?? now
315
+ this.writeMeta({ ...meta, nextBackupAt: last + meta.backupIntervalHours * HOUR_MS })
316
+ return { ok: true, backedUp: false, reason: 'scheduled-first' }
317
+ }
318
+ if (now < meta.nextBackupAt) return { ok: true, backedUp: false, reason: 'not-due' }
319
+ try {
320
+ const entry = this.backup({ bucket: BACKUP_BUCKET.AUTO })
321
+ return { ok: true, backedUp: true, entry }
322
+ } catch (error) {
323
+ this.logger?.warn?.(`[mirror] auto backup failed: ${error instanceof Error ? error.message : error}`)
324
+ return { ok: false, backedUp: false, reason: String(error instanceof Error ? error.message : error) }
325
+ }
326
+ }
327
+
328
+ /** 检查更新(仅查询官方源,不下发下载)。 */
329
+ checkUpdate() {
330
+ this.ensureLayoutWithoutRecursion()
331
+ const current = this.readMeta().version
332
+ if (this.officialSource === null) return { ok: false, current, latest: null, available: false, reason: 'official-source-unavailable' }
333
+ const settle = (latest) => ({ ok: true, current, latest: String(latest ?? null), available: latest !== null && latest !== current })
334
+ try {
335
+ const latest = this.officialSource.getLatestVersion()
336
+ if (latest && typeof latest.then === 'function') {
337
+ return latest.then(settle).catch((error) => ({
338
+ ok: false, current, latest: null, available: false,
339
+ reason: String(error instanceof Error ? error.message : error),
340
+ }))
341
+ }
342
+ return settle(latest)
343
+ } catch (error) {
344
+ return { ok: false, current, latest: null, available: false, reason: String(error instanceof Error ? error.message : error) }
345
+ }
346
+ }
347
+
348
+ /**
349
+ * 升级:备份 → 下载 staging → 原子替换 → 写 meta;失败自动回退。
350
+ * @param {{autoBackup?: boolean, autoRollback?: boolean, onProgress?: (stage: string) => void}} [options]
351
+ * @returns {Promise<Record<string, unknown>>}
352
+ */
353
+ async upgrade(options = {}) {
354
+ const autoBackup = options.autoBackup !== false
355
+ const autoRollback = options.autoRollback !== false
356
+ const progress = typeof options.onProgress === 'function' ? options.onProgress : null
357
+ const stage = (name) => { try { progress?.(name) } catch { /* 观察者异常不影响升级 */ } }
358
+
359
+ this.ensureLayout()
360
+ const from = this.readMeta().version
361
+ if (this.officialSource === null || typeof this.officialSource.download !== 'function') {
362
+ return { ok: false, from, to: from, backupId: null, rolledBack: false, reason: 'official-source-unavailable' }
363
+ }
364
+ stage(UPGRADE_STAGE.CHECKING)
365
+ let latest
366
+ try { latest = String((await Promise.resolve(this.officialSource.getLatestVersion())) ?? '') } catch (error) {
367
+ return { ok: false, from, to: from, backupId: null, rolledBack: false, reason: `fetch-latest-failed: ${error instanceof Error ? error.message : error}` }
368
+ }
369
+ if (latest.length === 0) return { ok: false, from, to: from, backupId: null, rolledBack: false, reason: 'latest-version-unknown' }
370
+ if (latest === from) return { ok: true, from, to: from, backupId: null, rolledBack: false, reason: 'already-latest' }
371
+
372
+ let backupId = null
373
+ if (autoBackup) {
374
+ stage(UPGRADE_STAGE.BACKING_UP)
375
+ backupId = this.backup({ label: `pre-upgrade-${latest}`, bucket: BACKUP_BUCKET.AUTO }).id
376
+ }
377
+
378
+ const staging = path.join(this.mirrorRoot, `.staging-${this.now()}-${sha256(`${latest}:${Math.random()}`).slice(0, 6)}`)
379
+ try {
380
+ stage(UPGRADE_STAGE.DOWNLOADING)
381
+ await Promise.resolve(this.officialSource.download(latest, staging))
382
+ stage(UPGRADE_STAGE.SWAPPING)
383
+ this.swapActive(staging)
384
+ stage(UPGRADE_STAGE.FINALIZING)
385
+ this.writeMeta({
386
+ ...this.readMeta(), version: latest, installedAt: this.now(),
387
+ lastBackupAt: backupId === null ? this.readMeta().lastBackupAt : this.now(),
388
+ })
389
+ writeJsonAtomic(path.join(this.activeDir(), 'version.json'), { version: latest })
390
+ // v3.0.3:客户端升级后先跑 compat 门禁;未通过则保持子插件关闭并跳过重算
391
+ let compat = null
392
+ if (this.compatGate !== null) {
393
+ try {
394
+ compat = await this.compatGate({ from, to: latest, backupId })
395
+ } catch (error) {
396
+ compat = {
397
+ ok: false,
398
+ checkedAt: Date.now(),
399
+ findings: [{ code: 'compat-gate-threw', severity: 'error', message: String(error instanceof Error ? error.message : error) }],
400
+ }
401
+ }
402
+ try { this.onCompatResult?.(compat, { from, to: latest }) } catch { /* 观察者异常隔离 */ }
403
+ if (compat === null || compat.ok !== true) {
404
+ stage(UPGRADE_STAGE.FAILED)
405
+ this.logger?.warn?.('[mirror] compat gate failed; sub-plugins stay disabled', {
406
+ findings: Array.isArray(compat?.findings) ? compat.findings.length : 0,
407
+ })
408
+ return {
409
+ ok: true, from, to: latest, backupId, rolledBack: false,
410
+ reason: 'compat-gate-failed', compat, postUpgradeTriggered: false,
411
+ }
412
+ }
413
+ }
414
+ stage(UPGRADE_STAGE.DONE)
415
+ this.logger?.info?.(`[mirror] upgraded ${from} → ${latest}`)
416
+ const result = { ok: true, from, to: latest, backupId, rolledBack: false }
417
+ if (compat !== null) result.compat = compat
418
+ if (this.postUpgradeHook !== null) {
419
+ result.postUpgradeTriggered = true
420
+ void this.runPostUpgrade({ from, to: latest, backupId })
421
+ }
422
+ return result
423
+ } catch (error) {
424
+ const reason = String(error instanceof Error ? error.message : error)
425
+ this.logger?.warn?.(`[mirror] upgrade failed: ${reason}`)
426
+ if (autoRollback && backupId !== null) {
427
+ const rollback = this.restoreSnapshot(backupId, { preBackup: false })
428
+ stage(UPGRADE_STAGE.FAILED)
429
+ return {
430
+ ok: false, from, to: from, backupId, rolledBack: rollback.ok === true,
431
+ reason: rollback.ok === true ? reason : `${reason}; rollback failed: ${rollback.reason}`,
432
+ }
433
+ }
434
+ stage(UPGRADE_STAGE.FAILED)
435
+ return { ok: false, from, to: from, backupId, rolledBack: false, reason }
436
+ } finally {
437
+ if (exists(staging)) removeTree(staging)
438
+ }
439
+ }
440
+
441
+ /**
442
+ * 触发升级后副作用(fire-and-forget):钩子失败只记 warn,不影响升级结果。
443
+ * @param {{from: string, to: string, backupId: string|null}} input
444
+ */
445
+ async runPostUpgrade(input) {
446
+ try {
447
+ this.logger?.info?.('[mirror] triggering post-upgrade hook', input)
448
+ await this.postUpgradeHook(input)
449
+ this.logger?.info?.('[mirror] post-upgrade hook completed', input)
450
+ } catch (error) {
451
+ this.logger?.warn?.('[mirror] post-upgrade hook failed', {
452
+ error: String(error instanceof Error ? error.message : error), ...input,
453
+ })
454
+ }
455
+ }
456
+
457
+ /** @param {unknown} value @returns {string} */
458
+ assertBackupId(value) {
459
+ const id = String(value ?? '')
460
+ if (!BACKUP_ID_RE.test(id)) throw new Error(`invalid backup id: ${id}`)
461
+ return id
462
+ }
463
+
464
+ /** 用 source 目录原子替换 active/(先拷到临时目录,再删旧、改名)。 */
465
+ swapActive(source) {
466
+ const stagingCopy = path.join(this.mirrorRoot, `.swap-${this.now()}-${sha256(`${source}:${Math.random()}`).slice(0, 6)}`)
467
+ try {
468
+ ensureDir(stagingCopy)
469
+ copyTree(source, stagingCopy)
470
+ if (exists(this.activeDir())) removeTree(this.activeDir())
471
+ fs.renameSync(stagingCopy, this.activeDir())
472
+ } finally {
473
+ if (exists(stagingCopy)) removeTree(stagingCopy)
474
+ }
475
+ }
476
+ }
477
+
478
+ export const _internals = Object.freeze({ dirSize, copyDir, normalizeIntervalHours, BACKUP_ID_RE })