fix: preserve intake and companion ticket workflows
This commit is contained in:
@@ -529,6 +529,8 @@ pub struct TicketMarkReady {
|
||||
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 {
|
||||
@@ -590,6 +592,16 @@ fn mark_ready_fingerprint(
|
||||
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()
|
||||
@@ -3598,6 +3610,28 @@ impl TicketBackend for SqliteTicketBackend {
|
||||
.unwrap_or("implementation target validated")
|
||||
.to_owned();
|
||||
let at = now_utc();
|
||||
if let Some(mut summary) = request.intake_summary.clone() {
|
||||
validate_intake_summary(&summary)?;
|
||||
summary.author = request.author.clone().or(summary.author);
|
||||
self.insert_event(
|
||||
conn,
|
||||
&ticket_id,
|
||||
&TicketEvent {
|
||||
kind: TicketEventKind::IntakeSummary,
|
||||
author: summary.author,
|
||||
at: None,
|
||||
status: None,
|
||||
from: None,
|
||||
to: None,
|
||||
reason: None,
|
||||
state_field: None,
|
||||
heading: Some(TicketEventKind::IntakeSummary.heading()),
|
||||
body: summary.body,
|
||||
references: summary.references,
|
||||
attributes: BTreeMap::new(),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
self.insert_event(
|
||||
conn,
|
||||
&ticket_id,
|
||||
@@ -4252,7 +4286,11 @@ impl TicketBackend for LocalTicketBackend {
|
||||
target.repository_id, target.ref_selector
|
||||
)),
|
||||
);
|
||||
change.author = request.author.or_else(|| Some(default_author()));
|
||||
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,
|
||||
@@ -6960,6 +6998,7 @@ state: planning
|
||||
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(
|
||||
@@ -7377,6 +7416,7 @@ state: planning
|
||||
operation_key: "test-flow-ready".to_owned(),
|
||||
reason: Some("ready_for_queue".to_owned()),
|
||||
author: Some("test".to_owned()),
|
||||
intake_summary: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -7648,6 +7688,7 @@ state: planning
|
||||
operation_key: "ready-op-1".to_owned(),
|
||||
reason: Some("accepted".to_owned()),
|
||||
author: Some("intake".to_owned()),
|
||||
intake_summary: None,
|
||||
};
|
||||
|
||||
let first = backend
|
||||
@@ -7679,6 +7720,7 @@ state: planning
|
||||
operation_key: "ready-op-1".to_owned(),
|
||||
reason: Some("different".to_owned()),
|
||||
author: Some("intake".to_owned()),
|
||||
intake_summary: None,
|
||||
},
|
||||
),
|
||||
Err(TicketError::OperationFingerprintMismatch { .. })
|
||||
|
||||
@@ -69,7 +69,7 @@ pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 5] = [
|
||||
pub const TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES: [&str; 2] =
|
||||
["TicketRelationQuery", "TicketOrchestrationPlanQuery"];
|
||||
|
||||
pub const TICKET_TOOL_NAMES: [&str; 19] = [
|
||||
pub const TICKET_TOOL_NAMES: [&str; 20] = [
|
||||
"TicketCreate",
|
||||
"TicketEditItem",
|
||||
"QueryTicket",
|
||||
@@ -79,6 +79,7 @@ pub const TICKET_TOOL_NAMES: [&str; 19] = [
|
||||
"TicketDecision",
|
||||
"TicketImplementationReport",
|
||||
"TicketMarkReady",
|
||||
"TicketIntakeReady",
|
||||
"TicketQueue",
|
||||
"TicketWorkflowState",
|
||||
"TicketClose",
|
||||
@@ -100,7 +101,7 @@ pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 6] = [
|
||||
"TicketOrchestrationPlanQuery",
|
||||
];
|
||||
|
||||
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 13] = [
|
||||
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 14] = [
|
||||
"TicketCreate",
|
||||
"TicketEditItem",
|
||||
"TicketComment",
|
||||
@@ -108,6 +109,7 @@ pub const TICKET_MUTATING_TOOL_NAMES: [&str; 13] = [
|
||||
"TicketDecision",
|
||||
"TicketImplementationReport",
|
||||
"TicketMarkReady",
|
||||
"TicketIntakeReady",
|
||||
"TicketQueue",
|
||||
"TicketWorkflowState",
|
||||
"TicketClose",
|
||||
@@ -136,6 +138,9 @@ const IMPLEMENTATION_REPORT_DESCRIPTION: &str =
|
||||
const MARK_READY_DESCRIPTION: &str = "Mark a planning Ticket ready through the typed Ticket backend. \
|
||||
The backend atomically validates and normalizes the persisted repository/ref target, records one typed \
|
||||
state_changed event, and transitions planning -> ready. `reason` is optional.";
|
||||
const INTAKE_READY_DESCRIPTION: &str = "Record a bounded intake summary and mark a planning Ticket ready. \
|
||||
The backend applies the same target validation and lock as TicketMarkReady and commits the summary, \
|
||||
state_changed event, effective target, and planning -> ready transition atomically.";
|
||||
const QUEUE_DESCRIPTION: &str = "Queue a ready Ticket for Orchestrator routing through the typed \
|
||||
Ticket backend. The backend performs the gated ready -> queued transition, records queued_by/queued_at, \
|
||||
and rejects unresolved blocking relations.";
|
||||
@@ -176,6 +181,7 @@ fn base_tool_description(name: &str) -> &'static str {
|
||||
"TicketDecision" => DECISION_DESCRIPTION,
|
||||
"TicketImplementationReport" => IMPLEMENTATION_REPORT_DESCRIPTION,
|
||||
"TicketMarkReady" => MARK_READY_DESCRIPTION,
|
||||
"TicketIntakeReady" => INTAKE_READY_DESCRIPTION,
|
||||
"TicketQueue" => QUEUE_DESCRIPTION,
|
||||
"TicketWorkflowState" => WORKFLOW_STATE_DESCRIPTION,
|
||||
"TicketClose" => CLOSE_DESCRIPTION,
|
||||
@@ -563,6 +569,17 @@ struct TicketMarkReadyParams {
|
||||
reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketIntakeReadyParams {
|
||||
/// Ticket id.
|
||||
ticket: String,
|
||||
/// Concise bounded intake summary appended before the ready transition.
|
||||
intake_summary: String,
|
||||
/// Optional reason attached to the state_changed event.
|
||||
#[serde(default)]
|
||||
reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketQueueParams {
|
||||
/// Ticket id.
|
||||
@@ -832,6 +849,11 @@ struct TicketMarkReadyTool {
|
||||
backend: TicketToolBackend,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TicketIntakeReadyTool {
|
||||
backend: TicketToolBackend,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TicketQueueTool {
|
||||
backend: TicketToolBackend,
|
||||
@@ -1138,6 +1160,7 @@ impl Tool for TicketMarkReadyTool {
|
||||
operation_key: format!("ticket-mark-ready:{}", ctx.call_id),
|
||||
reason: params.reason,
|
||||
author: None,
|
||||
intake_summary: None,
|
||||
},
|
||||
)
|
||||
.map_err(|error| backend_error("TicketMarkReady", error))?;
|
||||
@@ -1154,6 +1177,39 @@ impl Tool for TicketMarkReadyTool {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TicketIntakeReadyTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
ctx: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: TicketIntakeReadyParams = parse_input("TicketIntakeReady", input_json)?;
|
||||
let ticket = self
|
||||
.backend
|
||||
.mark_ready(
|
||||
TicketIdOrSlug::Query(params.ticket.clone()),
|
||||
TicketMarkReady {
|
||||
operation_key: format!("ticket-intake-ready:{}", ctx.call_id),
|
||||
reason: params.reason,
|
||||
author: None,
|
||||
intake_summary: Some(TicketIntakeSummary::new(params.intake_summary)),
|
||||
},
|
||||
)
|
||||
.map_err(|error| backend_error("TicketIntakeReady", error))?;
|
||||
Ok(json_output(
|
||||
format!("Marked ticket {} state ready after intake", params.ticket),
|
||||
json!({
|
||||
"ticket": ticket.meta.id,
|
||||
"state": ticket.meta.workflow_state.as_str(),
|
||||
"repository_id": ticket.meta.repository_id,
|
||||
"ref_selector": ticket.meta.ref_selector,
|
||||
"ok": true
|
||||
}),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TicketQueueTool {
|
||||
async fn execute(
|
||||
@@ -1728,6 +1784,7 @@ fn input_schema(name: &str) -> Value {
|
||||
serde_json::to_value(schemars::schema_for!(TicketThreadEventParams))
|
||||
}
|
||||
"TicketMarkReady" => serde_json::to_value(schemars::schema_for!(TicketMarkReadyParams)),
|
||||
"TicketIntakeReady" => serde_json::to_value(schemars::schema_for!(TicketIntakeReadyParams)),
|
||||
"TicketQueue" => serde_json::to_value(schemars::schema_for!(TicketQueueParams)),
|
||||
"TicketWorkflowState" => {
|
||||
serde_json::to_value(schemars::schema_for!(TicketWorkflowStateParams))
|
||||
@@ -1776,6 +1833,7 @@ impl_from_backend!(TicketPlanTool);
|
||||
impl_from_backend!(TicketDecisionTool);
|
||||
impl_from_backend!(TicketImplementationReportTool);
|
||||
impl_from_backend!(TicketMarkReadyTool);
|
||||
impl_from_backend!(TicketIntakeReadyTool);
|
||||
impl_from_backend!(TicketQueueTool);
|
||||
impl_from_backend!(TicketWorkflowStateTool);
|
||||
impl_from_backend!(TicketCloseTool);
|
||||
@@ -1803,6 +1861,7 @@ pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition
|
||||
backend.clone(),
|
||||
),
|
||||
tool_definition::<TicketMarkReadyTool>("TicketMarkReady", backend.clone()),
|
||||
tool_definition::<TicketIntakeReadyTool>("TicketIntakeReady", backend.clone()),
|
||||
tool_definition::<TicketQueueTool>("TicketQueue", backend.clone()),
|
||||
tool_definition::<TicketWorkflowStateTool>("TicketWorkflowState", backend.clone()),
|
||||
tool_definition::<TicketCloseTool>("TicketClose", backend.clone()),
|
||||
@@ -1897,6 +1956,7 @@ mod tests {
|
||||
"TicketDecision",
|
||||
"TicketImplementationReport",
|
||||
"TicketMarkReady",
|
||||
"TicketIntakeReady",
|
||||
"TicketQueue",
|
||||
"TicketWorkflowState",
|
||||
"TicketClose",
|
||||
@@ -2558,6 +2618,38 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_intake_ready_records_summary_with_validated_target() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let backend = backend(&temp);
|
||||
let mut input = NewTicket::new("Intake Workflow");
|
||||
input.repository_id = Some("main".to_owned());
|
||||
let created = backend.create(input).unwrap();
|
||||
tool_by_name(backend.clone(), "TicketIntakeReady")
|
||||
.execute(
|
||||
&json!({
|
||||
"ticket": created.id.clone(),
|
||||
"intake_summary": "Requirements and target are accepted.",
|
||||
"reason": "intake_complete"
|
||||
})
|
||||
.to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap();
|
||||
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Ready);
|
||||
assert_eq!(record.meta.ref_selector.as_deref(), Some("develop"));
|
||||
assert_eq!(
|
||||
record
|
||||
.events
|
||||
.iter()
|
||||
.filter(|event| event.kind == TicketEventKind::IntakeSummary)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_workflow_tool_allows_return_to_planning_from_ready_and_queued() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user