fix: preserve intake and companion ticket workflows
This commit is contained in:
@@ -529,6 +529,8 @@ pub struct TicketMarkReady {
|
|||||||
pub reason: Option<String>,
|
pub reason: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub author: Option<String>,
|
pub author: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub intake_summary: Option<TicketIntakeSummary>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TicketTargetEdit {
|
impl TicketTargetEdit {
|
||||||
@@ -590,6 +592,16 @@ fn mark_ready_fingerprint(
|
|||||||
if let Some(reason) = request.reason.as_deref() {
|
if let Some(reason) = request.reason.as_deref() {
|
||||||
digest.update(reason.as_bytes());
|
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
|
digest
|
||||||
.finalize()
|
.finalize()
|
||||||
.iter()
|
.iter()
|
||||||
@@ -3598,6 +3610,28 @@ impl TicketBackend for SqliteTicketBackend {
|
|||||||
.unwrap_or("implementation target validated")
|
.unwrap_or("implementation target validated")
|
||||||
.to_owned();
|
.to_owned();
|
||||||
let at = now_utc();
|
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(
|
self.insert_event(
|
||||||
conn,
|
conn,
|
||||||
&ticket_id,
|
&ticket_id,
|
||||||
@@ -4252,7 +4286,11 @@ impl TicketBackend for LocalTicketBackend {
|
|||||||
target.repository_id, target.ref_selector
|
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(
|
self.append_state_changed_event_with_attributes(
|
||||||
&dir,
|
&dir,
|
||||||
&change,
|
&change,
|
||||||
@@ -6960,6 +6998,7 @@ state: planning
|
|||||||
operation_key: "sqlite-ready".to_owned(),
|
operation_key: "sqlite-ready".to_owned(),
|
||||||
reason: Some("target accepted".to_owned()),
|
reason: Some("target accepted".to_owned()),
|
||||||
author: Some("test".to_owned()),
|
author: Some("test".to_owned()),
|
||||||
|
intake_summary: None,
|
||||||
};
|
};
|
||||||
let ready = backend
|
let ready = backend
|
||||||
.mark_ready(
|
.mark_ready(
|
||||||
@@ -7377,6 +7416,7 @@ state: planning
|
|||||||
operation_key: "test-flow-ready".to_owned(),
|
operation_key: "test-flow-ready".to_owned(),
|
||||||
reason: Some("ready_for_queue".to_owned()),
|
reason: Some("ready_for_queue".to_owned()),
|
||||||
author: Some("test".to_owned()),
|
author: Some("test".to_owned()),
|
||||||
|
intake_summary: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -7648,6 +7688,7 @@ state: planning
|
|||||||
operation_key: "ready-op-1".to_owned(),
|
operation_key: "ready-op-1".to_owned(),
|
||||||
reason: Some("accepted".to_owned()),
|
reason: Some("accepted".to_owned()),
|
||||||
author: Some("intake".to_owned()),
|
author: Some("intake".to_owned()),
|
||||||
|
intake_summary: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let first = backend
|
let first = backend
|
||||||
@@ -7679,6 +7720,7 @@ state: planning
|
|||||||
operation_key: "ready-op-1".to_owned(),
|
operation_key: "ready-op-1".to_owned(),
|
||||||
reason: Some("different".to_owned()),
|
reason: Some("different".to_owned()),
|
||||||
author: Some("intake".to_owned()),
|
author: Some("intake".to_owned()),
|
||||||
|
intake_summary: None,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
Err(TicketError::OperationFingerprintMismatch { .. })
|
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] =
|
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",
|
||||||
@@ -79,6 +79,7 @@ pub const TICKET_TOOL_NAMES: [&str; 19] = [
|
|||||||
"TicketDecision",
|
"TicketDecision",
|
||||||
"TicketImplementationReport",
|
"TicketImplementationReport",
|
||||||
"TicketMarkReady",
|
"TicketMarkReady",
|
||||||
|
"TicketIntakeReady",
|
||||||
"TicketQueue",
|
"TicketQueue",
|
||||||
"TicketWorkflowState",
|
"TicketWorkflowState",
|
||||||
"TicketClose",
|
"TicketClose",
|
||||||
@@ -100,7 +101,7 @@ 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",
|
||||||
@@ -108,6 +109,7 @@ pub const TICKET_MUTATING_TOOL_NAMES: [&str; 13] = [
|
|||||||
"TicketDecision",
|
"TicketDecision",
|
||||||
"TicketImplementationReport",
|
"TicketImplementationReport",
|
||||||
"TicketMarkReady",
|
"TicketMarkReady",
|
||||||
|
"TicketIntakeReady",
|
||||||
"TicketQueue",
|
"TicketQueue",
|
||||||
"TicketWorkflowState",
|
"TicketWorkflowState",
|
||||||
"TicketClose",
|
"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. \
|
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 \
|
The backend atomically validates and normalizes the persisted repository/ref target, records one typed \
|
||||||
state_changed event, and transitions planning -> ready. `reason` is optional.";
|
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.";
|
||||||
@@ -176,6 +181,7 @@ fn base_tool_description(name: &str) -> &'static str {
|
|||||||
"TicketDecision" => DECISION_DESCRIPTION,
|
"TicketDecision" => DECISION_DESCRIPTION,
|
||||||
"TicketImplementationReport" => IMPLEMENTATION_REPORT_DESCRIPTION,
|
"TicketImplementationReport" => IMPLEMENTATION_REPORT_DESCRIPTION,
|
||||||
"TicketMarkReady" => MARK_READY_DESCRIPTION,
|
"TicketMarkReady" => MARK_READY_DESCRIPTION,
|
||||||
|
"TicketIntakeReady" => INTAKE_READY_DESCRIPTION,
|
||||||
"TicketQueue" => QUEUE_DESCRIPTION,
|
"TicketQueue" => QUEUE_DESCRIPTION,
|
||||||
"TicketWorkflowState" => WORKFLOW_STATE_DESCRIPTION,
|
"TicketWorkflowState" => WORKFLOW_STATE_DESCRIPTION,
|
||||||
"TicketClose" => CLOSE_DESCRIPTION,
|
"TicketClose" => CLOSE_DESCRIPTION,
|
||||||
@@ -563,6 +569,17 @@ struct TicketMarkReadyParams {
|
|||||||
reason: Option<String>,
|
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)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
struct TicketQueueParams {
|
struct TicketQueueParams {
|
||||||
/// Ticket id.
|
/// Ticket id.
|
||||||
@@ -832,6 +849,11 @@ struct TicketMarkReadyTool {
|
|||||||
backend: TicketToolBackend,
|
backend: TicketToolBackend,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct TicketIntakeReadyTool {
|
||||||
|
backend: TicketToolBackend,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct TicketQueueTool {
|
struct TicketQueueTool {
|
||||||
backend: TicketToolBackend,
|
backend: TicketToolBackend,
|
||||||
@@ -1138,6 +1160,7 @@ impl Tool for TicketMarkReadyTool {
|
|||||||
operation_key: format!("ticket-mark-ready:{}", ctx.call_id),
|
operation_key: format!("ticket-mark-ready:{}", ctx.call_id),
|
||||||
reason: params.reason,
|
reason: params.reason,
|
||||||
author: None,
|
author: None,
|
||||||
|
intake_summary: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.map_err(|error| backend_error("TicketMarkReady", error))?;
|
.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]
|
#[async_trait]
|
||||||
impl Tool for TicketQueueTool {
|
impl Tool for TicketQueueTool {
|
||||||
async fn execute(
|
async fn execute(
|
||||||
@@ -1728,6 +1784,7 @@ fn input_schema(name: &str) -> Value {
|
|||||||
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)),
|
"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)),
|
"TicketQueue" => serde_json::to_value(schemars::schema_for!(TicketQueueParams)),
|
||||||
"TicketWorkflowState" => {
|
"TicketWorkflowState" => {
|
||||||
serde_json::to_value(schemars::schema_for!(TicketWorkflowStateParams))
|
serde_json::to_value(schemars::schema_for!(TicketWorkflowStateParams))
|
||||||
@@ -1776,6 +1833,7 @@ 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!(TicketMarkReadyTool);
|
||||||
|
impl_from_backend!(TicketIntakeReadyTool);
|
||||||
impl_from_backend!(TicketQueueTool);
|
impl_from_backend!(TicketQueueTool);
|
||||||
impl_from_backend!(TicketWorkflowStateTool);
|
impl_from_backend!(TicketWorkflowStateTool);
|
||||||
impl_from_backend!(TicketCloseTool);
|
impl_from_backend!(TicketCloseTool);
|
||||||
@@ -1803,6 +1861,7 @@ pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition
|
|||||||
backend.clone(),
|
backend.clone(),
|
||||||
),
|
),
|
||||||
tool_definition::<TicketMarkReadyTool>("TicketMarkReady", backend.clone()),
|
tool_definition::<TicketMarkReadyTool>("TicketMarkReady", 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()),
|
||||||
tool_definition::<TicketCloseTool>("TicketClose", backend.clone()),
|
tool_definition::<TicketCloseTool>("TicketClose", backend.clone()),
|
||||||
@@ -1897,6 +1956,7 @@ mod tests {
|
|||||||
"TicketDecision",
|
"TicketDecision",
|
||||||
"TicketImplementationReport",
|
"TicketImplementationReport",
|
||||||
"TicketMarkReady",
|
"TicketMarkReady",
|
||||||
|
"TicketIntakeReady",
|
||||||
"TicketQueue",
|
"TicketQueue",
|
||||||
"TicketWorkflowState",
|
"TicketWorkflowState",
|
||||||
"TicketClose",
|
"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]
|
#[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();
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use crate::runtime::dir::RuntimeDir;
|
|||||||
use crate::segment_log_sink::SegmentLogSink;
|
use crate::segment_log_sink::SegmentLogSink;
|
||||||
use crate::shared_state::WorkerSharedState;
|
use crate::shared_state::WorkerSharedState;
|
||||||
use crate::shutdown_after_idle::{
|
use crate::shutdown_after_idle::{
|
||||||
ShutdownAfterIdleRequest, TicketMarkReadyShutdownHook, is_ticket_intake_role,
|
ShutdownAfterIdleRequest, TicketIntakeReadyShutdownHook, is_ticket_intake_role,
|
||||||
take_shutdown_request_after_status,
|
take_shutdown_request_after_status,
|
||||||
};
|
};
|
||||||
use crate::spawn::registry::SpawnedWorkerRegistry;
|
use crate::spawn::registry::SpawnedWorkerRegistry;
|
||||||
@@ -423,11 +423,11 @@ impl WorkerController {
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Intake role Workers self-terminate only after a successful
|
// Intake role Workers self-terminate only after a successful
|
||||||
// TicketMarkReady turn has fully settled back to Idle. The request
|
// TicketIntakeReady turn has fully settled back to Idle. The request
|
||||||
// is transient controller state, not model-visible context or ticket
|
// is transient controller state, not model-visible context or ticket
|
||||||
// claim metadata.
|
// claim metadata.
|
||||||
let shutdown_after_idle = ShutdownAfterIdleRequest::default();
|
let shutdown_after_idle = ShutdownAfterIdleRequest::default();
|
||||||
worker.add_post_tool_call_hook(TicketMarkReadyShutdownHook::new(
|
worker.add_post_tool_call_hook(TicketIntakeReadyShutdownHook::new(
|
||||||
shutdown_after_idle.clone(),
|
shutdown_after_idle.clone(),
|
||||||
is_ticket_intake_role(worker.runtime_ticket_role()),
|
is_ticket_intake_role(worker.runtime_ticket_role()),
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -381,6 +381,8 @@ const READ_ONLY_TOOL_NAMES: &[&str] = &["QueryTicket", "ShowTicket"];
|
|||||||
const AUTHORING_TOOL_NAMES: &[&str] = &[
|
const AUTHORING_TOOL_NAMES: &[&str] = &[
|
||||||
"TicketCreate",
|
"TicketCreate",
|
||||||
"TicketEditItem",
|
"TicketEditItem",
|
||||||
|
"TicketMarkReady",
|
||||||
|
"TicketQueue",
|
||||||
"TicketClose",
|
"TicketClose",
|
||||||
"TicketRelationRecord",
|
"TicketRelationRecord",
|
||||||
"TicketRelationRemove",
|
"TicketRelationRemove",
|
||||||
@@ -388,7 +390,7 @@ const AUTHORING_TOOL_NAMES: &[&str] = &[
|
|||||||
|
|
||||||
const THREAD_TOOL_NAMES: &[&str] = &["TicketComment"];
|
const THREAD_TOOL_NAMES: &[&str] = &["TicketComment"];
|
||||||
|
|
||||||
const INTAKE_TOOL_NAMES: &[&str] = &["TicketMarkReady"];
|
const INTAKE_TOOL_NAMES: &[&str] = &["TicketIntakeReady"];
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
|
const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
|
||||||
@@ -397,6 +399,8 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
|
|||||||
"QueryTicket",
|
"QueryTicket",
|
||||||
"ShowTicket",
|
"ShowTicket",
|
||||||
"TicketComment",
|
"TicketComment",
|
||||||
|
"TicketMarkReady",
|
||||||
|
"TicketQueue",
|
||||||
"TicketClose",
|
"TicketClose",
|
||||||
"TicketRelationRecord",
|
"TicketRelationRecord",
|
||||||
"TicketRelationRemove",
|
"TicketRelationRemove",
|
||||||
@@ -407,7 +411,6 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[
|
|||||||
"QueryTicket",
|
"QueryTicket",
|
||||||
"ShowTicket",
|
"ShowTicket",
|
||||||
"TicketComment",
|
"TicketComment",
|
||||||
"TicketQueue",
|
|
||||||
"TicketWorkflowState",
|
"TicketWorkflowState",
|
||||||
"TicketClose",
|
"TicketClose",
|
||||||
"TicketDependencyCheck",
|
"TicketDependencyCheck",
|
||||||
@@ -418,7 +421,6 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[
|
|||||||
];
|
];
|
||||||
|
|
||||||
const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[
|
const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[
|
||||||
"TicketQueue",
|
|
||||||
"TicketWorkflowState",
|
"TicketWorkflowState",
|
||||||
"TicketClose",
|
"TicketClose",
|
||||||
"TicketDependencyCheck",
|
"TicketDependencyCheck",
|
||||||
@@ -1295,13 +1297,13 @@ 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(), 8);
|
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"
|
||||||
);
|
);
|
||||||
let workflow_names = TicketFeatureAccess::workflow().tool_names();
|
let workflow_names = TicketFeatureAccess::workflow().tool_names();
|
||||||
assert_eq!(workflow_names.len(), 11);
|
assert_eq!(workflow_names.len(), 10);
|
||||||
assert!(
|
assert!(
|
||||||
workflow_names.len() < 12,
|
workflow_names.len() < 12,
|
||||||
"workflow catalog must stay below the prior broad catalog"
|
"workflow catalog must stay below the prior broad catalog"
|
||||||
@@ -1382,7 +1384,7 @@ mod tests {
|
|||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
assert!(workspace_tools.contains(&"TicketCreate"));
|
assert!(workspace_tools.contains(&"TicketCreate"));
|
||||||
assert!(workspace_tools.contains(&"TicketEditItem"));
|
assert!(workspace_tools.contains(&"TicketEditItem"));
|
||||||
assert!(!workspace_tools.contains(&"TicketQueue"));
|
assert!(workspace_tools.contains(&"TicketQueue"));
|
||||||
assert!(!workspace_tools.contains(&"TicketWorkflowState"));
|
assert!(!workspace_tools.contains(&"TicketWorkflowState"));
|
||||||
|
|
||||||
let orchestration =
|
let orchestration =
|
||||||
@@ -1398,7 +1400,7 @@ mod tests {
|
|||||||
assert!(orchestration_tools.contains(&"TicketRelationRecord"));
|
assert!(orchestration_tools.contains(&"TicketRelationRecord"));
|
||||||
assert!(orchestration_tools.contains(&"TicketOrchestrationPlanRecord"));
|
assert!(orchestration_tools.contains(&"TicketOrchestrationPlanRecord"));
|
||||||
assert!(!orchestration_tools.contains(&"TicketEditItem"));
|
assert!(!orchestration_tools.contains(&"TicketEditItem"));
|
||||||
assert!(orchestration_tools.contains(&"TicketQueue"));
|
assert!(!orchestration_tools.contains(&"TicketQueue"));
|
||||||
|
|
||||||
let work_report =
|
let work_report =
|
||||||
ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::work_report());
|
ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::work_report());
|
||||||
@@ -1511,8 +1513,9 @@ language = "Japanese"
|
|||||||
assert_eq!(installed, WORKSPACE_AUTHORING_TOOL_NAMES);
|
assert_eq!(installed, WORKSPACE_AUTHORING_TOOL_NAMES);
|
||||||
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 == "TicketMarkReady"));
|
||||||
|
assert!(!installed.iter().any(|tool| *tool == "TicketIntakeReady"));
|
||||||
assert!(!installed.iter().any(|tool| *tool == "TicketWorkflowState"));
|
assert!(!installed.iter().any(|tool| *tool == "TicketWorkflowState"));
|
||||||
assert!(
|
assert!(
|
||||||
!installed
|
!installed
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use ticket::config::TicketRole;
|
|||||||
|
|
||||||
use crate::hook::{Hook, HookPostToolAction, PostToolCall, ToolResultSummary};
|
use crate::hook::{Hook, HookPostToolAction, PostToolCall, ToolResultSummary};
|
||||||
|
|
||||||
const TICKET_MARK_READY_TOOL_NAME: &str = "TicketMarkReady";
|
const TICKET_INTAKE_READY_TOOL_NAME: &str = "TicketIntakeReady";
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
pub(crate) struct ShutdownAfterIdleRequest {
|
pub(crate) struct ShutdownAfterIdleRequest {
|
||||||
@@ -42,12 +42,12 @@ pub(crate) fn take_shutdown_request_after_status(
|
|||||||
status == WorkerStatus::Idle && shutdown_after_idle.take()
|
status == WorkerStatus::Idle && shutdown_after_idle.take()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct TicketMarkReadyShutdownHook {
|
pub(crate) struct TicketIntakeReadyShutdownHook {
|
||||||
shutdown_after_idle: ShutdownAfterIdleRequest,
|
shutdown_after_idle: ShutdownAfterIdleRequest,
|
||||||
eligible_ticket_intake_role: bool,
|
eligible_ticket_intake_role: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TicketMarkReadyShutdownHook {
|
impl TicketIntakeReadyShutdownHook {
|
||||||
pub(crate) fn new(
|
pub(crate) fn new(
|
||||||
shutdown_after_idle: ShutdownAfterIdleRequest,
|
shutdown_after_idle: ShutdownAfterIdleRequest,
|
||||||
eligible_ticket_intake_role: bool,
|
eligible_ticket_intake_role: bool,
|
||||||
@@ -60,7 +60,7 @@ impl TicketMarkReadyShutdownHook {
|
|||||||
|
|
||||||
fn observe_tool_result(&self, info: &ToolResultSummary) {
|
fn observe_tool_result(&self, info: &ToolResultSummary) {
|
||||||
if self.eligible_ticket_intake_role
|
if self.eligible_ticket_intake_role
|
||||||
&& info.tool_name == TICKET_MARK_READY_TOOL_NAME
|
&& info.tool_name == TICKET_INTAKE_READY_TOOL_NAME
|
||||||
&& !info.is_error
|
&& !info.is_error
|
||||||
{
|
{
|
||||||
self.shutdown_after_idle.request();
|
self.shutdown_after_idle.request();
|
||||||
@@ -69,7 +69,7 @@ impl TicketMarkReadyShutdownHook {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Hook<PostToolCall> for TicketMarkReadyShutdownHook {
|
impl Hook<PostToolCall> for TicketIntakeReadyShutdownHook {
|
||||||
async fn call(&self, info: &ToolResultSummary) -> HookPostToolAction {
|
async fn call(&self, info: &ToolResultSummary) -> HookPostToolAction {
|
||||||
self.observe_tool_result(info);
|
self.observe_tool_result(info);
|
||||||
HookPostToolAction::Continue
|
HookPostToolAction::Continue
|
||||||
@@ -98,9 +98,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn successful_ticket_intake_ready_schedules_shutdown_after_idle_for_intake_role() {
|
fn successful_ticket_intake_ready_schedules_shutdown_after_idle_for_intake_role() {
|
||||||
let request = ShutdownAfterIdleRequest::default();
|
let request = ShutdownAfterIdleRequest::default();
|
||||||
let hook = TicketMarkReadyShutdownHook::new(request.clone(), true);
|
let hook = TicketIntakeReadyShutdownHook::new(request.clone(), true);
|
||||||
|
|
||||||
hook.observe_tool_result(&tool_result(TICKET_MARK_READY_TOOL_NAME, false));
|
hook.observe_tool_result(&tool_result(TICKET_INTAKE_READY_TOOL_NAME, false));
|
||||||
|
|
||||||
assert!(request.is_requested());
|
assert!(request.is_requested());
|
||||||
assert!(request.take());
|
assert!(request.take());
|
||||||
@@ -110,9 +110,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn failed_ticket_intake_ready_does_not_schedule_shutdown_after_idle() {
|
fn failed_ticket_intake_ready_does_not_schedule_shutdown_after_idle() {
|
||||||
let request = ShutdownAfterIdleRequest::default();
|
let request = ShutdownAfterIdleRequest::default();
|
||||||
let hook = TicketMarkReadyShutdownHook::new(request.clone(), true);
|
let hook = TicketIntakeReadyShutdownHook::new(request.clone(), true);
|
||||||
|
|
||||||
hook.observe_tool_result(&tool_result(TICKET_MARK_READY_TOOL_NAME, true));
|
hook.observe_tool_result(&tool_result(TICKET_INTAKE_READY_TOOL_NAME, true));
|
||||||
|
|
||||||
assert!(!request.is_requested());
|
assert!(!request.is_requested());
|
||||||
}
|
}
|
||||||
@@ -120,9 +120,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn non_intake_role_does_not_schedule_shutdown_after_idle() {
|
fn non_intake_role_does_not_schedule_shutdown_after_idle() {
|
||||||
let request = ShutdownAfterIdleRequest::default();
|
let request = ShutdownAfterIdleRequest::default();
|
||||||
let hook = TicketMarkReadyShutdownHook::new(request.clone(), false);
|
let hook = TicketIntakeReadyShutdownHook::new(request.clone(), false);
|
||||||
|
|
||||||
hook.observe_tool_result(&tool_result(TICKET_MARK_READY_TOOL_NAME, false));
|
hook.observe_tool_result(&tool_result(TICKET_INTAKE_READY_TOOL_NAME, false));
|
||||||
|
|
||||||
assert!(!request.is_requested());
|
assert!(!request.is_requested());
|
||||||
}
|
}
|
||||||
@@ -130,7 +130,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn other_successful_tools_do_not_schedule_shutdown_after_idle() {
|
fn other_successful_tools_do_not_schedule_shutdown_after_idle() {
|
||||||
let request = ShutdownAfterIdleRequest::default();
|
let request = ShutdownAfterIdleRequest::default();
|
||||||
let hook = TicketMarkReadyShutdownHook::new(request.clone(), true);
|
let hook = TicketIntakeReadyShutdownHook::new(request.clone(), true);
|
||||||
|
|
||||||
hook.observe_tool_result(&tool_result("ShowTicket", false));
|
hook.observe_tool_result(&tool_result("ShowTicket", false));
|
||||||
|
|
||||||
|
|||||||
@@ -3312,6 +3312,7 @@ async fn scoped_mark_ticket_ready_from_browser(
|
|||||||
operation_key: request.operation_key,
|
operation_key: request.operation_key,
|
||||||
reason: request.reason,
|
reason: request.reason,
|
||||||
author: Some("web".to_owned()),
|
author: Some("web".to_owned()),
|
||||||
|
intake_summary: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.map_err(Error::from)?;
|
.map_err(Error::from)?;
|
||||||
@@ -3660,6 +3661,8 @@ struct TicketMarkReadyRequest {
|
|||||||
operation_key: String,
|
operation_key: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
reason: Option<String>,
|
reason: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
intake_summary: Option<ticket::TicketIntakeSummary>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn scoped_set_ticket_state_field(
|
async fn scoped_set_ticket_state_field(
|
||||||
@@ -3717,6 +3720,7 @@ async fn scoped_mark_ticket_ready(
|
|||||||
operation_key: request.operation_key,
|
operation_key: request.operation_key,
|
||||||
reason: request.reason,
|
reason: request.reason,
|
||||||
author: None,
|
author: None,
|
||||||
|
intake_summary: request.intake_summary,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -4538,7 +4542,12 @@ 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::MarkReady { request, .. } => request.author = Some(author),
|
TicketBackendOperation::MarkReady { request, .. } => {
|
||||||
|
request.author = Some(author.clone());
|
||||||
|
if let Some(summary) = request.intake_summary.as_mut() {
|
||||||
|
summary.author = Some(author);
|
||||||
|
}
|
||||||
|
}
|
||||||
TicketBackendOperation::QueueReady { queued_by, .. } => *queued_by = author,
|
TicketBackendOperation::QueueReady { queued_by, .. } => *queued_by = author,
|
||||||
TicketBackendOperation::AddTicketRelation { relation, .. } => {
|
TicketBackendOperation::AddTicketRelation { relation, .. } => {
|
||||||
relation.author = Some(author)
|
relation.author = Some(author)
|
||||||
@@ -13896,6 +13905,7 @@ mod tests {
|
|||||||
operation_key: "ready-server-test".to_owned(),
|
operation_key: "ready-server-test".to_owned(),
|
||||||
reason: Some("target accepted".to_owned()),
|
reason: Some("target accepted".to_owned()),
|
||||||
author: Some("test".to_owned()),
|
author: Some("test".to_owned()),
|
||||||
|
intake_summary: None,
|
||||||
};
|
};
|
||||||
let ready = backend
|
let ready = backend
|
||||||
.mark_ready(TicketIdOrSlug::Id(ticket_ref.id.clone()), request.clone())
|
.mark_ready(TicketIdOrSlug::Id(ticket_ref.id.clone()), request.clone())
|
||||||
@@ -13940,6 +13950,7 @@ mod tests {
|
|||||||
operation_key: "missing-repository".to_owned(),
|
operation_key: "missing-repository".to_owned(),
|
||||||
reason: None,
|
reason: None,
|
||||||
author: None,
|
author: None,
|
||||||
|
intake_summary: None,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
Err(ticket::TicketError::UnknownTargetRepository(_))
|
Err(ticket::TicketError::UnknownTargetRepository(_))
|
||||||
@@ -14072,6 +14083,7 @@ mod tests {
|
|||||||
operation_key: "notification-ready".to_owned(),
|
operation_key: "notification-ready".to_owned(),
|
||||||
reason: Some("ready for implementation".to_owned()),
|
reason: Some("ready for implementation".to_owned()),
|
||||||
author: None,
|
author: None,
|
||||||
|
intake_summary: None,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
TicketBackendOperation::QueueReady {
|
TicketBackendOperation::QueueReady {
|
||||||
@@ -14988,6 +15000,7 @@ mod tests {
|
|||||||
Json(TicketMarkReadyRequest {
|
Json(TicketMarkReadyRequest {
|
||||||
operation_key: "browser-ready".to_owned(),
|
operation_key: "browser-ready".to_owned(),
|
||||||
reason: Some("intake complete".to_owned()),
|
reason: Some("intake complete".to_owned()),
|
||||||
|
intake_summary: None,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
Reference in New Issue
Block a user