use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use tandem_plan_compiler::api as compiler_api;
use uuid::Uuid;
use super::*;
#[allow(unused_imports)]
pub(crate) use compiler_api::planner_model_spec;
#[derive(Debug, Deserialize)]
pub(super) struct WorkflowPlanPreviewRequest {
pub prompt: String,
#[serde(default)]
pub schedule: Option<Value>,
#[serde(default)]
pub plan_source: Option<String>,
#[serde(default)]
pub allowed_mcp_servers: Vec<String>,
#[serde(default)]
pub workspace_root: Option<String>,
#[serde(default)]
pub operator_preferences: Option<Value>,
}
#[derive(Debug, Deserialize, Default)]
pub(super) struct WorkflowPlanApplyRequest {
#[serde(default)]
pub plan_id: Option<String>,
#[serde(default)]
pub plan: Option<crate::WorkflowPlan>,
#[serde(default)]
pub creator_id: Option<String>,
#[serde(default)]
pub pack_builder_export: Option<WorkflowPlanPackBuilderExportRequest>,
#[serde(default)]
pub overlap_decision: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(super) struct WorkflowPlanImportRequest {
pub bundle: compiler_api::PlanPackageImportBundle,
#[serde(default)]
pub creator_id: Option<String>,
}
#[derive(Debug, Deserialize, Clone, Default)]
pub(super) struct WorkflowPlanPackBuilderExportRequest {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub session_id: Option<String>,
#[serde(default)]
pub thread_key: Option<String>,
#[serde(default)]
pub auto_apply: Option<bool>,
}
#[derive(Debug, Deserialize)]
pub(super) struct WorkflowPlanChatStartRequest {
pub prompt: String,
#[serde(default)]
pub schedule: Option<Value>,
#[serde(default)]
pub plan_source: Option<String>,
#[serde(default)]
pub allowed_mcp_servers: Vec<String>,
#[serde(default)]
pub workspace_root: Option<String>,
#[serde(default)]
pub operator_preferences: Option<Value>,
}
#[derive(Debug, Deserialize)]
pub(super) struct WorkflowPlanChatMessageRequest {
pub plan_id: String,
pub message: String,
}
#[derive(Debug, Deserialize)]
pub(super) struct WorkflowPlanChatResetRequest {
pub plan_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowPlannerSessionRecord {
pub session_id: String,
pub project_slug: String,
pub title: String,
pub workspace_root: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_plan_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub draft: Option<crate::WorkflowPlanDraftRecord>,
#[serde(default)]
pub goal: String,
#[serde(default)]
pub notes: String,
#[serde(default)]
pub planner_provider: String,
#[serde(default)]
pub planner_model: String,
#[serde(default)]
pub plan_source: String,
#[serde(default)]
pub allowed_mcp_servers: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub operator_preferences: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub published_at_ms: Option<u64>,
#[serde(default)]
pub published_tasks: Vec<Value>,
pub created_at_ms: u64,
pub updated_at_ms: u64,
}
#[derive(Debug, Deserialize, Default)]
pub(super) struct WorkflowPlannerSessionListQuery {
#[serde(default)]
pub project_slug: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
pub(super) struct WorkflowPlannerSessionCreateRequest {
pub project_slug: String,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub workspace_root: Option<String>,
#[serde(default)]
pub goal: Option<String>,
#[serde(default)]
pub notes: Option<String>,
#[serde(default)]
pub planner_provider: Option<String>,
#[serde(default)]
pub planner_model: Option<String>,
#[serde(default)]
pub plan_source: Option<String>,
#[serde(default)]
pub plan: Option<crate::WorkflowPlan>,
#[serde(default)]
pub conversation: Option<crate::WorkflowPlanConversation>,
#[serde(default)]
pub planner_diagnostics: Option<Value>,
#[serde(default)]
pub plan_revision: Option<u32>,
#[serde(default)]
pub last_success_materialization: Option<Value>,
#[serde(default)]
pub allowed_mcp_servers: Vec<String>,
#[serde(default)]
pub operator_preferences: Option<Value>,
}
#[derive(Debug, Deserialize, Default)]
pub(super) struct WorkflowPlannerSessionPatchRequest {
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub workspace_root: Option<String>,
#[serde(default)]
pub goal: Option<String>,
#[serde(default)]
pub notes: Option<String>,
#[serde(default)]
pub planner_provider: Option<String>,
#[serde(default)]
pub planner_model: Option<String>,
#[serde(default)]
pub plan_source: Option<String>,
#[serde(default)]
pub allowed_mcp_servers: Option<Vec<String>>,
#[serde(default)]
pub operator_preferences: Option<Value>,
#[serde(default)]
pub current_plan_id: Option<String>,
#[serde(default)]
pub draft: Option<crate::WorkflowPlanDraftRecord>,
#[serde(default)]
pub published_at_ms: Option<u64>,
#[serde(default)]
pub published_tasks: Option<Vec<Value>>,
}
#[derive(Debug, Deserialize, Default)]
pub(super) struct WorkflowPlannerSessionStartRequest {
pub prompt: String,
#[serde(default)]
pub schedule: Option<Value>,
#[serde(default)]
pub plan_source: Option<String>,
#[serde(default)]
pub allowed_mcp_servers: Vec<String>,
#[serde(default)]
pub workspace_root: Option<String>,
#[serde(default)]
pub operator_preferences: Option<Value>,
}
#[derive(Debug, Deserialize, Default)]
pub(super) struct WorkflowPlannerSessionMessageRequest {
pub message: String,
}
#[derive(Debug, Deserialize, Default)]
pub(super) struct WorkflowPlannerSessionDuplicateRequest {
#[serde(default)]
pub title: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(super) struct WorkflowPlannerSessionListItem {
pub session_id: String,
pub title: String,
pub project_slug: String,
pub workspace_root: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_plan_id: Option<String>,
pub created_at_ms: u64,
pub updated_at_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub goal: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub planner_provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub planner_model: Option<String>,
}
fn workflow_plan_import_summary(plan_package: &compiler_api::PlanPackage) -> serde_json::Value {
json!({
"plan_id": plan_package.plan_id,
"plan_revision": plan_package.plan_revision,
"routine_count": plan_package.routine_graph.len(),
"context_object_count": plan_package.context_objects.len(),
"credential_envelope_count": plan_package.credential_envelopes.len(),
})
}
async fn compile_preview_plan_overlap(
state: &AppState,
plan_package: &compiler_api::PlanPackage,
) -> compiler_api::OverlapComparison {
let prior_plans = state
.list_automations_v2()
.await
.into_iter()
.filter_map(|automation| {
automation
.metadata
.as_ref()
.and_then(|metadata| {
metadata
.get("plan_package")
.or_else(|| metadata.get("planPackage"))
})
.cloned()
})
.filter_map(|value| serde_json::from_value::<compiler_api::PlanPackage>(value).ok())
.filter(|prior| prior.plan_id != plan_package.plan_id)
.collect::<Vec<_>>();
compiler_api::analyze_plan_overlap(plan_package, &prior_plans)
}
fn parse_overlap_decision(
decision: Option<&str>,
) -> Result<Option<compiler_api::OverlapDecision>, String> {
let Some(decision) = decision.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
match decision.to_ascii_lowercase().as_str() {
"reuse" => Ok(Some(compiler_api::OverlapDecision::Reuse)),
"merge" => Ok(Some(compiler_api::OverlapDecision::Merge)),
"fork" => Ok(Some(compiler_api::OverlapDecision::Fork)),
"new" => Ok(Some(compiler_api::OverlapDecision::New)),
_ => Err(format!("unsupported overlap decision `{decision}`")),
}
}
fn teaching_library_summary() -> Value {
serde_json::to_value(compiler_api::planner_teaching_library_summary())
.unwrap_or_else(|_| json!({}))
}
fn planner_session_default_title(goal: &str, fallback_time_ms: u64) -> String {
let goal = goal.trim();
if !goal.is_empty() {
return if goal.chars().count() > 48 {
let mut clipped = goal.chars().take(47).collect::<String>();
clipped.push('…');
clipped
} else {
goal.to_string()
};
}
format!(
"Plan {}",
chrono::DateTime::<chrono::Utc>::from_timestamp_millis(fallback_time_ms as i64)
.map(|value| value.format("%-H:%M:%S").to_string())
.unwrap_or_else(|| "now".to_string())
)
}
fn workflow_planner_session_list_item(
session: &WorkflowPlannerSessionRecord,
) -> WorkflowPlannerSessionListItem {
WorkflowPlannerSessionListItem {
session_id: session.session_id.clone(),
title: session.title.clone(),
project_slug: session.project_slug.clone(),
workspace_root: session.workspace_root.clone(),
current_plan_id: session.current_plan_id.clone(),
created_at_ms: session.created_at_ms,
updated_at_ms: session.updated_at_ms,
goal: if session.goal.trim().is_empty() {
None
} else {
Some(session.goal.clone())
},
planner_provider: if session.planner_provider.trim().is_empty() {
None
} else {
Some(session.planner_provider.clone())
},
planner_model: if session.planner_model.trim().is_empty() {
None
} else {
Some(session.planner_model.clone())
},
}
}
fn retag_workflow_plan_draft(
draft: &crate::WorkflowPlanDraftRecord,
new_plan_id: &str,
) -> Result<crate::WorkflowPlanDraftRecord, String> {
let mut value = serde_json::to_value(draft).map_err(|error| error.to_string())?;
if let Some(object) = value.as_object_mut() {
if let Some(plan) = object
.get_mut("current_plan")
.and_then(Value::as_object_mut)
{
plan.insert(
"plan_id".to_string(),
Value::String(new_plan_id.to_string()),
);
}
if let Some(plan) = object
.get_mut("initial_plan")
.and_then(Value::as_object_mut)
{
plan.insert(
"plan_id".to_string(),
Value::String(new_plan_id.to_string()),
);
}
if let Some(conversation) = object
.get_mut("conversation")
.and_then(Value::as_object_mut)
{
conversation.insert(
"plan_id".to_string(),
Value::String(new_plan_id.to_string()),
);
if let Some(conversation_id) = conversation.get_mut("conversation_id") {
*conversation_id = Value::String(format!("wfchat-{}", Uuid::new_v4()));
}
if let Some(messages) = conversation
.get_mut("messages")
.and_then(Value::as_array_mut)
{
for message in messages {
if let Some(message_obj) = message.as_object_mut() {
if let Some(role) = message_obj.get("role").and_then(Value::as_str) {
if role.is_empty() {
continue;
}
}
}
}
}
}
}
serde_json::from_value(value).map_err(|error| error.to_string())
}
pub(super) async fn workflow_plan_preview(
State(state): State<AppState>,
Json(input): Json<WorkflowPlanPreviewRequest>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let prompt = input.prompt.trim();
if prompt.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({
"error": "prompt is required",
"code": "WORKFLOW_PLAN_INVALID",
})),
));
}
if let Some(workspace_root) = input.workspace_root.as_deref() {
crate::normalize_absolute_workspace_root(workspace_root).map_err(|error| {
(
StatusCode::BAD_REQUEST,
Json(json!({
"error": error,
"code": "WORKFLOW_PLAN_INVALID",
})),
)
})?;
}
let build = workflow_planner_host::build_workflow_plan(
&state,
prompt,
input.schedule.as_ref(),
input.plan_source.as_deref().unwrap_or("unknown"),
input.allowed_mcp_servers,
input.workspace_root.as_deref(),
input.operator_preferences,
)
.await
.map_err(|error| {
(
StatusCode::BAD_REQUEST,
Json(json!({
"error": error,
"code": "WORKFLOW_PLAN_INVALID",
})),
)
})?;
let plan = build.plan;
let planner_diagnostics = build.planner_diagnostics.clone();
let teaching_library = teaching_library_summary();
let plan_json = compiler_api::workflow_plan_to_json(&plan).map_err(|error| {
(
StatusCode::BAD_REQUEST,
Json(json!({
"error": error,
"code": "WORKFLOW_PLAN_INVALID",
})),
)
})?;
let plan_package = compiler_api::compile_workflow_plan_preview_package_with_revision(
&plan_json,
Some("workflow_planner"),
1,
);
let plan_package_validation = compiler_api::validate_plan_package(&plan_package);
let overlap_analysis = compile_preview_plan_overlap(&state, &plan_package).await;
let plan_package_bundle = compiler_api::export_plan_package_bundle(&plan_package);
let host = workflow_planner_host::WorkflowPlannerHost { state: &state };
compiler_api::store_preview_draft::<
crate::routines::types::RoutineMisfirePolicy,
crate::AutomationFlowInputRef,
crate::AutomationFlowOutputContract,
_,
>(&host, plan.clone(), planner_diagnostics.clone())
.await
.map_err(|error| {
(
StatusCode::BAD_REQUEST,
Json(json!({
"error": format!("{error:?}"),
"code": "WORKFLOW_PLAN_INVALID",
})),
)
})?;
Ok(Json(json!({
"plan": plan,
"plan_package": plan_package,
"plan_package_bundle": plan_package_bundle,
"plan_package_validation": plan_package_validation,
"overlap_analysis": overlap_analysis,
"teaching_library": teaching_library,
"clarifier": build.clarifier,
"planner_diagnostics": planner_diagnostics,
"assistant_message": build.assistant_text.map(|text| json!({
"role": "assistant",
"text": text,
})),
})))
}
pub(super) async fn workflow_plan_chat_start(
State(state): State<AppState>,
Json(input): Json<WorkflowPlanChatStartRequest>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let prompt = input.prompt.trim();
if prompt.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({
"error": "prompt is required",
"code": "WORKFLOW_PLAN_INVALID",
})),
));
}
if let Some(workspace_root) = input.workspace_root.as_deref() {
crate::normalize_absolute_workspace_root(workspace_root).map_err(|error| {
(
StatusCode::BAD_REQUEST,
Json(json!({
"error": error,
"code": "WORKFLOW_PLAN_INVALID",
})),
)
})?;
}
let build = workflow_planner_host::build_workflow_plan(
&state,
prompt,
input.schedule.as_ref(),
input.plan_source.as_deref().unwrap_or("unknown"),
input.allowed_mcp_servers,
input.workspace_root.as_deref(),
input.operator_preferences,
)
.await
.map_err(|error| {
(
StatusCode::BAD_REQUEST,
Json(json!({
"error": error,
"code": "WORKFLOW_PLAN_INVALID",
})),
)
})?;
let plan = build.plan;
let host = workflow_planner_host::WorkflowPlannerHost { state: &state };
let draft = compiler_api::store_chat_start_draft::<
crate::routines::types::RoutineMisfirePolicy,
crate::AutomationFlowInputRef,
crate::AutomationFlowOutputContract,
_,
>(
&host,
plan.clone(),
build.planner_diagnostics.clone(),
build.assistant_text.clone(),
)
.await
.map_err(|error| {
(
StatusCode::BAD_REQUEST,
Json(json!({
"error": format!("{error:?}"),
"code": "WORKFLOW_PLAN_INVALID",
})),
)
})?;
let plan_json = compiler_api::workflow_plan_to_json(&draft.current_plan).map_err(|error| {
(
StatusCode::BAD_REQUEST,
Json(json!({
"error": error,
"code": "WORKFLOW_PLAN_INVALID",
})),
)
})?;
let plan_package = compiler_api::compile_workflow_plan_preview_package_with_revision(
&plan_json,
Some("workflow_planner"),
draft.plan_revision,
);
let plan_package_validation = compiler_api::validate_plan_package(&plan_package);
let overlap_analysis = compile_preview_plan_overlap(&state, &plan_package).await;
let plan_package_bundle = compiler_api::export_plan_package_bundle(&plan_package);
let teaching_library = teaching_library_summary();
Ok(Json(json!({
"plan": draft.current_plan,
"plan_package": plan_package,
"plan_package_bundle": plan_package_bundle,
"plan_package_validation": plan_package_validation,
"overlap_analysis": overlap_analysis,
"teaching_library": teaching_library,
"conversation": draft.conversation,
"planner_diagnostics": draft.planner_diagnostics,
"clarifier": build.clarifier,
"assistant_message": build.assistant_text.map(|text| json!({
"role": "assistant",
"text": text,
})),
})))
}
pub(super) async fn workflow_plan_get(
State(state): State<AppState>,
axum::extract::Path(plan_id): axum::extract::Path<String>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let host = workflow_planner_host::WorkflowPlannerHost { state: &state };
let draft = compiler_api::load_workflow_plan_draft::<
crate::routines::types::RoutineMisfirePolicy,
crate::AutomationFlowInputRef,
crate::AutomationFlowOutputContract,
_,
>(&host, &plan_id)
.await
.map_err(|error| match error {
compiler_api::PlannerDraftError::NotFound => (
StatusCode::NOT_FOUND,
Json(compiler_api::draft_not_found_response(&plan_id)),
),
compiler_api::PlannerDraftError::InvalidState(error)
| compiler_api::PlannerDraftError::Store(error) => (
StatusCode::BAD_REQUEST,
Json(json!({
"error": error,
"code": "WORKFLOW_PLAN_INVALID",
})),
),
})?;
let plan_json = compiler_api::workflow_plan_to_json(&draft.current_plan).map_err(|error| {
(
StatusCode::BAD_REQUEST,
Json(json!({
"error": error,
"code": "WORKFLOW_PLAN_INVALID",
})),
)
})?;
let plan_package = compiler_api::compile_workflow_plan_preview_package_with_revision(
&plan_json,
Some("workflow_planner"),
draft.plan_revision,
);
let plan_package_validation = compiler_api::validate_plan_package(&plan_package);
let plan_package_bundle = compiler_api::export_plan_package_bundle(&plan_package);
let overlap_analysis = compile_preview_plan_overlap(&state, &plan_package).await;
let initial_plan_json =
compiler_api::workflow_plan_to_json(&draft.initial_plan).map_err(|error| {
(
StatusCode::BAD_REQUEST,
Json(json!({
"error": error,
"code": "WORKFLOW_PLAN_INVALID",
})),
)
})?;
let plan_package_replay = compiler_api::compare_workflow_plan_preview_replay_with_revision(
&plan_json,
draft.plan_revision,
&initial_plan_json,
1,
);
let teaching_library = teaching_library_summary();
Ok(Json(json!({
"plan": draft.current_plan,
"plan_package": plan_package,
"plan_package_bundle": plan_package_bundle,
"plan_package_validation": plan_package_validation,
"overlap_analysis": overlap_analysis,
"plan_package_replay": plan_package_replay,
"teaching_library": teaching_library,
"conversation": draft.conversation,
"planner_diagnostics": draft.planner_diagnostics,
})))
}
pub(super) async fn workflow_plan_chat_message(
State(state): State<AppState>,
Json(input): Json<WorkflowPlanChatMessageRequest>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let plan_id = input.plan_id.trim();
let message = input.message.trim();
if plan_id.is_empty() || message.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({
"error": "plan_id and message are required",
"code": "WORKFLOW_PLAN_INVALID",
})),
));
}
let host = workflow_planner_host::WorkflowPlannerHost { state: &state };
let revision = compiler_api::revise_workflow_plan_draft::<
crate::routines::types::RoutineMisfirePolicy,
crate::AutomationFlowInputRef,
crate::AutomationFlowOutputContract,
_,
>(
&host,
plan_id,
message,
compiler_api::PlannerLoopConfig {
session_title: "Workflow Planner Revision".to_string(),
timeout_ms: super::workflow_planner_policy::planner_revision_timeout_ms(),
override_env: "TANDEM_WORKFLOW_PLANNER_TEST_REVISION_RESPONSE".to_string(),
},
workflow_planner_host::normalize_workflow_step_metadata,
)
.await
.map_err(|error| match error {
compiler_api::PlannerDraftError::NotFound => (
StatusCode::NOT_FOUND,
Json(compiler_api::draft_not_found_response(plan_id)),
),
compiler_api::PlannerDraftError::InvalidState(error)
| compiler_api::PlannerDraftError::Store(error) => (
StatusCode::BAD_REQUEST,
Json(json!({
"error": error,
"code": "WORKFLOW_PLAN_INVALID",
})),
),
})?;
let plan_json =
compiler_api::workflow_plan_to_json(&revision.draft.current_plan).map_err(|error| {
(
StatusCode::BAD_REQUEST,
Json(json!({
"error": error,
"code": "WORKFLOW_PLAN_INVALID",
})),
)
})?;
let plan_package = compiler_api::compile_workflow_plan_preview_package_with_revision(
&plan_json,
Some("workflow_planner"),
revision.draft.plan_revision,
);
let plan_package_validation = compiler_api::validate_plan_package(&plan_package);
let overlap_analysis = compile_preview_plan_overlap(&state, &plan_package).await;
let plan_package_bundle = compiler_api::export_plan_package_bundle(&plan_package);
let initial_plan_json = compiler_api::workflow_plan_to_json(&revision.draft.initial_plan)
.map_err(|error| {
(
StatusCode::BAD_REQUEST,
Json(json!({
"error": error,
"code": "WORKFLOW_PLAN_INVALID",
})),
)
})?;
let plan_package_replay = compiler_api::compare_workflow_plan_preview_replay_with_revision(
&plan_json,
revision.draft.plan_revision,
&initial_plan_json,
1,
);
let teaching_library = teaching_library_summary();
Ok(Json(json!({
"plan": revision.draft.current_plan,
"plan_package": plan_package,
"plan_package_bundle": plan_package_bundle,
"plan_package_validation": plan_package_validation,
"overlap_analysis": overlap_analysis,
"plan_package_replay": plan_package_replay,
"teaching_library": teaching_library,
"conversation": revision.draft.conversation,
"assistant_message": {
"role": "assistant",
"text": revision.assistant_text,
},
"change_summary": revision.change_summary,
"clarifier": revision.clarifier,
"planner_diagnostics": revision.draft.planner_diagnostics,
})))
}
pub(super) async fn workflow_plan_chat_reset(
State(state): State<AppState>,
Json(input): Json<WorkflowPlanChatResetRequest>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let plan_id = input.plan_id.trim();
if plan_id.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({
"error": "plan_id is required",
"code": "WORKFLOW_PLAN_INVALID",
})),
));
}
let host = workflow_planner_host::WorkflowPlannerHost { state: &state };
let draft = compiler_api::reset_workflow_plan_draft::<
crate::routines::types::RoutineMisfirePolicy,
crate::AutomationFlowInputRef,
crate::AutomationFlowOutputContract,
_,
>(&host, plan_id)
.await
.map_err(|error| match error {
compiler_api::PlannerDraftError::NotFound => (
StatusCode::NOT_FOUND,
Json(compiler_api::draft_not_found_response(plan_id)),
),
compiler_api::PlannerDraftError::InvalidState(error)
| compiler_api::PlannerDraftError::Store(error) => (
StatusCode::BAD_REQUEST,
Json(json!({
"error": error,
"code": "WORKFLOW_PLAN_INVALID",
})),
),
})?;
let plan_json = compiler_api::workflow_plan_to_json(&draft.current_plan).map_err(|error| {
(
StatusCode::BAD_REQUEST,
Json(json!({
"error": error,
"code": "WORKFLOW_PLAN_INVALID",
})),
)
})?;
let plan_package = compiler_api::compile_workflow_plan_preview_package_with_revision(
&plan_json,
Some("workflow_planner"),
draft.plan_revision,
);
let plan_package_validation = compiler_api::validate_plan_package(&plan_package);
let overlap_analysis = compile_preview_plan_overlap(&state, &plan_package).await;
let plan_package_bundle = compiler_api::export_plan_package_bundle(&plan_package);
let initial_plan_json =
compiler_api::workflow_plan_to_json(&draft.initial_plan).map_err(|error| {
(
StatusCode::BAD_REQUEST,
Json(json!({
"error": error,
"code": "WORKFLOW_PLAN_INVALID",
})),
)
})?;
let plan_package_replay = compiler_api::compare_workflow_plan_preview_replay_with_revision(
&plan_json,
draft.plan_revision,
&initial_plan_json,
1,
);
let teaching_library = teaching_library_summary();
Ok(Json(json!({
"plan": draft.current_plan,
"plan_package": plan_package,
"plan_package_bundle": plan_package_bundle,
"plan_package_validation": plan_package_validation,
"overlap_analysis": overlap_analysis,
"plan_package_replay": plan_package_replay,
"teaching_library": teaching_library,
"conversation": draft.conversation,
"planner_diagnostics": draft.planner_diagnostics,
})))
}
fn planner_session_title_from_record(session: &WorkflowPlannerSessionRecord) -> String {
let title = session.title.trim();
if !title.is_empty() {
return title.to_string();
}
planner_session_default_title(&session.goal, session.created_at_ms)
}
async fn workflow_planner_session_response(
state: &AppState,
session: &WorkflowPlannerSessionRecord,
response: Json<Value>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let plan = response.0.get("plan").cloned().unwrap_or(Value::Null);
let conversation = response
.0
.get("conversation")
.cloned()
.unwrap_or(Value::Null);
let planner_diagnostics = response
.0
.get("planner_diagnostics")
.or_else(|| response.0.get("plannerDiagnostics"))
.cloned()
.unwrap_or(Value::Null);
let change_summary = response
.0
.get("change_summary")
.cloned()
.unwrap_or_else(|| Value::Array(Vec::new()));
let clarifier = response
.0
.get("clarifier")
.cloned()
.unwrap_or_else(|| json!({"status":"none"}));
let draft = state
.get_workflow_plan_draft(session.current_plan_id.as_deref().unwrap_or_default())
.await;
let mut next_session = session.clone();
if let Some(draft) = draft {
next_session.current_plan_id = Some(draft.current_plan.plan_id.clone());
next_session.draft = Some(draft.clone());
next_session.updated_at_ms = crate::now_ms();
if next_session.title.trim().is_empty()
|| next_session.title.starts_with("Plan ")
|| next_session.title.starts_with("Untitled")
{
next_session.title = draft.current_plan.title.clone();
}
if next_session.title.trim().is_empty() {
next_session.title = planner_session_title_from_record(&next_session);
}
let _ = state
.put_workflow_planner_session(next_session.clone())
.await;
}
Ok(Json(json!({
"session": next_session,
"plan": plan,
"conversation": conversation,
"change_summary": change_summary,
"planner_diagnostics": planner_diagnostics,
"clarifier": clarifier,
})))
}
pub(super) async fn workflow_planner_session_list(
State(state): State<AppState>,
Query(query): Query<WorkflowPlannerSessionListQuery>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let sessions = state
.list_workflow_planner_sessions(query.project_slug.as_deref())
.await;
let items = sessions
.iter()
.map(workflow_planner_session_list_item)
.collect::<Vec<_>>();
Ok(Json(json!({
"sessions": items,
"count": items.len(),
})))
}
pub(super) async fn workflow_planner_session_create(
State(state): State<AppState>,
Json(input): Json<WorkflowPlannerSessionCreateRequest>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let project_slug = input.project_slug.trim();
if project_slug.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({
"error": "project_slug is required",
"code": "WORKFLOW_PLAN_INVALID",
})),
));
}
if let Some(workspace_root) = input.workspace_root.as_deref() {
crate::normalize_absolute_workspace_root(workspace_root).map_err(|error| {
(
StatusCode::BAD_REQUEST,
Json(json!({
"error": error,
"code": "WORKFLOW_PLAN_INVALID",
})),
)
})?;
}
let now = crate::now_ms();
let session = WorkflowPlannerSessionRecord {
session_id: format!("wfplan-session-{}", Uuid::new_v4()),
project_slug: project_slug.to_string(),
title: input
.title
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.unwrap_or_else(|| {
planner_session_default_title(input.goal.as_deref().unwrap_or(""), now)
}),
workspace_root: input
.workspace_root
.as_deref()
.map(str::trim)
.unwrap_or("")
.to_string(),
current_plan_id: None,
draft: None,
goal: input.goal.unwrap_or_default(),
notes: input.notes.unwrap_or_default(),
planner_provider: input.planner_provider.unwrap_or_default(),
planner_model: input.planner_model.unwrap_or_default(),
plan_source: input
.plan_source
.unwrap_or_else(|| "coding_task_planning".to_string()),
allowed_mcp_servers: input.allowed_mcp_servers,
operator_preferences: input.operator_preferences,
published_at_ms: None,
published_tasks: Vec::new(),
created_at_ms: now,
updated_at_ms: now,
};
let mut session = session;
if let Some(plan) = input.plan {
let conversation = input
.conversation
.unwrap_or_else(|| crate::WorkflowPlanConversation {
conversation_id: format!("wfchat-{}", Uuid::new_v4()),
plan_id: plan.plan_id.clone(),
created_at_ms: now,
updated_at_ms: now,
messages: Vec::new(),
});
let draft = crate::WorkflowPlanDraftRecord {
initial_plan: plan.clone(),
current_plan: plan,
plan_revision: input.plan_revision.unwrap_or(1),
conversation,
planner_diagnostics: input.planner_diagnostics,
last_success_materialization: input.last_success_materialization,
};
session.current_plan_id = Some(draft.current_plan.plan_id.clone());
session.draft = Some(draft);
}
let stored = state
.put_workflow_planner_session(session.clone())
.await
.map_err(|error| {
(
StatusCode::BAD_REQUEST,
Json(json!({
"error": error.to_string(),
"code": "WORKFLOW_PLAN_INVALID",
})),
)
})?;
Ok(Json(json!({
"session": stored,
})))
}
pub(super) async fn workflow_planner_session_get(
State(state): State<AppState>,
Path(session_id): Path<String>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let Some(session) = state.get_workflow_planner_session(&session_id).await else {
return Err((
StatusCode::NOT_FOUND,
Json(json!({
"error": "planner session not found",
"code": "WORKFLOW_PLAN_SESSION_NOT_FOUND",
"session_id": session_id,
})),
));
};
Ok(Json(json!({
"session": session,
})))
}
pub(super) async fn workflow_planner_session_patch(
State(state): State<AppState>,
Path(session_id): Path<String>,
Json(input): Json<WorkflowPlannerSessionPatchRequest>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let Some(mut session) = state.get_workflow_planner_session(&session_id).await else {
return Err((
StatusCode::NOT_FOUND,
Json(json!({
"error": "planner session not found",
"code": "WORKFLOW_PLAN_SESSION_NOT_FOUND",
"session_id": session_id,
})),
));
};
if let Some(title) = input.title.as_deref() {
let title = title.trim();
if title.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({
"error": "title cannot be empty",
"code": "WORKFLOW_PLAN_INVALID",
})),
));
}
session.title = title.to_string();
}
if let Some(workspace_root) = input.workspace_root.as_deref() {
crate::normalize_absolute_workspace_root(workspace_root).map_err(|error| {
(
StatusCode::BAD_REQUEST,
Json(json!({
"error": error,
"code": "WORKFLOW_PLAN_INVALID",
})),
)
})?;
session.workspace_root = workspace_root.trim().to_string();
}
if let Some(goal) = input.goal {
session.goal = goal;
}
if let Some(notes) = input.notes {
session.notes = notes;
}
if let Some(provider) = input.planner_provider {
session.planner_provider = provider;
}
if let Some(model) = input.planner_model {
session.planner_model = model;
}
if let Some(plan_source) = input.plan_source {
session.plan_source = plan_source;
}
if let Some(allowed) = input.allowed_mcp_servers {
session.allowed_mcp_servers = allowed;
}
if let Some(preferences) = input.operator_preferences {
session.operator_preferences = Some(preferences);
}
if let Some(current_plan_id) = input.current_plan_id {
let current_plan_id = current_plan_id.trim();
session.current_plan_id = if current_plan_id.is_empty() {
None
} else {
Some(current_plan_id.to_string())
};
}
if let Some(draft) = input.draft {
session.current_plan_id = Some(draft.current_plan.plan_id.clone());
session.draft = Some(draft);
}
if let Some(published_at_ms) = input.published_at_ms {
session.published_at_ms = Some(published_at_ms);
}
if let Some(published_tasks) = input.published_tasks {
session.published_tasks = published_tasks;
}
let stored = state
.put_workflow_planner_session(session)
.await
.map_err(|error| {
(
StatusCode::BAD_REQUEST,
Json(json!({
"error": error.to_string(),
"code": "WORKFLOW_PLAN_INVALID",
})),
)
})?;
Ok(Json(json!({
"session": stored,
})))
}
pub(super) async fn workflow_planner_session_delete(
State(state): State<AppState>,
Path(session_id): Path<String>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let Some(session) = state.delete_workflow_planner_session(&session_id).await else {
return Err((
StatusCode::NOT_FOUND,
Json(json!({
"error": "planner session not found",
"code": "WORKFLOW_PLAN_SESSION_NOT_FOUND",
"session_id": session_id,
})),
));
};
Ok(Json(json!({
"ok": true,
"session": session,
})))
}
pub(super) async fn workflow_planner_session_duplicate(
State(state): State<AppState>,
Path(session_id): Path<String>,
Json(input): Json<WorkflowPlannerSessionDuplicateRequest>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let Some(source) = state.get_workflow_planner_session(&session_id).await else {
return Err((
StatusCode::NOT_FOUND,
Json(json!({
"error": "planner session not found",
"code": "WORKFLOW_PLAN_SESSION_NOT_FOUND",
"session_id": session_id,
})),
));
};
let now = crate::now_ms();
let mut next = source.clone();
next.session_id = format!("wfplan-session-{}", Uuid::new_v4());
next.title = input
.title
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.unwrap_or_else(|| format!("Copy of {}", source.title));
next.created_at_ms = now;
next.updated_at_ms = now;
if let Some(draft) = source.draft.as_ref() {
let new_plan_id = format!("wfplan-{}", Uuid::new_v4());
let duplicated = retag_workflow_plan_draft(draft, &new_plan_id).map_err(|error| {
(
StatusCode::BAD_REQUEST,
Json(json!({
"error": error,
"code": "WORKFLOW_PLAN_INVALID",
})),
)
})?;
next.current_plan_id = Some(new_plan_id);
next.draft = Some(duplicated);
}
let stored = state
.put_workflow_planner_session(next)
.await
.map_err(|error| {
(
StatusCode::BAD_REQUEST,
Json(json!({
"error": error.to_string(),
"code": "WORKFLOW_PLAN_INVALID",
})),
)
})?;
Ok(Json(json!({
"session": stored,
})))
}
pub(super) async fn workflow_planner_session_start(
State(state): State<AppState>,
Path(session_id): Path<String>,
Json(input): Json<WorkflowPlannerSessionStartRequest>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let Some(mut session) = state.get_workflow_planner_session(&session_id).await else {
return Err((
StatusCode::NOT_FOUND,
Json(json!({
"error": "planner session not found",
"code": "WORKFLOW_PLAN_SESSION_NOT_FOUND",
"session_id": session_id,
})),
));
};
let chat_start = WorkflowPlanChatStartRequest {
prompt: input.prompt,
schedule: input.schedule,
plan_source: input
.plan_source
.or_else(|| Some(session.plan_source.clone())),
allowed_mcp_servers: if input.allowed_mcp_servers.is_empty() {
session.allowed_mcp_servers.clone()
} else {
input.allowed_mcp_servers
},
workspace_root: input
.workspace_root
.or_else(|| Some(session.workspace_root.clone())),
operator_preferences: input
.operator_preferences
.or(session.operator_preferences.clone()),
};
let response = workflow_plan_chat_start(State(state.clone()), Json(chat_start)).await?;
if let Some(plan) = response.0.get("plan").cloned() {
if let Ok(plan) = serde_json::from_value::<crate::WorkflowPlan>(plan) {
session.current_plan_id = Some(plan.plan_id.clone());
session.goal = response
.0
.get("plan")
.and_then(|plan| plan.get("title"))
.and_then(Value::as_str)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| session.goal.clone());
session.title = if session.title.trim().is_empty()
|| session.title.starts_with("Plan ")
|| session.title.starts_with("Untitled")
{
plan.title.clone()
} else {
session.title.clone()
};
}
}
if let Some(plan_id) = response
.0
.get("plan")
.and_then(|plan| plan.get("plan_id"))
.and_then(Value::as_str)
{
if let Some(draft) = state.get_workflow_plan_draft(plan_id).await {
session.current_plan_id = Some(plan_id.to_string());
session.draft = Some(draft.clone());
session.updated_at_ms = crate::now_ms();
let _ = state.put_workflow_planner_session(session.clone()).await;
return workflow_planner_session_response(&state, &session, response).await;
}
}
workflow_planner_session_response(&state, &session, response).await
}
pub(super) async fn workflow_planner_session_message(
State(state): State<AppState>,
Path(session_id): Path<String>,
Json(input): Json<WorkflowPlannerSessionMessageRequest>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let Some(mut session) = state.get_workflow_planner_session(&session_id).await else {
return Err((
StatusCode::NOT_FOUND,
Json(json!({
"error": "planner session not found",
"code": "WORKFLOW_PLAN_SESSION_NOT_FOUND",
"session_id": session_id,
})),
));
};
if session.current_plan_id.is_none() {
if let Some(draft) = session.draft.clone() {
session.current_plan_id = Some(draft.current_plan.plan_id.clone());
let _ = state.put_workflow_plan_draft(draft).await;
}
}
let Some(plan_id) = session.current_plan_id.clone() else {
return Err((
StatusCode::NOT_FOUND,
Json(json!({
"error": "planner session has no active plan",
"code": "WORKFLOW_PLAN_SESSION_NOT_FOUND",
"session_id": session_id,
})),
));
};
if state.get_workflow_plan_draft(&plan_id).await.is_none() {
if let Some(draft) = session.draft.clone() {
state.put_workflow_plan_draft(draft).await;
}
}
let response = workflow_plan_chat_message(
State(state.clone()),
Json(WorkflowPlanChatMessageRequest {
plan_id: plan_id.clone(),
message: input.message,
}),
)
.await?;
if let Some(draft) = state.get_workflow_plan_draft(&plan_id).await {
session.draft = Some(draft.clone());
session.updated_at_ms = crate::now_ms();
if session.title.trim().is_empty()
|| session.title.starts_with("Plan ")
|| session.title.starts_with("Untitled")
{
session.title = draft.current_plan.title.clone();
}
let _ = state.put_workflow_planner_session(session.clone()).await;
}
workflow_planner_session_response(&state, &session, response).await
}
pub(super) async fn workflow_planner_session_reset(
State(state): State<AppState>,
Path(session_id): Path<String>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let Some(mut session) = state.get_workflow_planner_session(&session_id).await else {
return Err((
StatusCode::NOT_FOUND,
Json(json!({
"error": "planner session not found",
"code": "WORKFLOW_PLAN_SESSION_NOT_FOUND",
"session_id": session_id,
})),
));
};
if session.current_plan_id.is_none() {
if let Some(draft) = session.draft.clone() {
session.current_plan_id = Some(draft.current_plan.plan_id.clone());
let _ = state.put_workflow_plan_draft(draft).await;
}
}
let Some(plan_id) = session.current_plan_id.clone() else {
return Err((
StatusCode::NOT_FOUND,
Json(json!({
"error": "planner session has no active plan",
"code": "WORKFLOW_PLAN_SESSION_NOT_FOUND",
"session_id": session_id,
})),
));
};
if state.get_workflow_plan_draft(&plan_id).await.is_none() {
if let Some(draft) = session.draft.clone() {
state.put_workflow_plan_draft(draft).await;
}
}
let response = workflow_plan_chat_reset(
State(state.clone()),
Json(WorkflowPlanChatResetRequest {
plan_id: plan_id.clone(),
}),
)
.await?;
if let Some(draft) = state.get_workflow_plan_draft(&plan_id).await {
session.draft = Some(draft.clone());
session.updated_at_ms = crate::now_ms();
if session.title.trim().is_empty()
|| session.title.starts_with("Plan ")
|| session.title.starts_with("Untitled")
{
session.title = draft.current_plan.title.clone();
}
let _ = state.put_workflow_planner_session(session.clone()).await;
}
workflow_planner_session_response(&state, &session, response).await
}
pub(super) async fn workflow_plan_apply(
State(state): State<AppState>,
Json(input): Json<WorkflowPlanApplyRequest>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let plan_id = input
.plan_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let plan = match (input.plan, plan_id.as_deref()) {
(Some(plan), _) => plan,
(None, Some(plan_id)) => state.get_workflow_plan(plan_id).await.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(json!({
"error": "workflow plan not found",
"code": "WORKFLOW_PLAN_NOT_FOUND",
"plan_id": plan_id,
})),
)
})?,
(None, None) => {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({
"error": "plan or plan_id is required",
"code": "WORKFLOW_PLAN_INVALID",
})),
));
}
};
compiler_api::validate_workflow_plan(&plan).map_err(|error| {
(
StatusCode::BAD_REQUEST,
Json(json!({
"error": error,
"code": "WORKFLOW_PLAN_INVALID",
})),
)
})?;
let draft_context = if let Some(plan_id) = plan_id.as_deref() {
state.get_workflow_plan_draft(plan_id).await
} else {
None
};
let apply_revision = draft_context
.as_ref()
.map(|draft| draft.plan_revision)
.unwrap_or(1);
let planner_diagnostics = draft_context
.as_ref()
.and_then(|draft| draft.planner_diagnostics.clone());
let plan_json = compiler_api::workflow_plan_to_json(&plan).map_err(|error| {
(
StatusCode::BAD_REQUEST,
Json(json!({
"error": error,
"code": "WORKFLOW_PLAN_INVALID",
})),
)
})?;
let mut plan_package = compiler_api::compile_workflow_plan_preview_package_with_revision(
&plan_json,
Some("workflow_planner"),
apply_revision,
);
let plan_package_validation = compiler_api::validate_plan_package(&plan_package);
let mut overlap_analysis = compile_preview_plan_overlap(&state, &plan_package).await;
if plan_package_validation.blocker_count > 0 {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({
"error": "plan package validation failed",
"code": "WORKFLOW_PLAN_INVALID",
"plan_package": plan_package,
"plan_package_validation": plan_package_validation,
})),
));
}
let requested_overlap_decision = parse_overlap_decision(input.overlap_decision.as_deref())
.map_err(|error| {
(
StatusCode::BAD_REQUEST,
Json(json!({
"error": error,
"code": "WORKFLOW_PLAN_INVALID",
})),
)
})?;
if overlap_analysis.requires_user_confirmation && requested_overlap_decision.is_none() {
return Err((
StatusCode::CONFLICT,
Json(json!({
"error": "overlap confirmation is required before apply",
"code": "WORKFLOW_PLAN_OVERLAP_CONFIRMATION_REQUIRED",
"plan_package": plan_package,
"plan_package_validation": plan_package_validation,
"overlap_analysis": overlap_analysis,
})),
));
}
if overlap_analysis.matched_plan_id.is_none() && requested_overlap_decision.is_some() {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({
"error": "overlap_decision was provided but no prior overlap was detected",
"code": "WORKFLOW_PLAN_INVALID",
})),
));
}
if let Some(decision) = requested_overlap_decision {
overlap_analysis.decision = decision;
overlap_analysis.requires_user_confirmation = false;
}
if let Some(entry) = compiler_api::overlap_log_entry_from_analysis(
&overlap_analysis,
input.creator_id.as_deref().unwrap_or("workflow_planner"),
&chrono::Utc::now().to_rfc3339(),
) {
plan_package
.overlap_policy
.get_or_insert_with(Default::default)
.overlap_log
.push(entry);
}
let mut automation = compile_plan_to_automation_v2(
&plan,
Some(&plan_package),
input.creator_id.as_deref().unwrap_or("workflow_planner"),
);
let approved_plan_materialization = compiler_api::approved_plan_materialization(&plan_package);
let approved_plan_success_memory =
compiler_api::approved_plan_success_memory_value(&plan_package);
let plan_package_bundle = compiler_api::export_plan_package_bundle(&plan_package);
if let Some(metadata) = automation.metadata.as_mut().and_then(Value::as_object_mut) {
metadata.insert(
"plan_source".to_string(),
serde_json::to_value(&plan.plan_source).unwrap_or(Value::Null),
);
metadata.insert(
"plan_package".to_string(),
serde_json::to_value(&plan_package).unwrap_or(Value::Null),
);
metadata.insert(
"plan_package_bundle".to_string(),
serde_json::to_value(&plan_package_bundle).unwrap_or(Value::Null),
);
metadata.insert(
"plan_package_validation".to_string(),
serde_json::to_value(&plan_package_validation).unwrap_or(Value::Null),
);
metadata.insert(
"overlap_analysis".to_string(),
serde_json::to_value(&overlap_analysis).unwrap_or(Value::Null),
);
metadata.insert(
"approved_plan_materialization".to_string(),
approved_plan_success_memory.clone(),
);
metadata.insert(
"planner_diagnostics".to_string(),
planner_diagnostics.clone().unwrap_or(Value::Null),
);
} else {
automation.metadata = Some(json!({
"plan_package": plan_package,
"plan_package_bundle": plan_package_bundle.clone(),
"plan_package_validation": plan_package_validation,
"overlap_analysis": overlap_analysis,
"approved_plan_materialization": approved_plan_success_memory.clone(),
"planner_diagnostics": planner_diagnostics,
}));
}
let stored = state.put_automation_v2(automation).await.map_err(|error| {
(
StatusCode::BAD_REQUEST,
Json(json!({
"error": error.to_string(),
"code": "WORKFLOW_PLAN_APPLY_FAILED",
})),
)
})?;
if let Some(plan_id) = plan_id.as_deref() {
if let Some(mut draft) = state.get_workflow_plan_draft(plan_id).await {
draft.last_success_materialization = Some(approved_plan_success_memory);
state.put_workflow_plan_draft(draft).await;
}
}
let pack_builder_export = match input.pack_builder_export {
Some(export) if export.enabled.unwrap_or(true) => {
Some(export_workflow_plan_to_pack_builder(&state, &plan, &export).await)
}
_ => None,
};
Ok(Json(json!({
"ok": true,
"plan": plan,
"plan_package": plan_package,
"plan_package_bundle": plan_package_bundle,
"overlap_analysis": overlap_analysis,
"approved_plan_materialization": approved_plan_materialization,
"automation": stored,
"pack_builder_export": pack_builder_export,
})))
}
async fn workflow_plan_import_inner(
State(state): State<AppState>,
Json(input): Json<WorkflowPlanImportRequest>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let report = compiler_api::validate_plan_package_bundle(&input.bundle);
if !report.compatible {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({
"error": "plan bundle import validation failed",
"code": "WORKFLOW_PLAN_INVALID",
"bundle": input.bundle,
"import_validation": report,
})),
));
}
let workspace_root = state.workspace_index.snapshot().await.root;
let import_preview = compiler_api::preview_plan_package_import_bundle(
&input.bundle,
&workspace_root,
input.creator_id.as_deref().unwrap_or("workflow_planner"),
);
let plan_package_validation = compiler_api::validate_plan_package(&import_preview.plan_package);
Ok(Json(json!({
"ok": true,
"bundle": input.bundle,
"import_validation": report,
"plan_package_preview": import_preview.plan_package,
"plan_package_validation": plan_package_validation,
"derived_scope_snapshot": import_preview.derived_scope_snapshot,
"summary": workflow_plan_import_summary(&import_preview.plan_package),
"import_transform_log": import_preview.import_transform_log,
"import_source_bundle_digest": import_preview.source_bundle_digest,
})))
}
pub(super) async fn workflow_plan_import(
State(state): State<AppState>,
Json(input): Json<WorkflowPlanImportRequest>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
workflow_plan_import_inner(State(state), Json(input)).await
}
pub(super) async fn workflow_plan_import_preview(
State(state): State<AppState>,
Json(input): Json<WorkflowPlanImportRequest>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
workflow_plan_import_inner(State(state), Json(input)).await
}
async fn export_workflow_plan_to_pack_builder(
state: &AppState,
plan: &crate::WorkflowPlan,
export: &WorkflowPlanPackBuilderExportRequest,
) -> Value {
let args = compiler_api::pack_builder_export_args(
plan,
&compiler_api::PackBuilderExportOptions {
session_id: export.session_id.clone(),
thread_key: export.thread_key.clone(),
auto_apply: export.auto_apply.unwrap_or(false),
},
);
match super::pack_builder::run_pack_builder_tool(state, args).await {
Ok(payload) => payload,
Err(code) => json!({
"status": "export_failed",
"error": "pack_builder_export_failed",
"http_status": code.as_u16(),
}),
}
}