Compare commits
1 Commits
2332b7f815
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 3be55795a6 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -24,6 +24,9 @@ frontend/coverage/
|
|||||||
# ─── Claude Code ────────────────────────────────────────────────────────────
|
# ─── Claude Code ────────────────────────────────────────────────────────────
|
||||||
# Personal, machine-local overrides (shared settings.json, if any, stays tracked).
|
# Personal, machine-local overrides (shared settings.json, if any, stays tracked).
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
# Ephemeral git worktrees created by Claude Code's isolated sub-agents — dev
|
||||||
|
# tooling only, unrelated to IdeA (which stays git-independent).
|
||||||
|
.claude/worktrees/
|
||||||
|
|
||||||
# ─── IdeA project data ──────────────────────────────────────────────────────
|
# ─── IdeA project data ──────────────────────────────────────────────────────
|
||||||
# Ephemeral per-agent run directories (isolated PTY cwd + generated convention
|
# Ephemeral per-agent run directories (isolated PTY cwd + generated convention
|
||||||
|
|||||||
@ -18,6 +18,7 @@
|
|||||||
"type": "leaf",
|
"type": "leaf",
|
||||||
"node": {
|
"node": {
|
||||||
"id": "d8a86eb1-cd4d-4937-b900-4989da7c868d",
|
"id": "d8a86eb1-cd4d-4937-b900-4989da7c868d",
|
||||||
|
"session": "2e6293fe-250a-4654-a8b2-4fe6996f8288",
|
||||||
"agent": "a6ced819-b893-4213-b003-9e9dc79b9641"
|
"agent": "a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -27,7 +28,8 @@
|
|||||||
"node": {
|
"node": {
|
||||||
"type": "leaf",
|
"type": "leaf",
|
||||||
"node": {
|
"node": {
|
||||||
"id": "6c5be5e7-a54b-468c-a2e2-8ec853629d5e"
|
"id": "6c5be5e7-a54b-468c-a2e2-8ec853629d5e",
|
||||||
|
"session": "9aa5859c-5868-46b3-a045-68cbea6eb629"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"weight": 1.0
|
"weight": 1.0
|
||||||
|
|||||||
@ -35,8 +35,31 @@ Modéliser les **Skills** : workflows réutilisables (équivalent universel des
|
|||||||
- **`Agent`** étendu : champ `skills: Vec<SkillRef>` (serde `default`), méthodes `assign_skill` (idempotent), `unassign_skill`, `with_skills` (dédup). **`ManifestEntry`** : champ `skills` (serde `default` + `skip_serializing_if` → rétrocompat des manifests pré-L12) ; `from_agent`/`to_agent` préservent les skills.
|
- **`Agent`** étendu : champ `skills: Vec<SkillRef>` (serde `default`), méthodes `assign_skill` (idempotent), `unassign_skill`, `with_skills` (dédup). **`ManifestEntry`** : champ `skills` (serde `default` + `skip_serializing_if` → rétrocompat des manifests pré-L12) ; `from_agent`/`to_agent` préservent les skills.
|
||||||
- **Tests** : 8 invariants (`entities.rs`) + 3 serde dont rétrocompat d'un manifest legacy sans clé `skills` (`serde_roundtrip.rs`). `cargo test -p domain` vert ; `cargo test --workspace` vert (0 régression) ; clippy clean.
|
- **Tests** : 8 invariants (`entities.rs`) + 3 serde dont rétrocompat d'un manifest legacy sans clé `skills` (`serde_roundtrip.rs`). `cargo test -p domain` vert ; `cargo test --workspace` vert (0 régression) ; clippy clean.
|
||||||
|
|
||||||
|
### ✅ Port + adapter store (vert)
|
||||||
|
- **Port `SkillStore`** (`domain/ports.rs`) : `list/get/save/delete` portant `scope` + `root: &ProjectPath` **par appel** (root ignoré pour `Global`, résolu pour `Project`) — un seul store sert tous les projets ouverts, comme `AgentContextStore`.
|
||||||
|
- **Adapter `FsSkillStore`** (`infrastructure/store/skill.rs`) : même forme on-disk que `FsTemplateStore` (`index.json` + `md/<id>.md`), deux racines disjointes : `<app_data>/skills/` (Global) et `<root>/.ideai/skills/` (Project). Delete laisse l'orphelin md (pas de remove dans le port FS), index = source de vérité. **7 tests** d'intégration tmpdir (`skill_store.rs`) : round-trip 2 scopes, **isolation de scope**, upsert, delete idempotent, camelCase.
|
||||||
|
|
||||||
|
### ✅ Use cases application (vert)
|
||||||
|
- `application/skill` : `CreateSkill`, `UpdateSkill`, `DeleteSkill`, `ListSkills(scope)` (inputs portant `project_root`), `AssignSkillToAgent` / `UnassignSkillFromAgent` (mutent l'`AgentManifest` via `to_agent`/`from_agent`, dédup, émettent `SkillAssigned`, **idempotents**). **9 tests** (`skill_usecases.rs`).
|
||||||
|
|
||||||
|
### ✅ Injection dans le convention file (vert, fil L6)
|
||||||
|
- `LaunchAgent` reçoit le port `SkillStore` ; `resolve_skills` lit les `.md` des skills assignés (ordre manifest, déterministe ; skill supprimé = `SkillRef` pendant → ignoré sans bloquer le lancement).
|
||||||
|
- `compose_convention_file` étendu : section `# Skills` (sous-titres `## <name>`) après le persona ; omise si aucun skill. **3 tests** unitaires + e2e (`agent_lifecycle.rs` : injection ordonnée, ref pendant tolérée).
|
||||||
|
- **Composition root** (`app-tauri/state.rs`) : `FsSkillStore` construit (app-data global), injecté dans `LaunchAgent`.
|
||||||
|
|
||||||
|
### ✅ IPC `app-tauri` (vert)
|
||||||
|
- **DTOs** (`dto.rs`) : `SkillDto` (transparent sur `Skill`, camelCase), `SkillListDto`, request DTOs (`Create/Update/Assign/UnassignSkillRequestDto`), `parse_skill_id`. `scope` désérialise directement vers `SkillScope` (`"global"`/`"project"`).
|
||||||
|
- **Commandes** (`commands.rs`) : `create_skill`, `update_skill`, `list_skills`, `delete_skill`, `assign_skill_to_agent`, `unassign_skill_from_agent` — shells fins qui résolvent le `Project` (→ `project.root`) puis appellent le use case. Enregistrées dans `lib.rs`.
|
||||||
|
- **Composition root** (`state.rs`) : 6 use cases skill câblés sur le `skill_store_port` (déjà construit pour le launcher) et le `contexts_port` partagé.
|
||||||
|
- `cargo build -p app-tauri` + `cargo test --workspace` (304) verts ; clippy clean.
|
||||||
|
|
||||||
|
### ✅ Front `features/skills` (vert)
|
||||||
|
- **Domaine** (`domain/index.ts`) : `SkillScope`, `Skill`, `SkillRef` ; `Agent` étendu avec `skills: SkillRef[]`.
|
||||||
|
- **Port** (`ports/index.ts`) : `SkillGateway` (list/create/update/delete + assign/unassign) + `CreateSkillInput` ; ajouté à `Gateways`.
|
||||||
|
- **Adapters** : `TauriSkillGateway` (`adapters/skill.ts`, invoke camelCase) ; `MockSkillGateway` (`adapters/mock`, scopes disjoints + mutation partagée du `MockAgentGateway` via `_setSkills`, assign idempotent).
|
||||||
|
- **Feature** : `useSkills` (VM 2 scopes), `SkillEditor` (overlay md edit/preview + sélecteur de scope), `SkillsPanel` (listes Project/Global, CRUD). Onglet **Skills** ajouté dans `ProjectsView`.
|
||||||
|
- **Assignation** dans `AgentsPanel` : chips des skills assignés + sélecteur d'assignation + unassign, sur l'agent sélectionné ; refresh après mutation.
|
||||||
|
- **Tests** (`skills.test.tsx`, RTL via `DIProvider` + mocks) : CRUD project/global, isolation de scope, édition, suppression, assign/unassign reflétés sur l'agent, idempotence, **garde-fou « no direct invoke »** (aucune action run/launch). `vitest` : **229** verts (0 régression ; test « ten gateways » mis à jour).
|
||||||
|
|
||||||
### ⏳ Reste à faire
|
### ⏳ Reste à faire
|
||||||
- Port `SkillStore` (`domain/ports.rs`) + adapter `FsSkillStore` (`infrastructure/store`).
|
- Cycle manuel : créer un skill, l'assigner à un agent, l'activer → vérifier qu'il apparaît dans le convention file de `.ideai/run/<agent-id>/` (à faire sur l'AppImage).
|
||||||
- Use cases `application/skill` : CRUD + `AssignSkillToAgent`/`UnassignSkillFromAgent`.
|
|
||||||
- Injection des skills assignés dans le convention file à l'activation (fil L6).
|
|
||||||
- IPC `app-tauri` + front `features/skills`.
|
|
||||||
|
|||||||
@ -14,6 +14,8 @@ use application::{
|
|||||||
GitStatusInput, LaunchAgentInput, ListAgentsInput, ListLayoutsInput, LoadLayoutInput,
|
GitStatusInput, LaunchAgentInput, ListAgentsInput, ListLayoutsInput, LoadLayoutInput,
|
||||||
MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, RenameLayoutInput,
|
MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, RenameLayoutInput,
|
||||||
SetActiveLayoutInput, SyncAgentWithTemplateInput, UpdateAgentContextInput,
|
SetActiveLayoutInput, SyncAgentWithTemplateInput, UpdateAgentContextInput,
|
||||||
|
AssignSkillToAgentInput, CreateSkillInput, DeleteSkillInput, ListSkillsInput,
|
||||||
|
UnassignSkillFromAgentInput, UpdateSkillInput,
|
||||||
};
|
};
|
||||||
use domain::ports::PtyHandle;
|
use domain::ports::PtyHandle;
|
||||||
|
|
||||||
@ -31,8 +33,10 @@ use crate::dto::{
|
|||||||
ReattachResultDto, RenameLayoutRequestDto, ResizeTerminalRequestDto, SaveProfileRequestDto,
|
ReattachResultDto, RenameLayoutRequestDto, ResizeTerminalRequestDto, SaveProfileRequestDto,
|
||||||
SetActiveLayoutRequestDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
|
SetActiveLayoutRequestDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
|
||||||
TemplateListDto, TerminalClosedDto, TerminalSessionDto, UpdateAgentContextRequestDto,
|
TemplateListDto, TerminalClosedDto, TerminalSessionDto, UpdateAgentContextRequestDto,
|
||||||
UpdateTemplateRequestDto, WriteTerminalRequestDto,
|
UpdateTemplateRequestDto, WriteTerminalRequestDto, parse_skill_id, AssignSkillRequestDto,
|
||||||
|
CreateSkillRequestDto, SkillDto, SkillListDto, UnassignSkillRequestDto, UpdateSkillRequestDto,
|
||||||
};
|
};
|
||||||
|
use domain::{SkillRef, SkillScope};
|
||||||
use crate::pty::{PtyBridge, PtyChunk};
|
use crate::pty::{PtyBridge, PtyChunk};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@ -1182,3 +1186,157 @@ pub async fn move_tab_to_new_window(
|
|||||||
|
|
||||||
Ok(MoveTabResultDto::from(out))
|
Ok(MoveTabResultDto::from(out))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Skills (L12)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// `create_skill` — create a skill in its scope's store.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an [`ErrorDto`] (`INVALID` for an empty name/content or malformed
|
||||||
|
/// project id, `NOT_FOUND` if the project is unknown, `STORE` on failure).
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn create_skill(
|
||||||
|
request: CreateSkillRequestDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<SkillDto, ErrorDto> {
|
||||||
|
let project = resolve_project(&request.project_id, &state).await?;
|
||||||
|
state
|
||||||
|
.create_skill
|
||||||
|
.execute(CreateSkillInput {
|
||||||
|
name: request.name,
|
||||||
|
content: request.content,
|
||||||
|
scope: request.scope,
|
||||||
|
project_root: project.root,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map(SkillDto::from)
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `update_skill` — replace a skill's Markdown content.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an [`ErrorDto`] (`INVALID` for malformed ids or empty content,
|
||||||
|
/// `NOT_FOUND` if the project or skill is unknown, `STORE` on failure).
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_skill(
|
||||||
|
request: UpdateSkillRequestDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<SkillDto, ErrorDto> {
|
||||||
|
let project = resolve_project(&request.project_id, &state).await?;
|
||||||
|
let skill_id = parse_skill_id(&request.skill_id)?;
|
||||||
|
state
|
||||||
|
.update_skill
|
||||||
|
.execute(UpdateSkillInput {
|
||||||
|
scope: request.scope,
|
||||||
|
skill_id,
|
||||||
|
content: request.content,
|
||||||
|
project_root: project.root,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map(SkillDto::from)
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `list_skills` — list the skills in one scope.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
|
||||||
|
/// project is unknown, `STORE` on failure).
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_skills(
|
||||||
|
project_id: String,
|
||||||
|
scope: SkillScope,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<SkillListDto, ErrorDto> {
|
||||||
|
let project = resolve_project(&project_id, &state).await?;
|
||||||
|
state
|
||||||
|
.list_skills
|
||||||
|
.execute(ListSkillsInput {
|
||||||
|
scope,
|
||||||
|
project_root: project.root,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map(SkillListDto::from)
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `delete_skill` — remove a skill from its scope's store.
|
||||||
|
///
|
||||||
|
/// Agents that referenced it keep their `SkillRef`; injection simply skips the
|
||||||
|
/// now-absent skill.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an [`ErrorDto`] (`INVALID` for malformed ids, `NOT_FOUND` if the
|
||||||
|
/// project or skill is unknown, `STORE` on failure).
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_skill(
|
||||||
|
project_id: String,
|
||||||
|
scope: SkillScope,
|
||||||
|
skill_id: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), ErrorDto> {
|
||||||
|
let project = resolve_project(&project_id, &state).await?;
|
||||||
|
let id = parse_skill_id(&skill_id)?;
|
||||||
|
state
|
||||||
|
.delete_skill
|
||||||
|
.execute(DeleteSkillInput {
|
||||||
|
scope,
|
||||||
|
skill_id: id,
|
||||||
|
project_root: project.root,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `assign_skill_to_agent` — record a `SkillRef` in the agent's manifest entry.
|
||||||
|
/// Idempotent.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an [`ErrorDto`] (`INVALID` for malformed ids, `NOT_FOUND` if the
|
||||||
|
/// project or agent is unknown, `STORE` on failure).
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn assign_skill_to_agent(
|
||||||
|
request: AssignSkillRequestDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), ErrorDto> {
|
||||||
|
let project = resolve_project(&request.project_id, &state).await?;
|
||||||
|
let agent_id = parse_agent_id(&request.agent_id)?;
|
||||||
|
let skill_id = parse_skill_id(&request.skill_id)?;
|
||||||
|
state
|
||||||
|
.assign_skill
|
||||||
|
.execute(AssignSkillToAgentInput {
|
||||||
|
project,
|
||||||
|
agent_id,
|
||||||
|
skill: SkillRef::new(skill_id, request.scope),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `unassign_skill_from_agent` — drop a skill assignment from an agent.
|
||||||
|
/// Idempotent.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an [`ErrorDto`] (`INVALID` for malformed ids, `NOT_FOUND` if the
|
||||||
|
/// project or agent is unknown, `STORE` on failure).
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn unassign_skill_from_agent(
|
||||||
|
request: UnassignSkillRequestDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), ErrorDto> {
|
||||||
|
let project = resolve_project(&request.project_id, &state).await?;
|
||||||
|
let agent_id = parse_agent_id(&request.agent_id)?;
|
||||||
|
let skill_id = parse_skill_id(&request.skill_id)?;
|
||||||
|
state
|
||||||
|
.unassign_skill
|
||||||
|
.execute(UnassignSkillFromAgentInput {
|
||||||
|
project,
|
||||||
|
agent_id,
|
||||||
|
skill_id,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|||||||
@ -1342,3 +1342,107 @@ pub fn parse_tab_id(raw: &str) -> Result<TabId, ErrorDto> {
|
|||||||
message: format!("invalid tab id: {raw}"),
|
message: format!("invalid tab id: {raw}"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Skills (L12)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
use application::{CreateSkillOutput, ListSkillsOutput, UpdateSkillOutput};
|
||||||
|
use domain::{Skill, SkillId, SkillScope};
|
||||||
|
|
||||||
|
/// A skill crossing the wire. [`Skill`] already serialises camelCase
|
||||||
|
/// (`id`, `name`, `contentMd`, `scope` as `"global"`/`"project"`), so we embed
|
||||||
|
/// it directly — the TS mirror matches this shape.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct SkillDto(pub Skill);
|
||||||
|
|
||||||
|
/// A list of skills (transparent array on the wire).
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct SkillListDto(pub Vec<SkillDto>);
|
||||||
|
|
||||||
|
impl From<ListSkillsOutput> for SkillListDto {
|
||||||
|
fn from(out: ListSkillsOutput) -> Self {
|
||||||
|
Self(out.skills.into_iter().map(SkillDto).collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CreateSkillOutput> for SkillDto {
|
||||||
|
fn from(out: CreateSkillOutput) -> Self {
|
||||||
|
Self(out.skill)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<UpdateSkillOutput> for SkillDto {
|
||||||
|
fn from(out: UpdateSkillOutput) -> Self {
|
||||||
|
Self(out.skill)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request DTO for `create_skill`.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CreateSkillRequestDto {
|
||||||
|
/// Owning project (resolved to a root; ignored on disk for `Global`).
|
||||||
|
pub project_id: String,
|
||||||
|
/// Display name.
|
||||||
|
pub name: String,
|
||||||
|
/// Initial Markdown content.
|
||||||
|
pub content: String,
|
||||||
|
/// Scope the skill is created in.
|
||||||
|
pub scope: SkillScope,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request DTO for `update_skill`.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UpdateSkillRequestDto {
|
||||||
|
/// Owning project (resolved to a root; ignored on disk for `Global`).
|
||||||
|
pub project_id: String,
|
||||||
|
/// Id of the skill to update.
|
||||||
|
pub skill_id: String,
|
||||||
|
/// Scope the skill lives in.
|
||||||
|
pub scope: SkillScope,
|
||||||
|
/// New Markdown content.
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request DTO for `assign_skill_to_agent`.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AssignSkillRequestDto {
|
||||||
|
/// Owning project.
|
||||||
|
pub project_id: String,
|
||||||
|
/// Agent receiving the skill.
|
||||||
|
pub agent_id: String,
|
||||||
|
/// Skill to assign.
|
||||||
|
pub skill_id: String,
|
||||||
|
/// Scope of the skill (recorded alongside the ref).
|
||||||
|
pub scope: SkillScope,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request DTO for `unassign_skill_from_agent`.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UnassignSkillRequestDto {
|
||||||
|
/// Owning project.
|
||||||
|
pub project_id: String,
|
||||||
|
/// Agent losing the skill.
|
||||||
|
pub agent_id: String,
|
||||||
|
/// Skill to unassign.
|
||||||
|
pub skill_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a skill-id string (UUID) coming from the frontend.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID.
|
||||||
|
pub fn parse_skill_id(raw: &str) -> Result<SkillId, ErrorDto> {
|
||||||
|
uuid::Uuid::parse_str(raw)
|
||||||
|
.map(SkillId::from_uuid)
|
||||||
|
.map_err(|_| ErrorDto {
|
||||||
|
code: "INVALID".to_owned(),
|
||||||
|
message: format!("invalid skill id: {raw}"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -119,6 +119,12 @@ pub fn run() {
|
|||||||
commands::git_log,
|
commands::git_log,
|
||||||
commands::git_init,
|
commands::git_init,
|
||||||
commands::git_graph,
|
commands::git_graph,
|
||||||
|
commands::create_skill,
|
||||||
|
commands::update_skill,
|
||||||
|
commands::list_skills,
|
||||||
|
commands::delete_skill,
|
||||||
|
commands::assign_skill_to_agent,
|
||||||
|
commands::unassign_skill_from_agent,
|
||||||
commands::move_tab_to_new_window,
|
commands::move_tab_to_new_window,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
|
|||||||
@ -13,18 +13,19 @@ use application::{
|
|||||||
CreateAgentFromTemplate, CreateLayout, CreateProject, CreateTemplate, DeleteAgent,
|
CreateAgentFromTemplate, CreateLayout, CreateProject, CreateTemplate, DeleteAgent,
|
||||||
DeleteLayout, DeleteProfile, DeleteTemplate, DetectAgentDrift, DetectProfiles, FirstRunState,
|
DeleteLayout, DeleteProfile, DeleteTemplate, DetectAgentDrift, DetectProfiles, FirstRunState,
|
||||||
GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage,
|
GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage,
|
||||||
HealthUseCase, LaunchAgent, ListAgents, ListLayouts, ListProfiles, ListProjects, ListTemplates,
|
HealthUseCase, LaunchAgent, ListAgents, ListLayouts, ListProfiles, ListProjects, ListSkills,
|
||||||
LoadLayout, MoveTabToNewWindow, MutateLayout, OpenProject, OpenTerminal, ReadAgentContext,
|
ListTemplates, LoadLayout, MoveTabToNewWindow, MutateLayout, OpenProject, OpenTerminal,
|
||||||
ReferenceProfiles, RenameLayout, ResizeTerminal, SaveProfile, SetActiveLayout,
|
ReadAgentContext, ReferenceProfiles, RenameLayout, ResizeTerminal, SaveProfile, SetActiveLayout,
|
||||||
|
AssignSkillToAgent, CreateSkill, DeleteSkill, UnassignSkillFromAgent, UpdateSkill,
|
||||||
SyncAgentWithTemplate, TerminalSessions, UpdateAgentContext, UpdateTemplate, WriteToTerminal,
|
SyncAgentWithTemplate, TerminalSessions, UpdateAgentContext, UpdateTemplate, WriteToTerminal,
|
||||||
};
|
};
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AgentContextStore, AgentRuntime, Clock, EventBus, FileSystem, GitPort, IdGenerator,
|
AgentContextStore, AgentRuntime, Clock, EventBus, FileSystem, GitPort, IdGenerator,
|
||||||
ProcessSpawner, ProfileStore, ProjectStore, PtyPort, TemplateStore,
|
ProcessSpawner, ProfileStore, ProjectStore, PtyPort, SkillStore, TemplateStore,
|
||||||
};
|
};
|
||||||
|
|
||||||
use infrastructure::{
|
use infrastructure::{
|
||||||
CliAgentRuntime, FsProfileStore, FsProjectStore, FsTemplateStore, Git2Repository,
|
CliAgentRuntime, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore, Git2Repository,
|
||||||
IdeaiContextStore, LocalFileSystem, LocalProcessSpawner, PortablePtyAdapter, SystemClock,
|
IdeaiContextStore, LocalFileSystem, LocalProcessSpawner, PortablePtyAdapter, SystemClock,
|
||||||
TokioBroadcastEventBus, UuidGenerator,
|
TokioBroadcastEventBus, UuidGenerator,
|
||||||
};
|
};
|
||||||
@ -147,6 +148,19 @@ pub struct AppState {
|
|||||||
pub git_init: Arc<GitInit>,
|
pub git_init: Arc<GitInit>,
|
||||||
/// Return the commit graph for all local branches.
|
/// Return the commit graph for all local branches.
|
||||||
pub git_graph: Arc<GitGraph>,
|
pub git_graph: Arc<GitGraph>,
|
||||||
|
// --- Skills (L12) ---
|
||||||
|
/// Create a skill in a scope's store.
|
||||||
|
pub create_skill: Arc<CreateSkill>,
|
||||||
|
/// Update a skill's content.
|
||||||
|
pub update_skill: Arc<UpdateSkill>,
|
||||||
|
/// List skills in a scope.
|
||||||
|
pub list_skills: Arc<ListSkills>,
|
||||||
|
/// Delete a skill from its scope's store.
|
||||||
|
pub delete_skill: Arc<DeleteSkill>,
|
||||||
|
/// Assign a skill to an agent (records a `SkillRef`).
|
||||||
|
pub assign_skill: Arc<AssignSkillToAgent>,
|
||||||
|
/// Unassign a skill from an agent.
|
||||||
|
pub unassign_skill: Arc<UnassignSkillFromAgent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
@ -287,6 +301,17 @@ impl AppState {
|
|||||||
let contexts = Arc::new(IdeaiContextStore::new(Arc::clone(&fs_port)));
|
let contexts = Arc::new(IdeaiContextStore::new(Arc::clone(&fs_port)));
|
||||||
let contexts_port = Arc::clone(&contexts) as Arc<dyn AgentContextStore>;
|
let contexts_port = Arc::clone(&contexts) as Arc<dyn AgentContextStore>;
|
||||||
|
|
||||||
|
// --- Skill store (L12) ---
|
||||||
|
// Global skills live in the machine-local app-data dir; project skills are
|
||||||
|
// resolved per call from each project's `.ideai/` (so one store serves all
|
||||||
|
// open projects). Shared by the skill use cases and the agent launcher
|
||||||
|
// (assigned-skill injection into the convention file, §14.2).
|
||||||
|
let skill_store = Arc::new(FsSkillStore::new(
|
||||||
|
Arc::clone(&fs_port),
|
||||||
|
app_data_dir.to_string_lossy().into_owned(),
|
||||||
|
));
|
||||||
|
let skill_store_port = Arc::clone(&skill_store) as Arc<dyn SkillStore>;
|
||||||
|
|
||||||
let create_agent = Arc::new(CreateAgentFromScratch::new(
|
let create_agent = Arc::new(CreateAgentFromScratch::new(
|
||||||
Arc::clone(&contexts_port),
|
Arc::clone(&contexts_port),
|
||||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||||
@ -307,6 +332,7 @@ impl AppState {
|
|||||||
Arc::clone(&runtime_port),
|
Arc::clone(&runtime_port),
|
||||||
Arc::clone(&fs_port),
|
Arc::clone(&fs_port),
|
||||||
Arc::clone(&pty_port),
|
Arc::clone(&pty_port),
|
||||||
|
Arc::clone(&skill_store_port),
|
||||||
Arc::clone(&terminal_sessions),
|
Arc::clone(&terminal_sessions),
|
||||||
Arc::clone(&events_port),
|
Arc::clone(&events_port),
|
||||||
));
|
));
|
||||||
@ -364,6 +390,25 @@ impl AppState {
|
|||||||
let git_init = Arc::new(GitInit::new(Arc::clone(&git_port), Arc::clone(&events_port)));
|
let git_init = Arc::new(GitInit::new(Arc::clone(&git_port), Arc::clone(&events_port)));
|
||||||
let git_graph = Arc::new(GitGraph::new(Arc::clone(&git_port)));
|
let git_graph = Arc::new(GitGraph::new(Arc::clone(&git_port)));
|
||||||
|
|
||||||
|
// --- Skill use cases (L12) ---
|
||||||
|
// Reuse the skill store (built above for the launcher) and the shared
|
||||||
|
// agent context store for the agent↔skill assignment.
|
||||||
|
let create_skill = Arc::new(CreateSkill::new(
|
||||||
|
Arc::clone(&skill_store_port),
|
||||||
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||||
|
));
|
||||||
|
let update_skill = Arc::new(UpdateSkill::new(Arc::clone(&skill_store_port)));
|
||||||
|
let list_skills = Arc::new(ListSkills::new(Arc::clone(&skill_store_port)));
|
||||||
|
let delete_skill = Arc::new(DeleteSkill::new(Arc::clone(&skill_store_port)));
|
||||||
|
let assign_skill = Arc::new(AssignSkillToAgent::new(
|
||||||
|
Arc::clone(&contexts_port),
|
||||||
|
Arc::clone(&events_port),
|
||||||
|
));
|
||||||
|
let unassign_skill = Arc::new(UnassignSkillFromAgent::new(
|
||||||
|
Arc::clone(&contexts_port),
|
||||||
|
Arc::clone(&events_port),
|
||||||
|
));
|
||||||
|
|
||||||
// --- Windows (L10) ---
|
// --- Windows (L10) ---
|
||||||
let move_tab = Arc::new(MoveTabToNewWindow::new(
|
let move_tab = Arc::new(MoveTabToNewWindow::new(
|
||||||
Arc::clone(&store_port),
|
Arc::clone(&store_port),
|
||||||
@ -422,6 +467,12 @@ impl AppState {
|
|||||||
git_log,
|
git_log,
|
||||||
git_init,
|
git_init,
|
||||||
git_graph,
|
git_graph,
|
||||||
|
create_skill,
|
||||||
|
update_skill,
|
||||||
|
list_skills,
|
||||||
|
delete_skill,
|
||||||
|
assign_skill,
|
||||||
|
unassign_skill,
|
||||||
move_tab,
|
move_tab,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,11 +15,11 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, PreparedContext,
|
AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, PreparedContext,
|
||||||
ProfileStore, PtyPort, RemotePath, SpawnSpec,
|
ProfileStore, PtyPort, RemotePath, SkillStore, SpawnSpec, StoreError,
|
||||||
};
|
};
|
||||||
use domain::{
|
use domain::{
|
||||||
Agent, AgentId, AgentManifest, AgentOrigin, DomainEvent, ManifestEntry, MarkdownDoc, NodeId,
|
Agent, AgentId, AgentManifest, AgentOrigin, DomainEvent, ManifestEntry, MarkdownDoc, NodeId,
|
||||||
Project, ProfileId, ProjectPath, PtySize, SessionKind, SessionStatus, TerminalSession,
|
Project, ProfileId, ProjectPath, PtySize, SessionKind, SessionStatus, Skill, TerminalSession,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
@ -350,6 +350,7 @@ pub struct LaunchAgent {
|
|||||||
runtime: Arc<dyn AgentRuntime>,
|
runtime: Arc<dyn AgentRuntime>,
|
||||||
fs: Arc<dyn FileSystem>,
|
fs: Arc<dyn FileSystem>,
|
||||||
pty: Arc<dyn PtyPort>,
|
pty: Arc<dyn PtyPort>,
|
||||||
|
skills: Arc<dyn SkillStore>,
|
||||||
sessions: Arc<TerminalSessions>,
|
sessions: Arc<TerminalSessions>,
|
||||||
events: Arc<dyn EventBus>,
|
events: Arc<dyn EventBus>,
|
||||||
}
|
}
|
||||||
@ -364,6 +365,7 @@ impl LaunchAgent {
|
|||||||
runtime: Arc<dyn AgentRuntime>,
|
runtime: Arc<dyn AgentRuntime>,
|
||||||
fs: Arc<dyn FileSystem>,
|
fs: Arc<dyn FileSystem>,
|
||||||
pty: Arc<dyn PtyPort>,
|
pty: Arc<dyn PtyPort>,
|
||||||
|
skills: Arc<dyn SkillStore>,
|
||||||
sessions: Arc<TerminalSessions>,
|
sessions: Arc<TerminalSessions>,
|
||||||
events: Arc<dyn EventBus>,
|
events: Arc<dyn EventBus>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
@ -373,11 +375,35 @@ impl LaunchAgent {
|
|||||||
runtime,
|
runtime,
|
||||||
fs,
|
fs,
|
||||||
pty,
|
pty,
|
||||||
|
skills,
|
||||||
sessions,
|
sessions,
|
||||||
events,
|
events,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolves the Markdown bodies of an agent's assigned skills, in the
|
||||||
|
/// **manifest order** (deterministic). A skill that no longer exists in its
|
||||||
|
/// store (deleted out from under the assignment) is silently skipped — a
|
||||||
|
/// dangling [`domain::SkillRef`] must not block a launch.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`AppError::Store`] on any store failure other than a missing skill.
|
||||||
|
async fn resolve_skills(
|
||||||
|
&self,
|
||||||
|
agent: &Agent,
|
||||||
|
root: &ProjectPath,
|
||||||
|
) -> Result<Vec<Skill>, AppError> {
|
||||||
|
let mut out = Vec::with_capacity(agent.skills.len());
|
||||||
|
for skill_ref in &agent.skills {
|
||||||
|
match self.skills.get(skill_ref.scope, root, skill_ref.skill_id).await {
|
||||||
|
Ok(skill) => out.push(skill),
|
||||||
|
Err(StoreError::NotFound) => {}
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
/// Executes the launch.
|
/// Executes the launch.
|
||||||
///
|
///
|
||||||
/// Step order is contractually significant (and unit-tested): resolve the
|
/// Step order is contractually significant (and unit-tested): resolve the
|
||||||
@ -444,8 +470,10 @@ impl LaunchAgent {
|
|||||||
.runtime
|
.runtime
|
||||||
.prepare_invocation(&profile, &prepared, &run_dir)?;
|
.prepare_invocation(&profile, &prepared, &run_dir)?;
|
||||||
|
|
||||||
// 5. Apply the injection plan side effects *before* spawning.
|
// 5. Resolve the agent's assigned skills (their `.md` bodies), then apply
|
||||||
self.apply_injection(&input.project, &agent.context_path, &content, &mut spec)
|
// the injection plan side effects *before* spawning.
|
||||||
|
let skills = self.resolve_skills(&agent, &input.project.root).await?;
|
||||||
|
self.apply_injection(&input.project, &agent.context_path, &content, &skills, &mut spec)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// 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.
|
||||||
@ -487,6 +515,7 @@ impl LaunchAgent {
|
|||||||
project: &Project,
|
project: &Project,
|
||||||
context_rel_path: &str,
|
context_rel_path: &str,
|
||||||
content: &MarkdownDoc,
|
content: &MarkdownDoc,
|
||||||
|
skills: &[Skill],
|
||||||
spec: &mut SpawnSpec,
|
spec: &mut SpawnSpec,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
match spec.context_plan.clone() {
|
match spec.context_plan.clone() {
|
||||||
@ -496,9 +525,10 @@ impl LaunchAgent {
|
|||||||
// run directory — `spec.cwd` is that run dir, never the project
|
// run directory — `spec.cwd` is that run dir, never the project
|
||||||
// root, so there is zero collision between agents. The document is
|
// root, so there is zero collision between agents. The document is
|
||||||
// composed: an absolute project-root header (so the agent knows
|
// composed: an absolute project-root header (so the agent knows
|
||||||
// where to operate, since its cwd is *not* the root) followed by
|
// where to operate, since its cwd is *not* the root), the agent's
|
||||||
// the agent's persona `.md`.
|
// persona `.md`, then the bodies of its assigned skills (§14.2).
|
||||||
let document = compose_convention_file(project.root.as_str(), content.as_str());
|
let document =
|
||||||
|
compose_convention_file(project.root.as_str(), content.as_str(), skills);
|
||||||
let path = RemotePath::new(join(&spec.cwd, &target));
|
let path = RemotePath::new(join(&spec.cwd, &target));
|
||||||
self.fs.write(&path, document.as_bytes()).await?;
|
self.fs.write(&path, document.as_bytes()).await?;
|
||||||
}
|
}
|
||||||
@ -537,13 +567,17 @@ fn agent_run_dir(root: &ProjectPath, agent_id: &AgentId) -> Result<ProjectPath,
|
|||||||
|
|
||||||
/// 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) followed by the agent's persona `.md`.
|
/// so it must be told where to work), the agent's persona `.md`, then the bodies
|
||||||
|
/// of its assigned `skills` under a `# Skills` section (ARCHITECTURE §14.2).
|
||||||
///
|
///
|
||||||
/// Kept as a **pure** function (no I/O) so it is unit-testable in isolation, and
|
/// Skills are emitted in the order given (the caller passes them in manifest
|
||||||
/// deliberately structured so future blocks (assigned skills, shared project
|
/// order, making the output deterministic); each is introduced by a `##` header
|
||||||
/// context — ARCHITECTURE §14.2) can be appended without touching the launcher.
|
/// carrying its name. When `skills` is empty the section is omitted entirely, so
|
||||||
|
/// an agent with no skills gets exactly the previous document.
|
||||||
|
///
|
||||||
|
/// Kept as a **pure** function (no I/O) so it is unit-testable in isolation.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub(crate) fn compose_convention_file(project_root: &str, agent_md: &str) -> String {
|
pub(crate) fn compose_convention_file(project_root: &str, agent_md: &str, skills: &[Skill]) -> String {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
out.push_str("# Project root\n\n");
|
out.push_str("# Project root\n\n");
|
||||||
out.push_str(project_root);
|
out.push_str(project_root);
|
||||||
@ -554,6 +588,17 @@ pub(crate) fn compose_convention_file(project_root: &str, agent_md: &str) -> Str
|
|||||||
);
|
);
|
||||||
out.push_str("---\n\n");
|
out.push_str("---\n\n");
|
||||||
out.push_str(agent_md);
|
out.push_str(agent_md);
|
||||||
|
|
||||||
|
if !skills.is_empty() {
|
||||||
|
out.push_str("\n\n---\n\n# Skills\n");
|
||||||
|
for skill in skills {
|
||||||
|
out.push_str("\n## ");
|
||||||
|
out.push_str(&skill.name);
|
||||||
|
out.push_str("\n\n");
|
||||||
|
out.push_str(skill.content_md.as_str());
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -610,7 +655,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn compose_convention_file_carries_root_then_persona() {
|
fn compose_convention_file_carries_root_then_persona() {
|
||||||
let doc = compose_convention_file("/abs/project/root", "# Persona\n\nDo things.");
|
let doc = compose_convention_file("/abs/project/root", "# Persona\n\nDo things.", &[]);
|
||||||
|
|
||||||
// Absolute project root present.
|
// Absolute project root present.
|
||||||
assert!(doc.contains("/abs/project/root"));
|
assert!(doc.contains("/abs/project/root"));
|
||||||
@ -621,5 +666,38 @@ mod tests {
|
|||||||
let root_at = doc.find("/abs/project/root").unwrap();
|
let root_at = doc.find("/abs/project/root").unwrap();
|
||||||
let persona_at = doc.find("# Persona").unwrap();
|
let persona_at = doc.find("# Persona").unwrap();
|
||||||
assert!(root_at < persona_at, "root header must precede the persona");
|
assert!(root_at < persona_at, "root header must precede the persona");
|
||||||
|
// No skills ⇒ no Skills section.
|
||||||
|
assert!(!doc.contains("# Skills"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compose_convention_file_appends_assigned_skills_in_order() {
|
||||||
|
let s = |n: u128, name: &str, body: &str| {
|
||||||
|
Skill::new(
|
||||||
|
domain::SkillId::from_uuid(uuid::Uuid::from_u128(n)),
|
||||||
|
name,
|
||||||
|
MarkdownDoc::new(body),
|
||||||
|
domain::SkillScope::Global,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
let doc = compose_convention_file(
|
||||||
|
"/root",
|
||||||
|
"# Persona",
|
||||||
|
&[s(1, "refactor", "REFAC_BODY"), s(2, "review", "REVIEW_BODY")],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Both skill bodies present, after the persona.
|
||||||
|
assert!(doc.contains("REFAC_BODY"));
|
||||||
|
assert!(doc.contains("REVIEW_BODY"));
|
||||||
|
let persona_at = doc.find("# Persona").unwrap();
|
||||||
|
let refac_at = doc.find("REFAC_BODY").unwrap();
|
||||||
|
let review_at = doc.find("REVIEW_BODY").unwrap();
|
||||||
|
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"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,7 @@ pub mod health;
|
|||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod project;
|
pub mod project;
|
||||||
pub mod remote;
|
pub mod remote;
|
||||||
|
pub mod skill;
|
||||||
pub mod template;
|
pub mod template;
|
||||||
pub mod terminal;
|
pub mod terminal;
|
||||||
pub mod window;
|
pub mod window;
|
||||||
@ -61,6 +62,12 @@ pub use template::{
|
|||||||
SyncAgentWithTemplateInput, SyncAgentWithTemplateOutput, UpdateTemplate, UpdateTemplateInput,
|
SyncAgentWithTemplateInput, SyncAgentWithTemplateOutput, UpdateTemplate, UpdateTemplateInput,
|
||||||
UpdateTemplateOutput,
|
UpdateTemplateOutput,
|
||||||
};
|
};
|
||||||
|
pub use skill::{
|
||||||
|
AssignSkillToAgent, AssignSkillToAgentInput, CreateSkill, CreateSkillInput, CreateSkillOutput,
|
||||||
|
DeleteSkill, DeleteSkillInput, ListSkills, ListSkillsInput, ListSkillsOutput,
|
||||||
|
UnassignSkillFromAgent, UnassignSkillFromAgentInput, UpdateSkill, UpdateSkillInput,
|
||||||
|
UpdateSkillOutput,
|
||||||
|
};
|
||||||
pub use terminal::{
|
pub use terminal::{
|
||||||
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, OpenTerminal, OpenTerminalInput,
|
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, OpenTerminal, OpenTerminalInput,
|
||||||
OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, TerminalSessions, WriteToTerminal,
|
OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, TerminalSessions, WriteToTerminal,
|
||||||
|
|||||||
21
crates/application/src/skill/mod.rs
Normal file
21
crates/application/src/skill/mod.rs
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
//! Skill use cases (ARCHITECTURE §14.2; L12).
|
||||||
|
//!
|
||||||
|
//! Skills are reusable, model-agnostic workflows (IdeA's universal equivalent of
|
||||||
|
//! a CLI slash-command). This module owns their CRUD across both scopes
|
||||||
|
//! ([`domain::skill::SkillScope`]) and the agent↔skill assignment that records a
|
||||||
|
//! [`domain::skill::SkillRef`] in the project manifest. The actual injection of
|
||||||
|
//! an assigned skill's body into the generated convention file happens at agent
|
||||||
|
//! activation (L6).
|
||||||
|
//!
|
||||||
|
//! Every use case talks only to ports ([`domain::ports::SkillStore`],
|
||||||
|
//! [`domain::ports::AgentContextStore`], [`domain::ports::IdGenerator`],
|
||||||
|
//! [`domain::ports::EventBus`]).
|
||||||
|
|
||||||
|
mod usecases;
|
||||||
|
|
||||||
|
pub use usecases::{
|
||||||
|
AssignSkillToAgent, AssignSkillToAgentInput, CreateSkill, CreateSkillInput, CreateSkillOutput,
|
||||||
|
DeleteSkill, DeleteSkillInput, ListSkills, ListSkillsInput, ListSkillsOutput,
|
||||||
|
UnassignSkillFromAgent, UnassignSkillFromAgentInput, UpdateSkill, UpdateSkillInput,
|
||||||
|
UpdateSkillOutput,
|
||||||
|
};
|
||||||
339
crates/application/src/skill/usecases.rs
Normal file
339
crates/application/src/skill/usecases.rs
Normal file
@ -0,0 +1,339 @@
|
|||||||
|
//! Skill use cases (ARCHITECTURE §14.2; L12).
|
||||||
|
//!
|
||||||
|
//! - **CRUD** in either scope: [`CreateSkill`], [`UpdateSkill`], [`DeleteSkill`],
|
||||||
|
//! [`ListSkills`].
|
||||||
|
//! - **Assignment**: [`AssignSkillToAgent`] / [`UnassignSkillFromAgent`] mutate
|
||||||
|
//! the project manifest entry's `skills` and announce
|
||||||
|
//! [`DomainEvent::SkillAssigned`]. Both are idempotent.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::ports::{AgentContextStore, EventBus, IdGenerator, SkillStore};
|
||||||
|
use domain::{
|
||||||
|
AgentId, AgentManifest, DomainEvent, MarkdownDoc, Project, ProjectPath, Skill, SkillId,
|
||||||
|
SkillRef, SkillScope,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CreateSkill
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Input for [`CreateSkill::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CreateSkillInput {
|
||||||
|
/// Display name (also the `.md` stem on disk).
|
||||||
|
pub name: String,
|
||||||
|
/// Initial Markdown body.
|
||||||
|
pub content: String,
|
||||||
|
/// Scope the skill is created in (selects its backing store).
|
||||||
|
pub scope: SkillScope,
|
||||||
|
/// Active project root (used only for [`SkillScope::Project`]).
|
||||||
|
pub project_root: ProjectPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Output of [`CreateSkill::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CreateSkillOutput {
|
||||||
|
/// The created skill.
|
||||||
|
pub skill: Skill,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a skill in the store of its [`SkillScope`].
|
||||||
|
pub struct CreateSkill {
|
||||||
|
skills: Arc<dyn SkillStore>,
|
||||||
|
ids: Arc<dyn IdGenerator>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CreateSkill {
|
||||||
|
/// Builds the use case from its ports.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(skills: Arc<dyn SkillStore>, ids: Arc<dyn IdGenerator>) -> Self {
|
||||||
|
Self { skills, ids }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes creation.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// - [`AppError::Invalid`] if `name`/`content` is empty,
|
||||||
|
/// - [`AppError::Store`] on persistence failure.
|
||||||
|
pub async fn execute(&self, input: CreateSkillInput) -> Result<CreateSkillOutput, AppError> {
|
||||||
|
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()))?;
|
||||||
|
self.skills.save(&skill, &input.project_root).await?;
|
||||||
|
Ok(CreateSkillOutput { skill })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// UpdateSkill
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Input for [`UpdateSkill::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct UpdateSkillInput {
|
||||||
|
/// Scope the skill lives in.
|
||||||
|
pub scope: SkillScope,
|
||||||
|
/// Skill to update.
|
||||||
|
pub skill_id: SkillId,
|
||||||
|
/// New Markdown body.
|
||||||
|
pub content: String,
|
||||||
|
/// Active project root (used only for [`SkillScope::Project`]).
|
||||||
|
pub project_root: ProjectPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Output of [`UpdateSkill::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct UpdateSkillOutput {
|
||||||
|
/// The updated skill.
|
||||||
|
pub skill: Skill,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces a skill's content (re-validating the non-empty invariant).
|
||||||
|
pub struct UpdateSkill {
|
||||||
|
skills: Arc<dyn SkillStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpdateSkill {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(skills: Arc<dyn SkillStore>) -> Self {
|
||||||
|
Self { skills }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes the update.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// - [`AppError::NotFound`] if the skill is unknown in that scope,
|
||||||
|
/// - [`AppError::Invalid`] if the new content is empty,
|
||||||
|
/// - [`AppError::Store`] on persistence failure.
|
||||||
|
pub async fn execute(&self, input: UpdateSkillInput) -> Result<UpdateSkillOutput, AppError> {
|
||||||
|
let current = self
|
||||||
|
.skills
|
||||||
|
.get(input.scope, &input.project_root, input.skill_id)
|
||||||
|
.await?;
|
||||||
|
let updated = current
|
||||||
|
.with_content(MarkdownDoc::new(input.content))
|
||||||
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||||
|
self.skills.save(&updated, &input.project_root).await?;
|
||||||
|
Ok(UpdateSkillOutput { skill: updated })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ListSkills / DeleteSkill
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Input for [`ListSkills::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ListSkillsInput {
|
||||||
|
/// Scope to list.
|
||||||
|
pub scope: SkillScope,
|
||||||
|
/// Active project root (used only for [`SkillScope::Project`]).
|
||||||
|
pub project_root: ProjectPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Output of [`ListSkills::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ListSkillsOutput {
|
||||||
|
/// All skills in the requested scope.
|
||||||
|
pub skills: Vec<Skill>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists the skills in one scope.
|
||||||
|
pub struct ListSkills {
|
||||||
|
skills: Arc<dyn SkillStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListSkills {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(skills: Arc<dyn SkillStore>) -> Self {
|
||||||
|
Self { skills }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists skills in `input.scope`.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`AppError::Store`] on persistence failure.
|
||||||
|
pub async fn execute(&self, input: ListSkillsInput) -> Result<ListSkillsOutput, AppError> {
|
||||||
|
Ok(ListSkillsOutput {
|
||||||
|
skills: self.skills.list(input.scope, &input.project_root).await?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Input for [`DeleteSkill::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct DeleteSkillInput {
|
||||||
|
/// Scope the skill lives in.
|
||||||
|
pub scope: SkillScope,
|
||||||
|
/// Skill to delete.
|
||||||
|
pub skill_id: SkillId,
|
||||||
|
/// Active project root (used only for [`SkillScope::Project`]).
|
||||||
|
pub project_root: ProjectPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a skill from its scope's store.
|
||||||
|
///
|
||||||
|
/// Agents that referenced it keep their [`SkillRef`]; the injection step simply
|
||||||
|
/// finds nothing to resolve for the now-absent skill and skips it.
|
||||||
|
pub struct DeleteSkill {
|
||||||
|
skills: Arc<dyn SkillStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeleteSkill {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(skills: Arc<dyn SkillStore>) -> Self {
|
||||||
|
Self { skills }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes the skill.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// - [`AppError::NotFound`] if the skill is unknown in that scope,
|
||||||
|
/// - [`AppError::Store`] on persistence failure.
|
||||||
|
pub async fn execute(&self, input: DeleteSkillInput) -> Result<(), AppError> {
|
||||||
|
self.skills
|
||||||
|
.delete(input.scope, &input.project_root, input.skill_id)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AssignSkillToAgent / UnassignSkillFromAgent
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Input for [`AssignSkillToAgent::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct AssignSkillToAgentInput {
|
||||||
|
/// The owning project.
|
||||||
|
pub project: Project,
|
||||||
|
/// The agent receiving the skill.
|
||||||
|
pub agent_id: AgentId,
|
||||||
|
/// The skill to assign.
|
||||||
|
pub skill: SkillRef,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assigns a skill to an agent by recording a [`SkillRef`] in its manifest entry.
|
||||||
|
/// Idempotent: re-assigning the same skill is a no-op (no duplicate).
|
||||||
|
pub struct AssignSkillToAgent {
|
||||||
|
contexts: Arc<dyn AgentContextStore>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AssignSkillToAgent {
|
||||||
|
/// Builds the use case from its ports.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(contexts: Arc<dyn AgentContextStore>, events: Arc<dyn EventBus>) -> Self {
|
||||||
|
Self { contexts, events }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes the assignment.
|
||||||
|
///
|
||||||
|
/// # 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: AssignSkillToAgentInput) -> Result<(), AppError> {
|
||||||
|
let mut manifest = self.contexts.load_manifest(&input.project).await?;
|
||||||
|
let entry = manifest
|
||||||
|
.entries
|
||||||
|
.iter_mut()
|
||||||
|
.find(|e| e.agent_id == input.agent_id)
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
|
||||||
|
|
||||||
|
// Mutate through the domain entity so the dedup invariant is enforced in
|
||||||
|
// one place, then fold the result back into the manifest entry.
|
||||||
|
let mut agent = entry.to_agent().map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||||
|
let changed = agent.assign_skill(input.skill);
|
||||||
|
*entry = domain::ManifestEntry::from_agent(&agent);
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
self.persist_and_announce(&input.project, manifest, input.agent_id, input.skill.skill_id, true)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves the manifest and announces the assignment change.
|
||||||
|
async fn persist_and_announce(
|
||||||
|
&self,
|
||||||
|
project: &Project,
|
||||||
|
manifest: AgentManifest,
|
||||||
|
agent_id: AgentId,
|
||||||
|
skill_id: SkillId,
|
||||||
|
assigned: bool,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let manifest = AgentManifest::new(manifest.version, manifest.entries)
|
||||||
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||||
|
self.contexts.save_manifest(project, &manifest).await?;
|
||||||
|
self.events.publish(DomainEvent::SkillAssigned {
|
||||||
|
agent_id,
|
||||||
|
skill_id,
|
||||||
|
assigned,
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Input for [`UnassignSkillFromAgent::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct UnassignSkillFromAgentInput {
|
||||||
|
/// The owning project.
|
||||||
|
pub project: Project,
|
||||||
|
/// The agent losing the skill.
|
||||||
|
pub agent_id: AgentId,
|
||||||
|
/// The skill to unassign.
|
||||||
|
pub skill_id: SkillId,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a skill assignment from an agent. Idempotent: unassigning a skill the
|
||||||
|
/// agent does not carry is a no-op.
|
||||||
|
pub struct UnassignSkillFromAgent {
|
||||||
|
contexts: Arc<dyn AgentContextStore>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UnassignSkillFromAgent {
|
||||||
|
/// Builds the use case from its ports.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(contexts: Arc<dyn AgentContextStore>, events: Arc<dyn EventBus>) -> Self {
|
||||||
|
Self { contexts, events }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes the un-assignment.
|
||||||
|
///
|
||||||
|
/// # 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: UnassignSkillFromAgentInput) -> Result<(), AppError> {
|
||||||
|
let mut manifest = self.contexts.load_manifest(&input.project).await?;
|
||||||
|
let entry = manifest
|
||||||
|
.entries
|
||||||
|
.iter_mut()
|
||||||
|
.find(|e| e.agent_id == input.agent_id)
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
|
||||||
|
|
||||||
|
let mut agent = entry.to_agent().map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||||
|
let changed = agent.unassign_skill(input.skill_id);
|
||||||
|
*entry = domain::ManifestEntry::from_agent(&agent);
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
let manifest = AgentManifest::new(manifest.version, manifest.entries)
|
||||||
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||||
|
self.contexts.save_manifest(&input.project, &manifest).await?;
|
||||||
|
self.events.publish(DomainEvent::SkillAssigned {
|
||||||
|
agent_id: input.agent_id,
|
||||||
|
skill_id: input.skill_id,
|
||||||
|
assigned: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -26,12 +26,13 @@ use domain::markdown::MarkdownDoc;
|
|||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
|
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
|
||||||
ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore,
|
ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore,
|
||||||
PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SpawnSpec, StoreError,
|
PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SkillStore, SpawnSpec, StoreError,
|
||||||
};
|
};
|
||||||
use domain::profile::{AgentProfile, ContextInjection};
|
use domain::profile::{AgentProfile, ContextInjection};
|
||||||
use domain::project::{Project, ProjectPath};
|
use domain::project::{Project, ProjectPath};
|
||||||
use domain::remote::RemoteRef;
|
use domain::remote::RemoteRef;
|
||||||
use domain::{PtySize, SessionId};
|
use domain::skill::{Skill, SkillScope};
|
||||||
|
use domain::{PtySize, SessionId, SkillId, SkillRef};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
@ -177,6 +178,49 @@ impl ProfileStore for FakeProfiles {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// FakeSkills (SkillStore) — an in-memory store seeded with a few skills
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct FakeSkills(Arc<Vec<Skill>>);
|
||||||
|
|
||||||
|
impl FakeSkills {
|
||||||
|
fn with(skills: Vec<Skill>) -> Self {
|
||||||
|
Self(Arc::new(skills))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl SkillStore for FakeSkills {
|
||||||
|
async fn list(&self, scope: SkillScope, _root: &ProjectPath) -> Result<Vec<Skill>, StoreError> {
|
||||||
|
Ok(self.0.iter().filter(|s| s.scope == scope).cloned().collect())
|
||||||
|
}
|
||||||
|
async fn get(
|
||||||
|
&self,
|
||||||
|
scope: SkillScope,
|
||||||
|
_root: &ProjectPath,
|
||||||
|
id: SkillId,
|
||||||
|
) -> Result<Skill, StoreError> {
|
||||||
|
self.0
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.scope == scope && s.id == id)
|
||||||
|
.cloned()
|
||||||
|
.ok_or(StoreError::NotFound)
|
||||||
|
}
|
||||||
|
async fn save(&self, _skill: &Skill, _root: &ProjectPath) -> Result<(), StoreError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn delete(
|
||||||
|
&self,
|
||||||
|
_scope: SkillScope,
|
||||||
|
_root: &ProjectPath,
|
||||||
|
_id: SkillId,
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// FakeRuntime (AgentRuntime) — records prepare + returns a configured plan
|
// FakeRuntime (AgentRuntime) — records prepare + returns a configured plan
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -577,6 +621,7 @@ fn launch_fixture(injection: ContextInjection, plan: Option<ContextInjectionPlan
|
|||||||
Arc::new(runtime),
|
Arc::new(runtime),
|
||||||
Arc::new(fs.clone()),
|
Arc::new(fs.clone()),
|
||||||
Arc::new(pty.clone()),
|
Arc::new(pty.clone()),
|
||||||
|
Arc::new(FakeSkills::default()),
|
||||||
Arc::clone(&sessions),
|
Arc::clone(&sessions),
|
||||||
Arc::new(bus.clone()),
|
Arc::new(bus.clone()),
|
||||||
);
|
);
|
||||||
@ -695,6 +740,7 @@ async fn two_agents_same_root_get_distinct_run_dirs_no_collision() {
|
|||||||
Arc::new(FakeRuntime::new(Arc::clone(&tr), plan)),
|
Arc::new(FakeRuntime::new(Arc::clone(&tr), plan)),
|
||||||
Arc::new(fs.clone()),
|
Arc::new(fs.clone()),
|
||||||
Arc::new(pty.clone()),
|
Arc::new(pty.clone()),
|
||||||
|
Arc::new(FakeSkills::default()),
|
||||||
Arc::clone(&sessions),
|
Arc::clone(&sessions),
|
||||||
Arc::new(SpyBus::default()),
|
Arc::new(SpyBus::default()),
|
||||||
);
|
);
|
||||||
@ -730,6 +776,98 @@ async fn two_agents_same_root_get_distinct_run_dirs_no_collision() {
|
|||||||
assert!(String::from_utf8(writes[1].1.clone()).unwrap().contains("# bravo"));
|
assert!(String::from_utf8(writes[1].1.clone()).unwrap().contains("# bravo"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn launch_conventionfile_injects_assigned_skills_in_order() {
|
||||||
|
// An agent with two assigned global skills; the generated convention file must
|
||||||
|
// carry both skill bodies, after the persona, in assignment order (§14.2).
|
||||||
|
let skill_id = |n: u128| SkillId::from_uuid(Uuid::from_u128(n));
|
||||||
|
let skill = |n: u128, name: &str, body: &str| {
|
||||||
|
Skill::new(skill_id(n), name, MarkdownDoc::new(body), SkillScope::Global).unwrap()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||||
|
agent.assign_skill(SkillRef::new(skill_id(1), SkillScope::Global));
|
||||||
|
agent.assign_skill(SkillRef::new(skill_id(2), SkillScope::Global));
|
||||||
|
|
||||||
|
let contexts = FakeContexts::with_agent(&agent, "# persona");
|
||||||
|
let profiles = FakeProfiles::new(vec![profile(
|
||||||
|
pid(9),
|
||||||
|
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||||
|
)]);
|
||||||
|
let tr = trace();
|
||||||
|
let fs = FakeFs::new(Arc::clone(&tr));
|
||||||
|
let pty = FakePty::new(Arc::clone(&tr), sid(777));
|
||||||
|
let skills = FakeSkills::with(vec![
|
||||||
|
skill(1, "refactor", "REFAC_BODY"),
|
||||||
|
skill(2, "review", "REVIEW_BODY"),
|
||||||
|
]);
|
||||||
|
let launch = LaunchAgent::new(
|
||||||
|
Arc::new(contexts),
|
||||||
|
Arc::new(profiles),
|
||||||
|
Arc::new(FakeRuntime::new(
|
||||||
|
Arc::clone(&tr),
|
||||||
|
Some(ContextInjectionPlan::File {
|
||||||
|
target: "CLAUDE.md".to_owned(),
|
||||||
|
}),
|
||||||
|
)),
|
||||||
|
Arc::new(fs.clone()),
|
||||||
|
Arc::new(pty.clone()),
|
||||||
|
Arc::new(skills),
|
||||||
|
Arc::new(TerminalSessions::new()),
|
||||||
|
Arc::new(SpyBus::default()),
|
||||||
|
);
|
||||||
|
|
||||||
|
launch.execute(launch_input(agent.id)).await.unwrap();
|
||||||
|
|
||||||
|
let writes = fs.writes();
|
||||||
|
assert_eq!(writes.len(), 1);
|
||||||
|
let doc = String::from_utf8(writes[0].1.clone()).unwrap();
|
||||||
|
assert!(doc.contains("# persona"), "persona present: {doc}");
|
||||||
|
assert!(doc.contains("REFAC_BODY"), "first skill body present");
|
||||||
|
assert!(doc.contains("REVIEW_BODY"), "second skill body present");
|
||||||
|
// Deterministic order: persona before skills, skill 1 before skill 2.
|
||||||
|
let persona_at = doc.find("# persona").unwrap();
|
||||||
|
let refac_at = doc.find("REFAC_BODY").unwrap();
|
||||||
|
let review_at = doc.find("REVIEW_BODY").unwrap();
|
||||||
|
assert!(persona_at < refac_at && refac_at < review_at, "ordering: {doc}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn launch_skips_dangling_skill_ref_without_failing() {
|
||||||
|
// The agent references a skill that no longer exists in the store: launch must
|
||||||
|
// still succeed and simply omit it (no Skills section for a sole dangling ref).
|
||||||
|
let mut agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||||
|
agent.assign_skill(SkillRef::new(SkillId::from_uuid(Uuid::from_u128(99)), SkillScope::Global));
|
||||||
|
|
||||||
|
let contexts = FakeContexts::with_agent(&agent, "# persona");
|
||||||
|
let profiles = FakeProfiles::new(vec![profile(
|
||||||
|
pid(9),
|
||||||
|
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||||
|
)]);
|
||||||
|
let tr = trace();
|
||||||
|
let fs = FakeFs::new(Arc::clone(&tr));
|
||||||
|
let pty = FakePty::new(Arc::clone(&tr), sid(777));
|
||||||
|
let launch = LaunchAgent::new(
|
||||||
|
Arc::new(contexts),
|
||||||
|
Arc::new(profiles),
|
||||||
|
Arc::new(FakeRuntime::new(
|
||||||
|
Arc::clone(&tr),
|
||||||
|
Some(ContextInjectionPlan::File {
|
||||||
|
target: "CLAUDE.md".to_owned(),
|
||||||
|
}),
|
||||||
|
)),
|
||||||
|
Arc::new(fs.clone()),
|
||||||
|
Arc::new(pty.clone()),
|
||||||
|
Arc::new(FakeSkills::default()), // empty store ⇒ the ref is dangling
|
||||||
|
Arc::new(TerminalSessions::new()),
|
||||||
|
Arc::new(SpyBus::default()),
|
||||||
|
);
|
||||||
|
|
||||||
|
launch.execute(launch_input(agent.id)).await.expect("launch must succeed");
|
||||||
|
let doc = String::from_utf8(fs.writes()[0].1.clone()).unwrap();
|
||||||
|
assert!(!doc.contains("# Skills"), "no Skills section for a dangling ref: {doc}");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn launch_stdin_strategy_pipes_context_after_spawn() {
|
async fn launch_stdin_strategy_pipes_context_after_spawn() {
|
||||||
let (launch, agent, fs, pty, _bus, _sessions, tr) =
|
let (launch, agent, fs, pty, _bus, _sessions, tr) =
|
||||||
@ -771,6 +909,7 @@ async fn launch_unknown_profile_is_not_found() {
|
|||||||
Arc::new(FakeRuntime::new(Arc::clone(&tr), Some(ContextInjectionPlan::Stdin))),
|
Arc::new(FakeRuntime::new(Arc::clone(&tr), Some(ContextInjectionPlan::Stdin))),
|
||||||
Arc::new(FakeFs::new(Arc::clone(&tr))),
|
Arc::new(FakeFs::new(Arc::clone(&tr))),
|
||||||
Arc::new(pty.clone()),
|
Arc::new(pty.clone()),
|
||||||
|
Arc::new(FakeSkills::default()),
|
||||||
Arc::new(TerminalSessions::new()),
|
Arc::new(TerminalSessions::new()),
|
||||||
Arc::new(SpyBus::default()),
|
Arc::new(SpyBus::default()),
|
||||||
);
|
);
|
||||||
|
|||||||
395
crates/application/tests/skill_usecases.rs
Normal file
395
crates/application/tests/skill_usecases.rs
Normal file
@ -0,0 +1,395 @@
|
|||||||
|
//! L12 tests for the skill use cases with in-memory port fakes (no real
|
||||||
|
//! store/FS): CRUD across scopes (`CreateSkill`, `UpdateSkill`, `DeleteSkill`,
|
||||||
|
//! `ListSkills`) and the manifest-mutating assignment
|
||||||
|
//! (`AssignSkillToAgent` / `UnassignSkillFromAgent`), asserting the
|
||||||
|
//! `SkillAssigned` event and idempotence.
|
||||||
|
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::events::DomainEvent;
|
||||||
|
use domain::ids::{AgentId, ProfileId, ProjectId, SkillId};
|
||||||
|
use domain::markdown::MarkdownDoc;
|
||||||
|
use domain::ports::{
|
||||||
|
AgentContextStore, EventBus, EventStream, IdGenerator, SkillStore, StoreError,
|
||||||
|
};
|
||||||
|
use domain::skill::{Skill, SkillScope};
|
||||||
|
use domain::{AgentManifest, ManifestEntry, Project, ProjectPath, RemoteRef, SkillRef};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use application::{
|
||||||
|
AssignSkillToAgent, AssignSkillToAgentInput, CreateSkill, CreateSkillInput, DeleteSkill,
|
||||||
|
DeleteSkillInput, ListSkills, ListSkillsInput, UnassignSkillFromAgent,
|
||||||
|
UnassignSkillFromAgentInput, UpdateSkill, UpdateSkillInput,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fakes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// In-memory skill store keyed by `(scope, id)`, honouring scope isolation.
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct FakeSkills(Arc<Mutex<Vec<Skill>>>);
|
||||||
|
#[async_trait]
|
||||||
|
impl SkillStore for FakeSkills {
|
||||||
|
async fn list(&self, scope: SkillScope, _root: &ProjectPath) -> Result<Vec<Skill>, StoreError> {
|
||||||
|
Ok(self
|
||||||
|
.0
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.scope == scope)
|
||||||
|
.cloned()
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
async fn get(
|
||||||
|
&self,
|
||||||
|
scope: SkillScope,
|
||||||
|
_root: &ProjectPath,
|
||||||
|
id: SkillId,
|
||||||
|
) -> Result<Skill, StoreError> {
|
||||||
|
self.0
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.scope == scope && s.id == id)
|
||||||
|
.cloned()
|
||||||
|
.ok_or(StoreError::NotFound)
|
||||||
|
}
|
||||||
|
async fn save(&self, skill: &Skill, _root: &ProjectPath) -> Result<(), StoreError> {
|
||||||
|
let mut v = self.0.lock().unwrap();
|
||||||
|
if let Some(slot) = v.iter_mut().find(|s| s.scope == skill.scope && s.id == skill.id) {
|
||||||
|
*slot = skill.clone();
|
||||||
|
} else {
|
||||||
|
v.push(skill.clone());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn delete(
|
||||||
|
&self,
|
||||||
|
scope: SkillScope,
|
||||||
|
_root: &ProjectPath,
|
||||||
|
id: SkillId,
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
let mut v = self.0.lock().unwrap();
|
||||||
|
let before = v.len();
|
||||||
|
v.retain(|s| !(s.scope == scope && s.id == id));
|
||||||
|
if v.len() == before {
|
||||||
|
return Err(StoreError::NotFound);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct FakeContexts(Arc<Mutex<AgentManifest>>);
|
||||||
|
impl FakeContexts {
|
||||||
|
fn new(entries: Vec<ManifestEntry>) -> Self {
|
||||||
|
Self(Arc::new(Mutex::new(AgentManifest { version: 1, entries })))
|
||||||
|
}
|
||||||
|
fn manifest(&self) -> AgentManifest {
|
||||||
|
self.0.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[async_trait]
|
||||||
|
impl AgentContextStore for FakeContexts {
|
||||||
|
async fn read_context(
|
||||||
|
&self,
|
||||||
|
_p: &Project,
|
||||||
|
_agent: &AgentId,
|
||||||
|
) -> Result<MarkdownDoc, StoreError> {
|
||||||
|
Err(StoreError::NotFound)
|
||||||
|
}
|
||||||
|
async fn write_context(
|
||||||
|
&self,
|
||||||
|
_p: &Project,
|
||||||
|
_agent: &AgentId,
|
||||||
|
_md: &MarkdownDoc,
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn load_manifest(&self, _p: &Project) -> Result<AgentManifest, StoreError> {
|
||||||
|
Ok(self.manifest())
|
||||||
|
}
|
||||||
|
async fn save_manifest(&self, _p: &Project, m: &AgentManifest) -> Result<(), StoreError> {
|
||||||
|
*self.0.lock().unwrap() = m.clone();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Clone)]
|
||||||
|
struct SpyBus(Arc<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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SeqIds(Mutex<u128>);
|
||||||
|
impl SeqIds {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self(Mutex::new(1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl IdGenerator for SeqIds {
|
||||||
|
fn new_uuid(&self) -> Uuid {
|
||||||
|
let mut n = self.0.lock().unwrap();
|
||||||
|
let id = Uuid::from_u128(*n);
|
||||||
|
*n += 1;
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Builders
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn pid(n: u128) -> ProfileId {
|
||||||
|
ProfileId::from_uuid(Uuid::from_u128(n))
|
||||||
|
}
|
||||||
|
fn aid(n: u128) -> AgentId {
|
||||||
|
AgentId::from_uuid(Uuid::from_u128(n))
|
||||||
|
}
|
||||||
|
fn sid(n: u128) -> SkillId {
|
||||||
|
SkillId::from_uuid(Uuid::from_u128(n))
|
||||||
|
}
|
||||||
|
fn root() -> ProjectPath {
|
||||||
|
ProjectPath::new("/home/me/demo").unwrap()
|
||||||
|
}
|
||||||
|
fn project() -> Project {
|
||||||
|
Project::new(
|
||||||
|
ProjectId::from_uuid(Uuid::from_u128(1000)),
|
||||||
|
"demo",
|
||||||
|
root(),
|
||||||
|
RemoteRef::local(),
|
||||||
|
1_700_000_000_000,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
fn scratch_entry(agent: AgentId) -> ManifestEntry {
|
||||||
|
ManifestEntry::new(agent, "A", "agents/a.md", pid(1), None, false, None).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CRUD
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn create_skill_persists_in_its_scope() {
|
||||||
|
let store = FakeSkills::default();
|
||||||
|
let out = CreateSkill::new(Arc::new(store.clone()), Arc::new(SeqIds::new()))
|
||||||
|
.execute(CreateSkillInput {
|
||||||
|
name: "refactor".to_owned(),
|
||||||
|
content: "# body".to_owned(),
|
||||||
|
scope: SkillScope::Project,
|
||||||
|
project_root: root(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(out.skill.scope, SkillScope::Project);
|
||||||
|
assert_eq!(
|
||||||
|
store.list(SkillScope::Project, &root()).await.unwrap().len(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert!(store
|
||||||
|
.list(SkillScope::Global, &root())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn create_skill_rejects_empty_content() {
|
||||||
|
let store = FakeSkills::default();
|
||||||
|
let err = CreateSkill::new(Arc::new(store), Arc::new(SeqIds::new()))
|
||||||
|
.execute(CreateSkillInput {
|
||||||
|
name: "k".to_owned(),
|
||||||
|
content: String::new(),
|
||||||
|
scope: SkillScope::Global,
|
||||||
|
project_root: root(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(err.code(), "INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn update_skill_replaces_content() {
|
||||||
|
let store = FakeSkills::default();
|
||||||
|
store
|
||||||
|
.save(
|
||||||
|
&Skill::new(sid(1), "k", MarkdownDoc::new("v1"), SkillScope::Global).unwrap(),
|
||||||
|
&root(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let out = UpdateSkill::new(Arc::new(store.clone()))
|
||||||
|
.execute(UpdateSkillInput {
|
||||||
|
scope: SkillScope::Global,
|
||||||
|
skill_id: sid(1),
|
||||||
|
content: "v2".to_owned(),
|
||||||
|
project_root: root(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(out.skill.content_md.as_str(), "v2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn update_unknown_skill_is_not_found() {
|
||||||
|
let store = FakeSkills::default();
|
||||||
|
let err = UpdateSkill::new(Arc::new(store))
|
||||||
|
.execute(UpdateSkillInput {
|
||||||
|
scope: SkillScope::Global,
|
||||||
|
skill_id: sid(404),
|
||||||
|
content: "x".to_owned(),
|
||||||
|
project_root: root(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(err.code(), "NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_is_scope_filtered() {
|
||||||
|
let store = FakeSkills::default();
|
||||||
|
store
|
||||||
|
.save(
|
||||||
|
&Skill::new(sid(1), "g", MarkdownDoc::new("x"), SkillScope::Global).unwrap(),
|
||||||
|
&root(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.save(
|
||||||
|
&Skill::new(sid(2), "p", MarkdownDoc::new("x"), SkillScope::Project).unwrap(),
|
||||||
|
&root(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let listed = ListSkills::new(Arc::new(store))
|
||||||
|
.execute(ListSkillsInput {
|
||||||
|
scope: SkillScope::Global,
|
||||||
|
project_root: root(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(listed.skills.len(), 1);
|
||||||
|
assert_eq!(listed.skills[0].id, sid(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_then_delete_is_not_found() {
|
||||||
|
let store = FakeSkills::default();
|
||||||
|
store
|
||||||
|
.save(
|
||||||
|
&Skill::new(sid(1), "k", MarkdownDoc::new("x"), SkillScope::Project).unwrap(),
|
||||||
|
&root(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let uc = DeleteSkill::new(Arc::new(store));
|
||||||
|
uc.execute(DeleteSkillInput {
|
||||||
|
scope: SkillScope::Project,
|
||||||
|
skill_id: sid(1),
|
||||||
|
project_root: root(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let err = uc
|
||||||
|
.execute(DeleteSkillInput {
|
||||||
|
scope: SkillScope::Project,
|
||||||
|
skill_id: sid(1),
|
||||||
|
project_root: root(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(err.code(), "NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Assignment
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn assigned_event(events: &[DomainEvent]) -> Vec<(SkillId, bool)> {
|
||||||
|
events
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| match e {
|
||||||
|
DomainEvent::SkillAssigned {
|
||||||
|
skill_id, assigned, ..
|
||||||
|
} => Some((*skill_id, *assigned)),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn assign_mutates_manifest_emits_event_and_is_idempotent() {
|
||||||
|
let contexts = FakeContexts::new(vec![scratch_entry(aid(1))]);
|
||||||
|
let bus = SpyBus::default();
|
||||||
|
let uc = AssignSkillToAgent::new(Arc::new(contexts.clone()), Arc::new(bus.clone()));
|
||||||
|
let input = AssignSkillToAgentInput {
|
||||||
|
project: project(),
|
||||||
|
agent_id: aid(1),
|
||||||
|
skill: SkillRef::new(sid(9), SkillScope::Global),
|
||||||
|
};
|
||||||
|
|
||||||
|
uc.execute(input.clone()).await.unwrap();
|
||||||
|
// Re-assigning the same skill is a no-op (dedup).
|
||||||
|
uc.execute(input).await.unwrap();
|
||||||
|
|
||||||
|
let entry = &contexts.manifest().entries[0];
|
||||||
|
assert_eq!(entry.skills.len(), 1, "no duplicate assignment");
|
||||||
|
assert_eq!(entry.skills[0].skill_id, sid(9));
|
||||||
|
assert_eq!(
|
||||||
|
assigned_event(&bus.events()),
|
||||||
|
vec![(sid(9), true)],
|
||||||
|
"exactly one assign event despite double execute"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unassign_removes_and_emits_false_then_is_noop() {
|
||||||
|
let mut entry = scratch_entry(aid(1));
|
||||||
|
entry.skills.push(SkillRef::new(sid(9), SkillScope::Global));
|
||||||
|
let contexts = FakeContexts::new(vec![entry]);
|
||||||
|
let bus = SpyBus::default();
|
||||||
|
let uc = UnassignSkillFromAgent::new(Arc::new(contexts.clone()), Arc::new(bus.clone()));
|
||||||
|
let input = UnassignSkillFromAgentInput {
|
||||||
|
project: project(),
|
||||||
|
agent_id: aid(1),
|
||||||
|
skill_id: sid(9),
|
||||||
|
};
|
||||||
|
|
||||||
|
uc.execute(input.clone()).await.unwrap();
|
||||||
|
uc.execute(input).await.unwrap(); // already gone → no-op
|
||||||
|
|
||||||
|
assert!(contexts.manifest().entries[0].skills.is_empty());
|
||||||
|
assert_eq!(assigned_event(&bus.events()), vec![(sid(9), false)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn assign_to_unknown_agent_is_not_found() {
|
||||||
|
let contexts = FakeContexts::new(vec![]);
|
||||||
|
let bus = SpyBus::default();
|
||||||
|
let err = AssignSkillToAgent::new(Arc::new(contexts), Arc::new(bus))
|
||||||
|
.execute(AssignSkillToAgentInput {
|
||||||
|
project: project(),
|
||||||
|
agent_id: aid(404),
|
||||||
|
skill: SkillRef::new(sid(9), SkillScope::Global),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(err.code(), "NOT_FOUND");
|
||||||
|
}
|
||||||
@ -34,6 +34,7 @@ use crate::markdown::MarkdownDoc;
|
|||||||
use crate::profile::AgentProfile;
|
use crate::profile::AgentProfile;
|
||||||
use crate::project::{Project, ProjectPath};
|
use crate::project::{Project, ProjectPath};
|
||||||
use crate::remote::RemoteKind;
|
use crate::remote::RemoteKind;
|
||||||
|
use crate::skill::{Skill, SkillScope};
|
||||||
use crate::template::AgentTemplate;
|
use crate::template::AgentTemplate;
|
||||||
use crate::terminal::PtySize;
|
use crate::terminal::PtySize;
|
||||||
|
|
||||||
@ -209,7 +210,7 @@ pub enum FsError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Errors from persistence stores ([`TemplateStore`], [`ProjectStore`],
|
/// Errors from persistence stores ([`TemplateStore`], [`ProjectStore`],
|
||||||
/// [`AgentContextStore`]).
|
/// [`AgentContextStore`], [`SkillStore`]).
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||||
pub enum StoreError {
|
pub enum StoreError {
|
||||||
/// The requested item was not found.
|
/// The requested item was not found.
|
||||||
@ -460,6 +461,57 @@ pub trait TemplateStore: Send + Sync {
|
|||||||
async fn delete(&self, id: crate::ids::TemplateId) -> Result<(), StoreError>;
|
async fn delete(&self, id: crate::ids::TemplateId) -> Result<(), StoreError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// CRUD for [`Skill`]s across both scopes ([`SkillScope::Global`] in the IDE
|
||||||
|
/// store, [`SkillScope::Project`] under `.ideai/skills/`), per ARCHITECTURE
|
||||||
|
/// §14.2 and L12.
|
||||||
|
///
|
||||||
|
/// The two scopes are **isolated**: a skill saved as `Project` never surfaces in
|
||||||
|
/// a `Global` listing and vice-versa. Each call carries the [`SkillScope`]
|
||||||
|
/// explicitly (or via the [`Skill`] for [`save`](Self::save)) so the adapter
|
||||||
|
/// resolves the right backing location.
|
||||||
|
///
|
||||||
|
/// `root` identifies the project whose `.ideai/skills/` to use for
|
||||||
|
/// [`SkillScope::Project`]; it is **ignored** for [`SkillScope::Global`] (which
|
||||||
|
/// lives in the machine-global IDE store). Passing the root per call — rather
|
||||||
|
/// than baking it into the adapter — keeps a single store instance correct
|
||||||
|
/// across every open project (mirroring [`AgentContextStore`]).
|
||||||
|
#[async_trait]
|
||||||
|
pub trait SkillStore: Send + Sync {
|
||||||
|
/// Lists all skills in `scope` (for `root`'s project when project-scoped).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`StoreError`] on failure.
|
||||||
|
async fn list(&self, scope: SkillScope, root: &ProjectPath) -> Result<Vec<Skill>, StoreError>;
|
||||||
|
|
||||||
|
/// Gets a skill by id within `scope`.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`StoreError::NotFound`] if absent in that scope.
|
||||||
|
async fn get(
|
||||||
|
&self,
|
||||||
|
scope: SkillScope,
|
||||||
|
root: &ProjectPath,
|
||||||
|
id: crate::ids::SkillId,
|
||||||
|
) -> Result<Skill, StoreError>;
|
||||||
|
|
||||||
|
/// Saves (creates or replaces by id) a skill in its own [`Skill::scope`].
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`StoreError`] on failure.
|
||||||
|
async fn save(&self, skill: &Skill, root: &ProjectPath) -> Result<(), StoreError>;
|
||||||
|
|
||||||
|
/// Deletes a skill by id within `scope`.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`StoreError::NotFound`] if absent in that scope.
|
||||||
|
async fn delete(
|
||||||
|
&self,
|
||||||
|
scope: SkillScope,
|
||||||
|
root: &ProjectPath,
|
||||||
|
id: crate::ids::SkillId,
|
||||||
|
) -> Result<(), StoreError>;
|
||||||
|
}
|
||||||
|
|
||||||
/// Persistence of the known-projects registry and the workspace.
|
/// Persistence of the known-projects registry and the workspace.
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait ProjectStore: Send + Sync {
|
pub trait ProjectStore: Send + Sync {
|
||||||
|
|||||||
@ -32,4 +32,6 @@ pub use process::LocalProcessSpawner;
|
|||||||
pub use pty::PortablePtyAdapter;
|
pub use pty::PortablePtyAdapter;
|
||||||
pub use remote::{remote_host, LocalHost};
|
pub use remote::{remote_host, LocalHost};
|
||||||
pub use runtime::CliAgentRuntime;
|
pub use runtime::CliAgentRuntime;
|
||||||
pub use store::{FsProfileStore, FsProjectStore, FsTemplateStore, IdeaiContextStore};
|
pub use store::{
|
||||||
|
FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore, IdeaiContextStore,
|
||||||
|
};
|
||||||
|
|||||||
@ -7,9 +7,11 @@
|
|||||||
mod context;
|
mod context;
|
||||||
mod profile;
|
mod profile;
|
||||||
mod project;
|
mod project;
|
||||||
|
mod skill;
|
||||||
mod template;
|
mod template;
|
||||||
|
|
||||||
pub use context::IdeaiContextStore;
|
pub use context::IdeaiContextStore;
|
||||||
pub use profile::FsProfileStore;
|
pub use profile::FsProfileStore;
|
||||||
pub use project::FsProjectStore;
|
pub use project::FsProjectStore;
|
||||||
|
pub use skill::FsSkillStore;
|
||||||
pub use template::FsTemplateStore;
|
pub use template::FsTemplateStore;
|
||||||
|
|||||||
263
crates/infrastructure/src/store/skill.rs
Normal file
263
crates/infrastructure/src/store/skill.rs
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
//! [`FsSkillStore`] — file implementation of the [`SkillStore`] port
|
||||||
|
//! (ARCHITECTURE §14.2, L12).
|
||||||
|
//!
|
||||||
|
//! Skills are reusable, model-agnostic workflows. They live in **two isolated
|
||||||
|
//! scopes**, each backed by its own directory but the same on-disk shape
|
||||||
|
//! (mirroring [`crate::store::FsTemplateStore`]): a small JSON index carrying the
|
||||||
|
//! metadata needed to list without parsing every `.md`, plus the Markdown bodies
|
||||||
|
//! under `md/`:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! <app_data_dir>/skills/ # SkillScope::Global (shared across projects)
|
||||||
|
//! <project_root>/.ideai/skills/ # SkillScope::Project (travels with the code)
|
||||||
|
//! ├── index.json # { version, skills: [{ id, name, contentHash }] }
|
||||||
|
//! └── md/
|
||||||
|
//! └── <id>.md # a skill's Markdown content
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! The two scopes never bleed into one another: a `Project` skill is invisible to
|
||||||
|
//! a `Global` listing and vice-versa, because each resolves to a different
|
||||||
|
//! directory. All I/O goes through the [`FileSystem`] port, so the adapter is
|
||||||
|
//! location-neutral (a project hosted over SSH/WSL works unchanged) and
|
||||||
|
//! Tauri-agnostic.
|
||||||
|
//!
|
||||||
|
//! Like the template store, [`delete`](SkillStore::delete) drops the index row
|
||||||
|
//! and leaves the orphaned `md/<id>.md` on disk (the [`FileSystem`] port exposes
|
||||||
|
//! no remove); since listing is index-driven, the skill is effectively gone.
|
||||||
|
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use domain::ids::SkillId;
|
||||||
|
use domain::markdown::MarkdownDoc;
|
||||||
|
use domain::ports::{FileSystem, RemotePath, SkillStore, StoreError};
|
||||||
|
use domain::project::ProjectPath;
|
||||||
|
use domain::skill::{Skill, SkillScope};
|
||||||
|
|
||||||
|
/// Directory (under app-data) holding the global skills store.
|
||||||
|
const GLOBAL_SKILLS_DIR: &str = "skills";
|
||||||
|
|
||||||
|
/// The `.ideai/` directory name inside a project root.
|
||||||
|
const IDEAI_DIR: &str = ".ideai";
|
||||||
|
|
||||||
|
/// Sub-path of the project-scoped skills store inside `.ideai/`.
|
||||||
|
const PROJECT_SKILLS_DIR: &str = "skills";
|
||||||
|
|
||||||
|
/// Index file name inside a skills dir.
|
||||||
|
const INDEX_FILE: &str = "index.json";
|
||||||
|
|
||||||
|
/// Current schema version of the index file.
|
||||||
|
const INDEX_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
/// One metadata row in `index.json` (the `.md` content lives separately).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct IndexEntry {
|
||||||
|
id: SkillId,
|
||||||
|
name: String,
|
||||||
|
content_hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// On-disk shape of a scope's `index.json`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct IndexDoc {
|
||||||
|
version: u32,
|
||||||
|
skills: Vec<IndexEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for IndexDoc {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
version: INDEX_VERSION,
|
||||||
|
skills: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stable, dependency-free digest of Markdown content for out-of-app edit
|
||||||
|
/// detection — deterministic across runs and platforms (fixed-key hasher).
|
||||||
|
fn content_hash(md: &MarkdownDoc) -> String {
|
||||||
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
md.as_str().hash(&mut hasher);
|
||||||
|
format!("{:016x}", hasher.finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// File-backed [`SkillStore`], composing a [`FileSystem`] port. Holds only the
|
||||||
|
/// machine-global app-data dir; the project root for [`SkillScope::Project`] is
|
||||||
|
/// supplied **per call**, so a single instance serves every open project
|
||||||
|
/// (mirroring [`crate::store::IdeaiContextStore`]).
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct FsSkillStore {
|
||||||
|
fs: Arc<dyn FileSystem>,
|
||||||
|
app_data_dir: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FsSkillStore {
|
||||||
|
/// Builds the store from an injected [`FileSystem`] and the global app-data
|
||||||
|
/// dir (used for [`SkillScope::Global`]). Directories are created on first
|
||||||
|
/// write.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(fs: Arc<dyn FileSystem>, app_data_dir: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
fs,
|
||||||
|
app_data_dir: app_data_dir.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Root directory of a scope's store. `root` is ignored for `Global`.
|
||||||
|
fn dir(&self, scope: SkillScope, root: &ProjectPath) -> String {
|
||||||
|
match scope {
|
||||||
|
SkillScope::Global => {
|
||||||
|
let base = self.app_data_dir.trim_end_matches(['/', '\\']);
|
||||||
|
format!("{base}/{GLOBAL_SKILLS_DIR}")
|
||||||
|
}
|
||||||
|
SkillScope::Project => {
|
||||||
|
let base = root.as_str().trim_end_matches(['/', '\\']);
|
||||||
|
format!("{base}/{IDEAI_DIR}/{PROJECT_SKILLS_DIR}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<scope-dir>/index.json`.
|
||||||
|
fn index_path(&self, scope: SkillScope, root: &ProjectPath) -> RemotePath {
|
||||||
|
RemotePath::new(format!("{}/{INDEX_FILE}", self.dir(scope, root)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<scope-dir>/md/<id>.md`.
|
||||||
|
fn md_path(&self, scope: SkillScope, root: &ProjectPath, id: SkillId) -> RemotePath {
|
||||||
|
RemotePath::new(format!("{}/md/{id}.md", self.dir(scope, root)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a scope's index, returning an empty default if absent.
|
||||||
|
async fn read_index(
|
||||||
|
&self,
|
||||||
|
scope: SkillScope,
|
||||||
|
root: &ProjectPath,
|
||||||
|
) -> Result<IndexDoc, StoreError> {
|
||||||
|
match self.fs.read(&self.index_path(scope, root)).await {
|
||||||
|
Ok(bytes) => {
|
||||||
|
serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string()))
|
||||||
|
}
|
||||||
|
Err(domain::ports::FsError::NotFound(_)) => Ok(IndexDoc::default()),
|
||||||
|
Err(e) => Err(StoreError::Io(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes a scope's index, ensuring its directory exists.
|
||||||
|
async fn write_index(
|
||||||
|
&self,
|
||||||
|
scope: SkillScope,
|
||||||
|
root: &ProjectPath,
|
||||||
|
doc: &IndexDoc,
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
self.fs
|
||||||
|
.create_dir_all(&RemotePath::new(self.dir(scope, root)))
|
||||||
|
.await
|
||||||
|
.map_err(|e| StoreError::Io(e.to_string()))?;
|
||||||
|
let bytes =
|
||||||
|
serde_json::to_vec_pretty(doc).map_err(|e| StoreError::Serialization(e.to_string()))?;
|
||||||
|
self.fs
|
||||||
|
.write(&self.index_path(scope, root), &bytes)
|
||||||
|
.await
|
||||||
|
.map_err(|e| StoreError::Io(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reconstructs the [`Skill`] for an index entry by reading its `.md`.
|
||||||
|
async fn load(
|
||||||
|
&self,
|
||||||
|
scope: SkillScope,
|
||||||
|
root: &ProjectPath,
|
||||||
|
entry: &IndexEntry,
|
||||||
|
) -> Result<Skill, StoreError> {
|
||||||
|
let bytes = self
|
||||||
|
.fs
|
||||||
|
.read(&self.md_path(scope, root, entry.id))
|
||||||
|
.await
|
||||||
|
.map_err(|e| match e {
|
||||||
|
domain::ports::FsError::NotFound(_) => StoreError::NotFound,
|
||||||
|
other => StoreError::Io(other.to_string()),
|
||||||
|
})?;
|
||||||
|
let content =
|
||||||
|
String::from_utf8(bytes).map_err(|e| StoreError::Serialization(e.to_string()))?;
|
||||||
|
Skill::new(entry.id, entry.name.clone(), MarkdownDoc::new(content), scope)
|
||||||
|
.map_err(|e| StoreError::Serialization(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl SkillStore for FsSkillStore {
|
||||||
|
async fn list(&self, scope: SkillScope, root: &ProjectPath) -> Result<Vec<Skill>, StoreError> {
|
||||||
|
let index = self.read_index(scope, root).await?;
|
||||||
|
let mut out = Vec::with_capacity(index.skills.len());
|
||||||
|
for entry in &index.skills {
|
||||||
|
out.push(self.load(scope, root, entry).await?);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(
|
||||||
|
&self,
|
||||||
|
scope: SkillScope,
|
||||||
|
root: &ProjectPath,
|
||||||
|
id: SkillId,
|
||||||
|
) -> Result<Skill, StoreError> {
|
||||||
|
let index = self.read_index(scope, root).await?;
|
||||||
|
let entry = index
|
||||||
|
.skills
|
||||||
|
.iter()
|
||||||
|
.find(|e| e.id == id)
|
||||||
|
.ok_or(StoreError::NotFound)?;
|
||||||
|
self.load(scope, root, entry).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save(&self, skill: &Skill, root: &ProjectPath) -> Result<(), StoreError> {
|
||||||
|
let scope = skill.scope;
|
||||||
|
// (1) Write the Markdown content.
|
||||||
|
self.fs
|
||||||
|
.create_dir_all(&RemotePath::new(format!("{}/md", self.dir(scope, root))))
|
||||||
|
.await
|
||||||
|
.map_err(|e| StoreError::Io(e.to_string()))?;
|
||||||
|
self.fs
|
||||||
|
.write(
|
||||||
|
&self.md_path(scope, root, skill.id),
|
||||||
|
skill.content_md.as_str().as_bytes(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| StoreError::Io(e.to_string()))?;
|
||||||
|
|
||||||
|
// (2) Upsert the index metadata.
|
||||||
|
let mut index = self.read_index(scope, root).await?;
|
||||||
|
let row = IndexEntry {
|
||||||
|
id: skill.id,
|
||||||
|
name: skill.name.clone(),
|
||||||
|
content_hash: content_hash(&skill.content_md),
|
||||||
|
};
|
||||||
|
if let Some(slot) = index.skills.iter_mut().find(|e| e.id == skill.id) {
|
||||||
|
*slot = row;
|
||||||
|
} else {
|
||||||
|
index.skills.push(row);
|
||||||
|
}
|
||||||
|
self.write_index(scope, root, &index).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(
|
||||||
|
&self,
|
||||||
|
scope: SkillScope,
|
||||||
|
root: &ProjectPath,
|
||||||
|
id: SkillId,
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
let mut index = self.read_index(scope, root).await?;
|
||||||
|
let before = index.skills.len();
|
||||||
|
index.skills.retain(|e| e.id != id);
|
||||||
|
if index.skills.len() == before {
|
||||||
|
return Err(StoreError::NotFound);
|
||||||
|
}
|
||||||
|
// The orphaned `md/<id>.md` is left on disk (no FileSystem delete); the
|
||||||
|
// index no longer references it, so it is effectively gone.
|
||||||
|
self.write_index(scope, root, &index).await
|
||||||
|
}
|
||||||
|
}
|
||||||
211
crates/infrastructure/tests/skill_store.rs
Normal file
211
crates/infrastructure/tests/skill_store.rs
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
//! L12 integration tests for [`FsSkillStore`] against a real temp directory and a
|
||||||
|
//! real [`LocalFileSystem`]: md + `index.json` round-trip in **both** scopes,
|
||||||
|
//! scope isolation (a `Project` skill never appears under `Global` and vice
|
||||||
|
//! versa), upsert, delete, tolerant reads, and the on-disk layout.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::ids::SkillId;
|
||||||
|
use domain::markdown::MarkdownDoc;
|
||||||
|
use domain::ports::{FileSystem, RemotePath, SkillStore, StoreError};
|
||||||
|
use domain::project::ProjectPath;
|
||||||
|
use domain::skill::{Skill, SkillScope};
|
||||||
|
use infrastructure::{FsSkillStore, LocalFileSystem};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// A unique scratch directory under the OS temp dir, cleaned up on drop. It plays
|
||||||
|
/// **both** roles: its own path is the global app-data dir, and a `project/`
|
||||||
|
/// child is the project root (so the two scopes resolve to disjoint subtrees).
|
||||||
|
struct TempDir(PathBuf);
|
||||||
|
impl TempDir {
|
||||||
|
fn new() -> Self {
|
||||||
|
let p = std::env::temp_dir().join(format!("idea-l12-skill-{}", Uuid::new_v4()));
|
||||||
|
std::fs::create_dir_all(&p).unwrap();
|
||||||
|
Self(p)
|
||||||
|
}
|
||||||
|
fn app_data_dir(&self) -> String {
|
||||||
|
self.0.to_string_lossy().into_owned()
|
||||||
|
}
|
||||||
|
fn project_root(&self) -> ProjectPath {
|
||||||
|
ProjectPath::new(self.0.join("project").to_string_lossy().into_owned()).unwrap()
|
||||||
|
}
|
||||||
|
fn child(&self, rel: &str) -> RemotePath {
|
||||||
|
RemotePath::new(self.0.join(rel).to_string_lossy().into_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Drop for TempDir {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_dir_all(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store(tmp: &TempDir) -> FsSkillStore {
|
||||||
|
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
|
||||||
|
FsSkillStore::new(fs, tmp.app_data_dir())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sid(n: u128) -> SkillId {
|
||||||
|
SkillId::from_uuid(Uuid::from_u128(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn skill(id: SkillId, name: &str, content: &str, scope: SkillScope) -> Skill {
|
||||||
|
Skill::new(id, name, MarkdownDoc::new(content), scope).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn missing_index_lists_empty_in_both_scopes() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let s = store(&tmp);
|
||||||
|
let root = tmp.project_root();
|
||||||
|
assert!(s.list(SkillScope::Global, &root).await.unwrap().is_empty());
|
||||||
|
assert!(s.list(SkillScope::Project, &root).await.unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn global_roundtrip_save_get_list() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let s = store(&tmp);
|
||||||
|
let root = tmp.project_root();
|
||||||
|
|
||||||
|
let sk = skill(sid(1), "refactor", "# Refactor workflow", SkillScope::Global);
|
||||||
|
s.save(&sk, &root).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(s.get(SkillScope::Global, &root, sid(1)).await.unwrap(), sk);
|
||||||
|
assert_eq!(
|
||||||
|
s.list(SkillScope::Global, &root).await.unwrap(),
|
||||||
|
vec![sk.clone()]
|
||||||
|
);
|
||||||
|
|
||||||
|
// The Markdown landed under the global skills dir.
|
||||||
|
let fs = LocalFileSystem::new();
|
||||||
|
let bytes = fs
|
||||||
|
.read(&tmp.child(&format!("skills/md/{}.md", sid(1))))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(String::from_utf8(bytes).unwrap(), sk.content_md.as_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn project_roundtrip_lands_under_ideai() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let s = store(&tmp);
|
||||||
|
let root = tmp.project_root();
|
||||||
|
|
||||||
|
let sk = skill(sid(1), "review", "# Review workflow", SkillScope::Project);
|
||||||
|
s.save(&sk, &root).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(s.get(SkillScope::Project, &root, sid(1)).await.unwrap(), sk);
|
||||||
|
|
||||||
|
let fs = LocalFileSystem::new();
|
||||||
|
let bytes = fs
|
||||||
|
.read(&tmp.child(&format!("project/.ideai/skills/md/{}.md", sid(1))))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(String::from_utf8(bytes).unwrap(), sk.content_md.as_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scopes_are_isolated() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let s = store(&tmp);
|
||||||
|
let root = tmp.project_root();
|
||||||
|
|
||||||
|
s.save(&skill(sid(1), "g", "global body", SkillScope::Global), &root)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
s.save(
|
||||||
|
&skill(sid(2), "p", "project body", SkillScope::Project),
|
||||||
|
&root,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// A global skill never surfaces in the project scope and vice-versa.
|
||||||
|
let globals = s.list(SkillScope::Global, &root).await.unwrap();
|
||||||
|
let projects = s.list(SkillScope::Project, &root).await.unwrap();
|
||||||
|
assert_eq!(globals.len(), 1);
|
||||||
|
assert_eq!(projects.len(), 1);
|
||||||
|
assert_eq!(globals[0].id, sid(1));
|
||||||
|
assert_eq!(projects[0].id, sid(2));
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
s.get(SkillScope::Global, &root, sid(2)).await.unwrap_err(),
|
||||||
|
StoreError::NotFound
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
s.get(SkillScope::Project, &root, sid(1)).await.unwrap_err(),
|
||||||
|
StoreError::NotFound
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn save_upserts_content() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let s = store(&tmp);
|
||||||
|
let root = tmp.project_root();
|
||||||
|
|
||||||
|
s.save(&skill(sid(1), "k", "v1", SkillScope::Global), &root)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
s.save(&skill(sid(1), "k", "v2", SkillScope::Global), &root)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
s.get(SkillScope::Global, &root, sid(1))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.content_md
|
||||||
|
.as_str(),
|
||||||
|
"v2"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
s.list(SkillScope::Global, &root).await.unwrap().len(),
|
||||||
|
1,
|
||||||
|
"upsert, not append"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_removes_from_index_and_is_not_found_twice() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let s = store(&tmp);
|
||||||
|
let root = tmp.project_root();
|
||||||
|
s.save(&skill(sid(1), "k", "x", SkillScope::Project), &root)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
s.delete(SkillScope::Project, &root, sid(1)).await.unwrap();
|
||||||
|
assert!(s.list(SkillScope::Project, &root).await.unwrap().is_empty());
|
||||||
|
assert!(matches!(
|
||||||
|
s.delete(SkillScope::Project, &root, sid(1))
|
||||||
|
.await
|
||||||
|
.unwrap_err(),
|
||||||
|
StoreError::NotFound
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn index_is_camelcase_with_content_hash() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let s = store(&tmp);
|
||||||
|
let root = tmp.project_root();
|
||||||
|
s.save(&skill(sid(1), "refactor", "hello", SkillScope::Global), &root)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let fs = LocalFileSystem::new();
|
||||||
|
let bytes = fs.read(&tmp.child("skills/index.json")).await.unwrap();
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||||
|
let entry = &json.get("skills").unwrap().as_array().unwrap()[0];
|
||||||
|
assert_eq!(entry.get("name").and_then(|v| v.as_str()), Some("refactor"));
|
||||||
|
assert!(
|
||||||
|
entry.get("contentHash").is_some(),
|
||||||
|
"camelCase contentHash present"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
entry.get("content_hash").is_none(),
|
||||||
|
"no snake_case leak"
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -19,6 +19,7 @@ import { TauriTerminalGateway } from "./terminal";
|
|||||||
import { TauriLayoutGateway } from "./layout";
|
import { TauriLayoutGateway } from "./layout";
|
||||||
import { TauriProfileGateway } from "./profile";
|
import { TauriProfileGateway } from "./profile";
|
||||||
import { TauriTemplateGateway } from "./template";
|
import { TauriTemplateGateway } from "./template";
|
||||||
|
import { TauriSkillGateway } from "./skill";
|
||||||
import { TauriGitGateway } from "./git";
|
import { TauriGitGateway } from "./git";
|
||||||
|
|
||||||
function notImplemented(what: string): never {
|
function notImplemented(what: string): never {
|
||||||
@ -47,6 +48,7 @@ export function createTauriGateways(): Gateways {
|
|||||||
remote: new TauriRemoteGateway(),
|
remote: new TauriRemoteGateway(),
|
||||||
profile: new TauriProfileGateway(),
|
profile: new TauriProfileGateway(),
|
||||||
template: new TauriTemplateGateway(),
|
template: new TauriTemplateGateway(),
|
||||||
|
skill: new TauriSkillGateway(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,5 +60,6 @@ export {
|
|||||||
TauriLayoutGateway,
|
TauriLayoutGateway,
|
||||||
TauriProfileGateway,
|
TauriProfileGateway,
|
||||||
TauriTemplateGateway,
|
TauriTemplateGateway,
|
||||||
|
TauriSkillGateway,
|
||||||
TauriGitGateway,
|
TauriGitGateway,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -23,12 +23,15 @@ import type {
|
|||||||
LayoutTree,
|
LayoutTree,
|
||||||
Project,
|
Project,
|
||||||
ProfileAvailability,
|
ProfileAvailability,
|
||||||
|
Skill,
|
||||||
|
SkillScope,
|
||||||
Template,
|
Template,
|
||||||
Unsubscribe,
|
Unsubscribe,
|
||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
import type {
|
import type {
|
||||||
AgentGateway,
|
AgentGateway,
|
||||||
CreateAgentInput,
|
CreateAgentInput,
|
||||||
|
CreateSkillInput,
|
||||||
CreateTemplateInput,
|
CreateTemplateInput,
|
||||||
Gateways,
|
Gateways,
|
||||||
GitGateway,
|
GitGateway,
|
||||||
@ -38,6 +41,7 @@ import type {
|
|||||||
ProjectGateway,
|
ProjectGateway,
|
||||||
ReattachResult,
|
ReattachResult,
|
||||||
RemoteGateway,
|
RemoteGateway,
|
||||||
|
SkillGateway,
|
||||||
SystemGateway,
|
SystemGateway,
|
||||||
TemplateGateway,
|
TemplateGateway,
|
||||||
TerminalGateway,
|
TerminalGateway,
|
||||||
@ -191,6 +195,7 @@ export class MockAgentGateway implements AgentGateway {
|
|||||||
profileId: input.profileId,
|
profileId: input.profileId,
|
||||||
origin: { type: "scratch" },
|
origin: { type: "scratch" },
|
||||||
synchronized: false,
|
synchronized: false,
|
||||||
|
skills: [],
|
||||||
};
|
};
|
||||||
list.push(agent);
|
list.push(agent);
|
||||||
this.contexts.set(
|
this.contexts.set(
|
||||||
@ -282,6 +287,18 @@ export class MockAgentGateway implements AgentGateway {
|
|||||||
return this.getAgents(projectId);
|
return this.getAgents(projectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces an agent's assigned skills in-place.
|
||||||
|
* Used by `MockSkillGateway.assignSkill` / `unassignSkill` so both gateways
|
||||||
|
* share the same in-memory store.
|
||||||
|
*/
|
||||||
|
_setSkills(projectId: string, agentId: string, skills: Agent["skills"]): void {
|
||||||
|
const list = this.getAgents(projectId);
|
||||||
|
const idx = list.findIndex((a) => a.id === agentId);
|
||||||
|
if (idx === -1) return;
|
||||||
|
list[idx] = { ...list[idx], skills };
|
||||||
|
}
|
||||||
|
|
||||||
async launchAgent(
|
async launchAgent(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
@ -931,6 +948,7 @@ export class MockTemplateGateway implements TemplateGateway {
|
|||||||
syncedTemplateVersion: template.version,
|
syncedTemplateVersion: template.version,
|
||||||
},
|
},
|
||||||
synchronized,
|
synchronized,
|
||||||
|
skills: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
this.agentGateway._insertAgent(projectId, agent, template.contentMd);
|
this.agentGateway._insertAgent(projectId, agent, template.contentMd);
|
||||||
@ -990,6 +1008,130 @@ export class MockTemplateGateway implements TemplateGateway {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stateful in-memory skill gateway (L12).
|
||||||
|
*
|
||||||
|
* Skills are keyed by `${scope}` (project scope is further partitioned by
|
||||||
|
* `projectId`, mirroring the disjoint on-disk roots). Assignment mutates the
|
||||||
|
* agent record held by the injected {@link MockAgentGateway}, so both gateways
|
||||||
|
* share one store — exactly like {@link MockTemplateGateway}.
|
||||||
|
*/
|
||||||
|
export class MockSkillGateway implements SkillGateway {
|
||||||
|
// Global skills are project-independent; project skills live under their id.
|
||||||
|
private global: Skill[] = [];
|
||||||
|
private byProject = new Map<string, Skill[]>();
|
||||||
|
private seq = 0;
|
||||||
|
|
||||||
|
constructor(private readonly agentGateway: MockAgentGateway) {}
|
||||||
|
|
||||||
|
private bucket(projectId: string, scope: SkillScope): Skill[] {
|
||||||
|
if (scope === "global") return this.global;
|
||||||
|
let list = this.byProject.get(projectId);
|
||||||
|
if (!list) {
|
||||||
|
list = [];
|
||||||
|
this.byProject.set(projectId, list);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSkills(projectId: string, scope: SkillScope): Promise<Skill[]> {
|
||||||
|
return structuredClone(this.bucket(projectId, scope));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSkill(input: CreateSkillInput): Promise<Skill> {
|
||||||
|
this.seq += 1;
|
||||||
|
const skill: Skill = {
|
||||||
|
id: `mock-skill-${this.seq}`,
|
||||||
|
name: input.name,
|
||||||
|
contentMd: input.content,
|
||||||
|
scope: input.scope,
|
||||||
|
};
|
||||||
|
this.bucket(input.projectId, input.scope).push(skill);
|
||||||
|
return structuredClone(skill);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSkill(
|
||||||
|
projectId: string,
|
||||||
|
scope: SkillScope,
|
||||||
|
skillId: string,
|
||||||
|
content: string,
|
||||||
|
): Promise<Skill> {
|
||||||
|
const list = this.bucket(projectId, scope);
|
||||||
|
const idx = list.findIndex((s) => s.id === skillId);
|
||||||
|
if (idx === -1) {
|
||||||
|
const err: GatewayError = {
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: `skill ${skillId} not found`,
|
||||||
|
};
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
list[idx] = { ...list[idx], contentMd: content };
|
||||||
|
return structuredClone(list[idx]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSkill(
|
||||||
|
projectId: string,
|
||||||
|
scope: SkillScope,
|
||||||
|
skillId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const list = this.bucket(projectId, scope);
|
||||||
|
const idx = list.findIndex((s) => s.id === skillId);
|
||||||
|
if (idx === -1) {
|
||||||
|
const err: GatewayError = {
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: `skill ${skillId} not found`,
|
||||||
|
};
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
list.splice(idx, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async assignSkill(
|
||||||
|
projectId: string,
|
||||||
|
agentId: string,
|
||||||
|
skillId: string,
|
||||||
|
scope: SkillScope,
|
||||||
|
): Promise<void> {
|
||||||
|
const agent = this.agentGateway
|
||||||
|
._rawAgents(projectId)
|
||||||
|
.find((a) => a.id === agentId);
|
||||||
|
if (!agent) {
|
||||||
|
const err: GatewayError = {
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: `agent ${agentId} not found in project ${projectId}`,
|
||||||
|
};
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (agent.skills.some((s) => s.skillId === skillId)) return; // idempotent
|
||||||
|
this.agentGateway._setSkills(projectId, agentId, [
|
||||||
|
...agent.skills,
|
||||||
|
{ skillId, scope },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async unassignSkill(
|
||||||
|
projectId: string,
|
||||||
|
agentId: string,
|
||||||
|
skillId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const agent = this.agentGateway
|
||||||
|
._rawAgents(projectId)
|
||||||
|
.find((a) => a.id === agentId);
|
||||||
|
if (!agent) {
|
||||||
|
const err: GatewayError = {
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: `agent ${agentId} not found in project ${projectId}`,
|
||||||
|
};
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
this.agentGateway._setSkills(
|
||||||
|
projectId,
|
||||||
|
agentId,
|
||||||
|
agent.skills.filter((s) => s.skillId !== skillId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 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();
|
||||||
@ -1003,6 +1145,7 @@ export function createMockGateways(): Gateways {
|
|||||||
remote: new MockRemoteGateway(),
|
remote: new MockRemoteGateway(),
|
||||||
profile: new MockProfileGateway(),
|
profile: new MockProfileGateway(),
|
||||||
template: new MockTemplateGateway(agentGateway),
|
template: new MockTemplateGateway(agentGateway),
|
||||||
|
skill: new MockSkillGateway(agentGateway),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import { createMockGateways, MockSystemGateway } from "./index";
|
|||||||
const gateways: Gateways = createMockGateways();
|
const gateways: Gateways = createMockGateways();
|
||||||
|
|
||||||
describe("createMockGateways", () => {
|
describe("createMockGateways", () => {
|
||||||
it("exposes all nine gateways", () => {
|
it("exposes all ten gateways", () => {
|
||||||
expect(Object.keys(gateways).sort()).toEqual([
|
expect(Object.keys(gateways).sort()).toEqual([
|
||||||
"agent",
|
"agent",
|
||||||
"git",
|
"git",
|
||||||
@ -20,6 +20,7 @@ describe("createMockGateways", () => {
|
|||||||
"profile",
|
"profile",
|
||||||
"project",
|
"project",
|
||||||
"remote",
|
"remote",
|
||||||
|
"skill",
|
||||||
"system",
|
"system",
|
||||||
"template",
|
"template",
|
||||||
"terminal",
|
"terminal",
|
||||||
|
|||||||
70
frontend/src/adapters/skill.ts
Normal file
70
frontend/src/adapters/skill.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Tauri adapter for {@link SkillGateway} (L12).
|
||||||
|
*
|
||||||
|
* Commands use snake_case (Tauri convention); payload keys are camelCase
|
||||||
|
* (matching the backend DTO `#[serde(rename_all = "camelCase")]`), consistent
|
||||||
|
* with the other adapters in this directory. The `scope` value (`"global"` /
|
||||||
|
* `"project"`) maps directly onto the backend `SkillScope` serde enum.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
import type { Skill, SkillScope } from "@/domain";
|
||||||
|
import type { CreateSkillInput, SkillGateway } from "@/ports";
|
||||||
|
|
||||||
|
export class TauriSkillGateway implements SkillGateway {
|
||||||
|
listSkills(projectId: string, scope: SkillScope): Promise<Skill[]> {
|
||||||
|
return invoke<Skill[]>("list_skills", { projectId, scope });
|
||||||
|
}
|
||||||
|
|
||||||
|
createSkill(input: CreateSkillInput): Promise<Skill> {
|
||||||
|
return invoke<Skill>("create_skill", {
|
||||||
|
request: {
|
||||||
|
projectId: input.projectId,
|
||||||
|
name: input.name,
|
||||||
|
content: input.content,
|
||||||
|
scope: input.scope,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSkill(
|
||||||
|
projectId: string,
|
||||||
|
scope: SkillScope,
|
||||||
|
skillId: string,
|
||||||
|
content: string,
|
||||||
|
): Promise<Skill> {
|
||||||
|
return invoke<Skill>("update_skill", {
|
||||||
|
request: { projectId, scope, skillId, content },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSkill(
|
||||||
|
projectId: string,
|
||||||
|
scope: SkillScope,
|
||||||
|
skillId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await invoke("delete_skill", { projectId, scope, skillId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async assignSkill(
|
||||||
|
projectId: string,
|
||||||
|
agentId: string,
|
||||||
|
skillId: string,
|
||||||
|
scope: SkillScope,
|
||||||
|
): Promise<void> {
|
||||||
|
await invoke("assign_skill_to_agent", {
|
||||||
|
request: { projectId, agentId, skillId, scope },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async unassignSkill(
|
||||||
|
projectId: string,
|
||||||
|
agentId: string,
|
||||||
|
skillId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await invoke("unassign_skill_from_agent", {
|
||||||
|
request: { projectId, agentId, skillId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -236,6 +236,35 @@ export interface Agent {
|
|||||||
profileId: string;
|
profileId: string;
|
||||||
origin: AgentOrigin;
|
origin: AgentOrigin;
|
||||||
synchronized: boolean;
|
synchronized: boolean;
|
||||||
|
/** Skills assigned to this agent (injected into its convention file). */
|
||||||
|
skills: SkillRef[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Skills (L12) — mirror of the domain `Skill` / `SkillRef`.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a skill lives (selects its backing store): `global` skills are reusable
|
||||||
|
* across projects; `project` skills are specific to one project's `.ideai/`.
|
||||||
|
*/
|
||||||
|
export type SkillScope = "global" | "project";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reusable, model-agnostic workflow assignable to agents (mirror of the
|
||||||
|
* backend `Skill` DTO, camelCase wire format).
|
||||||
|
*/
|
||||||
|
export interface Skill {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
contentMd: string;
|
||||||
|
scope: SkillScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A reference from an agent to one assigned skill (mirror of `SkillRef`). */
|
||||||
|
export interface SkillRef {
|
||||||
|
skillId: string;
|
||||||
|
scope: SkillScope;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -54,6 +54,50 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
|||||||
void templateGw.listTemplates().then(setTemplates).catch(() => {});
|
void templateGw.listTemplates().then(setTemplates).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skills available for assignment (global + project), loaded lazily once.
|
||||||
|
const skillGw = gateways.skill ?? null;
|
||||||
|
const [skills, setSkills] = useState<import("@/domain").Skill[]>([]);
|
||||||
|
const [skillsLoaded, setSkillsLoaded] = useState(false);
|
||||||
|
const [skillToAssign, setSkillToAssign] = useState("");
|
||||||
|
|
||||||
|
async function refreshSkills() {
|
||||||
|
if (!skillGw) return;
|
||||||
|
try {
|
||||||
|
const [globals, projects] = await Promise.all([
|
||||||
|
skillGw.listSkills(projectId, "global"),
|
||||||
|
skillGw.listSkills(projectId, "project"),
|
||||||
|
]);
|
||||||
|
setSkills([...projects, ...globals]);
|
||||||
|
} catch {
|
||||||
|
// Skills are optional — leave the list empty on failure.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!skillsLoaded && skillGw) {
|
||||||
|
setSkillsLoaded(true);
|
||||||
|
void refreshSkills();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAssignSkill() {
|
||||||
|
if (!skillGw || !vm.selectedAgentId || !skillToAssign) return;
|
||||||
|
const target = skills.find((s) => s.id === skillToAssign);
|
||||||
|
if (!target) return;
|
||||||
|
await skillGw.assignSkill(
|
||||||
|
projectId,
|
||||||
|
vm.selectedAgentId,
|
||||||
|
target.id,
|
||||||
|
target.scope,
|
||||||
|
);
|
||||||
|
setSkillToAssign("");
|
||||||
|
await vm.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUnassignSkill(skillId: string) {
|
||||||
|
if (!skillGw || !vm.selectedAgentId) return;
|
||||||
|
await skillGw.unassignSkill(projectId, vm.selectedAgentId, skillId);
|
||||||
|
await vm.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
// Context editor state — local copy before Save
|
// Context editor state — local copy before Save
|
||||||
const [editedContext, setEditedContext] = useState(vm.context);
|
const [editedContext, setEditedContext] = useState(vm.context);
|
||||||
|
|
||||||
@ -351,6 +395,79 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Skill assignment ── */}
|
||||||
|
{selectedAgent && skillGw && (
|
||||||
|
<div className="flex flex-col gap-2 border-t border-border p-4">
|
||||||
|
<h4 className="text-sm font-semibold text-content">
|
||||||
|
Skills — {selectedAgent.name}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{/* Assigned skills */}
|
||||||
|
{selectedAgent.skills.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted">No skills assigned.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-wrap gap-1.5">
|
||||||
|
{selectedAgent.skills.map((ref) => {
|
||||||
|
const name =
|
||||||
|
skills.find((s) => s.id === ref.skillId)?.name ?? ref.skillId;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={ref.skillId}
|
||||||
|
className="flex items-center gap-1 rounded-full bg-raised px-2 py-0.5 text-xs text-content"
|
||||||
|
>
|
||||||
|
<span>{name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`unassign ${name}`}
|
||||||
|
disabled={vm.busy}
|
||||||
|
onClick={() => void handleUnassignSkill(ref.skillId)}
|
||||||
|
className="text-faint hover:text-danger"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Assign selector */}
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<select
|
||||||
|
aria-label="skill to assign"
|
||||||
|
value={skillToAssign}
|
||||||
|
onChange={(e) => setSkillToAssign(e.target.value)}
|
||||||
|
disabled={vm.busy}
|
||||||
|
className={cn(
|
||||||
|
"h-9 min-w-0 flex-1 rounded-md bg-raised px-3 text-sm text-content",
|
||||||
|
"border border-border outline-none transition-colors",
|
||||||
|
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<option value="">— assign a skill —</option>
|
||||||
|
{skills
|
||||||
|
.filter(
|
||||||
|
(s) =>
|
||||||
|
!selectedAgent.skills.some((r) => r.skillId === s.id),
|
||||||
|
)
|
||||||
|
.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name} ({s.scope})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
aria-label="assign skill"
|
||||||
|
disabled={vm.busy || !skillToAssign}
|
||||||
|
onClick={() => void handleAssignSkill()}
|
||||||
|
>
|
||||||
|
Assign
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Context editor ── */}
|
{/* ── Context editor ── */}
|
||||||
{selectedAgent && (
|
{selectedAgent && (
|
||||||
<div className="flex flex-col gap-2 border-t border-border p-4">
|
<div className="flex flex-col gap-2 border-t border-border p-4">
|
||||||
|
|||||||
@ -34,17 +34,19 @@ import type { LayoutInfo } from "@/domain";
|
|||||||
import { LayoutGrid, LayoutTabs } from "@/features/layout";
|
import { LayoutGrid, LayoutTabs } from "@/features/layout";
|
||||||
import { AgentsPanel } from "@/features/agents";
|
import { AgentsPanel } from "@/features/agents";
|
||||||
import { TemplatesPanel } from "@/features/templates";
|
import { TemplatesPanel } from "@/features/templates";
|
||||||
|
import { SkillsPanel } from "@/features/skills";
|
||||||
import { GitPanel, GitGraphView } from "@/features/git";
|
import { GitPanel, GitGraphView } from "@/features/git";
|
||||||
import { Button, Input, Panel, Tabs, cn } from "@/shared";
|
import { Button, Input, Panel, Tabs, cn } from "@/shared";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
import { useProjects } from "./useProjects";
|
import { useProjects } from "./useProjects";
|
||||||
|
|
||||||
type SidebarTab = "projects" | "agents" | "templates" | "git";
|
type SidebarTab = "projects" | "agents" | "templates" | "skills" | "git";
|
||||||
|
|
||||||
const SIDEBAR_TABS: { id: SidebarTab; label: string }[] = [
|
const SIDEBAR_TABS: { id: SidebarTab; label: string }[] = [
|
||||||
{ id: "projects", label: "Projects" },
|
{ id: "projects", label: "Projects" },
|
||||||
{ id: "agents", label: "Agents" },
|
{ id: "agents", label: "Agents" },
|
||||||
{ id: "templates", label: "Templates" },
|
{ id: "templates", label: "Templates" },
|
||||||
|
{ id: "skills", label: "Skills" },
|
||||||
{ id: "git", label: "Git" },
|
{ id: "git", label: "Git" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -259,6 +261,14 @@ export function ProjectsView() {
|
|||||||
<p className="text-sm text-muted">Open a project to manage templates.</p>
|
<p className="text-sm text-muted">Open a project to manage templates.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Skills panel */}
|
||||||
|
{sidebarTab === "skills" && active && (
|
||||||
|
<SkillsPanel projectId={active.id} />
|
||||||
|
)}
|
||||||
|
{sidebarTab === "skills" && !active && (
|
||||||
|
<p className="text-sm text-muted">Open a project to manage skills.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Git panel */}
|
{/* Git panel */}
|
||||||
{sidebarTab === "git" && active && (
|
{sidebarTab === "git" && active && (
|
||||||
<GitPanel projectId={active.id} />
|
<GitPanel projectId={active.id} />
|
||||||
|
|||||||
207
frontend/src/features/skills/SkillEditor.tsx
Normal file
207
frontend/src/features/skills/SkillEditor.tsx
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* `SkillEditor` — a fullscreen overlay for creating or editing skills (L12).
|
||||||
|
* Rendered on top of the rest of the UI (`fixed inset-0`).
|
||||||
|
*
|
||||||
|
* Provides:
|
||||||
|
* - Name field and scope selector (create-mode only; scope is fixed in edit-mode)
|
||||||
|
* - Two tabs: "Edit" (textarea) and "Preview" (react-markdown)
|
||||||
|
* - Save (create/update) and Cancel/Close buttons
|
||||||
|
*
|
||||||
|
* Pure presentation: all mutations are delegated to the callbacks supplied by
|
||||||
|
* the parent (`SkillsPanel`). Styled with `@/shared`; no inline styles.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
|
||||||
|
import type { Skill, SkillScope } from "@/domain";
|
||||||
|
import { Button, Input, cn } from "@/shared";
|
||||||
|
|
||||||
|
export interface SkillEditorProps {
|
||||||
|
/**
|
||||||
|
* When set, the editor is in edit-mode for the given skill; otherwise it is in
|
||||||
|
* create-mode (all fields start empty).
|
||||||
|
*/
|
||||||
|
skill?: Skill | null;
|
||||||
|
/** Called when the user submits the form. */
|
||||||
|
onSave: (name: string, content: string, scope: SkillScope) => Promise<void>;
|
||||||
|
/** Called when the user cancels / closes the overlay. */
|
||||||
|
onClose: () => void;
|
||||||
|
/** Whether a save operation is in flight. */
|
||||||
|
busy?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditorTab = "edit" | "preview";
|
||||||
|
|
||||||
|
export function SkillEditor({
|
||||||
|
skill,
|
||||||
|
onSave,
|
||||||
|
onClose,
|
||||||
|
busy = false,
|
||||||
|
}: SkillEditorProps) {
|
||||||
|
const [name, setName] = useState(skill?.name ?? "");
|
||||||
|
const [content, setContent] = useState(skill?.contentMd ?? "");
|
||||||
|
const [scope, setScope] = useState<SkillScope>(skill?.scope ?? "project");
|
||||||
|
const [tab, setTab] = useState<EditorTab>("edit");
|
||||||
|
|
||||||
|
const editing = skill != null;
|
||||||
|
const canSave = name.trim().length > 0 && content.trim().length > 0 && !busy;
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!canSave) return;
|
||||||
|
await onSave(name.trim(), content, scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex flex-col bg-canvas"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="skill editor"
|
||||||
|
>
|
||||||
|
{/* ── Header bar ── */}
|
||||||
|
<div className="flex shrink-0 items-center justify-between border-b border-border bg-surface px-4 py-3">
|
||||||
|
<h2 className="text-sm font-semibold text-content">
|
||||||
|
{editing ? `Edit skill — ${skill.name}` : "New skill"}
|
||||||
|
</h2>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label="close skill editor"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Form body ── */}
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => void handleSubmit(e)}
|
||||||
|
className="flex flex-1 flex-col gap-0 overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* ── Meta fields ── */}
|
||||||
|
<div className="flex shrink-0 flex-wrap items-end gap-3 border-b border-border px-4 py-3">
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
|
<label htmlFor="se-name" className="text-xs font-medium text-muted">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="se-name"
|
||||||
|
aria-label="skill name"
|
||||||
|
placeholder="My skill"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
className="min-w-48"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
|
<label htmlFor="se-scope" className="text-xs font-medium text-muted">
|
||||||
|
Scope
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="se-scope"
|
||||||
|
aria-label="skill scope"
|
||||||
|
value={scope}
|
||||||
|
onChange={(e) => setScope(e.target.value as SkillScope)}
|
||||||
|
disabled={busy || editing}
|
||||||
|
className={cn(
|
||||||
|
"h-9 w-full rounded-md bg-raised px-3 text-sm text-content",
|
||||||
|
"border border-border outline-none transition-colors",
|
||||||
|
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<option value="project">Project</option>
|
||||||
|
<option value="global">Global</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Tab strip ── */}
|
||||||
|
<div className="flex shrink-0 items-center gap-0 border-b border-border px-4">
|
||||||
|
{(["edit", "preview"] as EditorTab[]).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-label={t === "edit" ? "Edit" : "Preview"}
|
||||||
|
aria-selected={tab === t}
|
||||||
|
onClick={() => setTab(t)}
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-2 text-sm font-medium capitalize transition-colors",
|
||||||
|
tab === t
|
||||||
|
? "border-b-2 border-primary text-content"
|
||||||
|
: "text-muted hover:text-content",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t === "edit" ? "Edit" : "Preview"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Edit / Preview pane ── */}
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden px-4 py-3">
|
||||||
|
{tab === "edit" ? (
|
||||||
|
<textarea
|
||||||
|
aria-label="skill content"
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 w-full rounded-md bg-raised px-3 py-2 text-sm text-content font-mono",
|
||||||
|
"border border-border outline-none transition-colors resize-none",
|
||||||
|
"focus:border-primary placeholder:text-faint",
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
)}
|
||||||
|
placeholder="# Skill workflow ..."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex-1 overflow-auto rounded-md border border-border bg-raised px-6 py-4",
|
||||||
|
"prose prose-sm prose-invert max-w-none",
|
||||||
|
"[&_h1]:text-content [&_h2]:text-content [&_h3]:text-content",
|
||||||
|
"[&_p]:text-content/90 [&_li]:text-content/90",
|
||||||
|
"[&_code]:bg-canvas [&_code]:text-primary [&_code]:rounded [&_code]:px-1",
|
||||||
|
"[&_pre]:bg-canvas [&_pre]:border [&_pre]:border-border [&_pre]:rounded-md",
|
||||||
|
"[&_a]:text-primary [&_a:hover]:underline",
|
||||||
|
"[&_blockquote]:border-l-2 [&_blockquote]:border-primary [&_blockquote]:text-muted",
|
||||||
|
"[&_hr]:border-border",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{content.trim() ? (
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted italic">
|
||||||
|
Nothing to preview yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Footer ── */}
|
||||||
|
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-border px-4 py-3">
|
||||||
|
<Button type="button" variant="ghost" onClick={onClose} disabled={busy}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
aria-label="Save skill"
|
||||||
|
disabled={!canSave}
|
||||||
|
loading={busy}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
frontend/src/features/skills/SkillsPanel.tsx
Normal file
154
frontend/src/features/skills/SkillsPanel.tsx
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* `SkillsPanel` — feature component for skill management (L12).
|
||||||
|
*
|
||||||
|
* Pure presentation: all behaviour comes from {@link useSkills}. Styled with
|
||||||
|
* `@/shared` design system tokens; no inline styles.
|
||||||
|
*
|
||||||
|
* Provides, per scope (Global + Project):
|
||||||
|
* - Skill list (name + scope)
|
||||||
|
* - "New skill" button that opens {@link SkillEditor} in create-mode
|
||||||
|
* - Per-skill "Edit" (opens the editor in edit-mode) and "Delete"
|
||||||
|
*
|
||||||
|
* Skills are assigned to agents from the `AgentsPanel`, not here — this panel is
|
||||||
|
* the authoring surface only ("no direct invoke": a skill is never run from the
|
||||||
|
* UI, only injected into an agent's convention file at activation).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import type { Skill, SkillScope } from "@/domain";
|
||||||
|
import { Button, Panel, Spinner } from "@/shared";
|
||||||
|
import { SkillEditor } from "./SkillEditor";
|
||||||
|
import { useSkills } from "./useSkills";
|
||||||
|
|
||||||
|
export interface SkillsPanelProps {
|
||||||
|
/** The project whose skills to manage. */
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Editor open-state: "create" or edit-mode for a specific skill. */
|
||||||
|
type EditorState = { mode: "create" } | { mode: "edit"; skill: Skill };
|
||||||
|
|
||||||
|
export function SkillsPanel({ projectId }: SkillsPanelProps) {
|
||||||
|
const vm = useSkills(projectId);
|
||||||
|
|
||||||
|
const [editorState, setEditorState] = useState<EditorState | null>(null);
|
||||||
|
const [editorBusy, setEditorBusy] = useState(false);
|
||||||
|
|
||||||
|
async function handleSave(name: string, content: string, scope: SkillScope) {
|
||||||
|
if (!editorState) return;
|
||||||
|
setEditorBusy(true);
|
||||||
|
try {
|
||||||
|
if (editorState.mode === "create") {
|
||||||
|
await vm.createSkill({ name, content, scope });
|
||||||
|
} else {
|
||||||
|
await vm.updateSkill(editorState.skill.scope, editorState.skill.id, content);
|
||||||
|
}
|
||||||
|
setEditorState(null);
|
||||||
|
} finally {
|
||||||
|
setEditorBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList(scope: SkillScope, skills: Skill[]) {
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
{skills.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted">No {scope} skills yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col divide-y divide-border">
|
||||||
|
{skills.map((s) => (
|
||||||
|
<li
|
||||||
|
key={s.id}
|
||||||
|
className="flex items-center justify-between gap-3 py-3 first:pt-0 last:pb-0"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 font-medium text-content">{s.name}</span>
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`edit skill ${s.name}`}
|
||||||
|
disabled={vm.busy}
|
||||||
|
onClick={() => setEditorState({ mode: "edit", skill: s })}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`delete skill ${s.name}`}
|
||||||
|
disabled={vm.busy}
|
||||||
|
onClick={() => void vm.deleteSkill(s.scope, s.id)}
|
||||||
|
className="text-danger hover:text-danger"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* ── Fullscreen skill editor overlay ── */}
|
||||||
|
{editorState !== null && (
|
||||||
|
<SkillEditor
|
||||||
|
skill={editorState.mode === "edit" ? editorState.skill : null}
|
||||||
|
onSave={handleSave}
|
||||||
|
onClose={() => setEditorState(null)}
|
||||||
|
busy={editorBusy}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Panel title="Skills" className="flex flex-col gap-0">
|
||||||
|
{vm.error && (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="mx-4 mt-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
|
||||||
|
>
|
||||||
|
{vm.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Create button ── */}
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wide text-faint">
|
||||||
|
Skills
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{vm.busy && <Spinner size={14} />}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
aria-label="create skill"
|
||||||
|
disabled={vm.busy}
|
||||||
|
onClick={() => setEditorState({ mode: "create" })}
|
||||||
|
>
|
||||||
|
New skill
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Project skills ── */}
|
||||||
|
<div className="border-b border-border px-4 pt-3">
|
||||||
|
<h5 className="text-xs font-semibold uppercase tracking-wide text-faint">
|
||||||
|
Project
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
{renderList("project", vm.projectSkills)}
|
||||||
|
|
||||||
|
{/* ── Global skills ── */}
|
||||||
|
<div className="border-y border-border px-4 pt-3">
|
||||||
|
<h5 className="text-xs font-semibold uppercase tracking-wide text-faint">
|
||||||
|
Global
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
{renderList("global", vm.globalSkills)}
|
||||||
|
</Panel>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
frontend/src/features/skills/index.ts
Normal file
6
frontend/src/features/skills/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
/** Public surface of the skills feature (L12). */
|
||||||
|
export { SkillsPanel } from "./SkillsPanel";
|
||||||
|
export type { SkillsPanelProps } from "./SkillsPanel";
|
||||||
|
export { SkillEditor } from "./SkillEditor";
|
||||||
|
export { useSkills } from "./useSkills";
|
||||||
|
export type { SkillsViewModel } from "./useSkills";
|
||||||
297
frontend/src/features/skills/skills.test.tsx
Normal file
297
frontend/src/features/skills/skills.test.tsx
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
/**
|
||||||
|
* L12 — skills feature + agent assignment, wired to the stateful
|
||||||
|
* `MockSkillGateway` (sharing a `MockAgentGateway`) via the real `DIProvider`.
|
||||||
|
*
|
||||||
|
* Covers:
|
||||||
|
* - createSkill (project + global) → skill appears under the right scope
|
||||||
|
* - updateSkill → content persisted in the gateway
|
||||||
|
* - deleteSkill → skill removed from the list
|
||||||
|
* - assignSkill / unassignSkill in AgentsPanel → reflected on the agent record
|
||||||
|
* - guardrail "no direct invoke": SkillsPanel never offers a run/launch action
|
||||||
|
*
|
||||||
|
* Also includes MockSkillGateway unit tests.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
MockAgentGateway,
|
||||||
|
MockProfileGateway,
|
||||||
|
MockSkillGateway,
|
||||||
|
} from "@/adapters/mock";
|
||||||
|
import type { Gateways } from "@/ports";
|
||||||
|
import { DIProvider } from "@/app/di";
|
||||||
|
import { SkillsPanel } from "./SkillsPanel";
|
||||||
|
import { AgentsPanel } from "@/features/agents/AgentsPanel";
|
||||||
|
|
||||||
|
const PROJECT_ID = "proj-skill-test";
|
||||||
|
|
||||||
|
function renderSkillsPanel(agent?: MockAgentGateway, skill?: MockSkillGateway) {
|
||||||
|
const a = agent ?? new MockAgentGateway();
|
||||||
|
const sk = skill ?? new MockSkillGateway(a);
|
||||||
|
const gateways = { agent: a, skill: sk } as unknown as Gateways;
|
||||||
|
return {
|
||||||
|
agent: a,
|
||||||
|
skill: sk,
|
||||||
|
...render(
|
||||||
|
<DIProvider gateways={gateways}>
|
||||||
|
<SkillsPanel projectId={PROJECT_ID} />
|
||||||
|
</DIProvider>,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAgentsPanel(agent: MockAgentGateway, skill: MockSkillGateway) {
|
||||||
|
const profile = new MockProfileGateway();
|
||||||
|
const gateways = { agent, profile, skill } as unknown as Gateways;
|
||||||
|
return render(
|
||||||
|
<DIProvider gateways={gateways}>
|
||||||
|
<AgentsPanel projectId={PROJECT_ID} projectRoot="/tmp/proj" />
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForSkillsIdle() {
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("button", { name: "create skill" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the SkillEditor, fills it, and saves. */
|
||||||
|
async function createSkill(
|
||||||
|
name: string,
|
||||||
|
content = "# Workflow",
|
||||||
|
scope: "project" | "global" = "project",
|
||||||
|
) {
|
||||||
|
await waitForSkillsIdle();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "create skill" }));
|
||||||
|
await waitFor(() => expect(screen.getByLabelText("skill name")).toBeTruthy());
|
||||||
|
fireEvent.change(screen.getByLabelText("skill name"), {
|
||||||
|
target: { value: name },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText("skill content"), {
|
||||||
|
target: { value: content },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText("skill scope"), {
|
||||||
|
target: { value: scope },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save skill" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SkillsPanel feature tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("SkillsPanel (with MockSkillGateway)", () => {
|
||||||
|
it("shows empty state for both scopes initially", async () => {
|
||||||
|
renderSkillsPanel();
|
||||||
|
await waitForSkillsIdle();
|
||||||
|
expect(screen.getByText("No project skills yet.")).toBeTruthy();
|
||||||
|
expect(screen.getByText("No global skills yet.")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creating a project skill adds it to the list", async () => {
|
||||||
|
const { skill } = renderSkillsPanel();
|
||||||
|
await createSkill("Refactor", "## steps", "project");
|
||||||
|
await screen.findByText("Refactor");
|
||||||
|
const projects = await skill.listSkills(PROJECT_ID, "project");
|
||||||
|
expect(projects).toHaveLength(1);
|
||||||
|
expect(projects[0].name).toBe("Refactor");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creating a global skill stores it under the global scope", async () => {
|
||||||
|
const { skill } = renderSkillsPanel();
|
||||||
|
await createSkill("Review", "## global", "global");
|
||||||
|
await screen.findByText("Review");
|
||||||
|
expect(await skill.listSkills(PROJECT_ID, "global")).toHaveLength(1);
|
||||||
|
expect(await skill.listSkills(PROJECT_ID, "project")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Save is disabled when name or content is empty", async () => {
|
||||||
|
renderSkillsPanel();
|
||||||
|
await waitForSkillsIdle();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "create skill" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole("button", { name: "Save skill" })).toBeTruthy(),
|
||||||
|
);
|
||||||
|
const save = screen.getByRole("button", {
|
||||||
|
name: "Save skill",
|
||||||
|
}) as HTMLButtonElement;
|
||||||
|
expect(save.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("editing a skill persists the new content", async () => {
|
||||||
|
const { skill } = renderSkillsPanel();
|
||||||
|
await createSkill("Editable", "old", "project");
|
||||||
|
await screen.findByText("Editable");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "edit skill Editable" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByLabelText("skill content")).toBeTruthy(),
|
||||||
|
);
|
||||||
|
fireEvent.change(screen.getByLabelText("skill content"), {
|
||||||
|
target: { value: "new content" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save skill" }));
|
||||||
|
|
||||||
|
await waitFor(async () => {
|
||||||
|
const list = await skill.listSkills(PROJECT_ID, "project");
|
||||||
|
expect(list[0].contentMd).toBe("new content");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleting a skill removes it from the list", async () => {
|
||||||
|
renderSkillsPanel();
|
||||||
|
await createSkill("ToDelete", "x", "project");
|
||||||
|
await screen.findByText("ToDelete");
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "delete skill ToDelete" }),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(screen.queryByText("ToDelete")).toBeNull());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("guardrail: never offers a run/launch action for a skill (no direct invoke)", async () => {
|
||||||
|
renderSkillsPanel();
|
||||||
|
await createSkill("Plain", "x", "project");
|
||||||
|
await screen.findByText("Plain");
|
||||||
|
// Only Edit/Delete are exposed; nothing that would "run" the skill.
|
||||||
|
expect(screen.queryByRole("button", { name: /^run/i })).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: /^launch/i })).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: /invoke/i })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Assignment integration (AgentsPanel)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("Skill assignment (AgentsPanel + MockSkillGateway)", () => {
|
||||||
|
it("assigns a skill to the selected agent and shows it as a chip", async () => {
|
||||||
|
const agent = new MockAgentGateway();
|
||||||
|
const skill = new MockSkillGateway(agent);
|
||||||
|
const created = await agent.createAgent(PROJECT_ID, {
|
||||||
|
name: "Worker",
|
||||||
|
profileId: "p1",
|
||||||
|
});
|
||||||
|
await skill.createSkill({
|
||||||
|
projectId: PROJECT_ID,
|
||||||
|
name: "Deploy",
|
||||||
|
content: "## deploy",
|
||||||
|
scope: "project",
|
||||||
|
});
|
||||||
|
|
||||||
|
renderAgentsPanel(agent, skill);
|
||||||
|
await waitFor(() => expect(screen.getByLabelText("agent name")).toBeTruthy());
|
||||||
|
|
||||||
|
// Select the agent
|
||||||
|
fireEvent.click(screen.getByText("Worker"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByLabelText("skill to assign")).toBeTruthy(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Choose the skill and assign
|
||||||
|
const sk = (await skill.listSkills(PROJECT_ID, "project"))[0];
|
||||||
|
fireEvent.change(screen.getByLabelText("skill to assign"), {
|
||||||
|
target: { value: sk.id },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "assign skill" }));
|
||||||
|
|
||||||
|
// The agent record now carries the skill ref
|
||||||
|
await waitFor(async () => {
|
||||||
|
const agents = await agent.listAgents(PROJECT_ID);
|
||||||
|
const a = agents.find((x) => x.id === created.id)!;
|
||||||
|
expect(a.skills.map((s) => s.skillId)).toContain(sk.id);
|
||||||
|
});
|
||||||
|
// Chip with an unassign control appears
|
||||||
|
expect(screen.getByRole("button", { name: "unassign Deploy" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unassigns a skill from the agent", async () => {
|
||||||
|
const agent = new MockAgentGateway();
|
||||||
|
const skill = new MockSkillGateway(agent);
|
||||||
|
const a = await agent.createAgent(PROJECT_ID, {
|
||||||
|
name: "Worker2",
|
||||||
|
profileId: "p1",
|
||||||
|
});
|
||||||
|
const sk = await skill.createSkill({
|
||||||
|
projectId: PROJECT_ID,
|
||||||
|
name: "Cleanup",
|
||||||
|
content: "## cleanup",
|
||||||
|
scope: "global",
|
||||||
|
});
|
||||||
|
await skill.assignSkill(PROJECT_ID, a.id, sk.id, sk.scope);
|
||||||
|
|
||||||
|
renderAgentsPanel(agent, skill);
|
||||||
|
await waitFor(() => expect(screen.getByLabelText("agent name")).toBeTruthy());
|
||||||
|
fireEvent.click(screen.getByText("Worker2"));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "unassign Cleanup" }),
|
||||||
|
).toBeTruthy(),
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "unassign Cleanup" }));
|
||||||
|
|
||||||
|
await waitFor(async () => {
|
||||||
|
const agents = await agent.listAgents(PROJECT_ID);
|
||||||
|
expect(agents.find((x) => x.id === a.id)!.skills).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// MockSkillGateway unit tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("MockSkillGateway (unit)", () => {
|
||||||
|
it("scopes are isolated: a project skill never appears in global and vice-versa", async () => {
|
||||||
|
const gw = new MockSkillGateway(new MockAgentGateway());
|
||||||
|
await gw.createSkill({
|
||||||
|
projectId: "p",
|
||||||
|
name: "P",
|
||||||
|
content: "x",
|
||||||
|
scope: "project",
|
||||||
|
});
|
||||||
|
await gw.createSkill({
|
||||||
|
projectId: "p",
|
||||||
|
name: "G",
|
||||||
|
content: "y",
|
||||||
|
scope: "global",
|
||||||
|
});
|
||||||
|
expect(await gw.listSkills("p", "project")).toHaveLength(1);
|
||||||
|
expect(await gw.listSkills("p", "global")).toHaveLength(1);
|
||||||
|
expect((await gw.listSkills("p", "project"))[0].name).toBe("P");
|
||||||
|
expect((await gw.listSkills("p", "global"))[0].name).toBe("G");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updateSkill throws NOT_FOUND for unknown skill", async () => {
|
||||||
|
const gw = new MockSkillGateway(new MockAgentGateway());
|
||||||
|
await expect(
|
||||||
|
gw.updateSkill("p", "project", "ghost", "x"),
|
||||||
|
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assignSkill is idempotent (no duplicate refs)", async () => {
|
||||||
|
const agentGw = new MockAgentGateway();
|
||||||
|
const gw = new MockSkillGateway(agentGw);
|
||||||
|
const a = await agentGw.createAgent("p", { name: "A", profileId: "p1" });
|
||||||
|
const s = await gw.createSkill({
|
||||||
|
projectId: "p",
|
||||||
|
name: "S",
|
||||||
|
content: "x",
|
||||||
|
scope: "project",
|
||||||
|
});
|
||||||
|
await gw.assignSkill("p", a.id, s.id, s.scope);
|
||||||
|
await gw.assignSkill("p", a.id, s.id, s.scope);
|
||||||
|
const agents = await agentGw.listAgents("p");
|
||||||
|
expect(agents[0].skills).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assignSkill throws NOT_FOUND for unknown agent", async () => {
|
||||||
|
const gw = new MockSkillGateway(new MockAgentGateway());
|
||||||
|
await expect(
|
||||||
|
gw.assignSkill("p", "ghost", "s", "project"),
|
||||||
|
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||||
|
});
|
||||||
|
});
|
||||||
145
frontend/src/features/skills/useSkills.ts
Normal file
145
frontend/src/features/skills/useSkills.ts
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* `useSkills` — view-model hook for the skills feature (L12).
|
||||||
|
*
|
||||||
|
* Owns the skill lists for both scopes (`global` + `project`) of one project and
|
||||||
|
* the CRUD actions. Consumes {@link SkillGateway} exclusively; never touches
|
||||||
|
* `invoke()` or `@tauri-apps/api`, keeping the component layer testable with
|
||||||
|
* mock gateways (ARCHITECTURE §1.3).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import type { GatewayError, Skill, SkillScope } from "@/domain";
|
||||||
|
import type { CreateSkillInput } from "@/ports";
|
||||||
|
import { useGateways } from "@/app/di";
|
||||||
|
|
||||||
|
/** What the skills UI needs from this hook. */
|
||||||
|
export interface SkillsViewModel {
|
||||||
|
/** Global skills (reusable across projects). */
|
||||||
|
globalSkills: Skill[];
|
||||||
|
/** Project-scoped skills. */
|
||||||
|
projectSkills: Skill[];
|
||||||
|
/** Last error message, or `null`. */
|
||||||
|
error: string | null;
|
||||||
|
/** Whether a request is in flight. */
|
||||||
|
busy: boolean;
|
||||||
|
/** Reloads both scope lists. */
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
/** Creates a new skill and refreshes its scope's list. */
|
||||||
|
createSkill: (input: Omit<CreateSkillInput, "projectId">) => Promise<void>;
|
||||||
|
/** Updates the content of an existing skill. */
|
||||||
|
updateSkill: (
|
||||||
|
scope: SkillScope,
|
||||||
|
skillId: string,
|
||||||
|
content: string,
|
||||||
|
) => Promise<void>;
|
||||||
|
/** Deletes a skill by id from its scope. */
|
||||||
|
deleteSkill: (scope: SkillScope, skillId: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describe(e: unknown): string {
|
||||||
|
if (e && typeof e === "object" && "message" in e) {
|
||||||
|
return String((e as GatewayError).message);
|
||||||
|
}
|
||||||
|
return String(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSkills(projectId: string): SkillsViewModel {
|
||||||
|
const { skill } = useGateways();
|
||||||
|
|
||||||
|
const [globalSkills, setGlobalSkills] = useState<Skill[]>([]);
|
||||||
|
const [projectSkills, setProjectSkills] = useState<Skill[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
if (!projectId) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [globals, projects] = await Promise.all([
|
||||||
|
skill.listSkills(projectId, "global"),
|
||||||
|
skill.listSkills(projectId, "project"),
|
||||||
|
]);
|
||||||
|
setGlobalSkills(globals);
|
||||||
|
setProjectSkills(projects);
|
||||||
|
} catch (e) {
|
||||||
|
setError(describe(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [skill, projectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const setter = (scope: SkillScope) =>
|
||||||
|
scope === "global" ? setGlobalSkills : setProjectSkills;
|
||||||
|
|
||||||
|
const createSkill = useCallback(
|
||||||
|
async (input: Omit<CreateSkillInput, "projectId">) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const created = await skill.createSkill({ ...input, projectId });
|
||||||
|
setter(input.scope)((prev) => [...prev, created]);
|
||||||
|
} catch (e) {
|
||||||
|
setError(describe(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[skill, projectId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateSkill = useCallback(
|
||||||
|
async (scope: SkillScope, skillId: string, content: string) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const updated = await skill.updateSkill(
|
||||||
|
projectId,
|
||||||
|
scope,
|
||||||
|
skillId,
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
setter(scope)((prev) =>
|
||||||
|
prev.map((s) => (s.id === skillId ? updated : s)),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
setError(describe(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[skill, projectId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteSkill = useCallback(
|
||||||
|
async (scope: SkillScope, skillId: string) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await skill.deleteSkill(projectId, scope, skillId);
|
||||||
|
setter(scope)((prev) => prev.filter((s) => s.id !== skillId));
|
||||||
|
} catch (e) {
|
||||||
|
setError(describe(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[skill, projectId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
globalSkills,
|
||||||
|
projectSkills,
|
||||||
|
error,
|
||||||
|
busy,
|
||||||
|
refresh,
|
||||||
|
createSkill,
|
||||||
|
updateSkill,
|
||||||
|
deleteSkill,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -25,6 +25,8 @@ import type {
|
|||||||
LayoutTree,
|
LayoutTree,
|
||||||
Project,
|
Project,
|
||||||
ProfileAvailability,
|
ProfileAvailability,
|
||||||
|
Skill,
|
||||||
|
SkillScope,
|
||||||
Template,
|
Template,
|
||||||
Unsubscribe,
|
Unsubscribe,
|
||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
@ -260,6 +262,53 @@ export interface TemplateGateway {
|
|||||||
): Promise<{ synced: boolean; version: number | null }>;
|
): Promise<{ synced: boolean; version: number | null }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Input for {@link SkillGateway.createSkill}. */
|
||||||
|
export interface CreateSkillInput {
|
||||||
|
/** Owning project (resolved to a root; ignored on disk for `global`). */
|
||||||
|
projectId: string;
|
||||||
|
name: string;
|
||||||
|
content: string;
|
||||||
|
scope: SkillScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skills (L12): CRUD for reusable, model-agnostic workflows in either scope, and
|
||||||
|
* agent↔skill assignment. Assigned skills are injected into the agent's
|
||||||
|
* convention file at activation (handled backend-side, ARCHITECTURE §14.2).
|
||||||
|
*/
|
||||||
|
export interface SkillGateway {
|
||||||
|
/** Lists the skills in one scope for the given project. */
|
||||||
|
listSkills(projectId: string, scope: SkillScope): Promise<Skill[]>;
|
||||||
|
/** Creates a new skill; returns the created skill. */
|
||||||
|
createSkill(input: CreateSkillInput): Promise<Skill>;
|
||||||
|
/** Updates a skill's content; returns the updated skill. */
|
||||||
|
updateSkill(
|
||||||
|
projectId: string,
|
||||||
|
scope: SkillScope,
|
||||||
|
skillId: string,
|
||||||
|
content: string,
|
||||||
|
): Promise<Skill>;
|
||||||
|
/** Deletes a skill by id from its scope's store. */
|
||||||
|
deleteSkill(
|
||||||
|
projectId: string,
|
||||||
|
scope: SkillScope,
|
||||||
|
skillId: string,
|
||||||
|
): Promise<void>;
|
||||||
|
/** Assigns a skill to an agent (idempotent). */
|
||||||
|
assignSkill(
|
||||||
|
projectId: string,
|
||||||
|
agentId: string,
|
||||||
|
skillId: string,
|
||||||
|
scope: SkillScope,
|
||||||
|
): Promise<void>;
|
||||||
|
/** Unassigns a skill from an agent (idempotent). */
|
||||||
|
unassignSkill(
|
||||||
|
projectId: string,
|
||||||
|
agentId: string,
|
||||||
|
skillId: string,
|
||||||
|
): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI profiles & first-run (L5). Drives the first-run wizard and profile
|
* AI profiles & first-run (L5). Drives the first-run wizard and profile
|
||||||
* management: the pre-filled reference catalogue, detection of installed CLIs,
|
* management: the pre-filled reference catalogue, detection of installed CLIs,
|
||||||
@ -296,4 +345,5 @@ export interface Gateways {
|
|||||||
remote: RemoteGateway;
|
remote: RemoteGateway;
|
||||||
profile: ProfileGateway;
|
profile: ProfileGateway;
|
||||||
template: TemplateGateway;
|
template: TemplateGateway;
|
||||||
|
skill: SkillGateway;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user