greentic-start-dev 1.1.27190108346

Greentic lifecycle runner for start/restart/stop orchestration
Documentation
use std::collections::HashMap;
use std::path::{Path, PathBuf};

use anyhow::Context;
use greentic_runner_desktop::{
    DevProfile, MocksConfig, Profile, RunOptions, RunResult, RunStatus, SigningPolicy,
    TenantContext,
};
use serde_json::Value as JsonValue;

use crate::domains::Domain;
use crate::secrets_gate;
use crate::state_layout;

pub struct RunOutput {
    pub result: RunResult,
    pub run_dir: PathBuf,
}

pub struct RunRequest {
    pub root: PathBuf,
    pub domain: Domain,
    pub pack_path: PathBuf,
    pub pack_label: String,
    pub flow_id: String,
    pub tenant: String,
    pub team: Option<String>,
    pub input: JsonValue,
    pub dist_offline: bool,
}

pub fn run_provider_pack_flow(request: RunRequest) -> anyhow::Result<RunOutput> {
    // Ensure flow.log is initialized in bundle's logs directory
    let _ = crate::flow_log::init(&request.root.join("logs"));

    let run_dir = state_layout::run_dir(
        &request.root,
        request.domain,
        &request.pack_label,
        &request.flow_id,
    )?;
    std::fs::create_dir_all(&run_dir)?;
    let input_path = run_dir.join("input.json");
    let input_json = serde_json::to_string_pretty(&request.input)?;
    std::fs::write(&input_path, input_json)?;

    let log_pack = request.pack_label.clone();
    let log_flow = request.flow_id.clone();
    let log_tenant = request.tenant.clone();
    let log_team = request.team.as_deref().unwrap_or("default").to_string();

    // Extract user input for logging
    let user_text = request
        .input
        .pointer("/input/text")
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .to_string();
    let user_verb = request
        .input
        .pointer("/input/metadata/verb")
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .to_string();
    let user_from = request
        .input
        .pointer("/input/from/id")
        .and_then(|v| v.as_str())
        .unwrap_or("?")
        .to_string();

    let input_summary = if !user_verb.is_empty() {
        format!("verb={user_verb}")
    } else if !user_text.is_empty() {
        let truncated = if user_text.len() > 80 {
            format!("{}...", &user_text[..80])
        } else {
            user_text.clone()
        };
        format!("text=\"{truncated}\"")
    } else {
        "empty".to_string()
    };

    crate::flow_log::flow_start(&log_pack, &log_flow, &log_tenant, &log_team);
    crate::flow_log::log(
        "INPUT",
        &format!("pack={log_pack} flow={log_flow} from={user_from} {input_summary}"),
    );

    let secrets_handle = secrets_gate::resolve_secrets_manager(
        &request.root,
        &request.tenant,
        request.team.as_deref(),
    )
    .with_context(|| {
        format!(
            "resolve secrets manager for tenant={} team={}",
            request.tenant,
            request.team.as_deref().unwrap_or("default")
        )
    })?;

    let team = request
        .team
        .clone()
        .unwrap_or_else(|| "default".to_string());
    // Derive a stable session_id from the inbound envelope so the desktop
    // runner can persist/restore the engine's `FlowSnapshot` between
    // activities. Conversational messaging flows rely on this to resume
    // at the card the user paused on instead of restarting at the entry.
    //
    // We compose `{pack}:{flow}:{envelope.session_id}` so two flows or
    // packs sharing the same conversation id stay isolated.
    let envelope_session_id = request
        .input
        .pointer("/input/session_id")
        .and_then(|v| v.as_str())
        .filter(|s| !s.is_empty())
        .map(|s| s.to_string());
    let session_id = envelope_session_id.as_deref().map(|conv| {
        format!(
            "{pack}:{flow}:{conv}",
            pack = request.pack_label,
            flow = request.flow_id,
        )
    });
    let session_state_dir = Some(
        request
            .root
            .join("state")
            .join("sessions")
            .join(&request.tenant)
            .join(&team)
            .join(&request.pack_label)
            .join(&request.flow_id),
    );
    let components_map = component_overrides_from_env();
    let opts = RunOptions {
        profile: Profile::Dev(DevProfile {
            tenant_id: request.tenant.clone(),
            team_id: team.clone(),
            user_id: "developer".to_string(),
            ..DevProfile::default()
        }),
        entry_flow: Some(request.flow_id.clone()),
        input: request.input,
        ctx: TenantContext {
            tenant_id: Some(request.tenant),
            team_id: Some(team),
            user_id: Some("developer".to_string()),
            session_id: session_id.clone(),
        },
        mocks: MocksConfig {
            net_allowlist: vec!["127.0.0.1".to_string(), "localhost".to_string()],
            ..MocksConfig::default()
        },
        artifacts_dir: Some(run_dir.clone()),
        signing: SigningPolicy::DevOk,
        dist_offline: request.dist_offline,
        allow_missing_hash: false,
        secrets_manager: Some(secrets_handle.runtime_manager(Some(&request.pack_label))),
        session_state_dir,
        components_map,
        ..RunOptions::default()
    };

    let result = greentic_runner_desktop::run_pack_with_options(&request.pack_path, opts)?;

    // Log node-level execution trace
    for node in &result.node_summaries {
        crate::flow_log::log(
            "NODE",
            &format!(
                "pack={log_pack} flow={log_flow} node={} component={} status={:?} duration={}ms",
                node.node_id, node.component, node.status, node.duration_ms,
            ),
        );
    }

    crate::flow_log::flow_end(
        &log_pack,
        &log_flow,
        &log_tenant,
        &log_team,
        result.status == RunStatus::Success,
        result.error.as_deref(),
    );

    write_run_artifacts(&run_dir, &result)?;

    Ok(RunOutput { result, run_dir })
}
/// Collect component WASM override paths from environment variables.
///
/// Supports two forms:
/// - `GREENTIC_COMPONENT_ADAPTIVE_CARD_WASM=/path/to/component_adaptive_card.wasm`
///   for the shipped adaptive-card component
///   (id `ai.greentic.component-adaptive-card`).
/// - `GREENTIC_COMPONENT_OVERRIDES=id1=/path1,id2=/path2` for arbitrary
///   component overrides keyed by component spec id.
///
/// Missing files are skipped silently; existence is enforced by the runner
/// host at pack load time.
fn component_overrides_from_env() -> HashMap<String, PathBuf> {
    let mut map = HashMap::new();

    if let Ok(path) = std::env::var("GREENTIC_COMPONENT_ADAPTIVE_CARD_WASM") {
        let trimmed = path.trim();
        if !trimmed.is_empty() {
            map.insert(
                "ai.greentic.component-adaptive-card".to_string(),
                PathBuf::from(trimmed),
            );
        }
    }

    if let Ok(list) = std::env::var("GREENTIC_COMPONENT_OVERRIDES") {
        for entry in list.split(',') {
            let entry = entry.trim();
            if entry.is_empty() {
                continue;
            }
            let Some((id, path)) = entry.split_once('=') else {
                continue;
            };
            let id = id.trim();
            let path = path.trim();
            if id.is_empty() || path.is_empty() {
                continue;
            }
            map.insert(id.to_string(), PathBuf::from(path));
        }
    }

    map
}

fn write_run_artifacts(run_dir: &Path, result: &RunResult) -> anyhow::Result<()> {
    let run_json = run_dir.join("run.json");
    let summary_path = run_dir.join("summary.txt");
    let artifacts_path = run_dir.join("artifacts_dir");

    let json = serde_json::to_string_pretty(result)?;
    std::fs::write(run_json, json)?;

    let summary = format!(
        "status: {:?}\npack_id: {}\nflow_id: {}\nerror: {}\n",
        result.status,
        result.pack_id,
        result.flow_id,
        result.error.clone().unwrap_or_else(|| "none".to_string())
    );
    std::fs::write(summary_path, summary)?;
    std::fs::write(artifacts_path, result.artifacts_dir.display().to_string())?;

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::{RunRequest, run_provider_pack_flow};
    use crate::domains::Domain;
    use serde_json::Value;
    use std::path::PathBuf;

    #[test]
    #[ignore = "requires local /tmp ollama repro artifacts"]
    fn local_ollama_repro_stays_on_ollama_path() {
        let bundle = PathBuf::from("/tmp/ollama-runtime-bundle");
        let pack_path = bundle.join("packs/pack.gtpack");
        let input_path =
            bundle.join("state/runs/messaging/ollama-runtime-repro/main/1776707368/input.json");
        let input: Value =
            serde_json::from_slice(&std::fs::read(&input_path).expect("read captured input.json"))
                .expect("parse captured input.json");

        let output = run_provider_pack_flow(RunRequest {
            root: bundle.clone(),
            domain: Domain::Messaging,
            pack_path,
            pack_label: "ollama-runtime-repro".to_string(),
            flow_id: "main".to_string(),
            tenant: "demo".to_string(),
            team: Some("default".to_string()),
            input,
            dist_offline: true,
        })
        .expect("run provider pack flow");

        let error = output.result.error.unwrap_or_default();
        assert!(
            error.contains("OLLAMA_API_KEY"),
            "expected ollama secret failure, got: {error}"
        );
        assert!(
            !error.contains("provider Openai requires an API key"),
            "unexpected openai fallback: {error}"
        );
    }

    #[test]
    #[ignore = "requires local /tmp deep-research repro artifacts"]
    fn local_deep_research_repro_stays_on_ollama_path() {
        let bundle = PathBuf::from("/tmp/deep-research-retest-bundle");
        let pack_path = bundle.join("packs/deep-research-demo.gtpack");
        let input_path =
            bundle.join("state/runs/messaging/deep-research-demo/main/1776805260/input.json");
        let input: Value =
            serde_json::from_slice(&std::fs::read(&input_path).expect("read captured input.json"))
                .expect("parse captured input.json");

        let output = run_provider_pack_flow(RunRequest {
            root: bundle.clone(),
            domain: Domain::Messaging,
            pack_path,
            pack_label: "deep-research-demo".to_string(),
            flow_id: "main".to_string(),
            tenant: "demo".to_string(),
            team: Some("default".to_string()),
            input,
            dist_offline: true,
        })
        .expect("run provider pack flow");

        assert_eq!(
            output.result.status,
            greentic_runner_desktop::RunStatus::Success
        );

        let transcript =
            std::fs::read_to_string(output.run_dir.join("transcript.jsonl")).expect("transcript");
        assert!(
            transcript.contains("\"provider\":\"ollama\""),
            "expected ollama provider in transcript, got: {transcript}"
        );
        assert!(
            transcript.contains("\"default_model\":\"llama3:8b\""),
            "expected default_model in transcript, got: {transcript}"
        );
        assert!(
            !transcript.contains("provider Openai requires an API key"),
            "unexpected openai fallback in transcript: {transcript}"
        );
    }
}