diff --git a/crates/tower-cmd/src/mcp.rs b/crates/tower-cmd/src/mcp.rs index 7ccbce56..c7994819 100644 --- a/crates/tower-cmd/src/mcp.rs +++ b/crates/tower-cmd/src/mcp.rs @@ -80,6 +80,9 @@ struct UpdateTowerfileRequest { script: Option, description: Option, source: Option>, + /// Compute size for each run: one of basic.xsmall, basic.small, + /// basic.medium, basic.large. Omit to let Tower apply its default. + run_size: Option, } #[derive(Debug, Deserialize, JsonSchema)] @@ -1020,7 +1023,7 @@ impl TowerService { } #[tool( - description = "Update Towerfile config (app name, script, description, source). Use this instead of editing TOML. Optional: working_directory." + description = "Update Towerfile config (app name, script, description, source, run_size). Use this instead of editing TOML. Optional: working_directory." )] async fn tower_file_update( &self, @@ -1039,6 +1042,9 @@ impl TowerService { if let Some(source) = request.source { tf.app.source = source; } + if let Some(run_size) = request.run_size { + tf.app.run_size = Some(run_size); + } Ok("Towerfile updated".into()) }) } diff --git a/crates/tower-cmd/src/skill.rs b/crates/tower-cmd/src/skill.rs index c575293c..2f28e505 100644 --- a/crates/tower-cmd/src/skill.rs +++ b/crates/tower-cmd/src/skill.rs @@ -190,6 +190,8 @@ tower_file_generate → tower_file_update → tower_file_add/edit/remove_paramet Always use `tower_file_update` or `tower_file_add/edit/remove_parameter` to modify. Never edit the TOML directly. +To give an app more compute, set `run_size` via `tower_file_update`: one of `basic.xsmall`, `basic.small`, `basic.medium` (the default), `basic.large`. It is versioned with the code, and self-hosted runners ignore it. + ### 2. Local development (preferred) ``` diff --git a/crates/tower-package/src/lib.rs b/crates/tower-package/src/lib.rs index 3f8520ed..35586799 100644 --- a/crates/tower-package/src/lib.rs +++ b/crates/tower-package/src/lib.rs @@ -5,7 +5,7 @@ pub use core::{ build_package, compute_sha256_bytes, compute_sha256_package, normalize_path, BuiltPackage, Entry, Manifest, PackageInputs, CURRENT_PACKAGE_VERSION, }; -pub use towerfile::{App, Parameter, Towerfile}; +pub use towerfile::{App, Parameter, Towerfile, RUN_SIZES}; #[cfg(feature = "native")] mod error; diff --git a/crates/tower-package/src/towerfile.rs b/crates/tower-package/src/towerfile.rs index c97250b4..a89e598c 100644 --- a/crates/tower-package/src/towerfile.rs +++ b/crates/tower-package/src/towerfile.rs @@ -2,6 +2,12 @@ use crate::core::Error; use serde::{Deserialize, Serialize}; use std::path::PathBuf; +/// Compute sizes accepted by `[app] run_size`. Managed runners apply the size; +/// self-hosted runners ignore it and use their own configured resources. When +/// the Towerfile omits it, the server applies its own default, so this list +/// deliberately has no default of its own. +pub const RUN_SIZES: [&str; 4] = ["basic.xsmall", "basic.small", "basic.medium", "basic.large"]; + #[derive(Clone, Deserialize, Serialize, Debug)] pub struct Parameter { #[serde(default)] @@ -39,6 +45,12 @@ pub struct App { #[serde(default)] pub import_paths: Vec, + + /// Compute size every run of this app gets, e.g. "basic.medium". `None` + /// means the Towerfile didn't set one (and the key is omitted when + /// serializing), leaving the server to apply its default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub run_size: Option, } #[derive(Deserialize, Serialize, Debug)] @@ -66,6 +78,7 @@ impl Towerfile { schedule: String::from("0 0 * * *"), description: None, import_paths: vec![], + run_size: None, }, } } @@ -74,20 +87,27 @@ impl Towerfile { /// the base_dir field always needs to be set after parsing. pub fn from_toml(toml: &str) -> Result { let towerfile: Towerfile = toml::from_str(toml)?; + towerfile.validate()?; + Ok(towerfile) + } - if towerfile.app.name.is_empty() { + /// validate checks the semantic rules that TOML parsing alone can't enforce. + /// It runs both when a Towerfile is read and before one is written back, so a + /// mutation can't leave an invalid file on disk. + pub fn validate(&self) -> Result<(), Error> { + if self.app.name.is_empty() { return Err(Error::MissingRequiredAppField { field: "name".to_string(), }); } - if towerfile.app.script.is_empty() { + if self.app.script.is_empty() { return Err(Error::MissingRequiredAppField { field: "script".to_string(), }); } - for import_path in &towerfile.app.import_paths { + for import_path in &self.app.import_paths { let as_str = import_path.to_string_lossy(); if as_str.is_empty() { return Err(Error::InvalidTowerfile { @@ -106,7 +126,21 @@ impl Towerfile { } } - Ok(towerfile) + if let Some(run_size) = &self.app.run_size { + // The server rejects unknown sizes at deploy time. Catching it here + // means the user finds out before the upload rather than after. + if !RUN_SIZES.contains(&run_size.as_str()) { + return Err(Error::InvalidTowerfile { + message: format!( + "run_size {:?} is not a valid size (expected one of: {})", + run_size, + RUN_SIZES.join(", ") + ), + }); + } + } + + Ok(()) } /// set_parameter upserts a parameter by lookup name. If a parameter with the given name @@ -161,6 +195,8 @@ impl Towerfile { pub fn save(&self, path: Option<&std::path::Path>) -> Result<(), crate::error::Error> { use crate::error::Error as OuterError; + self.validate()?; + let target_path = path.unwrap_or_else(|| std::path::Path::new("Towerfile")); let serialized = toml::to_string_pretty(self).map_err(|err| OuterError::InvalidTowerfile { @@ -436,6 +472,129 @@ mod test { assert!(serialized.contains(r#"description = "My app""#)); } + #[test] + fn test_run_size_absent_when_not_set() { + let toml = r#" + [app] + name = "test" + script = "./script.py" + "#; + + let towerfile = crate::Towerfile::from_toml(toml).unwrap(); + assert_eq!(towerfile.app.run_size, None); + + // An unset run_size must not be written into the user's Towerfile, so + // the server keeps applying its own default. + let serialized = toml::to_string_pretty(&towerfile).unwrap(); + assert!(!serialized.contains("run_size")); + } + + #[test] + fn test_run_size_accepts_every_known_size() { + for size in crate::RUN_SIZES { + let toml = format!( + r#" + [app] + name = "test" + script = "./script.py" + run_size = "{}" + "#, + size + ); + + let towerfile = crate::Towerfile::from_toml(&toml).unwrap(); + assert_eq!(towerfile.app.run_size.as_deref(), Some(size)); + } + } + + #[test] + fn test_run_size_rejects_unknown_size() { + let toml = r#" + [app] + name = "test" + script = "./script.py" + run_size = "basic.enormous" + "#; + + let err = crate::Towerfile::from_toml(toml).unwrap_err(); + let message = err.to_string(); + assert!(message.contains("basic.enormous"), "was: {}", message); + assert!(message.contains("basic.medium"), "was: {}", message); + } + + #[test] + fn test_run_size_is_case_and_prefix_sensitive() { + // The size names are matched exactly; near misses must not slip through + // to the server. + for bad in ["medium", "basic.Medium", "BASIC.MEDIUM", " basic.medium"] { + let toml = format!( + r#" + [app] + name = "test" + script = "./script.py" + run_size = "{}" + "#, + bad + ); + + assert!( + crate::Towerfile::from_toml(&toml).is_err(), + "expected {:?} to be rejected", + bad + ); + } + } + + #[test] + fn test_run_size_survives_read_modify_write() { + // Regression: every MCP write round-trips the Towerfile through this + // struct, so a field that isn't modelled is silently dropped from the + // user's file. Editing an unrelated field must leave run_size intact. + let toml = r#" + [app] + name = "test" + script = "./script.py" + run_size = "basic.large" + "#; + + let mut towerfile = crate::Towerfile::from_toml(toml).unwrap(); + towerfile.app.description = Some("edited".to_string()); + + let serialized = toml::to_string_pretty(&towerfile).unwrap(); + let reparsed = crate::Towerfile::from_toml(&serialized).unwrap(); + + assert_eq!(reparsed.app.run_size.as_deref(), Some("basic.large")); + assert_eq!(reparsed.app.description.as_deref(), Some("edited")); + } + + #[test] + fn test_save_refuses_to_persist_an_invalid_run_size() { + // MCP edits mutate the struct and write it straight back, so save() has + // to hold the mutation to the same rules as a read. Otherwise a bad + // value lands on disk and the user only finds out on the next read. + let toml = r#" + [app] + name = "test" + script = "./script.py" + run_size = "basic.small" + "#; + + let mut towerfile = crate::Towerfile::from_toml(toml).unwrap(); + towerfile.app.run_size = Some("basic.enormous".to_string()); + + let tempfile = + TestFile::new("Towerfile-save-invalid").expect("Failed to create temporary file"); + let path = PathBuf::from("Towerfile-save-invalid"); + + let err = towerfile.save(Some(&path)).unwrap_err(); + assert!(err.to_string().contains("basic.enormous"), "was: {}", err); + + // The file must be untouched, not half-written. + assert_eq!(std::fs::read_to_string(&path).unwrap(), ""); + + drop(tempfile); + } + #[test] fn test_roundtrip_serialization() { let original_toml = r#"[app] diff --git a/crates/tower-package/tests/package_test.rs b/crates/tower-package/tests/package_test.rs index e39a020f..9aca0b6e 100644 --- a/crates/tower-package/tests/package_test.rs +++ b/crates/tower-package/tests/package_test.rs @@ -605,3 +605,44 @@ where .expect("Failed to read string from stream"); content } + +#[tokio::test] +async fn it_ships_run_size_in_the_packaged_towerfile() { + // The server reads run_size out of the Towerfile stored on the app version, + // not out of the MANIFEST, so the raw file has to survive packaging verbatim. + let tmp_dir = TmpDir::new("example") + .await + .expect("Failed to create temp dir"); + + let towerfile = "[app]\nname = \"test\"\nscript = \"main.py\"\nrun_size = \"basic.large\"\n"; + create_test_file(tmp_dir.to_path_buf(), "Towerfile", towerfile).await; + create_test_file(tmp_dir.to_path_buf(), "main.py", "print('Hello, world!')").await; + + let spec = PackageSpec { + base_dir: tmp_dir.to_path_buf(), + towerfile_path: tmp_dir.to_path_buf().join("Towerfile").to_path_buf(), + file_globs: vec!["*.py".to_string()], + import_paths: vec![], + }; + + let package = Package::build(spec).await.expect("Failed to build package"); + let files = read_package_files(package).await; + + let packaged = files + .get("Towerfile") + .expect("package was missing the Towerfile entry"); + assert!( + packaged.contains(r#"run_size = "basic.large""#), + "packaged Towerfile lost run_size: {:?}", + packaged + ); + + // run_size deliberately stays out of the MANIFEST: the runner doesn't act on + // it, and a second copy could disagree with the Towerfile above. + let manifest = files.get("MANIFEST").expect("package was missing MANIFEST"); + assert!( + !manifest.contains("run_size"), + "MANIFEST should not carry run_size: {:?}", + manifest + ); +} diff --git a/plugin/skills/tower/SKILL.md b/plugin/skills/tower/SKILL.md index a6fe0ad7..476f4b4e 100644 --- a/plugin/skills/tower/SKILL.md +++ b/plugin/skills/tower/SKILL.md @@ -90,6 +90,8 @@ tower_file_generate → tower_file_update → tower_file_add/edit/remove_paramet Always use `tower_file_update` or `tower_file_add/edit/remove_parameter` to modify. Never edit the TOML directly. +To give an app more compute, set `run_size` via `tower_file_update`: one of `basic.xsmall`, `basic.small`, `basic.medium` (the default), `basic.large`. It is versioned with the code, and self-hosted runners ignore it. + ### 2. Local development (preferred) ``` diff --git a/tests/integration/features/mcp_app_management.feature b/tests/integration/features/mcp_app_management.feature index ccae5790..03762225 100644 --- a/tests/integration/features/mcp_app_management.feature +++ b/tests/integration/features/mcp_app_management.feature @@ -34,6 +34,24 @@ Feature: MCP App Management When I call tower_file_read via MCP Then I should receive the parsed Towerfile configuration + Scenario: Set the app's compute size through the Towerfile + Given I have a valid Towerfile in the current directory + When I call tower_file_update with run_size "basic.large" + Then I should receive a success response + And the Towerfile on disk should contain run_size "basic.large" + + Scenario: Reject an invalid compute size + Given I have a valid Towerfile in the current directory + When I call tower_file_update with run_size "basic.enormous" + Then I should receive an error response + And the Towerfile on disk should contain run_size "basic.small" + + Scenario: Editing an unrelated field preserves the compute size + Given I have a valid Towerfile in the current directory + When I call tower_file_update with description "an edited description" + Then I should receive a success response + And the Towerfile on disk should contain run_size "basic.small" + Scenario: Run simple application successfully locally Given I have a simple hello world application When I call tower_run_local via MCP diff --git a/tests/integration/features/steps/mcp_steps.py b/tests/integration/features/steps/mcp_steps.py index b9afa60e..1f097ba2 100644 --- a/tests/integration/features/steps/mcp_steps.py +++ b/tests/integration/features/steps/mcp_steps.py @@ -249,6 +249,26 @@ async def step_call_mcp_tool_with_unique_app_name(context, tool_name, app_name): ) +@step('I call tower_file_update with run_size "{run_size}"') +@async_run_until_complete +async def step_update_run_size(context, run_size): + await call_mcp_tool(context, "tower_file_update", {"run_size": run_size}) + + +@step('I call tower_file_update with description "{description}"') +@async_run_until_complete +async def step_update_description(context, description): + await call_mcp_tool(context, "tower_file_update", {"description": description}) + + +@then('the Towerfile on disk should contain run_size "{run_size}"') +def step_check_towerfile_run_size(context, run_size): + contents = Path("Towerfile").read_text() + assert ( + f'run_size = "{run_size}"' in contents + ), f"Towerfile should keep run_size {run_size!r}, got: {contents}" + + @step("I should receive a response") def step_check_response_exists(context): assert hasattr(context, "mcp_response") and context.mcp_response is not None diff --git a/tests/integration/templates/Towerfile.j2 b/tests/integration/templates/Towerfile.j2 index a6e926ef..11a521fb 100644 --- a/tests/integration/templates/Towerfile.j2 +++ b/tests/integration/templates/Towerfile.j2 @@ -3,6 +3,7 @@ name = "{{ app_name }}" script = "./{{ script_name }}" description = "{{ description }}" source = ["./{{ script_name }}"] +run_size = "basic.small" [build] python = "3.11" \ No newline at end of file