@twinsearth/fang-dsh-plugin 0.0.0-stage → 3.3.4
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/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/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 +579 -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 +3938 -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/boot-guard.mjs +95 -0
- package/tools/decision-ingest.mjs +53 -0
- package/tools/desktop-launch.mjs +71 -0
- package/tools/fangctl.mjs +408 -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,182 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
|
|
3
|
+
// dsh-Fang kernel — path resolution and the fork web-URL contract.
|
|
4
|
+
//
|
|
5
|
+
// Layout (mother):
|
|
6
|
+
// $DSH_HOME/fang/<profileTag>/ data dir (audit.jsonl, forks.json, buffer/, snapshots/, run/)
|
|
7
|
+
// Forks: every fork owns its own $DSH_HOME, so the same rule isolates it from the mother.
|
|
8
|
+
import os from 'node:os'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
import fs from 'node:fs'
|
|
11
|
+
|
|
12
|
+
/** `--profile name` > `--profile=name` > $DSH_PROFILE > .../profiles/<name> > 'default' */
|
|
13
|
+
export function detectProfileTag(argv = process.argv, env = process.env) {
|
|
14
|
+
const argvList = Array.isArray(argv) ? argv : []
|
|
15
|
+
// The launcher sets DSH_PROFILE for the booted profile; trust it before argv, because a
|
|
16
|
+
// profile boot may re-exec with the template name still on the command line.
|
|
17
|
+
if (env && typeof env.DSH_PROFILE === 'string' && env.DSH_PROFILE.trim()) return env.DSH_PROFILE.trim()
|
|
18
|
+
const i = argvList.indexOf('--profile')
|
|
19
|
+
if (i >= 0 && argvList[i + 1]) return String(argvList[i + 1])
|
|
20
|
+
const eq = argvList.find(a => typeof a === 'string' && a.startsWith('--profile='))
|
|
21
|
+
if (eq) return eq.slice('--profile='.length)
|
|
22
|
+
const joined = argvList.join(' ')
|
|
23
|
+
const m = /[\\/]profiles[\\/]([^\\/\s"']+)/.exec(joined)
|
|
24
|
+
if (m) return m[1]
|
|
25
|
+
const cwd = process.cwd()
|
|
26
|
+
const cm = /[\\/]profiles[\\/]([^\\/\s"']+)/.exec(cwd)
|
|
27
|
+
if (cm) return cm[1]
|
|
28
|
+
return 'default'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function resolveDshHome(env = process.env) {
|
|
32
|
+
const raw = env && env.DSH_HOME
|
|
33
|
+
if (typeof raw === 'string' && raw.trim()) return path.resolve(raw.trim())
|
|
34
|
+
return path.join(os.homedir(), '.dsh')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function resolveProfileDir(profileTag, env = process.env) {
|
|
38
|
+
return path.join(resolveDshHome(env), 'profiles', profileTag)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 用户级数据根:$FANG_HOME 优先;未设置时新装默认 D:\ToFuture(旧布局有数据则继续用旧布局)。 */
|
|
42
|
+
export function resolveFangHome(env = process.env) {
|
|
43
|
+
const raw = env && env.FANG_HOME
|
|
44
|
+
if (typeof raw === 'string' && raw.trim()) return path.resolve(raw.trim())
|
|
45
|
+
// 迁移指针:<DSH_HOME>/fang/fang-home.txt 第一行写明数据根(无需改环境变量/配置即可切换)。
|
|
46
|
+
try {
|
|
47
|
+
const pointer = path.join(resolveDshHome(env), 'fang', 'fang-home.txt')
|
|
48
|
+
if (fs.existsSync(pointer)) {
|
|
49
|
+
const first = fs.readFileSync(pointer, 'utf8').replace(/^\uFEFF/, '').split(/\r?\n/)[0].trim()
|
|
50
|
+
if (first && path.isAbsolute(first)) {
|
|
51
|
+
// 迁移指针可能是旧盘/旧目录:根不存在或指向普通文件时忽略,回退到本机布局,
|
|
52
|
+
// 避免 ensureDir(dataDir) 在插件 apply 阶段抛错导致“必须删 .dsh”才能启动。
|
|
53
|
+
const root = path.parse(first).root
|
|
54
|
+
const rootUsable = Boolean(root) && fs.existsSync(root)
|
|
55
|
+
let targetUsable = true
|
|
56
|
+
try { if (fs.existsSync(first) && !fs.statSync(first).isDirectory()) targetUsable = false } catch { targetUsable = false }
|
|
57
|
+
if (rootUsable && targetUsable) return path.resolve(first)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} catch { /* ignore */ }
|
|
61
|
+
// 兼容:已有 $DSH_HOME/fang/<tag> 数据的安装不搬家,继续旧布局。
|
|
62
|
+
try {
|
|
63
|
+
if (fs.existsSync(path.join(resolveDshHome(env), 'fang'))) return ''
|
|
64
|
+
} catch { /* ignore */ }
|
|
65
|
+
// 新装默认主文件夹(Windows:D:\ToFuture)——只对“真正的默认安装”生效:
|
|
66
|
+
// 显式设置 DSH_HOME(e2e/测试/便携安装)时不得跳盘,否则会写进真实 D: 数据根。
|
|
67
|
+
const explicitDshHome = Boolean(env && String(env.DSH_HOME ?? '').trim())
|
|
68
|
+
if (!explicitDshHome && process.platform === 'win32') {
|
|
69
|
+
try { if (fs.existsSync('D:\\')) return 'D:\\ToFuture' } catch { /* ignore */ }
|
|
70
|
+
}
|
|
71
|
+
return ''
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Every path a Fang plugin needs. Pure: no directory is created here.
|
|
76
|
+
* @returns {{dataDir:string, profileTag:string, dshHome:string, ledgerFile:string, stateFile:string,
|
|
77
|
+
* forksFile:string, pidFile:string, bufferDir:string, snapshotsDir:string, roundsDir:string,
|
|
78
|
+
* runDir:string, forkHomesDir:string}}
|
|
79
|
+
*/
|
|
80
|
+
export function resolveFangLayout(options = {}) {
|
|
81
|
+
const {
|
|
82
|
+
dataRoot = '',
|
|
83
|
+
bufferRoot = '',
|
|
84
|
+
forksRoot = '',
|
|
85
|
+
fangHome = '',
|
|
86
|
+
env = process.env,
|
|
87
|
+
argv = process.argv,
|
|
88
|
+
} = options
|
|
89
|
+
const profileTag = detectProfileTag(argv, env)
|
|
90
|
+
const dshHome = resolveDshHome(env)
|
|
91
|
+
// 显式传入 dataRoot/forksRoot(测试、独立部署)时不要自动跳到 FANG_HOME 默认值,
|
|
92
|
+
// 否则临时目录测试会写进真实的 D:\ToFuture。
|
|
93
|
+
const explicitRoots = Boolean(String(dataRoot ?? '').trim() || String(forksRoot ?? '').trim() || String(bufferRoot ?? '').trim())
|
|
94
|
+
const hasEnvFangHome = Boolean(String(env?.FANG_HOME ?? '').trim())
|
|
95
|
+
const resolvedFangHome = fangHome
|
|
96
|
+
? path.resolve(fangHome)
|
|
97
|
+
: (explicitRoots && !hasEnvFangHome ? '' : resolveFangHome(env))
|
|
98
|
+
const dataDir = dataRoot
|
|
99
|
+
? path.resolve(dataRoot)
|
|
100
|
+
: (resolvedFangHome ? path.join(resolvedFangHome, 'mirror', profileTag) : path.join(dshHome, 'fang', profileTag))
|
|
101
|
+
// 三区分离:母体=mirror/<tag>,原身=essence/,分身=forks/,会话/运行态=sessions/。
|
|
102
|
+
const essenceRoot = resolvedFangHome ? path.join(resolvedFangHome, 'essence') : path.join(dataDir, 'essences')
|
|
103
|
+
const forkHomesDir = forksRoot
|
|
104
|
+
? path.resolve(forksRoot)
|
|
105
|
+
: (resolvedFangHome ? path.join(resolvedFangHome, 'forks') : path.join(dataDir, 'forks'))
|
|
106
|
+
const sessionsRoot = resolvedFangHome ? path.join(resolvedFangHome, 'sessions') : ''
|
|
107
|
+
const runtimeRoot = resolvedFangHome ? path.join(resolvedFangHome, 'sessions') : path.join(dataDir, 'runtime')
|
|
108
|
+
return {
|
|
109
|
+
dataDir,
|
|
110
|
+
profileTag,
|
|
111
|
+
dshHome,
|
|
112
|
+
fangHome: resolvedFangHome,
|
|
113
|
+
ledgerFile: path.join(dataDir, 'audit.jsonl'),
|
|
114
|
+
stateFile: path.join(dataDir, 'state.json'),
|
|
115
|
+
forksFile: path.join(dataDir, 'forks.json'),
|
|
116
|
+
tasksFile: path.join(dataDir, 'tasks.json'),
|
|
117
|
+
pidFile: path.join(dataDir, 'fang.pid'),
|
|
118
|
+
bufferDir: bufferRoot ? path.resolve(bufferRoot) : path.join(dataDir, 'buffer'),
|
|
119
|
+
snapshotsDir: path.join(dataDir, 'snapshots'),
|
|
120
|
+
roundsDir: path.join(dataDir, 'rounds'),
|
|
121
|
+
runDir: path.join(dataDir, 'run'),
|
|
122
|
+
forkHomesDir,
|
|
123
|
+
essenceRoot,
|
|
124
|
+
runtimeRoot,
|
|
125
|
+
sessionsRoot,
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Matches the line a `dsh web` boot prints: `dsh web: http://127.0.0.1:<port>/...token...` */
|
|
130
|
+
export const WEB_URL_RE = /dsh web:\s+(http:\/\/127\.0\.0\.1:(\d+)\/\S*)/
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Parse a fork's stdout tail into its tokenized web URL.
|
|
134
|
+
* The bare URL is 401, so a line without `?token=` is NOT a usable fork URL and
|
|
135
|
+
* parses to null — failing loud beats handing the UI an address that always 401s.
|
|
136
|
+
* @returns {{url:string, port:number, token:string}|null}
|
|
137
|
+
*/
|
|
138
|
+
export function parseWebUrl(text) {
|
|
139
|
+
if (!text) return null
|
|
140
|
+
const m = WEB_URL_RE.exec(String(text))
|
|
141
|
+
if (!m) return null
|
|
142
|
+
const url = m[1]
|
|
143
|
+
const t = /[?&]token=([^&\s]+)/.exec(url)
|
|
144
|
+
if (!t) return null
|
|
145
|
+
let token = t[1]
|
|
146
|
+
try { token = decodeURIComponent(token) } catch { /* keep raw */ }
|
|
147
|
+
return { url, port: Number(m[2]), token }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A fork is healthy only when its loopback URL answers; token is part of the URL. */
|
|
151
|
+
export async function probeWebUrl(url, { timeoutMs = 3000, fetchImpl = globalThis.fetch } = {}) {
|
|
152
|
+
if (typeof fetchImpl !== 'function') return { ok: false, reason: 'no-fetch' }
|
|
153
|
+
const ac = new AbortController()
|
|
154
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs)
|
|
155
|
+
try {
|
|
156
|
+
const res = await fetchImpl(url, { signal: ac.signal })
|
|
157
|
+
return { ok: res.status < 500, status: res.status }
|
|
158
|
+
} catch (e) {
|
|
159
|
+
return { ok: false, reason: String(e && e.message ? e.message : e) }
|
|
160
|
+
} finally {
|
|
161
|
+
clearTimeout(timer)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Read-only layout of the mother's session/workspace storage. Session catalog uses
|
|
168
|
+
* this; it must never write these paths ($DSH_HOME is the mother's own data).
|
|
169
|
+
*/
|
|
170
|
+
export function resolveSessionsLayout(options = {}) {
|
|
171
|
+
const { env = process.env } = options
|
|
172
|
+
const dshHome = resolveDshHome(env)
|
|
173
|
+
const sessionsDir = path.join(dshHome, 'sessions')
|
|
174
|
+
const storagesDir = path.join(dshHome, 'storages')
|
|
175
|
+
return {
|
|
176
|
+
dshHome,
|
|
177
|
+
sessionsDir,
|
|
178
|
+
storagesDir,
|
|
179
|
+
workspaceFile: path.join(storagesDir, 'workspace.json'),
|
|
180
|
+
projcacheDir: path.join(storagesDir, 'session_projcache'),
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
|
|
3
|
+
// dsh-Fang kernel — deployment roles and the plugin/role matrix.
|
|
4
|
+
//
|
|
5
|
+
// The same bundle is installed in the mother and in every fork, so which half of a
|
|
6
|
+
// plugin activates must be decided at runtime: the forker launches a fork with
|
|
7
|
+
// DSH_FANG_ROLE=fork, and every plugin asks this module whether its role is enabled.
|
|
8
|
+
//
|
|
9
|
+
// Matrix is the product contract (docs/CONTRACTS-V2.md §2). Adding a plugin to a role
|
|
10
|
+
// here is the only place that decision lives.
|
|
11
|
+
|
|
12
|
+
export const ROLE = { MOTHER: 'mother', FORK: 'fork', CONSUMER: 'consumer' }
|
|
13
|
+
|
|
14
|
+
/** plugin key → roles in which the plugin activates. */
|
|
15
|
+
export const MATRIX = {
|
|
16
|
+
orchestrator: ['mother'],
|
|
17
|
+
forker: ['mother'],
|
|
18
|
+
restartGuard: ['mother'],
|
|
19
|
+
healer: ['fork'],
|
|
20
|
+
hotReloader: ['fork'],
|
|
21
|
+
adapter: ['fork'],
|
|
22
|
+
scorer: ['mother', 'fork'],
|
|
23
|
+
router: ['mother', 'fork'],
|
|
24
|
+
umbrella: ['mother', 'fork'],
|
|
25
|
+
approver: ['mother', 'fork'],
|
|
26
|
+
bufferKeeper: ['fork'],
|
|
27
|
+
auditor: ['mother', 'fork'],
|
|
28
|
+
sessionCatalog: ['mother'],
|
|
29
|
+
sessionBridge: ['mother'],
|
|
30
|
+
sessionSync: ['mother', 'consumer'],
|
|
31
|
+
sessionRouter: ['mother'],
|
|
32
|
+
decisionCenter: ['mother', 'fork'],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const VALID_ROLES = new Set([ROLE.MOTHER, ROLE.FORK, ROLE.CONSUMER])
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The environment wins over config: a fork is started by the forker with
|
|
39
|
+
* DSH_FANG_ROLE=fork and must never be able to promote itself to a mother by config.
|
|
40
|
+
* @returns {'mother'|'fork'|'consumer'}
|
|
41
|
+
*/
|
|
42
|
+
export function resolveRole({ cfgRole = '', env = process.env } = {}) {
|
|
43
|
+
const fromEnv = env?.DSH_FANG_ROLE
|
|
44
|
+
if (VALID_ROLES.has(fromEnv)) return fromEnv
|
|
45
|
+
if (VALID_ROLES.has(cfgRole)) return cfgRole
|
|
46
|
+
return ROLE.MOTHER
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function roleAllows(pluginKey, role) {
|
|
50
|
+
return (MATRIX[pluginKey] ?? []).includes(role)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Guard used at the top of every plugin's apply().
|
|
55
|
+
* @returns {{role:string, active:boolean, reason:string}}
|
|
56
|
+
*/
|
|
57
|
+
export function roleGate(pluginKey, { cfgRole = '', env = process.env, logger = null } = {}) {
|
|
58
|
+
const role = resolveRole({ cfgRole, env })
|
|
59
|
+
const active = roleAllows(pluginKey, role)
|
|
60
|
+
const reason = active ? `${pluginKey} active in ${role}` : `${pluginKey} is not deployed in ${role}`
|
|
61
|
+
if (!active && logger && typeof logger.info === 'function') logger.info(reason)
|
|
62
|
+
return { role, active, reason }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Roles a plugin activates in — used by status surfaces and tests. */
|
|
66
|
+
export function rolesOf(pluginKey) {
|
|
67
|
+
return [...(MATRIX[pluginKey] ?? [])]
|
|
68
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
|
|
3
|
+
// dsh-Fang kernel — round (hot-update transaction) core.
|
|
4
|
+
// Zero node dependencies: every filesystem call comes through an injected fsApi,
|
|
5
|
+
// so the same logic runs under node:fs in the host and under a memory FS in a browser.
|
|
6
|
+
//
|
|
7
|
+
// fsApi contract (node:fs compatible subset):
|
|
8
|
+
// readdirSync, statSync, readFileSync, writeFileSync, mkdirSync, copyFileSync,
|
|
9
|
+
// unlinkSync, existsSync
|
|
10
|
+
|
|
11
|
+
const SKIP_NAMES = new Set(['changes.json', '.age.json', '.rounds'])
|
|
12
|
+
|
|
13
|
+
function hashHex(bytes) {
|
|
14
|
+
const data = typeof bytes === 'string' ? bytes : bufferLikeToString(bytes)
|
|
15
|
+
let h1 = 0x811c9dc5
|
|
16
|
+
let h2 = 0x01000193
|
|
17
|
+
for (let i = 0; i < data.length; i++) {
|
|
18
|
+
const c = data.charCodeAt(i) & 0xff
|
|
19
|
+
h1 = (h1 ^ c) * 16777619 >>> 0
|
|
20
|
+
h2 = (h2 + c * (i + 1)) >>> 0
|
|
21
|
+
}
|
|
22
|
+
return h1.toString(16).padStart(8, '0') + h2.toString(16).padStart(8, '0')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function bufferLikeToString(b) {
|
|
26
|
+
if (b == null) return ''
|
|
27
|
+
if (typeof b === 'string') return b
|
|
28
|
+
if (typeof b.toString === 'function') {
|
|
29
|
+
const s = b.toString('utf8')
|
|
30
|
+
if (typeof s === 'string' && !/^\[object/.test(s)) return s
|
|
31
|
+
}
|
|
32
|
+
if (b instanceof Uint8Array) return Array.from(b).map(x => String.fromCharCode(x)).join('')
|
|
33
|
+
return String(b)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createRounds(fsApi) {
|
|
37
|
+
const fs = fsApi
|
|
38
|
+
const join = (...parts) => parts.filter(p => p != null && p !== '').join('/').replace(/\/+/g, '/')
|
|
39
|
+
const parentOf = (p) => { const i = p.lastIndexOf('/'); return i > 0 ? p.slice(0, i) : '.' }
|
|
40
|
+
const roundsDirOf = (stagingDir, name) => stagingDir + '/' + name + '/.rounds'
|
|
41
|
+
|
|
42
|
+
const fileDigests = (dir) => {
|
|
43
|
+
const out = {}
|
|
44
|
+
const walk = (d, rel = '') => {
|
|
45
|
+
let names = []
|
|
46
|
+
try { names = fs.readdirSync(d) ?? [] } catch { return }
|
|
47
|
+
for (const n of names) {
|
|
48
|
+
const r = rel ? rel + '/' + n : n
|
|
49
|
+
if (SKIP_NAMES.has(r) || SKIP_NAMES.has(n)) continue
|
|
50
|
+
const p = d + '/' + n
|
|
51
|
+
let st
|
|
52
|
+
try { st = fs.statSync(p) } catch { continue }
|
|
53
|
+
if (st && typeof st.isDirectory === 'function' && st.isDirectory()) { walk(p, r); continue }
|
|
54
|
+
try { out[r] = hashHex(fs.readFileSync(p)) } catch { /* unreadable: skip */ }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
walk(dir)
|
|
58
|
+
return out
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const copyTreeInto = (srcDir, dstDir) => {
|
|
62
|
+
try { fs.mkdirSync(dstDir, { recursive: true }) } catch { /* ignore */ }
|
|
63
|
+
let names = []
|
|
64
|
+
try { names = fs.readdirSync(srcDir) ?? [] } catch { return }
|
|
65
|
+
for (const n of names) {
|
|
66
|
+
if (SKIP_NAMES.has(n)) continue
|
|
67
|
+
const p = srcDir + '/' + n
|
|
68
|
+
let st
|
|
69
|
+
try { st = fs.statSync(p) } catch { continue }
|
|
70
|
+
if (st && typeof st.isDirectory === 'function' && st.isDirectory()) { copyTreeInto(p, dstDir + '/' + n); continue }
|
|
71
|
+
try {
|
|
72
|
+
fs.mkdirSync(parentOf(dstDir + '/' + n), { recursive: true })
|
|
73
|
+
fs.copyFileSync(p, dstDir + '/' + n)
|
|
74
|
+
} catch { /* ignore */ }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const beginRound = ({ stagingDir, name, note = '', now = () => new Date().toISOString() }) => {
|
|
79
|
+
const stageDir = stagingDir + '/' + name
|
|
80
|
+
if (!fs.existsSync(stageDir)) return { ok: false, phase: 'not-in-staging' }
|
|
81
|
+
const roundId = 'r' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5)
|
|
82
|
+
const dir = roundsDirOf(stagingDir, name) + '/' + roundId
|
|
83
|
+
const preDir = dir + '/pre'
|
|
84
|
+
try { copyTreeInto(stageDir, preDir) } catch (e) {
|
|
85
|
+
return { ok: false, phase: 'copy-failed', error: String(e?.message ?? e) }
|
|
86
|
+
}
|
|
87
|
+
const manifest = { roundId, name, note: String(note ?? ''), at: now(), files: fileDigests(preDir) }
|
|
88
|
+
try { fs.writeFileSync(dir + '/manifest.json', JSON.stringify(manifest, null, 2)) } catch { /* ignore */ }
|
|
89
|
+
return { ok: true, phase: 'round-open', roundId, files: Object.keys(manifest.files).length }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const listRounds = ({ stagingDir, name }) => {
|
|
93
|
+
let ids = []
|
|
94
|
+
try { ids = fs.readdirSync(roundsDirOf(stagingDir, name)) ?? [] } catch { return [] }
|
|
95
|
+
const out = []
|
|
96
|
+
for (const id of ids) {
|
|
97
|
+
const dir = roundsDirOf(stagingDir, name) + '/' + id
|
|
98
|
+
let m = null
|
|
99
|
+
try { m = JSON.parse(bufferLikeToString(fs.readFileSync(dir + '/manifest.json'))) } catch { /* ignore */ }
|
|
100
|
+
if (m) out.push({ ...m, dir })
|
|
101
|
+
}
|
|
102
|
+
return out.sort((a, b) => (a.at < b.at ? 1 : -1))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const resetRound = ({ stagingDir, name, roundId } = {}) => {
|
|
106
|
+
const errors = []
|
|
107
|
+
const safeCopy = (src, dst) => {
|
|
108
|
+
try {
|
|
109
|
+
if (!fs.existsSync(src)) { errors.push({ src, error: 'missing-source' }); return false }
|
|
110
|
+
fs.mkdirSync(parentOf(dst), { recursive: true })
|
|
111
|
+
fs.copyFileSync(src, dst)
|
|
112
|
+
return true
|
|
113
|
+
} catch (e) { errors.push({ src, error: String(e?.message ?? e) }); return false }
|
|
114
|
+
}
|
|
115
|
+
const safeUnlink = (p) => {
|
|
116
|
+
try { if (fs.existsSync(p)) fs.unlinkSync(p); return true } catch (e) {
|
|
117
|
+
errors.push({ path: p, error: String(e?.message ?? e) }); return false
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
const rounds = listRounds({ stagingDir, name })
|
|
122
|
+
if (!rounds.length) return { ok: false, phase: 'no-rounds', touched: [], errors }
|
|
123
|
+
const target = roundId ? rounds.find(r => r.roundId === roundId) : rounds[0]
|
|
124
|
+
if (!target) return { ok: false, phase: 'round-not-found', available: rounds.map(r => r.roundId), touched: [], errors }
|
|
125
|
+
|
|
126
|
+
const stageDir = stagingDir + '/' + name
|
|
127
|
+
const preDir = target.dir + '/pre'
|
|
128
|
+
const pre = fileDigests(preDir)
|
|
129
|
+
const cur = fileDigests(stageDir)
|
|
130
|
+
|
|
131
|
+
if (Object.keys(pre).length === 0 && Object.keys(cur).length > 0) {
|
|
132
|
+
return {
|
|
133
|
+
ok: false, phase: 'round-corrupt', roundId: target.roundId,
|
|
134
|
+
manifestFiles: Object.keys(target.files ?? {}).length,
|
|
135
|
+
currentFiles: Object.keys(cur).length, touched: [], errors,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const manifest = target.files && typeof target.files === 'object' ? target.files : null
|
|
140
|
+
const inManifest = (p) => !!manifest && Object.prototype.hasOwnProperty.call(manifest, p)
|
|
141
|
+
const touched = []
|
|
142
|
+
|
|
143
|
+
for (const [p, d] of Object.entries(cur)) {
|
|
144
|
+
if (pre[p] === d) continue
|
|
145
|
+
if (inManifest(p)) {
|
|
146
|
+
if (pre[p] !== undefined) {
|
|
147
|
+
if (safeCopy(preDir + '/' + p, stageDir + '/' + p)) touched.push({ path: p, action: 'restored' })
|
|
148
|
+
} else {
|
|
149
|
+
errors.push({ path: p, error: 'pre-image-missing' })
|
|
150
|
+
touched.push({ path: p, action: 'kept-missing-preimage' })
|
|
151
|
+
}
|
|
152
|
+
} else if (pre[p] !== undefined) {
|
|
153
|
+
if (safeCopy(preDir + '/' + p, stageDir + '/' + p)) touched.push({ path: p, action: 'restored' })
|
|
154
|
+
} else {
|
|
155
|
+
if (safeUnlink(stageDir + '/' + p)) touched.push({ path: p, action: 'removed' })
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
for (const p of Object.keys(pre)) {
|
|
159
|
+
if (cur[p] !== undefined) continue
|
|
160
|
+
if (safeCopy(preDir + '/' + p, stageDir + '/' + p)) touched.push({ path: p, action: 'recovered' })
|
|
161
|
+
}
|
|
162
|
+
const after = fileDigests(stageDir)
|
|
163
|
+
const clean = Object.keys(pre).length === Object.keys(after).length &&
|
|
164
|
+
Object.entries(pre).every(([k, v]) => after[k] === v)
|
|
165
|
+
return { ok: errors.length === 0, phase: 'reset', roundId: target.roundId, touched, clean, errors }
|
|
166
|
+
} catch (e) {
|
|
167
|
+
return { ok: false, phase: 'threw', error: String(e?.message ?? e), touched: [], errors }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return { fileDigests, copyTreeInto, beginRound, listRounds, resetRound, roundsDirOf, join }
|
|
172
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
|
|
3
|
+
// Vendored schemastery (link:-installed bundles cannot resolve @deepseek-ai/schemastery,
|
|
4
|
+
// so the schema library ships with the project). Same pattern as packages/shared/vendor.
|
|
5
|
+
export { default } from './vendor/schemastery.mjs'
|