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:
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user