dsh-over-ssh 0.2.2 → 0.2.4
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.md +82 -4
- package/agents/agent-manifest.json +7 -7
- package/agents/linux-amd64/dsh-over-ssh-agent +0 -0
- package/agents/linux-arm64/dsh-over-ssh-agent +0 -0
- package/cordis.patch.yml +10 -12
- package/lib/client.js +161 -60
- package/lib/file-references.js +136 -13
- package/lib/fs.js +2 -1
- package/lib/index.js +1245 -165
- package/lib/types/src/config.d.ts +44 -0
- package/lib/types/src/host/app/backend.d.ts +31 -0
- package/lib/types/src/host/app/routed-fs.d.ts +238 -0
- package/lib/types/src/host/app/session-admin/session-admin-contract.d.ts +17 -0
- package/lib/types/src/host/app/transport/transport-service.d.ts +66 -11
- package/lib/types/src/host/domain/capability.d.ts +6 -0
- package/lib/types/src/host/domain/roots/route-table.d.ts +101 -0
- package/lib/types/src/host/domain/session/session-admin.d.ts +8 -0
- package/lib/types/src/host/infra/backend/poll-watch.d.ts +119 -0
- package/lib/types/src/host/infra/backend/remote-backend.d.ts +297 -0
- package/lib/types/src/host/infra/cache/budget.d.ts +68 -0
- package/lib/types/src/host/infra/cache/versioned.d.ts +98 -0
- package/lib/types/src/host/infra/transport/ssh/askpass.d.ts +132 -0
- package/lib/types/src/host/infra/transport/transport.d.ts +48 -0
- package/lib/types/src/host/integration/agent/askpass-registry.d.ts +65 -0
- package/lib/types/src/host/integration/fs/remote-index.d.ts +183 -0
- package/lib/types/src/host/integration/roots/remote-home.d.ts +68 -0
- package/lib/types/src/host/integration/roots/root-registry.d.ts +148 -0
- package/lib/types/src/index.d.ts +7 -0
- package/lib/types/src/local-bridge.d.ts +4 -4
- package/lib/types/src/wiring/connections.d.ts +261 -0
- package/lib/types/src/wiring/install-memo.d.ts +31 -0
- package/package.json +1 -5
- package/lib/local-bridge-refs.js +0 -16
- package/lib/types/src/local-bridge-refs.d.ts +0 -25
|
@@ -128,6 +128,17 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
|
|
|
128
128
|
transferChunkBytes: z<number, number, "defined">;
|
|
129
129
|
/** Chunks in flight at once during a transfer. 1 restores the sequential behaviour. */
|
|
130
130
|
transferConcurrency: z<number, number, "defined">;
|
|
131
|
+
/**
|
|
132
|
+
* Split one connection into traffic lanes (`interactive` / `bulk` / `compute`).
|
|
133
|
+
*
|
|
134
|
+
* On by default: transfers then open their own transport instead of competing with an interactive
|
|
135
|
+
* `remote_exec` for the connection's in-flight window. A lane that fails to connect falls back to the
|
|
136
|
+
* interactive transport, and turning this off restores exactly the pre-lane behaviour (one transport
|
|
137
|
+
* per connection). `stateful` carries long-lived job/LSP/watch state (watch uses a dedicated per-session key when an ambient session id is available).
|
|
138
|
+
*/
|
|
139
|
+
transportLanes: z<boolean, boolean, "defined">;
|
|
140
|
+
/** Upper bound on concurrent stateful lane transports per connection, separately for job/LSP sessions and watch (default 8 each). */
|
|
141
|
+
statefulLanes: z<number, number, "defined">;
|
|
131
142
|
/** How many finished transfers the progress table retains (FR-2.6.4). */
|
|
132
143
|
transferHistoryEntries: z<number, number, "defined">;
|
|
133
144
|
binDir: z<string, string, "defined">;
|
|
@@ -214,6 +225,17 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
|
|
|
214
225
|
transferChunkBytes: z<number, number, "defined">;
|
|
215
226
|
/** Chunks in flight at once during a transfer. 1 restores the sequential behaviour. */
|
|
216
227
|
transferConcurrency: z<number, number, "defined">;
|
|
228
|
+
/**
|
|
229
|
+
* Split one connection into traffic lanes (`interactive` / `bulk` / `compute`).
|
|
230
|
+
*
|
|
231
|
+
* On by default: transfers then open their own transport instead of competing with an interactive
|
|
232
|
+
* `remote_exec` for the connection's in-flight window. A lane that fails to connect falls back to the
|
|
233
|
+
* interactive transport, and turning this off restores exactly the pre-lane behaviour (one transport
|
|
234
|
+
* per connection). `stateful` carries long-lived job/LSP/watch state (watch uses a dedicated per-session key when an ambient session id is available).
|
|
235
|
+
*/
|
|
236
|
+
transportLanes: z<boolean, boolean, "defined">;
|
|
237
|
+
/** Upper bound on concurrent stateful lane transports per connection, separately for job/LSP sessions and watch (default 8 each). */
|
|
238
|
+
statefulLanes: z<number, number, "defined">;
|
|
217
239
|
/** How many finished transfers the progress table retains (FR-2.6.4). */
|
|
218
240
|
transferHistoryEntries: z<number, number, "defined">;
|
|
219
241
|
binDir: z<string, string, "defined">;
|
|
@@ -346,6 +368,17 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
|
|
|
346
368
|
transferChunkBytes: z<number, number, "defined">;
|
|
347
369
|
/** Chunks in flight at once during a transfer. 1 restores the sequential behaviour. */
|
|
348
370
|
transferConcurrency: z<number, number, "defined">;
|
|
371
|
+
/**
|
|
372
|
+
* Split one connection into traffic lanes (`interactive` / `bulk` / `compute`).
|
|
373
|
+
*
|
|
374
|
+
* On by default: transfers then open their own transport instead of competing with an interactive
|
|
375
|
+
* `remote_exec` for the connection's in-flight window. A lane that fails to connect falls back to the
|
|
376
|
+
* interactive transport, and turning this off restores exactly the pre-lane behaviour (one transport
|
|
377
|
+
* per connection). `stateful` carries long-lived job/LSP/watch state (watch uses a dedicated per-session key when an ambient session id is available).
|
|
378
|
+
*/
|
|
379
|
+
transportLanes: z<boolean, boolean, "defined">;
|
|
380
|
+
/** Upper bound on concurrent stateful lane transports per connection, separately for job/LSP sessions and watch (default 8 each). */
|
|
381
|
+
statefulLanes: z<number, number, "defined">;
|
|
349
382
|
/** How many finished transfers the progress table retains (FR-2.6.4). */
|
|
350
383
|
transferHistoryEntries: z<number, number, "defined">;
|
|
351
384
|
binDir: z<string, string, "defined">;
|
|
@@ -432,6 +465,17 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
|
|
|
432
465
|
transferChunkBytes: z<number, number, "defined">;
|
|
433
466
|
/** Chunks in flight at once during a transfer. 1 restores the sequential behaviour. */
|
|
434
467
|
transferConcurrency: z<number, number, "defined">;
|
|
468
|
+
/**
|
|
469
|
+
* Split one connection into traffic lanes (`interactive` / `bulk` / `compute`).
|
|
470
|
+
*
|
|
471
|
+
* On by default: transfers then open their own transport instead of competing with an interactive
|
|
472
|
+
* `remote_exec` for the connection's in-flight window. A lane that fails to connect falls back to the
|
|
473
|
+
* interactive transport, and turning this off restores exactly the pre-lane behaviour (one transport
|
|
474
|
+
* per connection). `stateful` carries long-lived job/LSP/watch state (watch uses a dedicated per-session key when an ambient session id is available).
|
|
475
|
+
*/
|
|
476
|
+
transportLanes: z<boolean, boolean, "defined">;
|
|
477
|
+
/** Upper bound on concurrent stateful lane transports per connection, separately for job/LSP sessions and watch (default 8 each). */
|
|
478
|
+
statefulLanes: z<number, number, "defined">;
|
|
435
479
|
/** How many finished transfers the progress table retains (FR-2.6.4). */
|
|
436
480
|
transferHistoryEntries: z<number, number, "defined">;
|
|
437
481
|
binDir: z<string, string, "defined">;
|
|
@@ -60,6 +60,10 @@ export interface BackendText {
|
|
|
60
60
|
readonly size: number;
|
|
61
61
|
readonly truncated: boolean;
|
|
62
62
|
readonly version: string;
|
|
63
|
+
/** The bytes are not valid UTF-8; `text` is only a readable replacement. */
|
|
64
|
+
readonly binary?: boolean;
|
|
65
|
+
/** Exact bytes (base64), present only when {@link binary} is true. */
|
|
66
|
+
readonly dataBase64?: string;
|
|
63
67
|
}
|
|
64
68
|
/** Outcome of a write. */
|
|
65
69
|
export interface BackendWrite {
|
|
@@ -129,6 +133,33 @@ export interface Backend {
|
|
|
129
133
|
* ignore it. Structural on purpose — the app layer must not import platform types.
|
|
130
134
|
*/
|
|
131
135
|
writeText(absolute: string, text: string, expectedVersion: string | undefined, signal?: AbortSignal, policy?: BackendWritePolicy): Promise<BackendWrite>;
|
|
136
|
+
/** One-round-trip project overview; absent when the backend has no `repo.summary`. */
|
|
137
|
+
repoSummary?(root: string, signal?: AbortSignal): Promise<Record<string, unknown>>;
|
|
138
|
+
/** Multi-file edit in one round trip; absent when the backend has no `fs.patchMany`. */
|
|
139
|
+
patchMany?(edits: readonly {
|
|
140
|
+
readonly path: string;
|
|
141
|
+
readonly expectedVersion?: string;
|
|
142
|
+
readonly find?: string;
|
|
143
|
+
readonly replace?: string;
|
|
144
|
+
readonly all?: boolean;
|
|
145
|
+
readonly create?: boolean;
|
|
146
|
+
readonly patch?: string;
|
|
147
|
+
}[], atomic: boolean, signal?: AbortSignal): Promise<Record<string, unknown>>;
|
|
148
|
+
/**
|
|
149
|
+
* Read several paths in **one** round trip (the backend's own batch sweep).
|
|
150
|
+
*
|
|
151
|
+
* Optional: backends without a batch primitive leave it absent and the caller falls back to parallel
|
|
152
|
+
* single reads. `chunks` keeps one slot per requested path (undefined where it could not be read), so
|
|
153
|
+
* "missing" and "truncated" never collapse into a shorter array.
|
|
154
|
+
*/
|
|
155
|
+
readMany?(absolutePaths: readonly string[], options: {
|
|
156
|
+
readonly maxBytesEach: number;
|
|
157
|
+
readonly totalBytesCap?: number;
|
|
158
|
+
readonly signal?: AbortSignal;
|
|
159
|
+
}): Promise<{
|
|
160
|
+
readonly chunks: readonly (BackendText | undefined)[];
|
|
161
|
+
readonly missing: readonly string[];
|
|
162
|
+
}>;
|
|
132
163
|
/**
|
|
133
164
|
* Apply a unified diff to one file, atomically.
|
|
134
165
|
*
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The routed file system: one workspace, many roots, local and remote side by side.
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities, in order:
|
|
5
|
+
*
|
|
6
|
+
* 1. **Resolve** a model-facing path to exactly one root plus a backend-absolute path
|
|
7
|
+
* (all rules live in `domain/route-table.ts`; this module only applies the result).
|
|
8
|
+
* One addition lives here rather than in the table: the **implicit session root**, so the session's own
|
|
9
|
+
* working directory is always usable even when no registered root covers it (see {@link
|
|
10
|
+
* RoutedFileSystem.tryResolve}).
|
|
11
|
+
* 2. **Namespace** target keys as `{backendId}\0{absolute path}` — the same file spelled locally
|
|
12
|
+
* and remotely must never collide, because core components key their state by target key.
|
|
13
|
+
* 3. **Fence writes**: resolution already rejects paths outside every root, so `workspace-write`
|
|
14
|
+
* means "inside the roots"; `read-only` denies every write; `danger-full-access` allows.
|
|
15
|
+
* This is the plugin's own multi-root confinement (the core's single-root fence cannot express
|
|
16
|
+
* several roots, see `doc/02-platform/fs-seam.md` §4).
|
|
17
|
+
* 4. Keep the operations the model's tools need: stat / list / read / write / edit.
|
|
18
|
+
*
|
|
19
|
+
* @module dsh-over-ssh/app/routed-fs
|
|
20
|
+
*/
|
|
21
|
+
import type { Backend, BackendEntry, BackendRead, BackendSandboxMode, BackendStat, BackendText } from './backend.ts';
|
|
22
|
+
import { SshDevError } from '../domain/errors.ts';
|
|
23
|
+
import { type RootRef } from '../domain/roots/root.ts';
|
|
24
|
+
import { type RouteOptions } from '../domain/roots/route-table.ts';
|
|
25
|
+
/** A resolved path: one root, one backend, one backend-absolute location. */
|
|
26
|
+
export interface RoutedTarget {
|
|
27
|
+
readonly root: RootRef;
|
|
28
|
+
readonly backend: Backend;
|
|
29
|
+
/** Key of the root, also the router's backend key. */
|
|
30
|
+
readonly backendKey: string;
|
|
31
|
+
/** Absolute path in the backend's own spelling. */
|
|
32
|
+
readonly absolute: string;
|
|
33
|
+
/** Namespaced identity: `{backendId}\0{absolute}`. */
|
|
34
|
+
readonly targetKey: string;
|
|
35
|
+
readonly relativePath: string;
|
|
36
|
+
/** Model-facing spelling (`ssh://…` for remote roots). */
|
|
37
|
+
readonly displayPath: string;
|
|
38
|
+
}
|
|
39
|
+
/** Resolution outcome with a resolved target, or the reason it failed. */
|
|
40
|
+
export type RoutedResolution = {
|
|
41
|
+
readonly ok: true;
|
|
42
|
+
readonly target: RoutedTarget;
|
|
43
|
+
} | {
|
|
44
|
+
readonly ok: false;
|
|
45
|
+
readonly error: SshDevError;
|
|
46
|
+
};
|
|
47
|
+
/** A directory entry with its resolved identity. */
|
|
48
|
+
export interface RoutedEntry extends BackendEntry {
|
|
49
|
+
readonly targetKey: string;
|
|
50
|
+
readonly displayPath: string;
|
|
51
|
+
}
|
|
52
|
+
/** Options for {@link RoutedFileSystem}. */
|
|
53
|
+
export interface RoutedFileSystemOptions {
|
|
54
|
+
readonly roots: readonly RootRef[];
|
|
55
|
+
/** Backends keyed by `rootKey(root)`. */
|
|
56
|
+
readonly backends: ReadonlyMap<string, Backend>;
|
|
57
|
+
readonly caseInsensitive?: boolean;
|
|
58
|
+
/** Cap for a single read; defaults to 8 MiB. */
|
|
59
|
+
readonly maxReadBytes?: number;
|
|
60
|
+
/**
|
|
61
|
+
* Builds the backend for the **implicit session root** — the session's own cwd (see {@link
|
|
62
|
+
* RoutedFileSystem.tryResolve}).
|
|
63
|
+
*
|
|
64
|
+
* Injected rather than imported because building a local backend needs `node:fs`, and this layer must not
|
|
65
|
+
* reach a concrete adapter (架构测试: `app` only depends on `infra` port modules). Omitting it **disables**
|
|
66
|
+
* the implicit root, which is the honest default: a router that was given no way to serve host paths keeps
|
|
67
|
+
* reporting `ROOT_OUTSIDE` for them.
|
|
68
|
+
*/
|
|
69
|
+
readonly createLocalBackend?: (root: RootRef) => Backend;
|
|
70
|
+
}
|
|
71
|
+
/** Write options. */
|
|
72
|
+
export interface RoutedWriteOptions {
|
|
73
|
+
readonly cwd?: string;
|
|
74
|
+
/** Version token that must match before the write proceeds. */
|
|
75
|
+
readonly expectedVersion?: string;
|
|
76
|
+
/** Policy the caller is operating under. */
|
|
77
|
+
readonly sandbox?: BackendSandboxMode;
|
|
78
|
+
readonly signal?: AbortSignal;
|
|
79
|
+
}
|
|
80
|
+
/** Literal edit request, mirroring the core's `editText` semantics. */
|
|
81
|
+
export interface RoutedEdit {
|
|
82
|
+
readonly find: string;
|
|
83
|
+
readonly replace: string;
|
|
84
|
+
/** Replace every occurrence instead of requiring a unique one. */
|
|
85
|
+
readonly all?: boolean;
|
|
86
|
+
}
|
|
87
|
+
/** Multi-root file system over a set of backends. */
|
|
88
|
+
export declare class RoutedFileSystem {
|
|
89
|
+
#private;
|
|
90
|
+
constructor(options: RoutedFileSystemOptions);
|
|
91
|
+
/** Registered roots, in registration order. */
|
|
92
|
+
get roots(): readonly RootRef[];
|
|
93
|
+
/** Confinement the router enforces itself. */
|
|
94
|
+
get sandboxMode(): BackendSandboxMode;
|
|
95
|
+
/** Register a root together with the backend that serves it. */
|
|
96
|
+
addRoot(root: RootRef, backend: Backend): void;
|
|
97
|
+
/** Remove a root and its backend binding. */
|
|
98
|
+
removeRoot(key: string): boolean;
|
|
99
|
+
/** Replace every root and backend binding at once. */
|
|
100
|
+
setRoots(entries: readonly {
|
|
101
|
+
readonly root: RootRef;
|
|
102
|
+
readonly backend: Backend;
|
|
103
|
+
}[]): void;
|
|
104
|
+
/**
|
|
105
|
+
* Resolve a path.
|
|
106
|
+
*
|
|
107
|
+
* @param input - model-facing path (host path, `ssh://…`, or relative to `cwd`).
|
|
108
|
+
* @param options - session cwd and case folding.
|
|
109
|
+
* @returns the resolved target.
|
|
110
|
+
* @throws SshDevError `ROOT_OUTSIDE` / `ROOT_AMBIGUOUS` / `ROOT_INVALID`.
|
|
111
|
+
*/
|
|
112
|
+
resolve(input: string, options?: RouteOptions): RoutedTarget;
|
|
113
|
+
/**
|
|
114
|
+
* Non-throwing resolution for UI code.
|
|
115
|
+
*
|
|
116
|
+
* A registered root is always asked first, so it wins over the implicit session root — the implicit root only
|
|
117
|
+
* answers paths the table refused.
|
|
118
|
+
*/
|
|
119
|
+
tryResolve(input: string, options?: RouteOptions): RoutedResolution;
|
|
120
|
+
/** Metadata for a path. */
|
|
121
|
+
stat(input: string, options?: RouteOptions & {
|
|
122
|
+
readonly signal?: AbortSignal;
|
|
123
|
+
}): Promise<BackendStat & {
|
|
124
|
+
readonly target: RoutedTarget;
|
|
125
|
+
}>;
|
|
126
|
+
/** Direct children of a directory, with resolved identities. */
|
|
127
|
+
list(input: string, options?: RouteOptions & {
|
|
128
|
+
readonly signal?: AbortSignal;
|
|
129
|
+
}): Promise<readonly RoutedEntry[]>;
|
|
130
|
+
/**
|
|
131
|
+
* List an already-resolved directory.
|
|
132
|
+
*
|
|
133
|
+
* Takes the target so callers that hold one (the core `FsTarget` contract hands one to every
|
|
134
|
+
* operation) do not pay for a second resolution.
|
|
135
|
+
*
|
|
136
|
+
* @param target - resolved directory.
|
|
137
|
+
* @param signal - cancellation.
|
|
138
|
+
* @returns direct children with resolved identities.
|
|
139
|
+
*/
|
|
140
|
+
listTarget(target: RoutedTarget, signal?: AbortSignal): Promise<readonly RoutedEntry[]>;
|
|
141
|
+
/**
|
|
142
|
+
* Stat an already-resolved target.
|
|
143
|
+
*
|
|
144
|
+
* @param target - resolved target.
|
|
145
|
+
* @param options - when `followSymlink` is false and the backend supports it, the link itself is
|
|
146
|
+
* inspected instead of its target.
|
|
147
|
+
* @returns metadata (never content).
|
|
148
|
+
*/
|
|
149
|
+
statTarget(target: RoutedTarget, options?: {
|
|
150
|
+
readonly followSymlink?: boolean;
|
|
151
|
+
readonly signal?: AbortSignal;
|
|
152
|
+
}): Promise<BackendStat>;
|
|
153
|
+
/**
|
|
154
|
+
* Resolve an opaque target key back to the root, backend and backend-absolute path that own it.
|
|
155
|
+
*
|
|
156
|
+
* Every operation after {@link resolve} arrives this way (the core hands back only the target key), so an
|
|
157
|
+
* implicit session root must be found here too: it is not in the table, but its backend id is what its target
|
|
158
|
+
* keys carry.
|
|
159
|
+
*/
|
|
160
|
+
targetFromKey(targetKey: string): RoutedTarget;
|
|
161
|
+
/** Whether a path exists. */
|
|
162
|
+
exists(input: string, options?: RouteOptions & {
|
|
163
|
+
readonly signal?: AbortSignal;
|
|
164
|
+
}): Promise<boolean>;
|
|
165
|
+
/** Read bytes, capped by the router's limit. */
|
|
166
|
+
readBytes(input: string, options?: RouteOptions & {
|
|
167
|
+
readonly maxBytes?: number;
|
|
168
|
+
readonly signal?: AbortSignal;
|
|
169
|
+
}): Promise<BackendRead & {
|
|
170
|
+
readonly target: RoutedTarget;
|
|
171
|
+
}>;
|
|
172
|
+
/** Read text. */
|
|
173
|
+
readText(input: string, options?: RouteOptions & {
|
|
174
|
+
readonly maxBytes?: number;
|
|
175
|
+
readonly signal?: AbortSignal;
|
|
176
|
+
}): Promise<BackendText & {
|
|
177
|
+
readonly target: RoutedTarget;
|
|
178
|
+
}>;
|
|
179
|
+
/**
|
|
180
|
+
* Write text after enforcing the router's own confinement.
|
|
181
|
+
*
|
|
182
|
+
* @param input - destination path.
|
|
183
|
+
* @param text - content to write.
|
|
184
|
+
* @param options - policy, version guard and cancellation.
|
|
185
|
+
* @returns the write outcome plus the resolved target.
|
|
186
|
+
*/
|
|
187
|
+
writeText(input: string, text: string, options?: RoutedWriteOptions): Promise<{
|
|
188
|
+
readonly bytes: number;
|
|
189
|
+
readonly version: string;
|
|
190
|
+
readonly target: RoutedTarget;
|
|
191
|
+
}>;
|
|
192
|
+
/**
|
|
193
|
+
* Apply a unified diff to one file, atomically.
|
|
194
|
+
*
|
|
195
|
+
* Forwarded rather than reimplemented: the backend owns the hunk matching, and a second implementation
|
|
196
|
+
* here would be a second set of edge cases to keep in sync.
|
|
197
|
+
*/
|
|
198
|
+
patchText(input: string, diff: string, options?: RoutedWriteOptions): Promise<{
|
|
199
|
+
readonly bytes: number;
|
|
200
|
+
readonly version: string;
|
|
201
|
+
readonly hunks: number;
|
|
202
|
+
readonly target: RoutedTarget;
|
|
203
|
+
}>;
|
|
204
|
+
/**
|
|
205
|
+
* Replace a literal string inside a file.
|
|
206
|
+
*
|
|
207
|
+
* Mirrors the core's semantics: an absent pattern and an ambiguous match without `all` are both
|
|
208
|
+
* errors, and a version guard can refuse the whole edit.
|
|
209
|
+
*
|
|
210
|
+
* @param input - file to edit.
|
|
211
|
+
* @param edit - find/replace plus the `all` flag.
|
|
212
|
+
* @param options - policy, version guard, cwd and cancellation.
|
|
213
|
+
* @returns the new version and the number of replacements.
|
|
214
|
+
*/
|
|
215
|
+
editText(input: string, edit: RoutedEdit, options?: RoutedWriteOptions): Promise<{
|
|
216
|
+
readonly replacements: number;
|
|
217
|
+
readonly version: string;
|
|
218
|
+
readonly target: RoutedTarget;
|
|
219
|
+
}>;
|
|
220
|
+
/**
|
|
221
|
+
* Whether one target is inside another.
|
|
222
|
+
*
|
|
223
|
+
* Both targets must belong to the same backend; across backends the answer is always `false`
|
|
224
|
+
* (a local path can never contain a remote path).
|
|
225
|
+
*/
|
|
226
|
+
contains(parent: RoutedTarget, child: RoutedTarget): boolean;
|
|
227
|
+
/** Host path for a child process in the target's world, when that world has one. */
|
|
228
|
+
processPath(target: RoutedTarget): string | undefined;
|
|
229
|
+
/** `file:` URL in the target's world. */
|
|
230
|
+
fileUrl(target: RoutedTarget): string | undefined;
|
|
231
|
+
}
|
|
232
|
+
/** Namespaced identity of one file; the backend id prevents local/remote collisions. */
|
|
233
|
+
export declare function makeTargetKey(backend: Backend, absolute: string): string;
|
|
234
|
+
/** Split a target key back into its parts (diagnostics and tests only). */
|
|
235
|
+
export declare function splitTargetKey(targetKey: string): {
|
|
236
|
+
readonly backendId: string;
|
|
237
|
+
readonly absolute: string;
|
|
238
|
+
} | undefined;
|
|
@@ -163,6 +163,23 @@ export interface RestoreSessionResult {
|
|
|
163
163
|
/** Absolute path the session directory was moved back to. */
|
|
164
164
|
readonly restoredPath: string;
|
|
165
165
|
readonly movedBy: 'rename' | 'copy';
|
|
166
|
+
/**
|
|
167
|
+
* Sub-sessions restored **together with** the requested entry.
|
|
168
|
+
*
|
|
169
|
+
* Restoring a parent used to leave its deleted children in the bin with no discoverable link back to the
|
|
170
|
+
* parent, so the user could never learn they were still there. They are now restored in the same call.
|
|
171
|
+
*/
|
|
172
|
+
readonly restoredSubsessions: readonly {
|
|
173
|
+
readonly sessionId: string;
|
|
174
|
+
readonly restoredPath: string;
|
|
175
|
+
}[];
|
|
176
|
+
/** Sub-sessions that could not be restored (path occupied, move failed); they stay in the bin. */
|
|
177
|
+
readonly failedSubsessions: readonly {
|
|
178
|
+
readonly sessionId: string;
|
|
179
|
+
readonly reason: string;
|
|
180
|
+
}[];
|
|
181
|
+
/** True when the restored entry is a sub-session whose parent is still in the bin. */
|
|
182
|
+
readonly parentStillBinned: boolean;
|
|
166
183
|
}
|
|
167
184
|
/** One purge outcome. */
|
|
168
185
|
export interface PurgeSessionResult {
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Transport lifecycle: acquire, reuse, replace and release
|
|
2
|
+
* Transport lifecycle: acquire, reuse, replace and release transports per connection and lane.
|
|
3
3
|
*
|
|
4
4
|
* The service owns three behaviours the rest of the plugin relies on:
|
|
5
5
|
*
|
|
6
|
-
* 1. **One transport per connection**, created lazily and shared by every caller
|
|
6
|
+
* 1. **One transport per connection and lane**, created lazily and shared by every caller on that
|
|
7
|
+
* lane (single flight). A call without a lane keeps the historical one-transport-per-connection
|
|
8
|
+
* behaviour and shares that slot with the `interactive` lane.
|
|
7
9
|
* 2. **Replacement on a changed decision**: when the selector's answer changes (for example after
|
|
8
|
-
* a mux failure is recorded), the old
|
|
10
|
+
* a mux failure is recorded), the old transports are disposed and new ones built, so a stale
|
|
9
11
|
* decision can never keep serving traffic.
|
|
10
12
|
* 3. **Explicit release**, so shutting a connection down closes its process/connection rather
|
|
11
|
-
* than leaving it to the garbage collector.
|
|
13
|
+
* than leaving it to the garbage collector. Release/dispose cover **every lane** of a profile.
|
|
12
14
|
*
|
|
13
15
|
* The transport contract lives in `infra/transport/transport.ts` and is imported as a type only,
|
|
14
16
|
* which keeps this module free of implementation and of the core packages.
|
|
@@ -27,7 +29,7 @@ export interface TransportFactories {
|
|
|
27
29
|
* Creates the Tier 2 agent transport. Optional: a build without the agent (or without a shipped binary)
|
|
28
30
|
* simply omits it, and the selector never chooses `agent` in that case.
|
|
29
31
|
*/
|
|
30
|
-
readonly createAgent?: (profile: ConnectionProfile, decision: TransportDecision) => Transport;
|
|
32
|
+
readonly createAgent?: (profile: ConnectionProfile, decision: TransportDecision, lane?: TransportLane, sessionKey?: string) => Transport;
|
|
31
33
|
}
|
|
32
34
|
/**
|
|
33
35
|
* Optional asynchronous step between deciding and connecting.
|
|
@@ -45,10 +47,34 @@ export interface TransportFactories {
|
|
|
45
47
|
* keep uploading anyway because only `transport.connect()` saw the signal.
|
|
46
48
|
*/
|
|
47
49
|
export type TransportPreflight = (profile: ConnectionProfile, decision: TransportDecision, signal?: AbortSignal) => Promise<TransportDecision>;
|
|
50
|
+
/**
|
|
51
|
+
* Traffic class of a transport.
|
|
52
|
+
*
|
|
53
|
+
* `interactive` carries fs/exec calls a user is waiting on; `bulk` carries transfer reads; `compute`
|
|
54
|
+
* carries search/index/git/repo-summary work; `stateful` carries long-lived job/LSP/watch sessions.
|
|
55
|
+
* The wiring uses a DSH session id for job/LSP and a `watch:<session>` (or shared `watch`) key for watch. Splitting them keeps a
|
|
56
|
+
* large transfer or an index scan from consuming the slots of an interactive command.
|
|
57
|
+
*/
|
|
58
|
+
export type TransportLane = 'interactive' | 'bulk' | 'compute' | 'stateful';
|
|
59
|
+
/** Lane selection for {@link TransportService.acquire}. */
|
|
60
|
+
export interface TransportAcquireOptions {
|
|
61
|
+
/** Traffic class; defaults to `interactive`. */
|
|
62
|
+
readonly lane?: TransportLane;
|
|
63
|
+
/**
|
|
64
|
+
* Extra cache dimension for stateful clients (one agent session per id).
|
|
65
|
+
*
|
|
66
|
+
* Ignored unless a lane is given: a call without options must keep the old key.
|
|
67
|
+
*/
|
|
68
|
+
readonly sessionKey?: string;
|
|
69
|
+
}
|
|
48
70
|
/** One acquired transport plus the decision that produced it. */
|
|
49
71
|
export interface AcquiredTransport {
|
|
50
72
|
readonly transport: Transport;
|
|
51
73
|
readonly decision: TransportDecision;
|
|
74
|
+
/** Which lane this transport serves. */
|
|
75
|
+
readonly lane: TransportLane;
|
|
76
|
+
/** Connection this transport belongs to; set by the service's own cache. */
|
|
77
|
+
readonly connectionId?: string;
|
|
52
78
|
}
|
|
53
79
|
/** Facts injected for testability. */
|
|
54
80
|
export interface TransportServiceOptions {
|
|
@@ -56,8 +82,17 @@ export interface TransportServiceOptions {
|
|
|
56
82
|
readonly factories: TransportFactories;
|
|
57
83
|
/** See {@link TransportPreflight}. Omitted means "no revision". */
|
|
58
84
|
readonly preflight?: TransportPreflight;
|
|
85
|
+
/**
|
|
86
|
+
* Upper bound on concurrent `stateful` lane transports per connection.
|
|
87
|
+
*
|
|
88
|
+
* Each stateful lane carries an AgentTransport with its own in-flight window; without a cap, one DSH
|
|
89
|
+
* session per connection would multiply the bounded window by the number of live sessions. Job/LSP and
|
|
90
|
+
* watch lanes are counted separately; hitting the cap makes {@link acquire} fail and lets
|
|
91
|
+
* `TransportLaneRouter` fall back to interactive.
|
|
92
|
+
*/
|
|
93
|
+
readonly statefulLaneLimit?: number;
|
|
59
94
|
}
|
|
60
|
-
/** Owns
|
|
95
|
+
/** Owns transport instances per connection and lane. */
|
|
61
96
|
export declare class TransportService {
|
|
62
97
|
#private;
|
|
63
98
|
constructor(options: TransportServiceOptions);
|
|
@@ -65,17 +100,37 @@ export declare class TransportService {
|
|
|
65
100
|
* Get a connected transport for a profile, creating it if needed.
|
|
66
101
|
*
|
|
67
102
|
* @param profile - the connection to serve.
|
|
68
|
-
* @param
|
|
103
|
+
* @param signalOrOptions - cancellation for the connect attempt (legacy form) or lane selection.
|
|
104
|
+
* @param signal - cancellation when the second argument carries lane options.
|
|
69
105
|
* @returns the transport and the decision behind it (after any preflight revision).
|
|
70
106
|
* @throws SshDevError from the selector or from the transport's own connect.
|
|
71
107
|
*/
|
|
72
108
|
acquire(profile: ConnectionProfile, signal?: AbortSignal): Promise<AcquiredTransport>;
|
|
73
|
-
|
|
74
|
-
|
|
109
|
+
acquire(profile: ConnectionProfile, options?: TransportAcquireOptions, signal?: AbortSignal): Promise<AcquiredTransport>;
|
|
110
|
+
/**
|
|
111
|
+
* The cached transport for a connection.
|
|
112
|
+
*
|
|
113
|
+
* Without options this is the legacy interactive slot, so existing callers keep their old meaning.
|
|
114
|
+
*/
|
|
115
|
+
current(connectionId: string, options?: TransportAcquireOptions): AcquiredTransport | undefined;
|
|
75
116
|
/** Decisions currently in force, for the connection status UI. */
|
|
76
117
|
decisions(): readonly TransportDecision[];
|
|
77
|
-
/** Dispose and forget one connection. */
|
|
118
|
+
/** Dispose and forget **every lane** of one connection. */
|
|
78
119
|
release(connectionId: string): Promise<void>;
|
|
79
|
-
/**
|
|
120
|
+
/** Unique DSH session keys currently owning a stateful lane (used by the idle janitor). */
|
|
121
|
+
statefulSessions(): readonly string[];
|
|
122
|
+
/** Release stateful lanes whose DSH session is no longer live. */
|
|
123
|
+
pruneStatefulSessions(isLive: (sessionKey: string) => boolean): Promise<void>;
|
|
124
|
+
/**
|
|
125
|
+
* Dispose the `stateful` lane of one DSH session on every connection.
|
|
126
|
+
*
|
|
127
|
+
* Stateful lanes are keyed by session, so deleting a DSH session would otherwise leave one agent
|
|
128
|
+
* transport per connection alive for the life of the host process (and with it the daemon-side jobs/LSP
|
|
129
|
+
* ownership records that transport created). Other lanes are untouched.
|
|
130
|
+
*
|
|
131
|
+
* @param sessionKey - the DSH session id used when the stateful lane was acquired.
|
|
132
|
+
*/
|
|
133
|
+
releaseSession(sessionKey: string): Promise<void>;
|
|
134
|
+
/** Dispose every connection and lane (plugin unload, connection-service shutdown). */
|
|
80
135
|
disposeAll(): Promise<void>;
|
|
81
136
|
}
|
|
@@ -28,6 +28,12 @@ export interface RemoteCapabilities {
|
|
|
28
28
|
* reported to a caller as "not installed", because we have no evidence either way.
|
|
29
29
|
*/
|
|
30
30
|
readonly lspServers?: readonly LspServerInfo[];
|
|
31
|
+
/**
|
|
32
|
+
* `search.grep` offset/limit paging support (agent capability `grepPaging`).
|
|
33
|
+
*
|
|
34
|
+
* Optional: `undefined` means "this transport cannot page" — same as false for callers.
|
|
35
|
+
*/
|
|
36
|
+
readonly grepPaging?: boolean;
|
|
31
37
|
}
|
|
32
38
|
/** One language server found on the remote `PATH`. */
|
|
33
39
|
export interface LspServerInfo {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The route table: turns a model-facing path into one backend plus a root-relative path.
|
|
3
|
+
*
|
|
4
|
+
* This is the heart of "one workspace, several roots". Resolution order is deliberate, and
|
|
5
|
+
* every branch is a documented rule rather than a heuristic:
|
|
6
|
+
*
|
|
7
|
+
* 1. `ssh://<connection>/abs` — the explicit remote grammar always wins.
|
|
8
|
+
* 2. Anchor match — a path (or the session cwd) under a remote root's local anchor resolves
|
|
9
|
+
* to that remote root. The anchor is what makes a remote root a legal DSH workspace.
|
|
10
|
+
* 3. Local root containment — absolute host paths, and `cwd`-relative spellings.
|
|
11
|
+
* 4. Bare POSIX absolute path matching remote roots — needed because a prompt lists remote
|
|
12
|
+
* roots in POSIX spelling; two equally specific remote roots on different connections
|
|
13
|
+
* are reported as ambiguous instead of being guessed.
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-over-ssh/domain/route-table
|
|
16
|
+
*/
|
|
17
|
+
import { SshDevError } from '../errors.ts';
|
|
18
|
+
import { type RootRef } from './root.ts';
|
|
19
|
+
import { type SessionIdentity } from './session-roots.ts';
|
|
20
|
+
/** One resolved route. */
|
|
21
|
+
export interface RouteTarget {
|
|
22
|
+
/** The root the path belongs to. */
|
|
23
|
+
readonly root: RootRef;
|
|
24
|
+
/** Root-relative spelling with `/` separators; an empty string means the root itself. */
|
|
25
|
+
readonly relativePath: string;
|
|
26
|
+
/** Stable backend key; one backend serves all paths of one root. */
|
|
27
|
+
readonly backendKey: string;
|
|
28
|
+
}
|
|
29
|
+
/** Per-call resolution options. */
|
|
30
|
+
export interface RouteOptions {
|
|
31
|
+
/** Session working directory used for relative spellings. */
|
|
32
|
+
readonly cwd?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Who is asking, when the caller knows more than its cwd (ADR-0013).
|
|
35
|
+
*
|
|
36
|
+
* The route table never invents this: the fs provider reads it from the session scope the tool dispatcher
|
|
37
|
+
* carries down (`integration/session-scope.ts`) and hands it over. `undefined` means "no session identity" —
|
|
38
|
+
* the workspace-level rule then stands, which is exactly the behaviour before ADR-0013.
|
|
39
|
+
*/
|
|
40
|
+
readonly session?: SessionIdentity;
|
|
41
|
+
/**
|
|
42
|
+
* The asking session's own working directory, when the caller did not supply a `cwd`.
|
|
43
|
+
*
|
|
44
|
+
* Used **only** by the implicit session root (a session's own directory is always readable to it) — never by
|
|
45
|
+
* the gate below, which must keep seeing exactly what the platform passed. Synthesising a `cwd` for the gate
|
|
46
|
+
* would apply the workspace rule to calls that legitimately carry none, which is the failure ADR-0012 §4
|
|
47
|
+
* boundary 2 records (a whole turn lost before the model was called).
|
|
48
|
+
*/
|
|
49
|
+
readonly sessionCwd?: string;
|
|
50
|
+
/** Override host-path case folding (defaults to the platform). */
|
|
51
|
+
readonly caseInsensitive?: boolean;
|
|
52
|
+
}
|
|
53
|
+
/** A path resolved to a root, or a diagnostic reason why it could not be. */
|
|
54
|
+
export type RouteOutcome = {
|
|
55
|
+
readonly ok: true;
|
|
56
|
+
readonly target: RouteTarget;
|
|
57
|
+
} | {
|
|
58
|
+
readonly ok: false;
|
|
59
|
+
readonly error: SshDevError;
|
|
60
|
+
};
|
|
61
|
+
/** Resolution table over an ordered set of roots. */
|
|
62
|
+
export declare class RouteTable {
|
|
63
|
+
#private;
|
|
64
|
+
constructor(roots?: readonly RootRef[], options?: {
|
|
65
|
+
readonly caseInsensitive?: boolean;
|
|
66
|
+
});
|
|
67
|
+
/** Roots in registration order. */
|
|
68
|
+
get roots(): readonly RootRef[];
|
|
69
|
+
/**
|
|
70
|
+
* Register a root.
|
|
71
|
+
*
|
|
72
|
+
* @param root - root to add.
|
|
73
|
+
* @throws SshDevError `ROOT_INVALID` when the root is malformed or already registered.
|
|
74
|
+
*/
|
|
75
|
+
add(root: RootRef): void;
|
|
76
|
+
/** Remove a root by its key; returns whether it was present. */
|
|
77
|
+
remove(key: string): boolean;
|
|
78
|
+
/** Replace every root; throws like {@link add} when any root is invalid. */
|
|
79
|
+
reset(roots: readonly RootRef[]): void;
|
|
80
|
+
/**
|
|
81
|
+
* Resolve a path, then apply the **session gate**.
|
|
82
|
+
*
|
|
83
|
+
* Two failures wear the same code on purpose: "no root covers this path" and "the root that covers it belongs
|
|
84
|
+
* to another session" are both `ROOT_OUTSIDE`, because from the caller's side they are the same fact — this
|
|
85
|
+
* session cannot use that path. The message and the `root` detail say which one it was.
|
|
86
|
+
*
|
|
87
|
+
* @param input - model-facing, host or remote spelling.
|
|
88
|
+
* @param options - session cwd (also the session identity, see {@link rootVisibleToSession}) and case folding.
|
|
89
|
+
* @returns the resolved route, or a failure carrying `ROOT_OUTSIDE` / `ROOT_AMBIGUOUS`.
|
|
90
|
+
*/
|
|
91
|
+
tryResolve(input: string, options?: RouteOptions): RouteOutcome;
|
|
92
|
+
/**
|
|
93
|
+
* Resolve a path or throw.
|
|
94
|
+
*
|
|
95
|
+
* @param input - path to resolve.
|
|
96
|
+
* @param options - session cwd and case-folding override.
|
|
97
|
+
* @returns the resolved route.
|
|
98
|
+
* @throws SshDevError `ROOT_OUTSIDE` or `ROOT_AMBIGUOUS`.
|
|
99
|
+
*/
|
|
100
|
+
resolve(input: string, options?: RouteOptions): RouteTarget;
|
|
101
|
+
}
|
|
@@ -227,6 +227,13 @@ export interface SessionTrashEntry {
|
|
|
227
227
|
*/
|
|
228
228
|
readonly originalSegment: string;
|
|
229
229
|
readonly title: string | undefined;
|
|
230
|
+
/**
|
|
231
|
+
* Parent session id from the header, when this deleted session was a sub-session.
|
|
232
|
+
*
|
|
233
|
+
* Optional so an index written by 0.2.2 (which did not record it) still parses; restoring a parent then
|
|
234
|
+
* cannot find children recorded before the field existed, which is exactly the old behaviour.
|
|
235
|
+
*/
|
|
236
|
+
readonly parentSessionId?: string | undefined;
|
|
230
237
|
/** Total bytes of the moved log files, for the retention policy. */
|
|
231
238
|
readonly bytes: number;
|
|
232
239
|
readonly deletedAtMs: number;
|
|
@@ -342,6 +349,7 @@ export declare function makeSessionTrashEntry(input: {
|
|
|
342
349
|
readonly segment: string;
|
|
343
350
|
readonly originalSegment: string;
|
|
344
351
|
readonly title: string | undefined;
|
|
352
|
+
readonly parentSessionId?: string | undefined;
|
|
345
353
|
readonly bytes: number;
|
|
346
354
|
readonly deletedAtMs: number;
|
|
347
355
|
readonly movedBy: 'rename' | 'copy';
|