@dsh-electron/dsh-plugin-multi-root-workspace 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.en.md +89 -0
- package/CHANGELOG.md +89 -0
- package/README.en.md +96 -20
- package/README.md +97 -19
- package/cordis.patch.yml +22 -1
- package/lib/client.js +234 -71
- package/lib/client.js.map +1 -1
- package/lib/{command-BSkrYOAf.js → command-DyOWujPo.js} +24 -61
- package/lib/command.js +2 -2
- package/lib/compat-CRdv0vPw.js +272 -0
- package/lib/compat.js +2 -0
- package/lib/{fs-D7UoCb7T.js → fs-D_Sw3o5z.js} +7 -3
- package/lib/fs.js +1 -1
- package/lib/index.js +8 -6
- package/lib/instructions-Cn1Jj5Xi.js +703 -0
- package/lib/instructions.js +2 -0
- package/lib/registry-DySMbjgW.js +793 -0
- package/lib/registry-lease-win32-dy8TQMD9.js +93 -0
- package/lib/registry.js +2 -466
- package/lib/{sandbox-BhuMTDgH.js → sandbox-D8Fw95FM.js} +51 -4
- package/lib/sandbox.js +1 -1
- package/lib/scope.js +1 -1
- package/lib/types/client/locales.d.ts +6 -0
- package/lib/types/client/panel-client.d.ts +0 -2
- package/lib/types/command.d.ts +17 -0
- package/lib/types/compat/agent-instructions.d.ts +138 -0
- package/lib/types/compat/client-icons.d.ts +40 -0
- package/lib/types/compat/client-session.d.ts +32 -0
- package/lib/types/compat/dsh-version.d.ts +119 -0
- package/lib/types/compat/llm-message.d.ts +72 -0
- package/lib/types/compat/sandbox-confine.d.ts +58 -0
- package/lib/types/compat/tool-result.d.ts +29 -0
- package/lib/types/compat.d.ts +85 -0
- package/lib/types/contract.d.ts +6 -8
- package/lib/types/index.d.ts +19 -5
- package/lib/types/instructions.d.ts +125 -0
- package/lib/types/registry-lease-win32.d.ts +29 -0
- package/lib/types/registry-lease.d.ts +62 -0
- package/lib/types/registry.d.ts +130 -19
- package/lib/types/roots.d.ts +1 -1
- package/lib/types/sandbox.d.ts +22 -2
- package/package.json +60 -13
- package/lib/{roots-DyT2jgEP.js → roots-DasWEBJk.js} +1 -1
package/lib/types/contract.d.ts
CHANGED
|
@@ -94,16 +94,14 @@ export interface PanelResponseMap {
|
|
|
94
94
|
readonly reveal: RevealedView;
|
|
95
95
|
}
|
|
96
96
|
/**
|
|
97
|
-
* The fields a request may carry. `
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* the host asserts which ones that endpoint requires.
|
|
97
|
+
* The fields a request may carry. `sessionId` is required on every endpoint:
|
|
98
|
+
* the host derives the primary root from that session's immutable cwd and
|
|
99
|
+
* never accepts a client-named path. The remaining fields are used per
|
|
100
|
+
* endpoint and the host asserts which ones that endpoint requires.
|
|
101
101
|
*/
|
|
102
102
|
export interface PanelRequest {
|
|
103
|
-
/** The
|
|
104
|
-
readonly
|
|
105
|
-
/** The session whose workspace root should be used when `primaryRoot` is absent. */
|
|
106
|
-
readonly sessionId?: string;
|
|
103
|
+
/** The live host session whose cwd is the primary root this call acts on. */
|
|
104
|
+
readonly sessionId: string;
|
|
107
105
|
/** Exact target row from the most recent list snapshot. */
|
|
108
106
|
readonly entry?: RootEntryView;
|
|
109
107
|
/** Compatibility reference; accepted only when the id is unique. */
|
package/lib/types/index.d.ts
CHANGED
|
@@ -13,22 +13,36 @@
|
|
|
13
13
|
* whole public surface from a single specifier.
|
|
14
14
|
*
|
|
15
15
|
* The two facts worth remembering about this surface: a registration carries the
|
|
16
|
-
* canonical directory it was GRANTED for (`recordedPath`),
|
|
17
|
-
* has one declared response shape per endpoint (`PanelResponseMap`)
|
|
16
|
+
* canonical directory it was GRANTED for (`recordedPath`), the panel channel
|
|
17
|
+
* has one declared response shape per endpoint (`PanelResponseMap`), and the
|
|
18
|
+
* panel primary root is derived from a live host session — never named by the
|
|
19
|
+
* browser.
|
|
18
20
|
*
|
|
19
21
|
* @module @dsh-electron/dsh-plugin-multi-root-workspace
|
|
20
22
|
*/
|
|
23
|
+
export { DshCompatUnsupportedError, MultiRootCompatService, assertSupportedInstallation } from './compat.ts';
|
|
24
|
+
export { classifyInstallation, compatEnforcement, CORE_PACKAGES, OPTIONAL_CORE_PACKAGES, readInstalledVersion, REQUIRED_CORE_PACKAGES, SUPPORTED_DSH_RELEASES, } from './compat/dsh-version.ts';
|
|
25
|
+
export type { CompatEnforcement, CompatReport, CompatVerdict, InspectedPackage, SupportedDshRelease, VersionReader, } from './compat/dsh-version.ts';
|
|
26
|
+
export { instructionsApi, renderInstructions } from './compat/agent-instructions.ts';
|
|
27
|
+
export type { InstructionsApi, RenderedInstructions } from './compat/agent-instructions.ts';
|
|
28
|
+
export { createInstructionMessage } from './compat/llm-message.ts';
|
|
29
|
+
export type { InstructionMessageInput } from './compat/llm-message.ts';
|
|
30
|
+
export { widenConfined } from './compat/sandbox-confine.ts';
|
|
31
|
+
export type { ConfineCall, UpstreamConfined } from './compat/sandbox-confine.ts';
|
|
32
|
+
export { composeInstructionMessage, DEFAULT_MAX_BYTES, PLUGIN_SOURCE } from './instructions.ts';
|
|
33
|
+
export type { Config as InstructionsConfig } from './instructions.ts';
|
|
21
34
|
export { MultiRootFileSystem } from './fs.ts';
|
|
22
35
|
export type { Config as FileSystemConfig } from './fs.ts';
|
|
23
36
|
export { MultiRootSandboxProvider } from './sandbox.ts';
|
|
24
37
|
export type { Config as SandboxConfig } from './sandbox.ts';
|
|
25
38
|
export { MultiRootScopeService, sanitizeAdditionalRoots, renderWorkspaceRootsContext } from './scope.ts';
|
|
26
39
|
export type { AdditionalWorkspaceRoot, FilesystemScope } from './scope.ts';
|
|
27
|
-
export { MultiRootRegistry, DOMAIN_NAME, MAX_ALIAS_LENGTH, multiRootDomainSpec } from './registry.ts';
|
|
28
|
-
export type { AddRootInput, PersistedPrimaryRoot } from './registry.ts';
|
|
40
|
+
export { MultiRootRegistry, DOMAIN_NAME, MAX_ALIAS_LENGTH, REGISTRY_CONTENDED_MESSAGE, multiRootDomainSpec } from './registry.ts';
|
|
41
|
+
export type { AddRootInput, Config as RegistryConfig, PersistedPrimaryRoot, RegistryAuthorityState } from './registry.ts';
|
|
42
|
+
export { RegistryAuthorityLease, RegistryLeaseContendedError } from './registry-lease.ts';
|
|
29
43
|
export { availableRoots, canonicalRoot, classifyStoredRoots, expandRootInput, indexedStatuses, isCanonicallyUnder, additionalRootId, removeStatusAt, resolveRootRef, RootValidationError, validateRootCandidate, } from './roots.ts';
|
|
30
44
|
export type { AdditionalRootId, RegisteredRoot, RootRef, RootState, RootStatus, RootValidationCode, } from './roots.ts';
|
|
31
|
-
export { COMMAND_NAME, parseFoldersCommand, renderRootsReport, revealArgv } from './command.ts';
|
|
45
|
+
export { COMMAND_NAME, parseFoldersCommand, renderRootsReport, resolvePanelPrimaryRoot, revealArgv } from './command.ts';
|
|
32
46
|
export type { FoldersCommand } from './command.ts';
|
|
33
47
|
export { PANEL_CHANNEL, PANEL_ENDPOINTS, parsePanelCall, parseRevealedView, parseRootsView } from './contract.ts';
|
|
34
48
|
export type { PanelCall, PanelEndpoint, PanelFailure, PanelRequest, PanelResponseMap, RevealedView, RootView, RootsView, } from './contract.ts';
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `multi-root-instructions`: the additional roots' own instruction files, made
|
|
3
|
+
* visible to the model.
|
|
4
|
+
*
|
|
5
|
+
* Upstream `agent-instructions` discovers instruction files by walking UP from
|
|
6
|
+
* the session cwd (plus the one user-global file). In a multi-root workspace
|
|
7
|
+
* that walk never reaches an additional root, so `repo-b/AGENTS.md` is
|
|
8
|
+
* invisible to a model that is nevertheless allowed to write `repo-b`. This row
|
|
9
|
+
* closes exactly that gap, and nothing else: the primary root's chain — nested
|
|
10
|
+
* files included — and the user-global file stay upstream's business.
|
|
11
|
+
*
|
|
12
|
+
* Four deliberate choices:
|
|
13
|
+
*
|
|
14
|
+
* 1. **Not a system-prompt contribution.** Instruction text is
|
|
15
|
+
* producer-supplied context, not system authority, so it enters as a
|
|
16
|
+
* user-role message tagged `form: 'instructions'`. Through session format 3
|
|
17
|
+
* the source kind is `plugin`; format 4 retired that shared kind, so the
|
|
18
|
+
* adapter persists this plugin's own (`multi-root-workspace`). It is also
|
|
19
|
+
* the only channel that is logged, and model-visible content must be logged.
|
|
20
|
+
* 2. **Injected from `agent/pre-step`, not a session lifecycle event.**
|
|
21
|
+
* `pre-step` is an awaited waterfall, so discovery, reading and rendering all
|
|
22
|
+
* complete BEFORE the step it feeds — deterministically, on the first step.
|
|
23
|
+
* A synchronous prompt callback could not await them, and an emit-mode
|
|
24
|
+
* lifecycle listener would race the first step. It is also the one hook whose
|
|
25
|
+
* shape is identical across every supported release, which keeps the
|
|
26
|
+
* instruction feature out of the compatibility matrix (ADR-0009).
|
|
27
|
+
* 3. **Discovery is pinned to the root.** `projectRoot` is the additional root,
|
|
28
|
+
* so the walk can never leave it, and every candidate is then required to be
|
|
29
|
+
* canonically INSIDE that root. That is what keeps `$DSH_HOME/AGENTS.md`, the
|
|
30
|
+
* primary root's `AGENTS.md`, and any ancestor's file from being injected a
|
|
31
|
+
* second time by this plugin.
|
|
32
|
+
* 4. **Nested files are driven by the session's own tool activity.** A
|
|
33
|
+
* subdirectory's `AGENTS.md` only matters once the session has worked in that
|
|
34
|
+
* subdirectory, so the directories worth examining are derived from the
|
|
35
|
+
* session's persisted events: `tool/call` and `tool/result` are paired, and a
|
|
36
|
+
* successful `read`/`write`/`edit` makes the path from the root down to the
|
|
37
|
+
* touched file's directory relevant (ADR-0010). A file that disappears is
|
|
38
|
+
* retracted explicitly, because the text it contributed earlier is still in
|
|
39
|
+
* the conversation and silence does not withdraw it.
|
|
40
|
+
*
|
|
41
|
+
* The byte budget is shared: `maxBytes` bounds the WHOLE additional-root
|
|
42
|
+
* snapshot, not each root, because ten roots must not quietly cost ten budgets
|
|
43
|
+
* of context. Roots consume it in scope order. Retractions are exempt, because a
|
|
44
|
+
* truncated withdrawal is worse than a slightly over-budget message.
|
|
45
|
+
*
|
|
46
|
+
* @module @dsh-electron/dsh-plugin-multi-root-workspace/instructions
|
|
47
|
+
*/
|
|
48
|
+
import { Context } from '@deepseek-ai/cordis';
|
|
49
|
+
/** The `plugin` attribution every message this row produces carries. */
|
|
50
|
+
export declare const PLUGIN_SOURCE = "@dsh-electron/dsh-plugin-multi-root-workspace";
|
|
51
|
+
/**
|
|
52
|
+
* Total UTF-8 byte budget for the additional-root snapshot, matching the
|
|
53
|
+
* default the upstream `agent-instructions` row is composed with so that one
|
|
54
|
+
* extra workspace root costs at most as much as the primary one does.
|
|
55
|
+
*/
|
|
56
|
+
export declare const DEFAULT_MAX_BYTES = 65536;
|
|
57
|
+
/** Largest instruction file this row will read; larger files are ignored. */
|
|
58
|
+
export declare const DEFAULT_MAX_SOURCE_BYTES = 32768;
|
|
59
|
+
/** Plugin configuration for the `multi-root-instructions` row. */
|
|
60
|
+
export interface Config {
|
|
61
|
+
/** Total budget for ALL additional roots (default {@link DEFAULT_MAX_BYTES}); non-positive disables the row. */
|
|
62
|
+
readonly maxBytes?: number;
|
|
63
|
+
/** Per-file source cap (default {@link DEFAULT_MAX_SOURCE_BYTES}). */
|
|
64
|
+
readonly maxSourceBytes?: number;
|
|
65
|
+
/** Ordered same-directory instruction candidates; upstream's defaults when omitted. */
|
|
66
|
+
readonly instructionFileCandidates?: readonly string[];
|
|
67
|
+
/** Ordered same-directory local-overlay candidates; upstream's defaults when omitted. */
|
|
68
|
+
readonly localInstructionFileCandidates?: readonly string[];
|
|
69
|
+
}
|
|
70
|
+
/** One root's freshly rendered instruction text. */
|
|
71
|
+
export interface RenderedRoot {
|
|
72
|
+
/** The canonical additional root. */
|
|
73
|
+
readonly root: string;
|
|
74
|
+
/** The rendered instruction text. */
|
|
75
|
+
readonly text: string;
|
|
76
|
+
}
|
|
77
|
+
/** One instruction file that is no longer present. */
|
|
78
|
+
export interface RemovedScope {
|
|
79
|
+
/** The additional root it belonged to. */
|
|
80
|
+
readonly root: string;
|
|
81
|
+
/** The instruction file's absolute path. */
|
|
82
|
+
readonly path: string;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The path one RAW tool call touched.
|
|
86
|
+
*
|
|
87
|
+
* `arguments` is the model-produced JSON string, so the path is only known after
|
|
88
|
+
* parsing it; a call that names no `file_path`, or is not one of the tools whose
|
|
89
|
+
* success makes a directory relevant, touched nothing this row cares about.
|
|
90
|
+
* @param name - the tool name the model called.
|
|
91
|
+
* @param argumentsJson - the tool call's raw arguments, exactly as produced.
|
|
92
|
+
* @returns the trimmed absolute-or-relative path, or `undefined`.
|
|
93
|
+
*/
|
|
94
|
+
export declare function touchedPathOfToolCall(name: string, argumentsJson: string): string | undefined;
|
|
95
|
+
/**
|
|
96
|
+
* Compose the model-facing message body for one batch of changes.
|
|
97
|
+
*
|
|
98
|
+
* All three halves matter. A newly visible root — or a newly touched
|
|
99
|
+
* subdirectory — needs its rules; a root that left needs an explicit
|
|
100
|
+
* revocation; and a file that disappeared needs an explicit withdrawal, because
|
|
101
|
+
* the instructions it contributed are still sitting in the conversation history
|
|
102
|
+
* and would otherwise keep applying forever. Silence is not a retraction.
|
|
103
|
+
* @param rendered - roots whose instruction text is new or changed.
|
|
104
|
+
* @param revoked - canonical roots that left the workspace scope.
|
|
105
|
+
* @param removed - instruction files that are no longer present.
|
|
106
|
+
* @returns the message text, or `undefined` when there is nothing to say.
|
|
107
|
+
*/
|
|
108
|
+
export declare function composeInstructionMessage(rendered: readonly RenderedRoot[], revoked: readonly string[], removed: readonly RemovedScope[]): string | undefined;
|
|
109
|
+
/** This row is gated on the compatibility contract like every other provider. */
|
|
110
|
+
export declare const inject: string[];
|
|
111
|
+
/**
|
|
112
|
+
* Mount the additional-root instruction provider.
|
|
113
|
+
*
|
|
114
|
+
* `fs` is a SOFT dependency, like the scope service's prompt contribution: a
|
|
115
|
+
* composition with no filesystem seam (headless probes, bare test contexts)
|
|
116
|
+
* contributes nothing instead of failing to load. The same is true of the
|
|
117
|
+
* upstream instruction package, which is an optional peer.
|
|
118
|
+
*
|
|
119
|
+
* Touch recording is registered OUTSIDE the `fs` injection, so a composition
|
|
120
|
+
* without a filesystem seam still learns which directories the session worked
|
|
121
|
+
* in; delivery is what needs the seam.
|
|
122
|
+
* @param ctx - the row's context.
|
|
123
|
+
* @param config - the row's configuration.
|
|
124
|
+
*/
|
|
125
|
+
export declare function apply(ctx: Context, config?: Config): void;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows named-kernel-semaphore adapter for the registry authority lease.
|
|
3
|
+
*
|
|
4
|
+
* This follows the same kernel-object contract as upstream
|
|
5
|
+
* `SessionWriteLease` (published `@deepseek-ai/dsh-session-persistence-jsonl`
|
|
6
|
+
* 0.1.5-rc.2): a count-1 named semaphore, a zero-timeout wait, and release by
|
|
7
|
+
* restoring the count then closing the handle — the object dies with its last
|
|
8
|
+
* handle, including on process death. It is a local reimplementation; it does
|
|
9
|
+
* not deep-import `session-persistence-jsonl/src/win32.ts` (ADR-0002).
|
|
10
|
+
*
|
|
11
|
+
* Koffi is loaded lazily so POSIX processes never load it.
|
|
12
|
+
*
|
|
13
|
+
* @module @dsh-electron/dsh-plugin-multi-root-workspace/registry-lease-win32
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Derive the semaphore name from the physical parent directory, resolving
|
|
17
|
+
* junctions, symlinks and short path aliases before case folding and hashing.
|
|
18
|
+
* @param path - the lock path; its parent exists, but the file need not.
|
|
19
|
+
* @returns the store's kernel-object name.
|
|
20
|
+
*/
|
|
21
|
+
export declare function leaseNameWin32(path: string): string;
|
|
22
|
+
/** Acquire the semaphore for the physical store directory. */
|
|
23
|
+
export declare function acquireLockHandleWin32(path: string): Promise<number>;
|
|
24
|
+
/**
|
|
25
|
+
* Restore the semaphore count and close the handle (the object dies with its
|
|
26
|
+
* last handle, including on process death).
|
|
27
|
+
* @param handle - the open semaphore handle.
|
|
28
|
+
*/
|
|
29
|
+
export declare function releaseLockHandleWin32(handle: number): Promise<void>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Store-wide kernel lease for the multi-root registry.
|
|
3
|
+
*
|
|
4
|
+
* The JSON storage backend is memory-authoritative after `open`: it reads the
|
|
5
|
+
* domain document once, then every write is an atomic rewrite of that in-memory
|
|
6
|
+
* snapshot. Two DSH processes sharing one storage root can therefore each grant
|
|
7
|
+
* a stale set, and a later rewrite silently drops the other process's mutation.
|
|
8
|
+
* The in-process `chains` queue in `registry.ts` cannot see that race.
|
|
9
|
+
*
|
|
10
|
+
* This lease makes one process the Registry Authority for the whole
|
|
11
|
+
* `multi_root_workspace` medium. The arbiter is the kernel, matching upstream
|
|
12
|
+
* session JSONL persistence: POSIX non-blocking `flock(2)` via
|
|
13
|
+
* `@deepseek-ai/node-addon-system/flock`, Windows a named kernel semaphore.
|
|
14
|
+
* There is no TTL and no stale-PID algorithm — the kernel releases the lock
|
|
15
|
+
* when the holder's descriptor (or last object handle) closes, including on
|
|
16
|
+
* crash. See ADR-0007.
|
|
17
|
+
*
|
|
18
|
+
* @module @dsh-electron/dsh-plugin-multi-root-workspace/registry-lease
|
|
19
|
+
*/
|
|
20
|
+
/** Thrown when another live process already holds the registry lease. */
|
|
21
|
+
export declare class RegistryLeaseContendedError extends Error {
|
|
22
|
+
/** Stable code the registry maps onto `registry-contended`. */
|
|
23
|
+
readonly code: "registry-contended";
|
|
24
|
+
/** Absolute lock path that was contended. */
|
|
25
|
+
readonly leasePath: string;
|
|
26
|
+
/**
|
|
27
|
+
* @param leasePath - the store-wide lock path that was contended.
|
|
28
|
+
*/
|
|
29
|
+
constructor(leasePath: string);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* One held store-wide registry lock. Constructed only by
|
|
33
|
+
* {@link RegistryAuthorityLease.acquire}; `release` closes the descriptor or
|
|
34
|
+
* handle, which is what releases the lock.
|
|
35
|
+
*/
|
|
36
|
+
export declare class RegistryAuthorityLease {
|
|
37
|
+
private readonly held;
|
|
38
|
+
private released;
|
|
39
|
+
private constructor();
|
|
40
|
+
/**
|
|
41
|
+
* Acquire the exclusive kernel lock for one registry store.
|
|
42
|
+
*
|
|
43
|
+
* POSIX opens `leasePath` (creating it and its parent) and takes a
|
|
44
|
+
* non-blocking exclusive flock. Because flock names an inode, the holder then
|
|
45
|
+
* verifies the locked inode is still the file at `leasePath` and retries
|
|
46
|
+
* otherwise — an unlinked-and-recreated lock file carries a fresh inode.
|
|
47
|
+
* Windows never flocks the file: it holds a named kernel semaphore derived
|
|
48
|
+
* from the canonical path so readers and directory removal stay free.
|
|
49
|
+
* @param leasePath - absolute path of the lock file (store-wide, beside the domain JSON).
|
|
50
|
+
* @returns the held lease.
|
|
51
|
+
* @throws {RegistryLeaseContendedError} while another holder keeps the lock.
|
|
52
|
+
*/
|
|
53
|
+
static acquire(leasePath: string): Promise<RegistryAuthorityLease>;
|
|
54
|
+
/**
|
|
55
|
+
* Release the kernel lock by closing its descriptor or handle. The POSIX
|
|
56
|
+
* lock file is never removed: keeping it preserves the stable inode later
|
|
57
|
+
* lockers verify against. POSIX close is synchronous so a fiber disposer that
|
|
58
|
+
* does not await this promise still drops the flock before returning.
|
|
59
|
+
* Idempotent.
|
|
60
|
+
*/
|
|
61
|
+
release(): Promise<void>;
|
|
62
|
+
}
|
package/lib/types/registry.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* (`ctx.multiRootScope`). Neither provider learns that a registry exists —
|
|
9
9
|
* `setAdditionalRoots()` stays the scope's only write port.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
11
|
+
* Five deliberate properties:
|
|
12
12
|
*
|
|
13
13
|
* - **Only usable roots are granted.** A registered directory that is absent
|
|
14
14
|
* right now stays registered and is reported as `missing`, but is withheld
|
|
@@ -33,6 +33,11 @@
|
|
|
33
33
|
* re-stats and re-canonicalizes every record, republishes the scope, and
|
|
34
34
|
* leaves storage untouched — a read must not rewrite the store, and a
|
|
35
35
|
* read-only refresh must not resurrect anything.
|
|
36
|
+
* - **One process is the Registry Authority.** The JSON backend is
|
|
37
|
+
* memory-authoritative after open, so two DSH processes sharing a storage
|
|
38
|
+
* root cannot each keep a live domain snapshot. A store-wide kernel lease
|
|
39
|
+
* (ADR-0007) elects one authority; a contended process publishes an empty
|
|
40
|
+
* scope, rejects mutations, and retries the lease from {@link MultiRootRegistry.refresh}.
|
|
36
41
|
*
|
|
37
42
|
* @module @dsh-electron/dsh-plugin-multi-root-workspace/registry
|
|
38
43
|
*/
|
|
@@ -46,6 +51,34 @@ export declare const DOMAIN_NAME = "multi_root_workspace";
|
|
|
46
51
|
export declare const TABLE_NAME = "roots";
|
|
47
52
|
/** Longest accepted display alias. */
|
|
48
53
|
export declare const MAX_ALIAS_LENGTH = 120;
|
|
54
|
+
/**
|
|
55
|
+
* Operator-facing English for a contended lease. Host-side commands have no
|
|
56
|
+
* locale; the panel also shows this string verbatim via `RootsView.unavailable`.
|
|
57
|
+
*/
|
|
58
|
+
export declare const REGISTRY_CONTENDED_MESSAGE = "the root registry is owned by another DSH process; additional roots are not granted here. Close the other process, then retry.";
|
|
59
|
+
/**
|
|
60
|
+
* Whether this process is the Registry Authority for the store-wide domain
|
|
61
|
+
* medium. Contended and storage-failed are both fail-closed: the scope stays
|
|
62
|
+
* empty and mutations reject.
|
|
63
|
+
*/
|
|
64
|
+
export type RegistryAuthorityState = {
|
|
65
|
+
readonly kind: 'active';
|
|
66
|
+
} | {
|
|
67
|
+
readonly kind: 'contended';
|
|
68
|
+
} | {
|
|
69
|
+
readonly kind: 'storage-failed';
|
|
70
|
+
readonly reason: string;
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Plugin configuration for the registry row. `leasePath` must sit beside the
|
|
74
|
+
* JSON backend's domain document; the patch default is
|
|
75
|
+
* `$DSH_HOME/storages/multi_root_workspace.lock`. A custom `storage-json.root`
|
|
76
|
+
* must override this in lockstep (ADR-0007).
|
|
77
|
+
*/
|
|
78
|
+
export interface Config {
|
|
79
|
+
/** Absolute path of the store-wide kernel lock file. */
|
|
80
|
+
readonly leasePath?: string;
|
|
81
|
+
}
|
|
49
82
|
declare module '@deepseek-ai/cordis' {
|
|
50
83
|
interface Context {
|
|
51
84
|
multiRootRegistry: MultiRootRegistry;
|
|
@@ -99,40 +132,59 @@ export interface AddRootInput {
|
|
|
99
132
|
/** The registry service: `ctx.multiRootRegistry`. */
|
|
100
133
|
export declare class MultiRootRegistry extends Service {
|
|
101
134
|
static inject: string[];
|
|
102
|
-
/**
|
|
103
|
-
private
|
|
104
|
-
/**
|
|
105
|
-
private
|
|
135
|
+
/** Absolute lock path; absent means the row was composed without `leasePath`. */
|
|
136
|
+
private readonly leasePath;
|
|
137
|
+
/** Whether this process currently owns the store. */
|
|
138
|
+
private authorityState;
|
|
139
|
+
/** Held kernel lease; present once acquire succeeded, including after a failed open. */
|
|
140
|
+
private lease;
|
|
141
|
+
/** Open domain handle; present only while {@link authority} is `active`. */
|
|
142
|
+
private domain;
|
|
143
|
+
/** The open table; present once this process is the authority. */
|
|
144
|
+
private table;
|
|
106
145
|
/** Last classified status list per canonical primary root. */
|
|
107
146
|
private readonly cache;
|
|
108
147
|
/** Signature of the last published grant per key, so a no-op refresh stays a no-op. */
|
|
109
148
|
private readonly published;
|
|
110
149
|
/** One promise chain per canonical primary root: mutations never interleave. */
|
|
111
150
|
private readonly chains;
|
|
151
|
+
/** Store-wide queue for lease acquire / open / release. */
|
|
152
|
+
private authorityChain;
|
|
112
153
|
/** Change listeners, keyed by their own disposer identity. */
|
|
113
154
|
private readonly listeners;
|
|
155
|
+
/** True after a contended-lease warning has been emitted, so refresh does not spam. */
|
|
156
|
+
private loggedContended;
|
|
157
|
+
/** True once the fiber disposer has run: no further acquisition may happen. */
|
|
158
|
+
private disposed;
|
|
114
159
|
/**
|
|
115
160
|
* @param ctx - the host context; `storageDomain` and `multiRootScope` are injected.
|
|
161
|
+
* @param config - the patch row's `leasePath`.
|
|
116
162
|
*/
|
|
117
|
-
constructor(ctx: Context);
|
|
163
|
+
constructor(ctx: Context, config?: Config);
|
|
118
164
|
/**
|
|
119
|
-
*
|
|
165
|
+
* Become the Registry Authority if possible: acquire the store-wide lease,
|
|
166
|
+
* then open the domain and publish every stored root into the scope.
|
|
120
167
|
*
|
|
121
|
-
* A
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
* reason until the file is repaired or removed.
|
|
168
|
+
* A contended lease is fail-closed rather than a stale open: this process
|
|
169
|
+
* grants nothing and every mutation reports `registry-contended`. A store
|
|
170
|
+
* this build cannot read is reported loudly and also grants nothing. Neither
|
|
171
|
+
* case fails activation — a broken or contended side store must not keep the
|
|
172
|
+
* whole harness from starting. {@link MultiRootRegistry.refresh} retries.
|
|
127
173
|
*/
|
|
128
174
|
protected [Service.init](): Promise<void>;
|
|
129
175
|
/**
|
|
130
|
-
* Why the
|
|
131
|
-
* silently empty list, so an operator can tell "no roots
|
|
132
|
-
* "the
|
|
133
|
-
*
|
|
176
|
+
* Why the registry is not granting, when it is not. Surfaces show this
|
|
177
|
+
* instead of a silently empty list, so an operator can tell "no roots
|
|
178
|
+
* configured" from "another DSH process owns the store" or "the file could
|
|
179
|
+
* not be read".
|
|
180
|
+
* @returns the recorded failure text, or `undefined` while this process is the authority.
|
|
134
181
|
*/
|
|
135
182
|
get unavailable(): string | undefined;
|
|
183
|
+
/**
|
|
184
|
+
* Whether this process is the Registry Authority for the store-wide medium.
|
|
185
|
+
* Tests and diagnostics read this; surfaces use {@link unavailable}.
|
|
186
|
+
*/
|
|
187
|
+
get authority(): RegistryAuthorityState;
|
|
136
188
|
/**
|
|
137
189
|
* Every registration of one primary root, in registry order, as last
|
|
138
190
|
* classified. A READ of the in-memory state: it never touches the filesystem
|
|
@@ -156,6 +208,11 @@ export declare class MultiRootRegistry extends Service {
|
|
|
156
208
|
*
|
|
157
209
|
* The serialized queue is shared with the mutations, so a refresh never
|
|
158
210
|
* interleaves with a write that is mid-flight.
|
|
211
|
+
*
|
|
212
|
+
* Refresh is also the takeover path: it calls {@link MultiRootRegistry.ensureAuthority}
|
|
213
|
+
* first, so a process that started contended can become the authority after
|
|
214
|
+
* the previous holder exits, re-open the domain from disk, and publish the
|
|
215
|
+
* last durable state — without a restart.
|
|
159
216
|
* @param primaryRoot - the session workspace root to re-check.
|
|
160
217
|
* @returns the refreshed status list.
|
|
161
218
|
*/
|
|
@@ -284,10 +341,64 @@ export declare class MultiRootRegistry extends Service {
|
|
|
284
341
|
/**
|
|
285
342
|
* The open table.
|
|
286
343
|
* @returns the live table.
|
|
287
|
-
* @throws {RootValidationError} `
|
|
288
|
-
*
|
|
344
|
+
* @throws {RootValidationError} `registry-contended` when another DSH process
|
|
345
|
+
* owns the store, `storage-unavailable` when the store could not be opened,
|
|
346
|
+
* or an error when the service has not started.
|
|
289
347
|
*/
|
|
290
348
|
private requireTable;
|
|
349
|
+
/**
|
|
350
|
+
* Reject mutations unless this process is the Registry Authority.
|
|
351
|
+
* Called before path validation so a contended process never mis-reports
|
|
352
|
+
* `missing` / `not-absolute` for a directory it is not allowed to register.
|
|
353
|
+
*/
|
|
354
|
+
private requireActive;
|
|
355
|
+
/**
|
|
356
|
+
* Acquire the store-wide lease if this process does not already hold it, then
|
|
357
|
+
* open the domain from disk. Idempotent while active. A contended process
|
|
358
|
+
* retries here (the command `list` and the panel Retry button both call
|
|
359
|
+
* {@link MultiRootRegistry.refresh}).
|
|
360
|
+
*/
|
|
361
|
+
private ensureAuthority;
|
|
362
|
+
/**
|
|
363
|
+
* Open the domain under an already-held lease and publish every stored root.
|
|
364
|
+
* On failure the lease is kept so a later refresh can retry the open without
|
|
365
|
+
* letting a second process grant from a stale snapshot.
|
|
366
|
+
*/
|
|
367
|
+
private openDomain;
|
|
368
|
+
/** Fail closed: empty scope, no table, contended state. */
|
|
369
|
+
private enterContended;
|
|
370
|
+
/** Fail closed: empty scope, no table, storage-failed state. The lease, if held, stays. */
|
|
371
|
+
private enterStorageFailed;
|
|
372
|
+
/** Drop every grant this process has published. Idempotent. */
|
|
373
|
+
private withdrawPublished;
|
|
374
|
+
/**
|
|
375
|
+
* Drain every queued mutation, close the domain, then release the kernel
|
|
376
|
+
* lease — in that order, and inside the authority-transition queue.
|
|
377
|
+
*
|
|
378
|
+
* The order is the invariant, not a detail. The lease is what makes "the
|
|
379
|
+
* store is mine" true, so it must outlive every write this process will ever
|
|
380
|
+
* make, including the queued writes `close()` itself drains: a successor that
|
|
381
|
+
* acquired the lease and opened the medium in the window between a release
|
|
382
|
+
* and a `put()` would read a snapshot missing that mutation. Running the
|
|
383
|
+
* whole teardown through {@link MultiRootRegistry.serializeAuthority} is the
|
|
384
|
+
* other half: a `refresh()` that is mid-flight when the fiber disposes must
|
|
385
|
+
* not finish by opening a domain and a lease that nothing will ever close.
|
|
386
|
+
*/
|
|
387
|
+
private releaseAuthority;
|
|
388
|
+
/**
|
|
389
|
+
* Wait until no per-primary-root mutation is queued or running.
|
|
390
|
+
*
|
|
391
|
+
* One pass is enough, and only because the authority state has already been
|
|
392
|
+
* flipped: no further mutation can be admitted (it would fail in
|
|
393
|
+
* `requireTable()`), and every mutation admitted earlier put its own tail in
|
|
394
|
+
* `chains` before it ran.
|
|
395
|
+
*/
|
|
396
|
+
private drainMutations;
|
|
397
|
+
/**
|
|
398
|
+
* Run one authority-transition job after every job already queued. Acquire,
|
|
399
|
+
* open, and release never interleave with each other.
|
|
400
|
+
*/
|
|
401
|
+
private serializeAuthority;
|
|
291
402
|
}
|
|
292
403
|
export type { AdditionalRootId, RegisteredRoot, RootRef, RootStatus };
|
|
293
404
|
export default MultiRootRegistry;
|
package/lib/types/roots.d.ts
CHANGED
|
@@ -108,7 +108,7 @@ export type RootRef = {
|
|
|
108
108
|
readonly addedAt: string;
|
|
109
109
|
};
|
|
110
110
|
/** Every way root handling can fail, as a stable code the surfaces localize. */
|
|
111
|
-
export type RootValidationCode = 'not-absolute' | 'missing' | 'not-a-directory' | 'equals-primary' | 'primary-overlap' | 'duplicate' | 'nested' | 'invalid-alias' | 'not-found' | 'invalid-ref' | 'storage-unavailable' | 'reveal-unavailable';
|
|
111
|
+
export type RootValidationCode = 'not-absolute' | 'missing' | 'not-a-directory' | 'equals-primary' | 'primary-overlap' | 'duplicate' | 'nested' | 'invalid-alias' | 'not-found' | 'invalid-ref' | 'storage-unavailable' | 'registry-contended' | 'reveal-unavailable' | 'session-not-found';
|
|
112
112
|
/** A rejected root operation; `code` is the stable contract, `message` the fallback prose. */
|
|
113
113
|
export declare class RootValidationError extends Error {
|
|
114
114
|
readonly code: RootValidationCode;
|
package/lib/types/sandbox.d.ts
CHANGED
|
@@ -26,9 +26,10 @@
|
|
|
26
26
|
*
|
|
27
27
|
* @module @dsh-electron/dsh-plugin-multi-root-workspace/sandbox
|
|
28
28
|
*/
|
|
29
|
-
import type {
|
|
29
|
+
import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox';
|
|
30
30
|
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local';
|
|
31
31
|
import type { Config } from '@deepseek-ai/dsh-sandbox-local';
|
|
32
|
+
import type { UpstreamConfined } from './compat/sandbox-confine.ts';
|
|
32
33
|
export type { Config };
|
|
33
34
|
/**
|
|
34
35
|
* Local process-sandbox provider over the multi-root scope. Registers as
|
|
@@ -48,11 +49,30 @@ export declare class MultiRootSandboxProvider extends LocalSandboxProvider {
|
|
|
48
49
|
* bash executor and the PTY backend both hand their argv straight to the local
|
|
49
50
|
* runtime in that mode. Widening therefore only ever ADDS the scope's
|
|
50
51
|
* additional roots to an upstream `workspace-write` profile.
|
|
52
|
+
*
|
|
53
|
+
* The return shape is the INSTALLED upstream provider's: synchronous on a
|
|
54
|
+
* release with a synchronous `confine`, a promise on a release with an
|
|
55
|
+
* asynchronous one. `widenConfined` preserves whichever it is — see
|
|
56
|
+
* `./compat/sandbox-confine.ts`.
|
|
51
57
|
* @param argv - the exact argv the caller is about to spawn.
|
|
52
58
|
* @param policy - the file-effect policy this execution runs under.
|
|
59
|
+
* @param signal - cancellation, forwarded to releases that accept it.
|
|
53
60
|
* @returns the argv to spawn instead, plus the selected backend's facts.
|
|
54
61
|
*/
|
|
55
|
-
confine(argv: readonly string[], policy: SandboxPolicy):
|
|
62
|
+
confine(argv: readonly string[], policy: SandboxPolicy, signal?: AbortSignal): UpstreamConfined;
|
|
63
|
+
/**
|
|
64
|
+
* Graft the scope's additional roots onto one upstream `confine` result.
|
|
65
|
+
*
|
|
66
|
+
* Pure with respect to the upstream call: it only ever reads the scope and
|
|
67
|
+
* rewrites `argv`. `enforcement`, `denialSignatures` and `runnerFailureRules`
|
|
68
|
+
* are passed through untouched, because the bash executor derives its denial
|
|
69
|
+
* and enforcement reporting from them.
|
|
70
|
+
* @param confined - the upstream result for this call.
|
|
71
|
+
* @param argv - the caller's argv, needed to re-split the wrapped form.
|
|
72
|
+
* @param policy - the per-call policy.
|
|
73
|
+
* @returns the upstream result element for element, or one with a widened argv.
|
|
74
|
+
*/
|
|
75
|
+
private widenForScope;
|
|
56
76
|
/**
|
|
57
77
|
* Report the documented first-release limitation exactly once: the Windows ACL
|
|
58
78
|
* rung grants through per-workspace write SIDs rather than argv paths, so
|