@ai_mind/dsh-a2a 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +444 -2
- package/client.js +868 -0
- package/cordis.patch.yml +21 -0
- package/icon.svg +15 -0
- package/index.js +59 -0
- package/lib/auth.js +196 -0
- package/lib/bridge.js +518 -0
- package/lib/card.js +214 -0
- package/lib/client-discovery.js +58 -0
- package/lib/config.js +565 -0
- package/lib/executor.js +357 -0
- package/lib/files.js +52 -0
- package/lib/limits.js +133 -0
- package/lib/panel.js +357 -0
- package/lib/prompt.js +161 -0
- package/lib/server.js +456 -0
- package/lib/store.js +288 -0
- package/lib/util.js +221 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +54 -4
package/lib/executor.js
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A2A `AgentExecutor` implementation backed by the Harness agent runtime.
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* - translate the SDK event-bus contract into a well-formed task lifecycle
|
|
6
|
+
* (`SUBMITTED` → `WORKING` → terminal state, with streaming artifacts);
|
|
7
|
+
* - serialize turns inside one A2A context and bound total concurrency;
|
|
8
|
+
* - implement cancellation for both queued and running turns;
|
|
9
|
+
* - turn every failure into a `TASK_STATE_FAILED` status carrying a
|
|
10
|
+
* human-readable message instead of leaving a task stuck in `WORKING`.
|
|
11
|
+
*
|
|
12
|
+
* @module @ai_mind/dsh-a2a/executor
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { AgentEvent } from '@a2a-js/sdk/server'
|
|
16
|
+
import { Role, TaskState } from '@a2a-js/sdk'
|
|
17
|
+
import { errorMessage, newId, nowIso, truncate } from './util.js'
|
|
18
|
+
import { buildAgentPrompt } from './prompt.js'
|
|
19
|
+
import { Semaphore } from './limits.js'
|
|
20
|
+
|
|
21
|
+
/** Artifact id used for the assistant's textual answer. */
|
|
22
|
+
const RESPONSE_ARTIFACT_ID = 'response'
|
|
23
|
+
|
|
24
|
+
/** @returns {object} an A2A `Message` in SDK object form */
|
|
25
|
+
function agentMessage({ taskId, contextId, text }) {
|
|
26
|
+
return {
|
|
27
|
+
role: Role.ROLE_AGENT,
|
|
28
|
+
messageId: newId(),
|
|
29
|
+
taskId,
|
|
30
|
+
contextId,
|
|
31
|
+
parts: [
|
|
32
|
+
{
|
|
33
|
+
content: { $case: 'text', value: text },
|
|
34
|
+
metadata: undefined,
|
|
35
|
+
filename: '',
|
|
36
|
+
mediaType: 'text/plain',
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
metadata: {},
|
|
40
|
+
extensions: [],
|
|
41
|
+
referenceTaskIds: [],
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @returns {object} a text-only A2A artifact */
|
|
46
|
+
function textArtifact(text) {
|
|
47
|
+
return {
|
|
48
|
+
artifactId: RESPONSE_ARTIFACT_ID,
|
|
49
|
+
name: 'response',
|
|
50
|
+
description: 'Final response from the DeepSeek Harness agent.',
|
|
51
|
+
parts: [
|
|
52
|
+
{
|
|
53
|
+
content: { $case: 'text', value: text },
|
|
54
|
+
metadata: undefined,
|
|
55
|
+
filename: '',
|
|
56
|
+
mediaType: 'text/plain',
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
metadata: undefined,
|
|
60
|
+
extensions: [],
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A2A executor bridging request events to `AgentRuntime`.
|
|
66
|
+
*/
|
|
67
|
+
export class HarnessAgentExecutor {
|
|
68
|
+
#runtime
|
|
69
|
+
#config
|
|
70
|
+
#logger
|
|
71
|
+
#semaphore
|
|
72
|
+
#active = new Map()
|
|
73
|
+
#taskContexts = new Map()
|
|
74
|
+
#maxContextEntries
|
|
75
|
+
#disposed = false
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {object} options
|
|
79
|
+
* @param {import('./bridge.js').AgentRuntime} options.runtime
|
|
80
|
+
* @param {Readonly<object>} options.config
|
|
81
|
+
* @param {{ info: (...args: unknown[]) => void, warn: (...args: unknown[]) => void }} options.logger
|
|
82
|
+
*/
|
|
83
|
+
constructor({ runtime, config, logger }) {
|
|
84
|
+
this.#runtime = runtime
|
|
85
|
+
this.#config = config
|
|
86
|
+
this.#logger = logger
|
|
87
|
+
this.#semaphore = new Semaphore(config.agent.maxConcurrentTasks)
|
|
88
|
+
this.#maxContextEntries = Math.max(1000, config.tasks.maxTasks)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** @returns {{ active: number, queued: number, max: number }} */
|
|
92
|
+
concurrency() {
|
|
93
|
+
return this.#semaphore.stats()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Publish one status update, remembering task context ids for cancellation. */
|
|
97
|
+
#status(eventBus, taskId, contextId, state, messageText) {
|
|
98
|
+
this.#remember(taskId, contextId)
|
|
99
|
+
const status = {
|
|
100
|
+
state,
|
|
101
|
+
message: messageText === undefined ? undefined : agentMessage({ taskId, contextId, text: messageText }),
|
|
102
|
+
timestamp: nowIso(),
|
|
103
|
+
}
|
|
104
|
+
eventBus.publish(
|
|
105
|
+
AgentEvent.statusUpdate({
|
|
106
|
+
taskId,
|
|
107
|
+
contextId,
|
|
108
|
+
status,
|
|
109
|
+
metadata: undefined,
|
|
110
|
+
}),
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
#remember(taskId, contextId) {
|
|
115
|
+
if (contextId === undefined || contextId === '') return
|
|
116
|
+
if (this.#taskContexts.has(taskId)) {
|
|
117
|
+
this.#taskContexts.delete(taskId)
|
|
118
|
+
} else if (this.#taskContexts.size >= this.#maxContextEntries) {
|
|
119
|
+
const oldest = this.#taskContexts.keys().next().value
|
|
120
|
+
if (oldest !== undefined) this.#taskContexts.delete(oldest)
|
|
121
|
+
}
|
|
122
|
+
this.#taskContexts.set(taskId, contextId)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#artifact(eventBus, taskId, contextId, text, { lastChunk, replace }) {
|
|
126
|
+
eventBus.publish(
|
|
127
|
+
AgentEvent.artifactUpdate({
|
|
128
|
+
taskId,
|
|
129
|
+
contextId,
|
|
130
|
+
artifact: textArtifact(text),
|
|
131
|
+
append: !replace,
|
|
132
|
+
lastChunk,
|
|
133
|
+
metadata: undefined,
|
|
134
|
+
}),
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Execute one A2A message turn.
|
|
140
|
+
*
|
|
141
|
+
* @param {import('@a2a-js/sdk/server').RequestContext} requestContext
|
|
142
|
+
* @param {import('@a2a-js/sdk/server').ExecutionEventBus} eventBus
|
|
143
|
+
*/
|
|
144
|
+
async execute(requestContext, eventBus) {
|
|
145
|
+
const taskId = requestContext.taskId
|
|
146
|
+
const contextId = requestContext.contextId
|
|
147
|
+
const userMessage = requestContext.userMessage
|
|
148
|
+
const existingTask = requestContext.task
|
|
149
|
+
|
|
150
|
+
const state = {
|
|
151
|
+
taskId,
|
|
152
|
+
contextId,
|
|
153
|
+
terminal: false,
|
|
154
|
+
cancelRequested: false,
|
|
155
|
+
turn: undefined,
|
|
156
|
+
worker: undefined,
|
|
157
|
+
emitted: '',
|
|
158
|
+
settle: undefined,
|
|
159
|
+
}
|
|
160
|
+
state.settled = new Promise((resolve) => {
|
|
161
|
+
state.settle = resolve
|
|
162
|
+
})
|
|
163
|
+
this.#active.set(taskId, state)
|
|
164
|
+
this.#remember(taskId, contextId)
|
|
165
|
+
|
|
166
|
+
const claimTerminal = () => {
|
|
167
|
+
if (state.terminal) return false
|
|
168
|
+
state.terminal = true
|
|
169
|
+
return true
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Every execution must begin with a Task or Message event.
|
|
173
|
+
const snapshot =
|
|
174
|
+
existingTask ??
|
|
175
|
+
{
|
|
176
|
+
id: taskId,
|
|
177
|
+
contextId,
|
|
178
|
+
status: { state: TaskState.TASK_STATE_SUBMITTED, message: undefined, timestamp: nowIso() },
|
|
179
|
+
artifacts: [],
|
|
180
|
+
history: [userMessage],
|
|
181
|
+
metadata: {},
|
|
182
|
+
}
|
|
183
|
+
eventBus.publish(AgentEvent.task(snapshot))
|
|
184
|
+
|
|
185
|
+
let permit
|
|
186
|
+
try {
|
|
187
|
+
try {
|
|
188
|
+
permit = await this.#semaphore.acquire({ timeoutMs: this.#config.agent.queueTimeoutMs })
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (claimTerminal()) {
|
|
191
|
+
this.#status(
|
|
192
|
+
eventBus,
|
|
193
|
+
taskId,
|
|
194
|
+
contextId,
|
|
195
|
+
TaskState.TASK_STATE_REJECTED,
|
|
196
|
+
`The A2A server is at capacity (${errorMessage(error)}). Retry later.`,
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
this.#runtime.recordOutcome('failed')
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (state.cancelRequested) {
|
|
204
|
+
if (claimTerminal()) {
|
|
205
|
+
this.#status(eventBus, taskId, contextId, TaskState.TASK_STATE_CANCELED, 'Task canceled.')
|
|
206
|
+
}
|
|
207
|
+
this.#runtime.recordOutcome('canceled')
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let prompt
|
|
212
|
+
try {
|
|
213
|
+
const cwd = await this.#runtime.resolveCwd()
|
|
214
|
+
const built = await buildAgentPrompt({
|
|
215
|
+
message: userMessage,
|
|
216
|
+
config: this.#config,
|
|
217
|
+
cwd,
|
|
218
|
+
logger: this.#logger,
|
|
219
|
+
})
|
|
220
|
+
prompt = built.text
|
|
221
|
+
if (prompt.trim() === '') throw new Error('message contains no usable content parts')
|
|
222
|
+
} catch (error) {
|
|
223
|
+
if (claimTerminal()) {
|
|
224
|
+
this.#status(eventBus, taskId, contextId, TaskState.TASK_STATE_FAILED, `Invalid request: ${errorMessage(error)}`)
|
|
225
|
+
}
|
|
226
|
+
this.#runtime.recordOutcome('failed')
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const worker = this.#runtime.acquire(contextId)
|
|
231
|
+
state.worker = worker
|
|
232
|
+
const turn = worker.createTurn(taskId, {
|
|
233
|
+
onStart: () => {
|
|
234
|
+
if (!state.terminal && !state.cancelRequested) {
|
|
235
|
+
this.#status(eventBus, taskId, contextId, TaskState.TASK_STATE_WORKING)
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
onDelta: (text) => {
|
|
239
|
+
if (state.terminal || state.cancelRequested) return
|
|
240
|
+
state.emitted += text
|
|
241
|
+
this.#artifact(eventBus, taskId, contextId, text, { lastChunk: false, replace: false })
|
|
242
|
+
},
|
|
243
|
+
onReset: () => {
|
|
244
|
+
if (state.terminal || state.cancelRequested) return
|
|
245
|
+
state.emitted = ''
|
|
246
|
+
this.#artifact(eventBus, taskId, contextId, '', { lastChunk: false, replace: true })
|
|
247
|
+
},
|
|
248
|
+
})
|
|
249
|
+
state.turn = turn
|
|
250
|
+
|
|
251
|
+
if (state.cancelRequested) {
|
|
252
|
+
if (claimTerminal()) {
|
|
253
|
+
this.#status(eventBus, taskId, contextId, TaskState.TASK_STATE_CANCELED, 'Task canceled.')
|
|
254
|
+
}
|
|
255
|
+
this.#runtime.recordOutcome('canceled')
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
let outcome
|
|
260
|
+
try {
|
|
261
|
+
outcome = await worker.runTurn(turn, {
|
|
262
|
+
prompt,
|
|
263
|
+
timeoutMs: this.#config.agent.turnTimeoutMs,
|
|
264
|
+
})
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if (claimTerminal()) {
|
|
267
|
+
this.#status(eventBus, taskId, contextId, TaskState.TASK_STATE_FAILED, errorMessage(error))
|
|
268
|
+
}
|
|
269
|
+
this.#runtime.recordOutcome('failed')
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (outcome.canceled || state.cancelRequested) {
|
|
274
|
+
if (claimTerminal()) {
|
|
275
|
+
this.#status(eventBus, taskId, contextId, TaskState.TASK_STATE_CANCELED, 'Task canceled.')
|
|
276
|
+
}
|
|
277
|
+
this.#runtime.recordOutcome('canceled')
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (outcome.error) {
|
|
282
|
+
if (claimTerminal()) {
|
|
283
|
+
this.#status(eventBus, taskId, contextId, TaskState.TASK_STATE_FAILED, truncate(outcome.error.message, 4000))
|
|
284
|
+
}
|
|
285
|
+
this.#runtime.recordOutcome('failed')
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const text = truncate(outcome.text ?? '', this.#config.limits.maxArtifactChars)
|
|
290
|
+
if (text !== '' && !state.terminal) {
|
|
291
|
+
// Consolidate the streamed chunks into one authoritative artifact.
|
|
292
|
+
this.#artifact(eventBus, taskId, contextId, text, { lastChunk: true, replace: true })
|
|
293
|
+
}
|
|
294
|
+
if (claimTerminal()) {
|
|
295
|
+
this.#status(eventBus, taskId, contextId, TaskState.TASK_STATE_COMPLETED)
|
|
296
|
+
}
|
|
297
|
+
this.#runtime.recordOutcome('completed')
|
|
298
|
+
} catch (error) {
|
|
299
|
+
this.#logger.warn(`[a2a] task ${taskId} failed unexpectedly: ${errorMessage(error)}`)
|
|
300
|
+
if (claimTerminal()) {
|
|
301
|
+
this.#status(eventBus, taskId, contextId, TaskState.TASK_STATE_FAILED, errorMessage(error))
|
|
302
|
+
}
|
|
303
|
+
this.#runtime.recordOutcome('failed')
|
|
304
|
+
} finally {
|
|
305
|
+
this.#active.delete(taskId)
|
|
306
|
+
state.settle()
|
|
307
|
+
permit?.()
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Request cancellation of a running or queued turn.
|
|
313
|
+
*
|
|
314
|
+
* @param {string} taskId
|
|
315
|
+
* @param {import('@a2a-js/sdk/server').ExecutionEventBus} eventBus
|
|
316
|
+
*/
|
|
317
|
+
async cancelTask(taskId, eventBus) {
|
|
318
|
+
const state = this.#active.get(taskId)
|
|
319
|
+
if (state === undefined) {
|
|
320
|
+
// No executor state: the task either finished concurrently or the SDK
|
|
321
|
+
// is cancelling a task whose execution never started. Publish a
|
|
322
|
+
// terminal CANCELED status so the SDK's cancel wait always settles.
|
|
323
|
+
const contextId = this.#taskContexts.get(taskId) ?? ''
|
|
324
|
+
this.#status(eventBus, taskId, contextId, TaskState.TASK_STATE_CANCELED, 'Task canceled.')
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
state.cancelRequested = true
|
|
328
|
+
if (state.turn !== undefined && state.worker !== undefined) {
|
|
329
|
+
state.worker.cancel(state.turn)
|
|
330
|
+
}
|
|
331
|
+
await Promise.race([state.settled, new Promise((resolve) => setTimeout(resolve, 10000).unref?.())])
|
|
332
|
+
if (!state.terminal) {
|
|
333
|
+
state.terminal = true
|
|
334
|
+
this.#status(eventBus, taskId, state.contextId, TaskState.TASK_STATE_CANCELED, 'Task canceled.')
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Cancel every in-flight turn during shutdown and release the executor. */
|
|
339
|
+
async dispose() {
|
|
340
|
+
this.#disposed = true
|
|
341
|
+
for (const state of this.#active.values()) {
|
|
342
|
+
state.cancelRequested = true
|
|
343
|
+
try {
|
|
344
|
+
state.worker?.cancel(state.turn)
|
|
345
|
+
} catch {
|
|
346
|
+
/* shutting down */
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
this.#active.clear()
|
|
350
|
+
this.#taskContexts.clear()
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** @returns {boolean} whether shutdown has begun */
|
|
354
|
+
get disposed() {
|
|
355
|
+
return this.#disposed
|
|
356
|
+
}
|
|
357
|
+
}
|
package/lib/files.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace attachment retention.
|
|
3
|
+
*
|
|
4
|
+
* Inbound `raw` file parts are written under `<cwd>/<files.directory>/<taskId>`
|
|
5
|
+
* so the agent's sandboxed tools can read them. Those directories must not
|
|
6
|
+
* accumulate forever, so the server sweeps ones older than the configured
|
|
7
|
+
* retention window on the same timer as task retention.
|
|
8
|
+
*
|
|
9
|
+
* @module @ai_mind/dsh-a2a/files
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readdir, rm, stat } from 'node:fs/promises'
|
|
13
|
+
import { join, resolve, sep } from 'node:path'
|
|
14
|
+
import { errorMessage } from './util.js'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Delete stale task attachment directories.
|
|
18
|
+
*
|
|
19
|
+
* @param {object} options
|
|
20
|
+
* @param {string} options.cwd absolute session workspace
|
|
21
|
+
* @param {string} options.directory relative inbox directory
|
|
22
|
+
* @param {number} options.retentionMs maximum age before deletion (0 disables)
|
|
23
|
+
* @param {{ warn: (...args: unknown[]) => void }} options.logger
|
|
24
|
+
* @returns {Promise<{ removed: number }>}
|
|
25
|
+
*/
|
|
26
|
+
export async function sweepAttachmentDirectories({ cwd, directory, retentionMs, logger }) {
|
|
27
|
+
if (retentionMs <= 0) return { removed: 0 }
|
|
28
|
+
const root = resolve(cwd, directory)
|
|
29
|
+
if (root !== resolve(cwd) && !root.startsWith(`${resolve(cwd)}${sep}`)) return { removed: 0 }
|
|
30
|
+
let entries
|
|
31
|
+
try {
|
|
32
|
+
entries = await readdir(root, { withFileTypes: true })
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (error?.code !== 'ENOENT') logger.warn(`[a2a] attachment sweep failed: ${errorMessage(error)}`)
|
|
35
|
+
return { removed: 0 }
|
|
36
|
+
}
|
|
37
|
+
const now = Date.now()
|
|
38
|
+
let removed = 0
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
if (!entry.isDirectory()) continue
|
|
41
|
+
const path = join(root, entry.name)
|
|
42
|
+
try {
|
|
43
|
+
const info = await stat(path)
|
|
44
|
+
if (now - info.mtimeMs < retentionMs) continue
|
|
45
|
+
await rm(path, { recursive: true, force: true })
|
|
46
|
+
removed += 1
|
|
47
|
+
} catch (error) {
|
|
48
|
+
logger.warn(`[a2a] cannot remove attachment directory ${path}: ${errorMessage(error)}`)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { removed }
|
|
52
|
+
}
|
package/lib/limits.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Concurrency primitives used by the executor.
|
|
3
|
+
*
|
|
4
|
+
* @module @ai_mind/dsh-a2a/limits
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A FIFO counting semaphore with optional acquisition timeout and abort.
|
|
9
|
+
*
|
|
10
|
+
* A waiter that times out or aborts is removed from the queue, so a late
|
|
11
|
+
* release can never hand a permit to an abandoned request.
|
|
12
|
+
*/
|
|
13
|
+
export class Semaphore {
|
|
14
|
+
#max
|
|
15
|
+
#active = 0
|
|
16
|
+
#queue = []
|
|
17
|
+
|
|
18
|
+
/** @param {number} max maximum simultaneous holders */
|
|
19
|
+
constructor(max) {
|
|
20
|
+
this.#max = Math.max(1, max)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** @returns {{ active: number, queued: number, max: number }} */
|
|
24
|
+
stats() {
|
|
25
|
+
return { active: this.#active, queued: this.#queue.length, max: this.#max }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Acquire one permit.
|
|
30
|
+
*
|
|
31
|
+
* @param {{ timeoutMs?: number, signal?: AbortSignal }} [options]
|
|
32
|
+
* @returns {Promise<() => void>} an idempotent release function
|
|
33
|
+
* @throws when the wait times out or the signal aborts
|
|
34
|
+
*/
|
|
35
|
+
acquire(options = {}) {
|
|
36
|
+
const { timeoutMs = 0, signal } = options
|
|
37
|
+
if (signal?.aborted) return Promise.reject(signal.reason ?? new Error('aborted'))
|
|
38
|
+
|
|
39
|
+
if (this.#active < this.#max) {
|
|
40
|
+
this.#active += 1
|
|
41
|
+
return Promise.resolve(this.#makeRelease())
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const waiter = { resolve, reject, timer: undefined, onAbort: undefined, done: false }
|
|
46
|
+
|
|
47
|
+
const cleanup = () => {
|
|
48
|
+
if (waiter.timer !== undefined) clearTimeout(waiter.timer)
|
|
49
|
+
if (waiter.onAbort !== undefined) signal?.removeEventListener('abort', waiter.onAbort)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const remove = () => {
|
|
53
|
+
const index = this.#queue.indexOf(waiter)
|
|
54
|
+
if (index !== -1) this.#queue.splice(index, 1)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
waiter.fail = (error) => {
|
|
58
|
+
if (waiter.done) return
|
|
59
|
+
waiter.done = true
|
|
60
|
+
cleanup()
|
|
61
|
+
remove()
|
|
62
|
+
reject(error)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
waiter.grant = () => {
|
|
66
|
+
if (waiter.done) return
|
|
67
|
+
waiter.done = true
|
|
68
|
+
cleanup()
|
|
69
|
+
this.#active += 1
|
|
70
|
+
resolve(this.#makeRelease())
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (timeoutMs > 0) {
|
|
74
|
+
waiter.timer = setTimeout(() => {
|
|
75
|
+
waiter.fail(new Error('timed out waiting for an execution slot'))
|
|
76
|
+
}, timeoutMs)
|
|
77
|
+
waiter.timer.unref?.()
|
|
78
|
+
}
|
|
79
|
+
if (signal !== undefined) {
|
|
80
|
+
waiter.onAbort = () => waiter.fail(signal.reason ?? new Error('aborted'))
|
|
81
|
+
signal.addEventListener('abort', waiter.onAbort, { once: true })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
this.#queue.push(waiter)
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
#makeRelease() {
|
|
89
|
+
let released = false
|
|
90
|
+
return () => {
|
|
91
|
+
if (released) return
|
|
92
|
+
released = true
|
|
93
|
+
this.#active = Math.max(0, this.#active - 1)
|
|
94
|
+
const next = this.#queue.shift()
|
|
95
|
+
if (next !== undefined) next.grant()
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Race a promise against a timeout. `onTimeout` runs before the rejection so
|
|
102
|
+
* callers can cancel underlying work.
|
|
103
|
+
*
|
|
104
|
+
* @template T
|
|
105
|
+
* @param {Promise<T>} promise
|
|
106
|
+
* @param {number} ms
|
|
107
|
+
* @param {() => void} [onTimeout]
|
|
108
|
+
* @param {string} [message]
|
|
109
|
+
* @returns {Promise<T>}
|
|
110
|
+
*/
|
|
111
|
+
export function withTimeout(promise, ms, onTimeout, message = 'operation timed out') {
|
|
112
|
+
if (ms <= 0) return promise
|
|
113
|
+
return new Promise((resolve, reject) => {
|
|
114
|
+
const timer = setTimeout(() => {
|
|
115
|
+
try {
|
|
116
|
+
onTimeout?.()
|
|
117
|
+
} finally {
|
|
118
|
+
reject(new Error(message))
|
|
119
|
+
}
|
|
120
|
+
}, ms)
|
|
121
|
+
timer.unref?.()
|
|
122
|
+
promise.then(
|
|
123
|
+
(value) => {
|
|
124
|
+
clearTimeout(timer)
|
|
125
|
+
resolve(value)
|
|
126
|
+
},
|
|
127
|
+
(error) => {
|
|
128
|
+
clearTimeout(timer)
|
|
129
|
+
reject(error)
|
|
130
|
+
},
|
|
131
|
+
)
|
|
132
|
+
})
|
|
133
|
+
}
|