feat: replace intake workflow state with planning
This commit is contained in:
+89
-34
@@ -176,7 +176,7 @@ impl From<TicketStatus> for ExtensibleTicketStatus {
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum TicketWorkflowState {
|
||||
Intake,
|
||||
Planning,
|
||||
Ready,
|
||||
Queued,
|
||||
InProgress,
|
||||
@@ -186,7 +186,7 @@ pub enum TicketWorkflowState {
|
||||
impl TicketWorkflowState {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Intake => "intake",
|
||||
Self::Planning => "planning",
|
||||
Self::Ready => "ready",
|
||||
Self::Queued => "queued",
|
||||
Self::InProgress => "inprogress",
|
||||
@@ -196,7 +196,7 @@ impl TicketWorkflowState {
|
||||
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"intake" => Some(Self::Intake),
|
||||
"planning" | "intake" => Some(Self::Planning),
|
||||
"ready" => Some(Self::Ready),
|
||||
"queued" => Some(Self::Queued),
|
||||
"inprogress" => Some(Self::InProgress),
|
||||
@@ -208,12 +208,12 @@ impl TicketWorkflowState {
|
||||
pub fn default_for_status(status: &ExtensibleTicketStatus) -> Self {
|
||||
match status {
|
||||
ExtensibleTicketStatus::Closed => Self::Done,
|
||||
_ => Self::Intake,
|
||||
_ => Self::Planning,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_intake_ready_transition(from: Self, to: Self) -> bool {
|
||||
from == Self::Intake && to == Self::Ready
|
||||
pub fn is_planning_ready_transition(from: Self, to: Self) -> bool {
|
||||
from == Self::Planning && to == Self::Ready
|
||||
}
|
||||
|
||||
pub fn is_queue_transition(from: Self, to: Self) -> bool {
|
||||
@@ -223,7 +223,10 @@ impl TicketWorkflowState {
|
||||
pub fn is_role_transition(from: Self, to: Self) -> bool {
|
||||
matches!(
|
||||
(from, to),
|
||||
(Self::Queued, Self::InProgress) | (Self::InProgress, Self::Done)
|
||||
(Self::Queued, Self::InProgress)
|
||||
| (Self::InProgress, Self::Done)
|
||||
| (Self::Ready, Self::Planning)
|
||||
| (Self::Queued, Self::Planning)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -495,6 +498,7 @@ pub struct NewTicket {
|
||||
pub assignee: Option<String>,
|
||||
pub legacy_ticket: Option<String>,
|
||||
pub readiness: Option<String>,
|
||||
/// Legacy metadata accepted for existing records only; not a workflow stop gate.
|
||||
pub needs_preflight: Option<bool>,
|
||||
pub risk_flags: Vec<String>,
|
||||
pub action_required: Option<String>,
|
||||
@@ -566,6 +570,7 @@ pub struct TicketMeta {
|
||||
pub assignee: Option<String>,
|
||||
pub legacy_ticket: Option<String>,
|
||||
pub readiness: Option<String>,
|
||||
/// Legacy metadata accepted for existing records only; not a workflow stop gate.
|
||||
pub needs_preflight: Option<bool>,
|
||||
pub risk_flags: Vec<String>,
|
||||
pub action_required: Option<String>,
|
||||
@@ -587,6 +592,7 @@ pub struct TicketSummary {
|
||||
pub priority: String,
|
||||
pub labels: Vec<String>,
|
||||
pub readiness: Option<String>,
|
||||
/// Legacy metadata accepted for existing records only; not a workflow stop gate.
|
||||
pub needs_preflight: Option<bool>,
|
||||
pub action_required: Option<String>,
|
||||
pub workflow_state: TicketWorkflowState,
|
||||
@@ -737,9 +743,9 @@ impl LocalTicketBackend {
|
||||
|
||||
pub fn default_intake_ready_state_change_body(&self, from: &str) -> String {
|
||||
if is_japanese_record_language(self.record_language()) {
|
||||
format!("Ticket intake が完了しました。workflow_state {from} -> ready。\n")
|
||||
format!("Ticket planning が完了しました。workflow_state {from} -> ready。\n")
|
||||
} else {
|
||||
format!("Ticket intake complete; workflow_state {from} -> ready.\n")
|
||||
format!("Ticket planning complete; workflow_state {from} -> ready.\n")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1130,7 +1136,7 @@ impl TicketBackend for LocalTicketBackend {
|
||||
format_yaml_string_scalar(
|
||||
input
|
||||
.workflow_state
|
||||
.unwrap_or(TicketWorkflowState::Intake)
|
||||
.unwrap_or(TicketWorkflowState::Planning)
|
||||
.as_str(),
|
||||
),
|
||||
));
|
||||
@@ -1279,7 +1285,7 @@ impl TicketBackend for LocalTicketBackend {
|
||||
})?;
|
||||
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",
|
||||
"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()
|
||||
)));
|
||||
@@ -1307,9 +1313,9 @@ impl TicketBackend for LocalTicketBackend {
|
||||
change.to
|
||||
))
|
||||
})?;
|
||||
if !TicketWorkflowState::is_intake_ready_transition(from, to) {
|
||||
if !TicketWorkflowState::is_planning_ready_transition(from, to) {
|
||||
return Err(TicketError::Conflict(format!(
|
||||
"mark_intake_ready only allows workflow_state intake -> ready, got {} -> {}",
|
||||
"mark_intake_ready only allows workflow_state planning -> ready, got {} -> {}",
|
||||
from.as_str(),
|
||||
to.as_str()
|
||||
)));
|
||||
@@ -1747,7 +1753,7 @@ fn parse_ticket_frontmatter(content: &str) -> std::result::Result<TicketItemFron
|
||||
let workflow_state_value = yaml_string(&mapping, "workflow_state")?;
|
||||
let workflow_state = match workflow_state_value.as_deref() {
|
||||
Some(value) => Some(TicketWorkflowState::parse(value).ok_or_else(|| {
|
||||
format!("invalid workflow_state '{value}': expected intake, ready, queued, inprogress, or done")
|
||||
format!("invalid workflow_state '{value}': expected planning, ready, queued, inprogress, done, or legacy intake")
|
||||
})?),
|
||||
None => None,
|
||||
};
|
||||
@@ -2470,6 +2476,55 @@ mod tests {
|
||||
LocalTicketBackend::new(dir.path().join("tickets"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_state_parses_legacy_intake_as_planning_and_emits_planning() {
|
||||
assert_eq!(
|
||||
TicketWorkflowState::parse("planning"),
|
||||
Some(TicketWorkflowState::Planning)
|
||||
);
|
||||
assert_eq!(
|
||||
TicketWorkflowState::parse("intake"),
|
||||
Some(TicketWorkflowState::Planning)
|
||||
);
|
||||
assert_eq!(TicketWorkflowState::Planning.as_str(), "planning");
|
||||
assert_eq!(
|
||||
TicketWorkflowState::default_for_status(&ExtensibleTicketStatus::Open),
|
||||
TicketWorkflowState::Planning
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_state_transition_graph_allows_planning_lane_and_returns() {
|
||||
assert!(TicketWorkflowState::is_planning_ready_transition(
|
||||
TicketWorkflowState::Planning,
|
||||
TicketWorkflowState::Ready
|
||||
));
|
||||
assert!(TicketWorkflowState::is_queue_transition(
|
||||
TicketWorkflowState::Ready,
|
||||
TicketWorkflowState::Queued
|
||||
));
|
||||
assert!(TicketWorkflowState::is_role_transition(
|
||||
TicketWorkflowState::Queued,
|
||||
TicketWorkflowState::InProgress
|
||||
));
|
||||
assert!(TicketWorkflowState::is_role_transition(
|
||||
TicketWorkflowState::InProgress,
|
||||
TicketWorkflowState::Done
|
||||
));
|
||||
assert!(TicketWorkflowState::is_role_transition(
|
||||
TicketWorkflowState::Ready,
|
||||
TicketWorkflowState::Planning
|
||||
));
|
||||
assert!(TicketWorkflowState::is_role_transition(
|
||||
TicketWorkflowState::Queued,
|
||||
TicketWorkflowState::Planning
|
||||
));
|
||||
assert!(!TicketWorkflowState::is_role_transition(
|
||||
TicketWorkflowState::Planning,
|
||||
TicketWorkflowState::Queued
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_item_frontmatter_and_optional_fields() {
|
||||
let item = r#"---
|
||||
@@ -2537,7 +2592,7 @@ workflow_state: intake
|
||||
assert_eq!(meta.action_required.as_deref(), Some("null"));
|
||||
assert_eq!(meta.readiness.as_deref(), Some("~"));
|
||||
assert_eq!(meta.needs_preflight, Some(false));
|
||||
assert_eq!(meta.workflow_state, TicketWorkflowState::Intake);
|
||||
assert_eq!(meta.workflow_state, TicketWorkflowState::Planning);
|
||||
assert!(meta.workflow_state_explicit);
|
||||
}
|
||||
|
||||
@@ -2581,7 +2636,7 @@ workflow_state: intake
|
||||
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_eq!(record.meta.workflow_state, TicketWorkflowState::Planning);
|
||||
assert!(record.meta.workflow_state_explicit);
|
||||
let report = backend.doctor().unwrap();
|
||||
assert!(report.is_ok(), "{:?}", report.diagnostics);
|
||||
@@ -2754,10 +2809,10 @@ workflow_state: intake
|
||||
.create(NewTicket::new("Typed Thread Ticket"))
|
||||
.unwrap();
|
||||
let mut change = TicketStateChange::new(
|
||||
"preflight",
|
||||
"requirements-sync",
|
||||
"implementation-ready",
|
||||
"preflight approved",
|
||||
"Preflight finished; implementation can begin.",
|
||||
"requirements approved",
|
||||
"Planning sync finished; implementation can begin.",
|
||||
);
|
||||
change.author = Some("orchestrator".into());
|
||||
backend
|
||||
@@ -2775,13 +2830,13 @@ workflow_state: intake
|
||||
.iter()
|
||||
.find(|event| event.kind == TicketEventKind::StateChanged)
|
||||
.unwrap();
|
||||
assert_eq!(state_event.from.as_deref(), Some("preflight"));
|
||||
assert_eq!(state_event.from.as_deref(), Some("requirements-sync"));
|
||||
assert_eq!(state_event.to.as_deref(), Some("implementation-ready"));
|
||||
assert_eq!(state_event.reason.as_deref(), Some("preflight approved"));
|
||||
assert_eq!(state_event.reason.as_deref(), Some("requirements approved"));
|
||||
assert_eq!(state_event.author.as_deref(), Some("orchestrator"));
|
||||
assert_eq!(
|
||||
state_event.attributes.get("reason").map(String::as_str),
|
||||
Some("preflight approved")
|
||||
Some("requirements approved")
|
||||
);
|
||||
assert!(
|
||||
record
|
||||
@@ -2798,7 +2853,7 @@ workflow_state: intake
|
||||
)
|
||||
.unwrap();
|
||||
assert!(thread.contains("event: state_changed"));
|
||||
assert!(thread.contains("reason: \"preflight approved\""));
|
||||
assert!(thread.contains("reason: \"requirements approved\""));
|
||||
assert!(thread.contains("event: intake_summary"));
|
||||
let report = backend.doctor().unwrap();
|
||||
assert!(report.is_ok(), "{:?}", report.diagnostics);
|
||||
@@ -2817,11 +2872,11 @@ workflow_state: intake
|
||||
.join(&ticket.id)
|
||||
.join("item.md");
|
||||
backend
|
||||
.set_frontmatter_fields(&item, &[("readiness", "preflight")])
|
||||
.set_frontmatter_fields(&item, &[("readiness", "requirements-sync")])
|
||||
.unwrap();
|
||||
|
||||
let mut change = TicketStateChange::new(
|
||||
"preflight",
|
||||
"requirements-sync",
|
||||
"implementation-ready",
|
||||
"requirements accepted",
|
||||
"Implementation is authorized.",
|
||||
@@ -2843,7 +2898,7 @@ workflow_state: intake
|
||||
.unwrap();
|
||||
assert_eq!(event.state_field.as_deref(), Some("readiness"));
|
||||
let stale = TicketStateChange::new(
|
||||
"preflight",
|
||||
"requirements-sync",
|
||||
"done",
|
||||
"stale update",
|
||||
"This must be rejected.",
|
||||
@@ -2861,7 +2916,7 @@ workflow_state: intake
|
||||
let missing_meta = ticket_meta(
|
||||
parse_ticket_frontmatter("status: open").expect("missing workflow state parses"),
|
||||
);
|
||||
assert_eq!(missing_meta.workflow_state, TicketWorkflowState::Intake);
|
||||
assert_eq!(missing_meta.workflow_state, TicketWorkflowState::Planning);
|
||||
assert!(!missing_meta.workflow_state_explicit);
|
||||
|
||||
let closed_meta =
|
||||
@@ -2896,14 +2951,14 @@ workflow_state: intake
|
||||
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();
|
||||
let ticket = backend.create(NewTicket::new("Planning 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_eq!(record.meta.workflow_state, TicketWorkflowState::Planning);
|
||||
assert!(record.meta.queued_by.is_none());
|
||||
assert!(
|
||||
!record
|
||||
@@ -2921,7 +2976,7 @@ workflow_state: intake
|
||||
.create(NewTicket::new("Generic Workflow Bypass"))
|
||||
.unwrap();
|
||||
let change = TicketStateChange::new(
|
||||
"intake",
|
||||
"planning",
|
||||
"done",
|
||||
"bypass",
|
||||
"Generic state field API must not mutate workflow_state.",
|
||||
@@ -2936,18 +2991,18 @@ workflow_state: intake
|
||||
Err(TicketError::Conflict(_))
|
||||
));
|
||||
let record = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap();
|
||||
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Intake);
|
||||
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Planning);
|
||||
}
|
||||
|
||||
#[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 ticket = backend.create(NewTicket::new("Planning Ready")).unwrap();
|
||||
let mut summary = TicketIntakeSummary::new("Concise accepted requirements.");
|
||||
summary.author = Some("intake".to_string());
|
||||
let mut change =
|
||||
TicketStateChange::new("intake", "ready", "accepted", "Ticket is ready to queue.");
|
||||
TicketStateChange::new("planning", "ready", "accepted", "Ticket is ready to queue.");
|
||||
change.author = Some("intake".to_string());
|
||||
|
||||
backend
|
||||
@@ -2964,7 +3019,7 @@ workflow_state: intake
|
||||
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.from.as_deref() == Some("planning")
|
||||
&& event.to.as_deref() == Some("ready")
|
||||
}));
|
||||
}
|
||||
|
||||
+79
-15
@@ -68,14 +68,14 @@ 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 \
|
||||
const INTAKE_READY_DESCRIPTION: &str = "Mark an existing Ticket planning lane ready through the typed \
|
||||
Ticket backend. The tool appends a bounded `intake_summary`, appends a typed `state_changed` event \
|
||||
for `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. Treat `queued -> inprogress` \
|
||||
as the implementation acceptance step: implementation side effects should happen only after that \
|
||||
transition is accepted and recorded.";
|
||||
transition is accepted and recorded. Orchestrator may return `ready` or `queued` Tickets to `planning` only with a concrete missing decision/information reason.";
|
||||
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`.";
|
||||
@@ -116,16 +116,13 @@ struct TicketCreateParams {
|
||||
/// Optional readiness frontmatter value.
|
||||
#[serde(default)]
|
||||
readiness: Option<String>,
|
||||
/// Optional preflight flag frontmatter value.
|
||||
#[serde(default)]
|
||||
needs_preflight: Option<bool>,
|
||||
/// Optional risk flag frontmatter values.
|
||||
#[serde(default)]
|
||||
risk_flags: Vec<String>,
|
||||
/// Optional action-required frontmatter value.
|
||||
#[serde(default)]
|
||||
action_required: Option<String>,
|
||||
/// Optional workflow_state frontmatter value. Defaults to `intake`.
|
||||
/// Optional workflow_state frontmatter value. Defaults to `planning`.
|
||||
#[serde(default)]
|
||||
workflow_state: Option<TicketWorkflowStateParam>,
|
||||
/// Optional attention_required overlay frontmatter value.
|
||||
@@ -142,7 +139,8 @@ struct TicketCreateParams {
|
||||
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum TicketWorkflowStateParam {
|
||||
Intake,
|
||||
#[serde(alias = "intake")]
|
||||
Planning,
|
||||
Ready,
|
||||
Queued,
|
||||
Inprogress,
|
||||
@@ -152,7 +150,7 @@ enum TicketWorkflowStateParam {
|
||||
impl TicketWorkflowStateParam {
|
||||
fn into_state(self) -> TicketWorkflowState {
|
||||
match self {
|
||||
Self::Intake => TicketWorkflowState::Intake,
|
||||
Self::Planning => TicketWorkflowState::Planning,
|
||||
Self::Ready => TicketWorkflowState::Ready,
|
||||
Self::Queued => TicketWorkflowState::Queued,
|
||||
Self::Inprogress => TicketWorkflowState::InProgress,
|
||||
@@ -277,7 +275,7 @@ struct TicketIntakeReadyParams {
|
||||
/// 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`.
|
||||
/// Reason attached to the state_changed event. Defaults to `planning_ready`.
|
||||
#[serde(default)]
|
||||
reason: Option<String>,
|
||||
/// Optional state_changed body. If omitted, a concise default is used.
|
||||
@@ -413,7 +411,6 @@ impl Tool for TicketCreateTool {
|
||||
input.assignee = params.assignee;
|
||||
input.legacy_ticket = params.legacy_ticket;
|
||||
input.readiness = params.readiness;
|
||||
input.needs_preflight = params.needs_preflight;
|
||||
input.risk_flags = params.risk_flags;
|
||||
input.action_required = params.action_required;
|
||||
input.workflow_state = params
|
||||
@@ -580,8 +577,10 @@ impl Tool for TicketReviewTool {
|
||||
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 from = TicketWorkflowState::Planning;
|
||||
let reason = params
|
||||
.reason
|
||||
.unwrap_or_else(|| "planning_ready".to_string());
|
||||
let body = params.state_change_body.unwrap_or_else(|| {
|
||||
self.backend
|
||||
.default_intake_ready_state_change_body(from.as_str())
|
||||
@@ -1229,7 +1228,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
transitions,
|
||||
vec![
|
||||
(Some("intake"), Some("ready")),
|
||||
(Some("planning"), Some("ready")),
|
||||
(Some("ready"), Some("queued")),
|
||||
(Some("queued"), Some("inprogress")),
|
||||
(Some("inprogress"), Some("done"))
|
||||
@@ -1237,6 +1236,71 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_workflow_tool_allows_return_to_planning_from_ready_and_queued() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let backend = backend(&temp);
|
||||
let workflow = tool_by_name(backend.clone(), "TicketWorkflowState");
|
||||
|
||||
let mut ready_input = NewTicket::new("Ready Needs Planning");
|
||||
ready_input.workflow_state = Some(TicketWorkflowState::Ready);
|
||||
let ready = backend.create(ready_input).unwrap();
|
||||
workflow
|
||||
.execute(
|
||||
&json!({
|
||||
"ticket": ready.id,
|
||||
"from": "ready",
|
||||
"to": "planning",
|
||||
"reason": "missing_acceptance_decision",
|
||||
"body": "Missing decision: clarify acceptance criteria before queueing.\n",
|
||||
"author": "orchestrator"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let ready_record = backend.show(TicketIdOrSlug::Query(ready.slug)).unwrap();
|
||||
assert_eq!(
|
||||
ready_record.meta.workflow_state,
|
||||
TicketWorkflowState::Planning
|
||||
);
|
||||
assert!(ready_record.events.iter().any(|event| {
|
||||
event.kind == TicketEventKind::StateChanged
|
||||
&& event.from.as_deref() == Some("ready")
|
||||
&& event.to.as_deref() == Some("planning")
|
||||
&& event.reason.as_deref() == Some("missing_acceptance_decision")
|
||||
}));
|
||||
|
||||
let mut queued_input = NewTicket::new("Queued Needs Planning");
|
||||
queued_input.workflow_state = Some(TicketWorkflowState::Queued);
|
||||
let queued = backend.create(queued_input).unwrap();
|
||||
workflow
|
||||
.execute(
|
||||
&json!({
|
||||
"ticket": queued.id,
|
||||
"from": "queued",
|
||||
"to": "planning",
|
||||
"reason": "missing_authority_decision",
|
||||
"body": "Missing decision: define authority boundary before implementation side effects.\n",
|
||||
"author": "orchestrator"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let queued_record = backend.show(TicketIdOrSlug::Query(queued.slug)).unwrap();
|
||||
assert_eq!(
|
||||
queued_record.meta.workflow_state,
|
||||
TicketWorkflowState::Planning
|
||||
);
|
||||
assert!(queued_record.events.iter().any(|event| {
|
||||
event.kind == TicketEventKind::StateChanged
|
||||
&& event.from.as_deref() == Some("queued")
|
||||
&& event.to.as_deref() == Some("planning")
|
||||
&& event.reason.as_deref() == Some("missing_authority_decision")
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_workflow_tool_rejects_stale_transition_without_status_move() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
@@ -1266,7 +1330,7 @@ mod tests {
|
||||
.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.workflow_state, TicketWorkflowState::Planning);
|
||||
assert_eq!(record.meta.status.as_local(), Some(TicketStatus::Open));
|
||||
assert!(!record.events.iter().any(|event| {
|
||||
event.kind == TicketEventKind::StateChanged
|
||||
@@ -1336,7 +1400,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_intake_ready_tool_rejects_non_intake_ticket() {
|
||||
async fn ticket_intake_ready_tool_rejects_non_planning_ticket() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let backend = backend(&temp);
|
||||
let mut input = NewTicket::new("Already Ready");
|
||||
|
||||
Reference in New Issue
Block a user