use std::collections::BTreeSet;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use super::{AgentType, ProjectSchema, START, ToolConfig, ToolType, WorkflowType};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeployManifest {
pub schema_version: String,
pub generated_at: DateTime<Utc>,
pub source: DeploySource,
pub app: SpatialAppManifest,
pub runtime: DeployRuntime,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeploySource {
pub kind: String,
pub project_id: String,
pub project_name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpatialAppManifest {
pub id: String,
pub name: String,
pub version: String,
pub description: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub capabilities: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub permissions: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub handoff_allowlist: Vec<String>,
pub default_risk: DeployRiskTier,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub starter_prompts: Vec<String>,
#[serde(default)]
pub runtime: SpatialAppRuntime,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SpatialAppRuntime {
#[serde(default = "default_runtime_mode")]
pub mode: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub workflow_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub root_agent_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub instruction: Option<String>,
pub supports_sub_agents: bool,
pub supports_a2a: bool,
#[serde(default)]
pub callback_mode: DeployCallbackMode,
#[serde(default)]
pub tool_confirmation_policy: DeployToolConfirmationPolicy,
#[serde(default)]
pub guardrails: DeployGuardrailPolicy,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum DeployCallbackMode {
#[default]
Off,
Observe,
Enforce,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum DeployToolConfirmationPolicy {
#[default]
Never,
Always,
PerTool(Vec<String>),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum DeployGuardrailProfile {
HarmfulContent,
PiiRedaction,
#[serde(rename = "output_max_length_2000")]
OutputMaxLength2000,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct DeployGuardrailPolicy {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub input_profiles: Vec<DeployGuardrailProfile>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub output_profiles: Vec<DeployGuardrailProfile>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum DeployRiskTier {
Safe,
Controlled,
Dangerous,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeployRuntime {
pub mode: String,
pub workflow_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub root_agent_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_model: Option<String>,
pub supports_sub_agents: bool,
pub supports_a2a: bool,
}
impl DeployManifest {
pub fn from_project(project: &ProjectSchema) -> Self {
let capabilities = infer_capabilities(project);
let permissions = infer_permissions(project);
let risk = infer_risk_tier(project, &permissions);
let root_agent_id = infer_root_agent_id(project);
let default_model = infer_default_model(project);
let default_provider = infer_default_provider(project);
let app_id = build_app_id(project);
let starter_prompts = infer_starter_prompts(project, root_agent_id.as_deref());
let root_instruction = root_agent_id
.as_ref()
.and_then(|agent_id| project.agents.get(agent_id))
.map(|agent| agent.instruction.trim().to_string())
.filter(|instruction| !instruction.is_empty());
Self {
schema_version: "adk-spatial-os-deploy/v1".to_string(),
generated_at: Utc::now(),
source: DeploySource {
kind: "adk_studio".to_string(),
project_id: project.id.to_string(),
project_name: project.name.clone(),
},
app: SpatialAppManifest {
id: app_id,
name: project.name.clone(),
version: normalize_version(&project.version),
description: normalize_description(project),
capabilities,
permissions,
handoff_allowlist: vec![],
default_risk: risk,
starter_prompts,
runtime: SpatialAppRuntime {
mode: "adk_runner".to_string(),
workflow_type: Some(
workflow_type_label(&project.workflow.workflow_type).to_string(),
),
root_agent_id: root_agent_id.clone(),
default_model: default_model.clone(),
provider: default_provider.clone(),
instruction: root_instruction,
supports_sub_agents: project
.agents
.values()
.any(|agent| !agent.sub_agents.is_empty()),
supports_a2a: false,
callback_mode: DeployCallbackMode::Observe,
tool_confirmation_policy: DeployToolConfirmationPolicy::Never,
guardrails: DeployGuardrailPolicy {
input_profiles: vec![
DeployGuardrailProfile::HarmfulContent,
DeployGuardrailProfile::PiiRedaction,
],
output_profiles: vec![
DeployGuardrailProfile::PiiRedaction,
DeployGuardrailProfile::OutputMaxLength2000,
],
},
},
},
runtime: DeployRuntime {
mode: "adk_runner".to_string(),
workflow_type: workflow_type_label(&project.workflow.workflow_type).to_string(),
root_agent_id,
default_model,
supports_sub_agents: project
.agents
.values()
.any(|agent| !agent.sub_agents.is_empty()),
supports_a2a: false,
},
}
}
}
fn build_app_id(project: &ProjectSchema) -> String {
let slug = project
.name
.to_lowercase()
.chars()
.map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
.collect::<String>()
.split('-')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join("-");
let prefix = if slug.is_empty() {
"studio-app".to_string()
} else {
slug
};
let short_id = project.id.to_string().chars().take(8).collect::<String>();
format!("{prefix}-{short_id}")
}
fn normalize_version(version: &str) -> String {
if version.trim().is_empty() {
"1.0.0".to_string()
} else {
version.to_string()
}
}
fn normalize_description(project: &ProjectSchema) -> String {
if project.description.trim().is_empty() {
format!("Deployed from ADK Studio project {}", project.name)
} else {
project.description.clone()
}
}
fn default_runtime_mode() -> String {
"adk_runner".to_string()
}
fn workflow_type_label(workflow_type: &WorkflowType) -> &'static str {
match workflow_type {
WorkflowType::Single => "single",
WorkflowType::Sequential => "sequential",
WorkflowType::Parallel => "parallel",
WorkflowType::Graph => "graph",
}
}
fn agent_type_label(agent_type: &AgentType) -> &'static str {
match agent_type {
AgentType::Llm => "llm",
AgentType::Tool => "tool",
AgentType::Sequential => "sequential",
AgentType::Parallel => "parallel",
AgentType::Loop => "loop",
AgentType::Router => "router",
AgentType::Graph => "graph",
AgentType::Custom => "custom",
}
}
fn infer_root_agent_id(project: &ProjectSchema) -> Option<String> {
project
.workflow
.edges
.iter()
.find(|edge| edge.from == START)
.map(|edge| edge.to.clone())
.or_else(|| project.agents.keys().next().cloned())
}
fn infer_default_model(project: &ProjectSchema) -> Option<String> {
project
.agents
.values()
.find_map(|agent| agent.model.clone())
.or_else(|| Some(project.settings.default_model.clone()))
}
fn infer_default_provider(project: &ProjectSchema) -> Option<String> {
project
.settings
.default_provider
.as_ref()
.map(|value| value.trim().to_lowercase())
.filter(|value| !value.is_empty())
.map(|provider| {
if provider.contains("vertex") {
"vertex_adc".to_string()
} else {
"ai_studio".to_string()
}
})
}
fn infer_capabilities(project: &ProjectSchema) -> Vec<String> {
let mut capabilities = BTreeSet::new();
capabilities.insert(format!(
"workflow:{}",
workflow_type_label(&project.workflow.workflow_type)
));
for (agent_id, agent) in &project.agents {
capabilities.insert(format!("agent:{}", agent_type_label(&agent.agent_type)));
capabilities.insert(format!("agent-id:{agent_id}"));
for tool in &agent.tools {
capabilities.insert(format!("tool:{tool}"));
}
}
for tool_name in project.tools.keys() {
capabilities.insert(format!("tool:{tool_name}"));
}
for tool_schema in project.tools.values() {
let kind = match tool_schema.tool_type {
ToolType::Builtin => "builtin",
ToolType::Mcp => "mcp",
ToolType::Custom => "custom",
};
capabilities.insert(format!("tool-kind:{kind}"));
}
for action in project.action_nodes.values() {
capabilities.insert(format!("action:{}", action.node_type()));
}
capabilities.into_iter().collect()
}
fn infer_permissions(project: &ProjectSchema) -> Vec<String> {
let mut permissions = BTreeSet::new();
permissions.insert("runner:invoke".to_string());
for agent in project.agents.values() {
for tool in &agent.tools {
apply_tool_permission(tool, &mut permissions);
}
}
for tool in project.tools.keys() {
apply_tool_permission(tool, &mut permissions);
}
for config in project.tool_configs.values() {
match config {
ToolConfig::Mcp(_) => {
permissions.insert("tool:mcp:invoke".to_string());
}
ToolConfig::Function(_) => {
permissions.insert("tool:function:invoke".to_string());
}
ToolConfig::Browser(_) => {
permissions.insert("net:web:read".to_string());
}
}
}
for action in project.action_nodes.values() {
match action.node_type() {
"http" => {
permissions.insert("net:http:request".to_string());
}
"database" => {
permissions.insert("db:access".to_string());
}
"email" => {
permissions.insert("comm:email:send".to_string());
}
"notification" => {
permissions.insert("comm:notify:send".to_string());
}
"file" => {
permissions.insert("fs:read_write".to_string());
}
"code" => {
permissions.insert("code:execute".to_string());
}
_ => {}
}
}
permissions.into_iter().collect()
}
fn apply_tool_permission(tool: &str, permissions: &mut BTreeSet<String>) {
match tool {
"google_search" => {
permissions.insert("net:search:read".to_string());
}
"browser" | "web_browse" => {
permissions.insert("net:web:read".to_string());
}
"code_exec" => {
permissions.insert("code:execute".to_string());
}
"file_read" | "file_write" => {
permissions.insert("fs:read_write".to_string());
}
_ => {}
}
}
fn infer_risk_tier(project: &ProjectSchema, permissions: &[String]) -> DeployRiskTier {
let has_high_risk_action = project
.action_nodes
.values()
.any(|action| matches!(action.node_type(), "code" | "database" | "file"));
if has_high_risk_action || permissions.iter().any(|perm| perm == "tool:mcp:invoke") {
return DeployRiskTier::Dangerous;
}
let has_controlled_actions = project.action_nodes.values().any(|action| {
matches!(
action.node_type(),
"http" | "email" | "notification" | "set" | "transform"
)
});
if has_controlled_actions || permissions.len() > 1 {
DeployRiskTier::Controlled
} else {
DeployRiskTier::Safe
}
}
fn infer_starter_prompts(project: &ProjectSchema, root_agent_id: Option<&str>) -> Vec<String> {
let mut prompts = Vec::new();
prompts.push(format!(
"Run {} and summarize the highest-priority outcomes.",
project.name
));
if let Some(root_id) = root_agent_id {
if let Some(root_agent) = project.agents.get(root_id) {
let instruction = root_agent.instruction.trim();
if !instruction.is_empty() {
let first_sentence = instruction
.split_terminator('.')
.next()
.unwrap_or(instruction)
.trim();
if !first_sentence.is_empty() {
prompts.push(format!("Follow this objective: {first_sentence}."));
}
}
if !root_agent.tools.is_empty() {
let tool_preview = root_agent.tools.iter().take(3).cloned().collect::<Vec<_>>();
prompts.push(format!(
"Use {} and return a concise action plan.",
tool_preview.join(", ")
));
}
}
}
if prompts.len() < 3 {
prompts.push(format!(
"Provide a safe, step-by-step execution plan for {}.",
project.name
));
}
prompts.into_iter().take(3).collect()
}
#[cfg(test)]
mod tests {
use super::{DeployManifest, DeployRiskTier};
use crate::schema::{AgentSchema, ProjectSchema, START, WorkflowType};
#[test]
fn deploy_manifest_infers_root_agent_and_capabilities() {
let mut project = ProjectSchema::new("Support Ops");
project.workflow.workflow_type = WorkflowType::Sequential;
project.agents.insert(
"router".to_string(),
AgentSchema::llm("gemini-3.1-flash-lite-preview")
.with_instruction("route support tickets")
.with_tools(vec!["google_search".to_string()]),
);
project
.workflow
.edges
.push(crate::schema::Edge::new(START, "router"));
let manifest = DeployManifest::from_project(&project);
assert!(manifest.app.id.starts_with("support-ops-"));
assert_eq!(manifest.runtime.root_agent_id.as_deref(), Some("router"));
assert!(
manifest
.app
.capabilities
.iter()
.any(|cap| cap == "workflow:sequential")
);
assert_eq!(manifest.app.default_risk, DeployRiskTier::Controlled);
}
#[test]
fn deploy_manifest_marks_dangerous_for_high_risk_action_nodes() {
let payload = r#"{
"type": "code",
"id": "run_js",
"name": "Run JS",
"description": "exec code",
"language": "javascript",
"script": "return {}",
"errorHandling": {"mode":"stop"},
"tracing": {"enabled": false, "logLevel":"error"},
"callbacks": {},
"execution": {"timeout": 30000},
"mapping": {"outputKey":"result"}
}"#;
let code_node: crate::codegen::action_node_types::ActionNodeConfig =
serde_json::from_str(payload).expect("valid code action node");
let mut project = ProjectSchema::new("Risky");
project.action_nodes.insert("run_js".to_string(), code_node);
let manifest = DeployManifest::from_project(&project);
assert_eq!(manifest.app.default_risk, DeployRiskTier::Dangerous);
}
}