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:
@ -12,6 +12,7 @@ thiserror = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
subtle = { workspace = true }
|
||||
# Resolves the OpenCode cache dir (`~/.cache/opencode/models.json`) for the
|
||||
# dynamic provider catalogue (ticket #92 follow-up). See
|
||||
|
||||
@ -21,8 +21,8 @@ use domain::ports::{
|
||||
SpawnSpec, StructuredProviderLaunchPolicy, SystemPermissionStore,
|
||||
};
|
||||
use domain::profile::{
|
||||
McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter, CODEX_CODE_MODE_FEATURES_TABLE,
|
||||
CODEX_CODE_MODE_FEATURES_TOML,
|
||||
resolve_effort, EffortSelection, McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter,
|
||||
CODEX_CODE_MODE_FEATURES_TABLE, CODEX_CODE_MODE_FEATURES_TOML,
|
||||
};
|
||||
use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan};
|
||||
use domain::{
|
||||
@ -123,8 +123,9 @@ pub struct InjectedLiveRow {
|
||||
pub struct ResolvedAssignedSkill {
|
||||
/// Agent-facing snapshot metadata.
|
||||
pub snapshot: domain::AssignedSkillSnapshot,
|
||||
/// Full Markdown body, used only on non-MCP profiles that cannot call
|
||||
/// `idea_skill_read`.
|
||||
/// Full Markdown body kept in the effective snapshot so authorized lazy-read
|
||||
/// paths can use the same resolution; convention-file rendering exposes only
|
||||
/// bounded affordances.
|
||||
pub content: MarkdownDoc,
|
||||
}
|
||||
|
||||
@ -140,7 +141,9 @@ pub struct EffectiveAgentContext {
|
||||
pub capabilities: OrchestrationCapabilitySnapshot,
|
||||
/// Compact agent capability affordances resolved by [`ResolveAgentCapabilities`].
|
||||
pub agent_capabilities: Vec<AgentCapability>,
|
||||
/// Resolved assigned skill bodies for fallback non-MCP injection.
|
||||
/// Resolved assigned skill bodies. These are not dumped into provider context
|
||||
/// by default; agents receive compact affordances and load details through the
|
||||
/// active IdeA surface.
|
||||
pub assigned_skills: Vec<ResolvedAssignedSkill>,
|
||||
/// Project-memory recall selected for this launch.
|
||||
pub memory: Vec<MemoryIndexEntry>,
|
||||
@ -281,6 +284,8 @@ pub struct ListAgentsOutput {
|
||||
pub agents: Vec<Agent>,
|
||||
/// Resolved discoverable capabilities per agent.
|
||||
pub capabilities: Vec<ListedAgentCapabilities>,
|
||||
/// The manifest's resolved orchestrator.
|
||||
pub effective_orchestrator: Option<AgentId>,
|
||||
}
|
||||
|
||||
/// Resolved capabilities for one listed agent.
|
||||
@ -301,6 +306,8 @@ pub struct AgentDiscoveryEntry {
|
||||
pub agent: Agent,
|
||||
/// Resolved capability affordances.
|
||||
pub capabilities: Vec<AgentCapability>,
|
||||
/// Whether this agent is the project's current orchestrator.
|
||||
pub is_orchestrator: bool,
|
||||
}
|
||||
|
||||
impl ListAgentsOutput {
|
||||
@ -318,6 +325,7 @@ impl ListAgentsOutput {
|
||||
.map(|entry| entry.capabilities.clone())
|
||||
.unwrap_or_default();
|
||||
AgentDiscoveryEntry {
|
||||
is_orchestrator: self.effective_orchestrator == Some(agent.id),
|
||||
agent,
|
||||
capabilities,
|
||||
}
|
||||
@ -390,6 +398,7 @@ impl ListAgents {
|
||||
Ok(ListAgentsOutput {
|
||||
agents,
|
||||
capabilities,
|
||||
effective_orchestrator: manifest.effective_orchestrator(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -480,6 +489,74 @@ impl UpdateAgentContext {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UpdateAgentEffort
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`UpdateAgentEffort::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateAgentEffortInput {
|
||||
/// The owning project.
|
||||
pub project: Project,
|
||||
/// The agent whose effort override changes.
|
||||
pub agent_id: AgentId,
|
||||
/// `None` clears the override, falling back to the profile default.
|
||||
pub effort: Option<EffortSelection>,
|
||||
}
|
||||
|
||||
/// Output of [`UpdateAgentEffort::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateAgentEffortOutput {
|
||||
/// The updated agent.
|
||||
pub agent: Agent,
|
||||
}
|
||||
|
||||
/// Sets or clears an agent's per-agent effort override in the manifest.
|
||||
///
|
||||
/// The change applies at the agent's next launch; live sessions are not mutated.
|
||||
pub struct UpdateAgentEffort {
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
}
|
||||
|
||||
impl UpdateAgentEffort {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(contexts: Arc<dyn AgentContextStore>) -> Self {
|
||||
Self { contexts }
|
||||
}
|
||||
|
||||
/// Executes the update.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if the agent is unknown to the project,
|
||||
/// - [`AppError::Invalid`] if the resulting manifest is invalid,
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: UpdateAgentEffortInput,
|
||||
) -> Result<UpdateAgentEffortOutput, AppError> {
|
||||
let mut manifest = self.contexts.load_manifest(&input.project).await?;
|
||||
let entry = manifest
|
||||
.entries
|
||||
.iter_mut()
|
||||
.find(|entry| entry.agent_id == input.agent_id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
|
||||
|
||||
let agent = entry
|
||||
.to_agent()
|
||||
.map_err(|err| AppError::Invalid(err.to_string()))?
|
||||
.with_effort(input.effort);
|
||||
*entry = ManifestEntry::from_agent(&agent);
|
||||
|
||||
let manifest = AgentManifest::new(manifest.version, manifest.entries)
|
||||
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
||||
self.contexts
|
||||
.save_manifest(&input.project, &manifest)
|
||||
.await?;
|
||||
Ok(UpdateAgentEffortOutput { agent })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ChangeAgentProfile
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -2017,6 +2094,12 @@ impl LaunchAgent {
|
||||
network_permission,
|
||||
&input.project.root,
|
||||
);
|
||||
let resolved_effort = resolve_effort(
|
||||
profile.model_reasoning_effort.as_deref(),
|
||||
agent.effort.as_ref(),
|
||||
);
|
||||
let mut launch_profile = profile.clone();
|
||||
launch_profile.model_reasoning_effort = resolved_effort;
|
||||
|
||||
// 5b. ── POINT DE ROUTAGE §17.4 : IA structuré vs terminal brut ──
|
||||
// L'intention est explicite sur le launcher : les cellules humaines peuvent
|
||||
@ -2053,7 +2136,7 @@ impl LaunchAgent {
|
||||
factory.as_ref(),
|
||||
structured,
|
||||
&agent,
|
||||
&profile,
|
||||
&launch_profile,
|
||||
&prepared,
|
||||
&run_dir,
|
||||
&session_plan,
|
||||
@ -2083,7 +2166,7 @@ impl LaunchAgent {
|
||||
// CODEX_HOME isolé. Passer le modèle du profil sur l'argv garantit que le
|
||||
// lancement interactif respecte l'édition IdeA, comme le chemin structuré
|
||||
// le fait déjà dans `CodexExecSession`.
|
||||
append_codex_pty_model_overrides(&profile, &mut spec);
|
||||
append_codex_pty_model_overrides(&launch_profile, &mut spec);
|
||||
|
||||
// 6. Spawn the PTY at the resolved cwd; adopt its session id everywhere.
|
||||
let handle = self.pty.spawn(spec.clone(), size).await?;
|
||||
@ -3440,8 +3523,7 @@ fn append_block(input: &str, block: &str) -> String {
|
||||
/// Composes the convention file IdeA writes into an agent's run directory: an
|
||||
/// absolute project-root header (the agent's cwd is the run dir, *not* the root,
|
||||
/// so it must be told where to work), the IdeA orchestration contract, the
|
||||
/// agent's persona `.md`, then the bodies of its assigned `skills` under a
|
||||
/// `# Skills` section (ARCHITECTURE §14.2).
|
||||
/// agent's persona `.md` (ARCHITECTURE §14.2).
|
||||
///
|
||||
/// A short skill-awareness paragraph is always injected in the orchestration
|
||||
/// block (followed by the auto-memory harvest directive, Lot E1): it explains that
|
||||
@ -3451,18 +3533,12 @@ fn append_block(input: &str, block: &str) -> String {
|
||||
/// protocol). This awareness deliberately does not inject unassigned skill bodies;
|
||||
/// assignment remains the context boundary.
|
||||
///
|
||||
/// On top of that awareness, the assigned skills surface in one of two ways
|
||||
/// depending on the agent's **surface** (feature « skills à la MCP »), always in
|
||||
/// the given (manifest) order — making the output deterministic:
|
||||
/// - **MCP mode** (`mcp_enabled`): a high-altitude `# Skills disponibles` section,
|
||||
/// right after the orchestration block, listing each as
|
||||
/// `**<name>** — <effective description> (<kind>)` (affordances only, *no body*), with
|
||||
/// prose pointing to `idea_skill_read` to load a body on demand. Respects the
|
||||
/// altitude: the capability is exposed, never the skill content.
|
||||
/// - **Non-MCP mode**: the legacy `# Skills` section dumping each body in full
|
||||
/// under a `##` header carrying its name (unchanged — zero regression).
|
||||
/// When `skills` is empty both sections are omitted entirely, so an agent with no
|
||||
/// skills gets exactly the previous document.
|
||||
/// On top of that awareness, assigned skills surface as a high-altitude
|
||||
/// `# Skills disponibles` section right after the orchestration block, listing
|
||||
/// each as `**<name>** — <effective description> (<kind>)` (affordances only,
|
||||
/// *no body*). This bounded surface is shared by MCP and non-MCP profiles; only
|
||||
/// the instruction for loading details is adapted to the active runtime surface.
|
||||
/// When `skills` is empty the section is omitted entirely.
|
||||
///
|
||||
/// The project's `memory` recall (index/hooks, ARCHITECTURE §14.5.4) is appended as
|
||||
/// a `# Mémoire projet` section — one `- [Title](slug.md) — hook (type)` line per
|
||||
@ -3576,22 +3652,28 @@ pub(crate) fn compose_convention_file(
|
||||
out.push_str(memory_awareness());
|
||||
out.push_str("---\n\n");
|
||||
|
||||
// Skills « à la MCP » (feature skill-awareness) : à HAUTE ALTITUDE, juste après
|
||||
// le bloc d'orchestration. On expose les skills assignés comme des **affordances
|
||||
// nommées+décrites** (et NON leur corps complet), à la manière des outils MCP,
|
||||
// pour que l'agent sache qu'ils existent et charge le détail à la demande via
|
||||
// `idea_skill_read`. Réservé au mode MCP (le mode sans MCP conserve l'ancien dump
|
||||
// du corps complet en fin de fichier, plus bas). Omis si zéro skill.
|
||||
if mcp_enabled && !effective.agent_capabilities.is_empty() {
|
||||
// Skills « capability-first » : à HAUTE ALTITUDE, juste après le bloc
|
||||
// d'orchestration. On expose les skills assignés comme des affordances
|
||||
// nommées+décrites (et NON leur corps complet), à la manière des outils MCP.
|
||||
if !effective.agent_capabilities.is_empty() {
|
||||
out.push_str("# Skills disponibles\n\n");
|
||||
out.push_str("Snapshot version: ");
|
||||
out.push_str(&effective.capabilities.version.to_string());
|
||||
out.push_str("\n\n");
|
||||
out.push_str(
|
||||
"Les skills suivants te sont assignés. Pour en exécuter un ou consulter son \
|
||||
détail, appelle l'outil `idea_skill_read(name=…)` — n'improvise pas un \
|
||||
workflow déjà couvert par un skill, charge-le.\n\n",
|
||||
);
|
||||
if mcp_enabled {
|
||||
out.push_str(
|
||||
"Les skills suivants te sont assignés. Pour en exécuter un ou consulter son \
|
||||
détail, appelle l'outil `idea_skill_read(name=…)` — n'improvise pas un \
|
||||
workflow déjà couvert par un skill, charge-le.\n\n",
|
||||
);
|
||||
} else {
|
||||
out.push_str(
|
||||
"Les skills suivants te sont assignés. Pour en exécuter un ou consulter son \
|
||||
détail, lis le fichier `.ideai/skills/md/<skill-id>.md` correspondant au \
|
||||
`skillId` assigné dans le manifeste — n'improvise pas un workflow déjà \
|
||||
couvert par un skill, charge-le.\n\n",
|
||||
);
|
||||
}
|
||||
for skill in &effective.agent_capabilities {
|
||||
out.push_str("**");
|
||||
out.push_str(&skill.name);
|
||||
@ -3616,24 +3698,6 @@ pub(crate) fn compose_convention_file(
|
||||
|
||||
out.push_str(effective.persona.as_str());
|
||||
|
||||
// MODE SANS MCP (exigence zéro régression, décision produit 4.2(b)) : on conserve
|
||||
// l'ancien dump du **corps complet** des skills en fin de fichier. En mode MCP, le
|
||||
// corps n'est PAS injecté ici (l'agent le charge à la demande via `idea_skill_read`,
|
||||
// cf. la section « # Skills disponibles » à haute altitude plus haut).
|
||||
if !effective.assigned_skills.is_empty() && !mcp_enabled {
|
||||
out.push_str("\n\n---\n\n# Skills\n");
|
||||
out.push_str("\nSnapshot version: ");
|
||||
out.push_str(&effective.capabilities.version.to_string());
|
||||
out.push('\n');
|
||||
for skill in &effective.assigned_skills {
|
||||
out.push_str("\n## ");
|
||||
out.push_str(&skill.snapshot.name);
|
||||
out.push_str("\n\n");
|
||||
out.push_str(skill.content.as_str());
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
if !effective.memory.is_empty() {
|
||||
out.push_str("\n\n---\n\n# Mémoire projet\n\n");
|
||||
for entry in &effective.memory {
|
||||
@ -3891,7 +3955,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_convention_file_appends_assigned_skills_in_order() {
|
||||
fn compose_convention_file_appends_assigned_skill_affordances_in_order() {
|
||||
let s = |n: u128, name: &str, body: &str| {
|
||||
Skill::new(
|
||||
domain::SkillId::from_uuid(uuid::Uuid::from_u128(n)),
|
||||
@ -3915,28 +3979,28 @@ mod tests {
|
||||
false,
|
||||
);
|
||||
|
||||
// Both skill bodies present, after the persona.
|
||||
assert!(doc.contains("REFAC_BODY"));
|
||||
assert!(doc.contains("REVIEW_BODY"));
|
||||
// Both skill affordances are present, but bodies are not dumped.
|
||||
assert!(doc.contains("**refactor** — REFAC_BODY (workflow)"));
|
||||
assert!(doc.contains("**review** — REVIEW_BODY (workflow)"));
|
||||
assert!(!doc.contains("\n\nREFAC_BODY"));
|
||||
assert!(!doc.contains("\n\nREVIEW_BODY"));
|
||||
let awareness_at = doc.find("**Skills IdeA**").unwrap();
|
||||
let skills_at = doc.find("# Skills disponibles").unwrap();
|
||||
let persona_at = doc.find("# Persona").unwrap();
|
||||
let skills_at = doc.find("\n# Skills\n").unwrap();
|
||||
let refac_at = doc.find("REFAC_BODY").unwrap();
|
||||
let review_at = doc.find("REVIEW_BODY").unwrap();
|
||||
let refac_at = doc.find("**refactor**").unwrap();
|
||||
let review_at = doc.find("**review**").unwrap();
|
||||
assert!(
|
||||
awareness_at < persona_at,
|
||||
"skill awareness belongs to orchestration, before persona"
|
||||
);
|
||||
assert!(
|
||||
persona_at < skills_at && skills_at < refac_at,
|
||||
"assigned skill bodies come under the Skills section after persona"
|
||||
awareness_at < skills_at && skills_at < persona_at,
|
||||
"assigned skill affordances come before persona"
|
||||
);
|
||||
assert!(persona_at < refac_at, "skills come after the persona");
|
||||
// Deterministic order: first assigned skill precedes the second.
|
||||
assert!(refac_at < review_at, "skills emitted in the given order");
|
||||
// Skill names surface as sub-headers.
|
||||
assert!(doc.contains("## refactor"));
|
||||
assert!(doc.contains("## review"));
|
||||
assert!(!doc.contains("## refactor"));
|
||||
assert!(!doc.contains("## review"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -4048,10 +4112,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_convention_file_mcp_mode_exposes_skill_affordances_not_bodies() {
|
||||
// MCP mode (feature « skills à la MCP »): a high-altitude `# Skills disponibles`
|
||||
fn compose_convention_file_exposes_skill_affordances_not_bodies() {
|
||||
// Both surfaces: a high-altitude `# Skills disponibles`
|
||||
// section after the Orchestration block, listing `**name** — description`
|
||||
// affordances and pointing to `idea_skill_read`, WITHOUT dumping the bodies.
|
||||
// affordances, WITHOUT dumping the bodies.
|
||||
let s = |n: u128, name: &str, desc: Option<&str>, body: &str| {
|
||||
Skill::new(
|
||||
domain::SkillId::from_uuid(uuid::Uuid::from_u128(n)),
|
||||
@ -4062,44 +4126,47 @@ mod tests {
|
||||
.unwrap()
|
||||
.with_description(desc.map(str::to_owned))
|
||||
};
|
||||
let doc = compose_convention_file(
|
||||
"/root",
|
||||
"",
|
||||
"# Persona",
|
||||
&[
|
||||
s(1, "refactor", Some("Refactors code"), "REFAC_BODY"),
|
||||
// No explicit description ⇒ effective_description falls back to the
|
||||
// body's first line (heading marker stripped).
|
||||
s(2, "review", None, "# Review skill\n\nREVIEW_BODY"),
|
||||
],
|
||||
&[],
|
||||
None,
|
||||
&[],
|
||||
true, // mcp_enabled
|
||||
);
|
||||
for mcp_enabled in [true, false] {
|
||||
let doc = compose_convention_file(
|
||||
"/root",
|
||||
"",
|
||||
"# Persona",
|
||||
&[
|
||||
s(1, "refactor", Some("Refactors code"), "REFAC_BODY"),
|
||||
// No explicit description ⇒ effective_description falls back to the
|
||||
// body's first line (heading marker stripped).
|
||||
s(2, "review", None, "# Review skill\n\nREVIEW_BODY"),
|
||||
],
|
||||
&[],
|
||||
None,
|
||||
&[],
|
||||
mcp_enabled,
|
||||
);
|
||||
|
||||
// The affordance section is present.
|
||||
assert!(
|
||||
doc.contains("# Skills disponibles"),
|
||||
"MCP skills section present"
|
||||
);
|
||||
assert!(doc.contains("idea_skill_read"), "points to the read tool");
|
||||
// Affordance lines: `**name** — <effective description> (<kind>)`.
|
||||
assert!(doc.contains("**refactor** — Refactors code (workflow)"));
|
||||
assert!(doc.contains("**review** — Review skill (workflow)"));
|
||||
// The full bodies are NOT injected in MCP mode (loaded on demand instead).
|
||||
assert!(!doc.contains("REFAC_BODY"), "no full body in MCP mode");
|
||||
assert!(!doc.contains("REVIEW_BODY"), "no full body in MCP mode");
|
||||
// The legacy `## <name>` body dump headers are absent too.
|
||||
assert!(!doc.contains("## refactor"));
|
||||
assert!(
|
||||
doc.contains("# Skills disponibles"),
|
||||
"skills section present (mcp_enabled={mcp_enabled})"
|
||||
);
|
||||
if mcp_enabled {
|
||||
assert!(doc.contains("idea_skill_read"), "points to the read tool");
|
||||
} else {
|
||||
assert!(
|
||||
doc.contains(".ideai/skills/md/<skill-id>.md"),
|
||||
"points to the file-protocol read path"
|
||||
);
|
||||
}
|
||||
assert!(doc.contains("**refactor** — Refactors code (workflow)"));
|
||||
assert!(doc.contains("**review** — Review skill (workflow)"));
|
||||
assert!(!doc.contains("REFAC_BODY"), "no full body is injected");
|
||||
assert!(!doc.contains("REVIEW_BODY"), "no full body is injected");
|
||||
assert!(!doc.contains("## refactor"));
|
||||
|
||||
// The section sits at high altitude: after the Orchestration block, before
|
||||
// the persona.
|
||||
let orch_at = doc.find("# Orchestration IdeA").unwrap();
|
||||
let skills_at = doc.find("# Skills disponibles").unwrap();
|
||||
let persona_at = doc.find("# Persona").unwrap();
|
||||
assert!(orch_at < skills_at, "skills come after orchestration");
|
||||
assert!(skills_at < persona_at, "skills come before the persona");
|
||||
let orch_at = doc.find("# Orchestration IdeA").unwrap();
|
||||
let skills_at = doc.find("# Skills disponibles").unwrap();
|
||||
let persona_at = doc.find("# Persona").unwrap();
|
||||
assert!(orch_at < skills_at, "skills come after orchestration");
|
||||
assert!(skills_at < persona_at, "skills come before the persona");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -37,8 +37,9 @@ pub use lifecycle::{
|
||||
LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, ListedAgentCapabilities,
|
||||
LiveStateLeanProvider, McpRuntime, PermissionProjectorRegistry, ProviderSessionProvider,
|
||||
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredRoutingMode,
|
||||
StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput,
|
||||
AGENT_MEMORY_RECALL_BUDGET, DEFAULT_OPENCODE_MCP_TIMEOUT_MS, LIVE_STATE_INJECT_MAX,
|
||||
StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort,
|
||||
UpdateAgentEffortInput, UpdateAgentEffortOutput, AGENT_MEMORY_RECALL_BUDGET,
|
||||
DEFAULT_OPENCODE_MCP_TIMEOUT_MS, LIVE_STATE_INJECT_MAX,
|
||||
};
|
||||
pub use model_catalogue::{
|
||||
claude_model_catalogue, codex_model_catalogue, ListClaudeModels, ListClaudeModelsOutput,
|
||||
|
||||
@ -26,6 +26,11 @@ pub enum AppError {
|
||||
#[error("invalid input: {0}")]
|
||||
Invalid(String),
|
||||
|
||||
/// An optimistic-concurrency `if_match` did not match the resource's current
|
||||
/// version. Carries the current version so the caller can retry.
|
||||
#[error("concurrency conflict: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
/// A filesystem operation failed.
|
||||
#[error("filesystem error: {0}")]
|
||||
FileSystem(String),
|
||||
@ -107,6 +112,7 @@ impl AppError {
|
||||
match self {
|
||||
Self::NotFound(_) => "NOT_FOUND",
|
||||
Self::Invalid(_) => "INVALID",
|
||||
Self::Conflict(_) => "CONFLICT",
|
||||
Self::FileSystem(_) => "FILESYSTEM",
|
||||
Self::Store(_) => "STORE",
|
||||
Self::Process(_) => "PROCESS",
|
||||
|
||||
@ -61,7 +61,8 @@ pub use agent::{
|
||||
ResumableAgent, SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput,
|
||||
SaveOpenCodeProviderProfileOutput, SaveProfile, SaveProfileInput, SaveProfileOutput,
|
||||
SessionLimitService, StructuredRoutingMode, StructuredSessionDescriptor, TurnOutcome,
|
||||
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS,
|
||||
UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort, UpdateAgentEffortInput,
|
||||
UpdateAgentEffortOutput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS,
|
||||
LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
|
||||
};
|
||||
pub use background::{
|
||||
@ -162,16 +163,16 @@ pub use plugin::{
|
||||
PluginEventPollInput, PluginEventSubscribeInput, PluginEventSubscription,
|
||||
PluginEventSubscriptions, PluginEventUnsubscribeInput, PluginFileDiagnostic,
|
||||
PluginFileRequirement, PluginInstallResult, PluginPublicEvent, PluginReview,
|
||||
PluginRunCommandInput, PluginRuntimeCatalog, PluginRuntimePlugin, PluginTaskStatusInput,
|
||||
PluginToolDiagnostic, PluginToolRequirement, PluginToolchainDiagnostic,
|
||||
PluginToolchainDiagnosticInput, PluginToolchainDiagnostics, PluginWorkspaceAccess,
|
||||
PluginWorkspaceBinaryFile, PluginWorkspaceDirEntry, PluginWorkspaceDirectoryListing,
|
||||
PluginWorkspacePathInput, PluginWorkspaceStat, PluginWorkspaceTextFile,
|
||||
PluginWorkspaceWriteBinaryInput, PluginWorkspaceWriteTextInput, ProjectConvention,
|
||||
ProjectModule, ProjectStructureEntry, ProjectStructureQuery, QueryProjectStructure,
|
||||
QueryProjectStructureInput, ReconcilePluginMcpServers, ReviewPluginPackage,
|
||||
ReviewPluginPackageInput, SetPluginEnabled, SetPluginEnabledInput, UninstallPlugin,
|
||||
UninstallPluginInput, UninstallPluginResult,
|
||||
PluginRunCommandInput, PluginRuntimeCatalog, PluginRuntimePlugin, PluginStorageAccess,
|
||||
PluginStorageGetInput, PluginStorageSetInput, PluginTaskStatusInput, PluginToolDiagnostic,
|
||||
PluginToolRequirement, PluginToolchainDiagnostic, PluginToolchainDiagnosticInput,
|
||||
PluginToolchainDiagnostics, PluginWorkspaceAccess, PluginWorkspaceBinaryFile,
|
||||
PluginWorkspaceDirEntry, PluginWorkspaceDirectoryListing, PluginWorkspacePathInput,
|
||||
PluginWorkspaceStat, PluginWorkspaceTextFile, PluginWorkspaceWriteBinaryInput,
|
||||
PluginWorkspaceWriteTextInput, ProjectConvention, ProjectModule, ProjectStructureEntry,
|
||||
ProjectStructureQuery, QueryProjectStructure, QueryProjectStructureInput,
|
||||
ReconcilePluginMcpServers, ReviewPluginPackage, ReviewPluginPackageInput, SetPluginEnabled,
|
||||
SetPluginEnabledInput, UninstallPlugin, UninstallPluginInput, UninstallPluginResult,
|
||||
};
|
||||
pub use project::{
|
||||
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
|
||||
|
||||
@ -29,8 +29,9 @@ use domain::conversation::ConversationParty;
|
||||
use domain::fileguard::{may_write_directly, FileGuard, GuardError, GuardedResource};
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType};
|
||||
use domain::ports::{AgentContextStore, Clock, FileSystem, MemoryStore, RemotePath};
|
||||
use domain::{AgentId, Project};
|
||||
use domain::ports::{AgentContextStore, Clock, EventBus, FileSystem, MemoryStore, RemotePath};
|
||||
use domain::{AgentId, DomainEvent, Project};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
@ -45,6 +46,16 @@ fn join_root(project: &Project, rel: &str) -> RemotePath {
|
||||
RemotePath::new(format!("{base}/{rel}"))
|
||||
}
|
||||
|
||||
pub(crate) fn hex_sha256(bytes: &[u8]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(bytes);
|
||||
hex_encode(&hasher.finalize())
|
||||
}
|
||||
|
||||
fn hex_encode(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Resolves an agent display name to its [`AgentId`] via the project manifest
|
||||
/// (case-insensitive), or [`AppError::NotFound`].
|
||||
async fn resolve_agent(
|
||||
@ -80,6 +91,14 @@ pub struct ReadContextInput {
|
||||
pub requester: ConversationParty,
|
||||
}
|
||||
|
||||
/// Output of [`ReadContext`].
|
||||
pub struct ReadContextOutput {
|
||||
/// The context Markdown.
|
||||
pub content: MarkdownDoc,
|
||||
/// sha256 hex digest of `content`, only for the global project context.
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
impl ReadContext {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
@ -99,7 +118,7 @@ impl ReadContext {
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError`] when the agent/context does not exist or the store/fs fails.
|
||||
pub async fn execute(&self, input: ReadContextInput) -> Result<MarkdownDoc, AppError> {
|
||||
pub async fn execute(&self, input: ReadContextInput) -> Result<ReadContextOutput, AppError> {
|
||||
let ReadContextInput {
|
||||
project,
|
||||
target,
|
||||
@ -115,9 +134,13 @@ impl ReadContext {
|
||||
.map_err(map_guard_err)?;
|
||||
let path = join_root(&project, PROJECT_CONTEXT_FILE);
|
||||
let bytes = self.fs.read(&path).await?;
|
||||
let version = hex_sha256(&bytes);
|
||||
let text =
|
||||
String::from_utf8(bytes).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
Ok(MarkdownDoc::new(text))
|
||||
Ok(ReadContextOutput {
|
||||
content: MarkdownDoc::new(text),
|
||||
version: Some(version),
|
||||
})
|
||||
}
|
||||
Some(name) => {
|
||||
let agent = resolve_agent(&self.contexts, &project, &name).await?;
|
||||
@ -126,12 +149,114 @@ impl ReadContext {
|
||||
.acquire_read(requester, GuardedResource::AgentContext(agent))
|
||||
.await
|
||||
.map_err(map_guard_err)?;
|
||||
Ok(self.contexts.read_context(&project, &agent).await?)
|
||||
Ok(ReadContextOutput {
|
||||
content: self.contexts.read_context(&project, &agent).await?,
|
||||
version: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Directly updates the global project context. This is the strict, fail-loud
|
||||
/// counterpart to [`ProposeContext`]'s soft-degrading global branch.
|
||||
pub struct UpdateProjectContext {
|
||||
guard: Arc<dyn FileGuard>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
events: Arc<dyn EventBus>,
|
||||
clock: Arc<dyn Clock>,
|
||||
}
|
||||
|
||||
/// Input for [`UpdateProjectContext`].
|
||||
pub struct UpdateProjectContextInput {
|
||||
/// The project to write within.
|
||||
pub project: Project,
|
||||
/// New global project context Markdown.
|
||||
pub content: String,
|
||||
/// Optional expected current version.
|
||||
pub if_match: Option<String>,
|
||||
/// The writing party.
|
||||
pub requester: ConversationParty,
|
||||
}
|
||||
|
||||
/// Output of [`UpdateProjectContext`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateProjectContextOutput {
|
||||
/// sha256 hex digest of the newly written content.
|
||||
pub new_version: String,
|
||||
}
|
||||
|
||||
impl UpdateProjectContext {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
guard: Arc<dyn FileGuard>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
events: Arc<dyn EventBus>,
|
||||
clock: Arc<dyn Clock>,
|
||||
) -> Self {
|
||||
Self {
|
||||
guard,
|
||||
contexts,
|
||||
fs,
|
||||
events,
|
||||
clock,
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes the direct global-context update.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::Invalid`] when the requester is not allowed to write directly,
|
||||
/// - [`AppError::Conflict`] when `if_match` does not match current content,
|
||||
/// - [`AppError`] on store/fs failure.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: UpdateProjectContextInput,
|
||||
) -> Result<UpdateProjectContextOutput, AppError> {
|
||||
let UpdateProjectContextInput {
|
||||
project,
|
||||
content,
|
||||
if_match,
|
||||
requester,
|
||||
} = input;
|
||||
|
||||
let manifest = self.contexts.load_manifest(&project).await?;
|
||||
let designation = manifest.orchestrator_designation();
|
||||
let resource = GuardedResource::ProjectContext;
|
||||
if !may_write_directly(requester, &resource, &designation) {
|
||||
return Err(map_guard_err(GuardError::Forbidden));
|
||||
}
|
||||
|
||||
let _lease = self
|
||||
.guard
|
||||
.acquire_write(requester, resource)
|
||||
.await
|
||||
.map_err(map_guard_err)?;
|
||||
|
||||
let path = join_root(&project, PROJECT_CONTEXT_FILE);
|
||||
let current_bytes = self.fs.read(&path).await?;
|
||||
let current_version = hex_sha256(¤t_bytes);
|
||||
if let Some(expected) = if_match {
|
||||
if expected != current_version {
|
||||
return Err(AppError::Conflict(current_version));
|
||||
}
|
||||
}
|
||||
|
||||
self.fs.write(&path, content.as_bytes()).await?;
|
||||
let new_version = hex_sha256(content.as_bytes());
|
||||
self.events.publish(DomainEvent::ProjectContextUpdated {
|
||||
project_id: project.id,
|
||||
by: requester,
|
||||
at_ms: self.clock.now_millis(),
|
||||
});
|
||||
|
||||
Ok(UpdateProjectContextOutput { new_version })
|
||||
}
|
||||
}
|
||||
|
||||
/// Proposes new content for an IdeA-owned context under the [`FileGuard`].
|
||||
///
|
||||
/// For an **agent** context: a direct write under an exclusive write-lease. For the
|
||||
@ -393,7 +518,7 @@ mod tests {
|
||||
use domain::agent::{AgentManifest, ManifestEntry};
|
||||
use domain::conversation::ConversationParty;
|
||||
use domain::fileguard::{ReadLease, WriteLease};
|
||||
use domain::ports::{FsError, MemoryError, StoreError};
|
||||
use domain::ports::{EventStream, FsError, MemoryError, StoreError};
|
||||
use domain::project::ProjectPath;
|
||||
use domain::{ProfileId, ProjectId, RemoteRef};
|
||||
use std::collections::HashMap;
|
||||
@ -599,6 +724,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SpyBus(Mutex<Vec<DomainEvent>>);
|
||||
|
||||
impl SpyBus {
|
||||
fn events(&self) -> Vec<DomainEvent> {
|
||||
self.0.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventBus for SpyBus {
|
||||
fn publish(&self, event: DomainEvent) {
|
||||
self.0.lock().unwrap().push(event);
|
||||
}
|
||||
|
||||
fn subscribe(&self) -> EventStream {
|
||||
Box::new(std::iter::empty())
|
||||
}
|
||||
}
|
||||
|
||||
fn guard() -> Arc<dyn FileGuard> {
|
||||
Arc::new(TestGuard::default())
|
||||
}
|
||||
@ -619,6 +763,7 @@ mod tests {
|
||||
synchronized: false,
|
||||
synced_template_version: None,
|
||||
skills: Vec::new(),
|
||||
effort: None,
|
||||
}],
|
||||
},
|
||||
contexts: Mutex::new(contexts),
|
||||
@ -635,7 +780,7 @@ mod tests {
|
||||
contexts_with("Dev", agent, "# hello"),
|
||||
Arc::new(FakeFs::default()),
|
||||
);
|
||||
let md = uc
|
||||
let out = uc
|
||||
.execute(ReadContextInput {
|
||||
project: project(),
|
||||
target: Some("dev".to_owned()), // case-insensitive
|
||||
@ -643,7 +788,8 @@ mod tests {
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(md.as_str(), "# hello");
|
||||
assert_eq!(out.content.as_str(), "# hello");
|
||||
assert_eq!(out.version, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -658,7 +804,7 @@ mod tests {
|
||||
contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
|
||||
fs,
|
||||
);
|
||||
let md = uc
|
||||
let out = uc
|
||||
.execute(ReadContextInput {
|
||||
project: project(),
|
||||
target: None,
|
||||
@ -666,7 +812,8 @@ mod tests {
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(md.as_str(), "# project");
|
||||
assert_eq!(out.content.as_str(), "# project");
|
||||
assert_eq!(out.version, Some(hex_sha256(b"# project")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -749,6 +896,262 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_project_context_orchestrator_writes_directly_and_returns_new_version() {
|
||||
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# old".to_vec());
|
||||
let bus = Arc::new(SpyBus::default());
|
||||
let uc = UpdateProjectContext::new(
|
||||
guard(),
|
||||
contexts_with("Dev", agent, "x"),
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
Arc::clone(&bus) as Arc<dyn EventBus>,
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
|
||||
let out = uc
|
||||
.execute(UpdateProjectContextInput {
|
||||
project: project(),
|
||||
content: "# new".to_owned(),
|
||||
if_match: None,
|
||||
requester: ConversationParty::agent(agent),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.new_version, hex_sha256(b"# new"));
|
||||
assert_eq!(
|
||||
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
|
||||
b"# new"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_project_context_non_orchestrator_fails_loud_no_proposal_filed() {
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# old".to_vec());
|
||||
let uc = UpdateProjectContext::new(
|
||||
guard(),
|
||||
contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
Arc::new(SpyBus::default()),
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
|
||||
let err = uc
|
||||
.execute(UpdateProjectContextInput {
|
||||
project: project(),
|
||||
content: "# rejected".to_owned(),
|
||||
if_match: None,
|
||||
requester: agent_party(8),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.code(), "INVALID");
|
||||
let files = fs.files.lock().unwrap();
|
||||
assert_eq!(files.get("/tmp/demo/CLAUDE.md").unwrap(), b"# old");
|
||||
assert!(
|
||||
!files.keys().any(|path| path.contains("/.ideai/proposals/")),
|
||||
"strict update must fail loud, not file a proposal"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_project_context_if_match_mismatch_returns_conflict_with_current_version() {
|
||||
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# current".to_vec());
|
||||
let uc = UpdateProjectContext::new(
|
||||
guard(),
|
||||
contexts_with("Dev", agent, "x"),
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
Arc::new(SpyBus::default()),
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
|
||||
let err = uc
|
||||
.execute(UpdateProjectContextInput {
|
||||
project: project(),
|
||||
content: "# new".to_owned(),
|
||||
if_match: Some("stale".to_owned()),
|
||||
requester: ConversationParty::agent(agent),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err, AppError::Conflict(hex_sha256(b"# current")));
|
||||
assert_eq!(
|
||||
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
|
||||
b"# current"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_project_context_if_match_matching_succeeds() {
|
||||
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# old".to_vec());
|
||||
let uc = UpdateProjectContext::new(
|
||||
guard(),
|
||||
contexts_with("Dev", agent, "x"),
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
Arc::new(SpyBus::default()),
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
|
||||
let out = uc
|
||||
.execute(UpdateProjectContextInput {
|
||||
project: project(),
|
||||
content: "# new".to_owned(),
|
||||
if_match: Some(hex_sha256(b"# old")),
|
||||
requester: ConversationParty::agent(agent),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.new_version, hex_sha256(b"# new"));
|
||||
assert_eq!(
|
||||
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
|
||||
b"# new"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_project_context_no_if_match_is_last_write_wins() {
|
||||
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# previous".to_vec());
|
||||
let uc = UpdateProjectContext::new(
|
||||
guard(),
|
||||
contexts_with("Dev", agent, "x"),
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
Arc::new(SpyBus::default()),
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
|
||||
uc.execute(UpdateProjectContextInput {
|
||||
project: project(),
|
||||
content: "# latest".to_owned(),
|
||||
if_match: None,
|
||||
requester: ConversationParty::agent(agent),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
|
||||
b"# latest"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_project_context_publishes_project_context_updated_event() {
|
||||
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# old".to_vec());
|
||||
let bus = Arc::new(SpyBus::default());
|
||||
let uc = UpdateProjectContext::new(
|
||||
guard(),
|
||||
contexts_with("Dev", agent, "x"),
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
Arc::clone(&bus) as Arc<dyn EventBus>,
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
|
||||
uc.execute(UpdateProjectContextInput {
|
||||
project: project(),
|
||||
content: "# new".to_owned(),
|
||||
if_match: None,
|
||||
requester: ConversationParty::agent(agent),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
bus.events(),
|
||||
vec![DomainEvent::ProjectContextUpdated {
|
||||
project_id: project().id,
|
||||
by: ConversationParty::agent(agent),
|
||||
at_ms: 42,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_context_and_update_project_context_version_round_trip() {
|
||||
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# first".to_vec());
|
||||
let contexts = contexts_with("Dev", agent, "agent body");
|
||||
let reader = ReadContext::new(
|
||||
guard(),
|
||||
Arc::clone(&contexts),
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
);
|
||||
let updater = UpdateProjectContext::new(
|
||||
guard(),
|
||||
contexts,
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
Arc::new(SpyBus::default()),
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
|
||||
let version = reader
|
||||
.execute(ReadContextInput {
|
||||
project: project(),
|
||||
target: None,
|
||||
requester: ConversationParty::agent(agent),
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.version
|
||||
.unwrap();
|
||||
|
||||
updater
|
||||
.execute(UpdateProjectContextInput {
|
||||
project: project(),
|
||||
content: "# second".to_owned(),
|
||||
if_match: Some(version.clone()),
|
||||
requester: ConversationParty::agent(agent),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let stale = updater
|
||||
.execute(UpdateProjectContextInput {
|
||||
project: project(),
|
||||
content: "# third".to_owned(),
|
||||
if_match: Some(version),
|
||||
requester: ConversationParty::agent(agent),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(stale, AppError::Conflict(hex_sha256(b"# second")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn propose_agent_context_writes_directly() {
|
||||
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
|
||||
|
||||
@ -10,8 +10,9 @@ mod service;
|
||||
pub mod wake;
|
||||
|
||||
pub use context_guard::{
|
||||
ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory,
|
||||
ReadMemoryInput, WriteMemory, WriteMemoryInput,
|
||||
ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput,
|
||||
ReadContextOutput, ReadMemory, ReadMemoryInput, UpdateProjectContext,
|
||||
UpdateProjectContextInput, UpdateProjectContextOutput, WriteMemory, WriteMemoryInput,
|
||||
};
|
||||
pub use rendezvous::{
|
||||
resolve_rendezvous_ceiling, resolve_rendezvous_window, run_inactivity_watchdog,
|
||||
|
||||
@ -47,7 +47,8 @@ use crate::error::AppError;
|
||||
use crate::orchestrator::rendezvous::{run_inactivity_watchdog, WatchdogOutcome};
|
||||
use crate::orchestrator::{
|
||||
ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory,
|
||||
ReadMemoryInput, WriteMemory, WriteMemoryInput,
|
||||
ReadMemoryInput, UpdateProjectContext, UpdateProjectContextInput, WriteMemory,
|
||||
WriteMemoryInput,
|
||||
};
|
||||
use crate::skill::{CreateSkill, CreateSkillInput, ReadSkill, ReadSkillInput};
|
||||
use crate::terminal::{CloseTerminal, CloseTerminalInput, StructuredSessions, TerminalSessions};
|
||||
@ -534,6 +535,8 @@ pub struct ContextGuardUseCases {
|
||||
pub read_context: Arc<ReadContext>,
|
||||
/// Proposition/écriture d'un contexte `.md` IdeA sous le garde.
|
||||
pub propose_context: Arc<ProposeContext>,
|
||||
/// Écriture directe stricte du contexte projet global.
|
||||
pub update_project_context: Arc<UpdateProjectContext>,
|
||||
/// Lecture mémoire sous read-lease.
|
||||
pub read_memory: Arc<ReadMemory>,
|
||||
/// Écriture mémoire sous write-lease.
|
||||
@ -1241,6 +1244,14 @@ impl OrchestratorService {
|
||||
self.propose_context(project, target, content, requester)
|
||||
.await
|
||||
}
|
||||
OrchestratorCommand::UpdateProjectContext {
|
||||
content,
|
||||
if_match,
|
||||
requester,
|
||||
} => {
|
||||
self.update_project_context(project, content, if_match, requester)
|
||||
.await
|
||||
}
|
||||
OrchestratorCommand::ReadMemory { slug, requester } => {
|
||||
self.read_memory(project, slug, requester).await
|
||||
}
|
||||
@ -1363,7 +1374,7 @@ impl OrchestratorService {
|
||||
target: Option<String>,
|
||||
requester: ConversationParty,
|
||||
) -> Result<OrchestratorOutcome, AppError> {
|
||||
let md = self
|
||||
let out = self
|
||||
.require_context_guard()?
|
||||
.read_context
|
||||
.execute(ReadContextInput {
|
||||
@ -1372,9 +1383,13 @@ impl OrchestratorService {
|
||||
requester,
|
||||
})
|
||||
.await?;
|
||||
let mut text = out.content.into_string();
|
||||
if let Some(version) = &out.version {
|
||||
text.push_str(&format!("\n\n<!-- idea-context-version: {version} -->"));
|
||||
}
|
||||
Ok(OrchestratorOutcome {
|
||||
detail: format!("read {} context", target.as_deref().unwrap_or("project")),
|
||||
reply: Some(md.into_string()),
|
||||
reply: Some(text),
|
||||
})
|
||||
}
|
||||
|
||||
@ -1411,6 +1426,31 @@ impl OrchestratorService {
|
||||
})
|
||||
}
|
||||
|
||||
/// `context.update` → strict direct write of the global project context.
|
||||
async fn update_project_context(
|
||||
&self,
|
||||
project: &Project,
|
||||
content: String,
|
||||
if_match: Option<String>,
|
||||
requester: ConversationParty,
|
||||
) -> Result<OrchestratorOutcome, AppError> {
|
||||
let out = self
|
||||
.require_context_guard()?
|
||||
.update_project_context
|
||||
.execute(UpdateProjectContextInput {
|
||||
project: project.clone(),
|
||||
content,
|
||||
if_match,
|
||||
requester,
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(OrchestratorOutcome {
|
||||
detail: format!("wrote project context (version {})", out.new_version),
|
||||
reply: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `memory.read` → reads a note (or the index) under a shared read-lease; the
|
||||
/// content is returned inline in the outcome's `reply`.
|
||||
async fn read_memory(
|
||||
@ -2783,6 +2823,7 @@ impl OrchestratorService {
|
||||
description: None,
|
||||
content,
|
||||
scope,
|
||||
kind: domain::SkillKind::Workflow,
|
||||
project_root: project.root.clone(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
@ -7,7 +7,10 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::PermissionStore;
|
||||
use domain::{AgentId, EffectivePermissions, PermissionSet, Project, ProjectPermissions};
|
||||
use domain::{
|
||||
AgentId, EffectivePermissions, PermissionSet, PermissionShadowReport, Project,
|
||||
ProjectPermissions,
|
||||
};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
@ -131,6 +134,7 @@ impl ResolveAgentPermissions {
|
||||
let doc = self.store.load_permissions(&input.project).await?;
|
||||
Ok(ResolveAgentPermissionsOutput {
|
||||
effective: doc.resolve_for(input.agent_id),
|
||||
shadowed: doc.shadow_for(input.agent_id),
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -147,4 +151,6 @@ pub struct ResolveAgentPermissionsInput {
|
||||
pub struct ResolveAgentPermissionsOutput {
|
||||
/// Resolved policy, or `None` when neither project nor agent policy exists.
|
||||
pub effective: Option<EffectivePermissions>,
|
||||
/// Diagnostic report for agent-level allows shadowed by project defaults.
|
||||
pub shadowed: PermissionShadowReport,
|
||||
}
|
||||
|
||||
@ -8,8 +8,8 @@ use domain::ports::{
|
||||
BackgroundTaskStore, Clock, DirEntry, EventBus, FileMetadata, FileSystem, IdGenerator,
|
||||
LocalPath, Output, PluginManifestBytes, PluginManifestError, PluginManifestValidator,
|
||||
PluginMcpError, PluginMcpSupervisor, PluginPackageStore, PluginRegistryError,
|
||||
PluginRegistryStore, PluginStoreError, ProcessError, ProcessSpawner, ProjectStore, RemotePath,
|
||||
SpawnSpec,
|
||||
PluginRegistryStore, PluginStorageError, PluginStorageStore, PluginStoreError, ProcessError,
|
||||
ProcessSpawner, ProjectStore, RemotePath, SpawnSpec,
|
||||
};
|
||||
use domain::{
|
||||
AgentId, BackgroundTask, BackgroundTaskState, BackgroundTaskWakePolicy, ContentHash,
|
||||
@ -157,6 +157,28 @@ pub struct PluginRuntimePlugin {
|
||||
pub contributes: PluginContributionSet,
|
||||
}
|
||||
|
||||
/// Input for plugin-owned storage reads/deletes.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginStorageGetInput {
|
||||
/// Plugin id owning the value.
|
||||
pub plugin_id: String,
|
||||
/// Plugin-owned key.
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
/// Input for plugin-owned storage writes.
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginStorageSetInput {
|
||||
/// Plugin id owning the value.
|
||||
pub plugin_id: String,
|
||||
/// Plugin-owned key.
|
||||
pub key: String,
|
||||
/// JSON value to persist.
|
||||
pub value: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Input for reviewing a package.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ReviewPluginPackageInput {
|
||||
@ -2023,6 +2045,14 @@ fn map_store(e: PluginStoreError) -> AppError {
|
||||
}
|
||||
}
|
||||
|
||||
fn map_storage(e: PluginStorageError) -> AppError {
|
||||
match e {
|
||||
PluginStorageError::Invalid(m) => AppError::Invalid(m),
|
||||
PluginStorageError::Io(m) => AppError::FileSystem(m),
|
||||
PluginStorageError::Serialization(m) => AppError::Store(m),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_registry(e: PluginRegistryError) -> AppError {
|
||||
match e {
|
||||
PluginRegistryError::Io(m) => AppError::Store(m),
|
||||
@ -2516,9 +2546,91 @@ pub struct UninstallPluginInput {
|
||||
pub plugin_id: String,
|
||||
}
|
||||
|
||||
/// Plugin-owned key/value storage facade.
|
||||
pub struct PluginStorageAccess {
|
||||
storage: Arc<dyn PluginStorageStore>,
|
||||
registry: Arc<dyn PluginRegistryStore>,
|
||||
}
|
||||
|
||||
impl PluginStorageAccess {
|
||||
/// Builds the facade.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
storage: Arc<dyn PluginStorageStore>,
|
||||
registry: Arc<dyn PluginRegistryStore>,
|
||||
) -> Self {
|
||||
Self { storage, registry }
|
||||
}
|
||||
|
||||
/// Reads one plugin-owned JSON value.
|
||||
pub async fn get(
|
||||
&self,
|
||||
input: PluginStorageGetInput,
|
||||
) -> Result<Option<serde_json::Value>, AppError> {
|
||||
let plugin_id = self.active_plugin_id(input.plugin_id).await?;
|
||||
validate_storage_key(&input.key)?;
|
||||
self.storage
|
||||
.get(&plugin_id, &input.key)
|
||||
.await
|
||||
.map_err(map_storage)
|
||||
}
|
||||
|
||||
/// Writes one plugin-owned JSON value.
|
||||
pub async fn set(&self, input: PluginStorageSetInput) -> Result<(), AppError> {
|
||||
let plugin_id = self.active_plugin_id(input.plugin_id).await?;
|
||||
validate_storage_key(&input.key)?;
|
||||
self.storage
|
||||
.set(&plugin_id, &input.key, input.value)
|
||||
.await
|
||||
.map_err(map_storage)
|
||||
}
|
||||
|
||||
/// Deletes one plugin-owned JSON value.
|
||||
pub async fn delete(&self, input: PluginStorageGetInput) -> Result<bool, AppError> {
|
||||
let plugin_id = self.active_plugin_id(input.plugin_id).await?;
|
||||
validate_storage_key(&input.key)?;
|
||||
self.storage
|
||||
.delete(&plugin_id, &input.key)
|
||||
.await
|
||||
.map_err(map_storage)
|
||||
}
|
||||
|
||||
async fn active_plugin_id(&self, raw: String) -> Result<PluginId, AppError> {
|
||||
let plugin_id = PluginId::new(raw).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
let registry = self.registry.load_registry().await.map_err(map_registry)?;
|
||||
let entry = registry
|
||||
.find(&plugin_id)
|
||||
.ok_or_else(|| AppError::NotFound("plugin".to_owned()))?;
|
||||
if !entry.lifecycle_state.is_runtime_active() {
|
||||
return Err(AppError::Invalid("plugin is not runtime-active".to_owned()));
|
||||
}
|
||||
Ok(plugin_id)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_storage_key(key: &str) -> Result<(), AppError> {
|
||||
if key.trim().is_empty() {
|
||||
return Err(AppError::Invalid(
|
||||
"plugin storage key must not be empty".to_owned(),
|
||||
));
|
||||
}
|
||||
if key.len() > 512 {
|
||||
return Err(AppError::Invalid(
|
||||
"plugin storage key must not exceed 512 bytes".to_owned(),
|
||||
));
|
||||
}
|
||||
if key.contains('\0') {
|
||||
return Err(AppError::Invalid(
|
||||
"plugin storage key must not contain NUL bytes".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Uninstalls a plugin.
|
||||
pub struct UninstallPlugin {
|
||||
packages: Arc<dyn PluginPackageStore>,
|
||||
storage: Arc<dyn PluginStorageStore>,
|
||||
registry: Arc<dyn PluginRegistryStore>,
|
||||
events: Arc<dyn EventBus>,
|
||||
mcp: Arc<dyn PluginMcpSupervisor>,
|
||||
@ -2529,12 +2641,14 @@ impl UninstallPlugin {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
packages: Arc<dyn PluginPackageStore>,
|
||||
storage: Arc<dyn PluginStorageStore>,
|
||||
registry: Arc<dyn PluginRegistryStore>,
|
||||
events: Arc<dyn EventBus>,
|
||||
mcp: Arc<dyn PluginMcpSupervisor>,
|
||||
) -> Self {
|
||||
Self {
|
||||
packages,
|
||||
storage,
|
||||
registry,
|
||||
events,
|
||||
mcp,
|
||||
@ -2562,6 +2676,10 @@ impl UninstallPlugin {
|
||||
.remove_package(&plugin_id)
|
||||
.await
|
||||
.map_err(map_store)?;
|
||||
self.storage
|
||||
.purge_plugin(&plugin_id)
|
||||
.await
|
||||
.map_err(map_storage)?;
|
||||
self.events.publish(DomainEvent::PluginUninstalled {
|
||||
plugin_id: plugin_id.clone(),
|
||||
restart_required: true,
|
||||
@ -3214,7 +3332,8 @@ mod tests {
|
||||
use domain::ports::{
|
||||
BackgroundCompletionStream, BackgroundTaskHandle, BackgroundTaskPortError,
|
||||
BackgroundTaskRunner, BackgroundTaskSpec, EventStream, FileMetadata, IdGenerator,
|
||||
PluginPackageStore, PluginRegistryStore, PluginStoreError, StoreError,
|
||||
PluginPackageStore, PluginRegistryStore, PluginStorageError, PluginStorageStore,
|
||||
PluginStoreError, StoreError,
|
||||
};
|
||||
use domain::remote::RemoteRef;
|
||||
use domain::{BackgroundTaskState, ProjectPath};
|
||||
@ -3411,6 +3530,69 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeStorage {
|
||||
values: Mutex<HashMap<(String, String), serde_json::Value>>,
|
||||
purged: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl PluginStorageStore for FakeStorage {
|
||||
async fn get(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
key: &str,
|
||||
) -> Result<Option<serde_json::Value>, PluginStorageError> {
|
||||
Ok(self
|
||||
.values
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&(plugin_id.as_str().to_owned(), key.to_owned()))
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn set(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
key: &str,
|
||||
value: serde_json::Value,
|
||||
) -> Result<(), PluginStorageError> {
|
||||
self.values
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert((plugin_id.as_str().to_owned(), key.to_owned()), value);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
key: &str,
|
||||
) -> Result<bool, PluginStorageError> {
|
||||
Ok(self
|
||||
.values
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&(plugin_id.as_str().to_owned(), key.to_owned()))
|
||||
.is_some())
|
||||
}
|
||||
|
||||
async fn purge_plugin(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
) -> Result<RemovalOutcome, PluginStorageError> {
|
||||
self.purged
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(plugin_id.as_str().to_owned());
|
||||
self.values
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|(id, _), _| id != plugin_id.as_str());
|
||||
Ok(RemovalOutcome::Removed)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeEvents {
|
||||
events: Mutex<Vec<DomainEvent>>,
|
||||
@ -3802,6 +3984,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn uninstall_removes_registry_package_and_stops_mcp() {
|
||||
let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));
|
||||
let storage = Arc::new(FakeStorage::default());
|
||||
let registry = Arc::new(FakeRegistry {
|
||||
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
|
||||
});
|
||||
@ -3809,6 +3992,7 @@ mod tests {
|
||||
let mcp = Arc::new(FakeMcp::default());
|
||||
let uninstall = UninstallPlugin::new(
|
||||
packages.clone(),
|
||||
storage.clone(),
|
||||
registry.clone(),
|
||||
events.clone(),
|
||||
mcp.clone(),
|
||||
@ -3825,6 +4009,7 @@ mod tests {
|
||||
assert!(result.restart_required);
|
||||
assert!(registry.load_registry().await.unwrap().plugins.is_empty());
|
||||
assert_eq!(&*packages.removed.lock().unwrap(), &["dev.acme.gitgraph"]);
|
||||
assert_eq!(&*storage.purged.lock().unwrap(), &["dev.acme.gitgraph"]);
|
||||
assert_eq!(&*mcp.stops.lock().unwrap(), &["dev.acme.gitgraph"]);
|
||||
assert!(events.events.lock().unwrap().iter().any(|event| matches!(
|
||||
event,
|
||||
@ -3835,6 +4020,89 @@ mod tests {
|
||||
)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_storage_round_trips_json_for_runtime_active_plugin() {
|
||||
let storage = Arc::new(FakeStorage::default());
|
||||
let registry = Arc::new(FakeRegistry {
|
||||
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
|
||||
});
|
||||
let access = PluginStorageAccess::new(storage, registry);
|
||||
|
||||
access
|
||||
.set(PluginStorageSetInput {
|
||||
plugin_id: "dev.acme.gitgraph".to_owned(),
|
||||
key: "helloPlugin.launches".to_owned(),
|
||||
value: serde_json::json!({"count": 2}),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let value = access
|
||||
.get(PluginStorageGetInput {
|
||||
plugin_id: "dev.acme.gitgraph".to_owned(),
|
||||
key: "helloPlugin.launches".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(value, Some(serde_json::json!({"count": 2})));
|
||||
assert!(access
|
||||
.delete(PluginStorageGetInput {
|
||||
plugin_id: "dev.acme.gitgraph".to_owned(),
|
||||
key: "helloPlugin.launches".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap());
|
||||
assert_eq!(
|
||||
access
|
||||
.get(PluginStorageGetInput {
|
||||
plugin_id: "dev.acme.gitgraph".to_owned(),
|
||||
key: "helloPlugin.launches".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_storage_rejects_inactive_plugin_and_invalid_key() {
|
||||
let storage = Arc::new(FakeStorage::default());
|
||||
let registry = Arc::new(FakeRegistry {
|
||||
registry: Mutex::new(registry_with(PluginLifecycleState::Disabled)),
|
||||
});
|
||||
let access = PluginStorageAccess::new(storage, registry);
|
||||
|
||||
let inactive = access
|
||||
.set(PluginStorageSetInput {
|
||||
plugin_id: "dev.acme.gitgraph".to_owned(),
|
||||
key: "helloPlugin.launches".to_owned(),
|
||||
value: serde_json::json!(1),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
inactive,
|
||||
AppError::Invalid("plugin is not runtime-active".to_owned())
|
||||
);
|
||||
|
||||
let storage = Arc::new(FakeStorage::default());
|
||||
let registry = Arc::new(FakeRegistry {
|
||||
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
|
||||
});
|
||||
let access = PluginStorageAccess::new(storage, registry);
|
||||
let invalid = access
|
||||
.get(PluginStorageGetInput {
|
||||
plugin_id: "dev.acme.gitgraph".to_owned(),
|
||||
key: " ".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
invalid,
|
||||
AppError::Invalid("plugin storage key must not be empty".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uninstall_then_reinstall_leaves_runtime_catalog_active_without_residue() {
|
||||
let packages = Arc::new(FakePackages::with_manifest_and_staged_count(
|
||||
@ -3844,6 +4112,7 @@ mod tests {
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
let events = Arc::new(FakeEvents::default());
|
||||
let mcp = Arc::new(FakeMcp::default());
|
||||
let storage = Arc::new(FakeStorage::default());
|
||||
let install = InstallPluginFromDirectory::new(
|
||||
packages.clone(),
|
||||
registry.clone(),
|
||||
@ -3851,8 +4120,13 @@ mod tests {
|
||||
events.clone(),
|
||||
mcp.clone(),
|
||||
);
|
||||
let uninstall =
|
||||
UninstallPlugin::new(packages.clone(), registry.clone(), events, mcp.clone());
|
||||
let uninstall = UninstallPlugin::new(
|
||||
packages.clone(),
|
||||
storage.clone(),
|
||||
registry.clone(),
|
||||
events,
|
||||
mcp.clone(),
|
||||
);
|
||||
|
||||
install.execute("/source/plugin".to_owned()).await.unwrap();
|
||||
uninstall
|
||||
|
||||
@ -30,6 +30,9 @@ pub struct CreateSkillInput {
|
||||
/// `None`/empty ⇒ the skill falls back to the first line of its body when
|
||||
/// surfaced (see [`domain::Skill::effective_description`]).
|
||||
pub description: Option<String>,
|
||||
/// Capability nature to expose for this skill. Defaults to workflow when the
|
||||
/// caller does not specify it.
|
||||
pub kind: SkillKind,
|
||||
/// Initial Markdown body.
|
||||
pub content: String,
|
||||
/// Scope the skill is created in (selects its backing store).
|
||||
@ -67,7 +70,8 @@ impl CreateSkill {
|
||||
let id = SkillId::from_uuid(self.ids.new_uuid());
|
||||
let skill = Skill::new(id, input.name, MarkdownDoc::new(input.content), input.scope)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?
|
||||
.with_description(input.description);
|
||||
.with_description(input.description)
|
||||
.with_kind(input.kind);
|
||||
self.skills.save(&skill, &input.project_root).await?;
|
||||
Ok(CreateSkillOutput { skill })
|
||||
}
|
||||
|
||||
@ -35,8 +35,8 @@ use domain::ports::{
|
||||
RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, SystemPermissionStore,
|
||||
};
|
||||
use domain::profile::{
|
||||
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, OpenCodeConfig,
|
||||
SessionStrategy, StructuredAdapter,
|
||||
AgentProfile, ContextInjection, EffortSelection, McpCapability, McpConfigStrategy,
|
||||
McpTransport, OpenCodeConfig, SessionStrategy, StructuredAdapter,
|
||||
};
|
||||
use domain::project::{Project, ProjectPath};
|
||||
use domain::remote::RemoteRef;
|
||||
@ -50,7 +50,7 @@ use application::{
|
||||
CreateAgentFromScratch, CreateAgentInput, DeleteAgent, DeleteAgentInput, LaunchAgent,
|
||||
LaunchAgentInput, ListAgents, ListAgentsInput, PermissionProjectorRegistry, ReadAgentContext,
|
||||
ReadAgentContextInput, StructuredRoutingMode, StructuredSessions, TerminalSessions,
|
||||
UpdateAgentContext, UpdateAgentContextInput,
|
||||
UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort, UpdateAgentEffortInput,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -604,6 +604,7 @@ impl AgentSession for FakeSession {
|
||||
struct FakeStructuredFactory {
|
||||
trace: Trace,
|
||||
starts: Arc<Mutex<Vec<ProfileId>>>,
|
||||
efforts: Arc<Mutex<Vec<Option<String>>>>,
|
||||
envs: Arc<Mutex<Vec<Vec<(String, String)>>>>,
|
||||
policies: Arc<Mutex<Vec<Option<domain::ports::StructuredProviderLaunchPolicy>>>>,
|
||||
next_session: SessionId,
|
||||
@ -614,6 +615,7 @@ impl FakeStructuredFactory {
|
||||
Self {
|
||||
trace,
|
||||
starts: Arc::new(Mutex::new(Vec::new())),
|
||||
efforts: Arc::new(Mutex::new(Vec::new())),
|
||||
envs: Arc::new(Mutex::new(Vec::new())),
|
||||
policies: Arc::new(Mutex::new(Vec::new())),
|
||||
next_session,
|
||||
@ -624,6 +626,10 @@ impl FakeStructuredFactory {
|
||||
self.starts.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn efforts(&self) -> Vec<Option<String>> {
|
||||
self.efforts.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn envs(&self) -> Vec<Vec<(String, String)>> {
|
||||
self.envs.lock().unwrap().clone()
|
||||
}
|
||||
@ -655,6 +661,10 @@ impl AgentSessionFactory for FakeStructuredFactory {
|
||||
.unwrap()
|
||||
.push("structured.start".to_owned());
|
||||
self.starts.lock().unwrap().push(profile.id);
|
||||
self.efforts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(profile.model_reasoning_effort.clone());
|
||||
self.envs.lock().unwrap().push(_env.to_vec());
|
||||
self.policies
|
||||
.lock()
|
||||
@ -864,6 +874,54 @@ async fn list_resolves_agent_capabilities_additively() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_agents_marks_effective_orchestrator_as_is_orchestrator_true() {
|
||||
let a1 = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
let a2 = scratch_agent(aid(2), "Frontend", "agents/frontend.md", pid(9));
|
||||
let contexts = FakeContexts::with_agent(&a1, "ctx1");
|
||||
{
|
||||
let mut inner = contexts.0.lock().unwrap();
|
||||
inner.manifest.entries.push(ManifestEntry::from_agent(&a2));
|
||||
inner.manifest.designate(a2.id).unwrap();
|
||||
}
|
||||
let list = ListAgents::new(Arc::new(contexts));
|
||||
|
||||
let out = list
|
||||
.execute(ListAgentsInput { project: project() })
|
||||
.await
|
||||
.unwrap();
|
||||
let entries = out.discovery_entries();
|
||||
|
||||
assert_eq!(out.effective_orchestrator, Some(a2.id));
|
||||
assert_eq!(entries[0].is_orchestrator, false);
|
||||
assert_eq!(entries[1].is_orchestrator, true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_agents_default_orchestrator_is_oldest_agent_when_none_designated() {
|
||||
let a1 = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
let a2 = scratch_agent(aid(2), "Frontend", "agents/frontend.md", pid(9));
|
||||
let contexts = FakeContexts::with_agent(&a1, "ctx1");
|
||||
contexts
|
||||
.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.manifest
|
||||
.entries
|
||||
.push(ManifestEntry::from_agent(&a2));
|
||||
let list = ListAgents::new(Arc::new(contexts));
|
||||
|
||||
let out = list
|
||||
.execute(ListAgentsInput { project: project() })
|
||||
.await
|
||||
.unwrap();
|
||||
let entries = out.discovery_entries();
|
||||
|
||||
assert_eq!(out.effective_orchestrator, Some(a1.id));
|
||||
assert_eq!(entries[0].is_orchestrator, true);
|
||||
assert_eq!(entries[1].is_orchestrator, false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_then_update_context_roundtrips() {
|
||||
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
@ -895,6 +953,69 @@ async fn read_then_update_context_roundtrips() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_agent_effort_sets_preset_selection_and_persists_manifest() {
|
||||
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
let contexts = FakeContexts::with_agent(&a, "ctx");
|
||||
let update = UpdateAgentEffort::new(Arc::new(contexts.clone()));
|
||||
|
||||
let out = update
|
||||
.execute(UpdateAgentEffortInput {
|
||||
project: project(),
|
||||
agent_id: a.id,
|
||||
effort: Some(EffortSelection::Preset("high".to_owned())),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
out.agent.effort,
|
||||
Some(EffortSelection::Preset("high".to_owned()))
|
||||
);
|
||||
assert_eq!(
|
||||
contexts.manifest().entries[0].effort,
|
||||
Some(EffortSelection::Preset("high".to_owned()))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_agent_effort_none_clears_existing_override() {
|
||||
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9))
|
||||
.with_effort(Some(EffortSelection::Custom("x-deep".to_owned())));
|
||||
let contexts = FakeContexts::with_agent(&a, "ctx");
|
||||
let update = UpdateAgentEffort::new(Arc::new(contexts.clone()));
|
||||
|
||||
let out = update
|
||||
.execute(UpdateAgentEffortInput {
|
||||
project: project(),
|
||||
agent_id: a.id,
|
||||
effort: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.agent.effort, None);
|
||||
assert_eq!(contexts.manifest().entries[0].effort, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_agent_effort_not_found_for_unknown_agent_id() {
|
||||
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
let contexts = FakeContexts::with_agent(&a, "ctx");
|
||||
let update = UpdateAgentEffort::new(Arc::new(contexts));
|
||||
|
||||
let err = update
|
||||
.execute(UpdateAgentEffortInput {
|
||||
project: project(),
|
||||
agent_id: aid(404),
|
||||
effort: Some(EffortSelection::Preset("medium".to_owned())),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_removes_entry_then_unknown_is_not_found() {
|
||||
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
@ -965,6 +1086,15 @@ fn launch_fixture_with_profile_and_recall(
|
||||
recall: FakeRecall,
|
||||
) -> LaunchFixture {
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id);
|
||||
launch_fixture_with_profile_agent_and_recall(profile, agent, plan, recall)
|
||||
}
|
||||
|
||||
fn launch_fixture_with_profile_agent_and_recall(
|
||||
profile: AgentProfile,
|
||||
agent: Agent,
|
||||
plan: Option<ContextInjectionPlan>,
|
||||
recall: FakeRecall,
|
||||
) -> LaunchFixture {
|
||||
let contexts = FakeContexts::with_agent(&agent, "# ctx body");
|
||||
let profiles = FakeProfiles::new(vec![profile]);
|
||||
let tr = trace();
|
||||
@ -1127,6 +1257,63 @@ async fn structured_profile_with_factory_routes_to_structured_session_without_pt
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_agent_structured_forwards_resolved_effort_ahead_of_profile_default() {
|
||||
let profile = profile(
|
||||
pid(9),
|
||||
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||
)
|
||||
.with_structured_adapter(StructuredAdapter::Codex)
|
||||
.with_model_reasoning_effort("low");
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id)
|
||||
.with_effort(Some(EffortSelection::Preset("high".to_owned())));
|
||||
let (launch, agent, _fs, pty, _bus, _sessions, tr, _session) =
|
||||
launch_fixture_with_profile_agent_and_recall(
|
||||
profile,
|
||||
agent,
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
FakeRecall::default(),
|
||||
);
|
||||
let factory = FakeStructuredFactory::new(Arc::clone(&tr), sid(888));
|
||||
let structured = Arc::new(StructuredSessions::new());
|
||||
let launch = launch
|
||||
.with_structured_routing_mode(StructuredRoutingMode::RequireStructured)
|
||||
.with_structured(Arc::new(factory.clone()), structured);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.unwrap();
|
||||
|
||||
assert!(pty.spawns().is_empty());
|
||||
assert_eq!(factory.efforts(), vec![Some("high".to_owned())]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_agent_structured_falls_back_to_profile_default_when_agent_has_no_override() {
|
||||
let profile = profile(
|
||||
pid(9),
|
||||
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||
)
|
||||
.with_structured_adapter(StructuredAdapter::Codex)
|
||||
.with_model_reasoning_effort("low");
|
||||
let (launch, agent, _fs, pty, _bus, _sessions, tr, _session) = launch_fixture_with_profile(
|
||||
profile,
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
let factory = FakeStructuredFactory::new(Arc::clone(&tr), sid(888));
|
||||
let structured = Arc::new(StructuredSessions::new());
|
||||
let launch = launch
|
||||
.with_structured_routing_mode(StructuredRoutingMode::RequireStructured)
|
||||
.with_structured(Arc::new(factory.clone()), structured);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.unwrap();
|
||||
|
||||
assert!(pty.spawns().is_empty());
|
||||
assert_eq!(factory.efforts(), vec![Some("low".to_owned())]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn structured_profile_without_factory_require_structured_errors_without_pty_spawn() {
|
||||
let profile = profile(
|
||||
@ -3264,6 +3451,17 @@ fn launch_with_projection_and_env(
|
||||
env: Vec<(String, String)>,
|
||||
) -> (LaunchAgent, Agent, FakeFs, FakePty, Arc<TerminalSessions>) {
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id);
|
||||
launch_with_projection_agent_and_env(profile, agent, plan, registry, perm_doc, env)
|
||||
}
|
||||
|
||||
fn launch_with_projection_agent_and_env(
|
||||
profile: AgentProfile,
|
||||
agent: Agent,
|
||||
plan: Option<ContextInjectionPlan>,
|
||||
registry: Option<Arc<PermissionProjectorRegistry>>,
|
||||
perm_doc: Option<ProjectPermissions>,
|
||||
env: Vec<(String, String)>,
|
||||
) -> (LaunchAgent, Agent, FakeFs, FakePty, Arc<TerminalSessions>) {
|
||||
let contexts = FakeContexts::with_agent(&agent, "# ctx body");
|
||||
let profiles = FakeProfiles::new(vec![profile]);
|
||||
let tr = trace();
|
||||
@ -3896,6 +4094,40 @@ async fn codex_pty_launch_forwards_profile_model_as_config_override() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_agent_pty_codex_overrides_use_resolved_effort() {
|
||||
let profile = codex_profile()
|
||||
.with_projector(ProjectorKey::Codex)
|
||||
.with_model_reasoning_effort("low");
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id)
|
||||
.with_effort(Some(EffortSelection::Custom("x-deep".to_owned())));
|
||||
let (launch, agent, _fs, pty, _s) = launch_with_projection_agent_and_env(
|
||||
profile,
|
||||
agent,
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "AGENTS.md".to_owned(),
|
||||
}),
|
||||
Some(full_registry()),
|
||||
None,
|
||||
Vec::new(),
|
||||
);
|
||||
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch");
|
||||
|
||||
let args = &pty.spawns()[0].args;
|
||||
assert!(
|
||||
args.windows(2).any(|w| w
|
||||
== [
|
||||
"-c".to_owned(),
|
||||
"model_reasoning_effort=\"x-deep\"".to_owned()
|
||||
]),
|
||||
"PTY Codex launch must forward the resolved per-agent effort, got {args:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- (5) MCP decoupling — THE key case of the lot ---------------------------
|
||||
|
||||
/// (5) A Codex profile with **no MCP capability** still gets its sandbox projected
|
||||
|
||||
@ -9,7 +9,10 @@ use domain::ids::{AgentId, ProjectId};
|
||||
use domain::ports::{PermissionStore, StoreError};
|
||||
use domain::project::{Project, ProjectPath};
|
||||
use domain::remote::RemoteRef;
|
||||
use domain::{PermissionSet, Posture, ProjectPermissions};
|
||||
use domain::{
|
||||
Capability, Effect, PermissionRule, PermissionSet, PermissionShadowReport, Posture,
|
||||
ProjectPermissions,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakePermissionStore {
|
||||
@ -125,4 +128,69 @@ async fn resolve_agent_permissions_returns_effective_policy() {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.effective.unwrap().fallback(), Posture::Ask);
|
||||
assert_eq!(out.shadowed, PermissionShadowReport::default());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_agent_permissions_reports_shadowed_alongside_unchanged_effective() {
|
||||
let agent = AgentId::new_random();
|
||||
let store = Arc::new(FakePermissionStore {
|
||||
doc: Mutex::new(ProjectPermissions::new(
|
||||
Some(PermissionSet::new(
|
||||
vec![PermissionRule::bash(Effect::Deny, vec![])],
|
||||
Posture::Ask,
|
||||
)),
|
||||
vec![domain::AgentPermissionOverride::new(
|
||||
agent,
|
||||
PermissionSet::new(
|
||||
vec![PermissionRule::bash(Effect::Allow, vec![])],
|
||||
Posture::Ask,
|
||||
),
|
||||
)],
|
||||
)),
|
||||
saves: Mutex::new(0),
|
||||
});
|
||||
let use_case = ResolveAgentPermissions::new(store);
|
||||
|
||||
let out = use_case
|
||||
.execute(ResolveAgentPermissionsInput {
|
||||
project: project(),
|
||||
agent_id: agent,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(out.shadowed.execute_bash);
|
||||
assert_eq!(out.effective.unwrap().decide_bash("ls"), Posture::Deny);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_agent_permissions_shadowed_defaults_when_no_agent_override() {
|
||||
let agent = AgentId::new_random();
|
||||
let store = Arc::new(FakePermissionStore {
|
||||
doc: Mutex::new(ProjectPermissions::new(
|
||||
Some(PermissionSet::new(
|
||||
vec![PermissionRule::file(
|
||||
Capability::Read,
|
||||
Effect::Deny,
|
||||
domain::PathScope::new(["**".to_owned()]).unwrap(),
|
||||
)
|
||||
.unwrap()],
|
||||
Posture::Deny,
|
||||
)),
|
||||
vec![],
|
||||
)),
|
||||
saves: Mutex::new(0),
|
||||
});
|
||||
let use_case = ResolveAgentPermissions::new(store);
|
||||
|
||||
let out = use_case
|
||||
.execute(ResolveAgentPermissionsInput {
|
||||
project: project(),
|
||||
agent_id: agent,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.shadowed, PermissionShadowReport::default());
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@ use domain::markdown::MarkdownDoc;
|
||||
use domain::ports::{
|
||||
AgentContextStore, EventBus, EventStream, IdGenerator, SkillStore, StoreError,
|
||||
};
|
||||
use domain::skill::{Skill, SkillScope};
|
||||
use domain::skill::{Skill, SkillKind, SkillScope};
|
||||
use domain::{AgentManifest, ManifestEntry, Project, ProjectPath, RemoteRef, SkillRef};
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -196,6 +196,7 @@ async fn create_skill_persists_in_its_scope() {
|
||||
.execute(CreateSkillInput {
|
||||
name: "refactor".to_owned(),
|
||||
description: Some("Refactors code".to_owned()),
|
||||
kind: SkillKind::Reference,
|
||||
content: "# body".to_owned(),
|
||||
scope: SkillScope::Project,
|
||||
project_root: root(),
|
||||
@ -204,6 +205,7 @@ async fn create_skill_persists_in_its_scope() {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.skill.scope, SkillScope::Project);
|
||||
assert_eq!(out.skill.kind, SkillKind::Reference);
|
||||
// The one-line affordance description flows through the use case onto the skill.
|
||||
assert_eq!(out.skill.description.as_deref(), Some("Refactors code"));
|
||||
assert_eq!(
|
||||
@ -228,6 +230,7 @@ async fn create_skill_rejects_empty_content() {
|
||||
.execute(CreateSkillInput {
|
||||
name: "k".to_owned(),
|
||||
description: None,
|
||||
kind: SkillKind::Workflow,
|
||||
content: String::new(),
|
||||
scope: SkillScope::Global,
|
||||
project_root: root(),
|
||||
|
||||
Reference in New Issue
Block a user