feat(sprints): modèle de sprints — domaine, use-cases, persistance et surfaces (backend)

Ticket #10 — introduction du modèle de sprints côté backend.

- Domaine : nouvel agrégat Sprint (sprint.rs), IDs, événements et invariants ;
  rattachement des issues à un sprint (issue.rs) et ports associés.
- Application : use-cases sprints (application/src/sprints) + erreurs dédiées.
- Infrastructure : store de sprints (infrastructure/src/sprints.rs), adaptation
  du store d'issues et exposition MCP via orchestrator/mcp/tickets.rs.
- app-tauri : commandes, state et events pour piloter les sprints depuis l'UI.

Tests domaine/application/infra/app-tauri verts (sprint_usecases, sprint_store,
issue_store, mcp_server).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 10:57:33 +02:00
parent 9740149ebb
commit 48b652ff91
21 changed files with 2896 additions and 45 deletions

View File

@ -299,6 +299,52 @@ pub enum DomainEventDto {
/// New optimistic version.
version: u64,
},
/// A sprint was created.
#[serde(rename_all = "camelCase")]
SprintCreated {
/// Stable sprint id.
sprint_id: String,
/// Reorderable order.
order: u32,
},
/// A sprint was renamed.
#[serde(rename_all = "camelCase")]
SprintRenamed {
/// Stable sprint id.
sprint_id: String,
/// New name.
name: String,
/// New optimistic version.
version: u64,
},
/// A sprint was reordered.
#[serde(rename_all = "camelCase")]
SprintReordered {
/// Stable sprint id.
sprint_id: String,
/// New order.
order: u32,
/// New optimistic version.
version: u64,
},
/// A sprint was deleted.
#[serde(rename_all = "camelCase")]
SprintDeleted {
/// Stable sprint id.
sprint_id: String,
},
/// A ticket changed sprint membership.
#[serde(rename_all = "camelCase")]
IssueSprintChanged {
/// Public ticket reference (`#N`).
issue_ref: String,
/// Previous sprint id.
from: Option<String>,
/// New sprint id.
to: Option<String>,
/// New optimistic version.
version: u64,
},
/// An orchestrator request was processed on behalf of a requester agent.
#[serde(rename_all = "camelCase")]
OrchestratorRequestProcessed {
@ -773,6 +819,42 @@ impl From<&DomainEvent> for DomainEventDto {
agent_id: agent_id.to_string(),
version: version.get(),
},
DomainEvent::SprintCreated { sprint_id, order } => Self::SprintCreated {
sprint_id: sprint_id.to_string(),
order: order.get(),
},
DomainEvent::SprintRenamed {
sprint_id,
name,
version,
} => Self::SprintRenamed {
sprint_id: sprint_id.to_string(),
name: name.clone(),
version: version.get(),
},
DomainEvent::SprintReordered {
sprint_id,
order,
version,
} => Self::SprintReordered {
sprint_id: sprint_id.to_string(),
order: order.get(),
version: version.get(),
},
DomainEvent::SprintDeleted { sprint_id } => Self::SprintDeleted {
sprint_id: sprint_id.to_string(),
},
DomainEvent::IssueSprintChanged {
issue_ref,
from,
to,
version,
} => Self::IssueSprintChanged {
issue_ref: issue_ref.to_string(),
from: from.map(|id| id.to_string()),
to: to.map(|id| id.to_string()),
version: version.get(),
},
DomainEvent::OrchestratorRequestProcessed {
requester_id,
action,
@ -981,4 +1063,22 @@ mod tests {
assert_eq!(json["agentId"], agent(11).to_string());
assert_eq!(json["resetsAtMs"], 1_700_000_000_000_i64);
}
#[test]
fn issue_sprint_changed_relays_to_dto_and_wire() {
let from = domain::SprintId::from_uuid(uuid::Uuid::from_u128(41));
let to = domain::SprintId::from_uuid(uuid::Uuid::from_u128(42));
let dto = DomainEventDto::from(&DomainEvent::IssueSprintChanged {
issue_ref: domain::IssueRef::from(domain::IssueNumber::new(7).unwrap()),
from: Some(from),
to: Some(to),
version: domain::IssueVersion::new(3).unwrap(),
});
let json = serde_json::to_value(&dto).expect("serialisable");
assert_eq!(json["type"], "issueSprintChanged");
assert_eq!(json["issueRef"], "#7");
assert_eq!(json["from"], from.to_string());
assert_eq!(json["to"], to.to_string());
assert_eq!(json["version"], 3);
}
}

View File

@ -173,6 +173,13 @@ pub fn run() {
tickets::ticket_link,
tickets::ticket_unlink,
tickets::ticket_assign,
tickets::sprint_create,
tickets::sprint_list,
tickets::sprint_rename,
tickets::sprint_reorder,
tickets::sprint_delete,
tickets::ticket_assign_sprint,
tickets::ticket_unassign_sprint,
commands::get_project_work_state,
commands::read_conversation_page,
commands::list_live_agents,

View File

@ -13,30 +13,32 @@ use std::sync::{Arc, Mutex};
use application::{
AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent,
AttachLiveAgent, BackgroundCommandArchive, CancelBackgroundTask, 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, RetryBackgroundTask,
RotateConversationLog, SaveEmbedderProfile, SaveProfile, SessionLimitService, SetActiveLayout,
SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent, StructuredSessions,
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent,
UnlinkIssues, UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet,
UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill,
UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
AssignTicketToSprint, AttachLiveAgent, BackgroundCommandArchive, CancelBackgroundTask,
ChangeAgentProfile, CheckEmbedderSuggestion, CloseProject, CloseTab, CloseTerminal,
ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate,
CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateSprint,
CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteLayout, DeleteMemory, DeleteProfile,
DeleteSkill, DeleteSprint, 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, ListSprints, 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,
RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks,
RetryBackgroundTask, RotateConversationLog, SaveEmbedderProfile, SaveProfile,
SessionLimitService, SetActiveLayout, SnapshotRunningAgents, SpawnBackgroundCommand,
StopLiveAgent, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate,
TerminalSessions, UnassignSkillFromAgent, UnassignTicketFromSprint, UnlinkIssues,
UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState,
UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate,
WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
};
use async_trait::async_trait;
use domain::ports::{
@ -45,7 +47,7 @@ use domain::ports::{
EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort,
IdGenerator, IssueNumberAllocator, IssueStore, MemoryRecall, MemoryStore, PermissionStore,
ProcessSpawner, ProfileStore, ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore,
TemplateStore, WakeError, WakeReason,
SprintStore, TemplateStore, WakeError, WakeReason,
};
use domain::profile::{
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
@ -66,7 +68,7 @@ use infrastructure::{
FsBackgroundTaskStore, FsConversationLog, FsEmbedderProfileStore, FsEmbedderPromptStore,
FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsMemoryStore,
FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, FsProjectStore,
FsProviderSessionStore, FsSkillStore, FsTemplateStore, Git2Repository,
FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore, Git2Repository,
HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory,
@ -804,6 +806,20 @@ pub struct AppState {
pub unlink_issues: Arc<UnlinkIssues>,
/// Assign or unassign an agent on a public ticket.
pub assign_issue_agent: Arc<AssignIssueAgent>,
/// Create a sprint.
pub create_sprint: Arc<CreateSprint>,
/// List sprints.
pub list_sprints: Arc<ListSprints>,
/// Rename a sprint.
pub rename_sprint: Arc<RenameSprint>,
/// Reorder sprints.
pub reorder_sprints: Arc<ReorderSprints>,
/// Delete a sprint.
pub delete_sprint: Arc<DeleteSprint>,
/// Assign a ticket to a sprint.
pub assign_ticket_to_sprint: Arc<AssignTicketToSprint>,
/// Unassign a ticket from its sprint.
pub unassign_ticket_from_sprint: Arc<UnassignTicketFromSprint>,
/// MCP provider for the public `idea_ticket_*` tools.
pub(crate) ticket_tool_provider: Arc<dyn TicketToolProvider>,
/// Generic PTY↔Channel bridge registry (consumed by L3).
@ -1213,6 +1229,45 @@ impl AppState {
Arc::clone(&clock) as Arc<dyn Clock>,
Arc::clone(&events_port),
));
let sprint_store = Arc::new(FsSprintStore::new());
let sprint_store_port = Arc::clone(&sprint_store) as Arc<dyn SprintStore>;
let create_sprint = Arc::new(CreateSprint::new(
Arc::clone(&sprint_store_port),
Arc::clone(&ids) as Arc<dyn IdGenerator>,
Arc::clone(&clock) as Arc<dyn Clock>,
Arc::clone(&events_port),
));
let list_sprints = Arc::new(ListSprints::new(
Arc::clone(&sprint_store_port),
Arc::clone(&issue_store_port),
));
let rename_sprint = Arc::new(RenameSprint::new(
Arc::clone(&sprint_store_port),
Arc::clone(&clock) as Arc<dyn Clock>,
Arc::clone(&events_port),
));
let reorder_sprints = Arc::new(ReorderSprints::new(
Arc::clone(&sprint_store_port),
Arc::clone(&clock) as Arc<dyn Clock>,
Arc::clone(&events_port),
));
let delete_sprint = Arc::new(DeleteSprint::new(
Arc::clone(&sprint_store_port),
Arc::clone(&issue_store_port),
Arc::clone(&clock) as Arc<dyn Clock>,
Arc::clone(&events_port),
));
let assign_ticket_to_sprint = Arc::new(AssignTicketToSprint::new(
Arc::clone(&sprint_store_port),
Arc::clone(&issue_store_port),
Arc::clone(&clock) as Arc<dyn Clock>,
Arc::clone(&events_port),
));
let unassign_ticket_from_sprint = Arc::new(UnassignTicketFromSprint::new(
Arc::clone(&issue_store_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),
@ -1222,6 +1277,7 @@ impl AppState {
update_carnet: Arc::clone(&update_issue_carnet),
link: Arc::clone(&link_issues),
unlink: Arc::clone(&unlink_issues),
list_sprints: Arc::clone(&list_sprints),
});
// --- Project permissions (LP1) ---
@ -2114,6 +2170,13 @@ impl AppState {
link_issues,
unlink_issues,
assign_issue_agent,
create_sprint,
list_sprints,
rename_sprint,
reorder_sprints,
delete_sprint,
assign_ticket_to_sprint,
unassign_ticket_from_sprint,
ticket_tool_provider,
pty_bridge,
structured_sessions,
@ -4194,6 +4257,7 @@ mod mcp_serve_peer_tests {
"idea_ticket_update_carnet",
"idea_ticket_link",
"idea_ticket_unlink",
"idea_sprint_list",
] {
assert!(
names.contains(&expected),
@ -4203,8 +4267,8 @@ mod mcp_serve_peer_tests {
assert!(!names.contains(&"idea_reply"));
assert_eq!(
tools.len(),
24,
"exactly the twenty-four exposed idea_* tools; got {names:?}"
25,
"exactly the twenty-five exposed idea_* tools; got {names:?}"
);
drop(client); // EOF ⇒ serve loop ends

View File

@ -14,14 +14,15 @@ use tauri::State;
use uuid::Uuid;
use application::{
AppError, AssignIssueAgentInput, CreateIssueInput, LinkIssuesInput, ListIssuesInput,
OpenProjectInput, ReadIssueCarnetInput, ReadIssueInput, UnlinkIssuesInput,
UpdateIssueCarnetInput, UpdateIssueInput,
AppError, AssignIssueAgentInput, AssignTicketToSprintInput, CreateIssueInput,
CreateSprintInput, DeleteSprintInput, LinkIssuesInput, ListIssuesInput, ListSprintsInput,
OpenProjectInput, ReadIssueCarnetInput, ReadIssueInput, RenameSprintInput, ReorderSprintsInput,
UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateIssueCarnetInput, UpdateIssueInput,
};
use domain::{
AgentId, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueIndexEntry, IssueLink,
IssueLinkKind, IssueListFilter, IssuePriority, IssueRef, IssueStatus, IssueVersion, Project,
ProjectId,
ProjectId, SprintId, SprintStatus, SprintVersion,
};
use infrastructure::{TicketToolError, TicketToolProvider};
@ -39,6 +40,9 @@ pub struct TicketDto {
pub description: String,
pub status: String,
pub priority: String,
pub sprint_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sprint: Option<TicketSprintContextDto>,
#[serde(skip_serializing_if = "Option::is_none")]
pub carnet: Option<String>,
pub links: Vec<TicketLinkDto>,
@ -59,6 +63,9 @@ pub struct TicketSummaryDto {
pub title: String,
pub status: String,
pub priority: String,
pub sprint_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sprint: Option<TicketSprintContextDto>,
pub assigned_agent_ids: Vec<String>,
pub updated_at: u64,
}
@ -72,6 +79,33 @@ pub struct TicketListDto {
pub next_cursor: Option<String>,
}
/// Sprint context embedded in MCP ticket read/list responses.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketSprintContextDto {
pub order: u32,
pub name: String,
}
/// Public sprint DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SprintDto {
pub id: String,
pub order: u32,
pub name: String,
pub status: String,
pub ticket_count: usize,
pub version: u64,
}
/// Public sprint list DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SprintListDto {
pub items: Vec<SprintDto>,
}
/// Public link DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -131,6 +165,7 @@ pub struct TicketListRequestDto {
pub status: Option<String>,
pub priority: Option<String>,
pub assigned_agent_id: Option<String>,
pub sprint_id: Option<String>,
pub text: Option<String>,
pub limit: Option<usize>,
pub cursor: Option<String>,
@ -206,6 +241,74 @@ pub struct TicketAssignRequestDto {
pub expected_version: u64,
}
/// Create sprint request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SprintCreateRequestDto {
#[serde(default)]
pub project_id: String,
pub name: String,
pub status: Option<String>,
}
/// List sprint request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SprintListRequestDto {
#[serde(default)]
pub project_id: String,
}
/// Rename sprint request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SprintRenameRequestDto {
#[serde(default)]
pub project_id: String,
pub sprint_id: String,
pub name: String,
pub expected_version: u64,
}
/// Reorder sprint request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SprintReorderRequestDto {
#[serde(default)]
pub project_id: String,
pub ordered_ids: Vec<String>,
}
/// Delete sprint request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SprintDeleteRequestDto {
#[serde(default)]
pub project_id: String,
pub sprint_id: String,
}
/// Assign ticket to sprint request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketSprintAssignRequestDto {
#[serde(default)]
pub project_id: String,
pub r#ref: String,
pub sprint_id: String,
pub expected_version: u64,
}
/// Unassign ticket from sprint request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketSprintUnassignRequestDto {
#[serde(default)]
pub project_id: String,
pub r#ref: String,
pub expected_version: u64,
}
#[derive(Clone)]
pub struct AppTicketToolProvider {
pub create: Arc<application::CreateIssue>,
@ -216,6 +319,7 @@ pub struct AppTicketToolProvider {
pub update_carnet: Arc<application::UpdateIssueCarnet>,
pub link: Arc<application::LinkIssues>,
pub unlink: Arc<application::UnlinkIssues>,
pub list_sprints: Arc<application::ListSprints>,
}
#[async_trait]
@ -259,7 +363,15 @@ impl TicketToolProvider for AppTicketToolProvider {
} else {
None
};
json!(TicketDto::from_issue(issue, carnet))
let sprints = self
.list_sprints
.execute(ListSprintsInput {
project: project.clone(),
})
.await
.map_err(ticket_error)?
.sprints;
json!(TicketDto::from_issue_with_sprints(issue, carnet, &sprints))
}
"idea_ticket_list" => {
let req = mcp_list_request(project, arguments)?;
@ -272,7 +384,26 @@ impl TicketToolProvider for AppTicketToolProvider {
.await
.map_err(ticket_error)?
.issues;
json!(paginate(rows, req.limit, req.cursor))
let sprints = self
.list_sprints
.execute(ListSprintsInput {
project: project.clone(),
})
.await
.map_err(ticket_error)?
.sprints;
json!(paginate_with_sprints(rows, req.limit, req.cursor, &sprints))
}
"idea_sprint_list" => {
let rows = self
.list_sprints
.execute(ListSprintsInput {
project: project.clone(),
})
.await
.map_err(ticket_error)?
.sprints;
json!(SprintListDto::from(rows))
}
"idea_ticket_update" => {
let req = mcp_update_request(project, arguments)?;
@ -476,6 +607,152 @@ pub async fn ticket_list(
Ok(paginate(rows, page.limit, page.cursor))
}
#[tauri::command]
pub async fn sprint_create(
request: SprintCreateRequestDto,
state: State<'_, AppState>,
) -> Result<SprintDto, ErrorDto> {
let project = resolve_project(&state, &request.project_id).await?;
let sprint = state
.create_sprint
.execute(CreateSprintInput {
project,
name: request.name,
status: request
.status
.as_deref()
.map(parse_sprint_status_dto)
.transpose()?,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?
.sprint;
Ok(SprintDto::from_sprint(sprint, 0))
}
#[tauri::command]
pub async fn sprint_list(
request: SprintListRequestDto,
state: State<'_, AppState>,
) -> Result<SprintListDto, ErrorDto> {
let project = resolve_project(&state, &request.project_id).await?;
let rows = state
.list_sprints
.execute(ListSprintsInput { project })
.await
.map_err(ErrorDto::from)?
.sprints;
Ok(SprintListDto::from(rows))
}
#[tauri::command]
pub async fn sprint_rename(
request: SprintRenameRequestDto,
state: State<'_, AppState>,
) -> Result<SprintDto, ErrorDto> {
let project = resolve_project(&state, &request.project_id).await?;
let sprint = state
.rename_sprint
.execute(RenameSprintInput {
project,
sprint_id: parse_sprint_id_dto(&request.sprint_id)?,
expected_version: sprint_version_dto(request.expected_version)?,
name: request.name,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?
.sprint;
Ok(SprintDto::from_sprint(sprint, 0))
}
#[tauri::command]
pub async fn sprint_reorder(
request: SprintReorderRequestDto,
state: State<'_, AppState>,
) -> Result<SprintListDto, ErrorDto> {
let project = resolve_project(&state, &request.project_id).await?;
state
.reorder_sprints
.execute(ReorderSprintsInput {
project: project.clone(),
ordered_ids: request
.ordered_ids
.iter()
.map(|id| parse_sprint_id_dto(id))
.collect::<Result<Vec<_>, _>>()?,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?;
sprint_list(
SprintListRequestDto {
project_id: project.id.to_string(),
},
state,
)
.await
}
#[tauri::command]
pub async fn sprint_delete(
request: SprintDeleteRequestDto,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let project = resolve_project(&state, &request.project_id).await?;
state
.delete_sprint
.execute(DeleteSprintInput {
project,
sprint_id: parse_sprint_id_dto(&request.sprint_id)?,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)
}
#[tauri::command]
pub async fn ticket_assign_sprint(
request: TicketSprintAssignRequestDto,
state: State<'_, AppState>,
) -> Result<TicketDto, ErrorDto> {
let project = resolve_project(&state, &request.project_id).await?;
let issue = state
.assign_ticket_to_sprint
.execute(AssignTicketToSprintInput {
project,
issue_ref: parse_ref_dto(&request.r#ref)?,
sprint_id: parse_sprint_id_dto(&request.sprint_id)?,
expected_version: version_dto(request.expected_version)?,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?
.issue;
Ok(TicketDto::from_issue(issue, None))
}
#[tauri::command]
pub async fn ticket_unassign_sprint(
request: TicketSprintUnassignRequestDto,
state: State<'_, AppState>,
) -> Result<TicketDto, ErrorDto> {
let project = resolve_project(&state, &request.project_id).await?;
let issue = state
.unassign_ticket_from_sprint
.execute(UnassignTicketFromSprintInput {
project,
issue_ref: parse_ref_dto(&request.r#ref)?,
expected_version: version_dto(request.expected_version)?,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?
.issue;
Ok(TicketDto::from_issue(issue, None))
}
#[tauri::command]
pub async fn ticket_update(
request: TicketUpdateRequestDto,
@ -616,6 +893,8 @@ impl TicketDto {
description: issue.description.as_str().to_owned(),
status: status_wire(issue.status).to_owned(),
priority: priority_wire(issue.priority).to_owned(),
sprint_id: issue.sprint.map(|id| id.to_string()),
sprint: None,
carnet,
links: issue.links.into_iter().map(TicketLinkDto::from).collect(),
assigned_agent_ids: issue
@ -631,6 +910,19 @@ impl TicketDto {
version: issue.version.get(),
}
}
fn from_issue_with_sprints(
issue: Issue,
carnet: Option<String>,
sprints: &[application::SprintListEntry],
) -> Self {
let sprint = issue
.sprint
.and_then(|id| ticket_sprint_context(id, sprints));
let mut dto = Self::from_issue(issue, carnet);
dto.sprint = sprint;
dto
}
}
impl From<IssueIndexEntry> for TicketSummaryDto {
@ -641,6 +933,8 @@ impl From<IssueIndexEntry> for TicketSummaryDto {
title: row.title,
status: status_wire(row.status).to_owned(),
priority: priority_wire(row.priority).to_owned(),
sprint_id: row.sprint.map(|id| id.to_string()),
sprint: None,
assigned_agent_ids: row
.assigned_agent_ids
.into_iter()
@ -651,6 +945,52 @@ impl From<IssueIndexEntry> for TicketSummaryDto {
}
}
impl TicketSummaryDto {
fn from_row_with_sprints(
row: IssueIndexEntry,
sprints: &[application::SprintListEntry],
) -> Self {
let sprint = row.sprint.and_then(|id| ticket_sprint_context(id, sprints));
let mut dto = Self::from(row);
dto.sprint = sprint;
dto
}
}
impl SprintDto {
fn from_sprint(sprint: domain::Sprint, ticket_count: usize) -> Self {
Self {
id: sprint.id.to_string(),
order: sprint.order.get(),
name: sprint.name,
status: sprint_status_wire(sprint.status).to_owned(),
ticket_count,
version: sprint.version.get(),
}
}
}
impl From<application::SprintListEntry> for SprintDto {
fn from(entry: application::SprintListEntry) -> Self {
Self {
id: entry.sprint.id.to_string(),
order: entry.sprint.order.get(),
name: entry.sprint.name,
status: sprint_status_wire(entry.sprint.status).to_owned(),
ticket_count: entry.ticket_count,
version: entry.sprint.version.get(),
}
}
}
impl From<Vec<application::SprintListEntry>> for SprintListDto {
fn from(items: Vec<application::SprintListEntry>) -> Self {
Self {
items: items.into_iter().map(SprintDto::from).collect(),
}
}
}
impl From<IssueLink> for TicketLinkDto {
fn from(link: IssueLink) -> Self {
Self {
@ -707,6 +1047,11 @@ impl TicketListPageInput {
.as_deref()
.map(parse_agent_id_dto)
.transpose()?,
sprint: request
.sprint_id
.as_deref()
.map(parse_sprint_id_dto)
.transpose()?,
text: request.text,
},
limit: request.limit.unwrap_or(100).clamp(1, 500),
@ -819,6 +1164,42 @@ fn paginate(rows: Vec<IssueIndexEntry>, limit: usize, cursor: Option<String>) ->
}
}
fn paginate_with_sprints(
rows: Vec<IssueIndexEntry>,
limit: usize,
cursor: Option<String>,
sprints: &[application::SprintListEntry],
) -> TicketListDto {
let start = cursor
.as_deref()
.and_then(|raw| raw.parse::<usize>().ok())
.unwrap_or(0);
let total = rows.len();
let end = total.min(start.saturating_add(limit));
TicketListDto {
items: rows
.into_iter()
.skip(start)
.take(limit)
.map(|row| TicketSummaryDto::from_row_with_sprints(row, sprints))
.collect(),
next_cursor: (end < total).then(|| end.to_string()),
}
}
fn ticket_sprint_context(
sprint_id: SprintId,
sprints: &[application::SprintListEntry],
) -> Option<TicketSprintContextDto> {
sprints
.iter()
.find(|entry| entry.sprint.id == sprint_id)
.map(|entry| TicketSprintContextDto {
order: entry.sprint.order.get(),
name: entry.sprint.name.clone(),
})
}
async fn read_carnet_body(
read_carnet: &application::ReadIssueCarnet,
project: &Project,
@ -853,10 +1234,20 @@ fn parse_agent_id_dto(raw: &str) -> Result<AgentId, ErrorDto> {
.map_err(|e| ErrorDto::invalid(format!("invalid agent id: {e}")))
}
fn parse_sprint_id_dto(raw: &str) -> Result<SprintId, ErrorDto> {
Uuid::parse_str(raw)
.map(SprintId::from_uuid)
.map_err(|e| ErrorDto::invalid(format!("invalid sprint id: {e}")))
}
fn version_dto(raw: u64) -> Result<IssueVersion, ErrorDto> {
IssueVersion::new(raw).map_err(|e| ErrorDto::invalid(e.to_string()))
}
fn sprint_version_dto(raw: u64) -> Result<SprintVersion, ErrorDto> {
SprintVersion::new(raw).map_err(|e| ErrorDto::invalid(e.to_string()))
}
fn parse_status_dto(raw: &str) -> Result<IssueStatus, ErrorDto> {
parse_status(raw).map_err(|e| ErrorDto::invalid(e.message))
}
@ -869,6 +1260,15 @@ fn parse_link_kind_dto(raw: &str) -> Result<IssueLinkKind, ErrorDto> {
parse_link_kind(raw).map_err(|e| ErrorDto::invalid(e.message))
}
fn parse_sprint_status_dto(raw: &str) -> Result<SprintStatus, ErrorDto> {
match raw {
"planned" => Ok(SprintStatus::Planned),
"active" => Ok(SprintStatus::Active),
"done" => Ok(SprintStatus::Done),
_ => Err(ErrorDto::invalid(format!("invalid sprint status: {raw}"))),
}
}
fn parse_status(raw: &str) -> Result<IssueStatus, TicketToolError> {
match raw {
"open" => Ok(IssueStatus::Open),
@ -937,6 +1337,14 @@ fn link_kind_wire(kind: IssueLinkKind) -> &'static str {
}
}
fn sprint_status_wire(status: SprintStatus) -> &'static str {
match status {
SprintStatus::Planned => "planned",
SprintStatus::Active => "active",
SprintStatus::Done => "done",
}
}
fn ticket_error(err: AppError) -> TicketToolError {
let message = err.to_string();
let code = if message.contains("issue version conflict") {

View File

@ -9,8 +9,8 @@ use domain::ports::{
AgentSessionError, EmbedderError, FsError, GitError, MemoryError, ProcessError, PtyError,
RemoteError, RuntimeError, StoreError,
};
use domain::IssueStoreError;
use domain::{AgentId, NodeId};
use domain::{IssueStoreError, SprintStoreError};
/// Errors surfaced by application use cases.
///
@ -140,6 +140,17 @@ impl From<IssueStoreError> for AppError {
}
}
impl From<SprintStoreError> for AppError {
fn from(e: SprintStoreError) -> Self {
match e {
SprintStoreError::NotFound => Self::NotFound("sprint".to_owned()),
SprintStoreError::VersionConflict { .. } => Self::Invalid(e.to_string()),
SprintStoreError::Invalid(message) => Self::Invalid(message),
SprintStoreError::Store(message) => Self::Store(message),
}
}
}
impl From<MemoryError> for AppError {
fn from(e: MemoryError) -> Self {
match e {

View File

@ -27,6 +27,7 @@ pub mod permission;
pub mod project;
pub mod remote;
pub mod skill;
pub mod sprints;
pub mod template;
pub mod terminal;
pub mod window;
@ -127,6 +128,13 @@ pub use skill::{
ReadSkillInput, UnassignSkillFromAgent, UnassignSkillFromAgentInput, UpdateSkill,
UpdateSkillInput, UpdateSkillOutput,
};
pub use sprints::{
normalized_reorder, AssignTicketToSprint, AssignTicketToSprintInput,
AssignTicketToSprintOutput, CreateSprint, CreateSprintInput, CreateSprintOutput, DeleteSprint,
DeleteSprintInput, ListSprints, ListSprintsInput, ListSprintsOutput, RenameSprint,
RenameSprintInput, ReorderSprints, ReorderSprintsInput, ReorderSprintsOutput, SprintListEntry,
SprintOutput, UnassignTicketFromSprint, UnassignTicketFromSprintInput,
};
pub use template::{
AgentDrift, CreateAgentFromTemplate, CreateAgentFromTemplateInput,
CreateAgentFromTemplateOutput, CreateTemplate, CreateTemplateInput, CreateTemplateOutput,

View File

@ -0,0 +1,615 @@
//! Sprint use cases.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use domain::{
Clock, DomainEvent, EventBus, IdGenerator, IssueActor, IssueListFilter, IssueRef, IssueStore,
IssueVersion, Project, Sprint, SprintId, SprintIndexEntry, SprintOrder, SprintStatus,
SprintStore, SprintVersion,
};
use crate::error::AppError;
/// Input for [`CreateSprint::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateSprintInput {
/// Project owning the sprint.
pub project: Project,
/// Sprint name.
pub name: String,
/// Initial status. Defaults to planned.
pub status: Option<SprintStatus>,
/// Actor creating the sprint.
pub actor: IssueActor,
}
/// Output of [`CreateSprint::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateSprintOutput {
/// Created sprint.
pub sprint: Sprint,
}
/// Creates a sprint at `max(order)+1`.
pub struct CreateSprint {
sprints: Arc<dyn SprintStore>,
ids: Arc<dyn IdGenerator>,
clock: Arc<dyn Clock>,
events: Arc<dyn EventBus>,
}
impl CreateSprint {
/// Builds the use case.
#[must_use]
pub fn new(
sprints: Arc<dyn SprintStore>,
ids: Arc<dyn IdGenerator>,
clock: Arc<dyn Clock>,
events: Arc<dyn EventBus>,
) -> Self {
Self {
sprints,
ids,
clock,
events,
}
}
/// Executes creation.
///
/// # Errors
/// [`AppError`] on invariant or store failure.
pub async fn execute(&self, input: CreateSprintInput) -> Result<CreateSprintOutput, AppError> {
let rows = self.sprints.list(&input.project.root).await?;
let next_order = rows.iter().map(|row| row.order.get()).max().unwrap_or(0) + 1;
let sprint = Sprint::new(
SprintId::from_uuid(self.ids.new_uuid()),
SprintOrder::new(next_order).map_err(|err| AppError::Invalid(err.to_string()))?,
input.name,
input.status,
input.actor,
now(&self.clock),
)
.map_err(|err| AppError::Invalid(err.to_string()))?;
self.sprints.create(&input.project.root, &sprint).await?;
self.events.publish(DomainEvent::SprintCreated {
sprint_id: sprint.id,
order: sprint.order,
});
Ok(CreateSprintOutput { sprint })
}
}
/// Input for [`ListSprints::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListSprintsInput {
/// Project owning the sprints.
pub project: Project,
}
/// Sprint list row enriched with ticket count.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SprintListEntry {
/// Sprint index projection.
pub sprint: SprintIndexEntry,
/// Number of tickets assigned to the sprint.
pub ticket_count: usize,
}
/// Output of [`ListSprints::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListSprintsOutput {
/// Sprints sorted by order.
pub sprints: Vec<SprintListEntry>,
}
/// Lists sprints with derived ticket counts.
pub struct ListSprints {
sprints: Arc<dyn SprintStore>,
issues: Arc<dyn IssueStore>,
}
impl ListSprints {
/// Builds the use case.
#[must_use]
pub fn new(sprints: Arc<dyn SprintStore>, issues: Arc<dyn IssueStore>) -> Self {
Self { sprints, issues }
}
/// Executes list.
///
/// # Errors
/// [`AppError`] on store failure.
pub async fn execute(&self, input: ListSprintsInput) -> Result<ListSprintsOutput, AppError> {
let rows = self.sprints.list(&input.project.root).await?;
let mut out = Vec::with_capacity(rows.len());
for row in rows {
let ticket_count = self
.issues
.list(
&input.project.root,
IssueListFilter {
sprint: Some(row.id),
..IssueListFilter::default()
},
)
.await?
.len();
out.push(SprintListEntry {
sprint: row,
ticket_count,
});
}
Ok(ListSprintsOutput { sprints: out })
}
}
/// Input for [`RenameSprint::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenameSprintInput {
/// Project owning the sprint.
pub project: Project,
/// Sprint id.
pub sprint_id: SprintId,
/// Expected optimistic version.
pub expected_version: SprintVersion,
/// New name.
pub name: String,
/// Actor updating the sprint.
pub actor: IssueActor,
}
/// Output for sprint mutations.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SprintOutput {
/// Updated sprint.
pub sprint: Sprint,
}
/// Renames a sprint.
pub struct RenameSprint {
sprints: Arc<dyn SprintStore>,
clock: Arc<dyn Clock>,
events: Arc<dyn EventBus>,
}
impl RenameSprint {
/// Builds the use case.
#[must_use]
pub fn new(
sprints: Arc<dyn SprintStore>,
clock: Arc<dyn Clock>,
events: Arc<dyn EventBus>,
) -> Self {
Self {
sprints,
clock,
events,
}
}
/// Executes rename.
///
/// # Errors
/// [`AppError`] on store failure or conflict.
pub async fn execute(&self, input: RenameSprintInput) -> Result<SprintOutput, AppError> {
let current = self
.sprints
.get(&input.project.root, input.sprint_id)
.await?;
let updated = current
.mutate(input.actor, now(&self.clock), |sprint| {
sprint.name = input.name;
})
.map_err(|err| AppError::Invalid(err.to_string()))?;
self.sprints
.update(&input.project.root, &updated, input.expected_version)
.await?;
self.events.publish(DomainEvent::SprintRenamed {
sprint_id: updated.id,
name: updated.name.clone(),
version: updated.version,
});
Ok(SprintOutput { sprint: updated })
}
}
/// Input for [`ReorderSprints::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReorderSprintsInput {
/// Project owning the sprints.
pub project: Project,
/// Complete ordered list of sprint ids.
pub ordered_ids: Vec<SprintId>,
/// Actor updating the sprints.
pub actor: IssueActor,
}
/// Output of [`ReorderSprints::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReorderSprintsOutput {
/// Updated sprints sorted by order.
pub sprints: Vec<Sprint>,
}
/// Reorders all sprints and normalises orders to 1..n.
pub struct ReorderSprints {
sprints: Arc<dyn SprintStore>,
clock: Arc<dyn Clock>,
events: Arc<dyn EventBus>,
}
impl ReorderSprints {
/// Builds the use case.
#[must_use]
pub fn new(
sprints: Arc<dyn SprintStore>,
clock: Arc<dyn Clock>,
events: Arc<dyn EventBus>,
) -> Self {
Self {
sprints,
clock,
events,
}
}
/// Executes reorder.
///
/// # Errors
/// [`AppError`] when ids are incomplete/duplicated or persistence fails.
pub async fn execute(
&self,
input: ReorderSprintsInput,
) -> Result<ReorderSprintsOutput, AppError> {
let rows = self.sprints.list(&input.project.root).await?;
let current_ids: HashSet<SprintId> = rows.iter().map(|row| row.id).collect();
let ordered_ids: HashSet<SprintId> = input.ordered_ids.iter().copied().collect();
if ordered_ids.len() != input.ordered_ids.len() || ordered_ids != current_ids {
return Err(AppError::Invalid(
"ordered sprint ids must contain every sprint exactly once".to_owned(),
));
}
let mut updated = Vec::with_capacity(input.ordered_ids.len());
for (idx, sprint_id) in input.ordered_ids.into_iter().enumerate() {
let current = self.sprints.get(&input.project.root, sprint_id).await?;
let expected_version = current.version;
let order = SprintOrder::new((idx as u32) + 1)
.map_err(|err| AppError::Invalid(err.to_string()))?;
let next = current
.mutate(input.actor.clone(), now(&self.clock), |sprint| {
sprint.order = order;
})
.map_err(|err| AppError::Invalid(err.to_string()))?;
self.sprints
.update(&input.project.root, &next, expected_version)
.await?;
self.events.publish(DomainEvent::SprintReordered {
sprint_id: next.id,
order: next.order,
version: next.version,
});
updated.push(next);
}
updated.sort_by_key(|sprint| sprint.order.get());
Ok(ReorderSprintsOutput { sprints: updated })
}
}
/// Input for [`DeleteSprint::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteSprintInput {
/// Project owning the sprint.
pub project: Project,
/// Sprint id.
pub sprint_id: SprintId,
/// Actor deleting the sprint and unassigning tickets.
pub actor: IssueActor,
}
/// Deletes a sprint and unassigns its tickets.
pub struct DeleteSprint {
sprints: Arc<dyn SprintStore>,
issues: Arc<dyn IssueStore>,
clock: Arc<dyn Clock>,
events: Arc<dyn EventBus>,
}
impl DeleteSprint {
/// Builds the use case.
#[must_use]
pub fn new(
sprints: Arc<dyn SprintStore>,
issues: Arc<dyn IssueStore>,
clock: Arc<dyn Clock>,
events: Arc<dyn EventBus>,
) -> Self {
Self {
sprints,
issues,
clock,
events,
}
}
/// Executes deletion.
///
/// # Errors
/// [`AppError`] on store failure or issue conflict.
pub async fn execute(&self, input: DeleteSprintInput) -> Result<(), AppError> {
self.sprints
.get(&input.project.root, input.sprint_id)
.await?;
let assigned = self
.issues
.list(
&input.project.root,
IssueListFilter {
sprint: Some(input.sprint_id),
..IssueListFilter::default()
},
)
.await?;
for row in assigned {
self.assign_issue(
&input.project,
row.issue_ref,
None,
input.actor.clone(),
now(&self.clock),
)
.await?;
}
self.sprints
.delete(&input.project.root, input.sprint_id)
.await?;
self.events.publish(DomainEvent::SprintDeleted {
sprint_id: input.sprint_id,
});
Ok(())
}
async fn assign_issue(
&self,
project: &Project,
issue_ref: IssueRef,
sprint_id: Option<SprintId>,
actor: IssueActor,
now_ms: u64,
) -> Result<(), AppError> {
let current = self.issues.get_by_ref(&project.root, issue_ref).await?;
let expected_version = current.version;
let from = current.sprint;
if from == sprint_id {
return Ok(());
}
let updated = current
.mutate(actor, now_ms, |issue| issue.sprint = sprint_id)
.map_err(|err| AppError::Invalid(err.to_string()))?;
self.issues
.update(&project.root, &updated, expected_version)
.await?;
publish_issue_sprint_changed(&self.events, issue_ref, from, sprint_id, updated.version);
Ok(())
}
}
/// Input for [`AssignTicketToSprint::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AssignTicketToSprintInput {
/// Project owning the issue.
pub project: Project,
/// Issue reference.
pub issue_ref: IssueRef,
/// Target sprint.
pub sprint_id: SprintId,
/// Expected issue optimistic version.
pub expected_version: IssueVersion,
/// Actor updating the issue.
pub actor: IssueActor,
}
/// Input for [`UnassignTicketFromSprint::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnassignTicketFromSprintInput {
/// Project owning the issue.
pub project: Project,
/// Issue reference.
pub issue_ref: IssueRef,
/// Expected issue optimistic version.
pub expected_version: IssueVersion,
/// Actor updating the issue.
pub actor: IssueActor,
}
/// Output of ticket/sprint assignment use cases.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AssignTicketToSprintOutput {
/// Updated issue.
pub issue: domain::Issue,
}
/// Assigns a ticket to a sprint via `IssueStore.update`.
pub struct AssignTicketToSprint {
sprints: Arc<dyn SprintStore>,
issues: Arc<dyn IssueStore>,
clock: Arc<dyn Clock>,
events: Arc<dyn EventBus>,
}
impl AssignTicketToSprint {
/// Builds the use case.
#[must_use]
pub fn new(
sprints: Arc<dyn SprintStore>,
issues: Arc<dyn IssueStore>,
clock: Arc<dyn Clock>,
events: Arc<dyn EventBus>,
) -> Self {
Self {
sprints,
issues,
clock,
events,
}
}
/// Executes assignment.
///
/// # Errors
/// [`AppError`] when the sprint/issue is missing or the issue version conflicts.
pub async fn execute(
&self,
input: AssignTicketToSprintInput,
) -> Result<AssignTicketToSprintOutput, AppError> {
self.sprints
.get(&input.project.root, input.sprint_id)
.await?;
assign_ticket_sprint(
&self.issues,
&self.clock,
&self.events,
input.project,
input.issue_ref,
Some(input.sprint_id),
input.expected_version,
input.actor,
)
.await
}
}
/// Unassigns a ticket from its sprint via `IssueStore.update`.
pub struct UnassignTicketFromSprint {
issues: Arc<dyn IssueStore>,
clock: Arc<dyn Clock>,
events: Arc<dyn EventBus>,
}
impl UnassignTicketFromSprint {
/// 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 unassignment.
///
/// # Errors
/// [`AppError`] when the issue is missing or its version conflicts.
pub async fn execute(
&self,
input: UnassignTicketFromSprintInput,
) -> Result<AssignTicketToSprintOutput, AppError> {
assign_ticket_sprint(
&self.issues,
&self.clock,
&self.events,
input.project,
input.issue_ref,
None,
input.expected_version,
input.actor,
)
.await
}
}
async fn assign_ticket_sprint(
issues: &Arc<dyn IssueStore>,
clock: &Arc<dyn Clock>,
events: &Arc<dyn EventBus>,
project: Project,
issue_ref: IssueRef,
sprint_id: Option<SprintId>,
expected_version: IssueVersion,
actor: IssueActor,
) -> Result<AssignTicketToSprintOutput, AppError> {
let current = issues.get_by_ref(&project.root, issue_ref).await?;
let from = current.sprint;
let updated = current
.mutate(actor, now(clock), |issue| issue.sprint = sprint_id)
.map_err(|err| AppError::Invalid(err.to_string()))?;
issues
.update(&project.root, &updated, expected_version)
.await?;
if from != sprint_id {
publish_issue_sprint_changed(events, issue_ref, from, sprint_id, updated.version);
}
Ok(AssignTicketToSprintOutput { issue: updated })
}
fn publish_issue_sprint_changed(
events: &Arc<dyn EventBus>,
issue_ref: IssueRef,
from: Option<SprintId>,
to: Option<SprintId>,
version: IssueVersion,
) {
events.publish(DomainEvent::IssueUpdated { issue_ref, version });
events.publish(DomainEvent::IssueSprintChanged {
issue_ref,
from,
to,
version,
});
}
fn now(clock: &Arc<dyn Clock>) -> u64 {
clock.now_millis().max(0) as u64
}
/// Returns sprints in the requested order with contiguous order values.
///
/// # Errors
/// [`AppError::Invalid`] when the requested ids are not a complete permutation.
pub fn normalized_reorder(
sprints: &[Sprint],
ordered_ids: &[SprintId],
actor: IssueActor,
now_ms: u64,
) -> Result<Vec<Sprint>, AppError> {
let by_id: HashMap<SprintId, Sprint> = sprints
.iter()
.cloned()
.map(|sprint| (sprint.id, sprint))
.collect();
if by_id.len() != sprints.len() || ordered_ids.len() != sprints.len() {
return Err(AppError::Invalid(
"ordered sprint ids must contain every sprint exactly once".to_owned(),
));
}
let mut seen = HashSet::new();
let mut out = Vec::with_capacity(ordered_ids.len());
for (idx, sprint_id) in ordered_ids.iter().copied().enumerate() {
if !seen.insert(sprint_id) {
return Err(AppError::Invalid(
"ordered sprint ids must contain every sprint exactly once".to_owned(),
));
}
let Some(current) = by_id.get(&sprint_id) else {
return Err(AppError::Invalid(
"ordered sprint ids must contain every sprint exactly once".to_owned(),
));
};
let order =
SprintOrder::new((idx as u32) + 1).map_err(|err| AppError::Invalid(err.to_string()))?;
out.push(
current
.clone()
.mutate(actor.clone(), now_ms, |sprint| sprint.order = order)
.map_err(|err| AppError::Invalid(err.to_string()))?,
);
}
Ok(out)
}

View File

@ -0,0 +1,513 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use domain::ports::{Clock, EventBus, EventStream, IdGenerator, IssueStore, IssueStoreError};
use domain::{
DomainEvent, Issue, IssueActor, IssueCarnet, IssueId, IssueIndexEntry, IssueListFilter,
IssueNumber, IssuePriority, IssueRef, IssueStatus, IssueVersion, MarkdownDoc, Project,
ProjectId, ProjectPath, RemoteRef, Sprint, SprintId, SprintIndexEntry, SprintOrder,
SprintStatus, SprintStore, SprintStoreError, SprintVersion,
};
use uuid::Uuid;
use application::{
normalized_reorder, AssignTicketToSprint, AssignTicketToSprintInput, CreateSprint,
CreateSprintInput, DeleteSprint, DeleteSprintInput, ListSprints, ListSprintsInput,
RenameSprint, RenameSprintInput, ReorderSprints, ReorderSprintsInput, UnassignTicketFromSprint,
UnassignTicketFromSprintInput,
};
#[derive(Default)]
struct FakeSprints {
sprints: Mutex<HashMap<SprintId, Sprint>>,
}
#[async_trait]
impl SprintStore for FakeSprints {
async fn create(&self, _root: &ProjectPath, sprint: &Sprint) -> Result<(), SprintStoreError> {
self.sprints
.lock()
.unwrap()
.insert(sprint.id, sprint.clone());
Ok(())
}
async fn get(
&self,
_root: &ProjectPath,
sprint_id: SprintId,
) -> Result<Sprint, SprintStoreError> {
self.sprints
.lock()
.unwrap()
.get(&sprint_id)
.cloned()
.ok_or(SprintStoreError::NotFound)
}
async fn list(&self, _root: &ProjectPath) -> Result<Vec<SprintIndexEntry>, SprintStoreError> {
let mut rows: Vec<_> = self
.sprints
.lock()
.unwrap()
.values()
.map(SprintIndexEntry::from)
.collect();
rows.sort_by_key(|row| row.order.get());
Ok(rows)
}
async fn update(
&self,
_root: &ProjectPath,
sprint: &Sprint,
expected_version: SprintVersion,
) -> Result<(), SprintStoreError> {
let mut sprints = self.sprints.lock().unwrap();
let current = sprints.get(&sprint.id).ok_or(SprintStoreError::NotFound)?;
if current.version != expected_version {
return Err(SprintStoreError::VersionConflict {
expected: expected_version,
actual: current.version,
});
}
sprints.insert(sprint.id, sprint.clone());
Ok(())
}
async fn delete(
&self,
_root: &ProjectPath,
sprint_id: SprintId,
) -> Result<(), SprintStoreError> {
self.sprints
.lock()
.unwrap()
.remove(&sprint_id)
.map(|_| ())
.ok_or(SprintStoreError::NotFound)
}
}
#[derive(Default)]
struct FakeIssues {
issues: Mutex<HashMap<IssueRef, Issue>>,
}
#[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> {
let mut rows: Vec<IssueIndexEntry> = self
.issues
.lock()
.unwrap()
.values()
.filter(|issue| {
filter
.sprint
.map_or(true, |sprint| issue.sprint == Some(sprint))
})
.map(IssueIndexEntry::from)
.collect();
rows.sort_by_key(|row| row.issue_ref.number().get());
Ok(rows)
}
async fn update(
&self,
_root: &ProjectPath,
issue: &Issue,
expected_version: IssueVersion,
) -> Result<(), IssueStoreError> {
let mut issues = self.issues.lock().unwrap();
let current = issues
.get(&issue.reference())
.ok_or(IssueStoreError::NotFound)?;
if current.version != expected_version {
return Err(IssueStoreError::VersionConflict {
expected: expected_version,
actual: current.version,
});
}
issues.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> {
unimplemented!("not needed")
}
}
struct SeqIds(Mutex<u128>);
impl IdGenerator for SeqIds {
fn new_uuid(&self) -> Uuid {
let mut next = self.0.lock().unwrap();
let id = Uuid::from_u128(*next);
*next += 1;
id
}
}
struct FixedClock(i64);
impl Clock for FixedClock {
fn now_millis(&self) -> i64 {
self.0
}
}
#[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(Vec::new().into_iter())
}
}
fn project() -> Project {
Project {
id: ProjectId::from_uuid(Uuid::from_u128(900)),
name: "P".to_owned(),
root: ProjectPath::new("/tmp/idea-sprint-usecases").unwrap(),
remote: RemoteRef::Local,
created_at: 1_000,
}
}
fn sid(n: u128) -> SprintId {
SprintId::from_uuid(Uuid::from_u128(n))
}
fn sprint(id: SprintId, order: u32, name: &str) -> Sprint {
Sprint::new(
id,
SprintOrder::new(order).unwrap(),
name,
None,
IssueActor::User,
1_000,
)
.unwrap()
}
fn issue(number: u64, title: &str, sprint_id: Option<SprintId>) -> Issue {
Issue::new(
IssueId::from_uuid(Uuid::from_u128(number as u128)),
IssueNumber::new(number).unwrap(),
title,
MarkdownDoc::default(),
IssueStatus::Open,
IssuePriority::Medium,
MarkdownDoc::default(),
Vec::new(),
Vec::new(),
IssueActor::User,
1_000,
)
.unwrap()
.mutate(IssueActor::System, 1_000, |issue| issue.sprint = sprint_id)
.unwrap()
}
#[tokio::test]
async fn create_sprint_uses_max_order_plus_one_and_publishes_event() {
let sprints = Arc::new(FakeSprints::default());
sprints
.create(&project().root, &sprint(sid(1), 2, "Existing"))
.await
.unwrap();
let bus = Arc::new(SpyBus::default());
let usecase = CreateSprint::new(
sprints,
Arc::new(SeqIds(Mutex::new(10))),
Arc::new(FixedClock(2_000)),
bus.clone(),
);
let out = usecase
.execute(CreateSprintInput {
project: project(),
name: "Next".to_owned(),
status: Some(SprintStatus::Active),
actor: IssueActor::User,
})
.await
.unwrap();
assert_eq!(out.sprint.id, sid(10));
assert_eq!(out.sprint.order.get(), 3);
assert_eq!(out.sprint.status, SprintStatus::Active);
assert!(bus.events().contains(&DomainEvent::SprintCreated {
sprint_id: sid(10),
order: SprintOrder::new(3).unwrap(),
}));
}
#[tokio::test]
async fn rename_and_reorder_publish_sprint_events() {
let sprints = Arc::new(FakeSprints::default());
let first = sprint(sid(1), 1, "One");
let second = sprint(sid(2), 2, "Two");
sprints.create(&project().root, &first).await.unwrap();
sprints.create(&project().root, &second).await.unwrap();
let bus = Arc::new(SpyBus::default());
let renamed = RenameSprint::new(sprints.clone(), Arc::new(FixedClock(2_000)), bus.clone())
.execute(RenameSprintInput {
project: project(),
sprint_id: sid(1),
expected_version: first.version,
name: "Renamed".to_owned(),
actor: IssueActor::User,
})
.await
.unwrap()
.sprint;
assert_eq!(renamed.name, "Renamed");
let reordered = ReorderSprints::new(sprints.clone(), Arc::new(FixedClock(3_000)), bus.clone())
.execute(ReorderSprintsInput {
project: project(),
ordered_ids: vec![sid(2), sid(1)],
actor: IssueActor::System,
})
.await
.unwrap();
assert_eq!(
reordered
.sprints
.iter()
.map(|sprint| (sprint.id, sprint.order.get()))
.collect::<Vec<_>>(),
vec![(sid(2), 1), (sid(1), 2)]
);
assert!(bus.events().iter().any(|event| {
matches!(
event,
DomainEvent::SprintRenamed {
sprint_id,
name,
..
} if *sprint_id == sid(1) && name == "Renamed"
)
}));
assert!(bus.events().iter().any(|event| {
matches!(
event,
DomainEvent::SprintReordered { sprint_id, order, .. }
if *sprint_id == sid(2) && order.get() == 1
)
}));
}
#[test]
fn normalized_reorder_rejects_incomplete_or_duplicate_ids() {
let sprints = vec![sprint(sid(1), 1, "One"), sprint(sid(2), 2, "Two")];
assert!(normalized_reorder(&sprints, &[sid(1)], IssueActor::User, 2_000).is_err());
assert!(normalized_reorder(&sprints, &[sid(1), sid(1)], IssueActor::User, 2_000).is_err());
}
#[tokio::test]
async fn assign_and_unassign_ticket_to_sprint_use_issue_expected_version() {
let sprints = Arc::new(FakeSprints::default());
sprints
.create(&project().root, &sprint(sid(1), 1, "One"))
.await
.unwrap();
let issues = Arc::new(FakeIssues::default());
let original = issue(1, "Ticket", None);
issues.create(&project().root, &original).await.unwrap();
let bus = Arc::new(SpyBus::default());
let assign = AssignTicketToSprint::new(
sprints,
issues.clone(),
Arc::new(FixedClock(2_000)),
bus.clone(),
);
let conflict = assign
.execute(AssignTicketToSprintInput {
project: project(),
issue_ref: original.reference(),
sprint_id: sid(1),
expected_version: original.version.next(),
actor: IssueActor::User,
})
.await
.unwrap_err();
assert_eq!(conflict.code(), "INVALID");
let assigned = assign
.execute(AssignTicketToSprintInput {
project: project(),
issue_ref: original.reference(),
sprint_id: sid(1),
expected_version: original.version,
actor: IssueActor::User,
})
.await
.unwrap()
.issue;
assert_eq!(assigned.sprint, Some(sid(1)));
let unassigned =
UnassignTicketFromSprint::new(issues, Arc::new(FixedClock(3_000)), bus.clone())
.execute(UnassignTicketFromSprintInput {
project: project(),
issue_ref: original.reference(),
expected_version: assigned.version,
actor: IssueActor::User,
})
.await
.unwrap()
.issue;
assert_eq!(unassigned.sprint, None);
assert!(bus.events().iter().any(|event| {
matches!(
event,
DomainEvent::IssueSprintChanged {
issue_ref,
from: None,
to: Some(target),
..
} if *issue_ref == original.reference() && *target == sid(1)
)
}));
}
#[tokio::test]
async fn delete_sprint_unassigns_tickets_without_deleting_them() {
let sprints = Arc::new(FakeSprints::default());
sprints
.create(&project().root, &sprint(sid(1), 1, "One"))
.await
.unwrap();
let issues = Arc::new(FakeIssues::default());
let assigned = issue(1, "Assigned", Some(sid(1)));
let backlog = issue(2, "Backlog", None);
issues.create(&project().root, &assigned).await.unwrap();
issues.create(&project().root, &backlog).await.unwrap();
let bus = Arc::new(SpyBus::default());
DeleteSprint::new(
sprints.clone(),
issues.clone(),
Arc::new(FixedClock(2_000)),
bus.clone(),
)
.execute(DeleteSprintInput {
project: project(),
sprint_id: sid(1),
actor: IssueActor::System,
})
.await
.unwrap();
assert!(matches!(
sprints.get(&project().root, sid(1)).await.unwrap_err(),
SprintStoreError::NotFound
));
assert_eq!(
issues
.get_by_ref(&project().root, assigned.reference())
.await
.unwrap()
.sprint,
None
);
assert!(issues
.get_by_ref(&project().root, backlog.reference())
.await
.is_ok());
assert!(bus
.events()
.contains(&DomainEvent::SprintDeleted { sprint_id: sid(1) }));
}
#[tokio::test]
async fn list_sprints_returns_ticket_counts() {
let sprints = Arc::new(FakeSprints::default());
sprints
.create(&project().root, &sprint(sid(1), 1, "One"))
.await
.unwrap();
let issues = Arc::new(FakeIssues::default());
issues
.create(&project().root, &issue(1, "A", Some(sid(1))))
.await
.unwrap();
issues
.create(&project().root, &issue(2, "B", Some(sid(1))))
.await
.unwrap();
let out = ListSprints::new(sprints, issues)
.execute(ListSprintsInput { project: project() })
.await
.unwrap();
assert_eq!(out.sprints.len(), 1);
assert_eq!(out.sprints[0].ticket_count, 2);
}

View File

@ -2,10 +2,13 @@
//! presentation layer (ARCHITECTURE §3.2).
use crate::conversation::ConversationParty;
use crate::ids::{AgentId, IssueId, ProfileId, ProjectId, SessionId, SkillId, TaskId, TemplateId};
use crate::ids::{
AgentId, IssueId, ProfileId, ProjectId, SessionId, SkillId, SprintId, TaskId, TemplateId,
};
use crate::issue::{IssueLinkKind, IssuePriority, IssueRef, IssueStatus, IssueVersion};
use crate::mailbox::TicketId;
use crate::memory::MemorySlug;
use crate::sprint::{SprintOrder, SprintVersion};
use crate::template::TemplateVersion;
/// Which entry door a processed orchestration request arrived through.
@ -367,6 +370,47 @@ pub enum DomainEvent {
/// New optimistic version.
version: IssueVersion,
},
/// A sprint was created.
SprintCreated {
/// Stable sprint id.
sprint_id: SprintId,
/// Reorderable sprint order.
order: SprintOrder,
},
/// A sprint was renamed.
SprintRenamed {
/// Stable sprint id.
sprint_id: SprintId,
/// New sprint name.
name: String,
/// New optimistic version.
version: SprintVersion,
},
/// Sprint orders changed.
SprintReordered {
/// Stable sprint id.
sprint_id: SprintId,
/// New sprint order.
order: SprintOrder,
/// New optimistic version.
version: SprintVersion,
},
/// A sprint was deleted.
SprintDeleted {
/// Stable sprint id.
sprint_id: SprintId,
},
/// An issue was moved into, out of, or between sprints.
IssueSprintChanged {
/// Human-friendly issue reference.
issue_ref: IssueRef,
/// Previous sprint.
from: Option<SprintId>,
/// New sprint.
to: Option<SprintId>,
/// 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

@ -76,6 +76,10 @@ typed_id!(
/// Identifies a [`crate::issue::Issue`].
IssueId
);
typed_id!(
/// Identifies a [`crate::sprint::Sprint`].
SprintId
);
typed_id!(
/// Identifies a [`crate::terminal::TerminalSession`].
SessionId

View File

@ -8,7 +8,7 @@ use std::str::FromStr;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::ids::{AgentId, IssueId};
use crate::ids::{AgentId, IssueId, SprintId};
use crate::markdown::MarkdownDoc;
/// Sequential per-project issue number.
@ -255,6 +255,8 @@ pub struct Issue {
pub status: IssueStatus,
/// Priority.
pub priority: IssuePriority,
/// Sprint membership. `None` means backlog / no sprint.
pub sprint: Option<SprintId>,
/// Editable issue-local knowledge.
pub carnet: MarkdownDoc,
/// Links to other issues.
@ -299,6 +301,7 @@ impl Issue {
description,
status,
priority,
sprint: None,
carnet,
links,
agent_refs,
@ -392,6 +395,8 @@ pub struct IssueIndexEntry {
pub status: IssueStatus,
/// Priority.
pub priority: IssuePriority,
/// Sprint membership.
pub sprint: Option<SprintId>,
/// Assigned agent ids.
pub assigned_agent_ids: Vec<AgentId>,
/// Last update time.
@ -406,6 +411,7 @@ impl From<&Issue> for IssueIndexEntry {
title: issue.title.clone(),
status: issue.status,
priority: issue.priority,
sprint: issue.sprint,
assigned_agent_ids: issue
.agent_refs
.iter()
@ -426,6 +432,8 @@ pub struct IssueListFilter {
pub priority: Option<IssuePriority>,
/// Optional assigned agent filter.
pub assigned_agent_id: Option<AgentId>,
/// Optional sprint membership filter.
pub sprint: Option<SprintId>,
/// Optional case-insensitive text query.
pub text: Option<String>,
}

View File

@ -58,6 +58,7 @@ pub mod remote;
pub mod sandbox;
pub mod session_limit;
pub mod skill;
pub mod sprint;
pub mod template;
pub mod terminal;
@ -71,7 +72,7 @@ pub use error::DomainError;
pub use ids::{
AgentId, IssueId, LayoutId, NodeId, ProfileId, ProjectId, ScheduleId, SessionId, SkillId,
TabId, TaskId, TemplateId, WindowId,
SprintId, TabId, TaskId, TemplateId, WindowId,
};
pub use project::{Project, ProjectPath};
@ -116,6 +117,11 @@ pub use issue::{
IssueVersion,
};
pub use sprint::{
validate_sprint_ordering, Sprint, SprintError, SprintIndexEntry, SprintOrder, SprintStatus,
SprintVersion,
};
pub use live_state::{LiveEntry, LiveState, WorkStatus, FIELD_MAX_BYTES, FIELD_PREVIEW_MAX_CHARS};
pub use readiness::{ReadinessPolicy, ReadinessSignal};
@ -183,5 +189,5 @@ pub use ports::{
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,
ScheduledTask, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError, TemplateStore,
};

View File

@ -32,7 +32,7 @@ use crate::background_task::{
BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy,
};
use crate::events::DomainEvent;
use crate::ids::{AgentId, NodeId, ProjectId, ScheduleId, SessionId, TaskId};
use crate::ids::{AgentId, NodeId, ProjectId, ScheduleId, SessionId, SprintId, TaskId};
use crate::issue::{
Issue, IssueCarnet, IssueIndexEntry, IssueListFilter, IssueNumber, IssueRef, IssueVersion,
};
@ -43,6 +43,7 @@ use crate::profile::{AgentProfile, EmbedderProfile};
use crate::project::{Project, ProjectPath};
use crate::remote::RemoteKind;
use crate::skill::{Skill, SkillScope};
use crate::sprint::{Sprint, SprintIndexEntry, SprintVersion};
use crate::template::AgentTemplate;
use crate::terminal::PtySize;
@ -596,6 +597,28 @@ pub enum IssueStoreError {
#[error("issue store failed: {0}")]
Store(String),
}
/// Errors from the sprint store.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum SprintStoreError {
/// The requested sprint does not exist.
#[error("sprint not found")]
NotFound,
/// Optimistic concurrency conflict.
#[error("sprint version conflict: expected {expected}, actual {actual}")]
VersionConflict {
/// Expected version.
expected: SprintVersion,
/// Actual persisted version.
actual: SprintVersion,
},
/// The sprint payload is invalid.
#[error("sprint invalid: {0}")]
Invalid(String),
/// Store I/O or serialization failed.
#[error("sprint store failed: {0}")]
Store(String),
}
// ---------------------------------------------------------------------------
// Git port support types
// ---------------------------------------------------------------------------
@ -1507,6 +1530,50 @@ pub trait IssueStore: Send + Sync {
) -> Result<IssueCarnet, IssueStoreError>;
}
/// Persistence port for project-scoped sprints.
#[async_trait]
pub trait SprintStore: Send + Sync {
/// Creates a new sprint.
///
/// # Errors
/// [`SprintStoreError`] when the sprint already exists or cannot be stored.
async fn create(&self, root: &ProjectPath, sprint: &Sprint) -> Result<(), SprintStoreError>;
/// Reads a sprint by stable id.
///
/// # Errors
/// [`SprintStoreError::NotFound`] when absent.
async fn get(
&self,
root: &ProjectPath,
sprint_id: SprintId,
) -> Result<Sprint, SprintStoreError>;
/// Lists sprint index entries.
///
/// # Errors
/// [`SprintStoreError`] on persistence failure.
async fn list(&self, root: &ProjectPath) -> Result<Vec<SprintIndexEntry>, SprintStoreError>;
/// Replaces a sprint after checking its expected version.
///
/// # Errors
/// [`SprintStoreError::VersionConflict`] on optimistic-concurrency conflict.
async fn update(
&self,
root: &ProjectPath,
sprint: &Sprint,
expected_version: SprintVersion,
) -> Result<(), SprintStoreError>;
/// Deletes a sprint by id.
///
/// # Errors
/// [`SprintStoreError::NotFound`] when absent.
async fn delete(&self, root: &ProjectPath, sprint_id: SprintId)
-> Result<(), SprintStoreError>;
}
/// Git operations for a project. Named `GitPort` to avoid clashing with the
/// [`crate::git::GitRepository`] *entity* (state image).
#[async_trait]

350
crates/domain/src/sprint.rs Normal file
View File

@ -0,0 +1,350 @@
//! Sprint domain model.
//!
//! A sprint is a project-scoped planning aggregate. Ticket membership remains
//! authoritative on [`crate::issue::Issue`].
use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::ids::SprintId;
use crate::issue::IssueActor;
/// Reorderable execution position. Values start at 1.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SprintOrder(u32);
impl SprintOrder {
/// Builds a sprint order.
///
/// # Errors
/// [`SprintError::InvalidOrder`] when `value == 0`.
pub fn new(value: u32) -> Result<Self, SprintError> {
if value == 0 {
return Err(SprintError::InvalidOrder);
}
Ok(Self(value))
}
/// Returns the raw order.
#[must_use]
pub const fn get(self) -> u32 {
self.0
}
}
impl std::fmt::Display for SprintOrder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
/// Optimistic-concurrency version.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SprintVersion(u64);
impl SprintVersion {
/// Initial version assigned to a newly-created sprint.
pub const INITIAL: Self = Self(1);
/// Builds a version.
///
/// # Errors
/// [`SprintError::InvalidVersion`] when `value == 0`.
pub fn new(value: u64) -> Result<Self, SprintError> {
if value == 0 {
return Err(SprintError::InvalidVersion);
}
Ok(Self(value))
}
/// Returns the raw version.
#[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 SprintVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
/// Sprint lifecycle status.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum SprintStatus {
/// Planned and not active yet.
Planned,
/// Current sprint.
Active,
/// Completed sprint.
Done,
}
impl Default for SprintStatus {
fn default() -> Self {
Self::Planned
}
}
/// Sprint aggregate.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Sprint {
/// Stable UUID.
pub id: SprintId,
/// Reorderable execution position.
pub order: SprintOrder,
/// Human-readable name. Empty means UI may display `Sprint {order}`.
pub name: String,
/// Lifecycle status.
pub status: SprintStatus,
/// 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: SprintVersion,
}
impl Sprint {
/// Builds a new sprint with version 1.
///
/// # Errors
/// [`SprintError`] when invariants are violated.
pub fn new(
id: SprintId,
order: SprintOrder,
name: impl Into<String>,
status: Option<SprintStatus>,
actor: IssueActor,
now_ms: u64,
) -> Result<Self, SprintError> {
let sprint = Self {
id,
order,
name: name.into(),
status: status.unwrap_or_default(),
created_by: actor.clone(),
updated_by: actor,
created_at: now_ms,
updated_at: now_ms,
version: SprintVersion::INITIAL,
};
sprint.validate()?;
Ok(sprint)
}
/// Rehydrates a persisted sprint.
///
/// # Errors
/// [`SprintError`] when persisted data violates invariants.
pub fn rehydrate(sprint: Self) -> Result<Self, SprintError> {
sprint.validate()?;
Ok(sprint)
}
/// Validates invariants local to one sprint.
///
/// # Errors
/// [`SprintError`] when an invariant is violated.
pub fn validate(&self) -> Result<(), SprintError> {
SprintOrder::new(self.order.get())?;
SprintVersion::new(self.version.get())?;
Ok(())
}
/// Applies a mutation and increments the version.
///
/// # Errors
/// [`SprintError`] when the resulting sprint violates invariants.
pub fn mutate(
mut self,
actor: IssueActor,
now_ms: u64,
f: impl FnOnce(&mut Self),
) -> Result<Self, SprintError> {
f(&mut self);
self.updated_by = actor;
self.updated_at = now_ms;
self.version = self.version.next();
self.validate()?;
Ok(self)
}
}
/// Index row used by stores and list use cases.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SprintIndexEntry {
/// Stable sprint id.
pub id: SprintId,
/// Relative path to the sprint folder.
pub path: String,
/// Reorderable position.
pub order: SprintOrder,
/// Human-readable name.
pub name: String,
/// Lifecycle status.
pub status: SprintStatus,
/// Last update time.
pub updated_at: u64,
/// Current optimistic version.
pub version: SprintVersion,
}
impl From<&Sprint> for SprintIndexEntry {
fn from(sprint: &Sprint) -> Self {
Self {
id: sprint.id,
path: sprint.id.to_string(),
order: sprint.order,
name: sprint.name.clone(),
status: sprint.status,
updated_at: sprint.updated_at,
version: sprint.version,
}
}
}
/// Validates that sprint orders are unique and contiguous from 1.
///
/// # Errors
/// [`SprintError::DuplicateOrder`] or [`SprintError::NonContiguousOrder`].
pub fn validate_sprint_ordering<'a>(
sprints: impl IntoIterator<Item = &'a Sprint>,
) -> Result<(), SprintError> {
let mut orders: Vec<u32> = sprints
.into_iter()
.map(|sprint| sprint.order.get())
.collect();
orders.sort_unstable();
let mut seen = HashSet::new();
for order in &orders {
if !seen.insert(*order) {
return Err(SprintError::DuplicateOrder(*order));
}
}
for (idx, order) in orders.into_iter().enumerate() {
let expected = (idx as u32) + 1;
if order != expected {
return Err(SprintError::NonContiguousOrder {
expected,
actual: order,
});
}
}
Ok(())
}
/// Domain errors for sprint invariants and value objects.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum SprintError {
/// Sprint order is strictly positive.
#[error("sprint order must be greater than zero")]
InvalidOrder,
/// Sprint versions are strictly positive.
#[error("sprint version must be greater than zero")]
InvalidVersion,
/// Two sprints share the same order.
#[error("duplicate sprint order {0}")]
DuplicateOrder(u32),
/// Sprint ordering must be contiguous from 1.
#[error("non-contiguous sprint order: expected {expected}, actual {actual}")]
NonContiguousOrder {
/// Expected contiguous value.
expected: u32,
/// Actual encountered value.
actual: u32,
},
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ids::SprintId;
use uuid::Uuid;
fn sid(n: u128) -> SprintId {
SprintId::from_uuid(Uuid::from_u128(n))
}
fn sprint(order: u32) -> Sprint {
Sprint::new(
sid(order as u128),
SprintOrder::new(order).unwrap(),
"",
None,
IssueActor::User,
10,
)
.unwrap()
}
#[test]
fn sprint_defaults_to_planned_and_version_one() {
let sprint = sprint(1);
assert_eq!(sprint.status, SprintStatus::Planned);
assert_eq!(sprint.version, SprintVersion::INITIAL);
}
#[test]
fn order_and_version_reject_zero() {
assert_eq!(SprintOrder::new(0), Err(SprintError::InvalidOrder));
assert_eq!(SprintVersion::new(0), Err(SprintError::InvalidVersion));
}
#[test]
fn validate_ordering_accepts_contiguous_unique_orders() {
let a = sprint(1);
let b = sprint(2);
assert_eq!(validate_sprint_ordering([&b, &a]), Ok(()));
}
#[test]
fn validate_ordering_rejects_duplicates_and_gaps() {
let a = sprint(1);
let duplicate = sprint(1);
assert_eq!(
validate_sprint_ordering([&a, &duplicate]),
Err(SprintError::DuplicateOrder(1))
);
let c = sprint(3);
assert_eq!(
validate_sprint_ordering([&a, &c]),
Err(SprintError::NonContiguousOrder {
expected: 2,
actual: 3,
})
);
}
#[test]
fn mutate_increments_version_and_tracks_actor() {
let updated = sprint(1)
.mutate(IssueActor::System, 20, |sprint| {
sprint.name = "Delivery".to_owned();
})
.unwrap();
assert_eq!(updated.name, "Delivery");
assert_eq!(updated.updated_by, IssueActor::System);
assert_eq!(updated.updated_at, 20);
assert_eq!(updated.version.get(), 2);
}
}

View File

@ -16,7 +16,7 @@ use tokio::io::AsyncWriteExt;
use domain::{
AgentIssueRef, Issue, IssueActor, IssueCarnet, IssueId, IssueIndexEntry, IssueLink,
IssueListFilter, IssueNumber, IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus,
IssueStore, IssueStoreError, IssueVersion, MarkdownDoc, ProjectPath,
IssueStore, IssueStoreError, IssueVersion, MarkdownDoc, ProjectPath, SprintId,
};
const IDEAI_DIR: &str = ".ideai";
@ -211,6 +211,12 @@ fn filter_matches(row: &IssueIndexEntry, filter: &IssueListFilter) -> bool {
return false;
}
}
if filter
.sprint
.is_some_and(|sprint| row.sprint != Some(sprint))
{
return false;
}
if let Some(text) = filter
.text
.as_ref()
@ -423,6 +429,7 @@ number: {}\n\
title: {}\n\
status: {}\n\
priority: {}\n\
sprint: {}\n\
links: {}\n\
agentRefs: {}\n\
createdBy: {}\n\
@ -436,6 +443,7 @@ version: {}\n\
json_string(&issue.title),
json_value(&issue.status),
json_value(&issue.priority),
json_value(&issue.sprint),
json_value(&issue.links),
json_value(&issue.agent_refs),
json_value(&issue.created_by),
@ -475,6 +483,7 @@ fn parse_issue_doc(text: &str) -> Result<Issue, IssueStoreError> {
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 sprint: Option<SprintId> = parse_optional_json_field(&map, "sprint")?.unwrap_or(None);
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")?;
@ -490,6 +499,7 @@ fn parse_issue_doc(text: &str) -> Result<Issue, IssueStoreError> {
description: MarkdownDoc::new(body),
status,
priority,
sprint,
carnet: MarkdownDoc::default(),
links,
agent_refs,
@ -567,6 +577,18 @@ fn parse_json_field<T: serde::de::DeserializeOwned>(
serde_json::from_str(value).map_err(|err| IssueStoreError::Invalid(err.to_string()))
}
fn parse_optional_json_field<T: serde::de::DeserializeOwned>(
map: &BTreeMap<String, String>,
key: &str,
) -> Result<Option<T>, IssueStoreError> {
let Some(value) = map.get(key) else {
return Ok(None);
};
serde_json::from_str(value)
.map(Some)
.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")
}

View File

@ -35,6 +35,7 @@ pub mod runtime;
pub mod sandbox;
pub mod scheduler;
pub mod session;
pub mod sprints;
pub mod store;
pub mod timeparse;
@ -79,6 +80,7 @@ pub use sandbox::LandlockSandbox;
pub use sandbox::{default_enforcer, NoopSandbox};
pub use scheduler::TokioScheduler;
pub use session::{ClaudeSdkSession, CodexExecSession, FakeCli, StructuredSessionFactory};
pub use sprints::FsSprintStore;
#[cfg(feature = "vector-onnx")]
pub use store::OnnxEmbedder;
#[cfg(feature = "vector-http")]

View File

@ -39,7 +39,7 @@ impl TicketToolError {
/// 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`.
/// Executes one public ticket/sprint planning tool for `project`.
async fn handle_ticket_tool(
&self,
project: &Project,
@ -64,6 +64,7 @@ pub fn is_ticket_tool(name: &str) -> bool {
| "idea_ticket_update_carnet"
| "idea_ticket_link"
| "idea_ticket_unlink"
| "idea_sprint_list"
)
}
@ -234,5 +235,14 @@ pub fn catalogue() -> Vec<ToolDef> {
"additionalProperties": false
}),
},
ToolDef {
name: "idea_sprint_list",
description: "List IdeA sprints in execution order. Read-only.",
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
},
]
}

View File

@ -0,0 +1,342 @@
//! Filesystem sprint store.
//!
//! Project-scoped sprints live under `<root>/.ideai/sprints/`, one directory per
//! stable sprint id. 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 async_trait::async_trait;
use serde::{Deserialize, Serialize};
use domain::{
IssueActor, ProjectPath, Sprint, SprintId, SprintIndexEntry, SprintOrder, SprintStatus,
SprintStore, SprintStoreError, SprintVersion,
};
const IDEAI_DIR: &str = ".ideai";
const SPRINTS_DIR: &str = "sprints";
const SPRINT_FILE: &str = "sprint.md";
const INDEX_FILE: &str = "index.json";
const INDEX_VERSION: u32 = 1;
/// Filesystem-backed sprint store.
#[derive(Debug, Clone, Default)]
pub struct FsSprintStore;
impl FsSprintStore {
/// Builds a store.
#[must_use]
pub const fn new() -> Self {
Self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IndexDoc {
version: u32,
sprints: Vec<SprintIndexEntry>,
}
fn sprint_root(root: &ProjectPath) -> PathBuf {
PathBuf::from(root.as_str())
.join(IDEAI_DIR)
.join(SPRINTS_DIR)
}
fn sprint_dir(root: &ProjectPath, sprint_id: SprintId) -> PathBuf {
sprint_root(root).join(sprint_id.to_string())
}
fn sprint_path(root: &ProjectPath, sprint_id: SprintId) -> PathBuf {
sprint_dir(root, sprint_id).join(SPRINT_FILE)
}
fn index_path(root: &ProjectPath) -> PathBuf {
sprint_root(root).join(INDEX_FILE)
}
async fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), SprintStoreError> {
let parent = path
.parent()
.ok_or_else(|| SprintStoreError::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, SprintStoreError> {
let bytes = tokio::fs::read(path).await.map_err(|err| {
if err.kind() == std::io::ErrorKind::NotFound {
SprintStoreError::NotFound
} else {
io_error(err)
}
})?;
String::from_utf8(bytes).map_err(|err| SprintStoreError::Store(err.to_string()))
}
fn io_error(err: std::io::Error) -> SprintStoreError {
SprintStoreError::Store(err.to_string())
}
async fn load_sprint(root: &ProjectPath, sprint_id: SprintId) -> Result<Sprint, SprintStoreError> {
let text = read_string(&sprint_path(root, sprint_id)).await?;
parse_sprint_doc(&text)
}
async fn save_sprint(root: &ProjectPath, sprint: &Sprint) -> Result<(), SprintStoreError> {
let dir = sprint_dir(root, sprint.id);
tokio::fs::create_dir_all(&dir).await.map_err(io_error)?;
write_atomic(&dir.join(SPRINT_FILE), render_sprint_doc(sprint).as_bytes()).await
}
async fn rebuild_index(root: &ProjectPath) -> Result<Vec<SprintIndexEntry>, SprintStoreError> {
let dir = sprint_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(id) = uuid::Uuid::parse_str(&name) else {
continue;
};
if let Ok(sprint) = load_sprint(root, SprintId::from_uuid(id)).await {
rows.push(SprintIndexEntry::from(&sprint));
}
}
rows.sort_by_key(|row| row.order.get());
write_index(root, &rows).await?;
Ok(rows)
}
async fn read_index(root: &ProjectPath) -> Result<Vec<SprintIndexEntry>, SprintStoreError> {
match tokio::fs::read(index_path(root)).await {
Ok(bytes) => {
let doc: IndexDoc = serde_json::from_slice(&bytes)
.map_err(|err| SprintStoreError::Store(err.to_string()))?;
Ok(doc.sprints)
}
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: &[SprintIndexEntry],
) -> Result<(), SprintStoreError> {
let doc = IndexDoc {
version: INDEX_VERSION,
sprints: rows.to_vec(),
};
let bytes =
serde_json::to_vec_pretty(&doc).map_err(|err| SprintStoreError::Store(err.to_string()))?;
write_atomic(&index_path(root), &bytes).await
}
#[async_trait]
impl SprintStore for FsSprintStore {
async fn create(&self, root: &ProjectPath, sprint: &Sprint) -> Result<(), SprintStoreError> {
sprint
.validate()
.map_err(|err| SprintStoreError::Invalid(err.to_string()))?;
if tokio::fs::try_exists(sprint_path(root, sprint.id))
.await
.map_err(io_error)?
{
return Err(SprintStoreError::Invalid(format!(
"sprint {} already exists",
sprint.id
)));
}
save_sprint(root, sprint).await?;
rebuild_index(root).await?;
Ok(())
}
async fn get(
&self,
root: &ProjectPath,
sprint_id: SprintId,
) -> Result<Sprint, SprintStoreError> {
load_sprint(root, sprint_id).await
}
async fn list(&self, root: &ProjectPath) -> Result<Vec<SprintIndexEntry>, SprintStoreError> {
let mut rows = read_index(root).await?;
rows.sort_by_key(|row| row.order.get());
Ok(rows)
}
async fn update(
&self,
root: &ProjectPath,
sprint: &Sprint,
expected_version: SprintVersion,
) -> Result<(), SprintStoreError> {
sprint
.validate()
.map_err(|err| SprintStoreError::Invalid(err.to_string()))?;
let current = load_sprint(root, sprint.id).await?;
if current.version != expected_version {
return Err(SprintStoreError::VersionConflict {
expected: expected_version,
actual: current.version,
});
}
save_sprint(root, sprint).await?;
rebuild_index(root).await?;
Ok(())
}
async fn delete(
&self,
root: &ProjectPath,
sprint_id: SprintId,
) -> Result<(), SprintStoreError> {
if !tokio::fs::try_exists(sprint_path(root, sprint_id))
.await
.map_err(io_error)?
{
return Err(SprintStoreError::NotFound);
}
tokio::fs::remove_dir_all(sprint_dir(root, sprint_id))
.await
.map_err(io_error)?;
rebuild_index(root).await?;
Ok(())
}
}
fn render_sprint_doc(sprint: &Sprint) -> String {
format!(
"---\n\
id: {}\n\
order: {}\n\
name: {}\n\
status: {}\n\
createdBy: {}\n\
updatedBy: {}\n\
createdAt: {}\n\
updatedAt: {}\n\
version: {}\n\
---\n",
json_string(&sprint.id.to_string()),
sprint.order.get(),
json_string(&sprint.name),
json_value(&sprint.status),
json_value(&sprint.created_by),
json_value(&sprint.updated_by),
sprint.created_at,
sprint.updated_at,
sprint.version.get(),
)
}
fn parse_sprint_doc(text: &str) -> Result<Sprint, SprintStoreError> {
let (fm, _body) = split_frontmatter(text)?;
let map = parse_frontmatter_map(fm)?;
let id_raw: String = parse_json_field(&map, "id")?;
let id = SprintId::from_uuid(
uuid::Uuid::parse_str(&id_raw).map_err(|err| SprintStoreError::Invalid(err.to_string()))?,
);
let order = SprintOrder::new(parse_plain_u32(&map, "order")?)
.map_err(|err| SprintStoreError::Invalid(err.to_string()))?;
let name: String = parse_json_field(&map, "name")?;
let status: SprintStatus = parse_json_field(&map, "status")?;
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 = SprintVersion::new(parse_plain_u64(&map, "version")?)
.map_err(|err| SprintStoreError::Invalid(err.to_string()))?;
Sprint::rehydrate(Sprint {
id,
order,
name,
status,
created_by,
updated_by,
created_at,
updated_at,
version,
})
.map_err(|err| SprintStoreError::Invalid(err.to_string()))
}
fn split_frontmatter(text: &str) -> Result<(&str, &str), SprintStoreError> {
let rest = text
.strip_prefix("---\n")
.or_else(|| text.strip_prefix("---\r\n"))
.ok_or_else(|| SprintStoreError::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(SprintStoreError::Invalid(
"missing closing frontmatter fence".to_owned(),
))
}
fn parse_frontmatter_map(block: &str) -> Result<BTreeMap<String, String>, SprintStoreError> {
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(|| SprintStoreError::Invalid("frontmatter line missing ':'".to_owned()))?;
map.insert(key.trim().to_owned(), value.trim().to_owned());
}
Ok(map)
}
fn parse_plain_u32(map: &BTreeMap<String, String>, key: &str) -> Result<u32, SprintStoreError> {
map.get(key)
.ok_or_else(|| SprintStoreError::Invalid(format!("missing frontmatter key `{key}`")))?
.parse::<u32>()
.map_err(|err| SprintStoreError::Invalid(err.to_string()))
}
fn parse_plain_u64(map: &BTreeMap<String, String>, key: &str) -> Result<u64, SprintStoreError> {
map.get(key)
.ok_or_else(|| SprintStoreError::Invalid(format!("missing frontmatter key `{key}`")))?
.parse::<u64>()
.map_err(|err| SprintStoreError::Invalid(err.to_string()))
}
fn parse_json_field<T: serde::de::DeserializeOwned>(
map: &BTreeMap<String, String>,
key: &str,
) -> Result<T, SprintStoreError> {
let value = map
.get(key)
.ok_or_else(|| SprintStoreError::Invalid(format!("missing frontmatter key `{key}`")))?;
serde_json::from_str(value).map_err(|err| SprintStoreError::Invalid(err.to_string()))
}
fn json_value<T: Serialize>(value: &T) -> String {
serde_json::to_string(value).expect("serializing sprint frontmatter cannot fail")
}
fn json_string(value: &str) -> String {
serde_json::to_string(value).expect("serializing sprint string cannot fail")
}

View File

@ -4,7 +4,7 @@ use std::str::FromStr;
use domain::{
AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueId, IssueListFilter,
IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore, IssueStoreError,
MarkdownDoc, ProjectPath,
MarkdownDoc, ProjectPath, SprintId,
};
use infrastructure::{FsIssueNumberAllocator, FsIssueStore};
use uuid::Uuid;
@ -144,6 +144,44 @@ async fn issue_store_lists_by_index_filters() {
assert_eq!(rows[0].title, "Beta");
}
#[tokio::test]
async fn issue_store_persists_and_filters_sprint_membership() {
let tmp = TempDir::new();
let root = tmp.root();
let store = FsIssueStore::new();
let sprint_id = SprintId::from_uuid(Uuid::from_u128(42));
let assigned = issue(&root, 1, "Assigned")
.mutate(IssueActor::System, 2_000, |i| {
i.sprint = Some(sprint_id);
})
.unwrap();
store.create(&root, &assigned).await.unwrap();
store
.create(&root, &issue(&root, 2, "Backlog"))
.await
.unwrap();
let loaded = store
.get_by_ref(&root, IssueRef::from_str("#1").unwrap())
.await
.unwrap();
assert_eq!(loaded.sprint, Some(sprint_id));
let rows = store
.list(
&root,
IssueListFilter {
sprint: Some(sprint_id),
..IssueListFilter::default()
},
)
.await
.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].title, "Assigned");
assert_eq!(rows[0].sprint, Some(sprint_id));
}
#[tokio::test]
async fn allocator_never_reuses_numbers() {
let tmp = TempDir::new();

View File

@ -52,6 +52,7 @@ use application::{
OrchestratorService, TerminalSessions, UpdateAgentContext,
};
use infrastructure::orchestrator::mcp::jsonrpc::error_codes;
use infrastructure::orchestrator::mcp::{TicketToolError, TicketToolProvider};
use infrastructure::{
InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport,
SystemMillisClock,
@ -459,6 +460,59 @@ fn result_text(result: &Value) -> &str {
.expect("text content block")
}
#[derive(Clone, Default)]
struct FakeTicketTools {
calls: Arc<Mutex<Vec<String>>>,
mutation_attempts: Arc<Mutex<usize>>,
sprints: Arc<Mutex<Vec<Value>>>,
}
impl FakeTicketTools {
fn seed_sprint(&self, order: u32, name: &str) {
self.sprints.lock().unwrap().push(json!({
"id": Uuid::new_v4().to_string(),
"order": order,
"name": name,
"status": "planned",
"ticketCount": 0,
"version": 1
}));
}
fn calls(&self) -> Vec<String> {
self.calls.lock().unwrap().clone()
}
fn mutation_attempts(&self) -> usize {
*self.mutation_attempts.lock().unwrap()
}
}
#[async_trait]
impl TicketToolProvider for FakeTicketTools {
async fn handle_ticket_tool(
&self,
_project: &Project,
_requester: &str,
name: &str,
_arguments: Value,
) -> Result<Value, TicketToolError> {
self.calls.lock().unwrap().push(name.to_owned());
match name {
"idea_sprint_list" => Ok(json!({
"items": self.sprints.lock().unwrap().clone()
})),
_ => {
*self.mutation_attempts.lock().unwrap() += 1;
Err(TicketToolError::new(
"unexpectedMutation",
format!("unexpected mutable ticket tool {name}"),
))
}
}
}
}
// ---------------------------------------------------------------------------
// 1. tools/list catalogue
// ---------------------------------------------------------------------------
@ -512,6 +566,7 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
"idea_ticket_update_carnet",
"idea_ticket_link",
"idea_ticket_unlink",
"idea_sprint_list",
] {
assert!(
names.contains(&expected),
@ -521,8 +576,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
assert!(!names.contains(&"idea_reply"));
assert_eq!(
tools.len(),
24,
"exactly the twenty-four exposed idea_* tools; got {names:?}"
25,
"exactly the twenty-five exposed idea_* tools; got {names:?}"
);
// Every tool advertises an object input schema.
@ -696,6 +751,56 @@ async fn list_agents_returns_the_agents_inline_as_json_array() {
assert!(names.contains(&"dev-backend"));
}
#[tokio::test]
async fn sprint_list_tool_call_returns_seeded_sprints_read_only() {
let (service, _s) = build_service(FakeContexts::new());
let ticket_tools = Arc::new(FakeTicketTools::default());
ticket_tools.seed_sprint(1, "Sprint Alpha");
ticket_tools.seed_sprint(2, "Sprint Beta");
let server = server(service).with_ticket_tools(ticket_tools.clone());
let raw = tools_call(41, "idea_sprint_list", json!({}));
let response = server.handle_raw(&raw).await.expect("reply owed");
assert!(
response.error.is_none(),
"transport error: {:?}",
response.error
);
let result = response.result.expect("result");
assert_eq!(result["isError"], json!(false), "got {result}");
let payload: Value =
serde_json::from_str(result_text(&result)).expect("sprint list payload is JSON");
let items = payload["items"].as_array().expect("SprintListDto.items");
assert_eq!(items.len(), 2, "got payload {payload}");
assert_eq!(items[0]["order"], json!(1));
assert_eq!(items[0]["name"], json!("Sprint Alpha"));
assert_eq!(items[1]["order"], json!(2));
assert_eq!(items[1]["name"], json!("Sprint Beta"));
assert_eq!(ticket_tools.calls(), vec!["idea_sprint_list".to_owned()]);
assert_eq!(
ticket_tools.mutation_attempts(),
0,
"idea_sprint_list must be a read-only provider call"
);
let raw = tools_call(42, "idea_sprint_create", json!({ "name": "Not exposed" }));
let response = server.handle_raw(&raw).await.expect("reply owed");
let error = response.error.expect("unknown sprint mutation expected");
assert_eq!(error.code, error_codes::METHOD_NOT_FOUND);
assert!(
error.message.contains("unknown tool"),
"message should name the cause; got {}",
error.message
);
assert_eq!(
ticket_tools.calls(),
vec!["idea_sprint_list".to_owned()],
"no sprint mutation tool should be routed to the provider"
);
assert_eq!(ticket_tools.mutation_attempts(), 0);
}
// ---------------------------------------------------------------------------
// 5. A failed IdeA command ⇒ isError: true (not a transport error, no panic)
// ---------------------------------------------------------------------------

View File

@ -0,0 +1,127 @@
use std::path::PathBuf;
use domain::{
IssueActor, ProjectPath, Sprint, SprintId, SprintOrder, SprintStatus, SprintStore,
SprintStoreError,
};
use infrastructure::FsSprintStore;
use uuid::Uuid;
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
let path = std::env::temp_dir().join(format!("idea-sprints-{}", 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 sid(n: u128) -> SprintId {
SprintId::from_uuid(Uuid::from_u128(n))
}
fn sprint(id: SprintId, order: u32, name: &str) -> Sprint {
Sprint::new(
id,
SprintOrder::new(order).unwrap(),
name,
None,
IssueActor::User,
1_000,
)
.unwrap()
}
#[tokio::test]
async fn sprint_store_writes_markdown_doc_and_index() {
let tmp = TempDir::new();
let root = tmp.root();
let store = FsSprintStore::new();
let sprint = sprint(sid(1), 1, "Delivery");
store.create(&root, &sprint).await.unwrap();
let sprint_md =
std::fs::read_to_string(tmp.child(&format!(".ideai/sprints/{}/sprint.md", sprint.id)))
.unwrap();
let index = std::fs::read_to_string(tmp.child(".ideai/sprints/index.json")).unwrap();
assert!(sprint_md.starts_with("---\n"));
assert!(sprint_md.contains("name: \"Delivery\""));
assert!(index.contains("\"name\": \"Delivery\""));
let loaded = store.get(&root, sprint.id).await.unwrap();
assert_eq!(loaded.name, "Delivery");
assert_eq!(loaded.order.get(), 1);
assert_eq!(loaded.status, SprintStatus::Planned);
}
#[tokio::test]
async fn sprint_store_update_checks_expected_version() {
let tmp = TempDir::new();
let root = tmp.root();
let store = FsSprintStore::new();
let original = sprint(sid(2), 1, "Plan");
store.create(&root, &original).await.unwrap();
let updated = original
.clone()
.mutate(IssueActor::System, 2_000, |sprint| {
sprint.name = "Build".to_owned();
})
.unwrap();
let err = store
.update(&root, &updated, updated.version)
.await
.unwrap_err();
assert!(matches!(err, SprintStoreError::VersionConflict { .. }));
store
.update(&root, &updated, original.version)
.await
.unwrap();
let loaded = store.get(&root, updated.id).await.unwrap();
assert_eq!(loaded.name, "Build");
assert_eq!(loaded.version.get(), 2);
}
#[tokio::test]
async fn sprint_store_lists_by_order_and_deletes() {
let tmp = TempDir::new();
let root = tmp.root();
let store = FsSprintStore::new();
let first = sprint(sid(10), 2, "Second");
let second = sprint(sid(11), 1, "First");
store.create(&root, &first).await.unwrap();
store.create(&root, &second).await.unwrap();
let rows = store.list(&root).await.unwrap();
assert_eq!(
rows.iter().map(|row| row.name.as_str()).collect::<Vec<_>>(),
vec!["First", "Second"]
);
store.delete(&root, first.id).await.unwrap();
assert!(matches!(
store.get(&root, first.id).await.unwrap_err(),
SprintStoreError::NotFound
));
let rows = store.list(&root).await.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].id, second.id);
}