@dsh-electron/dsh-plugin-multi-root-workspace 0.1.12 → 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.
package/lib/registry.js CHANGED
@@ -1,3 +1,3 @@
1
- import { a as MultiRootRegistry, c as multiRootDomainSpec, i as MAX_ALIAS_LENGTH, n as DOMAIN_NAME, o as REGISTRY_CONTENDED_MESSAGE, r as MAX_ADDITIONAL_ROOTS_CEILING, s as TABLE_NAME, t as DEFAULT_MAX_ADDITIONAL_ROOTS } from "./registry-YTUOjmVu.js";
2
- import { o as nativeHostAvailability } from "./protocol-4zdwV7u4.js";
1
+ import { a as MultiRootRegistry, c as multiRootDomainSpec, i as MAX_ALIAS_LENGTH, n as DOMAIN_NAME, o as REGISTRY_CONTENDED_MESSAGE, r as MAX_ADDITIONAL_ROOTS_CEILING, s as TABLE_NAME, t as DEFAULT_MAX_ADDITIONAL_ROOTS } from "./registry-Bv431TfO.js";
2
+ import { o as nativeHostAvailability } from "./protocol-CJOvAvOS.js";
3
3
  export { DEFAULT_MAX_ADDITIONAL_ROOTS, DOMAIN_NAME, MAX_ADDITIONAL_ROOTS_CEILING, MAX_ALIAS_LENGTH, MultiRootRegistry, MultiRootRegistry as default, REGISTRY_CONTENDED_MESSAGE, TABLE_NAME, multiRootDomainSpec, nativeHostAvailability };
package/lib/scope.js CHANGED
@@ -154,6 +154,10 @@ var MultiRootScopeService = class extends Service {
154
154
  /** Additional roots keyed by canonical primary root; empty until roots are registered. */
155
155
  rootsByPrimary = /* @__PURE__ */ new Map();
156
156
  listeners = /* @__PURE__ */ new Set();
157
+ /** Enclosing {@link MultiRootScopeService.batch} depth; inner calls nest. */
158
+ batchDepth = 0;
159
+ /** True when a batched write coalesced its notification into the flush. */
160
+ batchDirty = false;
157
161
  admissionProof;
158
162
  /**
159
163
  * The registry-authority fact the registry row publishes. Defaults to
@@ -183,6 +187,36 @@ var MultiRootScopeService = class extends Service {
183
187
  };
184
188
  }
185
189
  /**
190
+ * Run `body` as one notification round: `setAdditionalRoots()` and
191
+ * `setRegistryAuthority()` apply their state immediately (a same-value
192
+ * authority write still stays silent), but subscriber invocations coalesce
193
+ * into a single flush when the OUTERMOST batch ends — also when `body`
194
+ * throws, because the state already landed. A write a subscriber makes
195
+ * during the flush is not part of the batch and notifies synchronously
196
+ * under the normal rules.
197
+ * @param body - the writes to run as one round.
198
+ * @returns the body's return value.
199
+ */
200
+ batch(body) {
201
+ this.batchDepth++;
202
+ try {
203
+ return body();
204
+ } finally {
205
+ if (--this.batchDepth === 0 && this.batchDirty) {
206
+ this.batchDirty = false;
207
+ for (const listener of this.listeners) listener();
208
+ }
209
+ }
210
+ }
211
+ /** Notify subscribers now, or coalesce into the enclosing batch's flush. */
212
+ notifySubscribers() {
213
+ if (this.batchDepth > 0) {
214
+ this.batchDirty = true;
215
+ return;
216
+ }
217
+ for (const listener of this.listeners) listener();
218
+ }
219
+ /**
186
220
  * The sorted names of the tools `toolAccess` caps — read-only for the
187
221
  * prompt contribution. An invalid config is not enumerated (it caps every
188
222
  * call but is not a listable tool set).
@@ -403,7 +437,7 @@ var MultiRootScopeService = class extends Service {
403
437
  const key = canonicalPath(primaryRoot);
404
438
  if (roots.length === 0) this.rootsByPrimary.delete(key);
405
439
  else this.rootsByPrimary.set(key, [...roots]);
406
- for (const listener of this.listeners) listener();
440
+ this.notifySubscribers();
407
441
  }
408
442
  /**
409
443
  * Publish the registry-authority fact. This is the REGISTRY's write port —
@@ -420,7 +454,7 @@ var MultiRootScopeService = class extends Service {
420
454
  const next = available ? "available" : "unavailable";
421
455
  if (this.registryAuthority === next) return;
422
456
  this.registryAuthority = next;
423
- for (const listener of this.listeners) listener();
457
+ this.notifySubscribers();
424
458
  }
425
459
  };
426
460
  //#endregion
@@ -46,7 +46,7 @@ export declare class AuthorityClient {
46
46
  private closed;
47
47
  epoch: string | undefined;
48
48
  private constructor();
49
- static connect(endpoint: string, store: string, limit: number, onChange: (snapshot: Snapshot) => void, disconnected: () => void): Promise<AuthorityClient>;
49
+ static connect(endpoint: string, store: string, limit: number, onChange: (snapshot: Snapshot) => void, disconnected: () => void, signal?: AbortSignal): Promise<AuthorityClient>;
50
50
  request(method: Method, args: readonly unknown[]): Promise<unknown>;
51
51
  close(): void;
52
52
  }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Bounded background retry for the registry authority: one pending timer and
3
+ * one in-flight attempt per instance, post-completion backoff, and local
4
+ * cancellation on dispose. The scheduler owns NO policy — the caller supplies
5
+ * `run` (one recovery attempt) and `eligible` (whether a degraded state still
6
+ * wants recovery), so the same object can drive contended, follower
7
+ * resync, and retryable storage failures.
8
+ *
9
+ * @module @dsh-electron/dsh-plugin-multi-root-workspace/authority/recovery
10
+ */
11
+ /** Injectable clock so fake-timer specs can drive the scheduler exactly. */
12
+ export interface RecoveryTimers {
13
+ setTimeout: typeof setTimeout;
14
+ clearTimeout: typeof clearTimeout;
15
+ random(): number;
16
+ now(): number;
17
+ }
18
+ /** What the scheduler is doing, for diagnostics surfaces. */
19
+ export type RecoveryPhase = 'idle' | 'waiting' | 'attempting' | 'disabled';
20
+ export declare class RecoveryScheduler {
21
+ private readonly run;
22
+ private readonly eligible;
23
+ private readonly enabled;
24
+ private readonly timers;
25
+ private timer;
26
+ private inFlight;
27
+ private disposed;
28
+ private attempts;
29
+ private retryAt;
30
+ constructor(options: {
31
+ run: () => Promise<void>;
32
+ eligible: () => boolean;
33
+ enabled: boolean;
34
+ timers?: RecoveryTimers;
35
+ });
36
+ /**
37
+ * Ask for a recovery attempt. Called after every `ensureAuthority()` and on
38
+ * every downgrade event. A healthy state (`!eligible()`) resets the backoff;
39
+ * a pending timer or in-flight attempt is never duplicated or reset.
40
+ */
41
+ arm(): void;
42
+ /**
43
+ * Progress event (a follower connection was just adopted): the backoff is
44
+ * earned, so reset it and run the next attempt immediately — still through
45
+ * the single pending timer.
46
+ */
47
+ kick(): void;
48
+ /** Stop scheduling. An in-flight attempt is left to finish on its own. */
49
+ dispose(): void;
50
+ get state(): {
51
+ phase: RecoveryPhase;
52
+ attempts: number;
53
+ nextRetryAt: number | undefined;
54
+ };
55
+ private clearTimer;
56
+ private delay;
57
+ private tick;
58
+ }
@@ -32,9 +32,12 @@
32
32
  * the same element's `closest('[data-dockkit-content]')` — the dockkit
33
33
  * tabHost section — with a generated fallback where the hook is absent.
34
34
  *
35
- * The selected root is component state on purpose: every tab render
36
- * occurrence gets its own, and reopening the pane resets to the primary
37
- * root. There is no cross-tab or persisted selection.
35
+ * The selected root lives in the inject product's session-scoped snapshot,
36
+ * keyed by that dockkit tab id — component state could not survive the
37
+ * remount every file open causes (the opened tab takes the pane, the Files
38
+ * body unmounts with the whole occupant subtree). One selection per tab;
39
+ * session switches mint a fresh inject product, so no selection crosses
40
+ * sessions, and a registry eviction reconciles it back to primary.
38
41
  *
39
42
  * @module @dsh-electron/dsh-plugin-multi-root-workspace/client/RootSwitcher
40
43
  */
@@ -25,19 +25,72 @@
25
25
  */
26
26
  import type { Context as ClientContext } from '@deepseek-ai/cordis';
27
27
  import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client';
28
- import type { BoundActions } from '@deepseek-ai/dsh-client-store';
28
+ import { type BoundActions, type SnapshotSelectorHook } from '@deepseek-ai/dsh-client-store';
29
+ import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots';
30
+ import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit';
29
31
  import type { SessionId } from '@deepseek-ai/dsh-session/types';
30
32
  import { type FilesInjected } from './face.ts';
31
33
  import { createFilesStore } from './store.ts';
32
34
  import { filesTreeSurfaceSupported } from './primitives.ts';
33
35
  import type { FilesTreeProps } from './FilesTree.tsx';
36
+ import type { RootView } from '../../contract.ts';
34
37
  import { NS } from './locales.ts';
38
+ /**
39
+ * One session's per-tab additional-root selections, keyed by the pane's
40
+ * dockkit tab id. An absent entry means the primary root.
41
+ */
42
+ export type TreeSelection = Readonly<Record<string, RootView>>;
43
+ /**
44
+ * The lifetimes a selection runs under, keyed by tab like the store bucket:
45
+ * while an entry's signal is live the tree keeps its loaded levels, watches,
46
+ * expansion, and scroll — exactly what upstream's tab-record signal gives the
47
+ * native tree across a body remount (opening a file deactivates this tab,
48
+ * unmounting its body without ending the record).
49
+ */
50
+ export interface FilesTreeTakeover {
51
+ /**
52
+ * The live signal for (tabId, root); reuses the live entry so a remounted
53
+ * body finds its bucket and watches intact. A different root — or an entry
54
+ * already aborted — ends the previous controller before minting the next.
55
+ * @param tabId - the pane's dockkit tab id.
56
+ * @param root - absolute path of the root being browsed.
57
+ * @param section - the dockkit tabHost section the caller mounted under; a
58
+ * live entry keeps an observer on it that ends the takeover whenever the
59
+ * section leaves the document — the record's real end — including when the
60
+ * body that mounted it has long been unmounted itself.
61
+ * @returns the signal the forked tree lives under.
62
+ */
63
+ readonly signal: (tabId: TabId, root: string, section?: Element) => AbortSignal;
64
+ /**
65
+ * Abort and drop one tab's takeover: the reader deselected or switched
66
+ * roots, or the tab record itself went away.
67
+ * @param tabId - the pane's dockkit tab id.
68
+ */
69
+ readonly end: (tabId: TabId) => void;
70
+ }
35
71
  /** The inject face the registration composes onto the occupant's props. */
36
72
  export interface FilesTreeInjected {
37
73
  /** The verbatim files face (list/watch bookkeeping included). */
38
74
  readonly face: FilesInjected;
39
75
  /** Open one `dsh-resource://file` address in the host viewer. */
40
76
  readonly openFile: (address: string) => void;
77
+ /**
78
+ * The reserved inject compartment: the slot renderer binds each source to a
79
+ * `use<Name>` selector hook, so `treeSelection` reaches the component as
80
+ * `useTreeSelection`, never as a prop under its own key.
81
+ */
82
+ readonly hooks: {
83
+ readonly treeSelection: HostObservable<TreeSelection>;
84
+ };
85
+ /**
86
+ * Persist one pane's selected root; `'primary'` drops its entry. The
87
+ * session-scoped snapshot survives the body remount a file open causes.
88
+ * @param tabId - the pane's dockkit tab id.
89
+ * @param root - the selected root, or `'primary'` to browse the session root.
90
+ */
91
+ readonly selectRoot: (tabId: TabId, root: RootView | 'primary') => void;
92
+ /** The forked tree's lifetimes; see {@link FilesTreeTakeover}. */
93
+ readonly takeover: FilesTreeTakeover;
41
94
  }
42
95
  /** Everything `FilesTree` needs that the slot framework does not synthesize. */
43
96
  export interface FilesTreeSurface {
@@ -47,6 +100,12 @@ export interface FilesTreeSurface {
47
100
  readonly face: FilesInjected;
48
101
  readonly openFile: (address: string) => void;
49
102
  readonly t: FilesTreeProps['t'];
103
+ /** Selector hook over this session's per-tab root selections. */
104
+ readonly useTreeSelection: SnapshotSelectorHook<TreeSelection>;
105
+ /** The selection's write side; see {@link FilesTreeInjected.selectRoot}. */
106
+ readonly selectRoot: FilesTreeInjected['selectRoot'];
107
+ /** The forked tree's lifetimes; see {@link FilesTreeTakeover}. */
108
+ readonly takeover: FilesTreeTakeover;
50
109
  }
51
110
  /**
52
111
  * A `ClientRemote` stand-in that resolves the real service per call.
@@ -89,6 +148,9 @@ export declare function createFilesTreeSurface(props: {
89
148
  readonly actions: FilesTreeSurface['actions'];
90
149
  readonly face: FilesInjected | undefined;
91
150
  readonly openFile: ((address: string) => void) | undefined;
151
+ readonly useTreeSelection: FilesTreeSurface['useTreeSelection'] | undefined;
152
+ readonly selectRoot: FilesTreeSurface['selectRoot'] | undefined;
153
+ readonly takeover: FilesTreeSurface['takeover'] | undefined;
92
154
  }, t: FilesTreeProps['t']): FilesTreeSurface | undefined;
93
155
  export { filesTreeSurfaceSupported, NS as FILES_TREE_NS };
94
156
  export { FilesTree } from './FilesTree.tsx';
@@ -70,6 +70,28 @@ export declare const REGISTRY_CONTENDED_MESSAGE = "the root registry is owned by
70
70
  export declare const DEFAULT_MAX_ADDITIONAL_ROOTS = 16;
71
71
  /** Hard ceiling for `maxAdditionalRoots`: values above it are config-invalid. */
72
72
  export declare const MAX_ADDITIONAL_ROOTS_CEILING = 64;
73
+ /**
74
+ * The recovery class of a `storage-failed` authority: `retryable` failures
75
+ * (open/listen/acquire) may be re-attempted by `ensureAuthority`, while
76
+ * `config` (a row the operator must fix) and `quarantine` (an unknown durable
77
+ * outcome, isolated until restart) make the state terminal.
78
+ */
79
+ export type StorageFailureRecovery = 'retryable' | 'config' | 'quarantine';
80
+ /** The recovery class a degraded authority belongs to (see {@link StorageFailureRecovery}). */
81
+ export type RecoveryClass = 'retryable' | 'config' | 'quarantine';
82
+ /** Operator-facing read-only picture of the registry authority and its recovery. */
83
+ export interface RegistryDiagnostics {
84
+ readonly authorityState: RegistryAuthorityState;
85
+ readonly epoch: string | undefined;
86
+ readonly connectionFailure: string | undefined;
87
+ readonly recoveryClass: RecoveryClass | undefined;
88
+ readonly recovery: {
89
+ readonly phase: 'idle' | 'waiting' | 'attempting' | 'disabled' | 'requires-restart';
90
+ readonly attempts: number;
91
+ readonly nextRetryAt: number | undefined;
92
+ readonly pendingSync: number;
93
+ };
94
+ }
73
95
  /**
74
96
  * Whether this process is the Registry Authority for the store-wide domain
75
97
  * medium. Contended and storage-failed are both fail-closed: the scope stays
@@ -85,6 +107,7 @@ export type RegistryAuthorityState = {
85
107
  } | {
86
108
  readonly kind: 'storage-failed';
87
109
  readonly reason: string;
110
+ readonly recovery: StorageFailureRecovery;
88
111
  } | {
89
112
  readonly kind: 'config-invalid';
90
113
  readonly reason: string;
@@ -110,6 +133,12 @@ export interface Config {
110
133
  * exactly what classification grants — only new adds are refused.
111
134
  */
112
135
  readonly maxAdditionalRoots?: number;
136
+ /**
137
+ * Whether the registry retries a degraded authority in the background
138
+ * (bounded backoff, `unref`ed). Defaults to `true`; `false` leaves
139
+ * recovery to on-demand calls (refresh, the panel Retry button).
140
+ */
141
+ readonly backgroundRecovery?: boolean;
113
142
  }
114
143
  declare module '@deepseek-ai/cordis' {
115
144
  interface Context {
@@ -206,8 +235,18 @@ export declare class MultiRootRegistry extends Service {
206
235
  private client;
207
236
  private connectionFailure;
208
237
  private connectionGeneration;
238
+ /** Local cancel of the in-flight handshake; teardown must not wait out hello. */
239
+ private connectionAbort;
209
240
  private remoteProjectionReady;
210
241
  private readonly remoteRevisions;
242
+ /**
243
+ * Primary roots whose projection must still be synced after a follower
244
+ * reconnect — populated from the tracked cache keys at adoption, removed as
245
+ * each `applySnapshot` lands. Recovery completes only when it empties.
246
+ */
247
+ private pendingSync;
248
+ /** Bounded background retry for degraded authority states (ADR-0026 R1). */
249
+ private readonly recovery;
211
250
  private readonly operationProof;
212
251
  /** Whether this process currently owns the store. */
213
252
  private authorityState;
@@ -215,6 +254,8 @@ export declare class MultiRootRegistry extends Service {
215
254
  private lease;
216
255
  /** Open domain handle; present only while {@link authority} is `active`. */
217
256
  private domain;
257
+ /** The last failed `openDomain` reason; a repeat failure is not re-logged. */
258
+ private lastOpenFailure;
218
259
  /** The open table; present once this process is the authority. */
219
260
  private table;
220
261
  /** Last classified status list per canonical primary root. */
@@ -271,6 +312,23 @@ export declare class MultiRootRegistry extends Service {
271
312
  get shared(): boolean;
272
313
  get available(): boolean;
273
314
  get authority(): RegistryAuthorityState;
315
+ /** The class a degraded authority belongs to; `undefined` when healthy. */
316
+ private get recoveryClass();
317
+ /**
318
+ * Read-only diagnostics for operators: the authority state, the epoch, the
319
+ * last connection failure, and what the recovery scheduler is doing.
320
+ * `requires-restart` marks classes no timer will ever fix — a `config`
321
+ * row or a `quarantine`d store must NOT read as "retrying". Purely a read;
322
+ * it never arms or runs anything.
323
+ */
324
+ get diagnostics(): RegistryDiagnostics;
325
+ /** The last logged `(kind, recoveryClass)` pair; dedupes transition logs. */
326
+ private loggedRecovery;
327
+ /**
328
+ * Log one line per authority/recovery-class TRANSITION — never per tick:
329
+ * waiting↔attempting moves say nothing an operator did not already know.
330
+ */
331
+ private noteRecoveryTransition;
274
332
  /**
275
333
  * The configured cap on stored additional-root records per primary root.
276
334
  * Surfaces show it next to the count so "N of LIMIT" is always explainable.
@@ -423,6 +481,12 @@ export declare class MultiRootRegistry extends Service {
423
481
  grantedFor(policy: SandboxExecutionPolicy): readonly string[];
424
482
  /** Classify one stored record set and cache it. */
425
483
  private accept;
484
+ /**
485
+ * The pure half of {@link accept}: classification plus the over-limit
486
+ * warning, without writing the cache or the scope. {@link openDomain}
487
+ * staging uses it so a half-opened domain cannot materialize rows.
488
+ */
489
+ private classify;
426
490
  /**
427
491
  * The statuses of one key, from memory where possible: the cache, else what
428
492
  * storage holds, else empty. A cached answer is never re-checked here — that
@@ -482,8 +546,42 @@ export declare class MultiRootRegistry extends Service {
482
546
  private snapshotOf;
483
547
  private captureSnapshot;
484
548
  private notifyShared;
549
+ /** Depth of {@link commit}; nested calls share the outer round's buffers. */
550
+ private commitDepth;
551
+ /** Keys whose publish inside a commit produced deferred notifications, first-seen order. */
552
+ private readonly commitNotifications;
553
+ /**
554
+ * Run `body` as one Registry commit: scope writes inside it go through
555
+ * `multiRootScope.batch`, and the `notifyShared` broadcasts plus Registry
556
+ * listener calls `publish()` produces are collected per key instead of
557
+ * delivered. When the OUTERMOST commit ends — also when `body` throws — the
558
+ * scope batch flushes first, then the broadcasts go out in first-seen key
559
+ * order, then the listeners: no synchronous callback can observe a
560
+ * half-committed state.
561
+ */
562
+ private commit;
563
+ /** Deliver one key's notifications now, or collect them inside a commit. */
564
+ private deliverNotifications;
485
565
  private applySnapshot;
486
566
  private remoteUnavailable;
567
+ /**
568
+ * Resynchronize each still-pending tracked primary outside the authority
569
+ * queue: per-key snapshots through the live client. A failed key stays in
570
+ * `pendingSync` for the next tick; a lost connection abandons the round —
571
+ * `remoteUnavailable` already emptied the set.
572
+ */
573
+ private syncProjection;
574
+ /**
575
+ * Write one key's withheld-only projection into the scope, keeping
576
+ * `published` truthful: the signature records what the scope HOLDS, never
577
+ * what `publish()` last computed — a withheld signature can never equal a
578
+ * publish signature, so an empty snapshot afterwards really writes the
579
+ * retraction. A repeated write of the same withheld projection is a no-op
580
+ * (the same-signature skip is also the republish gate of a repeated
581
+ * downgrade). `metadataPublished` is untouched: a withheld write announces
582
+ * nothing to the Registry view.
583
+ */
584
+ private withholdScope;
487
585
  private remoteSnapshot;
488
586
  private remote;
489
587
  private dispatchShared;
@@ -208,6 +208,10 @@ export declare class MultiRootScopeService extends Service {
208
208
  /** Additional roots keyed by canonical primary root; empty until roots are registered. */
209
209
  private readonly rootsByPrimary;
210
210
  private readonly listeners;
211
+ /** Enclosing {@link MultiRootScopeService.batch} depth; inner calls nest. */
212
+ private batchDepth;
213
+ /** True when a batched write coalesced its notification into the flush. */
214
+ private batchDirty;
211
215
  private admissionProof;
212
216
  /**
213
217
  * The registry-authority fact the registry row publishes. Defaults to
@@ -231,6 +235,20 @@ export declare class MultiRootScopeService extends Service {
231
235
  private readonly dshHome;
232
236
  /** Observe registry publications; consumers still resolve authority from scopeOf(). */
233
237
  subscribe(listener: () => void): () => void;
238
+ /**
239
+ * Run `body` as one notification round: `setAdditionalRoots()` and
240
+ * `setRegistryAuthority()` apply their state immediately (a same-value
241
+ * authority write still stays silent), but subscriber invocations coalesce
242
+ * into a single flush when the OUTERMOST batch ends — also when `body`
243
+ * throws, because the state already landed. A write a subscriber makes
244
+ * during the flush is not part of the batch and notifies synchronously
245
+ * under the normal rules.
246
+ * @param body - the writes to run as one round.
247
+ * @returns the body's return value.
248
+ */
249
+ batch<T>(body: () => T): T;
250
+ /** Notify subscribers now, or coalesce into the enclosing batch's flush. */
251
+ private notifySubscribers;
234
252
  /**
235
253
  * The sorted names of the tools `toolAccess` caps — read-only for the
236
254
  * prompt contribution. An invalid config is not enumerated (it caps every
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dsh-electron/dsh-plugin-multi-root-workspace",
3
3
  "description": "Multi-root Workspace for DeepSeek Harness: adds N additional roots to the workspace sandbox scope as an out-of-tree bundle",
4
- "version": "0.1.12",
4
+ "version": "0.1.13",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",