@dsh-plus/agent-preset-rea 0.1.0 → 0.1.1
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/lib/client.js +614 -54
- package/lib/index.d.ts +293 -7
- package/lib/index.js +1003 -201
- package/package.json +3 -2
- package/src/client/api.ts +76 -0
- package/src/client/card.tsx +35 -10
- package/src/client/client.ts +3 -3
- package/src/client/draft.ts +33 -6
- package/src/client/i18n.ts +95 -4
- package/src/client/install.tsx +274 -0
- package/src/client/styles.ts +27 -2
- package/src/config.ts +23 -12
- package/src/definition.ts +193 -136
- package/src/dto.ts +87 -0
- package/src/exec.ts +155 -0
- package/src/index.ts +94 -3
- package/src/jobs.ts +145 -0
- package/src/manager.ts +419 -0
- package/src/mcp.ts +47 -6
- package/src/routes.ts +181 -0
- package/src/skill.ts +17 -7
package/src/exec.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 外部命令执行面与作业原语:受管安装/更新作业要跑 `npm` / `tar`,这些调用全部经
|
|
3
|
+
* {@link CommandRunner} 注入,单测用假 runner 跑完整状态机(零网络、零 npm)。
|
|
4
|
+
*
|
|
5
|
+
* 作业阶段由 {@link JobTaskContext} 上报,日志行由 {@link JobOperation} 的调用方
|
|
6
|
+
* 收集;本模块只负责「怎么跑命令」与「怎么把输出变成日志行」。
|
|
7
|
+
* @module @dsh-plus/agent-preset-rea/exec
|
|
8
|
+
*/
|
|
9
|
+
import { spawn } from 'node:child_process'
|
|
10
|
+
import { readdirSync } from 'node:fs'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
|
|
13
|
+
import type { AgentPresetReaConfig } from './config.ts'
|
|
14
|
+
|
|
15
|
+
/** 版本字符串形状:进 shell 前把用户可配项收窄成纯版本号。 */
|
|
16
|
+
const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/
|
|
17
|
+
|
|
18
|
+
/** npm 命令名(Windows 经 shell 执行,见 {@link spawnRunner} 注释)。 */
|
|
19
|
+
export const NPM = 'npm'
|
|
20
|
+
|
|
21
|
+
/** 作业取消(AbortSignal 触发后由 runner 抛出)。 */
|
|
22
|
+
export class JobCancelled extends Error {
|
|
23
|
+
constructor() {
|
|
24
|
+
super('作业已取消')
|
|
25
|
+
this.name = 'JobCancelled'
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 外部命令执行面。 */
|
|
30
|
+
export interface CommandRunner {
|
|
31
|
+
run(
|
|
32
|
+
command: string,
|
|
33
|
+
args: string[],
|
|
34
|
+
options: {
|
|
35
|
+
env: Record<string, string>
|
|
36
|
+
signal: AbortSignal
|
|
37
|
+
onLine(line: string): void
|
|
38
|
+
},
|
|
39
|
+
): Promise<{ code: number }>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 作业执行上下文(阶段推进 + 日志 + 取消信号)。 */
|
|
43
|
+
export interface JobTaskContext {
|
|
44
|
+
/** 进入第 step 个阶段(从 0 起,与 {@link JobOperation.phases} 对应)。 */
|
|
45
|
+
phase(step: number): void
|
|
46
|
+
log(line: string): void
|
|
47
|
+
signal: AbortSignal
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 一类作业:阶段名 + 实现。 */
|
|
51
|
+
export interface JobOperation {
|
|
52
|
+
phases: string[]
|
|
53
|
+
run(ctx: JobTaskContext): Promise<string>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 实际 runner:spawn 子进程、逐行回调、AbortSignal 触发即杀。 */
|
|
57
|
+
export const spawnRunner: CommandRunner = {
|
|
58
|
+
run: (command, args, options) =>
|
|
59
|
+
new Promise((resolve, reject) => {
|
|
60
|
+
// Windows 下 npm/tar 都是 .cmd/.exe 包装,shell:false 无法直接执行
|
|
61
|
+
// (与官方 MCP 客户端同一坑);参数全部由本模块构造,用户可配的版本号已按
|
|
62
|
+
// 形状收窄,无拼串注入面。
|
|
63
|
+
const child = spawn(command, args, {
|
|
64
|
+
env: options.env,
|
|
65
|
+
shell: process.platform === 'win32',
|
|
66
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
67
|
+
})
|
|
68
|
+
let pending = ''
|
|
69
|
+
const emit = (chunk: string): void => {
|
|
70
|
+
pending += chunk
|
|
71
|
+
const lines = pending.split('\n')
|
|
72
|
+
pending = lines.pop() ?? ''
|
|
73
|
+
for (const line of lines) options.onLine(line.trimEnd())
|
|
74
|
+
}
|
|
75
|
+
child.stdout.setEncoding('utf8')
|
|
76
|
+
child.stderr.setEncoding('utf8')
|
|
77
|
+
child.stdout.on('data', emit)
|
|
78
|
+
child.stderr.on('data', emit)
|
|
79
|
+
const abort = (): void => {
|
|
80
|
+
child.kill('SIGTERM')
|
|
81
|
+
}
|
|
82
|
+
options.signal.addEventListener('abort', abort, { once: true })
|
|
83
|
+
child.on('error', reject)
|
|
84
|
+
child.on('close', (code) => {
|
|
85
|
+
options.signal.removeEventListener('abort', abort)
|
|
86
|
+
if (pending !== '') options.onLine(pending.trimEnd())
|
|
87
|
+
if (options.signal.aborted) reject(new JobCancelled())
|
|
88
|
+
else resolve({ code: code ?? 1 })
|
|
89
|
+
})
|
|
90
|
+
}),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* npm 子进程环境:继承宿主环境,附带代理端口(port > 0 时同时给 npm 与
|
|
95
|
+
* prebuild-install 注入代理——后者只认 `https_proxy` 系列环境变量,
|
|
96
|
+
* 这是原生依赖 prebuild 下载卡死的正解)。
|
|
97
|
+
*/
|
|
98
|
+
export function npmEnv(config: AgentPresetReaConfig): Record<string, string> {
|
|
99
|
+
const env: Record<string, string> = {}
|
|
100
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
101
|
+
if (value !== undefined) env[key] = value
|
|
102
|
+
}
|
|
103
|
+
env['npm_config_audit'] = 'false'
|
|
104
|
+
env['npm_config_fund'] = 'false'
|
|
105
|
+
env['npm_config_update_notifier'] = 'false'
|
|
106
|
+
env['npm_config_loglevel'] = 'http'
|
|
107
|
+
if (config.proxyPort > 0) {
|
|
108
|
+
const proxy = `http://127.0.0.1:${config.proxyPort}`
|
|
109
|
+
env['HTTP_PROXY'] = proxy
|
|
110
|
+
env['HTTPS_PROXY'] = proxy
|
|
111
|
+
env['http_proxy'] = proxy
|
|
112
|
+
env['https_proxy'] = proxy
|
|
113
|
+
env['npm_config_proxy'] = proxy
|
|
114
|
+
env['npm_config_https_proxy'] = proxy
|
|
115
|
+
}
|
|
116
|
+
return env
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 版本收窄:进命令前把配置里的钉版校验成纯版本号。 */
|
|
120
|
+
export function safeVersion(version: string): string {
|
|
121
|
+
const trimmed = version.trim()
|
|
122
|
+
if (!VERSION_PATTERN.test(trimmed)) {
|
|
123
|
+
throw new Error(`配置里的 reaVersion 不是合法版本号:${JSON.stringify(version)}`)
|
|
124
|
+
}
|
|
125
|
+
return trimmed
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 逐行转发子进程输出到作业日志(截断超长行)。 */
|
|
129
|
+
export function lineSink(ctx: JobTaskContext): (line: string) => void {
|
|
130
|
+
return (line) => {
|
|
131
|
+
if (line !== '') ctx.log(line.slice(0, 500))
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 递归列出目录下全部文件(相对 POSIX 路径,排序稳定)。 */
|
|
136
|
+
export function listFiles(dir: string, base = ''): string[] {
|
|
137
|
+
const out: string[] = []
|
|
138
|
+
for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
|
|
139
|
+
a.name.localeCompare(b.name),
|
|
140
|
+
)) {
|
|
141
|
+
const rel = base === '' ? entry.name : `${base}/${entry.name}`
|
|
142
|
+
if (entry.isDirectory()) out.push(...listFiles(join(dir, entry.name), rel))
|
|
143
|
+
else out.push(rel)
|
|
144
|
+
}
|
|
145
|
+
return out
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 目录下文件数(目录缺席或不可读按 0;状态展示用,不抛)。 */
|
|
149
|
+
export function countFiles(dir: string): number {
|
|
150
|
+
try {
|
|
151
|
+
return listFiles(dir).length
|
|
152
|
+
} catch {
|
|
153
|
+
return 0
|
|
154
|
+
}
|
|
155
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,11 @@
|
|
|
18
18
|
* 本插件据此换代注册(注销旧声明 → 按新值重建声明),**新建会话**即用新提示词,
|
|
19
19
|
* 已存在会话保持既有 composition(注册表语义:运行中会话的装配不随换代改变)。
|
|
20
20
|
*
|
|
21
|
+
* REA 的安装与更新也在卡片上(受管安装 + 后台作业 + 实时进度 + 可选代理端口):
|
|
22
|
+
* 宿主半的 manager 把 `rea-agents` 装进插件数据目录、把 skill 拉成可替换副本,
|
|
23
|
+
* 作业完成后同样换代注册——因此预设声明里**不会**在 boot 期 spawn `npx`
|
|
24
|
+
* (那会跑 npm 安装脚本,原生依赖 prebuild 下载一卡就吊死预设激活与整个名册)。
|
|
25
|
+
*
|
|
21
26
|
* 设计取舍:
|
|
22
27
|
* - 独立插件而非 cordis.patch.yml 里的裸 `@deepseek-ai/dsh-agent-preset`
|
|
23
28
|
* 行:便于纳入本仓的构建/测试/文档体系,并把显示面与 MCP/skill 参数开放为
|
|
@@ -36,7 +41,12 @@ import { unwrapVolatile } from '@dsh-plus/shared'
|
|
|
36
41
|
|
|
37
42
|
import { type AgentPresetReaConfig, type AgentPresetReaConfigFields, Config } from './config.ts'
|
|
38
43
|
import { reaPresetDefinition } from './definition.ts'
|
|
44
|
+
import { createJobBoard } from './jobs.ts'
|
|
45
|
+
import { createManager } from './manager.ts'
|
|
46
|
+
import { REA_PINNED_VERSION } from './mcp.ts'
|
|
39
47
|
import { createPresetRegistrar } from './registration.ts'
|
|
48
|
+
import { registerReaRoutes } from './routes.ts'
|
|
49
|
+
import { BUNDLED_SKILL_ROOT } from './skill.ts'
|
|
40
50
|
|
|
41
51
|
export const name = 'dsh-plus-agent-preset-rea'
|
|
42
52
|
|
|
@@ -44,17 +54,71 @@ export const name = 'dsh-plus-agent-preset-rea'
|
|
|
44
54
|
export const inject = [] as const
|
|
45
55
|
|
|
46
56
|
export { SETTINGS_NS } from './config.ts'
|
|
47
|
-
export {
|
|
57
|
+
export {
|
|
58
|
+
ADDED_ROWS,
|
|
59
|
+
CONFIG_DELTAS,
|
|
60
|
+
emptyRuntime,
|
|
61
|
+
REA_PRESET_ID,
|
|
62
|
+
reaPresetDefinition,
|
|
63
|
+
} from './definition.ts'
|
|
64
|
+
export type {
|
|
65
|
+
InstallDto,
|
|
66
|
+
JobActionDto,
|
|
67
|
+
JobDto,
|
|
68
|
+
JobKind,
|
|
69
|
+
JobProgressDto,
|
|
70
|
+
JobState,
|
|
71
|
+
McpDto,
|
|
72
|
+
SkillDto,
|
|
73
|
+
StatusDto,
|
|
74
|
+
} from './dto.ts'
|
|
75
|
+
export {
|
|
76
|
+
type CommandRunner,
|
|
77
|
+
JobCancelled,
|
|
78
|
+
type JobOperation,
|
|
79
|
+
type JobTaskContext,
|
|
80
|
+
NPM,
|
|
81
|
+
npmEnv,
|
|
82
|
+
safeVersion,
|
|
83
|
+
spawnRunner,
|
|
84
|
+
} from './exec.ts'
|
|
85
|
+
export { createJobBoard, type JobBoard, type JobSnapshot } from './jobs.ts'
|
|
86
|
+
export {
|
|
87
|
+
configWith,
|
|
88
|
+
createManager,
|
|
89
|
+
INSTALL_PHASES,
|
|
90
|
+
type JobDeps,
|
|
91
|
+
type JobOverrides,
|
|
92
|
+
type ManagerPaths,
|
|
93
|
+
managerPaths,
|
|
94
|
+
probeInstall,
|
|
95
|
+
probeRuntime,
|
|
96
|
+
probeSkill,
|
|
97
|
+
REA_INSTALL_DIR,
|
|
98
|
+
REA_PACKAGE,
|
|
99
|
+
REA_SKILL_DIR,
|
|
100
|
+
REA_SKILL_NAME,
|
|
101
|
+
type ReaInstall,
|
|
102
|
+
type ReaManager,
|
|
103
|
+
type RuntimeSnapshot,
|
|
104
|
+
SKILL_PHASES,
|
|
105
|
+
type SkillManifest,
|
|
106
|
+
type SkillState,
|
|
107
|
+
} from './manager.ts'
|
|
48
108
|
export {
|
|
49
109
|
DEFAULT_MCP_COMMAND,
|
|
50
110
|
INPUT_SCHEMA_PROFILES,
|
|
111
|
+
type InstalledEntry,
|
|
112
|
+
type McpLaunch,
|
|
51
113
|
OUTPUT_SCHEMA_PROFILES,
|
|
52
114
|
REA_PINNED_VERSION,
|
|
53
115
|
reaMcpArgs,
|
|
54
116
|
reaMcpEnv,
|
|
117
|
+
reaMcpLaunch,
|
|
55
118
|
} from './mcp.ts'
|
|
56
119
|
export { PLAN_MODE_SECTION } from './prompt.ts'
|
|
57
120
|
export { createPresetRegistrar, type PresetRegistrar, type Unregister } from './registration.ts'
|
|
121
|
+
export { ROUTE_BASE, registerReaRoutes, statusPayload } from './routes.ts'
|
|
58
122
|
export { BUNDLED_SKILL_ROOT, reaSkillDirs } from './skill.ts'
|
|
59
123
|
export type { AgentPresetReaConfig, AgentPresetReaConfigFields }
|
|
60
124
|
export { Config }
|
|
@@ -76,20 +140,47 @@ export function apply(ctx: Context, config: AgentPresetReaConfigFields): void {
|
|
|
76
140
|
const detail = error instanceof Error ? error.message : String(error)
|
|
77
141
|
logger.warn(`register rea preset failed: ${detail}`)
|
|
78
142
|
}
|
|
143
|
+
const manager = createManager({
|
|
144
|
+
config: current,
|
|
145
|
+
bundledRoot: BUNDLED_SKILL_ROOT,
|
|
146
|
+
bundledVersion: REA_PINNED_VERSION,
|
|
147
|
+
})
|
|
148
|
+
const board = createJobBoard()
|
|
149
|
+
// 换代入口:安装/更新作业完成、或活动配置提交后,都要按新事实重建声明。
|
|
150
|
+
let reregister: (() => void) | null = null
|
|
151
|
+
const refresh = (): void => {
|
|
152
|
+
manager.refresh()
|
|
153
|
+
reregister?.()
|
|
154
|
+
}
|
|
155
|
+
ctx.effect(
|
|
156
|
+
() =>
|
|
157
|
+
board.onSettled((snapshot) => {
|
|
158
|
+
refresh()
|
|
159
|
+
logger.info(`rea ${snapshot.kind} 作业结束:${snapshot.state}`)
|
|
160
|
+
}),
|
|
161
|
+
'agent-preset-rea: refresh after job',
|
|
162
|
+
)
|
|
79
163
|
|
|
80
164
|
ctx.inject(['agentPresets'], (scope) => {
|
|
81
165
|
scope.effect(() => {
|
|
82
166
|
const registrar = createPresetRegistrar({
|
|
83
167
|
register: (definition) => scope.agentPresets.register(definition),
|
|
84
|
-
build: () => reaPresetDefinition(current()),
|
|
168
|
+
build: () => reaPresetDefinition(current(), manager.snapshot()),
|
|
85
169
|
onError,
|
|
86
170
|
})
|
|
171
|
+
reregister = () => registrar.refresh()
|
|
87
172
|
// 监听器挂在插件自身 fiber 上:loader 正是对该 fiber 广播 volatile 提交。
|
|
88
|
-
const off = ctx.events.on('loader/volatile-update', () =>
|
|
173
|
+
const off = ctx.events.on('loader/volatile-update', () => refresh())
|
|
89
174
|
return () => {
|
|
175
|
+
reregister = null
|
|
90
176
|
off()
|
|
91
177
|
registrar.dispose()
|
|
92
178
|
}
|
|
93
179
|
}, 'agent-preset-rea: register rea preset')
|
|
94
180
|
})
|
|
181
|
+
|
|
182
|
+
// 安装/更新端点:webServer 缺席(headless)即不注册,插件其余能力照常。
|
|
183
|
+
ctx.inject(['webServer'], (scope) => {
|
|
184
|
+
registerReaRoutes(scope as unknown as Context, { manager, board, config: current })
|
|
185
|
+
})
|
|
95
186
|
}
|
package/src/jobs.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 单作业作业板:同一时刻只允许一个安装/更新作业,快照供 HTTP 轮询,
|
|
3
|
+
* 完成后回调订阅者(宿主据此换代注册预设声明)。
|
|
4
|
+
*
|
|
5
|
+
* 进度按「阶段」推进(`percent = step / steps`):安装类作业的外部命令不产出可靠
|
|
6
|
+
* 字节进度,硬造百分比是骗人;阶段 + 实时日志尾部 + 起止时间才是可核对的事实。
|
|
7
|
+
* 日志只保留尾部 {@link JOB_LOG_LIMIT} 行(回传浏览器,必须有界)。
|
|
8
|
+
*
|
|
9
|
+
* 状态放在 {@link BoardState} 里、函数都提到模块级:卡片/端点/测试三方共享同一份
|
|
10
|
+
* 语义,单个函数也保持在可读长度内(仓库门禁:函数 60/150 行)。
|
|
11
|
+
* @module @dsh-plus/agent-preset-rea/jobs
|
|
12
|
+
*/
|
|
13
|
+
import type { JobDto, JobKind, JobProgressDto } from './dto.ts'
|
|
14
|
+
import { JobCancelled, type JobOperation, type JobTaskContext } from './exec.ts'
|
|
15
|
+
|
|
16
|
+
/** 作业进度(与 wire DTO 同形)。 */
|
|
17
|
+
export type JobProgress = JobProgressDto
|
|
18
|
+
|
|
19
|
+
/** 作业快照(与 wire DTO 同形,HTTP 直接回传)。 */
|
|
20
|
+
export type JobSnapshot = JobDto
|
|
21
|
+
|
|
22
|
+
/** 作业板。 */
|
|
23
|
+
export interface JobBoard {
|
|
24
|
+
/** 启动作业;已有作业在跑时返回 null(调用方回 409)。 */
|
|
25
|
+
start(kind: JobKind, operation: JobOperation): JobSnapshot | null
|
|
26
|
+
/** 取消当前作业(返回是否真的发出取消)。 */
|
|
27
|
+
cancel(): boolean
|
|
28
|
+
/** 当前/最近一次作业快照。 */
|
|
29
|
+
snapshot(): JobSnapshot | null
|
|
30
|
+
/** 订阅作业完成(返回退订函数)。 */
|
|
31
|
+
onSettled(listener: (snapshot: JobSnapshot) => void): () => void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 日志尾部保留行数。 */
|
|
35
|
+
export const JOB_LOG_LIMIT = 200
|
|
36
|
+
|
|
37
|
+
/** 作业板可变状态(函数按引用操作它,不靠闭包嵌套)。 */
|
|
38
|
+
interface BoardState {
|
|
39
|
+
seq: number
|
|
40
|
+
current: JobSnapshot | null
|
|
41
|
+
abort: AbortController | null
|
|
42
|
+
listeners: Set<(snapshot: JobSnapshot) => void>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 阶段序号 → 进度(进入该阶段即报该阶段起点百分比)。 */
|
|
46
|
+
function progressOf(operation: JobOperation, step: number): JobProgress {
|
|
47
|
+
const steps = operation.phases.length
|
|
48
|
+
return {
|
|
49
|
+
step: step + 1,
|
|
50
|
+
steps,
|
|
51
|
+
phase: operation.phases[step] ?? '',
|
|
52
|
+
percent: steps === 0 ? 0 : Math.round((step / steps) * 100),
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 落定:写状态 + 通知订阅者(订阅者异常不得影响状态机)。 */
|
|
57
|
+
function settleJob(state: BoardState, patch: Partial<JobSnapshot>): void {
|
|
58
|
+
const base = state.current
|
|
59
|
+
if (base === null) return
|
|
60
|
+
state.current = { ...base, ...patch, finishedAt: new Date().toISOString() }
|
|
61
|
+
for (const listener of [...state.listeners]) {
|
|
62
|
+
try {
|
|
63
|
+
listener(state.current)
|
|
64
|
+
} catch {
|
|
65
|
+
// 订阅者异常不影响作业板状态
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 兜底进度形状(正常路径下 `state.current` 必有值,这里只防类型上的缺席)。 */
|
|
71
|
+
const ZERO_PROGRESS: JobProgress = { step: 1, steps: 1, phase: '', percent: 0 }
|
|
72
|
+
|
|
73
|
+
/** 收尾:成功记 ok + 版本,失败区分「取消」与「真失败」。 */
|
|
74
|
+
function finishJob(state: BoardState, error: unknown, version: string | null): void {
|
|
75
|
+
if (version !== null) {
|
|
76
|
+
const progress = { ...(state.current?.progress ?? ZERO_PROGRESS), percent: 100 }
|
|
77
|
+
settleJob(state, { state: 'ok', version, progress })
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
const cancelled = error instanceof JobCancelled || state.abort?.signal.aborted === true
|
|
81
|
+
settleJob(state, {
|
|
82
|
+
state: cancelled ? 'cancelled' : 'error',
|
|
83
|
+
error: cancelled ? null : error instanceof Error ? error.message : String(error),
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 起一个作业并挂上状态机(调用方已确认当前无运行中作业)。 */
|
|
88
|
+
function startJob(state: BoardState, kind: JobKind, operation: JobOperation): JobSnapshot {
|
|
89
|
+
const controller = new AbortController()
|
|
90
|
+
state.abort = controller
|
|
91
|
+
state.seq += 1
|
|
92
|
+
const snapshot: JobSnapshot = {
|
|
93
|
+
id: state.seq,
|
|
94
|
+
kind,
|
|
95
|
+
state: 'running',
|
|
96
|
+
progress: progressOf(operation, 0),
|
|
97
|
+
log: [],
|
|
98
|
+
startedAt: new Date().toISOString(),
|
|
99
|
+
finishedAt: null,
|
|
100
|
+
version: null,
|
|
101
|
+
error: null,
|
|
102
|
+
}
|
|
103
|
+
state.current = snapshot
|
|
104
|
+
const push = (patch: Partial<JobSnapshot>): void => {
|
|
105
|
+
if (state.current !== null) state.current = { ...state.current, ...patch }
|
|
106
|
+
}
|
|
107
|
+
const ctx: JobTaskContext = {
|
|
108
|
+
phase: (step) => push({ progress: progressOf(operation, step) }),
|
|
109
|
+
log: (line) => push({ log: [...(state.current?.log ?? []), line].slice(-JOB_LOG_LIMIT) }),
|
|
110
|
+
signal: controller.signal,
|
|
111
|
+
}
|
|
112
|
+
void operation
|
|
113
|
+
.run(ctx)
|
|
114
|
+
.then(
|
|
115
|
+
(version) => finishJob(state, null, version),
|
|
116
|
+
(error: unknown) => finishJob(state, error, null),
|
|
117
|
+
)
|
|
118
|
+
.finally(() => {
|
|
119
|
+
state.abort = null
|
|
120
|
+
})
|
|
121
|
+
return snapshot
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 取消当前作业。 */
|
|
125
|
+
function cancelJob(state: BoardState): boolean {
|
|
126
|
+
// 只看状态:落定后 finally 还没跑(微任务次序),按 abort 句柄判断会误报「已取消」。
|
|
127
|
+
if (state.abort === null || state.current?.state !== 'running') return false
|
|
128
|
+
state.abort.abort()
|
|
129
|
+
return true
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 建一个作业板。 */
|
|
133
|
+
export function createJobBoard(): JobBoard {
|
|
134
|
+
const state: BoardState = { seq: 0, current: null, abort: null, listeners: new Set() }
|
|
135
|
+
return {
|
|
136
|
+
start: (kind, operation) =>
|
|
137
|
+
state.current?.state === 'running' ? null : startJob(state, kind, operation),
|
|
138
|
+
cancel: () => cancelJob(state),
|
|
139
|
+
snapshot: () => state.current,
|
|
140
|
+
onSettled: (listener) => {
|
|
141
|
+
state.listeners.add(listener)
|
|
142
|
+
return () => state.listeners.delete(listener)
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
}
|