feat(wave): #119/#122/#131/#132 verts + sprint plugins ESM/persistance #135/#136/#139

État d'intégration confiné à la branche batch. Les tickets #119 (skills →
capacités agent découvrables), #122 (override permissions par défaut), #131
(effort par agent/presets) et #132 (outil MCP d'édition du contexte projet)
sont verts en périmètre. Le sprint plugins multi-fichiers ESM / persistance
plugin-owned (#135/#136/#139) est co-implémenté dans les MÊMES fichiers de
câblage (frontend/src/ports/index.ts, backend/src/lib.rs, domain/ports.rs,
backend/dto.rs), inséparable sans staging interactif (indisponible ici).

Commit unique volontaire : préserve l'état vert QA sans découpe hunk risquée.
NON mergé vers develop tant que #137 (QA e2e plugins) n'est pas vert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 11:06:23 +02:00
parent 22c6bd803d
commit 171c6c923c
59 changed files with 3654 additions and 291 deletions

View File

@ -139,4 +139,25 @@ describe("TauriAgentGateway invoke payloads", () => {
);
expect(out.relaunchedSession).toBeUndefined();
});
it("update_agent_effort wraps the nullable effort override in the request DTO", async () => {
invoke.mockResolvedValueOnce({ id: "agent-2", effort: { kind: "preset", value: "high" } });
await new TauriAgentGateway().updateAgentEffort("proj-1", "agent-2", {
kind: "preset",
value: "high",
});
expect(invoke).toHaveBeenCalledWith("update_agent_effort", {
request: {
projectId: "proj-1",
agentId: "agent-2",
effort: { kind: "preset", value: "high" },
},
});
invoke.mockClear().mockResolvedValueOnce({ id: "agent-2" });
await new TauriAgentGateway().updateAgentEffort("proj-1", "agent-2", null);
expect(invoke).toHaveBeenCalledWith("update_agent_effort", {
request: { projectId: "proj-1", agentId: "agent-2", effort: null },
});
});
});

View File

@ -16,6 +16,7 @@ import { Channel, invoke } from "@tauri-apps/api/core";
import type {
Agent,
EffortSelection,
ResumableAgent,
TerminalSession,
} from "@/domain";
@ -105,6 +106,16 @@ export class TauriAgentGateway implements AgentGateway {
);
}
updateAgentEffort(
projectId: string,
agentId: string,
effort: EffortSelection | null,
): Promise<Agent> {
return invoke<Agent>("update_agent_effort", {
request: { projectId, agentId, effort },
});
}
readContext(projectId: string, agentId: string): Promise<string> {
return invoke<string>("read_agent_context", { projectId, agentId });
}

View File

@ -48,6 +48,7 @@ import {
WebPluginConfigGateway,
WebPluginEventGateway,
WebPluginGateway,
WebPluginStorageGateway,
WebPluginTaskGateway,
WebPluginToolchainGateway,
WebPluginWorkspaceGateway,
@ -151,6 +152,7 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway
pluginToolchain: new WebPluginToolchainGateway(),
pluginEvents: new WebPluginEventGateway(),
pluginConfig: new WebPluginConfigGateway(),
pluginStorage: new WebPluginStorageGateway(),
};
}

View File

@ -15,7 +15,6 @@ import type {
Agent,
AgentDrift,
AgentProfile,
EffectivePermissions,
EmbedderEngines,
EmbedderProfile,
FirstRunState,
@ -43,6 +42,7 @@ import type {
ProjectSystemPermissions,
ProfileAvailability,
ProfileModelCatalog,
ResolvedAgentPermissions,
ResolvedAgentSystemPermissions,
SystemPermissionSet,
Skill,
@ -286,7 +286,14 @@ export class HttpSkillGateway implements SkillGateway {
}
createSkill(input: CreateSkillInput): Promise<Skill> {
return this.http.invoke<Skill>("create_skill", {
request: { projectId: input.projectId, name: input.name, content: input.content, scope: input.scope },
request: {
projectId: input.projectId,
name: input.name,
description: input.description,
kind: input.kind,
content: input.content,
scope: input.scope,
},
});
}
updateSkill(projectId: string, scope: SkillScope, skillId: string, content: string): Promise<Skill> {
@ -381,8 +388,8 @@ export class HttpPermissionGateway implements PermissionGateway {
request: { projectId, agentId, permissions },
});
}
resolveAgentPermissions(projectId: string, agentId: string): Promise<EffectivePermissions | null> {
return this.http.invoke<EffectivePermissions | null>("resolve_agent_permissions", {
resolveAgentPermissions(projectId: string, agentId: string): Promise<ResolvedAgentPermissions> {
return this.http.invoke<ResolvedAgentPermissions>("resolve_agent_permissions", {
request: { projectId, agentId },
});
}

View File

@ -23,6 +23,7 @@ import type {
Agent,
AppExitWorkGuardState,
DomainEvent,
EffortSelection,
HealthReport,
ReplyChunk,
ResumableAgent,
@ -223,6 +224,15 @@ export class HttpAgentGateway implements AgentGateway {
request: { projectId, agentId, profileId, rows, cols },
});
}
updateAgentEffort(
projectId: string,
agentId: string,
effort: EffortSelection | null,
): Promise<Agent> {
return this.http.invoke<Agent>("update_agent_effort", {
request: { projectId, agentId, effort },
});
}
readContext(projectId: string, agentId: string): Promise<string> {
return this.http.invoke<string>("read_agent_context", { projectId, agentId });
}

View File

@ -12,6 +12,7 @@
import type {
EmbeddedServerStatus,
GatewayError,
JsonValue,
PluginAdmin,
PluginCommandTask,
PluginConfigDocument,
@ -44,6 +45,9 @@ import type {
PluginEventSubscribeInput,
PluginEventUnsubscribeInput,
PluginGateway,
PluginStorageGateway,
PluginStorageGetInput,
PluginStorageSetInput,
PluginProjectStructureQuery,
PluginRunCommandInput,
PluginTaskGateway,
@ -263,3 +267,18 @@ export class WebPluginConfigGateway implements PluginConfigGateway {
return unsupportedOnWeb("Plugin structured config documents");
}
}
/** Web stub: plugin-owned storage is owned by the desktop host app-data. */
export class WebPluginStorageGateway implements PluginStorageGateway {
async get(_input: PluginStorageGetInput): Promise<JsonValue | null> {
return unsupportedOnWeb("Plugin storage");
}
async set(_input: PluginStorageSetInput): Promise<void> {
return unsupportedOnWeb("Plugin storage");
}
async delete(_input: PluginStorageGetInput): Promise<boolean> {
return unsupportedOnWeb("Plugin storage");
}
}

View File

@ -40,6 +40,7 @@ import { TauriPluginTaskGateway } from "./pluginTask";
import { TauriPluginToolchainGateway } from "./pluginToolchain";
import { TauriPluginEventGateway } from "./pluginEvents";
import { TauriPluginConfigGateway } from "./pluginConfig";
import { TauriPluginStorageGateway } from "./pluginStorage";
function notImplemented(what: string): never {
const err: GatewayError = {
@ -87,6 +88,7 @@ export function createTauriGateways(): Gateways {
pluginToolchain: new TauriPluginToolchainGateway(),
pluginEvents: new TauriPluginEventGateway(),
pluginConfig: new TauriPluginConfigGateway(),
pluginStorage: new TauriPluginStorageGateway(),
};
}
@ -114,4 +116,5 @@ export {
TauriFocusedProjectGateway,
LocalStorageUiPreferencesGateway,
TauriPluginGateway,
TauriPluginStorageGateway,
};

View File

@ -11,6 +11,7 @@ import type {
AgentProfile,
DiagnosticWarning,
DomainEvent,
EffortSelection,
EmbedderEngines,
EmbedderProfile,
EmbeddedServerStatus,
@ -37,7 +38,6 @@ import type {
OpenCodeProviderCatalogEntry,
ProfileModelCatalog,
ProfileModelCatalogEntry,
EffectivePermissions,
PairedDevice,
PairingCode,
PermissionSet,
@ -72,6 +72,7 @@ import type {
ProjectSystemPermissions,
ProfileAvailability,
ResumableAgent,
ResolvedAgentPermissions,
ResolvedAgentSystemPermissions,
ServerExposurePreview,
ServerExposureSettings,
@ -129,6 +130,9 @@ import type {
PluginEventSubscribeInput,
PluginEventUnsubscribeInput,
PluginGateway,
PluginStorageGateway,
PluginStorageGetInput,
PluginStorageSetInput,
PluginProjectStructureQuery,
PluginRunCommandInput,
PluginTaskGateway,
@ -603,6 +607,28 @@ export class MockAgentGateway implements AgentGateway {
};
}
async updateAgentEffort(
projectId: string,
agentId: string,
effort: EffortSelection | null,
): Promise<Agent> {
const list = this.getAgents(projectId);
const idx = list.findIndex((a) => a.id === agentId);
if (idx === -1) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `agent ${agentId} not found in project ${projectId}`,
};
throw err;
}
const next =
effort === null
? (({ effort: _dropped, ...rest }) => rest)(list[idx])
: { ...list[idx], effort };
list[idx] = next;
return structuredClone(next);
}
// ── Internal helpers for MockTemplateGateway (same-package use only) ──
/**
@ -2006,6 +2032,8 @@ export class MockSkillGateway implements SkillGateway {
const skill: Skill = {
id: `mock-skill-${this.seq}`,
name: input.name,
description: input.description ?? null,
kind: input.kind ?? "workflow",
contentMd: input.content,
scope: input.scope,
};
@ -2431,14 +2459,18 @@ export class MockPermissionGateway implements PermissionGateway {
async resolveAgentPermissions(
projectId: string,
agentId: string,
): Promise<EffectivePermissions | null> {
): Promise<ResolvedAgentPermissions> {
const doc = this.doc(projectId);
const project = doc.projectDefaults;
const agent = doc.agents?.find((entry) => entry.agentId === agentId)?.permissions;
if (!project && !agent) return null;
const shadowed = permissionShadowReport(project, agent);
if (!project && !agent) return { effective: null, shadowed };
return {
rules: [...(project?.rules ?? []), ...(agent?.rules ?? [])],
fallback: mostRestrictive(project?.fallback, agent?.fallback),
effective: {
rules: [...(project?.rules ?? []), ...(agent?.rules ?? [])],
fallback: mostRestrictive(project?.fallback, agent?.fallback),
},
shadowed,
};
}
@ -3466,6 +3498,47 @@ function mostRestrictive(
return rank[agent] >= rank[fallback] ? agent : fallback;
}
function permissionShadowReport(project?: PermissionSet, agent?: PermissionSet) {
const empty = {
read: false,
write: false,
delete: false,
executeBash: false,
fallback: false,
};
if (!agent) return empty;
const shadowed = (capability: "read" | "write" | "delete" | "executeBash") =>
blanketEffect(project, capability, "deny") === "deny" &&
blanketEffect(agent, capability, "allow") === "allow";
return {
read: shadowed("read"),
write: shadowed("write"),
delete: shadowed("delete"),
executeBash: shadowed("executeBash"),
fallback: agent.fallback !== mostRestrictive(project?.fallback, agent.fallback),
};
}
function blanketEffect(
set: PermissionSet | undefined,
capability: "read" | "write" | "delete" | "executeBash",
wins: "allow" | "deny",
) {
let found: "allow" | "deny" | undefined;
for (const rule of set?.rules ?? []) {
if (rule.capability !== capability || !isBlanketRule(rule)) continue;
if (rule.effect === wins) return rule.effect;
found = rule.effect;
}
return found;
}
function isBlanketRule(rule: PermissionSet["rules"][number]) {
if (rule.capability === "executeBash") return (rule.commands ?? []).length === 0;
const paths = rule.paths ?? [];
return paths.length === 1 && paths[0] === "**";
}
/**
* In-memory plugin store (ticket #43, F1). Mirrors the carnet contract closely
* enough to develop/test F1-F4 without the backend (B1-B4, landing in
@ -4016,6 +4089,38 @@ export class MockPluginConfigGateway implements PluginConfigGateway {
}
}
/**
* In-memory plugin-owned storage gateway for plugin runtime tests/dev.
*/
export class MockPluginStorageGateway implements PluginStorageGateway {
private readonly values = new Map<string, JsonValue>();
private storageKey(input: PluginStorageGetInput): string {
if (!input.pluginId.trim()) {
const err: GatewayError = { code: "INVALID", message: "pluginId must not be empty" };
throw err;
}
if (!input.key.trim()) {
const err: GatewayError = { code: "INVALID", message: "key must not be empty" };
throw err;
}
return `${input.pluginId}:${input.key}`;
}
async get(input: PluginStorageGetInput): Promise<JsonValue | null> {
const value = this.values.get(this.storageKey(input));
return value === undefined ? null : cloneJson(value);
}
async set(input: PluginStorageSetInput): Promise<void> {
this.values.set(this.storageKey(input), cloneJson(input.value));
}
async delete(input: PluginStorageGetInput): Promise<boolean> {
return this.values.delete(this.storageKey(input));
}
}
/** Builds the full set of mock gateways. */
export function createMockGateways(): Gateways {
const agentGateway = new MockAgentGateway();
@ -4050,6 +4155,7 @@ export function createMockGateways(): Gateways {
pluginToolchain: new MockPluginToolchainGateway(),
pluginEvents: new MockPluginEventGateway(),
pluginConfig: new MockPluginConfigGateway(),
pluginStorage: new MockPluginStorageGateway(),
};
}

View File

@ -1,12 +1,12 @@
import { invoke } from "@tauri-apps/api/core";
import type {
EffectivePermissions,
McpToolPolicy,
PermissionSet,
ProjectMcpToolPermissions,
ProjectPermissions,
ProjectSystemPermissions,
ResolvedAgentPermissions,
ResolvedAgentSystemPermissions,
SystemPermissionSet,
} from "@/domain";
@ -40,8 +40,8 @@ export class TauriPermissionGateway implements PermissionGateway {
resolveAgentPermissions(
projectId: string,
agentId: string,
): Promise<EffectivePermissions | null> {
return invoke<EffectivePermissions | null>("resolve_agent_permissions", {
): Promise<ResolvedAgentPermissions> {
return invoke<ResolvedAgentPermissions>("resolve_agent_permissions", {
request: { projectId, agentId },
});
}

View File

@ -0,0 +1,26 @@
/**
* Tauri adapter for plugin-owned JSON storage (#139).
*/
import { invoke } from "@tauri-apps/api/core";
import type { JsonValue } from "@/domain";
import type {
PluginStorageGateway,
PluginStorageGetInput,
PluginStorageSetInput,
} from "@/ports";
export class TauriPluginStorageGateway implements PluginStorageGateway {
get(input: PluginStorageGetInput): Promise<JsonValue | null> {
return invoke<JsonValue | null>("plugin_storage_get", { input });
}
async set(input: PluginStorageSetInput): Promise<void> {
await invoke("plugin_storage_set", { input });
}
delete(input: PluginStorageGetInput): Promise<boolean> {
return invoke<boolean>("plugin_storage_delete", { input });
}
}

View File

@ -22,6 +22,8 @@ export class TauriSkillGateway implements SkillGateway {
request: {
projectId: input.projectId,
name: input.name,
description: input.description,
kind: input.kind,
content: input.content,
scope: input.scope,
},