feat: integrate ticket ready target guards
This commit is contained in:
Generated
+1
@@ -4417,6 +4417,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
|
"sha2 0.11.0",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ schemars = { workspace = true }
|
|||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
serde_yaml = "0.9.34"
|
serde_yaml = "0.9.34"
|
||||||
|
sha2.workspace = true
|
||||||
rusqlite.workspace = true
|
rusqlite.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
|
|||||||
+625
-114
@@ -19,6 +19,7 @@ use project_record::{allocate_record_id, unix_epoch_millis_now, validate_record_
|
|||||||
use rusqlite::{Connection, OptionalExtension, params};
|
use rusqlite::{Connection, OptionalExtension, params};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
|
use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
pub mod config;
|
pub mod config;
|
||||||
@@ -80,6 +81,32 @@ pub enum TicketError {
|
|||||||
Locked { path: PathBuf },
|
Locked { path: PathBuf },
|
||||||
#[error("ticket conflict: {0}")]
|
#[error("ticket conflict: {0}")]
|
||||||
Conflict(String),
|
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}")]
|
#[error("SQLite ticket backend error: {0}")]
|
||||||
Sqlite(String),
|
Sqlite(String),
|
||||||
#[error("ticket parse error in {path}: {message}")]
|
#[error("ticket parse error in {path}: {message}")]
|
||||||
@@ -475,6 +502,37 @@ pub enum TicketTargetEdit {
|
|||||||
Clear,
|
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 {
|
impl TicketTargetEdit {
|
||||||
fn validate(&self) -> Result<()> {
|
fn validate(&self) -> Result<()> {
|
||||||
if let Self::Set {
|
if let Self::Set {
|
||||||
@@ -504,6 +562,116 @@ fn validate_ticket_target(repository_id: Option<&str>, ref_selector: Option<&str
|
|||||||
Ok(())
|
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)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct TicketItemEdit {
|
pub struct TicketItemEdit {
|
||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
@@ -1524,12 +1692,7 @@ pub trait TicketBackend {
|
|||||||
change: TicketStateChange,
|
change: TicketStateChange,
|
||||||
) -> Result<()>;
|
) -> Result<()>;
|
||||||
fn set_workflow_state(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()>;
|
fn set_workflow_state(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()>;
|
||||||
fn mark_intake_ready(
|
fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> Result<Ticket>;
|
||||||
&self,
|
|
||||||
id: TicketIdOrSlug,
|
|
||||||
summary: TicketIntakeSummary,
|
|
||||||
change: TicketStateChange,
|
|
||||||
) -> Result<()>;
|
|
||||||
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> Result<()>;
|
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> Result<()>;
|
||||||
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()>;
|
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()>;
|
||||||
fn add_ticket_relation(
|
fn add_ticket_relation(
|
||||||
@@ -1605,10 +1768,9 @@ pub enum TicketBackendOperation {
|
|||||||
id: TicketIdOrSlug,
|
id: TicketIdOrSlug,
|
||||||
change: TicketStateChange,
|
change: TicketStateChange,
|
||||||
},
|
},
|
||||||
MarkIntakeReady {
|
MarkReady {
|
||||||
id: TicketIdOrSlug,
|
id: TicketIdOrSlug,
|
||||||
summary: TicketIntakeSummary,
|
request: TicketMarkReady,
|
||||||
change: TicketStateChange,
|
|
||||||
},
|
},
|
||||||
QueueReady {
|
QueueReady {
|
||||||
id: TicketIdOrSlug,
|
id: TicketIdOrSlug,
|
||||||
@@ -1710,13 +1872,8 @@ where
|
|||||||
backend.set_workflow_state(id, change)?;
|
backend.set_workflow_state(id, change)?;
|
||||||
TicketBackendOperationResult::Unit
|
TicketBackendOperationResult::Unit
|
||||||
}
|
}
|
||||||
TicketBackendOperation::MarkIntakeReady {
|
TicketBackendOperation::MarkReady { id, request } => {
|
||||||
id,
|
TicketBackendOperationResult::Ticket(backend.mark_ready(id, request)?)
|
||||||
summary,
|
|
||||||
change,
|
|
||||||
} => {
|
|
||||||
backend.mark_intake_ready(id, summary, change)?;
|
|
||||||
TicketBackendOperationResult::Unit
|
|
||||||
}
|
}
|
||||||
TicketBackendOperation::QueueReady { id, queued_by } => {
|
TicketBackendOperation::QueueReady { id, queued_by } => {
|
||||||
backend.queue_ready(id, &queued_by)?;
|
backend.queue_ready(id, &queued_by)?;
|
||||||
@@ -1756,10 +1913,25 @@ where
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Clone)]
|
||||||
pub struct LocalTicketBackend {
|
pub struct LocalTicketBackend {
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
record_language: Option<String>,
|
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 {
|
impl LocalTicketBackend {
|
||||||
@@ -1767,6 +1939,7 @@ impl LocalTicketBackend {
|
|||||||
Self {
|
Self {
|
||||||
root: root.into(),
|
root: root.into(),
|
||||||
record_language: None,
|
record_language: None,
|
||||||
|
target_authority: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1775,6 +1948,11 @@ impl LocalTicketBackend {
|
|||||||
self
|
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> {
|
pub fn record_language(&self) -> Option<&str> {
|
||||||
self.record_language.as_deref()
|
self.record_language.as_deref()
|
||||||
}
|
}
|
||||||
@@ -2076,6 +2254,16 @@ impl LocalTicketBackend {
|
|||||||
dir: &Path,
|
dir: &Path,
|
||||||
change: &TicketStateChange,
|
change: &TicketStateChange,
|
||||||
state_field: Option<&str>,
|
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<()> {
|
) -> Result<()> {
|
||||||
validate_state_change(change)?;
|
validate_state_change(change)?;
|
||||||
let author = change.author.clone().unwrap_or_else(default_author);
|
let author = change.author.clone().unwrap_or_else(default_author);
|
||||||
@@ -2087,6 +2275,7 @@ impl LocalTicketBackend {
|
|||||||
if let Some(state_field) = state_field {
|
if let Some(state_field) = state_field {
|
||||||
attrs.push(("field", state_field));
|
attrs.push(("field", state_field));
|
||||||
}
|
}
|
||||||
|
attrs.extend_from_slice(extra_attributes);
|
||||||
self.append_thread_event(
|
self.append_thread_event(
|
||||||
dir,
|
dir,
|
||||||
TicketEventKind::StateChanged.as_str(),
|
TicketEventKind::StateChanged.as_str(),
|
||||||
@@ -2247,6 +2436,7 @@ pub struct SqliteTicketBackend {
|
|||||||
record_language: Option<String>,
|
record_language: Option<String>,
|
||||||
event_attributes: BTreeMap<String, String>,
|
event_attributes: BTreeMap<String, String>,
|
||||||
mutation_hook: Option<Arc<SqliteTicketMutationHook>>,
|
mutation_hook: Option<Arc<SqliteTicketMutationHook>>,
|
||||||
|
target_authority: Option<Arc<dyn TicketTargetAuthority>>,
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
full_ticket_load_count: Arc<AtomicUsize>,
|
full_ticket_load_count: Arc<AtomicUsize>,
|
||||||
}
|
}
|
||||||
@@ -2263,6 +2453,10 @@ impl fmt::Debug for SqliteTicketBackend {
|
|||||||
"mutation_hook",
|
"mutation_hook",
|
||||||
&self.mutation_hook.as_ref().map(|_| "configured"),
|
&self.mutation_hook.as_ref().map(|_| "configured"),
|
||||||
)
|
)
|
||||||
|
.field(
|
||||||
|
"target_authority",
|
||||||
|
&self.target_authority.as_ref().map(|_| "configured"),
|
||||||
|
)
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2275,6 +2469,7 @@ impl SqliteTicketBackend {
|
|||||||
record_language: None,
|
record_language: None,
|
||||||
event_attributes: BTreeMap::new(),
|
event_attributes: BTreeMap::new(),
|
||||||
mutation_hook: None,
|
mutation_hook: None,
|
||||||
|
target_authority: None,
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
full_ticket_load_count: Arc::new(AtomicUsize::new(0)),
|
full_ticket_load_count: Arc::new(AtomicUsize::new(0)),
|
||||||
}
|
}
|
||||||
@@ -2316,6 +2511,11 @@ impl SqliteTicketBackend {
|
|||||||
self
|
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 {
|
pub fn db_path(&self) -> &Path {
|
||||||
self.db_path.as_path()
|
self.db_path.as_path()
|
||||||
}
|
}
|
||||||
@@ -3166,6 +3366,15 @@ impl TicketBackend for SqliteTicketBackend {
|
|||||||
validate_required_event_value("author", author)?;
|
validate_required_event_value("author", author)?;
|
||||||
}
|
}
|
||||||
let ticket_id = self.resolve_ticket_id(conn, id)?;
|
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 now = now_utc();
|
||||||
let mut body_edit_audit = TicketBodyEditAudit::None;
|
let mut body_edit_audit = TicketBodyEditAudit::None;
|
||||||
if let Some(title) = edit.title.as_ref() {
|
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<()> {
|
fn set_workflow_state(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()> {
|
||||||
|
validate_state_change(&change)?;
|
||||||
|
let from = TicketWorkflowState::parse(&change.from).ok_or_else(|| {
|
||||||
|
TicketError::InvalidWorkflowTransition {
|
||||||
|
from: change.from.clone(),
|
||||||
|
to: change.to.clone(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
let to = TicketWorkflowState::parse(&change.to).ok_or_else(|| {
|
||||||
|
TicketError::InvalidWorkflowTransition {
|
||||||
|
from: change.from.clone(),
|
||||||
|
to: change.to.clone(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
validate_generic_state_change(from, to)?;
|
||||||
self.with_write(|conn| {
|
self.with_write(|conn| {
|
||||||
validate_state_change(&change)?;
|
|
||||||
let ticket_id = self.resolve_ticket_id(conn, id)?;
|
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();
|
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() })?;
|
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)?;
|
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(
|
fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> Result<Ticket> {
|
||||||
&self,
|
validate_required_event_value("operation_key", &request.operation_key)?;
|
||||||
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<()> {
|
|
||||||
self.with_write(|conn| {
|
self.with_write(|conn| {
|
||||||
let ticket_id = self.resolve_ticket_id(conn, id)?;
|
let ticket_id = self.resolve_ticket_id(conn, id)?;
|
||||||
let ticket = self.load_ticket(conn, &ticket_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();
|
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)?;
|
if let Some(mut summary) = request.intake_summary.clone() {
|
||||||
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() })
|
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 _lock = self.acquire_lock()?;
|
||||||
let dir = self.find_ticket_dir(&id)?;
|
let dir = self.find_ticket_dir(&id)?;
|
||||||
let item = dir.join("item.md");
|
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 content = fs::read_to_string(&item).map_err(|e| io_err(&item, e))?;
|
||||||
let mut body_edit_audit = TicketBodyEditAudit::None;
|
let mut body_edit_audit = TicketBodyEditAudit::None;
|
||||||
let mut updates = Vec::new();
|
let mut updates = Vec::new();
|
||||||
@@ -3892,13 +4237,7 @@ impl TicketBackend for LocalTicketBackend {
|
|||||||
change.to
|
change.to
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
if !TicketWorkflowState::is_role_transition(from, to) {
|
validate_generic_state_change(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()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let _lock = self.acquire_lock()?;
|
let _lock = self.acquire_lock()?;
|
||||||
let dir = self.find_ticket_dir(&id)?;
|
let dir = self.find_ticket_dir(&id)?;
|
||||||
if from == TicketWorkflowState::Queued && to == TicketWorkflowState::InProgress {
|
if from == TicketWorkflowState::Queued && to == TicketWorkflowState::InProgress {
|
||||||
@@ -3916,43 +4255,62 @@ impl TicketBackend for LocalTicketBackend {
|
|||||||
self.apply_workflow_state_change(&dir, from, to, change, &[])
|
self.apply_workflow_state_change(&dir, from, to, change, &[])
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mark_intake_ready(
|
fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> Result<Ticket> {
|
||||||
&self,
|
validate_required_event_value("operation_key", &request.operation_key)?;
|
||||||
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()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let _lock = self.acquire_lock()?;
|
let _lock = self.acquire_lock()?;
|
||||||
let dir = self.find_ticket_dir(&id)?;
|
let dir = self.find_ticket_dir(&id)?;
|
||||||
let current = self.ticket_workflow_state_from_dir(&dir)?;
|
let ticket = self.ticket_from_dir(&dir)?;
|
||||||
if current != from {
|
if validate_mark_ready_replay(&ticket, &request)? {
|
||||||
return Err(TicketError::Conflict(format!(
|
return Ok(ticket);
|
||||||
"state changed concurrently: expected `{}`, found `{}`",
|
|
||||||
from.as_str(),
|
|
||||||
current.as_str()
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
self.append_intake_summary_event(&dir, &summary)?;
|
let target = resolve_ready_target(self.target_authority.as_ref(), "local", &ticket)?;
|
||||||
self.apply_workflow_state_change(&dir, from, to, change, &[])
|
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.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<()> {
|
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 dir = self.find_ticket_dir(&id)?;
|
||||||
let item = dir.join("item.md");
|
let item = dir.join("item.md");
|
||||||
let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?;
|
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 blockers = self.relation_blockers_for_meta(&meta)?;
|
||||||
let active_blockers = blockers
|
let active_blockers = blockers
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|blocker| !relation_blocker_allows_queue(blocker))
|
.filter(|blocker| !relation_blocker_allows_queue(blocker))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
if !active_blockers.is_empty() {
|
if !active_blockers.is_empty() {
|
||||||
return Err(TicketError::Conflict(format!(
|
return Err(TicketError::BlockingRelations(format!(
|
||||||
"ticket {} has unresolved blocking relation(s): {}",
|
"{}: {}",
|
||||||
meta.id,
|
meta.id,
|
||||||
format_relation_blockers(&active_blockers)
|
format_relation_blockers(&active_blockers)
|
||||||
)));
|
)));
|
||||||
@@ -3986,7 +4352,12 @@ impl TicketBackend for LocalTicketBackend {
|
|||||||
TicketWorkflowState::Ready,
|
TicketWorkflowState::Ready,
|
||||||
TicketWorkflowState::Queued,
|
TicketWorkflowState::Queued,
|
||||||
change,
|
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::Locked { .. } => "ticket backend is locked",
|
||||||
TicketError::Sqlite(_) => "could not read ticket record",
|
TicketError::Sqlite(_) => "could not read ticket record",
|
||||||
TicketError::NotFound(_) => "ticket record is missing",
|
TicketError::NotFound(_) => "ticket record is missing",
|
||||||
TicketError::Ambiguous { .. } | TicketError::Conflict(_) => {
|
TicketError::Ambiguous { .. }
|
||||||
"invalid ticket record metadata"
|
| 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 super::*;
|
||||||
use tempfile::TempDir;
|
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 {
|
fn backend(dir: &TempDir) -> LocalTicketBackend {
|
||||||
LocalTicketBackend::new(dir.path().join("tickets"))
|
LocalTicketBackend::new(dir.path().join("tickets"))
|
||||||
|
.with_target_authority(Arc::new(TestTargetAuthority))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn assert_ticket_target_edit_semantics<B: TicketBackend>(backend: &B) {
|
fn assert_ticket_target_edit_semantics<B: TicketBackend>(backend: &B) {
|
||||||
@@ -6574,6 +6977,92 @@ state: planning
|
|||||||
assert_ticket_target_edit_semantics(&backend);
|
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]
|
#[test]
|
||||||
fn sqlite_backend_persists_and_edits_ticket_target() {
|
fn sqlite_backend_persists_and_edits_ticket_target() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
@@ -6917,24 +7406,25 @@ state: planning
|
|||||||
fn add_event_status_and_close_preserve_local_layout() {
|
fn add_event_status_and_close_preserve_local_layout() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let backend = backend(&tmp);
|
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
|
backend
|
||||||
.add_event(
|
.add_event(
|
||||||
TicketIdOrSlug::Id(ticket.id.clone()),
|
TicketIdOrSlug::Id(ticket.id.clone()),
|
||||||
NewTicketEvent::new(TicketEventKind::Plan, "Implementation plan."),
|
NewTicketEvent::new(TicketEventKind::Plan, "Implementation plan."),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.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
|
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();
|
.unwrap();
|
||||||
let current_item = tmp.path().join("tickets").join(&ticket.id).join("item.md");
|
let current_item = tmp.path().join("tickets").join(&ticket.id).join("item.md");
|
||||||
assert!(current_item.exists());
|
assert!(current_item.exists());
|
||||||
@@ -7127,6 +7617,8 @@ state: planning
|
|||||||
|
|
||||||
let mut ready_input = NewTicket::new("Ready Workflow");
|
let mut ready_input = NewTicket::new("Ready Workflow");
|
||||||
ready_input.workflow_state = Some(TicketWorkflowState::Ready);
|
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();
|
let ready = backend.create(ready_input).unwrap();
|
||||||
backend
|
backend
|
||||||
.queue_ready(TicketIdOrSlug::Id(ready.id.clone()), "workspace-panel")
|
.queue_ready(TicketIdOrSlug::Id(ready.id.clone()), "workspace-panel")
|
||||||
@@ -7156,7 +7648,7 @@ state: planning
|
|||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
backend.queue_ready(TicketIdOrSlug::Id(ticket.id.clone()), "workspace-panel"),
|
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();
|
let record = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap();
|
||||||
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Planning);
|
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Planning);
|
||||||
@@ -7192,41 +7684,60 @@ state: planning
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mark_intake_ready_records_summary_and_state_change() {
|
fn mark_ready_resolves_target_and_is_idempotent() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let backend = backend(&tmp);
|
let backend = backend(&tmp);
|
||||||
let ticket = backend.create(NewTicket::new("Planning Ready")).unwrap();
|
let mut input = NewTicket::new("Planning Ready");
|
||||||
let mut summary = TicketIntakeSummary::new("Concise accepted requirements.");
|
input.repository_id = Some("main".to_owned());
|
||||||
summary.author = Some("intake".to_string());
|
let ticket = backend.create(input).unwrap();
|
||||||
let mut change =
|
let request = TicketMarkReady {
|
||||||
TicketStateChange::new("planning", "ready", "accepted", "Ticket is ready to queue.");
|
operation_key: "ready-op-1".to_owned(),
|
||||||
change.author = Some("intake".to_string());
|
reason: Some("accepted".to_owned()),
|
||||||
|
author: Some("intake".to_owned()),
|
||||||
|
intake_summary: None,
|
||||||
|
};
|
||||||
|
|
||||||
backend
|
let first = backend
|
||||||
.mark_intake_ready(TicketIdOrSlug::Id(ticket.id.clone()), summary, change)
|
.mark_ready(TicketIdOrSlug::Id(ticket.id.clone()), request.clone())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let record = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap();
|
let second = backend
|
||||||
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Ready);
|
.mark_ready(TicketIdOrSlug::Id(ticket.id.clone()), request)
|
||||||
assert!(
|
.unwrap();
|
||||||
record
|
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
|
.events
|
||||||
.iter()
|
.iter()
|
||||||
.any(|event| event.kind == TicketEventKind::IntakeSummary)
|
.filter(|event| {
|
||||||
|
event.kind == TicketEventKind::StateChanged
|
||||||
|
&& event.from.as_deref() == Some("planning")
|
||||||
|
&& event.to.as_deref() == Some("ready")
|
||||||
|
})
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
);
|
);
|
||||||
assert!(record.events.iter().any(|event| {
|
assert!(matches!(
|
||||||
event.kind == TicketEventKind::StateChanged
|
backend.mark_ready(
|
||||||
&& event.state_field.as_deref() == Some("state")
|
TicketIdOrSlug::Id(ticket.id),
|
||||||
&& event.from.as_deref() == Some("planning")
|
TicketMarkReady {
|
||||||
&& event.to.as_deref() == Some("ready")
|
operation_key: "ready-op-1".to_owned(),
|
||||||
}));
|
reason: Some("different".to_owned()),
|
||||||
|
author: Some("intake".to_owned()),
|
||||||
|
intake_summary: None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Err(TicketError::OperationFingerprintMismatch { .. })
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn close_sets_state_closed() {
|
fn close_sets_state_closed() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let backend = backend(&tmp);
|
let backend = backend(&tmp);
|
||||||
let mut input = NewTicket::new("Close Workflow");
|
let input = NewTicket::new("Close Workflow");
|
||||||
input.workflow_state = Some(TicketWorkflowState::Queued);
|
|
||||||
let ticket = backend.create(input).unwrap();
|
let ticket = backend.create(input).unwrap();
|
||||||
|
|
||||||
backend
|
backend
|
||||||
|
|||||||
+185
-61
@@ -16,8 +16,9 @@ use crate::{
|
|||||||
NewTicket, NewTicketEvent, NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord,
|
NewTicket, NewTicketEvent, NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord,
|
||||||
Result as TicketResult, Ticket, TicketBackend, TicketBodyReplacement, TicketDoctorDiagnostic,
|
Result as TicketResult, Ticket, TicketBackend, TicketBodyReplacement, TicketDoctorDiagnostic,
|
||||||
TicketDoctorReport, TicketDoctorSeverity, TicketError, TicketEventKind, TicketIdOrSlug,
|
TicketDoctorReport, TicketDoctorSeverity, TicketError, TicketEventKind, TicketIdOrSlug,
|
||||||
TicketIntakeSummary, TicketListState, TicketRef, TicketRelation, TicketRelationKind,
|
TicketIntakeSummary, TicketListState, TicketMarkReady, TicketRef, TicketRelation,
|
||||||
TicketRelationView, TicketStateChange, TicketSummary, TicketWorkflowState, default_author,
|
TicketRelationKind, TicketRelationView, TicketStateChange, TicketSummary, TicketWorkflowState,
|
||||||
|
default_author,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_LIST_LIMIT: usize = 50;
|
const DEFAULT_LIST_LIMIT: usize = 50;
|
||||||
@@ -42,7 +43,7 @@ pub const TICKET_BASE_TOOL_NAMES: [&str; 14] = [
|
|||||||
"TicketPlan",
|
"TicketPlan",
|
||||||
"TicketDecision",
|
"TicketDecision",
|
||||||
"TicketImplementationReport",
|
"TicketImplementationReport",
|
||||||
"TicketIntakeReady",
|
"TicketMarkReady",
|
||||||
"TicketQueue",
|
"TicketQueue",
|
||||||
"TicketWorkflowState",
|
"TicketWorkflowState",
|
||||||
"TicketClose",
|
"TicketClose",
|
||||||
@@ -68,7 +69,7 @@ pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 5] = [
|
|||||||
pub const TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES: [&str; 2] =
|
pub const TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES: [&str; 2] =
|
||||||
["TicketRelationQuery", "TicketOrchestrationPlanQuery"];
|
["TicketRelationQuery", "TicketOrchestrationPlanQuery"];
|
||||||
|
|
||||||
pub const TICKET_TOOL_NAMES: [&str; 19] = [
|
pub const TICKET_TOOL_NAMES: [&str; 20] = [
|
||||||
"TicketCreate",
|
"TicketCreate",
|
||||||
"TicketEditItem",
|
"TicketEditItem",
|
||||||
"QueryTicket",
|
"QueryTicket",
|
||||||
@@ -77,6 +78,7 @@ pub const TICKET_TOOL_NAMES: [&str; 19] = [
|
|||||||
"TicketPlan",
|
"TicketPlan",
|
||||||
"TicketDecision",
|
"TicketDecision",
|
||||||
"TicketImplementationReport",
|
"TicketImplementationReport",
|
||||||
|
"TicketMarkReady",
|
||||||
"TicketIntakeReady",
|
"TicketIntakeReady",
|
||||||
"TicketQueue",
|
"TicketQueue",
|
||||||
"TicketWorkflowState",
|
"TicketWorkflowState",
|
||||||
@@ -99,13 +101,14 @@ pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 6] = [
|
|||||||
"TicketOrchestrationPlanQuery",
|
"TicketOrchestrationPlanQuery",
|
||||||
];
|
];
|
||||||
|
|
||||||
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 13] = [
|
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 14] = [
|
||||||
"TicketCreate",
|
"TicketCreate",
|
||||||
"TicketEditItem",
|
"TicketEditItem",
|
||||||
"TicketComment",
|
"TicketComment",
|
||||||
"TicketPlan",
|
"TicketPlan",
|
||||||
"TicketDecision",
|
"TicketDecision",
|
||||||
"TicketImplementationReport",
|
"TicketImplementationReport",
|
||||||
|
"TicketMarkReady",
|
||||||
"TicketIntakeReady",
|
"TicketIntakeReady",
|
||||||
"TicketQueue",
|
"TicketQueue",
|
||||||
"TicketWorkflowState",
|
"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 DECISION_DESCRIPTION: &str = "Append a typed Ticket decision event. `body` is Markdown.";
|
||||||
const IMPLEMENTATION_REPORT_DESCRIPTION: &str =
|
const IMPLEMENTATION_REPORT_DESCRIPTION: &str =
|
||||||
"Append a typed Ticket implementation_report event. `body` is Markdown.";
|
"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 \
|
const MARK_READY_DESCRIPTION: &str = "Mark a planning Ticket ready through the typed Ticket backend. \
|
||||||
Ticket backend. The tool appends a bounded `intake_summary`, appends a typed `state_changed` event \
|
The backend atomically validates and normalizes the persisted repository/ref target, records one typed \
|
||||||
for `state`, and transitions state to `ready`.";
|
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 \
|
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, \
|
Ticket backend. The backend performs the gated ready -> queued transition, records queued_by/queued_at, \
|
||||||
and rejects unresolved blocking relations.";
|
and rejects unresolved blocking relations.";
|
||||||
@@ -174,6 +180,7 @@ fn base_tool_description(name: &str) -> &'static str {
|
|||||||
"TicketPlan" => PLAN_DESCRIPTION,
|
"TicketPlan" => PLAN_DESCRIPTION,
|
||||||
"TicketDecision" => DECISION_DESCRIPTION,
|
"TicketDecision" => DECISION_DESCRIPTION,
|
||||||
"TicketImplementationReport" => IMPLEMENTATION_REPORT_DESCRIPTION,
|
"TicketImplementationReport" => IMPLEMENTATION_REPORT_DESCRIPTION,
|
||||||
|
"TicketMarkReady" => MARK_READY_DESCRIPTION,
|
||||||
"TicketIntakeReady" => INTAKE_READY_DESCRIPTION,
|
"TicketIntakeReady" => INTAKE_READY_DESCRIPTION,
|
||||||
"TicketQueue" => QUEUE_DESCRIPTION,
|
"TicketQueue" => QUEUE_DESCRIPTION,
|
||||||
"TicketWorkflowState" => WORKFLOW_STATE_DESCRIPTION,
|
"TicketWorkflowState" => WORKFLOW_STATE_DESCRIPTION,
|
||||||
@@ -305,13 +312,8 @@ impl TicketBackend for TicketToolBackend {
|
|||||||
self.backend.set_workflow_state(id, change)
|
self.backend.set_workflow_state(id, change)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mark_intake_ready(
|
fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> TicketResult<Ticket> {
|
||||||
&self,
|
self.backend.mark_ready(id, request)
|
||||||
id: TicketIdOrSlug,
|
|
||||||
summary: TicketIntakeSummary,
|
|
||||||
change: TicketStateChange,
|
|
||||||
) -> TicketResult<()> {
|
|
||||||
self.backend.mark_intake_ready(id, summary, change)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> TicketResult<()> {
|
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> TicketResult<()> {
|
||||||
@@ -558,18 +560,24 @@ struct TicketThreadEventParams {
|
|||||||
body: String,
|
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)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
struct TicketIntakeReadyParams {
|
struct TicketIntakeReadyParams {
|
||||||
/// Ticket id.
|
/// Ticket id.
|
||||||
ticket: String,
|
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,
|
intake_summary: String,
|
||||||
/// Reason attached to the state_changed event. Defaults to `planning_ready`.
|
/// Optional reason attached to the state_changed event.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
reason: Option<String>,
|
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)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
@@ -836,6 +844,11 @@ struct TicketImplementationReportTool {
|
|||||||
backend: TicketToolBackend,
|
backend: TicketToolBackend,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct TicketMarkReadyTool {
|
||||||
|
backend: TicketToolBackend,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct TicketIntakeReadyTool {
|
struct TicketIntakeReadyTool {
|
||||||
backend: TicketToolBackend,
|
backend: TicketToolBackend,
|
||||||
@@ -899,6 +912,23 @@ impl Tool for TicketCreateTool {
|
|||||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let params: TicketCreateParams = parse_input("TicketCreate", input_json)?;
|
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);
|
let mut input = NewTicket::new(params.title);
|
||||||
if let Some(body) = params.body {
|
if let Some(body) = params.body {
|
||||||
input.body = MarkdownText::new(body);
|
input.body = MarkdownText::new(body);
|
||||||
@@ -1114,41 +1144,68 @@ impl_ticket_thread_event_tool!(
|
|||||||
TicketEventKind::ImplementationReport
|
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]
|
#[async_trait]
|
||||||
impl Tool for TicketIntakeReadyTool {
|
impl Tool for TicketIntakeReadyTool {
|
||||||
async fn execute(
|
async fn execute(
|
||||||
&self,
|
&self,
|
||||||
input_json: &str,
|
input_json: &str,
|
||||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
ctx: llm_engine::tool::ToolExecutionContext,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let params: TicketIntakeReadyParams = parse_input("TicketIntakeReady", input_json)?;
|
let params: TicketIntakeReadyParams = parse_input("TicketIntakeReady", input_json)?;
|
||||||
let from = TicketWorkflowState::Planning;
|
let ticket = self
|
||||||
let reason = params
|
.backend
|
||||||
.reason
|
.mark_ready(
|
||||||
.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(
|
|
||||||
TicketIdOrSlug::Query(params.ticket.clone()),
|
TicketIdOrSlug::Query(params.ticket.clone()),
|
||||||
summary,
|
TicketMarkReady {
|
||||||
change,
|
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))?;
|
.map_err(|error| backend_error("TicketIntakeReady", error))?;
|
||||||
Ok(json_output(
|
Ok(json_output(
|
||||||
format!("Marked ticket {} state ready", params.ticket),
|
format!("Marked ticket {} state ready after intake", params.ticket),
|
||||||
json!({ "ticket": params.ticket, "state": "ready", "ok": true }),
|
json!({
|
||||||
|
"ticket": ticket.meta.id,
|
||||||
|
"state": ticket.meta.workflow_state.as_str(),
|
||||||
|
"repository_id": ticket.meta.repository_id,
|
||||||
|
"ref_selector": ticket.meta.ref_selector,
|
||||||
|
"ok": true
|
||||||
|
}),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1726,6 +1783,7 @@ fn input_schema(name: &str) -> Value {
|
|||||||
"TicketComment" | "TicketPlan" | "TicketDecision" | "TicketImplementationReport" => {
|
"TicketComment" | "TicketPlan" | "TicketDecision" | "TicketImplementationReport" => {
|
||||||
serde_json::to_value(schemars::schema_for!(TicketThreadEventParams))
|
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)),
|
"TicketIntakeReady" => serde_json::to_value(schemars::schema_for!(TicketIntakeReadyParams)),
|
||||||
"TicketQueue" => serde_json::to_value(schemars::schema_for!(TicketQueueParams)),
|
"TicketQueue" => serde_json::to_value(schemars::schema_for!(TicketQueueParams)),
|
||||||
"TicketWorkflowState" => {
|
"TicketWorkflowState" => {
|
||||||
@@ -1774,6 +1832,7 @@ impl_from_backend!(TicketCommentTool);
|
|||||||
impl_from_backend!(TicketPlanTool);
|
impl_from_backend!(TicketPlanTool);
|
||||||
impl_from_backend!(TicketDecisionTool);
|
impl_from_backend!(TicketDecisionTool);
|
||||||
impl_from_backend!(TicketImplementationReportTool);
|
impl_from_backend!(TicketImplementationReportTool);
|
||||||
|
impl_from_backend!(TicketMarkReadyTool);
|
||||||
impl_from_backend!(TicketIntakeReadyTool);
|
impl_from_backend!(TicketIntakeReadyTool);
|
||||||
impl_from_backend!(TicketQueueTool);
|
impl_from_backend!(TicketQueueTool);
|
||||||
impl_from_backend!(TicketWorkflowStateTool);
|
impl_from_backend!(TicketWorkflowStateTool);
|
||||||
@@ -1801,6 +1860,7 @@ pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition
|
|||||||
"TicketImplementationReport",
|
"TicketImplementationReport",
|
||||||
backend.clone(),
|
backend.clone(),
|
||||||
),
|
),
|
||||||
|
tool_definition::<TicketMarkReadyTool>("TicketMarkReady", backend.clone()),
|
||||||
tool_definition::<TicketIntakeReadyTool>("TicketIntakeReady", backend.clone()),
|
tool_definition::<TicketIntakeReadyTool>("TicketIntakeReady", backend.clone()),
|
||||||
tool_definition::<TicketQueueTool>("TicketQueue", backend.clone()),
|
tool_definition::<TicketQueueTool>("TicketQueue", backend.clone()),
|
||||||
tool_definition::<TicketWorkflowStateTool>("TicketWorkflowState", backend.clone()),
|
tool_definition::<TicketWorkflowStateTool>("TicketWorkflowState", backend.clone()),
|
||||||
@@ -1826,8 +1886,26 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use tempfile::TempDir;
|
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 {
|
fn backend(temp: &TempDir) -> LocalTicketBackend {
|
||||||
LocalTicketBackend::new(temp.path().join("tickets"))
|
LocalTicketBackend::new(temp.path().join("tickets"))
|
||||||
|
.with_target_authority(Arc::new(TestTargetAuthority))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tool(definition: ToolDefinition) -> Arc<dyn Tool> {
|
fn tool(definition: ToolDefinition) -> Arc<dyn Tool> {
|
||||||
@@ -1877,6 +1955,7 @@ mod tests {
|
|||||||
"TicketPlan",
|
"TicketPlan",
|
||||||
"TicketDecision",
|
"TicketDecision",
|
||||||
"TicketImplementationReport",
|
"TicketImplementationReport",
|
||||||
|
"TicketMarkReady",
|
||||||
"TicketIntakeReady",
|
"TicketIntakeReady",
|
||||||
"TicketQueue",
|
"TicketQueue",
|
||||||
"TicketWorkflowState",
|
"TicketWorkflowState",
|
||||||
@@ -2460,16 +2539,17 @@ mod tests {
|
|||||||
async fn ticket_workflow_tools_mark_ready_and_transition_state() {
|
async fn ticket_workflow_tools_mark_ready_and_transition_state() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
let backend = backend(&temp);
|
let backend = backend(&temp);
|
||||||
let created = backend.create(NewTicket::new("Workflow Tool")).unwrap();
|
let mut input = NewTicket::new("Workflow Tool");
|
||||||
let intake_ready = tool_by_name(backend.clone(), "TicketIntakeReady");
|
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");
|
let workflow = tool_by_name(backend.clone(), "TicketWorkflowState");
|
||||||
|
|
||||||
intake_ready
|
intake_ready
|
||||||
.execute(
|
.execute(
|
||||||
&json!({
|
&json!({
|
||||||
"ticket": created.id.clone(),
|
"ticket": created.id.clone(),
|
||||||
"intake_summary": "Requirements accepted; implementation can be queued.",
|
"reason": "requirements accepted"
|
||||||
"author": "intake-worker"
|
|
||||||
})
|
})
|
||||||
.to_string(),
|
.to_string(),
|
||||||
Default::default(),
|
Default::default(),
|
||||||
@@ -2512,12 +2592,12 @@ mod tests {
|
|||||||
|
|
||||||
let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap();
|
let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap();
|
||||||
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Done);
|
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Done);
|
||||||
assert!(
|
assert!(record.events.iter().any(|event| {
|
||||||
record
|
event.kind == TicketEventKind::StateChanged
|
||||||
.events
|
&& event.from.as_deref() == Some("planning")
|
||||||
.iter()
|
&& event.to.as_deref() == Some("ready")
|
||||||
.any(|event| event.kind == TicketEventKind::IntakeSummary)
|
&& event.attributes.contains_key("request_fingerprint")
|
||||||
);
|
}));
|
||||||
let transitions = record
|
let transitions = record
|
||||||
.events
|
.events
|
||||||
.iter()
|
.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]
|
#[tokio::test]
|
||||||
async fn ticket_workflow_tool_allows_return_to_planning_from_ready_and_queued() {
|
async fn ticket_workflow_tool_allows_return_to_planning_from_ready_and_queued() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
@@ -2661,7 +2773,11 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.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");
|
let mut done_input = NewTicket::new("Backward Bypass");
|
||||||
done_input.workflow_state = Some(TicketWorkflowState::Done);
|
done_input.workflow_state = Some(TicketWorkflowState::Done);
|
||||||
@@ -2680,7 +2796,11 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.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");
|
let mut queued_input = NewTicket::new("Skip Bypass");
|
||||||
queued_input.workflow_state = Some(TicketWorkflowState::Queued);
|
queued_input.workflow_state = Some(TicketWorkflowState::Queued);
|
||||||
@@ -2699,17 +2819,21 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(skip_error.to_string().contains("not allowed"));
|
assert!(
|
||||||
|
skip_error
|
||||||
|
.to_string()
|
||||||
|
.contains("invalid ticket workflow transition")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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 temp = TempDir::new().unwrap();
|
||||||
let backend = backend(&temp);
|
let backend = backend(&temp);
|
||||||
let mut input = NewTicket::new("Already Ready");
|
let mut input = NewTicket::new("Already Ready");
|
||||||
input.workflow_state = Some(TicketWorkflowState::Ready);
|
input.workflow_state = Some(TicketWorkflowState::Ready);
|
||||||
let created = backend.create(input).unwrap();
|
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
|
let error = intake_ready
|
||||||
.execute(
|
.execute(
|
||||||
@@ -2723,7 +2847,7 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.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();
|
let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap();
|
||||||
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Ready);
|
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Ready);
|
||||||
assert!(!record.events.iter().any(|event| {
|
assert!(!record.events.iter().any(|event| {
|
||||||
@@ -2867,7 +2991,7 @@ mod tests {
|
|||||||
"TicketPlan",
|
"TicketPlan",
|
||||||
"TicketDecision",
|
"TicketDecision",
|
||||||
"TicketImplementationReport",
|
"TicketImplementationReport",
|
||||||
"TicketIntakeReady",
|
"TicketMarkReady",
|
||||||
"TicketQueue",
|
"TicketQueue",
|
||||||
"TicketRelationRecord",
|
"TicketRelationRecord",
|
||||||
"TicketOrchestrationPlanRecord",
|
"TicketOrchestrationPlanRecord",
|
||||||
|
|||||||
@@ -380,6 +380,7 @@ const READ_ONLY_TOOL_NAMES: &[&str] = &["QueryTicket", "ShowTicket"];
|
|||||||
const AUTHORING_TOOL_NAMES: &[&str] = &[
|
const AUTHORING_TOOL_NAMES: &[&str] = &[
|
||||||
"TicketCreate",
|
"TicketCreate",
|
||||||
"TicketEditItem",
|
"TicketEditItem",
|
||||||
|
"TicketMarkReady",
|
||||||
"TicketQueue",
|
"TicketQueue",
|
||||||
"TicketClose",
|
"TicketClose",
|
||||||
"TicketRelationRecord",
|
"TicketRelationRecord",
|
||||||
@@ -397,6 +398,7 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
|
|||||||
"QueryTicket",
|
"QueryTicket",
|
||||||
"ShowTicket",
|
"ShowTicket",
|
||||||
"TicketComment",
|
"TicketComment",
|
||||||
|
"TicketMarkReady",
|
||||||
"TicketQueue",
|
"TicketQueue",
|
||||||
"TicketClose",
|
"TicketClose",
|
||||||
"TicketRelationRecord",
|
"TicketRelationRecord",
|
||||||
@@ -866,16 +868,15 @@ impl WorkspaceHttpTicketBackend {
|
|||||||
TicketError::Conflict(format!("serialize Ticket workflow change: {error}"))
|
TicketError::Conflict(format!("serialize Ticket workflow change: {error}"))
|
||||||
})?),
|
})?),
|
||||||
),
|
),
|
||||||
TicketBackendOperation::MarkIntakeReady {
|
TicketBackendOperation::MarkReady { id, request } => Self::request(
|
||||||
id,
|
|
||||||
summary,
|
|
||||||
change,
|
|
||||||
} => Self::request_unit(
|
|
||||||
client,
|
client,
|
||||||
WorkspaceRequestMethod::Post,
|
WorkspaceRequestMethod::Post,
|
||||||
format!("{base}/{}/intake-ready", Self::ticket_path(&id)),
|
format!("{base}/{}/workflow/mark-ready", Self::ticket_path(&id)),
|
||||||
Some(serde_json::json!({ "summary": summary, "change": change })),
|
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(
|
TicketBackendOperation::QueueReady { id, .. } => Self::request_unit(
|
||||||
client,
|
client,
|
||||||
WorkspaceRequestMethod::Post,
|
WorkspaceRequestMethod::Post,
|
||||||
@@ -1091,22 +1092,15 @@ impl TicketBackend for WorkspaceHttpTicketBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mark_intake_ready(
|
fn mark_ready(
|
||||||
&self,
|
&self,
|
||||||
id: TicketIdOrSlug,
|
id: TicketIdOrSlug,
|
||||||
summary: TicketIntakeSummary,
|
request: ticket::TicketMarkReady,
|
||||||
change: TicketStateChange,
|
) -> TicketResult<Ticket> {
|
||||||
) -> TicketResult<()> {
|
expect_ticket_result!(
|
||||||
match self.invoke(TicketBackendOperation::MarkIntakeReady {
|
self.invoke(TicketBackendOperation::MarkReady { id, request }),
|
||||||
id,
|
TicketBackendOperationResult::Ticket
|
||||||
summary,
|
)
|
||||||
change,
|
|
||||||
})? {
|
|
||||||
TicketBackendOperationResult::Unit => Ok(()),
|
|
||||||
other => Err(TicketError::Conflict(format!(
|
|
||||||
"unexpected ticket backend response: {other:?}"
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> TicketResult<()> {
|
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> TicketResult<()> {
|
||||||
@@ -1275,7 +1269,7 @@ mod tests {
|
|||||||
assert_eq!(show.name, "ShowTicket");
|
assert_eq!(show.name, "ShowTicket");
|
||||||
assert!(show.input_schema["properties"]["event_limit"].is_object());
|
assert!(show.input_schema["properties"]["event_limit"].is_object());
|
||||||
let tool_names = TicketFeatureAccess::workspace_authoring().tool_names();
|
let tool_names = TicketFeatureAccess::workspace_authoring().tool_names();
|
||||||
assert_eq!(tool_names.len(), 9);
|
assert_eq!(tool_names.len(), 10);
|
||||||
assert!(
|
assert!(
|
||||||
tool_names.len() < 13,
|
tool_names.len() < 13,
|
||||||
"authoring catalog must stay below the prior broad catalog"
|
"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 == "TicketCreate"));
|
||||||
assert!(installed.iter().any(|tool| *tool == "TicketEditItem"));
|
assert!(installed.iter().any(|tool| *tool == "TicketEditItem"));
|
||||||
assert!(installed.iter().any(|tool| *tool == "TicketQueue"));
|
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 == "TicketIntakeReady"));
|
||||||
assert!(!installed.iter().any(|tool| *tool == "TicketWorkflowState"));
|
assert!(!installed.iter().any(|tool| *tool == "TicketWorkflowState"));
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -1060,11 +1060,18 @@ impl WorkspaceApi {
|
|||||||
&self,
|
&self,
|
||||||
request: &WorkerSpawnRequest,
|
request: &WorkerSpawnRequest,
|
||||||
) -> ApiResult<()> {
|
) -> ApiResult<()> {
|
||||||
let selected_repository_id =
|
let (selected_repository_id, selected_ref_selector) =
|
||||||
if let Some(working_directory) = request.resolved_working_directory_request.as_ref() {
|
if let Some(working_directory) = request.resolved_working_directory_request.as_ref() {
|
||||||
let repository_id = working_directory.repository.id.as_str();
|
let repository_id = working_directory.repository.id.as_str();
|
||||||
self.require_workspace_repository(repository_id)?;
|
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() {
|
} else if let Some(claim) = request.resolved_working_directory.as_ref() {
|
||||||
let workdir = self
|
let workdir = self
|
||||||
.store
|
.store
|
||||||
@@ -1076,20 +1083,48 @@ impl WorkspaceApi {
|
|||||||
)))
|
)))
|
||||||
})?;
|
})?;
|
||||||
self.require_workspace_repository(&workdir.repository_id)?;
|
self.require_workspace_repository(&workdir.repository_id)?;
|
||||||
Some(workdir.repository_id)
|
(Some(workdir.repository_id), workdir.creation_selector)
|
||||||
} else {
|
} else {
|
||||||
None
|
(None, None)
|
||||||
};
|
};
|
||||||
|
|
||||||
if let WorkerSpawnIntent::TicketRole { ticket_id, .. } = &request.intent {
|
if let WorkerSpawnIntent::TicketRole { ticket_id, .. } = &request.intent {
|
||||||
let ticket = self.authority.ticket(ticket_id)?;
|
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.
|
||||||
self.require_workspace_repository(repository_id)?;
|
// Preserve that control-plane launch while still validating any persisted
|
||||||
if selected_repository_id.as_deref() != Some(repository_id) {
|
// target (including its Workspace ownership) when one exists.
|
||||||
return Err(ApiError::from(Error::Config(format!(
|
if selected_repository_id.is_none() && ticket.repository_id.is_none() {
|
||||||
"Ticket `{ticket_id}` targets repository `{repository_id}`, but the Worker launch does not resolve that repository in this Workspace"
|
return Ok(());
|
||||||
))));
|
}
|
||||||
}
|
let repository_id = ticket.repository_id.as_deref().ok_or_else(|| {
|
||||||
|
ApiError::from(Error::Config(
|
||||||
|
"Ticket implementation target must be validated and persisted before spawning a Ticket Worker".to_owned(),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let ref_selector = ticket.ref_selector.as_deref().ok_or_else(|| {
|
||||||
|
ApiError::from(Error::Config(
|
||||||
|
"Ticket implementation target selector must be validated and persisted before spawning a Ticket Worker".to_owned(),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
self.require_workspace_repository(repository_id)?;
|
||||||
|
self.repository_reader()
|
||||||
|
.observe_merge_target(repository_id, Some(ref_selector))
|
||||||
|
.map_err(|error| {
|
||||||
|
ApiError::from(Error::Config(format!(
|
||||||
|
"Ticket implementation target is no longer resolvable: {error:?}"
|
||||||
|
)))
|
||||||
|
})?;
|
||||||
|
if selected_repository_id.as_deref() != Some(repository_id) {
|
||||||
|
return Err(ApiError::from(Error::Config(format!(
|
||||||
|
"Ticket `{ticket_id}` targets repository `{repository_id}`, but the Worker launch resolves `{}`",
|
||||||
|
selected_repository_id.as_deref().unwrap_or("none")
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
if selected_ref_selector.as_deref() != Some(ref_selector) {
|
||||||
|
return Err(ApiError::from(Error::Config(format!(
|
||||||
|
"Ticket `{ticket_id}` targets selector `{ref_selector}`, but the Worker launch resolves `{}`",
|
||||||
|
selected_ref_selector.as_deref().unwrap_or("none")
|
||||||
|
))));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1315,8 +1350,8 @@ pub fn build_router(api: WorkspaceApi) -> Router {
|
|||||||
post(scoped_set_ticket_workflow_state),
|
post(scoped_set_ticket_workflow_state),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/w/{workspace_id}/tickets/{id}/intake-ready",
|
"/api/w/{workspace_id}/tickets/{id}/workflow/mark-ready",
|
||||||
post(scoped_prepare_ticket_intake_ready),
|
post(scoped_mark_ticket_ready),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/w/{workspace_id}/tickets/{id}/workflow/queue",
|
"/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",
|
"/api/w/{workspace_id}/tickets/{id}/state",
|
||||||
post(scoped_transition_ticket_state),
|
post(scoped_transition_ticket_state),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/tickets/{id}/ready",
|
||||||
|
post(scoped_mark_ticket_ready_from_browser),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/w/{workspace_id}/tickets/{id}/events",
|
"/api/w/{workspace_id}/tickets/{id}/events",
|
||||||
post(scoped_append_ticket_event),
|
post(scoped_append_ticket_event),
|
||||||
@@ -3107,6 +3146,55 @@ struct BrowserCloseTicketRequest {
|
|||||||
resolution: String,
|
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> {
|
fn browser_ticket_backend(api: &WorkspaceApi) -> Result<SqliteTicketBackend> {
|
||||||
let config = ticket::config::TicketConfig::load_workspace(&api.config.workspace_root)
|
let config = ticket::config::TicketConfig::load_workspace(&api.config.workspace_root)
|
||||||
.map_err(|error| Error::Config(format!("load Ticket workspace settings: {error}")))?;
|
.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.database_path.clone(),
|
||||||
api.config.workspace_id.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>> {
|
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)
|
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(
|
async fn scoped_queue_ticket(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedRecordPath>,
|
AxumPath(path): AxumPath<ScopedRecordPath>,
|
||||||
@@ -3269,7 +3380,10 @@ async fn execute_worker_ticket_rest_operation(
|
|||||||
api.config.workspace_id.clone(),
|
api.config.workspace_id.clone(),
|
||||||
)
|
)
|
||||||
.map_err(Error::from)?
|
.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 operation_kind = ticket_mutation_operation_kind(&operation);
|
||||||
let is_mutation = operation_kind != "read";
|
let is_mutation = operation_kind != "read";
|
||||||
let target = ticket_mutation_target(&operation).cloned();
|
let target = ticket_mutation_target(&operation).cloned();
|
||||||
@@ -3421,6 +3535,15 @@ async fn scoped_create_ticket_record(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(input): Json<ticket::NewTicket>,
|
Json(input): Json<ticket::NewTicket>,
|
||||||
) -> ApiResult<Json<ticket::TicketRef>> {
|
) -> 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(
|
let result = execute_worker_ticket_rest_operation(
|
||||||
&api,
|
&api,
|
||||||
&path.workspace_id,
|
&path.workspace_id,
|
||||||
@@ -3534,9 +3657,12 @@ async fn scoped_add_ticket_intake_summary(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct TicketIntakeReadyRequest {
|
struct TicketMarkReadyRequest {
|
||||||
summary: ticket::TicketIntakeSummary,
|
operation_key: String,
|
||||||
change: TicketStateChange,
|
#[serde(default)]
|
||||||
|
reason: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
intake_summary: Option<ticket::TicketIntakeSummary>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn scoped_set_ticket_state_field(
|
async fn scoped_set_ticket_state_field(
|
||||||
@@ -3578,24 +3704,31 @@ async fn scoped_set_ticket_workflow_state(
|
|||||||
ticket_rest_unit(result)
|
ticket_rest_unit(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn scoped_prepare_ticket_intake_ready(
|
async fn scoped_mark_ticket_ready(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath((workspace_id, id)): AxumPath<(String, String)>,
|
AxumPath((workspace_id, id)): AxumPath<(String, String)>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(request): Json<TicketIntakeReadyRequest>,
|
Json(request): Json<TicketMarkReadyRequest>,
|
||||||
) -> ApiResult<StatusCode> {
|
) -> ApiResult<Json<ticket::Ticket>> {
|
||||||
let result = execute_worker_ticket_rest_operation(
|
let result = execute_worker_ticket_rest_operation(
|
||||||
&api,
|
&api,
|
||||||
&workspace_id,
|
&workspace_id,
|
||||||
headers,
|
headers,
|
||||||
TicketBackendOperation::MarkIntakeReady {
|
TicketBackendOperation::MarkReady {
|
||||||
id: TicketIdOrSlug::Query(id),
|
id: TicketIdOrSlug::Query(id),
|
||||||
summary: request.summary,
|
request: ticket::TicketMarkReady {
|
||||||
change: request.change,
|
operation_key: request.operation_key,
|
||||||
|
reason: request.reason,
|
||||||
|
author: None,
|
||||||
|
intake_summary: request.intake_summary,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
ticket_rest_unit(result)
|
ticket_rest_result(result, |result| match result {
|
||||||
|
TicketBackendOperationResult::Ticket(ticket) => Some(ticket),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn scoped_queue_ticket_record(
|
async fn scoped_queue_ticket_record(
|
||||||
@@ -4368,7 +4501,7 @@ fn ticket_mutation_target(operation: &TicketBackendOperation) -> Option<&TicketI
|
|||||||
| TicketBackendOperation::AddIntakeSummary { id, .. }
|
| TicketBackendOperation::AddIntakeSummary { id, .. }
|
||||||
| TicketBackendOperation::SetStateField { id, .. }
|
| TicketBackendOperation::SetStateField { id, .. }
|
||||||
| TicketBackendOperation::SetWorkflowState { id, .. }
|
| TicketBackendOperation::SetWorkflowState { id, .. }
|
||||||
| TicketBackendOperation::MarkIntakeReady { id, .. }
|
| TicketBackendOperation::MarkReady { id, .. }
|
||||||
| TicketBackendOperation::QueueReady { id, .. }
|
| TicketBackendOperation::QueueReady { id, .. }
|
||||||
| TicketBackendOperation::Close { id, .. }
|
| TicketBackendOperation::Close { id, .. }
|
||||||
| TicketBackendOperation::AddTicketRelation { id, .. }
|
| TicketBackendOperation::AddTicketRelation { id, .. }
|
||||||
@@ -4409,11 +4542,11 @@ fn bind_worker_ticket_operation_source(
|
|||||||
| TicketBackendOperation::SetStateField { change, .. }
|
| TicketBackendOperation::SetStateField { change, .. }
|
||||||
| TicketBackendOperation::SetWorkflowState { change, .. } => change.author = Some(author),
|
| TicketBackendOperation::SetWorkflowState { change, .. } => change.author = Some(author),
|
||||||
TicketBackendOperation::AddIntakeSummary { summary, .. } => summary.author = Some(author),
|
TicketBackendOperation::AddIntakeSummary { summary, .. } => summary.author = Some(author),
|
||||||
TicketBackendOperation::MarkIntakeReady {
|
TicketBackendOperation::MarkReady { request, .. } => {
|
||||||
summary, change, ..
|
request.author = Some(author.clone());
|
||||||
} => {
|
if let Some(summary) = request.intake_summary.as_mut() {
|
||||||
summary.author = Some(author.clone());
|
summary.author = Some(author);
|
||||||
change.author = Some(author);
|
}
|
||||||
}
|
}
|
||||||
TicketBackendOperation::QueueReady { queued_by, .. } => *queued_by = author,
|
TicketBackendOperation::QueueReady { queued_by, .. } => *queued_by = author,
|
||||||
TicketBackendOperation::AddTicketRelation { relation, .. } => {
|
TicketBackendOperation::AddTicketRelation { relation, .. } => {
|
||||||
@@ -4435,7 +4568,7 @@ fn ticket_mutation_operation_kind(operation: &TicketBackendOperation) -> &'stati
|
|||||||
TicketBackendOperation::AddIntakeSummary { .. } => "add_intake_summary",
|
TicketBackendOperation::AddIntakeSummary { .. } => "add_intake_summary",
|
||||||
TicketBackendOperation::SetStateField { .. } => "set_state_field",
|
TicketBackendOperation::SetStateField { .. } => "set_state_field",
|
||||||
TicketBackendOperation::SetWorkflowState { .. } => "set_workflow_state",
|
TicketBackendOperation::SetWorkflowState { .. } => "set_workflow_state",
|
||||||
TicketBackendOperation::MarkIntakeReady { .. } => "mark_intake_ready",
|
TicketBackendOperation::MarkReady { .. } => "mark_ready",
|
||||||
TicketBackendOperation::QueueReady { .. } => "queue_ready",
|
TicketBackendOperation::QueueReady { .. } => "queue_ready",
|
||||||
TicketBackendOperation::Close { .. } => "close",
|
TicketBackendOperation::Close { .. } => "close",
|
||||||
TicketBackendOperation::AddTicketRelation { .. } => "add_relation",
|
TicketBackendOperation::AddTicketRelation { .. } => "add_relation",
|
||||||
@@ -11883,7 +12016,26 @@ impl From<Error> for ApiError {
|
|||||||
ticket::TicketError::NotFound(_) => "ticket_not_found",
|
ticket::TicketError::NotFound(_) => "ticket_not_found",
|
||||||
ticket::TicketError::Ambiguous { .. } => "ticket_ambiguous",
|
ticket::TicketError::Ambiguous { .. } => "ticket_ambiguous",
|
||||||
ticket::TicketError::Locked { .. } => "ticket_locked",
|
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::InvalidPathComponent(_)
|
||||||
| ticket::TicketError::PathEscapesRoot { .. } => "invalid_ticket_request",
|
| ticket::TicketError::PathEscapesRoot { .. } => "invalid_ticket_request",
|
||||||
ticket::TicketError::Io { .. }
|
ticket::TicketError::Io { .. }
|
||||||
@@ -13549,7 +13701,7 @@ mod tests {
|
|||||||
config.repositories = vec![ConfiguredRepository {
|
config.repositories = vec![ConfiguredRepository {
|
||||||
id: TEST_REPOSITORY_ID.to_string(),
|
id: TEST_REPOSITORY_ID.to_string(),
|
||||||
provider: "git".to_string(),
|
provider: "git".to_string(),
|
||||||
uri: ".".to_string(),
|
uri: workspace_root.display().to_string(),
|
||||||
path: workspace_root,
|
path: workspace_root,
|
||||||
display_name: Some("Test Repository".to_string()),
|
display_name: Some("Test Repository".to_string()),
|
||||||
default_selector: Some("HEAD".to_string()),
|
default_selector: Some("HEAD".to_string()),
|
||||||
@@ -13706,7 +13858,7 @@ mod tests {
|
|||||||
|
|
||||||
fn init_clean_git_workspace(path: &std::path::Path) {
|
fn init_clean_git_workspace(path: &std::path::Path) {
|
||||||
for args in [
|
for args in [
|
||||||
vec!["init"],
|
vec!["init", "--initial-branch=develop"],
|
||||||
vec!["config", "user.email", "test@example.invalid"],
|
vec!["config", "user.email", "test@example.invalid"],
|
||||||
vec!["config", "user.name", "Yoi Test"],
|
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]
|
#[test]
|
||||||
fn worker_source_actor_roles_use_canonical_vocabulary() {
|
fn worker_source_actor_roles_use_canonical_vocabulary() {
|
||||||
assert_eq!(worker_source_actor_role(true, false), "coder");
|
assert_eq!(worker_source_actor_role(true, false), "coder");
|
||||||
@@ -13779,6 +14013,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn orchestrator_ticket_notifications_project_authoritative_post_mutation_state() {
|
async fn orchestrator_ticket_notifications_project_authoritative_post_mutation_state() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
init_clean_git_workspace(dir.path());
|
||||||
let (api, execution) = test_api_with_recording_backend(dir.path()).await;
|
let (api, execution) = test_api_with_recording_backend(dir.path()).await;
|
||||||
let source_worker = api
|
let source_worker = api
|
||||||
.runtime
|
.runtime
|
||||||
@@ -13836,29 +14071,24 @@ mod tests {
|
|||||||
let orchestrator = started.worker.unwrap().worker;
|
let orchestrator = started.worker.unwrap().worker;
|
||||||
execution.take_inputs();
|
execution.take_inputs();
|
||||||
|
|
||||||
let ticket = browser_ticket_backend(&api)
|
let mut input = ticket::NewTicket::new("Bounded notification");
|
||||||
.unwrap()
|
input.repository_id = Some(TEST_REPOSITORY_ID.to_owned());
|
||||||
.create(ticket::NewTicket::new("Bounded notification"))
|
input.ref_selector = Some("develop".to_owned());
|
||||||
.unwrap();
|
let ticket = browser_ticket_backend(&api).unwrap().create(input).unwrap();
|
||||||
let ticket_id = TicketIdOrSlug::Id(ticket.id.clone());
|
let ticket_id = TicketIdOrSlug::Id(ticket.id.clone());
|
||||||
let operations = [
|
let operations = [
|
||||||
TicketBackendOperation::SetWorkflowState {
|
TicketBackendOperation::MarkReady {
|
||||||
id: ticket_id.clone(),
|
id: ticket_id.clone(),
|
||||||
change: TicketStateChange::new(
|
request: ticket::TicketMarkReady {
|
||||||
"planning",
|
operation_key: "notification-ready".to_owned(),
|
||||||
"ready",
|
reason: Some("ready for implementation".to_owned()),
|
||||||
"ready for implementation",
|
author: None,
|
||||||
"test transition",
|
intake_summary: None,
|
||||||
),
|
},
|
||||||
},
|
},
|
||||||
TicketBackendOperation::SetWorkflowState {
|
TicketBackendOperation::QueueReady {
|
||||||
id: ticket_id.clone(),
|
id: ticket_id.clone(),
|
||||||
change: TicketStateChange::new(
|
queued_by: "spoofed".to_owned(),
|
||||||
"ready",
|
|
||||||
"queued",
|
|
||||||
"queued for implementation",
|
|
||||||
"test transition",
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
TicketBackendOperation::SetWorkflowState {
|
TicketBackendOperation::SetWorkflowState {
|
||||||
id: ticket_id.clone(),
|
id: ticket_id.clone(),
|
||||||
@@ -14263,30 +14493,11 @@ mod tests {
|
|||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let api = test_api(dir.path()).await;
|
let api = test_api(dir.path()).await;
|
||||||
let backend = browser_ticket_backend(&api).unwrap();
|
let backend = browser_ticket_backend(&api).unwrap();
|
||||||
let ticket_ref = backend
|
let mut input = ticket::NewTicket::new("Recover queued work");
|
||||||
.create(ticket::NewTicket::new("Recover queued work"))
|
input.workflow_state = Some(TicketWorkflowState::Queued);
|
||||||
.unwrap();
|
input.repository_id = Some(TEST_REPOSITORY_ID.to_owned());
|
||||||
backend
|
input.ref_selector = Some("HEAD".to_owned());
|
||||||
.mark_intake_ready(
|
let ticket_ref = backend.create(input).unwrap();
|
||||||
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();
|
|
||||||
*api.orchestrator_attention_fingerprint.lock().unwrap() = Some(ticket_ref.id.clone());
|
*api.orchestrator_attention_fingerprint.lock().unwrap() = Some(ticket_ref.id.clone());
|
||||||
|
|
||||||
let Json(started) = scoped_start_workspace_orchestrator(
|
let Json(started) = scoped_start_workspace_orchestrator(
|
||||||
@@ -14712,6 +14923,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn ticket_browser_endpoints_mutate_typed_backend_and_return_thread() {
|
async fn ticket_browser_endpoints_mutate_typed_backend_and_return_thread() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
init_clean_git_workspace(dir.path());
|
||||||
let api = test_api(dir.path()).await;
|
let api = test_api(dir.path()).await;
|
||||||
let ticket_ref = browser_ticket_backend(&api)
|
let ticket_ref = browser_ticket_backend(&api)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -14751,7 +14963,7 @@ mod tests {
|
|||||||
replace_all: false,
|
replace_all: false,
|
||||||
target: Some(TicketTargetEdit::Set {
|
target: Some(TicketTargetEdit::Set {
|
||||||
repository_id: "main".to_string(),
|
repository_id: "main".to_string(),
|
||||||
ref_selector: Some("feature/api".to_string()),
|
ref_selector: Some("develop".to_string()),
|
||||||
}),
|
}),
|
||||||
author: Some("browser-user".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.title, "Browser Ticket API edited");
|
||||||
assert_eq!(edited.body, "Updated from the Browser API.");
|
assert_eq!(edited.body, "Updated from the Browser API.");
|
||||||
assert_eq!(edited.repository_id.as_deref(), Some("main"));
|
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.assignee, None);
|
||||||
assert_eq!(edited.relations.outgoing.len(), 1);
|
assert_eq!(edited.relations.outgoing.len(), 1);
|
||||||
assert_eq!(edited.relations.outgoing[0].target, related_ticket_id);
|
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")
|
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()),
|
State(api.clone()),
|
||||||
AxumPath(path()),
|
AxumPath(path()),
|
||||||
Json(BrowserTransitionTicketStateRequest {
|
Json(TicketMarkReadyRequest {
|
||||||
state: TicketWorkflowState::Ready,
|
operation_key: "browser-ready".to_owned(),
|
||||||
reason: Some("intake complete".to_string()),
|
reason: Some("intake complete".to_owned()),
|
||||||
body: Some("Ready for queue".to_string()),
|
intake_summary: None,
|
||||||
author: Some("browser-user".to_string()),
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ use ticket::config::{
|
|||||||
use ticket::{
|
use ticket::{
|
||||||
LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation,
|
LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation,
|
||||||
SqliteTicketBackend, TicketBackend, TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug,
|
SqliteTicketBackend, TicketBackend, TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug,
|
||||||
TicketIntakeSummary, TicketListQuery, TicketListState, TicketRelationKind, TicketSummary,
|
TicketListQuery, TicketListState, TicketRelationKind, TicketSummary, TicketWorkflowState,
|
||||||
TicketWorkflowState,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_LIST_LIMIT: usize = 50;
|
const DEFAULT_LIST_LIMIT: usize = 50;
|
||||||
@@ -630,8 +629,16 @@ fn state(
|
|||||||
let id = TicketIdOrSlug::Query(options.query.clone());
|
let id = TicketIdOrSlug::Query(options.query.clone());
|
||||||
let target_state = match options.state {
|
let target_state = match options.state {
|
||||||
StateTarget::Planning => TicketWorkflowState::Planning,
|
StateTarget::Planning => TicketWorkflowState::Planning,
|
||||||
StateTarget::Ready => TicketWorkflowState::Ready,
|
StateTarget::Ready => {
|
||||||
StateTarget::Queued => TicketWorkflowState::Queued,
|
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::InProgress => TicketWorkflowState::InProgress,
|
||||||
StateTarget::Done => {
|
StateTarget::Done => {
|
||||||
return Err(TicketCliError::new(
|
return Err(TicketCliError::new(
|
||||||
@@ -646,33 +653,16 @@ fn state(
|
|||||||
};
|
};
|
||||||
let current = backend.show(id.clone())?;
|
let current = backend.show(id.clone())?;
|
||||||
let ticket_id = current.meta.id.clone();
|
let ticket_id = current.meta.id.clone();
|
||||||
match target_state {
|
let from = current.meta.workflow_state;
|
||||||
TicketWorkflowState::Ready => backend.mark_intake_ready(
|
let change = ticket::TicketStateChange {
|
||||||
id,
|
from: from.as_str().to_string(),
|
||||||
TicketIntakeSummary::new("Marked ready by `yoi ticket state`."),
|
to: target_state.as_str().to_string(),
|
||||||
ticket::TicketStateChange {
|
reason: "cli_state".to_string(),
|
||||||
from: current.meta.workflow_state.as_str().to_string(),
|
author: Some("yoi ticket".to_string()),
|
||||||
to: TicketWorkflowState::Ready.as_str().to_string(),
|
body: format!("State changed to `{}`.\n", target_state.as_str()).into(),
|
||||||
reason: "cli_state".to_string(),
|
references: Vec::new(),
|
||||||
author: Some("yoi ticket".to_string()),
|
};
|
||||||
body: "Marked ready by `yoi ticket state`.\n".into(),
|
backend.set_workflow_state(id, change)?;
|
||||||
references: Vec::new(),
|
|
||||||
},
|
|
||||||
)?,
|
|
||||||
TicketWorkflowState::Queued => backend.queue_ready(id, "yoi ticket")?,
|
|
||||||
_ => {
|
|
||||||
let from = current.meta.workflow_state;
|
|
||||||
let change = ticket::TicketStateChange {
|
|
||||||
from: from.as_str().to_string(),
|
|
||||||
to: target_state.as_str().to_string(),
|
|
||||||
reason: "cli_state".to_string(),
|
|
||||||
author: Some("yoi ticket".to_string()),
|
|
||||||
body: format!("State changed to `{}`.\n", target_state.as_str()).into(),
|
|
||||||
references: Vec::new(),
|
|
||||||
};
|
|
||||||
backend.set_workflow_state(id, change)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(success(format!(
|
Ok(success(format!(
|
||||||
"state\t{}\t{}\n",
|
"state\t{}\t{}\n",
|
||||||
ticket_id,
|
ticket_id,
|
||||||
@@ -1335,23 +1325,14 @@ mod tests {
|
|||||||
.contains(&format!("appended\t{}\timplementation_report", ticket_id))
|
.contains(&format!("appended\t{}\timplementation_report", ticket_id))
|
||||||
);
|
);
|
||||||
|
|
||||||
let ready = run(&temp, &["state", &ticket_id, "ready"]);
|
let ready_error = parse_ticket_args(&args(&["state", &ticket_id, "ready"]))
|
||||||
assert_eq!(ready.stdout, format!("state\t{}\tready\n", ticket_id));
|
.and_then(|cli| run_in_workspace(cli, temp.path()))
|
||||||
let ready_listed = run(&temp, &["list", "--state", "ready"]);
|
.unwrap_err();
|
||||||
assert!(ready_listed.stdout.contains(&ticket_id));
|
assert!(ready_error.to_string().contains("TicketMarkReady"));
|
||||||
|
let queue_error = parse_ticket_args(&args(&["state", &ticket_id, "queued"]))
|
||||||
let queued = run(&temp, &["state", &ticket_id, "queued"]);
|
.and_then(|cli| run_in_workspace(cli, temp.path()))
|
||||||
assert_eq!(queued.stdout, format!("state\t{}\tqueued\n", ticket_id));
|
.unwrap_err();
|
||||||
let queued_listed = run(&temp, &["list", "--state", "queued"]);
|
assert!(queue_error.to_string().contains("TicketQueue"));
|
||||||
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 done_error = parse_ticket_args(&args(&["state", &ticket_id, "done"]))
|
let done_error = parse_ticket_args(&args(&["state", &ticket_id, "done"]))
|
||||||
.and_then(|cli| run_in_workspace(cli, temp.path()))
|
.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(
|
assert(
|
||||||
ticketDetailLoad.includes("/repositories") &&
|
ticketDetailLoad.includes("/repositories") &&
|
||||||
ticketDetailPage.includes('mutate("state", "/state"') &&
|
ticketDetailPage.includes('mutate("state", "/state"') &&
|
||||||
|
ticketDetailPage.includes('mutate("ready", "/ready"') &&
|
||||||
ticketDetailPage.includes('mutate("queue", "/queue"') &&
|
ticketDetailPage.includes('mutate("queue", "/queue"') &&
|
||||||
|
ticketDetailPage.includes("targetCandidateValid") &&
|
||||||
|
ticketDetailPage.includes("persistedTargetValid") &&
|
||||||
!ticketDetailPage.includes("/merge-request/merge") &&
|
!ticketDetailPage.includes("/merge-request/merge") &&
|
||||||
ticketDetailPage.includes("merged_result_commit") &&
|
|
||||||
!ticketDetailPage.includes('mutate("review", "/review"') &&
|
!ticketDetailPage.includes('mutate("review", "/review"') &&
|
||||||
ticketDetailPage.includes('mutate("close", "/close"') &&
|
ticketDetailPage.includes('mutate("close", "/close"') &&
|
||||||
ticketDetailPage.includes("ticketWorkerLaunchHref") &&
|
ticketDetailPage.includes("ticketWorkerLaunchHref") &&
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
import type { ApiResult } from "$lib/workspace/api/http";
|
import type { ApiResult } from "$lib/workspace/api/http";
|
||||||
import type {
|
import type {
|
||||||
RepositoryListResponse,
|
RepositoryListResponse,
|
||||||
|
RepositorySummary,
|
||||||
TicketDetail,
|
TicketDetail,
|
||||||
} from "$lib/workspace/sidebar/types";
|
} from "$lib/workspace/sidebar/types";
|
||||||
|
|
||||||
@@ -65,7 +66,9 @@
|
|||||||
thread: MergeRequestThreadEvent[];
|
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<{
|
const { data } = $props<{
|
||||||
data: {
|
data: {
|
||||||
@@ -115,6 +118,27 @@
|
|||||||
let resolution = $state("");
|
let resolution = $state("");
|
||||||
let busy = $state<string | null>(null);
|
let busy = $state<string | null>(null);
|
||||||
let errorMessage = $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(
|
const ticketPath = $derived(
|
||||||
workspaceApiPath(
|
workspaceApiPath(
|
||||||
@@ -180,6 +204,33 @@
|
|||||||
}, "PATCH");
|
}, "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) {
|
async function transition(event: SubmitEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (
|
if (
|
||||||
@@ -329,16 +380,19 @@
|
|||||||
<p class="ticket-assignment-line">
|
<p class="ticket-assignment-line">
|
||||||
Assigned to <strong>{ticket.assignee ?? "Unassigned"}</strong>
|
Assigned to <strong>{ticket.assignee ?? "Unassigned"}</strong>
|
||||||
</p>
|
</p>
|
||||||
{#if orchestratorOnline}
|
{#if orchestratorOnline && implementationStartEligible}
|
||||||
<p>The Orchestrator is online. Start a role-specific Worker with the Ticket target below.</p>
|
<p>The Orchestrator is online. Start a role-specific Worker with the validated Ticket target below.</p>
|
||||||
<div class="ticket-role-actions">
|
<div class="ticket-role-actions">
|
||||||
<a class="workspace-primary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket, "coder")}>Coder</a>
|
<a class="workspace-primary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket, "coder")}>Coder</a>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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">
|
<div class="ticket-role-actions">
|
||||||
<button class="workspace-primary-button" type="button" disabled>Coder</button>
|
<button class="workspace-primary-button" type="button" disabled>Coder</button>
|
||||||
<button class="workspace-secondary-button" type="button" disabled>Reviewer</button>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
@@ -347,15 +401,15 @@
|
|||||||
<header><h2>Repository target</h2></header>
|
<header><h2>Repository target</h2></header>
|
||||||
<form class="ticket-control-form" onsubmit={saveTarget}>
|
<form class="ticket-control-form" onsubmit={saveTarget}>
|
||||||
<label>Repository
|
<label>Repository
|
||||||
<select bind:value={repositoryId}>
|
<select bind:value={repositoryId} disabled={ticket.state !== "planning"}>
|
||||||
<option value="">Not assigned</option>
|
<option value="">Not assigned</option>
|
||||||
{#each loadedRepositories?.items ?? [] as repository}
|
{#each loadedRepositories?.items ?? [] as repository}
|
||||||
<option value={repository.id}>{repository.display_name}</option>
|
<option value={repository.id}>{repository.display_name}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>Ref selector<input bind:value={refSelector} placeholder="branch, tag, or revision" /></label>
|
<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"}>
|
<button class="workspace-secondary-button" type="submit" disabled={busy === "target" || ticket.state !== "planning"}>
|
||||||
{busy === "target" ? "Saving…" : "Save target"}
|
{busy === "target" ? "Saving…" : "Save target"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -374,8 +428,15 @@
|
|||||||
Apply state
|
Apply state
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{#if ticket.state === "ready"}
|
{#if ticket.state === "planning"}
|
||||||
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy === "queue" || !orchestratorOnline} onclick={() => mutate("queue", "/queue", {})}>
|
<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"}
|
{busy === "queue" ? "Queueing…" : orchestratorOnline ? "Queue ticket" : "Orchestrator offline"}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user