@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.
- package/CLA.md +79 -0
- package/LICENSE-COMMERCIAL +78 -0
- package/LICENSE-CORE +661 -0
- package/LICENSE-SDK +21 -0
- package/LICENSES/cordis-MIT.txt +39 -0
- package/NOTICE +95 -0
- package/README.md +158 -2
- package/client.js +7287 -0
- package/cordis.patch.yml +163 -0
- package/defaults/umbrella-rules.yaml +113 -0
- package/docs/APPROVAL-DRILL.md +42 -0
- package/docs/CONTRACT-INTEROP.md +77 -0
- package/docs/CRASH-SAFETY.md +69 -0
- package/docs/DECISION-RELAY.md +46 -0
- package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
- package/docs/FORK-SEED.md +64 -0
- package/docs/MAIN-CITY-GATE.md +58 -0
- package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
- package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
- package/docs/PM-DECISION-POLICY.md +61 -0
- package/docs/WORKFLOW-UI.md +86 -0
- package/icon.svg +8 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +71 -4
- package/sdk/README.md +65 -0
- package/sdk/examples/status-badge/README.md +16 -0
- package/sdk/examples/status-badge/client.js +60 -0
- package/sdk/examples/status-badge/cordis.patch.yml +7 -0
- package/sdk/examples/status-badge/index.js +12 -0
- package/sdk/examples/status-badge/package.json +24 -0
- package/sdk/templates/plugin/README.md +36 -0
- package/sdk/templates/plugin/client.js +35 -0
- package/sdk/templates/plugin/cordis.patch.yml +8 -0
- package/sdk/templates/plugin/icon.svg +5 -0
- package/sdk/templates/plugin/index.js +23 -0
- package/sdk/templates/plugin/locale/en.json +6 -0
- package/sdk/templates/plugin/package.json +36 -0
- package/src/compose.mjs +67 -0
- package/src/contract/VENDOR.json +16 -0
- package/src/contract/client.js +93 -0
- package/src/contract/constants.js +94 -0
- package/src/contract/envelope.js +83 -0
- package/src/contract/index.js +20 -0
- package/src/contract/negotiate.js +106 -0
- package/src/contract/relay.js +223 -0
- package/src/contract/server.js +77 -0
- package/src/contract/transports.js +319 -0
- package/src/index.mjs +11 -0
- package/src/kernel/core.mjs +100 -0
- package/src/kernel/extensions.mjs +61 -0
- package/src/kernel/fsx.mjs +349 -0
- package/src/kernel/index.mjs +21 -0
- package/src/kernel/ledger.mjs +94 -0
- package/src/kernel/paths.mjs +182 -0
- package/src/kernel/role.mjs +68 -0
- package/src/kernel/rounds.mjs +172 -0
- package/src/kernel/schema.mjs +5 -0
- package/src/kernel/types.mjs +266 -0
- package/src/kernel/vendor/cosmokit.mjs +421 -0
- package/src/kernel/vendor/schemastery.mjs +638 -0
- package/src/lib/adapter-client.mjs +383 -0
- package/src/lib/adapter-protocol.mjs +243 -0
- package/src/lib/ai-secret-store.mjs +202 -0
- package/src/lib/approval-drill.mjs +111 -0
- package/src/lib/boot-guard.mjs +186 -0
- package/src/lib/branch-store.mjs +84 -0
- package/src/lib/branch.mjs +74 -0
- package/src/lib/bridge-client.mjs +133 -0
- package/src/lib/broker-adapter.mjs +67 -0
- package/src/lib/broker.mjs +45 -0
- package/src/lib/business-producers.mjs +109 -0
- package/src/lib/command-cache.mjs +209 -0
- package/src/lib/compat.mjs +170 -0
- package/src/lib/composer-journal.mjs +128 -0
- package/src/lib/container/index.mjs +806 -0
- package/src/lib/decision-events.mjs +129 -0
- package/src/lib/decision-ingest-client.mjs +41 -0
- package/src/lib/decision-outbox.mjs +129 -0
- package/src/lib/decision-policy.mjs +52 -0
- package/src/lib/default-essence.mjs +61 -0
- package/src/lib/desktop-launch.mjs +138 -0
- package/src/lib/essence.mjs +1211 -0
- package/src/lib/fork-client.mjs +410 -0
- package/src/lib/fork-config-lock.mjs +166 -0
- package/src/lib/fork-seed.mjs +304 -0
- package/src/lib/fork-sessions.mjs +183 -0
- package/src/lib/fork-supervision-settings.mjs +58 -0
- package/src/lib/fork-supervisor.mjs +122 -0
- package/src/lib/fs-guard.mjs +28 -0
- package/src/lib/http-auth.mjs +162 -0
- package/src/lib/idle-audit.mjs +178 -0
- package/src/lib/interaction-poke.mjs +61 -0
- package/src/lib/main-gate.mjs +582 -0
- package/src/lib/mirror-etag.mjs +92 -0
- package/src/lib/mirror-official-source.mjs +93 -0
- package/src/lib/mirror.mjs +478 -0
- package/src/lib/mother-link.mjs +156 -0
- package/src/lib/mutex.mjs +31 -0
- package/src/lib/permissions.mjs +115 -0
- package/src/lib/pnpm-store.mjs +189 -0
- package/src/lib/preferences.mjs +212 -0
- package/src/lib/producer-contract.mjs +162 -0
- package/src/lib/reflow.mjs +41 -0
- package/src/lib/remote.mjs +408 -0
- package/src/lib/restart-contract.mjs +752 -0
- package/src/lib/roots.mjs +30 -0
- package/src/lib/rule-registry.mjs +1127 -0
- package/src/lib/runtime-capture.mjs +307 -0
- package/src/lib/semaphore.mjs +34 -0
- package/src/lib/session-bundle.mjs +138 -0
- package/src/lib/session-format.mjs +672 -0
- package/src/lib/sse-parser.mjs +102 -0
- package/src/lib/ssrf-guard.mjs +170 -0
- package/src/lib/status-store.mjs +257 -0
- package/src/lib/task-handle.mjs +622 -0
- package/src/lib/ui-actions.mjs +55 -0
- package/src/lib/umbrella.mjs +1661 -0
- package/src/lib/upgrade-coordinator.mjs +107 -0
- package/src/lib/upgrade-status.mjs +43 -0
- package/src/plugins/adapter.mjs +1468 -0
- package/src/plugins/approver.mjs +1210 -0
- package/src/plugins/auditor.mjs +330 -0
- package/src/plugins/buffer-keeper.mjs +522 -0
- package/src/plugins/decision-center.mjs +2581 -0
- package/src/plugins/forker.mjs +1610 -0
- package/src/plugins/healer.mjs +702 -0
- package/src/plugins/hot-reloader.mjs +274 -0
- package/src/plugins/imports.mjs +247 -0
- package/src/plugins/orchestrator.mjs +3979 -0
- package/src/plugins/restart-guard.mjs +1022 -0
- package/src/plugins/router.mjs +714 -0
- package/src/plugins/scorer.mjs +661 -0
- package/src/plugins/session-bridge.mjs +166 -0
- package/src/plugins/session-catalog.mjs +771 -0
- package/src/plugins/session-router.mjs +125 -0
- package/src/plugins/session-sync.mjs +171 -0
- package/src/plugins/umbrella.mjs +419 -0
- package/tools/approval-drill.mjs +21 -0
- package/tools/boot-guard.mjs +95 -0
- package/tools/decision-ingest.mjs +53 -0
- package/tools/desktop-launch.mjs +71 -0
- package/tools/fangctl.mjs +425 -0
- package/tools/gate/main-city.mjs +204 -0
- package/tools/mcp/server.mjs +121 -0
- package/tools/mcp/tools.mjs +65 -0
- 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 })
|