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