dsh-vscode-mode 0.9.0 → 0.11.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.
@@ -0,0 +1,588 @@
1
+ /**
2
+ * dsh-vscode-mode host — SVN update job with incremental output and bounded replay.
3
+ * @author ddj 2026年09月24号
4
+ */
5
+ import { randomUUID } from 'node:crypto'
6
+ import { isAbsolute, relative, resolve } from 'node:path'
7
+ import type { Readable } from 'node:stream'
8
+ import type { SvnUpdateCounts, SvnUpdatePhase, SvnUpdatePoll, SvnUpdateRow, SvnUpdateState } from './shared/svn.js'
9
+ import { updateLineOf } from './svnText.js'
10
+
11
+ const ROW_CAP = 20_000
12
+ const PAGE_SIZE = 250
13
+ const RAW_CAP = 32_000
14
+ const LINE_CAP = 16_000
15
+ const JOB_CAP = 16
16
+ const RESULT_TTL_MS = 10 * 60_000
17
+ const UPDATE_MAX_MS = 60 * 60_000
18
+ const COLLECT_MS = 100
19
+
20
+ interface OutputRead { text: string; nextOffset: number; lossy: boolean }
21
+ interface OutputReader { readFrom(offset: number): OutputRead }
22
+ interface ExitFacts { exitCode: number | null; code?: number | null }
23
+ interface ManagedHandle {
24
+ done: Promise<ExitFacts>
25
+ terminate(): void
26
+ collected?: { stdout?: OutputReader; stderr?: OutputReader }
27
+ }
28
+ interface TerminalHandle {
29
+ done: Promise<ExitFacts>
30
+ terminate(): Promise<void>
31
+ output: Readable
32
+ }
33
+ export interface UpdateProcess {
34
+ spawn(spec: Record<string, unknown>): ManagedHandle
35
+ spawnTerminal?: (spec: Record<string, unknown>) => Promise<TerminalHandle>
36
+ }
37
+ export interface UpdateTarget {
38
+ cwd: string
39
+ root: string
40
+ path: string
41
+ exe: string
42
+ }
43
+ interface UpdateJob {
44
+ id: string
45
+ root: string
46
+ cwd: string
47
+ target: string
48
+ phase: SvnUpdatePhase
49
+ counts: SvnUpdateCounts
50
+ revision: number | null
51
+ error: string
52
+ rawTail: string
53
+ truncated: boolean
54
+ startedAt: number
55
+ endedAt: number | null
56
+ rows: SvnUpdateRow[]
57
+ seq: number
58
+ pending: string
59
+ cancelled: boolean
60
+ exited: boolean
61
+ probeAbort?: AbortController
62
+ timer?: ReturnType<typeof setInterval>
63
+ deadline?: ReturnType<typeof setTimeout>
64
+ stop?: () => void
65
+ }
66
+
67
+ /**
68
+ * 创建每个任务独立的 SVN 状态计数。
69
+ * @author ddj 2026年09月24号
70
+ * @returns 初始计数
71
+ */
72
+ function emptyCounts(): SvnUpdateCounts {
73
+ return { A: 0, U: 0, D: 0, G: 0, C: 0, E: 0, R: 0 }
74
+ }
75
+
76
+ /**
77
+ * 工作副本根作为并发键;Windows 路径大小写不敏感。
78
+ * @author ddj 2026年09月24号
79
+ * @param root 工作副本根
80
+ * @param platform 目标平台
81
+ * @returns 并发键
82
+ */
83
+ function rootKey(root: string, platform: NodeJS.Platform): string {
84
+ return platform === 'win32' ? root.toLowerCase() : root
85
+ }
86
+
87
+ /**
88
+ * 有状态更新任务:原 RPC 与新后台任务独立,测试可注入进程服务/时钟。
89
+ * @author ddj 2026年09月24号
90
+ */
91
+ export class SvnUpdateJobs {
92
+ private readonly jobs = new Map<string, UpdateJob>()
93
+ private readonly platform: NodeJS.Platform
94
+ private readonly now: () => number
95
+
96
+ /**
97
+ * 建立任务管理器。
98
+ * @author ddj 2026年09月24号
99
+ * @param service DSH subprocess 服务获取器
100
+ * @param platform 平台
101
+ * @param now 时钟
102
+ */
103
+ constructor(
104
+ private readonly service: () => UpdateProcess | null,
105
+ platform: NodeJS.Platform = process.platform,
106
+ now: () => number = Date.now,
107
+ ) {
108
+ this.platform = platform
109
+ this.now = now
110
+ }
111
+
112
+ /**
113
+ * 同根重复点击只返回当前任务;旧结果可由 active 取回,新点击启动新任务。
114
+ * @author ddj 2026年09月24号
115
+ * @param target 已验证的工作副本目标
116
+ * @returns 任务初始状态
117
+ */
118
+ start(target: UpdateTarget): SvnUpdateState {
119
+ this.prune()
120
+ const key = rootKey(target.root, this.platform)
121
+ const old = this.jobs.get(key)
122
+ if (old && !this.isFinal(old.phase)) return this.stateOf(old)
123
+ if (!old && this.jobs.size >= JOB_CAP) throw new Error('同时保留的 SVN 更新任务过多,请稍后重试')
124
+ const job: UpdateJob = {
125
+ id: randomUUID(), root: target.root, cwd: target.cwd, target: target.path,
126
+ phase: 'starting', counts: emptyCounts(), revision: null, error: '', rawTail: '',
127
+ truncated: false, startedAt: this.now(), endedAt: null, rows: [], seq: 0,
128
+ pending: '', cancelled: false, exited: false,
129
+ }
130
+ this.jobs.set(key, job)
131
+ void this.launch(job, target.exe)
132
+ return this.stateOf(job)
133
+ }
134
+
135
+ /**
136
+ * 游标分页取状态;即使前端掉线,有限窗口仍可重新读取。
137
+ * @author ddj 2026年09月24号
138
+ * @param root 已验证会话的工作副本根
139
+ * @param jobId 任务 id
140
+ * @param since 最后一条已读序号
141
+ * @returns 一页增量及状态
142
+ */
143
+ poll(root: string, jobId: string, since: number): SvnUpdatePoll {
144
+ const job = this.getJob(root, jobId)
145
+ const offset = job.rows[0]?.seq ?? job.seq + 1
146
+ const cursor = Math.max(offset - 1, Number.isFinite(since) ? Math.floor(since) : 0)
147
+ const start = Math.max(0, cursor - offset + 1)
148
+ const rows = job.rows.slice(start, start + PAGE_SIZE)
149
+ return { ...this.stateOf(job), rows, offset, totalSeq: job.seq, nextSeq: rows.at(-1)?.seq ?? cursor }
150
+ }
151
+
152
+ /**
153
+ * 请求终止整个受管进程范围;真实退出前保持 cancelling 状态。
154
+ * @author ddj 2026年09月24号
155
+ * @param root 已验证会话的工作副本根
156
+ * @param jobId 任务 id
157
+ * @returns 当前状态
158
+ */
159
+ cancel(root: string, jobId: string): SvnUpdateState {
160
+ const job = this.getJob(root, jobId)
161
+ if (!this.isFinal(job.phase) && !job.exited) {
162
+ job.cancelled = true
163
+ job.phase = 'cancelling'
164
+ job.probeAbort?.abort(new Error('用户已取消 SVN 更新'))
165
+ try { job.stop?.() } catch (error) { job.error = String(error) }
166
+ }
167
+ return this.stateOf(job)
168
+ }
169
+
170
+ /**
171
+ * 按当前会话工作副本恢复进行中或最近一次结果。
172
+ * @author ddj 2026年09月24号
173
+ * @param root 已验证的工作副本根
174
+ * @returns 状态或 null
175
+ */
176
+ active(root: string): SvnUpdateState | null {
177
+ this.prune()
178
+ const job = this.jobs.get(rootKey(root, this.platform))
179
+ return job ? this.stateOf(job) : null
180
+ }
181
+
182
+ /**
183
+ * 插件卸载回收任务与定时器。
184
+ * @author ddj 2026年09月24号
185
+ */
186
+ dispose(): void {
187
+ for (const job of this.jobs.values()) {
188
+ if (job.timer) clearInterval(job.timer)
189
+ if (job.deadline) clearTimeout(job.deadline)
190
+ if (!this.isFinal(job.phase)) {
191
+ job.cancelled = true
192
+ job.probeAbort?.abort(new Error('SVN 插件已卸载'))
193
+ try { job.stop?.() } catch { /* 卸载继续清理其它任务 */ }
194
+ }
195
+ }
196
+ this.jobs.clear()
197
+ }
198
+
199
+ /**
200
+ * 先用只读 version 探测捕获通道,再允许启动会修改工作副本的 update。
201
+ * @author ddj 2026年09月24号
202
+ * @param job 待启动任务
203
+ * @param exe 已配置 SVN CLI
204
+ */
205
+ private async launch(job: UpdateJob, exe: string): Promise<void> {
206
+ const controller = new AbortController()
207
+ job.probeAbort = controller
208
+ const probeDeadline = setTimeout(() => controller.abort(new Error('SVN 输出探测超时(8 秒)')), 8000)
209
+ probeDeadline.unref?.()
210
+ try {
211
+ const service = this.service()
212
+ if (!service) throw new Error('subprocess 服务不可用')
213
+ const modes = this.platform === 'win32' ? ['terminal', 'collect'] : ['collect', 'terminal']
214
+ let mode: string | null = null
215
+ let reason = ''
216
+ for (const candidate of modes) {
217
+ controller.signal.throwIfAborted()
218
+ try { await this.probe(service, exe, job.cwd, candidate, controller.signal); mode = candidate; break }
219
+ catch (error) {
220
+ if (controller.signal.aborted) throw error
221
+ reason = String(error)
222
+ }
223
+ }
224
+ if (job.cancelled) { this.finish(job, null); return }
225
+ if (!mode) throw new Error('SVN 输出通道不可用:' + reason)
226
+ clearTimeout(probeDeadline)
227
+ await this.startUpdate(job, service, mode, exe, controller)
228
+ } catch (error) {
229
+ job.error = String(error)
230
+ this.finish(job, null)
231
+ } finally {
232
+ clearTimeout(probeDeadline)
233
+ job.probeAbort = undefined
234
+ }
235
+ }
236
+
237
+ /**
238
+ * 分配实际更新句柄时继续允许取消;未完成的分配不盲目重试更新。
239
+ * @author ddj 2026年09月24号
240
+ * @param job 当前任务
241
+ * @param service DSH 子进程服务
242
+ * @param mode 已探测可用的流式通道
243
+ * @param exe SVN 可执行文件
244
+ * @param controller 启动阶段的取消信号
245
+ */
246
+ private async startUpdate(job: UpdateJob, service: UpdateProcess, mode: string, exe: string, controller: AbortController): Promise<void> {
247
+ const argv = [exe, '--non-interactive', 'update', '--', job.target || '.']
248
+ job.phase = 'running'
249
+ job.stop = () => controller.abort(new Error('更新启动期间被取消'))
250
+ job.deadline = setTimeout(() => {
251
+ job.error = 'SVN 更新超过 60 分钟,已请求停止;工作副本可能已部分更新'
252
+ try { job.stop?.() } catch { /* 退出路径仍会报告错误 */ }
253
+ }, UPDATE_MAX_MS)
254
+ job.deadline.unref?.()
255
+ if (mode === 'terminal' && service.spawnTerminal) {
256
+ const pending = service.spawnTerminal({ ...this.terminalSpec(argv, job.cwd), signal: controller.signal })
257
+ const handle = await this.awaitTerminal(pending, controller.signal)
258
+ job.stop = () => { void handle.terminate().catch((error) => { job.error = String(error) }) }
259
+ if (job.cancelled) job.stop()
260
+ this.watchTerminal(job, handle)
261
+ } else {
262
+ const handle = service.spawn({ ...this.collectSpec(argv, job.cwd), signal: controller.signal })
263
+ job.stop = () => handle.terminate()
264
+ if (job.cancelled) job.stop()
265
+ this.watchCollect(job, handle)
266
+ }
267
+ }
268
+
269
+ /**
270
+ * 终端句柄分配与 Abort 之间的微任务间隙也必须回收晚到句柄。
271
+ * @author ddj 2026年09月24号
272
+ * @param pending 分配中的终端
273
+ * @param signal 用户取消或截止信号
274
+ * @returns 可受管的终端句柄
275
+ */
276
+ private async awaitTerminal(pending: Promise<TerminalHandle>, signal: AbortSignal): Promise<TerminalHandle> {
277
+ let handle: TerminalHandle | null = null
278
+ const stop = () => { if (handle) void handle.terminate().catch(() => {}) }
279
+ signal.addEventListener('abort', stop, { once: true })
280
+ try {
281
+ const guarded = pending.then((ready) => {
282
+ handle = ready
283
+ if (signal.aborted) stop()
284
+ return ready
285
+ })
286
+ return await this.waitProbe(guarded, signal, () => {})
287
+ } finally {
288
+ signal.removeEventListener('abort', stop)
289
+ }
290
+ }
291
+
292
+ /**
293
+ * 探针/启动 Promise 可由超时和用户取消立即打断,并终止已分配的句柄。
294
+ * @author ddj 2026年09月24号
295
+ * @param pending 待完成操作
296
+ * @param signal 任务取消信号
297
+ * @param stop 停止已经分配的子进程
298
+ * @returns 操作结果
299
+ */
300
+ private waitProbe<T>(pending: Promise<T>, signal: AbortSignal, stop: () => void): Promise<T> {
301
+ return new Promise<T>((resolve, reject) => {
302
+ let finished = false
303
+ const abort = () => {
304
+ if (finished) return
305
+ finished = true
306
+ try { stop() } catch { /* 使用取消原因报告 */ }
307
+ reject(signal.reason ?? new Error('SVN 探测已取消'))
308
+ }
309
+ if (signal.aborted) { abort(); void pending.catch(() => {}); return }
310
+ signal.addEventListener('abort', abort, { once: true })
311
+ pending.then((value) => {
312
+ signal.removeEventListener('abort', abort)
313
+ if (!finished) { finished = true; resolve(value) }
314
+ }, (error) => {
315
+ signal.removeEventListener('abort', abort)
316
+ if (!finished) { finished = true; reject(error) }
317
+ })
318
+ })
319
+ }
320
+
321
+ /**
322
+ * 探针失败只换读法,不会重试一次已启动的实际 update。
323
+ * @author ddj 2026年09月24号
324
+ * @param service DSH 服务
325
+ * @param exe SVN 命令
326
+ * @param cwd 进程目录
327
+ * @param mode 输出通道
328
+ * @param signal 用户取消或探针超时
329
+ */
330
+ private async probe(service: UpdateProcess, exe: string, cwd: string, mode: string, signal: AbortSignal): Promise<void> {
331
+ signal.throwIfAborted()
332
+ const argv = [exe, '--version', '--quiet']
333
+ if (mode === 'terminal') {
334
+ if (!service.spawnTerminal) throw new Error('终端输出通道不可用')
335
+ const pending = service.spawnTerminal({ ...this.terminalSpec(argv, cwd), signal })
336
+ const handle = await this.awaitTerminal(pending, signal)
337
+ let text = ''
338
+ let failure = ''
339
+ const drained = new Promise<void>((resolve) => {
340
+ handle.output.on('data', (chunk) => { text += String(chunk) })
341
+ handle.output.once('end', resolve)
342
+ handle.output.once('error', (error) => { failure = String(error); resolve() })
343
+ })
344
+ const outcome = await this.waitProbe(handle.done, signal, () => { void handle.terminate().catch(() => {}) })
345
+ await this.waitProbe(Promise.race([drained, new Promise<void>((resolve) => {
346
+ const timer = setTimeout(resolve, 2000)
347
+ timer.unref?.()
348
+ })]), signal, () => { void handle.terminate().catch(() => {}) })
349
+ if (outcome.exitCode !== 0 || !text.trim() || failure) throw new Error('终端输出探测失败:' + failure)
350
+ return
351
+ }
352
+ const handle = service.spawn({ ...this.collectSpec(argv, cwd), signal })
353
+ const outcome = await this.waitProbe(handle.done, signal, () => handle.terminate())
354
+ const text = handle.collected?.stdout?.readFrom(0).text ?? ''
355
+ if (outcome.exitCode !== 0 || !text.trim()) throw new Error('收集输出探测失败')
356
+ }
357
+
358
+ /**
359
+ * DSH PTY 读取:TextDecoder 保证跨数据块的 UTF-8 字符不被截断。
360
+ * @author ddj 2026年09月24号
361
+ * @param job 活动任务
362
+ * @param handle 终端句柄
363
+ */
364
+ private watchTerminal(job: UpdateJob, handle: TerminalHandle): void {
365
+ const decoder = new TextDecoder()
366
+ let drained = false
367
+ let finishOutput: () => void = () => {}
368
+ const outputDone = new Promise<void>((resolve) => { finishOutput = resolve })
369
+ handle.output.on('data', (chunk: Buffer | string) => {
370
+ const text = typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true })
371
+ this.ingest(job, text)
372
+ })
373
+ handle.output.on('end', () => { drained = true; this.ingest(job, decoder.decode()); finishOutput() })
374
+ handle.output.on('error', (error) => { job.error = '读取输出失败:' + String(error); finishOutput() })
375
+ void handle.done.then(async (outcome) => {
376
+ job.exited = true
377
+ if (!drained) {
378
+ await Promise.race([outputDone, new Promise<void>((resolve) => {
379
+ const timeout = setTimeout(resolve, 2000)
380
+ timeout.unref?.()
381
+ })])
382
+ }
383
+ this.finish(job, outcome.exitCode)
384
+ }, (error) => { job.exited = true; job.error = String(error); this.finish(job, null) })
385
+ }
386
+
387
+ /**
388
+ * 管道收集的读取器独立管理 stdout/stderr 字节偏移;只消费完整行,避免截断中文。
389
+ * @author ddj 2026年09月24号
390
+ * @param job 活动任务
391
+ * @param handle 子进程句柄
392
+ */
393
+ private watchCollect(job: UpdateJob, handle: ManagedHandle): void {
394
+ const offsets = { stdout: 0, stderr: 0 }
395
+ job.timer = setInterval(() => this.readCollect(job, handle, offsets, false), COLLECT_MS)
396
+ job.timer.unref?.()
397
+ void handle.done.then((outcome) => {
398
+ job.exited = true
399
+ if (job.timer) clearInterval(job.timer)
400
+ this.readCollect(job, handle, offsets, true)
401
+ this.finish(job, outcome.exitCode ?? outcome.code ?? null)
402
+ }, (error) => { job.exited = true; job.error = String(error); this.finish(job, null) })
403
+ }
404
+
405
+ /**
406
+ * 从两个 bounded collector 取完整行;lossy 时标记不完整且不伪造丢失计数。
407
+ * @author ddj 2026年09月24号
408
+ * @param job 活动任务
409
+ * @param handle 子进程句柄
410
+ * @param offsets 各路已消费字节位置
411
+ * @param final 进程已结束,可以消费末尾无换行内容
412
+ */
413
+ private readCollect(job: UpdateJob, handle: ManagedHandle, offsets: { stdout: number; stderr: number }, final: boolean): void {
414
+ for (const key of ['stdout', 'stderr'] as const) {
415
+ const reader = handle.collected?.[key]
416
+ if (!reader) { job.truncated = true; continue }
417
+ const read = reader.readFrom(offsets[key])
418
+ if (read.lossy) {
419
+ job.truncated = true
420
+ offsets[key] = read.nextOffset
421
+ job.pending = ''
422
+ this.recordLine(job, '输出前段已截断,以下是可恢复的尾部记录', null)
423
+ const firstEnd = read.text.indexOf('\n')
424
+ if (firstEnd >= 0) this.ingest(job, read.text.slice(firstEnd + 1))
425
+ continue
426
+ }
427
+ const last = final ? read.text.length : read.text.lastIndexOf('\n') + 1
428
+ if (!last) continue
429
+ const chunk = read.text.slice(0, last)
430
+ offsets[key] = final ? read.nextOffset : offsets[key] + Buffer.byteLength(chunk, 'utf8')
431
+ this.ingest(job, chunk)
432
+ }
433
+ }
434
+
435
+ /**
436
+ * 增量行缓冲:ANSI 和 CR/LF 归一;过长单行主动截断并告警。
437
+ * @author ddj 2026年09月24号
438
+ * @param job 活动任务
439
+ * @param text 新输出
440
+ */
441
+ private ingest(job: UpdateJob, text: string): void {
442
+ if (!text) return
443
+ job.pending += text.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
444
+ const lines = job.pending.split(/\r\n|\n|\r/)
445
+ job.pending = lines.pop() ?? ''
446
+ for (const line of lines) this.recordLine(job, line, updateLineOf(line))
447
+ if (job.pending.length > LINE_CAP) {
448
+ job.truncated = true
449
+ this.recordLine(job, job.pending.slice(0, LINE_CAP) + '…(单行过长)', null)
450
+ job.pending = ''
451
+ }
452
+ }
453
+
454
+ /**
455
+ * 记录有界行与原文;修订号只读 svn 自己的完成/无变化行。
456
+ * @author ddj 2026年09月24号
457
+ * @param job 活动任务
458
+ * @param line 完整输出行
459
+ * @param entry 可识别的逐文件状态
460
+ */
461
+ private recordLine(job: UpdateJob, line: string, entry: ReturnType<typeof updateLineOf>): void {
462
+ const raw = line.trimEnd()
463
+ if (!raw) return
464
+ job.rawTail = (job.rawTail + raw + '\n').slice(-RAW_CAP)
465
+ const rev = /(?:Updated to revision|At revision|已更新到版本|版本)\s*\.?\s*(\d+)/i.exec(raw)
466
+ if (rev) job.revision = Number(rev[1])
467
+ if (entry) job.counts[entry.action] += 1
468
+ const path = entry?.path ?? ''
469
+ const row: SvnUpdateRow = {
470
+ seq: ++job.seq, action: entry?.action ?? null, path,
471
+ relPath: entry ? this.relPathOf(job, path) : null,
472
+ label: entry?.label ?? raw.trim(), raw,
473
+ }
474
+ job.rows.push(row)
475
+ if (job.rows.length > ROW_CAP) { job.rows.splice(0, 1000); job.truncated = true }
476
+ }
477
+
478
+ /**
479
+ * 只给工作区内的输出路径开放文件动作(不信任 CLI 文本直接当作目标)。
480
+ * @author ddj 2026年09月24号
481
+ * @param job 活动任务
482
+ * @param path SVN 输出路径
483
+ * @returns 工作区相对路径或 null
484
+ */
485
+ private relPathOf(job: UpdateJob, path: string): string | null {
486
+ const abs = resolve(job.cwd, path)
487
+ const wcRel = relative(job.root, abs)
488
+ const rel = relative(job.cwd, abs)
489
+ if (isAbsolute(wcRel) || wcRel === '..' || wcRel.startsWith('..\\') || wcRel.startsWith('../')) return null
490
+ if (isAbsolute(rel) || rel === '..' || rel.startsWith('..\\') || rel.startsWith('../')) return null
491
+ return rel.replace(/\\/g, '/') || null
492
+ }
493
+
494
+ /**
495
+ * 保留终态行供用户检查,取消不能误报成功。
496
+ * @author ddj 2026年09月24号
497
+ * @param job 活动任务
498
+ * @param code 真实退出码
499
+ */
500
+ private finish(job: UpdateJob, code: number | null): void {
501
+ if (this.isFinal(job.phase)) return
502
+ if (job.pending.trim()) this.recordLine(job, job.pending, updateLineOf(job.pending))
503
+ job.pending = ''
504
+ if (job.timer) { clearInterval(job.timer); job.timer = undefined }
505
+ if (job.deadline) { clearTimeout(job.deadline); job.deadline = undefined }
506
+ job.endedAt = this.now()
507
+ if (job.cancelled) job.phase = 'cancelled'
508
+ else if (code === 0 && !job.error) job.phase = 'completed'
509
+ else {
510
+ job.phase = 'failed'
511
+ job.error ||= 'SVN 更新失败(退出码 ' + String(code) + '):' + job.rawTail.slice(-500)
512
+ }
513
+ }
514
+
515
+ /**
516
+ * 快照不暴露可变数组;耗时使用现场时钟或终态时间。
517
+ * @author ddj 2026年09月24号
518
+ * @param job 目标任务
519
+ * @returns 纯数据快照
520
+ */
521
+ private stateOf(job: UpdateJob): SvnUpdateState {
522
+ return {
523
+ jobId: job.id, target: job.target, phase: job.phase, counts: { ...job.counts },
524
+ revision: job.revision, elapsedMs: Math.max(0, (job.endedAt ?? this.now()) - job.startedAt),
525
+ error: job.error, rawTail: this.isFinal(job.phase) || job.truncated ? job.rawTail : '',
526
+ truncated: job.truncated, nextSeq: job.seq,
527
+ }
528
+ }
529
+
530
+ /**
531
+ * 任务 ID 必须属于调用会话的工作副本根。
532
+ * @author ddj 2026年09月24号
533
+ * @param root 已验证工作副本根
534
+ * @param jobId 客户端任务 id
535
+ * @returns job
536
+ */
537
+ private getJob(root: string, jobId: string): UpdateJob {
538
+ this.prune()
539
+ const job = this.jobs.get(rootKey(root, this.platform))
540
+ if (!job || job.id !== jobId) throw new Error('SVN 更新任务不存在或工作区不匹配')
541
+ return job
542
+ }
543
+
544
+ /**
545
+ * 清理过期终态,不丢活动中的更新。
546
+ * @author ddj 2026年09月24号
547
+ */
548
+ private prune(): void {
549
+ for (const [key, job] of this.jobs) {
550
+ if (job.endedAt !== null && this.now() - job.endedAt > RESULT_TTL_MS) this.jobs.delete(key)
551
+ }
552
+ }
553
+
554
+ /**
555
+ * 终态判断集中定义,避免取消请求被误视作结束。
556
+ * @author ddj 2026年09月24号
557
+ * @param phase 当前阶段
558
+ * @returns 是否结束
559
+ */
560
+ private isFinal(phase: SvnUpdatePhase): boolean {
561
+ return phase === 'completed' || phase === 'failed' || phase === 'cancelled'
562
+ }
563
+
564
+ /**
565
+ * 不启 shell:每个 argv 直接传入,标准输入忽略且提供进程清理预算。
566
+ * @author ddj 2026年09月24号
567
+ * @param argv 可执行文件及参数
568
+ * @param cwd 目标工作目录
569
+ * @returns DSH terminal spec
570
+ */
571
+ private terminalSpec(argv: string[], cwd: string): Record<string, unknown> {
572
+ return { argv, cwd, rows: 30, cols: 180, terminalType: 'xterm-256color', graceMs: 10_000 }
573
+ }
574
+
575
+ /**
576
+ * collected reader 大于单次 RPC:后台按 100ms 消费,避免 1MB 尾部静默丢头。
577
+ * @author ddj 2026年09月24号
578
+ * @param argv 可执行文件及参数
579
+ * @param cwd 目标工作目录
580
+ * @returns DSH process spec
581
+ */
582
+ private collectSpec(argv: string[], cwd: string): Record<string, unknown> {
583
+ return {
584
+ argv, cwd, graceMs: 10_000,
585
+ stdio: { stdin: 'ignore', stdout: { maxBytes: 4 * 1024 * 1024 }, stderr: { maxBytes: 128 * 1024 } },
586
+ }
587
+ }
588
+ }