feat(sdk,plugins): typage et stabilisation runtime UI/layout publique (#128)

This commit is contained in:
2026-08-02 13:36:17 +02:00
parent e3e887d6a4
commit 8c4f1ea2e3
9 changed files with 1090 additions and 20 deletions

View File

@ -1,9 +1,21 @@
import type { BackgroundCompletion } from "@/domain";
import type {
BackgroundCompletion,
JsonValue,
PluginCommandTask,
PluginPublicEvent,
PluginPublicEventType,
} from "@/domain";
import type {
BackgroundTaskAttachment,
FocusedProject,
FocusedProjectGateway,
OpenTerminalOptions,
PluginConfigGateway,
PluginEventGateway,
PluginProjectStructureQuery,
PluginTaskGateway,
PluginToolchainGateway,
PluginWorkspaceGateway,
ProjectGateway,
TerminalGateway,
WorkStateGateway,
@ -12,6 +24,9 @@ import type {
export interface PluginServices {
workspace: WorkspaceService;
tasks: BackgroundTaskService;
tooling: ToolingService;
events: EventService;
config: ConfigDocumentService;
terminal: TerminalService;
}
@ -26,6 +41,98 @@ export interface WorkspaceService {
getProjectRoot(projectId?: string): Promise<string>;
readProjectContext(projectId?: string): Promise<string>;
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 {
@ -52,7 +159,177 @@ export interface BackgroundTaskRetryResult {
taskId?: string;
}
export interface RunCommandTaskOptions {
projectId?: string;
ownerAgentId: string;
label?: string;
command: string;
args?: string[];
cwd?: string;
env?: Record<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 {
runCommand(options: RunCommandTaskOptions): Promise<CommandTaskStatus>;
getCommandStatus(taskId: string): Promise<CommandTaskStatus | null>;
list(projectId?: string): Promise<BackgroundTaskStatus[]>;
getStatus(taskId: string, projectId?: string): Promise<BackgroundTaskStatus | null>;
attachOutput(
@ -98,10 +375,17 @@ interface PluginServiceGatewaySet {
terminal: TerminalGateway;
workState: WorkStateGateway;
focusedProject: FocusedProjectGateway;
pluginWorkspace: PluginWorkspaceGateway;
pluginTask: PluginTaskGateway;
pluginToolchain: PluginToolchainGateway;
pluginEvents: PluginEventGateway;
pluginConfig: PluginConfigGateway;
}
const DEFAULT_ROWS = 24;
const DEFAULT_COLS = 80;
const DEFAULT_EVENT_POLL_INTERVAL_MS = 1000;
const MIN_EVENT_POLL_INTERVAL_MS = 100;
function noopDataHandler(): void {
// Intentionally empty: plugin code may opt into output bytes per call.
@ -126,6 +410,58 @@ function toBackgroundTaskStatus(task: BackgroundCompletion): BackgroundTaskStatu
};
}
function toCommandTaskStatus(task: PluginCommandTask): CommandTaskStatus {
return {
taskId: task.taskId,
ownerAgentId: task.ownerAgentId,
projectId: task.projectId,
kind: task.kind,
state: task.state,
exitCode: task.exitCode,
summary: task.summary,
stdoutTail: task.stdoutTail,
stderrTail: task.stderrTail,
createdAtMs: task.createdAtMs,
updatedAtMs: task.updatedAtMs,
};
}
function envEntries(env: RunCommandTaskOptions["env"]): Array<[string, string]> {
if (!env) return [];
return Array.isArray(env) ? env : Object.entries(env);
}
function toolEnvEntries(env: ToolRequirement["env"]): Array<[string, string]> {
if (!env) return [];
return Array.isArray(env) ? env : Object.entries(env);
}
function commandLabel(options: RunCommandTaskOptions): string {
if (options.label?.trim()) return options.label;
return [options.command, ...(options.args ?? [])].join(" ");
}
function eventPollIntervalMs(options: EventSubscribeOptions): number {
return Math.max(options.pollIntervalMs ?? DEFAULT_EVENT_POLL_INTERVAL_MS, MIN_EVENT_POLL_INTERVAL_MS);
}
function workspaceWatchKind(operation: string): WorkspaceWatchEvent["kind"] {
const normalized = operation.toLowerCase();
if (normalized.includes("create") || normalized.includes("write")) return "created";
if (normalized.includes("delete") || normalized.includes("remove")) return "deleted";
if (normalized.includes("rename") || normalized.includes("move")) return "renamed";
if (normalized.includes("modify") || normalized.includes("update")) return "modified";
return "unknown";
}
function workspacePathMatches(watchedPath: string, eventPath: string): boolean {
return (
watchedPath === "" ||
eventPath === watchedPath ||
eventPath.startsWith(`${watchedPath}/`)
);
}
export function createPluginServices(gateways: PluginServiceGatewaySet): PluginServices {
async function currentProject(): Promise<WorkspaceProject | null> {
const focused = await gateways.focusedProject.getFocusedProject();
@ -144,6 +480,55 @@ export function createPluginServices(gateways: PluginServiceGatewaySet): PluginS
return focused;
}
async function subscribeToEvents(
options: EventSubscribeOptions,
handler: EventHandler,
): Promise<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 = {
getCurrentProject: currentProject,
async getProjectRoot(projectId) {
@ -157,6 +542,63 @@ export function createPluginServices(gateways: PluginServiceGatewaySet): PluginS
const project = await requireProject(projectId);
await gateways.project.updateProjectContext(project.id, content);
},
async resolvePath(path, projectId) {
const project = await requireProject(projectId);
const stat = await gateways.pluginWorkspace.stat({ projectId: project.id, path });
return { projectId: project.id, root: project.root, path: stat.path };
},
async readTextFile(path, projectId) {
const project = await requireProject(projectId);
return gateways.pluginWorkspace.readText({ projectId: project.id, path });
},
async readBinaryFile(path, projectId) {
const project = await requireProject(projectId);
return gateways.pluginWorkspace.readBinary({ projectId: project.id, path });
},
async writeTextFile(path, content, projectId) {
const project = await requireProject(projectId);
await gateways.pluginWorkspace.writeText({ projectId: project.id, path, content });
},
async writeBinaryFile(path, bytes, projectId) {
const project = await requireProject(projectId);
await gateways.pluginWorkspace.writeBinary({ projectId: project.id, path, bytes });
},
async listDirectory(path = ".", projectId) {
const project = await requireProject(projectId);
return gateways.pluginWorkspace.listDir({ projectId: project.id, path });
},
async stat(path, projectId) {
const project = await requireProject(projectId);
return gateways.pluginWorkspace.stat({ projectId: project.id, path });
},
async watch(path, handler, projectId) {
const project = await requireProject(projectId);
const stat = await gateways.pluginWorkspace.stat({ projectId: project.id, path });
const subscription = await subscribeToEvents(
{ projectId: project.id, eventTypes: ["workspaceFileChanged"] },
(event) => {
if (event.type !== "workspaceFileChanged") return;
if (!workspacePathMatches(stat.path, event.path)) return;
handler({
path: event.path,
kind: workspaceWatchKind(event.operation),
operation: event.operation,
projectId: event.projectId,
});
},
);
return { dispose: () => subscription.dispose() };
},
async queryStructure(query = {}) {
const project = await requireProject(query.projectId);
const input: PluginProjectStructureQuery = {
projectId: project.id,
path: query.path,
maxDepth: query.maxDepth,
maxEntries: query.maxEntries,
};
return gateways.pluginWorkspace.queryProjectStructure(input);
},
};
async function listTasks(projectId?: string): Promise<BackgroundTaskStatus[]> {
@ -168,6 +610,28 @@ export function createPluginServices(gateways: PluginServiceGatewaySet): PluginS
}
const tasks: BackgroundTaskService = {
async runCommand(options) {
const project = await requireProject(options.projectId);
if (options.ownerAgentId.trim() === "") {
throw new Error("ownerAgentId is required to correlate a plugin command task");
}
const task = await gateways.pluginTask.runCommand({
projectId: project.id,
ownerAgentId: options.ownerAgentId,
label: commandLabel(options),
command: options.command,
args: options.args ?? [],
cwd: options.cwd,
env: envEntries(options.env),
recordOnly: options.recordOnly ?? false,
deadlineMs: options.deadlineMs,
});
return toCommandTaskStatus(task);
},
async getCommandStatus(taskId) {
const task = await gateways.pluginTask.getStatus({ taskId });
return task ? toCommandTaskStatus(task) : null;
},
list: listTasks,
async getStatus(taskId, projectId) {
return (await listTasks(projectId)).find((task) => task.taskId === taskId) ?? null;
@ -191,6 +655,53 @@ export function createPluginServices(gateways: PluginServiceGatewaySet): PluginS
},
};
const tooling: ToolingService = {
async diagnose(request) {
const project = await requireProject(request.projectId);
return gateways.pluginToolchain.diagnose({
projectId: project.id,
cwd: request.cwd,
tools: (request.tools ?? []).map((tool) => ({
id: tool.id,
executable: tool.executable,
versionArgs: tool.versionArgs ?? [],
required: tool.required ?? false,
env: toolEnvEntries(tool.env),
})),
env: request.env ?? [],
files: request.files ?? [],
});
},
};
const events: EventService = {
subscribe: subscribeToEvents,
};
const config: ConfigDocumentService = {
async readDocument<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 = {
async open(options = {}) {
const cwd = options.cwd ?? (await workspace.getProjectRoot());
@ -213,5 +724,5 @@ export function createPluginServices(gateways: PluginServiceGatewaySet): PluginS
},
};
return { workspace, tasks, terminal };
return { workspace, tasks, tooling, events, config, terminal };
}