From 8de7be01a8495bd3e153b61e1bb54689b6cc25eb Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 2 Jul 2026 22:34:04 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(tickets):=20checkpoint=20backend=20V1?= =?UTF-8?q?=20du=20syst=C3=A8me=20de=20tickets=20(T1-T5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checkpoint de travail — QA formelle encore à venir (après reset Codex). `cargo build` workspace OK, tests ticket/issue verts (domaine, store, use cases, app-tauri 47/51). Le domaine s'appelle `Issue`, exposé « ticket » côté MCP/UI. - T1 domaine : entité Issue (statut, priorité, liens, carnet), events, ids, ports. - T2 infra : store FS Markdown + allocator de références #N. - T3 application : use cases Issue (create/read/list/update/status/ priority/carnet/link/unlink/assign) + erreurs dédiées. - T4 surface MCP : 10 outils publics idea_ticket_* (23 outils au total), mappés vers les use cases Issue. - T5 app-tauri : commandes Tauri ticket_* + DTOs d'events Issue + câblage state.rs. Dette de test PRÉ-EXISTANTE héritée de develop (4 tests app-tauri mcp_e2e_loopback / mcp_serve_peer rouges) hors périmètre tickets, non traitée ici. Co-Authored-By: Claude Opus 4.8 --- crates/app-tauri/src/events.rs | 190 +++ crates/app-tauri/src/lib.rs | 10 + crates/app-tauri/src/state.rs | 175 ++- crates/app-tauri/src/tickets.rs | 1025 +++++++++++++++++ crates/application/src/error.rs | 12 + crates/application/src/issues/mod.rs | 774 +++++++++++++ crates/application/src/lib.rs | 9 + crates/application/tests/issue_usecases.rs | 402 +++++++ crates/domain/src/events.rs | 82 +- crates/domain/src/ids.rs | 4 + crates/domain/src/issue.rs | 454 ++++++++ crates/domain/src/lib.rs | 20 +- crates/domain/src/ports.rs | 103 ++ crates/domain/tests/issue.rs | 120 ++ crates/infrastructure/src/issues.rs | 576 +++++++++ crates/infrastructure/src/lib.rs | 6 +- .../src/orchestrator/mcp/mod.rs | 2 + .../src/orchestrator/mcp/server.rs | 51 + .../src/orchestrator/mcp/tickets.rs | 238 ++++ .../src/orchestrator/mcp/tools.rs | 14 +- crates/infrastructure/tests/issue_store.rs | 163 +++ crates/infrastructure/tests/mcp_server.rs | 15 +- 22 files changed, 4395 insertions(+), 50 deletions(-) create mode 100644 crates/app-tauri/src/tickets.rs create mode 100644 crates/application/src/issues/mod.rs create mode 100644 crates/application/tests/issue_usecases.rs create mode 100644 crates/domain/src/issue.rs create mode 100644 crates/domain/tests/issue.rs create mode 100644 crates/infrastructure/src/issues.rs create mode 100644 crates/infrastructure/src/orchestrator/mcp/tickets.rs create mode 100644 crates/infrastructure/tests/issue_store.rs diff --git a/crates/app-tauri/src/events.rs b/crates/app-tauri/src/events.rs index f31ff2e..e6e95f9 100644 --- a/crates/app-tauri/src/events.rs +++ b/crates/app-tauri/src/events.rs @@ -14,6 +14,7 @@ use tauri::{AppHandle, Emitter}; use domain::events::{DomainEvent, OrchestrationSource}; use domain::input::AgentLiveness; +use domain::{IssueLinkKind, IssuePriority, IssueStatus}; use infrastructure::TokioBroadcastEventBus; /// Name of the Tauri event carrying relayed [`DomainEvent`]s. @@ -158,6 +159,94 @@ pub enum DomainEventDto { /// Project id. project_id: String, }, + /// An issue-backed public ticket was created. + #[serde(rename_all = "camelCase")] + IssueCreated { + /// Issue id. + issue_id: String, + /// Public ticket reference (`#N`). + issue_ref: String, + }, + /// An issue-backed public ticket was updated. + #[serde(rename_all = "camelCase")] + IssueUpdated { + /// Public ticket reference (`#N`). + issue_ref: String, + /// New optimistic version. + version: u64, + }, + /// A public ticket status changed. + #[serde(rename_all = "camelCase")] + IssueStatusChanged { + /// Public ticket reference (`#N`). + issue_ref: String, + /// New status. + status: String, + /// New optimistic version. + version: u64, + }, + /// A public ticket priority changed. + #[serde(rename_all = "camelCase")] + IssuePriorityChanged { + /// Public ticket reference (`#N`). + issue_ref: String, + /// New priority. + priority: String, + /// New optimistic version. + version: u64, + }, + /// A public ticket carnet changed. + #[serde(rename_all = "camelCase")] + IssueCarnetUpdated { + /// Public ticket reference (`#N`). + issue_ref: String, + /// New optimistic version. + version: u64, + }, + /// A public ticket link was added. + #[serde(rename_all = "camelCase")] + IssueLinked { + /// Public source ticket reference (`#N`). + issue_ref: String, + /// Public target ticket reference (`#N`). + target: String, + /// Link kind. + kind: String, + /// New optimistic version. + version: u64, + }, + /// A public ticket link was removed. + #[serde(rename_all = "camelCase")] + IssueUnlinked { + /// Public source ticket reference (`#N`). + issue_ref: String, + /// Public target ticket reference (`#N`). + target: String, + /// Link kind. + kind: String, + /// New optimistic version. + version: u64, + }, + /// An agent was assigned to a public ticket. + #[serde(rename_all = "camelCase")] + IssueAgentAssigned { + /// Public ticket reference (`#N`). + issue_ref: String, + /// Assigned agent id. + agent_id: String, + /// New optimistic version. + version: u64, + }, + /// An agent was unassigned from a public ticket. + #[serde(rename_all = "camelCase")] + IssueAgentUnassigned { + /// Public ticket reference (`#N`). + issue_ref: String, + /// Unassigned agent id. + agent_id: String, + /// New optimistic version. + version: u64, + }, /// An orchestrator request was processed on behalf of a requester agent. #[serde(rename_all = "camelCase")] OrchestratorRequestProcessed { @@ -312,6 +401,34 @@ impl From for OrchestrationSourceDto { } } +fn issue_status_wire(status: IssueStatus) -> &'static str { + match status { + IssueStatus::Open => "open", + IssueStatus::InProgress => "inProgress", + IssueStatus::Qa => "QA", + IssueStatus::Closed => "closed", + } +} + +fn issue_priority_wire(priority: IssuePriority) -> &'static str { + match priority { + IssuePriority::Low => "low", + IssuePriority::Medium => "medium", + IssuePriority::High => "high", + IssuePriority::Critical => "critical", + } +} + +fn issue_link_kind_wire(kind: IssueLinkKind) -> &'static str { + match kind { + IssueLinkKind::RelatesTo => "relatesTo", + IssueLinkKind::Blocks => "blocks", + IssueLinkKind::BlockedBy => "blockedBy", + IssueLinkKind::Duplicates => "duplicates", + IssueLinkKind::DependsOn => "dependsOn", + } +} + impl From<&DomainEvent> for DomainEventDto { fn from(e: &DomainEvent) -> Self { match e { @@ -400,6 +517,79 @@ impl From<&DomainEvent> for DomainEventDto { DomainEvent::GitStateChanged { project_id } => Self::GitStateChanged { project_id: project_id.to_string(), }, + DomainEvent::IssueCreated { + issue_id, + issue_ref, + } => Self::IssueCreated { + issue_id: issue_id.to_string(), + issue_ref: issue_ref.to_string(), + }, + DomainEvent::IssueUpdated { issue_ref, version } => Self::IssueUpdated { + issue_ref: issue_ref.to_string(), + version: version.get(), + }, + DomainEvent::IssueStatusChanged { + issue_ref, + status, + version, + } => Self::IssueStatusChanged { + issue_ref: issue_ref.to_string(), + status: issue_status_wire(*status).to_owned(), + version: version.get(), + }, + DomainEvent::IssuePriorityChanged { + issue_ref, + priority, + version, + } => Self::IssuePriorityChanged { + issue_ref: issue_ref.to_string(), + priority: issue_priority_wire(*priority).to_owned(), + version: version.get(), + }, + DomainEvent::IssueCarnetUpdated { issue_ref, version } => Self::IssueCarnetUpdated { + issue_ref: issue_ref.to_string(), + version: version.get(), + }, + DomainEvent::IssueLinked { + issue_ref, + target, + kind, + version, + } => Self::IssueLinked { + issue_ref: issue_ref.to_string(), + target: target.to_string(), + kind: issue_link_kind_wire(*kind).to_owned(), + version: version.get(), + }, + DomainEvent::IssueUnlinked { + issue_ref, + target, + kind, + version, + } => Self::IssueUnlinked { + issue_ref: issue_ref.to_string(), + target: target.to_string(), + kind: issue_link_kind_wire(*kind).to_owned(), + version: version.get(), + }, + DomainEvent::IssueAgentAssigned { + issue_ref, + agent_id, + version, + } => Self::IssueAgentAssigned { + issue_ref: issue_ref.to_string(), + agent_id: agent_id.to_string(), + version: version.get(), + }, + DomainEvent::IssueAgentUnassigned { + issue_ref, + agent_id, + version, + } => Self::IssueAgentUnassigned { + issue_ref: issue_ref.to_string(), + agent_id: agent_id.to_string(), + version: version.get(), + }, DomainEvent::OrchestratorRequestProcessed { requester_id, action, diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 371fcfe..2681bbe 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -21,6 +21,7 @@ pub mod mcp_bridge; pub mod mcp_endpoint; pub mod pty; pub mod state; +pub mod tickets; use std::process::ExitCode; @@ -163,6 +164,15 @@ pub fn run() { commands::dismiss_embedder_suggestion, commands::create_agent, commands::list_agents, + tickets::ticket_create, + tickets::ticket_read, + tickets::ticket_list, + tickets::ticket_update, + tickets::ticket_read_carnet, + tickets::ticket_update_carnet, + tickets::ticket_link, + tickets::ticket_unlink, + tickets::ticket_assign, commands::get_project_work_state, commands::read_conversation_page, commands::list_live_agents, diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index 46b18ba..97a5f67 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -12,35 +12,36 @@ use std::path::PathBuf; use std::sync::{Arc, Mutex}; use application::{ - AgentResumer, AppError, AssignSkillToAgent, AttachLiveAgent, ChangeAgentProfile, - CheckEmbedderSuggestion, CloseProject, CloseTab, CloseTerminal, ConfigureProfiles, - ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate, CreateLayout, - CreateMemory, CreateProject, CreateSkill, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, - DeleteLayout, DeleteMemory, DeleteProfile, DeleteSkill, DeleteTemplate, - DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, - FirstRunState, GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectWorkState, - GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, - GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation, LaunchAgent, - LaunchAgentInput, ListAgents, ListAgentsInput, ListEmbedderProfiles, ListLayouts, ListMemories, - ListProfiles, ListProjects, ListResumableAgents, ListSkills, ListTemplates, LiveAgentRegistry, - LiveSessions, LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, - McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal, - OrchestratorService, PermissionProjectorRegistry, ProposeContext, ReadAgentContext, - ReadContext, ReadConversationPage, ReadMemory, ReadMemoryIndex, ReadProjectContext, ReadSkill, - RecallMemory, ReconcileLayouts, ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, - RecordTurnProvider, ReferenceProfiles, RenameLayout, ResizeTerminal, ResolveAgentPermissions, - ResolveMemoryLinks, RotateConversationLog, SaveEmbedderProfile, SaveProfile, - SessionLimitService, SetActiveLayout, SnapshotRunningAgents, StopLiveAgent, StructuredSessions, - SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, - UpdateAgentContext, UpdateAgentPermissions, UpdateLiveState, UpdateMemory, - UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate, WriteMemory, - WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, + AgentResumer, AppError, AssignIssueAgent, AssignSkillToAgent, AttachLiveAgent, + ChangeAgentProfile, CheckEmbedderSuggestion, CloseProject, CloseTab, CloseTerminal, + ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate, + CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateTemplate, + DeleteAgent, DeleteEmbedderProfile, DeleteLayout, DeleteMemory, DeleteProfile, DeleteSkill, + DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, + DismissEmbedderSuggestion, FirstRunState, GetLiveStateLean, GetMemory, GetProjectPermissions, + GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, + GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation, LaunchAgent, + LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListEmbedderProfiles, ListIssues, + ListLayouts, ListMemories, ListProfiles, ListProjects, ListResumableAgents, ListSkills, + ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, LiveStateProvider, + LiveStateReadProvider, LoadLayout, McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, + OpenProject, OpenTerminal, OrchestratorService, PermissionProjectorRegistry, ProposeContext, + ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory, + ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, + ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, + RenameLayout, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks, + RotateConversationLog, SaveEmbedderProfile, SaveProfile, SessionLimitService, SetActiveLayout, + SnapshotRunningAgents, StopLiveAgent, StructuredSessions, SuggestedThisSession, + SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UnlinkIssues, + UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, + UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate, + WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, }; use domain::ports::{ AgentContextStore, AgentRuntime, AgentSessionFactory, Clock, Embedder, EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, - MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner, ProfileStore, ProjectStore, - PtyPort, ScheduledTask, Scheduler, SkillStore, TemplateStore, + IssueNumberAllocator, IssueStore, MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner, + ProfileStore, ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, TemplateStore, }; use domain::profile::{ AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter, @@ -54,19 +55,21 @@ use infrastructure::{ embedder_from_profile, AdaptiveMemoryRecall, ClaudePermissionProjector, ClaudeTranscriptInspector, CliAgentRuntime, CodexPermissionProjector, EmbedderEnvProbe, FsConversationLog, FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, - FsLiveStateStore, FsMemoryStore, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, - FsProjectStore, FsProviderSessionStore, FsSkillStore, FsTemplateStore, Git2Repository, - HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, - LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, - OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory, - SystemClock, SystemMillisClock, TokioBroadcastEventBus, TokioScheduler, UuidGenerator, - VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, - VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, + FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsMemoryStore, FsOrchestratorWatcher, + FsPermissionStore, FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSkillStore, + FsTemplateStore, Git2Repository, HeuristicHandoffSummarizer, IdeaiContextStore, + InMemoryConversationRegistry, InMemoryMailbox, LocalFileSystem, LocalProcessSpawner, McpServer, + MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, + StructuredSessionFactory, SystemClock, SystemMillisClock, TicketToolProvider, + TokioBroadcastEventBus, TokioScheduler, UuidGenerator, VectorMemoryRecall, + DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, + VECTOR_ONNX_ENABLED, }; use crate::chat::ChatBridge; use crate::mcp_endpoint::{mcp_endpoint, AppMcpRuntimeProvider, McpEndpoint}; use crate::pty::PtyBridge; +use crate::tickets::AppTicketToolProvider; use infrastructure::StdioTransport; @@ -442,6 +445,26 @@ pub struct AppState { pub terminal_sessions: Arc, /// The domain event bus (also handed to the event relay). pub event_bus: Arc, + /// Create an issue-backed public ticket. + pub create_issue: Arc, + /// Read an issue-backed public ticket. + pub read_issue: Arc, + /// List issue-backed public tickets. + pub list_issues: Arc, + /// Update an issue-backed public ticket. + pub update_issue: Arc, + /// Read a ticket carnet. + pub read_issue_carnet: Arc, + /// Update a ticket carnet. + pub update_issue_carnet: Arc, + /// Link two public tickets. + pub link_issues: Arc, + /// Unlink public tickets. + pub unlink_issues: Arc, + /// Assign or unassign an agent on a public ticket. + pub assign_issue_agent: Arc, + /// MCP provider for the public `idea_ticket_*` tools. + pub(crate) ticket_tool_provider: Arc, /// Generic PTY↔Channel bridge registry (consumed by L3). pub pty_bridge: Arc, /// Registre des sessions structurées (IA / cellules chat, §17.5). Partagé avec @@ -795,6 +818,62 @@ impl AppState { let contexts = Arc::new(IdeaiContextStore::new(Arc::clone(&fs_port))); let contexts_port = Arc::clone(&contexts) as Arc; + // --- Public tickets (Issue domain) --- + // One stateless FS store/allocator serves every project; the project root is + // passed on each port call and resolves to `/.ideai/tickets/`. + let issue_store = Arc::new(FsIssueStore::new()); + let issue_store_port = Arc::clone(&issue_store) as Arc; + let issue_allocator = Arc::new(FsIssueNumberAllocator::new()); + let issue_allocator_port = Arc::clone(&issue_allocator) as Arc; + let create_issue = Arc::new(CreateIssue::new( + Arc::clone(&issue_store_port), + Arc::clone(&issue_allocator_port), + Arc::clone(&contexts_port), + Arc::clone(&ids) as Arc, + Arc::clone(&clock) as Arc, + Arc::clone(&events_port), + )); + let read_issue = Arc::new(ReadIssue::new(Arc::clone(&issue_store_port))); + let list_issues = Arc::new(ListIssues::new(Arc::clone(&issue_store_port))); + let update_issue = Arc::new(UpdateIssue::new( + Arc::clone(&issue_store_port), + Arc::clone(&contexts_port), + Arc::clone(&clock) as Arc, + Arc::clone(&events_port), + )); + let read_issue_carnet = Arc::new(ReadIssueCarnet::new(Arc::clone(&issue_store_port))); + let update_issue_carnet = Arc::new(UpdateIssueCarnet::new( + Arc::clone(&issue_store_port), + Arc::clone(&clock) as Arc, + Arc::clone(&events_port), + )); + let link_issues = Arc::new(LinkIssues::new( + Arc::clone(&issue_store_port), + Arc::clone(&clock) as Arc, + Arc::clone(&events_port), + )); + let unlink_issues = Arc::new(UnlinkIssues::new( + Arc::clone(&issue_store_port), + Arc::clone(&clock) as Arc, + Arc::clone(&events_port), + )); + let assign_issue_agent = Arc::new(AssignIssueAgent::new( + Arc::clone(&issue_store_port), + Arc::clone(&contexts_port), + Arc::clone(&clock) as Arc, + Arc::clone(&events_port), + )); + let ticket_tool_provider: Arc = Arc::new(AppTicketToolProvider { + create: Arc::clone(&create_issue), + read: Arc::clone(&read_issue), + list: Arc::clone(&list_issues), + update: Arc::clone(&update_issue), + read_carnet: Arc::clone(&read_issue_carnet), + update_carnet: Arc::clone(&update_issue_carnet), + link: Arc::clone(&link_issues), + unlink: Arc::clone(&unlink_issues), + }); + // --- Project permissions (LP1) --- let permission_store = Arc::new(FsPermissionStore::new(Arc::clone(&fs_port))); let permission_store_port = Arc::clone(&permission_store) as Arc; @@ -1500,6 +1579,16 @@ impl AppState { pty_port, terminal_sessions, event_bus, + create_issue, + read_issue, + list_issues, + update_issue, + read_issue_carnet, + update_issue_carnet, + link_issues, + unlink_issues, + assign_issue_agent, + ticket_tool_provider, pty_bridge, structured_sessions, chat_bridge, @@ -1654,7 +1743,8 @@ impl AppState { let handle = McpServerHandle::start( McpServer::new(Arc::clone(&self.orchestrator_service), project.clone()) .with_events(events) - .with_ready_sink(ready_sink), + .with_ready_sink(ready_sink) + .with_ticket_tools(Arc::clone(&self.ticket_tool_provider)), endpoint, listener, project_id, @@ -3343,6 +3433,7 @@ mod mcp_serve_peer_tests { let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); for expected in [ "idea_list_agents", + "idea_ask_agent", "idea_launch_agent", "idea_stop_agent", "idea_update_context", @@ -3357,18 +3448,28 @@ mod mcp_serve_peer_tests { // Live-state (programme live-state, lot LS4). "idea_workstate_read", "idea_workstate_set", + // Public ticket tools (Issue domain). + "idea_ticket_create", + "idea_ticket_read", + "idea_ticket_list", + "idea_ticket_update", + "idea_ticket_update_status", + "idea_ticket_update_priority", + "idea_ticket_read_carnet", + "idea_ticket_update_carnet", + "idea_ticket_link", + "idea_ticket_unlink", ] { assert!( names.contains(&expected), "missing tool {expected}; got {names:?}" ); } - assert!(!names.contains(&"idea_ask_agent")); assert!(!names.contains(&"idea_reply")); assert_eq!( tools.len(), - 12, - "exactly the twelve non-conversation idea_* tools; got {names:?}" + 23, + "exactly the twenty-three exposed idea_* tools; got {names:?}" ); drop(client); // EOF ⇒ serve loop ends diff --git a/crates/app-tauri/src/tickets.rs b/crates/app-tauri/src/tickets.rs new file mode 100644 index 0000000..42271fe --- /dev/null +++ b/crates/app-tauri/src/tickets.rs @@ -0,0 +1,1025 @@ +//! Public ticket adapters (Tauri commands + MCP provider). +//! +//! Public wire names use `ticket`; the application/domain model remains `Issue`. + +#![allow(missing_docs)] + +use std::str::FromStr; +use std::sync::Arc; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tauri::State; +use uuid::Uuid; + +use application::{ + AppError, AssignIssueAgentInput, CreateIssueInput, LinkIssuesInput, ListIssuesInput, + OpenProjectInput, ReadIssueCarnetInput, ReadIssueInput, UnlinkIssuesInput, + UpdateIssueCarnetInput, UpdateIssueInput, +}; +use domain::{ + AgentId, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueIndexEntry, IssueLink, + IssueLinkKind, IssueListFilter, IssuePriority, IssueRef, IssueStatus, IssueVersion, Project, + ProjectId, +}; +use infrastructure::{TicketToolError, TicketToolProvider}; + +use crate::dto::ErrorDto; +use crate::state::AppState; + +/// Public full ticket DTO. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TicketDto { + pub id: String, + pub r#ref: String, + pub number: u64, + pub title: String, + pub description: String, + pub status: String, + pub priority: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub carnet: Option, + pub links: Vec, + pub assigned_agent_ids: Vec, + pub created_by: TicketActorDto, + pub updated_by: TicketActorDto, + pub created_at: u64, + pub updated_at: u64, + pub version: u64, +} + +/// Public ticket summary DTO. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TicketSummaryDto { + pub r#ref: String, + pub path: String, + pub title: String, + pub status: String, + pub priority: String, + pub assigned_agent_ids: Vec, + pub updated_at: u64, +} + +/// Public list DTO. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TicketListDto { + pub items: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, +} + +/// Public link DTO. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TicketLinkDto { + pub target_ref: String, + pub kind: String, +} + +/// Public actor DTO. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum TicketActorDto { + User, + Agent { agent_id: String }, + System, +} + +/// Carnet read DTO. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TicketCarnetDto { + pub r#ref: String, + pub carnet: String, + pub version: u64, +} + +/// Create request. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TicketCreateRequestDto { + #[serde(default)] + pub project_id: String, + pub title: String, + pub description: Option, + pub priority: Option, + pub status: Option, + pub assigned_agent_ids: Option>, + pub links: Option>, +} + +/// Read request. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TicketReadRequestDto { + #[serde(default)] + pub project_id: String, + pub r#ref: String, + pub include_carnet: Option, +} + +/// List request. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TicketListRequestDto { + #[serde(default)] + pub project_id: String, + pub status: Option, + pub priority: Option, + pub assigned_agent_id: Option, + pub text: Option, + pub limit: Option, + pub cursor: Option, +} + +/// Update request. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TicketUpdateRequestDto { + #[serde(default)] + pub project_id: String, + pub r#ref: String, + pub title: Option, + pub description: Option, + pub status: Option, + pub priority: Option, + pub assigned_agent_ids: Option>, + pub expected_version: u64, +} + +/// Carnet update request. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TicketUpdateCarnetRequestDto { + #[serde(default)] + pub project_id: String, + pub r#ref: String, + pub carnet: String, + pub expected_version: u64, +} + +/// Link mutation request. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TicketLinkRequestDto { + pub target_ref: String, + pub kind: String, +} + +/// Link command request. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TicketLinkCommandRequestDto { + #[serde(default)] + pub project_id: String, + pub r#ref: String, + pub target_ref: String, + pub kind: String, + pub expected_version: u64, +} + +/// Unlink command request. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TicketUnlinkCommandRequestDto { + #[serde(default)] + pub project_id: String, + pub r#ref: String, + pub target_ref: String, + pub kind: Option, + pub expected_version: u64, +} + +/// Assign command request. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TicketAssignRequestDto { + #[serde(default)] + pub project_id: String, + pub r#ref: String, + pub agent_id: String, + pub assigned: bool, + pub expected_version: u64, +} + +#[derive(Clone)] +pub struct AppTicketToolProvider { + pub create: Arc, + pub read: Arc, + pub list: Arc, + pub update: Arc, + pub read_carnet: Arc, + pub update_carnet: Arc, + pub link: Arc, + pub unlink: Arc, +} + +#[async_trait] +impl TicketToolProvider for AppTicketToolProvider { + async fn handle_ticket_tool( + &self, + project: &Project, + requester: &str, + name: &str, + arguments: Value, + ) -> Result { + let actor = actor_from_requester(requester); + let result = match name { + "idea_ticket_create" => { + let req = mcp_create_request(project, arguments)?; + let issue = self + .create + .execute(create_input(project.clone(), req, actor).map_err(dto_tool_error)?) + .await + .map_err(ticket_error)? + .issue; + json!(TicketDto::from_issue(issue, None)) + } + "idea_ticket_read" => { + let issue_ref = parse_json_ref(&arguments, "ref")?; + let include_carnet = arguments + .get("includeCarnet") + .and_then(Value::as_bool) + .unwrap_or(false); + let issue = self + .read + .execute(ReadIssueInput { + project: project.clone(), + issue_ref, + }) + .await + .map_err(ticket_error)? + .issue; + let carnet = if include_carnet { + Some(read_carnet_body(&*self.read_carnet, project, issue_ref).await?) + } else { + None + }; + json!(TicketDto::from_issue(issue, carnet)) + } + "idea_ticket_list" => { + let req = mcp_list_request(project, arguments)?; + let rows = self + .list + .execute(ListIssuesInput { + project: project.clone(), + filter: req.filter, + }) + .await + .map_err(ticket_error)? + .issues; + json!(paginate(rows, req.limit, req.cursor)) + } + "idea_ticket_update" => { + let req = mcp_update_request(project, arguments)?; + let issue = self + .update + .execute(update_input(project.clone(), req, actor).map_err(dto_tool_error)?) + .await + .map_err(ticket_error)? + .issue; + json!(TicketDto::from_issue(issue, None)) + } + "idea_ticket_update_status" => { + let issue = self + .update + .execute(UpdateIssueInput { + project: project.clone(), + issue_ref: parse_json_ref(&arguments, "ref")?, + expected_version: parse_json_version(&arguments)?, + title: None, + description: None, + status: Some(parse_status(required_str(&arguments, "status")?)?), + priority: None, + assigned_agent_ids: None, + actor, + }) + .await + .map_err(ticket_error)? + .issue; + json!(TicketDto::from_issue(issue, None)) + } + "idea_ticket_update_priority" => { + let issue = self + .update + .execute(UpdateIssueInput { + project: project.clone(), + issue_ref: parse_json_ref(&arguments, "ref")?, + expected_version: parse_json_version(&arguments)?, + title: None, + description: None, + status: None, + priority: Some(parse_priority(required_str(&arguments, "priority")?)?), + assigned_agent_ids: None, + actor, + }) + .await + .map_err(ticket_error)? + .issue; + json!(TicketDto::from_issue(issue, None)) + } + "idea_ticket_read_carnet" => { + let carnet = self + .read_carnet + .execute(ReadIssueCarnetInput { + project: project.clone(), + issue_ref: parse_json_ref(&arguments, "ref")?, + }) + .await + .map_err(ticket_error)? + .carnet; + json!(TicketCarnetDto::from(carnet)) + } + "idea_ticket_update_carnet" => { + let issue_ref = parse_json_ref(&arguments, "ref")?; + self.update_carnet + .execute(UpdateIssueCarnetInput { + project: project.clone(), + issue_ref, + expected_version: parse_json_version(&arguments)?, + carnet: required_str(&arguments, "carnet")?.to_owned(), + actor, + }) + .await + .map_err(ticket_error)?; + let issue = self + .read + .execute(ReadIssueInput { + project: project.clone(), + issue_ref, + }) + .await + .map_err(ticket_error)? + .issue; + json!(TicketDto::from_issue( + issue, + Some(read_carnet_body(&*self.read_carnet, project, issue_ref).await?) + )) + } + "idea_ticket_link" => { + let issue = self + .link + .execute(LinkIssuesInput { + project: project.clone(), + issue_ref: parse_json_ref(&arguments, "ref")?, + target: parse_json_ref(&arguments, "targetRef")?, + kind: parse_link_kind(required_str(&arguments, "kind")?)?, + expected_version: parse_json_version(&arguments)?, + actor, + }) + .await + .map_err(ticket_error)? + .issue; + json!(TicketDto::from_issue(issue, None)) + } + "idea_ticket_unlink" => { + let kind = arguments + .get("kind") + .and_then(Value::as_str) + .map(parse_link_kind) + .transpose()?; + let issue = self + .unlink + .execute(UnlinkIssuesInput { + project: project.clone(), + issue_ref: parse_json_ref(&arguments, "ref")?, + target: parse_json_ref(&arguments, "targetRef")?, + kind, + expected_version: parse_json_version(&arguments)?, + actor, + }) + .await + .map_err(ticket_error)? + .issue; + json!(TicketDto::from_issue(issue, None)) + } + _ => { + return Err(TicketToolError::new( + "unknownTool", + format!("unknown tool: {name}"), + )) + } + }; + Ok(result) + } +} + +#[tauri::command] +pub async fn ticket_create( + request: TicketCreateRequestDto, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&state, &request.project_id).await?; + let issue = state + .create_issue + .execute(create_input(project, request, IssueActor::User)?) + .await + .map_err(ErrorDto::from)? + .issue; + Ok(TicketDto::from_issue(issue, None)) +} + +#[tauri::command] +pub async fn ticket_read( + request: TicketReadRequestDto, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&state, &request.project_id).await?; + let issue_ref = parse_ref_dto(&request.r#ref)?; + let issue = state + .read_issue + .execute(ReadIssueInput { + project: project.clone(), + issue_ref, + }) + .await + .map_err(ErrorDto::from)? + .issue; + let carnet = if request.include_carnet.unwrap_or(false) { + Some( + state + .read_issue_carnet + .execute(ReadIssueCarnetInput { project, issue_ref }) + .await + .map_err(ErrorDto::from)? + .carnet + .carnet + .as_str() + .to_owned(), + ) + } else { + None + }; + Ok(TicketDto::from_issue(issue, carnet)) +} + +#[tauri::command] +pub async fn ticket_list( + request: TicketListRequestDto, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&state, &request.project_id).await?; + let page = TicketListPageInput::from_request(request)?; + let rows = state + .list_issues + .execute(ListIssuesInput { + project, + filter: page.filter, + }) + .await + .map_err(ErrorDto::from)? + .issues; + Ok(paginate(rows, page.limit, page.cursor)) +} + +#[tauri::command] +pub async fn ticket_update( + request: TicketUpdateRequestDto, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&state, &request.project_id).await?; + let issue = state + .update_issue + .execute(update_input(project, request, IssueActor::User)?) + .await + .map_err(ErrorDto::from)? + .issue; + Ok(TicketDto::from_issue(issue, None)) +} + +#[tauri::command] +pub async fn ticket_read_carnet( + request: TicketReadRequestDto, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&state, &request.project_id).await?; + state + .read_issue_carnet + .execute(ReadIssueCarnetInput { + project, + issue_ref: parse_ref_dto(&request.r#ref)?, + }) + .await + .map(|out| TicketCarnetDto::from(out.carnet)) + .map_err(ErrorDto::from) +} + +#[tauri::command] +pub async fn ticket_update_carnet( + request: TicketUpdateCarnetRequestDto, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&state, &request.project_id).await?; + let issue_ref = parse_ref_dto(&request.r#ref)?; + state + .update_issue_carnet + .execute(UpdateIssueCarnetInput { + project: project.clone(), + issue_ref, + expected_version: version_dto(request.expected_version)?, + carnet: request.carnet, + actor: IssueActor::User, + }) + .await + .map_err(ErrorDto::from)?; + let issue = state + .read_issue + .execute(ReadIssueInput { project, issue_ref }) + .await + .map_err(ErrorDto::from)? + .issue; + Ok(TicketDto::from_issue(issue, None)) +} + +#[tauri::command] +pub async fn ticket_link( + request: TicketLinkCommandRequestDto, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&state, &request.project_id).await?; + let issue = state + .link_issues + .execute(LinkIssuesInput { + project, + issue_ref: parse_ref_dto(&request.r#ref)?, + target: parse_ref_dto(&request.target_ref)?, + kind: parse_link_kind_dto(&request.kind)?, + expected_version: version_dto(request.expected_version)?, + actor: IssueActor::User, + }) + .await + .map_err(ErrorDto::from)? + .issue; + Ok(TicketDto::from_issue(issue, None)) +} + +#[tauri::command] +pub async fn ticket_unlink( + request: TicketUnlinkCommandRequestDto, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&state, &request.project_id).await?; + let issue = state + .unlink_issues + .execute(UnlinkIssuesInput { + project, + issue_ref: parse_ref_dto(&request.r#ref)?, + target: parse_ref_dto(&request.target_ref)?, + kind: request + .kind + .as_deref() + .map(parse_link_kind_dto) + .transpose()?, + expected_version: version_dto(request.expected_version)?, + actor: IssueActor::User, + }) + .await + .map_err(ErrorDto::from)? + .issue; + Ok(TicketDto::from_issue(issue, None)) +} + +#[tauri::command] +pub async fn ticket_assign( + request: TicketAssignRequestDto, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&state, &request.project_id).await?; + let agent_id = parse_agent_id_dto(&request.agent_id)?; + let issue = state + .assign_issue_agent + .execute(AssignIssueAgentInput { + project, + issue_ref: parse_ref_dto(&request.r#ref)?, + agent_id, + assigned: request.assigned, + expected_version: version_dto(request.expected_version)?, + actor: IssueActor::User, + }) + .await + .map_err(ErrorDto::from)? + .issue; + Ok(TicketDto::from_issue(issue, None)) +} + +impl TicketDto { + fn from_issue(issue: Issue, carnet: Option) -> Self { + Self { + id: issue.id.to_string(), + r#ref: issue.reference().to_string(), + number: issue.number.get(), + title: issue.title, + description: issue.description.as_str().to_owned(), + status: status_wire(issue.status).to_owned(), + priority: priority_wire(issue.priority).to_owned(), + carnet, + links: issue.links.into_iter().map(TicketLinkDto::from).collect(), + assigned_agent_ids: issue + .agent_refs + .iter() + .filter(|r| r.role == AgentIssueRole::Assigned) + .map(|r| r.agent_id.to_string()) + .collect(), + created_by: TicketActorDto::from(issue.created_by), + updated_by: TicketActorDto::from(issue.updated_by), + created_at: issue.created_at, + updated_at: issue.updated_at, + version: issue.version.get(), + } + } +} + +impl From for TicketSummaryDto { + fn from(row: IssueIndexEntry) -> Self { + Self { + r#ref: row.issue_ref.to_string(), + path: row.path, + title: row.title, + status: status_wire(row.status).to_owned(), + priority: priority_wire(row.priority).to_owned(), + assigned_agent_ids: row + .assigned_agent_ids + .into_iter() + .map(|id| id.to_string()) + .collect(), + updated_at: row.updated_at, + } + } +} + +impl From for TicketLinkDto { + fn from(link: IssueLink) -> Self { + Self { + target_ref: link.target.to_string(), + kind: link_kind_wire(link.kind).to_owned(), + } + } +} + +impl From for TicketActorDto { + fn from(actor: IssueActor) -> Self { + match actor { + IssueActor::User => Self::User, + IssueActor::Agent { agent_id } => Self::Agent { + agent_id: agent_id.to_string(), + }, + IssueActor::System => Self::System, + } + } +} + +impl From for TicketCarnetDto { + fn from(carnet: IssueCarnet) -> Self { + Self { + r#ref: carnet.issue_ref.to_string(), + carnet: carnet.carnet.as_str().to_owned(), + version: carnet.version.get(), + } + } +} + +struct TicketListPageInput { + filter: IssueListFilter, + limit: usize, + cursor: Option, +} + +impl TicketListPageInput { + fn from_request(request: TicketListRequestDto) -> Result { + Ok(Self { + filter: IssueListFilter { + status: request + .status + .as_deref() + .map(parse_status_dto) + .transpose()?, + priority: request + .priority + .as_deref() + .map(parse_priority_dto) + .transpose()?, + assigned_agent_id: request + .assigned_agent_id + .as_deref() + .map(parse_agent_id_dto) + .transpose()?, + text: request.text, + }, + limit: request.limit.unwrap_or(100).clamp(1, 500), + cursor: request.cursor, + }) + } +} + +async fn resolve_project(state: &AppState, project_id: &str) -> Result { + let id = ProjectId::from_uuid( + Uuid::parse_str(project_id) + .map_err(|e| ErrorDto::invalid(format!("invalid project id: {e}")))?, + ); + state + .open_project + .execute(OpenProjectInput { project_id: id }) + .await + .map(|out| out.project) + .map_err(ErrorDto::from) +} + +fn create_input( + project: Project, + request: TicketCreateRequestDto, + actor: IssueActor, +) -> Result { + Ok(CreateIssueInput { + project, + title: request.title, + description: request.description.unwrap_or_default(), + priority: request + .priority + .as_deref() + .map(parse_priority_dto) + .transpose()? + .unwrap_or(IssuePriority::Medium), + status: request + .status + .as_deref() + .map(parse_status_dto) + .transpose()? + .unwrap_or(IssueStatus::Open), + links: request + .links + .unwrap_or_default() + .into_iter() + .map(parse_link_request) + .collect::, _>>()?, + assigned_agent_ids: request + .assigned_agent_ids + .unwrap_or_default() + .iter() + .map(|id| parse_agent_id_dto(id)) + .collect::, _>>()?, + actor, + }) +} + +fn update_input( + project: Project, + request: TicketUpdateRequestDto, + actor: IssueActor, +) -> Result { + Ok(UpdateIssueInput { + project, + issue_ref: parse_ref_dto(&request.r#ref)?, + expected_version: version_dto(request.expected_version)?, + title: request.title, + description: request.description, + status: request + .status + .as_deref() + .map(parse_status_dto) + .transpose()?, + priority: request + .priority + .as_deref() + .map(parse_priority_dto) + .transpose()?, + assigned_agent_ids: request + .assigned_agent_ids + .map(|ids| ids.iter().map(|id| parse_agent_id_dto(id)).collect()) + .transpose()?, + actor, + }) +} + +fn parse_link_request(link: TicketLinkRequestDto) -> Result { + Ok(IssueLink { + target: parse_ref_dto(&link.target_ref)?, + kind: parse_link_kind_dto(&link.kind)?, + }) +} + +fn paginate(rows: Vec, limit: usize, cursor: Option) -> TicketListDto { + let start = cursor + .as_deref() + .and_then(|raw| raw.parse::().ok()) + .unwrap_or(0); + let total = rows.len(); + let end = total.min(start.saturating_add(limit)); + TicketListDto { + items: rows + .into_iter() + .skip(start) + .take(limit) + .map(TicketSummaryDto::from) + .collect(), + next_cursor: (end < total).then(|| end.to_string()), + } +} + +async fn read_carnet_body( + read_carnet: &application::ReadIssueCarnet, + project: &Project, + issue_ref: IssueRef, +) -> Result { + read_carnet + .execute(ReadIssueCarnetInput { + project: project.clone(), + issue_ref, + }) + .await + .map(|out| out.carnet.carnet.as_str().to_owned()) + .map_err(ticket_error) +} + +fn actor_from_requester(requester: &str) -> IssueActor { + Uuid::parse_str(requester) + .ok() + .map(|uuid| IssueActor::Agent { + agent_id: AgentId::from_uuid(uuid), + }) + .unwrap_or(IssueActor::System) +} + +fn parse_ref_dto(raw: &str) -> Result { + IssueRef::from_str(raw).map_err(|e| ErrorDto::invalid(e.to_string())) +} + +fn parse_agent_id_dto(raw: &str) -> Result { + Uuid::parse_str(raw) + .map(AgentId::from_uuid) + .map_err(|e| ErrorDto::invalid(format!("invalid agent id: {e}"))) +} + +fn version_dto(raw: u64) -> Result { + IssueVersion::new(raw).map_err(|e| ErrorDto::invalid(e.to_string())) +} + +fn parse_status_dto(raw: &str) -> Result { + parse_status(raw).map_err(|e| ErrorDto::invalid(e.message)) +} + +fn parse_priority_dto(raw: &str) -> Result { + parse_priority(raw).map_err(|e| ErrorDto::invalid(e.message)) +} + +fn parse_link_kind_dto(raw: &str) -> Result { + parse_link_kind(raw).map_err(|e| ErrorDto::invalid(e.message)) +} + +fn parse_status(raw: &str) -> Result { + match raw { + "open" => Ok(IssueStatus::Open), + "inProgress" => Ok(IssueStatus::InProgress), + "QA" => Ok(IssueStatus::Qa), + "closed" => Ok(IssueStatus::Closed), + _ => Err(TicketToolError::new( + "invalid", + format!("invalid ticket status: {raw}"), + )), + } +} + +fn parse_priority(raw: &str) -> Result { + match raw { + "low" => Ok(IssuePriority::Low), + "medium" => Ok(IssuePriority::Medium), + "high" => Ok(IssuePriority::High), + "critical" => Ok(IssuePriority::Critical), + _ => Err(TicketToolError::new( + "invalid", + format!("invalid ticket priority: {raw}"), + )), + } +} + +fn parse_link_kind(raw: &str) -> Result { + match raw { + "relatesTo" => Ok(IssueLinkKind::RelatesTo), + "blocks" => Ok(IssueLinkKind::Blocks), + "blockedBy" => Ok(IssueLinkKind::BlockedBy), + "duplicates" => Ok(IssueLinkKind::Duplicates), + "dependsOn" => Ok(IssueLinkKind::DependsOn), + _ => Err(TicketToolError::new( + "invalid", + format!("invalid ticket link kind: {raw}"), + )), + } +} + +fn status_wire(status: IssueStatus) -> &'static str { + match status { + IssueStatus::Open => "open", + IssueStatus::InProgress => "inProgress", + IssueStatus::Qa => "QA", + IssueStatus::Closed => "closed", + } +} + +fn priority_wire(priority: IssuePriority) -> &'static str { + match priority { + IssuePriority::Low => "low", + IssuePriority::Medium => "medium", + IssuePriority::High => "high", + IssuePriority::Critical => "critical", + } +} + +fn link_kind_wire(kind: IssueLinkKind) -> &'static str { + match kind { + IssueLinkKind::RelatesTo => "relatesTo", + IssueLinkKind::Blocks => "blocks", + IssueLinkKind::BlockedBy => "blockedBy", + IssueLinkKind::Duplicates => "duplicates", + IssueLinkKind::DependsOn => "dependsOn", + } +} + +fn ticket_error(err: AppError) -> TicketToolError { + let message = err.to_string(); + let code = if message.contains("issue version conflict") { + "versionConflict" + } else { + match err.code() { + "NOT_FOUND" => "notFound", + "INVALID" => "invalid", + "STORE" => "store", + "FILESYSTEM" => "filesystem", + _ => "internal", + } + }; + TicketToolError::new(code, message) +} + +fn dto_tool_error(err: ErrorDto) -> TicketToolError { + let code = match err.code.as_str() { + "NOT_FOUND" => "notFound", + "INVALID" => "invalid", + "STORE" => "store", + "FILESYSTEM" => "filesystem", + _ => "internal", + }; + TicketToolError::new(code, err.message) +} + +fn mcp_create_request( + project: &Project, + arguments: Value, +) -> Result { + let mut req: TicketCreateRequestDto = serde_json::from_value(arguments) + .map_err(|e| TicketToolError::new("invalid", e.to_string()))?; + req.project_id = project.id.to_string(); + Ok(req) +} + +fn mcp_update_request( + project: &Project, + arguments: Value, +) -> Result { + let mut req: TicketUpdateRequestDto = serde_json::from_value(arguments) + .map_err(|e| TicketToolError::new("invalid", e.to_string()))?; + req.project_id = project.id.to_string(); + Ok(req) +} + +fn mcp_list_request( + project: &Project, + arguments: Value, +) -> Result { + let mut req: TicketListRequestDto = serde_json::from_value(arguments) + .map_err(|e| TicketToolError::new("invalid", e.to_string()))?; + req.project_id = project.id.to_string(); + TicketListPageInput::from_request(req).map_err(|e| TicketToolError::new("invalid", e.message)) +} + +fn parse_json_ref(arguments: &Value, key: &str) -> Result { + required_str(arguments, key).and_then(|raw| { + IssueRef::from_str(raw).map_err(|e| TicketToolError::new("invalid", e.to_string())) + }) +} + +fn parse_json_version(arguments: &Value) -> Result { + let version = arguments + .get("expectedVersion") + .and_then(Value::as_u64) + .ok_or_else(|| TicketToolError::new("invalid", "missing expectedVersion"))?; + IssueVersion::new(version).map_err(|e| TicketToolError::new("invalid", e.to_string())) +} + +fn required_str<'a>(arguments: &'a Value, key: &str) -> Result<&'a str, TicketToolError> { + arguments + .get(key) + .and_then(Value::as_str) + .ok_or_else(|| TicketToolError::new("invalid", format!("missing {key}"))) +} + +impl ErrorDto { + fn invalid(message: impl Into) -> Self { + Self { + code: "INVALID".to_owned(), + message: message.into(), + } + } +} diff --git a/crates/application/src/error.rs b/crates/application/src/error.rs index 8ae5944..fb7205f 100644 --- a/crates/application/src/error.rs +++ b/crates/application/src/error.rs @@ -9,6 +9,7 @@ use domain::ports::{ AgentSessionError, EmbedderError, FsError, GitError, MemoryError, ProcessError, PtyError, RemoteError, RuntimeError, StoreError, }; +use domain::IssueStoreError; use domain::{AgentId, NodeId}; /// Errors surfaced by application use cases. @@ -128,6 +129,17 @@ impl From for AppError { } } +impl From for AppError { + fn from(e: IssueStoreError) -> Self { + match e { + IssueStoreError::NotFound => Self::NotFound("issue".to_owned()), + IssueStoreError::VersionConflict { .. } => Self::Invalid(e.to_string()), + IssueStoreError::Invalid(message) => Self::Invalid(message), + IssueStoreError::Store(message) => Self::Store(message), + } + } +} + impl From for AppError { fn from(e: MemoryError) -> Self { match e { diff --git a/crates/application/src/issues/mod.rs b/crates/application/src/issues/mod.rs new file mode 100644 index 0000000..b736dbb --- /dev/null +++ b/crates/application/src/issues/mod.rs @@ -0,0 +1,774 @@ +//! Issue use cases. +//! +//! The domain/code term is `Issue` throughout this layer. + +use std::collections::HashSet; +use std::sync::Arc; + +use domain::{AgentContextStore, Clock, EventBus, IdGenerator}; +use domain::{ + AgentId, AgentIssueRef, AgentIssueRole, DomainEvent, Issue, IssueActor, IssueCarnet, IssueId, + IssueIndexEntry, IssueLink, IssueLinkKind, IssueListFilter, IssueNumberAllocator, + IssuePriority, IssueRef, IssueStatus, IssueStore, IssueVersion, MarkdownDoc, Project, +}; + +use crate::error::AppError; + +/// Input for [`CreateIssue::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreateIssueInput { + /// Project owning the issue. + pub project: Project, + /// Issue title. + pub title: String, + /// Issue description Markdown. + pub description: String, + /// Initial priority. + pub priority: IssuePriority, + /// Initial status. + pub status: IssueStatus, + /// Initial issue links. + pub links: Vec, + /// Initially assigned agents. + pub assigned_agent_ids: Vec, + /// Actor creating the issue. + pub actor: IssueActor, +} + +/// Output of [`CreateIssue::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreateIssueOutput { + /// Created issue. + pub issue: Issue, +} + +/// Creates an issue, allocating a non-reused project-local number. +pub struct CreateIssue { + issues: Arc, + allocator: Arc, + contexts: Arc, + ids: Arc, + clock: Arc, + events: Arc, +} + +impl CreateIssue { + /// Builds the use case. + #[must_use] + pub fn new( + issues: Arc, + allocator: Arc, + contexts: Arc, + ids: Arc, + clock: Arc, + events: Arc, + ) -> Self { + Self { + issues, + allocator, + contexts, + ids, + clock, + events, + } + } + + /// Executes creation. + /// + /// # Errors + /// [`AppError`] on invariant, manifest validation or store failure. + pub async fn execute(&self, input: CreateIssueInput) -> Result { + validate_agents(&self.contexts, &input.project, &input.assigned_agent_ids).await?; + let number = self.allocator.allocate_next(&input.project.root).await?; + let issue_ref = IssueRef::from(number); + for link in &input.links { + if link.target != issue_ref { + self.issues + .get_by_ref(&input.project.root, link.target) + .await?; + } + } + let agent_refs = input + .assigned_agent_ids + .into_iter() + .map(|agent_id| AgentIssueRef { + agent_id, + role: AgentIssueRole::Assigned, + }) + .collect(); + let issue = Issue::new( + IssueId::from_uuid(self.ids.new_uuid()), + number, + input.title, + MarkdownDoc::new(input.description), + input.status, + input.priority, + MarkdownDoc::default(), + input.links, + agent_refs, + input.actor, + now(&self.clock), + ) + .map_err(|err| AppError::Invalid(err.to_string()))?; + self.issues.create(&input.project.root, &issue).await?; + self.events.publish(DomainEvent::IssueCreated { + issue_id: issue.id, + issue_ref: issue.reference(), + }); + Ok(CreateIssueOutput { issue }) + } +} + +/// Input for [`ReadIssue::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReadIssueInput { + /// Project owning the issue. + pub project: Project, + /// Issue reference. + pub issue_ref: IssueRef, +} + +/// Output of [`ReadIssue::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReadIssueOutput { + /// Issue. + pub issue: Issue, +} + +/// Reads an issue. +pub struct ReadIssue { + issues: Arc, +} + +impl ReadIssue { + /// Builds the use case. + #[must_use] + pub fn new(issues: Arc) -> Self { + Self { issues } + } + + /// Executes read. + /// + /// # Errors + /// [`AppError`] on store failure. + pub async fn execute(&self, input: ReadIssueInput) -> Result { + Ok(ReadIssueOutput { + issue: self + .issues + .get_by_ref(&input.project.root, input.issue_ref) + .await?, + }) + } +} + +/// Input for [`ReadIssueCarnet::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReadIssueCarnetInput { + /// Project owning the issue. + pub project: Project, + /// Issue reference. + pub issue_ref: IssueRef, +} + +/// Output of [`ReadIssueCarnet::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReadIssueCarnetOutput { + /// Carnet projection. + pub carnet: IssueCarnet, +} + +/// Reads an issue carnet. +pub struct ReadIssueCarnet { + issues: Arc, +} + +impl ReadIssueCarnet { + /// Builds the use case. + #[must_use] + pub fn new(issues: Arc) -> Self { + Self { issues } + } + + /// Executes carnet read. + /// + /// # Errors + /// [`AppError`] on store failure. + pub async fn execute( + &self, + input: ReadIssueCarnetInput, + ) -> Result { + Ok(ReadIssueCarnetOutput { + carnet: self + .issues + .read_carnet(&input.project.root, input.issue_ref) + .await?, + }) + } +} + +/// Input for [`ListIssues::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListIssuesInput { + /// Project owning the issues. + pub project: Project, + /// Filter. + pub filter: IssueListFilter, +} + +/// Output of [`ListIssues::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListIssuesOutput { + /// Matching issue index entries. + pub issues: Vec, +} + +/// Lists issues. +pub struct ListIssues { + issues: Arc, +} + +impl ListIssues { + /// Builds the use case. + #[must_use] + pub fn new(issues: Arc) -> Self { + Self { issues } + } + + /// Executes list. + /// + /// # Errors + /// [`AppError`] on store failure. + pub async fn execute(&self, input: ListIssuesInput) -> Result { + Ok(ListIssuesOutput { + issues: self.issues.list(&input.project.root, input.filter).await?, + }) + } +} + +/// Input for [`UpdateIssue::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpdateIssueInput { + /// Project owning the issue. + pub project: Project, + /// Issue reference. + pub issue_ref: IssueRef, + /// Expected optimistic version. + pub expected_version: IssueVersion, + /// Optional title replacement. + pub title: Option, + /// Optional description replacement. + pub description: Option, + /// Optional status replacement. + pub status: Option, + /// Optional priority replacement. + pub priority: Option, + /// Optional full assigned-agent replacement. + pub assigned_agent_ids: Option>, + /// Actor updating the issue. + pub actor: IssueActor, +} + +/// Output of [`UpdateIssue::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpdateIssueOutput { + /// Updated issue. + pub issue: Issue, +} + +/// Updates issue fields with optimistic concurrency. +pub struct UpdateIssue { + issues: Arc, + contexts: Arc, + clock: Arc, + events: Arc, +} + +impl UpdateIssue { + /// Builds the use case. + #[must_use] + pub fn new( + issues: Arc, + contexts: Arc, + clock: Arc, + events: Arc, + ) -> Self { + Self { + issues, + contexts, + clock, + events, + } + } + + /// Executes update. + /// + /// # Errors + /// [`AppError`] on invariant, manifest validation or store failure. + pub async fn execute(&self, input: UpdateIssueInput) -> Result { + if let Some(agent_ids) = &input.assigned_agent_ids { + validate_agents(&self.contexts, &input.project, agent_ids).await?; + } + let current = self + .issues + .get_by_ref(&input.project.root, input.issue_ref) + .await?; + let old_status = current.status; + let old_priority = current.priority; + let old_assigned = assigned_set(¤t); + let assigned_agent_ids = input.assigned_agent_ids.clone(); + let updated = current + .mutate(input.actor, now(&self.clock), |issue| { + if let Some(title) = input.title { + issue.title = title; + } + if let Some(description) = input.description { + issue.description = MarkdownDoc::new(description); + } + if let Some(status) = input.status { + issue.status = status; + } + if let Some(priority) = input.priority { + issue.priority = priority; + } + if let Some(agent_ids) = assigned_agent_ids { + replace_assigned_agents(issue, agent_ids); + } + }) + .map_err(|err| AppError::Invalid(err.to_string()))?; + self.issues + .update(&input.project.root, &updated, input.expected_version) + .await?; + publish_update_events( + &self.events, + &updated, + old_status, + old_priority, + old_assigned, + ); + Ok(UpdateIssueOutput { issue: updated }) + } +} + +/// Input for [`UpdateIssueCarnet::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpdateIssueCarnetInput { + /// Project owning the issue. + pub project: Project, + /// Issue reference. + pub issue_ref: IssueRef, + /// Expected optimistic version. + pub expected_version: IssueVersion, + /// Carnet Markdown. + pub carnet: String, + /// Actor updating the carnet. + pub actor: IssueActor, +} + +/// Output of [`UpdateIssueCarnet::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpdateIssueCarnetOutput { + /// Carnet projection. + pub carnet: IssueCarnet, +} + +/// Updates an issue carnet. +pub struct UpdateIssueCarnet { + issues: Arc, + clock: Arc, + events: Arc, +} + +impl UpdateIssueCarnet { + /// Builds the use case. + #[must_use] + pub fn new( + issues: Arc, + clock: Arc, + events: Arc, + ) -> Self { + Self { + issues, + clock, + events, + } + } + + /// Executes carnet update. + /// + /// # Errors + /// [`AppError`] on store failure or conflict. + pub async fn execute( + &self, + input: UpdateIssueCarnetInput, + ) -> Result { + let carnet = self + .issues + .write_carnet( + &input.project.root, + input.issue_ref, + MarkdownDoc::new(input.carnet), + input.actor, + now(&self.clock), + input.expected_version, + ) + .await?; + self.events.publish(DomainEvent::IssueCarnetUpdated { + issue_ref: input.issue_ref, + version: carnet.version, + }); + Ok(UpdateIssueCarnetOutput { carnet }) + } +} + +/// Input for [`LinkIssues::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LinkIssuesInput { + /// Project owning the issues. + pub project: Project, + /// Source issue. + pub issue_ref: IssueRef, + /// Target issue. + pub target: IssueRef, + /// Link kind. + pub kind: IssueLinkKind, + /// Expected optimistic version. + pub expected_version: IssueVersion, + /// Actor updating the issue. + pub actor: IssueActor, +} + +/// Input for [`UnlinkIssues::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnlinkIssuesInput { + /// Project owning the issues. + pub project: Project, + /// Source issue. + pub issue_ref: IssueRef, + /// Target issue. + pub target: IssueRef, + /// Optional link kind. `None` removes every link from `issue_ref` to `target`. + pub kind: Option, + /// Expected optimistic version. + pub expected_version: IssueVersion, + /// Actor updating the issue. + pub actor: IssueActor, +} + +/// Output for link mutations. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LinkIssuesOutput { + /// Updated issue. + pub issue: Issue, +} + +/// Adds an issue link. +pub struct LinkIssues { + issues: Arc, + clock: Arc, + events: Arc, +} + +impl LinkIssues { + /// Builds the use case. + #[must_use] + pub fn new( + issues: Arc, + clock: Arc, + events: Arc, + ) -> Self { + Self { + issues, + clock, + events, + } + } + + /// Executes link creation. + /// + /// # Errors + /// [`AppError`] on invariant or store failure. + pub async fn execute(&self, input: LinkIssuesInput) -> Result { + // Ensure target exists and source self-link is caught before persisting. + self.issues + .get_by_ref(&input.project.root, input.target) + .await?; + let current = self + .issues + .get_by_ref(&input.project.root, input.issue_ref) + .await?; + let updated = current + .mutate(input.actor, now(&self.clock), |issue| { + let link = IssueLink { + target: input.target, + kind: input.kind, + }; + if !issue.links.contains(&link) { + issue.links.push(link); + } + }) + .map_err(|err| AppError::Invalid(err.to_string()))?; + self.issues + .update(&input.project.root, &updated, input.expected_version) + .await?; + self.events.publish(DomainEvent::IssueLinked { + issue_ref: input.issue_ref, + target: input.target, + kind: input.kind, + version: updated.version, + }); + Ok(LinkIssuesOutput { issue: updated }) + } +} + +/// Removes an issue link. +pub struct UnlinkIssues { + issues: Arc, + clock: Arc, + events: Arc, +} + +impl UnlinkIssues { + /// Builds the use case. + #[must_use] + pub fn new( + issues: Arc, + clock: Arc, + events: Arc, + ) -> Self { + Self { + issues, + clock, + events, + } + } + + /// Executes link removal. + /// + /// # Errors + /// [`AppError`] on store failure. + pub async fn execute(&self, input: UnlinkIssuesInput) -> Result { + let current = self + .issues + .get_by_ref(&input.project.root, input.issue_ref) + .await?; + let removed: Vec = current + .links + .iter() + .filter(|link| { + link.target == input.target && input.kind.map_or(true, |kind| link.kind == kind) + }) + .map(|link| link.kind) + .collect(); + let updated = current + .mutate(input.actor, now(&self.clock), |issue| { + issue.links.retain(|link| { + !(link.target == input.target + && input.kind.map_or(true, |kind| link.kind == kind)) + }); + }) + .map_err(|err| AppError::Invalid(err.to_string()))?; + self.issues + .update(&input.project.root, &updated, input.expected_version) + .await?; + for kind in removed { + self.events.publish(DomainEvent::IssueUnlinked { + issue_ref: input.issue_ref, + target: input.target, + kind, + version: updated.version, + }); + } + Ok(LinkIssuesOutput { issue: updated }) + } +} + +/// Input for [`AssignIssueAgent::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AssignIssueAgentInput { + /// Project owning the issue. + pub project: Project, + /// Issue reference. + pub issue_ref: IssueRef, + /// Agent to assign or unassign. + pub agent_id: AgentId, + /// Whether the agent must be assigned. + pub assigned: bool, + /// Expected optimistic version. + pub expected_version: IssueVersion, + /// Actor updating the issue. + pub actor: IssueActor, +} + +/// Output of [`AssignIssueAgent::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AssignIssueAgentOutput { + /// Updated issue. + pub issue: Issue, +} + +/// Assigns or unassigns an agent on an issue. +pub struct AssignIssueAgent { + issues: Arc, + contexts: Arc, + clock: Arc, + events: Arc, +} + +impl AssignIssueAgent { + /// Builds the use case. + #[must_use] + pub fn new( + issues: Arc, + contexts: Arc, + clock: Arc, + events: Arc, + ) -> Self { + Self { + issues, + contexts, + clock, + events, + } + } + + /// Executes assignment mutation. + /// + /// # Errors + /// [`AppError`] on manifest validation or store failure. + pub async fn execute( + &self, + input: AssignIssueAgentInput, + ) -> Result { + validate_agents(&self.contexts, &input.project, &[input.agent_id]).await?; + let current = self + .issues + .get_by_ref(&input.project.root, input.issue_ref) + .await?; + let updated = + current + .mutate(input.actor, now(&self.clock), |issue| { + if input.assigned { + if !issue.agent_refs.iter().any(|r| { + r.agent_id == input.agent_id && r.role == AgentIssueRole::Assigned + }) { + issue.agent_refs.push(AgentIssueRef { + agent_id: input.agent_id, + role: AgentIssueRole::Assigned, + }); + } + } else { + issue.agent_refs.retain(|r| { + !(r.agent_id == input.agent_id && r.role == AgentIssueRole::Assigned) + }); + } + }) + .map_err(|err| AppError::Invalid(err.to_string()))?; + self.issues + .update(&input.project.root, &updated, input.expected_version) + .await?; + if input.assigned { + self.events.publish(DomainEvent::IssueAgentAssigned { + issue_ref: input.issue_ref, + agent_id: input.agent_id, + version: updated.version, + }); + } else { + self.events.publish(DomainEvent::IssueAgentUnassigned { + issue_ref: input.issue_ref, + agent_id: input.agent_id, + version: updated.version, + }); + } + Ok(AssignIssueAgentOutput { issue: updated }) + } +} + +fn now(clock: &Arc) -> u64 { + clock.now_millis().max(0) as u64 +} + +async fn validate_agents( + contexts: &Arc, + project: &Project, + agent_ids: &[AgentId], +) -> Result<(), AppError> { + if agent_ids.is_empty() { + return Ok(()); + } + let manifest = contexts.load_manifest(project).await?; + let known: HashSet = manifest + .entries + .into_iter() + .map(|entry| entry.agent_id) + .collect(); + for agent_id in agent_ids { + if !known.contains(agent_id) { + return Err(AppError::NotFound(format!("agent {agent_id}"))); + } + } + Ok(()) +} + +fn assigned_set(issue: &Issue) -> HashSet { + issue + .agent_refs + .iter() + .filter(|reference| reference.role == AgentIssueRole::Assigned) + .map(|reference| reference.agent_id) + .collect() +} + +fn replace_assigned_agents(issue: &mut Issue, agent_ids: Vec) { + issue + .agent_refs + .retain(|reference| reference.role != AgentIssueRole::Assigned); + for agent_id in agent_ids { + issue.agent_refs.push(AgentIssueRef { + agent_id, + role: AgentIssueRole::Assigned, + }); + } +} + +fn publish_update_events( + events: &Arc, + issue: &Issue, + old_status: IssueStatus, + old_priority: IssuePriority, + old_assigned: HashSet, +) { + let issue_ref = issue.reference(); + events.publish(DomainEvent::IssueUpdated { + issue_ref, + version: issue.version, + }); + if issue.status != old_status { + events.publish(DomainEvent::IssueStatusChanged { + issue_ref, + status: issue.status, + version: issue.version, + }); + } + if issue.priority != old_priority { + events.publish(DomainEvent::IssuePriorityChanged { + issue_ref, + priority: issue.priority, + version: issue.version, + }); + } + let new_assigned = assigned_set(issue); + for agent_id in new_assigned.difference(&old_assigned) { + events.publish(DomainEvent::IssueAgentAssigned { + issue_ref, + agent_id: *agent_id, + version: issue.version, + }); + } + for agent_id in old_assigned.difference(&new_assigned) { + events.publish(DomainEvent::IssueAgentUnassigned { + issue_ref, + agent_id: *agent_id, + version: issue.version, + }); + } +} diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 0e56e1e..e478789 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -18,6 +18,7 @@ pub mod embedder; pub mod error; pub mod git; pub mod health; +pub mod issues; pub mod layout; pub mod memory; pub mod orchestrator; @@ -66,6 +67,14 @@ pub use git::{ GitStatusInput, GitStatusOutput, GitUnstage, }; pub use health::{HealthInput, HealthReport, HealthUseCase}; +pub use issues::{ + AssignIssueAgent, AssignIssueAgentInput, AssignIssueAgentOutput, CreateIssue, CreateIssueInput, + CreateIssueOutput, LinkIssues, LinkIssuesInput, LinkIssuesOutput, ListIssues, ListIssuesInput, + ListIssuesOutput, ReadIssue, ReadIssueCarnet, ReadIssueCarnetInput, ReadIssueCarnetOutput, + ReadIssueInput, ReadIssueOutput, UnlinkIssues, UnlinkIssuesInput, UpdateIssue, + UpdateIssueCarnet, UpdateIssueCarnetInput, UpdateIssueCarnetOutput, UpdateIssueInput, + UpdateIssueOutput, +}; pub use layout::{ CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput, DeleteLayoutOutput, LayoutInfo, LayoutKind, LayoutOperation, LayoutsDoc, ListLayouts, diff --git a/crates/application/tests/issue_usecases.rs b/crates/application/tests/issue_usecases.rs new file mode 100644 index 0000000..3ac5103 --- /dev/null +++ b/crates/application/tests/issue_usecases.rs @@ -0,0 +1,402 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use domain::ports::{ + AgentContextStore, Clock, EventBus, EventStream, IdGenerator, IssueNumberAllocator, IssueStore, + IssueStoreError, StoreError, +}; +use domain::{ + AgentId, AgentManifest, DomainEvent, Issue, IssueActor, IssueCarnet, IssueIndexEntry, + IssueListFilter, IssueNumber, IssuePriority, IssueRef, IssueStatus, IssueVersion, + ManifestEntry, MarkdownDoc, ProfileId, Project, ProjectId, ProjectPath, RemoteRef, +}; +use uuid::Uuid; + +use application::{CreateIssue, CreateIssueInput, UpdateIssue, UpdateIssueInput}; + +#[derive(Default)] +struct FakeIssues { + issues: Mutex>, + conflict: Mutex, +} + +#[async_trait] +impl IssueStore for FakeIssues { + async fn create(&self, _root: &ProjectPath, issue: &Issue) -> Result<(), IssueStoreError> { + self.issues + .lock() + .unwrap() + .insert(issue.reference(), issue.clone()); + Ok(()) + } + + async fn get_by_ref( + &self, + _root: &ProjectPath, + issue_ref: IssueRef, + ) -> Result { + self.issues + .lock() + .unwrap() + .get(&issue_ref) + .cloned() + .ok_or(IssueStoreError::NotFound) + } + + async fn list( + &self, + _root: &ProjectPath, + _filter: IssueListFilter, + ) -> Result, IssueStoreError> { + Ok(self + .issues + .lock() + .unwrap() + .values() + .map(IssueIndexEntry::from) + .collect()) + } + + async fn update( + &self, + _root: &ProjectPath, + issue: &Issue, + expected_version: IssueVersion, + ) -> Result<(), IssueStoreError> { + if *self.conflict.lock().unwrap() { + return Err(IssueStoreError::VersionConflict { + expected: expected_version, + actual: issue.version, + }); + } + let current = self.get_by_ref(_root, issue.reference()).await?; + if current.version != expected_version { + return Err(IssueStoreError::VersionConflict { + expected: expected_version, + actual: current.version, + }); + } + self.issues + .lock() + .unwrap() + .insert(issue.reference(), issue.clone()); + Ok(()) + } + + async fn read_carnet( + &self, + _root: &ProjectPath, + issue_ref: IssueRef, + ) -> Result { + let issue = self.get_by_ref(_root, issue_ref).await?; + Ok(IssueCarnet { + issue_ref, + carnet: issue.carnet, + version: issue.version, + updated_by: issue.updated_by, + updated_at: issue.updated_at, + }) + } + + async fn write_carnet( + &self, + _root: &ProjectPath, + issue_ref: IssueRef, + carnet: MarkdownDoc, + actor: IssueActor, + now_ms: u64, + expected_version: IssueVersion, + ) -> Result { + let issue = self.get_by_ref(_root, issue_ref).await?; + if issue.version != expected_version { + return Err(IssueStoreError::VersionConflict { + expected: expected_version, + actual: issue.version, + }); + } + let updated = issue + .mutate(actor, now_ms, |i| i.carnet = carnet) + .map_err(|err| IssueStoreError::Invalid(err.to_string()))?; + self.update(_root, &updated, expected_version).await?; + self.read_carnet(_root, issue_ref).await + } +} + +struct SeqAllocator(Mutex); + +#[async_trait] +impl IssueNumberAllocator for SeqAllocator { + async fn allocate_next(&self, _root: &ProjectPath) -> Result { + let mut next = self.0.lock().unwrap(); + let number = IssueNumber::new(*next).unwrap(); + *next += 1; + Ok(number) + } +} + +#[derive(Clone)] +struct FakeContexts(AgentManifest); + +#[async_trait] +impl AgentContextStore for FakeContexts { + async fn read_context( + &self, + _project: &Project, + _agent: &AgentId, + ) -> Result { + Err(StoreError::NotFound) + } + + async fn write_context( + &self, + _project: &Project, + _agent: &AgentId, + _md: &MarkdownDoc, + ) -> Result<(), StoreError> { + Ok(()) + } + + async fn load_manifest(&self, _project: &Project) -> Result { + Ok(self.0.clone()) + } + + async fn save_manifest( + &self, + _project: &Project, + _manifest: &AgentManifest, + ) -> Result<(), StoreError> { + Ok(()) + } +} + +#[derive(Default)] +struct SpyBus(Mutex>); + +impl SpyBus { + fn events(&self) -> Vec { + 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 FixedClock; + +impl Clock for FixedClock { + fn now_millis(&self) -> i64 { + 1_234 + } +} + +struct FixedIds; + +impl IdGenerator for FixedIds { + fn new_uuid(&self) -> Uuid { + Uuid::from_u128(42) + } +} + +fn project() -> Project { + Project::new( + ProjectId::new_random(), + "IdeA", + ProjectPath::new("/tmp/idea").unwrap(), + RemoteRef::Local, + 0, + ) + .unwrap() +} + +fn manifest(agent_id: AgentId) -> AgentManifest { + AgentManifest::new( + 1, + vec![ManifestEntry::new( + agent_id, + "Dev", + "agents/dev.md", + ProfileId::new_random(), + None, + false, + None, + ) + .unwrap()], + ) + .unwrap() +} + +#[tokio::test] +async fn create_issue_allocates_number_validates_agent_and_emits_event() { + let agent_id = AgentId::new_random(); + let issues = Arc::new(FakeIssues::default()); + let bus = Arc::new(SpyBus::default()); + let uc = CreateIssue::new( + issues.clone(), + Arc::new(SeqAllocator(Mutex::new(1))), + Arc::new(FakeContexts(manifest(agent_id))), + Arc::new(FixedIds), + Arc::new(FixedClock), + bus.clone(), + ); + + let out = uc + .execute(CreateIssueInput { + project: project(), + title: "Ship issues".into(), + description: "Body".into(), + priority: IssuePriority::Critical, + status: IssueStatus::Open, + links: Vec::new(), + assigned_agent_ids: vec![agent_id], + actor: IssueActor::User, + }) + .await + .unwrap(); + + assert_eq!(out.issue.reference().to_string(), "#1"); + assert_eq!( + out.issue.id, + domain::IssueId::from_uuid(Uuid::from_u128(42)) + ); + assert_eq!(out.issue.created_at, 1_234); + assert!(matches!( + bus.events().as_slice(), + [DomainEvent::IssueCreated { issue_ref, .. }] if issue_ref.to_string() == "#1" + )); +} + +#[tokio::test] +async fn create_issue_rejects_unknown_assignee() { + let known = AgentId::new_random(); + let unknown = AgentId::new_random(); + let uc = CreateIssue::new( + Arc::new(FakeIssues::default()), + Arc::new(SeqAllocator(Mutex::new(1))), + Arc::new(FakeContexts(manifest(known))), + Arc::new(FixedIds), + Arc::new(FixedClock), + Arc::new(SpyBus::default()), + ); + + let err = uc + .execute(CreateIssueInput { + project: project(), + title: "Bad assignee".into(), + description: "Body".into(), + priority: IssuePriority::Medium, + status: IssueStatus::Open, + links: Vec::new(), + assigned_agent_ids: vec![unknown], + actor: IssueActor::User, + }) + .await + .unwrap_err(); + + assert_eq!(err.code(), "NOT_FOUND"); +} + +#[tokio::test] +async fn update_issue_propagates_expected_version_and_emits_status_event() { + let agent_id = AgentId::new_random(); + let issues = Arc::new(FakeIssues::default()); + let initial = Issue::new( + domain::IssueId::new_random(), + IssueNumber::new(1).unwrap(), + "Initial", + MarkdownDoc::new("Body"), + IssueStatus::Open, + IssuePriority::Medium, + MarkdownDoc::default(), + Vec::new(), + Vec::new(), + IssueActor::User, + 1, + ) + .unwrap(); + issues.create(&project().root, &initial).await.unwrap(); + let bus = Arc::new(SpyBus::default()); + let uc = UpdateIssue::new( + issues, + Arc::new(FakeContexts(manifest(agent_id))), + Arc::new(FixedClock), + bus.clone(), + ); + + let out = uc + .execute(UpdateIssueInput { + project: project(), + issue_ref: initial.reference(), + expected_version: initial.version, + title: Some("Updated".into()), + description: None, + status: Some(IssueStatus::InProgress), + priority: None, + assigned_agent_ids: None, + actor: IssueActor::System, + }) + .await + .unwrap(); + + assert_eq!(out.issue.version.get(), 2); + assert!(bus.events().iter().any(|event| matches!( + event, + DomainEvent::IssueStatusChanged { + status: IssueStatus::InProgress, + .. + } + ))); +} + +#[tokio::test] +async fn update_issue_maps_version_conflict() { + let agent_id = AgentId::new_random(); + let issues = Arc::new(FakeIssues::default()); + let initial = Issue::new( + domain::IssueId::new_random(), + IssueNumber::new(1).unwrap(), + "Initial", + MarkdownDoc::new("Body"), + IssueStatus::Open, + IssuePriority::Medium, + MarkdownDoc::default(), + Vec::new(), + Vec::new(), + IssueActor::User, + 1, + ) + .unwrap(); + issues.create(&project().root, &initial).await.unwrap(); + *issues.conflict.lock().unwrap() = true; + let uc = UpdateIssue::new( + issues, + Arc::new(FakeContexts(manifest(agent_id))), + Arc::new(FixedClock), + Arc::new(SpyBus::default()), + ); + + let err = uc + .execute(UpdateIssueInput { + project: project(), + issue_ref: initial.reference(), + expected_version: initial.version, + title: Some("Updated".into()), + description: None, + status: None, + priority: None, + assigned_agent_ids: None, + actor: IssueActor::System, + }) + .await + .unwrap_err(); + + assert_eq!(err.code(), "INVALID"); + assert!(err.to_string().contains("version conflict")); +} diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index f1d100e..180d357 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -1,7 +1,8 @@ //! Domain events published on the [`crate::ports::EventBus`] and relayed to the //! presentation layer (ARCHITECTURE §3.2). -use crate::ids::{AgentId, ProfileId, ProjectId, SessionId, SkillId, TemplateId}; +use crate::ids::{AgentId, IssueId, ProfileId, ProjectId, SessionId, SkillId, TemplateId}; +use crate::issue::{IssueLinkKind, IssuePriority, IssueRef, IssueStatus, IssueVersion}; use crate::mailbox::TicketId; use crate::memory::MemorySlug; use crate::template::TemplateVersion; @@ -168,6 +169,85 @@ pub enum DomainEvent { /// The project whose index was rebuilt. project_id: ProjectId, }, + /// An issue was created. + IssueCreated { + /// Stable issue id. + issue_id: IssueId, + /// Human-friendly issue reference. + issue_ref: IssueRef, + }, + /// An issue was updated. + IssueUpdated { + /// Human-friendly issue reference. + issue_ref: IssueRef, + /// New optimistic version. + version: IssueVersion, + }, + /// An issue status changed. + IssueStatusChanged { + /// Human-friendly issue reference. + issue_ref: IssueRef, + /// New status. + status: IssueStatus, + /// New optimistic version. + version: IssueVersion, + }, + /// An issue priority changed. + IssuePriorityChanged { + /// Human-friendly issue reference. + issue_ref: IssueRef, + /// New priority. + priority: IssuePriority, + /// New optimistic version. + version: IssueVersion, + }, + /// An issue carnet was updated. + IssueCarnetUpdated { + /// Human-friendly issue reference. + issue_ref: IssueRef, + /// New optimistic version. + version: IssueVersion, + }, + /// Two issues were linked. + IssueLinked { + /// Source issue. + issue_ref: IssueRef, + /// Target issue. + target: IssueRef, + /// Link kind. + kind: IssueLinkKind, + /// New optimistic version. + version: IssueVersion, + }, + /// Two issues were unlinked. + IssueUnlinked { + /// Source issue. + issue_ref: IssueRef, + /// Target issue. + target: IssueRef, + /// Link kind. + kind: IssueLinkKind, + /// New optimistic version. + version: IssueVersion, + }, + /// An agent was assigned to an issue. + IssueAgentAssigned { + /// Human-friendly issue reference. + issue_ref: IssueRef, + /// Assigned agent. + agent_id: AgentId, + /// New optimistic version. + version: IssueVersion, + }, + /// An agent was unassigned from an issue. + IssueAgentUnassigned { + /// Human-friendly issue reference. + issue_ref: IssueRef, + /// Unassigned agent. + agent_id: AgentId, + /// New optimistic version. + version: IssueVersion, + }, /// A project's memory grew past the recall budget while no embedder is /// configured (strategy `none`): the first moment a semantic embedder would /// help (ARCHITECTURE §14.5.5, LOT C3). Published **at most once per session per diff --git a/crates/domain/src/ids.rs b/crates/domain/src/ids.rs index 0e67a71..cc49e2b 100644 --- a/crates/domain/src/ids.rs +++ b/crates/domain/src/ids.rs @@ -72,6 +72,10 @@ typed_id!( /// Identifies a [`crate::skill::Skill`]. SkillId ); +typed_id!( + /// Identifies a [`crate::issue::Issue`]. + IssueId +); typed_id!( /// Identifies a [`crate::terminal::TerminalSession`]. SessionId diff --git a/crates/domain/src/issue.rs b/crates/domain/src/issue.rs new file mode 100644 index 0000000..b25047a --- /dev/null +++ b/crates/domain/src/issue.rs @@ -0,0 +1,454 @@ +//! Issue domain model. +//! +//! Code uses `Issue` terminology exclusively to avoid colliding with the +//! inter-agent delegation model. + +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::ids::{AgentId, IssueId}; +use crate::markdown::MarkdownDoc; + +/// Sequential per-project issue number. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct IssueNumber(u64); + +impl IssueNumber { + /// Builds an issue number. Values start at 1. + /// + /// # Errors + /// [`IssueError::InvalidNumber`] when `value == 0`. + pub fn new(value: u64) -> Result { + if value == 0 { + return Err(IssueError::InvalidNumber); + } + Ok(Self(value)) + } + + /// Returns the raw numeric value. + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } +} + +impl std::fmt::Display for IssueNumber { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// Human-friendly issue reference, formatted as `#`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct IssueRef(IssueNumber); + +impl IssueRef { + /// Builds a reference from a validated number. + #[must_use] + pub const fn new(number: IssueNumber) -> Self { + Self(number) + } + + /// Returns the referenced issue number. + #[must_use] + pub const fn number(self) -> IssueNumber { + self.0 + } +} + +impl std::fmt::Display for IssueRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "#{}", self.0.get()) + } +} + +impl From for IssueRef { + fn from(number: IssueNumber) -> Self { + Self::new(number) + } +} + +impl FromStr for IssueRef { + type Err = IssueError; + + fn from_str(raw: &str) -> Result { + let digits = raw + .strip_prefix('#') + .ok_or_else(|| IssueError::InvalidRef(raw.to_owned()))?; + if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) { + return Err(IssueError::InvalidRef(raw.to_owned())); + } + let number = digits + .parse::() + .map_err(|_| IssueError::InvalidRef(raw.to_owned()))?; + Ok(Self(IssueNumber::new(number)?)) + } +} + +impl Serialize for IssueRef { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for IssueRef { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = String::deserialize(deserializer)?; + raw.parse().map_err(serde::de::Error::custom) + } +} + +/// Optimistic-concurrency version. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct IssueVersion(u64); + +impl IssueVersion { + /// Initial version assigned to a newly-created issue. + pub const INITIAL: Self = Self(1); + + /// Builds a version. Values start at 1. + /// + /// # Errors + /// [`IssueError::InvalidVersion`] when `value == 0`. + pub fn new(value: u64) -> Result { + if value == 0 { + return Err(IssueError::InvalidVersion); + } + Ok(Self(value)) + } + + /// Returns the raw numeric value. + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } + + /// Returns the next optimistic version. + #[must_use] + pub const fn next(self) -> Self { + Self(self.0 + 1) + } +} + +impl std::fmt::Display for IssueVersion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// Issue lifecycle status. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum IssueStatus { + /// Open and not started. + Open, + /// Being worked. + InProgress, + /// Ready for QA. + Qa, + /// Closed. + Closed, +} + +/// Issue priority. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum IssuePriority { + /// Low priority. + Low, + /// Default priority. + Medium, + /// High priority. + High, + /// Critical priority. + Critical, +} + +/// Relationship kind between two issues. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum IssueLinkKind { + /// Informational relation. + RelatesTo, + /// This issue blocks the target. + Blocks, + /// This issue is blocked by the target. + BlockedBy, + /// This issue duplicates the target. + Duplicates, + /// This issue depends on the target. + DependsOn, +} + +/// Link from one issue to another. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IssueLink { + /// Target issue. + pub target: IssueRef, + /// Link kind. + pub kind: IssueLinkKind, +} + +/// Role an agent has on an issue. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AgentIssueRole { + /// Assigned implementer. + Assigned, + /// Mentioned participant. + Mentioned, + /// Reviewer. + Reviewer, + /// Owner. + Owner, +} + +/// Agent reference on an issue. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentIssueRef { + /// Agent id. + pub agent_id: AgentId, + /// Agent role. + pub role: AgentIssueRole, +} + +/// Actor responsible for an issue mutation. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum IssueActor { + /// Human user. + User, + /// Agent actor. + Agent { + /// Agent id. + agent_id: AgentId, + }, + /// System actor. + System, +} + +/// Issue aggregate. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Issue { + /// Stable UUID. + pub id: IssueId, + /// Sequential per-project number. + pub number: IssueNumber, + /// Human-readable title. + pub title: String, + /// Markdown description. + pub description: MarkdownDoc, + /// Lifecycle status. + pub status: IssueStatus, + /// Priority. + pub priority: IssuePriority, + /// Editable issue-local knowledge. + pub carnet: MarkdownDoc, + /// Links to other issues. + pub links: Vec, + /// Agent references. + pub agent_refs: Vec, + /// Creator. + pub created_by: IssueActor, + /// Last updater. + pub updated_by: IssueActor, + /// Creation time, epoch milliseconds. + pub created_at: u64, + /// Last update time, epoch milliseconds. + pub updated_at: u64, + /// Optimistic-concurrency version. + pub version: IssueVersion, +} + +impl Issue { + /// Builds a new issue with version 1. + /// + /// # Errors + /// [`IssueError`] when invariants are violated. + #[allow(clippy::too_many_arguments)] + pub fn new( + id: IssueId, + number: IssueNumber, + title: impl Into, + description: MarkdownDoc, + status: IssueStatus, + priority: IssuePriority, + carnet: MarkdownDoc, + links: Vec, + agent_refs: Vec, + actor: IssueActor, + now_ms: u64, + ) -> Result { + let issue = Self { + id, + number, + title: title.into(), + description, + status, + priority, + carnet, + links, + agent_refs, + created_by: actor.clone(), + updated_by: actor, + created_at: now_ms, + updated_at: now_ms, + version: IssueVersion::INITIAL, + }; + issue.validate()?; + Ok(issue) + } + + /// Rehydrates a persisted issue. + /// + /// # Errors + /// [`IssueError`] when persisted data violates invariants. + pub fn rehydrate(issue: Self) -> Result { + issue.validate()?; + Ok(issue) + } + + /// Returns this issue as `#`. + #[must_use] + pub const fn reference(&self) -> IssueRef { + IssueRef::new(self.number) + } + + /// Validates invariants. + /// + /// # Errors + /// [`IssueError`] when an invariant is violated. + pub fn validate(&self) -> Result<(), IssueError> { + IssueNumber::new(self.number.get())?; + IssueVersion::new(self.version.get())?; + if self.title.trim().is_empty() { + return Err(IssueError::EmptyTitle); + } + let own = self.reference(); + if self.links.iter().any(|link| link.target == own) { + return Err(IssueError::SelfLink { reference: own }); + } + Ok(()) + } + + /// Applies a mutation and increments the version. + /// + /// # Errors + /// [`IssueError`] when the resulting issue violates invariants. + pub fn mutate( + mut self, + actor: IssueActor, + now_ms: u64, + f: impl FnOnce(&mut Self), + ) -> Result { + f(&mut self); + self.updated_by = actor; + self.updated_at = now_ms; + self.version = self.version.next(); + self.validate()?; + Ok(self) + } +} + +/// Carnet projection with its optimistic version. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IssueCarnet { + /// Issue reference. + pub issue_ref: IssueRef, + /// Carnet Markdown body. + pub carnet: MarkdownDoc, + /// Current issue version. + pub version: IssueVersion, + /// Actor that last updated the carnet. + pub updated_by: IssueActor, + /// Last carnet update time, epoch milliseconds. + pub updated_at: u64, +} + +/// Index row used by stores and list use cases. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IssueIndexEntry { + /// Issue reference. + pub issue_ref: IssueRef, + /// Relative path to the issue folder. + pub path: String, + /// Title. + pub title: String, + /// Status. + pub status: IssueStatus, + /// Priority. + pub priority: IssuePriority, + /// Assigned agent ids. + pub assigned_agent_ids: Vec, + /// Last update time. + pub updated_at: u64, +} + +impl From<&Issue> for IssueIndexEntry { + fn from(issue: &Issue) -> Self { + Self { + issue_ref: issue.reference(), + path: issue.number.get().to_string(), + title: issue.title.clone(), + status: issue.status, + priority: issue.priority, + assigned_agent_ids: issue + .agent_refs + .iter() + .filter(|r| r.role == AgentIssueRole::Assigned) + .map(|r| r.agent_id) + .collect(), + updated_at: issue.updated_at, + } + } +} + +/// Store-side list filter. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct IssueListFilter { + /// Optional status filter. + pub status: Option, + /// Optional priority filter. + pub priority: Option, + /// Optional assigned agent filter. + pub assigned_agent_id: Option, + /// Optional case-insensitive text query. + pub text: Option, +} + +/// Domain errors for issue invariants and value objects. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum IssueError { + /// Issue numbers are strictly positive. + #[error("issue number must be greater than zero")] + InvalidNumber, + /// Issue versions are strictly positive. + #[error("issue version must be greater than zero")] + InvalidVersion, + /// Issue title cannot be empty. + #[error("issue title cannot be empty")] + EmptyTitle, + /// Issue reference must be exactly `#`. + #[error("invalid issue reference: {0}")] + InvalidRef(String), + /// A link targets the same issue. + #[error("issue {reference} cannot link to itself")] + SelfLink { + /// Self reference. + reference: IssueRef, + }, +} diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 8865fe0..b343332 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -39,6 +39,7 @@ pub mod fileguard; pub mod git; pub mod ids; pub mod input; +pub mod issue; pub mod layout; pub mod live_state; pub mod mailbox; @@ -67,8 +68,8 @@ mod validation; pub use error::DomainError; pub use ids::{ - AgentId, LayoutId, NodeId, ProfileId, ProjectId, ScheduleId, SessionId, SkillId, TabId, - TemplateId, WindowId, + AgentId, IssueId, LayoutId, NodeId, ProfileId, ProjectId, ScheduleId, SessionId, SkillId, + TabId, TemplateId, WindowId, }; pub use project::{Project, ProjectPath}; @@ -96,6 +97,12 @@ pub use conversation::{ pub use input::{AgentBusyState, AgentLiveness, InputMediator, InputSource}; +pub use issue::{ + AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueError, IssueIndexEntry, + IssueLink, IssueLinkKind, IssueListFilter, IssueNumber, IssuePriority, IssueRef, IssueStatus, + IssueVersion, +}; + pub use live_state::{LiveEntry, LiveState, WorkStatus, FIELD_MAX_BYTES, FIELD_PREVIEW_MAX_CHARS}; pub use readiness::{ReadinessPolicy, ReadinessSignal}; @@ -158,8 +165,9 @@ pub use ports::{ EmbedderEnvInspector, EmbedderEnvReport, EmbedderError, EmbedderProfileStore, EmbedderPromptDismissal, EmbedderPromptStore, EventBus, EventStream, ExitStatus, FileSystem, FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator, - LiveStateStore, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream, - PermissionStore, PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore, - PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, ScheduledTask, - Scheduler, SpawnSpec, StoreError, TemplateStore, + IssueNumberAllocator, IssueStore, IssueStoreError, LiveStateStore, MemoryError, MemoryQuery, + MemoryRecall, MemoryStore, Output, OutputStream, PermissionStore, PreparedContext, + ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort, + RemoteError, RemoteHost, RemotePath, RuntimeError, ScheduledTask, Scheduler, SpawnSpec, + StoreError, TemplateStore, }; diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 470740d..03161c5 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -30,6 +30,9 @@ use thiserror::Error; use crate::agent::AgentManifest; use crate::events::DomainEvent; use crate::ids::{AgentId, NodeId, ScheduleId, SessionId}; +use crate::issue::{ + Issue, IssueCarnet, IssueIndexEntry, IssueListFilter, IssueNumber, IssueRef, IssueVersion, +}; use crate::markdown::MarkdownDoc; use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug}; use crate::permission::ProjectPermissions; @@ -461,6 +464,28 @@ pub enum GitError { Operation(String), } +/// Errors from the issue store. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum IssueStoreError { + /// The requested issue does not exist. + #[error("issue not found")] + NotFound, + /// Optimistic concurrency conflict. + #[error("issue version conflict: expected {expected}, actual {actual}")] + VersionConflict { + /// Expected version. + expected: IssueVersion, + /// Actual persisted version. + actual: IssueVersion, + }, + /// The issue payload is invalid. + #[error("issue invalid: {0}")] + Invalid(String), + /// Store I/O or serialization failed. + #[error("issue store failed: {0}")] + Store(String), +} + // --------------------------------------------------------------------------- // Git port support types // --------------------------------------------------------------------------- @@ -1191,6 +1216,84 @@ pub trait PermissionStore: Send + Sync { ) -> Result<(), StoreError>; } +/// Allocates monotonic per-project issue numbers. +#[async_trait] +pub trait IssueNumberAllocator: Send + Sync { + /// Allocates the next number for `root`. + /// + /// Implementations must never reuse an allocated number, even if a later + /// issue creation step fails. + /// + /// # Errors + /// [`IssueStoreError`] on persistence/lock failure. + async fn allocate_next(&self, root: &ProjectPath) -> Result; +} + +/// Persistence port for project-scoped issues. +#[async_trait] +pub trait IssueStore: Send + Sync { + /// Creates a new issue. + /// + /// # Errors + /// [`IssueStoreError`] when the issue already exists or cannot be stored. + async fn create(&self, root: &ProjectPath, issue: &Issue) -> Result<(), IssueStoreError>; + + /// Reads an issue by `#N` reference. + /// + /// # Errors + /// [`IssueStoreError::NotFound`] when absent. + async fn get_by_ref( + &self, + root: &ProjectPath, + issue_ref: IssueRef, + ) -> Result; + + /// Lists issue index entries matching `filter`. + /// + /// # Errors + /// [`IssueStoreError`] on persistence failure. + async fn list( + &self, + root: &ProjectPath, + filter: IssueListFilter, + ) -> Result, IssueStoreError>; + + /// Replaces an issue after checking its expected version. + /// + /// # Errors + /// [`IssueStoreError::VersionConflict`] on optimistic-concurrency conflict. + async fn update( + &self, + root: &ProjectPath, + issue: &Issue, + expected_version: IssueVersion, + ) -> Result<(), IssueStoreError>; + + /// Reads only the issue carnet projection. + /// + /// # Errors + /// [`IssueStoreError::NotFound`] when absent. + async fn read_carnet( + &self, + root: &ProjectPath, + issue_ref: IssueRef, + ) -> Result; + + /// Replaces an issue carnet and increments the issue version. + /// + /// # Errors + /// [`IssueStoreError::VersionConflict`] on optimistic-concurrency conflict. + async fn write_carnet( + &self, + root: &ProjectPath, + issue_ref: IssueRef, + carnet: MarkdownDoc, + actor: crate::issue::IssueActor, + now_ms: u64, + expected_version: IssueVersion, + ) -> Result; +} + /// Git operations for a project. Named `GitPort` to avoid clashing with the /// [`crate::git::GitRepository`] *entity* (state image). #[async_trait] diff --git a/crates/domain/tests/issue.rs b/crates/domain/tests/issue.rs new file mode 100644 index 0000000..c5967ce --- /dev/null +++ b/crates/domain/tests/issue.rs @@ -0,0 +1,120 @@ +use std::str::FromStr; + +use domain::{ + AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueError, IssueId, IssueLink, + IssueLinkKind, IssueNumber, IssuePriority, IssueRef, IssueStatus, IssueVersion, MarkdownDoc, +}; + +fn issue_number(n: u64) -> IssueNumber { + IssueNumber::new(n).unwrap() +} + +fn issue() -> Issue { + Issue::new( + IssueId::new_random(), + issue_number(7), + "Fix boot reconcile", + MarkdownDoc::new("Description"), + IssueStatus::Open, + IssuePriority::Medium, + MarkdownDoc::new("Carnet"), + vec![IssueLink { + target: IssueRef::from(issue_number(8)), + kind: IssueLinkKind::RelatesTo, + }], + vec![AgentIssueRef { + agent_id: domain::AgentId::new_random(), + role: AgentIssueRole::Assigned, + }], + IssueActor::User, + 1_000, + ) + .unwrap() +} + +#[test] +fn issue_ref_parses_and_formats_strict_hash_number() { + let reference = IssueRef::from_str("#42").unwrap(); + + assert_eq!(reference.number().get(), 42); + assert_eq!(reference.to_string(), "#42"); + assert_eq!( + IssueRef::from_str("42"), + Err(IssueError::InvalidRef("42".into())) + ); + assert_eq!(IssueRef::from_str("#0"), Err(IssueError::InvalidNumber)); + assert_eq!( + IssueRef::from_str("#abc"), + Err(IssueError::InvalidRef("#abc".into())) + ); +} + +#[test] +fn issue_number_must_be_positive() { + assert_eq!(IssueNumber::new(0), Err(IssueError::InvalidNumber)); +} + +#[test] +fn issue_title_must_not_be_empty() { + let err = Issue::new( + IssueId::new_random(), + issue_number(1), + " ", + MarkdownDoc::new("Description"), + IssueStatus::Open, + IssuePriority::Medium, + MarkdownDoc::default(), + Vec::new(), + Vec::new(), + IssueActor::User, + 1, + ) + .unwrap_err(); + + assert_eq!(err, IssueError::EmptyTitle); +} + +#[test] +fn issue_rejects_self_link() { + let err = Issue::new( + IssueId::new_random(), + issue_number(9), + "Self link", + MarkdownDoc::new("Description"), + IssueStatus::Open, + IssuePriority::Medium, + MarkdownDoc::default(), + vec![IssueLink { + target: IssueRef::from(issue_number(9)), + kind: IssueLinkKind::Blocks, + }], + Vec::new(), + IssueActor::User, + 1, + ) + .unwrap_err(); + + assert_eq!( + err, + IssueError::SelfLink { + reference: IssueRef::from(issue_number(9)) + } + ); +} + +#[test] +fn issue_mutation_increments_version_and_updates_actor_time() { + let issue = issue(); + assert_eq!(issue.version, IssueVersion::INITIAL); + + let updated = issue + .mutate(IssueActor::System, 2_000, |i| { + i.status = IssueStatus::InProgress; + }) + .unwrap(); + + assert_eq!(updated.version.get(), 2); + assert_eq!(updated.status, IssueStatus::InProgress); + assert_eq!(updated.updated_by, IssueActor::System); + assert_eq!(updated.updated_at, 2_000); +} diff --git a/crates/infrastructure/src/issues.rs b/crates/infrastructure/src/issues.rs new file mode 100644 index 0000000..5a909fc --- /dev/null +++ b/crates/infrastructure/src/issues.rs @@ -0,0 +1,576 @@ +//! Filesystem issue store. +//! +//! Project-scoped issues live under `/.ideai/tickets/`, one directory per +//! issue number. The Markdown files are the source of truth; `index.json` is a +//! reconstructible projection kept in sync after writes. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use std::time::Duration; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use tokio::io::AsyncWriteExt; + +use domain::{ + AgentIssueRef, Issue, IssueActor, IssueCarnet, IssueId, IssueIndexEntry, IssueLink, + IssueListFilter, IssueNumber, IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, + IssueStore, IssueStoreError, IssueVersion, MarkdownDoc, ProjectPath, +}; + +const IDEAI_DIR: &str = ".ideai"; +const ISSUES_DIR: &str = "tickets"; +const ISSUE_FILE: &str = "issue.md"; +const CARNET_FILE: &str = "carnet.md"; +const INDEX_FILE: &str = "index.json"; +const COUNTER_FILE: &str = "counter.json"; +const COUNTER_LOCK: &str = "counter.lock"; +const INDEX_VERSION: u32 = 1; + +/// Filesystem-backed issue store. +#[derive(Debug, Clone, Default)] +pub struct FsIssueStore; + +impl FsIssueStore { + /// Builds a store. + #[must_use] + pub const fn new() -> Self { + Self + } +} + +/// Filesystem-backed issue number allocator. +#[derive(Debug, Clone, Default)] +pub struct FsIssueNumberAllocator; + +impl FsIssueNumberAllocator { + /// Builds an allocator. + #[must_use] + pub const fn new() -> Self { + Self + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CounterDoc { + next_number: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct IndexDoc { + version: u32, + issues: Vec, +} + +fn issue_root(root: &ProjectPath) -> PathBuf { + PathBuf::from(root.as_str()) + .join(IDEAI_DIR) + .join(ISSUES_DIR) +} + +fn issue_dir(root: &ProjectPath, number: IssueNumber) -> PathBuf { + issue_root(root).join(number.get().to_string()) +} + +fn issue_path(root: &ProjectPath, number: IssueNumber) -> PathBuf { + issue_dir(root, number).join(ISSUE_FILE) +} + +fn carnet_path(root: &ProjectPath, number: IssueNumber) -> PathBuf { + issue_dir(root, number).join(CARNET_FILE) +} + +fn index_path(root: &ProjectPath) -> PathBuf { + issue_root(root).join(INDEX_FILE) +} + +fn counter_path(root: &ProjectPath) -> PathBuf { + issue_root(root).join(COUNTER_FILE) +} + +fn counter_lock_path(root: &ProjectPath) -> PathBuf { + issue_root(root).join(COUNTER_LOCK) +} + +async fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), IssueStoreError> { + let parent = path + .parent() + .ok_or_else(|| IssueStoreError::Store("path has no parent".to_owned()))?; + tokio::fs::create_dir_all(parent).await.map_err(io_error)?; + let tmp = path.with_extension("tmp"); + tokio::fs::write(&tmp, bytes).await.map_err(io_error)?; + tokio::fs::rename(&tmp, path).await.map_err(io_error) +} + +async fn read_string(path: &Path) -> Result { + let bytes = tokio::fs::read(path).await.map_err(|err| { + if err.kind() == std::io::ErrorKind::NotFound { + IssueStoreError::NotFound + } else { + io_error(err) + } + })?; + String::from_utf8(bytes).map_err(|err| IssueStoreError::Store(err.to_string())) +} + +fn io_error(err: std::io::Error) -> IssueStoreError { + IssueStoreError::Store(err.to_string()) +} + +async fn load_issue(root: &ProjectPath, issue_ref: IssueRef) -> Result { + let number = issue_ref.number(); + let issue_text = read_string(&issue_path(root, number)).await?; + let mut issue = parse_issue_doc(&issue_text)?; + let carnet = match read_string(&carnet_path(root, number)).await { + Ok(text) => parse_carnet_doc(&text)?.carnet, + Err(IssueStoreError::NotFound) => MarkdownDoc::default(), + Err(err) => return Err(err), + }; + issue.carnet = carnet; + Issue::rehydrate(issue).map_err(|err| IssueStoreError::Invalid(err.to_string())) +} + +async fn save_issue(root: &ProjectPath, issue: &Issue) -> Result<(), IssueStoreError> { + let dir = issue_dir(root, issue.number); + tokio::fs::create_dir_all(&dir).await.map_err(io_error)?; + write_atomic(&dir.join(ISSUE_FILE), render_issue_doc(issue).as_bytes()).await?; + write_atomic(&dir.join(CARNET_FILE), render_carnet_doc(issue).as_bytes()).await +} + +async fn rebuild_index(root: &ProjectPath) -> Result, IssueStoreError> { + let dir = issue_root(root); + let mut rows = Vec::new(); + let mut entries = match tokio::fs::read_dir(&dir).await { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + write_index(root, &rows).await?; + return Ok(rows); + } + Err(err) => return Err(io_error(err)), + }; + while let Some(entry) = entries.next_entry().await.map_err(io_error)? { + let Ok(file_type) = entry.file_type().await else { + continue; + }; + if !file_type.is_dir() { + continue; + } + let name = entry.file_name().to_string_lossy().to_string(); + let Ok(number) = name.parse::() else { + continue; + }; + let Ok(number) = IssueNumber::new(number) else { + continue; + }; + if let Ok(issue) = load_issue(root, IssueRef::from(number)).await { + rows.push(IssueIndexEntry::from(&issue)); + } + } + rows.sort_by_key(|row| row.issue_ref.number().get()); + write_index(root, &rows).await?; + Ok(rows) +} + +async fn read_index(root: &ProjectPath) -> Result, IssueStoreError> { + match tokio::fs::read(index_path(root)).await { + Ok(bytes) => { + let doc: IndexDoc = serde_json::from_slice(&bytes) + .map_err(|err| IssueStoreError::Store(err.to_string()))?; + Ok(doc.issues) + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => rebuild_index(root).await, + Err(err) => Err(io_error(err)), + } +} + +async fn write_index(root: &ProjectPath, rows: &[IssueIndexEntry]) -> Result<(), IssueStoreError> { + let doc = IndexDoc { + version: INDEX_VERSION, + issues: rows.to_vec(), + }; + let bytes = + serde_json::to_vec_pretty(&doc).map_err(|err| IssueStoreError::Store(err.to_string()))?; + write_atomic(&index_path(root), &bytes).await +} + +fn filter_matches(row: &IssueIndexEntry, filter: &IssueListFilter) -> bool { + if filter.status.is_some_and(|status| row.status != status) { + return false; + } + if filter + .priority + .is_some_and(|priority| row.priority != priority) + { + return false; + } + if let Some(agent_id) = filter.assigned_agent_id { + if !row.assigned_agent_ids.contains(&agent_id) { + return false; + } + } + if let Some(text) = filter + .text + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + { + row.title + .to_ascii_lowercase() + .contains(&text.to_ascii_lowercase()) + } else { + true + } +} + +#[async_trait] +impl IssueStore for FsIssueStore { + async fn create(&self, root: &ProjectPath, issue: &Issue) -> Result<(), IssueStoreError> { + issue + .validate() + .map_err(|err| IssueStoreError::Invalid(err.to_string()))?; + if tokio::fs::try_exists(issue_path(root, issue.number)) + .await + .map_err(io_error)? + { + return Err(IssueStoreError::Invalid(format!( + "issue {} already exists", + issue.reference() + ))); + } + save_issue(root, issue).await?; + rebuild_index(root).await?; + Ok(()) + } + + async fn get_by_ref( + &self, + root: &ProjectPath, + issue_ref: IssueRef, + ) -> Result { + load_issue(root, issue_ref).await + } + + async fn list( + &self, + root: &ProjectPath, + filter: IssueListFilter, + ) -> Result, IssueStoreError> { + let rows = read_index(root).await?; + if filter + .text + .as_ref() + .is_some_and(|text| !text.trim().is_empty()) + { + // Text search may need description/carnet, so fall back to source files. + let needle = filter.text.as_ref().unwrap().trim().to_ascii_lowercase(); + let base_filter = IssueListFilter { + text: None, + ..filter + }; + let mut out = Vec::new(); + for row in rows { + if !filter_matches(&row, &base_filter) { + continue; + } + if row.title.to_ascii_lowercase().contains(&needle) { + out.push(row); + continue; + } + if let Ok(issue) = load_issue(root, row.issue_ref).await { + if issue_contains(&issue, &needle) { + out.push(row); + } + } + } + return Ok(out); + } + let mut rows = rows; + rows.retain(|row| filter_matches(row, &filter)); + Ok(rows) + } + + async fn update( + &self, + root: &ProjectPath, + issue: &Issue, + expected_version: IssueVersion, + ) -> Result<(), IssueStoreError> { + issue + .validate() + .map_err(|err| IssueStoreError::Invalid(err.to_string()))?; + let current = load_issue(root, issue.reference()).await?; + if current.version != expected_version { + return Err(IssueStoreError::VersionConflict { + expected: expected_version, + actual: current.version, + }); + } + save_issue(root, issue).await?; + rebuild_index(root).await?; + Ok(()) + } + + async fn read_carnet( + &self, + root: &ProjectPath, + issue_ref: IssueRef, + ) -> Result { + let text = read_string(&carnet_path(root, issue_ref.number())).await?; + parse_carnet_doc(&text) + } + + async fn write_carnet( + &self, + root: &ProjectPath, + issue_ref: IssueRef, + carnet: MarkdownDoc, + actor: IssueActor, + now_ms: u64, + expected_version: IssueVersion, + ) -> Result { + let current = load_issue(root, issue_ref).await?; + if current.version != expected_version { + return Err(IssueStoreError::VersionConflict { + expected: expected_version, + actual: current.version, + }); + } + let updated = current + .mutate(actor, now_ms, |issue| issue.carnet = carnet) + .map_err(|err| IssueStoreError::Invalid(err.to_string()))?; + save_issue(root, &updated).await?; + rebuild_index(root).await?; + self.read_carnet(root, issue_ref).await + } +} + +#[async_trait] +impl IssueNumberAllocator for FsIssueNumberAllocator { + async fn allocate_next(&self, root: &ProjectPath) -> Result { + let dir = issue_root(root); + tokio::fs::create_dir_all(&dir).await.map_err(io_error)?; + let lock_path = counter_lock_path(root); + let mut lock = None; + for _ in 0..50 { + match tokio::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&lock_path) + .await + { + Ok(file) => { + lock = Some(file); + break; + } + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { + tokio::time::sleep(Duration::from_millis(10)).await; + } + Err(err) => return Err(io_error(err)), + } + } + let Some(mut lock_file) = lock else { + return Err(IssueStoreError::Store( + "issue number allocator lock timed out".to_owned(), + )); + }; + lock_file.write_all(b"locked\n").await.map_err(io_error)?; + + let result = async { + let path = counter_path(root); + let current = match tokio::fs::read(&path).await { + Ok(bytes) => { + serde_json::from_slice::(&bytes) + .map_err(|err| IssueStoreError::Store(err.to_string()))? + .next_number + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => 1, + Err(err) => return Err(io_error(err)), + }; + let allocated = IssueNumber::new(current) + .map_err(|err| IssueStoreError::Invalid(err.to_string()))?; + let next = CounterDoc { + next_number: current + 1, + }; + let bytes = serde_json::to_vec_pretty(&next) + .map_err(|err| IssueStoreError::Store(err.to_string()))?; + write_atomic(&path, &bytes).await?; + Ok(allocated) + } + .await; + + let _ = tokio::fs::remove_file(&lock_path).await; + result + } +} + +fn issue_contains(issue: &Issue, needle: &str) -> bool { + issue + .description + .as_str() + .to_ascii_lowercase() + .contains(needle) + || issue.carnet.as_str().to_ascii_lowercase().contains(needle) +} + +fn render_issue_doc(issue: &Issue) -> String { + format!( + "---\n\ +id: {}\n\ +number: {}\n\ +title: {}\n\ +status: {}\n\ +priority: {}\n\ +links: {}\n\ +agentRefs: {}\n\ +createdBy: {}\n\ +updatedBy: {}\n\ +createdAt: {}\n\ +updatedAt: {}\n\ +version: {}\n\ +---\n{}", + json_string(&issue.id.to_string()), + issue.number.get(), + json_string(&issue.title), + json_value(&issue.status), + json_value(&issue.priority), + json_value(&issue.links), + json_value(&issue.agent_refs), + json_value(&issue.created_by), + json_value(&issue.updated_by), + issue.created_at, + issue.updated_at, + issue.version.get(), + issue.description.as_str() + ) +} + +fn render_carnet_doc(issue: &Issue) -> String { + format!( + "---\n\ +issueRef: {}\n\ +version: {}\n\ +updatedBy: {}\n\ +updatedAt: {}\n\ +---\n{}", + json_string(&issue.reference().to_string()), + issue.version.get(), + json_value(&issue.updated_by), + issue.updated_at, + issue.carnet.as_str() + ) +} + +fn parse_issue_doc(text: &str) -> Result { + let (fm, body) = split_frontmatter(text)?; + let map = parse_frontmatter_map(fm)?; + let id_raw: String = parse_json_field(&map, "id")?; + let id = IssueId::from_uuid( + uuid::Uuid::parse_str(&id_raw).map_err(|err| IssueStoreError::Invalid(err.to_string()))?, + ); + let number = IssueNumber::new(parse_plain_u64(&map, "number")?) + .map_err(|err| IssueStoreError::Invalid(err.to_string()))?; + let title: String = parse_json_field(&map, "title")?; + let status: IssueStatus = parse_json_field(&map, "status")?; + let priority: IssuePriority = parse_json_field(&map, "priority")?; + let links: Vec = parse_json_field(&map, "links")?; + let agent_refs: Vec = parse_json_field(&map, "agentRefs")?; + let created_by: IssueActor = parse_json_field(&map, "createdBy")?; + let updated_by: IssueActor = parse_json_field(&map, "updatedBy")?; + let created_at = parse_plain_u64(&map, "createdAt")?; + let updated_at = parse_plain_u64(&map, "updatedAt")?; + let version = IssueVersion::new(parse_plain_u64(&map, "version")?) + .map_err(|err| IssueStoreError::Invalid(err.to_string()))?; + Issue::rehydrate(Issue { + id, + number, + title, + description: MarkdownDoc::new(body), + status, + priority, + carnet: MarkdownDoc::default(), + links, + agent_refs, + created_by, + updated_by, + created_at, + updated_at, + version, + }) + .map_err(|err| IssueStoreError::Invalid(err.to_string())) +} + +fn parse_carnet_doc(text: &str) -> Result { + let (fm, body) = split_frontmatter(text)?; + let map = parse_frontmatter_map(fm)?; + let raw_ref: String = parse_json_field(&map, "issueRef")?; + let issue_ref = + IssueRef::from_str(&raw_ref).map_err(|err| IssueStoreError::Invalid(err.to_string()))?; + let version = IssueVersion::new(parse_plain_u64(&map, "version")?) + .map_err(|err| IssueStoreError::Invalid(err.to_string()))?; + let updated_by: IssueActor = parse_json_field(&map, "updatedBy")?; + let updated_at = parse_plain_u64(&map, "updatedAt")?; + Ok(IssueCarnet { + issue_ref, + carnet: MarkdownDoc::new(body), + version, + updated_by, + updated_at, + }) +} + +fn split_frontmatter(text: &str) -> Result<(&str, &str), IssueStoreError> { + let rest = text + .strip_prefix("---\n") + .or_else(|| text.strip_prefix("---\r\n")) + .ok_or_else(|| IssueStoreError::Invalid("missing opening frontmatter fence".to_owned()))?; + let mut offset = 0; + for line in rest.split_inclusive('\n') { + if line.trim_end_matches(['\r', '\n']) == "---" { + let body_start = offset + line.len(); + return Ok((&rest[..offset], &rest[body_start..])); + } + offset += line.len(); + } + Err(IssueStoreError::Invalid( + "missing closing frontmatter fence".to_owned(), + )) +} + +fn parse_frontmatter_map(block: &str) -> Result, IssueStoreError> { + let mut map = BTreeMap::new(); + for line in block.lines().filter(|line| !line.trim().is_empty()) { + let (key, value) = line + .split_once(':') + .ok_or_else(|| IssueStoreError::Invalid("frontmatter line missing ':'".to_owned()))?; + map.insert(key.trim().to_owned(), value.trim().to_owned()); + } + Ok(map) +} + +fn parse_plain_u64(map: &BTreeMap, key: &str) -> Result { + map.get(key) + .ok_or_else(|| IssueStoreError::Invalid(format!("missing frontmatter key `{key}`")))? + .parse::() + .map_err(|err| IssueStoreError::Invalid(err.to_string())) +} + +fn parse_json_field( + map: &BTreeMap, + key: &str, +) -> Result { + let value = map + .get(key) + .ok_or_else(|| IssueStoreError::Invalid(format!("missing frontmatter key `{key}`")))?; + serde_json::from_str(value).map_err(|err| IssueStoreError::Invalid(err.to_string())) +} + +fn json_value(value: &T) -> String { + serde_json::to_string(value).expect("serializing issue frontmatter cannot fail") +} + +fn json_string(value: &str) -> String { + serde_json::to_string(value).expect("serializing issue string cannot fail") +} diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index e19f9e2..3289154 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -22,6 +22,7 @@ pub mod git; pub mod id; pub mod input; pub mod inspector; +pub mod issues; pub mod mailbox; pub mod orchestrator; pub mod permission; @@ -51,8 +52,11 @@ pub use input::{MediatedInbox, MillisClock, SystemMillisClock}; pub use inspector::{ transcript_activity_token, ClaudeTranscriptInspector, ClaudeTranscriptTurnWatcher, }; +pub use issues::{FsIssueNumberAllocator, FsIssueStore}; pub use mailbox::InMemoryMailbox; -pub use orchestrator::mcp::{McpServer, MemoryTransport, StdioTransport}; +pub use orchestrator::mcp::{ + McpServer, MemoryTransport, StdioTransport, TicketToolError, TicketToolProvider, +}; pub use orchestrator::{ process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle, REQUESTS_SUBDIR, diff --git a/crates/infrastructure/src/orchestrator/mcp/mod.rs b/crates/infrastructure/src/orchestrator/mcp/mod.rs index 711622a..4ca0916 100644 --- a/crates/infrastructure/src/orchestrator/mcp/mod.rs +++ b/crates/infrastructure/src/orchestrator/mcp/mod.rs @@ -30,6 +30,7 @@ pub mod jsonrpc; pub mod server; +pub mod tickets; pub mod tools; pub mod transport; @@ -37,5 +38,6 @@ pub use jsonrpc::{ JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, JSONRPC_VERSION, }; pub use server::McpServer; +pub use tickets::{TicketToolError, TicketToolProvider}; pub use tools::{catalogue, map_tool_call, tool_returns_reply, ToolDef, ToolMapError}; pub use transport::{MemoryTransport, StdioTransport}; diff --git a/crates/infrastructure/src/orchestrator/mcp/server.rs b/crates/infrastructure/src/orchestrator/mcp/server.rs index 1d271d1..e24212b 100644 --- a/crates/infrastructure/src/orchestrator/mcp/server.rs +++ b/crates/infrastructure/src/orchestrator/mcp/server.rs @@ -28,6 +28,7 @@ use super::jsonrpc::{ error_codes, JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, JSONRPC_VERSION, }; +use super::tickets::TicketToolProvider; use super::tools::{self, ToolMapError}; /// The MCP protocol version this server speaks (advertised on `initialize`). @@ -61,6 +62,9 @@ pub struct McpServer { /// (fix race cold-launch, signal MCP). L'infra ne connaît pas `AgentId` : la /// composition root parse le `requester`. `None` ⇒ no-op. ready_sink: Option>, + /// Optional public ticket provider. The MCP surface says `ticket`; the + /// provider maps those calls to application/domain `Issue` use cases. + ticket_tools: Option>, } impl McpServer { @@ -75,6 +79,7 @@ impl McpServer { events: None, requester: String::new(), ready_sink: None, + ticket_tools: None, } } @@ -99,6 +104,13 @@ impl McpServer { self } + /// Attaches the public ticket tool provider. + #[must_use] + pub fn with_ticket_tools(mut self, ticket_tools: Arc) -> Self { + self.ticket_tools = Some(ticket_tools); + self + } + /// Returns a per-connection clone of this server tagged with the connected /// peer's `requester` id (the loopback handshake's `requester`, cadrage v5 §1.4). /// @@ -115,6 +127,7 @@ impl McpServer { events: self.events.clone(), requester: requester.into(), ready_sink: self.ready_sink.clone(), + ticket_tools: self.ticket_tools.clone(), } } @@ -333,6 +346,44 @@ impl McpServer { task_len={task_len}", ); + if tools::is_ticket_tool(&name) { + let result = match &self.ticket_tools { + Some(provider) => { + provider + .handle_ticket_tool(&self.project, &self.requester, &name, arguments) + .await + } + None => Err(super::tickets::TicketToolError::new( + "notConfigured", + "ticket tools are not configured", + )), + }; + self.publish_processed(&name, result.is_ok()); + return match result { + Ok(value) => { + let text = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_owned()); + application::diag!( + "[mcp] tools_call end tool={name} requester={requester_label} \ + target={arg_target} ok=true is_error=false result_len={} elapsed_ms={}", + text.len(), + started.elapsed().as_millis(), + ); + Ok(tool_result_text(&text, false)) + } + Err(err) => { + let text = + serde_json::to_string(&err.to_value()).unwrap_or_else(|_| err.to_string()); + application::diag!( + "[mcp] tools_call end tool={name} requester={requester_label} \ + target={arg_target} ok=false is_error=true result_len={} elapsed_ms={}", + text.len(), + started.elapsed().as_millis(), + ); + Ok(tool_result_text(&text, true)) + } + }; + } + // The handshake-provided requester is still passed to the mapper for tools that // need peer identity. let command = match tools::map_tool_call(&name, &arguments, &self.requester) { diff --git a/crates/infrastructure/src/orchestrator/mcp/tickets.rs b/crates/infrastructure/src/orchestrator/mcp/tickets.rs new file mode 100644 index 0000000..dd42037 --- /dev/null +++ b/crates/infrastructure/src/orchestrator/mcp/tickets.rs @@ -0,0 +1,238 @@ +//! Public MCP ticket tools. +//! +//! The public surface says `ticket`, while the provider behind this trait maps +//! to the application/domain `Issue` use cases. + +use async_trait::async_trait; +use domain::Project; +use serde_json::{json, Value}; + +use super::tools::ToolDef; + +/// Error returned by an MCP ticket tool provider. +#[derive(Debug, Clone, thiserror::Error)] +#[error("{code}: {message}")] +pub struct TicketToolError { + /// Stable machine-readable code. + pub code: &'static str, + /// Human-readable message. + pub message: String, +} + +impl TicketToolError { + /// Builds a typed ticket-tool error. + #[must_use] + pub fn new(code: &'static str, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } + + /// Serialises the error as a camelCase JSON object. + #[must_use] + pub fn to_value(&self) -> Value { + json!({ "code": self.code, "message": self.message }) + } +} + +/// Provider injected by the composition root to execute public ticket tools. +#[async_trait] +pub trait TicketToolProvider: Send + Sync { + /// Executes one public `idea_ticket_*` tool for `project`. + async fn handle_ticket_tool( + &self, + project: &Project, + requester: &str, + name: &str, + arguments: Value, + ) -> Result; +} + +/// Returns true when `name` is one of the public ticket tools. +#[must_use] +pub fn is_ticket_tool(name: &str) -> bool { + matches!( + name, + "idea_ticket_create" + | "idea_ticket_read" + | "idea_ticket_list" + | "idea_ticket_update" + | "idea_ticket_update_status" + | "idea_ticket_update_priority" + | "idea_ticket_read_carnet" + | "idea_ticket_update_carnet" + | "idea_ticket_link" + | "idea_ticket_unlink" + ) +} + +/// Public ticket tool definitions advertised by `tools/list`. +#[must_use] +pub fn catalogue() -> Vec { + let ticket_ref = json!({ "type": "string", "pattern": "^#[1-9][0-9]*$" }); + let status = json!({ "type": "string", "enum": ["open", "inProgress", "QA", "closed"] }); + let priority = json!({ "type": "string", "enum": ["low", "medium", "high", "critical"] }); + let link_kind = json!({ + "type": "string", + "enum": ["relatesTo", "blocks", "blockedBy", "duplicates", "dependsOn"] + }); + let link = json!({ + "type": "object", + "properties": { + "targetRef": ticket_ref.clone(), + "kind": link_kind.clone() + }, + "required": ["targetRef", "kind"], + "additionalProperties": false + }); + + vec![ + ToolDef { + name: "idea_ticket_create", + description: "Create an IdeA ticket in the current project and return it as JSON.", + input_schema: json!({ + "type": "object", + "properties": { + "title": { "type": "string" }, + "description": { "type": "string" }, + "priority": priority.clone(), + "status": status.clone(), + "assignedAgentIds": { "type": "array", "items": { "type": "string", "format": "uuid" } }, + "links": { "type": "array", "items": link.clone() } + }, + "required": ["title"], + "additionalProperties": false + }), + }, + ToolDef { + name: "idea_ticket_read", + description: "Read one IdeA ticket by public reference (#N).", + input_schema: json!({ + "type": "object", + "properties": { + "ref": ticket_ref.clone(), + "includeCarnet": { "type": "boolean" } + }, + "required": ["ref"], + "additionalProperties": false + }), + }, + ToolDef { + name: "idea_ticket_list", + description: "List IdeA tickets in the current project.", + input_schema: json!({ + "type": "object", + "properties": { + "status": status.clone(), + "priority": priority.clone(), + "assignedAgentId": { "type": "string", "format": "uuid" }, + "text": { "type": "string" }, + "limit": { "type": "integer", "minimum": 1 }, + "cursor": { "type": "string" } + }, + "additionalProperties": false + }), + }, + ToolDef { + name: "idea_ticket_update", + description: "Update ticket fields using optimistic concurrency.", + input_schema: json!({ + "type": "object", + "properties": { + "ref": ticket_ref.clone(), + "title": { "type": "string" }, + "description": { "type": "string" }, + "status": status.clone(), + "priority": priority.clone(), + "assignedAgentIds": { "type": "array", "items": { "type": "string", "format": "uuid" } }, + "expectedVersion": { "type": "integer", "minimum": 1 } + }, + "required": ["ref", "expectedVersion"], + "additionalProperties": false + }), + }, + ToolDef { + name: "idea_ticket_update_status", + description: "Update a ticket status using optimistic concurrency.", + input_schema: json!({ + "type": "object", + "properties": { + "ref": ticket_ref.clone(), + "status": status.clone(), + "expectedVersion": { "type": "integer", "minimum": 1 } + }, + "required": ["ref", "status", "expectedVersion"], + "additionalProperties": false + }), + }, + ToolDef { + name: "idea_ticket_update_priority", + description: "Update a ticket priority using optimistic concurrency.", + input_schema: json!({ + "type": "object", + "properties": { + "ref": ticket_ref.clone(), + "priority": priority.clone(), + "expectedVersion": { "type": "integer", "minimum": 1 } + }, + "required": ["ref", "priority", "expectedVersion"], + "additionalProperties": false + }), + }, + ToolDef { + name: "idea_ticket_read_carnet", + description: "Read the editable carnet attached to a ticket.", + input_schema: json!({ + "type": "object", + "properties": { "ref": ticket_ref.clone() }, + "required": ["ref"], + "additionalProperties": false + }), + }, + ToolDef { + name: "idea_ticket_update_carnet", + description: "Update a ticket carnet using optimistic concurrency.", + input_schema: json!({ + "type": "object", + "properties": { + "ref": ticket_ref.clone(), + "carnet": { "type": "string" }, + "expectedVersion": { "type": "integer", "minimum": 1 } + }, + "required": ["ref", "carnet", "expectedVersion"], + "additionalProperties": false + }), + }, + ToolDef { + name: "idea_ticket_link", + description: "Add a link from one ticket to another using optimistic concurrency.", + input_schema: json!({ + "type": "object", + "properties": { + "ref": ticket_ref.clone(), + "targetRef": ticket_ref.clone(), + "kind": link_kind.clone(), + "expectedVersion": { "type": "integer", "minimum": 1 } + }, + "required": ["ref", "targetRef", "kind", "expectedVersion"], + "additionalProperties": false + }), + }, + ToolDef { + name: "idea_ticket_unlink", + description: "Remove a link from one ticket to another using optimistic concurrency.", + input_schema: json!({ + "type": "object", + "properties": { + "ref": ticket_ref.clone(), + "targetRef": ticket_ref, + "kind": link_kind, + "expectedVersion": { "type": "integer", "minimum": 1 } + }, + "required": ["ref", "targetRef", "expectedVersion"], + "additionalProperties": false + }), + }, + ] +} diff --git a/crates/infrastructure/src/orchestrator/mcp/tools.rs b/crates/infrastructure/src/orchestrator/mcp/tools.rs index c287d0e..0753ff5 100644 --- a/crates/infrastructure/src/orchestrator/mcp/tools.rs +++ b/crates/infrastructure/src/orchestrator/mcp/tools.rs @@ -61,7 +61,13 @@ pub fn tool_returns_reply(tool: &str) -> bool { | "idea_memory_read" | "idea_skill_read" | "idea_workstate_read" - ) + ) || is_ticket_tool(tool) +} + +/// Whether `tool` is a public ticket MCP tool. +#[must_use] +pub fn is_ticket_tool(tool: &str) -> bool { + super::tickets::is_ticket_tool(tool) } /// The full catalogue advertised on `tools/list`. @@ -70,7 +76,7 @@ pub fn tool_returns_reply(tool: &str) -> bool { /// [`OrchestratorCommand`]. #[must_use] pub fn catalogue() -> Vec { - vec![ + let mut tools = vec![ ToolDef { name: "idea_list_agents", description: "List the IdeA agents declared in the project's manifest. Returns the \ @@ -255,7 +261,9 @@ pub fn catalogue() -> Vec { "additionalProperties": false }), }, - ] + ]; + tools.extend(super::tickets::catalogue()); + tools } /// Maps an MCP `tools/call` (`name` + `arguments`) to a validated diff --git a/crates/infrastructure/tests/issue_store.rs b/crates/infrastructure/tests/issue_store.rs new file mode 100644 index 0000000..ec99746 --- /dev/null +++ b/crates/infrastructure/tests/issue_store.rs @@ -0,0 +1,163 @@ +use std::path::PathBuf; +use std::str::FromStr; + +use domain::{ + AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueId, IssueListFilter, + IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore, IssueStoreError, + MarkdownDoc, ProjectPath, +}; +use infrastructure::{FsIssueNumberAllocator, FsIssueStore}; +use uuid::Uuid; + +struct TempDir(PathBuf); + +impl TempDir { + fn new() -> Self { + let path = std::env::temp_dir().join(format!("idea-issues-{}", Uuid::new_v4())); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } + + fn root(&self) -> ProjectPath { + ProjectPath::new(self.0.to_string_lossy().to_string()).unwrap() + } + + fn child(&self, rel: &str) -> PathBuf { + self.0.join(rel) + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn issue(root: &ProjectPath, number: u64, title: &str) -> Issue { + let _ = root; + Issue::new( + IssueId::new_random(), + domain::IssueNumber::new(number).unwrap(), + title, + MarkdownDoc::new("Initial description"), + IssueStatus::Open, + IssuePriority::High, + MarkdownDoc::new("Initial carnet"), + Vec::new(), + vec![AgentIssueRef { + agent_id: domain::AgentId::new_random(), + role: AgentIssueRole::Assigned, + }], + IssueActor::User, + 1_000, + ) + .unwrap() +} + +#[tokio::test] +async fn issue_store_writes_markdown_docs_and_index() { + let tmp = TempDir::new(); + let root = tmp.root(); + let store = FsIssueStore::new(); + let issue = issue(&root, 1, "Wire issues"); + + store.create(&root, &issue).await.unwrap(); + + let issue_md = std::fs::read_to_string(tmp.child(".ideai/tickets/1/issue.md")).unwrap(); + let carnet_md = std::fs::read_to_string(tmp.child(".ideai/tickets/1/carnet.md")).unwrap(); + let index = std::fs::read_to_string(tmp.child(".ideai/tickets/index.json")).unwrap(); + + assert!(issue_md.starts_with("---\n")); + assert!(issue_md.contains("title: \"Wire issues\"")); + assert!(issue_md.ends_with("Initial description")); + assert!(carnet_md.contains("issueRef: \"#1\"")); + assert!(carnet_md.ends_with("Initial carnet")); + assert!(index.contains("\"issueRef\": \"#1\"")); + + let loaded = store + .get_by_ref(&root, IssueRef::from_str("#1").unwrap()) + .await + .unwrap(); + assert_eq!(loaded.title, "Wire issues"); + assert_eq!(loaded.carnet.as_str(), "Initial carnet"); +} + +#[tokio::test] +async fn issue_store_update_checks_expected_version() { + let tmp = TempDir::new(); + let root = tmp.root(); + let store = FsIssueStore::new(); + let original = issue(&root, 2, "Conflict"); + store.create(&root, &original).await.unwrap(); + let updated = original + .clone() + .mutate(IssueActor::System, 2_000, |i| { + i.status = IssueStatus::InProgress; + }) + .unwrap(); + + let err = store + .update(&root, &updated, updated.version) + .await + .unwrap_err(); + assert!(matches!(err, IssueStoreError::VersionConflict { .. })); + + store + .update(&root, &updated, original.version) + .await + .unwrap(); + let loaded = store.get_by_ref(&root, updated.reference()).await.unwrap(); + assert_eq!(loaded.status, IssueStatus::InProgress); + assert_eq!(loaded.version.get(), 2); +} + +#[tokio::test] +async fn issue_store_lists_by_index_filters() { + let tmp = TempDir::new(); + let root = tmp.root(); + let store = FsIssueStore::new(); + store + .create(&root, &issue(&root, 1, "Alpha")) + .await + .unwrap(); + let closed = issue(&root, 2, "Beta") + .mutate(IssueActor::System, 2_000, |i| { + i.status = IssueStatus::Closed; + i.priority = IssuePriority::Low; + }) + .unwrap(); + store.create(&root, &closed).await.unwrap(); + + let rows = store + .list( + &root, + IssueListFilter { + status: Some(IssueStatus::Closed), + priority: Some(IssuePriority::Low), + ..IssueListFilter::default() + }, + ) + .await + .unwrap(); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].title, "Beta"); +} + +#[tokio::test] +async fn allocator_never_reuses_numbers() { + let tmp = TempDir::new(); + let root = tmp.root(); + let allocator = FsIssueNumberAllocator::new(); + + let first = allocator.allocate_next(&root).await.unwrap(); + let second = allocator.allocate_next(&root).await.unwrap(); + let third = FsIssueNumberAllocator::new() + .allocate_next(&root) + .await + .unwrap(); + + assert_eq!(first.get(), 1); + assert_eq!(second.get(), 2); + assert_eq!(third.get(), 3); +} diff --git a/crates/infrastructure/tests/mcp_server.rs b/crates/infrastructure/tests/mcp_server.rs index 46bf456..2b84c5c 100644 --- a/crates/infrastructure/tests/mcp_server.rs +++ b/crates/infrastructure/tests/mcp_server.rs @@ -500,6 +500,17 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() { // Live-state tools (programme live-state, lot LS4). "idea_workstate_read", "idea_workstate_set", + // Public ticket tools (Issue domain). + "idea_ticket_create", + "idea_ticket_read", + "idea_ticket_list", + "idea_ticket_update", + "idea_ticket_update_status", + "idea_ticket_update_priority", + "idea_ticket_read_carnet", + "idea_ticket_update_carnet", + "idea_ticket_link", + "idea_ticket_unlink", ] { assert!( names.contains(&expected), @@ -509,8 +520,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() { assert!(!names.contains(&"idea_reply")); assert_eq!( tools.len(), - 13, - "exactly the thirteen exposed idea_* tools; got {names:?}" + 23, + "exactly the twenty-three exposed idea_* tools; got {names:?}" ); // Every tool advertises an object input schema. From c1d82eee8d4832c4aa8703216569c009de9670bc Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 2 Jul 2026 22:56:36 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat(tickets):=20surface=20frontend=20V1=20?= =?UTF-8?q?du=20syst=C3=A8me=20de=20tickets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface UI complète des tickets (domaine Issue exposé « ticket »), validée QA de bout en bout : cargo build + tests backend verts, npm run build + npm test (459) verts. - Domaine : DTO Ticket, 9 events Issue* + guard isTicketEvent. - Ports : TicketGateway (+ types query/input) ajouté à Gateways. - Adapters : TauriTicketGateway réel (+ isTicketVersionConflict), wiring, et MockTicketGateway pour les tests. - Feature tickets : hooks (useTickets, useTicketDetail, useProjectAgents), TicketsPanel, TicketDetail, TicketsView, ticketMeta + tests. - ProjectsView : onglet sidebar « Tickets ». Co-Authored-By: Claude Opus 4.8 --- frontend/src/adapters/index.ts | 3 + frontend/src/adapters/mock/index.ts | 281 ++++++++++- frontend/src/adapters/mock/mock.test.ts | 1 + frontend/src/adapters/ticket.ts | 125 +++++ frontend/src/domain/index.ts | 133 ++++++ .../src/features/projects/ProjectsView.tsx | 11 + .../src/features/tickets/TicketDetail.tsx | 450 ++++++++++++++++++ .../src/features/tickets/TicketsPanel.tsx | 256 ++++++++++ frontend/src/features/tickets/TicketsView.tsx | 34 ++ frontend/src/features/tickets/index.ts | 22 + frontend/src/features/tickets/ticketMeta.tsx | 169 +++++++ .../src/features/tickets/tickets.test.tsx | 245 ++++++++++ .../src/features/tickets/useProjectAgents.ts | 47 ++ .../src/features/tickets/useTicketDetail.ts | 179 +++++++ frontend/src/features/tickets/useTickets.ts | 96 ++++ frontend/src/ports/index.ts | 99 ++++ 16 files changed, 2150 insertions(+), 1 deletion(-) create mode 100644 frontend/src/adapters/ticket.ts create mode 100644 frontend/src/features/tickets/TicketDetail.tsx create mode 100644 frontend/src/features/tickets/TicketsPanel.tsx create mode 100644 frontend/src/features/tickets/TicketsView.tsx create mode 100644 frontend/src/features/tickets/index.ts create mode 100644 frontend/src/features/tickets/ticketMeta.tsx create mode 100644 frontend/src/features/tickets/tickets.test.tsx create mode 100644 frontend/src/features/tickets/useProjectAgents.ts create mode 100644 frontend/src/features/tickets/useTicketDetail.ts create mode 100644 frontend/src/features/tickets/useTickets.ts diff --git a/frontend/src/adapters/index.ts b/frontend/src/adapters/index.ts index 80715b3..4637a5a 100644 --- a/frontend/src/adapters/index.ts +++ b/frontend/src/adapters/index.ts @@ -27,6 +27,7 @@ import { TauriGitGateway } from "./git"; import { TauriPermissionGateway } from "./permission"; import { TauriWorkStateGateway } from "./workState"; import { TauriConversationGateway } from "./conversation"; +import { TauriTicketGateway } from "./ticket"; function notImplemented(what: string): never { const err: GatewayError = { @@ -61,6 +62,7 @@ export function createTauriGateways(): Gateways { permission: new TauriPermissionGateway(), workState: new TauriWorkStateGateway(), conversation: new TauriConversationGateway(), + ticket: new TauriTicketGateway(), }; } @@ -80,4 +82,5 @@ export { TauriPermissionGateway, TauriWorkStateGateway, TauriConversationGateway, + TauriTicketGateway, }; diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index a520224..249d1b5 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -38,6 +38,11 @@ import type { SkillScope, Template, TerminalSession, + Ticket, + TicketCarnet, + TicketLink, + TicketLinkKind, + TicketList, TurnPage, TurnView, Unsubscribe, @@ -70,6 +75,10 @@ import type { TemplateGateway, TerminalGateway, TerminalHandle, + TicketGateway, + TicketListQuery, + CreateTicketInput, + UpdateTicketInput, WorkStateGateway, } from "@/ports"; import { normalizeProjectWorkState } from "../workStateNormalization"; @@ -1783,6 +1792,274 @@ export class MockConversationGateway implements ConversationGateway { } } +/** + * In-memory {@link TicketGateway} with real optimistic-concurrency semantics: + * every mutation checks `expectedVersion` against the stored version and rejects + * a stale write with an `INVALID` {@link GatewayError} whose message contains + * `"version conflict"` (mirroring the backend `IssueStoreError::VersionConflict` + * → `AppError::Invalid` mapping). Mutations emit the matching `Issue*` domain + * event through the injected {@link MockSystemGateway} so event-driven refresh + * can be exercised offline. + */ +export class MockTicketGateway implements TicketGateway { + private tickets = new Map>(); + private carnets = new Map>(); + private counters = new Map(); + + constructor(private readonly system?: MockSystemGateway) {} + + /** Seeds a ticket for deterministic tests; returns the stored clone. */ + _seedTicket(projectId: string, ticket: Ticket): Ticket { + const stored = structuredClone(ticket); + this.projectTickets(projectId).set(stored.ref, stored); + this.counters.set( + projectId, + Math.max(this.counters.get(projectId) ?? 0, stored.number), + ); + return structuredClone(stored); + } + + private projectTickets(projectId: string): Map { + let map = this.tickets.get(projectId); + if (!map) { + map = new Map(); + this.tickets.set(projectId, map); + } + return map; + } + + private projectCarnets(projectId: string): Map { + let map = this.carnets.get(projectId); + if (!map) { + map = new Map(); + this.carnets.set(projectId, map); + } + return map; + } + + private require(projectId: string, ref: string): Ticket { + const ticket = this.projectTickets(projectId).get(ref); + if (!ticket) { + throw { code: "NOT_FOUND", message: `ticket ${ref} not found` } as GatewayError; + } + return ticket; + } + + private guard(ticket: Ticket, expectedVersion: number): void { + if (ticket.version !== expectedVersion) { + throw { + code: "INVALID", + message: `issue version conflict: expected ${expectedVersion}, actual ${ticket.version}`, + } as GatewayError; + } + } + + async create(projectId: string, input: CreateTicketInput): Promise { + const number = (this.counters.get(projectId) ?? 0) + 1; + this.counters.set(projectId, number); + const now = Date.now(); + const ticket: Ticket = { + id: `mock-ticket-${projectId}-${number}`, + ref: `#${number}`, + number, + title: input.title, + description: input.description ?? "", + status: input.status ?? "open", + priority: input.priority ?? "medium", + links: [], + assignedAgentIds: [...(input.assignedAgentIds ?? [])], + createdBy: { kind: "user" }, + updatedBy: { kind: "user" }, + createdAt: now, + updatedAt: now, + version: 1, + }; + this.projectTickets(projectId).set(ticket.ref, ticket); + this.system?.emit({ + type: "issueCreated", + issueId: ticket.id, + issueRef: ticket.ref, + }); + return structuredClone(ticket); + } + + async read( + projectId: string, + ref: string, + includeCarnet = false, + ): Promise { + const ticket = structuredClone(this.require(projectId, ref)); + if (includeCarnet) { + ticket.carnet = this.projectCarnets(projectId).get(ref) ?? ""; + } + return ticket; + } + + async list(projectId: string, query?: TicketListQuery): Promise { + const q = query ?? {}; + const text = q.text?.trim().toLowerCase(); + const rows = [...this.projectTickets(projectId).values()] + .filter((t) => !q.status || t.status === q.status) + .filter((t) => !q.priority || t.priority === q.priority) + .filter( + (t) => + !q.assignedAgentId || t.assignedAgentIds.includes(q.assignedAgentId), + ) + .filter( + (t) => + !text || + t.title.toLowerCase().includes(text) || + t.description.toLowerCase().includes(text), + ) + .sort((a, b) => b.number - a.number); + const start = Number.parseInt(q.cursor ?? "0", 10) || 0; + const limit = Math.min(Math.max(q.limit ?? 100, 1), 500); + const end = Math.min(rows.length, start + limit); + return { + items: rows.slice(start, end).map((t) => ({ + ref: t.ref, + path: `.ideai/tickets/${t.number}.md`, + title: t.title, + status: t.status, + priority: t.priority, + assignedAgentIds: [...t.assignedAgentIds], + updatedAt: t.updatedAt, + })), + ...(end < rows.length ? { nextCursor: String(end) } : {}), + }; + } + + private bump(ticket: Ticket): void { + ticket.version += 1; + ticket.updatedAt = Date.now(); + ticket.updatedBy = { kind: "user" }; + } + + async update( + projectId: string, + ref: string, + input: UpdateTicketInput, + ): Promise { + const ticket = this.require(projectId, ref); + this.guard(ticket, input.expectedVersion); + if (input.title !== undefined) ticket.title = input.title; + if (input.description !== undefined) ticket.description = input.description; + if (input.status !== undefined) ticket.status = input.status; + if (input.priority !== undefined) ticket.priority = input.priority; + if (input.assignedAgentIds !== undefined) { + ticket.assignedAgentIds = [...input.assignedAgentIds]; + } + this.bump(ticket); + this.system?.emit({ + type: "issueUpdated", + issueRef: ref, + version: ticket.version, + }); + return structuredClone(ticket); + } + + async readCarnet(projectId: string, ref: string): Promise { + const ticket = this.require(projectId, ref); + return { + ref, + carnet: this.projectCarnets(projectId).get(ref) ?? "", + version: ticket.version, + }; + } + + async updateCarnet( + projectId: string, + ref: string, + carnet: string, + expectedVersion: number, + ): Promise { + const ticket = this.require(projectId, ref); + this.guard(ticket, expectedVersion); + this.projectCarnets(projectId).set(ref, carnet); + this.bump(ticket); + this.system?.emit({ + type: "issueCarnetUpdated", + issueRef: ref, + version: ticket.version, + }); + return structuredClone(ticket); + } + + async link( + projectId: string, + ref: string, + targetRef: string, + kind: TicketLinkKind, + expectedVersion: number, + ): Promise { + const ticket = this.require(projectId, ref); + this.guard(ticket, expectedVersion); + const exists = ticket.links.some( + (l: TicketLink) => l.targetRef === targetRef && l.kind === kind, + ); + if (!exists) ticket.links.push({ targetRef, kind }); + this.bump(ticket); + this.system?.emit({ + type: "issueLinked", + issueRef: ref, + target: targetRef, + kind, + version: ticket.version, + }); + return structuredClone(ticket); + } + + async unlink( + projectId: string, + ref: string, + targetRef: string, + expectedVersion: number, + kind?: TicketLinkKind, + ): Promise { + const ticket = this.require(projectId, ref); + this.guard(ticket, expectedVersion); + ticket.links = ticket.links.filter( + (l: TicketLink) => + l.targetRef !== targetRef || (kind !== undefined && l.kind !== kind), + ); + this.bump(ticket); + this.system?.emit({ + type: "issueUnlinked", + issueRef: ref, + target: targetRef, + kind: kind ?? "relatesTo", + version: ticket.version, + }); + return structuredClone(ticket); + } + + async assign( + projectId: string, + ref: string, + agentId: string, + assigned: boolean, + expectedVersion: number, + ): Promise { + const ticket = this.require(projectId, ref); + this.guard(ticket, expectedVersion); + const has = ticket.assignedAgentIds.includes(agentId); + if (assigned && !has) ticket.assignedAgentIds.push(agentId); + if (!assigned && has) { + ticket.assignedAgentIds = ticket.assignedAgentIds.filter( + (id) => id !== agentId, + ); + } + this.bump(ticket); + this.system?.emit({ + type: assigned ? "issueAgentAssigned" : "issueAgentUnassigned", + issueRef: ref, + agentId, + version: ticket.version, + }); + return structuredClone(ticket); + } +} + function mostRestrictive( project?: PermissionSet["fallback"], agent?: PermissionSet["fallback"], @@ -1796,8 +2073,9 @@ function mostRestrictive( /** Builds the full set of mock gateways. */ export function createMockGateways(): Gateways { const agentGateway = new MockAgentGateway(); + const systemGateway = new MockSystemGateway(); return { - system: new MockSystemGateway(), + system: systemGateway, agent: agentGateway, input: new MockInputGateway(), terminal: new MockTerminalGateway(), @@ -1813,6 +2091,7 @@ export function createMockGateways(): Gateways { permission: new MockPermissionGateway(), workState: new MockWorkStateGateway(), conversation: new MockConversationGateway(), + ticket: new MockTicketGateway(systemGateway), }; } diff --git a/frontend/src/adapters/mock/mock.test.ts b/frontend/src/adapters/mock/mock.test.ts index dd3062a..4e9bf09 100644 --- a/frontend/src/adapters/mock/mock.test.ts +++ b/frontend/src/adapters/mock/mock.test.ts @@ -29,6 +29,7 @@ describe("createMockGateways", () => { "system", "template", "terminal", + "ticket", "workState", ]); }); diff --git a/frontend/src/adapters/ticket.ts b/frontend/src/adapters/ticket.ts new file mode 100644 index 0000000..3b25729 --- /dev/null +++ b/frontend/src/adapters/ticket.ts @@ -0,0 +1,125 @@ +/** + * Tauri adapter for {@link TicketGateway}. + * + * The single frontend owner of the `ticket_*` command names and their request + * envelopes (every command takes one camelCase `request` object). Features + * consume the gateway port through DI and never call `invoke()` directly. + * + * Optimistic concurrency: the backend rejects a stale write with an `INVALID` + * {@link GatewayError} whose message contains `"version conflict"`; use + * {@link isTicketVersionConflict} to branch on it in the UI. + */ + +import { invoke } from "@tauri-apps/api/core"; + +import type { + GatewayError, + Ticket, + TicketCarnet, + TicketLinkKind, + TicketList, +} from "@/domain"; +import type { + CreateTicketInput, + TicketGateway, + TicketListQuery, + UpdateTicketInput, +} from "@/ports"; + +/** + * Whether a gateway error is an optimistic-concurrency conflict (the ticket was + * mutated since it was read). The backend returns a generic `INVALID` code, so + * we key off the stable message fragment emitted by the domain store error. + */ +export function isTicketVersionConflict(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const message = (error as GatewayError).message; + return typeof message === "string" && message.includes("version conflict"); +} + +export class TauriTicketGateway implements TicketGateway { + async create(projectId: string, input: CreateTicketInput): Promise { + return invoke("ticket_create", { + request: { projectId, ...input }, + }); + } + + async read( + projectId: string, + ref: string, + includeCarnet = false, + ): Promise { + return invoke("ticket_read", { + request: { projectId, ref, includeCarnet }, + }); + } + + async list(projectId: string, query?: TicketListQuery): Promise { + return invoke("ticket_list", { + request: { projectId, ...(query ?? {}) }, + }); + } + + async update( + projectId: string, + ref: string, + input: UpdateTicketInput, + ): Promise { + return invoke("ticket_update", { + request: { projectId, ref, ...input }, + }); + } + + async readCarnet(projectId: string, ref: string): Promise { + return invoke("ticket_read_carnet", { + request: { projectId, ref }, + }); + } + + async updateCarnet( + projectId: string, + ref: string, + carnet: string, + expectedVersion: number, + ): Promise { + return invoke("ticket_update_carnet", { + request: { projectId, ref, carnet, expectedVersion }, + }); + } + + async link( + projectId: string, + ref: string, + targetRef: string, + kind: TicketLinkKind, + expectedVersion: number, + ): Promise { + return invoke("ticket_link", { + request: { projectId, ref, targetRef, kind, expectedVersion }, + }); + } + + async unlink( + projectId: string, + ref: string, + targetRef: string, + expectedVersion: number, + kind?: TicketLinkKind, + ): Promise { + return invoke("ticket_unlink", { + request: { projectId, ref, targetRef, expectedVersion, kind }, + }); + } + + async assign( + projectId: string, + ref: string, + agentId: string, + assigned: boolean, + expectedVersion: number, + ): Promise { + return invoke("ticket_assign", { + request: { projectId, ref, agentId, assigned, expectedVersion }, + }); + } +} diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index ae67206..2dc4a0f 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -104,8 +104,60 @@ export type DomainEvent = */ source?: "mcp" | "file"; } + | { type: "issueCreated"; issueId: string; issueRef: string } + | { type: "issueUpdated"; issueRef: string; version: number } + | { + type: "issueStatusChanged"; + issueRef: string; + status: TicketStatus; + version: number; + } + | { + type: "issuePriorityChanged"; + issueRef: string; + priority: TicketPriority; + version: number; + } + | { type: "issueCarnetUpdated"; issueRef: string; version: number } + | { + type: "issueLinked"; + issueRef: string; + target: string; + kind: TicketLinkKind; + version: number; + } + | { + type: "issueUnlinked"; + issueRef: string; + target: string; + kind: TicketLinkKind; + version: number; + } + | { + type: "issueAgentAssigned"; + issueRef: string; + agentId: string; + version: number; + } + | { + type: "issueAgentUnassigned"; + issueRef: string; + agentId: string; + version: number; + } | { type: "ptyOutput"; sessionId: string; bytes: number[] }; +/** + * Whether a domain event is one of the ticket (`Issue*`) events. A small helper + * so ticket view-models refresh on any ticket mutation without re-enumerating + * every variant at each call site. + */ +export function isTicketEvent( + event: DomainEvent, +): event is Extract { + return event.type.startsWith("issue"); +} + /** Where a project physically lives (mirror of the backend `RemoteRef`). */ export type RemoteRef = | { kind: "local" } @@ -709,3 +761,84 @@ export interface GitBranches { branches: string[]; current: string | null; } + +// --------------------------------------------------------------------------- +// Tickets (public wire name for the backend `Issue` model — "ticket" is the +// user-visible term; all commands/DTOs/events speak `ticket`/`issue`). Mirrors +// the `Ticket*Dto` shapes in `crates/app-tauri/src/tickets.rs` (camelCase). +// --------------------------------------------------------------------------- + +/** A ticket reference on the wire, always `#` (e.g. `"#42"`). */ +export type TicketRef = string; + +/** Lifecycle status of a ticket. */ +export type TicketStatus = "open" | "inProgress" | "QA" | "closed"; + +/** Priority of a ticket. */ +export type TicketPriority = "low" | "medium" | "high" | "critical"; + +/** The kind of relationship between two tickets. */ +export type TicketLinkKind = + | "relatesTo" + | "blocks" + | "blockedBy" + | "duplicates" + | "dependsOn"; + +/** Who performed a ticket mutation (mirror of `TicketActorDto`, tagged `kind`). */ +export type TicketActor = + | { kind: "user" } + | { kind: "agent"; agentId: string } + | { kind: "system" }; + +/** One directed link from a ticket to another (mirror of `TicketLinkDto`). */ +export interface TicketLink { + targetRef: TicketRef; + kind: TicketLinkKind; +} + +/** The full ticket, as returned by `ticket_read`/`ticket_create`/… . */ +export interface Ticket { + id: string; + ref: TicketRef; + number: number; + title: string; + description: string; + status: TicketStatus; + priority: TicketPriority; + /** Present only when the ticket was read with `includeCarnet`. */ + carnet?: string; + links: TicketLink[]; + assignedAgentIds: string[]; + createdBy: TicketActor; + updatedBy: TicketActor; + createdAt: number; + updatedAt: number; + /** Optimistic-concurrency version; echoed back as `expectedVersion` on writes. */ + version: number; +} + +/** A ticket summary row from `ticket_list` (mirror of `TicketSummaryDto`). */ +export interface TicketSummary { + ref: TicketRef; + path: string; + title: string; + status: TicketStatus; + priority: TicketPriority; + assignedAgentIds: string[]; + updatedAt: number; +} + +/** One page of ticket summaries (mirror of `TicketListDto`). */ +export interface TicketList { + items: TicketSummary[]; + /** Opaque cursor for the next page, absent when the list is exhausted. */ + nextCursor?: string; +} + +/** The scoped-to-a-ticket Markdown carnet (mirror of `TicketCarnetDto`). */ +export interface TicketCarnet { + ref: TicketRef; + carnet: string; + version: number; +} diff --git a/frontend/src/features/projects/ProjectsView.tsx b/frontend/src/features/projects/ProjectsView.tsx index 7915fd1..e11e83c 100644 --- a/frontend/src/features/projects/ProjectsView.tsx +++ b/frontend/src/features/projects/ProjectsView.tsx @@ -39,6 +39,7 @@ import { MemoryPanel } from "@/features/memory"; import { EmbedderSettings } from "@/features/embedder"; import { PermissionsPanel } from "@/features/permissions"; import { ProjectWorkStatePanel } from "@/features/workstate"; +import { TicketsView } from "@/features/tickets"; import { ConversationViewer } from "@/features/conversations"; import { GitPanel, GitGraphView } from "@/features/git"; import { Button, Input, Panel, Tabs, cn } from "@/shared"; @@ -50,6 +51,7 @@ type SidebarTab = | "projects" | "context" | "work" + | "tickets" | "agents" | "templates" | "skills" @@ -61,6 +63,7 @@ const SIDEBAR_TABS: { id: SidebarTab; label: string }[] = [ { id: "projects", label: "Projects" }, { id: "context", label: "Context" }, { id: "work", label: "Work" }, + { id: "tickets", label: "Tickets" }, { id: "agents", label: "Agents" }, { id: "templates", label: "Templates" }, { id: "skills", label: "Skills" }, @@ -304,6 +307,14 @@ export function ProjectsView() {

Open a project to view work state.

)} + {/* Tickets panel */} + {sidebarTab === "tickets" && active && ( + + )} + {sidebarTab === "tickets" && !active && ( +

Open a project to view tickets.

+ )} + {/* Agents panel — only rendered when active project exists */} {sidebarTab === "agents" && active && ( diff --git a/frontend/src/features/tickets/TicketDetail.tsx b/frontend/src/features/tickets/TicketDetail.tsx new file mode 100644 index 0000000..bd7f5ed --- /dev/null +++ b/frontend/src/features/tickets/TicketDetail.tsx @@ -0,0 +1,450 @@ +/** + * Ticket detail / edit overlay (F3, F4, F5, F7). + * + * A full-screen dialog mirroring the ticket: editable title/description, status + * and priority, the ticket-scoped Markdown **carnet** (replaceable save, F4), + * links to other tickets (F5) and agent assignments limited to known agents + * (F5). Optimistic-concurrency conflicts surface a clear banner and reload + * (F3). `#ref`s are clickable throughout (F7), and a one-click "delegation + * context" prefill lets the user hand an agent "work on #N" (F7). + */ + +import { useEffect, useState } from "react"; + +import type { TicketLinkKind } from "@/domain"; +import { Button, Input, Spinner, cn } from "@/shared"; +import { useTicketDetail } from "./useTicketDetail"; +import { useProjectAgents } from "./useProjectAgents"; +import { + PriorityBadge, + StatusBadge, + TICKET_LINK_KINDS, + TICKET_PRIORITIES, + TICKET_STATUSES, + TicketRef, + linkKindLabel, + linkifyTicketRefs, + priorityLabel, + statusLabel, +} from "./ticketMeta"; + +const selectClass = cn( + "h-9 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", +); + +const textareaClass = cn( + "w-full rounded-md bg-raised p-3 text-sm text-content", + "border border-border outline-none transition-colors", + "focus:border-primary disabled:cursor-not-allowed disabled:opacity-50", +); + +export interface TicketDetailProps { + projectId: string; + ticketRef: string; + onClose: () => void; + /** Navigate to another ticket (from a link or an inline `#ref`, F7). */ + onOpenRef: (ref: string) => void; +} + +async function copyToClipboard(text: string): Promise { + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + return false; +} + +function Section({ + title, + hint, + children, +}: { + title: string; + hint?: string; + children: React.ReactNode; +}) { + return ( +
+
+

+ {title} +

+ {hint &&

{hint}

} +
+ {children} +
+ ); +} + +export function TicketDetail({ + projectId, + ticketRef, + onClose, + onOpenRef, +}: TicketDetailProps) { + const vm = useTicketDetail(projectId, ticketRef); + const { agents, nameOf } = useProjectAgents(projectId); + const t = vm.ticket; + + // Local draft state, re-seeded whenever the loaded ticket version changes so a + // conflict-reload or an external edit refreshes the fields. + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [carnet, setCarnet] = useState(""); + const [linkTarget, setLinkTarget] = useState(""); + const [linkKind, setLinkKind] = useState("relatesTo"); + const [assignPick, setAssignPick] = useState(""); + const [copied, setCopied] = useState(false); + + const version = t?.version; + useEffect(() => { + if (!t) return; + setTitle(t.title); + setDescription(t.description); + setCarnet(t.carnet ?? ""); + }, [t, version]); + + const dirtyFields = + !!t && (title !== t.title || description !== t.description); + const dirtyCarnet = !!t && carnet !== (t.carnet ?? ""); + const assignable = agents.filter((a) => !t?.assignedAgentIds.includes(a.id)); + + async function copyDelegation() { + if (!t) return; + const ok = await copyToClipboard( + `Travaille sur le ticket ${t.ref} : ${t.title}`, + ); + if (ok) { + setCopied(true); + window.setTimeout(() => setCopied(false), 1200); + } + } + + return ( +
+ {/* ── Header ── */} +
+
+ + {t && } + {t && } + + {t?.title ?? ticketRef} + +
+
+ + +
+
+ + {vm.conflict && ( +

+ This ticket was modified elsewhere and reloaded. Re-apply your change. +

+ )} + {vm.error && !vm.conflict && ( +

+ {vm.error} +

+ )} + + {vm.busy && !t ? ( +
+ + Loading ticket… +
+ ) : !t ? ( +
+ Ticket not found. +
+ ) : ( +
+ {/* ── Fields (F3) ── */} +
+ + setTitle(e.target.value)} + disabled={vm.busy} + /> + +