@dsh-electron/dsh-plugin-multi-root-workspace 0.1.11 → 0.1.13

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 (52) hide show
  1. package/CHANGELOG.en.md +25 -1
  2. package/CHANGELOG.md +25 -1
  3. package/README.en.md +10 -8
  4. package/README.md +10 -8
  5. package/cordis.patch.yml +7 -2
  6. package/lib/auto-review.js +1 -1
  7. package/lib/client.js +197 -74
  8. package/lib/client.js.map +1 -1
  9. package/lib/{command-BGdz7VBH.js → command-B9855H7y.js} +3 -2
  10. package/lib/command.js +1 -1
  11. package/lib/{compat-BiW-m_q0.js → compat-Bg2HFVMX.js} +3 -0
  12. package/lib/compat.js +1 -1
  13. package/lib/{sandbox-CXk5h-ct.js → dialects-Dr4rDR-H.js} +6 -264
  14. package/lib/{fs-BTXySwSG.js → fs-CfzJq2wd.js} +23 -8
  15. package/lib/fs.js +1 -1
  16. package/lib/index.js +7 -6
  17. package/lib/{instructions-MePoRigy.js → instructions-KsUwUFE0.js} +53 -37
  18. package/lib/instructions.js +1 -1
  19. package/lib/lsp.js +19 -16
  20. package/lib/native-win32-2waCRkPI.js +312 -0
  21. package/lib/process-fence.js +62 -12
  22. package/lib/protocol-CJOvAvOS.js +652 -0
  23. package/lib/{registry-KSsmU393.js → registry-Bv431TfO.js} +717 -74
  24. package/lib/registry-operation-C2ZV3Jhw.js +116 -0
  25. package/lib/registry.js +3 -2
  26. package/lib/{review-context-AH-SXHmj.js → review-context-NDpKeMCc.js} +1 -1
  27. package/lib/review-context.js +1 -1
  28. package/lib/sandbox-C-vCJCp3.js +278 -0
  29. package/lib/sandbox.js +1 -1
  30. package/lib/scope.js +101 -11
  31. package/lib/skills.js +48 -29
  32. package/lib/types/authority/native-win32.d.ts +7 -0
  33. package/lib/types/authority/native.d.ts +23 -0
  34. package/lib/types/authority/protocol.d.ts +70 -0
  35. package/lib/types/authority/recovery.d.ts +58 -0
  36. package/lib/types/client/RootSwitcher.d.ts +6 -3
  37. package/lib/types/client/files-tree/adapter.d.ts +63 -1
  38. package/lib/types/compat/authority-storage.d.ts +6 -0
  39. package/lib/types/compat/dsh-version.d.ts +4 -4
  40. package/lib/types/compat/open-in-app-device.d.ts +1 -1
  41. package/lib/types/compat/process-entrypoints.d.ts +2 -0
  42. package/lib/types/compat/process-profile.d.ts +3 -0
  43. package/lib/types/compat/sandbox-confine.d.ts +2 -0
  44. package/lib/types/fs.d.ts +4 -1
  45. package/lib/types/instructions.d.ts +0 -47
  46. package/lib/types/registry-operation.d.ts +31 -0
  47. package/lib/types/registry.d.ts +145 -6
  48. package/lib/types/scope.d.ts +28 -0
  49. package/lib/types/skills/catalog.d.ts +3 -0
  50. package/lib/types/skills/pool.d.ts +1 -1
  51. package/lib/workspace-files.js +63 -7
  52. package/package.json +38 -31
@@ -1,13 +1,53 @@
1
+ import { a as runPrimaryOperation, n as freezeOperationScope, o as runRegistryOperation } from "./registry-operation-C2ZV3Jhw.js";
1
2
  import { i as isWindowsNetworkOrDevicePath } from "./windows-runner-contract-B5R2K3mM.js";
2
3
  import { a as classifyStoredRoots, d as reservedRootBoundary, f as resolveRootIndex, i as canonicalRoot, m as validateRootCandidate, n as additionalRootId, r as availableRoots, s as expandRootInput, t as RootValidationError, u as removeStatusAt } from "./roots-Ca_W1S9n.js";
3
4
  import { r as validateAliasId, t as migrateRootSkills } from "./metadata-DgqSdVX_.js";
5
+ import { a as snapshotSchema, i as parseArguments, r as AuthorityServer, s as storeIdentity, t as AuthorityClient } from "./protocol-CJOvAvOS.js";
4
6
  import { createRequire } from "node:module";
5
7
  import { closeSync, fstatSync, mkdirSync, openSync, statSync } from "node:fs";
6
8
  import { randomUUID } from "node:crypto";
7
- import { dirname, isAbsolute } from "node:path";
9
+ import { basename, dirname, isAbsolute } from "node:path";
8
10
  import { Service } from "@deepseek-ai/cordis";
9
- import { defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
11
+ import { DomainFacility, defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
10
12
  import { tryLockExclusive } from "@deepseek-ai/node-addon-system/flock";
13
+ //#region src/compat/authority-storage.ts
14
+ /** Open an explicitly bound medium through public storage APIs only. */
15
+ async function openAuthorityStorage(ctx, root, spec) {
16
+ const { JsonStorageBackend } = await import("@deepseek-ai/dsh-storage-json");
17
+ const backend = new JsonStorageBackend(root);
18
+ const name = `multi-root-${randomUUID()}`;
19
+ const unmount = ctx.get("storage").backend.register(name, backend);
20
+ let facility;
21
+ const fiber = ctx.inject(["storage"], (scope) => {
22
+ facility = new DomainFacility(scope, {
23
+ backend: name,
24
+ routes: {}
25
+ });
26
+ });
27
+ try {
28
+ await fiber;
29
+ if (!facility) throw new Error("shared registry requires the storage hub");
30
+ const domain = await facility.open(spec);
31
+ return {
32
+ table: domain.table.bind(domain),
33
+ async close() {
34
+ try {
35
+ await domain.close();
36
+ } finally {
37
+ unmount();
38
+ await backend.close();
39
+ await fiber.dispose();
40
+ }
41
+ }
42
+ };
43
+ } catch (error) {
44
+ unmount();
45
+ await backend.close();
46
+ await fiber.dispose();
47
+ throw error;
48
+ }
49
+ }
50
+ //#endregion
11
51
  //#region src/compat/storage-zod.ts
12
52
  /**
13
53
  * Build the domain record schemas with the zod module the installed
@@ -54,6 +94,107 @@ function domainZod() {
54
94
  return createRequire(require.resolve("@deepseek-ai/dsh-storage-domain/package.json"))("zod");
55
95
  }
56
96
  //#endregion
97
+ //#region src/authority/recovery.ts
98
+ var RecoveryScheduler = class {
99
+ run;
100
+ eligible;
101
+ enabled;
102
+ timers;
103
+ timer;
104
+ inFlight = false;
105
+ disposed = false;
106
+ attempts = 0;
107
+ retryAt;
108
+ constructor(options) {
109
+ this.run = options.run;
110
+ this.eligible = options.eligible;
111
+ this.enabled = options.enabled;
112
+ this.timers = options.timers ?? {
113
+ setTimeout,
114
+ clearTimeout,
115
+ random: Math.random,
116
+ now: Date.now
117
+ };
118
+ }
119
+ /**
120
+ * Ask for a recovery attempt. Called after every `ensureAuthority()` and on
121
+ * every downgrade event. A healthy state (`!eligible()`) resets the backoff;
122
+ * a pending timer or in-flight attempt is never duplicated or reset.
123
+ */
124
+ arm() {
125
+ if (this.disposed || !this.enabled) return;
126
+ if (!this.eligible()) {
127
+ this.attempts = 0;
128
+ this.clearTimer();
129
+ return;
130
+ }
131
+ if (this.timer !== void 0 || this.inFlight) return;
132
+ const delay = this.delay(this.attempts);
133
+ this.retryAt = this.timers.now() + delay;
134
+ this.timer = this.timers.setTimeout(() => {
135
+ this.tick();
136
+ }, delay);
137
+ this.timer.unref();
138
+ }
139
+ /**
140
+ * Progress event (a follower connection was just adopted): the backoff is
141
+ * earned, so reset it and run the next attempt immediately — still through
142
+ * the single pending timer.
143
+ */
144
+ kick() {
145
+ if (this.disposed || !this.enabled) return;
146
+ this.attempts = 0;
147
+ if (this.inFlight) return;
148
+ this.clearTimer();
149
+ this.retryAt = this.timers.now();
150
+ this.timer = this.timers.setTimeout(() => {
151
+ this.tick();
152
+ }, 0);
153
+ this.timer.unref();
154
+ }
155
+ /** Stop scheduling. An in-flight attempt is left to finish on its own. */
156
+ dispose() {
157
+ this.disposed = true;
158
+ this.clearTimer();
159
+ }
160
+ get state() {
161
+ return {
162
+ phase: this.disposed || !this.enabled && this.eligible() ? "disabled" : this.inFlight ? "attempting" : this.timer !== void 0 ? "waiting" : "idle",
163
+ attempts: this.attempts,
164
+ nextRetryAt: this.timer === void 0 ? void 0 : this.retryAt
165
+ };
166
+ }
167
+ clearTimer() {
168
+ if (this.timer !== void 0) {
169
+ this.timers.clearTimeout(this.timer);
170
+ this.timer = void 0;
171
+ }
172
+ this.retryAt = void 0;
173
+ }
174
+ delay(n) {
175
+ return Math.min(1e3 * 2 ** n, 3e4) * (.8 + .4 * this.timers.random());
176
+ }
177
+ async tick() {
178
+ this.timer = void 0;
179
+ this.retryAt = void 0;
180
+ if (!this.eligible()) {
181
+ this.attempts = 0;
182
+ return;
183
+ }
184
+ this.inFlight = true;
185
+ try {
186
+ await this.run();
187
+ } catch {}
188
+ this.inFlight = false;
189
+ if (this.disposed || !this.eligible()) {
190
+ this.attempts = 0;
191
+ return;
192
+ }
193
+ this.attempts++;
194
+ this.arm();
195
+ }
196
+ };
197
+ //#endregion
57
198
  //#region src/registry-lease.ts
58
199
  /**
59
200
  * Store-wide kernel lease for the multi-root registry.
@@ -209,8 +350,8 @@ var RegistryAuthorityLease = class RegistryAuthorityLease {
209
350
  * only when the operator removes it (or when the store is repaired). Storage
210
351
  * that cannot be parsed at all is backed up and skipped by the domain layer,
211
352
  * with a warning.
212
- * - **Mutations are serialized per primary root.** Every mutation runs its
213
- * whole read → validate → persist sequence inside one queue per key. The
353
+ * - **Mutations and fs admissions are serialized store-wide.** Every mutation
354
+ * runs its whole read → validate → persist sequence inside the store queue. The
214
355
  * storage domain serializes individual `put()`/`delete()` calls, which is not
215
356
  * enough: two concurrent `add()` calls would both read the same snapshot and
216
357
  * the later write would drop the earlier one, and a removal racing an add
@@ -251,6 +392,8 @@ const REGISTRY_CONTENDED_MESSAGE = "the root registry is owned by another DSH pr
251
392
  const DEFAULT_MAX_ADDITIONAL_ROOTS = 16;
252
393
  /** Hard ceiling for `maxAdditionalRoots`: values above it are config-invalid. */
253
394
  const MAX_ADDITIONAL_ROOTS_CEILING = 64;
395
+ /** A medium/lock binding error only a changed configuration can repair. */
396
+ var SharedConfigError = class extends Error {};
254
397
  /**
255
398
  * The durable-boundary validators. The builder is the zod module the
256
399
  * INSTALLED storage-domain resolves, not this package's own `zod` copy:
@@ -296,20 +439,46 @@ var MultiRootRegistry = class extends Service {
296
439
  ];
297
440
  /** Absolute lock path; absent means the row was composed without `leasePath`. */
298
441
  leasePath;
442
+ sharedStorageRoot;
443
+ authorityStorageContext;
444
+ medium;
445
+ server;
446
+ client;
447
+ connectionFailure;
448
+ connectionGeneration = 0;
449
+ /** Local cancel of the in-flight handshake; teardown must not wait out hello. */
450
+ connectionAbort;
451
+ remoteProjectionReady = false;
452
+ remoteRevisions = /* @__PURE__ */ new Map();
453
+ /**
454
+ * Primary roots whose projection must still be synced after a follower
455
+ * reconnect — populated from the tracked cache keys at adoption, removed as
456
+ * each `applySnapshot` lands. Recovery completes only when it empties.
457
+ */
458
+ pendingSync = /* @__PURE__ */ new Set();
459
+ /** Bounded background retry for degraded authority states (ADR-0026 R1). */
460
+ recovery;
461
+ operationProof = Object.freeze({});
299
462
  /** Whether this process currently owns the store. */
300
463
  authorityState = { kind: "contended" };
301
464
  /** Held kernel lease; present once acquire succeeded, including after a failed open. */
302
465
  lease;
303
466
  /** Open domain handle; present only while {@link authority} is `active`. */
304
467
  domain;
468
+ /** The last failed `openDomain` reason; a repeat failure is not re-logged. */
469
+ lastOpenFailure;
305
470
  /** The open table; present once this process is the authority. */
306
471
  table;
307
472
  /** Last classified status list per canonical primary root. */
308
473
  cache = /* @__PURE__ */ new Map();
309
474
  /** Signature of the last published grant per key, so a no-op refresh stays a no-op. */
310
475
  published = /* @__PURE__ */ new Map();
311
- /** One promise chain per canonical primary root: mutations never interleave. */
476
+ /** Pending store-ordered work, grouped by primary for teardown accounting. */
312
477
  chains = /* @__PURE__ */ new Map();
478
+ /** Admissions and mutations share one order, including across primary roots. */
479
+ operationChain = Promise.resolve();
480
+ operationEpoch;
481
+ operationRevision = 0;
313
482
  /** Store-wide queue for lease acquire / open / release. */
314
483
  authorityChain = Promise.resolve();
315
484
  /** Change listeners, keyed by their own disposer identity. */
@@ -328,7 +497,23 @@ var MultiRootRegistry = class extends Service {
328
497
  */
329
498
  constructor(ctx, config = {}) {
330
499
  super(ctx, "multiRootRegistry");
500
+ this.authorityStorageContext = ctx;
331
501
  this.leasePath = config.leasePath;
502
+ this.sharedStorageRoot = config.sharedStorageRoot;
503
+ this.recovery = new RecoveryScheduler({
504
+ enabled: config.backgroundRecovery !== false,
505
+ eligible: () => !this.disposed && (this.authorityState.kind === "contended" || this.authorityState.kind === "storage-failed" && this.authorityState.recovery === "retryable" || this.authorityState.kind === "follower" && this.pendingSync.size > 0),
506
+ run: async () => {
507
+ if (this.authorityState.kind === "follower" && this.pendingSync.size > 0) {
508
+ await this.syncProjection();
509
+ return;
510
+ }
511
+ await this.ensureAuthority({ background: true });
512
+ if (this.client) await this.syncProjection();
513
+ this.noteRecoveryTransition();
514
+ }
515
+ });
516
+ if (config.sharedStorageRoot !== void 0) this.ctx.multiRootScope.requireAdmission(this.operationProof);
332
517
  this.ctx.multiRootScope.setRegistryAuthority(false);
333
518
  const configured = config.maxAdditionalRoots === void 0 ? 16 : config.maxAdditionalRoots;
334
519
  if (Number.isInteger(configured) && configured >= 1 && configured <= 64) this.maxRoots = configured;
@@ -354,6 +539,7 @@ var MultiRootRegistry = class extends Service {
354
539
  this.ctx.effect(() => () => this.releaseAuthority(), "multi-root-registry: authority release");
355
540
  if (this.authorityState.kind === "config-invalid") {
356
541
  this.ctx.logger.error(`multi-root-workspace: ${this.authorityState.reason}; no additional root is granted`);
542
+ this.noteRecoveryTransition();
357
543
  return;
358
544
  }
359
545
  await this.ensureAuthority();
@@ -367,8 +553,9 @@ var MultiRootRegistry = class extends Service {
367
553
  */
368
554
  get unavailable() {
369
555
  switch (this.authorityState.kind) {
370
- case "active": return;
371
- case "contended": return REGISTRY_CONTENDED_MESSAGE;
556
+ case "active":
557
+ case "follower": return;
558
+ case "contended": return this.shared && this.connectionFailure ? `shared registry is unavailable: ${this.connectionFailure}` : REGISTRY_CONTENDED_MESSAGE;
372
559
  case "storage-failed":
373
560
  case "config-invalid": return this.authorityState.reason;
374
561
  }
@@ -377,9 +564,64 @@ var MultiRootRegistry = class extends Service {
377
564
  * Whether this process is the Registry Authority for the store-wide medium.
378
565
  * Tests and diagnostics read this; surfaces use {@link unavailable}.
379
566
  */
567
+ get epoch() {
568
+ return this.authorityState.kind === "active" ? this.operationEpoch : this.client?.epoch;
569
+ }
570
+ get shared() {
571
+ return this.sharedStorageRoot !== void 0;
572
+ }
573
+ get available() {
574
+ return this.authorityState.kind === "active" || this.authorityState.kind === "follower";
575
+ }
380
576
  get authority() {
381
577
  return this.authorityState;
382
578
  }
579
+ /** The class a degraded authority belongs to; `undefined` when healthy. */
580
+ get recoveryClass() {
581
+ switch (this.authorityState.kind) {
582
+ case "contended": return "retryable";
583
+ case "storage-failed": return this.authorityState.recovery;
584
+ case "config-invalid": return "config";
585
+ case "follower": return this.pendingSync.size > 0 ? "retryable" : void 0;
586
+ default: return;
587
+ }
588
+ }
589
+ /**
590
+ * Read-only diagnostics for operators: the authority state, the epoch, the
591
+ * last connection failure, and what the recovery scheduler is doing.
592
+ * `requires-restart` marks classes no timer will ever fix — a `config`
593
+ * row or a `quarantine`d store must NOT read as "retrying". Purely a read;
594
+ * it never arms or runs anything.
595
+ */
596
+ get diagnostics() {
597
+ const recoveryClass = this.recoveryClass;
598
+ const scheduled = this.recovery.state;
599
+ return {
600
+ authorityState: this.authorityState,
601
+ epoch: this.epoch,
602
+ connectionFailure: this.connectionFailure,
603
+ recoveryClass,
604
+ recovery: {
605
+ phase: recoveryClass === "config" || recoveryClass === "quarantine" ? "requires-restart" : scheduled.phase,
606
+ attempts: scheduled.attempts,
607
+ nextRetryAt: scheduled.nextRetryAt,
608
+ pendingSync: this.pendingSync.size
609
+ }
610
+ };
611
+ }
612
+ /** The last logged `(kind, recoveryClass)` pair; dedupes transition logs. */
613
+ loggedRecovery;
614
+ /**
615
+ * Log one line per authority/recovery-class TRANSITION — never per tick:
616
+ * waiting↔attempting moves say nothing an operator did not already know.
617
+ */
618
+ noteRecoveryTransition() {
619
+ const key = `${this.authorityState.kind}:${this.recoveryClass ?? ""}`;
620
+ if (this.loggedRecovery === key) return;
621
+ this.loggedRecovery = key;
622
+ const detail = this.authorityState.kind === "storage-failed" || this.authorityState.kind === "config-invalid" ? this.authorityState.reason : this.connectionFailure ?? "";
623
+ this.ctx.logger.info(this.recoveryClass === void 0 ? `multi-root-workspace: registry authority recovered (${this.authorityState.kind})` : `multi-root-workspace: registry authority is ${this.authorityState.kind} (${this.recoveryClass})${detail === "" ? "" : `: ${detail.slice(0, 200)}`}`);
624
+ }
383
625
  /**
384
626
  * The configured cap on stored additional-root records per primary root.
385
627
  * Surfaces show it next to the count so "N of LIMIT" is always explainable.
@@ -430,11 +672,47 @@ var MultiRootRegistry = class extends Service {
430
672
  */
431
673
  async refresh(primaryRoot) {
432
674
  await this.ensureAuthority();
675
+ if (this.client) try {
676
+ return await this.remote("snapshot", [primaryRoot]);
677
+ } catch (error) {
678
+ if (this.client) throw error;
679
+ await this.ensureAuthority();
680
+ if (this.client) return await this.remote("snapshot", [primaryRoot]);
681
+ }
433
682
  if (this.authorityState.kind !== "active") return [];
434
683
  const key = canonicalRoot(primaryRoot);
435
684
  return await this.serialize(key, () => this.reclassify(key));
436
685
  }
437
686
  /**
687
+ * Admit one fs invocation in the same order as mutations, then run its I/O
688
+ * OUTSIDE the queue. Revocation does not wait for already-admitted work.
689
+ * Shared followers obtain the same ordered snapshot through attested IPC.
690
+ */
691
+ async withOperation(policy, kind, body) {
692
+ if (this.disposed) throw new Error("multi-root registry: cannot admit an operation after disposal");
693
+ const capturedPolicy = structuredClone(policy);
694
+ await this.ensureAuthority();
695
+ const key = canonicalRoot(capturedPolicy.workspaceRoot);
696
+ if (this.shared && this.available) {
697
+ const snapshot = this.client ? await this.remoteSnapshot("admit", [key]) : await this.captureSnapshot(key);
698
+ const scope = freezeOperationScope(this.ctx.multiRootScope.resolveSnapshot(capturedPolicy, this.projectRoots(snapshot.roots), snapshot.available));
699
+ return await runRegistryOperation(scope, kind, snapshot.epoch, snapshot.revision, body, this.operationProof, capturedPolicy.mode);
700
+ }
701
+ const admission = await this.serialize(key, () => {
702
+ if (this.disposed) throw new Error("multi-root registry: cannot admit an operation after disposal");
703
+ return {
704
+ scope: freezeOperationScope(this.shared ? this.ctx.multiRootScope.resolveSnapshot(capturedPolicy, this.projectRoots(this.cache.get(key) ?? []), false) : this.ctx.multiRootScope.resolve(capturedPolicy)),
705
+ epoch: this.operationEpoch,
706
+ revision: this.operationRevision
707
+ };
708
+ });
709
+ return await runRegistryOperation(admission.scope, kind, admission.epoch, admission.revision, body, this.operationProof, capturedPolicy.mode);
710
+ }
711
+ withPrimaryOperation(policy, body) {
712
+ const scope = this.ctx.multiRootScope.resolveSnapshot(policy, this.projectRoots(this.cache.get(canonicalRoot(policy.workspaceRoot)) ?? []), false);
713
+ return runPrimaryOperation(freezeOperationScope(scope), policy.mode, this.operationProof, body);
714
+ }
715
+ /**
438
716
  * Registers one additional root.
439
717
  * @param primaryRoot - the session workspace root the root belongs to.
440
718
  * @param input - the directory and optional alias.
@@ -443,6 +721,7 @@ var MultiRootRegistry = class extends Service {
443
721
  */
444
722
  async add(primaryRoot, input) {
445
723
  await this.ensureAuthority();
724
+ if (this.client) return await this.remote("add", [primaryRoot, input]);
446
725
  this.requireActive();
447
726
  const key = canonicalRoot(primaryRoot);
448
727
  return await this.serialize(key, async () => {
@@ -513,6 +792,11 @@ var MultiRootRegistry = class extends Service {
513
792
  */
514
793
  async setAccess(primaryRoot, ref, access) {
515
794
  await this.ensureAuthority();
795
+ if (this.client) return await this.remote("setAccess", [
796
+ primaryRoot,
797
+ ref,
798
+ access
799
+ ]);
516
800
  this.requireActive();
517
801
  const key = canonicalRoot(primaryRoot);
518
802
  return await this.serialize(key, async () => {
@@ -554,6 +838,7 @@ var MultiRootRegistry = class extends Service {
554
838
  */
555
839
  async removeAt(primaryRoot, ref) {
556
840
  await this.ensureAuthority();
841
+ if (this.client) return await this.remote("removeAt", [primaryRoot, ref]);
557
842
  this.requireActive();
558
843
  const key = canonicalRoot(primaryRoot);
559
844
  return await this.serialize(key, async () => await this.persist(key, removeStatusAt(this.reclassify(key), ref)));
@@ -568,6 +853,11 @@ var MultiRootRegistry = class extends Service {
568
853
  */
569
854
  async setAlias(primaryRoot, ref, alias) {
570
855
  await this.ensureAuthority();
856
+ if (this.client) return await this.remote("setAlias", [
857
+ primaryRoot,
858
+ ref,
859
+ alias ?? null
860
+ ]);
571
861
  this.requireActive();
572
862
  const key = canonicalRoot(primaryRoot);
573
863
  return await this.serialize(key, async () => {
@@ -587,10 +877,22 @@ var MultiRootRegistry = class extends Service {
587
877
  }
588
878
  /** Change a Skill switch without changing any grant field. */
589
879
  async setSkillsEnabled(primaryRoot, ref, skillsEnabled) {
880
+ await this.ensureAuthority();
881
+ if (this.client) return await this.remote("setSkillsEnabled", [
882
+ primaryRoot,
883
+ ref,
884
+ skillsEnabled
885
+ ]);
590
886
  if (typeof skillsEnabled !== "boolean") throw new RootValidationError("invalid-ref", "skillsEnabled must be a boolean");
591
887
  return await this.setSkillsMetadata(primaryRoot, ref, { skillsEnabled });
592
888
  }
593
889
  async setAliasId(primaryRoot, ref, aliasId) {
890
+ await this.ensureAuthority();
891
+ if (this.client) return await this.remote("setAliasId", [
892
+ primaryRoot,
893
+ ref,
894
+ aliasId
895
+ ]);
594
896
  return await this.setSkillsMetadata(primaryRoot, ref, { aliasId });
595
897
  }
596
898
  async setSkillsMetadata(primaryRoot, ref, patch) {
@@ -617,6 +919,11 @@ var MultiRootRegistry = class extends Service {
617
919
  */
618
920
  async move(primaryRoot, ref, beforeRef) {
619
921
  await this.ensureAuthority();
922
+ if (this.client) return await this.remote("move", [
923
+ primaryRoot,
924
+ ref,
925
+ beforeRef ?? null
926
+ ]);
620
927
  this.requireActive();
621
928
  const key = canonicalRoot(primaryRoot);
622
929
  return await this.serialize(key, async () => {
@@ -645,6 +952,7 @@ var MultiRootRegistry = class extends Service {
645
952
  */
646
953
  async recheck(primaryRoot) {
647
954
  await this.ensureAuthority();
955
+ if (this.client) return await this.remote("recheck", [primaryRoot]);
648
956
  this.requireActive();
649
957
  const key = canonicalRoot(primaryRoot);
650
958
  return await this.serialize(key, async () => await this.persist(key, this.reclassify(key)));
@@ -666,7 +974,10 @@ var MultiRootRegistry = class extends Service {
666
974
  * @returns the canonical paths of every `available` root.
667
975
  */
668
976
  granted(primaryRoot) {
669
- return availableRoots(this.statusesOf(canonicalRoot(primaryRoot)));
977
+ return this.shared ? this.ctx.multiRootScope.snapshot({
978
+ workspaceRoot: primaryRoot,
979
+ mode: "workspace-write"
980
+ }).additionalRoots : availableRoots(this.statusesOf(canonicalRoot(primaryRoot)));
670
981
  }
671
982
  /**
672
983
  * The scope the registry publishes: only usable roots, in registry order.
@@ -679,13 +990,22 @@ var MultiRootRegistry = class extends Service {
679
990
  /** Classify one stored record set and cache it. */
680
991
  accept(primaryRoot, record) {
681
992
  const key = canonicalRoot(primaryRoot);
682
- const statuses = classifyStoredRoots(key, record.roots.map(toRegisteredRoot));
993
+ const statuses = this.classify(key, record);
683
994
  this.cache.set(key, statuses);
995
+ this.publish(key, statuses);
996
+ return statuses;
997
+ }
998
+ /**
999
+ * The pure half of {@link accept}: classification plus the over-limit
1000
+ * warning, without writing the cache or the scope. {@link openDomain}
1001
+ * staging uses it so a half-opened domain cannot materialize rows.
1002
+ */
1003
+ classify(key, record) {
1004
+ const statuses = classifyStoredRoots(key, record.roots.map(toRegisteredRoot));
684
1005
  if (statuses.length > this.maxRoots && !this.warnedOverLimit.has(key)) {
685
1006
  this.warnedOverLimit.add(key);
686
1007
  this.ctx.logger.warn(`multi-root-workspace: ${statuses.length} roots are stored for ${key}, above the configured limit of ${this.maxRoots}; all of them stay granted, only new adds are refused`);
687
1008
  }
688
- this.publish(key, statuses);
689
1009
  return statuses;
690
1010
  }
691
1011
  /**
@@ -709,7 +1029,12 @@ var MultiRootRegistry = class extends Service {
709
1029
  */
710
1030
  lookupRecords(key) {
711
1031
  if (this.authorityState.kind !== "active") return void 0;
712
- return this.table?.get(key);
1032
+ try {
1033
+ return this.table?.get(key);
1034
+ } catch (error) {
1035
+ if (unknownStorageState(error)) this.enterStorageFailed("registry storage state cannot be confirmed; restart and reconcile", "quarantine");
1036
+ throw error;
1037
+ }
713
1038
  }
714
1039
  /**
715
1040
  * Re-classify one key from its stored records: every directory is `stat`ed
@@ -734,22 +1059,28 @@ var MultiRootRegistry = class extends Service {
734
1059
  async persist(key, statuses) {
735
1060
  const table = this.requireTable();
736
1061
  const durable = persistedPrimaryRoot.parse({ roots: migrateRootSkills(statuses.map(toPersistedRoot)) });
737
- if (statuses.length === 0) await table.delete(key);
738
- else await table.put(key, durable);
1062
+ try {
1063
+ if (statuses.length === 0) await table.delete(key);
1064
+ else await table.put(key, durable);
1065
+ } catch (error) {
1066
+ if (unknownStorageState(error)) this.enterStorageFailed("registry write result is unknown; restart and reconcile durable state", "quarantine");
1067
+ throw error;
1068
+ }
739
1069
  const classified = statuses.length === 0 ? [] : classifyStoredRoots(key, durable.roots.map(toRegisteredRoot));
740
1070
  this.cache.set(key, classified);
741
1071
  this.publish(key, classified);
742
1072
  return classified;
743
1073
  }
744
1074
  /**
745
- * Run one job for one primary root after every job already queued for that
746
- * root. The whole read → validate → write sequence belongs inside the job:
1075
+ * Run one job after every store job already queued. The whole
1076
+ * read → validate → write sequence belongs inside the job:
747
1077
  * the storage domain serializes individual writes, so without this the loser
748
1078
  * of a race reads a snapshot that is already stale and writes it back.
749
1079
  */
750
1080
  async serialize(key, job) {
751
- const run = (this.chains.get(key) ?? Promise.resolve()).then(job);
1081
+ const run = this.operationChain.then(job);
752
1082
  const tail = run.then(() => void 0, () => void 0);
1083
+ this.operationChain = tail;
753
1084
  this.chains.set(key, tail);
754
1085
  try {
755
1086
  return await run;
@@ -781,7 +1112,7 @@ var MultiRootRegistry = class extends Service {
781
1112
  const withheld = statuses.filter((status) => status.state !== "available");
782
1113
  const signature = [...statuses.filter((status) => status.state === "available").map((status) => `available:${status.access}:${recordedGrantOf(status)}`), ...withheld.map((status) => `${status.state}:${status.invalidReason ?? ""}:${status.access}:${status.path}:${recordedGrantOf(status) ?? ""}`)].join("\n");
783
1114
  const previousMetadata = this.metadataPublished.get(key);
784
- const metadata = JSON.stringify(statuses.map((row) => [
1115
+ const metadata = JSON.stringify(this.shared ? statuses : statuses.map((row) => [
785
1116
  row.id,
786
1117
  row.aliasId,
787
1118
  row.skillsEnabled,
@@ -789,11 +1120,22 @@ var MultiRootRegistry = class extends Service {
789
1120
  ]));
790
1121
  this.metadataPublished.set(key, metadata);
791
1122
  if ((this.published.get(key) ?? "") === signature) {
792
- if (previousMetadata !== metadata && (previousMetadata !== void 0 || statuses.length > 0)) for (const listener of this.listeners) listener(key);
1123
+ if (previousMetadata !== metadata && (previousMetadata !== void 0 || statuses.length > 0)) {
1124
+ this.operationRevision++;
1125
+ this.deliverNotifications(key);
1126
+ }
793
1127
  return;
794
1128
  }
795
- this.published.set(key, signature);
796
- const effective = statuses.flatMap((status) => {
1129
+ if (signature === "") this.published.delete(key);
1130
+ else this.published.set(key, signature);
1131
+ this.operationRevision++;
1132
+ const effective = this.projectRoots(statuses);
1133
+ this.ctx.multiRootScope.setAdditionalRoots(key, effective);
1134
+ 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`);
1135
+ this.deliverNotifications(key);
1136
+ }
1137
+ projectRoots(statuses) {
1138
+ return statuses.flatMap((status) => {
797
1139
  const recorded = recordedGrantOf(status);
798
1140
  if (status.state === "invalid" && status.invalidReason !== "membership-conflict" || recorded === void 0) return [];
799
1141
  return [{
@@ -805,10 +1147,188 @@ var MultiRootRegistry = class extends Service {
805
1147
  ...status.alias === void 0 ? {} : { alias: status.alias }
806
1148
  }];
807
1149
  });
808
- this.ctx.multiRootScope.setAdditionalRoots(key, effective);
809
- 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`);
1150
+ }
1151
+ snapshotOf(primary) {
1152
+ return snapshotSchema.parse({
1153
+ protocol: 1,
1154
+ store: this.medium.id,
1155
+ epoch: this.operationEpoch,
1156
+ revision: this.operationRevision,
1157
+ primary,
1158
+ roots: this.cache.get(primary) ?? [],
1159
+ available: this.authorityState.kind === "active"
1160
+ });
1161
+ }
1162
+ async captureSnapshot(primary) {
1163
+ const key = canonicalRoot(primary);
1164
+ return await this.serialize(key, () => {
1165
+ this.requireActive();
1166
+ this.reclassify(key);
1167
+ return this.snapshotOf(key);
1168
+ });
1169
+ }
1170
+ notifyShared(key) {
1171
+ if (this.server && this.authorityState.kind === "active") this.server.changed(this.snapshotOf(key));
1172
+ }
1173
+ /** Depth of {@link commit}; nested calls share the outer round's buffers. */
1174
+ commitDepth = 0;
1175
+ /** Keys whose publish inside a commit produced deferred notifications, first-seen order. */
1176
+ commitNotifications = /* @__PURE__ */ new Set();
1177
+ /**
1178
+ * Run `body` as one Registry commit: scope writes inside it go through
1179
+ * `multiRootScope.batch`, and the `notifyShared` broadcasts plus Registry
1180
+ * listener calls `publish()` produces are collected per key instead of
1181
+ * delivered. When the OUTERMOST commit ends — also when `body` throws — the
1182
+ * scope batch flushes first, then the broadcasts go out in first-seen key
1183
+ * order, then the listeners: no synchronous callback can observe a
1184
+ * half-committed state.
1185
+ */
1186
+ commit(body) {
1187
+ this.commitDepth++;
1188
+ try {
1189
+ return this.ctx.multiRootScope.batch(body);
1190
+ } finally {
1191
+ if (--this.commitDepth === 0) {
1192
+ const keys = [...this.commitNotifications];
1193
+ this.commitNotifications.clear();
1194
+ for (const key of keys) this.notifyShared(key);
1195
+ for (const key of keys) for (const listener of this.listeners) listener(key);
1196
+ }
1197
+ }
1198
+ }
1199
+ /** Deliver one key's notifications now, or collect them inside a commit. */
1200
+ deliverNotifications(key) {
1201
+ if (this.commitDepth > 0) {
1202
+ this.commitNotifications.add(key);
1203
+ return;
1204
+ }
1205
+ this.notifyShared(key);
810
1206
  for (const listener of this.listeners) listener(key);
811
1207
  }
1208
+ applySnapshot(snapshot) {
1209
+ if (!this.client || snapshot.store !== this.medium?.id || snapshot.epoch !== this.client.epoch) return;
1210
+ const previous = this.remoteRevisions.get(snapshot.primary) ?? -1;
1211
+ if (snapshot.revision < previous) return;
1212
+ this.remoteRevisions.set(snapshot.primary, snapshot.revision);
1213
+ const rows = snapshot.roots.map((row) => Object.fromEntries(Object.entries({
1214
+ ...row,
1215
+ id: additionalRootId(row.id)
1216
+ }).filter(([, value]) => value !== void 0)));
1217
+ this.commit(() => {
1218
+ this.cache.set(snapshot.primary, rows);
1219
+ this.publish(snapshot.primary, rows);
1220
+ this.ctx.multiRootScope.setRegistryAuthority(snapshot.available);
1221
+ });
1222
+ this.remoteProjectionReady = snapshot.available;
1223
+ this.pendingSync.delete(snapshot.primary);
1224
+ }
1225
+ remoteUnavailable() {
1226
+ try {
1227
+ this.connectionGeneration++;
1228
+ this.client = void 0;
1229
+ this.remoteRevisions.clear();
1230
+ this.pendingSync.clear();
1231
+ this.authorityState = { kind: "contended" };
1232
+ this.ctx.multiRootScope.setRegistryAuthority(false);
1233
+ if (!this.remoteProjectionReady) return;
1234
+ this.remoteProjectionReady = false;
1235
+ for (const [primary, rows] of this.cache) this.withholdScope(primary, rows);
1236
+ } finally {
1237
+ this.recovery.arm();
1238
+ this.noteRecoveryTransition();
1239
+ }
1240
+ }
1241
+ /**
1242
+ * Resynchronize each still-pending tracked primary outside the authority
1243
+ * queue: per-key snapshots through the live client. A failed key stays in
1244
+ * `pendingSync` for the next tick; a lost connection abandons the round —
1245
+ * `remoteUnavailable` already emptied the set.
1246
+ */
1247
+ async syncProjection() {
1248
+ for (const key of [...this.pendingSync]) try {
1249
+ await this.remoteSnapshot("snapshot", [key]);
1250
+ } catch {
1251
+ if (!this.client) break;
1252
+ }
1253
+ }
1254
+ /**
1255
+ * Write one key's withheld-only projection into the scope, keeping
1256
+ * `published` truthful: the signature records what the scope HOLDS, never
1257
+ * what `publish()` last computed — a withheld signature can never equal a
1258
+ * publish signature, so an empty snapshot afterwards really writes the
1259
+ * retraction. A repeated write of the same withheld projection is a no-op
1260
+ * (the same-signature skip is also the republish gate of a repeated
1261
+ * downgrade). `metadataPublished` is untouched: a withheld write announces
1262
+ * nothing to the Registry view.
1263
+ */
1264
+ withholdScope(key, rows) {
1265
+ const roots = this.projectRoots(rows).map((root) => ({
1266
+ ...root,
1267
+ withheld: true
1268
+ }));
1269
+ const signature = roots.length === 0 ? "" : `\u0000withheld\n${JSON.stringify(roots)}`;
1270
+ if ((this.published.get(key) ?? "") === signature) return;
1271
+ if (signature === "") this.published.delete(key);
1272
+ else this.published.set(key, signature);
1273
+ this.ctx.multiRootScope.setAdditionalRoots(key, roots);
1274
+ }
1275
+ async remoteSnapshot(method, args) {
1276
+ const client = this.client, generation = this.connectionGeneration;
1277
+ if (!client) throw new Error("registry authority is unavailable");
1278
+ const value = await client.request(method, args);
1279
+ let snapshot;
1280
+ try {
1281
+ snapshot = snapshotSchema.parse(value);
1282
+ } catch (error) {
1283
+ client.close();
1284
+ throw error;
1285
+ }
1286
+ if (generation !== this.connectionGeneration || client !== this.client || snapshot.store !== this.medium?.id || snapshot.epoch !== client.epoch || snapshot.primary !== canonicalRoot(String(args[0]))) {
1287
+ client.close();
1288
+ throw new Error("stale registry connection or authority epoch");
1289
+ }
1290
+ this.applySnapshot(snapshot);
1291
+ return snapshot;
1292
+ }
1293
+ async remote(method, args) {
1294
+ await this.remoteSnapshot(method, args);
1295
+ return this.list(String(args[0]));
1296
+ }
1297
+ async dispatchShared(method, values) {
1298
+ const args = parseArguments(method, values);
1299
+ const primary = String(args[0]);
1300
+ if (!isAbsolute(primary)) throw new Error("registry primary must be absolute");
1301
+ if (method === "snapshot" || method === "admit") return this.captureSnapshot(primary);
1302
+ const ref = args[1];
1303
+ switch (method) {
1304
+ case "add":
1305
+ await this.add(primary, args[1]);
1306
+ break;
1307
+ case "removeAt":
1308
+ await this.removeAt(primary, ref);
1309
+ break;
1310
+ case "setAccess":
1311
+ await this.setAccess(primary, ref, args[2]);
1312
+ break;
1313
+ case "setAlias":
1314
+ await this.setAlias(primary, ref, args[2] === null ? void 0 : args[2]);
1315
+ break;
1316
+ case "setSkillsEnabled":
1317
+ await this.setSkillsEnabled(primary, ref, args[2]);
1318
+ break;
1319
+ case "setAliasId":
1320
+ await this.setAliasId(primary, ref, args[2]);
1321
+ break;
1322
+ case "move":
1323
+ await this.move(primary, ref, args[2] === null ? void 0 : args[2]);
1324
+ break;
1325
+ case "recheck":
1326
+ await this.recheck(primary);
1327
+ break;
1328
+ default: throw new Error("unsupported registry method");
1329
+ }
1330
+ return this.captureSnapshot(primary);
1331
+ }
812
1332
  /**
813
1333
  * The open table.
814
1334
  * @returns the live table.
@@ -817,6 +1337,7 @@ var MultiRootRegistry = class extends Service {
817
1337
  * or an error when the service has not started.
818
1338
  */
819
1339
  requireTable() {
1340
+ if (this.authorityState.kind === "follower") throw new Error("a follower never opens registry storage");
820
1341
  if (this.authorityState.kind === "contended") throw new RootValidationError("registry-contended", REGISTRY_CONTENDED_MESSAGE);
821
1342
  if (this.authorityState.kind === "config-invalid") throw new RootValidationError("config-invalid", `the root registry configuration is invalid (${this.authorityState.reason}); no additional root is granted`);
822
1343
  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`);
@@ -837,36 +1358,93 @@ var MultiRootRegistry = class extends Service {
837
1358
  * retries here (the command `list` and the panel Retry button both call
838
1359
  * {@link MultiRootRegistry.refresh}).
839
1360
  */
840
- async ensureAuthority() {
841
- await this.serializeAuthority(async () => {
842
- if (this.disposed) return;
843
- if (this.authorityState.kind === "active") return;
844
- if (this.authorityState.kind === "config-invalid") return;
845
- if (this.lease !== void 0) {
846
- await this.openDomain();
847
- return;
848
- }
849
- const leasePath = this.leasePath;
850
- if (leasePath === void 0 || leasePath === "") {
851
- this.enterStorageFailed(`${DOMAIN_NAME}: leasePath is not configured; set it next to storage-json.root (default $DSH_HOME/storages/${DOMAIN_NAME}.lock)`);
852
- return;
853
- }
854
- if (!isAbsolute(leasePath)) {
855
- this.enterStorageFailed(`${DOMAIN_NAME}: leasePath must be an absolute path`);
856
- return;
857
- }
858
- try {
859
- this.lease = await RegistryAuthorityLease.acquire(leasePath);
860
- } catch (error) {
861
- if (error instanceof RegistryLeaseContendedError) {
862
- this.enterContended();
1361
+ async ensureAuthority(options = {}) {
1362
+ try {
1363
+ await this.serializeAuthority(async () => {
1364
+ if (this.disposed) return;
1365
+ if (this.authorityState.kind === "active" || this.client !== void 0) return;
1366
+ if (this.authorityState.kind === "config-invalid") return;
1367
+ if (this.authorityState.kind === "storage-failed" && this.authorityState.recovery !== "retryable") return;
1368
+ if (this.lease !== void 0) {
1369
+ if (this.domain !== void 0) return;
1370
+ await this.openDomain();
863
1371
  return;
864
1372
  }
865
- this.enterStorageFailed(`${DOMAIN_NAME}: cannot acquire the registry lease: ${error instanceof Error ? error.message : String(error)}`);
866
- return;
867
- }
868
- await this.openDomain();
869
- });
1373
+ const leasePath = this.leasePath;
1374
+ if (leasePath === void 0 || leasePath === "") {
1375
+ this.enterStorageFailed(`${DOMAIN_NAME}: leasePath is not configured; set it next to storage-json.root (default $DSH_HOME/storages/${DOMAIN_NAME}.lock)`, "config");
1376
+ return;
1377
+ }
1378
+ if (!isAbsolute(leasePath)) {
1379
+ this.enterStorageFailed(`${DOMAIN_NAME}: leasePath must be an absolute path`, "config");
1380
+ return;
1381
+ }
1382
+ if (this.sharedStorageRoot !== void 0 && this.medium === void 0) try {
1383
+ if (!isAbsolute(this.sharedStorageRoot)) throw new SharedConfigError("sharedStorageRoot must be absolute");
1384
+ const medium = storeIdentity(this.sharedStorageRoot);
1385
+ if (storeIdentity(dirname(leasePath)).id !== medium.id || basename(leasePath) !== `multi_root_workspace.lock`) throw new SharedConfigError("shared registry requires its canonical medium lock, without alternate lock names");
1386
+ this.medium = medium;
1387
+ } catch (error) {
1388
+ this.enterStorageFailed(String(error), error instanceof SharedConfigError ? "config" : "retryable");
1389
+ return;
1390
+ }
1391
+ try {
1392
+ this.lease = await RegistryAuthorityLease.acquire(leasePath);
1393
+ } catch (error) {
1394
+ if (error instanceof RegistryLeaseContendedError) {
1395
+ this.enterContended();
1396
+ if (this.medium !== void 0) for (const delay of options.background ? [0] : [
1397
+ 0,
1398
+ 50,
1399
+ 150
1400
+ ]) {
1401
+ if (delay) await new Promise((resolve) => setTimeout(resolve, delay));
1402
+ if (this.disposed) return;
1403
+ const generation = ++this.connectionGeneration;
1404
+ const controller = new AbortController();
1405
+ this.connectionAbort = controller;
1406
+ let adopted = false;
1407
+ try {
1408
+ const client = await AuthorityClient.connect(this.medium.endpoint, this.medium.id, this.maxRoots, (snapshot) => {
1409
+ if (adopted && generation === this.connectionGeneration) this.applySnapshot(snapshot);
1410
+ }, () => {
1411
+ if (adopted && generation === this.connectionGeneration) this.remoteUnavailable();
1412
+ }, controller.signal);
1413
+ if (this.disposed || generation !== this.connectionGeneration) {
1414
+ client.close();
1415
+ return;
1416
+ }
1417
+ this.client = client;
1418
+ adopted = true;
1419
+ this.connectionFailure = void 0;
1420
+ this.authorityState = {
1421
+ kind: "follower",
1422
+ epoch: client.epoch
1423
+ };
1424
+ this.pendingSync = new Set(this.cache.keys());
1425
+ if (this.pendingSync.size === 0) {
1426
+ this.ctx.multiRootScope.setRegistryAuthority(true);
1427
+ this.remoteProjectionReady = true;
1428
+ }
1429
+ this.recovery.kick();
1430
+ break;
1431
+ } catch (error) {
1432
+ this.connectionFailure = String(error);
1433
+ } finally {
1434
+ if (this.connectionAbort === controller) this.connectionAbort = void 0;
1435
+ }
1436
+ }
1437
+ return;
1438
+ }
1439
+ this.enterStorageFailed(`${DOMAIN_NAME}: cannot acquire the registry lease: ${error instanceof Error ? error.message : String(error)}`, "retryable");
1440
+ return;
1441
+ }
1442
+ await this.openDomain();
1443
+ });
1444
+ } finally {
1445
+ this.recovery.arm();
1446
+ this.noteRecoveryTransition();
1447
+ }
870
1448
  }
871
1449
  /**
872
1450
  * Open the domain under an already-held lease and publish every stored root.
@@ -876,35 +1454,78 @@ var MultiRootRegistry = class extends Service {
876
1454
  async openDomain() {
877
1455
  let domain;
878
1456
  try {
879
- domain = await this.ctx.storageDomain.open(multiRootDomainSpec);
1457
+ domain = this.medium === void 0 ? await this.ctx.storageDomain.open(multiRootDomainSpec) : await openAuthorityStorage(this.authorityStorageContext, this.medium.root, multiRootDomainSpec);
880
1458
  } catch (error) {
881
1459
  const reason = `${DOMAIN_NAME}: ${error instanceof Error ? error.message : String(error)}`;
882
- 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}`);
883
- this.enterStorageFailed(reason);
1460
+ if (this.lastOpenFailure !== reason) {
1461
+ this.lastOpenFailure = reason;
1462
+ 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}`);
1463
+ }
1464
+ this.enterStorageFailed(reason, "retryable");
884
1465
  return;
885
1466
  }
886
- this.domain = domain;
887
- this.table = domain.table(TABLE_NAME);
888
- this.authorityState = { kind: "active" };
889
- this.loggedContended = false;
890
- this.cache.clear();
891
- this.published.clear();
892
- for (const [primaryRoot, record] of this.table.entries()) await this.serialize(canonicalRoot(primaryRoot), async () => {
893
- const migrated = { roots: migrateRootSkills(record.roots) };
894
- let accepted = record;
895
- if (JSON.stringify(migrated) !== JSON.stringify(record)) try {
896
- await this.table.put(primaryRoot, migrated);
897
- accepted = migrated;
898
- } catch (error) {
899
- this.ctx.logger.warn(`multi-root skills: metadata migration failed; Skills remain withheld: ${String(error)}`);
1467
+ const epoch = randomUUID();
1468
+ const staged = /* @__PURE__ */ new Map();
1469
+ let table;
1470
+ try {
1471
+ table = domain.table(TABLE_NAME);
1472
+ for (const [primaryRoot, record] of table.entries()) {
1473
+ const migrated = { roots: migrateRootSkills(record.roots) };
1474
+ let accepted = record;
1475
+ if (JSON.stringify(migrated) !== JSON.stringify(record)) try {
1476
+ await table.put(primaryRoot, migrated);
1477
+ accepted = migrated;
1478
+ } catch (error) {
1479
+ if (unknownStorageState(error)) {
1480
+ this.domain = domain;
1481
+ this.enterStorageFailed("Skills migration result is unknown; restart and reconcile", "quarantine");
1482
+ return;
1483
+ }
1484
+ this.ctx.logger.warn(`multi-root skills: metadata migration failed; Skills remain withheld: ${String(error)}`);
1485
+ }
1486
+ const key = canonicalRoot(primaryRoot);
1487
+ staged.set(key, this.classify(key, accepted));
900
1488
  }
901
- this.accept(primaryRoot, accepted);
1489
+ } catch (error) {
1490
+ if (unknownStorageState(error)) {
1491
+ this.domain = domain;
1492
+ this.enterStorageFailed("registry storage state cannot be confirmed; restart and reconcile", "quarantine");
1493
+ return;
1494
+ }
1495
+ try {
1496
+ await domain.close();
1497
+ } catch {}
1498
+ throw error;
1499
+ }
1500
+ let server;
1501
+ if (this.medium !== void 0) try {
1502
+ server = await AuthorityServer.listen(this.medium.endpoint, this.medium.id, epoch, this.maxRoots, (method, args) => this.dispatchShared(method, args));
1503
+ } catch (error) {
1504
+ await domain.close();
1505
+ this.enterStorageFailed(`cannot start shared registry: ${String(error)}`, "retryable");
1506
+ return;
1507
+ }
1508
+ this.commit(() => {
1509
+ this.domain = domain;
1510
+ this.table = table;
1511
+ this.operationEpoch = epoch;
1512
+ this.operationRevision = 0;
1513
+ this.authorityState = { kind: "active" };
1514
+ this.loggedContended = false;
1515
+ this.server = server;
1516
+ const earlier = new Set(this.cache.keys());
1517
+ this.cache.clear();
1518
+ for (const [key, statuses] of staged) this.cache.set(key, statuses);
1519
+ for (const key of /* @__PURE__ */ new Set([...earlier, ...staged.keys()])) this.publish(key, staged.get(key) ?? []);
1520
+ this.ctx.multiRootScope.setRegistryAuthority(true);
1521
+ this.lastOpenFailure = void 0;
902
1522
  });
903
- this.ctx.multiRootScope.setRegistryAuthority(true);
904
1523
  }
905
1524
  /** Fail closed: empty scope, no table, contended state. */
906
1525
  enterContended() {
907
- this.withdrawPublished();
1526
+ this.operationEpoch = void 0;
1527
+ if (this.shared) this.remoteUnavailable();
1528
+ else this.withdrawPublished();
908
1529
  this.ctx.multiRootScope.setRegistryAuthority(false);
909
1530
  this.table = void 0;
910
1531
  this.authorityState = { kind: "contended" };
@@ -914,14 +1535,21 @@ var MultiRootRegistry = class extends Service {
914
1535
  }
915
1536
  }
916
1537
  /** Fail closed: empty scope, no table, storage-failed state. The lease, if held, stays. */
917
- enterStorageFailed(reason) {
918
- this.withdrawPublished();
1538
+ enterStorageFailed(reason, recovery) {
1539
+ this.operationEpoch = void 0;
919
1540
  this.ctx.multiRootScope.setRegistryAuthority(false);
1541
+ if (this.shared) {
1542
+ for (const [key, rows] of this.cache) this.withholdScope(key, rows);
1543
+ this.server?.close();
1544
+ this.server = void 0;
1545
+ } else this.withdrawPublished();
920
1546
  this.table = void 0;
921
1547
  this.authorityState = {
922
1548
  kind: "storage-failed",
923
- reason
1549
+ reason,
1550
+ recovery
924
1551
  };
1552
+ this.noteRecoveryTransition();
925
1553
  }
926
1554
  /** Drop every grant this process has published. Idempotent. */
927
1555
  withdrawPublished() {
@@ -945,8 +1573,16 @@ var MultiRootRegistry = class extends Service {
945
1573
  */
946
1574
  async releaseAuthority() {
947
1575
  this.disposed = true;
1576
+ this.connectionGeneration++;
1577
+ this.recovery.dispose();
1578
+ this.connectionAbort?.abort();
1579
+ this.client?.close();
1580
+ this.client = void 0;
948
1581
  await this.serializeAuthority(async () => {
1582
+ this.server?.close();
1583
+ this.server = void 0;
949
1584
  this.table = void 0;
1585
+ this.operationEpoch = void 0;
950
1586
  this.authorityState = { kind: "contended" };
951
1587
  this.ctx.multiRootScope.setRegistryAuthority(false);
952
1588
  await this.drainMutations();
@@ -964,7 +1600,7 @@ var MultiRootRegistry = class extends Service {
964
1600
  });
965
1601
  }
966
1602
  /**
967
- * Wait until no per-primary-root mutation is queued or running.
1603
+ * Wait until no store work is queued or running (operation I/O is outside).
968
1604
  *
969
1605
  * One pass is enough, and only because the authority state has already been
970
1606
  * flipped: no further mutation can be admitted (it would fail in
@@ -1063,5 +1699,12 @@ function normalizeAlias(alias) {
1063
1699
  if (trimmed.length > 120) throw new RootValidationError("invalid-alias", `an alias must be at most 120 characters`);
1064
1700
  return trimmed;
1065
1701
  }
1702
+ /** Structural across storage releases; older versions do not expose this code. */
1703
+ function unknownStorageState(error, seen = /* @__PURE__ */ new Set()) {
1704
+ if (error === null || typeof error !== "object" || seen.has(error)) return false;
1705
+ seen.add(error);
1706
+ const value = error;
1707
+ return value.code === "unknown-state" || unknownStorageState(value.cause, seen) || Array.isArray(value.errors) && value.errors.some((item) => unknownStorageState(item, seen));
1708
+ }
1066
1709
  //#endregion
1067
1710
  export { MultiRootRegistry as a, multiRootDomainSpec as c, MAX_ALIAS_LENGTH as i, RegistryAuthorityLease as l, DOMAIN_NAME as n, REGISTRY_CONTENDED_MESSAGE as o, MAX_ADDITIONAL_ROOTS_CEILING as r, TABLE_NAME as s, DEFAULT_MAX_ADDITIONAL_ROOTS as t, RegistryLeaseContendedError as u };