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.
- package/README.en.md +13 -10
- package/README.md +14 -9
- package/cordis.patch.yml +10 -5
- package/lib/client.js +3319 -1093
- package/lib/index.js +211 -9
- package/lib/types/client/api.d.ts +12 -0
- package/lib/types/client/composer.d.ts +5 -1
- package/lib/types/client/exec-output.d.ts +58 -0
- package/lib/types/client/i18n.d.ts +1 -1
- package/lib/types/client/scm/CommitPane.d.ts +73 -0
- package/lib/types/client/scm/ExecPanel.d.ts +34 -0
- package/lib/types/client/scm/HistoryPanel.d.ts +12 -4
- package/lib/types/client/scm/actions.d.ts +50 -3
- package/lib/types/client/scm/commit-model.d.ts +119 -0
- package/lib/types/client/store.d.ts +9 -3
- package/lib/types/client/vsc/kit.d.ts +16 -0
- package/lib/types/git-format.d.ts +5 -0
- package/lib/types/git-ops.d.ts +57 -2
- package/lib/types/index.d.ts +5 -0
- package/lib/types/pool.d.ts +9 -0
- package/lib/types/protocol.d.ts +23 -0
- package/lib/types/timeouts.d.ts +17 -2
- package/package.json +37 -37
- package/src/client/SettingsHosts.tsx +2 -1
- package/src/client/api.ts +61 -0
- package/src/client/composer.ts +11 -2
- package/src/client/exec-output.ts +129 -0
- package/src/client/i18n.ts +133 -4
- package/src/client/scm/CommitPane.tsx +726 -0
- package/src/client/scm/ExecPanel.tsx +124 -0
- package/src/client/scm/HistoryPanel.tsx +483 -104
- package/src/client/scm/ScmPanel.tsx +247 -9
- package/src/client/scm/actions.ts +93 -10
- package/src/client/scm/commit-model.ts +317 -0
- package/src/client/store.tsx +13 -6
- package/src/client/vsc/kit.tsx +71 -0
- package/src/client/vsc/vsc.css +647 -0
- package/src/git-format.ts +7 -3
- package/src/git-ops.ts +83 -7
- package/src/index.ts +10 -2
- package/src/pool.ts +84 -4
- package/src/protocol.ts +14 -0
- package/src/routes.ts +135 -1
- 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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
25
|
-
|
|
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
|
|