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:
@ -2,7 +2,7 @@ use app_tauri_lib::dto::{
|
||||
PluginAdminDto, PluginContributionSummaryDto, PluginRuntimeContributionCatalogDto,
|
||||
PluginRuntimePluginDto,
|
||||
};
|
||||
use domain::{PluginContributionSet, PluginLifecycleState, PluginTrustLevel};
|
||||
use domain::{PluginCapability, PluginContributionSet, PluginLifecycleState, PluginTrustLevel};
|
||||
|
||||
#[test]
|
||||
fn plugin_admin_dto_serialises_exact_contract_shape() {
|
||||
@ -48,6 +48,7 @@ fn runtime_catalog_dto_carries_bundle_hash_and_contributions() {
|
||||
bundle_url: "idea-plugin://dev.acme.gitgraph/1.2.3/abc/dist/index.js".to_owned(),
|
||||
icon_url: None,
|
||||
content_hash: "abc".to_owned(),
|
||||
capabilities: vec![PluginCapability::Ui, PluginCapability::Tooling],
|
||||
contributes: PluginContributionSet::default(),
|
||||
}],
|
||||
};
|
||||
@ -58,6 +59,10 @@ fn runtime_catalog_dto_carries_bundle_hash_and_contributions() {
|
||||
"idea-plugin://dev.acme.gitgraph/1.2.3/abc/dist/index.js"
|
||||
);
|
||||
assert_eq!(value["plugins"][0]["contentHash"], "abc");
|
||||
assert_eq!(
|
||||
value["plugins"][0]["capabilities"],
|
||||
serde_json::json!(["ui", "tooling"])
|
||||
);
|
||||
assert!(value["plugins"][0]["contributes"]["menus"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
|
||||
@ -146,6 +146,8 @@ pub struct PluginRuntimePlugin {
|
||||
pub icon_url: Option<String>,
|
||||
/// Content hash.
|
||||
pub content_hash: String,
|
||||
/// Public manifest capabilities.
|
||||
pub capabilities: Vec<domain::PluginCapability>,
|
||||
/// Contributions.
|
||||
pub contributes: PluginContributionSet,
|
||||
}
|
||||
@ -824,6 +826,7 @@ async fn runtime_plugin_from_entry(
|
||||
bundle_url: bundle,
|
||||
icon_url,
|
||||
content_hash: descriptor.registry.content_hash.as_str().to_owned(),
|
||||
capabilities: descriptor.manifest.capabilities,
|
||||
contributes: descriptor.manifest.contributes,
|
||||
})
|
||||
}
|
||||
@ -1170,6 +1173,7 @@ impl PluginManifestValidator for JsonPluginManifestValidator {
|
||||
.map(|c| match c.as_str() {
|
||||
"ui" => Ok(domain::PluginCapability::Ui),
|
||||
"mcp" => Ok(domain::PluginCapability::Mcp),
|
||||
"tooling" => Ok(domain::PluginCapability::Tooling),
|
||||
_ => Err(PluginManifestError::Invalid(format!(
|
||||
"unknown capability: {c}"
|
||||
))),
|
||||
@ -1377,7 +1381,7 @@ mod tests {
|
||||
"engines": {"idea": ">=0.1.0 <1.0.0"},
|
||||
"main": "dist/index.js",
|
||||
"trustLevel": "full",
|
||||
"capabilities": ["ui", "mcp"],
|
||||
"capabilities": ["ui", "mcp", "tooling"],
|
||||
"contributes": {
|
||||
"menus": [{"id":"dev.acme.menu","label":"Graph","topLevel":true}],
|
||||
"menuItems": [{"id":"dev.acme.open","targetMenuId":"panels","label":"Open","command":"dev.acme.open"}],
|
||||
@ -1630,10 +1634,39 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(m.id.as_str(), "dev.acme.gitgraph");
|
||||
assert_eq!(
|
||||
m.capabilities,
|
||||
vec![
|
||||
domain::PluginCapability::Ui,
|
||||
domain::PluginCapability::Mcp,
|
||||
domain::PluginCapability::Tooling,
|
||||
]
|
||||
);
|
||||
assert_eq!(m.contributes.layouts.len(), 1);
|
||||
assert_eq!(m.contributes.mcp_servers.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_manifest_capability() {
|
||||
let mut value: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap();
|
||||
value["capabilities"] = serde_json::json!(["ui", "android"]);
|
||||
|
||||
let err = validator()
|
||||
.validate(
|
||||
&serde_json::to_vec(&value).unwrap(),
|
||||
&domain::PluginPackageRef {
|
||||
plugin_id: None,
|
||||
root: "x".into(),
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
err,
|
||||
PluginManifestError::Invalid("unknown capability: android".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsafe_main_path_and_non_full_trust() {
|
||||
let mut value: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap();
|
||||
@ -1695,6 +1728,28 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_catalog_carries_manifest_capabilities() {
|
||||
let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));
|
||||
let registry = Arc::new(FakeRegistry {
|
||||
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
|
||||
});
|
||||
let usecase =
|
||||
ListPluginRuntimeContributions::new(packages, registry, Arc::new(validator()));
|
||||
|
||||
let catalog = usecase.execute().await.unwrap();
|
||||
|
||||
assert_eq!(catalog.plugins.len(), 1);
|
||||
assert_eq!(
|
||||
catalog.plugins[0].capabilities,
|
||||
vec![
|
||||
domain::PluginCapability::Ui,
|
||||
domain::PluginCapability::Mcp,
|
||||
domain::PluginCapability::Tooling,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_catalog_marks_invalid_active_plugin_and_keeps_bootstrap_alive() {
|
||||
let packages = Arc::new(FakePackages::with_manifest(br#"{"broken":true}"#.to_vec()));
|
||||
|
||||
@ -239,6 +239,8 @@ pub struct PluginRuntimePluginDto {
|
||||
pub icon_url: Option<String>,
|
||||
/// Content hash.
|
||||
pub content_hash: String,
|
||||
/// Public manifest capabilities.
|
||||
pub capabilities: Vec<domain::PluginCapability>,
|
||||
/// Contributions.
|
||||
pub contributes: domain::PluginContributionSet,
|
||||
}
|
||||
@ -265,6 +267,7 @@ impl From<application::PluginRuntimePlugin> for PluginRuntimePluginDto {
|
||||
bundle_url: value.bundle_url,
|
||||
icon_url: value.icon_url,
|
||||
content_hash: value.content_hash,
|
||||
capabilities: value.capabilities,
|
||||
contributes: value.contributes,
|
||||
}
|
||||
}
|
||||
|
||||
@ -284,6 +284,8 @@ pub enum PluginCapability {
|
||||
Ui,
|
||||
/// External MCP server declarations.
|
||||
Mcp,
|
||||
/// Public tooling/build/debug services.
|
||||
Tooling,
|
||||
}
|
||||
|
||||
/// Plugin command id.
|
||||
@ -678,4 +680,17 @@ mod tests {
|
||||
assert!(!PluginLifecycleState::PendingUninstall.is_runtime_active());
|
||||
assert!(PluginLifecycleState::Enabled.is_runtime_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_capabilities_serialize_public_manifest_names() {
|
||||
assert_eq!(
|
||||
serde_json::to_value([
|
||||
PluginCapability::Ui,
|
||||
PluginCapability::Mcp,
|
||||
PluginCapability::Tooling
|
||||
])
|
||||
.unwrap(),
|
||||
serde_json::json!(["ui", "mcp", "tooling"])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1748,6 +1748,7 @@ export interface PluginRuntimePlugin {
|
||||
displayName: string;
|
||||
publisher?: string;
|
||||
version: string;
|
||||
capabilities?: string[];
|
||||
bundleUrl: string;
|
||||
iconUrl?: string;
|
||||
contentHash: string;
|
||||
|
||||
@ -114,6 +114,8 @@ export function PluginLayoutCellView({
|
||||
terminal: gateways.terminal,
|
||||
agents: gateways.agent,
|
||||
system: gateways.system,
|
||||
workState: gateways.workState,
|
||||
focusedProject: gateways.focusedProject,
|
||||
}}
|
||||
/>
|
||||
</PluginLayoutErrorBoundary>
|
||||
|
||||
@ -73,6 +73,8 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
|
||||
terminal: gateways.terminal,
|
||||
agents: gateways.agent,
|
||||
system: gateways.system,
|
||||
workState: gateways.workState,
|
||||
focusedProject: gateways.focusedProject,
|
||||
}),
|
||||
)
|
||||
.then((result) => {
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
@ -6,6 +6,7 @@ This first version intentionally stays small:
|
||||
|
||||
- public manifest types for `idea-plugin.json`;
|
||||
- public runtime types for plugin modules exposing `activate(ctx)`;
|
||||
- a stable `ctx.services` facade for workspace, background task and terminal operations;
|
||||
- a lightweight manifest validator;
|
||||
- a minimal `examples/hello-plugin` plugin.
|
||||
|
||||
@ -69,6 +70,42 @@ export function activate(ctx: ActivateContext): void {
|
||||
}
|
||||
```
|
||||
|
||||
## Runtime Services
|
||||
|
||||
Plugins declaring the `tooling` capability receive `ctx.services`. Plugins
|
||||
without that capability do not receive this facade. Prefer `ctx.services` over
|
||||
IdeA's internal runtime objects when it is available:
|
||||
|
||||
```ts
|
||||
import type { ActivateContext } from "@idea/plugin-sdk";
|
||||
|
||||
export async function activate(ctx: ActivateContext): Promise<void> {
|
||||
const project = await ctx.services?.workspace.getCurrentProject();
|
||||
ctx.logger.info("current project", project);
|
||||
|
||||
const task = await ctx.services?.tasks.getStatus("task-id");
|
||||
ctx.logger.info("task status", task?.status);
|
||||
|
||||
const terminal = await ctx.services?.terminal.open({ rows: 24, cols: 80 });
|
||||
await terminal?.write(new TextEncoder().encode("echo hello\\r"));
|
||||
}
|
||||
```
|
||||
|
||||
Current terminal scope is intentionally minimal: it opens or reattaches a shell
|
||||
PTY, writes bytes, resizes, detaches and closes. The background task service is
|
||||
observation/control only in this SDK version: `list`, `getStatus`, `attachOutput`,
|
||||
`cancel` and `retry` operate on existing tasks visible through IdeA's Work read
|
||||
model. Starting new background tasks is not part of the public plugin API in this
|
||||
lot.
|
||||
|
||||
Declare the additive `tooling` capability to receive `ctx.services` at runtime:
|
||||
|
||||
```json
|
||||
{
|
||||
"capabilities": ["ui", "tooling"]
|
||||
}
|
||||
```
|
||||
|
||||
## Manifest Validation
|
||||
|
||||
```ts
|
||||
|
||||
@ -8,6 +8,7 @@ It exercises the current plugin primitives end to end:
|
||||
- menu entry: `hello-plugin`;
|
||||
- command: `hello-plugin`, returning `hello-world`;
|
||||
- layout contribution: `hello-plugin.hello-world`, rendered as `hello-world`.
|
||||
- tooling capability: logs the focused workspace project when `ctx.services` is available.
|
||||
|
||||
```sh
|
||||
npm run typecheck:examples
|
||||
@ -25,6 +26,7 @@ During activation the plugin logs:
|
||||
- whether the command and layout runtime registries are available;
|
||||
- successful registration of the `hello-plugin` command;
|
||||
- successful registration of the `hello-plugin.hello-world` layout;
|
||||
- availability of the workspace service from the `tooling` runtime capability;
|
||||
- the first layout render, including project/node identifiers.
|
||||
|
||||
These messages are intentionally small and stable so installation, bundle import, activation and
|
||||
|
||||
@ -11,7 +11,8 @@
|
||||
},
|
||||
"trustLevel": "full",
|
||||
"capabilities": [
|
||||
"ui"
|
||||
"ui",
|
||||
"tooling"
|
||||
],
|
||||
"contributes": {
|
||||
"menus": [
|
||||
|
||||
@ -71,6 +71,13 @@ export function activate(ctx: ActivateContext): void {
|
||||
} else {
|
||||
ctx.logger.warn("layout registry unavailable", { layoutType: LAYOUT_TYPE });
|
||||
}
|
||||
|
||||
void ctx.services?.workspace.getCurrentProject().then((project) => {
|
||||
ctx.logger.info("workspace service available", {
|
||||
projectId: project?.id ?? null,
|
||||
hasProjectRoot: Boolean(project?.root)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const plugin: IdeAPluginModule = {
|
||||
|
||||
@ -17,7 +17,19 @@ export type {
|
||||
CommandDisposable,
|
||||
CommandHandler,
|
||||
CommandRegistry,
|
||||
BackgroundTaskOutputAttachment,
|
||||
BackgroundTaskRetryResult,
|
||||
BackgroundTaskService,
|
||||
BackgroundTaskStatus,
|
||||
IdeAPluginModule,
|
||||
PluginLogger,
|
||||
PluginStorage
|
||||
PluginServices,
|
||||
PluginStorage,
|
||||
TerminalOpenOptions,
|
||||
TerminalReattachOptions,
|
||||
TerminalReattachResult,
|
||||
TerminalService,
|
||||
TerminalSession,
|
||||
WorkspaceProject,
|
||||
WorkspaceService
|
||||
} from "./runtime.js";
|
||||
|
||||
@ -59,8 +59,8 @@ function validateCapabilities(value, errors) {
|
||||
return;
|
||||
}
|
||||
value.forEach((capability, index) => {
|
||||
if (capability !== "ui" && capability !== "mcp") {
|
||||
errors.push(`capabilities[${index}] must be "ui" or "mcp"`);
|
||||
if (capability !== "ui" && capability !== "mcp" && capability !== "tooling") {
|
||||
errors.push(`capabilities[${index}] must be "ui", "mcp" or "tooling"`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -17,7 +17,7 @@ export interface IdeAPluginManifest {
|
||||
};
|
||||
}
|
||||
|
||||
export type IdeAPluginCapability = "ui" | "mcp";
|
||||
export type IdeAPluginCapability = "ui" | "mcp" | "tooling";
|
||||
|
||||
export interface IdeAPluginEngineConstraints {
|
||||
idea?: string;
|
||||
@ -145,8 +145,8 @@ function validateCapabilities(value: unknown, errors: string[]): void {
|
||||
}
|
||||
|
||||
value.forEach((capability, index) => {
|
||||
if (capability !== "ui" && capability !== "mcp") {
|
||||
errors.push(`capabilities[${index}] must be "ui" or "mcp"`);
|
||||
if (capability !== "ui" && capability !== "mcp" && capability !== "tooling") {
|
||||
errors.push(`capabilities[${index}] must be "ui", "mcp" or "tooling"`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -4,6 +4,12 @@ export interface ActivateContext {
|
||||
subscriptions: CommandDisposable[];
|
||||
commands?: CommandRegistry;
|
||||
storage?: PluginStorage;
|
||||
/**
|
||||
* Stable public service facade for plugins that need workspace, background
|
||||
* task, or terminal operations. This intentionally does not expose IdeA's
|
||||
* internal runtime/gateway objects.
|
||||
*/
|
||||
services?: PluginServices;
|
||||
}
|
||||
|
||||
export interface IdeAPluginModule {
|
||||
@ -34,3 +40,103 @@ export interface PluginStorage {
|
||||
delete(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PluginServices {
|
||||
workspace: WorkspaceService;
|
||||
tasks: BackgroundTaskService;
|
||||
terminal: TerminalService;
|
||||
}
|
||||
|
||||
export interface WorkspaceProject {
|
||||
id: string;
|
||||
name: string;
|
||||
root: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceService {
|
||||
/** Returns the currently focused project, or null when no project is active. */
|
||||
getCurrentProject(): Promise<WorkspaceProject | null>;
|
||||
/** Returns the root path for the given project or for the current project. */
|
||||
getProjectRoot(projectId?: string): Promise<string>;
|
||||
/** Reads IdeA's shared project context for the given or current project. */
|
||||
readProjectContext(projectId?: string): Promise<string>;
|
||||
/** Updates IdeA's shared project context for the given or current project. */
|
||||
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 {
|
||||
/** Present when the host reports the replacement task id. */
|
||||
taskId?: string;
|
||||
}
|
||||
|
||||
export interface BackgroundTaskService {
|
||||
/** Lists background tasks visible in the project work-state read model. */
|
||||
list(projectId?: string): Promise<BackgroundTaskStatus[]>;
|
||||
/** Reads one task status from the project work-state read model. */
|
||||
getStatus(taskId: string, projectId?: string): Promise<BackgroundTaskStatus | null>;
|
||||
/** Attaches to retained/live output for a task. */
|
||||
attachOutput(
|
||||
taskId: string,
|
||||
onData: (bytes: Uint8Array) => void,
|
||||
): Promise<BackgroundTaskOutputAttachment>;
|
||||
/** Cancels a pending/running task. */
|
||||
cancel(taskId: string): Promise<void>;
|
||||
/** Retries a failed/cancelled task; future hosts may return the new task id. */
|
||||
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 {
|
||||
/**
|
||||
* Opens a shell PTY in the requested/current project directory. This MVP is a
|
||||
* terminal control surface, not a command runner; use tasks for build/test
|
||||
* commands that should be tracked in the Work panel.
|
||||
*/
|
||||
open(options?: TerminalOpenOptions): Promise<TerminalSession>;
|
||||
/** Reattaches to an already-running PTY and returns retained scrollback. */
|
||||
reattach(sessionId: string, options?: TerminalReattachOptions): Promise<TerminalReattachResult>;
|
||||
/** Kills a PTY by id. */
|
||||
close(sessionId: string): Promise<void>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user