use anyhow::anyhow;
use async_trait::async_trait;
use serde_json::json;
use std::fmt::Write as _;
use crate::{Tool, Workspace, WorkspaceStatus};
pub(crate) struct WorkspaceControlTool;
#[async_trait]
impl Tool for WorkspaceControlTool {
fn name(&self) -> &'static str {
"workspace_control"
}
fn parameters_schema(&self) -> serde_json::Value {
super::tool_params_schema(
&json!({
"action": {
"type": "string",
"enum": ["pause", "resume", "list"],
"description": "`pause` / `resume` freeze or lift the pipeline of the workspace named in `name`; `list` reports every registered workspace with its status and whether its pipeline is paused."
},
"name": {
"type": "string",
"description": "(pause / resume) The workspace's registered name, exactly as the `<registered-workspaces>` block spells it — matching is exact, never approximate."
}
}),
&["action"],
)
}
fn should_scrub_output(&self, _args: &serde_json::Value) -> bool {
false
}
async fn execute(&self, _ws: &Workspace, args: serde_json::Value) -> anyhow::Result<String> {
match super::get_str(&args, "action")? {
"list" => list_workspaces().await,
"pause" => set_pipeline_paused(super::get_str(&args, "name")?, true).await,
"resume" => set_pipeline_paused(super::get_str(&args, "name")?, false).await,
other => Err(anyhow!(
"unknown action '{other}' — expected one of: pause, resume, list"
)),
}
}
}
async fn list_workspaces() -> anyhow::Result<String> {
let workspaces = crate::users::registered_workspaces().await?;
if workspaces.is_empty() {
return Ok("No workspace is registered.".to_string());
}
let mut listing = String::from("Registered workspaces:\n");
for ws in workspaces {
let paused = if ws.paused { ", paused" } else { "" };
let _ = writeln!(
listing,
"- {} ({}{paused}): {}",
ws.name, ws.status, ws.path
);
}
Ok(listing.trim_end().to_string())
}
async fn set_pipeline_paused(name: &str, paused: bool) -> anyhow::Result<String> {
let name = name.trim();
if crate::users::is_personal_workspace(name) {
return Err(anyhow!(
"'{name}' is a personal space, not a registered workspace — personal spaces have no pipeline."
));
}
let Some(ws) = crate::workspace::get_by_name(name).await? else {
let known = crate::users::registered_workspaces()
.await?
.into_iter()
.map(|ws| ws.name)
.collect::<Vec<_>>()
.join(", ");
return Err(anyhow!(
"No workspace is registered under the name '{name}' — registered: {}",
if known.is_empty() { "none" } else { &known }
));
};
if ws.status != WorkspaceStatus::Ready {
return Err(anyhow!(
"Workspace '{}' is {} — only a ready workspace has a pipeline to pause or resume.",
ws.name,
ws.status
));
}
if ws.paused == paused {
return Ok(if paused {
format!("The pipeline of workspace '{}' is already paused.", ws.name)
} else {
format!(
"The pipeline of workspace '{}' is not paused — nothing to resume.",
ws.name
)
});
}
crate::workspace::store()
.set_paused(&ws.name, paused)
.await?;
let verb = if paused { "paused" } else { "resumed" };
Ok(format!("Pipeline {verb} for workspace '{}'.", ws.name))
}
#[cfg(test)]
mod tests {
use super::*;
fn tool_args(action: &str, name: Option<&str>) -> serde_json::Value {
match name {
Some(name) => json!({ "action": action, "name": name }),
None => json!({ "action": action }),
}
}
#[tokio::test]
async fn refusals_are_plain_and_named() {
crate::util::test::init_test_stores().await;
let ws = crate::workspace::test_ws("/tmp/wsctl_refusals");
let tool = WorkspaceControlTool;
let err = tool
.execute(&ws, tool_args("pause", Some("wsctl_absent")))
.await
.unwrap_err()
.to_string();
assert!(err.contains("No workspace is registered"), "got: {err}");
let err = tool
.execute(&ws, tool_args("resume", Some("personal:admin")))
.await
.unwrap_err()
.to_string();
assert!(err.contains("personal space"), "got: {err}");
crate::util::test::create_test_workspace("/tmp/wsctl_pending", "wsctl_pending").await;
let err = tool
.execute(&ws, tool_args("pause", Some("wsctl_pending")))
.await
.unwrap_err()
.to_string();
assert!(
err.contains("pending") && err.contains("ready"),
"got: {err}"
);
assert!(
!crate::workspace::get_by_name("wsctl_pending")
.await
.expect("read")
.expect("exists")
.paused,
"the refusal must not pause the workspace"
);
}
#[tokio::test]
async fn pause_and_resume_a_ready_workspace() {
crate::util::test::init_test_stores().await;
let ws = crate::workspace::test_ws("/tmp/wsctl_ready");
let tool = WorkspaceControlTool;
crate::util::test::create_test_workspace("/tmp/wsctl_ready", "wsctl_ready").await;
crate::workspace::store()
.set_status("wsctl_ready", &WorkspaceStatus::Ready)
.await
.expect("mark ready");
let reply = tool
.execute(&ws, tool_args("pause", Some("wsctl_ready")))
.await
.expect("pause");
assert!(
reply.contains("wsctl_ready") && reply.contains("paused"),
"got: {reply}"
);
assert!(
crate::workspace::get_by_name("wsctl_ready")
.await
.expect("read")
.expect("exists")
.paused
);
let listing = tool
.execute(&ws, tool_args("list", None))
.await
.expect("list");
assert!(
listing.contains("- wsctl_ready (ready, paused)"),
"got: {listing}"
);
let reply = tool
.execute(&ws, tool_args("pause", Some("wsctl_ready")))
.await
.expect("re-pause");
assert!(reply.contains("already paused"), "got: {reply}");
let reply = tool
.execute(&ws, tool_args("resume", Some("wsctl_ready")))
.await
.expect("resume");
assert!(reply.contains("resumed"), "got: {reply}");
assert!(
!crate::workspace::get_by_name("wsctl_ready")
.await
.expect("read")
.expect("exists")
.paused
);
assert!(
!tool
.execute(&ws, tool_args("list", None))
.await
.expect("list")
.contains("- wsctl_ready (ready, paused)"),
"a resumed workspace must not carry the pause marker"
);
}
}