feat: integrate ticket ready target guards
This commit is contained in:
Generated
+1
@@ -4417,6 +4417,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha2 0.11.0",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
|
||||
@@ -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
|
||||
|
||||
+622
-111
@@ -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,37 @@ 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>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub intake_summary: Option<TicketIntakeSummary>,
|
||||
}
|
||||
|
||||
impl TicketTargetEdit {
|
||||
fn validate(&self) -> Result<()> {
|
||||
if let Self::Set {
|
||||
@@ -504,6 +562,116 @@ 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());
|
||||
}
|
||||
if let Some(summary) = request.intake_summary.as_ref() {
|
||||
digest.update(b"\0intake-summary\0");
|
||||
digest.update(summary.body.as_str().as_bytes());
|
||||
for reference in &summary.references {
|
||||
digest.update(b"\0");
|
||||
digest.update(reference.kind.as_bytes());
|
||||
digest.update(b":");
|
||||
digest.update(reference.target.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 +1692,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 +1768,9 @@ pub enum TicketBackendOperation {
|
||||
id: TicketIdOrSlug,
|
||||
change: TicketStateChange,
|
||||
},
|
||||
MarkIntakeReady {
|
||||
MarkReady {
|
||||
id: TicketIdOrSlug,
|
||||
summary: TicketIntakeSummary,
|
||||
change: TicketStateChange,
|
||||
request: TicketMarkReady,
|
||||
},
|
||||
QueueReady {
|
||||
id: TicketIdOrSlug,
|
||||
@@ -1710,13 +1872,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 +1913,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 +1939,7 @@ impl LocalTicketBackend {
|
||||
Self {
|
||||
root: root.into(),
|
||||
record_language: None,
|
||||
target_authority: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1775,6 +1948,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 +2254,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 +2275,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 +2436,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 +2453,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 +2469,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 +2511,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 +3366,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 +3540,41 @@ impl TicketBackend for SqliteTicketBackend {
|
||||
}
|
||||
|
||||
fn set_workflow_state(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()> {
|
||||
self.with_write(|conn| {
|
||||
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| {
|
||||
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 +3582,120 @@ 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() })
|
||||
if let Some(mut summary) = request.intake_summary.clone() {
|
||||
validate_intake_summary(&summary)?;
|
||||
summary.author = request.author.clone().or(summary.author);
|
||||
self.insert_event(
|
||||
conn,
|
||||
&ticket_id,
|
||||
&TicketEvent {
|
||||
kind: TicketEventKind::IntakeSummary,
|
||||
author: summary.author,
|
||||
at: None,
|
||||
status: None,
|
||||
from: None,
|
||||
to: None,
|
||||
reason: None,
|
||||
state_field: None,
|
||||
heading: Some(TicketEventKind::IntakeSummary.heading()),
|
||||
body: summary.body,
|
||||
references: summary.references,
|
||||
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 +4012,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 +4237,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 +4255,62 @@ 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);
|
||||
}
|
||||
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.clone().or_else(|| Some(default_author()));
|
||||
if let Some(mut summary) = request.intake_summary {
|
||||
summary.author = request.author.clone().or(summary.author);
|
||||
self.append_intake_summary_event(&dir, &summary)?;
|
||||
self.apply_workflow_state_change(&dir, from, to, change, &[])
|
||||
}
|
||||
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 +4319,22 @@ 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)?;
|
||||
if meta.workflow_state != TicketWorkflowState::Ready {
|
||||
return Err(TicketError::StaleWorkflowState {
|
||||
expected: TicketWorkflowState::Ready.as_str().to_owned(),
|
||||
actual: meta.workflow_state.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
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 +4352,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 +5094,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 +6443,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 +6977,92 @@ 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()),
|
||||
intake_summary: None,
|
||||
};
|
||||
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 +7406,25 @@ 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()),
|
||||
intake_summary: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let current_item = tmp.path().join("tickets").join(&ticket.id).join("item.md");
|
||||
assert!(current_item.exists());
|
||||
@@ -7127,6 +7617,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")
|
||||
@@ -7156,7 +7648,7 @@ state: planning
|
||||
|
||||
assert!(matches!(
|
||||
backend.queue_ready(TicketIdOrSlug::Id(ticket.id.clone()), "workspace-panel"),
|
||||
Err(TicketError::Conflict(_))
|
||||
Err(TicketError::StaleWorkflowState { .. })
|
||||
));
|
||||
let record = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap();
|
||||
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Planning);
|
||||
@@ -7192,41 +7684,60 @@ 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()),
|
||||
intake_summary: None,
|
||||
};
|
||||
|
||||
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)
|
||||
);
|
||||
assert!(record.events.iter().any(|event| {
|
||||
.filter(|event| {
|
||||
event.kind == TicketEventKind::StateChanged
|
||||
&& event.state_field.as_deref() == Some("state")
|
||||
&& event.from.as_deref() == Some("planning")
|
||||
&& event.to.as_deref() == Some("ready")
|
||||
}));
|
||||
})
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
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()),
|
||||
intake_summary: None,
|
||||
},
|
||||
),
|
||||
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
|
||||
|
||||
+185
-61
@@ -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",
|
||||
@@ -68,7 +69,7 @@ pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 5] = [
|
||||
pub const TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES: [&str; 2] =
|
||||
["TicketRelationQuery", "TicketOrchestrationPlanQuery"];
|
||||
|
||||
pub const TICKET_TOOL_NAMES: [&str; 19] = [
|
||||
pub const TICKET_TOOL_NAMES: [&str; 20] = [
|
||||
"TicketCreate",
|
||||
"TicketEditItem",
|
||||
"QueryTicket",
|
||||
@@ -77,6 +78,7 @@ pub const TICKET_TOOL_NAMES: [&str; 19] = [
|
||||
"TicketPlan",
|
||||
"TicketDecision",
|
||||
"TicketImplementationReport",
|
||||
"TicketMarkReady",
|
||||
"TicketIntakeReady",
|
||||
"TicketQueue",
|
||||
"TicketWorkflowState",
|
||||
@@ -99,13 +101,14 @@ pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 6] = [
|
||||
"TicketOrchestrationPlanQuery",
|
||||
];
|
||||
|
||||
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 13] = [
|
||||
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 14] = [
|
||||
"TicketCreate",
|
||||
"TicketEditItem",
|
||||
"TicketComment",
|
||||
"TicketPlan",
|
||||
"TicketDecision",
|
||||
"TicketImplementationReport",
|
||||
"TicketMarkReady",
|
||||
"TicketIntakeReady",
|
||||
"TicketQueue",
|
||||
"TicketWorkflowState",
|
||||
@@ -132,9 +135,12 @@ 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 INTAKE_READY_DESCRIPTION: &str = "Record a bounded intake summary and mark a planning Ticket ready. \
|
||||
The backend applies the same target validation and lock as TicketMarkReady and commits the summary, \
|
||||
state_changed event, effective target, and planning -> ready transition atomically.";
|
||||
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,6 +180,7 @@ fn base_tool_description(name: &str) -> &'static str {
|
||||
"TicketPlan" => PLAN_DESCRIPTION,
|
||||
"TicketDecision" => DECISION_DESCRIPTION,
|
||||
"TicketImplementationReport" => IMPLEMENTATION_REPORT_DESCRIPTION,
|
||||
"TicketMarkReady" => MARK_READY_DESCRIPTION,
|
||||
"TicketIntakeReady" => INTAKE_READY_DESCRIPTION,
|
||||
"TicketQueue" => QUEUE_DESCRIPTION,
|
||||
"TicketWorkflowState" => WORKFLOW_STATE_DESCRIPTION,
|
||||
@@ -305,13 +312,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<()> {
|
||||
@@ -558,18 +560,24 @@ struct TicketThreadEventParams {
|
||||
body: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketMarkReadyParams {
|
||||
/// Ticket id.
|
||||
ticket: String,
|
||||
/// Optional reason attached to the state_changed event.
|
||||
#[serde(default)]
|
||||
reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketIntakeReadyParams {
|
||||
/// Ticket id.
|
||||
ticket: String,
|
||||
/// Concise bounded intake summary to append as a typed intake_summary event.
|
||||
/// Concise bounded intake summary appended before the ready transition.
|
||||
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)]
|
||||
@@ -836,6 +844,11 @@ struct TicketImplementationReportTool {
|
||||
backend: TicketToolBackend,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TicketMarkReadyTool {
|
||||
backend: TicketToolBackend,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TicketIntakeReadyTool {
|
||||
backend: TicketToolBackend,
|
||||
@@ -899,6 +912,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);
|
||||
@@ -1114,41 +1144,68 @@ impl_ticket_thread_event_tool!(
|
||||
TicketEventKind::ImplementationReport
|
||||
);
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TicketMarkReadyTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
ctx: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: TicketMarkReadyParams = parse_input("TicketMarkReady", input_json)?;
|
||||
let ticket = self
|
||||
.backend
|
||||
.mark_ready(
|
||||
TicketIdOrSlug::Query(params.ticket.clone()),
|
||||
TicketMarkReady {
|
||||
operation_key: format!("ticket-mark-ready:{}", ctx.call_id),
|
||||
reason: params.reason,
|
||||
author: None,
|
||||
intake_summary: None,
|
||||
},
|
||||
)
|
||||
.map_err(|error| backend_error("TicketMarkReady", error))?;
|
||||
Ok(json_output(
|
||||
format!("Marked ticket {} state ready", params.ticket),
|
||||
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
|
||||
}),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TicketIntakeReadyTool {
|
||||
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 ticket = self
|
||||
.backend
|
||||
.mark_ready(
|
||||
TicketIdOrSlug::Query(params.ticket.clone()),
|
||||
summary,
|
||||
change,
|
||||
TicketMarkReady {
|
||||
operation_key: format!("ticket-intake-ready:{}", ctx.call_id),
|
||||
reason: params.reason,
|
||||
author: None,
|
||||
intake_summary: Some(TicketIntakeSummary::new(params.intake_summary)),
|
||||
},
|
||||
)
|
||||
.map_err(|error| backend_error("TicketIntakeReady", error))?;
|
||||
Ok(json_output(
|
||||
format!("Marked ticket {} state ready", params.ticket),
|
||||
json!({ "ticket": params.ticket, "state": "ready", "ok": true }),
|
||||
format!("Marked ticket {} state ready after intake", params.ticket),
|
||||
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,6 +1783,7 @@ fn input_schema(name: &str) -> Value {
|
||||
"TicketComment" | "TicketPlan" | "TicketDecision" | "TicketImplementationReport" => {
|
||||
serde_json::to_value(schemars::schema_for!(TicketThreadEventParams))
|
||||
}
|
||||
"TicketMarkReady" => serde_json::to_value(schemars::schema_for!(TicketMarkReadyParams)),
|
||||
"TicketIntakeReady" => serde_json::to_value(schemars::schema_for!(TicketIntakeReadyParams)),
|
||||
"TicketQueue" => serde_json::to_value(schemars::schema_for!(TicketQueueParams)),
|
||||
"TicketWorkflowState" => {
|
||||
@@ -1774,6 +1832,7 @@ impl_from_backend!(TicketCommentTool);
|
||||
impl_from_backend!(TicketPlanTool);
|
||||
impl_from_backend!(TicketDecisionTool);
|
||||
impl_from_backend!(TicketImplementationReportTool);
|
||||
impl_from_backend!(TicketMarkReadyTool);
|
||||
impl_from_backend!(TicketIntakeReadyTool);
|
||||
impl_from_backend!(TicketQueueTool);
|
||||
impl_from_backend!(TicketWorkflowStateTool);
|
||||
@@ -1801,6 +1860,7 @@ pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition
|
||||
"TicketImplementationReport",
|
||||
backend.clone(),
|
||||
),
|
||||
tool_definition::<TicketMarkReadyTool>("TicketMarkReady", backend.clone()),
|
||||
tool_definition::<TicketIntakeReadyTool>("TicketIntakeReady", backend.clone()),
|
||||
tool_definition::<TicketQueueTool>("TicketQueue", backend.clone()),
|
||||
tool_definition::<TicketWorkflowStateTool>("TicketWorkflowState", backend.clone()),
|
||||
@@ -1826,8 +1886,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,6 +1955,7 @@ mod tests {
|
||||
"TicketPlan",
|
||||
"TicketDecision",
|
||||
"TicketImplementationReport",
|
||||
"TicketMarkReady",
|
||||
"TicketIntakeReady",
|
||||
"TicketQueue",
|
||||
"TicketWorkflowState",
|
||||
@@ -2460,16 +2539,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 +2592,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()
|
||||
@@ -2538,6 +2618,38 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_intake_ready_records_summary_with_validated_target() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let backend = backend(&temp);
|
||||
let mut input = NewTicket::new("Intake Workflow");
|
||||
input.repository_id = Some("main".to_owned());
|
||||
let created = backend.create(input).unwrap();
|
||||
tool_by_name(backend.clone(), "TicketIntakeReady")
|
||||
.execute(
|
||||
&json!({
|
||||
"ticket": created.id.clone(),
|
||||
"intake_summary": "Requirements and target are accepted.",
|
||||
"reason": "intake_complete"
|
||||
})
|
||||
.to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap();
|
||||
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Ready);
|
||||
assert_eq!(record.meta.ref_selector.as_deref(), Some("develop"));
|
||||
assert_eq!(
|
||||
record
|
||||
.events
|
||||
.iter()
|
||||
.filter(|event| event.kind == TicketEventKind::IntakeSummary)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_workflow_tool_allows_return_to_planning_from_ready_and_queued() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
@@ -2661,7 +2773,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 +2796,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 +2819,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 +2847,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 +2991,7 @@ mod tests {
|
||||
"TicketPlan",
|
||||
"TicketDecision",
|
||||
"TicketImplementationReport",
|
||||
"TicketIntakeReady",
|
||||
"TicketMarkReady",
|
||||
"TicketQueue",
|
||||
"TicketRelationRecord",
|
||||
"TicketOrchestrationPlanRecord",
|
||||
|
||||
@@ -380,6 +380,7 @@ const READ_ONLY_TOOL_NAMES: &[&str] = &["QueryTicket", "ShowTicket"];
|
||||
const AUTHORING_TOOL_NAMES: &[&str] = &[
|
||||
"TicketCreate",
|
||||
"TicketEditItem",
|
||||
"TicketMarkReady",
|
||||
"TicketQueue",
|
||||
"TicketClose",
|
||||
"TicketRelationRecord",
|
||||
@@ -397,6 +398,7 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
|
||||
"QueryTicket",
|
||||
"ShowTicket",
|
||||
"TicketComment",
|
||||
"TicketMarkReady",
|
||||
"TicketQueue",
|
||||
"TicketClose",
|
||||
"TicketRelationRecord",
|
||||
@@ -866,16 +868,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,
|
||||
@@ -1091,22 +1092,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<()> {
|
||||
@@ -1275,7 +1269,7 @@ 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(), 10);
|
||||
assert!(
|
||||
tool_names.len() < 13,
|
||||
"authoring catalog must stay below the prior broad catalog"
|
||||
@@ -1492,6 +1486,7 @@ language = "Japanese"
|
||||
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 == "TicketMarkReady"));
|
||||
assert!(!installed.iter().any(|tool| *tool == "TicketIntakeReady"));
|
||||
assert!(!installed.iter().any(|tool| *tool == "TicketWorkflowState"));
|
||||
assert!(
|
||||
|
||||
@@ -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() {
|
||||
// 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 does not resolve that repository in this Workspace"
|
||||
"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,26 @@ 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()),
|
||||
intake_summary: None,
|
||||
},
|
||||
)
|
||||
.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 +3380,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 +3535,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 +3657,12 @@ 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>,
|
||||
#[serde(default)]
|
||||
intake_summary: Option<ticket::TicketIntakeSummary>,
|
||||
}
|
||||
|
||||
async fn scoped_set_ticket_state_field(
|
||||
@@ -3578,24 +3704,31 @@ 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,
|
||||
intake_summary: request.intake_summary,
|
||||
},
|
||||
},
|
||||
)
|
||||
.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 +4501,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,11 +4542,11 @@ 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.clone());
|
||||
if let Some(summary) = request.intake_summary.as_mut() {
|
||||
summary.author = Some(author);
|
||||
}
|
||||
}
|
||||
TicketBackendOperation::QueueReady { queued_by, .. } => *queued_by = author,
|
||||
TicketBackendOperation::AddTicketRelation { relation, .. } => {
|
||||
@@ -4435,7 +4568,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 +12016,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 +13701,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 +13858,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 +13890,88 @@ 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()),
|
||||
intake_summary: None,
|
||||
};
|
||||
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,
|
||||
intake_summary: 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 +14013,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 +14071,24 @@ 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,
|
||||
intake_summary: 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 +14493,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 +14923,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 +14963,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 +14973,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 +14994,13 @@ 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()),
|
||||
intake_summary: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -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,21 +653,6 @@ 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(),
|
||||
@@ -671,8 +663,6 @@ fn state(
|
||||
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}
|
||||
|
||||
Reference in New Issue
Block a user