diff options
Diffstat (limited to 'src/features/plugins/logic')
| -rw-r--r-- | src/features/plugins/logic/cascade.test.ts | 66 | ||||
| -rw-r--r-- | src/features/plugins/logic/cascade.ts | 42 | ||||
| -rw-r--r-- | src/features/plugins/logic/highlight.test.ts | 60 | ||||
| -rw-r--r-- | src/features/plugins/logic/highlight.ts | 37 | ||||
| -rw-r--r-- | src/features/plugins/logic/last-provider.test.ts | 70 | ||||
| -rw-r--r-- | src/features/plugins/logic/last-provider.ts | 31 | ||||
| -rw-r--r-- | src/features/plugins/logic/prereqs.test.ts | 58 | ||||
| -rw-r--r-- | src/features/plugins/logic/prereqs.ts | 45 | ||||
| -rw-r--r-- | src/features/plugins/logic/test-fixtures.ts | 30 | ||||
| -rw-r--r-- | src/features/plugins/logic/types.ts | 99 | ||||
| -rw-r--r-- | src/features/plugins/logic/view-model.test.ts | 296 | ||||
| -rw-r--r-- | src/features/plugins/logic/view-model.ts | 160 | ||||
| -rw-r--r-- | src/features/plugins/logic/workspace-row.test.ts | 67 | ||||
| -rw-r--r-- | src/features/plugins/logic/workspace-row.ts | 54 |
14 files changed, 1115 insertions, 0 deletions
diff --git a/src/features/plugins/logic/cascade.test.ts b/src/features/plugins/logic/cascade.test.ts new file mode 100644 index 0000000..109c08b --- /dev/null +++ b/src/features/plugins/logic/cascade.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { computeCascade } from "./cascade"; +import { node } from "./test-fixtures"; + +describe("computeCascade", () => { + it("returns [] when the target has no dependents", () => { + const nodes = [node({ id: "a" }), node({ id: "b" })]; + expect(computeCascade("a", nodes)).toEqual([]); + }); + + it("returns the direct loaded dependents", () => { + const nodes = [ + node({ id: "heartbeat" }), + node({ id: "transport-http", dependsOn: ["heartbeat"] }), + ]; + expect(computeCascade("heartbeat", nodes)).toEqual(["transport-http"]); + }); + + it("is transitive (dependent of dependent)", () => { + const nodes = [ + node({ id: "a" }), + node({ id: "b", dependsOn: ["a"] }), + node({ id: "c", dependsOn: ["b"] }), + ]; + expect(computeCascade("a", nodes)).toEqual(["b", "c"]); + }); + + it("does NOT traverse through an UNLOADED intermediate (loaded-subgraph closure — server mirror)", () => { + // c depends on b, b is unloaded: unloading a cascades to b only (b is not + // collected — unloaded) and does NOT reach c (its dep chain is already unmet). + const nodes = [ + node({ id: "a" }), + node({ id: "b", loaded: false, section: "discovered", dependsOn: ["a"] }), + node({ id: "c", dependsOn: ["b"] }), + ]; + expect(computeCascade("a", nodes)).toEqual([]); + }); + + it("ignores plugins that merely share a dependency (direction matters)", () => { + const nodes = [node({ id: "dep" }), node({ id: "x", dependsOn: ["dep"] })]; + expect(computeCascade("x", nodes)).toEqual([]); + }); + + it("handles diamond dependencies without duplicates", () => { + const nodes = [ + node({ id: "root" }), + node({ id: "left", dependsOn: ["root"] }), + node({ id: "right", dependsOn: ["root"] }), + node({ id: "bottom", dependsOn: ["left", "right"] }), + ]; + expect(computeCascade("root", nodes)).toEqual(["bottom", "left", "right"]); + }); + + it("returns [] for a target that is not in the list", () => { + expect(computeCascade("ghost", [node({ id: "a" })])).toEqual([]); + }); + + it("is deterministic (alphabetical order)", () => { + const nodes = [ + node({ id: "root" }), + node({ id: "zeta", dependsOn: ["root"] }), + node({ id: "alpha", dependsOn: ["root"] }), + ]; + expect(computeCascade("root", nodes)).toEqual(["alpha", "zeta"]); + }); +}); diff --git a/src/features/plugins/logic/cascade.ts b/src/features/plugins/logic/cascade.ts new file mode 100644 index 0000000..0cc6834 --- /dev/null +++ b/src/features/plugins/logic/cascade.ts @@ -0,0 +1,42 @@ +import type { PluginNode } from "./types"; + +/** + * The transitive set of LOADED dependents that would also be unloaded if + * `targetId` were unloaded — the §8 cascade-uncheck warning's list ("unloading + * X also unchecks Y, Z"). Client mirror of the backend's + * `computeUnloadCascade` (plan 05): the reverse-dependency closure computed + * over the LOADED subgraph only (an unloaded intermediate breaks the chain — + * a loaded dependent behind it is already unmet and is not part of THIS + * cascade). The server still enforces the real cascade; the FE computes the + * same set to warn before the request goes out. + * + * Pure input → output; deterministic order (alphabetical by id) so tests and + * renderings are stable. + */ +export function computeCascade(targetId: string, nodes: readonly PluginNode[]): readonly string[] { + // Reverse edges within the loaded subgraph: dep id → loaded dependent ids. + const dependentsOf = new Map<string, string[]>(); + for (const node of nodes) { + if (!node.loaded) continue; + for (const dep of node.dependsOn) { + const list = dependentsOf.get(dep); + if (list) list.push(node.id); + else dependentsOf.set(dep, [node.id]); + } + } + + // BFS over reverse edges, collecting every reached loaded dependent. + const out: string[] = []; + const seen = new Set<string>([targetId]); + const queue = [targetId]; + while (queue.length > 0) { + const current = queue.shift() as string; + for (const dependent of dependentsOf.get(current) ?? []) { + if (seen.has(dependent)) continue; + seen.add(dependent); + out.push(dependent); + queue.push(dependent); + } + } + return out.sort((a, b) => a.localeCompare(b)); +} diff --git a/src/features/plugins/logic/highlight.test.ts b/src/features/plugins/logic/highlight.test.ts new file mode 100644 index 0000000..616f7ad --- /dev/null +++ b/src/features/plugins/logic/highlight.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { dependencyHighlightSets } from "./highlight"; +import { node } from "./test-fixtures"; + +describe("dependencyHighlightSets", () => { + const nodes = [ + node({ id: "lib", loaded: false, section: "discovered" }), + node({ id: "mid", dependsOn: ["lib"] }), + node({ id: "app", dependsOn: ["mid", "lib"] }), + ]; + + it("returns empty sets for no hover", () => { + const sets = dependencyHighlightSets(null, nodes); + expect(sets.deps.size).toBe(0); + expect(sets.dependents.size).toBe(0); + }); + + it("hovering an item highlights its DIRECT dependencies (one hop)", () => { + const sets = dependencyHighlightSets("app", nodes); + expect(sets.deps).toEqual(new Set(["mid", "lib"])); + expect(sets.dependents.size).toBe(0); + }); + + it("hovering a dependency highlights its dependents (both directions work)", () => { + const sets = dependencyHighlightSets("lib", nodes); + expect(sets.deps.size).toBe(0); + expect(sets.dependents).toEqual(new Set(["mid", "app"])); + }); + + it("hovering a middle node highlights BOTH directions", () => { + const sets = dependencyHighlightSets("mid", nodes); + expect(sets.deps).toEqual(new Set(["lib"])); + expect(sets.dependents).toEqual(new Set(["app"])); + }); + + it("is one hop — not the transitive closure", () => { + // app → mid → lib: hovering app highlights mid + lib (both direct deps), + // but hovering lib highlights only DIRECT dependents (mid, app) — already + // one hop. The one-hop rule shows in: a plugin two hops away via an + // unrelated chain is not highlighted. + const chain = [ + node({ id: "a" }), + node({ id: "b", dependsOn: ["a"] }), + node({ id: "c", dependsOn: ["b"] }), + ]; + expect(dependencyHighlightSets("c", chain).deps).toEqual(new Set(["b"])); + }); + + it("returns empty sets for an unknown hovered id", () => { + const sets = dependencyHighlightSets("ghost", nodes); + expect(sets.deps.size).toBe(0); + expect(sets.dependents.size).toBe(0); + }); + + it("works across sections (a loaded row highlights an unloaded dependency)", () => { + // `app` loaded, `lib` discovered: hovering app still highlights lib. + const sets = dependencyHighlightSets("app", nodes); + expect(sets.deps.has("lib")).toBe(true); + }); +}); diff --git a/src/features/plugins/logic/highlight.ts b/src/features/plugins/logic/highlight.ts new file mode 100644 index 0000000..1bcfaad --- /dev/null +++ b/src/features/plugins/logic/highlight.ts @@ -0,0 +1,37 @@ +import type { PluginNode } from "./types"; + +/** The two highlight sets for the §8 bidirectional dependency hover. */ +export interface HighlightSets { + /** The hovered row's DIRECT dependencies (highlighted when hovering an item). */ + readonly deps: ReadonlySet<string>; + /** The loaded/unloaded rows that DIRECTLY depend on the hovered row + * (highlighted when hovering a dependency). */ + readonly dependents: ReadonlySet<string>; +} + +const EMPTY: HighlightSets = { deps: new Set(), dependents: new Set() }; + +/** + * Bidirectional hover highlighting (§8): hover any item → its DIRECT + * dependencies highlight; hover a dependency → its dependents highlight. + * One hop in each direction (the plan's grammar is "its dependencies" / + * "its dependents" — the visual neighborhood, not the transitive closure; + * the transitive closures live in `computeCascade` / `autoCheckBackendPrereqs`). + * + * Works across BOTH manager tables (a loaded row can highlight an unloaded + * dependency row and vice versa) — the node list spans the whole catalog. + * Pure input → output; `hoveredId` null (no hover) → both sets empty. + */ +export function dependencyHighlightSets( + hoveredId: string | null, + nodes: readonly PluginNode[], +): HighlightSets { + if (hoveredId === null) return EMPTY; + const hovered = nodes.find((n) => n.id === hoveredId); + if (hovered === undefined) return EMPTY; + const deps = new Set(hovered.dependsOn); + const dependents = new Set( + nodes.filter((n) => n.id !== hoveredId && n.dependsOn.includes(hoveredId)).map((n) => n.id), + ); + return { deps, dependents }; +} diff --git a/src/features/plugins/logic/last-provider.test.ts b/src/features/plugins/logic/last-provider.test.ts new file mode 100644 index 0000000..f85ba89 --- /dev/null +++ b/src/features/plugins/logic/last-provider.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { isLastProvider, unloadBlockReason } from "./last-provider"; +import { node } from "./test-fixtures"; + +describe("isLastProvider", () => { + it("is true for a loaded provider when it is the only one", () => { + const nodes = [node({ id: "opencode", tier: "providers" })]; + expect(isLastProvider("opencode", nodes)).toBe(true); + }); + + it("is false for a loaded provider when another provider is loaded", () => { + const nodes = [ + node({ id: "opencode", tier: "providers" }), + node({ id: "kimi", tier: "providers" }), + ]; + expect(isLastProvider("opencode", nodes)).toBe(false); + }); + + it("is false for a non-provider target (even when alone)", () => { + const nodes = [node({ id: "solo", tier: "standard" })]; + expect(isLastProvider("solo", nodes)).toBe(false); + }); + + it("is false for an UNLOADED provider", () => { + const nodes = [node({ id: "opencode", tier: "providers", loaded: false })]; + expect(isLastProvider("opencode", nodes)).toBe(false); + }); + + it("is false for an unknown id", () => { + expect(isLastProvider("ghost", [])).toBe(false); + }); + + it("is false for a core-only list (no providers at all)", () => { + const nodes = [node({ id: "kernel", tier: "core", locked: true })]; + expect(isLastProvider("kernel", nodes)).toBe(false); + }); +}); + +describe("unloadBlockReason", () => { + it("explains the core lock", () => { + const nodes = [node({ id: "kernel", tier: "core", locked: true })]; + const reason = unloadBlockReason("kernel", nodes); + expect(reason).toContain("Core plugins cannot be unloaded"); + }); + + it("explains the last-provider lock", () => { + const nodes = [node({ id: "opencode", tier: "providers" })]; + const reason = unloadBlockReason("opencode", nodes); + expect(reason).toContain("last provider"); + }); + + it("is null for an interactive row", () => { + const nodes = [ + node({ id: "opencode", tier: "providers" }), + node({ id: "kimi", tier: "providers" }), + node({ id: "app" }), + ]; + expect(unloadBlockReason("app", nodes)).toBeNull(); + expect(unloadBlockReason("opencode", nodes)).toBeNull(); + }); + + it("prefers the core lock when both would apply", () => { + const nodes = [node({ id: "kernel", tier: "core", locked: true })]; + expect(unloadBlockReason("kernel", nodes)).toContain("Core"); + }); + + it("is null for an unknown id", () => { + expect(unloadBlockReason("ghost", [])).toBeNull(); + }); +}); diff --git a/src/features/plugins/logic/last-provider.ts b/src/features/plugins/logic/last-provider.ts new file mode 100644 index 0000000..279df4d --- /dev/null +++ b/src/features/plugins/logic/last-provider.ts @@ -0,0 +1,31 @@ +import type { PluginNode } from "./types"; + +/** + * Whether unloading `targetId` would remove the LAST loaded provider — the + * §8 hard lock (the one unload the server refuses outright, never force-able). + * The UI disables the control with an explanatory tooltip; the server still + * enforces the rule. Pure input → output. + */ +export function isLastProvider(targetId: string, nodes: readonly PluginNode[]): boolean { + const target = nodes.find((n) => n.id === targetId); + if (target === undefined || !target.loaded || target.tier !== "providers") return false; + const loadedProviders = nodes.filter((n) => n.loaded && n.tier === "providers"); + return loadedProviders.length <= 1; +} + +/** + * Why the row's load/unload control is disabled, or null when it is + * interactive. Composes the two client-mirrored locks: + * - tier "core" (`locked`) → the server refuses to unload it (§3/§5); + * - the last loaded provider → the §8 hard lock. + * Missing rows have no control at all (handled separately by the UI). + */ +export function unloadBlockReason(targetId: string, nodes: readonly PluginNode[]): string | null { + const target = nodes.find((n) => n.id === targetId); + if (target === undefined) return null; + if (target.locked) return "Core plugins cannot be unloaded — they are part of the runtime."; + if (isLastProvider(targetId, nodes)) { + return "Cannot unload the last provider — Dispatch needs at least one to pick models."; + } + return null; +} diff --git a/src/features/plugins/logic/prereqs.test.ts b/src/features/plugins/logic/prereqs.test.ts new file mode 100644 index 0000000..704399c --- /dev/null +++ b/src/features/plugins/logic/prereqs.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { autoCheckBackendPrereqs } from "./prereqs"; +import { node } from "./test-fixtures"; + +describe("autoCheckBackendPrereqs", () => { + it("returns [] when all dependencies are already loaded", () => { + const nodes = [node({ id: "dep" }), node({ id: "app", dependsOn: ["dep"] })]; + expect(autoCheckBackendPrereqs("app", nodes)).toEqual([]); + }); + + it("returns the unloaded direct dependencies (to auto-check)", () => { + const nodes = [ + node({ id: "dep", loaded: false, section: "discovered" }), + node({ id: "other", loaded: false, section: "discovered" }), + node({ id: "loaded-dep" }), + node({ id: "app", dependsOn: ["dep", "other", "loaded-dep"] }), + ]; + // Only the UNLOADED deps are returned (loaded-dep needs no auto-check). + expect(autoCheckBackendPrereqs("app", nodes)).toEqual(["dep", "other"]); + }); + + it("is transitive (dep of a dep)", () => { + const nodes = [ + node({ id: "base", loaded: false, section: "discovered" }), + node({ id: "mid", loaded: false, section: "discovered", dependsOn: ["base"] }), + node({ id: "app", dependsOn: ["mid"] }), + ]; + // Load order: dependencies BEFORE their dependents. + expect(autoCheckBackendPrereqs("app", nodes)).toEqual(["mid", "base"]); + }); + + it("is one-directional: it never pulls DEPENDENTS in (no reverse traversal)", () => { + // Checking `dep` must not auto-check `app` (app depends on dep, not vice versa). + const nodes = [ + node({ id: "dep", loaded: false, section: "discovered" }), + node({ id: "app", dependsOn: ["dep"] }), + ]; + expect(autoCheckBackendPrereqs("dep", nodes)).toEqual([]); + }); + + it("excludes the target itself and skips already-seen nodes (diamond)", () => { + const nodes = [ + node({ id: "base", loaded: false, section: "discovered" }), + node({ id: "left", loaded: false, section: "discovered", dependsOn: ["base"] }), + node({ id: "right", loaded: false, section: "discovered", dependsOn: ["base"] }), + node({ id: "app", dependsOn: ["left", "right"] }), + ]; + expect(autoCheckBackendPrereqs("app", nodes)).toEqual(["left", "right", "base"]); + }); + + it("returns [] for an unknown target", () => { + expect(autoCheckBackendPrereqs("ghost", [node({ id: "a" })])).toEqual([]); + }); + + it("returns [] for a target with no dependencies", () => { + expect(autoCheckBackendPrereqs("app", [node({ id: "app" })])).toEqual([]); + }); +}); diff --git a/src/features/plugins/logic/prereqs.ts b/src/features/plugins/logic/prereqs.ts new file mode 100644 index 0000000..9066413 --- /dev/null +++ b/src/features/plugins/logic/prereqs.ts @@ -0,0 +1,45 @@ +import type { PluginNode } from "./types"; + +/** + * When CHECKING (loading) `targetId`, the transitive set of its dependencies + * that are NOT currently loaded — the plugins that must be auto-checked + * (loaded) first (§8: "Checking a frontend plugin auto-checks its required + * backend plugins (transitively)"). + * + * One-directional by construction: only `dependsOn` edges FROM the target are + * followed (frontend→backend / backend→backend). The reverse direction is + * NEVER traversed here — backend plugins never depend on frontend ones (§8), + * so a dependency auto-check never pulls the other way. + * + * Returns the ids in deterministic load order (dependencies before their + * dependents — alphabetical within a level) so the caller can issue the load + * requests in a safe sequence. Pure input → output. + */ +export function autoCheckBackendPrereqs( + targetId: string, + nodes: readonly PluginNode[], +): readonly string[] { + const byId = new Map(nodes.map((n) => [n.id, n])); + + // BFS over dependency edges (target → deps), collecting UNLOADED deps. + const out: string[] = []; + const seen = new Set<string>([targetId]); + // Queue of [id, depth] — sorted per level so ties resolve alphabetically. + let frontier: string[] = [targetId]; + while (frontier.length > 0) { + const next: string[] = []; + for (const id of frontier) { + const node = byId.get(id); + for (const dep of node?.dependsOn ?? []) { + if (seen.has(dep)) continue; + seen.add(dep); + next.push(dep); + if (!byId.get(dep)?.loaded) out.push(dep); + } + } + frontier = next.sort((a, b) => a.localeCompare(b)); + } + // Load order: dependencies before dependents (BFS level order already is), + // alphabetical within a level. + return out; +} diff --git a/src/features/plugins/logic/test-fixtures.ts b/src/features/plugins/logic/test-fixtures.ts new file mode 100644 index 0000000..e7b3f5d --- /dev/null +++ b/src/features/plugins/logic/test-fixtures.ts @@ -0,0 +1,30 @@ +import type { PluginListResponse } from "@dispatch/transport-contract"; +import type { PluginNode } from "./types"; +import { pluginNodes } from "./view-model"; + +/** + * Build a PluginNode directly (the graph-logic tests' fixture). Defaults: + * loaded, standard tier, no deps. + */ +export function node(overrides: Partial<PluginNode> = {}): PluginNode { + return { + id: "p", + name: "p", + version: "1.0.0", + tier: "standard", + trust: "bundled", + loaded: true, + locked: false, + dependsOn: [], + reason: null, + section: "loaded", + ...overrides, + }; +} + +/** An empty `GET /plugins` response. */ +export function emptyListResponse(): PluginListResponse { + return { loaded: [], disabledWithReason: [], discovered: [], missing: [] }; +} + +export { pluginNodes }; diff --git a/src/features/plugins/logic/types.ts b/src/features/plugins/logic/types.ts new file mode 100644 index 0000000..e64b553 --- /dev/null +++ b/src/features/plugins/logic/types.ts @@ -0,0 +1,99 @@ +import type { PluginTier, PluginTrustLevel } from "@dispatch/transport-contract"; + +/** Which manager table a plugin row belongs to (the response's row kinds). */ +export type PluginSection = "loaded" | "discovered" | "missing"; + +/** + * The manager's normalized view of one plugin — a single shape across the + * `GET /plugins` response's row kinds (loaded / disabledWithReason / + * discovered / missing) so the pure graph logic (cascade / prereqs / + * highlight / last-provider) and the manager tables work over ONE node type. + * Built by `pluginNodes` (`logic/view-model`) from a `PluginListResponse`. + */ +export interface PluginNode { + readonly id: string; + readonly name: string; + readonly version: string; + readonly tier: PluginTier; + /** Trust level, when the row carries one (missing rows don't). */ + readonly trust: PluginTrustLevel | null; + /** Loaded and live — the manager's checkbox reflects this. */ + readonly loaded: boolean; + /** True for tier "core" — the server refuses to unload it (locked control). */ + readonly locked: boolean; + /** Declared dependencies (plugin ids), possibly empty. */ + readonly dependsOn: readonly string[]; + /** Why the plugin is not loaded (disabled-with-reason) or gone (missing). */ + readonly reason: string | null; + /** + * The frontend-plugin (plan 03) issue line for the manager row, when the + * plugin's FE half is incompatible ("incompatible frontend plugin") or + * failed (`frontend failed: <error>`). Absent = the FE half is fine (or + * the plugin declares no frontend section). + */ + readonly frontendIssue?: string; + readonly section: PluginSection; +} + +/** Display labels for the classification tiers (backend-canonical values). */ +export function tierLabel(tier: PluginTier): string { + switch (tier) { + case "core": + return "Core"; + case "providers": + return "Provider"; + case "standard": + return "Standard"; + } +} + +/** Display order of the tiers within a manager table (Core first). */ +export const TIER_ORDER: readonly PluginTier[] = ["core", "providers", "standard"]; + +/** + * The layout port (plan 04 §4.4) the workspace plugins modal needs for the + * "Reset layout to default" affordance — the modal is the natural home (it + * is where users manage the layout plugins themselves). Wired by the + * composition root over the layout store; absent → the section doesn't + * render (tests). + */ +export interface WorkspaceLayoutPort { + /** + * Whether a layout provider is active (the reset button's gate). False → + * the button is DISABLED with the reason shown ("no layout plugin + * enabled") — there is nothing to reset and no persistence to re-seed. + */ + readonly providerActive: boolean; + /** + * Reset the workspace's layout to the default seed (`DELETE + * /workspaces/:id/layout` → re-seed → broadcast). Resolves to `null` on + * success or to the failure reason to display. + */ + readonly reset: (workspaceId: string) => Promise<string | null>; + /** + * The workspace's PER-WORKSPACE PLUGIN STATE changed (the modal just + * toggled a plugin's enablement): the layout plugin that serves THIS + * workspace may have changed (the per-workspace activation rule) — the + * composition root re-probes the layout so the newly-enabled provider's + * tree (or the no-provider degrade) lands without a reload. Only the + * ACTIVE workspace's toggle re-renders; others are ignored by the store. + */ + readonly refresh: (workspaceId: string) => void; +} + +/** + * Runtime-normalize a tier value to the canonical THREE: anything that is not + * exactly `core` | `providers` | `standard` — a missing tier, or a value + * outside the contract ("kernel" is NOT a tier; the kernel hosts plugins, it + * is not one) — folds into `"standard"`, the safe catch-all classification. + * The wire contract pins the three values, so this only ever fires on a + * contract violation (the backend rejects tierless externals at boot and + * directory-scanned plugins fail manifest validation); folding rather than + * surfacing the raw value keeps every tier-keyed consumer total — e.g. + * `tierLabel`'s exhaustive switch (an out-of-contract value would render an + * empty badge) and the manager table's keyed grouping (a non-canonical key + * would create a second, colliding group). + */ +export function normalizeTier(tier: unknown): PluginTier { + return tier === "core" || tier === "providers" || tier === "standard" ? tier : "standard"; +} diff --git a/src/features/plugins/logic/view-model.test.ts b/src/features/plugins/logic/view-model.test.ts new file mode 100644 index 0000000..661cade --- /dev/null +++ b/src/features/plugins/logic/view-model.test.ts @@ -0,0 +1,296 @@ +import type { PluginListResponse, PluginTier } from "@dispatch/transport-contract"; +import { describe, expect, it } from "vitest"; +import { emptyListResponse, node } from "./test-fixtures"; +import { groupByTier, pluginNodes, splitSections, withFrontendStates } from "./view-model"; + +function listResponse(overrides: Partial<PluginListResponse> = {}): PluginListResponse { + return { + loaded: [ + { + id: "kernel", + name: "Kernel", + version: "1.0.0", + tier: "core", + trust: "bundled", + locked: true, + dependsOn: [], + }, + { + id: "opencode", + name: "OpenCode", + version: "2.0.0", + tier: "providers", + trust: "bundled", + locked: false, + dependsOn: ["kernel"], + }, + ], + disabledWithReason: [ + { + id: "broken", + name: "Broken", + version: "0.1.0", + tier: "standard", + trust: "local", + reason: "activation threw", + }, + ], + discovered: [ + { + id: "fresh", + name: "Fresh", + version: "3.0.0", + tier: "standard", + trust: "external", + dir: "/plugins/fresh", + }, + ], + missing: [{ id: "gone", reason: "directory is gone", disabledAt: "2026-01-01" }], + ...overrides, + }; +} + +describe("pluginNodes", () => { + it("normalizes every row kind into one node shape", () => { + const nodes = pluginNodes(listResponse()); + expect(nodes.map((n) => n.id)).toEqual(["kernel", "opencode", "broken", "fresh", "gone"]); + }); + + it("maps loaded rows (loaded: true, locked from the row, deps kept)", () => { + const nodes = pluginNodes(listResponse()); + const kernel = nodes.find((n) => n.id === "kernel"); + expect(kernel).toMatchObject({ + loaded: true, + locked: true, + tier: "core", + section: "loaded", + dependsOn: [], + reason: null, + }); + const opencode = nodes.find((n) => n.id === "opencode"); + expect(opencode).toMatchObject({ loaded: true, locked: false, dependsOn: ["kernel"] }); + }); + + it("maps disabledWithReason rows (unloaded, reason kept, discovered section)", () => { + const nodes = pluginNodes(listResponse()); + const broken = nodes.find((n) => n.id === "broken"); + expect(broken).toMatchObject({ + loaded: false, + locked: false, + reason: "activation threw", + section: "discovered", + }); + }); + + it("maps discovered rows (unloaded, no reason)", () => { + const nodes = pluginNodes(listResponse()); + const fresh = nodes.find((n) => n.id === "fresh"); + expect(fresh).toMatchObject({ loaded: false, reason: null, section: "discovered" }); + }); + + it("maps missing rows (greyed strip; no trust/version)", () => { + const nodes = pluginNodes(listResponse()); + const gone = nodes.find((n) => n.id === "gone"); + expect(gone).toMatchObject({ + loaded: false, + section: "missing", + reason: "directory is gone", + trust: null, + version: "", + }); + }); + + it("handles the empty response", () => { + expect(pluginNodes(emptyListResponse())).toEqual([]); + }); + + it("normalizes a missing/unknown tier at the boundary so node tiers are canonical", () => { + // Contract-violation casts: the wire type pins three tier values, but the + // view-model must survive a backend that emits a tierless or out-of- + // contract row (the original duplicate-key crash trigger). + const response = listResponse({ + loaded: [ + { + id: "kernel", + name: "Kernel", + version: "1.0.0", + tier: "core", + trust: "bundled", + locked: true, + dependsOn: [], + }, + { + id: "tierless", + name: "Tierless", + version: "1.0.0", + tier: undefined as unknown as PluginTier, + trust: "external", + locked: false, + dependsOn: [], + }, + { + id: "kernelish", + name: "Kernelish", + version: "1.0.0", + tier: "kernel" as unknown as PluginTier, + trust: "external", + locked: false, + dependsOn: [], + }, + ], + }); + const nodes = pluginNodes(response); + expect(nodes.find((n) => n.id === "tierless")?.tier).toBe("standard"); + expect(nodes.find((n) => n.id === "kernelish")?.tier).toBe("standard"); + + // The Loaded table's grouping: exactly two groups (core + standard), no + // duplicate keys, every row present. + const groups = groupByTier(nodes.filter((n) => n.section === "loaded")); + const keys = groups.map((g) => g.tier); + expect(new Set(keys).size).toBe(keys.length); + expect(keys).toEqual(["core", "standard"]); + expect(groups[0]?.rows.map((r) => r.id)).toEqual(["kernel"]); + expect(groups[1]?.rows.map((r) => r.id)).toEqual(["kernelish", "tierless"]); + }); +}); + +describe("groupByTier", () => { + it("clusters rows in tier display order (Core → Providers → Standard)", () => { + const nodes = pluginNodes(listResponse()); + const groups = groupByTier(nodes.filter((n) => n.section === "loaded")); + expect(groups.map((g) => g.tier)).toEqual(["core", "providers"]); + expect(groups[0]?.rows.map((r) => r.id)).toEqual(["kernel"]); + }); + + it("omits empty tiers", () => { + const groups = groupByTier([pluginNodes(listResponse())[2] ?? []].flat()); + expect(groups.map((g) => g.tier)).toEqual(["standard"]); + }); + + it("emits one group per canonical tier, all three present", () => { + const groups = groupByTier([ + node({ id: "s", tier: "standard" }), + node({ id: "c", tier: "core" }), + node({ id: "p", tier: "providers" }), + ]); + expect(groups.map((g) => g.tier)).toEqual(["core", "providers", "standard"]); + expect(groups.flatMap((g) => g.rows.map((r) => r.id))).toEqual(["c", "p", "s"]); + }); + + it("never emits duplicate group keys — an unknown tier folds into standard", () => { + // Contract violation cast: "kernel" is NOT a tier (the kernel hosts + // plugins, it is not one). groupByTier must stay total anyway. + const groups = groupByTier([ + node({ id: "s-row", tier: "standard" }), + node({ id: "k-row", tier: "kernel" as PluginTier }), + ]); + const keys = groups.map((g) => g.tier); + // No duplicates (the keyed each `(group.tier)` crashes on them)… + expect(new Set(keys).size).toBe(keys.length); + // …and the unknown-tier row is NOT dropped — it merges into standard. + expect(keys).toEqual(["standard"]); + expect(groups[0]?.rows.map((r) => r.id)).toEqual(["k-row", "s-row"]); + }); + + it("never emits duplicate group keys — a missing tier folds into standard", () => { + const groups = groupByTier([ + node({ id: "s-row", tier: "standard" }), + node({ id: "m-row", tier: undefined as unknown as PluginTier }), + ]); + const keys = groups.map((g) => g.tier); + expect(new Set(keys).size).toBe(keys.length); + expect(keys).toEqual(["standard"]); + expect(groups[0]?.rows.map((r) => r.id)).toEqual(["m-row", "s-row"]); + }); + + it("keeps unknown/missing tiers out of the canonical groups' identity (no second standard group)", () => { + // The old implementation pushed a trailing "rest" group keyed + // `rest[0]?.tier ?? "standard"` — a DUPLICATE standard key when canonical + // standard rows existed (the headless-Chromium each_key_duplicate crash). + const groups = groupByTier([ + node({ id: "a", tier: "standard" }), + node({ id: "b", tier: "bogus" as PluginTier }), + node({ id: "c", tier: undefined as unknown as PluginTier }), + ]); + expect(groups.map((g) => g.tier)).toEqual(["standard"]); + expect(groups[0]?.rows).toHaveLength(3); + }); +}); + +describe("splitSections", () => { + it("splits the node list into the two tables + the missing strip", () => { + const sections = splitSections(pluginNodes(listResponse())); + expect(sections.loaded.map((n) => n.id)).toEqual(["kernel", "opencode"]); + expect(sections.discovered.map((n) => n.id)).toEqual(["broken", "fresh"]); + expect(sections.missing.map((n) => n.id)).toEqual(["gone"]); + }); +}); + +// ── withFrontendStates (plan 03) — the manager rows' FE-half issues ───────── + +describe("withFrontendStates", () => { + function listWith(...ids: readonly string[]): PluginListResponse { + return { + loaded: ids.map((id) => ({ + id, + name: id, + version: "1.0.0", + tier: "standard" as const, + trust: "local" as const, + locked: false, + dependsOn: [], + })), + disabledWithReason: [], + discovered: [], + missing: [], + }; + } + + it("renders the plan-mandated grammar for an incompatible FE half", () => { + const nodes = pluginNodes(listWith("bad")); + const states = new Map([["bad", { kind: "incompatible", apiVersion: "^2" } as const]]); + const out = withFrontendStates(nodes, states); + expect(out[0]?.frontendIssue).toBe("incompatible frontend plugin"); + }); + + it("renders the activation error for a failed FE half", () => { + const nodes = pluginNodes(listWith("broken")); + const states = new Map([["broken", { kind: "failed", error: "boom" } as const]]); + const out = withFrontendStates(nodes, states); + expect(out[0]?.frontendIssue).toBe("frontend failed: boom"); + }); + + it("a loaded FE half adds no issue line; absent states add none", () => { + const nodes = pluginNodes(listWith("ok", "no-fe")); + const states = new Map([["ok", { kind: "loaded" } as const]]); + const out = withFrontendStates(nodes, states); + expect(out.find((n) => n.id === "ok")?.frontendIssue).toBeUndefined(); + expect(out.find((n) => n.id === "no-fe")?.frontendIssue).toBeUndefined(); + }); + + it("never annotates non-loaded sections (discovered / missing rows)", () => { + const response: PluginListResponse = { + loaded: [], + disabledWithReason: [], + discovered: [ + { + id: "dis", + name: "dis", + version: "1.0.0", + tier: "standard", + trust: "local", + dir: "/plugins/dis", + }, + ], + missing: [], + }; + const states = new Map([["dis", { kind: "failed", error: "boom" } as const]]); + const out = withFrontendStates(pluginNodes(response), states); + expect(out[0]?.frontendIssue).toBeUndefined(); + }); + + it("an empty state map is the identity (no allocation on the hot path)", () => { + const nodes = pluginNodes(listWith("a")); + expect(withFrontendStates(nodes, new Map())).toBe(nodes); + }); +}); diff --git a/src/features/plugins/logic/view-model.ts b/src/features/plugins/logic/view-model.ts new file mode 100644 index 0000000..5a66ffa --- /dev/null +++ b/src/features/plugins/logic/view-model.ts @@ -0,0 +1,160 @@ +import type { PluginListResponse } from "@dispatch/transport-contract"; +import type { FrontendPluginState } from "../../../core/plugin-abi"; +import { normalizeTier, type PluginNode, type PluginSection, TIER_ORDER } from "./types"; + +/** + * Normalize a `GET /plugins` response into the flat `PluginNode` list the + * manager tables + graph logic consume. Each response row kind maps to a + * node: + * - `loaded` → section "loaded" (`loaded: true`, `locked` from the row — + * tier "core" is always locked); + * - `disabledWithReason` → section "discovered" with the refusal `reason` + * (present on disk, refused/failed — greyed rows with a visible why); + * - `discovered` → section "discovered" (`loaded: false`, unchecked); + * - `missing` → section "missing" (override record whose directory is gone). + * + * Pure input → output; zero DOM/fetch. + */ +export function pluginNodes(response: PluginListResponse): readonly PluginNode[] { + const nodes: PluginNode[] = []; + for (const row of response.loaded) { + nodes.push({ + id: row.id, + name: row.name, + version: row.version, + // Normalize at the boundary: a missing/out-of-contract tier (backend + // bug, stale contract) folds into "standard" so every node's tier is + // canonical and the tier-keyed consumers below stay total. + tier: normalizeTier(row.tier), + trust: row.trust, + loaded: true, + locked: row.locked, + dependsOn: row.dependsOn, + reason: null, + section: "loaded", + }); + } + for (const row of response.disabledWithReason) { + nodes.push({ + id: row.id, + name: row.name, + version: row.version, + tier: normalizeTier(row.tier), + trust: row.trust, + loaded: false, + locked: false, + dependsOn: [], + reason: row.reason, + section: "discovered", + }); + } + for (const row of response.discovered) { + nodes.push({ + id: row.id, + name: row.name, + version: row.version, + tier: normalizeTier(row.tier), + trust: row.trust, + loaded: false, + locked: false, + dependsOn: [], + reason: null, + section: "discovered", + }); + } + for (const row of response.missing) { + nodes.push({ + id: row.id, + name: row.id, + version: "", + tier: "standard", + trust: null, + loaded: false, + locked: false, + dependsOn: [], + reason: row.reason ?? "directory is gone", + section: "missing", + }); + } + return nodes; +} + +/** + * Merge the frontend-plugin (plan 03) per-plugin FE states into the node + * list — the manager rows' `frontendIssue` line. Pure input → output: + * - `incompatible` → the plan-mandated "incompatible frontend plugin" + * grammar (the backend apiVersion-mismatch wording, FE half); + * - `failed` → the activation error (the FE half failed; the backend half + * stays loaded). + * Loaded rows only — the FE states never apply to discovered/missing rows. + */ +export function withFrontendStates( + nodes: readonly PluginNode[], + states: ReadonlyMap<string, FrontendPluginState>, +): readonly PluginNode[] { + if (states.size === 0) return nodes; + return nodes.map((node) => { + if (node.section !== "loaded") return node; + const state = states.get(node.id); + if (state === undefined) return node; + const issue = + state.kind === "incompatible" + ? "incompatible frontend plugin" + : state.kind === "failed" + ? `frontend failed: ${state.error}` + : null; + return issue === null ? node : { ...node, frontendIssue: issue }; + }); +} + +/** + * Cluster nodes for one manager table: tier groups in display order (Core → + * Providers → Standard), each tier's rows sorted by id. Tiers with no rows + * are omitted. Used by the loaded table's tier grouping (plan 07 §7.2). + * + * Duplicate-key-proof BY CONSTRUCTION: nodes are bucketed through a Map keyed + * by the NORMALIZED tier, so each of the three canonical tiers can yield at + * most one group — `PluginTable.svelte` renders a keyed each + * (`{#each groups as group (group.tier)}`), and a duplicate key throws + * `each_key_duplicate` in Svelte 5, aborting the whole manager render. The + * normalization also means an unknown/missing tier (contract violation — the + * backend rejects tierless externals at boot and directory scans fail + * validation) folds into the `standard` bucket instead of forming a separate + * trailing group; the old fallback (`rest[0]?.tier ?? "standard"`) produced a + * DUPLICATE `standard` key when canonical standard rows were present. + * groupByTier normalizes on the way in even though `pluginNodes` already + * does at the boundary — it is exported and must be safe for any caller. + */ +export function groupByTier( + nodes: readonly PluginNode[], +): ReadonlyArray<{ readonly tier: PluginNode["tier"]; readonly rows: readonly PluginNode[] }> { + const byTier = new Map<PluginNode["tier"], PluginNode[]>(); + for (const n of nodes) { + const tier = normalizeTier(n.tier); + const rows = byTier.get(tier); + if (rows) rows.push(n); + else byTier.set(tier, [n]); + } + const out: { tier: PluginNode["tier"]; rows: PluginNode[] }[] = []; + for (const tier of TIER_ORDER) { + const rows = byTier.get(tier); + if (!rows || rows.length === 0) continue; + out.push({ tier, rows: rows.sort((a, b) => a.id.localeCompare(b.id)) }); + } + return out; +} + +/** Split nodes into the manager's two tables + the missing strip. */ +export function splitSections(nodes: readonly PluginNode[]): { + readonly loaded: readonly PluginNode[]; + readonly discovered: readonly PluginNode[]; + readonly missing: readonly PluginNode[]; +} { + const bySection = (section: PluginSection): PluginNode[] => + nodes.filter((n) => n.section === section); + return { + loaded: bySection("loaded"), + discovered: bySection("discovered"), + missing: bySection("missing"), + }; +} diff --git a/src/features/plugins/logic/workspace-row.test.ts b/src/features/plugins/logic/workspace-row.test.ts new file mode 100644 index 0000000..4c7d29e --- /dev/null +++ b/src/features/plugins/logic/workspace-row.test.ts @@ -0,0 +1,67 @@ +import type { WorkspacePluginRow } from "@dispatch/transport-contract"; +import { describe, expect, it } from "vitest"; +import { rowBlockReason, rowChecked, rowSelectable, stateLabel } from "./workspace-row"; + +function row(overrides: Partial<WorkspacePluginRow> = {}): WorkspacePluginRow { + return { + pluginId: "mcp", + tier: "standard", + state: "undefined", + loaded: true, + locked: false, + ...overrides, + }; +} + +describe("rowChecked", () => { + it("is true only for an explicit enabled record", () => { + expect(rowChecked(row({ state: "enabled" }))).toBe(true); + expect(rowChecked(row({ state: "disabled" }))).toBe(false); + expect(rowChecked(row({ state: "undefined" }))).toBe(false); + }); +}); + +describe("rowSelectable", () => { + it("is true for a loaded, unlocked row (all three states)", () => { + for (const state of ["enabled", "disabled", "undefined"] as const) { + expect(rowSelectable(row({ state }))).toBe(true); + } + }); + + it("is false for an UNLOADED plugin (§7 strict layering — greyed out)", () => { + expect(rowSelectable(row({ loaded: false, state: "disabled" }))).toBe(false); + }); + + it("is false for a LOCKED (core) row — the server refuses the PUT (409)", () => { + expect(rowSelectable(row({ tier: "core", locked: true, state: "enabled" }))).toBe(false); + }); +}); + +describe("rowBlockReason", () => { + it("points at the global manager for unloaded plugins", () => { + const reason = rowBlockReason(row({ loaded: false })); + expect(reason).toContain("plugin manager"); + }); + + it("explains the core lock", () => { + const reason = rowBlockReason(row({ tier: "core", locked: true })); + expect(reason).toContain("Core plugins are enabled by default"); + }); + + it("prefers the unloaded reason when both apply", () => { + const reason = rowBlockReason(row({ loaded: false, tier: "core", locked: true })); + expect(reason).toContain("plugin manager"); + }); + + it("is null for an interactive row", () => { + expect(rowBlockReason(row())).toBeNull(); + }); +}); + +describe("stateLabel", () => { + it("renders the three wire states in a uniform grammar", () => { + expect(stateLabel("enabled")).toBe("enabled"); + expect(stateLabel("disabled")).toBe("disabled"); + expect(stateLabel("undefined")).toBe("not set"); + }); +}); diff --git a/src/features/plugins/logic/workspace-row.ts b/src/features/plugins/logic/workspace-row.ts new file mode 100644 index 0000000..c85b759 --- /dev/null +++ b/src/features/plugins/logic/workspace-row.ts @@ -0,0 +1,54 @@ +import type { WorkspacePluginRow, WorkspacePluginStateValue } from "@dispatch/transport-contract"; + +/** + * Pure helpers for the per-workspace plugin modal (plan 06 §6.6 wire rows → + * checkbox semantics). Zero DOM. + * + * State model (backend-canonical): `"enabled"` / `"disabled"` — an explicit + * record — or `"undefined"` for NO record (the blank slate; behaves disabled + * for Providers/Standard). Core rows arrive materialized `enabled` (Q-A). + */ + +/** Whether the modal checkbox is checked for a row (explicit `enabled` only). */ +export function rowChecked(row: WorkspacePluginRow): boolean { + return row.state === "enabled"; +} + +/** + * Whether the row's checkbox is selectable. Two unselectable cases (§7 + * strict layering + plan 06 §6.6): + * - the plugin is NOT loaded globally → greyed out and unselectable (only the + * global manager loads code); + * - the server reports the row locked (tier "core") → the server refuses the + * PUT (`core-locked` 409), so the control is disabled with a tooltip. + */ +export function rowSelectable(row: WorkspacePluginRow): boolean { + return row.loaded && !row.locked; +} + +/** + * Why the row is unselectable (tooltip text), or null when interactive. + * Unloaded beats locked in the copy (the user's actionable next step is the + * global manager). + */ +export function rowBlockReason(row: WorkspacePluginRow): string | null { + if (!row.loaded) { + return "Not loaded globally — load it in the plugin manager first (only the manager loads code)."; + } + if (row.locked) { + return "Core plugins are enabled by default in every workspace and managed globally."; + } + return null; +} + +/** The effective-state label shown next to a row (uniform three-state grammar). */ +export function stateLabel(state: WorkspacePluginStateValue): string { + switch (state) { + case "enabled": + return "enabled"; + case "disabled": + return "disabled"; + case "undefined": + return "not set"; + } +} |
