@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.
- package/CHANGELOG.en.md +29 -1
- package/CHANGELOG.md +29 -1
- package/README.en.md +9 -8
- package/README.md +9 -8
- package/cordis.patch.yml +4 -0
- package/detail/DETAIL.en.md +1 -0
- package/detail/DETAIL.md +1 -0
- package/detail/preview.svg +1 -0
- package/detail/preview.zh.svg +1 -0
- package/lib/client.js +492 -87
- package/lib/client.js.map +1 -1
- package/lib/{command-CmLekG1M.js → command-Dcz6nULo.js} +25 -3
- package/lib/command.js +1 -1
- package/lib/{compat-Bg2HFVMX.js → compat-veDqCOoa.js} +3 -1
- package/lib/compat.js +1 -1
- package/lib/{fs-CfzJq2wd.js → fs-Uws0ZpmV.js} +38 -0
- package/lib/fs.js +1 -1
- package/lib/index.js +4 -4
- package/lib/{native-win32-2waCRkPI.js → native-win32-CRNXr11U.js} +2 -1
- package/lib/{protocol-4zdwV7u4.js → protocol-CnR1KTXy.js} +62 -9
- package/lib/{registry-YTUOjmVu.js → registry-DR_sMuvS.js} +425 -125
- package/lib/{registry-lease-win32-DVTq9d4D.js → registry-lease-win32-DDO-c7ig.js} +2 -1
- package/lib/registry.js +2 -2
- package/lib/scope.js +36 -2
- package/lib/types/authority/protocol.d.ts +1 -1
- package/lib/types/authority/recovery.d.ts +58 -0
- package/lib/types/client/RootSwitcher.d.ts +8 -3
- package/lib/types/client/auto-review-settings.d.ts +18 -0
- package/lib/types/client/files-tree/FilesTree.d.ts +4 -1
- package/lib/types/client/files-tree/adapter.d.ts +63 -1
- package/lib/types/client/files-tree/locales.d.ts +1 -1
- package/lib/types/client/locales.d.ts +12 -0
- package/lib/types/client/open-in-app/locales.d.ts +1 -1
- package/lib/types/client/skills-settings.d.ts +12 -0
- package/lib/types/client/styles.d.ts +1 -1
- package/lib/types/compat/auto-review-client.d.ts +58 -0
- package/lib/types/compat/connection-rpc.d.ts +12 -0
- package/lib/types/compat/dsh-version.d.ts +1 -1
- package/lib/types/compat/files-tree.d.ts +36 -0
- package/lib/types/compat/fs-patch.d.ts +6 -0
- package/lib/types/compat/koffi.d.ts +2 -0
- package/lib/types/fs.d.ts +3 -1
- package/lib/types/registry.d.ts +98 -0
- package/lib/types/scope.d.ts +18 -0
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/package.json +89 -82
- package/src/auto-review/README.en.md +1 -1
- 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-
|
|
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-
|
|
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-
|
|
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 =
|
|
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.
|
|
942
|
-
for (const listener of this.listeners) listener(key);
|
|
1125
|
+
this.deliverNotifications(key);
|
|
943
1126
|
}
|
|
944
1127
|
return;
|
|
945
1128
|
}
|
|
946
|
-
this.published.
|
|
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.
|
|
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.
|
|
1000
|
-
|
|
1001
|
-
|
|
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
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
if (this.
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
this.
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
this.
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
const
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
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.
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
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.
|
|
1187
|
-
|
|
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
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
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
|
-
|
|
1486
|
+
const key = canonicalRoot(primaryRoot);
|
|
1487
|
+
staged.set(key, this.classify(key, accepted));
|
|
1210
1488
|
}
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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 () => {
|