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/agents.json
|
||||||
.ideai/background-tasks/
|
.ideai/background-tasks/
|
||||||
.ideai/mcp-tool-permissions.json
|
.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",
|
"domain",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"subtle",
|
"subtle",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arbitrary"
|
||||||
|
version = "1.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||||
|
dependencies = [
|
||||||
|
"derive_arbitrary",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.89"
|
version = "0.1.89"
|
||||||
@ -744,6 +754,17 @@ dependencies = [
|
|||||||
"serde_core",
|
"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]]
|
[[package]]
|
||||||
name = "derive_builder"
|
name = "derive_builder"
|
||||||
version = "0.20.2"
|
version = "0.20.2"
|
||||||
@ -1996,6 +2017,7 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -6236,8 +6258,37 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
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,
|
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
|
||||||
RotateConversationLogInput, SetActiveLayoutInput, SnapshotRunningAgentsInput,
|
RotateConversationLogInput, SetActiveLayoutInput, SnapshotRunningAgentsInput,
|
||||||
StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput,
|
StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput,
|
||||||
UpdateAgentContextInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
|
UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput,
|
||||||
UpdateAgentSystemPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput,
|
UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateMemoryInput,
|
||||||
UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
|
UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
|
||||||
UpdateProjectSystemPermissionsInput, UpdateSkillInput,
|
UpdateProjectSystemPermissionsInput, UpdateSkillInput,
|
||||||
};
|
};
|
||||||
use backend::stream::OutputSink;
|
use backend::stream::OutputSink;
|
||||||
@ -47,28 +47,28 @@ use crate::dto::{
|
|||||||
CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
|
CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
|
||||||
CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
|
CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
|
||||||
DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
|
DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
|
||||||
EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto,
|
EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto,
|
||||||
ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto,
|
FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto,
|
||||||
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
|
GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto,
|
||||||
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
|
HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, InterruptAgentRequestDto,
|
||||||
InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto,
|
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
|
||||||
LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto,
|
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto,
|
||||||
ModelServerConfigDto, ModelServerConfigListDto, OpenCodeProviderListDto,
|
ModelServerConfigListDto, OpenCodeProviderListDto, OpenTerminalRequestDto,
|
||||||
OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto, ProfileListDto,
|
PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto,
|
||||||
ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto,
|
ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto,
|
||||||
ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto,
|
ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
|
||||||
ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto,
|
ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto,
|
||||||
ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
|
RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto,
|
||||||
ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto,
|
ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto,
|
||||||
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
|
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
|
||||||
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveModelServerRequestDto,
|
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveModelServerRequestDto,
|
||||||
SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto,
|
SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto,
|
||||||
SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto,
|
SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto,
|
||||||
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
|
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
|
||||||
TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
|
TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
|
||||||
UpdateAgentContextRequestDto, UpdateAgentMcpToolPermissionsRequestDto,
|
UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto,
|
||||||
UpdateAgentPermissionsRequestDto, UpdateAgentSystemPermissionsRequestDto,
|
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
|
||||||
UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
||||||
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
|
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
|
||||||
UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
|
UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
|
||||||
WriteTerminalRequestDto,
|
WriteTerminalRequestDto,
|
||||||
@ -538,6 +538,29 @@ pub async fn update_agent_permissions(
|
|||||||
.map_err(ErrorDto::from)
|
.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.
|
/// `resolve_agent_permissions` — resolve project defaults plus agent override.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
@ -546,14 +569,14 @@ pub async fn update_agent_permissions(
|
|||||||
pub async fn resolve_agent_permissions(
|
pub async fn resolve_agent_permissions(
|
||||||
request: ResolveAgentPermissionsRequestDto,
|
request: ResolveAgentPermissionsRequestDto,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<Option<EffectivePermissionsDto>, ErrorDto> {
|
) -> Result<ResolveAgentPermissionsResponseDto, ErrorDto> {
|
||||||
let project = resolve_project(&request.project_id, &state).await?;
|
let project = resolve_project(&request.project_id, &state).await?;
|
||||||
let agent_id = parse_agent_id(&request.agent_id)?;
|
let agent_id = parse_agent_id(&request.agent_id)?;
|
||||||
state
|
state
|
||||||
.resolve_agent_permissions
|
.resolve_agent_permissions
|
||||||
.execute(ResolveAgentPermissionsInput { project, agent_id })
|
.execute(ResolveAgentPermissionsInput { project, agent_id })
|
||||||
.await
|
.await
|
||||||
.map(|out| out.effective.map(EffectivePermissionsDto))
|
.map(ResolveAgentPermissionsResponseDto::from)
|
||||||
.map_err(ErrorDto::from)
|
.map_err(ErrorDto::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3308,12 +3331,10 @@ pub async fn create_skill(
|
|||||||
.create_skill
|
.create_skill
|
||||||
.execute(CreateSkillInput {
|
.execute(CreateSkillInput {
|
||||||
name: request.name,
|
name: request.name,
|
||||||
// Description is set via the dedicated frontend field (T6); the create
|
description: request.description,
|
||||||
// path stays None for now so the affordance falls back to the body's
|
|
||||||
// first line (see `Skill::effective_description`).
|
|
||||||
description: None,
|
|
||||||
content: request.content,
|
content: request.content,
|
||||||
scope: request.scope,
|
scope: request.scope,
|
||||||
|
kind: request.kind,
|
||||||
project_root: project.root,
|
project_root: project.root,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
|||||||
@ -257,6 +257,7 @@ pub fn run() {
|
|||||||
commands::get_project_permissions,
|
commands::get_project_permissions,
|
||||||
commands::update_project_permissions,
|
commands::update_project_permissions,
|
||||||
commands::update_agent_permissions,
|
commands::update_agent_permissions,
|
||||||
|
commands::update_agent_effort,
|
||||||
commands::resolve_agent_permissions,
|
commands::resolve_agent_permissions,
|
||||||
commands::get_project_system_permissions,
|
commands::get_project_system_permissions,
|
||||||
commands::update_project_system_permissions,
|
commands::update_project_system_permissions,
|
||||||
@ -401,6 +402,9 @@ pub fn run() {
|
|||||||
plugins::plugin_workspace_read_binary,
|
plugins::plugin_workspace_read_binary,
|
||||||
plugins::plugin_workspace_write_text,
|
plugins::plugin_workspace_write_text,
|
||||||
plugins::plugin_workspace_write_binary,
|
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_list_dir,
|
||||||
plugins::plugin_workspace_stat,
|
plugins::plugin_workspace_stat,
|
||||||
plugins::plugin_query_project_structure,
|
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_read_binary,
|
||||||
plugins::plugin_workspace_write_text,
|
plugins::plugin_workspace_write_text,
|
||||||
plugins::plugin_workspace_write_binary,
|
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_list_dir,
|
||||||
plugins::plugin_workspace_stat,
|
plugins::plugin_workspace_stat,
|
||||||
plugins::plugin_query_project_structure,
|
plugins::plugin_query_project_structure,
|
||||||
@ -1030,6 +1037,61 @@ mod tests {
|
|||||||
std::fs::remove_dir_all(app_data).ok();
|
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]
|
#[test]
|
||||||
fn dto_plugins_command_task_commands_are_registered_in_tauri_invoke_handler() {
|
fn dto_plugins_command_task_commands_are_registered_in_tauri_invoke_handler() {
|
||||||
let app_data = test_app_data_dir("plugin-task-commands");
|
let app_data = test_app_data_dir("plugin-task-commands");
|
||||||
|
|||||||
@ -10,11 +10,11 @@ use backend::dto::{
|
|||||||
PluginEventPollDto, PluginEventSubscribeDto, PluginEventSubscriptionDto,
|
PluginEventPollDto, PluginEventSubscribeDto, PluginEventSubscriptionDto,
|
||||||
PluginEventUnsubscribeDto, PluginInstallResultDto, PluginProjectStructureDto,
|
PluginEventUnsubscribeDto, PluginInstallResultDto, PluginProjectStructureDto,
|
||||||
PluginProjectStructureQueryDto, PluginReviewDto, PluginRunCommandDto,
|
PluginProjectStructureQueryDto, PluginReviewDto, PluginRunCommandDto,
|
||||||
PluginRuntimeContributionCatalogDto, PluginTaskDto, PluginTaskStatusDto,
|
PluginRuntimeContributionCatalogDto, PluginStorageGetDto, PluginStorageSetDto, PluginTaskDto,
|
||||||
PluginToolchainDiagnosticDto, PluginToolchainDiagnosticRequestDto, PluginUninstallResultDto,
|
PluginTaskStatusDto, PluginToolchainDiagnosticDto, PluginToolchainDiagnosticRequestDto,
|
||||||
PluginWorkspaceBinaryFileDto, PluginWorkspaceDirectoryListingDto, PluginWorkspacePathDto,
|
PluginUninstallResultDto, PluginWorkspaceBinaryFileDto, PluginWorkspaceDirectoryListingDto,
|
||||||
PluginWorkspaceStatDto, PluginWorkspaceTextFileDto, PluginWorkspaceWriteBinaryDto,
|
PluginWorkspacePathDto, PluginWorkspaceStatDto, PluginWorkspaceTextFileDto,
|
||||||
PluginWorkspaceWriteTextDto, ReviewPluginPackageDto,
|
PluginWorkspaceWriteBinaryDto, PluginWorkspaceWriteTextDto, ReviewPluginPackageDto,
|
||||||
};
|
};
|
||||||
use domain::ports::{PluginManifestValidator, PluginPackageStore, PluginRegistryStore};
|
use domain::ports::{PluginManifestValidator, PluginPackageStore, PluginRegistryStore};
|
||||||
use domain::{PluginId, RelativePath};
|
use domain::{PluginId, RelativePath};
|
||||||
@ -216,6 +216,45 @@ pub async fn plugin_workspace_write_binary(
|
|||||||
.map_err(ErrorDto::from)
|
.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.
|
/// Lists a workspace directory for the public plugin API.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn plugin_workspace_list_dir(
|
pub async fn plugin_workspace_list_dir(
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
use app_tauri_lib::dto::{
|
use app_tauri_lib::dto::{
|
||||||
parse_agent_id, AgentDto, AgentListDto, ConversationDetailsDto, CreateAgentRequestDto,
|
parse_agent_id, AgentDto, AgentListDto, ConversationDetailsDto, CreateAgentRequestDto,
|
||||||
InspectConversationRequestDto, LaunchAgentRequestDto, LiveAgentListDto, TerminalSessionDto,
|
InspectConversationRequestDto, LaunchAgentRequestDto, LiveAgentListDto, TerminalSessionDto,
|
||||||
UpdateAgentContextRequestDto,
|
UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto,
|
||||||
};
|
};
|
||||||
use application::AppError;
|
use application::AppError;
|
||||||
use application::{
|
use application::{
|
||||||
@ -17,7 +17,7 @@ use application::{
|
|||||||
use domain::ids::{AgentId, NodeId, ProfileId, SessionId};
|
use domain::ids::{AgentId, NodeId, ProfileId, SessionId};
|
||||||
use domain::ports::ConversationDetails;
|
use domain::ports::ConversationDetails;
|
||||||
use domain::terminal::{PtySize, SessionKind, SessionStatus, TerminalSession};
|
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 serde_json::json;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@ -60,6 +60,15 @@ fn agent_dto_serialises_camelcase() {
|
|||||||
assert!(v.get("profile_id").is_none());
|
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]
|
#[test]
|
||||||
fn agent_list_dto_is_transparent_array() {
|
fn agent_list_dto_is_transparent_array() {
|
||||||
let first = make_agent(1, 2);
|
let first = make_agent(1, 2);
|
||||||
@ -73,6 +82,7 @@ fn agent_list_dto_is_transparent_array() {
|
|||||||
kind: SkillKind::Reference,
|
kind: SkillKind::Reference,
|
||||||
}],
|
}],
|
||||||
}],
|
}],
|
||||||
|
effective_orchestrator: Some(first.id),
|
||||||
};
|
};
|
||||||
let dto = AgentListDto::from(out);
|
let dto = AgentListDto::from(out);
|
||||||
let v = serde_json::to_value(&dto).unwrap();
|
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]["name"], "review");
|
||||||
assert_eq!(arr[0]["capabilities"][0]["description"], "Reviews changes");
|
assert_eq!(arr[0]["capabilities"][0]["description"], "Reviews changes");
|
||||||
assert_eq!(arr[0]["capabilities"][0]["kind"], "reference");
|
assert_eq!(arr[0]["capabilities"][0]["kind"], "reference");
|
||||||
|
assert_eq!(arr[0]["isOrchestrator"], true);
|
||||||
assert_eq!(arr[1]["capabilities"], json!([]));
|
assert_eq!(arr[1]["capabilities"], json!([]));
|
||||||
|
assert_eq!(arr[1]["isOrchestrator"], false);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -139,6 +151,28 @@ fn update_agent_context_request_deserialises_camelcase() {
|
|||||||
assert_eq!(dto.content, "# Updated");
|
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]
|
#[test]
|
||||||
fn launch_agent_request_deserialises_camelcase() {
|
fn launch_agent_request_deserialises_camelcase() {
|
||||||
let project_id = Uuid::from_u128(1).to_string();
|
let project_id = Uuid::from_u128(1).to_string();
|
||||||
|
|||||||
@ -12,6 +12,7 @@ thiserror = { workspace = true }
|
|||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
sha2 = { workspace = true }
|
||||||
subtle = { workspace = true }
|
subtle = { workspace = true }
|
||||||
# Resolves the OpenCode cache dir (`~/.cache/opencode/models.json`) for the
|
# Resolves the OpenCode cache dir (`~/.cache/opencode/models.json`) for the
|
||||||
# dynamic provider catalogue (ticket #92 follow-up). See
|
# dynamic provider catalogue (ticket #92 follow-up). See
|
||||||
|
|||||||
@ -21,8 +21,8 @@ use domain::ports::{
|
|||||||
SpawnSpec, StructuredProviderLaunchPolicy, SystemPermissionStore,
|
SpawnSpec, StructuredProviderLaunchPolicy, SystemPermissionStore,
|
||||||
};
|
};
|
||||||
use domain::profile::{
|
use domain::profile::{
|
||||||
McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter, CODEX_CODE_MODE_FEATURES_TABLE,
|
resolve_effort, EffortSelection, McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter,
|
||||||
CODEX_CODE_MODE_FEATURES_TOML,
|
CODEX_CODE_MODE_FEATURES_TABLE, CODEX_CODE_MODE_FEATURES_TOML,
|
||||||
};
|
};
|
||||||
use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan};
|
use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan};
|
||||||
use domain::{
|
use domain::{
|
||||||
@ -123,8 +123,9 @@ pub struct InjectedLiveRow {
|
|||||||
pub struct ResolvedAssignedSkill {
|
pub struct ResolvedAssignedSkill {
|
||||||
/// Agent-facing snapshot metadata.
|
/// Agent-facing snapshot metadata.
|
||||||
pub snapshot: domain::AssignedSkillSnapshot,
|
pub snapshot: domain::AssignedSkillSnapshot,
|
||||||
/// Full Markdown body, used only on non-MCP profiles that cannot call
|
/// Full Markdown body kept in the effective snapshot so authorized lazy-read
|
||||||
/// `idea_skill_read`.
|
/// paths can use the same resolution; convention-file rendering exposes only
|
||||||
|
/// bounded affordances.
|
||||||
pub content: MarkdownDoc,
|
pub content: MarkdownDoc,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -140,7 +141,9 @@ pub struct EffectiveAgentContext {
|
|||||||
pub capabilities: OrchestrationCapabilitySnapshot,
|
pub capabilities: OrchestrationCapabilitySnapshot,
|
||||||
/// Compact agent capability affordances resolved by [`ResolveAgentCapabilities`].
|
/// Compact agent capability affordances resolved by [`ResolveAgentCapabilities`].
|
||||||
pub agent_capabilities: Vec<AgentCapability>,
|
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>,
|
pub assigned_skills: Vec<ResolvedAssignedSkill>,
|
||||||
/// Project-memory recall selected for this launch.
|
/// Project-memory recall selected for this launch.
|
||||||
pub memory: Vec<MemoryIndexEntry>,
|
pub memory: Vec<MemoryIndexEntry>,
|
||||||
@ -281,6 +284,8 @@ pub struct ListAgentsOutput {
|
|||||||
pub agents: Vec<Agent>,
|
pub agents: Vec<Agent>,
|
||||||
/// Resolved discoverable capabilities per agent.
|
/// Resolved discoverable capabilities per agent.
|
||||||
pub capabilities: Vec<ListedAgentCapabilities>,
|
pub capabilities: Vec<ListedAgentCapabilities>,
|
||||||
|
/// The manifest's resolved orchestrator.
|
||||||
|
pub effective_orchestrator: Option<AgentId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolved capabilities for one listed agent.
|
/// Resolved capabilities for one listed agent.
|
||||||
@ -301,6 +306,8 @@ pub struct AgentDiscoveryEntry {
|
|||||||
pub agent: Agent,
|
pub agent: Agent,
|
||||||
/// Resolved capability affordances.
|
/// Resolved capability affordances.
|
||||||
pub capabilities: Vec<AgentCapability>,
|
pub capabilities: Vec<AgentCapability>,
|
||||||
|
/// Whether this agent is the project's current orchestrator.
|
||||||
|
pub is_orchestrator: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ListAgentsOutput {
|
impl ListAgentsOutput {
|
||||||
@ -318,6 +325,7 @@ impl ListAgentsOutput {
|
|||||||
.map(|entry| entry.capabilities.clone())
|
.map(|entry| entry.capabilities.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
AgentDiscoveryEntry {
|
AgentDiscoveryEntry {
|
||||||
|
is_orchestrator: self.effective_orchestrator == Some(agent.id),
|
||||||
agent,
|
agent,
|
||||||
capabilities,
|
capabilities,
|
||||||
}
|
}
|
||||||
@ -390,6 +398,7 @@ impl ListAgents {
|
|||||||
Ok(ListAgentsOutput {
|
Ok(ListAgentsOutput {
|
||||||
agents,
|
agents,
|
||||||
capabilities,
|
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
|
// ChangeAgentProfile
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -2017,6 +2094,12 @@ impl LaunchAgent {
|
|||||||
network_permission,
|
network_permission,
|
||||||
&input.project.root,
|
&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 ──
|
// 5b. ── POINT DE ROUTAGE §17.4 : IA structuré vs terminal brut ──
|
||||||
// L'intention est explicite sur le launcher : les cellules humaines peuvent
|
// L'intention est explicite sur le launcher : les cellules humaines peuvent
|
||||||
@ -2053,7 +2136,7 @@ impl LaunchAgent {
|
|||||||
factory.as_ref(),
|
factory.as_ref(),
|
||||||
structured,
|
structured,
|
||||||
&agent,
|
&agent,
|
||||||
&profile,
|
&launch_profile,
|
||||||
&prepared,
|
&prepared,
|
||||||
&run_dir,
|
&run_dir,
|
||||||
&session_plan,
|
&session_plan,
|
||||||
@ -2083,7 +2166,7 @@ impl LaunchAgent {
|
|||||||
// CODEX_HOME isolé. Passer le modèle du profil sur l'argv garantit que le
|
// 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é
|
// lancement interactif respecte l'édition IdeA, comme le chemin structuré
|
||||||
// le fait déjà dans `CodexExecSession`.
|
// 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.
|
// 6. Spawn the PTY at the resolved cwd; adopt its session id everywhere.
|
||||||
let handle = self.pty.spawn(spec.clone(), size).await?;
|
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
|
/// 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,
|
/// 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
|
/// 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
|
/// agent's persona `.md` (ARCHITECTURE §14.2).
|
||||||
/// `# Skills` section (ARCHITECTURE §14.2).
|
|
||||||
///
|
///
|
||||||
/// A short skill-awareness paragraph is always injected in the orchestration
|
/// A short skill-awareness paragraph is always injected in the orchestration
|
||||||
/// block (followed by the auto-memory harvest directive, Lot E1): it explains that
|
/// 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;
|
/// protocol). This awareness deliberately does not inject unassigned skill bodies;
|
||||||
/// assignment remains the context boundary.
|
/// assignment remains the context boundary.
|
||||||
///
|
///
|
||||||
/// On top of that awareness, the assigned skills surface in one of two ways
|
/// On top of that awareness, assigned skills surface as a high-altitude
|
||||||
/// depending on the agent's **surface** (feature « skills à la MCP »), always in
|
/// `# Skills disponibles` section right after the orchestration block, listing
|
||||||
/// the given (manifest) order — making the output deterministic:
|
/// each as `**<name>** — <effective description> (<kind>)` (affordances only,
|
||||||
/// - **MCP mode** (`mcp_enabled`): a high-altitude `# Skills disponibles` section,
|
/// *no body*). This bounded surface is shared by MCP and non-MCP profiles; only
|
||||||
/// right after the orchestration block, listing each as
|
/// the instruction for loading details is adapted to the active runtime surface.
|
||||||
/// `**<name>** — <effective description> (<kind>)` (affordances only, *no body*), with
|
/// When `skills` is empty the section is omitted entirely.
|
||||||
/// 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.
|
|
||||||
///
|
///
|
||||||
/// The project's `memory` recall (index/hooks, ARCHITECTURE §14.5.4) is appended as
|
/// 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
|
/// 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(memory_awareness());
|
||||||
out.push_str("---\n\n");
|
out.push_str("---\n\n");
|
||||||
|
|
||||||
// Skills « à la MCP » (feature skill-awareness) : à HAUTE ALTITUDE, juste après
|
// Skills « capability-first » : à HAUTE ALTITUDE, juste après le bloc
|
||||||
// le bloc d'orchestration. On expose les skills assignés comme des **affordances
|
// 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,
|
// 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
|
if !effective.agent_capabilities.is_empty() {
|
||||||
// `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() {
|
|
||||||
out.push_str("# Skills disponibles\n\n");
|
out.push_str("# Skills disponibles\n\n");
|
||||||
out.push_str("Snapshot version: ");
|
out.push_str("Snapshot version: ");
|
||||||
out.push_str(&effective.capabilities.version.to_string());
|
out.push_str(&effective.capabilities.version.to_string());
|
||||||
out.push_str("\n\n");
|
out.push_str("\n\n");
|
||||||
out.push_str(
|
if mcp_enabled {
|
||||||
"Les skills suivants te sont assignés. Pour en exécuter un ou consulter son \
|
out.push_str(
|
||||||
détail, appelle l'outil `idea_skill_read(name=…)` — n'improvise pas un \
|
"Les skills suivants te sont assignés. Pour en exécuter un ou consulter son \
|
||||||
workflow déjà couvert par un skill, charge-le.\n\n",
|
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 {
|
for skill in &effective.agent_capabilities {
|
||||||
out.push_str("**");
|
out.push_str("**");
|
||||||
out.push_str(&skill.name);
|
out.push_str(&skill.name);
|
||||||
@ -3616,24 +3698,6 @@ pub(crate) fn compose_convention_file(
|
|||||||
|
|
||||||
out.push_str(effective.persona.as_str());
|
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() {
|
if !effective.memory.is_empty() {
|
||||||
out.push_str("\n\n---\n\n# Mémoire projet\n\n");
|
out.push_str("\n\n---\n\n# Mémoire projet\n\n");
|
||||||
for entry in &effective.memory {
|
for entry in &effective.memory {
|
||||||
@ -3891,7 +3955,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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| {
|
let s = |n: u128, name: &str, body: &str| {
|
||||||
Skill::new(
|
Skill::new(
|
||||||
domain::SkillId::from_uuid(uuid::Uuid::from_u128(n)),
|
domain::SkillId::from_uuid(uuid::Uuid::from_u128(n)),
|
||||||
@ -3915,28 +3979,28 @@ mod tests {
|
|||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Both skill bodies present, after the persona.
|
// Both skill affordances are present, but bodies are not dumped.
|
||||||
assert!(doc.contains("REFAC_BODY"));
|
assert!(doc.contains("**refactor** — REFAC_BODY (workflow)"));
|
||||||
assert!(doc.contains("REVIEW_BODY"));
|
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 awareness_at = doc.find("**Skills IdeA**").unwrap();
|
||||||
|
let skills_at = doc.find("# Skills disponibles").unwrap();
|
||||||
let persona_at = doc.find("# Persona").unwrap();
|
let persona_at = doc.find("# Persona").unwrap();
|
||||||
let skills_at = doc.find("\n# Skills\n").unwrap();
|
let refac_at = doc.find("**refactor**").unwrap();
|
||||||
let refac_at = doc.find("REFAC_BODY").unwrap();
|
let review_at = doc.find("**review**").unwrap();
|
||||||
let review_at = doc.find("REVIEW_BODY").unwrap();
|
|
||||||
assert!(
|
assert!(
|
||||||
awareness_at < persona_at,
|
awareness_at < persona_at,
|
||||||
"skill awareness belongs to orchestration, before persona"
|
"skill awareness belongs to orchestration, before persona"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
persona_at < skills_at && skills_at < refac_at,
|
awareness_at < skills_at && skills_at < persona_at,
|
||||||
"assigned skill bodies come under the Skills section after persona"
|
"assigned skill affordances come before persona"
|
||||||
);
|
);
|
||||||
assert!(persona_at < refac_at, "skills come after the persona");
|
|
||||||
// Deterministic order: first assigned skill precedes the second.
|
// Deterministic order: first assigned skill precedes the second.
|
||||||
assert!(refac_at < review_at, "skills emitted in the given order");
|
assert!(refac_at < review_at, "skills emitted in the given order");
|
||||||
// Skill names surface as sub-headers.
|
assert!(!doc.contains("## refactor"));
|
||||||
assert!(doc.contains("## refactor"));
|
assert!(!doc.contains("## review"));
|
||||||
assert!(doc.contains("## review"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -4048,10 +4112,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn compose_convention_file_mcp_mode_exposes_skill_affordances_not_bodies() {
|
fn compose_convention_file_exposes_skill_affordances_not_bodies() {
|
||||||
// MCP mode (feature « skills à la MCP »): a high-altitude `# Skills disponibles`
|
// Both surfaces: a high-altitude `# Skills disponibles`
|
||||||
// section after the Orchestration block, listing `**name** — description`
|
// 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| {
|
let s = |n: u128, name: &str, desc: Option<&str>, body: &str| {
|
||||||
Skill::new(
|
Skill::new(
|
||||||
domain::SkillId::from_uuid(uuid::Uuid::from_u128(n)),
|
domain::SkillId::from_uuid(uuid::Uuid::from_u128(n)),
|
||||||
@ -4062,44 +4126,47 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.with_description(desc.map(str::to_owned))
|
.with_description(desc.map(str::to_owned))
|
||||||
};
|
};
|
||||||
let doc = compose_convention_file(
|
for mcp_enabled in [true, false] {
|
||||||
"/root",
|
let doc = compose_convention_file(
|
||||||
"",
|
"/root",
|
||||||
"# Persona",
|
"",
|
||||||
&[
|
"# Persona",
|
||||||
s(1, "refactor", Some("Refactors code"), "REFAC_BODY"),
|
&[
|
||||||
// No explicit description ⇒ effective_description falls back to the
|
s(1, "refactor", Some("Refactors code"), "REFAC_BODY"),
|
||||||
// body's first line (heading marker stripped).
|
// No explicit description ⇒ effective_description falls back to the
|
||||||
s(2, "review", None, "# Review skill\n\nREVIEW_BODY"),
|
// body's first line (heading marker stripped).
|
||||||
],
|
s(2, "review", None, "# Review skill\n\nREVIEW_BODY"),
|
||||||
&[],
|
],
|
||||||
None,
|
&[],
|
||||||
&[],
|
None,
|
||||||
true, // mcp_enabled
|
&[],
|
||||||
);
|
mcp_enabled,
|
||||||
|
);
|
||||||
|
|
||||||
// The affordance section is present.
|
assert!(
|
||||||
assert!(
|
doc.contains("# Skills disponibles"),
|
||||||
doc.contains("# Skills disponibles"),
|
"skills section present (mcp_enabled={mcp_enabled})"
|
||||||
"MCP skills section present"
|
);
|
||||||
);
|
if mcp_enabled {
|
||||||
assert!(doc.contains("idea_skill_read"), "points to the read tool");
|
assert!(doc.contains("idea_skill_read"), "points to the read tool");
|
||||||
// Affordance lines: `**name** — <effective description> (<kind>)`.
|
} else {
|
||||||
assert!(doc.contains("**refactor** — Refactors code (workflow)"));
|
assert!(
|
||||||
assert!(doc.contains("**review** — Review skill (workflow)"));
|
doc.contains(".ideai/skills/md/<skill-id>.md"),
|
||||||
// The full bodies are NOT injected in MCP mode (loaded on demand instead).
|
"points to the file-protocol read path"
|
||||||
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** — Refactors code (workflow)"));
|
||||||
assert!(!doc.contains("## refactor"));
|
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
|
let orch_at = doc.find("# Orchestration IdeA").unwrap();
|
||||||
// the persona.
|
let skills_at = doc.find("# Skills disponibles").unwrap();
|
||||||
let orch_at = doc.find("# Orchestration IdeA").unwrap();
|
let persona_at = doc.find("# Persona").unwrap();
|
||||||
let skills_at = doc.find("# Skills disponibles").unwrap();
|
assert!(orch_at < skills_at, "skills come after orchestration");
|
||||||
let persona_at = doc.find("# Persona").unwrap();
|
assert!(skills_at < persona_at, "skills come before the persona");
|
||||||
assert!(orch_at < skills_at, "skills come after orchestration");
|
}
|
||||||
assert!(skills_at < persona_at, "skills come before the persona");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -37,8 +37,9 @@ pub use lifecycle::{
|
|||||||
LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, ListedAgentCapabilities,
|
LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, ListedAgentCapabilities,
|
||||||
LiveStateLeanProvider, McpRuntime, PermissionProjectorRegistry, ProviderSessionProvider,
|
LiveStateLeanProvider, McpRuntime, PermissionProjectorRegistry, ProviderSessionProvider,
|
||||||
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredRoutingMode,
|
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredRoutingMode,
|
||||||
StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput,
|
StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort,
|
||||||
AGENT_MEMORY_RECALL_BUDGET, DEFAULT_OPENCODE_MCP_TIMEOUT_MS, LIVE_STATE_INJECT_MAX,
|
UpdateAgentEffortInput, UpdateAgentEffortOutput, AGENT_MEMORY_RECALL_BUDGET,
|
||||||
|
DEFAULT_OPENCODE_MCP_TIMEOUT_MS, LIVE_STATE_INJECT_MAX,
|
||||||
};
|
};
|
||||||
pub use model_catalogue::{
|
pub use model_catalogue::{
|
||||||
claude_model_catalogue, codex_model_catalogue, ListClaudeModels, ListClaudeModelsOutput,
|
claude_model_catalogue, codex_model_catalogue, ListClaudeModels, ListClaudeModelsOutput,
|
||||||
|
|||||||
@ -26,6 +26,11 @@ pub enum AppError {
|
|||||||
#[error("invalid input: {0}")]
|
#[error("invalid input: {0}")]
|
||||||
Invalid(String),
|
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.
|
/// A filesystem operation failed.
|
||||||
#[error("filesystem error: {0}")]
|
#[error("filesystem error: {0}")]
|
||||||
FileSystem(String),
|
FileSystem(String),
|
||||||
@ -107,6 +112,7 @@ impl AppError {
|
|||||||
match self {
|
match self {
|
||||||
Self::NotFound(_) => "NOT_FOUND",
|
Self::NotFound(_) => "NOT_FOUND",
|
||||||
Self::Invalid(_) => "INVALID",
|
Self::Invalid(_) => "INVALID",
|
||||||
|
Self::Conflict(_) => "CONFLICT",
|
||||||
Self::FileSystem(_) => "FILESYSTEM",
|
Self::FileSystem(_) => "FILESYSTEM",
|
||||||
Self::Store(_) => "STORE",
|
Self::Store(_) => "STORE",
|
||||||
Self::Process(_) => "PROCESS",
|
Self::Process(_) => "PROCESS",
|
||||||
|
|||||||
@ -61,7 +61,8 @@ pub use agent::{
|
|||||||
ResumableAgent, SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput,
|
ResumableAgent, SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput,
|
||||||
SaveOpenCodeProviderProfileOutput, SaveProfile, SaveProfileInput, SaveProfileOutput,
|
SaveOpenCodeProviderProfileOutput, SaveProfile, SaveProfileInput, SaveProfileOutput,
|
||||||
SessionLimitService, StructuredRoutingMode, StructuredSessionDescriptor, TurnOutcome,
|
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,
|
LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
|
||||||
};
|
};
|
||||||
pub use background::{
|
pub use background::{
|
||||||
@ -162,16 +163,16 @@ pub use plugin::{
|
|||||||
PluginEventPollInput, PluginEventSubscribeInput, PluginEventSubscription,
|
PluginEventPollInput, PluginEventSubscribeInput, PluginEventSubscription,
|
||||||
PluginEventSubscriptions, PluginEventUnsubscribeInput, PluginFileDiagnostic,
|
PluginEventSubscriptions, PluginEventUnsubscribeInput, PluginFileDiagnostic,
|
||||||
PluginFileRequirement, PluginInstallResult, PluginPublicEvent, PluginReview,
|
PluginFileRequirement, PluginInstallResult, PluginPublicEvent, PluginReview,
|
||||||
PluginRunCommandInput, PluginRuntimeCatalog, PluginRuntimePlugin, PluginTaskStatusInput,
|
PluginRunCommandInput, PluginRuntimeCatalog, PluginRuntimePlugin, PluginStorageAccess,
|
||||||
PluginToolDiagnostic, PluginToolRequirement, PluginToolchainDiagnostic,
|
PluginStorageGetInput, PluginStorageSetInput, PluginTaskStatusInput, PluginToolDiagnostic,
|
||||||
PluginToolchainDiagnosticInput, PluginToolchainDiagnostics, PluginWorkspaceAccess,
|
PluginToolRequirement, PluginToolchainDiagnostic, PluginToolchainDiagnosticInput,
|
||||||
PluginWorkspaceBinaryFile, PluginWorkspaceDirEntry, PluginWorkspaceDirectoryListing,
|
PluginToolchainDiagnostics, PluginWorkspaceAccess, PluginWorkspaceBinaryFile,
|
||||||
PluginWorkspacePathInput, PluginWorkspaceStat, PluginWorkspaceTextFile,
|
PluginWorkspaceDirEntry, PluginWorkspaceDirectoryListing, PluginWorkspacePathInput,
|
||||||
PluginWorkspaceWriteBinaryInput, PluginWorkspaceWriteTextInput, ProjectConvention,
|
PluginWorkspaceStat, PluginWorkspaceTextFile, PluginWorkspaceWriteBinaryInput,
|
||||||
ProjectModule, ProjectStructureEntry, ProjectStructureQuery, QueryProjectStructure,
|
PluginWorkspaceWriteTextInput, ProjectConvention, ProjectModule, ProjectStructureEntry,
|
||||||
QueryProjectStructureInput, ReconcilePluginMcpServers, ReviewPluginPackage,
|
ProjectStructureQuery, QueryProjectStructure, QueryProjectStructureInput,
|
||||||
ReviewPluginPackageInput, SetPluginEnabled, SetPluginEnabledInput, UninstallPlugin,
|
ReconcilePluginMcpServers, ReviewPluginPackage, ReviewPluginPackageInput, SetPluginEnabled,
|
||||||
UninstallPluginInput, UninstallPluginResult,
|
SetPluginEnabledInput, UninstallPlugin, UninstallPluginInput, UninstallPluginResult,
|
||||||
};
|
};
|
||||||
pub use project::{
|
pub use project::{
|
||||||
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
|
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::fileguard::{may_write_directly, FileGuard, GuardError, GuardedResource};
|
||||||
use domain::markdown::MarkdownDoc;
|
use domain::markdown::MarkdownDoc;
|
||||||
use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType};
|
use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType};
|
||||||
use domain::ports::{AgentContextStore, Clock, FileSystem, MemoryStore, RemotePath};
|
use domain::ports::{AgentContextStore, Clock, EventBus, FileSystem, MemoryStore, RemotePath};
|
||||||
use domain::{AgentId, Project};
|
use domain::{AgentId, DomainEvent, Project};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
|
||||||
@ -45,6 +46,16 @@ fn join_root(project: &Project, rel: &str) -> RemotePath {
|
|||||||
RemotePath::new(format!("{base}/{rel}"))
|
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
|
/// Resolves an agent display name to its [`AgentId`] via the project manifest
|
||||||
/// (case-insensitive), or [`AppError::NotFound`].
|
/// (case-insensitive), or [`AppError::NotFound`].
|
||||||
async fn resolve_agent(
|
async fn resolve_agent(
|
||||||
@ -80,6 +91,14 @@ pub struct ReadContextInput {
|
|||||||
pub requester: ConversationParty,
|
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 {
|
impl ReadContext {
|
||||||
/// Builds the use case from its ports.
|
/// Builds the use case from its ports.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
@ -99,7 +118,7 @@ impl ReadContext {
|
|||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// [`AppError`] when the agent/context does not exist or the store/fs fails.
|
/// [`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 {
|
let ReadContextInput {
|
||||||
project,
|
project,
|
||||||
target,
|
target,
|
||||||
@ -115,9 +134,13 @@ impl ReadContext {
|
|||||||
.map_err(map_guard_err)?;
|
.map_err(map_guard_err)?;
|
||||||
let path = join_root(&project, PROJECT_CONTEXT_FILE);
|
let path = join_root(&project, PROJECT_CONTEXT_FILE);
|
||||||
let bytes = self.fs.read(&path).await?;
|
let bytes = self.fs.read(&path).await?;
|
||||||
|
let version = hex_sha256(&bytes);
|
||||||
let text =
|
let text =
|
||||||
String::from_utf8(bytes).map_err(|e| AppError::Invalid(e.to_string()))?;
|
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) => {
|
Some(name) => {
|
||||||
let agent = resolve_agent(&self.contexts, &project, &name).await?;
|
let agent = resolve_agent(&self.contexts, &project, &name).await?;
|
||||||
@ -126,12 +149,114 @@ impl ReadContext {
|
|||||||
.acquire_read(requester, GuardedResource::AgentContext(agent))
|
.acquire_read(requester, GuardedResource::AgentContext(agent))
|
||||||
.await
|
.await
|
||||||
.map_err(map_guard_err)?;
|
.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`].
|
/// 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
|
/// 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::agent::{AgentManifest, ManifestEntry};
|
||||||
use domain::conversation::ConversationParty;
|
use domain::conversation::ConversationParty;
|
||||||
use domain::fileguard::{ReadLease, WriteLease};
|
use domain::fileguard::{ReadLease, WriteLease};
|
||||||
use domain::ports::{FsError, MemoryError, StoreError};
|
use domain::ports::{EventStream, FsError, MemoryError, StoreError};
|
||||||
use domain::project::ProjectPath;
|
use domain::project::ProjectPath;
|
||||||
use domain::{ProfileId, ProjectId, RemoteRef};
|
use domain::{ProfileId, ProjectId, RemoteRef};
|
||||||
use std::collections::HashMap;
|
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> {
|
fn guard() -> Arc<dyn FileGuard> {
|
||||||
Arc::new(TestGuard::default())
|
Arc::new(TestGuard::default())
|
||||||
}
|
}
|
||||||
@ -619,6 +763,7 @@ mod tests {
|
|||||||
synchronized: false,
|
synchronized: false,
|
||||||
synced_template_version: None,
|
synced_template_version: None,
|
||||||
skills: Vec::new(),
|
skills: Vec::new(),
|
||||||
|
effort: None,
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
contexts: Mutex::new(contexts),
|
contexts: Mutex::new(contexts),
|
||||||
@ -635,7 +780,7 @@ mod tests {
|
|||||||
contexts_with("Dev", agent, "# hello"),
|
contexts_with("Dev", agent, "# hello"),
|
||||||
Arc::new(FakeFs::default()),
|
Arc::new(FakeFs::default()),
|
||||||
);
|
);
|
||||||
let md = uc
|
let out = uc
|
||||||
.execute(ReadContextInput {
|
.execute(ReadContextInput {
|
||||||
project: project(),
|
project: project(),
|
||||||
target: Some("dev".to_owned()), // case-insensitive
|
target: Some("dev".to_owned()), // case-insensitive
|
||||||
@ -643,7 +788,8 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(md.as_str(), "# hello");
|
assert_eq!(out.content.as_str(), "# hello");
|
||||||
|
assert_eq!(out.version, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@ -658,7 +804,7 @@ mod tests {
|
|||||||
contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
|
contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
|
||||||
fs,
|
fs,
|
||||||
);
|
);
|
||||||
let md = uc
|
let out = uc
|
||||||
.execute(ReadContextInput {
|
.execute(ReadContextInput {
|
||||||
project: project(),
|
project: project(),
|
||||||
target: None,
|
target: None,
|
||||||
@ -666,7 +812,8 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.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]
|
#[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]
|
#[tokio::test]
|
||||||
async fn propose_agent_context_writes_directly() {
|
async fn propose_agent_context_writes_directly() {
|
||||||
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
|
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
|
||||||
|
|||||||
@ -10,8 +10,9 @@ mod service;
|
|||||||
pub mod wake;
|
pub mod wake;
|
||||||
|
|
||||||
pub use context_guard::{
|
pub use context_guard::{
|
||||||
ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory,
|
ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput,
|
||||||
ReadMemoryInput, WriteMemory, WriteMemoryInput,
|
ReadContextOutput, ReadMemory, ReadMemoryInput, UpdateProjectContext,
|
||||||
|
UpdateProjectContextInput, UpdateProjectContextOutput, WriteMemory, WriteMemoryInput,
|
||||||
};
|
};
|
||||||
pub use rendezvous::{
|
pub use rendezvous::{
|
||||||
resolve_rendezvous_ceiling, resolve_rendezvous_window, run_inactivity_watchdog,
|
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::rendezvous::{run_inactivity_watchdog, WatchdogOutcome};
|
||||||
use crate::orchestrator::{
|
use crate::orchestrator::{
|
||||||
ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory,
|
ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory,
|
||||||
ReadMemoryInput, WriteMemory, WriteMemoryInput,
|
ReadMemoryInput, UpdateProjectContext, UpdateProjectContextInput, WriteMemory,
|
||||||
|
WriteMemoryInput,
|
||||||
};
|
};
|
||||||
use crate::skill::{CreateSkill, CreateSkillInput, ReadSkill, ReadSkillInput};
|
use crate::skill::{CreateSkill, CreateSkillInput, ReadSkill, ReadSkillInput};
|
||||||
use crate::terminal::{CloseTerminal, CloseTerminalInput, StructuredSessions, TerminalSessions};
|
use crate::terminal::{CloseTerminal, CloseTerminalInput, StructuredSessions, TerminalSessions};
|
||||||
@ -534,6 +535,8 @@ pub struct ContextGuardUseCases {
|
|||||||
pub read_context: Arc<ReadContext>,
|
pub read_context: Arc<ReadContext>,
|
||||||
/// Proposition/écriture d'un contexte `.md` IdeA sous le garde.
|
/// Proposition/écriture d'un contexte `.md` IdeA sous le garde.
|
||||||
pub propose_context: Arc<ProposeContext>,
|
pub propose_context: Arc<ProposeContext>,
|
||||||
|
/// Écriture directe stricte du contexte projet global.
|
||||||
|
pub update_project_context: Arc<UpdateProjectContext>,
|
||||||
/// Lecture mémoire sous read-lease.
|
/// Lecture mémoire sous read-lease.
|
||||||
pub read_memory: Arc<ReadMemory>,
|
pub read_memory: Arc<ReadMemory>,
|
||||||
/// Écriture mémoire sous write-lease.
|
/// Écriture mémoire sous write-lease.
|
||||||
@ -1241,6 +1244,14 @@ impl OrchestratorService {
|
|||||||
self.propose_context(project, target, content, requester)
|
self.propose_context(project, target, content, requester)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
OrchestratorCommand::UpdateProjectContext {
|
||||||
|
content,
|
||||||
|
if_match,
|
||||||
|
requester,
|
||||||
|
} => {
|
||||||
|
self.update_project_context(project, content, if_match, requester)
|
||||||
|
.await
|
||||||
|
}
|
||||||
OrchestratorCommand::ReadMemory { slug, requester } => {
|
OrchestratorCommand::ReadMemory { slug, requester } => {
|
||||||
self.read_memory(project, slug, requester).await
|
self.read_memory(project, slug, requester).await
|
||||||
}
|
}
|
||||||
@ -1363,7 +1374,7 @@ impl OrchestratorService {
|
|||||||
target: Option<String>,
|
target: Option<String>,
|
||||||
requester: ConversationParty,
|
requester: ConversationParty,
|
||||||
) -> Result<OrchestratorOutcome, AppError> {
|
) -> Result<OrchestratorOutcome, AppError> {
|
||||||
let md = self
|
let out = self
|
||||||
.require_context_guard()?
|
.require_context_guard()?
|
||||||
.read_context
|
.read_context
|
||||||
.execute(ReadContextInput {
|
.execute(ReadContextInput {
|
||||||
@ -1372,9 +1383,13 @@ impl OrchestratorService {
|
|||||||
requester,
|
requester,
|
||||||
})
|
})
|
||||||
.await?;
|
.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 {
|
Ok(OrchestratorOutcome {
|
||||||
detail: format!("read {} context", target.as_deref().unwrap_or("project")),
|
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
|
/// `memory.read` → reads a note (or the index) under a shared read-lease; the
|
||||||
/// content is returned inline in the outcome's `reply`.
|
/// content is returned inline in the outcome's `reply`.
|
||||||
async fn read_memory(
|
async fn read_memory(
|
||||||
@ -2783,6 +2823,7 @@ impl OrchestratorService {
|
|||||||
description: None,
|
description: None,
|
||||||
content,
|
content,
|
||||||
scope,
|
scope,
|
||||||
|
kind: domain::SkillKind::Workflow,
|
||||||
project_root: project.root.clone(),
|
project_root: project.root.clone(),
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@ -7,7 +7,10 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use domain::ports::PermissionStore;
|
use domain::ports::PermissionStore;
|
||||||
use domain::{AgentId, EffectivePermissions, PermissionSet, Project, ProjectPermissions};
|
use domain::{
|
||||||
|
AgentId, EffectivePermissions, PermissionSet, PermissionShadowReport, Project,
|
||||||
|
ProjectPermissions,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
|
||||||
@ -131,6 +134,7 @@ impl ResolveAgentPermissions {
|
|||||||
let doc = self.store.load_permissions(&input.project).await?;
|
let doc = self.store.load_permissions(&input.project).await?;
|
||||||
Ok(ResolveAgentPermissionsOutput {
|
Ok(ResolveAgentPermissionsOutput {
|
||||||
effective: doc.resolve_for(input.agent_id),
|
effective: doc.resolve_for(input.agent_id),
|
||||||
|
shadowed: doc.shadow_for(input.agent_id),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -147,4 +151,6 @@ pub struct ResolveAgentPermissionsInput {
|
|||||||
pub struct ResolveAgentPermissionsOutput {
|
pub struct ResolveAgentPermissionsOutput {
|
||||||
/// Resolved policy, or `None` when neither project nor agent policy exists.
|
/// Resolved policy, or `None` when neither project nor agent policy exists.
|
||||||
pub effective: Option<EffectivePermissions>,
|
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,
|
BackgroundTaskStore, Clock, DirEntry, EventBus, FileMetadata, FileSystem, IdGenerator,
|
||||||
LocalPath, Output, PluginManifestBytes, PluginManifestError, PluginManifestValidator,
|
LocalPath, Output, PluginManifestBytes, PluginManifestError, PluginManifestValidator,
|
||||||
PluginMcpError, PluginMcpSupervisor, PluginPackageStore, PluginRegistryError,
|
PluginMcpError, PluginMcpSupervisor, PluginPackageStore, PluginRegistryError,
|
||||||
PluginRegistryStore, PluginStoreError, ProcessError, ProcessSpawner, ProjectStore, RemotePath,
|
PluginRegistryStore, PluginStorageError, PluginStorageStore, PluginStoreError, ProcessError,
|
||||||
SpawnSpec,
|
ProcessSpawner, ProjectStore, RemotePath, SpawnSpec,
|
||||||
};
|
};
|
||||||
use domain::{
|
use domain::{
|
||||||
AgentId, BackgroundTask, BackgroundTaskState, BackgroundTaskWakePolicy, ContentHash,
|
AgentId, BackgroundTask, BackgroundTaskState, BackgroundTaskWakePolicy, ContentHash,
|
||||||
@ -157,6 +157,28 @@ pub struct PluginRuntimePlugin {
|
|||||||
pub contributes: PluginContributionSet,
|
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.
|
/// Input for reviewing a package.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum ReviewPluginPackageInput {
|
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 {
|
fn map_registry(e: PluginRegistryError) -> AppError {
|
||||||
match e {
|
match e {
|
||||||
PluginRegistryError::Io(m) => AppError::Store(m),
|
PluginRegistryError::Io(m) => AppError::Store(m),
|
||||||
@ -2516,9 +2546,91 @@ pub struct UninstallPluginInput {
|
|||||||
pub plugin_id: String,
|
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.
|
/// Uninstalls a plugin.
|
||||||
pub struct UninstallPlugin {
|
pub struct UninstallPlugin {
|
||||||
packages: Arc<dyn PluginPackageStore>,
|
packages: Arc<dyn PluginPackageStore>,
|
||||||
|
storage: Arc<dyn PluginStorageStore>,
|
||||||
registry: Arc<dyn PluginRegistryStore>,
|
registry: Arc<dyn PluginRegistryStore>,
|
||||||
events: Arc<dyn EventBus>,
|
events: Arc<dyn EventBus>,
|
||||||
mcp: Arc<dyn PluginMcpSupervisor>,
|
mcp: Arc<dyn PluginMcpSupervisor>,
|
||||||
@ -2529,12 +2641,14 @@ impl UninstallPlugin {
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
packages: Arc<dyn PluginPackageStore>,
|
packages: Arc<dyn PluginPackageStore>,
|
||||||
|
storage: Arc<dyn PluginStorageStore>,
|
||||||
registry: Arc<dyn PluginRegistryStore>,
|
registry: Arc<dyn PluginRegistryStore>,
|
||||||
events: Arc<dyn EventBus>,
|
events: Arc<dyn EventBus>,
|
||||||
mcp: Arc<dyn PluginMcpSupervisor>,
|
mcp: Arc<dyn PluginMcpSupervisor>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
packages,
|
packages,
|
||||||
|
storage,
|
||||||
registry,
|
registry,
|
||||||
events,
|
events,
|
||||||
mcp,
|
mcp,
|
||||||
@ -2562,6 +2676,10 @@ impl UninstallPlugin {
|
|||||||
.remove_package(&plugin_id)
|
.remove_package(&plugin_id)
|
||||||
.await
|
.await
|
||||||
.map_err(map_store)?;
|
.map_err(map_store)?;
|
||||||
|
self.storage
|
||||||
|
.purge_plugin(&plugin_id)
|
||||||
|
.await
|
||||||
|
.map_err(map_storage)?;
|
||||||
self.events.publish(DomainEvent::PluginUninstalled {
|
self.events.publish(DomainEvent::PluginUninstalled {
|
||||||
plugin_id: plugin_id.clone(),
|
plugin_id: plugin_id.clone(),
|
||||||
restart_required: true,
|
restart_required: true,
|
||||||
@ -3214,7 +3332,8 @@ mod tests {
|
|||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
BackgroundCompletionStream, BackgroundTaskHandle, BackgroundTaskPortError,
|
BackgroundCompletionStream, BackgroundTaskHandle, BackgroundTaskPortError,
|
||||||
BackgroundTaskRunner, BackgroundTaskSpec, EventStream, FileMetadata, IdGenerator,
|
BackgroundTaskRunner, BackgroundTaskSpec, EventStream, FileMetadata, IdGenerator,
|
||||||
PluginPackageStore, PluginRegistryStore, PluginStoreError, StoreError,
|
PluginPackageStore, PluginRegistryStore, PluginStorageError, PluginStorageStore,
|
||||||
|
PluginStoreError, StoreError,
|
||||||
};
|
};
|
||||||
use domain::remote::RemoteRef;
|
use domain::remote::RemoteRef;
|
||||||
use domain::{BackgroundTaskState, ProjectPath};
|
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)]
|
#[derive(Default)]
|
||||||
struct FakeEvents {
|
struct FakeEvents {
|
||||||
events: Mutex<Vec<DomainEvent>>,
|
events: Mutex<Vec<DomainEvent>>,
|
||||||
@ -3802,6 +3984,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn uninstall_removes_registry_package_and_stops_mcp() {
|
async fn uninstall_removes_registry_package_and_stops_mcp() {
|
||||||
let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));
|
let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));
|
||||||
|
let storage = Arc::new(FakeStorage::default());
|
||||||
let registry = Arc::new(FakeRegistry {
|
let registry = Arc::new(FakeRegistry {
|
||||||
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
|
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
|
||||||
});
|
});
|
||||||
@ -3809,6 +3992,7 @@ mod tests {
|
|||||||
let mcp = Arc::new(FakeMcp::default());
|
let mcp = Arc::new(FakeMcp::default());
|
||||||
let uninstall = UninstallPlugin::new(
|
let uninstall = UninstallPlugin::new(
|
||||||
packages.clone(),
|
packages.clone(),
|
||||||
|
storage.clone(),
|
||||||
registry.clone(),
|
registry.clone(),
|
||||||
events.clone(),
|
events.clone(),
|
||||||
mcp.clone(),
|
mcp.clone(),
|
||||||
@ -3825,6 +4009,7 @@ mod tests {
|
|||||||
assert!(result.restart_required);
|
assert!(result.restart_required);
|
||||||
assert!(registry.load_registry().await.unwrap().plugins.is_empty());
|
assert!(registry.load_registry().await.unwrap().plugins.is_empty());
|
||||||
assert_eq!(&*packages.removed.lock().unwrap(), &["dev.acme.gitgraph"]);
|
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_eq!(&*mcp.stops.lock().unwrap(), &["dev.acme.gitgraph"]);
|
||||||
assert!(events.events.lock().unwrap().iter().any(|event| matches!(
|
assert!(events.events.lock().unwrap().iter().any(|event| matches!(
|
||||||
event,
|
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]
|
#[tokio::test]
|
||||||
async fn uninstall_then_reinstall_leaves_runtime_catalog_active_without_residue() {
|
async fn uninstall_then_reinstall_leaves_runtime_catalog_active_without_residue() {
|
||||||
let packages = Arc::new(FakePackages::with_manifest_and_staged_count(
|
let packages = Arc::new(FakePackages::with_manifest_and_staged_count(
|
||||||
@ -3844,6 +4112,7 @@ mod tests {
|
|||||||
let registry = Arc::new(FakeRegistry::default());
|
let registry = Arc::new(FakeRegistry::default());
|
||||||
let events = Arc::new(FakeEvents::default());
|
let events = Arc::new(FakeEvents::default());
|
||||||
let mcp = Arc::new(FakeMcp::default());
|
let mcp = Arc::new(FakeMcp::default());
|
||||||
|
let storage = Arc::new(FakeStorage::default());
|
||||||
let install = InstallPluginFromDirectory::new(
|
let install = InstallPluginFromDirectory::new(
|
||||||
packages.clone(),
|
packages.clone(),
|
||||||
registry.clone(),
|
registry.clone(),
|
||||||
@ -3851,8 +4120,13 @@ mod tests {
|
|||||||
events.clone(),
|
events.clone(),
|
||||||
mcp.clone(),
|
mcp.clone(),
|
||||||
);
|
);
|
||||||
let uninstall =
|
let uninstall = UninstallPlugin::new(
|
||||||
UninstallPlugin::new(packages.clone(), registry.clone(), events, mcp.clone());
|
packages.clone(),
|
||||||
|
storage.clone(),
|
||||||
|
registry.clone(),
|
||||||
|
events,
|
||||||
|
mcp.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
install.execute("/source/plugin".to_owned()).await.unwrap();
|
install.execute("/source/plugin".to_owned()).await.unwrap();
|
||||||
uninstall
|
uninstall
|
||||||
|
|||||||
@ -30,6 +30,9 @@ pub struct CreateSkillInput {
|
|||||||
/// `None`/empty ⇒ the skill falls back to the first line of its body when
|
/// `None`/empty ⇒ the skill falls back to the first line of its body when
|
||||||
/// surfaced (see [`domain::Skill::effective_description`]).
|
/// surfaced (see [`domain::Skill::effective_description`]).
|
||||||
pub description: Option<String>,
|
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.
|
/// Initial Markdown body.
|
||||||
pub content: String,
|
pub content: String,
|
||||||
/// Scope the skill is created in (selects its backing store).
|
/// 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 id = SkillId::from_uuid(self.ids.new_uuid());
|
||||||
let skill = Skill::new(id, input.name, MarkdownDoc::new(input.content), input.scope)
|
let skill = Skill::new(id, input.name, MarkdownDoc::new(input.content), input.scope)
|
||||||
.map_err(|e| AppError::Invalid(e.to_string()))?
|
.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?;
|
self.skills.save(&skill, &input.project_root).await?;
|
||||||
Ok(CreateSkillOutput { skill })
|
Ok(CreateSkillOutput { skill })
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,8 +35,8 @@ use domain::ports::{
|
|||||||
RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, SystemPermissionStore,
|
RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, SystemPermissionStore,
|
||||||
};
|
};
|
||||||
use domain::profile::{
|
use domain::profile::{
|
||||||
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, OpenCodeConfig,
|
AgentProfile, ContextInjection, EffortSelection, McpCapability, McpConfigStrategy,
|
||||||
SessionStrategy, StructuredAdapter,
|
McpTransport, OpenCodeConfig, SessionStrategy, StructuredAdapter,
|
||||||
};
|
};
|
||||||
use domain::project::{Project, ProjectPath};
|
use domain::project::{Project, ProjectPath};
|
||||||
use domain::remote::RemoteRef;
|
use domain::remote::RemoteRef;
|
||||||
@ -50,7 +50,7 @@ use application::{
|
|||||||
CreateAgentFromScratch, CreateAgentInput, DeleteAgent, DeleteAgentInput, LaunchAgent,
|
CreateAgentFromScratch, CreateAgentInput, DeleteAgent, DeleteAgentInput, LaunchAgent,
|
||||||
LaunchAgentInput, ListAgents, ListAgentsInput, PermissionProjectorRegistry, ReadAgentContext,
|
LaunchAgentInput, ListAgents, ListAgentsInput, PermissionProjectorRegistry, ReadAgentContext,
|
||||||
ReadAgentContextInput, StructuredRoutingMode, StructuredSessions, TerminalSessions,
|
ReadAgentContextInput, StructuredRoutingMode, StructuredSessions, TerminalSessions,
|
||||||
UpdateAgentContext, UpdateAgentContextInput,
|
UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort, UpdateAgentEffortInput,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -604,6 +604,7 @@ impl AgentSession for FakeSession {
|
|||||||
struct FakeStructuredFactory {
|
struct FakeStructuredFactory {
|
||||||
trace: Trace,
|
trace: Trace,
|
||||||
starts: Arc<Mutex<Vec<ProfileId>>>,
|
starts: Arc<Mutex<Vec<ProfileId>>>,
|
||||||
|
efforts: Arc<Mutex<Vec<Option<String>>>>,
|
||||||
envs: Arc<Mutex<Vec<Vec<(String, String)>>>>,
|
envs: Arc<Mutex<Vec<Vec<(String, String)>>>>,
|
||||||
policies: Arc<Mutex<Vec<Option<domain::ports::StructuredProviderLaunchPolicy>>>>,
|
policies: Arc<Mutex<Vec<Option<domain::ports::StructuredProviderLaunchPolicy>>>>,
|
||||||
next_session: SessionId,
|
next_session: SessionId,
|
||||||
@ -614,6 +615,7 @@ impl FakeStructuredFactory {
|
|||||||
Self {
|
Self {
|
||||||
trace,
|
trace,
|
||||||
starts: Arc::new(Mutex::new(Vec::new())),
|
starts: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
efforts: Arc::new(Mutex::new(Vec::new())),
|
||||||
envs: Arc::new(Mutex::new(Vec::new())),
|
envs: Arc::new(Mutex::new(Vec::new())),
|
||||||
policies: Arc::new(Mutex::new(Vec::new())),
|
policies: Arc::new(Mutex::new(Vec::new())),
|
||||||
next_session,
|
next_session,
|
||||||
@ -624,6 +626,10 @@ impl FakeStructuredFactory {
|
|||||||
self.starts.lock().unwrap().clone()
|
self.starts.lock().unwrap().clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn efforts(&self) -> Vec<Option<String>> {
|
||||||
|
self.efforts.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
fn envs(&self) -> Vec<Vec<(String, String)>> {
|
fn envs(&self) -> Vec<Vec<(String, String)>> {
|
||||||
self.envs.lock().unwrap().clone()
|
self.envs.lock().unwrap().clone()
|
||||||
}
|
}
|
||||||
@ -655,6 +661,10 @@ impl AgentSessionFactory for FakeStructuredFactory {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.push("structured.start".to_owned());
|
.push("structured.start".to_owned());
|
||||||
self.starts.lock().unwrap().push(profile.id);
|
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.envs.lock().unwrap().push(_env.to_vec());
|
||||||
self.policies
|
self.policies
|
||||||
.lock()
|
.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]
|
#[tokio::test]
|
||||||
async fn read_then_update_context_roundtrips() {
|
async fn read_then_update_context_roundtrips() {
|
||||||
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
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]
|
#[tokio::test]
|
||||||
async fn delete_removes_entry_then_unknown_is_not_found() {
|
async fn delete_removes_entry_then_unknown_is_not_found() {
|
||||||
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
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,
|
recall: FakeRecall,
|
||||||
) -> LaunchFixture {
|
) -> LaunchFixture {
|
||||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id);
|
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 contexts = FakeContexts::with_agent(&agent, "# ctx body");
|
||||||
let profiles = FakeProfiles::new(vec![profile]);
|
let profiles = FakeProfiles::new(vec![profile]);
|
||||||
let tr = trace();
|
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]
|
#[tokio::test]
|
||||||
async fn structured_profile_without_factory_require_structured_errors_without_pty_spawn() {
|
async fn structured_profile_without_factory_require_structured_errors_without_pty_spawn() {
|
||||||
let profile = profile(
|
let profile = profile(
|
||||||
@ -3264,6 +3451,17 @@ fn launch_with_projection_and_env(
|
|||||||
env: Vec<(String, String)>,
|
env: Vec<(String, String)>,
|
||||||
) -> (LaunchAgent, Agent, FakeFs, FakePty, Arc<TerminalSessions>) {
|
) -> (LaunchAgent, Agent, FakeFs, FakePty, Arc<TerminalSessions>) {
|
||||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id);
|
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 contexts = FakeContexts::with_agent(&agent, "# ctx body");
|
||||||
let profiles = FakeProfiles::new(vec![profile]);
|
let profiles = FakeProfiles::new(vec![profile]);
|
||||||
let tr = trace();
|
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) MCP decoupling — THE key case of the lot ---------------------------
|
||||||
|
|
||||||
/// (5) A Codex profile with **no MCP capability** still gets its sandbox projected
|
/// (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::ports::{PermissionStore, StoreError};
|
||||||
use domain::project::{Project, ProjectPath};
|
use domain::project::{Project, ProjectPath};
|
||||||
use domain::remote::RemoteRef;
|
use domain::remote::RemoteRef;
|
||||||
use domain::{PermissionSet, Posture, ProjectPermissions};
|
use domain::{
|
||||||
|
Capability, Effect, PermissionRule, PermissionSet, PermissionShadowReport, Posture,
|
||||||
|
ProjectPermissions,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct FakePermissionStore {
|
struct FakePermissionStore {
|
||||||
@ -125,4 +128,69 @@ async fn resolve_agent_permissions_returns_effective_policy() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(out.effective.unwrap().fallback(), Posture::Ask);
|
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::{
|
use domain::ports::{
|
||||||
AgentContextStore, EventBus, EventStream, IdGenerator, SkillStore, StoreError,
|
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 domain::{AgentManifest, ManifestEntry, Project, ProjectPath, RemoteRef, SkillRef};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@ -196,6 +196,7 @@ async fn create_skill_persists_in_its_scope() {
|
|||||||
.execute(CreateSkillInput {
|
.execute(CreateSkillInput {
|
||||||
name: "refactor".to_owned(),
|
name: "refactor".to_owned(),
|
||||||
description: Some("Refactors code".to_owned()),
|
description: Some("Refactors code".to_owned()),
|
||||||
|
kind: SkillKind::Reference,
|
||||||
content: "# body".to_owned(),
|
content: "# body".to_owned(),
|
||||||
scope: SkillScope::Project,
|
scope: SkillScope::Project,
|
||||||
project_root: root(),
|
project_root: root(),
|
||||||
@ -204,6 +205,7 @@ async fn create_skill_persists_in_its_scope() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(out.skill.scope, SkillScope::Project);
|
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.
|
// 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!(out.skill.description.as_deref(), Some("Refactors code"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@ -228,6 +230,7 @@ async fn create_skill_rejects_empty_content() {
|
|||||||
.execute(CreateSkillInput {
|
.execute(CreateSkillInput {
|
||||||
name: "k".to_owned(),
|
name: "k".to_owned(),
|
||||||
description: None,
|
description: None,
|
||||||
|
kind: SkillKind::Workflow,
|
||||||
content: String::new(),
|
content: String::new(),
|
||||||
scope: SkillScope::Global,
|
scope: SkillScope::Global,
|
||||||
project_root: root(),
|
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.
|
/// Plugin structured config document read request DTO.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@ -2377,8 +2418,8 @@ use application::{
|
|||||||
LaunchAgentOutput, ListAgentsOutput, ReadAgentContextOutput, ReadMcpToolPermissionsOutput,
|
LaunchAgentOutput, ListAgentsOutput, ReadAgentContextOutput, ReadMcpToolPermissionsOutput,
|
||||||
};
|
};
|
||||||
use domain::{
|
use domain::{
|
||||||
Agent, AgentMcpToolPolicyOverride, EffectivePermissions, McpToolPolicy, PermissionSet,
|
Agent, AgentMcpToolPolicyOverride, EffectivePermissions, EffortSelection, McpToolPolicy,
|
||||||
ProjectPermissions, SkillKind, TerminalSession,
|
PermissionSet, PermissionShadowReport, ProjectPermissions, SkillKind, TerminalSession,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// One discoverable capability carried by an agent.
|
/// One discoverable capability carried by an agent.
|
||||||
@ -2413,6 +2454,8 @@ pub struct AgentDto {
|
|||||||
pub agent: Agent,
|
pub agent: Agent,
|
||||||
/// Resolved discoverable capabilities.
|
/// Resolved discoverable capabilities.
|
||||||
pub capabilities: Vec<AgentCapabilityDto>,
|
pub capabilities: Vec<AgentCapabilityDto>,
|
||||||
|
/// Whether this agent is the effective project orchestrator.
|
||||||
|
pub is_orchestrator: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AgentDto {
|
impl AgentDto {
|
||||||
@ -2422,6 +2465,7 @@ impl AgentDto {
|
|||||||
Self {
|
Self {
|
||||||
agent,
|
agent,
|
||||||
capabilities: Vec::new(),
|
capabilities: Vec::new(),
|
||||||
|
is_orchestrator: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2443,6 +2487,7 @@ impl From<ListAgentsOutput> for AgentListDto {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(AgentCapabilityDto::from)
|
.map(AgentCapabilityDto::from)
|
||||||
.collect(),
|
.collect(),
|
||||||
|
is_orchestrator: entry.is_orchestrator,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
)
|
)
|
||||||
@ -2512,6 +2557,25 @@ pub struct ProjectPermissionsDto(pub ProjectPermissions);
|
|||||||
#[serde(transparent)]
|
#[serde(transparent)]
|
||||||
pub struct EffectivePermissionsDto(pub EffectivePermissions);
|
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.
|
/// Full project system permission document crossing the wire.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(transparent)]
|
#[serde(transparent)]
|
||||||
@ -2767,6 +2831,18 @@ pub struct ChangeAgentProfileRequestDto {
|
|||||||
pub cols: u16,
|
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
|
/// Response DTO for `change_agent_profile`: the mutated agent plus the freshly
|
||||||
/// relaunched session when a live session was hot-swapped (absent otherwise).
|
/// relaunched session when a live session was hot-swapped (absent otherwise).
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
@ -3997,10 +4073,16 @@ pub struct CreateSkillRequestDto {
|
|||||||
pub project_id: String,
|
pub project_id: String,
|
||||||
/// Display name.
|
/// Display name.
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
/// Optional one-line affordance description.
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: Option<String>,
|
||||||
/// Initial Markdown content.
|
/// Initial Markdown content.
|
||||||
pub content: String,
|
pub content: String,
|
||||||
/// Scope the skill is created in.
|
/// Scope the skill is created in.
|
||||||
pub scope: SkillScope,
|
pub scope: SkillScope,
|
||||||
|
/// Capability nature. Missing legacy clients create workflow skills.
|
||||||
|
#[serde(default)]
|
||||||
|
pub kind: SkillKind,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request DTO for `update_skill`.
|
/// Request DTO for `update_skill`.
|
||||||
@ -4703,7 +4785,7 @@ pub struct SpawnBackgroundCommandRequestDto {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use application::McpToolPermissionCatalogue;
|
use application::McpToolPermissionCatalogue;
|
||||||
use domain::mailbox::TicketId;
|
use domain::mailbox::TicketId;
|
||||||
use domain::{AgentId, ConversationId, ProjectMcpToolPermissions};
|
use domain::{AgentId, ConversationId, PermissionShadowReport, ProjectMcpToolPermissions};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use uuid::Uuid;
|
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]
|
#[test]
|
||||||
fn dto_plugins_workspace_requests_use_stable_camel_case_contract() {
|
fn dto_plugins_workspace_requests_use_stable_camel_case_contract() {
|
||||||
let path = PluginWorkspacePathDto {
|
let path = PluginWorkspacePathDto {
|
||||||
@ -4868,6 +4978,42 @@ mod tests {
|
|||||||
assert_eq!(input.value["enabled"], true);
|
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]
|
#[test]
|
||||||
fn dto_plugins_workspace_outputs_use_stable_camel_case_contract() {
|
fn dto_plugins_workspace_outputs_use_stable_camel_case_contract() {
|
||||||
let listing = PluginWorkspaceDirectoryListingDto {
|
let listing = PluginWorkspaceDirectoryListingDto {
|
||||||
|
|||||||
@ -579,6 +579,16 @@ pub enum DomainEventDto {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
orchestrator: Option<String>,
|
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.
|
/// A memory note was created or updated.
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
MemorySaved {
|
MemorySaved {
|
||||||
@ -1213,6 +1223,15 @@ impl From<&DomainEvent> for DomainEventDto {
|
|||||||
project_id: project_id.to_string(),
|
project_id: project_id.to_string(),
|
||||||
orchestrator: orchestrator.as_ref().map(|a| a.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 {
|
DomainEvent::MemorySaved { slug } => Self::MemorySaved {
|
||||||
slug: slug.as_str().to_string(),
|
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]
|
#[test]
|
||||||
fn background_completion_relays_rendezvous_context_to_wire() {
|
fn background_completion_relays_rendezvous_context_to_wire() {
|
||||||
let project_id = ProjectId::from_uuid(uuid::Uuid::from_u128(1));
|
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::path::PathBuf;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use application::orchestrator::UpdateProjectContext as GuardedUpdateProjectContext;
|
||||||
use application::{
|
use application::{
|
||||||
AddIssueAttachment, AgentResumer, AgentWakeService, AppError, AssignIssueAgent,
|
AddIssueAttachment, AgentResumer, AgentWakeService, AppError, AssignIssueAgent,
|
||||||
AssignSkillToAgent, AssignTicketToSprint, AttachLiveAgent, AuthenticateSession,
|
AssignSkillToAgent, AssignTicketToSprint, AttachLiveAgent, AuthenticateSession,
|
||||||
@ -35,7 +36,7 @@ use application::{
|
|||||||
MarkIssueAttachmentSummarized, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow,
|
MarkIssueAttachmentSummarized, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow,
|
||||||
MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OpenTicketAssistant,
|
MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OpenTicketAssistant,
|
||||||
OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry,
|
OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry,
|
||||||
PluginCommandTasks, PluginConfigDocuments, PluginEventSubscriptions,
|
PluginCommandTasks, PluginConfigDocuments, PluginEventSubscriptions, PluginStorageAccess,
|
||||||
PluginToolchainDiagnostics, PluginWorkspaceAccess, ProposeContext, QueryProjectStructure,
|
PluginToolchainDiagnostics, PluginWorkspaceAccess, ProposeContext, QueryProjectStructure,
|
||||||
ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueAttachment,
|
ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueAttachment,
|
||||||
ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, ReadProjectContext,
|
ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, ReadProjectContext,
|
||||||
@ -49,7 +50,7 @@ use application::{
|
|||||||
SetPluginEnabled, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand,
|
SetPluginEnabled, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand,
|
||||||
StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession,
|
StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession,
|
||||||
SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent,
|
SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent,
|
||||||
UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContext,
|
UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContext, UpdateAgentEffort,
|
||||||
UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentSystemPermissions,
|
UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentSystemPermissions,
|
||||||
UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext,
|
UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext,
|
||||||
UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateProjectSystemPermissions,
|
UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateProjectSystemPermissions,
|
||||||
@ -64,10 +65,11 @@ use domain::ports::{
|
|||||||
EmbedderProfileStore, EmbedderPromptStore, EnvironmentReader, EventBus, FileSystem, GitPort,
|
EmbedderProfileStore, EmbedderPromptStore, EnvironmentReader, EventBus, FileSystem, GitPort,
|
||||||
IdGenerator, IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall,
|
IdGenerator, IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall,
|
||||||
MemoryStore, ModelArtifactDownloader, PermissionStore, PluginManifestValidator,
|
MemoryStore, ModelArtifactDownloader, PermissionStore, PluginManifestValidator,
|
||||||
PluginMcpSupervisor, PluginPackageStore, PluginRegistryStore, ProcessSpawner, ProfileStore,
|
PluginMcpSupervisor, PluginPackageStore, PluginRegistryStore, PluginStorageStore,
|
||||||
ProjectStore, PtyHandle, PtyPort, RuntimePermissionProbe, ScheduledTask, Scheduler,
|
ProcessSpawner, ProfileStore, ProjectStore, PtyHandle, PtyPort, RuntimePermissionProbe,
|
||||||
SecretStore, SkillStore, SprintStore, StructuredSessionEnvironmentPreparer,
|
ScheduledTask, Scheduler, SecretStore, SkillStore, SprintStore,
|
||||||
SystemPermissionStore, TemplateStore, ToolInvoker, WakeError, WakeReason, WindowStateStore,
|
StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, ToolInvoker,
|
||||||
|
WakeError, WakeReason, WindowStateStore,
|
||||||
};
|
};
|
||||||
use domain::profile::{
|
use domain::profile::{
|
||||||
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
||||||
@ -91,15 +93,15 @@ use infrastructure::{
|
|||||||
FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator,
|
FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator,
|
||||||
FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry,
|
FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry,
|
||||||
FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore,
|
FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore,
|
||||||
FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSecretStore, FsSkillStore,
|
FsPluginStorageStore, FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSecretStore,
|
||||||
FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore, Git2Repository,
|
FsSkillStore, FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore,
|
||||||
HeuristicHandoffSummarizer, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe,
|
Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
|
||||||
HttpProviderModelCatalogue, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, IdeaiContextStore,
|
||||||
InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalEnvironmentReader, LocalFileSystem,
|
InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime,
|
||||||
LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
|
LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer,
|
||||||
OrchestratorWatchHandle, PortablePtyAdapter, ProcessCliVersionReader,
|
MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter,
|
||||||
ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory, SystemClock,
|
ProcessCliVersionReader, ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory,
|
||||||
SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer,
|
SystemClock, SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer,
|
||||||
TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator,
|
TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator,
|
||||||
VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
|
VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
|
||||||
VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||||
@ -1093,6 +1095,8 @@ pub struct BackendCore {
|
|||||||
pub update_project_permissions: Arc<UpdateProjectPermissions>,
|
pub update_project_permissions: Arc<UpdateProjectPermissions>,
|
||||||
/// Update one agent permission override.
|
/// Update one agent permission override.
|
||||||
pub update_agent_permissions: Arc<UpdateAgentPermissions>,
|
pub update_agent_permissions: Arc<UpdateAgentPermissions>,
|
||||||
|
/// Update one agent effort override.
|
||||||
|
pub update_agent_effort: Arc<UpdateAgentEffort>,
|
||||||
/// Resolve effective permissions for one agent.
|
/// Resolve effective permissions for one agent.
|
||||||
pub resolve_agent_permissions: Arc<ResolveAgentPermissions>,
|
pub resolve_agent_permissions: Arc<ResolveAgentPermissions>,
|
||||||
/// Read the project system permission document.
|
/// Read the project system permission document.
|
||||||
@ -1142,6 +1146,8 @@ pub struct BackendCore {
|
|||||||
pub plugin_workspace_access: Arc<PluginWorkspaceAccess>,
|
pub plugin_workspace_access: Arc<PluginWorkspaceAccess>,
|
||||||
/// Public plugin structured config document facade.
|
/// Public plugin structured config document facade.
|
||||||
pub plugin_config_documents: Arc<PluginConfigDocuments>,
|
pub plugin_config_documents: Arc<PluginConfigDocuments>,
|
||||||
|
/// Public plugin-owned storage facade.
|
||||||
|
pub plugin_storage_access: Arc<PluginStorageAccess>,
|
||||||
/// Public plugin project-structure query use case.
|
/// Public plugin project-structure query use case.
|
||||||
pub query_project_structure: Arc<QueryProjectStructure>,
|
pub query_project_structure: Arc<QueryProjectStructure>,
|
||||||
/// Public plugin command/task facade.
|
/// Public plugin command/task facade.
|
||||||
@ -1416,11 +1422,13 @@ impl BackendCore {
|
|||||||
let events_port = Arc::clone(&event_bus) as Arc<dyn EventBus>;
|
let events_port = Arc::clone(&event_bus) as Arc<dyn EventBus>;
|
||||||
let plugin_packages = Arc::new(FsPluginPackageStore::new(app_data_dir.clone()));
|
let plugin_packages = Arc::new(FsPluginPackageStore::new(app_data_dir.clone()));
|
||||||
let plugin_registry = Arc::new(FsPluginRegistryStore::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 =
|
let plugin_validator =
|
||||||
Arc::new(JsonPluginManifestValidator::new(env!("CARGO_PKG_VERSION")));
|
Arc::new(JsonPluginManifestValidator::new(env!("CARGO_PKG_VERSION")));
|
||||||
let plugin_mcp_supervisor = Arc::new(ExternalMcpPluginSupervisor::new());
|
let plugin_mcp_supervisor = Arc::new(ExternalMcpPluginSupervisor::new());
|
||||||
let plugin_package_store = Arc::clone(&plugin_packages) as Arc<dyn PluginPackageStore>;
|
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_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 =
|
let plugin_manifest_validator =
|
||||||
Arc::clone(&plugin_validator) as Arc<dyn PluginManifestValidator>;
|
Arc::clone(&plugin_validator) as Arc<dyn PluginManifestValidator>;
|
||||||
let plugin_mcp_supervisor_port =
|
let plugin_mcp_supervisor_port =
|
||||||
@ -2178,6 +2186,7 @@ impl BackendCore {
|
|||||||
let update_agent_permissions = Arc::new(UpdateAgentPermissions::new(Arc::clone(
|
let update_agent_permissions = Arc::new(UpdateAgentPermissions::new(Arc::clone(
|
||||||
&permission_store_port,
|
&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(
|
let resolve_agent_permissions = Arc::new(ResolveAgentPermissions::new(Arc::clone(
|
||||||
&permission_store_port,
|
&permission_store_port,
|
||||||
)));
|
)));
|
||||||
@ -2425,6 +2434,7 @@ impl BackendCore {
|
|||||||
));
|
));
|
||||||
let uninstall_plugin = Arc::new(UninstallPlugin::new(
|
let uninstall_plugin = Arc::new(UninstallPlugin::new(
|
||||||
Arc::clone(&plugin_package_store),
|
Arc::clone(&plugin_package_store),
|
||||||
|
Arc::clone(&plugin_storage_store),
|
||||||
Arc::clone(&plugin_registry_store),
|
Arc::clone(&plugin_registry_store),
|
||||||
Arc::clone(&events_port),
|
Arc::clone(&events_port),
|
||||||
Arc::clone(&plugin_mcp_supervisor_port),
|
Arc::clone(&plugin_mcp_supervisor_port),
|
||||||
@ -2448,6 +2458,10 @@ impl BackendCore {
|
|||||||
PluginConfigDocuments::new(Arc::clone(&store_port), Arc::clone(&fs_port))
|
PluginConfigDocuments::new(Arc::clone(&store_port), Arc::clone(&fs_port))
|
||||||
.with_events(Arc::clone(&events_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(
|
let query_project_structure = Arc::new(QueryProjectStructure::new(
|
||||||
Arc::clone(&store_port),
|
Arc::clone(&store_port),
|
||||||
Arc::clone(&fs_port),
|
Arc::clone(&fs_port),
|
||||||
@ -2718,6 +2732,13 @@ impl BackendCore {
|
|||||||
Arc::clone(&fs_port),
|
Arc::clone(&fs_port),
|
||||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
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(
|
read_memory: Arc::new(ReadMemory::new(
|
||||||
Arc::clone(&file_guard),
|
Arc::clone(&file_guard),
|
||||||
Arc::clone(&memory_store_port),
|
Arc::clone(&memory_store_port),
|
||||||
@ -2976,6 +2997,7 @@ impl BackendCore {
|
|||||||
get_project_permissions,
|
get_project_permissions,
|
||||||
update_project_permissions,
|
update_project_permissions,
|
||||||
update_agent_permissions,
|
update_agent_permissions,
|
||||||
|
update_agent_effort,
|
||||||
resolve_agent_permissions,
|
resolve_agent_permissions,
|
||||||
get_project_system_permissions,
|
get_project_system_permissions,
|
||||||
update_project_system_permissions,
|
update_project_system_permissions,
|
||||||
@ -3045,6 +3067,7 @@ impl BackendCore {
|
|||||||
reconcile_plugin_mcp_servers,
|
reconcile_plugin_mcp_servers,
|
||||||
plugin_workspace_access,
|
plugin_workspace_access,
|
||||||
plugin_config_documents,
|
plugin_config_documents,
|
||||||
|
plugin_storage_access,
|
||||||
query_project_structure,
|
query_project_structure,
|
||||||
plugin_command_tasks,
|
plugin_command_tasks,
|
||||||
plugin_toolchain_diagnostics,
|
plugin_toolchain_diagnostics,
|
||||||
@ -4649,6 +4672,7 @@ mod mcp_serve_peer_tests {
|
|||||||
synchronized: false,
|
synchronized: false,
|
||||||
synced_template_version: None,
|
synced_template_version: None,
|
||||||
skills: Vec::new(),
|
skills: Vec::new(),
|
||||||
|
effort: None,
|
||||||
});
|
});
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
@ -5387,6 +5411,7 @@ mod mcp_serve_peer_tests {
|
|||||||
// traite ces commandes sans cette erreur — et le réfute sans le câblage.
|
// 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 application::{ContextGuardUseCases, ProposeContext, ReadContext, ReadMemory, WriteMemory};
|
||||||
use domain::conversation::ConversationParty;
|
use domain::conversation::ConversationParty;
|
||||||
use domain::memory::{
|
use domain::memory::{
|
||||||
@ -5481,6 +5506,13 @@ mod mcp_serve_peer_tests {
|
|||||||
Arc::new(FakeFs),
|
Arc::new(FakeFs),
|
||||||
Arc::new(FixedClock),
|
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(
|
read_memory: Arc::new(ReadMemory::new(
|
||||||
Arc::clone(&file_guard),
|
Arc::clone(&file_guard),
|
||||||
Arc::clone(&memory) as Arc<dyn MemoryStore>,
|
Arc::clone(&memory) as Arc<dyn MemoryStore>,
|
||||||
@ -5977,6 +6009,7 @@ mod mcp_e2e_loopback_tests {
|
|||||||
synchronized: false,
|
synchronized: false,
|
||||||
synced_template_version: None,
|
synced_template_version: None,
|
||||||
skills: Vec::new(),
|
skills: Vec::new(),
|
||||||
|
effort: None,
|
||||||
});
|
});
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::error::DomainError;
|
use crate::error::DomainError;
|
||||||
use crate::ids::{AgentId, ProfileId, TemplateId};
|
use crate::ids::{AgentId, ProfileId, TemplateId};
|
||||||
|
use crate::profile::EffortSelection;
|
||||||
use crate::skill::SkillRef;
|
use crate::skill::SkillRef;
|
||||||
use crate::template::TemplateVersion;
|
use crate::template::TemplateVersion;
|
||||||
|
|
||||||
@ -70,6 +71,10 @@ pub struct Agent {
|
|||||||
/// activation (ARCHITECTURE §14.2). Empty by default.
|
/// activation (ARCHITECTURE §14.2). Empty by default.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub skills: Vec<SkillRef>,
|
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 {
|
impl Agent {
|
||||||
@ -104,6 +109,7 @@ impl Agent {
|
|||||||
origin,
|
origin,
|
||||||
synchronized,
|
synchronized,
|
||||||
skills: Vec::new(),
|
skills: Vec::new(),
|
||||||
|
effort: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -128,6 +134,13 @@ impl Agent {
|
|||||||
self
|
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
|
/// Assigns a skill to this agent. Idempotent: re-assigning the same
|
||||||
/// `skill_id` is a no-op (returns `false`); a new assignment returns `true`.
|
/// `skill_id` is a no-op (returns `false`); a new assignment returns `true`.
|
||||||
pub fn assign_skill(&mut self, skill: SkillRef) -> bool {
|
pub fn assign_skill(&mut self, skill: SkillRef) -> bool {
|
||||||
@ -183,6 +196,9 @@ pub struct ManifestEntry {
|
|||||||
/// backward-compatible deserialisation of pre-L12 manifests.
|
/// backward-compatible deserialisation of pre-L12 manifests.
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub skills: Vec<SkillRef>,
|
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 {
|
impl ManifestEntry {
|
||||||
@ -221,6 +237,7 @@ impl ManifestEntry {
|
|||||||
synchronized,
|
synchronized,
|
||||||
synced_template_version,
|
synced_template_version,
|
||||||
skills: Vec::new(),
|
skills: Vec::new(),
|
||||||
|
effort: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -246,6 +263,7 @@ impl ManifestEntry {
|
|||||||
synchronized: agent.synchronized,
|
synchronized: agent.synchronized,
|
||||||
synced_template_version,
|
synced_template_version,
|
||||||
skills: agent.skills.clone(),
|
skills: agent.skills.clone(),
|
||||||
|
effort: agent.effort.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -271,7 +289,8 @@ impl ManifestEntry {
|
|||||||
origin,
|
origin,
|
||||||
self.synchronized,
|
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,
|
&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).
|
/// (the oldest agent orchestrates).
|
||||||
orchestrator: Option<AgentId>,
|
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).
|
/// Raw PTY output (usually routed to a dedicated channel, not this bus).
|
||||||
PtyOutput {
|
PtyOutput {
|
||||||
/// The session.
|
/// The session.
|
||||||
|
|||||||
@ -106,8 +106,8 @@ pub use skill::{Skill, SkillKind, SkillRef, SkillScope};
|
|||||||
pub use template::{AgentTemplate, TemplateVersion};
|
pub use template::{AgentTemplate, TemplateVersion};
|
||||||
|
|
||||||
pub use profile::{
|
pub use profile::{
|
||||||
AgentProfile, ContextInjection, EmbedderProfile, EmbedderStrategy, LivenessStrategy,
|
resolve_effort, AgentProfile, ContextInjection, EffortOption, EffortSelection, EmbedderProfile,
|
||||||
McpServerWiring, RateLimitPattern, SessionStrategy,
|
EmbedderStrategy, LivenessStrategy, McpServerWiring, RateLimitPattern, SessionStrategy,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use mailbox::{
|
pub use mailbox::{
|
||||||
@ -205,8 +205,8 @@ pub use permission::{
|
|||||||
opencode_permission_block, render_permission_summary, resolve as resolve_permissions,
|
opencode_permission_block, render_permission_summary, resolve as resolve_permissions,
|
||||||
AgentPermissionOverride, Capability, CommandMatcher, CommandRule, Effect, EffectivePermissions,
|
AgentPermissionOverride, Capability, CommandMatcher, CommandRule, Effect, EffectivePermissions,
|
||||||
Glob, PathScope, PermissionError, PermissionProjection, PermissionProjector, PermissionRule,
|
Glob, PathScope, PermissionError, PermissionProjection, PermissionProjector, PermissionRule,
|
||||||
PermissionSet, Posture, ProjectPermissions, ProjectedFile, ProjectionContext, ProjectorKey,
|
PermissionSet, PermissionShadowReport, Posture, ProjectPermissions, ProjectedFile,
|
||||||
PERMISSIONS_VERSION,
|
ProjectionContext, ProjectorKey, PERMISSIONS_VERSION,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use system_permissions::{
|
pub use system_permissions::{
|
||||||
@ -248,10 +248,10 @@ pub use ports::{
|
|||||||
MemoryStore, ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress,
|
MemoryStore, ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress,
|
||||||
ModelArtifactResolution, Output, OutputStream, PermissionStore, PluginManifestBytes,
|
ModelArtifactResolution, Output, OutputStream, PermissionStore, PluginManifestBytes,
|
||||||
PluginManifestError, PluginManifestValidator, PluginMcpError, PluginMcpSupervisor,
|
PluginManifestError, PluginManifestValidator, PluginMcpError, PluginMcpSupervisor,
|
||||||
PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStoreError,
|
PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStorageError,
|
||||||
PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore,
|
PluginStorageStore, PluginStoreError, PreparedContext, ProcessError, ProcessSpawner,
|
||||||
ProviderModelCatalogue, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath,
|
ProfileStore, ProjectStore, ProviderModelCatalogue, PtyError, PtyHandle, PtyPort, RemoteError,
|
||||||
RuntimeError, RuntimePermissionProbe, ScheduledTask, Scheduler, SpawnSpec, SprintStore,
|
RemoteHost, RemotePath, RuntimeError, RuntimePermissionProbe, ScheduledTask, Scheduler,
|
||||||
SprintStoreError, StoreError, StructuredSessionEnvironment,
|
SpawnSpec, SprintStore, SprintStoreError, StoreError, StructuredSessionEnvironment,
|
||||||
StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, WindowStateStore,
|
StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, WindowStateStore,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -194,6 +194,9 @@ pub struct OrchestratorRequest {
|
|||||||
/// `memory.write`, cadrage C7). Required by those actions, ignored otherwise.
|
/// `memory.write`, cadrage C7). Required by those actions, ignored otherwise.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub content: Option<String>,
|
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`,
|
/// Target memory note slug for the memory tools (`memory.read`/`memory.write`,
|
||||||
/// cadrage C7). Required by `memory.write`; optional for `memory.read` (absent ⇒
|
/// cadrage C7). Required by `memory.write`; optional for `memory.read` (absent ⇒
|
||||||
/// the aggregated index). Ignored by the other actions.
|
/// the aggregated index). Ignored by the other actions.
|
||||||
@ -342,6 +345,16 @@ pub enum OrchestratorCommand {
|
|||||||
/// The proposing party (handshake identity).
|
/// The proposing party (handshake identity).
|
||||||
requester: ConversationParty,
|
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).
|
/// Read a memory note under the [`crate::fileguard::FileGuard`] (cadrage C7).
|
||||||
/// `slug` absent = the aggregated `MEMORY.md` index; otherwise one note.
|
/// `slug` absent = the aggregated `MEMORY.md` index; otherwise one note.
|
||||||
ReadMemory {
|
ReadMemory {
|
||||||
@ -500,6 +513,11 @@ impl OrchestratorRequest {
|
|||||||
content: self.require("content", action, self.content.as_deref())?,
|
content: self.require("content", action, self.content.as_deref())?,
|
||||||
requester: self.requester_party(),
|
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 {
|
"memory.read" => Ok(OrchestratorCommand::ReadMemory {
|
||||||
slug: self.optional_slug(),
|
slug: self.optional_slug(),
|
||||||
requester: self.requester_party(),
|
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]
|
#[test]
|
||||||
fn memory_read_optional_slug() {
|
fn memory_read_optional_slug() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@ -542,6 +542,36 @@ impl ProjectPermissions {
|
|||||||
self.agent_permissions(agent_id),
|
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
|
/// The normalised, flattened output of [`resolve`] — the **sole input** of the
|
||||||
@ -713,6 +743,72 @@ pub fn resolve(
|
|||||||
Some(EffectivePermissions { rules, fallback })
|
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
|
/// Renders a human-readable Markdown **summary** of the resolved policy, suitable
|
||||||
/// for injection into an agent's context (lot LP4-0).
|
/// 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()
|
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 -----------------------------------
|
// ---- VO construction & invariants -----------------------------------
|
||||||
|
|
||||||
#[test]
|
#[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 ------
|
// ---- LP3: ProjectorKey serde + PermissionProjection invariant ------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -386,6 +386,48 @@ pub enum PluginMcpError {
|
|||||||
Process(String),
|
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.
|
/// Store for installed plugin packages under the global app data directory.
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait PluginPackageStore: Send + Sync {
|
pub trait PluginPackageStore: Send + Sync {
|
||||||
|
|||||||
@ -846,6 +846,53 @@ fn toml_string(s: &str) -> String {
|
|||||||
json_string(s)
|
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.
|
/// Declarative runtime configuration for one AI CLI.
|
||||||
///
|
///
|
||||||
/// Invariants:
|
/// Invariants:
|
||||||
@ -914,6 +961,10 @@ pub struct AgentProfile {
|
|||||||
/// conserve le défaut natif de la CLI ; seules les sessions Codex le consomment.
|
/// conserve le défaut natif de la CLI ; seules les sessions Codex le consomment.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub model_reasoning_effort: Option<String>,
|
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).
|
/// Capacité **MCP** (ARCHITECTURE §14.3, orchestration v3, Décision 1).
|
||||||
/// `None` ⇒ repli fichier `.ideai/requests` + prose (comportement actuel).
|
/// `None` ⇒ repli fichier `.ideai/requests` + prose (comportement actuel).
|
||||||
/// `Some(_)` ⇒ IdeA matérialise la config MCP de cette CLI au lancement et
|
/// `Some(_)` ⇒ IdeA matérialise la config MCP de cette CLI au lancement et
|
||||||
@ -1122,6 +1173,7 @@ impl AgentProfile {
|
|||||||
opencode_provider: None,
|
opencode_provider: None,
|
||||||
model: None,
|
model: None,
|
||||||
model_reasoning_effort: None,
|
model_reasoning_effort: None,
|
||||||
|
effort_options: Vec::new(),
|
||||||
mcp: None,
|
mcp: None,
|
||||||
liveness: None,
|
liveness: None,
|
||||||
rate_limit_pattern: None,
|
rate_limit_pattern: None,
|
||||||
@ -1186,6 +1238,13 @@ impl AgentProfile {
|
|||||||
self
|
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
|
/// Builder : fixe la [`McpCapability`] (§14.3, orchestration v3) et renvoie le
|
||||||
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
|
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
|
||||||
/// profils sans MCP ne l'appellent simplement pas.
|
/// 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"));
|
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]
|
#[test]
|
||||||
fn opencode_backend_consistency_rejects_both_configs_set() {
|
fn opencode_backend_consistency_rejects_both_configs_set() {
|
||||||
let local = OpenCodeConfig::new(
|
let local = OpenCodeConfig::new(
|
||||||
|
|||||||
@ -24,6 +24,7 @@ serde = { workspace = true }
|
|||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
sha2 = { workspace = true }
|
sha2 = { workspace = true }
|
||||||
hex = { 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).
|
# AEAD encryption for the at-rest `SecretStore` adapter (ticket #92, lot B2).
|
||||||
# Already vendored transitively (rustls/reqwest use it) — made an explicit direct
|
# Already vendored transitively (rustls/reqwest use it) — made an explicit direct
|
||||||
# dependency here rather than adding a new crate to the tree.
|
# 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 pair_attempt_limiter::InMemoryPairAttemptLimiter;
|
||||||
pub use permission::{ClaudePermissionProjector, CodexPermissionProjector};
|
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 process::{LocalEnvironmentReader, LocalProcessSpawner};
|
||||||
pub use pty::PortablePtyAdapter;
|
pub use pty::PortablePtyAdapter;
|
||||||
pub use ratelimit::RateLimitParser;
|
pub use ratelimit::RateLimitParser;
|
||||||
|
|||||||
@ -68,6 +68,7 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[
|
|||||||
"idea_stop_agent",
|
"idea_stop_agent",
|
||||||
"idea_update_context",
|
"idea_update_context",
|
||||||
"idea_context_propose",
|
"idea_context_propose",
|
||||||
|
"idea_context_update",
|
||||||
"idea_memory_write",
|
"idea_memory_write",
|
||||||
"idea_workstate_set",
|
"idea_workstate_set",
|
||||||
"idea_create_skill",
|
"idea_create_skill",
|
||||||
@ -166,7 +167,7 @@ pub fn catalogue() -> Vec<ToolDef> {
|
|||||||
name: "idea_list_agents",
|
name: "idea_list_agents",
|
||||||
description: "List the IdeA agents declared in the project's manifest. Returns the \
|
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, \
|
agents inline as a JSON array (id, name, profile, origin, raw skills, \
|
||||||
and resolved capabilities).",
|
resolved capabilities, and isOrchestrator).",
|
||||||
input_schema: json!({
|
input_schema: json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {},
|
"properties": {},
|
||||||
@ -308,6 +309,23 @@ pub fn catalogue() -> Vec<ToolDef> {
|
|||||||
"additionalProperties": false
|
"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 {
|
ToolDef {
|
||||||
name: "idea_memory_read",
|
name: "idea_memory_read",
|
||||||
description: "Read project memory (under the file guard). Omit `slug` for the aggregated \
|
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"),
|
content: s("content"),
|
||||||
..base()
|
..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 {
|
"idea_memory_read" => OrchestratorRequest {
|
||||||
request_type: Some("memory.read".to_owned()),
|
request_type: Some("memory.read".to_owned()),
|
||||||
requested_by: Some(requester.to_owned()),
|
requested_by: Some(requester.to_owned()),
|
||||||
@ -556,6 +584,7 @@ fn base() -> OrchestratorRequest {
|
|||||||
result: None,
|
result: None,
|
||||||
ticket: None,
|
ticket: None,
|
||||||
content: None,
|
content: None,
|
||||||
|
if_match: None,
|
||||||
slug: None,
|
slug: None,
|
||||||
status: None,
|
status: None,
|
||||||
intent: 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
|
/// Parses an optional `nodeId` JSON value into a [`domain::NodeId`], silently
|
||||||
/// dropping a malformed/absent one (validation then rejects a `visible` launch
|
/// dropping a malformed/absent one (validation then rejects a `visible` launch
|
||||||
/// missing its node, with a precise field error).
|
/// missing its node, with a precise field error).
|
||||||
@ -880,6 +923,51 @@ mod tests {
|
|||||||
assert!(matches!(err, Err(ToolMapError::Invalid(_))));
|
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]
|
#[test]
|
||||||
fn memory_read_and_write_map_to_their_commands() {
|
fn memory_read_and_write_map_to_their_commands() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::Read;
|
use std::io::{self, Read, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
use std::sync::{Arc, Mutex, MutexGuard};
|
use std::sync::{Arc, Mutex, MutexGuard};
|
||||||
@ -11,7 +11,8 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
LocalPath, PluginManifestBytes, PluginMcpError, PluginMcpSupervisor, PluginPackageStore,
|
LocalPath, PluginManifestBytes, PluginMcpError, PluginMcpSupervisor, PluginPackageStore,
|
||||||
PluginRegistryError, PluginRegistryStore, PluginStoreError,
|
PluginRegistryError, PluginRegistryStore, PluginStorageError, PluginStorageStore,
|
||||||
|
PluginStoreError,
|
||||||
};
|
};
|
||||||
use domain::{
|
use domain::{
|
||||||
ContentHash, PluginBundleUrl, PluginId, PluginInstallSource, PluginMcpServerSpec,
|
ContentHash, PluginBundleUrl, PluginId, PluginInstallSource, PluginMcpServerSpec,
|
||||||
@ -23,6 +24,14 @@ use tokio::process::Child;
|
|||||||
|
|
||||||
const REGISTRY_FILE: &str = "registry.json";
|
const REGISTRY_FILE: &str = "registry.json";
|
||||||
const MANIFEST_FILE: &str = "idea-plugin.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/`.
|
/// Filesystem package store under app-data `plugins/`.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@ -151,18 +160,7 @@ impl PluginPackageStore for FsPluginPackageStore {
|
|||||||
archive: &LocalPath,
|
archive: &LocalPath,
|
||||||
) -> Result<StagedPluginPackage, PluginStoreError> {
|
) -> Result<StagedPluginPackage, PluginStoreError> {
|
||||||
let stage = self.stage_root()?;
|
let stage = self.stage_root()?;
|
||||||
let status = std::process::Command::new("unzip")
|
extract_archive_confined(Path::new(archive.as_str()), &stage)?;
|
||||||
.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}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
ensure_manifest(&stage)?;
|
ensure_manifest(&stage)?;
|
||||||
let content_hash = hash_dir(&stage)?;
|
let content_hash = hash_dir(&stage)?;
|
||||||
Ok(StagedPluginPackage {
|
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> {
|
fn copy_dir_all(source: &Path, target: &Path) -> Result<(), PluginStoreError> {
|
||||||
fs::create_dir_all(target).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
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()))? {
|
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)?;
|
copy_dir_all(&entry.path(), &dest)?;
|
||||||
} else if ty.is_file() {
|
} else if ty.is_file() {
|
||||||
fs::copy(entry.path(), dest).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
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(())
|
Ok(())
|
||||||
@ -329,6 +374,16 @@ fn collect_files(root: &Path, files: &mut Vec<PathBuf>) -> Result<(), PluginStor
|
|||||||
collect_files(&entry.path(), files)?;
|
collect_files(&entry.path(), files)?;
|
||||||
} else if ty.is_file() {
|
} else if ty.is_file() {
|
||||||
files.push(entry.path());
|
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(())
|
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]
|
#[async_trait]
|
||||||
impl PluginRegistryStore for FsPluginRegistryStore {
|
impl PluginRegistryStore for FsPluginRegistryStore {
|
||||||
async fn load_registry(&self) -> Result<PluginRegistry, PluginRegistryError> {
|
async fn load_registry(&self) -> Result<PluginRegistry, PluginRegistryError> {
|
||||||
@ -574,6 +720,99 @@ mod tests {
|
|||||||
.unwrap();
|
.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)]
|
#[derive(Default)]
|
||||||
struct RecordingBridge {
|
struct RecordingBridge {
|
||||||
started: Mutex<Vec<PluginMcpServerSpec>>,
|
started: Mutex<Vec<PluginMcpServerSpec>>,
|
||||||
@ -671,35 +910,23 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn extracts_archive_without_path_escape() {
|
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 app = temp_dir("archive-app");
|
||||||
let source = temp_dir("archive-source");
|
|
||||||
write_plugin(&source, "bundle");
|
|
||||||
let archive_path = app.join("plugin.ideaplug");
|
let archive_path = app.join("plugin.ideaplug");
|
||||||
{
|
write_zip(
|
||||||
let status = std::process::Command::new("zip")
|
&archive_path,
|
||||||
.arg("-qr")
|
&[
|
||||||
.arg(&archive_path)
|
ZipEntrySpec {
|
||||||
.arg(".")
|
name: "idea-plugin.json",
|
||||||
.current_dir(&source)
|
contents: br#"{"ideaPluginManifestVersion":1,"id":"dev.acme.test","displayName":"Test","version":"1.0.0","main":"dist/index.js","trustLevel":"full","contributes":{}}"#,
|
||||||
.status()
|
unix_mode: Some(0o100644),
|
||||||
.unwrap();
|
},
|
||||||
assert!(status.success());
|
ZipEntrySpec {
|
||||||
}
|
name: "dist/index.js",
|
||||||
|
contents: b"bundle",
|
||||||
|
unix_mode: Some(0o100644),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
let store = FsPluginPackageStore::new(app.join("data"));
|
let store = FsPluginPackageStore::new(app.join("data"));
|
||||||
let staged = store
|
let staged = store
|
||||||
.install_from_archive(&LocalPath::new(archive_path.to_string_lossy()))
|
.install_from_archive(&LocalPath::new(archive_path.to_string_lossy()))
|
||||||
@ -707,9 +934,142 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(PathBuf::from(staged.root).join(MANIFEST_FILE).exists());
|
assert!(PathBuf::from(staged.root).join(MANIFEST_FILE).exists());
|
||||||
let _ = fs::remove_dir_all(app);
|
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);
|
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]
|
#[tokio::test]
|
||||||
async fn supervisor_delegates_stdio_servers_to_external_mcp_bridge() {
|
async fn supervisor_delegates_stdio_servers_to_external_mcp_bridge() {
|
||||||
let bridge = Arc::new(RecordingBridge::default());
|
let bridge = Arc::new(RecordingBridge::default());
|
||||||
|
|||||||
@ -108,6 +108,7 @@ impl FakeContexts {
|
|||||||
synchronized: false,
|
synchronized: false,
|
||||||
synced_template_version: None,
|
synced_template_version: None,
|
||||||
skills: Vec::new(),
|
skills: Vec::new(),
|
||||||
|
effort: None,
|
||||||
});
|
});
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
|||||||
@ -97,6 +97,7 @@ impl FakeContexts {
|
|||||||
synchronized: false,
|
synchronized: false,
|
||||||
synced_template_version: None,
|
synced_template_version: None,
|
||||||
skills: Vec::new(),
|
skills: Vec::new(),
|
||||||
|
effort: None,
|
||||||
});
|
});
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,7 @@ use application::{
|
|||||||
ListPlugins, UninstallPlugin, UninstallPluginInput,
|
ListPlugins, UninstallPlugin, UninstallPluginInput,
|
||||||
};
|
};
|
||||||
use infrastructure::{
|
use infrastructure::{
|
||||||
ExternalMcpPluginSupervisor, FsPluginPackageStore, FsPluginRegistryStore,
|
ExternalMcpPluginSupervisor, FsPluginPackageStore, FsPluginRegistryStore, FsPluginStorageStore,
|
||||||
TokioBroadcastEventBus,
|
TokioBroadcastEventBus,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -39,6 +39,48 @@ fn sdk_hello_plugin_path() -> PathBuf {
|
|||||||
.join("sdk/IdeaSDK/examples/hello-plugin")
|
.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]
|
#[tokio::test]
|
||||||
async fn installs_reference_fixture_and_loads_runtime_catalog() {
|
async fn installs_reference_fixture_and_loads_runtime_catalog() {
|
||||||
let app_data = temp_dir("app-data");
|
let app_data = temp_dir("app-data");
|
||||||
@ -100,7 +142,7 @@ async fn installs_reference_fixture_and_loads_runtime_catalog() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn installs_sdk_hello_plugin_and_loads_runtime_catalog() {
|
async fn installs_sdk_hello_plugin_and_loads_runtime_catalog() {
|
||||||
let app_data = temp_dir("hello-app-data");
|
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 packages = Arc::new(FsPluginPackageStore::new(&app_data));
|
||||||
let registry = Arc::new(FsPluginRegistryStore::new(&app_data));
|
let registry = Arc::new(FsPluginRegistryStore::new(&app_data));
|
||||||
let validator = Arc::new(JsonPluginManifestValidator::new("0.3.0"));
|
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");
|
assert_eq!(plugin.contributes.layouts[0].label.as_str(), "hello-world");
|
||||||
|
|
||||||
let _ = fs::remove_dir_all(app_data);
|
let _ = fs::remove_dir_all(app_data);
|
||||||
|
let _ = fs::remove_dir_all(hello_plugin);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn uninstalls_then_reinstalls_sdk_hello_plugin_without_runtime_residue() {
|
async fn uninstalls_then_reinstalls_sdk_hello_plugin_without_runtime_residue() {
|
||||||
let app_data = temp_dir("hello-reinstall-app-data");
|
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 packages = Arc::new(FsPluginPackageStore::new(&app_data));
|
||||||
let registry = Arc::new(FsPluginRegistryStore::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 validator = Arc::new(JsonPluginManifestValidator::new("0.3.0"));
|
||||||
let events = Arc::new(TokioBroadcastEventBus::new());
|
let events = Arc::new(TokioBroadcastEventBus::new());
|
||||||
let mcp = Arc::new(ExternalMcpPluginSupervisor::new());
|
let mcp = Arc::new(ExternalMcpPluginSupervisor::new());
|
||||||
@ -170,7 +214,7 @@ async fn uninstalls_then_reinstalls_sdk_hello_plugin_without_runtime_residue() {
|
|||||||
events.clone(),
|
events.clone(),
|
||||||
mcp.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
|
install
|
||||||
.execute(hello_plugin.to_string_lossy().into_owned())
|
.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"));
|
assert!(catalog.plugins[0].bundle_url.ends_with("/dist/index.js"));
|
||||||
|
|
||||||
let _ = fs::remove_dir_all(app_data);
|
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]
|
#[tokio::test]
|
||||||
|
|||||||
@ -52,10 +52,11 @@ use application::{
|
|||||||
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
|
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
|
||||||
RotateConversationLogInput, StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput,
|
RotateConversationLogInput, StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput,
|
||||||
UnassignSkillFromAgentInput, UnassignTicketFromSprintInput, UnlinkIssuesInput,
|
UnassignSkillFromAgentInput, UnassignTicketFromSprintInput, UnlinkIssuesInput,
|
||||||
UpdateAgentContextInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
|
UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput,
|
||||||
UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput, UpdateMemoryInput,
|
UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput,
|
||||||
UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
|
UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput,
|
||||||
UpdateProjectSystemPermissionsInput, UpdateSkillInput, WriteToTerminalInput,
|
UpdateProjectPermissionsInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput,
|
||||||
|
WriteToTerminalInput,
|
||||||
};
|
};
|
||||||
use domain::ports::PtyHandle;
|
use domain::ports::PtyHandle;
|
||||||
use domain::IssueActor;
|
use domain::IssueActor;
|
||||||
@ -74,16 +75,16 @@ use backend::dto::{
|
|||||||
ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto,
|
ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto,
|
||||||
ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto,
|
ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto,
|
||||||
CreateAgentRequestDto, CreateMemoryRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
|
CreateAgentRequestDto, CreateMemoryRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
|
||||||
DetectProfilesRequestDto, DetectProfilesResponseDto, EffectivePermissionsDto,
|
DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto,
|
||||||
EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto,
|
EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto,
|
||||||
GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto,
|
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
|
||||||
GitStageRequestDto, GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto,
|
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
|
||||||
InspectConversationRequestDto, LaunchAgentRequestDto, LiveAgentListDto, MemoryDto,
|
LaunchAgentRequestDto, LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto,
|
||||||
MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenCodeProviderListDto, OpenTerminalRequestDto,
|
MemoryListDto, OpenCodeProviderListDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto,
|
||||||
ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto, ProjectListDto,
|
ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto,
|
||||||
ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSystemPermissionsDto,
|
ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto,
|
||||||
ProjectWorkStateDto, ReadAgentContextResponseDto, ReadConversationPageRequestDto,
|
ReadAgentContextResponseDto, ReadConversationPageRequestDto, RecallMemoryRequestDto,
|
||||||
RecallMemoryRequestDto, ResolveAgentPermissionsRequestDto,
|
ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto,
|
||||||
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
|
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
|
||||||
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto,
|
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto,
|
||||||
SaveProfileRequestDto, SkillDto, SkillListDto, SprintCreateRequestDto, SprintDeleteRequestDto,
|
SaveProfileRequestDto, SkillDto, SkillListDto, SprintCreateRequestDto, SprintDeleteRequestDto,
|
||||||
@ -97,8 +98,9 @@ use backend::dto::{
|
|||||||
TicketListRequestDto, TicketReadRequestDto, TicketSprintAssignRequestDto,
|
TicketListRequestDto, TicketReadRequestDto, TicketSprintAssignRequestDto,
|
||||||
TicketSprintUnassignRequestDto, TicketUnlinkCommandRequestDto, TicketUpdateCarnetRequestDto,
|
TicketSprintUnassignRequestDto, TicketUnlinkCommandRequestDto, TicketUpdateCarnetRequestDto,
|
||||||
TicketUpdateRequestDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
|
TicketUpdateRequestDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
|
||||||
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
|
UpdateAgentEffortRequestDto, UpdateAgentMcpToolPermissionsRequestDto,
|
||||||
UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
UpdateAgentPermissionsRequestDto, UpdateAgentSystemPermissionsRequestDto,
|
||||||
|
UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
||||||
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
|
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
|
||||||
UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
|
UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
|
||||||
};
|
};
|
||||||
@ -2426,6 +2428,7 @@ async fn invoke(
|
|||||||
"update_agent_permissions" => {
|
"update_agent_permissions" => {
|
||||||
invoke_update_agent_permissions(&request.args, &state.app).await
|
invoke_update_agent_permissions(&request.args, &state.app).await
|
||||||
}
|
}
|
||||||
|
"update_agent_effort" => invoke_update_agent_effort(&request.args, &state.app).await,
|
||||||
"resolve_agent_permissions" => {
|
"resolve_agent_permissions" => {
|
||||||
invoke_resolve_agent_permissions(&request.args, &state.app).await
|
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
|
.create_skill
|
||||||
.execute(CreateSkillInput {
|
.execute(CreateSkillInput {
|
||||||
name: request.name,
|
name: request.name,
|
||||||
description: None,
|
description: request.description,
|
||||||
content: request.content,
|
content: request.content,
|
||||||
scope: request.scope,
|
scope: request.scope,
|
||||||
|
kind: request.kind,
|
||||||
project_root: project.root,
|
project_root: project.root,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@ -3620,6 +3624,22 @@ async fn invoke_update_agent_permissions(
|
|||||||
serde_json::to_value(output).map_err(serialization_error)
|
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(
|
async fn invoke_resolve_agent_permissions(
|
||||||
args: &Value,
|
args: &Value,
|
||||||
state: &BackendCore,
|
state: &BackendCore,
|
||||||
@ -3634,7 +3654,7 @@ async fn invoke_resolve_agent_permissions(
|
|||||||
agent_id: parse_agent_id(&request.agent_id)?,
|
agent_id: parse_agent_id(&request.agent_id)?,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map(|out| out.effective.map(EffectivePermissionsDto))
|
.map(ResolveAgentPermissionsResponseDto::from)
|
||||||
.map_err(ErrorDto::from)?;
|
.map_err(ErrorDto::from)?;
|
||||||
serde_json::to_value(output).map_err(serialization_error)
|
serde_json::to_value(output).map_err(serialization_error)
|
||||||
}
|
}
|
||||||
@ -7895,6 +7915,7 @@ mod tests {
|
|||||||
"get_project_permissions",
|
"get_project_permissions",
|
||||||
"update_project_permissions",
|
"update_project_permissions",
|
||||||
"update_agent_permissions",
|
"update_agent_permissions",
|
||||||
|
"update_agent_effort",
|
||||||
"resolve_agent_permissions",
|
"resolve_agent_permissions",
|
||||||
"get_project_system_permissions",
|
"get_project_system_permissions",
|
||||||
"update_project_system_permissions",
|
"update_project_system_permissions",
|
||||||
|
|||||||
@ -139,4 +139,25 @@ describe("TauriAgentGateway invoke payloads", () => {
|
|||||||
);
|
);
|
||||||
expect(out.relaunchedSession).toBeUndefined();
|
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 {
|
import type {
|
||||||
Agent,
|
Agent,
|
||||||
|
EffortSelection,
|
||||||
ResumableAgent,
|
ResumableAgent,
|
||||||
TerminalSession,
|
TerminalSession,
|
||||||
} from "@/domain";
|
} 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> {
|
readContext(projectId: string, agentId: string): Promise<string> {
|
||||||
return invoke<string>("read_agent_context", { projectId, agentId });
|
return invoke<string>("read_agent_context", { projectId, agentId });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,6 +48,7 @@ import {
|
|||||||
WebPluginConfigGateway,
|
WebPluginConfigGateway,
|
||||||
WebPluginEventGateway,
|
WebPluginEventGateway,
|
||||||
WebPluginGateway,
|
WebPluginGateway,
|
||||||
|
WebPluginStorageGateway,
|
||||||
WebPluginTaskGateway,
|
WebPluginTaskGateway,
|
||||||
WebPluginToolchainGateway,
|
WebPluginToolchainGateway,
|
||||||
WebPluginWorkspaceGateway,
|
WebPluginWorkspaceGateway,
|
||||||
@ -151,6 +152,7 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway
|
|||||||
pluginToolchain: new WebPluginToolchainGateway(),
|
pluginToolchain: new WebPluginToolchainGateway(),
|
||||||
pluginEvents: new WebPluginEventGateway(),
|
pluginEvents: new WebPluginEventGateway(),
|
||||||
pluginConfig: new WebPluginConfigGateway(),
|
pluginConfig: new WebPluginConfigGateway(),
|
||||||
|
pluginStorage: new WebPluginStorageGateway(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -15,7 +15,6 @@ import type {
|
|||||||
Agent,
|
Agent,
|
||||||
AgentDrift,
|
AgentDrift,
|
||||||
AgentProfile,
|
AgentProfile,
|
||||||
EffectivePermissions,
|
|
||||||
EmbedderEngines,
|
EmbedderEngines,
|
||||||
EmbedderProfile,
|
EmbedderProfile,
|
||||||
FirstRunState,
|
FirstRunState,
|
||||||
@ -43,6 +42,7 @@ import type {
|
|||||||
ProjectSystemPermissions,
|
ProjectSystemPermissions,
|
||||||
ProfileAvailability,
|
ProfileAvailability,
|
||||||
ProfileModelCatalog,
|
ProfileModelCatalog,
|
||||||
|
ResolvedAgentPermissions,
|
||||||
ResolvedAgentSystemPermissions,
|
ResolvedAgentSystemPermissions,
|
||||||
SystemPermissionSet,
|
SystemPermissionSet,
|
||||||
Skill,
|
Skill,
|
||||||
@ -286,7 +286,14 @@ export class HttpSkillGateway implements SkillGateway {
|
|||||||
}
|
}
|
||||||
createSkill(input: CreateSkillInput): Promise<Skill> {
|
createSkill(input: CreateSkillInput): Promise<Skill> {
|
||||||
return this.http.invoke<Skill>("create_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> {
|
updateSkill(projectId: string, scope: SkillScope, skillId: string, content: string): Promise<Skill> {
|
||||||
@ -381,8 +388,8 @@ export class HttpPermissionGateway implements PermissionGateway {
|
|||||||
request: { projectId, agentId, permissions },
|
request: { projectId, agentId, permissions },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
resolveAgentPermissions(projectId: string, agentId: string): Promise<EffectivePermissions | null> {
|
resolveAgentPermissions(projectId: string, agentId: string): Promise<ResolvedAgentPermissions> {
|
||||||
return this.http.invoke<EffectivePermissions | null>("resolve_agent_permissions", {
|
return this.http.invoke<ResolvedAgentPermissions>("resolve_agent_permissions", {
|
||||||
request: { projectId, agentId },
|
request: { projectId, agentId },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import type {
|
|||||||
Agent,
|
Agent,
|
||||||
AppExitWorkGuardState,
|
AppExitWorkGuardState,
|
||||||
DomainEvent,
|
DomainEvent,
|
||||||
|
EffortSelection,
|
||||||
HealthReport,
|
HealthReport,
|
||||||
ReplyChunk,
|
ReplyChunk,
|
||||||
ResumableAgent,
|
ResumableAgent,
|
||||||
@ -223,6 +224,15 @@ export class HttpAgentGateway implements AgentGateway {
|
|||||||
request: { projectId, agentId, profileId, rows, cols },
|
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> {
|
readContext(projectId: string, agentId: string): Promise<string> {
|
||||||
return this.http.invoke<string>("read_agent_context", { projectId, agentId });
|
return this.http.invoke<string>("read_agent_context", { projectId, agentId });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
import type {
|
import type {
|
||||||
EmbeddedServerStatus,
|
EmbeddedServerStatus,
|
||||||
GatewayError,
|
GatewayError,
|
||||||
|
JsonValue,
|
||||||
PluginAdmin,
|
PluginAdmin,
|
||||||
PluginCommandTask,
|
PluginCommandTask,
|
||||||
PluginConfigDocument,
|
PluginConfigDocument,
|
||||||
@ -44,6 +45,9 @@ import type {
|
|||||||
PluginEventSubscribeInput,
|
PluginEventSubscribeInput,
|
||||||
PluginEventUnsubscribeInput,
|
PluginEventUnsubscribeInput,
|
||||||
PluginGateway,
|
PluginGateway,
|
||||||
|
PluginStorageGateway,
|
||||||
|
PluginStorageGetInput,
|
||||||
|
PluginStorageSetInput,
|
||||||
PluginProjectStructureQuery,
|
PluginProjectStructureQuery,
|
||||||
PluginRunCommandInput,
|
PluginRunCommandInput,
|
||||||
PluginTaskGateway,
|
PluginTaskGateway,
|
||||||
@ -263,3 +267,18 @@ export class WebPluginConfigGateway implements PluginConfigGateway {
|
|||||||
return unsupportedOnWeb("Plugin structured config documents");
|
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 { TauriPluginToolchainGateway } from "./pluginToolchain";
|
||||||
import { TauriPluginEventGateway } from "./pluginEvents";
|
import { TauriPluginEventGateway } from "./pluginEvents";
|
||||||
import { TauriPluginConfigGateway } from "./pluginConfig";
|
import { TauriPluginConfigGateway } from "./pluginConfig";
|
||||||
|
import { TauriPluginStorageGateway } from "./pluginStorage";
|
||||||
|
|
||||||
function notImplemented(what: string): never {
|
function notImplemented(what: string): never {
|
||||||
const err: GatewayError = {
|
const err: GatewayError = {
|
||||||
@ -87,6 +88,7 @@ export function createTauriGateways(): Gateways {
|
|||||||
pluginToolchain: new TauriPluginToolchainGateway(),
|
pluginToolchain: new TauriPluginToolchainGateway(),
|
||||||
pluginEvents: new TauriPluginEventGateway(),
|
pluginEvents: new TauriPluginEventGateway(),
|
||||||
pluginConfig: new TauriPluginConfigGateway(),
|
pluginConfig: new TauriPluginConfigGateway(),
|
||||||
|
pluginStorage: new TauriPluginStorageGateway(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -114,4 +116,5 @@ export {
|
|||||||
TauriFocusedProjectGateway,
|
TauriFocusedProjectGateway,
|
||||||
LocalStorageUiPreferencesGateway,
|
LocalStorageUiPreferencesGateway,
|
||||||
TauriPluginGateway,
|
TauriPluginGateway,
|
||||||
|
TauriPluginStorageGateway,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import type {
|
|||||||
AgentProfile,
|
AgentProfile,
|
||||||
DiagnosticWarning,
|
DiagnosticWarning,
|
||||||
DomainEvent,
|
DomainEvent,
|
||||||
|
EffortSelection,
|
||||||
EmbedderEngines,
|
EmbedderEngines,
|
||||||
EmbedderProfile,
|
EmbedderProfile,
|
||||||
EmbeddedServerStatus,
|
EmbeddedServerStatus,
|
||||||
@ -37,7 +38,6 @@ import type {
|
|||||||
OpenCodeProviderCatalogEntry,
|
OpenCodeProviderCatalogEntry,
|
||||||
ProfileModelCatalog,
|
ProfileModelCatalog,
|
||||||
ProfileModelCatalogEntry,
|
ProfileModelCatalogEntry,
|
||||||
EffectivePermissions,
|
|
||||||
PairedDevice,
|
PairedDevice,
|
||||||
PairingCode,
|
PairingCode,
|
||||||
PermissionSet,
|
PermissionSet,
|
||||||
@ -72,6 +72,7 @@ import type {
|
|||||||
ProjectSystemPermissions,
|
ProjectSystemPermissions,
|
||||||
ProfileAvailability,
|
ProfileAvailability,
|
||||||
ResumableAgent,
|
ResumableAgent,
|
||||||
|
ResolvedAgentPermissions,
|
||||||
ResolvedAgentSystemPermissions,
|
ResolvedAgentSystemPermissions,
|
||||||
ServerExposurePreview,
|
ServerExposurePreview,
|
||||||
ServerExposureSettings,
|
ServerExposureSettings,
|
||||||
@ -129,6 +130,9 @@ import type {
|
|||||||
PluginEventSubscribeInput,
|
PluginEventSubscribeInput,
|
||||||
PluginEventUnsubscribeInput,
|
PluginEventUnsubscribeInput,
|
||||||
PluginGateway,
|
PluginGateway,
|
||||||
|
PluginStorageGateway,
|
||||||
|
PluginStorageGetInput,
|
||||||
|
PluginStorageSetInput,
|
||||||
PluginProjectStructureQuery,
|
PluginProjectStructureQuery,
|
||||||
PluginRunCommandInput,
|
PluginRunCommandInput,
|
||||||
PluginTaskGateway,
|
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) ──
|
// ── Internal helpers for MockTemplateGateway (same-package use only) ──
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -2006,6 +2032,8 @@ export class MockSkillGateway implements SkillGateway {
|
|||||||
const skill: Skill = {
|
const skill: Skill = {
|
||||||
id: `mock-skill-${this.seq}`,
|
id: `mock-skill-${this.seq}`,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
|
description: input.description ?? null,
|
||||||
|
kind: input.kind ?? "workflow",
|
||||||
contentMd: input.content,
|
contentMd: input.content,
|
||||||
scope: input.scope,
|
scope: input.scope,
|
||||||
};
|
};
|
||||||
@ -2431,14 +2459,18 @@ export class MockPermissionGateway implements PermissionGateway {
|
|||||||
async resolveAgentPermissions(
|
async resolveAgentPermissions(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
): Promise<EffectivePermissions | null> {
|
): Promise<ResolvedAgentPermissions> {
|
||||||
const doc = this.doc(projectId);
|
const doc = this.doc(projectId);
|
||||||
const project = doc.projectDefaults;
|
const project = doc.projectDefaults;
|
||||||
const agent = doc.agents?.find((entry) => entry.agentId === agentId)?.permissions;
|
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 {
|
return {
|
||||||
rules: [...(project?.rules ?? []), ...(agent?.rules ?? [])],
|
effective: {
|
||||||
fallback: mostRestrictive(project?.fallback, agent?.fallback),
|
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;
|
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
|
* 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
|
* 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. */
|
/** Builds the full set of mock gateways. */
|
||||||
export function createMockGateways(): Gateways {
|
export function createMockGateways(): Gateways {
|
||||||
const agentGateway = new MockAgentGateway();
|
const agentGateway = new MockAgentGateway();
|
||||||
@ -4050,6 +4155,7 @@ export function createMockGateways(): Gateways {
|
|||||||
pluginToolchain: new MockPluginToolchainGateway(),
|
pluginToolchain: new MockPluginToolchainGateway(),
|
||||||
pluginEvents: new MockPluginEventGateway(),
|
pluginEvents: new MockPluginEventGateway(),
|
||||||
pluginConfig: new MockPluginConfigGateway(),
|
pluginConfig: new MockPluginConfigGateway(),
|
||||||
|
pluginStorage: new MockPluginStorageGateway(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
EffectivePermissions,
|
|
||||||
McpToolPolicy,
|
McpToolPolicy,
|
||||||
PermissionSet,
|
PermissionSet,
|
||||||
ProjectMcpToolPermissions,
|
ProjectMcpToolPermissions,
|
||||||
ProjectPermissions,
|
ProjectPermissions,
|
||||||
ProjectSystemPermissions,
|
ProjectSystemPermissions,
|
||||||
|
ResolvedAgentPermissions,
|
||||||
ResolvedAgentSystemPermissions,
|
ResolvedAgentSystemPermissions,
|
||||||
SystemPermissionSet,
|
SystemPermissionSet,
|
||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
@ -40,8 +40,8 @@ export class TauriPermissionGateway implements PermissionGateway {
|
|||||||
resolveAgentPermissions(
|
resolveAgentPermissions(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
): Promise<EffectivePermissions | null> {
|
): Promise<ResolvedAgentPermissions> {
|
||||||
return invoke<EffectivePermissions | null>("resolve_agent_permissions", {
|
return invoke<ResolvedAgentPermissions>("resolve_agent_permissions", {
|
||||||
request: { projectId, agentId },
|
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: {
|
request: {
|
||||||
projectId: input.projectId,
|
projectId: input.projectId,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
|
description: input.description,
|
||||||
|
kind: input.kind,
|
||||||
content: input.content,
|
content: input.content,
|
||||||
scope: input.scope,
|
scope: input.scope,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -767,6 +767,21 @@ export interface EffectivePermissions {
|
|||||||
fallback: PermissionPosture;
|
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. */
|
/** Wanted/effective network policy for system permissions. */
|
||||||
export type NetworkPolicy = "allow" | "deny" | "ask";
|
export type NetworkPolicy = "allow" | "deny" | "ask";
|
||||||
|
|
||||||
@ -1151,6 +1166,21 @@ export interface ProfileModelCatalog {
|
|||||||
warnings: string[];
|
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
|
* A declarative AI-CLI profile (mirror of the backend `AgentProfile`). `id` is a
|
||||||
* UUID string; `detect` is the optional detection command line.
|
* 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.
|
* CLI's own default. OpenCode keeps its dedicated provider/local model fields.
|
||||||
*/
|
*/
|
||||||
model?: string;
|
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). */
|
/** Availability of a candidate profile after detection (mirror of the DTO). */
|
||||||
@ -1233,6 +1273,8 @@ export interface Agent {
|
|||||||
synchronized: boolean;
|
synchronized: boolean;
|
||||||
/** Skills assigned to this agent (injected into its convention file). */
|
/** Skills assigned to this agent (injected into its convention file). */
|
||||||
skills: SkillRef[];
|
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/`.
|
* across projects; `project` skills are specific to one project's `.ideai/`.
|
||||||
*/
|
*/
|
||||||
export type SkillScope = "global" | "project";
|
export type SkillScope = "global" | "project";
|
||||||
|
export type SkillKind = "workflow" | "reference";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A reusable, model-agnostic workflow assignable to agents (mirror of the
|
* A reusable, model-agnostic workflow assignable to agents (mirror of the
|
||||||
@ -1292,6 +1335,8 @@ export type SkillScope = "global" | "project";
|
|||||||
export interface Skill {
|
export interface Skill {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
kind: SkillKind;
|
||||||
contentMd: string;
|
contentMd: string;
|
||||||
scope: SkillScope;
|
scope: SkillScope;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,7 +25,13 @@ import { useAgents } from "./useAgents";
|
|||||||
import { correlateModelServerStatus } from "./modelServerLaunch";
|
import { correlateModelServerStatus } from "./modelServerLaunch";
|
||||||
import { AgentLimitBadge } from "./AgentLimitBadge";
|
import { AgentLimitBadge } from "./AgentLimitBadge";
|
||||||
import { ModelServerLaunchBadge } from "./ModelServerLaunchBadge";
|
import { ModelServerLaunchBadge } from "./ModelServerLaunchBadge";
|
||||||
import type { ResolvedAgentSystemPermissions } from "@/domain";
|
import type {
|
||||||
|
Agent,
|
||||||
|
AgentProfile,
|
||||||
|
EffortOption,
|
||||||
|
EffortSelection,
|
||||||
|
ResolvedAgentSystemPermissions,
|
||||||
|
} from "@/domain";
|
||||||
|
|
||||||
export interface AgentsPanelProps {
|
export interface AgentsPanelProps {
|
||||||
/** The project whose agents to manage. */
|
/** 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).
|
// Determine if a template is chosen → profile selector is hidden (template imposes it).
|
||||||
const hasTemplate = newTemplateId !== "";
|
const hasTemplate = newTemplateId !== "";
|
||||||
|
|
||||||
const profileLabel = (profile: import("@/domain").AgentProfile): string => {
|
const profileLabel = (profile: AgentProfile): string => {
|
||||||
const model =
|
const model =
|
||||||
profile.model ??
|
profile.model ??
|
||||||
profile.opencode?.model ??
|
profile.opencode?.model ??
|
||||||
@ -365,12 +371,9 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
|||||||
const isSelected = a.id === vm.selectedAgentId;
|
const isSelected = a.id === vm.selectedAgentId;
|
||||||
const isRunning = a.id === activeAgentId;
|
const isRunning = a.id === activeAgentId;
|
||||||
const live = vm.liveAgents.find((candidate) => candidate.agentId === a.id);
|
const live = vm.liveAgents.find((candidate) => candidate.agentId === a.id);
|
||||||
|
const agentProfile = vm.profiles.find((p) => p.id === a.profileId) ?? null;
|
||||||
const profileName =
|
const profileName =
|
||||||
(() => {
|
(agentProfile ? profileLabel(agentProfile) : null) ?? a.profileId;
|
||||||
const p = vm.profiles.find((p) => p.id === a.profileId);
|
|
||||||
return p ? profileLabel(p) : null;
|
|
||||||
})() ??
|
|
||||||
a.profileId;
|
|
||||||
const agentDrift = drift.driftByAgentId.get(a.id);
|
const agentDrift = drift.driftByAgentId.get(a.id);
|
||||||
// Source of this agent's last orchestration delegation (mcp vs
|
// Source of this agent's last orchestration delegation (mcp vs
|
||||||
// file), if any has been observed. Absent ⇒ no badge.
|
// 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 && (
|
{agentDrift && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
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({
|
function NetworkPermissionBadge({
|
||||||
state,
|
state,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@ -492,6 +492,11 @@ async function seededProfiles(): Promise<MockProfileGateway> {
|
|||||||
contextInjection: { strategy: "conventionFile", target: "CLAUDE.md" },
|
contextInjection: { strategy: "conventionFile", target: "CLAUDE.md" },
|
||||||
detect: null,
|
detect: null,
|
||||||
cwdTemplate: "{projectRoot}",
|
cwdTemplate: "{projectRoot}",
|
||||||
|
effortOptions: [
|
||||||
|
{ value: "low", label: "Léger" },
|
||||||
|
{ value: "medium", label: "Standard" },
|
||||||
|
{ value: "high", label: "Profond" },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "prof-2",
|
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", () => {
|
describe("AgentsPanel live refresh on domain events", () => {
|
||||||
it("refreshes the list when an `agentLaunched` event fires (out-of-band creation)", async () => {
|
it("refreshes the list when an `agentLaunched` event fires (out-of-band creation)", async () => {
|
||||||
const agent = new MockAgentGateway();
|
const agent = new MockAgentGateway();
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import type {
|
import type {
|
||||||
Agent,
|
Agent,
|
||||||
AgentProfile,
|
AgentProfile,
|
||||||
|
EffortSelection,
|
||||||
GatewayError,
|
GatewayError,
|
||||||
ModelServerStatus,
|
ModelServerStatus,
|
||||||
TerminalSession,
|
TerminalSession,
|
||||||
@ -124,6 +125,11 @@ export interface AgentsViewModel {
|
|||||||
rows: number,
|
rows: number,
|
||||||
cols: number,
|
cols: number,
|
||||||
) => Promise<TerminalSession | undefined>;
|
) => 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. */
|
/** Deletes an agent; deselects if it was selected. */
|
||||||
deleteAgent: (agentId: string) => Promise<void>;
|
deleteAgent: (agentId: string) => Promise<void>;
|
||||||
/**
|
/**
|
||||||
@ -427,6 +433,26 @@ export function useAgents(projectId: string): AgentsViewModel {
|
|||||||
[agent, projectId, refreshLiveAgents],
|
[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(
|
const deleteAgent = useCallback(
|
||||||
async (agentId: string) => {
|
async (agentId: string) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@ -544,6 +570,7 @@ export function useAgents(projectId: string): AgentsViewModel {
|
|||||||
selectAgent,
|
selectAgent,
|
||||||
saveContext,
|
saveContext,
|
||||||
changeAgentProfile,
|
changeAgentProfile,
|
||||||
|
updateAgentEffort,
|
||||||
deleteAgent,
|
deleteAgent,
|
||||||
launchAgent,
|
launchAgent,
|
||||||
stopAgent,
|
stopAgent,
|
||||||
|
|||||||
@ -8,8 +8,10 @@ import type {
|
|||||||
PermissionPosture,
|
PermissionPosture,
|
||||||
PermissionRule,
|
PermissionRule,
|
||||||
PermissionSet,
|
PermissionSet,
|
||||||
|
PermissionShadowReport,
|
||||||
ProjectPermissions,
|
ProjectPermissions,
|
||||||
ProjectSystemPermissions,
|
ProjectSystemPermissions,
|
||||||
|
ResolvedAgentPermissions,
|
||||||
ResolvedAgentSystemPermissions,
|
ResolvedAgentSystemPermissions,
|
||||||
SystemPermissionSet,
|
SystemPermissionSet,
|
||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
@ -27,6 +29,7 @@ export interface PolicyDraft {
|
|||||||
export interface AgentPermissionRow {
|
export interface AgentPermissionRow {
|
||||||
agent: Agent;
|
agent: Agent;
|
||||||
override: PermissionSet | null;
|
override: PermissionSet | null;
|
||||||
|
shadowed: PermissionShadowReport | null;
|
||||||
systemOverride: SystemPermissionSet | null;
|
systemOverride: SystemPermissionSet | null;
|
||||||
resolvedSystem: ResolvedAgentSystemPermissions | null;
|
resolvedSystem: ResolvedAgentSystemPermissions | null;
|
||||||
}
|
}
|
||||||
@ -132,6 +135,9 @@ export function usePermissions(projectId: string): PermissionsViewModel {
|
|||||||
const [resolvedSystemByAgent, setResolvedSystemByAgent] = useState<
|
const [resolvedSystemByAgent, setResolvedSystemByAgent] = useState<
|
||||||
Record<string, ResolvedAgentSystemPermissions>
|
Record<string, ResolvedAgentSystemPermissions>
|
||||||
>({});
|
>({});
|
||||||
|
const [resolvedPermissionsByAgent, setResolvedPermissionsByAgent] = useState<
|
||||||
|
Record<string, ResolvedAgentPermissions>
|
||||||
|
>({});
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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);
|
setAgents(agentList);
|
||||||
setDocument(permissionDoc);
|
setDocument(permissionDoc);
|
||||||
setSystemDocument(systemPermissionDoc);
|
setSystemDocument(systemPermissionDoc);
|
||||||
|
setResolvedPermissionsByAgent(
|
||||||
|
Object.fromEntries(
|
||||||
|
resolvedPermissionPairs.filter(
|
||||||
|
(pair): pair is readonly [string, ResolvedAgentPermissions] =>
|
||||||
|
pair[1] !== null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
setResolvedSystemByAgent(
|
setResolvedSystemByAgent(
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
resolvedPairs.filter(
|
resolvedPairs.filter(
|
||||||
@ -191,10 +217,17 @@ export function usePermissions(projectId: string): PermissionsViewModel {
|
|||||||
return agents.map((candidate) => ({
|
return agents.map((candidate) => ({
|
||||||
agent: candidate,
|
agent: candidate,
|
||||||
override: overrides.get(candidate.id) ?? null,
|
override: overrides.get(candidate.id) ?? null,
|
||||||
|
shadowed: resolvedPermissionsByAgent[candidate.id]?.shadowed ?? null,
|
||||||
systemOverride: systemOverrides.get(candidate.id) ?? null,
|
systemOverride: systemOverrides.get(candidate.id) ?? null,
|
||||||
resolvedSystem: resolvedSystemByAgent[candidate.id] ?? null,
|
resolvedSystem: resolvedSystemByAgent[candidate.id] ?? null,
|
||||||
}));
|
}));
|
||||||
}, [agents, document, systemDocument, resolvedSystemByAgent]);
|
}, [
|
||||||
|
agents,
|
||||||
|
document,
|
||||||
|
systemDocument,
|
||||||
|
resolvedPermissionsByAgent,
|
||||||
|
resolvedSystemByAgent,
|
||||||
|
]);
|
||||||
|
|
||||||
const projectDraft = useMemo(
|
const projectDraft = useMemo(
|
||||||
() => draftFromSet(document?.projectDefaults ?? null),
|
() => draftFromSet(document?.projectDefaults ?? null),
|
||||||
|
|||||||
@ -80,6 +80,7 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
|
|||||||
pluginToolchain: gateways.pluginToolchain,
|
pluginToolchain: gateways.pluginToolchain,
|
||||||
pluginEvents: gateways.pluginEvents,
|
pluginEvents: gateways.pluginEvents,
|
||||||
pluginConfig: gateways.pluginConfig,
|
pluginConfig: gateways.pluginConfig,
|
||||||
|
pluginStorage: gateways.pluginStorage,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
|
|||||||
@ -210,6 +210,7 @@ describe("loadPlugins", () => {
|
|||||||
"pluginToolchain",
|
"pluginToolchain",
|
||||||
"pluginEvents",
|
"pluginEvents",
|
||||||
"pluginConfig",
|
"pluginConfig",
|
||||||
|
"pluginStorage",
|
||||||
].some((key) => key in ctx);
|
].some((key) => key in ctx);
|
||||||
globalThis.__serviceKeys = Object.keys(ctx.services).sort();
|
globalThis.__serviceKeys = Object.keys(ctx.services).sort();
|
||||||
globalThis.__workspaceServiceKeys = Object.keys(ctx.services.workspace).sort();
|
globalThis.__workspaceServiceKeys = Object.keys(ctx.services.workspace).sort();
|
||||||
@ -241,6 +242,7 @@ describe("loadPlugins", () => {
|
|||||||
"pluginDisplayName",
|
"pluginDisplayName",
|
||||||
"pluginId",
|
"pluginId",
|
||||||
"services",
|
"services",
|
||||||
|
"storage",
|
||||||
"subscriptions",
|
"subscriptions",
|
||||||
"version",
|
"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 () => {
|
it("loads the hello-plugin command and layout contribution shape", async () => {
|
||||||
const bundle = dataUrl(`
|
const bundle = dataUrl(`
|
||||||
export function activate(ctx) {
|
export function activate(ctx) {
|
||||||
|
|||||||
@ -15,7 +15,7 @@
|
|||||||
* collected, never thrown past `loadPlugins`.
|
* collected, never thrown past `loadPlugins`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { PluginContributionDto, PluginRuntimePlugin } from "@/domain";
|
import type { JsonValue, PluginContributionDto, PluginRuntimePlugin } from "@/domain";
|
||||||
import {
|
import {
|
||||||
PluginCommandRegistry,
|
PluginCommandRegistry,
|
||||||
PluginLayoutRegistry,
|
PluginLayoutRegistry,
|
||||||
@ -38,9 +38,16 @@ export interface IdeaPluginContext {
|
|||||||
commands: PluginCommandContext;
|
commands: PluginCommandContext;
|
||||||
layouts: PluginLayoutRegistry;
|
layouts: PluginLayoutRegistry;
|
||||||
menu: PluginMenuRegistry;
|
menu: PluginMenuRegistry;
|
||||||
|
storage: PluginStorage;
|
||||||
services?: PluginServices;
|
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 {
|
export interface PluginActivation {
|
||||||
dispose?: () => void | Promise<void>;
|
dispose?: () => void | Promise<void>;
|
||||||
}
|
}
|
||||||
@ -241,6 +248,7 @@ async function loadOne(
|
|||||||
const commands = new PluginCommandRegistry(pluginId, declaredCommandIds);
|
const commands = new PluginCommandRegistry(pluginId, declaredCommandIds);
|
||||||
const layouts = new PluginLayoutRegistry(pluginId, declaredLayoutTypes);
|
const layouts = new PluginLayoutRegistry(pluginId, declaredLayoutTypes);
|
||||||
const menu = new PluginMenuRegistry(pluginId);
|
const menu = new PluginMenuRegistry(pluginId);
|
||||||
|
const storage = createPluginStorage(gateways, pluginId);
|
||||||
|
|
||||||
const ctx: IdeaPluginContext = {
|
const ctx: IdeaPluginContext = {
|
||||||
pluginId,
|
pluginId,
|
||||||
@ -251,6 +259,7 @@ async function loadOne(
|
|||||||
commands: createCommandContext(commands),
|
commands: createCommandContext(commands),
|
||||||
layouts,
|
layouts,
|
||||||
menu,
|
menu,
|
||||||
|
storage,
|
||||||
};
|
};
|
||||||
if (hasCapability(entry, "tooling")) {
|
if (hasCapability(entry, "tooling")) {
|
||||||
ctx.services = createPluginServices(gateways);
|
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}.
|
* Loads every plugin in the catalog into a fresh {@link PluginRuntimeRegistry}.
|
||||||
* Called once at app bootstrap (and never again in the same session — carnet
|
* Called once at app bootstrap (and never again in the same session — carnet
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import type {
|
|||||||
GitGateway,
|
GitGateway,
|
||||||
PluginConfigGateway,
|
PluginConfigGateway,
|
||||||
PluginEventGateway,
|
PluginEventGateway,
|
||||||
|
PluginStorageGateway,
|
||||||
PluginTaskGateway,
|
PluginTaskGateway,
|
||||||
PluginToolchainGateway,
|
PluginToolchainGateway,
|
||||||
PluginWorkspaceGateway,
|
PluginWorkspaceGateway,
|
||||||
@ -48,6 +49,7 @@ export interface PluginGatewaySet {
|
|||||||
pluginToolchain: PluginToolchainGateway;
|
pluginToolchain: PluginToolchainGateway;
|
||||||
pluginEvents: PluginEventGateway;
|
pluginEvents: PluginEventGateway;
|
||||||
pluginConfig: PluginConfigGateway;
|
pluginConfig: PluginConfigGateway;
|
||||||
|
pluginStorage: PluginStorageGateway;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A disposable handle returned by every `register*` call. */
|
/** A disposable handle returned by every `register*` call. */
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import type {
|
|||||||
AppExitWorkGuardState,
|
AppExitWorkGuardState,
|
||||||
CustomProviderConfig,
|
CustomProviderConfig,
|
||||||
DomainEvent,
|
DomainEvent,
|
||||||
|
EffortSelection,
|
||||||
EmbedderEngines,
|
EmbedderEngines,
|
||||||
EmbedderProfile,
|
EmbedderProfile,
|
||||||
EmbeddedServerStatus,
|
EmbeddedServerStatus,
|
||||||
@ -24,6 +25,7 @@ import type {
|
|||||||
GitFileStatus,
|
GitFileStatus,
|
||||||
GraphCommit,
|
GraphCommit,
|
||||||
HealthReport,
|
HealthReport,
|
||||||
|
JsonValue,
|
||||||
LayoutKind,
|
LayoutKind,
|
||||||
LayoutList,
|
LayoutList,
|
||||||
LayoutOperation,
|
LayoutOperation,
|
||||||
@ -38,7 +40,6 @@ import type {
|
|||||||
OpenCodeConfig,
|
OpenCodeConfig,
|
||||||
OpenCodeProviderCatalogEntry,
|
OpenCodeProviderCatalogEntry,
|
||||||
ProfileModelCatalog,
|
ProfileModelCatalog,
|
||||||
EffectivePermissions,
|
|
||||||
PairedDevice,
|
PairedDevice,
|
||||||
PairingCode,
|
PairingCode,
|
||||||
PermissionSet,
|
PermissionSet,
|
||||||
@ -71,11 +72,13 @@ import type {
|
|||||||
ProjectSystemPermissions,
|
ProjectSystemPermissions,
|
||||||
ProfileAvailability,
|
ProfileAvailability,
|
||||||
ResumableAgent,
|
ResumableAgent,
|
||||||
|
ResolvedAgentPermissions,
|
||||||
ResolvedAgentSystemPermissions,
|
ResolvedAgentSystemPermissions,
|
||||||
ReplyChunk,
|
ReplyChunk,
|
||||||
ServerExposurePreview,
|
ServerExposurePreview,
|
||||||
ServerExposureSettings,
|
ServerExposureSettings,
|
||||||
Skill,
|
Skill,
|
||||||
|
SkillKind,
|
||||||
SkillScope,
|
SkillScope,
|
||||||
SystemPermissionSet,
|
SystemPermissionSet,
|
||||||
Sprint,
|
Sprint,
|
||||||
@ -207,6 +210,15 @@ export interface AgentGateway {
|
|||||||
rows: number,
|
rows: number,
|
||||||
cols: number,
|
cols: number,
|
||||||
): Promise<{ agent: Agent; relaunchedSession?: TerminalSession }>;
|
): 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. */
|
/** Reads an agent's `.md` context by agent id. */
|
||||||
readContext(projectId: string, agentId: string): Promise<string>;
|
readContext(projectId: string, agentId: string): Promise<string>;
|
||||||
/** Overwrites an agent's `.md` context. */
|
/** Overwrites an agent's `.md` context. */
|
||||||
@ -538,6 +550,8 @@ export interface CreateSkillInput {
|
|||||||
/** Owning project (resolved to a root; ignored on disk for `global`). */
|
/** Owning project (resolved to a root; ignored on disk for `global`). */
|
||||||
projectId: string;
|
projectId: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
kind?: SkillKind;
|
||||||
content: string;
|
content: string;
|
||||||
scope: SkillScope;
|
scope: SkillScope;
|
||||||
}
|
}
|
||||||
@ -925,7 +939,7 @@ export interface PermissionGateway {
|
|||||||
resolveAgentPermissions(
|
resolveAgentPermissions(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
): Promise<EffectivePermissions | null>;
|
): Promise<ResolvedAgentPermissions>;
|
||||||
/** Reads the full project system-permission document. */
|
/** Reads the full project system-permission document. */
|
||||||
getProjectSystemPermissions(projectId: string): Promise<ProjectSystemPermissions>;
|
getProjectSystemPermissions(projectId: string): Promise<ProjectSystemPermissions>;
|
||||||
/** Replaces or removes project-level default system permissions. */
|
/** Replaces or removes project-level default system permissions. */
|
||||||
@ -1496,6 +1510,21 @@ export interface PluginConfigGateway {
|
|||||||
updateDocument(input: PluginConfigDocumentUpdateInput): Promise<PluginConfigDocumentWriteResult>;
|
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 {
|
export interface Gateways {
|
||||||
system: SystemGateway;
|
system: SystemGateway;
|
||||||
agent: AgentGateway;
|
agent: AgentGateway;
|
||||||
@ -1526,4 +1555,5 @@ export interface Gateways {
|
|||||||
pluginToolchain: PluginToolchainGateway;
|
pluginToolchain: PluginToolchainGateway;
|
||||||
pluginEvents: PluginEventGateway;
|
pluginEvents: PluginEventGateway;
|
||||||
pluginConfig: PluginConfigGateway;
|
pluginConfig: PluginConfigGateway;
|
||||||
|
pluginStorage: PluginStorageGateway;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user