@twinsearth/fang-dsh-plugin 0.0.0-stage → 3.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLA.md +79 -0
- package/LICENSE-COMMERCIAL +78 -0
- package/LICENSE-CORE +661 -0
- package/LICENSE-SDK +21 -0
- package/LICENSES/cordis-MIT.txt +39 -0
- package/NOTICE +95 -0
- package/README.md +158 -2
- package/client.js +7287 -0
- package/cordis.patch.yml +163 -0
- package/defaults/umbrella-rules.yaml +113 -0
- package/docs/CONTRACT-INTEROP.md +77 -0
- package/docs/CRASH-SAFETY.md +69 -0
- package/docs/DECISION-RELAY.md +46 -0
- package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
- package/docs/FORK-SEED.md +64 -0
- package/docs/MAIN-CITY-GATE.md +58 -0
- package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
- package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
- package/docs/PM-DECISION-POLICY.md +61 -0
- package/docs/WORKFLOW-UI.md +86 -0
- package/icon.svg +8 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +71 -4
- package/sdk/README.md +65 -0
- package/sdk/examples/status-badge/README.md +16 -0
- package/sdk/examples/status-badge/client.js +60 -0
- package/sdk/examples/status-badge/cordis.patch.yml +7 -0
- package/sdk/examples/status-badge/index.js +12 -0
- package/sdk/examples/status-badge/package.json +24 -0
- package/sdk/templates/plugin/README.md +36 -0
- package/sdk/templates/plugin/client.js +35 -0
- package/sdk/templates/plugin/cordis.patch.yml +8 -0
- package/sdk/templates/plugin/icon.svg +5 -0
- package/sdk/templates/plugin/index.js +23 -0
- package/sdk/templates/plugin/locale/en.json +6 -0
- package/sdk/templates/plugin/package.json +36 -0
- package/src/compose.mjs +67 -0
- package/src/contract/VENDOR.json +16 -0
- package/src/contract/client.js +93 -0
- package/src/contract/constants.js +94 -0
- package/src/contract/envelope.js +83 -0
- package/src/contract/index.js +20 -0
- package/src/contract/negotiate.js +106 -0
- package/src/contract/relay.js +223 -0
- package/src/contract/server.js +77 -0
- package/src/contract/transports.js +319 -0
- package/src/index.mjs +11 -0
- package/src/kernel/core.mjs +100 -0
- package/src/kernel/extensions.mjs +61 -0
- package/src/kernel/fsx.mjs +349 -0
- package/src/kernel/index.mjs +21 -0
- package/src/kernel/ledger.mjs +94 -0
- package/src/kernel/paths.mjs +182 -0
- package/src/kernel/role.mjs +68 -0
- package/src/kernel/rounds.mjs +172 -0
- package/src/kernel/schema.mjs +5 -0
- package/src/kernel/types.mjs +266 -0
- package/src/kernel/vendor/cosmokit.mjs +421 -0
- package/src/kernel/vendor/schemastery.mjs +638 -0
- package/src/lib/adapter-client.mjs +383 -0
- package/src/lib/adapter-protocol.mjs +243 -0
- package/src/lib/ai-secret-store.mjs +202 -0
- package/src/lib/boot-guard.mjs +186 -0
- package/src/lib/branch-store.mjs +84 -0
- package/src/lib/branch.mjs +74 -0
- package/src/lib/bridge-client.mjs +133 -0
- package/src/lib/broker-adapter.mjs +67 -0
- package/src/lib/broker.mjs +45 -0
- package/src/lib/business-producers.mjs +109 -0
- package/src/lib/command-cache.mjs +209 -0
- package/src/lib/compat.mjs +170 -0
- package/src/lib/composer-journal.mjs +128 -0
- package/src/lib/container/index.mjs +806 -0
- package/src/lib/decision-events.mjs +129 -0
- package/src/lib/decision-ingest-client.mjs +41 -0
- package/src/lib/decision-outbox.mjs +129 -0
- package/src/lib/decision-policy.mjs +52 -0
- package/src/lib/default-essence.mjs +61 -0
- package/src/lib/desktop-launch.mjs +138 -0
- package/src/lib/essence.mjs +1211 -0
- package/src/lib/fork-client.mjs +410 -0
- package/src/lib/fork-config-lock.mjs +166 -0
- package/src/lib/fork-seed.mjs +304 -0
- package/src/lib/fork-sessions.mjs +183 -0
- package/src/lib/fork-supervision-settings.mjs +58 -0
- package/src/lib/fork-supervisor.mjs +122 -0
- package/src/lib/fs-guard.mjs +28 -0
- package/src/lib/http-auth.mjs +162 -0
- package/src/lib/idle-audit.mjs +178 -0
- package/src/lib/interaction-poke.mjs +61 -0
- package/src/lib/main-gate.mjs +579 -0
- package/src/lib/mirror-etag.mjs +92 -0
- package/src/lib/mirror-official-source.mjs +93 -0
- package/src/lib/mirror.mjs +478 -0
- package/src/lib/mother-link.mjs +156 -0
- package/src/lib/mutex.mjs +31 -0
- package/src/lib/permissions.mjs +115 -0
- package/src/lib/pnpm-store.mjs +189 -0
- package/src/lib/preferences.mjs +212 -0
- package/src/lib/producer-contract.mjs +162 -0
- package/src/lib/reflow.mjs +41 -0
- package/src/lib/remote.mjs +408 -0
- package/src/lib/restart-contract.mjs +752 -0
- package/src/lib/roots.mjs +30 -0
- package/src/lib/rule-registry.mjs +1127 -0
- package/src/lib/runtime-capture.mjs +307 -0
- package/src/lib/semaphore.mjs +34 -0
- package/src/lib/session-bundle.mjs +138 -0
- package/src/lib/session-format.mjs +672 -0
- package/src/lib/sse-parser.mjs +102 -0
- package/src/lib/ssrf-guard.mjs +170 -0
- package/src/lib/status-store.mjs +257 -0
- package/src/lib/task-handle.mjs +622 -0
- package/src/lib/ui-actions.mjs +55 -0
- package/src/lib/umbrella.mjs +1661 -0
- package/src/lib/upgrade-coordinator.mjs +107 -0
- package/src/lib/upgrade-status.mjs +43 -0
- package/src/plugins/adapter.mjs +1468 -0
- package/src/plugins/approver.mjs +1210 -0
- package/src/plugins/auditor.mjs +330 -0
- package/src/plugins/buffer-keeper.mjs +522 -0
- package/src/plugins/decision-center.mjs +2581 -0
- package/src/plugins/forker.mjs +1610 -0
- package/src/plugins/healer.mjs +702 -0
- package/src/plugins/hot-reloader.mjs +274 -0
- package/src/plugins/imports.mjs +247 -0
- package/src/plugins/orchestrator.mjs +3938 -0
- package/src/plugins/restart-guard.mjs +1022 -0
- package/src/plugins/router.mjs +714 -0
- package/src/plugins/scorer.mjs +661 -0
- package/src/plugins/session-bridge.mjs +166 -0
- package/src/plugins/session-catalog.mjs +771 -0
- package/src/plugins/session-router.mjs +125 -0
- package/src/plugins/session-sync.mjs +171 -0
- package/src/plugins/umbrella.mjs +419 -0
- package/tools/boot-guard.mjs +95 -0
- package/tools/decision-ingest.mjs +53 -0
- package/tools/desktop-launch.mjs +71 -0
- package/tools/fangctl.mjs +408 -0
- package/tools/gate/main-city.mjs +204 -0
- package/tools/mcp/server.mjs +121 -0
- package/tools/mcp/tools.mjs +65 -0
- package/tools/recover-queue.mjs +133 -0
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
|
|
3
|
+
// dsh-Fang v2.6.7 — TaskHandle:Forker 进程层 + ForkClient 命令层的统一生命周期对象。
|
|
4
|
+
//
|
|
5
|
+
// 契约:docs/TASK-HANDLE.md。
|
|
6
|
+
// 职责:
|
|
7
|
+
// * start():确保 fork 已运行(可选 spawn)→ 用 forker 的 token 建 ForkClient → 连接;
|
|
8
|
+
// * 崩溃:监听 forker 的 fang/fork-crashed 事件 → reconnecting → 等 fang/fork-started
|
|
9
|
+
// (或轮询 status)→ 用**新 token/新端口**重建 client;
|
|
10
|
+
// * 命令代理:转发到 ForkClient;重连窗口内短等待(commandWaitMs),之后 reject;
|
|
11
|
+
// * 生命周期:stop(保留 home)/ recycle(stop + reap);stop/recycle 幂等;
|
|
12
|
+
// * 事件:onEvent(SSE 事件)+ onTaskEvent(状态/崩溃/重启/重连/失败)。
|
|
13
|
+
//
|
|
14
|
+
// 与 fork-client.mjs 的分工:ForkClient 处理「同一个分身进程内的 SSE 断线/游标/命令 inflight」;
|
|
15
|
+
// TaskHandle 处理「整个分身进程崩溃重启」——端口与 Adapter token 都会变,必须重建 client。
|
|
16
|
+
import { bus as kernelBus } from '../kernel/core.mjs'
|
|
17
|
+
import { ADAPTER_OP } from '../kernel/types.mjs'
|
|
18
|
+
import { createForkClient } from './fork-client.mjs'
|
|
19
|
+
|
|
20
|
+
export const TASK_HANDLE_STATE = Object.freeze({
|
|
21
|
+
IDLE: 'idle',
|
|
22
|
+
STARTING: 'starting',
|
|
23
|
+
RUNNING: 'running',
|
|
24
|
+
RECONNECTING: 'reconnecting',
|
|
25
|
+
STOPPING: 'stopping',
|
|
26
|
+
STOPPED: 'stopped',
|
|
27
|
+
FAILED: 'failed',
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
export const TASK_HANDLE_DEFAULTS = Object.freeze({
|
|
31
|
+
commandWaitMs: 5_000,
|
|
32
|
+
restartTimeoutMs: 60_000,
|
|
33
|
+
pollMs: 250,
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
export const FORK_EVENT = Object.freeze({
|
|
37
|
+
STARTED: 'fang/fork-started',
|
|
38
|
+
CRASHED: 'fang/fork-crashed',
|
|
39
|
+
REAPED: 'fang/fork-reaped',
|
|
40
|
+
/** v2.7.3:自动重启失败/放弃重启;payload { forkId, error, terminal }。 */
|
|
41
|
+
RESTART_FAILED: 'fang/fork-restart-failed',
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const msgOf = (e) => String(e && e.message !== undefined ? e.message : e)
|
|
45
|
+
|
|
46
|
+
function makeError(message, extra = null) {
|
|
47
|
+
const error = new Error(message)
|
|
48
|
+
if (extra && typeof extra === 'object') Object.assign(error, extra)
|
|
49
|
+
return error
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function positiveMs(value, fallback) {
|
|
53
|
+
const n = Number(value)
|
|
54
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function requireForker(forker) {
|
|
58
|
+
const required = ['status', 'adapterToken', 'stop', 'reap']
|
|
59
|
+
for (const method of required) {
|
|
60
|
+
if (typeof forker?.[method] !== 'function') {
|
|
61
|
+
throw new TypeError(`task handle forker is missing ${method}()`)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return forker
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @typedef {object} TaskHandleOptions
|
|
69
|
+
* @property {string} taskId
|
|
70
|
+
* @property {object} forker repo 的 fangForker 服务(status/adapterToken/stop/reap/spawn)
|
|
71
|
+
* @property {(opts:object)=>object} [createClient] 默认 createForkClient
|
|
72
|
+
* @property {object|null} [spawnOptions] 若 fork 尚未运行,start() 用它调用 forker.spawn
|
|
73
|
+
* @property {{on:(topic:string,fn:Function)=>()=>void}} [eventSource] 默认 kernel bus
|
|
74
|
+
* @property {number} [commandWaitMs=5000]
|
|
75
|
+
* @property {number} [restartTimeoutMs=60000]
|
|
76
|
+
* @property {number} [pollMs=250]
|
|
77
|
+
* @property {{debug?:Function,warn?:Function}} [logger]
|
|
78
|
+
*/
|
|
79
|
+
|
|
80
|
+
export class TaskHandle {
|
|
81
|
+
/** @param {TaskHandleOptions} options */
|
|
82
|
+
constructor(options = {}) {
|
|
83
|
+
const opts = options && typeof options === 'object' ? options : {}
|
|
84
|
+
this.taskId = String(opts.taskId ?? '').trim()
|
|
85
|
+
if (!this.taskId) throw new TypeError('TaskHandle requires a taskId')
|
|
86
|
+
this.forker = requireForker(opts.forker)
|
|
87
|
+
this.createClient = typeof opts.createClient === 'function' ? opts.createClient : (clientOptions) => createForkClient(clientOptions)
|
|
88
|
+
this.spawnOptions = opts.spawnOptions && typeof opts.spawnOptions === 'object' ? { ...opts.spawnOptions } : null
|
|
89
|
+
this.eventSource = opts.eventSource && typeof opts.eventSource.on === 'function' ? opts.eventSource : kernelBus
|
|
90
|
+
this.commandWaitMs = positiveMs(opts.commandWaitMs, TASK_HANDLE_DEFAULTS.commandWaitMs)
|
|
91
|
+
this.restartTimeoutMs = positiveMs(opts.restartTimeoutMs, TASK_HANDLE_DEFAULTS.restartTimeoutMs)
|
|
92
|
+
this.pollMs = positiveMs(opts.pollMs, TASK_HANDLE_DEFAULTS.pollMs)
|
|
93
|
+
this.logger = opts.logger && typeof opts.logger === 'object' ? opts.logger : {}
|
|
94
|
+
|
|
95
|
+
this.state = TASK_HANDLE_STATE.IDLE
|
|
96
|
+
this.meta = null
|
|
97
|
+
this.client = null
|
|
98
|
+
this.clientUnsubs = []
|
|
99
|
+
this.busUnsubs = []
|
|
100
|
+
this.eventsAttached = false
|
|
101
|
+
this.startPromise = null
|
|
102
|
+
this.stopPromise = null
|
|
103
|
+
this.connectPromise = null
|
|
104
|
+
this.crashHandler = null
|
|
105
|
+
this.restartSlot = null
|
|
106
|
+
this.disposed = false
|
|
107
|
+
|
|
108
|
+
/** @type {Set<{resolve:Function,reject:Function,timer:any}>} */
|
|
109
|
+
this.waiters = new Set()
|
|
110
|
+
this.taskEventHandlers = new Set()
|
|
111
|
+
this.eventHandlers = new Set()
|
|
112
|
+
|
|
113
|
+
// 绑定一次,避免每次订阅产生新函数(unsubscribe 依赖同一个引用)
|
|
114
|
+
this.onForkStarted = (payload) => this.handleForkStarted(payload)
|
|
115
|
+
this.onForkCrashed = (payload) => this.handleForkCrashed(payload)
|
|
116
|
+
this.onForkReaped = (payload) => this.handleForkReaped(payload)
|
|
117
|
+
this.onForkRestartFailed = (payload) => this.handleForkRestartFailed(payload)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
get status() {
|
|
121
|
+
return this.state
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
get taskMeta() {
|
|
125
|
+
return this.meta
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── 生命周期 ──────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
/** 启动:确保 fork 运行 → 建 client → connect。幂等(重复调用返回同一 promise)。 */
|
|
131
|
+
start() {
|
|
132
|
+
if (this.startPromise) return this.startPromise
|
|
133
|
+
if (this.state !== TASK_HANDLE_STATE.IDLE) {
|
|
134
|
+
return Promise.reject(makeError(`cannot start in status ${this.state}`, { code: 'bad-state' }))
|
|
135
|
+
}
|
|
136
|
+
this.startPromise = this.doStart().finally(() => {
|
|
137
|
+
this.startPromise = null
|
|
138
|
+
})
|
|
139
|
+
return this.startPromise
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async doStart() {
|
|
143
|
+
this.setState(TASK_HANDLE_STATE.STARTING)
|
|
144
|
+
try {
|
|
145
|
+
this.attachForkEvents()
|
|
146
|
+
let live = this.forker.status(this.taskId)
|
|
147
|
+
if (!live || !live.running) {
|
|
148
|
+
if (!this.spawnOptions) {
|
|
149
|
+
throw makeError('fork is not running and no spawnOptions were provided', { code: 'fork-not-running' })
|
|
150
|
+
}
|
|
151
|
+
if (typeof this.forker.spawn !== 'function') {
|
|
152
|
+
throw makeError('forker.spawn is not available', { code: 'spawn-unavailable' })
|
|
153
|
+
}
|
|
154
|
+
const spawned = await this.forker.spawn({ forkId: this.taskId, ...this.spawnOptions })
|
|
155
|
+
if (!spawned || spawned.ok !== true) {
|
|
156
|
+
throw makeError(`fork spawn failed: ${spawned?.error ?? 'unknown'}`, {
|
|
157
|
+
code: 'spawn-failed',
|
|
158
|
+
detail: spawned,
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
live = this.forker.status(this.taskId)
|
|
162
|
+
}
|
|
163
|
+
this.meta = live
|
|
164
|
+
await this.connectClient()
|
|
165
|
+
this.setState(TASK_HANDLE_STATE.RUNNING)
|
|
166
|
+
} catch (e) {
|
|
167
|
+
this.setState(TASK_HANDLE_STATE.FAILED)
|
|
168
|
+
this.emitTaskEvent({ kind: 'failed', reason: msgOf(e) })
|
|
169
|
+
this.rejectWaiters(e)
|
|
170
|
+
throw e
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 停止:取消订阅 → reject 等待中的命令 → 断开 client → forker.stop。
|
|
176
|
+
* 与 recycle 的区别:stop 保留 fork home,recycle 之后会 reap 删除。
|
|
177
|
+
*/
|
|
178
|
+
stop(options = {}) {
|
|
179
|
+
if (this.stopPromise) return this.stopPromise
|
|
180
|
+
if (this.state === TASK_HANDLE_STATE.STOPPED || this.state === TASK_HANDLE_STATE.IDLE) {
|
|
181
|
+
return Promise.resolve()
|
|
182
|
+
}
|
|
183
|
+
this.stopPromise = this.doStop(options).finally(() => {
|
|
184
|
+
this.stopPromise = null
|
|
185
|
+
})
|
|
186
|
+
return this.stopPromise
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async doStop(options = {}) {
|
|
190
|
+
this.setState(TASK_HANDLE_STATE.STOPPING)
|
|
191
|
+
this.detachForkEvents()
|
|
192
|
+
const stopping = makeError('task is stopping', { code: 'task-stopping' })
|
|
193
|
+
this.restartSlot?.finish(stopping)
|
|
194
|
+
this.rejectWaiters(stopping)
|
|
195
|
+
await this.detachClient()
|
|
196
|
+
try {
|
|
197
|
+
await this.forker.stop(this.taskId)
|
|
198
|
+
} catch (e) {
|
|
199
|
+
this.logger.warn?.(`[task-handle] stop failed: ${msgOf(e)}`)
|
|
200
|
+
}
|
|
201
|
+
this.setState(TASK_HANDLE_STATE.STOPPED)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** 停止并回收(删除 fork home)。返回 forker.reap 的结果。 */
|
|
205
|
+
async recycle(options = {}) {
|
|
206
|
+
if (this.state === TASK_HANDLE_STATE.STOPPED) {
|
|
207
|
+
return this.forker.reap(this.taskId)
|
|
208
|
+
}
|
|
209
|
+
await this.stop({ graceful: options?.graceful !== false })
|
|
210
|
+
return this.forker.reap(this.taskId)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** 释放本地资源(不杀 fork;用于宿主卸载)。 */
|
|
214
|
+
async dispose() {
|
|
215
|
+
if (this.disposed) return
|
|
216
|
+
this.disposed = true
|
|
217
|
+
this.detachForkEvents()
|
|
218
|
+
this.restartSlot?.finish(makeError('task handle disposed', { code: 'disposed' }))
|
|
219
|
+
this.rejectWaiters(makeError('task handle disposed', { code: 'disposed' }))
|
|
220
|
+
await this.detachClient()
|
|
221
|
+
this.setState(TASK_HANDLE_STATE.STOPPED)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── 连接 ──────────────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
connectClient() {
|
|
227
|
+
if (this.connectPromise) return this.connectPromise
|
|
228
|
+
this.connectPromise = this.doConnect().finally(() => {
|
|
229
|
+
this.connectPromise = null
|
|
230
|
+
})
|
|
231
|
+
return this.connectPromise
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async doConnect() {
|
|
235
|
+
const live = this.forker.status(this.taskId)
|
|
236
|
+
if (!live || !live.running || !live.url) {
|
|
237
|
+
throw makeError('fork is not running', { code: 'fork-not-running' })
|
|
238
|
+
}
|
|
239
|
+
const adapterToken = String(this.forker.adapterToken?.(this.taskId) ?? '')
|
|
240
|
+
if (!adapterToken) {
|
|
241
|
+
throw makeError('fork adapter token is not available', { code: 'fork-token-missing' })
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const client = this.createClient({ url: live.url, adapterToken, taskId: this.taskId })
|
|
245
|
+
const unsubs = []
|
|
246
|
+
unsubs.push(client.onEvent((envelope) => this.emitEvent(envelope)))
|
|
247
|
+
unsubs.push(client.onReconnect((payload) => {
|
|
248
|
+
this.emitTaskEvent({
|
|
249
|
+
kind: 'reconnected',
|
|
250
|
+
resetCursor: !!(payload && payload.resetCursor),
|
|
251
|
+
meta: this.meta,
|
|
252
|
+
})
|
|
253
|
+
}))
|
|
254
|
+
unsubs.push(client.onDisconnect((payload) => {
|
|
255
|
+
// SSE 断开 ≠ 进程退出:状态由 fork 事件决定,这里只记录(可等 ForkClient 自己重连)。
|
|
256
|
+
this.logger.warn?.(`[task-handle] adapter stream disconnected: ${payload?.reason ?? 'unknown'}`)
|
|
257
|
+
}))
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
const result = await client.connect()
|
|
261
|
+
if (result && result.ok === false) {
|
|
262
|
+
throw makeError(`adapter connect failed: ${result.code ?? 'unknown'}`, {
|
|
263
|
+
code: result.code ?? 'connect-failed',
|
|
264
|
+
fatal: !!result.fatal,
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
} catch (e) {
|
|
268
|
+
for (const off of unsubs) {
|
|
269
|
+
try {
|
|
270
|
+
off()
|
|
271
|
+
} catch {
|
|
272
|
+
/* ignore */
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
try {
|
|
276
|
+
await client.disconnect()
|
|
277
|
+
} catch {
|
|
278
|
+
/* ignore */
|
|
279
|
+
}
|
|
280
|
+
throw e
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const previous = this.client
|
|
284
|
+
const previousUnsubs = this.clientUnsubs
|
|
285
|
+
this.client = client
|
|
286
|
+
this.clientUnsubs = unsubs
|
|
287
|
+
this.meta = live
|
|
288
|
+
if (previous) {
|
|
289
|
+
for (const off of previousUnsubs) {
|
|
290
|
+
try {
|
|
291
|
+
off()
|
|
292
|
+
} catch {
|
|
293
|
+
/* ignore */
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
try {
|
|
297
|
+
await previous.disconnect()
|
|
298
|
+
} catch {
|
|
299
|
+
/* ignore */
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return client
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async detachClient() {
|
|
306
|
+
const client = this.client
|
|
307
|
+
this.client = null
|
|
308
|
+
for (const off of this.clientUnsubs.splice(0)) {
|
|
309
|
+
try {
|
|
310
|
+
off()
|
|
311
|
+
} catch {
|
|
312
|
+
/* ignore */
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (client) {
|
|
316
|
+
try {
|
|
317
|
+
await client.disconnect()
|
|
318
|
+
} catch {
|
|
319
|
+
/* ignore */
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ── fork 事件与崩溃恢复 ───────────────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
attachForkEvents() {
|
|
327
|
+
if (this.eventsAttached) return
|
|
328
|
+
this.eventsAttached = true
|
|
329
|
+
this.busUnsubs.push(this.eventSource.on(FORK_EVENT.STARTED, this.onForkStarted))
|
|
330
|
+
this.busUnsubs.push(this.eventSource.on(FORK_EVENT.CRASHED, this.onForkCrashed))
|
|
331
|
+
this.busUnsubs.push(this.eventSource.on(FORK_EVENT.REAPED, this.onForkReaped))
|
|
332
|
+
this.busUnsubs.push(this.eventSource.on(FORK_EVENT.RESTART_FAILED, this.onForkRestartFailed))
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
detachForkEvents() {
|
|
336
|
+
this.eventsAttached = false
|
|
337
|
+
for (const off of this.busUnsubs.splice(0)) {
|
|
338
|
+
try {
|
|
339
|
+
off()
|
|
340
|
+
} catch {
|
|
341
|
+
/* ignore */
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
handleForkStarted(payload) {
|
|
347
|
+
if (String(payload?.forkId ?? '') !== this.taskId) return
|
|
348
|
+
if (this.state !== TASK_HANDLE_STATE.RECONNECTING) return
|
|
349
|
+
const live = this.forker.status(this.taskId)
|
|
350
|
+
if (!live || !live.running || !live.url) return
|
|
351
|
+
this.restartSlot?.finish(null, live)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
handleForkCrashed(payload) {
|
|
355
|
+
if (String(payload?.forkId ?? '') !== this.taskId) return
|
|
356
|
+
if (this.state !== TASK_HANDLE_STATE.RUNNING) return
|
|
357
|
+
if (this.crashHandler) return
|
|
358
|
+
this.crashHandler = this.handleCrash(payload)
|
|
359
|
+
.catch((e) => this.logger.warn?.(`[task-handle] crash handler failed: ${msgOf(e)}`))
|
|
360
|
+
.finally(() => {
|
|
361
|
+
this.crashHandler = null
|
|
362
|
+
})
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
handleForkReaped(payload) {
|
|
366
|
+
if (String(payload?.forkId ?? '') !== this.taskId) return
|
|
367
|
+
if (this.state === TASK_HANDLE_STATE.STOPPING || this.state === TASK_HANDLE_STATE.STOPPED) return
|
|
368
|
+
this.detachForkEvents()
|
|
369
|
+
void this.detachClient()
|
|
370
|
+
this.setState(TASK_HANDLE_STATE.STOPPED)
|
|
371
|
+
this.rejectWaiters(makeError('task was reaped', { code: 'task-reaped' }))
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* v2.7.3:Forker 自动重启失败事件。
|
|
376
|
+
* - terminal=true(放弃重启/无 CLI)→ 立即结束等待,进入 failed(不等 restartTimeoutMs);
|
|
377
|
+
* - terminal=false(本次尝试失败,Forker 还会再试)→ 只记录日志并继续等待,
|
|
378
|
+
* 由后续 fork-started 或 status 轮询兜底推进(避免把「还会重试」误判为终态)。
|
|
379
|
+
*/
|
|
380
|
+
handleForkRestartFailed(payload) {
|
|
381
|
+
if (String(payload?.forkId ?? '') !== this.taskId) return
|
|
382
|
+
if (this.state !== TASK_HANDLE_STATE.RECONNECTING) return
|
|
383
|
+
const terminal = payload?.terminal !== false
|
|
384
|
+
const error = makeError(`fork restart failed: ${String(payload?.error ?? 'unknown')}`, {
|
|
385
|
+
code: terminal ? 'restart-failed' : 'restart-retry-failed',
|
|
386
|
+
terminal,
|
|
387
|
+
})
|
|
388
|
+
if (!terminal) {
|
|
389
|
+
this.logger.warn?.(`[task-handle] restart attempt failed (will retry): ${error.message}`)
|
|
390
|
+
return
|
|
391
|
+
}
|
|
392
|
+
this.logger.warn?.(`[task-handle] restart gave up: ${error.message}`)
|
|
393
|
+
this.restartSlot?.finish(error)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async handleCrash(payload) {
|
|
397
|
+
this.emitTaskEvent({
|
|
398
|
+
kind: 'crashed',
|
|
399
|
+
meta: this.meta,
|
|
400
|
+
reason: String(payload?.kind ?? (payload?.code === null ? 'crash' : `exit ${payload?.code ?? ''}`)),
|
|
401
|
+
})
|
|
402
|
+
this.setState(TASK_HANDLE_STATE.RECONNECTING)
|
|
403
|
+
await this.detachClient()
|
|
404
|
+
|
|
405
|
+
let live
|
|
406
|
+
try {
|
|
407
|
+
live = await this.waitForRestart()
|
|
408
|
+
} catch (e) {
|
|
409
|
+
if (this.state === TASK_HANDLE_STATE.STOPPING || this.state === TASK_HANDLE_STATE.STOPPED) return
|
|
410
|
+
this.setState(TASK_HANDLE_STATE.FAILED)
|
|
411
|
+
this.emitTaskEvent({ kind: 'failed', reason: msgOf(e) })
|
|
412
|
+
this.rejectWaiters(e)
|
|
413
|
+
return
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
this.meta = live
|
|
417
|
+
this.emitTaskEvent({ kind: 'restarting', meta: live })
|
|
418
|
+
try {
|
|
419
|
+
await this.connectClient()
|
|
420
|
+
if (this.state === TASK_HANDLE_STATE.STOPPING || this.state === TASK_HANDLE_STATE.STOPPED) {
|
|
421
|
+
await this.detachClient()
|
|
422
|
+
return
|
|
423
|
+
}
|
|
424
|
+
this.setState(TASK_HANDLE_STATE.RUNNING)
|
|
425
|
+
this.resolveWaiters()
|
|
426
|
+
this.emitTaskEvent({ kind: 'reconnected', resetCursor: true, meta: live })
|
|
427
|
+
} catch (e) {
|
|
428
|
+
if (this.state === TASK_HANDLE_STATE.STOPPING || this.state === TASK_HANDLE_STATE.STOPPED) return
|
|
429
|
+
this.setState(TASK_HANDLE_STATE.FAILED)
|
|
430
|
+
this.emitTaskEvent({ kind: 'failed', reason: msgOf(e) })
|
|
431
|
+
this.rejectWaiters(e)
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* 等待 fork 重新运行(端口/token 由 Forker 重启时更新)。
|
|
437
|
+
* 优先由 fang/fork-started 事件唤醒;事件丢失时用 status 轮询兜底。
|
|
438
|
+
*/
|
|
439
|
+
waitForRestart() {
|
|
440
|
+
return new Promise((resolve, reject) => {
|
|
441
|
+
const deadline = Date.now() + this.restartTimeoutMs
|
|
442
|
+
let settled = false
|
|
443
|
+
let timer = null
|
|
444
|
+
const cleanup = () => {
|
|
445
|
+
if (timer) clearInterval(timer)
|
|
446
|
+
if (this.restartSlot?.finish === finish) this.restartSlot = null
|
|
447
|
+
}
|
|
448
|
+
const finish = (error, live) => {
|
|
449
|
+
if (settled) return
|
|
450
|
+
settled = true
|
|
451
|
+
cleanup()
|
|
452
|
+
if (error) reject(error)
|
|
453
|
+
else resolve(live)
|
|
454
|
+
}
|
|
455
|
+
const check = () => {
|
|
456
|
+
const live = this.forker.status(this.taskId)
|
|
457
|
+
if (live && live.running && live.url) return finish(null, live)
|
|
458
|
+
if (Date.now() >= deadline) {
|
|
459
|
+
return finish(makeError(`fork did not restart within ${this.restartTimeoutMs}ms`, { code: 'restart-timeout' }))
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
this.restartSlot = { finish }
|
|
463
|
+
timer = setInterval(check, this.pollMs)
|
|
464
|
+
check()
|
|
465
|
+
})
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ── 命令代理 ──────────────────────────────────────────────────────────────
|
|
469
|
+
|
|
470
|
+
/** 通用命令代理:等待可用(最多 commandWaitMs),然后转发给 ForkClient。 */
|
|
471
|
+
async command(op, payload = {}, options = {}) {
|
|
472
|
+
const operation = String(op ?? '').trim()
|
|
473
|
+
if (!operation) throw makeError('op is required', { code: 'missing-op' })
|
|
474
|
+
await this.waitUntilReady()
|
|
475
|
+
const client = this.client
|
|
476
|
+
if (!client) throw makeError('adapter client is not connected', { code: 'client-missing' })
|
|
477
|
+
return client.command(operation, payload, options)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
ping(payload = {}) {
|
|
481
|
+
return this.command(ADAPTER_OP.PING, payload)
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
status(payload = {}) {
|
|
485
|
+
return this.command(ADAPTER_OP.STATUS, payload)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
flushBuffer(payload = {}) {
|
|
489
|
+
return this.command(ADAPTER_OP.FLUSH_BUFFER, payload)
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
scorePlugin(payload = {}) {
|
|
493
|
+
return this.command(ADAPTER_OP.SCORE_PLUGIN, payload)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
readPlugin(payload = {}) {
|
|
497
|
+
return this.command(ADAPTER_OP.READ_PLUGIN, payload)
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
routeConfirm(payload = {}) {
|
|
501
|
+
return this.command(ADAPTER_OP.ROUTE_CONFIRM, payload)
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
approvals(payload = {}) {
|
|
505
|
+
return this.command(ADAPTER_OP.APPROVALS, payload)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
approvalDecide(payload = {}) {
|
|
509
|
+
return this.command(ADAPTER_OP.APPROVAL_DECIDE, payload)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
archive(payload = {}) {
|
|
513
|
+
return this.command(ADAPTER_OP.ARCHIVE, payload)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
waitUntilReady() {
|
|
517
|
+
if (this.state === TASK_HANDLE_STATE.RUNNING && this.client) return Promise.resolve()
|
|
518
|
+
if (this.state === TASK_HANDLE_STATE.IDLE) {
|
|
519
|
+
return Promise.reject(makeError('task is not started', { code: 'task-not-started' }))
|
|
520
|
+
}
|
|
521
|
+
if (this.state === TASK_HANDLE_STATE.STOPPED || this.state === TASK_HANDLE_STATE.STOPPING) {
|
|
522
|
+
return Promise.reject(makeError('task is stopped', { code: 'task-stopped' }))
|
|
523
|
+
}
|
|
524
|
+
if (this.state === TASK_HANDLE_STATE.FAILED) {
|
|
525
|
+
return Promise.reject(makeError('task failed', { code: 'task-failed' }))
|
|
526
|
+
}
|
|
527
|
+
return new Promise((resolve, reject) => {
|
|
528
|
+
const timer = setTimeout(() => {
|
|
529
|
+
this.waiters.delete(waiter)
|
|
530
|
+
reject(makeError(`task not available within ${this.commandWaitMs}ms`, { code: 'task-unavailable' }))
|
|
531
|
+
}, this.commandWaitMs)
|
|
532
|
+
const waiter = {
|
|
533
|
+
resolve: () => {
|
|
534
|
+
clearTimeout(timer)
|
|
535
|
+
resolve()
|
|
536
|
+
},
|
|
537
|
+
reject: (error) => {
|
|
538
|
+
clearTimeout(timer)
|
|
539
|
+
reject(error)
|
|
540
|
+
},
|
|
541
|
+
timer,
|
|
542
|
+
}
|
|
543
|
+
this.waiters.add(waiter)
|
|
544
|
+
})
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
resolveWaiters() {
|
|
548
|
+
for (const waiter of [...this.waiters]) {
|
|
549
|
+
this.waiters.delete(waiter)
|
|
550
|
+
waiter.resolve()
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
rejectWaiters(error) {
|
|
555
|
+
for (const waiter of [...this.waiters]) {
|
|
556
|
+
this.waiters.delete(waiter)
|
|
557
|
+
waiter.reject(error ?? makeError('task unavailable', { code: 'task-unavailable' }))
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// ── 查询 / 事件 ───────────────────────────────────────────────────────────
|
|
562
|
+
|
|
563
|
+
getState() {
|
|
564
|
+
return this.client?.getState?.() ?? null
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
getCursor() {
|
|
568
|
+
return String(this.client?.getCursor?.() ?? '0')
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
getInflightCount() {
|
|
572
|
+
return Number(this.client?.getInflightCount?.() ?? 0)
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
onEvent(handler) {
|
|
576
|
+
return addListener(this.eventHandlers, handler)
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
onTaskEvent(handler) {
|
|
580
|
+
return addListener(this.taskEventHandlers, handler)
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
emitEvent(envelope) {
|
|
584
|
+
for (const handler of [...this.eventHandlers]) {
|
|
585
|
+
try {
|
|
586
|
+
handler(envelope)
|
|
587
|
+
} catch (e) {
|
|
588
|
+
this.logger.warn?.(`[task-handle] event handler threw: ${msgOf(e)}`)
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
emitTaskEvent(event) {
|
|
594
|
+
const payload = { ...event, taskId: this.taskId, at: Date.now() }
|
|
595
|
+
for (const handler of [...this.taskEventHandlers]) {
|
|
596
|
+
try {
|
|
597
|
+
handler(payload)
|
|
598
|
+
} catch (e) {
|
|
599
|
+
this.logger.warn?.(`[task-handle] task event handler threw: ${msgOf(e)}`)
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
setState(next) {
|
|
605
|
+
if (this.state === next) return
|
|
606
|
+
const from = this.state
|
|
607
|
+
this.state = next
|
|
608
|
+
this.emitTaskEvent({ kind: 'status-changed', from, to: next })
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function addListener(set, handler) {
|
|
613
|
+
if (typeof handler !== 'function') return () => {}
|
|
614
|
+
set.add(handler)
|
|
615
|
+
return () => set.delete(handler)
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
export function createTaskHandle(options) {
|
|
619
|
+
return new TaskHandle(options)
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
export const _internals = { positiveMs, requireForker }
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// v3.0.7/S1 — UI 动作语义的唯一来源:稳定 action id / enabled / code / reason / confirm。
|
|
3
|
+
// 服务端返回后,UI 只做投影,不再自行推断禁用原因;data-btn-id 与 id 一一对应。
|
|
4
|
+
|
|
5
|
+
export const TASK_ACTION_IDS = Object.freeze(['open', 'rename', 'archive', 'copy', 'download', 'delete'])
|
|
6
|
+
export const ESSENCE_ACTION_IDS = Object.freeze(['derive', 'configure', 'rename', 'archive', 'restore', 'copy', 'download', 'delete'])
|
|
7
|
+
|
|
8
|
+
const item = (id, enabled, code, reason, confirm = 'none') => ({
|
|
9
|
+
id,
|
|
10
|
+
enabled: enabled === true,
|
|
11
|
+
code: enabled === true ? 'ok' : code,
|
|
12
|
+
reason: enabled === true ? '' : reason,
|
|
13
|
+
confirm,
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
/** 分身动作矩阵:运行中可打开/归档;归档后可删除;未运行禁止打开/归档。 */
|
|
17
|
+
export function buildTaskActions({ running = false, archived = false } = {}) {
|
|
18
|
+
const actions = {
|
|
19
|
+
open: item('open', running, 'not-running', '分身未运行,先打开或复原后再操作'),
|
|
20
|
+
rename: item('rename', !archived, 'archived', '已归档的分身不可重命名'),
|
|
21
|
+
archive: item('archive', running && !archived, 'not-running', '分身未运行,无需归档', 'once'),
|
|
22
|
+
copy: item('copy', true, 'ok', ''),
|
|
23
|
+
download: item('download', true, 'ok', ''),
|
|
24
|
+
delete: item('delete', archived, 'archived-required', '需先归档才能永久删除', 'strong'),
|
|
25
|
+
}
|
|
26
|
+
const blocks = []
|
|
27
|
+
if (!running) blocks.push({ code: 'not-running', reason: '分身未运行' })
|
|
28
|
+
if (archived) blocks.push({ code: 'archived', reason: '分身已归档(home 已保留)' })
|
|
29
|
+
return { actions, blocks }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 元主动作矩阵:冻结(archived)后只能删除;官方 locked 只允许派生/复制/下载。 */
|
|
33
|
+
export function buildEssenceActions({ locked = false, archived = false } = {}) {
|
|
34
|
+
const frozen = archived === true
|
|
35
|
+
const mutable = locked !== true && frozen !== true
|
|
36
|
+
const actions = {
|
|
37
|
+
derive: item('derive', frozen !== true, 'archived', '已归档(冻结)的元主不可派生'),
|
|
38
|
+
configure: item('configure', mutable, locked ? 'locked' : 'archived', locked ? '官方推荐元主不支持修改设置' : '已冻结的元主不可配置'),
|
|
39
|
+
rename: item('rename', mutable, locked ? 'locked' : 'archived', locked ? '官方推荐元主不可重命名' : '已冻结的元主不可重命名'),
|
|
40
|
+
archive: item('archive', mutable, locked ? 'locked' : 'archived', locked ? '官方推荐元主不可归档' : '已归档(冻结)', 'once'),
|
|
41
|
+
// 冻结态唯一可做的两件事:还原(解冻)或删除。
|
|
42
|
+
restore: item('restore', frozen === true && locked !== true, locked ? 'locked' : 'not-archived', locked ? '官方推荐元主不可操作' : '未冻结,无需还原(备份回滚请用「还原备份」)'),
|
|
43
|
+
copy: item('copy', frozen !== true, 'archived', '已冻结的元主不可复制'),
|
|
44
|
+
download: item('download', frozen !== true, 'archived', '已冻结的元主不可下载'),
|
|
45
|
+
delete: item('delete', frozen === true && locked !== true, locked ? 'locked' : 'archived-required', locked ? '官方推荐元主不可删除' : '需先归档(冻结)才能删除', 'strong'),
|
|
46
|
+
}
|
|
47
|
+
const blocks = locked === true
|
|
48
|
+
? [{ code: 'locked', reason: '官方推荐元主不支持修改设置' }]
|
|
49
|
+
: frozen
|
|
50
|
+
? [{ code: 'archived', reason: '元主已归档(冻结):只能删除' }]
|
|
51
|
+
: []
|
|
52
|
+
return { actions, blocks }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const _internals = Object.freeze({ item })
|