feat(sdk,plugins): typage et stabilisation runtime UI/layout publique (#128)
This commit is contained in:
@ -30,7 +30,10 @@ function cell(overrides: Partial<CustomPluginLayoutCell> = {}): CustomPluginLayo
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let latestLayoutProps: PluginLayoutProps | null = null;
|
||||||
|
|
||||||
function MockLayoutComponent(props: PluginLayoutProps) {
|
function MockLayoutComponent(props: PluginLayoutProps) {
|
||||||
|
latestLayoutProps = props;
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<p data-testid="state">{JSON.stringify(props.state)}</p>
|
<p data-testid="state">{JSON.stringify(props.state)}</p>
|
||||||
@ -87,8 +90,11 @@ describe("PluginLayoutCellView", () => {
|
|||||||
it("renders the mock registered component when the provider is loaded", () => {
|
it("renders the mock registered component when the provider is loaded", () => {
|
||||||
const registry = new PluginRuntimeRegistry();
|
const registry = new PluginRuntimeRegistry();
|
||||||
registry.add(stubPlugin());
|
registry.add(stubPlugin());
|
||||||
|
latestLayoutProps = null;
|
||||||
renderCell(registry);
|
renderCell(registry);
|
||||||
expect(screen.getByTestId("state").textContent).toBe(JSON.stringify({ commits: 3 }));
|
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", () => {
|
it("round-trips state through setState → onStateChange", () => {
|
||||||
|
|||||||
@ -13,7 +13,6 @@
|
|||||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||||
|
|
||||||
import type { CustomPluginLayoutCell, PluginAdmin } from "@/domain";
|
import type { CustomPluginLayoutCell, PluginAdmin } from "@/domain";
|
||||||
import { useGateways } from "@/app/di";
|
|
||||||
import { usePluginRuntime } from "./PluginRuntimeProvider";
|
import { usePluginRuntime } from "./PluginRuntimeProvider";
|
||||||
import { PluginLayoutFallback } from "./PluginLayoutFallback";
|
import { PluginLayoutFallback } from "./PluginLayoutFallback";
|
||||||
import { resolvePluginLayoutAvailability } from "./layoutAvailability";
|
import { resolvePluginLayoutAvailability } from "./layoutAvailability";
|
||||||
@ -68,7 +67,6 @@ export function PluginLayoutCellView({
|
|||||||
onChooseAnotherLayout,
|
onChooseAnotherLayout,
|
||||||
}: PluginLayoutCellViewProps) {
|
}: PluginLayoutCellViewProps) {
|
||||||
const { registry } = usePluginRuntime();
|
const { registry } = usePluginRuntime();
|
||||||
const gateways = useGateways();
|
|
||||||
const availability = resolvePluginLayoutAvailability(registry, cell, installedPlugins);
|
const availability = resolvePluginLayoutAvailability(registry, cell, installedPlugins);
|
||||||
const providerDisplayName =
|
const providerDisplayName =
|
||||||
registry.get(cell.pluginId)?.displayName ??
|
registry.get(cell.pluginId)?.displayName ??
|
||||||
@ -108,15 +106,6 @@ export function PluginLayoutCellView({
|
|||||||
state={cell.state}
|
state={cell.state}
|
||||||
setState={onStateChange}
|
setState={onStateChange}
|
||||||
availability="available"
|
availability="available"
|
||||||
gateways={{
|
|
||||||
project: gateways.project,
|
|
||||||
git: gateways.git,
|
|
||||||
terminal: gateways.terminal,
|
|
||||||
agents: gateways.agent,
|
|
||||||
system: gateways.system,
|
|
||||||
workState: gateways.workState,
|
|
||||||
focusedProject: gateways.focusedProject,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</PluginLayoutErrorBoundary>
|
</PluginLayoutErrorBoundary>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -75,6 +75,11 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
|
|||||||
system: gateways.system,
|
system: gateways.system,
|
||||||
workState: gateways.workState,
|
workState: gateways.workState,
|
||||||
focusedProject: gateways.focusedProject,
|
focusedProject: gateways.focusedProject,
|
||||||
|
pluginWorkspace: gateways.pluginWorkspace,
|
||||||
|
pluginTask: gateways.pluginTask,
|
||||||
|
pluginToolchain: gateways.pluginToolchain,
|
||||||
|
pluginEvents: gateways.pluginEvents,
|
||||||
|
pluginConfig: gateways.pluginConfig,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
|
|||||||
@ -24,12 +24,52 @@ export {
|
|||||||
type BackgroundTaskRetryResult,
|
type BackgroundTaskRetryResult,
|
||||||
type BackgroundTaskService,
|
type BackgroundTaskService,
|
||||||
type BackgroundTaskStatus,
|
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 PluginServices,
|
||||||
|
type PublicEvent,
|
||||||
|
type PublicEventType,
|
||||||
|
type RunCommandTaskOptions,
|
||||||
type TerminalOpenOptions,
|
type TerminalOpenOptions,
|
||||||
type TerminalReattachOptions,
|
type TerminalReattachOptions,
|
||||||
type TerminalReattachResult,
|
type TerminalReattachResult,
|
||||||
type TerminalService,
|
type TerminalService,
|
||||||
type TerminalSession,
|
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 WorkspaceProject,
|
||||||
type WorkspaceService,
|
type WorkspaceService,
|
||||||
} from "./services";
|
} from "./services";
|
||||||
|
|||||||
@ -196,9 +196,27 @@ describe("loadPlugins", () => {
|
|||||||
it("injects the public plugin services facade for plugins declaring tooling", async () => {
|
it("injects the public plugin services facade for plugins declaring tooling", async () => {
|
||||||
const bundle = dataUrl(`
|
const bundle = dataUrl(`
|
||||||
export function activate(ctx) {
|
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.__serviceKeys = Object.keys(ctx.services).sort();
|
||||||
globalThis.__workspaceServiceKeys = Object.keys(ctx.services.workspace).sort();
|
globalThis.__workspaceServiceKeys = Object.keys(ctx.services.workspace).sort();
|
||||||
globalThis.__taskServiceKeys = Object.keys(ctx.services.tasks).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();
|
globalThis.__terminalServiceKeys = Object.keys(ctx.services.terminal).sort();
|
||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
@ -215,23 +233,59 @@ describe("loadPlugins", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(failures).toEqual([]);
|
expect(failures).toEqual([]);
|
||||||
|
expect((globalThis as Record<string, unknown>).__activationContextKeys).toEqual([
|
||||||
|
"commands",
|
||||||
|
"layouts",
|
||||||
|
"logger",
|
||||||
|
"menu",
|
||||||
|
"pluginDisplayName",
|
||||||
|
"pluginId",
|
||||||
|
"services",
|
||||||
|
"subscriptions",
|
||||||
|
"version",
|
||||||
|
]);
|
||||||
|
expect((globalThis as Record<string, unknown>).__hasPrivateGateway).toBe(false);
|
||||||
expect((globalThis as Record<string, unknown>).__serviceKeys).toEqual([
|
expect((globalThis as Record<string, unknown>).__serviceKeys).toEqual([
|
||||||
|
"config",
|
||||||
|
"events",
|
||||||
"tasks",
|
"tasks",
|
||||||
"terminal",
|
"terminal",
|
||||||
|
"tooling",
|
||||||
"workspace",
|
"workspace",
|
||||||
]);
|
]);
|
||||||
expect((globalThis as Record<string, unknown>).__workspaceServiceKeys).toEqual([
|
expect((globalThis as Record<string, unknown>).__workspaceServiceKeys).toEqual([
|
||||||
"getCurrentProject",
|
"getCurrentProject",
|
||||||
"getProjectRoot",
|
"getProjectRoot",
|
||||||
|
"listDirectory",
|
||||||
|
"queryStructure",
|
||||||
|
"readBinaryFile",
|
||||||
"readProjectContext",
|
"readProjectContext",
|
||||||
|
"readTextFile",
|
||||||
|
"resolvePath",
|
||||||
|
"stat",
|
||||||
"updateProjectContext",
|
"updateProjectContext",
|
||||||
|
"watch",
|
||||||
|
"writeBinaryFile",
|
||||||
|
"writeTextFile",
|
||||||
]);
|
]);
|
||||||
expect((globalThis as Record<string, unknown>).__taskServiceKeys).toEqual([
|
expect((globalThis as Record<string, unknown>).__taskServiceKeys).toEqual([
|
||||||
"attachOutput",
|
"attachOutput",
|
||||||
"cancel",
|
"cancel",
|
||||||
|
"getCommandStatus",
|
||||||
"getStatus",
|
"getStatus",
|
||||||
"list",
|
"list",
|
||||||
"retry",
|
"retry",
|
||||||
|
"runCommand",
|
||||||
|
]);
|
||||||
|
expect((globalThis as Record<string, unknown>).__toolingServiceKeys).toEqual([
|
||||||
|
"diagnose",
|
||||||
|
]);
|
||||||
|
expect((globalThis as Record<string, unknown>).__eventServiceKeys).toEqual([
|
||||||
|
"subscribe",
|
||||||
|
]);
|
||||||
|
expect((globalThis as Record<string, unknown>).__configServiceKeys).toEqual([
|
||||||
|
"readDocument",
|
||||||
|
"updateDocument",
|
||||||
]);
|
]);
|
||||||
expect((globalThis as Record<string, unknown>).__terminalServiceKeys).toEqual([
|
expect((globalThis as Record<string, unknown>).__terminalServiceKeys).toEqual([
|
||||||
"close",
|
"close",
|
||||||
|
|||||||
@ -3,8 +3,8 @@
|
|||||||
*
|
*
|
||||||
* At UI bootstrap, the app calls {@link loadPlugins} once with the catalog
|
* At UI bootstrap, the app calls {@link loadPlugins} once with the catalog
|
||||||
* from `PluginGateway.listRuntimeContributions()` (already filtered by the
|
* from `PluginGateway.listRuntimeContributions()` (already filtered by the
|
||||||
* backend to `enabled && !pendingUninstall`, carnet §1.3) and the stable
|
* backend to `enabled && !pendingUninstall`, carnet §1.3) and the gateway set
|
||||||
* gateways the plugin context exposes. For each entry it dynamically imports
|
* used to build the public service facade. For each entry it dynamically imports
|
||||||
* the bundle URL, validates the module shape, and calls `activate(ctx)`,
|
* the bundle URL, validates the module shape, and calls `activate(ctx)`,
|
||||||
* scoping the command/layout registries to exactly the ids declared in that
|
* scoping the command/layout registries to exactly the ids declared in that
|
||||||
* plugin's manifest (enforced by {@link PluginCommandRegistry}/
|
* plugin's manifest (enforced by {@link PluginCommandRegistry}/
|
||||||
@ -29,7 +29,7 @@ import { createPluginServices, type PluginServices } from "./services";
|
|||||||
|
|
||||||
export type { PluginGatewaySet } from "./registry";
|
export type { PluginGatewaySet } from "./registry";
|
||||||
|
|
||||||
export interface IdeaPluginContext extends PluginGatewaySet {
|
export interface IdeaPluginContext {
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
pluginDisplayName: string;
|
pluginDisplayName: string;
|
||||||
version: string;
|
version: string;
|
||||||
@ -251,7 +251,6 @@ async function loadOne(
|
|||||||
commands: createCommandContext(commands),
|
commands: createCommandContext(commands),
|
||||||
layouts,
|
layouts,
|
||||||
menu,
|
menu,
|
||||||
...gateways,
|
|
||||||
};
|
};
|
||||||
if (hasCapability(entry, "tooling")) {
|
if (hasCapability(entry, "tooling")) {
|
||||||
ctx.services = createPluginServices(gateways);
|
ctx.services = createPluginServices(gateways);
|
||||||
|
|||||||
@ -23,13 +23,18 @@ import type {
|
|||||||
AgentGateway,
|
AgentGateway,
|
||||||
FocusedProjectGateway,
|
FocusedProjectGateway,
|
||||||
GitGateway,
|
GitGateway,
|
||||||
|
PluginConfigGateway,
|
||||||
|
PluginEventGateway,
|
||||||
|
PluginTaskGateway,
|
||||||
|
PluginToolchainGateway,
|
||||||
|
PluginWorkspaceGateway,
|
||||||
ProjectGateway,
|
ProjectGateway,
|
||||||
SystemGateway,
|
SystemGateway,
|
||||||
TerminalGateway,
|
TerminalGateway,
|
||||||
WorkStateGateway,
|
WorkStateGateway,
|
||||||
} from "@/ports";
|
} 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 {
|
export interface PluginGatewaySet {
|
||||||
project: ProjectGateway;
|
project: ProjectGateway;
|
||||||
git: GitGateway;
|
git: GitGateway;
|
||||||
@ -38,6 +43,11 @@ export interface PluginGatewaySet {
|
|||||||
system: SystemGateway;
|
system: SystemGateway;
|
||||||
workState: WorkStateGateway;
|
workState: WorkStateGateway;
|
||||||
focusedProject: FocusedProjectGateway;
|
focusedProject: FocusedProjectGateway;
|
||||||
|
pluginWorkspace: PluginWorkspaceGateway;
|
||||||
|
pluginTask: PluginTaskGateway;
|
||||||
|
pluginToolchain: PluginToolchainGateway;
|
||||||
|
pluginEvents: PluginEventGateway;
|
||||||
|
pluginConfig: PluginConfigGateway;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A disposable handle returned by every `register*` call. */
|
/** A disposable handle returned by every `register*` call. */
|
||||||
@ -97,7 +107,6 @@ export interface PluginLayoutProps {
|
|||||||
state: unknown;
|
state: unknown;
|
||||||
setState(next: unknown): void;
|
setState(next: unknown): void;
|
||||||
availability: "available";
|
availability: "available";
|
||||||
gateways: PluginGatewaySet;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginLayoutDefinition {
|
export interface PluginLayoutDefinition {
|
||||||
|
|||||||
@ -4,6 +4,11 @@ import type { ProjectWorkState } from "@/domain";
|
|||||||
import type {
|
import type {
|
||||||
BackgroundTaskAttachment,
|
BackgroundTaskAttachment,
|
||||||
FocusedProjectGateway,
|
FocusedProjectGateway,
|
||||||
|
PluginConfigGateway,
|
||||||
|
PluginEventGateway,
|
||||||
|
PluginTaskGateway,
|
||||||
|
PluginToolchainGateway,
|
||||||
|
PluginWorkspaceGateway,
|
||||||
ProjectGateway,
|
ProjectGateway,
|
||||||
ReattachResult,
|
ReattachResult,
|
||||||
TerminalGateway,
|
TerminalGateway,
|
||||||
@ -27,6 +32,11 @@ function gateways(overrides: {
|
|||||||
project?: Partial<ProjectGateway>;
|
project?: Partial<ProjectGateway>;
|
||||||
workState?: Partial<WorkStateGateway>;
|
workState?: Partial<WorkStateGateway>;
|
||||||
terminal?: Partial<TerminalGateway>;
|
terminal?: Partial<TerminalGateway>;
|
||||||
|
pluginWorkspace?: Partial<PluginWorkspaceGateway>;
|
||||||
|
pluginTask?: Partial<PluginTaskGateway>;
|
||||||
|
pluginToolchain?: Partial<PluginToolchainGateway>;
|
||||||
|
pluginEvents?: Partial<PluginEventGateway>;
|
||||||
|
pluginConfig?: Partial<PluginConfigGateway>;
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const focusedProject: FocusedProjectGateway = {
|
const focusedProject: FocusedProjectGateway = {
|
||||||
setFocusedProject: vi.fn(),
|
setFocusedProject: vi.fn(),
|
||||||
@ -79,8 +89,163 @@ function gateways(overrides: {
|
|||||||
closeTerminal: vi.fn(),
|
closeTerminal: vi.fn(),
|
||||||
...overrides.terminal,
|
...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<void> {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("createPluginServices", () => {
|
describe("createPluginServices", () => {
|
||||||
@ -155,6 +320,298 @@ describe("createPluginServices", () => {
|
|||||||
expect(detach).toHaveBeenCalled();
|
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 () => {
|
it("opens terminal sessions with project-root defaults and delegates controls", async () => {
|
||||||
const g = gateways();
|
const g = gateways();
|
||||||
const services = createPluginServices(g);
|
const services = createPluginServices(g);
|
||||||
|
|||||||
@ -1,9 +1,21 @@
|
|||||||
import type { BackgroundCompletion } from "@/domain";
|
import type {
|
||||||
|
BackgroundCompletion,
|
||||||
|
JsonValue,
|
||||||
|
PluginCommandTask,
|
||||||
|
PluginPublicEvent,
|
||||||
|
PluginPublicEventType,
|
||||||
|
} from "@/domain";
|
||||||
import type {
|
import type {
|
||||||
BackgroundTaskAttachment,
|
BackgroundTaskAttachment,
|
||||||
FocusedProject,
|
FocusedProject,
|
||||||
FocusedProjectGateway,
|
FocusedProjectGateway,
|
||||||
OpenTerminalOptions,
|
OpenTerminalOptions,
|
||||||
|
PluginConfigGateway,
|
||||||
|
PluginEventGateway,
|
||||||
|
PluginProjectStructureQuery,
|
||||||
|
PluginTaskGateway,
|
||||||
|
PluginToolchainGateway,
|
||||||
|
PluginWorkspaceGateway,
|
||||||
ProjectGateway,
|
ProjectGateway,
|
||||||
TerminalGateway,
|
TerminalGateway,
|
||||||
WorkStateGateway,
|
WorkStateGateway,
|
||||||
@ -12,6 +24,9 @@ import type {
|
|||||||
export interface PluginServices {
|
export interface PluginServices {
|
||||||
workspace: WorkspaceService;
|
workspace: WorkspaceService;
|
||||||
tasks: BackgroundTaskService;
|
tasks: BackgroundTaskService;
|
||||||
|
tooling: ToolingService;
|
||||||
|
events: EventService;
|
||||||
|
config: ConfigDocumentService;
|
||||||
terminal: TerminalService;
|
terminal: TerminalService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -26,6 +41,98 @@ export interface WorkspaceService {
|
|||||||
getProjectRoot(projectId?: string): Promise<string>;
|
getProjectRoot(projectId?: string): Promise<string>;
|
||||||
readProjectContext(projectId?: string): Promise<string>;
|
readProjectContext(projectId?: string): Promise<string>;
|
||||||
updateProjectContext(content: string, projectId?: string): Promise<void>;
|
updateProjectContext(content: string, projectId?: string): Promise<void>;
|
||||||
|
resolvePath(path: string, projectId?: string): Promise<WorkspaceResolvedPath>;
|
||||||
|
readTextFile(path: string, projectId?: string): Promise<WorkspaceTextFile>;
|
||||||
|
readBinaryFile(path: string, projectId?: string): Promise<WorkspaceBinaryFile>;
|
||||||
|
writeTextFile(path: string, content: string, projectId?: string): Promise<void>;
|
||||||
|
writeBinaryFile(path: string, bytes: Uint8Array, projectId?: string): Promise<void>;
|
||||||
|
listDirectory(path?: string, projectId?: string): Promise<WorkspaceDirectoryListing>;
|
||||||
|
stat(path: string, projectId?: string): Promise<WorkspaceStat>;
|
||||||
|
watch(path: string, handler: WorkspaceWatchHandler, projectId?: string): Promise<WorkspaceWatch>;
|
||||||
|
queryStructure(query?: WorkspaceStructureQuery): Promise<ProjectStructure>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
export interface BackgroundTaskStatus {
|
||||||
@ -52,7 +159,177 @@ export interface BackgroundTaskRetryResult {
|
|||||||
taskId?: string;
|
taskId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RunCommandTaskOptions {
|
||||||
|
projectId?: string;
|
||||||
|
ownerAgentId: string;
|
||||||
|
label?: string;
|
||||||
|
command: string;
|
||||||
|
args?: string[];
|
||||||
|
cwd?: string;
|
||||||
|
env?: Record<string, string> | 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<string, string> | 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<ToolchainDiagnostic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<EventSubscription>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<T extends JsonValue = JsonValue> {
|
||||||
|
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<T extends JsonValue = JsonValue>(
|
||||||
|
options: ConfigDocumentReadOptions,
|
||||||
|
): Promise<ConfigDocument<T>>;
|
||||||
|
updateDocument(options: ConfigDocumentUpdateOptions): Promise<ConfigDocumentWriteResult>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BackgroundTaskService {
|
export interface BackgroundTaskService {
|
||||||
|
runCommand(options: RunCommandTaskOptions): Promise<CommandTaskStatus>;
|
||||||
|
getCommandStatus(taskId: string): Promise<CommandTaskStatus | null>;
|
||||||
list(projectId?: string): Promise<BackgroundTaskStatus[]>;
|
list(projectId?: string): Promise<BackgroundTaskStatus[]>;
|
||||||
getStatus(taskId: string, projectId?: string): Promise<BackgroundTaskStatus | null>;
|
getStatus(taskId: string, projectId?: string): Promise<BackgroundTaskStatus | null>;
|
||||||
attachOutput(
|
attachOutput(
|
||||||
@ -98,10 +375,17 @@ interface PluginServiceGatewaySet {
|
|||||||
terminal: TerminalGateway;
|
terminal: TerminalGateway;
|
||||||
workState: WorkStateGateway;
|
workState: WorkStateGateway;
|
||||||
focusedProject: FocusedProjectGateway;
|
focusedProject: FocusedProjectGateway;
|
||||||
|
pluginWorkspace: PluginWorkspaceGateway;
|
||||||
|
pluginTask: PluginTaskGateway;
|
||||||
|
pluginToolchain: PluginToolchainGateway;
|
||||||
|
pluginEvents: PluginEventGateway;
|
||||||
|
pluginConfig: PluginConfigGateway;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_ROWS = 24;
|
const DEFAULT_ROWS = 24;
|
||||||
const DEFAULT_COLS = 80;
|
const DEFAULT_COLS = 80;
|
||||||
|
const DEFAULT_EVENT_POLL_INTERVAL_MS = 1000;
|
||||||
|
const MIN_EVENT_POLL_INTERVAL_MS = 100;
|
||||||
|
|
||||||
function noopDataHandler(): void {
|
function noopDataHandler(): void {
|
||||||
// Intentionally empty: plugin code may opt into output bytes per call.
|
// 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 {
|
export function createPluginServices(gateways: PluginServiceGatewaySet): PluginServices {
|
||||||
async function currentProject(): Promise<WorkspaceProject | null> {
|
async function currentProject(): Promise<WorkspaceProject | null> {
|
||||||
const focused = await gateways.focusedProject.getFocusedProject();
|
const focused = await gateways.focusedProject.getFocusedProject();
|
||||||
@ -144,6 +480,55 @@ export function createPluginServices(gateways: PluginServiceGatewaySet): PluginS
|
|||||||
return focused;
|
return focused;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function subscribeToEvents(
|
||||||
|
options: EventSubscribeOptions,
|
||||||
|
handler: EventHandler,
|
||||||
|
): Promise<EventSubscription> {
|
||||||
|
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 = {
|
const workspace: WorkspaceService = {
|
||||||
getCurrentProject: currentProject,
|
getCurrentProject: currentProject,
|
||||||
async getProjectRoot(projectId) {
|
async getProjectRoot(projectId) {
|
||||||
@ -157,6 +542,63 @@ export function createPluginServices(gateways: PluginServiceGatewaySet): PluginS
|
|||||||
const project = await requireProject(projectId);
|
const project = await requireProject(projectId);
|
||||||
await gateways.project.updateProjectContext(project.id, content);
|
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<BackgroundTaskStatus[]> {
|
async function listTasks(projectId?: string): Promise<BackgroundTaskStatus[]> {
|
||||||
@ -168,6 +610,28 @@ export function createPluginServices(gateways: PluginServiceGatewaySet): PluginS
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tasks: BackgroundTaskService = {
|
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,
|
list: listTasks,
|
||||||
async getStatus(taskId, projectId) {
|
async getStatus(taskId, projectId) {
|
||||||
return (await listTasks(projectId)).find((task) => task.taskId === taskId) ?? null;
|
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<T extends JsonValue = JsonValue>(
|
||||||
|
options: ConfigDocumentReadOptions,
|
||||||
|
): Promise<ConfigDocument<T>> {
|
||||||
|
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<T>;
|
||||||
|
},
|
||||||
|
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 = {
|
const terminal: TerminalService = {
|
||||||
async open(options = {}) {
|
async open(options = {}) {
|
||||||
const cwd = options.cwd ?? (await workspace.getProjectRoot());
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user