@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,1610 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
|
|
3
|
+
// dsh-Fang 2.0 — fang-forker: the process layer for disposable fork dsh-Web instances.
|
|
4
|
+
//
|
|
5
|
+
// Hard boundary (CONTRACTS-V2.md §5.5 / §1):
|
|
6
|
+
// * mother-only: apply() starts with roleGate('forker'); in a fork nothing is registered.
|
|
7
|
+
// * The mother is immutable. `provision` only READS profileTemplate. With cleanTemplate
|
|
8
|
+
// (v2 default) the fork profile is cloned from a *forker-owned clean template* (written
|
|
9
|
+
// under the fork home), so a dirty mother profile can never leak into a fork; from the
|
|
10
|
+
// mother it takes one thing only — the node_modules source for the junction.
|
|
11
|
+
// Removal always goes through fsx.removeTree() (junction-safe: never enters the mother).
|
|
12
|
+
// * A fork shares nothing: its own DSH_HOME (env.DSH_HOME=homeDir), its own buffer dir,
|
|
13
|
+
// and in v2 a *deterministic* port from the pool (18000–18100) instead of --port 0.
|
|
14
|
+
// `allocatePorts=false` keeps the v1 `--port 0` behaviour.
|
|
15
|
+
// * This plugin owns process lifecycle only. Risk scoring / approval / routing / adapter
|
|
16
|
+
// handshakes do not live here (scorer / approver / router / adapter own those).
|
|
17
|
+
//
|
|
18
|
+
// Port pool (v2):
|
|
19
|
+
// * ports are probed with net.createServer().listen({port,host:'127.0.0.1'}) — a port is
|
|
20
|
+
// usable only if the bind test succeeds; a busy port is skipped (next candidate).
|
|
21
|
+
// * the occupancy table is persisted to <dataRoot>/ports.json as {port:{forkId,pid,at}}.
|
|
22
|
+
// On first port use, entries whose recorded child pid is dead (or that never got a pid)
|
|
23
|
+
// are reclaimed, so a crashed mother can never leak the deterministic pool forever.
|
|
24
|
+
// * spawn validates the child's printed URL port against the port it was told to bind:
|
|
25
|
+
// a mismatch is a hard failure (child stopped, {ok:false,error:'port-mismatch'}).
|
|
26
|
+
// * stop/reap/dispose release the port; pool exhaustion is {ok:false,error:'port-exhausted'}.
|
|
27
|
+
//
|
|
28
|
+
// Windows launch note (measured): the default launcher is a `.cmd`
|
|
29
|
+
// (<DSH_INSTALL>\resources\runtime\cli\bin\dsh.cmd). child_process.spawn('.cmd') throws
|
|
30
|
+
// EINVAL/ENOENT without a shell, so `.cmd`/`.bat` must be launched with
|
|
31
|
+
// `shell: true`; `.mjs`/`.js` entries are launched through node; `.exe` directly.
|
|
32
|
+
import { spawn as spawnProcess, spawnSync } from 'node:child_process'
|
|
33
|
+
import { randomBytes } from 'node:crypto'
|
|
34
|
+
import fs from 'node:fs'
|
|
35
|
+
import { createRequire } from 'node:module'
|
|
36
|
+
import net from 'node:net'
|
|
37
|
+
import path from 'node:path'
|
|
38
|
+
import {
|
|
39
|
+
AUDIT,
|
|
40
|
+
EXIT,
|
|
41
|
+
FORK_STATUS,
|
|
42
|
+
SERVICE,
|
|
43
|
+
bus,
|
|
44
|
+
copyTree,
|
|
45
|
+
ensureDir,
|
|
46
|
+
ensureModuleMirror,
|
|
47
|
+
exists,
|
|
48
|
+
iso,
|
|
49
|
+
listFiles,
|
|
50
|
+
mirrorModuleTree,
|
|
51
|
+
newId,
|
|
52
|
+
nowMs,
|
|
53
|
+
parseWebUrl,
|
|
54
|
+
readJson,
|
|
55
|
+
removeTree,
|
|
56
|
+
resolveFangLayout,
|
|
57
|
+
roleGate,
|
|
58
|
+
writeFileAtomic,
|
|
59
|
+
writeJsonAtomic,
|
|
60
|
+
z,
|
|
61
|
+
} from '../kernel/index.mjs'
|
|
62
|
+
import { computeBackoff, planRestart, reconcileOrphans, writeForkMeta, probeHttpAlive, SUPERVISION_DEFAULTS } from '../lib/fork-supervisor.mjs'
|
|
63
|
+
import { applyForkConfigLock, setProfileConfigReadonly } from '../lib/fork-config-lock.mjs'
|
|
64
|
+
import { runMainGate, formatGateFailure } from '../lib/main-gate.mjs'
|
|
65
|
+
import { DEFAULT_FORK_SEED, applyForkSeedToProfile } from '../lib/fork-seed.mjs'
|
|
66
|
+
|
|
67
|
+
export const name = 'fang-forker'
|
|
68
|
+
export const inject = []
|
|
69
|
+
|
|
70
|
+
/** In-process event topics (CONTRACTS.md §6). */
|
|
71
|
+
const EVENTS = {
|
|
72
|
+
started: 'fang/fork-started',
|
|
73
|
+
crashed: 'fang/fork-crashed',
|
|
74
|
+
reaped: 'fang/fork-reaped',
|
|
75
|
+
/** v2.7.3:自动重启失败 / 放弃重启(terminal=true 表示不会再有自动重试)。 */
|
|
76
|
+
restartFailed: 'fang/fork-restart-failed',
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** v2 deterministic port pool (CONTRACTS-V2.md §5.5). */
|
|
80
|
+
const DEFAULT_PORT_POOL = { start: 18000, end: 18100 }
|
|
81
|
+
/** Loopback interface a fork's `dsh web` binds; the pool is probed on the same host. */
|
|
82
|
+
const PORT_HOST = '127.0.0.1'
|
|
83
|
+
/** A bind probe that does not answer by then counts as "not usable" (fail-closed, try next). */
|
|
84
|
+
const PORT_PROBE_TIMEOUT_MS = 5000
|
|
85
|
+
/** Bundle list of the clean fork profile: base + web app + this bundle (Cordis patch). */
|
|
86
|
+
const CLEAN_TEMPLATE_BUNDLES = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']
|
|
87
|
+
|
|
88
|
+
// v3.1.0: DSH's first-run preview notice persists ui-settings-general.welcomeNoticeVersion.
|
|
89
|
+
// A clean profile that omits the row leaves the config form unavailable, so 「继续」 always
|
|
90
|
+
// reports "暂时无法保存确认状态". Seed the acknowledgement in every generated clean profile;
|
|
91
|
+
// the fork lock then appends its own disable rows.
|
|
92
|
+
const WELCOME_NOTICE_PATCH = [
|
|
93
|
+
'- id: ui-settings-general',
|
|
94
|
+
' name: "@deepseek-ai/dsh-client-ui-settings-general"',
|
|
95
|
+
' config:',
|
|
96
|
+
' welcomeNoticeVersion: 2026-09-28.1',
|
|
97
|
+
'',
|
|
98
|
+
].join('\n')
|
|
99
|
+
|
|
100
|
+
const DEFAULTS = {
|
|
101
|
+
cliPath: '',
|
|
102
|
+
profileTemplate: '',
|
|
103
|
+
startTimeoutMs: 60000,
|
|
104
|
+
maxForks: 8,
|
|
105
|
+
maxLogLines: 400,
|
|
106
|
+
dataRoot: '',
|
|
107
|
+
portPool: { ...DEFAULT_PORT_POOL },
|
|
108
|
+
allocatePorts: true,
|
|
109
|
+
cleanTemplate: true,
|
|
110
|
+
bundleName: '@twinsearth/fang-dsh-plugin',
|
|
111
|
+
forkProfileTag: 'web-base',
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Files cloned from a (clean or mother) template profile: package.json + cordis*.yml only. */
|
|
115
|
+
const CLONED_RE = /^(package\.json|cordis.*\.ya?ml)$/i
|
|
116
|
+
const PROFILE_UNSAFE_RE = /[^a-zA-Z0-9_.-]/g
|
|
117
|
+
/** stdout/stderr scan window kept for `parseWebUrl` (the banner may be split over chunks). */
|
|
118
|
+
const SCAN_TEXT_LIMIT = 64 * 1024
|
|
119
|
+
|
|
120
|
+
export { parseWebUrl }
|
|
121
|
+
|
|
122
|
+
export const Config = z
|
|
123
|
+
.object({
|
|
124
|
+
cliPath: z.string().default('').description('dsh CLI 入口:.exe / .cmd / .mjs(空 = 由 spawn 入参给)'),
|
|
125
|
+
profileTemplate: z
|
|
126
|
+
.string()
|
|
127
|
+
.default('')
|
|
128
|
+
.description('母版 profile 目录:clone 源 / node_modules junction 源(cleanTemplate 时只用后者)'),
|
|
129
|
+
startTimeoutMs: z.natural().default(60000).description('等待 web URL 的启动超时(毫秒)'),
|
|
130
|
+
maxForks: z.natural().default(8).description('允许同时存活的分身上限'),
|
|
131
|
+
maxLogLines: z.natural().default(400).description('每个分身 stdout 滚动缓冲行数'),
|
|
132
|
+
dataRoot: z.string().default('').description('注册表/端口表落盘根目录(空 = 仅进程内存)'),
|
|
133
|
+
portPool: z
|
|
134
|
+
.object({
|
|
135
|
+
start: z.number().default(DEFAULT_PORT_POOL.start).description('端口池起始(含)'),
|
|
136
|
+
end: z.number().default(DEFAULT_PORT_POOL.end).description('端口池结束(含)'),
|
|
137
|
+
})
|
|
138
|
+
.default({})
|
|
139
|
+
.description('确定性端口池 18000–18100'),
|
|
140
|
+
allocatePorts: z
|
|
141
|
+
.boolean()
|
|
142
|
+
.default(true)
|
|
143
|
+
.description('true:预分配确定性端口(--port <port>);false:退回 v1 的 --port 0'),
|
|
144
|
+
cleanTemplate: z
|
|
145
|
+
.boolean()
|
|
146
|
+
.default(true)
|
|
147
|
+
.description('true:用内置干净模板(不继承母体脏 profile);false:沿用 profileTemplate'),
|
|
148
|
+
bundleName: z.string().default('@twinsearth/fang-dsh-plugin').description('干净模板 profile.bundles 的第三个 bundle'),
|
|
149
|
+
forkProfileTag: z.string().default('web-base').description('干净模板 profile 标签(模板目录/缺省 profile 名)'),
|
|
150
|
+
// v3.1.12:新机器/新用户的干净分身默认不带第三方插件;这里按“安装不激活 /
|
|
151
|
+
// 安装并激活”两组清单给每个新分身装默认插件(开关可关;失败不阻塞分身启动)。
|
|
152
|
+
forkSeed: z
|
|
153
|
+
.object({
|
|
154
|
+
enabled: z.boolean().default(true).description('派生新分身时安装默认第三方插件种子'),
|
|
155
|
+
install: z.array(z.string()).default([...DEFAULT_FORK_SEED.install]).description('只安装、不加入 profile bundles 的插件包名'),
|
|
156
|
+
activate: z.array(z.string()).default([...DEFAULT_FORK_SEED.activate]).description('安装并加入 profile bundles 的插件包名'),
|
|
157
|
+
})
|
|
158
|
+
.default({})
|
|
159
|
+
.description('派生分身的默认第三方插件种子'),
|
|
160
|
+
supervise: z.boolean().default(false)
|
|
161
|
+
.description('restart a crashed (already-ready) fork with exponential backoff; the patch layer enables it'),
|
|
162
|
+
maxRestarts: z.natural().default(SUPERVISION_DEFAULTS.maxRestarts)
|
|
163
|
+
.description('max automatic restarts per fork'),
|
|
164
|
+
backoffBaseMs: z.natural().default(SUPERVISION_DEFAULTS.baseMs)
|
|
165
|
+
.description('backoff base in milliseconds'),
|
|
166
|
+
backoffMaxMs: z.natural().default(SUPERVISION_DEFAULTS.maxMs)
|
|
167
|
+
.description('backoff cap in milliseconds'),
|
|
168
|
+
backoffJitter: z.number().min(0).max(0.9).default(SUPERVISION_DEFAULTS.jitter)
|
|
169
|
+
.description('backoff jitter ratio'),
|
|
170
|
+
healthPath: z.string().default(SUPERVISION_DEFAULTS.healthPath)
|
|
171
|
+
.description('HTTP liveness path (any response counts)')
|
|
172
|
+
})
|
|
173
|
+
.description('fang-forker 配置')
|
|
174
|
+
|
|
175
|
+
const msgOf = (e) => String(e && e.message ? e.message : e)
|
|
176
|
+
|
|
177
|
+
function safeProfile(value) {
|
|
178
|
+
const p = String(value ?? '').trim().replace(PROFILE_UNSAFE_RE, '_')
|
|
179
|
+
return p && p !== '.' && p !== '..' ? p : 'default'
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Pure: is this cliPath a Windows shell launcher (.cmd/.bat)?
|
|
184
|
+
* Measured on Windows: spawn('...dsh.cmd', args) throws EINVAL/ENOENT unless shell:true.
|
|
185
|
+
*/
|
|
186
|
+
export function isShellLauncher(cliPath) {
|
|
187
|
+
return /\.(cmd|bat)$/i.test(String(cliPath ?? '').trim())
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Pure: argv appended to the CLI (CONTRACTS.md §5.1).
|
|
192
|
+
* v2: with allocatePorts the forker passes the pre-allocated `--port <n>`; the v1 fallback
|
|
193
|
+
* is still `--port 0` (OS picks a free port).
|
|
194
|
+
* v3.1.7: `patchPaths` are launcher layers (`--patch <path>`) handed to the child before
|
|
195
|
+
* its app args; the headless directory-picker overlay travels this way.
|
|
196
|
+
* @returns {string[]}
|
|
197
|
+
*/
|
|
198
|
+
export function buildSpawnArgs({ profile = 'default', port = 0, noOpen = true, patchPaths = [] } = {}) {
|
|
199
|
+
const n = Math.floor(Number(port))
|
|
200
|
+
const p = Number.isInteger(n) && n >= 0 && n <= 65535 ? n : 0
|
|
201
|
+
const args = ['--profile', safeProfile(profile)]
|
|
202
|
+
for (const entry of Array.isArray(patchPaths) ? patchPaths : []) {
|
|
203
|
+
const patchPath = String(entry ?? '').trim()
|
|
204
|
+
if (patchPath) args.push('--patch', patchPath)
|
|
205
|
+
}
|
|
206
|
+
args.push('--port', String(p))
|
|
207
|
+
if (noOpen) args.push('--no-open')
|
|
208
|
+
return args
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Pure: turn a cliPath into the exact `{command, args, shell}` triple to spawn.
|
|
213
|
+
* .cmd / .bat → shell launcher (shell:true)
|
|
214
|
+
* .mjs/.cjs/.js→ node + script prefix
|
|
215
|
+
* anything else (normally .exe) → direct spawn
|
|
216
|
+
* Never touches the filesystem, so it is safe to unit-test without launching anything.
|
|
217
|
+
* @returns {{command:string, args:string[], shell:boolean, windowsHide:boolean, nodeScript:boolean}}
|
|
218
|
+
*/
|
|
219
|
+
export function buildLaunchSpec({ cliPath = '', profile = 'default', port = 0, patchPaths = [] } = {}) {
|
|
220
|
+
const entry = String(cliPath ?? '').trim()
|
|
221
|
+
const args = buildSpawnArgs({ profile, port, patchPaths })
|
|
222
|
+
if (isShellLauncher(entry)) {
|
|
223
|
+
// `shell: true` concatenates argv without escaping (DEP0190); a profile home or overlay
|
|
224
|
+
// path containing whitespace would otherwise split into two child arguments.
|
|
225
|
+
const quoted = args.map((arg) => (/[\s"]/u.test(arg) ? `"${String(arg).replace(/"/g, '\\"')}"` : arg))
|
|
226
|
+
return { command: entry, args: quoted, shell: true, windowsHide: true, nodeScript: false }
|
|
227
|
+
}
|
|
228
|
+
if (/\.(mjs|cjs|js)$/i.test(entry)) {
|
|
229
|
+
return { command: process.execPath, args: [entry, ...args], shell: false, windowsHide: true, nodeScript: true }
|
|
230
|
+
}
|
|
231
|
+
return { command: entry, args, shell: false, windowsHide: true, nodeScript: false }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* v3.0.5/B5:派生进程环境变量白名单(不再全量继承母体 env —— 防止云密钥/适配器 token 泄漏给最低信任层)。
|
|
236
|
+
* 只放行启动 DSH 必需的运行时/代理变量;DSH_*、API key、token 等一律不继承(由 overrides 显式注入)。
|
|
237
|
+
* @param {Record<string, string|undefined>} [base]
|
|
238
|
+
* @param {Record<string, string|undefined>} [overrides]
|
|
239
|
+
* @returns {Record<string, string>}
|
|
240
|
+
*/
|
|
241
|
+
export function buildForkChildEnv(base = process.env, overrides = {}) {
|
|
242
|
+
const KEYS = [
|
|
243
|
+
'PATH', 'Path', 'PATHEXT', 'SystemRoot', 'SystemDrive', 'windir', 'ComSpec',
|
|
244
|
+
'TEMP', 'TMP', 'TMPDIR', 'HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA',
|
|
245
|
+
'PROGRAMDATA', 'PROGRAMFILES', 'PROGRAMFILES(X86)', 'OS', 'NUMBER_OF_PROCESSORS',
|
|
246
|
+
'PROCESSOR_ARCHITECTURE', 'USERNAME', 'USERDOMAIN', 'LANG', 'LC_ALL', 'TERM', 'SHELL',
|
|
247
|
+
'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy',
|
|
248
|
+
]
|
|
249
|
+
/** @type {Record<string,string>} */
|
|
250
|
+
const env = {}
|
|
251
|
+
for (const key of KEYS) {
|
|
252
|
+
if (typeof base?.[key] === 'string') env[key] = base[key]
|
|
253
|
+
}
|
|
254
|
+
for (const [key, value] of Object.entries(overrides ?? {})) {
|
|
255
|
+
if (typeof value === 'string') env[key] = value
|
|
256
|
+
else delete env[key]
|
|
257
|
+
}
|
|
258
|
+
return env
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The launch overlay that pins the directory picker to the browser dialog in every
|
|
263
|
+
* fork/ESSENCE-RUNTIME web host.
|
|
264
|
+
*
|
|
265
|
+
* `dsh-host-directory-picker-auto` resolves `native` on win32/darwin whenever the
|
|
266
|
+
* bind is loopback and the launch is not SSH — it has no way to know the host was
|
|
267
|
+
* started headless through `dsh.cmd` (ELECTRON_RUN_AS_NODE, no Electron shell), so
|
|
268
|
+
* the native chooser has no window to open and "选择工作区" fails in the fork Web UI
|
|
269
|
+
* (v3.1.7 report). The documented composition switch is an overlay that disables
|
|
270
|
+
* the auto row and mounts the browse backend + its client surface directly; both
|
|
271
|
+
* package names exist as installation dependencies of the same DSH release.
|
|
272
|
+
*/
|
|
273
|
+
export const HEADLESS_PICKER_OVERLAY = [
|
|
274
|
+
'# dsh-Fang v3.1.7: headless web host (dsh.cmd --no-open) has no native chooser window.',
|
|
275
|
+
'# Pin the directory-picker interaction to the browser dialog.',
|
|
276
|
+
'- id: directory-picker',
|
|
277
|
+
' disabled: true',
|
|
278
|
+
'- insert:',
|
|
279
|
+
' - id: directory-picker-browse',
|
|
280
|
+
" name: '@deepseek-ai/dsh-host-directory-picker-browse'",
|
|
281
|
+
' - id: ui-directory-picker-browse',
|
|
282
|
+
" name: '@deepseek-ai/dsh-client-ui-directory-picker-browse'",
|
|
283
|
+
'',
|
|
284
|
+
].join('\n')
|
|
285
|
+
|
|
286
|
+
/** File name of the headless launch overlay inside the fork/essence-runtime profile. */
|
|
287
|
+
export const HEADLESS_PICKER_OVERLAY_FILE = 'fang-headless.patch.yml'
|
|
288
|
+
|
|
289
|
+
/** Whether this DSH installation carries the browse-picker pair the overlay mounts. */
|
|
290
|
+
export function headlessPickerSupported({ cliPath = '' } = {}) {
|
|
291
|
+
const names = [
|
|
292
|
+
'@deepseek-ai/dsh-host-directory-picker-browse',
|
|
293
|
+
'@deepseek-ai/dsh-client-ui-directory-picker-browse',
|
|
294
|
+
]
|
|
295
|
+
let require = null
|
|
296
|
+
try { require = createRequire(import.meta.url) } catch { require = null }
|
|
297
|
+
const anchors = []
|
|
298
|
+
// Desktop install: <root>/resources/app.asar/dsh/node_modules (asar-aware fs in the host).
|
|
299
|
+
try { anchors.push(path.join(path.dirname(process.execPath), 'resources', 'app.asar', 'dsh', 'node_modules')) } catch { /* ignore */ }
|
|
300
|
+
// Launcher-derived install root: .../resources/runtime/cli/bin/dsh.cmd → .../resources/app.asar/dsh/node_modules
|
|
301
|
+
const cli = String(cliPath ?? '').trim()
|
|
302
|
+
if (cli) {
|
|
303
|
+
const resources = path.resolve(path.dirname(cli), '..', '..', '..', '..', 'resources')
|
|
304
|
+
anchors.push(path.join(resources, 'app.asar', 'dsh', 'node_modules'))
|
|
305
|
+
}
|
|
306
|
+
for (const name of names) {
|
|
307
|
+
let found = false
|
|
308
|
+
if (require) {
|
|
309
|
+
try { require.resolve(name); found = true } catch { /* not resolvable here */ }
|
|
310
|
+
}
|
|
311
|
+
if (!found) {
|
|
312
|
+
for (const anchor of anchors) {
|
|
313
|
+
try {
|
|
314
|
+
if (fs.existsSync(path.join(anchor, ...name.split('/'), 'package.json'))) { found = true; break }
|
|
315
|
+
} catch { /* ignore */ }
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (!found) return false
|
|
319
|
+
}
|
|
320
|
+
return true
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Write (or refresh) the headless-picker launch overlay in `profileDir`.
|
|
325
|
+
* `DSH_FANG_HEADLESS_PICKER_PATCH=0` opts out; an installation without the browse
|
|
326
|
+
* pair keeps the DSH default (native) so an unknown-version host is not patched blind.
|
|
327
|
+
* @param {string} profileDir The fork/essence-runtime profile directory.
|
|
328
|
+
* @param {{cliPath?: string, supported?: boolean}} [options] The launcher the fork will
|
|
329
|
+
* be started with; `supported` overrides the installation probe (tests).
|
|
330
|
+
* @returns {string|null} The overlay path to pass as `--patch`, or null.
|
|
331
|
+
*/
|
|
332
|
+
export function writeHeadlessPickerOverlay(profileDir, { cliPath = '', supported } = {}) {
|
|
333
|
+
if (String(process.env.DSH_FANG_HEADLESS_PICKER_PATCH ?? '1') === '0') return null
|
|
334
|
+
if (!profileDir || !exists(profileDir)) return null
|
|
335
|
+
const available = typeof supported === 'boolean' ? supported : headlessPickerSupported({ cliPath })
|
|
336
|
+
if (!available) return null
|
|
337
|
+
const file = path.join(profileDir, HEADLESS_PICKER_OVERLAY_FILE)
|
|
338
|
+
try {
|
|
339
|
+
writeFileAtomic(file, HEADLESS_PICKER_OVERLAY)
|
|
340
|
+
return file
|
|
341
|
+
} catch {
|
|
342
|
+
return null
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Pure: why did a fork exit? (CONTRACTS.md §5.1)
|
|
348
|
+
* 0 = normal, 75 = the guard allowed a restart (supervisor contract), else crash.
|
|
349
|
+
* @returns {{kind:'normal'|'restart-request'|'crash', code:number|null, signal:string|null, restartRequested:boolean}}
|
|
350
|
+
*/
|
|
351
|
+
export function classifyExit(code, signal = null) {
|
|
352
|
+
const n = code === null || code === undefined || Number.isNaN(Number(code)) ? null : Number(code)
|
|
353
|
+
if (n === EXIT.OK) return { kind: 'normal', code: n, signal, restartRequested: false }
|
|
354
|
+
if (n === EXIT.RESTART) return { kind: 'restart-request', code: n, signal, restartRequested: true }
|
|
355
|
+
return { kind: 'crash', code: n, signal, restartRequested: false }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Pure: normalise a configured port pool. Returns null for an unusable range, so the
|
|
360
|
+
* caller can fall back to the contract default instead of silently allocating nothing.
|
|
361
|
+
* @returns {{start:number, end:number}|null}
|
|
362
|
+
*/
|
|
363
|
+
export function resolvePortPool(pool) {
|
|
364
|
+
const start = Math.floor(Number(pool?.start))
|
|
365
|
+
const end = Math.floor(Number(pool?.end))
|
|
366
|
+
if (!Number.isInteger(start) || !Number.isInteger(end)) return null
|
|
367
|
+
if (start < 1 || end > 65535 || start > end) return null
|
|
368
|
+
return { start, end }
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Pure: the clean fork profile's package.json (CONTRACTS-V2.md §5.5).
|
|
373
|
+
* `dsh.profile.bundles` is the loader contract: base plugins, the web app, then this
|
|
374
|
+
* bundle — nothing inherited from the mother profile.
|
|
375
|
+
* @returns {{name:string, version:string, private:boolean, dsh:{profile:{bundles:string[]}}}}
|
|
376
|
+
*/
|
|
377
|
+
export function buildTemplatePackageJson({ bundleName = DEFAULTS.bundleName, forkProfileTag = DEFAULTS.forkProfileTag } = {}) {
|
|
378
|
+
const tag = safeProfile(forkProfileTag)
|
|
379
|
+
const bundles = [...CLEAN_TEMPLATE_BUNDLES, String(bundleName ?? '').trim() || DEFAULTS.bundleName]
|
|
380
|
+
return {
|
|
381
|
+
name: `dsh-fang-fork-${tag}`,
|
|
382
|
+
version: '1.0.0',
|
|
383
|
+
private: true,
|
|
384
|
+
dsh: { profile: { bundles } },
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Rolling stdout tail: line buffer capped at maxLines + a bounded text window for parseWebUrl. */
|
|
389
|
+
function createTextTail(maxLines) {
|
|
390
|
+
const limit = Math.max(0, Math.floor(Number(maxLines) || 0)) || DEFAULTS.maxLogLines
|
|
391
|
+
const lines = []
|
|
392
|
+
let partial = ''
|
|
393
|
+
let text = ''
|
|
394
|
+
const trim = () => {
|
|
395
|
+
if (lines.length > limit) lines.splice(0, lines.length - limit)
|
|
396
|
+
}
|
|
397
|
+
return {
|
|
398
|
+
lines,
|
|
399
|
+
get text() {
|
|
400
|
+
return text
|
|
401
|
+
},
|
|
402
|
+
push(chunk) {
|
|
403
|
+
const s = String(chunk ?? '')
|
|
404
|
+
text = (text + s).slice(-SCAN_TEXT_LIMIT)
|
|
405
|
+
partial += s
|
|
406
|
+
let idx
|
|
407
|
+
while ((idx = partial.indexOf('\n')) >= 0) {
|
|
408
|
+
lines.push(partial.slice(0, idx).replace(/\r$/, ''))
|
|
409
|
+
partial = partial.slice(idx + 1)
|
|
410
|
+
}
|
|
411
|
+
trim()
|
|
412
|
+
return s
|
|
413
|
+
},
|
|
414
|
+
feed(chunk) {
|
|
415
|
+
return this.push(chunk)
|
|
416
|
+
},
|
|
417
|
+
tail(n = 40) {
|
|
418
|
+
const count = Math.max(0, Math.floor(Number(n) || 0))
|
|
419
|
+
if (count === 0) return ''
|
|
420
|
+
const all = partial ? [...lines, partial.replace(/\r$/, '')] : lines
|
|
421
|
+
return all.slice(-count).join('\n')
|
|
422
|
+
},
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Is this pid still alive? EPERM means "exists but not ours" — still alive. */
|
|
427
|
+
function isPidAlive(pid) {
|
|
428
|
+
const n = Number(pid)
|
|
429
|
+
if (!Number.isInteger(n) || n <= 0) return false
|
|
430
|
+
try {
|
|
431
|
+
process.kill(n, 0)
|
|
432
|
+
return true
|
|
433
|
+
} catch (e) {
|
|
434
|
+
return e && e.code === 'EPERM'
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Bind-test one port on loopback: resolve true only when net can actually listen on it.
|
|
440
|
+
* Asynchronous by contract: never blocks the host event loop while probing a busy pool.
|
|
441
|
+
* @returns {Promise<boolean>}
|
|
442
|
+
*/
|
|
443
|
+
function probePortFree(port, { host = PORT_HOST, timeoutMs = PORT_PROBE_TIMEOUT_MS } = {}) {
|
|
444
|
+
return new Promise((resolve) => {
|
|
445
|
+
const server = net.createServer()
|
|
446
|
+
let settled = false
|
|
447
|
+
let timer = null
|
|
448
|
+
const done = (free) => {
|
|
449
|
+
if (settled) return
|
|
450
|
+
settled = true
|
|
451
|
+
if (timer) clearTimeout(timer)
|
|
452
|
+
try {
|
|
453
|
+
server.close(() => {})
|
|
454
|
+
} catch {
|
|
455
|
+
/* already closed */
|
|
456
|
+
}
|
|
457
|
+
resolve(free)
|
|
458
|
+
}
|
|
459
|
+
timer = setTimeout(() => done(false), Math.max(1, Math.floor(timeoutMs) || PORT_PROBE_TIMEOUT_MS))
|
|
460
|
+
timer.unref?.()
|
|
461
|
+
server.once('error', () => done(false))
|
|
462
|
+
try {
|
|
463
|
+
server.listen({ port, host, exclusive: true }, () => done(true))
|
|
464
|
+
} catch {
|
|
465
|
+
done(false)
|
|
466
|
+
}
|
|
467
|
+
})
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export function apply(ctx, config) {
|
|
471
|
+
// v2 deployment matrix: forker is a mother-only organ. Must be the FIRST statement —
|
|
472
|
+
// in a fork role nothing (service / timer / dir) may be created.
|
|
473
|
+
const gate = roleGate('forker', { cfgRole: config?.role, logger: ctx?.logger })
|
|
474
|
+
// v2 deployment matrix: this plugin is not deployed in this role — register nothing.
|
|
475
|
+
if (!gate.active) return
|
|
476
|
+
|
|
477
|
+
const rawConfig = { ...DEFAULTS, ...(config ?? {}) }
|
|
478
|
+
const portPool = resolvePortPool(rawConfig.portPool) ?? { ...DEFAULT_PORT_POOL }
|
|
479
|
+
const allocatePorts = rawConfig.allocatePorts !== false
|
|
480
|
+
const cleanTemplate = rawConfig.cleanTemplate !== false
|
|
481
|
+
const bundleName = String(rawConfig.bundleName ?? DEFAULTS.bundleName).trim() || DEFAULTS.bundleName
|
|
482
|
+
const forkProfileTag = safeProfile(rawConfig.forkProfileTag)
|
|
483
|
+
const cfg = { ...rawConfig, portPool, allocatePorts, cleanTemplate, bundleName, forkProfileTag }
|
|
484
|
+
// 运行时 supervise 开关:patch/config 的初值 + 可在不重启宿主的情况下切换(UI/API)。
|
|
485
|
+
let runtimeSupervise = cfg.supervise === true
|
|
486
|
+
|
|
487
|
+
const logger = ctx?.logger ?? { debug() {}, info() {}, warn() {}, error() {} }
|
|
488
|
+
const logWarn = (line) => {
|
|
489
|
+
try {
|
|
490
|
+
logger.warn?.(line)
|
|
491
|
+
} catch {
|
|
492
|
+
/* logger must never break the process layer */
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Cross-plugin reads are always tolerant (auditor/approver may be disabled rows). */
|
|
497
|
+
const safeGet = (key) => {
|
|
498
|
+
try {
|
|
499
|
+
return ctx?.get?.(key)
|
|
500
|
+
} catch {
|
|
501
|
+
return undefined
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
const audit = (kind, data) => {
|
|
505
|
+
try {
|
|
506
|
+
const auditor = safeGet(SERVICE.auditor)
|
|
507
|
+
if (auditor && typeof auditor.append === 'function') return auditor.append(kind, data)
|
|
508
|
+
} catch {
|
|
509
|
+
/* auditing must never break fork lifecycle */
|
|
510
|
+
}
|
|
511
|
+
return null
|
|
512
|
+
}
|
|
513
|
+
const emit = (topic, payload) => {
|
|
514
|
+
try {
|
|
515
|
+
return bus.emit(topic, payload)
|
|
516
|
+
} catch {
|
|
517
|
+
return 0
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** 广播 Adapter token 刷新/生成;handler 异常隔离,绝不影响 spawn 流程。 */
|
|
522
|
+
const emitTokenRefresh = (forkId, token) => {
|
|
523
|
+
for (const handler of [...tokenRefreshHandlers]) {
|
|
524
|
+
try {
|
|
525
|
+
handler(forkId, token)
|
|
526
|
+
} catch {
|
|
527
|
+
/* 订阅者异常不得打断 spawn */
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** forkId -> fork record. Only the live child handle and log tail are in-memory-only. */
|
|
533
|
+
const records = new Map()
|
|
534
|
+
/** forkId -> disposer for an in-flight spawn promise (so plugin dispose cannot hang one). */
|
|
535
|
+
const pendingSpawns = new Set()
|
|
536
|
+
/** (forkId, token) 订阅者:每次 spawn 重新生成 Adapter token 时广播(首次派生与重启都会触发)。 */
|
|
537
|
+
const tokenRefreshHandlers = new Set()
|
|
538
|
+
/** port -> forkId reserved/assigned by THIS plugin instance (never release another instance's). */
|
|
539
|
+
const ownedPorts = new Map()
|
|
540
|
+
/** port -> {forkId,pid,at} persisted view of the pool (loaded lazily). */
|
|
541
|
+
const portTable = new Map()
|
|
542
|
+
|
|
543
|
+
let forksFile = ''
|
|
544
|
+
let portsFile = ''
|
|
545
|
+
let layout = null
|
|
546
|
+
if (cfg.dataRoot) {
|
|
547
|
+
try {
|
|
548
|
+
layout = resolveFangLayout({ dataRoot: cfg.dataRoot })
|
|
549
|
+
forksFile = layout.forksFile
|
|
550
|
+
portsFile = path.join(layout.dataDir, 'ports.json')
|
|
551
|
+
} catch (e) {
|
|
552
|
+
logWarn(`[fang-forker] layout resolve failed: ${msgOf(e)}`)
|
|
553
|
+
forksFile = ''
|
|
554
|
+
portsFile = ''
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
let registryLoaded = false
|
|
558
|
+
let portsLoaded = false
|
|
559
|
+
let disposed = false
|
|
560
|
+
const starting = () => [...records.values()].filter((r) => r.running || r.child || r.starting).length
|
|
561
|
+
|
|
562
|
+
/** v3.0.5/B4:持久化/列表用的 URL 一律去掉 ?token=(token 只在内存)。 */
|
|
563
|
+
const stripUrlToken = (url) => {
|
|
564
|
+
const raw = String(url ?? '')
|
|
565
|
+
if (!raw) return raw || null
|
|
566
|
+
try {
|
|
567
|
+
const parsed = new URL(raw)
|
|
568
|
+
if (!parsed.searchParams.has('token')) return raw
|
|
569
|
+
parsed.searchParams.delete('token')
|
|
570
|
+
return parsed.toString()
|
|
571
|
+
} catch {
|
|
572
|
+
return raw.replace(/([?&])token=[^&#]*&?/i, (_, sep) => (sep === '?' ? '?' : '')).replace(/[?&]$/, '')
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const publicFork = (rec) => ({
|
|
577
|
+
forkId: rec.forkId,
|
|
578
|
+
taskTitle: rec.taskTitle ?? '',
|
|
579
|
+
status: rec.status ?? FORK_STATUS.STOPPED,
|
|
580
|
+
pid: rec.pid ?? null,
|
|
581
|
+
port: rec.port ?? null,
|
|
582
|
+
url: stripUrlToken(rec.url),
|
|
583
|
+
homeDir: rec.homeDir ?? '',
|
|
584
|
+
profile: rec.profile ?? '',
|
|
585
|
+
profileDir: rec.profileDir ?? '',
|
|
586
|
+
template: rec.template ?? '',
|
|
587
|
+
bufferDir: rec.bufferDir ?? '',
|
|
588
|
+
running: !!rec.running,
|
|
589
|
+
createdAt: rec.createdAt ?? null,
|
|
590
|
+
startedAt: rec.startedAt ?? null,
|
|
591
|
+
exitedAt: rec.exitedAt ?? null,
|
|
592
|
+
exit: rec.exit ?? null,
|
|
593
|
+
lastError: rec.lastError ?? null,
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
/** Load the persisted registry lazily, so `apply()` performs no I/O. */
|
|
597
|
+
const ensureRegistry = () => {
|
|
598
|
+
if (registryLoaded) return
|
|
599
|
+
registryLoaded = true
|
|
600
|
+
if (!forksFile) return
|
|
601
|
+
const saved = readJson(forksFile, null)
|
|
602
|
+
const rows = Array.isArray(saved) ? saved : Array.isArray(saved?.forks) ? saved.forks : []
|
|
603
|
+
for (const row of rows) {
|
|
604
|
+
const forkId = row && String(row.forkId ?? '').trim()
|
|
605
|
+
if (!forkId || records.has(forkId)) continue
|
|
606
|
+
records.set(forkId, { ...row, forkId, running: false, child: null, starting: false, restored: true, log: null })
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const persist = () => {
|
|
611
|
+
if (!forksFile) return false
|
|
612
|
+
try {
|
|
613
|
+
writeJsonAtomic(forksFile, { at: iso(), forks: [...records.values()].map(publicFork) })
|
|
614
|
+
return true
|
|
615
|
+
} catch (e) {
|
|
616
|
+
logWarn(`[fang-forker] registry write failed: ${msgOf(e)}`)
|
|
617
|
+
return false
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ───────────────────────────── port pool ─────────────────────────────
|
|
622
|
+
const portsSnapshot = () => {
|
|
623
|
+
const out = {}
|
|
624
|
+
for (const port of [...portTable.keys()].sort((a, b) => a - b)) out[String(port)] = { ...portTable.get(port) }
|
|
625
|
+
return out
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const persistPorts = () => {
|
|
629
|
+
if (!portsFile) return false
|
|
630
|
+
try {
|
|
631
|
+
writeJsonAtomic(portsFile, portsSnapshot())
|
|
632
|
+
return true
|
|
633
|
+
} catch (e) {
|
|
634
|
+
logWarn(`[fang-forker] ports write failed: ${msgOf(e)}`)
|
|
635
|
+
return false
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Lazy load + startup reclaim. An entry is kept only when its recorded child pid is alive:
|
|
641
|
+
* a pid-less reservation belongs to a mother that died mid-spawn and must be reclaimed too.
|
|
642
|
+
*/
|
|
643
|
+
const ensurePorts = () => {
|
|
644
|
+
if (portsLoaded) return
|
|
645
|
+
portsLoaded = true
|
|
646
|
+
if (!portsFile) return
|
|
647
|
+
const saved = readJson(portsFile, null)
|
|
648
|
+
const rows =
|
|
649
|
+
saved && typeof saved === 'object'
|
|
650
|
+
? saved.ports && typeof saved.ports === 'object'
|
|
651
|
+
? saved.ports
|
|
652
|
+
: saved
|
|
653
|
+
: {}
|
|
654
|
+
let changed = false
|
|
655
|
+
for (const [key, meta] of Object.entries(rows)) {
|
|
656
|
+
const port = Number(key)
|
|
657
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
658
|
+
changed = true
|
|
659
|
+
continue
|
|
660
|
+
}
|
|
661
|
+
const pid = meta ? Number(meta.pid) : null
|
|
662
|
+
if (!meta || !isPidAlive(pid)) {
|
|
663
|
+
changed = true
|
|
664
|
+
continue
|
|
665
|
+
}
|
|
666
|
+
portTable.set(port, { forkId: String(meta.forkId ?? ''), pid: Math.floor(pid), at: String(meta.at ?? '') })
|
|
667
|
+
}
|
|
668
|
+
if (changed) persistPorts()
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** Reserve a port for this instance (persisted with pid=null until the child exists). */
|
|
672
|
+
const reservePort = (port, forkId) => {
|
|
673
|
+
ensurePorts()
|
|
674
|
+
const n = Math.floor(Number(port))
|
|
675
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535) return { ok: false, port: null, error: 'invalid-port' }
|
|
676
|
+
const existing = portTable.get(n)
|
|
677
|
+
if (existing && !ownedPorts.has(n) && existing.forkId !== String(forkId ?? '')) {
|
|
678
|
+
return { ok: false, port: n, error: 'port-in-use' }
|
|
679
|
+
}
|
|
680
|
+
portTable.set(n, { forkId: String(forkId ?? ''), pid: null, at: iso() })
|
|
681
|
+
ownedPorts.set(n, String(forkId ?? ''))
|
|
682
|
+
persistPorts()
|
|
683
|
+
return { ok: true, port: n }
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** Attach the real child pid to a reservation so a later startup can prove liveness. */
|
|
687
|
+
const bindPortToPid = (port, forkId, pid) => {
|
|
688
|
+
const n = Math.floor(Number(port))
|
|
689
|
+
if (ownedPorts.get(n) !== forkId) return false
|
|
690
|
+
portTable.set(n, { forkId, pid: Number(pid) || null, at: iso() })
|
|
691
|
+
persistPorts()
|
|
692
|
+
return true
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** Release a port owned by this instance (never another instance's / another fork's). */
|
|
696
|
+
const releaseOwnedPort = (port, forkId) => {
|
|
697
|
+
ensurePorts()
|
|
698
|
+
const n = Math.floor(Number(port))
|
|
699
|
+
if (!Number.isInteger(n) || n < 1) return { ok: true, port: null, released: false }
|
|
700
|
+
const owner = ownedPorts.get(n)
|
|
701
|
+
if (owner === undefined || (forkId && owner !== forkId)) {
|
|
702
|
+
return { ok: true, port: n, released: false, reason: 'not-owned' }
|
|
703
|
+
}
|
|
704
|
+
ownedPorts.delete(n)
|
|
705
|
+
const entry = portTable.get(n)
|
|
706
|
+
if (!entry) return { ok: true, port: n, released: false }
|
|
707
|
+
if (forkId && entry.forkId && entry.forkId !== forkId) {
|
|
708
|
+
return { ok: true, port: n, released: false, reason: 'not-owner' }
|
|
709
|
+
}
|
|
710
|
+
portTable.delete(n)
|
|
711
|
+
persistPorts()
|
|
712
|
+
return { ok: true, port: n, released: true }
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** Public release: removes any persisted entry for the port (operator/test escape hatch). */
|
|
716
|
+
const releasePort = (port) => {
|
|
717
|
+
ensurePorts()
|
|
718
|
+
const n = Math.floor(Number(port))
|
|
719
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535) {
|
|
720
|
+
return { ok: false, port: null, released: false, error: 'invalid-port' }
|
|
721
|
+
}
|
|
722
|
+
ownedPorts.delete(n)
|
|
723
|
+
const held = portTable.delete(n)
|
|
724
|
+
if (held) persistPorts()
|
|
725
|
+
return { ok: true, port: n, released: held }
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Allocate one deterministic port from the pool. A candidate is usable only when the
|
|
730
|
+
* loopback bind test succeeds; occupied candidates are skipped. Pool exhausted → error.
|
|
731
|
+
* @returns {Promise<{ok:boolean, port:number|null, reason:string, error?:string, pool?:object}>}
|
|
732
|
+
*/
|
|
733
|
+
const allocatePort = async (opts = {}) => {
|
|
734
|
+
ensurePorts()
|
|
735
|
+
const forkId = String(opts.forkId ?? '').trim()
|
|
736
|
+
const { start, end } = portPool
|
|
737
|
+
for (let port = start; port <= end; port++) {
|
|
738
|
+
if (portTable.has(port)) continue
|
|
739
|
+
let free = false
|
|
740
|
+
try {
|
|
741
|
+
free = await probePortFree(port)
|
|
742
|
+
} catch {
|
|
743
|
+
free = false
|
|
744
|
+
}
|
|
745
|
+
if (!free) continue
|
|
746
|
+
portTable.set(port, { forkId, pid: null, at: iso() })
|
|
747
|
+
ownedPorts.set(port, forkId)
|
|
748
|
+
persistPorts()
|
|
749
|
+
return { ok: true, port, forkId, reason: '' }
|
|
750
|
+
}
|
|
751
|
+
return {
|
|
752
|
+
ok: false,
|
|
753
|
+
port: null,
|
|
754
|
+
forkId,
|
|
755
|
+
reason: 'port-exhausted',
|
|
756
|
+
error: 'port-exhausted',
|
|
757
|
+
pool: { ...portPool },
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** Windows: taskkill /PID <pid> /T /F kills the whole tree (electron children too). */
|
|
762
|
+
const killTree = (rec) => {
|
|
763
|
+
const pid = rec?.pid ?? rec?.child?.pid ?? null
|
|
764
|
+
if (process.platform === 'win32' && pid) {
|
|
765
|
+
try {
|
|
766
|
+
const res = spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true, timeout: 10000 })
|
|
767
|
+
if (res && res.status === 0) return true
|
|
768
|
+
} catch {
|
|
769
|
+
/* fall through to child.kill */
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
try {
|
|
773
|
+
if (rec?.child && typeof rec.child.kill === 'function') {
|
|
774
|
+
rec.child.kill('SIGTERM')
|
|
775
|
+
return true
|
|
776
|
+
}
|
|
777
|
+
} catch {
|
|
778
|
+
/* already gone */
|
|
779
|
+
}
|
|
780
|
+
return false
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const isSafeToRemove = (homeDir) => {
|
|
784
|
+
const resolved = path.resolve(homeDir)
|
|
785
|
+
const root = path.parse(resolved).root
|
|
786
|
+
return resolved !== root && resolved.length > root.length + 1
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// ───────────────────────────── clean template ─────────────────────────────
|
|
790
|
+
/**
|
|
791
|
+
* Write a clean fork profile template (CONTRACTS-V2.md §5.5):
|
|
792
|
+
* package.json { name, private, dsh.profile.bundles:[dsh-base,dsh-web-app,bundleName] }
|
|
793
|
+
* cordis.yml '[]'
|
|
794
|
+
* cordis.patch.yml '[]'
|
|
795
|
+
* node_modules junction → nodeModulesSource (absent source → warning, still ok)
|
|
796
|
+
* Idempotent: same content is rewritten, an existing junction is left untouched.
|
|
797
|
+
* @returns {{ok:boolean, dir:string, files:string[], warnings:string[], error?:string}}
|
|
798
|
+
*/
|
|
799
|
+
const ensureTemplate = (opts = {}) => {
|
|
800
|
+
const rawDir = String(opts.templateDir ?? '').trim()
|
|
801
|
+
if (!rawDir) return { ok: false, dir: '', files: [], warnings: [], error: 'no-template-dir' }
|
|
802
|
+
const templateDir = path.resolve(rawDir)
|
|
803
|
+
const files = []
|
|
804
|
+
const warnings = []
|
|
805
|
+
try {
|
|
806
|
+
ensureDir(templateDir)
|
|
807
|
+
writeJsonAtomic(
|
|
808
|
+
path.join(templateDir, 'package.json'),
|
|
809
|
+
buildTemplatePackageJson({ bundleName: cfg.bundleName, forkProfileTag: cfg.forkProfileTag }),
|
|
810
|
+
)
|
|
811
|
+
files.push('package.json')
|
|
812
|
+
for (const name of ['cordis.yml', 'cordis.patch.yml']) {
|
|
813
|
+
writeFileAtomic(path.join(templateDir, name), name === 'cordis.patch.yml' ? WELCOME_NOTICE_PATCH : '[]\n')
|
|
814
|
+
files.push(name)
|
|
815
|
+
}
|
|
816
|
+
const srcRaw = String(opts.nodeModulesSource ?? '').trim()
|
|
817
|
+
const source = srcRaw ? path.resolve(srcRaw) : ''
|
|
818
|
+
const link = path.join(templateDir, 'node_modules')
|
|
819
|
+
if (source && exists(source)) {
|
|
820
|
+
try {
|
|
821
|
+
// v3.1.7: per-entry mirror instead of one whole-tree junction — see fsx.mirrorModuleTree.
|
|
822
|
+
const mirrored = mirrorModuleTree(source, link)
|
|
823
|
+
if (mirrored.ok !== true) warnings.push(`node-modules-mirror-failed: ${mirrored.error ?? 'unknown'}`)
|
|
824
|
+
else files.push('node_modules')
|
|
825
|
+
} catch (e) {
|
|
826
|
+
warnings.push(`node-modules-mirror-failed: ${msgOf(e)}`)
|
|
827
|
+
}
|
|
828
|
+
} else {
|
|
829
|
+
warnings.push('node-modules-source-missing')
|
|
830
|
+
}
|
|
831
|
+
return { ok: true, dir: templateDir, files, warnings }
|
|
832
|
+
} catch (e) {
|
|
833
|
+
return { ok: false, dir: templateDir, files, warnings, error: 'template-write-failed', message: msgOf(e) }
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/** Clone exactly package.json + cordis*.yml from a template into the fork profile dir. */
|
|
838
|
+
const cloneProfileConfig = (fromDir, toDir) => {
|
|
839
|
+
const wanted = listFiles(fromDir).filter((rel) => !rel.includes('/') && CLONED_RE.test(rel))
|
|
840
|
+
copyTree(fromDir, toDir, { filter: (entryName) => CLONED_RE.test(entryName) })
|
|
841
|
+
return wanted.filter((rel) => exists(path.join(toDir, rel)))
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// ───────────────────────────── provision ─────────────────────────────
|
|
845
|
+
/**
|
|
846
|
+
* Build homeDir/profiles/<profile>.
|
|
847
|
+
* cleanTemplate=true: the clone source is a forker-owned clean template
|
|
848
|
+
* (<homeDir>/templates/<forkProfileTag>); only package.json + cordis*.yml are cloned and
|
|
849
|
+
* the mother contributes the node_modules junction source only — a dirty mother profile
|
|
850
|
+
* can never leak in. cleanTemplate=false: v1 behaviour (clone from profileTemplate).
|
|
851
|
+
* @returns {{ok:boolean, forkId?:string, homeDir?:string, profileDir?:string, bufferDir?:string,
|
|
852
|
+
* profile?:string, template?:string, cloned?:string[], node_modules?:string|null,
|
|
853
|
+
* warnings?:string[], error?:string}}
|
|
854
|
+
*/
|
|
855
|
+
const provision = (opts = {}) => {
|
|
856
|
+
ensureRegistry()
|
|
857
|
+
const homeRaw = String(opts.homeDir ?? '').trim()
|
|
858
|
+
if (!homeRaw) return { ok: false, error: 'no-home-dir' }
|
|
859
|
+
const templateRaw = String(opts.profileTemplate ?? cfg.profileTemplate ?? '').trim()
|
|
860
|
+
if (!templateRaw) return { ok: false, error: 'no-profile-template' }
|
|
861
|
+
const sourceTemplateDir = path.resolve(templateRaw)
|
|
862
|
+
if (!exists(sourceTemplateDir)) return { ok: false, error: 'profile-template-missing', profileTemplate: sourceTemplateDir }
|
|
863
|
+
|
|
864
|
+
const forkId = String(opts.forkId ?? '').trim() || newId('fork')
|
|
865
|
+
const existing = records.get(forkId)
|
|
866
|
+
if (existing && (existing.running || existing.starting)) return { ok: false, error: 'already-running', forkId }
|
|
867
|
+
|
|
868
|
+
const homeDir = path.resolve(homeRaw)
|
|
869
|
+
const profile = safeProfile(opts.profile ?? (cleanTemplate ? forkProfileTag : path.basename(sourceTemplateDir)))
|
|
870
|
+
const profileDir = path.join(homeDir, 'profiles', profile)
|
|
871
|
+
// v3.3.0 主城门禁(新建 profile):分身/元主运行态首次落盘前必须过路径/磁盘/门禁。
|
|
872
|
+
// 已存在的 profile 走 restart 路径,不重复新建检查。
|
|
873
|
+
if (!exists(profileDir)) {
|
|
874
|
+
const gate = runMainGate({
|
|
875
|
+
target: 'profile',
|
|
876
|
+
homeDir,
|
|
877
|
+
profile,
|
|
878
|
+
profileDir,
|
|
879
|
+
create: true,
|
|
880
|
+
force: false,
|
|
881
|
+
})
|
|
882
|
+
if (!gate.ok) {
|
|
883
|
+
return { ok: false, error: 'main-city-gate-blocked', detail: formatGateFailure(gate), gate }
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
ensureDir(homeDir)
|
|
887
|
+
ensureDir(profileDir)
|
|
888
|
+
|
|
889
|
+
const warnings = []
|
|
890
|
+
// 显式 profileTemplate(例如原身 merged/profile)优先:此时不再用 clean template。
|
|
891
|
+
const useCleanTemplate = typeof opts.cleanTemplate === 'boolean' ? opts.cleanTemplate : cleanTemplate
|
|
892
|
+
const nodeModulesSource = String(opts.nodeModulesSource ?? '').trim() || path.join(sourceTemplateDir, 'node_modules')
|
|
893
|
+
let template = sourceTemplateDir
|
|
894
|
+
let cloned = []
|
|
895
|
+
|
|
896
|
+
if (useCleanTemplate) {
|
|
897
|
+
// Forker-owned clean template: never written into the mother profile directory.
|
|
898
|
+
const cleanDir = path.join(homeDir, 'templates', forkProfileTag)
|
|
899
|
+
const ensured = ensureTemplate({
|
|
900
|
+
templateDir: cleanDir,
|
|
901
|
+
nodeModulesSource: exists(nodeModulesSource) ? nodeModulesSource : '',
|
|
902
|
+
})
|
|
903
|
+
if (!ensured.ok) {
|
|
904
|
+
return {
|
|
905
|
+
ok: false,
|
|
906
|
+
error: ensured.error ?? 'clean-template-failed',
|
|
907
|
+
template: ensured.dir,
|
|
908
|
+
warnings: ensured.warnings,
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
template = ensured.dir
|
|
912
|
+
warnings.push(...ensured.warnings)
|
|
913
|
+
cloned = cloneProfileConfig(template, profileDir)
|
|
914
|
+
} else {
|
|
915
|
+
cloned = cloneProfileConfig(sourceTemplateDir, profileDir)
|
|
916
|
+
}
|
|
917
|
+
// 生产盘锁定:关闭插件安装 / 设置 UI(派生时写入 profile patch)。
|
|
918
|
+
// essence-runtime 是元宝的“配置态实例”,必须保留插件管理能力,不能套生产盘锁。
|
|
919
|
+
const configInstance = path.basename(String(profileDir)) === 'essence-runtime'
|
|
920
|
+
const configLock = configInstance ? { ok: true, skipped: true, runtime: true } : applyForkConfigLock(profileDir)
|
|
921
|
+
if (configLock.ok !== true) warnings.push('fork-config-lock-failed')
|
|
922
|
+
// 配置文件只读:防误写(DSH_FANG_FORK_PROFILE_RO=0 可关)。
|
|
923
|
+
const profileReadonly = setProfileConfigReadonly(profileDir, true)
|
|
924
|
+
|
|
925
|
+
// node_modules mirror in the fork profile (always to the mother's module tree).
|
|
926
|
+
// v3.1.7: one junction per top-level entry instead of a whole-tree junction. DSH's
|
|
927
|
+
// profile-scope resolver only supplies installation packages (`@deepseek-ai/*`) to an
|
|
928
|
+
// importer inside the profile tree or below a linked root; a whole-tree junction keeps
|
|
929
|
+
// the importer outside every scope, so lazy host imports fall back to plugin shims
|
|
930
|
+
// (w2m's shim emits `required: false` in a property schema → the provider rejects
|
|
931
|
+
// every request: "Invalid schema for function 'w2m_report'", v3.1.7 report).
|
|
932
|
+
let nodeModules = null
|
|
933
|
+
if (exists(nodeModulesSource)) {
|
|
934
|
+
try {
|
|
935
|
+
const mirrored = mirrorModuleTree(nodeModulesSource, path.join(profileDir, 'node_modules'))
|
|
936
|
+
if (mirrored.ok === true) nodeModules = nodeModulesSource
|
|
937
|
+
else warnings.push(`node-modules-mirror-failed: ${mirrored.error ?? 'unknown'}`)
|
|
938
|
+
} catch (e) {
|
|
939
|
+
warnings.push(`node-modules-mirror-failed: ${msgOf(e)}`)
|
|
940
|
+
}
|
|
941
|
+
} else if (!cleanTemplate) {
|
|
942
|
+
// Degraded but still usable: the fork simply has no node_modules link to the mother.
|
|
943
|
+
warnings.push('node-modules-missing')
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const bufferDir = path.join(homeDir, 'fang', profile, 'buffer')
|
|
947
|
+
ensureDir(bufferDir)
|
|
948
|
+
|
|
949
|
+
const rec = existing ?? { forkId, createdAt: nowMs() }
|
|
950
|
+
Object.assign(rec, {
|
|
951
|
+
forkId,
|
|
952
|
+
taskTitle: String(opts.taskTitle ?? rec.taskTitle ?? ''),
|
|
953
|
+
homeDir,
|
|
954
|
+
profile,
|
|
955
|
+
profileDir,
|
|
956
|
+
bufferDir,
|
|
957
|
+
template,
|
|
958
|
+
nodeModules,
|
|
959
|
+
pid: rec.pid ?? null,
|
|
960
|
+
port: rec.port ?? null,
|
|
961
|
+
url: rec.url ?? null,
|
|
962
|
+
child: rec.child ?? null,
|
|
963
|
+
running: false,
|
|
964
|
+
starting: false,
|
|
965
|
+
status: FORK_STATUS.PROVISIONING,
|
|
966
|
+
exit: null,
|
|
967
|
+
lastError: null,
|
|
968
|
+
})
|
|
969
|
+
records.set(forkId, rec)
|
|
970
|
+
persist()
|
|
971
|
+
audit(AUDIT.FORK_CREATED, { forkId, homeDir, profile, taskTitle: rec.taskTitle, cloned, warnings, template })
|
|
972
|
+
return { ok: true, forkId, homeDir, profileDir, bufferDir, profile, template, cloned, node_modules: nodeModules, warnings, configLock, profileReadonly }
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// ───────────────────────────── spawn ─────────────────────────────
|
|
976
|
+
/**
|
|
977
|
+
* Launch the fork and resolve once stdout printed the tokenized web URL.
|
|
978
|
+
* v2: with allocatePorts the child is told `--port <pool port>` and the printed URL port
|
|
979
|
+
* must match it; a mismatch stops the child ({ok:false,error:'port-mismatch'}).
|
|
980
|
+
* @returns {Promise<{ok:boolean, forkId?:string, pid?:number|null, port?:number, url?:string,
|
|
981
|
+
* token?:string, error?:string, expectedPort?:number}>}
|
|
982
|
+
*/
|
|
983
|
+
const writeForkMetaOf = (rec) => {
|
|
984
|
+
if (!rec?.homeDir) return false
|
|
985
|
+
return writeForkMeta(rec.homeDir, {
|
|
986
|
+
forkId: rec.forkId, status: rec.status, pid: rec.pid ?? null, port: rec.port ?? null,
|
|
987
|
+
url: rec.url ?? null, profile: rec.profile ?? null,
|
|
988
|
+
crashCount: rec.crashCount ?? 0, restartCount: rec.restartCount ?? 0,
|
|
989
|
+
lastError: rec.lastError ?? null, startedAt: rec.startedAt ?? null,
|
|
990
|
+
exitedAt: rec.exitedAt ?? null, restartScheduledAt: rec.restartScheduledAt ?? null,
|
|
991
|
+
})
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
const spawn = async (opts = {}) => {
|
|
995
|
+
ensureRegistry()
|
|
996
|
+
ensurePorts()
|
|
997
|
+
const forkId = String(opts.forkId ?? '').trim() || newId('fork')
|
|
998
|
+
const homeDir = String(opts.homeDir ?? '').trim()
|
|
999
|
+
const profile = safeProfile(opts.profile ?? 'default')
|
|
1000
|
+
const cliPath = String(opts.cliPath ?? cfg.cliPath ?? '').trim()
|
|
1001
|
+
const timeoutMs = Math.max(1, Math.floor(Number(opts.timeoutMs ?? cfg.startTimeoutMs) || DEFAULTS.startTimeoutMs))
|
|
1002
|
+
const maxLogLines = Math.max(1, Math.floor(Number(cfg.maxLogLines) || DEFAULTS.maxLogLines))
|
|
1003
|
+
|
|
1004
|
+
if (!homeDir) return { forkId, ok: false, error: 'no-home-dir' }
|
|
1005
|
+
if (!cliPath) return { forkId, ok: false, error: 'no-cli-path' }
|
|
1006
|
+
const previous = records.get(forkId)
|
|
1007
|
+
if (previous && (previous.running || previous.child || previous.starting)) {
|
|
1008
|
+
return { forkId, ok: false, error: 'already-running', pid: previous.pid ?? null }
|
|
1009
|
+
}
|
|
1010
|
+
const maxForks = Math.max(1, Math.floor(Number(cfg.maxForks) || DEFAULTS.maxForks))
|
|
1011
|
+
if (starting() >= maxForks) return { forkId, ok: false, error: 'max-forks', limit: maxForks }
|
|
1012
|
+
|
|
1013
|
+
const collector = createTextTail(maxLogLines)
|
|
1014
|
+
const rec = previous ?? { forkId, createdAt: nowMs(), taskTitle: '' }
|
|
1015
|
+
Object.assign(rec, {
|
|
1016
|
+
forkId,
|
|
1017
|
+
homeDir,
|
|
1018
|
+
profile,
|
|
1019
|
+
profileDir: rec.profileDir ?? path.join(path.resolve(homeDir), 'profiles', profile),
|
|
1020
|
+
pid: null,
|
|
1021
|
+
port: null,
|
|
1022
|
+
url: null,
|
|
1023
|
+
child: null,
|
|
1024
|
+
running: false,
|
|
1025
|
+
starting: true,
|
|
1026
|
+
status: FORK_STATUS.PROVISIONING,
|
|
1027
|
+
exit: null,
|
|
1028
|
+
lastError: null,
|
|
1029
|
+
intentionalStop: false,
|
|
1030
|
+
startedAt: nowMs(),
|
|
1031
|
+
ready: false,
|
|
1032
|
+
cliPath,
|
|
1033
|
+
crashCount: rec.crashCount ?? 0,
|
|
1034
|
+
restartCount: rec.restartCount ?? 0,
|
|
1035
|
+
restartScheduledAt: null,
|
|
1036
|
+
/**
|
|
1037
|
+
* v3 Adapter Bearer token:**每次 spawn 重新生成**,只存在于母体内存 + 分身环境变量。
|
|
1038
|
+
* 不写 fang-meta.json、不进 forks.json(publicFork 不含)、不写 URL —— 母体重启后
|
|
1039
|
+
* 旧 token 自然失效,孤儿分身不会被接管(PROTOCOL-V3 §3)。
|
|
1040
|
+
*/
|
|
1041
|
+
adapterToken: randomBytes(24).toString('hex'),
|
|
1042
|
+
log: collector,
|
|
1043
|
+
})
|
|
1044
|
+
records.set(forkId, rec)
|
|
1045
|
+
// v3.0.9 生产盘:每次打开/重启都从来源模板重放配置(原身 merged 或 clean template),
|
|
1046
|
+
// 分身自己的改动不保存;sessions/storages/buffer 与 node_modules junction 保持不动。
|
|
1047
|
+
const templateDirForRec = String(rec.template ?? '').trim()
|
|
1048
|
+
if (templateDirForRec && exists(templateDirForRec) && exists(rec.profileDir)) {
|
|
1049
|
+
try {
|
|
1050
|
+
// 重放前先解锁,否则复制会因只读失败。
|
|
1051
|
+
setProfileConfigReadonly(rec.profileDir, false)
|
|
1052
|
+
cloneProfileConfig(templateDirForRec, rec.profileDir)
|
|
1053
|
+
const settingsSrc = path.join(templateDirForRec, 'settings')
|
|
1054
|
+
if (exists(settingsSrc)) {
|
|
1055
|
+
const settingsDst = path.join(rec.profileDir, 'settings')
|
|
1056
|
+
if (exists(settingsDst)) removeTree(settingsDst)
|
|
1057
|
+
copyTree(settingsSrc, settingsDst)
|
|
1058
|
+
}
|
|
1059
|
+
rec.configSync = { at: nowMs(), from: templateDirForRec }
|
|
1060
|
+
} catch (e) {
|
|
1061
|
+
rec.configSync = { at: nowMs(), ok: false, error: msgOf(e) }
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
try { rec.configLock = applyForkConfigLock(rec.profileDir) } catch { rec.configLock = { ok: false } }
|
|
1065
|
+
try { rec.profileReadonly = setProfileConfigReadonly(rec.profileDir, true) } catch { rec.profileReadonly = { ok: false } }
|
|
1066
|
+
// v3.1.7: repair a whole-tree `node_modules` junction left by any previous release into
|
|
1067
|
+
// the per-entry mirror. This is what makes the fork host resolve the installation's
|
|
1068
|
+
// `@deepseek-ai/*` packages for profile plugins again; without it every agent request in
|
|
1069
|
+
// the fork is rejected by the provider (w2m offline shim → invalid tool schema).
|
|
1070
|
+
try {
|
|
1071
|
+
const repaired = ensureModuleMirror(path.join(rec.profileDir, 'node_modules'))
|
|
1072
|
+
rec.nodeModulesMirror = {
|
|
1073
|
+
changed: repaired.changed === true,
|
|
1074
|
+
ok: repaired.ok !== false,
|
|
1075
|
+
source: repaired.source ?? null,
|
|
1076
|
+
links: Array.isArray(repaired.links) ? repaired.links.length : 0,
|
|
1077
|
+
error: repaired.error ?? null,
|
|
1078
|
+
}
|
|
1079
|
+
} catch (e) {
|
|
1080
|
+
rec.nodeModulesMirror = { ok: false, changed: false, error: msgOf(e) }
|
|
1081
|
+
}
|
|
1082
|
+
// v3.1.12:默认第三方插件种子。install=只安装;activate=安装并激活(写入 bundles)。
|
|
1083
|
+
// 种子安装在独立 seed home(dsh 自带 pnpm),模块以 hardlink 合并进分身 node_modules;
|
|
1084
|
+
// 同一指纹只装一次。失败不阻塞分身启动,但结果保留在 rec.forkSeed 供 UI 诊断。
|
|
1085
|
+
rec.forkSeed = null
|
|
1086
|
+
if (cfg.forkSeed?.enabled !== false && !String(profile).startsWith('essence-') && process.env.DSH_FANG_FORK_SEED !== '0') {
|
|
1087
|
+
const seedRoot = path.join(path.dirname(path.resolve(homeDir)), '.plugin-seed')
|
|
1088
|
+
try { setProfileConfigReadonly(rec.profileDir, false) } catch { /* ignore */ }
|
|
1089
|
+
try {
|
|
1090
|
+
rec.forkSeed = await applyForkSeedToProfile({
|
|
1091
|
+
seed: cfg.forkSeed ?? DEFAULT_FORK_SEED,
|
|
1092
|
+
profileDir: rec.profileDir,
|
|
1093
|
+
seedRoot,
|
|
1094
|
+
cliPath,
|
|
1095
|
+
logger: ctx?.logger,
|
|
1096
|
+
})
|
|
1097
|
+
} catch (e) {
|
|
1098
|
+
rec.forkSeed = { ok: false, error: 'fork-seed-threw', detail: msgOf(e) }
|
|
1099
|
+
} finally {
|
|
1100
|
+
try { setProfileConfigReadonly(rec.profileDir, true) } catch { /* ignore */ }
|
|
1101
|
+
}
|
|
1102
|
+
if (rec.forkSeed?.ok === false) {
|
|
1103
|
+
audit(AUDIT.FORK_CREATED, { forkId, forkSeedError: rec.forkSeed.error, forkSeedDetail: String(rec.forkSeed.detail ?? '').slice(0, 600) })
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
writeForkMetaOf(rec)
|
|
1107
|
+
// 广播新 token:首次派生 = 生成,重启 = 刷新;订阅者拿到的是「当前 token」,
|
|
1108
|
+
// 不是「进程就绪」信号(进程就绪以 fang/fork-started 为准)。
|
|
1109
|
+
emitTokenRefresh(forkId, rec.adapterToken)
|
|
1110
|
+
|
|
1111
|
+
// Port: explicit spawn option > deterministic pool allocation > v1 --port 0.
|
|
1112
|
+
const requested = Math.floor(Number(opts.port))
|
|
1113
|
+
let expectedPort = 0
|
|
1114
|
+
if (Number.isInteger(requested) && requested > 0 && requested <= 65535) {
|
|
1115
|
+
const reserved = reservePort(requested, forkId)
|
|
1116
|
+
if (!reserved.ok) {
|
|
1117
|
+
rec.starting = false
|
|
1118
|
+
rec.status = FORK_STATUS.STOPPED
|
|
1119
|
+
rec.lastError = reserved.error ?? 'port-in-use'
|
|
1120
|
+
persist()
|
|
1121
|
+
return { forkId, ok: false, error: rec.lastError }
|
|
1122
|
+
}
|
|
1123
|
+
expectedPort = reserved.port
|
|
1124
|
+
} else if (allocatePorts) {
|
|
1125
|
+
const alloc = await allocatePort({ forkId })
|
|
1126
|
+
if (!alloc.ok) {
|
|
1127
|
+
rec.starting = false
|
|
1128
|
+
rec.status = FORK_STATUS.STOPPED
|
|
1129
|
+
rec.lastError = alloc.error ?? 'port-exhausted'
|
|
1130
|
+
persist()
|
|
1131
|
+
return { forkId, ok: false, error: rec.lastError, reason: alloc.reason, pool: { ...portPool } }
|
|
1132
|
+
}
|
|
1133
|
+
expectedPort = alloc.port
|
|
1134
|
+
}
|
|
1135
|
+
if (expectedPort > 0) rec.port = expectedPort
|
|
1136
|
+
if (disposed) {
|
|
1137
|
+
rec.starting = false
|
|
1138
|
+
if (expectedPort > 0) releaseOwnedPort(expectedPort, forkId)
|
|
1139
|
+
return { forkId, ok: false, error: 'disposed' }
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
return new Promise((resolve) => {
|
|
1143
|
+
let done = false
|
|
1144
|
+
let timer = null
|
|
1145
|
+
let cleanup = null
|
|
1146
|
+
|
|
1147
|
+
const releaseReserved = () => {
|
|
1148
|
+
if (expectedPort > 0) releaseOwnedPort(expectedPort, forkId)
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
const finish = (result) => {
|
|
1152
|
+
if (done) return false
|
|
1153
|
+
done = true
|
|
1154
|
+
rec.starting = false
|
|
1155
|
+
if (timer) {
|
|
1156
|
+
clearTimeout(timer)
|
|
1157
|
+
timer = null
|
|
1158
|
+
}
|
|
1159
|
+
if (cleanup) pendingSpawns.delete(cleanup)
|
|
1160
|
+
resolve({ forkId, ...result })
|
|
1161
|
+
return true
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
cleanup = () => {
|
|
1165
|
+
if (done) return
|
|
1166
|
+
rec.intentionalStop = true
|
|
1167
|
+
killTree(rec)
|
|
1168
|
+
rec.running = false
|
|
1169
|
+
rec.child = null
|
|
1170
|
+
rec.starting = false
|
|
1171
|
+
releaseReserved()
|
|
1172
|
+
finish({ ok: false, error: 'disposed' })
|
|
1173
|
+
}
|
|
1174
|
+
pendingSpawns.add(cleanup)
|
|
1175
|
+
|
|
1176
|
+
// v3.1.7: the headless web host (`dsh.cmd --no-open`, ELECTRON_RUN_AS_NODE) has no
|
|
1177
|
+
// window for the native directory chooser; pin the browse interaction through a
|
|
1178
|
+
// launcher overlay so "select workspace" works inside every fork Web UI.
|
|
1179
|
+
const patchPaths = []
|
|
1180
|
+
const headlessOverlay = writeHeadlessPickerOverlay(rec.profileDir, { cliPath })
|
|
1181
|
+
if (headlessOverlay) patchPaths.push(headlessOverlay)
|
|
1182
|
+
const spec = buildLaunchSpec({ cliPath, profile, port: expectedPort, patchPaths })
|
|
1183
|
+
const env = buildForkChildEnv(process.env, {
|
|
1184
|
+
DSH_HOME: homeDir,
|
|
1185
|
+
// Fork identity is injected by the forker (a fork must never be able to call itself mother).
|
|
1186
|
+
DSH_FANG_ROLE: 'fork',
|
|
1187
|
+
DSH_FANG_TASK_ID: forkId,
|
|
1188
|
+
...(opts.env ?? {}),
|
|
1189
|
+
// v3:Adapter Bearer token(PROTOCOL-V3 §3)。放在 opts.env 之后,母体生成的 token 优先。
|
|
1190
|
+
DSH_ADAPTER_TOKEN: rec.adapterToken,
|
|
1191
|
+
})
|
|
1192
|
+
let child = null
|
|
1193
|
+
try {
|
|
1194
|
+
child = spawnProcess(spec.command, spec.args, {
|
|
1195
|
+
env,
|
|
1196
|
+
...(exists(homeDir) ? { cwd: homeDir } : {}),
|
|
1197
|
+
windowsHide: spec.windowsHide,
|
|
1198
|
+
shell: spec.shell,
|
|
1199
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1200
|
+
})
|
|
1201
|
+
} catch (e) {
|
|
1202
|
+
rec.status = FORK_STATUS.CRASHED
|
|
1203
|
+
rec.lastError = 'spawn-error'
|
|
1204
|
+
rec.exit = { kind: 'crash', code: null, message: msgOf(e) }
|
|
1205
|
+
releaseReserved()
|
|
1206
|
+
persist()
|
|
1207
|
+
return finish({ ok: false, error: 'spawn-error', message: msgOf(e), spec })
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
rec.child = child
|
|
1211
|
+
rec.pid = child.pid ?? null
|
|
1212
|
+
if (expectedPort > 0 && rec.pid) bindPortToPid(expectedPort, forkId, rec.pid)
|
|
1213
|
+
// Note: on a failed launch (ENOENT/EINVAL) `child.pid` is undefined and the
|
|
1214
|
+
// `error` event arrives on the next tick — never resolve early here, or that
|
|
1215
|
+
// event would have no listener and surface as an uncaughtException.
|
|
1216
|
+
|
|
1217
|
+
const onData = (chunk) => {
|
|
1218
|
+
collector.push(chunk)
|
|
1219
|
+
if (rec.url) return
|
|
1220
|
+
const found = parseWebUrl(collector.text)
|
|
1221
|
+
if (!found) return
|
|
1222
|
+
// v2: the deterministic port is only trusted when the child really bound it.
|
|
1223
|
+
if (expectedPort > 0 && found.port !== expectedPort) {
|
|
1224
|
+
const pid = rec.pid
|
|
1225
|
+
rec.lastError = 'port-mismatch'
|
|
1226
|
+
const stopped = stopRecord(rec)
|
|
1227
|
+
persist()
|
|
1228
|
+
finish({
|
|
1229
|
+
ok: false,
|
|
1230
|
+
error: 'port-mismatch',
|
|
1231
|
+
expectedPort,
|
|
1232
|
+
pid,
|
|
1233
|
+
port: found.port,
|
|
1234
|
+
url: found.url,
|
|
1235
|
+
killed: stopped.killed,
|
|
1236
|
+
})
|
|
1237
|
+
return
|
|
1238
|
+
}
|
|
1239
|
+
rec.url = found.url
|
|
1240
|
+
rec.port = found.port
|
|
1241
|
+
rec.token = found.token ?? null
|
|
1242
|
+
rec.status = FORK_STATUS.RUNNING
|
|
1243
|
+
rec.running = true
|
|
1244
|
+
rec.starting = false
|
|
1245
|
+
rec.ready = true
|
|
1246
|
+
rec.startedAt = nowMs()
|
|
1247
|
+
writeForkMetaOf(rec)
|
|
1248
|
+
if (expectedPort > 0 && rec.pid) bindPortToPid(expectedPort, forkId, rec.pid)
|
|
1249
|
+
persist()
|
|
1250
|
+
emit(EVENTS.started, { forkId, pid: rec.pid, port: rec.port })
|
|
1251
|
+
audit(AUDIT.FORK_STARTED, { forkId, pid: rec.pid, port: rec.port, url: rec.url, profile })
|
|
1252
|
+
finish({
|
|
1253
|
+
ok: true,
|
|
1254
|
+
pid: rec.pid,
|
|
1255
|
+
port: rec.port,
|
|
1256
|
+
url: rec.url,
|
|
1257
|
+
token: rec.token,
|
|
1258
|
+
...(expectedPort > 0 ? { allocatedPort: expectedPort } : {}),
|
|
1259
|
+
...(rec.forkSeed ? { forkSeed: rec.forkSeed } : {}),
|
|
1260
|
+
})
|
|
1261
|
+
}
|
|
1262
|
+
// The tokenized banner is normally on stdout; scanning stderr too costs nothing.
|
|
1263
|
+
try {
|
|
1264
|
+
child.stdout?.on('data', onData)
|
|
1265
|
+
child.stderr?.on('data', onData)
|
|
1266
|
+
} catch {
|
|
1267
|
+
/* pipes unavailable: the timeout path below still settles the promise */
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
child.once('error', (e) => {
|
|
1271
|
+
rec.running = false
|
|
1272
|
+
rec.child = null
|
|
1273
|
+
rec.starting = false
|
|
1274
|
+
rec.status = FORK_STATUS.CRASHED
|
|
1275
|
+
rec.lastError = msgOf(e)
|
|
1276
|
+
rec.exit = { kind: 'crash', code: null, message: msgOf(e) }
|
|
1277
|
+
releaseReserved()
|
|
1278
|
+
persist()
|
|
1279
|
+
finish({ ok: false, error: 'spawn-error', message: msgOf(e), log: collector.tail(20) })
|
|
1280
|
+
})
|
|
1281
|
+
|
|
1282
|
+
child.once('exit', (code, signal) => {
|
|
1283
|
+
const exitInfo = classifyExit(code, signal)
|
|
1284
|
+
rec.exit = exitInfo
|
|
1285
|
+
rec.exitedAt = nowMs()
|
|
1286
|
+
rec.running = false
|
|
1287
|
+
rec.child = null
|
|
1288
|
+
rec.starting = false
|
|
1289
|
+
if (rec.intentionalStop) rec.status = FORK_STATUS.STOPPED
|
|
1290
|
+
else {
|
|
1291
|
+
rec.status = FORK_STATUS.CRASHED
|
|
1292
|
+
emit(EVENTS.crashed, { forkId, code: exitInfo.code, kind: exitInfo.kind })
|
|
1293
|
+
audit(AUDIT.FORK_CRASHED, { forkId, code: exitInfo.code, kind: exitInfo.kind, signal })
|
|
1294
|
+
}
|
|
1295
|
+
// A dead child never keeps a deterministic port: release it on every exit path.
|
|
1296
|
+
releaseReserved()
|
|
1297
|
+
if (!rec.intentionalStop) rec.crashCount = (rec.crashCount ?? 0) + 1
|
|
1298
|
+
// A reap may already have deleted the record + home; never resurrect it from the exit handler.
|
|
1299
|
+
if (records.get(forkId) === rec) {
|
|
1300
|
+
writeForkMetaOf(rec)
|
|
1301
|
+
persist()
|
|
1302
|
+
}
|
|
1303
|
+
if (!rec.intentionalStop && runtimeSupervise && !disposed) {
|
|
1304
|
+
const plan = planRestart(exitInfo, rec, {
|
|
1305
|
+
maxRestarts: cfg.maxRestarts, baseMs: cfg.backoffBaseMs,
|
|
1306
|
+
maxMs: cfg.backoffMaxMs, jitter: cfg.backoffJitter,
|
|
1307
|
+
})
|
|
1308
|
+
if (plan.action === 'restart' && rec.cliPath && records.get(forkId) === rec) {
|
|
1309
|
+
rec.restartCount = (rec.restartCount ?? 0) + 1
|
|
1310
|
+
rec.restartScheduledAt = nowMs() + plan.delayMs
|
|
1311
|
+
rec.status = FORK_STATUS.PROVISIONING
|
|
1312
|
+
rec.lastError = `crash-restart-in-${plan.delayMs}ms`
|
|
1313
|
+
writeForkMetaOf(rec)
|
|
1314
|
+
rec.restartTimer = setTimeout(() => { rec.restartTimer = null; void restartFork(rec) }, plan.delayMs)
|
|
1315
|
+
audit(AUDIT.FORK_CRASHED, { forkId, restartInMs: plan.delayMs, attempt: plan.attempt, cause: plan.reason })
|
|
1316
|
+
} else if (plan.action === 'give-up') {
|
|
1317
|
+
rec.lastError = `restart-given-up:${plan.reason}`
|
|
1318
|
+
writeForkMetaOf(rec)
|
|
1319
|
+
audit(AUDIT.FORK_CRASHED, { forkId, restartGivenUp: plan.reason })
|
|
1320
|
+
// 放弃重启 = 终态:不会再有自动重试,订阅者据此 fail-fast。
|
|
1321
|
+
emit(EVENTS.restartFailed, { forkId, error: rec.lastError, terminal: true })
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
finish({ ok: false, error: 'exited', code: exitInfo.code, signal, kind: exitInfo.kind, log: collector.tail(20) })
|
|
1325
|
+
})
|
|
1326
|
+
|
|
1327
|
+
timer = setTimeout(() => {
|
|
1328
|
+
rec.lastError = 'timeout'
|
|
1329
|
+
const stopped = stopRecord(rec)
|
|
1330
|
+
persist()
|
|
1331
|
+
finish({
|
|
1332
|
+
ok: false,
|
|
1333
|
+
error: 'timeout',
|
|
1334
|
+
pid: rec.pid,
|
|
1335
|
+
timeoutMs,
|
|
1336
|
+
killed: stopped.killed,
|
|
1337
|
+
log: collector.tail(40),
|
|
1338
|
+
})
|
|
1339
|
+
}, timeoutMs)
|
|
1340
|
+
})
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
// ───────────────────────────── stop / reap ─────────────────────────────
|
|
1344
|
+
/** Shared stop primitive: kill the tree, mark stopped, release the port. Idempotent. */
|
|
1345
|
+
const stopRecord = (rec) => {
|
|
1346
|
+
rec.intentionalStop = true
|
|
1347
|
+
if (rec.restartTimer) { clearTimeout(rec.restartTimer); rec.restartTimer = null }
|
|
1348
|
+
rec.restartScheduledAt = null
|
|
1349
|
+
rec.ready = false
|
|
1350
|
+
// token 随进程死亡作废:绝不留给下一次 spawn 复用(restartFork 会重新生成)。
|
|
1351
|
+
rec.adapterToken = null
|
|
1352
|
+
const wasRunning = !!rec.running || !!rec.child || !!rec.starting
|
|
1353
|
+
const pid = rec.pid ?? rec.child?.pid ?? null
|
|
1354
|
+
if (wasRunning) {
|
|
1355
|
+
killTree(rec)
|
|
1356
|
+
rec.running = false
|
|
1357
|
+
rec.child = null
|
|
1358
|
+
rec.starting = false
|
|
1359
|
+
rec.status = FORK_STATUS.STOPPED
|
|
1360
|
+
rec.exitedAt = nowMs()
|
|
1361
|
+
} else if (rec.status !== FORK_STATUS.REAPED) {
|
|
1362
|
+
rec.status = FORK_STATUS.STOPPED
|
|
1363
|
+
}
|
|
1364
|
+
if (rec.port) releaseOwnedPort(rec.port, rec.forkId)
|
|
1365
|
+
return { ok: true, forkId: rec.forkId, pid, killed: wasRunning }
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
/** Kill the process tree. Idempotent: an already-dead fork returns ok with killed:false. */
|
|
1369
|
+
const stop = (forkId) => {
|
|
1370
|
+
ensureRegistry()
|
|
1371
|
+
ensurePorts()
|
|
1372
|
+
const id = String(forkId ?? '').trim()
|
|
1373
|
+
const rec = records.get(id)
|
|
1374
|
+
if (!rec) return { ok: false, error: 'not-found', forkId: id }
|
|
1375
|
+
const res = stopRecord(rec)
|
|
1376
|
+
persist()
|
|
1377
|
+
return res
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
/** stop + removeTree(homeDir) + registry delete. removeTree is junction-safe. */
|
|
1381
|
+
const reap = (forkId) => {
|
|
1382
|
+
ensureRegistry()
|
|
1383
|
+
ensurePorts()
|
|
1384
|
+
const id = String(forkId ?? '').trim()
|
|
1385
|
+
const rec = records.get(id)
|
|
1386
|
+
if (!rec) return { ok: false, error: 'not-found', forkId: id }
|
|
1387
|
+
stop(id)
|
|
1388
|
+
rec.status = FORK_STATUS.REAPED
|
|
1389
|
+
writeForkMetaOf(rec)
|
|
1390
|
+
const homeDir = rec.homeDir ? path.resolve(rec.homeDir) : ''
|
|
1391
|
+
let removed = false
|
|
1392
|
+
if (homeDir && isSafeToRemove(homeDir)) removed = exists(homeDir) ? removeTree(homeDir) : false
|
|
1393
|
+
records.delete(id)
|
|
1394
|
+
persist()
|
|
1395
|
+
emit(EVENTS.reaped, { forkId: id })
|
|
1396
|
+
audit(AUDIT.FORK_REAPED, { forkId: id, homeDir, removed })
|
|
1397
|
+
return { ok: true, forkId: id, removed, homeDir }
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// ───────────────────────────── introspection ─────────────────────────────
|
|
1401
|
+
const status = (forkId) => {
|
|
1402
|
+
ensureRegistry()
|
|
1403
|
+
const id = String(forkId ?? '').trim()
|
|
1404
|
+
const rec = records.get(id)
|
|
1405
|
+
if (!rec) return { forkId: id, running: false, pid: null, error: 'not-found' }
|
|
1406
|
+
return publicFork(rec)
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
const list = () => {
|
|
1410
|
+
ensureRegistry()
|
|
1411
|
+
return [...records.values()]
|
|
1412
|
+
.map(publicFork)
|
|
1413
|
+
.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0) || String(a.forkId).localeCompare(String(b.forkId)))
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
const logs = (forkId, tailLines = 40) => {
|
|
1417
|
+
ensureRegistry()
|
|
1418
|
+
const rec = records.get(String(forkId ?? '').trim())
|
|
1419
|
+
if (!rec || !rec.log) return ''
|
|
1420
|
+
return rec.log.tail(tailLines)
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
/**
|
|
1424
|
+
* v3 Adapter Bearer token(内存 only)。母体用它给 Remote/AdapterClient 加
|
|
1425
|
+
* `Authorization: Bearer`;**不要**把它塞进 status()/publicFork()(那会随
|
|
1426
|
+
* `/fang/api/status` 暴露给浏览器,也会落 forks.json)。
|
|
1427
|
+
* @returns {string} fork 不存在或已停止时为空串
|
|
1428
|
+
*/
|
|
1429
|
+
const adapterToken = (forkId) => {
|
|
1430
|
+
ensureRegistry()
|
|
1431
|
+
const rec = records.get(String(forkId ?? '').trim())
|
|
1432
|
+
return typeof rec?.adapterToken === 'string' ? rec.adapterToken : ''
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
/**
|
|
1436
|
+
* v3.0.5/B4:只在内存中返回带 token 的 Web URL,用于“打开分身”。
|
|
1437
|
+
* 该值绝不落盘、不进入 publicFork/list/status。
|
|
1438
|
+
*/
|
|
1439
|
+
const openUrl = (forkId) => {
|
|
1440
|
+
ensureRegistry()
|
|
1441
|
+
const rec = records.get(String(forkId ?? '').trim())
|
|
1442
|
+
if (!rec || !rec.running || !rec.url) return { ok: false, error: 'not-running' }
|
|
1443
|
+
return { ok: true, forkId: rec.forkId, url: rec.url, port: rec.port ?? null }
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
/** Re-spawn a crashed-but-ready fork after the backoff timer fires. */
|
|
1447
|
+
const restartFork = async (rec) => {
|
|
1448
|
+
if (disposed || rec.intentionalStop || records.get(rec.forkId) !== rec) return
|
|
1449
|
+
if (!rec.cliPath) {
|
|
1450
|
+
rec.lastError = 'restart-no-cli'
|
|
1451
|
+
writeForkMetaOf(rec)
|
|
1452
|
+
emit(EVENTS.restartFailed, { forkId: rec.forkId, error: 'restart-no-cli', terminal: true })
|
|
1453
|
+
return
|
|
1454
|
+
}
|
|
1455
|
+
rec.ready = false
|
|
1456
|
+
const out = await spawn({
|
|
1457
|
+
forkId: rec.forkId, homeDir: rec.homeDir, profile: rec.profile,
|
|
1458
|
+
cliPath: rec.cliPath, taskTitle: rec.taskTitle ?? '',
|
|
1459
|
+
})
|
|
1460
|
+
if (!out.ok && !disposed && records.get(rec.forkId) === rec) {
|
|
1461
|
+
rec.lastError = `restart-failed:${out.error}`
|
|
1462
|
+
writeForkMetaOf(rec)
|
|
1463
|
+
// 自动重启尝试失败:Forker 可能还会按 supervise 计划再试,因此 terminal=false。
|
|
1464
|
+
emit(EVENTS.restartFailed, {
|
|
1465
|
+
forkId: rec.forkId,
|
|
1466
|
+
error: String(out.error ?? 'restart-failed'),
|
|
1467
|
+
terminal: false,
|
|
1468
|
+
})
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
/** HTTP liveness for a fork (any response counts); process state is reported separately. */
|
|
1473
|
+
const health = async (forkId) => {
|
|
1474
|
+
ensureRegistry()
|
|
1475
|
+
ensurePorts()
|
|
1476
|
+
const id = String(forkId ?? '').trim()
|
|
1477
|
+
const rec = records.get(id)
|
|
1478
|
+
if (!rec) return { ok: false, error: 'not-found', forkId: id }
|
|
1479
|
+
const alive = !!rec.running && !!rec.child && rec.child.exitCode === null
|
|
1480
|
+
const http = alive && rec.port ? await probeHttpAlive(rec.port, { path: cfg.healthPath }) : false
|
|
1481
|
+
return { ok: true, forkId: id, status: rec.status, running: alive, pid: rec.pid ?? null, port: rec.port ?? null, http, url: rec.url ?? null }
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
/** Reconcile persisted records after a mother restart; never kills a live foreign pid. */
|
|
1485
|
+
const recover = async () => {
|
|
1486
|
+
ensureRegistry()
|
|
1487
|
+
ensurePorts()
|
|
1488
|
+
const actions = reconcileOrphans(
|
|
1489
|
+
[...records.values()].map((rec) => ({ forkId: rec.forkId, status: rec.status, pid: rec.pid })),
|
|
1490
|
+
isPidAlive,
|
|
1491
|
+
)
|
|
1492
|
+
for (const action of actions) {
|
|
1493
|
+
if (action.action !== 'mark-crashed') continue
|
|
1494
|
+
const rec = records.get(action.forkId)
|
|
1495
|
+
if (!rec) continue
|
|
1496
|
+
rec.pid = null
|
|
1497
|
+
rec.running = false
|
|
1498
|
+
rec.child = null
|
|
1499
|
+
rec.ready = false
|
|
1500
|
+
rec.status = FORK_STATUS.CRASHED
|
|
1501
|
+
rec.lastError = `orphaned:${action.reason}`
|
|
1502
|
+
if (rec.port) releaseOwnedPort(rec.port, rec.forkId)
|
|
1503
|
+
writeForkMetaOf(rec)
|
|
1504
|
+
}
|
|
1505
|
+
persist()
|
|
1506
|
+
return { ok: true, actions, forks: actions.filter((a) => a.action === 'mark-crashed').length }
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
/** 真实进程存活判断(用于归档确认 stop 真的生效,而不是只看 rec.running 状态)。 */
|
|
1510
|
+
const alive = (forkId) => {
|
|
1511
|
+
ensureRegistry()
|
|
1512
|
+
const rec = records.get(String(forkId ?? '').trim())
|
|
1513
|
+
if (!rec) return false
|
|
1514
|
+
const pid = rec.pid ?? rec.child?.pid ?? null
|
|
1515
|
+
return isPidAlive(pid)
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
const service = {
|
|
1519
|
+
role: gate.role,
|
|
1520
|
+
provision,
|
|
1521
|
+
spawn,
|
|
1522
|
+
stop,
|
|
1523
|
+
reap,
|
|
1524
|
+
alive,
|
|
1525
|
+
health,
|
|
1526
|
+
recover,
|
|
1527
|
+
status,
|
|
1528
|
+
list,
|
|
1529
|
+
logs,
|
|
1530
|
+
adapterToken,
|
|
1531
|
+
getToken: adapterToken,
|
|
1532
|
+
openUrl,
|
|
1533
|
+
onTokenRefresh(handler) {
|
|
1534
|
+
if (typeof handler !== 'function') return () => {}
|
|
1535
|
+
tokenRefreshHandlers.add(handler)
|
|
1536
|
+
return () => tokenRefreshHandlers.delete(handler)
|
|
1537
|
+
},
|
|
1538
|
+
allocatePort,
|
|
1539
|
+
releasePort,
|
|
1540
|
+
ensureTemplate,
|
|
1541
|
+
portPool: () => ({ ...portPool }),
|
|
1542
|
+
ports: () => {
|
|
1543
|
+
ensurePorts()
|
|
1544
|
+
return portsSnapshot()
|
|
1545
|
+
},
|
|
1546
|
+
get config() {
|
|
1547
|
+
return { ...cfg }
|
|
1548
|
+
},
|
|
1549
|
+
/** 运行时看护状态;enabled 可在不重启宿主的情况下切换。 */
|
|
1550
|
+
getSupervision() {
|
|
1551
|
+
return {
|
|
1552
|
+
enabled: runtimeSupervise,
|
|
1553
|
+
configured: cfg.supervise === true,
|
|
1554
|
+
maxRestarts: cfg.maxRestarts,
|
|
1555
|
+
backoffBaseMs: cfg.backoffBaseMs,
|
|
1556
|
+
backoffMaxMs: cfg.backoffMaxMs,
|
|
1557
|
+
backoffJitter: cfg.backoffJitter,
|
|
1558
|
+
healthPath: cfg.healthPath,
|
|
1559
|
+
}
|
|
1560
|
+
},
|
|
1561
|
+
setSupervise(enabled) {
|
|
1562
|
+
runtimeSupervise = enabled === true
|
|
1563
|
+
try { audit(AUDIT.FORK_SUPERVISION, { enabled: runtimeSupervise }) } catch { /* audit optional */ }
|
|
1564
|
+
return { ok: true, enabled: runtimeSupervise }
|
|
1565
|
+
},
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
ctx.effect(() => ctx.provide(SERVICE.forker, service))
|
|
1569
|
+
// Plugin unload must never leave orphan children (or leaked pool ports) behind.
|
|
1570
|
+
ctx.effect(() => () => {
|
|
1571
|
+
disposed = true
|
|
1572
|
+
for (const pending of [...pendingSpawns]) {
|
|
1573
|
+
try {
|
|
1574
|
+
pending()
|
|
1575
|
+
} catch {
|
|
1576
|
+
/* ignore */
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
for (const rec of records.values()) {
|
|
1580
|
+
if (rec.restartTimer) { clearTimeout(rec.restartTimer); rec.restartTimer = null }
|
|
1581
|
+
if (rec.running || rec.child || rec.starting) {
|
|
1582
|
+
rec.intentionalStop = true
|
|
1583
|
+
killTree(rec)
|
|
1584
|
+
rec.running = false
|
|
1585
|
+
rec.child = null
|
|
1586
|
+
rec.starting = false
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
for (const [port, forkId] of [...ownedPorts]) {
|
|
1590
|
+
try {
|
|
1591
|
+
releaseOwnedPort(port, forkId)
|
|
1592
|
+
} catch {
|
|
1593
|
+
/* best effort on unload */
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
persist()
|
|
1597
|
+
})
|
|
1598
|
+
return service
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
export const _internals = {
|
|
1602
|
+
createTextTail,
|
|
1603
|
+
safeProfile,
|
|
1604
|
+
isPidAlive,
|
|
1605
|
+
probePortFree,
|
|
1606
|
+
DEFAULTS,
|
|
1607
|
+
EVENTS,
|
|
1608
|
+
DEFAULT_PORT_POOL,
|
|
1609
|
+
CLEAN_TEMPLATE_BUNDLES,
|
|
1610
|
+
}
|