diff --git a/console/src/deploy.ts b/console/src/deploy.ts index 6e27ebc..7d2d6b9 100644 --- a/console/src/deploy.ts +++ b/console/src/deploy.ts @@ -92,7 +92,23 @@ function randomGreekName(): string { return GREEK_GODS[Math.floor(Math.random() * GREEK_GODS.length)]; } -export type DeployMode = { kind: "new-fleet" } | { kind: "add-instance"; fleetName: string }; +// studio#153: "add-instance" now carries the target fleet's existing +// runtime binding (read once, by the caller, from `FleetConfigEntry` — +// `main.ts` already has it in hand from the fleet the operator drilled +// into) instead of assuming ECS. This wizard has no k8s identity step for +// "add-instance" (that only exists for "new-fleet"), so there's nothing to +// re-collect from the operator — a k8s fleet's context/namespace/service +// account were fixed the moment the fleet was created. +export type DeployMode = + | { kind: "new-fleet" } + | { + kind: "add-instance"; + fleetName: string; + runtime: "ecs" | "k8s"; + context: string | null; + namespace: string | null; + expectedPrincipal: string | null; + }; // What the panel reports back once a deploy + fleets.toml write both succeed — // enough for the caller (`main.ts`) to log it and re-derive screen state @@ -281,6 +297,20 @@ export function initDeployPanel(deps: DeployPanelDeps): DeployPanelHandle | null if (isCustom) imageCustomInput.focus(); }; + // studio#153: ACP-enabled deploys need an image with ACP wired as a + // first-class adapter (openab#1418, first shipped in 0.10.0-beta.2) — + // Stable can lag behind that fix for a long stretch (it did: Stable was + // pinned to 0.9.0, which predates the fix entirely, and picking it for an + // ACP-enabled agent reproduces the exact "no adapter configured" crash + // this was written to catch). ACP-on should not silently inherit the + // select's implicit first-option default. Only nudges *into* Beta the + // moment ACP is turned on; never fights a selection made afterward. + const preferBetaForAcp = (): void => { + if (!acpCheckbox.checked) return; + const betaOption = Array.from(imageSelectEl.options).find((o) => o.textContent?.startsWith("Beta")); + if (betaOption) imageSelectEl.value = betaOption.value; + }; + // studio#128/#136: a real lives) is skipped for "add-instance", and reset() - // (run on every open()) puts the only lives in the identity - // step, skipped for "add instance"), so submitting here would silently - // deploy an ECS service into a k8s fleet instead of erroring. Block it - // with the same clear-message pattern the wizard already uses for its - // own not-yet-supported k8s-provider case, rather than let that happen. + // studio#153: the k8s block studio#146's final-review pass added here + // is gone — `deploy.ts`'s submit handler now reads the target fleet's + // runtime/context/namespace/expected_principal straight off + // `FleetConfigEntry` (this lookup) instead of assuming ECS, so an + // "add instance" submit against a k8s fleet provisions into that + // fleet's actual context/namespace rather than silently targeting + // ECS. `fleet` should always resolve here (the operator already + // drilled into this fleet to see the button); fall back to "ecs" only + // to keep the type checker happy, not because it's an expected case. const fleet = fleetConfig?.fleets.find((f) => f.name === activeFleet); - if (fleet?.runtime === "k8s") { - note( - "info", - `fleet "${activeFleet}" is a k8s fleet — adding an instance to an existing k8s fleet isn't supported yet`, - ); - return; - } - deployPanel?.open({ kind: "add-instance", fleetName: activeFleet }); + deployPanel?.open({ + kind: "add-instance", + fleetName: activeFleet, + runtime: fleet?.runtime ?? "ecs", + context: fleet?.context ?? null, + namespace: fleet?.namespace ?? null, + expectedPrincipal: fleet?.expected_principal ?? null, + }); return; } if (target.closest('[data-action="fleet-debug"]') && activeFleet) { diff --git a/crates/oab-mcp/src/lib.rs b/crates/oab-mcp/src/lib.rs index 132ff02..bf9b5fa 100644 --- a/crates/oab-mcp/src/lib.rs +++ b/crates/oab-mcp/src/lib.rs @@ -253,7 +253,7 @@ pub fn tools() -> Vec { ), Tool::new( "resolve_vendor_image_tags", - "Resolve a vendor's real, currently-published Stable/Beta image tags on ghcr.io/openabdev/openab (studio#128 — backs the New Fleet wizard's Vendor + Image tag fields). \"Beta\" is the hourly rolling pre-beta- build; \"Stable\" is the newest openab release whose matching - image is confirmed to actually exist (a release existing doesn't guarantee a matching image was ever built — the build workflow is a manual, disconnected step). Anonymous GHCR/GitHub access, no auth needed (public package/repo). Either or both fields come back null if nothing verified — not an error; the caller should fall back to a plain editable text field.", + "Resolve a vendor's real, currently-published Stable/Beta image tags on ghcr.io/openabdev/openab (studio#128 — backs the New Fleet wizard's Vendor + Image tag fields). Both are pinned - tags, never a rolling moving tag: \"Beta\" is the newest beta-named openab release (-beta.N) whose matching -beta.N- image is confirmed to actually exist; \"Stable\" is the newest non-beta release whose matching - image is confirmed to actually exist (a release existing doesn't guarantee a matching image was ever built — the build workflow is a manual, disconnected step). Anonymous GHCR/GitHub access, no auth needed (public package/repo). Either or both fields come back null if nothing verified — not an error; the caller should fall back to a plain editable text field.", as_map(json!({ "type": "object", "properties": { diff --git a/crates/oabctl/src/vendor_images.rs b/crates/oabctl/src/vendor_images.rs index 2d8ff50..b7a9f67 100644 --- a/crates/oabctl/src/vendor_images.rs +++ b/crates/oabctl/src/vendor_images.rs @@ -1,15 +1,19 @@ //! Vendor image tag resolution (studio#128): resolves a vendor name (e.g. //! `claude`, `codex`, `cursor`, `kiro`, `antigravity`) to real, //! currently-published `ghcr.io/openabdev/openab` image tags — "Beta" (the -//! hourly rolling `pre-beta-` build) and "Stable" (the newest -//! openab release whose matching `-` image is confirmed to -//! actually exist). +//! newest beta-named openab release, `-beta.N`, whose matching +//! `-beta.N-` image is confirmed to exist) and "Stable" +//! (the newest non-beta release whose matching `-` image +//! is confirmed to exist). Both are pinned version tags, never the rolling +//! `pre-beta-`/`nightly-` moving tags — a caller (or a +//! human reading the dropdown) needs an actual version number to reason +//! about "is this new enough for fix X", which a moving tag can't answer. //! //! A GitHub release tag existing does **not** guarantee a matching image //! was ever published: the image-build workflow (`build-images.yml`) is a //! manual `workflow_dispatch` step, completely disconnected from cutting a -//! release — confirmed by reading both workflows. So "stable" has to be -//! verified against GHCR directly, not inferred from the release list +//! release — confirmed by reading both workflows. So both channels have to +//! be verified against GHCR directly, not inferred from the release list //! alone. //! //! All access here is anonymous — no GitHub token needed. `ghcr.io` speaks @@ -72,13 +76,13 @@ struct GhRelease { prerelease: bool, } -/// Real (non-beta) openab release version numbers, newest first — matches -/// GitHub's own default ordering for this endpoint. Filters on both the -/// `prerelease` flag *and* the tag name itself: at least one real release -/// (`openab-0.10.0-beta.3`) has `prerelease: false` despite its name, so -/// the flag alone isn't reliable. -async fn openab_release_versions(client: &reqwest::Client) -> Result> { - let releases: Vec = client +/// One GitHub API call, split into the two channels `resolve_vendor_image_tags` +/// walks — newest first in both, matching GitHub's own default ordering for +/// this endpoint. `prerelease` alone isn't a reliable channel signal (at +/// least one real release, `openab-0.10.0-beta.3`, has `prerelease: false` +/// despite its name) — the tag name itself decides stable vs. beta. +async fn fetch_openab_releases(client: &reqwest::Client) -> Result> { + client .get("https://api.github.com/repos/openabdev/openab/releases") // GitHub's REST API rejects requests with no User-Agent. .header("User-Agent", "openab-studio") @@ -89,19 +93,40 @@ async fn openab_release_versions(client: &reqwest::Client) -> Result .context("GitHub releases API returned an error")? .json() .await - .context("GitHub releases API returned invalid JSON")?; - Ok(releases - .into_iter() + .context("GitHub releases API returned invalid JSON") +} + +/// Real (non-beta) openab release version numbers, newest first. +fn stable_release_versions(releases: &[GhRelease]) -> Vec { + releases + .iter() .filter(|r| !r.prerelease && r.tag_name.starts_with("openab-") && !r.tag_name.contains("-beta")) .map(|r| r.tag_name.trim_start_matches("openab-").to_string()) - .collect()) + .collect() +} + +/// Beta-named openab release version numbers (`-beta.N`), newest +/// first — same "trust the tag name, not the `prerelease` flag" reasoning +/// as `stable_release_versions`. +fn beta_release_versions(releases: &[GhRelease]) -> Vec { + releases + .iter() + .filter(|r| r.tag_name.starts_with("openab-") && r.tag_name.contains("-beta")) + .map(|r| r.tag_name.trim_start_matches("openab-").to_string()) + .collect() } #[derive(Debug, Clone, Default, Serialize)] pub struct VendorImageTags { - /// `pre-beta-` if the GHCR check confirms it exists. + /// The newest beta release version (`-beta.N`) whose + /// `-beta.N-` image is confirmed to exist on GHCR. + /// Pinned, not the old rolling `pre-beta-` moving tag — a + /// caller needs a version number to reason about (e.g. "is this + /// build new enough for fix X"), which a moving tag can't give. + /// `None` if no beta release has a matching image yet (or the + /// GitHub/GHCR calls themselves failed). pub beta: Option, - /// The newest release version whose `-` image is + /// The newest stable release version whose `-` image is /// confirmed to exist on GHCR. `None` if no release has a matching /// image yet (or the GitHub/GHCR calls themselves failed). pub stable: Option, @@ -120,20 +145,69 @@ pub async fn resolve_vendor_image_tags(vendor: &str) -> VendorImageTags { return out; }; - let beta_tag = format!("pre-beta-{vendor}"); - if ghcr_tag_exists(&client, &token, &beta_tag).await.unwrap_or(false) { - out.beta = Some(beta_tag); + let Ok(releases) = fetch_openab_releases(&client).await else { + return out; + }; + + for version in beta_release_versions(&releases) { + let candidate = format!("{version}-{vendor}"); + if ghcr_tag_exists(&client, &token, &candidate).await.unwrap_or(false) { + out.beta = Some(candidate); + break; + } } - if let Ok(versions) = openab_release_versions(&client).await { - for version in versions { - let candidate = format!("{version}-{vendor}"); - if ghcr_tag_exists(&client, &token, &candidate).await.unwrap_or(false) { - out.stable = Some(candidate); - break; - } + for version in stable_release_versions(&releases) { + let candidate = format!("{version}-{vendor}"); + if ghcr_tag_exists(&client, &token, &candidate).await.unwrap_or(false) { + out.stable = Some(candidate); + break; } } out } + +#[cfg(test)] +mod tests { + use super::*; + + fn release(tag_name: &str, prerelease: bool) -> GhRelease { + GhRelease { tag_name: tag_name.to_string(), prerelease } + } + + // Real-world snapshot (2026-09-08, `gh api repos/openabdev/openab/releases`): + // newest-first, mixes stable/beta/unrelated tags, and includes the + // `prerelease: false` beta release that makes the flag alone unreliable. + fn sample_releases() -> Vec { + vec![ + release("oabctl-pre-beta", true), + release("openab-0.10.0-beta.3", false), + release("openab-0.10.0-beta.2", false), + release("openab-0.10.0-beta.1", false), + release("openab-0.9.0", false), + release("openab-0.9.0-beta.12", false), + release("pre-seed-utils-v2.35.13-ghp0.3.2", false), + ] + } + + #[test] + fn stable_versions_excludes_beta_and_unrelated_tags() { + assert_eq!(stable_release_versions(&sample_releases()), vec!["0.9.0"]); + } + + #[test] + fn beta_versions_newest_first_ignores_prerelease_flag() { + assert_eq!( + beta_release_versions(&sample_releases()), + vec!["0.10.0-beta.3", "0.10.0-beta.2", "0.10.0-beta.1", "0.9.0-beta.12"] + ); + } + + #[test] + fn both_channels_ignore_non_openab_prefixed_releases() { + let releases = vec![release("oabctl-pre-beta", true), release("pre-seed-utils-v2.35.13-ghp0.3.2", false)]; + assert!(stable_release_versions(&releases).is_empty()); + assert!(beta_release_versions(&releases).is_empty()); + } +}