dsh-winrm 0.3.1 → 0.4.1
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 +132 -0
- package/README.md +20 -7
- package/docs/screenshots/winrm-panel.png +0 -0
- package/lib/client.js +597 -73
- package/lib/index.js +898 -26
- package/lib/types/client/locales.d.ts +44 -0
- package/lib/types/client/tool/card-model.d.ts +71 -0
- package/lib/types/client/tool/cards.d.ts +9 -0
- package/lib/types/diff.d.ts +62 -0
- package/lib/types/engine/client.d.ts +30 -4
- package/lib/types/engine.d.ts +51 -1
- package/lib/types/index.d.ts +4 -3
- package/lib/types/powershell.d.ts +64 -0
- package/lib/types/protocol.d.ts +54 -0
- package/lib/types/tools.d.ts +7 -1
- package/package.json +8 -4
- package/scripts/verify-live.mts +116 -0
- package/src/client/index.ts +17 -0
- package/src/client/locales.ts +44 -0
- package/src/client/tool/card-model.ts +151 -0
- package/src/client/tool/cards.module.css +128 -0
- package/src/client/tool/cards.tsx +335 -0
- package/src/diff.ts +128 -0
- package/src/engine/client.ts +66 -16
- package/src/engine.ts +222 -5
- package/src/index.ts +10 -3
- package/src/powershell.ts +253 -0
- package/src/protocol.ts +59 -0
- package/src/smb.ts +4 -1
- package/src/tools.ts +303 -4
- package/README.zh.md +0 -126
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcript cards for `winrm_read`, `winrm_write` and `winrm_edit`.
|
|
3
|
+
*
|
|
4
|
+
* Without these the three tools render as the generic tool row, so the user
|
|
5
|
+
* reads the same envelope text the model reads. Every call becomes a collapsed
|
|
6
|
+
* disclosure with a one-line header — file-type icon, verb, remote path — that
|
|
7
|
+
* expands into a card. The header is drawn from the first dispatch, so settling
|
|
8
|
+
* only fills in the trailing figure instead of replacing the row: the line the
|
|
9
|
+
* user watched while it ran is the line they click to open the result.
|
|
10
|
+
*
|
|
11
|
+
* Collapsed by default is the harness's own convention for these rows: a long
|
|
12
|
+
* file would otherwise fill the transcript before the user asked for it.
|
|
13
|
+
*
|
|
14
|
+
* The header is `DisclosureRow` and the body is `ReadBlock` / `DiffBlock`, all
|
|
15
|
+
* from the harness's published `ui-primitives`, so a remote file looks and
|
|
16
|
+
* behaves exactly like a local one. Open state comes from the `useDisclosure`
|
|
17
|
+
* hook the Tool layer hands every toolview, which also resets it when the
|
|
18
|
+
* enclosing turn re-renders.
|
|
19
|
+
* @module dsh-winrm/src/client/tool/cards
|
|
20
|
+
*/
|
|
21
|
+
import type { ReactNode } from 'react'
|
|
22
|
+
import {
|
|
23
|
+
DiffBlock, DisclosureRow, FileTypeIcon, ReadBlock,
|
|
24
|
+
type DiffBlockLabels, type DiffHunk, type ReadBlockLabels,
|
|
25
|
+
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
26
|
+
import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'
|
|
27
|
+
import { tt } from '../panel/helpers.ts'
|
|
28
|
+
import { diffCard, readCard, type DiffCard as DiffCardModel, type ReadCard as ReadCardModel } from './card-model.ts'
|
|
29
|
+
import css from './cards.module.css'
|
|
30
|
+
|
|
31
|
+
/** The Tool view props these cards read. */
|
|
32
|
+
export type WinrmFileCardProps = ToolCallViewProps
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Content lines a transcript card shows before the primitive collapses the
|
|
36
|
+
* middle. Half the primitive's own default (16), because a chat row summarizes
|
|
37
|
+
* inside a message flow while a dedicated panel is the reading surface. Mirrors
|
|
38
|
+
* the harness's `CHAT_READ_MAX_LINES` / `CHAT_DIFF_MAX_LINES`.
|
|
39
|
+
*/
|
|
40
|
+
const CHAT_READ_MAX_LINES = 8
|
|
41
|
+
const CHAT_DIFF_MAX_LINES = 9
|
|
42
|
+
|
|
43
|
+
/** Grammar hint from a remote path's extension; unknown extensions stay plain. */
|
|
44
|
+
const LANG_BY_EXTENSION: Readonly<Record<string, string>> = {
|
|
45
|
+
bat: 'bat', cmd: 'bat', ps1: 'powershell', psd1: 'powershell', psm1: 'powershell',
|
|
46
|
+
c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', hpp: 'cpp', cs: 'csharp',
|
|
47
|
+
css: 'css', scss: 'scss', less: 'less',
|
|
48
|
+
go: 'go', rs: 'rust', java: 'java', kt: 'kotlin', swift: 'swift',
|
|
49
|
+
js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript',
|
|
50
|
+
ts: 'typescript', mts: 'typescript', cts: 'typescript', tsx: 'typescript',
|
|
51
|
+
json: 'json', jsonc: 'jsonc', yml: 'yaml', yaml: 'yaml', toml: 'toml', ini: 'ini',
|
|
52
|
+
md: 'markdown', markdown: 'markdown', rst: 'rst',
|
|
53
|
+
py: 'python', rb: 'ruby', php: 'php', pl: 'perl', lua: 'lua',
|
|
54
|
+
sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'bash',
|
|
55
|
+
sql: 'sql', xml: 'xml', html: 'html', htm: 'html', vue: 'vue', svelte: 'svelte',
|
|
56
|
+
diff: 'diff', patch: 'diff', csv: 'csv', tsv: 'tsv',
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Guess the grammar hint from a path's extension.
|
|
61
|
+
* @param path - the remote path as authored.
|
|
62
|
+
* @returns the language id, or undefined to keep the block plain.
|
|
63
|
+
*/
|
|
64
|
+
function langOf(path: string): string | undefined {
|
|
65
|
+
const dot = path.lastIndexOf('.')
|
|
66
|
+
if (dot < 0) return undefined
|
|
67
|
+
const ext = path.slice(dot + 1).toLowerCase()
|
|
68
|
+
return Object.hasOwn(LANG_BY_EXTENSION, ext) ? LANG_BY_EXTENSION[ext] : undefined
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Localized chrome for {@link ReadBlock}. */
|
|
72
|
+
function readLabels(): ReadBlockLabels {
|
|
73
|
+
return {
|
|
74
|
+
codeLabel: tt('tool.codeLabel'),
|
|
75
|
+
wrapLabel: tt('tool.wrapLabel'),
|
|
76
|
+
unwrapLabel: tt('tool.unwrapLabel'),
|
|
77
|
+
window: (shown, total) => tt('tool.window', { shown, total }),
|
|
78
|
+
copy: tt('tool.copy'),
|
|
79
|
+
copied: tt('tool.copied'),
|
|
80
|
+
collapseAria: tt('tool.collapseAria'),
|
|
81
|
+
expandAria: hidden => tt('tool.expandAria', { hidden }),
|
|
82
|
+
collapse: tt('tool.collapse'),
|
|
83
|
+
expand: hidden => tt('tool.expand', { hidden }),
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Localized chrome for {@link DiffBlock}. */
|
|
88
|
+
function diffLabels(): DiffBlockLabels {
|
|
89
|
+
return {
|
|
90
|
+
codeLabel: tt('tool.codeLabel'),
|
|
91
|
+
wrapLabel: tt('tool.wrapLabel'),
|
|
92
|
+
unwrapLabel: tt('tool.unwrapLabel'),
|
|
93
|
+
copy: tt('tool.copy'),
|
|
94
|
+
copied: tt('tool.copied'),
|
|
95
|
+
collapseAria: tt('tool.collapseAria'),
|
|
96
|
+
expandAria: hidden => tt('tool.expandAria', { hidden }),
|
|
97
|
+
collapse: tt('tool.collapse'),
|
|
98
|
+
expand: hidden => tt('tool.expand', { hidden }),
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** One-line row for a call that failed: the message replaces the card. */
|
|
103
|
+
function Row(props: { title: string; detail: string }) {
|
|
104
|
+
return (
|
|
105
|
+
<div className={css.rowError}>
|
|
106
|
+
<span className={css.title}>{props.title}</span>
|
|
107
|
+
{props.detail !== '' && <span className={css.detail}>{props.detail}</span>}
|
|
108
|
+
</div>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Read the persisted metadata off a settled result node. */
|
|
113
|
+
function metaOf(block: unknown): unknown {
|
|
114
|
+
if (typeof block !== 'object' || block === null) return undefined
|
|
115
|
+
return (block as { meta?: unknown }).meta
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The remote path a call named, read from its arguments.
|
|
120
|
+
*
|
|
121
|
+
* Available from the first dispatch onward, which is what keeps the header line
|
|
122
|
+
* stable across the running → settled transition: the row keeps saying which
|
|
123
|
+
* file it is working on instead of swapping its text when the result lands. A
|
|
124
|
+
* settled node carries the call head under `call`; a running one is the head.
|
|
125
|
+
* @param block - any phase of the Tool block.
|
|
126
|
+
* @returns the authored path, or undefined when none was named yet.
|
|
127
|
+
*/
|
|
128
|
+
function argsPath(block: unknown): string | undefined {
|
|
129
|
+
if (typeof block !== 'object' || block === null) return undefined
|
|
130
|
+
const record = block as { argsRaw?: unknown; call?: { argsRaw?: unknown } | null }
|
|
131
|
+
const head = record.call ?? record
|
|
132
|
+
if (typeof head.argsRaw !== 'string' || head.argsRaw === '') return undefined
|
|
133
|
+
let parsed: unknown
|
|
134
|
+
try {
|
|
135
|
+
parsed = JSON.parse(head.argsRaw)
|
|
136
|
+
} catch {
|
|
137
|
+
return undefined
|
|
138
|
+
}
|
|
139
|
+
if (typeof parsed !== 'object' || parsed === null) return undefined
|
|
140
|
+
const path = (parsed as { remotePath?: unknown }).remotePath
|
|
141
|
+
return typeof path === 'string' && path !== '' ? path : undefined
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Count the lines a hunk adds and removes. */
|
|
145
|
+
function changedLines(card: DiffCardModel): { added: number; removed: number } {
|
|
146
|
+
let added = 0
|
|
147
|
+
let removed = 0
|
|
148
|
+
for (const hunk of card.hunks) {
|
|
149
|
+
added += hunk.newText === '' ? 0 : hunk.newText.split('\n').length
|
|
150
|
+
removed += hunk.oldText === '' ? 0 : hunk.oldText.split('\n').length
|
|
151
|
+
}
|
|
152
|
+
return { added, removed }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The `+n -m` figure a collapsed write or edit row shows beside its path. */
|
|
156
|
+
function changedTotals(card: DiffCardModel): { added: number; removed: number } | undefined {
|
|
157
|
+
const totals = changedLines(card)
|
|
158
|
+
return totals.added === 0 && totals.removed === 0 ? undefined : totals
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The summary line a write or edit card shows under its diff. */
|
|
162
|
+
function diffSummary(card: DiffCardModel): string {
|
|
163
|
+
const parts: string[] = []
|
|
164
|
+
if (card.tool === 'write') {
|
|
165
|
+
parts.push(tt(card.operation === 'create' ? 'tool.created' : 'tool.updated', { bytes: card.bytes }))
|
|
166
|
+
} else {
|
|
167
|
+
parts.push(tt('tool.replacements', { count: card.replacements }))
|
|
168
|
+
}
|
|
169
|
+
if (card.encoding !== 'utf8') parts.push(card.encoding)
|
|
170
|
+
if (card.beyondBasis) parts.push(tt('tool.beyondBasis'))
|
|
171
|
+
return parts.join(' · ')
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* One collapsed file row that expands into a card.
|
|
176
|
+
* @param props - the disclosure state, the header material, and the card body.
|
|
177
|
+
* @returns the disclosure row, body included only while expanded.
|
|
178
|
+
*/
|
|
179
|
+
function FileDisclosure(props: {
|
|
180
|
+
path: string
|
|
181
|
+
title: string
|
|
182
|
+
meta?: string | undefined
|
|
183
|
+
diff?: { added: number; removed: number } | undefined
|
|
184
|
+
open: boolean
|
|
185
|
+
expandable: boolean
|
|
186
|
+
running?: boolean
|
|
187
|
+
onToggle: () => void
|
|
188
|
+
children: ReactNode
|
|
189
|
+
}) {
|
|
190
|
+
const meta = props.meta ?? ''
|
|
191
|
+
const hasPath = props.path !== ''
|
|
192
|
+
return (
|
|
193
|
+
<div className={css.wrap}>
|
|
194
|
+
<DisclosureRow
|
|
195
|
+
icon={<FileTypeIcon path={props.path} size={14} />}
|
|
196
|
+
title={props.title}
|
|
197
|
+
open={props.open}
|
|
198
|
+
expandable={props.expandable}
|
|
199
|
+
running={props.running ?? false}
|
|
200
|
+
expandOnRowClick={props.expandable}
|
|
201
|
+
onToggle={props.onToggle}
|
|
202
|
+
collapsedContent={hasPath || meta !== '' ? (
|
|
203
|
+
<>
|
|
204
|
+
{hasPath && <span className={css.sep} aria-hidden />}
|
|
205
|
+
{hasPath && <span className={css.summary}>{props.path}</span>}
|
|
206
|
+
{meta !== '' && <span className={css.meta}>{meta}</span>}
|
|
207
|
+
{props.diff !== undefined && (
|
|
208
|
+
<span className={css.diffStat}>
|
|
209
|
+
<span className={css.added}>{`+${props.diff.added}`}</span>{' '}
|
|
210
|
+
<span className={css.removed}>{`-${props.diff.removed}`}</span>
|
|
211
|
+
</span>
|
|
212
|
+
)}
|
|
213
|
+
</>
|
|
214
|
+
) : undefined}
|
|
215
|
+
>
|
|
216
|
+
{props.children}
|
|
217
|
+
</DisclosureRow>
|
|
218
|
+
</div>
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Render the settled read card. */
|
|
223
|
+
function ReadView(props: { card: ReadCardModel; open: boolean; onToggle: () => void }) {
|
|
224
|
+
const { card, open, onToggle } = props
|
|
225
|
+
const notes: string[] = []
|
|
226
|
+
if (card.truncatedByBytes) notes.push(tt('tool.truncatedByBytes'))
|
|
227
|
+
if (card.truncatedLines > 0) notes.push(tt('tool.truncatedLines', { count: card.truncatedLines }))
|
|
228
|
+
if (card.encoding !== 'utf8') notes.push(card.encoding)
|
|
229
|
+
return (
|
|
230
|
+
<FileDisclosure
|
|
231
|
+
path={card.path}
|
|
232
|
+
title={tt('tool.read')}
|
|
233
|
+
meta={tt('tool.lines', { count: card.totalLines })}
|
|
234
|
+
open={open}
|
|
235
|
+
expandable
|
|
236
|
+
onToggle={onToggle}
|
|
237
|
+
>
|
|
238
|
+
<ReadBlock
|
|
239
|
+
label={card.path}
|
|
240
|
+
lines={card.lines}
|
|
241
|
+
totalLines={card.totalLines}
|
|
242
|
+
lang={langOf(card.path)}
|
|
243
|
+
labels={readLabels()}
|
|
244
|
+
maxLines={CHAT_READ_MAX_LINES}
|
|
245
|
+
/>
|
|
246
|
+
{notes.length > 0 && <div className={css.note}>{notes.join(' · ')}</div>}
|
|
247
|
+
</FileDisclosure>
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Render the settled write or edit card. */
|
|
252
|
+
function DiffView(props: { card: DiffCardModel; open: boolean; onToggle: () => void }) {
|
|
253
|
+
const { card, open, onToggle } = props
|
|
254
|
+
// The primitive takes `oldText: null` to mean "no prior content", which is a
|
|
255
|
+
// create; the host writes '' for that case so the two stay JSON-friendly.
|
|
256
|
+
const diffs: DiffHunk[] = card.hunks.map(hunk => ({
|
|
257
|
+
path: card.path,
|
|
258
|
+
oldText: hunk.oldText === '' ? null : hunk.oldText,
|
|
259
|
+
newText: hunk.newText,
|
|
260
|
+
}))
|
|
261
|
+
return (
|
|
262
|
+
<FileDisclosure
|
|
263
|
+
path={card.path}
|
|
264
|
+
title={tt(card.tool === 'write' ? 'tool.write' : 'tool.edit')}
|
|
265
|
+
diff={changedTotals(card)}
|
|
266
|
+
open={open}
|
|
267
|
+
expandable={diffs.length > 0}
|
|
268
|
+
onToggle={onToggle}
|
|
269
|
+
>
|
|
270
|
+
{diffs.length > 0 && (
|
|
271
|
+
<DiffBlock diffs={diffs} labels={diffLabels()} maxLines={CHAT_DIFF_MAX_LINES} />
|
|
272
|
+
)}
|
|
273
|
+
<div className={css.note}>{diffSummary(card)}</div>
|
|
274
|
+
</FileDisclosure>
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Shared driver: one card per stage, with the model built from the metadata. */
|
|
279
|
+
function FileCardView(props: WinrmFileCardProps & { tool: 'read' | 'write' | 'edit' }) {
|
|
280
|
+
const { phase, block, tool, useDisclosure } = props
|
|
281
|
+
const disclosure = useDisclosure()
|
|
282
|
+
const title = tt(`tool.${tool}`)
|
|
283
|
+
const path = argsPath(block) ?? ''
|
|
284
|
+
if (phase !== 'result') {
|
|
285
|
+
return (
|
|
286
|
+
<FileDisclosure
|
|
287
|
+
path={path}
|
|
288
|
+
title={title}
|
|
289
|
+
meta={path === '' ? tt('tool.running') : undefined}
|
|
290
|
+
open={false}
|
|
291
|
+
expandable={false}
|
|
292
|
+
running
|
|
293
|
+
onToggle={() => {}}
|
|
294
|
+
>
|
|
295
|
+
{null}
|
|
296
|
+
</FileDisclosure>
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
const meta = metaOf(block)
|
|
300
|
+
const card = tool === 'read' ? readCard(meta, path) : diffCard(meta, path, tool)
|
|
301
|
+
if (card === undefined) {
|
|
302
|
+
return (
|
|
303
|
+
<FileDisclosure
|
|
304
|
+
path={path}
|
|
305
|
+
title={title}
|
|
306
|
+
meta={tt('tool.noDetail')}
|
|
307
|
+
open={false}
|
|
308
|
+
expandable={false}
|
|
309
|
+
onToggle={() => {}}
|
|
310
|
+
>
|
|
311
|
+
{null}
|
|
312
|
+
</FileDisclosure>
|
|
313
|
+
)
|
|
314
|
+
}
|
|
315
|
+
if (card.kind === 'error') return <Row title={title} detail={card.message} />
|
|
316
|
+
if (card.kind === 'read') {
|
|
317
|
+
return <ReadView card={card} open={disclosure.expanded} onToggle={disclosure.toggle} />
|
|
318
|
+
}
|
|
319
|
+
return <DiffView card={card} open={disclosure.expanded} onToggle={disclosure.toggle} />
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Card for `winrm_read`. */
|
|
323
|
+
export function WinrmReadCard(props: WinrmFileCardProps) {
|
|
324
|
+
return <FileCardView {...props} tool="read" />
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Card for `winrm_write`. */
|
|
328
|
+
export function WinrmWriteCard(props: WinrmFileCardProps) {
|
|
329
|
+
return <FileCardView {...props} tool="write" />
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Card for `winrm_edit`. */
|
|
333
|
+
export function WinrmEditCard(props: WinrmFileCardProps) {
|
|
334
|
+
return <FileCardView {...props} tool="edit" />
|
|
335
|
+
}
|
package/src/diff.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contextual diff derivation for the write/edit presentation metadata.
|
|
3
|
+
*
|
|
4
|
+
* The GUI renders a diff card from `tool/result` metadata rather than from the
|
|
5
|
+
* model-facing text, so the hunks are projected here, at result time, and
|
|
6
|
+
* persisted with the session log. This mirrors the harness's own file tools:
|
|
7
|
+
* one entry per applied change with a few surrounding context lines.
|
|
8
|
+
*
|
|
9
|
+
* The alignment is deliberately cheap rather than a full LCS: common leading
|
|
10
|
+
* and trailing lines are trimmed, and when the remaining halves have the same
|
|
11
|
+
* length each differing run becomes its own hunk. That covers the cases these
|
|
12
|
+
* two tools actually produce — an exact-string edit, and a rewrite that keeps
|
|
13
|
+
* most lines — without a diff dependency in a plugin whose point is a small
|
|
14
|
+
* footprint. A rewrite that changes the line count collapses to one hunk over
|
|
15
|
+
* the changed span, which is still a truthful summary.
|
|
16
|
+
* @module dsh-winrm/src/diff
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Context lines shown on each side of an applied hunk. */
|
|
20
|
+
export const DIFF_CONTEXT = 3
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Largest file whose text may serve as a diff basis. Above this the card shows
|
|
24
|
+
* the path and operation without hunks: fetching and projecting a bigger basis
|
|
25
|
+
* would cost another transfer and a large metadata payload for a summary the
|
|
26
|
+
* user reads at a glance.
|
|
27
|
+
*/
|
|
28
|
+
export const DIFF_BASIS_MAX_BYTES = 256 * 1024
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Largest serialized hunk list kept on one result. A pathological edit (a
|
|
32
|
+
* whole-file rewrite of a large file) can produce thousands of hunks; past
|
|
33
|
+
* this bound the card degrades to path-only instead of bloating the log.
|
|
34
|
+
*/
|
|
35
|
+
export const DIFF_META_MAX_BYTES = 512 * 1024
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One applied hunk: the fragment before the change and after it.
|
|
39
|
+
*
|
|
40
|
+
* Declared as a type alias rather than an interface so it satisfies the
|
|
41
|
+
* `JsonValue` index signature the tool-output metadata is typed against.
|
|
42
|
+
*/
|
|
43
|
+
export type RemoteFileDiff = {
|
|
44
|
+
/** Remote path the hunk belongs to (the authored argument). */
|
|
45
|
+
path: string
|
|
46
|
+
/** Prior content including context lines; empty for a pure insertion. */
|
|
47
|
+
oldText: string
|
|
48
|
+
/** Content after the change, including the shared context lines. */
|
|
49
|
+
newText: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Split text into lines, treating every terminator as a separator. */
|
|
53
|
+
function toLines(text: string): string[] {
|
|
54
|
+
return text === '' ? [] : text.split(/\r?\n/)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Compute the hunks between two texts.
|
|
59
|
+
* @param path - remote path stamped on every hunk.
|
|
60
|
+
* @param before - file text before the change.
|
|
61
|
+
* @param after - file text after the change.
|
|
62
|
+
* @returns one hunk per contiguous change in file order; empty when equal.
|
|
63
|
+
*/
|
|
64
|
+
export function computeHunkDiffs(path: string, before: string, after: string): RemoteFileDiff[] {
|
|
65
|
+
if (before === after) return []
|
|
66
|
+
const oldLines = toLines(before)
|
|
67
|
+
const newLines = toLines(after)
|
|
68
|
+
|
|
69
|
+
let head = 0
|
|
70
|
+
while (head < oldLines.length && head < newLines.length && oldLines[head] === newLines[head]) head += 1
|
|
71
|
+
let tail = 0
|
|
72
|
+
while (
|
|
73
|
+
tail < oldLines.length - head
|
|
74
|
+
&& tail < newLines.length - head
|
|
75
|
+
&& oldLines[oldLines.length - 1 - tail] === newLines[newLines.length - 1 - tail]
|
|
76
|
+
) tail += 1
|
|
77
|
+
|
|
78
|
+
const oldMid = oldLines.slice(head, oldLines.length - tail)
|
|
79
|
+
const newMid = newLines.slice(head, newLines.length - tail)
|
|
80
|
+
|
|
81
|
+
/** Build one hunk around the changed range `[from, to)` in the middle. */
|
|
82
|
+
const emit = (from: number, to: number): RemoteFileDiff => {
|
|
83
|
+
// Both mid arrays have the same length in the aligned branch below, so one
|
|
84
|
+
// clamped window addresses the old and the new side alike.
|
|
85
|
+
const start = Math.max(0, from - DIFF_CONTEXT)
|
|
86
|
+
const stop = Math.min(oldMid.length, to + DIFF_CONTEXT)
|
|
87
|
+
return {
|
|
88
|
+
path,
|
|
89
|
+
oldText: oldLines.slice(head + start, head + stop).join('\n'),
|
|
90
|
+
newText: newLines.slice(head + start, head + stop).join('\n'),
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (oldMid.length !== newMid.length) {
|
|
95
|
+
// Unaligned rewrite: one hunk over the whole changed span.
|
|
96
|
+
return [{
|
|
97
|
+
path,
|
|
98
|
+
oldText: oldLines.slice(Math.max(0, head - DIFF_CONTEXT), oldLines.length - Math.max(0, tail - DIFF_CONTEXT)).join('\n'),
|
|
99
|
+
newText: newLines.slice(Math.max(0, head - DIFF_CONTEXT), newLines.length - Math.max(0, tail - DIFF_CONTEXT)).join('\n'),
|
|
100
|
+
}]
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const diffs: RemoteFileDiff[] = []
|
|
104
|
+
let index = 0
|
|
105
|
+
while (index < oldMid.length) {
|
|
106
|
+
if (oldMid[index] === newMid[index]) {
|
|
107
|
+
index += 1
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
const from = index
|
|
111
|
+
while (index < oldMid.length && oldMid[index] !== newMid[index]) index += 1
|
|
112
|
+
diffs.push(emit(from, index))
|
|
113
|
+
}
|
|
114
|
+
return diffs
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Compute hunks and drop them when the serialized payload would exceed the cap.
|
|
119
|
+
* @param path - remote path stamped on every hunk.
|
|
120
|
+
* @param before - prior text.
|
|
121
|
+
* @param after - text after the change.
|
|
122
|
+
* @returns the kept hunks; empty when nothing changed or the payload is too big.
|
|
123
|
+
*/
|
|
124
|
+
export function boundedHunkDiffs(path: string, before: string, after: string): RemoteFileDiff[] {
|
|
125
|
+
const diffs = computeHunkDiffs(path, before, after)
|
|
126
|
+
if (diffs.length === 0) return diffs
|
|
127
|
+
return JSON.stringify(diffs).length > DIFF_META_MAX_BYTES ? [] : diffs
|
|
128
|
+
}
|
package/src/engine/client.ts
CHANGED
|
@@ -337,6 +337,41 @@ const STREAM_GZIP_MIN_RATIO = 0.85
|
|
|
337
337
|
/** Per-Send HTTP budget; a single slice is small, so this only bounds a stalled connection. */
|
|
338
338
|
const STREAM_SEND_TIMEOUT_MS = 120_000
|
|
339
339
|
|
|
340
|
+
/**
|
|
341
|
+
* How long a streaming transfer may deliver nothing before it is treated as
|
|
342
|
+
* stalled.
|
|
343
|
+
*
|
|
344
|
+
* A wall-clock cap on the whole transfer cannot work here: this transport is
|
|
345
|
+
* latency-bound and measured at ~0.73 MiB/s against a real target, so a 700 MiB
|
|
346
|
+
* file legitimately needs about sixteen minutes and any smaller fixed cap fails
|
|
347
|
+
* a transfer that is making steady progress. What a transfer actually needs
|
|
348
|
+
* bounding against is a *stall* — a range that has stopped producing bytes —
|
|
349
|
+
* which is what this idle window measures.
|
|
350
|
+
*/
|
|
351
|
+
export const STREAM_IDLE_TIMEOUT_MS = 180_000
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Floor used only to size the runaway backstop, never as a throughput target.
|
|
355
|
+
* Deliberately pessimistic (64 KiB/s): it must not fail a slow link, only a
|
|
356
|
+
* transfer that is somehow still "busy" hours after it should have finished.
|
|
357
|
+
*/
|
|
358
|
+
const STREAM_MIN_BYTES_PER_SEC = 65_536
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Budget for one streaming transfer of `bytes` bytes.
|
|
362
|
+
*
|
|
363
|
+
* The idle window is the effective limit; the hard window exists so a wedged
|
|
364
|
+
* transfer cannot stay alive forever, and it scales with the payload because
|
|
365
|
+
* the payload is what sets the legitimate duration.
|
|
366
|
+
* @param bytes - total bytes the transfer must move.
|
|
367
|
+
* @param idleMs - how long the transfer may go without delivering anything.
|
|
368
|
+
* @returns the idle window and the absolute backstop, both in milliseconds.
|
|
369
|
+
*/
|
|
370
|
+
export function transferBudget(bytes: number, idleMs = STREAM_IDLE_TIMEOUT_MS): { idleMs: number; hardMs: number } {
|
|
371
|
+
const optimisticMs = Math.ceil(Math.max(0, bytes) / STREAM_MIN_BYTES_PER_SEC) * 1000
|
|
372
|
+
return { idleMs, hardMs: Math.max(idleMs * 4, optimisticMs + idleMs) }
|
|
373
|
+
}
|
|
374
|
+
|
|
340
375
|
/**
|
|
341
376
|
* One streaming transfer over a single shell: create → run the receiver →
|
|
342
377
|
* push the receiver script, the header frame and every data frame down stdin →
|
|
@@ -349,7 +384,7 @@ const STREAM_SEND_TIMEOUT_MS = 120_000
|
|
|
349
384
|
* @param remotePath - destination path on the target.
|
|
350
385
|
* @param data - the file contents to send.
|
|
351
386
|
* @param onProgress - called with cumulative bytes after each frame.
|
|
352
|
-
* @param
|
|
387
|
+
* @param budget - idle window and absolute backstop for the transfer.
|
|
353
388
|
* @param authMethod - the auth scheme for this attempt.
|
|
354
389
|
* @param sendHttp - the resolved library send function.
|
|
355
390
|
* @returns the number of file bytes the receiver acknowledged writing.
|
|
@@ -360,7 +395,7 @@ async function streamOnce(
|
|
|
360
395
|
remotePath: string,
|
|
361
396
|
data: Buffer,
|
|
362
397
|
onProgress: ((transferred: number, total: number) => void) | undefined,
|
|
363
|
-
|
|
398
|
+
budget: { idleMs: number; hardMs: number },
|
|
364
399
|
authMethod: WinrmAuth,
|
|
365
400
|
sendHttp: ReturnType<typeof loadSendHttp> & object,
|
|
366
401
|
): Promise<number> {
|
|
@@ -375,7 +410,12 @@ async function streamOnce(
|
|
|
375
410
|
useHttps: conn.useHttps ?? false,
|
|
376
411
|
rejectUnauthorized: conn.rejectUnauthorized ?? true,
|
|
377
412
|
}
|
|
378
|
-
const hardDeadline = Date.now() +
|
|
413
|
+
const hardDeadline = Date.now() + budget.hardMs + SETUP_GRACE_MS
|
|
414
|
+
// Progress is what keeps a transfer alive: the send loop and the final
|
|
415
|
+
// receive loop both refresh this, and only a window with no progress at all
|
|
416
|
+
// is treated as a stall.
|
|
417
|
+
let lastProgressAt = Date.now()
|
|
418
|
+
const stalled = (): boolean => Date.now() - lastProgressAt >= budget.idleMs
|
|
379
419
|
const state = { shellId: undefined as string | undefined, timedOut: false, settled: false }
|
|
380
420
|
const total = data.length
|
|
381
421
|
|
|
@@ -405,7 +445,7 @@ async function streamOnce(
|
|
|
405
445
|
let slice = STREAM_CHUNK_BYTES
|
|
406
446
|
let ceiling = STREAM_CHUNK_BYTES
|
|
407
447
|
while (offset < total) {
|
|
408
|
-
if (state.settled ||
|
|
448
|
+
if (state.settled || stalled()) throw new WinrmTimedOut()
|
|
409
449
|
const take = Math.min(slice, total - offset)
|
|
410
450
|
const chunk = data.subarray(offset, offset + take)
|
|
411
451
|
const gz = gzipSync(chunk, { level: 6 })
|
|
@@ -422,6 +462,7 @@ async function streamOnce(
|
|
|
422
462
|
throw error
|
|
423
463
|
}
|
|
424
464
|
offset += take
|
|
465
|
+
lastProgressAt = Date.now()
|
|
425
466
|
onProgress?.(offset, total)
|
|
426
467
|
if (slice < ceiling) slice = Math.min(ceiling, slice * 2)
|
|
427
468
|
}
|
|
@@ -429,15 +470,15 @@ async function streamOnce(
|
|
|
429
470
|
// 4. The receiver stops on its own once the declared byte count lands, so
|
|
430
471
|
// there is nothing to close — just wait for its envelope.
|
|
431
472
|
const receive = { ...base, shellId, commandId }
|
|
432
|
-
const commandDeadline = Date.now() + timeoutMs
|
|
433
473
|
let stdout = ''
|
|
434
474
|
let stderr = ''
|
|
435
475
|
for (;;) {
|
|
436
|
-
if (state.settled ||
|
|
476
|
+
if (state.settled || stalled()) throw new WinrmTimedOut()
|
|
437
477
|
const chunk = await Command.doReceiveOutputNonBlocking(receive)
|
|
438
478
|
stdout += chunk.output
|
|
439
479
|
stderr += chunk.stderr
|
|
440
480
|
if (chunk.isComplete) break
|
|
481
|
+
if (chunk.output !== '' || chunk.stderr !== '') lastProgressAt = Date.now()
|
|
441
482
|
}
|
|
442
483
|
|
|
443
484
|
const parsed = parseEnvelope(stdout)
|
|
@@ -488,7 +529,7 @@ async function streamOnce(
|
|
|
488
529
|
* @param remotePath - destination path on the target.
|
|
489
530
|
* @param data - the file contents to send.
|
|
490
531
|
* @param onProgress - called with cumulative bytes after each frame.
|
|
491
|
-
* @param
|
|
532
|
+
* @param idleMs - how long the transfer may go without moving a slice.
|
|
492
533
|
* @returns bytes written, or null when the streaming path is unavailable.
|
|
493
534
|
*/
|
|
494
535
|
export async function uploadStream(
|
|
@@ -496,15 +537,16 @@ export async function uploadStream(
|
|
|
496
537
|
remotePath: string,
|
|
497
538
|
data: Buffer,
|
|
498
539
|
onProgress?: (transferred: number, total: number) => void,
|
|
499
|
-
|
|
540
|
+
idleMs = STREAM_IDLE_TIMEOUT_MS,
|
|
500
541
|
): Promise<number | null> {
|
|
501
542
|
const sendHttp = loadSendHttp()
|
|
502
543
|
if (sendHttp === null) return null
|
|
544
|
+
const budget = transferBudget(data.length, idleMs)
|
|
503
545
|
const candidates: WinrmAuth[] = conn.useHttps ? ['basic', 'ntlm'] : ['ntlm', 'basic']
|
|
504
546
|
let lastError: unknown
|
|
505
547
|
for (const authMethod of candidates) {
|
|
506
548
|
try {
|
|
507
|
-
return await streamOnce(conn, remotePath, data, onProgress,
|
|
549
|
+
return await streamOnce(conn, remotePath, data, onProgress, budget, authMethod, sendHttp)
|
|
508
550
|
} catch (error) {
|
|
509
551
|
if (error instanceof WinrmTimedOut) throw error
|
|
510
552
|
lastError = error
|
|
@@ -527,7 +569,7 @@ const DOWNLOAD_RECEIVE_TIMEOUT_MS = 120_000
|
|
|
527
569
|
* @param start - first byte offset of this worker's range.
|
|
528
570
|
* @param length - bytes this worker must deliver.
|
|
529
571
|
* @param onData - receives each decompressed piece together with its file offset.
|
|
530
|
-
* @param
|
|
572
|
+
* @param budget - idle window and absolute backstop for this worker's range.
|
|
531
573
|
* @param authMethod - the auth scheme for this attempt.
|
|
532
574
|
* @param sendHttp - the resolved library HTTP function.
|
|
533
575
|
* @returns the number of file bytes this worker delivered.
|
|
@@ -538,7 +580,7 @@ async function downloadWorkerOnce(
|
|
|
538
580
|
start: number,
|
|
539
581
|
length: number,
|
|
540
582
|
onData: (offset: number, data: Buffer) => void,
|
|
541
|
-
|
|
583
|
+
budget: { idleMs: number; hardMs: number },
|
|
542
584
|
authMethod: WinrmAuth,
|
|
543
585
|
sendHttp: ReturnType<typeof loadSendHttp> & object,
|
|
544
586
|
): Promise<number> {
|
|
@@ -553,7 +595,12 @@ async function downloadWorkerOnce(
|
|
|
553
595
|
useHttps: conn.useHttps ?? false,
|
|
554
596
|
rejectUnauthorized: conn.rejectUnauthorized ?? true,
|
|
555
597
|
}
|
|
556
|
-
const hardDeadline = Date.now() +
|
|
598
|
+
const hardDeadline = Date.now() + budget.hardMs + SETUP_GRACE_MS
|
|
599
|
+
// Same rule as the upload path: bytes arriving keep the worker alive, and
|
|
600
|
+
// only a window with no bytes at all counts as a stall. A flat whole-transfer
|
|
601
|
+
// cap would fail a large file on a slow link even while it is moving.
|
|
602
|
+
let lastDataAt = Date.now()
|
|
603
|
+
const stalled = (): boolean => Date.now() - lastDataAt >= budget.idleMs
|
|
557
604
|
const state = { shellId: undefined as string | undefined, timedOut: false, settled: false }
|
|
558
605
|
|
|
559
606
|
const inner = async (): Promise<number> => {
|
|
@@ -589,7 +636,7 @@ async function downloadWorkerOnce(
|
|
|
589
636
|
// A worker that lost the deadline race keeps running in the background
|
|
590
637
|
// unless it checks; without this it would go on fetching and pushing
|
|
591
638
|
// bytes into a file the caller has already given up on.
|
|
592
|
-
if (state.settled ||
|
|
639
|
+
if (state.settled || stalled()) throw new WinrmTimedOut()
|
|
593
640
|
const result = await receiveOutput(target, shellId, commandId, sendHttp, DOWNLOAD_RECEIVE_TIMEOUT_MS)
|
|
594
641
|
let sawData = false
|
|
595
642
|
for (const stream of result.streams) {
|
|
@@ -605,6 +652,7 @@ async function downloadWorkerOnce(
|
|
|
605
652
|
stderr += stream.data.toString('utf8')
|
|
606
653
|
}
|
|
607
654
|
}
|
|
655
|
+
if (sawData) lastDataAt = Date.now()
|
|
608
656
|
if (inflateError !== undefined) throw inflateError
|
|
609
657
|
if (result.exitCode !== undefined) exitCode = result.exitCode
|
|
610
658
|
// A finished command whose final response carried nothing means the
|
|
@@ -663,7 +711,7 @@ async function downloadWorkerOnce(
|
|
|
663
711
|
* @param remotePath - file to read.
|
|
664
712
|
* @param onData - receives each decompressed piece with its absolute file offset.
|
|
665
713
|
* @param onProgress - receives cumulative bytes delivered and the total size.
|
|
666
|
-
* @param
|
|
714
|
+
* @param idleMs - how long a worker may go without delivering bytes.
|
|
667
715
|
* @returns the number of bytes delivered, or null when streaming is unavailable.
|
|
668
716
|
*/
|
|
669
717
|
export async function downloadStream(
|
|
@@ -671,13 +719,15 @@ export async function downloadStream(
|
|
|
671
719
|
remotePath: string,
|
|
672
720
|
onData: (offset: number, data: Buffer) => void,
|
|
673
721
|
onProgress?: (transferred: number, total: number) => void,
|
|
674
|
-
|
|
722
|
+
idleMs = STREAM_IDLE_TIMEOUT_MS,
|
|
675
723
|
): Promise<number | null> {
|
|
676
724
|
const sendHttp = loadSendHttp()
|
|
677
725
|
if (sendHttp === null) return null
|
|
678
726
|
const total = await remoteFileSize(conn, remotePath)
|
|
679
727
|
if (total === null) throw new Error(`remote file not found: ${remotePath}`)
|
|
680
728
|
if (total === 0) return 0
|
|
729
|
+
// The payload size, not a constant, is what sets a legitimate duration here.
|
|
730
|
+
const budget = transferBudget(total, idleMs)
|
|
681
731
|
const workers = Math.max(1, Math.min(DOWNLOAD_MAX_WORKERS, Math.ceil(total / DOWNLOAD_MIN_RANGE_BYTES)))
|
|
682
732
|
const rangeSize = Math.ceil(total / workers)
|
|
683
733
|
let transferred = 0
|
|
@@ -708,7 +758,7 @@ export async function downloadStream(
|
|
|
708
758
|
}
|
|
709
759
|
await Promise.all(
|
|
710
760
|
ranges.map(range =>
|
|
711
|
-
downloadWorkerOnce(conn, remotePath, range.start, range.length, report,
|
|
761
|
+
downloadWorkerOnce(conn, remotePath, range.start, range.length, report, budget, authMethod, sendHttp),
|
|
712
762
|
),
|
|
713
763
|
)
|
|
714
764
|
return transferred
|