diff --git a/console/index.html b/console/index.html
index 8dccee8..ce892e2 100644
--- a/console/index.html
+++ b/console/index.html
@@ -90,6 +90,31 @@
+
diff --git a/console/src/compose.ts b/console/src/compose.ts
index 7b6b1fd..328b267 100644
--- a/console/src/compose.ts
+++ b/console/src/compose.ts
@@ -152,6 +152,22 @@ export function initComposeTab(): void {
const parseLibrary = (): Library => JSON.parse(text.value) as Library;
+ // Deploy form (revealed after a successful preview so the operator deploys
+ // exactly what they just previewed).
+ const deployForm = document.getElementById("compose-deploy-form") as HTMLFormElement | null;
+ const nsInput = document.getElementById("compose-ns") as HTMLInputElement | null;
+ const nameInput = document.getElementById("compose-name") as HTMLInputElement | null;
+ const imageInput = document.getElementById("compose-image") as HTMLInputElement | null;
+ const deployBtn = document.getElementById("compose-deploy-btn") as HTMLButtonElement | null;
+ const deployStatusEl = document.getElementById("compose-deploy-status");
+
+ const setDeployStatus = (msg: string, cls = ""): void => {
+ if (deployStatusEl) {
+ deployStatusEl.textContent = msg;
+ deployStatusEl.className = cls ? `compose-status ${cls}` : "compose-status";
+ }
+ };
+
invoke("compose_library_get")
.then((lib) => {
text.value = JSON.stringify(lib, null, 2);
@@ -203,9 +219,58 @@ export function initComposeTab(): void {
const preview = await invoke("compose_preview", { library: lib, template, overlay });
if (out) out.innerHTML = renderPreviewHtml(preview);
setStatus(`composed — ${preview.files.length} files`, "ok");
+ // Reveal the deploy form and prefill sensible defaults from the preview.
+ if (deployForm) deployForm.hidden = false;
+ if (nameInput && !nameInput.value) nameInput.value = overlay ?? template;
+ if (imageInput) imageInput.placeholder = preview.image_tag;
+ setDeployStatus("");
} catch (e) {
if (out) out.innerHTML = "";
+ if (deployForm) deployForm.hidden = true;
setStatus(`compose failed: ${errText(e)}`, "err");
}
});
+
+ deployForm?.addEventListener("submit", async (ev) => {
+ ev.preventDefault();
+ let lib: Library;
+ try {
+ lib = parseLibrary();
+ } catch (e) {
+ setDeployStatus(`invalid JSON: ${errText(e)}`, "err");
+ return;
+ }
+ const template = tmplSel.value;
+ const name = nameInput?.value.trim() ?? "";
+ if (!template) {
+ setDeployStatus("preview a template first", "err");
+ return;
+ }
+ if (!name) {
+ setDeployStatus("agent name is required", "err");
+ return;
+ }
+ const overlay = ovlSel.value || null;
+ const namespace = nsInput?.value.trim() || "default";
+ const image = imageInput?.value.trim() || null;
+ if (deployBtn) deployBtn.disabled = true;
+ setDeployStatus("deploying…");
+ try {
+ const res = await invoke<{
+ action?: string;
+ objects?: number;
+ image?: string;
+ digest?: string;
+ }>("deploy_provision", { library: lib, template, overlay, name, namespace, image });
+ const action = res.action ? res.action.toLowerCase() : "applied";
+ setDeployStatus(
+ `${action} ${namespace}/${name} @ ${res.image ?? image ?? "?"} — ${res.objects ?? 0} files pushed`,
+ "ok",
+ );
+ } catch (e) {
+ setDeployStatus(`deploy failed: ${errText(e)}`, "err");
+ } finally {
+ if (deployBtn) deployBtn.disabled = false;
+ }
+ });
}
diff --git a/console/src/styles.css b/console/src/styles.css
index 26705ff..ee28bc3 100644
--- a/console/src/styles.css
+++ b/console/src/styles.css
@@ -1003,3 +1003,33 @@ button.act:disabled {
color: var(--muted);
font-style: italic;
}
+
+/* Compose → Deploy form (agent-deployment ADR, slice 2). */
+.compose-deploy {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ margin-top: 14px;
+ padding-top: 12px;
+ border-top: 1px solid var(--border);
+}
+.compose-deploy-head {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text);
+}
+.compose-deploy label {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ font-size: 12px;
+ color: var(--muted);
+}
+.compose-input {
+ padding: 6px 8px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--bg);
+ color: var(--text);
+ font: inherit;
+}
diff --git a/crates/oab-mcp/src/lib.rs b/crates/oab-mcp/src/lib.rs
index ff29786..8daa59a 100644
--- a/crates/oab-mcp/src/lib.rs
+++ b/crates/oab-mcp/src/lib.rs
@@ -6,7 +6,8 @@
//!
//! - read: `deploy_list`, `deploy_get`, `get_agent_states`, `deploy_events`,
//! `runtime_context`, `fleet_config`
-//! - write: `deploy_apply`, `deploy_scale`, `deploy_delete`, `fleet_config_write`
+//! - write: `deploy_apply`, `deploy_provision`, `deploy_scale`, `deploy_delete`,
+//! `fleet_config_write`
//!
//! **Transport-agnostic on purpose.** The handler is a *library* so the same
//! tool logic serves two front doors: the `oab-mcp` binary drives it over
@@ -143,6 +144,24 @@ pub fn tools() -> Vec {
"required": ["name", "size"]
})),
),
+ Tool::new(
+ "deploy_provision",
+ "Provision an agent from the compose library: compose template ⊕ overlay into a file bundle, push it to the agent's S3 artifacts prefix, and redeploy the ECS service at the chosen image tag. Reuses the agent's stored manifest for networking/resources/secrets, so the agent must already have been created.",
+ as_map(json!({
+ "type": "object",
+ "properties": {
+ "library": { "type": "object", "description": "The compose library document: { templates, overlays, skills }." },
+ "template": { "type": "string", "description": "Template name in the library." },
+ "overlay": { "type": "string", "description": "Overlay name (optional; omitted composes the bare template)." },
+ "name": { "type": "string", "description": "Agent / service name (service = oab-{namespace}-{name})." },
+ "namespace": { "type": "string", "description": "Namespace (default \"default\")." },
+ "image_tag": { "type": "string", "description": "Image tag override (defaults to the bundle's own image tag)." },
+ "fleet": { "type": "string", "description": "Fleet name (see fleet_config): targets the fleet's cluster and managing credential; a write to a service outside the fleet's members is refused. Overrides the cluster arg." },
+ "cluster": { "type": "string", "description": "ECS cluster (defaults to the server's configured cluster)." }
+ },
+ "required": ["library", "template", "name"]
+ })),
+ ),
Tool::new(
"deploy_delete",
"Delete a control-plane resource (e.g. an OABService).",
@@ -294,6 +313,7 @@ impl OabMcp {
"get_agent_states" => self.t_states(args).await,
"deploy_events" => self.t_events(args).await,
"deploy_apply" => self.t_apply(args).await,
+ "deploy_provision" => self.t_provision(args).await,
"deploy_scale" => self.t_scale(args).await,
"deploy_delete" => self.t_delete(args).await,
"runtime_context" => self.t_runtime_context(args).await,
@@ -471,6 +491,61 @@ impl OabMcp {
}))
}
+ async fn t_provision(&self, args: &Map) -> Result {
+ let t = self.target(args)?;
+ let cluster = t.cluster.clone();
+ let namespace = args
+ .get("namespace")
+ .and_then(Value::as_str)
+ .unwrap_or("default");
+ let name = args
+ .get("name")
+ .and_then(Value::as_str)
+ .ok_or_else(|| anyhow::anyhow!("missing required arg: name"))?;
+ let template = args
+ .get("template")
+ .and_then(Value::as_str)
+ .ok_or_else(|| anyhow::anyhow!("missing required arg: template"))?;
+ let overlay = args.get("overlay").and_then(Value::as_str);
+ let image = args.get("image_tag").and_then(Value::as_str);
+ let library: scp::Library = serde_json::from_value(
+ args.get("library")
+ .cloned()
+ .ok_or_else(|| anyhow::anyhow!("missing required arg: library"))?,
+ )
+ .map_err(|e| anyhow::anyhow!("invalid library: {e}"))?;
+
+ // Same fleet-scope guard as scale/delete: a fleet handle only provisions
+ // its own members, so a scoped call can't reach a co-located non-member.
+ let service_name = format!("oab-{namespace}-{name}");
+ if !t.includes(&service_name, name) {
+ anyhow::bail!("service {service_name:?} is not a member of the named fleet");
+ }
+
+ let outcome = scp::provision_from_library(
+ &self.aws_for(&cluster).await,
+ &cluster,
+ namespace,
+ name,
+ &library,
+ template,
+ overlay,
+ image,
+ )
+ .await?;
+ Ok(json!({
+ "ok": true,
+ "cluster": cluster,
+ "namespace": namespace,
+ "name": name,
+ "image": outcome.image,
+ "digest": outcome.digest,
+ "objects": outcome.objects,
+ "action": outcome.action,
+ "services_applied": outcome.services_applied,
+ }))
+ }
+
async fn t_apply(&self, args: &Map) -> Result {
let cluster = self.target(args)?.cluster;
let manifest = args
@@ -705,13 +780,14 @@ mod tests {
.iter()
.map(|t| t["name"].as_str().expect("tool has a name").to_string())
.collect();
- assert_eq!(names.len(), 10);
+ assert_eq!(names.len(), 11);
for expected in [
"deploy_list",
"deploy_get",
"get_agent_states",
"deploy_events",
"deploy_apply",
+ "deploy_provision",
"deploy_scale",
"deploy_delete",
"runtime_context",
diff --git a/crates/oabctl/src/manifest.rs b/crates/oabctl/src/manifest.rs
index fe665f8..8343653 100644
--- a/crates/oabctl/src/manifest.rs
+++ b/crates/oabctl/src/manifest.rs
@@ -47,6 +47,8 @@ pub struct FleetTemplate {
#[serde(default)]
pub resources: Option,
#[serde(default)]
+ pub bundle_from: Option,
+ #[serde(default)]
pub bootstrap_from: Option,
#[serde(default)]
pub secrets: HashMap,
@@ -63,6 +65,8 @@ pub struct AgentOverride {
#[serde(default)]
pub resources: Option,
#[serde(default)]
+ pub bundle_from: Option,
+ #[serde(default)]
pub bootstrap_from: Option,
#[serde(default)]
pub secrets: Option>,
@@ -123,6 +127,9 @@ impl OABFleetManifest {
.unwrap_or_else(|| self.spec.template.image.clone()),
resources,
config_from: agent.config_from.replace("${name}", &agent.name),
+ bundle_from: agent.bundle_from.clone()
+ .or(self.spec.template.bundle_from.clone())
+ .map(|s| s.replace("${name}", &agent.name)),
bootstrap_from: agent.bootstrap_from.clone()
.or(self.spec.template.bootstrap_from.clone())
.map(|s| s.replace("${name}", &agent.name)),
@@ -152,6 +159,14 @@ pub struct Spec {
pub image: String,
pub resources: Resources,
pub config_from: String,
+ /// Optional S3 **prefix** URI (`s3://{bucket}/artifacts/{ns}/{name}/`) holding
+ /// the agent's full composed file bundle — config + persona + skills (agent
+ /// deployment ADR, path A). The runtime restores this prefix into `~` at first
+ /// boot; `configFrom` remains the single-file config source it reads directly.
+ /// Omitted for legacy config-only services, so existing manifests are
+ /// unchanged.
+ #[serde(default)]
+ pub bundle_from: Option,
#[serde(default)]
pub bootstrap_from: Option,
#[serde(default)]
diff --git a/crates/oabctl/src/studio_api.rs b/crates/oabctl/src/studio_api.rs
index 609dac1..8800cf3 100644
--- a/crates/oabctl/src/studio_api.rs
+++ b/crates/oabctl/src/studio_api.rs
@@ -14,6 +14,7 @@
use crate::manifest::{OABFleetManifest, OABServiceManifest, RawManifest};
use anyhow::{Context, Result};
+use aws_sdk_s3::primitives::ByteStream;
/// Parse a manifest YAML document into one or more service manifests.
///
@@ -38,6 +39,153 @@ pub fn parse_manifests(yaml: &str) -> Result> {
}
}
+/// The `bundleFrom` S3 **prefix** URI an agent's composed bundle is uploaded to
+/// and restored from at boot: `s3://{bucket}/artifacts/{namespace}/{name}/`
+/// (trailing slash). Pairs with `studio_compose::Bundle::artifact_objects`, whose
+/// keys are exactly `artifacts/{namespace}/{name}/{path}` under the same bucket.
+pub fn bundle_from_uri(bucket: &str, namespace: &str, name: &str) -> String {
+ format!("s3://{bucket}/artifacts/{namespace}/{name}/")
+}
+
+/// Outcome of pushing a bundle: which bucket it landed in and how many objects.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct PushBundleReport {
+ pub bucket: String,
+ pub objects: usize,
+}
+
+/// Upload a composed bundle's `(s3_key, bytes)` objects to the control-plane
+/// bucket (agent deployment ADR, path A). Keys must already be under the agent's
+/// artifacts prefix — produce them with `studio_compose::Bundle::artifact_objects`
+/// so they line up with the `bundleFrom` the manifest carries. Puts are
+/// idempotent overwrites, so re-provisioning simply replaces the prior bundle.
+///
+/// Config-free: the bucket is `control_plane_bucket`, else
+/// `$OAB_CONTROL_PLANE_BUCKET`, else derived from the caller's account — never
+/// `~/.oabctl/config.toml`.
+pub async fn push_bundle(
+ config: &aws_config::SdkConfig,
+ control_plane_bucket: Option<&str>,
+ objects: &[(String, Vec)],
+) -> Result {
+ let bucket = crate::control_plane::resolve_bucket(config, control_plane_bucket).await?;
+ let s3 = aws_sdk_s3::Client::new(config);
+ for (key, bytes) in objects {
+ s3.put_object()
+ .bucket(&bucket)
+ .key(key)
+ .body(ByteStream::from(bytes.clone()))
+ .send()
+ .await
+ .with_context(|| format!("failed to upload bundle object '{key}'"))?;
+ }
+ Ok(PushBundleReport {
+ bucket,
+ objects: objects.len(),
+ })
+}
+
+/// Provision an agent from a composed bundle: **push the bundle** to the agent's
+/// artifacts prefix, then **apply the manifest** (create/update the ECS service
+/// at the manifest's chosen image tag). This is the ECS half of the deployment
+/// ADR's provider-tagged driver (slice 2).
+///
+/// Order matters: the bundle is uploaded first, so the artifacts prefix the
+/// service reads (`configFrom` / `bundleFrom`) exists before the task starts.
+/// `manifest_yaml` is a rendered `OABService` (or `OABFleet`) whose `bundleFrom`
+/// should be [`bundle_from_uri`] for the same `(bucket, namespace, name)`.
+/// Config-free like the rest of this module.
+pub async fn provision(
+ config: &aws_config::SdkConfig,
+ cluster: &str,
+ manifest_yaml: &str,
+ objects: &[(String, Vec)],
+ control_plane_bucket: Option<&str>,
+) -> Result {
+ // 1. Bundle first — idempotent puts, so the file carrier is ready before ECS
+ // pulls the task up and reads config/persona/skills from it.
+ push_bundle(config, control_plane_bucket, objects).await?;
+
+ // 2. Apply the service manifest at its chosen image tag. `apply_manifests` is
+ // config-free and reconciles create-or-update.
+ let manifests = parse_manifests(manifest_yaml)?;
+ let mut opts = crate::apply::ApplyOptions::new(cluster);
+ if let Some(bucket) = control_plane_bucket {
+ opts = opts.with_control_plane_bucket(bucket);
+ }
+ crate::apply::apply_manifests(config, &manifests, &opts)
+ .await
+ .context("failed to apply manifest during provision")
+}
+
+/// Load the desired `OABService` manifest oabctl persists at
+/// `manifests/{namespace}/{name}.yaml` in the control-plane bucket. Returns
+/// `Ok(None)` when the agent has no stored manifest yet (never applied); other
+/// S3/parse errors propagate. This is the deploy config's single source of truth
+/// — networking/resources/secrets already live here, so a redeploy reuses them
+/// instead of re-collecting them.
+pub async fn load_manifest(
+ config: &aws_config::SdkConfig,
+ namespace: &str,
+ name: &str,
+ control_plane_bucket: Option<&str>,
+) -> Result