dsh-over-ssh 0.0.0-stage → 0.1.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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +119 -2
  3. package/agents/agent-manifest.json +20 -0
  4. package/agents/linux-amd64/dsh-over-ssh-agent +0 -0
  5. package/agents/linux-arm64/dsh-over-ssh-agent +0 -0
  6. package/cordis.patch.yml +84 -0
  7. package/lib/client.js +6660 -0
  8. package/lib/file-references.js +411 -0
  9. package/lib/fs.js +1451 -0
  10. package/lib/index.js +19740 -0
  11. package/lib/local-bridge-refs.js +16 -0
  12. package/lib/local-bridge.js +16 -0
  13. package/lib/terminal-wrapper.js +299 -0
  14. package/lib/types/src/client/index.d.ts +49 -0
  15. package/lib/types/src/config.d.ts +383 -0
  16. package/lib/types/src/file-references.d.ts +24 -0
  17. package/lib/types/src/host/app/backend.d.ts +178 -0
  18. package/lib/types/src/host/app/recycle-bin/recycle-bin-contract.d.ts +246 -0
  19. package/lib/types/src/host/app/recycle-bin/recycle-bin-service.d.ts +44 -0
  20. package/lib/types/src/host/app/session-admin/session-admin-contract.d.ts +232 -0
  21. package/lib/types/src/host/app/session-admin/session-admin-service.d.ts +39 -0
  22. package/lib/types/src/host/app/transport/transport-selector.d.ts +97 -0
  23. package/lib/types/src/host/app/transport/transport-service.d.ts +81 -0
  24. package/lib/types/src/host/domain/capability.d.ts +77 -0
  25. package/lib/types/src/host/domain/errors.d.ts +54 -0
  26. package/lib/types/src/host/domain/roots/connection.d.ts +105 -0
  27. package/lib/types/src/host/domain/roots/root.d.ts +123 -0
  28. package/lib/types/src/host/domain/roots/session-roots.d.ts +150 -0
  29. package/lib/types/src/host/domain/roots/ssh-config.d.ts +72 -0
  30. package/lib/types/src/host/domain/session/session-admin.d.ts +344 -0
  31. package/lib/types/src/host/domain/transfer/recycle-bin.d.ts +336 -0
  32. package/lib/types/src/host/domain/transfer/transfer.d.ts +259 -0
  33. package/lib/types/src/host/infra/transport/agent/agent-installer.d.ts +120 -0
  34. package/lib/types/src/host/infra/transport/ssh/ssh2-transport.d.ts +327 -0
  35. package/lib/types/src/host/infra/transport/transport-base.d.ts +33 -0
  36. package/lib/types/src/host/infra/transport/transport.d.ts +541 -0
  37. package/lib/types/src/host/integration/agent/agent-preflight.d.ts +55 -0
  38. package/lib/types/src/host/integration/fs/file-reference-provider.d.ts +141 -0
  39. package/lib/types/src/host/integration/fs/fs-provider.d.ts +366 -0
  40. package/lib/types/src/host/integration/transfer/transfer-service.d.ts +211 -0
  41. package/lib/types/src/index.d.ts +88 -0
  42. package/lib/types/src/local-bridge-refs.d.ts +25 -0
  43. package/lib/types/src/local-bridge.d.ts +35 -0
  44. package/lib/types/src/shared/format.d.ts +13 -0
  45. package/lib/types/src/shared/root-keys.d.ts +120 -0
  46. package/lib/types/src/wiring/helpers.d.ts +143 -0
  47. package/package.json +122 -4
@@ -0,0 +1,246 @@
1
+ /**
2
+ * 回收站服务的**契约**:依赖注入形状、目标/结果类型、索引状态。
3
+ *
4
+ * 从 `recycle-bin-service.ts` 抽出(审计 0076)。抽出的直接原因不是"文件太长",而是
5
+ * 世界原语模块需要这些类型:留在 service 里会形成 `world → service`(type)与
6
+ * `service → world`(value)的环,架构测试会红。契约独立后方向单一。
7
+ *
8
+ * @module dsh-over-ssh/app/recycle-bin-contract
9
+ */
10
+ import type { BackendEntry, BackendStat } from '../backend.ts';
11
+ import { type RecycleBinEntry, type RecycleBinEntryId } from '../../domain/transfer/recycle-bin.ts';
12
+ import { type RootRef } from '../../domain/roots/root.ts';
13
+ export interface RecycleBinRemovalOptions {
14
+ /** Whether the target is a directory that must be removed with its content. */
15
+ readonly recursive: boolean;
16
+ /** Caller cancellation. */
17
+ readonly signal?: AbortSignal;
18
+ }
19
+ /** The mutating primitives of one execution world, shaped like the remote group. */
20
+ /**
21
+ * Serialize every read-modify-write of the bin index.
22
+ *
23
+ * ## Why this is a seam and not a mutex inside the service
24
+ *
25
+ * Two separate processes can drive the same bin: the plugin runs inside DSH, but the user may also have a
26
+ * second DSH (another port, a headless run) pointed at the same state directory. An in-process promise chain
27
+ * would serialize one process and still let the other interleave — that is exactly the hole
28
+ * `session-bin-lock.ts` was written to close for the **session** bin, and this bin has the same shape.
29
+ *
30
+ * The implementation is a cross-process file lock (see `createSessionBinLock`). Keeping it behind this seam
31
+ * means the domain service never imports `node:fs`, and tests can inject a no-op or a recording stub.
32
+ *
33
+ * @param key - the root being mutated; implementations may use it to pick a per-root lock file.
34
+ * @param operation - the critical section; must perform its own read **and** write inside it.
35
+ */
36
+ export type WithIndexLock = <T>(key: string, operation: () => Promise<T>) => Promise<T>;
37
+ export interface RecycleBinWorldIo {
38
+ /**
39
+ * Move (rename) a path.
40
+ *
41
+ * Contract: the implementation must create a missing destination parent directory (equivalent to
42
+ * `mkdir -p` + `rename`). A cross-device move that cannot be done by rename must **fail loudly**; silently
43
+ * degrading to copy-then-delete would break the "don't delete until it is in the bin" guarantee.
44
+ */
45
+ readonly move: (from: string, to: string, signal?: AbortSignal) => Promise<void>;
46
+ /** Physically delete a path. */
47
+ readonly remove: (path: string, options: RecycleBinRemovalOptions) => Promise<void>;
48
+ /** List one directory. */
49
+ readonly list: (dir: string, signal?: AbortSignal) => Promise<readonly BackendEntry[]>;
50
+ /** Read a text file (the index). */
51
+ readonly readText: (path: string, signal?: AbortSignal) => Promise<string>;
52
+ /** Write a text file as UTF-8 **without BOM** (the index). */
53
+ readonly writeText: (path: string, text: string, signal?: AbortSignal) => Promise<void>;
54
+ }
55
+ /** Everything {@link createRecycleBinService} needs; each field is injected so tests need no host. */
56
+ export interface RecycleBinServiceDeps {
57
+ /** Bin root directory for one root; a deployment setting, never hardcoded here. */
58
+ readonly binRootFor: (root: RootRef) => string;
59
+ /** Find a root by its `rootKey()`; `undefined` means the root is no longer registered. */
60
+ readonly resolveRoot: (key: string) => RootRef | undefined;
61
+ /** `stat` in the local world. */
62
+ readonly statLocal: (path: string, signal?: AbortSignal) => Promise<BackendStat>;
63
+ /** `stat` in the remote world. */
64
+ readonly statRemote: (path: string, signal?: AbortSignal) => Promise<BackendStat>;
65
+ /** Move primitive of the remote world. */
66
+ readonly moveRemote: (from: string, to: string, signal?: AbortSignal) => Promise<void>;
67
+ /** Remove primitive of the remote world. */
68
+ readonly removeRemote: (path: string, options: RecycleBinRemovalOptions) => Promise<void>;
69
+ /** List primitive of the remote world. */
70
+ readonly listRemote: (dir: string, signal?: AbortSignal) => Promise<readonly BackendEntry[]>;
71
+ /** Index read primitive of the remote world. */
72
+ readonly readTextRemote: (path: string, signal?: AbortSignal) => Promise<string>;
73
+ /** Index write primitive of the remote world (UTF-8, no BOM). */
74
+ readonly writeTextRemote: (path: string, text: string, signal?: AbortSignal) => Promise<void>;
75
+ /** Clock, injected so a test can pin "now". */
76
+ readonly now: () => number;
77
+ /** Undo depth from configuration (a non-positive value keeps no history). */
78
+ readonly undoDepth: number;
79
+ /** Roots whose bins exist, so `sweep()` can walk them all. */
80
+ readonly knownRoots: () => readonly RootRef[];
81
+ /**
82
+ * Cross-process lock around every index read-modify-write.
83
+ *
84
+ * Optional so the domain service stays testable without touching the filesystem; when absent the service
85
+ * falls back to an in-process chain, which is **weaker** (it cannot see a second DSH process) and is
86
+ * therefore only acceptable in tests. Production wiring always injects the real lock.
87
+ */
88
+ readonly withIndexLock?: WithIndexLock;
89
+ /**
90
+ * Local-world mutators.
91
+ *
92
+ * Optional: a service built for remote work omits it, and touching a local target then fails with
93
+ * `REMOTE_UNSUPPORTED` instead of misapplying the remote primitives to a local path.
94
+ */
95
+ readonly localIo?: RecycleBinWorldIo;
96
+ }
97
+ /** One deletion target: a root plus a path inside it. */
98
+ export interface RecycleBinTarget {
99
+ readonly root: RootRef;
100
+ /** Absolute path in the root's own spelling. */
101
+ readonly path: string;
102
+ }
103
+ /** A bin view scope: one root, optionally narrowed to targets deleted under one directory. */
104
+ export interface RecycleBinScope {
105
+ readonly root: RootRef;
106
+ /** Optional directory prefix, matched per path segment. */
107
+ readonly path?: string;
108
+ }
109
+ /** How {@link RecycleBinService.discard} removes a target. */
110
+ export type DiscardMode = 'permanent' | 'recycle';
111
+ /** Options for {@link RecycleBinService.discard}. */
112
+ export interface DiscardOptions {
113
+ readonly mode: DiscardMode;
114
+ readonly signal?: AbortSignal;
115
+ }
116
+ /** Result of a discard. */
117
+ export interface DiscardResult {
118
+ readonly mode: DiscardMode;
119
+ /** The bin entry in `recycle` mode; `undefined` for `permanent`. */
120
+ readonly entry: RecycleBinEntry | undefined;
121
+ /** Whether the content can still be recovered. **Always `false` for `permanent`.** */
122
+ readonly recoverable: boolean;
123
+ /** Post-operation confirmation: the original path is gone. */
124
+ readonly verified: boolean;
125
+ /** Human-facing note, mandatory for an irreversible delete. */
126
+ readonly warning: string | undefined;
127
+ }
128
+ /** What a bin actually holds for one root. */
129
+ export interface RecycleBinListing {
130
+ readonly entries: readonly RecycleBinEntry[];
131
+ /** Ids the index knows but the bin directory no longer contains. */
132
+ readonly missing: readonly RecycleBinEntryId[];
133
+ /** Directory names in the bin that no index record claims. */
134
+ readonly orphans: readonly string[];
135
+ /** Index records that were unusable and skipped. */
136
+ readonly skipped: number;
137
+ }
138
+ /** Result of a restore. */
139
+ export interface RestoreResult {
140
+ readonly entry: RecycleBinEntry;
141
+ readonly restoredPath: string;
142
+ readonly verified: boolean;
143
+ }
144
+ /** Result of a physical delete from the bin. */
145
+ export interface PurgeResult {
146
+ readonly id: RecycleBinEntryId;
147
+ readonly sizeBytes: number;
148
+ /** Always true when the call returns; a failed delete throws instead. */
149
+ readonly purged: boolean;
150
+ readonly warning: string;
151
+ }
152
+ /** Options for `list`, `restore`, `purge`, `undo` and `redo`. */
153
+ export interface OperationOptions {
154
+ readonly signal?: AbortSignal;
155
+ }
156
+ /** Result of an undo or redo step. */
157
+ export interface UndoResult {
158
+ /** The action that was executed. */
159
+ readonly action: 'discard' | 'restore';
160
+ readonly entry: RecycleBinEntry;
161
+ /** Human-facing description of what happened. */
162
+ readonly message: string;
163
+ }
164
+ /** Retention limits for one sweep; all optional. */
165
+ export interface SweepOptions {
166
+ /** Keep at most this many entries per bin; non-positive/absent = unlimited. */
167
+ readonly maxEntries?: number;
168
+ /** Keep at most this many bytes per bin; non-positive/absent = unlimited. */
169
+ readonly maxBytes?: number;
170
+ /** Keep entries at most this old; non-positive/absent = unlimited. */
171
+ readonly maxAgeMs?: number;
172
+ readonly signal?: AbortSignal;
173
+ }
174
+ /** One entry physically removed by a sweep. */
175
+ export interface SweepPurge {
176
+ readonly rootKey: string;
177
+ readonly id: RecycleBinEntryId;
178
+ readonly sizeBytes: number;
179
+ readonly dimension: 'age' | 'count' | 'bytes';
180
+ }
181
+ /** One entry a sweep could not delete; the sweep continues with the rest. */
182
+ export interface SweepFailure {
183
+ readonly rootKey: string;
184
+ readonly id?: RecycleBinEntryId;
185
+ readonly message: string;
186
+ }
187
+ /** Per-root summary of a sweep. */
188
+ export interface SweepRootReport {
189
+ readonly rootKey: string;
190
+ readonly considered: number;
191
+ readonly purged: readonly RecycleBinEntryId[];
192
+ readonly kept: readonly RecycleBinEntryId[];
193
+ readonly reasons: readonly {
194
+ readonly id: RecycleBinEntryId;
195
+ readonly dimension: 'age' | 'count' | 'bytes';
196
+ }[];
197
+ }
198
+ /** Outcome of a sweep. */
199
+ export interface SweepReport {
200
+ readonly purged: readonly SweepPurge[];
201
+ readonly roots: readonly SweepRootReport[];
202
+ readonly failures: readonly SweepFailure[];
203
+ /** `false` when no dimension was limited: no retention decision was made (an unconfigured 0 must not empty a bin). */
204
+ readonly policyApplied: boolean;
205
+ /**
206
+ * Empty entry directories removed because no index record claimed them.
207
+ *
208
+ * Reported separately from `purged` because these are garbage, not retention: they are pruned even when no
209
+ * limit is configured, and they were never recoverable (audit 0098).
210
+ */
211
+ readonly prunedOrphans: number;
212
+ }
213
+ /** Read-only undo state, for a UI that needs to enable or disable the buttons. */
214
+ export interface RecycleBinHistory {
215
+ readonly canUndo: boolean;
216
+ readonly canRedo: boolean;
217
+ readonly undo: readonly {
218
+ readonly kind: string;
219
+ readonly id: string;
220
+ }[];
221
+ readonly redo: readonly {
222
+ readonly kind: string;
223
+ readonly id: string;
224
+ }[];
225
+ readonly dropped: number;
226
+ readonly undoDepth: number;
227
+ }
228
+ /** The service surface. */
229
+ export interface RecycleBinService {
230
+ /** Delete a target permanently or into the recycle bin. */
231
+ discard(target: RecycleBinTarget, options: DiscardOptions): Promise<DiscardResult>;
232
+ /** List one bin, reconciled against the directory that actually exists. */
233
+ list(scope: RecycleBinScope, options?: OperationOptions): Promise<RecycleBinListing>;
234
+ /** Restore one entry to its original path. */
235
+ restore(id: RecycleBinEntryId, options?: OperationOptions): Promise<RestoreResult>;
236
+ /** Physically delete one entry from the bin. */
237
+ purge(id: RecycleBinEntryId, options?: OperationOptions): Promise<PurgeResult>;
238
+ /** Apply retention limits to every known bin. */
239
+ sweep(options: SweepOptions): Promise<SweepReport>;
240
+ /** Undo the newest operation. */
241
+ undo(options?: OperationOptions): Promise<UndoResult>;
242
+ /** Re-apply the newest undone operation. */
243
+ redo(options?: OperationOptions): Promise<UndoResult>;
244
+ /** Current undo/redo state. */
245
+ history(): RecycleBinHistory;
246
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The recycle-bin service: the domain's pure decisions wired to injected I/O primitives.
3
+ *
4
+ * The seam is deliberate (AGENTS.md 红线 3): every path that touches a real filesystem or a real SSH
5
+ * connection arrives as a function, so this module is testable offline and a test can inject a move that fails.
6
+ *
7
+ * Two contracts matter more than the rest:
8
+ *
9
+ * 1. **Move first, then confirm.** In `recycle` mode a success is reported only after `stat` shows the entry
10
+ * inside the bin **and** the original path gone. A move that throws is confirmed before anything is
11
+ * claimed: if the move actually landed (a lost response) it counts as success, otherwise the call fails
12
+ * and nothing is recorded — "deleted but not in the bin" must never be reported as success.
13
+ * 2. **The index write is never swallowed.** If the index cannot be written after a successful move, the move
14
+ * is rolled back and the failure is reported; a physical delete has no rollback, so that case is reported
15
+ * with `purged: true, indexStale: true` instead of being hidden.
16
+ *
17
+ * Other decisions worth knowing:
18
+ *
19
+ * - A `permanent` discard is irreversible by construction and says so in its result (`recoverable: false`),
20
+ * and its undo record is refused as `irreversible`.
21
+ * - Restoring onto an occupied original path is **refused** (`TRANSFER_CONFLICT`, with the informational
22
+ * `suggestedPath` in `details`); this module never silently restores to a different name.
23
+ * - `*Remote` primitives are the **remote world's** mutators. A local root additionally needs `localIo`; when it
24
+ * is missing the call fails with `REMOTE_UNSUPPORTED` rather than reusing remote primitives on local paths.
25
+ * - Every destructive call takes an `AbortSignal`, propagated to every injected primitive.
26
+ * - Error codes are reused from the existing table (no new code is registered): `TRASH_NOT_FOUND` (missing
27
+ * entry, unreadable index), `REMOTE_NOT_FOUND` (target gone), `TRANSFER_CONFLICT` (occupied restore path),
28
+ * `ROOT_OUTSIDE` (symlink target refused), `ROOT_AMBIGUOUS` (one id in two bins), `REMOTE_UNSUPPORTED`
29
+ * (local root without local primitives), `REMOTE_COMMAND_FAILED` (an operation that could not be confirmed),
30
+ * `ROOT_INVALID` (unusable configuration/input), `CANCELLED` (aborted signal).
31
+ *
32
+ * @module dsh-over-ssh/integration/recycle-bin-service
33
+ */
34
+ import type { RecycleBinServiceDeps, RecycleBinService } from './recycle-bin-contract.ts';
35
+ export * from './recycle-bin-contract.ts';
36
+ /** Removal options for one physical delete. */
37
+ export * from './recycle-bin-contract.ts';
38
+ /**
39
+ * Build the service.
40
+ *
41
+ * @param deps - injected I/O, clock, configuration and root lookup.
42
+ * @returns a {@link RecycleBinService}; its undo stack is per-instance, in memory and not persisted.
43
+ */
44
+ export declare function createRecycleBinService(deps: RecycleBinServiceDeps): RecycleBinService;
@@ -0,0 +1,232 @@
1
+ /**
2
+ * 会话管理的**契约**:依赖注入形状、会话头、删除/恢复/清理结果与服务接口。
3
+ *
4
+ * 从 `session-admin-service.ts` 抽出(审计 0076):上下文模块需要这些类型,留在 service 里会形成环。
5
+ *
6
+ * @module dsh-over-ssh/app/session-admin-contract
7
+ */
8
+ import { type DeleteDecision, type SessionRecord, type SessionTrashEntry, type SessionTrashEntryId } from '../../domain/session/session-admin.ts';
9
+ export interface SessionDirEntry {
10
+ readonly name: string;
11
+ readonly isDirectory: boolean;
12
+ readonly bytes: number;
13
+ readonly mtimeMs: number;
14
+ }
15
+ /**
16
+ * Everything the service needs from the outside world.
17
+ *
18
+ * `readHeader` is deliberately the **only** way to learn about a session: the directory name cannot be decoded
19
+ * (see ADR-0009), so classification must come from the header.
20
+ */
21
+ export interface SessionAdminDeps {
22
+ /** The platform's sessions root (e.g. `~/.dsh/sessions`). */
23
+ readonly sessionsRoot: string;
24
+ /** The plugin's own bin root; must **not** be inside {@link sessionsRoot}. */
25
+ readonly binRoot: string;
26
+ /** List one directory's entries; a missing directory yields an empty list, not a failure. */
27
+ readonly listDir: (path: string) => Promise<readonly SessionDirEntry[]>;
28
+ /** Read a session header. `undefined` means the log is unreadable (corrupt/unsupported). */
29
+ readonly readHeader: (sessionDir: string) => Promise<SessionHeader | undefined>;
30
+ /**
31
+ * Display title of the workspace that owns a session directory, or `undefined` when none does.
32
+ *
33
+ * Injected because the mapping lives in the platform's workspace registry, which the domain layer must not
34
+ * import (see the repository's red lines). A listing without this resolver still answers: every row keeps
35
+ * its `cwd`, so a reader falls back to the directory's own leaf name.
36
+ */
37
+ readonly resolveWorkspaceTitle?: ((cwd: string | undefined) => string | undefined) | undefined;
38
+ /**
39
+ * The session's generated title, or `undefined` when it has none.
40
+ *
41
+ * The header carries no title (it is a projection, not header metadata), so this is the only way a listing
42
+ * can label a session by name. Injected for the same reason as {@link resolveWorkspaceTitle}; without it
43
+ * every row falls back to its id.
44
+ */
45
+ readonly resolveSessionTitle?: ((header: SessionHeader) => string | undefined) | undefined;
46
+ /** `stat`; `undefined` when the path does not exist. */
47
+ readonly stat: (path: string) => Promise<{
48
+ readonly isDirectory: boolean;
49
+ readonly bytes: number;
50
+ } | undefined>;
51
+ /** Read a text file. */
52
+ readonly readText: (path: string) => Promise<string>;
53
+ /** Write a text file (UTF-8, no BOM). */
54
+ readonly writeText: (path: string, text: string) => Promise<void>;
55
+ /**
56
+ * Replace a text file **atomically** (write a temp file, then rename over the target).
57
+ *
58
+ * Optional, and worth supplying: without it a process killed mid-write leaves a truncated JSON index,
59
+ * which the parser then rejects as a hard failure. Atomic replace makes the index either the old or the
60
+ * new document, never a fragment.
61
+ */
62
+ readonly writeTextAtomic?: (path: string, text: string) => Promise<void>;
63
+ /**
64
+ * Run `operation` while holding a **cross-process** lock for `key`.
65
+ *
66
+ * The in-process chain cannot see another DSH process; without this, two processes deleting at the same
67
+ * time can each read the old index and overwrite the other's entry — leaving bytes in the bin that no
68
+ * index mentions (invisible and unrestorable).
69
+ *
70
+ * A caller-supplied implementation must be **exclusive** and must not deadlock when the previous holder
71
+ * died (see `createSessionBinLock`, which reclaims a stale lock by age).
72
+ */
73
+ readonly withLock?: <T>(key: string, operation: () => Promise<T>) => Promise<T>;
74
+ /**
75
+ * Move a directory. Must be atomic on one filesystem; the caller supplies the cross-device fallback.
76
+ *
77
+ * Returning `'copy'` means the implementation copied (cross-device) and the source is already gone.
78
+ */
79
+ readonly moveDir: (from: string, to: string) => Promise<'rename' | 'copy'>;
80
+ /** Recursively remove a directory (used for purge and for rolling back a failed record). */
81
+ readonly removeDir: (path: string) => Promise<void>;
82
+ /** Copy a directory recursively (the cross-device fallback, when the deps implement it themselves). */
83
+ readonly copyDir?: (from: string, to: string) => Promise<void>;
84
+ /** Whether the host currently holds this session in its store (it was opened in this process). */
85
+ readonly isLive: (sessionId: string) => boolean;
86
+ /**
87
+ * Whether this session's agent is running a turn right now.
88
+ *
89
+ * The platform's own "运行中" signal (`agents.get(id)?.status === 'running'`, the same one the session
90
+ * list uses for its status dot). This — not {@link isLive} — is what a deletion refuses (FR-3.6).
91
+ */
92
+ readonly isRunning: (sessionId: string) => boolean;
93
+ /** Remove a session from workspace bookkeeping (`detachSession`); optional where unavailable. */
94
+ readonly detachSession?: (sessionId: string) => Promise<void>;
95
+ /** Announce the removal so clients drop the row (`api-session/removed`). */
96
+ readonly emitRemoved?: (sessionId: string) => void;
97
+ /**
98
+ * The session is gone: drop it from any bookkeeping that remembers session ids.
99
+ *
100
+ * Fired **after** the deletion is durable, on both the permanent and the recycle path, and it is not awaited:
101
+ * losing a stale id in `user-roots.json` must not turn a completed deletion into a failure. The implementation
102
+ * is expected to be best-effort and to log its own warnings.
103
+ *
104
+ * Audit 0112 P3-13: before this, nothing told `user-roots.json` that a session had been deleted, so every root
105
+ * a session had added kept its dead id forever — the panel went on showing the root as "added by a session
106
+ * that no longer exists" and the file grew on every write.
107
+ */
108
+ readonly onSessionRemoved?: (sessionId: string) => void;
109
+ /** Injected clock. */
110
+ readonly now: () => number;
111
+ /** Who confirmed, for the audit trail (FR-3.10); absent when the caller did not say. */
112
+ readonly confirmedBy?: string;
113
+ }
114
+ /** The parts of a session header this feature needs. */
115
+ export interface SessionHeader {
116
+ readonly id: string;
117
+ /** `cwd` from the header; `undefined` for the platform's no-cwd bucket. */
118
+ readonly cwd: string | undefined;
119
+ readonly title: string | undefined;
120
+ readonly parentSessionId: string | undefined;
121
+ /**
122
+ * Session format generation, as stored in the log's first line.
123
+ *
124
+ * Together with {@link createdAt} and {@link isSeeded} this is the **lifecycle identity** a header alone
125
+ * witnesses, and it is what the platform's projection cache validates a stored title row against. A
126
+ * resolver that skipped it could serve a title from a different lifecycle that reused the same id.
127
+ */
128
+ readonly version?: number | undefined;
129
+ readonly createdAt?: number | undefined;
130
+ readonly isSeeded?: boolean | undefined;
131
+ }
132
+ /** Options shared by the destructive calls. */
133
+ export interface SessionAdminOptions {
134
+ readonly signal?: AbortSignal;
135
+ }
136
+ /** Result of a session deletion. */
137
+ export interface DeleteSessionResult {
138
+ readonly mode: 'recycle' | 'permanent';
139
+ /** The bin entry in `recycle` mode; `undefined` for `permanent`. */
140
+ readonly entry: SessionTrashEntry | undefined;
141
+ /** Whether the content can still be recovered. **Always `false` for `permanent`.** */
142
+ readonly recoverable: boolean;
143
+ /** True when the move was observed to have landed (store gone + bin present). */
144
+ readonly verified: boolean;
145
+ /** True when the bytes crossed a filesystem boundary and were copied. */
146
+ readonly copied: boolean;
147
+ /** Ids that were refused, with the reason (a partial deletion is reported, never hidden). */
148
+ readonly refusals: DeleteDecision['refusals'];
149
+ readonly bytes: number;
150
+ }
151
+ /** Result of a restore. */
152
+ export interface RestoreSessionResult {
153
+ readonly entry: SessionTrashEntry;
154
+ /** Absolute path the session directory was moved back to. */
155
+ readonly restoredPath: string;
156
+ readonly movedBy: 'rename' | 'copy';
157
+ }
158
+ /** One purge outcome. */
159
+ export interface PurgeSessionResult {
160
+ readonly id: SessionTrashEntryId;
161
+ readonly sessionId: string;
162
+ readonly purged: boolean;
163
+ /** True when the entry was removed physically but the index could not be updated afterwards. */
164
+ readonly indexStale: boolean;
165
+ }
166
+ /** A retention sweep outcome for the session bin. */
167
+ export interface SweepSessionResult {
168
+ readonly purged: readonly PurgeSessionResult[];
169
+ readonly failed: readonly {
170
+ readonly id: SessionTrashEntryId;
171
+ readonly reason: string;
172
+ }[];
173
+ readonly remainingEntries: number;
174
+ readonly remainingBytes: number;
175
+ }
176
+ /** The session-bin retention policy (same three dimensions as the file-level bin). */
177
+ export interface SessionBinPolicy {
178
+ readonly maxEntries: number;
179
+ readonly maxBytes: number;
180
+ readonly maxAgeMs: number;
181
+ }
182
+ export declare function joinPath(base: string, ...segments: readonly string[]): string;
183
+ /** The service surface. */
184
+ export interface SessionAdminService {
185
+ /** Inventory every session in the store (FR-3.3). */
186
+ inventory(options?: SessionAdminOptions): Promise<readonly SessionRecord[]>;
187
+ /** Preview a deletion without touching anything (FR-3.7). */
188
+ previewDelete(ids: readonly string[], options?: SessionAdminOptions & {
189
+ readonly keep?: readonly string[];
190
+ }): Promise<DeleteDecision>;
191
+ /** Delete sessions: into the bin (`recycle`) or irreversibly (`permanent`). */
192
+ deleteSession(ids: readonly string[], options?: SessionAdminOptions & {
193
+ readonly mode?: 'recycle' | 'permanent';
194
+ readonly keep?: readonly string[];
195
+ }): Promise<DeleteSessionResult>;
196
+ /**
197
+ * Delete every session that belongs to one workspace directory, and nothing else.
198
+ *
199
+ * The workspace itself is **left registered**: the user asked to clear the workspace's sessions, not to
200
+ * unregister the workspace (and the platform's `workspaceRegistry.delete` would also be a different,
201
+ * separately-confirmed action). Sessions are matched by their header's `cwd` against the given directory,
202
+ * folded the same way the label resolver folds paths, so a Windows case/separator difference still matches.
203
+ *
204
+ * Live sessions are refused by the same rules as {@link deleteSession} (they appear in `refusals`), so a
205
+ * workspace containing a running session is deleted **except** for that session rather than failing whole.
206
+ *
207
+ * @param cwd - the workspace directory whose sessions should go.
208
+ * @param options - deletion mode plus cancellation.
209
+ * @returns the ids deleted, the per-id refusals, and the bytes freed.
210
+ */
211
+ deleteWorkspaceSessions(cwd: string, options?: SessionAdminOptions & {
212
+ readonly mode?: 'recycle' | 'permanent';
213
+ }): Promise<DeleteSessionResult & {
214
+ readonly matched: number;
215
+ }>;
216
+ /** List the bin. */
217
+ list(options?: SessionAdminOptions): Promise<readonly SessionTrashEntry[]>;
218
+ /** Move one entry back to where it came from. */
219
+ restore(entryId: string, options?: SessionAdminOptions): Promise<RestoreSessionResult>;
220
+ /** Delete one entry physically (the irreversible step, after the retention period). */
221
+ purge(entryId: string, options?: SessionAdminOptions): Promise<PurgeSessionResult>;
222
+ /** Apply the retention policy. */
223
+ sweep(policy: SessionBinPolicy, options?: SessionAdminOptions): Promise<SweepSessionResult>;
224
+ }
225
+ /**
226
+ * Build the session-administration service.
227
+ *
228
+ * @param deps - injected I/O.
229
+ * @returns the service.
230
+ * @throws SshDevError `ROOT_INVALID` when the bin root is inside the sessions root (that placement breaks the
231
+ * platform's session listing — see the module note and ADR-0009).
232
+ */
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Session administration service: inventory, deletion into the session bin, restore, purge, retention.
3
+ *
4
+ * The domain decisions live in [`domain/session-admin.ts`](../domain/session-admin.ts); this module wires them to
5
+ * injected I/O. Every filesystem and platform call arrives as a function (AGENTS.md 红线 3), so this module is
6
+ * testable offline and a test can make a move fail.
7
+ *
8
+ * ## Three contracts matter more than the rest
9
+ *
10
+ * 1. **Move first, confirm, then record.** `deleteSession` reports success only after the session directory is
11
+ * observed inside the bin **and** gone from the store. A move that throws is confirmed before anything is
12
+ * claimed: if it actually landed (a lost response), that counts as success; otherwise the call fails and
13
+ * **nothing** is recorded. "Deleted but not in the bin" must never be reported as success.
14
+ * 2. **The index write is never swallowed.** If the index cannot be written after a successful move, the move is
15
+ * rolled back and the failure is reported. A physical purge has no rollback, so that case is reported with
16
+ * `purged: true, indexStale: true` instead of being hidden.
17
+ * 3. **Running sessions are never touched.** The decision layer refuses them; this layer re-checks before
18
+ * moving, so a session that started running between preview and confirm is refused rather than killed.
19
+ * "Running" is the platform's `agents.get(id)?.status === 'running'`, not "loaded in the host": the latter
20
+ * is true for every session opened since the process started and would make almost nothing deletable.
21
+ *
22
+ * ## Where the bin lives (and why not under `sessions/`)
23
+ *
24
+ * The platform enumerates **every** directory under the sessions root (`listProjectDirs` has no dot-directory
25
+ * filter) and fails the whole listing when one session id appears twice. A bin inside `sessions/` that kept the
26
+ * original log filename would therefore make deleted sessions reappear *and* break the session list. The bin
27
+ * lives in the plugin's own storage instead — see ADR-0009.
28
+ *
29
+ * @module dsh-over-ssh/integration/session-admin-service
30
+ */
31
+ import type { SessionAdminDeps, SessionAdminService } from './session-admin-contract.ts';
32
+ export * from './session-admin-contract.ts';
33
+ /**
34
+ * Build the service.
35
+ *
36
+ * @param deps - injected platform services.
37
+ * @returns the {@link SessionAdminService}.
38
+ */
39
+ export declare function createSessionAdminService(deps: SessionAdminDeps): SessionAdminService;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Transport selection: which implementation serves a connection, and why.
3
+ *
4
+ * The decision is **probe-driven**, never platform-guessed (ADR-0006): a mux failure measured on
5
+ * this machine is remembered, and a later decision for the same connection changes accordingly.
6
+ * Everything observable about the decision is returned as a structured record so the connection
7
+ * UI can show *why* a connection is slower instead of hiding it.
8
+ *
9
+ * @module dsh-over-ssh/app/transport-selector
10
+ */
11
+ import type { ConnectionProfile, TransportKind } from '../../domain/roots/connection.ts';
12
+ import type { MultiplexingCache } from '../../infra/transport/transport.ts';
13
+ /** Stable reasons a transport was chosen. */
14
+ export type TransportReason = 'explicit-ssh2' | 'explicit-alias' | 'explicit-agent' | 'agent-unavailable' | 'password-requires-ssh2' | 'no-multiplexing' | 'ssh2-unavailable' | 'alias-required' | 'default-alias';
15
+ /** A non-fatal loss of quality the user should know about. */
16
+ export interface TransportDegradation {
17
+ /** Stable code for the UI to branch on. */
18
+ readonly code: 'no-connection-reuse' | 'ssh2-unavailable' | 'alias-unavailable' | 'password-needs-key' | 'agent-unavailable';
19
+ /** Actionable, user-facing sentence. */
20
+ readonly hint: string;
21
+ }
22
+ /** The outcome of one selection. */
23
+ export interface TransportDecision {
24
+ readonly connectionId: string;
25
+ readonly kind: TransportKind;
26
+ readonly reason: TransportReason;
27
+ /** One sentence for the UI/status badge. */
28
+ readonly hint: string;
29
+ readonly degradations: readonly TransportDegradation[];
30
+ /** Whether commands can share one connection (`false` on Windows without ssh2, for example). */
31
+ readonly reusesConnection: boolean;
32
+ }
33
+ /** Whether the optional Tier 2 agent can serve this connection right now. */
34
+ export interface AgentAvailability {
35
+ /**
36
+ * `false` only when a previous ladder run already failed (the reason is then required).
37
+ *
38
+ * `true` and `undefined` both mean "the selector cannot know yet": an explicit agent choice is honoured
39
+ * optimistically and the async preflight downgrades it after running the ladder.
40
+ */
41
+ readonly available: boolean;
42
+ /** Why it is unavailable (the ladder's reason), for the UI. */
43
+ readonly reason?: string;
44
+ /** One sentence for the status badge. */
45
+ readonly hint?: string;
46
+ }
47
+ /** Facts the selector needs. All of them are injected so this module stays testable offline. */
48
+ export interface TransportSelectionInput {
49
+ readonly profile: ConnectionProfile;
50
+ /** Whether the `ssh2` dependency is loadable in this build. */
51
+ readonly ssh2Available: boolean;
52
+ /** Remembers hosts whose `ssh` client cannot multiplex. */
53
+ readonly multiplexing: Pick<MultiplexingCache, 'isUnsupported' | 'reasonFor'>;
54
+ /** `ssh` executable name, for the multiplexing cache key. */
55
+ readonly sshBinary?: string;
56
+ /** Tier 2 availability. Absent means "not considered", which keeps the agent strictly opt-in. */
57
+ readonly agent?: AgentAvailability;
58
+ }
59
+ /**
60
+ * Choose a transport for one connection.
61
+ *
62
+ * Order of concerns: an explicit user choice wins unless it cannot work at all (password auth on
63
+ * the system client, a missing dependency); only then is another implementation substituted, and
64
+ * the substitution is always reported as a degradation.
65
+ *
66
+ * @param input - profile plus injected environment facts.
67
+ * @returns the decision, including every degradation.
68
+ */
69
+ export declare function selectTransport(input: TransportSelectionInput): TransportDecision;
70
+ /** Records observed facts so later decisions reflect reality. */
71
+ export declare class TransportSelector {
72
+ #private;
73
+ constructor(options: {
74
+ readonly multiplexing: Pick<MultiplexingCache, 'isUnsupported' | 'reasonFor'> & Partial<Pick<MultiplexingCache, 'markUnsupported'>>;
75
+ readonly ssh2Available: boolean;
76
+ readonly sshBinary?: string;
77
+ });
78
+ /** Whether the `ssh2` dependency is currently loadable. */
79
+ get ssh2Available(): boolean;
80
+ /** Late-bound availability (the dependency is loaded lazily). */
81
+ setSsh2Available(available: boolean): void;
82
+ /**
83
+ * Decide for one profile.
84
+ *
85
+ * @param profile - the saved connection.
86
+ * @returns the decision.
87
+ * @throws SshDevError `CONNECTION_INVALID` when no implementation can serve the connection.
88
+ */
89
+ decide(profile: ConnectionProfile): TransportDecision;
90
+ /**
91
+ * Record that this connection's `ssh` client could not multiplex.
92
+ *
93
+ * Called by the integration layer right after an {@link AliasTransport} connect attempt, so the
94
+ * next decision (and every other connection sharing the same client+alias) picks ssh2.
95
+ */
96
+ noteMultiplexingFailure(profile: ConnectionProfile, reason: string): void;
97
+ }