@dsh-electron/dsh-plugin-multi-root-workspace 0.1.12 → 0.1.14

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 (49) hide show
  1. package/CHANGELOG.en.md +29 -1
  2. package/CHANGELOG.md +29 -1
  3. package/README.en.md +9 -8
  4. package/README.md +9 -8
  5. package/cordis.patch.yml +4 -0
  6. package/detail/DETAIL.en.md +1 -0
  7. package/detail/DETAIL.md +1 -0
  8. package/detail/preview.svg +1 -0
  9. package/detail/preview.zh.svg +1 -0
  10. package/lib/client.js +492 -87
  11. package/lib/client.js.map +1 -1
  12. package/lib/{command-CmLekG1M.js → command-Dcz6nULo.js} +25 -3
  13. package/lib/command.js +1 -1
  14. package/lib/{compat-Bg2HFVMX.js → compat-veDqCOoa.js} +3 -1
  15. package/lib/compat.js +1 -1
  16. package/lib/{fs-CfzJq2wd.js → fs-Uws0ZpmV.js} +38 -0
  17. package/lib/fs.js +1 -1
  18. package/lib/index.js +4 -4
  19. package/lib/{native-win32-2waCRkPI.js → native-win32-CRNXr11U.js} +2 -1
  20. package/lib/{protocol-4zdwV7u4.js → protocol-CnR1KTXy.js} +62 -9
  21. package/lib/{registry-YTUOjmVu.js → registry-DR_sMuvS.js} +425 -125
  22. package/lib/{registry-lease-win32-DVTq9d4D.js → registry-lease-win32-DDO-c7ig.js} +2 -1
  23. package/lib/registry.js +2 -2
  24. package/lib/scope.js +36 -2
  25. package/lib/types/authority/protocol.d.ts +1 -1
  26. package/lib/types/authority/recovery.d.ts +58 -0
  27. package/lib/types/client/RootSwitcher.d.ts +8 -3
  28. package/lib/types/client/auto-review-settings.d.ts +18 -0
  29. package/lib/types/client/files-tree/FilesTree.d.ts +4 -1
  30. package/lib/types/client/files-tree/adapter.d.ts +63 -1
  31. package/lib/types/client/files-tree/locales.d.ts +1 -1
  32. package/lib/types/client/locales.d.ts +12 -0
  33. package/lib/types/client/open-in-app/locales.d.ts +1 -1
  34. package/lib/types/client/skills-settings.d.ts +12 -0
  35. package/lib/types/client/styles.d.ts +1 -1
  36. package/lib/types/compat/auto-review-client.d.ts +58 -0
  37. package/lib/types/compat/connection-rpc.d.ts +12 -0
  38. package/lib/types/compat/dsh-version.d.ts +1 -1
  39. package/lib/types/compat/files-tree.d.ts +36 -0
  40. package/lib/types/compat/fs-patch.d.ts +6 -0
  41. package/lib/types/compat/koffi.d.ts +2 -0
  42. package/lib/types/fs.d.ts +3 -1
  43. package/lib/types/registry.d.ts +98 -0
  44. package/lib/types/scope.d.ts +18 -0
  45. package/locale/en.json +7 -0
  46. package/locale/zh.json +7 -0
  47. package/package.json +89 -82
  48. package/src/auto-review/README.en.md +1 -1
  49. package/src/auto-review/README.md +1 -1
@@ -2,7 +2,7 @@ import { a as runPrimaryOperation, n as freezeOperationScope, o as runRegistryOp
2
2
  import { i as isWindowsNetworkOrDevicePath } from "./windows-runner-contract-B5R2K3mM.js";
3
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";
4
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-4zdwV7u4.js";
5
+ import { a as snapshotSchema, i as parseArguments, r as AuthorityServer, s as storeIdentity, t as AuthorityClient } from "./protocol-CnR1KTXy.js";
6
6
  import { createRequire } from "node:module";
7
7
  import { closeSync, fstatSync, mkdirSync, openSync, statSync } from "node:fs";
8
8
  import { randomUUID } from "node:crypto";
@@ -94,6 +94,107 @@ function domainZod() {
94
94
  return createRequire(require.resolve("@deepseek-ai/dsh-storage-domain/package.json"))("zod");
95
95
  }
96
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
97
198
  //#region src/registry-lease.ts
98
199
  /**
99
200
  * Store-wide kernel lease for the multi-root registry.
@@ -164,7 +265,7 @@ var RegistryAuthorityLease = class RegistryAuthorityLease {
164
265
  mode: 448
165
266
  });
166
267
  if (process.platform === "win32") {
167
- const { acquireLockHandleWin32 } = await import("./registry-lease-win32-DVTq9d4D.js");
268
+ const { acquireLockHandleWin32 } = await import("./registry-lease-win32-DDO-c7ig.js");
168
269
  let handle;
169
270
  try {
170
271
  handle = await acquireLockHandleWin32(leasePath);
@@ -216,7 +317,7 @@ var RegistryAuthorityLease = class RegistryAuthorityLease {
216
317
  if (this.released) return;
217
318
  this.released = true;
218
319
  if (this.held.kind === "win32") {
219
- const { releaseLockHandleWin32 } = await import("./registry-lease-win32-DVTq9d4D.js");
320
+ const { releaseLockHandleWin32 } = await import("./registry-lease-win32-DDO-c7ig.js");
220
321
  await releaseLockHandleWin32(this.held.handle);
221
322
  return;
222
323
  }
@@ -291,6 +392,8 @@ const REGISTRY_CONTENDED_MESSAGE = "the root registry is owned by another DSH pr
291
392
  const DEFAULT_MAX_ADDITIONAL_ROOTS = 16;
292
393
  /** Hard ceiling for `maxAdditionalRoots`: values above it are config-invalid. */
293
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 {};
294
397
  /**
295
398
  * The durable-boundary validators. The builder is the zod module the
296
399
  * INSTALLED storage-domain resolves, not this package's own `zod` copy:
@@ -343,8 +446,18 @@ var MultiRootRegistry = class extends Service {
343
446
  client;
344
447
  connectionFailure;
345
448
  connectionGeneration = 0;
449
+ /** Local cancel of the in-flight handshake; teardown must not wait out hello. */
450
+ connectionAbort;
346
451
  remoteProjectionReady = false;
347
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;
348
461
  operationProof = Object.freeze({});
349
462
  /** Whether this process currently owns the store. */
350
463
  authorityState = { kind: "contended" };
@@ -352,6 +465,8 @@ var MultiRootRegistry = class extends Service {
352
465
  lease;
353
466
  /** Open domain handle; present only while {@link authority} is `active`. */
354
467
  domain;
468
+ /** The last failed `openDomain` reason; a repeat failure is not re-logged. */
469
+ lastOpenFailure;
355
470
  /** The open table; present once this process is the authority. */
356
471
  table;
357
472
  /** Last classified status list per canonical primary root. */
@@ -385,6 +500,19 @@ var MultiRootRegistry = class extends Service {
385
500
  this.authorityStorageContext = ctx;
386
501
  this.leasePath = config.leasePath;
387
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
+ });
388
516
  if (config.sharedStorageRoot !== void 0) this.ctx.multiRootScope.requireAdmission(this.operationProof);
389
517
  this.ctx.multiRootScope.setRegistryAuthority(false);
390
518
  const configured = config.maxAdditionalRoots === void 0 ? 16 : config.maxAdditionalRoots;
@@ -411,6 +539,7 @@ var MultiRootRegistry = class extends Service {
411
539
  this.ctx.effect(() => () => this.releaseAuthority(), "multi-root-registry: authority release");
412
540
  if (this.authorityState.kind === "config-invalid") {
413
541
  this.ctx.logger.error(`multi-root-workspace: ${this.authorityState.reason}; no additional root is granted`);
542
+ this.noteRecoveryTransition();
414
543
  return;
415
544
  }
416
545
  await this.ensureAuthority();
@@ -447,6 +576,52 @@ var MultiRootRegistry = class extends Service {
447
576
  get authority() {
448
577
  return this.authorityState;
449
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
+ }
450
625
  /**
451
626
  * The configured cap on stored additional-root records per primary root.
452
627
  * Surfaces show it next to the count so "N of LIMIT" is always explainable.
@@ -815,13 +990,22 @@ var MultiRootRegistry = class extends Service {
815
990
  /** Classify one stored record set and cache it. */
816
991
  accept(primaryRoot, record) {
817
992
  const key = canonicalRoot(primaryRoot);
818
- const statuses = classifyStoredRoots(key, record.roots.map(toRegisteredRoot));
993
+ const statuses = this.classify(key, record);
819
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));
820
1005
  if (statuses.length > this.maxRoots && !this.warnedOverLimit.has(key)) {
821
1006
  this.warnedOverLimit.add(key);
822
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`);
823
1008
  }
824
- this.publish(key, statuses);
825
1009
  return statuses;
826
1010
  }
827
1011
  /**
@@ -848,7 +1032,7 @@ var MultiRootRegistry = class extends Service {
848
1032
  try {
849
1033
  return this.table?.get(key);
850
1034
  } catch (error) {
851
- if (unknownStorageState(error)) this.enterStorageFailed("registry storage state cannot be confirmed; restart and reconcile");
1035
+ if (unknownStorageState(error)) this.enterStorageFailed("registry storage state cannot be confirmed; restart and reconcile", "quarantine");
852
1036
  throw error;
853
1037
  }
854
1038
  }
@@ -879,7 +1063,7 @@ var MultiRootRegistry = class extends Service {
879
1063
  if (statuses.length === 0) await table.delete(key);
880
1064
  else await table.put(key, durable);
881
1065
  } catch (error) {
882
- if (unknownStorageState(error)) this.enterStorageFailed("registry write result is unknown; restart and reconcile durable state");
1066
+ if (unknownStorageState(error)) this.enterStorageFailed("registry write result is unknown; restart and reconcile durable state", "quarantine");
883
1067
  throw error;
884
1068
  }
885
1069
  const classified = statuses.length === 0 ? [] : classifyStoredRoots(key, durable.roots.map(toRegisteredRoot));
@@ -938,18 +1122,17 @@ var MultiRootRegistry = class extends Service {
938
1122
  if ((this.published.get(key) ?? "") === signature) {
939
1123
  if (previousMetadata !== metadata && (previousMetadata !== void 0 || statuses.length > 0)) {
940
1124
  this.operationRevision++;
941
- this.notifyShared(key);
942
- for (const listener of this.listeners) listener(key);
1125
+ this.deliverNotifications(key);
943
1126
  }
944
1127
  return;
945
1128
  }
946
- this.published.set(key, signature);
1129
+ if (signature === "") this.published.delete(key);
1130
+ else this.published.set(key, signature);
947
1131
  this.operationRevision++;
948
1132
  const effective = this.projectRoots(statuses);
949
1133
  this.ctx.multiRootScope.setAdditionalRoots(key, effective);
950
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`);
951
- this.notifyShared(key);
952
- for (const listener of this.listeners) listener(key);
1135
+ this.deliverNotifications(key);
953
1136
  }
954
1137
  projectRoots(statuses) {
955
1138
  return statuses.flatMap((status) => {
@@ -987,6 +1170,41 @@ var MultiRootRegistry = class extends Service {
987
1170
  notifyShared(key) {
988
1171
  if (this.server && this.authorityState.kind === "active") this.server.changed(this.snapshotOf(key));
989
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);
1206
+ for (const listener of this.listeners) listener(key);
1207
+ }
990
1208
  applySnapshot(snapshot) {
991
1209
  if (!this.client || snapshot.store !== this.medium?.id || snapshot.epoch !== this.client.epoch) return;
992
1210
  const previous = this.remoteRevisions.get(snapshot.primary) ?? -1;
@@ -996,24 +1214,63 @@ var MultiRootRegistry = class extends Service {
996
1214
  ...row,
997
1215
  id: additionalRootId(row.id)
998
1216
  }).filter(([, value]) => value !== void 0)));
999
- this.cache.set(snapshot.primary, rows);
1000
- this.publish(snapshot.primary, rows);
1001
- this.ctx.multiRootScope.setRegistryAuthority(snapshot.available);
1217
+ this.commit(() => {
1218
+ this.cache.set(snapshot.primary, rows);
1219
+ this.publish(snapshot.primary, rows);
1220
+ this.ctx.multiRootScope.setRegistryAuthority(snapshot.available);
1221
+ });
1002
1222
  this.remoteProjectionReady = snapshot.available;
1223
+ this.pendingSync.delete(snapshot.primary);
1003
1224
  }
1004
1225
  remoteUnavailable() {
1005
- this.connectionGeneration++;
1006
- this.client = void 0;
1007
- this.remoteRevisions.clear();
1008
- this.authorityState = { kind: "contended" };
1009
- this.ctx.multiRootScope.setRegistryAuthority(false);
1010
- if (!this.remoteProjectionReady) return;
1011
- this.remoteProjectionReady = false;
1012
- for (const [primary, rows] of this.cache) this.ctx.multiRootScope.setAdditionalRoots(primary, this.projectRoots(rows).map((root) => ({
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) => ({
1013
1266
  ...root,
1014
1267
  withheld: true
1015
- })));
1016
- this.published.clear();
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);
1017
1274
  }
1018
1275
  async remoteSnapshot(method, args) {
1019
1276
  const client = this.client, generation = this.connectionGeneration;
@@ -1101,76 +1358,93 @@ var MultiRootRegistry = class extends Service {
1101
1358
  * retries here (the command `list` and the panel Retry button both call
1102
1359
  * {@link MultiRootRegistry.refresh}).
1103
1360
  */
1104
- async ensureAuthority() {
1105
- await this.serializeAuthority(async () => {
1106
- if (this.disposed) return;
1107
- if (this.authorityState.kind === "active" || this.client !== void 0) return;
1108
- if (this.authorityState.kind === "config-invalid") return;
1109
- if (this.lease !== void 0) {
1110
- if (this.domain !== void 0) return;
1111
- await this.openDomain();
1112
- return;
1113
- }
1114
- const leasePath = this.leasePath;
1115
- if (leasePath === void 0 || leasePath === "") {
1116
- this.enterStorageFailed(`${DOMAIN_NAME}: leasePath is not configured; set it next to storage-json.root (default $DSH_HOME/storages/${DOMAIN_NAME}.lock)`);
1117
- return;
1118
- }
1119
- if (!isAbsolute(leasePath)) {
1120
- this.enterStorageFailed(`${DOMAIN_NAME}: leasePath must be an absolute path`);
1121
- return;
1122
- }
1123
- if (this.sharedStorageRoot !== void 0 && this.medium === void 0) try {
1124
- if (!isAbsolute(this.sharedStorageRoot)) throw new Error("sharedStorageRoot must be absolute");
1125
- this.medium = storeIdentity(this.sharedStorageRoot);
1126
- if (storeIdentity(dirname(leasePath)).id !== this.medium.id || basename(leasePath) !== `multi_root_workspace.lock`) throw new Error("shared registry requires its canonical medium lock, without alternate lock names");
1127
- } catch (error) {
1128
- this.enterStorageFailed(String(error));
1129
- return;
1130
- }
1131
- try {
1132
- this.lease = await RegistryAuthorityLease.acquire(leasePath);
1133
- } catch (error) {
1134
- if (error instanceof RegistryLeaseContendedError) {
1135
- this.enterContended();
1136
- if (this.medium !== void 0) for (const delay of [
1137
- 0,
1138
- 50,
1139
- 150
1140
- ]) {
1141
- if (delay) await new Promise((resolve) => setTimeout(resolve, delay));
1142
- if (this.disposed) return;
1143
- const generation = ++this.connectionGeneration;
1144
- let adopted = false;
1145
- try {
1146
- const client = await AuthorityClient.connect(this.medium.endpoint, this.medium.id, this.maxRoots, (snapshot) => {
1147
- if (adopted && generation === this.connectionGeneration) this.applySnapshot(snapshot);
1148
- }, () => {
1149
- if (adopted && generation === this.connectionGeneration) this.remoteUnavailable();
1150
- });
1151
- if (this.disposed || generation !== this.connectionGeneration) {
1152
- client.close();
1153
- return;
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();
1371
+ return;
1372
+ }
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;
1154
1435
  }
1155
- this.client = client;
1156
- adopted = true;
1157
- this.connectionFailure = void 0;
1158
- this.authorityState = {
1159
- kind: "follower",
1160
- epoch: client.epoch
1161
- };
1162
- break;
1163
- } catch (error) {
1164
- this.connectionFailure = String(error);
1165
1436
  }
1437
+ return;
1166
1438
  }
1439
+ this.enterStorageFailed(`${DOMAIN_NAME}: cannot acquire the registry lease: ${error instanceof Error ? error.message : String(error)}`, "retryable");
1167
1440
  return;
1168
1441
  }
1169
- this.enterStorageFailed(`${DOMAIN_NAME}: cannot acquire the registry lease: ${error instanceof Error ? error.message : String(error)}`);
1170
- return;
1171
- }
1172
- await this.openDomain();
1173
- });
1442
+ await this.openDomain();
1443
+ });
1444
+ } finally {
1445
+ this.recovery.arm();
1446
+ this.noteRecoveryTransition();
1447
+ }
1174
1448
  }
1175
1449
  /**
1176
1450
  * Open the domain under an already-held lease and publish every stored root.
@@ -1183,43 +1457,69 @@ var MultiRootRegistry = class extends Service {
1183
1457
  domain = this.medium === void 0 ? await this.ctx.storageDomain.open(multiRootDomainSpec) : await openAuthorityStorage(this.authorityStorageContext, this.medium.root, multiRootDomainSpec);
1184
1458
  } catch (error) {
1185
1459
  const reason = `${DOMAIN_NAME}: ${error instanceof Error ? error.message : String(error)}`;
1186
- 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}`);
1187
- 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");
1188
1465
  return;
1189
1466
  }
1190
- this.domain = domain;
1191
- this.table = domain.table(TABLE_NAME);
1192
- this.operationEpoch = randomUUID();
1193
- this.operationRevision = 0;
1194
- this.authorityState = { kind: "active" };
1195
- this.loggedContended = false;
1196
- this.cache.clear();
1197
- this.published.clear();
1198
- for (const [primaryRoot, record] of this.table.entries()) await this.serialize(canonicalRoot(primaryRoot), async () => {
1199
- const migrated = { roots: migrateRootSkills(record.roots) };
1200
- let accepted = record;
1201
- if (JSON.stringify(migrated) !== JSON.stringify(record)) try {
1202
- await this.table.put(primaryRoot, migrated);
1203
- accepted = migrated;
1204
- } catch (error) {
1205
- if (unknownStorageState(error)) {
1206
- this.enterStorageFailed("Skills migration result is unknown; restart and reconcile");
1207
- return;
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)}`);
1208
1485
  }
1209
- this.ctx.logger.warn(`multi-root skills: metadata migration failed; Skills remain withheld: ${String(error)}`);
1486
+ const key = canonicalRoot(primaryRoot);
1487
+ staged.set(key, this.classify(key, accepted));
1210
1488
  }
1211
- this.accept(primaryRoot, accepted);
1212
- });
1213
- if (this.authorityState.kind !== "active") return;
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;
1214
1501
  if (this.medium !== void 0) try {
1215
- this.server = await AuthorityServer.listen(this.medium.endpoint, this.medium.id, this.operationEpoch, this.maxRoots, (method, args) => this.dispatchShared(method, args));
1502
+ server = await AuthorityServer.listen(this.medium.endpoint, this.medium.id, epoch, this.maxRoots, (method, args) => this.dispatchShared(method, args));
1216
1503
  } catch (error) {
1217
1504
  await domain.close();
1218
- this.domain = void 0;
1219
- this.enterStorageFailed(`cannot start shared registry: ${String(error)}`);
1505
+ this.enterStorageFailed(`cannot start shared registry: ${String(error)}`, "retryable");
1220
1506
  return;
1221
1507
  }
1222
- this.ctx.multiRootScope.setRegistryAuthority(true);
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;
1522
+ });
1223
1523
  }
1224
1524
  /** Fail closed: empty scope, no table, contended state. */
1225
1525
  enterContended() {
@@ -1235,23 +1535,21 @@ var MultiRootRegistry = class extends Service {
1235
1535
  }
1236
1536
  }
1237
1537
  /** Fail closed: empty scope, no table, storage-failed state. The lease, if held, stays. */
1238
- enterStorageFailed(reason) {
1538
+ enterStorageFailed(reason, recovery) {
1239
1539
  this.operationEpoch = void 0;
1240
1540
  this.ctx.multiRootScope.setRegistryAuthority(false);
1241
1541
  if (this.shared) {
1242
- for (const [key, rows] of this.cache) this.ctx.multiRootScope.setAdditionalRoots(key, this.projectRoots(rows).map((root) => ({
1243
- ...root,
1244
- withheld: true
1245
- })));
1246
- this.published.clear();
1542
+ for (const [key, rows] of this.cache) this.withholdScope(key, rows);
1247
1543
  this.server?.close();
1248
1544
  this.server = void 0;
1249
1545
  } else this.withdrawPublished();
1250
1546
  this.table = void 0;
1251
1547
  this.authorityState = {
1252
1548
  kind: "storage-failed",
1253
- reason
1549
+ reason,
1550
+ recovery
1254
1551
  };
1552
+ this.noteRecoveryTransition();
1255
1553
  }
1256
1554
  /** Drop every grant this process has published. Idempotent. */
1257
1555
  withdrawPublished() {
@@ -1276,6 +1574,8 @@ var MultiRootRegistry = class extends Service {
1276
1574
  async releaseAuthority() {
1277
1575
  this.disposed = true;
1278
1576
  this.connectionGeneration++;
1577
+ this.recovery.dispose();
1578
+ this.connectionAbort?.abort();
1279
1579
  this.client?.close();
1280
1580
  this.client = void 0;
1281
1581
  await this.serializeAuthority(async () => {