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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.en.md +89 -0
  2. package/CHANGELOG.md +89 -0
  3. package/README.en.md +96 -20
  4. package/README.md +97 -19
  5. package/cordis.patch.yml +22 -1
  6. package/lib/client.js +234 -71
  7. package/lib/client.js.map +1 -1
  8. package/lib/{command-BSkrYOAf.js → command-DyOWujPo.js} +24 -61
  9. package/lib/command.js +2 -2
  10. package/lib/compat-CRdv0vPw.js +272 -0
  11. package/lib/compat.js +2 -0
  12. package/lib/{fs-D7UoCb7T.js → fs-D_Sw3o5z.js} +7 -3
  13. package/lib/fs.js +1 -1
  14. package/lib/index.js +8 -6
  15. package/lib/instructions-Cn1Jj5Xi.js +703 -0
  16. package/lib/instructions.js +2 -0
  17. package/lib/registry-DySMbjgW.js +793 -0
  18. package/lib/registry-lease-win32-dy8TQMD9.js +93 -0
  19. package/lib/registry.js +2 -466
  20. package/lib/{sandbox-BhuMTDgH.js → sandbox-D8Fw95FM.js} +51 -4
  21. package/lib/sandbox.js +1 -1
  22. package/lib/scope.js +1 -1
  23. package/lib/types/client/locales.d.ts +6 -0
  24. package/lib/types/client/panel-client.d.ts +0 -2
  25. package/lib/types/command.d.ts +17 -0
  26. package/lib/types/compat/agent-instructions.d.ts +138 -0
  27. package/lib/types/compat/client-icons.d.ts +40 -0
  28. package/lib/types/compat/client-session.d.ts +32 -0
  29. package/lib/types/compat/dsh-version.d.ts +119 -0
  30. package/lib/types/compat/llm-message.d.ts +72 -0
  31. package/lib/types/compat/sandbox-confine.d.ts +58 -0
  32. package/lib/types/compat/tool-result.d.ts +29 -0
  33. package/lib/types/compat.d.ts +85 -0
  34. package/lib/types/contract.d.ts +6 -8
  35. package/lib/types/index.d.ts +19 -5
  36. package/lib/types/instructions.d.ts +125 -0
  37. package/lib/types/registry-lease-win32.d.ts +29 -0
  38. package/lib/types/registry-lease.d.ts +62 -0
  39. package/lib/types/registry.d.ts +130 -19
  40. package/lib/types/roots.d.ts +1 -1
  41. package/lib/types/sandbox.d.ts +22 -2
  42. package/package.json +60 -13
  43. package/lib/{roots-DyT2jgEP.js → roots-DasWEBJk.js} +1 -1
@@ -0,0 +1,793 @@
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-DasWEBJk.js";
2
+ import { Service } from "@deepseek-ai/cordis";
3
+ import { randomUUID } from "node:crypto";
4
+ import { dirname, isAbsolute } from "node:path";
5
+ import { closeSync, fstatSync, mkdirSync, openSync, statSync } from "node:fs";
6
+ import { defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
7
+ import { z } from "zod";
8
+ import { tryLockExclusive } from "@deepseek-ai/node-addon-system/flock";
9
+ //#region src/registry-lease.ts
10
+ /**
11
+ * Store-wide kernel lease for the multi-root registry.
12
+ *
13
+ * The JSON storage backend is memory-authoritative after `open`: it reads the
14
+ * domain document once, then every write is an atomic rewrite of that in-memory
15
+ * snapshot. Two DSH processes sharing one storage root can therefore each grant
16
+ * a stale set, and a later rewrite silently drops the other process's mutation.
17
+ * The in-process `chains` queue in `registry.ts` cannot see that race.
18
+ *
19
+ * This lease makes one process the Registry Authority for the whole
20
+ * `multi_root_workspace` medium. The arbiter is the kernel, matching upstream
21
+ * session JSONL persistence: POSIX non-blocking `flock(2)` via
22
+ * `@deepseek-ai/node-addon-system/flock`, Windows a named kernel semaphore.
23
+ * There is no TTL and no stale-PID algorithm — the kernel releases the lock
24
+ * when the holder's descriptor (or last object handle) closes, including on
25
+ * crash. See ADR-0007.
26
+ *
27
+ * @module @dsh-electron/dsh-plugin-multi-root-workspace/registry-lease
28
+ */
29
+ /** Thrown when another live process already holds the registry lease. */
30
+ var RegistryLeaseContendedError = class extends Error {
31
+ /** Stable code the registry maps onto `registry-contended`. */
32
+ code = "registry-contended";
33
+ /** Absolute lock path that was contended. */
34
+ leasePath;
35
+ /**
36
+ * @param leasePath - the store-wide lock path that was contended.
37
+ */
38
+ constructor(leasePath) {
39
+ super(`the root registry lease is held by another DSH process (${leasePath})`);
40
+ this.name = "RegistryLeaseContendedError";
41
+ this.leasePath = leasePath;
42
+ }
43
+ };
44
+ /** Whether a native lock failure means another holder keeps the lock. */
45
+ function isLockContention(error) {
46
+ const code = error?.code;
47
+ return code === "EAGAIN" || code === "EWOULDBLOCK" || code === "EBUSY";
48
+ }
49
+ /**
50
+ * One held store-wide registry lock. Constructed only by
51
+ * {@link RegistryAuthorityLease.acquire}; `release` closes the descriptor or
52
+ * handle, which is what releases the lock.
53
+ */
54
+ var RegistryAuthorityLease = class RegistryAuthorityLease {
55
+ held;
56
+ released = false;
57
+ constructor(held) {
58
+ this.held = held;
59
+ }
60
+ /**
61
+ * Acquire the exclusive kernel lock for one registry store.
62
+ *
63
+ * POSIX opens `leasePath` (creating it and its parent) and takes a
64
+ * non-blocking exclusive flock. Because flock names an inode, the holder then
65
+ * verifies the locked inode is still the file at `leasePath` and retries
66
+ * otherwise — an unlinked-and-recreated lock file carries a fresh inode.
67
+ * Windows never flocks the file: it holds a named kernel semaphore derived
68
+ * from the canonical path so readers and directory removal stay free.
69
+ * @param leasePath - absolute path of the lock file (store-wide, beside the domain JSON).
70
+ * @returns the held lease.
71
+ * @throws {RegistryLeaseContendedError} while another holder keeps the lock.
72
+ */
73
+ static async acquire(leasePath) {
74
+ mkdirSync(dirname(leasePath), {
75
+ recursive: true,
76
+ mode: 448
77
+ });
78
+ if (process.platform === "win32") {
79
+ const { acquireLockHandleWin32 } = await import("./registry-lease-win32-dy8TQMD9.js");
80
+ let handle;
81
+ try {
82
+ handle = await acquireLockHandleWin32(leasePath);
83
+ } catch (error) {
84
+ if (isLockContention(error)) throw new RegistryLeaseContendedError(leasePath);
85
+ throw error;
86
+ }
87
+ return new RegistryAuthorityLease({
88
+ kind: "win32",
89
+ handle
90
+ });
91
+ }
92
+ for (let attempt = 0; attempt < 3; attempt += 1) {
93
+ const fd = openSync(leasePath, "w");
94
+ try {
95
+ try {
96
+ await tryLockExclusive(fd);
97
+ } catch (error) {
98
+ if (isLockContention(error)) throw new RegistryLeaseContendedError(leasePath);
99
+ throw error;
100
+ }
101
+ const held = fstatSync(fd, { bigint: true });
102
+ let current;
103
+ try {
104
+ current = statSync(leasePath, { bigint: true });
105
+ } catch (error) {
106
+ if (error.code !== "ENOENT") throw error;
107
+ }
108
+ if (current !== void 0 && current.ino === held.ino && current.dev === held.dev) return new RegistryAuthorityLease({
109
+ kind: "posix",
110
+ fd
111
+ });
112
+ } catch (error) {
113
+ closeSync(fd);
114
+ throw error;
115
+ }
116
+ closeSync(fd);
117
+ }
118
+ throw new RegistryLeaseContendedError(leasePath);
119
+ }
120
+ /**
121
+ * Release the kernel lock by closing its descriptor or handle. The POSIX
122
+ * lock file is never removed: keeping it preserves the stable inode later
123
+ * lockers verify against. POSIX close is synchronous so a fiber disposer that
124
+ * does not await this promise still drops the flock before returning.
125
+ * Idempotent.
126
+ */
127
+ async release() {
128
+ if (this.released) return;
129
+ this.released = true;
130
+ if (this.held.kind === "win32") {
131
+ const { releaseLockHandleWin32 } = await import("./registry-lease-win32-dy8TQMD9.js");
132
+ await releaseLockHandleWin32(this.held.handle);
133
+ return;
134
+ }
135
+ closeSync(this.held.fd);
136
+ }
137
+ };
138
+ //#endregion
139
+ //#region src/registry.ts
140
+ /**
141
+ * `MultiRootRegistry`: the durable answer to "which directories are extra
142
+ * writable roots of this workspace".
143
+ *
144
+ * It is the M3 data source the M2 providers were already written against: the
145
+ * registry owns the records, validates every mutation, re-validates everything
146
+ * it reads back, and feeds the ONE place enforcement resolves from
147
+ * (`ctx.multiRootScope`). Neither provider learns that a registry exists —
148
+ * `setAdditionalRoots()` stays the scope's only write port.
149
+ *
150
+ * Five deliberate properties:
151
+ *
152
+ * - **Only usable roots are granted.** A registered directory that is absent
153
+ * right now stays registered and is reported as `missing`, but is withheld
154
+ * from the scope: handing a nonexistent root to bwrap or Landlock would fail
155
+ * the confined command instead of merely not granting it. A registration
156
+ * whose path now resolves to a DIFFERENT directory is `redirected` and is
157
+ * withheld for the same reason it was never granted: re-resolving a path is
158
+ * not re-authorizing it (see `src/scope.ts`).
159
+ * - **Nothing is deleted on the operator's behalf.** A record that violates a
160
+ * rule is reported as `invalid` and excluded from the scope; it disappears
161
+ * only when the operator removes it (or when the store is repaired). Storage
162
+ * that cannot be parsed at all is backed up and skipped by the domain layer,
163
+ * with a warning.
164
+ * - **Mutations are serialized per primary root.** Every mutation runs its
165
+ * whole read → validate → persist sequence inside one queue per key. The
166
+ * storage domain serializes individual `put()`/`delete()` calls, which is not
167
+ * enough: two concurrent `add()` calls would both read the same snapshot and
168
+ * the later write would drop the earlier one, and a removal racing an add
169
+ * could write a deleted record back.
170
+ * - **Reads re-check, but never write.** {@link MultiRootRegistry.refresh} is
171
+ * the one revalidation entry point the command and the panel both call: it
172
+ * re-stats and re-canonicalizes every record, republishes the scope, and
173
+ * leaves storage untouched — a read must not rewrite the store, and a
174
+ * read-only refresh must not resurrect anything.
175
+ * - **One process is the Registry Authority.** The JSON backend is
176
+ * memory-authoritative after open, so two DSH processes sharing a storage
177
+ * root cannot each keep a live domain snapshot. A store-wide kernel lease
178
+ * (ADR-0007) elects one authority; a contended process publishes an empty
179
+ * scope, rejects mutations, and retries the lease from {@link MultiRootRegistry.refresh}.
180
+ *
181
+ * @module @dsh-electron/dsh-plugin-multi-root-workspace/registry
182
+ */
183
+ /** The domain name; also the store file stem (`$DSH_HOME/storages/<name>.json`). */
184
+ const DOMAIN_NAME = "multi_root_workspace";
185
+ /** The single table name inside the domain. */
186
+ const TABLE_NAME = "roots";
187
+ /** Longest accepted display alias. */
188
+ const MAX_ALIAS_LENGTH = 120;
189
+ /**
190
+ * Operator-facing English for a contended lease. Host-side commands have no
191
+ * locale; the panel also shows this string verbatim via `RootsView.unavailable`.
192
+ */
193
+ const REGISTRY_CONTENDED_MESSAGE = "the root registry is owned by another DSH process; additional roots are not granted here. Close the other process, then retry.";
194
+ /** One persisted root record. `zod` validates it at the durable boundary. */
195
+ const persistedRoot = z.object({
196
+ id: z.string().min(1),
197
+ path: z.string().min(1),
198
+ recordedPath: z.string().min(1).optional(),
199
+ alias: z.string().optional(),
200
+ addedAt: z.string().min(1)
201
+ });
202
+ /**
203
+ * One primary root's whole registration list. The domain's `single` layout
204
+ * stores the entire unit as one document, so keys stay opaque strings — which
205
+ * is exactly what lets the canonical primary root itself be the key.
206
+ */
207
+ const persistedPrimaryRoot = z.object({ roots: z.array(persistedRoot) });
208
+ /**
209
+ * The domain declaration: version 1, `single` layout, one `roots` table keyed
210
+ * by the canonical primary root. Records that fail validation are backed up and
211
+ * skipped rather than blocking the whole harness from starting (see
212
+ * docs/decisions/ADR-0004).
213
+ */
214
+ const multiRootDomainSpec = defineDomain({
215
+ name: DOMAIN_NAME,
216
+ version: 1,
217
+ invalidRecords: "backup-and-skip",
218
+ tables: { roots: domainTable(persistedPrimaryRoot) }
219
+ });
220
+ /** The registry service: `ctx.multiRootRegistry`. */
221
+ var MultiRootRegistry = class extends Service {
222
+ static inject = [
223
+ "multiRootCompat",
224
+ "storageDomain",
225
+ "multiRootScope"
226
+ ];
227
+ /** Absolute lock path; absent means the row was composed without `leasePath`. */
228
+ leasePath;
229
+ /** Whether this process currently owns the store. */
230
+ authorityState = { kind: "contended" };
231
+ /** Held kernel lease; present once acquire succeeded, including after a failed open. */
232
+ lease;
233
+ /** Open domain handle; present only while {@link authority} is `active`. */
234
+ domain;
235
+ /** The open table; present once this process is the authority. */
236
+ table;
237
+ /** Last classified status list per canonical primary root. */
238
+ cache = /* @__PURE__ */ new Map();
239
+ /** Signature of the last published grant per key, so a no-op refresh stays a no-op. */
240
+ published = /* @__PURE__ */ new Map();
241
+ /** One promise chain per canonical primary root: mutations never interleave. */
242
+ chains = /* @__PURE__ */ new Map();
243
+ /** Store-wide queue for lease acquire / open / release. */
244
+ authorityChain = Promise.resolve();
245
+ /** Change listeners, keyed by their own disposer identity. */
246
+ listeners = /* @__PURE__ */ new Set();
247
+ /** True after a contended-lease warning has been emitted, so refresh does not spam. */
248
+ loggedContended = false;
249
+ /** True once the fiber disposer has run: no further acquisition may happen. */
250
+ disposed = false;
251
+ /**
252
+ * @param ctx - the host context; `storageDomain` and `multiRootScope` are injected.
253
+ * @param config - the patch row's `leasePath`.
254
+ */
255
+ constructor(ctx, config = {}) {
256
+ super(ctx, "multiRootRegistry");
257
+ this.leasePath = config.leasePath;
258
+ }
259
+ /**
260
+ * Become the Registry Authority if possible: acquire the store-wide lease,
261
+ * then open the domain and publish every stored root into the scope.
262
+ *
263
+ * A contended lease is fail-closed rather than a stale open: this process
264
+ * grants nothing and every mutation reports `registry-contended`. A store
265
+ * this build cannot read is reported loudly and also grants nothing. Neither
266
+ * case fails activation — a broken or contended side store must not keep the
267
+ * whole harness from starting. {@link MultiRootRegistry.refresh} retries.
268
+ */
269
+ async [Service.init]() {
270
+ this.ctx.effect(() => () => this.releaseAuthority(), "multi-root-registry: authority release");
271
+ await this.ensureAuthority();
272
+ }
273
+ /**
274
+ * Why the registry is not granting, when it is not. Surfaces show this
275
+ * instead of a silently empty list, so an operator can tell "no roots
276
+ * configured" from "another DSH process owns the store" or "the file could
277
+ * not be read".
278
+ * @returns the recorded failure text, or `undefined` while this process is the authority.
279
+ */
280
+ get unavailable() {
281
+ switch (this.authorityState.kind) {
282
+ case "active": return;
283
+ case "contended": return REGISTRY_CONTENDED_MESSAGE;
284
+ case "storage-failed": return this.authorityState.reason;
285
+ }
286
+ }
287
+ /**
288
+ * Whether this process is the Registry Authority for the store-wide medium.
289
+ * Tests and diagnostics read this; surfaces use {@link unavailable}.
290
+ */
291
+ get authority() {
292
+ return this.authorityState;
293
+ }
294
+ /**
295
+ * Every registration of one primary root, in registry order, as last
296
+ * classified. A READ of the in-memory state: it never touches the filesystem
297
+ * and never writes. Surfaces that must re-check the directories first call
298
+ * {@link MultiRootRegistry.refresh}.
299
+ * @param primaryRoot - the session workspace root (any spelling).
300
+ * @returns the statuses; empty when nothing is registered.
301
+ */
302
+ list(primaryRoot) {
303
+ return this.statusesOf(canonicalRoot(primaryRoot));
304
+ }
305
+ /**
306
+ * Re-examine every registration of one primary root WITHOUT writing storage:
307
+ * re-`stat` every directory, re-resolve every path against its recorded
308
+ * directory, re-judge every rule, then republish the scope and notify
309
+ * listeners.
310
+ *
311
+ * This is the one revalidation entry point the command (`/workspace-folders
312
+ * list`) and the panel (`list` endpoint) both call, so "what the list shows"
313
+ * and "what is granted" cannot drift: a directory deleted after registration
314
+ * becomes `missing` and loses its grant, and a directory that came back is
315
+ * granted again — without either surface asking for a restart.
316
+ *
317
+ * The serialized queue is shared with the mutations, so a refresh never
318
+ * interleaves with a write that is mid-flight.
319
+ *
320
+ * Refresh is also the takeover path: it calls {@link MultiRootRegistry.ensureAuthority}
321
+ * first, so a process that started contended can become the authority after
322
+ * the previous holder exits, re-open the domain from disk, and publish the
323
+ * last durable state — without a restart.
324
+ * @param primaryRoot - the session workspace root to re-check.
325
+ * @returns the refreshed status list.
326
+ */
327
+ async refresh(primaryRoot) {
328
+ await this.ensureAuthority();
329
+ if (this.authorityState.kind !== "active") return [];
330
+ const key = canonicalRoot(primaryRoot);
331
+ return await this.serialize(key, () => this.reclassify(key));
332
+ }
333
+ /**
334
+ * Registers one additional root.
335
+ * @param primaryRoot - the session workspace root the root belongs to.
336
+ * @param input - the directory and optional alias.
337
+ * @returns the updated status list.
338
+ * @throws {RootValidationError} when the candidate violates a root rule.
339
+ */
340
+ async add(primaryRoot, input) {
341
+ await this.ensureAuthority();
342
+ this.requireActive();
343
+ const key = canonicalRoot(primaryRoot);
344
+ return await this.serialize(key, async () => {
345
+ const statuses = this.statusesOf(key);
346
+ const canonical = validateRootCandidate(input.path, {
347
+ primaryRoot: key,
348
+ existing: availableRoots(statuses)
349
+ });
350
+ const alias = normalizeAlias(input.alias);
351
+ const revived = statuses.findIndex((status) => status.path === canonical || canonicalRoot(status.path) === canonical);
352
+ const next = revived === -1 ? [...statuses, {
353
+ id: additionalRootId(randomUUID()),
354
+ path: canonical,
355
+ recordedPath: canonical,
356
+ state: "available",
357
+ ...alias === void 0 ? {} : { alias },
358
+ addedAt: (/* @__PURE__ */ new Date()).toISOString()
359
+ }] : statuses.map((status, index) => index === revived ? {
360
+ ...status,
361
+ path: canonical,
362
+ recordedPath: canonical,
363
+ addedAt: (/* @__PURE__ */ new Date()).toISOString(),
364
+ ...alias === void 0 ? {} : { alias }
365
+ } : status);
366
+ return await this.persist(key, next);
367
+ });
368
+ }
369
+ /**
370
+ * Remove one registration.
371
+ * @param primaryRoot - the session workspace root the root belongs to.
372
+ * @param ref - which registered root to remove.
373
+ * @returns the updated status list.
374
+ * @throws {RootValidationError} `not-found` when the reference matches nothing.
375
+ */
376
+ async remove(primaryRoot, ref) {
377
+ return await this.removeAt(primaryRoot, ref);
378
+ }
379
+ /**
380
+ * Remove exactly one registration — the one the reference names.
381
+ *
382
+ * Unlike a filter by id, this removes a single record, so a store holding two
383
+ * records that share an id can be cleaned up one entry at a time instead of
384
+ * losing both to one click.
385
+ * @param primaryRoot - the session workspace root the root belongs to.
386
+ * @param ref - which registered root to remove.
387
+ * @returns the updated status list.
388
+ * @throws {RootValidationError} `not-found` when the reference matches nothing.
389
+ */
390
+ async removeAt(primaryRoot, ref) {
391
+ await this.ensureAuthority();
392
+ this.requireActive();
393
+ const key = canonicalRoot(primaryRoot);
394
+ return await this.serialize(key, async () => await this.persist(key, removeStatusAt(this.statusesOf(key), ref)));
395
+ }
396
+ /**
397
+ * Set or clear one registration's display alias.
398
+ * @param primaryRoot - the session workspace root the root belongs to.
399
+ * @param ref - which registered root to rename.
400
+ * @param alias - the new alias; empty or absent clears it.
401
+ * @returns the updated status list.
402
+ * @throws {RootValidationError} `not-found`/`invalid-alias`.
403
+ */
404
+ async setAlias(primaryRoot, ref, alias) {
405
+ await this.ensureAuthority();
406
+ this.requireActive();
407
+ const key = canonicalRoot(primaryRoot);
408
+ return await this.serialize(key, async () => {
409
+ const statuses = this.statusesOf(key);
410
+ const targetIndex = resolveRootIndex(statuses, ref);
411
+ const normalized = normalizeAlias(alias);
412
+ const next = statuses.map((status, index) => {
413
+ if (index !== targetIndex) return status;
414
+ const { alias: _dropped, ...rest } = status;
415
+ return normalized === void 0 ? rest : {
416
+ ...rest,
417
+ alias: normalized
418
+ };
419
+ });
420
+ return await this.persist(key, next);
421
+ });
422
+ }
423
+ /**
424
+ * Move one registration within the display order.
425
+ * @param primaryRoot - the session workspace root the root belongs to.
426
+ * @param ref - which registered root to move.
427
+ * @param beforeRef - the anchor to move in front of; absent moves it to the end.
428
+ * @returns the updated status list.
429
+ * @throws {RootValidationError} `not-found` when either reference matches nothing.
430
+ */
431
+ async move(primaryRoot, ref, beforeRef) {
432
+ await this.ensureAuthority();
433
+ this.requireActive();
434
+ const key = canonicalRoot(primaryRoot);
435
+ return await this.serialize(key, async () => {
436
+ const statuses = this.statusesOf(key);
437
+ const targetIndex = resolveRootIndex(statuses, ref);
438
+ const anchorIndex = beforeRef === void 0 ? void 0 : resolveRootIndex(statuses, beforeRef);
439
+ if (anchorIndex === targetIndex) return statuses;
440
+ const target = statuses[targetIndex];
441
+ const without = statuses.filter((_status, index) => index !== targetIndex);
442
+ const adjustedAnchor = anchorIndex === void 0 ? without.length : anchorIndex - (targetIndex < anchorIndex ? 1 : 0);
443
+ const next = [
444
+ ...without.slice(0, adjustedAnchor),
445
+ target,
446
+ ...without.slice(adjustedAnchor)
447
+ ];
448
+ return await this.persist(key, next);
449
+ });
450
+ }
451
+ /**
452
+ * Re-examine every registration of one primary root and persist what the
453
+ * re-examination produced — {@link MultiRootRegistry.refresh} plus one durable
454
+ * write. The recovery path after a directory was restored by hand, and the
455
+ * only read-shaped operation that is allowed to touch the store.
456
+ * @param primaryRoot - the session workspace root to re-check.
457
+ * @returns the refreshed status list.
458
+ */
459
+ async recheck(primaryRoot) {
460
+ await this.ensureAuthority();
461
+ this.requireActive();
462
+ const key = canonicalRoot(primaryRoot);
463
+ return await this.serialize(key, async () => await this.persist(key, this.reclassify(key)));
464
+ }
465
+ /**
466
+ * Observe registry changes.
467
+ * @param listener - called with the canonical primary root after each durable change.
468
+ * @returns the disposer that stops observing.
469
+ */
470
+ onChange(listener) {
471
+ this.listeners.add(listener);
472
+ return () => {
473
+ this.listeners.delete(listener);
474
+ };
475
+ }
476
+ /**
477
+ * The scope the registry publishes: only usable roots, in registry order.
478
+ * @param primaryRoot - the session workspace root (any spelling).
479
+ * @returns the canonical paths of every `available` root.
480
+ */
481
+ granted(primaryRoot) {
482
+ return availableRoots(this.statusesOf(canonicalRoot(primaryRoot)));
483
+ }
484
+ /**
485
+ * The scope the registry publishes: only usable roots, in registry order.
486
+ * @param policy - the per-call policy; its workspace root is the primary root.
487
+ * @returns the granted canonical roots.
488
+ */
489
+ grantedFor(policy) {
490
+ return this.granted(policy.workspaceRoot);
491
+ }
492
+ /** Classify one stored record set and cache it. */
493
+ accept(primaryRoot, record) {
494
+ const key = canonicalRoot(primaryRoot);
495
+ const statuses = classifyStoredRoots(key, record.roots.map(toRegisteredRoot));
496
+ this.cache.set(key, statuses);
497
+ this.publish(key, statuses);
498
+ return statuses;
499
+ }
500
+ /**
501
+ * The statuses of one key, from memory where possible: the cache, else what
502
+ * storage holds, else empty. A cached answer is never re-checked here — that
503
+ * is {@link MultiRootRegistry.reclassify}, which the read surfaces and
504
+ * {@link MultiRootRegistry.refresh} call deliberately.
505
+ */
506
+ statusesOf(key) {
507
+ const cached = this.cache.get(key);
508
+ if (cached !== void 0) return cached;
509
+ const record = this.lookupRecords(key);
510
+ if (record === void 0) return [];
511
+ return this.accept(key, record);
512
+ }
513
+ /**
514
+ * The stored record set of one key, or `undefined` when there is none (or
515
+ * when the store itself could not be read: an unusable store reads as
516
+ * "nothing registered" so reads stay answerable and the surfaces can explain
517
+ * the situation, while every write fails loudly in `requireTable()`).
518
+ */
519
+ lookupRecords(key) {
520
+ if (this.authorityState.kind !== "active") return void 0;
521
+ return this.table?.get(key);
522
+ }
523
+ /**
524
+ * Re-classify one key from its stored records: every directory is `stat`ed
525
+ * again and every path is resolved again against its recorded canonical
526
+ * directory. Updates the cache, republishes the scope, and returns the new
527
+ * list — the shared body of the read-path re-checks.
528
+ */
529
+ reclassify(key) {
530
+ const record = this.lookupRecords(key);
531
+ if (record === void 0) {
532
+ this.cache.set(key, []);
533
+ this.publish(key, []);
534
+ return [];
535
+ }
536
+ return this.accept(key, record);
537
+ }
538
+ /**
539
+ * Persist one status list, then republish it into the scope and to listeners.
540
+ * The durable write happens first: a failed write must not leave the running
541
+ * scope granting something storage does not hold.
542
+ */
543
+ async persist(key, statuses) {
544
+ const table = this.requireTable();
545
+ if (statuses.length === 0) await table.delete(key);
546
+ else {
547
+ const durable = persistedPrimaryRoot.parse({ roots: statuses.map(toPersistedRoot) });
548
+ await table.put(key, durable);
549
+ }
550
+ const classified = statuses.length === 0 ? [] : classifyStoredRoots(key, statuses.map(toRegisteredRoot));
551
+ this.cache.set(key, classified);
552
+ this.publish(key, classified);
553
+ return classified;
554
+ }
555
+ /**
556
+ * Run one job for one primary root after every job already queued for that
557
+ * root. The whole read → validate → write sequence belongs inside the job:
558
+ * the storage domain serializes individual writes, so without this the loser
559
+ * of a race reads a snapshot that is already stale and writes it back.
560
+ */
561
+ async serialize(key, job) {
562
+ const run = (this.chains.get(key) ?? Promise.resolve()).then(job);
563
+ const tail = run.then(() => void 0, () => void 0);
564
+ this.chains.set(key, tail);
565
+ try {
566
+ return await run;
567
+ } finally {
568
+ if (this.chains.get(key) === tail) this.chains.delete(key);
569
+ }
570
+ }
571
+ /**
572
+ * Push one status list into the scope and notify listeners.
573
+ *
574
+ * What is published is the effective GRANT — only `available` roots, so a
575
+ * withheld registration can never reach a provider. When the grant and the
576
+ * withheld set are unchanged (the common case for a read-path refresh on a
577
+ * healthy workspace) the scope is left alone and no listener fires: a refresh
578
+ * exists to keep the two in sync, not to churn on every listing — and, since
579
+ * the scope rebuilds its root table on each call, skipping the no-op write is
580
+ * also what keeps the read path cheap.
581
+ */
582
+ publish(key, statuses) {
583
+ const withheld = statuses.filter((status) => status.state !== "available");
584
+ const signature = [...statuses.filter((status) => status.state === "available").map((status) => `available:${status.recordedPath}`), ...withheld.map((status) => `${status.state}:${status.path}`)].join("\n");
585
+ if (this.published.get(key) === signature) return;
586
+ this.published.set(key, signature);
587
+ const effective = statuses.flatMap((status) => {
588
+ if (status.state !== "available" || status.recordedPath === void 0) return [];
589
+ return [{
590
+ id: status.id,
591
+ path: status.path,
592
+ recordedPath: status.recordedPath,
593
+ ...status.alias === void 0 ? {} : { alias: status.alias }
594
+ }];
595
+ });
596
+ this.ctx.multiRootScope.setAdditionalRoots(key, effective);
597
+ 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`);
598
+ for (const listener of this.listeners) listener(key);
599
+ }
600
+ /**
601
+ * The open table.
602
+ * @returns the live table.
603
+ * @throws {RootValidationError} `registry-contended` when another DSH process
604
+ * owns the store, `storage-unavailable` when the store could not be opened,
605
+ * or an error when the service has not started.
606
+ */
607
+ requireTable() {
608
+ if (this.authorityState.kind === "contended") throw new RootValidationError("registry-contended", REGISTRY_CONTENDED_MESSAGE);
609
+ if (this.authorityState.kind === "storage-failed") throw new RootValidationError("storage-unavailable", `the root registry store is unavailable (${this.authorityState.reason}); remove or repair \$DSH_HOME/storages/multi_root_workspace.json and restart dsh`);
610
+ if (this.table === void 0) throw new Error("multi-root registry is not started yet");
611
+ return this.table;
612
+ }
613
+ /**
614
+ * Reject mutations unless this process is the Registry Authority.
615
+ * Called before path validation so a contended process never mis-reports
616
+ * `missing` / `not-absolute` for a directory it is not allowed to register.
617
+ */
618
+ requireActive() {
619
+ this.requireTable();
620
+ }
621
+ /**
622
+ * Acquire the store-wide lease if this process does not already hold it, then
623
+ * open the domain from disk. Idempotent while active. A contended process
624
+ * retries here (the command `list` and the panel Retry button both call
625
+ * {@link MultiRootRegistry.refresh}).
626
+ */
627
+ async ensureAuthority() {
628
+ await this.serializeAuthority(async () => {
629
+ if (this.disposed) return;
630
+ if (this.authorityState.kind === "active") return;
631
+ if (this.lease !== void 0) {
632
+ await this.openDomain();
633
+ return;
634
+ }
635
+ const leasePath = this.leasePath;
636
+ if (leasePath === void 0 || leasePath === "") {
637
+ this.enterStorageFailed(`${DOMAIN_NAME}: leasePath is not configured; set it next to storage-json.root (default $DSH_HOME/storages/${DOMAIN_NAME}.lock)`);
638
+ return;
639
+ }
640
+ if (!isAbsolute(leasePath)) {
641
+ this.enterStorageFailed(`${DOMAIN_NAME}: leasePath must be an absolute path`);
642
+ return;
643
+ }
644
+ try {
645
+ this.lease = await RegistryAuthorityLease.acquire(leasePath);
646
+ } catch (error) {
647
+ if (error instanceof RegistryLeaseContendedError) {
648
+ this.enterContended();
649
+ return;
650
+ }
651
+ this.enterStorageFailed(`${DOMAIN_NAME}: cannot acquire the registry lease: ${error instanceof Error ? error.message : String(error)}`);
652
+ return;
653
+ }
654
+ await this.openDomain();
655
+ });
656
+ }
657
+ /**
658
+ * Open the domain under an already-held lease and publish every stored root.
659
+ * On failure the lease is kept so a later refresh can retry the open without
660
+ * letting a second process grant from a stale snapshot.
661
+ */
662
+ async openDomain() {
663
+ let domain;
664
+ try {
665
+ domain = await this.ctx.storageDomain.open(multiRootDomainSpec);
666
+ } catch (error) {
667
+ const reason = `${DOMAIN_NAME}: ${error instanceof Error ? error.message : String(error)}`;
668
+ 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: ${reason}`);
669
+ this.enterStorageFailed(reason);
670
+ return;
671
+ }
672
+ this.domain = domain;
673
+ this.table = domain.table(TABLE_NAME);
674
+ this.authorityState = { kind: "active" };
675
+ this.loggedContended = false;
676
+ this.cache.clear();
677
+ this.published.clear();
678
+ for (const [primaryRoot, record] of this.table.entries()) this.accept(primaryRoot, record);
679
+ }
680
+ /** Fail closed: empty scope, no table, contended state. */
681
+ enterContended() {
682
+ this.withdrawPublished();
683
+ this.table = void 0;
684
+ this.authorityState = { kind: "contended" };
685
+ if (!this.loggedContended) {
686
+ this.loggedContended = true;
687
+ this.ctx.logger.warn("multi-root-workspace: registry lease is held by another DSH process; additional roots are not granted here until it exits and this process refreshes");
688
+ }
689
+ }
690
+ /** Fail closed: empty scope, no table, storage-failed state. The lease, if held, stays. */
691
+ enterStorageFailed(reason) {
692
+ this.withdrawPublished();
693
+ this.table = void 0;
694
+ this.authorityState = {
695
+ kind: "storage-failed",
696
+ reason
697
+ };
698
+ }
699
+ /** Drop every grant this process has published. Idempotent. */
700
+ withdrawPublished() {
701
+ for (const key of this.published.keys()) this.ctx.multiRootScope.setAdditionalRoots(key, []);
702
+ this.published.clear();
703
+ this.cache.clear();
704
+ }
705
+ /**
706
+ * Drain every queued mutation, close the domain, then release the kernel
707
+ * lease — in that order, and inside the authority-transition queue.
708
+ *
709
+ * The order is the invariant, not a detail. The lease is what makes "the
710
+ * store is mine" true, so it must outlive every write this process will ever
711
+ * make, including the queued writes `close()` itself drains: a successor that
712
+ * acquired the lease and opened the medium in the window between a release
713
+ * and a `put()` would read a snapshot missing that mutation. Running the
714
+ * whole teardown through {@link MultiRootRegistry.serializeAuthority} is the
715
+ * other half: a `refresh()` that is mid-flight when the fiber disposes must
716
+ * not finish by opening a domain and a lease that nothing will ever close.
717
+ */
718
+ async releaseAuthority() {
719
+ this.disposed = true;
720
+ await this.serializeAuthority(async () => {
721
+ this.table = void 0;
722
+ this.authorityState = { kind: "contended" };
723
+ await this.drainMutations();
724
+ this.withdrawPublished();
725
+ const domain = this.domain;
726
+ this.domain = void 0;
727
+ if (domain !== void 0) try {
728
+ await domain.close();
729
+ } catch {}
730
+ const lease = this.lease;
731
+ this.lease = void 0;
732
+ if (lease !== void 0) try {
733
+ await lease.release();
734
+ } catch {}
735
+ });
736
+ }
737
+ /**
738
+ * Wait until no per-primary-root mutation is queued or running.
739
+ *
740
+ * One pass is enough, and only because the authority state has already been
741
+ * flipped: no further mutation can be admitted (it would fail in
742
+ * `requireTable()`), and every mutation admitted earlier put its own tail in
743
+ * `chains` before it ran.
744
+ */
745
+ async drainMutations() {
746
+ await Promise.allSettled([...this.chains.values()]);
747
+ }
748
+ /**
749
+ * Run one authority-transition job after every job already queued. Acquire,
750
+ * open, and release never interleave with each other.
751
+ */
752
+ async serializeAuthority(job) {
753
+ const run = this.authorityChain.then(job);
754
+ this.authorityChain = run.then(() => void 0, () => void 0);
755
+ return await run;
756
+ }
757
+ };
758
+ /** Project one status back to the durable record shape. */
759
+ function toPersistedRoot(status) {
760
+ return {
761
+ id: status.id,
762
+ path: status.path,
763
+ ...status.recordedPath === void 0 || status.recordedPath === "" ? {} : { recordedPath: status.recordedPath },
764
+ ...status.alias === void 0 ? {} : { alias: status.alias },
765
+ addedAt: status.addedAt
766
+ };
767
+ }
768
+ /** Recover the registered-root view of a stored record entry. */
769
+ function toRegisteredRoot(entry) {
770
+ return {
771
+ id: additionalRootId(entry.id),
772
+ path: entry.path,
773
+ ...entry.recordedPath === void 0 ? {} : { recordedPath: entry.recordedPath },
774
+ ...entry.alias === void 0 ? {} : { alias: entry.alias },
775
+ addedAt: entry.addedAt
776
+ };
777
+ }
778
+ /**
779
+ * Validate and normalize an optional display alias.
780
+ * @param alias - the operator input; empty means "no alias".
781
+ * @returns the trimmed alias, or `undefined`.
782
+ * @throws {RootValidationError} `invalid-alias` for control characters or an over-long value.
783
+ */
784
+ function normalizeAlias(alias) {
785
+ if (alias === void 0) return void 0;
786
+ const trimmed = alias.trim();
787
+ if (trimmed === "") return void 0;
788
+ if (/[\u0000-\u001f\u007f]/.test(trimmed)) throw new RootValidationError("invalid-alias", "an alias must not contain control characters");
789
+ if (trimmed.length > 120) throw new RootValidationError("invalid-alias", `an alias must be at most 120 characters`);
790
+ return trimmed;
791
+ }
792
+ //#endregion
793
+ export { TABLE_NAME as a, RegistryLeaseContendedError as c, REGISTRY_CONTENDED_MESSAGE as i, MAX_ALIAS_LENGTH as n, multiRootDomainSpec as o, MultiRootRegistry as r, RegistryAuthorityLease as s, DOMAIN_NAME as t };