use std::{
path::PathBuf,
time::{Duration, Instant},
};
use agent_infra_sdk::{
InfraClient,
agent_contract::{CreateAgentRequest, ResourceRelations},
context_contract::{ConversationRecord, ConversationScope},
deploy_contract::{CreateRuntimeRepositoryRequest, RuntimeRepositoryStatus},
runtime_identity_contract::{CreateRuntimeIdentityRequest, RuntimeIdentityKind},
trace_contract::InitializeRuntimeBindingRequest,
};
use anyhow::{Result, anyhow, bail};
use serde_json::json;
use crate::{CommandOutput, runtime_repo_checkout};
pub(crate) struct InitArgs {
pub name: String,
pub template_id: Option<String>,
pub dir: Option<PathBuf>,
pub no_clone: bool,
pub owner_email: Option<String>,
}
pub(crate) async fn execute(client: &InfraClient, args: InitArgs) -> Result<CommandOutput> {
let name = normalize_init_name(&args.name)?;
eprintln!("init: creating runtime {name}");
let runtime = client
.runtime_identity()
.create(&CreateRuntimeIdentityRequest {
id: None,
name: name.clone(),
kind: RuntimeIdentityKind::Code,
agent_id: None,
relations: Default::default(),
metadata: Default::default(),
idempotency_key: String::new(),
})
.await?;
let runtime_id = runtime.identity.id.as_str().to_string();
eprintln!("init: creating agent {name}");
let agent = client
.agents()
.create(&CreateAgentRequest {
name: name.clone(),
description: format!("initialized by infra init ({name})"),
relations: ResourceRelations {
runtime_ids: vec![runtime_id.clone()],
..Default::default()
},
idempotency_key: String::new(),
})
.await?;
let agent_id = agent.id.as_str().to_string();
let template_id = resolve_template_id(client, args.template_id).await?;
eprintln!("init: initializing runtime repository from {template_id}");
let mut record = client
.deploy()
.create_runtime_repository(
&CreateRuntimeRepositoryRequest {
template_id,
project_name: name.clone(),
project_path: None,
namespace: None,
description: Some(format!("initialized by infra init ({name})")),
visibility: Default::default(),
owners: runtime_repo_checkout::merge_identity_owners(
Vec::new(),
Some(&agent_id),
Some(&runtime_id),
),
},
&format!("init-repo-{name}"),
)
.await?;
let deadline = Instant::now() + Duration::from_secs(120);
while record.status == RuntimeRepositoryStatus::Importing {
if Instant::now() >= deadline {
bail!(
"runtime repository import timed out for {}",
record.path_with_namespace
);
}
tokio::time::sleep(Duration::from_secs(2)).await;
record = client.deploy().runtime_repository(&record.id).await?;
}
if record.status == RuntimeRepositoryStatus::Failed {
bail!(
"{}",
record
.error_message
.unwrap_or_else(|| "runtime repository import failed".to_string())
);
}
let checkout = runtime_repo_checkout::checkout_runtime_repository(
&record,
&runtime_repo_checkout::CheckoutOptions {
dir: args.dir,
clone: !args.no_clone,
agent_id: Some(agent_id.clone()),
runtime_id: Some(runtime_id.clone()),
},
)?;
eprintln!(
"checked out {} ({})",
checkout.display(),
checkout.join(".env.local").display()
);
eprintln!("init: initializing trace runtime binding");
let binding = client
.trace()
.initialize_runtime_binding(
&runtime_id,
&InitializeRuntimeBindingRequest {
project_name: Some(name.clone()),
agent_id: Some(agent.id.clone()),
owner_email: args.owner_email,
},
&format!("init-trace-{runtime_id}"),
)
.await?;
let conversation_id = format!("conv_init_{name}");
let message_id = format!("msg_init_{name}");
eprintln!("init: writing context conversation {conversation_id}");
client
.context()
.append_messages(vec![ConversationRecord {
id: message_id.clone(),
scope: ConversationScope {
conversation_id: conversation_id.clone(),
user_id: Some(agent.owner_user_id.clone()),
agent_id: Some(agent_id.clone()),
agent_space_id: None,
runtime_type: None,
relations: ResourceRelations {
agent_ids: vec![agent.id.clone()],
runtime_ids: vec![runtime_id.clone()],
user_ids: vec![agent.owner_user_id.clone()],
},
},
role: "system".into(),
content: json!({ "text": "initialized by infra init" }),
name: None,
tool_call_id: None,
sequence: 0,
created_at_ms: 0,
metadata: json!({ "source": "infra-init" }),
}])
.await?;
let recent = client
.context()
.recent_messages(&conversation_id, Some(10), &[])
.await?;
if !recent.iter().any(|item| item.id == message_id) {
bail!("context recent did not return initialized message {message_id}");
}
eprintln!("init: ensuring default model config");
let model_config = client
.model()
.ensure_default_agent_config(&agent_id)
.await?;
let model_config_id = model_config
.get("id")
.and_then(|value| value.as_str())
.unwrap_or("default")
.to_string();
CommandOutput::json(json!({
"runtimeId": runtime_id,
"agentId": agent_id,
"rrepoId": record.id,
"checkoutDir": checkout,
"langfuseProjectId": binding.langfuse_project_id,
"conversationId": conversation_id,
"messageId": message_id,
"modelConfigId": model_config_id,
}))
}
fn normalize_init_name(name: &str) -> Result<String> {
let normalized = name
.trim()
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() {
character.to_ascii_lowercase()
} else {
'_'
}
})
.collect::<String>()
.trim_matches('_')
.to_string();
if normalized.is_empty() {
bail!("--name must contain at least one letter or digit");
}
Ok(normalized)
}
async fn resolve_template_id(client: &InfraClient, template_id: Option<String>) -> Result<String> {
if let Some(template_id) = template_id {
let template_id = template_id.trim();
if template_id.is_empty() {
bail!("--template-id must not be empty");
}
return Ok(template_id.to_string());
}
let page = client.deploy().list_runtime_templates(None, None).await?;
match page.items.as_slice() {
[template] => Ok(template.id.clone()),
[] => Err(anyhow!(
"--template-id is required because no enabled runtime template is available"
)),
_ => Err(anyhow!(
"--template-id is required because multiple enabled runtime templates exist"
)),
}
}