From 4e323ad26f1b59fad4e1973b8426c521f1a68027 Mon Sep 17 00:00:00 2001 From: Andrey Vyrodov Date: Fri, 9 Oct 2026 23:03:51 +0200 Subject: [PATCH] Issue #345: activity.list and activity.cancel commands The registry is reachable as commands, so the UI, the control channel, the CLI and MCP list and cancel background tasks the same way. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/activity.rs | 65 +++++++++++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + docs/control-protocol.md | 2 + 3 files changed, 69 insertions(+) create mode 100644 crates/engine/src/cmd/activity.rs diff --git a/crates/engine/src/cmd/activity.rs b/crates/engine/src/cmd/activity.rs new file mode 100644 index 000000000..af8667728 --- /dev/null +++ b/crates/engine/src/cmd/activity.rs @@ -0,0 +1,65 @@ +//! `activity.list` / `activity.cancel`: the background tasks in flight (see [`crate::activity`]). + +use serde_json::{Value, json}; + +use super::{CommandSpec, always, bad, cmd}; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "activity.list", + "Background Tasks", + [], + None, + "{} → {tasks: [{id, kind, label, done, total, unit: count|bytes|percent, detail, cancellable, cancelling, ageMs}]} — the long-running tasks in flight (imports, exports, preview builds, downloads, the face scan…), oldest first; total 0 = not known yet", + always, + |s, _| Ok(json!({"tasks": s.activity.list()})) + ), + cmd!( + "activity.cancel", + "Cancel Background Task", + [], + None, + "{id} | {all: true} → {cancelled: n} — ask a task (or every cancellable task) to stop; it stops at its next file or photo. An unknown id (it may have just finished) or a task that can't be cancelled is an error", + always, + |s, p| { + const C: &str = "activity.cancel"; + if p.get("all").and_then(Value::as_bool) == Some(true) { + return Ok(json!({"cancelled": s.activity.cancel_all()})); + } + let id = p.get("id").and_then(Value::as_u64).ok_or_else(|| bad(C, "id or all"))?; + s.activity.cancel(id).map_err(|e| bad(C, e))?; + Ok(json!({"cancelled": 1})) + } + ), + ] +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::Session; + use crate::activity::Cancel; + + #[test] + fn list_and_cancel_through_execute() { + let mut s = Session::new(); + let g = s.activity.start("export", "Exporting", Cancel::Yes); + let r = s.execute("activity.list", &json!({})).unwrap(); + assert_eq!(r["tasks"][0]["kind"], "export"); + assert_eq!(r["tasks"][0]["cancellable"], true); + let r = s.execute("activity.cancel", &json!({"id": g.id()})).unwrap(); + assert_eq!(r["cancelled"], 1); + assert!(g.is_cancelled()); + } + + #[test] + fn cancel_all_and_errors() { + let mut s = Session::new(); + let (_a, _b) = (s.activity.start("export", "E", Cancel::Yes), s.activity.start("faces", "F", Cancel::No)); + assert_eq!(s.execute("activity.cancel", &json!({"all": true})).unwrap()["cancelled"], 1); + assert!(s.execute("activity.cancel", &json!({"id": 999})).is_err()); + assert!(s.execute("activity.cancel", &json!({})).is_err()); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index ebc82862b..e7def13d3 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -4,6 +4,7 @@ //! delete), `album.*`, `develop.*` (settings), `crop.*`, `mask.*`, `preset.*`, `version.*`, //! `edit.*` (undo/redo), and queries (`catalog.query`, `photo.inspect`, `develop.get`…). +mod activity; mod before; pub(crate) mod browse; mod color; @@ -154,6 +155,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(face_models::specs()); v.extend(face_detect::specs()); v.extend(face_recognize::specs()); + v.extend(activity::specs()); v }) } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f6af190e0..1de4a46ca 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -46,6 +46,8 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation: | `ui.zoomFactor` | `{factor}` | Set the interface scale (0.5–3; egui's zoom keys are off, Cmd+= / Cmd+- zoom the photo) | | `ui.screenshot` | `{path?, headless?}` | `{path, width, height}` once the frame (with finished renders) is captured. `headless: true` draws the UI on the CPU (no compositor needed); a windowed capture that gets no frame within 2 s falls back to headless automatically | | `engine.execute {command: "app.export", params}` | export params (see `docs/mcp.md`), plus `preset`, `dir` / `path`, `ids`, `background` | Writes the files and returns `{files}`; with `background: true` (what the Export dialog and menus use) it returns `{background: true, total}` at once and the batch runs on a worker thread — poll `ui.inspect` → `export` | +| `engine.execute {command: "activity.list"}` | — | The long-running tasks in flight (imports, exports, preview builds, downloads, the face scan…), oldest first: `{tasks: [{id, kind, label, done, total, unit, detail, cancellable, cancelling, ageMs}]}`; `total` 0 = not known yet; `unit` is `count`, `bytes` or `percent`; `label` is English (the app shows it translated) | +| `engine.execute {command: "activity.cancel"}` | `{id}` or `{all: true}` | Asks the task (or every cancellable one) to stop at its next file or photo → `{cancelled: n}`; an unknown id (it may have just finished) or a task that can't be cancelled is an error | | `ui.render` | `{id?, size?, path?}` | Render a photo (PNG to `path`), `{width, height}` | | `app.quit` | — | Close the app |