feat(sdk,plugins): API publique d'accès fichiers/workspace + analyse structure (#124,#129)
This commit is contained in:
@ -45,7 +45,12 @@ import {
|
||||
import {
|
||||
WebDesktopServerGateway,
|
||||
WebFocusedProjectGateway,
|
||||
WebPluginConfigGateway,
|
||||
WebPluginEventGateway,
|
||||
WebPluginGateway,
|
||||
WebPluginTaskGateway,
|
||||
WebPluginToolchainGateway,
|
||||
WebPluginWorkspaceGateway,
|
||||
WebRemoteGateway,
|
||||
WebWindowGateway,
|
||||
} from "./unsupported";
|
||||
@ -141,6 +146,11 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway
|
||||
// Frontend-owned UI prefs are transport-neutral (localStorage) — reuse as-is.
|
||||
uiPreferences: new LocalStorageUiPreferencesGateway(),
|
||||
plugin: new WebPluginGateway(),
|
||||
pluginWorkspace: new WebPluginWorkspaceGateway(),
|
||||
pluginTask: new WebPluginTaskGateway(),
|
||||
pluginToolchain: new WebPluginToolchainGateway(),
|
||||
pluginEvents: new WebPluginEventGateway(),
|
||||
pluginConfig: new WebPluginConfigGateway(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -13,10 +13,21 @@ import type {
|
||||
EmbeddedServerStatus,
|
||||
GatewayError,
|
||||
PluginAdmin,
|
||||
PluginCommandTask,
|
||||
PluginConfigDocument,
|
||||
PluginConfigDocumentWriteResult,
|
||||
PluginEventBatch,
|
||||
PluginEventSubscription,
|
||||
PluginInstallResult,
|
||||
PluginToolchainDiagnostic,
|
||||
PluginProjectStructure,
|
||||
PluginReview,
|
||||
PluginRuntimeContributionCatalog,
|
||||
PluginUninstallResult,
|
||||
PluginWorkspaceBinaryFile,
|
||||
PluginWorkspaceDirectoryListing,
|
||||
PluginWorkspaceStat,
|
||||
PluginWorkspaceTextFile,
|
||||
ServerExposurePreview,
|
||||
ServerExposureSettings,
|
||||
Unsubscribe,
|
||||
@ -25,7 +36,24 @@ import type {
|
||||
DesktopServerGateway,
|
||||
FocusedProject,
|
||||
FocusedProjectGateway,
|
||||
PluginConfigDocumentReadInput,
|
||||
PluginConfigDocumentUpdateInput,
|
||||
PluginConfigGateway,
|
||||
PluginEventGateway,
|
||||
PluginEventPollInput,
|
||||
PluginEventSubscribeInput,
|
||||
PluginEventUnsubscribeInput,
|
||||
PluginGateway,
|
||||
PluginProjectStructureQuery,
|
||||
PluginRunCommandInput,
|
||||
PluginTaskGateway,
|
||||
PluginTaskStatusInput,
|
||||
PluginToolchainDiagnosticRequest,
|
||||
PluginToolchainGateway,
|
||||
PluginWorkspaceGateway,
|
||||
PluginWorkspacePathInput,
|
||||
PluginWorkspaceWriteBinaryInput,
|
||||
PluginWorkspaceWriteTextInput,
|
||||
RemoteGateway,
|
||||
ReviewPluginPackageInput,
|
||||
ViewWindowClosed,
|
||||
@ -160,3 +188,78 @@ export class WebPluginGateway implements PluginGateway {
|
||||
return unsupportedOnWeb("Plugin management");
|
||||
}
|
||||
}
|
||||
|
||||
/** Web stub: public plugin workspace services are only meaningful where plugins run. */
|
||||
export class WebPluginWorkspaceGateway implements PluginWorkspaceGateway {
|
||||
async readText(_input: PluginWorkspacePathInput): Promise<PluginWorkspaceTextFile> {
|
||||
return unsupportedOnWeb("Plugin workspace access");
|
||||
}
|
||||
async readBinary(_input: PluginWorkspacePathInput): Promise<PluginWorkspaceBinaryFile> {
|
||||
return unsupportedOnWeb("Plugin workspace access");
|
||||
}
|
||||
async writeText(_input: PluginWorkspaceWriteTextInput): Promise<void> {
|
||||
return unsupportedOnWeb("Plugin workspace access");
|
||||
}
|
||||
async writeBinary(_input: PluginWorkspaceWriteBinaryInput): Promise<void> {
|
||||
return unsupportedOnWeb("Plugin workspace access");
|
||||
}
|
||||
async listDir(_input: PluginWorkspacePathInput): Promise<PluginWorkspaceDirectoryListing> {
|
||||
return unsupportedOnWeb("Plugin workspace access");
|
||||
}
|
||||
async stat(_input: PluginWorkspacePathInput): Promise<PluginWorkspaceStat> {
|
||||
return unsupportedOnWeb("Plugin workspace access");
|
||||
}
|
||||
async queryProjectStructure(
|
||||
_input: PluginProjectStructureQuery,
|
||||
): Promise<PluginProjectStructure> {
|
||||
return unsupportedOnWeb("Plugin workspace access");
|
||||
}
|
||||
}
|
||||
|
||||
/** Web stub: plugin command tasks are desktop-hosted in this runtime. */
|
||||
export class WebPluginTaskGateway implements PluginTaskGateway {
|
||||
async runCommand(_input: PluginRunCommandInput): Promise<PluginCommandTask> {
|
||||
return unsupportedOnWeb("Plugin command tasks");
|
||||
}
|
||||
|
||||
async getStatus(_input: PluginTaskStatusInput): Promise<PluginCommandTask | null> {
|
||||
return unsupportedOnWeb("Plugin command tasks");
|
||||
}
|
||||
}
|
||||
|
||||
/** Web stub: plugin toolchain diagnostics run on the desktop host. */
|
||||
export class WebPluginToolchainGateway implements PluginToolchainGateway {
|
||||
async diagnose(
|
||||
_input: PluginToolchainDiagnosticRequest,
|
||||
): Promise<PluginToolchainDiagnostic> {
|
||||
return unsupportedOnWeb("Plugin toolchain diagnostics");
|
||||
}
|
||||
}
|
||||
|
||||
/** Web stub: plugin public events are sourced from the desktop host. */
|
||||
export class WebPluginEventGateway implements PluginEventGateway {
|
||||
async subscribe(_input: PluginEventSubscribeInput): Promise<PluginEventSubscription> {
|
||||
return unsupportedOnWeb("Plugin public events");
|
||||
}
|
||||
|
||||
async poll(_input: PluginEventPollInput): Promise<PluginEventBatch> {
|
||||
return unsupportedOnWeb("Plugin public events");
|
||||
}
|
||||
|
||||
async unsubscribe(_input: PluginEventUnsubscribeInput): Promise<PluginEventSubscription> {
|
||||
return unsupportedOnWeb("Plugin public events");
|
||||
}
|
||||
}
|
||||
|
||||
/** Web stub: plugin config documents are read/written by the desktop host. */
|
||||
export class WebPluginConfigGateway implements PluginConfigGateway {
|
||||
async readDocument(_input: PluginConfigDocumentReadInput): Promise<PluginConfigDocument> {
|
||||
return unsupportedOnWeb("Plugin structured config documents");
|
||||
}
|
||||
|
||||
async updateDocument(
|
||||
_input: PluginConfigDocumentUpdateInput,
|
||||
): Promise<PluginConfigDocumentWriteResult> {
|
||||
return unsupportedOnWeb("Plugin structured config documents");
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,6 +35,11 @@ import { TauriWindowGateway } from "./window";
|
||||
import { TauriFocusedProjectGateway } from "./focusedProject";
|
||||
import { LocalStorageUiPreferencesGateway } from "./uiPreferences";
|
||||
import { TauriPluginGateway } from "./plugin";
|
||||
import { TauriPluginWorkspaceGateway } from "./pluginWorkspace";
|
||||
import { TauriPluginTaskGateway } from "./pluginTask";
|
||||
import { TauriPluginToolchainGateway } from "./pluginToolchain";
|
||||
import { TauriPluginEventGateway } from "./pluginEvents";
|
||||
import { TauriPluginConfigGateway } from "./pluginConfig";
|
||||
|
||||
function notImplemented(what: string): never {
|
||||
const err: GatewayError = {
|
||||
@ -77,6 +82,11 @@ export function createTauriGateways(): Gateways {
|
||||
focusedProject: new TauriFocusedProjectGateway(),
|
||||
uiPreferences: new LocalStorageUiPreferencesGateway(),
|
||||
plugin: new TauriPluginGateway(),
|
||||
pluginWorkspace: new TauriPluginWorkspaceGateway(),
|
||||
pluginTask: new TauriPluginTaskGateway(),
|
||||
pluginToolchain: new TauriPluginToolchainGateway(),
|
||||
pluginEvents: new TauriPluginEventGateway(),
|
||||
pluginConfig: new TauriPluginConfigGateway(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -42,13 +42,30 @@ import type {
|
||||
PairingCode,
|
||||
PermissionSet,
|
||||
PluginAdmin,
|
||||
PluginCommandTask,
|
||||
PluginConfigDocument,
|
||||
PluginConfigDocumentWriteResult,
|
||||
PluginContributionSummary,
|
||||
PluginEventBatch,
|
||||
PluginEventSubscription,
|
||||
PluginInstallResult,
|
||||
PluginLifecycleState,
|
||||
PluginProjectConvention,
|
||||
PluginProjectModule,
|
||||
PluginProjectStructure,
|
||||
PluginProjectStructureEntry,
|
||||
PluginReview,
|
||||
PluginRuntimeContributionCatalog,
|
||||
PluginToolchainDiagnostic,
|
||||
PluginUninstallResult,
|
||||
PluginPublicEvent,
|
||||
PluginWorkspaceBinaryFile,
|
||||
PluginWorkspaceDirectoryListing,
|
||||
PluginWorkspaceDirEntry,
|
||||
PluginWorkspaceStat,
|
||||
PluginWorkspaceTextFile,
|
||||
Project,
|
||||
JsonValue,
|
||||
ProjectMcpToolPermissions,
|
||||
ProjectPermissions,
|
||||
ProjectWorkState,
|
||||
@ -104,7 +121,24 @@ import type {
|
||||
ProfileGateway,
|
||||
ProjectGateway,
|
||||
PermissionGateway,
|
||||
PluginConfigDocumentReadInput,
|
||||
PluginConfigDocumentUpdateInput,
|
||||
PluginConfigGateway,
|
||||
PluginEventGateway,
|
||||
PluginEventPollInput,
|
||||
PluginEventSubscribeInput,
|
||||
PluginEventUnsubscribeInput,
|
||||
PluginGateway,
|
||||
PluginProjectStructureQuery,
|
||||
PluginRunCommandInput,
|
||||
PluginTaskGateway,
|
||||
PluginTaskStatusInput,
|
||||
PluginToolchainDiagnosticRequest,
|
||||
PluginToolchainGateway,
|
||||
PluginWorkspaceGateway,
|
||||
PluginWorkspacePathInput,
|
||||
PluginWorkspaceWriteBinaryInput,
|
||||
PluginWorkspaceWriteTextInput,
|
||||
ReattachResult,
|
||||
RemoteGateway,
|
||||
ReviewPluginPackageInput,
|
||||
@ -3540,6 +3574,448 @@ export class MockPluginGateway implements PluginGateway {
|
||||
}
|
||||
}
|
||||
|
||||
const PROJECT_MARKERS: Record<string, string> = {
|
||||
"package.json": "node-package",
|
||||
"Cargo.toml": "rust-cargo",
|
||||
"pyproject.toml": "python-project",
|
||||
"go.mod": "go-module",
|
||||
Makefile: "makefile",
|
||||
makefile: "makefile",
|
||||
".git": "git-repository",
|
||||
};
|
||||
|
||||
function invalidWorkspacePath(path: string): GatewayError {
|
||||
return {
|
||||
code: "INVALID",
|
||||
message: `workspace path must be relative to the project root: ${path}`,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeWorkspacePath(path: string): string {
|
||||
const raw = path.trim();
|
||||
if (raw === "" || raw === ".") return "";
|
||||
if (raw.includes("\0") || raw.startsWith("/") || raw.startsWith("\\") || raw.includes(":")) {
|
||||
throw invalidWorkspacePath(path);
|
||||
}
|
||||
const parts = raw.replace(/\\/g, "/").split("/");
|
||||
if (parts.some((part) => part === "" || part === "." || part === "..")) {
|
||||
throw invalidWorkspacePath(path);
|
||||
}
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
function basename(path: string): string {
|
||||
return path.split("/").pop() ?? path;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory plugin workspace gateway for offline plugin development/tests.
|
||||
* It mirrors the public contract shape, not the host filesystem.
|
||||
*/
|
||||
export class MockPluginWorkspaceGateway implements PluginWorkspaceGateway {
|
||||
private readonly files = new Map<string, Map<string, Uint8Array>>();
|
||||
|
||||
private bucket(projectId: string): Map<string, Uint8Array> {
|
||||
let files = this.files.get(projectId);
|
||||
if (!files) {
|
||||
files = new Map();
|
||||
this.files.set(projectId, files);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
_seedText(projectId: string, path: string, content: string): void {
|
||||
this.bucket(projectId).set(normalizeWorkspacePath(path), new TextEncoder().encode(content));
|
||||
}
|
||||
|
||||
async readText(input: PluginWorkspacePathInput): Promise<PluginWorkspaceTextFile> {
|
||||
const file = await this.readBinary(input);
|
||||
return { path: file.path, content: new TextDecoder().decode(file.bytes) };
|
||||
}
|
||||
|
||||
async readBinary(input: PluginWorkspacePathInput): Promise<PluginWorkspaceBinaryFile> {
|
||||
const path = normalizeWorkspacePath(input.path);
|
||||
const bytes = this.bucket(input.projectId).get(path);
|
||||
if (!bytes) {
|
||||
const err: GatewayError = { code: "NOT_FOUND", message: `workspace file ${path} not found` };
|
||||
throw err;
|
||||
}
|
||||
return { path, bytes: new Uint8Array(bytes) };
|
||||
}
|
||||
|
||||
async writeText(input: PluginWorkspaceWriteTextInput): Promise<void> {
|
||||
const path = normalizeWorkspacePath(input.path);
|
||||
this.bucket(input.projectId).set(path, new TextEncoder().encode(input.content));
|
||||
}
|
||||
|
||||
async writeBinary(input: PluginWorkspaceWriteBinaryInput): Promise<void> {
|
||||
const path = normalizeWorkspacePath(input.path);
|
||||
this.bucket(input.projectId).set(path, new Uint8Array(input.bytes));
|
||||
}
|
||||
|
||||
async listDir(input: PluginWorkspacePathInput): Promise<PluginWorkspaceDirectoryListing> {
|
||||
const path = normalizeWorkspacePath(input.path);
|
||||
const prefix = path === "" ? "" : `${path}/`;
|
||||
const entries = new Map<string, PluginWorkspaceDirEntry>();
|
||||
for (const filePath of this.bucket(input.projectId).keys()) {
|
||||
if (!filePath.startsWith(prefix)) continue;
|
||||
const rest = filePath.slice(prefix.length);
|
||||
if (rest === "") continue;
|
||||
const [name, ...tail] = rest.split("/");
|
||||
const entryPath = path === "" ? name : `${path}/${name}`;
|
||||
const existing = entries.get(name);
|
||||
entries.set(name, {
|
||||
name,
|
||||
path: entryPath,
|
||||
isDir: Boolean(existing?.isDir) || tail.length > 0,
|
||||
});
|
||||
}
|
||||
return { path, entries: [...entries.values()].sort((a, b) => a.name.localeCompare(b.name)) };
|
||||
}
|
||||
|
||||
async stat(input: PluginWorkspacePathInput): Promise<PluginWorkspaceStat> {
|
||||
const path = normalizeWorkspacePath(input.path);
|
||||
const files = this.bucket(input.projectId);
|
||||
const bytes = files.get(path);
|
||||
if (bytes) {
|
||||
return { path, exists: true, isFile: true, isDir: false, len: bytes.byteLength };
|
||||
}
|
||||
const prefix = path === "" ? "" : `${path}/`;
|
||||
const isDir = [...files.keys()].some((filePath) => filePath.startsWith(prefix));
|
||||
return { path, exists: isDir, isFile: false, isDir, len: null };
|
||||
}
|
||||
|
||||
async queryProjectStructure(
|
||||
input: PluginProjectStructureQuery,
|
||||
): Promise<PluginProjectStructure> {
|
||||
const rootPath = normalizeWorkspacePath(input.path ?? "");
|
||||
const maxDepth = Math.min(input.maxDepth ?? 3, 8);
|
||||
const maxEntries = Math.min(input.maxEntries ?? 500, 5000);
|
||||
const prefix = rootPath === "" ? "" : `${rootPath}/`;
|
||||
const entries = new Map<string, PluginProjectStructureEntry>();
|
||||
const conventions = new Map<string, PluginProjectConvention>();
|
||||
const modules = new Map<string, PluginProjectModule>();
|
||||
|
||||
for (const filePath of this.bucket(input.projectId).keys()) {
|
||||
if (!filePath.startsWith(prefix)) continue;
|
||||
const rest = filePath.slice(prefix.length);
|
||||
const parts = rest.split("/").filter(Boolean);
|
||||
for (let i = 0; i < parts.length && i <= maxDepth; i += 1) {
|
||||
const path = [rootPath, ...parts.slice(0, i + 1)].filter(Boolean).join("/");
|
||||
const isLeaf = i === parts.length - 1;
|
||||
entries.set(path, {
|
||||
path,
|
||||
name: parts[i],
|
||||
kind: isLeaf ? "file" : "directory",
|
||||
});
|
||||
}
|
||||
|
||||
const marker = basename(filePath);
|
||||
const conventionId = PROJECT_MARKERS[marker];
|
||||
if (conventionId) {
|
||||
conventions.set(`${conventionId}:${filePath}`, { id: conventionId, markerPath: filePath });
|
||||
const modulePath = filePath.slice(0, Math.max(0, filePath.length - marker.length - 1));
|
||||
modules.set(`${conventionId}:${modulePath}`, {
|
||||
path: modulePath,
|
||||
markerPath: filePath,
|
||||
conventionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sortedEntries = [...entries.values()].sort((a, b) => a.path.localeCompare(b.path));
|
||||
return {
|
||||
projectId: input.projectId,
|
||||
rootPath,
|
||||
entries: sortedEntries.slice(0, maxEntries),
|
||||
conventions: [...conventions.values()].sort((a, b) =>
|
||||
a.markerPath.localeCompare(b.markerPath),
|
||||
),
|
||||
modules: [...modules.values()].sort((a, b) => a.path.localeCompare(b.path)),
|
||||
truncated: sortedEntries.length > maxEntries,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory command-task gateway for plugin runtime tests/dev. It models host
|
||||
* task creation and status reads; live output remains owned by WorkStateGateway.
|
||||
*/
|
||||
export class MockPluginTaskGateway implements PluginTaskGateway {
|
||||
private readonly tasks = new Map<string, PluginCommandTask>();
|
||||
private nextId = 1;
|
||||
|
||||
async runCommand(input: PluginRunCommandInput): Promise<PluginCommandTask> {
|
||||
if (input.command.trim() === "") {
|
||||
const err: GatewayError = { code: "INVALID", message: "command must not be empty" };
|
||||
throw err;
|
||||
}
|
||||
const now = Date.now();
|
||||
const task: PluginCommandTask = {
|
||||
taskId: `mock-plugin-task-${this.nextId++}`,
|
||||
ownerAgentId: input.ownerAgentId,
|
||||
projectId: input.projectId,
|
||||
kind: "command",
|
||||
state: "running",
|
||||
exitCode: null,
|
||||
summary: input.label,
|
||||
stdoutTail: null,
|
||||
stderrTail: null,
|
||||
createdAtMs: now,
|
||||
updatedAtMs: now,
|
||||
};
|
||||
this.tasks.set(task.taskId, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
async getStatus(input: PluginTaskStatusInput): Promise<PluginCommandTask | null> {
|
||||
return this.tasks.get(input.taskId) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory generic toolchain diagnostics for plugin tests/dev. It is
|
||||
* deterministic and does not inspect the real host environment.
|
||||
*/
|
||||
export class MockPluginToolchainGateway implements PluginToolchainGateway {
|
||||
async diagnose(input: PluginToolchainDiagnosticRequest): Promise<PluginToolchainDiagnostic> {
|
||||
const tools = (input.tools ?? []).map((tool) => {
|
||||
const missing = tool.executable.includes("missing");
|
||||
const ok = !missing;
|
||||
return {
|
||||
id: tool.id,
|
||||
executable: tool.executable,
|
||||
present: ok,
|
||||
ok,
|
||||
status: ok ? ("ok" as const) : ("missing" as const),
|
||||
required: tool.required ?? false,
|
||||
exitCode: ok ? 0 : null,
|
||||
version: ok ? `${tool.executable} mock-version` : null,
|
||||
stdout: ok ? `${tool.executable} mock-version\n` : null,
|
||||
stderr: null,
|
||||
error: ok ? null : `executable not found: ${tool.executable}`,
|
||||
};
|
||||
});
|
||||
|
||||
const env = (input.env ?? []).map((requirement) => {
|
||||
const present = !requirement.name.includes("MISSING");
|
||||
const value = present ? (requirement.equals ?? "mock") : null;
|
||||
const ok = present && (requirement.equals === undefined || value === requirement.equals);
|
||||
return {
|
||||
name: requirement.name,
|
||||
present,
|
||||
ok,
|
||||
required: requirement.required ?? false,
|
||||
value,
|
||||
status: ok ? ("ok" as const) : present ? ("mismatch" as const) : ("missing" as const),
|
||||
};
|
||||
});
|
||||
|
||||
const files = (input.files ?? []).map((requirement) => {
|
||||
const exists = !requirement.path.includes("missing");
|
||||
const kind = exists ? (requirement.kind === "directory" ? "directory" : "file") : "missing";
|
||||
const ok =
|
||||
exists &&
|
||||
(requirement.kind === undefined || requirement.kind === "any" || requirement.kind === kind);
|
||||
return {
|
||||
path: requirement.path,
|
||||
exists,
|
||||
ok,
|
||||
required: requirement.required ?? false,
|
||||
kind: kind as "file" | "directory" | "missing",
|
||||
expectedKind: requirement.kind ?? null,
|
||||
len: exists && kind === "file" ? 12 : null,
|
||||
};
|
||||
});
|
||||
|
||||
const messages = [
|
||||
...tools
|
||||
.filter((tool) => tool.required && !tool.ok)
|
||||
.map((tool) => ({ level: "error" as const, message: `${tool.id}: ${tool.error}` })),
|
||||
...env
|
||||
.filter((item) => item.required && !item.ok)
|
||||
.map((item) => ({ level: "error" as const, message: `${item.name}: ${item.status}` })),
|
||||
...files
|
||||
.filter((file) => file.required && !file.ok)
|
||||
.map((file) => ({ level: "error" as const, message: `${file.path}: ${file.kind}` })),
|
||||
];
|
||||
|
||||
return {
|
||||
projectId: input.projectId,
|
||||
cwd: input.cwd ?? "",
|
||||
ok: messages.length === 0,
|
||||
tools,
|
||||
env,
|
||||
files,
|
||||
messages,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface MockPluginEventSubscriptionState {
|
||||
subscription: PluginEventSubscription;
|
||||
queue: PluginPublicEvent[];
|
||||
dropped: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory public plugin event gateway for offline runtime tests/dev.
|
||||
*/
|
||||
export class MockPluginEventGateway implements PluginEventGateway {
|
||||
private readonly subscriptions = new Map<string, MockPluginEventSubscriptionState>();
|
||||
private nextId = 1;
|
||||
|
||||
async subscribe(input: PluginEventSubscribeInput): Promise<PluginEventSubscription> {
|
||||
const subscription: PluginEventSubscription = {
|
||||
subscriptionId: `mock-plugin-events-${this.nextId++}`,
|
||||
projectId: input.projectId,
|
||||
eventTypes: input.eventTypes?.length
|
||||
? input.eventTypes
|
||||
: ["workspaceFileChanged", "backgroundTaskChanged"],
|
||||
capacity: Math.min(Math.max(input.capacity ?? 100, 1), 1000),
|
||||
retention: "bestEffortBounded",
|
||||
};
|
||||
this.subscriptions.set(subscription.subscriptionId, {
|
||||
subscription,
|
||||
queue: [],
|
||||
dropped: 0,
|
||||
});
|
||||
return subscription;
|
||||
}
|
||||
|
||||
async poll(input: PluginEventPollInput): Promise<PluginEventBatch> {
|
||||
const state = this.subscriptions.get(input.subscriptionId);
|
||||
if (!state) {
|
||||
const err: GatewayError = {
|
||||
code: "NOT_FOUND",
|
||||
message: "plugin event subscription not found",
|
||||
};
|
||||
throw err;
|
||||
}
|
||||
const maxEvents = Math.min(Math.max(input.maxEvents ?? 100, 1), 1000);
|
||||
const events = state.queue.splice(0, maxEvents);
|
||||
const dropped = state.dropped;
|
||||
state.dropped = 0;
|
||||
return { subscriptionId: input.subscriptionId, events, dropped };
|
||||
}
|
||||
|
||||
async unsubscribe(input: PluginEventUnsubscribeInput): Promise<PluginEventSubscription> {
|
||||
const state = this.subscriptions.get(input.subscriptionId);
|
||||
this.subscriptions.delete(input.subscriptionId);
|
||||
return (
|
||||
state?.subscription ?? {
|
||||
subscriptionId: input.subscriptionId,
|
||||
projectId: "",
|
||||
eventTypes: [],
|
||||
capacity: 0,
|
||||
retention: "disposed",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
_emit(event: PluginPublicEvent): void {
|
||||
for (const state of this.subscriptions.values()) {
|
||||
if (
|
||||
state.subscription.projectId !== event.projectId ||
|
||||
!state.subscription.eventTypes.includes(event.type)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (state.queue.length >= state.subscription.capacity) {
|
||||
state.queue.shift();
|
||||
state.dropped += 1;
|
||||
}
|
||||
state.queue.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cloneJson<T extends JsonValue>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function applyJsonMergePatch(target: JsonValue, patch: JsonValue): JsonValue {
|
||||
if (patch === null || typeof patch !== "object" || Array.isArray(patch)) return cloneJson(patch);
|
||||
const base =
|
||||
target !== null && typeof target === "object" && !Array.isArray(target)
|
||||
? { ...target }
|
||||
: {};
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === null) {
|
||||
delete base[key];
|
||||
} else {
|
||||
base[key] = applyJsonMergePatch(base[key] ?? null, value);
|
||||
}
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory JSON config-document gateway for plugin tests/dev.
|
||||
*/
|
||||
export class MockPluginConfigGateway implements PluginConfigGateway {
|
||||
private readonly documents = new Map<string, JsonValue>();
|
||||
|
||||
_seed(projectId: string, path: string, value: JsonValue): void {
|
||||
this.documents.set(`${projectId}:${normalizeWorkspacePath(path)}`, cloneJson(value));
|
||||
}
|
||||
|
||||
async readDocument(input: PluginConfigDocumentReadInput): Promise<PluginConfigDocument> {
|
||||
const path = normalizeWorkspacePath(input.path);
|
||||
const format = input.format ?? "json";
|
||||
if (format !== "json") {
|
||||
const err: GatewayError = {
|
||||
code: "INVALID",
|
||||
message: `unsupported structured config format: ${format}; supported formats: json`,
|
||||
};
|
||||
throw err;
|
||||
}
|
||||
const value = this.documents.get(`${input.projectId}:${path}`);
|
||||
if (value === undefined) {
|
||||
const err: GatewayError = { code: "NOT_FOUND", message: `config document ${path} not found` };
|
||||
throw err;
|
||||
}
|
||||
return { projectId: input.projectId, path, format, value: cloneJson(value) };
|
||||
}
|
||||
|
||||
async updateDocument(
|
||||
input: PluginConfigDocumentUpdateInput,
|
||||
): Promise<PluginConfigDocumentWriteResult> {
|
||||
const path = normalizeWorkspacePath(input.path);
|
||||
const format = input.format ?? "json";
|
||||
const mode = input.mode ?? "mergePatch";
|
||||
if (format !== "json") {
|
||||
const err: GatewayError = {
|
||||
code: "INVALID",
|
||||
message: `unsupported structured config format: ${format}; supported formats: json`,
|
||||
};
|
||||
throw err;
|
||||
}
|
||||
if (mode !== "mergePatch" && mode !== "replace") {
|
||||
const err: GatewayError = {
|
||||
code: "INVALID",
|
||||
message: `unsupported structured config update mode: ${mode}`,
|
||||
};
|
||||
throw err;
|
||||
}
|
||||
const key = `${input.projectId}:${path}`;
|
||||
const current = this.documents.get(key);
|
||||
if (mode === "mergePatch" && current === undefined) {
|
||||
const err: GatewayError = { code: "NOT_FOUND", message: `config document ${path} not found` };
|
||||
throw err;
|
||||
}
|
||||
const next = mode === "replace" ? cloneJson(input.value) : applyJsonMergePatch(current!, input.value);
|
||||
this.documents.set(key, next);
|
||||
return {
|
||||
projectId: input.projectId,
|
||||
path,
|
||||
format,
|
||||
mode,
|
||||
bytesWritten: JSON.stringify(next, null, 2).length + 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds the full set of mock gateways. */
|
||||
export function createMockGateways(): Gateways {
|
||||
const agentGateway = new MockAgentGateway();
|
||||
@ -3569,6 +4045,11 @@ export function createMockGateways(): Gateways {
|
||||
focusedProject: new MockFocusedProjectGateway(),
|
||||
uiPreferences: new MockUiPreferencesGateway(),
|
||||
plugin: new MockPluginGateway(),
|
||||
pluginWorkspace: new MockPluginWorkspaceGateway(),
|
||||
pluginTask: new MockPluginTaskGateway(),
|
||||
pluginToolchain: new MockPluginToolchainGateway(),
|
||||
pluginEvents: new MockPluginEventGateway(),
|
||||
pluginConfig: new MockPluginConfigGateway(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -27,6 +27,11 @@ describe("createMockGateways", () => {
|
||||
"modelServer",
|
||||
"permission",
|
||||
"plugin",
|
||||
"pluginConfig",
|
||||
"pluginEvents",
|
||||
"pluginTask",
|
||||
"pluginToolchain",
|
||||
"pluginWorkspace",
|
||||
"profile",
|
||||
"project",
|
||||
"remote",
|
||||
|
||||
24
frontend/src/adapters/pluginConfig.ts
Normal file
24
frontend/src/adapters/pluginConfig.ts
Normal file
@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Tauri adapter for public plugin structured configuration documents (#130).
|
||||
*/
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type { PluginConfigDocument, PluginConfigDocumentWriteResult } from "@/domain";
|
||||
import type {
|
||||
PluginConfigDocumentReadInput,
|
||||
PluginConfigDocumentUpdateInput,
|
||||
PluginConfigGateway,
|
||||
} from "@/ports";
|
||||
|
||||
export class TauriPluginConfigGateway implements PluginConfigGateway {
|
||||
readDocument(input: PluginConfigDocumentReadInput): Promise<PluginConfigDocument> {
|
||||
return invoke<PluginConfigDocument>("plugin_config_read_document", { input });
|
||||
}
|
||||
|
||||
updateDocument(
|
||||
input: PluginConfigDocumentUpdateInput,
|
||||
): Promise<PluginConfigDocumentWriteResult> {
|
||||
return invoke<PluginConfigDocumentWriteResult>("plugin_config_update_document", { input });
|
||||
}
|
||||
}
|
||||
27
frontend/src/adapters/pluginEvents.ts
Normal file
27
frontend/src/adapters/pluginEvents.ts
Normal file
@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Tauri adapter for stable public plugin events (#127).
|
||||
*/
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type { PluginEventBatch, PluginEventSubscription } from "@/domain";
|
||||
import type {
|
||||
PluginEventGateway,
|
||||
PluginEventPollInput,
|
||||
PluginEventSubscribeInput,
|
||||
PluginEventUnsubscribeInput,
|
||||
} from "@/ports";
|
||||
|
||||
export class TauriPluginEventGateway implements PluginEventGateway {
|
||||
subscribe(input: PluginEventSubscribeInput): Promise<PluginEventSubscription> {
|
||||
return invoke<PluginEventSubscription>("plugin_events_subscribe", { input });
|
||||
}
|
||||
|
||||
poll(input: PluginEventPollInput): Promise<PluginEventBatch> {
|
||||
return invoke<PluginEventBatch>("plugin_events_poll", { input });
|
||||
}
|
||||
|
||||
unsubscribe(input: PluginEventUnsubscribeInput): Promise<PluginEventSubscription> {
|
||||
return invoke<PluginEventSubscription>("plugin_events_unsubscribe", { input });
|
||||
}
|
||||
}
|
||||
40
frontend/src/adapters/pluginTask.ts
Normal file
40
frontend/src/adapters/pluginTask.ts
Normal file
@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Tauri adapter for the public plugin command-task SDK facade (#125).
|
||||
*/
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type { PluginCommandTask } from "@/domain";
|
||||
import type { PluginRunCommandInput, PluginTaskGateway, PluginTaskStatusInput } from "@/ports";
|
||||
|
||||
type PluginCommandTaskDto = Omit<
|
||||
PluginCommandTask,
|
||||
"exitCode" | "summary" | "stdoutTail" | "stderrTail"
|
||||
> & {
|
||||
exitCode?: number | null;
|
||||
summary?: string | null;
|
||||
stdoutTail?: string | null;
|
||||
stderrTail?: string | null;
|
||||
};
|
||||
|
||||
function normalizeTask(task: PluginCommandTaskDto): PluginCommandTask {
|
||||
return {
|
||||
...task,
|
||||
exitCode: task.exitCode ?? null,
|
||||
summary: task.summary ?? null,
|
||||
stdoutTail: task.stdoutTail ?? null,
|
||||
stderrTail: task.stderrTail ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export class TauriPluginTaskGateway implements PluginTaskGateway {
|
||||
async runCommand(input: PluginRunCommandInput): Promise<PluginCommandTask> {
|
||||
const task = await invoke<PluginCommandTaskDto>("plugin_task_run_command", { input });
|
||||
return normalizeTask(task);
|
||||
}
|
||||
|
||||
async getStatus(input: PluginTaskStatusInput): Promise<PluginCommandTask | null> {
|
||||
const task = await invoke<PluginCommandTaskDto | null>("plugin_task_get_status", { input });
|
||||
return task ? normalizeTask(task) : null;
|
||||
}
|
||||
}
|
||||
17
frontend/src/adapters/pluginToolchain.ts
Normal file
17
frontend/src/adapters/pluginToolchain.ts
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Tauri adapter for public plugin external-toolchain diagnostics (#126).
|
||||
*/
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type { PluginToolchainDiagnostic } from "@/domain";
|
||||
import type {
|
||||
PluginToolchainDiagnosticRequest,
|
||||
PluginToolchainGateway,
|
||||
} from "@/ports";
|
||||
|
||||
export class TauriPluginToolchainGateway implements PluginToolchainGateway {
|
||||
diagnose(input: PluginToolchainDiagnosticRequest): Promise<PluginToolchainDiagnostic> {
|
||||
return invoke<PluginToolchainDiagnostic>("plugin_toolchain_diagnose", { input });
|
||||
}
|
||||
}
|
||||
77
frontend/src/adapters/pluginWorkspace.ts
Normal file
77
frontend/src/adapters/pluginWorkspace.ts
Normal file
@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Tauri adapter for the public plugin workspace/project-structure SDK facade
|
||||
* (#124 + #129). The commands are plugin-scoped even though the gateway is
|
||||
* frontend-internal: plugins only see the stable service methods.
|
||||
*/
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type {
|
||||
PluginProjectStructure,
|
||||
PluginWorkspaceBinaryFile,
|
||||
PluginWorkspaceDirectoryListing,
|
||||
PluginWorkspaceStat,
|
||||
PluginWorkspaceTextFile,
|
||||
} from "@/domain";
|
||||
import type {
|
||||
PluginProjectStructureQuery,
|
||||
PluginWorkspaceGateway,
|
||||
PluginWorkspacePathInput,
|
||||
PluginWorkspaceWriteBinaryInput,
|
||||
PluginWorkspaceWriteTextInput,
|
||||
} from "@/ports";
|
||||
|
||||
type BinaryFileDto = Omit<PluginWorkspaceBinaryFile, "bytes"> & {
|
||||
bytes: number[] | Uint8Array;
|
||||
};
|
||||
|
||||
function toByteArray(bytes: number[] | Uint8Array): Uint8Array {
|
||||
return bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
||||
}
|
||||
|
||||
function normalizeBinaryFile(file: BinaryFileDto): PluginWorkspaceBinaryFile {
|
||||
return { ...file, bytes: toByteArray(file.bytes) };
|
||||
}
|
||||
|
||||
function binaryInput(input: PluginWorkspaceWriteBinaryInput): {
|
||||
projectId: string;
|
||||
path: string;
|
||||
bytes: number[];
|
||||
} {
|
||||
return {
|
||||
projectId: input.projectId,
|
||||
path: input.path,
|
||||
bytes: Array.from(input.bytes),
|
||||
};
|
||||
}
|
||||
|
||||
export class TauriPluginWorkspaceGateway implements PluginWorkspaceGateway {
|
||||
readText(input: PluginWorkspacePathInput): Promise<PluginWorkspaceTextFile> {
|
||||
return invoke<PluginWorkspaceTextFile>("plugin_workspace_read_text", { input });
|
||||
}
|
||||
|
||||
async readBinary(input: PluginWorkspacePathInput): Promise<PluginWorkspaceBinaryFile> {
|
||||
const file = await invoke<BinaryFileDto>("plugin_workspace_read_binary", { input });
|
||||
return normalizeBinaryFile(file);
|
||||
}
|
||||
|
||||
async writeText(input: PluginWorkspaceWriteTextInput): Promise<void> {
|
||||
await invoke("plugin_workspace_write_text", { input });
|
||||
}
|
||||
|
||||
async writeBinary(input: PluginWorkspaceWriteBinaryInput): Promise<void> {
|
||||
await invoke("plugin_workspace_write_binary", { input: binaryInput(input) });
|
||||
}
|
||||
|
||||
listDir(input: PluginWorkspacePathInput): Promise<PluginWorkspaceDirectoryListing> {
|
||||
return invoke<PluginWorkspaceDirectoryListing>("plugin_workspace_list_dir", { input });
|
||||
}
|
||||
|
||||
stat(input: PluginWorkspacePathInput): Promise<PluginWorkspaceStat> {
|
||||
return invoke<PluginWorkspaceStat>("plugin_workspace_stat", { input });
|
||||
}
|
||||
|
||||
queryProjectStructure(input: PluginProjectStructureQuery): Promise<PluginProjectStructure> {
|
||||
return invoke<PluginProjectStructure>("plugin_query_project_structure", { input });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user