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