@dsh-plus/image-studio 0.1.0

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/src/service.ts ADDED
@@ -0,0 +1,458 @@
1
+ /**
2
+ * image-studio 服务主体:
3
+ * - settings installSection(三组预设 + 并发/超时/代理/画廊上限);
4
+ * - TaskRunner 并发生图:提交即排队,成功自动入画廊(元数据 + 图片落盘);
5
+ * - 提供商预设按 id 即时 resolve credentials(不缓存);
6
+ * - 端点:generate/tasks/gallery/images/credentials/providers(api.ts 注册)。
7
+ * 红线:开发测试仅指向本地 mock,严禁真实调用产生费用。
8
+ * @module image-studio/service
9
+ */
10
+ import { type Context, Service } from '@deepseek-ai/cordis'
11
+ import { credentialRef } from '@deepseek-ai/dsh-credentials'
12
+ import type {} from '@deepseek-ai/dsh-settings'
13
+ import { registerImageStudioApi } from './api.ts'
14
+ import {
15
+ type ImageStudioConfig,
16
+ type ParamPresetEntry,
17
+ type PromptPresetEntry,
18
+ type ProviderPresetEntry,
19
+ SETTINGS_NS,
20
+ } from './config.ts'
21
+ import { credentialRefNameOf, isValidPresetId } from './credentials.ts'
22
+ import type { GenerateRequest, TaskWire } from './dto.ts'
23
+ import { ImageStudioError } from './errors.ts'
24
+ import type { GalleryItem } from './gallery/store.ts'
25
+ import {
26
+ loadGallery,
27
+ readImageBytes,
28
+ deleteGalleryItem as removeGalleryItem,
29
+ saveGalleryItem,
30
+ } from './gallery/store.ts'
31
+ import type { ParamSpecMap } from './params/spec.ts'
32
+ import { normalizeParamSpecs, validateParamSpecs } from './params/spec.ts'
33
+ import { createProvider } from './provider/registry.ts'
34
+ import type { ImageEndpoint, InputImage, ProviderTarget } from './provider/types.ts'
35
+ import type { TaskRecord } from './task/runner.ts'
36
+ import { TaskRunner } from './task/runner.ts'
37
+
38
+ interface SettingsLike {
39
+ installSection(
40
+ ownerCtx: Context,
41
+ ns: string,
42
+ schema: unknown,
43
+ base: unknown,
44
+ hooks: { setSource(source: () => unknown): void; onChange(): void },
45
+ ): void
46
+ }
47
+
48
+ /** credentials seam 最小面(真实服务经 inject 探测获取)。 */
49
+ interface CredentialsFace {
50
+ resolve(ref: unknown): Promise<{ value: string; source: string } | undefined>
51
+ describe(ref: unknown): Promise<{ configured: boolean }>
52
+ set(ref: unknown, value: string): Promise<void>
53
+ unset(ref: unknown): Promise<void>
54
+ }
55
+
56
+ /** 提交时的请求摘要(任务快照)。 */
57
+ interface TaskSnapshot {
58
+ endpoint: ImageEndpoint
59
+ model: string
60
+ prompt: string
61
+ }
62
+
63
+ /** 单任务成功产物(TaskRunner result 槽)。 */
64
+ interface TaskOutcome {
65
+ galleryItemId: string
66
+ }
67
+
68
+ export class ImageStudioService extends Service {
69
+ private current: () => ImageStudioConfig
70
+ private readonly runner: TaskRunner<TaskSnapshot>
71
+ private readonly log: (message: string) => void
72
+
73
+ constructor(ctx: Context, config: ImageStudioConfig) {
74
+ super(ctx, 'imageStudio')
75
+ this.current = () => config
76
+ this.log = (message) => ctx.logger('image-studio').warn(message)
77
+ this.runner = new TaskRunner<TaskSnapshot>({ maxConcurrent: config.maxConcurrent })
78
+ ctx.inject(['settings'], (settingsCtx) => {
79
+ this.settingsRef = settingsCtx.settings as unknown as SettingsLike
80
+ settingsCtx.settings.installSection(ctx, SETTINGS_NS, configSchemaRef, config, {
81
+ setSource: (source) => {
82
+ this.current = source as () => ImageStudioConfig
83
+ this.applyConfig()
84
+ },
85
+ onChange: () => {},
86
+ })
87
+ })
88
+ ctx.inject(['webServer'], (webCtx) => {
89
+ registerImageStudioApi(webCtx as Context, this)
90
+ })
91
+ }
92
+
93
+ /** 配置热更:并发上限即时生效(进行中任务不受影响)。 */
94
+ private applyConfig(): void {
95
+ this.runner.reconfigure(this.current().maxConcurrent)
96
+ }
97
+
98
+ // ---------- 预设读取 ----------
99
+
100
+ private promptPresets(): PromptPresetEntry[] {
101
+ return this.current().promptPresets
102
+ }
103
+
104
+ private paramPresets(): ParamPresetEntry[] {
105
+ return this.current().paramPresets as ParamPresetEntry[]
106
+ }
107
+
108
+ private providerPresets(): ProviderPresetEntry[] {
109
+ return this.current().providerPresets as ProviderPresetEntry[]
110
+ }
111
+
112
+ /** 预设快照(端点投影)。 */
113
+ presets(): {
114
+ promptPresets: PromptPresetEntry[]
115
+ paramPresets: ParamPresetEntry[]
116
+ providerPresets: ProviderPresetEntry[]
117
+ } {
118
+ return {
119
+ promptPresets: this.promptPresets(),
120
+ paramPresets: this.paramPresets(),
121
+ providerPresets: this.providerPresets(),
122
+ }
123
+ }
124
+
125
+ // ---------- 凭据 ----------
126
+
127
+ /** describe 单个提供商预设凭据(不回传值)。 */
128
+ async credentialStatus(
129
+ presetId: string,
130
+ ): Promise<{ credentialRef: string; configured: boolean }> {
131
+ const refName = credentialRefNameOf(presetId)
132
+ const credentials = await this.credentialsSeam()
133
+ if (credentials === null) return { credentialRef: refName, configured: false }
134
+ const info = await credentials.describe(credentialRef(refName))
135
+ return { credentialRef: refName, configured: info.configured }
136
+ }
137
+
138
+ /** set/unset 凭据(端点写通道)。 */
139
+ async setCredential(presetId: string, value: string): Promise<void> {
140
+ const credentials = await this.requireCredentials()
141
+ await credentials.set(credentialRef(credentialRefNameOf(presetId)), value)
142
+ }
143
+
144
+ async unsetCredential(presetId: string): Promise<void> {
145
+ const credentials = await this.requireCredentials()
146
+ await credentials.unset(credentialRef(credentialRefNameOf(presetId)))
147
+ }
148
+
149
+ /** credentials seam 可选探测(缺席返回 null)。 */
150
+ private async credentialsSeam(): Promise<CredentialsFace | null> {
151
+ const found = (this.ctx as unknown as { get?(key: 'credentials'): unknown }).get?.(
152
+ 'credentials',
153
+ )
154
+ return found === undefined || found === null ? null : (found as CredentialsFace)
155
+ }
156
+
157
+ private async requireCredentials(): Promise<CredentialsFace> {
158
+ const seam = await this.credentialsSeam()
159
+ if (seam === null) throw new ImageStudioError('credential-unavailable')
160
+ return seam
161
+ }
162
+
163
+ // ---------- 生图任务 ----------
164
+
165
+ /** 提交生图任务:校验 → 入队 → 返回 taskId。 */
166
+ async submitGenerate(request: GenerateRequest): Promise<string> {
167
+ const snapshot = await this.prepareTask(request)
168
+ const summary: TaskSnapshot = {
169
+ endpoint: snapshot.endpoint,
170
+ model: snapshot.target.model,
171
+ prompt: snapshot.prompt,
172
+ }
173
+ const record = this.runner.submit(summary, (signal) => this.executeTask(snapshot, signal))
174
+ return record.id
175
+ }
176
+
177
+ /** 单任务查询(wire 投影)。 */
178
+ taskWire(id: string): TaskWire | null {
179
+ const record = this.runner.get(id)
180
+ return record === null ? null : this.toTaskWire(record)
181
+ }
182
+
183
+ /** 任务列表(最新在前)。 */
184
+ taskWires(): TaskWire[] {
185
+ return this.runner
186
+ .list()
187
+ .reverse()
188
+ .map((record) => this.toTaskWire(record))
189
+ }
190
+
191
+ /** 取消任务。 */
192
+ cancelTask(id: string): boolean {
193
+ return this.runner.cancel(id)
194
+ }
195
+
196
+ private toTaskWire(record: TaskRecord<TaskSnapshot>): TaskWire {
197
+ const outcome = record.result as TaskOutcome | null
198
+ return {
199
+ id: record.id,
200
+ state: record.state,
201
+ endpoint: record.snapshot.endpoint,
202
+ model: record.snapshot.model,
203
+ promptPreview: record.snapshot.prompt.slice(0, 120),
204
+ createdAt: record.createdAt,
205
+ startedAt: record.startedAt,
206
+ finishedAt: record.finishedAt,
207
+ error: record.error,
208
+ galleryItemId:
209
+ record.state === 'succeeded' && outcome !== null ? outcome.galleryItemId : null,
210
+ }
211
+ }
212
+
213
+ /**
214
+ * 任务准备(边界校验集中地):提供商预设/inline → target;
215
+ * 参数预设 + 覆盖 → 归一化参数表;sourceIds → 输入图字节。
216
+ */
217
+ private async prepareTask(request: GenerateRequest): Promise<{
218
+ target: ProviderTarget
219
+ endpoint: ImageEndpoint
220
+ prompt: string
221
+ n: number
222
+ images: InputImage[]
223
+ mask: InputImage | null
224
+ params: Record<string, unknown>
225
+ sourceIds: string[]
226
+ }> {
227
+ const target = await this.resolveTarget(request)
228
+ const endpoint = request.endpoint
229
+ if (endpoint !== 'generation' && endpoint !== 'edit') {
230
+ throw new ImageStudioError('invalid-request', `未知端点:${String(endpoint)}`)
231
+ }
232
+ if (typeof request.prompt !== 'string' || request.prompt.trim().length === 0) {
233
+ throw new ImageStudioError('invalid-request', 'prompt 不能为空')
234
+ }
235
+ const params = await this.resolveParams(request, endpoint)
236
+ const nValue = params.n
237
+ const n = typeof nValue === 'number' ? nValue : 1
238
+ const sourceIds = request.sourceIds ?? []
239
+ const images = await this.loadSourceImages(sourceIds, endpoint)
240
+ const mask = request.maskId !== undefined ? await this.loadMask(request.maskId) : null
241
+ return {
242
+ target,
243
+ endpoint,
244
+ prompt: request.prompt,
245
+ n,
246
+ images,
247
+ mask,
248
+ params: stripHostParams(params),
249
+ sourceIds,
250
+ }
251
+ }
252
+
253
+ /** 提供商预设 / inline → ProviderTarget(凭据即时 resolve)。 */
254
+ private async resolveTarget(request: GenerateRequest): Promise<ProviderTarget> {
255
+ const config = this.current()
256
+ const inline = request.inlineProvider
257
+ let protocol: string
258
+ let baseUrl: string
259
+ let model: string
260
+ let extraHeaders: Record<string, string> = {}
261
+ let refName: string
262
+ if (inline !== undefined) {
263
+ protocol = inline.protocol
264
+ baseUrl = inline.baseUrl
265
+ model = inline.model
266
+ // inline 无独立凭据:复用 providerPresetId 引用或拒绝。
267
+ if (request.providerPresetId === undefined) {
268
+ throw new ImageStudioError(
269
+ 'invalid-request',
270
+ 'inline 提供商必须同时给出 providerPresetId 以定位凭据',
271
+ )
272
+ }
273
+ refName = credentialRefNameOf(request.providerPresetId)
274
+ } else if (request.providerPresetId !== undefined) {
275
+ const preset = this.providerPresets().find((item) => item.id === request.providerPresetId)
276
+ if (preset === undefined) {
277
+ throw new ImageStudioError('invalid-request', `未知提供商预设:${request.providerPresetId}`)
278
+ }
279
+ protocol = preset.protocol
280
+ baseUrl = preset.baseUrl
281
+ model = preset.model
282
+ extraHeaders = preset.extraHeaders ?? {}
283
+ refName = isValidPresetId(preset.id)
284
+ ? credentialRefNameOf(preset.id)
285
+ : credentialRefNameOf(preset.credentialRef)
286
+ } else {
287
+ throw new ImageStudioError('invalid-request', '必须提供 providerPresetId 或 inlineProvider')
288
+ }
289
+ const apiKey = await this.resolveApiKey(refName)
290
+ const target: ProviderTarget & { presetId?: string } = {
291
+ protocol,
292
+ baseUrl,
293
+ model,
294
+ apiKey,
295
+ extraHeaders,
296
+ proxy: config.proxy,
297
+ timeoutMs: config.requestTimeoutMs,
298
+ }
299
+ // 预设 id 旁路携带(executeTask 落画廊时补记 providerPresetId)。
300
+ target.presetId =
301
+ inline !== undefined ? (request.providerPresetId ?? null) : (request.providerPresetId ?? null)
302
+ return target
303
+ }
304
+
305
+ /** 凭据解析(每次操作即时 resolve,不缓存)。 */
306
+ private async resolveApiKey(refName: string): Promise<string> {
307
+ const credentials = await this.requireCredentials()
308
+ const resolved = await credentials.resolve(credentialRef(refName))
309
+ if (resolved === undefined) {
310
+ throw new ImageStudioError('credential-unavailable', `凭据未配置:${refName}`)
311
+ }
312
+ return resolved.value
313
+ }
314
+
315
+ /** 参数预设 + 覆盖合并 → 端点归一化(model 由 target 承载)。 */
316
+ private async resolveParams(
317
+ request: GenerateRequest,
318
+ endpoint: ImageEndpoint,
319
+ ): Promise<Record<string, unknown>> {
320
+ let specs: ParamSpecMap = {}
321
+ if (request.paramPresetId !== undefined) {
322
+ const preset = this.paramPresets().find((item) => item.id === request.paramPresetId)
323
+ if (preset === undefined) {
324
+ throw new ImageStudioError('invalid-request', `未知参数预设:${request.paramPresetId}`)
325
+ }
326
+ specs = validateParamSpecs(preset.paramSpecs)
327
+ }
328
+ if (request.paramSpecs !== undefined) {
329
+ specs = { ...specs, ...validateParamSpecs(request.paramSpecs) }
330
+ }
331
+ const normalized = normalizeParamSpecs(specs, endpoint)
332
+ return { model: this.modelOf(request), ...normalized }
333
+ }
334
+
335
+ /** 模型优先级:inline > 提供商预设(参数表中的 model 仅为展示,不参与)。 */
336
+ private modelOf(request: GenerateRequest): string {
337
+ return request.inlineProvider?.model ?? this.providerModelOf(request.providerPresetId)
338
+ }
339
+
340
+ private providerModelOf(presetId: string | undefined): string {
341
+ if (presetId === undefined) return ''
342
+ return this.providerPresets().find((item) => item.id === presetId)?.model ?? ''
343
+ }
344
+
345
+ /** 源图字节加载(edit ≥1 张;单图上限 50MB 对齐官方)。 */
346
+ private async loadSourceImages(
347
+ sourceIds: string[],
348
+ endpoint: ImageEndpoint,
349
+ ): Promise<InputImage[]> {
350
+ if (endpoint === 'generation') return []
351
+ if (sourceIds.length === 0) {
352
+ throw new ImageStudioError('invalid-request', 'edit 端点必须提供至少一张源图(sourceIds)')
353
+ }
354
+ if (sourceIds.length > 16) {
355
+ throw new ImageStudioError('invalid-request', '源图最多 16 张(官方 GPT Image 限制)')
356
+ }
357
+ const images: InputImage[] = []
358
+ for (const imageId of sourceIds) {
359
+ images.push(await this.loadOneImage(imageId))
360
+ }
361
+ return images
362
+ }
363
+
364
+ private async loadMask(maskId: string): Promise<InputImage> {
365
+ return this.loadOneImage(maskId)
366
+ }
367
+
368
+ private async loadOneImage(imageId: string): Promise<InputImage> {
369
+ try {
370
+ const { data, ext } = await readImageBytes(imageId)
371
+ return { data, mime: ext === 'jpg' ? 'image/jpeg' : `image/${ext}` }
372
+ } catch {
373
+ throw new ImageStudioError('unknown-source', `源图不存在:${imageId}`)
374
+ }
375
+ }
376
+
377
+ /** 任务体:调协议适配器 → 成功入画廊。失败向上抛给 TaskRunner 记录。 */
378
+ private async executeTask(
379
+ snapshot: Awaited<ReturnType<ImageStudioService['prepareTask']>>,
380
+ signal: AbortSignal,
381
+ ): Promise<TaskOutcome> {
382
+ const provider = createProvider(snapshot.target.protocol)
383
+ if (!provider.endpoints.includes(snapshot.endpoint)) {
384
+ throw new ImageStudioError(
385
+ 'unknown-endpoint',
386
+ `协议 ${provider.id} 不支持端点 ${snapshot.endpoint}`,
387
+ )
388
+ }
389
+ const result = await provider.generate(
390
+ {
391
+ target: snapshot.target,
392
+ endpoint: snapshot.endpoint,
393
+ prompt: snapshot.prompt,
394
+ n: snapshot.n,
395
+ images: snapshot.images,
396
+ mask: snapshot.mask,
397
+ params: snapshot.params,
398
+ },
399
+ signal,
400
+ )
401
+ const item = await saveGalleryItem({
402
+ item: {
403
+ providerPresetId: this.presetIdOf(snapshot),
404
+ protocol: snapshot.target.protocol,
405
+ endpoint: snapshot.endpoint,
406
+ model: snapshot.target.model,
407
+ prompt: snapshot.prompt,
408
+ params: snapshot.params,
409
+ sourceIds: snapshot.sourceIds,
410
+ },
411
+ images: result.images.map((image) => ({
412
+ data: image.data,
413
+ mime: image.mime,
414
+ revisedPrompt: image.revisedPrompt,
415
+ })),
416
+ })
417
+ return { galleryItemId: item.id }
418
+ }
419
+
420
+ /** 提交时上下文里的预设 id(executeTask 无原请求,经 target 旁路携带)。 */
421
+ private presetIdOf(snapshot: { target: ProviderTarget }): string | null {
422
+ return (snapshot.target as ProviderTarget & { presetId?: string }).presetId ?? null
423
+ }
424
+
425
+ // ---------- 画廊 ----------
426
+
427
+ /** 画廊列表(全量;分页/过滤由端点层做)。 */
428
+ async gallery(): Promise<GalleryItem[]> {
429
+ return loadGallery(this.log)
430
+ }
431
+
432
+ /** 删除画廊条目(图片 + 元数据)。 */
433
+ async deleteGalleryItem(itemId: string): Promise<boolean> {
434
+ return removeGalleryItem(itemId)
435
+ }
436
+
437
+ /** 读取图片字节(端点直投流)。 */
438
+ async imageBytes(imageId: string): Promise<{ data: Uint8Array; ext: string } | null> {
439
+ try {
440
+ return await readImageBytes(imageId)
441
+ } catch {
442
+ return null
443
+ }
444
+ }
445
+
446
+ dispose(): void {
447
+ // 进行中任务随进程终止(AbortController 无需逐一 abort——进程退出即断)。
448
+ }
449
+ }
450
+
451
+ /** 静态 schema 引用(installSection 用;避免循环 import config → service)。 */
452
+ import { Config as configSchemaRef } from './config.ts'
453
+
454
+ /** 剔除 host 承载参数(model 不进协议参数表——openai-images 请求体单独拼)。 */
455
+ function stripHostParams(params: Record<string, unknown>): Record<string, unknown> {
456
+ const { model: _model, ...rest } = params
457
+ return rest
458
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * 生图任务并发执行器:FIFO 队列 + 可配置并发上限(0 = 不限)。
3
+ * 与业务解耦:任务体是注入的异步函数,本模块只管调度与状态;
4
+ * 纯调度逻辑可单测(不触网)。
5
+ * @module image-studio/task/runner
6
+ */
7
+
8
+ /** 任务状态。 */
9
+ export type TaskState = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'
10
+
11
+ /** 任务记录(调度器视图)。 */
12
+ export interface TaskRecord<S> {
13
+ id: string
14
+ state: TaskState
15
+ /** 排队/运行中的业务快照(提交时的请求摘要)。 */
16
+ snapshot: S
17
+ /** 成功结果(succeeded 时存在)。 */
18
+ result: unknown | null
19
+ /** 失败信息(failed/cancelled 时存在)。 */
20
+ error: string | null
21
+ createdAt: string
22
+ startedAt: string | null
23
+ finishedAt: string | null
24
+ }
25
+
26
+ interface TaskEntry<S> {
27
+ record: TaskRecord<S>
28
+ run: (signal: AbortSignal) => Promise<unknown>
29
+ controller: AbortController
30
+ }
31
+
32
+ export interface RunnerOptions {
33
+ /** 并发上限(0 = 不限)。 */
34
+ maxConcurrent: number
35
+ /** 任务 id 生成器(缺省自增)。 */
36
+ nextId?: () => string
37
+ }
38
+
39
+ /**
40
+ * 并发任务执行器。
41
+ * - submit 入队即返回记录引用(状态经 record 可变字段即时可见);
42
+ * - 每次完成自动拉起下一段(pump 链式,无定时器);
43
+ * - cancel 对 queued 置 cancelled、对 running 发 abort(任务体自行响应)。
44
+ */
45
+ export class TaskRunner<S> {
46
+ private readonly entries = new Map<string, TaskEntry<S>>()
47
+ private readonly order: string[] = []
48
+ private maxConcurrent: number
49
+ private readonly nextId: () => string
50
+ /** 完结任务保留条数(防 Map 膨胀;由调用方配置)。 */
51
+ private readonly keepFinished: number
52
+
53
+ constructor(options: RunnerOptions, keepFinished = 200) {
54
+ this.maxConcurrent = Math.max(0, Math.floor(options.maxConcurrent))
55
+ this.nextId = options.nextId ?? defaultIdCounter()
56
+ this.keepFinished = keepFinished
57
+ }
58
+
59
+ /** 并发上限热更(配置热生效入口;进行中任务不受影响)。 */
60
+ reconfigure(maxConcurrent: number): void {
61
+ this.maxConcurrent = Math.max(0, Math.floor(maxConcurrent))
62
+ }
63
+
64
+ /** 提交任务:返回记录(后续状态经同一引用轮询)。 */
65
+ submit(snapshot: S, run: (signal: AbortSignal) => Promise<unknown>): TaskRecord<S> {
66
+ const controller = new AbortController()
67
+ const record: TaskRecord<S> = {
68
+ id: this.nextId(),
69
+ state: 'queued',
70
+ snapshot,
71
+ result: null,
72
+ error: null,
73
+ createdAt: new Date().toISOString(),
74
+ startedAt: null,
75
+ finishedAt: null,
76
+ }
77
+ this.entries.set(record.id, { record, run, controller })
78
+ this.order.push(record.id)
79
+ void this.pump()
80
+ return record
81
+ }
82
+
83
+ /** 单任务记录查询。 */
84
+ get(id: string): TaskRecord<S> | null {
85
+ return this.entries.get(id)?.record ?? null
86
+ }
87
+
88
+ /** 全部记录(完结在前端按需过滤)。 */
89
+ list(): TaskRecord<S>[] {
90
+ return this.order
91
+ .map((id) => this.entries.get(id)?.record)
92
+ .filter((record): record is TaskRecord<S> => record !== undefined)
93
+ }
94
+
95
+ /** 取消:queued 直接置 cancelled;running 发 abort(异步生效)。 */
96
+ cancel(id: string): boolean {
97
+ const entry = this.entries.get(id)
98
+ if (entry === undefined) return false
99
+ if (entry.record.state === 'queued') {
100
+ entry.record.state = 'cancelled'
101
+ entry.record.finishedAt = new Date().toISOString()
102
+ this.prune()
103
+ return true
104
+ }
105
+ if (entry.record.state === 'running') {
106
+ entry.controller.abort()
107
+ return true
108
+ }
109
+ return false
110
+ }
111
+
112
+ /** 活跃数(queued + running)。 */
113
+ get activeCount(): number {
114
+ let count = 0
115
+ for (const entry of this.entries.values()) {
116
+ if (entry.record.state === 'queued' || entry.record.state === 'running') count += 1
117
+ }
118
+ return count
119
+ }
120
+
121
+ /** 拉起可运行任务至并发上限。 */
122
+ private async pump(): Promise<void> {
123
+ for (;;) {
124
+ const capacity =
125
+ this.maxConcurrent === 0
126
+ ? Number.POSITIVE_INFINITY
127
+ : this.maxConcurrent - this.runningCount()
128
+ if (capacity <= 0) return
129
+ const next = this.order
130
+ .map((id) => this.entries.get(id))
131
+ .find((entry) => entry !== undefined && entry.record.state === 'queued')
132
+ if (next === undefined) return
133
+ this.start(next)
134
+ if (this.maxConcurrent !== 0) return
135
+ }
136
+ }
137
+
138
+ private start(entry: TaskEntry<S>): void {
139
+ entry.record.state = 'running'
140
+ entry.record.startedAt = new Date().toISOString()
141
+ const signal = entry.controller.signal
142
+ void entry
143
+ .run(signal)
144
+ .then((result) => {
145
+ // abort 后 gate 仍可能正常 resolve:以 signal.aborted 为准判失败。
146
+ if (signal.aborted) {
147
+ entry.record.state = 'failed'
148
+ entry.record.error = '任务已取消'
149
+ entry.record.finishedAt = new Date().toISOString()
150
+ return
151
+ }
152
+ if (entry.record.state !== 'running') return
153
+ entry.record.state = 'succeeded'
154
+ entry.record.result = result
155
+ entry.record.finishedAt = new Date().toISOString()
156
+ })
157
+ .catch((error: unknown) => {
158
+ if (entry.record.state === 'cancelled') return
159
+ entry.record.state = 'failed'
160
+ entry.record.error = error instanceof Error ? error.message : String(error)
161
+ entry.record.finishedAt = new Date().toISOString()
162
+ })
163
+ .finally(() => {
164
+ this.prune()
165
+ void this.pump()
166
+ })
167
+ }
168
+
169
+ private runningCount(): number {
170
+ let count = 0
171
+ for (const entry of this.entries.values()) {
172
+ if (entry.record.state === 'running') count += 1
173
+ }
174
+ return count
175
+ }
176
+
177
+ /** 清理最旧完结条目(保持 Map 上限)。 */
178
+ private prune(): void {
179
+ const finished = this.order.filter((id) => {
180
+ const state = this.entries.get(id)?.record.state
181
+ return state === 'succeeded' || state === 'failed' || state === 'cancelled'
182
+ })
183
+ const overflow = finished.length - this.keepFinished
184
+ if (overflow <= 0) return
185
+ for (const id of finished.slice(0, overflow)) {
186
+ this.entries.delete(id)
187
+ const index = this.order.indexOf(id)
188
+ if (index >= 0) this.order.splice(index, 1)
189
+ }
190
+ }
191
+ }
192
+
193
+ /** 缺省 id 计数器。 */
194
+ function defaultIdCounter(): () => string {
195
+ let counter = 0
196
+ return () => {
197
+ counter += 1
198
+ return `task-${counter}`
199
+ }
200
+ }