feat(sdk,plugins): ajoute capability tooling et services runtime publics (workspace/terminal/tasks)

- capability manifeste tooling supportée backend + transport runtime catalog
- ctx.services publique côté SDK/runtime, gated par tooling
- surface honnête: workspace, terminal, et tasks en observation/contrôle uniquement
- docs/exemple/tests mis à jour
- QA PASS sur feature/sdk-plugin-tooling-build-debug-surface
This commit is contained in:
2026-08-02 00:31:15 +02:00
parent efaeb31a38
commit 033e9a86d5
21 changed files with 748 additions and 10 deletions

View File

@ -18,4 +18,19 @@ export {
type PluginLoadFailure,
type PluginLoadResult,
} from "./loader";
export {
createPluginServices,
type BackgroundTaskOutputAttachment,
type BackgroundTaskRetryResult,
type BackgroundTaskService,
type BackgroundTaskStatus,
type PluginServices,
type TerminalOpenOptions,
type TerminalReattachOptions,
type TerminalReattachResult,
type TerminalService,
type TerminalSession,
type WorkspaceProject,
type WorkspaceService,
} from "./services";
export { evaluateWhen, type WhenContext, type WhenEvalResult, type WhenVariable } from "./when";

View File

@ -178,6 +178,68 @@ describe("loadPlugins", () => {
expect(registry.get("com.example.hello-plugin")).toBeUndefined();
});
it("does not inject the public plugin services facade without the tooling capability", async () => {
const bundle = dataUrl(`
export function activate(ctx) {
globalThis.__servicesWithoutTooling = ctx.services;
}
`);
const { failures } = await loadPlugins(
[entry({ id: "dev.acme.no-services", displayName: "No Services", bundleUrl: bundle })],
gateways,
);
expect(failures).toEqual([]);
expect((globalThis as Record<string, unknown>).__servicesWithoutTooling).toBeUndefined();
});
it("injects the public plugin services facade for plugins declaring tooling", async () => {
const bundle = dataUrl(`
export function activate(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.__terminalServiceKeys = Object.keys(ctx.services.terminal).sort();
}
`);
const { failures } = await loadPlugins(
[
entry({
id: "dev.acme.services",
displayName: "Services",
capabilities: ["tooling"],
bundleUrl: bundle,
}),
],
gateways,
);
expect(failures).toEqual([]);
expect((globalThis as Record<string, unknown>).__serviceKeys).toEqual([
"tasks",
"terminal",
"workspace",
]);
expect((globalThis as Record<string, unknown>).__workspaceServiceKeys).toEqual([
"getCurrentProject",
"getProjectRoot",
"readProjectContext",
"updateProjectContext",
]);
expect((globalThis as Record<string, unknown>).__taskServiceKeys).toEqual([
"attachOutput",
"cancel",
"getStatus",
"list",
"retry",
]);
expect((globalThis as Record<string, unknown>).__terminalServiceKeys).toEqual([
"close",
"open",
"reattach",
]);
});
it("loads the hello-plugin command and layout contribution shape", async () => {
const bundle = dataUrl(`
export function activate(ctx) {

View File

@ -25,6 +25,7 @@ import {
type LoadedPlugin,
type PluginGatewaySet,
} from "./registry";
import { createPluginServices, type PluginServices } from "./services";
export type { PluginGatewaySet } from "./registry";
@ -37,6 +38,7 @@ export interface IdeaPluginContext extends PluginGatewaySet {
commands: PluginCommandContext;
layouts: PluginLayoutRegistry;
menu: PluginMenuRegistry;
services?: PluginServices;
}
export interface PluginActivation {
@ -177,6 +179,10 @@ function normalizeContributes(entry: PluginRuntimePlugin): PluginContributionDto
};
}
function hasCapability(entry: PluginRuntimePlugin, capability: string): boolean {
return arrayOrEmpty<string>(objectOrEmpty(entry).capabilities).includes(capability);
}
async function disposeAll(disposables: Disposable[], activation?: void | PluginActivation): Promise<void> {
try {
await activation?.dispose?.();
@ -247,6 +253,9 @@ async function loadOne(
menu,
...gateways,
};
if (hasCapability(entry, "tooling")) {
ctx.services = createPluginServices(gateways);
}
activation = await withTimeout(
Promise.resolve(mod.activate(ctx)),

View File

@ -19,7 +19,15 @@ import type {
PluginMenuItemContribution,
PluginTopLevelMenuContribution,
} from "@/domain";
import type { AgentGateway, GitGateway, ProjectGateway, SystemGateway, TerminalGateway } from "@/ports";
import type {
AgentGateway,
FocusedProjectGateway,
GitGateway,
ProjectGateway,
SystemGateway,
TerminalGateway,
WorkStateGateway,
} from "@/ports";
/** The stable gateways a plugin's `activate(ctx)` is allowed to reach (carnet §6). */
export interface PluginGatewaySet {
@ -28,6 +36,8 @@ export interface PluginGatewaySet {
terminal: TerminalGateway;
agents: AgentGateway;
system: SystemGateway;
workState: WorkStateGateway;
focusedProject: FocusedProjectGateway;
}
/** A disposable handle returned by every `register*` call. */

View File

@ -0,0 +1,177 @@
import { describe, expect, it, vi } from "vitest";
import type { ProjectWorkState } from "@/domain";
import type {
BackgroundTaskAttachment,
FocusedProjectGateway,
ProjectGateway,
ReattachResult,
TerminalGateway,
TerminalHandle,
WorkStateGateway,
} from "@/ports";
import { createPluginServices } from "./services";
function terminalHandle(sessionId: string): TerminalHandle {
return {
sessionId,
write: vi.fn(),
resize: vi.fn(),
detach: vi.fn(),
close: vi.fn(),
};
}
function gateways(overrides: {
focusedProject?: Partial<FocusedProjectGateway>;
project?: Partial<ProjectGateway>;
workState?: Partial<WorkStateGateway>;
terminal?: Partial<TerminalGateway>;
} = {}) {
const focusedProject: FocusedProjectGateway = {
setFocusedProject: vi.fn(),
getFocusedProject: vi.fn(async () => ({
id: "project-1",
name: "Project One",
root: "/workspace/project-one",
})),
onFocusedProjectChanged: vi.fn(),
...overrides.focusedProject,
};
const project: ProjectGateway = {
listProjects: vi.fn(async () => [
{
id: "project-1",
name: "Project One",
root: "/workspace/project-one",
remote: { kind: "local" as const },
createdAt: 1,
},
]),
createProject: vi.fn(),
openProject: vi.fn(),
closeProject: vi.fn(),
readProjectContext: vi.fn(async () => "project context"),
updateProjectContext: vi.fn(),
...overrides.project,
};
const workState: WorkStateGateway = {
getProjectWorkState: vi.fn(async (): Promise<ProjectWorkState> => ({
agents: [],
conversations: [],
})),
attachBackgroundTask: vi.fn(async (taskId): Promise<BackgroundTaskAttachment> => ({
taskId,
scrollback: new Uint8Array([65]),
live: false,
detach: vi.fn(),
})),
cancelBackgroundTask: vi.fn(),
retryBackgroundTask: vi.fn(),
...overrides.workState,
};
const terminal: TerminalGateway = {
openTerminal: vi.fn(async () => terminalHandle("terminal-1")),
reattach: vi.fn(async (): Promise<ReattachResult> => ({
handle: terminalHandle("terminal-2"),
scrollback: new Uint8Array([66]),
})),
closeTerminal: vi.fn(),
...overrides.terminal,
};
return { focusedProject, project, workState, terminal };
}
describe("createPluginServices", () => {
it("exposes focused workspace project helpers without leaking project gateway DTOs", async () => {
const g = gateways();
const services = createPluginServices(g);
await expect(services.workspace.getCurrentProject()).resolves.toEqual({
id: "project-1",
name: "Project One",
root: "/workspace/project-one",
});
await expect(services.workspace.getProjectRoot()).resolves.toBe("/workspace/project-one");
await expect(services.workspace.readProjectContext()).resolves.toBe("project context");
await services.workspace.updateProjectContext("next context");
expect(g.project.updateProjectContext).toHaveBeenCalledWith("project-1", "next context");
});
it("maps background tasks through the work-state gateway and wraps output attachment", async () => {
const detach = vi.fn();
const onData = vi.fn();
const g = gateways({
workState: {
getProjectWorkState: vi.fn(async (): Promise<ProjectWorkState> => ({
conversations: [],
agents: [
{
agentId: "agent-1",
name: "Agent",
profileId: "profile-1",
busy: { state: "idle" as const },
tickets: [],
backgroundTasks: [
{
taskId: "task-1",
ownerAgentId: "agent-1",
projectId: "project-1",
kind: "command",
status: "running" as const,
exitCode: null,
summary: null,
stdoutTail: "ok",
stderrTail: null,
updatedAtMs: 42,
},
],
},
],
})),
attachBackgroundTask: vi.fn(async (taskId) => ({
taskId,
scrollback: new Uint8Array([1, 2, 3]),
live: true,
detach,
})),
},
});
const services = createPluginServices(g);
await expect(services.tasks.getStatus("task-1")).resolves.toMatchObject({
taskId: "task-1",
status: "running",
stdoutTail: "ok",
});
await expect(services.tasks.getStatus("missing")).resolves.toBeNull();
const attachment = await services.tasks.attachOutput("task-1", onData);
expect(attachment.scrollback).toEqual(new Uint8Array([1, 2, 3]));
expect(attachment.live).toBe(true);
attachment.detach();
expect(detach).toHaveBeenCalled();
});
it("opens terminal sessions with project-root defaults and delegates controls", async () => {
const g = gateways();
const services = createPluginServices(g);
const session = await services.terminal.open();
expect(g.terminal.openTerminal).toHaveBeenCalledWith(
{ cwd: "/workspace/project-one", rows: 24, cols: 80 },
expect.any(Function),
);
expect(session.sessionId).toBe("terminal-1");
const reattached = await services.terminal.reattach("terminal-1");
expect(g.terminal.reattach).toHaveBeenCalledWith("terminal-1", expect.any(Function));
expect(reattached.session.sessionId).toBe("terminal-2");
expect(reattached.scrollback).toEqual(new Uint8Array([66]));
await services.terminal.close("terminal-1");
expect(g.terminal.closeTerminal).toHaveBeenCalledWith("terminal-1");
});
});

View File

@ -0,0 +1,217 @@
import type { BackgroundCompletion } from "@/domain";
import type {
BackgroundTaskAttachment,
FocusedProject,
FocusedProjectGateway,
OpenTerminalOptions,
ProjectGateway,
TerminalGateway,
WorkStateGateway,
} from "@/ports";
export interface PluginServices {
workspace: WorkspaceService;
tasks: BackgroundTaskService;
terminal: TerminalService;
}
export interface WorkspaceProject {
id: string;
name: string;
root: string;
}
export interface WorkspaceService {
getCurrentProject(): Promise<WorkspaceProject | null>;
getProjectRoot(projectId?: string): Promise<string>;
readProjectContext(projectId?: string): Promise<string>;
updateProjectContext(content: string, projectId?: string): Promise<void>;
}
export interface BackgroundTaskStatus {
taskId: string;
ownerAgentId: string;
projectId: string;
kind: string;
status: "pending" | "running" | "completed" | "failed" | "cancelled" | "delivered";
exitCode: number | null;
summary: string | null;
stdoutTail: string | null;
stderrTail: string | null;
updatedAtMs: number;
}
export interface BackgroundTaskOutputAttachment {
taskId: string;
scrollback: Uint8Array;
live: boolean;
detach(): void;
}
export interface BackgroundTaskRetryResult {
taskId?: string;
}
export interface BackgroundTaskService {
list(projectId?: string): Promise<BackgroundTaskStatus[]>;
getStatus(taskId: string, projectId?: string): Promise<BackgroundTaskStatus | null>;
attachOutput(
taskId: string,
onData: (bytes: Uint8Array) => void,
): Promise<BackgroundTaskOutputAttachment>;
cancel(taskId: string): Promise<void>;
retry(taskId: string): Promise<BackgroundTaskRetryResult>;
}
export interface TerminalOpenOptions {
cwd?: string;
rows?: number;
cols?: number;
onData?: (bytes: Uint8Array) => void;
}
export interface TerminalReattachOptions {
onData?: (bytes: Uint8Array) => void;
}
export interface TerminalSession {
readonly sessionId: string;
write(data: Uint8Array): Promise<void>;
resize(rows: number, cols: number): Promise<void>;
detach(): void;
close(): Promise<void>;
}
export interface TerminalReattachResult {
session: TerminalSession;
scrollback: Uint8Array;
}
export interface TerminalService {
open(options?: TerminalOpenOptions): Promise<TerminalSession>;
reattach(sessionId: string, options?: TerminalReattachOptions): Promise<TerminalReattachResult>;
close(sessionId: string): Promise<void>;
}
interface PluginServiceGatewaySet {
project: ProjectGateway;
terminal: TerminalGateway;
workState: WorkStateGateway;
focusedProject: FocusedProjectGateway;
}
const DEFAULT_ROWS = 24;
const DEFAULT_COLS = 80;
function noopDataHandler(): void {
// Intentionally empty: plugin code may opt into output bytes per call.
}
function toWorkspaceProject(project: FocusedProject): WorkspaceProject {
return { id: project.id, name: project.name, root: project.root };
}
function toBackgroundTaskStatus(task: BackgroundCompletion): BackgroundTaskStatus {
return {
taskId: task.taskId,
ownerAgentId: task.ownerAgentId,
projectId: task.projectId,
kind: task.kind,
status: task.status,
exitCode: task.exitCode,
summary: task.summary,
stdoutTail: task.stdoutTail,
stderrTail: task.stderrTail,
updatedAtMs: task.updatedAtMs,
};
}
export function createPluginServices(gateways: PluginServiceGatewaySet): PluginServices {
async function currentProject(): Promise<WorkspaceProject | null> {
const focused = await gateways.focusedProject.getFocusedProject();
return focused ? toWorkspaceProject(focused) : null;
}
async function requireProject(projectId?: string): Promise<WorkspaceProject> {
if (projectId) {
const project = (await gateways.project.listProjects()).find((p) => p.id === projectId);
if (!project) throw new Error(`project not found: ${projectId}`);
return { id: project.id, name: project.name, root: project.root };
}
const focused = await currentProject();
if (!focused) throw new Error("no current project is focused");
return focused;
}
const workspace: WorkspaceService = {
getCurrentProject: currentProject,
async getProjectRoot(projectId) {
return (await requireProject(projectId)).root;
},
async readProjectContext(projectId) {
const project = await requireProject(projectId);
return gateways.project.readProjectContext(project.id);
},
async updateProjectContext(content, projectId) {
const project = await requireProject(projectId);
await gateways.project.updateProjectContext(project.id, content);
},
};
async function listTasks(projectId?: string): Promise<BackgroundTaskStatus[]> {
const project = await requireProject(projectId);
const state = await gateways.workState.getProjectWorkState(project.id);
return state.agents
.flatMap((agent) => agent.backgroundTasks ?? [])
.map(toBackgroundTaskStatus);
}
const tasks: BackgroundTaskService = {
list: listTasks,
async getStatus(taskId, projectId) {
return (await listTasks(projectId)).find((task) => task.taskId === taskId) ?? null;
},
async attachOutput(taskId, onData) {
const attachment: BackgroundTaskAttachment =
await gateways.workState.attachBackgroundTask(taskId, onData);
return {
taskId: attachment.taskId,
scrollback: attachment.scrollback,
live: attachment.live,
detach: () => attachment.detach(),
};
},
async cancel(taskId) {
await gateways.workState.cancelBackgroundTask(taskId);
},
async retry(taskId) {
await gateways.workState.retryBackgroundTask(taskId);
return {};
},
};
const terminal: TerminalService = {
async open(options = {}) {
const cwd = options.cwd ?? (await workspace.getProjectRoot());
const openOptions: OpenTerminalOptions = {
cwd,
rows: options.rows ?? DEFAULT_ROWS,
cols: options.cols ?? DEFAULT_COLS,
};
return gateways.terminal.openTerminal(openOptions, options.onData ?? noopDataHandler);
},
async reattach(sessionId, options = {}) {
const result = await gateways.terminal.reattach(
sessionId,
options.onData ?? noopDataHandler,
);
return { session: result.handle, scrollback: result.scrollback };
},
async close(sessionId) {
await gateways.terminal.closeTerminal(sessionId);
},
};
return { workspace, tasks, terminal };
}