feat: gate ticket readiness on validated targets

This commit is contained in:
2026-08-17 14:23:47 +09:00
parent 063f203efe
commit a2cd860199
11 changed files with 1106 additions and 375 deletions
+576 -113
View File
@@ -19,6 +19,7 @@ use project_record::{allocate_record_id, unix_epoch_millis_now, validate_record_
use rusqlite::{Connection, OptionalExtension, params};
use serde::{Deserialize, Serialize};
use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
use sha2::{Digest, Sha256};
use thiserror::Error;
pub mod config;
@@ -80,6 +81,32 @@ pub enum TicketError {
Locked { path: PathBuf },
#[error("ticket conflict: {0}")]
Conflict(String),
#[error("ticket target repository is required")]
MissingTargetRepository,
#[error("ticket target repository `{0}` is not registered in this workspace")]
UnknownTargetRepository(String),
#[error("ticket target selector is required for repository `{0}`")]
MissingTargetSelector(String),
#[error(
"ticket target selector `{selector}` is invalid for repository `{repository_id}`: {reason}"
)]
InvalidTargetSelector {
repository_id: String,
selector: String,
reason: String,
},
#[error("ticket target authority is unavailable")]
TargetAuthorityUnavailable,
#[error("stale ticket workflow state: expected `{expected}`, found `{actual}`")]
StaleWorkflowState { expected: String, actual: String },
#[error("invalid ticket workflow transition `{from}` -> `{to}`")]
InvalidWorkflowTransition { from: String, to: String },
#[error("ticket has unresolved blocking relations: {0}")]
BlockingRelations(String),
#[error(
"ticket operation key `{operation_key}` was reused with a different request fingerprint"
)]
OperationFingerprintMismatch { operation_key: String },
#[error("SQLite ticket backend error: {0}")]
Sqlite(String),
#[error("ticket parse error in {path}: {message}")]
@@ -475,6 +502,35 @@ pub enum TicketTargetEdit {
Clear,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolvedTicketTarget {
pub repository_id: String,
pub ref_selector: String,
}
/// Workspace-owned authority used to resolve and validate implementation targets.
///
/// Ticket storage never infers repositories from cwd or repository paths. The
/// Workspace Backend supplies this boundary from its authoritative repository
/// catalog. Backends without it fail closed for ready/queue transitions.
pub trait TicketTargetAuthority: Send + Sync {
fn resolve_target(
&self,
workspace_id: &str,
repository_id: Option<&str>,
ref_selector: Option<&str>,
) -> Result<ResolvedTicketTarget>;
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketMarkReady {
pub operation_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
}
impl TicketTargetEdit {
fn validate(&self) -> Result<()> {
if let Self::Set {
@@ -504,6 +560,106 @@ fn validate_ticket_target(repository_id: Option<&str>, ref_selector: Option<&str
Ok(())
}
fn resolve_ready_target(
authority: Option<&Arc<dyn TicketTargetAuthority>>,
workspace_id: &str,
ticket: &Ticket,
) -> Result<ResolvedTicketTarget> {
authority
.ok_or(TicketError::TargetAuthorityUnavailable)?
.resolve_target(
workspace_id,
ticket.meta.repository_id.as_deref(),
ticket.meta.ref_selector.as_deref(),
)
}
fn mark_ready_fingerprint(
ticket: &Ticket,
request: &TicketMarkReady,
target: &ResolvedTicketTarget,
) -> String {
let mut digest = Sha256::new();
digest.update(b"ticket.mark-ready.v1\0");
digest.update(ticket.meta.id.as_str().as_bytes());
digest.update(b"\0planning\0");
digest.update(target.repository_id.as_bytes());
digest.update(b"\0");
digest.update(target.ref_selector.as_bytes());
digest.update(b"\0");
if let Some(reason) = request.reason.as_deref() {
digest.update(reason.as_bytes());
}
digest
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
fn validate_mark_ready_replay(ticket: &Ticket, request: &TicketMarkReady) -> Result<bool> {
let Some(event) = ticket
.events
.iter()
.find(|event| event.attributes.get("operation_key") == Some(&request.operation_key))
else {
return Ok(false);
};
let target = ResolvedTicketTarget {
repository_id: event
.attributes
.get("repository_id")
.cloned()
.ok_or_else(|| {
TicketError::Conflict("mark-ready event is missing repository_id".to_owned())
})?,
ref_selector: event
.attributes
.get("ref_selector")
.cloned()
.ok_or_else(|| {
TicketError::Conflict("mark-ready event is missing ref_selector".to_owned())
})?,
};
let fingerprint = mark_ready_fingerprint(ticket, request, &target);
if event.attributes.get("request_fingerprint") != Some(&fingerprint) {
return Err(TicketError::OperationFingerprintMismatch {
operation_key: request.operation_key.clone(),
});
}
if ticket.meta.workflow_state != TicketWorkflowState::Ready
|| ticket.meta.repository_id.as_deref() != Some(target.repository_id.as_str())
|| ticket.meta.ref_selector.as_deref() != Some(target.ref_selector.as_str())
{
return Err(TicketError::StaleWorkflowState {
expected: TicketWorkflowState::Ready.as_str().to_owned(),
actual: ticket.meta.workflow_state.as_str().to_owned(),
});
}
Ok(true)
}
fn validate_generic_state_change(
current: TicketWorkflowState,
to: TicketWorkflowState,
) -> Result<()> {
if current == TicketWorkflowState::Planning && to == TicketWorkflowState::Ready
|| current == TicketWorkflowState::Ready && to == TicketWorkflowState::Queued
{
return Err(TicketError::InvalidWorkflowTransition {
from: current.as_str().to_owned(),
to: to.as_str().to_owned(),
});
}
if !TicketWorkflowState::is_role_transition(current, to) {
return Err(TicketError::InvalidWorkflowTransition {
from: current.as_str().to_owned(),
to: to.as_str().to_owned(),
});
}
Ok(())
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketItemEdit {
pub title: Option<String>,
@@ -1524,12 +1680,7 @@ pub trait TicketBackend {
change: TicketStateChange,
) -> Result<()>;
fn set_workflow_state(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()>;
fn mark_intake_ready(
&self,
id: TicketIdOrSlug,
summary: TicketIntakeSummary,
change: TicketStateChange,
) -> Result<()>;
fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> Result<Ticket>;
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> Result<()>;
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()>;
fn add_ticket_relation(
@@ -1605,10 +1756,9 @@ pub enum TicketBackendOperation {
id: TicketIdOrSlug,
change: TicketStateChange,
},
MarkIntakeReady {
MarkReady {
id: TicketIdOrSlug,
summary: TicketIntakeSummary,
change: TicketStateChange,
request: TicketMarkReady,
},
QueueReady {
id: TicketIdOrSlug,
@@ -1710,13 +1860,8 @@ where
backend.set_workflow_state(id, change)?;
TicketBackendOperationResult::Unit
}
TicketBackendOperation::MarkIntakeReady {
id,
summary,
change,
} => {
backend.mark_intake_ready(id, summary, change)?;
TicketBackendOperationResult::Unit
TicketBackendOperation::MarkReady { id, request } => {
TicketBackendOperationResult::Ticket(backend.mark_ready(id, request)?)
}
TicketBackendOperation::QueueReady { id, queued_by } => {
backend.queue_ready(id, &queued_by)?;
@@ -1756,10 +1901,25 @@ where
})
}
#[derive(Debug, Clone)]
#[derive(Clone)]
pub struct LocalTicketBackend {
root: PathBuf,
record_language: Option<String>,
target_authority: Option<Arc<dyn TicketTargetAuthority>>,
}
impl fmt::Debug for LocalTicketBackend {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("LocalTicketBackend")
.field("root", &self.root)
.field("record_language", &self.record_language)
.field(
"target_authority",
&self.target_authority.as_ref().map(|_| "configured"),
)
.finish()
}
}
impl LocalTicketBackend {
@@ -1767,6 +1927,7 @@ impl LocalTicketBackend {
Self {
root: root.into(),
record_language: None,
target_authority: None,
}
}
@@ -1775,6 +1936,11 @@ impl LocalTicketBackend {
self
}
pub fn with_target_authority(mut self, authority: Arc<dyn TicketTargetAuthority>) -> Self {
self.target_authority = Some(authority);
self
}
pub fn record_language(&self) -> Option<&str> {
self.record_language.as_deref()
}
@@ -2076,6 +2242,16 @@ impl LocalTicketBackend {
dir: &Path,
change: &TicketStateChange,
state_field: Option<&str>,
) -> Result<()> {
self.append_state_changed_event_with_attributes(dir, change, state_field, &[])
}
fn append_state_changed_event_with_attributes(
&self,
dir: &Path,
change: &TicketStateChange,
state_field: Option<&str>,
extra_attributes: &[(&str, &str)],
) -> Result<()> {
validate_state_change(change)?;
let author = change.author.clone().unwrap_or_else(default_author);
@@ -2087,6 +2263,7 @@ impl LocalTicketBackend {
if let Some(state_field) = state_field {
attrs.push(("field", state_field));
}
attrs.extend_from_slice(extra_attributes);
self.append_thread_event(
dir,
TicketEventKind::StateChanged.as_str(),
@@ -2247,6 +2424,7 @@ pub struct SqliteTicketBackend {
record_language: Option<String>,
event_attributes: BTreeMap<String, String>,
mutation_hook: Option<Arc<SqliteTicketMutationHook>>,
target_authority: Option<Arc<dyn TicketTargetAuthority>>,
#[cfg(test)]
full_ticket_load_count: Arc<AtomicUsize>,
}
@@ -2263,6 +2441,10 @@ impl fmt::Debug for SqliteTicketBackend {
"mutation_hook",
&self.mutation_hook.as_ref().map(|_| "configured"),
)
.field(
"target_authority",
&self.target_authority.as_ref().map(|_| "configured"),
)
.finish()
}
}
@@ -2275,6 +2457,7 @@ impl SqliteTicketBackend {
record_language: None,
event_attributes: BTreeMap::new(),
mutation_hook: None,
target_authority: None,
#[cfg(test)]
full_ticket_load_count: Arc::new(AtomicUsize::new(0)),
}
@@ -2316,6 +2499,11 @@ impl SqliteTicketBackend {
self
}
pub fn with_target_authority(mut self, authority: Arc<dyn TicketTargetAuthority>) -> Self {
self.target_authority = Some(authority);
self
}
pub fn db_path(&self) -> &Path {
self.db_path.as_path()
}
@@ -3166,6 +3354,15 @@ impl TicketBackend for SqliteTicketBackend {
validate_required_event_value("author", author)?;
}
let ticket_id = self.resolve_ticket_id(conn, id)?;
if edit.target.is_some() {
let current = self.load_ticket(conn, &ticket_id)?.meta.workflow_state;
if current != TicketWorkflowState::Planning {
return Err(TicketError::Conflict(format!(
"ticket implementation target is locked after planning (current state: {})",
current.as_str()
)));
}
}
let now = now_utc();
let mut body_edit_audit = TicketBodyEditAudit::None;
if let Some(title) = edit.title.as_ref() {
@@ -3331,10 +3528,41 @@ impl TicketBackend for SqliteTicketBackend {
}
fn set_workflow_state(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()> {
validate_state_change(&change)?;
let from = TicketWorkflowState::parse(&change.from).ok_or_else(|| {
TicketError::InvalidWorkflowTransition {
from: change.from.clone(),
to: change.to.clone(),
}
})?;
let to = TicketWorkflowState::parse(&change.to).ok_or_else(|| {
TicketError::InvalidWorkflowTransition {
from: change.from.clone(),
to: change.to.clone(),
}
})?;
validate_generic_state_change(from, to)?;
self.with_write(|conn| {
validate_state_change(&change)?;
let ticket_id = self.resolve_ticket_id(conn, id)?;
let to = TicketWorkflowState::parse(&change.to).ok_or_else(|| TicketError::Conflict(format!("unknown workflow_state '{}':", change.to)))?;
let current = self.load_ticket(conn, &ticket_id)?.meta.workflow_state;
if current != from {
return Err(TicketError::StaleWorkflowState {
expected: from.as_str().to_owned(),
actual: current.as_str().to_owned(),
});
}
if from == TicketWorkflowState::Queued && to == TicketWorkflowState::InProgress {
let ticket = self.load_ticket(conn, &ticket_id)?;
let blockers = ticket
.relations
.blockers
.into_iter()
.filter(|blocker| !relation_blocker_allows_queue(blocker))
.collect::<Vec<_>>();
if !blockers.is_empty() {
return Err(TicketError::BlockingRelations(format_relation_blockers(&blockers)));
}
}
let at = now_utc();
self.insert_event(conn, &ticket_id, &TicketEvent { kind: TicketEventKind::StateChanged, author: Some(change.author.clone().unwrap_or_else(default_author)), at: Some(at.clone()), status: None, from: Some(change.from), to: Some(change.to), reason: Some(change.reason), state_field: Some("state".to_string()), heading: Some(TicketEventKind::StateChanged.heading()), body: change.body, references: change.references, attributes: BTreeMap::new() })?;
conn.execute("UPDATE typed_tickets SET workflow_state = ?3, workflow_state_explicit = 1, updated_at = ?4, status = CASE WHEN ?3 = 'closed' THEN 'closed' ELSE status END WHERE workspace_id = ?1 AND ticket_id = ?2", params![self.workspace_id, ticket_id, to.as_str(), at]).map_err(sqlite_err)?;
@@ -3342,24 +3570,98 @@ impl TicketBackend for SqliteTicketBackend {
})
}
fn mark_intake_ready(
&self,
id: TicketIdOrSlug,
summary: TicketIntakeSummary,
change: TicketStateChange,
) -> Result<()> {
self.add_intake_summary(id.clone(), summary)?;
self.set_workflow_state(id, change)
}
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> Result<()> {
fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> Result<Ticket> {
validate_required_event_value("operation_key", &request.operation_key)?;
self.with_write(|conn| {
let ticket_id = self.resolve_ticket_id(conn, id)?;
let ticket = self.load_ticket(conn, &ticket_id)?;
if ticket.meta.workflow_state != TicketWorkflowState::Ready { return Err(TicketError::Conflict(format!("Ticket state is {}; only ready Tickets can be queued", ticket.meta.workflow_state.as_str()))); }
if validate_mark_ready_replay(&ticket, &request)? {
return Ok(ticket);
}
let target = resolve_ready_target(
self.target_authority.as_ref(),
&self.workspace_id,
&ticket,
)?;
let fingerprint = mark_ready_fingerprint(&ticket, &request, &target);
if ticket.meta.workflow_state != TicketWorkflowState::Planning {
return Err(TicketError::StaleWorkflowState {
expected: TicketWorkflowState::Planning.as_str().to_owned(),
actual: ticket.meta.workflow_state.as_str().to_owned(),
});
}
let reason = request
.reason
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("implementation target validated")
.to_owned();
let at = now_utc();
conn.execute("UPDATE typed_tickets SET workflow_state = 'queued', workflow_state_explicit = 1, queued_by = ?3, queued_at = ?4, updated_at = ?4 WHERE workspace_id = ?1 AND ticket_id = ?2", params![self.workspace_id, ticket_id, queued_by, at]).map_err(sqlite_err)?;
self.insert_event(conn, &ticket_id, &TicketEvent { kind: TicketEventKind::StateChanged, author: Some(queued_by.to_string()), at: Some(at), status: None, from: Some("ready".to_string()), to: Some("queued".to_string()), reason: Some("queued".to_string()), state_field: Some("state".to_string()), heading: Some(TicketEventKind::StateChanged.heading()), body: MarkdownText::new(format!("Queued for Orchestrator by {queued_by}.")), references: Vec::new(), attributes: BTreeMap::new() })
self.insert_event(
conn,
&ticket_id,
&TicketEvent {
kind: TicketEventKind::StateChanged,
author: Some(request.author.unwrap_or_else(default_author)),
at: Some(at.clone()),
status: None,
from: Some(TicketWorkflowState::Planning.as_str().to_owned()),
to: Some(TicketWorkflowState::Ready.as_str().to_owned()),
reason: Some(reason),
state_field: Some("state".to_owned()),
heading: Some(TicketEventKind::StateChanged.heading()),
body: MarkdownText::new(format!(
"Implementation target `{}` at selector `{}` was validated and the Ticket was marked ready.",
target.repository_id, target.ref_selector
)),
references: Vec::new(),
attributes: BTreeMap::from([
("operation_key".to_owned(), request.operation_key),
("request_fingerprint".to_owned(), fingerprint),
("repository_id".to_owned(), target.repository_id.clone()),
("ref_selector".to_owned(), target.ref_selector.clone()),
]),
},
)?;
conn.execute(
"UPDATE typed_tickets SET workflow_state = 'ready', workflow_state_explicit = 1, repository_id = ?3, ref_selector = ?4, updated_at = ?5 WHERE workspace_id = ?1 AND ticket_id = ?2 AND workflow_state = 'planning'",
params![self.workspace_id, ticket_id, target.repository_id, target.ref_selector, at],
)
.map_err(sqlite_err)?;
self.load_ticket(conn, &ticket_id)
})
}
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> Result<()> {
validate_required_event_value("queued_by", queued_by)?;
self.with_write(|conn| {
let ticket_id = self.resolve_ticket_id(conn, id)?;
let ticket = self.load_ticket(conn, &ticket_id)?;
if ticket.meta.workflow_state != TicketWorkflowState::Ready {
return Err(TicketError::StaleWorkflowState {
expected: TicketWorkflowState::Ready.as_str().to_owned(),
actual: ticket.meta.workflow_state.as_str().to_owned(),
});
}
let target = resolve_ready_target(
self.target_authority.as_ref(),
&self.workspace_id,
&ticket,
)?;
let blockers = ticket
.relations
.blockers
.iter()
.filter(|blocker| !relation_blocker_allows_queue(blocker))
.cloned()
.collect::<Vec<_>>();
if !blockers.is_empty() {
return Err(TicketError::BlockingRelations(format_relation_blockers(&blockers)));
}
let at = now_utc();
conn.execute("UPDATE typed_tickets SET workflow_state = 'queued', workflow_state_explicit = 1, queued_by = ?3, queued_at = ?4, repository_id = ?5, ref_selector = ?6, updated_at = ?4 WHERE workspace_id = ?1 AND ticket_id = ?2 AND workflow_state = 'ready'", params![self.workspace_id, ticket_id, queued_by, at, target.repository_id, target.ref_selector]).map_err(sqlite_err)?;
self.insert_event(conn, &ticket_id, &TicketEvent { kind: TicketEventKind::StateChanged, author: Some(queued_by.to_string()), at: Some(at.clone()), status: None, from: Some("ready".to_string()), to: Some("queued".to_string()), reason: Some("queued".to_string()), state_field: Some("state".to_string()), heading: Some(TicketEventKind::StateChanged.heading()), body: MarkdownText::new(format!("Queued for Orchestrator by {queued_by}.")), references: Vec::new(), attributes: BTreeMap::from([("queued_by".to_owned(), queued_by.to_owned()), ("queued_at".to_owned(), at), ("repository_id".to_owned(), target.repository_id), ("ref_selector".to_owned(), target.ref_selector)]) })
})
}
@@ -3676,6 +3978,15 @@ impl TicketBackend for LocalTicketBackend {
let _lock = self.acquire_lock()?;
let dir = self.find_ticket_dir(&id)?;
let item = dir.join("item.md");
if edit.target.is_some() {
let current = self.ticket_workflow_state_from_dir(&dir)?;
if current != TicketWorkflowState::Planning {
return Err(TicketError::Conflict(format!(
"ticket implementation target is locked after planning (current state: {})",
current.as_str()
)));
}
}
let mut content = fs::read_to_string(&item).map_err(|e| io_err(&item, e))?;
let mut body_edit_audit = TicketBodyEditAudit::None;
let mut updates = Vec::new();
@@ -3892,13 +4203,7 @@ impl TicketBackend for LocalTicketBackend {
change.to
))
})?;
if !TicketWorkflowState::is_role_transition(from, to) {
return Err(TicketError::Conflict(format!(
"workflow_state transition {} -> {} is not allowed through set_workflow_state; use dedicated planning-ready or queue APIs for gated transitions",
from.as_str(),
to.as_str()
)));
}
validate_generic_state_change(from, to)?;
let _lock = self.acquire_lock()?;
let dir = self.find_ticket_dir(&id)?;
if from == TicketWorkflowState::Queued && to == TicketWorkflowState::InProgress {
@@ -3916,43 +4221,58 @@ impl TicketBackend for LocalTicketBackend {
self.apply_workflow_state_change(&dir, from, to, change, &[])
}
fn mark_intake_ready(
&self,
id: TicketIdOrSlug,
summary: TicketIntakeSummary,
change: TicketStateChange,
) -> Result<()> {
let from = TicketWorkflowState::parse(&change.from).ok_or_else(|| {
TicketError::Conflict(format!(
"invalid workflow_state transition source: {}",
change.from
))
})?;
let to = TicketWorkflowState::parse(&change.to).ok_or_else(|| {
TicketError::Conflict(format!(
"invalid workflow_state transition target: {}",
change.to
))
})?;
if !TicketWorkflowState::is_planning_ready_transition(from, to) {
return Err(TicketError::Conflict(format!(
"mark_intake_ready only allows state planning -> ready, got {} -> {}",
from.as_str(),
to.as_str()
)));
}
fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> Result<Ticket> {
validate_required_event_value("operation_key", &request.operation_key)?;
let _lock = self.acquire_lock()?;
let dir = self.find_ticket_dir(&id)?;
let current = self.ticket_workflow_state_from_dir(&dir)?;
if current != from {
return Err(TicketError::Conflict(format!(
"state changed concurrently: expected `{}`, found `{}`",
from.as_str(),
current.as_str()
)));
let ticket = self.ticket_from_dir(&dir)?;
if validate_mark_ready_replay(&ticket, &request)? {
return Ok(ticket);
}
self.append_intake_summary_event(&dir, &summary)?;
self.apply_workflow_state_change(&dir, from, to, change, &[])
let target = resolve_ready_target(self.target_authority.as_ref(), "local", &ticket)?;
let fingerprint = mark_ready_fingerprint(&ticket, &request, &target);
if ticket.meta.workflow_state != TicketWorkflowState::Planning {
return Err(TicketError::StaleWorkflowState {
expected: TicketWorkflowState::Planning.as_str().to_owned(),
actual: ticket.meta.workflow_state.as_str().to_owned(),
});
}
let reason = request
.reason
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("implementation target validated");
let mut change = TicketStateChange::new(
TicketWorkflowState::Planning.as_str(),
TicketWorkflowState::Ready.as_str(),
reason,
MarkdownText::new(format!(
"Implementation target `{}` at selector `{}` was validated and the Ticket was marked ready.",
target.repository_id, target.ref_selector
)),
);
change.author = request.author.or_else(|| Some(default_author()));
self.append_state_changed_event_with_attributes(
&dir,
&change,
Some("state"),
&[
("operation_key", request.operation_key.as_str()),
("request_fingerprint", fingerprint.as_str()),
("repository_id", target.repository_id.as_str()),
("ref_selector", target.ref_selector.as_str()),
],
)?;
self.set_frontmatter_fields(
&dir.join("item.md"),
&[
("state", TicketWorkflowState::Ready.as_str()),
("repository_id", target.repository_id.as_str()),
("ref_selector", target.ref_selector.as_str()),
],
)?;
self.ticket_from_dir(&dir)
}
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> Result<()> {
@@ -3961,14 +4281,16 @@ impl TicketBackend for LocalTicketBackend {
let dir = self.find_ticket_dir(&id)?;
let item = dir.join("item.md");
let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?;
let ticket = self.ticket_from_dir(&dir)?;
let target = resolve_ready_target(self.target_authority.as_ref(), "local", &ticket)?;
let blockers = self.relation_blockers_for_meta(&meta)?;
let active_blockers = blockers
.into_iter()
.filter(|blocker| !relation_blocker_allows_queue(blocker))
.collect::<Vec<_>>();
if !active_blockers.is_empty() {
return Err(TicketError::Conflict(format!(
"ticket {} has unresolved blocking relation(s): {}",
return Err(TicketError::BlockingRelations(format!(
"{}: {}",
meta.id,
format_relation_blockers(&active_blockers)
)));
@@ -3986,7 +4308,12 @@ impl TicketBackend for LocalTicketBackend {
TicketWorkflowState::Ready,
TicketWorkflowState::Queued,
change,
&[("queued_by", queued_by), ("queued_at", at.as_str())],
&[
("queued_by", queued_by),
("queued_at", at.as_str()),
("repository_id", target.repository_id.as_str()),
("ref_selector", target.ref_selector.as_str()),
],
)
}
@@ -4723,9 +5050,17 @@ fn invalid_ticket_record_reason(error: &TicketError) -> &'static str {
TicketError::Locked { .. } => "ticket backend is locked",
TicketError::Sqlite(_) => "could not read ticket record",
TicketError::NotFound(_) => "ticket record is missing",
TicketError::Ambiguous { .. } | TicketError::Conflict(_) => {
"invalid ticket record metadata"
}
TicketError::Ambiguous { .. }
| TicketError::Conflict(_)
| TicketError::MissingTargetRepository
| TicketError::UnknownTargetRepository(_)
| TicketError::MissingTargetSelector(_)
| TicketError::InvalidTargetSelector { .. }
| TicketError::TargetAuthorityUnavailable
| TicketError::StaleWorkflowState { .. }
| TicketError::InvalidWorkflowTransition { .. }
| TicketError::BlockingRelations(_)
| TicketError::OperationFingerprintMismatch { .. } => "invalid ticket record metadata",
}
}
@@ -6064,8 +6399,32 @@ mod tests {
use super::*;
use tempfile::TempDir;
#[derive(Debug)]
struct TestTargetAuthority;
impl TicketTargetAuthority for TestTargetAuthority {
fn resolve_target(
&self,
_workspace_id: &str,
repository_id: Option<&str>,
ref_selector: Option<&str>,
) -> Result<ResolvedTicketTarget> {
let repository_id = repository_id.unwrap_or("main");
if repository_id == "unknown" {
return Err(TicketError::UnknownTargetRepository(
repository_id.to_owned(),
));
}
Ok(ResolvedTicketTarget {
repository_id: repository_id.to_owned(),
ref_selector: ref_selector.unwrap_or("develop").to_owned(),
})
}
}
fn backend(dir: &TempDir) -> LocalTicketBackend {
LocalTicketBackend::new(dir.path().join("tickets"))
.with_target_authority(Arc::new(TestTargetAuthority))
}
fn assert_ticket_target_edit_semantics<B: TicketBackend>(backend: &B) {
@@ -6574,6 +6933,91 @@ state: planning
assert_ticket_target_edit_semantics(&backend);
}
#[test]
fn sqlite_mark_ready_and_queue_enforce_target_and_blockers_atomically() {
let tmp = TempDir::new().unwrap();
let backend = SqliteTicketBackend::open(tmp.path().join("workspace.db"), "workspace-test")
.unwrap()
.with_target_authority(Arc::new(TestTargetAuthority));
let mut dependency = NewTicket::new("Dependency");
dependency.repository_id = Some("main".to_owned());
let dependency = backend.create(dependency).unwrap();
let mut implementation = NewTicket::new("Implementation");
implementation.repository_id = Some("main".to_owned());
let implementation = backend.create(implementation).unwrap();
backend
.add_ticket_relation(
TicketIdOrSlug::Id(implementation.id.clone()),
NewTicketRelation {
kind: TicketRelationKind::DependsOn,
target: dependency.id.clone(),
note: None,
author: Some("test".to_owned()),
},
)
.unwrap();
let request = TicketMarkReady {
operation_key: "sqlite-ready".to_owned(),
reason: Some("target accepted".to_owned()),
author: Some("test".to_owned()),
};
let ready = backend
.mark_ready(
TicketIdOrSlug::Id(implementation.id.clone()),
request.clone(),
)
.unwrap();
assert_eq!(ready.meta.ref_selector.as_deref(), Some("develop"));
let replay = backend
.mark_ready(TicketIdOrSlug::Id(implementation.id.clone()), request)
.unwrap();
assert_eq!(
replay
.events
.iter()
.filter(|event| event.attributes.contains_key("operation_key"))
.count(),
1
);
assert!(matches!(
backend.queue_ready(
TicketIdOrSlug::Id(implementation.id.clone()),
"orchestrator",
),
Err(TicketError::BlockingRelations(_))
));
let after_rejection = backend
.show(TicketIdOrSlug::Id(implementation.id.clone()))
.unwrap();
assert_eq!(
after_rejection.meta.workflow_state,
TicketWorkflowState::Ready
);
assert!(!after_rejection.events.iter().any(|event| {
event.from.as_deref() == Some("ready") && event.to.as_deref() == Some("queued")
}));
backend
.close(
TicketIdOrSlug::Id(dependency.id),
MarkdownText::new("resolved"),
)
.unwrap();
backend
.queue_ready(
TicketIdOrSlug::Id(implementation.id.clone()),
"orchestrator",
)
.unwrap();
assert_eq!(
backend
.show(TicketIdOrSlug::Id(implementation.id))
.unwrap()
.meta
.workflow_state,
TicketWorkflowState::Queued
);
}
#[test]
fn sqlite_backend_persists_and_edits_ticket_target() {
let tmp = TempDir::new().unwrap();
@@ -6917,24 +7361,24 @@ state: planning
fn add_event_status_and_close_preserve_local_layout() {
let tmp = TempDir::new().unwrap();
let backend = backend(&tmp);
let ticket = backend.create(NewTicket::new("Flow Ticket")).unwrap();
let mut input = NewTicket::new("Flow Ticket");
input.repository_id = Some("main".to_owned());
let ticket = backend.create(input).unwrap();
backend
.add_event(
TicketIdOrSlug::Id(ticket.id.clone()),
NewTicketEvent::new(TicketEventKind::Plan, "Implementation plan."),
)
.unwrap();
let mut summary = TicketIntakeSummary::new("Ready for queue.");
summary.author = Some("test".to_string());
let mut change = TicketStateChange::new(
"planning",
"ready",
"ready_for_queue",
MarkdownText::new("Ready for queue."),
);
change.author = Some("test".to_string());
backend
.mark_intake_ready(TicketIdOrSlug::Id(ticket.id.clone()), summary, change)
.mark_ready(
TicketIdOrSlug::Id(ticket.id.clone()),
TicketMarkReady {
operation_key: "test-flow-ready".to_owned(),
reason: Some("ready_for_queue".to_owned()),
author: Some("test".to_owned()),
},
)
.unwrap();
let current_item = tmp.path().join("tickets").join(&ticket.id).join("item.md");
assert!(current_item.exists());
@@ -7127,6 +7571,8 @@ state: planning
let mut ready_input = NewTicket::new("Ready Workflow");
ready_input.workflow_state = Some(TicketWorkflowState::Ready);
ready_input.repository_id = Some("main".to_owned());
ready_input.ref_selector = Some("develop".to_owned());
let ready = backend.create(ready_input).unwrap();
backend
.queue_ready(TicketIdOrSlug::Id(ready.id.clone()), "workspace-panel")
@@ -7192,41 +7638,58 @@ state: planning
}
#[test]
fn mark_intake_ready_records_summary_and_state_change() {
fn mark_ready_resolves_target_and_is_idempotent() {
let tmp = TempDir::new().unwrap();
let backend = backend(&tmp);
let ticket = backend.create(NewTicket::new("Planning Ready")).unwrap();
let mut summary = TicketIntakeSummary::new("Concise accepted requirements.");
summary.author = Some("intake".to_string());
let mut change =
TicketStateChange::new("planning", "ready", "accepted", "Ticket is ready to queue.");
change.author = Some("intake".to_string());
let mut input = NewTicket::new("Planning Ready");
input.repository_id = Some("main".to_owned());
let ticket = backend.create(input).unwrap();
let request = TicketMarkReady {
operation_key: "ready-op-1".to_owned(),
reason: Some("accepted".to_owned()),
author: Some("intake".to_owned()),
};
backend
.mark_intake_ready(TicketIdOrSlug::Id(ticket.id.clone()), summary, change)
let first = backend
.mark_ready(TicketIdOrSlug::Id(ticket.id.clone()), request.clone())
.unwrap();
let record = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap();
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Ready);
assert!(
record
let second = backend
.mark_ready(TicketIdOrSlug::Id(ticket.id.clone()), request)
.unwrap();
assert_eq!(first.meta.workflow_state, TicketWorkflowState::Ready);
assert_eq!(first.meta.repository_id.as_deref(), Some("main"));
assert_eq!(first.meta.ref_selector.as_deref(), Some("develop"));
assert_eq!(first.events, second.events);
assert_eq!(
first
.events
.iter()
.any(|event| event.kind == TicketEventKind::IntakeSummary)
.filter(|event| {
event.kind == TicketEventKind::StateChanged
&& event.from.as_deref() == Some("planning")
&& event.to.as_deref() == Some("ready")
})
.count(),
1
);
assert!(record.events.iter().any(|event| {
event.kind == TicketEventKind::StateChanged
&& event.state_field.as_deref() == Some("state")
&& event.from.as_deref() == Some("planning")
&& event.to.as_deref() == Some("ready")
}));
assert!(matches!(
backend.mark_ready(
TicketIdOrSlug::Id(ticket.id),
TicketMarkReady {
operation_key: "ready-op-1".to_owned(),
reason: Some("different".to_owned()),
author: Some("intake".to_owned()),
},
),
Err(TicketError::OperationFingerprintMismatch { .. })
));
}
#[test]
fn close_sets_state_closed() {
let tmp = TempDir::new().unwrap();
let backend = backend(&tmp);
let mut input = NewTicket::new("Close Workflow");
input.workflow_state = Some(TicketWorkflowState::Queued);
let input = NewTicket::new("Close Workflow");
let ticket = backend.create(input).unwrap();
backend