Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions console/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,31 @@
</div>
</form>
<div id="compose-preview" class="compose-preview"></div>
<form id="compose-deploy-form" class="compose-deploy" autocomplete="off" hidden>
<div class="compose-deploy-head">Deploy to ECS</div>
<label
>Namespace
<input id="compose-ns" class="compose-input" type="text" value="default" spellcheck="false" />
</label>
<label
>Agent name
<input id="compose-name" class="compose-input" type="text" placeholder="e.g. orca" spellcheck="false" />
</label>
<label
>Image tag
<input id="compose-image" class="compose-input" type="text" placeholder="(bundle default)" spellcheck="false" />
</label>
<div class="compose-actions">
<button type="submit" id="compose-deploy-btn">Deploy</button>
<span class="compose-status" id="compose-deploy-status"></span>
</div>
<p class="config-hint">
Pushes the composed bundle to the agent's S3 artifacts prefix and
redeploys its ECS service at this image tag, reusing the stored
manifest's networking/resources. The agent must already have been
created.
</p>
</form>
</div>
</div>
<p class="config-hint">
Expand Down
65 changes: 65 additions & 0 deletions console/src/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Library>("compose_library_get")
.then((lib) => {
text.value = JSON.stringify(lib, null, 2);
Expand Down Expand Up @@ -203,9 +219,58 @@ export function initComposeTab(): void {
const preview = await invoke<BundlePreview>("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;
}
});
}
30 changes: 30 additions & 0 deletions console/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
80 changes: 78 additions & 2 deletions crates/oab-mcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -143,6 +144,24 @@ pub fn tools() -> Vec<Tool> {
"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).",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -471,6 +491,61 @@ impl OabMcp {
}))
}

async fn t_provision(&self, args: &Map<String, Value>) -> Result<Value> {
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<String, Value>) -> Result<Value> {
let cluster = self.target(args)?.cluster;
let manifest = args
Expand Down Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions crates/oabctl/src/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ pub struct FleetTemplate {
#[serde(default)]
pub resources: Option<Resources>,
#[serde(default)]
pub bundle_from: Option<String>,
#[serde(default)]
pub bootstrap_from: Option<String>,
#[serde(default)]
pub secrets: HashMap<String, String>,
Expand All @@ -63,6 +65,8 @@ pub struct AgentOverride {
#[serde(default)]
pub resources: Option<Resources>,
#[serde(default)]
pub bundle_from: Option<String>,
#[serde(default)]
pub bootstrap_from: Option<String>,
#[serde(default)]
pub secrets: Option<HashMap<String, String>>,
Expand Down Expand Up @@ -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)),
Expand Down Expand Up @@ -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<String>,
#[serde(default)]
pub bootstrap_from: Option<String>,
#[serde(default)]
Expand Down
Loading
Loading