Skip to main content

tandem_server/
pack_builder.rs

1use std::collections::{BTreeSet, HashMap};
2use std::fs::{self, File};
3use std::io::Write;
4use std::path::PathBuf;
5use std::sync::Arc;
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use anyhow::Context;
9use async_trait::async_trait;
10use serde::{Deserialize, Serialize};
11use serde_json::{json, Value};
12use tokio::sync::RwLock;
13use uuid::Uuid;
14
15use tandem_tools::Tool;
16use tandem_types::{ToolResult, ToolSchema};
17
18use crate::pack_manager::PackInstallRequest;
19use crate::{
20    mcp_catalog, AppState, RoutineMisfirePolicy, RoutineSchedule, RoutineSpec, RoutineStatus,
21};
22
23#[derive(Clone)]
24pub struct PackBuilderTool {
25    state: AppState,
26    plans: Arc<RwLock<HashMap<String, PreparedPlan>>>,
27    plans_path: PathBuf,
28    last_plan_by_session: Arc<RwLock<HashMap<String, String>>>,
29    workflows: Arc<RwLock<HashMap<String, WorkflowRecord>>>,
30    workflows_path: PathBuf,
31}
32
33impl PackBuilderTool {
34    pub fn new(state: AppState) -> Self {
35        let workflows_path = resolve_pack_builder_workflows_path();
36        let plans_path = resolve_pack_builder_plans_path();
37        Self {
38            state,
39            plans: Arc::new(RwLock::new(load_plans(&plans_path))),
40            plans_path,
41            last_plan_by_session: Arc::new(RwLock::new(HashMap::new())),
42            workflows: Arc::new(RwLock::new(load_workflows(&workflows_path))),
43            workflows_path,
44        }
45    }
46
47    async fn upsert_workflow(
48        &self,
49        event_type: &str,
50        status: WorkflowStatus,
51        plan_id: &str,
52        session_id: Option<&str>,
53        thread_key: Option<&str>,
54        goal: &str,
55        metadata: &Value,
56    ) {
57        let now = now_ms();
58        let workflow_id = format!("wf-{}", plan_id);
59        let mut workflows = self.workflows.write().await;
60        let created_at_ms = workflows
61            .get(plan_id)
62            .map(|row| row.created_at_ms)
63            .unwrap_or(now);
64        workflows.insert(
65            plan_id.to_string(),
66            WorkflowRecord {
67                workflow_id: workflow_id.clone(),
68                plan_id: plan_id.to_string(),
69                session_id: session_id.map(ToString::to_string),
70                thread_key: thread_key.map(ToString::to_string),
71                goal: goal.to_string(),
72                status: status.clone(),
73                metadata: metadata.clone(),
74                created_at_ms,
75                updated_at_ms: now,
76            },
77        );
78        retain_recent_workflows(&mut workflows, 256);
79        save_workflows(&self.workflows_path, &workflows);
80        drop(workflows);
81
82        self.state.event_bus.publish(tandem_types::EngineEvent::new(
83            event_type,
84            json!({
85                "sessionID": session_id.unwrap_or_default(),
86                "threadKey": thread_key.unwrap_or_default(),
87                "planID": plan_id,
88                "status": workflow_status_label(&status),
89                "metadata": metadata,
90            }),
91        ));
92    }
93
94    async fn resolve_plan_id_from_session(
95        &self,
96        session_id: Option<&str>,
97        thread_key: Option<&str>,
98    ) -> Option<String> {
99        if let Some(session) = session_id {
100            if let Some(thread) = thread_key {
101                let scoped_key = session_thread_scope_key(session, Some(thread));
102                if let Some(found) = self
103                    .last_plan_by_session
104                    .read()
105                    .await
106                    .get(&scoped_key)
107                    .cloned()
108                {
109                    return Some(found);
110                }
111            }
112        }
113        if let Some(session) = session_id {
114            if let Some(found) = self.last_plan_by_session.read().await.get(session).cloned() {
115                return Some(found);
116            }
117        }
118        let workflows = self.workflows.read().await;
119        let mut best: Option<(&String, u64)> = None;
120        for (plan_id, wf) in workflows.iter() {
121            if !matches!(wf.status, WorkflowStatus::PreviewPending) {
122                continue;
123            }
124            if session_id.is_some() && wf.session_id.as_deref() != session_id {
125                continue;
126            }
127            if let Some(thread) = thread_key {
128                if wf.thread_key.as_deref() != Some(thread) {
129                    continue;
130                }
131            }
132            let ts = wf.updated_at_ms;
133            if best.map(|(_, b)| ts > b).unwrap_or(true) {
134                best = Some((plan_id, ts));
135            }
136        }
137        best.map(|(plan_id, _)| plan_id.clone())
138    }
139
140    fn emit_metric(
141        &self,
142        metric: &str,
143        plan_id: &str,
144        status: &str,
145        session_id: Option<&str>,
146        thread_key: Option<&str>,
147    ) {
148        let surface = infer_surface(thread_key);
149        self.state.event_bus.publish(tandem_types::EngineEvent::new(
150            "pack_builder.metric",
151            json!({
152                "metric": metric,
153                "value": 1,
154                "surface": surface,
155                "planID": plan_id,
156                "status": status,
157                "sessionID": session_id.unwrap_or_default(),
158                "threadKey": thread_key.unwrap_or_default(),
159            }),
160        ));
161    }
162}
163
164#[derive(Debug, Clone, Deserialize, Default)]
165struct PackBuilderInput {
166    #[serde(default)]
167    mode: Option<String>,
168    #[serde(default)]
169    goal: Option<String>,
170    #[serde(default)]
171    auto_apply: Option<bool>,
172    #[serde(default)]
173    selected_connectors: Vec<String>,
174    #[serde(default)]
175    plan_id: Option<String>,
176    #[serde(default)]
177    approve_connector_registration: Option<bool>,
178    #[serde(default)]
179    approve_pack_install: Option<bool>,
180    #[serde(default)]
181    approve_enable_routines: Option<bool>,
182    #[serde(default)]
183    schedule: Option<PreviewScheduleInput>,
184    #[serde(default, rename = "__session_id")]
185    session_id: Option<String>,
186    #[serde(default)]
187    thread_key: Option<String>,
188    #[serde(default)]
189    secret_refs_confirmed: Option<Value>,
190    /// Execution architecture: "single" | "team" | "swarm"
191    /// - single: one agent loop (current default fallback)
192    /// - team: orchestrated agent team with planner + workers
193    /// - swarm: context-run swarm (parallel sub-tasks)
194    #[serde(default)]
195    execution_mode: Option<String>,
196    /// For swarm mode: max parallel sub-tasks
197    #[serde(default)]
198    max_agents: Option<u32>,
199}
200
201#[derive(Debug, Clone, Deserialize, Default)]
202struct PreviewScheduleInput {
203    #[serde(default)]
204    interval_seconds: Option<u64>,
205    #[serde(default)]
206    cron: Option<String>,
207    #[serde(default)]
208    timezone: Option<String>,
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize)]
212struct ConnectorCandidate {
213    slug: String,
214    name: String,
215    description: String,
216    documentation_url: String,
217    transport_url: String,
218    requires_auth: bool,
219    requires_setup: bool,
220    tool_count: usize,
221    score: usize,
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize)]
225struct PreparedPlan {
226    plan_id: String,
227    goal: String,
228    pack_id: String,
229    pack_name: String,
230    version: String,
231    capabilities_required: Vec<String>,
232    capabilities_optional: Vec<String>,
233    recommended_connectors: Vec<ConnectorCandidate>,
234    selected_connector_slugs: Vec<String>,
235    selected_mcp_tools: Vec<String>,
236    fallback_warnings: Vec<String>,
237    required_secrets: Vec<String>,
238    generated_zip_path: PathBuf,
239    routine_ids: Vec<String>,
240    routine_template: RoutineTemplate,
241    created_at_ms: u64,
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
245#[serde(rename_all = "snake_case")]
246enum WorkflowStatus {
247    PreviewPending,
248    ApplyBlockedMissingSecrets,
249    ApplyBlockedAuth,
250    ApplyComplete,
251    Cancelled,
252    Error,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
256struct WorkflowRecord {
257    workflow_id: String,
258    plan_id: String,
259    session_id: Option<String>,
260    thread_key: Option<String>,
261    goal: String,
262    status: WorkflowStatus,
263    metadata: Value,
264    created_at_ms: u64,
265    updated_at_ms: u64,
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize)]
269struct RoutineTemplate {
270    routine_id: String,
271    name: String,
272    timezone: String,
273    schedule: RoutineSchedule,
274    entrypoint: String,
275    allowed_tools: Vec<String>,
276}
277
278fn automation_v2_schedule_from_routine(
279    schedule: &RoutineSchedule,
280    timezone: &str,
281) -> crate::AutomationV2Schedule {
282    match schedule {
283        RoutineSchedule::IntervalSeconds { seconds } => crate::AutomationV2Schedule {
284            schedule_type: crate::AutomationV2ScheduleType::Interval,
285            cron_expression: None,
286            interval_seconds: Some(*seconds),
287            timezone: timezone.to_string(),
288            misfire_policy: crate::RoutineMisfirePolicy::RunOnce,
289        },
290        RoutineSchedule::Cron { expression } => crate::AutomationV2Schedule {
291            schedule_type: crate::AutomationV2ScheduleType::Cron,
292            cron_expression: Some(expression.clone()),
293            interval_seconds: None,
294            timezone: timezone.to_string(),
295            misfire_policy: crate::RoutineMisfirePolicy::RunOnce,
296        },
297    }
298}
299
300fn build_pack_builder_automation(
301    plan: &PreparedPlan,
302    routine_id: &str,
303    execution_mode: &str,
304    max_agents: u32,
305    registered_servers: &[String],
306    routine_enabled: bool,
307) -> crate::AutomationV2Spec {
308    let now = now_ms();
309    let automation_id = format!("automation.{}", routine_id);
310    crate::AutomationV2Spec {
311        automation_id: automation_id.clone(),
312        name: format!("{} automation", plan.pack_name),
313        description: Some(format!(
314            "Pack Builder automation for `{}` generated from plan `{}`.",
315            plan.pack_name, plan.plan_id
316        )),
317        // Pack Builder still uses the routine as the active trigger wrapper today.
318        // Keep the mirrored automation paused so apply does not double-register
319        // two active schedulable runtimes for the same pack.
320        status: crate::AutomationV2Status::Paused,
321        schedule: automation_v2_schedule_from_routine(
322            &plan.routine_template.schedule,
323            &plan.routine_template.timezone,
324        ),
325        knowledge: tandem_orchestrator::KnowledgeBinding::default(),
326        agents: vec![crate::AutomationAgentProfile {
327            agent_id: "pack_builder_agent".to_string(),
328            template_id: None,
329            display_name: plan.pack_name.clone(),
330            avatar_url: None,
331            model_policy: None,
332            skills: vec![plan.pack_id.clone()],
333            tool_policy: crate::AutomationAgentToolPolicy {
334                allowlist: plan.routine_template.allowed_tools.clone(),
335                denylist: Vec::new(),
336            },
337            mcp_policy: crate::AutomationAgentMcpPolicy {
338                allowed_servers: registered_servers.to_vec(),
339                allowed_tools: None,
340            },
341            approval_policy: None,
342        }],
343        flow: crate::AutomationFlowSpec {
344            nodes: vec![crate::AutomationFlowNode {
345                node_id: "pack_builder_execute".to_string(),
346                agent_id: "pack_builder_agent".to_string(),
347                objective: format!(
348                    "Execute the installed pack `{}` for this goal: {}",
349                    plan.pack_name, plan.goal
350                ),
351                knowledge: Default::default(),
352                depends_on: Vec::new(),
353                input_refs: Vec::new(),
354                output_contract: Some(crate::AutomationFlowOutputContract {
355                    kind: "report_markdown".to_string(),
356                    validator: Some(crate::AutomationOutputValidatorKind::GenericArtifact),
357                    enforcement: None,
358                    schema: None,
359                    summary_guidance: None,
360                }),
361                retry_policy: Some(json!({ "max_attempts": 3 })),
362                timeout_ms: None,
363                stage_kind: Some(crate::AutomationNodeStageKind::Workstream),
364                gate: None,
365                metadata: Some(json!({
366                    "builder": {
367                        "origin": "pack_builder",
368                        "task_kind": "pack_recipe",
369                        "execution_mode": execution_mode,
370                    },
371                    "pack_builder": {
372                        "pack_id": plan.pack_id,
373                        "pack_name": plan.pack_name,
374                        "plan_id": plan.plan_id,
375                        "routine_id": routine_id,
376                    }
377                })),
378            }],
379        },
380        execution: crate::AutomationExecutionPolicy {
381            max_parallel_agents: Some(max_agents.clamp(1, 16)),
382            max_total_runtime_ms: None,
383            max_total_tool_calls: None,
384            max_total_tokens: None,
385            max_total_cost_usd: None,
386        },
387        output_targets: vec![format!("run/{routine_id}/report.md")],
388        created_at_ms: now,
389        updated_at_ms: now,
390        creator_id: "pack_builder".to_string(),
391        workspace_root: None,
392        metadata: Some(json!({
393            "origin": "pack_builder",
394            "pack_builder_plan_id": plan.plan_id,
395            "pack_id": plan.pack_id,
396            "pack_name": plan.pack_name,
397            "goal": plan.goal,
398            "execution_mode": execution_mode,
399            "routine_id": routine_id,
400            "activation_mode": "routine_wrapper_mirror",
401            "routine_enabled": routine_enabled,
402            "registered_servers": registered_servers,
403        })),
404        next_fire_at_ms: None,
405        last_fired_at_ms: None,
406    }
407}
408
409#[derive(Debug, Clone, Serialize, Deserialize)]
410struct CapabilityNeed {
411    id: String,
412    external: bool,
413    query_terms: Vec<String>,
414}
415
416#[derive(Debug, Clone)]
417struct CatalogServer {
418    slug: String,
419    name: String,
420    description: String,
421    documentation_url: String,
422    transport_url: String,
423    requires_auth: bool,
424    requires_setup: bool,
425    tool_names: Vec<String>,
426}
427
428#[derive(Clone)]
429struct McpBridgeTool {
430    schema: ToolSchema,
431    mcp: tandem_runtime::McpRegistry,
432    server_name: String,
433    tool_name: String,
434}
435
436#[async_trait]
437impl Tool for McpBridgeTool {
438    fn schema(&self) -> ToolSchema {
439        self.schema.clone()
440    }
441
442    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
443        self.mcp
444            .call_tool(&self.server_name, &self.tool_name, args)
445            .await
446            .map_err(anyhow::Error::msg)
447    }
448}
449
450#[async_trait]
451impl Tool for PackBuilderTool {
452    fn schema(&self) -> ToolSchema {
453        ToolSchema::new(
454            "pack_builder",
455            "MCP-first Tandem pack builder with preview/apply phases",
456            json!({
457                "type": "object",
458                "properties": {
459                    "mode": {"type": "string", "enum": ["preview", "apply", "cancel", "pending"]},
460                    "goal": {"type": "string"},
461                    "auto_apply": {"type": "boolean"},
462                    "plan_id": {"type": "string"},
463                    "thread_key": {"type": "string"},
464                    "secret_refs_confirmed": {"oneOf":[{"type":"boolean"},{"type":"array","items":{"type":"string"}}]},
465                    "selected_connectors": {"type": "array", "items": {"type": "string"}},
466                    "approve_connector_registration": {"type": "boolean"},
467                    "approve_pack_install": {"type": "boolean"},
468                    "approve_enable_routines": {"type": "boolean"},
469                    "execution_mode": {
470                        "type": "string",
471                        "enum": ["single", "team", "swarm"],
472                        "description": "Execution architecture: single agent, orchestrated team, or parallel swarm"
473                    },
474                    "max_agents": {"type": "integer", "minimum": 2, "maximum": 32},
475                    "schedule": {
476                        "type": "object",
477                        "properties": {
478                            "interval_seconds": {"type": "integer", "minimum": 30},
479                            "cron": {"type": "string"},
480                            "timezone": {"type": "string"}
481                        }
482                    }
483                },
484                "required": ["mode"]
485            }),
486        )
487    }
488
489    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
490        let mut input: PackBuilderInput = serde_json::from_value(args).unwrap_or_default();
491        let mut mode = input
492            .mode
493            .as_deref()
494            .unwrap_or("preview")
495            .trim()
496            .to_ascii_lowercase();
497
498        if mode == "apply" && input.plan_id.is_none() {
499            input.plan_id = self
500                .resolve_plan_id_from_session(
501                    input.session_id.as_deref(),
502                    input.thread_key.as_deref(),
503                )
504                .await;
505        }
506
507        if mode == "preview" {
508            let goal_text = input.goal.as_deref().map(str::trim).unwrap_or("");
509            if is_confirmation_goal_text(goal_text) {
510                if let Some(last_plan_id) = self
511                    .resolve_plan_id_from_session(
512                        input.session_id.as_deref(),
513                        input.thread_key.as_deref(),
514                    )
515                    .await
516                {
517                    input.mode = Some("apply".to_string());
518                    input.plan_id = Some(last_plan_id);
519                    input.approve_pack_install = Some(true);
520                    input.approve_connector_registration = Some(true);
521                    input.approve_enable_routines = Some(true);
522                    mode = "apply".to_string();
523                }
524            }
525        }
526
527        match mode.as_str() {
528            "cancel" => self.cancel(input).await,
529            "pending" => self.pending(input).await,
530            "apply" => self.apply(input).await,
531            _ => self.preview(input).await,
532        }
533    }
534}
535
536impl PackBuilderTool {
537    async fn preview(&self, input: PackBuilderInput) -> anyhow::Result<ToolResult> {
538        let goal = input
539            .goal
540            .as_deref()
541            .map(str::trim)
542            .filter(|v| !v.is_empty())
543            .unwrap_or("Create a useful automation pack")
544            .to_string();
545
546        let needs = infer_capabilities_from_goal(&goal);
547        let all_catalog = catalog_servers();
548        let builtin_tools = available_builtin_tools(&self.state).await;
549        let mut recommended_connectors = Vec::<ConnectorCandidate>::new();
550        let mut selected_connector_slugs = BTreeSet::<String>::new();
551        let mut selected_mcp_tools = BTreeSet::<String>::new();
552        let mut required = Vec::<String>::new();
553        let mut optional = Vec::<String>::new();
554        let mut fallback_warnings = Vec::<String>::new();
555        let mut unresolved_external_needs = Vec::<String>::new();
556        let mut resolved_needs = BTreeSet::<String>::new();
557
558        for need in &needs {
559            if need.external {
560                required.push(need.id.clone());
561            } else {
562                optional.push(need.id.clone());
563            }
564            if !need.external {
565                continue;
566            }
567            if need_satisfied_by_builtin(&builtin_tools, need) {
568                resolved_needs.insert(need.id.clone());
569                continue;
570            }
571            unresolved_external_needs.push(need.id.clone());
572            let mut candidates = score_candidates_for_need(&all_catalog, need);
573            if candidates.is_empty() {
574                fallback_warnings.push(format!(
575                    "No MCP connector found for capability `{}`. Falling back to built-in tools.",
576                    need.id
577                ));
578                continue;
579            }
580            candidates.sort_by(|a, b| b.score.cmp(&a.score).then_with(|| a.slug.cmp(&b.slug)));
581            if let Some(best) = candidates.first() {
582                if should_auto_select_connector(need, best) {
583                    selected_connector_slugs.insert(best.slug.clone());
584                    resolved_needs.insert(need.id.clone());
585                    if let Some(server) = all_catalog.iter().find(|s| s.slug == best.slug) {
586                        for tool in server.tool_names.iter().take(3) {
587                            selected_mcp_tools.insert(format!(
588                                "mcp.{}.{}",
589                                namespace_segment(&server.slug),
590                                namespace_segment(tool)
591                            ));
592                        }
593                    }
594                }
595            }
596            recommended_connectors.extend(candidates.into_iter().take(3));
597        }
598
599        recommended_connectors
600            .sort_by(|a, b| b.score.cmp(&a.score).then_with(|| a.slug.cmp(&b.slug)));
601        recommended_connectors.dedup_by(|a, b| a.slug == b.slug);
602
603        let schedule = build_schedule(input.schedule.as_ref());
604        let pack_slug = goal_to_slug(&goal);
605        let pack_id = format!("tpk_pack_builder_{}", pack_slug);
606        let pack_name = format!("pack-builder-{}", pack_slug);
607        let version = "0.4.1".to_string();
608
609        // Use the persistent state dir for staging – NOT temp_dir() which OSes
610        // clean up arbitrarily. The zip must outlive the preview phase so that
611        // apply() can still find it even if several minutes pass between the two.
612        let zips_dir = resolve_pack_builder_zips_dir();
613        fs::create_dir_all(&zips_dir)?;
614        let stage_id = Uuid::new_v4();
615        let pack_root = zips_dir.join(format!("stage-{}", stage_id)).join("pack");
616        fs::create_dir_all(pack_root.join("agents"))?;
617        fs::create_dir_all(pack_root.join("missions"))?;
618        fs::create_dir_all(pack_root.join("routines"))?;
619
620        let mission_id = "default".to_string();
621        let routine_id = "default".to_string();
622        let tool_ids = selected_mcp_tools.iter().cloned().collect::<Vec<_>>();
623        let routine_template = RoutineTemplate {
624            routine_id: format!("{}.{}", pack_id, routine_id),
625            name: format!("{} routine", pack_name),
626            timezone: schedule.2.clone(),
627            schedule: schedule.0.clone(),
628            entrypoint: "mission.default".to_string(),
629            allowed_tools: build_allowed_tools(&tool_ids, &needs),
630        };
631
632        let mission_yaml = render_mission_yaml(&mission_id, &tool_ids, &needs);
633        let agent_md = render_agent_md(&tool_ids, &goal);
634        let routine_yaml = render_routine_yaml(
635            &routine_id,
636            &schedule.0,
637            &schedule.1,
638            &schedule.2,
639            &routine_template.allowed_tools,
640        );
641        let manifest_yaml = render_manifest_yaml(
642            &pack_id,
643            &pack_name,
644            &version,
645            &required,
646            &optional,
647            &mission_id,
648            &routine_id,
649        );
650
651        fs::write(pack_root.join("missions/default.yaml"), mission_yaml)?;
652        fs::write(pack_root.join("agents/default.md"), agent_md)?;
653        fs::write(pack_root.join("routines/default.yaml"), routine_yaml)?;
654        fs::write(pack_root.join("tandempack.yaml"), manifest_yaml)?;
655        fs::write(pack_root.join("README.md"), "# Generated by pack_builder\n")?;
656
657        // Save the zip into the same persistent dir (parent of pack_root)
658        let zip_path = pack_root
659            .parent()
660            .expect("pack_root always has a parent staging dir")
661            .join(format!("{}-{}.zip", pack_name, version));
662        zip_dir(&pack_root, &zip_path)?;
663
664        let plan_id = format!("plan-{}", Uuid::new_v4());
665        let selected_connector_slugs = selected_connector_slugs.into_iter().collect::<Vec<_>>();
666        let required_secrets =
667            derive_required_secret_refs_for_selected(&all_catalog, &selected_connector_slugs);
668        let connector_selection_required = unresolved_external_needs
669            .iter()
670            .any(|need_id| !resolved_needs.contains(need_id));
671        let auto_apply_requested = input.auto_apply.unwrap_or(true);
672        let auto_apply_ready = auto_apply_requested
673            && !connector_selection_required
674            && required_secrets.is_empty()
675            && fallback_warnings.is_empty();
676
677        let prepared = PreparedPlan {
678            plan_id: plan_id.clone(),
679            goal: goal.clone(),
680            pack_id: pack_id.clone(),
681            pack_name: pack_name.clone(),
682            version,
683            capabilities_required: required.clone(),
684            capabilities_optional: optional.clone(),
685            recommended_connectors: recommended_connectors.clone(),
686            selected_connector_slugs: selected_connector_slugs.clone(),
687            selected_mcp_tools: tool_ids.clone(),
688            fallback_warnings: fallback_warnings.clone(),
689            required_secrets: required_secrets.clone(),
690            generated_zip_path: zip_path.clone(),
691            routine_ids: vec![routine_template.routine_id.clone()],
692            routine_template,
693            created_at_ms: now_ms(),
694        };
695        {
696            let mut plans = self.plans.write().await;
697            plans.insert(plan_id.clone(), prepared);
698            retain_recent_plans(&mut plans, 256);
699            save_plans(&self.plans_path, &plans);
700        }
701        if let Some(session_id) = input
702            .session_id
703            .as_deref()
704            .map(str::trim)
705            .filter(|v| !v.is_empty())
706        {
707            let mut last = self.last_plan_by_session.write().await;
708            last.insert(session_id.to_string(), plan_id.clone());
709            if let Some(thread_key) = input
710                .thread_key
711                .as_deref()
712                .map(str::trim)
713                .filter(|v| !v.is_empty())
714            {
715                last.insert(
716                    session_thread_scope_key(session_id, Some(thread_key)),
717                    plan_id.clone(),
718                );
719            }
720        }
721
722        let output = json!({
723            "workflow_id": format!("wf-{}", plan_id),
724            "mode": "preview",
725            "plan_id": plan_id,
726            "session_id": input.session_id,
727            "thread_key": input.thread_key,
728            "goal": goal,
729            "pack": {
730                "pack_id": pack_id,
731                "name": pack_name,
732                "version": "0.4.1"
733            },
734            "connector_candidates": recommended_connectors,
735            "selected_connectors": selected_connector_slugs,
736            "connector_selection_required": connector_selection_required,
737            "mcp_mapping": tool_ids,
738            "fallback_warnings": fallback_warnings,
739            "required_secrets": required_secrets,
740            "zip_path": zip_path.to_string_lossy(),
741            "auto_apply_requested": auto_apply_requested,
742            "auto_apply_ready": auto_apply_ready,
743            "status": "preview_pending",
744            "next_actions": build_preview_next_actions(
745                connector_selection_required,
746                &required_secrets,
747                !selected_connector_slugs.is_empty(),
748            ),
749            "approval_required": {
750                "register_connectors": false,
751                "install_pack": false,
752                "enable_routines": false
753            }
754        });
755
756        self.emit_metric(
757            "pack_builder.preview.count",
758            plan_id.as_str(),
759            "preview_pending",
760            input.session_id.as_deref(),
761            input.thread_key.as_deref(),
762        );
763
764        if auto_apply_ready {
765            let applied = self
766                .apply(PackBuilderInput {
767                    mode: Some("apply".to_string()),
768                    goal: None,
769                    auto_apply: Some(false),
770                    selected_connectors: selected_connector_slugs.clone(),
771                    plan_id: Some(plan_id.clone()),
772                    approve_connector_registration: Some(true),
773                    approve_pack_install: Some(true),
774                    approve_enable_routines: Some(true),
775                    schedule: None,
776                    session_id: input.session_id.clone(),
777                    thread_key: input.thread_key.clone(),
778                    secret_refs_confirmed: Some(json!(true)),
779                    // Forward the execution mode from the preview input
780                    execution_mode: input.execution_mode.clone(),
781                    max_agents: input.max_agents,
782                })
783                .await?;
784            let mut metadata = applied.metadata.clone();
785            if let Some(obj) = metadata.as_object_mut() {
786                obj.insert("auto_applied_from_preview".to_string(), json!(true));
787                obj.insert("preview_plan_id".to_string(), json!(plan_id));
788            }
789            self.upsert_workflow(
790                "pack_builder.apply_completed",
791                WorkflowStatus::ApplyComplete,
792                plan_id.as_str(),
793                input.session_id.as_deref(),
794                input.thread_key.as_deref(),
795                goal.as_str(),
796                &metadata,
797            )
798            .await;
799            return Ok(ToolResult {
800                output: render_pack_builder_apply_output(&metadata),
801                metadata,
802            });
803        }
804
805        self.upsert_workflow(
806            "pack_builder.preview_ready",
807            WorkflowStatus::PreviewPending,
808            plan_id.as_str(),
809            input.session_id.as_deref(),
810            input.thread_key.as_deref(),
811            goal.as_str(),
812            &output,
813        )
814        .await;
815
816        Ok(ToolResult {
817            output: render_pack_builder_preview_output(&output),
818            metadata: output,
819        })
820    }
821
822    async fn apply(&self, input: PackBuilderInput) -> anyhow::Result<ToolResult> {
823        let resolved_plan_id = if input.plan_id.is_none() {
824            self.resolve_plan_id_from_session(
825                input.session_id.as_deref(),
826                input.thread_key.as_deref(),
827            )
828            .await
829        } else {
830            input.plan_id.clone()
831        };
832        let Some(plan_id) = resolved_plan_id.as_deref() else {
833            self.emit_metric(
834                "pack_builder.apply.wrong_plan_prevented",
835                "unknown",
836                "error",
837                input.session_id.as_deref(),
838                input.thread_key.as_deref(),
839            );
840            let output = json!({"error":"plan_id is required for apply"});
841            self.upsert_workflow(
842                "pack_builder.error",
843                WorkflowStatus::Error,
844                "unknown",
845                input.session_id.as_deref(),
846                input.thread_key.as_deref(),
847                input.goal.as_deref().unwrap_or_default(),
848                &output,
849            )
850            .await;
851            return Ok(ToolResult {
852                output: render_pack_builder_apply_output(&output),
853                metadata: output,
854            });
855        };
856
857        let plan = {
858            let guard = self.plans.read().await;
859            guard.get(plan_id).cloned()
860        };
861        let Some(plan) = plan else {
862            self.emit_metric(
863                "pack_builder.apply.wrong_plan_prevented",
864                plan_id,
865                "error",
866                input.session_id.as_deref(),
867                input.thread_key.as_deref(),
868            );
869            let output = json!({"error":"unknown plan_id", "plan_id": plan_id});
870            self.upsert_workflow(
871                "pack_builder.error",
872                WorkflowStatus::Error,
873                plan_id,
874                input.session_id.as_deref(),
875                input.thread_key.as_deref(),
876                input.goal.as_deref().unwrap_or_default(),
877                &output,
878            )
879            .await;
880            return Ok(ToolResult {
881                output: render_pack_builder_apply_output(&output),
882                metadata: output,
883            });
884        };
885
886        let session_id = input.session_id.as_deref();
887        let thread_key = input.thread_key.as_deref();
888        if self
889            .workflows
890            .read()
891            .await
892            .get(plan_id)
893            .map(|wf| matches!(wf.status, WorkflowStatus::Cancelled))
894            .unwrap_or(false)
895        {
896            let output = json!({
897                "error":"plan_cancelled",
898                "plan_id": plan_id,
899                "status":"cancelled",
900                "next_actions": ["Create a new preview to continue."]
901            });
902            return Ok(ToolResult {
903                output: render_pack_builder_apply_output(&output),
904                metadata: output,
905            });
906        }
907
908        self.emit_metric(
909            "pack_builder.apply.count",
910            plan_id,
911            "apply_started",
912            session_id,
913            thread_key,
914        );
915
916        if input.approve_pack_install != Some(true) {
917            let output = json!({
918                "error": "approval_required",
919                "required": {
920                    "approve_pack_install": true
921                },
922                "status": "error"
923            });
924            self.upsert_workflow(
925                "pack_builder.error",
926                WorkflowStatus::Error,
927                plan_id,
928                session_id,
929                thread_key,
930                &plan.goal,
931                &output,
932            )
933            .await;
934            return Ok(ToolResult {
935                output: render_pack_builder_apply_output(&output),
936                metadata: output,
937            });
938        }
939
940        let all_catalog = catalog_servers();
941        let selected = if input.selected_connectors.is_empty() {
942            plan.selected_connector_slugs.clone()
943        } else {
944            input.selected_connectors.clone()
945        };
946        if !selected.is_empty() && input.approve_connector_registration != Some(true) {
947            let output = json!({
948                "error": "approval_required",
949                "required": {
950                    "approve_connector_registration": true,
951                    "approve_pack_install": true
952                },
953                "status": "error"
954            });
955            self.upsert_workflow(
956                "pack_builder.error",
957                WorkflowStatus::Error,
958                plan_id,
959                session_id,
960                thread_key,
961                &plan.goal,
962                &output,
963            )
964            .await;
965            return Ok(ToolResult {
966                output: render_pack_builder_apply_output(&output),
967                metadata: output,
968            });
969        }
970
971        if !plan.required_secrets.is_empty()
972            && !secret_refs_confirmed(&input.secret_refs_confirmed, &plan.required_secrets)
973        {
974            let output = json!({
975                "workflow_id": format!("wf-{}", plan.plan_id),
976                "mode": "apply",
977                "plan_id": plan.plan_id,
978                "session_id": input.session_id,
979                "thread_key": input.thread_key,
980                "goal": plan.goal,
981                "status": "apply_blocked_missing_secrets",
982                "required_secrets": plan.required_secrets,
983                "next_actions": [
984                    "Set required secrets in engine settings/environment.",
985                    "Re-run apply with `secret_refs_confirmed` after secrets are set."
986                ],
987            });
988            self.upsert_workflow(
989                "pack_builder.apply_blocked",
990                WorkflowStatus::ApplyBlockedMissingSecrets,
991                plan_id,
992                session_id,
993                thread_key,
994                &plan.goal,
995                &output,
996            )
997            .await;
998            self.emit_metric(
999                "pack_builder.apply.blocked_missing_secrets",
1000                plan_id,
1001                "apply_blocked_missing_secrets",
1002                session_id,
1003                thread_key,
1004            );
1005            return Ok(ToolResult {
1006                output: render_pack_builder_apply_output(&output),
1007                metadata: output,
1008            });
1009        }
1010
1011        let auth_blocked = selected.iter().any(|slug| {
1012            plan.recommended_connectors
1013                .iter()
1014                .any(|c| &c.slug == slug && (c.requires_setup || c.transport_url.contains('{')))
1015        });
1016        if auth_blocked {
1017            let output = json!({
1018                "workflow_id": format!("wf-{}", plan.plan_id),
1019                "mode": "apply",
1020                "plan_id": plan.plan_id,
1021                "session_id": input.session_id,
1022                "thread_key": input.thread_key,
1023                "goal": plan.goal,
1024                "status": "apply_blocked_auth",
1025                "selected_connectors": selected,
1026                "next_actions": [
1027                    "Complete connector setup/auth from the connector documentation.",
1028                    "Re-run apply after connector auth is completed."
1029                ],
1030            });
1031            self.upsert_workflow(
1032                "pack_builder.apply_blocked",
1033                WorkflowStatus::ApplyBlockedAuth,
1034                plan_id,
1035                session_id,
1036                thread_key,
1037                &plan.goal,
1038                &output,
1039            )
1040            .await;
1041            self.emit_metric(
1042                "pack_builder.apply.blocked_auth",
1043                plan_id,
1044                "apply_blocked_auth",
1045                session_id,
1046                thread_key,
1047            );
1048            return Ok(ToolResult {
1049                output: render_pack_builder_apply_output(&output),
1050                metadata: output,
1051            });
1052        }
1053
1054        self.state.event_bus.publish(tandem_types::EngineEvent::new(
1055            "pack_builder.apply_started",
1056            json!({
1057                "sessionID": session_id.unwrap_or_default(),
1058                "threadKey": thread_key.unwrap_or_default(),
1059                "planID": plan_id,
1060                "status": "apply_started",
1061            }),
1062        ));
1063
1064        if !plan.generated_zip_path.exists() {
1065            let output = json!({
1066                "workflow_id": format!("wf-{}", plan.plan_id),
1067                "mode": "apply",
1068                "plan_id": plan.plan_id,
1069                "session_id": input.session_id,
1070                "thread_key": input.thread_key,
1071                "goal": plan.goal,
1072                "status": "apply_blocked_missing_preview_artifacts",
1073                "error": "preview_artifacts_missing",
1074                "next_actions": [
1075                    "Run a new Pack Builder preview for this goal.",
1076                    "Confirm apply from the new preview."
1077                ]
1078            });
1079            self.upsert_workflow(
1080                "pack_builder.apply_blocked",
1081                WorkflowStatus::Error,
1082                plan_id,
1083                session_id,
1084                thread_key,
1085                &plan.goal,
1086                &output,
1087            )
1088            .await;
1089            return Ok(ToolResult {
1090                output: render_pack_builder_apply_output(&output),
1091                metadata: output,
1092            });
1093        }
1094
1095        let mut connector_results = Vec::<Value>::new();
1096        let mut registered_servers = Vec::<String>::new();
1097
1098        for slug in &selected {
1099            let Some(server) = all_catalog.iter().find(|s| &s.slug == slug) else {
1100                connector_results
1101                    .push(json!({"slug": slug, "ok": false, "error": "not_in_catalog"}));
1102                continue;
1103            };
1104            let transport = if server.transport_url.contains('{') || server.transport_url.is_empty()
1105            {
1106                connector_results.push(json!({
1107                    "slug": server.slug,
1108                    "ok": false,
1109                    "error": "transport_requires_manual_setup",
1110                    "documentation_url": server.documentation_url
1111                }));
1112                continue;
1113            } else {
1114                server.transport_url.clone()
1115            };
1116
1117            let name = server.slug.clone();
1118            self.state
1119                .mcp
1120                .add_or_update(name.clone(), transport, HashMap::new(), true)
1121                .await;
1122            let connected = self.state.mcp.connect(&name).await;
1123            let tool_count = if connected {
1124                sync_mcp_tools_for_server(&self.state, &name).await
1125            } else {
1126                0
1127            };
1128            if connected {
1129                registered_servers.push(name.clone());
1130            }
1131            connector_results.push(json!({
1132                "slug": server.slug,
1133                "ok": connected,
1134                "registered_name": name,
1135                "tool_count": tool_count,
1136                "documentation_url": server.documentation_url,
1137                "requires_auth": server.requires_auth
1138            }));
1139        }
1140
1141        let installed = self
1142            .state
1143            .pack_manager
1144            .install(PackInstallRequest {
1145                path: Some(plan.generated_zip_path.to_string_lossy().to_string()),
1146                url: None,
1147                source: json!({"kind":"pack_builder", "plan_id": plan.plan_id, "goal": plan.goal}),
1148            })
1149            .await?;
1150
1151        let mut routines_registered = Vec::<String>::new();
1152        let mut automations_registered = Vec::<String>::new();
1153        for routine_id in &plan.routine_ids {
1154            let exec_mode = input
1155                .execution_mode
1156                .as_deref()
1157                .map(str::trim)
1158                .filter(|v| !v.is_empty())
1159                .unwrap_or("team");
1160            let max_agents = input.max_agents.unwrap_or(4);
1161            let mut routine = RoutineSpec {
1162                routine_id: routine_id.clone(),
1163                name: plan.routine_template.name.clone(),
1164                status: RoutineStatus::Active,
1165                schedule: plan.routine_template.schedule.clone(),
1166                timezone: plan.routine_template.timezone.clone(),
1167                misfire_policy: RoutineMisfirePolicy::RunOnce,
1168                entrypoint: plan.routine_template.entrypoint.clone(),
1169                args: json!({
1170                    "prompt": plan.goal,
1171                    // execution_mode controls how the orchestrator handles this routine:
1172                    // "single"  → one agent loop (simple tasks)
1173                    // "team"    → orchestrated agent team with planner + specialist workers
1174                    // "swarm"   → context-run based swarm with parallel sub-tasks
1175                    "mode": exec_mode,
1176                    "uses_external_integrations": true,
1177                    "pack_id": plan.pack_id,
1178                    "pack_name": plan.pack_name,
1179                    "pack_builder_plan_id": plan.plan_id,
1180                    // team/swarm configuration hints for the orchestrator
1181                    "orchestration": {
1182                        "execution_mode": exec_mode,
1183                        "max_agents": max_agents,
1184                        "objective": plan.goal,
1185                    },
1186                }),
1187                allowed_tools: plan.routine_template.allowed_tools.clone(),
1188                output_targets: vec![format!("run/{}/report.md", routine_id)],
1189                creator_type: "agent".to_string(),
1190                creator_id: "pack_builder".to_string(),
1191                requires_approval: false,
1192                external_integrations_allowed: true,
1193                next_fire_at_ms: None,
1194                last_fired_at_ms: None,
1195            };
1196            if input.approve_enable_routines == Some(false) {
1197                routine.status = RoutineStatus::Paused;
1198            }
1199            let automation = build_pack_builder_automation(
1200                &plan,
1201                routine_id,
1202                exec_mode,
1203                max_agents,
1204                &registered_servers,
1205                input.approve_enable_routines != Some(false),
1206            );
1207            let stored_automation = self.state.put_automation_v2(automation).await?;
1208            automations_registered.push(stored_automation.automation_id.clone());
1209            let stored = self
1210                .state
1211                .put_routine(routine)
1212                .await
1213                .map_err(|err| anyhow::anyhow!("failed to register routine: {:?}", err))?;
1214            routines_registered.push(stored.routine_id);
1215        }
1216
1217        let preset_path = save_pack_preset(&plan, &registered_servers)?;
1218
1219        let output = json!({
1220            "workflow_id": format!("wf-{}", plan.plan_id),
1221            "mode": "apply",
1222            "plan_id": plan.plan_id,
1223            "session_id": input.session_id,
1224            "thread_key": input.thread_key,
1225            "capabilities": {
1226                "required": plan.capabilities_required,
1227                "optional": plan.capabilities_optional
1228            },
1229            "pack_installed": {
1230                "pack_id": installed.pack_id,
1231                "name": installed.name,
1232                "version": installed.version,
1233                "install_path": installed.install_path,
1234            },
1235            "connectors": connector_results,
1236            "registered_servers": registered_servers,
1237            "automations_registered": automations_registered,
1238            "routines_registered": routines_registered,
1239            "routines_enabled": input.approve_enable_routines != Some(false),
1240            "fallback_warnings": plan.fallback_warnings,
1241            "status": "apply_complete",
1242            "next_actions": [
1243                "Review the installed pack in Packs view.",
1244                "Routine is enabled by default and will run on schedule."
1245            ],
1246            "pack_preset": {
1247                "path": preset_path.to_string_lossy().to_string(),
1248                "required_secrets": plan.required_secrets,
1249                "selected_tools": plan.selected_mcp_tools,
1250            }
1251        });
1252
1253        self.upsert_workflow(
1254            "pack_builder.apply_completed",
1255            WorkflowStatus::ApplyComplete,
1256            plan_id,
1257            session_id,
1258            thread_key,
1259            &plan.goal,
1260            &output,
1261        )
1262        .await;
1263        self.emit_metric(
1264            "pack_builder.apply.success",
1265            plan_id,
1266            "apply_complete",
1267            session_id,
1268            thread_key,
1269        );
1270
1271        Ok(ToolResult {
1272            output: render_pack_builder_apply_output(&output),
1273            metadata: output,
1274        })
1275    }
1276
1277    async fn cancel(&self, input: PackBuilderInput) -> anyhow::Result<ToolResult> {
1278        let plan_id = if let Some(plan_id) = input.plan_id.as_deref().map(str::trim) {
1279            if !plan_id.is_empty() {
1280                Some(plan_id.to_string())
1281            } else {
1282                None
1283            }
1284        } else {
1285            self.resolve_plan_id_from_session(
1286                input.session_id.as_deref(),
1287                input.thread_key.as_deref(),
1288            )
1289            .await
1290        };
1291        let Some(plan_id) = plan_id else {
1292            let output = json!({"error":"plan_id is required for cancel"});
1293            return Ok(ToolResult {
1294                output: render_pack_builder_apply_output(&output),
1295                metadata: output,
1296            });
1297        };
1298        let goal = self
1299            .plans
1300            .read()
1301            .await
1302            .get(&plan_id)
1303            .map(|p| p.goal.clone())
1304            .unwrap_or_default();
1305        let output = json!({
1306            "workflow_id": format!("wf-{}", plan_id),
1307            "mode": "cancel",
1308            "plan_id": plan_id,
1309            "session_id": input.session_id,
1310            "thread_key": input.thread_key,
1311            "goal": goal,
1312            "status": "cancelled",
1313            "next_actions": ["Create a new preview when ready."]
1314        });
1315        self.upsert_workflow(
1316            "pack_builder.cancelled",
1317            WorkflowStatus::Cancelled,
1318            output
1319                .get("plan_id")
1320                .and_then(Value::as_str)
1321                .unwrap_or_default(),
1322            input.session_id.as_deref(),
1323            input.thread_key.as_deref(),
1324            output
1325                .get("goal")
1326                .and_then(Value::as_str)
1327                .unwrap_or_default(),
1328            &output,
1329        )
1330        .await;
1331        self.emit_metric(
1332            "pack_builder.apply.cancelled",
1333            output
1334                .get("plan_id")
1335                .and_then(Value::as_str)
1336                .unwrap_or_default(),
1337            "cancelled",
1338            input.session_id.as_deref(),
1339            input.thread_key.as_deref(),
1340        );
1341        Ok(ToolResult {
1342            output: "Pack Builder Apply Cancelled\n- Pending plan cancelled.".to_string(),
1343            metadata: output,
1344        })
1345    }
1346
1347    async fn pending(&self, input: PackBuilderInput) -> anyhow::Result<ToolResult> {
1348        let plan_id = if let Some(plan_id) = input.plan_id.as_deref().map(str::trim) {
1349            if !plan_id.is_empty() {
1350                Some(plan_id.to_string())
1351            } else {
1352                None
1353            }
1354        } else {
1355            self.resolve_plan_id_from_session(
1356                input.session_id.as_deref(),
1357                input.thread_key.as_deref(),
1358            )
1359            .await
1360        };
1361        let Some(plan_id) = plan_id else {
1362            let output = json!({"status":"none","pending":null});
1363            return Ok(ToolResult {
1364                output: "No pending pack-builder plan for this session.".to_string(),
1365                metadata: output,
1366            });
1367        };
1368        let workflows = self.workflows.read().await;
1369        let Some(record) = workflows.get(&plan_id) else {
1370            let output = json!({"status":"none","plan_id":plan_id});
1371            return Ok(ToolResult {
1372                output: "No pending pack-builder plan found.".to_string(),
1373                metadata: output,
1374            });
1375        };
1376        let output = json!({
1377            "status":"ok",
1378            "pending": record,
1379            "plan_id": plan_id
1380        });
1381        Ok(ToolResult {
1382            output: serde_json::to_string_pretty(&output).unwrap_or_else(|_| output.to_string()),
1383            metadata: output,
1384        })
1385    }
1386}
1387
1388fn render_pack_builder_preview_output(meta: &Value) -> String {
1389    let goal = meta
1390        .get("goal")
1391        .and_then(Value::as_str)
1392        .unwrap_or("automation goal");
1393    let plan_id = meta.get("plan_id").and_then(Value::as_str).unwrap_or("-");
1394    let pack_name = meta
1395        .get("pack")
1396        .and_then(|v| v.get("name"))
1397        .and_then(Value::as_str)
1398        .unwrap_or("generated-pack");
1399    let pack_id = meta
1400        .get("pack")
1401        .and_then(|v| v.get("pack_id"))
1402        .and_then(Value::as_str)
1403        .unwrap_or("-");
1404    let auto_apply_ready = meta
1405        .get("auto_apply_ready")
1406        .and_then(Value::as_bool)
1407        .unwrap_or(false);
1408    let connector_selection_required = meta
1409        .get("connector_selection_required")
1410        .and_then(Value::as_bool)
1411        .unwrap_or(false);
1412    let selected_connectors = meta
1413        .get("selected_connectors")
1414        .and_then(Value::as_array)
1415        .map(|rows| {
1416            rows.iter()
1417                .filter_map(Value::as_str)
1418                .map(|v| format!("- {}", v))
1419                .collect::<Vec<_>>()
1420        })
1421        .unwrap_or_default();
1422    let required_secrets = meta
1423        .get("required_secrets")
1424        .and_then(Value::as_array)
1425        .map(|rows| {
1426            rows.iter()
1427                .filter_map(Value::as_str)
1428                .map(|v| format!("- {}", v))
1429                .collect::<Vec<_>>()
1430        })
1431        .unwrap_or_default();
1432    let fallback_warnings = meta
1433        .get("fallback_warnings")
1434        .and_then(Value::as_array)
1435        .map(|rows| {
1436            rows.iter()
1437                .filter_map(Value::as_str)
1438                .map(|v| format!("- {}", v))
1439                .collect::<Vec<_>>()
1440        })
1441        .unwrap_or_default();
1442
1443    let mut lines = vec![
1444        "Pack Builder Preview".to_string(),
1445        format!("- Goal: {}", goal),
1446        format!("- Plan ID: {}", plan_id),
1447        format!("- Pack: {} ({})", pack_name, pack_id),
1448    ];
1449
1450    if selected_connectors.is_empty() {
1451        lines.push("- Selected connectors: none".to_string());
1452    } else {
1453        lines.push("- Selected connectors:".to_string());
1454        lines.extend(selected_connectors);
1455    }
1456    if required_secrets.is_empty() {
1457        lines.push("- Required secrets: none".to_string());
1458    } else {
1459        lines.push("- Required secrets:".to_string());
1460        lines.extend(required_secrets);
1461    }
1462    if !fallback_warnings.is_empty() {
1463        lines.push("- Warnings:".to_string());
1464        lines.extend(fallback_warnings);
1465    }
1466
1467    if auto_apply_ready {
1468        lines.push("- Status: ready for automatic apply".to_string());
1469    } else {
1470        lines.push("- Status: waiting for apply confirmation".to_string());
1471        if connector_selection_required {
1472            lines.push("- Action needed: choose connectors before apply.".to_string());
1473        }
1474    }
1475    lines.join("\n")
1476}
1477
1478fn render_pack_builder_apply_output(meta: &Value) -> String {
1479    if let Some(status) = meta.get("status").and_then(Value::as_str) {
1480        match status {
1481            "apply_blocked_missing_secrets" => {
1482                let required = meta
1483                    .get("required_secrets")
1484                    .and_then(Value::as_array)
1485                    .map(|rows| {
1486                        rows.iter()
1487                            .filter_map(Value::as_str)
1488                            .map(|v| format!("- {}", v))
1489                            .collect::<Vec<_>>()
1490                    })
1491                    .unwrap_or_default();
1492                let mut lines = vec![
1493                    "Pack Builder Apply Blocked".to_string(),
1494                    "- Reason: missing required secrets.".to_string(),
1495                ];
1496                if !required.is_empty() {
1497                    lines.push("- Required secrets:".to_string());
1498                    lines.extend(required);
1499                }
1500                lines.push("- Action: set secrets, then apply again.".to_string());
1501                return lines.join("\n");
1502            }
1503            "apply_blocked_auth" => {
1504                let connectors = meta
1505                    .get("selected_connectors")
1506                    .and_then(Value::as_array)
1507                    .map(|rows| {
1508                        rows.iter()
1509                            .filter_map(Value::as_str)
1510                            .map(|v| format!("- {}", v))
1511                            .collect::<Vec<_>>()
1512                    })
1513                    .unwrap_or_default();
1514                let mut lines = vec![
1515                    "Pack Builder Apply Blocked".to_string(),
1516                    "- Reason: connector authentication/setup required.".to_string(),
1517                ];
1518                if !connectors.is_empty() {
1519                    lines.push("- Connectors awaiting setup:".to_string());
1520                    lines.extend(connectors);
1521                }
1522                lines.push("- Action: complete connector auth, then apply again.".to_string());
1523                return lines.join("\n");
1524            }
1525            "cancelled" => {
1526                return "Pack Builder Apply Cancelled\n- Pending plan cancelled.".to_string();
1527            }
1528            "apply_blocked_missing_preview_artifacts" => {
1529                return "Pack Builder Apply Blocked\n- Preview artifacts expired. Run preview again, then confirm.".to_string();
1530            }
1531            _ => {}
1532        }
1533    }
1534
1535    if let Some(error) = meta.get("error").and_then(Value::as_str) {
1536        return match error {
1537            "approval_required" => {
1538                "Pack Builder Apply Blocked\n- Approval required for this apply step.".to_string()
1539            }
1540            "unknown plan_id" => "Pack Builder Apply Failed\n- Plan not found.".to_string(),
1541            "plan_cancelled" => {
1542                "Pack Builder Apply Failed\n- Plan was already cancelled.".to_string()
1543            }
1544            _ => format!("Pack Builder Apply Failed\n- {}", error),
1545        };
1546    }
1547
1548    let pack_id = meta
1549        .get("pack_installed")
1550        .and_then(|v| v.get("pack_id"))
1551        .and_then(Value::as_str)
1552        .unwrap_or("-");
1553    let pack_name = meta
1554        .get("pack_installed")
1555        .and_then(|v| v.get("name"))
1556        .and_then(Value::as_str)
1557        .unwrap_or("-");
1558    let install_path = meta
1559        .get("pack_installed")
1560        .and_then(|v| v.get("install_path"))
1561        .and_then(Value::as_str)
1562        .unwrap_or("-");
1563    let routines_enabled = meta
1564        .get("routines_enabled")
1565        .and_then(Value::as_bool)
1566        .unwrap_or(false);
1567    let registered_servers = meta
1568        .get("registered_servers")
1569        .and_then(Value::as_array)
1570        .map(|rows| {
1571            rows.iter()
1572                .filter_map(Value::as_str)
1573                .map(|v| format!("- {}", v))
1574                .collect::<Vec<_>>()
1575        })
1576        .unwrap_or_default();
1577    let routines = meta
1578        .get("routines_registered")
1579        .and_then(Value::as_array)
1580        .map(|rows| {
1581            rows.iter()
1582                .filter_map(Value::as_str)
1583                .map(|v| format!("- {}", v))
1584                .collect::<Vec<_>>()
1585        })
1586        .unwrap_or_default();
1587
1588    let mut lines = vec![
1589        "Pack Builder Apply Complete".to_string(),
1590        format!("- Installed pack: {} ({})", pack_name, pack_id),
1591        format!("- Install path: {}", install_path),
1592        format!(
1593            "- Routines: {}",
1594            if routines_enabled {
1595                "enabled"
1596            } else {
1597                "paused"
1598            }
1599        ),
1600    ];
1601
1602    if registered_servers.is_empty() {
1603        lines.push("- Registered connectors: none".to_string());
1604    } else {
1605        lines.push("- Registered connectors:".to_string());
1606        lines.extend(registered_servers);
1607    }
1608    if !routines.is_empty() {
1609        lines.push("- Registered routines:".to_string());
1610        lines.extend(routines);
1611    }
1612
1613    lines.join("\n")
1614}
1615
1616fn resolve_pack_builder_workflows_path() -> PathBuf {
1617    if let Ok(dir) = std::env::var("TANDEM_STATE_DIR") {
1618        let trimmed = dir.trim();
1619        if !trimmed.is_empty() {
1620            return PathBuf::from(trimmed).join("pack_builder_workflows.json");
1621        }
1622    }
1623    if let Some(data_dir) = dirs::data_dir() {
1624        return data_dir
1625            .join("tandem")
1626            .join("data")
1627            .join("pack_builder_workflows.json");
1628    }
1629    dirs::home_dir()
1630        .unwrap_or_else(|| PathBuf::from("."))
1631        .join(".tandem")
1632        .join("data")
1633        .join("pack_builder_workflows.json")
1634}
1635
1636fn resolve_pack_builder_plans_path() -> PathBuf {
1637    if let Ok(dir) = std::env::var("TANDEM_STATE_DIR") {
1638        let trimmed = dir.trim();
1639        if !trimmed.is_empty() {
1640            return PathBuf::from(trimmed).join("pack_builder_plans.json");
1641        }
1642    }
1643    if let Some(data_dir) = dirs::data_dir() {
1644        return data_dir
1645            .join("tandem")
1646            .join("data")
1647            .join("pack_builder_plans.json");
1648    }
1649    dirs::home_dir()
1650        .unwrap_or_else(|| PathBuf::from("."))
1651        .join(".tandem")
1652        .join("data")
1653        .join("pack_builder_plans.json")
1654}
1655
1656/// Returns the directory for persistent pack zip staging.
1657/// Zips are stored here (not in temp_dir) so they survive until apply() runs.
1658fn resolve_pack_builder_zips_dir() -> PathBuf {
1659    if let Ok(dir) = std::env::var("TANDEM_STATE_DIR") {
1660        let trimmed = dir.trim();
1661        if !trimmed.is_empty() {
1662            return PathBuf::from(trimmed).join("pack_builder_zips");
1663        }
1664    }
1665    if let Some(data_dir) = dirs::data_dir() {
1666        return data_dir
1667            .join("tandem")
1668            .join("data")
1669            .join("pack_builder_zips");
1670    }
1671    dirs::home_dir()
1672        .unwrap_or_else(|| PathBuf::from("."))
1673        .join(".tandem")
1674        .join("data")
1675        .join("pack_builder_zips")
1676}
1677
1678fn load_workflows(path: &PathBuf) -> HashMap<String, WorkflowRecord> {
1679    let Ok(bytes) = fs::read(path) else {
1680        return HashMap::new();
1681    };
1682    serde_json::from_slice::<HashMap<String, WorkflowRecord>>(&bytes).unwrap_or_default()
1683}
1684
1685fn save_workflows(path: &PathBuf, workflows: &HashMap<String, WorkflowRecord>) {
1686    if let Some(parent) = path.parent() {
1687        let _ = fs::create_dir_all(parent);
1688    }
1689    if let Ok(bytes) = serde_json::to_vec_pretty(workflows) {
1690        let _ = fs::write(path, bytes);
1691    }
1692}
1693
1694fn load_plans(path: &PathBuf) -> HashMap<String, PreparedPlan> {
1695    let Ok(bytes) = fs::read(path) else {
1696        return HashMap::new();
1697    };
1698    serde_json::from_slice::<HashMap<String, PreparedPlan>>(&bytes).unwrap_or_default()
1699}
1700
1701fn save_plans(path: &PathBuf, plans: &HashMap<String, PreparedPlan>) {
1702    if let Some(parent) = path.parent() {
1703        let _ = fs::create_dir_all(parent);
1704    }
1705    if let Ok(bytes) = serde_json::to_vec_pretty(plans) {
1706        let _ = fs::write(path, bytes);
1707    }
1708}
1709
1710fn now_ms() -> u64 {
1711    SystemTime::now()
1712        .duration_since(UNIX_EPOCH)
1713        .map(|d| d.as_millis() as u64)
1714        .unwrap_or(0)
1715}
1716
1717fn retain_recent_workflows(workflows: &mut HashMap<String, WorkflowRecord>, keep: usize) {
1718    if workflows.len() <= keep {
1719        return;
1720    }
1721    let mut rows = workflows
1722        .iter()
1723        .map(|(key, value)| (key.clone(), value.updated_at_ms))
1724        .collect::<Vec<_>>();
1725    rows.sort_by(|a, b| b.1.cmp(&a.1));
1726    let keep_keys = rows
1727        .into_iter()
1728        .take(keep)
1729        .map(|(key, _)| key)
1730        .collect::<BTreeSet<_>>();
1731    workflows.retain(|key, _| keep_keys.contains(key));
1732}
1733
1734fn retain_recent_plans(plans: &mut HashMap<String, PreparedPlan>, keep: usize) {
1735    if plans.len() <= keep {
1736        return;
1737    }
1738    let mut rows = plans
1739        .iter()
1740        .map(|(key, value)| {
1741            (
1742                key.clone(),
1743                value.created_at_ms,
1744                value.generated_zip_path.clone(),
1745            )
1746        })
1747        .collect::<Vec<_>>();
1748    rows.sort_by(|a, b| b.1.cmp(&a.1));
1749    let mut keep_keys = BTreeSet::<String>::new();
1750    let mut evict_zips = Vec::<PathBuf>::new();
1751    for (i, (key, _, zip_path)) in rows.iter().enumerate() {
1752        if i < keep {
1753            keep_keys.insert(key.clone());
1754        } else {
1755            evict_zips.push(zip_path.clone());
1756        }
1757    }
1758    plans.retain(|key, _| keep_keys.contains(key));
1759    // Best-effort removal of the staging directories for evicted plans
1760    for zip in evict_zips {
1761        if let Some(stage_dir) = zip.parent() {
1762            let _ = fs::remove_dir_all(stage_dir);
1763        }
1764    }
1765}
1766
1767fn session_thread_scope_key(session_id: &str, thread_key: Option<&str>) -> String {
1768    let thread = thread_key.unwrap_or_default().trim();
1769    if thread.is_empty() {
1770        return session_id.trim().to_string();
1771    }
1772    format!("{}::{}", session_id.trim(), thread)
1773}
1774
1775fn workflow_status_label(status: &WorkflowStatus) -> &'static str {
1776    match status {
1777        WorkflowStatus::PreviewPending => "preview_pending",
1778        WorkflowStatus::ApplyBlockedMissingSecrets => "apply_blocked_missing_secrets",
1779        WorkflowStatus::ApplyBlockedAuth => "apply_blocked_auth",
1780        WorkflowStatus::ApplyComplete => "apply_complete",
1781        WorkflowStatus::Cancelled => "cancelled",
1782        WorkflowStatus::Error => "error",
1783    }
1784}
1785
1786fn infer_surface(thread_key: Option<&str>) -> &'static str {
1787    let key = thread_key.unwrap_or_default().to_lowercase();
1788    if key.starts_with("telegram:") {
1789        "telegram"
1790    } else if key.starts_with("discord:") {
1791        "discord"
1792    } else if key.starts_with("slack:") {
1793        "slack"
1794    } else if key.starts_with("desktop:") || key.starts_with("tauri:") {
1795        "tauri"
1796    } else if key.starts_with("web:") || key.starts_with("control-panel:") {
1797        "web"
1798    } else {
1799        "unknown"
1800    }
1801}
1802
1803fn build_preview_next_actions(
1804    connector_selection_required: bool,
1805    required_secrets: &[String],
1806    has_connector_registration: bool,
1807) -> Vec<String> {
1808    let mut actions = Vec::new();
1809    if connector_selection_required {
1810        actions.push("Select connector(s) before applying.".to_string());
1811    }
1812    if !required_secrets.is_empty() {
1813        actions.push("Set required secrets in engine settings/environment.".to_string());
1814    }
1815    if has_connector_registration {
1816        actions.push("Confirm connector registration and pack install.".to_string());
1817    } else {
1818        actions.push("Apply to install the generated pack.".to_string());
1819    }
1820    actions
1821}
1822
1823fn secret_refs_confirmed(confirmed: &Option<Value>, required: &[String]) -> bool {
1824    if required.is_empty() {
1825        return true;
1826    }
1827    if env_has_all_required_secrets(required) {
1828        return true;
1829    }
1830    let Some(value) = confirmed else {
1831        return false;
1832    };
1833    if value.as_bool() == Some(true) {
1834        return true;
1835    }
1836    let Some(rows) = value.as_array() else {
1837        return false;
1838    };
1839    let confirmed = rows
1840        .iter()
1841        .filter_map(Value::as_str)
1842        .map(|v| v.trim().to_ascii_uppercase())
1843        .collect::<BTreeSet<_>>();
1844    required
1845        .iter()
1846        .all(|item| confirmed.contains(&item.to_ascii_uppercase()))
1847}
1848
1849fn env_has_all_required_secrets(required: &[String]) -> bool {
1850    required.iter().all(|key| {
1851        std::env::var(key)
1852            .ok()
1853            .map(|v| !v.trim().is_empty())
1854            .unwrap_or(false)
1855    })
1856}
1857
1858fn build_schedule(input: Option<&PreviewScheduleInput>) -> (RoutineSchedule, String, String) {
1859    let timezone = input
1860        .and_then(|v| v.timezone.as_deref())
1861        .filter(|v| !v.trim().is_empty())
1862        .unwrap_or("UTC")
1863        .to_string();
1864
1865    if let Some(cron) = input
1866        .and_then(|v| v.cron.as_deref())
1867        .map(str::trim)
1868        .filter(|v| !v.is_empty())
1869    {
1870        return (
1871            RoutineSchedule::Cron {
1872                expression: cron.to_string(),
1873            },
1874            "cron".to_string(),
1875            timezone,
1876        );
1877    }
1878
1879    let seconds = input
1880        .and_then(|v| v.interval_seconds)
1881        .unwrap_or(86_400)
1882        .clamp(30, 31_536_000);
1883
1884    (
1885        RoutineSchedule::IntervalSeconds { seconds },
1886        format!("every_{}_seconds", seconds),
1887        timezone,
1888    )
1889}
1890
1891fn build_allowed_tools(mcp_tools: &[String], needs: &[CapabilityNeed]) -> Vec<String> {
1892    let mut out = BTreeSet::<String>::new();
1893    for tool in mcp_tools {
1894        out.insert(tool.clone());
1895    }
1896    out.insert("question".to_string());
1897    if needs.iter().any(|n| !n.external) {
1898        out.insert("read".to_string());
1899        out.insert("write".to_string());
1900    }
1901    if needs
1902        .iter()
1903        .any(|n| n.id.contains("news") || n.id.contains("headline"))
1904    {
1905        out.insert("websearch".to_string());
1906        out.insert("webfetch".to_string());
1907    }
1908    out.into_iter().collect()
1909}
1910
1911fn render_mission_yaml(mission_id: &str, mcp_tools: &[String], needs: &[CapabilityNeed]) -> String {
1912    let mut lines = vec![
1913        format!("id: {}", mission_id),
1914        "title: Generated Pack Builder Mission".to_string(),
1915        "steps:".to_string(),
1916    ];
1917
1918    let mut step_idx = 1usize;
1919    for tool in mcp_tools {
1920        lines.push(format!("  - id: step_{}", step_idx));
1921        lines.push(format!("    action: {}", tool));
1922        step_idx += 1;
1923    }
1924
1925    if mcp_tools.is_empty() {
1926        lines.push("  - id: step_1".to_string());
1927        lines.push("    action: websearch".to_string());
1928    }
1929
1930    for need in needs {
1931        lines.push(format!("  - id: verify_{}", namespace_segment(&need.id)));
1932        lines.push("    action: question".to_string());
1933        lines.push("    optional: true".to_string());
1934    }
1935
1936    lines.join("\n") + "\n"
1937}
1938
1939fn render_agent_md(mcp_tools: &[String], goal: &str) -> String {
1940    let mut lines = vec![
1941        "---".to_string(),
1942        "name: default".to_string(),
1943        "description: Generated MCP-first pack agent".to_string(),
1944        "---".to_string(),
1945        "".to_string(),
1946        "You are the Pack Builder runtime agent for this routine.".to_string(),
1947        format!("Mission goal: {}", goal),
1948        "Use the mission steps exactly and invoke the discovered MCP tools explicitly.".to_string(),
1949        "".to_string(),
1950        "Discovered MCP tool IDs: ".to_string(),
1951    ];
1952
1953    if mcp_tools.is_empty() {
1954        lines
1955            .push("- (none discovered; fallback to built-ins is allowed for this run)".to_string());
1956    } else {
1957        for tool in mcp_tools {
1958            lines.push(format!("- {}", tool));
1959        }
1960    }
1961
1962    lines.push("".to_string());
1963    lines.push("If a required connector is missing or unauthorized, report it and stop before side effects.".to_string());
1964    lines.join("\n") + "\n"
1965}
1966
1967fn render_routine_yaml(
1968    routine_id: &str,
1969    schedule: &RoutineSchedule,
1970    schedule_label: &str,
1971    timezone: &str,
1972    allowed_tools: &[String],
1973) -> String {
1974    let mut lines = vec![format!("id: {}", routine_id), "trigger:".to_string()];
1975
1976    match schedule {
1977        RoutineSchedule::Cron { expression } => {
1978            lines.push("  type: cron".to_string());
1979            lines.push(format!("  expression: \"{}\"", expression));
1980        }
1981        RoutineSchedule::IntervalSeconds { seconds } => {
1982            lines.push("  type: interval_seconds".to_string());
1983            lines.push(format!("  seconds: {}", seconds));
1984        }
1985    }
1986    lines.push("mission_id: default".to_string());
1987    lines.push("enabled_by_default: false".to_string());
1988    lines.push("".to_string());
1989
1990    lines.push(format!("routine_id: {}", routine_id));
1991    lines.push(format!("name: {}", schedule_label));
1992    lines.push(format!("timezone: {}", timezone));
1993    match schedule {
1994        RoutineSchedule::Cron { expression } => {
1995            lines.push("schedule:".to_string());
1996            lines.push(format!("  cron: \"{}\"", expression));
1997        }
1998        RoutineSchedule::IntervalSeconds { seconds } => {
1999            lines.push("schedule:".to_string());
2000            lines.push(format!("  interval_seconds: {}", seconds));
2001        }
2002    }
2003    lines.push("entrypoint: mission.default".to_string());
2004    lines.push("allowed_tools:".to_string());
2005    for tool in allowed_tools {
2006        lines.push(format!("  - {}", tool));
2007    }
2008    lines.push("output_targets:".to_string());
2009    lines.push(format!("  - run/{}/report.md", routine_id));
2010    lines.push("requires_approval: false".to_string());
2011    lines.push("external_integrations_allowed: true".to_string());
2012    lines.join("\n") + "\n"
2013}
2014
2015fn render_manifest_yaml(
2016    pack_id: &str,
2017    pack_name: &str,
2018    version: &str,
2019    required: &[String],
2020    optional: &[String],
2021    mission_id: &str,
2022    routine_id: &str,
2023) -> String {
2024    let mut lines = vec![
2025        "manifest_schema_version: 1".to_string(),
2026        format!("pack_id: \"{}\"", pack_id),
2027        format!("name: {}", pack_name),
2028        format!("version: {}", version),
2029        "type: workflow".to_string(),
2030        "entrypoints:".to_string(),
2031        format!("  missions: [\"{}\"]", mission_id),
2032        format!("  routines: [\"{}\"]", routine_id),
2033        "capabilities:".to_string(),
2034        "  required:".to_string(),
2035    ];
2036
2037    if required.is_empty() {
2038        lines.push("    - websearch".to_string());
2039    } else {
2040        for cap in required {
2041            lines.push(format!("    - {}", cap));
2042        }
2043    }
2044
2045    lines.push("  optional:".to_string());
2046    for cap in optional {
2047        lines.push(format!("    - {}", cap));
2048    }
2049    if optional.is_empty() {
2050        lines.push("    - question".to_string());
2051    }
2052
2053    lines.push("contents:".to_string());
2054    lines.push("  agents:".to_string());
2055    lines.push("    - id: default".to_string());
2056    lines.push("      path: agents/default.md".to_string());
2057    lines.push("  missions:".to_string());
2058    lines.push(format!("    - id: {}", mission_id));
2059    lines.push("      path: missions/default.yaml".to_string());
2060    lines.push("  routines:".to_string());
2061    lines.push(format!("    - id: {}", routine_id));
2062    lines.push("      path: routines/default.yaml".to_string());
2063    lines.join("\n") + "\n"
2064}
2065
2066fn infer_capabilities_from_goal(goal: &str) -> Vec<CapabilityNeed> {
2067    let g = goal.to_ascii_lowercase();
2068    let mut out = Vec::<CapabilityNeed>::new();
2069    let push_need = |id: &str, external: bool, terms: &[&str], out: &mut Vec<CapabilityNeed>| {
2070        if out.iter().any(|n| n.id == id) {
2071            return;
2072        }
2073        out.push(CapabilityNeed {
2074            id: id.to_string(),
2075            external,
2076            query_terms: terms.iter().map(|v| v.to_string()).collect(),
2077        });
2078    };
2079
2080    if g.contains("notion") {
2081        push_need("notion.read_write", true, &["notion"], &mut out);
2082    }
2083    if g.contains("slack") {
2084        push_need("slack.post_message", true, &["slack"], &mut out);
2085    }
2086    if g.contains("stripe") || g.contains("payment") {
2087        push_need("stripe.read_write", true, &["stripe"], &mut out);
2088    }
2089    if g.contains("github") || g.contains("pr") {
2090        push_need("github.read_write", true, &["github"], &mut out);
2091    }
2092    if g.contains("headline") || g.contains("news") {
2093        push_need("news.latest", true, &["news", "zapier"], &mut out);
2094    }
2095    if g.contains("email") || contains_email_address(goal) {
2096        push_need("email.send", true, &["gmail", "email", "zapier"], &mut out);
2097    }
2098
2099    push_need("question.ask", false, &["question"], &mut out);
2100    if out.len() == 1 {
2101        push_need("web.research", false, &["websearch"], &mut out);
2102    }
2103    out
2104}
2105
2106fn contains_email_address(text: &str) -> bool {
2107    text.split_whitespace().any(|token| {
2108        let token = token.trim_matches(|ch: char| {
2109            ch.is_ascii_punctuation() && ch != '@' && ch != '.' && ch != '_' && ch != '-'
2110        });
2111        let mut parts = token.split('@');
2112        let local = parts.next().unwrap_or_default();
2113        let domain = parts.next().unwrap_or_default();
2114        let no_extra = parts.next().is_none();
2115        no_extra
2116            && !local.is_empty()
2117            && domain.contains('.')
2118            && domain
2119                .chars()
2120                .all(|ch| ch.is_ascii_alphanumeric() || ch == '.' || ch == '-')
2121    })
2122}
2123
2124fn is_confirmation_goal_text(text: &str) -> bool {
2125    let trimmed = text.trim();
2126    if trimmed.is_empty() {
2127        return false;
2128    }
2129    let lower = trimmed.to_ascii_lowercase();
2130    matches!(
2131        lower.as_str(),
2132        "ok" | "okay"
2133            | "yes"
2134            | "y"
2135            | "confirm"
2136            | "confirmed"
2137            | "approve"
2138            | "approved"
2139            | "go"
2140            | "go ahead"
2141            | "proceed"
2142            | "do it"
2143            | "ship it"
2144            | "run it"
2145            | "apply"
2146    )
2147}
2148
2149fn catalog_servers() -> Vec<CatalogServer> {
2150    let mut out = Vec::<CatalogServer>::new();
2151    let Some(index) = mcp_catalog::index() else {
2152        return out;
2153    };
2154    let rows = index
2155        .get("servers")
2156        .and_then(Value::as_array)
2157        .cloned()
2158        .unwrap_or_default();
2159    for row in rows {
2160        let slug = row.get("slug").and_then(Value::as_str).unwrap_or("").trim();
2161        if slug.is_empty() {
2162            continue;
2163        }
2164        let transport = row
2165            .get("transport_url")
2166            .and_then(Value::as_str)
2167            .unwrap_or("")
2168            .trim()
2169            .to_string();
2170        let tool_names = row
2171            .get("tool_names")
2172            .and_then(Value::as_array)
2173            .map(|vals| {
2174                vals.iter()
2175                    .filter_map(Value::as_str)
2176                    .map(|s| s.to_string())
2177                    .collect::<Vec<_>>()
2178            })
2179            .unwrap_or_default();
2180        out.push(CatalogServer {
2181            slug: slug.to_string(),
2182            name: row
2183                .get("name")
2184                .and_then(Value::as_str)
2185                .unwrap_or(slug)
2186                .to_string(),
2187            description: row
2188                .get("description")
2189                .and_then(Value::as_str)
2190                .unwrap_or("")
2191                .to_string(),
2192            documentation_url: row
2193                .get("documentation_url")
2194                .and_then(Value::as_str)
2195                .unwrap_or("")
2196                .to_string(),
2197            transport_url: transport,
2198            requires_auth: row
2199                .get("requires_auth")
2200                .and_then(Value::as_bool)
2201                .unwrap_or(false),
2202            requires_setup: row
2203                .get("requires_setup")
2204                .and_then(Value::as_bool)
2205                .unwrap_or(false),
2206            tool_names,
2207        });
2208    }
2209    out
2210}
2211
2212fn score_candidates_for_need(
2213    catalog: &[CatalogServer],
2214    need: &CapabilityNeed,
2215) -> Vec<ConnectorCandidate> {
2216    let mut out = Vec::<ConnectorCandidate>::new();
2217    for server in catalog {
2218        let mut score = 0usize;
2219        let hay = format!(
2220            "{} {} {} {}",
2221            server.slug,
2222            server.name.to_ascii_lowercase(),
2223            server.description.to_ascii_lowercase(),
2224            server.tool_names.join(" ").to_ascii_lowercase()
2225        );
2226        for term in &need.query_terms {
2227            if hay.contains(&term.to_ascii_lowercase()) {
2228                score += 3;
2229            }
2230        }
2231        if need.id.contains("news") && hay.contains("news") {
2232            score += 4;
2233        }
2234        if score == 0 {
2235            continue;
2236        }
2237        out.push(ConnectorCandidate {
2238            slug: server.slug.clone(),
2239            name: server.name.clone(),
2240            description: server.description.clone(),
2241            documentation_url: server.documentation_url.clone(),
2242            transport_url: server.transport_url.clone(),
2243            requires_auth: server.requires_auth,
2244            requires_setup: server.requires_setup,
2245            tool_count: server.tool_names.len(),
2246            score,
2247        });
2248    }
2249    out
2250}
2251
2252fn should_auto_select_connector(need: &CapabilityNeed, candidate: &ConnectorCandidate) -> bool {
2253    match need.id.as_str() {
2254        "email.send" => {
2255            if candidate.score < 6 {
2256                return false;
2257            }
2258            let hay = format!(
2259                "{} {} {}",
2260                candidate.slug.to_ascii_lowercase(),
2261                candidate.name.to_ascii_lowercase(),
2262                candidate.description.to_ascii_lowercase()
2263            );
2264            let looks_like_marketing = ["crm", "campaign", "marketing", "sales"]
2265                .iter()
2266                .any(|term| hay.contains(term));
2267            let looks_like_mail_delivery = [
2268                "email",
2269                "mail",
2270                "gmail",
2271                "smtp",
2272                "sendgrid",
2273                "mailgun",
2274                "outlook",
2275                "office365",
2276            ]
2277            .iter()
2278            .any(|term| hay.contains(term));
2279            if looks_like_marketing && !looks_like_mail_delivery {
2280                return false;
2281            }
2282            true
2283        }
2284        _ => true,
2285    }
2286}
2287
2288async fn available_builtin_tools(state: &AppState) -> BTreeSet<String> {
2289    state
2290        .tools
2291        .list()
2292        .await
2293        .into_iter()
2294        .map(|schema| schema.name)
2295        .filter(|name| !name.starts_with("mcp."))
2296        .collect()
2297}
2298
2299fn need_satisfied_by_builtin(builtin_tools: &BTreeSet<String>, need: &CapabilityNeed) -> bool {
2300    let has = |name: &str| builtin_tools.contains(name);
2301    match need.id.as_str() {
2302        "news.latest" | "web.research" => has("websearch") && has("webfetch"),
2303        "question.ask" => has("question"),
2304        _ => false,
2305    }
2306}
2307
2308fn derive_required_secret_refs_for_selected(
2309    catalog: &[CatalogServer],
2310    selected_connectors: &[String],
2311) -> Vec<String> {
2312    let mut refs = BTreeSet::<String>::new();
2313    for slug in selected_connectors {
2314        if let Some(connector) = catalog.iter().find(|row| &row.slug == slug) {
2315            if !connector.requires_auth {
2316                continue;
2317            }
2318            refs.insert(format!(
2319                "{}_TOKEN",
2320                connector.slug.to_ascii_uppercase().replace('-', "_")
2321            ));
2322        }
2323    }
2324    refs.into_iter().collect()
2325}
2326
2327fn goal_to_slug(goal: &str) -> String {
2328    let mut out = String::new();
2329    for ch in goal.chars() {
2330        if ch.is_ascii_alphanumeric() {
2331            out.push(ch.to_ascii_lowercase());
2332        } else if !out.ends_with('-') {
2333            out.push('-');
2334        }
2335        if out.len() >= 42 {
2336            break;
2337        }
2338    }
2339    let trimmed = out.trim_matches('-');
2340    if trimmed.is_empty() {
2341        "automation".to_string()
2342    } else {
2343        trimmed.to_string()
2344    }
2345}
2346
2347fn namespace_segment(raw: &str) -> String {
2348    let mut out = String::new();
2349    let mut prev_sep = false;
2350    for ch in raw.trim().chars() {
2351        if ch.is_ascii_alphanumeric() {
2352            out.push(ch.to_ascii_lowercase());
2353            prev_sep = false;
2354        } else if !prev_sep {
2355            out.push('_');
2356            prev_sep = true;
2357        }
2358    }
2359    let trimmed = out.trim_matches('_');
2360    if trimmed.is_empty() {
2361        "tool".to_string()
2362    } else {
2363        trimmed.to_string()
2364    }
2365}
2366
2367async fn sync_mcp_tools_for_server(state: &AppState, name: &str) -> usize {
2368    let prefix = format!("mcp.{}.", namespace_segment(name));
2369    state.tools.unregister_by_prefix(&prefix).await;
2370    let tools = state.mcp.server_tools(name).await;
2371    for tool in &tools {
2372        let schema = ToolSchema::new(
2373            tool.namespaced_name.clone(),
2374            if tool.description.trim().is_empty() {
2375                format!("MCP tool {} from {}", tool.tool_name, tool.server_name)
2376            } else {
2377                tool.description.clone()
2378            },
2379            tool.input_schema.clone(),
2380        );
2381        state
2382            .tools
2383            .register_tool(
2384                schema.name.clone(),
2385                Arc::new(McpBridgeTool {
2386                    schema,
2387                    mcp: state.mcp.clone(),
2388                    server_name: tool.server_name.clone(),
2389                    tool_name: tool.tool_name.clone(),
2390                }),
2391            )
2392            .await;
2393    }
2394    tools.len()
2395}
2396
2397fn save_pack_preset(plan: &PreparedPlan, registered_servers: &[String]) -> anyhow::Result<PathBuf> {
2398    let paths = tandem_core::resolve_shared_paths().context("resolve shared paths")?;
2399    let dir = paths
2400        .canonical_root
2401        .join("presets")
2402        .join("overrides")
2403        .join("pack_presets");
2404    fs::create_dir_all(&dir)?;
2405    let path = dir.join(format!("{}.yaml", plan.pack_id));
2406
2407    let mut content = String::new();
2408    content.push_str(&format!("id: {}\n", plan.pack_id));
2409    content.push_str(&format!("version: {}\n", plan.version));
2410    content.push_str("kind: pack_preset\n");
2411    content.push_str("pack:\n");
2412    content.push_str(&format!("  pack_id: {}\n", plan.pack_id));
2413    content.push_str(&format!("  name: {}\n", plan.pack_name));
2414    content.push_str(&format!(
2415        "  goal: |\n    {}\n",
2416        plan.goal.replace('\n', "\n    ")
2417    ));
2418    content.push_str("connectors:\n");
2419    for row in &plan.recommended_connectors {
2420        let selected = registered_servers.iter().any(|v| v == &row.slug);
2421        content.push_str(&format!("  - slug: {}\n", row.slug));
2422        content.push_str(&format!("    name: {}\n", row.name));
2423        content.push_str(&format!(
2424            "    documentation_url: {}\n",
2425            row.documentation_url
2426        ));
2427        content.push_str(&format!("    transport_url: {}\n", row.transport_url));
2428        content.push_str(&format!("    requires_auth: {}\n", row.requires_auth));
2429        content.push_str(&format!("    selected: {}\n", selected));
2430    }
2431    content.push_str("registered_servers:\n");
2432    for srv in registered_servers {
2433        content.push_str(&format!("  - {}\n", srv));
2434    }
2435    content.push_str("required_credentials:\n");
2436    for sec in &plan.required_secrets {
2437        content.push_str(&format!("  - {}\n", sec));
2438    }
2439    content.push_str("selected_mcp_tools:\n");
2440    for tool in &plan.selected_mcp_tools {
2441        content.push_str(&format!("  - {}\n", tool));
2442    }
2443
2444    fs::write(&path, content)?;
2445    Ok(path)
2446}
2447
2448fn zip_dir(src_dir: &PathBuf, output_zip: &PathBuf) -> anyhow::Result<()> {
2449    let file =
2450        File::create(output_zip).with_context(|| format!("create {}", output_zip.display()))?;
2451    let mut zip = zip::ZipWriter::new(file);
2452    let opts = zip::write::SimpleFileOptions::default()
2453        .compression_method(zip::CompressionMethod::Deflated)
2454        .unix_permissions(0o644);
2455
2456    let mut stack = vec![src_dir.clone()];
2457    while let Some(current) = stack.pop() {
2458        let mut entries = fs::read_dir(&current)?
2459            .filter_map(|e| e.ok())
2460            .collect::<Vec<_>>();
2461        entries.sort_by_key(|e| e.path());
2462        for entry in entries {
2463            let path = entry.path();
2464            let rel = path
2465                .strip_prefix(src_dir)
2466                .context("strip prefix")?
2467                .to_string_lossy()
2468                .replace('\\', "/");
2469            if path.is_dir() {
2470                if !rel.is_empty() {
2471                    zip.add_directory(format!("{}/", rel), opts)?;
2472                }
2473                stack.push(path);
2474                continue;
2475            }
2476            zip.start_file(rel, opts)?;
2477            let bytes = fs::read(&path)?;
2478            zip.write_all(&bytes)?;
2479        }
2480    }
2481    zip.finish()?;
2482    Ok(())
2483}
2484
2485#[cfg(test)]
2486mod tests {
2487    use super::*;
2488
2489    #[test]
2490    fn email_send_does_not_auto_select_low_confidence_connector() {
2491        let need = CapabilityNeed {
2492            id: "email.send".to_string(),
2493            external: true,
2494            query_terms: vec!["email".to_string()],
2495        };
2496        let candidate = ConnectorCandidate {
2497            slug: "activecampaign".to_string(),
2498            name: "ActiveCampaign".to_string(),
2499            description: "Marketing automation and CRM workflows".to_string(),
2500            documentation_url: String::new(),
2501            transport_url: String::new(),
2502            requires_auth: true,
2503            requires_setup: false,
2504            tool_count: 5,
2505            score: 3,
2506        };
2507        assert!(!should_auto_select_connector(&need, &candidate));
2508    }
2509
2510    #[test]
2511    fn email_send_allows_high_confidence_mail_connector() {
2512        let need = CapabilityNeed {
2513            id: "email.send".to_string(),
2514            external: true,
2515            query_terms: vec!["email".to_string()],
2516        };
2517        let candidate = ConnectorCandidate {
2518            slug: "gmail".to_string(),
2519            name: "Gmail".to_string(),
2520            description: "Send and manage email messages".to_string(),
2521            documentation_url: String::new(),
2522            transport_url: String::new(),
2523            requires_auth: true,
2524            requires_setup: false,
2525            tool_count: 8,
2526            score: 9,
2527        };
2528        assert!(should_auto_select_connector(&need, &candidate));
2529    }
2530
2531    #[test]
2532    fn build_pack_builder_automation_mirrors_routine_template() {
2533        let plan = PreparedPlan {
2534            plan_id: "plan-pack-builder-test".to_string(),
2535            goal: "Create a daily digest pack".to_string(),
2536            pack_id: "daily_digest_pack".to_string(),
2537            pack_name: "Daily Digest Pack".to_string(),
2538            version: "0.1.0".to_string(),
2539            capabilities_required: vec!["web.search".to_string()],
2540            capabilities_optional: Vec::new(),
2541            recommended_connectors: Vec::new(),
2542            selected_connector_slugs: Vec::new(),
2543            selected_mcp_tools: Vec::new(),
2544            fallback_warnings: Vec::new(),
2545            required_secrets: Vec::new(),
2546            generated_zip_path: PathBuf::from("/tmp/daily-digest-pack.zip"),
2547            routine_ids: vec!["routine.daily_digest_pack".to_string()],
2548            routine_template: RoutineTemplate {
2549                routine_id: "routine.daily_digest_pack".to_string(),
2550                name: "Daily Digest Pack".to_string(),
2551                timezone: "UTC".to_string(),
2552                schedule: RoutineSchedule::Cron {
2553                    expression: "0 8 * * *".to_string(),
2554                },
2555                entrypoint: "packs/daily_digest_pack/run".to_string(),
2556                allowed_tools: vec!["web_search".to_string(), "write_file".to_string()],
2557            },
2558            created_at_ms: 0,
2559        };
2560
2561        let automation = build_pack_builder_automation(
2562            &plan,
2563            "routine.daily_digest_pack",
2564            "team",
2565            6,
2566            &["slack".to_string(), "github".to_string()],
2567            true,
2568        );
2569
2570        assert_eq!(
2571            automation.automation_id,
2572            "automation.routine.daily_digest_pack"
2573        );
2574        assert_eq!(automation.status, crate::AutomationV2Status::Paused);
2575        assert_eq!(
2576            automation.schedule.schedule_type,
2577            crate::AutomationV2ScheduleType::Cron
2578        );
2579        assert_eq!(
2580            automation.schedule.cron_expression.as_deref(),
2581            Some("0 8 * * *")
2582        );
2583        assert_eq!(automation.agents.len(), 1);
2584        assert_eq!(automation.flow.nodes.len(), 1);
2585        assert_eq!(automation.flow.nodes[0].node_id, "pack_builder_execute");
2586        assert_eq!(
2587            automation.flow.nodes[0]
2588                .output_contract
2589                .as_ref()
2590                .map(|contract| contract.validator.clone()),
2591            Some(Some(crate::AutomationOutputValidatorKind::GenericArtifact))
2592        );
2593        assert_eq!(
2594            automation
2595                .metadata
2596                .as_ref()
2597                .and_then(|v| v.get("origin"))
2598                .and_then(|v| v.as_str()),
2599            Some("pack_builder")
2600        );
2601        assert_eq!(
2602            automation
2603                .metadata
2604                .as_ref()
2605                .and_then(|v| v.get("activation_mode"))
2606                .and_then(|v| v.as_str()),
2607            Some("routine_wrapper_mirror")
2608        );
2609        assert_eq!(
2610            automation
2611                .metadata
2612                .as_ref()
2613                .and_then(|v| v.get("routine_enabled"))
2614                .and_then(|v| v.as_bool()),
2615            Some(true)
2616        );
2617        assert_eq!(
2618            automation
2619                .metadata
2620                .as_ref()
2621                .and_then(|v| v.get("pack_builder_plan_id"))
2622                .and_then(|v| v.as_str()),
2623            Some("plan-pack-builder-test")
2624        );
2625        assert_eq!(
2626            automation
2627                .metadata
2628                .as_ref()
2629                .and_then(|v| v.get("routine_id"))
2630                .and_then(|v| v.as_str()),
2631            Some("routine.daily_digest_pack")
2632        );
2633    }
2634}