@melaya/runner 1.1.61 → 1.1.63

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.
@@ -0,0 +1,15 @@
1
+ /**
2
+ * `melaya-runner obsidian-rest` — connect the Obsidian Local REST API plugin
3
+ * of a vault registered on this computer, by hand (obsidianApp.ts).
4
+ *
5
+ * The usual path is the vault's settings in Melaya ("Obsidian app" →
6
+ * Connect), where the runner reads the plugin's own settings. This command is
7
+ * for setups where that fails (custom port, another config folder): the API
8
+ * key is typed HERE, never into Melaya, and the certificate's fingerprint is
9
+ * shown for the user to confirm before it is pinned.
10
+ *
11
+ * melaya-runner obsidian-rest list vaults and their App status
12
+ * melaya-runner obsidian-rest --vault "Work" [--url https://127.0.0.1:27124]
13
+ * melaya-runner obsidian-rest --vault "Work" --disconnect
14
+ */
15
+ export declare function obsidianRestCli(): Promise<void>;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * `melaya-runner obsidian-rest` — connect the Obsidian Local REST API plugin
3
+ * of a vault registered on this computer, by hand (obsidianApp.ts).
4
+ *
5
+ * The usual path is the vault's settings in Melaya ("Obsidian app" →
6
+ * Connect), where the runner reads the plugin's own settings. This command is
7
+ * for setups where that fails (custom port, another config folder): the API
8
+ * key is typed HERE, never into Melaya, and the certificate's fingerprint is
9
+ * shown for the user to confirm before it is pinned.
10
+ *
11
+ * melaya-runner obsidian-rest list vaults and their App status
12
+ * melaya-runner obsidian-rest --vault "Work" [--url https://127.0.0.1:27124]
13
+ * melaya-runner obsidian-rest --vault "Work" --disconnect
14
+ */
15
+ import readline from "readline";
16
+ import { VaultRegistry } from "./vaults.js";
17
+ import { AppLocalConfig, ObsidianAppBridge, normalizeFingerprint, parseRestUrl, peerFingerprint, pinnedRequest } from "./obsidianApp.js";
18
+ function arg(name) {
19
+ const i = process.argv.indexOf(`--${name}`);
20
+ if (i >= 0 && process.argv[i + 1] && !process.argv[i + 1].startsWith("--"))
21
+ return process.argv[i + 1];
22
+ const eq = process.argv.find((a) => a.startsWith(`--${name}=`));
23
+ return eq ? eq.slice(name.length + 3) : null;
24
+ }
25
+ function ask(question, hidden = false) {
26
+ return new Promise((resolve) => {
27
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
28
+ if (hidden) {
29
+ // Echo nothing while the key is typed.
30
+ rl._writeToOutput = (s) => {
31
+ if (s.includes(question))
32
+ process.stdout.write(question);
33
+ };
34
+ }
35
+ rl.question(question, (a) => {
36
+ rl.close();
37
+ if (hidden)
38
+ process.stdout.write("\n");
39
+ resolve(a.trim());
40
+ });
41
+ });
42
+ }
43
+ export async function obsidianRestCli() {
44
+ const registry = new VaultRegistry();
45
+ registry.load();
46
+ const vaults = registry.list();
47
+ const want = arg("vault");
48
+ if (!want) {
49
+ if (!vaults.length) {
50
+ console.log("No Obsidian vault is registered on this computer. Connect one in Melaya: Connectors → Obsidian.");
51
+ return;
52
+ }
53
+ const bridge = new ObsidianAppBridge({ dir: registry.dir });
54
+ for (const v of vaults) {
55
+ const st = await bridge.status(v).catch(() => null);
56
+ console.log(`${v.label} (${v.id}) CLI: ${st?.cli ?? "?"} Local REST API: ${st?.rest ?? "?"}`);
57
+ }
58
+ console.log("\nConnect a vault's Local REST API: melaya-runner obsidian-rest --vault \"<name>\"");
59
+ return;
60
+ }
61
+ const reg = vaults.find((v) => v.id === want) ?? vaults.find((v) => v.label.toLowerCase() === want.toLowerCase());
62
+ if (!reg) {
63
+ console.error(`No vault named "${want}" is registered on this computer.`);
64
+ process.exitCode = 1;
65
+ return;
66
+ }
67
+ const config = new AppLocalConfig(registry.dir);
68
+ if (process.argv.includes("--disconnect")) {
69
+ config.setRest(reg.id, null);
70
+ console.log(`Disconnected the Local REST API of "${reg.label}". The key was removed from this computer.`);
71
+ return;
72
+ }
73
+ const url = arg("url") || "https://127.0.0.1:27124";
74
+ if (!parseRestUrl(url)) {
75
+ console.error("The address must be https on this computer, e.g. https://127.0.0.1:27124");
76
+ process.exitCode = 1;
77
+ return;
78
+ }
79
+ let fp;
80
+ try {
81
+ fp = await peerFingerprint(url);
82
+ }
83
+ catch {
84
+ console.error(`Nothing answers at ${url}. Open Obsidian with the Local REST API plugin on (HTTPS server enabled).`);
85
+ process.exitCode = 1;
86
+ return;
87
+ }
88
+ console.log(`\nCertificate presented at ${url}`);
89
+ console.log(`SHA-256 ${fp.match(/.{2}/g)?.join(":")}`);
90
+ console.log("Compare it with the plugin's certificate (Obsidian → Settings → Local REST API).");
91
+ const yes = (await ask("Trust this certificate for this vault? [y/N] ")).toLowerCase();
92
+ if (yes !== "y" && yes !== "yes") {
93
+ console.log("Not connected.");
94
+ return;
95
+ }
96
+ const apiKey = await ask("API key (from the plugin's settings; stays on this computer): ", true);
97
+ if (apiKey.length < 16 || /\s/.test(apiKey)) {
98
+ console.error("That does not look like the plugin's API key.");
99
+ process.exitCode = 1;
100
+ return;
101
+ }
102
+ const cfg = { url, apiKey, fingerprint256: normalizeFingerprint(fp), headerName: "Authorization", connectedAt: new Date().toISOString() };
103
+ try {
104
+ const r = await pinnedRequest(cfg, { method: "GET", path: "/", headers: { Authorization: `Bearer ${apiKey}` }, timeoutMs: 8000 });
105
+ let j = null;
106
+ try {
107
+ j = JSON.parse(r.body.toString("utf-8"));
108
+ }
109
+ catch { /* not JSON */ }
110
+ if (r.status === 401 || r.status === 403 || j?.authenticated === false) {
111
+ console.error("The plugin refused that key.");
112
+ process.exitCode = 1;
113
+ return;
114
+ }
115
+ }
116
+ catch {
117
+ console.error("The connection failed after the certificate check.");
118
+ process.exitCode = 1;
119
+ return;
120
+ }
121
+ config.setRest(reg.id, cfg);
122
+ console.log(`Connected "${reg.label}". Agents can now use the Obsidian app tools that need the Local REST API.`);
123
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Cloud copy of device vaults — the runner's coordination side
3
+ * (docs/plans/obsidian-vault-connector.md §6.4, S4).
4
+ *
5
+ * When the owner sets a device vault's policy to `storage: mirror`, the server
6
+ * pushes `runner:vault-mirror {vaultId, enabled, capability, expiresAt}` to the
7
+ * computer that holds it (again on every reconnect). The capability is a
8
+ * mirror-scoped Ed25519 token (aud `vault-mirror`, one vault, ≤ 10 minutes):
9
+ * it lives in this process's memory only and is renewed over the socket
10
+ * (`runner:vault-mirror-renew`), never written to disk, never logged.
11
+ *
12
+ * This class decides WHEN to sync; the Python host (localVault.py →
13
+ * localVaultMirror.py) does the round itself over HTTPS:
14
+ * - after an index change (host status with a new indexRev), debounced;
15
+ * - when a capability arrives (enable, reconnect: uploads the device journal
16
+ * written while offline);
17
+ * - every 10 minutes (settings files are not watched);
18
+ * - after a resume from sleep: a full stat reconciliation first (`changed`
19
+ * with null), since fs.watch misses changes made while asleep.
20
+ * One sync runs at a time (all vaults); requests for a vault that is already
21
+ * queued or running coalesce into one more round.
22
+ *
23
+ * Status goes to the server as `runner:vault-mirror-status {vaultId, state,
24
+ * pending, error?}`; the builder reports each published revision itself.
25
+ */
26
+ import type { VaultHostStatusEvent, VaultHostTransport } from "./vaults.js";
27
+ export type MirrorState = "syncing" | "ready" | "partial" | "retry" | "busy" | "error" | "quota" | "auth" | "off";
28
+ export interface MirrorStatus {
29
+ vaultId: string;
30
+ state: MirrorState;
31
+ pending?: number;
32
+ error?: string;
33
+ publishedSeq?: number;
34
+ replicaRev?: number;
35
+ }
36
+ export interface VaultMirrorOpts {
37
+ host: VaultHostTransport;
38
+ /** The vault is registered (and opened) on this runner. */
39
+ isRegistered: (vaultId: string) => boolean;
40
+ /** https base of the mirror routes for one vault (server proxy). */
41
+ apiBase: (vaultId: string) => string;
42
+ /** Socket emit (renew requests, status). */
43
+ emit: (event: string, payload: Record<string, unknown>) => void;
44
+ log?: (m: string) => void;
45
+ now?: () => number;
46
+ debounceMs?: number;
47
+ periodicMs?: number;
48
+ tickMs?: number;
49
+ /** Start the periodic timer (off in unit tests, which call tick()). */
50
+ timers?: boolean;
51
+ }
52
+ export declare class VaultMirrorManager {
53
+ private readonly o;
54
+ private readonly grants;
55
+ private readonly due;
56
+ private readonly lastSync;
57
+ private readonly lastIndexRev;
58
+ private readonly failures;
59
+ private readonly renewAsked;
60
+ private readonly status;
61
+ private running;
62
+ private wake;
63
+ private ticker;
64
+ private lastTick;
65
+ private stopped;
66
+ constructor(o: VaultMirrorOpts);
67
+ private now;
68
+ private get debounceMs();
69
+ private get periodicMs();
70
+ private get tickMs();
71
+ /** Vaults with a live mirror capability on this runner. */
72
+ mirrored(): string[];
73
+ latest(vaultId: string): MirrorStatus | undefined;
74
+ /** Statuses to re-send after a reconnect. */
75
+ latestStatuses(): MirrorStatus[];
76
+ /** runner:vault-mirror */
77
+ setGrant(p: {
78
+ vaultId?: unknown;
79
+ enabled?: unknown;
80
+ capability?: unknown;
81
+ expiresAt?: unknown;
82
+ }): void;
83
+ /** Index status from the host: a new revision means files changed. */
84
+ onIndexStatus(ev: VaultHostStatusEvent): void;
85
+ /** The vault was unregistered from this runner. */
86
+ forget(vaultId: string): void;
87
+ /** Ask for a round of `vaultId` in `delayMs` (an earlier request wins). */
88
+ schedule(vaultId: string, delayMs: number): void;
89
+ /** Periodic housekeeping: sleep detection and the 10-minute round. */
90
+ tick(): void;
91
+ /** Resolves once nothing is running or due now (tests, shutdown). */
92
+ idle(): Promise<void>;
93
+ stop(): void;
94
+ private startTicker;
95
+ private nextDue;
96
+ private arm;
97
+ private pump;
98
+ private report;
99
+ private requestRenew;
100
+ private backoff;
101
+ private runOne;
102
+ }
103
+ /** Server proxy base for one vault's mirror routes. */
104
+ export declare function mirrorApiBase(serverHttpBase: string, vaultId: string): string;
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Cloud copy of device vaults — the runner's coordination side
3
+ * (docs/plans/obsidian-vault-connector.md §6.4, S4).
4
+ *
5
+ * When the owner sets a device vault's policy to `storage: mirror`, the server
6
+ * pushes `runner:vault-mirror {vaultId, enabled, capability, expiresAt}` to the
7
+ * computer that holds it (again on every reconnect). The capability is a
8
+ * mirror-scoped Ed25519 token (aud `vault-mirror`, one vault, ≤ 10 minutes):
9
+ * it lives in this process's memory only and is renewed over the socket
10
+ * (`runner:vault-mirror-renew`), never written to disk, never logged.
11
+ *
12
+ * This class decides WHEN to sync; the Python host (localVault.py →
13
+ * localVaultMirror.py) does the round itself over HTTPS:
14
+ * - after an index change (host status with a new indexRev), debounced;
15
+ * - when a capability arrives (enable, reconnect: uploads the device journal
16
+ * written while offline);
17
+ * - every 10 minutes (settings files are not watched);
18
+ * - after a resume from sleep: a full stat reconciliation first (`changed`
19
+ * with null), since fs.watch misses changes made while asleep.
20
+ * One sync runs at a time (all vaults); requests for a vault that is already
21
+ * queued or running coalesce into one more round.
22
+ *
23
+ * Status goes to the server as `runner:vault-mirror-status {vaultId, state,
24
+ * pending, error?}`; the builder reports each published revision itself.
25
+ */
26
+ const VAULT_ID_RE = /^vlt_[A-Za-z0-9]{1,64}$/;
27
+ const RENEW_MARGIN_S = 120;
28
+ const MAX_BUDGET_S = 600;
29
+ const MAX_BACKOFF_MS = 30 * 60_000;
30
+ export class VaultMirrorManager {
31
+ o;
32
+ grants = new Map();
33
+ due = new Map();
34
+ lastSync = new Map();
35
+ lastIndexRev = new Map();
36
+ failures = new Map();
37
+ renewAsked = new Map();
38
+ status = new Map();
39
+ running = null;
40
+ wake = null;
41
+ ticker = null;
42
+ lastTick;
43
+ stopped = false;
44
+ constructor(o) {
45
+ this.o = o;
46
+ this.lastTick = this.now();
47
+ }
48
+ now() { return (this.o.now ?? Date.now)(); }
49
+ get debounceMs() { return this.o.debounceMs ?? 5_000; }
50
+ get periodicMs() { return this.o.periodicMs ?? 10 * 60_000; }
51
+ get tickMs() { return this.o.tickMs ?? 30_000; }
52
+ /** Vaults with a live mirror capability on this runner. */
53
+ mirrored() { return Array.from(this.grants.keys()); }
54
+ latest(vaultId) { return this.status.get(vaultId); }
55
+ /** Statuses to re-send after a reconnect. */
56
+ latestStatuses() { return Array.from(this.status.values()); }
57
+ /** runner:vault-mirror */
58
+ setGrant(p) {
59
+ const vaultId = String(p?.vaultId || "");
60
+ if (!VAULT_ID_RE.test(vaultId) || this.stopped)
61
+ return;
62
+ if (p?.enabled === false) {
63
+ const had = this.grants.delete(vaultId);
64
+ this.due.delete(vaultId);
65
+ this.renewAsked.delete(vaultId);
66
+ if (had || this.status.has(vaultId)) {
67
+ this.o.host.request("mirror_drop", { vaultId }, 30_000).catch(() => { });
68
+ }
69
+ this.report({ vaultId, state: "off" });
70
+ this.status.delete(vaultId);
71
+ return;
72
+ }
73
+ const capability = typeof p?.capability === "string" ? p.capability : "";
74
+ const exp = Number(p?.expiresAt);
75
+ if (!capability || !Number.isFinite(exp))
76
+ return;
77
+ this.grants.set(vaultId, { capability, exp });
78
+ this.renewAsked.delete(vaultId);
79
+ this.startTicker();
80
+ this.schedule(vaultId, 0);
81
+ }
82
+ /** Index status from the host: a new revision means files changed. */
83
+ onIndexStatus(ev) {
84
+ if (!this.grants.has(ev.vaultId) || ev.status !== "ready")
85
+ return;
86
+ const rev = Number(ev.indexRev);
87
+ if (!Number.isFinite(rev))
88
+ return;
89
+ const prev = this.lastIndexRev.get(ev.vaultId);
90
+ this.lastIndexRev.set(ev.vaultId, rev);
91
+ if (prev === undefined || rev !== prev)
92
+ this.schedule(ev.vaultId, this.debounceMs);
93
+ }
94
+ /** The vault was unregistered from this runner. */
95
+ forget(vaultId) {
96
+ this.grants.delete(vaultId);
97
+ this.due.delete(vaultId);
98
+ this.status.delete(vaultId);
99
+ this.lastIndexRev.delete(vaultId);
100
+ }
101
+ /** Ask for a round of `vaultId` in `delayMs` (an earlier request wins). */
102
+ schedule(vaultId, delayMs) {
103
+ if (this.stopped || !this.grants.has(vaultId))
104
+ return;
105
+ const at = this.now() + Math.max(0, delayMs);
106
+ const cur = this.due.get(vaultId);
107
+ if (cur === undefined || at < cur)
108
+ this.due.set(vaultId, at);
109
+ this.arm();
110
+ }
111
+ /** Periodic housekeeping: sleep detection and the 10-minute round. */
112
+ tick() {
113
+ const now = this.now();
114
+ const gap = now - this.lastTick;
115
+ this.lastTick = now;
116
+ if (gap > this.tickMs * 3) {
117
+ // Resumed from sleep (or the clock jumped): fs.watch missed whatever
118
+ // happened meanwhile. A full stat-only reconciliation, then a round.
119
+ for (const vaultId of this.grants.keys()) {
120
+ if (!this.o.isRegistered(vaultId))
121
+ continue;
122
+ this.o.host.request("changed", { vaultId, paths: null }, 10 * 60_000).catch(() => { });
123
+ this.schedule(vaultId, this.debounceMs * 2);
124
+ }
125
+ }
126
+ for (const vaultId of this.grants.keys()) {
127
+ if (now - (this.lastSync.get(vaultId) ?? 0) >= this.periodicMs)
128
+ this.schedule(vaultId, 0);
129
+ }
130
+ this.arm();
131
+ }
132
+ /** Resolves once nothing is running or due now (tests, shutdown). */
133
+ async idle() {
134
+ for (let i = 0; i < 100; i++) {
135
+ if (this.running) {
136
+ await this.running;
137
+ continue;
138
+ }
139
+ const next = this.nextDue();
140
+ if (!next || next.at > this.now())
141
+ return;
142
+ this.pump();
143
+ }
144
+ }
145
+ stop() {
146
+ this.stopped = true;
147
+ if (this.wake)
148
+ clearTimeout(this.wake);
149
+ if (this.ticker)
150
+ clearInterval(this.ticker);
151
+ this.wake = null;
152
+ this.ticker = null;
153
+ }
154
+ // ── internals ──
155
+ startTicker() {
156
+ if (this.ticker || this.o.timers === false)
157
+ return;
158
+ this.lastTick = this.now();
159
+ this.ticker = setInterval(() => this.tick(), this.tickMs);
160
+ this.ticker.unref?.();
161
+ }
162
+ nextDue() {
163
+ let best = null;
164
+ for (const [vaultId, at] of this.due)
165
+ if (!best || at < best.at)
166
+ best = { vaultId, at };
167
+ return best;
168
+ }
169
+ arm() {
170
+ if (this.stopped || this.running)
171
+ return;
172
+ const next = this.nextDue();
173
+ if (this.wake) {
174
+ clearTimeout(this.wake);
175
+ this.wake = null;
176
+ }
177
+ if (!next)
178
+ return;
179
+ const delay = Math.max(0, next.at - this.now());
180
+ if (delay === 0) {
181
+ queueMicrotask(() => this.pump());
182
+ return;
183
+ }
184
+ if (this.o.timers === false)
185
+ return; // tests drive time
186
+ this.wake = setTimeout(() => { this.wake = null; this.pump(); }, delay);
187
+ this.wake.unref?.();
188
+ }
189
+ pump() {
190
+ if (this.running || this.stopped)
191
+ return;
192
+ const next = this.nextDue();
193
+ if (!next || next.at > this.now()) {
194
+ this.arm();
195
+ return;
196
+ }
197
+ this.due.delete(next.vaultId);
198
+ this.running = this.runOne(next.vaultId)
199
+ .catch((e) => this.o.log?.(`mirror ${next.vaultId}: ${String(e?.message || e).slice(0, 200)}`))
200
+ .finally(() => { this.running = null; this.arm(); });
201
+ }
202
+ report(s) {
203
+ const prev = this.status.get(s.vaultId);
204
+ this.status.set(s.vaultId, s);
205
+ if (prev && prev.state === s.state && prev.pending === s.pending && prev.error === s.error)
206
+ return;
207
+ const payload = { vaultId: s.vaultId, state: s.state };
208
+ if (s.pending !== undefined)
209
+ payload.pending = s.pending;
210
+ if (s.error)
211
+ payload.error = s.error.slice(0, 300);
212
+ if (s.replicaRev !== undefined)
213
+ payload.replicaRev = s.replicaRev;
214
+ try {
215
+ this.o.emit("runner:vault-mirror-status", payload);
216
+ }
217
+ catch { /* disconnected */ }
218
+ }
219
+ requestRenew(vaultId) {
220
+ const asked = this.renewAsked.get(vaultId) ?? 0;
221
+ if (this.now() - asked < 60_000)
222
+ return;
223
+ this.renewAsked.set(vaultId, this.now());
224
+ try {
225
+ this.o.emit("runner:vault-mirror-renew", { vaultId });
226
+ }
227
+ catch { /* disconnected: re-pushed on hello */ }
228
+ }
229
+ backoff(vaultId, baseMs) {
230
+ const n = (this.failures.get(vaultId) ?? 0) + 1;
231
+ this.failures.set(vaultId, n);
232
+ return Math.min(MAX_BACKOFF_MS, baseMs * 2 ** Math.min(n - 1, 10));
233
+ }
234
+ async runOne(vaultId) {
235
+ const grant = this.grants.get(vaultId);
236
+ if (!grant)
237
+ return;
238
+ if (!this.o.isRegistered(vaultId)) {
239
+ this.schedule(vaultId, 60_000);
240
+ return;
241
+ }
242
+ const remaining = grant.exp - Math.floor(this.now() / 1000);
243
+ if (remaining < RENEW_MARGIN_S) {
244
+ // A round needs a capability that outlives it: renew first; the next
245
+ // grant schedules the round.
246
+ this.requestRenew(vaultId);
247
+ return;
248
+ }
249
+ this.report({ vaultId, state: "syncing", pending: this.status.get(vaultId)?.pending });
250
+ const budget = Math.max(30, Math.min(MAX_BUDGET_S, remaining - 60));
251
+ let r;
252
+ try {
253
+ r = await this.o.host.request("mirror_sync", {
254
+ vaultId,
255
+ token: grant.capability,
256
+ apiBase: this.o.apiBase(vaultId),
257
+ budgetSeconds: budget,
258
+ }, (budget + 180) * 1000);
259
+ }
260
+ catch (e) {
261
+ // host restarting / vault not reopened yet: try again shortly
262
+ this.report({ vaultId, state: "retry", error: String(e?.message || "vault_host_failed").slice(0, 120) });
263
+ this.schedule(vaultId, this.backoff(vaultId, 30_000));
264
+ return;
265
+ }
266
+ this.lastSync.set(vaultId, this.now());
267
+ const state = String(r?.state || "error");
268
+ const pending = Number.isFinite(Number(r?.pending)) ? Number(r.pending) : undefined;
269
+ const base = { vaultId, pending, replicaRev: Number.isFinite(Number(r?.replicaRev)) ? Number(r.replicaRev) : undefined };
270
+ switch (state) {
271
+ case "ready":
272
+ this.failures.delete(vaultId);
273
+ this.report({ ...base, state: "ready" });
274
+ break;
275
+ case "partial":
276
+ // more to send (budget or journal batch): continue right away
277
+ this.report({ ...base, state: "syncing" });
278
+ this.schedule(vaultId, 0);
279
+ break;
280
+ case "busy":
281
+ this.schedule(vaultId, 5_000);
282
+ break;
283
+ case "auth":
284
+ this.report({ ...base, state: "auth" });
285
+ this.grants.set(vaultId, { ...grant, exp: 0 });
286
+ this.requestRenew(vaultId);
287
+ break;
288
+ case "quota":
289
+ this.report({ ...base, state: "quota", error: String(r?.error || "quota") });
290
+ this.schedule(vaultId, this.backoff(vaultId, 15 * 60_000));
291
+ break;
292
+ case "retry":
293
+ this.report({ ...base, state: "retry" });
294
+ this.schedule(vaultId, Math.max(this.debounceMs, this.backoff(vaultId, 15_000)));
295
+ break;
296
+ default:
297
+ this.report({ ...base, state: "error", error: String(r?.error || "sync failed") });
298
+ this.schedule(vaultId, this.backoff(vaultId, 60_000));
299
+ }
300
+ }
301
+ }
302
+ /** Server proxy base for one vault's mirror routes. */
303
+ export function mirrorApiBase(serverHttpBase, vaultId) {
304
+ return `${serverHttpBase.replace(/\/+$/, "")}/api/v1/vault-api/mirror/${encodeURIComponent(vaultId)}`;
305
+ }