fix(aws): stop guard review round 1

The stop guard's choice now reaches the workspace: after a successful
push (or for Stop without pushing) the app records pushed/discard in
/run/triple-c/stop-reason as root, and the entrypoint's shutdown hook
then skips its own push; a marker that can't be written is reported
and the stop goes on. The push runs before the auth bridge is stopped;
a guarded stop of a workspace that already stopped succeeds. The
Fargate table allows only 512/1024/2048 MiB with 256 CPU, pinned on
both sides by a shared fixture. Removal's missing-connection refusal
carries a stable prefix. OpenAI Compatible is allowed on AWS, with a
warning for local addresses and the app's own gateway. HostSection
locks resource actions unless Stopped, the add dialog drops the
environment on a region change, and the repository editor keeps rows
in progress across a refresh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-11-15 10:26:53 -08:00
1 parent eb7c6c7b21
commit 8441a096c8
26 files changed
+780 -193

No files matched your search

+101 -18
View File
@@ -1494,7 +1494,7 @@ pub async fn stop_project_container(
wip: Option<WipChoice>,
app_handle: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<(), String> {
) -> Result<Option<String>, String> {
// Stop had **no** exclusion at all, which is the one gap CLAUDE.md's "every
// command that stops, removes or recreates the container consults
// `is_migrating`" rule already named and this function did not honour. A
@@ -1508,25 +1508,37 @@ pub async fn stop_project_container(
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
// The stop guard asked, and meanwhile the workspace stopped by itself
// (idle stop, another window): what the user wanted has happened.
if project.is_aws() && wip.is_some() && project.status == ProjectStatus::Stopped {
return Ok(None);
}
state.projects_store.update_status(&project_id, ProjectStatus::Stopping)?;
// The stop guard's choice, before anything else is torn down: a push that
// fails aborts the stop with the project as it was (auth bridge included).
let mut note = None;
if project.is_aws() {
if wip == Some(WipChoice::Push) {
emit_progress(&app_handle, &project_id, "Pushing unsaved work...");
}
let target = ContainerTarget::for_project(&project);
note = aws_guard_before_stop(
&state.projects_store,
&project_id,
project.status.clone(),
target.as_ref(),
wip,
super::stop_guard::PUSH_TIMEOUT,
)
.await?;
}
// Drop host listeners first: they only make sense while the container runs.
state.auth_bridge.stop(&project_id).await;
if project.is_aws() {
// The stop guard's choice: a push that fails aborts the stop, and
// the project keeps its status (the push output says why).
if wip == Some(WipChoice::Push) {
emit_progress(&app_handle, &project_id, "Pushing unsaved work...");
let target = ContainerTarget::for_project(&project);
if let Err(e) =
super::stop_guard::before_stop(target.as_ref(), wip, super::stop_guard::PUSH_TIMEOUT).await
{
log::warn!("Project {}: WIP push before stop failed: {}", project_id, e);
let _ = state.projects_store.update_status(&project_id, project.status.clone());
return Err(e);
}
}
// A failed StopTask leaves a billed task running, so unlike Docker it
// is not papered over: the project keeps its status and the user
// sees why (expired SSO, typically), and can retry.
@@ -1560,7 +1572,27 @@ pub async fn stop_project_container(
}
state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?;
Ok(())
Ok(note)
}
/// The AWS stop guard's step before StopTask ([`super::stop_guard::prepare_stop`]).
/// A failed push puts `previous` back, so the project is as it was.
async fn aws_guard_before_stop<R: super::stop_guard::WipRunner>(
store: &crate::storage::projects_store::ProjectsStore,
project_id: &str,
previous: ProjectStatus,
runner: Option<&R>,
wip: Option<WipChoice>,
push_timeout: std::time::Duration,
) -> Result<Option<String>, String> {
match super::stop_guard::prepare_stop(runner, wip, push_timeout).await {
Ok(note) => Ok(note),
Err(e) => {
log::warn!("Project {}: WIP push before stop failed: {}", project_id, e);
let _ = store.update_status(project_id, previous);
Err(e)
}
}
}
/// The stop guard's question: what would stopping this AWS workspace lose?
@@ -1735,15 +1767,19 @@ pub async fn rebuild_project_container(
/// whose container was removed mid-migration reports Stopped.
/// Removing an AWS project whose connection was deleted can't reach AWS:
/// refused unless the user confirmed leaving its resources behind.
/// Stable prefix of that refusal, so the UI can offer the second confirm
/// without matching prose. Mirrored by `AWS_CONNECTION_MISSING` in `lib/types.ts`.
pub const AWS_CONNECTION_MISSING: &str = "AWS_CONNECTION_MISSING: ";
fn missing_connection_removal(forget_aws_resources: bool) -> Result<(), String> {
if forget_aws_resources {
return Ok(());
}
Err("This project's AWS connection no longer exists, so its task (which may still be \
Err(AWS_CONNECTION_MISSING.to_string()
+ "This project's AWS connection no longer exists, so its task (which may still be \
running and billed), secret and data can't be removed from here. Add the connection \
again in Settings → Hosts and remove the project, or confirm removing it while leaving \
those resources in AWS."
.into())
those resources in AWS.")
}
/// After a failed start: an AWS project whose task ARN is still stored has
@@ -2612,7 +2648,54 @@ mod tests {
fn removing_without_the_connection_needs_explicit_confirmation() {
let e = missing_connection_removal(false).unwrap_err();
assert!(e.contains("still be running") && e.contains("Settings → Hosts"), "{e}");
assert!(e.starts_with(AWS_CONNECTION_MISSING));
assert!(missing_connection_removal(true).is_ok());
// The frontend keys its second confirm on the same prefix.
let ts = include_str!("../../../src/lib/types.ts");
assert!(
ts.contains(&format!("export const AWS_CONNECTION_MISSING = {:?};", AWS_CONNECTION_MISSING)),
"lib/types.ts must mirror AWS_CONNECTION_MISSING"
);
}
fn stop_store(status: ProjectStatus) -> (tempfile::TempDir, crate::storage::projects_store::ProjectsStore, String) {
let mut p = aws_project();
p.status = status;
let dir = tempfile::tempdir().unwrap();
let store = crate::storage::projects_store::ProjectsStore::over_file_for_test(
dir.path().join("projects.json"),
vec![p.clone()],
);
(dir, store, p.id)
}
#[tokio::test]
async fn a_failed_push_before_stop_puts_the_status_back_and_records_nothing() {
use crate::commands::stop_guard::tests::Fake;
let (_d, store, id) = stop_store(ProjectStatus::Running);
store.update_status(&id, ProjectStatus::Stopping).unwrap();
let fake = Fake::new(Ok(("push to origin failed: denied".into(), 1)));
let e = aws_guard_before_stop(&store, &id, ProjectStatus::Running, Some(&fake), Some(WipChoice::Push), std::time::Duration::from_secs(5))
.await
.unwrap_err();
assert!(e.contains("denied"), "{e}");
assert_eq!(store.get(&id).unwrap().status, ProjectStatus::Running);
assert!(fake.markers.lock().unwrap().is_empty());
}
#[tokio::test]
async fn a_successful_push_before_stop_records_it_and_leaves_the_stop_going() {
use crate::commands::stop_guard::tests::Fake;
let (_d, store, id) = stop_store(ProjectStatus::Running);
store.update_status(&id, ProjectStatus::Stopping).unwrap();
let fake = Fake::new(Ok(("pushed".into(), 0)));
let note = aws_guard_before_stop(&store, &id, ProjectStatus::Running, Some(&fake), Some(WipChoice::Push), std::time::Duration::from_secs(5))
.await
.unwrap();
assert_eq!(note, None);
assert_eq!(store.get(&id).unwrap().status, ProjectStatus::Stopping, "untouched: the stop goes on");
assert_eq!(*fake.markers.lock().unwrap(), vec!["pushed".to_string()]);
assert_eq!(fake.calls.lock().unwrap().len(), 1);
}
#[test]
+135 -26
View File
@@ -20,9 +20,20 @@ pub const PUSH_TIMEOUT: Duration = Duration::from_secs(300);
/// Cap on the push output carried into an error.
const MAX_OUTPUT: usize = 4000;
/// Runs the WIP script in the workspace: `(combined output, exit code)`.
/// Where the entrypoint's stop hook looks for the app's decision (Task 7's
/// file, which the idle watcher also writes `idle` to).
pub const STOP_REASON_FILE: &str = "/run/triple-c/stop-reason";
/// Recorded after the guard's push succeeded: the hook does not push again.
pub const MARKER_PUSHED: &str = "pushed";
/// Recorded for "Stop without pushing": the hook does not push.
pub const MARKER_DISCARD: &str = "discard";
/// The workspace, as the stop guard uses it.
pub trait WipRunner: Sync {
/// Run the WIP script as `claude`: `(combined output, exit code)`.
fn run(&self, args: Vec<String>) -> impl Future<Output = Result<(String, i64), String>> + Send;
/// Record `marker` in [`STOP_REASON_FILE`] (as root: the directory is root's).
fn write_stop_reason(&self, marker: &str) -> impl Future<Output = Result<(), String>> + Send;
}
impl WipRunner for ContainerTarget {
@@ -31,6 +42,31 @@ impl WipRunner for ContainerTarget {
cmd.extend(args);
self.exec_oneshot_as("claude", cmd, Vec::new()).await
}
async fn write_stop_reason(&self, marker: &str) -> Result<(), String> {
let (out, code) = self
.exec_oneshot_as("root", stop_reason_command(marker), Vec::new())
.await?;
if code == 0 {
Ok(())
} else {
Err(format!("exit {code}: {}", cap(&out)))
}
}
}
/// The root command that records `marker`; the value travels as `$1`, never
/// inside the script.
pub fn stop_reason_command(marker: &str) -> Vec<String> {
vec![
"sh".into(),
"-c".into(),
format!(
"umask 077 && mkdir -p /run/triple-c && chmod 700 /run/triple-c && printf '%s\\n' \"$1\" > {STOP_REASON_FILE}"
),
"sh".into(),
marker.into(),
]
}
/// The repository lines of a dry run. Lines that are not JSON objects (a
@@ -47,13 +83,6 @@ pub fn parse_dry_run(output: &str) -> Result<Vec<RepoState>, String> {
.collect()
}
/// Whether anything in `work` would be lost by a stop (or could not be checked).
/// The frontend applies the same rule (`stopGuardNeeded`).
#[cfg(test)]
pub fn needs_guard(work: &UnsavedWork) -> bool {
!work.checked || work.repos.iter().any(|r| r.dirty || r.unpushed > 0)
}
fn unchecked(reason: String) -> UnsavedWork {
UnsavedWork { checked: false, repos: Vec::new(), error: Some(reason) }
}
@@ -125,20 +154,61 @@ pub async fn before_stop<R: WipRunner>(
}
}
/// The note a stop returns when the hook's own push may still run.
pub const MARKER_NOT_RECORDED: &str = "Triple-C could not tell the workspace what you chose, so its own \
push of work in progress on shutdown may still run (if it is turned on).";
/// Everything the guard does before StopTask: `Push` pushes (a failure is an
/// `Err` and the stop must not happen), then records `pushed`; `Discard`
/// records `discard`; no choice does nothing. A marker that can't be
/// written does not stop the stop: `Ok(Some(note))` says the hook may push.
pub async fn prepare_stop<R: WipRunner>(
runner: Option<&R>,
choice: Option<WipChoice>,
push_timeout: Duration,
) -> Result<Option<String>, String> {
let marker = match choice {
None => return Ok(None),
Some(WipChoice::Push) => {
before_stop(runner, choice, push_timeout).await?;
MARKER_PUSHED
}
Some(WipChoice::Discard) => MARKER_DISCARD,
};
let written = match runner {
Some(r) => tokio::time::timeout(DRY_RUN_TIMEOUT, r.write_stop_reason(marker))
.await
.unwrap_or_else(|_| Err("timed out".into())),
None => Err("not connected to the workspace".into()),
};
match written {
Ok(()) => Ok(None),
Err(e) => {
log::warn!("Could not record the stop guard's choice ({marker}): {e}");
Ok(Some(MARKER_NOT_RECORDED.to_string()))
}
}
}
#[cfg(test)]
mod tests {
pub(crate) mod tests {
use super::*;
use std::sync::Mutex;
struct Fake {
reply: Result<(String, i64), String>,
calls: Mutex<Vec<Vec<String>>>,
hang: bool,
pub(crate) struct Fake {
pub reply: Result<(String, i64), String>,
pub calls: Mutex<Vec<Vec<String>>>,
pub hang: bool,
pub markers: Mutex<Vec<String>>,
pub marker_fails: bool,
}
impl Fake {
fn new(reply: Result<(String, i64), String>) -> Self {
Fake { reply, calls: Mutex::new(Vec::new()), hang: false }
pub fn new(reply: Result<(String, i64), String>) -> Self {
Fake { reply, calls: Mutex::new(Vec::new()), hang: false, markers: Mutex::new(Vec::new()), marker_fails: false }
}
fn markers(&self) -> Vec<String> {
self.markers.lock().unwrap().clone()
}
fn calls(&self) -> Vec<Vec<String>> {
self.calls.lock().unwrap().clone()
@@ -153,6 +223,13 @@ mod tests {
}
self.reply.clone()
}
async fn write_stop_reason(&self, marker: &str) -> Result<(), String> {
if self.marker_fails {
return Err("agent unreachable".into());
}
self.markers.lock().unwrap().push(marker.to_string());
Ok(())
}
}
const T: Duration = Duration::from_secs(5);
@@ -175,7 +252,6 @@ mod tests {
let w = unsaved_work(Some(&fake), T).await;
assert!(w.checked && w.error.is_none());
assert_eq!(w.repos.len(), 1);
assert!(!needs_guard(&w));
assert_eq!(fake.calls(), vec![vec!["--dry-run".to_string()]]);
}
@@ -184,7 +260,6 @@ mod tests {
let w = unsaved_work::<Fake>(None, T).await;
assert!(!w.checked);
assert!(w.error.as_deref().unwrap().contains("not connected"));
assert!(needs_guard(&w));
let failing = Fake::new(Err("connection refused".into()));
let w = unsaved_work(Some(&failing), T).await;
@@ -203,15 +278,6 @@ mod tests {
assert!(!w.checked && w.error.unwrap().contains("in time"));
}
#[test]
fn dirty_or_unpushed_work_needs_the_guard() {
let repo = |dirty, unpushed| RepoState { folder: "x".into(), dirty, unpushed, branch: "b".into() };
let w = |r| UnsavedWork { checked: true, repos: vec![r], error: None };
assert!(needs_guard(&w(repo(true, 0))));
assert!(needs_guard(&w(repo(false, 1))));
assert!(!needs_guard(&w(repo(false, 0))));
}
#[tokio::test]
async fn push_runs_the_script_with_reason_stop() {
let fake = Fake::new(Ok(("pushed".into(), 0)));
@@ -246,4 +312,47 @@ mod tests {
let c = cap(&s);
assert!(c.ends_with("END") && c.len() < 4100);
}
#[tokio::test]
async fn a_successful_push_records_pushed_and_stop_without_pushing_records_discard() {
let fake = Fake::new(Ok(("pushed".into(), 0)));
assert_eq!(prepare_stop(Some(&fake), Some(WipChoice::Push), T).await.unwrap(), None);
assert_eq!(fake.markers(), vec![MARKER_PUSHED]);
let fake = Fake::new(Err("must not run".into()));
assert_eq!(prepare_stop(Some(&fake), Some(WipChoice::Discard), T).await.unwrap(), None);
assert!(fake.calls().is_empty());
assert_eq!(fake.markers(), vec![MARKER_DISCARD]);
assert_eq!(prepare_stop(Some(&fake), None, T).await.unwrap(), None);
assert_eq!(fake.markers().len(), 1, "no choice records nothing");
}
#[tokio::test]
async fn a_failed_push_records_nothing() {
let fake = Fake::new(Ok(("denied".into(), 1)));
assert!(prepare_stop(Some(&fake), Some(WipChoice::Push), T).await.is_err());
assert!(fake.markers().is_empty());
}
#[tokio::test]
async fn an_unwritable_marker_still_stops_and_says_the_hook_may_push() {
let mut fake = Fake::new(Ok((String::new(), 0)));
fake.marker_fails = true;
let note = prepare_stop(Some(&fake), Some(WipChoice::Discard), T).await.unwrap();
assert!(note.unwrap().contains("may still run"));
let note = prepare_stop::<Fake>(None, Some(WipChoice::Discard), T).await.unwrap();
assert!(note.is_some());
}
#[test]
fn the_marker_is_written_as_an_argument_to_the_file_the_hook_reads() {
let cmd = stop_reason_command("pushed");
assert_eq!(&cmd[..2], &["sh".to_string(), "-c".to_string()]);
assert!(cmd[2].contains("> /run/triple-c/stop-reason") && cmd[2].contains("\"$1\""));
assert_eq!(cmd.last().unwrap(), "pushed");
// The entrypoint matches exactly these values.
let ep = include_str!("../../../../container/entrypoint.sh");
assert!(ep.contains("pushed|discard)") && ep.contains(STOP_REASON_FILE));
}
}
@@ -875,6 +875,34 @@ fn aws_stop_runs_the_wip_push_with_the_recorded_reason_once() {
assert!(!log.exists(), "WIP push off");
}
#[test]
fn aws_stop_skips_its_push_when_the_app_already_pushed_or_the_user_declined() {
let tmp = tempfile::tempdir().unwrap();
let log = tmp.path().join("calls");
let fake = write_script(
tmp.path(),
"triple-c-wip-push",
&format!("#!/bin/bash\necho \"$*\" >> '{}'\n", log.display()),
);
let reason = tmp.path().join("reason");
let blocks = aws_blocks(&["aws_env", "aws_stop"])
.replace("/usr/local/bin/triple-c-wip-push", fake.to_str().unwrap())
.replace("/run/triple-c/stop-reason", reason.to_str().unwrap());
let script = format!("{blocks}\naws_before_stop\n");
for marker in ["pushed", "discard"] {
fs::write(&reason, format!("{marker}\n")).unwrap();
let out = bash(&script, &[], &[("TRIPLEC_HOST", "aws"), ("TRIPLEC_WIP_PUSH", "1")], &[]);
assert!(out.status.success(), "{}", text(&out));
assert!(!log.exists(), "{marker}: no second push");
assert!(text(&out).contains(&format!("already handled by Triple-C ({marker})")), "{}", text(&out));
}
// Anything else is not a marker the app writes: the push still runs.
fs::write(&reason, "something-else\n").unwrap();
let out = bash(&script, &[], &[("TRIPLEC_HOST", "aws"), ("TRIPLEC_WIP_PUSH", "1")], &[]);
assert!(out.status.success(), "{}", text(&out));
assert_eq!(fs::read_to_string(&log).unwrap(), "--reason sigterm\n");
}
#[test]
fn docker_mode_never_reaches_the_aws_blocks() {
// Every new step sits behind TRIPLEC_HOST=aws; the scripts are only
+47 -18
View File
@@ -557,18 +557,20 @@ impl Default for AwsWorkspaceConfig {
}
}
/// The valid Fargate memory range (min, max, step) in MiB for a vCPU size.
fn fargate_memory_range(cpu: u32) -> Option<(u32, u32, u32)> {
match cpu {
256 => Some((512, 2048, 512)),
512 => Some((1024, 4096, 1024)),
1024 => Some((2048, 8192, 1024)),
2048 => Some((4096, 16384, 1024)),
4096 => Some((8192, 30720, 1024)),
8192 => Some((16384, 61440, 4096)),
16384 => Some((32768, 122880, 8192)),
_ => None,
}
/// The valid Fargate memory sizes (MiB) for a CPU size, smallest first.
/// 256 CPU is the irregular one: 512, 1024 or 2048 only.
fn fargate_memory_sizes(cpu: u32) -> Option<Vec<u32>> {
let (min, max, step) = match cpu {
256 => return Some(vec![512, 1024, 2048]),
512 => (1024, 4096, 1024),
1024 => (2048, 8192, 1024),
2048 => (4096, 16384, 1024),
4096 => (8192, 30720, 1024),
8192 => (16384, 61440, 4096),
16384 => (32768, 122880, 8192),
_ => return None,
};
Some((min..=max).step_by(step as usize).collect())
}
fn validate_repo_url(url: &str) -> Result<(), String> {
@@ -643,17 +645,29 @@ fn validate_repo_folder(folder: &str) -> Result<(), String> {
}
}
/// "512, 1024 or 2048 MiB", or "2048 to 8192 MiB in steps of 1024".
fn describe_sizes(sizes: &[u32]) -> String {
let (first, last) = (sizes[0], sizes[sizes.len() - 1]);
if sizes.len() <= 3 {
let head: Vec<String> = sizes[..sizes.len() - 1].iter().map(u32::to_string).collect();
return format!("{} or {} MiB", head.join(", "), last);
}
format!("{} to {} MiB in steps of {}", first, last, sizes[1] - sizes[0])
}
pub fn validate_aws_workspace(cfg: &AwsWorkspaceConfig) -> Result<(), String> {
let (min, max, step) = fargate_memory_range(cfg.cpu).ok_or_else(|| {
let sizes = fargate_memory_sizes(cfg.cpu).ok_or_else(|| {
format!(
"{} is not a Fargate CPU size (256, 512, 1024, 2048, 4096, 8192 or 16384)",
cfg.cpu
)
})?;
if cfg.memory_mib < min || cfg.memory_mib > max || (cfg.memory_mib - min) % step != 0 {
if !sizes.contains(&cfg.memory_mib) {
return Err(format!(
"{} MiB of memory is not valid with {} CPU units: use {} to {} MiB in steps of {}",
cfg.memory_mib, cfg.cpu, min, max, step
"{} MiB of memory is not valid with {} CPU units: use {}",
cfg.memory_mib,
cfg.cpu,
describe_sizes(&sizes)
));
}
if !(21..=200).contains(&cfg.ephemeral_gib) {
@@ -1323,12 +1337,27 @@ mod tests {
#[test]
fn cpu_and_memory_must_be_a_fargate_pair() {
for (c, m) in [(256, 512), (256, 2048), (1024, 2048), (1024, 8192), (2048, 4096), (4096, 30720), (8192, 61440), (16384, 32768), (16384, 122880)] {
// Shared with the frontend's copy (`lib/awsWorkspace.test.ts`).
let fixture: serde_json::Value =
serde_json::from_str(include_str!("../../../src/test/fixtures/fargate-pairs.json")).unwrap();
let pairs = |k: &str| -> Vec<(u32, u32)> {
fixture[k]
.as_array()
.unwrap()
.iter()
.map(|p| (p[0].as_u64().unwrap() as u32, p[1].as_u64().unwrap() as u32))
.collect()
};
for (c, m) in pairs("valid") {
assert!(validate_aws_workspace(&cfg(c, m)).is_ok(), "{c}/{m} should be valid");
}
for (c, m) in [(256, 4096), (1024, 1024), (1024, 9216), (1024, 2500), (300, 1024), (16384, 114688 + 1), (8192, 18432)] {
for (c, m) in pairs("invalid") {
assert!(validate_aws_workspace(&cfg(c, m)).is_err(), "{c}/{m} should be rejected");
}
let e = validate_aws_workspace(&cfg(256, 1536)).unwrap_err();
assert!(e.ends_with("use 512, 1024 or 2048 MiB"), "{e}");
let e = validate_aws_workspace(&cfg(1024, 2500)).unwrap_err();
assert!(e.ends_with("use 2048 to 8192 MiB in steps of 1024"), "{e}");
}
#[test]
@@ -214,4 +214,23 @@ describe("AddProjectDialog on AWS", () => {
fireEvent.click(submitButton());
expect(await screen.findByRole("alert")).toHaveTextContent(/use https:\/\//);
});
it("forgets the environment when the region changes until that region's scan is done", async () => {
detectAwsEnvironments.mockResolvedValueOnce(scan([environment("dev", "ready", true)]));
let finish!: (v: EnvironmentScan) => void;
detectAwsEnvironments.mockReturnValueOnce(new Promise((r) => (finish = r)));
render(<AddProjectDialog onClose={vi.fn()} />);
chooseAws();
await waitFor(() => expect(screen.getByLabelText("Environment")).toHaveValue("dev"));
fireEvent.change(screen.getByLabelText("Project name"), { target: { value: "api" } });
const region = screen.getByLabelText("Region");
fireEvent.change(region, { target: { value: "us-east-1" } });
fireEvent.blur(region);
expect(await screen.findByText(/Looking for environments in us-east-1/)).toBeInTheDocument();
fireEvent.click(submitButton());
expect(await screen.findByRole("alert")).toHaveTextContent(/Choose an environment/);
expect(add).not.toHaveBeenCalled();
await act(async () => finish(scan([environment("prod", "ready", true)])));
expect(screen.getByLabelText("Environment")).toHaveValue("prod");
});
});
@@ -66,6 +66,8 @@ export default function AddProjectDialog({ onClose }: Props) {
return;
}
let cancelled = false;
// An environment chosen in another region is not one in this region.
setEnvironment("");
setScan({ status: "loading" });
detectAwsEnvironments(connection.id, scanRegion).then(
(found) => {
@@ -126,7 +128,13 @@ export default function AddProjectDialog({ onClose }: Props) {
return;
}
if (connection) {
if (!environment) {
// Only an environment from a finished scan of the region shown counts.
if (region.trim() !== scanRegion) {
setScanRegion(region.trim());
setError("Choose an environment in the new region first");
return;
}
if (scan.status !== "done" || !environment) {
setError("Choose an environment to run the project in");
return;
}
@@ -5,7 +5,7 @@ import ConfirmRemoveModal, { ConfirmForgetAwsModal } from "./ConfirmRemoveModal"
describe("ConfirmRemoveModal", () => {
it("keeps an AWS workspace's saved files unless asked", () => {
const onConfirm = vi.fn();
render(<ConfirmRemoveModal projectName="api" aws onConfirm={onConfirm} onCancel={vi.fn()} />);
render(<ConfirmRemoveModal projectName="api" aws savedFiles onConfirm={onConfirm} onCancel={vi.fn()} />);
const box = screen.getByRole("checkbox", { name: /saved files \(EFS\)/ });
expect(box).not.toBeChecked();
fireEvent.click(screen.getByRole("button", { name: "Remove" }));
@@ -14,6 +14,14 @@ describe("ConfirmRemoveModal", () => {
expect(onConfirm.mock.calls).toEqual([[{ discardData: false }], [{ discardData: true }]]);
});
it("offers no EFS choice for an Ephemeral AWS workspace", () => {
const onConfirm = vi.fn();
render(<ConfirmRemoveModal projectName="api" aws onConfirm={onConfirm} onCancel={vi.fn()} />);
expect(screen.queryByRole("checkbox")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Remove" }));
expect(onConfirm).toHaveBeenCalledWith({ discardData: false });
});
it("offers no EFS choice for a local project", () => {
render(<ConfirmRemoveModal projectName="api" onConfirm={vi.fn()} onCancel={vi.fn()} />);
expect(screen.queryByRole("checkbox")).toBeNull();
@@ -6,6 +6,8 @@ interface Props {
projectName: string;
/** An AWS project: its task, task definitions and secret go instead of Docker state. */
aws?: boolean;
/** AWS: the workspace may have saved files on EFS (offers deleting them). */
savedFiles?: boolean;
onConfirm: (opts: { discardData: boolean }) => void;
onCancel: () => void;
}
@@ -13,7 +15,13 @@ interface Props {
const REMOVE_CLASS =
"bg-[var(--error-emphasis)] text-white border border-transparent hover:opacity-90";
export default function ConfirmRemoveModal({ projectName, aws = false, onConfirm, onCancel }: Props) {
export default function ConfirmRemoveModal({
projectName,
aws = false,
savedFiles = false,
onConfirm,
onCancel,
}: Props) {
const [discardData, setDiscardData] = useState(false);
return (
<Modal
@@ -25,7 +33,11 @@ export default function ConfirmRemoveModal({ projectName, aws = false, onConfirm
<Button size="md" variant="ghost" onClick={onCancel}>
Cancel
</Button>
<Button size="md" onClick={() => onConfirm({ discardData })} className={REMOVE_CLASS}>
<Button
size="md"
onClick={() => onConfirm({ discardData: savedFiles && discardData })}
className={REMOVE_CLASS}
>
Remove
</Button>
</>
@@ -40,21 +52,23 @@ export default function ConfirmRemoveModal({ projectName, aws = false, onConfirm
deleted after AWS&rsquo;s 7-day recovery window). Work that was not pushed to a
remote is lost.
</p>
<label className="mt-3 flex items-start gap-2 text-[13px] text-[var(--text-primary)]">
<input
type="checkbox"
className="mt-0.5"
checked={discardData}
onChange={(e) => setDiscardData(e.target.checked)}
/>
<span>
Also delete the workspace&rsquo;s saved files (EFS)
<span className="block text-xs text-[var(--text-secondary)]">
The Claude login, history and SSH keys kept between starts. Left in place
otherwise.
{savedFiles && (
<label className="mt-3 flex items-start gap-2 text-[13px] text-[var(--text-primary)]">
<input
type="checkbox"
className="mt-0.5"
checked={discardData}
onChange={(e) => setDiscardData(e.target.checked)}
/>
<span>
Also delete the workspace&rsquo;s saved files (EFS)
<span className="block text-xs text-[var(--text-secondary)]">
The Claude login, history and SSH keys kept between starts. Left in place
otherwise.
</span>
</span>
</span>
</label>
</label>
)}
</>
) : (
<>
+6 -1
View File
@@ -162,7 +162,12 @@ export default function ProjectRow({ project }: Props) {
{terminal.reasonNode}
</div>
{stopGuard && (
<StopGuardModal projectName={project.name} work={stopGuard} onChoose={resolveStopGuard} />
<StopGuardModal
projectName={project.name}
work={stopGuard}
wipPushOnStop={project.aws?.wip_push_on_stop ?? false}
onChoose={resolveStopGuard}
/>
)}
</div>
);
+27 -21
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { RepoSource } from "../../lib/types";
import { cleanRepos, repoFolderFromUrl } from "../../lib/awsWorkspace";
import Button from "../ui/Button";
@@ -70,8 +70,8 @@ export function RepoRows({ rows, onRowsChange, onCommit, disabled = false }: Rep
aria-label={`Remove repository ${i + 1}`}
onClick={() => {
onRowsChange(rows.filter((_, j) => j !== i));
// A removal is a finished edit. The commit is applied after
// the rows update renders, so it sees the shorter list.
// A removal is a finished edit; `onRowsChange` has already
// handed the shorter list over.
onCommit?.();
}}
>
@@ -98,6 +98,9 @@ function sameRepos(a: RepoSource[], b: RepoSource[]): boolean {
* The Config tab's save-on-blur repository list. Saves when every row that
* has anything in it has a URL; the backend's validation message (bad URL,
* folder used twice…) comes back through the save's toast.
*
* Rows in progress survive a refresh of the project list: they are replaced
* from `repos` only when the stored value actually changed.
*/
export default function RepoListEditor({
repos,
@@ -109,25 +112,28 @@ export default function RepoListEditor({
disabled?: boolean;
}) {
const [rows, setRows] = useState<RepoSource[]>(repos);
useEffect(() => setRows(repos), [repos]);
// The latest rows, so a commit right after an edit (a removal) sees them.
const rowsRef = useRef(rows);
const storedRef = useRef(repos);
// A commit bumps this; the effect then runs against the rendered rows.
const [pending, setPending] = useState(0);
useEffect(() => {
if (pending === 0) return;
const cleaned = cleanRepos(rows);
if (cleaned.some((r) => !r.url)) return;
if (sameRepos(cleaned, repos)) return;
void onSave(cleaned);
// Only a commit triggers this; `rows` changing alone does not save.
}, [pending]);
if (sameRepos(repos, storedRef.current)) return;
storedRef.current = repos;
rowsRef.current = repos;
setRows(repos);
}, [repos]);
return (
<RepoRows
rows={rows}
onRowsChange={setRows}
onCommit={() => setPending((n) => n + 1)}
disabled={disabled}
/>
);
const change = (next: RepoSource[]) => {
rowsRef.current = next;
setRows(next);
};
const commit = () => {
const cleaned = cleanRepos(rowsRef.current);
if (cleaned.some((r) => !r.url)) return;
if (sameRepos(cleaned, storedRef.current)) return;
void onSave(cleaned);
};
return <RepoRows rows={rows} onRowsChange={change} onCommit={commit} disabled={disabled} />;
}
@@ -25,10 +25,10 @@ describe("StopGuardModal", () => {
expect(onChoose).toHaveBeenCalledWith("push");
});
it("offers Stop anyway and Cancel", () => {
it("offers Stop without pushing and Cancel", () => {
const onChoose = vi.fn();
render(<StopGuardModal projectName="api" work={dirty} onChoose={onChoose} />);
fireEvent.click(screen.getByRole("button", { name: "Stop anyway" }));
fireEvent.click(screen.getByRole("button", { name: "Stop without pushing" }));
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onChoose.mock.calls).toEqual([["discard"], [null]]);
});
@@ -39,10 +39,12 @@ describe("StopGuardModal", () => {
<StopGuardModal
projectName="api"
work={{ checked: false, repos: [], error: "Triple-C is not connected to the workspace" }}
wipPushOnStop
onChoose={onChoose}
/>,
);
expect(screen.queryByRole("button", { name: /Push/ })).toBeNull();
expect(screen.getByText(/own push of work in progress still runs/)).toBeInTheDocument();
expect(screen.getByText("Triple-C is not connected to the workspace")).toBeInTheDocument();
expect(screen.getByText(/could not check/)).toBeInTheDocument();
const stop = screen.getByRole("button", { name: "Stop anyway" });
@@ -6,6 +6,8 @@ import Button from "../ui/Button";
interface Props {
projectName: string;
work: UnsavedWork;
/** The project's "push work in progress when the workspace stops" setting. */
wipPushOnStop?: boolean;
/** `null` = Cancel. */
onChoose: (choice: WipChoice | null) => void;
}
@@ -23,7 +25,7 @@ function describeRepo(dirty: boolean, unpushed: number): string {
* the default (it has focus); it is not offered when the check could not
* run, since the push goes the same way the check did.
*/
export default function StopGuardModal({ projectName, work, onChoose }: Props) {
export default function StopGuardModal({ projectName, work, wipPushOnStop = false, onChoose }: Props) {
const pushRef = useRef<HTMLButtonElement>(null);
const stopRef = useRef<HTMLButtonElement>(null);
const atRisk = work.repos.filter((r) => r.dirty || r.unpushed > 0);
@@ -45,7 +47,7 @@ export default function StopGuardModal({ projectName, work, onChoose }: Props) {
variant={work.checked ? "danger" : "primary"}
onClick={() => onChoose("discard")}
>
Stop anyway
{work.checked ? "Stop without pushing" : "Stop anyway"}
</Button>
{work.checked && (
<Button ref={pushRef} size="md" variant="primary" onClick={() => onChoose("push")}>
@@ -74,7 +76,9 @@ export default function StopGuardModal({ projectName, work, onChoose }: Props) {
<p className="mt-3 text-[13px] text-[var(--text-secondary)]">
Push to WIP branch saves a snapshot of each repository to a{" "}
<span className="font-mono">triple-c/wip/…</span> branch on its remote, without
touching your branches. If the push fails, the workspace keeps running.
touching your branches. If the push fails, the workspace keeps running. Stop
without pushing skips that push, including the one the workspace would make as it
shuts down.
</p>
</>
) : (
@@ -94,6 +98,8 @@ export default function StopGuardModal({ projectName, work, onChoose }: Props) {
)}
<p className="mt-2 text-[13px] text-[var(--text-secondary)]">
Uncommitted or unpushed work in the workspace may be lost if you stop it now.
{wipPushOnStop &&
" The workspace's own push of work in progress still runs as it shuts down, if it can reach the remote."}
</p>
</>
)}
@@ -0,0 +1,81 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import ProjectHome from "./ProjectHome";
import { useAppState } from "../../../store/appState";
import { awsProject } from "../../../test/awsFixtures";
import { AWS_CONNECTION_MISSING } from "../../../lib/types";
const remove = vi.fn();
const project = awsProject({ name: "api" });
vi.mock("../../../hooks/useProjects", () => ({
useProjects: () => ({ projects: [project], remove }),
}));
vi.mock("../../../hooks/useProjectActions", () => ({
useProjectActions: () => ({
busy: false,
backingUp: false,
handleStart: vi.fn(),
handleStop: vi.fn(),
handleReset: vi.fn(),
handleBackup: vi.fn(),
openClaudeTerminal: vi.fn(),
openShell: vi.fn(),
stopGuard: null,
resolveStopGuard: vi.fn(),
}),
}));
vi.mock("../../../hooks/useSaveState", () => ({
useProjectSave: () => ({ save: vi.fn(), saveState: { status: "idle", error: null } }),
}));
vi.mock("../../../hooks/useContainerMigration", () => ({
useContainerMigration: () => ({ running: false, interrupted: false, probeSettled: true, staleness: null }),
}));
vi.mock("./OverviewTab", () => ({ default: () => <div>overview</div> }));
const clean = { container: null, image: null, volumes: [], retry_scheduled: false };
beforeEach(() => {
vi.clearAllMocks();
useAppState.setState({ toasts: [] } as never);
});
async function confirmRemove(discard = false) {
render(<ProjectHome projectId={project.id} active />);
fireEvent.click(screen.getByRole("button", { name: "More actions" }));
fireEvent.click(screen.getByRole("menuitem", { name: "Remove project…" }));
const dialog = screen.getByRole("dialog");
if (discard) fireEvent.click(within(dialog).getByRole("checkbox"));
fireEvent.click(within(dialog).getByRole("button", { name: "Remove" }));
}
describe("ProjectHome removal of an AWS project", () => {
it("passes the saved-files choice", async () => {
remove.mockResolvedValue(clean);
await confirmRemove(true);
await waitFor(() => expect(remove).toHaveBeenCalledWith(project.id, { discardData: true }));
});
it("asks again when the connection is gone, then leaves the resources in AWS", async () => {
remove
.mockRejectedValueOnce(`${AWS_CONNECTION_MISSING}This project's AWS connection no longer exists, so …`)
.mockResolvedValueOnce(clean);
await confirmRemove();
const second = await screen.findByRole("dialog", { name: "Remove without its AWS resources?" });
// The marker is not shown to the user.
expect(within(second).getByText(/connection no longer exists/).textContent).not.toContain(AWS_CONNECTION_MISSING);
fireEvent.click(within(second).getByRole("button", { name: "Remove and leave them in AWS" }));
await waitFor(() =>
expect(remove).toHaveBeenLastCalledWith(project.id, { discardData: false, forgetAwsResources: true }),
);
});
it("toasts any other failure without a second confirm", async () => {
remove.mockRejectedValueOnce("Could not stop the task: ExpiredToken");
await confirmRemove();
await waitFor(() => expect(remove).toHaveBeenCalledTimes(1));
expect(screen.queryByRole("dialog")).toBeNull();
const toasts = () => (useAppState.getState() as unknown as { toasts: { detail?: string }[] }).toasts;
await waitFor(() => expect(toasts().at(-1)?.detail).toMatch(/ExpiredToken/));
});
});
@@ -11,8 +11,8 @@ import Button from "../../ui/Button";
import OverflowMenu from "../../ui/OverflowMenu";
import ConfirmRemoveModal, { ConfirmForgetAwsModal } from "../ConfirmRemoveModal";
import StopGuardModal from "../StopGuardModal";
import { isAwsProject } from "../../../lib/awsWorkspace";
import type { RemoveProjectOptions } from "../../../lib/types";
import { hasSavedFiles, isAwsProject } from "../../../lib/awsWorkspace";
import { AWS_CONNECTION_MISSING, type RemoveProjectOptions } from "../../../lib/types";
import ConfirmResetModal from "../ConfirmResetModal";
import MigrateContainerModal from "../MigrateContainerModal";
import OverviewTab from "./OverviewTab";
@@ -148,14 +148,17 @@ export default function ProjectHome({ projectId, active }: Props) {
}
}
} catch (e) {
if (aws && !opts.forgetAwsResources && /connection no longer exists/i.test(String(e))) {
setForgetAws({ reason: String(e), discardData: !!opts.discardData });
const text = String(e);
const missing = text.startsWith(AWS_CONNECTION_MISSING);
const reason = missing ? text.slice(AWS_CONNECTION_MISSING.length) : text;
if (missing && !opts.forgetAwsResources) {
setForgetAws({ reason, discardData: !!opts.discardData });
return;
}
useAppState.getState().pushToast({
kind: "error",
message: `Could not remove “${project.name}”`,
detail: String(e),
detail: reason,
});
}
};
@@ -325,6 +328,7 @@ export default function ProjectHome({ projectId, active }: Props) {
<StopGuardModal
projectName={project.name}
work={actions.stopGuard}
wipPushOnStop={project.aws?.wip_push_on_stop ?? false}
onChoose={actions.resolveStopGuard}
/>
)}
@@ -344,6 +348,7 @@ export default function ProjectHome({ projectId, active }: Props) {
<ConfirmRemoveModal
projectName={project.name}
aws={aws}
savedFiles={hasSavedFiles(project)}
onCancel={() => setConfirmRemove(false)}
onConfirm={({ discardData }) => {
setConfirmRemove(false);
@@ -6,7 +6,8 @@ import WorkspaceSection from "./WorkspaceSection";
import ModelSection from "./ModelSection";
import HostSection from "./HostSection";
import { awsProject, localProject } from "../../../../test/awsFixtures";
import type { AuthBridgeStatus, Project } from "../../../../lib/types";
import type { AppSettings, AuthBridgeStatus, Project } from "../../../../lib/types";
import { useAppState } from "../../../../store/appState";
const OFF_BRIDGE: AuthBridgeStatus = { enabled: false, active_ports: [], conflicts: [] };
const removeAwsResources = vi.fn();
@@ -56,12 +57,45 @@ describe("AWS project config", () => {
expect(screen.getByText(/mounts no folders/)).toBeInTheDocument();
});
it("disables the gateway backend and warns about local model URLs on AWS", () => {
render(<ModelSection project={awsProject({ backend: "ollama" })} save={save} disabled={false} />);
const gateway = screen.getByRole("option", { name: /OpenAI Compatible/ }) as HTMLOptionElement;
expect(gateway.disabled).toBe(true);
expect(gateway.textContent).toMatch(/Not available on AWS/);
it("allows every custom-endpoint backend on AWS with the reachability warning", () => {
render(
<ModelSection
project={awsProject({
backend: "open_ai_compatible",
openai_compatible_config: { base_url: "https://llm.example.com", api_key: null, model_id: null, haiku_model_id: null } as never,
})}
save={save}
disabled={false}
/>,
);
const oai = screen.getByRole("option", { name: "OpenAI Compatible" }) as HTMLOptionElement;
expect(oai.disabled).toBe(false);
expect(screen.getByText(/must be reachable from the AWS task/)).toBeInTheDocument();
expect(screen.queryByText(/can't reach it/)).toBeNull();
});
it("warns when the endpoint is this machine, or the app's own gateway", () => {
const { unmount } = render(
<ModelSection project={awsProject({ backend: "ollama" })} save={save} disabled={false} />,
);
// The Ollama default is host.docker.internal.
expect(screen.getByText(/host.docker.internal is this machine/)).toBeInTheDocument();
unmount();
useAppState.setState({
appSettings: { gateway: { enabled: true, port: 4000 } } as unknown as AppSettings,
});
render(
<ModelSection
project={awsProject({
backend: "open_ai_compatible",
openai_compatible_config: { base_url: "http://127.0.0.1:4000", api_key: null, model_id: null, haiku_model_id: null } as never,
})}
save={save}
disabled={false}
/>,
);
expect(screen.getByText(/Triple-C's own LLM gateway/)).toBeInTheDocument();
useAppState.setState({ appSettings: null });
});
});
@@ -125,8 +159,51 @@ describe("HostSection", () => {
fireEvent.click(screen.getByRole("button", { name: "Remove AWS resources…" }));
const dialog = screen.getByRole("dialog");
// Hybrid: the saved files can go too.
fireEvent.click(within(dialog).getByRole("checkbox"));
fireEvent.click(within(dialog).getByRole("button", { name: "Remove" }));
await waitFor(() => expect(removeAwsResources).toHaveBeenCalledWith(p.id, true));
});
it("keeps the AWS resource buttons locked while the project is in Error (its task may live)", () => {
const p = awsProject({ status: "error" });
p.aws = { ...p.aws!, task_arn: "arn:task" };
renderHost(p, false);
for (const name of ["Remove AWS resources…", "Run on this machine instead"]) {
const b = screen.getByRole("button", { name });
expect(b).toHaveAttribute("aria-disabled", "true");
expect(b).toHaveAccessibleDescription(/still running/);
}
});
it("offers no saved-files choice for an Ephemeral workspace", () => {
const p = awsProject();
p.aws = { ...p.aws!, storage: "ephemeral", task_definition_arn: "arn:td:1" };
renderHost(p);
fireEvent.click(screen.getByRole("button", { name: "Remove AWS resources…" }));
expect(within(screen.getByRole("dialog")).queryByRole("checkbox")).toBeNull();
});
it("only promises the Spot push when the WIP push is on", () => {
const p = awsProject();
p.aws = { ...p.aws!, wip_push_on_stop: false };
renderHost(p);
expect(screen.getByText(/unpushed work is lost/)).toBeInTheDocument();
expect(screen.queryByText(/push below still runs/)).toBeNull();
});
it("keeps a repository being typed when the project list refreshes with the same repos", () => {
const p = awsProject();
const { rerender } = renderHost(p);
fireEvent.click(screen.getByRole("button", { name: "+ Add repository" }));
fireEvent.change(screen.getByLabelText("Repository 2 URL"), { target: { value: "git@h:o/half" } });
// A refresh hands over a new, equal array.
const refreshed = { ...p, aws: { ...p.aws!, repos: p.aws!.repos.map((r) => ({ ...r })) } };
rerender(<HostSection project={refreshed} save={save} disabled={false} />);
expect(screen.getByLabelText("Repository 2 URL")).toHaveValue("git@h:o/half");
// A real change replaces the rows.
const changed = { ...p, aws: { ...p.aws!, repos: [] } };
rerender(<HostSection project={changed} save={save} disabled={false} />);
expect(screen.queryByLabelText("Repository 1 URL")).toBeNull();
});
});
@@ -9,6 +9,7 @@ import {
formatCpu,
formatMemory,
memoryError,
hasSavedFiles,
memoryOptions,
nearestMemory,
} from "../../../../lib/awsWorkspace";
@@ -26,6 +27,7 @@ interface Props {
}
const DEFAULT_IDLE_MINUTES = 60;
const STILL_RUNNING = "The task is still running — stop it first.";
/**
* Where an AWS project runs and what its Fargate task looks like. Only shown
@@ -56,6 +58,8 @@ export default function HostSection({ project, save, disabled }: Props) {
const saveAws = (patch: Partial<AwsWorkspaceConfig>) => save({ aws: { ...aws, ...patch } });
const hasResources = !!(aws.task_arn || aws.access_point_id || aws.task_definition_arn);
// Not `disabled` (which also allows Error): on AWS, Error can mean a live task.
const stopped = project.status === "stopped";
const commitMemory = () => {
const value = Number(memory);
@@ -236,7 +240,11 @@ export default function HostSection({ project, save, disabled }: Props) {
<SwitchRow
label="Spot"
hint="Runs on spare capacity at up to 70% off. AWS may reclaim it with two minutes' notice; the work-in-progress push below still runs."
hint={
aws.wip_push_on_stop
? "Runs on spare capacity at up to 70% off. AWS may reclaim it with two minutes' notice; the work-in-progress push below still runs."
: "Runs on spare capacity at up to 70% off. AWS may reclaim it with two minutes' notice, and with the work-in-progress push below off, unpushed work is lost."
}
control={
<Toggle
label="Spot"
@@ -315,10 +323,10 @@ export default function HostSection({ project, save, disabled }: Props) {
<Button
size="md"
variant="danger"
unavailable={disabled || !hasResources}
unavailable={!stopped || !hasResources}
unavailableReason={
disabled
? "Stop the project first."
!stopped
? STILL_RUNNING
: "This project has no AWS resources to remove."
}
onClick={() => setConfirmRemoveResources(true)}
@@ -327,10 +335,10 @@ export default function HostSection({ project, save, disabled }: Props) {
</Button>
<Button
size="md"
unavailable={disabled || hasResources}
unavailable={!stopped || hasResources}
unavailableReason={
disabled
? "Stop the project first."
!stopped
? STILL_RUNNING
: "Remove the workspace's AWS resources first, so nothing is left running or billed."
}
onClick={() => void save({ host_id: null, aws_placement: null, aws: null })}
@@ -397,15 +405,17 @@ function RemoveAwsResourcesModal({ project, onClose }: { project: Project; onClo
keeps the project, so it can run somewhere else. Its next start on AWS sets them up
again.
</p>
<label className="mt-3 flex items-start gap-2 text-[13px] text-[var(--text-primary)]">
<input
type="checkbox"
className="mt-0.5"
checked={discardData}
onChange={(e) => setDiscardData(e.target.checked)}
/>
<span>Also delete the workspace&rsquo;s saved files (EFS)</span>
</label>
{hasSavedFiles(project) && (
<label className="mt-3 flex items-start gap-2 text-[13px] text-[var(--text-primary)]">
<input
type="checkbox"
className="mt-0.5"
checked={discardData}
onChange={(e) => setDiscardData(e.target.checked)}
/>
<span>Also delete the workspace&rsquo;s saved files (EFS)</span>
</label>
)}
{error && (
<p
role="alert"
@@ -9,6 +9,7 @@ import type {
OpenAiCompatibleConfig,
Project,
} from "../../../../lib/types";
import { CUSTOM_ENDPOINT_BACKENDS } from "../../../../lib/types";
import Field, {
ConfigGroup,
SwitchRow,
@@ -16,11 +17,12 @@ import Field, {
selectClass,
} from "../../../ui/Field";
import Toggle from "../../../ui/Toggle";
import { NOT_ON_AWS, isAwsProject } from "../../../../lib/awsWorkspace";
import { isAwsProject, localEndpointProblem } from "../../../../lib/awsWorkspace";
import { useAppState } from "../../../../store/appState";
/** P6: an AWS workspace can reach a local model only over the network. */
const LOCAL_MODEL_ON_AWS =
"This project runs on AWS: the URL must be reachable from the AWS task, so host.docker.internal and this machine's LAN addresses will not work.";
"This project runs on AWS: the URL must be reachable from the AWS task, so host.docker.internal, localhost and this machine's LAN addresses will not work.";
/** Bedrock fields held in the OS keychain, never serialized back to us. */
const BEDROCK_SECRET_KEYS = [
@@ -187,6 +189,19 @@ export default function ModelSection({ project, save, disabled }: Props) {
const useSharedToken = project.use_shared_auth_token !== false;
const aws = isAwsProject(project);
const gateway = useAppState((s) => s.appSettings?.gateway ?? null);
const savedBaseUrl =
project.backend === "ollama"
? (project.ollama_config ?? DEFAULT_OLLAMA_CONFIG).base_url
: project.backend === "llama_cpp"
? (project.llamacpp_config ?? DEFAULT_LLAMACPP_CONFIG).base_url
: project.backend === "open_ai_compatible"
? (project.openai_compatible_config ?? DEFAULT_OPENAI_COMPATIBLE_CONFIG).base_url
: null;
const endpointProblem =
aws && savedBaseUrl
? localEndpointProblem(savedBaseUrl, gateway?.enabled ? gateway.port : null)
: null;
const handleBackendChange = (mode: Backend) => {
const patch: Partial<Project> = { backend: mode };
@@ -219,24 +234,18 @@ export default function ModelSection({ project, save, disabled }: Props) {
<option value="bedrock">Bedrock</option>
<option value="ollama">Ollama</option>
<option value="llama_cpp">llama.cpp</option>
<option value="open_ai_compatible" disabled={aws}>
{aws ? `OpenAI Compatible (gateway) — ${NOT_ON_AWS}` : "OpenAI Compatible"}
</option>
<option value="open_ai_compatible">OpenAI Compatible</option>
</select>
)}
</Field>
{aws && project.backend === "open_ai_compatible" && (
<p role="note" className="text-xs text-[var(--warning)]">
OpenAI Compatible (gateway): {NOT_ON_AWS}. Choose another backend.
</p>
)}
{aws && (project.backend === "ollama" || project.backend === "llama_cpp") && (
<p
{aws && CUSTOM_ENDPOINT_BACKENDS.includes(project.backend) && (
<div
role="note"
className="px-2 py-1.5 text-xs text-[var(--warning)] bg-[var(--warning-muted)] border border-[var(--warning)]/30 rounded-[var(--radius-control)]"
className="px-2 py-1.5 space-y-1 text-xs text-[var(--warning)] bg-[var(--warning-muted)] border border-[var(--warning)]/30 rounded-[var(--radius-control)]"
>
{LOCAL_MODEL_ON_AWS}
</p>
<p>{LOCAL_MODEL_ON_AWS}</p>
{endpointProblem && <p>{endpointProblem}</p>}
</div>
)}
{/* Only Anthropic reads CLAUDE_CODE_OAUTH_TOKEN; the other backends
+16
View File
@@ -99,6 +99,22 @@ describe("useProjectActions stop guard", () => {
});
});
describe("useProjectActions stop notes", () => {
it("tells the user when the choice could not be recorded in the workspace", async () => {
checkUnsavedWork.mockResolvedValue(dirty);
stop.mockResolvedValue("its own push of work in progress on shutdown may still run");
const { result } = renderHook(() => useProjectActions(awsProject({ status: "running" })));
await act(async () => {
await result.current.handleStop();
});
await act(async () => {
await result.current.resolveStopGuard("discard");
});
expect(stop).toHaveBeenCalledWith("p1", "discard");
expect(toasts().at(-1)?.detail).toMatch(/may still run/);
});
});
describe("useProjectActions start", () => {
it("adds the task's last log lines to an AWS start failure", async () => {
start.mockRejectedValue("The task stopped: Essential container exited");
+6 -2
View File
@@ -108,9 +108,13 @@ export function useProjectActions(project: Project) {
(choice: WipChoice | null) => {
setStopGuard(null);
if (choice === null) return undefined;
return run("Stop", () => stop(project.id, choice));
return run("Stop", async () => {
const note = await stop(project.id, choice);
if (note) pushToast({ kind: "info", message: `“${project.name}” stopped`, detail: note });
return note;
});
},
[run, stop, project.id],
[run, stop, project.id, project.name, pushToast],
);
const handleReset = useCallback(
+2 -1
View File
@@ -126,9 +126,10 @@ export function useProjects() {
const stop = useCallback(
(id: string, wip?: WipChoice) =>
withOptimisticStatus(id, "stopping", async () => {
await commands.stopProjectContainer(id, wip);
const note = await commands.stopProjectContainer(id, wip);
const list = await commands.listProjects();
setProjects(list);
return note;
}),
[setProjects, withOptimisticStatus],
);
+11 -5
View File
@@ -9,13 +9,19 @@ import {
stopGuardNeeded,
} from "./awsWorkspace";
import { environment } from "../test/awsFixtures";
// Shared with the Rust validator (`models/project.rs`).
import fargatePairs from "../test/fixtures/fargate-pairs.json";
describe("awsWorkspace", () => {
it("knows Fargate's valid CPU/memory pairs", () => {
expect(memoryOptions(256)).toEqual([512, 1024, 1536, 2048]);
expect(memoryError(1024, 4096)).toBeNull();
expect(memoryError(256, 4096)).toMatch(/not valid with 256/);
expect(memoryError(8192, 17000)).toMatch(/steps of 4096/);
it("agrees with the Rust validator on every pair in the shared fixture", () => {
for (const [cpu, mem] of fargatePairs.valid) expect(memoryError(cpu, mem), `${cpu}/${mem}`).toBeNull();
for (const [cpu, mem] of fargatePairs.invalid) expect(memoryError(cpu, mem), `${cpu}/${mem}`).not.toBeNull();
});
it("words the refusal as Rust does and snaps to a valid size", () => {
expect(memoryOptions(256)).toEqual([512, 1024, 2048]);
expect(memoryError(256, 1536)).toMatch(/use 512, 1024 or 2048 MiB$/);
expect(memoryError(1024, 2500)).toMatch(/use 2048 to 8192 MiB in steps of 1024$/);
expect(memoryError(300, 1024)).toMatch(/not a Fargate CPU/);
expect(nearestMemory(256, 4096)).toBe(2048);
expect(nearestMemory(16384, 4096)).toBe(32768);
+54 -29
View File
@@ -20,31 +20,19 @@ export function isAwsProject(p: Pick<Project, "host_id">): boolean {
/** Fargate CPU sizes (units of 1/1024 vCPU). */
export const FARGATE_CPUS = [256, 512, 1024, 2048, 4096, 8192, 16384] as const;
/** Mirrors Rust `fargate_memory_range`: `[min, max, step]` MiB for a CPU size. */
export function fargateMemoryRange(cpu: number): [number, number, number] | null {
switch (cpu) {
case 256:
return [512, 2048, 512];
case 512:
return [1024, 4096, 1024];
case 1024:
return [2048, 8192, 1024];
case 2048:
return [4096, 16384, 1024];
case 4096:
return [8192, 30720, 1024];
case 8192:
return [16384, 61440, 4096];
case 16384:
return [32768, 122880, 8192];
default:
return null;
}
}
/** Every memory size valid with `cpu`, smallest first. */
/** Mirrors Rust `fargate_memory_sizes`: every memory size (MiB) valid with
* `cpu`, smallest first. 256 CPU is irregular: 512, 1024 or 2048 only. */
export function memoryOptions(cpu: number): number[] {
const range = fargateMemoryRange(cpu);
if (cpu === 256) return [512, 1024, 2048];
const ranges: Record<number, [number, number, number]> = {
512: [1024, 4096, 1024],
1024: [2048, 8192, 1024],
2048: [4096, 16384, 1024],
4096: [8192, 30720, 1024],
8192: [16384, 61440, 4096],
16384: [32768, 122880, 8192],
};
const range = ranges[cpu];
if (!range) return [];
const [min, max, step] = range;
const out: number[] = [];
@@ -52,13 +40,19 @@ export function memoryOptions(cpu: number): number[] {
return out;
}
/** Mirrors Rust `describe_sizes`. */
function describeSizes(sizes: number[]): string {
const last = sizes[sizes.length - 1];
if (sizes.length <= 3) return `${sizes.slice(0, -1).join(", ")} or ${last} MiB`;
return `${sizes[0]} to ${last} MiB in steps of ${sizes[1] - sizes[0]}`;
}
/** Null when `memory` MiB is valid with `cpu`; otherwise the reason (as Rust words it). */
export function memoryError(cpu: number, memory: number): string | null {
const range = fargateMemoryRange(cpu);
if (!range) return `${cpu} is not a Fargate CPU size`;
const [min, max, step] = range;
if (!Number.isInteger(memory) || memory < min || memory > max || (memory - min) % step !== 0) {
return `${memory} MiB of memory is not valid with ${cpu} CPU units: use ${min} to ${max} MiB in steps of ${step}`;
const sizes = memoryOptions(cpu);
if (sizes.length === 0) return `${cpu} is not a Fargate CPU size`;
if (!sizes.includes(memory)) {
return `${memory} MiB of memory is not valid with ${cpu} CPU units: use ${describeSizes(sizes)}`;
}
return null;
}
@@ -123,3 +117,34 @@ export function environmentSelectable(env: DetectedEnvironment): boolean {
const ready = env.state.state === "ready" || env.state.state === "update_available";
return ready && env.onboarding?.state === "joined";
}
/** Whether an AWS project may have saved files on EFS (Hybrid now, or an
* access point left from when it was). Ephemeral-only projects have none. */
export function hasSavedFiles(p: Pick<Project, "aws">): boolean {
return p.aws?.storage === "hybrid" || !!p.aws?.access_point_id;
}
/** Hosts that only mean something on this machine or its Docker bridge. */
const LOCAL_HOSTS = /^(localhost|127(\.\d{1,3}){3}|0\.0\.0\.0|\[?::1\]?|host\.docker\.internal|172\.17\.0\.1)$/i;
/**
* Why `url` cannot work from an AWS task, or null: a loopback / Docker-host
* address, or the app's own LLM gateway (it runs on this machine).
*/
export function localEndpointProblem(url: string, gatewayPort: number | null): string | null {
let parsed: URL;
try {
parsed = new URL(url.trim());
} catch {
return null;
}
const host = parsed.hostname;
const port = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80));
if (LOCAL_HOSTS.test(host)) {
if (gatewayPort !== null && port === gatewayPort) {
return "This is Triple-C's own LLM gateway, which runs on this machine; an AWS workspace can't reach it.";
}
return `${host} is this machine (or its Docker host) — an AWS workspace can't reach it.`;
}
return null;
}
+4 -2
View File
@@ -28,9 +28,11 @@ export const updateProject = (project: Project) =>
invoke<Project>("update_project", { project });
export const startProjectContainer = (projectId: string) =>
invoke<Project>("start_project_container", { projectId });
/** `wip` is the stop guard's choice for an AWS project (a local one ignores it). */
/** `wip` is the stop guard's choice for an AWS project (a local one ignores it).
* Resolves to a note when the choice could not be recorded in the workspace
* (its own shutdown push may then still run), else null. */
export const stopProjectContainer = (projectId: string, wip?: WipChoice) =>
invoke<void>("stop_project_container", { projectId, wip: wip ?? null });
invoke<string | null>("stop_project_container", { projectId, wip: wip ?? null });
export const checkUnsavedWork = (projectId: string) =>
invoke<UnsavedWork>("check_unsaved_work", { projectId });
export const awsTaskLog = (projectId: string, limit: number) =>
+4
View File
@@ -122,6 +122,10 @@ export interface RemoveProjectOptions {
forgetAwsResources?: boolean;
}
/** Prefix of `remove_project`'s refusal when an AWS project's connection is
* gone (Rust `AWS_CONNECTION_MISSING`; a Rust test pins the two together). */
export const AWS_CONNECTION_MISSING = "AWS_CONNECTION_MISSING: ";
/** What Stop does with an AWS workspace's unsaved git work (Rust `WipChoice`). */
export type WipChoice = "push" | "discard";
+22
View File
@@ -0,0 +1,22 @@
{
"_comment": "Fargate CPU/memory pairs, read by both models/project.rs (Rust) and lib/awsWorkspace.test.ts. Keep the two validators agreeing on every line.",
"valid": [
[256, 512], [256, 1024], [256, 2048],
[512, 1024], [512, 2048], [512, 3072], [512, 4096],
[1024, 2048], [1024, 3072], [1024, 4096], [1024, 8192],
[2048, 4096], [2048, 5120], [2048, 16384],
[4096, 8192], [4096, 9216], [4096, 30720],
[8192, 16384], [8192, 20480], [8192, 61440],
[16384, 32768], [16384, 40960], [16384, 122880]
],
"invalid": [
[256, 256], [256, 1536], [256, 3072], [256, 4096],
[512, 512], [512, 1536], [512, 5120],
[1024, 1024], [1024, 2500], [1024, 9216],
[2048, 2048], [2048, 4608], [2048, 17408],
[4096, 7168], [4096, 31744],
[8192, 18432], [8192, 65536],
[16384, 36864], [16384, 122881], [16384, 131072],
[300, 1024], [0, 512], [32768, 65536]
]
}
+13 -5
View File
@@ -1168,7 +1168,9 @@ start_agent
# ── AWS: WIP push on stop, idle auto-stop ───────────────────────────────────
# aws_before_stop runs from the TERM trap below, before the keepalive goes:
# with TRIPLEC_WIP_PUSH=1 it pushes every repo's unsaved work to a
# triple-c/wip/<stamp> branch (as `claude`, bounded at 90 s). ECS gives a task
# triple-c/wip/<stamp> branch (as `claude`, bounded at 90 s), unless the app's
# stop guard recorded `pushed` or `discard` in /run/triple-c/stop-reason
# (written as root through the agent just before StopTask). ECS gives a task
# `stopTimeout` seconds between SIGTERM and SIGKILL — 30 by default, 120 at
# most on Fargate — so the task definition must raise it for the push to
# finish. Once only: a second TERM during the push changes nothing.
@@ -1185,10 +1187,16 @@ aws_before_stop() {
[ "${TRIPLEC_HOST:-}" = aws ] || return 0
[ -z "${triplec_stopping:-}" ] || return 0
triplec_stopping=1
local reason=sigterm
if [ "$(cat /run/triple-c/stop-reason 2>/dev/null)" = idle ]; then
reason=idle
fi
local reason=sigterm recorded
recorded=$(cat /run/triple-c/stop-reason 2>/dev/null)
case "$recorded" in
idle) reason=idle ;;
# The app's stop guard already pushed, or the user chose to stop
# without pushing: honour that instead of pushing again.
pushed|discard)
echo "entrypoint: stopping — work in progress already handled by Triple-C ($recorded); not pushing"
return 0 ;;
esac
if [ "${TRIPLEC_WIP_PUSH:-0}" = 1 ]; then
echo "entrypoint: stopping ($reason) — pushing work in progress..."
_triplec_as_user timeout -k 5 90 /usr/local/bin/triple-c-wip-push --reason "$reason" \