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
8 changes: 7 additions & 1 deletion crates/tower-cmd/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ struct UpdateTowerfileRequest {
script: Option<String>,
description: Option<String>,
source: Option<Vec<String>>,
/// 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<String>,
}

#[derive(Debug, Deserialize, JsonSchema)]
Expand Down Expand Up @@ -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,
Expand All @@ -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())
})
}
Expand Down
2 changes: 2 additions & 0 deletions crates/tower-cmd/src/skill.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)

```
Expand Down
2 changes: 1 addition & 1 deletion crates/tower-package/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
167 changes: 163 additions & 4 deletions crates/tower-package/src/towerfile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -39,6 +45,12 @@ pub struct App {

#[serde(default)]
pub import_paths: Vec<PathBuf>,

/// 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<String>,
}

#[derive(Deserialize, Serialize, Debug)]
Expand Down Expand Up @@ -66,6 +78,7 @@ impl Towerfile {
schedule: String::from("0 0 * * *"),
description: None,
import_paths: vec![],
run_size: None,
},
}
}
Expand All @@ -74,20 +87,27 @@ impl Towerfile {
/// the base_dir field always needs to be set after parsing.
pub fn from_toml(toml: &str) -> Result<Self, Error> {
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 {
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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]
Expand Down
41 changes: 41 additions & 0 deletions crates/tower-package/tests/package_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
2 changes: 2 additions & 0 deletions plugin/skills/tower/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

```
Expand Down
18 changes: 18 additions & 0 deletions tests/integration/features/mcp_app_management.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions tests/integration/features/steps/mcp_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions tests/integration/templates/Towerfile.j2
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ name = "{{ app_name }}"
script = "./{{ script_name }}"
description = "{{ description }}"
source = ["./{{ script_name }}"]
run_size = "basic.small"

[build]
python = "3.11"
Loading