dsh-over-ssh 0.2.3 → 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 +52 -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/lib/client.js +78 -38
- package/lib/fs.js +2 -1
- package/lib/index.js +1083 -129
- 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/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/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/wiring/connections.d.ts +261 -0
- package/lib/types/src/wiring/install-memo.d.ts +31 -0
- package/package.json +1 -1
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier 1 的**轮询式** watch:没有代理原生 watcher 时,用"定时快照 + 对比"回答"这个路径变了"。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它:`Backend.watch` 是可选方法,Tier 2(Go 代理有 inotify/kqueue)实现了,Tier 1(纯 POSIX
|
|
5
|
+
* ssh)没有。后果不是崩溃,而是**能力缺口**:纯 POSIX 路径上目录变了没人知道,文件面板不会刷新,只能靠
|
|
6
|
+
* 重新读。诚实拒绝(`REMOTE_UNSUPPORTED`)比假装订阅好,但"能轮询"比"完全不能"好——只要轮询的代价被
|
|
7
|
+
* 明确限住。
|
|
8
|
+
*
|
|
9
|
+
* 三条设计约束,都是"不要拖垮连接":
|
|
10
|
+
*
|
|
11
|
+
* 1. **只轮询被 watch 的那一层**(`maxDepth = 1`,见 `remote-listing.ts` 的 `buildListScript`)。递归
|
|
12
|
+
* 轮询一个 `~` 目录会把远端打爆:一次快照就是一次 `find`,深度每加一层,条目数与 I/O 都成倍增长。
|
|
13
|
+
* 需要子树通知的调用方应当分别 watch 子目录,而不是让一个订阅替它扫全树。
|
|
14
|
+
* 2. **条目数有上限**(默认 {@link DEFAULT_POLL_MAX_ENTRIES})。超过上限不是"慢慢比",而是**诚实失败**:
|
|
15
|
+
* 每 2.5 s 传回并比较两万行,比不提供监听更糟。失败通过 `changed(error)` 上报,订阅方据此退回
|
|
16
|
+
* "重新读"的旧行为。
|
|
17
|
+
* 3. **连续失败要退避,且到阈值就报死**。每 2.5 s 打一次 ssh 的重试是"看起来活着其实早死了":连接
|
|
18
|
+
* 断了以后订阅方永远等不到通知,却以为自己在被监听。默认 3 次连续失败后 `changed(error)` 并停止。
|
|
19
|
+
*
|
|
20
|
+
* 本模块**不碰 transport**:快照函数由调用方注入(`RemoteBackend` 用一条 shell 命令实现),所以这里
|
|
21
|
+
* 可以完全用假快照 + 假时钟单测,不需要 ssh、不需要 sleep 真实时间。
|
|
22
|
+
*
|
|
23
|
+
* @module dsh-over-ssh/infra/backend/poll-watch
|
|
24
|
+
*/
|
|
25
|
+
import type { BackendEntry } from '../../app/backend.ts';
|
|
26
|
+
/** 默认轮询间隔:2.5 s。 */
|
|
27
|
+
export declare const DEFAULT_POLL_INTERVAL_MS = 2500;
|
|
28
|
+
/** 默认连续失败阈值:3 次。到阈值就 `changed(error)` 并停止,不无限静默重试。 */
|
|
29
|
+
export declare const DEFAULT_POLL_FAILURE_THRESHOLD = 3;
|
|
30
|
+
/** 默认退避上限:30 s。连续失败时按 `interval × 2^(n-1)` 退避,但不超过这个值。 */
|
|
31
|
+
export declare const DEFAULT_POLL_MAX_BACKOFF_MS = 30000;
|
|
32
|
+
/**
|
|
33
|
+
* 默认单次快照的条目上限:2 000。
|
|
34
|
+
*
|
|
35
|
+
* 与目录列举的 `REMOTE_LIST_MAX_ENTRIES` 同量级:一个需要监听的目录通常远小于它,而超过它的目录每
|
|
36
|
+
* 2.5 s 传回并比较一次,成本已经超过"不提供监听"。超过时诚实失败,见模块注释。
|
|
37
|
+
*/
|
|
38
|
+
export declare const DEFAULT_POLL_MAX_ENTRIES = 2000;
|
|
39
|
+
/** 一次快照:一个**可比较的签名**,加上条目数(用于上限判断)。 */
|
|
40
|
+
export interface PollSnapshot {
|
|
41
|
+
/**
|
|
42
|
+
* 稳定签名:两次快照签名相同即"没变"。
|
|
43
|
+
*
|
|
44
|
+
* 由调用方决定包含什么(目录用 `(name, type, size, mtime)` 列表,单文件用 `(type, size, mtime)`),
|
|
45
|
+
* 本模块只做字符串比较——这样"什么算变化"的策略留在知道目标类型的那一侧。
|
|
46
|
+
*/
|
|
47
|
+
readonly signature: string;
|
|
48
|
+
/** 这次快照观察到的条目数;单文件快照是 0。 */
|
|
49
|
+
readonly entries: number;
|
|
50
|
+
}
|
|
51
|
+
/** 轮询参数;全部可注入,测试用小间隔 + 假 sleep 即可,不依赖真实时间。 */
|
|
52
|
+
export interface PollWatchOptions {
|
|
53
|
+
/** 轮询间隔,默认 {@link DEFAULT_POLL_INTERVAL_MS}。 */
|
|
54
|
+
readonly intervalMs?: number;
|
|
55
|
+
/** 连续失败阈值,默认 {@link DEFAULT_POLL_FAILURE_THRESHOLD}。 */
|
|
56
|
+
readonly failureThreshold?: number;
|
|
57
|
+
/** 退避上限,默认 {@link DEFAULT_POLL_MAX_BACKOFF_MS}。 */
|
|
58
|
+
readonly maxBackoffMs?: number;
|
|
59
|
+
/** 单次快照条目上限,默认 {@link DEFAULT_POLL_MAX_ENTRIES}。 */
|
|
60
|
+
readonly maxEntries?: number;
|
|
61
|
+
/**
|
|
62
|
+
* 可注入的等待函数(毫秒 + 取消信号)。
|
|
63
|
+
*
|
|
64
|
+
* 默认实现用 `setTimeout` 并在 abort 时立刻 resolve;测试注入一个"手动推进"的版本,就能在不 sleep
|
|
65
|
+
* 真实时间的前提下精确控制轮询节奏。
|
|
66
|
+
*/
|
|
67
|
+
readonly sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
|
|
68
|
+
/**
|
|
69
|
+
* 哪些错误应当**立刻**上报而不是计入"连续失败"。
|
|
70
|
+
*
|
|
71
|
+
* 用于"这个路径根本不该轮询"的确定性失败(例如目录条目数超过上限):重试三次只会多打三次 ssh,
|
|
72
|
+
* 而结论不会变。默认没有致命错误。
|
|
73
|
+
*/
|
|
74
|
+
readonly isFatal?: (error: unknown) => boolean;
|
|
75
|
+
}
|
|
76
|
+
/** {@link startPollWatch} 的依赖。 */
|
|
77
|
+
export interface PollWatchDeps {
|
|
78
|
+
/** 取一次快照;抛错表示这次轮询失败(连接问题、命令失败等)。 */
|
|
79
|
+
readonly snapshot: (signal: AbortSignal) => Promise<PollSnapshot>;
|
|
80
|
+
/**
|
|
81
|
+
* 建立订阅时已经取到的基线快照。
|
|
82
|
+
*
|
|
83
|
+
* 有它时循环不再立刻多打一次 ssh:建立订阅的那一次快照既是"确认目标可读",也是比较的起点。
|
|
84
|
+
* 没有它时第一次轮询只建立基线、不通知(订阅方刚拿到当前状态,没有"变化"可言)。
|
|
85
|
+
*/
|
|
86
|
+
readonly initial?: PollSnapshot;
|
|
87
|
+
/** 平台回调:无参 = 重新 stat;带 error = 订阅已死。 */
|
|
88
|
+
readonly changed: (error?: Error) => void;
|
|
89
|
+
/** 调用方的取消信号;abort 后循环立刻停止。 */
|
|
90
|
+
readonly signal: AbortSignal;
|
|
91
|
+
/** 轮询参数。 */
|
|
92
|
+
readonly options?: PollWatchOptions;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* 把一次目录列举压成可比较的签名。
|
|
96
|
+
*
|
|
97
|
+
* 四列都参与:**新增/删除**改变条目集合,**改大小**改变 size,**改 mtime** 改变时间戳。只比名字会漏掉
|
|
98
|
+
* "内容变了但名字没变"——那正是文件面板最需要刷新的情况。
|
|
99
|
+
*
|
|
100
|
+
* 名字里可能含制表符(POSIX 允许),所以用 `\u0000` 分隔而不是 `\t`;条目按名字排序,保证同一目录的
|
|
101
|
+
* 两次快照顺序一致(`parseListing` 已经排过,这里再排一次是为了让本函数对任意输入都稳定)。
|
|
102
|
+
*
|
|
103
|
+
* @param entries - 一次目录列举的结果。
|
|
104
|
+
* @returns 稳定签名。
|
|
105
|
+
*/
|
|
106
|
+
export declare function listingSignature(entries: readonly BackendEntry[]): string;
|
|
107
|
+
/**
|
|
108
|
+
* 启动一个轮询循环。
|
|
109
|
+
*
|
|
110
|
+
* 第一次快照只建立基线,**不**通知(订阅方刚拿到当前状态,没有"变化"可言)。之后每次签名不同就调用一次
|
|
111
|
+
* `changed()`;两次轮询之间的多次变化合并成一次通知(契约本来就是"重新 stat 一下",不是精确 diff)。
|
|
112
|
+
*
|
|
113
|
+
* 停止条件只有四个:调用方 abort、调用方调用返回的 stop、连续失败到阈值、快照报出致命错误。每个停止
|
|
114
|
+
* 路径都保证循环不再持有定时器。
|
|
115
|
+
*
|
|
116
|
+
* @param deps - 快照、回调、取消信号与参数。
|
|
117
|
+
* @returns 幂等的停止函数。
|
|
118
|
+
*/
|
|
119
|
+
export declare function startPollWatch(deps: PollWatchDeps): () => Promise<void>;
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The remote backend: one remote directory served over a {@link Transport}.
|
|
3
|
+
*
|
|
4
|
+
* This is the Tier 1 shape — no agent on the remote host, only POSIX tools the server already has
|
|
5
|
+
* (`sh`, `wc`, `stat`, `base64`). Directory listing is therefore one bounded shell script rather
|
|
6
|
+
* than a protocol method, and the script is built from a fixed template with a single quoted path,
|
|
7
|
+
* so there is no injection surface.
|
|
8
|
+
*
|
|
9
|
+
* Version tokens are whatever **one** transport reports for the same file state, and this backend never
|
|
10
|
+
* mixes two sources. On Tier 1 that is the shell `size:mtimeSeconds` shape (comparable with the ssh2
|
|
11
|
+
* transport); on Tier 2 it is the agent's `size:mtimeNs`, obtained from {@link Transport.statPath} so that a
|
|
12
|
+
* token read by `readText` and a token compared by `writeText` come from the same producer. Deriving one
|
|
13
|
+
* from the other is what made every read→write guard fail on Tier 2 (audit 0055).
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-over-ssh/infra/backend/remote-backend
|
|
16
|
+
*/
|
|
17
|
+
import type { Backend, BackendEntry, BackendRead, BackendSandboxMode, BackendStat, BackendText, BackendWrite } from '../../app/backend.ts';
|
|
18
|
+
import type { RemoteCapabilities } from '../../domain/capability.ts';
|
|
19
|
+
import { type VersionedCacheStats } from '../cache/versioned.ts';
|
|
20
|
+
import type { CacheBudget } from '../cache/budget.ts';
|
|
21
|
+
import { type PollWatchOptions } from './poll-watch.ts';
|
|
22
|
+
import type { Transport } from '../transport/transport.ts';
|
|
23
|
+
/** Options for {@link RemoteBackend}. */
|
|
24
|
+
export interface RemoteBackendOptions {
|
|
25
|
+
/** Backend id; the router namespaces target keys with it (equals the root key). */
|
|
26
|
+
readonly id: string;
|
|
27
|
+
/** A ready transport. Mutually exclusive with {@link getTransport}. */
|
|
28
|
+
readonly transport?: Transport;
|
|
29
|
+
/**
|
|
30
|
+
* Lazy transport resolver, called once on first use.
|
|
31
|
+
*
|
|
32
|
+
* The root registry's backend factory is synchronous while acquiring a transport is not, so the
|
|
33
|
+
* connection is established when the first operation needs it and reused afterwards.
|
|
34
|
+
*/
|
|
35
|
+
readonly getTransport?: () => Promise<Transport>;
|
|
36
|
+
/**
|
|
37
|
+
* Transport used **only** for `watch.*` subscriptions, when the caller wants them on a separate
|
|
38
|
+
* (stateful) lane. Omitted means "same transport as everything else" (the pre-lane behaviour).
|
|
39
|
+
*/
|
|
40
|
+
readonly watchTransport?: () => Promise<Transport>;
|
|
41
|
+
/**
|
|
42
|
+
* Transport used **only** for `repo.summary`, when the caller wants it on a separate (compute) lane.
|
|
43
|
+
*
|
|
44
|
+
* Omitted means "same transport as everything else", which is the pre-lane behaviour. Every other operation
|
|
45
|
+
* keeps using {@link getTransport}: reads/writes/listing/watch must stay on the interactive lane.
|
|
46
|
+
*/
|
|
47
|
+
readonly repoSummaryTransport?: () => Promise<Transport>;
|
|
48
|
+
/** Read cap applied by the backend itself; the router passes a smaller one when it wants to. */
|
|
49
|
+
readonly maxReadBytes?: number;
|
|
50
|
+
/**
|
|
51
|
+
* Freshness cache policy for the read path (FR-2.7). Omitted means {@link DEFAULT_CACHE}.
|
|
52
|
+
*
|
|
53
|
+
* This is the switch that makes "re-opening the same file within the TTL costs **zero** round trips"
|
|
54
|
+
* true. Before it existed, `VersionedCache` was written, benchmarked and then never constructed by
|
|
55
|
+
* any production code: every read went to the host, and the numbers in `baseline.md` measured a
|
|
56
|
+
* component nobody called (audit 0044).
|
|
57
|
+
*/
|
|
58
|
+
readonly cache?: RemoteBackendCacheOptions;
|
|
59
|
+
/**
|
|
60
|
+
* Budget shared by **every** remote root's caches.
|
|
61
|
+
*
|
|
62
|
+
* The per-root limits in {@link cache} cannot bound the process: there is one read cache and one listing cache
|
|
63
|
+
* per root, and roots come and go. Supplying a budget adds that missing total bound, and `dispose()` leaves it
|
|
64
|
+
* so a retired root stops counting (audit 0112 P3-12).
|
|
65
|
+
*/
|
|
66
|
+
readonly cacheBudget?: CacheBudget;
|
|
67
|
+
/**
|
|
68
|
+
* Tier 1 轮询监听的参数(间隔、退避、条目上限、可注入的 sleep)。
|
|
69
|
+
*
|
|
70
|
+
* 只在**没有** native watcher 时生效:Tier 2 的 `watch.start/poll/stop` 永远优先,轮询是能力缺口上的
|
|
71
|
+
* 兜底,不是替代品。省略时用 `poll-watch.ts` 的默认值(2.5 s / 3 次失败 / 2000 条目)。
|
|
72
|
+
*/
|
|
73
|
+
readonly pollWatch?: PollWatchOptions;
|
|
74
|
+
/**
|
|
75
|
+
* 可注入时钟:轮询快照共享窗口的 TTL 用。
|
|
76
|
+
*
|
|
77
|
+
* 必须与 `pollWatch.sleep` 走同一个时钟。测试里 sleep 是假的,若 TTL 读真实 `Date.now()`,假时钟推进后
|
|
78
|
+
* 仍会命中缓存,"目录变了"的用例会全部失败——这不是测试的问题,是两个时间源不一致。
|
|
79
|
+
*/
|
|
80
|
+
readonly now?: () => number;
|
|
81
|
+
}
|
|
82
|
+
/** Cache policy for one remote backend. */
|
|
83
|
+
export interface RemoteBackendCacheOptions {
|
|
84
|
+
/** Freshness window; the FR-2.7 budget is 5 s. `0` disables retention (every read goes remote). */
|
|
85
|
+
readonly ttlMs?: number;
|
|
86
|
+
/** Bound on retained entries. */
|
|
87
|
+
readonly maxEntries?: number;
|
|
88
|
+
/** Bound on retained payload bytes. */
|
|
89
|
+
readonly maxBytes?: number;
|
|
90
|
+
/** Injectable clock, so a TTL can be tested without sleeping. */
|
|
91
|
+
readonly now?: () => number;
|
|
92
|
+
}
|
|
93
|
+
/** Filesystem access for one remote root over a transport. */
|
|
94
|
+
export declare class RemoteBackend implements Backend {
|
|
95
|
+
#private;
|
|
96
|
+
readonly id: string;
|
|
97
|
+
readonly sandboxMode: BackendSandboxMode | undefined;
|
|
98
|
+
/**
|
|
99
|
+
* Whether this root has a **native** watcher: the transport is one that speaks `watch.*` *and* its handshake
|
|
100
|
+
* advertised `watch === "native"`. It reads `false` until the transport is known — the capability is a fact
|
|
101
|
+
* about the connected agent, and claiming it early would advertise a watcher for a Tier 1 host that can
|
|
102
|
+
* never serve one.
|
|
103
|
+
*/
|
|
104
|
+
get watches(): boolean;
|
|
105
|
+
constructor(options: RemoteBackendOptions);
|
|
106
|
+
/**
|
|
107
|
+
* Read-path cache counters, for diagnostics and the performance baseline.
|
|
108
|
+
*
|
|
109
|
+
* Reported rather than inferred: the FR-2.7 claims ("0 round trips inside the TTL", "≈5× on
|
|
110
|
+
* revalidation") are only checkable if the counters behind them are reachable from outside.
|
|
111
|
+
*/
|
|
112
|
+
get cacheStats(): {
|
|
113
|
+
readonly reads: VersionedCacheStats;
|
|
114
|
+
readonly listings: VersionedCacheStats;
|
|
115
|
+
};
|
|
116
|
+
/** Remote capabilities discovered so far (probe results live on the transport). */
|
|
117
|
+
capabilities(): Promise<RemoteCapabilities>;
|
|
118
|
+
/**
|
|
119
|
+
* Stat a path, preferring the transport's **own** token source when it has one.
|
|
120
|
+
*
|
|
121
|
+
* This is where the two tiers are reconciled, and it is the *only* place that decides. A transport that
|
|
122
|
+
* exposes {@link Transport.statPath} (Tier 2: the agent's `fs.stat`) reports the very same token its
|
|
123
|
+
* `readFile` reports (`size:mtimeNs`), so a version read by one and compared by the other is comparing
|
|
124
|
+
* like with like. Tier 1 has no native stat, so the shell script below runs exactly as it always has and
|
|
125
|
+
* its long-standing `size:mtimeSeconds` tokens are untouched.
|
|
126
|
+
*
|
|
127
|
+
* Before this, `stat` always ran the shell script: on Tier 2 it produced seconds while every read produced
|
|
128
|
+
* nanoseconds, so the `remote_read` → `remote_write`/`remote_edit` version guard could never pass and the
|
|
129
|
+
* FR-2.7 stale-revalidation branch never matched either.
|
|
130
|
+
*/
|
|
131
|
+
stat(absolute: string, signal?: AbortSignal): Promise<BackendStat>;
|
|
132
|
+
list(absolute: string, signal?: AbortSignal): Promise<readonly BackendEntry[]>;
|
|
133
|
+
/**
|
|
134
|
+
* Read a file, going through the FR-2.7 freshness cache.
|
|
135
|
+
*
|
|
136
|
+
* The three states the budget is written in terms of:
|
|
137
|
+
* - **fresh** (inside the TTL): returned from memory, **zero** round trips;
|
|
138
|
+
* - **stale**: one cheap `stat` decides. If the version still matches, the retained body is reused and
|
|
139
|
+
* only the timestamp is refreshed — this is the ≈5× revalidation path;
|
|
140
|
+
* - **gone or changed**: re-read and replace.
|
|
141
|
+
*
|
|
142
|
+
* A read with a different cap is a different cache entry (see the field notes), so a capped probe can
|
|
143
|
+
* never satisfy a full read.
|
|
144
|
+
*/
|
|
145
|
+
readBytes(absolute: string, maxBytes: number, signal?: AbortSignal): Promise<BackendRead>;
|
|
146
|
+
/**
|
|
147
|
+
* Atomic in-place edit through the agent's `fs.edit` (审计 0030).
|
|
148
|
+
*
|
|
149
|
+
* The caller passes the version it just read; the agent re-checks it, so a concurrent modification
|
|
150
|
+
* between our read and this call is refused rather than clobbered. Throws `REMOTE_UNSUPPORTED` when the
|
|
151
|
+
* agent predates the method, which is what makes `ctx.fs` fall back to read → replace → write.
|
|
152
|
+
*/
|
|
153
|
+
editText(absolute: string, oldString: string, newString: string, replaceAll: boolean, expectedVersion: string | undefined, signal?: AbortSignal): Promise<BackendWrite & {
|
|
154
|
+
readonly replaced: number;
|
|
155
|
+
}>;
|
|
156
|
+
/**
|
|
157
|
+
* Apply a unified diff server-side: every hunk in one round trip, all-or-nothing.
|
|
158
|
+
*
|
|
159
|
+
* No client-side fallback on purpose. Reading the file, patching it here and writing it back would cost
|
|
160
|
+
* three round trips for two hunks — more than the `remote_edit` calls it is meant to replace — and would
|
|
161
|
+
* need a second implementation of the hunk-matching rules.
|
|
162
|
+
*/
|
|
163
|
+
patchText(absolute: string, diff: string, expectedVersion: string | undefined, signal?: AbortSignal): Promise<BackendWrite & {
|
|
164
|
+
readonly hunks: number;
|
|
165
|
+
}>;
|
|
166
|
+
repoSummary(root: string, signal?: AbortSignal): Promise<Record<string, unknown>>;
|
|
167
|
+
patchMany(edits: readonly {
|
|
168
|
+
readonly path: string;
|
|
169
|
+
readonly expectedVersion?: string;
|
|
170
|
+
readonly find?: string;
|
|
171
|
+
readonly replace?: string;
|
|
172
|
+
readonly all?: boolean;
|
|
173
|
+
readonly create?: boolean;
|
|
174
|
+
readonly patch?: string;
|
|
175
|
+
}[], atomic: boolean, signal?: AbortSignal): Promise<Record<string, unknown>>;
|
|
176
|
+
readText(absolute: string, maxBytes: number, signal?: AbortSignal): Promise<BackendText>;
|
|
177
|
+
/**
|
|
178
|
+
* Cache-aware wrapper around {@link readManyUncached}, so a batch sweep leaves the cache warm.
|
|
179
|
+
*
|
|
180
|
+
* Satisfying the whole batch from cache is deliberately **not** attempted: the batch exists to collapse
|
|
181
|
+
* N round trips into one, and splitting it into cached and uncached halves would give back the round trips
|
|
182
|
+
* it was built to save. Cached entries are therefore refreshed from the batch's answer, and files already
|
|
183
|
+
* fresh are still fetched (one shared round trip) rather than removed from the batch.
|
|
184
|
+
*/
|
|
185
|
+
readMany(absolutePaths: readonly string[], options: {
|
|
186
|
+
readonly maxBytesEach: number;
|
|
187
|
+
readonly totalBytesCap?: number;
|
|
188
|
+
readonly signal?: AbortSignal;
|
|
189
|
+
}): Promise<{
|
|
190
|
+
readonly chunks: readonly (BackendText | undefined)[];
|
|
191
|
+
readonly missing: readonly string[];
|
|
192
|
+
}>;
|
|
193
|
+
/**
|
|
194
|
+
* Write text remotely.
|
|
195
|
+
*
|
|
196
|
+
* **The version guard is tier-aware, and that is the point.** The token a caller passes back is one this
|
|
197
|
+
* backend just handed out, so the guard must be verified by the *same* token source:
|
|
198
|
+
*
|
|
199
|
+
* - a transport with a native version token (Tier 2) verifies the token itself through
|
|
200
|
+
* {@link WriteFileRequest.expectedVersion}, which is what its own `fs.write` guard compares — no digest
|
|
201
|
+
* is involved and none could be, because the agent advertises no sha256 tool;
|
|
202
|
+
* - a Tier 1 transport verifies a content digest, so the current content is hashed and handed over as
|
|
203
|
+
* `expectedSha256`. That path is unchanged.
|
|
204
|
+
*
|
|
205
|
+
* A plain write is one round trip plus one `stat` for the new version.
|
|
206
|
+
*/
|
|
207
|
+
writeText(absolute: string, text: string, expectedVersion: string | undefined, signal?: AbortSignal): Promise<BackendWrite>;
|
|
208
|
+
/**
|
|
209
|
+
* Watch one remote path (directory, or one file) through the agent's native watcher.
|
|
210
|
+
*
|
|
211
|
+
* The agent watches **directories**: `watch.start` registers recursively from the given roots, and each
|
|
212
|
+
* reported event carries the path of the changed entry. A file target therefore watches its parent directory
|
|
213
|
+
* and filters events by exact path — the same shape the platform's own local watcher uses, and the only way
|
|
214
|
+
* inotify can report changes to one file.
|
|
215
|
+
*
|
|
216
|
+
* Degradation is **not** silent:
|
|
217
|
+
* - `watch.start` reporting `truncated` (maxWatches / `max_user_watches` cut registration short) means the
|
|
218
|
+
* tree is only partially covered; the watcher is stopped and this call fails rather than promising
|
|
219
|
+
* notifications it cannot deliver;
|
|
220
|
+
* - a poll whose cumulative `dropped` grew means events were lost (queue limit / kernel overflow); the
|
|
221
|
+
* owner is told through `changed(error)` and the watcher stops, so the caller rescans instead of trusting
|
|
222
|
+
* an incomplete stream.
|
|
223
|
+
*
|
|
224
|
+
* `changed()` also covers "nothing to re-stat but something happened" — the platform's contract is
|
|
225
|
+
* "re-stat this path", so events are coalesced per poll.
|
|
226
|
+
*
|
|
227
|
+
* **Tier 1 没有 native watcher 时走轮询**(`poll-watch.ts`):定时取一次"这一层"的快照并比较签名。
|
|
228
|
+
* 这是能力缺口上的兜底,不是替代品——native 可用时永远优先 native。轮询的代价被明确限住:只列被 watch
|
|
229
|
+
* 的那一层(`maxDepth = 1`)、条目数有上限、连续失败到阈值就 `changed(error)` 报死而不是静默重试。
|
|
230
|
+
*
|
|
231
|
+
* @param absolute - root-absolute remote path (directory or file).
|
|
232
|
+
* @param changed - platform callback: no argument = re-stat; an error = the subscription is dead.
|
|
233
|
+
* @param signal - cancels the subscription; stopping is immediate and idempotent.
|
|
234
|
+
* @returns the disposer (stops the watcher; idempotent).
|
|
235
|
+
* @throws SshDevError `REMOTE_NOT_FOUND` when a file target's parent directory does not exist,
|
|
236
|
+
* `CANCELLED` when aborted during setup.
|
|
237
|
+
*/
|
|
238
|
+
watch(absolute: string, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise<void>>;
|
|
239
|
+
/**
|
|
240
|
+
* Stop every watcher this backend started.
|
|
241
|
+
*
|
|
242
|
+
* Not part of {@link Backend}: the wiring owns backend teardown and is outside this change's scope. It is
|
|
243
|
+
* public so whoever retires the backend can end the poll loops, and so tests can prove they stop.
|
|
244
|
+
*/
|
|
245
|
+
dispose(): Promise<void>;
|
|
246
|
+
/**
|
|
247
|
+
* Create a directory (FR-2.2.2).
|
|
248
|
+
*
|
|
249
|
+
* Prefers the agent's `fs.mkdir` (Tier 2) and falls back to one bounded shell script. The shell path is not
|
|
250
|
+
* dead weight: Tier 1 has no agent, and an older agent answers `E_METHOD`, which
|
|
251
|
+
* {@link Transport.makeDir} reports as `REMOTE_UNSUPPORTED`.
|
|
252
|
+
*
|
|
253
|
+
* **Why the protocol path is worth preferring**: the shell version depends on the remote having a POSIX
|
|
254
|
+
* `mkdir`, and its refusals have to be parsed back out of the script's own `DSH_ERROR=` line. Windows
|
|
255
|
+
* targets have neither. The product rule — `recursive: false` refuses a name that is already taken, so a
|
|
256
|
+
* 「新建目录」 dialog cannot silently "succeed" into someone else's folder — is applied **here**, once, for
|
|
257
|
+
* both paths.
|
|
258
|
+
*/
|
|
259
|
+
mkdir(absolute: string, options: {
|
|
260
|
+
readonly recursive: boolean;
|
|
261
|
+
}, signal?: AbortSignal): Promise<void>;
|
|
262
|
+
/**
|
|
263
|
+
* Rename/move a path (FR-2.2.2), refusing to clobber.
|
|
264
|
+
*
|
|
265
|
+
* The no-clobber rule lives in {@link renameCommand}'s script (see its notes): `mv` would either replace a
|
|
266
|
+
* file or move the source *inside* an existing directory, and both are silent.
|
|
267
|
+
*/
|
|
268
|
+
rename(from: string, to: string, options: {
|
|
269
|
+
readonly createParents: boolean;
|
|
270
|
+
}, signal?: AbortSignal): Promise<void>;
|
|
271
|
+
/** Remove a path (FR-2.2.2); `recursive: false` refuses a directory. */
|
|
272
|
+
remove(absolute: string, options: {
|
|
273
|
+
readonly recursive: boolean;
|
|
274
|
+
}, signal?: AbortSignal): Promise<void>;
|
|
275
|
+
/**
|
|
276
|
+
* Change one path's permission bits.
|
|
277
|
+
*
|
|
278
|
+
* Runs through `exec` rather than a transport primitive because the agent has no `fs.chmod`; the same
|
|
279
|
+
* script therefore works on both tiers, which is what keeps `remote_write`'s `mode` from meaning two
|
|
280
|
+
* different things depending on the host (audit 0064 P4). `chmodCommand` re-validates the octal digits, so
|
|
281
|
+
* this is not a place where model-supplied text reaches a shell unchecked.
|
|
282
|
+
*/
|
|
283
|
+
chmod(absolute: string, mode: string, signal?: AbortSignal): Promise<void>;
|
|
284
|
+
/**
|
|
285
|
+
* Create a symbolic link; refuse an existing path.
|
|
286
|
+
*
|
|
287
|
+
* Runs through `exec` for the same reason {@link chmod} does: the agent has no `fs.symlink`, and one script
|
|
288
|
+
* keeps the two tiers from disagreeing about what "create a link" means on this host.
|
|
289
|
+
*/
|
|
290
|
+
symlink(target: string, linkPath: string, signal?: AbortSignal): Promise<void>;
|
|
291
|
+
/** Read a link target; `undefined` means "this is not a link", which is an answer, not an error. */
|
|
292
|
+
readlink(absolute: string, signal?: AbortSignal): Promise<string | undefined>;
|
|
293
|
+
/** Whether this backend's root contains a path (used by the router's `contains`). */
|
|
294
|
+
contains(parent: string, candidate: string): boolean;
|
|
295
|
+
}
|
|
296
|
+
/** Parse `KEY=value` header lines into a map. */
|
|
297
|
+
export declare function parseHeaders(stdout: string): Map<string, string>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A **global** byte budget shared by several caches.
|
|
3
|
+
*
|
|
4
|
+
* The per-cache bounds (`maxEntries` / `maxBytes`) answer "how big may *this* cache get". They cannot answer
|
|
5
|
+
* "how much may the plugin hold in total", because the number of caches is a runtime fact: one read cache plus
|
|
6
|
+
* one listing cache per remote root, and roots are added and removed while the process runs. Before this module
|
|
7
|
+
* ten roots meant ten times the per-root allowance held forever — the bound was per root and nothing related
|
|
8
|
+
* the roots to each other (audit 0112 P3-12).
|
|
9
|
+
*
|
|
10
|
+
* The design is deliberately **not** a shared store: each cache keeps its own entries and its own recency order,
|
|
11
|
+
* and only reports/trims bytes. A shared store would have to interleave several key spaces (route keys, listing
|
|
12
|
+
* keys, different value types) and lose each cache's own `maxEntries`.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-over-ssh/infra/cache/budget
|
|
15
|
+
*/
|
|
16
|
+
/** One cache taking part in the budget. Implemented by `VersionedCache`. */
|
|
17
|
+
export interface CacheBudgetMember {
|
|
18
|
+
/** Bytes this member currently holds. */
|
|
19
|
+
readonly bytes: number;
|
|
20
|
+
/**
|
|
21
|
+
* Drop this member's oldest entries until it holds at most `bytes`.
|
|
22
|
+
*
|
|
23
|
+
* @param bytes - the ceiling to come down to.
|
|
24
|
+
* @returns how many entries were dropped.
|
|
25
|
+
*/
|
|
26
|
+
trimTo(bytes: number): number;
|
|
27
|
+
/**
|
|
28
|
+
* Drop the single oldest entry.
|
|
29
|
+
*
|
|
30
|
+
* @returns `false` when the member holds nothing, so the caller does not spin.
|
|
31
|
+
*/
|
|
32
|
+
evictOldest(): boolean;
|
|
33
|
+
}
|
|
34
|
+
/** Limits for {@link createCacheBudget}. */
|
|
35
|
+
export interface CacheBudgetLimits {
|
|
36
|
+
/** Ceiling for the **sum** over every member. */
|
|
37
|
+
readonly maxBytes: number;
|
|
38
|
+
}
|
|
39
|
+
/** The shared budget. */
|
|
40
|
+
export interface CacheBudget {
|
|
41
|
+
/** Register a member. Registering twice is a no-op. */
|
|
42
|
+
join(member: CacheBudgetMember): void;
|
|
43
|
+
/** Stop accounting for a member (its root went away). */
|
|
44
|
+
leave(member: CacheBudgetMember): void;
|
|
45
|
+
/**
|
|
46
|
+
* Bring the sum back under the ceiling after `grown` added bytes.
|
|
47
|
+
*
|
|
48
|
+
* @param grown - the member that just grew, for diagnostics; it gets no special treatment.
|
|
49
|
+
*/
|
|
50
|
+
enforce(grown?: CacheBudgetMember): void;
|
|
51
|
+
/** Bytes held by every member. */
|
|
52
|
+
readonly totalBytes: number;
|
|
53
|
+
/** The ceiling. */
|
|
54
|
+
readonly maxBytes: number;
|
|
55
|
+
/** How many members are registered. */
|
|
56
|
+
readonly memberCount: number;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Build a budget.
|
|
60
|
+
*
|
|
61
|
+
* Enforcement evicts from the **largest** member rather than from the one that just grew: the grower is usually
|
|
62
|
+
* what a reader is looking at right now (an open file, the directory being browsed), while the largest cache is
|
|
63
|
+
* where the pressure actually is. With one member the two rules coincide.
|
|
64
|
+
*
|
|
65
|
+
* @param limits - the total ceiling.
|
|
66
|
+
* @returns the budget.
|
|
67
|
+
*/
|
|
68
|
+
export declare function createCacheBudget(limits: CacheBudgetLimits): CacheBudget;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TTL + version cache used by the remote read path.
|
|
3
|
+
*
|
|
4
|
+
* The performance budget depends on this three-state behaviour:
|
|
5
|
+
* - within TTL: a hit with zero remote round trips;
|
|
6
|
+
* - past TTL: a *stale* hit whose body may still be reused if a cheap version check matches
|
|
7
|
+
* (this is what makes revalidation ≈5× cheaper than a first open);
|
|
8
|
+
* - invalidated: no usable body.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-over-ssh/infra/cache/versioned
|
|
11
|
+
*/
|
|
12
|
+
import type { CacheBudget, CacheBudgetMember } from './budget.ts';
|
|
13
|
+
/** Cache policy for one cache instance. */
|
|
14
|
+
export interface VersionedCacheOptions {
|
|
15
|
+
/** Freshness window in milliseconds. */
|
|
16
|
+
readonly ttlMs: number;
|
|
17
|
+
readonly maxEntries: number;
|
|
18
|
+
readonly maxBytes: number;
|
|
19
|
+
/** Monotonic-or-wall clock; injectable so tests are deterministic. */
|
|
20
|
+
readonly now?: () => number;
|
|
21
|
+
/**
|
|
22
|
+
* A budget shared with the other caches of the same plugin.
|
|
23
|
+
*
|
|
24
|
+
* Omitting it keeps today's per-cache behaviour (this instance's own limits still apply); supplying it adds
|
|
25
|
+
* the missing **total** bound: when the sum over every member exceeds the budget, the largest cache gives
|
|
26
|
+
* bytes back. Without it, N remote roots hold N times the per-root allowance (audit 0112 P3-12).
|
|
27
|
+
*/
|
|
28
|
+
readonly budget?: CacheBudget;
|
|
29
|
+
}
|
|
30
|
+
/** One lookup result. */
|
|
31
|
+
export interface VersionedHit<V> {
|
|
32
|
+
readonly value: V;
|
|
33
|
+
/** The version token recorded when the body was cached. */
|
|
34
|
+
readonly version: string;
|
|
35
|
+
/** Whether the entry is still inside its freshness window. */
|
|
36
|
+
readonly fresh: boolean;
|
|
37
|
+
readonly ageMs: number;
|
|
38
|
+
}
|
|
39
|
+
/** Counters reported for diagnostics and the performance baseline. */
|
|
40
|
+
export interface VersionedCacheStats {
|
|
41
|
+
readonly entries: number;
|
|
42
|
+
readonly bytes: number;
|
|
43
|
+
readonly hits: number;
|
|
44
|
+
readonly misses: number;
|
|
45
|
+
readonly freshHits: number;
|
|
46
|
+
readonly staleHits: number;
|
|
47
|
+
/** Version checks that reused an existing body. */
|
|
48
|
+
readonly revalidations: number;
|
|
49
|
+
readonly evictions: number;
|
|
50
|
+
}
|
|
51
|
+
/** Freshness-aware cache keyed by route key. */
|
|
52
|
+
export declare class VersionedCache<V> implements CacheBudgetMember {
|
|
53
|
+
#private;
|
|
54
|
+
constructor(options: VersionedCacheOptions, sizeOf: (value: V) => number);
|
|
55
|
+
/** Bytes this cache holds (the budget member contract). */
|
|
56
|
+
get bytes(): number;
|
|
57
|
+
/** Drop oldest entries down to `maxBytes` (the budget member contract). */
|
|
58
|
+
trimTo(maxBytes: number): number;
|
|
59
|
+
/** Drop the oldest entry (the budget member contract). */
|
|
60
|
+
evictOldest(): boolean;
|
|
61
|
+
/**
|
|
62
|
+
* Leave the shared budget, so a retired cache stops counting against the total.
|
|
63
|
+
*
|
|
64
|
+
* Called by whoever owns the cache when its root goes away. Without it a removed root's cache stayed in the
|
|
65
|
+
* accounting forever, which is the leak the budget exists to prevent.
|
|
66
|
+
*/
|
|
67
|
+
leaveBudget(): void;
|
|
68
|
+
/**
|
|
69
|
+
* Look up an entry.
|
|
70
|
+
*
|
|
71
|
+
* @param key - route key.
|
|
72
|
+
* @returns the entry with its freshness, or `undefined` when nothing is retained.
|
|
73
|
+
*/
|
|
74
|
+
get(key: string): VersionedHit<V> | undefined;
|
|
75
|
+
/** Insert or replace an entry, stamping it as just-read. */
|
|
76
|
+
set(key: string, value: V, version: string): boolean;
|
|
77
|
+
/**
|
|
78
|
+
* Refresh an entry's timestamp after a cheap remote version check matched.
|
|
79
|
+
*
|
|
80
|
+
* @param key - route key.
|
|
81
|
+
* @param version - the version observed by the check; a mismatch refuses the touch.
|
|
82
|
+
* @returns whether the entry was refreshed.
|
|
83
|
+
*/
|
|
84
|
+
touch(key: string, version: string): boolean;
|
|
85
|
+
/** Drop one entry. */
|
|
86
|
+
invalidate(key: string): boolean;
|
|
87
|
+
/**
|
|
88
|
+
* Drop every entry whose key starts with the prefix, used when a directory epoch changes.
|
|
89
|
+
*
|
|
90
|
+
* @param prefix - key prefix.
|
|
91
|
+
* @returns the number of removed entries.
|
|
92
|
+
*/
|
|
93
|
+
invalidatePrefix(prefix: string): number;
|
|
94
|
+
/** Remove everything. */
|
|
95
|
+
clear(): void;
|
|
96
|
+
/** Current counters. */
|
|
97
|
+
get stats(): VersionedCacheStats;
|
|
98
|
+
}
|