dsh-remote-vs 0.9.3 → 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.
Files changed (44) hide show
  1. package/README.en.md +13 -10
  2. package/README.md +14 -9
  3. package/cordis.patch.yml +10 -5
  4. package/lib/client.js +3319 -1093
  5. package/lib/index.js +211 -9
  6. package/lib/types/client/api.d.ts +12 -0
  7. package/lib/types/client/composer.d.ts +5 -1
  8. package/lib/types/client/exec-output.d.ts +58 -0
  9. package/lib/types/client/i18n.d.ts +1 -1
  10. package/lib/types/client/scm/CommitPane.d.ts +73 -0
  11. package/lib/types/client/scm/ExecPanel.d.ts +34 -0
  12. package/lib/types/client/scm/HistoryPanel.d.ts +12 -4
  13. package/lib/types/client/scm/actions.d.ts +50 -3
  14. package/lib/types/client/scm/commit-model.d.ts +119 -0
  15. package/lib/types/client/store.d.ts +9 -3
  16. package/lib/types/client/vsc/kit.d.ts +16 -0
  17. package/lib/types/git-format.d.ts +5 -0
  18. package/lib/types/git-ops.d.ts +57 -2
  19. package/lib/types/index.d.ts +5 -0
  20. package/lib/types/pool.d.ts +9 -0
  21. package/lib/types/protocol.d.ts +23 -0
  22. package/lib/types/timeouts.d.ts +17 -2
  23. package/package.json +37 -37
  24. package/src/client/SettingsHosts.tsx +2 -1
  25. package/src/client/api.ts +61 -0
  26. package/src/client/composer.ts +11 -2
  27. package/src/client/exec-output.ts +129 -0
  28. package/src/client/i18n.ts +133 -4
  29. package/src/client/scm/CommitPane.tsx +726 -0
  30. package/src/client/scm/ExecPanel.tsx +124 -0
  31. package/src/client/scm/HistoryPanel.tsx +483 -104
  32. package/src/client/scm/ScmPanel.tsx +247 -9
  33. package/src/client/scm/actions.ts +93 -10
  34. package/src/client/scm/commit-model.ts +317 -0
  35. package/src/client/store.tsx +13 -6
  36. package/src/client/vsc/kit.tsx +71 -0
  37. package/src/client/vsc/vsc.css +647 -0
  38. package/src/git-format.ts +7 -3
  39. package/src/git-ops.ts +83 -7
  40. package/src/index.ts +10 -2
  41. package/src/pool.ts +84 -4
  42. package/src/protocol.ts +14 -0
  43. package/src/routes.ts +135 -1
  44. package/src/timeouts.ts +18 -2
package/src/git-ops.ts CHANGED
@@ -114,6 +114,23 @@ export function assertRemoteUrl(url: string): string {
114
114
  return url
115
115
  }
116
116
 
117
+ /**
118
+ * Assert a push refspec (`HEAD:refs/for/master`, `+refs/heads/x:refs/heads/y`).
119
+ *
120
+ * The charset is the union git accepts on the two sides of the optional colon,
121
+ * plus the force marker and the `{}`/`~`/`^`/`*` revision syntax. `assertRef`
122
+ * cannot be reused: it deliberately rejects `:`, and the whole point of a
123
+ * custom refspec is the destination side (Gerrit's `refs/for/<branch>`).
124
+ */
125
+ export function assertPushRefspec(refspec: string): string {
126
+ if (typeof refspec !== 'string' || refspec === '' || refspec.length > 256) throw new Error(`invalid push refspec: ${refspec}`)
127
+ if (!/^\+?[A-Za-z0-9][A-Za-z0-9._/@~^*{}-]*(?::[A-Za-z0-9][A-Za-z0-9._/@~^*{}-]*)?$/.test(refspec)) {
128
+ throw new Error(`invalid push refspec: ${refspec}`)
129
+ }
130
+ if (refspec.includes('..') || refspec.includes('@{')) throw new Error(`invalid push refspec: ${refspec}`)
131
+ return refspec
132
+ }
133
+
117
134
  // -------------------------------------------------------- read builders
118
135
 
119
136
  /** Resolve the enclosing repository root (fails outside a work tree). */
@@ -241,9 +258,43 @@ export function diffCommand(workdir: string, request: DiffRequest = {}): string
241
258
  return capped(parts.join(' '))
242
259
  }
243
260
 
244
- /** Files touched by one commit (`--name-status`, no patch body). */
261
+ /**
262
+ * Files touched by one commit (`--name-status`, no patch body).
263
+ *
264
+ * `--first-parent` is what makes a MERGE commit show anything: plain
265
+ * `git show --name-status <merge>` prints no file rows at all (measured), so
266
+ * the pane said "这个提交没有改动文件" for every merge. With it, the diff is
267
+ * against the first parent — the merge's own contribution — and non-merge
268
+ * commits are byte-for-byte unchanged.
269
+ */
245
270
  export function commitFilesCommand(workdir: string, ref: string): string {
246
- return capped(`${git(workdir)} show --no-color --format= --name-status ${assertRef(ref)} --`)
271
+ return capped(`${git(workdir)} show --no-color --format= --name-status --first-parent ${assertRef(ref)} --`)
272
+ }
273
+
274
+ /**
275
+ * Whole patch of one commit, with the commit header stripped (`--format=`), so
276
+ * the output starts at the first `diff --git` line and parses as a plain
277
+ * unified diff. Capped like every read-only builder: a commit that rewrites a
278
+ * generated file would otherwise ship megabytes for one sidebar pane.
279
+ * `--first-parent` makes merge commits readable (see `commitFilesCommand`).
280
+ */
281
+ export function commitPatchCommand(workdir: string, ref: string): string {
282
+ return capped(`${git(workdir)} show --no-color --no-ext-diff --format= --patch --first-parent ${assertRef(ref)} --`)
283
+ }
284
+
285
+ /**
286
+ * One changed file's patch inside a commit, with rename detection
287
+ * (`-M`) across BOTH sides of the path.
288
+ *
289
+ * The plain `git diff <hash>^ -- <newPath>` the editor drill-down used to run
290
+ * is wrong for a rename: the old path never matches, so a 100% rename renders
291
+ * as an all-added file. Passing both paths to `git show -M` lets git report
292
+ * the rename the commit actually recorded. Restricted to `paths` so a giant
293
+ * commit still ships one file.
294
+ */
295
+ export function commitFileDiffCommand(workdir: string, ref: string, paths: readonly string[]): string {
296
+ const specs = paths.map(path => shQuote(assertRepoPath(path))).join(' ')
297
+ return capped(`${git(workdir)} show --no-color --no-ext-diff --format= --patch -M ${assertRef(ref)} -- ${specs}`)
247
298
  }
248
299
 
249
300
  /** One file's content at a revision (`rev` may also be `:0` for the index). */
@@ -339,19 +390,41 @@ export function publishBranchCommand(workdir: string, branch: string, remote: st
339
390
  return statusOk(`${git(workdir)} push --set-upstream ${shQuote(assertRemoteName(remote))} ${shQuote(assertBranchName(branch))}`)
340
391
  }
341
392
 
342
- /** Fetch every remote, pruning deleted tracking refs. */
393
+ /**
394
+ * Fetch every remote, pruning deleted tracking refs.
395
+ *
396
+ * NOTE: the four NETWORK verbs deliberately do NOT use `statusOk()`. That
397
+ * wrapper buffers git's output in temp files and cats them back only after the
398
+ * command exits, which made the live-output panel sit on "waiting…" for the
399
+ * whole run and then dump everything at once (measured: a 59 s push produced
400
+ * 0.2 s of output after completion). No pipe is involved here, so the shell
401
+ * returns git's own exit status directly, and `--progress` forces the progress
402
+ * lines even though the remote side is not a TTY.
403
+ */
343
404
  export function fetchCommand(workdir: string): string {
344
- return statusOk(`${git(workdir)} fetch --all --prune`)
405
+ return `${git(workdir)} fetch --all --prune --progress`
345
406
  }
346
407
 
347
408
  /** Pull the current branch (merge, no editor prompt). */
348
409
  export function pullCommand(workdir: string): string {
349
- return statusOk(`${git(workdir)} pull --no-edit`)
410
+ return `${git(workdir)} pull --no-edit --progress`
350
411
  }
351
412
 
352
413
  /** Push the current branch. */
353
414
  export function pushCommand(workdir: string): string {
354
- return statusOk(`${git(workdir)} push`)
415
+ return `${git(workdir)} push --progress`
416
+ }
417
+
418
+ /**
419
+ * Push to an explicit remote with an explicit refspec.
420
+ *
421
+ * This is the Gerrit shape (`git push origin HEAD:refs/for/master`): the plain
422
+ * `git push` above only ever talks to the branch's upstream, so a review
423
+ * workflow had to drop into a terminal. The remote is explicit because a
424
+ * refspec says nothing about which remote it belongs to.
425
+ */
426
+ export function pushRefspecCommand(workdir: string, remote: string, refspec: string): string {
427
+ return `${git(workdir)} push --progress ${shQuote(assertRemoteName(remote))} ${shQuote(assertPushRefspec(refspec))}`
355
428
  }
356
429
 
357
430
  /** Stash the working tree (optionally including untracked files). */
@@ -659,7 +732,10 @@ export function parseCommitFiles(stdout: string): GitCommitFile[] {
659
732
  if (tab < 0) continue
660
733
  const status = line.slice(0, tab).trim()
661
734
  const rest = line.slice(tab + 1)
662
- if (!/^[A-Z]\d*$/.test(status)) continue
735
+ // `R100`/`C75` carry a score, and a combined diff spells statuses like `MM`.
736
+ // Both must survive: rejecting `MM` made a conflict-resolved merge look like
737
+ // it changed nothing.
738
+ if (!/^[A-Z][A-Z0-9]*$/.test(status)) continue
663
739
  const renamed = status.startsWith('R') || status.startsWith('C')
664
740
  if (renamed) {
665
741
  const parts = rest.split('\t')
package/src/index.ts CHANGED
@@ -69,6 +69,11 @@ export interface Config {
69
69
  /**
70
70
  * P2 transparent-routing kill-switch: off = pure local passthrough;
71
71
  * shadow = log remote decisions but execute locally; enforce = route.
72
+ *
73
+ * The DEFAULT is `enforce`: the plugin exists to execute remote workspaces
74
+ * remotely, and a fresh profile that silently kept executing locally was the
75
+ * most common "why is nothing remote" report. `off`/`shadow` stay available as
76
+ * explicit opt-outs for debugging and for hosts that must stay local.
72
77
  */
73
78
  routingMode?: RoutingMode
74
79
  /** Per-command timeout for git work on a remote host (ms). */
@@ -107,7 +112,7 @@ export interface ResolvedConfig {
107
112
  export const Config = z.object({
108
113
  announceToAgent: z.boolean().default(false).volatile(),
109
114
  enabled: z.boolean().default(true).volatile(),
110
- routingMode: z.union(['off', 'shadow', 'enforce'] as const).default('off').volatile(),
115
+ routingMode: z.union(['off', 'shadow', 'enforce'] as const).default('enforce').volatile(),
111
116
  gitTimeoutMs: z.number().step(1).min(MIN_TIMEOUT_MS).max(MAX_TIMEOUT_MS)
112
117
  .default(DEFAULT_GIT_TIMEOUT_MS).volatile(),
113
118
  readTimeoutMs: z.number().step(1).min(MIN_TIMEOUT_MS).max(MAX_TIMEOUT_MS)
@@ -171,7 +176,10 @@ export function apply(ctx: Context, config?: ResolvedConfig): void {
171
176
  const worlds = new RemoteWorlds(ctx, {
172
177
  store,
173
178
  pool,
174
- mode: () => readField(config?.routingMode, 'off' as RoutingMode),
179
+ // Keep the runtime fallback equal to the schema default: a Host that hands
180
+ // the plugin a config object without this field must behave like a fresh
181
+ // profile, not like the old opt-in (`off`) behaviour.
182
+ mode: () => readField(config?.routingMode, 'enforce' as RoutingMode),
175
183
  })
176
184
  ctx.provide('remoteWorlds', worlds)
177
185
  ctx.effect(() => () => { pool.dispose() }, 'dsh-remote-vs: pool')
package/src/pool.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { createHash } from 'node:crypto'
10
10
  import { readFileSync } from 'node:fs'
11
+ import { StringDecoder } from 'node:string_decoder'
11
12
  import { Client, type ClientChannel, type SFTPWrapper } from 'ssh2'
12
13
  import type { RemoteStore } from './store.ts'
13
14
  import type { RemoteExecResult, RemoteHostEntry } from './protocol.ts'
@@ -99,6 +100,15 @@ interface Pooled {
99
100
 
100
101
  export interface ExecOptions {
101
102
  timeoutMs?: number
103
+ /**
104
+ * Live sink for the command's output (the streaming network verbs). Called
105
+ * with already-decoded text so a multi-byte character split across ssh2
106
+ * chunks is never mangled; the accumulated `stdout`/`stderr` on the result
107
+ * keep the same bytes.
108
+ */
109
+ onData?: (stream: 'out' | 'err', text: string) => void
110
+ /** Receives the cancel handle once the run is under way (see `cancel`). */
111
+ onCancel?: (cancel: () => void) => void
102
112
  /**
103
113
  * Internal: send the line exactly as given — no session wrapper, and no group
104
114
  * kill when it times out. Only the two commands the pool sends on its own
@@ -291,6 +301,37 @@ export class RemotePool {
291
301
  let stream: ClientChannel | undefined
292
302
  let out = ''
293
303
  let err = ''
304
+ /** Set by the cancel handle: closed + group-killed, reported as error. */
305
+ let cancelled = false
306
+ const outDecoder = new StringDecoder('utf8')
307
+ const errDecoder = new StringDecoder('utf8')
308
+ /** Wrapper marker never reaches the live log (see the data handler). */
309
+ let pendingForward = ''
310
+ const forward = (kind: 'out' | 'err', text: string): void => {
311
+ if (text !== '' && opts.onData !== undefined) opts.onData(kind, text)
312
+ }
313
+ /**
314
+ * Cancel the run: close our channel and kill the remote process group.
315
+ *
316
+ * `finish` reports it as an error rather than a timeout — "the user
317
+ * stopped it" and "the budget ran out" are different stories, and the
318
+ * UI must not blame the host for the former.
319
+ */
320
+ const cancel = (): void => {
321
+ if (settled) return
322
+ cancelled = true
323
+ try {
324
+ stream?.close()
325
+ } catch {
326
+ // Already gone.
327
+ }
328
+ if (pgid !== undefined) terminateGroup()
329
+ // No session wrapper means no process group to signal: say so, like the
330
+ // timeout path does, instead of a silent "cancelled" with git still up.
331
+ else if (!session) warnOnce()
332
+ finish({ success: false, exitCode: null, timedOut: false, stdout: out, stderr: err, error: 'cancelled by user' })
333
+ }
334
+ opts.onCancel?.(cancel)
294
335
  conn.exec(session ? sessionLine(command) : command, (execError, channel) => {
295
336
  if (execError !== undefined && execError !== null) {
296
337
  finish({ success: false, exitCode: null, timedOut: false, stdout: '', stderr: '', error: execError.message })
@@ -318,11 +359,38 @@ export class RemotePool {
318
359
  // we send ourselves is not.
319
360
  if ((code !== null && code !== undefined) || (signalName !== null && signalName !== undefined)) exited = true
320
361
  if (timedOut && session && pgid === undefined) warnOnce()
362
+ // A channel can close before the marker is parsed (or in the middle
363
+ // of a single-line command's output): flush the held-back text so the
364
+ // live log does not lose its tail. A partial marker line itself is
365
+ // bookkeeping and stays hidden.
366
+ if (pendingForward !== '') {
367
+ const newline = pendingForward.indexOf('\n')
368
+ const visible = markerPending
369
+ ? pendingForward.startsWith(PGID_MARKER)
370
+ ? (newline < 0 ? '' : pendingForward.slice(newline + 1))
371
+ : pendingForward
372
+ : pendingForward
373
+ pendingForward = ''
374
+ forward('out', visible)
375
+ }
376
+ // Flush a decoder-held partial character before the final result.
377
+ const tailOut = outDecoder.end()
378
+ const tailErr = errDecoder.end()
379
+ if (tailOut !== '') { out = appendCapped(out, tailOut); forward('out', tailOut) }
380
+ if (tailErr !== '') { err = appendCapped(err, tailErr); forward('err', tailErr) }
321
381
  finish({ success: code === 0, exitCode: code, timedOut: false, stdout: out, stderr: err })
322
382
  })
323
383
  channel.on('data', (chunk: Buffer) => {
324
- out = appendCapped(out, chunk.toString('utf8'))
325
- if (pgid !== undefined || !markerPending) return
384
+ const text = outDecoder.write(chunk)
385
+ out = appendCapped(out, text)
386
+ if (pgid !== undefined || !markerPending) {
387
+ forward('out', text)
388
+ return
389
+ }
390
+ // The wrapper's group-marker line is bookkeeping, not command
391
+ // output: hold output until the marker is resolved, then forward
392
+ // only what the command itself printed.
393
+ pendingForward += text
326
394
  const marker = takePgid(out)
327
395
  if (marker.pgid !== undefined) {
328
396
  pgid = marker.pgid
@@ -331,13 +399,25 @@ export class RemotePool {
331
399
  // Only reachable when the timeout fired before this channel was
332
400
  // open: the group had no id then, so kill it now that it has one.
333
401
  if (timedOut) terminateGroup()
334
- } else if (out.includes('\n')) {
402
+ if (cancelled) terminateGroup()
403
+ const newline = pendingForward.indexOf('\n')
404
+ const visible = newline < 0 ? '' : pendingForward.slice(newline + 1)
405
+ pendingForward = ''
406
+ forward('out', visible)
407
+ return
408
+ }
409
+ if (out.includes('\n') || pendingForward.length > 8192) {
335
410
  // The first line is the command's own output, not our marker.
336
411
  markerPending = false
412
+ const visible = pendingForward
413
+ pendingForward = ''
414
+ forward('out', visible)
337
415
  }
338
416
  })
339
417
  channel.stderr.on('data', (chunk: Buffer) => {
340
- err = appendCapped(err, chunk.toString('utf8'))
418
+ const text = errDecoder.write(chunk)
419
+ err = appendCapped(err, text)
420
+ forward('err', text)
341
421
  })
342
422
  })
343
423
  })
package/src/protocol.ts CHANGED
@@ -10,6 +10,8 @@ export const REMOTE_API = {
10
10
  test: '/api/dsh-remote/test',
11
11
  keygen: '/api/dsh-remote/keygen',
12
12
  exec: '/api/dsh-remote/exec',
13
+ /** Streaming exec (server-sent events) for the long network verbs. */
14
+ execStream: '/api/dsh-remote/exec-stream',
13
15
  files: '/api/dsh-remote/files',
14
16
  /** Streaming binary upload: raw request body, host and path in the query. */
15
17
  upload: '/api/dsh-remote/upload',
@@ -216,6 +218,18 @@ export interface RemoteExecResult {
216
218
  error?: string | undefined
217
219
  }
218
220
 
221
+ /**
222
+ * One server-sent event of the streaming exec route.
223
+ *
224
+ * Used by the long network verbs (fetch / pull / push / sync) so the live git
225
+ * output can be shown while the command is still running; the unary exec route
226
+ * keeps serving everything that only needs the result.
227
+ */
228
+ export type ExecStreamEvent =
229
+ | { t: 'out'; d: string }
230
+ | { t: 'err'; d: string }
231
+ | { t: 'exit'; success: boolean; exitCode: number | null; timedOut: boolean; durationMs: number; error?: string }
232
+
219
233
  /** Client -> server terminal frames (JSON text). */
220
234
  export type TerminalClientFrame =
221
235
  | { t: 'in'; d: string }
package/src/routes.ts CHANGED
@@ -12,7 +12,7 @@ import type { Context } from '@deepseek-ai/cordis'
12
12
  import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
13
13
  import { readJsonBody, writeJson } from './http.ts'
14
14
  import { isLoopbackRequest } from './loopback.ts'
15
- import { REMOTE_API, type ImportReport, type RemoteTimeouts } from './protocol.ts'
15
+ import { REMOTE_API, type ExecStreamEvent, type ImportReport, type RemoteTimeouts } from './protocol.ts'
16
16
  import type { HostPayload, RemoteStore } from './store.ts'
17
17
  import type { RemotePool } from './pool.ts'
18
18
  import type { RemoteWorlds } from './p2/worlds.ts'
@@ -302,6 +302,140 @@ export function makeRoutes(deps: RemoteRoutesDeps): { routes: WebRoute[]; upgrad
302
302
  writeJson(res, 200, await pool.exec(body.alias, body.command, timeoutMs === undefined ? {} : { timeoutMs }))
303
303
  },
304
304
  },
305
+ // ----------------------------------------------------- exec (streaming)
306
+ {
307
+ kind: 'exact',
308
+ path: REMOTE_API.execStream,
309
+ handler: async (req, res) => {
310
+ if (!isLoopbackRequest(req)) {
311
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
312
+ return
313
+ }
314
+ if (req.method !== 'POST') {
315
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` })
316
+ return
317
+ }
318
+ const body = (await readJsonBody(req, { objectOnly: true })) as {
319
+ alias?: unknown
320
+ command?: unknown
321
+ timeoutMs?: unknown
322
+ } | null
323
+ if (body === null || typeof body.alias !== 'string' || typeof body.command !== 'string' || body.command === '') {
324
+ writeJson(res, 400, { error: 'alias and a non-empty command are required' })
325
+ return
326
+ }
327
+ const timeoutMs = clampExecTimeout(body.timeoutMs)
328
+ res.writeHead(200, {
329
+ 'Content-Type': 'text/event-stream; charset=utf-8',
330
+ 'Cache-Control': 'no-cache, no-transform',
331
+ Connection: 'keep-alive',
332
+ // A reverse proxy must not buffer an event stream into uselessness.
333
+ 'X-Accel-Buffering': 'no',
334
+ })
335
+ /** No more events to the client (finished, or the socket broke). */
336
+ let outputClosed = false
337
+ /** `res.end` already called. */
338
+ let ended = false
339
+ /** The client asked to stop: kill the remote even if the read is still opening. */
340
+ let cancelRequested = false
341
+ /** The cancel handle was invoked (only once). */
342
+ let cancelSent = false
343
+ let cancel: (() => void) | undefined
344
+ /** Server-side forward cap: the client shows a scrolling tail anyway. */
345
+ let sent = 0
346
+ const FORWARD_CAP = 4 * 1024 * 1024
347
+ /**
348
+ * Stop the remote process group exactly once.
349
+ *
350
+ * The disconnect may happen while `pool.exec` is still acquiring the
351
+ * channel (slot queue, setsid probe, connect) — the handle does not exist
352
+ * yet then, so the request is latched and fired the moment `onCancel`
353
+ * arrives; otherwise a push would finish unseen and uncancellable.
354
+ */
355
+ const abortRemote = (): void => {
356
+ cancelRequested = true
357
+ if (cancel === undefined || cancelSent) return
358
+ cancelSent = true
359
+ cancel()
360
+ }
361
+ const send = (event: ExecStreamEvent): void => {
362
+ if (outputClosed) return
363
+ try {
364
+ res.write(`data: ${JSON.stringify(event)}\n\n`)
365
+ } catch {
366
+ // A broken socket means the client is gone: stop the remote git too.
367
+ outputClosed = true
368
+ abortRemote()
369
+ }
370
+ }
371
+ const heartbeat = setInterval(() => {
372
+ if (outputClosed) return
373
+ try {
374
+ // SSE comment: keeps an idle connection alive without inventing data.
375
+ res.write(': ping\n\n')
376
+ } catch {
377
+ outputClosed = true
378
+ abortRemote()
379
+ }
380
+ }, 15_000)
381
+ heartbeat.unref?.()
382
+ const stop = (): void => {
383
+ if (ended) return
384
+ ended = true
385
+ outputClosed = true
386
+ clearInterval(heartbeat)
387
+ try {
388
+ res.end()
389
+ } catch {
390
+ // Already gone.
391
+ }
392
+ }
393
+ res.on('close', () => {
394
+ // Client aborted (tab closed / cancel button / broken socket): stop the
395
+ // remote git, not just our socket. Fires after a normal end too, where
396
+ // it is a no-op because the run already settled.
397
+ abortRemote()
398
+ stop()
399
+ })
400
+ try {
401
+ const result = await pool.exec(body.alias, body.command, {
402
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
403
+ onData: (stream, text) => {
404
+ if (outputClosed || sent >= FORWARD_CAP) return
405
+ sent += Buffer.byteLength(text, 'utf8')
406
+ send({ t: stream, d: text })
407
+ },
408
+ onCancel: (handle) => {
409
+ cancel = handle
410
+ if (cancelRequested && !cancelSent) {
411
+ cancelSent = true
412
+ handle()
413
+ }
414
+ },
415
+ })
416
+ if (outputClosed) return
417
+ send({
418
+ t: 'exit',
419
+ success: result.success,
420
+ exitCode: result.exitCode,
421
+ timedOut: result.timedOut,
422
+ durationMs: result.durationMs,
423
+ ...(result.error === undefined ? {} : { error: result.error }),
424
+ })
425
+ } catch (error) {
426
+ send({
427
+ t: 'exit',
428
+ success: false,
429
+ exitCode: null,
430
+ timedOut: false,
431
+ durationMs: 0,
432
+ error: error instanceof Error ? error.message : String(error),
433
+ })
434
+ } finally {
435
+ stop()
436
+ }
437
+ },
438
+ },
305
439
  // ------------------------------------------------------------- files
306
440
  {
307
441
  kind: 'exact',
package/src/timeouts.ts CHANGED
@@ -21,8 +21,14 @@ import type { RemoteTimeouts } from './protocol.ts'
21
21
  /** Smallest accepted timeout: below this, local work would start failing. */
22
22
  export const MIN_TIMEOUT_MS = 1_000
23
23
 
24
- /** Largest accepted timeout — the exec route clamps to the same ceiling. */
25
- export const MAX_TIMEOUT_MS = 600_000
24
+ /**
25
+ * Largest accepted timeout — the exec route clamps to the same ceiling.
26
+ *
27
+ * Raised from 10 to 30 minutes when the SCM push gained a live output panel:
28
+ * a Gerrit push on a large repo waits for server-side hooks and can outlast
29
+ * the old ceiling with nothing wrong on either side.
30
+ */
31
+ export const MAX_TIMEOUT_MS = 1_800_000
26
32
 
27
33
  /**
28
34
  * Default for one git command (status/log/branches/diff over exec).
@@ -34,6 +40,16 @@ export const MAX_TIMEOUT_MS = 600_000
34
40
  */
35
41
  export const DEFAULT_GIT_TIMEOUT_MS = 300_000
36
42
 
43
+ /**
44
+ * Floor for the NETWORK verbs (fetch / pull / push / sync).
45
+ *
46
+ * These wait on the far end's hooks and pack transfer, not on this machine, so
47
+ * their budget starts at 20 minutes and grows with `gitTimeoutMs × 2` up to
48
+ * `MAX_TIMEOUT_MS`. The old formula started at 5 minutes and capped at 10, which
49
+ * killed healthy Gerrit pushes.
50
+ */
51
+ export const DEFAULT_NETWORK_TIMEOUT_MS = 1_200_000
52
+
37
53
  /** Default for one SFTP file read (editor, diff sides, document preview). */
38
54
  export const DEFAULT_READ_TIMEOUT_MS = 120_000
39
55