From bc4b2f2cb34b2cd321ebf6a2b041cef24f9e541f Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sat, 29 Aug 2026 21:32:15 +0800 Subject: [PATCH] feat: New Fleet wizard writes config.toml locally before S3 (studio#135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the gap #134 flagged: the wizard generated config.toml server-side but never wrote it into the operator's local Config folder (#130), so the "Agent configs" view (#134) had nothing to show until something else populated the folder by hand. Brett's explicit ordering: "Wizard should write to local first, write to s3 if needed." That ordering can only actually be guaranteed inside the sidecar (oab-mcp) — provision_agent/provision_agent_k8s are what do the S3 upload, so writing the local copy there, before the upload, is a real sequencing guarantee. Having the console write a local copy *after* receiving the tool's response back would mean S3 had already happened first no matter what, the opposite of what was asked — and was the implementation this session's own earlier #135 write-up had assumed by default, corrected here after asking Brett directly rather than guessing. - studio-cp: new write_local_agent_config() — //config.toml, written from generate_agent_config()'s raw output *before* inject_pre_seed_hook mutates a copy for the S3/bundle path (the S3 zip URI hook injects is meaningless for a local reference copy). Both provision_agent and provision_agent_k8s gained a local_config_folder: Option<&str> parameter; write happens unconditionally whenever the caller passes one, hard-fails the whole deploy on a write error (folder set = a real requirement, not best-effort) rather than silently proceeding without the copy it promised. - oab-mcp: deploy_provision_agent's schema gained local_config_folder (optional). - src-tauri: bridge command threads the new param through. - console: deploy.ts reads the same oab-studio.configFolder localStorage key the Config-folder setting (#130) already uses and sends it along. This also resolves the mechanism question #135's own write-up had left open (send config_toml back to console vs. have the sidecar write directly) — the ordering requirement settles it: only the sidecar-writes approach can guarantee local-before-S3, so no config.toml text (which could contain secret *references*, if not raw values) needs to cross the Tauri IPC bridge back to the console at all. Ref #135. --- console/src/deploy.ts | 17 +++++++++++++++++ crates/oab-mcp/src/lib.rs | 8 ++++++++ crates/studio-cp/src/lib.rs | 29 +++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 4 ++++ 4 files changed, 58 insertions(+) diff --git a/console/src/deploy.ts b/console/src/deploy.ts index e87710e..a0c107a 100644 --- a/console/src/deploy.ts +++ b/console/src/deploy.ts @@ -27,6 +27,22 @@ function errText(e: unknown): string { return e instanceof Error ? e.message : String(e); } +// studio#135: same localStorage key main.ts's Config-folder setting uses +// (kept as a duplicated literal, not a shared export — every module in +// this console reads its own localStorage keys independently, matching +// the existing theme/log-level/config-folder settings' own pattern). +// Brett's explicit ordering ("write to local first, write to s3 if +// needed") can only actually be guaranteed inside the sidecar — passing +// the folder through lets provision_agent[_k8s] write it before touching +// S3 at all, rather than the console writing a copy after the fact. +function localConfigFolder(): string | undefined { + try { + return localStorage.getItem("oab-studio.configFolder") ?? undefined; + } catch { + return undefined; + } +} + function escapeHtml(s: string): string { return s .replace(/&/g, "&") @@ -487,6 +503,7 @@ export function initDeployPanel(deps: DeployPanelDeps): DeployPanelHandle | null chat_bot_token: chatTokenInput.value.trim() || undefined, chat_channel_secret: chatSecretInput.value.trim() || undefined, acp_enabled: acpCheckbox.checked, + local_config_folder: localConfigFolder(), ...(isK8s ? { provider: "k8s", context, expected_principal: expectedPrincipal } : {}), }); } catch (e) { diff --git a/crates/oab-mcp/src/lib.rs b/crates/oab-mcp/src/lib.rs index 87e0c04..72ceedc 100644 --- a/crates/oab-mcp/src/lib.rs +++ b/crates/oab-mcp/src/lib.rs @@ -179,6 +179,7 @@ pub fn tools() -> Vec { "chat_bot_token": { "type": "string", "description": "Discord/Telegram bot token, or LINE's channel access token." }, "chat_channel_secret": { "type": "string", "description": "LINE only." }, "acp_enabled": { "type": "boolean", "description": "Enable the reverse-MCP-over-ACP tunnel on this agent. Defaults to true when omitted (studio#119: Studio-deployed agents default to ACP on). Not honorable for every vendor — the caller is responsible for not setting this true for a vendor that can't support it (e.g. agy, whose bridge bypasses /acp entirely)." }, + "local_config_folder": { "type": "string", "description": "Optional local directory (studio#135) — when set, config.toml is written to //config.toml *before* anything touches S3. Omit to skip the local mirror entirely." }, "provider": { "type": "string", "description": "\"aws\" (default) or \"k8s\" — which driver applies the result." }, "fleet": { "type": "string", "description": "AWS only. 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": "AWS only. ECS cluster (defaults to the server's configured cluster)." }, @@ -737,6 +738,11 @@ impl OabMcp { // studio#128 made it caller-controlled. acp_enabled: args.get("acp_enabled").and_then(Value::as_bool).unwrap_or(true), }; + // studio#135: Brett's explicit ordering — write local first, S3 + // (via provision_agent[_k8s]'s existing upload) after. Optional: + // the console only ever sends this when the operator has a local + // Config folder set at all (its own opt-in setting). + let local_config_folder = args.get("local_config_folder").and_then(Value::as_str); if args.get("provider").and_then(Value::as_str) == Some("k8s") { let context = args.get("context").and_then(Value::as_str); @@ -749,6 +755,7 @@ impl OabMcp { image, input, expected_principal, + local_config_folder, ) .await?; return Ok(json!({ @@ -779,6 +786,7 @@ impl OabMcp { name, image, input, + local_config_folder, ) .await?; Ok(json!({ diff --git a/crates/studio-cp/src/lib.rs b/crates/studio-cp/src/lib.rs index ba71823..80e1158 100644 --- a/crates/studio-cp/src/lib.rs +++ b/crates/studio-cp/src/lib.rs @@ -1417,6 +1417,27 @@ usercron_path = "cronjob.toml" ) } +/// Mirrors a wizard-generated config.toml into the operator's local "Config +/// folder" (studio#135 — `//config.toml`), *before* anything +/// touches S3. Brett's explicit ordering: "Wizard should write to local +/// first, write to s3 if needed" — the only place that ordering can +/// actually be guaranteed is here, inside the sidecar, since the sidecar +/// (not the console) is what does the S3 upload; if the console wrote the +/// local copy itself after the fact (from a config_toml value handed back +/// in the response), S3 would always have already happened first no matter +/// what. Written before `inject_pre_seed_hook` runs — that mutation wires +/// in an S3 zip URI that's meaningless for a local reference copy, so the +/// local file is the clean, human-authored text the operator actually +/// configured, not an ECS/k8s-specific artifact. +fn write_local_agent_config(folder: &str, name: &str, config_toml: &[u8]) -> anyhow::Result<()> { + let dir = std::path::Path::new(folder).join(name); + std::fs::create_dir_all(&dir) + .map_err(|e| anyhow::anyhow!("failed to create {}: {e}", dir.display()))?; + let path = dir.join("config.toml"); + std::fs::write(&path, config_toml) + .map_err(|e| anyhow::anyhow!("failed to write {}: {e}", path.display())) +} + /// [`provision_from_library`], but for the studio#128 wizard's direct path — /// no compose library, no `template ⊕ overlay`: builds config.toml itself /// from [`AgentWizardInput`] (via [`generate_agent_config`]) instead of @@ -1432,9 +1453,13 @@ pub async fn provision_agent( name: &str, image: &str, input: AgentWizardInput, + local_config_folder: Option<&str>, ) -> anyhow::Result { provision_agent_secrets(aws_config, namespace, name, &input).await?; let config_toml = generate_agent_config(namespace, name, &input).into_bytes(); + if let Some(folder) = local_config_folder { + write_local_agent_config(folder, name, &config_toml)?; + } let mut bundle = studio_compose::Bundle { image_tag: image.to_string(), files: std::collections::BTreeMap::from([("config.toml".to_string(), config_toml)]), @@ -1519,6 +1544,7 @@ pub async fn provision_agent_k8s( image: &str, input: AgentWizardInput, expected_principal: Option<&str>, + local_config_folder: Option<&str>, ) -> anyhow::Result { if input.chat_platform.is_some() { anyhow::bail!( @@ -1529,6 +1555,9 @@ pub async fn provision_agent_k8s( } provision_agent_secrets(aws_config, namespace, name, &input).await?; let config_toml = generate_agent_config(namespace, name, &input).into_bytes(); + if let Some(folder) = local_config_folder { + write_local_agent_config(folder, name, &config_toml)?; + } let mut bundle = studio_compose::Bundle { image_tag: image.to_string(), files: std::collections::BTreeMap::from([("config.toml".to_string(), config_toml)]), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6e551da..5b72108 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -193,6 +193,7 @@ async fn deploy_provision_agent( chat_bot_token: Option, chat_channel_secret: Option, acp_enabled: Option, + local_config_folder: Option, cluster: Option, provider: Option, context: Option, @@ -229,6 +230,9 @@ async fn deploy_provision_agent( if let Some(a) = acp_enabled { params["acp_enabled"] = json!(a); } + if let Some(f) = local_config_folder.filter(|s| !s.is_empty()) { + params["local_config_folder"] = json!(f); + } if let Some(p) = provider.filter(|s| !s.is_empty()) { params["provider"] = json!(p); }