feat(sdk,plugins): typage et stabilisation runtime UI/layout publique (#128)
This commit is contained in:
@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user