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
Generated
+1
View File
@@ -4417,6 +4417,7 @@ dependencies = [
"serde",
"serde_json",
"serde_yaml",
"sha2 0.11.0",
"tempfile",
"thiserror 2.0.18",
"tokio",
+1
View File
@@ -14,6 +14,7 @@ schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
serde_yaml = "0.9.34"
sha2.workspace = true
rusqlite.workspace = true
thiserror.workspace = true
tempfile.workspace = true
+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
+103 -71
View File
@@ -16,8 +16,9 @@ use crate::{
NewTicket, NewTicketEvent, NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord,
Result as TicketResult, Ticket, TicketBackend, TicketBodyReplacement, TicketDoctorDiagnostic,
TicketDoctorReport, TicketDoctorSeverity, TicketError, TicketEventKind, TicketIdOrSlug,
TicketIntakeSummary, TicketListState, TicketRef, TicketRelation, TicketRelationKind,
TicketRelationView, TicketStateChange, TicketSummary, TicketWorkflowState, default_author,
TicketIntakeSummary, TicketListState, TicketMarkReady, TicketRef, TicketRelation,
TicketRelationKind, TicketRelationView, TicketStateChange, TicketSummary, TicketWorkflowState,
default_author,
};
const DEFAULT_LIST_LIMIT: usize = 50;
@@ -42,7 +43,7 @@ pub const TICKET_BASE_TOOL_NAMES: [&str; 14] = [
"TicketPlan",
"TicketDecision",
"TicketImplementationReport",
"TicketIntakeReady",
"TicketMarkReady",
"TicketQueue",
"TicketWorkflowState",
"TicketClose",
@@ -77,7 +78,7 @@ pub const TICKET_TOOL_NAMES: [&str; 19] = [
"TicketPlan",
"TicketDecision",
"TicketImplementationReport",
"TicketIntakeReady",
"TicketMarkReady",
"TicketQueue",
"TicketWorkflowState",
"TicketClose",
@@ -106,7 +107,7 @@ pub const TICKET_MUTATING_TOOL_NAMES: [&str; 13] = [
"TicketPlan",
"TicketDecision",
"TicketImplementationReport",
"TicketIntakeReady",
"TicketMarkReady",
"TicketQueue",
"TicketWorkflowState",
"TicketClose",
@@ -132,9 +133,9 @@ const PLAN_DESCRIPTION: &str = "Append a typed Ticket plan event. `body` is Mark
const DECISION_DESCRIPTION: &str = "Append a typed Ticket decision event. `body` is Markdown.";
const IMPLEMENTATION_REPORT_DESCRIPTION: &str =
"Append a typed Ticket implementation_report event. `body` is Markdown.";
const INTAKE_READY_DESCRIPTION: &str = "Mark an existing Ticket planning lane ready through the typed \
Ticket backend. The tool appends a bounded `intake_summary`, appends a typed `state_changed` event \
for `state`, and transitions state to `ready`.";
const MARK_READY_DESCRIPTION: &str = "Mark a planning Ticket ready through the typed Ticket backend. \
The backend atomically validates and normalizes the persisted repository/ref target, records one typed \
state_changed event, and transitions planning -> ready. `reason` is optional.";
const QUEUE_DESCRIPTION: &str = "Queue a ready Ticket for Orchestrator routing through the typed \
Ticket backend. The backend performs the gated ready -> queued transition, records queued_by/queued_at, \
and rejects unresolved blocking relations.";
@@ -174,7 +175,7 @@ fn base_tool_description(name: &str) -> &'static str {
"TicketPlan" => PLAN_DESCRIPTION,
"TicketDecision" => DECISION_DESCRIPTION,
"TicketImplementationReport" => IMPLEMENTATION_REPORT_DESCRIPTION,
"TicketIntakeReady" => INTAKE_READY_DESCRIPTION,
"TicketMarkReady" => MARK_READY_DESCRIPTION,
"TicketQueue" => QUEUE_DESCRIPTION,
"TicketWorkflowState" => WORKFLOW_STATE_DESCRIPTION,
"TicketClose" => CLOSE_DESCRIPTION,
@@ -305,13 +306,8 @@ impl TicketBackend for TicketToolBackend {
self.backend.set_workflow_state(id, change)
}
fn mark_intake_ready(
&self,
id: TicketIdOrSlug,
summary: TicketIntakeSummary,
change: TicketStateChange,
) -> TicketResult<()> {
self.backend.mark_intake_ready(id, summary, change)
fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> TicketResult<Ticket> {
self.backend.mark_ready(id, request)
}
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> TicketResult<()> {
@@ -559,17 +555,12 @@ struct TicketThreadEventParams {
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketIntakeReadyParams {
struct TicketMarkReadyParams {
/// Ticket id.
ticket: String,
/// Concise bounded intake summary to append as a typed intake_summary event.
intake_summary: String,
/// Reason attached to the state_changed event. Defaults to `planning_ready`.
/// Optional reason attached to the state_changed event.
#[serde(default)]
reason: Option<String>,
/// Optional state_changed body. If omitted, a concise default is used.
#[serde(default)]
state_change_body: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
@@ -837,7 +828,7 @@ struct TicketImplementationReportTool {
}
#[derive(Clone)]
struct TicketIntakeReadyTool {
struct TicketMarkReadyTool {
backend: TicketToolBackend,
}
@@ -899,6 +890,23 @@ impl Tool for TicketCreateTool {
_ctx: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: TicketCreateParams = parse_input("TicketCreate", input_json)?;
if params
.state
.is_some_and(|state| !matches!(state.into_state(), TicketWorkflowState::Planning))
{
return Err(backend_error(
"TicketCreate",
TicketError::InvalidWorkflowTransition {
from: "creation".to_owned(),
to: params
.state
.expect("checked non-planning state")
.into_state()
.as_str()
.to_owned(),
},
));
}
let mut input = NewTicket::new(params.title);
if let Some(body) = params.body {
input.body = MarkdownText::new(body);
@@ -1115,40 +1123,33 @@ impl_ticket_thread_event_tool!(
);
#[async_trait]
impl Tool for TicketIntakeReadyTool {
impl Tool for TicketMarkReadyTool {
async fn execute(
&self,
input_json: &str,
_ctx: llm_engine::tool::ToolExecutionContext,
ctx: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: TicketIntakeReadyParams = parse_input("TicketIntakeReady", input_json)?;
let from = TicketWorkflowState::Planning;
let reason = params
.reason
.unwrap_or_else(|| "planning_ready".to_string());
let body = params.state_change_body.unwrap_or_else(|| {
self.backend
.default_intake_ready_state_change_body(from.as_str())
});
let mut summary = TicketIntakeSummary::new(params.intake_summary);
summary.author = None;
let mut change = TicketStateChange::new(
from.as_str(),
TicketWorkflowState::Ready.as_str(),
reason,
body,
);
change.author = None;
self.backend
.mark_intake_ready(
let params: TicketMarkReadyParams = parse_input("TicketMarkReady", input_json)?;
let ticket = self
.backend
.mark_ready(
TicketIdOrSlug::Query(params.ticket.clone()),
summary,
change,
TicketMarkReady {
operation_key: format!("ticket-mark-ready:{}", ctx.call_id),
reason: params.reason,
author: None,
},
)
.map_err(|error| backend_error("TicketIntakeReady", error))?;
.map_err(|error| backend_error("TicketMarkReady", error))?;
Ok(json_output(
format!("Marked ticket {} state ready", params.ticket),
json!({ "ticket": params.ticket, "state": "ready", "ok": true }),
json!({
"ticket": ticket.meta.id,
"state": ticket.meta.workflow_state.as_str(),
"repository_id": ticket.meta.repository_id,
"ref_selector": ticket.meta.ref_selector,
"ok": true
}),
))
}
}
@@ -1726,7 +1727,7 @@ fn input_schema(name: &str) -> Value {
"TicketComment" | "TicketPlan" | "TicketDecision" | "TicketImplementationReport" => {
serde_json::to_value(schemars::schema_for!(TicketThreadEventParams))
}
"TicketIntakeReady" => serde_json::to_value(schemars::schema_for!(TicketIntakeReadyParams)),
"TicketMarkReady" => serde_json::to_value(schemars::schema_for!(TicketMarkReadyParams)),
"TicketQueue" => serde_json::to_value(schemars::schema_for!(TicketQueueParams)),
"TicketWorkflowState" => {
serde_json::to_value(schemars::schema_for!(TicketWorkflowStateParams))
@@ -1774,7 +1775,7 @@ impl_from_backend!(TicketCommentTool);
impl_from_backend!(TicketPlanTool);
impl_from_backend!(TicketDecisionTool);
impl_from_backend!(TicketImplementationReportTool);
impl_from_backend!(TicketIntakeReadyTool);
impl_from_backend!(TicketMarkReadyTool);
impl_from_backend!(TicketQueueTool);
impl_from_backend!(TicketWorkflowStateTool);
impl_from_backend!(TicketCloseTool);
@@ -1801,7 +1802,7 @@ pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition
"TicketImplementationReport",
backend.clone(),
),
tool_definition::<TicketIntakeReadyTool>("TicketIntakeReady", backend.clone()),
tool_definition::<TicketMarkReadyTool>("TicketMarkReady", backend.clone()),
tool_definition::<TicketQueueTool>("TicketQueue", backend.clone()),
tool_definition::<TicketWorkflowStateTool>("TicketWorkflowState", backend.clone()),
tool_definition::<TicketCloseTool>("TicketClose", backend.clone()),
@@ -1826,8 +1827,26 @@ mod tests {
use super::*;
use tempfile::TempDir;
#[derive(Debug)]
struct TestTargetAuthority;
impl crate::TicketTargetAuthority for TestTargetAuthority {
fn resolve_target(
&self,
_workspace_id: &str,
repository_id: Option<&str>,
ref_selector: Option<&str>,
) -> crate::Result<crate::ResolvedTicketTarget> {
Ok(crate::ResolvedTicketTarget {
repository_id: repository_id.unwrap_or("main").to_owned(),
ref_selector: ref_selector.unwrap_or("develop").to_owned(),
})
}
}
fn backend(temp: &TempDir) -> LocalTicketBackend {
LocalTicketBackend::new(temp.path().join("tickets"))
.with_target_authority(Arc::new(TestTargetAuthority))
}
fn tool(definition: ToolDefinition) -> Arc<dyn Tool> {
@@ -1877,7 +1896,7 @@ mod tests {
"TicketPlan",
"TicketDecision",
"TicketImplementationReport",
"TicketIntakeReady",
"TicketMarkReady",
"TicketQueue",
"TicketWorkflowState",
"TicketClose",
@@ -2460,16 +2479,17 @@ mod tests {
async fn ticket_workflow_tools_mark_ready_and_transition_state() {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let created = backend.create(NewTicket::new("Workflow Tool")).unwrap();
let intake_ready = tool_by_name(backend.clone(), "TicketIntakeReady");
let mut input = NewTicket::new("Workflow Tool");
input.repository_id = Some("main".to_owned());
let created = backend.create(input).unwrap();
let intake_ready = tool_by_name(backend.clone(), "TicketMarkReady");
let workflow = tool_by_name(backend.clone(), "TicketWorkflowState");
intake_ready
.execute(
&json!({
"ticket": created.id.clone(),
"intake_summary": "Requirements accepted; implementation can be queued.",
"author": "intake-worker"
"reason": "requirements accepted"
})
.to_string(),
Default::default(),
@@ -2512,12 +2532,12 @@ mod tests {
let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap();
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Done);
assert!(
record
.events
.iter()
.any(|event| event.kind == TicketEventKind::IntakeSummary)
);
assert!(record.events.iter().any(|event| {
event.kind == TicketEventKind::StateChanged
&& event.from.as_deref() == Some("planning")
&& event.to.as_deref() == Some("ready")
&& event.attributes.contains_key("request_fingerprint")
}));
let transitions = record
.events
.iter()
@@ -2661,7 +2681,11 @@ mod tests {
)
.await
.unwrap_err();
assert!(ready_error.to_string().contains("not allowed"));
assert!(
ready_error
.to_string()
.contains("invalid ticket workflow transition")
);
let mut done_input = NewTicket::new("Backward Bypass");
done_input.workflow_state = Some(TicketWorkflowState::Done);
@@ -2680,7 +2704,11 @@ mod tests {
)
.await
.unwrap_err();
assert!(backward_error.to_string().contains("not allowed"));
assert!(
backward_error
.to_string()
.contains("invalid ticket workflow transition")
);
let mut queued_input = NewTicket::new("Skip Bypass");
queued_input.workflow_state = Some(TicketWorkflowState::Queued);
@@ -2699,17 +2727,21 @@ mod tests {
)
.await
.unwrap_err();
assert!(skip_error.to_string().contains("not allowed"));
assert!(
skip_error
.to_string()
.contains("invalid ticket workflow transition")
);
}
#[tokio::test]
async fn ticket_intake_ready_tool_rejects_non_planning_ticket() {
async fn ticket_mark_ready_tool_rejects_non_planning_ticket() {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let mut input = NewTicket::new("Already Ready");
input.workflow_state = Some(TicketWorkflowState::Ready);
let created = backend.create(input).unwrap();
let intake_ready = tool_by_name(backend.clone(), "TicketIntakeReady");
let intake_ready = tool_by_name(backend.clone(), "TicketMarkReady");
let error = intake_ready
.execute(
@@ -2723,7 +2755,7 @@ mod tests {
.await
.unwrap_err();
assert!(error.to_string().contains("state changed concurrently"));
assert!(error.to_string().contains("stale ticket workflow state"));
let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap();
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Ready);
assert!(!record.events.iter().any(|event| {
@@ -2867,7 +2899,7 @@ mod tests {
"TicketPlan",
"TicketDecision",
"TicketImplementationReport",
"TicketIntakeReady",
"TicketMarkReady",
"TicketQueue",
"TicketRelationRecord",
"TicketOrchestrationPlanRecord",
+3 -3
View File
@@ -18,7 +18,7 @@ use crate::runtime::dir::RuntimeDir;
use crate::segment_log_sink::SegmentLogSink;
use crate::shared_state::WorkerSharedState;
use crate::shutdown_after_idle::{
ShutdownAfterIdleRequest, TicketIntakeReadyShutdownHook, is_ticket_intake_role,
ShutdownAfterIdleRequest, TicketMarkReadyShutdownHook, is_ticket_intake_role,
take_shutdown_request_after_status,
};
use crate::spawn::registry::SpawnedWorkerRegistry;
@@ -423,11 +423,11 @@ impl WorkerController {
.await?;
// Intake role Workers self-terminate only after a successful
// TicketIntakeReady turn has fully settled back to Idle. The request
// TicketMarkReady turn has fully settled back to Idle. The request
// is transient controller state, not model-visible context or ticket
// claim metadata.
let shutdown_after_idle = ShutdownAfterIdleRequest::default();
worker.add_post_tool_call_hook(TicketIntakeReadyShutdownHook::new(
worker.add_post_tool_call_hook(TicketMarkReadyShutdownHook::new(
shutdown_after_idle.clone(),
is_ticket_intake_role(worker.runtime_ticket_role()),
));
+23 -31
View File
@@ -381,7 +381,6 @@ const READ_ONLY_TOOL_NAMES: &[&str] = &["QueryTicket", "ShowTicket"];
const AUTHORING_TOOL_NAMES: &[&str] = &[
"TicketCreate",
"TicketEditItem",
"TicketQueue",
"TicketClose",
"TicketRelationRecord",
"TicketRelationRemove",
@@ -389,7 +388,7 @@ const AUTHORING_TOOL_NAMES: &[&str] = &[
const THREAD_TOOL_NAMES: &[&str] = &["TicketComment"];
const INTAKE_TOOL_NAMES: &[&str] = &["TicketIntakeReady"];
const INTAKE_TOOL_NAMES: &[&str] = &["TicketMarkReady"];
#[cfg(test)]
const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
@@ -398,7 +397,6 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
"QueryTicket",
"ShowTicket",
"TicketComment",
"TicketQueue",
"TicketClose",
"TicketRelationRecord",
"TicketRelationRemove",
@@ -409,6 +407,7 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[
"QueryTicket",
"ShowTicket",
"TicketComment",
"TicketQueue",
"TicketWorkflowState",
"TicketClose",
"TicketDependencyCheck",
@@ -419,6 +418,7 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[
];
const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[
"TicketQueue",
"TicketWorkflowState",
"TicketClose",
"TicketDependencyCheck",
@@ -894,16 +894,15 @@ impl WorkspaceHttpTicketBackend {
TicketError::Conflict(format!("serialize Ticket workflow change: {error}"))
})?),
),
TicketBackendOperation::MarkIntakeReady {
id,
summary,
change,
} => Self::request_unit(
TicketBackendOperation::MarkReady { id, request } => Self::request(
client,
WorkspaceRequestMethod::Post,
format!("{base}/{}/intake-ready", Self::ticket_path(&id)),
Some(serde_json::json!({ "summary": summary, "change": change })),
),
format!("{base}/{}/workflow/mark-ready", Self::ticket_path(&id)),
Some(serde_json::to_value(request).map_err(|error| {
TicketError::Conflict(format!("serialize Ticket mark-ready request: {error}"))
})?),
)
.map(TicketBackendOperationResult::Ticket),
TicketBackendOperation::QueueReady { id, .. } => Self::request_unit(
client,
WorkspaceRequestMethod::Post,
@@ -1119,22 +1118,15 @@ impl TicketBackend for WorkspaceHttpTicketBackend {
}
}
fn mark_intake_ready(
fn mark_ready(
&self,
id: TicketIdOrSlug,
summary: TicketIntakeSummary,
change: TicketStateChange,
) -> TicketResult<()> {
match self.invoke(TicketBackendOperation::MarkIntakeReady {
id,
summary,
change,
})? {
TicketBackendOperationResult::Unit => Ok(()),
other => Err(TicketError::Conflict(format!(
"unexpected ticket backend response: {other:?}"
))),
}
request: ticket::TicketMarkReady,
) -> TicketResult<Ticket> {
expect_ticket_result!(
self.invoke(TicketBackendOperation::MarkReady { id, request }),
TicketBackendOperationResult::Ticket
)
}
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> TicketResult<()> {
@@ -1303,13 +1295,13 @@ mod tests {
assert_eq!(show.name, "ShowTicket");
assert!(show.input_schema["properties"]["event_limit"].is_object());
let tool_names = TicketFeatureAccess::workspace_authoring().tool_names();
assert_eq!(tool_names.len(), 9);
assert_eq!(tool_names.len(), 8);
assert!(
tool_names.len() < 13,
"authoring catalog must stay below the prior broad catalog"
);
let workflow_names = TicketFeatureAccess::workflow().tool_names();
assert_eq!(workflow_names.len(), 10);
assert_eq!(workflow_names.len(), 11);
assert!(
workflow_names.len() < 12,
"workflow catalog must stay below the prior broad catalog"
@@ -1390,7 +1382,7 @@ mod tests {
.collect::<Vec<_>>();
assert!(workspace_tools.contains(&"TicketCreate"));
assert!(workspace_tools.contains(&"TicketEditItem"));
assert!(workspace_tools.contains(&"TicketQueue"));
assert!(!workspace_tools.contains(&"TicketQueue"));
assert!(!workspace_tools.contains(&"TicketWorkflowState"));
let orchestration =
@@ -1406,7 +1398,7 @@ mod tests {
assert!(orchestration_tools.contains(&"TicketRelationRecord"));
assert!(orchestration_tools.contains(&"TicketOrchestrationPlanRecord"));
assert!(!orchestration_tools.contains(&"TicketEditItem"));
assert!(!orchestration_tools.contains(&"TicketQueue"));
assert!(orchestration_tools.contains(&"TicketQueue"));
let work_report =
ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::work_report());
@@ -1519,8 +1511,8 @@ language = "Japanese"
assert_eq!(installed, WORKSPACE_AUTHORING_TOOL_NAMES);
assert!(installed.iter().any(|tool| *tool == "TicketCreate"));
assert!(installed.iter().any(|tool| *tool == "TicketEditItem"));
assert!(installed.iter().any(|tool| *tool == "TicketQueue"));
assert!(!installed.iter().any(|tool| *tool == "TicketIntakeReady"));
assert!(!installed.iter().any(|tool| *tool == "TicketQueue"));
assert!(!installed.iter().any(|tool| *tool == "TicketMarkReady"));
assert!(!installed.iter().any(|tool| *tool == "TicketWorkflowState"));
assert!(
!installed
+12 -12
View File
@@ -9,7 +9,7 @@ use ticket::config::TicketRole;
use crate::hook::{Hook, HookPostToolAction, PostToolCall, ToolResultSummary};
const TICKET_INTAKE_READY_TOOL_NAME: &str = "TicketIntakeReady";
const TICKET_MARK_READY_TOOL_NAME: &str = "TicketMarkReady";
#[derive(Clone, Default)]
pub(crate) struct ShutdownAfterIdleRequest {
@@ -42,12 +42,12 @@ pub(crate) fn take_shutdown_request_after_status(
status == WorkerStatus::Idle && shutdown_after_idle.take()
}
pub(crate) struct TicketIntakeReadyShutdownHook {
pub(crate) struct TicketMarkReadyShutdownHook {
shutdown_after_idle: ShutdownAfterIdleRequest,
eligible_ticket_intake_role: bool,
}
impl TicketIntakeReadyShutdownHook {
impl TicketMarkReadyShutdownHook {
pub(crate) fn new(
shutdown_after_idle: ShutdownAfterIdleRequest,
eligible_ticket_intake_role: bool,
@@ -60,7 +60,7 @@ impl TicketIntakeReadyShutdownHook {
fn observe_tool_result(&self, info: &ToolResultSummary) {
if self.eligible_ticket_intake_role
&& info.tool_name == TICKET_INTAKE_READY_TOOL_NAME
&& info.tool_name == TICKET_MARK_READY_TOOL_NAME
&& !info.is_error
{
self.shutdown_after_idle.request();
@@ -69,7 +69,7 @@ impl TicketIntakeReadyShutdownHook {
}
#[async_trait]
impl Hook<PostToolCall> for TicketIntakeReadyShutdownHook {
impl Hook<PostToolCall> for TicketMarkReadyShutdownHook {
async fn call(&self, info: &ToolResultSummary) -> HookPostToolAction {
self.observe_tool_result(info);
HookPostToolAction::Continue
@@ -98,9 +98,9 @@ mod tests {
#[test]
fn successful_ticket_intake_ready_schedules_shutdown_after_idle_for_intake_role() {
let request = ShutdownAfterIdleRequest::default();
let hook = TicketIntakeReadyShutdownHook::new(request.clone(), true);
let hook = TicketMarkReadyShutdownHook::new(request.clone(), true);
hook.observe_tool_result(&tool_result(TICKET_INTAKE_READY_TOOL_NAME, false));
hook.observe_tool_result(&tool_result(TICKET_MARK_READY_TOOL_NAME, false));
assert!(request.is_requested());
assert!(request.take());
@@ -110,9 +110,9 @@ mod tests {
#[test]
fn failed_ticket_intake_ready_does_not_schedule_shutdown_after_idle() {
let request = ShutdownAfterIdleRequest::default();
let hook = TicketIntakeReadyShutdownHook::new(request.clone(), true);
let hook = TicketMarkReadyShutdownHook::new(request.clone(), true);
hook.observe_tool_result(&tool_result(TICKET_INTAKE_READY_TOOL_NAME, true));
hook.observe_tool_result(&tool_result(TICKET_MARK_READY_TOOL_NAME, true));
assert!(!request.is_requested());
}
@@ -120,9 +120,9 @@ mod tests {
#[test]
fn non_intake_role_does_not_schedule_shutdown_after_idle() {
let request = ShutdownAfterIdleRequest::default();
let hook = TicketIntakeReadyShutdownHook::new(request.clone(), false);
let hook = TicketMarkReadyShutdownHook::new(request.clone(), false);
hook.observe_tool_result(&tool_result(TICKET_INTAKE_READY_TOOL_NAME, false));
hook.observe_tool_result(&tool_result(TICKET_MARK_READY_TOOL_NAME, false));
assert!(!request.is_requested());
}
@@ -130,7 +130,7 @@ mod tests {
#[test]
fn other_successful_tools_do_not_schedule_shutdown_after_idle() {
let request = ShutdownAfterIdleRequest::default();
let hook = TicketIntakeReadyShutdownHook::new(request.clone(), true);
let hook = TicketMarkReadyShutdownHook::new(request.clone(), true);
hook.observe_tool_result(&tool_result("ShowTicket", false));
+284 -86
View File
@@ -1060,11 +1060,18 @@ impl WorkspaceApi {
&self,
request: &WorkerSpawnRequest,
) -> ApiResult<()> {
let selected_repository_id =
let (selected_repository_id, selected_ref_selector) =
if let Some(working_directory) = request.resolved_working_directory_request.as_ref() {
let repository_id = working_directory.repository.id.as_str();
self.require_workspace_repository(repository_id)?;
Some(repository_id.to_string())
(
Some(repository_id.to_string()),
working_directory
.repository
.selector
.as_deref()
.map(str::to_owned),
)
} else if let Some(claim) = request.resolved_working_directory.as_ref() {
let workdir = self
.store
@@ -1076,20 +1083,48 @@ impl WorkspaceApi {
)))
})?;
self.require_workspace_repository(&workdir.repository_id)?;
Some(workdir.repository_id)
(Some(workdir.repository_id), workdir.creation_selector)
} else {
None
(None, None)
};
if let WorkerSpawnIntent::TicketRole { ticket_id, .. } = &request.intent {
let ticket = self.authority.ticket(ticket_id)?;
if let Some(repository_id) = ticket.repository_id.as_deref() {
self.require_workspace_repository(repository_id)?;
if selected_repository_id.as_deref() != Some(repository_id) {
return Err(ApiError::from(Error::Config(format!(
"Ticket `{ticket_id}` targets repository `{repository_id}`, but the Worker launch does not resolve that repository in this Workspace"
))));
}
// Workdir-less Ticket Workers cannot execute repository implementation.
// Preserve that control-plane launch while still validating any persisted
// target (including its Workspace ownership) when one exists.
if selected_repository_id.is_none() && ticket.repository_id.is_none() {
return Ok(());
}
let repository_id = ticket.repository_id.as_deref().ok_or_else(|| {
ApiError::from(Error::Config(
"Ticket implementation target must be validated and persisted before spawning a Ticket Worker".to_owned(),
))
})?;
let ref_selector = ticket.ref_selector.as_deref().ok_or_else(|| {
ApiError::from(Error::Config(
"Ticket implementation target selector must be validated and persisted before spawning a Ticket Worker".to_owned(),
))
})?;
self.require_workspace_repository(repository_id)?;
self.repository_reader()
.observe_merge_target(repository_id, Some(ref_selector))
.map_err(|error| {
ApiError::from(Error::Config(format!(
"Ticket implementation target is no longer resolvable: {error:?}"
)))
})?;
if selected_repository_id.as_deref() != Some(repository_id) {
return Err(ApiError::from(Error::Config(format!(
"Ticket `{ticket_id}` targets repository `{repository_id}`, but the Worker launch resolves `{}`",
selected_repository_id.as_deref().unwrap_or("none")
))));
}
if selected_ref_selector.as_deref() != Some(ref_selector) {
return Err(ApiError::from(Error::Config(format!(
"Ticket `{ticket_id}` targets selector `{ref_selector}`, but the Worker launch resolves `{}`",
selected_ref_selector.as_deref().unwrap_or("none")
))));
}
}
Ok(())
@@ -1315,8 +1350,8 @@ pub fn build_router(api: WorkspaceApi) -> Router {
post(scoped_set_ticket_workflow_state),
)
.route(
"/api/w/{workspace_id}/tickets/{id}/intake-ready",
post(scoped_prepare_ticket_intake_ready),
"/api/w/{workspace_id}/tickets/{id}/workflow/mark-ready",
post(scoped_mark_ticket_ready),
)
.route(
"/api/w/{workspace_id}/tickets/{id}/workflow/queue",
@@ -1397,6 +1432,10 @@ pub fn build_router(api: WorkspaceApi) -> Router {
"/api/w/{workspace_id}/tickets/{id}/state",
post(scoped_transition_ticket_state),
)
.route(
"/api/w/{workspace_id}/tickets/{id}/ready",
post(scoped_mark_ticket_ready_from_browser),
)
.route(
"/api/w/{workspace_id}/tickets/{id}/events",
post(scoped_append_ticket_event),
@@ -3107,6 +3146,55 @@ struct BrowserCloseTicketRequest {
resolution: String,
}
#[derive(Clone)]
struct WorkspaceTicketTargetAuthority {
api: WorkspaceApi,
}
impl ticket::TicketTargetAuthority for WorkspaceTicketTargetAuthority {
fn resolve_target(
&self,
workspace_id: &str,
repository_id: Option<&str>,
ref_selector: Option<&str>,
) -> ticket::Result<ticket::ResolvedTicketTarget> {
if workspace_id != self.api.config.workspace_id {
return Err(ticket::TicketError::UnknownTargetRepository(
repository_id.unwrap_or_default().to_owned(),
));
}
let repository_id = repository_id
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or(ticket::TicketError::MissingTargetRepository)?;
let repository = self
.api
.store
.get_repository(workspace_id, repository_id)
.map_err(|error| ticket::TicketError::Conflict(error.to_string()))?
.ok_or_else(|| {
ticket::TicketError::UnknownTargetRepository(repository_id.to_owned())
})?;
let selector = ref_selector
.map(str::trim)
.filter(|value| !value.is_empty())
.or(repository.default_ref.as_deref())
.ok_or_else(|| ticket::TicketError::MissingTargetSelector(repository_id.to_owned()))?;
self.api
.repository_reader()
.observe_merge_target(repository_id, Some(selector))
.map_err(|error| ticket::TicketError::InvalidTargetSelector {
repository_id: repository_id.to_owned(),
selector: selector.to_owned(),
reason: format!("{error:?}"),
})?;
Ok(ticket::ResolvedTicketTarget {
repository_id: repository_id.to_owned(),
ref_selector: selector.to_owned(),
})
}
}
fn browser_ticket_backend(api: &WorkspaceApi) -> Result<SqliteTicketBackend> {
let config = ticket::config::TicketConfig::load_workspace(&api.config.workspace_root)
.map_err(|error| Error::Config(format!("load Ticket workspace settings: {error}")))?;
@@ -3114,7 +3202,10 @@ fn browser_ticket_backend(api: &WorkspaceApi) -> Result<SqliteTicketBackend> {
api.config.database_path.clone(),
api.config.workspace_id.clone(),
)?
.with_record_language(config.ticket_record_language()))
.with_record_language(config.ticket_record_language())
.with_target_authority(Arc::new(WorkspaceTicketTargetAuthority {
api: api.clone(),
})))
}
fn browser_ticket_detail(api: &WorkspaceApi, ticket_id: &str) -> ApiResult<Json<TicketDetail>> {
@@ -3208,6 +3299,25 @@ async fn scoped_append_ticket_event(
browser_ticket_detail(&api, &path.id)
}
async fn scoped_mark_ticket_ready_from_browser(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRecordPath>,
Json(request): Json<TicketMarkReadyRequest>,
) -> ApiResult<Json<TicketDetail>> {
validate_workspace_scope(&api, &path.workspace_id)?;
browser_ticket_backend(&api)?
.mark_ready(
TicketIdOrSlug::Id(path.id.clone()),
ticket::TicketMarkReady {
operation_key: request.operation_key,
reason: request.reason,
author: Some("web".to_owned()),
},
)
.map_err(Error::from)?;
browser_ticket_detail(&api, &path.id)
}
async fn scoped_queue_ticket(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRecordPath>,
@@ -3269,7 +3379,10 @@ async fn execute_worker_ticket_rest_operation(
api.config.workspace_id.clone(),
)
.map_err(Error::from)?
.with_record_language(config.ticket_record_language());
.with_record_language(config.ticket_record_language())
.with_target_authority(Arc::new(WorkspaceTicketTargetAuthority {
api: api.clone(),
}));
let operation_kind = ticket_mutation_operation_kind(&operation);
let is_mutation = operation_kind != "read";
let target = ticket_mutation_target(&operation).cloned();
@@ -3421,6 +3534,15 @@ async fn scoped_create_ticket_record(
headers: HeaderMap,
Json(input): Json<ticket::NewTicket>,
) -> ApiResult<Json<ticket::TicketRef>> {
if input
.workflow_state
.is_some_and(|state| state != TicketWorkflowState::Planning)
{
return Err(settings_bad_request(
"ticket_create_state_bypass",
"Ticket creation must start in planning; use guarded workflow operations for later states",
));
}
let result = execute_worker_ticket_rest_operation(
&api,
&path.workspace_id,
@@ -3534,9 +3656,10 @@ async fn scoped_add_ticket_intake_summary(
}
#[derive(Debug, Deserialize)]
struct TicketIntakeReadyRequest {
summary: ticket::TicketIntakeSummary,
change: TicketStateChange,
struct TicketMarkReadyRequest {
operation_key: String,
#[serde(default)]
reason: Option<String>,
}
async fn scoped_set_ticket_state_field(
@@ -3578,24 +3701,30 @@ async fn scoped_set_ticket_workflow_state(
ticket_rest_unit(result)
}
async fn scoped_prepare_ticket_intake_ready(
async fn scoped_mark_ticket_ready(
State(api): State<WorkspaceApi>,
AxumPath((workspace_id, id)): AxumPath<(String, String)>,
headers: HeaderMap,
Json(request): Json<TicketIntakeReadyRequest>,
) -> ApiResult<StatusCode> {
Json(request): Json<TicketMarkReadyRequest>,
) -> ApiResult<Json<ticket::Ticket>> {
let result = execute_worker_ticket_rest_operation(
&api,
&workspace_id,
headers,
TicketBackendOperation::MarkIntakeReady {
TicketBackendOperation::MarkReady {
id: TicketIdOrSlug::Query(id),
summary: request.summary,
change: request.change,
request: ticket::TicketMarkReady {
operation_key: request.operation_key,
reason: request.reason,
author: None,
},
},
)
.await?;
ticket_rest_unit(result)
ticket_rest_result(result, |result| match result {
TicketBackendOperationResult::Ticket(ticket) => Some(ticket),
_ => None,
})
}
async fn scoped_queue_ticket_record(
@@ -4368,7 +4497,7 @@ fn ticket_mutation_target(operation: &TicketBackendOperation) -> Option<&TicketI
| TicketBackendOperation::AddIntakeSummary { id, .. }
| TicketBackendOperation::SetStateField { id, .. }
| TicketBackendOperation::SetWorkflowState { id, .. }
| TicketBackendOperation::MarkIntakeReady { id, .. }
| TicketBackendOperation::MarkReady { id, .. }
| TicketBackendOperation::QueueReady { id, .. }
| TicketBackendOperation::Close { id, .. }
| TicketBackendOperation::AddTicketRelation { id, .. }
@@ -4409,12 +4538,7 @@ fn bind_worker_ticket_operation_source(
| TicketBackendOperation::SetStateField { change, .. }
| TicketBackendOperation::SetWorkflowState { change, .. } => change.author = Some(author),
TicketBackendOperation::AddIntakeSummary { summary, .. } => summary.author = Some(author),
TicketBackendOperation::MarkIntakeReady {
summary, change, ..
} => {
summary.author = Some(author.clone());
change.author = Some(author);
}
TicketBackendOperation::MarkReady { request, .. } => request.author = Some(author),
TicketBackendOperation::QueueReady { queued_by, .. } => *queued_by = author,
TicketBackendOperation::AddTicketRelation { relation, .. } => {
relation.author = Some(author)
@@ -4435,7 +4559,7 @@ fn ticket_mutation_operation_kind(operation: &TicketBackendOperation) -> &'stati
TicketBackendOperation::AddIntakeSummary { .. } => "add_intake_summary",
TicketBackendOperation::SetStateField { .. } => "set_state_field",
TicketBackendOperation::SetWorkflowState { .. } => "set_workflow_state",
TicketBackendOperation::MarkIntakeReady { .. } => "mark_intake_ready",
TicketBackendOperation::MarkReady { .. } => "mark_ready",
TicketBackendOperation::QueueReady { .. } => "queue_ready",
TicketBackendOperation::Close { .. } => "close",
TicketBackendOperation::AddTicketRelation { .. } => "add_relation",
@@ -11883,7 +12007,26 @@ impl From<Error> for ApiError {
ticket::TicketError::NotFound(_) => "ticket_not_found",
ticket::TicketError::Ambiguous { .. } => "ticket_ambiguous",
ticket::TicketError::Locked { .. } => "ticket_locked",
ticket::TicketError::Conflict(_) => "ticket_conflict",
ticket::TicketError::Conflict(_)
| ticket::TicketError::StaleWorkflowState { .. }
| ticket::TicketError::InvalidWorkflowTransition { .. }
| ticket::TicketError::BlockingRelations(_)
| ticket::TicketError::OperationFingerprintMismatch { .. } => "ticket_conflict",
ticket::TicketError::MissingTargetRepository => {
"ticket_target_repository_missing"
}
ticket::TicketError::UnknownTargetRepository(_) => {
"ticket_target_repository_unknown"
}
ticket::TicketError::MissingTargetSelector(_) => {
"ticket_target_selector_missing"
}
ticket::TicketError::InvalidTargetSelector { .. } => {
"ticket_target_selector_invalid"
}
ticket::TicketError::TargetAuthorityUnavailable => {
"ticket_target_authority_unavailable"
}
ticket::TicketError::InvalidPathComponent(_)
| ticket::TicketError::PathEscapesRoot { .. } => "invalid_ticket_request",
ticket::TicketError::Io { .. }
@@ -13549,7 +13692,7 @@ mod tests {
config.repositories = vec![ConfiguredRepository {
id: TEST_REPOSITORY_ID.to_string(),
provider: "git".to_string(),
uri: ".".to_string(),
uri: workspace_root.display().to_string(),
path: workspace_root,
display_name: Some("Test Repository".to_string()),
default_selector: Some("HEAD".to_string()),
@@ -13706,7 +13849,7 @@ mod tests {
fn init_clean_git_workspace(path: &std::path::Path) {
for args in [
vec!["init"],
vec!["init", "--initial-branch=develop"],
vec!["config", "user.email", "test@example.invalid"],
vec!["config", "user.name", "Yoi Test"],
] {
@@ -13738,6 +13881,86 @@ mod tests {
}
}
#[tokio::test]
async fn mark_ready_resolves_workspace_target_and_closes_lifecycle_bypasses() {
let dir = tempfile::tempdir().unwrap();
init_clean_git_workspace(dir.path());
let api = test_api(dir.path()).await;
let backend = browser_ticket_backend(&api).unwrap();
let mut input = ticket::NewTicket::new("Validated target");
input.repository_id = Some(TEST_REPOSITORY_ID.to_owned());
input.ref_selector = Some("develop".to_owned());
let ticket_ref = backend.create(input).unwrap();
let request = ticket::TicketMarkReady {
operation_key: "ready-server-test".to_owned(),
reason: Some("target accepted".to_owned()),
author: Some("test".to_owned()),
};
let ready = backend
.mark_ready(TicketIdOrSlug::Id(ticket_ref.id.clone()), request.clone())
.unwrap();
assert_eq!(ready.meta.workflow_state, TicketWorkflowState::Ready);
assert_eq!(
ready.meta.repository_id.as_deref(),
Some(TEST_REPOSITORY_ID)
);
assert_eq!(ready.meta.ref_selector.as_deref(), Some("develop"));
assert_eq!(
backend
.mark_ready(TicketIdOrSlug::Id(ticket_ref.id.clone()), request)
.unwrap()
.events
.iter()
.filter(|event| event.attributes.contains_key("operation_key"))
.count(),
1
);
assert!(matches!(
backend.edit_item(
TicketIdOrSlug::Id(ticket_ref.id.clone()),
ticket::TicketItemEdit {
target: Some(ticket::TicketTargetEdit::Set {
repository_id: TEST_REPOSITORY_ID.to_owned(),
ref_selector: Some("other".to_owned()),
}),
..Default::default()
},
),
Err(ticket::TicketError::Conflict(_))
));
let mut missing = ticket::NewTicket::new("Missing target");
missing.repository_id = Some("unknown".to_owned());
let missing = backend.create(missing).unwrap();
assert!(matches!(
backend.mark_ready(
TicketIdOrSlug::Id(missing.id.clone()),
ticket::TicketMarkReady {
operation_key: "missing-repository".to_owned(),
reason: None,
author: None,
},
),
Err(ticket::TicketError::UnknownTargetRepository(_))
));
assert_eq!(
backend
.show(TicketIdOrSlug::Id(missing.id))
.unwrap()
.meta
.workflow_state,
TicketWorkflowState::Planning
);
assert!(matches!(
backend.set_workflow_state(
TicketIdOrSlug::Id(ticket_ref.id),
TicketStateChange::new("ready", "queued", "bypass", "must use TicketQueue",),
),
Err(ticket::TicketError::InvalidWorkflowTransition { .. })
));
}
#[test]
fn worker_source_actor_roles_use_canonical_vocabulary() {
assert_eq!(worker_source_actor_role(true, false), "coder");
@@ -13779,6 +14002,7 @@ mod tests {
#[tokio::test]
async fn orchestrator_ticket_notifications_project_authoritative_post_mutation_state() {
let dir = tempfile::tempdir().unwrap();
init_clean_git_workspace(dir.path());
let (api, execution) = test_api_with_recording_backend(dir.path()).await;
let source_worker = api
.runtime
@@ -13836,29 +14060,23 @@ mod tests {
let orchestrator = started.worker.unwrap().worker;
execution.take_inputs();
let ticket = browser_ticket_backend(&api)
.unwrap()
.create(ticket::NewTicket::new("Bounded notification"))
.unwrap();
let mut input = ticket::NewTicket::new("Bounded notification");
input.repository_id = Some(TEST_REPOSITORY_ID.to_owned());
input.ref_selector = Some("develop".to_owned());
let ticket = browser_ticket_backend(&api).unwrap().create(input).unwrap();
let ticket_id = TicketIdOrSlug::Id(ticket.id.clone());
let operations = [
TicketBackendOperation::SetWorkflowState {
TicketBackendOperation::MarkReady {
id: ticket_id.clone(),
change: TicketStateChange::new(
"planning",
"ready",
"ready for implementation",
"test transition",
),
request: ticket::TicketMarkReady {
operation_key: "notification-ready".to_owned(),
reason: Some("ready for implementation".to_owned()),
author: None,
},
},
TicketBackendOperation::SetWorkflowState {
TicketBackendOperation::QueueReady {
id: ticket_id.clone(),
change: TicketStateChange::new(
"ready",
"queued",
"queued for implementation",
"test transition",
),
queued_by: "spoofed".to_owned(),
},
TicketBackendOperation::SetWorkflowState {
id: ticket_id.clone(),
@@ -14263,30 +14481,11 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let backend = browser_ticket_backend(&api).unwrap();
let ticket_ref = backend
.create(ticket::NewTicket::new("Recover queued work"))
.unwrap();
backend
.mark_intake_ready(
TicketIdOrSlug::Id(ticket_ref.id.clone()),
ticket::TicketIntakeSummary {
author: Some("intake".to_string()),
body: MarkdownText::new("Ready"),
references: Vec::new(),
},
ticket::TicketStateChange {
from: "planning".to_string(),
to: "ready".to_string(),
reason: "ready".to_string(),
author: Some("intake".to_string()),
body: MarkdownText::new("Ready"),
references: Vec::new(),
},
)
.unwrap();
backend
.queue_ready(TicketIdOrSlug::Id(ticket_ref.id.clone()), "browser-user")
.unwrap();
let mut input = ticket::NewTicket::new("Recover queued work");
input.workflow_state = Some(TicketWorkflowState::Queued);
input.repository_id = Some(TEST_REPOSITORY_ID.to_owned());
input.ref_selector = Some("HEAD".to_owned());
let ticket_ref = backend.create(input).unwrap();
*api.orchestrator_attention_fingerprint.lock().unwrap() = Some(ticket_ref.id.clone());
let Json(started) = scoped_start_workspace_orchestrator(
@@ -14712,6 +14911,7 @@ mod tests {
#[tokio::test]
async fn ticket_browser_endpoints_mutate_typed_backend_and_return_thread() {
let dir = tempfile::tempdir().unwrap();
init_clean_git_workspace(dir.path());
let api = test_api(dir.path()).await;
let ticket_ref = browser_ticket_backend(&api)
.unwrap()
@@ -14751,7 +14951,7 @@ mod tests {
replace_all: false,
target: Some(TicketTargetEdit::Set {
repository_id: "main".to_string(),
ref_selector: Some("feature/api".to_string()),
ref_selector: Some("develop".to_string()),
}),
author: Some("browser-user".to_string()),
}),
@@ -14761,7 +14961,7 @@ mod tests {
assert_eq!(edited.title, "Browser Ticket API edited");
assert_eq!(edited.body, "Updated from the Browser API.");
assert_eq!(edited.repository_id.as_deref(), Some("main"));
assert_eq!(edited.ref_selector.as_deref(), Some("feature/api"));
assert_eq!(edited.ref_selector.as_deref(), Some("develop"));
assert_eq!(edited.assignee, None);
assert_eq!(edited.relations.outgoing.len(), 1);
assert_eq!(edited.relations.outgoing[0].target, related_ticket_id);
@@ -14782,14 +14982,12 @@ mod tests {
event.kind == "comment" && event.body.as_deref() == Some("API comment")
}));
let Json(ready) = scoped_transition_ticket_state(
let Json(ready) = scoped_mark_ticket_ready_from_browser(
State(api.clone()),
AxumPath(path()),
Json(BrowserTransitionTicketStateRequest {
state: TicketWorkflowState::Ready,
reason: Some("intake complete".to_string()),
body: Some("Ready for queue".to_string()),
author: Some("browser-user".to_string()),
Json(TicketMarkReadyRequest {
operation_key: "browser-ready".to_owned(),
reason: Some("intake complete".to_owned()),
}),
)
.await
+29 -48
View File
@@ -12,8 +12,7 @@ use ticket::config::{
use ticket::{
LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation,
SqliteTicketBackend, TicketBackend, TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug,
TicketIntakeSummary, TicketListQuery, TicketListState, TicketRelationKind, TicketSummary,
TicketWorkflowState,
TicketListQuery, TicketListState, TicketRelationKind, TicketSummary, TicketWorkflowState,
};
const DEFAULT_LIST_LIMIT: usize = 50;
@@ -630,8 +629,16 @@ fn state(
let id = TicketIdOrSlug::Query(options.query.clone());
let target_state = match options.state {
StateTarget::Planning => TicketWorkflowState::Planning,
StateTarget::Ready => TicketWorkflowState::Ready,
StateTarget::Queued => TicketWorkflowState::Queued,
StateTarget::Ready => {
return Err(TicketCliError::new(
"ready requires Workspace repository authority; use the Browser Mark ready action or TicketMarkReady",
));
}
StateTarget::Queued => {
return Err(TicketCliError::new(
"queued is an Orchestrator operation; use TicketQueue after MarkReady succeeds",
));
}
StateTarget::InProgress => TicketWorkflowState::InProgress,
StateTarget::Done => {
return Err(TicketCliError::new(
@@ -646,33 +653,16 @@ fn state(
};
let current = backend.show(id.clone())?;
let ticket_id = current.meta.id.clone();
match target_state {
TicketWorkflowState::Ready => backend.mark_intake_ready(
id,
TicketIntakeSummary::new("Marked ready by `yoi ticket state`."),
ticket::TicketStateChange {
from: current.meta.workflow_state.as_str().to_string(),
to: TicketWorkflowState::Ready.as_str().to_string(),
reason: "cli_state".to_string(),
author: Some("yoi ticket".to_string()),
body: "Marked ready by `yoi ticket state`.\n".into(),
references: Vec::new(),
},
)?,
TicketWorkflowState::Queued => backend.queue_ready(id, "yoi ticket")?,
_ => {
let from = current.meta.workflow_state;
let change = ticket::TicketStateChange {
from: from.as_str().to_string(),
to: target_state.as_str().to_string(),
reason: "cli_state".to_string(),
author: Some("yoi ticket".to_string()),
body: format!("State changed to `{}`.\n", target_state.as_str()).into(),
references: Vec::new(),
};
backend.set_workflow_state(id, change)?;
}
}
let from = current.meta.workflow_state;
let change = ticket::TicketStateChange {
from: from.as_str().to_string(),
to: target_state.as_str().to_string(),
reason: "cli_state".to_string(),
author: Some("yoi ticket".to_string()),
body: format!("State changed to `{}`.\n", target_state.as_str()).into(),
references: Vec::new(),
};
backend.set_workflow_state(id, change)?;
Ok(success(format!(
"state\t{}\t{}\n",
ticket_id,
@@ -1335,23 +1325,14 @@ mod tests {
.contains(&format!("appended\t{}\timplementation_report", ticket_id))
);
let ready = run(&temp, &["state", &ticket_id, "ready"]);
assert_eq!(ready.stdout, format!("state\t{}\tready\n", ticket_id));
let ready_listed = run(&temp, &["list", "--state", "ready"]);
assert!(ready_listed.stdout.contains(&ticket_id));
let queued = run(&temp, &["state", &ticket_id, "queued"]);
assert_eq!(queued.stdout, format!("state\t{}\tqueued\n", ticket_id));
let queued_listed = run(&temp, &["list", "--state", "queued"]);
assert!(queued_listed.stdout.contains(&ticket_id));
let inprogress = run(&temp, &["state", &ticket_id, "inprogress"]);
assert_eq!(
inprogress.stdout,
format!("state\t{}\tinprogress\n", ticket_id)
);
let inprogress_listed = run(&temp, &["list", "--state", "inprogress"]);
assert!(inprogress_listed.stdout.contains(&ticket_id));
let ready_error = parse_ticket_args(&args(&["state", &ticket_id, "ready"]))
.and_then(|cli| run_in_workspace(cli, temp.path()))
.unwrap_err();
assert!(ready_error.to_string().contains("TicketMarkReady"));
let queue_error = parse_ticket_args(&args(&["state", &ticket_id, "queued"]))
.and_then(|cli| run_in_workspace(cli, temp.path()))
.unwrap_err();
assert!(queue_error.to_string().contains("TicketQueue"));
let done_error = parse_ticket_args(&args(&["state", &ticket_id, "done"]))
.and_then(|cli| run_in_workspace(cli, temp.path()))
@@ -247,9 +247,11 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
assert(
ticketDetailLoad.includes("/repositories") &&
ticketDetailPage.includes('mutate("state", "/state"') &&
ticketDetailPage.includes('mutate("ready", "/ready"') &&
ticketDetailPage.includes('mutate("queue", "/queue"') &&
ticketDetailPage.includes("targetCandidateValid") &&
ticketDetailPage.includes("persistedTargetValid") &&
!ticketDetailPage.includes("/merge-request/merge") &&
ticketDetailPage.includes("merged_result_commit") &&
!ticketDetailPage.includes('mutate("review", "/review"') &&
ticketDetailPage.includes('mutate("close", "/close"') &&
ticketDetailPage.includes("ticketWorkerLaunchHref") &&
@@ -14,6 +14,7 @@
import type { ApiResult } from "$lib/workspace/api/http";
import type {
RepositoryListResponse,
RepositorySummary,
TicketDetail,
} from "$lib/workspace/sidebar/types";
@@ -65,7 +66,9 @@
thread: MergeRequestThreadEvent[];
};
const MUTABLE_TICKET_STATES = TICKET_STATES.filter((state) => state !== "done");
const MUTABLE_TICKET_STATES = TICKET_STATES.filter((state) =>
state !== "done" && state !== "ready" && state !== "queued"
);
const { data } = $props<{
data: {
@@ -115,6 +118,27 @@
let resolution = $state("");
let busy = $state<string | null>(null);
let errorMessage = $state<string | null>(null);
let readyOperationKey = $state<string | null>(null);
const selectedRepository = $derived(
(loadedRepositories?.items ?? []).find((repository: RepositorySummary) => repository.id === repositoryId) ?? null,
);
const effectiveRefSelector = $derived(refSelector.trim() || selectedRepository?.default_ref || "");
const targetCandidateValid = $derived(
ticket.state === "planning" &&
selectedRepository !== null &&
(selectedRepository.diagnostics ?? []).length === 0 &&
effectiveRefSelector.length > 0,
);
const persistedTargetValid = $derived(
ticket.repository_id !== null &&
ticket.ref_selector !== null &&
(loadedRepositories?.items ?? []).some((repository: RepositorySummary) =>
repository.id === ticket.repository_id && (repository.diagnostics ?? []).length === 0
),
);
const implementationStartEligible = $derived(
persistedTargetValid && ticket.state !== "planning" && ticket.state !== "closed",
);
const ticketPath = $derived(
workspaceApiPath(
@@ -180,6 +204,33 @@
}, "PATCH");
}
async function markReady() {
if (!targetCandidateValid || busy) return;
if (
ticket.repository_id !== repositoryId ||
(ticket.ref_selector ?? "") !== refSelector.trim()
) {
const saved = await mutate("target", "", {
target: {
action: "set",
repository_id: repositoryId,
ref_selector: refSelector.trim() || null,
},
}, "PATCH");
if (!saved) return;
}
readyOperationKey ??= crypto.randomUUID();
if (
await mutate("ready", "/ready", {
operation_key: readyOperationKey,
reason: transitionReason.trim() || null,
})
) {
readyOperationKey = null;
transitionReason = "";
}
}
async function transition(event: SubmitEvent) {
event.preventDefault();
if (
@@ -329,16 +380,19 @@
<p class="ticket-assignment-line">
Assigned to <strong>{ticket.assignee ?? "Unassigned"}</strong>
</p>
{#if orchestratorOnline}
<p>The Orchestrator is online. Start a role-specific Worker with the Ticket target below.</p>
{#if orchestratorOnline && implementationStartEligible}
<p>The Orchestrator is online. Start a role-specific Worker with the validated Ticket target below.</p>
<div class="ticket-role-actions">
<a class="workspace-primary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket, "coder")}>Coder</a>
</div>
{:else}
<p class="workspace-callout">Start the Workspace Orchestrator from the Ticket panel before launching Ticket Workers.</p>
<p class="workspace-callout">
{orchestratorOnline
? "Validate and persist the repository target before starting a Ticket Worker."
: "Start the Workspace Orchestrator from the Ticket panel before launching Ticket Workers."}
</p>
<div class="ticket-role-actions">
<button class="workspace-primary-button" type="button" disabled>Coder</button>
<button class="workspace-secondary-button" type="button" disabled>Reviewer</button>
</div>
{/if}
</section>
@@ -347,15 +401,15 @@
<header><h2>Repository target</h2></header>
<form class="ticket-control-form" onsubmit={saveTarget}>
<label>Repository
<select bind:value={repositoryId}>
<select bind:value={repositoryId} disabled={ticket.state !== "planning"}>
<option value="">Not assigned</option>
{#each loadedRepositories?.items ?? [] as repository}
<option value={repository.id}>{repository.display_name}</option>
{/each}
</select>
</label>
<label>Ref selector<input bind:value={refSelector} placeholder="branch, tag, or revision" /></label>
<button class="workspace-secondary-button" type="submit" disabled={busy === "target"}>
<label>Ref selector<input bind:value={refSelector} placeholder={selectedRepository?.default_ref ?? "branch, tag, or revision"} disabled={ticket.state !== "planning"} /></label>
<button class="workspace-secondary-button" type="submit" disabled={busy === "target" || ticket.state !== "planning"}>
{busy === "target" ? "Saving…" : "Save target"}
</button>
</form>
@@ -374,8 +428,15 @@
Apply state
</button>
</form>
{#if ticket.state === "ready"}
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy === "queue" || !orchestratorOnline} onclick={() => mutate("queue", "/queue", {})}>
{#if ticket.state === "planning"}
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy !== null || !targetCandidateValid} onclick={markReady}>
{busy === "ready" ? "Marking ready…" : "Mark ready"}
</button>
{#if !targetCandidateValid}
<p class="workspace-empty-copy">Choose a healthy repository and an effective ref selector before marking ready.</p>
{/if}
{:else if ticket.state === "ready"}
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy === "queue" || !orchestratorOnline || !persistedTargetValid} onclick={() => mutate("queue", "/queue", {})}>
{busy === "queue" ? "Queueing…" : orchestratorOnline ? "Queue ticket" : "Orchestrator offline"}
</button>
{/if}