dsh-plugin-container 0.0.0-stage → 0.2.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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +341 -2
  3. package/cordis.patch.yml +3 -0
  4. package/docs/ai-chat.png +0 -0
  5. package/docs/ai-new-session.png +0 -0
  6. package/docs/apple-container.png +0 -0
  7. package/docs/attach.png +0 -0
  8. package/docs/docker-console.png +0 -0
  9. package/docs/docker-overview.png +0 -0
  10. package/docs/docker-volumes.png +0 -0
  11. package/docs/image-architecture.png +0 -0
  12. package/docs/package-install.png +0 -0
  13. package/docs/provider-switch.png +0 -0
  14. package/docs/run-container.png +0 -0
  15. package/index.js +138 -0
  16. package/lib/client.js +3499 -0
  17. package/package.json +62 -4
  18. package/src/client/api.js +94 -0
  19. package/src/client/attach.jsx +133 -0
  20. package/src/client/components.jsx +301 -0
  21. package/src/client/hooks.js +161 -0
  22. package/src/client/i18n.js +496 -0
  23. package/src/client/icons.jsx +219 -0
  24. package/src/client/index.jsx +76 -0
  25. package/src/client/page.jsx +755 -0
  26. package/src/client/run-container.jsx +187 -0
  27. package/src/client/runs.js +182 -0
  28. package/src/client/store.js +91 -0
  29. package/src/client/styles.js +363 -0
  30. package/src/client/tabs/compose.jsx +94 -0
  31. package/src/client/tabs/console.jsx +211 -0
  32. package/src/client/tabs/containers.jsx +143 -0
  33. package/src/client/tabs/images.jsx +127 -0
  34. package/src/client/tabs/networks.jsx +91 -0
  35. package/src/client/tabs/overview.jsx +266 -0
  36. package/src/client/tabs/volumes.jsx +76 -0
  37. package/src/host/apple-cli.js +307 -0
  38. package/src/host/apple.js +471 -0
  39. package/src/host/attach.js +243 -0
  40. package/src/host/commands.js +235 -0
  41. package/src/host/docker-cli.js +405 -0
  42. package/src/host/jobs.js +245 -0
  43. package/src/host/parsers.js +484 -0
  44. package/src/host/runtime.js +285 -0
  45. package/src/host/service.js +832 -0
  46. package/src/host/tools.js +198 -0
  47. package/src/host/util.js +97 -0
@@ -0,0 +1,405 @@
1
+ /**
2
+ * Docker CLI discovery and fully-specified child process execution.
3
+ *
4
+ * No shell is involved: every command is an argv array handed to the
5
+ * subprocess seam. The CLI path is pinned for the lifetime of a process but
6
+ * re-resolved when a status check fails with a missing executable.
7
+ */
8
+ import { accessSync, constants, statSync } from 'node:fs'
9
+ import { homedir } from 'node:os'
10
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
11
+
12
+ import { codeOf, messageOf } from './util.js'
13
+ import { summarizeStderr } from './parsers.js'
14
+
15
+ const DEFAULT_MAX_OUTPUT = 1024 * 1024
16
+ const DEFAULT_QUERY_TIMEOUT = 20_000
17
+ const RESOLVE_RETRY_MS = 10_000
18
+
19
+ export class DockerCliError extends Error {
20
+ /** @param {string} message @param {string} [code] @param {Record<string, unknown>} [details] */
21
+ constructor(message, code = 'docker/cli-missing', details = {}) {
22
+ super(message)
23
+ this.name = 'DockerCliError'
24
+ this.code = code
25
+ this.details = details
26
+ }
27
+ }
28
+
29
+ export class DockerCommandError extends Error {
30
+ /** @param {string} message @param {{code?: string, stderr?: string, details?: Record<string, unknown>}} [options] */
31
+ constructor(message, options = {}) {
32
+ super(message)
33
+ this.name = 'DockerCommandError'
34
+ this.code = options.code ?? 'docker/exec-failed'
35
+ this.stderr = options.stderr ?? ''
36
+ this.details = options.details ?? {}
37
+ }
38
+ }
39
+
40
+ /** @param {number} bytes */
41
+ function clampBytes(bytes) {
42
+ if (!Number.isFinite(bytes)) return DEFAULT_MAX_OUTPUT
43
+ return Math.min(Math.max(Math.trunc(bytes), 64 * 1024), 8 * 1024 * 1024)
44
+ }
45
+
46
+ /** @param {string} value */
47
+ function executable(path) {
48
+ try {
49
+ accessSync(path, constants.X_OK)
50
+ return statSync(path).isFile()
51
+ } catch {
52
+ return false
53
+ }
54
+ }
55
+
56
+ export class DockerCli {
57
+ /**
58
+ * @param {{
59
+ * subprocess: any,
60
+ * config?: Record<string, any>,
61
+ * env?: NodeJS.ProcessEnv,
62
+ * home?: string,
63
+ * platform?: NodeJS.Platform,
64
+ * logger?: any,
65
+ * }} options
66
+ */
67
+ constructor(options) {
68
+ this.subprocess = options.subprocess
69
+ this.config = options.config ?? {}
70
+ this.env = options.env ?? process.env
71
+ this.home = options.home ?? homedir()
72
+ this.platform = options.platform ?? process.platform
73
+ this.logger = options.logger
74
+ this.maxOutput = clampBytes(Number(this.config.maxOutputBytes ?? DEFAULT_MAX_OUTPUT))
75
+ this.queryTimeoutMs = Number(this.config.queryTimeoutMs ?? DEFAULT_QUERY_TIMEOUT)
76
+ this.dockerPath = undefined
77
+ this.colimaPath = undefined
78
+ this.dockerError = undefined
79
+ this.lastDockerAttempt = 0
80
+ this.lastColimaAttempt = 0
81
+ }
82
+
83
+ /** @returns {string[]} */
84
+ dockerCandidates() {
85
+ const configured = typeof this.config.dockerPath === 'string' && this.config.dockerPath.trim() !== '' ? this.config.dockerPath.trim() : null
86
+ const envPath = typeof this.env.DOCKER_PATH === 'string' && this.env.DOCKER_PATH.trim() !== '' ? this.env.DOCKER_PATH.trim() : null
87
+ return [
88
+ configured,
89
+ envPath,
90
+ '/opt/homebrew/bin/docker',
91
+ '/usr/local/bin/docker',
92
+ join(this.home, '.docker/bin/docker'),
93
+ '/Applications/Docker.app/Contents/Resources/bin/docker',
94
+ ].filter((value, index, list) => typeof value === 'string' && value !== '' && list.indexOf(value) === index)
95
+ }
96
+
97
+ /** @returns {string[]} */
98
+ colimaCandidates() {
99
+ const configured = typeof this.config.colimaPath === 'string' && this.config.colimaPath.trim() !== '' ? this.config.colimaPath.trim() : null
100
+ return [
101
+ configured,
102
+ '/opt/homebrew/bin/colima',
103
+ '/usr/local/bin/colima',
104
+ join(this.home, 'go/bin/colima'),
105
+ ].filter((value, index, list) => typeof value === 'string' && value !== '' && list.indexOf(value) === index)
106
+ }
107
+
108
+ /** @param {string} command @param {AbortSignal | undefined} signal */
109
+ async resolveOnPath(command, signal) {
110
+ if (typeof this.subprocess.resolveExecutable !== 'function') return undefined
111
+ try {
112
+ return await this.subprocess.resolveExecutable(command, undefined, signal)
113
+ } catch (error) {
114
+ if (error?.name === 'SubprocessExecutableNotFoundError' || codeOf(error) === 'ENOENT') return undefined
115
+ throw error
116
+ }
117
+ }
118
+
119
+ /**
120
+ * @param {{signal?: AbortSignal, force?: boolean}} [options]
121
+ * @returns {Promise<string>}
122
+ */
123
+ async ensureDocker(options = {}) {
124
+ if (this.dockerPath !== undefined) return this.dockerPath
125
+ const now = Date.now()
126
+ if (!options.force && now - this.lastDockerAttempt < RESOLVE_RETRY_MS && this.dockerError !== undefined) throw this.dockerError
127
+ this.lastDockerAttempt = now
128
+ const configured = typeof this.config.dockerPath === 'string' && this.config.dockerPath.trim() !== '' ? this.config.dockerPath.trim() : null
129
+ if (configured !== null) {
130
+ const path = isAbsolute(configured) ? configured : resolve(this.home, configured)
131
+ if (executable(path)) {
132
+ this.dockerPath = path
133
+ this.dockerError = undefined
134
+ return path
135
+ }
136
+ const error = new DockerCliError(
137
+ `配置的 dockerPath 不可执行: ${configured}`,
138
+ 'docker/cli-missing',
139
+ { candidates: [configured] },
140
+ )
141
+ this.dockerError = error
142
+ throw error
143
+ }
144
+ for (const candidate of this.dockerCandidates()) {
145
+ if (candidate === configured) continue
146
+ const path = isAbsolute(candidate) ? candidate : resolve(this.home, candidate)
147
+ if (executable(path)) {
148
+ this.dockerPath = path
149
+ this.dockerError = undefined
150
+ return path
151
+ }
152
+ }
153
+ const onPath = await this.resolveOnPath('docker', options.signal)
154
+ if (onPath !== undefined && executable(onPath)) {
155
+ this.dockerPath = onPath
156
+ this.dockerError = undefined
157
+ return onPath
158
+ }
159
+ const error = new DockerCliError(
160
+ '未找到 docker 命令。请安装 Docker Desktop,或安装 colima 并启动运行时。',
161
+ 'docker/cli-missing',
162
+ { candidates: this.dockerCandidates() },
163
+ )
164
+ this.dockerError = error
165
+ throw error
166
+ }
167
+
168
+ /**
169
+ * @param {{signal?: AbortSignal, force?: boolean}} [options]
170
+ * @returns {Promise<string | null>}
171
+ */
172
+ async ensureColima(options = {}) {
173
+ if (this.colimaPath !== undefined) return this.colimaPath
174
+ const now = Date.now()
175
+ if (!options.force && now - this.lastColimaAttempt < RESOLVE_RETRY_MS && this.colimaPath === null) return null
176
+ this.lastColimaAttempt = now
177
+ const configured = typeof this.config.colimaPath === 'string' && this.config.colimaPath.trim() !== '' ? this.config.colimaPath.trim() : null
178
+ if (configured !== null) {
179
+ const path = isAbsolute(configured) ? configured : resolve(this.home, configured)
180
+ if (executable(path)) {
181
+ this.colimaPath = path
182
+ return path
183
+ }
184
+ this.colimaPath = null
185
+ return null
186
+ }
187
+ for (const candidate of this.colimaCandidates()) {
188
+ const path = isAbsolute(candidate) ? candidate : resolve(this.home, candidate)
189
+ if (executable(path)) {
190
+ this.colimaPath = path
191
+ return path
192
+ }
193
+ }
194
+ const onPath = await this.resolveOnPath('colima', options.signal)
195
+ if (onPath !== undefined && executable(onPath)) {
196
+ this.colimaPath = onPath
197
+ return onPath
198
+ }
199
+ this.colimaPath = null
200
+ return null
201
+ }
202
+
203
+ /** @param {string} path */
204
+ markDockerMissing(path) {
205
+ if (this.dockerPath === path) {
206
+ this.dockerPath = undefined
207
+ this.dockerError = undefined
208
+ this.lastDockerAttempt = 0
209
+ }
210
+ }
211
+
212
+ /** @returns {NodeJS.ProcessEnv} */
213
+ childEnv() {
214
+ const pathParts = []
215
+ if (this.dockerPath !== undefined) pathParts.push(dirname(this.dockerPath))
216
+ if (typeof this.colimaPath === 'string') pathParts.push(dirname(this.colimaPath))
217
+ pathParts.push('/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin')
218
+ if (typeof this.env.PATH === 'string') pathParts.push(...this.env.PATH.split(':'))
219
+ const PATH = [...new Set(pathParts.filter(Boolean))].join(':')
220
+ return {
221
+ PATH,
222
+ NO_COLOR: '1',
223
+ CLICOLOR: '0',
224
+ TERM: 'dumb',
225
+ DOCKER_CLI_HINTS: 'false',
226
+ DOCKER_CLIENT_TIMEOUT: String(this.config.dockerClientTimeoutSec ?? 10),
227
+ COMPOSE_HTTP_TIMEOUT: String(this.config.composeHttpTimeoutSec ?? 20),
228
+ }
229
+ }
230
+
231
+ /**
232
+ * @param {string[]} subargv
233
+ * @param {{context?: string | null, cwd?: string, timeoutMs?: number, signal?: AbortSignal, maxBytes?: number}} [options]
234
+ */
235
+ argvFor(subargv, options = {}) {
236
+ const path = this.dockerPath
237
+ if (path === undefined) throw new DockerCliError('docker 尚未解析', 'docker/cli-missing')
238
+ return [path, ...(options.context ? ['--context', options.context] : []), ...subargv]
239
+ }
240
+
241
+ /**
242
+ * Execute one Docker command and collect bounded stdout/stderr.
243
+ * @param {string[]} argv fully-resolved argv including the docker path
244
+ * @param {{cwd?: string, timeoutMs?: number, signal?: AbortSignal, maxBytes?: number}} [options]
245
+ */
246
+ async exec(argv, options = {}) {
247
+ const maxBytes = clampBytes(options.maxBytes ?? this.maxOutput)
248
+ const timeoutMs = Math.min(Math.max(Number(options.timeoutMs ?? this.queryTimeoutMs), 1000), 10 * 60_000)
249
+ const abort = new AbortController()
250
+ let timedOut = false
251
+ const onAbort = () => abort.abort(options.signal?.reason)
252
+ options.signal?.addEventListener('abort', onAbort, { once: true })
253
+ if (options.signal?.aborted === true) abort.abort(options.signal.reason)
254
+ const timeout = setTimeout(() => {
255
+ timedOut = true
256
+ abort.abort(new Error('docker command timed out'))
257
+ }, timeoutMs)
258
+ timeout.unref?.()
259
+
260
+ let handle
261
+ try {
262
+ handle = this.subprocess.spawn({
263
+ argv,
264
+ cwd: options.cwd ?? this.home,
265
+ stdio: { stdin: 'ignore', stdout: { maxBytes }, stderr: { maxBytes } },
266
+ graceMs: 2000,
267
+ env: this.childEnv(),
268
+ signal: abort.signal,
269
+ })
270
+ } catch (error) {
271
+ clearTimeout(timeout)
272
+ options.signal?.removeEventListener('abort', onAbort)
273
+ throw new DockerCommandError(`无法启动 docker: ${messageOf(error)}`, { code: 'docker/exec-failed' })
274
+ }
275
+
276
+ let outcome
277
+ try {
278
+ outcome = await handle.done
279
+ } catch (error) {
280
+ clearTimeout(timeout)
281
+ options.signal?.removeEventListener('abort', onAbort)
282
+ this.markDockerMissing(argv[0])
283
+ throw new DockerCommandError(`docker 进程失败: ${messageOf(error)}`, { code: 'docker/exec-failed' })
284
+ }
285
+ clearTimeout(timeout)
286
+ options.signal?.removeEventListener('abort', onAbort)
287
+
288
+ const read = (reader) => {
289
+ if (reader === undefined) return { text: '', truncated: false }
290
+ const value = reader.readFrom(0)
291
+ return { text: value.text, truncated: value.lossy === true, nextOffset: value.nextOffset }
292
+ }
293
+ const stdout = read(handle.collected?.stdout)
294
+ const stderr = read(handle.collected?.stderr)
295
+ return {
296
+ exitCode: outcome.exitCode,
297
+ signal: outcome.signal,
298
+ stdout: stdout.text,
299
+ stderr: stderr.text,
300
+ truncated: stdout.truncated || stderr.truncated,
301
+ timedOut,
302
+ argv,
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Execute a Docker subcommand with the selected context and parse-friendly
308
+ * error reporting.
309
+ * @param {string[]} subargv
310
+ * @param {{context?: string | null, cwd?: string, timeoutMs?: number, signal?: AbortSignal, maxBytes?: number}} [options]
311
+ */
312
+ async docker(subargv, options = {}) {
313
+ const path = await this.ensureDocker({ signal: options.signal })
314
+ const argv = this.argvFor(subargv, options)
315
+ const result = await this.exec(argv, { ...options, cwd: options.cwd ?? this.home })
316
+ if (result.timedOut || result.exitCode !== 0) {
317
+ const detail = summarizeStderr(result.stderr)
318
+ throw new DockerCommandError(
319
+ result.timedOut
320
+ ? `docker 命令超时(${subargv[0] ?? ''})`
321
+ : detail.trim() === ''
322
+ ? `docker ${subargv[0] ?? ''} 退出码 ${String(result.exitCode)}`
323
+ : detail,
324
+ {
325
+ code: result.timedOut ? 'docker/timeout' : 'docker/exec-failed',
326
+ stderr: result.stderr,
327
+ details: {
328
+ exitCode: result.exitCode,
329
+ signal: result.signal,
330
+ timedOut: result.timedOut,
331
+ argv,
332
+ },
333
+ },
334
+ )
335
+ }
336
+ return { ...result, path }
337
+ }
338
+
339
+ /**
340
+ * Execute a non-docker helper (colima/open/osascript) and collect output.
341
+ * @param {string} command absolute path or PATH name
342
+ * @param {string[]} args
343
+ * @param {{timeoutMs?: number, signal?: AbortSignal, maxBytes?: number, cwd?: string}} [options]
344
+ */
345
+ async program(command, args, options = {}) {
346
+ const maxBytes = clampBytes(options.maxBytes ?? this.maxOutput)
347
+ const timeoutMs = Math.min(Math.max(Number(options.timeoutMs ?? this.queryTimeoutMs), 1000), 30 * 60_000)
348
+ const abort = new AbortController()
349
+ let timedOut = false
350
+ const onAbort = () => abort.abort(options.signal?.reason)
351
+ options.signal?.addEventListener('abort', onAbort, { once: true })
352
+ if (options.signal?.aborted === true) abort.abort(options.signal.reason)
353
+ const timeout = setTimeout(() => {
354
+ timedOut = true
355
+ abort.abort(new Error('command timed out'))
356
+ }, timeoutMs)
357
+ timeout.unref?.()
358
+ let handle
359
+ try {
360
+ handle = this.subprocess.spawn({
361
+ argv: [command, ...args],
362
+ cwd: options.cwd ?? this.home,
363
+ stdio: { stdin: 'ignore', stdout: { maxBytes }, stderr: { maxBytes } },
364
+ graceMs: 2000,
365
+ env: this.childEnv(),
366
+ signal: abort.signal,
367
+ })
368
+ } catch (error) {
369
+ clearTimeout(timeout)
370
+ options.signal?.removeEventListener('abort', onAbort)
371
+ throw new DockerCommandError(`无法启动 ${basename(command)}: ${messageOf(error)}`, { code: 'docker/exec-failed' })
372
+ }
373
+ let outcome
374
+ try {
375
+ outcome = await handle.done
376
+ } catch (error) {
377
+ clearTimeout(timeout)
378
+ options.signal?.removeEventListener('abort', onAbort)
379
+ throw new DockerCommandError(`${basename(command)} 进程失败: ${messageOf(error)}`, { code: 'docker/exec-failed' })
380
+ }
381
+ clearTimeout(timeout)
382
+ options.signal?.removeEventListener('abort', onAbort)
383
+ const stdout = handle.collected?.stdout?.readFrom(0)?.text ?? ''
384
+ const stderr = handle.collected?.stderr?.readFrom(0)?.text ?? ''
385
+ if (timedOut || outcome.exitCode !== 0) {
386
+ throw new DockerCommandError(
387
+ timedOut ? `${basename(command)} 超时` : summarizeStderr(stderr) || `${basename(command)} 退出码 ${String(outcome.exitCode)}`, {
388
+ code: timedOut ? 'docker/timeout' : 'docker/exec-failed',
389
+ stderr,
390
+ details: { command, args, exitCode: outcome.exitCode, timedOut },
391
+ })
392
+ }
393
+ return { stdout, stderr, exitCode: outcome.exitCode, signal: outcome.signal, timedOut }
394
+ }
395
+
396
+ /** @param {string} endpoint */
397
+ static isLocalEndpoint(endpoint) {
398
+ return /^(?:unix|npipe):/iu.test(String(endpoint ?? ''))
399
+ }
400
+
401
+ /** @param {string} path */
402
+ static commandName(path) {
403
+ return basename(path)
404
+ }
405
+ }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Long-running Docker/CLI jobs with offset polling, cancellation, timeouts,
3
+ * bounded retention, and a concurrency cap.
4
+ *
5
+ * Jobs are independent of a single RPC call: `run` returns immediately and
6
+ * `poll` reads non-consuming collectors. That makes the terminal resilient to
7
+ * tab switches and navigation while the host command keeps running.
8
+ */
9
+ import { randomUUID } from 'node:crypto'
10
+
11
+ import { DockerCommandError } from './docker-cli.js'
12
+ import { codeOf, messageOf } from './util.js'
13
+
14
+ const MAX_RUNS = 4
15
+ const RETENTION_MS = 10 * 60_000
16
+ const MAX_RECORDS = 30
17
+ const MIN_TIMEOUT_MS = 5_000
18
+ const MAX_TIMEOUT_MS = 30 * 60_000
19
+ const DEFAULT_TIMEOUT_MS = 120_000
20
+
21
+ /** @param {unknown} value @param {number} fallback */
22
+ function clampOffset(value, fallback = 0) {
23
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.trunc(value) : fallback
24
+ }
25
+
26
+ /** @param {unknown} value @param {number} min */
27
+ function clampTimeout(value, min = MIN_TIMEOUT_MS) {
28
+ const requested = typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : DEFAULT_TIMEOUT_MS
29
+ return Math.min(Math.max(requested, min), MAX_TIMEOUT_MS)
30
+ }
31
+
32
+ export class JobManager {
33
+ /**
34
+ * @param {{
35
+ * subprocess: any,
36
+ * cli: import('./docker-cli.js').DockerCli,
37
+ * maxOutputBytes?: number,
38
+ * maxRuns?: number,
39
+ * retentionMs?: number,
40
+ * maxRecords?: number,
41
+ * }} options
42
+ */
43
+ constructor(options) {
44
+ this.subprocess = options.subprocess
45
+ this.cli = options.cli
46
+ this.maxOutputBytes = options.maxOutputBytes ?? 1024 * 1024
47
+ this.maxRuns = options.maxRuns ?? MAX_RUNS
48
+ this.retentionMs = options.retentionMs ?? RETENTION_MS
49
+ this.maxRecords = options.maxRecords ?? MAX_RECORDS
50
+ this.minTimeoutMs = options.minTimeoutMs ?? MIN_TIMEOUT_MS
51
+ this.idPrefix = options.idPrefix ?? 'run'
52
+ /** @type {Map<string, any>} */
53
+ this.records = new Map()
54
+ this.sweeper = setInterval(() => this.sweep(), 60_000)
55
+ this.sweeper.unref?.()
56
+ }
57
+
58
+ /** @param {string} runId */
59
+ find(runId) {
60
+ const record = this.records.get(runId)
61
+ if (record === undefined) {
62
+ throw Object.assign(new Error(`Docker 运行记录不存在或已过期: ${runId}`), { code: 'docker/run-not-found' })
63
+ }
64
+ return record
65
+ }
66
+
67
+ /** @param {any} record */
68
+ publicView(record) {
69
+ return {
70
+ id: record.id,
71
+ argv: record.argv,
72
+ displayCommand: record.displayCommand,
73
+ context: record.context,
74
+ cwd: record.cwd,
75
+ title: record.title,
76
+ status: record.status,
77
+ exitCode: record.exitCode,
78
+ signal: record.signal,
79
+ startedAt: record.startedAt,
80
+ endedAt: record.endedAt,
81
+ timedOut: record.timedOut,
82
+ error: record.error,
83
+ }
84
+ }
85
+
86
+ /**
87
+ * @param {{
88
+ * argv: string[],
89
+ * displayCommand: string,
90
+ * context?: string | null,
91
+ * cwd: string,
92
+ * title?: string,
93
+ * timeoutMs?: number,
94
+ * maxOutputBytes?: number,
95
+ * }} input
96
+ */
97
+ start(input) {
98
+ const active = [...this.records.values()].filter((record) => record.status === 'running' || record.status === 'cancelling')
99
+ if (active.length >= this.maxRuns) {
100
+ throw Object.assign(
101
+ new Error(`同时运行的 Docker 命令已达上限(${this.maxRuns} 个)`),
102
+ { code: 'docker/run-limit', details: { active: active.map((record) => record.id) } },
103
+ )
104
+ }
105
+ if (!Array.isArray(input.argv) || input.argv.length === 0) throw new DockerCommandError('命令为空', { code: 'docker/invalid-command' })
106
+ const timeoutMs = clampTimeout(input.timeoutMs, this.minTimeoutMs)
107
+ const requestedMax = Number(input.maxOutputBytes ?? this.maxOutputBytes)
108
+ const maxBytes = Number.isFinite(requestedMax) ? Math.min(Math.max(requestedMax, 64 * 1024), 8 * 1024 * 1024) : 1024 * 1024
109
+ const id = `${this.idPrefix}_${randomUUID()}`
110
+ const record = {
111
+ id,
112
+ argv: [...input.argv],
113
+ displayCommand: input.displayCommand,
114
+ context: input.context ?? null,
115
+ cwd: input.cwd,
116
+ title: input.title ?? '',
117
+ status: 'running',
118
+ exitCode: null,
119
+ signal: null,
120
+ startedAt: Date.now(),
121
+ endedAt: null,
122
+ timedOut: false,
123
+ cancelled: false,
124
+ error: null,
125
+ handle: undefined,
126
+ stdout: undefined,
127
+ stderr: undefined,
128
+ timer: undefined,
129
+ abort: undefined,
130
+ }
131
+
132
+ try {
133
+ record.handle = this.subprocess.spawn({
134
+ argv: record.argv,
135
+ cwd: record.cwd,
136
+ stdio: { stdin: 'ignore', stdout: { maxBytes }, stderr: { maxBytes } },
137
+ graceMs: 2000,
138
+ env: this.cli.childEnv(),
139
+ })
140
+ } catch (error) {
141
+ throw new DockerCommandError(`无法启动命令: ${messageOf(error)}`, {
142
+ code: codeOf(error) === 'ENOENT' ? 'docker/cli-missing' : 'docker/exec-failed',
143
+ })
144
+ }
145
+
146
+ record.stdout = record.handle.collected?.stdout
147
+ record.stderr = record.handle.collected?.stderr
148
+ record.timer = setTimeout(() => {
149
+ if (record.status !== 'running') return
150
+ record.timedOut = true
151
+ try {
152
+ record.handle?.terminate?.()
153
+ } catch {
154
+ // done still settles the record.
155
+ }
156
+ }, timeoutMs)
157
+ record.timer.unref?.()
158
+
159
+ const finish = (outcome) => {
160
+ if (record.status === 'exited' || record.status === 'cancelled' || record.status === 'timed-out' || record.status === 'failed') return
161
+ clearTimeout(record.timer)
162
+ record.endedAt = Date.now()
163
+ record.exitCode = outcome?.exitCode ?? null
164
+ record.signal = outcome?.signal ?? null
165
+ if (record.timedOut) record.status = 'timed-out'
166
+ else if (record.cancelled === true) record.status = 'cancelled'
167
+ else if (record.exitCode === 0) record.status = 'exited'
168
+ else record.status = 'failed'
169
+ this.sweep()
170
+ }
171
+
172
+ record.handle.done.then(finish, (error) => {
173
+ if (record.status !== 'running') return
174
+ clearTimeout(record.timer)
175
+ record.endedAt = Date.now()
176
+ record.error = messageOf(error)
177
+ record.status = 'failed'
178
+ this.sweep()
179
+ })
180
+
181
+ this.records.set(id, record)
182
+ return this.publicView(record)
183
+ }
184
+
185
+ /**
186
+ * @param {{runId: string, stdoutOffset?: number, stderrOffset?: number}} input
187
+ */
188
+ poll(input) {
189
+ const record = this.find(input.runId)
190
+ const read = (reader, offset) => {
191
+ if (reader === undefined) return { text: '', nextOffset: offset, lossy: false }
192
+ const value = reader.readFrom(clampOffset(offset))
193
+ return { text: value.text, nextOffset: value.nextOffset, lossy: value.lossy === true }
194
+ }
195
+ return {
196
+ run: this.publicView(record),
197
+ stdout: read(record.stdout, clampOffset(input.stdoutOffset)),
198
+ stderr: read(record.stderr, clampOffset(input.stderrOffset)),
199
+ }
200
+ }
201
+
202
+ /** @param {string} runId */
203
+ cancel(runId) {
204
+ const record = this.find(runId)
205
+ if (record.status !== 'running' && record.status !== 'cancelling') return this.publicView(record)
206
+ record.cancelled = true
207
+ record.status = 'cancelling'
208
+ try {
209
+ record.handle?.terminate?.()
210
+ } catch (error) {
211
+ record.error = messageOf(error)
212
+ }
213
+ return this.publicView(record)
214
+ }
215
+
216
+ /** Remove terminal records beyond retention or count. */
217
+ sweep() {
218
+ const now = Date.now()
219
+ const terminal = [...this.records.values()]
220
+ .filter((record) => record.status !== 'running' && record.status !== 'cancelling')
221
+ .sort((left, right) => (left.endedAt ?? left.startedAt) - (right.endedAt ?? right.startedAt))
222
+ for (const record of terminal) {
223
+ if (now - (record.endedAt ?? record.startedAt) > this.retentionMs) this.records.delete(record.id)
224
+ }
225
+ const remainingTerminal = [...this.records.values()]
226
+ .filter((record) => record.status !== 'running' && record.status !== 'cancelling')
227
+ .sort((left, right) => (right.endedAt ?? right.startedAt) - (left.endedAt ?? left.startedAt))
228
+ for (const record of remainingTerminal.slice(this.maxRecords)) this.records.delete(record.id)
229
+ }
230
+
231
+ dispose() {
232
+ clearInterval(this.sweeper)
233
+ for (const record of this.records.values()) {
234
+ clearTimeout(record.timer)
235
+ if (record.status === 'running' || record.status === 'cancelling') {
236
+ try {
237
+ record.handle?.terminate?.()
238
+ } catch {
239
+ // Best effort on plugin unload.
240
+ }
241
+ }
242
+ }
243
+ this.records.clear()
244
+ }
245
+ }