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