Merge branch 'feature/issue-ticket-system' into feature/background-tasks-first-class
Intègre le système de tickets/issues V1 (validé QA vert de bout en bout, mémoire tickets-v1-e2e-validated-qa) dans la branche des tâches de fond. Conflits résolus : app-tauri/state.rs, domain/events.rs, domain/lib.rs, domain/ports.rs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -14,6 +14,7 @@ use tauri::{AppHandle, Emitter};
|
|||||||
|
|
||||||
use domain::events::{DomainEvent, OrchestrationSource};
|
use domain::events::{DomainEvent, OrchestrationSource};
|
||||||
use domain::input::AgentLiveness;
|
use domain::input::AgentLiveness;
|
||||||
|
use domain::{IssueLinkKind, IssuePriority, IssueStatus};
|
||||||
use infrastructure::TokioBroadcastEventBus;
|
use infrastructure::TokioBroadcastEventBus;
|
||||||
|
|
||||||
/// Name of the Tauri event carrying relayed [`DomainEvent`]s.
|
/// Name of the Tauri event carrying relayed [`DomainEvent`]s.
|
||||||
@ -193,6 +194,94 @@ pub enum DomainEventDto {
|
|||||||
/// Project id.
|
/// Project id.
|
||||||
project_id: String,
|
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.
|
/// An orchestrator request was processed on behalf of a requester agent.
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
OrchestratorRequestProcessed {
|
OrchestratorRequestProcessed {
|
||||||
@ -347,6 +436,34 @@ impl From<OrchestrationSource> 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 {
|
impl From<&DomainEvent> for DomainEventDto {
|
||||||
fn from(e: &DomainEvent) -> Self {
|
fn from(e: &DomainEvent) -> Self {
|
||||||
match e {
|
match e {
|
||||||
@ -544,6 +661,79 @@ impl From<&DomainEvent> for DomainEventDto {
|
|||||||
DomainEvent::GitStateChanged { project_id } => Self::GitStateChanged {
|
DomainEvent::GitStateChanged { project_id } => Self::GitStateChanged {
|
||||||
project_id: project_id.to_string(),
|
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 {
|
DomainEvent::OrchestratorRequestProcessed {
|
||||||
requester_id,
|
requester_id,
|
||||||
action,
|
action,
|
||||||
|
|||||||
@ -21,6 +21,7 @@ pub mod mcp_bridge;
|
|||||||
pub mod mcp_endpoint;
|
pub mod mcp_endpoint;
|
||||||
pub mod pty;
|
pub mod pty;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
pub mod tickets;
|
||||||
|
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
|
|
||||||
@ -163,6 +164,15 @@ pub fn run() {
|
|||||||
commands::dismiss_embedder_suggestion,
|
commands::dismiss_embedder_suggestion,
|
||||||
commands::create_agent,
|
commands::create_agent,
|
||||||
commands::list_agents,
|
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::get_project_work_state,
|
||||||
commands::read_conversation_page,
|
commands::read_conversation_page,
|
||||||
commands::list_live_agents,
|
commands::list_live_agents,
|
||||||
|
|||||||
@ -12,30 +12,31 @@ use std::path::PathBuf;
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
AgentResumer, AgentWakeService, AppError, AssignSkillToAgent, AttachLiveAgent,
|
AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent,
|
||||||
BackgroundCommandArchive, CancelBackgroundTask, ChangeAgentProfile, CheckEmbedderSuggestion,
|
AttachLiveAgent, BackgroundCommandArchive, CancelBackgroundTask, ChangeAgentProfile,
|
||||||
CloseProject, CloseTab, CloseTerminal, RetryBackgroundTask, SpawnBackgroundCommand,
|
CheckEmbedderSuggestion, CloseProject, CloseTab, CloseTerminal, ConfigureProfiles,
|
||||||
ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate,
|
ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate, CreateIssue,
|
||||||
CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateTemplate, DeleteAgent,
|
CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateTemplate, DeleteAgent,
|
||||||
DeleteEmbedderProfile, DeleteLayout, DeleteMemory, DeleteProfile, DeleteSkill, DeleteTemplate,
|
DeleteEmbedderProfile, DeleteLayout, DeleteMemory, DeleteProfile, DeleteSkill, DeleteTemplate,
|
||||||
DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion,
|
DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion,
|
||||||
FirstRunState, GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectWorkState,
|
FirstRunState, GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectWorkState,
|
||||||
GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus,
|
GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus,
|
||||||
GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation, LaunchAgent,
|
GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation, LaunchAgent,
|
||||||
LaunchAgentInput, ListAgents, ListAgentsInput, ListEmbedderProfiles, ListLayouts, ListMemories,
|
LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListEmbedderProfiles, ListIssues,
|
||||||
ListProfiles, ListProjects, ListResumableAgents, ListSkills, ListTemplates, LiveAgentRegistry,
|
ListLayouts, ListMemories, ListProfiles, ListProjects, ListResumableAgents, ListSkills,
|
||||||
LiveSessions, LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout,
|
ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, LiveStateProvider,
|
||||||
McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
|
LiveStateReadProvider, LoadLayout, McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView,
|
||||||
OrchestratorService, PermissionProjectorRegistry, ProposeContext, ReadAgentContext,
|
OpenProject, OpenTerminal, OrchestratorService, PermissionProjectorRegistry, ProposeContext,
|
||||||
ReadContext, ReadConversationPage, ReadMemory, ReadMemoryIndex, ReadProjectContext, ReadSkill,
|
ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory,
|
||||||
RecallMemory, ReconcileLayouts, ReconcileLiveState, ReconcileLiveStateInput, RecordTurn,
|
ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts,
|
||||||
RecordTurnProvider, ReferenceProfiles, RenameLayout, ResizeTerminal, ResolveAgentPermissions,
|
ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles,
|
||||||
ResolveMemoryLinks, RotateConversationLog, SaveEmbedderProfile, SaveProfile,
|
RenameLayout, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks, RetryBackgroundTask,
|
||||||
SessionLimitService, SetActiveLayout, SnapshotRunningAgents, StopLiveAgent, StructuredSessions,
|
RotateConversationLog, SaveEmbedderProfile, SaveProfile, SessionLimitService, SetActiveLayout,
|
||||||
|
SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent, StructuredSessions,
|
||||||
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent,
|
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent,
|
||||||
UpdateAgentContext, UpdateAgentPermissions, UpdateLiveState, UpdateMemory,
|
UnlinkIssues, UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet,
|
||||||
UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate,
|
UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill,
|
||||||
WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
@ -43,8 +44,9 @@ use domain::ports::{
|
|||||||
BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskStore,
|
BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskStore,
|
||||||
Clock, Embedder, EmbedderEnvInspector,
|
Clock, Embedder, EmbedderEnvInspector,
|
||||||
EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator,
|
EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator,
|
||||||
MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner, ProfileStore, ProjectStore,
|
IssueNumberAllocator, IssueStore, MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner,
|
||||||
PtyPort, ScheduledTask, Scheduler, SkillStore, TemplateStore, WakeError, WakeReason,
|
ProfileStore, ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, TemplateStore,
|
||||||
|
WakeError, WakeReason,
|
||||||
};
|
};
|
||||||
use domain::profile::{
|
use domain::profile::{
|
||||||
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
||||||
@ -59,24 +61,25 @@ use serde_json::{json, Map, Value};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use infrastructure::{
|
use infrastructure::{
|
||||||
embedder_from_profile, AdaptiveMemoryRecall, BackgroundCompletionSink,
|
AdaptiveMemoryRecall, BackgroundCompletionSink, BackgroundTaskReadyToDeliver,
|
||||||
BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector,
|
ClaudePermissionProjector, ClaudeTranscriptInspector, CliAgentRuntime,
|
||||||
CliAgentRuntime, CommandBackgroundRunner,
|
CodexPermissionProjector, CommandBackgroundRunner, embedder_from_profile, EmbedderEnvProbe,
|
||||||
CodexPermissionProjector, EmbedderEnvProbe, FsBackgroundTaskStore, FsConversationLog,
|
FsBackgroundTaskStore, FsConversationLog, FsEmbedderProfileStore, FsEmbedderPromptStore,
|
||||||
FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsLiveStateStore, FsMemoryStore,
|
FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsMemoryStore,
|
||||||
FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, FsProjectStore,
|
FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, FsProjectStore,
|
||||||
FsProviderSessionStore, FsSkillStore, FsTemplateStore, Git2Repository,
|
FsProviderSessionStore, FsSkillStore, FsTemplateStore, Git2Repository,
|
||||||
HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
||||||
LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
|
LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
|
||||||
OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory,
|
OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory,
|
||||||
SystemClock, SystemMillisClock, TokioBroadcastEventBus, TokioScheduler, UuidGenerator,
|
SystemClock, SystemMillisClock, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler,
|
||||||
VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
|
UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
|
||||||
VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::chat::ChatBridge;
|
use crate::chat::ChatBridge;
|
||||||
use crate::mcp_endpoint::{mcp_endpoint, AppMcpRuntimeProvider, McpEndpoint};
|
use crate::mcp_endpoint::{mcp_endpoint, AppMcpRuntimeProvider, McpEndpoint};
|
||||||
use crate::pty::PtyBridge;
|
use crate::pty::PtyBridge;
|
||||||
|
use crate::tickets::AppTicketToolProvider;
|
||||||
|
|
||||||
use infrastructure::StdioTransport;
|
use infrastructure::StdioTransport;
|
||||||
|
|
||||||
@ -742,6 +745,26 @@ pub struct AppState {
|
|||||||
pub terminal_sessions: Arc<TerminalSessions>,
|
pub terminal_sessions: Arc<TerminalSessions>,
|
||||||
/// The domain event bus (also handed to the event relay).
|
/// The domain event bus (also handed to the event relay).
|
||||||
pub event_bus: Arc<TokioBroadcastEventBus>,
|
pub event_bus: Arc<TokioBroadcastEventBus>,
|
||||||
|
/// Create an issue-backed public ticket.
|
||||||
|
pub create_issue: Arc<CreateIssue>,
|
||||||
|
/// Read an issue-backed public ticket.
|
||||||
|
pub read_issue: Arc<ReadIssue>,
|
||||||
|
/// List issue-backed public tickets.
|
||||||
|
pub list_issues: Arc<ListIssues>,
|
||||||
|
/// Update an issue-backed public ticket.
|
||||||
|
pub update_issue: Arc<UpdateIssue>,
|
||||||
|
/// Read a ticket carnet.
|
||||||
|
pub read_issue_carnet: Arc<ReadIssueCarnet>,
|
||||||
|
/// Update a ticket carnet.
|
||||||
|
pub update_issue_carnet: Arc<UpdateIssueCarnet>,
|
||||||
|
/// Link two public tickets.
|
||||||
|
pub link_issues: Arc<LinkIssues>,
|
||||||
|
/// Unlink public tickets.
|
||||||
|
pub unlink_issues: Arc<UnlinkIssues>,
|
||||||
|
/// Assign or unassign an agent on a public ticket.
|
||||||
|
pub assign_issue_agent: Arc<AssignIssueAgent>,
|
||||||
|
/// MCP provider for the public `idea_ticket_*` tools.
|
||||||
|
pub(crate) ticket_tool_provider: Arc<dyn TicketToolProvider>,
|
||||||
/// Generic PTY↔Channel bridge registry (consumed by L3).
|
/// Generic PTY↔Channel bridge registry (consumed by L3).
|
||||||
pub pty_bridge: Arc<PtyBridge>,
|
pub pty_bridge: Arc<PtyBridge>,
|
||||||
/// Registre des sessions structurées (IA / cellules chat, §17.5). Partagé avec
|
/// Registre des sessions structurées (IA / cellules chat, §17.5). Partagé avec
|
||||||
@ -1104,6 +1127,62 @@ impl AppState {
|
|||||||
let contexts = Arc::new(IdeaiContextStore::new(Arc::clone(&fs_port)));
|
let contexts = Arc::new(IdeaiContextStore::new(Arc::clone(&fs_port)));
|
||||||
let contexts_port = Arc::clone(&contexts) as Arc<dyn AgentContextStore>;
|
let contexts_port = Arc::clone(&contexts) as Arc<dyn AgentContextStore>;
|
||||||
|
|
||||||
|
// --- Public tickets (Issue domain) ---
|
||||||
|
// One stateless FS store/allocator serves every project; the project root is
|
||||||
|
// passed on each port call and resolves to `<root>/.ideai/tickets/`.
|
||||||
|
let issue_store = Arc::new(FsIssueStore::new());
|
||||||
|
let issue_store_port = Arc::clone(&issue_store) as Arc<dyn IssueStore>;
|
||||||
|
let issue_allocator = Arc::new(FsIssueNumberAllocator::new());
|
||||||
|
let issue_allocator_port = Arc::clone(&issue_allocator) as Arc<dyn IssueNumberAllocator>;
|
||||||
|
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<dyn IdGenerator>,
|
||||||
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||||
|
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<dyn Clock>,
|
||||||
|
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<dyn Clock>,
|
||||||
|
Arc::clone(&events_port),
|
||||||
|
));
|
||||||
|
let link_issues = Arc::new(LinkIssues::new(
|
||||||
|
Arc::clone(&issue_store_port),
|
||||||
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||||
|
Arc::clone(&events_port),
|
||||||
|
));
|
||||||
|
let unlink_issues = Arc::new(UnlinkIssues::new(
|
||||||
|
Arc::clone(&issue_store_port),
|
||||||
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||||
|
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<dyn Clock>,
|
||||||
|
Arc::clone(&events_port),
|
||||||
|
));
|
||||||
|
let ticket_tool_provider: Arc<dyn TicketToolProvider> = 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) ---
|
// --- Project permissions (LP1) ---
|
||||||
let permission_store = Arc::new(FsPermissionStore::new(Arc::clone(&fs_port)));
|
let permission_store = Arc::new(FsPermissionStore::new(Arc::clone(&fs_port)));
|
||||||
let permission_store_port = Arc::clone(&permission_store) as Arc<dyn PermissionStore>;
|
let permission_store_port = Arc::clone(&permission_store) as Arc<dyn PermissionStore>;
|
||||||
@ -1964,6 +2043,16 @@ impl AppState {
|
|||||||
pty_port,
|
pty_port,
|
||||||
terminal_sessions,
|
terminal_sessions,
|
||||||
event_bus,
|
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,
|
pty_bridge,
|
||||||
structured_sessions,
|
structured_sessions,
|
||||||
chat_bridge,
|
chat_bridge,
|
||||||
@ -2122,7 +2211,8 @@ impl AppState {
|
|||||||
let handle = McpServerHandle::start(
|
let handle = McpServerHandle::start(
|
||||||
McpServer::new(Arc::clone(&self.orchestrator_service), project.clone())
|
McpServer::new(Arc::clone(&self.orchestrator_service), project.clone())
|
||||||
.with_events(events)
|
.with_events(events)
|
||||||
.with_ready_sink(ready_sink),
|
.with_ready_sink(ready_sink)
|
||||||
|
.with_ticket_tools(Arc::clone(&self.ticket_tool_provider)),
|
||||||
endpoint,
|
endpoint,
|
||||||
listener,
|
listener,
|
||||||
project_id,
|
project_id,
|
||||||
@ -4013,6 +4103,7 @@ mod mcp_serve_peer_tests {
|
|||||||
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
|
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
|
||||||
for expected in [
|
for expected in [
|
||||||
"idea_list_agents",
|
"idea_list_agents",
|
||||||
|
"idea_ask_agent",
|
||||||
"idea_launch_agent",
|
"idea_launch_agent",
|
||||||
"idea_stop_agent",
|
"idea_stop_agent",
|
||||||
"idea_update_context",
|
"idea_update_context",
|
||||||
@ -4029,6 +4120,17 @@ mod mcp_serve_peer_tests {
|
|||||||
// Live-state (programme live-state, lot LS4).
|
// Live-state (programme live-state, lot LS4).
|
||||||
"idea_workstate_read",
|
"idea_workstate_read",
|
||||||
"idea_workstate_set",
|
"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!(
|
assert!(
|
||||||
names.contains(&expected),
|
names.contains(&expected),
|
||||||
@ -4038,8 +4140,8 @@ mod mcp_serve_peer_tests {
|
|||||||
assert!(!names.contains(&"idea_reply"));
|
assert!(!names.contains(&"idea_reply"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tools.len(),
|
tools.len(),
|
||||||
13,
|
23,
|
||||||
"exactly the thirteen current idea_* tools; got {names:?}"
|
"exactly the twenty-three exposed idea_* tools; got {names:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
drop(client); // EOF ⇒ serve loop ends
|
drop(client); // EOF ⇒ serve loop ends
|
||||||
|
|||||||
1025
crates/app-tauri/src/tickets.rs
Normal file
1025
crates/app-tauri/src/tickets.rs
Normal file
File diff suppressed because it is too large
Load Diff
@ -9,6 +9,7 @@ use domain::ports::{
|
|||||||
AgentSessionError, EmbedderError, FsError, GitError, MemoryError, ProcessError, PtyError,
|
AgentSessionError, EmbedderError, FsError, GitError, MemoryError, ProcessError, PtyError,
|
||||||
RemoteError, RuntimeError, StoreError,
|
RemoteError, RuntimeError, StoreError,
|
||||||
};
|
};
|
||||||
|
use domain::IssueStoreError;
|
||||||
use domain::{AgentId, NodeId};
|
use domain::{AgentId, NodeId};
|
||||||
|
|
||||||
/// Errors surfaced by application use cases.
|
/// Errors surfaced by application use cases.
|
||||||
@ -128,6 +129,17 @@ impl From<StoreError> for AppError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<IssueStoreError> 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<MemoryError> for AppError {
|
impl From<MemoryError> for AppError {
|
||||||
fn from(e: MemoryError) -> Self {
|
fn from(e: MemoryError) -> Self {
|
||||||
match e {
|
match e {
|
||||||
|
|||||||
774
crates/application/src/issues/mod.rs
Normal file
774
crates/application/src/issues/mod.rs
Normal file
@ -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<IssueLink>,
|
||||||
|
/// Initially assigned agents.
|
||||||
|
pub assigned_agent_ids: Vec<AgentId>,
|
||||||
|
/// 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<dyn IssueStore>,
|
||||||
|
allocator: Arc<dyn IssueNumberAllocator>,
|
||||||
|
contexts: Arc<dyn AgentContextStore>,
|
||||||
|
ids: Arc<dyn IdGenerator>,
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CreateIssue {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
issues: Arc<dyn IssueStore>,
|
||||||
|
allocator: Arc<dyn IssueNumberAllocator>,
|
||||||
|
contexts: Arc<dyn AgentContextStore>,
|
||||||
|
ids: Arc<dyn IdGenerator>,
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
) -> 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<CreateIssueOutput, AppError> {
|
||||||
|
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<dyn IssueStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReadIssue {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(issues: Arc<dyn IssueStore>) -> Self {
|
||||||
|
Self { issues }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes read.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`AppError`] on store failure.
|
||||||
|
pub async fn execute(&self, input: ReadIssueInput) -> Result<ReadIssueOutput, AppError> {
|
||||||
|
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<dyn IssueStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReadIssueCarnet {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(issues: Arc<dyn IssueStore>) -> Self {
|
||||||
|
Self { issues }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes carnet read.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`AppError`] on store failure.
|
||||||
|
pub async fn execute(
|
||||||
|
&self,
|
||||||
|
input: ReadIssueCarnetInput,
|
||||||
|
) -> Result<ReadIssueCarnetOutput, AppError> {
|
||||||
|
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<IssueIndexEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists issues.
|
||||||
|
pub struct ListIssues {
|
||||||
|
issues: Arc<dyn IssueStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListIssues {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(issues: Arc<dyn IssueStore>) -> Self {
|
||||||
|
Self { issues }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes list.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`AppError`] on store failure.
|
||||||
|
pub async fn execute(&self, input: ListIssuesInput) -> Result<ListIssuesOutput, AppError> {
|
||||||
|
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<String>,
|
||||||
|
/// Optional description replacement.
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// Optional status replacement.
|
||||||
|
pub status: Option<IssueStatus>,
|
||||||
|
/// Optional priority replacement.
|
||||||
|
pub priority: Option<IssuePriority>,
|
||||||
|
/// Optional full assigned-agent replacement.
|
||||||
|
pub assigned_agent_ids: Option<Vec<AgentId>>,
|
||||||
|
/// 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<dyn IssueStore>,
|
||||||
|
contexts: Arc<dyn AgentContextStore>,
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpdateIssue {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
issues: Arc<dyn IssueStore>,
|
||||||
|
contexts: Arc<dyn AgentContextStore>,
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
) -> 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<UpdateIssueOutput, AppError> {
|
||||||
|
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<dyn IssueStore>,
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpdateIssueCarnet {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
issues: Arc<dyn IssueStore>,
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
issues,
|
||||||
|
clock,
|
||||||
|
events,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes carnet update.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`AppError`] on store failure or conflict.
|
||||||
|
pub async fn execute(
|
||||||
|
&self,
|
||||||
|
input: UpdateIssueCarnetInput,
|
||||||
|
) -> Result<UpdateIssueCarnetOutput, AppError> {
|
||||||
|
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<IssueLinkKind>,
|
||||||
|
/// 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<dyn IssueStore>,
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LinkIssues {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
issues: Arc<dyn IssueStore>,
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
issues,
|
||||||
|
clock,
|
||||||
|
events,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes link creation.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`AppError`] on invariant or store failure.
|
||||||
|
pub async fn execute(&self, input: LinkIssuesInput) -> Result<LinkIssuesOutput, AppError> {
|
||||||
|
// 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<dyn IssueStore>,
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UnlinkIssues {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
issues: Arc<dyn IssueStore>,
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
issues,
|
||||||
|
clock,
|
||||||
|
events,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes link removal.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`AppError`] on store failure.
|
||||||
|
pub async fn execute(&self, input: UnlinkIssuesInput) -> Result<LinkIssuesOutput, AppError> {
|
||||||
|
let current = self
|
||||||
|
.issues
|
||||||
|
.get_by_ref(&input.project.root, input.issue_ref)
|
||||||
|
.await?;
|
||||||
|
let removed: Vec<IssueLinkKind> = 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<dyn IssueStore>,
|
||||||
|
contexts: Arc<dyn AgentContextStore>,
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AssignIssueAgent {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
issues: Arc<dyn IssueStore>,
|
||||||
|
contexts: Arc<dyn AgentContextStore>,
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
) -> 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<AssignIssueAgentOutput, AppError> {
|
||||||
|
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<dyn Clock>) -> u64 {
|
||||||
|
clock.now_millis().max(0) as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn validate_agents(
|
||||||
|
contexts: &Arc<dyn AgentContextStore>,
|
||||||
|
project: &Project,
|
||||||
|
agent_ids: &[AgentId],
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
if agent_ids.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let manifest = contexts.load_manifest(project).await?;
|
||||||
|
let known: HashSet<AgentId> = 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<AgentId> {
|
||||||
|
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<AgentId>) {
|
||||||
|
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<dyn EventBus>,
|
||||||
|
issue: &Issue,
|
||||||
|
old_status: IssueStatus,
|
||||||
|
old_priority: IssuePriority,
|
||||||
|
old_assigned: HashSet<AgentId>,
|
||||||
|
) {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -19,6 +19,7 @@ pub mod embedder;
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod git;
|
pub mod git;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
|
pub mod issues;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod memory;
|
pub mod memory;
|
||||||
pub mod orchestrator;
|
pub mod orchestrator;
|
||||||
@ -71,6 +72,14 @@ pub use git::{
|
|||||||
GitStatusInput, GitStatusOutput, GitUnstage,
|
GitStatusInput, GitStatusOutput, GitUnstage,
|
||||||
};
|
};
|
||||||
pub use health::{HealthInput, HealthReport, HealthUseCase};
|
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::{
|
pub use layout::{
|
||||||
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,
|
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,
|
||||||
DeleteLayoutOutput, LayoutInfo, LayoutKind, LayoutOperation, LayoutsDoc, ListLayouts,
|
DeleteLayoutOutput, LayoutInfo, LayoutKind, LayoutOperation, LayoutsDoc, ListLayouts,
|
||||||
|
|||||||
402
crates/application/tests/issue_usecases.rs
Normal file
402
crates/application/tests/issue_usecases.rs
Normal file
@ -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<HashMap<IssueRef, Issue>>,
|
||||||
|
conflict: Mutex<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Issue, IssueStoreError> {
|
||||||
|
self.issues
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&issue_ref)
|
||||||
|
.cloned()
|
||||||
|
.ok_or(IssueStoreError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(
|
||||||
|
&self,
|
||||||
|
_root: &ProjectPath,
|
||||||
|
_filter: IssueListFilter,
|
||||||
|
) -> Result<Vec<IssueIndexEntry>, 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<IssueCarnet, IssueStoreError> {
|
||||||
|
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<IssueCarnet, IssueStoreError> {
|
||||||
|
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<u64>);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl IssueNumberAllocator for SeqAllocator {
|
||||||
|
async fn allocate_next(&self, _root: &ProjectPath) -> Result<IssueNumber, IssueStoreError> {
|
||||||
|
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<MarkdownDoc, StoreError> {
|
||||||
|
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<AgentManifest, StoreError> {
|
||||||
|
Ok(self.0.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_manifest(
|
||||||
|
&self,
|
||||||
|
_project: &Project,
|
||||||
|
_manifest: &AgentManifest,
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct SpyBus(Mutex<Vec<DomainEvent>>);
|
||||||
|
|
||||||
|
impl SpyBus {
|
||||||
|
fn events(&self) -> Vec<DomainEvent> {
|
||||||
|
self.0.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventBus for SpyBus {
|
||||||
|
fn publish(&self, event: DomainEvent) {
|
||||||
|
self.0.lock().unwrap().push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subscribe(&self) -> EventStream {
|
||||||
|
Box::new(std::iter::empty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"));
|
||||||
|
}
|
||||||
@ -1,7 +1,8 @@
|
|||||||
//! Domain events published on the [`crate::ports::EventBus`] and relayed to the
|
//! Domain events published on the [`crate::ports::EventBus`] and relayed to the
|
||||||
//! presentation layer (ARCHITECTURE §3.2).
|
//! presentation layer (ARCHITECTURE §3.2).
|
||||||
|
|
||||||
use crate::ids::{AgentId, ProfileId, ProjectId, SessionId, SkillId, TaskId, TemplateId};
|
use crate::ids::{AgentId, IssueId, ProfileId, ProjectId, SessionId, SkillId, TaskId, TemplateId};
|
||||||
|
use crate::issue::{IssueLinkKind, IssuePriority, IssueRef, IssueStatus, IssueVersion};
|
||||||
use crate::mailbox::TicketId;
|
use crate::mailbox::TicketId;
|
||||||
use crate::memory::MemorySlug;
|
use crate::memory::MemorySlug;
|
||||||
use crate::template::TemplateVersion;
|
use crate::template::TemplateVersion;
|
||||||
@ -270,6 +271,85 @@ pub enum DomainEvent {
|
|||||||
/// The project whose index was rebuilt.
|
/// The project whose index was rebuilt.
|
||||||
project_id: ProjectId,
|
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
|
/// A project's memory grew past the recall budget while no embedder is
|
||||||
/// configured (strategy `none`): the first moment a semantic embedder would
|
/// configured (strategy `none`): the first moment a semantic embedder would
|
||||||
/// help (ARCHITECTURE §14.5.5, LOT C3). Published **at most once per session per
|
/// help (ARCHITECTURE §14.5.5, LOT C3). Published **at most once per session per
|
||||||
|
|||||||
@ -72,6 +72,10 @@ typed_id!(
|
|||||||
/// Identifies a [`crate::skill::Skill`].
|
/// Identifies a [`crate::skill::Skill`].
|
||||||
SkillId
|
SkillId
|
||||||
);
|
);
|
||||||
|
typed_id!(
|
||||||
|
/// Identifies a [`crate::issue::Issue`].
|
||||||
|
IssueId
|
||||||
|
);
|
||||||
typed_id!(
|
typed_id!(
|
||||||
/// Identifies a [`crate::terminal::TerminalSession`].
|
/// Identifies a [`crate::terminal::TerminalSession`].
|
||||||
SessionId
|
SessionId
|
||||||
|
|||||||
454
crates/domain/src/issue.rs
Normal file
454
crates/domain/src/issue.rs
Normal file
@ -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<Self, IssueError> {
|
||||||
|
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 `#<number>`.
|
||||||
|
#[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<IssueNumber> for IssueRef {
|
||||||
|
fn from(number: IssueNumber) -> Self {
|
||||||
|
Self::new(number)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for IssueRef {
|
||||||
|
type Err = IssueError;
|
||||||
|
|
||||||
|
fn from_str(raw: &str) -> Result<Self, Self::Err> {
|
||||||
|
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::<u64>()
|
||||||
|
.map_err(|_| IssueError::InvalidRef(raw.to_owned()))?;
|
||||||
|
Ok(Self(IssueNumber::new(number)?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for IssueRef {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: serde::Serializer,
|
||||||
|
{
|
||||||
|
serializer.serialize_str(&self.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for IssueRef {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
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<Self, IssueError> {
|
||||||
|
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<IssueLink>,
|
||||||
|
/// Agent references.
|
||||||
|
pub agent_refs: Vec<AgentIssueRef>,
|
||||||
|
/// 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<String>,
|
||||||
|
description: MarkdownDoc,
|
||||||
|
status: IssueStatus,
|
||||||
|
priority: IssuePriority,
|
||||||
|
carnet: MarkdownDoc,
|
||||||
|
links: Vec<IssueLink>,
|
||||||
|
agent_refs: Vec<AgentIssueRef>,
|
||||||
|
actor: IssueActor,
|
||||||
|
now_ms: u64,
|
||||||
|
) -> Result<Self, IssueError> {
|
||||||
|
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<Self, IssueError> {
|
||||||
|
issue.validate()?;
|
||||||
|
Ok(issue)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns this issue as `#<number>`.
|
||||||
|
#[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<Self, IssueError> {
|
||||||
|
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<AgentId>,
|
||||||
|
/// 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<IssueStatus>,
|
||||||
|
/// Optional priority filter.
|
||||||
|
pub priority: Option<IssuePriority>,
|
||||||
|
/// Optional assigned agent filter.
|
||||||
|
pub assigned_agent_id: Option<AgentId>,
|
||||||
|
/// Optional case-insensitive text query.
|
||||||
|
pub text: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 `#<positive integer>`.
|
||||||
|
#[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,
|
||||||
|
},
|
||||||
|
}
|
||||||
@ -41,6 +41,7 @@ pub mod git;
|
|||||||
pub mod ids;
|
pub mod ids;
|
||||||
pub mod inbox;
|
pub mod inbox;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
|
pub mod issue;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod live_state;
|
pub mod live_state;
|
||||||
pub mod mailbox;
|
pub mod mailbox;
|
||||||
@ -69,8 +70,8 @@ mod validation;
|
|||||||
pub use error::DomainError;
|
pub use error::DomainError;
|
||||||
|
|
||||||
pub use ids::{
|
pub use ids::{
|
||||||
AgentId, LayoutId, NodeId, ProfileId, ProjectId, ScheduleId, SessionId, SkillId, TabId, TaskId,
|
AgentId, IssueId, LayoutId, NodeId, ProfileId, ProjectId, ScheduleId, SessionId, SkillId,
|
||||||
TemplateId, WindowId,
|
TabId, TaskId, TemplateId, WindowId,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use project::{Project, ProjectPath};
|
pub use project::{Project, ProjectPath};
|
||||||
@ -109,6 +110,12 @@ pub use inbox::{
|
|||||||
InboxReceiptStatus, InboxSource, DEFAULT_AGENT_INBOX_CAPACITY,
|
InboxReceiptStatus, InboxSource, DEFAULT_AGENT_INBOX_CAPACITY,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 live_state::{LiveEntry, LiveState, WorkStatus, FIELD_MAX_BYTES, FIELD_PREVIEW_MAX_CHARS};
|
||||||
|
|
||||||
pub use readiness::{ReadinessPolicy, ReadinessSignal};
|
pub use readiness::{ReadinessPolicy, ReadinessSignal};
|
||||||
@ -167,13 +174,14 @@ pub use orchestrator::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
pub use ports::{
|
pub use ports::{
|
||||||
AgentContextStore, AgentRuntime, BackgroundCompletionStream,
|
AgentContextStore, AgentRuntime, BackgroundCompletionStream, BackgroundTaskCompletion,
|
||||||
BackgroundTaskCompletion, BackgroundTaskHandle, BackgroundTaskPortError, BackgroundTaskRunner,
|
BackgroundTaskHandle, BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskSpec,
|
||||||
BackgroundTaskSpec, BackgroundTaskStore, Clock, ContextInjectionPlan, DirEntry, Embedder, EmbedderEnvInspector,
|
BackgroundTaskStore, Clock, ContextInjectionPlan, DirEntry, Embedder, EmbedderEnvInspector,
|
||||||
EmbedderEnvReport, EmbedderError, EmbedderProfileStore, EmbedderPromptDismissal,
|
EmbedderEnvReport, EmbedderError, EmbedderProfileStore, EmbedderPromptDismissal,
|
||||||
EmbedderPromptStore, EventBus, EventStream, ExitStatus, FileSystem, FsError, GitCommitInfo,
|
EmbedderPromptStore, EventBus, EventStream, ExitStatus, FileSystem, FsError, GitCommitInfo,
|
||||||
GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator, LiveStateStore, MemoryError,
|
GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator, IssueNumberAllocator, IssueStore,
|
||||||
MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream, PermissionStore, PreparedContext,
|
IssueStoreError, LiveStateStore, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output,
|
||||||
|
OutputStream, PermissionStore, PreparedContext,
|
||||||
ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort,
|
ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort,
|
||||||
RemoteError, RemoteHost, RemotePath, RuntimeError, ScheduledTask, Scheduler, SpawnSpec,
|
RemoteError, RemoteHost, RemotePath, RuntimeError, ScheduledTask, Scheduler, SpawnSpec,
|
||||||
StoreError, TemplateStore,
|
StoreError, TemplateStore,
|
||||||
|
|||||||
@ -33,6 +33,9 @@ use crate::background_task::{
|
|||||||
};
|
};
|
||||||
use crate::events::DomainEvent;
|
use crate::events::DomainEvent;
|
||||||
use crate::ids::{AgentId, NodeId, ProjectId, ScheduleId, SessionId, TaskId};
|
use crate::ids::{AgentId, NodeId, ProjectId, ScheduleId, SessionId, TaskId};
|
||||||
|
use crate::issue::{
|
||||||
|
Issue, IssueCarnet, IssueIndexEntry, IssueListFilter, IssueNumber, IssueRef, IssueVersion,
|
||||||
|
};
|
||||||
use crate::markdown::MarkdownDoc;
|
use crate::markdown::MarkdownDoc;
|
||||||
use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
|
use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
|
||||||
use crate::permission::ProjectPermissions;
|
use crate::permission::ProjectPermissions;
|
||||||
@ -566,6 +569,27 @@ pub enum WakeError {
|
|||||||
Task(String),
|
Task(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
|
// Git port support types
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -1385,6 +1409,84 @@ pub trait AgentWakePort: Send + Sync {
|
|||||||
) -> Result<(), WakeError>;
|
) -> Result<(), WakeError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<IssueNumber, IssueStoreError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<Issue, IssueStoreError>;
|
||||||
|
|
||||||
|
/// Lists issue index entries matching `filter`.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`IssueStoreError`] on persistence failure.
|
||||||
|
async fn list(
|
||||||
|
&self,
|
||||||
|
root: &ProjectPath,
|
||||||
|
filter: IssueListFilter,
|
||||||
|
) -> Result<Vec<IssueIndexEntry>, 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<IssueCarnet, IssueStoreError>;
|
||||||
|
|
||||||
|
/// 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<IssueCarnet, IssueStoreError>;
|
||||||
|
}
|
||||||
|
|
||||||
/// Git operations for a project. Named `GitPort` to avoid clashing with the
|
/// Git operations for a project. Named `GitPort` to avoid clashing with the
|
||||||
/// [`crate::git::GitRepository`] *entity* (state image).
|
/// [`crate::git::GitRepository`] *entity* (state image).
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|||||||
120
crates/domain/tests/issue.rs
Normal file
120
crates/domain/tests/issue.rs
Normal file
@ -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);
|
||||||
|
}
|
||||||
576
crates/infrastructure/src/issues.rs
Normal file
576
crates/infrastructure/src/issues.rs
Normal file
@ -0,0 +1,576 @@
|
|||||||
|
//! Filesystem issue store.
|
||||||
|
//!
|
||||||
|
//! Project-scoped issues live under `<root>/.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<IssueIndexEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String, IssueStoreError> {
|
||||||
|
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<Issue, IssueStoreError> {
|
||||||
|
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<Vec<IssueIndexEntry>, 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::<u64>() 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<Vec<IssueIndexEntry>, 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<Issue, IssueStoreError> {
|
||||||
|
load_issue(root, issue_ref).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(
|
||||||
|
&self,
|
||||||
|
root: &ProjectPath,
|
||||||
|
filter: IssueListFilter,
|
||||||
|
) -> Result<Vec<IssueIndexEntry>, 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<IssueCarnet, IssueStoreError> {
|
||||||
|
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<IssueCarnet, IssueStoreError> {
|
||||||
|
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<IssueNumber, IssueStoreError> {
|
||||||
|
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::<CounterDoc>(&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<Issue, IssueStoreError> {
|
||||||
|
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<IssueLink> = parse_json_field(&map, "links")?;
|
||||||
|
let agent_refs: Vec<AgentIssueRef> = 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<IssueCarnet, IssueStoreError> {
|
||||||
|
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<BTreeMap<String, String>, 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<String, String>, key: &str) -> Result<u64, IssueStoreError> {
|
||||||
|
map.get(key)
|
||||||
|
.ok_or_else(|| IssueStoreError::Invalid(format!("missing frontmatter key `{key}`")))?
|
||||||
|
.parse::<u64>()
|
||||||
|
.map_err(|err| IssueStoreError::Invalid(err.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_json_field<T: serde::de::DeserializeOwned>(
|
||||||
|
map: &BTreeMap<String, String>,
|
||||||
|
key: &str,
|
||||||
|
) -> Result<T, IssueStoreError> {
|
||||||
|
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<T: Serialize>(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")
|
||||||
|
}
|
||||||
@ -23,6 +23,7 @@ pub mod git;
|
|||||||
pub mod id;
|
pub mod id;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
pub mod inspector;
|
pub mod inspector;
|
||||||
|
pub mod issues;
|
||||||
pub mod mailbox;
|
pub mod mailbox;
|
||||||
pub mod orchestrator;
|
pub mod orchestrator;
|
||||||
pub mod permission;
|
pub mod permission;
|
||||||
@ -57,8 +58,11 @@ pub use input::{MediatedInbox, MillisClock, SystemMillisClock};
|
|||||||
pub use inspector::{
|
pub use inspector::{
|
||||||
transcript_activity_token, ClaudeTranscriptInspector, ClaudeTranscriptTurnWatcher,
|
transcript_activity_token, ClaudeTranscriptInspector, ClaudeTranscriptTurnWatcher,
|
||||||
};
|
};
|
||||||
|
pub use issues::{FsIssueNumberAllocator, FsIssueStore};
|
||||||
pub use mailbox::InMemoryMailbox;
|
pub use mailbox::InMemoryMailbox;
|
||||||
pub use orchestrator::mcp::{McpServer, MemoryTransport, StdioTransport};
|
pub use orchestrator::mcp::{
|
||||||
|
McpServer, MemoryTransport, StdioTransport, TicketToolError, TicketToolProvider,
|
||||||
|
};
|
||||||
pub use orchestrator::{
|
pub use orchestrator::{
|
||||||
process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle,
|
process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle,
|
||||||
REQUESTS_SUBDIR,
|
REQUESTS_SUBDIR,
|
||||||
|
|||||||
@ -30,6 +30,7 @@
|
|||||||
|
|
||||||
pub mod jsonrpc;
|
pub mod jsonrpc;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
|
pub mod tickets;
|
||||||
pub mod tools;
|
pub mod tools;
|
||||||
pub mod transport;
|
pub mod transport;
|
||||||
|
|
||||||
@ -37,5 +38,6 @@ pub use jsonrpc::{
|
|||||||
JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, JSONRPC_VERSION,
|
JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, JSONRPC_VERSION,
|
||||||
};
|
};
|
||||||
pub use server::McpServer;
|
pub use server::McpServer;
|
||||||
|
pub use tickets::{TicketToolError, TicketToolProvider};
|
||||||
pub use tools::{catalogue, map_tool_call, tool_returns_reply, ToolDef, ToolMapError};
|
pub use tools::{catalogue, map_tool_call, tool_returns_reply, ToolDef, ToolMapError};
|
||||||
pub use transport::{MemoryTransport, StdioTransport};
|
pub use transport::{MemoryTransport, StdioTransport};
|
||||||
|
|||||||
@ -28,6 +28,7 @@ use super::jsonrpc::{
|
|||||||
error_codes, JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError,
|
error_codes, JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError,
|
||||||
JSONRPC_VERSION,
|
JSONRPC_VERSION,
|
||||||
};
|
};
|
||||||
|
use super::tickets::TicketToolProvider;
|
||||||
use super::tools::{self, ToolMapError};
|
use super::tools::{self, ToolMapError};
|
||||||
|
|
||||||
/// The MCP protocol version this server speaks (advertised on `initialize`).
|
/// 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
|
/// (fix race cold-launch, signal MCP). L'infra ne connaît pas `AgentId` : la
|
||||||
/// composition root parse le `requester`. `None` ⇒ no-op.
|
/// composition root parse le `requester`. `None` ⇒ no-op.
|
||||||
ready_sink: Option<Arc<dyn Fn(&str) + Send + Sync>>,
|
ready_sink: Option<Arc<dyn Fn(&str) + Send + Sync>>,
|
||||||
|
/// Optional public ticket provider. The MCP surface says `ticket`; the
|
||||||
|
/// provider maps those calls to application/domain `Issue` use cases.
|
||||||
|
ticket_tools: Option<Arc<dyn TicketToolProvider>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl McpServer {
|
impl McpServer {
|
||||||
@ -75,6 +79,7 @@ impl McpServer {
|
|||||||
events: None,
|
events: None,
|
||||||
requester: String::new(),
|
requester: String::new(),
|
||||||
ready_sink: None,
|
ready_sink: None,
|
||||||
|
ticket_tools: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,6 +104,13 @@ impl McpServer {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attaches the public ticket tool provider.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_ticket_tools(mut self, ticket_tools: Arc<dyn TicketToolProvider>) -> Self {
|
||||||
|
self.ticket_tools = Some(ticket_tools);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns a per-connection clone of this server tagged with the connected
|
/// 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).
|
/// peer's `requester` id (the loopback handshake's `requester`, cadrage v5 §1.4).
|
||||||
///
|
///
|
||||||
@ -115,6 +127,7 @@ impl McpServer {
|
|||||||
events: self.events.clone(),
|
events: self.events.clone(),
|
||||||
requester: requester.into(),
|
requester: requester.into(),
|
||||||
ready_sink: self.ready_sink.clone(),
|
ready_sink: self.ready_sink.clone(),
|
||||||
|
ticket_tools: self.ticket_tools.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -333,6 +346,44 @@ impl McpServer {
|
|||||||
task_len={task_len}",
|
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
|
// The handshake-provided requester is still passed to the mapper for tools that
|
||||||
// need peer identity.
|
// need peer identity.
|
||||||
let command = match tools::map_tool_call(&name, &arguments, &self.requester) {
|
let command = match tools::map_tool_call(&name, &arguments, &self.requester) {
|
||||||
|
|||||||
238
crates/infrastructure/src/orchestrator/mcp/tickets.rs
Normal file
238
crates/infrastructure/src/orchestrator/mcp/tickets.rs
Normal file
@ -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<String>) -> 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<Value, TicketToolError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<ToolDef> {
|
||||||
|
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
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -61,7 +61,13 @@ pub fn tool_returns_reply(tool: &str) -> bool {
|
|||||||
| "idea_memory_read"
|
| "idea_memory_read"
|
||||||
| "idea_skill_read"
|
| "idea_skill_read"
|
||||||
| "idea_workstate_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`.
|
/// The full catalogue advertised on `tools/list`.
|
||||||
@ -70,7 +76,7 @@ pub fn tool_returns_reply(tool: &str) -> bool {
|
|||||||
/// [`OrchestratorCommand`].
|
/// [`OrchestratorCommand`].
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn catalogue() -> Vec<ToolDef> {
|
pub fn catalogue() -> Vec<ToolDef> {
|
||||||
vec![
|
let mut tools = vec![
|
||||||
ToolDef {
|
ToolDef {
|
||||||
name: "idea_list_agents",
|
name: "idea_list_agents",
|
||||||
description: "List the IdeA agents declared in the project's manifest. Returns the \
|
description: "List the IdeA agents declared in the project's manifest. Returns the \
|
||||||
@ -255,7 +261,9 @@ pub fn catalogue() -> Vec<ToolDef> {
|
|||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
tools.extend(super::tickets::catalogue());
|
||||||
|
tools
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maps an MCP `tools/call` (`name` + `arguments`) to a validated
|
/// Maps an MCP `tools/call` (`name` + `arguments`) to a validated
|
||||||
|
|||||||
163
crates/infrastructure/tests/issue_store.rs
Normal file
163
crates/infrastructure/tests/issue_store.rs
Normal file
@ -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);
|
||||||
|
}
|
||||||
@ -500,6 +500,17 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
|
|||||||
// Live-state tools (programme live-state, lot LS4).
|
// Live-state tools (programme live-state, lot LS4).
|
||||||
"idea_workstate_read",
|
"idea_workstate_read",
|
||||||
"idea_workstate_set",
|
"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!(
|
assert!(
|
||||||
names.contains(&expected),
|
names.contains(&expected),
|
||||||
@ -509,8 +520,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
|
|||||||
assert!(!names.contains(&"idea_reply"));
|
assert!(!names.contains(&"idea_reply"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tools.len(),
|
tools.len(),
|
||||||
13,
|
23,
|
||||||
"exactly the thirteen exposed idea_* tools; got {names:?}"
|
"exactly the twenty-three exposed idea_* tools; got {names:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Every tool advertises an object input schema.
|
// Every tool advertises an object input schema.
|
||||||
|
|||||||
@ -27,6 +27,7 @@ import { TauriGitGateway } from "./git";
|
|||||||
import { TauriPermissionGateway } from "./permission";
|
import { TauriPermissionGateway } from "./permission";
|
||||||
import { TauriWorkStateGateway } from "./workState";
|
import { TauriWorkStateGateway } from "./workState";
|
||||||
import { TauriConversationGateway } from "./conversation";
|
import { TauriConversationGateway } from "./conversation";
|
||||||
|
import { TauriTicketGateway } from "./ticket";
|
||||||
|
|
||||||
function notImplemented(what: string): never {
|
function notImplemented(what: string): never {
|
||||||
const err: GatewayError = {
|
const err: GatewayError = {
|
||||||
@ -61,6 +62,7 @@ export function createTauriGateways(): Gateways {
|
|||||||
permission: new TauriPermissionGateway(),
|
permission: new TauriPermissionGateway(),
|
||||||
workState: new TauriWorkStateGateway(),
|
workState: new TauriWorkStateGateway(),
|
||||||
conversation: new TauriConversationGateway(),
|
conversation: new TauriConversationGateway(),
|
||||||
|
ticket: new TauriTicketGateway(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,4 +82,5 @@ export {
|
|||||||
TauriPermissionGateway,
|
TauriPermissionGateway,
|
||||||
TauriWorkStateGateway,
|
TauriWorkStateGateway,
|
||||||
TauriConversationGateway,
|
TauriConversationGateway,
|
||||||
|
TauriTicketGateway,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -38,6 +38,11 @@ import type {
|
|||||||
SkillScope,
|
SkillScope,
|
||||||
Template,
|
Template,
|
||||||
TerminalSession,
|
TerminalSession,
|
||||||
|
Ticket,
|
||||||
|
TicketCarnet,
|
||||||
|
TicketLink,
|
||||||
|
TicketLinkKind,
|
||||||
|
TicketList,
|
||||||
TurnPage,
|
TurnPage,
|
||||||
TurnView,
|
TurnView,
|
||||||
Unsubscribe,
|
Unsubscribe,
|
||||||
@ -70,6 +75,10 @@ import type {
|
|||||||
TemplateGateway,
|
TemplateGateway,
|
||||||
TerminalGateway,
|
TerminalGateway,
|
||||||
TerminalHandle,
|
TerminalHandle,
|
||||||
|
TicketGateway,
|
||||||
|
TicketListQuery,
|
||||||
|
CreateTicketInput,
|
||||||
|
UpdateTicketInput,
|
||||||
WorkStateGateway,
|
WorkStateGateway,
|
||||||
} from "@/ports";
|
} from "@/ports";
|
||||||
import { normalizeProjectWorkState } from "../workStateNormalization";
|
import { normalizeProjectWorkState } from "../workStateNormalization";
|
||||||
@ -1791,6 +1800,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<string, Map<string, Ticket>>();
|
||||||
|
private carnets = new Map<string, Map<string, string>>();
|
||||||
|
private counters = new Map<string, number>();
|
||||||
|
|
||||||
|
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<string, Ticket> {
|
||||||
|
let map = this.tickets.get(projectId);
|
||||||
|
if (!map) {
|
||||||
|
map = new Map();
|
||||||
|
this.tickets.set(projectId, map);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private projectCarnets(projectId: string): Map<string, string> {
|
||||||
|
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<Ticket> {
|
||||||
|
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<Ticket> {
|
||||||
|
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<TicketList> {
|
||||||
|
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<Ticket> {
|
||||||
|
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<TicketCarnet> {
|
||||||
|
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<Ticket> {
|
||||||
|
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<Ticket> {
|
||||||
|
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<Ticket> {
|
||||||
|
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<Ticket> {
|
||||||
|
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(
|
function mostRestrictive(
|
||||||
project?: PermissionSet["fallback"],
|
project?: PermissionSet["fallback"],
|
||||||
agent?: PermissionSet["fallback"],
|
agent?: PermissionSet["fallback"],
|
||||||
@ -1804,8 +2081,9 @@ function mostRestrictive(
|
|||||||
/** Builds the full set of mock gateways. */
|
/** Builds the full set of mock gateways. */
|
||||||
export function createMockGateways(): Gateways {
|
export function createMockGateways(): Gateways {
|
||||||
const agentGateway = new MockAgentGateway();
|
const agentGateway = new MockAgentGateway();
|
||||||
|
const systemGateway = new MockSystemGateway();
|
||||||
return {
|
return {
|
||||||
system: new MockSystemGateway(),
|
system: systemGateway,
|
||||||
agent: agentGateway,
|
agent: agentGateway,
|
||||||
input: new MockInputGateway(),
|
input: new MockInputGateway(),
|
||||||
terminal: new MockTerminalGateway(),
|
terminal: new MockTerminalGateway(),
|
||||||
@ -1821,6 +2099,7 @@ export function createMockGateways(): Gateways {
|
|||||||
permission: new MockPermissionGateway(),
|
permission: new MockPermissionGateway(),
|
||||||
workState: new MockWorkStateGateway(),
|
workState: new MockWorkStateGateway(),
|
||||||
conversation: new MockConversationGateway(),
|
conversation: new MockConversationGateway(),
|
||||||
|
ticket: new MockTicketGateway(systemGateway),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -29,6 +29,7 @@ describe("createMockGateways", () => {
|
|||||||
"system",
|
"system",
|
||||||
"template",
|
"template",
|
||||||
"terminal",
|
"terminal",
|
||||||
|
"ticket",
|
||||||
"workState",
|
"workState",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
125
frontend/src/adapters/ticket.ts
Normal file
125
frontend/src/adapters/ticket.ts
Normal file
@ -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<Ticket> {
|
||||||
|
return invoke<Ticket>("ticket_create", {
|
||||||
|
request: { projectId, ...input },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async read(
|
||||||
|
projectId: string,
|
||||||
|
ref: string,
|
||||||
|
includeCarnet = false,
|
||||||
|
): Promise<Ticket> {
|
||||||
|
return invoke<Ticket>("ticket_read", {
|
||||||
|
request: { projectId, ref, includeCarnet },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(projectId: string, query?: TicketListQuery): Promise<TicketList> {
|
||||||
|
return invoke<TicketList>("ticket_list", {
|
||||||
|
request: { projectId, ...(query ?? {}) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
projectId: string,
|
||||||
|
ref: string,
|
||||||
|
input: UpdateTicketInput,
|
||||||
|
): Promise<Ticket> {
|
||||||
|
return invoke<Ticket>("ticket_update", {
|
||||||
|
request: { projectId, ref, ...input },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async readCarnet(projectId: string, ref: string): Promise<TicketCarnet> {
|
||||||
|
return invoke<TicketCarnet>("ticket_read_carnet", {
|
||||||
|
request: { projectId, ref },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCarnet(
|
||||||
|
projectId: string,
|
||||||
|
ref: string,
|
||||||
|
carnet: string,
|
||||||
|
expectedVersion: number,
|
||||||
|
): Promise<Ticket> {
|
||||||
|
return invoke<Ticket>("ticket_update_carnet", {
|
||||||
|
request: { projectId, ref, carnet, expectedVersion },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async link(
|
||||||
|
projectId: string,
|
||||||
|
ref: string,
|
||||||
|
targetRef: string,
|
||||||
|
kind: TicketLinkKind,
|
||||||
|
expectedVersion: number,
|
||||||
|
): Promise<Ticket> {
|
||||||
|
return invoke<Ticket>("ticket_link", {
|
||||||
|
request: { projectId, ref, targetRef, kind, expectedVersion },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async unlink(
|
||||||
|
projectId: string,
|
||||||
|
ref: string,
|
||||||
|
targetRef: string,
|
||||||
|
expectedVersion: number,
|
||||||
|
kind?: TicketLinkKind,
|
||||||
|
): Promise<Ticket> {
|
||||||
|
return invoke<Ticket>("ticket_unlink", {
|
||||||
|
request: { projectId, ref, targetRef, expectedVersion, kind },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async assign(
|
||||||
|
projectId: string,
|
||||||
|
ref: string,
|
||||||
|
agentId: string,
|
||||||
|
assigned: boolean,
|
||||||
|
expectedVersion: number,
|
||||||
|
): Promise<Ticket> {
|
||||||
|
return invoke<Ticket>("ticket_assign", {
|
||||||
|
request: { projectId, ref, agentId, assigned, expectedVersion },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -124,8 +124,60 @@ export type DomainEvent =
|
|||||||
*/
|
*/
|
||||||
source?: "mcp" | "file";
|
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[] };
|
| { 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<DomainEvent, { type: `issue${string}` }> {
|
||||||
|
return event.type.startsWith("issue");
|
||||||
|
}
|
||||||
|
|
||||||
/** Where a project physically lives (mirror of the backend `RemoteRef`). */
|
/** Where a project physically lives (mirror of the backend `RemoteRef`). */
|
||||||
export type RemoteRef =
|
export type RemoteRef =
|
||||||
| { kind: "local" }
|
| { kind: "local" }
|
||||||
@ -757,3 +809,84 @@ export interface GitBranches {
|
|||||||
branches: string[];
|
branches: string[];
|
||||||
current: string | null;
|
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 `#<n>` (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;
|
||||||
|
}
|
||||||
|
|||||||
@ -39,6 +39,7 @@ import { MemoryPanel } from "@/features/memory";
|
|||||||
import { EmbedderSettings } from "@/features/embedder";
|
import { EmbedderSettings } from "@/features/embedder";
|
||||||
import { PermissionsPanel } from "@/features/permissions";
|
import { PermissionsPanel } from "@/features/permissions";
|
||||||
import { ProjectWorkStatePanel } from "@/features/workstate";
|
import { ProjectWorkStatePanel } from "@/features/workstate";
|
||||||
|
import { TicketsView } from "@/features/tickets";
|
||||||
import { ConversationViewer } from "@/features/conversations";
|
import { ConversationViewer } from "@/features/conversations";
|
||||||
import { GitPanel, GitGraphView } from "@/features/git";
|
import { GitPanel, GitGraphView } from "@/features/git";
|
||||||
import { Button, Input, Panel, Tabs, cn } from "@/shared";
|
import { Button, Input, Panel, Tabs, cn } from "@/shared";
|
||||||
@ -50,6 +51,7 @@ type SidebarTab =
|
|||||||
| "projects"
|
| "projects"
|
||||||
| "context"
|
| "context"
|
||||||
| "work"
|
| "work"
|
||||||
|
| "tickets"
|
||||||
| "agents"
|
| "agents"
|
||||||
| "templates"
|
| "templates"
|
||||||
| "skills"
|
| "skills"
|
||||||
@ -61,6 +63,7 @@ const SIDEBAR_TABS: { id: SidebarTab; label: string }[] = [
|
|||||||
{ id: "projects", label: "Projects" },
|
{ id: "projects", label: "Projects" },
|
||||||
{ id: "context", label: "Context" },
|
{ id: "context", label: "Context" },
|
||||||
{ id: "work", label: "Work" },
|
{ id: "work", label: "Work" },
|
||||||
|
{ id: "tickets", label: "Tickets" },
|
||||||
{ id: "agents", label: "Agents" },
|
{ id: "agents", label: "Agents" },
|
||||||
{ id: "templates", label: "Templates" },
|
{ id: "templates", label: "Templates" },
|
||||||
{ id: "skills", label: "Skills" },
|
{ id: "skills", label: "Skills" },
|
||||||
@ -351,6 +354,14 @@ export function ProjectsView() {
|
|||||||
<p className="text-sm text-muted">Open a project to view work state.</p>
|
<p className="text-sm text-muted">Open a project to view work state.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Tickets panel */}
|
||||||
|
{sidebarTab === "tickets" && active && (
|
||||||
|
<TicketsView projectId={active.id} />
|
||||||
|
)}
|
||||||
|
{sidebarTab === "tickets" && !active && (
|
||||||
|
<p className="text-sm text-muted">Open a project to view tickets.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Agents panel — only rendered when active project exists */}
|
{/* Agents panel — only rendered when active project exists */}
|
||||||
{sidebarTab === "agents" && active && (
|
{sidebarTab === "agents" && active && (
|
||||||
<AgentsPanel projectId={active.id} projectRoot={active.root} />
|
<AgentsPanel projectId={active.id} projectRoot={active.root} />
|
||||||
|
|||||||
450
frontend/src/features/tickets/TicketDetail.tsx
Normal file
450
frontend/src/features/tickets/TicketDetail.tsx
Normal file
@ -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<boolean> {
|
||||||
|
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 (
|
||||||
|
<section className="flex flex-col gap-2 border-b border-border px-5 py-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted">
|
||||||
|
{title}
|
||||||
|
</h4>
|
||||||
|
{hint && <p className="mt-0.5 text-xs text-muted">{hint}</p>}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<TicketLinkKind>("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 (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex flex-col bg-canvas"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={`ticket ${ticketRef}`}
|
||||||
|
>
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-5 py-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<TicketRef ticketRef={ticketRef} />
|
||||||
|
{t && <StatusBadge status={t.status} />}
|
||||||
|
{t && <PriorityBadge priority={t.priority} />}
|
||||||
|
<span className="truncate text-sm font-medium text-content">
|
||||||
|
{t?.title ?? ticketRef}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={!t}
|
||||||
|
onClick={() => void copyDelegation()}
|
||||||
|
title="Copy a delegation prompt to hand this ticket to an agent"
|
||||||
|
>
|
||||||
|
{copied ? "Copied" : "Delegation context"}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{vm.conflict && (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="shrink-0 border-b border-warning/40 bg-warning/10 px-5 py-2 text-sm text-warning"
|
||||||
|
>
|
||||||
|
This ticket was modified elsewhere and reloaded. Re-apply your change.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{vm.error && !vm.conflict && (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="shrink-0 border-b border-danger/40 bg-danger/10 px-5 py-2 text-sm text-danger"
|
||||||
|
>
|
||||||
|
{vm.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{vm.busy && !t ? (
|
||||||
|
<div className="flex flex-1 items-center gap-2 p-5 text-sm text-muted">
|
||||||
|
<Spinner size={14} />
|
||||||
|
<span>Loading ticket…</span>
|
||||||
|
</div>
|
||||||
|
) : !t ? (
|
||||||
|
<div className="flex flex-1 items-center justify-center text-sm text-muted">
|
||||||
|
Ticket not found.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{/* ── Fields (F3) ── */}
|
||||||
|
<Section title="Details">
|
||||||
|
<label className="text-xs font-medium text-muted" htmlFor="td-title">
|
||||||
|
Title
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="td-title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
disabled={vm.busy}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
className="mt-2 text-xs font-medium text-muted"
|
||||||
|
htmlFor="td-desc"
|
||||||
|
>
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="td-desc"
|
||||||
|
className={cn(textareaClass, "min-h-24")}
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
disabled={vm.busy}
|
||||||
|
/>
|
||||||
|
{description.trim() && (
|
||||||
|
<p className="whitespace-pre-wrap break-words rounded-md bg-raised/40 px-3 py-2 text-xs text-muted">
|
||||||
|
{linkifyTicketRefs(description, onOpenRef)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!dirtyFields || vm.busy}
|
||||||
|
loading={vm.busy && dirtyFields}
|
||||||
|
onClick={() =>
|
||||||
|
void vm.updateFields({ title, description })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Save changes
|
||||||
|
</Button>
|
||||||
|
{dirtyFields && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={vm.busy}
|
||||||
|
onClick={() => {
|
||||||
|
setTitle(t.title);
|
||||||
|
setDescription(t.description);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ── Status & priority (F3) ── */}
|
||||||
|
<Section title="Status & priority">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<label className="flex items-center gap-2 text-xs text-muted">
|
||||||
|
Status
|
||||||
|
<select
|
||||||
|
aria-label="ticket status"
|
||||||
|
className={selectClass}
|
||||||
|
value={t.status}
|
||||||
|
disabled={vm.busy}
|
||||||
|
onChange={(e) =>
|
||||||
|
void vm.updateFields({
|
||||||
|
status: e.target.value as (typeof TICKET_STATUSES)[number],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{TICKET_STATUSES.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{statusLabel(s)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-muted">
|
||||||
|
Priority
|
||||||
|
<select
|
||||||
|
aria-label="ticket priority"
|
||||||
|
className={selectClass}
|
||||||
|
value={t.priority}
|
||||||
|
disabled={vm.busy}
|
||||||
|
onChange={(e) =>
|
||||||
|
void vm.updateFields({
|
||||||
|
priority:
|
||||||
|
e.target.value as (typeof TICKET_PRIORITIES)[number],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{TICKET_PRIORITIES.map((p) => (
|
||||||
|
<option key={p} value={p}>
|
||||||
|
{priorityLabel(p)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ── Carnet (F4) ── */}
|
||||||
|
<Section
|
||||||
|
title="Carnet"
|
||||||
|
hint="Markdown notebook scoped to this ticket only — separate from project memory. Saving replaces the whole carnet."
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
aria-label="ticket carnet"
|
||||||
|
className={cn(textareaClass, "min-h-40 font-mono")}
|
||||||
|
value={carnet}
|
||||||
|
onChange={(e) => setCarnet(e.target.value)}
|
||||||
|
disabled={vm.busy}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!dirtyCarnet || vm.busy}
|
||||||
|
loading={vm.busy && dirtyCarnet}
|
||||||
|
onClick={() => void vm.saveCarnet(carnet)}
|
||||||
|
>
|
||||||
|
Save carnet
|
||||||
|
</Button>
|
||||||
|
{dirtyCarnet && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={vm.busy}
|
||||||
|
onClick={() => setCarnet(t.carnet ?? "")}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ── Links (F5) ── */}
|
||||||
|
<Section title="Linked tickets">
|
||||||
|
{t.links.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted">No linked tickets.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{t.links.map((l) => (
|
||||||
|
<li
|
||||||
|
key={`${l.kind}-${l.targetRef}`}
|
||||||
|
className="flex items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<span className="text-xs text-muted">
|
||||||
|
{linkKindLabel(l.kind)}
|
||||||
|
</span>
|
||||||
|
<TicketRef ticketRef={l.targetRef} onOpen={onOpenRef} />
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={vm.busy}
|
||||||
|
onClick={() => void vm.unlink(l.targetRef, l.kind)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||||
|
<select
|
||||||
|
aria-label="link kind"
|
||||||
|
className={selectClass}
|
||||||
|
value={linkKind}
|
||||||
|
disabled={vm.busy}
|
||||||
|
onChange={(e) => setLinkKind(e.target.value as TicketLinkKind)}
|
||||||
|
>
|
||||||
|
{TICKET_LINK_KINDS.map((k) => (
|
||||||
|
<option key={k} value={k}>
|
||||||
|
{linkKindLabel(k)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Input
|
||||||
|
aria-label="link target ref"
|
||||||
|
placeholder="#id"
|
||||||
|
className="w-24"
|
||||||
|
value={linkTarget}
|
||||||
|
onChange={(e) => setLinkTarget(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!linkTarget.trim() || vm.busy}
|
||||||
|
onClick={async () => {
|
||||||
|
const target = linkTarget.trim().startsWith("#")
|
||||||
|
? linkTarget.trim()
|
||||||
|
: `#${linkTarget.trim()}`;
|
||||||
|
const ok = await vm.link(target, linkKind);
|
||||||
|
if (ok) setLinkTarget("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add link
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ── Assignments (F5) ── */}
|
||||||
|
<Section
|
||||||
|
title="Assigned agents"
|
||||||
|
hint="Only agents known to this project can be assigned."
|
||||||
|
>
|
||||||
|
{t.assignedAgentIds.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted">No agents assigned.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-wrap gap-2">
|
||||||
|
{t.assignedAgentIds.map((id) => (
|
||||||
|
<li
|
||||||
|
key={id}
|
||||||
|
className="flex items-center gap-1 rounded-full bg-raised px-2 py-0.5 text-xs text-content"
|
||||||
|
>
|
||||||
|
{nameOf(id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`unassign ${nameOf(id)}`}
|
||||||
|
className="text-muted hover:text-danger disabled:opacity-50"
|
||||||
|
disabled={vm.busy}
|
||||||
|
onClick={() => void vm.assign(id, false)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
aria-label="assign agent"
|
||||||
|
className={selectClass}
|
||||||
|
value={assignPick}
|
||||||
|
disabled={vm.busy || assignable.length === 0}
|
||||||
|
onChange={(e) => setAssignPick(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{assignable.length === 0
|
||||||
|
? "No more agents"
|
||||||
|
: "Select an agent…"}
|
||||||
|
</option>
|
||||||
|
{assignable.map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>
|
||||||
|
{a.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!assignPick || vm.busy}
|
||||||
|
onClick={async () => {
|
||||||
|
const ok = await vm.assign(assignPick, true);
|
||||||
|
if (ok) setAssignPick("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Assign
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
256
frontend/src/features/tickets/TicketsPanel.tsx
Normal file
256
frontend/src/features/tickets/TicketsPanel.tsx
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
/**
|
||||||
|
* Tickets list panel (F2) — the project's ticket board in the sidebar.
|
||||||
|
*
|
||||||
|
* Filterable by status / priority / assignee + free-text search; each row shows
|
||||||
|
* the `#ref`, title, status, priority and assigned agents. Clicking a row (or
|
||||||
|
* its `#ref`) opens the detail via `onOpen`. Refreshes live on `Issue*` events
|
||||||
|
* through {@link useTickets}.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import type { TicketPriority, TicketStatus } from "@/domain";
|
||||||
|
import { Button, Input, Panel, Spinner, cn } from "@/shared";
|
||||||
|
import { useTickets } from "./useTickets";
|
||||||
|
import { useProjectAgents } from "./useProjectAgents";
|
||||||
|
import {
|
||||||
|
PriorityBadge,
|
||||||
|
StatusBadge,
|
||||||
|
TICKET_PRIORITIES,
|
||||||
|
TICKET_STATUSES,
|
||||||
|
TicketRef,
|
||||||
|
priorityLabel,
|
||||||
|
statusLabel,
|
||||||
|
} from "./ticketMeta";
|
||||||
|
|
||||||
|
const selectClass = cn(
|
||||||
|
"h-8 rounded-md bg-raised px-2 text-xs text-content",
|
||||||
|
"border border-border outline-none transition-colors",
|
||||||
|
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface TicketsPanelProps {
|
||||||
|
projectId: string;
|
||||||
|
/** Opens the detail overlay for the given `#ref` (F7). */
|
||||||
|
onOpen: (ref: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
||||||
|
const vm = useTickets(projectId);
|
||||||
|
const { agents, nameOf } = useProjectAgents(projectId);
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [newTitle, setNewTitle] = useState("");
|
||||||
|
const [newPriority, setNewPriority] = useState<TicketPriority>("medium");
|
||||||
|
|
||||||
|
const items = vm.list?.items ?? [];
|
||||||
|
|
||||||
|
async function submitCreate(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newTitle.trim()) return;
|
||||||
|
const created = await vm.create({
|
||||||
|
title: newTitle.trim(),
|
||||||
|
priority: newPriority,
|
||||||
|
});
|
||||||
|
if (created) {
|
||||||
|
setNewTitle("");
|
||||||
|
setNewPriority("medium");
|
||||||
|
setShowCreate(false);
|
||||||
|
onOpen(created.ref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel
|
||||||
|
title="Tickets"
|
||||||
|
actions={
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setShowCreate((v) => !v)}
|
||||||
|
>
|
||||||
|
{showCreate ? "Cancel" : "New"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => void vm.refresh()}
|
||||||
|
loading={vm.busy}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{vm.error && (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="mb-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
|
||||||
|
>
|
||||||
|
{vm.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showCreate && (
|
||||||
|
<form
|
||||||
|
onSubmit={submitCreate}
|
||||||
|
className="mb-3 flex flex-col gap-2 rounded-md border border-border bg-raised/50 p-2"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
aria-label="new ticket title"
|
||||||
|
placeholder="Ticket title"
|
||||||
|
value={newTitle}
|
||||||
|
onChange={(e) => setNewTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
aria-label="new ticket priority"
|
||||||
|
className={selectClass}
|
||||||
|
value={newPriority}
|
||||||
|
onChange={(e) => setNewPriority(e.target.value as TicketPriority)}
|
||||||
|
>
|
||||||
|
{TICKET_PRIORITIES.map((p) => (
|
||||||
|
<option key={p} value={p}>
|
||||||
|
{priorityLabel(p)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
disabled={!newTitle.trim() || vm.busy}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Filters ── */}
|
||||||
|
<div className="mb-3 flex flex-col gap-2">
|
||||||
|
<Input
|
||||||
|
aria-label="search tickets"
|
||||||
|
placeholder="Search title/description…"
|
||||||
|
value={vm.query.text ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
vm.setQuery({ ...vm.query, text: e.target.value || undefined })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<select
|
||||||
|
aria-label="filter by status"
|
||||||
|
className={selectClass}
|
||||||
|
value={vm.query.status ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
vm.setQuery({
|
||||||
|
...vm.query,
|
||||||
|
status: (e.target.value || undefined) as TicketStatus | undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
{TICKET_STATUSES.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{statusLabel(s)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
aria-label="filter by priority"
|
||||||
|
className={selectClass}
|
||||||
|
value={vm.query.priority ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
vm.setQuery({
|
||||||
|
...vm.query,
|
||||||
|
priority: (e.target.value || undefined) as
|
||||||
|
| TicketPriority
|
||||||
|
| undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">All priorities</option>
|
||||||
|
{TICKET_PRIORITIES.map((p) => (
|
||||||
|
<option key={p} value={p}>
|
||||||
|
{priorityLabel(p)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
aria-label="filter by assignee"
|
||||||
|
className={selectClass}
|
||||||
|
value={vm.query.assignedAgentId ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
vm.setQuery({
|
||||||
|
...vm.query,
|
||||||
|
assignedAgentId: e.target.value || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">All assignees</option>
|
||||||
|
{agents.map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>
|
||||||
|
{a.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── List ── */}
|
||||||
|
{vm.busy && vm.list === null ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted">
|
||||||
|
<Spinner size={14} />
|
||||||
|
<span>Loading tickets…</span>
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted">No tickets.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col divide-y divide-border">
|
||||||
|
{items.map((t) => (
|
||||||
|
<li key={t.ref} className="py-2 first:pt-0 last:pb-0">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<TicketRef ticketRef={t.ref} onOpen={onOpen} className="mt-0.5" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="min-w-0 flex-1 text-left"
|
||||||
|
onClick={() => onOpen(t.ref)}
|
||||||
|
>
|
||||||
|
<span className="block truncate text-sm font-medium text-content hover:underline">
|
||||||
|
{t.title}
|
||||||
|
</span>
|
||||||
|
{t.assignedAgentIds.length > 0 && (
|
||||||
|
<span className="mt-0.5 block truncate text-xs text-muted">
|
||||||
|
{t.assignedAgentIds.map(nameOf).join(", ")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<span className="flex shrink-0 flex-col items-end gap-1">
|
||||||
|
<StatusBadge status={t.status} />
|
||||||
|
<PriorityBadge priority={t.priority} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{vm.list?.nextCursor && (
|
||||||
|
<div className="mt-3 flex justify-center">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
loading={vm.busy}
|
||||||
|
onClick={() =>
|
||||||
|
vm.setQuery({
|
||||||
|
...vm.query,
|
||||||
|
limit: (vm.query.limit ?? 100) + 100,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Load more
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
frontend/src/features/tickets/TicketsView.tsx
Normal file
34
frontend/src/features/tickets/TicketsView.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Tickets feature container: the sidebar list (F2) plus the full-screen detail
|
||||||
|
* overlay (F3/F4/F5/F7). Owns the currently-open `#ref` so a click in the list
|
||||||
|
* — or on any inline `#ref` inside the detail — swaps the overlay to that
|
||||||
|
* ticket.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { TicketsPanel } from "./TicketsPanel";
|
||||||
|
import { TicketDetail } from "./TicketDetail";
|
||||||
|
|
||||||
|
export interface TicketsViewProps {
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TicketsView({ projectId }: TicketsViewProps) {
|
||||||
|
const [openRef, setOpenRef] = useState<string | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TicketsPanel projectId={projectId} onOpen={setOpenRef} />
|
||||||
|
{openRef && (
|
||||||
|
<TicketDetail
|
||||||
|
key={`${projectId}-${openRef}`}
|
||||||
|
projectId={projectId}
|
||||||
|
ticketRef={openRef}
|
||||||
|
onClose={() => setOpenRef(null)}
|
||||||
|
onOpenRef={setOpenRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
frontend/src/features/tickets/index.ts
Normal file
22
frontend/src/features/tickets/index.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
/** Tickets feature (F1–F5, F7): project ticket board, detail/edit, carnet,
|
||||||
|
* links & assignments, and clickable `#ref` navigation. */
|
||||||
|
|
||||||
|
export { TicketsView } from "./TicketsView";
|
||||||
|
export type { TicketsViewProps } from "./TicketsView";
|
||||||
|
export { TicketsPanel } from "./TicketsPanel";
|
||||||
|
export type { TicketsPanelProps } from "./TicketsPanel";
|
||||||
|
export { TicketDetail } from "./TicketDetail";
|
||||||
|
export type { TicketDetailProps } from "./TicketDetail";
|
||||||
|
export { useTickets } from "./useTickets";
|
||||||
|
export type { TicketsViewModel } from "./useTickets";
|
||||||
|
export { useTicketDetail } from "./useTicketDetail";
|
||||||
|
export type { TicketDetailViewModel } from "./useTicketDetail";
|
||||||
|
export {
|
||||||
|
TicketRef,
|
||||||
|
StatusBadge,
|
||||||
|
PriorityBadge,
|
||||||
|
linkifyTicketRefs,
|
||||||
|
statusLabel,
|
||||||
|
priorityLabel,
|
||||||
|
linkKindLabel,
|
||||||
|
} from "./ticketMeta";
|
||||||
169
frontend/src/features/tickets/ticketMeta.tsx
Normal file
169
frontend/src/features/tickets/ticketMeta.tsx
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* Shared presentational helpers for tickets: human labels, badge styling, and
|
||||||
|
* the clickable `#ref` element (F7). Kept pure/presentational so both the list
|
||||||
|
* and the detail surfaces render tickets consistently.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import type { TicketLinkKind, TicketPriority, TicketStatus } from "@/domain";
|
||||||
|
import { cn } from "@/shared";
|
||||||
|
|
||||||
|
export const TICKET_STATUSES: TicketStatus[] = [
|
||||||
|
"open",
|
||||||
|
"inProgress",
|
||||||
|
"QA",
|
||||||
|
"closed",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TICKET_PRIORITIES: TicketPriority[] = [
|
||||||
|
"low",
|
||||||
|
"medium",
|
||||||
|
"high",
|
||||||
|
"critical",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TICKET_LINK_KINDS: TicketLinkKind[] = [
|
||||||
|
"relatesTo",
|
||||||
|
"blocks",
|
||||||
|
"blockedBy",
|
||||||
|
"duplicates",
|
||||||
|
"dependsOn",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function statusLabel(status: TicketStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case "open":
|
||||||
|
return "Open";
|
||||||
|
case "inProgress":
|
||||||
|
return "In progress";
|
||||||
|
case "QA":
|
||||||
|
return "QA";
|
||||||
|
case "closed":
|
||||||
|
return "Closed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function priorityLabel(priority: TicketPriority): string {
|
||||||
|
return priority.charAt(0).toUpperCase() + priority.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function linkKindLabel(kind: TicketLinkKind): string {
|
||||||
|
switch (kind) {
|
||||||
|
case "relatesTo":
|
||||||
|
return "relates to";
|
||||||
|
case "blocks":
|
||||||
|
return "blocks";
|
||||||
|
case "blockedBy":
|
||||||
|
return "blocked by";
|
||||||
|
case "duplicates":
|
||||||
|
return "duplicates";
|
||||||
|
case "dependsOn":
|
||||||
|
return "depends on";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusClass(status: TicketStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case "open":
|
||||||
|
return "bg-raised text-muted";
|
||||||
|
case "inProgress":
|
||||||
|
return "bg-warning/15 text-warning";
|
||||||
|
case "QA":
|
||||||
|
return "bg-primary/15 text-primary";
|
||||||
|
case "closed":
|
||||||
|
return "bg-success/15 text-success";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function priorityClass(priority: TicketPriority): string {
|
||||||
|
switch (priority) {
|
||||||
|
case "low":
|
||||||
|
return "bg-raised text-muted";
|
||||||
|
case "medium":
|
||||||
|
return "bg-primary/10 text-primary";
|
||||||
|
case "high":
|
||||||
|
return "bg-warning/15 text-warning";
|
||||||
|
case "critical":
|
||||||
|
return "bg-danger/15 text-danger";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function badgeBase(className?: string): string {
|
||||||
|
return cn(
|
||||||
|
"inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium",
|
||||||
|
className,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBadge({ status }: { status: TicketStatus }) {
|
||||||
|
return <span className={badgeBase(statusClass(status))}>{statusLabel(status)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PriorityBadge({ priority }: { priority: TicketPriority }) {
|
||||||
|
return (
|
||||||
|
<span className={badgeBase(priorityClass(priority))}>
|
||||||
|
{priorityLabel(priority)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A clickable ticket reference badge (`#42`). Renders as a button when
|
||||||
|
* `onOpen` is provided (F7 — opens the ticket detail), else as plain text.
|
||||||
|
*/
|
||||||
|
export function TicketRef({
|
||||||
|
ticketRef,
|
||||||
|
onOpen,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
ticketRef: string;
|
||||||
|
onOpen?: (ref: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const base = cn(
|
||||||
|
"inline-flex shrink-0 items-center rounded bg-raised px-1.5 py-0.5 font-mono text-xs text-content",
|
||||||
|
className,
|
||||||
|
);
|
||||||
|
if (!onOpen) return <code className={base}>{ticketRef}</code>;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`open ticket ${ticketRef}`}
|
||||||
|
className={cn(base, "transition-colors hover:bg-primary/15 hover:text-primary")}
|
||||||
|
onClick={() => onOpen(ticketRef)}
|
||||||
|
>
|
||||||
|
{ticketRef}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const REF_PATTERN = /#\d+/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linkifies inline `#42` references inside arbitrary text (F7). Non-ref segments
|
||||||
|
* are preserved verbatim; each `#n` becomes a clickable {@link TicketRef} when
|
||||||
|
* `onOpen` is provided.
|
||||||
|
*/
|
||||||
|
export function linkifyTicketRefs(
|
||||||
|
text: string,
|
||||||
|
onOpen?: (ref: string) => void,
|
||||||
|
): ReactNode[] {
|
||||||
|
const out: ReactNode[] = [];
|
||||||
|
let last = 0;
|
||||||
|
let index = 0;
|
||||||
|
for (const match of text.matchAll(REF_PATTERN)) {
|
||||||
|
const start = match.index ?? 0;
|
||||||
|
if (start > last) out.push(text.slice(last, start));
|
||||||
|
out.push(
|
||||||
|
<TicketRef
|
||||||
|
key={`ref-${index++}-${start}`}
|
||||||
|
ticketRef={match[0]}
|
||||||
|
onOpen={onOpen}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
last = start + match[0].length;
|
||||||
|
}
|
||||||
|
if (last < text.length) out.push(text.slice(last));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
245
frontend/src/features/tickets/tickets.test.tsx
Normal file
245
frontend/src/features/tickets/tickets.test.tsx
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
within,
|
||||||
|
} from "@testing-library/react";
|
||||||
|
|
||||||
|
import { DIProvider } from "@/app/di";
|
||||||
|
import {
|
||||||
|
MockAgentGateway,
|
||||||
|
MockSystemGateway,
|
||||||
|
MockTicketGateway,
|
||||||
|
} from "@/adapters/mock";
|
||||||
|
import type { Agent } from "@/domain";
|
||||||
|
import type { Gateways } from "@/ports";
|
||||||
|
import { TicketsView } from "./TicketsView";
|
||||||
|
import { TicketDetail } from "./TicketDetail";
|
||||||
|
|
||||||
|
const PROJECT_ID = "project-tickets-test";
|
||||||
|
|
||||||
|
function stubClipboard() {
|
||||||
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||||
|
Object.assign(navigator, { clipboard: { writeText } });
|
||||||
|
return writeText;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedAgent(agent: MockAgentGateway, name: string): Promise<Agent> {
|
||||||
|
return agent.createAgent(PROJECT_ID, { name, profileId: "p-1" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderView(
|
||||||
|
ticket: MockTicketGateway,
|
||||||
|
system: MockSystemGateway,
|
||||||
|
agent: MockAgentGateway,
|
||||||
|
) {
|
||||||
|
const gateways = { ticket, system, agent } as unknown as Gateways;
|
||||||
|
return render(
|
||||||
|
<DIProvider gateways={gateways}>
|
||||||
|
<TicketsView projectId={PROJECT_ID} />
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("MockTicketGateway", () => {
|
||||||
|
let system: MockSystemGateway;
|
||||||
|
let ticket: MockTicketGateway;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
system = new MockSystemGateway();
|
||||||
|
ticket = new MockTicketGateway(system);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assigns sequential #refs and emits issueCreated", async () => {
|
||||||
|
const events: string[] = [];
|
||||||
|
await system.onDomainEvent((e) => events.push(e.type));
|
||||||
|
|
||||||
|
const a = await ticket.create(PROJECT_ID, { title: "First" });
|
||||||
|
const b = await ticket.create(PROJECT_ID, { title: "Second" });
|
||||||
|
|
||||||
|
expect(a.ref).toBe("#1");
|
||||||
|
expect(b.ref).toBe("#2");
|
||||||
|
expect(b.version).toBe(1);
|
||||||
|
expect(events).toContain("issueCreated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by status and searches text", async () => {
|
||||||
|
await ticket.create(PROJECT_ID, { title: "alpha bug", status: "open" });
|
||||||
|
const t2 = await ticket.create(PROJECT_ID, { title: "beta task" });
|
||||||
|
await ticket.update(PROJECT_ID, t2.ref, {
|
||||||
|
status: "closed",
|
||||||
|
expectedVersion: t2.version,
|
||||||
|
});
|
||||||
|
|
||||||
|
const open = await ticket.list(PROJECT_ID, { status: "open" });
|
||||||
|
expect(open.items.map((i) => i.ref)).toEqual(["#1"]);
|
||||||
|
|
||||||
|
const search = await ticket.list(PROJECT_ID, { text: "beta" });
|
||||||
|
expect(search.items.map((i) => i.ref)).toEqual(["#2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a stale write with a version conflict", async () => {
|
||||||
|
const t = await ticket.create(PROJECT_ID, { title: "x" });
|
||||||
|
await ticket.update(PROJECT_ID, t.ref, {
|
||||||
|
title: "y",
|
||||||
|
expectedVersion: t.version,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ticket.update(PROJECT_ID, t.ref, {
|
||||||
|
title: "z",
|
||||||
|
expectedVersion: t.version, // stale (1, now 2)
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ message: expect.stringContaining("version conflict") });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("links and unlinks tickets, bumping the version", async () => {
|
||||||
|
const a = await ticket.create(PROJECT_ID, { title: "a" });
|
||||||
|
await ticket.create(PROJECT_ID, { title: "b" });
|
||||||
|
const linked = await ticket.link(PROJECT_ID, a.ref, "#2", "blocks", a.version);
|
||||||
|
expect(linked.links).toEqual([{ targetRef: "#2", kind: "blocks" }]);
|
||||||
|
const unlinked = await ticket.unlink(
|
||||||
|
PROJECT_ID,
|
||||||
|
a.ref,
|
||||||
|
"#2",
|
||||||
|
linked.version,
|
||||||
|
"blocks",
|
||||||
|
);
|
||||||
|
expect(unlinked.links).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TicketsView", () => {
|
||||||
|
let system: MockSystemGateway;
|
||||||
|
let ticket: MockTicketGateway;
|
||||||
|
let agent: MockAgentGateway;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
system = new MockSystemGateway();
|
||||||
|
ticket = new MockTicketGateway(system);
|
||||||
|
agent = new MockAgentGateway();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the empty state then a created ticket", async () => {
|
||||||
|
renderView(ticket, system, agent);
|
||||||
|
expect(await screen.findByText("No tickets.")).toBeTruthy();
|
||||||
|
|
||||||
|
await ticket.create(PROJECT_ID, { title: "Live created" });
|
||||||
|
// The panel refreshes on the issueCreated event.
|
||||||
|
expect(await screen.findByText("Live created")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the detail and edits status with a version bump", async () => {
|
||||||
|
const t = await ticket.create(PROJECT_ID, { title: "Editable" });
|
||||||
|
renderView(ticket, system, agent);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByText("Editable"));
|
||||||
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
|
||||||
|
const statusSelect = within(dialog).getByLabelText("ticket status");
|
||||||
|
fireEvent.change(statusSelect, { target: { value: "inProgress" } });
|
||||||
|
|
||||||
|
await waitFor(async () => {
|
||||||
|
const fresh = await ticket.read(PROJECT_ID, t.ref);
|
||||||
|
expect(fresh.status).toBe("inProgress");
|
||||||
|
expect(fresh.version).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves the ticket-scoped carnet (replaceable)", async () => {
|
||||||
|
const t = await ticket.create(PROJECT_ID, { title: "WithCarnet" });
|
||||||
|
renderView(ticket, system, agent);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByText("WithCarnet"));
|
||||||
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
|
||||||
|
fireEvent.change(within(dialog).getByLabelText("ticket carnet"), {
|
||||||
|
target: { value: "## notes\nscoped body" },
|
||||||
|
});
|
||||||
|
fireEvent.click(within(dialog).getByText("Save carnet"));
|
||||||
|
|
||||||
|
await waitFor(async () => {
|
||||||
|
const carnet = await ticket.readCarnet(PROJECT_ID, t.ref);
|
||||||
|
expect(carnet.carnet).toBe("## notes\nscoped body");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assigns only known project agents", async () => {
|
||||||
|
const known = await seedAgent(agent, "Backend");
|
||||||
|
const t = await ticket.create(PROJECT_ID, { title: "Assignable" });
|
||||||
|
renderView(ticket, system, agent);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByText("Assignable"));
|
||||||
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
|
||||||
|
fireEvent.change(within(dialog).getByLabelText("assign agent"), {
|
||||||
|
target: { value: known.id },
|
||||||
|
});
|
||||||
|
fireEvent.click(within(dialog).getByText("Assign"));
|
||||||
|
|
||||||
|
await waitFor(async () => {
|
||||||
|
const fresh = await ticket.read(PROJECT_ID, t.ref);
|
||||||
|
expect(fresh.assignedAgentIds).toEqual([known.id]);
|
||||||
|
});
|
||||||
|
expect(within(dialog).getAllByText("Backend").length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces a version-conflict banner and reloads (F3)", async () => {
|
||||||
|
// No system gateway ⇒ the detail does not auto-refresh on events, so we can
|
||||||
|
// deterministically make its held version go stale under an external write.
|
||||||
|
const t = await ticket.create(PROJECT_ID, { title: "Racy" });
|
||||||
|
const gateways = { ticket, agent } as unknown as Gateways;
|
||||||
|
render(
|
||||||
|
<DIProvider gateways={gateways}>
|
||||||
|
<TicketDetail
|
||||||
|
projectId={PROJECT_ID}
|
||||||
|
ticketRef={t.ref}
|
||||||
|
onClose={() => {}}
|
||||||
|
onOpenRef={() => {}}
|
||||||
|
/>
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
// The detail now holds version 1. Bump the store to version 2 behind its back.
|
||||||
|
await ticket.update(PROJECT_ID, t.ref, {
|
||||||
|
description: "external",
|
||||||
|
expectedVersion: t.version,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(within(dialog).getByLabelText("Title"), {
|
||||||
|
target: { value: "Racy edited" },
|
||||||
|
});
|
||||||
|
fireEvent.click(within(dialog).getByText("Save changes"));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await within(dialog).findByText(/modified elsewhere and reloaded/i),
|
||||||
|
).toBeTruthy();
|
||||||
|
// After the reload the detail field reflects the reloaded (title unchanged)
|
||||||
|
// ticket rather than the abandoned local edit.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
(within(dialog).getByLabelText("Title") as HTMLInputElement)
|
||||||
|
.value,
|
||||||
|
).toBe("Racy"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies a delegation context prompt (F7)", async () => {
|
||||||
|
const writeText = stubClipboard();
|
||||||
|
const t = await ticket.create(PROJECT_ID, { title: "Delegate me" });
|
||||||
|
renderView(ticket, system, agent);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByText("Delegate me"));
|
||||||
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
fireEvent.click(within(dialog).getByText("Delegation context"));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(writeText).toHaveBeenCalledWith(
|
||||||
|
`Travaille sur le ticket ${t.ref} : Delegate me`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
47
frontend/src/features/tickets/useProjectAgents.ts
Normal file
47
frontend/src/features/tickets/useProjectAgents.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* Minimal read-only list of a project's agents, used by the ticket surfaces to
|
||||||
|
* (a) resolve agent ids to names and (b) offer only *known* agents when
|
||||||
|
* assigning (F5). Deliberately lighter than the full `useAgents` view-model —
|
||||||
|
* tickets never launch or mutate agents.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import type { Agent } from "@/domain";
|
||||||
|
import { useGateways } from "@/app/di";
|
||||||
|
|
||||||
|
export interface ProjectAgents {
|
||||||
|
agents: Agent[];
|
||||||
|
/** Maps an agent id to a display name, falling back to a short id. */
|
||||||
|
nameOf: (agentId: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProjectAgents(projectId: string): ProjectAgents {
|
||||||
|
const { agent } = useGateways();
|
||||||
|
const [agents, setAgents] = useState<Agent[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
void agent
|
||||||
|
.listAgents(projectId)
|
||||||
|
.then((list) => {
|
||||||
|
if (!cancelled) setAgents(list);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setAgents([]);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [projectId, agent]);
|
||||||
|
|
||||||
|
const byId = useMemo(
|
||||||
|
() => new Map(agents.map((a) => [a.id, a.name])),
|
||||||
|
[agents],
|
||||||
|
);
|
||||||
|
|
||||||
|
const nameOf = (agentId: string): string =>
|
||||||
|
byId.get(agentId) ?? `${agentId.slice(0, 8)}…`;
|
||||||
|
|
||||||
|
return { agents, nameOf };
|
||||||
|
}
|
||||||
179
frontend/src/features/tickets/useTicketDetail.ts
Normal file
179
frontend/src/features/tickets/useTicketDetail.ts
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* View-model hook for a single ticket's detail/edit surface (F3/F4/F5).
|
||||||
|
*
|
||||||
|
* Owns optimistic-concurrency handling: every mutation sends the ticket's
|
||||||
|
* current `version` as `expectedVersion`. When the backend reports a version
|
||||||
|
* conflict (the ticket moved underneath — e.g. an agent edited it), the hook
|
||||||
|
* flips `conflict`, reloads the fresh ticket and surfaces a clear message so the
|
||||||
|
* user can re-apply their change against the new version.
|
||||||
|
*
|
||||||
|
* The loaded ticket always includes its carnet body (F4); mutations that return
|
||||||
|
* a carnet-less ticket preserve the last known carnet so the editor never blanks
|
||||||
|
* out unexpectedly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { isTicketEvent, type GatewayError, type Ticket, type TicketLinkKind } from "@/domain";
|
||||||
|
import { useGateways } from "@/app/di";
|
||||||
|
|
||||||
|
export interface TicketDetailViewModel {
|
||||||
|
ticket: Ticket | null;
|
||||||
|
error: string | null;
|
||||||
|
/** Set when the last write lost an optimistic-concurrency race (F3). */
|
||||||
|
conflict: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
updateFields: (input: {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
status?: Ticket["status"];
|
||||||
|
priority?: Ticket["priority"];
|
||||||
|
}) => Promise<boolean>;
|
||||||
|
saveCarnet: (carnet: string) => Promise<boolean>;
|
||||||
|
link: (targetRef: string, kind: TicketLinkKind) => Promise<boolean>;
|
||||||
|
unlink: (targetRef: string, kind?: TicketLinkKind) => Promise<boolean>;
|
||||||
|
assign: (agentId: string, assigned: boolean) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describe(e: unknown): string {
|
||||||
|
if (e && typeof e === "object" && "message" in e) {
|
||||||
|
return String((e as GatewayError).message);
|
||||||
|
}
|
||||||
|
return String(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A generic `INVALID` from the backend whose message flags a version conflict. */
|
||||||
|
function isVersionConflict(e: unknown): boolean {
|
||||||
|
return (
|
||||||
|
!!e &&
|
||||||
|
typeof e === "object" &&
|
||||||
|
typeof (e as GatewayError).message === "string" &&
|
||||||
|
(e as GatewayError).message.includes("version conflict")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTicketDetail(
|
||||||
|
projectId: string,
|
||||||
|
ref: string,
|
||||||
|
): TicketDetailViewModel {
|
||||||
|
const { ticket: gateway, system } = useGateways();
|
||||||
|
const [ticket, setTicket] = useState<Ticket | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [conflict, setConflict] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setTicket(await gateway.read(projectId, ref, true));
|
||||||
|
setConflict(false);
|
||||||
|
} catch (e) {
|
||||||
|
setError(describe(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [projectId, ref, gateway]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
// Refresh when *another* actor mutates this same ticket while it is open.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!system) return;
|
||||||
|
let unsubscribe: (() => void) | undefined;
|
||||||
|
let cancelled = false;
|
||||||
|
void system
|
||||||
|
.onDomainEvent((event) => {
|
||||||
|
if (isTicketEvent(event) && event.issueRef === ref) void refresh();
|
||||||
|
})
|
||||||
|
.then((u) => {
|
||||||
|
if (cancelled) u();
|
||||||
|
else unsubscribe = u;
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
unsubscribe?.();
|
||||||
|
};
|
||||||
|
}, [refresh, system, ref]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs one mutation, preserving the loaded carnet (mutations return a
|
||||||
|
* carnet-less ticket) and translating a version conflict into `conflict` +
|
||||||
|
* a reload so the UI can prompt a clean retry.
|
||||||
|
*/
|
||||||
|
const run = useCallback(
|
||||||
|
async (op: (version: number) => Promise<Ticket>, carnet?: string): Promise<boolean> => {
|
||||||
|
if (!ticket) return false;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const updated = await op(ticket.version);
|
||||||
|
setTicket({
|
||||||
|
...updated,
|
||||||
|
carnet: carnet !== undefined ? carnet : ticket.carnet,
|
||||||
|
});
|
||||||
|
setConflict(false);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
if (isVersionConflict(e)) {
|
||||||
|
setConflict(true);
|
||||||
|
setError(
|
||||||
|
"This ticket was modified elsewhere. It has been reloaded — re-apply your change.",
|
||||||
|
);
|
||||||
|
await refresh();
|
||||||
|
} else {
|
||||||
|
setError(describe(e));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[ticket, gateway, refresh],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateFields: TicketDetailViewModel["updateFields"] = useCallback(
|
||||||
|
(input) => run((version) => gateway.update(projectId, ref, { ...input, expectedVersion: version })),
|
||||||
|
[run, gateway, projectId, ref],
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveCarnet: TicketDetailViewModel["saveCarnet"] = useCallback(
|
||||||
|
(carnet) =>
|
||||||
|
run((version) => gateway.updateCarnet(projectId, ref, carnet, version), carnet),
|
||||||
|
[run, gateway, projectId, ref],
|
||||||
|
);
|
||||||
|
|
||||||
|
const link: TicketDetailViewModel["link"] = useCallback(
|
||||||
|
(targetRef, kind) =>
|
||||||
|
run((version) => gateway.link(projectId, ref, targetRef, kind, version)),
|
||||||
|
[run, gateway, projectId, ref],
|
||||||
|
);
|
||||||
|
|
||||||
|
const unlink: TicketDetailViewModel["unlink"] = useCallback(
|
||||||
|
(targetRef, kind) =>
|
||||||
|
run((version) => gateway.unlink(projectId, ref, targetRef, version, kind)),
|
||||||
|
[run, gateway, projectId, ref],
|
||||||
|
);
|
||||||
|
|
||||||
|
const assign: TicketDetailViewModel["assign"] = useCallback(
|
||||||
|
(agentId, assigned) =>
|
||||||
|
run((version) => gateway.assign(projectId, ref, agentId, assigned, version)),
|
||||||
|
[run, gateway, projectId, ref],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ticket,
|
||||||
|
error,
|
||||||
|
conflict,
|
||||||
|
busy,
|
||||||
|
refresh,
|
||||||
|
updateFields,
|
||||||
|
saveCarnet,
|
||||||
|
link,
|
||||||
|
unlink,
|
||||||
|
assign,
|
||||||
|
};
|
||||||
|
}
|
||||||
96
frontend/src/features/tickets/useTickets.ts
Normal file
96
frontend/src/features/tickets/useTickets.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* View-model hook for the project ticket list (F2).
|
||||||
|
*
|
||||||
|
* Consumes the {@link TicketGateway} for reads/creates and subscribes to the
|
||||||
|
* `Issue*` domain events (via {@link SystemGateway}) to refresh the list on any
|
||||||
|
* ticket mutation — including those made by agents through MCP. Filters/search
|
||||||
|
* are part of the query and drive a re-fetch when they change.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { isTicketEvent, type GatewayError, type Ticket, type TicketList } from "@/domain";
|
||||||
|
import type { CreateTicketInput, TicketListQuery } from "@/ports";
|
||||||
|
import { useGateways } from "@/app/di";
|
||||||
|
|
||||||
|
export interface TicketsViewModel {
|
||||||
|
list: TicketList | null;
|
||||||
|
error: string | null;
|
||||||
|
busy: boolean;
|
||||||
|
query: TicketListQuery;
|
||||||
|
setQuery: (next: TicketListQuery) => void;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
create: (input: CreateTicketInput) => Promise<Ticket | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describe(e: unknown): string {
|
||||||
|
if (e && typeof e === "object" && "message" in e) {
|
||||||
|
return String((e as GatewayError).message);
|
||||||
|
}
|
||||||
|
return String(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTickets(projectId: string): TicketsViewModel {
|
||||||
|
const { ticket, system } = useGateways();
|
||||||
|
const [list, setList] = useState<TicketList | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [query, setQuery] = useState<TicketListQuery>({});
|
||||||
|
|
||||||
|
// Stable dependency key so `refresh` only changes when a filter actually does.
|
||||||
|
const queryKey = useMemo(() => JSON.stringify(query), [query]);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setList(await ticket.list(projectId, query));
|
||||||
|
} catch (e) {
|
||||||
|
setError(describe(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
// `query` is captured via `queryKey`; listing it directly would rebuild the
|
||||||
|
// callback on every render (a new object identity each time).
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [projectId, ticket, queryKey]);
|
||||||
|
|
||||||
|
const create = useCallback(
|
||||||
|
async (input: CreateTicketInput): Promise<Ticket | null> => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const created = await ticket.create(projectId, input);
|
||||||
|
await refresh();
|
||||||
|
return created;
|
||||||
|
} catch (e) {
|
||||||
|
setError(describe(e));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[projectId, ticket, refresh],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!system) return;
|
||||||
|
let unsubscribe: (() => void) | undefined;
|
||||||
|
let cancelled = false;
|
||||||
|
void system
|
||||||
|
.onDomainEvent((event) => {
|
||||||
|
if (isTicketEvent(event)) void refresh();
|
||||||
|
})
|
||||||
|
.then((u) => {
|
||||||
|
if (cancelled) u();
|
||||||
|
else unsubscribe = u;
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
unsubscribe?.();
|
||||||
|
};
|
||||||
|
}, [refresh, system]);
|
||||||
|
|
||||||
|
return { list, error, busy, query, setQuery, refresh, create };
|
||||||
|
}
|
||||||
@ -41,6 +41,12 @@ import type {
|
|||||||
SkillScope,
|
SkillScope,
|
||||||
Template,
|
Template,
|
||||||
TerminalSession,
|
TerminalSession,
|
||||||
|
Ticket,
|
||||||
|
TicketCarnet,
|
||||||
|
TicketLinkKind,
|
||||||
|
TicketList,
|
||||||
|
TicketPriority,
|
||||||
|
TicketStatus,
|
||||||
TurnPage,
|
TurnPage,
|
||||||
Unsubscribe,
|
Unsubscribe,
|
||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
@ -698,6 +704,98 @@ export interface ConversationGateway {
|
|||||||
): Promise<TurnPage>;
|
): Promise<TurnPage>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Filter/search criteria for {@link TicketGateway.list}. */
|
||||||
|
export interface TicketListQuery {
|
||||||
|
status?: TicketStatus;
|
||||||
|
priority?: TicketPriority;
|
||||||
|
assignedAgentId?: string;
|
||||||
|
/** Free-text match over title/description. */
|
||||||
|
text?: string;
|
||||||
|
limit?: number;
|
||||||
|
cursor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Input for {@link TicketGateway.create}. */
|
||||||
|
export interface CreateTicketInput {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
priority?: TicketPriority;
|
||||||
|
status?: TicketStatus;
|
||||||
|
assignedAgentIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fields to change on {@link TicketGateway.update}. Only the provided keys are
|
||||||
|
* touched; `expectedVersion` is mandatory (optimistic concurrency — a stale
|
||||||
|
* value surfaces a `versionConflict` {@link GatewayError}).
|
||||||
|
*/
|
||||||
|
export interface UpdateTicketInput {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
status?: TicketStatus;
|
||||||
|
priority?: TicketPriority;
|
||||||
|
assignedAgentIds?: string[];
|
||||||
|
expectedVersion: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ticket gateway (public wire name for the backend `Issue` model). The only
|
||||||
|
* frontend place that knows the `ticket_*` command names; features consume this
|
||||||
|
* port through DI. Every mutation carries an `expectedVersion` and rejects with
|
||||||
|
* a `versionConflict` {@link GatewayError} when the ticket moved underneath.
|
||||||
|
*/
|
||||||
|
export interface TicketGateway {
|
||||||
|
/** Creates a ticket and returns it. */
|
||||||
|
create(projectId: string, input: CreateTicketInput): Promise<Ticket>;
|
||||||
|
/** Reads one ticket, optionally including its carnet body. */
|
||||||
|
read(
|
||||||
|
projectId: string,
|
||||||
|
ref: string,
|
||||||
|
includeCarnet?: boolean,
|
||||||
|
): Promise<Ticket>;
|
||||||
|
/** Lists tickets matching the (optional) filter/search query. */
|
||||||
|
list(projectId: string, query?: TicketListQuery): Promise<TicketList>;
|
||||||
|
/** Applies partial edits (title/description/status/priority/assignees). */
|
||||||
|
update(
|
||||||
|
projectId: string,
|
||||||
|
ref: string,
|
||||||
|
input: UpdateTicketInput,
|
||||||
|
): Promise<Ticket>;
|
||||||
|
/** Reads the ticket-scoped Markdown carnet. */
|
||||||
|
readCarnet(projectId: string, ref: string): Promise<TicketCarnet>;
|
||||||
|
/** Replaces (not appends) the ticket-scoped carnet body. */
|
||||||
|
updateCarnet(
|
||||||
|
projectId: string,
|
||||||
|
ref: string,
|
||||||
|
carnet: string,
|
||||||
|
expectedVersion: number,
|
||||||
|
): Promise<Ticket>;
|
||||||
|
/** Links this ticket to another `#id` with the given relationship kind. */
|
||||||
|
link(
|
||||||
|
projectId: string,
|
||||||
|
ref: string,
|
||||||
|
targetRef: string,
|
||||||
|
kind: TicketLinkKind,
|
||||||
|
expectedVersion: number,
|
||||||
|
): Promise<Ticket>;
|
||||||
|
/** Removes a link (optionally scoped to a single kind). */
|
||||||
|
unlink(
|
||||||
|
projectId: string,
|
||||||
|
ref: string,
|
||||||
|
targetRef: string,
|
||||||
|
expectedVersion: number,
|
||||||
|
kind?: TicketLinkKind,
|
||||||
|
): Promise<Ticket>;
|
||||||
|
/** Assigns/unassigns an agent to the ticket. */
|
||||||
|
assign(
|
||||||
|
projectId: string,
|
||||||
|
ref: string,
|
||||||
|
agentId: string,
|
||||||
|
assigned: boolean,
|
||||||
|
expectedVersion: number,
|
||||||
|
): Promise<Ticket>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The full set of gateways the app depends on, injected via the DI provider.
|
* The full set of gateways the app depends on, injected via the DI provider.
|
||||||
* The composition (real vs mock) is chosen in `app/`.
|
* The composition (real vs mock) is chosen in `app/`.
|
||||||
@ -719,4 +817,5 @@ export interface Gateways {
|
|||||||
permission: PermissionGateway;
|
permission: PermissionGateway;
|
||||||
workState: WorkStateGateway;
|
workState: WorkStateGateway;
|
||||||
conversation: ConversationGateway;
|
conversation: ConversationGateway;
|
||||||
|
ticket: TicketGateway;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user