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:
1 parent
eb7c6c7b21
commit
8441a096c8
26 files changed
+780
-193
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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’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’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’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>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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’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’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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
@@ -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
@@ -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" \
|
||||
|
||||
Reference in new issue
Block a user