From 512fa30bddd50ea56d6a22bfb1dfcf998ded3cd7 Mon Sep 17 00:00:00 2001 From: Orca Date: Sun, 16 Aug 2026 20:44:01 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(provision):=20compose=E2=86=92artifact?= =?UTF-8?q?s=20layout=20+=20bundleFrom=20+=20studio=5Fapi=20provision=20(d?= =?UTF-8?q?eploy=20slice=202,=20path=20A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 2 foundation of the agent-deployment ADR (#63), path A (extend the artifacts prefix; the runtime restores it into ~ at boot): - studio-compose: Bundle::artifact_objects(ns, name) — pure {path->bytes} → (artifacts/{ns}/{name}/{path}, bytes) mapping + artifacts_prefix(). Unit-tested (2 new). - oabctl manifest: new optional Spec.bundleFrom (mirrors bootstrapFrom; propagated through OABFleet expand). Legacy manifests unchanged (default None). - oabctl studio_api (additive, config-free): bundle_from_uri(), push_bundle() (put each object to the control-plane bucket), and provision() = push bundle then apply the manifest at its chosen image tag. Reuses parse_manifests + apply_manifests. Tests for bundle_from_uri + bundleFrom manifest round-trip. Deferred (next increments): oab-mcp deploy_provision tool + src-tauri command + Compose-tab Deploy button + create-wizard bundle support; and the openab image boot step that syncs bundleFrom prefix into ~ (cross-repo). Verified: cargo test -p studio-compose (19); cargo check -p oabctl (+ --tests). Full oabctl test + src-tauri build run under CI. Co-Authored-By: Claude Opus 4.8 --- crates/oabctl/src/manifest.rs | 15 ++++ crates/oabctl/src/studio_api.rs | 135 +++++++++++++++++++++++++++++++ crates/studio-compose/src/lib.rs | 52 ++++++++++++ 3 files changed, 202 insertions(+) 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..026c230 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,85 @@ 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") +} + /// Immediate scale of an OAB service to `size` replicas via ECS `UpdateService`. /// /// The service name is `oab-{namespace}-{name}`. OAB services carry a single @@ -78,3 +158,58 @@ pub async fn delete( let bucket = crate::control_plane::resolve_bucket(config, control_plane_bucket).await?; crate::delete::run_with_bucket(config, resource, name, cluster, namespace, &bucket).await } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bundle_from_uri_is_a_trailing_slash_prefix() { + assert_eq!( + bundle_from_uri("oab-control-plane-123", "prod", "orca"), + "s3://oab-control-plane-123/artifacts/prod/orca/" + ); + } + + #[test] + fn manifest_round_trips_bundle_from() { + // A manifest carrying bundleFrom parses into spec.bundle_from; one without + // it defaults to None (legacy config-only services stay unchanged). + let with = r#" +apiVersion: oab.dev/v2 +kind: OABService +metadata: + name: orca + namespace: prod +spec: + image: ghcr.io/openabdev/openab:0.9.0-claude + resources: { cpu: "256", memory: "512" } + configFrom: s3://b/artifacts/prod/orca/config.toml + bundleFrom: s3://b/artifacts/prod/orca/ + runtime: + type: ecs + networking: { subnets: ["subnet-1"], securityGroups: ["sg-1"] } +"#; + let m = &parse_manifests(with).unwrap()[0]; + assert_eq!( + m.spec.bundle_from.as_deref(), + Some("s3://b/artifacts/prod/orca/") + ); + + let without = r#" +apiVersion: oab.dev/v2 +kind: OABService +metadata: + name: orca + namespace: prod +spec: + image: ghcr.io/openabdev/openab:0.9.0-claude + resources: { cpu: "256", memory: "512" } + configFrom: s3://b/artifacts/prod/orca/config.toml + runtime: + type: ecs + networking: { subnets: ["subnet-1"], securityGroups: ["sg-1"] } +"#; + assert_eq!(parse_manifests(without).unwrap()[0].spec.bundle_from, None); + } +} diff --git a/crates/studio-compose/src/lib.rs b/crates/studio-compose/src/lib.rs index fcc6299..d50cf5d 100644 --- a/crates/studio-compose/src/lib.rs +++ b/crates/studio-compose/src/lib.rs @@ -141,6 +141,32 @@ impl Bundle { } } +/// The S3 **artifacts prefix** an agent's composed bundle lands under (path A of +/// the deployment ADR): `artifacts/{namespace}/{name}`. The control-plane bucket +/// already grants the runtime `s3:GetObject` on `{bucket}/artifacts/*`, and +/// `config.toml` already lives here as the manifest's `configFrom`; slice 2 fans +/// the *whole* bundle out under the same prefix, and the runtime restores the +/// prefix into `~` at first boot. No trailing slash (callers join with `/`). +pub fn artifacts_prefix(namespace: &str, name: &str) -> String { + format!("artifacts/{namespace}/{name}") +} + +impl Bundle { + /// The `(s3_key, bytes)` objects to upload for this bundle under an agent's + /// [`artifacts_prefix`] — each bundle path becomes + /// `artifacts/{ns}/{name}/{path}`. Sorted (bundle files are a `BTreeMap`), so + /// the upload set is deterministic. The provisioner (slice 2, `oabctl`) puts + /// each of these; this mapping is pure so the key layout is unit-testable + /// without touching S3. + pub fn artifact_objects(&self, namespace: &str, name: &str) -> Vec<(String, Vec)> { + let prefix = artifacts_prefix(namespace, name); + self.files + .iter() + .map(|(path, bytes)| (format!("{prefix}/{path}"), bytes.clone())) + .collect() + } +} + /// A UTF-8-lossy, serde-friendly view of one bundle file for the preview UI. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct FilePreview { @@ -627,6 +653,32 @@ mod tests { assert!(!claude.binary); } + #[test] + fn artifact_objects_key_each_file_under_agent_prefix() { + let lib = SkillsLibrary::from_iter([("s", skill_with(&[("SKILL.md", "hi\n")]))]); + let mut t = tmpl(); + t.skills = vec!["s".into()]; + let bundle = compose(&t, &Overlay::default(), &lib).unwrap(); + let objs = bundle.artifact_objects("prod", "orca"); + let keys: Vec<_> = objs.iter().map(|(k, _)| k.as_str()).collect(); + assert_eq!( + keys, + vec![ + "artifacts/prod/orca/.claude/skills/s/SKILL.md", + "artifacts/prod/orca/CLAUDE.md", + "artifacts/prod/orca/config.toml", + ] + ); + // bytes travel with the key, unmodified + let (_, cfg) = objs.iter().find(|(k, _)| k.ends_with("/config.toml")).unwrap(); + assert_eq!(cfg, b"[agent]\nname = \"base\"\n"); + } + + #[test] + fn artifacts_prefix_has_no_trailing_slash() { + assert_eq!(artifacts_prefix("ns", "a"), "artifacts/ns/a"); + } + #[test] fn round_trips_through_json() { // The Tauri boundary shuttles these as JSON; make sure serde is wired. From d20a6e0e294428cc466a977c4cf902d55530f727 Mon Sep 17 00:00:00 2001 From: Orca Date: Sun, 16 Aug 2026 22:04:37 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(deploy):=20Compose=E2=86=92Deploy=20ve?= =?UTF-8?q?rtical=20=E2=80=94=20deploy=5Fprovision=20(slice=202,=20track?= =?UTF-8?q?=20=E2=91=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the compose library to a one-click ECS deploy, end to end: - oabctl studio_api: load_manifest() reads the stored desired manifest (manifests/{ns}/{name}.yaml); redeploy() loads it, repoints image + bundleFrom, pushes the bundle, and applies — networking/resources/secrets ride along from the stored manifest, so a redeploy needs no infra input. - studio-cp: provision_from_library() — compose template⊕overlay, then redeploy; returns a ProvisionOutcome (image/digest/objects/action). - oab-mcp: new deploy_provision tool (thin dispatch into studio-cp). - src-tauri: deploy_provision command bridging to the sidecar tool. - console: Deploy form on the Compose tab (revealed after preview) — namespace/name/image tag → compose→push→apply, with result status. The agent must already be created (redeploy reuses its stored manifest); net-new agent networking-from-bootstrap is a follow-up, as is the openab image-side bundle consumption (still under discussion). Verified: cargo check -p oabctl / -p studio-cp / -p oab-mcp; console tsc + 65 vitest + vite build. Full workspace test + src-tauri build run under CI. Co-Authored-By: Claude Opus 4.8 --- console/index.html | 25 +++++++++++ console/src/compose.ts | 65 +++++++++++++++++++++++++++ console/src/styles.css | 30 +++++++++++++ crates/oab-mcp/src/lib.rs | 80 ++++++++++++++++++++++++++++++++- crates/oabctl/src/studio_api.rs | 68 ++++++++++++++++++++++++++++ crates/studio-cp/Cargo.toml | 1 + crates/studio-cp/src/lib.rs | 69 ++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 49 ++++++++++++++++++++ 8 files changed, 385 insertions(+), 2 deletions(-) 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/studio_api.rs b/crates/oabctl/src/studio_api.rs index 026c230..8800cf3 100644 --- a/crates/oabctl/src/studio_api.rs +++ b/crates/oabctl/src/studio_api.rs @@ -118,6 +118,74 @@ pub async fn provision( .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> { + let bucket = crate::control_plane::resolve_bucket(config, control_plane_bucket).await?; + let s3 = aws_sdk_s3::Client::new(config); + let key = format!("manifests/{namespace}/{name}.yaml"); + match s3.get_object().bucket(&bucket).key(&key).send().await { + Ok(resp) => { + let bytes = resp + .body + .collect() + .await + .with_context(|| format!("failed to read stored manifest '{key}'"))? + .into_bytes(); + let manifest: OABServiceManifest = serde_yaml::from_slice(&bytes) + .with_context(|| format!("failed to parse stored manifest '{key}'"))?; + Ok(Some(manifest)) + } + // A missing object is the "not provisioned yet" signal, not an error. + Err(err) if err.as_service_error().map(|e| e.is_no_such_key()).unwrap_or(false) => Ok(None), + Err(err) => { + Err(anyhow::Error::new(err).context(format!("failed to fetch stored manifest '{key}'"))) + } + } +} + +/// Re-provision an already-created agent from a freshly composed bundle: load its +/// stored manifest, repoint it at `image` (when given) and the bundle prefix, +/// **push the bundle, then apply**. Networking/resources/secrets are untouched — +/// they ride along from the stored manifest — so a redeploy needs no infra input. +/// +/// Errors if the agent has no stored manifest (it must be `create`d first). This +/// is the ECS "update this agent to a new image / persona / skills" path. +pub async fn redeploy( + config: &aws_config::SdkConfig, + cluster: &str, + namespace: &str, + name: &str, + image: Option<&str>, + objects: &[(String, Vec)], + control_plane_bucket: Option<&str>, +) -> Result { + // Resolve the bucket once and thread it through, so load/push/apply all agree. + let bucket = crate::control_plane::resolve_bucket(config, control_plane_bucket).await?; + let mut manifest = load_manifest(config, namespace, name, Some(&bucket)) + .await? + .with_context(|| { + format!("no stored manifest for {namespace}/{name} — create the agent before redeploying") + })?; + + if let Some(img) = image.filter(|s| !s.is_empty()) { + manifest.spec.image = img.to_string(); + } + manifest.spec.bundle_from = Some(bundle_from_uri(&bucket, namespace, name)); + + let yaml = serde_yaml::to_string(&manifest).context("failed to serialize patched manifest")?; + provision(config, cluster, &yaml, objects, Some(&bucket)).await +} + /// Immediate scale of an OAB service to `size` replicas via ECS `UpdateService`. /// /// The service name is `oab-{namespace}-{name}`. OAB services carry a single diff --git a/crates/studio-cp/Cargo.toml b/crates/studio-cp/Cargo.toml index f607d35..ed49225 100644 --- a/crates/studio-cp/Cargo.toml +++ b/crates/studio-cp/Cargo.toml @@ -8,6 +8,7 @@ license = "MIT" [dependencies] oabctl = { path = "../oabctl" } agent-lifecycle = { path = "../agent-lifecycle" } +studio-compose = { path = "../studio-compose" } aws-config = "1.5" aws-sdk-sts = "1" anyhow = "1.0" diff --git a/crates/studio-cp/src/lib.rs b/crates/studio-cp/src/lib.rs index 28952da..a1590d4 100644 --- a/crates/studio-cp/src/lib.rs +++ b/crates/studio-cp/src/lib.rs @@ -537,6 +537,75 @@ pub async fn apply_deployment( .map_err(|e| anyhow::anyhow!("apply failed [{:?}]: {e}", e.kind)) } +pub use studio_compose::Library; + +/// Structured outcome of a [`provision_from_library`] call — enough for the UI to +/// confirm what was deployed without holding the bundle bytes. +#[derive(Debug, Clone, serde::Serialize)] +pub struct ProvisionOutcome { + /// The image tag the service was pointed at. + pub image: String, + /// Content-address of the composed bundle (`sha256:…`). + pub digest: String, + /// Number of bundle files uploaded to the agent's artifacts prefix. + pub objects: usize, + /// Number of ECS services reconciled (1 for a single agent). + pub services_applied: usize, + /// The reconcile action on the (first) service, e.g. `Created` / `Updated`. + pub action: String, +} + +/// Provision an agent from the compose **library**: compose `template ⊕ overlay`, +/// then **redeploy** — push the bundle to the agent's artifacts prefix and apply +/// its stored manifest at the chosen image tag (agent-deployment ADR slice 2, +/// path A). Networking/resources/secrets ride along from the stored manifest, so +/// this is the "update this agent to new persona / skills / image" path; the +/// agent must already have been `create`d. +/// +/// `image_override` (when non-empty) wins over the bundle's own default image tag. +pub async fn provision_from_library( + aws_config: &aws_config::SdkConfig, + cluster: &str, + namespace: &str, + name: &str, + library: &Library, + template: &str, + overlay: Option<&str>, + image_override: Option<&str>, +) -> anyhow::Result { + let bundle = studio_compose::compose_named(library, template, overlay) + .map_err(|e| anyhow::anyhow!("compose failed: {e}"))?; + let image = image_override + .filter(|s| !s.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| bundle.image_tag.clone()); + let objects = bundle.artifact_objects(namespace, name); + let digest = bundle.digest(); + + let report = oabctl::studio_api::redeploy( + aws_config, + cluster, + namespace, + name, + Some(&image), + &objects, + None, + ) + .await?; + + Ok(ProvisionOutcome { + image, + digest, + objects: objects.len(), + services_applied: report.services.len(), + action: report + .services + .first() + .map(|s| format!("{:?}", s.action)) + .unwrap_or_default(), + }) +} + /// Scale an OAB service to `size` replicas (0 = off, 1 = on). /// /// Config-free: `cluster` / `namespace` are explicit (service = `oab-{namespace}-{name}`). diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 95e90be..3db42f9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -116,6 +116,54 @@ async fn compose_preview( compose::preview(&library, &template, overlay.as_deref()) } +/// Provision an agent from the compose library over MCP (`deploy_provision`): +/// compose `template ⊕ overlay`, push the bundle to the agent's S3 artifacts +/// prefix, and redeploy the ECS service at the chosen image tag (agent-deployment +/// ADR slice 2). The heavy lifting (compose + S3 + apply) runs in the sidecar +/// with its hermetic AWS env; this is a thin bridge like `deploy_scale`. +#[tauri::command] +async fn deploy_provision( + core: tauri::State<'_, Core>, + library: Value, + template: String, + overlay: Option, + name: String, + namespace: Option, + image: Option, + cluster: Option, +) -> Result { + let cluster = cluster.unwrap_or_else(default_cluster); + let client = { + let guard = core.0.lock().await; + guard + .as_ref() + .cloned() + .ok_or_else(|| "core not started yet".to_string())? + }; + let mut params = json!({ + "library": library, + "template": template, + "name": name, + "cluster": cluster, + }); + if let Some(o) = overlay { + params["overlay"] = json!(o); + } + if let Some(ns) = namespace { + params["namespace"] = json!(ns); + } + if let Some(img) = image.filter(|s| !s.is_empty()) { + params["image_tag"] = json!(img); + } + match client.call_tool("deploy_provision", params).await { + Ok(v) => Ok(v), + Err(e) => { + client.log("error", &format!("deploy_provision: {e}")); + Err(e) + } + } +} + /// List services (`deploy_list`) then fetch each one's per-instance 6-state /// (`deploy_get`), all over MCP — the two-step the in-process bridge used, /// now over the wire. Console view-model shape is unchanged. @@ -435,6 +483,7 @@ pub fn run() { compose_library_get, compose_library_set, compose_preview, + deploy_provision, deploy_list, runtime_context, fleet_config,