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