agent-workspace-contract 0.7.0

Transport-neutral contracts for Agent Infra workspace APIs
Documentation
//! Scope-owned Environment Image catalog and provider build contracts.

use super::*;

/// Provider-owned images used when product create APIs omit every Workspace
/// catalog snapshot/template selector. Scope-owned snapshots are selected only
/// by an explicit snapshot id.
pub const E2B_PLATFORM_TEMPLATE_ID: &str = "code-interpreter-v1";
pub const E2B_BUILTIN_TEMPLATE_IDS: [&str; 3] = [
    E2B_PLATFORM_TEMPLATE_ID,
    "context-management-moc",
    "vpc-sandbox",
];
pub const MOULIN_PLATFORM_SNAPSHOT_ID: &str = "python";

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum SnapshotVisibility {
    #[default]
    Private,
    Public,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum SnapshotBuildSpec {
    Dockerfile {
        dockerfile: String,
    },
    Template {
        #[serde(alias = "pipPackages")]
        pip_packages: String,
    },
    Git {
        #[serde(rename = "gitUrl", alias = "git_url")]
        git_url: String,
        #[serde(default, rename = "commitSha", alias = "commit_sha")]
        commit_sha: String,
    },
}

/// Provider identity at the Workspace image-build boundary. Product records
/// may remain wire-compatible strings, while build adapters cannot receive an
/// unknown provider.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorkspaceBuildProvider {
    Moulin,
    E2b,
    Custom(String),
}

impl WorkspaceBuildProvider {
    pub fn parse(value: &str) -> Result<Self> {
        match value {
            "moulin" => Ok(Self::Moulin),
            "e2b" => Ok(Self::E2b),
            other if !other.trim().is_empty() => Ok(Self::Custom(other.into())),
            _ => Err(anyhow::anyhow!("Workspace build provider is required")),
        }
    }

    pub fn as_str(&self) -> &str {
        match self {
            Self::Moulin => "moulin",
            Self::E2b => "e2b",
            Self::Custom(value) => value,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MoulinImageBuildRecipe {
    Dockerfile { dockerfile: String },
    Git { git_url: String, commit_sha: String },
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum E2bImageBuildRecipe {
    Dockerfile { dockerfile: String },
    Template { pip_packages: String },
    Git { git_url: String, commit_sha: String },
}

/// Type-driven provider dispatch. Invalid combinations such as a Moulin
/// template build cannot cross the application-to-adapter port.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EnvironmentImageBuildRequest {
    Moulin(MoulinImageBuildRecipe),
    E2b(E2bImageBuildRecipe),
    Custom {
        provider: String,
        recipe: SnapshotBuildSpec,
    },
}

impl EnvironmentImageBuildRequest {
    pub fn from_parts(provider: &str, spec: &SnapshotBuildSpec) -> Result<Self> {
        match (WorkspaceBuildProvider::parse(provider)?, spec) {
            (WorkspaceBuildProvider::Moulin, SnapshotBuildSpec::Dockerfile { dockerfile }) => {
                Ok(Self::Moulin(MoulinImageBuildRecipe::Dockerfile {
                    dockerfile: dockerfile.clone(),
                }))
            }
            (
                WorkspaceBuildProvider::Moulin,
                SnapshotBuildSpec::Git {
                    git_url,
                    commit_sha,
                },
            ) => Ok(Self::Moulin(MoulinImageBuildRecipe::Git {
                git_url: git_url.clone(),
                commit_sha: commit_sha.clone(),
            })),
            (WorkspaceBuildProvider::E2b, SnapshotBuildSpec::Dockerfile { dockerfile }) => {
                Ok(Self::E2b(E2bImageBuildRecipe::Dockerfile {
                    dockerfile: dockerfile.clone(),
                }))
            }
            (WorkspaceBuildProvider::E2b, SnapshotBuildSpec::Template { pip_packages }) => {
                Ok(Self::E2b(E2bImageBuildRecipe::Template {
                    pip_packages: pip_packages.clone(),
                }))
            }
            (
                WorkspaceBuildProvider::E2b,
                SnapshotBuildSpec::Git {
                    git_url,
                    commit_sha,
                },
            ) => Ok(Self::E2b(E2bImageBuildRecipe::Git {
                git_url: git_url.clone(),
                commit_sha: commit_sha.clone(),
            })),
            (WorkspaceBuildProvider::Moulin, SnapshotBuildSpec::Template { .. }) => Err(
                anyhow::anyhow!("Moulin does not support E2B template image recipes"),
            ),
            (WorkspaceBuildProvider::Custom(provider), recipe) => Ok(Self::Custom {
                provider,
                recipe: recipe.clone(),
            }),
        }
    }

    pub fn provider(&self) -> WorkspaceBuildProvider {
        match self {
            Self::Moulin(_) => WorkspaceBuildProvider::Moulin,
            Self::E2b(_) => WorkspaceBuildProvider::E2b,
            Self::Custom { provider, .. } => WorkspaceBuildProvider::Custom(provider.clone()),
        }
    }

    pub fn provider_name(&self) -> &str {
        match self {
            Self::Moulin(_) => "moulin",
            Self::E2b(_) => "e2b",
            Self::Custom { provider, .. } => provider,
        }
    }

    pub fn snapshot_spec(&self) -> SnapshotBuildSpec {
        match self {
            Self::Moulin(MoulinImageBuildRecipe::Dockerfile { dockerfile })
            | Self::E2b(E2bImageBuildRecipe::Dockerfile { dockerfile }) => {
                SnapshotBuildSpec::Dockerfile {
                    dockerfile: dockerfile.clone(),
                }
            }
            Self::E2b(E2bImageBuildRecipe::Template { pip_packages }) => {
                SnapshotBuildSpec::Template {
                    pip_packages: pip_packages.clone(),
                }
            }
            Self::Moulin(MoulinImageBuildRecipe::Git {
                git_url,
                commit_sha,
            })
            | Self::E2b(E2bImageBuildRecipe::Git {
                git_url,
                commit_sha,
            }) => SnapshotBuildSpec::Git {
                git_url: git_url.clone(),
                commit_sha: commit_sha.clone(),
            },
            Self::Custom { recipe, .. } => recipe.clone(),
        }
    }
}

/// Product-facing snapshot recipe size limits. The build adapters impose
/// additional limits on fetched repository contents and generated archives.
pub const SNAPSHOT_DOCKERFILE_MAX_BYTES: usize = 1024 * 1024;
pub const SNAPSHOT_TEMPLATE_PACKAGES_MAX_BYTES: usize = 64 * 1024;

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EnvironmentCatalogConfig {
    pub default_snapshot: Option<crate::DefaultExecutionSnapshot>,
    pub default_provider: Option<String>,
    pub default_resources: SandboxResources,
    pub default_builds: BTreeMap<String, SnapshotBuildSpec>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct CreateSnapshotResourceRequest {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub alias: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub provider: Option<String>,
    #[serde(default)]
    pub visibility: SnapshotVisibility,
    #[serde(default)]
    pub owners: Vec<OwnerRef>,
    #[serde(default)]
    pub builtin: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub build: Option<SnapshotBuildSpec>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct BuildBuiltinSnapshotRequest {
    pub provider: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub git_url: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub commit_sha: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub dockerfile: Option<String>,
}

pub const ADMIN_BUILD_BUILTIN_SNAPSHOTS_PATH: &str = "/admin/api/workspace-snapshots:build-builtin";
pub const ADMIN_SNAPSHOTS_PATH: &str = "/admin/api/workspace-snapshots";
pub const ADMIN_SNAPSHOT_PATH: &str = "/admin/api/workspace-snapshots/{snapshotId}";
pub const CATALOG_SNAPSHOT_PATH: &str = "/internal/v1/workspace/snapshots:catalog/{snapshotId}";

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ProviderBuildHandle {
    pub build_id: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub resource_external_id: Option<String>,
}

/// Provider build observation captured by background reconciliation. Logs are
/// bounded by the adapter/runtime and never contain provider credentials.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EnvironmentBuildObservation {
    pub stage: Option<String>,
    pub logs: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EnvironmentBuildStatus {
    Building(ProviderBuildHandle),
    BuildingObserved {
        handle: ProviderBuildHandle,
        observation: EnvironmentBuildObservation,
    },
    Ready(ProviderResourceRef),
    ReadyWithMetadata {
        provider_ref: ProviderResourceRef,
        digest: Option<String>,
        size_bytes: Option<u64>,
    },
    ReadyObserved {
        provider_ref: ProviderResourceRef,
        digest: Option<String>,
        size_bytes: Option<u64>,
        observation: EnvironmentBuildObservation,
    },
    Failed(String),
    FailedObserved {
        error: String,
        observation: EnvironmentBuildObservation,
    },
}

/// Build-only port. Implementations must not create Computer or Sandbox instances.
#[async_trait]
pub trait EnvironmentBuildPort: Send + Sync + Debug {
    async fn submit_environment_image(
        &self,
        request: &EnvironmentImageBuildRequest,
        idempotency_key: &str,
    ) -> Result<EnvironmentBuildStatus>;

    async fn poll_environment_image(
        &self,
        _provider: WorkspaceBuildProvider,
        _handle: &ProviderBuildHandle,
    ) -> Result<EnvironmentBuildStatus> {
        Err(anyhow::anyhow!(
            "build provider does not support asynchronous status observation"
        ))
    }
}