@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.
Files changed (144) 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/CONTRACT-INTEROP.md +77 -0
  12. package/docs/CRASH-SAFETY.md +69 -0
  13. package/docs/DECISION-RELAY.md +46 -0
  14. package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
  15. package/docs/FORK-SEED.md +64 -0
  16. package/docs/MAIN-CITY-GATE.md +58 -0
  17. package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
  18. package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
  19. package/docs/PM-DECISION-POLICY.md +61 -0
  20. package/docs/WORKFLOW-UI.md +86 -0
  21. package/icon.svg +8 -0
  22. package/locale/en.json +6 -0
  23. package/locale/zh.json +6 -0
  24. package/package.json +71 -4
  25. package/sdk/README.md +65 -0
  26. package/sdk/examples/status-badge/README.md +16 -0
  27. package/sdk/examples/status-badge/client.js +60 -0
  28. package/sdk/examples/status-badge/cordis.patch.yml +7 -0
  29. package/sdk/examples/status-badge/index.js +12 -0
  30. package/sdk/examples/status-badge/package.json +24 -0
  31. package/sdk/templates/plugin/README.md +36 -0
  32. package/sdk/templates/plugin/client.js +35 -0
  33. package/sdk/templates/plugin/cordis.patch.yml +8 -0
  34. package/sdk/templates/plugin/icon.svg +5 -0
  35. package/sdk/templates/plugin/index.js +23 -0
  36. package/sdk/templates/plugin/locale/en.json +6 -0
  37. package/sdk/templates/plugin/package.json +36 -0
  38. package/src/compose.mjs +67 -0
  39. package/src/contract/VENDOR.json +16 -0
  40. package/src/contract/client.js +93 -0
  41. package/src/contract/constants.js +94 -0
  42. package/src/contract/envelope.js +83 -0
  43. package/src/contract/index.js +20 -0
  44. package/src/contract/negotiate.js +106 -0
  45. package/src/contract/relay.js +223 -0
  46. package/src/contract/server.js +77 -0
  47. package/src/contract/transports.js +319 -0
  48. package/src/index.mjs +11 -0
  49. package/src/kernel/core.mjs +100 -0
  50. package/src/kernel/extensions.mjs +61 -0
  51. package/src/kernel/fsx.mjs +349 -0
  52. package/src/kernel/index.mjs +21 -0
  53. package/src/kernel/ledger.mjs +94 -0
  54. package/src/kernel/paths.mjs +182 -0
  55. package/src/kernel/role.mjs +68 -0
  56. package/src/kernel/rounds.mjs +172 -0
  57. package/src/kernel/schema.mjs +5 -0
  58. package/src/kernel/types.mjs +266 -0
  59. package/src/kernel/vendor/cosmokit.mjs +421 -0
  60. package/src/kernel/vendor/schemastery.mjs +638 -0
  61. package/src/lib/adapter-client.mjs +383 -0
  62. package/src/lib/adapter-protocol.mjs +243 -0
  63. package/src/lib/ai-secret-store.mjs +202 -0
  64. package/src/lib/boot-guard.mjs +186 -0
  65. package/src/lib/branch-store.mjs +84 -0
  66. package/src/lib/branch.mjs +74 -0
  67. package/src/lib/bridge-client.mjs +133 -0
  68. package/src/lib/broker-adapter.mjs +67 -0
  69. package/src/lib/broker.mjs +45 -0
  70. package/src/lib/business-producers.mjs +109 -0
  71. package/src/lib/command-cache.mjs +209 -0
  72. package/src/lib/compat.mjs +170 -0
  73. package/src/lib/composer-journal.mjs +128 -0
  74. package/src/lib/container/index.mjs +806 -0
  75. package/src/lib/decision-events.mjs +129 -0
  76. package/src/lib/decision-ingest-client.mjs +41 -0
  77. package/src/lib/decision-outbox.mjs +129 -0
  78. package/src/lib/decision-policy.mjs +52 -0
  79. package/src/lib/default-essence.mjs +61 -0
  80. package/src/lib/desktop-launch.mjs +138 -0
  81. package/src/lib/essence.mjs +1211 -0
  82. package/src/lib/fork-client.mjs +410 -0
  83. package/src/lib/fork-config-lock.mjs +166 -0
  84. package/src/lib/fork-seed.mjs +304 -0
  85. package/src/lib/fork-sessions.mjs +183 -0
  86. package/src/lib/fork-supervision-settings.mjs +58 -0
  87. package/src/lib/fork-supervisor.mjs +122 -0
  88. package/src/lib/fs-guard.mjs +28 -0
  89. package/src/lib/http-auth.mjs +162 -0
  90. package/src/lib/idle-audit.mjs +178 -0
  91. package/src/lib/interaction-poke.mjs +61 -0
  92. package/src/lib/main-gate.mjs +579 -0
  93. package/src/lib/mirror-etag.mjs +92 -0
  94. package/src/lib/mirror-official-source.mjs +93 -0
  95. package/src/lib/mirror.mjs +478 -0
  96. package/src/lib/mother-link.mjs +156 -0
  97. package/src/lib/mutex.mjs +31 -0
  98. package/src/lib/permissions.mjs +115 -0
  99. package/src/lib/pnpm-store.mjs +189 -0
  100. package/src/lib/preferences.mjs +212 -0
  101. package/src/lib/producer-contract.mjs +162 -0
  102. package/src/lib/reflow.mjs +41 -0
  103. package/src/lib/remote.mjs +408 -0
  104. package/src/lib/restart-contract.mjs +752 -0
  105. package/src/lib/roots.mjs +30 -0
  106. package/src/lib/rule-registry.mjs +1127 -0
  107. package/src/lib/runtime-capture.mjs +307 -0
  108. package/src/lib/semaphore.mjs +34 -0
  109. package/src/lib/session-bundle.mjs +138 -0
  110. package/src/lib/session-format.mjs +672 -0
  111. package/src/lib/sse-parser.mjs +102 -0
  112. package/src/lib/ssrf-guard.mjs +170 -0
  113. package/src/lib/status-store.mjs +257 -0
  114. package/src/lib/task-handle.mjs +622 -0
  115. package/src/lib/ui-actions.mjs +55 -0
  116. package/src/lib/umbrella.mjs +1661 -0
  117. package/src/lib/upgrade-coordinator.mjs +107 -0
  118. package/src/lib/upgrade-status.mjs +43 -0
  119. package/src/plugins/adapter.mjs +1468 -0
  120. package/src/plugins/approver.mjs +1210 -0
  121. package/src/plugins/auditor.mjs +330 -0
  122. package/src/plugins/buffer-keeper.mjs +522 -0
  123. package/src/plugins/decision-center.mjs +2581 -0
  124. package/src/plugins/forker.mjs +1610 -0
  125. package/src/plugins/healer.mjs +702 -0
  126. package/src/plugins/hot-reloader.mjs +274 -0
  127. package/src/plugins/imports.mjs +247 -0
  128. package/src/plugins/orchestrator.mjs +3938 -0
  129. package/src/plugins/restart-guard.mjs +1022 -0
  130. package/src/plugins/router.mjs +714 -0
  131. package/src/plugins/scorer.mjs +661 -0
  132. package/src/plugins/session-bridge.mjs +166 -0
  133. package/src/plugins/session-catalog.mjs +771 -0
  134. package/src/plugins/session-router.mjs +125 -0
  135. package/src/plugins/session-sync.mjs +171 -0
  136. package/src/plugins/umbrella.mjs +419 -0
  137. package/tools/boot-guard.mjs +95 -0
  138. package/tools/decision-ingest.mjs +53 -0
  139. package/tools/desktop-launch.mjs +71 -0
  140. package/tools/fangctl.mjs +408 -0
  141. package/tools/gate/main-city.mjs +204 -0
  142. package/tools/mcp/server.mjs +121 -0
  143. package/tools/mcp/tools.mjs +65 -0
  144. package/tools/recover-queue.mjs +133 -0
@@ -0,0 +1,307 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ // dsh-Fang lib — capture the essence runtime's plugin changes back into the essence.
4
+ //
5
+ // The essence runtime (`profiles/essence-runtime`) is a config instance: the user opens
6
+ // it, installs/updates/removes plugins through the DSH plugin manager, and expects the
7
+ // 元宝 to keep those changes. The runtime profile is cloned from the essence `merged`
8
+ // profile on every open, so without capture the next open would replay the old config and
9
+ // silently drop the work (v3.1.7 known boundary; implemented here in v3.1.8).
10
+ //
11
+ // What is captured:
12
+ // * `package.json`: dependencies + `dsh.profile.bundles` (+ name/private/type),
13
+ // * `cordis.patch.yml`: entries that differ from the merged profile (enable/disable),
14
+ // * the pnpm dependency-graph files (`pnpm-lock.yaml`, `pnpm-workspace.yaml`, `.npmrc`),
15
+ // * the touched module directories: packages pnpm re-materialized in the runtime
16
+ // `node_modules` are hardlink-copied into the essence module tree so derived forks see
17
+ // the same plugin set.
18
+ //
19
+ // Capture is cheap when nothing changed: a signature over package.json + cordis.patch.yml
20
+ // + the node_modules manifest mtime is stored in the runtime profile and compared first.
21
+ import fs from 'node:fs'
22
+ import path from 'node:path'
23
+
24
+ import { ensureDir, exists, hardlinkTree, readJson, removeTree, writeJsonAtomic } from '../kernel/index.mjs'
25
+ import { parsePatchEntries } from './essence.mjs'
26
+
27
+ /** Files the pnpm dependency graph needs and the runtime may have rewritten. */
28
+ export const CAPTURED_AUX_FILES = Object.freeze(['pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc'])
29
+ /** Marker written inside the runtime profile after a successful capture. */
30
+ export const CAPTURE_MARKER_FILE = '.fang-runtime-capture.json'
31
+ /** Top-level/scope entries that are pnpm bookkeeping, never package directories. */
32
+ const INTERNAL_MODULE_NAMES = new Set(['.pnpm', '.bin', '.modules.yaml', '.ignored', '.pnpm-workspace-state-v1.json'])
33
+
34
+ function readText(file) {
35
+ try { return fs.readFileSync(file, 'utf8') } catch { return '' }
36
+ }
37
+
38
+ function readPackageJson(profileDir) {
39
+ const value = readJson(path.join(String(profileDir ?? ''), 'package.json'), null)
40
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null
41
+ }
42
+
43
+ /** Stable JSON (sorted object keys) for structural comparisons. */
44
+ export function stableStringify(value) {
45
+ if (Array.isArray(value)) return `[${value.map((entry) => stableStringify(entry)).join(',')}]`
46
+ if (value && typeof value === 'object') {
47
+ const keys = Object.keys(value).sort()
48
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`
49
+ }
50
+ return JSON.stringify(value)
51
+ }
52
+
53
+ /** The profile package fields the essence overlay carries (mirrors EssenceManager.selectProfilePackage). */
54
+ export function pickProfilePackage(profilePkg) {
55
+ const picked = {}
56
+ for (const key of ['name', 'private', 'type']) {
57
+ if (profilePkg?.[key] !== undefined) picked[key] = profilePkg[key]
58
+ }
59
+ const dsh = profilePkg?.dsh
60
+ if (dsh && typeof dsh === 'object' && Object.keys(dsh).length > 0) picked.dsh = dsh
61
+ const deps = profilePkg?.dependencies
62
+ if (deps && typeof deps === 'object' && Object.keys(deps).length > 0) picked.dependencies = deps
63
+ return picked
64
+ }
65
+
66
+ /**
67
+ * Patch entries the runtime changed or added relative to the merged profile, plus merged
68
+ * ids the runtime no longer carries (the plugin manager removed the row; a disabled marker
69
+ * keeps the mirror-provided row from coming back).
70
+ * @param {Array<Record<string,unknown>>} mergedEntries
71
+ * @param {Array<Record<string,unknown>>} runtimeEntries
72
+ * @returns {{changed:Array<Record<string,unknown>>, removedIds:string[]}}
73
+ */
74
+ export function diffPatchEntries(mergedEntries = [], runtimeEntries = []) {
75
+ const mergedById = new Map((mergedEntries ?? []).map((entry) => [String(entry?.id ?? ''), entry]))
76
+ const runtimeById = new Map((runtimeEntries ?? []).map((entry) => [String(entry?.id ?? ''), entry]))
77
+ const changed = []
78
+ for (const [id, entry] of runtimeById) {
79
+ const previous = mergedById.get(id)
80
+ if (previous === undefined || stableStringify(previous) !== stableStringify(entry)) changed.push(entry)
81
+ }
82
+ const removedIds = [...mergedById.keys()].filter((id) => !runtimeById.has(id))
83
+ return { changed, removedIds }
84
+ }
85
+
86
+ /** Merge captured patch changes into the overlay entry list (by id; keep order stable). */
87
+ export function mergeOverlayPatchEntries(overlayEntries = [], { changed = [], removedIds = [] } = {}, mergedEntries = []) {
88
+ const byId = new Map((overlayEntries ?? []).map((entry) => [String(entry?.id ?? ''), entry]))
89
+ const mergedById = new Map((mergedEntries ?? []).map((entry) => [String(entry?.id ?? ''), entry]))
90
+ for (const entry of changed) byId.set(String(entry?.id ?? ''), entry)
91
+ for (const id of removedIds) {
92
+ const previous = byId.get(id) ?? mergedById.get(id)
93
+ byId.set(id, { ...(previous && typeof previous === 'object' ? previous : { id }), disabled: true })
94
+ }
95
+ return [...byId.values()]
96
+ }
97
+
98
+ function isRealDirectory(entryPath) {
99
+ try {
100
+ const stat = fs.lstatSync(entryPath)
101
+ return stat.isDirectory() && !stat.isSymbolicLink()
102
+ } catch {
103
+ return false
104
+ }
105
+ }
106
+
107
+ function packageVersion(entryDir) {
108
+ return String(readJson(path.join(entryDir, 'package.json'), null)?.version ?? '')
109
+ }
110
+
111
+ /** Module entries under `node_modules`, scope entries expanded; link entries are reported as untouched. */
112
+ function listModuleEntries(modulesDir) {
113
+ const entries = []
114
+ let top = []
115
+ try { top = fs.readdirSync(modulesDir, { withFileTypes: true }) } catch { return entries }
116
+ for (const entry of top) {
117
+ const name = entry.name
118
+ if (INTERNAL_MODULE_NAMES.has(name) || name.startsWith('.')) continue
119
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
120
+ const absolute = path.join(modulesDir, name)
121
+ if (name.startsWith('@')) {
122
+ // A mirrored scope is a single junction; its children were not touched by pnpm.
123
+ const scopeIsLink = !isRealDirectory(absolute)
124
+ let children = []
125
+ try { children = fs.readdirSync(absolute, { withFileTypes: true }) } catch { children = [] }
126
+ for (const child of children) {
127
+ if (!child.isDirectory() && !child.isSymbolicLink()) continue
128
+ entries.push({ rel: `${name}/${child.name}`, absolute: path.join(absolute, child.name), link: scopeIsLink || child.isSymbolicLink() })
129
+ }
130
+ continue
131
+ }
132
+ entries.push({ rel: name, absolute, link: !isRealDirectory(absolute) })
133
+ }
134
+ return entries
135
+ }
136
+
137
+ /**
138
+ * Decide which runtime module entries must travel into the essence tree.
139
+ * Junction entries are untouched mirror entries (pnpm never rewrote them) and are skipped.
140
+ * Real directories are the packages pnpm re-materialized in the runtime; only those whose
141
+ * version differs from the essence copy (or that the essence does not have) are copied.
142
+ * Entries the runtime no longer carries are removed from the essence tree.
143
+ * @param {string} runtimeModules
144
+ * @param {string} essenceModules
145
+ * @returns {{copy:Array<{rel:string, from:string}>, remove:string[], untouched:string[]}}
146
+ */
147
+ export function planRuntimeModuleChanges(runtimeModules, essenceModules) {
148
+ const copy = []
149
+ const remove = []
150
+ const untouched = []
151
+ const runtimeEntries = listModuleEntries(runtimeModules)
152
+ const runtimeByRel = new Map(runtimeEntries.map((entry) => [entry.rel, entry]))
153
+ for (const entry of runtimeEntries) {
154
+ if (entry.link === true || !isRealDirectory(entry.absolute)) { untouched.push(entry.rel); continue }
155
+ const destination = path.join(essenceModules, entry.rel)
156
+ if (!exists(destination)) { copy.push({ rel: entry.rel, from: entry.absolute }); continue }
157
+ const runtimeVersion = packageVersion(entry.absolute)
158
+ const essenceVersion = packageVersion(destination)
159
+ if (runtimeVersion !== essenceVersion) copy.push({ rel: entry.rel, from: entry.absolute })
160
+ else untouched.push(entry.rel)
161
+ }
162
+ const essenceEntries = listModuleEntries(essenceModules)
163
+ for (const entry of essenceEntries) {
164
+ if (!runtimeByRel.has(entry.rel)) remove.push(entry.rel)
165
+ }
166
+ return { copy, remove, untouched }
167
+ }
168
+
169
+ /** Hardlink-copy one planned entry into place, replacing the essence copy atomically enough. */
170
+ function materializeModuleEntry(essenceModules, { rel, from }) {
171
+ const destination = path.join(essenceModules, rel)
172
+ const staging = path.join(essenceModules, `.fang-sync-${rel.replace(/[\\/]/g, '_')}`)
173
+ removeTree(staging)
174
+ ensureDir(path.dirname(destination))
175
+ hardlinkTree(from, staging)
176
+ removeTree(destination)
177
+ fs.renameSync(staging, destination)
178
+ }
179
+
180
+ /**
181
+ * Execute a module plan; returns counts plus the entries it could not copy.
182
+ * @param {string} runtimeModules
183
+ * @param {string} essenceModules
184
+ * @param {ReturnType<typeof planRuntimeModuleChanges>} [plan]
185
+ */
186
+ export function reconcileRuntimeModules(runtimeModules, essenceModules, plan = planRuntimeModuleChanges(runtimeModules, essenceModules)) {
187
+ const copied = []
188
+ const removed = []
189
+ const failed = []
190
+ for (const entry of plan.copy) {
191
+ try { materializeModuleEntry(essenceModules, entry); copied.push(entry.rel) } catch (error) {
192
+ failed.push({ rel: entry.rel, error: String(error?.message ?? error) })
193
+ }
194
+ }
195
+ for (const rel of plan.remove) {
196
+ try { removeTree(path.join(essenceModules, rel)); removed.push(rel) } catch { /* best effort */ }
197
+ }
198
+ // Drop scopes emptied by the removals.
199
+ let scopes = []
200
+ try { scopes = fs.readdirSync(essenceModules, { withFileTypes: true }).filter((entry) => entry.name.startsWith('@')).map((entry) => entry.name) } catch { scopes = [] }
201
+ for (const scope of scopes) {
202
+ const scopePath = path.join(essenceModules, scope)
203
+ try { if (fs.readdirSync(scopePath).length === 0) removeTree(scopePath) } catch { /* ignore */ }
204
+ }
205
+ return { copied, removed, failed, untouched: plan.untouched }
206
+ }
207
+
208
+ /** Signature of the runtime profile's plugin configuration; cheap no-op detection. */
209
+ export function runtimeCaptureSignature(runtimeProfileDir) {
210
+ const packageText = readText(path.join(String(runtimeProfileDir ?? ''), 'package.json'))
211
+ const patchText = readText(path.join(String(runtimeProfileDir ?? ''), 'cordis.patch.yml'))
212
+ let modulesMtime = 0
213
+ try { modulesMtime = fs.statSync(path.join(String(runtimeProfileDir ?? ''), 'node_modules', '.modules.yaml')).mtimeMs } catch { modulesMtime = 0 }
214
+ return `${packageText.length}:${patchText.length}:${modulesMtime}`
215
+ }
216
+
217
+ function readCaptureMarker(runtimeProfileDir) {
218
+ return readJson(path.join(String(runtimeProfileDir ?? ''), CAPTURE_MARKER_FILE), null)
219
+ }
220
+
221
+ function writeCaptureMarker(runtimeProfileDir, marker) {
222
+ try { writeJsonAtomic(path.join(String(runtimeProfileDir ?? ''), CAPTURE_MARKER_FILE), marker) } catch { /* marker is an optimization only */ }
223
+ }
224
+
225
+ /**
226
+ * Capture the runtime profile's plugin work into the essence and recompute its merged
227
+ * profile. Safe to call when nothing changed (signature check) or when the runtime
228
+ * profile does not exist yet (no-op).
229
+ * @param {{essenceManager:object, essenceId:string, runtimeProfileDir:string, logger?:object}} options
230
+ * @returns {{ok:boolean, captured:boolean, packageChanged?:boolean, patchChanged?:number, modules?:object, error?:string, detail?:string}}
231
+ */
232
+ export function captureRuntimeProfileIntoEssence({ essenceManager, essenceId, runtimeProfileDir, logger = null } = {}) {
233
+ const id = String(essenceId ?? '').trim()
234
+ const profileDir = String(runtimeProfileDir ?? '').trim()
235
+ if (!essenceManager || typeof essenceManager.loadOverlay !== 'function' || typeof essenceManager.recompute !== 'function') {
236
+ return { ok: false, captured: false, error: 'essence-manager-unavailable' }
237
+ }
238
+ if (!id || !profileDir || !exists(profileDir)) return { ok: true, captured: false, skipped: 'runtime-profile-missing' }
239
+ const runtimePkg = readPackageJson(profileDir)
240
+ if (!runtimePkg) return { ok: true, captured: false, skipped: 'runtime-package-missing' }
241
+ const signature = runtimeCaptureSignature(profileDir)
242
+ const previousMarker = readCaptureMarker(profileDir)
243
+ if (previousMarker?.signature === signature) return { ok: true, captured: false, skipped: 'unchanged' }
244
+
245
+ const root = essenceManager.essenceRoot(id)
246
+ const mergedProfile = essenceManager.mergedProfileDir(id)
247
+ const mergedPkg = readPackageJson(mergedProfile) ?? {}
248
+ const runtimePatchText = readText(path.join(profileDir, 'cordis.patch.yml'))
249
+ const mergedPatchText = readText(path.join(mergedProfile, 'cordis.patch.yml'))
250
+ const runtimeEntries = parsePatchEntries(runtimePatchText).entries
251
+ const mergedEntries = parsePatchEntries(mergedPatchText).entries
252
+ const packageChanged = stableStringify(pickProfilePackage(runtimePkg)) !== stableStringify(pickProfilePackage(mergedPkg))
253
+ const { changed, removedIds } = diffPatchEntries(mergedEntries, runtimeEntries)
254
+ const runtimeModules = path.join(profileDir, 'node_modules')
255
+ const essenceModules = path.join(mergedProfile, 'node_modules')
256
+ const modulePlan = planRuntimeModuleChanges(runtimeModules, essenceModules)
257
+ const moduleChanges = modulePlan.copy.length + modulePlan.remove.length
258
+ if (!packageChanged && changed.length === 0 && removedIds.length === 0 && moduleChanges === 0) {
259
+ writeCaptureMarker(profileDir, { at: Date.now(), signature, captured: false })
260
+ return { ok: true, captured: false, skipped: 'no-plugin-changes' }
261
+ }
262
+
263
+ // 1) overlay: package.json (deps + bundles + profile metadata) and changed patch entries
264
+ const overlay = essenceManager.loadOverlay(root)
265
+ if (packageChanged) overlay.packageJson = { ...overlay.packageJson, ...pickProfilePackage(runtimePkg) }
266
+ if (changed.length > 0 || removedIds.length > 0) {
267
+ overlay.patchEntries = mergeOverlayPatchEntries(overlay.patchEntries ?? [], { changed, removedIds }, mergedEntries)
268
+ }
269
+ essenceManager.saveOverlay(root, overlay)
270
+ // 2) pnpm dependency graph files ride with the overlay so every rematerialization keeps them
271
+ const overlayRoot = path.join(root, 'overlay')
272
+ for (const name of CAPTURED_AUX_FILES) {
273
+ const source = path.join(profileDir, name)
274
+ if (!exists(source)) continue
275
+ try { fs.copyFileSync(source, path.join(overlayRoot, name)) } catch { /* single file best effort */ }
276
+ }
277
+ // 3) recompute merged (materializeMerged preserves the existing module tree) …
278
+ try {
279
+ essenceManager.recompute(id)
280
+ } catch (error) {
281
+ return { ok: false, captured: false, error: 'recompute-failed', detail: String(error?.message ?? error) }
282
+ }
283
+ // 4) … then hardlink the touched packages into the essence module tree.
284
+ let modules = { copied: [], removed: [], failed: [], untouched: [] }
285
+ try {
286
+ const essenceModulesAfter = path.join(essenceManager.mergedProfileDir(id), 'node_modules')
287
+ modules = reconcileRuntimeModules(runtimeModules, essenceModulesAfter, modulePlan)
288
+ } catch (error) {
289
+ logger?.warn?.(`[fang] runtime capture module sync failed: ${String(error?.message ?? error)}`)
290
+ modules = { copied: [], removed: [], failed: [{ rel: '*', error: String(error?.message ?? error) }], untouched: [] }
291
+ }
292
+ writeCaptureMarker(profileDir, {
293
+ at: Date.now(),
294
+ signature,
295
+ captured: true,
296
+ packageChanged,
297
+ patchChanged: changed.length + removedIds.length,
298
+ modules: { copied: modules.copied.length, removed: modules.removed.length, failed: modules.failed.length },
299
+ })
300
+ return {
301
+ ok: true,
302
+ captured: true,
303
+ packageChanged,
304
+ patchChanged: changed.length + removedIds.length,
305
+ modules,
306
+ }
307
+ }
@@ -0,0 +1,34 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // dsh-Fang v3.0.9 — 简单计数信号量(零依赖),用于 recomputeAll 的并发上限。
3
+ // acquire() 在无可用槽位时排队;release() 优先唤醒下一个等待者。
4
+
5
+ export class Semaphore {
6
+ /** @param {number} limit */
7
+ constructor(limit) {
8
+ const value = Number(limit)
9
+ if (!Number.isFinite(value) || value < 1) throw new RangeError('semaphore limit must be >= 1')
10
+ this.available = Math.floor(value)
11
+ /** @type {Array<() => void>} */
12
+ this.waiters = []
13
+ }
14
+
15
+ /** @returns {Promise<void>} */
16
+ acquire() {
17
+ if (this.available > 0) {
18
+ this.available -= 1
19
+ return Promise.resolve()
20
+ }
21
+ return new Promise(resolve => this.waiters.push(resolve))
22
+ }
23
+
24
+ release() {
25
+ const next = this.waiters.shift()
26
+ if (next !== undefined) next()
27
+ else this.available += 1
28
+ }
29
+
30
+ /** 诊断:可用槽位 + 排队数。 */
31
+ stats() {
32
+ return { available: this.available, waiting: this.waiters.length }
33
+ }
34
+ }
@@ -0,0 +1,138 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ // session-bundle — selected-session export/import for the sync mode.
4
+ //
5
+ // The unit is a session *directory* (`<workspaceKey>/<sessionId>/session.v4.jsonl.zstd`).
6
+ // Export is read-only on the source PC; import never overwrites: an existing target
7
+ // keeps both copies (`.fork-<ts>`), matching dsh-session-sync's keep-both semantics.
8
+ // This module is pure fs + zlib-free: it copies bytes and records sha256 manifests.
9
+
10
+ import fs from 'node:fs'
11
+ import path from 'node:path'
12
+ import { createHash } from 'node:crypto'
13
+ import { encodeWorkspaceKey } from './session-format.mjs'
14
+
15
+ export const SYNC_MANIFEST = 'manifest.json'
16
+ export const SESSION_LOG = 'session.v4.jsonl.zstd'
17
+
18
+ const sha256 = (buf) => createHash('sha256').update(buf).digest('hex')
19
+
20
+ function walkSessionDirs(sessionsRoot) {
21
+ const out = []
22
+ let roots = []
23
+ try { roots = fs.readdirSync(sessionsRoot, { withFileTypes: true }) } catch { return out }
24
+ for (const root of roots) {
25
+ if (!root.isDirectory()) continue
26
+ const dir = path.join(sessionsRoot, root.name)
27
+ let sessions = []
28
+ try { sessions = fs.readdirSync(dir, { withFileTypes: true }) } catch { continue }
29
+ for (const entry of sessions) {
30
+ if (!entry.isDirectory() || !entry.name.startsWith('session-')) continue
31
+ const file = path.join(dir, entry.name, SESSION_LOG)
32
+ if (!fs.existsSync(file)) continue
33
+ out.push({ workspaceKey: root.name, sessionId: entry.name, dir: path.join(dir, entry.name), file })
34
+ }
35
+ }
36
+ return out
37
+ }
38
+
39
+ /**
40
+ * Export selected sessions into a staging bundle (read-only on the source).
41
+ * @returns {{ok:true, bundleDir:string, manifest:object, exported:string[], skipped:string[], bytes:number}}
42
+ */
43
+ export function exportSessions(options = {}) {
44
+ const sessionsRoot = path.resolve(String(options.sessionsRoot ?? ''))
45
+ const bundleDir = path.resolve(String(options.bundleDir ?? ''))
46
+ if (!sessionsRoot || !bundleDir) return { ok: false, code: 'invalid-options' }
47
+ const wanted = Array.isArray(options.sessionIds) ? options.sessionIds.map((x) => String(x)).filter(Boolean) : []
48
+ const found = walkSessionDirs(sessionsRoot)
49
+ const selected = wanted.length > 0 ? found.filter((row) => wanted.includes(row.sessionId)) : found
50
+ const skipped = wanted.filter((id) => !found.some((row) => row.sessionId === id))
51
+ fs.mkdirSync(bundleDir, { recursive: true })
52
+ const workspaceByKey = new Map()
53
+ for (const ws of Array.isArray(options.workspaces) ? options.workspaces : []) {
54
+ if (ws && typeof ws.path === 'string') workspaceByKey.set(encodeWorkspaceKey(ws.path), ws)
55
+ }
56
+ const entries = []
57
+ let bytes = 0
58
+ for (const row of selected) {
59
+ const destDir = path.join(bundleDir, 'sessions', row.workspaceKey, row.sessionId)
60
+ fs.mkdirSync(destDir, { recursive: true })
61
+ const buf = fs.readFileSync(row.file)
62
+ fs.writeFileSync(path.join(destDir, SESSION_LOG), buf)
63
+ bytes += buf.length
64
+ const ws = workspaceByKey.get(row.workspaceKey) ?? null
65
+ entries.push({
66
+ sessionId: row.sessionId,
67
+ workspaceKey: row.workspaceKey,
68
+ workspacePath: ws?.path ?? null,
69
+ workspaceTitle: ws?.title ?? null,
70
+ bytes: buf.length,
71
+ sha256: sha256(buf),
72
+ file: `${row.workspaceKey}/${row.sessionId}/${SESSION_LOG}`,
73
+ mtimeMs: fs.statSync(row.file).mtimeMs,
74
+ })
75
+ }
76
+ const manifest = { version: 1, createdAt: new Date().toISOString(), source: sessionsRoot, sessions: entries, bytes }
77
+ fs.writeFileSync(path.join(bundleDir, SYNC_MANIFEST), JSON.stringify(manifest, null, 2) + '\n')
78
+ return { ok: true, bundleDir, manifest, exported: entries.map((e) => e.sessionId), skipped, bytes }
79
+ }
80
+
81
+ /**
82
+ * Import a staging bundle into a target sessions root.
83
+ * Matching is exact by workspace path first, then exact workspace title; a target
84
+ * without that workspace goes to pendingMatch instead of guessing.
85
+ * @returns {{ok:true, pulled:string[], conflicts:string[], pendingMatch:string[], skipped:string[]}}
86
+ */
87
+ export function importSessions(options = {}) {
88
+ const bundleDir = path.resolve(String(options.bundleDir ?? ''))
89
+ const targetRoot = path.resolve(String(options.targetSessionsRoot ?? ''))
90
+ const workspaces = Array.isArray(options.workspaces) ? options.workspaces : []
91
+ const sessionsDir = path.join(bundleDir, 'sessions')
92
+ const SAFE_SEGMENT = /^[A-Za-z0-9._-]{1,128}$/
93
+ const MAX_FILES = Math.max(1, Math.floor(Number(options.maxFiles) || 2000))
94
+ const MAX_TOTAL_BYTES = Math.max(1, Math.floor(Number(options.maxTotalBytes) || 512 * 1024 * 1024))
95
+ const MAX_FILE_BYTES = Math.max(1, Math.floor(Number(options.maxFileBytes) || 64 * 1024 * 1024))
96
+ let manifest
97
+ try { manifest = JSON.parse(fs.readFileSync(path.join(bundleDir, SYNC_MANIFEST), 'utf8')) } catch {
98
+ return { ok: false, code: 'manifest-unreadable' }
99
+ }
100
+ const conflicts = []
101
+ const pulled = []
102
+ const pendingMatch = []
103
+ const skipped = []
104
+ let fileCount = 0
105
+ let totalBytes = 0
106
+ for (const entry of Array.isArray(manifest.sessions) ? manifest.sessions : []) {
107
+ if (fileCount >= MAX_FILES || totalBytes >= MAX_TOTAL_BYTES) { skipped.push(String(entry?.sessionId ?? '')); continue }
108
+ const sessionId = String(entry?.sessionId ?? '')
109
+ if (!SAFE_SEGMENT.test(sessionId)) { skipped.push(sessionId || '<invalid>'); continue }
110
+ const ws = workspaces.find((w) => w.path && entry.workspacePath && w.path === entry.workspacePath)
111
+ ?? workspaces.find((w) => w.title && entry.workspaceTitle && w.title === entry.workspaceTitle)
112
+ if (!ws || !ws.path) { pendingMatch.push(sessionId); continue }
113
+ const workspaceKey = encodeWorkspaceKey(ws.path)
114
+ const destDir = path.resolve(targetRoot, workspaceKey, sessionId)
115
+ // v3.0.5/B3:目标必须落在 targetRoot 内(防 `../` 写出根外)
116
+ if (!destDir.startsWith(targetRoot + path.sep)) { skipped.push(sessionId); continue }
117
+ const destFile = path.join(destDir, SESSION_LOG)
118
+ const src = path.resolve(bundleDir, 'sessions', String(entry.file ?? ''))
119
+ // v3.0.5/B3:源必须落在 bundleDir/sessions 内(防 `../../` 读取 bundle 外文件)
120
+ if (!src.startsWith(sessionsDir + path.sep)) { skipped.push(sessionId); continue }
121
+ if (!fs.existsSync(src)) { skipped.push(sessionId); continue }
122
+ const stat = fs.statSync(src)
123
+ if (!stat.isFile() || stat.size > MAX_FILE_BYTES) { skipped.push(sessionId); continue }
124
+ const buf = fs.readFileSync(src)
125
+ totalBytes += buf.length
126
+ fileCount += 1
127
+ if (sha256(buf) !== entry.sha256) { skipped.push(sessionId); continue }
128
+ fs.mkdirSync(destDir, { recursive: true })
129
+ if (fs.existsSync(destFile)) {
130
+ fs.writeFileSync(path.join(destDir, `${SESSION_LOG}.fork-${Date.now()}-${conflicts.length}.jsonl.zstd`), buf)
131
+ conflicts.push(sessionId)
132
+ continue
133
+ }
134
+ fs.writeFileSync(destFile, buf)
135
+ pulled.push(sessionId)
136
+ }
137
+ return { ok: true, pulled, conflicts, pendingMatch, skipped }
138
+ }