feat: add explicit ticket workflow state
This commit is contained in:
+463
-1
@@ -153,6 +153,66 @@ impl From<TicketStatus> for ExtensibleTicketStatus {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum TicketWorkflowState {
|
||||
Intake,
|
||||
Ready,
|
||||
Queued,
|
||||
InProgress,
|
||||
Done,
|
||||
}
|
||||
|
||||
impl TicketWorkflowState {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Intake => "intake",
|
||||
Self::Ready => "ready",
|
||||
Self::Queued => "queued",
|
||||
Self::InProgress => "inprogress",
|
||||
Self::Done => "done",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"intake" => Some(Self::Intake),
|
||||
"ready" => Some(Self::Ready),
|
||||
"queued" => Some(Self::Queued),
|
||||
"inprogress" => Some(Self::InProgress),
|
||||
"done" => Some(Self::Done),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_for_status(status: &ExtensibleTicketStatus) -> Self {
|
||||
match status {
|
||||
ExtensibleTicketStatus::Closed => Self::Done,
|
||||
_ => Self::Intake,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_intake_ready_transition(from: Self, to: Self) -> bool {
|
||||
from == Self::Intake && to == Self::Ready
|
||||
}
|
||||
|
||||
pub fn is_queue_transition(from: Self, to: Self) -> bool {
|
||||
from == Self::Ready && to == Self::Queued
|
||||
}
|
||||
|
||||
pub fn is_role_transition(from: Self, to: Self) -> bool {
|
||||
matches!(
|
||||
(from, to),
|
||||
(Self::Queued, Self::InProgress) | (Self::InProgress, Self::Done)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for TicketWorkflowState {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MarkdownText(pub String);
|
||||
|
||||
@@ -417,6 +477,10 @@ pub struct NewTicket {
|
||||
pub needs_preflight: Option<bool>,
|
||||
pub risk_flags: Vec<String>,
|
||||
pub action_required: Option<String>,
|
||||
pub workflow_state: Option<TicketWorkflowState>,
|
||||
pub attention_required: Option<String>,
|
||||
pub queued_by: Option<String>,
|
||||
pub queued_at: Option<String>,
|
||||
}
|
||||
|
||||
impl NewTicket {
|
||||
@@ -437,6 +501,10 @@ impl NewTicket {
|
||||
needs_preflight: None,
|
||||
risk_flags: Vec::new(),
|
||||
action_required: None,
|
||||
workflow_state: None,
|
||||
attention_required: None,
|
||||
queued_by: None,
|
||||
queued_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -482,6 +550,11 @@ pub struct TicketMeta {
|
||||
pub needs_preflight: Option<bool>,
|
||||
pub risk_flags: Vec<String>,
|
||||
pub action_required: Option<String>,
|
||||
pub workflow_state: TicketWorkflowState,
|
||||
pub workflow_state_explicit: bool,
|
||||
pub attention_required: Option<String>,
|
||||
pub queued_by: Option<String>,
|
||||
pub queued_at: Option<String>,
|
||||
pub raw: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
@@ -497,6 +570,11 @@ pub struct TicketSummary {
|
||||
pub readiness: Option<String>,
|
||||
pub needs_preflight: Option<bool>,
|
||||
pub action_required: Option<String>,
|
||||
pub workflow_state: TicketWorkflowState,
|
||||
pub workflow_state_explicit: bool,
|
||||
pub attention_required: Option<String>,
|
||||
pub queued_by: Option<String>,
|
||||
pub queued_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
@@ -597,6 +675,14 @@ pub trait TicketBackend {
|
||||
field: &str,
|
||||
change: TicketStateChange,
|
||||
) -> Result<()>;
|
||||
fn set_workflow_state(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()>;
|
||||
fn mark_intake_ready(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
summary: TicketIntakeSummary,
|
||||
change: TicketStateChange,
|
||||
) -> Result<()>;
|
||||
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> Result<()>;
|
||||
fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> Result<()>;
|
||||
fn set_status(&self, id: TicketIdOrSlug, status: TicketStatus) -> Result<()>;
|
||||
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()>;
|
||||
@@ -729,6 +815,45 @@ impl LocalTicketBackend {
|
||||
})
|
||||
}
|
||||
|
||||
fn ticket_workflow_state_from_item(&self, item: &Path) -> Result<TicketWorkflowState> {
|
||||
let parsed = read_item_file(item)?;
|
||||
let meta = ticket_meta(parsed.frontmatter);
|
||||
Ok(meta.workflow_state)
|
||||
}
|
||||
|
||||
fn apply_workflow_state_change(
|
||||
&self,
|
||||
dir: &Path,
|
||||
expected_from: TicketWorkflowState,
|
||||
to: TicketWorkflowState,
|
||||
change: TicketStateChange,
|
||||
extra_updates: &[(&str, &str)],
|
||||
) -> Result<()> {
|
||||
validate_state_change(&change)?;
|
||||
if change.from.as_str() != expected_from.as_str() || change.to.as_str() != to.as_str() {
|
||||
return Err(TicketError::Conflict(format!(
|
||||
"workflow_state change payload mismatch: expected {} -> {}, got {} -> {}",
|
||||
expected_from.as_str(),
|
||||
to.as_str(),
|
||||
change.from,
|
||||
change.to
|
||||
)));
|
||||
}
|
||||
let item = dir.join("item.md");
|
||||
let current = self.ticket_workflow_state_from_item(&item)?;
|
||||
if current != expected_from {
|
||||
return Err(TicketError::Conflict(format!(
|
||||
"workflow_state changed concurrently: expected `{}`, found `{}`",
|
||||
expected_from.as_str(),
|
||||
current.as_str()
|
||||
)));
|
||||
}
|
||||
self.append_state_changed_event(dir, &change, Some("workflow_state"))?;
|
||||
let mut updates = vec![("workflow_state", to.as_str())];
|
||||
updates.extend_from_slice(extra_updates);
|
||||
self.set_frontmatter_fields(&item, &updates)
|
||||
}
|
||||
|
||||
fn append_thread_event(
|
||||
&self,
|
||||
dir: &Path,
|
||||
@@ -836,6 +961,11 @@ impl TicketBackend for LocalTicketBackend {
|
||||
readiness: meta.readiness,
|
||||
needs_preflight: meta.needs_preflight,
|
||||
action_required: meta.action_required,
|
||||
workflow_state: meta.workflow_state,
|
||||
workflow_state_explicit: meta.workflow_state_explicit,
|
||||
attention_required: meta.attention_required,
|
||||
queued_by: meta.queued_by,
|
||||
queued_at: meta.queued_at,
|
||||
updated_at: meta.updated_at,
|
||||
});
|
||||
}
|
||||
@@ -898,6 +1028,14 @@ impl TicketBackend for LocalTicketBackend {
|
||||
fields.push(("kind".to_string(), input.kind));
|
||||
fields.push(("priority".to_string(), input.priority));
|
||||
fields.push(("labels".to_string(), labels_yaml(&input.labels)));
|
||||
fields.push((
|
||||
"workflow_state".to_string(),
|
||||
input
|
||||
.workflow_state
|
||||
.unwrap_or(TicketWorkflowState::Intake)
|
||||
.as_str()
|
||||
.to_string(),
|
||||
));
|
||||
fields.push(("created_at".to_string(), created.clone()));
|
||||
fields.push(("updated_at".to_string(), created.clone()));
|
||||
fields.push((
|
||||
@@ -920,6 +1058,15 @@ impl TicketBackend for LocalTicketBackend {
|
||||
if let Some(action_required) = input.action_required {
|
||||
fields.push(("action_required".to_string(), action_required));
|
||||
}
|
||||
if let Some(attention_required) = input.attention_required {
|
||||
fields.push(("attention_required".to_string(), attention_required));
|
||||
}
|
||||
if let Some(queued_by) = input.queued_by {
|
||||
fields.push(("queued_by".to_string(), queued_by));
|
||||
}
|
||||
if let Some(queued_at) = input.queued_at {
|
||||
fields.push(("queued_at".to_string(), queued_at));
|
||||
}
|
||||
let item = serialize_item(&fields, input.body.as_str());
|
||||
atomic_write(&dir.join("item.md"), item.as_bytes())?;
|
||||
let thread = format!(
|
||||
@@ -967,6 +1114,11 @@ impl TicketBackend for LocalTicketBackend {
|
||||
change: TicketStateChange,
|
||||
) -> Result<()> {
|
||||
validate_state_field_name(field)?;
|
||||
if field == "workflow_state" {
|
||||
return Err(TicketError::Conflict(
|
||||
"workflow_state transitions must use dedicated workflow APIs".to_string(),
|
||||
));
|
||||
}
|
||||
let _lock = self.acquire_lock()?;
|
||||
let dir = self.find_ticket_dir(&id)?;
|
||||
let item = dir.join("item.md");
|
||||
@@ -986,6 +1138,91 @@ impl TicketBackend for LocalTicketBackend {
|
||||
self.set_frontmatter_fields(&item, &[(field, change.to.as_str())])
|
||||
}
|
||||
|
||||
fn set_workflow_state(&self, id: TicketIdOrSlug, 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_role_transition(from, to) {
|
||||
return Err(TicketError::Conflict(format!(
|
||||
"workflow_state transition {} -> {} is not allowed through set_workflow_state; use dedicated intake-ready or queue APIs for gated transitions",
|
||||
from.as_str(),
|
||||
to.as_str()
|
||||
)));
|
||||
}
|
||||
let _lock = self.acquire_lock()?;
|
||||
let dir = self.find_ticket_dir(&id)?;
|
||||
self.apply_workflow_state_change(&dir, from, to, change, &[])
|
||||
}
|
||||
|
||||
fn mark_intake_ready(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
summary: TicketIntakeSummary,
|
||||
change: TicketStateChange,
|
||||
) -> Result<()> {
|
||||
let from = TicketWorkflowState::parse(&change.from).ok_or_else(|| {
|
||||
TicketError::Conflict(format!(
|
||||
"invalid workflow_state transition source: {}",
|
||||
change.from
|
||||
))
|
||||
})?;
|
||||
let to = TicketWorkflowState::parse(&change.to).ok_or_else(|| {
|
||||
TicketError::Conflict(format!(
|
||||
"invalid workflow_state transition target: {}",
|
||||
change.to
|
||||
))
|
||||
})?;
|
||||
if !TicketWorkflowState::is_intake_ready_transition(from, to) {
|
||||
return Err(TicketError::Conflict(format!(
|
||||
"mark_intake_ready only allows workflow_state intake -> ready, got {} -> {}",
|
||||
from.as_str(),
|
||||
to.as_str()
|
||||
)));
|
||||
}
|
||||
let _lock = self.acquire_lock()?;
|
||||
let dir = self.find_ticket_dir(&id)?;
|
||||
let current = self.ticket_workflow_state_from_item(&dir.join("item.md"))?;
|
||||
if current != from {
|
||||
return Err(TicketError::Conflict(format!(
|
||||
"workflow_state changed concurrently: expected `{}`, found `{}`",
|
||||
from.as_str(),
|
||||
current.as_str()
|
||||
)));
|
||||
}
|
||||
self.append_intake_summary_event(&dir, &summary)?;
|
||||
self.apply_workflow_state_change(&dir, from, to, change, &[])
|
||||
}
|
||||
|
||||
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> Result<()> {
|
||||
validate_required_event_value("queued_by", queued_by)?;
|
||||
let _lock = self.acquire_lock()?;
|
||||
let dir = self.find_ticket_dir(&id)?;
|
||||
let at = now_utc();
|
||||
let mut change = TicketStateChange::new(
|
||||
TicketWorkflowState::Ready.as_str(),
|
||||
TicketWorkflowState::Queued.as_str(),
|
||||
"queued",
|
||||
"Ticket queued for Orchestrator routing.\n",
|
||||
);
|
||||
change.author = Some(queued_by.to_string());
|
||||
self.apply_workflow_state_change(
|
||||
&dir,
|
||||
TicketWorkflowState::Ready,
|
||||
TicketWorkflowState::Queued,
|
||||
change,
|
||||
&[("queued_by", queued_by), ("queued_at", at.as_str())],
|
||||
)
|
||||
}
|
||||
|
||||
fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> Result<()> {
|
||||
let _lock = self.acquire_lock()?;
|
||||
let dir = self.find_ticket_dir(&id)?;
|
||||
@@ -1060,9 +1297,25 @@ impl TicketBackend for LocalTicketBackend {
|
||||
fs::rename(&old_dir, &closed_dir).map_err(|e| io_err(&closed_dir, e))?;
|
||||
}
|
||||
let at = now_utc();
|
||||
let current_workflow_state =
|
||||
self.ticket_workflow_state_from_item(&closed_dir.join("item.md"))?;
|
||||
if current_workflow_state != TicketWorkflowState::Done {
|
||||
let mut change = TicketStateChange::new(
|
||||
current_workflow_state.as_str(),
|
||||
TicketWorkflowState::Done.as_str(),
|
||||
"closed",
|
||||
"Ticket closed; workflow_state set to done.\n",
|
||||
);
|
||||
change.author = Some(default_author());
|
||||
self.append_state_changed_event(&closed_dir, &change, Some("workflow_state"))?;
|
||||
}
|
||||
self.set_frontmatter_fields(
|
||||
&closed_dir.join("item.md"),
|
||||
&[("status", "closed"), ("updated_at", &at)],
|
||||
&[
|
||||
("status", "closed"),
|
||||
("workflow_state", TicketWorkflowState::Done.as_str()),
|
||||
("updated_at", &at),
|
||||
],
|
||||
)?;
|
||||
atomic_write(
|
||||
&closed_dir.join("resolution.md"),
|
||||
@@ -1178,6 +1431,28 @@ impl TicketBackend for LocalTicketBackend {
|
||||
Some(item.clone()),
|
||||
);
|
||||
}
|
||||
match parsed.frontmatter.get("workflow_state").map(String::as_str) {
|
||||
Some(value) if TicketWorkflowState::parse(value).is_none() => report
|
||||
.push_error(
|
||||
format!("invalid workflow_state '{value}': {}", item.display()),
|
||||
Some(item.clone()),
|
||||
),
|
||||
_ => {}
|
||||
}
|
||||
if status == TicketStatus::Closed
|
||||
&& parsed
|
||||
.frontmatter
|
||||
.get("workflow_state")
|
||||
.is_none_or(|value| value != TicketWorkflowState::Done.as_str())
|
||||
{
|
||||
report.push_warning(
|
||||
format!(
|
||||
"closed ticket should have workflow_state: done: {}",
|
||||
item.display()
|
||||
),
|
||||
Some(item.clone()),
|
||||
);
|
||||
}
|
||||
if status == TicketStatus::Closed && !dir.join("resolution.md").is_file() {
|
||||
report.push_warning(
|
||||
format!("closed ticket missing resolution.md: {}", dir.display()),
|
||||
@@ -1309,6 +1584,11 @@ fn ticket_meta(frontmatter: BTreeMap<String, String>) -> TicketMeta {
|
||||
.or_else(|| frontmatter.get("risks"))
|
||||
.map(|value| parse_yaml_list(value))
|
||||
.unwrap_or_default();
|
||||
let workflow_state_explicit = frontmatter.contains_key("workflow_state");
|
||||
let workflow_state = frontmatter
|
||||
.get("workflow_state")
|
||||
.and_then(|value| TicketWorkflowState::parse(value))
|
||||
.unwrap_or_else(|| TicketWorkflowState::default_for_status(&status));
|
||||
TicketMeta {
|
||||
id,
|
||||
slug,
|
||||
@@ -1331,6 +1611,11 @@ fn ticket_meta(frontmatter: BTreeMap<String, String>) -> TicketMeta {
|
||||
.and_then(|value| parse_bool(value)),
|
||||
risk_flags,
|
||||
action_required: frontmatter.get("action_required").cloned(),
|
||||
workflow_state,
|
||||
workflow_state_explicit,
|
||||
attention_required: frontmatter.get("attention_required").cloned(),
|
||||
queued_by: frontmatter.get("queued_by").cloned(),
|
||||
queued_at: frontmatter.get("queued_at").cloned(),
|
||||
raw: frontmatter,
|
||||
}
|
||||
}
|
||||
@@ -1929,6 +2214,10 @@ readiness: implementation-ready
|
||||
needs_preflight: false
|
||||
risk_flags: [low, local]
|
||||
action_required: none
|
||||
workflow_state: ready
|
||||
attention_required: none
|
||||
queued_by: workspace-panel
|
||||
queued_at: 2026-06-05T00:01:00Z
|
||||
---
|
||||
|
||||
## Body
|
||||
@@ -1941,6 +2230,11 @@ action_required: none
|
||||
assert_eq!(meta.needs_preflight, Some(false));
|
||||
assert_eq!(meta.risk_flags, vec!["low", "local"]);
|
||||
assert_eq!(meta.action_required.as_deref(), Some("none"));
|
||||
assert_eq!(meta.workflow_state, TicketWorkflowState::Ready);
|
||||
assert!(meta.workflow_state_explicit);
|
||||
assert_eq!(meta.attention_required.as_deref(), Some("none"));
|
||||
assert_eq!(meta.queued_by.as_deref(), Some("workspace-panel"));
|
||||
assert_eq!(meta.queued_at.as_deref(), Some("2026-06-05T00:01:00Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1955,6 +2249,9 @@ action_required: none
|
||||
assert!(dir.join("thread.md").exists());
|
||||
assert!(dir.join("artifacts/.gitkeep").exists());
|
||||
assert_eq!(ticket.slug, "example-ticket");
|
||||
let record = backend.show(TicketIdOrSlug::Id(ticket.id.clone())).unwrap();
|
||||
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Intake);
|
||||
assert!(record.meta.workflow_state_explicit);
|
||||
let report = backend.doctor().unwrap();
|
||||
assert!(report.is_ok(), "{:?}", report.diagnostics);
|
||||
}
|
||||
@@ -2167,6 +2464,171 @@ action_required: none
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_state_defaults_and_queue_transition_round_trip() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let backend = backend(&tmp);
|
||||
let mut missing_frontmatter = BTreeMap::new();
|
||||
missing_frontmatter.insert("status".to_string(), "open".to_string());
|
||||
let missing_meta = ticket_meta(missing_frontmatter);
|
||||
assert_eq!(missing_meta.workflow_state, TicketWorkflowState::Intake);
|
||||
assert!(!missing_meta.workflow_state_explicit);
|
||||
|
||||
let mut closed_frontmatter = BTreeMap::new();
|
||||
closed_frontmatter.insert("status".to_string(), "closed".to_string());
|
||||
let closed_meta = ticket_meta(closed_frontmatter);
|
||||
assert_eq!(closed_meta.workflow_state, TicketWorkflowState::Done);
|
||||
assert!(!closed_meta.workflow_state_explicit);
|
||||
|
||||
let mut ready_input = NewTicket::new("Ready Workflow");
|
||||
ready_input.workflow_state = Some(TicketWorkflowState::Ready);
|
||||
let ready = backend.create(ready_input).unwrap();
|
||||
backend
|
||||
.queue_ready(TicketIdOrSlug::Id(ready.id.clone()), "workspace-panel")
|
||||
.unwrap();
|
||||
|
||||
let queued = backend.show(TicketIdOrSlug::Id(ready.id)).unwrap();
|
||||
assert_eq!(queued.meta.workflow_state, TicketWorkflowState::Queued);
|
||||
assert!(queued.meta.workflow_state_explicit);
|
||||
assert_eq!(queued.meta.queued_by.as_deref(), Some("workspace-panel"));
|
||||
assert!(queued.meta.queued_at.is_some());
|
||||
let event = queued
|
||||
.events
|
||||
.iter()
|
||||
.find(|event| event.kind == TicketEventKind::StateChanged)
|
||||
.unwrap();
|
||||
assert_eq!(event.state_field.as_deref(), Some("workflow_state"));
|
||||
assert_eq!(event.from.as_deref(), Some("ready"));
|
||||
assert_eq!(event.to.as_deref(), Some("queued"));
|
||||
assert_eq!(event.reason.as_deref(), Some("queued"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_queue_rejects_non_ready_ticket_without_mutation() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let backend = backend(&tmp);
|
||||
let ticket = backend.create(NewTicket::new("Intake Ticket")).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
backend.queue_ready(TicketIdOrSlug::Id(ticket.id.clone()), "workspace-panel"),
|
||||
Err(TicketError::Conflict(_))
|
||||
));
|
||||
let record = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap();
|
||||
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Intake);
|
||||
assert!(record.meta.queued_by.is_none());
|
||||
assert!(
|
||||
!record
|
||||
.events
|
||||
.iter()
|
||||
.any(|event| event.kind == TicketEventKind::StateChanged)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_state_cannot_be_changed_through_generic_state_field_api() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let backend = backend(&tmp);
|
||||
let ticket = backend
|
||||
.create(NewTicket::new("Generic Workflow Bypass"))
|
||||
.unwrap();
|
||||
let change = TicketStateChange::new(
|
||||
"intake",
|
||||
"done",
|
||||
"bypass",
|
||||
"Generic state field API must not mutate workflow_state.",
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
backend.set_state_field(
|
||||
TicketIdOrSlug::Id(ticket.id.clone()),
|
||||
"workflow_state",
|
||||
change
|
||||
),
|
||||
Err(TicketError::Conflict(_))
|
||||
));
|
||||
let record = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap();
|
||||
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Intake);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_intake_ready_records_summary_and_state_change() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let backend = backend(&tmp);
|
||||
let ticket = backend.create(NewTicket::new("Intake Ready")).unwrap();
|
||||
let mut summary = TicketIntakeSummary::new("Concise accepted requirements.");
|
||||
summary.author = Some("intake".to_string());
|
||||
let mut change =
|
||||
TicketStateChange::new("intake", "ready", "accepted", "Ticket is ready to queue.");
|
||||
change.author = Some("intake".to_string());
|
||||
|
||||
backend
|
||||
.mark_intake_ready(TicketIdOrSlug::Id(ticket.id.clone()), summary, change)
|
||||
.unwrap();
|
||||
let record = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap();
|
||||
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Ready);
|
||||
assert!(
|
||||
record
|
||||
.events
|
||||
.iter()
|
||||
.any(|event| event.kind == TicketEventKind::IntakeSummary)
|
||||
);
|
||||
assert!(record.events.iter().any(|event| {
|
||||
event.kind == TicketEventKind::StateChanged
|
||||
&& event.state_field.as_deref() == Some("workflow_state")
|
||||
&& event.from.as_deref() == Some("intake")
|
||||
&& event.to.as_deref() == Some("ready")
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_sets_workflow_state_done() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let backend = backend(&tmp);
|
||||
let mut input = NewTicket::new("Close Workflow");
|
||||
input.workflow_state = Some(TicketWorkflowState::Queued);
|
||||
let ticket = backend.create(input).unwrap();
|
||||
|
||||
backend
|
||||
.close(
|
||||
TicketIdOrSlug::Id(ticket.id.clone()),
|
||||
MarkdownText::new("Completed."),
|
||||
)
|
||||
.unwrap();
|
||||
let record = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap();
|
||||
assert_eq!(record.meta.status, ExtensibleTicketStatus::Closed);
|
||||
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Done);
|
||||
assert!(record.events.iter().any(|event| {
|
||||
event.kind == TicketEventKind::StateChanged
|
||||
&& event.state_field.as_deref() == Some("workflow_state")
|
||||
&& event.to.as_deref() == Some("done")
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doctor_reports_invalid_workflow_state() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path().join("tickets");
|
||||
fs::create_dir_all(root.join("open/bad/artifacts")).unwrap();
|
||||
fs::write(
|
||||
root.join("open/bad/item.md"),
|
||||
"---\nid: bad\nslug: bad\ntitle: Bad\nstatus: open\nkind: task\npriority: P2\nworkflow_state: almost\nlabels: []\ncreated_at: x\nupdated_at: x\nassignee: null\nlegacy_ticket: null\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(root.join("open/bad/thread.md"), "").unwrap();
|
||||
fs::create_dir_all(root.join("pending")).unwrap();
|
||||
fs::create_dir_all(root.join("closed")).unwrap();
|
||||
|
||||
let report = LocalTicketBackend::new(&root).doctor().unwrap();
|
||||
let messages = report
|
||||
.diagnostics
|
||||
.iter()
|
||||
.map(|d| d.message.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(!report.is_ok());
|
||||
assert!(messages.contains("invalid workflow_state"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doctor_validates_typed_thread_event_attributes() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
+402
-3
@@ -14,8 +14,8 @@ use serde_json::{Value, json};
|
||||
use crate::{
|
||||
ExtensibleTicketStatus, LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, Ticket,
|
||||
TicketBackend, TicketDoctorDiagnostic, TicketDoctorReport, TicketDoctorSeverity, TicketError,
|
||||
TicketEventKind, TicketIdOrSlug, TicketRef, TicketReview, TicketReviewResult, TicketStatus,
|
||||
TicketSummary,
|
||||
TicketEventKind, TicketIdOrSlug, TicketIntakeSummary, TicketRef, TicketReview,
|
||||
TicketReviewResult, TicketStateChange, TicketStatus, TicketSummary, TicketWorkflowState,
|
||||
};
|
||||
|
||||
const DEFAULT_LIST_LIMIT: usize = 100;
|
||||
@@ -29,12 +29,14 @@ const MAX_BODY_MAX_BYTES: usize = 64 * 1024;
|
||||
const DEFAULT_DIAGNOSTIC_LIMIT: usize = 100;
|
||||
const MAX_DIAGNOSTIC_LIMIT: usize = 500;
|
||||
|
||||
pub const TICKET_TOOL_NAMES: [&str; 8] = [
|
||||
pub const TICKET_TOOL_NAMES: [&str; 10] = [
|
||||
"TicketCreate",
|
||||
"TicketList",
|
||||
"TicketShow",
|
||||
"TicketComment",
|
||||
"TicketReview",
|
||||
"TicketIntakeReady",
|
||||
"TicketWorkflowState",
|
||||
"TicketStatus",
|
||||
"TicketClose",
|
||||
"TicketDoctor",
|
||||
@@ -54,6 +56,12 @@ const COMMENT_DESCRIPTION: &str = "Append a typed Ticket thread event. `role` mu
|
||||
configured Ticket backend root.";
|
||||
const REVIEW_DESCRIPTION: &str = "Append a Ticket review event. `result` must be `approve` or \
|
||||
`request_changes`; `body` is Markdown. Writes stay inside the configured Ticket backend root.";
|
||||
const INTAKE_READY_DESCRIPTION: &str = "Mark an existing Ticket intake as ready through the typed \
|
||||
Ticket backend. The tool appends a bounded `intake_summary`, appends a typed `state_changed` event \
|
||||
for `workflow_state`, and transitions workflow_state to `ready`.";
|
||||
const WORKFLOW_STATE_DESCRIPTION: &str = "Transition Ticket `workflow_state` through the typed \
|
||||
Ticket backend with a bounded `state_changed` event. This does not move local open/pending/closed \
|
||||
status; use `TicketStatus` or `TicketClose` for local status changes.";
|
||||
const STATUS_DESCRIPTION: &str = "Move a Ticket between non-closed local statuses through the typed \
|
||||
Ticket backend. Use `TicketClose` for closing because closed Tickets require a resolution accepted \
|
||||
by `yoi ticket doctor`.";
|
||||
@@ -103,6 +111,40 @@ struct TicketCreateParams {
|
||||
/// Optional action-required frontmatter value.
|
||||
#[serde(default)]
|
||||
action_required: Option<String>,
|
||||
/// Optional workflow_state frontmatter value. Defaults to `intake`.
|
||||
#[serde(default)]
|
||||
workflow_state: Option<TicketWorkflowStateParam>,
|
||||
/// Optional attention_required overlay frontmatter value.
|
||||
#[serde(default)]
|
||||
attention_required: Option<String>,
|
||||
/// Optional queued_by frontmatter value.
|
||||
#[serde(default)]
|
||||
queued_by: Option<String>,
|
||||
/// Optional queued_at frontmatter value.
|
||||
#[serde(default)]
|
||||
queued_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum TicketWorkflowStateParam {
|
||||
Intake,
|
||||
Ready,
|
||||
Queued,
|
||||
Inprogress,
|
||||
Done,
|
||||
}
|
||||
|
||||
impl TicketWorkflowStateParam {
|
||||
fn into_state(self) -> TicketWorkflowState {
|
||||
match self {
|
||||
Self::Intake => TicketWorkflowState::Intake,
|
||||
Self::Ready => TicketWorkflowState::Ready,
|
||||
Self::Queued => TicketWorkflowState::Queued,
|
||||
Self::Inprogress => TicketWorkflowState::InProgress,
|
||||
Self::Done => TicketWorkflowState::Done,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
@@ -212,6 +254,40 @@ struct TicketStatusParams {
|
||||
status: TicketStatusParam,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketIntakeReadyParams {
|
||||
/// Ticket id or slug.
|
||||
ticket: String,
|
||||
/// Concise bounded intake summary to append as a typed intake_summary event.
|
||||
intake_summary: String,
|
||||
/// Optional author for both intake_summary and state_changed events.
|
||||
#[serde(default)]
|
||||
author: Option<String>,
|
||||
/// Reason attached to the state_changed event. Defaults to `intake_ready`.
|
||||
#[serde(default)]
|
||||
reason: Option<String>,
|
||||
/// Optional state_changed body. If omitted, a concise default is used.
|
||||
#[serde(default)]
|
||||
state_change_body: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketWorkflowStateParams {
|
||||
/// Ticket id or slug.
|
||||
ticket: String,
|
||||
/// Expected current workflow_state. The backend rejects stale transitions.
|
||||
from: TicketWorkflowStateParam,
|
||||
/// Target workflow_state.
|
||||
to: TicketWorkflowStateParam,
|
||||
/// Reason attached to the typed state_changed event.
|
||||
reason: String,
|
||||
/// Markdown body for the typed state_changed event.
|
||||
body: String,
|
||||
/// Optional thread author.
|
||||
#[serde(default)]
|
||||
author: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketCloseParams {
|
||||
/// Ticket id or slug.
|
||||
@@ -278,6 +354,16 @@ struct TicketReviewTool {
|
||||
backend: LocalTicketBackend,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TicketIntakeReadyTool {
|
||||
backend: LocalTicketBackend,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TicketWorkflowStateTool {
|
||||
backend: LocalTicketBackend,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TicketStatusTool {
|
||||
backend: LocalTicketBackend,
|
||||
@@ -316,6 +402,12 @@ impl Tool for TicketCreateTool {
|
||||
input.needs_preflight = params.needs_preflight;
|
||||
input.risk_flags = params.risk_flags;
|
||||
input.action_required = params.action_required;
|
||||
input.workflow_state = params
|
||||
.workflow_state
|
||||
.map(TicketWorkflowStateParam::into_state);
|
||||
input.attention_required = params.attention_required;
|
||||
input.queued_by = params.queued_by;
|
||||
input.queued_at = params.queued_at;
|
||||
|
||||
let created = self
|
||||
.backend
|
||||
@@ -470,6 +562,76 @@ impl Tool for TicketReviewTool {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TicketIntakeReadyTool {
|
||||
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
let params: TicketIntakeReadyParams = parse_input("TicketIntakeReady", input_json)?;
|
||||
let from = TicketWorkflowState::Intake;
|
||||
let reason = params.reason.unwrap_or_else(|| "intake_ready".to_string());
|
||||
let body = params.state_change_body.unwrap_or_else(|| {
|
||||
format!(
|
||||
"Ticket intake complete; workflow_state {} -> ready.\n",
|
||||
from.as_str()
|
||||
)
|
||||
});
|
||||
let mut summary = TicketIntakeSummary::new(params.intake_summary);
|
||||
summary.author = params.author.clone();
|
||||
let mut change = TicketStateChange::new(
|
||||
from.as_str(),
|
||||
TicketWorkflowState::Ready.as_str(),
|
||||
reason,
|
||||
body,
|
||||
);
|
||||
change.author = params.author;
|
||||
self.backend
|
||||
.mark_intake_ready(
|
||||
TicketIdOrSlug::Query(params.ticket.clone()),
|
||||
summary,
|
||||
change,
|
||||
)
|
||||
.map_err(|error| backend_error("TicketIntakeReady", error))?;
|
||||
Ok(json_output(
|
||||
format!("Marked ticket {} workflow_state ready", params.ticket),
|
||||
json!({ "ticket": params.ticket, "workflow_state": "ready", "ok": true }),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TicketWorkflowStateTool {
|
||||
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
let params: TicketWorkflowStateParams = parse_input("TicketWorkflowState", input_json)?;
|
||||
let from = params.from.into_state();
|
||||
let to = params.to.into_state();
|
||||
if from == to {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"workflow_state transition must change state".to_string(),
|
||||
));
|
||||
}
|
||||
let mut change =
|
||||
TicketStateChange::new(from.as_str(), to.as_str(), params.reason, params.body);
|
||||
change.author = params.author;
|
||||
self.backend
|
||||
.set_workflow_state(TicketIdOrSlug::Query(params.ticket.clone()), change)
|
||||
.map_err(|error| backend_error("TicketWorkflowState", error))?;
|
||||
Ok(json_output(
|
||||
format!(
|
||||
"Transitioned ticket {} workflow_state {} -> {}",
|
||||
params.ticket,
|
||||
from.as_str(),
|
||||
to.as_str()
|
||||
),
|
||||
json!({
|
||||
"ticket": params.ticket,
|
||||
"from": from.as_str(),
|
||||
"to": to.as_str(),
|
||||
"workflow_state": to.as_str(),
|
||||
"ok": true
|
||||
}),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TicketStatusTool {
|
||||
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
@@ -586,6 +748,11 @@ fn ticket_summary_json(ticket: TicketSummary) -> Value {
|
||||
"readiness": ticket.readiness,
|
||||
"needs_preflight": ticket.needs_preflight,
|
||||
"action_required": ticket.action_required,
|
||||
"workflow_state": ticket.workflow_state.as_str(),
|
||||
"workflow_state_explicit": ticket.workflow_state_explicit,
|
||||
"attention_required": ticket.attention_required,
|
||||
"queued_by": ticket.queued_by,
|
||||
"queued_at": ticket.queued_at,
|
||||
"updated_at": ticket.updated_at,
|
||||
})
|
||||
}
|
||||
@@ -641,6 +808,11 @@ fn ticket_json(
|
||||
"needs_preflight": ticket.meta.needs_preflight,
|
||||
"risk_flags": ticket.meta.risk_flags,
|
||||
"action_required": ticket.meta.action_required,
|
||||
"workflow_state": ticket.meta.workflow_state.as_str(),
|
||||
"workflow_state_explicit": ticket.meta.workflow_state_explicit,
|
||||
"attention_required": ticket.meta.attention_required,
|
||||
"queued_by": ticket.meta.queued_by,
|
||||
"queued_at": ticket.meta.queued_at,
|
||||
},
|
||||
"body": truncate_text(ticket.document.body.as_str(), body_max_bytes),
|
||||
"events": {
|
||||
@@ -736,6 +908,10 @@ fn input_schema(name: &str) -> Value {
|
||||
"TicketShow" => serde_json::to_value(schemars::schema_for!(TicketShowParams)),
|
||||
"TicketComment" => serde_json::to_value(schemars::schema_for!(TicketCommentParams)),
|
||||
"TicketReview" => serde_json::to_value(schemars::schema_for!(TicketReviewParams)),
|
||||
"TicketIntakeReady" => serde_json::to_value(schemars::schema_for!(TicketIntakeReadyParams)),
|
||||
"TicketWorkflowState" => {
|
||||
serde_json::to_value(schemars::schema_for!(TicketWorkflowStateParams))
|
||||
}
|
||||
"TicketStatus" => serde_json::to_value(schemars::schema_for!(TicketStatusParams)),
|
||||
"TicketClose" => serde_json::to_value(schemars::schema_for!(TicketCloseParams)),
|
||||
"TicketDoctor" => serde_json::to_value(schemars::schema_for!(TicketDoctorParams)),
|
||||
@@ -759,6 +935,8 @@ impl_from_backend!(TicketListTool);
|
||||
impl_from_backend!(TicketShowTool);
|
||||
impl_from_backend!(TicketCommentTool);
|
||||
impl_from_backend!(TicketReviewTool);
|
||||
impl_from_backend!(TicketIntakeReadyTool);
|
||||
impl_from_backend!(TicketWorkflowStateTool);
|
||||
impl_from_backend!(TicketStatusTool);
|
||||
impl_from_backend!(TicketCloseTool);
|
||||
impl_from_backend!(TicketDoctorTool);
|
||||
@@ -771,6 +949,16 @@ pub fn ticket_tools(backend: LocalTicketBackend) -> Vec<ToolDefinition> {
|
||||
tool_definition::<TicketShowTool>("TicketShow", SHOW_DESCRIPTION, backend.clone()),
|
||||
tool_definition::<TicketCommentTool>("TicketComment", COMMENT_DESCRIPTION, backend.clone()),
|
||||
tool_definition::<TicketReviewTool>("TicketReview", REVIEW_DESCRIPTION, backend.clone()),
|
||||
tool_definition::<TicketIntakeReadyTool>(
|
||||
"TicketIntakeReady",
|
||||
INTAKE_READY_DESCRIPTION,
|
||||
backend.clone(),
|
||||
),
|
||||
tool_definition::<TicketWorkflowStateTool>(
|
||||
"TicketWorkflowState",
|
||||
WORKFLOW_STATE_DESCRIPTION,
|
||||
backend.clone(),
|
||||
),
|
||||
tool_definition::<TicketStatusTool>("TicketStatus", STATUS_DESCRIPTION, backend.clone()),
|
||||
tool_definition::<TicketCloseTool>("TicketClose", CLOSE_DESCRIPTION, backend.clone()),
|
||||
tool_definition::<TicketDoctorTool>("TicketDoctor", DOCTOR_DESCRIPTION, backend),
|
||||
@@ -913,6 +1101,217 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_workflow_tools_mark_ready_and_transition_state() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let backend = backend(&temp);
|
||||
let created = backend.create(NewTicket::new("Workflow Tool")).unwrap();
|
||||
let intake_ready = tool_by_name(backend.clone(), "TicketIntakeReady");
|
||||
let workflow = tool_by_name(backend.clone(), "TicketWorkflowState");
|
||||
|
||||
intake_ready
|
||||
.execute(
|
||||
&json!({
|
||||
"ticket": created.slug,
|
||||
"intake_summary": "Requirements accepted; implementation can be queued.",
|
||||
"author": "intake-pod"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
backend
|
||||
.queue_ready(TicketIdOrSlug::Id(created.id.clone()), "panel")
|
||||
.unwrap();
|
||||
workflow
|
||||
.execute(
|
||||
&json!({
|
||||
"ticket": created.slug,
|
||||
"from": "queued",
|
||||
"to": "inprogress",
|
||||
"reason": "orchestrator_started",
|
||||
"body": "Orchestrator started implementation.\n",
|
||||
"author": "orchestrator"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
workflow
|
||||
.execute(
|
||||
&json!({
|
||||
"ticket": created.slug,
|
||||
"from": "inprogress",
|
||||
"to": "done",
|
||||
"reason": "implementation_complete",
|
||||
"body": "Implementation finished and is ready for close.\n",
|
||||
"author": "orchestrator"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let record = backend.show(TicketIdOrSlug::Query(created.slug)).unwrap();
|
||||
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Done);
|
||||
assert_eq!(record.meta.status.as_local(), Some(TicketStatus::Open));
|
||||
assert!(
|
||||
record
|
||||
.events
|
||||
.iter()
|
||||
.any(|event| event.kind == TicketEventKind::IntakeSummary)
|
||||
);
|
||||
let transitions = record
|
||||
.events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
event.kind == TicketEventKind::StateChanged
|
||||
&& event.state_field.as_deref() == Some("workflow_state")
|
||||
})
|
||||
.map(|event| (event.from.as_deref(), event.to.as_deref()))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
transitions,
|
||||
vec![
|
||||
(Some("intake"), Some("ready")),
|
||||
(Some("ready"), Some("queued")),
|
||||
(Some("queued"), Some("inprogress")),
|
||||
(Some("inprogress"), Some("done"))
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_workflow_tool_rejects_stale_transition_without_status_move() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let backend = backend(&temp);
|
||||
let created = backend
|
||||
.create(NewTicket::new("Stale Workflow Tool"))
|
||||
.unwrap();
|
||||
let workflow = tool_by_name(backend.clone(), "TicketWorkflowState");
|
||||
|
||||
let error = workflow
|
||||
.execute(
|
||||
&json!({
|
||||
"ticket": created.id,
|
||||
"from": "queued",
|
||||
"to": "inprogress",
|
||||
"reason": "orchestrator_started",
|
||||
"body": "Should not apply.\n"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("workflow_state changed concurrently")
|
||||
);
|
||||
let record = backend.show(TicketIdOrSlug::Query(created.slug)).unwrap();
|
||||
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Intake);
|
||||
assert_eq!(record.meta.status.as_local(), Some(TicketStatus::Open));
|
||||
assert!(!record.events.iter().any(|event| {
|
||||
event.kind == TicketEventKind::StateChanged
|
||||
&& event.state_field.as_deref() == Some("workflow_state")
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_workflow_tool_rejects_disallowed_transition_graph_edges() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let backend = backend(&temp);
|
||||
let workflow = tool_by_name(backend.clone(), "TicketWorkflowState");
|
||||
|
||||
let mut ready_input = NewTicket::new("Ready Bypass");
|
||||
ready_input.workflow_state = Some(TicketWorkflowState::Ready);
|
||||
let ready = backend.create(ready_input).unwrap();
|
||||
let ready_error = workflow
|
||||
.execute(
|
||||
&json!({
|
||||
"ticket": ready.id,
|
||||
"from": "ready",
|
||||
"to": "inprogress",
|
||||
"reason": "bypass_queue",
|
||||
"body": "Should not bypass Queue.\n"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(ready_error.to_string().contains("not allowed"));
|
||||
|
||||
let mut done_input = NewTicket::new("Backward Bypass");
|
||||
done_input.workflow_state = Some(TicketWorkflowState::Done);
|
||||
let done = backend.create(done_input).unwrap();
|
||||
let backward_error = workflow
|
||||
.execute(
|
||||
&json!({
|
||||
"ticket": done.id,
|
||||
"from": "done",
|
||||
"to": "intake",
|
||||
"reason": "backwards",
|
||||
"body": "Should not move backwards.\n"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(backward_error.to_string().contains("not allowed"));
|
||||
|
||||
let mut queued_input = NewTicket::new("Skip Bypass");
|
||||
queued_input.workflow_state = Some(TicketWorkflowState::Queued);
|
||||
let queued = backend.create(queued_input).unwrap();
|
||||
let skip_error = workflow
|
||||
.execute(
|
||||
&json!({
|
||||
"ticket": queued.id,
|
||||
"from": "queued",
|
||||
"to": "done",
|
||||
"reason": "skip_inprogress",
|
||||
"body": "Should not skip inprogress.\n"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(skip_error.to_string().contains("not allowed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_intake_ready_tool_rejects_non_intake_ticket() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let backend = backend(&temp);
|
||||
let mut input = NewTicket::new("Already Ready");
|
||||
input.workflow_state = Some(TicketWorkflowState::Ready);
|
||||
let created = backend.create(input).unwrap();
|
||||
let intake_ready = tool_by_name(backend.clone(), "TicketIntakeReady");
|
||||
|
||||
let error = intake_ready
|
||||
.execute(
|
||||
&json!({
|
||||
"ticket": created.id,
|
||||
"intake_summary": "Should not rewrite ready ticket."
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("workflow_state changed concurrently")
|
||||
);
|
||||
let record = backend.show(TicketIdOrSlug::Query(created.slug)).unwrap();
|
||||
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Ready);
|
||||
assert!(!record.events.iter().any(|event| {
|
||||
event.kind == TicketEventKind::StateChanged
|
||||
&& event.state_field.as_deref() == Some("workflow_state")
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_show_requires_exactly_one_identifier() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user