@dsh-electron/dsh-plugin-multi-root-workspace 0.1.0

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/lib/command.js ADDED
@@ -0,0 +1,2 @@
1
+ import { a as renderRootsReport, i as parseFoldersCommand, n as apply, o as revealArgv, r as inject, t as COMMAND_NAME } from "./command-BSkrYOAf.js";
2
+ export { COMMAND_NAME, apply, inject, parseFoldersCommand, renderRootsReport, revealArgv };
@@ -0,0 +1,152 @@
1
+ import { FsError } from "@deepseek-ai/dsh-fs";
2
+ import { LocalFileSystem } from "@deepseek-ai/dsh-fs-local";
3
+ import { writableRoots } from "@deepseek-ai/dsh-sandbox";
4
+ import { stat } from "node:fs/promises";
5
+ import { dirname, sep } from "node:path";
6
+ //#region src/containment.ts
7
+ const MISSING_CODES = /* @__PURE__ */ new Set(["ENOENT", "ENOTDIR"]);
8
+ function isMissing(error) {
9
+ const code = error.code;
10
+ return MISSING_CODES.has(code);
11
+ }
12
+ function comparablePath(path, caseSensitive) {
13
+ return caseSensitive ? path : path.toLowerCase();
14
+ }
15
+ function isLexicallyUnder(path, root, caseSensitive) {
16
+ const comparableTarget = comparablePath(path, caseSensitive);
17
+ const comparableRoot = comparablePath(root, caseSensitive);
18
+ if (comparableTarget === comparableRoot) return true;
19
+ const prefix = comparableRoot.endsWith(sep) ? comparableRoot : comparableRoot + sep;
20
+ return comparableTarget.startsWith(prefix);
21
+ }
22
+ async function statIfPresent(path) {
23
+ try {
24
+ return await stat(path, { bigint: true });
25
+ } catch (error) {
26
+ if (isMissing(error)) return void 0;
27
+ throw error;
28
+ }
29
+ }
30
+ function sameIdentity(left, right) {
31
+ return left.dev === right.dev && left.ino === right.ino;
32
+ }
33
+ /**
34
+ * Determine whether a canonical target is a writable root or lies beneath it.
35
+ * The lexical fast path handles normal canonical spellings. When spellings
36
+ * differ, walk the target's existing ancestors and compare filesystem identity
37
+ * with the root; this recognizes Windows long-name/8.3 aliases and casing
38
+ * without weakening containment to a textual approximation.
39
+ * @param path - canonical target key, which may end in a missing suffix.
40
+ * @param root - canonical writable root.
41
+ * @param caseSensitive - whether lexical comparison preserves case; defaults
42
+ * to the host filesystem convention used by supported platforms.
43
+ * @returns whether the target is the root or a descendant of it.
44
+ */
45
+ async function isPathUnder(path, root, caseSensitive = process.platform !== "win32") {
46
+ if (isLexicallyUnder(path, root, caseSensitive)) return true;
47
+ const rootInfo = await statIfPresent(root);
48
+ if (!rootInfo) return false;
49
+ let ancestor = path;
50
+ while (true) {
51
+ const ancestorInfo = await statIfPresent(ancestor);
52
+ if (ancestorInfo && sameIdentity(ancestorInfo, rootInfo)) return true;
53
+ const parent = dirname(ancestor);
54
+ if (parent === ancestor) return false;
55
+ ancestor = parent;
56
+ }
57
+ }
58
+ //#endregion
59
+ //#region src/fs.ts
60
+ /**
61
+ * Sandbox-enforcing filesystem backend over the multi-root scope. Registers as
62
+ * `ctx.fs`; `ctx.sandboxPolicy` still owns the mode and the primary root, and
63
+ * this class only widens the writable root set with the scope's additional
64
+ * roots.
65
+ */
66
+ var MultiRootFileSystem = class extends LocalFileSystem {
67
+ static inject = ["sandboxPolicy", "multiRootScope"];
68
+ defaultMode;
69
+ constructor(ctx, config) {
70
+ super(ctx, config);
71
+ this.defaultMode = ctx.sandboxPolicy.defaultMode;
72
+ }
73
+ /** The deployment default mode — the capability fact the tool layer reads to advertise escalation. */
74
+ get sandboxMode() {
75
+ return this.defaultMode;
76
+ }
77
+ /**
78
+ * Fence the write by the per-call policy, then delegate to the inherited
79
+ * atomic write.
80
+ * @param target - the resolved target to write.
81
+ * @param content - the full new file content.
82
+ * @param expected - the write intent guarding the write; omit for unconditional.
83
+ * @param signal - aborts before atomic publication takes effect.
84
+ * @param sandboxPolicy - the per-call mode and workspace root; omit to use
85
+ * the deployment fallback.
86
+ * @returns the write outcome from the inherited backend.
87
+ */
88
+ async writeText(target, content, expected, signal, sandboxPolicy) {
89
+ return super.writeText(await this.checkedTarget(target, sandboxPolicy), content, expected, signal);
90
+ }
91
+ /**
92
+ * Fence the edit by the per-call policy, then delegate to the inherited
93
+ * atomic edit.
94
+ * @param target - the resolved target to edit.
95
+ * @param edit - the literal search/replace request.
96
+ * @param expected - the version guard; omit for an unconditional edit.
97
+ * @param signal - aborts before atomic publication takes effect.
98
+ * @param sandboxPolicy - the per-call mode and workspace root; omit to use
99
+ * the deployment fallback.
100
+ * @returns the edit outcome from the inherited backend.
101
+ */
102
+ async editText(target, edit, expected, signal, sandboxPolicy) {
103
+ return super.editText(await this.checkedTarget(target, sandboxPolicy), edit, expected, signal);
104
+ }
105
+ /**
106
+ * The writable roots of one workspace-write call: the upstream derivation
107
+ * (primary root plus the platform temp areas, canonical and deduplicated)
108
+ * widened by the scope's additional roots, in scope order.
109
+ * @param policy - the per-call policy.
110
+ * @param scope - the scope resolved once for this call; both the fence and
111
+ * the denial message below share it instead of re-resolving.
112
+ * @returns the canonical writable roots; empty under `read-only`.
113
+ */
114
+ rootsFor(policy, scope) {
115
+ const roots = writableRoots(policy);
116
+ if (roots.length === 0) return roots;
117
+ for (const root of scope.additionalRoots) if (!roots.includes(root)) roots.push(root);
118
+ return roots;
119
+ }
120
+ /**
121
+ * Enforce the per-call policy against `target` and return the EXACT target the
122
+ * mutation must use, so the checked identity is the mutated one (no
123
+ * check-here-write-there TOCTOU). `read-only` denies; `workspace-write`
124
+ * re-canonicalizes NOW (reflecting a concurrently swapped symlink ancestor),
125
+ * requires containment under one of the scope's writable roots, and returns
126
+ * THAT fresh target; `danger-full-access` returns the caller's target unfenced.
127
+ * @param target - the resolved target about to be mutated.
128
+ * @param sandboxPolicy - the per-call policy; omit for the deployment fallback.
129
+ * @returns the fresh canonical target the mutation must use.
130
+ */
131
+ async checkedTarget(target, sandboxPolicy) {
132
+ const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve();
133
+ const { mode } = policy;
134
+ if (mode === "danger-full-access") return target;
135
+ if (mode === "read-only") throw new FsError(`cannot write "${target.displayPath}": file access denied under read-only mode`, "FS_SANDBOX_DENIED");
136
+ const fresh = await this.resolve(target.displayPath);
137
+ const scope = this.ctx.multiRootScope.resolve(policy);
138
+ const roots = this.rootsFor(policy, scope);
139
+ let contained = false;
140
+ for (const root of roots) if (await isPathUnder(fresh.targetKey, root)) {
141
+ contained = true;
142
+ break;
143
+ }
144
+ if (!contained) {
145
+ const denial = `cannot write "${target.displayPath}": file access denied under workspace-write mode`;
146
+ throw new FsError(scope.additionalRoots.length === 0 ? denial : `${denial}; allowed roots: ${roots.join(", ")}`, "FS_SANDBOX_DENIED");
147
+ }
148
+ return fresh;
149
+ }
150
+ };
151
+ //#endregion
152
+ export { MultiRootFileSystem as t };
package/lib/fs.js ADDED
@@ -0,0 +1,2 @@
1
+ import { t as MultiRootFileSystem } from "./fs-D7UoCb7T.js";
2
+ export { MultiRootFileSystem, MultiRootFileSystem as default };
package/lib/index.js ADDED
@@ -0,0 +1,17 @@
1
+ import { t as MultiRootFileSystem } from "./fs-D7UoCb7T.js";
2
+ import { t as MultiRootSandboxProvider } from "./sandbox-BhuMTDgH.js";
3
+ import { MultiRootScopeService, renderWorkspaceRootsContext, sanitizeAdditionalRoots } from "./scope.js";
4
+ import { a as classifyStoredRoots, c as indexedStatuses, f as resolveRootRef, i as canonicalRoot, l as isCanonicallyUnder, n as additionalRootId, p as validateRootCandidate, r as availableRoots, s as expandRootInput, t as RootValidationError, u as removeStatusAt } from "./roots-DyT2jgEP.js";
5
+ import { DOMAIN_NAME, MAX_ALIAS_LENGTH, MultiRootRegistry, multiRootDomainSpec } from "./registry.js";
6
+ import { a as renderRootsReport, c as PANEL_ENDPOINTS, d as parseRootsView, i as parseFoldersCommand, l as parsePanelCall, o as revealArgv, s as PANEL_CHANNEL, t as COMMAND_NAME, u as parseRevealedView } from "./command-BSkrYOAf.js";
7
+ //#region src/index.ts
8
+ /**
9
+ * Carrier plugin face for the bare-package-name loader row (see the module
10
+ * doc). It deliberately provides nothing: every host responsibility is owned
11
+ * by a subpath entry, and this row exists only so the web client-module scan
12
+ * can locate the package root and read its `dsh.client` declaration.
13
+ */
14
+ const inject = [];
15
+ function apply() {}
16
+ //#endregion
17
+ export { COMMAND_NAME, DOMAIN_NAME, MAX_ALIAS_LENGTH, MultiRootFileSystem, MultiRootRegistry, MultiRootSandboxProvider, MultiRootScopeService, PANEL_CHANNEL, PANEL_ENDPOINTS, RootValidationError, additionalRootId, apply, availableRoots, canonicalRoot, classifyStoredRoots, expandRootInput, indexedStatuses, inject, isCanonicallyUnder, multiRootDomainSpec, parseFoldersCommand, parsePanelCall, parseRevealedView, parseRootsView, removeStatusAt, renderRootsReport, renderWorkspaceRootsContext, resolveRootRef, revealArgv, sanitizeAdditionalRoots, validateRootCandidate };
@@ -0,0 +1,466 @@
1
+ import { a as classifyStoredRoots, d as resolveRootIndex, i as canonicalRoot, n as additionalRootId, p as validateRootCandidate, r as availableRoots, t as RootValidationError, u as removeStatusAt } from "./roots-DyT2jgEP.js";
2
+ import { Service } from "@deepseek-ai/cordis";
3
+ import { randomUUID } from "node:crypto";
4
+ import { defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
5
+ import { z } from "zod";
6
+ //#region src/registry.ts
7
+ /**
8
+ * `MultiRootRegistry`: the durable answer to "which directories are extra
9
+ * writable roots of this workspace".
10
+ *
11
+ * It is the M3 data source the M2 providers were already written against: the
12
+ * registry owns the records, validates every mutation, re-validates everything
13
+ * it reads back, and feeds the ONE place enforcement resolves from
14
+ * (`ctx.multiRootScope`). Neither provider learns that a registry exists —
15
+ * `setAdditionalRoots()` stays the scope's only write port.
16
+ *
17
+ * Four deliberate properties:
18
+ *
19
+ * - **Only usable roots are granted.** A registered directory that is absent
20
+ * right now stays registered and is reported as `missing`, but is withheld
21
+ * from the scope: handing a nonexistent root to bwrap or Landlock would fail
22
+ * the confined command instead of merely not granting it. A registration
23
+ * whose path now resolves to a DIFFERENT directory is `redirected` and is
24
+ * withheld for the same reason it was never granted: re-resolving a path is
25
+ * not re-authorizing it (see `src/scope.ts`).
26
+ * - **Nothing is deleted on the operator's behalf.** A record that violates a
27
+ * rule is reported as `invalid` and excluded from the scope; it disappears
28
+ * only when the operator removes it (or when the store is repaired). Storage
29
+ * that cannot be parsed at all is backed up and skipped by the domain layer,
30
+ * with a warning.
31
+ * - **Mutations are serialized per primary root.** Every mutation runs its
32
+ * whole read → validate → persist sequence inside one queue per key. The
33
+ * storage domain serializes individual `put()`/`delete()` calls, which is not
34
+ * enough: two concurrent `add()` calls would both read the same snapshot and
35
+ * the later write would drop the earlier one, and a removal racing an add
36
+ * could write a deleted record back.
37
+ * - **Reads re-check, but never write.** {@link MultiRootRegistry.refresh} is
38
+ * the one revalidation entry point the command and the panel both call: it
39
+ * re-stats and re-canonicalizes every record, republishes the scope, and
40
+ * leaves storage untouched — a read must not rewrite the store, and a
41
+ * read-only refresh must not resurrect anything.
42
+ *
43
+ * @module @dsh-electron/dsh-plugin-multi-root-workspace/registry
44
+ */
45
+ /** The domain name; also the store file stem (`$DSH_HOME/storages/<name>.json`). */
46
+ const DOMAIN_NAME = "multi_root_workspace";
47
+ /** The single table name inside the domain. */
48
+ const TABLE_NAME = "roots";
49
+ /** Longest accepted display alias. */
50
+ const MAX_ALIAS_LENGTH = 120;
51
+ /** One persisted root record. `zod` validates it at the durable boundary. */
52
+ const persistedRoot = z.object({
53
+ id: z.string().min(1),
54
+ path: z.string().min(1),
55
+ recordedPath: z.string().min(1).optional(),
56
+ alias: z.string().optional(),
57
+ addedAt: z.string().min(1)
58
+ });
59
+ /**
60
+ * One primary root's whole registration list. The domain's `single` layout
61
+ * stores the entire unit as one document, so keys stay opaque strings — which
62
+ * is exactly what lets the canonical primary root itself be the key.
63
+ */
64
+ const persistedPrimaryRoot = z.object({ roots: z.array(persistedRoot) });
65
+ /**
66
+ * The domain declaration: version 1, `single` layout, one `roots` table keyed
67
+ * by the canonical primary root. Records that fail validation are backed up and
68
+ * skipped rather than blocking the whole harness from starting (see
69
+ * docs/decisions/ADR-0004).
70
+ */
71
+ const multiRootDomainSpec = defineDomain({
72
+ name: DOMAIN_NAME,
73
+ version: 1,
74
+ invalidRecords: "backup-and-skip",
75
+ tables: { roots: domainTable(persistedPrimaryRoot) }
76
+ });
77
+ /** The registry service: `ctx.multiRootRegistry`. */
78
+ var MultiRootRegistry = class extends Service {
79
+ static inject = ["storageDomain", "multiRootScope"];
80
+ /** The open table; present once the service has started. */
81
+ table;
82
+ /** Why the store is unusable, when it is; every mutation then fails loudly. */
83
+ failure;
84
+ /** Last classified status list per canonical primary root. */
85
+ cache = /* @__PURE__ */ new Map();
86
+ /** Signature of the last published grant per key, so a no-op refresh stays a no-op. */
87
+ published = /* @__PURE__ */ new Map();
88
+ /** One promise chain per canonical primary root: mutations never interleave. */
89
+ chains = /* @__PURE__ */ new Map();
90
+ /** Change listeners, keyed by their own disposer identity. */
91
+ listeners = /* @__PURE__ */ new Set();
92
+ /**
93
+ * @param ctx - the host context; `storageDomain` and `multiRootScope` are injected.
94
+ */
95
+ constructor(ctx) {
96
+ super(ctx, "multiRootRegistry");
97
+ }
98
+ /**
99
+ * Open the domain and publish every stored root into the scope.
100
+ *
101
+ * A store this build cannot read (corrupt records, a version written by a
102
+ * different plugin version) is reported loudly and degrades to the empty
103
+ * root set instead of failing activation: granting nothing is the safe
104
+ * direction, and a broken side store must not keep the whole harness from
105
+ * starting. Every mutation then reports `storage-unavailable` with that
106
+ * reason until the file is repaired or removed.
107
+ */
108
+ async [Service.init]() {
109
+ let domain;
110
+ try {
111
+ domain = await this.ctx.storageDomain.open(multiRootDomainSpec);
112
+ } catch (error) {
113
+ this.failure = `${DOMAIN_NAME}: ${error instanceof Error ? error.message : String(error)}`;
114
+ this.ctx.logger.error(`multi-root-workspace: cannot open the root registry store (${DOMAIN_NAME}); no additional root is granted until it is repaired or removed: ${this.failure}`);
115
+ return;
116
+ }
117
+ this.ctx.effect(() => () => domain.close(), "multi-root-registry: domain close");
118
+ this.table = domain.table(TABLE_NAME);
119
+ for (const [primaryRoot, record] of this.table.entries()) this.accept(primaryRoot, record);
120
+ }
121
+ /**
122
+ * Why the store is unusable, when it is. Surfaces show this instead of a
123
+ * silently empty list, so an operator can tell "no roots configured" from
124
+ * "the configuration could not be read".
125
+ * @returns the recorded failure text, or `undefined` while the store is healthy.
126
+ */
127
+ get unavailable() {
128
+ return this.failure;
129
+ }
130
+ /**
131
+ * Every registration of one primary root, in registry order, as last
132
+ * classified. A READ of the in-memory state: it never touches the filesystem
133
+ * and never writes. Surfaces that must re-check the directories first call
134
+ * {@link MultiRootRegistry.refresh}.
135
+ * @param primaryRoot - the session workspace root (any spelling).
136
+ * @returns the statuses; empty when nothing is registered.
137
+ */
138
+ list(primaryRoot) {
139
+ return this.statusesOf(canonicalRoot(primaryRoot));
140
+ }
141
+ /**
142
+ * Re-examine every registration of one primary root WITHOUT writing storage:
143
+ * re-`stat` every directory, re-resolve every path against its recorded
144
+ * directory, re-judge every rule, then republish the scope and notify
145
+ * listeners.
146
+ *
147
+ * This is the one revalidation entry point the command (`/workspace-folders
148
+ * list`) and the panel (`list` endpoint) both call, so "what the list shows"
149
+ * and "what is granted" cannot drift: a directory deleted after registration
150
+ * becomes `missing` and loses its grant, and a directory that came back is
151
+ * granted again — without either surface asking for a restart.
152
+ *
153
+ * The serialized queue is shared with the mutations, so a refresh never
154
+ * interleaves with a write that is mid-flight.
155
+ * @param primaryRoot - the session workspace root to re-check.
156
+ * @returns the refreshed status list.
157
+ */
158
+ async refresh(primaryRoot) {
159
+ const key = canonicalRoot(primaryRoot);
160
+ return await this.serialize(key, () => this.reclassify(key));
161
+ }
162
+ /**
163
+ * Registers one additional root.
164
+ * @param primaryRoot - the session workspace root the root belongs to.
165
+ * @param input - the directory and optional alias.
166
+ * @returns the updated status list.
167
+ * @throws {RootValidationError} when the candidate violates a root rule.
168
+ */
169
+ async add(primaryRoot, input) {
170
+ const key = canonicalRoot(primaryRoot);
171
+ return await this.serialize(key, async () => {
172
+ const statuses = this.statusesOf(key);
173
+ const canonical = validateRootCandidate(input.path, {
174
+ primaryRoot: key,
175
+ existing: availableRoots(statuses)
176
+ });
177
+ const alias = normalizeAlias(input.alias);
178
+ const revived = statuses.findIndex((status) => status.path === canonical || canonicalRoot(status.path) === canonical);
179
+ const next = revived === -1 ? [...statuses, {
180
+ id: additionalRootId(randomUUID()),
181
+ path: canonical,
182
+ recordedPath: canonical,
183
+ state: "available",
184
+ ...alias === void 0 ? {} : { alias },
185
+ addedAt: (/* @__PURE__ */ new Date()).toISOString()
186
+ }] : statuses.map((status, index) => index === revived ? {
187
+ ...status,
188
+ path: canonical,
189
+ recordedPath: canonical,
190
+ addedAt: (/* @__PURE__ */ new Date()).toISOString(),
191
+ ...alias === void 0 ? {} : { alias }
192
+ } : status);
193
+ return await this.persist(key, next);
194
+ });
195
+ }
196
+ /**
197
+ * Remove one registration.
198
+ * @param primaryRoot - the session workspace root the root belongs to.
199
+ * @param ref - which registered root to remove.
200
+ * @returns the updated status list.
201
+ * @throws {RootValidationError} `not-found` when the reference matches nothing.
202
+ */
203
+ async remove(primaryRoot, ref) {
204
+ return await this.removeAt(primaryRoot, ref);
205
+ }
206
+ /**
207
+ * Remove exactly one registration — the one the reference names.
208
+ *
209
+ * Unlike a filter by id, this removes a single record, so a store holding two
210
+ * records that share an id can be cleaned up one entry at a time instead of
211
+ * losing both to one click.
212
+ * @param primaryRoot - the session workspace root the root belongs to.
213
+ * @param ref - which registered root to remove.
214
+ * @returns the updated status list.
215
+ * @throws {RootValidationError} `not-found` when the reference matches nothing.
216
+ */
217
+ async removeAt(primaryRoot, ref) {
218
+ const key = canonicalRoot(primaryRoot);
219
+ return await this.serialize(key, async () => await this.persist(key, removeStatusAt(this.statusesOf(key), ref)));
220
+ }
221
+ /**
222
+ * Set or clear one registration's display alias.
223
+ * @param primaryRoot - the session workspace root the root belongs to.
224
+ * @param ref - which registered root to rename.
225
+ * @param alias - the new alias; empty or absent clears it.
226
+ * @returns the updated status list.
227
+ * @throws {RootValidationError} `not-found`/`invalid-alias`.
228
+ */
229
+ async setAlias(primaryRoot, ref, alias) {
230
+ const key = canonicalRoot(primaryRoot);
231
+ return await this.serialize(key, async () => {
232
+ const statuses = this.statusesOf(key);
233
+ const targetIndex = resolveRootIndex(statuses, ref);
234
+ const normalized = normalizeAlias(alias);
235
+ const next = statuses.map((status, index) => {
236
+ if (index !== targetIndex) return status;
237
+ const { alias: _dropped, ...rest } = status;
238
+ return normalized === void 0 ? rest : {
239
+ ...rest,
240
+ alias: normalized
241
+ };
242
+ });
243
+ return await this.persist(key, next);
244
+ });
245
+ }
246
+ /**
247
+ * Move one registration within the display order.
248
+ * @param primaryRoot - the session workspace root the root belongs to.
249
+ * @param ref - which registered root to move.
250
+ * @param beforeRef - the anchor to move in front of; absent moves it to the end.
251
+ * @returns the updated status list.
252
+ * @throws {RootValidationError} `not-found` when either reference matches nothing.
253
+ */
254
+ async move(primaryRoot, ref, beforeRef) {
255
+ const key = canonicalRoot(primaryRoot);
256
+ return await this.serialize(key, async () => {
257
+ const statuses = this.statusesOf(key);
258
+ const targetIndex = resolveRootIndex(statuses, ref);
259
+ const anchorIndex = beforeRef === void 0 ? void 0 : resolveRootIndex(statuses, beforeRef);
260
+ if (anchorIndex === targetIndex) return statuses;
261
+ const target = statuses[targetIndex];
262
+ const without = statuses.filter((_status, index) => index !== targetIndex);
263
+ const adjustedAnchor = anchorIndex === void 0 ? without.length : anchorIndex - (targetIndex < anchorIndex ? 1 : 0);
264
+ const next = [
265
+ ...without.slice(0, adjustedAnchor),
266
+ target,
267
+ ...without.slice(adjustedAnchor)
268
+ ];
269
+ return await this.persist(key, next);
270
+ });
271
+ }
272
+ /**
273
+ * Re-examine every registration of one primary root and persist what the
274
+ * re-examination produced — {@link MultiRootRegistry.refresh} plus one durable
275
+ * write. The recovery path after a directory was restored by hand, and the
276
+ * only read-shaped operation that is allowed to touch the store.
277
+ * @param primaryRoot - the session workspace root to re-check.
278
+ * @returns the refreshed status list.
279
+ */
280
+ async recheck(primaryRoot) {
281
+ const key = canonicalRoot(primaryRoot);
282
+ return await this.serialize(key, async () => await this.persist(key, this.reclassify(key)));
283
+ }
284
+ /**
285
+ * Observe registry changes.
286
+ * @param listener - called with the canonical primary root after each durable change.
287
+ * @returns the disposer that stops observing.
288
+ */
289
+ onChange(listener) {
290
+ this.listeners.add(listener);
291
+ return () => {
292
+ this.listeners.delete(listener);
293
+ };
294
+ }
295
+ /**
296
+ * The scope the registry publishes: only usable roots, in registry order.
297
+ * @param primaryRoot - the session workspace root (any spelling).
298
+ * @returns the canonical paths of every `available` root.
299
+ */
300
+ granted(primaryRoot) {
301
+ return availableRoots(this.statusesOf(canonicalRoot(primaryRoot)));
302
+ }
303
+ /**
304
+ * The scope the registry publishes: only usable roots, in registry order.
305
+ * @param policy - the per-call policy; its workspace root is the primary root.
306
+ * @returns the granted canonical roots.
307
+ */
308
+ grantedFor(policy) {
309
+ return this.granted(policy.workspaceRoot);
310
+ }
311
+ /** Classify one stored record set and cache it. */
312
+ accept(primaryRoot, record) {
313
+ const key = canonicalRoot(primaryRoot);
314
+ const statuses = classifyStoredRoots(key, record.roots.map(toRegisteredRoot));
315
+ this.cache.set(key, statuses);
316
+ this.publish(key, statuses);
317
+ return statuses;
318
+ }
319
+ /**
320
+ * The statuses of one key, from memory where possible: the cache, else what
321
+ * storage holds, else empty. A cached answer is never re-checked here — that
322
+ * is {@link MultiRootRegistry.reclassify}, which the read surfaces and
323
+ * {@link MultiRootRegistry.refresh} call deliberately.
324
+ */
325
+ statusesOf(key) {
326
+ const cached = this.cache.get(key);
327
+ if (cached !== void 0) return cached;
328
+ const record = this.lookupRecords(key);
329
+ if (record === void 0) return [];
330
+ return this.accept(key, record);
331
+ }
332
+ /**
333
+ * The stored record set of one key, or `undefined` when there is none (or
334
+ * when the store itself could not be read: an unusable store reads as
335
+ * "nothing registered" so reads stay answerable and the surfaces can explain
336
+ * the situation, while every write fails loudly in `requireTable()`).
337
+ */
338
+ lookupRecords(key) {
339
+ if (this.failure !== void 0) return void 0;
340
+ return this.requireTable().get(key);
341
+ }
342
+ /**
343
+ * Re-classify one key from its stored records: every directory is `stat`ed
344
+ * again and every path is resolved again against its recorded canonical
345
+ * directory. Updates the cache, republishes the scope, and returns the new
346
+ * list — the shared body of the read-path re-checks.
347
+ */
348
+ reclassify(key) {
349
+ const record = this.lookupRecords(key);
350
+ if (record === void 0) {
351
+ this.cache.set(key, []);
352
+ this.publish(key, []);
353
+ return [];
354
+ }
355
+ return this.accept(key, record);
356
+ }
357
+ /**
358
+ * Persist one status list, then republish it into the scope and to listeners.
359
+ * The durable write happens first: a failed write must not leave the running
360
+ * scope granting something storage does not hold.
361
+ */
362
+ async persist(key, statuses) {
363
+ const table = this.requireTable();
364
+ if (statuses.length === 0) await table.delete(key);
365
+ else {
366
+ const durable = persistedPrimaryRoot.parse({ roots: statuses.map(toPersistedRoot) });
367
+ await table.put(key, durable);
368
+ }
369
+ const classified = statuses.length === 0 ? [] : classifyStoredRoots(key, statuses.map(toRegisteredRoot));
370
+ this.cache.set(key, classified);
371
+ this.publish(key, classified);
372
+ return classified;
373
+ }
374
+ /**
375
+ * Run one job for one primary root after every job already queued for that
376
+ * root. The whole read → validate → write sequence belongs inside the job:
377
+ * the storage domain serializes individual writes, so without this the loser
378
+ * of a race reads a snapshot that is already stale and writes it back.
379
+ */
380
+ async serialize(key, job) {
381
+ const run = (this.chains.get(key) ?? Promise.resolve()).then(job);
382
+ const tail = run.then(() => void 0, () => void 0);
383
+ this.chains.set(key, tail);
384
+ try {
385
+ return await run;
386
+ } finally {
387
+ if (this.chains.get(key) === tail) this.chains.delete(key);
388
+ }
389
+ }
390
+ /**
391
+ * Push one status list into the scope and notify listeners.
392
+ *
393
+ * What is published is the effective GRANT — only `available` roots, so a
394
+ * withheld registration can never reach a provider. When the grant and the
395
+ * withheld set are unchanged (the common case for a read-path refresh on a
396
+ * healthy workspace) the scope is left alone and no listener fires: a refresh
397
+ * exists to keep the two in sync, not to churn on every listing — and, since
398
+ * the scope rebuilds its root table on each call, skipping the no-op write is
399
+ * also what keeps the read path cheap.
400
+ */
401
+ publish(key, statuses) {
402
+ const withheld = statuses.filter((status) => status.state !== "available");
403
+ const signature = [...statuses.filter((status) => status.state === "available").map((status) => `available:${status.recordedPath}`), ...withheld.map((status) => `${status.state}:${status.path}`)].join("\n");
404
+ if (this.published.get(key) === signature) return;
405
+ this.published.set(key, signature);
406
+ const effective = statuses.flatMap((status) => {
407
+ if (status.state !== "available" || status.recordedPath === void 0) return [];
408
+ return [{
409
+ id: status.id,
410
+ path: status.path,
411
+ recordedPath: status.recordedPath,
412
+ ...status.alias === void 0 ? {} : { alias: status.alias }
413
+ }];
414
+ });
415
+ this.ctx.multiRootScope.setAdditionalRoots(key, effective);
416
+ if (withheld.length > 0) this.ctx.logger.warn(`multi-root-workspace: ${withheld.length} registered root(s) of ${key} are not writable right now (${withheld.map((status) => `${status.path}: ${status.state}`).join(", ")}); they stay registered and are reported by /workspace-folders`);
417
+ for (const listener of this.listeners) listener(key);
418
+ }
419
+ /**
420
+ * The open table.
421
+ * @returns the live table.
422
+ * @throws {RootValidationError} `storage-unavailable` when the store could
423
+ * not be opened at activation, or the service has not started.
424
+ */
425
+ requireTable() {
426
+ if (this.failure !== void 0) throw new RootValidationError("storage-unavailable", `the root registry store is unavailable (${this.failure}); remove or repair \$DSH_HOME/storages/multi_root_workspace.json and restart dsh`);
427
+ if (this.table === void 0) throw new Error("multi-root registry is not started yet");
428
+ return this.table;
429
+ }
430
+ };
431
+ /** Project one status back to the durable record shape. */
432
+ function toPersistedRoot(status) {
433
+ return {
434
+ id: status.id,
435
+ path: status.path,
436
+ ...status.recordedPath === void 0 || status.recordedPath === "" ? {} : { recordedPath: status.recordedPath },
437
+ ...status.alias === void 0 ? {} : { alias: status.alias },
438
+ addedAt: status.addedAt
439
+ };
440
+ }
441
+ /** Recover the registered-root view of a stored record entry. */
442
+ function toRegisteredRoot(entry) {
443
+ return {
444
+ id: additionalRootId(entry.id),
445
+ path: entry.path,
446
+ ...entry.recordedPath === void 0 ? {} : { recordedPath: entry.recordedPath },
447
+ ...entry.alias === void 0 ? {} : { alias: entry.alias },
448
+ addedAt: entry.addedAt
449
+ };
450
+ }
451
+ /**
452
+ * Validate and normalize an optional display alias.
453
+ * @param alias - the operator input; empty means "no alias".
454
+ * @returns the trimmed alias, or `undefined`.
455
+ * @throws {RootValidationError} `invalid-alias` for control characters or an over-long value.
456
+ */
457
+ function normalizeAlias(alias) {
458
+ if (alias === void 0) return void 0;
459
+ const trimmed = alias.trim();
460
+ if (trimmed === "") return void 0;
461
+ if (/[\u0000-\u001f\u007f]/.test(trimmed)) throw new RootValidationError("invalid-alias", "an alias must not contain control characters");
462
+ if (trimmed.length > 120) throw new RootValidationError("invalid-alias", `an alias must be at most 120 characters`);
463
+ return trimmed;
464
+ }
465
+ //#endregion
466
+ export { DOMAIN_NAME, MAX_ALIAS_LENGTH, MultiRootRegistry, MultiRootRegistry as default, TABLE_NAME, multiRootDomainSpec };