@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,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'