feat(tickets): checkpoint backend V1 du système de tickets (T1-T5)
Checkpoint de travail — QA formelle encore à venir (après reset Codex). `cargo build` workspace OK, tests ticket/issue verts (domaine, store, use cases, app-tauri 47/51). Le domaine s'appelle `Issue`, exposé « ticket » côté MCP/UI. - T1 domaine : entité Issue (statut, priorité, liens, carnet), events, ids, ports. - T2 infra : store FS Markdown + allocator de références #N. - T3 application : use cases Issue (create/read/list/update/status/ priority/carnet/link/unlink/assign) + erreurs dédiées. - T4 surface MCP : 10 outils publics idea_ticket_* (23 outils au total), mappés vers les use cases Issue. - T5 app-tauri : commandes Tauri ticket_* + DTOs d'events Issue + câblage state.rs. Dette de test PRÉ-EXISTANTE héritée de develop (4 tests app-tauri mcp_e2e_loopback / mcp_serve_peer rouges) hors périmètre tickets, non traitée ici. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -1,7 +1,8 @@
|
||||
//! Domain events published on the [`crate::ports::EventBus`] and relayed to the
|
||||
//! presentation layer (ARCHITECTURE §3.2).
|
||||
|
||||
use crate::ids::{AgentId, ProfileId, ProjectId, SessionId, SkillId, TemplateId};
|
||||
use crate::ids::{AgentId, IssueId, ProfileId, ProjectId, SessionId, SkillId, TemplateId};
|
||||
use crate::issue::{IssueLinkKind, IssuePriority, IssueRef, IssueStatus, IssueVersion};
|
||||
use crate::mailbox::TicketId;
|
||||
use crate::memory::MemorySlug;
|
||||
use crate::template::TemplateVersion;
|
||||
@ -168,6 +169,85 @@ pub enum DomainEvent {
|
||||
/// The project whose index was rebuilt.
|
||||
project_id: ProjectId,
|
||||
},
|
||||
/// An issue was created.
|
||||
IssueCreated {
|
||||
/// Stable issue id.
|
||||
issue_id: IssueId,
|
||||
/// Human-friendly issue reference.
|
||||
issue_ref: IssueRef,
|
||||
},
|
||||
/// An issue was updated.
|
||||
IssueUpdated {
|
||||
/// Human-friendly issue reference.
|
||||
issue_ref: IssueRef,
|
||||
/// New optimistic version.
|
||||
version: IssueVersion,
|
||||
},
|
||||
/// An issue status changed.
|
||||
IssueStatusChanged {
|
||||
/// Human-friendly issue reference.
|
||||
issue_ref: IssueRef,
|
||||
/// New status.
|
||||
status: IssueStatus,
|
||||
/// New optimistic version.
|
||||
version: IssueVersion,
|
||||
},
|
||||
/// An issue priority changed.
|
||||
IssuePriorityChanged {
|
||||
/// Human-friendly issue reference.
|
||||
issue_ref: IssueRef,
|
||||
/// New priority.
|
||||
priority: IssuePriority,
|
||||
/// New optimistic version.
|
||||
version: IssueVersion,
|
||||
},
|
||||
/// An issue carnet was updated.
|
||||
IssueCarnetUpdated {
|
||||
/// Human-friendly issue reference.
|
||||
issue_ref: IssueRef,
|
||||
/// New optimistic version.
|
||||
version: IssueVersion,
|
||||
},
|
||||
/// Two issues were linked.
|
||||
IssueLinked {
|
||||
/// Source issue.
|
||||
issue_ref: IssueRef,
|
||||
/// Target issue.
|
||||
target: IssueRef,
|
||||
/// Link kind.
|
||||
kind: IssueLinkKind,
|
||||
/// New optimistic version.
|
||||
version: IssueVersion,
|
||||
},
|
||||
/// Two issues were unlinked.
|
||||
IssueUnlinked {
|
||||
/// Source issue.
|
||||
issue_ref: IssueRef,
|
||||
/// Target issue.
|
||||
target: IssueRef,
|
||||
/// Link kind.
|
||||
kind: IssueLinkKind,
|
||||
/// New optimistic version.
|
||||
version: IssueVersion,
|
||||
},
|
||||
/// An agent was assigned to an issue.
|
||||
IssueAgentAssigned {
|
||||
/// Human-friendly issue reference.
|
||||
issue_ref: IssueRef,
|
||||
/// Assigned agent.
|
||||
agent_id: AgentId,
|
||||
/// New optimistic version.
|
||||
version: IssueVersion,
|
||||
},
|
||||
/// An agent was unassigned from an issue.
|
||||
IssueAgentUnassigned {
|
||||
/// Human-friendly issue reference.
|
||||
issue_ref: IssueRef,
|
||||
/// Unassigned agent.
|
||||
agent_id: AgentId,
|
||||
/// New optimistic version.
|
||||
version: IssueVersion,
|
||||
},
|
||||
/// A project's memory grew past the recall budget while no embedder is
|
||||
/// configured (strategy `none`): the first moment a semantic embedder would
|
||||
/// help (ARCHITECTURE §14.5.5, LOT C3). Published **at most once per session per
|
||||
|
||||
@ -72,6 +72,10 @@ typed_id!(
|
||||
/// Identifies a [`crate::skill::Skill`].
|
||||
SkillId
|
||||
);
|
||||
typed_id!(
|
||||
/// Identifies a [`crate::issue::Issue`].
|
||||
IssueId
|
||||
);
|
||||
typed_id!(
|
||||
/// Identifies a [`crate::terminal::TerminalSession`].
|
||||
SessionId
|
||||
|
||||
454
crates/domain/src/issue.rs
Normal file
454
crates/domain/src/issue.rs
Normal file
@ -0,0 +1,454 @@
|
||||
//! Issue domain model.
|
||||
//!
|
||||
//! Code uses `Issue` terminology exclusively to avoid colliding with the
|
||||
//! inter-agent delegation model.
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::ids::{AgentId, IssueId};
|
||||
use crate::markdown::MarkdownDoc;
|
||||
|
||||
/// Sequential per-project issue number.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct IssueNumber(u64);
|
||||
|
||||
impl IssueNumber {
|
||||
/// Builds an issue number. Values start at 1.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`IssueError::InvalidNumber`] when `value == 0`.
|
||||
pub fn new(value: u64) -> Result<Self, IssueError> {
|
||||
if value == 0 {
|
||||
return Err(IssueError::InvalidNumber);
|
||||
}
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
/// Returns the raw numeric value.
|
||||
#[must_use]
|
||||
pub const fn get(self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for IssueNumber {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-friendly issue reference, formatted as `#<number>`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct IssueRef(IssueNumber);
|
||||
|
||||
impl IssueRef {
|
||||
/// Builds a reference from a validated number.
|
||||
#[must_use]
|
||||
pub const fn new(number: IssueNumber) -> Self {
|
||||
Self(number)
|
||||
}
|
||||
|
||||
/// Returns the referenced issue number.
|
||||
#[must_use]
|
||||
pub const fn number(self) -> IssueNumber {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for IssueRef {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "#{}", self.0.get())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<IssueNumber> for IssueRef {
|
||||
fn from(number: IssueNumber) -> Self {
|
||||
Self::new(number)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for IssueRef {
|
||||
type Err = IssueError;
|
||||
|
||||
fn from_str(raw: &str) -> Result<Self, Self::Err> {
|
||||
let digits = raw
|
||||
.strip_prefix('#')
|
||||
.ok_or_else(|| IssueError::InvalidRef(raw.to_owned()))?;
|
||||
if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Err(IssueError::InvalidRef(raw.to_owned()));
|
||||
}
|
||||
let number = digits
|
||||
.parse::<u64>()
|
||||
.map_err(|_| IssueError::InvalidRef(raw.to_owned()))?;
|
||||
Ok(Self(IssueNumber::new(number)?))
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for IssueRef {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for IssueRef {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let raw = String::deserialize(deserializer)?;
|
||||
raw.parse().map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
/// Optimistic-concurrency version.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct IssueVersion(u64);
|
||||
|
||||
impl IssueVersion {
|
||||
/// Initial version assigned to a newly-created issue.
|
||||
pub const INITIAL: Self = Self(1);
|
||||
|
||||
/// Builds a version. Values start at 1.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`IssueError::InvalidVersion`] when `value == 0`.
|
||||
pub fn new(value: u64) -> Result<Self, IssueError> {
|
||||
if value == 0 {
|
||||
return Err(IssueError::InvalidVersion);
|
||||
}
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
/// Returns the raw numeric value.
|
||||
#[must_use]
|
||||
pub const fn get(self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Returns the next optimistic version.
|
||||
#[must_use]
|
||||
pub const fn next(self) -> Self {
|
||||
Self(self.0 + 1)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for IssueVersion {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue lifecycle status.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum IssueStatus {
|
||||
/// Open and not started.
|
||||
Open,
|
||||
/// Being worked.
|
||||
InProgress,
|
||||
/// Ready for QA.
|
||||
Qa,
|
||||
/// Closed.
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// Issue priority.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum IssuePriority {
|
||||
/// Low priority.
|
||||
Low,
|
||||
/// Default priority.
|
||||
Medium,
|
||||
/// High priority.
|
||||
High,
|
||||
/// Critical priority.
|
||||
Critical,
|
||||
}
|
||||
|
||||
/// Relationship kind between two issues.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum IssueLinkKind {
|
||||
/// Informational relation.
|
||||
RelatesTo,
|
||||
/// This issue blocks the target.
|
||||
Blocks,
|
||||
/// This issue is blocked by the target.
|
||||
BlockedBy,
|
||||
/// This issue duplicates the target.
|
||||
Duplicates,
|
||||
/// This issue depends on the target.
|
||||
DependsOn,
|
||||
}
|
||||
|
||||
/// Link from one issue to another.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IssueLink {
|
||||
/// Target issue.
|
||||
pub target: IssueRef,
|
||||
/// Link kind.
|
||||
pub kind: IssueLinkKind,
|
||||
}
|
||||
|
||||
/// Role an agent has on an issue.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AgentIssueRole {
|
||||
/// Assigned implementer.
|
||||
Assigned,
|
||||
/// Mentioned participant.
|
||||
Mentioned,
|
||||
/// Reviewer.
|
||||
Reviewer,
|
||||
/// Owner.
|
||||
Owner,
|
||||
}
|
||||
|
||||
/// Agent reference on an issue.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentIssueRef {
|
||||
/// Agent id.
|
||||
pub agent_id: AgentId,
|
||||
/// Agent role.
|
||||
pub role: AgentIssueRole,
|
||||
}
|
||||
|
||||
/// Actor responsible for an issue mutation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
pub enum IssueActor {
|
||||
/// Human user.
|
||||
User,
|
||||
/// Agent actor.
|
||||
Agent {
|
||||
/// Agent id.
|
||||
agent_id: AgentId,
|
||||
},
|
||||
/// System actor.
|
||||
System,
|
||||
}
|
||||
|
||||
/// Issue aggregate.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Issue {
|
||||
/// Stable UUID.
|
||||
pub id: IssueId,
|
||||
/// Sequential per-project number.
|
||||
pub number: IssueNumber,
|
||||
/// Human-readable title.
|
||||
pub title: String,
|
||||
/// Markdown description.
|
||||
pub description: MarkdownDoc,
|
||||
/// Lifecycle status.
|
||||
pub status: IssueStatus,
|
||||
/// Priority.
|
||||
pub priority: IssuePriority,
|
||||
/// Editable issue-local knowledge.
|
||||
pub carnet: MarkdownDoc,
|
||||
/// Links to other issues.
|
||||
pub links: Vec<IssueLink>,
|
||||
/// Agent references.
|
||||
pub agent_refs: Vec<AgentIssueRef>,
|
||||
/// Creator.
|
||||
pub created_by: IssueActor,
|
||||
/// Last updater.
|
||||
pub updated_by: IssueActor,
|
||||
/// Creation time, epoch milliseconds.
|
||||
pub created_at: u64,
|
||||
/// Last update time, epoch milliseconds.
|
||||
pub updated_at: u64,
|
||||
/// Optimistic-concurrency version.
|
||||
pub version: IssueVersion,
|
||||
}
|
||||
|
||||
impl Issue {
|
||||
/// Builds a new issue with version 1.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`IssueError`] when invariants are violated.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
id: IssueId,
|
||||
number: IssueNumber,
|
||||
title: impl Into<String>,
|
||||
description: MarkdownDoc,
|
||||
status: IssueStatus,
|
||||
priority: IssuePriority,
|
||||
carnet: MarkdownDoc,
|
||||
links: Vec<IssueLink>,
|
||||
agent_refs: Vec<AgentIssueRef>,
|
||||
actor: IssueActor,
|
||||
now_ms: u64,
|
||||
) -> Result<Self, IssueError> {
|
||||
let issue = Self {
|
||||
id,
|
||||
number,
|
||||
title: title.into(),
|
||||
description,
|
||||
status,
|
||||
priority,
|
||||
carnet,
|
||||
links,
|
||||
agent_refs,
|
||||
created_by: actor.clone(),
|
||||
updated_by: actor,
|
||||
created_at: now_ms,
|
||||
updated_at: now_ms,
|
||||
version: IssueVersion::INITIAL,
|
||||
};
|
||||
issue.validate()?;
|
||||
Ok(issue)
|
||||
}
|
||||
|
||||
/// Rehydrates a persisted issue.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`IssueError`] when persisted data violates invariants.
|
||||
pub fn rehydrate(issue: Self) -> Result<Self, IssueError> {
|
||||
issue.validate()?;
|
||||
Ok(issue)
|
||||
}
|
||||
|
||||
/// Returns this issue as `#<number>`.
|
||||
#[must_use]
|
||||
pub const fn reference(&self) -> IssueRef {
|
||||
IssueRef::new(self.number)
|
||||
}
|
||||
|
||||
/// Validates invariants.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`IssueError`] when an invariant is violated.
|
||||
pub fn validate(&self) -> Result<(), IssueError> {
|
||||
IssueNumber::new(self.number.get())?;
|
||||
IssueVersion::new(self.version.get())?;
|
||||
if self.title.trim().is_empty() {
|
||||
return Err(IssueError::EmptyTitle);
|
||||
}
|
||||
let own = self.reference();
|
||||
if self.links.iter().any(|link| link.target == own) {
|
||||
return Err(IssueError::SelfLink { reference: own });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Applies a mutation and increments the version.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`IssueError`] when the resulting issue violates invariants.
|
||||
pub fn mutate(
|
||||
mut self,
|
||||
actor: IssueActor,
|
||||
now_ms: u64,
|
||||
f: impl FnOnce(&mut Self),
|
||||
) -> Result<Self, IssueError> {
|
||||
f(&mut self);
|
||||
self.updated_by = actor;
|
||||
self.updated_at = now_ms;
|
||||
self.version = self.version.next();
|
||||
self.validate()?;
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Carnet projection with its optimistic version.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct IssueCarnet {
|
||||
/// Issue reference.
|
||||
pub issue_ref: IssueRef,
|
||||
/// Carnet Markdown body.
|
||||
pub carnet: MarkdownDoc,
|
||||
/// Current issue version.
|
||||
pub version: IssueVersion,
|
||||
/// Actor that last updated the carnet.
|
||||
pub updated_by: IssueActor,
|
||||
/// Last carnet update time, epoch milliseconds.
|
||||
pub updated_at: u64,
|
||||
}
|
||||
|
||||
/// Index row used by stores and list use cases.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IssueIndexEntry {
|
||||
/// Issue reference.
|
||||
pub issue_ref: IssueRef,
|
||||
/// Relative path to the issue folder.
|
||||
pub path: String,
|
||||
/// Title.
|
||||
pub title: String,
|
||||
/// Status.
|
||||
pub status: IssueStatus,
|
||||
/// Priority.
|
||||
pub priority: IssuePriority,
|
||||
/// Assigned agent ids.
|
||||
pub assigned_agent_ids: Vec<AgentId>,
|
||||
/// Last update time.
|
||||
pub updated_at: u64,
|
||||
}
|
||||
|
||||
impl From<&Issue> for IssueIndexEntry {
|
||||
fn from(issue: &Issue) -> Self {
|
||||
Self {
|
||||
issue_ref: issue.reference(),
|
||||
path: issue.number.get().to_string(),
|
||||
title: issue.title.clone(),
|
||||
status: issue.status,
|
||||
priority: issue.priority,
|
||||
assigned_agent_ids: issue
|
||||
.agent_refs
|
||||
.iter()
|
||||
.filter(|r| r.role == AgentIssueRole::Assigned)
|
||||
.map(|r| r.agent_id)
|
||||
.collect(),
|
||||
updated_at: issue.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Store-side list filter.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct IssueListFilter {
|
||||
/// Optional status filter.
|
||||
pub status: Option<IssueStatus>,
|
||||
/// Optional priority filter.
|
||||
pub priority: Option<IssuePriority>,
|
||||
/// Optional assigned agent filter.
|
||||
pub assigned_agent_id: Option<AgentId>,
|
||||
/// Optional case-insensitive text query.
|
||||
pub text: Option<String>,
|
||||
}
|
||||
|
||||
/// Domain errors for issue invariants and value objects.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum IssueError {
|
||||
/// Issue numbers are strictly positive.
|
||||
#[error("issue number must be greater than zero")]
|
||||
InvalidNumber,
|
||||
/// Issue versions are strictly positive.
|
||||
#[error("issue version must be greater than zero")]
|
||||
InvalidVersion,
|
||||
/// Issue title cannot be empty.
|
||||
#[error("issue title cannot be empty")]
|
||||
EmptyTitle,
|
||||
/// Issue reference must be exactly `#<positive integer>`.
|
||||
#[error("invalid issue reference: {0}")]
|
||||
InvalidRef(String),
|
||||
/// A link targets the same issue.
|
||||
#[error("issue {reference} cannot link to itself")]
|
||||
SelfLink {
|
||||
/// Self reference.
|
||||
reference: IssueRef,
|
||||
},
|
||||
}
|
||||
@ -39,6 +39,7 @@ pub mod fileguard;
|
||||
pub mod git;
|
||||
pub mod ids;
|
||||
pub mod input;
|
||||
pub mod issue;
|
||||
pub mod layout;
|
||||
pub mod live_state;
|
||||
pub mod mailbox;
|
||||
@ -67,8 +68,8 @@ mod validation;
|
||||
pub use error::DomainError;
|
||||
|
||||
pub use ids::{
|
||||
AgentId, LayoutId, NodeId, ProfileId, ProjectId, ScheduleId, SessionId, SkillId, TabId,
|
||||
TemplateId, WindowId,
|
||||
AgentId, IssueId, LayoutId, NodeId, ProfileId, ProjectId, ScheduleId, SessionId, SkillId,
|
||||
TabId, TemplateId, WindowId,
|
||||
};
|
||||
|
||||
pub use project::{Project, ProjectPath};
|
||||
@ -96,6 +97,12 @@ pub use conversation::{
|
||||
|
||||
pub use input::{AgentBusyState, AgentLiveness, InputMediator, InputSource};
|
||||
|
||||
pub use issue::{
|
||||
AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueError, IssueIndexEntry,
|
||||
IssueLink, IssueLinkKind, IssueListFilter, IssueNumber, IssuePriority, IssueRef, IssueStatus,
|
||||
IssueVersion,
|
||||
};
|
||||
|
||||
pub use live_state::{LiveEntry, LiveState, WorkStatus, FIELD_MAX_BYTES, FIELD_PREVIEW_MAX_CHARS};
|
||||
|
||||
pub use readiness::{ReadinessPolicy, ReadinessSignal};
|
||||
@ -158,8 +165,9 @@ pub use ports::{
|
||||
EmbedderEnvInspector, EmbedderEnvReport, EmbedderError, EmbedderProfileStore,
|
||||
EmbedderPromptDismissal, EmbedderPromptStore, EventBus, EventStream, ExitStatus, FileSystem,
|
||||
FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator,
|
||||
LiveStateStore, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream,
|
||||
PermissionStore, PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore,
|
||||
PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, ScheduledTask,
|
||||
Scheduler, SpawnSpec, StoreError, TemplateStore,
|
||||
IssueNumberAllocator, IssueStore, IssueStoreError, LiveStateStore, MemoryError, MemoryQuery,
|
||||
MemoryRecall, MemoryStore, Output, OutputStream, PermissionStore, PreparedContext,
|
||||
ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort,
|
||||
RemoteError, RemoteHost, RemotePath, RuntimeError, ScheduledTask, Scheduler, SpawnSpec,
|
||||
StoreError, TemplateStore,
|
||||
};
|
||||
|
||||
@ -30,6 +30,9 @@ use thiserror::Error;
|
||||
use crate::agent::AgentManifest;
|
||||
use crate::events::DomainEvent;
|
||||
use crate::ids::{AgentId, NodeId, ScheduleId, SessionId};
|
||||
use crate::issue::{
|
||||
Issue, IssueCarnet, IssueIndexEntry, IssueListFilter, IssueNumber, IssueRef, IssueVersion,
|
||||
};
|
||||
use crate::markdown::MarkdownDoc;
|
||||
use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
|
||||
use crate::permission::ProjectPermissions;
|
||||
@ -461,6 +464,28 @@ pub enum GitError {
|
||||
Operation(String),
|
||||
}
|
||||
|
||||
/// Errors from the issue store.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum IssueStoreError {
|
||||
/// The requested issue does not exist.
|
||||
#[error("issue not found")]
|
||||
NotFound,
|
||||
/// Optimistic concurrency conflict.
|
||||
#[error("issue version conflict: expected {expected}, actual {actual}")]
|
||||
VersionConflict {
|
||||
/// Expected version.
|
||||
expected: IssueVersion,
|
||||
/// Actual persisted version.
|
||||
actual: IssueVersion,
|
||||
},
|
||||
/// The issue payload is invalid.
|
||||
#[error("issue invalid: {0}")]
|
||||
Invalid(String),
|
||||
/// Store I/O or serialization failed.
|
||||
#[error("issue store failed: {0}")]
|
||||
Store(String),
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Git port support types
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -1191,6 +1216,84 @@ pub trait PermissionStore: Send + Sync {
|
||||
) -> Result<(), StoreError>;
|
||||
}
|
||||
|
||||
/// Allocates monotonic per-project issue numbers.
|
||||
#[async_trait]
|
||||
pub trait IssueNumberAllocator: Send + Sync {
|
||||
/// Allocates the next number for `root`.
|
||||
///
|
||||
/// Implementations must never reuse an allocated number, even if a later
|
||||
/// issue creation step fails.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`IssueStoreError`] on persistence/lock failure.
|
||||
async fn allocate_next(&self, root: &ProjectPath) -> Result<IssueNumber, IssueStoreError>;
|
||||
}
|
||||
|
||||
/// Persistence port for project-scoped issues.
|
||||
#[async_trait]
|
||||
pub trait IssueStore: Send + Sync {
|
||||
/// Creates a new issue.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`IssueStoreError`] when the issue already exists or cannot be stored.
|
||||
async fn create(&self, root: &ProjectPath, issue: &Issue) -> Result<(), IssueStoreError>;
|
||||
|
||||
/// Reads an issue by `#N` reference.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`IssueStoreError::NotFound`] when absent.
|
||||
async fn get_by_ref(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
issue_ref: IssueRef,
|
||||
) -> Result<Issue, IssueStoreError>;
|
||||
|
||||
/// Lists issue index entries matching `filter`.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`IssueStoreError`] on persistence failure.
|
||||
async fn list(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
filter: IssueListFilter,
|
||||
) -> Result<Vec<IssueIndexEntry>, IssueStoreError>;
|
||||
|
||||
/// Replaces an issue after checking its expected version.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`IssueStoreError::VersionConflict`] on optimistic-concurrency conflict.
|
||||
async fn update(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
issue: &Issue,
|
||||
expected_version: IssueVersion,
|
||||
) -> Result<(), IssueStoreError>;
|
||||
|
||||
/// Reads only the issue carnet projection.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`IssueStoreError::NotFound`] when absent.
|
||||
async fn read_carnet(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
issue_ref: IssueRef,
|
||||
) -> Result<IssueCarnet, IssueStoreError>;
|
||||
|
||||
/// Replaces an issue carnet and increments the issue version.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`IssueStoreError::VersionConflict`] on optimistic-concurrency conflict.
|
||||
async fn write_carnet(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
issue_ref: IssueRef,
|
||||
carnet: MarkdownDoc,
|
||||
actor: crate::issue::IssueActor,
|
||||
now_ms: u64,
|
||||
expected_version: IssueVersion,
|
||||
) -> Result<IssueCarnet, IssueStoreError>;
|
||||
}
|
||||
|
||||
/// Git operations for a project. Named `GitPort` to avoid clashing with the
|
||||
/// [`crate::git::GitRepository`] *entity* (state image).
|
||||
#[async_trait]
|
||||
|
||||
Reference in New Issue
Block a user