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.
- package/LICENSE +21 -0
- package/README.md +119 -2
- package/agents/agent-manifest.json +20 -0
- package/agents/linux-amd64/dsh-over-ssh-agent +0 -0
- package/agents/linux-arm64/dsh-over-ssh-agent +0 -0
- package/cordis.patch.yml +84 -0
- package/lib/client.js +6660 -0
- package/lib/file-references.js +411 -0
- package/lib/fs.js +1451 -0
- package/lib/index.js +19740 -0
- package/lib/local-bridge-refs.js +16 -0
- package/lib/local-bridge.js +16 -0
- package/lib/terminal-wrapper.js +299 -0
- package/lib/types/src/client/index.d.ts +49 -0
- package/lib/types/src/config.d.ts +383 -0
- package/lib/types/src/file-references.d.ts +24 -0
- package/lib/types/src/host/app/backend.d.ts +178 -0
- package/lib/types/src/host/app/recycle-bin/recycle-bin-contract.d.ts +246 -0
- package/lib/types/src/host/app/recycle-bin/recycle-bin-service.d.ts +44 -0
- package/lib/types/src/host/app/session-admin/session-admin-contract.d.ts +232 -0
- package/lib/types/src/host/app/session-admin/session-admin-service.d.ts +39 -0
- package/lib/types/src/host/app/transport/transport-selector.d.ts +97 -0
- package/lib/types/src/host/app/transport/transport-service.d.ts +81 -0
- package/lib/types/src/host/domain/capability.d.ts +77 -0
- package/lib/types/src/host/domain/errors.d.ts +54 -0
- package/lib/types/src/host/domain/roots/connection.d.ts +105 -0
- package/lib/types/src/host/domain/roots/root.d.ts +123 -0
- package/lib/types/src/host/domain/roots/session-roots.d.ts +150 -0
- package/lib/types/src/host/domain/roots/ssh-config.d.ts +72 -0
- package/lib/types/src/host/domain/session/session-admin.d.ts +344 -0
- package/lib/types/src/host/domain/transfer/recycle-bin.d.ts +336 -0
- package/lib/types/src/host/domain/transfer/transfer.d.ts +259 -0
- package/lib/types/src/host/infra/transport/agent/agent-installer.d.ts +120 -0
- package/lib/types/src/host/infra/transport/ssh/ssh2-transport.d.ts +327 -0
- package/lib/types/src/host/infra/transport/transport-base.d.ts +33 -0
- package/lib/types/src/host/infra/transport/transport.d.ts +541 -0
- package/lib/types/src/host/integration/agent/agent-preflight.d.ts +55 -0
- package/lib/types/src/host/integration/fs/file-reference-provider.d.ts +141 -0
- package/lib/types/src/host/integration/fs/fs-provider.d.ts +366 -0
- package/lib/types/src/host/integration/transfer/transfer-service.d.ts +211 -0
- package/lib/types/src/index.d.ts +88 -0
- package/lib/types/src/local-bridge-refs.d.ts +25 -0
- package/lib/types/src/local-bridge.d.ts +35 -0
- package/lib/types/src/shared/format.d.ts +13 -0
- package/lib/types/src/shared/root-keys.d.ts +120 -0
- package/lib/types/src/wiring/helpers.d.ts +143 -0
- package/package.json +122 -4
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session administration: inventory, hierarchy classification, delete plans and restore plans — pure logic only.
|
|
3
|
+
*
|
|
4
|
+
* Requirement FR-3.3–3.10 asks for a *session* recycle bin, which is a different thing from the existing
|
|
5
|
+
* *file-level* recycle bin in [`recycle-bin.ts`](./recycle-bin.ts) (see ADR-0009): that one moves one path
|
|
6
|
+
* inside a root, this one moves a whole session directory out of the platform's session store.
|
|
7
|
+
*
|
|
8
|
+
* ## The two facts everything here is built on
|
|
9
|
+
*
|
|
10
|
+
* 1. **`projectKey` is lossy.** The platform folds separators and truncates at 251 chars, so a directory
|
|
11
|
+
* name **cannot** be decoded back into a `cwd`. Every classification therefore reads the session
|
|
12
|
+
* **header**, never the directory name. (The encoder is reproduced in {@link projectKey} and was checked
|
|
13
|
+
* against the real store 6/6 — see ADR-0009.)
|
|
14
|
+
* 2. **A bin entry has two directory names.** `segment` is where it lives *inside the bin*; `originalSegment` A top-level session id carries a `session-` prefix while a
|
|
15
|
+
* sub-session id is a bare uuid, but the requirement is explicit that this must **not** be used to infer
|
|
16
|
+
* hierarchy: `origin`/`parentSession` from the header is the only authority.
|
|
17
|
+
*
|
|
18
|
+
* Everything in this module is a pure function over already-read data: no filesystem, no clock, no I/O.
|
|
19
|
+
*
|
|
20
|
+
* @module dsh-over-ssh/domain/session-admin
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* Reproduce the platform's `projectKey(cwd)` (source: `dsh-session-persistence-jsonl` `projectKey`).
|
|
24
|
+
*
|
|
25
|
+
* Rules: runs of `/`, `\` and `:` collapse to one `-`; `[A-Za-z0-9._-]` (except `~`) stay; everything else
|
|
26
|
+
* becomes `~XXXX` (4 uppercase hex digits of the UTF-16 code unit); leading `-` are dropped; empty becomes
|
|
27
|
+
* `root`; the body is truncated to 251 chars; the result is wrapped in `--…--`.
|
|
28
|
+
*
|
|
29
|
+
* **Lossy on purpose** (the platform says so too): two different `cwd` values can share one key. This
|
|
30
|
+
* function exists so the plugin can *compute* the directory a session lives in, never to decode one.
|
|
31
|
+
*
|
|
32
|
+
* @param cwd - the session's project directory (an absolute path).
|
|
33
|
+
* @returns the project directory name.
|
|
34
|
+
* @throws SshDevError `ROOT_INVALID` for an empty path (the platform refuses it too).
|
|
35
|
+
*/
|
|
36
|
+
export declare function projectKey(cwd: string): string;
|
|
37
|
+
/**
|
|
38
|
+
* Reproduce the platform's `encodeSegment(id)` (source: `dsh-session-persistence-jsonl`).
|
|
39
|
+
*
|
|
40
|
+
* Unlike {@link projectKey} this one is **injective**: `.` becomes `~002E` (so `.`/`..` cannot escape the
|
|
41
|
+
* directory), safe characters pass through, everything else becomes `~XXXX`. A `session-` prefix is
|
|
42
|
+
* therefore preserved verbatim — which is exactly why hierarchy must not be read from it.
|
|
43
|
+
*
|
|
44
|
+
* @param id - the session id.
|
|
45
|
+
* @returns the directory name.
|
|
46
|
+
*/
|
|
47
|
+
export declare function encodeSegment(id: string): string;
|
|
48
|
+
/**
|
|
49
|
+
* The directory one session lives in, relative to the sessions root.
|
|
50
|
+
*
|
|
51
|
+
* `cwd === undefined` selects the platform's `_no-cwd` bucket (the platform's own convention).
|
|
52
|
+
*
|
|
53
|
+
* @param cwd - the session's project directory, or `undefined` for the no-cwd bucket.
|
|
54
|
+
* @param id - the session id.
|
|
55
|
+
* @returns the path segments, root-relative.
|
|
56
|
+
*/
|
|
57
|
+
export declare function sessionSegments(cwd: string | undefined, id: string): readonly string[];
|
|
58
|
+
/**
|
|
59
|
+
* Classify one inventory record.
|
|
60
|
+
*
|
|
61
|
+
* The only authority is the header: `parentSessionId` present ⇒ sub-session. A record whose header could not
|
|
62
|
+
* be read is `unreadable`. The directory name is deliberately **not** consulted — `session-`-prefixed ids are
|
|
63
|
+
* top-level sessions, but the requirement forbids inferring hierarchy from that, and a future id scheme would
|
|
64
|
+
* silently break such an inference.
|
|
65
|
+
*
|
|
66
|
+
* @param record - a record without its `classification` field.
|
|
67
|
+
* @returns the class.
|
|
68
|
+
*/
|
|
69
|
+
export declare function classifySession(record: Omit<SessionRecord, 'classification'> & {
|
|
70
|
+
readonly readable?: boolean;
|
|
71
|
+
}): SessionClass;
|
|
72
|
+
/** How one session relates to the rest of the store. */
|
|
73
|
+
export type SessionClass =
|
|
74
|
+
/** A normal, independently-visible session. */
|
|
75
|
+
'normal'
|
|
76
|
+
/** A sub-session: its lifecycle follows its parent, so it must not be deleted on its own. */
|
|
77
|
+
| 'subsession'
|
|
78
|
+
/** A directory whose header could not be read (corrupt or unsupported format). */
|
|
79
|
+
| 'unreadable';
|
|
80
|
+
/** One session as the inventory sees it. */
|
|
81
|
+
export interface SessionRecord {
|
|
82
|
+
readonly id: string;
|
|
83
|
+
/** Project directory the log was found under, as spelled on disk. */
|
|
84
|
+
readonly projectDir: string;
|
|
85
|
+
/** Directory name (`encodeSegment(id)`) the log was found under. */
|
|
86
|
+
readonly segment: string;
|
|
87
|
+
/** `cwd` from the header; `undefined` for the no-cwd bucket. */
|
|
88
|
+
readonly cwd: string | undefined;
|
|
89
|
+
/** The session's own title (the platform's `title` projection), when one was ever generated. */
|
|
90
|
+
readonly title: string | undefined;
|
|
91
|
+
/**
|
|
92
|
+
* Display title of the workspace owning {@link cwd}, resolved from the platform's workspace registry.
|
|
93
|
+
*
|
|
94
|
+
* `undefined` when the caller supplied no resolver or no registered workspace owns the directory. The
|
|
95
|
+
* directory itself is still on the wire (`cwd`), so a reader falls back to its own leaf name.
|
|
96
|
+
*/
|
|
97
|
+
readonly workspaceTitle: string | undefined;
|
|
98
|
+
/** Parent session id from the header, when this is a sub-session. */
|
|
99
|
+
readonly parentSessionId: string | undefined;
|
|
100
|
+
/** Total bytes of the log files in the session directory (0 when unknown). */
|
|
101
|
+
readonly bytes: number;
|
|
102
|
+
/** Modification time of the newest log file, in ms since the epoch. */
|
|
103
|
+
readonly updatedAtMs: number;
|
|
104
|
+
/**
|
|
105
|
+
* True when the host holds this session in its store — it was opened in this DSH process.
|
|
106
|
+
*
|
|
107
|
+
* Loaded is **not** running: the platform keeps an agent for every session opened since the process
|
|
108
|
+
* started, and an idle agent is doing nothing. The panel renders this as 已打开, and an idle loaded
|
|
109
|
+
* session stays deletable.
|
|
110
|
+
*/
|
|
111
|
+
readonly live: boolean;
|
|
112
|
+
/**
|
|
113
|
+
* True when this session's agent is running a turn (`agents.get(id)?.status === 'running'`).
|
|
114
|
+
*
|
|
115
|
+
* This is the platform's own "运行中" signal — the one the session list uses for a row's status — and the
|
|
116
|
+
* only state that must refuse deletion (FR-3.6).
|
|
117
|
+
*/
|
|
118
|
+
readonly running: boolean;
|
|
119
|
+
/** How this session relates to the store (see {@link classifySession}). */
|
|
120
|
+
readonly classification: SessionClass;
|
|
121
|
+
}
|
|
122
|
+
/** Why one session may not be deleted. */
|
|
123
|
+
export type DeleteRefusal =
|
|
124
|
+
/** Its agent is running a turn: the platform has no public way to unload one (see FR-3.6). */
|
|
125
|
+
'running'
|
|
126
|
+
/** It is a sub-session: delete its parent instead. */
|
|
127
|
+
| 'subsession'
|
|
128
|
+
/** Its log could not be read, so deleting it would be guesswork. */
|
|
129
|
+
| 'unreadable'
|
|
130
|
+
/** The caller asked to keep a session that this operation would have removed. */
|
|
131
|
+
| 'protected';
|
|
132
|
+
/** The pure decision for one deletion. */
|
|
133
|
+
export interface DeleteDecision {
|
|
134
|
+
/** Ids to delete, **children before parents** (so a parent never outlives its children). */
|
|
135
|
+
readonly delete: readonly string[];
|
|
136
|
+
/** One refusal per skipped id, in the order the ids were considered. */
|
|
137
|
+
readonly refusals: readonly {
|
|
138
|
+
readonly id: string;
|
|
139
|
+
readonly reason: DeleteRefusal;
|
|
140
|
+
}[];
|
|
141
|
+
/** Total bytes the deletion will free, counting only the ids in `delete`. */
|
|
142
|
+
readonly bytes: number;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Decide what a deletion request would actually do, without touching anything.
|
|
146
|
+
*
|
|
147
|
+
* The rules, in order:
|
|
148
|
+
*
|
|
149
|
+
* 1. an id that is not in the inventory is refused as `unreadable` (it was never a session here);
|
|
150
|
+
* 2. a **running** session is refused (`running`) — FR-3.6 requires blocking rather than killing, because the
|
|
151
|
+
* platform exposes no way to unload a live agent, and the requirement is explicit that
|
|
152
|
+
* "stop and delete" is *not* offered this round. Loaded-but-idle is **not** refused: the host keeps an
|
|
153
|
+
* agent for every session opened since it started, so refusing those would make nearly every session
|
|
154
|
+
* undeletable, and a session that is doing nothing cannot be interrupted;
|
|
155
|
+
* 3. an `unreadable` record is refused (`unreadable`) — deleting a directory we could not identify is
|
|
156
|
+
* exactly the kind of irreversible guess this feature must not make;
|
|
157
|
+
* 4. a sub-session is refused (`subsession`) when it is selected **on its own** (FR-3.3: 子会话随父会话管理,
|
|
158
|
+
* 默认不可被单独删除);
|
|
159
|
+
* 5. selecting a parent **always** takes its whole descendant subtree along, ordered **children first**.
|
|
160
|
+
*
|
|
161
|
+
* Rule 5 is unconditional on purpose. It used to be an opt-in `includeSubsessions` switch, and that was the
|
|
162
|
+
* wrong shape: leaving a parent's children behind orphans their logs — the parent is gone, so nothing
|
|
163
|
+
* navigates to them, and the platform's listing hides them by `parentId` nesting, so the bytes stay on disk
|
|
164
|
+
* and invisible forever. FR-3.3 asks for sub-sessions to be "随父会话管理" and the requirement's own §7
|
|
165
|
+
* recommendation is "删除父会话时提示将一并处理 N 个子会话": always together, with the count shown.
|
|
166
|
+
*
|
|
167
|
+
* A sub-session that was explicitly requested *and* pulled in by its parent is deleted and **not** reported
|
|
168
|
+
* as a refusal — otherwise the caller would see a row refused and deleted in the same answer.
|
|
169
|
+
*
|
|
170
|
+
* **A subtree is atomic.** If any descendant of a requested session is running or protected, the whole
|
|
171
|
+
* request for that session is refused instead of deleting the ancestor and orphaning the descendant: a
|
|
172
|
+
* running child whose parent is gone can never be reached again (the listing nests children under their
|
|
173
|
+
* parent), and a protected one was explicitly asked to be spared. Refusing the parent is the only outcome
|
|
174
|
+
* that keeps both promises (FR-3.6 never interrupts a running agent; `keep` is honoured).
|
|
175
|
+
*
|
|
176
|
+
* `keep` wins over everything: an id listed there is refused as `protected` even if it is an idle normal
|
|
177
|
+
* session. It exists so a UI can show a preview and let the user uncheck rows.
|
|
178
|
+
*
|
|
179
|
+
* @param records - the full inventory.
|
|
180
|
+
* @param request - ids to delete, plus the optional keep list.
|
|
181
|
+
* @returns the decision; `delete` is empty when everything was refused.
|
|
182
|
+
*/
|
|
183
|
+
export declare function planDelete(records: readonly SessionRecord[], request: {
|
|
184
|
+
readonly ids: readonly string[];
|
|
185
|
+
readonly keep?: readonly string[];
|
|
186
|
+
}): DeleteDecision;
|
|
187
|
+
/** Index file name inside the session bin root. */
|
|
188
|
+
export declare const SESSION_TRASH_INDEX_FILE = "index.json";
|
|
189
|
+
/** Version stamped into the session bin index; a different version is a hard parse failure. */
|
|
190
|
+
export declare const SESSION_TRASH_INDEX_VERSION = 1;
|
|
191
|
+
/** Opaque identity of one session-bin entry. */
|
|
192
|
+
export type SessionTrashEntryId = string & {
|
|
193
|
+
readonly __sessionTrashEntryId: 'SessionTrashEntryId';
|
|
194
|
+
};
|
|
195
|
+
/** One entry in the session recycle bin: everything a restore needs. */
|
|
196
|
+
export interface SessionTrashEntry {
|
|
197
|
+
readonly id: SessionTrashEntryId;
|
|
198
|
+
/** The deleted session's id. */
|
|
199
|
+
readonly sessionId: string;
|
|
200
|
+
/**
|
|
201
|
+
* The session's `cwd` as recorded in its header, or `undefined` for the no-cwd bucket.
|
|
202
|
+
*
|
|
203
|
+
* Kept **verbatim** rather than as a `projectKey`: the key is lossy, so restoring from it could land the
|
|
204
|
+
* session under a different project directory than it came from.
|
|
205
|
+
*/
|
|
206
|
+
readonly cwd: string | undefined;
|
|
207
|
+
/** Project directory name the session was moved out of. */
|
|
208
|
+
readonly projectDir: string;
|
|
209
|
+
/**
|
|
210
|
+
* Directory name **inside the bin**; the session directory is moved here wholesale.
|
|
211
|
+
*
|
|
212
|
+
* Equals the entry id in practice, and is **not** the store-side name — see `originalSegment`. Keeping the
|
|
213
|
+
* two apart is what makes a restore land back where it came from: using one for both would make the
|
|
214
|
+
* restored session appear under the entry id.
|
|
215
|
+
*/
|
|
216
|
+
readonly segment: string;
|
|
217
|
+
/**
|
|
218
|
+
* The session's directory name **in the store** (`encodeSegment(sessionId)`), captured before the move.
|
|
219
|
+
*
|
|
220
|
+
* Stored rather than recomputed: `encodeSegment` is injective so recomputing would work today, but recording
|
|
221
|
+
* what was actually observed means a restore never depends on the encoder staying byte-identical.
|
|
222
|
+
*/
|
|
223
|
+
readonly originalSegment: string;
|
|
224
|
+
readonly title: string | undefined;
|
|
225
|
+
/** Total bytes of the moved log files, for the retention policy. */
|
|
226
|
+
readonly bytes: number;
|
|
227
|
+
readonly deletedAtMs: number;
|
|
228
|
+
/** How the bytes were moved. `copy` means the move crossed a filesystem boundary. */
|
|
229
|
+
readonly movedBy: 'rename' | 'copy';
|
|
230
|
+
/** Who confirmed the deletion, when the caller supplied it (FR-3.10 auditability). */
|
|
231
|
+
readonly confirmedBy: string | undefined;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Derive a stable, filesystem-safe entry id.
|
|
235
|
+
*
|
|
236
|
+
* Deterministic on `(sessionId, deletedAtMs)` so a retried deletion of the same session at the same instant
|
|
237
|
+
* lands on the same entry instead of leaving two copies; {@link allocateSessionTrashEntryId} breaks genuine
|
|
238
|
+
* collisions.
|
|
239
|
+
*
|
|
240
|
+
* @param input - identity inputs.
|
|
241
|
+
* @returns the entry id.
|
|
242
|
+
*/
|
|
243
|
+
export declare function makeSessionTrashEntryId(input: {
|
|
244
|
+
readonly sessionId: string;
|
|
245
|
+
readonly deletedAtMs: number;
|
|
246
|
+
}): SessionTrashEntryId;
|
|
247
|
+
/**
|
|
248
|
+
* Allocate an id that is not already taken, by appending a counter.
|
|
249
|
+
*
|
|
250
|
+
* @param existing - ids already in the bin.
|
|
251
|
+
* @param input - identity inputs.
|
|
252
|
+
* @returns a free id.
|
|
253
|
+
* @throws SshDevError `TRASH_NOT_FOUND` when the counter space is exhausted (never returns a taken id).
|
|
254
|
+
*/
|
|
255
|
+
export declare function allocateSessionTrashEntryId(existing: Iterable<string>, input: {
|
|
256
|
+
readonly sessionId: string;
|
|
257
|
+
readonly deletedAtMs: number;
|
|
258
|
+
}): SessionTrashEntryId;
|
|
259
|
+
/** The persisted session-bin index document. */
|
|
260
|
+
export interface SessionTrashIndex {
|
|
261
|
+
readonly version: number;
|
|
262
|
+
readonly entries: readonly SessionTrashEntry[];
|
|
263
|
+
}
|
|
264
|
+
/** One index record the parser refused. */
|
|
265
|
+
export interface SkippedSessionTrashRecord {
|
|
266
|
+
readonly index: number;
|
|
267
|
+
readonly reason: string;
|
|
268
|
+
}
|
|
269
|
+
/** Result of {@link parseSessionTrashIndex}. */
|
|
270
|
+
export interface ParsedSessionTrashIndex {
|
|
271
|
+
readonly entries: readonly SessionTrashEntry[];
|
|
272
|
+
readonly skipped: readonly SkippedSessionTrashRecord[];
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Serialize the session-bin index.
|
|
276
|
+
*
|
|
277
|
+
* Entries are sorted by id so the same set is byte-identical every time (diffable, testable).
|
|
278
|
+
*
|
|
279
|
+
* @param entries - entries to persist.
|
|
280
|
+
* @returns JSON text.
|
|
281
|
+
*/
|
|
282
|
+
export declare function serializeSessionTrashIndex(entries: readonly SessionTrashEntry[]): string;
|
|
283
|
+
/**
|
|
284
|
+
* Parse the session-bin index.
|
|
285
|
+
*
|
|
286
|
+
* A malformed document or unknown version is a **hard failure** (the bin cannot be trusted, and guessing
|
|
287
|
+
* could delete the wrong directory); a single unusable record is skipped and reported, so one corrupt entry
|
|
288
|
+
* never hides the rest.
|
|
289
|
+
*
|
|
290
|
+
* @param text - index file content.
|
|
291
|
+
* @returns usable entries plus skipped records.
|
|
292
|
+
* @throws SshDevError `TRASH_NOT_FOUND` when the document cannot be read as an index.
|
|
293
|
+
*/
|
|
294
|
+
export declare function parseSessionTrashIndex(text: string): ParsedSessionTrashIndex;
|
|
295
|
+
/** The pure decision for restoring one entry. */
|
|
296
|
+
export type RestoreSessionPlan = {
|
|
297
|
+
readonly kind: 'restore';
|
|
298
|
+
readonly entryId: SessionTrashEntryId;
|
|
299
|
+
readonly sessionId: string;
|
|
300
|
+
readonly projectDir: string;
|
|
301
|
+
/** Where it goes back to in the store (the original directory name). */
|
|
302
|
+
readonly originalSegment: string;
|
|
303
|
+
} | {
|
|
304
|
+
readonly kind: 'refuse';
|
|
305
|
+
readonly reason: 'occupied';
|
|
306
|
+
readonly entryId: SessionTrashEntryId;
|
|
307
|
+
readonly suggestedId: string;
|
|
308
|
+
};
|
|
309
|
+
/**
|
|
310
|
+
* Decide how to restore one entry.
|
|
311
|
+
*
|
|
312
|
+
* Restoring onto an occupied path is **refused** with a suggestion rather than silently restored under a
|
|
313
|
+
* different name: the platform keys a session by `(id, cwd)`, so a renamed directory would either be
|
|
314
|
+
* invisible or (worse) look like a second copy of a session that still exists.
|
|
315
|
+
*
|
|
316
|
+
* @param entry - the bin entry.
|
|
317
|
+
* @param options - whether the original path is currently occupied.
|
|
318
|
+
* @returns the plan.
|
|
319
|
+
*/
|
|
320
|
+
export declare function planRestoreSession(entry: SessionTrashEntry, options: {
|
|
321
|
+
readonly occupied: boolean;
|
|
322
|
+
}): RestoreSessionPlan;
|
|
323
|
+
/**
|
|
324
|
+
* Build one entry from a deletion that has already been performed.
|
|
325
|
+
*
|
|
326
|
+
* Pure: the caller has moved the bytes and passes the observed facts. Keeping this separate from the move
|
|
327
|
+
* means the recorded `bytes`/`movedBy` are what actually happened, not what was planned.
|
|
328
|
+
*
|
|
329
|
+
* @param input - observed facts.
|
|
330
|
+
* @returns the entry.
|
|
331
|
+
*/
|
|
332
|
+
export declare function makeSessionTrashEntry(input: {
|
|
333
|
+
readonly id: SessionTrashEntryId;
|
|
334
|
+
readonly sessionId: string;
|
|
335
|
+
readonly cwd: string | undefined;
|
|
336
|
+
readonly projectDir: string;
|
|
337
|
+
readonly segment: string;
|
|
338
|
+
readonly originalSegment: string;
|
|
339
|
+
readonly title: string | undefined;
|
|
340
|
+
readonly bytes: number;
|
|
341
|
+
readonly deletedAtMs: number;
|
|
342
|
+
readonly movedBy: 'rename' | 'copy';
|
|
343
|
+
readonly confirmedBy?: string | undefined;
|
|
344
|
+
}): SessionTrashEntry;
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recycle bin: entry model, on-disk layout, retention policy and the undo stack — pure logic only.
|
|
3
|
+
*
|
|
4
|
+
* Why the domain layer (AGENTS.md 红线 2): entry ids, the bin path layout, the retention decision and the
|
|
5
|
+
* undo state machine are all decisions that can be verified offline in milliseconds. Every real filesystem
|
|
6
|
+
* or SSH interaction stays behind the injected seam of
|
|
7
|
+
* [`integration/recycle-bin-service.ts`](../integration/recycle-bin-service.ts).
|
|
8
|
+
*
|
|
9
|
+
* Contract notes:
|
|
10
|
+
*
|
|
11
|
+
* - **Deterministic ids.** The same inputs (root key, original path, deletion instant, delete ordinal for
|
|
12
|
+
* that path) always yield the same id. Two deletes of the same path at the same millisecond are told
|
|
13
|
+
* apart by `sequence`, and {@link allocateRecycleBinId} breaks genuine hash collisions deterministically.
|
|
14
|
+
* - **Layout.** `<binRoot>/<id>/<original basename>`. The id is unique, so same-named files never collide;
|
|
15
|
+
* keeping the original basename in the bin means a restore does not rename anything. Everything else a
|
|
16
|
+
* restore needs (root key, original absolute path) lives on the entry, which the service persists in an
|
|
17
|
+
* index file next to the bins.
|
|
18
|
+
* - **Restore conflicts.** When the original path is occupied, {@link planRestore} reports `blocked` plus an
|
|
19
|
+
* informational suffix candidate; the caller must **refuse**, never silently write to a different name.
|
|
20
|
+
* - **Retention.** `maxEntries` / `maxBytes` / `maxAgeMs` are applied per dimension: a **non-positive value
|
|
21
|
+
* means "no limit"** for that dimension (0 and negatives never wipe anything — deliberate, because a
|
|
22
|
+
* misconfigured 0 must not empty the bin). Oversized bins shed **oldest first**, with the id as a stable
|
|
23
|
+
* tie-break, so the same input always produces the same decision.
|
|
24
|
+
* - **Undo.** A stack. The inverse of `discard` is `restore` (and vice versa, which is what makes the undo
|
|
25
|
+
* redoable); `purge` is a physical delete and is therefore **explicitly irreversible**.
|
|
26
|
+
*
|
|
27
|
+
* @module dsh-over-ssh/domain/recycle-bin
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* Opaque identity of one recycle-bin entry.
|
|
31
|
+
*
|
|
32
|
+
* Branded on purpose: an id is the directory name inside the bin and a handle the UI holds across calls, so
|
|
33
|
+
* it must not be confused with a path or a session id.
|
|
34
|
+
*/
|
|
35
|
+
export type RecycleBinEntryId = string & {
|
|
36
|
+
readonly __recycleBinEntryId: 'RecycleBinEntryId';
|
|
37
|
+
};
|
|
38
|
+
/** Which world a target lives in; same vocabulary as `RootRef.location`. */
|
|
39
|
+
export type RecycleBinLocation = 'local' | 'remote';
|
|
40
|
+
/** Name of the index file inside a bin root. */
|
|
41
|
+
export declare const RECYCLE_BIN_INDEX_FILE = "index.json";
|
|
42
|
+
/** Version stamped into the index file; a different version is a hard parse failure. */
|
|
43
|
+
export declare const RECYCLE_BIN_INDEX_VERSION = 1;
|
|
44
|
+
/** Inputs that determine an entry id. */
|
|
45
|
+
export interface RecycleBinIdInput {
|
|
46
|
+
/** Stable root key (`rootKey()`); scopes ids to one root. */
|
|
47
|
+
readonly rootKey: string;
|
|
48
|
+
/** Absolute path of the deleted target, in this repository's normalized spelling. */
|
|
49
|
+
readonly originalPath: string;
|
|
50
|
+
/** Deletion instant in milliseconds since the epoch. */
|
|
51
|
+
readonly deletedAtMs: number;
|
|
52
|
+
/** Which delete of that same path this is, 1-based. */
|
|
53
|
+
readonly sequence: number;
|
|
54
|
+
}
|
|
55
|
+
/** One recycle-bin entry: everything a restore needs, plus the facts the bin view shows. */
|
|
56
|
+
export interface RecycleBinEntry {
|
|
57
|
+
readonly id: RecycleBinEntryId;
|
|
58
|
+
/** Root key (`rootKey()`) the target was deleted from. */
|
|
59
|
+
readonly rootKey: string;
|
|
60
|
+
readonly location: RecycleBinLocation;
|
|
61
|
+
/** Original absolute path, normalized (local: `/` separators; remote: POSIX). */
|
|
62
|
+
readonly originalPath: string;
|
|
63
|
+
/** Final segment of {@link originalPath}; the bin keeps this name. */
|
|
64
|
+
readonly originalName: string;
|
|
65
|
+
/** Bin root directory (`binRootFor(root)`). */
|
|
66
|
+
readonly binRoot: string;
|
|
67
|
+
/** Actual location inside the bin: `<binRoot>/<id>/<originalName>`. */
|
|
68
|
+
readonly binPath: string;
|
|
69
|
+
readonly deletedAtMs: number;
|
|
70
|
+
/** Bytes reported by the pre-delete `stat`; for directories this is the directory entry, not a sum. */
|
|
71
|
+
readonly sizeBytes: number;
|
|
72
|
+
readonly isDirectory: boolean;
|
|
73
|
+
/** 1-based ordinal of this delete among deletes of the same path. */
|
|
74
|
+
readonly sequence: number;
|
|
75
|
+
}
|
|
76
|
+
/** Input for {@link makeRecycleBinEntry}; {@link RecycleBinIdInput} plus the id and the observed facts. */
|
|
77
|
+
export interface MakeRecycleBinEntryInput extends RecycleBinIdInput {
|
|
78
|
+
readonly id: RecycleBinEntryId;
|
|
79
|
+
readonly location: RecycleBinLocation;
|
|
80
|
+
readonly binRoot: string;
|
|
81
|
+
readonly sizeBytes: number;
|
|
82
|
+
readonly isDirectory: boolean;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Normalize a deletion target into the spelling the rest of the repository uses.
|
|
86
|
+
*
|
|
87
|
+
* Local targets go through `normalizeLocalPath` (separators to `/`, case preserved), remote targets through
|
|
88
|
+
* `normalizeRemotePath` (POSIX, absolute). A filesystem root is rejected because it can never be a target.
|
|
89
|
+
*
|
|
90
|
+
* @param path - target path in either separator spelling.
|
|
91
|
+
* @param location - which world the path belongs to.
|
|
92
|
+
* @returns the normalized absolute path.
|
|
93
|
+
* @throws SshDevError `ROOT_INVALID` when the path is empty, relative, or a filesystem root.
|
|
94
|
+
*/
|
|
95
|
+
export declare function normalizeRecycleTarget(path: string, location: RecycleBinLocation): string;
|
|
96
|
+
/**
|
|
97
|
+
* The deterministic id for one {@link RecycleBinIdInput}.
|
|
98
|
+
*
|
|
99
|
+
* Same input → same id (a test can therefore pin ids). Different inputs collide only if FNV-1a collides,
|
|
100
|
+
* which {@link allocateRecycleBinId} handles.
|
|
101
|
+
*
|
|
102
|
+
* @param input - root key, original path, deletion instant and delete ordinal.
|
|
103
|
+
* @returns a filesystem-safe id of the form `rb-<8 hex>`.
|
|
104
|
+
*/
|
|
105
|
+
export declare function makeRecycleBinEntryId(input: RecycleBinIdInput): RecycleBinEntryId;
|
|
106
|
+
/**
|
|
107
|
+
* Allocate an id that is not already taken by `existing`.
|
|
108
|
+
*
|
|
109
|
+
* The first candidate is {@link makeRecycleBinEntryId}; on collision the suffix `-2`, `-3`, … is appended.
|
|
110
|
+
* The loop is bounded by the number of live entries, because every candidate it rejects is one of them.
|
|
111
|
+
*
|
|
112
|
+
* @param existing - ids already present in the bin index.
|
|
113
|
+
* @param input - id material.
|
|
114
|
+
* @returns an id not present in `existing`.
|
|
115
|
+
*/
|
|
116
|
+
export declare function allocateRecycleBinId(existing: Iterable<string>, input: RecycleBinIdInput): RecycleBinEntryId;
|
|
117
|
+
/**
|
|
118
|
+
* Build one entry, validating its inputs and computing the bin layout.
|
|
119
|
+
*
|
|
120
|
+
* @param input - id, root, worlds, observed `stat` facts.
|
|
121
|
+
* @returns the entry, with `binPath` = `<binRoot>/<id>/<originalName>`.
|
|
122
|
+
* @throws SshDevError `ROOT_INVALID` when a path, the bin root, or a numeric field is unusable.
|
|
123
|
+
*/
|
|
124
|
+
export declare function makeRecycleBinEntry(input: MakeRecycleBinEntryInput): RecycleBinEntry;
|
|
125
|
+
/**
|
|
126
|
+
* Join segments under an absolute base with `/`.
|
|
127
|
+
*
|
|
128
|
+
* Both worlds use this spelling (`local` roots are stored normalized by `normalizeLocalPath`, remote ones are
|
|
129
|
+
* POSIX), so the bin layout is spelled the same way everywhere. A drive-rooted base (`D:`) keeps its slash.
|
|
130
|
+
*
|
|
131
|
+
* @param base - absolute base directory.
|
|
132
|
+
* @param segments - segments to append.
|
|
133
|
+
* @returns the joined path.
|
|
134
|
+
*/
|
|
135
|
+
export declare function joinUnderPath(base: string, ...segments: readonly string[]): string;
|
|
136
|
+
/** A restore decision: either the move is ready, or the original path is occupied and must not be reused. */
|
|
137
|
+
export type RestorePlan = {
|
|
138
|
+
readonly kind: 'ready';
|
|
139
|
+
readonly from: string;
|
|
140
|
+
readonly to: string;
|
|
141
|
+
} | {
|
|
142
|
+
readonly kind: 'blocked';
|
|
143
|
+
readonly to: string;
|
|
144
|
+
readonly reason: 'occupied';
|
|
145
|
+
readonly suggestedPath: string;
|
|
146
|
+
};
|
|
147
|
+
/**
|
|
148
|
+
* Decide how to restore one entry.
|
|
149
|
+
*
|
|
150
|
+
* Policy: **an occupied original path is refused, never renamed silently.** `suggestedPath` is returned so a
|
|
151
|
+
* UI can *offer* an alternative, but no code path in this module moves a target to it.
|
|
152
|
+
*
|
|
153
|
+
* @param entry - entry to restore.
|
|
154
|
+
* @param options - `occupied` is the caller's `stat` of `entry.originalPath`.
|
|
155
|
+
* @returns the plan.
|
|
156
|
+
*/
|
|
157
|
+
export declare function planRestore(entry: RecycleBinEntry, options: {
|
|
158
|
+
readonly occupied: boolean;
|
|
159
|
+
}): RestorePlan;
|
|
160
|
+
/** Retention policy. A non-positive value means "no limit" for that dimension. */
|
|
161
|
+
export interface RecycleBinPolicy {
|
|
162
|
+
/** Keep at most this many entries; non-positive = unlimited. */
|
|
163
|
+
readonly maxEntries: number;
|
|
164
|
+
/** Keep at most this many bytes (sum of entry sizes); non-positive = unlimited. */
|
|
165
|
+
readonly maxBytes: number;
|
|
166
|
+
/** Keep entries at most this old, in milliseconds; non-positive = unlimited. */
|
|
167
|
+
readonly maxAgeMs: number;
|
|
168
|
+
}
|
|
169
|
+
/** Neutral element: no dimension is limited, therefore nothing is ever swept with it. */
|
|
170
|
+
export declare const NO_RECYCLE_BIN_LIMITS: RecycleBinPolicy;
|
|
171
|
+
/** Which dimension caused one entry to be swept. */
|
|
172
|
+
export type SweepDimension = 'age' | 'count' | 'bytes';
|
|
173
|
+
/** Why one entry is scheduled for physical deletion. */
|
|
174
|
+
export interface SweepReason {
|
|
175
|
+
readonly id: RecycleBinEntryId;
|
|
176
|
+
readonly dimension: SweepDimension;
|
|
177
|
+
readonly detail: string;
|
|
178
|
+
}
|
|
179
|
+
/** The pure retention decision for one bin. */
|
|
180
|
+
export interface SweepDecision {
|
|
181
|
+
/** Ids to delete physically, oldest first. */
|
|
182
|
+
readonly purge: readonly RecycleBinEntryId[];
|
|
183
|
+
/** Ids to keep, oldest first. */
|
|
184
|
+
readonly keep: readonly RecycleBinEntryId[];
|
|
185
|
+
/** One reason per purged id, in the same order. */
|
|
186
|
+
readonly reasons: readonly SweepReason[];
|
|
187
|
+
/** Entries and bytes left after applying the decision. */
|
|
188
|
+
readonly remainingEntries: number;
|
|
189
|
+
readonly remainingBytes: number;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Apply a retention policy to one bin.
|
|
193
|
+
*
|
|
194
|
+
* Rules, in this order (all of them deterministic: entries are ordered oldest first with the id as tie-break):
|
|
195
|
+
*
|
|
196
|
+
* 1. `maxAgeMs > 0`: purge everything older than the limit. An entry exactly **at** the limit is kept, and a
|
|
197
|
+
* negative age (clock skew, future timestamp) is never purged.
|
|
198
|
+
* 2. `maxBytes > 0`: walk the survivors oldest first and purge until the remaining sum is within the limit.
|
|
199
|
+
* A single entry larger than the whole budget is purged too — otherwise the budget would be meaningless.
|
|
200
|
+
* 3. `maxEntries > 0`: purge the oldest survivors until the count is within the limit.
|
|
201
|
+
*
|
|
202
|
+
* @param entries - entries of one bin.
|
|
203
|
+
* @param policy - limits; non-positive values are ignored.
|
|
204
|
+
* @param nowMs - current instant in milliseconds since the epoch.
|
|
205
|
+
* @returns the decision.
|
|
206
|
+
*/
|
|
207
|
+
export declare function decideSweep(entries: readonly RecycleBinEntry[], policy: RecycleBinPolicy, nowMs: number): SweepDecision;
|
|
208
|
+
/** What one committed operation did; the unit of the undo stack. */
|
|
209
|
+
export type OperationKind = 'discard' | 'restore' | 'purge';
|
|
210
|
+
/** One committed operation. */
|
|
211
|
+
export interface RecycleBinOperation {
|
|
212
|
+
readonly kind: OperationKind;
|
|
213
|
+
/** Entry the operation acted on; `undefined` for a permanent delete, which has no bin entry. */
|
|
214
|
+
readonly entry: RecycleBinEntry | undefined;
|
|
215
|
+
/** Entry id, or a synthesized id for a permanent delete. */
|
|
216
|
+
readonly id: RecycleBinEntryId;
|
|
217
|
+
readonly atMs: number;
|
|
218
|
+
}
|
|
219
|
+
/** Stack-shaped undo state: `undo` is the history, `redo` what was undone and can be re-applied. */
|
|
220
|
+
export interface UndoStack {
|
|
221
|
+
readonly undo: readonly RecycleBinOperation[];
|
|
222
|
+
readonly redo: readonly RecycleBinOperation[];
|
|
223
|
+
/** Operations dropped for exceeding the depth limit; explains "too old to undo". */
|
|
224
|
+
readonly dropped: number;
|
|
225
|
+
}
|
|
226
|
+
/** Fresh state with no history. */
|
|
227
|
+
export declare const EMPTY_UNDO_STACK: UndoStack;
|
|
228
|
+
/** Why an undo/redo step was refused. */
|
|
229
|
+
export type UndoRejectionReason = 'nothing-to-undo' | 'nothing-to-redo' | 'entry-missing' | 'irreversible';
|
|
230
|
+
/** The world state an undo decision is validated against. */
|
|
231
|
+
export interface UndoContext {
|
|
232
|
+
/**
|
|
233
|
+
* Ids **currently present in the recycle bin**.
|
|
234
|
+
*
|
|
235
|
+
* One rule covers both directions: a `restore` action needs the bin copy (the id must be listed), and a
|
|
236
|
+
* `discard` action needs the file to be back at its original path (the id must not be listed).
|
|
237
|
+
*/
|
|
238
|
+
readonly liveIds: readonly RecycleBinEntryId[];
|
|
239
|
+
}
|
|
240
|
+
/** A step to execute: move the entry out of the bin, or back into it. */
|
|
241
|
+
export interface UndoAction {
|
|
242
|
+
readonly kind: 'restore' | 'discard';
|
|
243
|
+
readonly operation: RecycleBinOperation;
|
|
244
|
+
readonly entry: RecycleBinEntry;
|
|
245
|
+
}
|
|
246
|
+
/** Why a step is not executable. */
|
|
247
|
+
export interface UndoRejection {
|
|
248
|
+
readonly reason: UndoRejectionReason;
|
|
249
|
+
readonly message: string;
|
|
250
|
+
readonly operation: RecycleBinOperation | undefined;
|
|
251
|
+
}
|
|
252
|
+
/** Outcome of one undo/redo step: either an action to run, or a rejection. */
|
|
253
|
+
export interface UndoOutcome {
|
|
254
|
+
/** State after the step; a rejected record is dropped from the stack (it can never succeed). */
|
|
255
|
+
readonly stack: UndoStack;
|
|
256
|
+
readonly action: UndoAction | undefined;
|
|
257
|
+
readonly rejection: UndoRejection | undefined;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Record one committed operation.
|
|
261
|
+
*
|
|
262
|
+
* The redo half is cleared (a new operation starts a new branch of history), the record goes on top of the
|
|
263
|
+
* undo half, and anything beyond `undoDepth` is dropped. A non-positive `undoDepth` keeps no history at all.
|
|
264
|
+
*
|
|
265
|
+
* @param stack - current state.
|
|
266
|
+
* @param operation - the operation that just succeeded.
|
|
267
|
+
* @param options - `undoDepth` from configuration.
|
|
268
|
+
* @returns the new state.
|
|
269
|
+
*/
|
|
270
|
+
export declare function recordOperation(stack: UndoStack, operation: RecycleBinOperation, options: {
|
|
271
|
+
readonly undoDepth: number;
|
|
272
|
+
}): UndoStack;
|
|
273
|
+
/** The operation {@link takeUndo} would consume, or `undefined` for an empty history. */
|
|
274
|
+
export declare function nextUndo(stack: UndoStack): RecycleBinOperation | undefined;
|
|
275
|
+
/** The operation that can be re-applied, or `undefined` when nothing was undone. */
|
|
276
|
+
export declare function nextRedo(stack: UndoStack): RecycleBinOperation | undefined;
|
|
277
|
+
/**
|
|
278
|
+
* Consume the newest operation as an **inverse** step.
|
|
279
|
+
*
|
|
280
|
+
* `discard` reverses to `restore`; `restore` reverses to `discard`; `purge` is refused as `irreversible`
|
|
281
|
+
* (a physical delete cannot be undone, and the record is dropped instead of being offered again).
|
|
282
|
+
*
|
|
283
|
+
* @param stack - current state.
|
|
284
|
+
* @param context - which ids are actually in the bin right now.
|
|
285
|
+
* @returns the action to run plus the new state, or a rejection plus the state with the dead record dropped.
|
|
286
|
+
*/
|
|
287
|
+
export declare function takeUndo(stack: UndoStack, context: UndoContext): UndoOutcome;
|
|
288
|
+
/**
|
|
289
|
+
* Re-apply the newest undone operation.
|
|
290
|
+
*
|
|
291
|
+
* This is the exact inverse of {@link takeUndo}: it consumes the redo half and pushes the same operation back
|
|
292
|
+
* onto the undo half.
|
|
293
|
+
*
|
|
294
|
+
* @param stack - current state.
|
|
295
|
+
* @param context - which ids are actually in the bin right now.
|
|
296
|
+
* @returns the action to run plus the new state, or a rejection.
|
|
297
|
+
*/
|
|
298
|
+
export declare function takeRedo(stack: UndoStack, context: UndoContext): UndoOutcome;
|
|
299
|
+
/** The persisted index document. */
|
|
300
|
+
export interface RecycleBinIndex {
|
|
301
|
+
readonly version: number;
|
|
302
|
+
readonly entries: readonly RecycleBinEntry[];
|
|
303
|
+
}
|
|
304
|
+
/** One record the parser refused, with the reason (a corrupt index must never silently lose data). */
|
|
305
|
+
export interface SkippedIndexRecord {
|
|
306
|
+
readonly index: number;
|
|
307
|
+
readonly reason: string;
|
|
308
|
+
}
|
|
309
|
+
/** Result of {@link parseRecycleBinIndex}. */
|
|
310
|
+
export interface ParsedRecycleBinIndex {
|
|
311
|
+
readonly entries: readonly RecycleBinEntry[];
|
|
312
|
+
readonly skipped: readonly SkippedIndexRecord[];
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Serialize the index.
|
|
316
|
+
*
|
|
317
|
+
* Entries are sorted by id so the same set always produces byte-identical text (easy to diff, easy to test).
|
|
318
|
+
* The result is a plain JavaScript string: written as UTF-8 it carries **no BOM**, and {@link parseRecycleBinIndex}
|
|
319
|
+
* tolerates one anyway because remote tooling and editors add them.
|
|
320
|
+
*
|
|
321
|
+
* @param entries - entries to persist.
|
|
322
|
+
* @returns JSON text.
|
|
323
|
+
*/
|
|
324
|
+
export declare function serializeRecycleBinIndex(entries: readonly RecycleBinEntry[]): string;
|
|
325
|
+
/**
|
|
326
|
+
* Parse the index.
|
|
327
|
+
*
|
|
328
|
+
* A malformed document or an unknown version is a hard failure (the bin contents cannot be trusted); a single
|
|
329
|
+
* unusable record is skipped and reported. Stored `binPath` values are taken as authoritative and not recomputed,
|
|
330
|
+
* so changing the `binRootFor` configuration later does not invalidate existing entries.
|
|
331
|
+
*
|
|
332
|
+
* @param text - index file content.
|
|
333
|
+
* @returns the usable entries plus the skipped records.
|
|
334
|
+
* @throws SshDevError `TRASH_NOT_FOUND` when the document cannot be read as an index.
|
|
335
|
+
*/
|
|
336
|
+
export declare function parseRecycleBinIndex(text: string): ParsedRecycleBinIndex;
|