mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-10-21 13:54:39 +00:00
feat(plugins): add scoped V2 plugin activation controls (#726)
## Summary - Add narrow CodeNomad routes for native OpenCode V2 plugin inventory and activation-rule mutations. - Present one row per user/configured plugin with exactly two compact **Global** and **Project** switches, sharing Yolo/MCP switch geometry. Builtins are hidden; exact configured disabled IDs remain available. - Keep complete names, source paths, failures and configuration targets readable on narrow keyboard/touch layouts. Retain focused rows across refreshes and guard repeated pending activation without removing keyboard focus. - Provide translated feedback in all ten locales and suppress stale feedback after the owning surface is disposed. ## Authority and refresh behavior - Discover roots through the authenticated daemon's `config.get`, after native-location ownership and connection-generation checks. - Append only exact activation rules while preserving JSONC comments, source options, unknown keys, formatting, BOM and native permissions. Bound reads, check optimistic conflicts, serialize by write target and use atomic replacement with nonce-owned locks. - Require proven raw/normalized alignment before acknowledging a no-op. Watcher lag cannot report success for a rule that is no longer saved on disk. - Account for inherited `.opencode` precedence by choosing a higher-precedence target in the active project location; preserve ancestor files and reject an escaping fallback target. - Use native WSL filesystem operations. Retain canonical invalidation and one trailing reconciliation when host/native cache records converge. - Cache display snapshots by instance/worktree, share them across sessions in that worktree, and refresh only on visible demand. Global writes invalidate sibling snapshots without background reads. ## Compatibility The current server/UI/bundled-plugin dependency pin and recommended runtime are **2.0.16**, aligned with `dev`. The demonstrated technical minimum remains **2.0.7**. Activation writes use the narrow local JSONC operation rather than a runtime-specific write endpoint. ## Validation status and provenance Final reviewed head: **`5204d97694ca8ced954bddc9cc94ea464e46119d`**, aligned with `dev@9d7ea5d6`. Both independent resumed review loops reached **ZERO actionable findings**: UX R6 and quality/optimization R5. Local validation on the final head: **100 plugin/server/cache/WSL tests passed**, **eight real-component browser regressions passed**, **14 restoration/metadata integration tests passed**, server/UI typechecks and UI production build passed. Translation keys/placeholders match across all ten locales. The current-dev baseline and PR browser comparison passed the same 34 shared cases with one optional native Electron skip; detailed revision provenance is in the final review comment. **CI qualification is excluded at the user's explicit request.** No final CI success is claimed, and CI completion is not being awaited. The earlier head `38845060` failed typechecking because `noticeId` was undeclared; the earlier historical UX R4 zero-finding claim was also incorrect and has been [explicitly corrected](https://github.com/NeuralNomadsAI/CodeNomad/pull/726#issuecomment-5816956417). Historical test results and the installer built from `bd31dcd0` are not validation or artifacts of the final corrected head. See the final review comment for completed counts, review evidence and revision boundaries. The PR remains open. ## Scope note Implemented independently; no code was copied from PR #593. This separate V2 implementation supersedes PR #593.
This commit is contained in:
parent
9d7ea5d683
commit
ec1c0bedbf
51 changed files with 6254 additions and 97 deletions
3
.github/workflows/pr-build.yml
vendored
3
.github/workflows/pr-build.yml
vendored
|
|
@ -286,6 +286,9 @@ jobs:
|
|||
packages/ui/src/stores/client-state.test.ts
|
||||
packages/ui/src/stores/form-settlements.test.ts
|
||||
packages/ui/src/stores/instance-invalidation.test.ts
|
||||
packages/ui/src/stores/plugin-controls.test.ts
|
||||
packages/ui/src/stores/plugin-controls-demand.test.ts
|
||||
packages/ui/src/stores/plugin-controls-events.test.ts
|
||||
packages/ui/src/stores/message-prompt-display.test.ts
|
||||
packages/ui/src/stores/message-v2/instance-store.test.ts
|
||||
packages/ui/src/stores/message-v2/message-hydration-authority.test.ts
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
- Session hierarchy geometry lives in `styles/components/session-tree.css`; connector axes follow the parent expander at every depth, including selection mode, RTL and touch layouts.
|
||||
- Session search/filter mode uses flat per-session results with an optional subsession switch; filters, sorting, worktree badges and selection use each result's own identity. Normal browsing retains the session hierarchy.
|
||||
- Never use rounded corners in UI styling; keep corners square unless the user explicitly requests otherwise for a specific change.
|
||||
- Explicit round exceptions: Yolo and MCP switches (shared `styles/components/switches.css` geometry), overlay drawer navigation buttons, and floating message scroll buttons. Other chrome remains square.
|
||||
- Explicit round exceptions: Yolo, MCP and plugin activation switches (shared `styles/components/switches.css` geometry), overlay drawer navigation buttons, and floating message scroll buttons. Other chrome remains square.
|
||||
- Tags and numeric/context/token labels also use rounded geometry via `--chip-radius` (`--pill-radius` is an alias). Register badge variants in `styles/components/badges.css`; use `.badge-shape` for utility-styled labels rather than adding a local radius.
|
||||
- The message-content popup and Chat settings share `components/transcript-visibility.ts` and the semantic icons from `components/message-content-icons.ts`; tool presentation metadata lives independently of renderers in `components/tool-call/tool-presentation.ts`. Popup styles live in `styles/components/transcript-filters.css`.
|
||||
- Tool-result images retain native `state.content` and render through the shared `components/tool-call/output-images.tsx` surface, including MCP and specialized tools. Keep image bytes out of text projections (copy/search/speech); image styles live in `styles/messaging/tool-call/images.css`.
|
||||
|
|
@ -29,6 +29,7 @@
|
|||
- Palette settings follow the resolved appearance in Auto mode. Keep the picker, single-row square swatches and trailing actions aligned. Swatch names use tooltips and accessible input labels. At narrow card widths, scroll the swatch strip beside the picker and move actions below via the `palette-settings` container. Swatch styles live in `styles/components/theme-scheme-swatches.css`.
|
||||
- Appearance mode and the saved light/dark selections are independent (`lib/appearance-preferences.ts`). Message/tool cards use the muted surface, inset output and the composer use the base canvas, and preferences use the same secondary surface as the main panels. Use `--surface-hover-overlay` for a subtle local rollover; preserve selected backgrounds beneath that overlay instead of replacing them with a generic panel color.
|
||||
- Right-panel base-canvas button rollover overrides live in `styles/panels/control-hover.css`; do not substitute the secondary surface merely to show hover.
|
||||
- V2 plugin activation controls use the shared rounded SUID switch geometry in `styles/components/switches.css`. Present Global and Project as separate per-plugin columns so every mutation has an explicit scope. Cache display snapshots by instance and worktree directory, and refresh them only while the plugin surface is visible.
|
||||
- Project and right-panel tabs share `components/tab-scroll.tsx` and `styles/components/tab-scroll.css`. Keep their native scrollbar above upright content without mirrored transforms, negative border overlaps or permanent compositing hints. Validate shared scrollbar styling and adjoining edges at fractional zoom in the browser and isolated Electron renderer fixtures (`tests/browser/tab-chrome.test.ts`).
|
||||
|
||||
## Coding Principles
|
||||
|
|
|
|||
207
dev-docs/PLUGIN_ACTIVATION_CONTROLS.md
Normal file
207
dev-docs/PLUGIN_ACTIVATION_CONTROLS.md
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
# OpenCode V2 plugin activation controls
|
||||
|
||||
CodeNomad exposes **activation controls**, not a package manager. The surface
|
||||
does not install, remove, update, or edit plugin options. Those operations stay
|
||||
in OpenCode configuration and the OpenCode CLI.
|
||||
|
||||
## V2 contract
|
||||
|
||||
OpenCode applies plugin entries in configuration order. `-plugin.id` disables
|
||||
an ID, `plugin.id` later re-enables it, `*` matches all plugins, and `prefix.*`
|
||||
matches an ID prefix. Source declarations remain in place when an activation
|
||||
rule is added.
|
||||
|
||||
The runtime exposes `plugin.list`, `plugin.check`, and `plugin.update`, but no
|
||||
symmetric enable/disable operation. The pinned client's `config.update` only
|
||||
supports its declared preference fields. CodeNomad therefore does not proxy an
|
||||
upstream write. It performs one narrow, authenticated configuration mutation.
|
||||
|
||||
## Authority and precedence
|
||||
|
||||
Every read and mutation starts with the workspace's acquired, authenticated
|
||||
OpenCode connection and an ownership check for the complete native location.
|
||||
`config.get()` is the authority for the daemon's discovered paths:
|
||||
|
||||
- **Global** uses the daemon-reported global configuration directory. The
|
||||
highest existing `opencode.jsonc`/`opencode.json` document is selected;
|
||||
otherwise `opencode.jsonc` is created there.
|
||||
- **Project** uses only the active location directory. The highest existing
|
||||
direct or `.opencode` document is selected; otherwise
|
||||
`.opencode/opencode.jsonc` is created. When discovery reports an inherited
|
||||
`.opencode` document, every direct document ranks below it: Project therefore
|
||||
selects an existing active-location `.opencode` document or creates
|
||||
`.opencode/opencode.jsonc` there. It never selects the ancestor as its target.
|
||||
|
||||
WSL paths are resolved and mutated through the workspace's selected distro;
|
||||
configuration permissions never come from synthetic UNC metadata. No path is
|
||||
derived from the CodeNomad process environment, CLI debug output, or
|
||||
`OPENCODE_CONFIG_CONTENT`. Reads return the canonical service directory; the UI
|
||||
aliases an initial Windows/UNC request to that identity so native plugin events,
|
||||
later session locations, and the original host path share one cache record.
|
||||
|
||||
If the location is the global configuration root, or Global and Project resolve
|
||||
through symlinks/junctions to the same physical document, Project is unavailable.
|
||||
Its switch remains visible but disabled, and the server rejects a direct Project
|
||||
mutation rather than writing the Global document under the wrong scope.
|
||||
The higher-precedence Project fallback is also unavailable if its physical
|
||||
destination escapes the active location through a symlink/junction; it must not
|
||||
edit an ancestor/shared file to implement a location-local override. Missing
|
||||
fallback documents are created only by an explicit mutation, not display reads.
|
||||
|
||||
Global rules can be overridden by later project rules. The UI presents one
|
||||
compact row per user/configured plugin with separate **Global** and **Project**
|
||||
switches; each gesture therefore names its write scope directly. Built-in
|
||||
OpenCode entries remain in the authoritative snapshot but are not presented as
|
||||
user activation controls. An exact disabled ID remains available even when it
|
||||
is absent from `plugin.list`, so it can be re-enabled. A plugin hidden only by a
|
||||
broad wildcard cannot be named safely unless the runtime or an exact configured
|
||||
rule reveals its ID. OpenCode's reserved `opencode.*` identity is retained on
|
||||
disabled synthesized controls so builtins do not become visible merely because
|
||||
`plugin.list` omits inactive entries.
|
||||
|
||||
A scope without an explicit matching rule inherits predictably in the compact
|
||||
switch UI: Global uses OpenCode's enabled baseline, while Project inherits the
|
||||
resolved Global state. A Project override therefore never changes the displayed
|
||||
Global switch, even though it controls the plugin's final effective state.
|
||||
Object-form sources remain source records, but are also replayed as ordered add
|
||||
operations when they re-enable an already resolved plugin, matching OpenCode's
|
||||
native supervisor. Relative and `file://` local declarations are correlated with
|
||||
the resolved runtime entrypoint before that ordered replay.
|
||||
|
||||
Display snapshots are keyed by workspace instance and worktree directory, not
|
||||
by session workspace identifiers. Changing sessions inside one worktree reuses
|
||||
the same snapshot. The UI requests the first snapshot only while the Plugins
|
||||
surface is visible and expanded. Location-scoped plugin events mark only their
|
||||
worktree stale; an event for an unknown directory matches nothing immediately,
|
||||
while a canonical event arriving before its WSL alias is learned is retained
|
||||
and fences only the snapshot that later adopts that canonical identity.
|
||||
If host and canonical reads already have separate in-flight records, merging
|
||||
them retains the canonical invalidation generation and coalesced refresh demand.
|
||||
An older alias response cannot publish over that event; the orphaned read is
|
||||
aborted and one reconciliation read supplies the shared snapshot.
|
||||
Configuration events mark the instance's worktrees stale
|
||||
because the event does not identify whether the global document changed.
|
||||
Hidden surfaces retain their last snapshot without starting background
|
||||
OpenCode reads, then refresh on the next visible demand. Scoped demand handles
|
||||
follow canonical aliases and are released on deactivation/disposal. A queued
|
||||
follow-up starts only while at least one visible consumer remains; otherwise
|
||||
its stale state is retained for reopening. Renderer-to-backend SSE reconnection
|
||||
invalidates every cached instance/worktree even when the backend's native
|
||||
OpenCode subscription stayed connected. A successful Global
|
||||
mutation likewise marks sibling worktree snapshots stale without refreshing
|
||||
them in the background. Generic instance metadata hydration does not fetch
|
||||
`plugin.list`; only a visible activation surface requests plugin inventory.
|
||||
`plugin.updated` refreshes agents, providers, and commands without forcing a
|
||||
generic metadata round-trip.
|
||||
|
||||
## Mutation guarantees
|
||||
|
||||
`packages/server/src/opencode/plugin-control-document.ts` owns the file
|
||||
operation:
|
||||
|
||||
1. Read at most 4 MiB and require valid UTF-8 JSON/JSONC with one array-valued
|
||||
`plugins` key.
|
||||
2. Resolve physical file destinations, including symlinked parent directories,
|
||||
and fail closed on malformed content, invalid plugin entries, circular links,
|
||||
or filesystem errors.
|
||||
3. Append only the requested exact `id` or `-id` entry with `jsonc-parser`.
|
||||
Comments, source objects, options, unknown keys, ordering, indentation, line
|
||||
endings, and a UTF-8 BOM are retained.
|
||||
4. Serialize in-process mutations and hold a per-target interprocess lock across
|
||||
the final source verification and atomic rename. The lock carries an owner
|
||||
nonce verified immediately before the rename, so a stalled owner fails with
|
||||
`conflict` instead of overwriting a successor. A stalled file/symlink lock
|
||||
is reaped like a stale directory. A same-directory temporary file is synced
|
||||
first with the source mode explicitly restored after creation (host `umask`
|
||||
cannot tighten it); an external mode-only change is a conflict. Crash
|
||||
leftovers (`*.tmp` with per-process random names) are never read and remain
|
||||
inert; locks self-heal after 30 s.
|
||||
|
||||
WSL performs bounded inspect/read/create/chmod/sync/rename operations inside the
|
||||
selected distro. Existing native modes such as `0600` are retained rather than
|
||||
being inferred from the Windows UNC projection. Shell helpers prefer portable
|
||||
fallbacks (`realpath`, `sync`, `sha256sum`/`shasum`) and Ubuntu remains the
|
||||
tested distro for minimal busybox images. A WSL toggle costs four `wsl.exe`
|
||||
spawns on the happy path (`inspectMany`, `load`, `prepare`, `commit`; cleanup
|
||||
runs only when the commit fails), each paying full process cold-start since
|
||||
there is no pooling — expect roughly 2–8 s of filesystem time before daemon
|
||||
RPCs on slow hosts.
|
||||
|
||||
The server keeps no display cache by design: every read and mutation reissues
|
||||
`config.get` + `plugin.list` (plus one `location.get` for session-scoped
|
||||
locations) and re-resolves targets, so snapshots can never serve stale daemon
|
||||
state. Expensive work is bounded instead: one snapshot base per mutation with
|
||||
positional overlays (no triple rebuild), per-write-target mutation queues, and
|
||||
UI-side coalesced trailing refreshes.
|
||||
|
||||
UI cache records live until `workspace.stopped` clears the instance
|
||||
(`clearInstance` also aborts in-flight reads). Records are keyed by worktree
|
||||
directory plus one canonical alias at most, so retention is bounded in practice
|
||||
by the worktree count; only unknown-directory invalidations use a bounded
|
||||
(200-entry) pending set.
|
||||
|
||||
Daemon-normalized `ConfigEntry` values remain authoritative for inventory and
|
||||
activation semantics, including environment substitutions. The raw JSONC is
|
||||
used for editing and conflict detection only. During a serialized write burst,
|
||||
concrete authorized rules already durable on disk but not yet reflected by the
|
||||
daemon are paired positionally and replayed after its normalized document. Raw
|
||||
`{env:...}`/`{file:...}` placeholders never consume a later concrete rule or
|
||||
enable the no-op shortcut. A no-op additionally requires the whole normalized
|
||||
prefix to match the raw target, followed only by concrete rules included in the
|
||||
projection. A raw deletion/replacement or an unprojected entry during watcher
|
||||
lag instead appends the requested exact rule; stale daemon state cannot prove
|
||||
that the requested state is already durable.
|
||||
|
||||
The connection generation is rechecked after discovery, before file preparation,
|
||||
and again immediately before the atomic rename. Mutations also enter the active
|
||||
worktree's deletion fence, so
|
||||
worktree removal drains an admitted write and a blocked write fails closed. The
|
||||
route accepts no caller-provided config path and refuses foreign locations,
|
||||
malformed runtime inventory, or IDs absent from the authorized
|
||||
runtime/configured inventory.
|
||||
|
||||
OpenCode watches these configuration roots. CodeNomad does not restart the
|
||||
daemon and does not claim that a successful file write means the plugin is
|
||||
already active. The mutation response publishes the durable configured state;
|
||||
`config.updated` and `plugin.updated` trigger fenced, coalesced refreshes until
|
||||
runtime state catches up.
|
||||
|
||||
## Runtime qualification
|
||||
|
||||
The feature uses only `config.get` and `plugin.list`, which are already consumed
|
||||
through CodeNomad's connection-scoped compatibility transport.
|
||||
|
||||
The server, UI, and bundled plugin currently pin `2.0.16` together. Its
|
||||
declarations provide the location-scoped reads, ordered `ConfigEntry.plugins`,
|
||||
and plugin source/state metadata used here. CodeNomad's demonstrated technical
|
||||
minimum is `2.0.7`, while `2.0.16` is the separately recommended and tested
|
||||
target. These activation controls add no higher version requirement. The
|
||||
activation write remains a local JSONC rule; no runtime-specific mutation
|
||||
endpoint is used.
|
||||
|
||||
Historical beta and stable contract-family results remain recorded in
|
||||
`OPENCODE_V2_COMPATIBILITY.md` as compatibility-adapter evidence, not as a
|
||||
separate support promise for this feature. Runtime admission follows
|
||||
`opencode/runtime-support.ts` and the evidence policy in that document. There is
|
||||
no release-number-only gate:
|
||||
unknown runtimes must first pass the existing authenticated OpenAPI contract
|
||||
recognition. Read or discovery failures leave controls unavailable and never
|
||||
fall back to guessed paths.
|
||||
|
||||
## Validation
|
||||
|
||||
- JSONC preservation, malformed input, optimistic conflict, and atomic replace:
|
||||
`plugin-control-document.test.ts`
|
||||
- native WSL path operations and permission retention:
|
||||
`plugin-control-document-wsl.test.ts` (set
|
||||
`CODENOMAD_TEST_WSL_PLUGIN_CONTROLS` to a distro for the native fixture)
|
||||
- ownership, WSL/host path authority, precedence, visibility of disabled IDs,
|
||||
serialization, and scope mutations: `plugin-controls.test.ts`
|
||||
- strict CodeNomad route and error mapping: `plugin-controls.test.ts` beside
|
||||
the route
|
||||
- worktree-scoped cache sharing, retained snapshots, demand-driven invalidation,
|
||||
generation fencing, and trailing refresh:
|
||||
`packages/ui/src/stores/plugin-controls.test.ts`
|
||||
- real Solid UI, full stylesheet, dual explicit scope switches, lazy visibility,
|
||||
shared rounded geometry, mutation, and native event dispatcher:
|
||||
`packages/ui/tests/browser/plugin-controls.test.ts`
|
||||
7
package-lock.json
generated
7
package-lock.json
generated
|
|
@ -11927,6 +11927,12 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonc-parser": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
|
||||
"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsonfile": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
|
||||
|
|
@ -18470,6 +18476,7 @@
|
|||
"commander": "^12.1.0",
|
||||
"fastify": "^4.28.1",
|
||||
"fuzzysort": "^2.0.4",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"node-forge": "^1.3.3",
|
||||
"openai": "^6.27.0",
|
||||
"pino": "^9.4.0",
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@
|
|||
"commander": "^12.1.0",
|
||||
"fastify": "^4.28.1",
|
||||
"fuzzysort": "^2.0.4",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"node-forge": "^1.3.3",
|
||||
"openai": "^6.27.0",
|
||||
"pino": "^9.4.0",
|
||||
|
|
|
|||
|
|
@ -296,6 +296,90 @@ export interface ConfigFileContentRequest {
|
|||
contents: string
|
||||
}
|
||||
|
||||
export type PluginControlScope = "global" | "project"
|
||||
export type PluginConfigScope = PluginControlScope | "other" | "virtual"
|
||||
|
||||
export interface PluginControlLocation {
|
||||
directory: string
|
||||
workspaceID?: string
|
||||
}
|
||||
|
||||
export type PluginRuntimeSource =
|
||||
| { type: "builtin" }
|
||||
| { type: "package"; target: string; version?: string; outdated?: true; updating?: true }
|
||||
| { type: "local"; path: string }
|
||||
| { type: "sdk" }
|
||||
|
||||
export interface PluginRuntimeInventoryEntry {
|
||||
key: string
|
||||
id?: string
|
||||
source: PluginRuntimeSource
|
||||
features: { server?: true; tui?: true; rpc?: true }
|
||||
state: { status: "active" } | { status: "failed"; error: string; ref?: string }
|
||||
}
|
||||
|
||||
export interface PluginConfiguredRule {
|
||||
selector: string
|
||||
enabled: boolean
|
||||
scope: PluginConfigScope
|
||||
path?: string
|
||||
order: number
|
||||
entryIndex: number
|
||||
}
|
||||
|
||||
export interface PluginConfiguredSource {
|
||||
target: string
|
||||
scope: PluginConfigScope
|
||||
path?: string
|
||||
entryIndex: number
|
||||
hasOptions: boolean
|
||||
}
|
||||
|
||||
export type PluginScopeRuleState = "default" | "enabled" | "disabled"
|
||||
|
||||
export interface PluginActivationControl {
|
||||
id: string
|
||||
runtime?: PluginRuntimeInventoryEntry
|
||||
/** True for OpenCode-owned plugins, including disabled builtins absent from runtime inventory. */
|
||||
builtin: boolean
|
||||
effective: PluginScopeRuleState
|
||||
global: PluginScopeRuleState
|
||||
project: PluginScopeRuleState
|
||||
controllingRule?: PluginConfiguredRule
|
||||
}
|
||||
|
||||
export interface PluginControlTarget {
|
||||
scope: PluginControlScope
|
||||
path: string
|
||||
exists: boolean
|
||||
}
|
||||
|
||||
export interface PluginControlsSnapshot {
|
||||
location: PluginControlLocation
|
||||
runtime: PluginRuntimeInventoryEntry[]
|
||||
configured: {
|
||||
rules: PluginConfiguredRule[]
|
||||
sources: PluginConfiguredSource[]
|
||||
}
|
||||
controls: PluginActivationControl[]
|
||||
targets: PluginControlTarget[]
|
||||
}
|
||||
|
||||
export interface PluginActivationMutationRequest {
|
||||
location: PluginControlLocation
|
||||
pluginId: string
|
||||
scope: PluginControlScope
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface PluginActivationMutationResponse {
|
||||
snapshot: PluginControlsSnapshot
|
||||
rule: string
|
||||
target: PluginControlTarget
|
||||
changed: boolean
|
||||
reloadPending: boolean
|
||||
}
|
||||
|
||||
export const WINDOWS_DRIVES_ROOT = "__drives__"
|
||||
|
||||
export interface WorkspaceFileResponse {
|
||||
|
|
|
|||
217
packages/server/src/opencode/plugin-control-document-wsl.test.ts
Normal file
217
packages/server/src/opencode/plugin-control-document-wsl.test.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { execFileSync } from "node:child_process"
|
||||
import { randomBytes } from "node:crypto"
|
||||
import { describe, it } from "node:test"
|
||||
import { appendPluginControlRule, PluginControlDocumentError, readPluginControlDocument, replacePluginControlDocument } from "./plugin-control-document"
|
||||
import {
|
||||
createWslPluginControlDocumentFileSystem,
|
||||
type WslPluginControlExecution,
|
||||
type WslPluginControlExecutor,
|
||||
} from "./plugin-control-document-wsl"
|
||||
|
||||
describe("WSL plugin control documents", () => {
|
||||
it("keeps native paths and source permissions in WSL operations", async () => {
|
||||
const calls: Array<{ script: string; args: readonly string[]; input?: Buffer }> = []
|
||||
const execute: WslPluginControlExecutor = async (_distro, script, args, input) => {
|
||||
calls.push({ script: firstLine(script), args, ...(input ? { input } : {}) })
|
||||
if (script.includes("# read-document")) {
|
||||
return success(Buffer.concat([
|
||||
Buffer.from(["file", "/home/dev/.config/opencode/opencode.jsonc", "600", ""].join("\0")),
|
||||
Buffer.from(`{ "plugins": [] }\n`),
|
||||
]))
|
||||
}
|
||||
if (script.includes("# inspect-document")) {
|
||||
return success(Buffer.from("file\0/home/dev/.config/opencode/opencode.jsonc\0"))
|
||||
}
|
||||
return success()
|
||||
}
|
||||
const fileSystem = createWslPluginControlDocumentFileSystem("Ubuntu", execute)
|
||||
const document = await readPluginControlDocument("/home/dev/.config/opencode/opencode.jsonc", fileSystem)
|
||||
|
||||
assert.equal(document.mode, 0o600)
|
||||
await replacePluginControlDocument(document, appendPluginControlRule(document, "-acme"), undefined, fileSystem)
|
||||
|
||||
const prepare = calls.find((call) => call.script === "# prepare-document")
|
||||
assert.equal(prepare?.args[1], "/home/dev/.config/opencode/opencode.jsonc")
|
||||
assert.equal(prepare?.args[5], "600")
|
||||
assert.equal(prepare?.args[6], "600")
|
||||
assert.match(prepare?.args[8] ?? "", /^[a-f0-9]{64}$/)
|
||||
assert.match(prepare?.args[4] ?? "", /^[a-f0-9]{32}$/)
|
||||
const commit = calls.find((call) => call.script === "# commit-document")
|
||||
assert.equal(commit?.args[0], "/home/dev/.config/opencode/opencode.jsonc")
|
||||
assert.equal(commit?.args[6], "600")
|
||||
assert.equal(commit?.args[7], "600")
|
||||
assert.ok(calls.every((call) => call.args.every((value) => !value.startsWith("\\\\wsl"))))
|
||||
})
|
||||
|
||||
it("maps WSL lock contention and external changes to conflicts without cleanup leaks", async () => {
|
||||
const document = {
|
||||
requestedPath: "/home/dev/.config/opencode/opencode.jsonc",
|
||||
writePath: "/home/dev/.config/opencode/opencode.jsonc",
|
||||
exists: true,
|
||||
mode: 0o600,
|
||||
text: `{ "plugins": [] }`,
|
||||
byteOrderMark: false,
|
||||
plugins: [],
|
||||
} as const
|
||||
for (const scriptName of ["# prepare-document", "# commit-document"] as const) {
|
||||
for (const status of [73, 75]) {
|
||||
const calls: string[] = []
|
||||
const execute: WslPluginControlExecutor = async (_distro, script) => {
|
||||
calls.push(firstLine(script))
|
||||
if (firstLine(script) === scriptName) return { status, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) }
|
||||
return success()
|
||||
}
|
||||
const fileSystem = createWslPluginControlDocumentFileSystem("Ubuntu", execute)
|
||||
await assert.rejects(
|
||||
replacePluginControlDocument(document, `{ "plugins": ["-acme"] }\n`, undefined, fileSystem),
|
||||
(error: unknown) => error instanceof PluginControlDocumentError && error.kind === "conflict",
|
||||
)
|
||||
assert.ok(calls.includes(scriptName))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("passes the owner nonce to WSL cleanup so a stalled owner cannot reap a successor", async () => {
|
||||
const cleanups: Array<readonly string[]> = []
|
||||
const prepares: Array<readonly string[]> = []
|
||||
const execute: WslPluginControlExecutor = async (_distro, script, args) => {
|
||||
if (firstLine(script) === "# cleanup-document") cleanups.push(args)
|
||||
if (firstLine(script) === "# prepare-document") prepares.push(args)
|
||||
if (script.includes("# read-document")) {
|
||||
return success(Buffer.concat([
|
||||
Buffer.from(["file", "/home/dev/.config/opencode/opencode.jsonc", "600", ""].join("\0")),
|
||||
Buffer.from(`{ "plugins": [] }\n`),
|
||||
]))
|
||||
}
|
||||
if (script.includes("# inspect-document")) {
|
||||
return success(Buffer.from("file\0/home/dev/.config/opencode/opencode.jsonc\0"))
|
||||
}
|
||||
return success()
|
||||
}
|
||||
const fileSystem = createWslPluginControlDocumentFileSystem("Ubuntu", execute)
|
||||
const document = await readPluginControlDocument("/home/dev/.config/opencode/opencode.jsonc", fileSystem)
|
||||
await replacePluginControlDocument(document, appendPluginControlRule(document, "-acme"), undefined, fileSystem)
|
||||
|
||||
assert.equal(cleanups.length, 0, "successful commits clean up inside the commit script")
|
||||
assert.match(prepares[0]?.[4] ?? "", /^[a-f0-9]{32}$/)
|
||||
})
|
||||
|
||||
it("sends the owner nonce to WSL cleanup when authorization fails after prepare", async () => {
|
||||
const cleanups: Array<readonly string[]> = []
|
||||
const prepares: Array<readonly string[]> = []
|
||||
const execute: WslPluginControlExecutor = async (_distro, script, args) => {
|
||||
if (firstLine(script) === "# cleanup-document") cleanups.push(args)
|
||||
if (firstLine(script) === "# prepare-document") prepares.push(args)
|
||||
if (script.includes("# read-document")) {
|
||||
return success(Buffer.concat([
|
||||
Buffer.from(["file", "/home/dev/.config/opencode/opencode.jsonc", "600", ""].join("\0")),
|
||||
Buffer.from(`{ "plugins": [] }\n`),
|
||||
]))
|
||||
}
|
||||
return success()
|
||||
}
|
||||
const fileSystem = createWslPluginControlDocumentFileSystem("Ubuntu", execute)
|
||||
const document = await readPluginControlDocument("/home/dev/.config/opencode/opencode.jsonc", fileSystem)
|
||||
|
||||
await assert.rejects(
|
||||
replacePluginControlDocument(document, appendPluginControlRule(document, "-acme"), {
|
||||
beforeCommit: () => { throw new Error("stale connection") },
|
||||
}, fileSystem),
|
||||
(error: unknown) => error instanceof PluginControlDocumentError && error.kind === "filesystem",
|
||||
)
|
||||
|
||||
assert.equal(cleanups.length, 1)
|
||||
assert.equal(cleanups[0]?.[1], prepares[0]?.[3])
|
||||
assert.equal(cleanups[0]?.[2], prepares[0]?.[4])
|
||||
})
|
||||
|
||||
const distro = process.env.CODENOMAD_TEST_WSL_PLUGIN_CONTROLS
|
||||
it("preserves a native 0600 mode through a real WSL replacement", { skip: !distro }, async () => {
|
||||
const root = `/tmp/codenomad-plugin-controls-${randomBytes(8).toString("hex")}`
|
||||
const target = `${root}/opencode.jsonc`
|
||||
try {
|
||||
runWsl(distro!, `mkdir -m 700 -- "$1" && printf '%s\\n' '{ "plugins": [] }' > "$2" && chmod 600 -- "$2"`, root, target)
|
||||
const fileSystem = createWslPluginControlDocumentFileSystem(distro!)
|
||||
const document = await readPluginControlDocument(target, fileSystem)
|
||||
assert.equal(document.mode, 0o600)
|
||||
|
||||
await replacePluginControlDocument(document, appendPluginControlRule(document, "-acme"), undefined, fileSystem)
|
||||
|
||||
assert.equal(runWsl(distro!, `stat -c '%a' -- "$1"`, target).trim(), "600")
|
||||
assert.match(runWsl(distro!, `cat -- "$1"`, target), /"-acme"/)
|
||||
} finally {
|
||||
runWsl(distro!, `rm -rf -- "$1"`, root)
|
||||
}
|
||||
})
|
||||
|
||||
it("fails closed for a WSL symlink loop instead of reporting missing", { skip: !distro }, async () => {
|
||||
const root = `/tmp/codenomad-plugin-controls-${randomBytes(8).toString("hex")}`
|
||||
try {
|
||||
runWsl(distro!, `mkdir -p -- "$1" && ln -s loop -- "$1/loop"`, root)
|
||||
const fileSystem = createWslPluginControlDocumentFileSystem(distro!)
|
||||
await assert.rejects(
|
||||
readPluginControlDocument(`${root}/loop`, fileSystem),
|
||||
(error: unknown) => error instanceof PluginControlDocumentError && error.kind === "filesystem",
|
||||
)
|
||||
} finally {
|
||||
runWsl(distro!, `rm -rf -- "$1"`, root)
|
||||
}
|
||||
})
|
||||
|
||||
it("rejects a mode-only external change in WSL as a conflict", { skip: !distro }, async () => {
|
||||
const root = `/tmp/codenomad-plugin-controls-${randomBytes(8).toString("hex")}`
|
||||
const target = `${root}/opencode.jsonc`
|
||||
try {
|
||||
runWsl(distro!, `mkdir -m 700 -- "$1" && printf '%s\\n' '{ "plugins": [] }' > "$2" && chmod 600 -- "$2"`, root, target)
|
||||
const fileSystem = createWslPluginControlDocumentFileSystem(distro!)
|
||||
const document = await readPluginControlDocument(target, fileSystem)
|
||||
runWsl(distro!, `chmod 644 -- "$1"`, target)
|
||||
|
||||
await assert.rejects(
|
||||
replacePluginControlDocument(document, appendPluginControlRule(document, "-acme"), undefined, fileSystem),
|
||||
(error: unknown) => error instanceof PluginControlDocumentError && error.kind === "conflict",
|
||||
)
|
||||
assert.equal(runWsl(distro!, `stat -c '%a' -- "$1"`, target).trim(), "644")
|
||||
} finally {
|
||||
runWsl(distro!, `rm -rf -- "$1"`, root)
|
||||
}
|
||||
})
|
||||
|
||||
it("fails closed when the target becomes a directory before the WSL rename", { skip: !distro }, async () => {
|
||||
const root = `/tmp/codenomad-plugin-controls-${randomBytes(8).toString("hex")}`
|
||||
const target = `${root}/opencode.jsonc`
|
||||
try {
|
||||
runWsl(distro!, `mkdir -m 700 -- "$1" && printf '%s\\n' '{ "plugins": [] }' > "$2" && chmod 600 -- "$2"`, root, target)
|
||||
const fileSystem = createWslPluginControlDocumentFileSystem(distro!)
|
||||
const document = await readPluginControlDocument(target, fileSystem)
|
||||
|
||||
await assert.rejects(
|
||||
replacePluginControlDocument(document, appendPluginControlRule(document, "-acme"), {
|
||||
beforeCommit: () => runWsl(distro!, `rm -- "$1" && mkdir -- "$1"`, target),
|
||||
}, fileSystem),
|
||||
(error: unknown) => error instanceof PluginControlDocumentError && error.kind === "conflict",
|
||||
)
|
||||
assert.equal(runWsl(distro!, `test -d "$1" && echo dir || echo other`, target).trim(), "dir")
|
||||
assert.equal(runWsl(distro!, `ls -A "$1" | wc -l`, target).trim(), "0")
|
||||
} finally {
|
||||
runWsl(distro!, `rm -rf -- "$1"`, root)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function firstLine(script: string): string {
|
||||
return script.slice(0, script.indexOf("\n"))
|
||||
}
|
||||
|
||||
function success(stdout = Buffer.alloc(0)): WslPluginControlExecution {
|
||||
return { status: 0, stdout, stderr: Buffer.alloc(0) }
|
||||
}
|
||||
|
||||
function runWsl(distro: string, script: string, ...args: string[]): string {
|
||||
return execFileSync(
|
||||
"wsl.exe",
|
||||
["--distribution", distro, "--exec", "sh", "-c", script, "codenomad-test", ...args],
|
||||
{ encoding: "utf8", windowsHide: true },
|
||||
)
|
||||
}
|
||||
354
packages/server/src/opencode/plugin-control-document-wsl.ts
Normal file
354
packages/server/src/opencode/plugin-control-document-wsl.ts
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
import { execFile } from "node:child_process"
|
||||
import { createHash, randomBytes } from "node:crypto"
|
||||
import path from "node:path"
|
||||
import {
|
||||
PLUGIN_CONTROL_MAX_CONFIG_BYTES,
|
||||
PluginControlDocumentError,
|
||||
encodePluginControlOriginal,
|
||||
type PluginControlDocument,
|
||||
type PluginControlDocumentFileSystem,
|
||||
type PluginControlDocumentLoad,
|
||||
type PluginControlDocumentReplaceOptions,
|
||||
} from "./plugin-control-document"
|
||||
|
||||
const COMMAND_TIMEOUT_MS = 10_000
|
||||
const LOCK_WAIT_MS = 2_000
|
||||
const LOCK_STALE_SECONDS = 30
|
||||
const OUTPUT_ALLOWANCE = 64 * 1024
|
||||
|
||||
const INSPECT_SCRIPT = String.raw`# inspect-document
|
||||
set -eu
|
||||
resolve_one() {
|
||||
realpath -m -- "$1" 2>/dev/null || realpath -- "$1" 2>/dev/null || exit 66
|
||||
}
|
||||
for requested do
|
||||
target=$(resolve_one "$requested")
|
||||
if [ -L "$target" ]; then exit 66; fi
|
||||
if [ -e "$target" ]; then
|
||||
[ -f "$target" ] || exit 66
|
||||
printf 'file\0%s\0' "$target"
|
||||
else
|
||||
printf 'missing\0%s\0' "$target"
|
||||
fi
|
||||
done
|
||||
`
|
||||
|
||||
const READ_SCRIPT = String.raw`# read-document
|
||||
set -eu
|
||||
resolve_one() {
|
||||
realpath -m -- "$1" 2>/dev/null || realpath -- "$1" 2>/dev/null || exit 66
|
||||
}
|
||||
target=$(resolve_one "$1")
|
||||
if [ -L "$target" ]; then exit 66; fi
|
||||
maximum=$2
|
||||
if [ ! -e "$target" ]; then
|
||||
printf 'missing\0%s\0\0' "$target"
|
||||
exit 0
|
||||
fi
|
||||
[ -f "$target" ] || exit 66
|
||||
size=$(stat -c '%s' -- "$target" 2>/dev/null || stat -f '%z' -- "$target" 2>/dev/null || exit 66)
|
||||
[ "$size" -le "$maximum" ] || exit 77
|
||||
mode=$(stat -c '%a' -- "$target" 2>/dev/null || stat -f '%p' -- "$target" 2>/dev/null || exit 66)
|
||||
printf 'file\0%s\0%s\0' "$target" "$mode"
|
||||
cat -- "$target"
|
||||
`
|
||||
|
||||
const SHELL_LIB = String.raw`
|
||||
hash_of() {
|
||||
result=$(sha256sum -- "$1" 2>/dev/null | awk '{print $1}') && [ -n "$result" ] && printf '%s' "$result" && return 0
|
||||
result=$(shasum -a 256 -- "$1" 2>/dev/null | awk '{print $1}') && [ -n "$result" ] && printf '%s' "$result" && return 0
|
||||
return 1
|
||||
}
|
||||
mode_of() {
|
||||
stat -c '%a' -- "$1" 2>/dev/null || stat -f '%p' -- "$1" 2>/dev/null || return 1
|
||||
}
|
||||
`
|
||||
|
||||
const PREPARE_SCRIPT = String.raw`# prepare-document
|
||||
set -eu
|
||||
${SHELL_LIB}directory=$1
|
||||
target=$2
|
||||
temporary=$3
|
||||
lock=$4
|
||||
nonce=$5
|
||||
mode=$6
|
||||
expectedMode=$7
|
||||
existed=$8
|
||||
expected=$9
|
||||
stale=${10}
|
||||
deadlineMs=${11}
|
||||
locked=0
|
||||
cleanup() {
|
||||
rm -f -- "$temporary"
|
||||
if [ "$locked" = 1 ]; then
|
||||
current=$(cat -- "$lock/owner" 2>/dev/null || true)
|
||||
if [ "$current" = "$nonce" ]; then rm -rf -- "$lock" 2>/dev/null || true; fi
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
mkdir -p -- "$directory"
|
||||
umask 077
|
||||
( set -C; : > "$temporary" ) 2>/dev/null || exit 76
|
||||
chmod "$mode" "$temporary"
|
||||
cat > "$temporary"
|
||||
chmod "$mode" "$temporary"
|
||||
sync -d "$temporary" 2>/dev/null || sync 2>/dev/null || true
|
||||
deadline=$(( $(date +%s) + (deadlineMs + 999) / 1000 ))
|
||||
while ! mkdir -m 700 -- "$lock" 2>/dev/null; do
|
||||
now=$(date +%s)
|
||||
modified=$(stat -c '%Y' -- "$lock" 2>/dev/null || stat -f '%m' -- "$lock" 2>/dev/null || printf '%s' "$now")
|
||||
if [ $((now - modified)) -gt "$stale" ]; then rm -rf -- "$lock" 2>/dev/null || true; fi
|
||||
[ "$(date +%s)" -lt "$deadline" ] || exit 75
|
||||
sleep 0.02 2>/dev/null || sleep 0 2>/dev/null || true
|
||||
done
|
||||
printf '%s' "$nonce" > "$lock/owner"
|
||||
chmod 600 -- "$lock/owner"
|
||||
locked=1
|
||||
if [ "$existed" = 1 ]; then
|
||||
[ -f "$target" ] || exit 73
|
||||
actual=$(hash_of "$target") || exit 66
|
||||
[ "$actual" = "$expected" ] || exit 73
|
||||
currentMode=$(mode_of "$target") || exit 66
|
||||
[ "$currentMode" = "$expectedMode" ] || exit 73
|
||||
else
|
||||
[ ! -e "$target" ] || exit 73
|
||||
fi
|
||||
trap - EXIT
|
||||
`
|
||||
|
||||
const COMMIT_SCRIPT = String.raw`# commit-document
|
||||
set -eu
|
||||
${SHELL_LIB}target=$1
|
||||
existed=$2
|
||||
expected=$3
|
||||
temporary=$4
|
||||
lock=$5
|
||||
nonce=$6
|
||||
mode=$7
|
||||
expectedMode=$8
|
||||
cleanup() {
|
||||
rm -f -- "$temporary"
|
||||
current=$(cat -- "$lock/owner" 2>/dev/null || true)
|
||||
if [ "$current" = "$nonce" ]; then rm -rf -- "$lock" 2>/dev/null || true; fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
[ -d "$lock" ] || exit 75
|
||||
current=$(cat -- "$lock/owner" 2>/dev/null || true)
|
||||
[ "$current" = "$nonce" ] || exit 75
|
||||
if [ "$existed" = 1 ]; then
|
||||
[ -f "$target" ] || exit 73
|
||||
actual=$(hash_of "$target") || exit 66
|
||||
[ "$actual" = "$expected" ] || exit 73
|
||||
currentMode=$(mode_of "$target") || exit 66
|
||||
[ "$currentMode" = "$expectedMode" ] || exit 73
|
||||
else
|
||||
[ ! -e "$target" ] || exit 73
|
||||
fi
|
||||
mv -T -f -- "$temporary" "$target" 2>/dev/null || {
|
||||
if [ -d "$target" ]; then exit 73; fi
|
||||
mv -f -- "$temporary" "$target"
|
||||
}
|
||||
if [ ! -f "$target" ] || [ -e "$temporary" ]; then
|
||||
rm -f -- "$target/$(basename -- "$temporary")" 2>/dev/null || true
|
||||
rm -f -- "$temporary" 2>/dev/null || true
|
||||
exit 73
|
||||
fi
|
||||
sync -d "$target" 2>/dev/null || sync 2>/dev/null || true
|
||||
`
|
||||
|
||||
const CLEANUP_SCRIPT = String.raw`# cleanup-document
|
||||
set -eu
|
||||
temporary=$1
|
||||
lock=$2
|
||||
nonce=$3
|
||||
rm -f -- "$temporary"
|
||||
current=$(cat -- "$lock/owner" 2>/dev/null || true)
|
||||
if [ "$current" = "$nonce" ]; then rm -rf -- "$lock" 2>/dev/null || true; fi
|
||||
`
|
||||
|
||||
export interface WslPluginControlExecution {
|
||||
status: number | null
|
||||
stdout: Buffer
|
||||
stderr: Buffer
|
||||
error?: Error
|
||||
}
|
||||
|
||||
export type WslPluginControlExecutor = (
|
||||
distro: string,
|
||||
script: string,
|
||||
args: readonly string[],
|
||||
input?: Buffer,
|
||||
maxBuffer?: number,
|
||||
) => Promise<WslPluginControlExecution>
|
||||
|
||||
export function createWslPluginControlDocumentFileSystem(
|
||||
distro: string,
|
||||
execute: WslPluginControlExecutor = executeWslScript,
|
||||
): PluginControlDocumentFileSystem {
|
||||
const run = (script: string, args: readonly string[], input?: Buffer, maxBuffer?: number) => (
|
||||
execute(distro, script, args, input, maxBuffer)
|
||||
)
|
||||
|
||||
const load = async (requestedPath: string): Promise<PluginControlDocumentLoad> => {
|
||||
assertNativePath(requestedPath)
|
||||
const result = await run(
|
||||
READ_SCRIPT,
|
||||
[requestedPath, String(PLUGIN_CONTROL_MAX_CONFIG_BYTES)],
|
||||
undefined,
|
||||
PLUGIN_CONTROL_MAX_CONFIG_BYTES + OUTPUT_ALLOWANCE,
|
||||
)
|
||||
if (result.status === 77) throw new PluginControlDocumentError("OpenCode configuration file is too large", "invalid")
|
||||
assertSuccess(result, "Unable to read OpenCode configuration in WSL")
|
||||
const [status, resolved, mode, contents] = splitNullFields(result.stdout, 3, true)
|
||||
const writePath = resolved.toString("utf8")
|
||||
assertNativePath(writePath)
|
||||
if (status.equals(Buffer.from("missing"))) {
|
||||
return { writePath, exists: false, mode: 0o600, contents: Buffer.from("{}\n") }
|
||||
}
|
||||
if (!status.equals(Buffer.from("file"))) throw filesystemError("Invalid WSL configuration response")
|
||||
const parsedMode = Number.parseInt(mode.toString("ascii"), 8)
|
||||
if (!Number.isInteger(parsedMode) || parsedMode < 0 || parsedMode > 0o777) {
|
||||
throw filesystemError("Invalid WSL configuration permissions")
|
||||
}
|
||||
if (contents.length > PLUGIN_CONTROL_MAX_CONFIG_BYTES) {
|
||||
throw new PluginControlDocumentError("OpenCode configuration file is too large", "invalid")
|
||||
}
|
||||
return { writePath, exists: true, mode: parsedMode, contents }
|
||||
}
|
||||
|
||||
const inspectMany = async (requestedPaths: readonly string[]) => {
|
||||
requestedPaths.forEach(assertNativePath)
|
||||
const result = await run(INSPECT_SCRIPT, requestedPaths)
|
||||
assertSuccess(result, "Unable to inspect OpenCode configuration in WSL")
|
||||
const fields = splitNullFields(result.stdout, requestedPaths.length * 2)
|
||||
return requestedPaths.map((_, index) => {
|
||||
const status = fields[index * 2]!
|
||||
const writePath = fields[index * 2 + 1]!.toString("utf8")
|
||||
assertNativePath(writePath)
|
||||
if (!status.equals(Buffer.from("file")) && !status.equals(Buffer.from("missing"))) {
|
||||
throw filesystemError("Invalid WSL configuration response")
|
||||
}
|
||||
return { writePath, exists: status.equals(Buffer.from("file")) }
|
||||
})
|
||||
}
|
||||
|
||||
const replace = async (
|
||||
document: PluginControlDocument,
|
||||
updated: string,
|
||||
options?: PluginControlDocumentReplaceOptions,
|
||||
) => {
|
||||
assertNativePath(document.writePath)
|
||||
const directory = path.posix.dirname(document.writePath)
|
||||
const suffix = `${process.pid}.${randomBytes(8).toString("hex")}`
|
||||
const temporary = `${document.writePath}.${suffix}.tmp`
|
||||
const lock = `${document.writePath}.codenomad-plugin-controls.lock`
|
||||
const nonce = randomBytes(16).toString("hex")
|
||||
const mode = document.mode.toString(8).padStart(3, "0")
|
||||
const expected = createHash("sha256").update(encodePluginControlOriginal(document)).digest("hex")
|
||||
let locked = false
|
||||
try {
|
||||
const prepared = await run(PREPARE_SCRIPT, [
|
||||
directory,
|
||||
document.writePath,
|
||||
temporary,
|
||||
lock,
|
||||
nonce,
|
||||
mode,
|
||||
mode,
|
||||
document.exists ? "1" : "0",
|
||||
expected,
|
||||
String(LOCK_STALE_SECONDS),
|
||||
String(LOCK_WAIT_MS),
|
||||
], Buffer.from(updated, "utf8"))
|
||||
if (prepared.status === 73) throw changedError()
|
||||
if (prepared.status === 75) {
|
||||
throw new PluginControlDocumentError("OpenCode configuration is being updated by another process", "conflict")
|
||||
}
|
||||
assertSuccess(prepared, "Unable to prepare OpenCode configuration in WSL")
|
||||
locked = true
|
||||
try {
|
||||
options?.beforeCommit?.()
|
||||
} catch (error) {
|
||||
throw new PluginControlDocumentError("OpenCode configuration replacement is no longer authorized", "filesystem", { cause: error })
|
||||
}
|
||||
const committed = await run(COMMIT_SCRIPT, [
|
||||
document.writePath,
|
||||
document.exists ? "1" : "0",
|
||||
expected,
|
||||
temporary,
|
||||
lock,
|
||||
nonce,
|
||||
mode,
|
||||
mode,
|
||||
])
|
||||
if (committed.status !== null) locked = false
|
||||
if (committed.status === 73) throw changedError()
|
||||
if (committed.status === 75) {
|
||||
throw new PluginControlDocumentError("OpenCode configuration lock was lost before the atomic rename", "conflict")
|
||||
}
|
||||
assertSuccess(committed, "Unable to atomically replace OpenCode configuration in WSL")
|
||||
} finally {
|
||||
if (locked) await run(CLEANUP_SCRIPT, [temporary, lock, nonce]).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
return { inspectMany, load, replace }
|
||||
}
|
||||
|
||||
function splitNullFields(buffer: Buffer, count: number, includeRemainder = false): Buffer[] {
|
||||
const fields: Buffer[] = []
|
||||
let start = 0
|
||||
for (let index = 0; index < count; index++) {
|
||||
const end = buffer.indexOf(0, start)
|
||||
if (end < 0) throw filesystemError("Invalid WSL configuration response")
|
||||
fields.push(buffer.subarray(start, end))
|
||||
start = end + 1
|
||||
}
|
||||
if (includeRemainder) fields.push(buffer.subarray(start))
|
||||
return fields
|
||||
}
|
||||
|
||||
function assertSuccess(result: WslPluginControlExecution, message: string): void {
|
||||
if (result.status === 0 && !result.error) return
|
||||
throw filesystemError(message, result.error ?? new Error(result.stderr.toString("utf8").trim() || `exit ${result.status}`))
|
||||
}
|
||||
|
||||
function assertNativePath(value: string): void {
|
||||
if (!path.posix.isAbsolute(value) || value.startsWith("//") || /[\\\x00-\x1f\x7f]/.test(value)) {
|
||||
throw filesystemError("Invalid native WSL configuration path")
|
||||
}
|
||||
}
|
||||
|
||||
function changedError(): PluginControlDocumentError {
|
||||
return new PluginControlDocumentError("OpenCode configuration changed during the update", "conflict")
|
||||
}
|
||||
|
||||
function filesystemError(message: string, cause?: unknown): PluginControlDocumentError {
|
||||
return new PluginControlDocumentError(message, "filesystem", cause === undefined ? undefined : { cause })
|
||||
}
|
||||
|
||||
function executeWslScript(
|
||||
distro: string,
|
||||
script: string,
|
||||
args: readonly string[],
|
||||
input?: Buffer,
|
||||
maxBuffer = OUTPUT_ALLOWANCE,
|
||||
): Promise<WslPluginControlExecution> {
|
||||
return new Promise((resolve) => {
|
||||
const child = execFile(
|
||||
"wsl.exe",
|
||||
["--distribution", distro, "--exec", "sh", "-c", script, "codenomad-plugin-controls", ...args],
|
||||
{ encoding: null, windowsHide: true, timeout: COMMAND_TIMEOUT_MS, maxBuffer },
|
||||
(error, stdout, stderr) => {
|
||||
const code = error && "code" in error && typeof error.code === "number" ? error.code : error ? null : 0
|
||||
resolve({
|
||||
status: code,
|
||||
stdout: Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout ?? ""),
|
||||
stderr: Buffer.isBuffer(stderr) ? stderr : Buffer.from(stderr ?? ""),
|
||||
...(error ? { error } : {}),
|
||||
})
|
||||
},
|
||||
)
|
||||
child.stdin?.end(input)
|
||||
})
|
||||
}
|
||||
273
packages/server/src/opencode/plugin-control-document.test.ts
Normal file
273
packages/server/src/opencode/plugin-control-document.test.ts
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
import assert from "node:assert/strict"
|
||||
import fs from "node:fs"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { afterEach, describe, it } from "node:test"
|
||||
import { parse } from "jsonc-parser"
|
||||
import {
|
||||
appendPluginControlRule,
|
||||
PluginControlDocumentError,
|
||||
readPluginControlDocument,
|
||||
replacePluginControlDocument,
|
||||
} from "./plugin-control-document"
|
||||
|
||||
const temporaryDirectories = new Set<string>()
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of temporaryDirectories) fs.rmSync(directory, { recursive: true, force: true })
|
||||
temporaryDirectories.clear()
|
||||
})
|
||||
|
||||
describe("plugin control JSONC document", () => {
|
||||
it("appends an ordered rule without replacing comments, sources, options, or unrelated keys", async () => {
|
||||
const target = temporaryFile()
|
||||
const original = `{
|
||||
// retained top-level comment
|
||||
"plugins": [
|
||||
{
|
||||
"package": "@acme/reviewer",
|
||||
"options": { "strict": true, "agent": "reviewer" },
|
||||
"futureKey": { "retained": true }
|
||||
}, // retained source comment
|
||||
"-opencode.provider.*",
|
||||
],
|
||||
"experimental": { "custom": true },
|
||||
}
|
||||
`
|
||||
fs.writeFileSync(target, original)
|
||||
|
||||
const document = await readPluginControlDocument(target)
|
||||
const updated = appendPluginControlRule(document, "opencode.provider.openai")
|
||||
await replacePluginControlDocument(document, updated)
|
||||
|
||||
const written = fs.readFileSync(target, "utf8")
|
||||
assert.match(written, /retained top-level comment/)
|
||||
assert.match(written, /retained source comment/)
|
||||
assert.match(written, /"options": \{ "strict": true, "agent": "reviewer" \}/)
|
||||
assert.match(written, /"futureKey": \{ "retained": true \}/)
|
||||
assert.match(written, /"experimental": \{ "custom": true \}/)
|
||||
assert.deepEqual((parse(written) as any).plugins, [
|
||||
{ package: "@acme/reviewer", options: { strict: true, agent: "reviewer" }, futureKey: { retained: true } },
|
||||
"-opencode.provider.*",
|
||||
"opencode.provider.openai",
|
||||
])
|
||||
})
|
||||
|
||||
it("creates a minimal JSONC document for a missing target", async () => {
|
||||
const target = path.join(temporaryDirectory(), "nested", "opencode.jsonc")
|
||||
const document = await readPluginControlDocument(target)
|
||||
assert.equal(document.exists, false)
|
||||
|
||||
await replacePluginControlDocument(document, appendPluginControlRule(document, "-acme.reviewer"))
|
||||
|
||||
assert.deepEqual(parse(fs.readFileSync(target, "utf8")), { plugins: ["-acme.reviewer"] })
|
||||
})
|
||||
|
||||
it("retains a UTF-8 BOM, CRLF endings, and tab indentation", async () => {
|
||||
const target = temporaryFile()
|
||||
const original = `\uFEFF{\r\n\t"plugins": [\r\n\t\t"acme",\r\n\t],\r\n}\r\n`
|
||||
fs.writeFileSync(target, original, "utf8")
|
||||
|
||||
const document = await readPluginControlDocument(target)
|
||||
await replacePluginControlDocument(document, appendPluginControlRule(document, "-acme"))
|
||||
|
||||
const written = fs.readFileSync(target)
|
||||
assert.deepEqual([...written.subarray(0, 3)], [0xef, 0xbb, 0xbf])
|
||||
const text = written.toString("utf8")
|
||||
assert.equal(text.replaceAll("\r\n", "").includes("\n"), false)
|
||||
assert.match(text, /\r\n\t\t"-acme"/)
|
||||
assert.deepEqual((parse(text.slice(1)) as any).plugins, ["acme", "-acme"])
|
||||
})
|
||||
|
||||
it("never reads a configuration document beyond the 4 MiB limit", async () => {
|
||||
const target = temporaryFile()
|
||||
fs.writeFileSync(target, Buffer.alloc(4 * 1024 * 1024 + 1, 0x20))
|
||||
|
||||
await assert.rejects(readPluginControlDocument(target), (error: unknown) => (
|
||||
error instanceof PluginControlDocumentError && error.kind === "invalid"
|
||||
))
|
||||
})
|
||||
|
||||
it("fails closed for malformed JSONC, duplicate plugin keys, and malformed plugin options", async () => {
|
||||
for (const contents of [
|
||||
`{ "plugins": ["acme", } broken }`,
|
||||
`{ "plugins": ["acme"], "plugins": ["other"] }`,
|
||||
`{ "plugins": [{ "package": "acme", "options": true }] }`,
|
||||
]) {
|
||||
const target = temporaryFile()
|
||||
fs.writeFileSync(target, contents)
|
||||
await assert.rejects(readPluginControlDocument(target), (error: unknown) => (
|
||||
error instanceof PluginControlDocumentError && error.kind === "invalid"
|
||||
))
|
||||
assert.equal(fs.readFileSync(target, "utf8"), contents)
|
||||
}
|
||||
})
|
||||
|
||||
it("keeps an external replacement intact when the source changes before commit", async () => {
|
||||
const target = temporaryFile()
|
||||
fs.writeFileSync(target, `{ "plugins": ["acme"] }\n`)
|
||||
const document = await readPluginControlDocument(target)
|
||||
const updated = appendPluginControlRule(document, "-acme")
|
||||
fs.writeFileSync(target, `{ "plugins": ["external"] }\n`)
|
||||
|
||||
await assert.rejects(replacePluginControlDocument(document, updated), (error: unknown) => (
|
||||
error instanceof PluginControlDocumentError && error.kind === "conflict"
|
||||
))
|
||||
assert.equal(fs.readFileSync(target, "utf8"), `{ "plugins": ["external"] }\n`)
|
||||
assert.equal(fs.readdirSync(path.dirname(target)).some((entry) => entry.endsWith(".tmp")), false)
|
||||
})
|
||||
|
||||
it("rechecks authorization at the atomic commit point", async () => {
|
||||
const target = temporaryFile()
|
||||
const original = `{ "plugins": ["acme"] }\n`
|
||||
fs.writeFileSync(target, original)
|
||||
const document = await readPluginControlDocument(target)
|
||||
let checked = false
|
||||
|
||||
await assert.rejects(
|
||||
replacePluginControlDocument(document, appendPluginControlRule(document, "-acme"), {
|
||||
beforeCommit: () => {
|
||||
checked = true
|
||||
throw new Error("stale connection")
|
||||
},
|
||||
}),
|
||||
(error: unknown) => error instanceof PluginControlDocumentError && error.kind === "filesystem",
|
||||
)
|
||||
|
||||
assert.equal(checked, true)
|
||||
assert.equal(fs.readFileSync(target, "utf8"), original)
|
||||
assert.equal(fs.readdirSync(path.dirname(target)).some((entry) => entry.endsWith(".tmp")), false)
|
||||
assert.equal(fs.readdirSync(path.dirname(target)).some((entry) => entry.includes("codenomad-plugin-controls.lock")), false)
|
||||
})
|
||||
|
||||
it("rejects an external write made after the first conflict check", async () => {
|
||||
const target = temporaryFile()
|
||||
const original = `{ "plugins": ["acme"] }\n`
|
||||
const external = `{ "plugins": ["external"] }\n`
|
||||
fs.writeFileSync(target, original)
|
||||
const document = await readPluginControlDocument(target)
|
||||
|
||||
await assert.rejects(
|
||||
replacePluginControlDocument(document, appendPluginControlRule(document, "-acme"), {
|
||||
beforeCommit: () => fs.writeFileSync(target, external),
|
||||
}),
|
||||
(error: unknown) => error instanceof PluginControlDocumentError && error.kind === "conflict",
|
||||
)
|
||||
|
||||
assert.equal(fs.readFileSync(target, "utf8"), external)
|
||||
assert.equal(fs.readdirSync(path.dirname(target)).some((entry) => entry.includes("codenomad-plugin-controls.lock")), false)
|
||||
})
|
||||
|
||||
it("maps a directory swap before the atomic rename to a conflict", async () => {
|
||||
const target = temporaryFile()
|
||||
fs.writeFileSync(target, `{ "plugins": [] }\n`)
|
||||
const document = await readPluginControlDocument(target)
|
||||
|
||||
await assert.rejects(
|
||||
replacePluginControlDocument(document, appendPluginControlRule(document, "-acme"), {
|
||||
beforeCommit: () => {
|
||||
fs.rmSync(target, { force: true })
|
||||
fs.mkdirSync(target)
|
||||
},
|
||||
}),
|
||||
(error: unknown) => error instanceof PluginControlDocumentError && error.kind === "conflict",
|
||||
)
|
||||
assert.ok(fs.statSync(target).isDirectory())
|
||||
fs.rmSync(target, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("serializes competing backend replacements with a per-target lock", async () => {
|
||||
const target = temporaryFile()
|
||||
fs.writeFileSync(target, `{ "plugins": [] }\n`)
|
||||
const [first, second] = await Promise.all([
|
||||
readPluginControlDocument(target),
|
||||
readPluginControlDocument(target),
|
||||
])
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
replacePluginControlDocument(first, appendPluginControlRule(first, "first")),
|
||||
replacePluginControlDocument(second, appendPluginControlRule(second, "second")),
|
||||
])
|
||||
|
||||
assert.equal(results.filter((result) => result.status === "fulfilled").length, 1)
|
||||
const rejected = results.find((result): result is PromiseRejectedResult => result.status === "rejected")
|
||||
assert.ok(rejected?.reason instanceof PluginControlDocumentError)
|
||||
assert.equal(rejected.reason.kind, "conflict")
|
||||
const plugins = (parse(fs.readFileSync(target, "utf8")) as any).plugins
|
||||
assert.ok(Array.isArray(plugins) && plugins.length === 1 && ["first", "second"].includes(plugins[0]))
|
||||
})
|
||||
|
||||
it("does not delete a successor lock when a stalled owner releases", async () => {
|
||||
const target = temporaryFile()
|
||||
const original = `{ "plugins": [] }\n`
|
||||
fs.writeFileSync(target, original)
|
||||
const document = await readPluginControlDocument(target)
|
||||
const lockPath = `${document.writePath}.codenomad-plugin-controls.lock`
|
||||
const ownerPath = `${lockPath}/owner`
|
||||
|
||||
await assert.rejects(
|
||||
replacePluginControlDocument(document, appendPluginControlRule(document, "first"), {
|
||||
beforeCommit: () => {
|
||||
// Simulate a stalled first owner whose lease expired: a successor
|
||||
// reaps the stale directory and owns it with a different nonce.
|
||||
fs.rmSync(lockPath, { recursive: true, force: true })
|
||||
fs.mkdirSync(lockPath, { mode: 0o700 })
|
||||
fs.writeFileSync(ownerPath, "successor-nonce", { mode: 0o600 })
|
||||
},
|
||||
}),
|
||||
(error: unknown) => error instanceof PluginControlDocumentError && error.kind === "conflict",
|
||||
)
|
||||
|
||||
assert.equal(fs.readFileSync(target, "utf8"), original)
|
||||
assert.equal(fs.readFileSync(ownerPath, "utf8"), "successor-nonce")
|
||||
fs.rmSync(lockPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("retains the source mode when the process umask is restrictive", async () => {
|
||||
const target = temporaryFile()
|
||||
fs.writeFileSync(target, `{ "plugins": [] }\n`)
|
||||
await fs.promises.chmod(target, 0o644).catch(() => undefined)
|
||||
const previousUmask = process.umask(0o077)
|
||||
try {
|
||||
const document = await readPluginControlDocument(target)
|
||||
await replacePluginControlDocument(document, appendPluginControlRule(document, "-acme"))
|
||||
assert.equal(fs.statSync(target).mode & 0o777, document.mode)
|
||||
if (process.platform !== "win32") assert.equal(document.mode, 0o644)
|
||||
} finally {
|
||||
process.umask(previousUmask)
|
||||
}
|
||||
})
|
||||
|
||||
it("rejects a mode-only external change as a conflict", async function () {
|
||||
if (process.platform === "win32") return
|
||||
const target = temporaryFile()
|
||||
fs.writeFileSync(target, `{ "plugins": [] }\n`)
|
||||
await fs.promises.chmod(target, 0o600)
|
||||
const document = await readPluginControlDocument(target)
|
||||
await fs.promises.chmod(target, 0o644)
|
||||
|
||||
await assert.rejects(
|
||||
replacePluginControlDocument(document, appendPluginControlRule(document, "-acme")),
|
||||
(error: unknown) => error instanceof PluginControlDocumentError && error.kind === "conflict",
|
||||
)
|
||||
assert.equal(fs.statSync(target).mode & 0o777, 0o644)
|
||||
})
|
||||
|
||||
it("reports a deep missing path as missing instead of a symlink error", async () => {
|
||||
const root = temporaryDirectory()
|
||||
const deep = Array.from({ length: 20 }, (_, index) => `level-${index}`).join(path.sep)
|
||||
const target = path.join(root, deep, "opencode.jsonc")
|
||||
const document = await readPluginControlDocument(target)
|
||||
assert.equal(document.exists, false)
|
||||
})
|
||||
})
|
||||
|
||||
function temporaryDirectory(): string {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-plugin-controls-"))
|
||||
temporaryDirectories.add(directory)
|
||||
return directory
|
||||
}
|
||||
|
||||
function temporaryFile(): string {
|
||||
return path.join(temporaryDirectory(), "opencode.jsonc")
|
||||
}
|
||||
378
packages/server/src/opencode/plugin-control-document.ts
Normal file
378
packages/server/src/opencode/plugin-control-document.ts
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
import { randomBytes } from "node:crypto"
|
||||
import { chmod, lstat, mkdir, open, readFile, readlink, realpath, rename, rm, writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { applyEdits, modify, parse, parseTree, type FormattingOptions, type Node as JsonNode, type ParseError } from "jsonc-parser"
|
||||
|
||||
export const PLUGIN_CONTROL_MAX_CONFIG_BYTES = 4 * 1024 * 1024
|
||||
const MAX_SYMLINK_DEPTH = 16
|
||||
const REPLACEMENT_LOCK_WAIT_MS = 2_000
|
||||
const REPLACEMENT_LOCK_STALE_MS = 30_000
|
||||
|
||||
export type PluginConfigEntry = string | {
|
||||
package: string
|
||||
options?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export class PluginControlDocumentError extends Error {
|
||||
readonly cause?: unknown
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
readonly kind: "invalid" | "conflict" | "filesystem",
|
||||
options?: { cause?: unknown },
|
||||
) {
|
||||
super(message)
|
||||
this.name = "PluginControlDocumentError"
|
||||
if (options && "cause" in options) this.cause = options.cause
|
||||
}
|
||||
}
|
||||
|
||||
export interface PluginControlDocument {
|
||||
readonly requestedPath: string
|
||||
readonly writePath: string
|
||||
readonly exists: boolean
|
||||
readonly mode: number
|
||||
readonly text: string
|
||||
readonly byteOrderMark: boolean
|
||||
readonly plugins: readonly PluginConfigEntry[]
|
||||
}
|
||||
|
||||
export interface PluginControlDocumentLoad {
|
||||
writePath: string
|
||||
exists: boolean
|
||||
mode: number
|
||||
contents: Buffer
|
||||
}
|
||||
|
||||
export interface PluginControlDocumentReplaceOptions {
|
||||
beforeCommit?: () => void
|
||||
}
|
||||
|
||||
export interface PluginControlDocumentFileSystem {
|
||||
inspectMany(requestedPaths: readonly string[]): Promise<Array<{ writePath: string; exists: boolean }>>
|
||||
load(requestedPath: string): Promise<PluginControlDocumentLoad>
|
||||
replace(document: PluginControlDocument, updated: string, options?: PluginControlDocumentReplaceOptions): Promise<void>
|
||||
}
|
||||
|
||||
export async function readPluginControlDocument(
|
||||
requestedPath: string,
|
||||
fileSystem: PluginControlDocumentFileSystem = hostPluginControlDocumentFileSystem,
|
||||
): Promise<PluginControlDocument> {
|
||||
const { writePath, exists, mode, contents } = await fileSystem.load(requestedPath)
|
||||
|
||||
let decoded: string
|
||||
try {
|
||||
decoded = new TextDecoder("utf-8", { fatal: true }).decode(contents)
|
||||
} catch (error) {
|
||||
throw new PluginControlDocumentError("OpenCode configuration is not valid UTF-8", "invalid", { cause: error })
|
||||
}
|
||||
const byteOrderMark = contents.length >= 3 && contents[0] === 0xef && contents[1] === 0xbb && contents[2] === 0xbf
|
||||
const text = decoded.startsWith("\uFEFF") ? decoded.slice(1) : decoded
|
||||
const errors: ParseError[] = []
|
||||
const value: unknown = parse(text, errors, { allowTrailingComma: true, disallowComments: false })
|
||||
if (errors.length || !isRecord(value)) {
|
||||
throw new PluginControlDocumentError("OpenCode configuration is malformed", "invalid")
|
||||
}
|
||||
assertSinglePluginsProperty(parseTree(text, [], { allowTrailingComma: true, disallowComments: false }))
|
||||
const plugins = value.plugins
|
||||
if (plugins !== undefined && !Array.isArray(plugins)) {
|
||||
throw new PluginControlDocumentError("OpenCode plugins must be an array", "invalid")
|
||||
}
|
||||
const parsed = (plugins ?? []).map(parsePluginEntry)
|
||||
return { requestedPath, writePath, exists, mode, text, byteOrderMark, plugins: parsed }
|
||||
}
|
||||
|
||||
export function appendPluginControlRule(document: PluginControlDocument, rule: string): string {
|
||||
const formattingOptions = inferFormatting(document.text)
|
||||
const editPath = document.plugins.length > 0 || hasPluginsProperty(document.text)
|
||||
? ["plugins", document.plugins.length]
|
||||
: ["plugins"]
|
||||
const value = editPath.length === 1 ? [rule] : rule
|
||||
let updated: string
|
||||
try {
|
||||
updated = applyEdits(document.text, modify(document.text, editPath, value, { formattingOptions }))
|
||||
} catch (error) {
|
||||
throw new PluginControlDocumentError("Unable to update OpenCode plugins without replacing the document", "invalid", { cause: error })
|
||||
}
|
||||
if (!updated.endsWith("\n")) updated += formattingOptions.eol
|
||||
return document.byteOrderMark ? `\uFEFF${updated}` : updated
|
||||
}
|
||||
|
||||
export async function replacePluginControlDocument(
|
||||
document: PluginControlDocument,
|
||||
updated: string,
|
||||
options?: PluginControlDocumentReplaceOptions,
|
||||
fileSystem: PluginControlDocumentFileSystem = hostPluginControlDocumentFileSystem,
|
||||
): Promise<void> {
|
||||
return fileSystem.replace(document, updated, options)
|
||||
}
|
||||
|
||||
export const hostPluginControlDocumentFileSystem: PluginControlDocumentFileSystem = {
|
||||
inspectMany: (requestedPaths) => Promise.all(requestedPaths.map(inspectHostDocument)),
|
||||
load: loadHostDocument,
|
||||
replace: replaceHostDocument,
|
||||
}
|
||||
|
||||
async function inspectHostDocument(requestedPath: string): Promise<{ writePath: string; exists: boolean }> {
|
||||
const writePath = await resolveHostWriteDestination(requestedPath)
|
||||
try {
|
||||
const info = await lstat(writePath)
|
||||
if (!info.isFile()) throw new PluginControlDocumentError("OpenCode configuration target is not a regular file", "filesystem")
|
||||
return { writePath, exists: true }
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { writePath, exists: false }
|
||||
if (error instanceof PluginControlDocumentError) throw error
|
||||
throw new PluginControlDocumentError("Unable to inspect OpenCode configuration", "filesystem", { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHostDocument(requestedPath: string): Promise<PluginControlDocumentLoad> {
|
||||
const writePath = await resolveHostWriteDestination(requestedPath)
|
||||
try {
|
||||
const current = await readBoundedFile(writePath)
|
||||
return { writePath, exists: true, mode: current.mode, contents: current.contents }
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return { writePath, exists: false, mode: 0o600, contents: Buffer.from("{}\n") }
|
||||
}
|
||||
if (error instanceof PluginControlDocumentError) throw error
|
||||
throw new PluginControlDocumentError("Unable to read OpenCode configuration", "filesystem", { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
async function replaceHostDocument(
|
||||
document: PluginControlDocument,
|
||||
updated: string,
|
||||
options?: PluginControlDocumentReplaceOptions,
|
||||
): Promise<void> {
|
||||
const directory = path.dirname(document.writePath)
|
||||
await mkdir(directory, { recursive: true })
|
||||
const temporary = `${document.writePath}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`
|
||||
let created = false
|
||||
let releaseLock: (() => Promise<void>) | undefined
|
||||
try {
|
||||
const file = await open(temporary, "wx", document.mode)
|
||||
created = true
|
||||
try {
|
||||
await file.writeFile(updated, "utf8")
|
||||
await file.sync()
|
||||
} finally {
|
||||
await file.close()
|
||||
}
|
||||
// Node applies the process umask to the creation mode. Retain the source
|
||||
// document mode explicitly so a restrictive umask cannot silently tighten
|
||||
// user files and host behavior matches the WSL backend.
|
||||
await chmod(temporary, document.mode)
|
||||
const lock = await acquireReplacementLock(document.writePath)
|
||||
releaseLock = lock.release
|
||||
await assertUnchanged(document)
|
||||
try {
|
||||
options?.beforeCommit?.()
|
||||
} catch (error) {
|
||||
throw new PluginControlDocumentError("OpenCode configuration replacement is no longer authorized", "filesystem", { cause: error })
|
||||
}
|
||||
// The connection fence may run arbitrary application code in tests and a
|
||||
// non-CodeNomad editor does not honor our lock. Close that final window
|
||||
// before the atomic rename while the cross-profile lock remains held.
|
||||
await assertUnchanged(document)
|
||||
if (!await lock.verify()) {
|
||||
throw new PluginControlDocumentError("OpenCode configuration is being updated by another process", "conflict")
|
||||
}
|
||||
try {
|
||||
await rename(temporary, document.writePath)
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code === "EISDIR" || code === "ENOTDIR" || code === "EEXIST" || code === "ENOTEMPTY") {
|
||||
throw new PluginControlDocumentError("OpenCode configuration changed during the update", "conflict", { cause: error })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
created = false
|
||||
} catch (error) {
|
||||
if (error instanceof PluginControlDocumentError) throw error
|
||||
throw new PluginControlDocumentError("Unable to atomically replace OpenCode configuration", "filesystem", { cause: error })
|
||||
} finally {
|
||||
if (releaseLock) await releaseLock().catch(() => undefined)
|
||||
if (created) await rm(temporary, { force: true }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
async function acquireReplacementLock(writePath: string): Promise<{ release: () => Promise<void>; verify: () => Promise<boolean> }> {
|
||||
const lockPath = `${writePath}.codenomad-plugin-controls.lock`
|
||||
const ownerPath = `${lockPath}/owner`
|
||||
const nonce = randomBytes(16).toString("hex")
|
||||
const deadline = Date.now() + REPLACEMENT_LOCK_WAIT_MS
|
||||
while (true) {
|
||||
try {
|
||||
await mkdir(lockPath, { mode: 0o700 })
|
||||
try {
|
||||
await writeFile(ownerPath, nonce, { mode: 0o600 })
|
||||
} catch (error) {
|
||||
await rm(lockPath, { recursive: true, force: true }).catch(() => undefined)
|
||||
throw error
|
||||
}
|
||||
let released = false
|
||||
const verify = async (): Promise<boolean> => (
|
||||
(await readFile(ownerPath, "utf8").catch(() => undefined)) === nonce
|
||||
)
|
||||
return {
|
||||
verify,
|
||||
release: async () => {
|
||||
if (released) return
|
||||
released = true
|
||||
// Only remove a lock still owned by this acquisition. A stalled owner
|
||||
// must never delete a successor's lock after its own lease expired.
|
||||
if ((await readFile(ownerPath, "utf8").catch(() => undefined)) !== nonce) return
|
||||
await rm(lockPath, { recursive: true, force: true }).catch(() => undefined)
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
|
||||
throw new PluginControlDocumentError("Unable to lock OpenCode configuration", "filesystem", { cause: error })
|
||||
}
|
||||
try {
|
||||
const info = await lstat(lockPath)
|
||||
if (Date.now() - info.mtimeMs > REPLACEMENT_LOCK_STALE_MS) {
|
||||
// Reap stale directories as well as orphaned files/symlinks so one
|
||||
// crashed writer cannot block future mutations forever.
|
||||
await rm(lockPath, { recursive: true, force: true }).catch(() => undefined)
|
||||
continue
|
||||
}
|
||||
} catch (inspectionError) {
|
||||
if ((inspectionError as NodeJS.ErrnoException).code === "ENOENT") continue
|
||||
if ((inspectionError as NodeJS.ErrnoException).code !== "ENOTEMPTY") {
|
||||
throw new PluginControlDocumentError("Unable to inspect OpenCode configuration lock", "filesystem", { cause: inspectionError })
|
||||
}
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
throw new PluginControlDocumentError("OpenCode configuration is being updated by another process", "conflict")
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function assertUnchanged(document: PluginControlDocument): Promise<void> {
|
||||
try {
|
||||
const current = await readBoundedFile(document.writePath)
|
||||
if (!document.exists || !current.contents.equals(encodeOriginal(document))) {
|
||||
throw new PluginControlDocumentError("OpenCode configuration changed during the update", "conflict")
|
||||
}
|
||||
if (process.platform !== "win32" && current.mode !== document.mode) {
|
||||
throw new PluginControlDocumentError("OpenCode configuration changed during the update", "conflict")
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT" && !document.exists) return
|
||||
if (error instanceof PluginControlDocumentError) {
|
||||
if (document.exists && error.kind === "filesystem" && /not a (regular )?file/.test(error.message)) {
|
||||
throw new PluginControlDocumentError("OpenCode configuration changed during the update", "conflict", { cause: error })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
throw new PluginControlDocumentError("Unable to verify OpenCode configuration before replacement", "filesystem", { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
async function readBoundedFile(filePath: string): Promise<{ contents: Buffer; mode: number }> {
|
||||
const file = await open(filePath, "r")
|
||||
try {
|
||||
const info = await file.stat()
|
||||
if (!info.isFile()) throw new PluginControlDocumentError("OpenCode configuration target is not a file", "filesystem")
|
||||
if (info.size > PLUGIN_CONTROL_MAX_CONFIG_BYTES) throw new PluginControlDocumentError("OpenCode configuration file is too large", "invalid")
|
||||
|
||||
const buffer = Buffer.allocUnsafe(PLUGIN_CONTROL_MAX_CONFIG_BYTES + 1)
|
||||
let offset = 0
|
||||
while (offset < buffer.length) {
|
||||
const { bytesRead } = await file.read(buffer, offset, buffer.length - offset, offset)
|
||||
if (bytesRead === 0) break
|
||||
offset += bytesRead
|
||||
}
|
||||
if (offset > PLUGIN_CONTROL_MAX_CONFIG_BYTES) throw new PluginControlDocumentError("OpenCode configuration file is too large", "invalid")
|
||||
return { contents: buffer.subarray(0, offset), mode: info.mode & 0o777 }
|
||||
} finally {
|
||||
await file.close()
|
||||
}
|
||||
}
|
||||
|
||||
export function encodePluginControlOriginal(document: PluginControlDocument): Buffer {
|
||||
return encodeOriginal(document)
|
||||
}
|
||||
|
||||
function encodeOriginal(document: PluginControlDocument): Buffer {
|
||||
return Buffer.from(`${document.byteOrderMark ? "\uFEFF" : ""}${document.text}`, "utf8")
|
||||
}
|
||||
|
||||
async function resolveHostWriteDestination(requestedPath: string): Promise<string> {
|
||||
return resolveHostPath(path.resolve(requestedPath), 0, new Set())
|
||||
}
|
||||
|
||||
async function resolveHostPath(current: string, depth: number, seen: Set<string>): Promise<string> {
|
||||
if (depth >= MAX_SYMLINK_DEPTH) {
|
||||
throw new PluginControlDocumentError("OpenCode configuration symlink chain is too deep", "filesystem")
|
||||
}
|
||||
try {
|
||||
return await realpath(current)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw new PluginControlDocumentError("Unable to resolve OpenCode configuration", "filesystem", { cause: error })
|
||||
}
|
||||
}
|
||||
let info
|
||||
try {
|
||||
info = await lstat(current)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
const parent = path.dirname(current)
|
||||
if (parent === current) return current
|
||||
// Missing parents are not symlink traversals; resolve them without
|
||||
// consuming the symlink depth budget so deep new paths stay missing.
|
||||
return path.join(await resolveHostPath(parent, depth, seen), path.basename(current))
|
||||
}
|
||||
throw new PluginControlDocumentError("Unable to inspect OpenCode configuration", "filesystem", { cause: error })
|
||||
}
|
||||
if (!info.isSymbolicLink()) return current
|
||||
if (seen.has(current)) throw new PluginControlDocumentError("OpenCode configuration symlink is circular", "filesystem")
|
||||
seen.add(current)
|
||||
const target = await readlink(current)
|
||||
return resolveHostPath(path.resolve(path.dirname(current), target), depth + 1, seen)
|
||||
}
|
||||
|
||||
function parsePluginEntry(value: unknown): PluginConfigEntry {
|
||||
if (typeof value === "string") {
|
||||
if (!value || value === "-") throw new PluginControlDocumentError("OpenCode plugin entry is invalid", "invalid")
|
||||
return value
|
||||
}
|
||||
if (!isRecord(value) || typeof value.package !== "string" || !value.package) {
|
||||
throw new PluginControlDocumentError("OpenCode plugin source is invalid", "invalid")
|
||||
}
|
||||
if (value.options !== undefined && !isRecord(value.options)) {
|
||||
throw new PluginControlDocumentError("OpenCode plugin options must be an object", "invalid")
|
||||
}
|
||||
return { package: value.package, ...(value.options === undefined ? {} : { options: value.options }) }
|
||||
}
|
||||
|
||||
function assertSinglePluginsProperty(root: JsonNode | undefined): void {
|
||||
if (!root || root.type !== "object") throw new PluginControlDocumentError("OpenCode configuration is malformed", "invalid")
|
||||
const count = (root.children ?? []).filter((property) => property.children?.[0]?.value === "plugins").length
|
||||
if (count > 1) throw new PluginControlDocumentError("OpenCode configuration has duplicate plugins keys", "invalid")
|
||||
}
|
||||
|
||||
function hasPluginsProperty(text: string): boolean {
|
||||
const root = parseTree(text, [], { allowTrailingComma: true, disallowComments: false })
|
||||
return Boolean(root?.children?.some((property) => property.children?.[0]?.value === "plugins"))
|
||||
}
|
||||
|
||||
function inferFormatting(text: string): FormattingOptions {
|
||||
const eol = text.includes("\r\n") ? "\r\n" : "\n"
|
||||
const indentation = text.match(/(?:^|\r?\n)([\t ]+)\S/)
|
||||
const whitespace = indentation?.[1] ?? " "
|
||||
return {
|
||||
eol,
|
||||
insertSpaces: !whitespace.includes("\t"),
|
||||
tabSize: whitespace.includes("\t") ? 1 : Math.max(1, whitespace.length),
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
784
packages/server/src/opencode/plugin-controls.test.ts
Normal file
784
packages/server/src/opencode/plugin-controls.test.ts
Normal file
|
|
@ -0,0 +1,784 @@
|
|||
import assert from "node:assert/strict"
|
||||
import fs from "node:fs"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { afterEach, describe, it } from "node:test"
|
||||
import { parse } from "jsonc-parser"
|
||||
import { PluginControls, PluginControlsError } from "./plugin-controls"
|
||||
|
||||
const temporaryDirectories = new Set<string>()
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of temporaryDirectories) fs.rmSync(directory, { recursive: true, force: true })
|
||||
temporaryDirectories.clear()
|
||||
})
|
||||
|
||||
describe("OpenCode V2 plugin activation controls", () => {
|
||||
it("keeps runtime inventory separate from configured sources and ordered rules", async () => {
|
||||
const fixture = createFixture({
|
||||
globalPlugins: [{ package: "@acme/reviewer", options: { strict: true } }, "-acme.reviewer"],
|
||||
projectPlugins: ["acme.reviewer", "-opencode.provider.*"],
|
||||
runtime: [
|
||||
{ id: "acme.reviewer", source: { type: "package", target: "@acme/reviewer", version: "1.2.0" }, features: { server: true }, state: { status: "active" } },
|
||||
{ id: "broken", source: { type: "local", path: "/plugins/broken" }, features: { rpc: true }, state: { status: "failed", error: "boom", ref: "err_1" } },
|
||||
],
|
||||
})
|
||||
|
||||
const snapshot = await fixture.controls.read("workspace", fixture.location)
|
||||
|
||||
assert.deepEqual(snapshot.runtime.map((entry) => [entry.id, entry.state.status]), [
|
||||
["acme.reviewer", "active"], ["broken", "failed"],
|
||||
])
|
||||
assert.deepEqual(snapshot.configured.sources.map((entry) => [entry.target, entry.scope, entry.hasOptions]), [
|
||||
["@acme/reviewer", "global", true],
|
||||
])
|
||||
assert.deepEqual(snapshot.configured.rules.map((rule) => [rule.selector, rule.enabled, rule.scope]), [
|
||||
["acme.reviewer", false, "global"],
|
||||
["acme.reviewer", true, "project"],
|
||||
["opencode.provider.*", false, "project"],
|
||||
])
|
||||
const reviewer = snapshot.controls.find((entry) => entry.id === "acme.reviewer")!
|
||||
assert.equal(reviewer.global, "disabled")
|
||||
assert.equal(reviewer.project, "enabled")
|
||||
assert.equal(reviewer.effective, "enabled")
|
||||
assert.equal(reviewer.runtime?.source.type, "package")
|
||||
})
|
||||
|
||||
it("keeps an exact disabled plugin visible when it is absent from runtime", async () => {
|
||||
const fixture = createFixture({ globalPlugins: ["-acme.reviewer"], runtime: [] })
|
||||
|
||||
const snapshot = await fixture.controls.read("workspace", fixture.location)
|
||||
|
||||
assert.deepEqual(snapshot.runtime, [])
|
||||
assert.deepEqual(snapshot.controls.map((entry) => ({ id: entry.id, effective: entry.effective })), [
|
||||
{ id: "acme.reviewer", effective: "disabled" },
|
||||
])
|
||||
})
|
||||
|
||||
it("distinguishes a package target from later exact activation rules when target and ID match", async () => {
|
||||
const fixture = createFixture({
|
||||
globalPlugins: ["same.id", "-same.id", "same.id"],
|
||||
runtime: [activePlugin("same.id", "same.id")],
|
||||
})
|
||||
|
||||
const snapshot = await fixture.controls.read("workspace", fixture.location)
|
||||
|
||||
assert.deepEqual(snapshot.configured.sources.map((entry) => entry.target), ["same.id"])
|
||||
assert.deepEqual(snapshot.configured.rules.map((rule) => [rule.selector, rule.enabled]), [
|
||||
["same.id", false],
|
||||
["same.id", true],
|
||||
])
|
||||
assert.equal(snapshot.controls.find((entry) => entry.id === "same.id")?.effective, "enabled")
|
||||
})
|
||||
|
||||
it("replays object-form sources as ordered add operations", async () => {
|
||||
const fixture = createFixture({
|
||||
globalPlugins: ["same.id", "-same.id", { package: "same.id", options: {} }],
|
||||
runtime: [activePlugin("same.id", "same.id")],
|
||||
})
|
||||
|
||||
const snapshot = await fixture.controls.read("workspace", fixture.location)
|
||||
|
||||
assert.deepEqual(snapshot.configured.rules.map((rule) => [rule.selector, rule.enabled]), [
|
||||
["same.id", false],
|
||||
["same.id", true],
|
||||
])
|
||||
assert.equal(snapshot.controls.find((entry) => entry.id === "same.id")?.effective, "enabled")
|
||||
})
|
||||
|
||||
it("correlates relative and file URL local sources with their runtime entrypoint", async () => {
|
||||
for (const kind of ["relative", "file"] as const) {
|
||||
const fixture = createFixture({ runtime: [] })
|
||||
const configuredTarget = kind === "file"
|
||||
? pathToFileURL(path.join(fixture.projectDirectory, "plugins", "reviewer")).href
|
||||
: "./plugins/reviewer"
|
||||
const sourceDirectory = kind === "file"
|
||||
? path.join(fixture.projectDirectory, "plugins", "reviewer")
|
||||
: path.join(fixture.projectDirectory, ".opencode", "plugins", "reviewer")
|
||||
const entrypoint = path.join(sourceDirectory, "server.js")
|
||||
fixture.runtime.push({
|
||||
id: "acme.reviewer",
|
||||
source: { type: "local", path: entrypoint },
|
||||
features: { server: true },
|
||||
state: { status: "active" },
|
||||
})
|
||||
fixture.setProjectPlugins([
|
||||
configuredTarget,
|
||||
"-acme.reviewer",
|
||||
{ package: configuredTarget, options: {} },
|
||||
])
|
||||
|
||||
const snapshot = await fixture.controls.read("workspace", fixture.location)
|
||||
assert.equal(snapshot.controls.find((entry) => entry.id === "acme.reviewer")?.effective, "enabled")
|
||||
}
|
||||
})
|
||||
|
||||
it("marks an exact disabled OpenCode builtin even when runtime inventory omits it", async () => {
|
||||
const fixture = createFixture({ globalPlugins: ["-opencode.prompt.identity"], runtime: [] })
|
||||
|
||||
const control = (await fixture.controls.read("workspace", fixture.location)).controls
|
||||
.find((entry) => entry.id === "opencode.prompt.identity")
|
||||
|
||||
assert.equal(control?.builtin, true)
|
||||
assert.equal(control?.effective, "disabled")
|
||||
})
|
||||
|
||||
it("serializes mutations, writes only the selected project layer, and preserves prior rules", async () => {
|
||||
const fixture = createFixture({
|
||||
globalPlugins: [
|
||||
{ package: "@acme/reviewer", options: { strict: true } },
|
||||
{ package: "@acme/linter", options: { level: "strict" } },
|
||||
],
|
||||
runtime: [activePlugin("acme.reviewer", "@acme/reviewer"), activePlugin("acme.linter", "@acme/linter")],
|
||||
omitProjectDocument: true,
|
||||
})
|
||||
const globalBefore = fs.readFileSync(fixture.globalFile, "utf8")
|
||||
|
||||
const [reviewer, linter] = await Promise.all([
|
||||
fixture.controls.mutate("workspace", { location: fixture.location, pluginId: "acme.reviewer", scope: "project", enabled: false }),
|
||||
fixture.controls.mutate("workspace", { location: fixture.location, pluginId: "acme.linter", scope: "project", enabled: false }),
|
||||
])
|
||||
|
||||
assert.equal(reviewer.changed, true)
|
||||
assert.equal(linter.changed, true)
|
||||
assert.equal(reviewer.target.scope, "project")
|
||||
assert.equal(fs.readFileSync(fixture.globalFile, "utf8"), globalBefore)
|
||||
const projectFile = path.join(fixture.projectDirectory, ".opencode", "opencode.jsonc")
|
||||
assert.deepEqual((parse(fs.readFileSync(projectFile, "utf8")) as any).plugins, ["-acme.reviewer", "-acme.linter"])
|
||||
assert.equal(linter.snapshot.controls.find((entry) => entry.id === "acme.reviewer")?.project, "disabled")
|
||||
assert.equal(linter.snapshot.controls.find((entry) => entry.id === "acme.linter")?.project, "disabled")
|
||||
})
|
||||
|
||||
it("serializes same-target mutations while letting other scopes proceed", async () => {
|
||||
const fixture = createFixture({
|
||||
globalPlugins: [{ package: "@acme/reviewer", options: { strict: true } }],
|
||||
projectPlugins: [],
|
||||
runtime: [activePlugin("acme.reviewer", "@acme/reviewer")],
|
||||
})
|
||||
const manager = (fixture.controls as any).options.workspaceManager
|
||||
const innerIdentity = manager.getWorktreeIdentityForPath.bind(manager)
|
||||
const gate = deferred<void>()
|
||||
let identityCalls = 0
|
||||
let projectSettled = false
|
||||
manager.getWorktreeIdentityForPath = async (...args: [string, string]) => {
|
||||
identityCalls += 1
|
||||
// The first mutation (global scope) is held here; a per-target queue
|
||||
// lets the project-scope mutation overtake it instead of blocking.
|
||||
if (identityCalls === 1) await gate.promise
|
||||
return innerIdentity(...args)
|
||||
}
|
||||
|
||||
const global = fixture.controls.mutate("workspace", { location: fixture.location, pluginId: "acme.reviewer", scope: "global", enabled: false })
|
||||
const project = fixture.controls.mutate("workspace", { location: fixture.location, pluginId: "acme.reviewer", scope: "project", enabled: false })
|
||||
void project.then(() => { projectSettled = true })
|
||||
const deadline = Date.now() + 5_000
|
||||
while (!projectSettled && Date.now() < deadline) await tick()
|
||||
assert.equal(projectSettled, true)
|
||||
gate.resolve()
|
||||
const [globalResponse, projectResponse] = await Promise.all([global, project])
|
||||
|
||||
assert.equal(globalResponse.changed, true)
|
||||
assert.equal(projectResponse.changed, true)
|
||||
assert.deepEqual((parse(fs.readFileSync(fixture.globalFile, "utf8")) as any).plugins, [
|
||||
{ package: "@acme/reviewer", options: { strict: true } },
|
||||
"-acme.reviewer",
|
||||
])
|
||||
assert.deepEqual((parse(fs.readFileSync(fixture.projectFile, "utf8")) as any).plugins, ["-acme.reviewer"])
|
||||
})
|
||||
|
||||
it("serializes global writes from different worktree directories to the same file", async () => {
|
||||
const fixture = createFixture({
|
||||
globalPlugins: [],
|
||||
runtime: [activePlugin("acme.reviewer", "@acme/reviewer"), activePlugin("acme.linter", "@acme/linter")],
|
||||
})
|
||||
const otherLocation = { directory: path.join(path.dirname(fixture.projectDirectory), "other-worktree") }
|
||||
|
||||
const [reviewer, linter] = await Promise.all([
|
||||
fixture.controls.mutate("workspace", { location: fixture.location, pluginId: "acme.reviewer", scope: "global", enabled: false }),
|
||||
fixture.controls.mutate("workspace", { location: otherLocation, pluginId: "acme.linter", scope: "global", enabled: false }),
|
||||
])
|
||||
|
||||
assert.equal(reviewer.changed, true)
|
||||
assert.equal(linter.changed, true)
|
||||
assert.deepEqual((parse(fs.readFileSync(fixture.globalFile, "utf8")) as any).plugins, ["-acme.reviewer", "-acme.linter"])
|
||||
})
|
||||
|
||||
it("appends a later exact rule to re-enable without deleting the disabling rule", async () => {
|
||||
const fixture = createFixture({
|
||||
globalPlugins: [{ package: "@acme/reviewer", options: { strict: true } }],
|
||||
projectPlugins: ["-acme.reviewer"],
|
||||
runtime: [],
|
||||
})
|
||||
|
||||
const response = await fixture.controls.mutate("workspace", {
|
||||
location: fixture.location,
|
||||
pluginId: "acme.reviewer",
|
||||
scope: "project",
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
assert.equal(response.rule, "acme.reviewer")
|
||||
assert.equal(response.snapshot.controls.find((entry) => entry.id === "acme.reviewer")?.effective, "enabled")
|
||||
assert.deepEqual((parse(fs.readFileSync(fixture.projectFile, "utf8")) as any).plugins, ["-acme.reviewer", "acme.reviewer"])
|
||||
})
|
||||
|
||||
it("uses daemon-reported roots and host translation instead of process configuration", async () => {
|
||||
const host = temporaryDirectory()
|
||||
const globalHost = path.join(host, "daemon-global")
|
||||
const projectHost = path.join(host, "daemon-project")
|
||||
fs.mkdirSync(globalHost, { recursive: true })
|
||||
fs.mkdirSync(projectHost, { recursive: true })
|
||||
fs.writeFileSync(path.join(globalHost, "opencode.jsonc"), `{ "plugins": [] }\n`)
|
||||
const location = { directory: "/srv/project" }
|
||||
const entries = [
|
||||
{ type: "document", path: "/daemon/config/opencode.jsonc", info: { plugins: [] } },
|
||||
{ type: "directory", path: "/daemon/config" },
|
||||
]
|
||||
const manager = fakeManager({
|
||||
entries,
|
||||
runtime: [activePlugin("acme.reviewer", "@acme/reviewer")],
|
||||
style: "posix",
|
||||
translate: (servicePath) => servicePath === "/daemon/config" ? globalHost : servicePath === "/srv/project" ? projectHost : undefined,
|
||||
})
|
||||
const controls = new PluginControls({ workspaceManager: manager as any, worktreeDeletionFence: openFence(), logger: logger() })
|
||||
|
||||
const response = await controls.mutate("workspace", { location, pluginId: "acme.reviewer", scope: "global", enabled: false })
|
||||
|
||||
assert.equal(response.target.path, "/daemon/config/opencode.jsonc")
|
||||
assert.deepEqual((parse(fs.readFileSync(path.join(globalHost, "opencode.jsonc"), "utf8")) as any).plugins, ["-acme.reviewer"])
|
||||
})
|
||||
|
||||
it("uses the canonical service directory for WSL requests and snapshot identity", async () => {
|
||||
const host = temporaryDirectory()
|
||||
const globalHost = path.join(host, "daemon-global")
|
||||
const projectHost = path.join(host, "daemon-project")
|
||||
fs.mkdirSync(globalHost, { recursive: true })
|
||||
fs.mkdirSync(projectHost, { recursive: true })
|
||||
fs.writeFileSync(path.join(globalHost, "opencode.jsonc"), `{ "plugins": [] }\n`)
|
||||
const entries = [{ type: "directory", path: "/daemon/config" }]
|
||||
const manager = fakeManager({
|
||||
entries,
|
||||
runtime: [activePlugin("acme.reviewer", "@acme/reviewer")],
|
||||
style: "posix",
|
||||
serviceDirectory: "/srv/project",
|
||||
translate: (servicePath) => servicePath === "/daemon/config" ? globalHost : servicePath === "/srv/project" ? projectHost : undefined,
|
||||
})
|
||||
const controls = new PluginControls({ workspaceManager: manager as any, worktreeDeletionFence: openFence(), logger: logger() })
|
||||
|
||||
const snapshot = await controls.read("workspace", { directory: "\\\\wsl.localhost\\Ubuntu\\srv\\project" })
|
||||
|
||||
assert.equal(snapshot.location.directory, "/srv/project")
|
||||
assert.deepEqual(manager.calls.configLocations, ["/srv/project"])
|
||||
assert.deepEqual(manager.calls.pluginLocations, ["/srv/project"])
|
||||
})
|
||||
|
||||
it("does not expose Project when it resolves to the Global configuration file", async () => {
|
||||
const directory = temporaryDirectory()
|
||||
const configFile = path.join(directory, "opencode.jsonc")
|
||||
fs.writeFileSync(configFile, `{ "plugins": [] }\n`)
|
||||
const manager = fakeManager({
|
||||
entries: [
|
||||
{ type: "document", path: configFile, info: { plugins: [] } },
|
||||
{ type: "directory", path: directory },
|
||||
],
|
||||
runtime: [activePlugin("known", "known-package")],
|
||||
})
|
||||
const controls = new PluginControls({ workspaceManager: manager as any, worktreeDeletionFence: openFence(), logger: logger() })
|
||||
|
||||
const snapshot = await controls.read("workspace", { directory })
|
||||
assert.deepEqual(snapshot.targets.map((target) => target.scope), ["global"])
|
||||
await assert.rejects(
|
||||
controls.mutate("workspace", { location: { directory }, pluginId: "known", scope: "project", enabled: false }),
|
||||
(error: unknown) => error instanceof PluginControlsError && error.kind === "unavailable",
|
||||
)
|
||||
assert.deepEqual((parse(fs.readFileSync(configFile, "utf8")) as any).plugins, [])
|
||||
})
|
||||
|
||||
it("does not expose Project through a symlink to the Global configuration", async () => {
|
||||
const root = temporaryDirectory()
|
||||
const globalDirectory = path.join(root, "global")
|
||||
const projectDirectory = path.join(root, "project")
|
||||
const globalFile = path.join(globalDirectory, "opencode.jsonc")
|
||||
fs.mkdirSync(globalDirectory, { recursive: true })
|
||||
fs.mkdirSync(projectDirectory, { recursive: true })
|
||||
fs.writeFileSync(globalFile, `{ "plugins": [] }\n`)
|
||||
fs.symlinkSync(globalDirectory, path.join(projectDirectory, ".opencode"), process.platform === "win32" ? "junction" : "dir")
|
||||
const manager = fakeManager({
|
||||
entries: [
|
||||
{ type: "document", path: globalFile, info: { plugins: [] } },
|
||||
{ type: "directory", path: globalDirectory },
|
||||
],
|
||||
runtime: [activePlugin("known", "known-package")],
|
||||
})
|
||||
const controls = new PluginControls({ workspaceManager: manager as any, worktreeDeletionFence: openFence(), logger: logger() })
|
||||
|
||||
const snapshot = await controls.read("workspace", { directory: projectDirectory })
|
||||
assert.deepEqual(snapshot.targets.map((target) => target.scope), ["global"])
|
||||
await assert.rejects(
|
||||
controls.mutate("workspace", { location: { directory: projectDirectory }, pluginId: "known", scope: "project", enabled: false }),
|
||||
(error: unknown) => error instanceof PluginControlsError && error.kind === "unavailable",
|
||||
)
|
||||
assert.deepEqual((parse(fs.readFileSync(globalFile, "utf8")) as any).plugins, [])
|
||||
})
|
||||
|
||||
it("does not expose Project at the global configuration root when no document exists", async () => {
|
||||
const directory = temporaryDirectory()
|
||||
const manager = fakeManager({
|
||||
entries: [{ type: "directory", path: directory }],
|
||||
runtime: [activePlugin("known", "known-package")],
|
||||
})
|
||||
const controls = new PluginControls({ workspaceManager: manager as any, worktreeDeletionFence: openFence(), logger: logger() })
|
||||
|
||||
const snapshot = await controls.read("workspace", { directory })
|
||||
assert.deepEqual(snapshot.targets.map((target) => target.scope), ["global"])
|
||||
await assert.rejects(
|
||||
controls.mutate("workspace", { location: { directory }, pluginId: "known", scope: "project", enabled: false }),
|
||||
(error: unknown) => error instanceof PluginControlsError && error.kind === "unavailable",
|
||||
)
|
||||
assert.equal(fs.existsSync(path.join(directory, ".opencode", "opencode.jsonc")), false)
|
||||
})
|
||||
|
||||
it("uses normalized daemon entries for authorization while preserving raw placeholders", async () => {
|
||||
const fixture = createFixture({
|
||||
globalPlugins: ["{env:PLUGIN_RULE}"],
|
||||
reportedGlobalPlugins: ["-known"],
|
||||
runtime: [],
|
||||
})
|
||||
|
||||
const response = await fixture.controls.mutate("workspace", {
|
||||
location: fixture.location,
|
||||
pluginId: "known",
|
||||
scope: "global",
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
assert.equal(response.snapshot.controls.find((entry) => entry.id === "known")?.effective, "enabled")
|
||||
assert.deepEqual((parse(fs.readFileSync(fixture.globalFile, "utf8")) as any).plugins, ["{env:PLUGIN_RULE}", "known"])
|
||||
})
|
||||
|
||||
it("persists the requested rule when a raw replacement or deletion has overtaken daemon normalization", async () => {
|
||||
for (const rawPlugins of [["known"], [], ["known", "-known"]]) {
|
||||
const fixture = createFixture({
|
||||
globalPlugins: rawPlugins,
|
||||
reportedGlobalPlugins: ["-known"],
|
||||
runtime: [activePlugin("known", "known-package")],
|
||||
})
|
||||
|
||||
const response = await fixture.controls.mutate("workspace", {
|
||||
location: fixture.location, pluginId: "known", scope: "global", enabled: false,
|
||||
})
|
||||
|
||||
assert.equal(response.changed, true, "divergent raw documents cannot prove a no-op")
|
||||
assert.equal(response.snapshot.controls.find((entry) => entry.id === "known")?.global, "disabled")
|
||||
assert.deepEqual((parse(fs.readFileSync(fixture.globalFile, "utf8")) as any).plugins, [...rawPlugins, "-known"])
|
||||
}
|
||||
})
|
||||
|
||||
it("retains no-op detection for an aligned document and a concrete pending rule", async () => {
|
||||
for (const reportedGlobalPlugins of [["-known"], []]) {
|
||||
const fixture = createFixture({
|
||||
globalPlugins: ["-known"], reportedGlobalPlugins,
|
||||
runtime: [activePlugin("known", "known-package")],
|
||||
})
|
||||
const before = fs.readFileSync(fixture.globalFile, "utf8")
|
||||
|
||||
const response = await fixture.controls.mutate("workspace", {
|
||||
location: fixture.location, pluginId: "known", scope: "global", enabled: false,
|
||||
})
|
||||
|
||||
assert.equal(response.changed, false)
|
||||
assert.equal(fs.readFileSync(fixture.globalFile, "utf8"), before)
|
||||
}
|
||||
})
|
||||
|
||||
it("creates a location-local Project layer above inherited .opencode rules without editing the direct or ancestor file", async () => {
|
||||
for (const trailingVirtualRule of [false, true]) {
|
||||
const root = temporaryDirectory()
|
||||
const globalDirectory = path.join(root, "global")
|
||||
const projectDirectory = path.join(root, "project")
|
||||
const ancestorDirectory = path.join(root, ".opencode")
|
||||
const directFile = path.join(projectDirectory, "opencode.jsonc")
|
||||
const ancestorFile = path.join(ancestorDirectory, "opencode.jsonc")
|
||||
const targetFile = path.join(projectDirectory, ".opencode", "opencode.jsonc")
|
||||
for (const directory of [globalDirectory, projectDirectory, ancestorDirectory]) fs.mkdirSync(directory)
|
||||
if (trailingVirtualRule) fs.mkdirSync(path.dirname(targetFile))
|
||||
fs.writeFileSync(directFile, `{ "plugins": [], "model": "unchanged" }\n`)
|
||||
fs.writeFileSync(ancestorFile, `{ "plugins": ["-known"] }\n`)
|
||||
const directBefore = fs.readFileSync(directFile, "utf8")
|
||||
const ancestorBefore = fs.readFileSync(ancestorFile, "utf8")
|
||||
const entries = [
|
||||
{ type: "directory", path: globalDirectory },
|
||||
{ type: "document", path: directFile, info: { plugins: [] } },
|
||||
{ type: "document", path: ancestorFile, info: { plugins: ["-known"] } },
|
||||
{ type: "directory", path: ancestorDirectory },
|
||||
...(trailingVirtualRule ? [{ type: "directory", path: path.dirname(targetFile) }] : []),
|
||||
...(trailingVirtualRule ? [{ type: "document", info: { plugins: ["-known"] } }] : []),
|
||||
]
|
||||
const controls = new PluginControls({
|
||||
workspaceManager: fakeManager({ entries, runtime: [activePlugin("known", "known-package")] }) as any,
|
||||
worktreeDeletionFence: openFence(), logger: logger(),
|
||||
})
|
||||
|
||||
const before = await controls.read("workspace", { directory: projectDirectory })
|
||||
assert.deepEqual(before.targets.find((target) => target.scope === "project"), { scope: "project", path: targetFile, exists: false })
|
||||
assert.equal(fs.existsSync(targetFile), false, "display reads do not create the fallback")
|
||||
const response = await controls.mutate("workspace", {
|
||||
location: { directory: projectDirectory }, pluginId: "known", scope: "project", enabled: true,
|
||||
})
|
||||
|
||||
assert.equal(response.changed, true)
|
||||
assert.equal(response.target.path, targetFile)
|
||||
const control = response.snapshot.controls.find((entry) => entry.id === "known")!
|
||||
assert.equal(control.project, "enabled")
|
||||
assert.equal(control.effective, trailingVirtualRule ? "disabled" : "enabled")
|
||||
assert.deepEqual((parse(fs.readFileSync(targetFile, "utf8")) as any).plugins, ["known"])
|
||||
assert.equal(fs.readFileSync(directFile, "utf8"), directBefore)
|
||||
assert.equal(fs.readFileSync(ancestorFile, "utf8"), ancestorBefore)
|
||||
}
|
||||
})
|
||||
|
||||
it("makes Project unavailable when its higher-precedence fallback aliases an ancestor configuration", async () => {
|
||||
const root = temporaryDirectory()
|
||||
const globalDirectory = path.join(root, "global")
|
||||
const projectDirectory = path.join(root, "project")
|
||||
const ancestorDirectory = path.join(root, ".opencode")
|
||||
const ancestorFile = path.join(ancestorDirectory, "opencode.jsonc")
|
||||
for (const directory of [globalDirectory, projectDirectory, ancestorDirectory]) fs.mkdirSync(directory)
|
||||
fs.writeFileSync(ancestorFile, `{ "plugins": ["-known"] }\n`)
|
||||
fs.symlinkSync(ancestorDirectory, path.join(projectDirectory, ".opencode"), process.platform === "win32" ? "junction" : "dir")
|
||||
const controls = new PluginControls({
|
||||
workspaceManager: fakeManager({
|
||||
entries: [
|
||||
{ type: "directory", path: globalDirectory },
|
||||
{ type: "document", path: ancestorFile, info: { plugins: ["-known"] } },
|
||||
{ type: "directory", path: ancestorDirectory },
|
||||
],
|
||||
runtime: [activePlugin("known", "known-package")],
|
||||
}) as any,
|
||||
worktreeDeletionFence: openFence(), logger: logger(),
|
||||
})
|
||||
const before = fs.readFileSync(ancestorFile, "utf8")
|
||||
|
||||
const snapshot = await controls.read("workspace", { directory: projectDirectory })
|
||||
assert.deepEqual(snapshot.targets.map((target) => target.scope), ["global"])
|
||||
await assert.rejects(controls.mutate("workspace", {
|
||||
location: { directory: projectDirectory }, pluginId: "known", scope: "project", enabled: true,
|
||||
}), (error: unknown) => error instanceof PluginControlsError && error.kind === "unavailable")
|
||||
assert.equal(fs.readFileSync(ancestorFile, "utf8"), before)
|
||||
})
|
||||
|
||||
it("never treats a concrete pending rule as the normalized form of a placeholder", async () => {
|
||||
const fixture = createFixture({
|
||||
globalPlugins: ["{env:PLUGIN_RULE}", "-known", "known"],
|
||||
reportedGlobalPlugins: ["known"],
|
||||
runtime: [activePlugin("known", "known-package")],
|
||||
})
|
||||
|
||||
const response = await fixture.controls.mutate("workspace", {
|
||||
location: fixture.location,
|
||||
pluginId: "known",
|
||||
scope: "global",
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
assert.equal(response.changed, true)
|
||||
assert.equal(response.snapshot.controls.find((entry) => entry.id === "known")?.effective, "disabled")
|
||||
assert.deepEqual((parse(fs.readFileSync(fixture.globalFile, "utf8")) as any).plugins, [
|
||||
"{env:PLUGIN_RULE}", "-known", "known", "-known",
|
||||
])
|
||||
})
|
||||
|
||||
it("preserves normalized ordering when a placeholder follows a concrete rule", async () => {
|
||||
const fixture = createFixture({
|
||||
globalPlugins: ["-known", "{env:PLUGIN_RULE}"],
|
||||
reportedGlobalPlugins: ["-known", "known"],
|
||||
runtime: [activePlugin("known", "known-package")],
|
||||
})
|
||||
|
||||
const response = await fixture.controls.mutate("workspace", {
|
||||
location: fixture.location,
|
||||
pluginId: "known",
|
||||
scope: "global",
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
assert.equal(response.changed, true)
|
||||
assert.equal(response.snapshot.controls.find((entry) => entry.id === "known")?.effective, "disabled")
|
||||
assert.deepEqual((parse(fs.readFileSync(fixture.globalFile, "utf8")) as any).plugins, [
|
||||
"-known", "{env:PLUGIN_RULE}", "-known",
|
||||
])
|
||||
})
|
||||
|
||||
it("keeps daemon-reported virtual precedence when adding a missing global document", async () => {
|
||||
const root = temporaryDirectory()
|
||||
const globalDirectory = path.join(root, "global")
|
||||
const projectDirectory = path.join(root, "project")
|
||||
const projectConfigDirectory = path.join(projectDirectory, ".opencode")
|
||||
fs.mkdirSync(globalDirectory, { recursive: true })
|
||||
fs.mkdirSync(projectConfigDirectory, { recursive: true })
|
||||
const entries = [
|
||||
{ type: "document", info: { plugins: ["known"] } },
|
||||
{ type: "directory", path: globalDirectory },
|
||||
{ type: "document", path: path.join(projectConfigDirectory, "opencode.jsonc"), info: { plugins: [] } },
|
||||
{ type: "directory", path: projectConfigDirectory },
|
||||
{ type: "document", info: { plugins: ["-known"] } },
|
||||
]
|
||||
const manager = fakeManager({ entries, runtime: [activePlugin("known", "known-package")] })
|
||||
const controls = new PluginControls({
|
||||
workspaceManager: manager as any,
|
||||
worktreeDeletionFence: openFence(),
|
||||
logger: logger(),
|
||||
})
|
||||
|
||||
const response = await controls.mutate("workspace", {
|
||||
location: { directory: projectDirectory },
|
||||
pluginId: "known",
|
||||
scope: "global",
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
const control = response.snapshot.controls.find((entry) => entry.id === "known")
|
||||
assert.equal(control?.global, "enabled")
|
||||
assert.equal(control?.effective, "disabled", "the trailing virtual rule remains higher precedence")
|
||||
assert.deepEqual((parse(fs.readFileSync(path.join(globalDirectory, "opencode.jsonc"), "utf8")) as any).plugins, ["known"])
|
||||
})
|
||||
|
||||
it("rejects foreign locations and unknown plugin IDs before any write", async () => {
|
||||
const fixture = createFixture({ runtime: [activePlugin("known", "known-package")], ownsLocation: false })
|
||||
await assert.rejects(
|
||||
fixture.controls.mutate("workspace", { location: fixture.location, pluginId: "known", scope: "global", enabled: false }),
|
||||
(error: unknown) => error instanceof PluginControlsError && error.kind === "forbidden",
|
||||
)
|
||||
assert.equal(fixture.calls.config, 0)
|
||||
assert.equal(fixture.calls.plugins, 0)
|
||||
|
||||
const owned = createFixture({ runtime: [activePlugin("known", "known-package")] })
|
||||
await assert.rejects(
|
||||
owned.controls.mutate("workspace", { location: owned.location, pluginId: "invented", scope: "global", enabled: false }),
|
||||
(error: unknown) => error instanceof PluginControlsError && error.kind === "forbidden",
|
||||
)
|
||||
assert.deepEqual((parse(fs.readFileSync(owned.globalFile, "utf8")) as any).plugins, [])
|
||||
})
|
||||
|
||||
it("fails closed when the selected configuration document is malformed", async () => {
|
||||
const fixture = createFixture({ runtime: [activePlugin("known", "known-package")] })
|
||||
const malformed = `{ "plugins": ["known", } broken }`
|
||||
fs.writeFileSync(fixture.globalFile, malformed)
|
||||
|
||||
await assert.rejects(
|
||||
fixture.controls.mutate("workspace", { location: fixture.location, pluginId: "known", scope: "global", enabled: false }),
|
||||
(error: unknown) => error instanceof PluginControlsError && error.kind === "invalid",
|
||||
)
|
||||
assert.equal(fs.readFileSync(fixture.globalFile, "utf8"), malformed)
|
||||
})
|
||||
|
||||
it("refuses a mutation while worktree deletion blocks the location", async () => {
|
||||
const fixture = createFixture({
|
||||
runtime: [activePlugin("known", "known-package")],
|
||||
worktreeDeletionFence: { enter: () => undefined },
|
||||
})
|
||||
const before = fs.readFileSync(fixture.globalFile, "utf8")
|
||||
|
||||
await assert.rejects(
|
||||
fixture.controls.mutate("workspace", { location: fixture.location, pluginId: "known", scope: "global", enabled: false }),
|
||||
(error: unknown) => error instanceof PluginControlsError && error.kind === "conflict",
|
||||
)
|
||||
|
||||
assert.equal(fs.readFileSync(fixture.globalFile, "utf8"), before)
|
||||
})
|
||||
|
||||
it("fails closed instead of presenting malformed runtime inventory as active", async () => {
|
||||
const fixture = createFixture({
|
||||
runtime: [{ id: "known", source: { type: "unexpected" }, features: {}, state: { status: "pending" } }],
|
||||
})
|
||||
|
||||
await assert.rejects(
|
||||
fixture.controls.read("workspace", fixture.location),
|
||||
(error: unknown) => error instanceof PluginControlsError && error.kind === "unavailable",
|
||||
)
|
||||
})
|
||||
|
||||
it("fences a read if the authenticated connection changes during target discovery", async () => {
|
||||
const root = temporaryDirectory()
|
||||
const globalDirectory = path.join(root, "global")
|
||||
const projectDirectory = path.join(root, "project")
|
||||
fs.mkdirSync(globalDirectory, { recursive: true })
|
||||
fs.mkdirSync(projectDirectory, { recursive: true })
|
||||
const manager = fakeManager({
|
||||
entries: [{ type: "directory", path: globalDirectory }],
|
||||
runtime: [activePlugin("known", "known-package")],
|
||||
assertCurrent: (count) => { if (count === 2) throw new Error("stale connection") },
|
||||
})
|
||||
const controls = new PluginControls({
|
||||
workspaceManager: manager as any,
|
||||
worktreeDeletionFence: openFence(),
|
||||
logger: logger(),
|
||||
})
|
||||
|
||||
await assert.rejects(
|
||||
controls.read("workspace", { directory: projectDirectory }),
|
||||
(error: unknown) => error instanceof PluginControlsError && error.kind === "unavailable",
|
||||
)
|
||||
})
|
||||
|
||||
it("fences a mutation again immediately before the atomic rename", async () => {
|
||||
const fixture = createFixture({
|
||||
runtime: [activePlugin("known", "known-package")],
|
||||
assertCurrent: (count) => { if (count === 4) throw new Error("stale connection") },
|
||||
})
|
||||
const before = fs.readFileSync(fixture.globalFile, "utf8")
|
||||
|
||||
await assert.rejects(
|
||||
fixture.controls.mutate("workspace", {
|
||||
location: fixture.location,
|
||||
pluginId: "known",
|
||||
scope: "global",
|
||||
enabled: false,
|
||||
}),
|
||||
(error: unknown) => error instanceof PluginControlsError && error.kind === "unavailable",
|
||||
)
|
||||
|
||||
assert.equal(fixture.calls.assertCurrent, 4)
|
||||
assert.equal(fs.readFileSync(fixture.globalFile, "utf8"), before)
|
||||
})
|
||||
|
||||
it("fences a no-op mutation after reading the target document", async () => {
|
||||
const fixture = createFixture({
|
||||
globalPlugins: ["-known"],
|
||||
runtime: [],
|
||||
assertCurrent: (count) => { if (count === 3) throw new Error("stale connection") },
|
||||
})
|
||||
|
||||
await assert.rejects(
|
||||
fixture.controls.mutate("workspace", {
|
||||
location: fixture.location,
|
||||
pluginId: "known",
|
||||
scope: "global",
|
||||
enabled: false,
|
||||
}),
|
||||
(error: unknown) => error instanceof PluginControlsError && error.kind === "unavailable",
|
||||
)
|
||||
assert.equal(fixture.calls.assertCurrent, 3)
|
||||
})
|
||||
})
|
||||
|
||||
function createFixture(options: {
|
||||
globalPlugins?: unknown[]
|
||||
reportedGlobalPlugins?: unknown[]
|
||||
projectPlugins?: unknown[]
|
||||
reportedProjectPlugins?: unknown[]
|
||||
runtime?: any[]
|
||||
omitProjectDocument?: boolean
|
||||
ownsLocation?: boolean
|
||||
assertCurrent?: (count: number) => void
|
||||
worktreeDeletionFence?: { enter(identities: string[]): (() => void) | undefined }
|
||||
} = {}) {
|
||||
const root = temporaryDirectory()
|
||||
const globalDirectory = path.join(root, "daemon-config")
|
||||
const projectDirectory = path.join(root, "project")
|
||||
const globalFile = path.join(globalDirectory, "opencode.jsonc")
|
||||
const projectFile = path.join(projectDirectory, ".opencode", "opencode.jsonc")
|
||||
fs.mkdirSync(globalDirectory, { recursive: true })
|
||||
fs.mkdirSync(path.dirname(projectFile), { recursive: true })
|
||||
fs.writeFileSync(globalFile, `${JSON.stringify({ plugins: options.globalPlugins ?? [] }, null, 2)}\n`)
|
||||
if (!options.omitProjectDocument) fs.writeFileSync(projectFile, `${JSON.stringify({ plugins: options.projectPlugins ?? [] }, null, 2)}\n`)
|
||||
const entries: any[] = [
|
||||
{ type: "document", path: globalFile, info: { plugins: options.reportedGlobalPlugins ?? options.globalPlugins ?? [] } },
|
||||
{ type: "directory", path: globalDirectory },
|
||||
]
|
||||
let projectEntry: any
|
||||
if (!options.omitProjectDocument) {
|
||||
projectEntry = { type: "document", path: projectFile, info: { plugins: options.reportedProjectPlugins ?? options.projectPlugins ?? [] } }
|
||||
entries.push(projectEntry)
|
||||
entries.push({ type: "directory", path: path.dirname(projectFile) })
|
||||
}
|
||||
const runtime = options.runtime ?? []
|
||||
const manager = fakeManager({
|
||||
entries,
|
||||
runtime,
|
||||
ownsLocation: options.ownsLocation,
|
||||
assertCurrent: options.assertCurrent,
|
||||
})
|
||||
return {
|
||||
controls: new PluginControls({
|
||||
workspaceManager: manager as any,
|
||||
worktreeDeletionFence: options.worktreeDeletionFence ?? openFence(),
|
||||
logger: logger(),
|
||||
}),
|
||||
calls: manager.calls,
|
||||
location: { directory: projectDirectory },
|
||||
globalDirectory,
|
||||
projectDirectory,
|
||||
globalFile,
|
||||
projectFile,
|
||||
runtime,
|
||||
setProjectPlugins: (plugins: unknown[]) => {
|
||||
if (!projectEntry) throw new Error("Project document is omitted")
|
||||
projectEntry.info.plugins = plugins
|
||||
fs.writeFileSync(projectFile, `${JSON.stringify({ plugins }, null, 2)}\n`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function fakeManager(options: {
|
||||
entries: any[]
|
||||
runtime: any[]
|
||||
ownsLocation?: boolean
|
||||
style?: "win32" | "posix"
|
||||
translate?: (servicePath: string) => string | undefined
|
||||
serviceDirectory?: string
|
||||
wslDistro?: string
|
||||
assertCurrent?: (count: number) => void
|
||||
}) {
|
||||
const calls = { config: 0, plugins: 0, assertCurrent: 0, configLocations: [] as string[], pluginLocations: [] as string[] }
|
||||
const client = {
|
||||
config: { get: async (input: any) => { calls.config++; calls.configLocations.push(input.location.directory); return options.entries } },
|
||||
plugin: { list: async (input: any) => { calls.plugins++; calls.pluginLocations.push(input.location.directory); return { location: { directory: "unused" }, data: options.runtime } } },
|
||||
}
|
||||
return {
|
||||
calls,
|
||||
get: (id: string) => id === "workspace" ? { id } : undefined,
|
||||
getSharedServiceConnection: async () => ({
|
||||
client,
|
||||
assertCurrent: () => {
|
||||
calls.assertCurrent++
|
||||
options.assertCurrent?.(calls.assertCurrent)
|
||||
},
|
||||
}),
|
||||
ownsLocation: async () => options.ownsLocation !== false,
|
||||
getServiceDirectoryForPath: async (_id: string, directory: string) => options.serviceDirectory ?? directory,
|
||||
getWorktreeIdentityForPath: async () => "fixture-worktree",
|
||||
getServicePathStyle: () => options.style ?? (process.platform === "win32" ? "win32" : "posix"),
|
||||
getServiceWslDistro: () => options.wslDistro,
|
||||
getHostPathForServicePath: async (_id: string, servicePath: string) => options.translate?.(servicePath) ?? servicePath,
|
||||
}
|
||||
}
|
||||
|
||||
function activePlugin(id: string, target: string) {
|
||||
return { id, source: { type: "package", target }, features: { server: true }, state: { status: "active" } }
|
||||
}
|
||||
|
||||
function logger() {
|
||||
return { info() {}, warn() {}, error() {}, debug() {} } as any
|
||||
}
|
||||
|
||||
function openFence() {
|
||||
return { enter: () => () => {} }
|
||||
}
|
||||
|
||||
function temporaryDirectory(): string {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-plugin-controls-"))
|
||||
temporaryDirectories.add(directory)
|
||||
return directory
|
||||
}
|
||||
|
||||
function deferred<T = void>(): { promise: Promise<T>; resolve: (value: T | PromiseLike<T>) => void } {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void
|
||||
const promise = new Promise<T>((accept) => { resolve = accept })
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function tick(): Promise<void> {
|
||||
return new Promise((resolve) => setImmediate(resolve))
|
||||
}
|
||||
764
packages/server/src/opencode/plugin-controls.ts
Normal file
764
packages/server/src/opencode/plugin-controls.ts
Normal file
|
|
@ -0,0 +1,764 @@
|
|||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import type { ConfigEntry, LocationRef, PluginInfo } from "@opencode/client"
|
||||
import type {
|
||||
PluginActivationControl,
|
||||
PluginActivationMutationRequest,
|
||||
PluginActivationMutationResponse,
|
||||
PluginConfigScope,
|
||||
PluginConfiguredRule,
|
||||
PluginConfiguredSource,
|
||||
PluginControlLocation,
|
||||
PluginControlScope,
|
||||
PluginControlTarget,
|
||||
PluginControlsSnapshot,
|
||||
PluginRuntimeInventoryEntry,
|
||||
PluginRuntimeSource,
|
||||
PluginScopeRuleState,
|
||||
} from "../api-types"
|
||||
import type { Logger } from "../logger"
|
||||
import type { WorkspaceManager } from "../workspaces/manager"
|
||||
import type { ServiceConnection } from "../workspaces/opencode-service"
|
||||
import { locationRequestOptions } from "./compatibility/location"
|
||||
import {
|
||||
appendPluginControlRule,
|
||||
hostPluginControlDocumentFileSystem,
|
||||
readPluginControlDocument,
|
||||
replacePluginControlDocument,
|
||||
type PluginConfigEntry,
|
||||
type PluginControlDocumentFileSystem,
|
||||
PluginControlDocumentError,
|
||||
} from "./plugin-control-document"
|
||||
import { createWslPluginControlDocumentFileSystem } from "./plugin-control-document-wsl"
|
||||
|
||||
type PluginControlsWorkspaceManager = Pick<WorkspaceManager,
|
||||
| "get"
|
||||
| "getSharedServiceConnection"
|
||||
| "ownsLocation"
|
||||
| "getServiceDirectoryForPath"
|
||||
| "getWorktreeIdentityForPath"
|
||||
| "getServicePathStyle"
|
||||
| "getServiceWslDistro"
|
||||
| "getHostPathForServicePath"
|
||||
>
|
||||
|
||||
interface PluginControlsMutationFence {
|
||||
enter(identities: string[]): (() => void) | undefined
|
||||
}
|
||||
|
||||
type PathStyle = "win32" | "posix"
|
||||
|
||||
interface ReadContext {
|
||||
connection: ServiceConnection
|
||||
entries: ConfigEntry[]
|
||||
runtime: PluginInfo[]
|
||||
location: PluginControlLocation
|
||||
paths: path.PlatformPath
|
||||
style: PathStyle
|
||||
globalDirectory: string
|
||||
targets: ResolvedTarget[]
|
||||
fileSystem: PluginControlDocumentFileSystem
|
||||
}
|
||||
|
||||
interface ResolvedTarget extends PluginControlTarget {
|
||||
ioPath: string
|
||||
}
|
||||
|
||||
interface ConfigDocumentView {
|
||||
path?: string
|
||||
scope: PluginConfigScope
|
||||
plugins: readonly PluginConfigEntry[]
|
||||
order: number
|
||||
}
|
||||
|
||||
export type PluginControlsErrorKind = "not-found" | "forbidden" | "invalid" | "conflict" | "unavailable"
|
||||
|
||||
export class PluginControlsError extends Error {
|
||||
readonly cause?: unknown
|
||||
|
||||
constructor(message: string, readonly kind: PluginControlsErrorKind, options?: { cause?: unknown }) {
|
||||
super(message)
|
||||
this.name = "PluginControlsError"
|
||||
if (options && "cause" in options) this.cause = options.cause
|
||||
}
|
||||
}
|
||||
|
||||
export class PluginControls {
|
||||
private readonly mutationTails = new Map<string, Promise<void>>()
|
||||
|
||||
constructor(private readonly options: {
|
||||
workspaceManager: PluginControlsWorkspaceManager
|
||||
worktreeDeletionFence: PluginControlsMutationFence
|
||||
logger: Logger
|
||||
}) {}
|
||||
|
||||
async read(workspaceId: string, location: PluginControlLocation): Promise<PluginControlsSnapshot> {
|
||||
const context = await this.readContext(workspaceId, location)
|
||||
return buildSnapshot(context)
|
||||
}
|
||||
|
||||
mutate(workspaceId: string, request: PluginActivationMutationRequest): Promise<PluginActivationMutationResponse> {
|
||||
// Ordering is only required per write target. The global document is
|
||||
// daemon-wide for one workspace, so all of its global writes share one
|
||||
// queue regardless of which worktree directory requested them; project
|
||||
// writes queue per normalized directory. A fully file-identity-based key
|
||||
// would need async resolution, so a residual cross-workspace global race
|
||||
// stays possible and remains fail-closed with a retryable conflict.
|
||||
const normalizedDirectory = request.location.directory.trim().replace(/[\\/]+$/, "").toLowerCase()
|
||||
const tailKey = request.scope === "global"
|
||||
? `${workspaceId}\nglobal`
|
||||
: `${workspaceId}\nproject\n${normalizedDirectory}`
|
||||
return this.serializeMutation(tailKey, async () => {
|
||||
const context = await this.readContext(workspaceId, request.location)
|
||||
const target = context.targets.find((candidate) => candidate.scope === request.scope)
|
||||
if (!target) throw new PluginControlsError("OpenCode configuration scope is unavailable", "unavailable")
|
||||
const identity = await this.options.workspaceManager.getWorktreeIdentityForPath(workspaceId, context.location.directory)
|
||||
if (!identity) throw new PluginControlsError("Location is not owned by an active worktree", "forbidden")
|
||||
const release = this.options.worktreeDeletionFence.enter([identity])
|
||||
if (!release) throw new PluginControlsError("Worktree deletion is in progress", "conflict")
|
||||
|
||||
try {
|
||||
let document
|
||||
try {
|
||||
document = await readPluginControlDocument(target.ioPath, context.fileSystem)
|
||||
} catch (error) {
|
||||
throw mapDocumentError(error)
|
||||
}
|
||||
// The daemon's ConfigEntry values have already applied environment and
|
||||
// config substitutions. Keep those values authoritative for inventory
|
||||
// and state; the raw document is used only for conflict-safe editing.
|
||||
// The base is built once: authorization, projection, and both
|
||||
// snapshots derive from the same documents and runtime inventory.
|
||||
const base = snapshotBase(context)
|
||||
const authoritative = controlsForDocuments(base, context)
|
||||
const projection = mutationTargetPlugins(
|
||||
base.documents,
|
||||
context.paths,
|
||||
target.path,
|
||||
document.plugins,
|
||||
authoritative.controls.map((candidate) => candidate.id),
|
||||
)
|
||||
const before = controlsForDocuments(base, context, {
|
||||
servicePath: target.path,
|
||||
plugins: projection.plugins,
|
||||
scope: request.scope,
|
||||
})
|
||||
const control = before.controls.find((candidate) => candidate.id === request.pluginId)
|
||||
if (!control) throw new PluginControlsError("Plugin ID is not present in the authorized inventory", "forbidden")
|
||||
|
||||
const desired: PluginScopeRuleState = request.enabled ? "enabled" : "disabled"
|
||||
assertCurrentConnection(context.connection)
|
||||
if (projection.noOpSafe && control[request.scope] === desired) {
|
||||
return {
|
||||
snapshot: before,
|
||||
rule: request.enabled ? request.pluginId : `-${request.pluginId}`,
|
||||
target: publicTarget(target),
|
||||
changed: false,
|
||||
reloadPending: false,
|
||||
}
|
||||
}
|
||||
|
||||
const rule = request.enabled ? request.pluginId : `-${request.pluginId}`
|
||||
const updated = appendPluginControlRule(document, rule)
|
||||
try {
|
||||
await replacePluginControlDocument(document, updated, {
|
||||
beforeCommit: () => assertCurrentConnection(context.connection),
|
||||
}, context.fileSystem)
|
||||
} catch (error) {
|
||||
throw mapDocumentError(error)
|
||||
}
|
||||
target.exists = true
|
||||
const snapshot = controlsForDocuments(base, context, {
|
||||
servicePath: target.path,
|
||||
plugins: [...projection.plugins, rule],
|
||||
scope: request.scope,
|
||||
})
|
||||
this.options.logger.info({ workspaceId, scope: request.scope, pluginId: request.pluginId }, "Updated OpenCode plugin activation rule")
|
||||
return { snapshot, rule, target: publicTarget(target), changed: true, reloadPending: true }
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async readContext(workspaceId: string, input: PluginControlLocation): Promise<ReadContext> {
|
||||
if (!this.options.workspaceManager.get(workspaceId)) {
|
||||
throw new PluginControlsError("Workspace not found", "not-found")
|
||||
}
|
||||
const requestedLocation = normalizeLocation(input)
|
||||
const connection = await this.options.workspaceManager.getSharedServiceConnection(workspaceId)
|
||||
if (!connection) throw new PluginControlsError("OpenCode service is unavailable", "unavailable")
|
||||
const serviceDirectory = await this.options.workspaceManager.getServiceDirectoryForPath(
|
||||
workspaceId,
|
||||
requestedLocation.directory,
|
||||
)
|
||||
if (!serviceDirectory) throw new PluginControlsError("Location is not owned by this workspace", "forbidden")
|
||||
const location = { ...requestedLocation, directory: serviceDirectory }
|
||||
if (!await this.options.workspaceManager.ownsLocation(workspaceId, location, connection.client)) {
|
||||
throw new PluginControlsError("Location is not owned by this workspace", "forbidden")
|
||||
}
|
||||
|
||||
const requestLocation = { directory: location.directory }
|
||||
const requestOptions = locationRequestOptions(location)
|
||||
let entries: ConfigEntry[]
|
||||
let runtime: PluginInfo[]
|
||||
try {
|
||||
const [configOutput, pluginOutput] = await Promise.all([
|
||||
connection.client.config.get({ location: requestLocation }, requestOptions),
|
||||
connection.client.plugin.list({ location: requestLocation }, requestOptions),
|
||||
])
|
||||
connection.assertCurrent()
|
||||
if (!Array.isArray(configOutput) || !pluginOutput || !Array.isArray(pluginOutput.data)) {
|
||||
throw new Error("Unexpected OpenCode plugin controls response")
|
||||
}
|
||||
entries = configOutput
|
||||
runtime = pluginOutput.data
|
||||
} catch (error) {
|
||||
if (error instanceof PluginControlsError) throw error
|
||||
throw new PluginControlsError("Unable to read OpenCode plugin configuration", "unavailable", { cause: error })
|
||||
}
|
||||
|
||||
const style = this.options.workspaceManager.getServicePathStyle(workspaceId)
|
||||
if (!style) throw new PluginControlsError("Workspace path context is unavailable", "unavailable")
|
||||
const paths = style === "win32" ? path.win32 : path.posix
|
||||
const globalDirectory = entries.find((entry) => entry?.type === "directory")?.path
|
||||
if (typeof globalDirectory !== "string" || !validAbsolutePath(paths, globalDirectory)) {
|
||||
throw new PluginControlsError("OpenCode did not report its global configuration directory", "unavailable")
|
||||
}
|
||||
if (!validAbsolutePath(paths, location.directory)) {
|
||||
throw new PluginControlsError("OpenCode location directory is invalid", "invalid")
|
||||
}
|
||||
const distro = this.options.workspaceManager.getServiceWslDistro(workspaceId)
|
||||
const fileSystem = distro
|
||||
? createWslPluginControlDocumentFileSystem(distro)
|
||||
: hostPluginControlDocumentFileSystem
|
||||
const targets = await this.resolveTargets(workspaceId, paths, globalDirectory, location.directory, entries, fileSystem, Boolean(distro))
|
||||
assertCurrentConnection(connection)
|
||||
return { connection, entries, runtime, location, paths, style, globalDirectory, targets, fileSystem }
|
||||
}
|
||||
|
||||
private async resolveTargets(
|
||||
workspaceId: string,
|
||||
paths: path.PlatformPath,
|
||||
globalDirectory: string,
|
||||
projectDirectory: string,
|
||||
entries: readonly ConfigEntry[],
|
||||
fileSystem: PluginControlDocumentFileSystem,
|
||||
nativeWsl: boolean,
|
||||
): Promise<ResolvedTarget[]> {
|
||||
const [globalIoRoot, projectIoRoot] = nativeWsl
|
||||
? [globalDirectory, projectDirectory]
|
||||
: await Promise.all([
|
||||
this.options.workspaceManager.getHostPathForServicePath(workspaceId, globalDirectory),
|
||||
this.options.workspaceManager.getHostPathForServicePath(workspaceId, projectDirectory),
|
||||
])
|
||||
if (!globalIoRoot || !projectIoRoot) {
|
||||
throw new PluginControlsError("OpenCode configuration paths could not be mapped to the execution host", "unavailable")
|
||||
}
|
||||
const ioPaths = nativeWsl ? path.posix : hostPathStyle()
|
||||
if (!ioPaths.isAbsolute(globalIoRoot) || !ioPaths.isAbsolute(projectIoRoot)) {
|
||||
throw new PluginControlsError("OpenCode configuration paths could not be mapped to the execution host", "unavailable")
|
||||
}
|
||||
const globalCandidates = targetCandidates("global", paths, globalDirectory, globalIoRoot, ioPaths)
|
||||
const projectCandidates = targetCandidates("project", paths, projectDirectory, projectIoRoot, ioPaths)
|
||||
let inspected: Array<{ writePath: string; exists: boolean }>
|
||||
try {
|
||||
inspected = await fileSystem.inspectMany([...globalCandidates, ...projectCandidates].map((candidate) => candidate.ioPath))
|
||||
} catch (error) {
|
||||
throw mapDocumentError(error)
|
||||
}
|
||||
const globalTarget = selectTarget("global", globalCandidates, inspected.slice(0, globalCandidates.length))
|
||||
// Every inherited .opencode document follows every direct document. Keep
|
||||
// the write inside this location, creating its higher-precedence layer
|
||||
// when an existing direct file cannot override the inherited rules.
|
||||
const inheritedProjectLayer = entries.some((entry) => {
|
||||
if (entry.type !== "document" || !entry.path) return false
|
||||
const directory = paths.dirname(entry.path)
|
||||
const root = paths.dirname(directory)
|
||||
return paths.basename(directory) === ".opencode" && !samePath(paths, directory, globalDirectory)
|
||||
&& !samePath(paths, root, projectDirectory) && containsPath(paths, root, projectDirectory)
|
||||
})
|
||||
const projectInspected = inspected.slice(globalCandidates.length)
|
||||
const projectTarget = inheritedProjectLayer
|
||||
? selectTarget("project", projectCandidates.slice(2), projectInspected.slice(2))
|
||||
: selectTarget("project", projectCandidates, projectInspected)
|
||||
// An opened global config directory can make the direct project candidate
|
||||
// resolve to the global document. Never expose two scopes backed by one
|
||||
// file: a Project write would otherwise mutate Global configuration.
|
||||
return samePath(paths, globalDirectory, projectDirectory)
|
||||
|| samePath(ioPaths, globalTarget.ioPath, projectTarget.ioPath)
|
||||
// A symlinked fallback must not turn a location-local override into an
|
||||
// edit of the ancestor/shared configuration it was meant to override.
|
||||
|| (inheritedProjectLayer && !containsPath(ioPaths, projectIoRoot, projectTarget.ioPath))
|
||||
? [globalTarget]
|
||||
: [globalTarget, projectTarget]
|
||||
}
|
||||
|
||||
private serializeMutation<T>(key: string, operation: () => Promise<T>): Promise<T> {
|
||||
const tail = this.mutationTails.get(key) ?? Promise.resolve()
|
||||
const result = tail.then(operation, operation)
|
||||
const tracked = result.then(() => undefined, () => undefined)
|
||||
this.mutationTails.set(key, tracked)
|
||||
const forget = () => { if (this.mutationTails.get(key) === tracked) this.mutationTails.delete(key) }
|
||||
result.then(forget, forget)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
interface SnapshotBase {
|
||||
runtime: PluginRuntimeInventoryEntry[]
|
||||
runtimeIds: Set<string>
|
||||
runtimeSourceIds: Map<string, string>
|
||||
runtimeById: Map<string, PluginRuntimeInventoryEntry>
|
||||
documents: ConfigDocumentView[]
|
||||
sourceOrders: number[]
|
||||
}
|
||||
|
||||
const controlIdCollator = new Intl.Collator("en", { sensitivity: "variant" })
|
||||
|
||||
function snapshotBase(context: ReadContext): SnapshotBase {
|
||||
const runtime = context.runtime.map(normalizeRuntimeEntry)
|
||||
const runtimeIds = new Set<string>()
|
||||
const runtimeSourceIds = new Map<string, string>()
|
||||
const runtimeById = new Map<string, PluginRuntimeInventoryEntry>()
|
||||
for (const entry of runtime) {
|
||||
if (!entry.id) continue
|
||||
// Duplicate IDs are invalid daemon output; keep first-wins attachment so a
|
||||
// repeated entry cannot displace the inventory record already published.
|
||||
if (!runtimeById.has(entry.id)) runtimeById.set(entry.id, entry)
|
||||
runtimeIds.add(entry.id)
|
||||
if (entry.source.type === "package") runtimeSourceIds.set(entry.source.target, entry.id)
|
||||
else if (entry.source.type === "local") runtimeSourceIds.set(entry.source.path, entry.id)
|
||||
}
|
||||
return { runtime, runtimeIds, runtimeSourceIds, runtimeById, ...configDocuments(context) }
|
||||
}
|
||||
|
||||
function buildSnapshot(
|
||||
context: ReadContext,
|
||||
overlay?: { servicePath: string; plugins: readonly PluginConfigEntry[]; scope: PluginControlScope },
|
||||
): PluginControlsSnapshot {
|
||||
return controlsForDocuments(snapshotBase(context), context, overlay)
|
||||
}
|
||||
|
||||
function controlsForDocuments(
|
||||
base: SnapshotBase,
|
||||
context: ReadContext,
|
||||
overlay?: { servicePath: string; plugins: readonly PluginConfigEntry[]; scope: PluginControlScope },
|
||||
): PluginControlsSnapshot {
|
||||
const { runtime, runtimeIds, runtimeSourceIds, runtimeById } = base
|
||||
const documents = overlay ? applyOverlay(base, context, overlay) : base.documents
|
||||
const knownDefinitions = new Set(runtimeIds)
|
||||
const declaredTargets = new Set<string>()
|
||||
const rules: PluginConfiguredRule[] = []
|
||||
const sources: PluginConfiguredSource[] = []
|
||||
for (const document of documents) {
|
||||
document.plugins.forEach((entry, entryIndex) => {
|
||||
if (typeof entry !== "string") {
|
||||
sources.push({ target: entry.package, scope: document.scope, path: document.path, entryIndex, hasOptions: entry.options !== undefined })
|
||||
const runtimeId = runtimeIdForConfiguredTarget(context, document, entry.package, runtimeById, runtimeSourceIds)
|
||||
const isFirstRuntimeSourceDeclaration = Boolean(runtimeId && !declaredTargets.has(entry.package))
|
||||
const selectsPlugin = entry.package.startsWith("opencode.")
|
||||
|| (!isFirstRuntimeSourceDeclaration && knownDefinitions.has(entry.package))
|
||||
if (selectsPlugin) {
|
||||
rules.push({ selector: entry.package, enabled: true, scope: document.scope, path: document.path, order: document.order, entryIndex })
|
||||
} else if (runtimeId && rules.some((rule) => !rule.enabled && matchesSelector(rule.selector, runtimeId))) {
|
||||
// Every object entry is an ordered add operation. Loading the source
|
||||
// after a matching removal enables its resolved plugin again.
|
||||
rules.push({ selector: runtimeId, enabled: true, scope: document.scope, path: document.path, order: document.order, entryIndex })
|
||||
}
|
||||
declaredTargets.add(entry.package)
|
||||
if (runtimeId) knownDefinitions.add(runtimeId)
|
||||
return
|
||||
}
|
||||
const disabled = entry.startsWith("-")
|
||||
const selector = disabled ? entry.slice(1) : entry
|
||||
if (disabled) {
|
||||
rules.push({ selector, enabled: false, scope: document.scope, path: document.path, order: document.order, entryIndex })
|
||||
if (isExactSelector(selector)) knownDefinitions.add(selector)
|
||||
return
|
||||
}
|
||||
|
||||
const runtimeSourceId = runtimeIdForConfiguredTarget(context, document, selector, runtimeById, runtimeSourceIds)
|
||||
const isFirstRuntimeSourceDeclaration = Boolean(runtimeSourceId && !declaredTargets.has(selector))
|
||||
const selectsPlugin = selector === "*" || selector.endsWith(".*") || selector.startsWith("opencode.")
|
||||
|| (!isFirstRuntimeSourceDeclaration && knownDefinitions.has(selector))
|
||||
if (!selectsPlugin) {
|
||||
sources.push({ target: selector, scope: document.scope, path: document.path, entryIndex, hasOptions: false })
|
||||
declaredTargets.add(selector)
|
||||
if (runtimeSourceId) {
|
||||
if (rules.some((rule) => !rule.enabled && matchesSelector(rule.selector, runtimeSourceId))) {
|
||||
rules.push({ selector: runtimeSourceId, enabled: true, scope: document.scope, path: document.path, order: document.order, entryIndex })
|
||||
}
|
||||
knownDefinitions.add(runtimeSourceId)
|
||||
}
|
||||
return
|
||||
}
|
||||
rules.push({ selector, enabled: true, scope: document.scope, path: document.path, order: document.order, entryIndex })
|
||||
})
|
||||
}
|
||||
|
||||
const ids = new Set<string>(runtimeIds)
|
||||
for (const rule of rules) {
|
||||
if (isExactSelector(rule.selector)) ids.add(rule.selector)
|
||||
}
|
||||
const controls = [...ids].filter(validPluginId).sort(controlIdCollator.compare).map((id) => {
|
||||
const matching = rules.filter((rule) => matchesSelector(rule.selector, id))
|
||||
const controllingRule = matching.at(-1)
|
||||
const runtimeEntry = runtimeById.get(id)
|
||||
return {
|
||||
id,
|
||||
...(runtimeEntry ? { runtime: runtimeEntry } : {}),
|
||||
builtin: runtimeEntry?.source.type === "builtin" || id.startsWith("opencode."),
|
||||
effective: stateForRule(controllingRule),
|
||||
global: stateForRule(matching.filter((rule) => rule.scope === "global").at(-1)),
|
||||
project: stateForRule(matching.filter((rule) => rule.scope === "project").at(-1)),
|
||||
...(controllingRule ? { controllingRule } : {}),
|
||||
} satisfies PluginActivationControl
|
||||
})
|
||||
return {
|
||||
location: context.location,
|
||||
runtime,
|
||||
configured: { rules, sources },
|
||||
controls,
|
||||
targets: context.targets.map(publicTarget),
|
||||
}
|
||||
}
|
||||
|
||||
function mutationTargetPlugins(
|
||||
documents: readonly ConfigDocumentView[],
|
||||
paths: path.PlatformPath,
|
||||
servicePath: string,
|
||||
rawPlugins: readonly PluginConfigEntry[],
|
||||
authorizedIds: readonly string[],
|
||||
): { plugins: readonly PluginConfigEntry[]; noOpSafe: boolean } {
|
||||
const normalized = documents
|
||||
.filter((document) => Boolean(document.path && samePath(paths, document.path, servicePath)))
|
||||
.at(-1)?.plugins ?? []
|
||||
let noOpSafe = !rawPlugins.some(containsConfigVariable)
|
||||
let normalizedIndex = 0
|
||||
const pendingRules: string[] = []
|
||||
for (const entry of rawPlugins) {
|
||||
if (containsConfigVariable(entry)) {
|
||||
if (normalizedIndex < normalized.length) normalizedIndex += 1
|
||||
continue
|
||||
}
|
||||
if (normalizedIndex < normalized.length) {
|
||||
if (pluginEntriesEqual(entry, normalized[normalizedIndex])) normalizedIndex += 1
|
||||
else noOpSafe = false
|
||||
continue
|
||||
}
|
||||
if (typeof entry !== "string") {
|
||||
noOpSafe = false
|
||||
continue
|
||||
}
|
||||
const selector = entry.startsWith("-") ? entry.slice(1) : entry
|
||||
if (!selector || !authorizedIds.some((id) => matchesSelector(selector, id))) {
|
||||
noOpSafe = false
|
||||
continue
|
||||
}
|
||||
pendingRules.push(entry)
|
||||
}
|
||||
// Only a completely matched prefix followed by replayed concrete rules can
|
||||
// prove a durable no-op. Deletions/replacements during watcher lag cannot.
|
||||
return { plugins: [...normalized, ...pendingRules], noOpSafe: noOpSafe && normalizedIndex === normalized.length }
|
||||
}
|
||||
|
||||
function pluginEntriesEqual(left: PluginConfigEntry, right: PluginConfigEntry | undefined): boolean {
|
||||
if (typeof left !== typeof right) return false
|
||||
if (typeof left === "string" || typeof right === "string") return left === right
|
||||
if (left.package !== (right as { package: string }).package) return false
|
||||
const leftOptions = (left as { options?: Record<string, unknown> }).options
|
||||
const rightOptions = (right as { options?: Record<string, unknown> }).options
|
||||
if (leftOptions === rightOptions) return true
|
||||
if (!leftOptions || !rightOptions) return false
|
||||
// Options objects are user-controlled and unbounded; only pay for a deep
|
||||
// walk when the cheap package and key-count checks already match.
|
||||
if (Object.keys(leftOptions).length !== Object.keys(rightOptions).length) return false
|
||||
return isDeepStrictEqual(leftOptions, rightOptions)
|
||||
}
|
||||
|
||||
function containsConfigVariable(value: unknown): boolean {
|
||||
if (typeof value === "string") return /\{(?:env|file):[^}]+\}/.test(value)
|
||||
if (Array.isArray(value)) return value.some(containsConfigVariable)
|
||||
return isRecord(value) && Object.values(value).some(containsConfigVariable)
|
||||
}
|
||||
|
||||
function configDocuments(
|
||||
context: ReadContext,
|
||||
overlay?: { servicePath: string; plugins: readonly PluginConfigEntry[]; scope: PluginControlScope },
|
||||
): { documents: ConfigDocumentView[]; sourceOrders: number[] } {
|
||||
const documents: Array<Omit<ConfigDocumentView, "order"> & { sourceOrder: number }> = []
|
||||
let overlaid = false
|
||||
let targetDirectoryOrder: number | undefined
|
||||
context.entries.forEach((entry, sourceOrder) => {
|
||||
if (overlay && entry?.type === "directory" && samePath(context.paths, entry.path, context.paths.dirname(overlay.servicePath))) {
|
||||
targetDirectoryOrder = sourceOrder
|
||||
}
|
||||
if (entry?.type !== "document") return
|
||||
const servicePath = typeof entry.path === "string" ? entry.path : undefined
|
||||
const matchesOverlay = Boolean(overlay && servicePath && samePath(context.paths, servicePath, overlay.servicePath))
|
||||
if (matchesOverlay) overlaid = true
|
||||
documents.push({
|
||||
path: servicePath,
|
||||
scope: matchesOverlay ? overlay!.scope : classifyScope(context, servicePath),
|
||||
plugins: matchesOverlay ? overlay!.plugins : normalizePluginEntries(entry.info?.plugins),
|
||||
sourceOrder,
|
||||
})
|
||||
})
|
||||
if (overlay && !overlaid) {
|
||||
const lastPhysicalOrder = context.entries.reduce((latest, entry, index) => (
|
||||
typeof entry?.path === "string" ? index : latest
|
||||
), -1)
|
||||
documents.push({
|
||||
path: overlay.servicePath,
|
||||
scope: overlay.scope,
|
||||
plugins: overlay.plugins,
|
||||
sourceOrder: targetDirectoryOrder === undefined ? lastPhysicalOrder + 0.5 : targetDirectoryOrder - 0.5,
|
||||
})
|
||||
}
|
||||
const sorted = documents
|
||||
.sort((left, right) => left.sourceOrder - right.sourceOrder)
|
||||
return {
|
||||
documents: sorted.map(({ sourceOrder: _sourceOrder, ...document }, order) => ({ ...document, order })),
|
||||
sourceOrders: sorted.map((document) => document.sourceOrder),
|
||||
}
|
||||
}
|
||||
|
||||
function applyOverlay(
|
||||
base: SnapshotBase,
|
||||
context: ReadContext,
|
||||
overlay: { servicePath: string; plugins: readonly PluginConfigEntry[]; scope: PluginControlScope },
|
||||
): ConfigDocumentView[] {
|
||||
const matched = base.documents.some((document) => Boolean(document.path && samePath(context.paths, document.path, overlay.servicePath)))
|
||||
if (matched) {
|
||||
return base.documents.map((document) => (
|
||||
document.path && samePath(context.paths, document.path, overlay.servicePath)
|
||||
? { ...document, scope: overlay.scope, plugins: overlay.plugins }
|
||||
: document
|
||||
))
|
||||
}
|
||||
// The overlay target has no daemon document yet (missing file or virtual
|
||||
// precedence): insert it where configDocuments would have placed it so
|
||||
// derived snapshots keep daemon precedence without re-walking entries.
|
||||
let targetDirectoryOrder: number | undefined
|
||||
let lastPhysicalOrder = -1
|
||||
context.entries.forEach((entry, sourceOrder) => {
|
||||
if (entry?.type === "directory" && samePath(context.paths, entry.path, context.paths.dirname(overlay.servicePath))) {
|
||||
targetDirectoryOrder = sourceOrder
|
||||
}
|
||||
if (typeof entry?.path === "string") lastPhysicalOrder = sourceOrder
|
||||
})
|
||||
const overlaySourceOrder = targetDirectoryOrder === undefined ? lastPhysicalOrder + 0.5 : targetDirectoryOrder - 0.5
|
||||
const insertAt = base.sourceOrders.filter((order) => order < overlaySourceOrder).length
|
||||
return [
|
||||
...base.documents.slice(0, insertAt),
|
||||
{ path: overlay.servicePath, scope: overlay.scope, plugins: overlay.plugins, order: insertAt },
|
||||
...base.documents.slice(insertAt).map((document) => ({ ...document, order: document.order + 1 })),
|
||||
]
|
||||
}
|
||||
|
||||
function classifyScope(context: ReadContext, servicePath?: string): PluginConfigScope {
|
||||
if (!servicePath) return "virtual"
|
||||
const parent = context.paths.dirname(servicePath)
|
||||
if (samePath(context.paths, parent, context.globalDirectory)) return "global"
|
||||
const configRoot = context.paths.basename(parent) === ".opencode" ? context.paths.dirname(parent) : parent
|
||||
return containsPath(context.paths, configRoot, context.location.directory) ? "project" : "other"
|
||||
}
|
||||
|
||||
interface TargetCandidate {
|
||||
servicePath: string
|
||||
ioPath: string
|
||||
}
|
||||
|
||||
function targetCandidates(
|
||||
scope: PluginControlScope,
|
||||
servicePaths: path.PlatformPath,
|
||||
serviceRoot: string,
|
||||
ioRoot: string,
|
||||
ioPaths: path.PlatformPath,
|
||||
): TargetCandidate[] {
|
||||
const relativeCandidates = scope === "global"
|
||||
? ["opencode.json", "opencode.jsonc"]
|
||||
: ["opencode.json", "opencode.jsonc", servicePaths.join(".opencode", "opencode.json"), servicePaths.join(".opencode", "opencode.jsonc")]
|
||||
return relativeCandidates.map((relative) => ({
|
||||
servicePath: servicePaths.join(serviceRoot, relative),
|
||||
ioPath: ioPaths.join(ioRoot, ...relative.split(/[\\/]/)),
|
||||
}))
|
||||
}
|
||||
|
||||
function selectTarget(
|
||||
scope: PluginControlScope,
|
||||
candidates: readonly TargetCandidate[],
|
||||
inspected: readonly { writePath: string; exists: boolean }[],
|
||||
): ResolvedTarget {
|
||||
if (candidates.length !== inspected.length) {
|
||||
throw new PluginControlsError("OpenCode configuration target inspection was incomplete", "unavailable")
|
||||
}
|
||||
let selected = candidates.at(-1)!
|
||||
let exists = false
|
||||
candidates.forEach((candidate, index) => {
|
||||
const result = inspected[index]!
|
||||
if (result.exists) {
|
||||
selected = { ...candidate, ioPath: result.writePath }
|
||||
exists = true
|
||||
}
|
||||
})
|
||||
if (!exists) {
|
||||
selected = { ...selected, ioPath: inspected.at(-1)!.writePath }
|
||||
}
|
||||
return { scope, path: selected.servicePath, ioPath: selected.ioPath, exists }
|
||||
}
|
||||
|
||||
function normalizeRuntimeEntry(plugin: PluginInfo, index: number): PluginRuntimeInventoryEntry {
|
||||
if (!isRecord(plugin) || !isRecord(plugin.state)) {
|
||||
throw new PluginControlsError("OpenCode returned an invalid plugin inventory", "unavailable")
|
||||
}
|
||||
const source = normalizeRuntimeSource(plugin.source)
|
||||
const id = typeof plugin?.id === "string" && validPluginId(plugin.id) ? plugin.id : undefined
|
||||
const features = isRecord(plugin?.features) ? {
|
||||
...(plugin.features.server === true ? { server: true as const } : {}),
|
||||
...(plugin.features.tui === true ? { tui: true as const } : {}),
|
||||
...(plugin.features.rpc === true ? { rpc: true as const } : {}),
|
||||
} : {}
|
||||
const state = plugin.state.status === "active"
|
||||
? { status: "active" as const }
|
||||
: plugin.state.status === "failed" && typeof plugin.state.error === "string"
|
||||
? { status: "failed" as const, error: plugin.state.error, ...(typeof plugin.state.ref === "string" ? { ref: plugin.state.ref } : {}) }
|
||||
: undefined
|
||||
if (!state) throw new PluginControlsError("OpenCode returned an invalid plugin inventory", "unavailable")
|
||||
return { key: id ?? `${source.type}:${sourceDetail(source)}:${index}`, ...(id ? { id } : {}), source, features, state }
|
||||
}
|
||||
|
||||
function normalizeRuntimeSource(source: unknown): PluginRuntimeSource {
|
||||
if (!isRecord(source)) throw new PluginControlsError("OpenCode returned an invalid plugin source", "unavailable")
|
||||
if (source.type === "builtin") return { type: "builtin" }
|
||||
if (source.type === "sdk") return { type: "sdk" }
|
||||
if (source.type === "local" && typeof source.path === "string" && source.path) return { type: "local", path: source.path }
|
||||
if (source.type === "package" && typeof source.target === "string" && source.target) return {
|
||||
type: "package",
|
||||
target: source.target,
|
||||
...(typeof source.version === "string" ? { version: source.version } : {}),
|
||||
...(source.outdated === true ? { outdated: true as const } : {}),
|
||||
...(source.updating === true ? { updating: true as const } : {}),
|
||||
}
|
||||
throw new PluginControlsError("OpenCode returned an invalid plugin source", "unavailable")
|
||||
}
|
||||
|
||||
function normalizePluginEntries(input: unknown): PluginConfigEntry[] {
|
||||
if (!Array.isArray(input)) return []
|
||||
const output: PluginConfigEntry[] = []
|
||||
for (const entry of input) {
|
||||
if (typeof entry === "string") output.push(entry)
|
||||
else if (isRecord(entry) && typeof entry.package === "string") {
|
||||
output.push({ package: entry.package, ...(isRecord(entry.options) ? { options: entry.options } : {}) })
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
function runtimeIdForConfiguredTarget(
|
||||
context: ReadContext,
|
||||
document: ConfigDocumentView,
|
||||
target: string,
|
||||
runtimeById: ReadonlyMap<string, PluginRuntimeInventoryEntry>,
|
||||
direct: ReadonlyMap<string, string>,
|
||||
): string | undefined {
|
||||
const exact = direct.get(target)
|
||||
if (exact) return exact
|
||||
let localTarget: string | undefined
|
||||
try {
|
||||
if (target.startsWith("file://")) {
|
||||
localTarget = fileURLToPath(target, { windows: context.style === "win32" })
|
||||
} else if (target.startsWith("./") || target.startsWith("../")) {
|
||||
localTarget = context.paths.resolve(document.path ? context.paths.dirname(document.path) : context.location.directory, target)
|
||||
} else if (context.paths.isAbsolute(target)) {
|
||||
localTarget = target
|
||||
}
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
if (!localTarget) return undefined
|
||||
for (const entry of runtimeById.values()) {
|
||||
if (!entry.id || entry.source.type !== "local") continue
|
||||
if (samePath(context.paths, localTarget, entry.source.path)
|
||||
|| containsPath(context.paths, localTarget, entry.source.path)) return entry.id
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function normalizeLocation(input: PluginControlLocation): LocationRef & PluginControlLocation {
|
||||
const directory = input.directory?.trim()
|
||||
const workspaceID = input.workspaceID?.trim()
|
||||
if (!directory || directory.includes("\0") || (input.workspaceID !== undefined && !workspaceID)) {
|
||||
throw new PluginControlsError("Invalid OpenCode location", "invalid")
|
||||
}
|
||||
return { directory, ...(workspaceID ? { workspaceID } : {}) }
|
||||
}
|
||||
|
||||
function stateForRule(rule: PluginConfiguredRule | undefined): PluginScopeRuleState {
|
||||
return rule ? (rule.enabled ? "enabled" : "disabled") : "default"
|
||||
}
|
||||
|
||||
function matchesSelector(selector: string, pluginId: string): boolean {
|
||||
return selector === "*" || (selector.endsWith(".*") ? pluginId.startsWith(selector.slice(0, -1)) : selector === pluginId)
|
||||
}
|
||||
|
||||
function isExactSelector(selector: string): boolean {
|
||||
return selector !== "*" && !selector.endsWith(".*") && validPluginId(selector)
|
||||
}
|
||||
|
||||
function validPluginId(value: string): boolean {
|
||||
return value.length > 0 && value.length <= 256 && value === value.trim() && !value.startsWith("-")
|
||||
&& !value.includes("*") && !/[\u0000-\u001f\u007f]/.test(value)
|
||||
}
|
||||
|
||||
function validAbsolutePath(paths: path.PlatformPath, value: string): boolean {
|
||||
return paths.isAbsolute(value) && !/[\u0000-\u001f\u007f]/.test(value)
|
||||
}
|
||||
|
||||
function samePath(paths: path.PlatformPath, left: string, right: string): boolean {
|
||||
const normalize = (value: string) => paths.normalize(value).replace(/[\\/]$/, "")
|
||||
const a = normalize(left)
|
||||
const b = normalize(right)
|
||||
return paths === path.win32 ? a.toLowerCase() === b.toLowerCase() : a === b
|
||||
}
|
||||
|
||||
function containsPath(paths: path.PlatformPath, candidate: string, directory: string): boolean {
|
||||
const relative = paths.relative(candidate, directory)
|
||||
return relative === "" || (!relative.startsWith(`..${paths.sep}`) && relative !== ".." && !paths.isAbsolute(relative))
|
||||
}
|
||||
|
||||
function hostPathStyle(): path.PlatformPath {
|
||||
return process.platform === "win32" ? path.win32 : path.posix
|
||||
}
|
||||
|
||||
function sourceDetail(source: PluginRuntimeSource): string {
|
||||
if (source.type === "package") return source.target
|
||||
if (source.type === "local") return source.path
|
||||
return source.type
|
||||
}
|
||||
|
||||
function publicTarget(target: ResolvedTarget): PluginControlTarget {
|
||||
return { scope: target.scope, path: target.path, exists: target.exists }
|
||||
}
|
||||
|
||||
function mapDocumentError(error: unknown): PluginControlsError {
|
||||
if (!(error instanceof PluginControlDocumentError)) {
|
||||
return new PluginControlsError("Unable to update OpenCode configuration", "unavailable", { cause: error })
|
||||
}
|
||||
const kind = error.kind === "invalid" ? "invalid" : error.kind === "conflict" ? "conflict" : "unavailable"
|
||||
return new PluginControlsError(error.message, kind, { cause: error })
|
||||
}
|
||||
|
||||
function assertCurrentConnection(connection: ServiceConnection): void {
|
||||
try {
|
||||
connection.assertCurrent()
|
||||
} catch (error) {
|
||||
throw new PluginControlsError("OpenCode service connection changed", "unavailable", { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, any> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
|
@ -34,6 +34,8 @@ import { registerRemoteProxyRoutes } from "./routes/remote-proxy"
|
|||
import { registerSideCarRoutes } from "./routes/sidecars"
|
||||
import { registerPreviewRoutes } from "./routes/previews"
|
||||
import { registerUsageRoutes } from "./routes/usage"
|
||||
import { registerPluginControlRoutes } from "./routes/plugin-controls"
|
||||
import { PluginControls } from "../opencode/plugin-controls"
|
||||
import { PROMPT_INLINE_FILE_LIMITS, ServerMeta, SESSION_ENVIRONMENT_FAILED_ERROR_CODE } from "../api-types"
|
||||
import { InstanceStore } from "../storage/instance-store"
|
||||
import type { AutoAcceptManager } from "../permissions/auto-accept-manager"
|
||||
|
|
@ -300,6 +302,9 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||
|
||||
const worktreeDeletionFence = new WorktreeDeletionFence()
|
||||
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager, worktreeDeletionFence })
|
||||
registerPluginControlRoutes(app, {
|
||||
controls: new PluginControls({ workspaceManager: deps.workspaceManager, worktreeDeletionFence, logger: apiLogger }),
|
||||
})
|
||||
registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger })
|
||||
registerOpenCodeUpdateRoutes(app, {
|
||||
service: createOpenCodeUpdateService(deps.settings, deps.workspaceManager),
|
||||
|
|
|
|||
77
packages/server/src/server/routes/plugin-controls.test.ts
Normal file
77
packages/server/src/server/routes/plugin-controls.test.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
import Fastify from "fastify"
|
||||
import { PluginControlsError } from "../../opencode/plugin-controls"
|
||||
import { registerPluginControlRoutes } from "./plugin-controls"
|
||||
|
||||
describe("plugin activation control routes", () => {
|
||||
it("passes only a validated workspace location to the narrow read operation", async () => {
|
||||
const calls: unknown[] = []
|
||||
const app = Fastify()
|
||||
registerPluginControlRoutes(app, { controls: {
|
||||
read: async (...args: unknown[]) => { calls.push(args); return snapshot() },
|
||||
mutate: async () => { throw new Error("not used") },
|
||||
} as any })
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/workspaces/workspace/plugin-controls?directory=%2Frepo&workspaceID=native-id",
|
||||
})
|
||||
|
||||
assert.equal(response.statusCode, 200)
|
||||
assert.deepEqual(calls, [["workspace", { directory: "/repo", workspaceID: "native-id" }]])
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it("rejects malformed and excess mutation fields before invoking the operation", async () => {
|
||||
let calls = 0
|
||||
const app = Fastify()
|
||||
registerPluginControlRoutes(app, { controls: {
|
||||
read: async () => snapshot(),
|
||||
mutate: async () => { calls++; return {} },
|
||||
} as any })
|
||||
|
||||
for (const payload of [
|
||||
{ location: { directory: "/repo" }, pluginId: "-invalid", scope: "global", enabled: false },
|
||||
{ location: { directory: "/repo" }, pluginId: "known", scope: "machine", enabled: false },
|
||||
{ location: { directory: "/repo", token: "secret" }, pluginId: "known", scope: "project", enabled: true },
|
||||
{ location: { directory: "/repo" }, pluginId: "known", scope: "project", enabled: true, path: "/other" },
|
||||
]) {
|
||||
const response = await app.inject({ method: "PATCH", url: "/api/workspaces/workspace/plugin-controls", payload })
|
||||
assert.equal(response.statusCode, 400)
|
||||
}
|
||||
assert.equal(calls, 0)
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it("maps ownership, malformed-file, conflict, and availability failures without proxying upstream mutations", async () => {
|
||||
const cases = [
|
||||
["forbidden", 403], ["invalid", 422], ["conflict", 409], ["unavailable", 503], ["not-found", 404],
|
||||
] as const
|
||||
for (const [kind, status] of cases) {
|
||||
const app = Fastify({ logger: false })
|
||||
registerPluginControlRoutes(app, { controls: {
|
||||
read: async () => snapshot(),
|
||||
mutate: async () => { throw new PluginControlsError("controlled failure", kind) },
|
||||
} as any })
|
||||
const response = await app.inject({
|
||||
method: "PATCH",
|
||||
url: "/api/workspaces/workspace/plugin-controls",
|
||||
payload: { location: { directory: "/repo" }, pluginId: "known", scope: "project", enabled: false },
|
||||
})
|
||||
assert.equal(response.statusCode, status)
|
||||
assert.deepEqual(response.json(), { error: "controlled failure" })
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function snapshot() {
|
||||
return {
|
||||
location: { directory: "/repo" },
|
||||
runtime: [],
|
||||
configured: { rules: [], sources: [] },
|
||||
controls: [],
|
||||
targets: [],
|
||||
}
|
||||
}
|
||||
70
packages/server/src/server/routes/plugin-controls.ts
Normal file
70
packages/server/src/server/routes/plugin-controls.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import type { FastifyInstance, FastifyReply } from "fastify"
|
||||
import { z } from "zod"
|
||||
import type { PluginControls } from "../../opencode/plugin-controls"
|
||||
import { PluginControlsError } from "../../opencode/plugin-controls"
|
||||
|
||||
type PluginControlsApi = Pick<PluginControls, "read" | "mutate">
|
||||
|
||||
interface RouteDeps {
|
||||
controls: PluginControlsApi
|
||||
}
|
||||
|
||||
const LocationFields = {
|
||||
directory: z.string().trim().min(1).max(32_768),
|
||||
workspaceID: z.string().trim().min(1).max(1_024).optional(),
|
||||
}
|
||||
|
||||
const PluginControlsQuerySchema = z.object(LocationFields).strict()
|
||||
|
||||
const PluginActivationMutationSchema = z.object({
|
||||
location: z.object(LocationFields).strict(),
|
||||
pluginId: z.string().trim().min(1).max(256)
|
||||
.refine((value) => !value.startsWith("-") && !value.includes("*") && !/[\u0000-\u001f\u007f]/.test(value)),
|
||||
scope: z.enum(["global", "project"]),
|
||||
enabled: z.boolean(),
|
||||
}).strict()
|
||||
|
||||
export function registerPluginControlRoutes(app: FastifyInstance, deps: RouteDeps): void {
|
||||
app.get<{
|
||||
Params: { id: string }
|
||||
Querystring: { directory?: string; workspaceID?: string }
|
||||
}>("/api/workspaces/:id/plugin-controls", async (request, reply) => {
|
||||
const parsed = PluginControlsQuerySchema.safeParse(request.query ?? {})
|
||||
if (!parsed.success) return invalidRequest(reply)
|
||||
try {
|
||||
return await deps.controls.read(request.params.id, parsed.data)
|
||||
} catch (error) {
|
||||
return handleError(error, request.params.id, request.log, reply)
|
||||
}
|
||||
})
|
||||
|
||||
app.patch<{
|
||||
Params: { id: string }
|
||||
}>("/api/workspaces/:id/plugin-controls", async (request, reply) => {
|
||||
const parsed = PluginActivationMutationSchema.safeParse(request.body ?? {})
|
||||
if (!parsed.success) return invalidRequest(reply)
|
||||
try {
|
||||
return await deps.controls.mutate(request.params.id, parsed.data)
|
||||
} catch (error) {
|
||||
return handleError(error, request.params.id, request.log, reply)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function invalidRequest(reply: FastifyReply) {
|
||||
return reply.code(400).send({ error: "Invalid plugin activation request" })
|
||||
}
|
||||
|
||||
function handleError(error: unknown, workspaceId: string, logger: FastifyReply["log"], reply: FastifyReply) {
|
||||
if (error instanceof PluginControlsError) {
|
||||
const status = error.kind === "not-found" ? 404
|
||||
: error.kind === "forbidden" ? 403
|
||||
: error.kind === "invalid" ? 422
|
||||
: error.kind === "conflict" ? 409
|
||||
: 503
|
||||
if (status >= 500) logger.warn({ err: error, workspaceId }, "Plugin activation controls unavailable")
|
||||
return reply.code(status).send({ error: error.message })
|
||||
}
|
||||
logger.error({ err: error, workspaceId }, "Plugin activation controls failed")
|
||||
return reply.code(500).send({ error: "Plugin activation controls failed" })
|
||||
}
|
||||
|
|
@ -187,6 +187,16 @@ describe("workspace manager shared service lifecycle", () => {
|
|||
)
|
||||
})
|
||||
|
||||
it("uses the current host path style when no platform override is supplied", async () => {
|
||||
const { manager } = createHarness()
|
||||
try {
|
||||
const { workspace } = await manager.create(process.cwd())
|
||||
assert.equal(manager.getServicePathStyle(workspace.id), process.platform === "win32" ? "win32" : "posix")
|
||||
} finally {
|
||||
await manager.shutdown()
|
||||
}
|
||||
})
|
||||
|
||||
it("keeps WSL worktree reservation paths case-sensitive", { skip: process.platform !== "win32" }, async () => {
|
||||
const { manager } = createHarness(new ControlledSharedService(), { platform: "win32" })
|
||||
const releaseUpper = await manager.reserveWorktreeDeletion("\\\\wsl.localhost\\Ubuntu\\repo\\Foo")
|
||||
|
|
@ -327,7 +337,7 @@ describe("workspace manager shared service lifecycle", () => {
|
|||
},
|
||||
})
|
||||
|
||||
await manager.create(process.cwd())
|
||||
const { workspace } = await manager.create(process.cwd())
|
||||
|
||||
assert.deepEqual(factoryCall?.[0], { kind: "wsl", distro: "Ubuntu", binary: "/home/dev/opencode" })
|
||||
assert.equal(typeof factoryCall?.[1], "number")
|
||||
|
|
@ -347,6 +357,7 @@ describe("workspace manager shared service lifecycle", () => {
|
|||
)
|
||||
const record = [...(manager as any).workspaces.values()][0]
|
||||
assert.equal(record.wslDistro, "Ubuntu")
|
||||
assert.equal(manager.getServiceWslDistro(workspace.id), "Ubuntu")
|
||||
})
|
||||
|
||||
it("translates a case-insensitive Windows CA path before WSL identity and lifecycle construction", async () => {
|
||||
|
|
|
|||
|
|
@ -320,6 +320,26 @@ export class WorkspaceManager {
|
|||
?? (path.posix.isAbsolute(candidate) ? candidate : undefined)
|
||||
}
|
||||
|
||||
getServicePathStyle(id: string): "win32" | "posix" | undefined {
|
||||
const record = this.workspaces.get(id)
|
||||
if (!record?.[WORKSPACE_STATE].published) return undefined
|
||||
const platform = this.options.platform ?? process.platform
|
||||
return record.wslDistro || platform !== "win32" ? "posix" : "win32"
|
||||
}
|
||||
|
||||
getServiceWslDistro(id: string): string | undefined {
|
||||
const record = this.workspaces.get(id)
|
||||
return record?.[WORKSPACE_STATE].published ? record.wslDistro : undefined
|
||||
}
|
||||
|
||||
/** Translate a path reported by the authenticated daemon for local filesystem access. */
|
||||
async getHostPathForServicePath(id: string, servicePath: string): Promise<string | undefined> {
|
||||
const record = this.workspaces.get(id)
|
||||
if (!record?.[WORKSPACE_STATE].published) return undefined
|
||||
if (!record.wslDistro) return servicePath
|
||||
return await this.resolveWslHostDirectory(servicePath, record.wslDistro, DEFAULT_LAUNCH_TIMEOUT_MS) ?? undefined
|
||||
}
|
||||
|
||||
private async nativeWorktreeContext(id: string) {
|
||||
const record = this.workspaces.get(id)
|
||||
const location = this.getServiceLocation(id)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useI18n } from "../lib/i18n"
|
|||
|
||||
interface InfoViewProps {
|
||||
instanceId: string
|
||||
active?: boolean
|
||||
onBackToConversation?: () => void
|
||||
}
|
||||
|
||||
|
|
@ -134,7 +135,7 @@ const InfoView: Component<InfoViewProps> = (props) => {
|
|||
<div class="log-container">
|
||||
<div class="flex-1 flex flex-col lg:flex-row gap-4 p-4 overflow-hidden">
|
||||
<div class="lg:w-80 flex-shrink-0 min-h-0 overflow-y-auto max-h-[40vh] lg:max-h-none">
|
||||
<Show when={instance()}>{(inst) => <InstanceInfo instance={inst()} showReloadButton />}</Show>
|
||||
<Show when={instance()}>{(inst) => <InstanceInfo instance={inst()} active={props.active} showReloadButton />}</Show>
|
||||
</div>
|
||||
|
||||
<div class="panel flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { getLogger } from "../lib/logger"
|
|||
|
||||
interface InstanceInfoProps {
|
||||
instance: Instance
|
||||
active?: boolean
|
||||
compact?: boolean
|
||||
showReloadButton?: boolean
|
||||
}
|
||||
|
|
@ -166,7 +167,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||
</div>
|
||||
</Show>
|
||||
|
||||
<InstanceServiceStatus initialInstance={props.instance} class="space-y-3" />
|
||||
<InstanceServiceStatus initialInstance={props.instance} pluginsActive={props.active} class="space-y-3" />
|
||||
|
||||
<Show when={isLoadingMetadata()}>
|
||||
<div class="text-xs text-muted py-1">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import type { Instance, RawMcpStatus } from "../types/instance"
|
|||
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { getActiveCatalogLocation } from "../stores/sessions"
|
||||
import { PluginActivationControls } from "./plugin-activation-controls"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
|
|
@ -12,6 +14,7 @@ type ServiceSection = "mcp" | "plugins"
|
|||
interface InstanceServiceStatusProps {
|
||||
sections?: ServiceSection[]
|
||||
showSectionHeadings?: boolean
|
||||
pluginsActive?: boolean
|
||||
class?: string
|
||||
initialInstance?: Instance
|
||||
}
|
||||
|
|
@ -57,13 +60,11 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
|
|||
const metadataAccessor = metadataContext?.metadata ?? (() => instance().metadata)
|
||||
const metadata = createMemo(() => metadataAccessor())
|
||||
const hasMcpMetadata = () => metadata()?.mcpStatus !== undefined
|
||||
const hasPluginsMetadata = () => metadata()?.plugins !== undefined
|
||||
|
||||
const mcpServers = createMemo(() => parseMcpStatus(metadata()?.mcpStatus ?? undefined))
|
||||
const plugins = createMemo(() => metadata()?.plugins ?? [])
|
||||
|
||||
const isMcpLoading = () => isLoading() || !hasMcpMetadata()
|
||||
const isPluginsLoading = () => isLoading() || !hasPluginsMetadata()
|
||||
const pluginLocation = createMemo(() => getActiveCatalogLocation(instance().id) ?? { directory: instance().folder })
|
||||
|
||||
|
||||
const [pendingMcpActions, setPendingMcpActions] = createSignal<Record<string, "connect" | "disconnect">>({})
|
||||
|
|
@ -181,34 +182,19 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
|
|||
</section>
|
||||
)
|
||||
|
||||
const renderPluginsSection = () => (
|
||||
<section class="space-y-1.5">
|
||||
<Show when={showHeadings()}>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide">
|
||||
{t("instanceServiceStatus.sections.plugins")}
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={!isPluginsLoading() && plugins().length > 0}
|
||||
fallback={renderEmptyState(isPluginsLoading() ? t("instanceServiceStatus.plugins.loading") : t("instanceServiceStatus.plugins.empty"))}
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<For each={plugins()}>
|
||||
{(plugin) => (
|
||||
<div class="px-2 py-1.5 border bg-surface-secondary border-base">
|
||||
<div class="text-xs text-primary font-medium break-words whitespace-normal">{plugin}</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
|
||||
return (
|
||||
<div class={props.class}>
|
||||
<Show when={includeMcp()}>{renderMcpSection()}</Show>
|
||||
<Show when={includePlugins()}>{renderPluginsSection()}</Show>
|
||||
<Show when={includePlugins()}>
|
||||
<Show when={props.pluginsActive !== false}>
|
||||
<PluginActivationControls
|
||||
instanceId={instance().id}
|
||||
location={pluginLocation()}
|
||||
showHeading={showHeadings()}
|
||||
active={props.pluginsActive}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1264,7 +1264,11 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||
}
|
||||
>
|
||||
<div class="info-view-pane flex flex-col flex-1 min-h-0 overflow-y-auto">
|
||||
<InfoView instanceId={props.instance.id} onBackToConversation={handleBackToConversation} />
|
||||
<InfoView
|
||||
instanceId={props.instance.id}
|
||||
active={Boolean(props.isActiveInstance) && showingInfoView()}
|
||||
onBackToConversation={handleBackToConversation}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -223,6 +223,7 @@ export function createCoreRightPanelRuntime(options: CoreRightPanelRuntimeOption
|
|||
instanceId={options.instanceId}
|
||||
instance={options.instance}
|
||||
activeSession={options.activeSession}
|
||||
isActive={() => options.isActive() && options.rightPanelTab() === "status"}
|
||||
expandedItems={options.expandedItems}
|
||||
onExpandedItemsChange={options.onExpandedItemsChange}
|
||||
customization={options.customization}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ interface StatusTabProps {
|
|||
|
||||
activeSession: Accessor<Session | null>
|
||||
|
||||
isActive: Accessor<boolean>
|
||||
expandedItems: Accessor<string[]>
|
||||
onExpandedItemsChange: (values: string[]) => void
|
||||
customization: Accessor<RightPanelCustomization>
|
||||
|
|
@ -228,7 +229,13 @@ const StatusTab: Component<StatusTabProps> = (props) => {
|
|||
renderBackgroundProcesses,
|
||||
renderMcpStatus: () => <InstanceServiceStatus initialInstance={props.instance} sections={["mcp"]} showSectionHeadings={false} class="space-y-2" />,
|
||||
renderPluginStatus: () => (
|
||||
<InstanceServiceStatus initialInstance={props.instance} sections={["plugins"]} showSectionHeadings={false} class="space-y-2" />
|
||||
<InstanceServiceStatus
|
||||
initialInstance={props.instance}
|
||||
sections={["plugins"]}
|
||||
showSectionHeadings={false}
|
||||
pluginsActive={props.isActive() && isSectionExpanded("plugins")}
|
||||
class="space-y-2"
|
||||
/>
|
||||
),
|
||||
}).statusSections ?? []
|
||||
|
||||
|
|
|
|||
311
packages/ui/src/components/plugin-activation-controls.tsx
Normal file
311
packages/ui/src/components/plugin-activation-controls.tsx
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
import { For, Show, createEffect, createMemo, createSignal, createUniqueId, onCleanup, type Component } from "solid-js"
|
||||
import { RefreshCw } from "lucide-solid"
|
||||
import Switch from "@suid/material/Switch"
|
||||
import type {
|
||||
PluginActivationControl,
|
||||
PluginControlLocation,
|
||||
PluginControlScope,
|
||||
PluginRuntimeSource,
|
||||
} from "../../../server/src/api-types"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
import { HttpResponseError } from "../lib/retryable-file-search"
|
||||
import { pluginControlsCache } from "../stores/plugin-controls"
|
||||
import "../stores/plugin-controls-events"
|
||||
|
||||
interface PluginActivationControlsProps {
|
||||
instanceId: string
|
||||
location: PluginControlLocation
|
||||
showHeading?: boolean
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
const pendingKey = (scope: PluginControlScope, pluginId: string): string => `${scope}:${pluginId}`
|
||||
|
||||
export const PluginActivationControls: Component<PluginActivationControlsProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const [pending, setPending] = createSignal<Set<string>>(new Set())
|
||||
const headingId = `plugin-controls-${createUniqueId()}`
|
||||
const noticeId = `${headingId}-notice`
|
||||
const globalLabelId = `plugin-controls-${createUniqueId()}-global`
|
||||
const projectLabelId = `plugin-controls-${createUniqueId()}-project`
|
||||
const directory = createMemo(() => props.location.directory)
|
||||
let cachedLocation: PluginControlLocation | undefined
|
||||
const requestLocation = (): PluginControlLocation => {
|
||||
if (!cachedLocation || cachedLocation.directory !== directory()) {
|
||||
cachedLocation = { directory: directory() }
|
||||
}
|
||||
return cachedLocation
|
||||
}
|
||||
const state = createMemo(() => pluginControlsCache.state(props.instanceId, requestLocation()))
|
||||
const snapshot = createMemo(() => state().snapshot)
|
||||
const controls = createMemo(() => (
|
||||
snapshot()?.controls.filter((control) => !control.builtin && control.runtime?.source.type !== "builtin") ?? []
|
||||
))
|
||||
const controlsById = createMemo(() => new Map(controls().map((control) => [control.id, control])))
|
||||
const controlIds = createMemo(() => [...controlsById().keys()])
|
||||
let currentIdentity: string | undefined
|
||||
let locationGeneration = 0
|
||||
// The admitted write may finish after a project tab or visibility wrapper
|
||||
// disposes this surface. Only its live owner may publish presentation feedback.
|
||||
onCleanup(() => { locationGeneration += 1 })
|
||||
|
||||
createEffect(() => {
|
||||
const instanceId = props.instanceId
|
||||
const location = requestLocation()
|
||||
const identity = JSON.stringify([instanceId, location.directory])
|
||||
if (currentIdentity !== identity) {
|
||||
currentIdentity = identity
|
||||
locationGeneration += 1
|
||||
setPending(new Set<string>())
|
||||
}
|
||||
if (props.active === false) return
|
||||
onCleanup(pluginControlsCache.acquireDemand(instanceId, location))
|
||||
})
|
||||
|
||||
const setPendingPlugin = (scope: PluginControlScope, pluginId: string, value: boolean) => {
|
||||
setPending((previous) => {
|
||||
const next = new Set(previous)
|
||||
if (value) next.add(pendingKey(scope, pluginId))
|
||||
else next.delete(pendingKey(scope, pluginId))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggle = async (control: PluginActivationControl, scope: PluginControlScope, enabled: boolean) => {
|
||||
const key = pendingKey(scope, control.id)
|
||||
if (pending().has(key)) return
|
||||
const requestGeneration = locationGeneration
|
||||
setPendingPlugin(scope, control.id, true)
|
||||
try {
|
||||
const response = await pluginControlsCache.mutate(props.instanceId, requestLocation(), control.id, scope, enabled)
|
||||
if (requestGeneration !== locationGeneration || props.active === false) return
|
||||
const base = t(response.changed
|
||||
? "instanceServiceStatus.plugins.toast.ruleSaved"
|
||||
: "instanceServiceStatus.plugins.toast.ruleUnchanged", {
|
||||
name: control.id,
|
||||
scope: scopeLabel(scope),
|
||||
})
|
||||
showToastNotification({
|
||||
variant: "success",
|
||||
message: response.changed && response.reloadPending ? `${base} ${t("instanceServiceStatus.plugins.toast.reloadNote")}` : base,
|
||||
})
|
||||
} catch (error) {
|
||||
log.error("Failed to update plugin activation rule", { pluginId: control.id, scope, error })
|
||||
if (requestGeneration === locationGeneration && props.active !== false) {
|
||||
showToastNotification({
|
||||
variant: "error",
|
||||
message: t(errorMessageKey(error), { name: control.id, scope: scopeLabel(scope) }),
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
if (requestGeneration === locationGeneration) setPendingPlugin(scope, control.id, false)
|
||||
}
|
||||
}
|
||||
|
||||
const bidi = (text: string): string => `${text}`
|
||||
|
||||
const sourceLabel = (source: PluginRuntimeSource): string => {
|
||||
if (source.type === "package") return t("instanceServiceStatus.plugins.source.package", { source: source.target })
|
||||
if (source.type === "local") return t("instanceServiceStatus.plugins.source.local", { source: source.path })
|
||||
if (source.type === "builtin") return t("instanceServiceStatus.plugins.source.builtin")
|
||||
return t("instanceServiceStatus.plugins.source.sdk")
|
||||
}
|
||||
|
||||
const renderControl = (pluginId: string) => {
|
||||
// Key rows by ID, but read every displayed value from the current snapshot.
|
||||
// Passive refreshes must not replace the focused row's DOM.
|
||||
const control = createMemo(() => controlsById().get(pluginId))
|
||||
const failed = () => control()?.runtime?.state.status === "failed"
|
||||
const failureMessage = () => {
|
||||
const state = control()?.runtime?.state
|
||||
return state?.status === "failed" ? state.error : undefined
|
||||
}
|
||||
const overridden = () => Boolean(control() && control()?.project !== "default")
|
||||
const isAvailable = (scope: PluginControlScope): boolean =>
|
||||
snapshot()?.targets.some((target) => target.scope === scope) === true
|
||||
const scopeReason = (scope: PluginControlScope): string => isAvailable(scope)
|
||||
? t(`instanceServiceStatus.plugins.scope.${scope}.detail`)
|
||||
: scope === "project"
|
||||
? t("instanceServiceStatus.plugins.scope.unavailable")
|
||||
: t(`instanceServiceStatus.plugins.scope.${scope}.detail`)
|
||||
const renderSwitch = (scope: PluginControlScope) => {
|
||||
const checked = () => {
|
||||
const current = control()
|
||||
return current ? scopeChecked(current, scope) : false
|
||||
}
|
||||
const available = () => isAvailable(scope)
|
||||
const isPending = () => pending().has(pendingKey(scope, pluginId))
|
||||
const describedBy = () => available()
|
||||
? (scope === "global" ? globalLabelId : projectLabelId)
|
||||
: `${scope === "global" ? globalLabelId : projectLabelId} ${noticeId}`
|
||||
// Native tooltips do not fire on disabled inputs and disabled inputs
|
||||
// leave the tab order, so the reason lives on the hoverable wrapper and
|
||||
// in screen-reader text instead of the input title. Available switches
|
||||
// already describe themselves through the header labels, so only
|
||||
// unavailable lanes carry a tooltip.
|
||||
return (
|
||||
<div
|
||||
class="plugin-control-switch"
|
||||
data-scope={scope}
|
||||
title={available() ? undefined : scopeReason(scope)}
|
||||
>
|
||||
<Switch
|
||||
checked={checked()}
|
||||
disabled={!available()}
|
||||
color="success"
|
||||
size="small"
|
||||
inputProps={{
|
||||
"aria-label": bidi(t("instanceServiceStatus.plugins.toggleAriaLabel", {
|
||||
name: `${pluginId} — ${scopeLabel(scope)}`,
|
||||
})),
|
||||
"aria-describedby": describedBy(),
|
||||
"aria-disabled": isPending() || !available(),
|
||||
"aria-busy": isPending(),
|
||||
}}
|
||||
onChange={(event, nextChecked) => {
|
||||
const current = control()
|
||||
if (isPending() || !available() || !current) {
|
||||
// SUID restores the controlled checked value before calling us.
|
||||
// Cancel native activation too, while retaining keyboard focus.
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
void toggle(current, scope, Boolean(nextChecked))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const nameId = `${headingId}-name-${pluginId}`
|
||||
return (
|
||||
<div class="plugin-control-row" data-plugin-id={pluginId} role="group" aria-labelledby={nameId}>
|
||||
<div class="min-w-0">
|
||||
<span id={nameId} class="plugin-control-name"><bdi>{pluginId}</bdi></span>
|
||||
<div class="plugin-control-sub">
|
||||
<Show when={failed()} fallback={
|
||||
<Show when={overridden()} fallback={
|
||||
<Show when={control()?.runtime} fallback={
|
||||
<span>{t(`instanceServiceStatus.plugins.ruleState.${control()?.effective ?? "default"}`)}</span>
|
||||
}>
|
||||
{(runtime) => <span>{sourceLabel(runtime().source)}</span>}
|
||||
</Show>
|
||||
}>
|
||||
<span class={`status-dot ${control()?.effective === "enabled" ? "ready" : "stopped"}`} aria-hidden="true" />
|
||||
<span>{t("instanceServiceStatus.plugins.globalOverridden")}</span>
|
||||
</Show>
|
||||
}>
|
||||
<span class="status-dot error" aria-hidden="true" />
|
||||
<span><bdi>{t("instanceServiceStatus.plugins.runtime.failed")}{failureMessage() ? ` — ${failureMessage()}` : ""}</bdi></span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
{renderSwitch("global")}
|
||||
{renderSwitch("project")}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
class="plugin-controls"
|
||||
aria-labelledby={props.showHeading !== false ? headingId : undefined}
|
||||
aria-label={props.showHeading === false ? t("instanceServiceStatus.sections.plugins") : undefined}
|
||||
>
|
||||
<Show when={props.showHeading !== false}>
|
||||
<div id={headingId} class="text-xs font-medium text-muted uppercase tracking-wide">
|
||||
{t("instanceServiceStatus.sections.plugins")}
|
||||
</div>
|
||||
</Show>
|
||||
<p class="plugin-controls-description">{t("instanceServiceStatus.plugins.description")}</p>
|
||||
<Show when={snapshot() && !snapshot()?.targets.some((target) => target.scope === "project")}>
|
||||
<p id={noticeId} class="plugin-controls-notice" role="note">{t("instanceServiceStatus.plugins.scope.unavailable")}</p>
|
||||
</Show>
|
||||
|
||||
<div class="plugin-controls-header">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-button-compact"
|
||||
title={t("instanceServiceStatus.plugins.refresh")}
|
||||
aria-label={t("instanceServiceStatus.plugins.refresh")}
|
||||
disabled={state().loading || state().refreshing}
|
||||
onClick={() => void pluginControlsCache.load(props.instanceId, requestLocation(), { force: true })}
|
||||
>
|
||||
<RefreshCw class="h-3.5 w-3.5" classList={{ "animate-spin": state().loading || state().refreshing }} aria-hidden="true" />
|
||||
</button>
|
||||
<span class="sr-only">{t("instanceServiceStatus.plugins.scope.legend")}</span>
|
||||
<span id={globalLabelId} class="plugin-control-scope-label" title={t("instanceServiceStatus.plugins.scope.global.detail")}>{scopeLabel("global")}</span>
|
||||
<span
|
||||
id={projectLabelId}
|
||||
class="plugin-control-scope-label"
|
||||
title={!snapshot() || snapshot()?.targets.some((target) => target.scope === "project")
|
||||
? t("instanceServiceStatus.plugins.scope.project.detail")
|
||||
: t("instanceServiceStatus.plugins.scope.unavailable")}
|
||||
>{scopeLabel("project")}</span>
|
||||
</div>
|
||||
|
||||
<Show when={snapshot()} fallback={
|
||||
<div class="right-panel-empty-text" role="status">
|
||||
<p>{state().loading
|
||||
? t("instanceServiceStatus.plugins.loading")
|
||||
: state().error
|
||||
? t("instanceServiceStatus.plugins.errors.load")
|
||||
: t("instanceServiceStatus.plugins.loading")}</p>
|
||||
<Show when={!state().loading && state().error}>
|
||||
<button
|
||||
type="button"
|
||||
class="button-tertiary"
|
||||
onClick={() => void pluginControlsCache.load(props.instanceId, requestLocation(), { force: true })}
|
||||
>
|
||||
{t("instanceServiceStatus.plugins.refresh")}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
}>
|
||||
<Show when={controls().length > 0} fallback={<p class="right-panel-empty-text">{t("instanceServiceStatus.plugins.empty")}</p>}>
|
||||
<div class="plugin-control-list">
|
||||
<For each={controlIds()}>{renderControl}</For>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="plugin-controls-footer">
|
||||
<For each={snapshot()?.targets ?? []}>{(target) => {
|
||||
const line = () => `${scopeLabel(target.scope)} — ${target.exists
|
||||
? t("instanceServiceStatus.plugins.target.existing", { path: target.path })
|
||||
: t("instanceServiceStatus.plugins.target.new", { path: target.path })}`
|
||||
return (
|
||||
<div>
|
||||
{bidi(line())}
|
||||
</div>
|
||||
)
|
||||
}}</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={state().error && snapshot()}>
|
||||
<div class="plugin-controls-warning" role="status">{t("instanceServiceStatus.plugins.errors.refresh")}</div>
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
|
||||
function scopeLabel(value: PluginControlScope): string {
|
||||
return t(`instanceServiceStatus.plugins.scope.${value}`)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function scopeChecked(control: PluginActivationControl, scope: PluginControlScope): boolean {
|
||||
const scoped = control[scope]
|
||||
if (scoped !== "default") return scoped === "enabled"
|
||||
return scope === "project" ? scopeChecked(control, "global") : true
|
||||
}
|
||||
|
||||
function errorMessageKey(error: unknown): string {
|
||||
if (!(error instanceof HttpResponseError)) return "instanceServiceStatus.plugins.errors.save"
|
||||
if (error.status === 403) return "instanceServiceStatus.plugins.errors.forbidden"
|
||||
if (error.status === 409) return "instanceServiceStatus.plugins.errors.conflict"
|
||||
if (error.status === 422) return "instanceServiceStatus.plugins.errors.invalidConfig"
|
||||
return "instanceServiceStatus.plugins.errors.save"
|
||||
}
|
||||
|
|
@ -19,6 +19,10 @@ import type {
|
|||
SpeechTranscriptionResponse,
|
||||
SideCar,
|
||||
PreviewSession,
|
||||
PluginActivationMutationRequest,
|
||||
PluginActivationMutationResponse,
|
||||
PluginControlLocation,
|
||||
PluginControlsSnapshot,
|
||||
ProviderUsageResponse,
|
||||
ServerMeta,
|
||||
RemoteProxySessionCreateRequest,
|
||||
|
|
@ -336,6 +340,17 @@ export const serverApi = {
|
|||
body: JSON.stringify(body),
|
||||
})
|
||||
},
|
||||
getPluginControls(instanceId: string, location: PluginControlLocation, signal?: AbortSignal): Promise<PluginControlsSnapshot> {
|
||||
const params = new URLSearchParams({ directory: location.directory })
|
||||
if (location.workspaceID) params.set("workspaceID", location.workspaceID)
|
||||
return request<PluginControlsSnapshot>(`/api/workspaces/${encodeURIComponent(instanceId)}/plugin-controls?${params.toString()}`, { signal })
|
||||
},
|
||||
setPluginActivation(instanceId: string, payload: PluginActivationMutationRequest): Promise<PluginActivationMutationResponse> {
|
||||
return request<PluginActivationMutationResponse>(`/api/workspaces/${encodeURIComponent(instanceId)}/plugin-controls`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
setServerPassword(password: string): Promise<{ ok: boolean; username: string; passwordUserProvided: boolean }> {
|
||||
return request<{ ok: boolean; username: string; passwordUserProvided: boolean }>("/api/auth/password", {
|
||||
method: "POST",
|
||||
|
|
|
|||
|
|
@ -23,32 +23,19 @@ describe("instance metadata", () => {
|
|||
return { location: responseLocation, data: [] }
|
||||
},
|
||||
},
|
||||
plugin: {
|
||||
list: async (input: unknown) => {
|
||||
locations.push(input)
|
||||
return {
|
||||
location: responseLocation,
|
||||
data: [
|
||||
{ id: "opencode.plan", state: { status: "active" } },
|
||||
{ id: "ponytail", state: { status: "active" } },
|
||||
{ id: "broken", state: { status: "failed", error: "broken" } },
|
||||
],
|
||||
}
|
||||
},
|
||||
},
|
||||
plugin: { list: async () => { throw new Error("generic metadata must not read plugins") } },
|
||||
}
|
||||
|
||||
try {
|
||||
const location = { directory: "/repo/worktree" }
|
||||
await loadInstanceMetadata({ id: instanceId, folder: "/repo", client } as any, { location })
|
||||
assert.equal(getInstanceMetadata(instanceId)?.project?.vcs, "git")
|
||||
assert.deepEqual(getInstanceMetadata(instanceId)?.plugins, ["ponytail"])
|
||||
assert.equal(hasMetadataLoaded(getInstanceMetadata(instanceId), location), true)
|
||||
assert.equal(hasMetadataLoaded(getInstanceMetadata(instanceId), { directory: "/repo/worktree" }), true)
|
||||
assert.equal(hasMetadataLoaded(getInstanceMetadata(instanceId), { directory: "/repo" }), false)
|
||||
assert.deepEqual(locations, Array.from({ length: 2 }, () => ({
|
||||
assert.deepEqual(locations, [{
|
||||
location: { directory: "/repo/worktree" },
|
||||
})))
|
||||
}])
|
||||
} finally {
|
||||
clearInstanceMetadata(instanceId)
|
||||
}
|
||||
|
|
@ -66,9 +53,7 @@ describe("instance metadata", () => {
|
|||
list: async () => { await gate; return [] },
|
||||
},
|
||||
mcp: { list: async () => { await gate; return { data: [label] } } },
|
||||
plugin: {
|
||||
list: async () => { await gate; return { data: [{ id: label, state: { status: "active" } }] } },
|
||||
},
|
||||
plugin: { list: async () => { throw new Error("generic metadata must not read plugins") } },
|
||||
})
|
||||
|
||||
try {
|
||||
|
|
@ -86,9 +71,8 @@ describe("instance metadata", () => {
|
|||
}
|
||||
})
|
||||
|
||||
it("does not treat stale plugin inventory as loaded after a location switch", async () => {
|
||||
it("treats location metadata as loaded without fetching plugin inventory", async () => {
|
||||
const instanceId = "metadata-location-partial-failure"
|
||||
let failPlugins = false
|
||||
const project = { id: "project", directory: "/repo", canonical: "/repo" }
|
||||
const client = {
|
||||
location: { get: async () => ({ directory: "/repo", project }) },
|
||||
|
|
@ -104,26 +88,18 @@ describe("instance metadata", () => {
|
|||
data: [],
|
||||
}),
|
||||
},
|
||||
plugin: {
|
||||
list: async () => {
|
||||
if (failPlugins) throw new Error("plugin inventory unavailable")
|
||||
return { data: [{ id: "root-plugin", state: { status: "active" } }] }
|
||||
},
|
||||
},
|
||||
plugin: { list: async () => { throw new Error("generic metadata must not read plugins") } },
|
||||
}
|
||||
|
||||
try {
|
||||
await loadInstanceMetadata({ id: instanceId, folder: "/repo", client } as any)
|
||||
assert.deepEqual(getInstanceMetadata(instanceId)?.plugins, ["root-plugin"])
|
||||
|
||||
failPlugins = true
|
||||
const worktree = { directory: "/repo/worktree" }
|
||||
await loadInstanceMetadata({ id: instanceId, folder: "/repo", client } as any, { location: worktree })
|
||||
|
||||
const metadata = getInstanceMetadata(instanceId)
|
||||
assert.equal(metadata?.mcpStatus?.location.directory, worktree.directory)
|
||||
assert.equal(metadata?.plugins, undefined)
|
||||
assert.equal(hasMetadataLoaded(metadata, worktree), false)
|
||||
assert.equal(hasMetadataLoaded(metadata, worktree), true)
|
||||
} finally {
|
||||
clearInstanceMetadata(instanceId)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ function metadataMatchesLocation(metadata: Instance["metadata"] | undefined, loc
|
|||
|
||||
function hasMetadataLoaded(metadata?: Instance["metadata"], location?: LocationRef): boolean {
|
||||
if (!metadata) return false
|
||||
if (metadata.project === undefined || metadata.mcpStatus === undefined || metadata.plugins === undefined) return false
|
||||
if (metadata.project === undefined || metadata.mcpStatus === undefined) return false
|
||||
return !location || metadataMatchesLocation(metadata, location)
|
||||
}
|
||||
|
||||
|
|
@ -56,31 +56,20 @@ export function loadInstanceMetadata(instance: Instance, options?: { force?: boo
|
|||
request.promise = (async () => {
|
||||
try {
|
||||
const requestLocation = toRequestLocation(location)
|
||||
const [projectResult, projectsResult, mcpResult, pluginResult] = await Promise.allSettled([
|
||||
const [projectResult, projectsResult, mcpResult] = await Promise.allSettled([
|
||||
loadInstanceProjectMetadata(instance, options),
|
||||
client.project.list(),
|
||||
client.mcp.list({ location: requestLocation }, requestLocationOptions(location)),
|
||||
client.plugin.list({ location: requestLocation }, requestLocationOptions(location)),
|
||||
])
|
||||
|
||||
const currentProject = getInstanceMetadata(instance.id)?.project
|
||||
const listedProject = currentProject && projectsResult.status === "fulfilled"
|
||||
? projectsResult.value.find((project) => project.id === currentProject.id)
|
||||
: undefined
|
||||
const plugins = pluginResult.status === "fulfilled"
|
||||
? pluginResult.value.data.flatMap((plugin) => {
|
||||
const status = plugin.state?.status ?? (plugin as unknown as { status?: string }).status
|
||||
return status === "active" && typeof plugin.id === "string" && !plugin.id.startsWith("opencode.")
|
||||
? [plugin.id]
|
||||
: []
|
||||
})
|
||||
: undefined
|
||||
|
||||
const latestMetadata = getInstanceMetadata(instance.id) ?? currentMetadata
|
||||
const updates: Instance["metadata"] = { ...(latestMetadata ?? {}) }
|
||||
if (latestMetadata && !metadataMatchesLocation(latestMetadata, location)) {
|
||||
updates.mcpStatus = undefined
|
||||
updates.plugins = undefined
|
||||
}
|
||||
|
||||
if (projectResult.status === "fulfilled" && currentProject && listedProject?.vcs) {
|
||||
|
|
@ -91,10 +80,6 @@ export function loadInstanceMetadata(instance: Instance, options?: { force?: boo
|
|||
updates.mcpStatus = mcpResult.value
|
||||
}
|
||||
|
||||
if (pluginResult.status === "fulfilled") {
|
||||
updates.plugins = plugins ?? []
|
||||
}
|
||||
|
||||
if (!updates?.version && instance.binaryVersion) {
|
||||
updates.version = instance.binaryVersion
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,40 @@ export const settingsMessages = {
|
|||
"instanceServiceStatus.mcp.empty": "Keine MCP-Server erkannt.",
|
||||
"instanceServiceStatus.mcp.toggleAriaLabel": "{name} MCP-Server umschalten",
|
||||
"instanceServiceStatus.plugins.loading": "Plugins werden geladen...",
|
||||
"instanceServiceStatus.plugins.empty": "Keine aktiven Plugins.",
|
||||
"instanceServiceStatus.plugins.empty": "Keine Plugins zur Aktivierung verfügbar.",
|
||||
"instanceServiceStatus.plugins.description": "Die V2-Aktivierungssteuerung schreibt nur geordnete Regeln. Installation, Updates und Optionen bleiben in der OpenCode-Konfiguration oder CLI.",
|
||||
"instanceServiceStatus.plugins.scope.legend": "Nächste Regel schreiben nach",
|
||||
"instanceServiceStatus.plugins.scope.global": "Global",
|
||||
"instanceServiceStatus.plugins.scope.global.detail": "Alle Projekte dieses Daemons",
|
||||
"instanceServiceStatus.plugins.scope.project": "Projekt",
|
||||
"instanceServiceStatus.plugins.scope.project.detail": "Dieser aktive Projektort",
|
||||
"instanceServiceStatus.plugins.scope.unavailable": "Die Projektsteuerung ist nicht verfügbar: Das Konfigurationsziel wird mit dem Bereich „Global“ geteilt oder verweist auf einen Ort außerhalb dieses Projekts.",
|
||||
"instanceServiceStatus.plugins.target.existing": "Regelziel: {path}",
|
||||
"instanceServiceStatus.plugins.target.new": "Wird erstellt: {path}",
|
||||
"instanceServiceStatus.plugins.runtime.heading": "Runtime-Inventar",
|
||||
"instanceServiceStatus.plugins.runtime.active": "Aktiv",
|
||||
"instanceServiceStatus.plugins.runtime.failed": "Fehlgeschlagen",
|
||||
"instanceServiceStatus.plugins.runtime.inactive": "Nicht aktiv",
|
||||
"instanceServiceStatus.plugins.runtime.empty": "Die Runtime meldet keine Plugins.",
|
||||
"instanceServiceStatus.plugins.refresh": "Plugin-Steuerung aktualisieren",
|
||||
"instanceServiceStatus.plugins.toggleAriaLabel": "Aktivierungsregel für {name} ändern",
|
||||
"instanceServiceStatus.plugins.globalOverridden": "Eine Projektregel überschreibt hier den globalen Zustand.",
|
||||
"instanceServiceStatus.plugins.source.package": "Paket: {source}",
|
||||
"instanceServiceStatus.plugins.source.local": "Lokale Quelle: {source}",
|
||||
"instanceServiceStatus.plugins.source.builtin": "In OpenCode integriert",
|
||||
"instanceServiceStatus.plugins.source.sdk": "Vom SDK-Host bereitgestellt",
|
||||
"instanceServiceStatus.plugins.ruleState.default": "Geerbt",
|
||||
"instanceServiceStatus.plugins.ruleState.enabled": "Konfiguriert an",
|
||||
"instanceServiceStatus.plugins.ruleState.disabled": "Konfiguriert aus",
|
||||
"instanceServiceStatus.plugins.toast.ruleSaved": "{scope}-Aktivierungsregel für {name} gespeichert.",
|
||||
"instanceServiceStatus.plugins.toast.ruleUnchanged": "Die {scope}-Aktivierungsregel für {name} ist bereits aktuell.",
|
||||
"instanceServiceStatus.plugins.toast.reloadNote": "Sie wird wirksam, sobald der Daemon seine Konfiguration neu lädt.",
|
||||
"instanceServiceStatus.plugins.errors.load": "Plugin-Aktivierungssteuerung konnte nicht geladen werden.",
|
||||
"instanceServiceStatus.plugins.errors.refresh": "Aktualisierung fehlgeschlagen. Der letzte bekannte Zustand bleibt sichtbar.",
|
||||
"instanceServiceStatus.plugins.errors.save": "Die Aktivierungsregel für {name} im Bereich „{scope}“ konnte nicht gespeichert werden.",
|
||||
"instanceServiceStatus.plugins.errors.forbidden": "Dieser Workspace darf den Konfigurationsort von {name} im Bereich „{scope}“ nicht ändern.",
|
||||
"instanceServiceStatus.plugins.errors.conflict": "Das Projekt oder die Konfiguration wurde während des Speicherns der Regel für {name} im Bereich „{scope}“ geändert. Aktualisieren und erneut versuchen.",
|
||||
"instanceServiceStatus.plugins.errors.invalidConfig": "Die OpenCode-Konfiguration für {name} im Bereich „{scope}“ ist fehlerhaft. Sie wurde nicht geändert.",
|
||||
|
||||
"permissionBanner.pendingRequests.one": "{count} ausstehende Anfrage",
|
||||
"permissionBanner.pendingRequests.other": "{count} ausstehende Anfragen",
|
||||
|
|
|
|||
|
|
@ -72,7 +72,40 @@ export const settingsMessages = {
|
|||
"instanceServiceStatus.mcp.empty": "No MCP servers detected.",
|
||||
"instanceServiceStatus.mcp.toggleAriaLabel": "Toggle {name} MCP server",
|
||||
"instanceServiceStatus.plugins.loading": "Loading plugins...",
|
||||
"instanceServiceStatus.plugins.empty": "No active plugins.",
|
||||
"instanceServiceStatus.plugins.empty": "No plugins available for activation.",
|
||||
"instanceServiceStatus.plugins.description": "V2 activation controls write ordered rules only. Installation, updates, and options remain in OpenCode configuration or the CLI.",
|
||||
"instanceServiceStatus.plugins.scope.legend": "Write the next rule to",
|
||||
"instanceServiceStatus.plugins.scope.global": "Global",
|
||||
"instanceServiceStatus.plugins.scope.global.detail": "All projects using this daemon",
|
||||
"instanceServiceStatus.plugins.scope.project": "Project",
|
||||
"instanceServiceStatus.plugins.scope.project.detail": "This active project location",
|
||||
"instanceServiceStatus.plugins.scope.unavailable": "Project controls are unavailable: the configuration target is shared with Global or resolves outside this project location.",
|
||||
"instanceServiceStatus.plugins.target.existing": "Rule target: {path}",
|
||||
"instanceServiceStatus.plugins.target.new": "Will create: {path}",
|
||||
"instanceServiceStatus.plugins.runtime.heading": "Runtime inventory",
|
||||
"instanceServiceStatus.plugins.runtime.active": "Active",
|
||||
"instanceServiceStatus.plugins.runtime.failed": "Failed",
|
||||
"instanceServiceStatus.plugins.runtime.inactive": "Not running",
|
||||
"instanceServiceStatus.plugins.runtime.empty": "No plugins are reported by the runtime.",
|
||||
"instanceServiceStatus.plugins.refresh": "Refresh plugin controls",
|
||||
"instanceServiceStatus.plugins.toggleAriaLabel": "Change activation rule for {name}",
|
||||
"instanceServiceStatus.plugins.globalOverridden": "A project rule overrides the global state here.",
|
||||
"instanceServiceStatus.plugins.source.package": "Package: {source}",
|
||||
"instanceServiceStatus.plugins.source.local": "Local source: {source}",
|
||||
"instanceServiceStatus.plugins.source.builtin": "Built into OpenCode",
|
||||
"instanceServiceStatus.plugins.source.sdk": "Provided by the SDK host",
|
||||
"instanceServiceStatus.plugins.ruleState.default": "Inherited",
|
||||
"instanceServiceStatus.plugins.ruleState.enabled": "Configured on",
|
||||
"instanceServiceStatus.plugins.ruleState.disabled": "Configured off",
|
||||
"instanceServiceStatus.plugins.toast.ruleSaved": "Saved the {scope} activation rule for {name}.",
|
||||
"instanceServiceStatus.plugins.toast.ruleUnchanged": "The {scope} activation rule for {name} is already current.",
|
||||
"instanceServiceStatus.plugins.toast.reloadNote": "It takes effect when the daemon reloads its configuration.",
|
||||
"instanceServiceStatus.plugins.errors.load": "Plugin activation controls could not be loaded.",
|
||||
"instanceServiceStatus.plugins.errors.refresh": "Refresh failed. The last known plugin state is still shown.",
|
||||
"instanceServiceStatus.plugins.errors.save": "The {scope} activation rule for {name} could not be saved.",
|
||||
"instanceServiceStatus.plugins.errors.forbidden": "This workspace is not allowed to change the {scope} location of {name}.",
|
||||
"instanceServiceStatus.plugins.errors.conflict": "The project or configuration changed while saving the {scope} rule for {name}. Refresh and try again.",
|
||||
"instanceServiceStatus.plugins.errors.invalidConfig": "The {scope} OpenCode configuration for {name} is malformed. It was not changed.",
|
||||
|
||||
"permissionBanner.pendingRequests.one": "{count} pending request",
|
||||
"permissionBanner.pendingRequests.other": "{count} pending requests",
|
||||
|
|
|
|||
|
|
@ -72,7 +72,40 @@ export const settingsMessages = {
|
|||
"instanceServiceStatus.mcp.empty": "No se detectaron servidores MCP.",
|
||||
"instanceServiceStatus.mcp.toggleAriaLabel": "Alternar servidor MCP {name}",
|
||||
"instanceServiceStatus.plugins.loading": "Cargando plugins...",
|
||||
"instanceServiceStatus.plugins.empty": "No hay plugins activos.",
|
||||
"instanceServiceStatus.plugins.empty": "No hay plugins disponibles para activar.",
|
||||
"instanceServiceStatus.plugins.description": "Los controles de activación V2 solo escriben reglas ordenadas. La instalación, las actualizaciones y las opciones permanecen en la configuración de OpenCode o la CLI.",
|
||||
"instanceServiceStatus.plugins.scope.legend": "Escribir la próxima regla en",
|
||||
"instanceServiceStatus.plugins.scope.global": "Global",
|
||||
"instanceServiceStatus.plugins.scope.global.detail": "Todos los proyectos de este daemon",
|
||||
"instanceServiceStatus.plugins.scope.project": "Proyecto",
|
||||
"instanceServiceStatus.plugins.scope.project.detail": "Esta ubicación de proyecto activa",
|
||||
"instanceServiceStatus.plugins.scope.unavailable": "Los controles del proyecto no están disponibles: el destino de la configuración se comparte con el ámbito «Global» o apunta fuera de la ubicación de este proyecto.",
|
||||
"instanceServiceStatus.plugins.target.existing": "Destino de la regla: {path}",
|
||||
"instanceServiceStatus.plugins.target.new": "Se creará: {path}",
|
||||
"instanceServiceStatus.plugins.runtime.heading": "Inventario de ejecución",
|
||||
"instanceServiceStatus.plugins.runtime.active": "Activo",
|
||||
"instanceServiceStatus.plugins.runtime.failed": "Falló",
|
||||
"instanceServiceStatus.plugins.runtime.inactive": "Inactivo",
|
||||
"instanceServiceStatus.plugins.runtime.empty": "El runtime no informa de plugins.",
|
||||
"instanceServiceStatus.plugins.refresh": "Actualizar controles de plugins",
|
||||
"instanceServiceStatus.plugins.toggleAriaLabel": "Cambiar la regla de activación de {name}",
|
||||
"instanceServiceStatus.plugins.globalOverridden": "Una regla del proyecto reemplaza aquí el estado global.",
|
||||
"instanceServiceStatus.plugins.source.package": "Paquete: {source}",
|
||||
"instanceServiceStatus.plugins.source.local": "Fuente local: {source}",
|
||||
"instanceServiceStatus.plugins.source.builtin": "Integrado en OpenCode",
|
||||
"instanceServiceStatus.plugins.source.sdk": "Proporcionado por el host SDK",
|
||||
"instanceServiceStatus.plugins.ruleState.default": "Heredado",
|
||||
"instanceServiceStatus.plugins.ruleState.enabled": "Activado por regla",
|
||||
"instanceServiceStatus.plugins.ruleState.disabled": "Desactivado por regla",
|
||||
"instanceServiceStatus.plugins.toast.ruleSaved": "Se guardó la regla de activación {scope} para {name}.",
|
||||
"instanceServiceStatus.plugins.toast.ruleUnchanged": "La regla de activación {scope} para {name} ya está actualizada.",
|
||||
"instanceServiceStatus.plugins.toast.reloadNote": "Se aplicará cuando el daemon vuelva a cargar su configuración.",
|
||||
"instanceServiceStatus.plugins.errors.load": "No se pudieron cargar los controles de activación de plugins.",
|
||||
"instanceServiceStatus.plugins.errors.refresh": "La actualización falló. Se sigue mostrando el último estado conocido.",
|
||||
"instanceServiceStatus.plugins.errors.save": "No se pudo guardar la regla de activación de {name} en el ámbito «{scope}».",
|
||||
"instanceServiceStatus.plugins.errors.forbidden": "Este espacio de trabajo no tiene permiso para cambiar la ubicación de {name} en el ámbito «{scope}».",
|
||||
"instanceServiceStatus.plugins.errors.conflict": "El proyecto o la configuración cambió mientras se guardaba la regla de {name} en el ámbito «{scope}». Actualiza e inténtalo de nuevo.",
|
||||
"instanceServiceStatus.plugins.errors.invalidConfig": "La configuración de OpenCode de {name} en el ámbito «{scope}» tiene un formato incorrecto. No se modificó.",
|
||||
|
||||
"permissionBanner.pendingRequests.one": "{count} solicitud pendiente",
|
||||
"permissionBanner.pendingRequests.other": "{count} solicitudes pendientes",
|
||||
|
|
|
|||
|
|
@ -72,7 +72,40 @@ export const settingsMessages = {
|
|||
"instanceServiceStatus.mcp.empty": "Aucun serveur MCP détecté.",
|
||||
"instanceServiceStatus.mcp.toggleAriaLabel": "Basculer le serveur MCP {name}",
|
||||
"instanceServiceStatus.plugins.loading": "Chargement des plugins...",
|
||||
"instanceServiceStatus.plugins.empty": "Aucun plugin actif.",
|
||||
"instanceServiceStatus.plugins.empty": "Aucun plugin disponible pour l’activation.",
|
||||
"instanceServiceStatus.plugins.description": "Les contrôles d’activation V2 écrivent uniquement des règles ordonnées. L’installation, les mises à jour et les options restent dans la configuration OpenCode ou la CLI.",
|
||||
"instanceServiceStatus.plugins.scope.legend": "Écrire la prochaine règle dans",
|
||||
"instanceServiceStatus.plugins.scope.global": "Global",
|
||||
"instanceServiceStatus.plugins.scope.global.detail": "Tous les projets de ce daemon",
|
||||
"instanceServiceStatus.plugins.scope.project": "Projet",
|
||||
"instanceServiceStatus.plugins.scope.project.detail": "Cet emplacement de projet actif",
|
||||
"instanceServiceStatus.plugins.scope.unavailable": "Les contrôles du projet sont indisponibles : la cible de configuration est partagée avec la portée « Global » ou pointe en dehors de l’emplacement de ce projet.",
|
||||
"instanceServiceStatus.plugins.target.existing": "Cible de la règle : {path}",
|
||||
"instanceServiceStatus.plugins.target.new": "Sera créé : {path}",
|
||||
"instanceServiceStatus.plugins.runtime.heading": "Inventaire runtime",
|
||||
"instanceServiceStatus.plugins.runtime.active": "Actif",
|
||||
"instanceServiceStatus.plugins.runtime.failed": "En échec",
|
||||
"instanceServiceStatus.plugins.runtime.inactive": "Inactif",
|
||||
"instanceServiceStatus.plugins.runtime.empty": "Aucun plugin n’est signalé par le runtime.",
|
||||
"instanceServiceStatus.plugins.refresh": "Actualiser les contrôles des plugins",
|
||||
"instanceServiceStatus.plugins.toggleAriaLabel": "Modifier la règle d’activation de {name}",
|
||||
"instanceServiceStatus.plugins.globalOverridden": "Une règle projet remplace ici l’état global.",
|
||||
"instanceServiceStatus.plugins.source.package": "Package : {source}",
|
||||
"instanceServiceStatus.plugins.source.local": "Source locale : {source}",
|
||||
"instanceServiceStatus.plugins.source.builtin": "Intégré à OpenCode",
|
||||
"instanceServiceStatus.plugins.source.sdk": "Fourni par l’hôte SDK",
|
||||
"instanceServiceStatus.plugins.ruleState.default": "Hérité",
|
||||
"instanceServiceStatus.plugins.ruleState.enabled": "Activé par règle",
|
||||
"instanceServiceStatus.plugins.ruleState.disabled": "Désactivé par règle",
|
||||
"instanceServiceStatus.plugins.toast.ruleSaved": "Règle d’activation de {name} enregistrée dans la portée « {scope} ».",
|
||||
"instanceServiceStatus.plugins.toast.ruleUnchanged": "La règle d’activation de {name} est déjà à jour dans la portée « {scope} ».",
|
||||
"instanceServiceStatus.plugins.toast.reloadNote": "Elle prend effet quand le daemon recharge sa configuration.",
|
||||
"instanceServiceStatus.plugins.errors.load": "Impossible de charger les contrôles d’activation des plugins.",
|
||||
"instanceServiceStatus.plugins.errors.refresh": "L’actualisation a échoué. Le dernier état connu reste affiché.",
|
||||
"instanceServiceStatus.plugins.errors.save": "La règle d’activation de {name} n’a pas pu être enregistrée dans la portée « {scope} ».",
|
||||
"instanceServiceStatus.plugins.errors.forbidden": "Cet espace de travail ne peut pas modifier la règle de {name} dans la portée « {scope} ».",
|
||||
"instanceServiceStatus.plugins.errors.conflict": "Le projet ou la configuration a changé pendant l’enregistrement de la règle de {name} dans la portée « {scope} ». Actualisez puis réessayez.",
|
||||
"instanceServiceStatus.plugins.errors.invalidConfig": "La configuration OpenCode dans la portée « {scope} » est mal formée pour {name}. Elle n’a pas été modifiée.",
|
||||
|
||||
"permissionBanner.pendingRequests.one": "{count} demande en attente",
|
||||
"permissionBanner.pendingRequests.other": "{count} demandes en attente",
|
||||
|
|
|
|||
|
|
@ -72,7 +72,40 @@ export const settingsMessages = {
|
|||
"instanceServiceStatus.mcp.empty": "לא זוהו שרתי MCP.",
|
||||
"instanceServiceStatus.mcp.toggleAriaLabel": "הפעל/כבה שרת MCP {name}",
|
||||
"instanceServiceStatus.plugins.loading": "טוען תוספים...",
|
||||
"instanceServiceStatus.plugins.empty": "אין תוספים פעילים.",
|
||||
"instanceServiceStatus.plugins.empty": "אין תוספים זמינים להפעלה.",
|
||||
"instanceServiceStatus.plugins.description": "פקדי ההפעלה של V2 כותבים כללים מסודרים בלבד. התקנה, עדכונים ואפשרויות נשארים בתצורת OpenCode או ב-CLI.",
|
||||
"instanceServiceStatus.plugins.scope.legend": "כתיבת הכלל הבא אל",
|
||||
"instanceServiceStatus.plugins.scope.global": "כללי",
|
||||
"instanceServiceStatus.plugins.scope.global.detail": "כל הפרויקטים המשתמשים בתהליך זה",
|
||||
"instanceServiceStatus.plugins.scope.project": "פרויקט",
|
||||
"instanceServiceStatus.plugins.scope.project.detail": "מיקום הפרויקט הפעיל הזה",
|
||||
"instanceServiceStatus.plugins.scope.unavailable": "פקדי הפרויקט אינם זמינים: יעד התצורה משותף עם ההיקף «כללי» או מפנה אל מחוץ למיקום של פרויקט זה.",
|
||||
"instanceServiceStatus.plugins.target.existing": "יעד הכלל: {path}",
|
||||
"instanceServiceStatus.plugins.target.new": "ייווצר: {path}",
|
||||
"instanceServiceStatus.plugins.runtime.heading": "מלאי זמן ריצה",
|
||||
"instanceServiceStatus.plugins.runtime.active": "פעיל",
|
||||
"instanceServiceStatus.plugins.runtime.failed": "נכשל",
|
||||
"instanceServiceStatus.plugins.runtime.inactive": "לא פועל",
|
||||
"instanceServiceStatus.plugins.runtime.empty": "זמן הריצה אינו מדווח על תוספים.",
|
||||
"instanceServiceStatus.plugins.refresh": "רענון פקדי התוספים",
|
||||
"instanceServiceStatus.plugins.toggleAriaLabel": "שינוי כלל ההפעלה עבור {name}",
|
||||
"instanceServiceStatus.plugins.globalOverridden": "כלל פרויקט דורס כאן את המצב הכללי.",
|
||||
"instanceServiceStatus.plugins.source.package": "חבילה: {source}",
|
||||
"instanceServiceStatus.plugins.source.local": "מקור מקומי: {source}",
|
||||
"instanceServiceStatus.plugins.source.builtin": "מובנה ב-OpenCode",
|
||||
"instanceServiceStatus.plugins.source.sdk": "מסופק על ידי מארח ה-SDK",
|
||||
"instanceServiceStatus.plugins.ruleState.default": "בירושה",
|
||||
"instanceServiceStatus.plugins.ruleState.enabled": "מופעל בכלל",
|
||||
"instanceServiceStatus.plugins.ruleState.disabled": "מושבת בכלל",
|
||||
"instanceServiceStatus.plugins.toast.ruleSaved": "כלל ההפעלה {scope} עבור {name} נשמר.",
|
||||
"instanceServiceStatus.plugins.toast.ruleUnchanged": "כלל ההפעלה {scope} עבור {name} כבר מעודכן.",
|
||||
"instanceServiceStatus.plugins.toast.reloadNote": "הכלל ייכנס לתוקף כאשר תהליך הרקע יטען מחדש את התצורה שלו.",
|
||||
"instanceServiceStatus.plugins.errors.load": "לא ניתן לטעון את פקדי הפעלת התוספים.",
|
||||
"instanceServiceStatus.plugins.errors.refresh": "הרענון נכשל. המצב האחרון הידוע עדיין מוצג.",
|
||||
"instanceServiceStatus.plugins.errors.save": "לא ניתן לשמור את כלל ההפעלה עבור {name} בהיקף «{scope}».",
|
||||
"instanceServiceStatus.plugins.errors.forbidden": "לסביבת עבודה זו אין הרשאה לשנות את המיקום של {name} בהיקף «{scope}».",
|
||||
"instanceServiceStatus.plugins.errors.conflict": "הפרויקט או התצורה השתנו במהלך שמירת הכלל עבור {name} בהיקף «{scope}». יש לרענן ולנסות שוב.",
|
||||
"instanceServiceStatus.plugins.errors.invalidConfig": "תצורת OpenCode עבור {name} בהיקף «{scope}» אינה תקינה. היא לא שונתה.",
|
||||
|
||||
"permissionBanner.pendingRequests.one": "{count} בקשה ממתינה",
|
||||
"permissionBanner.pendingRequests.other": "{count} בקשות ממתינות",
|
||||
|
|
|
|||
|
|
@ -72,7 +72,40 @@ export const settingsMessages = {
|
|||
"instanceServiceStatus.mcp.empty": "MCP サーバーが検出されませんでした。",
|
||||
"instanceServiceStatus.mcp.toggleAriaLabel": "{name} MCP サーバーを切り替え",
|
||||
"instanceServiceStatus.plugins.loading": "プラグインを読み込み中...",
|
||||
"instanceServiceStatus.plugins.empty": "有効なプラグインはありません。",
|
||||
"instanceServiceStatus.plugins.empty": "有効化できるプラグインはありません。",
|
||||
"instanceServiceStatus.plugins.description": "V2 の有効化コントロールは順序付きルールのみを書き込みます。インストール、更新、オプションは OpenCode 設定または CLI で管理します。",
|
||||
"instanceServiceStatus.plugins.scope.legend": "次のルールの書き込み先",
|
||||
"instanceServiceStatus.plugins.scope.global": "グローバル",
|
||||
"instanceServiceStatus.plugins.scope.global.detail": "このデーモンを使うすべてのプロジェクト",
|
||||
"instanceServiceStatus.plugins.scope.project": "プロジェクト",
|
||||
"instanceServiceStatus.plugins.scope.project.detail": "現在のプロジェクト場所",
|
||||
"instanceServiceStatus.plugins.scope.unavailable": "プロジェクトの操作は利用できません。設定先が「グローバル」と共有されているか、このプロジェクトの場所の外部を参照しています。",
|
||||
"instanceServiceStatus.plugins.target.existing": "ルールの保存先: {path}",
|
||||
"instanceServiceStatus.plugins.target.new": "作成予定: {path}",
|
||||
"instanceServiceStatus.plugins.runtime.heading": "ランタイムインベントリ",
|
||||
"instanceServiceStatus.plugins.runtime.active": "有効",
|
||||
"instanceServiceStatus.plugins.runtime.failed": "失敗",
|
||||
"instanceServiceStatus.plugins.runtime.inactive": "未実行",
|
||||
"instanceServiceStatus.plugins.runtime.empty": "ランタイムからプラグインが報告されていません。",
|
||||
"instanceServiceStatus.plugins.refresh": "プラグインコントロールを更新",
|
||||
"instanceServiceStatus.plugins.toggleAriaLabel": "{name} の有効化ルールを変更",
|
||||
"instanceServiceStatus.plugins.globalOverridden": "ここではプロジェクトルールがグローバル状態を上書きしています。",
|
||||
"instanceServiceStatus.plugins.source.package": "パッケージ: {source}",
|
||||
"instanceServiceStatus.plugins.source.local": "ローカルソース: {source}",
|
||||
"instanceServiceStatus.plugins.source.builtin": "OpenCode 組み込み",
|
||||
"instanceServiceStatus.plugins.source.sdk": "SDK ホストから提供",
|
||||
"instanceServiceStatus.plugins.ruleState.default": "継承",
|
||||
"instanceServiceStatus.plugins.ruleState.enabled": "ルールで有効",
|
||||
"instanceServiceStatus.plugins.ruleState.disabled": "ルールで無効",
|
||||
"instanceServiceStatus.plugins.toast.ruleSaved": "{name} の {scope} 有効化ルールを保存しました。",
|
||||
"instanceServiceStatus.plugins.toast.ruleUnchanged": "{name} の {scope} 有効化ルールはすでに最新です。",
|
||||
"instanceServiceStatus.plugins.toast.reloadNote": "デーモンが設定を再読み込みすると、このルールが適用されます。",
|
||||
"instanceServiceStatus.plugins.errors.load": "プラグイン有効化コントロールを読み込めませんでした。",
|
||||
"instanceServiceStatus.plugins.errors.refresh": "更新に失敗しました。最後に確認できた状態を表示しています。",
|
||||
"instanceServiceStatus.plugins.errors.save": "{name} の有効化ルール(スコープ: {scope})を保存できませんでした。",
|
||||
"instanceServiceStatus.plugins.errors.forbidden": "このワークスペースには、{name} の設定場所(スコープ: {scope})を変更する権限がありません。",
|
||||
"instanceServiceStatus.plugins.errors.conflict": "{name} のルール(スコープ: {scope})を保存中に、プロジェクトまたは設定が変更されました。更新して再試行してください。",
|
||||
"instanceServiceStatus.plugins.errors.invalidConfig": "{name} の OpenCode 設定(スコープ: {scope})の形式が正しくありません。設定は変更されませんでした。",
|
||||
|
||||
"permissionBanner.pendingRequests.one": "{count} 件の保留中リクエスト",
|
||||
"permissionBanner.pendingRequests.other": "{count} 件の保留中リクエスト",
|
||||
|
|
|
|||
|
|
@ -72,7 +72,40 @@ export const settingsMessages = {
|
|||
"instanceServiceStatus.mcp.empty": "कुनै MCP सर्भरहरू फेला परेनन्।",
|
||||
"instanceServiceStatus.mcp.toggleAriaLabel": "{name} MCP सर्भर टगल गर्नुहोस्",
|
||||
"instanceServiceStatus.plugins.loading": "प्लगइनहरू लोड गर्दै...",
|
||||
"instanceServiceStatus.plugins.empty": "कुनै सक्रिय प्लगइनहरू छैनन्।",
|
||||
"instanceServiceStatus.plugins.empty": "सक्रिय गर्न मिल्ने कुनै प्लगइन उपलब्ध छैन।",
|
||||
"instanceServiceStatus.plugins.description": "V2 सक्रियता नियन्त्रणले क्रमबद्ध नियम मात्र लेख्छ। स्थापना, अद्यावधिक र विकल्पहरू OpenCode कन्फिगरेसन वा CLI मै रहन्छन्।",
|
||||
"instanceServiceStatus.plugins.scope.legend": "अर्को नियम यहाँ लेख्नुहोस्",
|
||||
"instanceServiceStatus.plugins.scope.global": "विश्वव्यापी",
|
||||
"instanceServiceStatus.plugins.scope.global.detail": "यो daemon प्रयोग गर्ने सबै परियोजना",
|
||||
"instanceServiceStatus.plugins.scope.project": "परियोजना",
|
||||
"instanceServiceStatus.plugins.scope.project.detail": "यो सक्रिय परियोजना स्थान",
|
||||
"instanceServiceStatus.plugins.scope.unavailable": "परियोजना नियन्त्रण उपलब्ध छैन: कन्फिगरेसनको लक्ष्य स्थान «विश्वव्यापी» दायरासँग साझा छ वा यस परियोजनाको स्थानभन्दा बाहिर पर्छ।",
|
||||
"instanceServiceStatus.plugins.target.existing": "नियम लक्ष्य: {path}",
|
||||
"instanceServiceStatus.plugins.target.new": "सिर्जना गरिनेछ: {path}",
|
||||
"instanceServiceStatus.plugins.runtime.heading": "रनटाइम सूची",
|
||||
"instanceServiceStatus.plugins.runtime.active": "सक्रिय",
|
||||
"instanceServiceStatus.plugins.runtime.failed": "असफल",
|
||||
"instanceServiceStatus.plugins.runtime.inactive": "चलिरहेको छैन",
|
||||
"instanceServiceStatus.plugins.runtime.empty": "रनटाइमले कुनै प्लगइन रिपोर्ट गरेको छैन।",
|
||||
"instanceServiceStatus.plugins.refresh": "प्लगइन नियन्त्रण ताजा गर्नुहोस्",
|
||||
"instanceServiceStatus.plugins.toggleAriaLabel": "{name} को सक्रियता नियम परिवर्तन गर्नुहोस्",
|
||||
"instanceServiceStatus.plugins.globalOverridden": "परियोजना नियमले यहाँ विश्वव्यापी अवस्था अधिलेखन गर्छ।",
|
||||
"instanceServiceStatus.plugins.source.package": "प्याकेज: {source}",
|
||||
"instanceServiceStatus.plugins.source.local": "स्थानीय स्रोत: {source}",
|
||||
"instanceServiceStatus.plugins.source.builtin": "OpenCode मा निर्मित",
|
||||
"instanceServiceStatus.plugins.source.sdk": "SDK होस्टद्वारा प्रदान गरिएको",
|
||||
"instanceServiceStatus.plugins.ruleState.default": "वंशानुगत",
|
||||
"instanceServiceStatus.plugins.ruleState.enabled": "नियमद्वारा सक्रिय",
|
||||
"instanceServiceStatus.plugins.ruleState.disabled": "नियमद्वारा निष्क्रिय",
|
||||
"instanceServiceStatus.plugins.toast.ruleSaved": "{name} को {scope} सक्रियता नियम सुरक्षित गरियो।",
|
||||
"instanceServiceStatus.plugins.toast.ruleUnchanged": "{name} को {scope} सक्रियता नियम पहिले नै अद्यावधिक छ।",
|
||||
"instanceServiceStatus.plugins.toast.reloadNote": "daemon ले आफ्नो कन्फिगरेसन पुनः लोड गरेपछि यो नियम लागू हुन्छ।",
|
||||
"instanceServiceStatus.plugins.errors.load": "प्लगइन सक्रियता नियन्त्रण लोड गर्न सकिएन।",
|
||||
"instanceServiceStatus.plugins.errors.refresh": "ताजा गर्न असफल भयो। पछिल्लो ज्ञात अवस्था अझै देखाइएको छ।",
|
||||
"instanceServiceStatus.plugins.errors.save": "{name} को {scope} दायराको सक्रियता नियम सुरक्षित गर्न सकिएन।",
|
||||
"instanceServiceStatus.plugins.errors.forbidden": "यो कार्यस्थानलाई {name} को {scope} दायराको स्थान परिवर्तन गर्ने अनुमति छैन।",
|
||||
"instanceServiceStatus.plugins.errors.conflict": "{name} को {scope} दायराको नियम सुरक्षित गर्ने क्रममा परियोजना वा कन्फिगरेसन परिवर्तन भयो। ताजा गरेर पुनः प्रयास गर्नुहोस्।",
|
||||
"instanceServiceStatus.plugins.errors.invalidConfig": "{name} को {scope} दायराको OpenCode कन्फिगरेसनको ढाँचा मिलेको छैन। यसलाई परिवर्तन गरिएन।",
|
||||
|
||||
"permissionBanner.pendingRequests.one": "{count} विचाराधीन अनुरोध",
|
||||
"permissionBanner.pendingRequests.other": "{count} विचाराधीन अनुरोधहरू",
|
||||
|
|
|
|||
|
|
@ -72,7 +72,40 @@ export const settingsMessages = {
|
|||
"instanceServiceStatus.mcp.empty": "MCP-серверы не обнаружены.",
|
||||
"instanceServiceStatus.mcp.toggleAriaLabel": "Переключить MCP-сервер {name}",
|
||||
"instanceServiceStatus.plugins.loading": "Загрузка плагинов…",
|
||||
"instanceServiceStatus.plugins.empty": "Нет активных плагинов.",
|
||||
"instanceServiceStatus.plugins.empty": "Нет плагинов, доступных для активации.",
|
||||
"instanceServiceStatus.plugins.description": "Элементы управления V2 записывают только упорядоченные правила активации. Установка, обновления и параметры остаются в конфигурации OpenCode или CLI.",
|
||||
"instanceServiceStatus.plugins.scope.legend": "Записать следующее правило в",
|
||||
"instanceServiceStatus.plugins.scope.global": "Глобально",
|
||||
"instanceServiceStatus.plugins.scope.global.detail": "Все проекты этого демона",
|
||||
"instanceServiceStatus.plugins.scope.project": "Проект",
|
||||
"instanceServiceStatus.plugins.scope.project.detail": "Текущее расположение проекта",
|
||||
"instanceServiceStatus.plugins.scope.unavailable": "Управление на уровне проекта недоступно: целевой путь конфигурации общий с областью «Глобально» или ведёт за пределы расположения этого проекта.",
|
||||
"instanceServiceStatus.plugins.target.existing": "Файл правила: {path}",
|
||||
"instanceServiceStatus.plugins.target.new": "Будет создан: {path}",
|
||||
"instanceServiceStatus.plugins.runtime.heading": "Инвентарь среды выполнения",
|
||||
"instanceServiceStatus.plugins.runtime.active": "Активен",
|
||||
"instanceServiceStatus.plugins.runtime.failed": "Ошибка",
|
||||
"instanceServiceStatus.plugins.runtime.inactive": "Не запущен",
|
||||
"instanceServiceStatus.plugins.runtime.empty": "Среда выполнения не сообщает о плагинах.",
|
||||
"instanceServiceStatus.plugins.refresh": "Обновить управление плагинами",
|
||||
"instanceServiceStatus.plugins.toggleAriaLabel": "Изменить правило активации для {name}",
|
||||
"instanceServiceStatus.plugins.globalOverridden": "Правило проекта переопределяет здесь глобальное состояние.",
|
||||
"instanceServiceStatus.plugins.source.package": "Пакет: {source}",
|
||||
"instanceServiceStatus.plugins.source.local": "Локальный источник: {source}",
|
||||
"instanceServiceStatus.plugins.source.builtin": "Встроен в OpenCode",
|
||||
"instanceServiceStatus.plugins.source.sdk": "Предоставлен хостом SDK",
|
||||
"instanceServiceStatus.plugins.ruleState.default": "Унаследовано",
|
||||
"instanceServiceStatus.plugins.ruleState.enabled": "Включено правилом",
|
||||
"instanceServiceStatus.plugins.ruleState.disabled": "Отключено правилом",
|
||||
"instanceServiceStatus.plugins.toast.ruleSaved": "Правило активации «{scope}» для {name} сохранено.",
|
||||
"instanceServiceStatus.plugins.toast.ruleUnchanged": "Правило активации «{scope}» для {name} уже актуально.",
|
||||
"instanceServiceStatus.plugins.toast.reloadNote": "Оно вступит в силу, когда демон перезагрузит свою конфигурацию.",
|
||||
"instanceServiceStatus.plugins.errors.load": "Не удалось загрузить управление активацией плагинов.",
|
||||
"instanceServiceStatus.plugins.errors.refresh": "Обновление не удалось. Показано последнее известное состояние.",
|
||||
"instanceServiceStatus.plugins.errors.save": "Не удалось сохранить правило активации для {name} в области «{scope}».",
|
||||
"instanceServiceStatus.plugins.errors.forbidden": "Этому рабочему пространству запрещено менять расположение конфигурации {name} в области «{scope}».",
|
||||
"instanceServiceStatus.plugins.errors.conflict": "Проект или конфигурация изменились во время сохранения правила для {name} в области «{scope}». Обновите данные и повторите попытку.",
|
||||
"instanceServiceStatus.plugins.errors.invalidConfig": "Конфигурация OpenCode для {name} в области «{scope}» имеет неверный формат. Она не была изменена.",
|
||||
|
||||
"permissionBanner.pendingRequests.one": "{count} ожидающий запрос",
|
||||
"permissionBanner.pendingRequests.other": "{count} ожидающих запросов",
|
||||
|
|
|
|||
|
|
@ -72,7 +72,40 @@ export const settingsMessages = {
|
|||
"instanceServiceStatus.mcp.empty": "Algılanan MCP sunucusu yok.",
|
||||
"instanceServiceStatus.mcp.toggleAriaLabel": "{name} MCP sunucusunu aç/kapat",
|
||||
"instanceServiceStatus.plugins.loading": "Eklentiler yükleniyor...",
|
||||
"instanceServiceStatus.plugins.empty": "Etkin plugin yok.",
|
||||
"instanceServiceStatus.plugins.empty": "Etkinleştirilebilecek eklenti yok.",
|
||||
"instanceServiceStatus.plugins.description": "V2 etkinleştirme denetimleri yalnızca sıralı kurallar yazar. Kurulum, güncellemeler ve seçenekler OpenCode yapılandırmasında veya CLI'da kalır.",
|
||||
"instanceServiceStatus.plugins.scope.legend": "Sonraki kuralı şuraya yaz",
|
||||
"instanceServiceStatus.plugins.scope.global": "Genel",
|
||||
"instanceServiceStatus.plugins.scope.global.detail": "Bu daemon'u kullanan tüm projeler",
|
||||
"instanceServiceStatus.plugins.scope.project": "Proje",
|
||||
"instanceServiceStatus.plugins.scope.project.detail": "Bu etkin proje konumu",
|
||||
"instanceServiceStatus.plugins.scope.unavailable": "Proje denetimleri kullanılamıyor: yapılandırma hedefi «Genel» kapsamıyla paylaşılıyor veya bu projenin konumunun dışına işaret ediyor.",
|
||||
"instanceServiceStatus.plugins.target.existing": "Kural hedefi: {path}",
|
||||
"instanceServiceStatus.plugins.target.new": "Oluşturulacak: {path}",
|
||||
"instanceServiceStatus.plugins.runtime.heading": "Çalışma zamanı envanteri",
|
||||
"instanceServiceStatus.plugins.runtime.active": "Etkin",
|
||||
"instanceServiceStatus.plugins.runtime.failed": "Başarısız",
|
||||
"instanceServiceStatus.plugins.runtime.inactive": "Çalışmıyor",
|
||||
"instanceServiceStatus.plugins.runtime.empty": "Çalışma zamanı hiçbir plugin bildirmiyor.",
|
||||
"instanceServiceStatus.plugins.refresh": "Plugin denetimlerini yenile",
|
||||
"instanceServiceStatus.plugins.toggleAriaLabel": "{name} için etkinleştirme kuralını değiştir",
|
||||
"instanceServiceStatus.plugins.globalOverridden": "Bir proje kuralı burada genel durumu geçersiz kılıyor.",
|
||||
"instanceServiceStatus.plugins.source.package": "Paket: {source}",
|
||||
"instanceServiceStatus.plugins.source.local": "Yerel kaynak: {source}",
|
||||
"instanceServiceStatus.plugins.source.builtin": "OpenCode'a yerleşik",
|
||||
"instanceServiceStatus.plugins.source.sdk": "SDK ana bilgisayarı tarafından sağlanıyor",
|
||||
"instanceServiceStatus.plugins.ruleState.default": "Devralındı",
|
||||
"instanceServiceStatus.plugins.ruleState.enabled": "Kuralla açık",
|
||||
"instanceServiceStatus.plugins.ruleState.disabled": "Kuralla kapalı",
|
||||
"instanceServiceStatus.plugins.toast.ruleSaved": "{name} için {scope} etkinleştirme kuralı kaydedildi.",
|
||||
"instanceServiceStatus.plugins.toast.ruleUnchanged": "{name} için {scope} etkinleştirme kuralı zaten güncel.",
|
||||
"instanceServiceStatus.plugins.toast.reloadNote": "Kural, daemon yapılandırmasını yeniden yüklediğinde yürürlüğe girer.",
|
||||
"instanceServiceStatus.plugins.errors.load": "Plugin etkinleştirme denetimleri yüklenemedi.",
|
||||
"instanceServiceStatus.plugins.errors.refresh": "Yenileme başarısız oldu. Bilinen son durum gösterilmeye devam ediyor.",
|
||||
"instanceServiceStatus.plugins.errors.save": "{name} için «{scope}» kapsamındaki etkinleştirme kuralı kaydedilemedi.",
|
||||
"instanceServiceStatus.plugins.errors.forbidden": "Bu çalışma alanının {name} için «{scope}» kapsamındaki konumu değiştirme izni yok.",
|
||||
"instanceServiceStatus.plugins.errors.conflict": "{name} için «{scope}» kapsamındaki kural kaydedilirken proje veya yapılandırma değişti. Yenileyip tekrar deneyin.",
|
||||
"instanceServiceStatus.plugins.errors.invalidConfig": "{name} için «{scope}» kapsamındaki OpenCode yapılandırmasının biçimi hatalı. Yapılandırma değiştirilmedi.",
|
||||
"permissionBanner.pendingRequests.one": "{count} bekleyen istek",
|
||||
"permissionBanner.pendingRequests.other": "{count} bekleyen istek",
|
||||
"permissionBanner.detail.permission.one": "{count} izin",
|
||||
|
|
|
|||
|
|
@ -72,7 +72,40 @@ export const settingsMessages = {
|
|||
"instanceServiceStatus.mcp.empty": "未检测到 MCP 服务器。",
|
||||
"instanceServiceStatus.mcp.toggleAriaLabel": "切换 {name} MCP 服务器",
|
||||
"instanceServiceStatus.plugins.loading": "正在加载插件...",
|
||||
"instanceServiceStatus.plugins.empty": "没有活动插件。",
|
||||
"instanceServiceStatus.plugins.empty": "没有可供激活的插件。",
|
||||
"instanceServiceStatus.plugins.description": "V2 激活控件只写入有序规则。安装、更新和选项仍由 OpenCode 配置或 CLI 管理。",
|
||||
"instanceServiceStatus.plugins.scope.legend": "将下一条规则写入",
|
||||
"instanceServiceStatus.plugins.scope.global": "全局",
|
||||
"instanceServiceStatus.plugins.scope.global.detail": "使用此守护进程的所有项目",
|
||||
"instanceServiceStatus.plugins.scope.project": "项目",
|
||||
"instanceServiceStatus.plugins.scope.project.detail": "当前活动项目位置",
|
||||
"instanceServiceStatus.plugins.scope.unavailable": "项目控件不可用:配置目标与“全局”共用,或指向此项目位置之外。",
|
||||
"instanceServiceStatus.plugins.target.existing": "规则目标:{path}",
|
||||
"instanceServiceStatus.plugins.target.new": "将创建:{path}",
|
||||
"instanceServiceStatus.plugins.runtime.heading": "运行时清单",
|
||||
"instanceServiceStatus.plugins.runtime.active": "活动",
|
||||
"instanceServiceStatus.plugins.runtime.failed": "失败",
|
||||
"instanceServiceStatus.plugins.runtime.inactive": "未运行",
|
||||
"instanceServiceStatus.plugins.runtime.empty": "运行时未报告任何插件。",
|
||||
"instanceServiceStatus.plugins.refresh": "刷新插件控件",
|
||||
"instanceServiceStatus.plugins.toggleAriaLabel": "更改 {name} 的激活规则",
|
||||
"instanceServiceStatus.plugins.globalOverridden": "项目规则在此处覆盖了全局状态。",
|
||||
"instanceServiceStatus.plugins.source.package": "软件包:{source}",
|
||||
"instanceServiceStatus.plugins.source.local": "本地来源:{source}",
|
||||
"instanceServiceStatus.plugins.source.builtin": "OpenCode 内置",
|
||||
"instanceServiceStatus.plugins.source.sdk": "由 SDK 主机提供",
|
||||
"instanceServiceStatus.plugins.ruleState.default": "继承",
|
||||
"instanceServiceStatus.plugins.ruleState.enabled": "规则已启用",
|
||||
"instanceServiceStatus.plugins.ruleState.disabled": "规则已禁用",
|
||||
"instanceServiceStatus.plugins.toast.ruleSaved": "已保存 {name} 的{scope}激活规则。",
|
||||
"instanceServiceStatus.plugins.toast.ruleUnchanged": "{name} 的{scope}激活规则已是最新。",
|
||||
"instanceServiceStatus.plugins.toast.reloadNote": "守护进程重新加载配置后,该规则才会生效。",
|
||||
"instanceServiceStatus.plugins.errors.load": "无法加载插件激活控件。",
|
||||
"instanceServiceStatus.plugins.errors.refresh": "刷新失败,仍显示最后已知状态。",
|
||||
"instanceServiceStatus.plugins.errors.save": "无法保存 {name} 的{scope}激活规则。",
|
||||
"instanceServiceStatus.plugins.errors.forbidden": "此工作区无权更改 {name} 的{scope}配置位置。",
|
||||
"instanceServiceStatus.plugins.errors.conflict": "保存 {name} 的{scope}规则时,项目或配置发生了更改。请刷新后重试。",
|
||||
"instanceServiceStatus.plugins.errors.invalidConfig": "{name} 的{scope} OpenCode 配置格式错误,未进行更改。",
|
||||
|
||||
"permissionBanner.pendingRequests.one": "{count} 个待处理请求",
|
||||
"permissionBanner.pendingRequests.other": "{count} 个待处理请求",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ describe("instance invalidation targets", () => {
|
|||
assert.deepEqual(getInstanceRefreshTargets("command.updated"), ["commands"])
|
||||
assert.deepEqual(getInstanceRefreshTargets("models-dev.refreshed"), ["agents", "providers", "commands"])
|
||||
assert.deepEqual(getInstanceRefreshTargets("model.updated"), ["providers"])
|
||||
assert.deepEqual(getInstanceRefreshTargets("plugin.updated"), ["agents", "providers", "commands", "metadata"])
|
||||
assert.deepEqual(getInstanceRefreshTargets("plugin.updated"), ["agents", "providers", "commands"])
|
||||
assert.deepEqual(getInstanceRefreshTargets("plugin.added"), [])
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export function getInstanceRefreshTargets(eventType: string): readonly InstanceR
|
|||
case "config.updated":
|
||||
return ["agents", "providers", "commands", "metadata"]
|
||||
case "plugin.updated":
|
||||
return ["agents", "providers", "commands", "metadata"]
|
||||
return ["agents", "providers", "commands"]
|
||||
case "mcp.status.changed":
|
||||
case "mcp.resources.changed":
|
||||
return ["metadata"]
|
||||
|
|
|
|||
401
packages/ui/src/stores/plugin-controls-demand.test.ts
Normal file
401
packages/ui/src/stores/plugin-controls-demand.test.ts
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
import { createRoot, onCleanup } from "solid-js"
|
||||
import type { PluginControlsSnapshot } from "../../../server/src/api-types"
|
||||
import { PluginControlsCache } from "./plugin-controls"
|
||||
|
||||
const location = { directory: "/repo" }
|
||||
|
||||
describe("plugin controls visible demand", () => {
|
||||
for (const dispose of [false, true]) {
|
||||
it(`defers a queued refresh after ${dispose ? "disposal" : "deactivation"} until reopening`, async (t) => {
|
||||
const pending = deferred<PluginControlsSnapshot>()
|
||||
let reads = 0
|
||||
const cache = new PluginControlsCache({
|
||||
getPluginControls: async () => ++reads === 2 ? pending.promise : snapshot(`read-${reads}`),
|
||||
setPluginActivation: async () => { throw new Error("not used") },
|
||||
})
|
||||
const release = dispose
|
||||
? createRoot((disposeRoot) => {
|
||||
onCleanup(cache.acquireDemand("instance", location))
|
||||
return disposeRoot
|
||||
})
|
||||
: cache.acquireDemand("instance", location)
|
||||
t.after(release)
|
||||
await tick()
|
||||
const refresh = cache.load("instance", location, { force: true })
|
||||
cache.invalidateInstance("instance")
|
||||
release()
|
||||
release()
|
||||
pending.resolve(snapshot("obsolete"))
|
||||
await refresh
|
||||
await tick()
|
||||
|
||||
assert.equal(reads, 2, "no follow-up starts without a visible owner")
|
||||
assert.equal(cache.state("instance", location).stale, true)
|
||||
assert.equal(cache.state("instance", location).snapshot?.controls[0].id, "read-1")
|
||||
t.after(cache.acquireDemand("instance", location))
|
||||
await tick()
|
||||
assert.equal(reads, 3)
|
||||
assert.equal(cache.state("instance", location).stale, false)
|
||||
assert.equal(cache.state("instance", location).snapshot?.controls[0].id, "read-3")
|
||||
})
|
||||
}
|
||||
|
||||
it("keeps a queued refresh while another consumer remains and releases idempotently", async (t) => {
|
||||
const pending = deferred<PluginControlsSnapshot>()
|
||||
let reads = 0
|
||||
const cache = new PluginControlsCache({
|
||||
getPluginControls: async () => ++reads === 2 ? pending.promise : snapshot(`read-${reads}`),
|
||||
setPluginActivation: async () => { throw new Error("not used") },
|
||||
})
|
||||
const first = cache.acquireDemand("instance", location)
|
||||
const second = cache.acquireDemand("instance", location)
|
||||
t.after(first)
|
||||
t.after(second)
|
||||
await tick()
|
||||
assert.equal(reads, 1, "consumers share initial loading")
|
||||
const refresh = cache.load("instance", location, { force: true })
|
||||
cache.invalidateInstance("instance")
|
||||
first()
|
||||
first()
|
||||
pending.resolve(snapshot("obsolete"))
|
||||
await refresh
|
||||
await tick()
|
||||
assert.equal(reads, 3, "remaining owner retains one trailing read")
|
||||
assert.equal(cache.state("instance", location).snapshot?.controls[0].id, "read-3")
|
||||
second()
|
||||
cache.invalidateInstance("instance")
|
||||
await tick()
|
||||
assert.equal(reads, 3)
|
||||
assert.equal(cache.state("instance", location).stale, true)
|
||||
})
|
||||
|
||||
it("retains demand when reopening before the old read settles", async (t) => {
|
||||
const pending = deferred<PluginControlsSnapshot>()
|
||||
let reads = 0
|
||||
const cache = new PluginControlsCache({
|
||||
getPluginControls: async () => ++reads === 1 ? pending.promise : snapshot("fresh"),
|
||||
setPluginActivation: async () => { throw new Error("not used") },
|
||||
})
|
||||
const release = cache.acquireDemand("instance", location)
|
||||
cache.invalidateInstance("instance")
|
||||
release()
|
||||
t.after(cache.acquireDemand("instance", location))
|
||||
assert.equal(reads, 1)
|
||||
pending.resolve(snapshot("obsolete"))
|
||||
await tick()
|
||||
assert.equal(reads, 2)
|
||||
assert.equal(cache.state("instance", location).snapshot?.controls[0].id, "fresh")
|
||||
})
|
||||
|
||||
for (const canonicalWins of [false, true]) {
|
||||
it(`transfers demand handles when the ${canonicalWins ? "canonical" : "host"} alias wins`, async (t) => {
|
||||
const host = { directory: "\\\\wsl.localhost\\Ubuntu\\srv\\repo" }
|
||||
const canonical = { directory: "/srv/repo" }
|
||||
const hostRead = deferred<PluginControlsSnapshot>()
|
||||
const canonicalRead = deferred<PluginControlsSnapshot>()
|
||||
const pending = deferred<PluginControlsSnapshot>()
|
||||
let reads = 0
|
||||
const cache = new PluginControlsCache({
|
||||
getPluginControls: async () => [hostRead, canonicalRead, pending][reads++].promise,
|
||||
setPluginActivation: async () => { throw new Error("not used") },
|
||||
})
|
||||
const releaseHost = cache.acquireDemand("instance", host)
|
||||
const releaseCanonical = cache.acquireDemand("instance", canonical)
|
||||
t.after(releaseHost)
|
||||
t.after(releaseCanonical)
|
||||
const value = { ...snapshot("initial"), location: canonical }
|
||||
const winner = canonicalWins ? canonicalRead : hostRead
|
||||
const loser = canonicalWins ? hostRead : canonicalRead
|
||||
winner.resolve(value)
|
||||
await tick()
|
||||
loser.resolve(value)
|
||||
await tick()
|
||||
// Release the owner originally on the orphaned record. Its handle must
|
||||
// remove only itself from the merged record, not lose the other owner.
|
||||
const releaseOrphan = canonicalWins ? releaseHost : releaseCanonical
|
||||
const releaseWinner = canonicalWins ? releaseCanonical : releaseHost
|
||||
releaseOrphan()
|
||||
cache.invalidateLocation("instance", canonical)
|
||||
assert.equal(reads, 3, "surviving owner refreshes the merged identity")
|
||||
cache.invalidateLocation("instance", canonical)
|
||||
releaseWinner()
|
||||
pending.resolve({ ...snapshot("obsolete"), location: canonical })
|
||||
await tick()
|
||||
assert.equal(reads, 3, "both original handles release their merged demand")
|
||||
for (const alias of [host, canonical]) {
|
||||
assert.equal(cache.state("instance", alias).stale, true)
|
||||
assert.equal(cache.state("instance", alias).snapshot?.controls[0].id, "initial")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it("does not let hidden mutation completion start an older read's follow-up", async (t) => {
|
||||
const pending = deferred<PluginControlsSnapshot>()
|
||||
const write = deferred<void>()
|
||||
let reads = 0
|
||||
const cache = new PluginControlsCache({
|
||||
getPluginControls: async () => ++reads === 2 ? pending.promise : snapshot("known"),
|
||||
setPluginActivation: async () => {
|
||||
await write.promise
|
||||
const durable = snapshot("known")
|
||||
durable.controls[0].project = "disabled"
|
||||
return {
|
||||
snapshot: durable, changed: true, reloadPending: true, rule: "-known",
|
||||
target: { scope: "project", path: "/repo/.opencode/opencode.jsonc", exists: true },
|
||||
}
|
||||
},
|
||||
})
|
||||
const release = cache.acquireDemand("instance", location)
|
||||
t.after(release)
|
||||
await tick()
|
||||
const refresh = cache.load("instance", location, { force: true })
|
||||
const mutation = cache.mutate("instance", location, "known", "project", false)
|
||||
release()
|
||||
write.resolve()
|
||||
await mutation
|
||||
pending.resolve(snapshot("obsolete"))
|
||||
await refresh
|
||||
await tick()
|
||||
assert.equal(reads, 2)
|
||||
assert.equal(cache.state("instance", location).snapshot?.controls[0].project, "disabled")
|
||||
assert.equal(cache.state("instance", location).stale, true)
|
||||
t.after(cache.acquireDemand("instance", location))
|
||||
await tick()
|
||||
assert.equal(reads, 3)
|
||||
})
|
||||
|
||||
it("retries a failed initial read on new demand without a retry loop", async (t) => {
|
||||
let reads = 0
|
||||
const cache = new PluginControlsCache({
|
||||
getPluginControls: async () => {
|
||||
if (++reads === 1) throw new Error("offline")
|
||||
return snapshot("recovered")
|
||||
},
|
||||
setPluginActivation: async () => { throw new Error("not used") },
|
||||
})
|
||||
const release = cache.acquireDemand("instance", location)
|
||||
await tick()
|
||||
assert.equal(reads, 1)
|
||||
assert.match(String(cache.state("instance", location).error), /offline/)
|
||||
release()
|
||||
t.after(cache.acquireDemand("instance", location))
|
||||
await tick()
|
||||
assert.equal(reads, 2)
|
||||
assert.equal(cache.state("instance", location).error, undefined)
|
||||
})
|
||||
|
||||
it("recovers a failed canonical refresh from the first successful host-alias read", async (t) => {
|
||||
const host = { directory: "\\\\wsl.localhost\\Ubuntu\\srv\\repo" }
|
||||
const canonical = { directory: "/srv/repo" }
|
||||
let reads = 0
|
||||
const cache = new PluginControlsCache({
|
||||
getPluginControls: async () => {
|
||||
if (++reads === 2) throw new Error("offline")
|
||||
return { ...snapshot(reads === 1 ? "old" : "recovered"), location: canonical }
|
||||
},
|
||||
setPluginActivation: async () => { throw new Error("not used") },
|
||||
})
|
||||
await cache.load("instance", canonical)
|
||||
cache.invalidateLocation("instance", canonical)
|
||||
await cache.load("instance", canonical)
|
||||
const release = cache.acquireDemand("instance", host)
|
||||
t.after(release)
|
||||
await tick()
|
||||
|
||||
assert.equal(reads, 3, "the successful recovery needs no duplicate read")
|
||||
for (const alias of [host, canonical]) {
|
||||
assert.equal(cache.state("instance", alias).snapshot?.controls[0].id, "recovered")
|
||||
assert.equal(cache.state("instance", alias).error, undefined)
|
||||
assert.equal(cache.state("instance", alias).stale, false)
|
||||
}
|
||||
cache.invalidateLocation("instance", canonical)
|
||||
await tick()
|
||||
assert.equal(reads, 4, "the transferred demand still refreshes")
|
||||
release()
|
||||
cache.invalidateLocation("instance", canonical)
|
||||
await tick()
|
||||
assert.equal(reads, 4, "the original host handle releases the canonical demand")
|
||||
})
|
||||
|
||||
for (const winner of ["healthy", "newer-read", "newer-mutation"] as const) {
|
||||
it(`does not recover over a ${winner} canonical snapshot`, async (t) => {
|
||||
const host = { directory: "\\\\wsl.localhost\\Ubuntu\\srv\\repo" }
|
||||
const canonical = { directory: "/srv/repo" }
|
||||
const aliasRead = deferred<PluginControlsSnapshot>()
|
||||
const reconciliation = deferred<PluginControlsSnapshot>()
|
||||
const write = deferred<void>()
|
||||
let canonicalReads = 0
|
||||
let hostReads = 0
|
||||
let fail = false
|
||||
let reconcile = false
|
||||
const cache = new PluginControlsCache({
|
||||
getPluginControls: async (_instanceId, requested) => {
|
||||
if (requested.directory === host.directory) {
|
||||
hostReads++
|
||||
return aliasRead.promise
|
||||
}
|
||||
canonicalReads++
|
||||
if (reconcile) return reconciliation.promise
|
||||
if (fail) throw new Error("offline")
|
||||
return { ...snapshot(canonicalReads === 1 ? "original" : "newer"), location: canonical }
|
||||
},
|
||||
setPluginActivation: async () => {
|
||||
await write.promise
|
||||
return {
|
||||
snapshot: { ...snapshot("newer"), location: canonical },
|
||||
changed: true, reloadPending: true, rule: "newer",
|
||||
target: { scope: "project", path: "/srv/repo/.opencode/opencode.jsonc", exists: true },
|
||||
}
|
||||
},
|
||||
})
|
||||
await cache.load("instance", canonical)
|
||||
// Dispatch the mutation BEFORE the alias read, but publish it AFTER.
|
||||
// Its dispatch revision alone must not let the alias roll it back.
|
||||
const mutation = winner === "newer-mutation"
|
||||
? cache.mutate("instance", canonical, "newer", "project", true)
|
||||
: undefined
|
||||
await tick()
|
||||
t.after(cache.acquireDemand("instance", host))
|
||||
if (winner === "newer-read") await cache.load("instance", canonical, { force: true })
|
||||
if (mutation) {
|
||||
write.resolve()
|
||||
await mutation
|
||||
}
|
||||
if (winner !== "healthy") {
|
||||
fail = true
|
||||
await cache.load("instance", canonical, { force: true })
|
||||
reconcile = true
|
||||
}
|
||||
aliasRead.resolve({ ...snapshot("obsolete-alias"), location: canonical })
|
||||
await tick()
|
||||
|
||||
assert.equal(hostReads, 1)
|
||||
for (const alias of [host, canonical]) {
|
||||
assert.equal(cache.state("instance", alias).snapshot?.controls[0].id, winner === "healthy" ? "original" : "newer")
|
||||
}
|
||||
if (winner === "healthy") {
|
||||
assert.equal(canonicalReads, 1, "healthy canonical caches retain first-winner behavior")
|
||||
} else {
|
||||
assert.equal(cache.state("instance", canonical).refreshing, true, "recovery is reconciled instead of using an obsolete alias")
|
||||
reconciliation.resolve({ ...snapshot("reconciled"), location: canonical })
|
||||
await tick()
|
||||
assert.equal(cache.state("instance", host).snapshot?.controls[0].id, "reconciled")
|
||||
assert.equal(cache.state("instance", host).error, undefined)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for (const hidden of [false, true]) {
|
||||
it(`preserves a later canonical invalidation during alias recovery, hidden=${hidden}`, async (t) => {
|
||||
const host = { directory: "\\\\wsl.localhost\\Ubuntu\\srv\\repo" }
|
||||
const canonical = { directory: "/srv/repo" }
|
||||
const aliasRead = deferred<PluginControlsSnapshot>()
|
||||
const reconciliation = deferred<PluginControlsSnapshot>()
|
||||
let reads = 0
|
||||
const cache = new PluginControlsCache({
|
||||
getPluginControls: async () => {
|
||||
reads++
|
||||
if (reads === 1) return { ...snapshot("old"), location: canonical }
|
||||
if (reads === 2) throw new Error("offline")
|
||||
return reads === 3 ? aliasRead.promise : reconciliation.promise
|
||||
},
|
||||
setPluginActivation: async () => { throw new Error("not used") },
|
||||
})
|
||||
await cache.load("instance", canonical)
|
||||
await cache.load("instance", canonical, { force: true })
|
||||
const release = cache.acquireDemand("instance", host)
|
||||
t.after(release)
|
||||
cache.invalidateLocation("instance", canonical)
|
||||
if (hidden) release()
|
||||
aliasRead.resolve({ ...snapshot("before-event"), location: canonical })
|
||||
await tick()
|
||||
|
||||
assert.equal(cache.state("instance", host).snapshot?.controls[0].id, "old")
|
||||
assert.equal(reads, hidden ? 3 : 4)
|
||||
if (hidden) {
|
||||
assert.equal(cache.state("instance", host).stale, true)
|
||||
t.after(cache.acquireDemand("instance", host))
|
||||
}
|
||||
assert.equal(reads, 4, "exactly one reconciliation survives the merge")
|
||||
reconciliation.resolve({ ...snapshot("after-event"), location: canonical })
|
||||
await tick()
|
||||
assert.equal(cache.state("instance", host).snapshot?.controls[0].id, "after-event")
|
||||
assert.equal(cache.state("instance", canonical).stale, false)
|
||||
})
|
||||
}
|
||||
|
||||
it("keeps canonical queued mutations when a successful alias recovers its failed refresh", async (t) => {
|
||||
const host = { directory: "\\\\wsl.localhost\\Ubuntu\\srv\\repo" }
|
||||
const canonical = { directory: "/srv/repo" }
|
||||
const firstWrite = deferred<void>()
|
||||
let reads = 0
|
||||
const writes: boolean[] = []
|
||||
const cache = new PluginControlsCache({
|
||||
getPluginControls: async () => {
|
||||
if (++reads === 2) throw new Error("offline")
|
||||
return { ...snapshot(reads === 1 ? "old" : "recovered"), location: canonical }
|
||||
},
|
||||
setPluginActivation: async (_instanceId, request) => {
|
||||
writes.push(request.enabled)
|
||||
if (writes.length === 1) await firstWrite.promise
|
||||
return {
|
||||
snapshot: { ...snapshot(request.enabled ? "enabled" : "disabled"), location: canonical },
|
||||
changed: true, reloadPending: true, rule: request.enabled ? "known" : "-known",
|
||||
target: { scope: "project", path: "/srv/repo/.opencode/opencode.jsonc", exists: true },
|
||||
}
|
||||
},
|
||||
})
|
||||
await cache.load("instance", canonical)
|
||||
await cache.load("instance", canonical, { force: true })
|
||||
const disable = cache.mutate("instance", canonical, "known", "project", false)
|
||||
const enable = cache.mutate("instance", canonical, "known", "project", true)
|
||||
await tick()
|
||||
const release = cache.acquireDemand("instance", host)
|
||||
t.after(release)
|
||||
await tick()
|
||||
assert.equal(cache.state("instance", host).snapshot?.controls[0].id, "recovered")
|
||||
assert.deepEqual(writes, [false])
|
||||
release()
|
||||
firstWrite.resolve()
|
||||
await Promise.all([disable, enable])
|
||||
assert.deepEqual(writes, [false, true], "neither queued mutation is orphaned or replayed")
|
||||
assert.equal(cache.state("instance", host).snapshot?.controls[0].id, "enabled")
|
||||
assert.equal(reads, 3, "completion cannot restart hidden reads")
|
||||
})
|
||||
|
||||
it("a released handle from a cleared instance cannot cancel its replacement", async (t) => {
|
||||
let reads = 0
|
||||
const cache = new PluginControlsCache({
|
||||
getPluginControls: async () => snapshot(`read-${++reads}`),
|
||||
setPluginActivation: async () => { throw new Error("not used") },
|
||||
})
|
||||
const old = cache.acquireDemand("instance", location)
|
||||
await tick()
|
||||
cache.clearInstance("instance")
|
||||
t.after(cache.acquireDemand("instance", location))
|
||||
await tick()
|
||||
old()
|
||||
cache.invalidateInstance("instance")
|
||||
await tick()
|
||||
assert.equal(reads, 3)
|
||||
})
|
||||
})
|
||||
|
||||
function snapshot(id: string): PluginControlsSnapshot {
|
||||
return {
|
||||
location, runtime: [], configured: { rules: [], sources: [] }, targets: [],
|
||||
controls: [{ id, builtin: false, global: "default", project: "default", effective: "default" }],
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void
|
||||
const promise = new Promise<T>((accept) => { resolve = accept })
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function tick(): Promise<void> {
|
||||
return new Promise((resolve) => setImmediate(resolve))
|
||||
}
|
||||
73
packages/ui/src/stores/plugin-controls-events.test.ts
Normal file
73
packages/ui/src/stores/plugin-controls-events.test.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import type { PluginControlsSnapshot } from "../../../server/src/api-types"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { pluginControlsCache } from "./plugin-controls"
|
||||
|
||||
test("renderer SSE reconnect invalidates every cached worktree without reading hidden surfaces", async (t) => {
|
||||
const sources: Array<{ onopen: (() => void) | null; close: () => void; closed: boolean }> = []
|
||||
// Keep the real ServerEvents connection/open dispatch and the feature's real
|
||||
// handler registration; replace only the network boundary before importing it.
|
||||
t.mock.method(serverApi, "connectEvents", () => {
|
||||
const source = { onopen: null, closed: false, close: () => { source.closed = true } }
|
||||
sources.push(source)
|
||||
return source
|
||||
})
|
||||
const { serverEvents } = await import("../lib/server-events")
|
||||
await import("./plugin-controls-events")
|
||||
await tick()
|
||||
sources[0].onopen!()
|
||||
let nativeStatuses = 0
|
||||
t.after(serverEvents.on("instance.eventStatus", () => { nativeStatuses++ }))
|
||||
|
||||
const visible = { directory: "/repo" }
|
||||
const sibling = { directory: "/repo/worktree" }
|
||||
const other = { directory: "/other" }
|
||||
const instances = ["reconnect-first", "reconnect-other"]
|
||||
t.after(() => instances.forEach((id) => pluginControlsCache.clearInstance(id)))
|
||||
const reads: string[] = []
|
||||
let version = "before-gap"
|
||||
t.mock.method(serverApi, "getPluginControls", async (instanceId: string, location: { directory: string }) => {
|
||||
reads.push(`${instanceId}:${location.directory}`)
|
||||
return {
|
||||
location, runtime: [], configured: { rules: [], sources: [] }, targets: [],
|
||||
controls: [{ id: version, builtin: false, global: "default", project: "default", effective: "default" }],
|
||||
} satisfies PluginControlsSnapshot
|
||||
})
|
||||
await Promise.all([
|
||||
pluginControlsCache.load(instances[0], visible),
|
||||
pluginControlsCache.load(instances[0], sibling),
|
||||
pluginControlsCache.load(instances[1], other),
|
||||
])
|
||||
const release = pluginControlsCache.acquireDemand(instances[0], visible)
|
||||
t.after(release)
|
||||
assert.equal(reads.length, 3)
|
||||
|
||||
version = "after-gap"
|
||||
serverEvents.restart("isolated plugin transport reconnect")
|
||||
await tick()
|
||||
assert.equal(sources[0].closed, true)
|
||||
assert.equal(sources.length, 2)
|
||||
sources[1].onopen!()
|
||||
await tick()
|
||||
|
||||
assert.equal(nativeStatuses, 0, "the backend's native subscription did not reconnect")
|
||||
assert.equal(reads.length, 4, "only the visible worktree reconciles")
|
||||
assert.equal(pluginControlsCache.state(instances[0], visible).snapshot?.controls[0].id, "after-gap")
|
||||
for (const [instanceId, location] of [[instances[0], sibling], [instances[1], other]] as const) {
|
||||
const hidden = pluginControlsCache.state(instanceId, location)
|
||||
assert.equal(hidden.stale, true)
|
||||
assert.equal(hidden.snapshot?.controls[0].id, "before-gap")
|
||||
}
|
||||
|
||||
release()
|
||||
t.after(pluginControlsCache.acquireDemand(instances[0], sibling))
|
||||
await tick()
|
||||
assert.equal(reads.length, 5)
|
||||
assert.equal(pluginControlsCache.state(instances[0], sibling).snapshot?.controls[0].id, "after-gap")
|
||||
assert.equal(pluginControlsCache.state(instances[1], other).stale, true)
|
||||
})
|
||||
|
||||
function tick(): Promise<void> {
|
||||
return new Promise((resolve) => setImmediate(resolve))
|
||||
}
|
||||
37
packages/ui/src/stores/plugin-controls-events.ts
Normal file
37
packages/ui/src/stores/plugin-controls-events.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import type { WorkspaceEventPayload } from "../../../server/src/api-types"
|
||||
import { serverEvents } from "../lib/server-events"
|
||||
import { pluginControlsCache } from "./plugin-controls"
|
||||
|
||||
type InstanceEvent = Extract<WorkspaceEventPayload, { type: "instance.event" }>
|
||||
|
||||
serverEvents.on("instance.event", (event) => {
|
||||
const payload = event as InstanceEvent
|
||||
if (payload.event.type !== "config.updated" && payload.event.type !== "plugin.updated") return
|
||||
if (payload.event.type === "config.updated") {
|
||||
// A config event does not identify whether its changed document was global.
|
||||
// Mark every worktree snapshot stale, but let visible consumers demand reads.
|
||||
pluginControlsCache.invalidateInstance(payload.instanceId)
|
||||
return
|
||||
}
|
||||
const directory = payload.event.location?.directory
|
||||
if (directory) {
|
||||
pluginControlsCache.invalidateLocation(payload.instanceId, { directory })
|
||||
return
|
||||
}
|
||||
pluginControlsCache.invalidateInstance(payload.instanceId)
|
||||
})
|
||||
|
||||
serverEvents.on("instance.eventStatus", (event) => {
|
||||
if (event.type === "instance.eventStatus" && event.status === "connected") {
|
||||
pluginControlsCache.invalidateInstance(event.instanceId)
|
||||
}
|
||||
})
|
||||
|
||||
// Renderer-to-backend reconnects are independent of the native subscription.
|
||||
// The event transport has no replay; visible demand reconciles these snapshots,
|
||||
// while hidden worktrees remain stale until their next activation.
|
||||
serverEvents.onOpen(() => pluginControlsCache.invalidateAll())
|
||||
|
||||
serverEvents.on("workspace.stopped", (event) => {
|
||||
if (event.type === "workspace.stopped") pluginControlsCache.clearInstance(event.workspaceId)
|
||||
})
|
||||
697
packages/ui/src/stores/plugin-controls.test.ts
Normal file
697
packages/ui/src/stores/plugin-controls.test.ts
Normal file
|
|
@ -0,0 +1,697 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
import type { PluginControlLocation, PluginControlsSnapshot } from "../../../server/src/api-types"
|
||||
import { PluginControlsCache } from "./plugin-controls"
|
||||
|
||||
describe("plugin controls cache", () => {
|
||||
it("shares concurrent reads and coalesces invalidations into one trailing refresh", async (t) => {
|
||||
const first = deferred<PluginControlsSnapshot>()
|
||||
const second = deferred<PluginControlsSnapshot>()
|
||||
const responses = [first, second]
|
||||
let reads = 0
|
||||
const cache = new PluginControlsCache({
|
||||
getPluginControls: async () => responses[reads++].promise,
|
||||
setPluginActivation: async () => { throw new Error("not used") },
|
||||
})
|
||||
const location = { directory: "/repo" }
|
||||
|
||||
const pending = cache.load("instance", location)
|
||||
t.after(cache.acquireDemand("instance", location))
|
||||
assert.equal(cache.state("instance", location).loading, true)
|
||||
assert.equal(cache.load("instance", location, { force: true }), pending)
|
||||
cache.invalidateInstance("instance")
|
||||
assert.equal(cache.state("instance", location).stale, true)
|
||||
assert.equal(cache.load("instance", location), pending)
|
||||
assert.equal(reads, 1)
|
||||
|
||||
first.resolve(snapshot("first"))
|
||||
await pending
|
||||
await tick()
|
||||
assert.equal(reads, 2)
|
||||
assert.equal(cache.state("instance", location).snapshot, undefined, "invalidated response is fenced")
|
||||
|
||||
second.resolve(snapshot("second"))
|
||||
await tick()
|
||||
assert.equal(cache.state("instance", location).snapshot?.controls[0]?.id, "second")
|
||||
})
|
||||
|
||||
it("marks hidden snapshots stale without starting background work", async () => {
|
||||
let reads = 0
|
||||
const cache = new PluginControlsCache({
|
||||
getPluginControls: async () => snapshot(`read-${++reads}`),
|
||||
setPluginActivation: async () => { throw new Error("not used") },
|
||||
})
|
||||
const location = { directory: "/repo" }
|
||||
await cache.load("instance", location)
|
||||
|
||||
cache.invalidateInstance("instance")
|
||||
|
||||
assert.equal(reads, 1)
|
||||
assert.equal(cache.state("instance", location).snapshot?.controls[0]?.id, "read-1")
|
||||
assert.equal(cache.state("instance", location).stale, true)
|
||||
await cache.load("instance", location)
|
||||
assert.equal(reads, 2)
|
||||
assert.equal(cache.state("instance", location).snapshot?.controls[0]?.id, "read-2")
|
||||
assert.equal(cache.state("instance", location).stale, false)
|
||||
})
|
||||
|
||||
it("shares one worktree snapshot across session workspace identifiers", async () => {
|
||||
let reads = 0
|
||||
const cache = new PluginControlsCache({
|
||||
getPluginControls: async () => {
|
||||
reads += 1
|
||||
return snapshot("shared")
|
||||
},
|
||||
setPluginActivation: async () => { throw new Error("not used") },
|
||||
})
|
||||
const firstSession = { directory: "/repo", workspaceID: "session-one" }
|
||||
const secondSession = { directory: "/repo", workspaceID: "session-two" }
|
||||
|
||||
await cache.load("instance", firstSession)
|
||||
await cache.load("instance", secondSession)
|
||||
|
||||
assert.equal(reads, 1)
|
||||
assert.equal(cache.state("instance", secondSession).snapshot?.controls[0]?.id, "shared")
|
||||
})
|
||||
|
||||
it("adopts the canonical service directory as an alias for WSL cache identity", async () => {
|
||||
let reads = 0
|
||||
const mutations: PluginControlLocation[] = []
|
||||
const hostLocation = { directory: "\\\\wsl.localhost\\Ubuntu\\srv\\repo" }
|
||||
const serviceLocation = { directory: "/srv/repo" }
|
||||
const cache = new PluginControlsCache({
|
||||
getPluginControls: async () => {
|
||||
reads += 1
|
||||
return { ...snapshot("canonical"), location: serviceLocation }
|
||||
},
|
||||
setPluginActivation: async (_instanceId, payload) => {
|
||||
mutations.push(payload.location)
|
||||
return {
|
||||
changed: true,
|
||||
rule: `-${payload.pluginId}`,
|
||||
reloadPending: true,
|
||||
target: { scope: payload.scope, path: "/srv/repo/.opencode/opencode.jsonc", exists: true },
|
||||
snapshot: { ...snapshot(payload.pluginId, "disabled"), location: serviceLocation },
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
await cache.load("instance", hostLocation)
|
||||
await cache.load("instance", serviceLocation)
|
||||
assert.equal(reads, 1)
|
||||
assert.equal(cache.state("instance", hostLocation).snapshot?.controls[0]?.id, "canonical")
|
||||
assert.equal(cache.state("instance", serviceLocation).snapshot?.controls[0]?.id, "canonical")
|
||||
|
||||
cache.invalidateLocation("instance", serviceLocation)
|
||||
assert.equal(cache.state("instance", hostLocation).stale, true)
|
||||
await cache.mutate("instance", hostLocation, "canonical", "project", false)
|
||||
assert.deepEqual(mutations, [serviceLocation])
|
||||
})
|
||||
|
||||
it("keeps the first successful canonical response when host and service aliases race", async () => {
|
||||
const hostResponse = deferred<PluginControlsSnapshot>()
|
||||
const serviceResponse = deferred<PluginControlsSnapshot>()
|
||||
const hostLocation = { directory: "\\\\wsl.localhost\\Ubuntu\\srv\\repo" }
|
||||
const serviceLocation = { directory: "/srv/repo" }
|
||||
const cache = new PluginControlsCache({
|
||||
getPluginControls: async (_instanceId, location) => (
|
||||
location.directory === hostLocation.directory ? hostResponse.promise : serviceResponse.promise
|
||||
),
|
||||
setPluginActivation: async () => { throw new Error("not used") },
|
||||
})
|
||||
|
||||
const hostLoad = cache.load("instance", hostLocation)
|
||||
const serviceLoad = cache.load("instance", serviceLocation)
|
||||
hostResponse.resolve({ ...snapshot("host-won"), location: serviceLocation })
|
||||
await hostLoad
|
||||
|
||||
assert.equal(cache.state("instance", hostLocation).snapshot?.controls[0]?.id, "host-won")
|
||||
assert.equal(cache.state("instance", serviceLocation).snapshot?.controls[0]?.id, "host-won")
|
||||
|
||||
serviceResponse.reject(new Error("late alias failure"))
|
||||
await serviceLoad
|
||||
assert.equal(cache.state("instance", serviceLocation).snapshot?.controls[0]?.id, "host-won")
|
||||
assert.equal(cache.state("instance", serviceLocation).error, undefined)
|
||||
})
|
||||
|
||||
it("retains canonical invalidations that arrive before a WSL alias is learned", async (t) => {
|
||||
const stale = deferred<PluginControlsSnapshot>()
|
||||
const fresh = deferred<PluginControlsSnapshot>()
|
||||
let reads = 0
|
||||
const hostLocation = { directory: "\\\\wsl.localhost\\Ubuntu\\srv\\repo" }
|
||||
const serviceLocation = { directory: "/srv/repo" }
|
||||
const cache = new PluginControlsCache({
|
||||
getPluginControls: async () => (reads++ === 0 ? stale.promise : fresh.promise),
|
||||
setPluginActivation: async () => { throw new Error("not used") },
|
||||
})
|
||||
|
||||
const pending = cache.load("instance", hostLocation)
|
||||
t.after(cache.acquireDemand("instance", hostLocation))
|
||||
cache.invalidateLocation("instance", serviceLocation)
|
||||
assert.equal(cache.load("instance", hostLocation), pending, "visible demand shares the in-flight read")
|
||||
stale.resolve({ ...snapshot("stale"), location: serviceLocation })
|
||||
await pending
|
||||
await tick()
|
||||
|
||||
assert.equal(cache.state("instance", hostLocation).snapshot, undefined)
|
||||
assert.equal(reads, 2)
|
||||
fresh.resolve({ ...snapshot("fresh"), location: serviceLocation })
|
||||
await tick()
|
||||
assert.equal(cache.state("instance", serviceLocation).snapshot?.controls[0]?.id, "fresh")
|
||||
})
|
||||
|
||||
it("preserves an existing canonical record's invalidation and trailing demand when a host alias resolves first", async (t) => {
|
||||
const hostResponse = deferred<PluginControlsSnapshot>()
|
||||
const nativeResponse = deferred<PluginControlsSnapshot>()
|
||||
const fresh = deferred<PluginControlsSnapshot>()
|
||||
const hostLocation = { directory: "\\\\wsl.localhost\\Ubuntu\\srv\\repo" }
|
||||
const serviceLocation = { directory: "/srv/repo" }
|
||||
const signals: Array<AbortSignal | undefined> = []
|
||||
let reads = 0
|
||||
const cache = new PluginControlsCache({
|
||||
getPluginControls: async (_instanceId, _location, signal) => {
|
||||
signals.push(signal)
|
||||
return [hostResponse, nativeResponse, fresh][reads++].promise
|
||||
},
|
||||
setPluginActivation: async () => { throw new Error("not used") },
|
||||
})
|
||||
|
||||
const hostLoad = cache.load("instance", hostLocation)
|
||||
const nativeLoad = cache.load("instance", serviceLocation)
|
||||
t.after(cache.acquireDemand("instance", serviceLocation))
|
||||
cache.invalidateLocation("instance", serviceLocation)
|
||||
cache.load("instance", serviceLocation)
|
||||
cache.load("instance", serviceLocation, { force: true })
|
||||
hostResponse.resolve({ ...snapshot("pre-event-host"), location: serviceLocation })
|
||||
await hostLoad
|
||||
await tick()
|
||||
|
||||
assert.equal(reads, 3, "one canonical reconciliation survives the merge")
|
||||
assert.equal(signals[1]?.aborted, true, "the orphaned native read is cancelled")
|
||||
assert.equal(cache.state("instance", hostLocation).snapshot, undefined)
|
||||
assert.equal(cache.state("instance", serviceLocation).snapshot, undefined)
|
||||
nativeResponse.resolve({ ...snapshot("pre-event-native"), location: serviceLocation })
|
||||
await nativeLoad
|
||||
fresh.resolve({ ...snapshot("post-event"), location: serviceLocation })
|
||||
await tick()
|
||||
|
||||
assert.equal(reads, 3)
|
||||
for (const location of [hostLocation, serviceLocation]) {
|
||||
assert.equal(cache.state("instance", location).snapshot?.controls[0]?.id, "post-event")
|
||||
assert.equal(cache.state("instance", location).stale, false)
|
||||
}
|
||||
})
|
||||
|
||||
it("retains canonical invalidation after its replacement read has already consumed stale", async (t) => {
|
||||
const hostResponse = deferred<PluginControlsSnapshot>()
|
||||
const oldNativeResponse = deferred<PluginControlsSnapshot>()
|
||||
const nativeRefresh = deferred<PluginControlsSnapshot>()
|
||||
const mergedRefresh = deferred<PluginControlsSnapshot>()
|
||||
const hostLocation = { directory: "\\\\wsl.localhost\\Ubuntu\\srv\\repo" }
|
||||
const serviceLocation = { directory: "/srv/repo" }
|
||||
let reads = 0
|
||||
const cache = new PluginControlsCache({
|
||||
getPluginControls: async () => [hostResponse, oldNativeResponse, nativeRefresh, mergedRefresh][reads++].promise,
|
||||
setPluginActivation: async () => { throw new Error("not used") },
|
||||
})
|
||||
const hostLoad = cache.load("instance", hostLocation)
|
||||
const nativeLoad = cache.load("instance", serviceLocation)
|
||||
t.after(cache.acquireDemand("instance", serviceLocation))
|
||||
cache.invalidateLocation("instance", serviceLocation)
|
||||
cache.load("instance", serviceLocation)
|
||||
oldNativeResponse.resolve({ ...snapshot("old-native"), location: serviceLocation })
|
||||
await nativeLoad
|
||||
await tick()
|
||||
assert.equal(reads, 3)
|
||||
assert.equal(cache.state("instance", serviceLocation).stale, false, "replacement dispatch consumed the stale flag")
|
||||
|
||||
hostResponse.resolve({ ...snapshot("old-host"), location: serviceLocation })
|
||||
await hostLoad
|
||||
await tick()
|
||||
assert.equal(cache.state("instance", serviceLocation).snapshot, undefined)
|
||||
assert.equal(reads, 4, "the canonical generation still fences the older alias")
|
||||
nativeRefresh.resolve({ ...snapshot("orphan"), location: serviceLocation })
|
||||
mergedRefresh.resolve({ ...snapshot("fresh"), location: serviceLocation })
|
||||
await tick()
|
||||
assert.equal(cache.state("instance", hostLocation).snapshot?.controls[0]?.id, "fresh")
|
||||
assert.equal(cache.state("instance", serviceLocation).snapshot?.controls[0]?.id, "fresh")
|
||||
})
|
||||
|
||||
it("matches nothing when an event names a directory that was never loaded", async () => {
|
||||
let reads = 0
|
||||
const cache = new PluginControlsCache({
|
||||
getPluginControls: async (_instanceId, location) => {
|
||||
reads += 1
|
||||
return { ...snapshot(location.directory), location }
|
||||
},
|
||||
setPluginActivation: async () => { throw new Error("not used") },
|
||||
})
|
||||
const first = { directory: "/repo/one" }
|
||||
const second = { directory: "/repo/two" }
|
||||
const firstLoad = cache.load("instance", first)
|
||||
const secondLoad = cache.load("instance", second)
|
||||
|
||||
cache.invalidateLocation("instance", { directory: "/repo/three" })
|
||||
await Promise.all([firstLoad, secondLoad])
|
||||
await tick()
|
||||
|
||||
assert.equal(cache.state("instance", first).stale, false)
|
||||
assert.equal(cache.state("instance", second).stale, false)
|
||||
assert.equal(cache.state("instance", first).snapshot?.controls[0]?.id, "/repo/one")
|
||||
assert.equal(cache.state("instance", second).snapshot?.controls[0]?.id, "/repo/two")
|
||||
assert.equal(reads, 2)
|
||||
})
|
||||
|
||||
it("consumes a pending canonical invalidation on mutation without an extra refresh", async (t) => {
|
||||
const stale = deferred<PluginControlsSnapshot>()
|
||||
const fresh = deferred<PluginControlsSnapshot>()
|
||||
let reads = 0
|
||||
const hostLocation = { directory: "\\\\wsl.localhost\\Ubuntu\\srv\\repo" }
|
||||
const serviceLocation = { directory: "/srv/repo" }
|
||||
const cache = new PluginControlsCache({
|
||||
getPluginControls: async () => (reads++ === 0 ? stale.promise : fresh.promise),
|
||||
setPluginActivation: async (_instanceId, payload) => ({
|
||||
changed: true,
|
||||
rule: `-${payload.pluginId}`,
|
||||
reloadPending: true,
|
||||
target: { scope: payload.scope, path: "/srv/repo/.opencode/opencode.jsonc", exists: true },
|
||||
snapshot: { ...snapshot("mutated", "disabled"), location: serviceLocation },
|
||||
}),
|
||||
})
|
||||
|
||||
const pending = cache.load("instance", hostLocation)
|
||||
t.after(cache.acquireDemand("instance", hostLocation))
|
||||
cache.invalidateLocation("instance", serviceLocation)
|
||||
stale.resolve({ ...snapshot("stale"), location: serviceLocation })
|
||||
await pending
|
||||
await tick()
|
||||
assert.equal(reads, 2)
|
||||
|
||||
fresh.resolve({ ...snapshot("fresh"), location: serviceLocation })
|
||||
await tick()
|
||||
await cache.mutate("instance", hostLocation, "fresh", "project", false)
|
||||
assert.equal(cache.state("instance", serviceLocation).snapshot?.controls[0]?.id, "mutated")
|
||||
|
||||
// The mutation superseded the pre-alias event; a later passive load must
|
||||
// not discard the durable mutation snapshot.
|
||||
const readsBefore = reads
|
||||
await cache.load("instance", serviceLocation)
|
||||
await tick()
|
||||
assert.equal(reads, readsBefore)
|
||||
assert.equal(cache.state("instance", serviceLocation).snapshot?.controls[0]?.id, "mutated")
|
||||
})
|
||||
|
||||
it("invalidates only the event worktree", async () => {
|
||||
let reads = 0
|
||||
const cache = new PluginControlsCache({
|
||||
getPluginControls: async (_instanceId, location) => {
|
||||
reads += 1
|
||||
return { ...snapshot(location.directory), location }
|
||||
},
|
||||
setPluginActivation: async () => { throw new Error("not used") },
|
||||
})
|
||||
const first = { directory: "/repo/one" }
|
||||
const second = { directory: "/repo/two" }
|
||||
await cache.load("instance", first)
|
||||
await cache.load("instance", second)
|
||||
|
||||
cache.invalidateLocation("instance", first)
|
||||
|
||||
assert.equal(cache.state("instance", first).stale, true)
|
||||
assert.equal(cache.state("instance", second).stale, false)
|
||||
assert.equal(reads, 2)
|
||||
})
|
||||
|
||||
it("marks sibling worktrees stale after a global mutation without refreshing them", async () => {
|
||||
let reads = 0
|
||||
const cache = new PluginControlsCache({
|
||||
getPluginControls: async (_instanceId, location) => {
|
||||
reads += 1
|
||||
return { ...snapshot(location.directory), location }
|
||||
},
|
||||
setPluginActivation: async (_instanceId, payload) => ({
|
||||
changed: true,
|
||||
rule: `-${payload.pluginId}`,
|
||||
reloadPending: true,
|
||||
target: { scope: payload.scope, path: "/config/opencode.jsonc", exists: true },
|
||||
snapshot: { ...snapshot("mutated"), location: payload.location },
|
||||
}),
|
||||
})
|
||||
const first = { directory: "/repo/one" }
|
||||
const second = { directory: "/repo/two" }
|
||||
await cache.load("instance", first)
|
||||
await cache.load("instance", second)
|
||||
|
||||
await cache.mutate("instance", first, "first", "global", false)
|
||||
|
||||
assert.equal(cache.state("instance", first).snapshot?.controls[0]?.id, "mutated")
|
||||
assert.equal(cache.state("instance", first).stale, false)
|
||||
assert.equal(cache.state("instance", second).stale, true)
|
||||
assert.equal(reads, 2)
|
||||
})
|
||||
|
||||
it("keeps the last successful snapshot when a passive refresh fails", async () => {
|
||||
let fail = false
|
||||
const cache = new PluginControlsCache({
|
||||
getPluginControls: async () => {
|
||||
if (fail) throw new Error("offline")
|
||||
return snapshot("retained")
|
||||
},
|
||||
setPluginActivation: async () => { throw new Error("not used") },
|
||||
})
|
||||
const location = { directory: "/repo" }
|
||||
await cache.load("instance", location)
|
||||
fail = true
|
||||
|
||||
await cache.load("instance", location, { force: true })
|
||||
|
||||
const state = cache.state("instance", location)
|
||||
assert.equal(state.snapshot?.controls[0]?.id, "retained")
|
||||
assert.match(String(state.error), /offline/)
|
||||
assert.equal(state.refreshing, false)
|
||||
})
|
||||
|
||||
it("publishes the durable mutation response and fences an older refresh", async (t) => {
|
||||
const stale = deferred<PluginControlsSnapshot>()
|
||||
const fresh = deferred<PluginControlsSnapshot>()
|
||||
let reads = 0
|
||||
const cache = new PluginControlsCache({
|
||||
getPluginControls: async () => reads++ === 0 ? snapshot("initial") : reads === 2 ? stale.promise : fresh.promise,
|
||||
setPluginActivation: async (_instanceId, request) => ({
|
||||
snapshot: snapshot(request.pluginId, "disabled"),
|
||||
rule: `-${request.pluginId}`,
|
||||
target: { scope: request.scope, path: "/repo/.opencode/opencode.jsonc", exists: true },
|
||||
changed: true,
|
||||
reloadPending: true,
|
||||
}),
|
||||
})
|
||||
const location = { directory: "/repo" }
|
||||
await cache.load("instance", location)
|
||||
t.after(cache.acquireDemand("instance", location))
|
||||
const pendingRefresh = cache.load("instance", location, { force: true })
|
||||
|
||||
await cache.mutate("instance", location, "acme", "project", false)
|
||||
assert.equal(cache.state("instance", location).snapshot?.controls[0]?.id, "acme")
|
||||
assert.equal(cache.state("instance", location).snapshot?.controls[0]?.effective, "disabled")
|
||||
|
||||
stale.resolve(snapshot("stale"))
|
||||
await pendingRefresh
|
||||
await tick()
|
||||
assert.equal(cache.state("instance", location).snapshot?.controls[0]?.id, "acme", "stale response cannot overwrite mutation")
|
||||
|
||||
fresh.resolve(snapshot("fresh", "disabled"))
|
||||
await tick()
|
||||
assert.equal(cache.state("instance", location).snapshot?.controls[0]?.id, "fresh")
|
||||
})
|
||||
|
||||
it("fences requests after an instance is cleared", async () => {
|
||||
const response = deferred<PluginControlsSnapshot>()
|
||||
const cache = new PluginControlsCache({
|
||||
getPluginControls: async () => response.promise,
|
||||
setPluginActivation: async () => { throw new Error("not used") },
|
||||
})
|
||||
const location = { directory: "/repo" }
|
||||
const pending = cache.load("instance", location)
|
||||
cache.clearInstance("instance")
|
||||
response.resolve(snapshot("late"))
|
||||
await pending
|
||||
|
||||
assert.equal(cache.state("instance", location).snapshot, undefined)
|
||||
})
|
||||
|
||||
it("retains an event during a hidden mutation until reopening demands reconciliation", async () => {
|
||||
const write = deferred<ReturnType<typeof mutationResponse>>()
|
||||
let reads = 0
|
||||
const location = { directory: "/repo" }
|
||||
const cache = new PluginControlsCache({
|
||||
getPluginControls: async () => ++reads === 1 ? snapshot("known") : snapshot("known", "disabled"),
|
||||
setPluginActivation: () => write.promise,
|
||||
})
|
||||
await cache.load("instance", location)
|
||||
const mutation = cache.mutate("instance", location, "known", "project", false)
|
||||
await tick()
|
||||
cache.invalidateLocation("instance", location)
|
||||
write.resolve(mutationResponse(snapshot("known", "disabled")))
|
||||
await mutation
|
||||
|
||||
assert.equal(cache.state("instance", location).snapshot?.controls[0].project, "disabled", "durable switch feedback is immediate")
|
||||
assert.equal(cache.state("instance", location).stale, true, "PATCH cannot consume the later event")
|
||||
assert.equal(reads, 1, "hidden mutation completion starts no background read")
|
||||
await cache.load("instance", location)
|
||||
assert.equal(reads, 2)
|
||||
assert.equal(cache.state("instance", location).stale, false)
|
||||
})
|
||||
|
||||
it("retains newer completed runtime inventory while publishing the durable mutation rule", async () => {
|
||||
const write = deferred<ReturnType<typeof mutationResponse>>()
|
||||
const location = { directory: "/repo" }
|
||||
const before = withRuntime(snapshot("known"), "known", "removed")
|
||||
const fresh = withRuntime(snapshot("known"), "known", "added")
|
||||
fresh.runtime[0].state = { status: "failed", error: "new load failure" }
|
||||
let reads = 0
|
||||
const cache = new PluginControlsCache({
|
||||
getPluginControls: async () => ++reads === 1 ? before : fresh,
|
||||
setPluginActivation: () => write.promise,
|
||||
})
|
||||
await cache.load("instance", location)
|
||||
const mutation = cache.mutate("instance", location, "known", "project", false)
|
||||
await tick()
|
||||
cache.invalidateInstance("instance")
|
||||
await cache.load("instance", location)
|
||||
const durable = withRuntime(snapshot("known", "disabled"), "known", "removed", "configured")
|
||||
durable.configured.rules.push({ selector: "configured", enabled: false, scope: "project", order: 0, entryIndex: 0 })
|
||||
write.resolve(mutationResponse(durable))
|
||||
await mutation
|
||||
|
||||
const published = cache.state("instance", location)
|
||||
assert.deepEqual(published.snapshot?.runtime, fresh.runtime)
|
||||
assert.deepEqual(published.snapshot?.controls.find((control) => control.id === "known")?.runtime?.state, {
|
||||
status: "failed", error: "new load failure",
|
||||
})
|
||||
assert.equal(published.snapshot?.controls.find((control) => control.id === "known")?.project, "disabled")
|
||||
assert.equal(published.snapshot?.controls.find((control) => control.id === "removed"), undefined)
|
||||
assert.ok(published.snapshot?.controls.some((control) => control.id === "configured"), "exact rules retain controls absent from runtime")
|
||||
assert.equal(published.snapshot?.controls.find((control) => control.id === "configured")?.runtime, undefined)
|
||||
assert.equal(published.snapshot?.controls.find((control) => control.id === "added")?.runtime?.id, "added")
|
||||
assert.equal(published.stale, true)
|
||||
assert.equal(reads, 2, "completed reads do not trigger an eager hidden reconciliation")
|
||||
await cache.load("instance", location)
|
||||
assert.equal(reads, 3)
|
||||
assert.equal(cache.state("instance", location).stale, false)
|
||||
})
|
||||
|
||||
it("keeps a canonical event received before mutation alias adoption for the next visible demand", async () => {
|
||||
const write = deferred<ReturnType<typeof mutationResponse>>()
|
||||
const host = { directory: "\\\\wsl.localhost\\Ubuntu\\srv\\repo" }
|
||||
const canonical = { directory: "/srv/repo" }
|
||||
let reads = 0
|
||||
const cache = new PluginControlsCache({
|
||||
getPluginControls: async () => { reads++; return { ...snapshot("known", "disabled"), location: canonical } },
|
||||
setPluginActivation: () => write.promise,
|
||||
})
|
||||
const mutation = cache.mutate("instance", host, "known", "project", false)
|
||||
await tick()
|
||||
cache.invalidateLocation("instance", canonical)
|
||||
write.resolve(mutationResponse({ ...snapshot("known", "disabled"), location: canonical }))
|
||||
await mutation
|
||||
|
||||
assert.equal(reads, 0)
|
||||
assert.equal(cache.state("instance", host).stale, true)
|
||||
assert.equal(cache.state("instance", canonical).stale, true)
|
||||
await cache.load("instance", host)
|
||||
assert.equal(reads, 1)
|
||||
assert.equal(cache.state("instance", canonical).stale, false)
|
||||
})
|
||||
|
||||
it("carries a newer canonical snapshot through mutation alias adoption and queued writes", async () => {
|
||||
const first = deferred<ReturnType<typeof mutationResponse>>()
|
||||
const second = deferred<ReturnType<typeof mutationResponse>>()
|
||||
const host = { directory: "\\\\wsl.localhost\\Ubuntu\\srv\\repo" }
|
||||
const canonical = { directory: "/srv/repo" }
|
||||
const fresh = { ...withRuntime(snapshot("known"), "known"), location: canonical }
|
||||
fresh.runtime[0].state = { status: "failed", error: "canonical reload failure" }
|
||||
const writes: Array<{ location: PluginControlLocation; enabled: boolean }> = []
|
||||
let reads = 0
|
||||
const cache = new PluginControlsCache({
|
||||
getPluginControls: async () => { reads++; return fresh },
|
||||
setPluginActivation: async (_instanceId, request) => {
|
||||
writes.push(request)
|
||||
return writes.length === 1 ? first.promise : second.promise
|
||||
},
|
||||
})
|
||||
const disable = cache.mutate("instance", host, "known", "project", false)
|
||||
const enable = cache.mutate("instance", host, "known", "project", true)
|
||||
await tick()
|
||||
await cache.load("instance", canonical)
|
||||
cache.invalidateLocation("instance", canonical)
|
||||
first.resolve(mutationResponse({ ...snapshot("known", "disabled"), location: canonical }))
|
||||
await disable
|
||||
await tick()
|
||||
|
||||
assert.deepEqual(writes.map((request) => [request.location.directory, request.enabled]), [
|
||||
[host.directory, false], [canonical.directory, true],
|
||||
])
|
||||
assert.deepEqual(cache.state("instance", host).snapshot?.runtime, fresh.runtime)
|
||||
assert.equal(cache.state("instance", canonical).snapshot?.controls[0].project, "disabled")
|
||||
assert.equal(cache.state("instance", canonical).stale, true)
|
||||
assert.equal(reads, 1)
|
||||
|
||||
// Each queued request captures its own dispatch boundary. A fresh read and
|
||||
// another event during the second write must survive its publication too.
|
||||
await cache.load("instance", canonical)
|
||||
cache.invalidateLocation("instance", canonical)
|
||||
second.resolve(mutationResponse({ ...snapshot("known", "enabled"), location: canonical }))
|
||||
await enable
|
||||
assert.equal(writes.length, 2, "alias adoption never replays a mutation")
|
||||
assert.equal(cache.state("instance", host).snapshot?.controls[0].project, "enabled")
|
||||
assert.deepEqual(cache.state("instance", host).snapshot?.runtime, fresh.runtime)
|
||||
assert.equal(cache.state("instance", host).stale, true)
|
||||
await cache.load("instance", host)
|
||||
assert.equal(reads, 3)
|
||||
assert.equal(cache.state("instance", canonical).stale, false)
|
||||
})
|
||||
|
||||
it("fences an orphaned canonical read when mutation adoption retains its refresh demand", async () => {
|
||||
const write = deferred<ReturnType<typeof mutationResponse>>()
|
||||
const oldRead = deferred<PluginControlsSnapshot>()
|
||||
const host = { directory: "\\\\wsl.localhost\\Ubuntu\\srv\\repo" }
|
||||
const canonical = { directory: "/srv/repo" }
|
||||
const signals: Array<AbortSignal | undefined> = []
|
||||
const cache = new PluginControlsCache({
|
||||
getPluginControls: async (_instanceId, _location, signal) => {
|
||||
signals.push(signal)
|
||||
return signals.length === 1 ? oldRead.promise : { ...snapshot("known", "disabled"), location: canonical }
|
||||
},
|
||||
setPluginActivation: () => write.promise,
|
||||
})
|
||||
const mutation = cache.mutate("instance", host, "known", "project", false)
|
||||
await tick()
|
||||
const pending = cache.load("instance", canonical)
|
||||
cache.invalidateLocation("instance", canonical)
|
||||
cache.load("instance", canonical)
|
||||
write.resolve(mutationResponse({ ...snapshot("known", "disabled"), location: canonical }))
|
||||
await mutation
|
||||
assert.equal(signals[0]?.aborted, true)
|
||||
assert.equal(cache.state("instance", host).stale, true)
|
||||
oldRead.resolve({ ...snapshot("orphan"), location: canonical })
|
||||
await pending
|
||||
assert.equal(cache.state("instance", canonical).snapshot?.controls[0].id, "known")
|
||||
assert.equal(signals.length, 1, "an orphan's trailing read cannot restart hidden work")
|
||||
|
||||
await cache.load("instance", host)
|
||||
assert.equal(signals.length, 2, "one visible reconciliation serves both aliases")
|
||||
assert.equal(cache.state("instance", canonical).stale, false)
|
||||
})
|
||||
|
||||
it("aborts in-flight reads when an instance is cleared", async () => {
|
||||
const signals: Array<AbortSignal | undefined> = []
|
||||
const cache = new PluginControlsCache({
|
||||
getPluginControls: async (_instanceId, _location, signal) => {
|
||||
signals.push(signal)
|
||||
await new Promise(() => {})
|
||||
return snapshot("never")
|
||||
},
|
||||
setPluginActivation: async () => { throw new Error("not used") },
|
||||
})
|
||||
const location = { directory: "/repo" }
|
||||
cache.load("instance", location).catch(() => undefined)
|
||||
await tick()
|
||||
assert.equal(signals.length, 1)
|
||||
assert.equal(signals[0]?.aborted, false)
|
||||
cache.clearInstance("instance")
|
||||
assert.equal(signals[0]?.aborted, true)
|
||||
})
|
||||
|
||||
it("does not mislabel a mutation failure as a passive refresh failure", async () => {
|
||||
const cache = new PluginControlsCache({
|
||||
getPluginControls: async () => snapshot("retained"),
|
||||
setPluginActivation: async () => { throw new Error("write refused") },
|
||||
})
|
||||
const location = { directory: "/repo" }
|
||||
await cache.load("instance", location)
|
||||
|
||||
await assert.rejects(cache.mutate("instance", location, "retained", "project", false), /write refused/)
|
||||
|
||||
assert.equal(cache.state("instance", location).snapshot?.controls[0]?.id, "retained")
|
||||
assert.equal(cache.state("instance", location).error, undefined)
|
||||
})
|
||||
|
||||
it("serializes mutations for one location so older responses cannot replace newer state", async () => {
|
||||
const first = deferred<PluginControlsSnapshot>()
|
||||
const second = deferred<PluginControlsSnapshot>()
|
||||
let writes = 0
|
||||
const cache = new PluginControlsCache({
|
||||
getPluginControls: async () => snapshot("initial"),
|
||||
setPluginActivation: async (_instanceId, request) => ({
|
||||
snapshot: await (writes++ === 0 ? first.promise : second.promise),
|
||||
rule: request.enabled ? request.pluginId : `-${request.pluginId}`,
|
||||
target: { scope: request.scope, path: "/repo/.opencode/opencode.jsonc", exists: true },
|
||||
changed: true,
|
||||
reloadPending: true,
|
||||
}),
|
||||
})
|
||||
const location = { directory: "/repo" }
|
||||
await cache.load("instance", location)
|
||||
|
||||
const disable = cache.mutate("instance", location, "first", "project", false)
|
||||
const enable = cache.mutate("instance", location, "second", "project", true)
|
||||
await tick()
|
||||
assert.equal(writes, 1, "the second write waits for the first response")
|
||||
|
||||
first.resolve(snapshot("first", "disabled"))
|
||||
await disable
|
||||
await tick()
|
||||
assert.equal(writes, 2)
|
||||
second.resolve(snapshot("second", "enabled"))
|
||||
await enable
|
||||
|
||||
assert.equal(cache.state("instance", location).snapshot?.controls[0]?.id, "second")
|
||||
assert.equal(cache.state("instance", location).snapshot?.controls[0]?.effective, "enabled")
|
||||
})
|
||||
})
|
||||
|
||||
function snapshot(id: string, effective: "default" | "enabled" | "disabled" = "default"): PluginControlsSnapshot {
|
||||
return {
|
||||
location: { directory: "/repo" },
|
||||
runtime: [],
|
||||
configured: { rules: [], sources: [] },
|
||||
controls: [{ id, builtin: false, effective, global: "default", project: effective }],
|
||||
targets: [
|
||||
{ scope: "global", path: "/global/opencode.jsonc", exists: true },
|
||||
{ scope: "project", path: "/repo/.opencode/opencode.jsonc", exists: true },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function mutationResponse(snapshot: PluginControlsSnapshot) {
|
||||
return {
|
||||
snapshot,
|
||||
rule: "-known",
|
||||
target: { scope: "project" as const, path: "/repo/.opencode/opencode.jsonc", exists: true },
|
||||
changed: true,
|
||||
reloadPending: true,
|
||||
}
|
||||
}
|
||||
|
||||
function withRuntime(value: PluginControlsSnapshot, ...ids: string[]): PluginControlsSnapshot {
|
||||
value.runtime = ids.map((id) => ({
|
||||
id, key: id, source: { type: "package", target: `${id}-package` }, features: {}, state: { status: "active" },
|
||||
}))
|
||||
for (const runtime of value.runtime) {
|
||||
const control = value.controls.find((entry) => entry.id === runtime.id)
|
||||
if (control) control.runtime = runtime
|
||||
else value.controls.push({ id: runtime.id!, builtin: false, effective: "default", global: "default", project: "default", runtime })
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: unknown) => void
|
||||
const promise = new Promise<T>((success, failure) => { resolve = success; reject = failure })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function tick(): Promise<void> {
|
||||
return new Promise((resolve) => setImmediate(resolve))
|
||||
}
|
||||
432
packages/ui/src/stores/plugin-controls.ts
Normal file
432
packages/ui/src/stores/plugin-controls.ts
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
import { createSignal } from "solid-js"
|
||||
import type {
|
||||
PluginActivationMutationRequest,
|
||||
PluginActivationMutationResponse,
|
||||
PluginControlLocation,
|
||||
PluginControlScope,
|
||||
PluginControlsSnapshot,
|
||||
} from "../../../server/src/api-types"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
|
||||
export interface PluginControlsState {
|
||||
snapshot?: PluginControlsSnapshot
|
||||
loading: boolean
|
||||
refreshing: boolean
|
||||
stale: boolean
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
interface CacheRecord {
|
||||
readonly keys: Set<string>
|
||||
readonly demands: Set<{ record: CacheRecord }>
|
||||
readonly instanceId: string
|
||||
location: PluginControlLocation
|
||||
canonicalLocation: boolean
|
||||
generation: number
|
||||
invalidationRevision: number
|
||||
snapshotRevision: number
|
||||
snapshot?: PluginControlsSnapshot
|
||||
loading: boolean
|
||||
refreshing: boolean
|
||||
stale: boolean
|
||||
error?: unknown
|
||||
inFlight?: Promise<void>
|
||||
inFlightController?: AbortController
|
||||
trailing: boolean
|
||||
mutationTail: Promise<void>
|
||||
}
|
||||
|
||||
interface PluginControlsApi {
|
||||
getPluginControls(instanceId: string, location: PluginControlLocation, signal?: AbortSignal): Promise<PluginControlsSnapshot>
|
||||
setPluginActivation(instanceId: string, payload: PluginActivationMutationRequest): Promise<PluginActivationMutationResponse>
|
||||
}
|
||||
|
||||
const EMPTY_STATE: PluginControlsState = { loading: false, refreshing: false, stale: false }
|
||||
|
||||
export class PluginControlsCache {
|
||||
private readonly records = new Map<string, CacheRecord>()
|
||||
private readonly pendingUnknownInvalidations = new Map<string, number>()
|
||||
private revision = 0
|
||||
private readonly stateSignal = createSignal<Map<string, PluginControlsState>>(new Map())
|
||||
readonly states = this.stateSignal[0]
|
||||
private readonly setStates = this.stateSignal[1]
|
||||
|
||||
constructor(private readonly api: PluginControlsApi) {}
|
||||
|
||||
state(instanceId: string, location: PluginControlLocation): PluginControlsState {
|
||||
return this.states().get(cacheKey(instanceId, location)) ?? EMPTY_STATE
|
||||
}
|
||||
|
||||
/** Own visible refresh demand until deactivation/disposal; aliases share owners. */
|
||||
acquireDemand(instanceId: string, location: PluginControlLocation): () => void {
|
||||
const record = this.record(instanceId, location)
|
||||
const demand = { record }
|
||||
record.demands.add(demand)
|
||||
void this.load(instanceId, location, { force: Boolean(record.error) })
|
||||
return () => {
|
||||
// Canonical adoption can transfer the handle to a different record.
|
||||
const current = demand.record
|
||||
if (!current.demands.delete(demand) || current.demands.size > 0) return
|
||||
if (current.trailing) {
|
||||
current.trailing = false
|
||||
current.stale = true
|
||||
this.publish(current)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
load(instanceId: string, location: PluginControlLocation, options?: { force?: boolean }): Promise<void> {
|
||||
const record = this.record(instanceId, location)
|
||||
if (record.snapshot && !record.stale && !options?.force) return Promise.resolve()
|
||||
return this.loadRecord(record, Boolean(options?.force || record.stale))
|
||||
}
|
||||
|
||||
async mutate(
|
||||
instanceId: string,
|
||||
location: PluginControlLocation,
|
||||
pluginId: string,
|
||||
scope: PluginControlScope,
|
||||
enabled: boolean,
|
||||
): Promise<PluginActivationMutationResponse> {
|
||||
const record = this.record(instanceId, location)
|
||||
const mutate = async () => {
|
||||
if (!this.isActive(record)) {
|
||||
throw new Error("Plugin control location is no longer active")
|
||||
}
|
||||
const requestRevision = ++this.revision
|
||||
const response = await this.api.setPluginActivation(instanceId, {
|
||||
location: record.location,
|
||||
pluginId,
|
||||
scope,
|
||||
enabled,
|
||||
})
|
||||
if (!this.isActive(record) || !this.adoptCanonicalLocation(record, response.snapshot.location, true)) return response
|
||||
// Fence any passive read that started before the mutation became durable.
|
||||
record.generation += 1
|
||||
// A mutation overlays a durable rule onto inventory read BEFORE the write.
|
||||
// Events and completed reads during that write still require reconciliation;
|
||||
// in particular, do not roll newer runtime failures back to that inventory.
|
||||
const newerSnapshot = record.snapshot && record.snapshotRevision > requestRevision
|
||||
? record.snapshot
|
||||
: undefined
|
||||
record.snapshot = newerSnapshot ? retainRuntime(response.snapshot, newerSnapshot) : response.snapshot
|
||||
// Alias reads dispatched during the write must also see this publication
|
||||
// as newer, even when the mutation's dispatch revision predates them.
|
||||
record.snapshotRevision = ++this.revision
|
||||
record.error = undefined
|
||||
record.loading = false
|
||||
record.refreshing = false
|
||||
record.stale = record.stale || record.invalidationRevision > requestRevision || Boolean(newerSnapshot)
|
||||
if (record.inFlight) record.trailing = true
|
||||
this.publish(record)
|
||||
if (scope === "global") this.invalidateSiblingLocations(record)
|
||||
return response
|
||||
}
|
||||
const result = record.mutationTail.then(mutate, mutate)
|
||||
// Keep writes for one location ordered even when an earlier write fails.
|
||||
// The caller presents mutation failures; passive read errors remain separate.
|
||||
record.mutationTail = result.then(() => undefined, () => undefined)
|
||||
return result
|
||||
}
|
||||
|
||||
invalidateInstance(instanceId: string): void {
|
||||
this.invalidate(instanceId)
|
||||
}
|
||||
|
||||
invalidateAll(): void {
|
||||
this.invalidateRecords([...new Set(this.records.values())])
|
||||
}
|
||||
|
||||
invalidateLocation(instanceId: string, location: PluginControlLocation): void {
|
||||
this.invalidate(instanceId, location)
|
||||
}
|
||||
|
||||
private invalidate(instanceId: string, location?: PluginControlLocation): void {
|
||||
if (!location) {
|
||||
const records = [...new Set(this.records.values())].filter((record) => record.instanceId === instanceId)
|
||||
this.invalidateRecords(records)
|
||||
return
|
||||
}
|
||||
const key = cacheKey(instanceId, location)
|
||||
const target = this.records.get(key)
|
||||
if (!target) {
|
||||
// Unknown directories match nothing immediately. Remember the canonical
|
||||
// key so a WSL alias learned later can still fence its first snapshot.
|
||||
this.pendingUnknownInvalidations.set(key, ++this.revision)
|
||||
while (this.pendingUnknownInvalidations.size > 200) {
|
||||
const oldest = this.pendingUnknownInvalidations.keys().next().value
|
||||
if (oldest === undefined) break
|
||||
this.pendingUnknownInvalidations.delete(oldest)
|
||||
}
|
||||
return
|
||||
}
|
||||
this.invalidateRecords([target])
|
||||
}
|
||||
|
||||
private invalidateSiblingLocations(current: CacheRecord): void {
|
||||
const siblings = [...new Set(this.records.values())]
|
||||
.filter((record) => record !== current && record.instanceId === current.instanceId)
|
||||
this.invalidateRecords(siblings)
|
||||
}
|
||||
|
||||
private invalidateRecords(records: readonly CacheRecord[]): void {
|
||||
for (const record of records) {
|
||||
record.generation += 1
|
||||
record.invalidationRevision = ++this.revision
|
||||
record.stale = true
|
||||
}
|
||||
this.publishAll(records)
|
||||
}
|
||||
|
||||
clearInstance(instanceId: string): void {
|
||||
let changed = false
|
||||
for (const record of new Set(this.records.values())) {
|
||||
if (record.instanceId !== instanceId) continue
|
||||
record.generation += 1
|
||||
record.inFlightController?.abort()
|
||||
record.inFlightController = undefined
|
||||
record.demands.clear()
|
||||
for (const key of record.keys) {
|
||||
if (this.records.get(key) === record) this.records.delete(key)
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
for (const key of this.pendingUnknownInvalidations.keys()) {
|
||||
if (key.startsWith(`${JSON.stringify(instanceId)}:`)) this.pendingUnknownInvalidations.delete(key)
|
||||
}
|
||||
if (!changed) return
|
||||
this.setStates((previous) => {
|
||||
const next = new Map(previous)
|
||||
for (const key of next.keys()) {
|
||||
if (key.startsWith(`${JSON.stringify(instanceId)}:`)) next.delete(key)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
private record(instanceId: string, location: PluginControlLocation): CacheRecord {
|
||||
const key = cacheKey(instanceId, location)
|
||||
const existing = this.records.get(key)
|
||||
if (existing) {
|
||||
if (!existing.canonicalLocation) existing.location = { ...location }
|
||||
return existing
|
||||
}
|
||||
const record: CacheRecord = {
|
||||
keys: new Set([key]),
|
||||
demands: new Set(),
|
||||
instanceId,
|
||||
location: { ...location },
|
||||
canonicalLocation: false,
|
||||
generation: 0,
|
||||
invalidationRevision: 0,
|
||||
snapshotRevision: 0,
|
||||
loading: false,
|
||||
refreshing: false,
|
||||
stale: false,
|
||||
trailing: false,
|
||||
mutationTail: Promise.resolve(),
|
||||
}
|
||||
this.records.set(key, record)
|
||||
return record
|
||||
}
|
||||
|
||||
private loadRecord(record: CacheRecord, force: boolean): Promise<void> {
|
||||
if (record.inFlight) {
|
||||
if (force) record.trailing = true
|
||||
return record.inFlight
|
||||
}
|
||||
const generation = record.generation
|
||||
const requestRevision = ++this.revision
|
||||
record.stale = false
|
||||
record.loading = !record.snapshot
|
||||
record.refreshing = Boolean(record.snapshot)
|
||||
record.error = undefined
|
||||
this.publish(record)
|
||||
const controller = new AbortController()
|
||||
record.inFlightController = controller
|
||||
const promise = this.api.getPluginControls(record.instanceId, record.location, controller.signal)
|
||||
.then((snapshot) => {
|
||||
if (!this.isActive(record) || generation !== record.generation) return
|
||||
if (!this.adoptCanonicalLocation(record, snapshot.location, false, { snapshot, revision: requestRevision })) return
|
||||
record.snapshot = snapshot
|
||||
record.snapshotRevision = ++this.revision
|
||||
record.error = undefined
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!this.isActive(record) || generation !== record.generation) return
|
||||
// An aborted orphan fetch must not surface as a passive read error.
|
||||
if (controller.signal.aborted) return
|
||||
record.error = error
|
||||
})
|
||||
.finally(() => {
|
||||
if (!this.isActive(record) || record.inFlight !== promise) return
|
||||
record.inFlight = undefined
|
||||
if (record.inFlightController === controller) record.inFlightController = undefined
|
||||
record.loading = false
|
||||
record.refreshing = false
|
||||
const trailing = record.trailing
|
||||
record.trailing = false
|
||||
if (trailing) {
|
||||
// No follow-up may outlive its last visible owner. Keep the demand
|
||||
// as stale display state so reopening performs one reconciliation.
|
||||
record.stale = true
|
||||
}
|
||||
this.publish(record)
|
||||
})
|
||||
record.inFlight = promise
|
||||
return promise
|
||||
}
|
||||
|
||||
private publish(record: CacheRecord): void {
|
||||
this.publishAll([record])
|
||||
}
|
||||
|
||||
private publishAll(records: readonly CacheRecord[]): void {
|
||||
const active = records.filter((record) => this.isActive(record))
|
||||
if (active.length === 0) return
|
||||
const states = new Map<CacheRecord, PluginControlsState>()
|
||||
for (const record of active) {
|
||||
states.set(record, {
|
||||
snapshot: record.snapshot,
|
||||
loading: record.loading,
|
||||
refreshing: record.refreshing,
|
||||
stale: record.stale,
|
||||
error: record.error,
|
||||
})
|
||||
}
|
||||
this.setStates((previous) => {
|
||||
const next = new Map(previous)
|
||||
for (const [record, state] of states) {
|
||||
for (const key of record.keys) {
|
||||
if (this.records.get(key) === record) next.set(key, state)
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
for (const record of active) {
|
||||
if (record.demands.size > 0 && record.stale) void this.loadRecord(record, true)
|
||||
}
|
||||
}
|
||||
|
||||
private isActive(record: CacheRecord): boolean {
|
||||
return [...record.keys].some((key) => this.records.get(key) === record)
|
||||
}
|
||||
|
||||
private adoptCanonicalLocation(
|
||||
record: CacheRecord,
|
||||
location: PluginControlLocation,
|
||||
preferIncoming = false,
|
||||
incomingRead?: { snapshot: PluginControlsSnapshot; revision: number },
|
||||
): boolean {
|
||||
const canonicalKey = cacheKey(record.instanceId, location)
|
||||
const existing = this.records.get(canonicalKey)
|
||||
let invalidatedAlias = false
|
||||
if (existing && existing !== record) {
|
||||
if (preferIncoming || !existing.snapshot) {
|
||||
if (preferIncoming) {
|
||||
// A mutation can be the first response to reveal a host/canonical
|
||||
// alias. Carry the canonical record's newer runtime and event demand
|
||||
// across the merge before publishing that mutation's durable rule.
|
||||
record.invalidationRevision = Math.max(record.invalidationRevision, existing.invalidationRevision)
|
||||
record.stale ||= existing.stale || Boolean(existing.inFlight) || existing.trailing
|
||||
if (existing.snapshot && existing.snapshotRevision > record.snapshotRevision) {
|
||||
record.snapshot = existing.snapshot
|
||||
record.snapshotRevision = existing.snapshotRevision
|
||||
}
|
||||
}
|
||||
// The canonical record may have consumed an event before this alias
|
||||
// was known. Its refresh clears stale at dispatch, so retain the
|
||||
// generation as well as queued demand before orphaning that read.
|
||||
invalidatedAlias = !preferIncoming && (existing.generation > 0 || existing.stale || existing.trailing)
|
||||
existing.generation += 1
|
||||
existing.inFlightController?.abort()
|
||||
this.mergeRecord(existing, record)
|
||||
} else {
|
||||
// Keep the canonical owner (including its mutation queue), but let a
|
||||
// genuinely newer successful alias read recover its failed refresh.
|
||||
// Completion order alone cannot authorize replacing newer publications
|
||||
// or invalidations; those require a canonical reconciliation instead.
|
||||
if (existing.error) {
|
||||
if (incomingRead && !existing.inFlight
|
||||
&& existing.snapshotRevision < incomingRead.revision
|
||||
&& existing.invalidationRevision < incomingRead.revision) {
|
||||
existing.snapshot = incomingRead.snapshot
|
||||
existing.snapshotRevision = ++this.revision
|
||||
existing.error = undefined
|
||||
} else {
|
||||
existing.stale = true
|
||||
}
|
||||
}
|
||||
existing.stale ||= record.stale || record.trailing
|
||||
record.generation += 1
|
||||
record.inFlightController?.abort()
|
||||
this.mergeRecord(record, existing)
|
||||
this.publish(existing)
|
||||
return false
|
||||
}
|
||||
}
|
||||
this.records.set(canonicalKey, record)
|
||||
record.keys.add(canonicalKey)
|
||||
record.location = { ...location }
|
||||
record.canonicalLocation = true
|
||||
const pendingInvalidation = this.pendingUnknownInvalidations.get(canonicalKey)
|
||||
this.pendingUnknownInvalidations.delete(canonicalKey)
|
||||
if (pendingInvalidation !== undefined || invalidatedAlias) {
|
||||
if (pendingInvalidation !== undefined) {
|
||||
record.invalidationRevision = Math.max(record.invalidationRevision, pendingInvalidation)
|
||||
}
|
||||
if (preferIncoming) {
|
||||
record.stale = true
|
||||
return true
|
||||
}
|
||||
// A canonical event/demand arrived before the WSL alias was learned.
|
||||
// Discard this snapshot and retain one trailing refresh, including when
|
||||
// the canonical key already had an in-flight record of its own.
|
||||
record.generation += 1
|
||||
record.stale = true
|
||||
record.trailing = true
|
||||
this.publish(record)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private mergeRecord(source: CacheRecord, target: CacheRecord): void {
|
||||
for (const key of source.keys) {
|
||||
if (this.records.get(key) !== source) continue
|
||||
this.records.set(key, target)
|
||||
target.keys.add(key)
|
||||
}
|
||||
for (const demand of source.demands) {
|
||||
demand.record = target
|
||||
target.demands.add(demand)
|
||||
}
|
||||
source.demands.clear()
|
||||
}
|
||||
}
|
||||
|
||||
function retainRuntime(durable: PluginControlsSnapshot, newer: PluginControlsSnapshot): PluginControlsSnapshot {
|
||||
const runtimeById = new Map(newer.runtime.flatMap((entry) => entry.id ? [[entry.id, entry] as const] : []))
|
||||
const configuredIds = new Set(durable.configured.rules.map((rule) => rule.selector))
|
||||
const durableIds = new Set(durable.controls.map((control) => control.id))
|
||||
return {
|
||||
...durable,
|
||||
runtime: newer.runtime,
|
||||
controls: [
|
||||
...durable.controls.flatMap(({ runtime: previousRuntime, ...control }) => {
|
||||
const runtime = runtimeById.get(control.id)
|
||||
// A removed runtime-only plugin is no longer an inventory control;
|
||||
// exact configured IDs (including the durable rule) remain actionable.
|
||||
if (!runtime && previousRuntime && !configuredIds.has(control.id)) return []
|
||||
return [{ ...control, ...(runtime ? { runtime } : {}) }]
|
||||
}),
|
||||
...newer.controls.filter((control) => !durableIds.has(control.id)),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export const pluginControlsCache = new PluginControlsCache(serverApi)
|
||||
|
||||
function cacheKey(instanceId: string, location: PluginControlLocation): string {
|
||||
return `${JSON.stringify(instanceId)}:${JSON.stringify(location.directory)}`
|
||||
}
|
||||
|
|
@ -1745,7 +1745,7 @@ describe("session request authority", () => {
|
|||
}
|
||||
;(oldClient as any).mcp = { list: () => wait({ data: [] }) }
|
||||
;(oldClient as any).plugin = { list: () => wait({ data: [] }) }
|
||||
setInstanceMetadata(instanceId, { project: { id: "cached" } as any, mcpStatus: {} as any, plugins: [] })
|
||||
setInstanceMetadata(instanceId, { project: { id: "cached" } as any, mcpStatus: {} as any })
|
||||
const oldRequest = loadInstanceMetadata(instances().get(instanceId)!, { force: true })
|
||||
|
||||
const newClient = {
|
||||
|
|
|
|||
120
packages/ui/src/styles/components/plugin-controls.css
Normal file
120
packages/ui/src/styles/components/plugin-controls.css
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
.plugin-controls {
|
||||
--plugin-control-scope-width: 3rem;
|
||||
@apply space-y-2;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.plugin-controls-header,
|
||||
.plugin-control-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) repeat(2, var(--plugin-control-scope-width));
|
||||
align-items: center;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
[dir="rtl"] .plugin-control-name,
|
||||
[dir="rtl"] .plugin-control-sub,
|
||||
[dir="rtl"] .plugin-controls-description,
|
||||
[dir="rtl"] .plugin-controls-footer {
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.plugin-controls .icon-button-compact {
|
||||
@apply inline-flex h-6 w-6 items-center justify-center border;
|
||||
justify-self: start;
|
||||
border-color: var(--border-base);
|
||||
color: var(--text-muted);
|
||||
background-color: var(--surface-base);
|
||||
}
|
||||
|
||||
.plugin-controls .icon-button-compact:hover:not(:disabled) {
|
||||
background-image: linear-gradient(var(--surface-hover-overlay), var(--surface-hover-overlay));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.plugin-controls .icon-button-compact:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--focus-ring-offset), 0 0 0 4px var(--focus-ring-color);
|
||||
}
|
||||
|
||||
.plugin-controls-description {
|
||||
@apply text-[11px];
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.plugin-controls-notice {
|
||||
@apply break-words text-[11px];
|
||||
color: var(--text-muted);
|
||||
border-inline-start: 2px solid var(--border-base);
|
||||
padding-inline-start: 6px;
|
||||
}
|
||||
|
||||
.plugin-controls-footer {
|
||||
@apply space-y-0.5 break-words text-[11px];
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.plugin-control-name,
|
||||
.plugin-control-sub,
|
||||
.plugin-controls-footer {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.plugin-control-list {
|
||||
@apply space-y-1;
|
||||
}
|
||||
|
||||
.plugin-control-row {
|
||||
@apply border px-2 py-1;
|
||||
border-color: var(--border-base);
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.plugin-control-name {
|
||||
@apply block min-w-0 text-xs font-medium;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.plugin-control-sub {
|
||||
@apply flex min-w-0 items-center gap-1 text-[11px];
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.plugin-control-sub > span:last-child {
|
||||
@apply min-w-0 flex-1;
|
||||
}
|
||||
|
||||
.plugin-control-sub .status-dot {
|
||||
@apply flex-shrink-0;
|
||||
}
|
||||
|
||||
.plugin-control-scope-label {
|
||||
@apply text-center text-[10px] font-medium uppercase tracking-wide;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.plugin-control-switch {
|
||||
@apply flex min-w-0 items-center justify-center;
|
||||
padding: 6px 0;
|
||||
margin: -6px 0;
|
||||
}
|
||||
|
||||
.plugin-control-switch:has(input[aria-busy="true"]) {
|
||||
@apply animate-pulse;
|
||||
}
|
||||
|
||||
.plugin-controls-warning {
|
||||
@apply mt-1 break-words text-[11px];
|
||||
color: var(--status-error-fg);
|
||||
}
|
||||
|
||||
.plugin-controls .right-panel-empty-text .button-tertiary {
|
||||
@apply mt-2;
|
||||
}
|
||||
|
||||
@container (max-width: 300px) {
|
||||
.plugin-controls-header,
|
||||
.plugin-control-row {
|
||||
grid-template-columns: minmax(0, 1fr) repeat(2, 2.5rem);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
/* Shared SUID switch geometry for Yolo and MCP service enable controls.
|
||||
/* Shared SUID switch geometry for Yolo, MCP and plugin activation controls.
|
||||
Keep the same track and thumb in checked, disabled and pending states;
|
||||
each caller retains its semantic color and accessible input behavior. */
|
||||
.MuiSwitch-switchBase,
|
||||
|
|
|
|||
|
|
@ -28,3 +28,4 @@
|
|||
@import "./components/provider-auth.css";
|
||||
@import "./components/provider-model-visibility.css";
|
||||
@import "./components/prompt-context-controls.css";
|
||||
@import "./components/plugin-controls.css";
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ export type RawMcpStatus = McpListOutput
|
|||
export interface InstanceMetadata {
|
||||
project?: ProjectInfo | null
|
||||
mcpStatus?: RawMcpStatus | null
|
||||
plugins?: string[] | null
|
||||
version?: string
|
||||
}
|
||||
|
||||
|
|
|
|||
228
packages/ui/tests/browser/fixtures/plugin-controls.tsx
Normal file
228
packages/ui/tests/browser/fixtures/plugin-controls.tsx
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
import { Show, createSignal } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import type { PluginControlsSnapshot } from "../../../../server/src/api-types"
|
||||
import { PluginActivationControls } from "../../../src/components/plugin-activation-controls"
|
||||
import { ConfigProvider, updatePreferences } from "../../../src/stores/preferences"
|
||||
import { I18nProvider } from "../../../src/lib/i18n"
|
||||
import { ThemeProvider } from "../../../src/lib/theme"
|
||||
import { serverApi } from "../../../src/lib/api-client"
|
||||
import { serverEvents } from "../../../src/lib/server-events"
|
||||
import { getToastHistory } from "../../../src/lib/notifications"
|
||||
import "../../../src/index.css"
|
||||
|
||||
const instanceId = "plugin-controls-fixture"
|
||||
const location = { directory: "/repo" }
|
||||
const calls: Array<Record<string, unknown>> = []
|
||||
let reads = 0
|
||||
let mutationGate: Promise<void> | undefined
|
||||
let releaseMutation: (() => void) | undefined
|
||||
let failMutation = false
|
||||
let readGate: Promise<void> | undefined
|
||||
let releaseRead: (() => void) | undefined
|
||||
const [viewActive, setViewActive] = createSignal(false)
|
||||
const [mounted, setMounted] = createSignal(true)
|
||||
const [workspaceID, setWorkspaceID] = createSignal("session-one")
|
||||
|
||||
const active = {
|
||||
key: "acme.reviewer",
|
||||
id: "acme.reviewer",
|
||||
source: { type: "package" as const, target: "@acme/reviewer", version: "1.2.0" },
|
||||
features: { server: true as const },
|
||||
state: { status: "active" as const },
|
||||
}
|
||||
const failed = {
|
||||
key: "broken.plugin",
|
||||
id: "broken.plugin",
|
||||
source: { type: "local" as const, path: "/repo/.opencode/plugins/broken" },
|
||||
features: { rpc: true as const },
|
||||
state: { status: "failed" as const, error: "Setup failed safely", ref: "err_fixture" },
|
||||
}
|
||||
const builtin = {
|
||||
key: "opencode.provider.demo",
|
||||
id: "opencode.provider.demo",
|
||||
source: { type: "builtin" as const },
|
||||
features: { server: true as const },
|
||||
state: { status: "active" as const },
|
||||
}
|
||||
|
||||
let snapshot: PluginControlsSnapshot = {
|
||||
location,
|
||||
runtime: [active, failed, builtin],
|
||||
configured: {
|
||||
sources: [{ target: "@acme/reviewer", scope: "global", path: "/daemon/opencode.jsonc", entryIndex: 0, hasOptions: true }],
|
||||
rules: [
|
||||
{ selector: "*", enabled: true, scope: "global", path: "/daemon/opencode.jsonc", order: 0, entryIndex: 1 },
|
||||
{ selector: "opencode.prompt.identity", enabled: false, scope: "global", path: "/daemon/opencode.jsonc", order: 0, entryIndex: 2 },
|
||||
{ selector: "sleeping.plugin", enabled: false, scope: "project", path: "/repo/.opencode/opencode.jsonc", order: 1, entryIndex: 0 },
|
||||
],
|
||||
},
|
||||
controls: [
|
||||
{ id: active.id, runtime: active, builtin: false, effective: "enabled", global: "enabled", project: "default" },
|
||||
{ id: failed.id, runtime: failed, builtin: false, effective: "default", global: "default", project: "default" },
|
||||
{ id: builtin.id, runtime: builtin, builtin: true, effective: "enabled", global: "enabled", project: "default" },
|
||||
{ id: "opencode.prompt.identity", builtin: true, effective: "disabled", global: "disabled", project: "default" },
|
||||
{
|
||||
id: "sleeping.plugin",
|
||||
builtin: false,
|
||||
effective: "disabled",
|
||||
global: "enabled",
|
||||
project: "disabled",
|
||||
controllingRule: { selector: "sleeping.plugin", enabled: false, scope: "project", path: "/repo/.opencode/opencode.jsonc", order: 1, entryIndex: 0 },
|
||||
},
|
||||
],
|
||||
targets: [
|
||||
{ scope: "global", path: "/daemon/opencode.jsonc", exists: true },
|
||||
{ scope: "project", path: "/repo/.opencode/opencode.jsonc", exists: true },
|
||||
],
|
||||
}
|
||||
|
||||
let uiConfig = { settings: { locale: "en" } }
|
||||
serverApi.fetchConfigOwner = async () => structuredClone(uiConfig) as any
|
||||
serverApi.patchConfigOwner = async (_owner, patch: any) => {
|
||||
uiConfig = { ...uiConfig, ...patch, settings: { ...uiConfig.settings, ...patch.settings } }
|
||||
return structuredClone(uiConfig) as any
|
||||
}
|
||||
serverApi.fetchStateOwner = async () => ({} as any)
|
||||
serverApi.getPluginControls = async (_id, requestedLocation) => {
|
||||
reads++
|
||||
calls.push({ type: "read", location: requestedLocation.directory })
|
||||
await readGate
|
||||
return structuredClone(snapshot)
|
||||
}
|
||||
serverApi.setPluginActivation = async (_id, request) => {
|
||||
calls.push({ type: "mutation", ...request })
|
||||
await mutationGate
|
||||
if (failMutation) {
|
||||
failMutation = false
|
||||
throw new Error("Isolated mutation failure")
|
||||
}
|
||||
const state = request.enabled ? "enabled" : "disabled"
|
||||
const rule = request.enabled ? request.pluginId : `-${request.pluginId}`
|
||||
const control = snapshot.controls.find((entry) => entry.id === request.pluginId)!
|
||||
control[request.scope] = state
|
||||
control.effective = request.scope === "project" || control.project === "default" ? state : control.effective
|
||||
const configuredRule = {
|
||||
selector: request.pluginId,
|
||||
enabled: request.enabled,
|
||||
scope: request.scope,
|
||||
path: snapshot.targets.find((target) => target.scope === request.scope)!.path,
|
||||
order: snapshot.configured.rules.length + 1,
|
||||
entryIndex: snapshot.configured.rules.length,
|
||||
}
|
||||
control.controllingRule = configuredRule
|
||||
snapshot.configured.rules.push(configuredRule)
|
||||
return {
|
||||
snapshot: structuredClone(snapshot),
|
||||
rule,
|
||||
target: snapshot.targets.find((target) => target.scope === request.scope)!,
|
||||
changed: true,
|
||||
reloadPending: true,
|
||||
}
|
||||
}
|
||||
|
||||
render(() => (
|
||||
<ConfigProvider>
|
||||
<I18nProvider>
|
||||
<ThemeProvider>
|
||||
<main style={{ width: "430px", margin: "24px", padding: "12px", "background-color": "var(--surface-secondary)" }}>
|
||||
<Show when={mounted()}><PluginActivationControls
|
||||
instanceId={instanceId}
|
||||
location={{ ...location, workspaceID: workspaceID() }}
|
||||
active={viewActive()}
|
||||
/></Show>
|
||||
</main>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</ConfigProvider>
|
||||
), document.getElementById("root")!)
|
||||
await updatePreferences({ locale: "en" })
|
||||
|
||||
;(window as any).fixture = {
|
||||
calls,
|
||||
reads: () => reads,
|
||||
isActive: viewActive,
|
||||
show: () => setViewActive(true),
|
||||
hide: () => setViewActive(false),
|
||||
unmount: () => setMounted(false),
|
||||
remount: () => setMounted(true),
|
||||
toastHistory: () => getToastHistory(),
|
||||
holdRead: () => { readGate = new Promise<void>((resolve) => { releaseRead = resolve }) },
|
||||
releaseRead: () => { releaseRead?.(); readGate = undefined },
|
||||
invalidate: () => (serverEvents as any).dispatchBatch([{
|
||||
type: "instance.event",
|
||||
instanceId,
|
||||
event: { id: `event-refresh-${Date.now()}`, created: Date.now(), type: "plugin.updated", data: {}, location },
|
||||
}]),
|
||||
holdMutation: () => { mutationGate = new Promise<void>((resolve) => { releaseMutation = resolve }) },
|
||||
releaseMutation: () => { releaseMutation?.(); mutationGate = undefined },
|
||||
failMutation: () => { failMutation = true },
|
||||
setLocale: (locale: "en" | "fr" | "he") => updatePreferences({ locale }),
|
||||
setLongDetails: () => {
|
||||
const id = "company.integration.identical-prefix.production"
|
||||
const source = "/repo/.opencode/plugins/a-long-distinct-entrypoint/index.ts"
|
||||
const error = "Setup failed: a-long-actionable-diagnostic-ending-with-recovery-instructions"
|
||||
const control = snapshot.controls.find((entry) => entry.id === active.id)!
|
||||
control.id = id
|
||||
control.runtime = { ...active, id, source: { type: "local", path: source } }
|
||||
snapshot.controls.push({ ...control, id: "company.integration.identical-prefix.staging" })
|
||||
snapshot.controls.find((entry) => entry.id === failed.id)!.runtime = { ...failed, state: { ...failed.state, error } }
|
||||
return { id, source, error }
|
||||
},
|
||||
setNarrow: () => {
|
||||
const main = document.querySelector("main") as HTMLElement | null
|
||||
if (main) {
|
||||
main.style.width = "240px"
|
||||
main.style.margin = "8px"
|
||||
main.style.padding = "8px"
|
||||
}
|
||||
},
|
||||
setTargets: (scopes: Array<"global" | "project">) => {
|
||||
snapshot.targets = snapshot.targets.filter((target) => scopes.includes(target.scope))
|
||||
},
|
||||
switchSession: () => setWorkspaceID((current) => current === "session-one" ? "session-two" : "session-one"),
|
||||
activateSleepingPlugin: () => {
|
||||
const runtime = {
|
||||
key: "sleeping.plugin",
|
||||
id: "sleeping.plugin",
|
||||
source: { type: "package" as const, target: "sleeping-package", version: "2.0.0" },
|
||||
features: { server: true as const },
|
||||
state: { status: "active" as const },
|
||||
}
|
||||
snapshot.runtime.push(runtime)
|
||||
snapshot.controls.find((entry) => entry.id === runtime.id)!.runtime = runtime
|
||||
;(serverEvents as any).dispatchBatch([{
|
||||
type: "instance.event",
|
||||
instanceId,
|
||||
event: { id: "event-plugin", created: Date.now(), type: "plugin.updated", data: {}, location },
|
||||
}])
|
||||
},
|
||||
eventBurst: async () => {
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||
let burstReads = 0
|
||||
serverApi.getPluginControls = async () => {
|
||||
burstReads++
|
||||
if (burstReads === 1) await gate
|
||||
return structuredClone(snapshot)
|
||||
}
|
||||
const events = Array.from({ length: 30 }, (_, index) => ({
|
||||
type: "instance.event",
|
||||
instanceId,
|
||||
event: { id: `event-${index}`, created: Date.now(), type: index % 2 ? "plugin.updated" : "config.updated", data: {}, location },
|
||||
}))
|
||||
;(serverEvents as any).dispatchBatch(events.slice(0, 1))
|
||||
const firstReadDeadline = Date.now() + 2_000
|
||||
while (burstReads < 1) {
|
||||
if (Date.now() > firstReadDeadline) throw new Error("The first event did not start a refresh")
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
;(serverEvents as any).dispatchBatch(events.slice(1))
|
||||
release()
|
||||
const deadline = Date.now() + 2_000
|
||||
while (burstReads < 2) {
|
||||
if (Date.now() > deadline) throw new Error(`Trailing refresh did not run; observed ${burstReads} read(s)`)
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
return burstReads
|
||||
},
|
||||
}
|
||||
296
packages/ui/tests/browser/plugin-controls.test.ts
Normal file
296
packages/ui/tests/browser/plugin-controls.test.ts
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { after, before, test } from "node:test"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import { join } from "node:path"
|
||||
import { chromium, type Browser } from "playwright"
|
||||
import { createServer, type ViteDevServer } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
|
||||
let server: ViteDevServer, browser: Browser, url: string
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
configFile: false,
|
||||
root: fileURLToPath(new URL("../..", import.meta.url)),
|
||||
logLevel: "error",
|
||||
plugins: [solid(), { name: "plugin-controls-fixture", configureServer(vite) {
|
||||
vite.middlewares.use("/fixture", async (_request, response) => {
|
||||
response.setHeader("Content-Type", "text/html")
|
||||
response.end(await vite.transformIndexHtml("/fixture", '<html><body><div id="root"></div><script type="module" src="/tests/browser/fixtures/plugin-controls.tsx"></script></body></html>'))
|
||||
})
|
||||
} }],
|
||||
resolve: { dedupe: ["solid-js"] },
|
||||
optimizeDeps: { exclude: ["lucide-solid"] },
|
||||
server: { host: "127.0.0.1", port: 0, hmr: false, watch: null },
|
||||
})
|
||||
await server.listen()
|
||||
url = `http://127.0.0.1:${(server.httpServer!.address() as { port: number }).port}/fixture`
|
||||
browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined })
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await browser?.close()
|
||||
await server?.close()
|
||||
})
|
||||
|
||||
test("V2 plugin controls load on demand and expose explicit Global and Project switches", async () => {
|
||||
const page = await browser.newPage({ viewport: { width: 520, height: 900 }, locale: "en-US" })
|
||||
const errors: string[] = []
|
||||
page.on("pageerror", (error) => errors.push(error.message))
|
||||
await page.goto(url)
|
||||
|
||||
await page.waitForTimeout(100)
|
||||
assert.equal(await page.evaluate(() => (window as any).fixture.reads()), 0, "a hidden plugin section must not load")
|
||||
await page.evaluate(() => (window as any).fixture.show())
|
||||
await page.getByText("acme.reviewer", { exact: true }).waitFor()
|
||||
if (process.env.CODENOMAD_PLUGIN_SCREENSHOTS) {
|
||||
await mkdir(process.env.CODENOMAD_PLUGIN_SCREENSHOTS, { recursive: true })
|
||||
await page.screenshot({ path: join(process.env.CODENOMAD_PLUGIN_SCREENSHOTS, "plugins-initial.png"), fullPage: true })
|
||||
}
|
||||
assert.equal(await page.locator(".plugin-control-row").count(), 3)
|
||||
assert.equal(await page.locator('.plugin-control-row input[type="checkbox"]').count(), 6)
|
||||
assert.deepEqual(await page.locator(".plugin-control-scope-label").allTextContents(), ["Global", "Project"])
|
||||
assert.equal(await page.locator(".plugin-controls-description").count(), 1)
|
||||
assert.equal(await page.locator(".plugin-controls-footer").count(), 1)
|
||||
assert.equal(await page.getByText("Global — Rule target: /daemon/opencode.jsonc", { exact: false }).count(), 1)
|
||||
assert.equal(await page.getByText("Project — Rule target: /repo/.opencode/opencode.jsonc", { exact: false }).count(), 1)
|
||||
assert.equal(await page.locator('[data-plugin-id="broken.plugin"]').getByText("Failed", { exact: false }).count(), 1)
|
||||
assert.equal(await page.locator('[data-plugin-id="sleeping.plugin"]').getByText("overrides", { exact: false }).count(), 1)
|
||||
assert.equal(await page.getByText("Runtime inventory", { exact: true }).count(), 0)
|
||||
assert.equal(await page.getByText("Configured entries", { exact: true }).count(), 0)
|
||||
assert.equal(await page.getByText("opencode.provider.demo", { exact: true }).count(), 0)
|
||||
assert.equal(await page.getByText("opencode.prompt.identity", { exact: true }).count(), 0)
|
||||
assert.equal(await page.locator('.plugin-control-row input[type="checkbox"]').first().isDisabled(), false)
|
||||
|
||||
const inherited = page.locator('[data-plugin-id="broken.plugin"]')
|
||||
const inheritedGlobal = inherited.locator('[data-scope="global"] input[type="checkbox"]')
|
||||
const inheritedProject = inherited.locator('[data-scope="project"] input[type="checkbox"]')
|
||||
assert.equal(await inheritedGlobal.isChecked(), true)
|
||||
assert.equal(await inheritedProject.isChecked(), true)
|
||||
await inheritedProject.click()
|
||||
await page.waitForFunction(() => (window as any).fixture.calls.some((call: any) => (
|
||||
call.type === "mutation" && call.pluginId === "broken.plugin"
|
||||
&& call.scope === "project" && call.enabled === false
|
||||
)))
|
||||
assert.equal(await inheritedGlobal.isChecked(), true, "a Project override must not change the Global switch")
|
||||
assert.equal(await inheritedProject.isChecked(), false)
|
||||
|
||||
const sleeping = page.locator('[data-plugin-id="sleeping.plugin"]')
|
||||
const globalToggle = sleeping.locator('[data-scope="global"] input[type="checkbox"]')
|
||||
const projectToggle = sleeping.locator('[data-scope="project"] input[type="checkbox"]')
|
||||
assert.equal(await globalToggle.isChecked(), true)
|
||||
assert.equal(await projectToggle.isChecked(), false)
|
||||
await projectToggle.click()
|
||||
await page.waitForFunction(() => (window as any).fixture.calls.some((call: any) => (
|
||||
call.type === "mutation" && call.pluginId === "sleeping.plugin"
|
||||
&& call.scope === "project" && call.enabled === true
|
||||
)))
|
||||
assert.equal(await projectToggle.isChecked(), true)
|
||||
|
||||
const acmeGlobal = page.locator('[data-plugin-id="acme.reviewer"] [data-scope="global"] input[type="checkbox"]')
|
||||
await acmeGlobal.click()
|
||||
await page.waitForFunction(() => (window as any).fixture.calls.some((call: any) => (
|
||||
call.type === "mutation" && call.pluginId === "acme.reviewer"
|
||||
&& call.scope === "global" && call.enabled === false
|
||||
)))
|
||||
|
||||
const switchGeometry = await page.evaluate(() => {
|
||||
const track = document.querySelector(".plugin-control-row .MuiSwitch-track")!
|
||||
const root = document.querySelector(".plugin-control-row .MuiSwitch-root") as HTMLElement | null
|
||||
const box = (root ?? track as HTMLElement).getBoundingClientRect()
|
||||
return { radius: getComputedStyle(track).borderRadius, width: Math.round(box.width), height: Math.round(box.height) }
|
||||
})
|
||||
assert.notEqual(switchGeometry.radius, "0px")
|
||||
assert.deepEqual([switchGeometry.width, switchGeometry.height], [40, 24])
|
||||
|
||||
const readsBeforeSessionChange = await page.evaluate(() => (window as any).fixture.reads())
|
||||
await page.evaluate(() => (window as any).fixture.switchSession())
|
||||
await page.waitForTimeout(100)
|
||||
assert.equal(
|
||||
await page.evaluate(() => (window as any).fixture.reads()),
|
||||
readsBeforeSessionChange,
|
||||
"another session in the same worktree must reuse the snapshot",
|
||||
)
|
||||
|
||||
const readsBeforeActivation = await page.evaluate(() => (window as any).fixture.reads())
|
||||
await page.evaluate(() => (window as any).fixture.hide())
|
||||
await page.waitForFunction(() => !(window as any).fixture.isActive())
|
||||
await page.evaluate(() => (window as any).fixture.activateSleepingPlugin())
|
||||
await page.waitForTimeout(100)
|
||||
assert.equal(
|
||||
await page.evaluate(() => (window as any).fixture.reads()),
|
||||
readsBeforeActivation,
|
||||
"events must not refresh a hidden plugin section",
|
||||
)
|
||||
await page.evaluate(() => (window as any).fixture.show())
|
||||
await page.waitForFunction((before) => (window as any).fixture.reads() > before, readsBeforeActivation)
|
||||
const burstReads = await page.evaluate(() => (window as any).fixture.eventBurst())
|
||||
assert.equal(burstReads, 2, "an in-flight event burst must coalesce into one trailing refresh")
|
||||
assert.deepEqual(errors, [])
|
||||
await page.close()
|
||||
})
|
||||
|
||||
test("V2 plugin controls disable Project when it resolves to the Global document", async () => {
|
||||
const page = await browser.newPage({ viewport: { width: 520, height: 900 }, locale: "en-US" })
|
||||
const errors: string[] = []
|
||||
page.on("pageerror", (error) => errors.push(error.message))
|
||||
await page.goto(url)
|
||||
|
||||
await page.evaluate(() => (window as any).fixture.setTargets(["global"]))
|
||||
await page.evaluate(() => (window as any).fixture.show())
|
||||
await page.getByText("acme.reviewer", { exact: true }).waitFor()
|
||||
|
||||
const row = page.locator('[data-plugin-id="acme.reviewer"]')
|
||||
assert.equal(await row.locator('[data-scope="global"] input[type="checkbox"]').isDisabled(), false)
|
||||
assert.equal(await row.locator('[data-scope="project"] input[type="checkbox"]').isDisabled(), true)
|
||||
assert.equal(await page.locator(".plugin-controls-notice").count(), 1)
|
||||
const noticeId = await page.locator(".plugin-controls-notice").getAttribute("id")
|
||||
assert.ok(noticeId)
|
||||
const projectDescription = await row.locator('[data-scope="project"] input').getAttribute("aria-describedby")
|
||||
assert.ok(projectDescription?.split(" ").includes(noticeId))
|
||||
assert.equal(await page.evaluate((ids) => ids!.split(" ").every(id => Boolean(document.getElementById(id))), projectDescription), true)
|
||||
assert.ok(!(await row.locator('[data-scope="global"] input').getAttribute("aria-describedby"))?.split(" ").includes(noticeId))
|
||||
|
||||
const mutationsBefore = await page.evaluate(() => (window as any).fixture.calls.filter((call: any) => call.type === "mutation").length)
|
||||
await row.locator('[data-scope="project"] input[type="checkbox"]').click({ force: true })
|
||||
await page.waitForTimeout(200)
|
||||
assert.equal(
|
||||
await page.evaluate(() => (window as any).fixture.calls.filter((call: any) => call.type === "mutation").length),
|
||||
mutationsBefore,
|
||||
"a disabled Project switch must not mutate",
|
||||
)
|
||||
|
||||
await page.evaluate(() => (window as any).fixture.setNarrow())
|
||||
await page.waitForTimeout(100)
|
||||
const narrowLane = await row.locator('[data-scope="global"]').boundingBox()
|
||||
assert.ok((narrowLane?.width ?? 48) <= 40, "narrow panels compact the switch lanes")
|
||||
assert.equal(await page.locator(".plugin-controls-notice").evaluate((node) => node.scrollWidth <= node.clientWidth + 1), true)
|
||||
if (process.env.CODENOMAD_PLUGIN_SCREENSHOTS) {
|
||||
await mkdir(process.env.CODENOMAD_PLUGIN_SCREENSHOTS, { recursive: true })
|
||||
await page.screenshot({ path: join(process.env.CODENOMAD_PLUGIN_SCREENSHOTS, "plugins-unavailable-narrow.png"), fullPage: true })
|
||||
}
|
||||
assert.deepEqual(errors, [])
|
||||
await page.close()
|
||||
})
|
||||
|
||||
test("plugin rows retain focus through passive refresh and guarded pending mutations", async () => {
|
||||
const page = await browser.newPage({ viewport: { width: 520, height: 900 } })
|
||||
await page.goto(url)
|
||||
await page.evaluate(() => (window as any).fixture.show())
|
||||
const input = page.locator('[data-plugin-id="acme.reviewer"] [data-scope="global"] input')
|
||||
await input.waitFor()
|
||||
await input.focus()
|
||||
const original = await input.elementHandle()
|
||||
await page.evaluate(() => (window as any).fixture.eventBurst())
|
||||
assert.equal(await original!.evaluate((node) => node.isConnected && node === document.activeElement), true)
|
||||
|
||||
await page.evaluate(() => (window as any).fixture.holdMutation())
|
||||
await input.press("Space")
|
||||
await page.waitForFunction(() => document.activeElement?.getAttribute("aria-busy") === "true")
|
||||
assert.equal(await input.isChecked(), true, "pending state retains the authoritative value")
|
||||
await input.press("Space")
|
||||
assert.equal(await page.evaluate(() => (window as any).fixture.calls.filter((call: any) => call.type === "mutation").length), 1)
|
||||
assert.equal(await input.isChecked(), true, "blocked repeat activation must not visually toggle")
|
||||
await input.click({ force: true })
|
||||
assert.equal(await page.evaluate(() => (window as any).fixture.calls.filter((call: any) => call.type === "mutation").length), 1)
|
||||
assert.equal(await input.isChecked(), true, "blocked pointer activation must not visually toggle")
|
||||
await page.evaluate(() => (window as any).fixture.releaseMutation())
|
||||
await page.waitForFunction(() => document.activeElement?.getAttribute("aria-busy") === "false")
|
||||
assert.equal(await original!.evaluate((node) => node.isConnected && node === document.activeElement), true)
|
||||
assert.equal(await input.isChecked(), false)
|
||||
await input.press("Tab")
|
||||
assert.equal(await page.locator('[data-plugin-id="acme.reviewer"] [data-scope="project"] input').evaluate((node) => node === document.activeElement), true)
|
||||
await input.focus()
|
||||
await page.evaluate(() => { (window as any).fixture.holdMutation(); (window as any).fixture.failMutation() })
|
||||
await input.press("Space")
|
||||
await page.waitForFunction(() => document.activeElement?.getAttribute("aria-busy") === "true")
|
||||
await page.evaluate(() => (window as any).fixture.releaseMutation())
|
||||
await page.waitForFunction(() => document.activeElement?.getAttribute("aria-busy") === "false")
|
||||
assert.equal(await original!.evaluate((node) => node.isConnected && node === document.activeElement), true)
|
||||
assert.equal(await input.isChecked(), false, "failed mutation retains the authoritative value")
|
||||
await page.close()
|
||||
})
|
||||
|
||||
test("narrow keyboard and touch layouts expose complete plugin names, sources and errors", async () => {
|
||||
const page = await browser.newPage({ viewport: { width: 320, height: 1200 }, hasTouch: true })
|
||||
await page.goto(url)
|
||||
const details = await page.evaluate(() => {
|
||||
const fixture = (window as any).fixture
|
||||
fixture.setNarrow()
|
||||
const details = fixture.setLongDetails()
|
||||
fixture.show()
|
||||
return details
|
||||
})
|
||||
const row = page.locator(`[data-plugin-id="${details.id}"]`)
|
||||
await row.waitFor()
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
await page.evaluate((locale) => (window as any).fixture.setLocale(locale), direction === "rtl" ? "he" : "fr")
|
||||
await page.locator(".plugin-control-scope-label").filter({ hasText: direction === "rtl" ? "פרויקט" : "Projet" }).waitFor()
|
||||
await page.evaluate((dir) => { document.documentElement.dir = dir }, direction)
|
||||
await row.locator(".plugin-control-name").tap()
|
||||
await row.locator("input").first().focus()
|
||||
assert.ok((await row.locator(".plugin-control-sub").textContent())?.includes(details.source))
|
||||
assert.ok((await page.locator('[data-plugin-id="broken.plugin"] .plugin-control-sub').textContent())?.includes(details.error))
|
||||
const clipped = await page.locator(".plugin-control-name, .plugin-control-sub, .plugin-control-sub > span:last-child, .plugin-controls-footer > div").evaluateAll((nodes) => nodes.filter((node) => node.scrollWidth > node.clientWidth + 1 || node.scrollHeight > node.clientHeight + 1).map((node) => node.textContent))
|
||||
assert.deepEqual(clipped, [], `full text remains readable in ${direction}`)
|
||||
assert.equal(await page.locator("main").evaluate((node) => node.scrollWidth <= node.clientWidth + 1), true)
|
||||
if (direction === "ltr") assert.deepEqual(await page.locator(".plugin-control-scope-label").allTextContents(), ["Global", "Projet"])
|
||||
if (process.env.CODENOMAD_PLUGIN_SCREENSHOTS) {
|
||||
await mkdir(process.env.CODENOMAD_PLUGIN_SCREENSHOTS, { recursive: true })
|
||||
await page.screenshot({ path: join(process.env.CODENOMAD_PLUGIN_SCREENSHOTS, `plugins-narrow-${direction}.png`), fullPage: true })
|
||||
}
|
||||
}
|
||||
await page.close()
|
||||
})
|
||||
|
||||
for (const fail of [false, true]) {
|
||||
test(`disposed plugin surfaces suppress late mutation feedback, failure=${fail}`, async () => {
|
||||
const page = await browser.newPage({ viewport: { width: 520, height: 900 } })
|
||||
await page.goto(url)
|
||||
await page.evaluate((fail) => {
|
||||
const fixture = (window as any).fixture
|
||||
fixture.show()
|
||||
fixture.holdMutation()
|
||||
if (fail) fixture.failMutation()
|
||||
}, fail)
|
||||
const input = page.locator('[data-plugin-id="acme.reviewer"] [data-scope="project"] input')
|
||||
await input.click()
|
||||
await page.waitForFunction(() => document.querySelector('input[aria-busy="true"]'))
|
||||
await page.evaluate(() => (window as any).fixture.unmount())
|
||||
assert.equal(await page.locator(".plugin-controls").count(), 0)
|
||||
await page.evaluate(() => (window as any).fixture.releaseMutation())
|
||||
// Flush the deferred response and its async presentation continuation.
|
||||
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 0)))
|
||||
assert.deepEqual(await page.evaluate(() => (window as any).fixture.toastHistory()), [])
|
||||
await page.close()
|
||||
})
|
||||
}
|
||||
|
||||
for (const dispose of [false, true]) {
|
||||
test(`queued plugin reads wait for visible demand after hiding, disposed=${dispose}`, async () => {
|
||||
const page = await browser.newPage({ viewport: { width: 520, height: 900 } })
|
||||
await page.goto(url)
|
||||
await page.evaluate(() => (window as any).fixture.show())
|
||||
await page.locator('[data-plugin-id="acme.reviewer"]').waitFor()
|
||||
await page.evaluate(() => { (window as any).fixture.holdRead(); (window as any).fixture.invalidate() })
|
||||
await page.waitForFunction(() => (window as any).fixture.reads() === 2)
|
||||
await page.evaluate(() => (window as any).fixture.invalidate())
|
||||
await page.evaluate((dispose) => {
|
||||
const fixture = (window as any).fixture
|
||||
if (dispose) fixture.unmount()
|
||||
else fixture.hide()
|
||||
fixture.releaseRead()
|
||||
}, dispose)
|
||||
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 0)))
|
||||
assert.equal(await page.evaluate(() => (window as any).fixture.reads()), 2, "a queued read must not start after demand is withdrawn")
|
||||
await page.evaluate((dispose) => {
|
||||
const fixture = (window as any).fixture
|
||||
if (dispose) fixture.remount()
|
||||
else fixture.show()
|
||||
}, dispose)
|
||||
await page.waitForFunction(() => (window as any).fixture.reads() === 3)
|
||||
await page.locator('[data-plugin-id="acme.reviewer"]').waitFor()
|
||||
await page.close()
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue