@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,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
|
+
}
|