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:
@ -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";
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)),
|
||||
|
||||
@ -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. */
|
||||
|
||||
177
frontend/src/plugins/runtime/services.test.ts
Normal file
177
frontend/src/plugins/runtime/services.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
217
frontend/src/plugins/runtime/services.ts
Normal file
217
frontend/src/plugins/runtime/services.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user