feat(tickets): checkpoint backend V1 du système de tickets (T1-T5)

Checkpoint de travail — QA formelle encore à venir (après reset Codex).
`cargo build` workspace OK, tests ticket/issue verts (domaine, store,
use cases, app-tauri 47/51). Le domaine s'appelle `Issue`, exposé
« ticket » côté MCP/UI.

- T1 domaine : entité Issue (statut, priorité, liens, carnet),
  events, ids, ports.
- T2 infra : store FS Markdown + allocator de références #N.
- T3 application : use cases Issue (create/read/list/update/status/
  priority/carnet/link/unlink/assign) + erreurs dédiées.
- T4 surface MCP : 10 outils publics idea_ticket_* (23 outils au
  total), mappés vers les use cases Issue.
- T5 app-tauri : commandes Tauri ticket_* + DTOs d'events Issue +
  câblage state.rs.

Dette de test PRÉ-EXISTANTE héritée de develop (4 tests app-tauri
mcp_e2e_loopback / mcp_serve_peer rouges) hors périmètre tickets,
non traitée ici.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 22:34:04 +02:00
parent a9653bc417
commit 8de7be01a8
22 changed files with 4395 additions and 50 deletions

View File

@ -14,6 +14,7 @@ use tauri::{AppHandle, Emitter};
use domain::events::{DomainEvent, OrchestrationSource};
use domain::input::AgentLiveness;
use domain::{IssueLinkKind, IssuePriority, IssueStatus};
use infrastructure::TokioBroadcastEventBus;
/// Name of the Tauri event carrying relayed [`DomainEvent`]s.
@ -158,6 +159,94 @@ pub enum DomainEventDto {
/// Project id.
project_id: String,
},
/// An issue-backed public ticket was created.
#[serde(rename_all = "camelCase")]
IssueCreated {
/// Issue id.
issue_id: String,
/// Public ticket reference (`#N`).
issue_ref: String,
},
/// An issue-backed public ticket was updated.
#[serde(rename_all = "camelCase")]
IssueUpdated {
/// Public ticket reference (`#N`).
issue_ref: String,
/// New optimistic version.
version: u64,
},
/// A public ticket status changed.
#[serde(rename_all = "camelCase")]
IssueStatusChanged {
/// Public ticket reference (`#N`).
issue_ref: String,
/// New status.
status: String,
/// New optimistic version.
version: u64,
},
/// A public ticket priority changed.
#[serde(rename_all = "camelCase")]
IssuePriorityChanged {
/// Public ticket reference (`#N`).
issue_ref: String,
/// New priority.
priority: String,
/// New optimistic version.
version: u64,
},
/// A public ticket carnet changed.
#[serde(rename_all = "camelCase")]
IssueCarnetUpdated {
/// Public ticket reference (`#N`).
issue_ref: String,
/// New optimistic version.
version: u64,
},
/// A public ticket link was added.
#[serde(rename_all = "camelCase")]
IssueLinked {
/// Public source ticket reference (`#N`).
issue_ref: String,
/// Public target ticket reference (`#N`).
target: String,
/// Link kind.
kind: String,
/// New optimistic version.
version: u64,
},
/// A public ticket link was removed.
#[serde(rename_all = "camelCase")]
IssueUnlinked {
/// Public source ticket reference (`#N`).
issue_ref: String,
/// Public target ticket reference (`#N`).
target: String,
/// Link kind.
kind: String,
/// New optimistic version.
version: u64,
},
/// An agent was assigned to a public ticket.
#[serde(rename_all = "camelCase")]
IssueAgentAssigned {
/// Public ticket reference (`#N`).
issue_ref: String,
/// Assigned agent id.
agent_id: String,
/// New optimistic version.
version: u64,
},
/// An agent was unassigned from a public ticket.
#[serde(rename_all = "camelCase")]
IssueAgentUnassigned {
/// Public ticket reference (`#N`).
issue_ref: String,
/// Unassigned agent id.
agent_id: String,
/// New optimistic version.
version: u64,
},
/// An orchestrator request was processed on behalf of a requester agent.
#[serde(rename_all = "camelCase")]
OrchestratorRequestProcessed {
@ -312,6 +401,34 @@ impl From<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 {
fn from(e: &DomainEvent) -> Self {
match e {
@ -400,6 +517,79 @@ impl From<&DomainEvent> for DomainEventDto {
DomainEvent::GitStateChanged { project_id } => Self::GitStateChanged {
project_id: project_id.to_string(),
},
DomainEvent::IssueCreated {
issue_id,
issue_ref,
} => Self::IssueCreated {
issue_id: issue_id.to_string(),
issue_ref: issue_ref.to_string(),
},
DomainEvent::IssueUpdated { issue_ref, version } => Self::IssueUpdated {
issue_ref: issue_ref.to_string(),
version: version.get(),
},
DomainEvent::IssueStatusChanged {
issue_ref,
status,
version,
} => Self::IssueStatusChanged {
issue_ref: issue_ref.to_string(),
status: issue_status_wire(*status).to_owned(),
version: version.get(),
},
DomainEvent::IssuePriorityChanged {
issue_ref,
priority,
version,
} => Self::IssuePriorityChanged {
issue_ref: issue_ref.to_string(),
priority: issue_priority_wire(*priority).to_owned(),
version: version.get(),
},
DomainEvent::IssueCarnetUpdated { issue_ref, version } => Self::IssueCarnetUpdated {
issue_ref: issue_ref.to_string(),
version: version.get(),
},
DomainEvent::IssueLinked {
issue_ref,
target,
kind,
version,
} => Self::IssueLinked {
issue_ref: issue_ref.to_string(),
target: target.to_string(),
kind: issue_link_kind_wire(*kind).to_owned(),
version: version.get(),
},
DomainEvent::IssueUnlinked {
issue_ref,
target,
kind,
version,
} => Self::IssueUnlinked {
issue_ref: issue_ref.to_string(),
target: target.to_string(),
kind: issue_link_kind_wire(*kind).to_owned(),
version: version.get(),
},
DomainEvent::IssueAgentAssigned {
issue_ref,
agent_id,
version,
} => Self::IssueAgentAssigned {
issue_ref: issue_ref.to_string(),
agent_id: agent_id.to_string(),
version: version.get(),
},
DomainEvent::IssueAgentUnassigned {
issue_ref,
agent_id,
version,
} => Self::IssueAgentUnassigned {
issue_ref: issue_ref.to_string(),
agent_id: agent_id.to_string(),
version: version.get(),
},
DomainEvent::OrchestratorRequestProcessed {
requester_id,
action,

View File

@ -21,6 +21,7 @@ pub mod mcp_bridge;
pub mod mcp_endpoint;
pub mod pty;
pub mod state;
pub mod tickets;
use std::process::ExitCode;
@ -163,6 +164,15 @@ pub fn run() {
commands::dismiss_embedder_suggestion,
commands::create_agent,
commands::list_agents,
tickets::ticket_create,
tickets::ticket_read,
tickets::ticket_list,
tickets::ticket_update,
tickets::ticket_read_carnet,
tickets::ticket_update_carnet,
tickets::ticket_link,
tickets::ticket_unlink,
tickets::ticket_assign,
commands::get_project_work_state,
commands::read_conversation_page,
commands::list_live_agents,

View File

@ -12,35 +12,36 @@ use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use application::{
AgentResumer, AppError, AssignSkillToAgent, AttachLiveAgent, ChangeAgentProfile,
CheckEmbedderSuggestion, CloseProject, CloseTab, CloseTerminal, ConfigureProfiles,
ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate, CreateLayout,
CreateMemory, CreateProject, CreateSkill, CreateTemplate, DeleteAgent, DeleteEmbedderProfile,
DeleteLayout, DeleteMemory, DeleteProfile, DeleteSkill, DeleteTemplate,
DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion,
FirstRunState, GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectWorkState,
GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus,
GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation, LaunchAgent,
LaunchAgentInput, ListAgents, ListAgentsInput, ListEmbedderProfiles, ListLayouts, ListMemories,
ListProfiles, ListProjects, ListResumableAgents, ListSkills, ListTemplates, LiveAgentRegistry,
LiveSessions, LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout,
McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
OrchestratorService, PermissionProjectorRegistry, ProposeContext, ReadAgentContext,
ReadContext, ReadConversationPage, ReadMemory, ReadMemoryIndex, ReadProjectContext, ReadSkill,
RecallMemory, ReconcileLayouts, ReconcileLiveState, ReconcileLiveStateInput, RecordTurn,
RecordTurnProvider, ReferenceProfiles, RenameLayout, ResizeTerminal, ResolveAgentPermissions,
ResolveMemoryLinks, RotateConversationLog, SaveEmbedderProfile, SaveProfile,
SessionLimitService, SetActiveLayout, SnapshotRunningAgents, StopLiveAgent, StructuredSessions,
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent,
UpdateAgentContext, UpdateAgentPermissions, UpdateLiveState, UpdateMemory,
UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate, WriteMemory,
WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
AgentResumer, AppError, AssignIssueAgent, AssignSkillToAgent, AttachLiveAgent,
ChangeAgentProfile, CheckEmbedderSuggestion, CloseProject, CloseTab, CloseTerminal,
ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate,
CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateTemplate,
DeleteAgent, DeleteEmbedderProfile, DeleteLayout, DeleteMemory, DeleteProfile, DeleteSkill,
DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles,
DismissEmbedderSuggestion, FirstRunState, GetLiveStateLean, GetMemory, GetProjectPermissions,
GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage,
GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation, LaunchAgent,
LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListEmbedderProfiles, ListIssues,
ListLayouts, ListMemories, ListProfiles, ListProjects, ListResumableAgents, ListSkills,
ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, LiveStateProvider,
LiveStateReadProvider, LoadLayout, McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView,
OpenProject, OpenTerminal, OrchestratorService, PermissionProjectorRegistry, ProposeContext,
ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory,
ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts,
ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles,
RenameLayout, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks,
RotateConversationLog, SaveEmbedderProfile, SaveProfile, SessionLimitService, SetActiveLayout,
SnapshotRunningAgents, StopLiveAgent, StructuredSessions, SuggestedThisSession,
SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UnlinkIssues,
UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState,
UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate,
WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
};
use domain::ports::{
AgentContextStore, AgentRuntime, AgentSessionFactory, Clock, Embedder, EmbedderEnvInspector,
EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator,
MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner, ProfileStore, ProjectStore,
PtyPort, ScheduledTask, Scheduler, SkillStore, TemplateStore,
IssueNumberAllocator, IssueStore, MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner,
ProfileStore, ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, TemplateStore,
};
use domain::profile::{
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
@ -54,19 +55,21 @@ use infrastructure::{
embedder_from_profile, AdaptiveMemoryRecall, ClaudePermissionProjector,
ClaudeTranscriptInspector, CliAgentRuntime, CodexPermissionProjector, EmbedderEnvProbe,
FsConversationLog, FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore,
FsLiveStateStore, FsMemoryStore, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore,
FsProjectStore, FsProviderSessionStore, FsSkillStore, FsTemplateStore, Git2Repository,
HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory,
SystemClock, SystemMillisClock, TokioBroadcastEventBus, TokioScheduler, UuidGenerator,
VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsMemoryStore, FsOrchestratorWatcher,
FsPermissionStore, FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSkillStore,
FsTemplateStore, Git2Repository, HeuristicHandoffSummarizer, IdeaiContextStore,
InMemoryConversationRegistry, InMemoryMailbox, LocalFileSystem, LocalProcessSpawner, McpServer,
MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard,
StructuredSessionFactory, SystemClock, SystemMillisClock, TicketToolProvider,
TokioBroadcastEventBus, TokioScheduler, UuidGenerator, VectorMemoryRecall,
DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED,
VECTOR_ONNX_ENABLED,
};
use crate::chat::ChatBridge;
use crate::mcp_endpoint::{mcp_endpoint, AppMcpRuntimeProvider, McpEndpoint};
use crate::pty::PtyBridge;
use crate::tickets::AppTicketToolProvider;
use infrastructure::StdioTransport;
@ -442,6 +445,26 @@ pub struct AppState {
pub terminal_sessions: Arc<TerminalSessions>,
/// The domain event bus (also handed to the event relay).
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).
pub pty_bridge: Arc<PtyBridge>,
/// Registre des sessions structurées (IA / cellules chat, §17.5). Partagé avec
@ -795,6 +818,62 @@ impl AppState {
let contexts = Arc::new(IdeaiContextStore::new(Arc::clone(&fs_port)));
let contexts_port = Arc::clone(&contexts) as Arc<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) ---
let permission_store = Arc::new(FsPermissionStore::new(Arc::clone(&fs_port)));
let permission_store_port = Arc::clone(&permission_store) as Arc<dyn PermissionStore>;
@ -1500,6 +1579,16 @@ impl AppState {
pty_port,
terminal_sessions,
event_bus,
create_issue,
read_issue,
list_issues,
update_issue,
read_issue_carnet,
update_issue_carnet,
link_issues,
unlink_issues,
assign_issue_agent,
ticket_tool_provider,
pty_bridge,
structured_sessions,
chat_bridge,
@ -1654,7 +1743,8 @@ impl AppState {
let handle = McpServerHandle::start(
McpServer::new(Arc::clone(&self.orchestrator_service), project.clone())
.with_events(events)
.with_ready_sink(ready_sink),
.with_ready_sink(ready_sink)
.with_ticket_tools(Arc::clone(&self.ticket_tool_provider)),
endpoint,
listener,
project_id,
@ -3343,6 +3433,7 @@ mod mcp_serve_peer_tests {
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
for expected in [
"idea_list_agents",
"idea_ask_agent",
"idea_launch_agent",
"idea_stop_agent",
"idea_update_context",
@ -3357,18 +3448,28 @@ mod mcp_serve_peer_tests {
// Live-state (programme live-state, lot LS4).
"idea_workstate_read",
"idea_workstate_set",
// Public ticket tools (Issue domain).
"idea_ticket_create",
"idea_ticket_read",
"idea_ticket_list",
"idea_ticket_update",
"idea_ticket_update_status",
"idea_ticket_update_priority",
"idea_ticket_read_carnet",
"idea_ticket_update_carnet",
"idea_ticket_link",
"idea_ticket_unlink",
] {
assert!(
names.contains(&expected),
"missing tool {expected}; got {names:?}"
);
}
assert!(!names.contains(&"idea_ask_agent"));
assert!(!names.contains(&"idea_reply"));
assert_eq!(
tools.len(),
12,
"exactly the twelve non-conversation idea_* tools; got {names:?}"
23,
"exactly the twenty-three exposed idea_* tools; got {names:?}"
);
drop(client); // EOF ⇒ serve loop ends

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,7 @@ use domain::ports::{
AgentSessionError, EmbedderError, FsError, GitError, MemoryError, ProcessError, PtyError,
RemoteError, RuntimeError, StoreError,
};
use domain::IssueStoreError;
use domain::{AgentId, NodeId};
/// Errors surfaced by application use cases.
@ -128,6 +129,17 @@ impl From<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 {
fn from(e: MemoryError) -> Self {
match e {

View 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(&current);
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,
});
}
}

View File

@ -18,6 +18,7 @@ pub mod embedder;
pub mod error;
pub mod git;
pub mod health;
pub mod issues;
pub mod layout;
pub mod memory;
pub mod orchestrator;
@ -66,6 +67,14 @@ pub use git::{
GitStatusInput, GitStatusOutput, GitUnstage,
};
pub use health::{HealthInput, HealthReport, HealthUseCase};
pub use issues::{
AssignIssueAgent, AssignIssueAgentInput, AssignIssueAgentOutput, CreateIssue, CreateIssueInput,
CreateIssueOutput, LinkIssues, LinkIssuesInput, LinkIssuesOutput, ListIssues, ListIssuesInput,
ListIssuesOutput, ReadIssue, ReadIssueCarnet, ReadIssueCarnetInput, ReadIssueCarnetOutput,
ReadIssueInput, ReadIssueOutput, UnlinkIssues, UnlinkIssuesInput, UpdateIssue,
UpdateIssueCarnet, UpdateIssueCarnetInput, UpdateIssueCarnetOutput, UpdateIssueInput,
UpdateIssueOutput,
};
pub use layout::{
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,
DeleteLayoutOutput, LayoutInfo, LayoutKind, LayoutOperation, LayoutsDoc, ListLayouts,

View 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"));
}

View File

@ -1,7 +1,8 @@
//! Domain events published on the [`crate::ports::EventBus`] and relayed to the
//! presentation layer (ARCHITECTURE §3.2).
use crate::ids::{AgentId, ProfileId, ProjectId, SessionId, SkillId, TemplateId};
use crate::ids::{AgentId, IssueId, ProfileId, ProjectId, SessionId, SkillId, TemplateId};
use crate::issue::{IssueLinkKind, IssuePriority, IssueRef, IssueStatus, IssueVersion};
use crate::mailbox::TicketId;
use crate::memory::MemorySlug;
use crate::template::TemplateVersion;
@ -168,6 +169,85 @@ pub enum DomainEvent {
/// The project whose index was rebuilt.
project_id: ProjectId,
},
/// An issue was created.
IssueCreated {
/// Stable issue id.
issue_id: IssueId,
/// Human-friendly issue reference.
issue_ref: IssueRef,
},
/// An issue was updated.
IssueUpdated {
/// Human-friendly issue reference.
issue_ref: IssueRef,
/// New optimistic version.
version: IssueVersion,
},
/// An issue status changed.
IssueStatusChanged {
/// Human-friendly issue reference.
issue_ref: IssueRef,
/// New status.
status: IssueStatus,
/// New optimistic version.
version: IssueVersion,
},
/// An issue priority changed.
IssuePriorityChanged {
/// Human-friendly issue reference.
issue_ref: IssueRef,
/// New priority.
priority: IssuePriority,
/// New optimistic version.
version: IssueVersion,
},
/// An issue carnet was updated.
IssueCarnetUpdated {
/// Human-friendly issue reference.
issue_ref: IssueRef,
/// New optimistic version.
version: IssueVersion,
},
/// Two issues were linked.
IssueLinked {
/// Source issue.
issue_ref: IssueRef,
/// Target issue.
target: IssueRef,
/// Link kind.
kind: IssueLinkKind,
/// New optimistic version.
version: IssueVersion,
},
/// Two issues were unlinked.
IssueUnlinked {
/// Source issue.
issue_ref: IssueRef,
/// Target issue.
target: IssueRef,
/// Link kind.
kind: IssueLinkKind,
/// New optimistic version.
version: IssueVersion,
},
/// An agent was assigned to an issue.
IssueAgentAssigned {
/// Human-friendly issue reference.
issue_ref: IssueRef,
/// Assigned agent.
agent_id: AgentId,
/// New optimistic version.
version: IssueVersion,
},
/// An agent was unassigned from an issue.
IssueAgentUnassigned {
/// Human-friendly issue reference.
issue_ref: IssueRef,
/// Unassigned agent.
agent_id: AgentId,
/// New optimistic version.
version: IssueVersion,
},
/// A project's memory grew past the recall budget while no embedder is
/// configured (strategy `none`): the first moment a semantic embedder would
/// help (ARCHITECTURE §14.5.5, LOT C3). Published **at most once per session per

View File

@ -72,6 +72,10 @@ typed_id!(
/// Identifies a [`crate::skill::Skill`].
SkillId
);
typed_id!(
/// Identifies a [`crate::issue::Issue`].
IssueId
);
typed_id!(
/// Identifies a [`crate::terminal::TerminalSession`].
SessionId

454
crates/domain/src/issue.rs Normal file
View 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,
},
}

View File

@ -39,6 +39,7 @@ pub mod fileguard;
pub mod git;
pub mod ids;
pub mod input;
pub mod issue;
pub mod layout;
pub mod live_state;
pub mod mailbox;
@ -67,8 +68,8 @@ mod validation;
pub use error::DomainError;
pub use ids::{
AgentId, LayoutId, NodeId, ProfileId, ProjectId, ScheduleId, SessionId, SkillId, TabId,
TemplateId, WindowId,
AgentId, IssueId, LayoutId, NodeId, ProfileId, ProjectId, ScheduleId, SessionId, SkillId,
TabId, TemplateId, WindowId,
};
pub use project::{Project, ProjectPath};
@ -96,6 +97,12 @@ pub use conversation::{
pub use input::{AgentBusyState, AgentLiveness, InputMediator, InputSource};
pub use issue::{
AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueError, IssueIndexEntry,
IssueLink, IssueLinkKind, IssueListFilter, IssueNumber, IssuePriority, IssueRef, IssueStatus,
IssueVersion,
};
pub use live_state::{LiveEntry, LiveState, WorkStatus, FIELD_MAX_BYTES, FIELD_PREVIEW_MAX_CHARS};
pub use readiness::{ReadinessPolicy, ReadinessSignal};
@ -158,8 +165,9 @@ pub use ports::{
EmbedderEnvInspector, EmbedderEnvReport, EmbedderError, EmbedderProfileStore,
EmbedderPromptDismissal, EmbedderPromptStore, EventBus, EventStream, ExitStatus, FileSystem,
FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator,
LiveStateStore, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream,
PermissionStore, PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore,
PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, ScheduledTask,
Scheduler, SpawnSpec, StoreError, TemplateStore,
IssueNumberAllocator, IssueStore, IssueStoreError, LiveStateStore, MemoryError, MemoryQuery,
MemoryRecall, MemoryStore, Output, OutputStream, PermissionStore, PreparedContext,
ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort,
RemoteError, RemoteHost, RemotePath, RuntimeError, ScheduledTask, Scheduler, SpawnSpec,
StoreError, TemplateStore,
};

View File

@ -30,6 +30,9 @@ use thiserror::Error;
use crate::agent::AgentManifest;
use crate::events::DomainEvent;
use crate::ids::{AgentId, NodeId, ScheduleId, SessionId};
use crate::issue::{
Issue, IssueCarnet, IssueIndexEntry, IssueListFilter, IssueNumber, IssueRef, IssueVersion,
};
use crate::markdown::MarkdownDoc;
use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
use crate::permission::ProjectPermissions;
@ -461,6 +464,28 @@ pub enum GitError {
Operation(String),
}
/// Errors from the issue store.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum IssueStoreError {
/// The requested issue does not exist.
#[error("issue not found")]
NotFound,
/// Optimistic concurrency conflict.
#[error("issue version conflict: expected {expected}, actual {actual}")]
VersionConflict {
/// Expected version.
expected: IssueVersion,
/// Actual persisted version.
actual: IssueVersion,
},
/// The issue payload is invalid.
#[error("issue invalid: {0}")]
Invalid(String),
/// Store I/O or serialization failed.
#[error("issue store failed: {0}")]
Store(String),
}
// ---------------------------------------------------------------------------
// Git port support types
// ---------------------------------------------------------------------------
@ -1191,6 +1216,84 @@ pub trait PermissionStore: Send + Sync {
) -> Result<(), StoreError>;
}
/// Allocates monotonic per-project issue numbers.
#[async_trait]
pub trait IssueNumberAllocator: Send + Sync {
/// Allocates the next number for `root`.
///
/// Implementations must never reuse an allocated number, even if a later
/// issue creation step fails.
///
/// # Errors
/// [`IssueStoreError`] on persistence/lock failure.
async fn allocate_next(&self, root: &ProjectPath) -> Result<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
/// [`crate::git::GitRepository`] *entity* (state image).
#[async_trait]

View 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);
}

View 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")
}

View File

@ -22,6 +22,7 @@ pub mod git;
pub mod id;
pub mod input;
pub mod inspector;
pub mod issues;
pub mod mailbox;
pub mod orchestrator;
pub mod permission;
@ -51,8 +52,11 @@ pub use input::{MediatedInbox, MillisClock, SystemMillisClock};
pub use inspector::{
transcript_activity_token, ClaudeTranscriptInspector, ClaudeTranscriptTurnWatcher,
};
pub use issues::{FsIssueNumberAllocator, FsIssueStore};
pub use mailbox::InMemoryMailbox;
pub use orchestrator::mcp::{McpServer, MemoryTransport, StdioTransport};
pub use orchestrator::mcp::{
McpServer, MemoryTransport, StdioTransport, TicketToolError, TicketToolProvider,
};
pub use orchestrator::{
process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle,
REQUESTS_SUBDIR,

View File

@ -30,6 +30,7 @@
pub mod jsonrpc;
pub mod server;
pub mod tickets;
pub mod tools;
pub mod transport;
@ -37,5 +38,6 @@ pub use jsonrpc::{
JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, JSONRPC_VERSION,
};
pub use server::McpServer;
pub use tickets::{TicketToolError, TicketToolProvider};
pub use tools::{catalogue, map_tool_call, tool_returns_reply, ToolDef, ToolMapError};
pub use transport::{MemoryTransport, StdioTransport};

View File

@ -28,6 +28,7 @@ use super::jsonrpc::{
error_codes, JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError,
JSONRPC_VERSION,
};
use super::tickets::TicketToolProvider;
use super::tools::{self, ToolMapError};
/// The MCP protocol version this server speaks (advertised on `initialize`).
@ -61,6 +62,9 @@ pub struct McpServer {
/// (fix race cold-launch, signal MCP). L'infra ne connaît pas `AgentId` : la
/// composition root parse le `requester`. `None` ⇒ no-op.
ready_sink: Option<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 {
@ -75,6 +79,7 @@ impl McpServer {
events: None,
requester: String::new(),
ready_sink: None,
ticket_tools: None,
}
}
@ -99,6 +104,13 @@ impl McpServer {
self
}
/// Attaches the public ticket tool provider.
#[must_use]
pub fn with_ticket_tools(mut self, ticket_tools: Arc<dyn TicketToolProvider>) -> Self {
self.ticket_tools = Some(ticket_tools);
self
}
/// Returns a per-connection clone of this server tagged with the connected
/// peer's `requester` id (the loopback handshake's `requester`, cadrage v5 §1.4).
///
@ -115,6 +127,7 @@ impl McpServer {
events: self.events.clone(),
requester: requester.into(),
ready_sink: self.ready_sink.clone(),
ticket_tools: self.ticket_tools.clone(),
}
}
@ -333,6 +346,44 @@ impl McpServer {
task_len={task_len}",
);
if tools::is_ticket_tool(&name) {
let result = match &self.ticket_tools {
Some(provider) => {
provider
.handle_ticket_tool(&self.project, &self.requester, &name, arguments)
.await
}
None => Err(super::tickets::TicketToolError::new(
"notConfigured",
"ticket tools are not configured",
)),
};
self.publish_processed(&name, result.is_ok());
return match result {
Ok(value) => {
let text = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_owned());
application::diag!(
"[mcp] tools_call end tool={name} requester={requester_label} \
target={arg_target} ok=true is_error=false result_len={} elapsed_ms={}",
text.len(),
started.elapsed().as_millis(),
);
Ok(tool_result_text(&text, false))
}
Err(err) => {
let text =
serde_json::to_string(&err.to_value()).unwrap_or_else(|_| err.to_string());
application::diag!(
"[mcp] tools_call end tool={name} requester={requester_label} \
target={arg_target} ok=false is_error=true result_len={} elapsed_ms={}",
text.len(),
started.elapsed().as_millis(),
);
Ok(tool_result_text(&text, true))
}
};
}
// The handshake-provided requester is still passed to the mapper for tools that
// need peer identity.
let command = match tools::map_tool_call(&name, &arguments, &self.requester) {

View 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
}),
},
]
}

View File

@ -61,7 +61,13 @@ pub fn tool_returns_reply(tool: &str) -> bool {
| "idea_memory_read"
| "idea_skill_read"
| "idea_workstate_read"
)
) || is_ticket_tool(tool)
}
/// Whether `tool` is a public ticket MCP tool.
#[must_use]
pub fn is_ticket_tool(tool: &str) -> bool {
super::tickets::is_ticket_tool(tool)
}
/// The full catalogue advertised on `tools/list`.
@ -70,7 +76,7 @@ pub fn tool_returns_reply(tool: &str) -> bool {
/// [`OrchestratorCommand`].
#[must_use]
pub fn catalogue() -> Vec<ToolDef> {
vec![
let mut tools = vec![
ToolDef {
name: "idea_list_agents",
description: "List the IdeA agents declared in the project's manifest. Returns the \
@ -255,7 +261,9 @@ pub fn catalogue() -> Vec<ToolDef> {
"additionalProperties": false
}),
},
]
];
tools.extend(super::tickets::catalogue());
tools
}
/// Maps an MCP `tools/call` (`name` + `arguments`) to a validated

View 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);
}

View File

@ -500,6 +500,17 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
// Live-state tools (programme live-state, lot LS4).
"idea_workstate_read",
"idea_workstate_set",
// Public ticket tools (Issue domain).
"idea_ticket_create",
"idea_ticket_read",
"idea_ticket_list",
"idea_ticket_update",
"idea_ticket_update_status",
"idea_ticket_update_priority",
"idea_ticket_read_carnet",
"idea_ticket_update_carnet",
"idea_ticket_link",
"idea_ticket_unlink",
] {
assert!(
names.contains(&expected),
@ -509,8 +520,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
assert!(!names.contains(&"idea_reply"));
assert_eq!(
tools.len(),
13,
"exactly the thirteen exposed idea_* tools; got {names:?}"
23,
"exactly the twenty-three exposed idea_* tools; got {names:?}"
);
// Every tool advertises an object input schema.