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:
4
.gitignore
vendored
4
.gitignore
vendored
@ -73,3 +73,7 @@ Thumbs.db
|
||||
.ideai/agents.json
|
||||
.ideai/background-tasks/
|
||||
.ideai/mcp-tool-permissions.json
|
||||
|
||||
# QA isolated cargo home/target (never commit)
|
||||
.qa-cargo-home/
|
||||
.qa-cargo-target/
|
||||
|
||||
51
Cargo.lock
generated
51
Cargo.lock
generated
@ -102,12 +102,22 @@ dependencies = [
|
||||
"domain",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
@ -744,6 +754,17 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_builder"
|
||||
version = "0.20.2"
|
||||
@ -1996,6 +2017,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"uuid",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -6236,8 +6258,37 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"displaydoc",
|
||||
"flate2",
|
||||
"indexmap 2.14.0",
|
||||
"memchr",
|
||||
"thiserror 2.0.18",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
@ -25,9 +25,9 @@ use application::{
|
||||
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
|
||||
RotateConversationLogInput, SetActiveLayoutInput, SnapshotRunningAgentsInput,
|
||||
StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput,
|
||||
UpdateAgentContextInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
|
||||
UpdateAgentSystemPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput,
|
||||
UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
|
||||
UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput,
|
||||
UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateMemoryInput,
|
||||
UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
|
||||
UpdateProjectSystemPermissionsInput, UpdateSkillInput,
|
||||
};
|
||||
use backend::stream::OutputSink;
|
||||
@ -47,28 +47,28 @@ use crate::dto::{
|
||||
CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
|
||||
CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
|
||||
DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
|
||||
EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto,
|
||||
ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto,
|
||||
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
|
||||
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
|
||||
InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto,
|
||||
LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto,
|
||||
ModelServerConfigDto, ModelServerConfigListDto, OpenCodeProviderListDto,
|
||||
OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto, ProfileListDto,
|
||||
ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto,
|
||||
ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto,
|
||||
ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto,
|
||||
ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
|
||||
ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto,
|
||||
EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto,
|
||||
FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto,
|
||||
GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto,
|
||||
HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, InterruptAgentRequestDto,
|
||||
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
|
||||
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto,
|
||||
ModelServerConfigListDto, OpenCodeProviderListDto, OpenTerminalRequestDto,
|
||||
PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto,
|
||||
ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto,
|
||||
ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
|
||||
ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto,
|
||||
RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto,
|
||||
ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto,
|
||||
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
|
||||
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveModelServerRequestDto,
|
||||
SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto,
|
||||
SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto,
|
||||
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
|
||||
TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
|
||||
UpdateAgentContextRequestDto, UpdateAgentMcpToolPermissionsRequestDto,
|
||||
UpdateAgentPermissionsRequestDto, UpdateAgentSystemPermissionsRequestDto,
|
||||
UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
||||
UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto,
|
||||
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
|
||||
UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
||||
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
|
||||
UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
|
||||
WriteTerminalRequestDto,
|
||||
@ -538,6 +538,29 @@ pub async fn update_agent_permissions(
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `update_agent_effort` — set or clear one agent's per-agent effort override.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] on invalid ids or store failure.
|
||||
#[tauri::command]
|
||||
pub async fn update_agent_effort(
|
||||
request: UpdateAgentEffortRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<AgentDto, ErrorDto> {
|
||||
let project = resolve_project(&request.project_id, &state).await?;
|
||||
let agent_id = parse_agent_id(&request.agent_id)?;
|
||||
state
|
||||
.update_agent_effort
|
||||
.execute(UpdateAgentEffortInput {
|
||||
project,
|
||||
agent_id,
|
||||
effort: request.effort,
|
||||
})
|
||||
.await
|
||||
.map(|out| AgentDto::from_agent(out.agent))
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `resolve_agent_permissions` — resolve project defaults plus agent override.
|
||||
///
|
||||
/// # Errors
|
||||
@ -546,14 +569,14 @@ pub async fn update_agent_permissions(
|
||||
pub async fn resolve_agent_permissions(
|
||||
request: ResolveAgentPermissionsRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Option<EffectivePermissionsDto>, ErrorDto> {
|
||||
) -> Result<ResolveAgentPermissionsResponseDto, ErrorDto> {
|
||||
let project = resolve_project(&request.project_id, &state).await?;
|
||||
let agent_id = parse_agent_id(&request.agent_id)?;
|
||||
state
|
||||
.resolve_agent_permissions
|
||||
.execute(ResolveAgentPermissionsInput { project, agent_id })
|
||||
.await
|
||||
.map(|out| out.effective.map(EffectivePermissionsDto))
|
||||
.map(ResolveAgentPermissionsResponseDto::from)
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
@ -3308,12 +3331,10 @@ pub async fn create_skill(
|
||||
.create_skill
|
||||
.execute(CreateSkillInput {
|
||||
name: request.name,
|
||||
// Description is set via the dedicated frontend field (T6); the create
|
||||
// path stays None for now so the affordance falls back to the body's
|
||||
// first line (see `Skill::effective_description`).
|
||||
description: None,
|
||||
description: request.description,
|
||||
content: request.content,
|
||||
scope: request.scope,
|
||||
kind: request.kind,
|
||||
project_root: project.root,
|
||||
})
|
||||
.await
|
||||
|
||||
@ -257,6 +257,7 @@ pub fn run() {
|
||||
commands::get_project_permissions,
|
||||
commands::update_project_permissions,
|
||||
commands::update_agent_permissions,
|
||||
commands::update_agent_effort,
|
||||
commands::resolve_agent_permissions,
|
||||
commands::get_project_system_permissions,
|
||||
commands::update_project_system_permissions,
|
||||
@ -401,6 +402,9 @@ pub fn run() {
|
||||
plugins::plugin_workspace_read_binary,
|
||||
plugins::plugin_workspace_write_text,
|
||||
plugins::plugin_workspace_write_binary,
|
||||
plugins::plugin_storage_get,
|
||||
plugins::plugin_storage_set,
|
||||
plugins::plugin_storage_delete,
|
||||
plugins::plugin_workspace_list_dir,
|
||||
plugins::plugin_workspace_stat,
|
||||
plugins::plugin_query_project_structure,
|
||||
@ -438,6 +442,9 @@ fn plugin_workspace_invoke_handler<R: tauri::Runtime>(
|
||||
plugins::plugin_workspace_read_binary,
|
||||
plugins::plugin_workspace_write_text,
|
||||
plugins::plugin_workspace_write_binary,
|
||||
plugins::plugin_storage_get,
|
||||
plugins::plugin_storage_set,
|
||||
plugins::plugin_storage_delete,
|
||||
plugins::plugin_workspace_list_dir,
|
||||
plugins::plugin_workspace_stat,
|
||||
plugins::plugin_query_project_structure,
|
||||
@ -1030,6 +1037,61 @@ mod tests {
|
||||
std::fs::remove_dir_all(app_data).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dto_plugins_storage_commands_are_registered_in_tauri_invoke_handler() {
|
||||
let app_data = test_app_data_dir("plugin-storage-commands");
|
||||
let app = mock_builder()
|
||||
.manage(crate::state::AppState::build(app_data.clone()))
|
||||
.invoke_handler(plugin_workspace_invoke_handler())
|
||||
.build(mock_context(noop_assets()))
|
||||
.expect("mock app builds");
|
||||
let webview = tauri::WebviewWindowBuilder::new(&app, "main", Default::default())
|
||||
.build()
|
||||
.expect("mock webview builds");
|
||||
|
||||
let get_err = invoke_plugin_command(
|
||||
&webview,
|
||||
"plugin_storage_get",
|
||||
json!({
|
||||
"input": {
|
||||
"pluginId": "dev.acme.missing",
|
||||
"key": "helloPlugin.launches"
|
||||
}
|
||||
}),
|
||||
)
|
||||
.expect_err("missing plugin must surface through the registered command");
|
||||
assert_eq!(get_err["code"], "NOT_FOUND");
|
||||
|
||||
let set_err = invoke_plugin_command(
|
||||
&webview,
|
||||
"plugin_storage_set",
|
||||
json!({
|
||||
"input": {
|
||||
"pluginId": "dev.acme.missing",
|
||||
"key": "helloPlugin.launches",
|
||||
"value": 1
|
||||
}
|
||||
}),
|
||||
)
|
||||
.expect_err("missing plugin must surface through the registered command");
|
||||
assert_eq!(set_err["code"], "NOT_FOUND");
|
||||
|
||||
let delete_err = invoke_plugin_command(
|
||||
&webview,
|
||||
"plugin_storage_delete",
|
||||
json!({
|
||||
"input": {
|
||||
"pluginId": "dev.acme.missing",
|
||||
"key": "helloPlugin.launches"
|
||||
}
|
||||
}),
|
||||
)
|
||||
.expect_err("missing plugin must surface through the registered command");
|
||||
assert_eq!(delete_err["code"], "NOT_FOUND");
|
||||
|
||||
std::fs::remove_dir_all(app_data).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dto_plugins_command_task_commands_are_registered_in_tauri_invoke_handler() {
|
||||
let app_data = test_app_data_dir("plugin-task-commands");
|
||||
|
||||
@ -10,11 +10,11 @@ use backend::dto::{
|
||||
PluginEventPollDto, PluginEventSubscribeDto, PluginEventSubscriptionDto,
|
||||
PluginEventUnsubscribeDto, PluginInstallResultDto, PluginProjectStructureDto,
|
||||
PluginProjectStructureQueryDto, PluginReviewDto, PluginRunCommandDto,
|
||||
PluginRuntimeContributionCatalogDto, PluginTaskDto, PluginTaskStatusDto,
|
||||
PluginToolchainDiagnosticDto, PluginToolchainDiagnosticRequestDto, PluginUninstallResultDto,
|
||||
PluginWorkspaceBinaryFileDto, PluginWorkspaceDirectoryListingDto, PluginWorkspacePathDto,
|
||||
PluginWorkspaceStatDto, PluginWorkspaceTextFileDto, PluginWorkspaceWriteBinaryDto,
|
||||
PluginWorkspaceWriteTextDto, ReviewPluginPackageDto,
|
||||
PluginRuntimeContributionCatalogDto, PluginStorageGetDto, PluginStorageSetDto, PluginTaskDto,
|
||||
PluginTaskStatusDto, PluginToolchainDiagnosticDto, PluginToolchainDiagnosticRequestDto,
|
||||
PluginUninstallResultDto, PluginWorkspaceBinaryFileDto, PluginWorkspaceDirectoryListingDto,
|
||||
PluginWorkspacePathDto, PluginWorkspaceStatDto, PluginWorkspaceTextFileDto,
|
||||
PluginWorkspaceWriteBinaryDto, PluginWorkspaceWriteTextDto, ReviewPluginPackageDto,
|
||||
};
|
||||
use domain::ports::{PluginManifestValidator, PluginPackageStore, PluginRegistryStore};
|
||||
use domain::{PluginId, RelativePath};
|
||||
@ -216,6 +216,45 @@ pub async fn plugin_workspace_write_binary(
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// Reads a plugin-owned JSON storage value.
|
||||
#[tauri::command]
|
||||
pub async fn plugin_storage_get(
|
||||
input: PluginStorageGetDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Option<serde_json::Value>, ErrorDto> {
|
||||
state
|
||||
.plugin_storage_access
|
||||
.get(input.into())
|
||||
.await
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// Writes a plugin-owned JSON storage value.
|
||||
#[tauri::command]
|
||||
pub async fn plugin_storage_set(
|
||||
input: PluginStorageSetDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), ErrorDto> {
|
||||
state
|
||||
.plugin_storage_access
|
||||
.set(input.into())
|
||||
.await
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// Deletes a plugin-owned JSON storage value.
|
||||
#[tauri::command]
|
||||
pub async fn plugin_storage_delete(
|
||||
input: PluginStorageGetDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<bool, ErrorDto> {
|
||||
state
|
||||
.plugin_storage_access
|
||||
.delete(input.into())
|
||||
.await
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// Lists a workspace directory for the public plugin API.
|
||||
#[tauri::command]
|
||||
pub async fn plugin_workspace_list_dir(
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
use app_tauri_lib::dto::{
|
||||
parse_agent_id, AgentDto, AgentListDto, ConversationDetailsDto, CreateAgentRequestDto,
|
||||
InspectConversationRequestDto, LaunchAgentRequestDto, LiveAgentListDto, TerminalSessionDto,
|
||||
UpdateAgentContextRequestDto,
|
||||
UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto,
|
||||
};
|
||||
use application::AppError;
|
||||
use application::{
|
||||
@ -17,7 +17,7 @@ use application::{
|
||||
use domain::ids::{AgentId, NodeId, ProfileId, SessionId};
|
||||
use domain::ports::ConversationDetails;
|
||||
use domain::terminal::{PtySize, SessionKind, SessionStatus, TerminalSession};
|
||||
use domain::{Agent, AgentOrigin, ProjectPath, SkillKind};
|
||||
use domain::{Agent, AgentOrigin, EffortSelection, ProjectPath, SkillKind};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -60,6 +60,15 @@ fn agent_dto_serialises_camelcase() {
|
||||
assert!(v.get("profile_id").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_dto_serialises_effort_tagged_shape_when_present() {
|
||||
let agent = make_agent(1, 2).with_effort(Some(EffortSelection::Preset("medium".to_owned())));
|
||||
let dto = AgentDto::from_agent(agent);
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
|
||||
assert_eq!(v["effort"], json!({"kind":"preset","value":"medium"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_list_dto_is_transparent_array() {
|
||||
let first = make_agent(1, 2);
|
||||
@ -73,6 +82,7 @@ fn agent_list_dto_is_transparent_array() {
|
||||
kind: SkillKind::Reference,
|
||||
}],
|
||||
}],
|
||||
effective_orchestrator: Some(first.id),
|
||||
};
|
||||
let dto = AgentListDto::from(out);
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
@ -82,7 +92,9 @@ fn agent_list_dto_is_transparent_array() {
|
||||
assert_eq!(arr[0]["capabilities"][0]["name"], "review");
|
||||
assert_eq!(arr[0]["capabilities"][0]["description"], "Reviews changes");
|
||||
assert_eq!(arr[0]["capabilities"][0]["kind"], "reference");
|
||||
assert_eq!(arr[0]["isOrchestrator"], true);
|
||||
assert_eq!(arr[1]["capabilities"], json!([]));
|
||||
assert_eq!(arr[1]["isOrchestrator"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -139,6 +151,28 @@ fn update_agent_context_request_deserialises_camelcase() {
|
||||
assert_eq!(dto.content, "# Updated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_agent_effort_request_deserialises_and_null_clears() {
|
||||
let raw = json!({
|
||||
"projectId": Uuid::from_u128(1).to_string(),
|
||||
"agentId": Uuid::from_u128(2).to_string(),
|
||||
"effort": {"kind": "custom", "value": "x-deep"}
|
||||
});
|
||||
let dto: UpdateAgentEffortRequestDto = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(
|
||||
dto.effort,
|
||||
Some(EffortSelection::Custom("x-deep".to_owned()))
|
||||
);
|
||||
|
||||
let clear: UpdateAgentEffortRequestDto = serde_json::from_value(json!({
|
||||
"projectId": Uuid::from_u128(1).to_string(),
|
||||
"agentId": Uuid::from_u128(2).to_string(),
|
||||
"effort": null
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(clear.effort, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_agent_request_deserialises_camelcase() {
|
||||
let project_id = Uuid::from_u128(1).to_string();
|
||||
|
||||
@ -12,6 +12,7 @@ thiserror = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
subtle = { workspace = true }
|
||||
# Resolves the OpenCode cache dir (`~/.cache/opencode/models.json`) for the
|
||||
# dynamic provider catalogue (ticket #92 follow-up). See
|
||||
|
||||
@ -21,8 +21,8 @@ use domain::ports::{
|
||||
SpawnSpec, StructuredProviderLaunchPolicy, SystemPermissionStore,
|
||||
};
|
||||
use domain::profile::{
|
||||
McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter, CODEX_CODE_MODE_FEATURES_TABLE,
|
||||
CODEX_CODE_MODE_FEATURES_TOML,
|
||||
resolve_effort, EffortSelection, McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter,
|
||||
CODEX_CODE_MODE_FEATURES_TABLE, CODEX_CODE_MODE_FEATURES_TOML,
|
||||
};
|
||||
use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan};
|
||||
use domain::{
|
||||
@ -123,8 +123,9 @@ pub struct InjectedLiveRow {
|
||||
pub struct ResolvedAssignedSkill {
|
||||
/// Agent-facing snapshot metadata.
|
||||
pub snapshot: domain::AssignedSkillSnapshot,
|
||||
/// Full Markdown body, used only on non-MCP profiles that cannot call
|
||||
/// `idea_skill_read`.
|
||||
/// Full Markdown body kept in the effective snapshot so authorized lazy-read
|
||||
/// paths can use the same resolution; convention-file rendering exposes only
|
||||
/// bounded affordances.
|
||||
pub content: MarkdownDoc,
|
||||
}
|
||||
|
||||
@ -140,7 +141,9 @@ pub struct EffectiveAgentContext {
|
||||
pub capabilities: OrchestrationCapabilitySnapshot,
|
||||
/// Compact agent capability affordances resolved by [`ResolveAgentCapabilities`].
|
||||
pub agent_capabilities: Vec<AgentCapability>,
|
||||
/// Resolved assigned skill bodies for fallback non-MCP injection.
|
||||
/// Resolved assigned skill bodies. These are not dumped into provider context
|
||||
/// by default; agents receive compact affordances and load details through the
|
||||
/// active IdeA surface.
|
||||
pub assigned_skills: Vec<ResolvedAssignedSkill>,
|
||||
/// Project-memory recall selected for this launch.
|
||||
pub memory: Vec<MemoryIndexEntry>,
|
||||
@ -281,6 +284,8 @@ pub struct ListAgentsOutput {
|
||||
pub agents: Vec<Agent>,
|
||||
/// Resolved discoverable capabilities per agent.
|
||||
pub capabilities: Vec<ListedAgentCapabilities>,
|
||||
/// The manifest's resolved orchestrator.
|
||||
pub effective_orchestrator: Option<AgentId>,
|
||||
}
|
||||
|
||||
/// Resolved capabilities for one listed agent.
|
||||
@ -301,6 +306,8 @@ pub struct AgentDiscoveryEntry {
|
||||
pub agent: Agent,
|
||||
/// Resolved capability affordances.
|
||||
pub capabilities: Vec<AgentCapability>,
|
||||
/// Whether this agent is the project's current orchestrator.
|
||||
pub is_orchestrator: bool,
|
||||
}
|
||||
|
||||
impl ListAgentsOutput {
|
||||
@ -318,6 +325,7 @@ impl ListAgentsOutput {
|
||||
.map(|entry| entry.capabilities.clone())
|
||||
.unwrap_or_default();
|
||||
AgentDiscoveryEntry {
|
||||
is_orchestrator: self.effective_orchestrator == Some(agent.id),
|
||||
agent,
|
||||
capabilities,
|
||||
}
|
||||
@ -390,6 +398,7 @@ impl ListAgents {
|
||||
Ok(ListAgentsOutput {
|
||||
agents,
|
||||
capabilities,
|
||||
effective_orchestrator: manifest.effective_orchestrator(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -480,6 +489,74 @@ impl UpdateAgentContext {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UpdateAgentEffort
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`UpdateAgentEffort::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateAgentEffortInput {
|
||||
/// The owning project.
|
||||
pub project: Project,
|
||||
/// The agent whose effort override changes.
|
||||
pub agent_id: AgentId,
|
||||
/// `None` clears the override, falling back to the profile default.
|
||||
pub effort: Option<EffortSelection>,
|
||||
}
|
||||
|
||||
/// Output of [`UpdateAgentEffort::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateAgentEffortOutput {
|
||||
/// The updated agent.
|
||||
pub agent: Agent,
|
||||
}
|
||||
|
||||
/// Sets or clears an agent's per-agent effort override in the manifest.
|
||||
///
|
||||
/// The change applies at the agent's next launch; live sessions are not mutated.
|
||||
pub struct UpdateAgentEffort {
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
}
|
||||
|
||||
impl UpdateAgentEffort {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(contexts: Arc<dyn AgentContextStore>) -> Self {
|
||||
Self { contexts }
|
||||
}
|
||||
|
||||
/// Executes the update.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if the agent is unknown to the project,
|
||||
/// - [`AppError::Invalid`] if the resulting manifest is invalid,
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: UpdateAgentEffortInput,
|
||||
) -> Result<UpdateAgentEffortOutput, AppError> {
|
||||
let mut manifest = self.contexts.load_manifest(&input.project).await?;
|
||||
let entry = manifest
|
||||
.entries
|
||||
.iter_mut()
|
||||
.find(|entry| entry.agent_id == input.agent_id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
|
||||
|
||||
let agent = entry
|
||||
.to_agent()
|
||||
.map_err(|err| AppError::Invalid(err.to_string()))?
|
||||
.with_effort(input.effort);
|
||||
*entry = ManifestEntry::from_agent(&agent);
|
||||
|
||||
let manifest = AgentManifest::new(manifest.version, manifest.entries)
|
||||
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
||||
self.contexts
|
||||
.save_manifest(&input.project, &manifest)
|
||||
.await?;
|
||||
Ok(UpdateAgentEffortOutput { agent })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ChangeAgentProfile
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -2017,6 +2094,12 @@ impl LaunchAgent {
|
||||
network_permission,
|
||||
&input.project.root,
|
||||
);
|
||||
let resolved_effort = resolve_effort(
|
||||
profile.model_reasoning_effort.as_deref(),
|
||||
agent.effort.as_ref(),
|
||||
);
|
||||
let mut launch_profile = profile.clone();
|
||||
launch_profile.model_reasoning_effort = resolved_effort;
|
||||
|
||||
// 5b. ── POINT DE ROUTAGE §17.4 : IA structuré vs terminal brut ──
|
||||
// L'intention est explicite sur le launcher : les cellules humaines peuvent
|
||||
@ -2053,7 +2136,7 @@ impl LaunchAgent {
|
||||
factory.as_ref(),
|
||||
structured,
|
||||
&agent,
|
||||
&profile,
|
||||
&launch_profile,
|
||||
&prepared,
|
||||
&run_dir,
|
||||
&session_plan,
|
||||
@ -2083,7 +2166,7 @@ impl LaunchAgent {
|
||||
// CODEX_HOME isolé. Passer le modèle du profil sur l'argv garantit que le
|
||||
// lancement interactif respecte l'édition IdeA, comme le chemin structuré
|
||||
// le fait déjà dans `CodexExecSession`.
|
||||
append_codex_pty_model_overrides(&profile, &mut spec);
|
||||
append_codex_pty_model_overrides(&launch_profile, &mut spec);
|
||||
|
||||
// 6. Spawn the PTY at the resolved cwd; adopt its session id everywhere.
|
||||
let handle = self.pty.spawn(spec.clone(), size).await?;
|
||||
@ -3440,8 +3523,7 @@ fn append_block(input: &str, block: &str) -> String {
|
||||
/// Composes the convention file IdeA writes into an agent's run directory: an
|
||||
/// absolute project-root header (the agent's cwd is the run dir, *not* the root,
|
||||
/// so it must be told where to work), the IdeA orchestration contract, the
|
||||
/// agent's persona `.md`, then the bodies of its assigned `skills` under a
|
||||
/// `# Skills` section (ARCHITECTURE §14.2).
|
||||
/// agent's persona `.md` (ARCHITECTURE §14.2).
|
||||
///
|
||||
/// A short skill-awareness paragraph is always injected in the orchestration
|
||||
/// block (followed by the auto-memory harvest directive, Lot E1): it explains that
|
||||
@ -3451,18 +3533,12 @@ fn append_block(input: &str, block: &str) -> String {
|
||||
/// protocol). This awareness deliberately does not inject unassigned skill bodies;
|
||||
/// assignment remains the context boundary.
|
||||
///
|
||||
/// On top of that awareness, the assigned skills surface in one of two ways
|
||||
/// depending on the agent's **surface** (feature « skills à la MCP »), always in
|
||||
/// the given (manifest) order — making the output deterministic:
|
||||
/// - **MCP mode** (`mcp_enabled`): a high-altitude `# Skills disponibles` section,
|
||||
/// right after the orchestration block, listing each as
|
||||
/// `**<name>** — <effective description> (<kind>)` (affordances only, *no body*), with
|
||||
/// prose pointing to `idea_skill_read` to load a body on demand. Respects the
|
||||
/// altitude: the capability is exposed, never the skill content.
|
||||
/// - **Non-MCP mode**: the legacy `# Skills` section dumping each body in full
|
||||
/// under a `##` header carrying its name (unchanged — zero regression).
|
||||
/// When `skills` is empty both sections are omitted entirely, so an agent with no
|
||||
/// skills gets exactly the previous document.
|
||||
/// On top of that awareness, assigned skills surface as a high-altitude
|
||||
/// `# Skills disponibles` section right after the orchestration block, listing
|
||||
/// each as `**<name>** — <effective description> (<kind>)` (affordances only,
|
||||
/// *no body*). This bounded surface is shared by MCP and non-MCP profiles; only
|
||||
/// the instruction for loading details is adapted to the active runtime surface.
|
||||
/// When `skills` is empty the section is omitted entirely.
|
||||
///
|
||||
/// The project's `memory` recall (index/hooks, ARCHITECTURE §14.5.4) is appended as
|
||||
/// a `# Mémoire projet` section — one `- [Title](slug.md) — hook (type)` line per
|
||||
@ -3576,22 +3652,28 @@ pub(crate) fn compose_convention_file(
|
||||
out.push_str(memory_awareness());
|
||||
out.push_str("---\n\n");
|
||||
|
||||
// Skills « à la MCP » (feature skill-awareness) : à HAUTE ALTITUDE, juste après
|
||||
// le bloc d'orchestration. On expose les skills assignés comme des **affordances
|
||||
// nommées+décrites** (et NON leur corps complet), à la manière des outils MCP,
|
||||
// pour que l'agent sache qu'ils existent et charge le détail à la demande via
|
||||
// `idea_skill_read`. Réservé au mode MCP (le mode sans MCP conserve l'ancien dump
|
||||
// du corps complet en fin de fichier, plus bas). Omis si zéro skill.
|
||||
if mcp_enabled && !effective.agent_capabilities.is_empty() {
|
||||
// Skills « capability-first » : à HAUTE ALTITUDE, juste après le bloc
|
||||
// d'orchestration. On expose les skills assignés comme des affordances
|
||||
// nommées+décrites (et NON leur corps complet), à la manière des outils MCP.
|
||||
if !effective.agent_capabilities.is_empty() {
|
||||
out.push_str("# Skills disponibles\n\n");
|
||||
out.push_str("Snapshot version: ");
|
||||
out.push_str(&effective.capabilities.version.to_string());
|
||||
out.push_str("\n\n");
|
||||
out.push_str(
|
||||
"Les skills suivants te sont assignés. Pour en exécuter un ou consulter son \
|
||||
détail, appelle l'outil `idea_skill_read(name=…)` — n'improvise pas un \
|
||||
workflow déjà couvert par un skill, charge-le.\n\n",
|
||||
);
|
||||
if mcp_enabled {
|
||||
out.push_str(
|
||||
"Les skills suivants te sont assignés. Pour en exécuter un ou consulter son \
|
||||
détail, appelle l'outil `idea_skill_read(name=…)` — n'improvise pas un \
|
||||
workflow déjà couvert par un skill, charge-le.\n\n",
|
||||
);
|
||||
} else {
|
||||
out.push_str(
|
||||
"Les skills suivants te sont assignés. Pour en exécuter un ou consulter son \
|
||||
détail, lis le fichier `.ideai/skills/md/<skill-id>.md` correspondant au \
|
||||
`skillId` assigné dans le manifeste — n'improvise pas un workflow déjà \
|
||||
couvert par un skill, charge-le.\n\n",
|
||||
);
|
||||
}
|
||||
for skill in &effective.agent_capabilities {
|
||||
out.push_str("**");
|
||||
out.push_str(&skill.name);
|
||||
@ -3616,24 +3698,6 @@ pub(crate) fn compose_convention_file(
|
||||
|
||||
out.push_str(effective.persona.as_str());
|
||||
|
||||
// MODE SANS MCP (exigence zéro régression, décision produit 4.2(b)) : on conserve
|
||||
// l'ancien dump du **corps complet** des skills en fin de fichier. En mode MCP, le
|
||||
// corps n'est PAS injecté ici (l'agent le charge à la demande via `idea_skill_read`,
|
||||
// cf. la section « # Skills disponibles » à haute altitude plus haut).
|
||||
if !effective.assigned_skills.is_empty() && !mcp_enabled {
|
||||
out.push_str("\n\n---\n\n# Skills\n");
|
||||
out.push_str("\nSnapshot version: ");
|
||||
out.push_str(&effective.capabilities.version.to_string());
|
||||
out.push('\n');
|
||||
for skill in &effective.assigned_skills {
|
||||
out.push_str("\n## ");
|
||||
out.push_str(&skill.snapshot.name);
|
||||
out.push_str("\n\n");
|
||||
out.push_str(skill.content.as_str());
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
if !effective.memory.is_empty() {
|
||||
out.push_str("\n\n---\n\n# Mémoire projet\n\n");
|
||||
for entry in &effective.memory {
|
||||
@ -3891,7 +3955,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_convention_file_appends_assigned_skills_in_order() {
|
||||
fn compose_convention_file_appends_assigned_skill_affordances_in_order() {
|
||||
let s = |n: u128, name: &str, body: &str| {
|
||||
Skill::new(
|
||||
domain::SkillId::from_uuid(uuid::Uuid::from_u128(n)),
|
||||
@ -3915,28 +3979,28 @@ mod tests {
|
||||
false,
|
||||
);
|
||||
|
||||
// Both skill bodies present, after the persona.
|
||||
assert!(doc.contains("REFAC_BODY"));
|
||||
assert!(doc.contains("REVIEW_BODY"));
|
||||
// Both skill affordances are present, but bodies are not dumped.
|
||||
assert!(doc.contains("**refactor** — REFAC_BODY (workflow)"));
|
||||
assert!(doc.contains("**review** — REVIEW_BODY (workflow)"));
|
||||
assert!(!doc.contains("\n\nREFAC_BODY"));
|
||||
assert!(!doc.contains("\n\nREVIEW_BODY"));
|
||||
let awareness_at = doc.find("**Skills IdeA**").unwrap();
|
||||
let skills_at = doc.find("# Skills disponibles").unwrap();
|
||||
let persona_at = doc.find("# Persona").unwrap();
|
||||
let skills_at = doc.find("\n# Skills\n").unwrap();
|
||||
let refac_at = doc.find("REFAC_BODY").unwrap();
|
||||
let review_at = doc.find("REVIEW_BODY").unwrap();
|
||||
let refac_at = doc.find("**refactor**").unwrap();
|
||||
let review_at = doc.find("**review**").unwrap();
|
||||
assert!(
|
||||
awareness_at < persona_at,
|
||||
"skill awareness belongs to orchestration, before persona"
|
||||
);
|
||||
assert!(
|
||||
persona_at < skills_at && skills_at < refac_at,
|
||||
"assigned skill bodies come under the Skills section after persona"
|
||||
awareness_at < skills_at && skills_at < persona_at,
|
||||
"assigned skill affordances come before persona"
|
||||
);
|
||||
assert!(persona_at < refac_at, "skills come after the persona");
|
||||
// Deterministic order: first assigned skill precedes the second.
|
||||
assert!(refac_at < review_at, "skills emitted in the given order");
|
||||
// Skill names surface as sub-headers.
|
||||
assert!(doc.contains("## refactor"));
|
||||
assert!(doc.contains("## review"));
|
||||
assert!(!doc.contains("## refactor"));
|
||||
assert!(!doc.contains("## review"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -4048,10 +4112,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_convention_file_mcp_mode_exposes_skill_affordances_not_bodies() {
|
||||
// MCP mode (feature « skills à la MCP »): a high-altitude `# Skills disponibles`
|
||||
fn compose_convention_file_exposes_skill_affordances_not_bodies() {
|
||||
// Both surfaces: a high-altitude `# Skills disponibles`
|
||||
// section after the Orchestration block, listing `**name** — description`
|
||||
// affordances and pointing to `idea_skill_read`, WITHOUT dumping the bodies.
|
||||
// affordances, WITHOUT dumping the bodies.
|
||||
let s = |n: u128, name: &str, desc: Option<&str>, body: &str| {
|
||||
Skill::new(
|
||||
domain::SkillId::from_uuid(uuid::Uuid::from_u128(n)),
|
||||
@ -4062,44 +4126,47 @@ mod tests {
|
||||
.unwrap()
|
||||
.with_description(desc.map(str::to_owned))
|
||||
};
|
||||
let doc = compose_convention_file(
|
||||
"/root",
|
||||
"",
|
||||
"# Persona",
|
||||
&[
|
||||
s(1, "refactor", Some("Refactors code"), "REFAC_BODY"),
|
||||
// No explicit description ⇒ effective_description falls back to the
|
||||
// body's first line (heading marker stripped).
|
||||
s(2, "review", None, "# Review skill\n\nREVIEW_BODY"),
|
||||
],
|
||||
&[],
|
||||
None,
|
||||
&[],
|
||||
true, // mcp_enabled
|
||||
);
|
||||
for mcp_enabled in [true, false] {
|
||||
let doc = compose_convention_file(
|
||||
"/root",
|
||||
"",
|
||||
"# Persona",
|
||||
&[
|
||||
s(1, "refactor", Some("Refactors code"), "REFAC_BODY"),
|
||||
// No explicit description ⇒ effective_description falls back to the
|
||||
// body's first line (heading marker stripped).
|
||||
s(2, "review", None, "# Review skill\n\nREVIEW_BODY"),
|
||||
],
|
||||
&[],
|
||||
None,
|
||||
&[],
|
||||
mcp_enabled,
|
||||
);
|
||||
|
||||
// The affordance section is present.
|
||||
assert!(
|
||||
doc.contains("# Skills disponibles"),
|
||||
"MCP skills section present"
|
||||
);
|
||||
assert!(doc.contains("idea_skill_read"), "points to the read tool");
|
||||
// Affordance lines: `**name** — <effective description> (<kind>)`.
|
||||
assert!(doc.contains("**refactor** — Refactors code (workflow)"));
|
||||
assert!(doc.contains("**review** — Review skill (workflow)"));
|
||||
// The full bodies are NOT injected in MCP mode (loaded on demand instead).
|
||||
assert!(!doc.contains("REFAC_BODY"), "no full body in MCP mode");
|
||||
assert!(!doc.contains("REVIEW_BODY"), "no full body in MCP mode");
|
||||
// The legacy `## <name>` body dump headers are absent too.
|
||||
assert!(!doc.contains("## refactor"));
|
||||
assert!(
|
||||
doc.contains("# Skills disponibles"),
|
||||
"skills section present (mcp_enabled={mcp_enabled})"
|
||||
);
|
||||
if mcp_enabled {
|
||||
assert!(doc.contains("idea_skill_read"), "points to the read tool");
|
||||
} else {
|
||||
assert!(
|
||||
doc.contains(".ideai/skills/md/<skill-id>.md"),
|
||||
"points to the file-protocol read path"
|
||||
);
|
||||
}
|
||||
assert!(doc.contains("**refactor** — Refactors code (workflow)"));
|
||||
assert!(doc.contains("**review** — Review skill (workflow)"));
|
||||
assert!(!doc.contains("REFAC_BODY"), "no full body is injected");
|
||||
assert!(!doc.contains("REVIEW_BODY"), "no full body is injected");
|
||||
assert!(!doc.contains("## refactor"));
|
||||
|
||||
// The section sits at high altitude: after the Orchestration block, before
|
||||
// the persona.
|
||||
let orch_at = doc.find("# Orchestration IdeA").unwrap();
|
||||
let skills_at = doc.find("# Skills disponibles").unwrap();
|
||||
let persona_at = doc.find("# Persona").unwrap();
|
||||
assert!(orch_at < skills_at, "skills come after orchestration");
|
||||
assert!(skills_at < persona_at, "skills come before the persona");
|
||||
let orch_at = doc.find("# Orchestration IdeA").unwrap();
|
||||
let skills_at = doc.find("# Skills disponibles").unwrap();
|
||||
let persona_at = doc.find("# Persona").unwrap();
|
||||
assert!(orch_at < skills_at, "skills come after orchestration");
|
||||
assert!(skills_at < persona_at, "skills come before the persona");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -37,8 +37,9 @@ pub use lifecycle::{
|
||||
LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, ListedAgentCapabilities,
|
||||
LiveStateLeanProvider, McpRuntime, PermissionProjectorRegistry, ProviderSessionProvider,
|
||||
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredRoutingMode,
|
||||
StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput,
|
||||
AGENT_MEMORY_RECALL_BUDGET, DEFAULT_OPENCODE_MCP_TIMEOUT_MS, LIVE_STATE_INJECT_MAX,
|
||||
StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort,
|
||||
UpdateAgentEffortInput, UpdateAgentEffortOutput, AGENT_MEMORY_RECALL_BUDGET,
|
||||
DEFAULT_OPENCODE_MCP_TIMEOUT_MS, LIVE_STATE_INJECT_MAX,
|
||||
};
|
||||
pub use model_catalogue::{
|
||||
claude_model_catalogue, codex_model_catalogue, ListClaudeModels, ListClaudeModelsOutput,
|
||||
|
||||
@ -26,6 +26,11 @@ pub enum AppError {
|
||||
#[error("invalid input: {0}")]
|
||||
Invalid(String),
|
||||
|
||||
/// An optimistic-concurrency `if_match` did not match the resource's current
|
||||
/// version. Carries the current version so the caller can retry.
|
||||
#[error("concurrency conflict: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
/// A filesystem operation failed.
|
||||
#[error("filesystem error: {0}")]
|
||||
FileSystem(String),
|
||||
@ -107,6 +112,7 @@ impl AppError {
|
||||
match self {
|
||||
Self::NotFound(_) => "NOT_FOUND",
|
||||
Self::Invalid(_) => "INVALID",
|
||||
Self::Conflict(_) => "CONFLICT",
|
||||
Self::FileSystem(_) => "FILESYSTEM",
|
||||
Self::Store(_) => "STORE",
|
||||
Self::Process(_) => "PROCESS",
|
||||
|
||||
@ -61,7 +61,8 @@ pub use agent::{
|
||||
ResumableAgent, SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput,
|
||||
SaveOpenCodeProviderProfileOutput, SaveProfile, SaveProfileInput, SaveProfileOutput,
|
||||
SessionLimitService, StructuredRoutingMode, StructuredSessionDescriptor, TurnOutcome,
|
||||
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS,
|
||||
UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort, UpdateAgentEffortInput,
|
||||
UpdateAgentEffortOutput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS,
|
||||
LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
|
||||
};
|
||||
pub use background::{
|
||||
@ -162,16 +163,16 @@ pub use plugin::{
|
||||
PluginEventPollInput, PluginEventSubscribeInput, PluginEventSubscription,
|
||||
PluginEventSubscriptions, PluginEventUnsubscribeInput, PluginFileDiagnostic,
|
||||
PluginFileRequirement, PluginInstallResult, PluginPublicEvent, PluginReview,
|
||||
PluginRunCommandInput, PluginRuntimeCatalog, PluginRuntimePlugin, PluginTaskStatusInput,
|
||||
PluginToolDiagnostic, PluginToolRequirement, PluginToolchainDiagnostic,
|
||||
PluginToolchainDiagnosticInput, PluginToolchainDiagnostics, PluginWorkspaceAccess,
|
||||
PluginWorkspaceBinaryFile, PluginWorkspaceDirEntry, PluginWorkspaceDirectoryListing,
|
||||
PluginWorkspacePathInput, PluginWorkspaceStat, PluginWorkspaceTextFile,
|
||||
PluginWorkspaceWriteBinaryInput, PluginWorkspaceWriteTextInput, ProjectConvention,
|
||||
ProjectModule, ProjectStructureEntry, ProjectStructureQuery, QueryProjectStructure,
|
||||
QueryProjectStructureInput, ReconcilePluginMcpServers, ReviewPluginPackage,
|
||||
ReviewPluginPackageInput, SetPluginEnabled, SetPluginEnabledInput, UninstallPlugin,
|
||||
UninstallPluginInput, UninstallPluginResult,
|
||||
PluginRunCommandInput, PluginRuntimeCatalog, PluginRuntimePlugin, PluginStorageAccess,
|
||||
PluginStorageGetInput, PluginStorageSetInput, PluginTaskStatusInput, PluginToolDiagnostic,
|
||||
PluginToolRequirement, PluginToolchainDiagnostic, PluginToolchainDiagnosticInput,
|
||||
PluginToolchainDiagnostics, PluginWorkspaceAccess, PluginWorkspaceBinaryFile,
|
||||
PluginWorkspaceDirEntry, PluginWorkspaceDirectoryListing, PluginWorkspacePathInput,
|
||||
PluginWorkspaceStat, PluginWorkspaceTextFile, PluginWorkspaceWriteBinaryInput,
|
||||
PluginWorkspaceWriteTextInput, ProjectConvention, ProjectModule, ProjectStructureEntry,
|
||||
ProjectStructureQuery, QueryProjectStructure, QueryProjectStructureInput,
|
||||
ReconcilePluginMcpServers, ReviewPluginPackage, ReviewPluginPackageInput, SetPluginEnabled,
|
||||
SetPluginEnabledInput, UninstallPlugin, UninstallPluginInput, UninstallPluginResult,
|
||||
};
|
||||
pub use project::{
|
||||
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
|
||||
|
||||
@ -29,8 +29,9 @@ use domain::conversation::ConversationParty;
|
||||
use domain::fileguard::{may_write_directly, FileGuard, GuardError, GuardedResource};
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType};
|
||||
use domain::ports::{AgentContextStore, Clock, FileSystem, MemoryStore, RemotePath};
|
||||
use domain::{AgentId, Project};
|
||||
use domain::ports::{AgentContextStore, Clock, EventBus, FileSystem, MemoryStore, RemotePath};
|
||||
use domain::{AgentId, DomainEvent, Project};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
@ -45,6 +46,16 @@ fn join_root(project: &Project, rel: &str) -> RemotePath {
|
||||
RemotePath::new(format!("{base}/{rel}"))
|
||||
}
|
||||
|
||||
pub(crate) fn hex_sha256(bytes: &[u8]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(bytes);
|
||||
hex_encode(&hasher.finalize())
|
||||
}
|
||||
|
||||
fn hex_encode(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Resolves an agent display name to its [`AgentId`] via the project manifest
|
||||
/// (case-insensitive), or [`AppError::NotFound`].
|
||||
async fn resolve_agent(
|
||||
@ -80,6 +91,14 @@ pub struct ReadContextInput {
|
||||
pub requester: ConversationParty,
|
||||
}
|
||||
|
||||
/// Output of [`ReadContext`].
|
||||
pub struct ReadContextOutput {
|
||||
/// The context Markdown.
|
||||
pub content: MarkdownDoc,
|
||||
/// sha256 hex digest of `content`, only for the global project context.
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
impl ReadContext {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
@ -99,7 +118,7 @@ impl ReadContext {
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError`] when the agent/context does not exist or the store/fs fails.
|
||||
pub async fn execute(&self, input: ReadContextInput) -> Result<MarkdownDoc, AppError> {
|
||||
pub async fn execute(&self, input: ReadContextInput) -> Result<ReadContextOutput, AppError> {
|
||||
let ReadContextInput {
|
||||
project,
|
||||
target,
|
||||
@ -115,9 +134,13 @@ impl ReadContext {
|
||||
.map_err(map_guard_err)?;
|
||||
let path = join_root(&project, PROJECT_CONTEXT_FILE);
|
||||
let bytes = self.fs.read(&path).await?;
|
||||
let version = hex_sha256(&bytes);
|
||||
let text =
|
||||
String::from_utf8(bytes).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
Ok(MarkdownDoc::new(text))
|
||||
Ok(ReadContextOutput {
|
||||
content: MarkdownDoc::new(text),
|
||||
version: Some(version),
|
||||
})
|
||||
}
|
||||
Some(name) => {
|
||||
let agent = resolve_agent(&self.contexts, &project, &name).await?;
|
||||
@ -126,12 +149,114 @@ impl ReadContext {
|
||||
.acquire_read(requester, GuardedResource::AgentContext(agent))
|
||||
.await
|
||||
.map_err(map_guard_err)?;
|
||||
Ok(self.contexts.read_context(&project, &agent).await?)
|
||||
Ok(ReadContextOutput {
|
||||
content: self.contexts.read_context(&project, &agent).await?,
|
||||
version: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Directly updates the global project context. This is the strict, fail-loud
|
||||
/// counterpart to [`ProposeContext`]'s soft-degrading global branch.
|
||||
pub struct UpdateProjectContext {
|
||||
guard: Arc<dyn FileGuard>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
events: Arc<dyn EventBus>,
|
||||
clock: Arc<dyn Clock>,
|
||||
}
|
||||
|
||||
/// Input for [`UpdateProjectContext`].
|
||||
pub struct UpdateProjectContextInput {
|
||||
/// The project to write within.
|
||||
pub project: Project,
|
||||
/// New global project context Markdown.
|
||||
pub content: String,
|
||||
/// Optional expected current version.
|
||||
pub if_match: Option<String>,
|
||||
/// The writing party.
|
||||
pub requester: ConversationParty,
|
||||
}
|
||||
|
||||
/// Output of [`UpdateProjectContext`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateProjectContextOutput {
|
||||
/// sha256 hex digest of the newly written content.
|
||||
pub new_version: String,
|
||||
}
|
||||
|
||||
impl UpdateProjectContext {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
guard: Arc<dyn FileGuard>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
events: Arc<dyn EventBus>,
|
||||
clock: Arc<dyn Clock>,
|
||||
) -> Self {
|
||||
Self {
|
||||
guard,
|
||||
contexts,
|
||||
fs,
|
||||
events,
|
||||
clock,
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes the direct global-context update.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::Invalid`] when the requester is not allowed to write directly,
|
||||
/// - [`AppError::Conflict`] when `if_match` does not match current content,
|
||||
/// - [`AppError`] on store/fs failure.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: UpdateProjectContextInput,
|
||||
) -> Result<UpdateProjectContextOutput, AppError> {
|
||||
let UpdateProjectContextInput {
|
||||
project,
|
||||
content,
|
||||
if_match,
|
||||
requester,
|
||||
} = input;
|
||||
|
||||
let manifest = self.contexts.load_manifest(&project).await?;
|
||||
let designation = manifest.orchestrator_designation();
|
||||
let resource = GuardedResource::ProjectContext;
|
||||
if !may_write_directly(requester, &resource, &designation) {
|
||||
return Err(map_guard_err(GuardError::Forbidden));
|
||||
}
|
||||
|
||||
let _lease = self
|
||||
.guard
|
||||
.acquire_write(requester, resource)
|
||||
.await
|
||||
.map_err(map_guard_err)?;
|
||||
|
||||
let path = join_root(&project, PROJECT_CONTEXT_FILE);
|
||||
let current_bytes = self.fs.read(&path).await?;
|
||||
let current_version = hex_sha256(¤t_bytes);
|
||||
if let Some(expected) = if_match {
|
||||
if expected != current_version {
|
||||
return Err(AppError::Conflict(current_version));
|
||||
}
|
||||
}
|
||||
|
||||
self.fs.write(&path, content.as_bytes()).await?;
|
||||
let new_version = hex_sha256(content.as_bytes());
|
||||
self.events.publish(DomainEvent::ProjectContextUpdated {
|
||||
project_id: project.id,
|
||||
by: requester,
|
||||
at_ms: self.clock.now_millis(),
|
||||
});
|
||||
|
||||
Ok(UpdateProjectContextOutput { new_version })
|
||||
}
|
||||
}
|
||||
|
||||
/// Proposes new content for an IdeA-owned context under the [`FileGuard`].
|
||||
///
|
||||
/// For an **agent** context: a direct write under an exclusive write-lease. For the
|
||||
@ -393,7 +518,7 @@ mod tests {
|
||||
use domain::agent::{AgentManifest, ManifestEntry};
|
||||
use domain::conversation::ConversationParty;
|
||||
use domain::fileguard::{ReadLease, WriteLease};
|
||||
use domain::ports::{FsError, MemoryError, StoreError};
|
||||
use domain::ports::{EventStream, FsError, MemoryError, StoreError};
|
||||
use domain::project::ProjectPath;
|
||||
use domain::{ProfileId, ProjectId, RemoteRef};
|
||||
use std::collections::HashMap;
|
||||
@ -599,6 +724,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SpyBus(Mutex<Vec<DomainEvent>>);
|
||||
|
||||
impl SpyBus {
|
||||
fn events(&self) -> Vec<DomainEvent> {
|
||||
self.0.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventBus for SpyBus {
|
||||
fn publish(&self, event: DomainEvent) {
|
||||
self.0.lock().unwrap().push(event);
|
||||
}
|
||||
|
||||
fn subscribe(&self) -> EventStream {
|
||||
Box::new(std::iter::empty())
|
||||
}
|
||||
}
|
||||
|
||||
fn guard() -> Arc<dyn FileGuard> {
|
||||
Arc::new(TestGuard::default())
|
||||
}
|
||||
@ -619,6 +763,7 @@ mod tests {
|
||||
synchronized: false,
|
||||
synced_template_version: None,
|
||||
skills: Vec::new(),
|
||||
effort: None,
|
||||
}],
|
||||
},
|
||||
contexts: Mutex::new(contexts),
|
||||
@ -635,7 +780,7 @@ mod tests {
|
||||
contexts_with("Dev", agent, "# hello"),
|
||||
Arc::new(FakeFs::default()),
|
||||
);
|
||||
let md = uc
|
||||
let out = uc
|
||||
.execute(ReadContextInput {
|
||||
project: project(),
|
||||
target: Some("dev".to_owned()), // case-insensitive
|
||||
@ -643,7 +788,8 @@ mod tests {
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(md.as_str(), "# hello");
|
||||
assert_eq!(out.content.as_str(), "# hello");
|
||||
assert_eq!(out.version, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -658,7 +804,7 @@ mod tests {
|
||||
contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
|
||||
fs,
|
||||
);
|
||||
let md = uc
|
||||
let out = uc
|
||||
.execute(ReadContextInput {
|
||||
project: project(),
|
||||
target: None,
|
||||
@ -666,7 +812,8 @@ mod tests {
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(md.as_str(), "# project");
|
||||
assert_eq!(out.content.as_str(), "# project");
|
||||
assert_eq!(out.version, Some(hex_sha256(b"# project")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -749,6 +896,262 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_project_context_orchestrator_writes_directly_and_returns_new_version() {
|
||||
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# old".to_vec());
|
||||
let bus = Arc::new(SpyBus::default());
|
||||
let uc = UpdateProjectContext::new(
|
||||
guard(),
|
||||
contexts_with("Dev", agent, "x"),
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
Arc::clone(&bus) as Arc<dyn EventBus>,
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
|
||||
let out = uc
|
||||
.execute(UpdateProjectContextInput {
|
||||
project: project(),
|
||||
content: "# new".to_owned(),
|
||||
if_match: None,
|
||||
requester: ConversationParty::agent(agent),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.new_version, hex_sha256(b"# new"));
|
||||
assert_eq!(
|
||||
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
|
||||
b"# new"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_project_context_non_orchestrator_fails_loud_no_proposal_filed() {
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# old".to_vec());
|
||||
let uc = UpdateProjectContext::new(
|
||||
guard(),
|
||||
contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
Arc::new(SpyBus::default()),
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
|
||||
let err = uc
|
||||
.execute(UpdateProjectContextInput {
|
||||
project: project(),
|
||||
content: "# rejected".to_owned(),
|
||||
if_match: None,
|
||||
requester: agent_party(8),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.code(), "INVALID");
|
||||
let files = fs.files.lock().unwrap();
|
||||
assert_eq!(files.get("/tmp/demo/CLAUDE.md").unwrap(), b"# old");
|
||||
assert!(
|
||||
!files.keys().any(|path| path.contains("/.ideai/proposals/")),
|
||||
"strict update must fail loud, not file a proposal"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_project_context_if_match_mismatch_returns_conflict_with_current_version() {
|
||||
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# current".to_vec());
|
||||
let uc = UpdateProjectContext::new(
|
||||
guard(),
|
||||
contexts_with("Dev", agent, "x"),
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
Arc::new(SpyBus::default()),
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
|
||||
let err = uc
|
||||
.execute(UpdateProjectContextInput {
|
||||
project: project(),
|
||||
content: "# new".to_owned(),
|
||||
if_match: Some("stale".to_owned()),
|
||||
requester: ConversationParty::agent(agent),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err, AppError::Conflict(hex_sha256(b"# current")));
|
||||
assert_eq!(
|
||||
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
|
||||
b"# current"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_project_context_if_match_matching_succeeds() {
|
||||
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# old".to_vec());
|
||||
let uc = UpdateProjectContext::new(
|
||||
guard(),
|
||||
contexts_with("Dev", agent, "x"),
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
Arc::new(SpyBus::default()),
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
|
||||
let out = uc
|
||||
.execute(UpdateProjectContextInput {
|
||||
project: project(),
|
||||
content: "# new".to_owned(),
|
||||
if_match: Some(hex_sha256(b"# old")),
|
||||
requester: ConversationParty::agent(agent),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.new_version, hex_sha256(b"# new"));
|
||||
assert_eq!(
|
||||
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
|
||||
b"# new"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_project_context_no_if_match_is_last_write_wins() {
|
||||
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# previous".to_vec());
|
||||
let uc = UpdateProjectContext::new(
|
||||
guard(),
|
||||
contexts_with("Dev", agent, "x"),
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
Arc::new(SpyBus::default()),
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
|
||||
uc.execute(UpdateProjectContextInput {
|
||||
project: project(),
|
||||
content: "# latest".to_owned(),
|
||||
if_match: None,
|
||||
requester: ConversationParty::agent(agent),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
|
||||
b"# latest"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_project_context_publishes_project_context_updated_event() {
|
||||
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# old".to_vec());
|
||||
let bus = Arc::new(SpyBus::default());
|
||||
let uc = UpdateProjectContext::new(
|
||||
guard(),
|
||||
contexts_with("Dev", agent, "x"),
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
Arc::clone(&bus) as Arc<dyn EventBus>,
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
|
||||
uc.execute(UpdateProjectContextInput {
|
||||
project: project(),
|
||||
content: "# new".to_owned(),
|
||||
if_match: None,
|
||||
requester: ConversationParty::agent(agent),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
bus.events(),
|
||||
vec![DomainEvent::ProjectContextUpdated {
|
||||
project_id: project().id,
|
||||
by: ConversationParty::agent(agent),
|
||||
at_ms: 42,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_context_and_update_project_context_version_round_trip() {
|
||||
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# first".to_vec());
|
||||
let contexts = contexts_with("Dev", agent, "agent body");
|
||||
let reader = ReadContext::new(
|
||||
guard(),
|
||||
Arc::clone(&contexts),
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
);
|
||||
let updater = UpdateProjectContext::new(
|
||||
guard(),
|
||||
contexts,
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
Arc::new(SpyBus::default()),
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
|
||||
let version = reader
|
||||
.execute(ReadContextInput {
|
||||
project: project(),
|
||||
target: None,
|
||||
requester: ConversationParty::agent(agent),
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.version
|
||||
.unwrap();
|
||||
|
||||
updater
|
||||
.execute(UpdateProjectContextInput {
|
||||
project: project(),
|
||||
content: "# second".to_owned(),
|
||||
if_match: Some(version.clone()),
|
||||
requester: ConversationParty::agent(agent),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let stale = updater
|
||||
.execute(UpdateProjectContextInput {
|
||||
project: project(),
|
||||
content: "# third".to_owned(),
|
||||
if_match: Some(version),
|
||||
requester: ConversationParty::agent(agent),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(stale, AppError::Conflict(hex_sha256(b"# second")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn propose_agent_context_writes_directly() {
|
||||
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
|
||||
|
||||
@ -10,8 +10,9 @@ mod service;
|
||||
pub mod wake;
|
||||
|
||||
pub use context_guard::{
|
||||
ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory,
|
||||
ReadMemoryInput, WriteMemory, WriteMemoryInput,
|
||||
ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput,
|
||||
ReadContextOutput, ReadMemory, ReadMemoryInput, UpdateProjectContext,
|
||||
UpdateProjectContextInput, UpdateProjectContextOutput, WriteMemory, WriteMemoryInput,
|
||||
};
|
||||
pub use rendezvous::{
|
||||
resolve_rendezvous_ceiling, resolve_rendezvous_window, run_inactivity_watchdog,
|
||||
|
||||
@ -47,7 +47,8 @@ use crate::error::AppError;
|
||||
use crate::orchestrator::rendezvous::{run_inactivity_watchdog, WatchdogOutcome};
|
||||
use crate::orchestrator::{
|
||||
ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory,
|
||||
ReadMemoryInput, WriteMemory, WriteMemoryInput,
|
||||
ReadMemoryInput, UpdateProjectContext, UpdateProjectContextInput, WriteMemory,
|
||||
WriteMemoryInput,
|
||||
};
|
||||
use crate::skill::{CreateSkill, CreateSkillInput, ReadSkill, ReadSkillInput};
|
||||
use crate::terminal::{CloseTerminal, CloseTerminalInput, StructuredSessions, TerminalSessions};
|
||||
@ -534,6 +535,8 @@ pub struct ContextGuardUseCases {
|
||||
pub read_context: Arc<ReadContext>,
|
||||
/// Proposition/écriture d'un contexte `.md` IdeA sous le garde.
|
||||
pub propose_context: Arc<ProposeContext>,
|
||||
/// Écriture directe stricte du contexte projet global.
|
||||
pub update_project_context: Arc<UpdateProjectContext>,
|
||||
/// Lecture mémoire sous read-lease.
|
||||
pub read_memory: Arc<ReadMemory>,
|
||||
/// Écriture mémoire sous write-lease.
|
||||
@ -1241,6 +1244,14 @@ impl OrchestratorService {
|
||||
self.propose_context(project, target, content, requester)
|
||||
.await
|
||||
}
|
||||
OrchestratorCommand::UpdateProjectContext {
|
||||
content,
|
||||
if_match,
|
||||
requester,
|
||||
} => {
|
||||
self.update_project_context(project, content, if_match, requester)
|
||||
.await
|
||||
}
|
||||
OrchestratorCommand::ReadMemory { slug, requester } => {
|
||||
self.read_memory(project, slug, requester).await
|
||||
}
|
||||
@ -1363,7 +1374,7 @@ impl OrchestratorService {
|
||||
target: Option<String>,
|
||||
requester: ConversationParty,
|
||||
) -> Result<OrchestratorOutcome, AppError> {
|
||||
let md = self
|
||||
let out = self
|
||||
.require_context_guard()?
|
||||
.read_context
|
||||
.execute(ReadContextInput {
|
||||
@ -1372,9 +1383,13 @@ impl OrchestratorService {
|
||||
requester,
|
||||
})
|
||||
.await?;
|
||||
let mut text = out.content.into_string();
|
||||
if let Some(version) = &out.version {
|
||||
text.push_str(&format!("\n\n<!-- idea-context-version: {version} -->"));
|
||||
}
|
||||
Ok(OrchestratorOutcome {
|
||||
detail: format!("read {} context", target.as_deref().unwrap_or("project")),
|
||||
reply: Some(md.into_string()),
|
||||
reply: Some(text),
|
||||
})
|
||||
}
|
||||
|
||||
@ -1411,6 +1426,31 @@ impl OrchestratorService {
|
||||
})
|
||||
}
|
||||
|
||||
/// `context.update` → strict direct write of the global project context.
|
||||
async fn update_project_context(
|
||||
&self,
|
||||
project: &Project,
|
||||
content: String,
|
||||
if_match: Option<String>,
|
||||
requester: ConversationParty,
|
||||
) -> Result<OrchestratorOutcome, AppError> {
|
||||
let out = self
|
||||
.require_context_guard()?
|
||||
.update_project_context
|
||||
.execute(UpdateProjectContextInput {
|
||||
project: project.clone(),
|
||||
content,
|
||||
if_match,
|
||||
requester,
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(OrchestratorOutcome {
|
||||
detail: format!("wrote project context (version {})", out.new_version),
|
||||
reply: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `memory.read` → reads a note (or the index) under a shared read-lease; the
|
||||
/// content is returned inline in the outcome's `reply`.
|
||||
async fn read_memory(
|
||||
@ -2783,6 +2823,7 @@ impl OrchestratorService {
|
||||
description: None,
|
||||
content,
|
||||
scope,
|
||||
kind: domain::SkillKind::Workflow,
|
||||
project_root: project.root.clone(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
@ -7,7 +7,10 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::PermissionStore;
|
||||
use domain::{AgentId, EffectivePermissions, PermissionSet, Project, ProjectPermissions};
|
||||
use domain::{
|
||||
AgentId, EffectivePermissions, PermissionSet, PermissionShadowReport, Project,
|
||||
ProjectPermissions,
|
||||
};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
@ -131,6 +134,7 @@ impl ResolveAgentPermissions {
|
||||
let doc = self.store.load_permissions(&input.project).await?;
|
||||
Ok(ResolveAgentPermissionsOutput {
|
||||
effective: doc.resolve_for(input.agent_id),
|
||||
shadowed: doc.shadow_for(input.agent_id),
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -147,4 +151,6 @@ pub struct ResolveAgentPermissionsInput {
|
||||
pub struct ResolveAgentPermissionsOutput {
|
||||
/// Resolved policy, or `None` when neither project nor agent policy exists.
|
||||
pub effective: Option<EffectivePermissions>,
|
||||
/// Diagnostic report for agent-level allows shadowed by project defaults.
|
||||
pub shadowed: PermissionShadowReport,
|
||||
}
|
||||
|
||||
@ -8,8 +8,8 @@ use domain::ports::{
|
||||
BackgroundTaskStore, Clock, DirEntry, EventBus, FileMetadata, FileSystem, IdGenerator,
|
||||
LocalPath, Output, PluginManifestBytes, PluginManifestError, PluginManifestValidator,
|
||||
PluginMcpError, PluginMcpSupervisor, PluginPackageStore, PluginRegistryError,
|
||||
PluginRegistryStore, PluginStoreError, ProcessError, ProcessSpawner, ProjectStore, RemotePath,
|
||||
SpawnSpec,
|
||||
PluginRegistryStore, PluginStorageError, PluginStorageStore, PluginStoreError, ProcessError,
|
||||
ProcessSpawner, ProjectStore, RemotePath, SpawnSpec,
|
||||
};
|
||||
use domain::{
|
||||
AgentId, BackgroundTask, BackgroundTaskState, BackgroundTaskWakePolicy, ContentHash,
|
||||
@ -157,6 +157,28 @@ pub struct PluginRuntimePlugin {
|
||||
pub contributes: PluginContributionSet,
|
||||
}
|
||||
|
||||
/// Input for plugin-owned storage reads/deletes.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginStorageGetInput {
|
||||
/// Plugin id owning the value.
|
||||
pub plugin_id: String,
|
||||
/// Plugin-owned key.
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
/// Input for plugin-owned storage writes.
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginStorageSetInput {
|
||||
/// Plugin id owning the value.
|
||||
pub plugin_id: String,
|
||||
/// Plugin-owned key.
|
||||
pub key: String,
|
||||
/// JSON value to persist.
|
||||
pub value: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Input for reviewing a package.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ReviewPluginPackageInput {
|
||||
@ -2023,6 +2045,14 @@ fn map_store(e: PluginStoreError) -> AppError {
|
||||
}
|
||||
}
|
||||
|
||||
fn map_storage(e: PluginStorageError) -> AppError {
|
||||
match e {
|
||||
PluginStorageError::Invalid(m) => AppError::Invalid(m),
|
||||
PluginStorageError::Io(m) => AppError::FileSystem(m),
|
||||
PluginStorageError::Serialization(m) => AppError::Store(m),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_registry(e: PluginRegistryError) -> AppError {
|
||||
match e {
|
||||
PluginRegistryError::Io(m) => AppError::Store(m),
|
||||
@ -2516,9 +2546,91 @@ pub struct UninstallPluginInput {
|
||||
pub plugin_id: String,
|
||||
}
|
||||
|
||||
/// Plugin-owned key/value storage facade.
|
||||
pub struct PluginStorageAccess {
|
||||
storage: Arc<dyn PluginStorageStore>,
|
||||
registry: Arc<dyn PluginRegistryStore>,
|
||||
}
|
||||
|
||||
impl PluginStorageAccess {
|
||||
/// Builds the facade.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
storage: Arc<dyn PluginStorageStore>,
|
||||
registry: Arc<dyn PluginRegistryStore>,
|
||||
) -> Self {
|
||||
Self { storage, registry }
|
||||
}
|
||||
|
||||
/// Reads one plugin-owned JSON value.
|
||||
pub async fn get(
|
||||
&self,
|
||||
input: PluginStorageGetInput,
|
||||
) -> Result<Option<serde_json::Value>, AppError> {
|
||||
let plugin_id = self.active_plugin_id(input.plugin_id).await?;
|
||||
validate_storage_key(&input.key)?;
|
||||
self.storage
|
||||
.get(&plugin_id, &input.key)
|
||||
.await
|
||||
.map_err(map_storage)
|
||||
}
|
||||
|
||||
/// Writes one plugin-owned JSON value.
|
||||
pub async fn set(&self, input: PluginStorageSetInput) -> Result<(), AppError> {
|
||||
let plugin_id = self.active_plugin_id(input.plugin_id).await?;
|
||||
validate_storage_key(&input.key)?;
|
||||
self.storage
|
||||
.set(&plugin_id, &input.key, input.value)
|
||||
.await
|
||||
.map_err(map_storage)
|
||||
}
|
||||
|
||||
/// Deletes one plugin-owned JSON value.
|
||||
pub async fn delete(&self, input: PluginStorageGetInput) -> Result<bool, AppError> {
|
||||
let plugin_id = self.active_plugin_id(input.plugin_id).await?;
|
||||
validate_storage_key(&input.key)?;
|
||||
self.storage
|
||||
.delete(&plugin_id, &input.key)
|
||||
.await
|
||||
.map_err(map_storage)
|
||||
}
|
||||
|
||||
async fn active_plugin_id(&self, raw: String) -> Result<PluginId, AppError> {
|
||||
let plugin_id = PluginId::new(raw).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
let registry = self.registry.load_registry().await.map_err(map_registry)?;
|
||||
let entry = registry
|
||||
.find(&plugin_id)
|
||||
.ok_or_else(|| AppError::NotFound("plugin".to_owned()))?;
|
||||
if !entry.lifecycle_state.is_runtime_active() {
|
||||
return Err(AppError::Invalid("plugin is not runtime-active".to_owned()));
|
||||
}
|
||||
Ok(plugin_id)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_storage_key(key: &str) -> Result<(), AppError> {
|
||||
if key.trim().is_empty() {
|
||||
return Err(AppError::Invalid(
|
||||
"plugin storage key must not be empty".to_owned(),
|
||||
));
|
||||
}
|
||||
if key.len() > 512 {
|
||||
return Err(AppError::Invalid(
|
||||
"plugin storage key must not exceed 512 bytes".to_owned(),
|
||||
));
|
||||
}
|
||||
if key.contains('\0') {
|
||||
return Err(AppError::Invalid(
|
||||
"plugin storage key must not contain NUL bytes".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Uninstalls a plugin.
|
||||
pub struct UninstallPlugin {
|
||||
packages: Arc<dyn PluginPackageStore>,
|
||||
storage: Arc<dyn PluginStorageStore>,
|
||||
registry: Arc<dyn PluginRegistryStore>,
|
||||
events: Arc<dyn EventBus>,
|
||||
mcp: Arc<dyn PluginMcpSupervisor>,
|
||||
@ -2529,12 +2641,14 @@ impl UninstallPlugin {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
packages: Arc<dyn PluginPackageStore>,
|
||||
storage: Arc<dyn PluginStorageStore>,
|
||||
registry: Arc<dyn PluginRegistryStore>,
|
||||
events: Arc<dyn EventBus>,
|
||||
mcp: Arc<dyn PluginMcpSupervisor>,
|
||||
) -> Self {
|
||||
Self {
|
||||
packages,
|
||||
storage,
|
||||
registry,
|
||||
events,
|
||||
mcp,
|
||||
@ -2562,6 +2676,10 @@ impl UninstallPlugin {
|
||||
.remove_package(&plugin_id)
|
||||
.await
|
||||
.map_err(map_store)?;
|
||||
self.storage
|
||||
.purge_plugin(&plugin_id)
|
||||
.await
|
||||
.map_err(map_storage)?;
|
||||
self.events.publish(DomainEvent::PluginUninstalled {
|
||||
plugin_id: plugin_id.clone(),
|
||||
restart_required: true,
|
||||
@ -3214,7 +3332,8 @@ mod tests {
|
||||
use domain::ports::{
|
||||
BackgroundCompletionStream, BackgroundTaskHandle, BackgroundTaskPortError,
|
||||
BackgroundTaskRunner, BackgroundTaskSpec, EventStream, FileMetadata, IdGenerator,
|
||||
PluginPackageStore, PluginRegistryStore, PluginStoreError, StoreError,
|
||||
PluginPackageStore, PluginRegistryStore, PluginStorageError, PluginStorageStore,
|
||||
PluginStoreError, StoreError,
|
||||
};
|
||||
use domain::remote::RemoteRef;
|
||||
use domain::{BackgroundTaskState, ProjectPath};
|
||||
@ -3411,6 +3530,69 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeStorage {
|
||||
values: Mutex<HashMap<(String, String), serde_json::Value>>,
|
||||
purged: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl PluginStorageStore for FakeStorage {
|
||||
async fn get(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
key: &str,
|
||||
) -> Result<Option<serde_json::Value>, PluginStorageError> {
|
||||
Ok(self
|
||||
.values
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&(plugin_id.as_str().to_owned(), key.to_owned()))
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn set(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
key: &str,
|
||||
value: serde_json::Value,
|
||||
) -> Result<(), PluginStorageError> {
|
||||
self.values
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert((plugin_id.as_str().to_owned(), key.to_owned()), value);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
key: &str,
|
||||
) -> Result<bool, PluginStorageError> {
|
||||
Ok(self
|
||||
.values
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&(plugin_id.as_str().to_owned(), key.to_owned()))
|
||||
.is_some())
|
||||
}
|
||||
|
||||
async fn purge_plugin(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
) -> Result<RemovalOutcome, PluginStorageError> {
|
||||
self.purged
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(plugin_id.as_str().to_owned());
|
||||
self.values
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|(id, _), _| id != plugin_id.as_str());
|
||||
Ok(RemovalOutcome::Removed)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeEvents {
|
||||
events: Mutex<Vec<DomainEvent>>,
|
||||
@ -3802,6 +3984,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn uninstall_removes_registry_package_and_stops_mcp() {
|
||||
let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));
|
||||
let storage = Arc::new(FakeStorage::default());
|
||||
let registry = Arc::new(FakeRegistry {
|
||||
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
|
||||
});
|
||||
@ -3809,6 +3992,7 @@ mod tests {
|
||||
let mcp = Arc::new(FakeMcp::default());
|
||||
let uninstall = UninstallPlugin::new(
|
||||
packages.clone(),
|
||||
storage.clone(),
|
||||
registry.clone(),
|
||||
events.clone(),
|
||||
mcp.clone(),
|
||||
@ -3825,6 +4009,7 @@ mod tests {
|
||||
assert!(result.restart_required);
|
||||
assert!(registry.load_registry().await.unwrap().plugins.is_empty());
|
||||
assert_eq!(&*packages.removed.lock().unwrap(), &["dev.acme.gitgraph"]);
|
||||
assert_eq!(&*storage.purged.lock().unwrap(), &["dev.acme.gitgraph"]);
|
||||
assert_eq!(&*mcp.stops.lock().unwrap(), &["dev.acme.gitgraph"]);
|
||||
assert!(events.events.lock().unwrap().iter().any(|event| matches!(
|
||||
event,
|
||||
@ -3835,6 +4020,89 @@ mod tests {
|
||||
)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_storage_round_trips_json_for_runtime_active_plugin() {
|
||||
let storage = Arc::new(FakeStorage::default());
|
||||
let registry = Arc::new(FakeRegistry {
|
||||
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
|
||||
});
|
||||
let access = PluginStorageAccess::new(storage, registry);
|
||||
|
||||
access
|
||||
.set(PluginStorageSetInput {
|
||||
plugin_id: "dev.acme.gitgraph".to_owned(),
|
||||
key: "helloPlugin.launches".to_owned(),
|
||||
value: serde_json::json!({"count": 2}),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let value = access
|
||||
.get(PluginStorageGetInput {
|
||||
plugin_id: "dev.acme.gitgraph".to_owned(),
|
||||
key: "helloPlugin.launches".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(value, Some(serde_json::json!({"count": 2})));
|
||||
assert!(access
|
||||
.delete(PluginStorageGetInput {
|
||||
plugin_id: "dev.acme.gitgraph".to_owned(),
|
||||
key: "helloPlugin.launches".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap());
|
||||
assert_eq!(
|
||||
access
|
||||
.get(PluginStorageGetInput {
|
||||
plugin_id: "dev.acme.gitgraph".to_owned(),
|
||||
key: "helloPlugin.launches".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_storage_rejects_inactive_plugin_and_invalid_key() {
|
||||
let storage = Arc::new(FakeStorage::default());
|
||||
let registry = Arc::new(FakeRegistry {
|
||||
registry: Mutex::new(registry_with(PluginLifecycleState::Disabled)),
|
||||
});
|
||||
let access = PluginStorageAccess::new(storage, registry);
|
||||
|
||||
let inactive = access
|
||||
.set(PluginStorageSetInput {
|
||||
plugin_id: "dev.acme.gitgraph".to_owned(),
|
||||
key: "helloPlugin.launches".to_owned(),
|
||||
value: serde_json::json!(1),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
inactive,
|
||||
AppError::Invalid("plugin is not runtime-active".to_owned())
|
||||
);
|
||||
|
||||
let storage = Arc::new(FakeStorage::default());
|
||||
let registry = Arc::new(FakeRegistry {
|
||||
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
|
||||
});
|
||||
let access = PluginStorageAccess::new(storage, registry);
|
||||
let invalid = access
|
||||
.get(PluginStorageGetInput {
|
||||
plugin_id: "dev.acme.gitgraph".to_owned(),
|
||||
key: " ".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
invalid,
|
||||
AppError::Invalid("plugin storage key must not be empty".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uninstall_then_reinstall_leaves_runtime_catalog_active_without_residue() {
|
||||
let packages = Arc::new(FakePackages::with_manifest_and_staged_count(
|
||||
@ -3844,6 +4112,7 @@ mod tests {
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
let events = Arc::new(FakeEvents::default());
|
||||
let mcp = Arc::new(FakeMcp::default());
|
||||
let storage = Arc::new(FakeStorage::default());
|
||||
let install = InstallPluginFromDirectory::new(
|
||||
packages.clone(),
|
||||
registry.clone(),
|
||||
@ -3851,8 +4120,13 @@ mod tests {
|
||||
events.clone(),
|
||||
mcp.clone(),
|
||||
);
|
||||
let uninstall =
|
||||
UninstallPlugin::new(packages.clone(), registry.clone(), events, mcp.clone());
|
||||
let uninstall = UninstallPlugin::new(
|
||||
packages.clone(),
|
||||
storage.clone(),
|
||||
registry.clone(),
|
||||
events,
|
||||
mcp.clone(),
|
||||
);
|
||||
|
||||
install.execute("/source/plugin".to_owned()).await.unwrap();
|
||||
uninstall
|
||||
|
||||
@ -30,6 +30,9 @@ pub struct CreateSkillInput {
|
||||
/// `None`/empty ⇒ the skill falls back to the first line of its body when
|
||||
/// surfaced (see [`domain::Skill::effective_description`]).
|
||||
pub description: Option<String>,
|
||||
/// Capability nature to expose for this skill. Defaults to workflow when the
|
||||
/// caller does not specify it.
|
||||
pub kind: SkillKind,
|
||||
/// Initial Markdown body.
|
||||
pub content: String,
|
||||
/// Scope the skill is created in (selects its backing store).
|
||||
@ -67,7 +70,8 @@ impl CreateSkill {
|
||||
let id = SkillId::from_uuid(self.ids.new_uuid());
|
||||
let skill = Skill::new(id, input.name, MarkdownDoc::new(input.content), input.scope)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?
|
||||
.with_description(input.description);
|
||||
.with_description(input.description)
|
||||
.with_kind(input.kind);
|
||||
self.skills.save(&skill, &input.project_root).await?;
|
||||
Ok(CreateSkillOutput { skill })
|
||||
}
|
||||
|
||||
@ -35,8 +35,8 @@ use domain::ports::{
|
||||
RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, SystemPermissionStore,
|
||||
};
|
||||
use domain::profile::{
|
||||
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, OpenCodeConfig,
|
||||
SessionStrategy, StructuredAdapter,
|
||||
AgentProfile, ContextInjection, EffortSelection, McpCapability, McpConfigStrategy,
|
||||
McpTransport, OpenCodeConfig, SessionStrategy, StructuredAdapter,
|
||||
};
|
||||
use domain::project::{Project, ProjectPath};
|
||||
use domain::remote::RemoteRef;
|
||||
@ -50,7 +50,7 @@ use application::{
|
||||
CreateAgentFromScratch, CreateAgentInput, DeleteAgent, DeleteAgentInput, LaunchAgent,
|
||||
LaunchAgentInput, ListAgents, ListAgentsInput, PermissionProjectorRegistry, ReadAgentContext,
|
||||
ReadAgentContextInput, StructuredRoutingMode, StructuredSessions, TerminalSessions,
|
||||
UpdateAgentContext, UpdateAgentContextInput,
|
||||
UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort, UpdateAgentEffortInput,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -604,6 +604,7 @@ impl AgentSession for FakeSession {
|
||||
struct FakeStructuredFactory {
|
||||
trace: Trace,
|
||||
starts: Arc<Mutex<Vec<ProfileId>>>,
|
||||
efforts: Arc<Mutex<Vec<Option<String>>>>,
|
||||
envs: Arc<Mutex<Vec<Vec<(String, String)>>>>,
|
||||
policies: Arc<Mutex<Vec<Option<domain::ports::StructuredProviderLaunchPolicy>>>>,
|
||||
next_session: SessionId,
|
||||
@ -614,6 +615,7 @@ impl FakeStructuredFactory {
|
||||
Self {
|
||||
trace,
|
||||
starts: Arc::new(Mutex::new(Vec::new())),
|
||||
efforts: Arc::new(Mutex::new(Vec::new())),
|
||||
envs: Arc::new(Mutex::new(Vec::new())),
|
||||
policies: Arc::new(Mutex::new(Vec::new())),
|
||||
next_session,
|
||||
@ -624,6 +626,10 @@ impl FakeStructuredFactory {
|
||||
self.starts.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn efforts(&self) -> Vec<Option<String>> {
|
||||
self.efforts.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn envs(&self) -> Vec<Vec<(String, String)>> {
|
||||
self.envs.lock().unwrap().clone()
|
||||
}
|
||||
@ -655,6 +661,10 @@ impl AgentSessionFactory for FakeStructuredFactory {
|
||||
.unwrap()
|
||||
.push("structured.start".to_owned());
|
||||
self.starts.lock().unwrap().push(profile.id);
|
||||
self.efforts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(profile.model_reasoning_effort.clone());
|
||||
self.envs.lock().unwrap().push(_env.to_vec());
|
||||
self.policies
|
||||
.lock()
|
||||
@ -864,6 +874,54 @@ async fn list_resolves_agent_capabilities_additively() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_agents_marks_effective_orchestrator_as_is_orchestrator_true() {
|
||||
let a1 = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
let a2 = scratch_agent(aid(2), "Frontend", "agents/frontend.md", pid(9));
|
||||
let contexts = FakeContexts::with_agent(&a1, "ctx1");
|
||||
{
|
||||
let mut inner = contexts.0.lock().unwrap();
|
||||
inner.manifest.entries.push(ManifestEntry::from_agent(&a2));
|
||||
inner.manifest.designate(a2.id).unwrap();
|
||||
}
|
||||
let list = ListAgents::new(Arc::new(contexts));
|
||||
|
||||
let out = list
|
||||
.execute(ListAgentsInput { project: project() })
|
||||
.await
|
||||
.unwrap();
|
||||
let entries = out.discovery_entries();
|
||||
|
||||
assert_eq!(out.effective_orchestrator, Some(a2.id));
|
||||
assert_eq!(entries[0].is_orchestrator, false);
|
||||
assert_eq!(entries[1].is_orchestrator, true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_agents_default_orchestrator_is_oldest_agent_when_none_designated() {
|
||||
let a1 = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
let a2 = scratch_agent(aid(2), "Frontend", "agents/frontend.md", pid(9));
|
||||
let contexts = FakeContexts::with_agent(&a1, "ctx1");
|
||||
contexts
|
||||
.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.manifest
|
||||
.entries
|
||||
.push(ManifestEntry::from_agent(&a2));
|
||||
let list = ListAgents::new(Arc::new(contexts));
|
||||
|
||||
let out = list
|
||||
.execute(ListAgentsInput { project: project() })
|
||||
.await
|
||||
.unwrap();
|
||||
let entries = out.discovery_entries();
|
||||
|
||||
assert_eq!(out.effective_orchestrator, Some(a1.id));
|
||||
assert_eq!(entries[0].is_orchestrator, true);
|
||||
assert_eq!(entries[1].is_orchestrator, false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_then_update_context_roundtrips() {
|
||||
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
@ -895,6 +953,69 @@ async fn read_then_update_context_roundtrips() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_agent_effort_sets_preset_selection_and_persists_manifest() {
|
||||
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
let contexts = FakeContexts::with_agent(&a, "ctx");
|
||||
let update = UpdateAgentEffort::new(Arc::new(contexts.clone()));
|
||||
|
||||
let out = update
|
||||
.execute(UpdateAgentEffortInput {
|
||||
project: project(),
|
||||
agent_id: a.id,
|
||||
effort: Some(EffortSelection::Preset("high".to_owned())),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
out.agent.effort,
|
||||
Some(EffortSelection::Preset("high".to_owned()))
|
||||
);
|
||||
assert_eq!(
|
||||
contexts.manifest().entries[0].effort,
|
||||
Some(EffortSelection::Preset("high".to_owned()))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_agent_effort_none_clears_existing_override() {
|
||||
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9))
|
||||
.with_effort(Some(EffortSelection::Custom("x-deep".to_owned())));
|
||||
let contexts = FakeContexts::with_agent(&a, "ctx");
|
||||
let update = UpdateAgentEffort::new(Arc::new(contexts.clone()));
|
||||
|
||||
let out = update
|
||||
.execute(UpdateAgentEffortInput {
|
||||
project: project(),
|
||||
agent_id: a.id,
|
||||
effort: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.agent.effort, None);
|
||||
assert_eq!(contexts.manifest().entries[0].effort, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_agent_effort_not_found_for_unknown_agent_id() {
|
||||
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
let contexts = FakeContexts::with_agent(&a, "ctx");
|
||||
let update = UpdateAgentEffort::new(Arc::new(contexts));
|
||||
|
||||
let err = update
|
||||
.execute(UpdateAgentEffortInput {
|
||||
project: project(),
|
||||
agent_id: aid(404),
|
||||
effort: Some(EffortSelection::Preset("medium".to_owned())),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_removes_entry_then_unknown_is_not_found() {
|
||||
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
@ -965,6 +1086,15 @@ fn launch_fixture_with_profile_and_recall(
|
||||
recall: FakeRecall,
|
||||
) -> LaunchFixture {
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id);
|
||||
launch_fixture_with_profile_agent_and_recall(profile, agent, plan, recall)
|
||||
}
|
||||
|
||||
fn launch_fixture_with_profile_agent_and_recall(
|
||||
profile: AgentProfile,
|
||||
agent: Agent,
|
||||
plan: Option<ContextInjectionPlan>,
|
||||
recall: FakeRecall,
|
||||
) -> LaunchFixture {
|
||||
let contexts = FakeContexts::with_agent(&agent, "# ctx body");
|
||||
let profiles = FakeProfiles::new(vec![profile]);
|
||||
let tr = trace();
|
||||
@ -1127,6 +1257,63 @@ async fn structured_profile_with_factory_routes_to_structured_session_without_pt
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_agent_structured_forwards_resolved_effort_ahead_of_profile_default() {
|
||||
let profile = profile(
|
||||
pid(9),
|
||||
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||
)
|
||||
.with_structured_adapter(StructuredAdapter::Codex)
|
||||
.with_model_reasoning_effort("low");
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id)
|
||||
.with_effort(Some(EffortSelection::Preset("high".to_owned())));
|
||||
let (launch, agent, _fs, pty, _bus, _sessions, tr, _session) =
|
||||
launch_fixture_with_profile_agent_and_recall(
|
||||
profile,
|
||||
agent,
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
FakeRecall::default(),
|
||||
);
|
||||
let factory = FakeStructuredFactory::new(Arc::clone(&tr), sid(888));
|
||||
let structured = Arc::new(StructuredSessions::new());
|
||||
let launch = launch
|
||||
.with_structured_routing_mode(StructuredRoutingMode::RequireStructured)
|
||||
.with_structured(Arc::new(factory.clone()), structured);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.unwrap();
|
||||
|
||||
assert!(pty.spawns().is_empty());
|
||||
assert_eq!(factory.efforts(), vec![Some("high".to_owned())]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_agent_structured_falls_back_to_profile_default_when_agent_has_no_override() {
|
||||
let profile = profile(
|
||||
pid(9),
|
||||
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||
)
|
||||
.with_structured_adapter(StructuredAdapter::Codex)
|
||||
.with_model_reasoning_effort("low");
|
||||
let (launch, agent, _fs, pty, _bus, _sessions, tr, _session) = launch_fixture_with_profile(
|
||||
profile,
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
let factory = FakeStructuredFactory::new(Arc::clone(&tr), sid(888));
|
||||
let structured = Arc::new(StructuredSessions::new());
|
||||
let launch = launch
|
||||
.with_structured_routing_mode(StructuredRoutingMode::RequireStructured)
|
||||
.with_structured(Arc::new(factory.clone()), structured);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.unwrap();
|
||||
|
||||
assert!(pty.spawns().is_empty());
|
||||
assert_eq!(factory.efforts(), vec![Some("low".to_owned())]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn structured_profile_without_factory_require_structured_errors_without_pty_spawn() {
|
||||
let profile = profile(
|
||||
@ -3264,6 +3451,17 @@ fn launch_with_projection_and_env(
|
||||
env: Vec<(String, String)>,
|
||||
) -> (LaunchAgent, Agent, FakeFs, FakePty, Arc<TerminalSessions>) {
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id);
|
||||
launch_with_projection_agent_and_env(profile, agent, plan, registry, perm_doc, env)
|
||||
}
|
||||
|
||||
fn launch_with_projection_agent_and_env(
|
||||
profile: AgentProfile,
|
||||
agent: Agent,
|
||||
plan: Option<ContextInjectionPlan>,
|
||||
registry: Option<Arc<PermissionProjectorRegistry>>,
|
||||
perm_doc: Option<ProjectPermissions>,
|
||||
env: Vec<(String, String)>,
|
||||
) -> (LaunchAgent, Agent, FakeFs, FakePty, Arc<TerminalSessions>) {
|
||||
let contexts = FakeContexts::with_agent(&agent, "# ctx body");
|
||||
let profiles = FakeProfiles::new(vec![profile]);
|
||||
let tr = trace();
|
||||
@ -3896,6 +4094,40 @@ async fn codex_pty_launch_forwards_profile_model_as_config_override() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_agent_pty_codex_overrides_use_resolved_effort() {
|
||||
let profile = codex_profile()
|
||||
.with_projector(ProjectorKey::Codex)
|
||||
.with_model_reasoning_effort("low");
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id)
|
||||
.with_effort(Some(EffortSelection::Custom("x-deep".to_owned())));
|
||||
let (launch, agent, _fs, pty, _s) = launch_with_projection_agent_and_env(
|
||||
profile,
|
||||
agent,
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "AGENTS.md".to_owned(),
|
||||
}),
|
||||
Some(full_registry()),
|
||||
None,
|
||||
Vec::new(),
|
||||
);
|
||||
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch");
|
||||
|
||||
let args = &pty.spawns()[0].args;
|
||||
assert!(
|
||||
args.windows(2).any(|w| w
|
||||
== [
|
||||
"-c".to_owned(),
|
||||
"model_reasoning_effort=\"x-deep\"".to_owned()
|
||||
]),
|
||||
"PTY Codex launch must forward the resolved per-agent effort, got {args:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- (5) MCP decoupling — THE key case of the lot ---------------------------
|
||||
|
||||
/// (5) A Codex profile with **no MCP capability** still gets its sandbox projected
|
||||
|
||||
@ -9,7 +9,10 @@ use domain::ids::{AgentId, ProjectId};
|
||||
use domain::ports::{PermissionStore, StoreError};
|
||||
use domain::project::{Project, ProjectPath};
|
||||
use domain::remote::RemoteRef;
|
||||
use domain::{PermissionSet, Posture, ProjectPermissions};
|
||||
use domain::{
|
||||
Capability, Effect, PermissionRule, PermissionSet, PermissionShadowReport, Posture,
|
||||
ProjectPermissions,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakePermissionStore {
|
||||
@ -125,4 +128,69 @@ async fn resolve_agent_permissions_returns_effective_policy() {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.effective.unwrap().fallback(), Posture::Ask);
|
||||
assert_eq!(out.shadowed, PermissionShadowReport::default());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_agent_permissions_reports_shadowed_alongside_unchanged_effective() {
|
||||
let agent = AgentId::new_random();
|
||||
let store = Arc::new(FakePermissionStore {
|
||||
doc: Mutex::new(ProjectPermissions::new(
|
||||
Some(PermissionSet::new(
|
||||
vec![PermissionRule::bash(Effect::Deny, vec![])],
|
||||
Posture::Ask,
|
||||
)),
|
||||
vec![domain::AgentPermissionOverride::new(
|
||||
agent,
|
||||
PermissionSet::new(
|
||||
vec![PermissionRule::bash(Effect::Allow, vec![])],
|
||||
Posture::Ask,
|
||||
),
|
||||
)],
|
||||
)),
|
||||
saves: Mutex::new(0),
|
||||
});
|
||||
let use_case = ResolveAgentPermissions::new(store);
|
||||
|
||||
let out = use_case
|
||||
.execute(ResolveAgentPermissionsInput {
|
||||
project: project(),
|
||||
agent_id: agent,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(out.shadowed.execute_bash);
|
||||
assert_eq!(out.effective.unwrap().decide_bash("ls"), Posture::Deny);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_agent_permissions_shadowed_defaults_when_no_agent_override() {
|
||||
let agent = AgentId::new_random();
|
||||
let store = Arc::new(FakePermissionStore {
|
||||
doc: Mutex::new(ProjectPermissions::new(
|
||||
Some(PermissionSet::new(
|
||||
vec![PermissionRule::file(
|
||||
Capability::Read,
|
||||
Effect::Deny,
|
||||
domain::PathScope::new(["**".to_owned()]).unwrap(),
|
||||
)
|
||||
.unwrap()],
|
||||
Posture::Deny,
|
||||
)),
|
||||
vec![],
|
||||
)),
|
||||
saves: Mutex::new(0),
|
||||
});
|
||||
let use_case = ResolveAgentPermissions::new(store);
|
||||
|
||||
let out = use_case
|
||||
.execute(ResolveAgentPermissionsInput {
|
||||
project: project(),
|
||||
agent_id: agent,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.shadowed, PermissionShadowReport::default());
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@ use domain::markdown::MarkdownDoc;
|
||||
use domain::ports::{
|
||||
AgentContextStore, EventBus, EventStream, IdGenerator, SkillStore, StoreError,
|
||||
};
|
||||
use domain::skill::{Skill, SkillScope};
|
||||
use domain::skill::{Skill, SkillKind, SkillScope};
|
||||
use domain::{AgentManifest, ManifestEntry, Project, ProjectPath, RemoteRef, SkillRef};
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -196,6 +196,7 @@ async fn create_skill_persists_in_its_scope() {
|
||||
.execute(CreateSkillInput {
|
||||
name: "refactor".to_owned(),
|
||||
description: Some("Refactors code".to_owned()),
|
||||
kind: SkillKind::Reference,
|
||||
content: "# body".to_owned(),
|
||||
scope: SkillScope::Project,
|
||||
project_root: root(),
|
||||
@ -204,6 +205,7 @@ async fn create_skill_persists_in_its_scope() {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.skill.scope, SkillScope::Project);
|
||||
assert_eq!(out.skill.kind, SkillKind::Reference);
|
||||
// The one-line affordance description flows through the use case onto the skill.
|
||||
assert_eq!(out.skill.description.as_deref(), Some("Refactors code"));
|
||||
assert_eq!(
|
||||
@ -228,6 +230,7 @@ async fn create_skill_rejects_empty_content() {
|
||||
.execute(CreateSkillInput {
|
||||
name: "k".to_owned(),
|
||||
description: None,
|
||||
kind: SkillKind::Workflow,
|
||||
content: String::new(),
|
||||
scope: SkillScope::Global,
|
||||
project_root: root(),
|
||||
|
||||
@ -337,6 +337,47 @@ impl From<PluginWorkspaceWriteBinaryDto> for application::PluginWorkspaceWriteBi
|
||||
}
|
||||
}
|
||||
|
||||
/// Plugin-owned storage read/delete request DTO.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginStorageGetDto {
|
||||
/// Plugin id owning the value.
|
||||
pub plugin_id: String,
|
||||
/// Plugin-owned key.
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
impl From<PluginStorageGetDto> for application::PluginStorageGetInput {
|
||||
fn from(value: PluginStorageGetDto) -> Self {
|
||||
Self {
|
||||
plugin_id: value.plugin_id,
|
||||
key: value.key,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Plugin-owned storage write request DTO.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginStorageSetDto {
|
||||
/// Plugin id owning the value.
|
||||
pub plugin_id: String,
|
||||
/// Plugin-owned key.
|
||||
pub key: String,
|
||||
/// JSON value to persist.
|
||||
pub value: Value,
|
||||
}
|
||||
|
||||
impl From<PluginStorageSetDto> for application::PluginStorageSetInput {
|
||||
fn from(value: PluginStorageSetDto) -> Self {
|
||||
Self {
|
||||
plugin_id: value.plugin_id,
|
||||
key: value.key,
|
||||
value: value.value,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Plugin structured config document read request DTO.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@ -2377,8 +2418,8 @@ use application::{
|
||||
LaunchAgentOutput, ListAgentsOutput, ReadAgentContextOutput, ReadMcpToolPermissionsOutput,
|
||||
};
|
||||
use domain::{
|
||||
Agent, AgentMcpToolPolicyOverride, EffectivePermissions, McpToolPolicy, PermissionSet,
|
||||
ProjectPermissions, SkillKind, TerminalSession,
|
||||
Agent, AgentMcpToolPolicyOverride, EffectivePermissions, EffortSelection, McpToolPolicy,
|
||||
PermissionSet, PermissionShadowReport, ProjectPermissions, SkillKind, TerminalSession,
|
||||
};
|
||||
|
||||
/// One discoverable capability carried by an agent.
|
||||
@ -2413,6 +2454,8 @@ pub struct AgentDto {
|
||||
pub agent: Agent,
|
||||
/// Resolved discoverable capabilities.
|
||||
pub capabilities: Vec<AgentCapabilityDto>,
|
||||
/// Whether this agent is the effective project orchestrator.
|
||||
pub is_orchestrator: bool,
|
||||
}
|
||||
|
||||
impl AgentDto {
|
||||
@ -2422,6 +2465,7 @@ impl AgentDto {
|
||||
Self {
|
||||
agent,
|
||||
capabilities: Vec::new(),
|
||||
is_orchestrator: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2443,6 +2487,7 @@ impl From<ListAgentsOutput> for AgentListDto {
|
||||
.into_iter()
|
||||
.map(AgentCapabilityDto::from)
|
||||
.collect(),
|
||||
is_orchestrator: entry.is_orchestrator,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
@ -2512,6 +2557,25 @@ pub struct ProjectPermissionsDto(pub ProjectPermissions);
|
||||
#[serde(transparent)]
|
||||
pub struct EffectivePermissionsDto(pub EffectivePermissions);
|
||||
|
||||
/// Response for resolving one agent's file/bash permissions.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResolveAgentPermissionsResponseDto {
|
||||
/// Resolved policy, or `null` when neither project nor agent policy exists.
|
||||
pub effective: Option<EffectivePermissions>,
|
||||
/// Diagnostic report for agent-level allows shadowed by project defaults.
|
||||
pub shadowed: PermissionShadowReport,
|
||||
}
|
||||
|
||||
impl From<application::ResolveAgentPermissionsOutput> for ResolveAgentPermissionsResponseDto {
|
||||
fn from(out: application::ResolveAgentPermissionsOutput) -> Self {
|
||||
Self {
|
||||
effective: out.effective,
|
||||
shadowed: out.shadowed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Full project system permission document crossing the wire.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
@ -2767,6 +2831,18 @@ pub struct ChangeAgentProfileRequestDto {
|
||||
pub cols: u16,
|
||||
}
|
||||
|
||||
/// Request DTO for `update_agent_effort`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateAgentEffortRequestDto {
|
||||
/// Id of the owning project.
|
||||
pub project_id: String,
|
||||
/// Id of the agent whose effort override changes.
|
||||
pub agent_id: String,
|
||||
/// `null` clears the override.
|
||||
pub effort: Option<EffortSelection>,
|
||||
}
|
||||
|
||||
/// Response DTO for `change_agent_profile`: the mutated agent plus the freshly
|
||||
/// relaunched session when a live session was hot-swapped (absent otherwise).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@ -3997,10 +4073,16 @@ pub struct CreateSkillRequestDto {
|
||||
pub project_id: String,
|
||||
/// Display name.
|
||||
pub name: String,
|
||||
/// Optional one-line affordance description.
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
/// Initial Markdown content.
|
||||
pub content: String,
|
||||
/// Scope the skill is created in.
|
||||
pub scope: SkillScope,
|
||||
/// Capability nature. Missing legacy clients create workflow skills.
|
||||
#[serde(default)]
|
||||
pub kind: SkillKind,
|
||||
}
|
||||
|
||||
/// Request DTO for `update_skill`.
|
||||
@ -4703,7 +4785,7 @@ pub struct SpawnBackgroundCommandRequestDto {
|
||||
mod tests {
|
||||
use application::McpToolPermissionCatalogue;
|
||||
use domain::mailbox::TicketId;
|
||||
use domain::{AgentId, ConversationId, ProjectMcpToolPermissions};
|
||||
use domain::{AgentId, ConversationId, PermissionShadowReport, ProjectMcpToolPermissions};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -4755,6 +4837,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_agent_permissions_response_dto_uses_effective_plus_shadowed_shape() {
|
||||
let dto = ResolveAgentPermissionsResponseDto {
|
||||
effective: None,
|
||||
shadowed: PermissionShadowReport {
|
||||
read: false,
|
||||
write: false,
|
||||
delete: false,
|
||||
execute_bash: true,
|
||||
fallback: true,
|
||||
},
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(dto).unwrap(),
|
||||
json!({
|
||||
"effective": null,
|
||||
"shadowed": {
|
||||
"read": false,
|
||||
"write": false,
|
||||
"delete": false,
|
||||
"executeBash": true,
|
||||
"fallback": true
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dto_plugins_workspace_requests_use_stable_camel_case_contract() {
|
||||
let path = PluginWorkspacePathDto {
|
||||
@ -4868,6 +4978,42 @@ mod tests {
|
||||
assert_eq!(input.value["enabled"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dto_plugins_storage_requests_use_stable_camel_case_contract() {
|
||||
let get = PluginStorageGetDto {
|
||||
plugin_id: "dev.acme.gitgraph".to_owned(),
|
||||
key: "helloPlugin.launches".to_owned(),
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_value(&get).unwrap(),
|
||||
json!({
|
||||
"pluginId": "dev.acme.gitgraph",
|
||||
"key": "helloPlugin.launches"
|
||||
})
|
||||
);
|
||||
let input: application::PluginStorageGetInput = get.into();
|
||||
assert_eq!(input.plugin_id, "dev.acme.gitgraph");
|
||||
assert_eq!(input.key, "helloPlugin.launches");
|
||||
|
||||
let set = PluginStorageSetDto {
|
||||
plugin_id: "dev.acme.gitgraph".to_owned(),
|
||||
key: "helloPlugin.enabled".to_owned(),
|
||||
value: json!({"enabled": true}),
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_value(&set).unwrap(),
|
||||
json!({
|
||||
"pluginId": "dev.acme.gitgraph",
|
||||
"key": "helloPlugin.enabled",
|
||||
"value": {"enabled": true}
|
||||
})
|
||||
);
|
||||
let input: application::PluginStorageSetInput = set.into();
|
||||
assert_eq!(input.plugin_id, "dev.acme.gitgraph");
|
||||
assert_eq!(input.key, "helloPlugin.enabled");
|
||||
assert_eq!(input.value, json!({"enabled": true}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dto_plugins_workspace_outputs_use_stable_camel_case_contract() {
|
||||
let listing = PluginWorkspaceDirectoryListingDto {
|
||||
|
||||
@ -579,6 +579,16 @@ pub enum DomainEventDto {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
orchestrator: Option<String>,
|
||||
},
|
||||
/// The project's global context was written directly.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
ProjectContextUpdated {
|
||||
/// The project whose global context changed.
|
||||
project_id: String,
|
||||
/// Writer party (`"user"` or agent id).
|
||||
by: String,
|
||||
/// Epoch-milliseconds of the write.
|
||||
at_ms: i64,
|
||||
},
|
||||
/// A memory note was created or updated.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
MemorySaved {
|
||||
@ -1213,6 +1223,15 @@ impl From<&DomainEvent> for DomainEventDto {
|
||||
project_id: project_id.to_string(),
|
||||
orchestrator: orchestrator.as_ref().map(|a| a.to_string()),
|
||||
},
|
||||
DomainEvent::ProjectContextUpdated {
|
||||
project_id,
|
||||
by,
|
||||
at_ms,
|
||||
} => Self::ProjectContextUpdated {
|
||||
project_id: project_id.to_string(),
|
||||
by: conversation_party_wire(*by),
|
||||
at_ms: *at_ms,
|
||||
},
|
||||
DomainEvent::MemorySaved { slug } => Self::MemorySaved {
|
||||
slug: slug.as_str().to_string(),
|
||||
},
|
||||
@ -1426,6 +1445,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_context_updated_relays_writer_to_wire() {
|
||||
let project_id = ProjectId::from_uuid(uuid::Uuid::from_u128(1));
|
||||
let writer = agent(2);
|
||||
|
||||
let dto = DomainEventDto::from(&DomainEvent::ProjectContextUpdated {
|
||||
project_id,
|
||||
by: ConversationParty::agent(writer),
|
||||
at_ms: 987_654,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(&dto).unwrap(),
|
||||
json!({
|
||||
"type": "projectContextUpdated",
|
||||
"projectId": project_id.to_string(),
|
||||
"by": writer.to_string(),
|
||||
"atMs": 987654,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_completion_relays_rendezvous_context_to_wire() {
|
||||
let project_id = ProjectId::from_uuid(uuid::Uuid::from_u128(1));
|
||||
|
||||
@ -11,6 +11,7 @@ use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use application::orchestrator::UpdateProjectContext as GuardedUpdateProjectContext;
|
||||
use application::{
|
||||
AddIssueAttachment, AgentResumer, AgentWakeService, AppError, AssignIssueAgent,
|
||||
AssignSkillToAgent, AssignTicketToSprint, AttachLiveAgent, AuthenticateSession,
|
||||
@ -35,7 +36,7 @@ use application::{
|
||||
MarkIssueAttachmentSummarized, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow,
|
||||
MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OpenTicketAssistant,
|
||||
OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry,
|
||||
PluginCommandTasks, PluginConfigDocuments, PluginEventSubscriptions,
|
||||
PluginCommandTasks, PluginConfigDocuments, PluginEventSubscriptions, PluginStorageAccess,
|
||||
PluginToolchainDiagnostics, PluginWorkspaceAccess, ProposeContext, QueryProjectStructure,
|
||||
ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueAttachment,
|
||||
ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, ReadProjectContext,
|
||||
@ -49,7 +50,7 @@ use application::{
|
||||
SetPluginEnabled, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand,
|
||||
StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession,
|
||||
SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent,
|
||||
UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContext,
|
||||
UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContext, UpdateAgentEffort,
|
||||
UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentSystemPermissions,
|
||||
UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext,
|
||||
UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateProjectSystemPermissions,
|
||||
@ -64,10 +65,11 @@ use domain::ports::{
|
||||
EmbedderProfileStore, EmbedderPromptStore, EnvironmentReader, EventBus, FileSystem, GitPort,
|
||||
IdGenerator, IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall,
|
||||
MemoryStore, ModelArtifactDownloader, PermissionStore, PluginManifestValidator,
|
||||
PluginMcpSupervisor, PluginPackageStore, PluginRegistryStore, ProcessSpawner, ProfileStore,
|
||||
ProjectStore, PtyHandle, PtyPort, RuntimePermissionProbe, ScheduledTask, Scheduler,
|
||||
SecretStore, SkillStore, SprintStore, StructuredSessionEnvironmentPreparer,
|
||||
SystemPermissionStore, TemplateStore, ToolInvoker, WakeError, WakeReason, WindowStateStore,
|
||||
PluginMcpSupervisor, PluginPackageStore, PluginRegistryStore, PluginStorageStore,
|
||||
ProcessSpawner, ProfileStore, ProjectStore, PtyHandle, PtyPort, RuntimePermissionProbe,
|
||||
ScheduledTask, Scheduler, SecretStore, SkillStore, SprintStore,
|
||||
StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, ToolInvoker,
|
||||
WakeError, WakeReason, WindowStateStore,
|
||||
};
|
||||
use domain::profile::{
|
||||
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
||||
@ -91,15 +93,15 @@ use infrastructure::{
|
||||
FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator,
|
||||
FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry,
|
||||
FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore,
|
||||
FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSecretStore, FsSkillStore,
|
||||
FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore, Git2Repository,
|
||||
HeuristicHandoffSummarizer, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe,
|
||||
HttpProviderModelCatalogue, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
||||
InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalEnvironmentReader, LocalFileSystem,
|
||||
LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
|
||||
OrchestratorWatchHandle, PortablePtyAdapter, ProcessCliVersionReader,
|
||||
ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory, SystemClock,
|
||||
SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer,
|
||||
FsPluginStorageStore, FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSecretStore,
|
||||
FsSkillStore, FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore,
|
||||
Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
|
||||
HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, IdeaiContextStore,
|
||||
InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime,
|
||||
LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer,
|
||||
MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter,
|
||||
ProcessCliVersionReader, ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory,
|
||||
SystemClock, SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer,
|
||||
TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator,
|
||||
VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
|
||||
VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||
@ -1093,6 +1095,8 @@ pub struct BackendCore {
|
||||
pub update_project_permissions: Arc<UpdateProjectPermissions>,
|
||||
/// Update one agent permission override.
|
||||
pub update_agent_permissions: Arc<UpdateAgentPermissions>,
|
||||
/// Update one agent effort override.
|
||||
pub update_agent_effort: Arc<UpdateAgentEffort>,
|
||||
/// Resolve effective permissions for one agent.
|
||||
pub resolve_agent_permissions: Arc<ResolveAgentPermissions>,
|
||||
/// Read the project system permission document.
|
||||
@ -1142,6 +1146,8 @@ pub struct BackendCore {
|
||||
pub plugin_workspace_access: Arc<PluginWorkspaceAccess>,
|
||||
/// Public plugin structured config document facade.
|
||||
pub plugin_config_documents: Arc<PluginConfigDocuments>,
|
||||
/// Public plugin-owned storage facade.
|
||||
pub plugin_storage_access: Arc<PluginStorageAccess>,
|
||||
/// Public plugin project-structure query use case.
|
||||
pub query_project_structure: Arc<QueryProjectStructure>,
|
||||
/// Public plugin command/task facade.
|
||||
@ -1416,11 +1422,13 @@ impl BackendCore {
|
||||
let events_port = Arc::clone(&event_bus) as Arc<dyn EventBus>;
|
||||
let plugin_packages = Arc::new(FsPluginPackageStore::new(app_data_dir.clone()));
|
||||
let plugin_registry = Arc::new(FsPluginRegistryStore::new(app_data_dir.clone()));
|
||||
let plugin_storage = Arc::new(FsPluginStorageStore::new(app_data_dir.clone()));
|
||||
let plugin_validator =
|
||||
Arc::new(JsonPluginManifestValidator::new(env!("CARGO_PKG_VERSION")));
|
||||
let plugin_mcp_supervisor = Arc::new(ExternalMcpPluginSupervisor::new());
|
||||
let plugin_package_store = Arc::clone(&plugin_packages) as Arc<dyn PluginPackageStore>;
|
||||
let plugin_registry_store = Arc::clone(&plugin_registry) as Arc<dyn PluginRegistryStore>;
|
||||
let plugin_storage_store = Arc::clone(&plugin_storage) as Arc<dyn PluginStorageStore>;
|
||||
let plugin_manifest_validator =
|
||||
Arc::clone(&plugin_validator) as Arc<dyn PluginManifestValidator>;
|
||||
let plugin_mcp_supervisor_port =
|
||||
@ -2178,6 +2186,7 @@ impl BackendCore {
|
||||
let update_agent_permissions = Arc::new(UpdateAgentPermissions::new(Arc::clone(
|
||||
&permission_store_port,
|
||||
)));
|
||||
let update_agent_effort = Arc::new(UpdateAgentEffort::new(Arc::clone(&contexts_port)));
|
||||
let resolve_agent_permissions = Arc::new(ResolveAgentPermissions::new(Arc::clone(
|
||||
&permission_store_port,
|
||||
)));
|
||||
@ -2425,6 +2434,7 @@ impl BackendCore {
|
||||
));
|
||||
let uninstall_plugin = Arc::new(UninstallPlugin::new(
|
||||
Arc::clone(&plugin_package_store),
|
||||
Arc::clone(&plugin_storage_store),
|
||||
Arc::clone(&plugin_registry_store),
|
||||
Arc::clone(&events_port),
|
||||
Arc::clone(&plugin_mcp_supervisor_port),
|
||||
@ -2448,6 +2458,10 @@ impl BackendCore {
|
||||
PluginConfigDocuments::new(Arc::clone(&store_port), Arc::clone(&fs_port))
|
||||
.with_events(Arc::clone(&events_port)),
|
||||
);
|
||||
let plugin_storage_access = Arc::new(PluginStorageAccess::new(
|
||||
Arc::clone(&plugin_storage_store),
|
||||
Arc::clone(&plugin_registry_store),
|
||||
));
|
||||
let query_project_structure = Arc::new(QueryProjectStructure::new(
|
||||
Arc::clone(&store_port),
|
||||
Arc::clone(&fs_port),
|
||||
@ -2718,6 +2732,13 @@ impl BackendCore {
|
||||
Arc::clone(&fs_port),
|
||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||
)),
|
||||
update_project_context: Arc::new(GuardedUpdateProjectContext::new(
|
||||
Arc::clone(&file_guard),
|
||||
Arc::clone(&contexts_port),
|
||||
Arc::clone(&fs_port),
|
||||
Arc::clone(&events_port),
|
||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||
)),
|
||||
read_memory: Arc::new(ReadMemory::new(
|
||||
Arc::clone(&file_guard),
|
||||
Arc::clone(&memory_store_port),
|
||||
@ -2976,6 +2997,7 @@ impl BackendCore {
|
||||
get_project_permissions,
|
||||
update_project_permissions,
|
||||
update_agent_permissions,
|
||||
update_agent_effort,
|
||||
resolve_agent_permissions,
|
||||
get_project_system_permissions,
|
||||
update_project_system_permissions,
|
||||
@ -3045,6 +3067,7 @@ impl BackendCore {
|
||||
reconcile_plugin_mcp_servers,
|
||||
plugin_workspace_access,
|
||||
plugin_config_documents,
|
||||
plugin_storage_access,
|
||||
query_project_structure,
|
||||
plugin_command_tasks,
|
||||
plugin_toolchain_diagnostics,
|
||||
@ -4649,6 +4672,7 @@ mod mcp_serve_peer_tests {
|
||||
synchronized: false,
|
||||
synced_template_version: None,
|
||||
skills: Vec::new(),
|
||||
effort: None,
|
||||
});
|
||||
id
|
||||
}
|
||||
@ -5387,6 +5411,7 @@ mod mcp_serve_peer_tests {
|
||||
// traite ces commandes sans cette erreur — et le réfute sans le câblage.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
use application::orchestrator::UpdateProjectContext as GuardedUpdateProjectContext;
|
||||
use application::{ContextGuardUseCases, ProposeContext, ReadContext, ReadMemory, WriteMemory};
|
||||
use domain::conversation::ConversationParty;
|
||||
use domain::memory::{
|
||||
@ -5481,6 +5506,13 @@ mod mcp_serve_peer_tests {
|
||||
Arc::new(FakeFs),
|
||||
Arc::new(FixedClock),
|
||||
)),
|
||||
update_project_context: Arc::new(GuardedUpdateProjectContext::new(
|
||||
Arc::clone(&file_guard),
|
||||
Arc::new(contexts.clone()),
|
||||
Arc::new(FakeFs),
|
||||
Arc::new(NoopBus),
|
||||
Arc::new(FixedClock),
|
||||
)),
|
||||
read_memory: Arc::new(ReadMemory::new(
|
||||
Arc::clone(&file_guard),
|
||||
Arc::clone(&memory) as Arc<dyn MemoryStore>,
|
||||
@ -5977,6 +6009,7 @@ mod mcp_e2e_loopback_tests {
|
||||
synchronized: false,
|
||||
synced_template_version: None,
|
||||
skills: Vec::new(),
|
||||
effort: None,
|
||||
});
|
||||
id
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::DomainError;
|
||||
use crate::ids::{AgentId, ProfileId, TemplateId};
|
||||
use crate::profile::EffortSelection;
|
||||
use crate::skill::SkillRef;
|
||||
use crate::template::TemplateVersion;
|
||||
|
||||
@ -70,6 +71,10 @@ pub struct Agent {
|
||||
/// activation (ARCHITECTURE §14.2). Empty by default.
|
||||
#[serde(default)]
|
||||
pub skills: Vec<SkillRef>,
|
||||
/// Per-agent effort selection. `None` falls back to the profile's
|
||||
/// `model_reasoning_effort` at launch.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub effort: Option<EffortSelection>,
|
||||
}
|
||||
|
||||
impl Agent {
|
||||
@ -104,6 +109,7 @@ impl Agent {
|
||||
origin,
|
||||
synchronized,
|
||||
skills: Vec::new(),
|
||||
effort: None,
|
||||
})
|
||||
}
|
||||
|
||||
@ -128,6 +134,13 @@ impl Agent {
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns a copy of this agent carrying the given per-agent effort override.
|
||||
#[must_use]
|
||||
pub fn with_effort(mut self, effort: Option<EffortSelection>) -> Self {
|
||||
self.effort = effort;
|
||||
self
|
||||
}
|
||||
|
||||
/// Assigns a skill to this agent. Idempotent: re-assigning the same
|
||||
/// `skill_id` is a no-op (returns `false`); a new assignment returns `true`.
|
||||
pub fn assign_skill(&mut self, skill: SkillRef) -> bool {
|
||||
@ -183,6 +196,9 @@ pub struct ManifestEntry {
|
||||
/// backward-compatible deserialisation of pre-L12 manifests.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub skills: Vec<SkillRef>,
|
||||
/// Per-agent effort selection. Missing in older manifests means no override.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub effort: Option<EffortSelection>,
|
||||
}
|
||||
|
||||
impl ManifestEntry {
|
||||
@ -221,6 +237,7 @@ impl ManifestEntry {
|
||||
synchronized,
|
||||
synced_template_version,
|
||||
skills: Vec::new(),
|
||||
effort: None,
|
||||
})
|
||||
}
|
||||
|
||||
@ -246,6 +263,7 @@ impl ManifestEntry {
|
||||
synchronized: agent.synchronized,
|
||||
synced_template_version,
|
||||
skills: agent.skills.clone(),
|
||||
effort: agent.effort.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -271,7 +289,8 @@ impl ManifestEntry {
|
||||
origin,
|
||||
self.synchronized,
|
||||
)?
|
||||
.with_skills(self.skills.clone()))
|
||||
.with_skills(self.skills.clone())
|
||||
.with_effort(self.effort.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
@ -530,4 +549,41 @@ mod orchestrator_tests {
|
||||
&d,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_with_effort_round_trips_through_manifest_entry() {
|
||||
let agent = Agent::new(
|
||||
agent_id(1),
|
||||
"agent-1",
|
||||
"agents/agent-1.md",
|
||||
ProfileId::from_uuid(uuid::Uuid::from_u128(1001)),
|
||||
AgentOrigin::Scratch,
|
||||
false,
|
||||
)
|
||||
.unwrap()
|
||||
.with_effort(Some(EffortSelection::Preset("medium".to_owned())));
|
||||
|
||||
let entry = ManifestEntry::from_agent(&agent);
|
||||
let back = entry.to_agent().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
back.effort,
|
||||
Some(EffortSelection::Preset("medium".to_owned()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_entry_effort_absent_deserialises_as_none() {
|
||||
let json = r#"{
|
||||
"agentId":"00000000-0000-0000-0000-000000000001",
|
||||
"name":"agent-1",
|
||||
"mdPath":"agents/agent-1.md",
|
||||
"profileId":"00000000-0000-0000-0000-0000000003e9",
|
||||
"synchronized":false
|
||||
}"#;
|
||||
|
||||
let entry: ManifestEntry = serde_json::from_str(json).expect("legacy entry deserialises");
|
||||
assert_eq!(entry.effort, None);
|
||||
assert_eq!(entry.to_agent().unwrap().effort, None);
|
||||
}
|
||||
}
|
||||
|
||||
@ -637,6 +637,16 @@ pub enum DomainEvent {
|
||||
/// (the oldest agent orchestrates).
|
||||
orchestrator: Option<AgentId>,
|
||||
},
|
||||
/// The project's global context was written directly, as opposed to a
|
||||
/// non-orchestrator's change being filed as a proposal.
|
||||
ProjectContextUpdated {
|
||||
/// The project whose global context changed.
|
||||
project_id: ProjectId,
|
||||
/// The party that performed the write.
|
||||
by: ConversationParty,
|
||||
/// Epoch-milliseconds of the write.
|
||||
at_ms: i64,
|
||||
},
|
||||
/// Raw PTY output (usually routed to a dedicated channel, not this bus).
|
||||
PtyOutput {
|
||||
/// The session.
|
||||
|
||||
@ -106,8 +106,8 @@ pub use skill::{Skill, SkillKind, SkillRef, SkillScope};
|
||||
pub use template::{AgentTemplate, TemplateVersion};
|
||||
|
||||
pub use profile::{
|
||||
AgentProfile, ContextInjection, EmbedderProfile, EmbedderStrategy, LivenessStrategy,
|
||||
McpServerWiring, RateLimitPattern, SessionStrategy,
|
||||
resolve_effort, AgentProfile, ContextInjection, EffortOption, EffortSelection, EmbedderProfile,
|
||||
EmbedderStrategy, LivenessStrategy, McpServerWiring, RateLimitPattern, SessionStrategy,
|
||||
};
|
||||
|
||||
pub use mailbox::{
|
||||
@ -205,8 +205,8 @@ pub use permission::{
|
||||
opencode_permission_block, render_permission_summary, resolve as resolve_permissions,
|
||||
AgentPermissionOverride, Capability, CommandMatcher, CommandRule, Effect, EffectivePermissions,
|
||||
Glob, PathScope, PermissionError, PermissionProjection, PermissionProjector, PermissionRule,
|
||||
PermissionSet, Posture, ProjectPermissions, ProjectedFile, ProjectionContext, ProjectorKey,
|
||||
PERMISSIONS_VERSION,
|
||||
PermissionSet, PermissionShadowReport, Posture, ProjectPermissions, ProjectedFile,
|
||||
ProjectionContext, ProjectorKey, PERMISSIONS_VERSION,
|
||||
};
|
||||
|
||||
pub use system_permissions::{
|
||||
@ -248,10 +248,10 @@ pub use ports::{
|
||||
MemoryStore, ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress,
|
||||
ModelArtifactResolution, Output, OutputStream, PermissionStore, PluginManifestBytes,
|
||||
PluginManifestError, PluginManifestValidator, PluginMcpError, PluginMcpSupervisor,
|
||||
PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStoreError,
|
||||
PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore,
|
||||
ProviderModelCatalogue, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath,
|
||||
RuntimeError, RuntimePermissionProbe, ScheduledTask, Scheduler, SpawnSpec, SprintStore,
|
||||
SprintStoreError, StoreError, StructuredSessionEnvironment,
|
||||
PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStorageError,
|
||||
PluginStorageStore, PluginStoreError, PreparedContext, ProcessError, ProcessSpawner,
|
||||
ProfileStore, ProjectStore, ProviderModelCatalogue, PtyError, PtyHandle, PtyPort, RemoteError,
|
||||
RemoteHost, RemotePath, RuntimeError, RuntimePermissionProbe, ScheduledTask, Scheduler,
|
||||
SpawnSpec, SprintStore, SprintStoreError, StoreError, StructuredSessionEnvironment,
|
||||
StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, WindowStateStore,
|
||||
};
|
||||
|
||||
@ -194,6 +194,9 @@ pub struct OrchestratorRequest {
|
||||
/// `memory.write`, cadrage C7). Required by those actions, ignored otherwise.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
/// Optional optimistic-concurrency version for `context.update`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub if_match: Option<String>,
|
||||
/// Target memory note slug for the memory tools (`memory.read`/`memory.write`,
|
||||
/// cadrage C7). Required by `memory.write`; optional for `memory.read` (absent ⇒
|
||||
/// the aggregated index). Ignored by the other actions.
|
||||
@ -342,6 +345,16 @@ pub enum OrchestratorCommand {
|
||||
/// The proposing party (handshake identity).
|
||||
requester: ConversationParty,
|
||||
},
|
||||
/// Directly update the global project context. Strict, orchestrator-only
|
||||
/// counterpart to [`Self::ProposeContext`].
|
||||
UpdateProjectContext {
|
||||
/// The new Markdown body.
|
||||
content: String,
|
||||
/// Optional expected current version.
|
||||
if_match: Option<String>,
|
||||
/// The writing party (handshake identity).
|
||||
requester: ConversationParty,
|
||||
},
|
||||
/// Read a memory note under the [`crate::fileguard::FileGuard`] (cadrage C7).
|
||||
/// `slug` absent = the aggregated `MEMORY.md` index; otherwise one note.
|
||||
ReadMemory {
|
||||
@ -500,6 +513,11 @@ impl OrchestratorRequest {
|
||||
content: self.require("content", action, self.content.as_deref())?,
|
||||
requester: self.requester_party(),
|
||||
}),
|
||||
"context.update" => Ok(OrchestratorCommand::UpdateProjectContext {
|
||||
content: self.require("content", action, self.content.as_deref())?,
|
||||
if_match: self.if_match.clone(),
|
||||
requester: self.requester_party(),
|
||||
}),
|
||||
"memory.read" => Ok(OrchestratorCommand::ReadMemory {
|
||||
slug: self.optional_slug(),
|
||||
requester: self.requester_party(),
|
||||
@ -1089,6 +1107,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_update_command_parses_content_and_if_match() {
|
||||
let uid = uuid::Uuid::from_u128(42);
|
||||
let r = req(&format!(
|
||||
r##"{{ "type":"context.update", "requestedBy":"{uid}", "content":"# body", "ifMatch":"abc123" }}"##
|
||||
));
|
||||
assert_eq!(
|
||||
r.validate().unwrap(),
|
||||
OrchestratorCommand::UpdateProjectContext {
|
||||
content: "# body".to_owned(),
|
||||
if_match: Some("abc123".to_owned()),
|
||||
requester: ConversationParty::agent(AgentId::from_uuid(uid)),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_update_requires_content() {
|
||||
let missing = req(r#"{ "type":"context.update", "ifMatch":"abc123" }"#);
|
||||
assert_eq!(
|
||||
missing.validate(),
|
||||
Err(OrchestratorError::MissingField {
|
||||
action: "context.update".to_owned(),
|
||||
field: "content".to_owned(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_read_optional_slug() {
|
||||
assert_eq!(
|
||||
|
||||
@ -542,6 +542,36 @@ impl ProjectPermissions {
|
||||
self.agent_permissions(agent_id),
|
||||
)
|
||||
}
|
||||
|
||||
/// Reports agent-level blanket allows that are shadowed by project-level
|
||||
/// blanket denies for `agent_id`.
|
||||
///
|
||||
/// This is a diagnostic companion to [`Self::resolve_for`]. It does not
|
||||
/// participate in permission resolution and does not change deny-wins.
|
||||
#[must_use]
|
||||
pub fn shadow_for(&self, agent_id: AgentId) -> PermissionShadowReport {
|
||||
shadow_report(
|
||||
self.project_defaults.as_ref(),
|
||||
self.agent_permissions(agent_id),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Diagnostic report for agent overrides that cannot loosen the project policy.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PermissionShadowReport {
|
||||
/// Agent blanket read allow is shadowed by a project blanket read deny.
|
||||
pub read: bool,
|
||||
/// Agent blanket write allow is shadowed by a project blanket write deny.
|
||||
pub write: bool,
|
||||
/// Agent blanket delete allow is shadowed by a project blanket delete deny.
|
||||
pub delete: bool,
|
||||
/// Agent blanket bash allow is shadowed by a project blanket bash deny.
|
||||
pub execute_bash: bool,
|
||||
/// Agent fallback choice is looser than the resolved project-tightened
|
||||
/// fallback.
|
||||
pub fallback: bool,
|
||||
}
|
||||
|
||||
/// The normalised, flattened output of [`resolve`] — the **sole input** of the
|
||||
@ -713,6 +743,72 @@ pub fn resolve(
|
||||
Some(EffectivePermissions { rules, fallback })
|
||||
}
|
||||
|
||||
/// Reports agent-level blanket allows shadowed by project-level blanket denies.
|
||||
///
|
||||
/// This is deliberately **not** a general glob-overlap solver. It is shaped to
|
||||
/// the current UI contract: file capabilities are considered blanket only when
|
||||
/// the rule has exactly one glob, `"**"`; bash is considered blanket only when
|
||||
/// the rule has no command matchers. Scoped rules are ignored by this diagnostic
|
||||
/// even though normal [`resolve`] and decision methods still honour them.
|
||||
#[must_use]
|
||||
pub fn shadow_report(
|
||||
project: Option<&PermissionSet>,
|
||||
agent: Option<&PermissionSet>,
|
||||
) -> PermissionShadowReport {
|
||||
let Some(agent) = agent else {
|
||||
return PermissionShadowReport::default();
|
||||
};
|
||||
|
||||
let shadowed = |capability| {
|
||||
blanket_effect(project, capability, BlanketLookupMode::DenyWins) == Some(Effect::Deny)
|
||||
&& blanket_effect(Some(agent), capability, BlanketLookupMode::AllowWins)
|
||||
== Some(Effect::Allow)
|
||||
};
|
||||
let fallback = resolve(project, Some(agent))
|
||||
.is_some_and(|resolved| agent.fallback() != resolved.fallback());
|
||||
|
||||
PermissionShadowReport {
|
||||
read: shadowed(Capability::Read),
|
||||
write: shadowed(Capability::Write),
|
||||
delete: shadowed(Capability::Delete),
|
||||
execute_bash: shadowed(Capability::ExecuteBash),
|
||||
fallback,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum BlanketLookupMode {
|
||||
DenyWins,
|
||||
AllowWins,
|
||||
}
|
||||
|
||||
fn blanket_effect(
|
||||
set: Option<&PermissionSet>,
|
||||
capability: Capability,
|
||||
mode: BlanketLookupMode,
|
||||
) -> Option<Effect> {
|
||||
let mut found = None;
|
||||
for rule in set?.rules() {
|
||||
if rule.capability() != capability || !is_blanket_rule(rule) {
|
||||
continue;
|
||||
}
|
||||
match (mode, rule.effect()) {
|
||||
(BlanketLookupMode::DenyWins, Effect::Deny) => return Some(Effect::Deny),
|
||||
(BlanketLookupMode::AllowWins, Effect::Allow) => return Some(Effect::Allow),
|
||||
_ => found = Some(rule.effect()),
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
fn is_blanket_rule(rule: &PermissionRule) -> bool {
|
||||
if rule.capability().is_bash() {
|
||||
return rule.commands().is_empty();
|
||||
}
|
||||
let globs = rule.paths().globs();
|
||||
globs.len() == 1 && globs[0].pattern() == "**"
|
||||
}
|
||||
|
||||
/// Renders a human-readable Markdown **summary** of the resolved policy, suitable
|
||||
/// for injection into an agent's context (lot LP4-0).
|
||||
///
|
||||
@ -1118,6 +1214,10 @@ mod tests {
|
||||
PathScope::new(patterns.iter().map(|s| s.to_string())).unwrap()
|
||||
}
|
||||
|
||||
fn blanket_file(capability: Capability, effect: Effect) -> PermissionRule {
|
||||
PermissionRule::file(capability, effect, path_scope(&["**"])).unwrap()
|
||||
}
|
||||
|
||||
// ---- VO construction & invariants -----------------------------------
|
||||
|
||||
#[test]
|
||||
@ -1398,6 +1498,155 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ---- shadow diagnostics (ticket #122) -------------------------------
|
||||
|
||||
#[test]
|
||||
fn shadow_report_flags_read_write_delete_bash_when_project_blanket_deny_beats_agent_blanket_allow(
|
||||
) {
|
||||
let project = PermissionSet::new(
|
||||
vec![
|
||||
blanket_file(Capability::Read, Effect::Deny),
|
||||
blanket_file(Capability::Write, Effect::Deny),
|
||||
blanket_file(Capability::Delete, Effect::Deny),
|
||||
PermissionRule::bash(Effect::Deny, vec![]),
|
||||
],
|
||||
Posture::Ask,
|
||||
);
|
||||
let agent = PermissionSet::new(
|
||||
vec![
|
||||
blanket_file(Capability::Read, Effect::Allow),
|
||||
blanket_file(Capability::Write, Effect::Allow),
|
||||
blanket_file(Capability::Delete, Effect::Allow),
|
||||
PermissionRule::bash(Effect::Allow, vec![]),
|
||||
],
|
||||
Posture::Ask,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
shadow_report(Some(&project), Some(&agent)),
|
||||
PermissionShadowReport {
|
||||
read: true,
|
||||
write: true,
|
||||
delete: true,
|
||||
execute_bash: true,
|
||||
fallback: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_report_false_when_project_has_no_deny_rule_for_capability() {
|
||||
let project = PermissionSet::new(
|
||||
vec![blanket_file(Capability::Read, Effect::Allow)],
|
||||
Posture::Ask,
|
||||
);
|
||||
let agent = PermissionSet::new(
|
||||
vec![blanket_file(Capability::Read, Effect::Allow)],
|
||||
Posture::Ask,
|
||||
);
|
||||
|
||||
assert_eq!(shadow_report(Some(&project), Some(&agent)).read, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_report_false_when_agent_rule_is_scoped_not_blanket() {
|
||||
let project = PermissionSet::new(
|
||||
vec![blanket_file(Capability::Write, Effect::Deny)],
|
||||
Posture::Ask,
|
||||
);
|
||||
let agent = PermissionSet::new(
|
||||
vec![
|
||||
PermissionRule::file(Capability::Write, Effect::Allow, path_scope(&["src/**"]))
|
||||
.unwrap(),
|
||||
],
|
||||
Posture::Ask,
|
||||
);
|
||||
|
||||
assert_eq!(shadow_report(Some(&project), Some(&agent)).write, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_report_false_when_agent_also_denies() {
|
||||
let project = PermissionSet::new(
|
||||
vec![PermissionRule::bash(Effect::Deny, vec![])],
|
||||
Posture::Ask,
|
||||
);
|
||||
let agent = PermissionSet::new(
|
||||
vec![PermissionRule::bash(Effect::Deny, vec![])],
|
||||
Posture::Ask,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
shadow_report(Some(&project), Some(&agent)).execute_bash,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_report_fallback_true_when_project_fallback_stricter_than_agent_chosen_fallback() {
|
||||
let project = PermissionSet::new(vec![], Posture::Deny);
|
||||
let agent = PermissionSet::new(vec![], Posture::Allow);
|
||||
|
||||
assert!(shadow_report(Some(&project), Some(&agent)).fallback);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_report_all_false_when_agent_is_none() {
|
||||
let project = PermissionSet::new(
|
||||
vec![PermissionRule::bash(Effect::Deny, vec![])],
|
||||
Posture::Deny,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
shadow_report(Some(&project), None),
|
||||
PermissionShadowReport::default()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_for_matches_free_function_via_project_permissions() {
|
||||
let agent = AgentId::new_random();
|
||||
let project = PermissionSet::new(
|
||||
vec![PermissionRule::bash(Effect::Deny, vec![])],
|
||||
Posture::Ask,
|
||||
);
|
||||
let custom = PermissionSet::new(
|
||||
vec![PermissionRule::bash(Effect::Allow, vec![])],
|
||||
Posture::Ask,
|
||||
);
|
||||
let doc = ProjectPermissions::new(
|
||||
Some(project.clone()),
|
||||
vec![AgentPermissionOverride::new(agent, custom.clone())],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
doc.shadow_for(agent),
|
||||
shadow_report(Some(&project), Some(&custom))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_shadow_report_serialises_camel_case() {
|
||||
let report = PermissionShadowReport {
|
||||
read: false,
|
||||
write: false,
|
||||
delete: false,
|
||||
execute_bash: true,
|
||||
fallback: true,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(report).unwrap(),
|
||||
serde_json::json!({
|
||||
"read": false,
|
||||
"write": false,
|
||||
"delete": false,
|
||||
"executeBash": true,
|
||||
"fallback": true
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ---- LP3: ProjectorKey serde + PermissionProjection invariant ------
|
||||
|
||||
#[test]
|
||||
|
||||
@ -386,6 +386,48 @@ pub enum PluginMcpError {
|
||||
Process(String),
|
||||
}
|
||||
|
||||
/// Plugin-owned storage errors.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum PluginStorageError {
|
||||
/// Invalid key or value.
|
||||
#[error("plugin storage invalid input: {0}")]
|
||||
Invalid(String),
|
||||
/// Filesystem failure.
|
||||
#[error("plugin storage I/O error: {0}")]
|
||||
Io(String),
|
||||
/// Serialization failure.
|
||||
#[error("plugin storage serialization error: {0}")]
|
||||
Serialization(String),
|
||||
}
|
||||
|
||||
/// Store for plugin-owned key/value JSON data under app-data `plugins/data/<pluginId>/`.
|
||||
#[async_trait]
|
||||
pub trait PluginStorageStore: Send + Sync {
|
||||
/// Reads one plugin-owned value.
|
||||
async fn get(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
key: &str,
|
||||
) -> Result<Option<Value>, PluginStorageError>;
|
||||
|
||||
/// Writes one plugin-owned value.
|
||||
async fn set(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
key: &str,
|
||||
value: Value,
|
||||
) -> Result<(), PluginStorageError>;
|
||||
|
||||
/// Deletes one plugin-owned value.
|
||||
async fn delete(&self, plugin_id: &PluginId, key: &str) -> Result<bool, PluginStorageError>;
|
||||
|
||||
/// Purges every plugin-owned value for one plugin.
|
||||
async fn purge_plugin(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
) -> Result<RemovalOutcome, PluginStorageError>;
|
||||
}
|
||||
|
||||
/// Store for installed plugin packages under the global app data directory.
|
||||
#[async_trait]
|
||||
pub trait PluginPackageStore: Send + Sync {
|
||||
|
||||
@ -846,6 +846,53 @@ fn toml_string(s: &str) -> String {
|
||||
json_string(s)
|
||||
}
|
||||
|
||||
/// One effort/reasoning preset a profile natively exposes to the UI.
|
||||
/// Declaration order is light to deep and is never reordered by consumers.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EffortOption {
|
||||
/// Raw value forwarded to the CLI/session (e.g. Codex's `"medium"`).
|
||||
pub value: String,
|
||||
/// Human-readable label for the UI droplist.
|
||||
pub label: String,
|
||||
/// Optional short description.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub hint: Option<String>,
|
||||
}
|
||||
|
||||
/// A per-agent effort choice, preserving whether the value came from a profile
|
||||
/// preset or free text.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
|
||||
pub enum EffortSelection {
|
||||
/// Picked from the profile's declared `effort_options`.
|
||||
Preset(String),
|
||||
/// Freehand value.
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
impl EffortSelection {
|
||||
/// Raw effort value forwarded to the session, regardless of provenance.
|
||||
#[must_use]
|
||||
pub fn value(&self) -> &str {
|
||||
match self {
|
||||
Self::Preset(value) | Self::Custom(value) => value,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the raw effort value forwarded to a session launch: the agent's
|
||||
/// explicit selection wins; otherwise the profile's static default.
|
||||
#[must_use]
|
||||
pub fn resolve_effort(
|
||||
profile_default: Option<&str>,
|
||||
agent_selection: Option<&EffortSelection>,
|
||||
) -> Option<String> {
|
||||
agent_selection
|
||||
.map(|selection| selection.value().to_owned())
|
||||
.or_else(|| profile_default.map(str::to_owned))
|
||||
}
|
||||
|
||||
/// Declarative runtime configuration for one AI CLI.
|
||||
///
|
||||
/// Invariants:
|
||||
@ -914,6 +961,10 @@ pub struct AgentProfile {
|
||||
/// conserve le défaut natif de la CLI ; seules les sessions Codex le consomment.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model_reasoning_effort: Option<String>,
|
||||
/// Effort/reasoning presets this profile natively exposes (ticket #131).
|
||||
/// Empty means the provider declares none.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub effort_options: Vec<EffortOption>,
|
||||
/// Capacité **MCP** (ARCHITECTURE §14.3, orchestration v3, Décision 1).
|
||||
/// `None` ⇒ repli fichier `.ideai/requests` + prose (comportement actuel).
|
||||
/// `Some(_)` ⇒ IdeA matérialise la config MCP de cette CLI au lancement et
|
||||
@ -1122,6 +1173,7 @@ impl AgentProfile {
|
||||
opencode_provider: None,
|
||||
model: None,
|
||||
model_reasoning_effort: None,
|
||||
effort_options: Vec::new(),
|
||||
mcp: None,
|
||||
liveness: None,
|
||||
rate_limit_pattern: None,
|
||||
@ -1186,6 +1238,13 @@ impl AgentProfile {
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder : fixe les presets d'effort exposés par ce profil.
|
||||
#[must_use]
|
||||
pub fn with_effort_options(mut self, options: Vec<EffortOption>) -> Self {
|
||||
self.effort_options = options;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder : fixe la [`McpCapability`] (§14.3, orchestration v3) et renvoie le
|
||||
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
|
||||
/// profils sans MCP ne l'appellent simplement pas.
|
||||
@ -1519,6 +1578,88 @@ mod mcp_tests {
|
||||
assert_eq!(back.model_reasoning_effort.as_deref(), Some("medium"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effort_option_round_trips_camel_case() {
|
||||
let option = EffortOption {
|
||||
value: "medium".to_owned(),
|
||||
label: "Medium".to_owned(),
|
||||
hint: Some("Balanced".to_owned()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&option).expect("serialise");
|
||||
assert_eq!(
|
||||
json,
|
||||
r#"{"value":"medium","label":"Medium","hint":"Balanced"}"#
|
||||
);
|
||||
let back: EffortOption = serde_json::from_str(&json).expect("deserialise");
|
||||
assert_eq!(back, option);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effort_selection_preset_and_custom_value_accessor() {
|
||||
assert_eq!(EffortSelection::Preset("high".to_owned()).value(), "high");
|
||||
assert_eq!(
|
||||
EffortSelection::Custom("provider-x".to_owned()).value(),
|
||||
"provider-x"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effort_selection_tagged_serde_shape() {
|
||||
let preset = serde_json::to_string(&EffortSelection::Preset("medium".to_owned()))
|
||||
.expect("serialise");
|
||||
assert_eq!(preset, r#"{"kind":"preset","value":"medium"}"#);
|
||||
|
||||
let custom = serde_json::to_string(&EffortSelection::Custom("x-deep".to_owned()))
|
||||
.expect("serialise");
|
||||
assert_eq!(custom, r#"{"kind":"custom","value":"x-deep"}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_effort_prefers_agent_selection_over_profile_default() {
|
||||
let selection = EffortSelection::Preset("high".to_owned());
|
||||
assert_eq!(
|
||||
resolve_effort(Some("low"), Some(&selection)).as_deref(),
|
||||
Some("high")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_effort_falls_back_to_profile_default_when_agent_selection_absent() {
|
||||
assert_eq!(resolve_effort(Some("low"), None).as_deref(), Some("low"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_effort_none_when_neither_present() {
|
||||
assert_eq!(resolve_effort(None, None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_profile_new_effort_options_defaults_empty_and_serialises_omitted() {
|
||||
let profile = profile_without_mcp();
|
||||
assert!(profile.effort_options.is_empty());
|
||||
|
||||
let json = serde_json::to_string(&profile).expect("serialise");
|
||||
assert!(
|
||||
!json.contains("\"effortOptions\""),
|
||||
"a profile without effort options must keep the legacy JSON shape: {json}"
|
||||
);
|
||||
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
|
||||
assert!(back.effort_options.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_effort_options_builder_sets_field() {
|
||||
let option = EffortOption {
|
||||
value: "high".to_owned(),
|
||||
label: "High".to_owned(),
|
||||
hint: None,
|
||||
};
|
||||
let profile = profile_without_mcp().with_effort_options(vec![option.clone()]);
|
||||
|
||||
assert_eq!(profile.effort_options, vec![option]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_backend_consistency_rejects_both_configs_set() {
|
||||
let local = OpenCodeConfig::new(
|
||||
|
||||
@ -24,6 +24,7 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
# AEAD encryption for the at-rest `SecretStore` adapter (ticket #92, lot B2).
|
||||
# Already vendored transitively (rustls/reqwest use it) — made an explicit direct
|
||||
# dependency here rather than adding a new crate to the tree.
|
||||
|
||||
@ -88,7 +88,9 @@ pub use orchestrator::{
|
||||
};
|
||||
pub use pair_attempt_limiter::InMemoryPairAttemptLimiter;
|
||||
pub use permission::{ClaudePermissionProjector, CodexPermissionProjector};
|
||||
pub use plugin::{ExternalMcpPluginSupervisor, FsPluginPackageStore, FsPluginRegistryStore};
|
||||
pub use plugin::{
|
||||
ExternalMcpPluginSupervisor, FsPluginPackageStore, FsPluginRegistryStore, FsPluginStorageStore,
|
||||
};
|
||||
pub use process::{LocalEnvironmentReader, LocalProcessSpawner};
|
||||
pub use pty::PortablePtyAdapter;
|
||||
pub use ratelimit::RateLimitParser;
|
||||
|
||||
@ -68,6 +68,7 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[
|
||||
"idea_stop_agent",
|
||||
"idea_update_context",
|
||||
"idea_context_propose",
|
||||
"idea_context_update",
|
||||
"idea_memory_write",
|
||||
"idea_workstate_set",
|
||||
"idea_create_skill",
|
||||
@ -166,7 +167,7 @@ pub fn catalogue() -> Vec<ToolDef> {
|
||||
name: "idea_list_agents",
|
||||
description: "List the IdeA agents declared in the project's manifest. Returns the \
|
||||
agents inline as a JSON array (id, name, profile, origin, raw skills, \
|
||||
and resolved capabilities).",
|
||||
resolved capabilities, and isOrchestrator).",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
@ -308,6 +309,23 @@ pub fn catalogue() -> Vec<ToolDef> {
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_context_update",
|
||||
description: "Directly update the global project context. Strict, orchestrator-only \
|
||||
counterpart to idea_context_propose: if you are not the project's \
|
||||
current orchestrator (see isOrchestrator on idea_list_agents), this call \
|
||||
fails and never silently files a proposal. Pass ifMatch from a prior \
|
||||
idea_context_read to reject the write if the context changed.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": { "type": "string", "description": "The new Markdown body." },
|
||||
"ifMatch": { "type": "string", "description": "Optional version from a prior idea_context_read; rejected if stale." }
|
||||
},
|
||||
"required": ["content"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_memory_read",
|
||||
description: "Read project memory (under the file guard). Omit `slug` for the aggregated \
|
||||
@ -485,6 +503,16 @@ pub fn map_tool_call(
|
||||
content: s("content"),
|
||||
..base()
|
||||
},
|
||||
"idea_context_update" => {
|
||||
reject_unknown_keys(name, args, &["content", "ifMatch"])?;
|
||||
OrchestratorRequest {
|
||||
request_type: Some("context.update".to_owned()),
|
||||
requested_by: Some(requester.to_owned()),
|
||||
content: s("content"),
|
||||
if_match: s("ifMatch"),
|
||||
..base()
|
||||
}
|
||||
}
|
||||
"idea_memory_read" => OrchestratorRequest {
|
||||
request_type: Some("memory.read".to_owned()),
|
||||
requested_by: Some(requester.to_owned()),
|
||||
@ -556,6 +584,7 @@ fn base() -> OrchestratorRequest {
|
||||
result: None,
|
||||
ticket: None,
|
||||
content: None,
|
||||
if_match: None,
|
||||
slug: None,
|
||||
status: None,
|
||||
intent: None,
|
||||
@ -569,6 +598,20 @@ fn base() -> OrchestratorRequest {
|
||||
}
|
||||
}
|
||||
|
||||
fn reject_unknown_keys(
|
||||
tool: &str,
|
||||
args: &serde_json::Map<String, Value>,
|
||||
allowed: &[&str],
|
||||
) -> Result<(), ToolMapError> {
|
||||
if args
|
||||
.keys()
|
||||
.any(|key| !allowed.iter().any(|allowed_key| allowed_key == key))
|
||||
{
|
||||
return Err(ToolMapError::BadArguments(tool.to_owned()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parses an optional `nodeId` JSON value into a [`domain::NodeId`], silently
|
||||
/// dropping a malformed/absent one (validation then rejects a `visible` launch
|
||||
/// missing its node, with a precise field error).
|
||||
@ -880,6 +923,51 @@ mod tests {
|
||||
assert!(matches!(err, Err(ToolMapError::Invalid(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idea_context_update_tool_registered_and_maps_to_context_update_command() {
|
||||
assert_eq!(
|
||||
tool_access("idea_context_update"),
|
||||
Some(McpToolAccess::WriteAction)
|
||||
);
|
||||
assert!(catalogue()
|
||||
.into_iter()
|
||||
.any(|tool| tool.name == "idea_context_update"));
|
||||
|
||||
let cmd = map_tool_call(
|
||||
"idea_context_update",
|
||||
&json!({ "content": "# body", "ifMatch": "abc123" }),
|
||||
REQ,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
cmd,
|
||||
OrchestratorCommand::UpdateProjectContext {
|
||||
content: "# body".to_owned(),
|
||||
if_match: Some("abc123".to_owned()),
|
||||
requester: domain::ConversationParty::agent(domain::AgentId::from_uuid(
|
||||
uuid::Uuid::parse_str(REQ).unwrap()
|
||||
)),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idea_context_update_requires_content_and_rejects_unknown_fields() {
|
||||
let missing = map_tool_call("idea_context_update", &json!({ "ifMatch": "abc123" }), REQ);
|
||||
assert!(matches!(missing, Err(ToolMapError::Invalid(_))));
|
||||
|
||||
let unknown = map_tool_call(
|
||||
"idea_context_update",
|
||||
&json!({ "content": "# body", "target": "Dev" }),
|
||||
REQ,
|
||||
);
|
||||
assert_eq!(
|
||||
unknown,
|
||||
Err(ToolMapError::BadArguments("idea_context_update".to_owned()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_read_and_write_map_to_their_commands() {
|
||||
assert_eq!(
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
@ -11,7 +11,8 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{
|
||||
LocalPath, PluginManifestBytes, PluginMcpError, PluginMcpSupervisor, PluginPackageStore,
|
||||
PluginRegistryError, PluginRegistryStore, PluginStoreError,
|
||||
PluginRegistryError, PluginRegistryStore, PluginStorageError, PluginStorageStore,
|
||||
PluginStoreError,
|
||||
};
|
||||
use domain::{
|
||||
ContentHash, PluginBundleUrl, PluginId, PluginInstallSource, PluginMcpServerSpec,
|
||||
@ -23,6 +24,14 @@ use tokio::process::Child;
|
||||
|
||||
const REGISTRY_FILE: &str = "registry.json";
|
||||
const MANIFEST_FILE: &str = "idea-plugin.json";
|
||||
const STORAGE_ENTRIES_DIR: &str = "entries";
|
||||
|
||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PluginStorageEntry {
|
||||
key: String,
|
||||
value: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Filesystem package store under app-data `plugins/`.
|
||||
#[derive(Debug, Clone)]
|
||||
@ -151,18 +160,7 @@ impl PluginPackageStore for FsPluginPackageStore {
|
||||
archive: &LocalPath,
|
||||
) -> Result<StagedPluginPackage, PluginStoreError> {
|
||||
let stage = self.stage_root()?;
|
||||
let status = std::process::Command::new("unzip")
|
||||
.arg("-q")
|
||||
.arg(archive.as_str())
|
||||
.arg("-d")
|
||||
.arg(&stage)
|
||||
.status()
|
||||
.map_err(|e| PluginStoreError::Io(format!("failed to run unzip: {e}")))?;
|
||||
if !status.success() {
|
||||
return Err(PluginStoreError::Format(format!(
|
||||
"unzip exited with status {status}"
|
||||
)));
|
||||
}
|
||||
extract_archive_confined(Path::new(archive.as_str()), &stage)?;
|
||||
ensure_manifest(&stage)?;
|
||||
let content_hash = hash_dir(&stage)?;
|
||||
Ok(StagedPluginPackage {
|
||||
@ -278,6 +276,43 @@ fn ensure_manifest(root: &Path) -> Result<(), PluginStoreError> {
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_archive_confined(archive: &Path, stage: &Path) -> Result<(), PluginStoreError> {
|
||||
let file = fs::File::open(archive).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
let mut archive = zip::ZipArchive::new(file)
|
||||
.map_err(|e| PluginStoreError::Format(format!("invalid zip archive: {e}")))?;
|
||||
for index in 0..archive.len() {
|
||||
let mut entry = archive
|
||||
.by_index(index)
|
||||
.map_err(|e| PluginStoreError::Format(format!("invalid zip entry: {e}")))?;
|
||||
let entry_name = entry.name().to_owned();
|
||||
let enclosed = entry.enclosed_name().ok_or_else(|| {
|
||||
PluginStoreError::Invalid(format!("archive entry escapes plugin root: {entry_name}"))
|
||||
})?;
|
||||
if entry
|
||||
.unix_mode()
|
||||
.is_some_and(|mode| mode & 0o170000 == 0o120000)
|
||||
{
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"archive entry symlinks are not allowed: {entry_name}"
|
||||
)));
|
||||
}
|
||||
let destination = stage.join(&enclosed);
|
||||
if entry.is_dir() {
|
||||
fs::create_dir_all(&destination).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
continue;
|
||||
}
|
||||
if let Some(parent) = destination.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
}
|
||||
let mut out =
|
||||
fs::File::create(&destination).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
io::copy(&mut entry, &mut out).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
out.flush()
|
||||
.map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_dir_all(source: &Path, target: &Path) -> Result<(), PluginStoreError> {
|
||||
fs::create_dir_all(target).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
for entry in fs::read_dir(source).map_err(|e| PluginStoreError::Io(e.to_string()))? {
|
||||
@ -290,6 +325,16 @@ fn copy_dir_all(source: &Path, target: &Path) -> Result<(), PluginStoreError> {
|
||||
copy_dir_all(&entry.path(), &dest)?;
|
||||
} else if ty.is_file() {
|
||||
fs::copy(entry.path(), dest).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
} else if ty.is_symlink() {
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"plugin source contains symlink: {}",
|
||||
entry.path().display()
|
||||
)));
|
||||
} else {
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"plugin source contains unsupported entry: {}",
|
||||
entry.path().display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@ -329,6 +374,16 @@ fn collect_files(root: &Path, files: &mut Vec<PathBuf>) -> Result<(), PluginStor
|
||||
collect_files(&entry.path(), files)?;
|
||||
} else if ty.is_file() {
|
||||
files.push(entry.path());
|
||||
} else if ty.is_symlink() {
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"plugin package contains symlink: {}",
|
||||
entry.path().display()
|
||||
)));
|
||||
} else {
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"plugin package contains unsupported entry: {}",
|
||||
entry.path().display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@ -354,6 +409,97 @@ impl FsPluginRegistryStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Filesystem plugin-owned storage under app-data `plugins/data/<pluginId>/`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FsPluginStorageStore {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl FsPluginStorageStore {
|
||||
/// Builds the store.
|
||||
#[must_use]
|
||||
pub fn new(app_data_dir: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
root: app_data_dir.into().join("plugins").join("data"),
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_dir(&self, plugin_id: &PluginId) -> PathBuf {
|
||||
self.root.join(plugin_id.as_str())
|
||||
}
|
||||
|
||||
fn entry_path(&self, plugin_id: &PluginId, key: &str) -> PathBuf {
|
||||
let digest = Sha256::digest(key.as_bytes());
|
||||
self.plugin_dir(plugin_id)
|
||||
.join(STORAGE_ENTRIES_DIR)
|
||||
.join(format!("{}.json", hex::encode(digest)))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginStorageStore for FsPluginStorageStore {
|
||||
async fn get(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
key: &str,
|
||||
) -> Result<Option<serde_json::Value>, PluginStorageError> {
|
||||
let path = self.entry_path(plugin_id, key);
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let bytes = fs::read(&path).map_err(|e| PluginStorageError::Io(e.to_string()))?;
|
||||
let entry: PluginStorageEntry = serde_json::from_slice(&bytes)
|
||||
.map_err(|e| PluginStorageError::Serialization(e.to_string()))?;
|
||||
if entry.key == key {
|
||||
Ok(Some(entry.value))
|
||||
} else {
|
||||
Err(PluginStorageError::Invalid(
|
||||
"plugin storage key hash collision".to_owned(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
async fn set(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
key: &str,
|
||||
value: serde_json::Value,
|
||||
) -> Result<(), PluginStorageError> {
|
||||
let path = self.entry_path(plugin_id, key);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| PluginStorageError::Io(e.to_string()))?;
|
||||
}
|
||||
let entry = PluginStorageEntry {
|
||||
key: key.to_owned(),
|
||||
value,
|
||||
};
|
||||
let bytes = serde_json::to_vec_pretty(&entry)
|
||||
.map_err(|e| PluginStorageError::Serialization(e.to_string()))?;
|
||||
fs::write(path, bytes).map_err(|e| PluginStorageError::Io(e.to_string()))
|
||||
}
|
||||
|
||||
async fn delete(&self, plugin_id: &PluginId, key: &str) -> Result<bool, PluginStorageError> {
|
||||
let path = self.entry_path(plugin_id, key);
|
||||
match fs::remove_file(path) {
|
||||
Ok(()) => Ok(true),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
||||
Err(e) => Err(PluginStorageError::Io(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn purge_plugin(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
) -> Result<RemovalOutcome, PluginStorageError> {
|
||||
let dir = self.plugin_dir(plugin_id);
|
||||
match fs::remove_dir_all(dir) {
|
||||
Ok(()) => Ok(RemovalOutcome::Removed),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(RemovalOutcome::NotFound),
|
||||
Err(e) => Err(PluginStorageError::Io(e.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginRegistryStore for FsPluginRegistryStore {
|
||||
async fn load_registry(&self) -> Result<PluginRegistry, PluginRegistryError> {
|
||||
@ -574,6 +720,99 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
struct ZipEntrySpec<'a> {
|
||||
name: &'a str,
|
||||
contents: &'a [u8],
|
||||
unix_mode: Option<u32>,
|
||||
}
|
||||
|
||||
fn write_u16(out: &mut Vec<u8>, value: u16) {
|
||||
out.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn write_u32(out: &mut Vec<u8>, value: u32) {
|
||||
out.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn crc32(bytes: &[u8]) -> u32 {
|
||||
let mut crc = 0xffff_ffffu32;
|
||||
for &byte in bytes {
|
||||
crc ^= u32::from(byte);
|
||||
for _ in 0..8 {
|
||||
let mask = (crc & 1).wrapping_neg();
|
||||
crc = (crc >> 1) ^ (0xedb8_8320 & mask);
|
||||
}
|
||||
}
|
||||
!crc
|
||||
}
|
||||
|
||||
fn write_zip(path: &Path, entries: &[ZipEntrySpec<'_>]) {
|
||||
let mut file = fs::File::create(path).unwrap();
|
||||
let mut central = Vec::new();
|
||||
let mut offset = 0u32;
|
||||
for entry in entries {
|
||||
let name = entry.name.as_bytes();
|
||||
let data = entry.contents;
|
||||
let crc = crc32(data);
|
||||
let local_size = 30u32 + name.len() as u32 + data.len() as u32;
|
||||
|
||||
let mut local = Vec::new();
|
||||
write_u32(&mut local, 0x0403_4b50);
|
||||
write_u16(&mut local, 20);
|
||||
write_u16(&mut local, 0);
|
||||
write_u16(&mut local, 0);
|
||||
write_u16(&mut local, 0);
|
||||
write_u16(&mut local, 0);
|
||||
write_u32(&mut local, crc);
|
||||
write_u32(&mut local, data.len() as u32);
|
||||
write_u32(&mut local, data.len() as u32);
|
||||
write_u16(&mut local, name.len() as u16);
|
||||
write_u16(&mut local, 0);
|
||||
local.extend_from_slice(name);
|
||||
local.extend_from_slice(data);
|
||||
file.write_all(&local).unwrap();
|
||||
|
||||
let mut header = Vec::new();
|
||||
let version_made_by = if entry.unix_mode.is_some() {
|
||||
(3u16 << 8) | 20
|
||||
} else {
|
||||
20
|
||||
};
|
||||
let external_attributes = entry.unix_mode.unwrap_or(0) << 16;
|
||||
write_u32(&mut header, 0x0201_4b50);
|
||||
write_u16(&mut header, version_made_by);
|
||||
write_u16(&mut header, 20);
|
||||
write_u16(&mut header, 0);
|
||||
write_u16(&mut header, 0);
|
||||
write_u16(&mut header, 0);
|
||||
write_u16(&mut header, 0);
|
||||
write_u32(&mut header, crc);
|
||||
write_u32(&mut header, data.len() as u32);
|
||||
write_u32(&mut header, data.len() as u32);
|
||||
write_u16(&mut header, name.len() as u16);
|
||||
write_u16(&mut header, 0);
|
||||
write_u16(&mut header, 0);
|
||||
write_u16(&mut header, 0);
|
||||
write_u16(&mut header, 0);
|
||||
write_u32(&mut header, external_attributes);
|
||||
write_u32(&mut header, offset);
|
||||
header.extend_from_slice(name);
|
||||
central.extend_from_slice(&header);
|
||||
offset += local_size;
|
||||
}
|
||||
file.write_all(¢ral).unwrap();
|
||||
let mut eocd = Vec::new();
|
||||
write_u32(&mut eocd, 0x0605_4b50);
|
||||
write_u16(&mut eocd, 0);
|
||||
write_u16(&mut eocd, 0);
|
||||
write_u16(&mut eocd, entries.len() as u16);
|
||||
write_u16(&mut eocd, entries.len() as u16);
|
||||
write_u32(&mut eocd, central.len() as u32);
|
||||
write_u32(&mut eocd, offset);
|
||||
write_u16(&mut eocd, 0);
|
||||
file.write_all(&eocd).unwrap();
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingBridge {
|
||||
started: Mutex<Vec<PluginMcpServerSpec>>,
|
||||
@ -671,35 +910,23 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn extracts_archive_without_path_escape() {
|
||||
if std::process::Command::new("zip")
|
||||
.arg("-h")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.is_err()
|
||||
|| std::process::Command::new("unzip")
|
||||
.arg("-h")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let app = temp_dir("archive-app");
|
||||
let source = temp_dir("archive-source");
|
||||
write_plugin(&source, "bundle");
|
||||
let archive_path = app.join("plugin.ideaplug");
|
||||
{
|
||||
let status = std::process::Command::new("zip")
|
||||
.arg("-qr")
|
||||
.arg(&archive_path)
|
||||
.arg(".")
|
||||
.current_dir(&source)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success());
|
||||
}
|
||||
write_zip(
|
||||
&archive_path,
|
||||
&[
|
||||
ZipEntrySpec {
|
||||
name: "idea-plugin.json",
|
||||
contents: br#"{"ideaPluginManifestVersion":1,"id":"dev.acme.test","displayName":"Test","version":"1.0.0","main":"dist/index.js","trustLevel":"full","contributes":{}}"#,
|
||||
unix_mode: Some(0o100644),
|
||||
},
|
||||
ZipEntrySpec {
|
||||
name: "dist/index.js",
|
||||
contents: b"bundle",
|
||||
unix_mode: Some(0o100644),
|
||||
},
|
||||
],
|
||||
);
|
||||
let store = FsPluginPackageStore::new(app.join("data"));
|
||||
let staged = store
|
||||
.install_from_archive(&LocalPath::new(archive_path.to_string_lossy()))
|
||||
@ -707,9 +934,142 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(PathBuf::from(staged.root).join(MANIFEST_FILE).exists());
|
||||
let _ = fs::remove_dir_all(app);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_from_directory_rejects_source_symlink() {
|
||||
let app = temp_dir("symlink-app");
|
||||
let source = temp_dir("symlink-source");
|
||||
write_plugin(&source, "bundle");
|
||||
let outside = app.join("outside.txt");
|
||||
fs::write(&outside, "secret").unwrap();
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(&outside, source.join("dist/escape.txt")).unwrap();
|
||||
#[cfg(windows)]
|
||||
std::os::windows::fs::symlink_file(&outside, source.join("dist/escape.txt")).unwrap();
|
||||
let store = FsPluginPackageStore::new(&app);
|
||||
|
||||
let err = store
|
||||
.install_from_directory(&LocalPath::new(source.to_string_lossy()))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(err, PluginStoreError::Invalid(_)));
|
||||
assert!(err.to_string().contains("symlink"));
|
||||
assert!(!app.join("plugins/installed/dev.acme.test").exists());
|
||||
let _ = fs::remove_dir_all(app);
|
||||
let _ = fs::remove_dir_all(source);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_from_archive_rejects_parent_traversal_without_writing_outside_stage() {
|
||||
let app = temp_dir("traversal-app");
|
||||
let archive_path = app.join("plugin.ideaplug");
|
||||
write_zip(
|
||||
&archive_path,
|
||||
&[
|
||||
ZipEntrySpec {
|
||||
name: "../../../../outside.txt",
|
||||
contents: b"pwned",
|
||||
unix_mode: Some(0o100644),
|
||||
},
|
||||
ZipEntrySpec {
|
||||
name: "idea-plugin.json",
|
||||
contents: br#"{"ideaPluginManifestVersion":1}"#,
|
||||
unix_mode: Some(0o100644),
|
||||
},
|
||||
],
|
||||
);
|
||||
let store = FsPluginPackageStore::new(&app);
|
||||
|
||||
let err = store
|
||||
.install_from_archive(&LocalPath::new(archive_path.to_string_lossy()))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(err, PluginStoreError::Invalid(_)));
|
||||
assert!(err.to_string().contains("escapes plugin root"));
|
||||
assert!(!app.join("outside.txt").exists());
|
||||
let _ = fs::remove_dir_all(app);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_from_archive_rejects_symlink_entries() {
|
||||
let app = temp_dir("archive-symlink-app");
|
||||
let archive_path = app.join("plugin.ideaplug");
|
||||
write_zip(
|
||||
&archive_path,
|
||||
&[
|
||||
ZipEntrySpec {
|
||||
name: "idea-plugin.json",
|
||||
contents: br#"{"ideaPluginManifestVersion":1,"id":"dev.acme.test","displayName":"Test","version":"1.0.0","main":"dist/index.js","trustLevel":"full","contributes":{}}"#,
|
||||
unix_mode: Some(0o100644),
|
||||
},
|
||||
ZipEntrySpec {
|
||||
name: "dist/link.js",
|
||||
contents: b"/tmp/outside.js",
|
||||
unix_mode: Some(0o120777),
|
||||
},
|
||||
],
|
||||
);
|
||||
let store = FsPluginPackageStore::new(&app);
|
||||
|
||||
let err = store
|
||||
.install_from_archive(&LocalPath::new(archive_path.to_string_lossy()))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(err, PluginStoreError::Invalid(_)));
|
||||
assert!(err.to_string().contains("symlinks are not allowed"));
|
||||
assert!(!app.join("plugins/installed/dev.acme.test").exists());
|
||||
let _ = fs::remove_dir_all(app);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_storage_store_round_trips_deletes_and_purges_plugin_data() {
|
||||
let app = temp_dir("storage-app");
|
||||
let store = FsPluginStorageStore::new(&app);
|
||||
let plugin_id = PluginId::new("dev.acme.test").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
store.get(&plugin_id, "helloPlugin.launches").await.unwrap(),
|
||||
None
|
||||
);
|
||||
store
|
||||
.set(
|
||||
&plugin_id,
|
||||
"helloPlugin.launches",
|
||||
serde_json::json!({"count": 1}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store.get(&plugin_id, "helloPlugin.launches").await.unwrap(),
|
||||
Some(serde_json::json!({"count": 1}))
|
||||
);
|
||||
assert!(app.join("plugins/data/dev.acme.test/entries").is_dir());
|
||||
|
||||
assert!(store
|
||||
.delete(&plugin_id, "helloPlugin.launches")
|
||||
.await
|
||||
.unwrap());
|
||||
assert_eq!(
|
||||
store.get(&plugin_id, "helloPlugin.launches").await.unwrap(),
|
||||
None
|
||||
);
|
||||
store
|
||||
.set(&plugin_id, "helloPlugin.enabled", serde_json::json!(true))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store.purge_plugin(&plugin_id).await.unwrap(),
|
||||
RemovalOutcome::Removed
|
||||
);
|
||||
assert!(!app.join("plugins/data/dev.acme.test").exists());
|
||||
|
||||
let _ = fs::remove_dir_all(app);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn supervisor_delegates_stdio_servers_to_external_mcp_bridge() {
|
||||
let bridge = Arc::new(RecordingBridge::default());
|
||||
|
||||
@ -108,6 +108,7 @@ impl FakeContexts {
|
||||
synchronized: false,
|
||||
synced_template_version: None,
|
||||
skills: Vec::new(),
|
||||
effort: None,
|
||||
});
|
||||
id
|
||||
}
|
||||
|
||||
@ -97,6 +97,7 @@ impl FakeContexts {
|
||||
synchronized: false,
|
||||
synced_template_version: None,
|
||||
skills: Vec::new(),
|
||||
effort: None,
|
||||
});
|
||||
id
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@ use application::{
|
||||
ListPlugins, UninstallPlugin, UninstallPluginInput,
|
||||
};
|
||||
use infrastructure::{
|
||||
ExternalMcpPluginSupervisor, FsPluginPackageStore, FsPluginRegistryStore,
|
||||
ExternalMcpPluginSupervisor, FsPluginPackageStore, FsPluginRegistryStore, FsPluginStorageStore,
|
||||
TokioBroadcastEventBus,
|
||||
};
|
||||
|
||||
@ -39,6 +39,48 @@ fn sdk_hello_plugin_path() -> PathBuf {
|
||||
.join("sdk/IdeaSDK/examples/hello-plugin")
|
||||
}
|
||||
|
||||
fn materialize_sdk_hello_plugin() -> PathBuf {
|
||||
let source = sdk_hello_plugin_path();
|
||||
let root = temp_dir("hello-plugin-built");
|
||||
fs::create_dir_all(root.join("dist")).unwrap();
|
||||
fs::write(
|
||||
root.join("idea-plugin.json"),
|
||||
fs::read(source.join("idea-plugin.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.join("dist/index.js"),
|
||||
"export function activate() { return 'hello-plugin'; }",
|
||||
)
|
||||
.unwrap();
|
||||
root
|
||||
}
|
||||
|
||||
fn write_multifile_plugin(root: &Path) {
|
||||
fs::create_dir_all(root.join("dist/core")).unwrap();
|
||||
fs::write(
|
||||
root.join("idea-plugin.json"),
|
||||
r#"{
|
||||
"ideaPluginManifestVersion": 1,
|
||||
"id": "dev.acme.multifile",
|
||||
"displayName": "Multifile Plugin",
|
||||
"version": "0.1.0",
|
||||
"main": "dist/index.js",
|
||||
"trustLevel": "full",
|
||||
"capabilities": ["ui"],
|
||||
"contributes": {}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.join("dist/index.js"),
|
||||
"import { answer } from './constants.js'; export default answer;",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(root.join("dist/constants.js"), "export const answer = 42;").unwrap();
|
||||
fs::write(root.join("dist/core/util.js"), "export const util = true;").unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn installs_reference_fixture_and_loads_runtime_catalog() {
|
||||
let app_data = temp_dir("app-data");
|
||||
@ -100,7 +142,7 @@ async fn installs_reference_fixture_and_loads_runtime_catalog() {
|
||||
#[tokio::test]
|
||||
async fn installs_sdk_hello_plugin_and_loads_runtime_catalog() {
|
||||
let app_data = temp_dir("hello-app-data");
|
||||
let hello_plugin = sdk_hello_plugin_path();
|
||||
let hello_plugin = materialize_sdk_hello_plugin();
|
||||
let packages = Arc::new(FsPluginPackageStore::new(&app_data));
|
||||
let registry = Arc::new(FsPluginRegistryStore::new(&app_data));
|
||||
let validator = Arc::new(JsonPluginManifestValidator::new("0.3.0"));
|
||||
@ -152,14 +194,16 @@ async fn installs_sdk_hello_plugin_and_loads_runtime_catalog() {
|
||||
assert_eq!(plugin.contributes.layouts[0].label.as_str(), "hello-world");
|
||||
|
||||
let _ = fs::remove_dir_all(app_data);
|
||||
let _ = fs::remove_dir_all(hello_plugin);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uninstalls_then_reinstalls_sdk_hello_plugin_without_runtime_residue() {
|
||||
let app_data = temp_dir("hello-reinstall-app-data");
|
||||
let hello_plugin = sdk_hello_plugin_path();
|
||||
let hello_plugin = materialize_sdk_hello_plugin();
|
||||
let packages = Arc::new(FsPluginPackageStore::new(&app_data));
|
||||
let registry = Arc::new(FsPluginRegistryStore::new(&app_data));
|
||||
let storage = Arc::new(FsPluginStorageStore::new(&app_data));
|
||||
let validator = Arc::new(JsonPluginManifestValidator::new("0.3.0"));
|
||||
let events = Arc::new(TokioBroadcastEventBus::new());
|
||||
let mcp = Arc::new(ExternalMcpPluginSupervisor::new());
|
||||
@ -170,7 +214,7 @@ async fn uninstalls_then_reinstalls_sdk_hello_plugin_without_runtime_residue() {
|
||||
events.clone(),
|
||||
mcp.clone(),
|
||||
);
|
||||
let uninstall = UninstallPlugin::new(packages.clone(), registry.clone(), events, mcp);
|
||||
let uninstall = UninstallPlugin::new(packages.clone(), storage, registry.clone(), events, mcp);
|
||||
|
||||
install
|
||||
.execute(hello_plugin.to_string_lossy().into_owned())
|
||||
@ -225,6 +269,76 @@ async fn uninstalls_then_reinstalls_sdk_hello_plugin_without_runtime_residue() {
|
||||
assert!(catalog.plugins[0].bundle_url.ends_with("/dist/index.js"));
|
||||
|
||||
let _ = fs::remove_dir_all(app_data);
|
||||
let _ = fs::remove_dir_all(hello_plugin);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uninstall_multifile_plugin_removes_package_registry_and_runtime_residue() {
|
||||
let app_data = temp_dir("multifile-uninstall-app-data");
|
||||
let source = temp_dir("multifile-source");
|
||||
write_multifile_plugin(&source);
|
||||
let packages = Arc::new(FsPluginPackageStore::new(&app_data));
|
||||
let registry = Arc::new(FsPluginRegistryStore::new(&app_data));
|
||||
let storage = Arc::new(FsPluginStorageStore::new(&app_data));
|
||||
let validator = Arc::new(JsonPluginManifestValidator::new("0.3.0"));
|
||||
let events = Arc::new(TokioBroadcastEventBus::new());
|
||||
let mcp = Arc::new(ExternalMcpPluginSupervisor::new());
|
||||
let install = InstallPluginFromDirectory::new(
|
||||
packages.clone(),
|
||||
registry.clone(),
|
||||
validator.clone(),
|
||||
events.clone(),
|
||||
mcp.clone(),
|
||||
);
|
||||
let uninstall = UninstallPlugin::new(packages.clone(), storage, registry.clone(), events, mcp);
|
||||
|
||||
install
|
||||
.execute(source.to_string_lossy().into_owned())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(app_data
|
||||
.join("plugins/installed/dev.acme.multifile/dist/constants.js")
|
||||
.is_file());
|
||||
assert!(app_data
|
||||
.join("plugins/installed/dev.acme.multifile/dist/core/util.js")
|
||||
.is_file());
|
||||
let plugin_data = app_data.join("plugins/data/dev.acme.multifile");
|
||||
fs::create_dir_all(&plugin_data).unwrap();
|
||||
fs::write(plugin_data.join("state.json"), r#"{"launches":1}"#).unwrap();
|
||||
|
||||
let uninstall_result = uninstall
|
||||
.execute(UninstallPluginInput {
|
||||
plugin_id: "dev.acme.multifile".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
uninstall_result.removal_outcome,
|
||||
domain::RemovalOutcome::Removed
|
||||
);
|
||||
assert!(!app_data
|
||||
.join("plugins/installed/dev.acme.multifile")
|
||||
.exists());
|
||||
assert!(!app_data.join("plugins/data/dev.acme.multifile").exists());
|
||||
assert!(
|
||||
ListPlugins::new(packages.clone(), registry.clone(), validator.clone())
|
||||
.execute()
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
ListPluginRuntimeContributions::new(packages, registry, validator)
|
||||
.execute()
|
||||
.await
|
||||
.unwrap()
|
||||
.plugins
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(app_data);
|
||||
let _ = fs::remove_dir_all(source);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@ -52,10 +52,11 @@ use application::{
|
||||
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
|
||||
RotateConversationLogInput, StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput,
|
||||
UnassignSkillFromAgentInput, UnassignTicketFromSprintInput, UnlinkIssuesInput,
|
||||
UpdateAgentContextInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
|
||||
UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput, UpdateMemoryInput,
|
||||
UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
|
||||
UpdateProjectSystemPermissionsInput, UpdateSkillInput, WriteToTerminalInput,
|
||||
UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput,
|
||||
UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput,
|
||||
UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput,
|
||||
UpdateProjectPermissionsInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput,
|
||||
WriteToTerminalInput,
|
||||
};
|
||||
use domain::ports::PtyHandle;
|
||||
use domain::IssueActor;
|
||||
@ -74,16 +75,16 @@ use backend::dto::{
|
||||
ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto,
|
||||
ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto,
|
||||
CreateAgentRequestDto, CreateMemoryRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
|
||||
DetectProfilesRequestDto, DetectProfilesResponseDto, EffectivePermissionsDto,
|
||||
EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto,
|
||||
GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto,
|
||||
GitStageRequestDto, GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto,
|
||||
InspectConversationRequestDto, LaunchAgentRequestDto, LiveAgentListDto, MemoryDto,
|
||||
MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenCodeProviderListDto, OpenTerminalRequestDto,
|
||||
ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto, ProjectListDto,
|
||||
ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSystemPermissionsDto,
|
||||
ProjectWorkStateDto, ReadAgentContextResponseDto, ReadConversationPageRequestDto,
|
||||
RecallMemoryRequestDto, ResolveAgentPermissionsRequestDto,
|
||||
DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto,
|
||||
EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto,
|
||||
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
|
||||
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
|
||||
LaunchAgentRequestDto, LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto,
|
||||
MemoryListDto, OpenCodeProviderListDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto,
|
||||
ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto,
|
||||
ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto,
|
||||
ReadAgentContextResponseDto, ReadConversationPageRequestDto, RecallMemoryRequestDto,
|
||||
ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto,
|
||||
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
|
||||
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto,
|
||||
SaveProfileRequestDto, SkillDto, SkillListDto, SprintCreateRequestDto, SprintDeleteRequestDto,
|
||||
@ -97,8 +98,9 @@ use backend::dto::{
|
||||
TicketListRequestDto, TicketReadRequestDto, TicketSprintAssignRequestDto,
|
||||
TicketSprintUnassignRequestDto, TicketUnlinkCommandRequestDto, TicketUpdateCarnetRequestDto,
|
||||
TicketUpdateRequestDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
|
||||
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
|
||||
UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
||||
UpdateAgentEffortRequestDto, UpdateAgentMcpToolPermissionsRequestDto,
|
||||
UpdateAgentPermissionsRequestDto, UpdateAgentSystemPermissionsRequestDto,
|
||||
UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
||||
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
|
||||
UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
|
||||
};
|
||||
@ -2426,6 +2428,7 @@ async fn invoke(
|
||||
"update_agent_permissions" => {
|
||||
invoke_update_agent_permissions(&request.args, &state.app).await
|
||||
}
|
||||
"update_agent_effort" => invoke_update_agent_effort(&request.args, &state.app).await,
|
||||
"resolve_agent_permissions" => {
|
||||
invoke_resolve_agent_permissions(&request.args, &state.app).await
|
||||
}
|
||||
@ -3482,9 +3485,10 @@ async fn invoke_create_skill(args: &Value, state: &BackendCore) -> Result<Value,
|
||||
.create_skill
|
||||
.execute(CreateSkillInput {
|
||||
name: request.name,
|
||||
description: None,
|
||||
description: request.description,
|
||||
content: request.content,
|
||||
scope: request.scope,
|
||||
kind: request.kind,
|
||||
project_root: project.root,
|
||||
})
|
||||
.await
|
||||
@ -3620,6 +3624,22 @@ async fn invoke_update_agent_permissions(
|
||||
serde_json::to_value(output).map_err(serialization_error)
|
||||
}
|
||||
|
||||
async fn invoke_update_agent_effort(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||
let request = required_request::<UpdateAgentEffortRequestDto>("update_agent_effort", args)?;
|
||||
let project = resolve_project_readonly(&request.project_id, state).await?;
|
||||
let output = state
|
||||
.update_agent_effort
|
||||
.execute(UpdateAgentEffortInput {
|
||||
project,
|
||||
agent_id: parse_agent_id(&request.agent_id)?,
|
||||
effort: request.effort,
|
||||
})
|
||||
.await
|
||||
.map(|out| AgentDto::from_agent(out.agent))
|
||||
.map_err(ErrorDto::from)?;
|
||||
serde_json::to_value(output).map_err(serialization_error)
|
||||
}
|
||||
|
||||
async fn invoke_resolve_agent_permissions(
|
||||
args: &Value,
|
||||
state: &BackendCore,
|
||||
@ -3634,7 +3654,7 @@ async fn invoke_resolve_agent_permissions(
|
||||
agent_id: parse_agent_id(&request.agent_id)?,
|
||||
})
|
||||
.await
|
||||
.map(|out| out.effective.map(EffectivePermissionsDto))
|
||||
.map(ResolveAgentPermissionsResponseDto::from)
|
||||
.map_err(ErrorDto::from)?;
|
||||
serde_json::to_value(output).map_err(serialization_error)
|
||||
}
|
||||
@ -7895,6 +7915,7 @@ mod tests {
|
||||
"get_project_permissions",
|
||||
"update_project_permissions",
|
||||
"update_agent_permissions",
|
||||
"update_agent_effort",
|
||||
"resolve_agent_permissions",
|
||||
"get_project_system_permissions",
|
||||
"update_project_system_permissions",
|
||||
|
||||
@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
26
frontend/src/adapters/pluginStorage.ts
Normal file
26
frontend/src/adapters/pluginStorage.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -767,6 +767,21 @@ export interface EffectivePermissions {
|
||||
fallback: PermissionPosture;
|
||||
}
|
||||
|
||||
/** Agent override choices shadowed by stricter project defaults. */
|
||||
export interface PermissionShadowReport {
|
||||
read: boolean;
|
||||
write: boolean;
|
||||
delete: boolean;
|
||||
executeBash: boolean;
|
||||
fallback: boolean;
|
||||
}
|
||||
|
||||
/** Resolved file/bash permissions plus non-authoritative diagnostics. */
|
||||
export interface ResolvedAgentPermissions {
|
||||
effective: EffectivePermissions | null;
|
||||
shadowed: PermissionShadowReport;
|
||||
}
|
||||
|
||||
/** Wanted/effective network policy for system permissions. */
|
||||
export type NetworkPolicy = "allow" | "deny" | "ask";
|
||||
|
||||
@ -1151,6 +1166,21 @@ export interface ProfileModelCatalog {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/** One native effort/reasoning preset declared by an AI profile. */
|
||||
export interface EffortOption {
|
||||
/** Raw value persisted/forwarded to the provider. */
|
||||
value: string;
|
||||
/** Human-readable label shown in selectors. */
|
||||
label: string;
|
||||
/** Optional short description from the profile declaration. */
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
/** Per-agent effort override. `undefined`/`null` means profile default. */
|
||||
export type EffortSelection =
|
||||
| { kind: "preset"; value: string }
|
||||
| { kind: "custom"; value: string };
|
||||
|
||||
/**
|
||||
* A declarative AI-CLI profile (mirror of the backend `AgentProfile`). `id` is a
|
||||
* UUID string; `detect` is the optional detection command line.
|
||||
@ -1192,6 +1222,16 @@ export interface AgentProfile {
|
||||
* CLI's own default. OpenCode keeps its dedicated provider/local model fields.
|
||||
*/
|
||||
model?: string;
|
||||
/**
|
||||
* Optional direct CLI reasoning effort configured on the profile. `undefined`
|
||||
* keeps the CLI/provider default.
|
||||
*/
|
||||
modelReasoningEffort?: string;
|
||||
/**
|
||||
* Native effort presets exposed by this profile, in declaration order from
|
||||
* light to deep. Empty/omitted means the provider declares no native options.
|
||||
*/
|
||||
effortOptions?: EffortOption[];
|
||||
}
|
||||
|
||||
/** Availability of a candidate profile after detection (mirror of the DTO). */
|
||||
@ -1233,6 +1273,8 @@ export interface Agent {
|
||||
synchronized: boolean;
|
||||
/** Skills assigned to this agent (injected into its convention file). */
|
||||
skills: SkillRef[];
|
||||
/** Per-agent effort override. Omitted for older manifests/profile default. */
|
||||
effort?: EffortSelection;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1284,6 +1326,7 @@ export interface ResumableAgent {
|
||||
* across projects; `project` skills are specific to one project's `.ideai/`.
|
||||
*/
|
||||
export type SkillScope = "global" | "project";
|
||||
export type SkillKind = "workflow" | "reference";
|
||||
|
||||
/**
|
||||
* A reusable, model-agnostic workflow assignable to agents (mirror of the
|
||||
@ -1292,6 +1335,8 @@ export type SkillScope = "global" | "project";
|
||||
export interface Skill {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
kind: SkillKind;
|
||||
contentMd: string;
|
||||
scope: SkillScope;
|
||||
}
|
||||
|
||||
@ -25,7 +25,13 @@ import { useAgents } from "./useAgents";
|
||||
import { correlateModelServerStatus } from "./modelServerLaunch";
|
||||
import { AgentLimitBadge } from "./AgentLimitBadge";
|
||||
import { ModelServerLaunchBadge } from "./ModelServerLaunchBadge";
|
||||
import type { ResolvedAgentSystemPermissions } from "@/domain";
|
||||
import type {
|
||||
Agent,
|
||||
AgentProfile,
|
||||
EffortOption,
|
||||
EffortSelection,
|
||||
ResolvedAgentSystemPermissions,
|
||||
} from "@/domain";
|
||||
|
||||
export interface AgentsPanelProps {
|
||||
/** The project whose agents to manage. */
|
||||
@ -237,7 +243,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
// Determine if a template is chosen → profile selector is hidden (template imposes it).
|
||||
const hasTemplate = newTemplateId !== "";
|
||||
|
||||
const profileLabel = (profile: import("@/domain").AgentProfile): string => {
|
||||
const profileLabel = (profile: AgentProfile): string => {
|
||||
const model =
|
||||
profile.model ??
|
||||
profile.opencode?.model ??
|
||||
@ -365,12 +371,9 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
const isSelected = a.id === vm.selectedAgentId;
|
||||
const isRunning = a.id === activeAgentId;
|
||||
const live = vm.liveAgents.find((candidate) => candidate.agentId === a.id);
|
||||
const agentProfile = vm.profiles.find((p) => p.id === a.profileId) ?? null;
|
||||
const profileName =
|
||||
(() => {
|
||||
const p = vm.profiles.find((p) => p.id === a.profileId);
|
||||
return p ? profileLabel(p) : null;
|
||||
})() ??
|
||||
a.profileId;
|
||||
(agentProfile ? profileLabel(agentProfile) : null) ?? a.profileId;
|
||||
const agentDrift = drift.driftByAgentId.get(a.id);
|
||||
// Source of this agent's last orchestration delegation (mcp vs
|
||||
// file), if any has been observed. Absent ⇒ no badge.
|
||||
@ -480,6 +483,14 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<EffortSelector
|
||||
agent={a}
|
||||
profile={agentProfile}
|
||||
busy={vm.busy}
|
||||
onChange={(effort) =>
|
||||
void vm.updateAgentEffort(a.id, effort)
|
||||
}
|
||||
/>
|
||||
{agentDrift && (
|
||||
<Button
|
||||
size="sm"
|
||||
@ -706,6 +717,141 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const EFFORT_DEFAULT_VALUE = "__profile_default__";
|
||||
const EFFORT_CUSTOM_VALUE = "__custom_effort__";
|
||||
|
||||
const GENERIC_EFFORT_OPTIONS: EffortOption[] = [
|
||||
{
|
||||
value: "low",
|
||||
label: "Rapide (par défaut)",
|
||||
hint: "Fallback générique léger.",
|
||||
},
|
||||
{
|
||||
value: "medium",
|
||||
label: "Standard (par défaut)",
|
||||
hint: "Fallback générique équilibré.",
|
||||
},
|
||||
{
|
||||
value: "high",
|
||||
label: "Approfondi (par défaut)",
|
||||
hint: "Fallback générique profond.",
|
||||
},
|
||||
];
|
||||
|
||||
function rawEffortValue(selection: EffortSelection | undefined): string {
|
||||
return selection?.value ?? "";
|
||||
}
|
||||
|
||||
function EffortSelector({
|
||||
agent,
|
||||
profile,
|
||||
busy,
|
||||
onChange,
|
||||
}: {
|
||||
agent: Agent;
|
||||
profile: AgentProfile | null;
|
||||
busy: boolean;
|
||||
onChange: (effort: EffortSelection | null) => void;
|
||||
}) {
|
||||
const nativeOptions = profile?.effortOptions ?? [];
|
||||
const hasNativeOptions = nativeOptions.length > 0;
|
||||
const displayedOptions = hasNativeOptions
|
||||
? nativeOptions
|
||||
: GENERIC_EFFORT_OPTIONS;
|
||||
const [customText, setCustomText] = useState(rawEffortValue(agent.effort));
|
||||
const [forceCustom, setForceCustom] = useState(agent.effort?.kind === "custom");
|
||||
|
||||
useEffect(() => {
|
||||
setCustomText(rawEffortValue(agent.effort));
|
||||
setForceCustom(agent.effort?.kind === "custom");
|
||||
}, [agent.id, agent.effort]);
|
||||
|
||||
let selectedOption = EFFORT_DEFAULT_VALUE;
|
||||
if (forceCustom) {
|
||||
selectedOption = EFFORT_CUSTOM_VALUE;
|
||||
} else if (
|
||||
hasNativeOptions &&
|
||||
agent.effort?.kind === "preset" &&
|
||||
nativeOptions.some((option) => option.value === agent.effort?.value)
|
||||
) {
|
||||
selectedOption = `preset:${agent.effort.value}`;
|
||||
} else if (
|
||||
!hasNativeOptions &&
|
||||
agent.effort &&
|
||||
displayedOptions.some((option) => option.value === agent.effort?.value)
|
||||
) {
|
||||
selectedOption = `fallback:${agent.effort.value}`;
|
||||
} else if (agent.effort) {
|
||||
selectedOption = EFFORT_CUSTOM_VALUE;
|
||||
}
|
||||
|
||||
const customVisible = selectedOption === EFFORT_CUSTOM_VALUE;
|
||||
|
||||
function commitCustom() {
|
||||
const value = customText.trim();
|
||||
if (value.length === 0) return;
|
||||
if (agent.effort?.kind === "custom" && agent.effort.value === value) return;
|
||||
onChange({ kind: "custom", value });
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="flex min-w-[11rem] max-w-full flex-wrap items-center gap-1.5">
|
||||
<SmallDropdown
|
||||
aria-label={`effort for ${agent.name}`}
|
||||
value={selectedOption}
|
||||
disabled={busy}
|
||||
onChange={(value) => {
|
||||
if (value === EFFORT_DEFAULT_VALUE) {
|
||||
setForceCustom(false);
|
||||
onChange(null);
|
||||
return;
|
||||
}
|
||||
if (value === EFFORT_CUSTOM_VALUE) {
|
||||
setForceCustom(true);
|
||||
setCustomText(rawEffortValue(agent.effort));
|
||||
return;
|
||||
}
|
||||
if (value.startsWith("preset:")) {
|
||||
setForceCustom(false);
|
||||
onChange({ kind: "preset", value: value.slice("preset:".length) });
|
||||
return;
|
||||
}
|
||||
if (value.startsWith("fallback:")) {
|
||||
setForceCustom(false);
|
||||
onChange({ kind: "custom", value: value.slice("fallback:".length) });
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ value: EFFORT_DEFAULT_VALUE, label: "Effort: profil" },
|
||||
...displayedOptions.map((option) => ({
|
||||
value: `${hasNativeOptions ? "preset" : "fallback"}:${option.value}`,
|
||||
label: option.label,
|
||||
})),
|
||||
{ value: EFFORT_CUSTOM_VALUE, label: "Personnalisé" },
|
||||
]}
|
||||
/>
|
||||
{customVisible && (
|
||||
<Input
|
||||
aria-label={`custom effort for ${agent.name}`}
|
||||
value={customText}
|
||||
disabled={busy}
|
||||
placeholder="valeur brute"
|
||||
onChange={(event) => setCustomText(event.target.value)}
|
||||
onBlur={commitCustom}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
commitCustom();
|
||||
}
|
||||
}}
|
||||
className="h-8 min-w-[8rem] flex-1 px-2 text-xs"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function NetworkPermissionBadge({
|
||||
state,
|
||||
}: {
|
||||
|
||||
@ -492,6 +492,11 @@ async function seededProfiles(): Promise<MockProfileGateway> {
|
||||
contextInjection: { strategy: "conventionFile", target: "CLAUDE.md" },
|
||||
detect: null,
|
||||
cwdTemplate: "{projectRoot}",
|
||||
effortOptions: [
|
||||
{ value: "low", label: "Léger" },
|
||||
{ value: "medium", label: "Standard" },
|
||||
{ value: "high", label: "Profond" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "prof-2",
|
||||
@ -588,6 +593,123 @@ describe("AgentsPanel profile hot-swap (A2)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentsPanel effort selection (#131)", () => {
|
||||
it("shows native profile effort options in declaration order with Personnalisé last", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
await agent.createAgent(PROJECT_ID, { name: "Thinker", profileId: "prof-1" });
|
||||
const profile = await seededProfiles();
|
||||
|
||||
renderPanel(agent, profile);
|
||||
await waitForIdle();
|
||||
await screen.findByText("Thinker");
|
||||
|
||||
openDropdown("effort for Thinker");
|
||||
const labels = screen
|
||||
.getAllByRole("option")
|
||||
.map((option) => option.textContent);
|
||||
|
||||
expect(labels).toEqual([
|
||||
"Effort: profil",
|
||||
"Léger",
|
||||
"Standard",
|
||||
"Profond",
|
||||
"Personnalisé",
|
||||
]);
|
||||
});
|
||||
|
||||
it("persists a native effort option as a preset", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
const created = await agent.createAgent(PROJECT_ID, {
|
||||
name: "Thinker",
|
||||
profileId: "prof-1",
|
||||
});
|
||||
const updateSpy = vi.spyOn(agent, "updateAgentEffort");
|
||||
|
||||
renderPanel(agent, await seededProfiles());
|
||||
await waitForIdle();
|
||||
await screen.findByText("Thinker");
|
||||
|
||||
chooseDropdownOption("effort for Thinker", "Profond");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSpy).toHaveBeenCalledWith(PROJECT_ID, created.id, {
|
||||
kind: "preset",
|
||||
value: "high",
|
||||
});
|
||||
});
|
||||
const [updated] = await agent.listAgents(PROJECT_ID);
|
||||
expect(updated.effort).toEqual({ kind: "preset", value: "high" });
|
||||
});
|
||||
|
||||
it("shows generic default fallback options when the profile declares no native options", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
const profile = new MockProfileGateway();
|
||||
await profile.configureProfiles([
|
||||
{
|
||||
id: "plain",
|
||||
name: "Plain provider",
|
||||
command: "plain-ai",
|
||||
args: [],
|
||||
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
|
||||
detect: null,
|
||||
cwdTemplate: "{projectRoot}",
|
||||
},
|
||||
]);
|
||||
const created = await agent.createAgent(PROJECT_ID, {
|
||||
name: "Fallback",
|
||||
profileId: "plain",
|
||||
});
|
||||
|
||||
renderPanel(agent, profile);
|
||||
await waitForIdle();
|
||||
await screen.findByText("Fallback");
|
||||
|
||||
openDropdown("effort for Fallback");
|
||||
const labels = screen
|
||||
.getAllByRole("option")
|
||||
.map((option) => option.textContent);
|
||||
expect(labels).toEqual([
|
||||
"Effort: profil",
|
||||
"Rapide (par défaut)",
|
||||
"Standard (par défaut)",
|
||||
"Approfondi (par défaut)",
|
||||
"Personnalisé",
|
||||
]);
|
||||
|
||||
fireEvent.click(screen.getByRole("option", { name: "Standard (par défaut)" }));
|
||||
await waitFor(async () => {
|
||||
const [updated] = await agent.listAgents(PROJECT_ID);
|
||||
expect(updated.id).toBe(created.id);
|
||||
expect(updated.effort).toEqual({ kind: "custom", value: "medium" });
|
||||
});
|
||||
});
|
||||
|
||||
it("reveals an inline custom effort field and persists the free text", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
const created = await agent.createAgent(PROJECT_ID, {
|
||||
name: "Custom",
|
||||
profileId: "prof-1",
|
||||
});
|
||||
const updateSpy = vi.spyOn(agent, "updateAgentEffort");
|
||||
|
||||
renderPanel(agent, await seededProfiles());
|
||||
await waitForIdle();
|
||||
await screen.findByText("Custom");
|
||||
|
||||
chooseDropdownOption("effort for Custom", "Personnalisé");
|
||||
const input = screen.getByLabelText("custom effort for Custom");
|
||||
fireEvent.change(input, { target: { value: "x-provider-deep" } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSpy).toHaveBeenCalledWith(PROJECT_ID, created.id, {
|
||||
kind: "custom",
|
||||
value: "x-provider-deep",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentsPanel live refresh on domain events", () => {
|
||||
it("refreshes the list when an `agentLaunched` event fires (out-of-band creation)", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
|
||||
@ -12,6 +12,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import type {
|
||||
Agent,
|
||||
AgentProfile,
|
||||
EffortSelection,
|
||||
GatewayError,
|
||||
ModelServerStatus,
|
||||
TerminalSession,
|
||||
@ -124,6 +125,11 @@ export interface AgentsViewModel {
|
||||
rows: number,
|
||||
cols: number,
|
||||
) => Promise<TerminalSession | undefined>;
|
||||
/** Sets or clears a per-agent effort override. */
|
||||
updateAgentEffort: (
|
||||
agentId: string,
|
||||
effort: EffortSelection | null,
|
||||
) => Promise<void>;
|
||||
/** Deletes an agent; deselects if it was selected. */
|
||||
deleteAgent: (agentId: string) => Promise<void>;
|
||||
/**
|
||||
@ -427,6 +433,26 @@ export function useAgents(projectId: string): AgentsViewModel {
|
||||
[agent, projectId, refreshLiveAgents],
|
||||
);
|
||||
|
||||
const updateAgentEffort = useCallback(
|
||||
async (agentId: string, effort: EffortSelection | null): Promise<void> => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await agent.updateAgentEffort(projectId, agentId, effort);
|
||||
setAgents((prev) =>
|
||||
prev.map((candidate) =>
|
||||
candidate.id === updated.id ? updated : candidate,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[agent, projectId],
|
||||
);
|
||||
|
||||
const deleteAgent = useCallback(
|
||||
async (agentId: string) => {
|
||||
setBusy(true);
|
||||
@ -544,6 +570,7 @@ export function useAgents(projectId: string): AgentsViewModel {
|
||||
selectAgent,
|
||||
saveContext,
|
||||
changeAgentProfile,
|
||||
updateAgentEffort,
|
||||
deleteAgent,
|
||||
launchAgent,
|
||||
stopAgent,
|
||||
|
||||
@ -8,8 +8,10 @@ import type {
|
||||
PermissionPosture,
|
||||
PermissionRule,
|
||||
PermissionSet,
|
||||
PermissionShadowReport,
|
||||
ProjectPermissions,
|
||||
ProjectSystemPermissions,
|
||||
ResolvedAgentPermissions,
|
||||
ResolvedAgentSystemPermissions,
|
||||
SystemPermissionSet,
|
||||
} from "@/domain";
|
||||
@ -27,6 +29,7 @@ export interface PolicyDraft {
|
||||
export interface AgentPermissionRow {
|
||||
agent: Agent;
|
||||
override: PermissionSet | null;
|
||||
shadowed: PermissionShadowReport | null;
|
||||
systemOverride: SystemPermissionSet | null;
|
||||
resolvedSystem: ResolvedAgentSystemPermissions | null;
|
||||
}
|
||||
@ -132,6 +135,9 @@ export function usePermissions(projectId: string): PermissionsViewModel {
|
||||
const [resolvedSystemByAgent, setResolvedSystemByAgent] = useState<
|
||||
Record<string, ResolvedAgentSystemPermissions>
|
||||
>({});
|
||||
const [resolvedPermissionsByAgent, setResolvedPermissionsByAgent] = useState<
|
||||
Record<string, ResolvedAgentPermissions>
|
||||
>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@ -156,9 +162,29 @@ export function usePermissions(projectId: string): PermissionsViewModel {
|
||||
}
|
||||
}),
|
||||
);
|
||||
const resolvedPermissionPairs = await Promise.all(
|
||||
agentList.map(async (candidate) => {
|
||||
try {
|
||||
return [
|
||||
candidate.id,
|
||||
await permission.resolveAgentPermissions(projectId, candidate.id),
|
||||
] as const;
|
||||
} catch {
|
||||
return [candidate.id, null] as const;
|
||||
}
|
||||
}),
|
||||
);
|
||||
setAgents(agentList);
|
||||
setDocument(permissionDoc);
|
||||
setSystemDocument(systemPermissionDoc);
|
||||
setResolvedPermissionsByAgent(
|
||||
Object.fromEntries(
|
||||
resolvedPermissionPairs.filter(
|
||||
(pair): pair is readonly [string, ResolvedAgentPermissions] =>
|
||||
pair[1] !== null,
|
||||
),
|
||||
),
|
||||
);
|
||||
setResolvedSystemByAgent(
|
||||
Object.fromEntries(
|
||||
resolvedPairs.filter(
|
||||
@ -191,10 +217,17 @@ export function usePermissions(projectId: string): PermissionsViewModel {
|
||||
return agents.map((candidate) => ({
|
||||
agent: candidate,
|
||||
override: overrides.get(candidate.id) ?? null,
|
||||
shadowed: resolvedPermissionsByAgent[candidate.id]?.shadowed ?? null,
|
||||
systemOverride: systemOverrides.get(candidate.id) ?? null,
|
||||
resolvedSystem: resolvedSystemByAgent[candidate.id] ?? null,
|
||||
}));
|
||||
}, [agents, document, systemDocument, resolvedSystemByAgent]);
|
||||
}, [
|
||||
agents,
|
||||
document,
|
||||
systemDocument,
|
||||
resolvedPermissionsByAgent,
|
||||
resolvedSystemByAgent,
|
||||
]);
|
||||
|
||||
const projectDraft = useMemo(
|
||||
() => draftFromSet(document?.projectDefaults ?? null),
|
||||
|
||||
@ -80,6 +80,7 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
|
||||
pluginToolchain: gateways.pluginToolchain,
|
||||
pluginEvents: gateways.pluginEvents,
|
||||
pluginConfig: gateways.pluginConfig,
|
||||
pluginStorage: gateways.pluginStorage,
|
||||
}),
|
||||
)
|
||||
.then((result) => {
|
||||
|
||||
@ -210,6 +210,7 @@ describe("loadPlugins", () => {
|
||||
"pluginToolchain",
|
||||
"pluginEvents",
|
||||
"pluginConfig",
|
||||
"pluginStorage",
|
||||
].some((key) => key in ctx);
|
||||
globalThis.__serviceKeys = Object.keys(ctx.services).sort();
|
||||
globalThis.__workspaceServiceKeys = Object.keys(ctx.services.workspace).sort();
|
||||
@ -241,6 +242,7 @@ describe("loadPlugins", () => {
|
||||
"pluginDisplayName",
|
||||
"pluginId",
|
||||
"services",
|
||||
"storage",
|
||||
"subscriptions",
|
||||
"version",
|
||||
]);
|
||||
@ -294,6 +296,59 @@ describe("loadPlugins", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("injects plugin-owned storage scoped to the activating plugin id", async () => {
|
||||
const calls: unknown[] = [];
|
||||
const storageGateways = {
|
||||
...gateways,
|
||||
pluginStorage: {
|
||||
async get(input: unknown) {
|
||||
calls.push(["get", input]);
|
||||
return { launches: 1 };
|
||||
},
|
||||
async set(input: unknown) {
|
||||
calls.push(["set", input]);
|
||||
},
|
||||
async delete(input: unknown) {
|
||||
calls.push(["delete", input]);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
} as PluginGatewaySet;
|
||||
const bundle = dataUrl(`
|
||||
export async function activate(ctx) {
|
||||
globalThis.__storageKeys = Object.keys(ctx.storage).sort();
|
||||
globalThis.__storedValue = await ctx.storage.get("helloPlugin.state");
|
||||
await ctx.storage.set("helloPlugin.state", { launches: 2 });
|
||||
await ctx.storage.delete("helloPlugin.state");
|
||||
}
|
||||
`);
|
||||
|
||||
const { failures } = await loadPlugins(
|
||||
[entry({ id: "dev.acme.storage", displayName: "Storage", bundleUrl: bundle })],
|
||||
storageGateways,
|
||||
);
|
||||
|
||||
expect(failures).toEqual([]);
|
||||
expect((globalThis as Record<string, unknown>).__storageKeys).toEqual([
|
||||
"delete",
|
||||
"get",
|
||||
"set",
|
||||
]);
|
||||
expect((globalThis as Record<string, unknown>).__storedValue).toEqual({ launches: 1 });
|
||||
expect(calls).toEqual([
|
||||
["get", { pluginId: "dev.acme.storage", key: "helloPlugin.state" }],
|
||||
[
|
||||
"set",
|
||||
{
|
||||
pluginId: "dev.acme.storage",
|
||||
key: "helloPlugin.state",
|
||||
value: { launches: 2 },
|
||||
},
|
||||
],
|
||||
["delete", { pluginId: "dev.acme.storage", key: "helloPlugin.state" }],
|
||||
]);
|
||||
});
|
||||
|
||||
it("loads the hello-plugin command and layout contribution shape", async () => {
|
||||
const bundle = dataUrl(`
|
||||
export function activate(ctx) {
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
* collected, never thrown past `loadPlugins`.
|
||||
*/
|
||||
|
||||
import type { PluginContributionDto, PluginRuntimePlugin } from "@/domain";
|
||||
import type { JsonValue, PluginContributionDto, PluginRuntimePlugin } from "@/domain";
|
||||
import {
|
||||
PluginCommandRegistry,
|
||||
PluginLayoutRegistry,
|
||||
@ -38,9 +38,16 @@ export interface IdeaPluginContext {
|
||||
commands: PluginCommandContext;
|
||||
layouts: PluginLayoutRegistry;
|
||||
menu: PluginMenuRegistry;
|
||||
storage: PluginStorage;
|
||||
services?: PluginServices;
|
||||
}
|
||||
|
||||
export interface PluginStorage {
|
||||
get<T extends JsonValue = JsonValue>(key: string): Promise<T | undefined>;
|
||||
set(key: string, value: JsonValue): Promise<void>;
|
||||
delete(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PluginActivation {
|
||||
dispose?: () => void | Promise<void>;
|
||||
}
|
||||
@ -241,6 +248,7 @@ async function loadOne(
|
||||
const commands = new PluginCommandRegistry(pluginId, declaredCommandIds);
|
||||
const layouts = new PluginLayoutRegistry(pluginId, declaredLayoutTypes);
|
||||
const menu = new PluginMenuRegistry(pluginId);
|
||||
const storage = createPluginStorage(gateways, pluginId);
|
||||
|
||||
const ctx: IdeaPluginContext = {
|
||||
pluginId,
|
||||
@ -251,6 +259,7 @@ async function loadOne(
|
||||
commands: createCommandContext(commands),
|
||||
layouts,
|
||||
menu,
|
||||
storage,
|
||||
};
|
||||
if (hasCapability(entry, "tooling")) {
|
||||
ctx.services = createPluginServices(gateways);
|
||||
@ -287,6 +296,21 @@ async function loadOne(
|
||||
}
|
||||
}
|
||||
|
||||
function createPluginStorage(gateways: PluginGatewaySet, pluginId: string): PluginStorage {
|
||||
return {
|
||||
async get<T extends JsonValue = JsonValue>(key: string): Promise<T | undefined> {
|
||||
const value = await gateways.pluginStorage.get({ pluginId, key });
|
||||
return value === null ? undefined : (value as T);
|
||||
},
|
||||
async set(key, value) {
|
||||
await gateways.pluginStorage.set({ pluginId, key, value });
|
||||
},
|
||||
async delete(key) {
|
||||
await gateways.pluginStorage.delete({ pluginId, key });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads every plugin in the catalog into a fresh {@link PluginRuntimeRegistry}.
|
||||
* Called once at app bootstrap (and never again in the same session — carnet
|
||||
|
||||
@ -25,6 +25,7 @@ import type {
|
||||
GitGateway,
|
||||
PluginConfigGateway,
|
||||
PluginEventGateway,
|
||||
PluginStorageGateway,
|
||||
PluginTaskGateway,
|
||||
PluginToolchainGateway,
|
||||
PluginWorkspaceGateway,
|
||||
@ -48,6 +49,7 @@ export interface PluginGatewaySet {
|
||||
pluginToolchain: PluginToolchainGateway;
|
||||
pluginEvents: PluginEventGateway;
|
||||
pluginConfig: PluginConfigGateway;
|
||||
pluginStorage: PluginStorageGateway;
|
||||
}
|
||||
|
||||
/** A disposable handle returned by every `register*` call. */
|
||||
|
||||
@ -15,6 +15,7 @@ import type {
|
||||
AppExitWorkGuardState,
|
||||
CustomProviderConfig,
|
||||
DomainEvent,
|
||||
EffortSelection,
|
||||
EmbedderEngines,
|
||||
EmbedderProfile,
|
||||
EmbeddedServerStatus,
|
||||
@ -24,6 +25,7 @@ import type {
|
||||
GitFileStatus,
|
||||
GraphCommit,
|
||||
HealthReport,
|
||||
JsonValue,
|
||||
LayoutKind,
|
||||
LayoutList,
|
||||
LayoutOperation,
|
||||
@ -38,7 +40,6 @@ import type {
|
||||
OpenCodeConfig,
|
||||
OpenCodeProviderCatalogEntry,
|
||||
ProfileModelCatalog,
|
||||
EffectivePermissions,
|
||||
PairedDevice,
|
||||
PairingCode,
|
||||
PermissionSet,
|
||||
@ -71,11 +72,13 @@ import type {
|
||||
ProjectSystemPermissions,
|
||||
ProfileAvailability,
|
||||
ResumableAgent,
|
||||
ResolvedAgentPermissions,
|
||||
ResolvedAgentSystemPermissions,
|
||||
ReplyChunk,
|
||||
ServerExposurePreview,
|
||||
ServerExposureSettings,
|
||||
Skill,
|
||||
SkillKind,
|
||||
SkillScope,
|
||||
SystemPermissionSet,
|
||||
Sprint,
|
||||
@ -207,6 +210,15 @@ export interface AgentGateway {
|
||||
rows: number,
|
||||
cols: number,
|
||||
): Promise<{ agent: Agent; relaunchedSession?: TerminalSession }>;
|
||||
/**
|
||||
* Sets or clears one agent's effort override. `null` clears the override and
|
||||
* lets launch fall back to the profile default.
|
||||
*/
|
||||
updateAgentEffort(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
effort: EffortSelection | null,
|
||||
): Promise<Agent>;
|
||||
/** Reads an agent's `.md` context by agent id. */
|
||||
readContext(projectId: string, agentId: string): Promise<string>;
|
||||
/** Overwrites an agent's `.md` context. */
|
||||
@ -538,6 +550,8 @@ export interface CreateSkillInput {
|
||||
/** Owning project (resolved to a root; ignored on disk for `global`). */
|
||||
projectId: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
kind?: SkillKind;
|
||||
content: string;
|
||||
scope: SkillScope;
|
||||
}
|
||||
@ -925,7 +939,7 @@ export interface PermissionGateway {
|
||||
resolveAgentPermissions(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
): Promise<EffectivePermissions | null>;
|
||||
): Promise<ResolvedAgentPermissions>;
|
||||
/** Reads the full project system-permission document. */
|
||||
getProjectSystemPermissions(projectId: string): Promise<ProjectSystemPermissions>;
|
||||
/** Replaces or removes project-level default system permissions. */
|
||||
@ -1496,6 +1510,21 @@ export interface PluginConfigGateway {
|
||||
updateDocument(input: PluginConfigDocumentUpdateInput): Promise<PluginConfigDocumentWriteResult>;
|
||||
}
|
||||
|
||||
export interface PluginStorageGetInput {
|
||||
pluginId: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface PluginStorageSetInput extends PluginStorageGetInput {
|
||||
value: JsonValue;
|
||||
}
|
||||
|
||||
export interface PluginStorageGateway {
|
||||
get(input: PluginStorageGetInput): Promise<JsonValue | null>;
|
||||
set(input: PluginStorageSetInput): Promise<void>;
|
||||
delete(input: PluginStorageGetInput): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface Gateways {
|
||||
system: SystemGateway;
|
||||
agent: AgentGateway;
|
||||
@ -1526,4 +1555,5 @@ export interface Gateways {
|
||||
pluginToolchain: PluginToolchainGateway;
|
||||
pluginEvents: PluginEventGateway;
|
||||
pluginConfig: PluginConfigGateway;
|
||||
pluginStorage: PluginStorageGateway;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user