use serde::{Deserialize, Serialize};
use crate::error::{Result, StoreError};
pub const PROVISION_VERSION: u32 = 1;
pub const MAX_SPEC_BYTES: u64 = 8 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ProvisionSpec {
pub version: u32,
pub identity: ChildIdentity,
pub executor: ExecutorSpec,
pub fabric_dir: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub storage_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<ModelRefSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disabled_tools: Option<Vec<String>>,
#[serde(default)]
pub limits: Limits,
#[serde(default)]
pub secrets: SecretsEnvelope,
#[serde(default)]
pub reusable: bool,
#[serde(default)]
pub placement: Placement,
#[serde(default)]
pub capabilities: Capabilities,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Capabilities {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mcp: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skills_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mcp_proxy: Option<McpProxyConfig>,
#[serde(default)]
pub enforce_permissions: bool,
#[serde(default)]
pub nested_spawn: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_spawn_depth: Option<u32>,
#[serde(default)]
pub bypass: bool,
#[serde(default)]
pub no_human_approver: bool,
#[serde(default)]
pub guardian_read_only: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct McpProxyConfig {
pub orchestrator: String,
pub endpoint: String,
pub token: String,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Placement {
#[default]
Local,
Remote { endpoint: String },
Schedulable { pool: String },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChildIdentity {
pub child_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_key: Option<String>,
#[serde(default)]
pub role: String,
#[serde(default)]
pub depth: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ExecutorSpec {
Echo,
BambooRuntime,
CliAdapter { command: String, args: Vec<String> },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ModelRefSpec {
pub provider: String,
pub model: String,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Limits {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run_timeout_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub idle_timeout_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_rounds: Option<u32>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct SecretsEnvelope {
#[serde(default)]
pub provider_credentials: Vec<ScopedCredential>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ScopedCredential {
pub provider: String,
pub api_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_type: Option<String>,
}
impl ProvisionSpec {
pub fn new(identity: ChildIdentity, executor: ExecutorSpec, fabric_dir: String) -> Self {
Self {
version: PROVISION_VERSION,
identity,
executor,
fabric_dir,
storage_dir: None,
workspace: None,
model: None,
disabled_tools: None,
limits: Limits::default(),
secrets: SecretsEnvelope::default(),
reusable: false,
placement: Placement::default(),
capabilities: Capabilities::default(),
}
}
pub fn to_json(&self) -> Result<String> {
serde_json::to_string(self)
.map_err(|e| StoreError::decode(std::path::Path::new("<provision>"), e))
}
pub fn from_json(s: &str) -> Result<Self> {
serde_json::from_str(s)
.map_err(|e| StoreError::decode(std::path::Path::new("<provision>"), e))
}
pub async fn read_from_stdin() -> Result<Self> {
use tokio::io::AsyncReadExt;
let mut buf = Vec::new();
tokio::io::stdin()
.take(MAX_SPEC_BYTES)
.read_to_end(&mut buf)
.await
.map_err(|e| StoreError::io("<stdin>", e))?;
let text = String::from_utf8_lossy(&buf);
Self::from_json(text.trim())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn spec() -> ProvisionSpec {
let mut s = ProvisionSpec::new(
ChildIdentity {
child_id: "c1".into(),
parent_id: Some("p1".into()),
project_key: Some("proj".into()),
role: "researcher".into(),
depth: 0,
},
ExecutorSpec::Echo,
"/tmp/fabric".into(),
);
s.model = Some(ModelRefSpec {
provider: "anthropic".into(),
model: "claude-sonnet-4-6".into(),
});
s.secrets.provider_credentials.push(ScopedCredential {
provider: "anthropic".into(),
api_key: "sk-test".into(),
base_url: None,
provider_type: None,
});
s
}
#[test]
fn round_trips() {
let s = spec();
let parsed = ProvisionSpec::from_json(&s.to_json().unwrap()).unwrap();
assert_eq!(parsed, s);
}
#[test]
fn unknown_fields_are_ignored_forward_compat() {
let mut v: serde_json::Value = serde_json::from_str(&spec().to_json().unwrap()).unwrap();
v["future_field"] = serde_json::json!({"x": 1});
v["identity"]["future_sub"] = serde_json::json!(true);
let parsed = ProvisionSpec::from_json(&v.to_string()).unwrap();
assert_eq!(parsed.identity.child_id, "c1");
}
#[test]
fn missing_optional_fields_default_backward_compat() {
let minimal = serde_json::json!({
"version": 1,
"identity": { "child_id": "c9" },
"executor": { "kind": "echo" },
"fabric_dir": "/tmp/f",
});
let parsed = ProvisionSpec::from_json(&minimal.to_string()).unwrap();
assert_eq!(parsed.identity.child_id, "c9");
assert_eq!(parsed.executor, ExecutorSpec::Echo);
assert!(parsed.model.is_none());
assert!(parsed.secrets.provider_credentials.is_empty());
assert_eq!(parsed.limits, Limits::default());
assert_eq!(parsed.placement, Placement::Local);
}
#[test]
fn placement_defaults_local_and_remote_round_trips() {
let v: serde_json::Value = serde_json::from_str(&spec().to_json().unwrap()).unwrap();
assert_eq!(v["placement"]["kind"], "local");
let mut s = spec();
s.placement = Placement::Remote {
endpoint: "wss://gpu-host:8443".into(),
};
let parsed = ProvisionSpec::from_json(&s.to_json().unwrap()).unwrap();
assert_eq!(
parsed.placement,
Placement::Remote {
endpoint: "wss://gpu-host:8443".into()
}
);
}
#[test]
fn capabilities_default_empty_and_round_trip() {
assert_eq!(spec().capabilities, Capabilities::default());
let mut s = spec();
s.capabilities = Capabilities {
mcp: Some(serde_json::json!({ "version": 1, "servers": [] })),
skills_dir: Some("/home/u/.bamboo/skills".into()),
mcp_proxy: None,
enforce_permissions: false,
nested_spawn: false,
max_spawn_depth: None,
bypass: false,
no_human_approver: false,
guardian_read_only: false,
};
let parsed = ProvisionSpec::from_json(&s.to_json().unwrap()).unwrap();
assert_eq!(
parsed.capabilities.skills_dir.as_deref(),
Some("/home/u/.bamboo/skills")
);
assert!(parsed.capabilities.mcp.is_some());
let minimal = serde_json::json!({
"version": 1,
"identity": { "child_id": "c" },
"executor": { "kind": "echo" },
"fabric_dir": "/tmp/f",
});
let parsed = ProvisionSpec::from_json(&minimal.to_string()).unwrap();
assert_eq!(parsed.capabilities, Capabilities::default());
}
#[test]
fn enforce_permissions_defaults_false_and_round_trips() {
assert!(!Capabilities::default().enforce_permissions);
let mut s = spec();
s.capabilities.enforce_permissions = true;
let parsed = ProvisionSpec::from_json(&s.to_json().unwrap()).unwrap();
assert!(parsed.capabilities.enforce_permissions);
}
#[test]
fn executor_tags_are_stable() {
let v: serde_json::Value = serde_json::from_str(&spec().to_json().unwrap()).unwrap();
assert_eq!(v["executor"]["kind"], "echo");
let cli = ExecutorSpec::CliAdapter {
command: "claude".into(),
args: vec!["-p".into()],
};
let vv = serde_json::to_value(&cli).unwrap();
assert_eq!(vv["kind"], "cli_adapter");
assert_eq!(
serde_json::to_value(ExecutorSpec::BambooRuntime).unwrap()["kind"],
"bamboo_runtime"
);
}
}