Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d73a19a9e7 | ||
|
|
c467308513 | ||
|
|
be2825d344 | ||
|
|
33eb63d907 | ||
|
|
884a665e7d | ||
|
|
170ba1e94d |
No files matched your search
@@ -352,7 +352,9 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
(`aws:userid` contains `:`). Workspace users can't deregister or delete task definitions
|
||||
(`DeregisterTaskDefinition` is `*`-only in IAM): removal leaves revisions ACTIVE for an admin
|
||||
(`aws/README.md`). Stop: the stop guard (`commands/stop_guard.rs`) dry-runs `triple-c-wip-push`
|
||||
through the agent, may push first, then writes `pushed` or `discard` to `/run/triple-c/stop-reason`
|
||||
through the agent, closes the project's terminals and auth bridge (so nothing is written after the
|
||||
push's snapshot), may push first (under an in-container `timeout`, since the agent does not kill an
|
||||
exec the client gave up on), then writes `pushed` or `discard` to `/run/triple-c/stop-reason`
|
||||
(root-owned; the entrypoint's TERM hook reads it, and the idle watcher writes `idle`) so the hook
|
||||
doesn't push twice; the marker is cleared if StopTask fails. Claude Code's sandbox is forced off in
|
||||
the generated settings (bubblewrap can't create namespaces on Fargate, verified live).
|
||||
@@ -400,10 +402,13 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
marker says the app handled it. The idle watcher runs as root under a supervisor and signals the
|
||||
entrypoint's own PID (not PID 1, which is docker-init), so idle and ECS stops share one trap.
|
||||
Idle = the agent's `last_activity_unix` older than N minutes (open-but-silent attaches/tunnels
|
||||
don't count; requests with `X-Triple-C-Background: 1`, e.g. the auth bridge's poll, never do),
|
||||
don't count; requests with `X-Triple-C-Background: 1` — the auth bridge's and the file viewer's polls — never do),
|
||||
no `claude`, no scheduler job, no running clone, and no unsaved git work: with the WIP push on the
|
||||
watcher pushes *before* signalling and stays up if that fails; with it off, dirty repos keep the
|
||||
workspace up. An idle stop exits 75 (`aws::tasks::IDLE_EXIT_CODE`) so the death watch can tell it
|
||||
workspace up; activity during the watcher's push sends it back to watching. A repo the background
|
||||
clone is still writing (`/run/triple-c-clone/repos` last says `cloning`, no `done`) is skipped by
|
||||
`triple-c-wip-push` (dry run: `"cloning": true`). The stop trap is set before any background work
|
||||
starts. An idle stop exits 75 (`aws::tasks::IDLE_EXIT_CODE`) so the death watch can tell it
|
||||
from a crash (`classify_stop` → the `ContainerExited` toast).
|
||||
**The `TRIPLEC_` env prefix is reserved** on both hosts (`RESERVED_ENV_PREFIXES`; `workspace.rs`
|
||||
drops custom variables in it): the entrypoint's settings live there.
|
||||
|
||||
@@ -1230,8 +1230,10 @@ fn validate_aws_update(
|
||||
})
|
||||
{
|
||||
return Err(
|
||||
"This project still has AWS resources (a task, its data's access point or task \
|
||||
definitions) where it runs now; remove the workspace's AWS resources first"
|
||||
"This project still has AWS resources (a task, its saved files' access point or task \
|
||||
definitions) where it runs now; remove the workspace's AWS resources first — \
|
||||
including the access point (\"Also remove the workspace's file-system access \
|
||||
point\"), which stays when the saved files are kept"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
@@ -1517,36 +1519,58 @@ pub async fn stop_project_container(
|
||||
|
||||
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).
|
||||
// AWS: the terminals and the auth bridge close *before* the stop guard's
|
||||
// push, so nothing written after its snapshot is silently dropped (the
|
||||
// entrypoint's hook skips its own push once `pushed` is recorded). A push
|
||||
// that fails aborts the stop with the status as it was; the auth bridge
|
||||
// is re-armed, and the error says the terminals were closed.
|
||||
let mut note = None;
|
||||
let guard_target = if project.is_aws() { ContainerTarget::for_project(&project) } else { None };
|
||||
if project.is_aws() {
|
||||
if wip == Some(WipChoice::Push) {
|
||||
emit_progress(&app_handle, &project_id, "Pushing unsaved work...");
|
||||
}
|
||||
note = aws_guard_before_stop(
|
||||
let close_first = async {
|
||||
state.auth_bridge.stop(&project_id).await;
|
||||
if let Some(container) = guard_target.as_ref() {
|
||||
state.exec_manager.close_sessions_for_container(container.id()).await;
|
||||
}
|
||||
if wip == Some(WipChoice::Push) {
|
||||
emit_progress(&app_handle, &project_id, "Pushing unsaved work...");
|
||||
}
|
||||
};
|
||||
let guarded = aws_guard_before_stop(
|
||||
&state.projects_store,
|
||||
&project_id,
|
||||
project.status.clone(),
|
||||
guard_target.as_ref(),
|
||||
wip,
|
||||
super::stop_guard::PUSH_TIMEOUT,
|
||||
close_first,
|
||||
)
|
||||
.await?;
|
||||
.await;
|
||||
match guarded {
|
||||
Ok(n) => note = n,
|
||||
Err(e) => {
|
||||
if project.auth_bridge_enabled {
|
||||
if let Some(target) = guard_target.clone() {
|
||||
state
|
||||
.auth_bridge
|
||||
.start(project_id.clone(), target, app_handle.clone(), state.projects_store.clone())
|
||||
.await;
|
||||
}
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Drop host listeners first: they only make sense while the container runs.
|
||||
state.auth_bridge.stop(&project_id).await;
|
||||
}
|
||||
|
||||
// Drop host listeners first: they only make sense while the container runs.
|
||||
state.auth_bridge.stop(&project_id).await;
|
||||
|
||||
if project.is_aws() {
|
||||
// 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.
|
||||
// sees why (expired SSO, typically), and can retry. Its terminals
|
||||
// and auth bridge were closed before the stop guard's step above.
|
||||
emit_progress(&app_handle, &project_id, "Stopping task...");
|
||||
if let Some(container) = ContainerTarget::for_project(&project) {
|
||||
state.exec_manager.close_sessions_for_container(container.id()).await;
|
||||
}
|
||||
let settings = state.settings_store.get();
|
||||
if let Err(e) = ProjectHost::for_project(&project).stop_project(&project, &settings).await {
|
||||
log::warn!("Stop failed for project {}: {}", project_id, e);
|
||||
@@ -1578,22 +1602,36 @@ pub async fn stop_project_container(
|
||||
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>(
|
||||
/// Appended to a failed stop-guard push: its terminals were closed first.
|
||||
const TERMINALS_CLOSED_FOR_PUSH: &str =
|
||||
"Its terminals were closed before the push, so nothing written after it could be lost; \
|
||||
open them again to keep working.";
|
||||
|
||||
/// The AWS stop guard's step before StopTask ([`super::stop_guard::prepare_stop`]),
|
||||
/// after `close_first` (the project's terminals and auth bridge): whatever
|
||||
/// they write after the push's snapshot would otherwise be lost, since the
|
||||
/// entrypoint's hook does not push again after `pushed`. A failed push puts
|
||||
/// `previous` back, so the project's status is as it was.
|
||||
async fn aws_guard_before_stop<R, F>(
|
||||
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> {
|
||||
close_first: F,
|
||||
) -> Result<Option<String>, String>
|
||||
where
|
||||
R: super::stop_guard::WipRunner,
|
||||
F: std::future::Future<Output = ()>,
|
||||
{
|
||||
close_first.await;
|
||||
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)
|
||||
Err(format!("{e}\n\n{TERMINALS_CLOSED_FOR_PUSH}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1655,13 +1693,26 @@ pub async fn remove_aws_resources(
|
||||
ProjectHost::for_project(&project)
|
||||
.remove_aws_resources(&project, &settings, discard_data)
|
||||
.await?;
|
||||
state.projects_store.set_aws_runtime(&project_id, None, None, None)?;
|
||||
state
|
||||
.projects_store
|
||||
.set_aws_runtime(&project_id, None, access_point_after_removal(&project, discard_data), None)?;
|
||||
state
|
||||
.projects_store
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))
|
||||
}
|
||||
|
||||
/// The access point a removal leaves stored: kept when the saved files were
|
||||
/// kept (it is still there, a later start reuses it and a later removal
|
||||
/// with `discard_data` deletes it; and while it is stored the project can't
|
||||
/// move away from it — [`validate_aws_update`]), gone when they were not.
|
||||
fn access_point_after_removal(project: &Project, discard_data: bool) -> Option<String> {
|
||||
if discard_data {
|
||||
return None;
|
||||
}
|
||||
project.aws.as_ref().and_then(|a| a.access_point_id.clone())
|
||||
}
|
||||
|
||||
fn check_aws_resources_removable(project: &Project) -> Result<(), String> {
|
||||
if !project.is_aws() {
|
||||
return Err("This project does not run on AWS".into());
|
||||
@@ -1910,8 +1961,11 @@ pub async fn reconcile_aws_projects(state: State<'_, AppState>) -> Result<Vec<Pr
|
||||
// A target id left from the last run is a loopback URL whose port a new
|
||||
// tunnel (any project's) may now own; until a re-attach registers, the
|
||||
// project gets an id no tunnel can have (m9).
|
||||
// A stopped mark a past unattached spell left on that id is forgotten
|
||||
// first: reconcile asks ECS now, and a stale mark would make the death
|
||||
// watch report a live, billed task as stopped.
|
||||
for p in &projects {
|
||||
if let Some(id) = crate::host::aws_host::unattached_container_id(p) {
|
||||
if let Some(id) = crate::host::aws_host::prepare_unattached(p) {
|
||||
let _ = state.projects_store.set_container_id(&p.id, Some(id));
|
||||
}
|
||||
}
|
||||
@@ -2632,6 +2686,30 @@ mod tests {
|
||||
assert!(validate_aws_update(&running, &edited, &hosts).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_removal_that_keeps_the_saved_files_keeps_their_access_point_and_the_project_in_place() {
|
||||
let hosts = vec![host("h1")];
|
||||
let mut stored = aws_project();
|
||||
stored.aws.as_mut().unwrap().access_point_id = Some("fsap-1".into());
|
||||
assert_eq!(access_point_after_removal(&stored, false), Some("fsap-1".into()));
|
||||
assert_eq!(access_point_after_removal(&stored, true), None);
|
||||
// What the store holds after a keep-the-files removal still blocks a move.
|
||||
let mut after = stored.clone();
|
||||
let a = after.aws.as_mut().unwrap();
|
||||
a.task_arn = None;
|
||||
a.task_definition_arn = None;
|
||||
a.access_point_id = access_point_after_removal(&stored, false);
|
||||
let mut local = after.clone();
|
||||
local.host_id = None;
|
||||
local.aws_placement = None;
|
||||
local.aws = None;
|
||||
let e = validate_aws_update(&after, &local, &hosts).unwrap_err();
|
||||
assert!(e.contains("access point"), "{e}");
|
||||
// After a discarding removal it can move.
|
||||
after.aws.as_mut().unwrap().access_point_id = access_point_after_removal(&stored, true);
|
||||
assert!(validate_aws_update(&after, &local, &hosts).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_project_with_aws_resources_cannot_be_moved_off_its_host() {
|
||||
let hosts = vec![host("h1"), host("h2")];
|
||||
@@ -2715,10 +2793,11 @@ mod tests {
|
||||
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))
|
||||
let e = aws_guard_before_stop(&store, &id, ProjectStatus::Running, Some(&fake), Some(WipChoice::Push), std::time::Duration::from_secs(5), async {})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(e.contains("denied"), "{e}");
|
||||
assert!(e.contains("terminals were closed"), "{e}");
|
||||
assert_eq!(store.get(&id).unwrap().status, ProjectStatus::Running);
|
||||
assert!(fake.markers.lock().unwrap().is_empty());
|
||||
}
|
||||
@@ -2729,7 +2808,7 @@ mod tests {
|
||||
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))
|
||||
let note = aws_guard_before_stop(&store, &id, ProjectStatus::Running, Some(&fake), Some(WipChoice::Push), std::time::Duration::from_secs(5), async {})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(note, None);
|
||||
@@ -2738,6 +2817,30 @@ mod tests {
|
||||
assert_eq!(fake.calls.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_terminals_close_before_the_stop_guard_pushes_or_records_anything() {
|
||||
use crate::commands::stop_guard::tests::Fake;
|
||||
for wip in [Some(WipChoice::Push), Some(WipChoice::Discard), None] {
|
||||
let (_d, store, id) = stop_store(ProjectStatus::Running);
|
||||
let fake = Fake::new(Ok(("pushed".into(), 0)));
|
||||
let seen_at_close = std::sync::Mutex::new(None);
|
||||
aws_guard_before_stop(&store, &id, ProjectStatus::Running, Some(&fake), wip, std::time::Duration::from_secs(5), async {
|
||||
*seen_at_close.lock().unwrap() =
|
||||
Some((fake.calls.lock().unwrap().len(), fake.markers.lock().unwrap().len()));
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(*seen_at_close.lock().unwrap(), Some((0, 0)), "{wip:?}: closed first");
|
||||
}
|
||||
// Stop's own code closes them through that step, not after it.
|
||||
let src = include_str!("project_commands.rs");
|
||||
let stop = &src[src.find("pub async fn stop_project_container").unwrap()..];
|
||||
let stop = &stop[..stop.find("\n}\n").unwrap()];
|
||||
let close = stop.find("close_sessions_for_container").unwrap();
|
||||
assert!(close < stop.find("aws_guard_before_stop(").unwrap());
|
||||
assert!(stop.find("auth_bridge.stop").unwrap() < stop.find("aws_guard_before_stop(").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_aws_project_takes_repos_not_folders_and_no_runtime_handles() {
|
||||
let hosts = vec![host("h1")];
|
||||
|
||||
@@ -19,6 +19,10 @@ pub const DRY_RUN_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
pub const PUSH_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
/// Cap on the push output carried into an error.
|
||||
const MAX_OUTPUT: usize = 4000;
|
||||
/// How much sooner than the client's timeout the in-container `timeout`
|
||||
/// ends the script (its TERM, then 5 s to the KILL): an exec the client gives
|
||||
/// up on is not killed by the agent, so the container bounds it itself.
|
||||
const IN_CONTAINER_MARGIN: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Where the entrypoint's stop hook looks for the app's decision (Task 7's
|
||||
/// file, which the idle watcher also writes `idle` to).
|
||||
@@ -30,8 +34,13 @@ 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;
|
||||
/// Run the WIP script as `claude`: `(combined output, exit code)`. The
|
||||
/// caller waits at most `limit`; the run must not outlive it.
|
||||
fn run(
|
||||
&self,
|
||||
args: Vec<String>,
|
||||
limit: Duration,
|
||||
) -> 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;
|
||||
/// Remove [`STOP_REASON_FILE`] (as root), so the hook pushes as configured again.
|
||||
@@ -39,10 +48,8 @@ pub trait WipRunner: Sync {
|
||||
}
|
||||
|
||||
impl WipRunner for ContainerTarget {
|
||||
async fn run(&self, args: Vec<String>) -> Result<(String, i64), String> {
|
||||
let mut cmd = vec![WIP_PUSH.to_string()];
|
||||
cmd.extend(args);
|
||||
self.exec_oneshot_as("claude", cmd, Vec::new()).await
|
||||
async fn run(&self, args: Vec<String>, limit: Duration) -> Result<(String, i64), String> {
|
||||
self.exec_oneshot_as("claude", wip_command(args, limit), Vec::new()).await
|
||||
}
|
||||
|
||||
async fn write_stop_reason(&self, marker: &str) -> Result<(), String> {
|
||||
@@ -68,6 +75,22 @@ impl WipRunner for ContainerTarget {
|
||||
}
|
||||
}
|
||||
|
||||
/// The script's argv under coreutils `timeout`, ending it
|
||||
/// [`IN_CONTAINER_MARGIN`] before the client's `limit` (KILL 5 s after the
|
||||
/// TERM), so a push the app gave up on is not left running in the workspace.
|
||||
pub fn wip_command(args: Vec<String>, limit: Duration) -> Vec<String> {
|
||||
let secs = limit.saturating_sub(IN_CONTAINER_MARGIN).as_secs().max(1);
|
||||
let mut cmd = vec![
|
||||
"/usr/bin/timeout".to_string(),
|
||||
"-k".to_string(),
|
||||
"5".to_string(),
|
||||
secs.to_string(),
|
||||
WIP_PUSH.to_string(),
|
||||
];
|
||||
cmd.extend(args);
|
||||
cmd
|
||||
}
|
||||
|
||||
/// The root command that forgets a recorded marker (a fixed argv).
|
||||
pub fn clear_stop_reason_command() -> Vec<String> {
|
||||
vec!["rm".into(), "-f".into(), STOP_REASON_FILE.into()]
|
||||
@@ -132,7 +155,7 @@ pub async fn unsaved_work<R: WipRunner>(runner: Option<&R>, timeout: Duration) -
|
||||
.into(),
|
||||
);
|
||||
};
|
||||
let res = tokio::time::timeout(timeout, runner.run(vec!["--dry-run".into()])).await;
|
||||
let res = tokio::time::timeout(timeout, runner.run(vec!["--dry-run".into()], timeout)).await;
|
||||
match res {
|
||||
Err(_) => unchecked("The workspace did not answer the check for unsaved work in time.".into()),
|
||||
Ok(Err(e)) => unchecked(format!("The workspace could not be asked about unsaved work: {e}")),
|
||||
@@ -175,7 +198,8 @@ pub async fn before_stop<R: WipRunner>(
|
||||
workspace was not stopped."
|
||||
.to_string()
|
||||
})?;
|
||||
let res = tokio::time::timeout(timeout, runner.run(vec!["--reason".into(), "stop".into()])).await;
|
||||
let res =
|
||||
tokio::time::timeout(timeout, runner.run(vec!["--reason".into(), "stop".into()], timeout)).await;
|
||||
match res {
|
||||
Err(_) => Err("Pushing unsaved work did not finish in time. The workspace was not stopped.".into()),
|
||||
Ok(Err(e)) => Err(format!("Could not push unsaved work: {e}. The workspace was not stopped.")),
|
||||
@@ -252,7 +276,7 @@ pub(crate) mod tests {
|
||||
}
|
||||
|
||||
impl WipRunner for Fake {
|
||||
async fn run(&self, args: Vec<String>) -> Result<(String, i64), String> {
|
||||
async fn run(&self, args: Vec<String>, _limit: Duration) -> Result<(String, i64), String> {
|
||||
self.calls.lock().unwrap().push(args);
|
||||
if self.hang {
|
||||
std::future::pending::<()>().await;
|
||||
@@ -280,7 +304,7 @@ pub(crate) mod tests {
|
||||
{\"folder\":\"web\",\"dirty\":false,\"unpushed\":2,\"branch\":\"HEAD\"}\n";
|
||||
let repos = parse_dry_run(out).unwrap();
|
||||
assert_eq!(repos.len(), 2);
|
||||
assert_eq!(repos[0], RepoState { folder: "api".into(), dirty: true, unpushed: 0, branch: "main".into() });
|
||||
assert_eq!(repos[0], RepoState { folder: "api".into(), dirty: true, unpushed: 0, branch: "main".into(), cloning: false });
|
||||
assert_eq!(repos[1].unpushed, 2);
|
||||
assert!(parse_dry_run("{\"folder\":1}").is_err());
|
||||
assert!(parse_dry_run("").unwrap().is_empty());
|
||||
@@ -346,6 +370,28 @@ pub(crate) mod tests {
|
||||
assert!(fake.calls().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_script_is_bounded_inside_the_workspace_before_the_client_gives_up() {
|
||||
let cmd = wip_command(vec!["--reason".into(), "stop".into()], PUSH_TIMEOUT);
|
||||
assert_eq!(cmd, vec!["/usr/bin/timeout", "-k", "5", "290", WIP_PUSH, "--reason", "stop"]);
|
||||
let secs: u64 = cmd[3].parse().unwrap();
|
||||
assert!(secs + 5 < PUSH_TIMEOUT.as_secs(), "TERM and KILL land before the client's timeout");
|
||||
let dry = wip_command(vec!["--dry-run".into()], DRY_RUN_TIMEOUT);
|
||||
assert_eq!(&dry[3..], &["20", WIP_PUSH, "--dry-run"]);
|
||||
assert_eq!(wip_command(Vec::new(), Duration::from_secs(3))[3], "1", "never 0 (no limit)");
|
||||
// The image has coreutils' timeout where the argv says.
|
||||
let df = include_str!("../../../../container/Dockerfile");
|
||||
assert!(df.contains("ubuntu:24.04") || df.contains("ubuntu:noble"), "coreutils at /usr/bin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cloning_repo_parses_with_its_flag_and_older_lines_without_it() {
|
||||
let out = "{\"folder\":\"half\",\"dirty\":false,\"unpushed\":0,\"branch\":\"\",\"cloning\":true}\n\
|
||||
{\"folder\":\"a\",\"dirty\":true,\"unpushed\":0,\"branch\":\"main\"}";
|
||||
let repos = parse_dry_run(out).unwrap();
|
||||
assert!(repos[0].cloning && !repos[1].cloning);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_output_keeps_its_end() {
|
||||
let s = format!("{}END", "x".repeat(10_000));
|
||||
|
||||
@@ -421,7 +421,8 @@ fn clone_repos_clones_skips_existing_and_reports_failures_without_failing() {
|
||||
assert_eq!(fs::read_to_string(ws.join(".dotted/README")).unwrap(), "hello");
|
||||
assert_eq!(
|
||||
fs::read_to_string(status.join("repos")).unwrap(),
|
||||
"main-copy\tcloned\ndev-copy\tcloned\nexisting\texists\nbroken\tfailed\n.dotted\tcloned\n"
|
||||
"main-copy\tcloning\nmain-copy\tcloned\ndev-copy\tcloning\ndev-copy\tcloned\n\
|
||||
existing\texists\nbroken\tcloning\nbroken\tfailed\n.dotted\tcloning\n.dotted\tcloned\n"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -473,6 +474,11 @@ fn write_script(dir: &Path, name: &str, body: &str) -> std::path::PathBuf {
|
||||
}
|
||||
|
||||
fn wip_push(ws: &Path, args: &[&str]) -> Output {
|
||||
let tmp = ws.parent().unwrap();
|
||||
wip_push_with_clone_dir(ws, args, &tmp.join("no-clone-status"))
|
||||
}
|
||||
|
||||
fn wip_push_with_clone_dir(ws: &Path, args: &[&str], clone_dir: &Path) -> Output {
|
||||
let tmp = ws.parent().unwrap();
|
||||
let script = write_script(tmp, "triple-c-wip-push", WIP_PUSH);
|
||||
// Through bash rather than exec'd: a script written a moment ago can be
|
||||
@@ -481,6 +487,7 @@ fn wip_push(ws: &Path, args: &[&str]) -> Output {
|
||||
.arg(script)
|
||||
.args(args)
|
||||
.env("TRIPLEC_WORKSPACE_ROOT", ws)
|
||||
.env("TRIPLEC_CLONE_STATUS_DIR", clone_dir)
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.env("HOME", tmp)
|
||||
.output()
|
||||
@@ -673,6 +680,44 @@ fn wip_push_uploads_lfs_objects_before_the_no_verify_push() {
|
||||
assert_eq!(fs::read_to_string(&log).unwrap().trim(), format!("push origin {pushed}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wip_push_skips_a_repo_the_background_clone_is_still_writing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (ws, repo, remote) = workspace_with_clone(tmp.path());
|
||||
// `half` is mid-clone: a checkout in progress reads as a dirty tree.
|
||||
git(&ws, &["clone", "-q", remote.to_str().unwrap(), "half"]);
|
||||
fs::remove_file(ws.join("half/README")).unwrap();
|
||||
fs::write(repo.join("y"), "y").unwrap();
|
||||
let clone = tmp.path().join("clone");
|
||||
fs::create_dir_all(&clone).unwrap();
|
||||
fs::write(clone.join("repos"), "proj\tcloning\nproj\tcloned\nhalf\tcloning\n").unwrap();
|
||||
|
||||
let out = wip_push_with_clone_dir(&ws, &["--dry-run"], &clone);
|
||||
assert!(out.status.success(), "{}", text(&out));
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let lines: Vec<serde_json::Value> = stdout.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
let by = |f: &str| lines.iter().find(|v| v["folder"] == f).unwrap().clone();
|
||||
assert_eq!(
|
||||
by("half"),
|
||||
serde_json::json!({"folder": "half", "dirty": false, "unpushed": 0, "branch": "", "cloning": true})
|
||||
);
|
||||
assert_eq!(by("proj")["dirty"], true, "a finished clone is checked as usual");
|
||||
// The Rust side parses that line.
|
||||
let parsed = crate::commands::stop_guard::parse_dry_run(&stdout).unwrap();
|
||||
assert!(parsed.iter().any(|r| r.folder == "half" && r.cloning));
|
||||
|
||||
let out = wip_push_with_clone_dir(&ws, &[], &clone);
|
||||
assert!(out.status.success(), "{}", text(&out));
|
||||
assert!(text(&out).contains("half: still cloning"), "{}", text(&out));
|
||||
assert_eq!(wip_refs(&remote).len(), 1, "only proj was pushed");
|
||||
|
||||
// Once the clone is done, the folder is an ordinary repository again.
|
||||
fs::write(clone.join("done"), "").unwrap();
|
||||
let out = wip_push_with_clone_dir(&ws, &["--dry-run"], &clone);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(stdout.contains(r#"{"folder":"half","dirty":true"#), "{stdout}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wip_push_rejects_an_unknown_reason() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -977,6 +1022,45 @@ fn idle_watch_pushes_before_an_idle_stop_and_stays_up_while_the_push_fails() {
|
||||
assert_eq!(fs::read_to_string(w.tmp.path().join("reason")).unwrap().trim(), "idle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_watch_goes_back_to_watching_when_someone_comes_back_during_the_push() {
|
||||
let w = Watch::new();
|
||||
w.dirty();
|
||||
let d = w.tmp.path();
|
||||
// The push starts a process the watcher counts as `claude` (a renamed
|
||||
// `sleep`, so `pgrep -x` matches it), as a user returning mid-push would.
|
||||
let busy_name = format!("tcbusy{}", std::process::id() % 100_000);
|
||||
let sleep = Command::new("sh").arg("-c").arg("command -v sleep").output().unwrap();
|
||||
let sleep = String::from_utf8_lossy(&sleep.stdout).trim().to_string();
|
||||
fs::copy(&sleep, d.join(&busy_name)).unwrap();
|
||||
fs::set_permissions(d.join(&busy_name), fs::Permissions::from_mode(0o755)).unwrap();
|
||||
write_script(
|
||||
d,
|
||||
"triple-c-wip-push",
|
||||
&format!(
|
||||
"#!/bin/bash\nif [ \"$1\" = --dry-run ]; then cat '{p}/dry'; exit 0; fi\n\
|
||||
echo \"$*\" >> '{p}/pushes'\n'{p}/{busy_name}' 30 </dev/null >/dev/null 2>&1 &\n\
|
||||
echo $! > '{p}/busy.pid'\nexit 0\n",
|
||||
p = d.display()
|
||||
),
|
||||
);
|
||||
let (url, mut agent) = fake_agent(d, OLD_ACTIVITY);
|
||||
let mut target = fake_entrypoint(d);
|
||||
let mut cmd = w.cmd(&url, target.id());
|
||||
cmd.env("TRIPLEC_WIP_PUSH", "1").env("TRIPLEC_IDLE_PROC_NAME", &busy_name);
|
||||
let ran = run_watch_for(cmd, 5);
|
||||
if let Ok(pid) = fs::read_to_string(d.join("busy.pid")) {
|
||||
let _ = Command::new("kill").arg(pid.trim()).status();
|
||||
}
|
||||
target.kill().ok();
|
||||
target.wait().ok();
|
||||
agent.kill().ok();
|
||||
assert!(ran.is_none(), "it must not stop: {:?}", ran.map(|o| text(&o)));
|
||||
assert_eq!(w.pushes(), "--reason idle\n", "it pushed once, then saw the activity");
|
||||
assert!(!d.join("got").exists(), "no TERM");
|
||||
assert!(!d.join("reason").exists(), "no idle marker");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_watch_keeps_a_stop_guard_marker_already_recorded() {
|
||||
for marker in ["pushed", "discard"] {
|
||||
@@ -1138,6 +1222,12 @@ fn docker_mode_never_reaches_the_aws_blocks() {
|
||||
};
|
||||
let agent_call = lines.iter().position(|l| l.trim() == "start_agent").expect("start_agent call");
|
||||
assert!(agent_call < pos("start_clone_repos"), "clones must start after the agent");
|
||||
// The stop trap is in place before any AWS background work starts, so a
|
||||
// TERM in between still runs aws_before_stop.
|
||||
let trap = pos("trap stop_keepalive TERM INT HUP");
|
||||
assert!(trap < pos("start_clone_repos"), "the trap must precede the clone");
|
||||
assert!(trap < pos("start_idle_watch"), "the trap must precede the idle watcher");
|
||||
assert!(pos("stop_keepalive()") < trap);
|
||||
assert!(block(ENTRYPOINT, "aws_repos").contains(") &"), "start_clone_repos backgrounds the clone");
|
||||
// And the snapshot-sensitive variables are gone before the agent's
|
||||
// environment snapshot is taken.
|
||||
|
||||
@@ -51,8 +51,10 @@ pub async fn poll_file(
|
||||
"poll".to_string(),
|
||||
container_path.to_string(),
|
||||
];
|
||||
// A background poll: an open viewer must not keep an idle AWS workspace
|
||||
// up. Opening and saving a file are the user's own actions and count.
|
||||
let (stdout, stderr, code) = container
|
||||
.exec_oneshot_streams_as("claude", cmd, Vec::new())
|
||||
.exec_oneshot_streams_background("claude", cmd)
|
||||
.await?;
|
||||
if code != 0 && code != 4 {
|
||||
return Err(format!(
|
||||
|
||||
@@ -400,6 +400,17 @@ pub async fn exec_oneshot_streams_as(
|
||||
Ok((out.stdout(), out.stderr(), out.exit_code))
|
||||
}
|
||||
|
||||
/// [`exec_oneshot_streams_as`] marked as a background poll (the file
|
||||
/// viewer's change check), so it never keeps an idle workspace up.
|
||||
pub async fn exec_oneshot_streams_background(
|
||||
ep: &AgentEndpoint,
|
||||
user: &str,
|
||||
cmd: Vec<String>,
|
||||
) -> Result<(String, String, i64), String> {
|
||||
let out = exec_oneshot_raw_marked(ep, user, cmd, Vec::new(), MAX_ONESHOT_OUTPUT, true).await?;
|
||||
Ok((out.stdout(), out.stderr(), out.exit_code))
|
||||
}
|
||||
|
||||
// ── detached execs, status, resize ───────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
||||
@@ -323,6 +323,22 @@ async fn only_a_background_poll_carries_the_background_marker() {
|
||||
assert_eq!(seen.exec_bodies[0], seen.exec_bodies[1], "the request is otherwise the same");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_file_viewers_poll_is_a_background_request_and_its_open_and_save_are_not() {
|
||||
let (ep, fake) = fake_agent().await;
|
||||
let t = target(&ep);
|
||||
// The 2 s change check, as the viewer runs it.
|
||||
let _ = crate::file_viewer::poll::poll_file(&t, "/workspace/x").await;
|
||||
t.exec_oneshot_streams_background("claude", cmd("sh")).await.unwrap();
|
||||
// What opening or saving goes through: user actions, which count.
|
||||
t.exec_oneshot_streams_as("claude", cmd("sh"), Vec::new()).await.unwrap();
|
||||
let seen = fake.seen.lock().unwrap();
|
||||
assert_eq!(seen.exec_background, [Some("1".to_string()), Some("1".to_string()), None]);
|
||||
let src = |s: &str| s.contains("exec_oneshot_streams_as(");
|
||||
assert!(!src(include_str!("../../file_viewer/poll.rs")), "the poll stays a background request");
|
||||
assert!(src(include_str!("../../file_viewer/resolve.rs")) && src(include_str!("../../file_viewer/write.rs")));
|
||||
}
|
||||
|
||||
// ── one-shot execs ───────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -928,10 +928,23 @@ pub fn unattached_container_id(p: &Project) -> Option<String> {
|
||||
if tunnels::get_endpoint(&p.id).is_some_and(|ep| ep.base_url.as_str() == stored) {
|
||||
return None;
|
||||
}
|
||||
let placeholder = format!("http://unattached.invalid/{}/", p.id);
|
||||
let placeholder = tunnels::unattached_id(&p.id);
|
||||
(stored != placeholder).then_some(placeholder)
|
||||
}
|
||||
|
||||
/// Before reconcile asks ECS about `p`: a project with no registered tunnel
|
||||
/// is (or is about to be) known by its [`tunnels::unattached_id`], which a
|
||||
/// past unattached spell may have left marked stopped. Reconcile is the
|
||||
/// authority about the task now, so that mark goes; the watch re-marks it if
|
||||
/// the task really is stopped. Returns the placeholder to store, if any.
|
||||
pub fn prepare_unattached(p: &Project) -> Option<String> {
|
||||
if !p.is_aws() || tunnels::get_endpoint(&p.id).is_some() {
|
||||
return None;
|
||||
}
|
||||
tunnels::forget_stopped(&tunnels::unattached_id(&p.id));
|
||||
unattached_container_id(p)
|
||||
}
|
||||
|
||||
/// Reconcile one AWS project the store has as Running (or Starting/
|
||||
/// Stopping/Error left by a crash): a gone task makes it Stopped (its task
|
||||
/// ARN cleared); a live one keeps it Running and its ARN stored. An ECS
|
||||
|
||||
@@ -754,6 +754,26 @@ async fn reconcile_falls_back_to_a_newer_live_task_when_the_stored_one_stopped()
|
||||
assert!(fake.called("list"), "it looked for another task first");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconcile_forgets_a_stale_stop_on_the_unattached_id_before_reusing_it() {
|
||||
use crate::host::tunnels;
|
||||
let mut p = aws_project(StorageMode::Ephemeral);
|
||||
p.status = ProjectStatus::Running;
|
||||
p.aws.as_mut().unwrap().task_arn = Some(task_arn("u2"));
|
||||
let placeholder = tunnels::unattached_id(&p.id);
|
||||
// A past unattached spell's task was seen stopped under this id.
|
||||
tunnels::mark_stopped(&placeholder, StopKind::Spot);
|
||||
p.container_id = Some("http://127.0.0.1:61400/".into());
|
||||
assert_eq!(prepare_unattached(&p), Some(placeholder.clone()));
|
||||
assert!(!tunnels::is_stopped(&placeholder), "the live task must not read as stopped");
|
||||
// Already on the placeholder: nothing to store, the mark still goes.
|
||||
p.container_id = Some(placeholder.clone());
|
||||
tunnels::mark_stopped(&placeholder, StopKind::Spot);
|
||||
assert_eq!(prepare_unattached(&p), None);
|
||||
assert!(!tunnels::is_stopped(&placeholder));
|
||||
assert_eq!(prepare_unattached(&Project::new("l".into(), Vec::new())), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unattached_aws_project_gets_a_target_id_no_tunnel_can_have() {
|
||||
// m9: the last run's loopback port may belong to another project's
|
||||
|
||||
@@ -302,6 +302,22 @@ impl ContainerTarget {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::exec_oneshot_streams_as`] for a background poll (the file
|
||||
/// viewer's change check): on the agent it carries the background marker,
|
||||
/// so it never counts as activity for idle stop. The same call on Docker.
|
||||
pub async fn exec_oneshot_streams_background(
|
||||
&self,
|
||||
user: &str,
|
||||
cmd: Vec<String>,
|
||||
) -> Result<(String, String, i64), String> {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::exec_oneshot_streams_as(container_id, user, cmd, Vec::new()).await
|
||||
}
|
||||
ContainerTarget::Agent(ep) => agent::exec_oneshot_streams_background(ep, user, cmd).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a command and return without waiting or reading its output.
|
||||
/// Returns the exec id.
|
||||
pub async fn exec_detached(&self, spec: ExecSpec) -> Result<String, String> {
|
||||
|
||||
@@ -89,6 +89,21 @@ fn notify() {
|
||||
changed().send_modify(|n| *n = n.wrapping_add(1));
|
||||
}
|
||||
|
||||
/// The target id an AWS project has while no tunnel is registered for it
|
||||
/// (see `aws_host::unattached_container_id`): never a loopback URL, so a
|
||||
/// port a new tunnel reuses can't be confused with it.
|
||||
pub fn unattached_id(project_id: &str) -> String {
|
||||
format!("http://unattached.invalid/{project_id}/")
|
||||
}
|
||||
|
||||
/// Forget that the watch saw the task behind `target_id` stopped. For a
|
||||
/// project's [`unattached_id`], which (unlike an endpoint) is reused by
|
||||
/// every later unattached spell of the same project: a stale entry would
|
||||
/// make a later live task read as stopped.
|
||||
pub fn forget_stopped(target_id: &str) {
|
||||
lock().stopped.remove(target_id);
|
||||
}
|
||||
|
||||
/// Register `tunnel` for `project_id`, returning the one it replaces (the
|
||||
/// caller closes it).
|
||||
pub fn register(project_id: &str, tunnel: LiveTunnel) -> Option<LiveTunnel> {
|
||||
@@ -96,6 +111,7 @@ pub fn register(project_id: &str, tunnel: LiveTunnel) -> Option<LiveTunnel> {
|
||||
let mut r = lock();
|
||||
*r.generation.entry(project_id.to_string()).or_default() += 1;
|
||||
r.stopped.remove(tunnel.endpoint.base_url.as_str());
|
||||
r.stopped.remove(&unattached_id(project_id));
|
||||
r.pending.remove(project_id);
|
||||
r.live.insert(project_id.to_string(), tunnel)
|
||||
};
|
||||
@@ -165,6 +181,7 @@ pub fn register_if_current(
|
||||
return Err(Box::new(tunnel));
|
||||
}
|
||||
r.stopped.remove(tunnel.endpoint.base_url.as_str());
|
||||
r.stopped.remove(&unattached_id(project_id));
|
||||
r.pending.remove(project_id);
|
||||
r.live.insert(project_id.to_string(), tunnel)
|
||||
};
|
||||
@@ -358,6 +375,28 @@ pub(crate) mod tests {
|
||||
assert_eq!(closes.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_registering_tunnel_clears_a_stop_seen_under_the_projects_unattached_id() {
|
||||
let p = pid("unattached-stale");
|
||||
let placeholder = unattached_id(&p);
|
||||
mark_stopped(&placeholder, StopKind::Spot);
|
||||
let closes = Arc::new(AtomicUsize::new(0));
|
||||
register(&p, fake_tunnel(61301, closes.clone()));
|
||||
assert!(!is_stopped(&placeholder), "a later unattached spell must not read as stopped");
|
||||
close(&p).await;
|
||||
|
||||
// A re-attach registering clears it too; and it can be forgotten directly.
|
||||
let q = pid("unattached-stale-reattach");
|
||||
mark_stopped(&unattached_id(&q), StopKind::Spot);
|
||||
let g = begin_reattach(&q);
|
||||
assert!(register_if_current(&q, g, fake_tunnel(61302, closes.clone())).is_ok());
|
||||
assert!(!is_stopped(&unattached_id(&q)));
|
||||
close(&q).await;
|
||||
mark_stopped(&unattached_id(&q), StopKind::Idle);
|
||||
forget_stopped(&unattached_id(&q));
|
||||
assert!(!is_stopped(&unattached_id(&q)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_reattach_that_began_before_a_stop_does_not_register() {
|
||||
let closes = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
@@ -727,6 +727,9 @@ pub struct RepoState {
|
||||
pub dirty: bool,
|
||||
pub unpushed: u64,
|
||||
pub branch: String,
|
||||
/// The start's background clone is still writing it: nothing to push.
|
||||
#[serde(default)]
|
||||
pub cloning: bool,
|
||||
}
|
||||
|
||||
/// What `check_unsaved_work` found. `checked: false` (with `error`) when the
|
||||
|
||||
@@ -25,6 +25,17 @@ describe("StopGuardModal", () => {
|
||||
expect(onChoose).toHaveBeenCalledWith("push");
|
||||
});
|
||||
|
||||
it("lists a repository still cloning as having nothing to push", () => {
|
||||
const work: UnsavedWork = {
|
||||
...dirty,
|
||||
repos: [...dirty.repos, { folder: "big", branch: "", dirty: false, unpushed: 0, cloning: true }],
|
||||
};
|
||||
render(<StopGuardModal projectName="api" work={work} onChoose={vi.fn()} />);
|
||||
expect(screen.getByText("/workspace/big")).toBeInTheDocument();
|
||||
expect(screen.getByText("still cloning — nothing to push")).toBeInTheDocument();
|
||||
expect(screen.getByText("/workspace/api")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers Stop without pushing and Cancel", () => {
|
||||
const onChoose = vi.fn();
|
||||
render(<StopGuardModal projectName="api" work={dirty} onChoose={onChoose} />);
|
||||
|
||||
@@ -31,7 +31,8 @@ function describeRepo(dirty: boolean, unpushed: number): string {
|
||||
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);
|
||||
const atRisk = work.repos.filter((r) => !r.cloning && (r.dirty || r.unpushed > 0));
|
||||
const cloning = work.repos.filter((r) => r.cloning);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -75,6 +76,14 @@ export default function StopGuardModal({ projectName, work, wipPushOnStop = fals
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{cloning.map((r) => (
|
||||
<li key={r.folder} className="text-[13px] text-[var(--text-primary)]">
|
||||
<span className="font-mono">/workspace/{r.folder}</span>{" "}
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
still cloning — nothing to push
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-3 text-[13px] text-[var(--text-secondary)]">
|
||||
Push to WIP branch saves a snapshot of each repository to a{" "}
|
||||
|
||||
@@ -165,6 +165,21 @@ describe("HostSection", () => {
|
||||
await waitFor(() => expect(removeAwsResources).toHaveBeenCalledWith(p.id, true));
|
||||
});
|
||||
|
||||
it("keeps a project whose kept files' access point remains on AWS until it is removed", () => {
|
||||
const p = awsProject();
|
||||
p.aws = { ...p.aws!, access_point_id: "fsap-1", task_arn: null, task_definition_arn: null };
|
||||
renderHost(p);
|
||||
const move = screen.getByRole("button", { name: "Run on this machine instead" });
|
||||
expect(move).toHaveAttribute("aria-disabled", "true");
|
||||
expect(move).toHaveAccessibleDescription(/access point is still on AWS/);
|
||||
expect(screen.getByRole("button", { name: "Remove AWS resources…" })).not.toHaveAttribute(
|
||||
"aria-disabled",
|
||||
"true",
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove AWS resources…" }));
|
||||
expect(within(screen.getByRole("dialog")).getByText(/access point\s+stays too/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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" };
|
||||
|
||||
@@ -58,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);
|
||||
// A removal that kept the saved files keeps their access point stored.
|
||||
const onlyAccessPoint = !!aws.access_point_id && !aws.task_arn && !aws.task_definition_arn;
|
||||
// Not `disabled` (which also allows Error): on AWS, Error can mean a live task.
|
||||
const stopped = project.status === "stopped";
|
||||
|
||||
@@ -343,7 +345,9 @@ export default function HostSection({ project, save, disabled }: Props) {
|
||||
unavailableReason={
|
||||
!stopped
|
||||
? STILL_RUNNING
|
||||
: "Remove the workspace's AWS resources first, so nothing is left running or billed."
|
||||
: onlyAccessPoint
|
||||
? "The saved files' access point is still on AWS. Remove the AWS resources with \u201cAlso remove the workspace\u2019s file-system access point\u201d first."
|
||||
: "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 })}
|
||||
>
|
||||
@@ -421,7 +425,9 @@ function RemoveAwsResourcesModal({ project, onClose }: { project: Project; onClo
|
||||
Also remove the workspace’s file-system access point
|
||||
<span className="block text-xs text-[var(--text-secondary)]">
|
||||
The files stay on your own EFS file system (only your workspace role can read
|
||||
them) until an administrator offboards you.
|
||||
them) until an administrator offboards you. Left unchecked, the access point
|
||||
stays too: the next start here reuses it, and the project can’t move to
|
||||
another host until it is removed.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -48,6 +48,8 @@ describe("awsWorkspace", () => {
|
||||
expect(stopGuardNeeded({ checked: true, repos: [{ ...repo, dirty: true }], error: null })).toBe(true);
|
||||
expect(stopGuardNeeded({ checked: true, repos: [{ ...repo, unpushed: 1 }], error: null })).toBe(true);
|
||||
expect(stopGuardNeeded({ checked: false, repos: [], error: "x" })).toBe(true);
|
||||
// A repository still cloning has nothing to push.
|
||||
expect(stopGuardNeeded({ checked: true, repos: [{ ...repo, cloning: true }], error: null })).toBe(false);
|
||||
});
|
||||
|
||||
it("selects only ready environments the user joined", () => {
|
||||
|
||||
@@ -109,7 +109,7 @@ export function cleanRepos(rows: RepoSource[]): RepoSource[] {
|
||||
|
||||
/** Whether Stop must ask first (the one place this rule lives). */
|
||||
export function stopGuardNeeded(work: UnsavedWork): boolean {
|
||||
return !work.checked || work.repos.some((r) => r.dirty || r.unpushed > 0);
|
||||
return !work.checked || work.repos.some((r) => !r.cloning && (r.dirty || r.unpushed > 0));
|
||||
}
|
||||
|
||||
/** An environment a project can be placed in: ready (or merely behind) and joined. */
|
||||
|
||||
@@ -136,6 +136,8 @@ export interface RepoState {
|
||||
dirty: boolean;
|
||||
unpushed: number;
|
||||
branch: string;
|
||||
/** The start's background clone is still writing it: nothing to push. */
|
||||
cloning?: boolean;
|
||||
}
|
||||
|
||||
/** What `checkUnsavedWork` found (Rust `UnsavedWork`). `checked: false` (with
|
||||
|
||||
+18
-10
@@ -635,9 +635,12 @@ chown claude:claude /home/claude/.gitconfig 2>/dev/null || true
|
||||
# app gives a starting task's agent only seconds to answer, so a clone in the
|
||||
# way of the agent would fail every start with a large repository. Terminals
|
||||
# work while the repos arrive. Progress, for the app to show later, is in
|
||||
# /run/triple-c-clone (world-readable, root-written): `repos` gets one
|
||||
# "<folder>\t<cloned|failed|exists>" line per repo, and `done` appears when
|
||||
# all are finished. The idle watcher counts an unfinished clone as activity.
|
||||
# /run/triple-c-clone (world-readable, root-written): `repos` gets a
|
||||
# "<folder>\tcloning" line as a clone begins and a
|
||||
# "<folder>\t<cloned|failed|exists>" line when it ends (the last line for a
|
||||
# folder is its state), and `done` appears when all are finished. The idle
|
||||
# watcher counts an unfinished clone as activity; triple-c-wip-push skips a
|
||||
# folder still `cloning`.
|
||||
# >>> aws_repos
|
||||
# clone_repos json root [status_dir]
|
||||
clone_repos() {
|
||||
@@ -658,6 +661,7 @@ clone_repos() {
|
||||
[ -n "$branch" ] && args+=(--branch "$branch")
|
||||
args+=(-- "$url" "$dest")
|
||||
echo "entrypoint: cloning $folder..."
|
||||
_clone_status "$folder" cloning
|
||||
# BatchMode is appended to whatever ssh command the user's git config
|
||||
# names (core.sshCommand), read as the user, rather than replacing it.
|
||||
err=$(_triplec_as_user sh -c '
|
||||
@@ -1244,6 +1248,17 @@ start_idle_watch() {
|
||||
) &
|
||||
}
|
||||
# <<< aws_stop
|
||||
# The stop trap goes in before any background work starts (the idle watcher
|
||||
# can signal this script; a clone is work a stop must account for), so a TERM
|
||||
# from here on always runs aws_before_stop. Defined after aws_stop, which
|
||||
# holds the function it calls.
|
||||
keepalive_pid=""
|
||||
stop_keepalive() {
|
||||
[ "$triplec_aws" = 1 ] && aws_before_stop
|
||||
[ -n "$keepalive_pid" ] && kill "$keepalive_pid" 2>/dev/null
|
||||
exit "${triplec_exit_status:-0}"
|
||||
}
|
||||
trap stop_keepalive TERM INT HUP
|
||||
if [ "$triplec_aws" = 1 ] && [ -n "${TRIPLEC_IDLE_STOP_MINUTES:-}" ]; then
|
||||
start_idle_watch "$TRIPLEC_IDLE_STOP_MINUTES" "$$"
|
||||
fi
|
||||
@@ -1253,13 +1268,6 @@ if [ "$triplec_aws" = 1 ]; then
|
||||
fi
|
||||
|
||||
echo "Triple-C container ready."
|
||||
keepalive_pid=""
|
||||
stop_keepalive() {
|
||||
[ "$triplec_aws" = 1 ] && aws_before_stop
|
||||
[ -n "$keepalive_pid" ] && kill "$keepalive_pid" 2>/dev/null
|
||||
exit "${triplec_exit_status:-0}"
|
||||
}
|
||||
trap stop_keepalive TERM INT HUP
|
||||
throttle_fifo=/run/triple-c-keepalive.throttle
|
||||
rm -f "$throttle_fifo"
|
||||
mkfifo -m 600 "$throttle_fifo" 2>/dev/null && exec 3<>"$throttle_fifo"
|
||||
|
||||
@@ -19,9 +19,10 @@
|
||||
# * no work would be lost: `triple-c-wip-push --dry-run` (as claude) finds
|
||||
# no dirty repository and no unpushed commit. With the WIP push on
|
||||
# (TRIPLEC_WIP_PUSH=1) such work is pushed here, before the stop, and the
|
||||
# workspace stops only once that push succeeded; a failed push keeps it
|
||||
# running and is retried later (backing off). With it off, unsaved work
|
||||
# simply keeps the workspace up.
|
||||
# workspace stops only once that push succeeded and the workspace is
|
||||
# still quiet afterwards (activity during the push sends it back to
|
||||
# watching); a failed push keeps it running and is retried later (backing
|
||||
# off). With it off, unsaved work simply keeps the workspace up.
|
||||
#
|
||||
# Anything it cannot read counts as NOT idle: a stop is the one thing here
|
||||
# that cannot be undone, so every doubt keeps the workspace running.
|
||||
@@ -206,6 +207,14 @@ while :; do
|
||||
echo "triple-c-idle-watch: the WIP push failed — staying up; retrying in ${wait_s}s"
|
||||
continue
|
||||
fi
|
||||
# The push took a while: someone may have come back meanwhile (a
|
||||
# terminal, a `claude`, a scheduler job). Their new work would be
|
||||
# lost, since the stop hook does not push again after an idle push.
|
||||
if ! quiet; then
|
||||
echo "triple-c-idle-watch: activity during the WIP push — not stopping"
|
||||
said=""
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
echo "triple-c-idle-watch: idle for ${minutes} minutes — stopping the workspace"
|
||||
mkdir -p "$(dirname "$reason_file")" 2>/dev/null
|
||||
|
||||
@@ -22,14 +22,22 @@
|
||||
# {"folder":…, "dirty":bool, "unpushed":n, "branch":…}
|
||||
# and changes nothing.
|
||||
#
|
||||
# A repository the start's background clone is still writing (the entrypoint's
|
||||
# /run/triple-c-clone: its `repos` file's last line for the folder says
|
||||
# `cloning` and `done` is absent) is skipped: a half-made checkout is not the
|
||||
# user's work. The dry run reports it as
|
||||
# {"folder":…, "dirty":false, "unpushed":0, "branch":"", "cloning":true}
|
||||
#
|
||||
# Exit 0 when every needed push succeeded (or nothing needed pushing), 1 when
|
||||
# any failed, 2 on bad usage.
|
||||
#
|
||||
# TRIPLEC_WORKSPACE_ROOT overrides /workspace (tests).
|
||||
# TRIPLEC_WORKSPACE_ROOT overrides /workspace, TRIPLEC_CLONE_STATUS_DIR
|
||||
# /run/triple-c-clone (tests).
|
||||
|
||||
set -u
|
||||
|
||||
root="${TRIPLEC_WORKSPACE_ROOT:-/workspace}"
|
||||
clone_dir="${TRIPLEC_CLONE_STATUS_DIR:-/run/triple-c-clone}"
|
||||
dry_run=0
|
||||
reason=stop
|
||||
|
||||
@@ -62,6 +70,14 @@ pick_remote() {
|
||||
if [ "$(git remote | wc -l)" -eq 1 ]; then git remote; fi
|
||||
}
|
||||
|
||||
# Whether the background clone is still writing folder $1: the clone is not
|
||||
# `done` and the last state it recorded for the folder is `cloning`.
|
||||
still_cloning() {
|
||||
[ -d "$clone_dir" ] && [ ! -e "$clone_dir/done" ] || return 1
|
||||
# ENVIRON, not -v: -v would read backslashes in a folder name as escapes.
|
||||
[ "$(F="$1" awk -F '\t' '$1 == ENVIRON["F"] { s = $2 } END { print s }' "$clone_dir/repos" 2>/dev/null)" = cloning ]
|
||||
}
|
||||
|
||||
user_ssh_command="${GIT_SSH_COMMAND:-}"
|
||||
|
||||
for dir in "$root"/*/; do
|
||||
@@ -73,6 +89,16 @@ for dir in "$root"/*/; do
|
||||
[ "$top" = "$(cd "$dir" && pwd -P)" ] || continue
|
||||
folder=$(basename "$dir")
|
||||
|
||||
if still_cloning "$folder"; then
|
||||
if [ "$dry_run" = 1 ]; then
|
||||
jq -cn --arg folder "$folder" \
|
||||
'{folder: $folder, dirty: false, unpushed: 0, branch: "", cloning: true}'
|
||||
else
|
||||
echo "triple-c-wip-push: $folder: still cloning — nothing to push"
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
|
||||
(
|
||||
cd "$dir" || exit 1
|
||||
# BatchMode: an unknown host key or a passphrase prompt fails at once
|
||||
|
||||
Reference in new issue
Block a user