Skip to main content

tandem_server/pack_builder_parts/
part01.rs

1#[derive(Clone)]
2pub struct PackBuilderTool {
3    state: AppState,
4    plans: Arc<RwLock<HashMap<String, PreparedPlan>>>,
5    plans_path: PathBuf,
6    last_plan_by_session: Arc<RwLock<HashMap<String, String>>>,
7    workflows: Arc<RwLock<HashMap<String, WorkflowRecord>>>,
8    workflows_path: PathBuf,
9}
10
11impl PackBuilderTool {
12    pub fn new(state: AppState) -> Self {
13        let workflows_path = resolve_pack_builder_workflows_path();
14        let plans_path = resolve_pack_builder_plans_path();
15        Self {
16            state,
17            plans: Arc::new(RwLock::new(load_plans(&plans_path))),
18            plans_path,
19            last_plan_by_session: Arc::new(RwLock::new(HashMap::new())),
20            workflows: Arc::new(RwLock::new(load_workflows(&workflows_path))),
21            workflows_path,
22        }
23    }
24
25    async fn upsert_workflow(
26        &self,
27        event_type: &str,
28        status: WorkflowStatus,
29        plan_id: &str,
30        session_id: Option<&str>,
31        thread_key: Option<&str>,
32        goal: &str,
33        metadata: &Value,
34    ) {
35        let now = now_ms();
36        let workflow_id = format!("wf-{}", plan_id);
37        let mut workflows = self.workflows.write().await;
38        let created_at_ms = workflows
39            .get(plan_id)
40            .map(|row| row.created_at_ms)
41            .unwrap_or(now);
42        workflows.insert(
43            plan_id.to_string(),
44            WorkflowRecord {
45                workflow_id: workflow_id.clone(),
46                plan_id: plan_id.to_string(),
47                session_id: session_id.map(ToString::to_string),
48                thread_key: thread_key.map(ToString::to_string),
49                goal: goal.to_string(),
50                status: status.clone(),
51                metadata: metadata.clone(),
52                created_at_ms,
53                updated_at_ms: now,
54            },
55        );
56        retain_recent_workflows(&mut workflows, 256);
57        save_workflows(&self.workflows_path, &workflows);
58        drop(workflows);
59
60        self.state.event_bus.publish(tandem_types::EngineEvent::new(
61            event_type,
62            json!({
63                "sessionID": session_id.unwrap_or_default(),
64                "threadKey": thread_key.unwrap_or_default(),
65                "planID": plan_id,
66                "status": workflow_status_label(&status),
67                "metadata": metadata,
68            }),
69        ));
70    }
71
72    async fn resolve_plan_id_from_session(
73        &self,
74        session_id: Option<&str>,
75        thread_key: Option<&str>,
76    ) -> Option<String> {
77        if let Some(session) = session_id {
78            if let Some(thread) = thread_key {
79                let scoped_key = session_thread_scope_key(session, Some(thread));
80                if let Some(found) = self
81                    .last_plan_by_session
82                    .read()
83                    .await
84                    .get(&scoped_key)
85                    .cloned()
86                {
87                    return Some(found);
88                }
89            }
90        }
91        if let Some(session) = session_id {
92            if let Some(found) = self.last_plan_by_session.read().await.get(session).cloned() {
93                return Some(found);
94            }
95        }
96        let workflows = self.workflows.read().await;
97        let mut best: Option<(&String, u64)> = None;
98        for (plan_id, wf) in workflows.iter() {
99            if !matches!(wf.status, WorkflowStatus::PreviewPending) {
100                continue;
101            }
102            if session_id.is_some() && wf.session_id.as_deref() != session_id {
103                continue;
104            }
105            if let Some(thread) = thread_key {
106                if wf.thread_key.as_deref() != Some(thread) {
107                    continue;
108                }
109            }
110            let ts = wf.updated_at_ms;
111            if best.map(|(_, b)| ts > b).unwrap_or(true) {
112                best = Some((plan_id, ts));
113            }
114        }
115        best.map(|(plan_id, _)| plan_id.clone())
116    }
117
118    fn emit_metric(
119        &self,
120        metric: &str,
121        plan_id: &str,
122        status: &str,
123        session_id: Option<&str>,
124        thread_key: Option<&str>,
125    ) {
126        let surface = infer_surface(thread_key);
127        self.state.event_bus.publish(tandem_types::EngineEvent::new(
128            "pack_builder.metric",
129            json!({
130                "metric": metric,
131                "value": 1,
132                "surface": surface,
133                "planID": plan_id,
134                "status": status,
135                "sessionID": session_id.unwrap_or_default(),
136                "threadKey": thread_key.unwrap_or_default(),
137            }),
138        ));
139    }
140}
141
142#[derive(Debug, Clone, Deserialize, Default)]
143struct PackBuilderInput {
144    #[serde(default)]
145    mode: Option<String>,
146    #[serde(default)]
147    goal: Option<String>,
148    #[serde(default)]
149    auto_apply: Option<bool>,
150    #[serde(default)]
151    selected_connectors: Vec<String>,
152    #[serde(default)]
153    plan_id: Option<String>,
154    #[serde(default)]
155    approve_connector_registration: Option<bool>,
156    #[serde(default)]
157    approve_pack_install: Option<bool>,
158    #[serde(default)]
159    approve_enable_routines: Option<bool>,
160    #[serde(default)]
161    schedule: Option<PreviewScheduleInput>,
162    #[serde(default, rename = "__session_id")]
163    session_id: Option<String>,
164    #[serde(default)]
165    thread_key: Option<String>,
166    #[serde(default)]
167    secret_refs_confirmed: Option<Value>,
168    /// Execution architecture: "single" | "team" | "swarm"
169    /// - single: one agent loop (current default fallback)
170    /// - team: orchestrated agent team with planner + workers
171    /// - swarm: context-run swarm (parallel sub-tasks)
172    #[serde(default)]
173    execution_mode: Option<String>,
174    /// For swarm mode: max parallel sub-tasks
175    #[serde(default)]
176    max_agents: Option<u32>,
177}
178
179#[derive(Debug, Clone, Deserialize, Default)]
180struct PreviewScheduleInput {
181    #[serde(default)]
182    interval_seconds: Option<u64>,
183    #[serde(default)]
184    cron: Option<String>,
185    #[serde(default)]
186    timezone: Option<String>,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
190struct ConnectorCandidate {
191    slug: String,
192    name: String,
193    description: String,
194    documentation_url: String,
195    transport_url: String,
196    requires_auth: bool,
197    requires_setup: bool,
198    tool_count: usize,
199    score: usize,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
203struct PreparedPlan {
204    plan_id: String,
205    goal: String,
206    pack_id: String,
207    pack_name: String,
208    version: String,
209    capabilities_required: Vec<String>,
210    capabilities_optional: Vec<String>,
211    recommended_connectors: Vec<ConnectorCandidate>,
212    selected_connector_slugs: Vec<String>,
213    selected_mcp_tools: Vec<String>,
214    fallback_warnings: Vec<String>,
215    required_secrets: Vec<String>,
216    generated_zip_path: PathBuf,
217    routine_ids: Vec<String>,
218    routine_template: RoutineTemplate,
219    created_at_ms: u64,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
223#[serde(rename_all = "snake_case")]
224enum WorkflowStatus {
225    PreviewPending,
226    ApplyBlockedMissingSecrets,
227    ApplyBlockedAuth,
228    ApplyComplete,
229    Cancelled,
230    Error,
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize)]
234struct WorkflowRecord {
235    workflow_id: String,
236    plan_id: String,
237    session_id: Option<String>,
238    thread_key: Option<String>,
239    goal: String,
240    status: WorkflowStatus,
241    metadata: Value,
242    created_at_ms: u64,
243    updated_at_ms: u64,
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize)]
247struct RoutineTemplate {
248    routine_id: String,
249    name: String,
250    timezone: String,
251    schedule: RoutineSchedule,
252    entrypoint: String,
253    allowed_tools: Vec<String>,
254}
255
256fn automation_v2_schedule_from_routine(
257    schedule: &RoutineSchedule,
258    timezone: &str,
259) -> crate::AutomationV2Schedule {
260    match schedule {
261        RoutineSchedule::IntervalSeconds { seconds } => crate::AutomationV2Schedule {
262            schedule_type: crate::AutomationV2ScheduleType::Interval,
263            cron_expression: None,
264            interval_seconds: Some(*seconds),
265            timezone: timezone.to_string(),
266            misfire_policy: crate::RoutineMisfirePolicy::RunOnce,
267        },
268        RoutineSchedule::Cron { expression } => crate::AutomationV2Schedule {
269            schedule_type: crate::AutomationV2ScheduleType::Cron,
270            cron_expression: Some(expression.clone()),
271            interval_seconds: None,
272            timezone: timezone.to_string(),
273            misfire_policy: crate::RoutineMisfirePolicy::RunOnce,
274        },
275    }
276}
277
278fn build_pack_builder_automation(
279    plan: &PreparedPlan,
280    routine_id: &str,
281    execution_mode: &str,
282    max_agents: u32,
283    registered_servers: &[String],
284    routine_enabled: bool,
285) -> crate::AutomationV2Spec {
286    let now = now_ms();
287    let automation_id = format!("automation.{}", routine_id);
288    crate::AutomationV2Spec {
289        automation_id: automation_id.clone(),
290        name: format!("{} automation", plan.pack_name),
291        description: Some(format!(
292            "Pack Builder automation for `{}` generated from plan `{}`.",
293            plan.pack_name, plan.plan_id
294        )),
295        // Pack Builder still uses the routine as the active trigger wrapper today.
296        // Keep the mirrored automation paused so apply does not double-register
297        // two active schedulable runtimes for the same pack.
298        status: crate::AutomationV2Status::Paused,
299        schedule: automation_v2_schedule_from_routine(
300            &plan.routine_template.schedule,
301            &plan.routine_template.timezone,
302        ),
303        knowledge: tandem_orchestrator::KnowledgeBinding::default(),
304        agents: vec![crate::AutomationAgentProfile {
305            agent_id: "pack_builder_agent".to_string(),
306            template_id: None,
307            display_name: plan.pack_name.clone(),
308            avatar_url: None,
309            model_policy: None,
310            skills: vec![plan.pack_id.clone()],
311            tool_policy: crate::AutomationAgentToolPolicy {
312                allowlist: plan.routine_template.allowed_tools.clone(),
313                denylist: Vec::new(),
314            },
315            mcp_policy: crate::AutomationAgentMcpPolicy {
316                allowed_servers: registered_servers.to_vec(),
317                allowed_tools: None,
318            },
319            approval_policy: None,
320        }],
321        flow: crate::AutomationFlowSpec {
322            nodes: vec![crate::AutomationFlowNode {
323                node_id: "pack_builder_execute".to_string(),
324                agent_id: "pack_builder_agent".to_string(),
325                objective: format!(
326                    "Execute the installed pack `{}` for this goal: {}",
327                    plan.pack_name, plan.goal
328                ),
329                knowledge: Default::default(),
330                depends_on: Vec::new(),
331                input_refs: Vec::new(),
332                output_contract: Some(crate::AutomationFlowOutputContract {
333                    kind: "report_markdown".to_string(),
334                    validator: Some(crate::AutomationOutputValidatorKind::GenericArtifact),
335                    enforcement: None,
336                    schema: None,
337                    summary_guidance: None,
338                }),
339                retry_policy: Some(json!({ "max_attempts": 3 })),
340                timeout_ms: None,
341                max_tool_calls: None,
342                stage_kind: Some(crate::AutomationNodeStageKind::Workstream),
343                gate: None,
344                metadata: Some(json!({
345                    "builder": {
346                        "origin": "pack_builder",
347                        "task_kind": "pack_recipe",
348                        "execution_mode": execution_mode,
349                    },
350                    "pack_builder": {
351                        "pack_id": plan.pack_id,
352                        "pack_name": plan.pack_name,
353                        "plan_id": plan.plan_id,
354                        "routine_id": routine_id,
355                    }
356                })),
357            }],
358        },
359        execution: crate::AutomationExecutionPolicy {
360            max_parallel_agents: Some(max_agents.clamp(1, 16)),
361            max_total_runtime_ms: None,
362            max_total_tool_calls: None,
363            max_total_tokens: None,
364            max_total_cost_usd: None,
365        },
366        output_targets: vec![format!("run/{routine_id}/report.md")],
367        created_at_ms: now,
368        updated_at_ms: now,
369        creator_id: "pack_builder".to_string(),
370        workspace_root: None,
371        metadata: Some(json!({
372            "origin": "pack_builder",
373            "pack_builder_plan_id": plan.plan_id,
374            "pack_id": plan.pack_id,
375            "pack_name": plan.pack_name,
376            "goal": plan.goal,
377            "execution_mode": execution_mode,
378            "routine_id": routine_id,
379            "activation_mode": "routine_wrapper_mirror",
380            "routine_enabled": routine_enabled,
381            "registered_servers": registered_servers,
382        })),
383        next_fire_at_ms: None,
384        last_fired_at_ms: None,
385        scope_policy: None,
386        watch_conditions: Vec::new(),
387        handoff_config: None,
388    }
389}
390
391#[derive(Debug, Clone, Serialize, Deserialize)]
392struct CapabilityNeed {
393    id: String,
394    external: bool,
395    query_terms: Vec<String>,
396}
397
398#[derive(Debug, Clone)]
399struct CatalogServer {
400    slug: String,
401    name: String,
402    description: String,
403    documentation_url: String,
404    transport_url: String,
405    requires_auth: bool,
406    requires_setup: bool,
407    tool_names: Vec<String>,
408}
409
410#[derive(Clone)]
411struct McpBridgeTool {
412    schema: ToolSchema,
413    mcp: tandem_runtime::McpRegistry,
414    server_name: String,
415    tool_name: String,
416}
417
418#[async_trait]
419impl Tool for McpBridgeTool {
420    fn schema(&self) -> ToolSchema {
421        self.schema.clone()
422    }
423
424    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
425        self.mcp
426            .call_tool(&self.server_name, &self.tool_name, args)
427            .await
428            .map_err(anyhow::Error::msg)
429    }
430}
431
432#[async_trait]
433impl Tool for PackBuilderTool {
434    fn schema(&self) -> ToolSchema {
435        ToolSchema::new(
436            "pack_builder",
437            "MCP-first Tandem pack builder with preview/apply phases",
438            json!({
439                "type": "object",
440                "properties": {
441                    "mode": {"type": "string", "enum": ["preview", "apply", "cancel", "pending"]},
442                    "goal": {"type": "string"},
443                    "auto_apply": {"type": "boolean"},
444                    "plan_id": {"type": "string"},
445                    "thread_key": {"type": "string"},
446                    "secret_refs_confirmed": {"oneOf":[{"type":"boolean"},{"type":"array","items":{"type":"string"}}]},
447                    "selected_connectors": {"type": "array", "items": {"type": "string"}},
448                    "approve_connector_registration": {"type": "boolean"},
449                    "approve_pack_install": {"type": "boolean"},
450                    "approve_enable_routines": {"type": "boolean"},
451                    "execution_mode": {
452                        "type": "string",
453                        "enum": ["single", "team", "swarm"],
454                        "description": "Execution architecture: single agent, orchestrated team, or parallel swarm"
455                    },
456                    "max_agents": {"type": "integer", "minimum": 2, "maximum": 32},
457                    "schedule": {
458                        "type": "object",
459                        "properties": {
460                            "interval_seconds": {"type": "integer", "minimum": 30},
461                            "cron": {"type": "string"},
462                            "timezone": {"type": "string"}
463                        }
464                    }
465                },
466                "required": ["mode"]
467            }),
468        )
469    }
470
471    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
472        let mut input: PackBuilderInput = serde_json::from_value(args).unwrap_or_default();
473        let mut mode = input
474            .mode
475            .as_deref()
476            .unwrap_or("preview")
477            .trim()
478            .to_ascii_lowercase();
479
480        if mode == "apply" && input.plan_id.is_none() {
481            input.plan_id = self
482                .resolve_plan_id_from_session(
483                    input.session_id.as_deref(),
484                    input.thread_key.as_deref(),
485                )
486                .await;
487        }
488
489        if mode == "preview" {
490            let goal_text = input.goal.as_deref().map(str::trim).unwrap_or("");
491            if is_confirmation_goal_text(goal_text) {
492                if let Some(last_plan_id) = self
493                    .resolve_plan_id_from_session(
494                        input.session_id.as_deref(),
495                        input.thread_key.as_deref(),
496                    )
497                    .await
498                {
499                    input.mode = Some("apply".to_string());
500                    input.plan_id = Some(last_plan_id);
501                    input.approve_pack_install = Some(true);
502                    input.approve_connector_registration = Some(true);
503                    input.approve_enable_routines = Some(true);
504                    mode = "apply".to_string();
505                }
506            }
507        }
508
509        match mode.as_str() {
510            "cancel" => self.cancel(input).await,
511            "pending" => self.pending(input).await,
512            "apply" => self.apply(input).await,
513            _ => self.preview(input).await,
514        }
515    }
516}
517
518impl PackBuilderTool {
519    async fn preview(&self, input: PackBuilderInput) -> anyhow::Result<ToolResult> {
520        let goal = input
521            .goal
522            .as_deref()
523            .map(str::trim)
524            .filter(|v| !v.is_empty())
525            .unwrap_or("Create a useful automation pack")
526            .to_string();
527
528        let needs = infer_capabilities_from_goal(&goal);
529        let all_catalog = catalog_servers();
530        let builtin_tools = available_builtin_tools(&self.state).await;
531        let mut recommended_connectors = Vec::<ConnectorCandidate>::new();
532        let mut selected_connector_slugs = BTreeSet::<String>::new();
533        let mut selected_mcp_tools = BTreeSet::<String>::new();
534        let mut required = Vec::<String>::new();
535        let mut optional = Vec::<String>::new();
536        let mut fallback_warnings = Vec::<String>::new();
537        let mut unresolved_external_needs = Vec::<String>::new();
538        let mut resolved_needs = BTreeSet::<String>::new();
539
540        for need in &needs {
541            if need.external {
542                required.push(need.id.clone());
543            } else {
544                optional.push(need.id.clone());
545            }
546            if !need.external {
547                continue;
548            }
549            if need_satisfied_by_builtin(&builtin_tools, need) {
550                resolved_needs.insert(need.id.clone());
551                continue;
552            }
553            unresolved_external_needs.push(need.id.clone());
554            let mut candidates = score_candidates_for_need(&all_catalog, need);
555            if candidates.is_empty() {
556                fallback_warnings.push(format!(
557                    "No MCP connector found for capability `{}`. Falling back to built-in tools.",
558                    need.id
559                ));
560                continue;
561            }
562            candidates.sort_by(|a, b| b.score.cmp(&a.score).then_with(|| a.slug.cmp(&b.slug)));
563            if let Some(best) = candidates.first() {
564                if should_auto_select_connector(need, best) {
565                    selected_connector_slugs.insert(best.slug.clone());
566                    resolved_needs.insert(need.id.clone());
567                    if let Some(server) = all_catalog.iter().find(|s| s.slug == best.slug) {
568                        for tool in server.tool_names.iter().take(3) {
569                            selected_mcp_tools.insert(format!(
570                                "mcp.{}.{}",
571                                namespace_segment(&server.slug),
572                                namespace_segment(tool)
573                            ));
574                        }
575                    }
576                }
577            }
578            recommended_connectors.extend(candidates.into_iter().take(3));
579        }
580
581        recommended_connectors
582            .sort_by(|a, b| b.score.cmp(&a.score).then_with(|| a.slug.cmp(&b.slug)));
583        recommended_connectors.dedup_by(|a, b| a.slug == b.slug);
584
585        let schedule = build_schedule(input.schedule.as_ref());
586        let pack_slug = goal_to_slug(&goal);
587        let pack_id = format!("tpk_pack_builder_{}", pack_slug);
588        let pack_name = format!("pack-builder-{}", pack_slug);
589        let version = "0.4.1".to_string();
590
591        // Use the persistent state dir for staging – NOT temp_dir() which OSes
592        // clean up arbitrarily. The zip must outlive the preview phase so that
593        // apply() can still find it even if several minutes pass between the two.
594        let zips_dir = resolve_pack_builder_zips_dir();
595        fs::create_dir_all(&zips_dir)?;
596        let stage_id = Uuid::new_v4();
597        let pack_root = zips_dir.join(format!("stage-{}", stage_id)).join("pack");
598        fs::create_dir_all(pack_root.join("agents"))?;
599        fs::create_dir_all(pack_root.join("missions"))?;
600        fs::create_dir_all(pack_root.join("routines"))?;
601
602        let mission_id = "default".to_string();
603        let routine_id = "default".to_string();
604        let tool_ids = selected_mcp_tools.iter().cloned().collect::<Vec<_>>();
605        let routine_template = RoutineTemplate {
606            routine_id: format!("{}.{}", pack_id, routine_id),
607            name: format!("{} routine", pack_name),
608            timezone: schedule.2.clone(),
609            schedule: schedule.0.clone(),
610            entrypoint: "mission.default".to_string(),
611            allowed_tools: build_allowed_tools(&tool_ids, &needs),
612        };
613
614        let mission_yaml = render_mission_yaml(&mission_id, &tool_ids, &needs);
615        let agent_md = render_agent_md(&tool_ids, &goal);
616        let routine_yaml = render_routine_yaml(
617            &routine_id,
618            &schedule.0,
619            &schedule.1,
620            &schedule.2,
621            &routine_template.allowed_tools,
622        );
623        let manifest_yaml = render_manifest_yaml(
624            &pack_id,
625            &pack_name,
626            &version,
627            &required,
628            &optional,
629            &mission_id,
630            &routine_id,
631        );
632
633        fs::write(pack_root.join("missions/default.yaml"), mission_yaml)?;
634        fs::write(pack_root.join("agents/default.md"), agent_md)?;
635        fs::write(pack_root.join("routines/default.yaml"), routine_yaml)?;
636        fs::write(pack_root.join("tandempack.yaml"), manifest_yaml)?;
637        fs::write(pack_root.join("README.md"), "# Generated by pack_builder\n")?;
638
639        // Save the zip into the same persistent dir (parent of pack_root)
640        let zip_path = pack_root
641            .parent()
642            .expect("pack_root always has a parent staging dir")
643            .join(format!("{}-{}.zip", pack_name, version));
644        zip_dir(&pack_root, &zip_path)?;
645
646        let plan_id = format!("plan-{}", Uuid::new_v4());
647        let selected_connector_slugs = selected_connector_slugs.into_iter().collect::<Vec<_>>();
648        let required_secrets =
649            derive_required_secret_refs_for_selected(&all_catalog, &selected_connector_slugs);
650        let connector_selection_required = unresolved_external_needs
651            .iter()
652            .any(|need_id| !resolved_needs.contains(need_id));
653        let auto_apply_requested = input.auto_apply.unwrap_or(true);
654        let auto_apply_ready = auto_apply_requested
655            && !connector_selection_required
656            && required_secrets.is_empty()
657            && fallback_warnings.is_empty();
658
659        let prepared = PreparedPlan {
660            plan_id: plan_id.clone(),
661            goal: goal.clone(),
662            pack_id: pack_id.clone(),
663            pack_name: pack_name.clone(),
664            version,
665            capabilities_required: required.clone(),
666            capabilities_optional: optional.clone(),
667            recommended_connectors: recommended_connectors.clone(),
668            selected_connector_slugs: selected_connector_slugs.clone(),
669            selected_mcp_tools: tool_ids.clone(),
670            fallback_warnings: fallback_warnings.clone(),
671            required_secrets: required_secrets.clone(),
672            generated_zip_path: zip_path.clone(),
673            routine_ids: vec![routine_template.routine_id.clone()],
674            routine_template,
675            created_at_ms: now_ms(),
676        };
677        {
678            let mut plans = self.plans.write().await;
679            plans.insert(plan_id.clone(), prepared);
680            retain_recent_plans(&mut plans, 256);
681            save_plans(&self.plans_path, &plans);
682        }
683        if let Some(session_id) = input
684            .session_id
685            .as_deref()
686            .map(str::trim)
687            .filter(|v| !v.is_empty())
688        {
689            let mut last = self.last_plan_by_session.write().await;
690            last.insert(session_id.to_string(), plan_id.clone());
691            if let Some(thread_key) = input
692                .thread_key
693                .as_deref()
694                .map(str::trim)
695                .filter(|v| !v.is_empty())
696            {
697                last.insert(
698                    session_thread_scope_key(session_id, Some(thread_key)),
699                    plan_id.clone(),
700                );
701            }
702        }
703
704        let output = json!({
705            "workflow_id": format!("wf-{}", plan_id),
706            "mode": "preview",
707            "plan_id": plan_id,
708            "session_id": input.session_id,
709            "thread_key": input.thread_key,
710            "goal": goal,
711            "pack": {
712                "pack_id": pack_id,
713                "name": pack_name,
714                "version": "0.4.1"
715            },
716            "connector_candidates": recommended_connectors,
717            "selected_connectors": selected_connector_slugs,
718            "connector_selection_required": connector_selection_required,
719            "mcp_mapping": tool_ids,
720            "fallback_warnings": fallback_warnings,
721            "required_secrets": required_secrets,
722            "zip_path": zip_path.to_string_lossy(),
723            "auto_apply_requested": auto_apply_requested,
724            "auto_apply_ready": auto_apply_ready,
725            "status": "preview_pending",
726            "next_actions": build_preview_next_actions(
727                connector_selection_required,
728                &required_secrets,
729                !selected_connector_slugs.is_empty(),
730            ),
731            "approval_required": {
732                "register_connectors": false,
733                "install_pack": false,
734                "enable_routines": false
735            }
736        });
737
738        self.emit_metric(
739            "pack_builder.preview.count",
740            plan_id.as_str(),
741            "preview_pending",
742            input.session_id.as_deref(),
743            input.thread_key.as_deref(),
744        );
745
746        if auto_apply_ready {
747            let applied = self
748                .apply(PackBuilderInput {
749                    mode: Some("apply".to_string()),
750                    goal: None,
751                    auto_apply: Some(false),
752                    selected_connectors: selected_connector_slugs.clone(),
753                    plan_id: Some(plan_id.clone()),
754                    approve_connector_registration: Some(true),
755                    approve_pack_install: Some(true),
756                    approve_enable_routines: Some(true),
757                    schedule: None,
758                    session_id: input.session_id.clone(),
759                    thread_key: input.thread_key.clone(),
760                    secret_refs_confirmed: Some(json!(true)),
761                    // Forward the execution mode from the preview input
762                    execution_mode: input.execution_mode.clone(),
763                    max_agents: input.max_agents,
764                })
765                .await?;
766            let mut metadata = applied.metadata.clone();
767            if let Some(obj) = metadata.as_object_mut() {
768                obj.insert("auto_applied_from_preview".to_string(), json!(true));
769                obj.insert("preview_plan_id".to_string(), json!(plan_id));
770            }
771            self.upsert_workflow(
772                "pack_builder.apply_completed",
773                WorkflowStatus::ApplyComplete,
774                plan_id.as_str(),
775                input.session_id.as_deref(),
776                input.thread_key.as_deref(),
777                goal.as_str(),
778                &metadata,
779            )
780            .await;
781            return Ok(ToolResult {
782                output: render_pack_builder_apply_output(&metadata),
783                metadata,
784            });
785        }
786
787        self.upsert_workflow(
788            "pack_builder.preview_ready",
789            WorkflowStatus::PreviewPending,
790            plan_id.as_str(),
791            input.session_id.as_deref(),
792            input.thread_key.as_deref(),
793            goal.as_str(),
794            &output,
795        )
796        .await;
797
798        Ok(ToolResult {
799            output: render_pack_builder_preview_output(&output),
800            metadata: output,
801        })
802    }
803
804    async fn apply(&self, input: PackBuilderInput) -> anyhow::Result<ToolResult> {
805        let resolved_plan_id = if input.plan_id.is_none() {
806            self.resolve_plan_id_from_session(
807                input.session_id.as_deref(),
808                input.thread_key.as_deref(),
809            )
810            .await
811        } else {
812            input.plan_id.clone()
813        };
814        let Some(plan_id) = resolved_plan_id.as_deref() else {
815            self.emit_metric(
816                "pack_builder.apply.wrong_plan_prevented",
817                "unknown",
818                "error",
819                input.session_id.as_deref(),
820                input.thread_key.as_deref(),
821            );
822            let output = json!({"error":"plan_id is required for apply"});
823            self.upsert_workflow(
824                "pack_builder.error",
825                WorkflowStatus::Error,
826                "unknown",
827                input.session_id.as_deref(),
828                input.thread_key.as_deref(),
829                input.goal.as_deref().unwrap_or_default(),
830                &output,
831            )
832            .await;
833            return Ok(ToolResult {
834                output: render_pack_builder_apply_output(&output),
835                metadata: output,
836            });
837        };
838
839        let plan = {
840            let guard = self.plans.read().await;
841            guard.get(plan_id).cloned()
842        };
843        let Some(plan) = plan else {
844            self.emit_metric(
845                "pack_builder.apply.wrong_plan_prevented",
846                plan_id,
847                "error",
848                input.session_id.as_deref(),
849                input.thread_key.as_deref(),
850            );
851            let output = json!({"error":"unknown plan_id", "plan_id": plan_id});
852            self.upsert_workflow(
853                "pack_builder.error",
854                WorkflowStatus::Error,
855                plan_id,
856                input.session_id.as_deref(),
857                input.thread_key.as_deref(),
858                input.goal.as_deref().unwrap_or_default(),
859                &output,
860            )
861            .await;
862            return Ok(ToolResult {
863                output: render_pack_builder_apply_output(&output),
864                metadata: output,
865            });
866        };
867
868        let session_id = input.session_id.as_deref();
869        let thread_key = input.thread_key.as_deref();
870        if self
871            .workflows
872            .read()
873            .await
874            .get(plan_id)
875            .map(|wf| matches!(wf.status, WorkflowStatus::Cancelled))
876            .unwrap_or(false)
877        {
878            let output = json!({
879                "error":"plan_cancelled",
880                "plan_id": plan_id,
881                "status":"cancelled",
882                "next_actions": ["Create a new preview to continue."]
883            });
884            return Ok(ToolResult {
885                output: render_pack_builder_apply_output(&output),
886                metadata: output,
887            });
888        }
889
890        self.emit_metric(
891            "pack_builder.apply.count",
892            plan_id,
893            "apply_started",
894            session_id,
895            thread_key,
896        );
897
898        if input.approve_pack_install != Some(true) {
899            let output = json!({
900                "error": "approval_required",
901                "required": {
902                    "approve_pack_install": true
903                },
904                "status": "error"
905            });
906            self.upsert_workflow(
907                "pack_builder.error",
908                WorkflowStatus::Error,
909                plan_id,
910                session_id,
911                thread_key,
912                &plan.goal,
913                &output,
914            )
915            .await;
916            return Ok(ToolResult {
917                output: render_pack_builder_apply_output(&output),
918                metadata: output,
919            });
920        }
921
922        let all_catalog = catalog_servers();
923        let selected = if input.selected_connectors.is_empty() {
924            plan.selected_connector_slugs.clone()
925        } else {
926            input.selected_connectors.clone()
927        };
928        if !selected.is_empty() && input.approve_connector_registration != Some(true) {
929            let output = json!({
930                "error": "approval_required",
931                "required": {
932                    "approve_connector_registration": true,
933                    "approve_pack_install": true
934                },
935                "status": "error"
936            });
937            self.upsert_workflow(
938                "pack_builder.error",
939                WorkflowStatus::Error,
940                plan_id,
941                session_id,
942                thread_key,
943                &plan.goal,
944                &output,
945            )
946            .await;
947            return Ok(ToolResult {
948                output: render_pack_builder_apply_output(&output),
949                metadata: output,
950            });
951        }
952
953        if !plan.required_secrets.is_empty()
954            && !secret_refs_confirmed(&input.secret_refs_confirmed, &plan.required_secrets)
955        {
956            let output = json!({
957                "workflow_id": format!("wf-{}", plan.plan_id),
958                "mode": "apply",
959                "plan_id": plan.plan_id,
960                "session_id": input.session_id,
961                "thread_key": input.thread_key,
962                "goal": plan.goal,
963                "status": "apply_blocked_missing_secrets",
964                "required_secrets": plan.required_secrets,
965                "next_actions": [
966                    "Set required secrets in engine settings/environment.",
967                    "Re-run apply with `secret_refs_confirmed` after secrets are set."
968                ],
969            });
970            self.upsert_workflow(
971                "pack_builder.apply_blocked",
972                WorkflowStatus::ApplyBlockedMissingSecrets,
973                plan_id,
974                session_id,
975                thread_key,
976                &plan.goal,
977                &output,
978            )
979            .await;
980            self.emit_metric(
981                "pack_builder.apply.blocked_missing_secrets",
982                plan_id,
983                "apply_blocked_missing_secrets",
984                session_id,
985                thread_key,
986            );
987            return Ok(ToolResult {
988                output: render_pack_builder_apply_output(&output),
989                metadata: output,
990            });
991        }
992
993        let auth_blocked = selected.iter().any(|slug| {
994            plan.recommended_connectors
995                .iter()
996                .any(|c| &c.slug == slug && (c.requires_setup || c.transport_url.contains('{')))
997        });
998        if auth_blocked {
999            let output = json!({
1000                "workflow_id": format!("wf-{}", plan.plan_id),
1001                "mode": "apply",
1002                "plan_id": plan.plan_id,
1003                "session_id": input.session_id,
1004                "thread_key": input.thread_key,
1005                "goal": plan.goal,
1006                "status": "apply_blocked_auth",
1007                "selected_connectors": selected,
1008                "next_actions": [
1009                    "Complete connector setup/auth from the connector documentation.",
1010                    "Re-run apply after connector auth is completed."
1011                ],
1012            });
1013            self.upsert_workflow(
1014                "pack_builder.apply_blocked",
1015                WorkflowStatus::ApplyBlockedAuth,
1016                plan_id,
1017                session_id,
1018                thread_key,
1019                &plan.goal,
1020                &output,
1021            )
1022            .await;
1023            self.emit_metric(
1024                "pack_builder.apply.blocked_auth",
1025                plan_id,
1026                "apply_blocked_auth",
1027                session_id,
1028                thread_key,
1029            );
1030            return Ok(ToolResult {
1031                output: render_pack_builder_apply_output(&output),
1032                metadata: output,
1033            });
1034        }
1035
1036        self.state.event_bus.publish(tandem_types::EngineEvent::new(
1037            "pack_builder.apply_started",
1038            json!({
1039                "sessionID": session_id.unwrap_or_default(),
1040                "threadKey": thread_key.unwrap_or_default(),
1041                "planID": plan_id,
1042                "status": "apply_started",
1043            }),
1044        ));
1045
1046        if !plan.generated_zip_path.exists() {
1047            let output = json!({
1048                "workflow_id": format!("wf-{}", plan.plan_id),
1049                "mode": "apply",
1050                "plan_id": plan.plan_id,
1051                "session_id": input.session_id,
1052                "thread_key": input.thread_key,
1053                "goal": plan.goal,
1054                "status": "apply_blocked_missing_preview_artifacts",
1055                "error": "preview_artifacts_missing",
1056                "next_actions": [
1057                    "Run a new Pack Builder preview for this goal.",
1058                    "Confirm apply from the new preview."
1059                ]
1060            });
1061            self.upsert_workflow(
1062                "pack_builder.apply_blocked",
1063                WorkflowStatus::Error,
1064                plan_id,
1065                session_id,
1066                thread_key,
1067                &plan.goal,
1068                &output,
1069            )
1070            .await;
1071            return Ok(ToolResult {
1072                output: render_pack_builder_apply_output(&output),
1073                metadata: output,
1074            });
1075        }
1076
1077        let mut connector_results = Vec::<Value>::new();
1078        let mut registered_servers = Vec::<String>::new();
1079
1080        for slug in &selected {
1081            let Some(server) = all_catalog.iter().find(|s| &s.slug == slug) else {
1082                connector_results
1083                    .push(json!({"slug": slug, "ok": false, "error": "not_in_catalog"}));
1084                continue;
1085            };
1086            let transport = if server.transport_url.contains('{') || server.transport_url.is_empty()
1087            {
1088                connector_results.push(json!({
1089                    "slug": server.slug,
1090                    "ok": false,
1091                    "error": "transport_requires_manual_setup",
1092                    "documentation_url": server.documentation_url
1093                }));
1094                continue;
1095            } else {
1096                server.transport_url.clone()
1097            };
1098
1099            let name = server.slug.clone();
1100            self.state
1101                .mcp
1102                .add_or_update(name.clone(), transport, HashMap::new(), true)
1103                .await;
1104            let connected = self.state.mcp.connect(&name).await;
1105            let tool_count = if connected {
1106                sync_mcp_tools_for_server(&self.state, &name).await
1107            } else {
1108                0
1109            };
1110            if connected {
1111                registered_servers.push(name.clone());
1112            }
1113            connector_results.push(json!({
1114                "slug": server.slug,
1115                "ok": connected,
1116                "registered_name": name,
1117                "tool_count": tool_count,
1118                "documentation_url": server.documentation_url,
1119                "requires_auth": server.requires_auth
1120            }));
1121        }
1122
1123        let installed = self
1124            .state
1125            .pack_manager
1126            .install(PackInstallRequest {
1127                path: Some(plan.generated_zip_path.to_string_lossy().to_string()),
1128                url: None,
1129                source: json!({"kind":"pack_builder", "plan_id": plan.plan_id, "goal": plan.goal}),
1130            })
1131            .await?;
1132
1133        let mut routines_registered = Vec::<String>::new();
1134        let mut automations_registered = Vec::<String>::new();
1135        for routine_id in &plan.routine_ids {
1136            let exec_mode = input
1137                .execution_mode
1138                .as_deref()
1139                .map(str::trim)
1140                .filter(|v| !v.is_empty())
1141                .unwrap_or("team");
1142            let max_agents = input.max_agents.unwrap_or(4);
1143            let mut routine = RoutineSpec {
1144                routine_id: routine_id.clone(),
1145                name: plan.routine_template.name.clone(),
1146                status: RoutineStatus::Active,
1147                schedule: plan.routine_template.schedule.clone(),
1148                timezone: plan.routine_template.timezone.clone(),
1149                misfire_policy: RoutineMisfirePolicy::RunOnce,
1150                entrypoint: plan.routine_template.entrypoint.clone(),
1151                args: json!({
1152                    "prompt": plan.goal,
1153                    // execution_mode controls how the orchestrator handles this routine:
1154                    // "single"  → one agent loop (simple tasks)
1155                    // "team"    → orchestrated agent team with planner + specialist workers
1156                    // "swarm"   → context-run based swarm with parallel sub-tasks
1157                    "mode": exec_mode,
1158                    "uses_external_integrations": true,
1159                    "pack_id": plan.pack_id,
1160                    "pack_name": plan.pack_name,
1161                    "pack_builder_plan_id": plan.plan_id,
1162                    // team/swarm configuration hints for the orchestrator
1163                    "orchestration": {
1164                        "execution_mode": exec_mode,
1165                        "max_agents": max_agents,
1166                        "objective": plan.goal,
1167                    },
1168                }),
1169                allowed_tools: plan.routine_template.allowed_tools.clone(),
1170                output_targets: vec![format!("run/{}/report.md", routine_id)],
1171                creator_type: "agent".to_string(),
1172                creator_id: "pack_builder".to_string(),
1173                requires_approval: false,
1174                external_integrations_allowed: true,
1175                next_fire_at_ms: None,
1176                last_fired_at_ms: None,
1177            };
1178            if input.approve_enable_routines == Some(false) {
1179                routine.status = RoutineStatus::Paused;
1180            }
1181            let automation = build_pack_builder_automation(
1182                &plan,
1183                routine_id,
1184                exec_mode,
1185                max_agents,
1186                &registered_servers,
1187                input.approve_enable_routines != Some(false),
1188            );
1189            let stored_automation = self.state.put_automation_v2(automation).await?;
1190            automations_registered.push(stored_automation.automation_id.clone());
1191            let stored = self
1192                .state
1193                .put_routine(routine)
1194                .await
1195                .map_err(|err| anyhow::anyhow!("failed to register routine: {:?}", err))?;
1196            routines_registered.push(stored.routine_id);
1197        }
1198
1199        let preset_path = save_pack_preset(&plan, &registered_servers)?;
1200
1201        let output = json!({
1202            "workflow_id": format!("wf-{}", plan.plan_id),
1203            "mode": "apply",
1204            "plan_id": plan.plan_id,
1205            "session_id": input.session_id,
1206            "thread_key": input.thread_key,
1207            "capabilities": {
1208                "required": plan.capabilities_required,
1209                "optional": plan.capabilities_optional
1210            },
1211            "pack_installed": {
1212                "pack_id": installed.pack_id,
1213                "name": installed.name,
1214                "version": installed.version,
1215                "install_path": installed.install_path,
1216            },
1217            "connectors": connector_results,
1218            "registered_servers": registered_servers,
1219            "automations_registered": automations_registered,
1220            "routines_registered": routines_registered,
1221            "routines_enabled": input.approve_enable_routines != Some(false),
1222            "fallback_warnings": plan.fallback_warnings,
1223            "status": "apply_complete",
1224            "next_actions": [
1225                "Review the installed pack in Packs view.",
1226                "Routine is enabled by default and will run on schedule."
1227            ],
1228            "pack_preset": {
1229                "path": preset_path.to_string_lossy().to_string(),
1230                "required_secrets": plan.required_secrets,
1231                "selected_tools": plan.selected_mcp_tools,
1232            }
1233        });
1234
1235        self.upsert_workflow(
1236            "pack_builder.apply_completed",
1237            WorkflowStatus::ApplyComplete,
1238            plan_id,
1239            session_id,
1240            thread_key,
1241            &plan.goal,
1242            &output,
1243        )
1244        .await;
1245        self.emit_metric(
1246            "pack_builder.apply.success",
1247            plan_id,
1248            "apply_complete",
1249            session_id,
1250            thread_key,
1251        );
1252
1253        Ok(ToolResult {
1254            output: render_pack_builder_apply_output(&output),
1255            metadata: output,
1256        })
1257    }
1258
1259    async fn cancel(&self, input: PackBuilderInput) -> anyhow::Result<ToolResult> {
1260        let plan_id = if let Some(plan_id) = input.plan_id.as_deref().map(str::trim) {
1261            if !plan_id.is_empty() {
1262                Some(plan_id.to_string())
1263            } else {
1264                None
1265            }
1266        } else {
1267            self.resolve_plan_id_from_session(
1268                input.session_id.as_deref(),
1269                input.thread_key.as_deref(),
1270            )
1271            .await
1272        };
1273        let Some(plan_id) = plan_id else {
1274            let output = json!({"error":"plan_id is required for cancel"});
1275            return Ok(ToolResult {
1276                output: render_pack_builder_apply_output(&output),
1277                metadata: output,
1278            });
1279        };
1280        let goal = self
1281            .plans
1282            .read()
1283            .await
1284            .get(&plan_id)
1285            .map(|p| p.goal.clone())
1286            .unwrap_or_default();
1287        let output = json!({
1288            "workflow_id": format!("wf-{}", plan_id),
1289            "mode": "cancel",
1290            "plan_id": plan_id,
1291            "session_id": input.session_id,
1292            "thread_key": input.thread_key,
1293            "goal": goal,
1294            "status": "cancelled",
1295            "next_actions": ["Create a new preview when ready."]
1296        });
1297        self.upsert_workflow(
1298            "pack_builder.cancelled",
1299            WorkflowStatus::Cancelled,
1300            output
1301                .get("plan_id")
1302                .and_then(Value::as_str)
1303                .unwrap_or_default(),
1304            input.session_id.as_deref(),
1305            input.thread_key.as_deref(),
1306            output
1307                .get("goal")
1308                .and_then(Value::as_str)
1309                .unwrap_or_default(),
1310            &output,
1311        )
1312        .await;
1313        self.emit_metric(
1314            "pack_builder.apply.cancelled",
1315            output
1316                .get("plan_id")
1317                .and_then(Value::as_str)
1318                .unwrap_or_default(),
1319            "cancelled",
1320            input.session_id.as_deref(),
1321            input.thread_key.as_deref(),
1322        );
1323        Ok(ToolResult {
1324            output: "Pack Builder Apply Cancelled\n- Pending plan cancelled.".to_string(),
1325            metadata: output,
1326        })
1327    }
1328
1329    async fn pending(&self, input: PackBuilderInput) -> anyhow::Result<ToolResult> {
1330        let plan_id = if let Some(plan_id) = input.plan_id.as_deref().map(str::trim) {
1331            if !plan_id.is_empty() {
1332                Some(plan_id.to_string())
1333            } else {
1334                None
1335            }
1336        } else {
1337            self.resolve_plan_id_from_session(
1338                input.session_id.as_deref(),
1339                input.thread_key.as_deref(),
1340            )
1341            .await
1342        };
1343        let Some(plan_id) = plan_id else {
1344            let output = json!({"status":"none","pending":null});
1345            return Ok(ToolResult {
1346                output: "No pending pack-builder plan for this session.".to_string(),
1347                metadata: output,
1348            });
1349        };
1350        let workflows = self.workflows.read().await;
1351        let Some(record) = workflows.get(&plan_id) else {
1352            let output = json!({"status":"none","plan_id":plan_id});
1353            return Ok(ToolResult {
1354                output: "No pending pack-builder plan found.".to_string(),
1355                metadata: output,
1356            });
1357        };
1358        let output = json!({
1359            "status":"ok",
1360            "pending": record,
1361            "plan_id": plan_id
1362        });
1363        Ok(ToolResult {
1364            output: serde_json::to_string_pretty(&output).unwrap_or_else(|_| output.to_string()),
1365            metadata: output,
1366        })
1367    }
1368}
1369
1370fn render_pack_builder_preview_output(meta: &Value) -> String {
1371    let goal = meta
1372        .get("goal")
1373        .and_then(Value::as_str)
1374        .unwrap_or("automation goal");
1375    let plan_id = meta.get("plan_id").and_then(Value::as_str).unwrap_or("-");
1376    let pack_name = meta
1377        .get("pack")
1378        .and_then(|v| v.get("name"))
1379        .and_then(Value::as_str)
1380        .unwrap_or("generated-pack");
1381    let pack_id = meta
1382        .get("pack")
1383        .and_then(|v| v.get("pack_id"))
1384        .and_then(Value::as_str)
1385        .unwrap_or("-");
1386    let auto_apply_ready = meta
1387        .get("auto_apply_ready")
1388        .and_then(Value::as_bool)
1389        .unwrap_or(false);
1390    let connector_selection_required = meta
1391        .get("connector_selection_required")
1392        .and_then(Value::as_bool)
1393        .unwrap_or(false);
1394    let selected_connectors = meta
1395        .get("selected_connectors")
1396        .and_then(Value::as_array)
1397        .map(|rows| {
1398            rows.iter()
1399                .filter_map(Value::as_str)
1400                .map(|v| format!("- {}", v))
1401                .collect::<Vec<_>>()
1402        })
1403        .unwrap_or_default();
1404    let required_secrets = meta
1405        .get("required_secrets")
1406        .and_then(Value::as_array)
1407        .map(|rows| {
1408            rows.iter()
1409                .filter_map(Value::as_str)
1410                .map(|v| format!("- {}", v))
1411                .collect::<Vec<_>>()
1412        })
1413        .unwrap_or_default();
1414    let fallback_warnings = meta
1415        .get("fallback_warnings")
1416        .and_then(Value::as_array)
1417        .map(|rows| {
1418            rows.iter()
1419                .filter_map(Value::as_str)
1420                .map(|v| format!("- {}", v))
1421                .collect::<Vec<_>>()
1422        })
1423        .unwrap_or_default();
1424
1425    let mut lines = vec![
1426        "Pack Builder Preview".to_string(),
1427        format!("- Goal: {}", goal),
1428        format!("- Plan ID: {}", plan_id),
1429        format!("- Pack: {} ({})", pack_name, pack_id),
1430    ];
1431
1432    if selected_connectors.is_empty() {
1433        lines.push("- Selected connectors: none".to_string());
1434    } else {
1435        lines.push("- Selected connectors:".to_string());
1436        lines.extend(selected_connectors);
1437    }
1438    if required_secrets.is_empty() {
1439        lines.push("- Required secrets: none".to_string());
1440    } else {
1441        lines.push("- Required secrets:".to_string());
1442        lines.extend(required_secrets);
1443    }
1444    if !fallback_warnings.is_empty() {
1445        lines.push("- Warnings:".to_string());
1446        lines.extend(fallback_warnings);
1447    }
1448
1449    if auto_apply_ready {
1450        lines.push("- Status: ready for automatic apply".to_string());
1451    } else {
1452        lines.push("- Status: waiting for apply confirmation".to_string());
1453        if connector_selection_required {
1454            lines.push("- Action needed: choose connectors before apply.".to_string());
1455        }
1456    }
1457    lines.join("\n")
1458}
1459
1460fn render_pack_builder_apply_output(meta: &Value) -> String {
1461    if let Some(status) = meta.get("status").and_then(Value::as_str) {
1462        match status {
1463            "apply_blocked_missing_secrets" => {
1464                let required = meta
1465                    .get("required_secrets")
1466                    .and_then(Value::as_array)
1467                    .map(|rows| {
1468                        rows.iter()
1469                            .filter_map(Value::as_str)
1470                            .map(|v| format!("- {}", v))
1471                            .collect::<Vec<_>>()
1472                    })
1473                    .unwrap_or_default();
1474                let mut lines = vec![
1475                    "Pack Builder Apply Blocked".to_string(),
1476                    "- Reason: missing required secrets.".to_string(),
1477                ];
1478                if !required.is_empty() {
1479                    lines.push("- Required secrets:".to_string());
1480                    lines.extend(required);
1481                }
1482                lines.push("- Action: set secrets, then apply again.".to_string());
1483                return lines.join("\n");
1484            }
1485            "apply_blocked_auth" => {
1486                let connectors = meta
1487                    .get("selected_connectors")
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: connector authentication/setup required.".to_string(),
1499                ];
1500                if !connectors.is_empty() {
1501                    lines.push("- Connectors awaiting setup:".to_string());
1502                    lines.extend(connectors);
1503                }
1504                lines.push("- Action: complete connector auth, then apply again.".to_string());
1505                return lines.join("\n");
1506            }
1507            "cancelled" => {
1508                return "Pack Builder Apply Cancelled\n- Pending plan cancelled.".to_string();
1509            }
1510            "apply_blocked_missing_preview_artifacts" => {
1511                return "Pack Builder Apply Blocked\n- Preview artifacts expired. Run preview again, then confirm.".to_string();
1512            }
1513            _ => {}
1514        }
1515    }
1516
1517    if let Some(error) = meta.get("error").and_then(Value::as_str) {
1518        return match error {
1519            "approval_required" => {
1520                "Pack Builder Apply Blocked\n- Approval required for this apply step.".to_string()
1521            }
1522            "unknown plan_id" => "Pack Builder Apply Failed\n- Plan not found.".to_string(),
1523            "plan_cancelled" => {
1524                "Pack Builder Apply Failed\n- Plan was already cancelled.".to_string()
1525            }
1526            _ => format!("Pack Builder Apply Failed\n- {}", error),
1527        };
1528    }
1529
1530    let pack_id = meta
1531        .get("pack_installed")
1532        .and_then(|v| v.get("pack_id"))
1533        .and_then(Value::as_str)
1534        .unwrap_or("-");
1535    let pack_name = meta
1536        .get("pack_installed")
1537        .and_then(|v| v.get("name"))
1538        .and_then(Value::as_str)
1539        .unwrap_or("-");
1540    let install_path = meta
1541        .get("pack_installed")
1542        .and_then(|v| v.get("install_path"))
1543        .and_then(Value::as_str)
1544        .unwrap_or("-");
1545    let routines_enabled = meta
1546        .get("routines_enabled")
1547        .and_then(Value::as_bool)
1548        .unwrap_or(false);
1549    let registered_servers = meta
1550        .get("registered_servers")
1551        .and_then(Value::as_array)
1552        .map(|rows| {
1553            rows.iter()
1554                .filter_map(Value::as_str)
1555                .map(|v| format!("- {}", v))
1556                .collect::<Vec<_>>()
1557        })
1558        .unwrap_or_default();
1559    let routines = meta
1560        .get("routines_registered")
1561        .and_then(Value::as_array)
1562        .map(|rows| {
1563            rows.iter()
1564                .filter_map(Value::as_str)
1565                .map(|v| format!("- {}", v))
1566                .collect::<Vec<_>>()
1567        })
1568        .unwrap_or_default();
1569
1570    let mut lines = vec![
1571        "Pack Builder Apply Complete".to_string(),
1572        format!("- Installed pack: {} ({})", pack_name, pack_id),
1573        format!("- Install path: {}", install_path),
1574        format!(
1575            "- Routines: {}",
1576            if routines_enabled {
1577                "enabled"
1578            } else {
1579                "paused"
1580            }
1581        ),
1582    ];
1583
1584    if registered_servers.is_empty() {
1585        lines.push("- Registered connectors: none".to_string());
1586    } else {
1587        lines.push("- Registered connectors:".to_string());
1588        lines.extend(registered_servers);
1589    }
1590    if !routines.is_empty() {
1591        lines.push("- Registered routines:".to_string());
1592        lines.extend(routines);
1593    }
1594
1595    lines.join("\n")
1596}
1597
1598fn resolve_pack_builder_workflows_path() -> PathBuf {
1599    if let Ok(dir) = std::env::var("TANDEM_STATE_DIR") {
1600        let trimmed = dir.trim();
1601        if !trimmed.is_empty() {
1602            return PathBuf::from(trimmed).join("pack_builder_workflows.json");
1603        }
1604    }
1605    if let Some(data_dir) = dirs::data_dir() {
1606        return data_dir
1607            .join("tandem")
1608            .join("data")
1609            .join("pack_builder_workflows.json");
1610    }
1611    dirs::home_dir()
1612        .unwrap_or_else(|| PathBuf::from("."))
1613        .join(".tandem")
1614        .join("data")
1615        .join("pack_builder_workflows.json")
1616}
1617
1618fn resolve_pack_builder_plans_path() -> PathBuf {
1619    if let Ok(dir) = std::env::var("TANDEM_STATE_DIR") {
1620        let trimmed = dir.trim();
1621        if !trimmed.is_empty() {
1622            return PathBuf::from(trimmed).join("pack_builder_plans.json");
1623        }
1624    }
1625    if let Some(data_dir) = dirs::data_dir() {
1626        return data_dir
1627            .join("tandem")
1628            .join("data")
1629            .join("pack_builder_plans.json");
1630    }
1631    dirs::home_dir()
1632        .unwrap_or_else(|| PathBuf::from("."))
1633        .join(".tandem")
1634        .join("data")
1635        .join("pack_builder_plans.json")
1636}
1637
1638/// Returns the directory for persistent pack zip staging.
1639/// Zips are stored here (not in temp_dir) so they survive until apply() runs.
1640fn resolve_pack_builder_zips_dir() -> 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_zips");
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_zips");
1652    }
1653    dirs::home_dir()
1654        .unwrap_or_else(|| PathBuf::from("."))
1655        .join(".tandem")
1656        .join("data")
1657        .join("pack_builder_zips")
1658}
1659
1660fn load_workflows(path: &PathBuf) -> HashMap<String, WorkflowRecord> {
1661    let Ok(bytes) = fs::read(path) else {
1662        return HashMap::new();
1663    };
1664    serde_json::from_slice::<HashMap<String, WorkflowRecord>>(&bytes).unwrap_or_default()
1665}
1666
1667fn save_workflows(path: &PathBuf, workflows: &HashMap<String, WorkflowRecord>) {
1668    if let Some(parent) = path.parent() {
1669        let _ = fs::create_dir_all(parent);
1670    }
1671    if let Ok(bytes) = serde_json::to_vec_pretty(workflows) {
1672        let _ = fs::write(path, bytes);
1673    }
1674}
1675
1676fn load_plans(path: &PathBuf) -> HashMap<String, PreparedPlan> {
1677    let Ok(bytes) = fs::read(path) else {
1678        return HashMap::new();
1679    };
1680    serde_json::from_slice::<HashMap<String, PreparedPlan>>(&bytes).unwrap_or_default()
1681}
1682
1683fn save_plans(path: &PathBuf, plans: &HashMap<String, PreparedPlan>) {
1684    if let Some(parent) = path.parent() {
1685        let _ = fs::create_dir_all(parent);
1686    }
1687    if let Ok(bytes) = serde_json::to_vec_pretty(plans) {
1688        let _ = fs::write(path, bytes);
1689    }
1690}
1691
1692fn now_ms() -> u64 {
1693    SystemTime::now()
1694        .duration_since(UNIX_EPOCH)
1695        .map(|d| d.as_millis() as u64)
1696        .unwrap_or(0)
1697}
1698
1699fn retain_recent_workflows(workflows: &mut HashMap<String, WorkflowRecord>, keep: usize) {
1700    if workflows.len() <= keep {
1701        return;
1702    }
1703    let mut rows = workflows
1704        .iter()
1705        .map(|(key, value)| (key.clone(), value.updated_at_ms))
1706        .collect::<Vec<_>>();
1707    rows.sort_by(|a, b| b.1.cmp(&a.1));
1708    let keep_keys = rows
1709        .into_iter()
1710        .take(keep)
1711        .map(|(key, _)| key)
1712        .collect::<BTreeSet<_>>();
1713    workflows.retain(|key, _| keep_keys.contains(key));
1714}
1715
1716fn retain_recent_plans(plans: &mut HashMap<String, PreparedPlan>, keep: usize) {
1717    if plans.len() <= keep {
1718        return;
1719    }
1720    let mut rows = plans
1721        .iter()
1722        .map(|(key, value)| {
1723            (
1724                key.clone(),
1725                value.created_at_ms,
1726                value.generated_zip_path.clone(),
1727            )
1728        })
1729        .collect::<Vec<_>>();
1730    rows.sort_by(|a, b| b.1.cmp(&a.1));
1731    let mut keep_keys = BTreeSet::<String>::new();
1732    let mut evict_zips = Vec::<PathBuf>::new();
1733    for (i, (key, _, zip_path)) in rows.iter().enumerate() {
1734        if i < keep {
1735            keep_keys.insert(key.clone());
1736        } else {
1737            evict_zips.push(zip_path.clone());
1738        }
1739    }
1740    plans.retain(|key, _| keep_keys.contains(key));
1741    // Best-effort removal of the staging directories for evicted plans
1742    for zip in evict_zips {
1743        if let Some(stage_dir) = zip.parent() {
1744            let _ = fs::remove_dir_all(stage_dir);
1745        }
1746    }
1747}
1748
1749fn session_thread_scope_key(session_id: &str, thread_key: Option<&str>) -> String {
1750    let thread = thread_key.unwrap_or_default().trim();
1751    if thread.is_empty() {
1752        return session_id.trim().to_string();
1753    }
1754    format!("{}::{}", session_id.trim(), thread)
1755}
1756
1757fn workflow_status_label(status: &WorkflowStatus) -> &'static str {
1758    match status {
1759        WorkflowStatus::PreviewPending => "preview_pending",
1760        WorkflowStatus::ApplyBlockedMissingSecrets => "apply_blocked_missing_secrets",
1761        WorkflowStatus::ApplyBlockedAuth => "apply_blocked_auth",
1762        WorkflowStatus::ApplyComplete => "apply_complete",
1763        WorkflowStatus::Cancelled => "cancelled",
1764        WorkflowStatus::Error => "error",
1765    }
1766}
1767
1768fn infer_surface(thread_key: Option<&str>) -> &'static str {
1769    let key = thread_key.unwrap_or_default().to_lowercase();
1770    if key.starts_with("telegram:") {
1771        "telegram"
1772    } else if key.starts_with("discord:") {
1773        "discord"
1774    } else if key.starts_with("slack:") {
1775        "slack"
1776    } else if key.starts_with("desktop:") || key.starts_with("tauri:") {
1777        "tauri"
1778    } else if key.starts_with("web:") || key.starts_with("control-panel:") {
1779        "web"
1780    } else {
1781        "unknown"
1782    }
1783}
1784
1785fn build_preview_next_actions(
1786    connector_selection_required: bool,
1787    required_secrets: &[String],
1788    has_connector_registration: bool,
1789) -> Vec<String> {
1790    let mut actions = Vec::new();
1791    if connector_selection_required {
1792        actions.push("Select connector(s) before applying.".to_string());
1793    }
1794    if !required_secrets.is_empty() {
1795        actions.push("Set required secrets in engine settings/environment.".to_string());
1796    }
1797    if has_connector_registration {
1798        actions.push("Confirm connector registration and pack install.".to_string());
1799    } else {
1800        actions.push("Apply to install the generated pack.".to_string());
1801    }
1802    actions
1803}
1804
1805fn secret_refs_confirmed(confirmed: &Option<Value>, required: &[String]) -> bool {
1806    if required.is_empty() {
1807        return true;
1808    }
1809    if env_has_all_required_secrets(required) {
1810        return true;
1811    }
1812    let Some(value) = confirmed else {
1813        return false;
1814    };
1815    if value.as_bool() == Some(true) {
1816        return true;
1817    }
1818    let Some(rows) = value.as_array() else {
1819        return false;
1820    };
1821    let confirmed = rows
1822        .iter()
1823        .filter_map(Value::as_str)
1824        .map(|v| v.trim().to_ascii_uppercase())
1825        .collect::<BTreeSet<_>>();
1826    required
1827        .iter()
1828        .all(|item| confirmed.contains(&item.to_ascii_uppercase()))
1829}
1830
1831fn env_has_all_required_secrets(required: &[String]) -> bool {
1832    required.iter().all(|key| {
1833        std::env::var(key)
1834            .ok()
1835            .map(|v| !v.trim().is_empty())
1836            .unwrap_or(false)
1837    })
1838}
1839
1840fn build_schedule(input: Option<&PreviewScheduleInput>) -> (RoutineSchedule, String, String) {
1841    let timezone = input
1842        .and_then(|v| v.timezone.as_deref())
1843        .filter(|v| !v.trim().is_empty())
1844        .unwrap_or("UTC")
1845        .to_string();
1846
1847    if let Some(cron) = input
1848        .and_then(|v| v.cron.as_deref())
1849        .map(str::trim)
1850        .filter(|v| !v.is_empty())
1851    {
1852        return (
1853            RoutineSchedule::Cron {
1854                expression: cron.to_string(),
1855            },
1856            "cron".to_string(),
1857            timezone,
1858        );
1859    }
1860
1861    let seconds = input
1862        .and_then(|v| v.interval_seconds)
1863        .unwrap_or(86_400)
1864        .clamp(30, 31_536_000);
1865
1866    (
1867        RoutineSchedule::IntervalSeconds { seconds },
1868        format!("every_{}_seconds", seconds),
1869        timezone,
1870    )
1871}
1872
1873fn build_allowed_tools(mcp_tools: &[String], needs: &[CapabilityNeed]) -> Vec<String> {
1874    let mut out = BTreeSet::<String>::new();
1875    for tool in mcp_tools {
1876        out.insert(tool.clone());
1877    }
1878    out.insert("question".to_string());
1879    if needs.iter().any(|n| !n.external) {
1880        out.insert("read".to_string());
1881        out.insert("write".to_string());
1882    }
1883    if needs
1884        .iter()
1885        .any(|n| n.id.contains("news") || n.id.contains("headline"))
1886    {
1887        out.insert("websearch".to_string());
1888        out.insert("webfetch".to_string());
1889    }
1890    out.into_iter().collect()
1891}
1892
1893fn render_mission_yaml(mission_id: &str, mcp_tools: &[String], needs: &[CapabilityNeed]) -> String {
1894    let mut lines = vec![
1895        format!("id: {}", mission_id),
1896        "title: Generated Pack Builder Mission".to_string(),
1897        "steps:".to_string(),
1898    ];
1899
1900    let mut step_idx = 1usize;
1901    for tool in mcp_tools {
1902        lines.push(format!("  - id: step_{}", step_idx));
1903        lines.push(format!("    action: {}", tool));
1904        step_idx += 1;
1905    }
1906
1907    if mcp_tools.is_empty() {
1908        lines.push("  - id: step_1".to_string());
1909        lines.push("    action: websearch".to_string());
1910    }
1911
1912    for need in needs {
1913        lines.push(format!("  - id: verify_{}", namespace_segment(&need.id)));
1914        lines.push("    action: question".to_string());
1915        lines.push("    optional: true".to_string());
1916    }
1917
1918    lines.join("\n") + "\n"
1919}
1920
1921fn render_agent_md(mcp_tools: &[String], goal: &str) -> String {
1922    let mut lines = vec![
1923        "---".to_string(),
1924        "name: default".to_string(),
1925        "description: Generated MCP-first pack agent".to_string(),
1926        "---".to_string(),
1927        "".to_string(),
1928        "You are the Pack Builder runtime agent for this routine.".to_string(),
1929        format!("Mission goal: {}", goal),
1930        "Use the mission steps exactly and invoke the discovered MCP tools explicitly.".to_string(),
1931        "".to_string(),
1932        "Discovered MCP tool IDs: ".to_string(),
1933    ];
1934
1935    if mcp_tools.is_empty() {
1936        lines
1937            .push("- (none discovered; fallback to built-ins is allowed for this run)".to_string());
1938    } else {
1939        for tool in mcp_tools {
1940            lines.push(format!("- {}", tool));
1941        }
1942    }
1943
1944    lines.push("".to_string());
1945    lines.push("If a required connector is missing or unauthorized, report it and stop before side effects.".to_string());
1946    lines.join("\n") + "\n"
1947}