From 8c4f1ea2e35532a785c11f9a0f868ad546288f21 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sun, 2 Aug 2026 13:36:17 +0200 Subject: [PATCH] feat(sdk,plugins): typage et stabilisation runtime UI/layout publique (#128) --- .../plugins/PluginLayoutCellView.test.tsx | 6 + .../features/plugins/PluginLayoutCellView.tsx | 11 - .../plugins/PluginRuntimeProvider.tsx | 5 + frontend/src/plugins/runtime/index.ts | 40 ++ frontend/src/plugins/runtime/loader.test.ts | 54 ++ frontend/src/plugins/runtime/loader.ts | 7 +- frontend/src/plugins/runtime/registry.ts | 13 +- frontend/src/plugins/runtime/services.test.ts | 459 +++++++++++++++- frontend/src/plugins/runtime/services.ts | 515 +++++++++++++++++- 9 files changed, 1090 insertions(+), 20 deletions(-) diff --git a/frontend/src/features/plugins/PluginLayoutCellView.test.tsx b/frontend/src/features/plugins/PluginLayoutCellView.test.tsx index 71bc41e..2fa7531 100644 --- a/frontend/src/features/plugins/PluginLayoutCellView.test.tsx +++ b/frontend/src/features/plugins/PluginLayoutCellView.test.tsx @@ -30,7 +30,10 @@ function cell(overrides: Partial = {}): CustomPluginLayo }; } +let latestLayoutProps: PluginLayoutProps | null = null; + function MockLayoutComponent(props: PluginLayoutProps) { + latestLayoutProps = props; return (

{JSON.stringify(props.state)}

@@ -87,8 +90,11 @@ describe("PluginLayoutCellView", () => { it("renders the mock registered component when the provider is loaded", () => { const registry = new PluginRuntimeRegistry(); registry.add(stubPlugin()); + latestLayoutProps = null; renderCell(registry); expect(screen.getByTestId("state").textContent).toBe(JSON.stringify({ commits: 3 })); + expect(latestLayoutProps).not.toBeNull(); + expect(Object.prototype.hasOwnProperty.call(latestLayoutProps, "gateways")).toBe(false); }); it("round-trips state through setState → onStateChange", () => { diff --git a/frontend/src/features/plugins/PluginLayoutCellView.tsx b/frontend/src/features/plugins/PluginLayoutCellView.tsx index fec0685..ace4158 100644 --- a/frontend/src/features/plugins/PluginLayoutCellView.tsx +++ b/frontend/src/features/plugins/PluginLayoutCellView.tsx @@ -13,7 +13,6 @@ import { Component, type ErrorInfo, type ReactNode } from "react"; import type { CustomPluginLayoutCell, PluginAdmin } from "@/domain"; -import { useGateways } from "@/app/di"; import { usePluginRuntime } from "./PluginRuntimeProvider"; import { PluginLayoutFallback } from "./PluginLayoutFallback"; import { resolvePluginLayoutAvailability } from "./layoutAvailability"; @@ -68,7 +67,6 @@ export function PluginLayoutCellView({ onChooseAnotherLayout, }: PluginLayoutCellViewProps) { const { registry } = usePluginRuntime(); - const gateways = useGateways(); const availability = resolvePluginLayoutAvailability(registry, cell, installedPlugins); const providerDisplayName = registry.get(cell.pluginId)?.displayName ?? @@ -108,15 +106,6 @@ export function PluginLayoutCellView({ state={cell.state} setState={onStateChange} availability="available" - gateways={{ - project: gateways.project, - git: gateways.git, - terminal: gateways.terminal, - agents: gateways.agent, - system: gateways.system, - workState: gateways.workState, - focusedProject: gateways.focusedProject, - }} /> ); diff --git a/frontend/src/features/plugins/PluginRuntimeProvider.tsx b/frontend/src/features/plugins/PluginRuntimeProvider.tsx index 08a1966..dc0b520 100644 --- a/frontend/src/features/plugins/PluginRuntimeProvider.tsx +++ b/frontend/src/features/plugins/PluginRuntimeProvider.tsx @@ -75,6 +75,11 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti system: gateways.system, workState: gateways.workState, focusedProject: gateways.focusedProject, + pluginWorkspace: gateways.pluginWorkspace, + pluginTask: gateways.pluginTask, + pluginToolchain: gateways.pluginToolchain, + pluginEvents: gateways.pluginEvents, + pluginConfig: gateways.pluginConfig, }), ) .then((result) => { diff --git a/frontend/src/plugins/runtime/index.ts b/frontend/src/plugins/runtime/index.ts index a9752dd..9b4a43f 100644 --- a/frontend/src/plugins/runtime/index.ts +++ b/frontend/src/plugins/runtime/index.ts @@ -24,12 +24,52 @@ export { type BackgroundTaskRetryResult, type BackgroundTaskService, type BackgroundTaskStatus, + type ConfigDocument, + type ConfigDocumentFormat, + type ConfigDocumentReadOptions, + type ConfigDocumentService, + type ConfigDocumentUpdateOptions, + type ConfigDocumentWriteResult, + type ConfigUpdateMode, + type CommandTaskStatus, + type DiagnosticMessage, + type EnvDiagnostic, + type EnvRequirement, + type EventHandler, + type EventService, + type EventSubscribeOptions, + type EventSubscription, + type FileDiagnostic, + type FileRequirement, type PluginServices, + type PublicEvent, + type PublicEventType, + type RunCommandTaskOptions, type TerminalOpenOptions, type TerminalReattachOptions, type TerminalReattachResult, type TerminalService, type TerminalSession, + type ToolchainDiagnostic, + type ToolchainDiagnosticRequest, + type ToolDiagnostic, + type ToolingService, + type ToolRequirement, + type ProjectConvention, + type ProjectModule, + type ProjectStructure, + type ProjectStructureEntry, + type ProjectStructureEntryKind, + type WorkspaceBinaryFile, + type WorkspaceDirEntry, + type WorkspaceDirectoryListing, + type WorkspaceResolvedPath, + type WorkspaceStat, + type WorkspaceStructureQuery, + type WorkspaceTextFile, + type WorkspaceWatch, + type WorkspaceWatchEvent, + type WorkspaceWatchHandler, type WorkspaceProject, type WorkspaceService, } from "./services"; diff --git a/frontend/src/plugins/runtime/loader.test.ts b/frontend/src/plugins/runtime/loader.test.ts index 6cd92f5..82881f5 100644 --- a/frontend/src/plugins/runtime/loader.test.ts +++ b/frontend/src/plugins/runtime/loader.test.ts @@ -196,9 +196,27 @@ describe("loadPlugins", () => { it("injects the public plugin services facade for plugins declaring tooling", async () => { const bundle = dataUrl(` export function activate(ctx) { + globalThis.__activationContextKeys = Object.keys(ctx).sort(); + globalThis.__hasPrivateGateway = [ + "project", + "git", + "terminal", + "agents", + "system", + "workState", + "focusedProject", + "pluginWorkspace", + "pluginTask", + "pluginToolchain", + "pluginEvents", + "pluginConfig", + ].some((key) => key in ctx); globalThis.__serviceKeys = Object.keys(ctx.services).sort(); globalThis.__workspaceServiceKeys = Object.keys(ctx.services.workspace).sort(); globalThis.__taskServiceKeys = Object.keys(ctx.services.tasks).sort(); + globalThis.__toolingServiceKeys = Object.keys(ctx.services.tooling).sort(); + globalThis.__eventServiceKeys = Object.keys(ctx.services.events).sort(); + globalThis.__configServiceKeys = Object.keys(ctx.services.config).sort(); globalThis.__terminalServiceKeys = Object.keys(ctx.services.terminal).sort(); } `); @@ -215,23 +233,59 @@ describe("loadPlugins", () => { ); expect(failures).toEqual([]); + expect((globalThis as Record).__activationContextKeys).toEqual([ + "commands", + "layouts", + "logger", + "menu", + "pluginDisplayName", + "pluginId", + "services", + "subscriptions", + "version", + ]); + expect((globalThis as Record).__hasPrivateGateway).toBe(false); expect((globalThis as Record).__serviceKeys).toEqual([ + "config", + "events", "tasks", "terminal", + "tooling", "workspace", ]); expect((globalThis as Record).__workspaceServiceKeys).toEqual([ "getCurrentProject", "getProjectRoot", + "listDirectory", + "queryStructure", + "readBinaryFile", "readProjectContext", + "readTextFile", + "resolvePath", + "stat", "updateProjectContext", + "watch", + "writeBinaryFile", + "writeTextFile", ]); expect((globalThis as Record).__taskServiceKeys).toEqual([ "attachOutput", "cancel", + "getCommandStatus", "getStatus", "list", "retry", + "runCommand", + ]); + expect((globalThis as Record).__toolingServiceKeys).toEqual([ + "diagnose", + ]); + expect((globalThis as Record).__eventServiceKeys).toEqual([ + "subscribe", + ]); + expect((globalThis as Record).__configServiceKeys).toEqual([ + "readDocument", + "updateDocument", ]); expect((globalThis as Record).__terminalServiceKeys).toEqual([ "close", diff --git a/frontend/src/plugins/runtime/loader.ts b/frontend/src/plugins/runtime/loader.ts index 2211c88..14d8715 100644 --- a/frontend/src/plugins/runtime/loader.ts +++ b/frontend/src/plugins/runtime/loader.ts @@ -3,8 +3,8 @@ * * At UI bootstrap, the app calls {@link loadPlugins} once with the catalog * from `PluginGateway.listRuntimeContributions()` (already filtered by the - * backend to `enabled && !pendingUninstall`, carnet §1.3) and the stable - * gateways the plugin context exposes. For each entry it dynamically imports + * backend to `enabled && !pendingUninstall`, carnet §1.3) and the gateway set + * used to build the public service facade. For each entry it dynamically imports * the bundle URL, validates the module shape, and calls `activate(ctx)`, * scoping the command/layout registries to exactly the ids declared in that * plugin's manifest (enforced by {@link PluginCommandRegistry}/ @@ -29,7 +29,7 @@ import { createPluginServices, type PluginServices } from "./services"; export type { PluginGatewaySet } from "./registry"; -export interface IdeaPluginContext extends PluginGatewaySet { +export interface IdeaPluginContext { pluginId: string; pluginDisplayName: string; version: string; @@ -251,7 +251,6 @@ async function loadOne( commands: createCommandContext(commands), layouts, menu, - ...gateways, }; if (hasCapability(entry, "tooling")) { ctx.services = createPluginServices(gateways); diff --git a/frontend/src/plugins/runtime/registry.ts b/frontend/src/plugins/runtime/registry.ts index 1245296..39c93d2 100644 --- a/frontend/src/plugins/runtime/registry.ts +++ b/frontend/src/plugins/runtime/registry.ts @@ -23,13 +23,18 @@ import type { AgentGateway, FocusedProjectGateway, GitGateway, + PluginConfigGateway, + PluginEventGateway, + PluginTaskGateway, + PluginToolchainGateway, + PluginWorkspaceGateway, ProjectGateway, SystemGateway, TerminalGateway, WorkStateGateway, } from "@/ports"; -/** The stable gateways a plugin's `activate(ctx)` is allowed to reach (carnet §6). */ +/** Internal gateways used to build the public plugin service facade. */ export interface PluginGatewaySet { project: ProjectGateway; git: GitGateway; @@ -38,6 +43,11 @@ export interface PluginGatewaySet { system: SystemGateway; workState: WorkStateGateway; focusedProject: FocusedProjectGateway; + pluginWorkspace: PluginWorkspaceGateway; + pluginTask: PluginTaskGateway; + pluginToolchain: PluginToolchainGateway; + pluginEvents: PluginEventGateway; + pluginConfig: PluginConfigGateway; } /** A disposable handle returned by every `register*` call. */ @@ -97,7 +107,6 @@ export interface PluginLayoutProps { state: unknown; setState(next: unknown): void; availability: "available"; - gateways: PluginGatewaySet; } export interface PluginLayoutDefinition { diff --git a/frontend/src/plugins/runtime/services.test.ts b/frontend/src/plugins/runtime/services.test.ts index 8bc232f..690b1df 100644 --- a/frontend/src/plugins/runtime/services.test.ts +++ b/frontend/src/plugins/runtime/services.test.ts @@ -4,6 +4,11 @@ import type { ProjectWorkState } from "@/domain"; import type { BackgroundTaskAttachment, FocusedProjectGateway, + PluginConfigGateway, + PluginEventGateway, + PluginTaskGateway, + PluginToolchainGateway, + PluginWorkspaceGateway, ProjectGateway, ReattachResult, TerminalGateway, @@ -27,6 +32,11 @@ function gateways(overrides: { project?: Partial; workState?: Partial; terminal?: Partial; + pluginWorkspace?: Partial; + pluginTask?: Partial; + pluginToolchain?: Partial; + pluginEvents?: Partial; + pluginConfig?: Partial; } = {}) { const focusedProject: FocusedProjectGateway = { setFocusedProject: vi.fn(), @@ -79,8 +89,163 @@ function gateways(overrides: { closeTerminal: vi.fn(), ...overrides.terminal, }; + const pluginWorkspace: PluginWorkspaceGateway = { + readText: vi.fn(async ({ path }) => ({ path, content: "file text" })), + readBinary: vi.fn(async ({ path }) => ({ path, bytes: new Uint8Array([67]) })), + writeText: vi.fn(), + writeBinary: vi.fn(), + listDir: vi.fn(async ({ path }) => ({ + path, + entries: [{ name: "main.ts", path: `${path}/main.ts`, isDir: false }], + })), + stat: vi.fn(async ({ path }) => ({ + path, + exists: true, + isFile: true, + isDir: false, + len: 9, + })), + queryProjectStructure: vi.fn(async ({ projectId, path }) => ({ + projectId, + rootPath: path ?? "", + entries: [{ path: "package.json", name: "package.json", kind: "file" as const }], + conventions: [{ id: "node-package", markerPath: "package.json" }], + modules: [{ path: "", markerPath: "package.json", conventionId: "node-package" }], + truncated: false, + })), + ...overrides.pluginWorkspace, + }; + const pluginTask: PluginTaskGateway = { + runCommand: vi.fn(async (input) => ({ + taskId: "task-command-1", + ownerAgentId: input.ownerAgentId, + projectId: input.projectId, + kind: "command", + state: "running" as const, + exitCode: null, + summary: input.label, + stdoutTail: null, + stderrTail: null, + createdAtMs: 10, + updatedAtMs: 10, + })), + getStatus: vi.fn(async ({ taskId }) => ({ + taskId, + ownerAgentId: "agent-1", + projectId: "project-1", + kind: "command", + state: "completed" as const, + exitCode: 0, + summary: "ok", + stdoutTail: "done", + stderrTail: null, + createdAtMs: 10, + updatedAtMs: 20, + })), + ...overrides.pluginTask, + }; + const pluginToolchain: PluginToolchainGateway = { + diagnose: vi.fn(async ({ projectId, cwd }) => ({ + projectId, + cwd: cwd ?? "", + ok: true, + tools: [ + { + id: "node", + executable: "node", + present: true, + ok: true, + status: "ok" as const, + required: true, + exitCode: 0, + version: "v20.0.0", + stdout: "v20.0.0\n", + stderr: null, + error: null, + }, + ], + env: [ + { + name: "CI", + present: true, + ok: true, + required: false, + value: "1", + status: "ok" as const, + }, + ], + files: [ + { + path: "package.json", + exists: true, + ok: true, + required: true, + kind: "file" as const, + expectedKind: "file" as const, + len: 42, + }, + ], + messages: [], + })), + ...overrides.pluginToolchain, + }; + const pluginEvents: PluginEventGateway = { + subscribe: vi.fn(async ({ projectId, eventTypes, capacity }) => ({ + subscriptionId: "subscription-1", + projectId, + eventTypes: eventTypes?.length + ? eventTypes + : ["workspaceFileChanged", "backgroundTaskChanged"], + capacity: capacity ?? 100, + retention: "bestEffortBounded", + })), + poll: vi.fn(async ({ subscriptionId }) => ({ + subscriptionId, + events: [], + dropped: 0, + })), + unsubscribe: vi.fn(async ({ subscriptionId }) => ({ + subscriptionId, + projectId: "project-1", + eventTypes: [], + capacity: 0, + retention: "disposed", + })), + ...overrides.pluginEvents, + }; + const pluginConfig: PluginConfigGateway = { + readDocument: vi.fn(async ({ projectId, path, format }) => ({ + projectId, + path, + format: format ?? "json", + value: { enabled: true, nested: { count: 1 } }, + })), + updateDocument: vi.fn(async ({ projectId, path, format, mode }) => ({ + projectId, + path, + format: format ?? "json", + mode: mode ?? "mergePatch", + bytesWritten: 42, + })), + ...overrides.pluginConfig, + }; - return { focusedProject, project, workState, terminal }; + return { + focusedProject, + project, + workState, + terminal, + pluginWorkspace, + pluginTask, + pluginToolchain, + pluginEvents, + pluginConfig, + }; +} + +async function flushMicrotasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); } describe("createPluginServices", () => { @@ -155,6 +320,298 @@ describe("createPluginServices", () => { expect(detach).toHaveBeenCalled(); }); + it("launches command-backed tasks through the public plugin task gateway", async () => { + const g = gateways(); + const services = createPluginServices(g); + + await expect( + services.tasks.runCommand({ + ownerAgentId: "agent-1", + command: "npm", + args: ["test", "--", "workspace"], + cwd: "frontend", + env: { CI: "1" }, + recordOnly: true, + deadlineMs: 123_000, + }), + ).resolves.toMatchObject({ + taskId: "task-command-1", + ownerAgentId: "agent-1", + projectId: "project-1", + state: "running", + summary: "npm test -- workspace", + }); + + expect(g.pluginTask.runCommand).toHaveBeenCalledWith({ + projectId: "project-1", + ownerAgentId: "agent-1", + label: "npm test -- workspace", + command: "npm", + args: ["test", "--", "workspace"], + cwd: "frontend", + env: [["CI", "1"]], + recordOnly: true, + deadlineMs: 123_000, + }); + + await expect(services.tasks.getCommandStatus("task-command-1")).resolves.toMatchObject({ + taskId: "task-command-1", + state: "completed", + exitCode: 0, + stdoutTail: "done", + }); + expect(g.pluginTask.getStatus).toHaveBeenCalledWith({ taskId: "task-command-1" }); + }); + + it("delegates workspace file and structure operations through the public plugin gateway", async () => { + const g = gateways(); + const services = createPluginServices(g); + + await expect(services.workspace.resolvePath("src/main.ts")).resolves.toEqual({ + projectId: "project-1", + root: "/workspace/project-one", + path: "src/main.ts", + }); + await expect(services.workspace.readTextFile("README.md")).resolves.toEqual({ + path: "README.md", + content: "file text", + }); + await expect(services.workspace.readBinaryFile("asset.bin")).resolves.toEqual({ + path: "asset.bin", + bytes: new Uint8Array([67]), + }); + + await services.workspace.writeTextFile("generated.txt", "hello"); + expect(g.pluginWorkspace.writeText).toHaveBeenCalledWith({ + projectId: "project-1", + path: "generated.txt", + content: "hello", + }); + + await services.workspace.writeBinaryFile("generated.bin", new Uint8Array([1, 2])); + expect(g.pluginWorkspace.writeBinary).toHaveBeenCalledWith({ + projectId: "project-1", + path: "generated.bin", + bytes: new Uint8Array([1, 2]), + }); + + await expect(services.workspace.listDirectory("src")).resolves.toMatchObject({ + path: "src", + entries: [{ name: "main.ts", path: "src/main.ts", isDir: false }], + }); + await expect(services.workspace.stat("README.md")).resolves.toMatchObject({ + path: "README.md", + exists: true, + }); + await expect(services.workspace.queryStructure({ maxDepth: 2 })).resolves.toMatchObject({ + projectId: "project-1", + conventions: [{ id: "node-package", markerPath: "package.json" }], + }); + }); + + it("delegates generic toolchain diagnostics through the public plugin gateway", async () => { + const g = gateways(); + const services = createPluginServices(g); + + await expect( + services.tooling.diagnose({ + cwd: "frontend", + tools: [ + { + id: "node", + executable: "node", + versionArgs: ["--version"], + required: true, + env: { CI: "1" }, + }, + ], + env: [{ name: "CI" }], + files: [{ path: "package.json", required: true, kind: "file" }], + }), + ).resolves.toMatchObject({ + projectId: "project-1", + ok: true, + tools: [{ id: "node", version: "v20.0.0" }], + }); + + expect(g.pluginToolchain.diagnose).toHaveBeenCalledWith({ + projectId: "project-1", + cwd: "frontend", + tools: [ + { + id: "node", + executable: "node", + versionArgs: ["--version"], + required: true, + env: [["CI", "1"]], + }, + ], + env: [{ name: "CI" }], + files: [{ path: "package.json", required: true, kind: "file" }], + }); + }); + + it("subscribes to public plugin events and disposes the host subscription", async () => { + const handler = vi.fn(); + const onDropped = vi.fn(); + const g = gateways({ + pluginEvents: { + poll: vi.fn(async ({ subscriptionId }) => ({ + subscriptionId, + dropped: 2, + events: [ + { + type: "backgroundTaskChanged" as const, + sequence: 7, + occurredAtMs: 10, + projectId: "project-1", + taskId: "task-1", + ownerAgentId: "agent-1", + state: "completed", + }, + ], + })), + }, + }); + const services = createPluginServices(g); + + const subscription = await services.events.subscribe( + { + eventTypes: ["backgroundTaskChanged"], + capacity: 10, + maxEventsPerPoll: 5, + pollIntervalMs: 60_000, + onDropped, + }, + handler, + ); + await flushMicrotasks(); + + expect(subscription.subscriptionId).toBe("subscription-1"); + expect(g.pluginEvents.subscribe).toHaveBeenCalledWith({ + projectId: "project-1", + eventTypes: ["backgroundTaskChanged"], + capacity: 10, + }); + expect(g.pluginEvents.poll).toHaveBeenCalledWith({ + subscriptionId: "subscription-1", + maxEvents: 5, + }); + expect(onDropped).toHaveBeenCalledWith(2); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ type: "backgroundTaskChanged", taskId: "task-1" }), + ); + + subscription.dispose(); + expect(g.pluginEvents.unsubscribe).toHaveBeenCalledWith({ + subscriptionId: "subscription-1", + }); + }); + + it("implements workspace.watch through public workspace file events", async () => { + const handler = vi.fn(); + const g = gateways({ + pluginWorkspace: { + stat: vi.fn(async ({ path }) => ({ + path, + exists: true, + isFile: false, + isDir: true, + len: null, + })), + }, + pluginEvents: { + poll: vi.fn(async ({ subscriptionId }) => ({ + subscriptionId, + dropped: 0, + events: [ + { + type: "workspaceFileChanged" as const, + sequence: 1, + occurredAtMs: 11, + projectId: "project-1", + path: "src/main.ts", + operation: "writeText", + }, + { + type: "workspaceFileChanged" as const, + sequence: 2, + occurredAtMs: 12, + projectId: "project-1", + path: "README.md", + operation: "writeText", + }, + ], + })), + }, + }); + const services = createPluginServices(g); + + const watch = await services.workspace.watch("src", handler); + await flushMicrotasks(); + + expect(g.pluginEvents.subscribe).toHaveBeenCalledWith({ + projectId: "project-1", + eventTypes: ["workspaceFileChanged"], + capacity: undefined, + }); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith({ + path: "src/main.ts", + kind: "created", + operation: "writeText", + projectId: "project-1", + }); + + watch.dispose(); + expect(g.pluginEvents.unsubscribe).toHaveBeenCalledWith({ + subscriptionId: "subscription-1", + }); + }); + + it("delegates structured config document operations through the public plugin gateway", async () => { + const g = gateways(); + const services = createPluginServices(g); + + await expect( + services.config.readDocument({ + path: "config/settings.json", + }), + ).resolves.toMatchObject({ + projectId: "project-1", + path: "config/settings.json", + format: "json", + value: { enabled: true }, + }); + + await expect( + services.config.updateDocument({ + path: "config/settings.json", + mode: "mergePatch", + value: { enabled: false, removeMe: null }, + }), + ).resolves.toMatchObject({ + projectId: "project-1", + path: "config/settings.json", + format: "json", + mode: "mergePatch", + bytesWritten: 42, + }); + + expect(g.pluginConfig.readDocument).toHaveBeenCalledWith({ + projectId: "project-1", + path: "config/settings.json", + format: undefined, + }); + expect(g.pluginConfig.updateDocument).toHaveBeenCalledWith({ + projectId: "project-1", + path: "config/settings.json", + format: undefined, + mode: "mergePatch", + value: { enabled: false, removeMe: null }, + }); + }); + it("opens terminal sessions with project-root defaults and delegates controls", async () => { const g = gateways(); const services = createPluginServices(g); diff --git a/frontend/src/plugins/runtime/services.ts b/frontend/src/plugins/runtime/services.ts index 7626b2d..f5eb837 100644 --- a/frontend/src/plugins/runtime/services.ts +++ b/frontend/src/plugins/runtime/services.ts @@ -1,9 +1,21 @@ -import type { BackgroundCompletion } from "@/domain"; +import type { + BackgroundCompletion, + JsonValue, + PluginCommandTask, + PluginPublicEvent, + PluginPublicEventType, +} from "@/domain"; import type { BackgroundTaskAttachment, FocusedProject, FocusedProjectGateway, OpenTerminalOptions, + PluginConfigGateway, + PluginEventGateway, + PluginProjectStructureQuery, + PluginTaskGateway, + PluginToolchainGateway, + PluginWorkspaceGateway, ProjectGateway, TerminalGateway, WorkStateGateway, @@ -12,6 +24,9 @@ import type { export interface PluginServices { workspace: WorkspaceService; tasks: BackgroundTaskService; + tooling: ToolingService; + events: EventService; + config: ConfigDocumentService; terminal: TerminalService; } @@ -26,6 +41,98 @@ export interface WorkspaceService { getProjectRoot(projectId?: string): Promise; readProjectContext(projectId?: string): Promise; updateProjectContext(content: string, projectId?: string): Promise; + resolvePath(path: string, projectId?: string): Promise; + readTextFile(path: string, projectId?: string): Promise; + readBinaryFile(path: string, projectId?: string): Promise; + writeTextFile(path: string, content: string, projectId?: string): Promise; + writeBinaryFile(path: string, bytes: Uint8Array, projectId?: string): Promise; + listDirectory(path?: string, projectId?: string): Promise; + stat(path: string, projectId?: string): Promise; + watch(path: string, handler: WorkspaceWatchHandler, projectId?: string): Promise; + queryStructure(query?: WorkspaceStructureQuery): Promise; +} + +export interface WorkspaceResolvedPath { + projectId: string; + root: string; + path: string; +} + +export interface WorkspaceTextFile { + path: string; + content: string; +} + +export interface WorkspaceBinaryFile { + path: string; + bytes: Uint8Array; +} + +export interface WorkspaceDirEntry { + name: string; + path: string; + isDir: boolean; +} + +export interface WorkspaceDirectoryListing { + path: string; + entries: WorkspaceDirEntry[]; +} + +export interface WorkspaceStat { + path: string; + exists: boolean; + isFile: boolean; + isDir: boolean; + len: number | null; +} + +export interface WorkspaceWatchEvent { + path: string; + kind: "created" | "modified" | "deleted" | "renamed" | "unknown"; + operation: string; + projectId: string; +} + +export type WorkspaceWatchHandler = (event: WorkspaceWatchEvent) => void; + +export interface WorkspaceWatch { + dispose(): void; +} + +export interface WorkspaceStructureQuery { + projectId?: string; + path?: string; + maxDepth?: number; + maxEntries?: number; +} + +export type ProjectStructureEntryKind = "file" | "directory"; + +export interface ProjectStructureEntry { + path: string; + name: string; + kind: ProjectStructureEntryKind; +} + +export interface ProjectConvention { + id: string; + markerPath: string; +} + +export interface ProjectModule { + path: string; + markerPath: string; + conventionId: string; +} + +export interface ProjectStructure { + projectId: string; + rootPath: string; + entries: ProjectStructureEntry[]; + conventions: ProjectConvention[]; + modules: ProjectModule[]; + truncated: boolean; } export interface BackgroundTaskStatus { @@ -52,7 +159,177 @@ export interface BackgroundTaskRetryResult { taskId?: string; } +export interface RunCommandTaskOptions { + projectId?: string; + ownerAgentId: string; + label?: string; + command: string; + args?: string[]; + cwd?: string; + env?: Record | Array<[string, string]>; + recordOnly?: boolean; + deadlineMs?: number; +} + +export interface CommandTaskStatus { + taskId: string; + ownerAgentId: string; + projectId: string; + kind: string; + state: "queued" | "running" | "waiting" | "completed" | "failed" | "cancelled" | "expired"; + exitCode: number | null; + summary: string | null; + stdoutTail: string | null; + stderrTail: string | null; + createdAtMs: number; + updatedAtMs: number; +} + +export interface ToolRequirement { + id: string; + executable: string; + versionArgs?: string[]; + required?: boolean; + env?: Record | Array<[string, string]>; +} + +export interface EnvRequirement { + name: string; + required?: boolean; + equals?: string; +} + +export interface FileRequirement { + path: string; + required?: boolean; + kind?: "file" | "directory" | "any"; +} + +export interface ToolchainDiagnosticRequest { + projectId?: string; + cwd?: string; + tools?: ToolRequirement[]; + env?: EnvRequirement[]; + files?: FileRequirement[]; +} + +export interface ToolchainDiagnostic { + projectId: string; + cwd: string; + ok: boolean; + tools: ToolDiagnostic[]; + env: EnvDiagnostic[]; + files: FileDiagnostic[]; + messages: DiagnosticMessage[]; +} + +export interface ToolDiagnostic { + id: string; + executable: string; + present: boolean; + ok: boolean; + status: "ok" | "failed" | "missing"; + required: boolean; + exitCode: number | null; + version: string | null; + stdout: string | null; + stderr: string | null; + error: string | null; +} + +export interface EnvDiagnostic { + name: string; + present: boolean; + ok: boolean; + required: boolean; + value: string | null; + status: "ok" | "missing" | "mismatch"; +} + +export interface FileDiagnostic { + path: string; + exists: boolean; + ok: boolean; + required: boolean; + kind: "file" | "directory" | "other" | "missing"; + expectedKind: "file" | "directory" | "any" | null; + len: number | null; +} + +export interface DiagnosticMessage { + level: "info" | "warning" | "error"; + message: string; +} + +export interface ToolingService { + diagnose(request: ToolchainDiagnosticRequest): Promise; +} + +export type PublicEvent = PluginPublicEvent; +export type PublicEventType = PluginPublicEventType; + +export interface EventSubscribeOptions { + projectId?: string; + eventTypes?: PublicEventType[]; + capacity?: number; + pollIntervalMs?: number; + maxEventsPerPoll?: number; + onDropped?: (count: number) => void; +} + +export interface EventSubscription { + readonly subscriptionId: string; + readonly projectId: string; + readonly eventTypes: PublicEventType[]; + readonly retention: string; + dispose(): void; +} + +export type EventHandler = (event: PublicEvent) => void; + +export interface EventService { + subscribe(options: EventSubscribeOptions, handler: EventHandler): Promise; +} + +export type ConfigDocumentFormat = "json"; +export type ConfigUpdateMode = "mergePatch" | "replace"; + +export interface ConfigDocumentReadOptions { + projectId?: string; + path: string; + format?: ConfigDocumentFormat; +} + +export interface ConfigDocumentUpdateOptions extends ConfigDocumentReadOptions { + mode?: ConfigUpdateMode; + value: JsonValue; +} + +export interface ConfigDocument { + projectId: string; + path: string; + format: ConfigDocumentFormat; + value: T; +} + +export interface ConfigDocumentWriteResult { + projectId: string; + path: string; + format: ConfigDocumentFormat; + mode: ConfigUpdateMode; + bytesWritten: number; +} + +export interface ConfigDocumentService { + readDocument( + options: ConfigDocumentReadOptions, + ): Promise>; + updateDocument(options: ConfigDocumentUpdateOptions): Promise; +} + export interface BackgroundTaskService { + runCommand(options: RunCommandTaskOptions): Promise; + getCommandStatus(taskId: string): Promise; list(projectId?: string): Promise; getStatus(taskId: string, projectId?: string): Promise; attachOutput( @@ -98,10 +375,17 @@ interface PluginServiceGatewaySet { terminal: TerminalGateway; workState: WorkStateGateway; focusedProject: FocusedProjectGateway; + pluginWorkspace: PluginWorkspaceGateway; + pluginTask: PluginTaskGateway; + pluginToolchain: PluginToolchainGateway; + pluginEvents: PluginEventGateway; + pluginConfig: PluginConfigGateway; } const DEFAULT_ROWS = 24; const DEFAULT_COLS = 80; +const DEFAULT_EVENT_POLL_INTERVAL_MS = 1000; +const MIN_EVENT_POLL_INTERVAL_MS = 100; function noopDataHandler(): void { // Intentionally empty: plugin code may opt into output bytes per call. @@ -126,6 +410,58 @@ function toBackgroundTaskStatus(task: BackgroundCompletion): BackgroundTaskStatu }; } +function toCommandTaskStatus(task: PluginCommandTask): CommandTaskStatus { + return { + taskId: task.taskId, + ownerAgentId: task.ownerAgentId, + projectId: task.projectId, + kind: task.kind, + state: task.state, + exitCode: task.exitCode, + summary: task.summary, + stdoutTail: task.stdoutTail, + stderrTail: task.stderrTail, + createdAtMs: task.createdAtMs, + updatedAtMs: task.updatedAtMs, + }; +} + +function envEntries(env: RunCommandTaskOptions["env"]): Array<[string, string]> { + if (!env) return []; + return Array.isArray(env) ? env : Object.entries(env); +} + +function toolEnvEntries(env: ToolRequirement["env"]): Array<[string, string]> { + if (!env) return []; + return Array.isArray(env) ? env : Object.entries(env); +} + +function commandLabel(options: RunCommandTaskOptions): string { + if (options.label?.trim()) return options.label; + return [options.command, ...(options.args ?? [])].join(" "); +} + +function eventPollIntervalMs(options: EventSubscribeOptions): number { + return Math.max(options.pollIntervalMs ?? DEFAULT_EVENT_POLL_INTERVAL_MS, MIN_EVENT_POLL_INTERVAL_MS); +} + +function workspaceWatchKind(operation: string): WorkspaceWatchEvent["kind"] { + const normalized = operation.toLowerCase(); + if (normalized.includes("create") || normalized.includes("write")) return "created"; + if (normalized.includes("delete") || normalized.includes("remove")) return "deleted"; + if (normalized.includes("rename") || normalized.includes("move")) return "renamed"; + if (normalized.includes("modify") || normalized.includes("update")) return "modified"; + return "unknown"; +} + +function workspacePathMatches(watchedPath: string, eventPath: string): boolean { + return ( + watchedPath === "" || + eventPath === watchedPath || + eventPath.startsWith(`${watchedPath}/`) + ); +} + export function createPluginServices(gateways: PluginServiceGatewaySet): PluginServices { async function currentProject(): Promise { const focused = await gateways.focusedProject.getFocusedProject(); @@ -144,6 +480,55 @@ export function createPluginServices(gateways: PluginServiceGatewaySet): PluginS return focused; } + async function subscribeToEvents( + options: EventSubscribeOptions, + handler: EventHandler, + ): Promise { + const project = await requireProject(options.projectId); + const subscription = await gateways.pluginEvents.subscribe({ + projectId: project.id, + eventTypes: options.eventTypes ?? [], + capacity: options.capacity, + }); + let disposed = false; + let polling = false; + const poll = async () => { + if (disposed || polling) return; + polling = true; + try { + const batch = await gateways.pluginEvents.poll({ + subscriptionId: subscription.subscriptionId, + maxEvents: options.maxEventsPerPoll, + }); + if (disposed) return; + if (batch.dropped > 0) options.onDropped?.(batch.dropped); + for (const event of batch.events) { + handler(event); + } + } catch (error) { + if (!disposed) console.warn("[plugin-events] poll failed", error); + } finally { + polling = false; + } + }; + void poll(); + const timer = setInterval(() => void poll(), eventPollIntervalMs(options)); + return { + subscriptionId: subscription.subscriptionId, + projectId: subscription.projectId, + eventTypes: subscription.eventTypes, + retention: subscription.retention, + dispose() { + if (disposed) return; + disposed = true; + clearInterval(timer); + void gateways.pluginEvents.unsubscribe({ + subscriptionId: subscription.subscriptionId, + }); + }, + }; + } + const workspace: WorkspaceService = { getCurrentProject: currentProject, async getProjectRoot(projectId) { @@ -157,6 +542,63 @@ export function createPluginServices(gateways: PluginServiceGatewaySet): PluginS const project = await requireProject(projectId); await gateways.project.updateProjectContext(project.id, content); }, + async resolvePath(path, projectId) { + const project = await requireProject(projectId); + const stat = await gateways.pluginWorkspace.stat({ projectId: project.id, path }); + return { projectId: project.id, root: project.root, path: stat.path }; + }, + async readTextFile(path, projectId) { + const project = await requireProject(projectId); + return gateways.pluginWorkspace.readText({ projectId: project.id, path }); + }, + async readBinaryFile(path, projectId) { + const project = await requireProject(projectId); + return gateways.pluginWorkspace.readBinary({ projectId: project.id, path }); + }, + async writeTextFile(path, content, projectId) { + const project = await requireProject(projectId); + await gateways.pluginWorkspace.writeText({ projectId: project.id, path, content }); + }, + async writeBinaryFile(path, bytes, projectId) { + const project = await requireProject(projectId); + await gateways.pluginWorkspace.writeBinary({ projectId: project.id, path, bytes }); + }, + async listDirectory(path = ".", projectId) { + const project = await requireProject(projectId); + return gateways.pluginWorkspace.listDir({ projectId: project.id, path }); + }, + async stat(path, projectId) { + const project = await requireProject(projectId); + return gateways.pluginWorkspace.stat({ projectId: project.id, path }); + }, + async watch(path, handler, projectId) { + const project = await requireProject(projectId); + const stat = await gateways.pluginWorkspace.stat({ projectId: project.id, path }); + const subscription = await subscribeToEvents( + { projectId: project.id, eventTypes: ["workspaceFileChanged"] }, + (event) => { + if (event.type !== "workspaceFileChanged") return; + if (!workspacePathMatches(stat.path, event.path)) return; + handler({ + path: event.path, + kind: workspaceWatchKind(event.operation), + operation: event.operation, + projectId: event.projectId, + }); + }, + ); + return { dispose: () => subscription.dispose() }; + }, + async queryStructure(query = {}) { + const project = await requireProject(query.projectId); + const input: PluginProjectStructureQuery = { + projectId: project.id, + path: query.path, + maxDepth: query.maxDepth, + maxEntries: query.maxEntries, + }; + return gateways.pluginWorkspace.queryProjectStructure(input); + }, }; async function listTasks(projectId?: string): Promise { @@ -168,6 +610,28 @@ export function createPluginServices(gateways: PluginServiceGatewaySet): PluginS } const tasks: BackgroundTaskService = { + async runCommand(options) { + const project = await requireProject(options.projectId); + if (options.ownerAgentId.trim() === "") { + throw new Error("ownerAgentId is required to correlate a plugin command task"); + } + const task = await gateways.pluginTask.runCommand({ + projectId: project.id, + ownerAgentId: options.ownerAgentId, + label: commandLabel(options), + command: options.command, + args: options.args ?? [], + cwd: options.cwd, + env: envEntries(options.env), + recordOnly: options.recordOnly ?? false, + deadlineMs: options.deadlineMs, + }); + return toCommandTaskStatus(task); + }, + async getCommandStatus(taskId) { + const task = await gateways.pluginTask.getStatus({ taskId }); + return task ? toCommandTaskStatus(task) : null; + }, list: listTasks, async getStatus(taskId, projectId) { return (await listTasks(projectId)).find((task) => task.taskId === taskId) ?? null; @@ -191,6 +655,53 @@ export function createPluginServices(gateways: PluginServiceGatewaySet): PluginS }, }; + const tooling: ToolingService = { + async diagnose(request) { + const project = await requireProject(request.projectId); + return gateways.pluginToolchain.diagnose({ + projectId: project.id, + cwd: request.cwd, + tools: (request.tools ?? []).map((tool) => ({ + id: tool.id, + executable: tool.executable, + versionArgs: tool.versionArgs ?? [], + required: tool.required ?? false, + env: toolEnvEntries(tool.env), + })), + env: request.env ?? [], + files: request.files ?? [], + }); + }, + }; + + const events: EventService = { + subscribe: subscribeToEvents, + }; + + const config: ConfigDocumentService = { + async readDocument( + options: ConfigDocumentReadOptions, + ): Promise> { + const project = await requireProject(options.projectId); + const document = await gateways.pluginConfig.readDocument({ + projectId: project.id, + path: options.path, + format: options.format, + }); + return document as ConfigDocument; + }, + async updateDocument(options) { + const project = await requireProject(options.projectId); + return gateways.pluginConfig.updateDocument({ + projectId: project.id, + path: options.path, + format: options.format, + mode: options.mode, + value: options.value, + }); + }, + }; + const terminal: TerminalService = { async open(options = {}) { const cwd = options.cwd ?? (await workspace.getProjectRoot()); @@ -213,5 +724,5 @@ export function createPluginServices(gateways: PluginServiceGatewaySet): PluginS }, }; - return { workspace, tasks, terminal }; + return { workspace, tasks, tooling, events, config, terminal }; }