What plugin management does now lives in `dsh-plugin-manager`, a boot-group package beside `app-boot`: `PluginInstaller` runs pnpm, probes, and judges new packages over the profile on disk, and `PluginManager` adds every operation over the booted tree, each refusal a `PluginOperationError` with a `plugins/*` code and no Remote protocol attached. The manager reads the profile runtime, the preset roster, and the running-agent count through per-call readers, and the roster only as `PresetLayers`, so the package depends on neither the roster nor the agent registry. `dsh-host-plugin-manager` keeps `pluginManager` and the `plugins` Remote as a relay: one Remote method per manager method, and one exhaustive switch turning a failure into the Remote error of the same code (`plugins/bad-request` into `gateway/bad-request`). Its `./types` re-exports the manager's types, so `api-remotes` and the client import one vocabulary unchanged. `dsh plugin add` and `remove` now run the installer instead of a bare pnpm forwarder: every new package is probed, a package that declares neither a bundle nor a plugin module is removed again with its reason, and newly installed bundles are enabled as before. Other pnpm verbs still forward.
240 lines
9.2 KiB
TypeScript
240 lines
9.2 KiB
TypeScript
/**
|
|
* The `pluginManager` service and the `plugins` Remote of the Web host: a
|
|
* Typert service that relays each call to the shared plugin manager over the
|
|
* booted profile and maps its `plugins/*` failures onto Remote errors of the
|
|
* same code. What plugin management does lives in `dsh-plugin-manager`; this
|
|
* row only reads the profile runtime, the preset roster, and the agent
|
|
* registry off the context and hands them over.
|
|
* @module @deepseek-ai/dsh-host-plugin-manager
|
|
*/
|
|
|
|
import type { Context } from '@deepseek-ai/cordis'
|
|
import z from '@deepseek-ai/schemastery'
|
|
import type { probePackage } from '@deepseek-ai/dsh-app-boot'
|
|
import type {} from '@deepseek-ai/dsh-agent'
|
|
import type {} from '@deepseek-ai/dsh-agent-presets'
|
|
import {
|
|
PluginManager,
|
|
pluginOperationFailureOf,
|
|
type PluginDependents,
|
|
type PluginEnableResult,
|
|
type PluginInstallResult,
|
|
type PluginOperationFailure,
|
|
type PluginPackageView,
|
|
type PluginRowAddition,
|
|
type PluginRowTarget,
|
|
type SpawnLike,
|
|
} from '@deepseek-ai/dsh-plugin-manager'
|
|
import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
|
import { assertNever, type JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
import type {} from './types.ts'
|
|
|
|
export type * from './types.ts'
|
|
|
|
declare module '@deepseek-ai/cordis' {
|
|
interface Context {
|
|
/** Plugin management over the booted profile; mounted by the web bundle. */
|
|
pluginManager: PluginManagerRemote
|
|
}
|
|
}
|
|
|
|
/** Plugin config: the pnpm command and the bounds on the child processes plugin management runs. */
|
|
export interface Config {
|
|
/** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. */
|
|
pnpmCommand: string
|
|
/** Bound on one install or remove run, in milliseconds. */
|
|
installTimeoutMs: number
|
|
/** Bound on one package probe, in milliseconds. */
|
|
probeTimeoutMs: number
|
|
/** How many trailing bytes of an install run's output an install failure reports. */
|
|
installLogTailBytes: number
|
|
}
|
|
|
|
/** Test seams: the child spawner, the package probe, or a manager standing in for the shared one. */
|
|
export interface PluginManagerInternals {
|
|
spawn?: SpawnLike
|
|
probe?: typeof probePackage
|
|
/** The manager every call relays to; defaults to one over this context's profile runtime. */
|
|
manager?: PluginManager
|
|
}
|
|
|
|
/**
|
|
* The `pluginManager` service and the `plugins` Remote.
|
|
*
|
|
* The row injects only the Loader; the profile runtime, the preset roster,
|
|
* and the agent registry are read off the context per call, so a
|
|
* composition without them (a test, a launcher other than the profile
|
|
* launcher) still mounts this service and every call then reports
|
|
* `plugins/unavailable` rather than the service failing to start.
|
|
*/
|
|
export class PluginManagerRemote extends TypertRemoteService {
|
|
static inject = ['loader']
|
|
|
|
static Config: z<Config> = z.object({
|
|
pnpmCommand: z.string().default('pnpm'),
|
|
installTimeoutMs: z.number().min(1_000).default(600_000),
|
|
probeTimeoutMs: z.number().min(1_000).default(20_000),
|
|
installLogTailBytes: z.number().min(256).default(16_384),
|
|
})
|
|
|
|
private readonly manager: PluginManager
|
|
|
|
constructor(ctx: Context, public config: Config, internals: PluginManagerInternals = {}) {
|
|
super(ctx, 'pluginManager', { namespace: 'plugins' })
|
|
this.manager = internals.manager ?? new PluginManager(ctx, {
|
|
config,
|
|
runtime: () => ctx.get('profileRuntime'),
|
|
presets: () => ctx.get('agentPresets'),
|
|
runningAgents: () => (ctx.get('agents')?.list() ?? []).filter(agent => agent.status === 'running').length,
|
|
...internals.spawn === undefined ? {} : { spawn: internals.spawn },
|
|
...internals.probe === undefined ? {} : { probe: internals.probe },
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Every package the profile knows, one view each.
|
|
* @returns the views, bundles first in layer order.
|
|
*/
|
|
@Remote('list')
|
|
async list(): Promise<PluginPackageView[]> {
|
|
return relay(() => this.manager.list())
|
|
}
|
|
|
|
/**
|
|
* Install a package with pnpm, probe it, and leave it disabled unless asked otherwise.
|
|
* @param spec - what to install, in pnpm's own vocabulary.
|
|
* @param options - `enable` puts every newly installed bundle into the layer list at once.
|
|
* @returns what the run installed and enabled.
|
|
*/
|
|
@Remote('add')
|
|
async add(spec: string, options?: { enable?: boolean }): Promise<PluginInstallResult> {
|
|
return relay(() => this.manager.add(spec, options))
|
|
}
|
|
|
|
/**
|
|
* Remove a package from the profile with its user-layer rows and probe record.
|
|
* @param packageName - the installed dependency to remove.
|
|
*/
|
|
@Remote('uninstall')
|
|
async uninstall(packageName: string): Promise<void> {
|
|
return relay(() => this.manager.uninstall(packageName))
|
|
}
|
|
|
|
/**
|
|
* Put an installed bundle into the layer list and, on a live profile, recompose the tree with it.
|
|
* @param packageName - the installed bundle.
|
|
* @returns whether the list changed and whether the change is live.
|
|
*/
|
|
@Remote('enable')
|
|
async enable(packageName: string): Promise<PluginEnableResult> {
|
|
return relay(() => this.manager.enable(packageName))
|
|
}
|
|
|
|
/**
|
|
* Take a bundle out of the layer list and, on a live profile, recompose the tree without it.
|
|
* @param packageName - the enabled bundle.
|
|
* @returns whether the list changed and whether the change is live.
|
|
*/
|
|
@Remote('disable')
|
|
async disable(packageName: string): Promise<PluginEnableResult> {
|
|
return relay(() => this.manager.disable(packageName))
|
|
}
|
|
|
|
/**
|
|
* Compose an enabled bundle again from scratch.
|
|
* @param packageName - the enabled bundle.
|
|
* @returns the enable outcome of the second step.
|
|
*/
|
|
@Remote('retry')
|
|
async retry(packageName: string): Promise<PluginEnableResult> {
|
|
return relay(() => this.manager.retry(packageName))
|
|
}
|
|
|
|
/**
|
|
* Add a row naming one of the package's modules to a user layer.
|
|
* @param packageName - the installed package.
|
|
* @param target - which layer.
|
|
* @param options - `module` selects a declared `dsh.plugins[]` name (default `.`),
|
|
* `id` overrides the derived row id, `config` overrides the declared default.
|
|
* @returns where the row landed.
|
|
*/
|
|
@Remote('addRow')
|
|
async addRow(
|
|
packageName: string,
|
|
target: PluginRowTarget,
|
|
options?: { module?: string; id?: string; config?: JsonValue },
|
|
): Promise<PluginRowAddition> {
|
|
return relay(() => this.manager.addRow(packageName, target, options))
|
|
}
|
|
|
|
/**
|
|
* Remove a row a user layer inserted.
|
|
* @param target - which layer.
|
|
* @param rowId - the inserted row's id.
|
|
*/
|
|
@Remote('removeRow')
|
|
async removeRow(target: PluginRowTarget, rowId: string): Promise<void> {
|
|
return relay(() => this.manager.removeRow(target, rowId))
|
|
}
|
|
|
|
/**
|
|
* Switch one row off or on in a user layer; deny-only.
|
|
* @param target - which layer.
|
|
* @param rowId - the row's id as the composition declares it.
|
|
* @param disabled - whether the layer should switch the row off.
|
|
*/
|
|
@Remote('setRowDisabled')
|
|
async setRowDisabled(target: PluginRowTarget, rowId: string, disabled: boolean): Promise<void> {
|
|
return relay(() => this.manager.setRowDisabled(target, rowId, disabled))
|
|
}
|
|
|
|
/**
|
|
* What disabling or removing a package would strand.
|
|
* @param packageName - the package.
|
|
* @returns the dependents.
|
|
*/
|
|
@Remote('dependents')
|
|
async dependents(packageName: string): Promise<PluginDependents> {
|
|
return relay(() => this.manager.dependents(packageName))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run one manager call and turn its failure into the Remote error of the
|
|
* same code; anything else propagates untouched.
|
|
*/
|
|
async function relay<T>(run: () => Promise<T>): Promise<T> {
|
|
try {
|
|
return await run()
|
|
} catch (error) {
|
|
const failure = pluginOperationFailureOf(error)
|
|
throw failure === undefined ? error : remoteErrorOf(failure)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The Remote error a plugin operation failure crosses the wire as: the same
|
|
* code and details, except the generic refusal, which is the Gateway's
|
|
* `gateway/bad-request`.
|
|
* @param failure - the manager's failure.
|
|
* @returns the Remote error, with the failure as its cause.
|
|
*/
|
|
export function remoteErrorOf(failure: PluginOperationFailure): RemoteError {
|
|
const options = { cause: failure }
|
|
switch (failure.code) {
|
|
case 'plugins/unavailable': return new RemoteError(failure.code, failure.message, failure.details, options)
|
|
case 'plugins/not-installed': return new RemoteError(failure.code, failure.message, failure.details, options)
|
|
case 'plugins/not-enableable': return new RemoteError(failure.code, failure.message, failure.details, options)
|
|
case 'plugins/enable-failed': return new RemoteError(failure.code, failure.message, failure.details, options)
|
|
case 'plugins/install-failed': return new RemoteError(failure.code, failure.message, failure.details, options)
|
|
case 'plugins/row-conflict': return new RemoteError(failure.code, failure.message, failure.details, options)
|
|
case 'plugins/busy': return new RemoteError(failure.code, failure.message, failure.details, options)
|
|
case 'plugins/agents-running': return new RemoteError(failure.code, failure.message, failure.details, options)
|
|
case 'plugins/bad-request': return new RemoteError('gateway/bad-request', failure.message, {}, options)
|
|
/* v8 ignore next -- closed-union exhaustiveness guard */
|
|
default: return assertNever(failure)
|
|
}
|
|
}
|
|
|
|
export default PluginManagerRemote
|