diff --git a/specs/swarm-safety/tasks.md b/specs/swarm-safety/tasks.md index 35963f3..068648e 100644 --- a/specs/swarm-safety/tasks.md +++ b/specs/swarm-safety/tasks.md @@ -4,19 +4,19 @@ Each task is independently testable. Tests first. Stop on failure. Tasks in the ## Wave 1: foundations (parallel) -- [ ] 1. **Client identity** (`src/mcp/mod.rs`, `src/mcp/server.rs`). `Client.parent: Option`; `agent_of(query) -> Option` next to `chat_of`; `client_of` builds `Client{name, session:"agent:", parent}`; sanitize names, reject `You`/`Chat`/`Actions`. Test: parsing, sanitizing, reserved names, equality, `chat=` still wins. -- [ ] 2. **CLI identity** (`src/bin/papercraft-cli.rs`, `src/cli.rs`, `src/mcp/delegate.rs`). `--as`, `--parent`, `PAPERCRAFT_AGENT`, `PAPERCRAFT_PARENT` (flag > env > `cli`); online appends the query to the MCP URL; `Offline::new(store, project, client)`; `delegate::run` sets both env vars on the helper; one-time stderr hint for unnamed. Test: precedence, URL, offline carries the client, helper env visible (unix test), shell keeps one identity across lines. Depends on 1. -- [ ] 3. **Gate skeleton** (new `src/mcp/swarm/{mod,gate}.rs`, `src/lib.rs` or `src/mcp/mod.rs` registration; call sites in `src/app.rs::run_mcp_calls` and `cli::Offline::call`). `Swarm`, `Gate::{Allow, Reject(String)}`, `swarm::call(host, tracker, swarm, client, name, args, now)`, `swarm::attach(client, r)` (before `steer::attach`). Empty check chain; behaviour unchanged. Test: all existing `tests/mcp.rs` and `tests/cli_offline.rs` pass; a stub check can reject and annotate. -- [ ] 4. **Harness skeleton** (`tests/common/swarm.rs`, `tests/swarm.rs`). `Sim`, `SimClient`, `Step`, `FakeClock`, `Oracle` stub, `disjoint(n)` scenario: N clients write own artboards, assert every write present and ordered `rev`s. Depends on 3. +- [x] 1. **Client identity** (`src/mcp/mod.rs`, `src/mcp/server.rs`). `Client.parent: Option`; `agent_of(query) -> Option` next to `chat_of`; `client_of` builds `Client{name, session:"agent:", parent}`; sanitize names, reject `You`/`Chat`/`Actions`. Test: parsing, sanitizing, reserved names, equality, `chat=` still wins. +- [ ] 2. **CLI identity** (`src/bin/papercraft-cli.rs`, `src/cli.rs`, `src/mcp/delegate.rs`). `--as`, `--parent`, `PAPERCRAFT_AGENT`, `PAPERCRAFT_PARENT` (flag > env > `cli`); online appends the query to the MCP URL; `Offline::new(store, project, client)`; `delegate::run` sets both env vars on the helper; one-time stderr hint for unnamed. Test: precedence, URL, offline carries the client, helper env visible (unix test), shell keeps one identity across lines. Depends on 1. *Partly done: `--as`/`--parent`, env and helper env; offline client and stderr hint open.* +- [x] 3. **Gate skeleton** (new `src/mcp/swarm/{mod,gate}.rs`, `src/lib.rs` or `src/mcp/mod.rs` registration; call sites in `src/app.rs::run_mcp_calls` and `cli::Offline::call`). `Swarm`, `Gate::{Allow, Reject(String)}`, `swarm::call(host, tracker, swarm, client, name, args, now)`, `swarm::attach(client, r)` (before `steer::attach`). Empty check chain; behaviour unchanged. Test: all existing `tests/mcp.rs` and `tests/cli_offline.rs` pass; a stub check can reject and annotate. +- [ ] 4. **Harness skeleton** (`tests/common/swarm.rs`, `tests/swarm.rs`). `Sim`, `SimClient`, `Step`, `FakeClock`, `Oracle` stub, `disjoint(n)` scenario: N clients write own artboards, assert every write present and ordered `rev`s. Depends on 3. *Partly done: `Sim` in `tests/swarm.rs` with a fake clock; oracle and seeds open.* - [ ] 5. **Save-time stamp check and merge** (`src/session.rs::save`, `src/merge.rs`, `src/project/packed.rs`, `src/editor/pages.rs`). `PageBase`, stamp per page file, `merge::merge_report -> Merged{value, notes}` (keep `merge` as a wrapper), changed file -> merge -> write -> `restore_snapshot` as "Merged changes from disk"; undecodable or newer format -> refuse with a message. Test: unchanged writes; changed merges and reports; refusal; one undo step; property test: no kept id lost. ## Wave 2: behaviour (after wave 1; 6, 8, 10, 11 parallel) -- [ ] 6. **Targets and reach** (`src/mcp/swarm/targets.rs`). `of(name, args, ed) -> Targets{nodes, pages, variables, resources, reach}`, `Reach::{None,Node,Children,Subtree}`, `resources_of`. Test: every tool in `tools::definitions()` classified (fails on a new unclassified tool); resolution of `nodeId`, `nodeIds`, `targetNodeId`, parent, page, `var(--x)` in html/styles. -- [ ] 7. **Conflict rule** (`src/mcp/swarm/conflicts.rs`, `tools.rs` schemas, settings). `ifRev`, `onConflict`, `seen[(client,page,artboard)]`, relations table, warn trailing block, strict `conflict` error before execution, `history_reset`, cap 20, Settings entry + `PAPERCRAFT_CONFLICTS`. Test: each relation +/-, mode precedence, strict changes nothing (doc, rev, history, runs), human reported, oracle comparison in `tests/swarm.rs` (`overlap`, `churn` 200 seeds). Depends on 6. -- [ ] 8. **Claims core** (`src/mcp/swarm/claims.rs`). State machine, overlap (same/ancestor/descendant), atomic all-or-nothing, own-claim merge, sliding TTL, limits, lazy expiry plus `tick`, parent-chain exemption, `Block`/`Warn`, resources, `Claim required`. Pure logic over ids and a clock, no UI. Test: the full state table and each auto-release row except those needing the app (project switch is a `Swarm::reset`). Depends on 3. -- [ ] 9. **Claim tools and delegate scope** (`src/mcp/tools.rs`, `src/mcp/exec.rs`, `src/app.rs`, `src/mcp/delegate.rs`). `claim_nodes`, `release_claims`, `list_claims`; `finish_working_on_nodes` releases; turn end (`app.rs:772` neighbourhood) releases chat claims; delegate `scope` required for `build`, claim taken before spawn on the UI thread, released when the helper ends; `claimed` / `needs_claim` errors. Update README and ROADMAP tool counts. Test in `tests/mcp.rs` per tool; stub-helper test for claim lifetime. Depends on 8. -- [ ] 10. **Per-client undo** (`src/history.rs`, `src/editor/mod.rs`, `src/mcp/exec.rs::undo_redo`, `tools.rs`). `Change.author/rev/step`, `History::undo_by/redo_by`, independence check, root set-difference, `scope` argument, default `mine` for agents, `undo_blocked` result, author in the status text for Ctrl+Z. Test: independence matrix, redo, defaults, Ctrl+Z unchanged, cross-page design steps. +- [ ] 6. **Targets and reach** (`src/mcp/swarm/targets.rs`). `of(name, args, ed) -> Targets{nodes, pages, variables, resources, reach}`, `Reach::{None,Node,Children,Subtree}`, `resources_of`. Test: every tool in `tools::definitions()` classified (fails on a new unclassified tool); resolution of `nodeId`, `nodeIds`, `targetNodeId`, parent, page, `var(--x)` in html/styles. *Partly done: `swarm::reach` and `exec::named_layers`; variable and page targets open.* +- [ ] 7. **Conflict rule** (`src/mcp/swarm/conflicts.rs`, `tools.rs` schemas, settings). `ifRev`, `onConflict`, `seen[(client,page,artboard)]`, relations table, warn trailing block, strict `conflict` error before execution, `history_reset`, cap 20, Settings entry + `PAPERCRAFT_CONFLICTS`. Test: each relation +/-, mode precedence, strict changes nothing (doc, rev, history, runs), human reported, oracle comparison in `tests/swarm.rs` (`overlap`, `churn` 200 seeds). Depends on 6. *Partly done: `ifRev`, `onConflict`, `PAPERCRAFT_CONFLICTS`, warn block, strict error, cap 20; settings entry open.* +- [x] 8. **Claims core** (`src/mcp/swarm/claims.rs`). State machine, overlap (same/ancestor/descendant), atomic all-or-nothing, own-claim merge, sliding TTL, limits, lazy expiry plus `tick`, parent-chain exemption, `Block`/`Warn`, resources, `Claim required`. Pure logic over ids and a clock, no UI. Test: the full state table and each auto-release row except those needing the app (project switch is a `Swarm::reset`). Depends on 3. +- [x] 9. **Claim tools and delegate scope** (`src/mcp/tools.rs`, `src/mcp/exec.rs`, `src/app.rs`, `src/mcp/delegate.rs`). `claim_nodes`, `release_claims`, `list_claims`; `finish_working_on_nodes` releases; turn end (`app.rs:772` neighbourhood) releases chat claims; delegate `scope` required for `build`, claim taken before spawn on the UI thread, released when the helper ends; `claimed` / `needs_claim` errors. Update README and ROADMAP tool counts. Test in `tests/mcp.rs` per tool; stub-helper test for claim lifetime. Depends on 8. +- [ ] 10. **Per-client undo** (`src/history.rs`, `src/editor/mod.rs`, `src/mcp/exec.rs::undo_redo`, `tools.rs`). `Change.author/rev/step`, `History::undo_by/redo_by`, independence check, root set-difference, `scope` argument, default `mine` for agents, `undo_blocked` result, author in the status text for Ctrl+Z. Test: independence matrix, redo, defaults, Ctrl+Z unchanged, cross-page design steps. *Partly done: `undo_by`, `scope`, `undo_blocked`, redo of own step; author in Ctrl+Z status open.* - [ ] 11. **Offline lease and app lock** (`src/cli.rs`, `src/session.rs::open`, `src/project/mod.rs`, `src/bin/papercraft-cli.rs`). Lock around each editing call; stamp-based per-page refresh; lock file records holder; `Session::open` takes the project lock with a 2 s wait and keeps it; error text distinguishes app from CLI. Test: 3-thread `OfflineSim` (3N + M edits), idle shell does not block, app open waits then reports the holder, other pages keep history. - [ ] 12. **Offline runs, log and claims file** (`src/cli.rs`, change-log persistence, `src/runs.rs`). `Tracker` in `Offline`, run closed at exit; `activity.json` (bounded, epoch) written under the lease and loaded by app and CLI; `claims.json` offline claims; app discards the file on open. Test: offline run and version exist and revert; roundtrip; bound; absent file. Depends on 2, 8, 11. diff --git a/src/bin/papercraft-cli.rs b/src/bin/papercraft-cli.rs index b7edf6a..d37bdca 100644 --- a/src/bin/papercraft-cli.rs +++ b/src/bin/papercraft-cli.rs @@ -306,8 +306,26 @@ fn runtime() -> Result { .map_err(|e| e.to_string()) } +/// Who this CLI is to other agents (`--as`, `--parent`, else `$PAPERCRAFT_AGENT`, +/// `$PAPERCRAFT_PARENT`): a named agent keeps one session across its calls and claims. +static AGENT: std::sync::OnceLock<(Option, Option)> = std::sync::OnceLock::new(); + +/// Percent-encoding for a query value. +fn encode(s: &str) -> String { + s.bytes() + .map(|b| if b.is_ascii_alphanumeric() || b"._-".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }) + .collect() +} + fn connect(rt: &tokio::runtime::Runtime) -> Result { - let url = mcp::url(mcp::port()); + let mut url = mcp::url(mcp::port()); + let (name, parent) = AGENT.get().cloned().unwrap_or_default(); + if let Some(name) = name { + url = format!("{url}?agent={}", encode(&name)); + if let Some(p) = parent { + url = format!("{url}&parent={}", encode(&p)); + } + } rt.block_on(async { ().serve(StreamableHttpClientTransport::from_uri(url.clone())).await }) .map_err(|e| format!("cannot reach papercraft at {url}: {e}. Is the app running?")) } @@ -742,6 +760,10 @@ fn main() -> ExitCode { let mut args: Vec = std::env::args().skip(1).collect(); let out = take_flag(&mut args, "--out", Some("-o")); let project = take_flag(&mut args, "--project", None); + let env = |k: &str| std::env::var(k).ok().filter(|v| !v.trim().is_empty()); + let agent = take_flag(&mut args, "--as", None).or_else(|| env("PAPERCRAFT_AGENT")); + let parent = take_flag(&mut args, "--parent", None).or_else(|| env("PAPERCRAFT_PARENT")); + AGENT.get_or_init(|| (agent, parent)); let offline = args.iter().any(|a| a == "--offline"); ONLINE_ONLY.store(args.iter().any(|a| a == "--online"), Ordering::Relaxed); args.retain(|a| a != "--offline" && a != "--online"); diff --git a/src/editor/design.rs b/src/editor/design.rs index beb7d4b..b79a727 100644 --- a/src/editor/design.rs +++ b/src/editor/design.rs @@ -168,7 +168,7 @@ impl Editor { } match self.take_rec() { Some((rec, key)) if keep => { - if self.log_touched(&rec) && self.history.commit(&self.doc, rec, key) { + if self.log_touched(&rec) && self.history.commit_by(&self.doc, rec, key, self.changes.client.as_ref().map(|w| w.key.clone())) { self.revision += 1; } } diff --git a/src/history.rs b/src/history.rs index 774e9f7..d0095c0 100644 --- a/src/history.rs +++ b/src/history.rs @@ -33,6 +33,8 @@ pub struct Change { annotations: Option<(Vec, Vec)>, next_id: (u64, u64), coalesce_key: Option, + /// The agent that made it (its client key); None for the person. + author: Option, } /// Project-level state a step carries besides the page document; the editor puts it back. @@ -538,6 +540,11 @@ pub struct History { impl History { /// Finalize a recording into a change. Returns false when nothing changed. pub fn commit(&mut self, doc: &Document, rec: Recording, coalesce_key: Option) -> bool { + self.commit_by(doc, rec, coalesce_key, None) + } + + /// [`History::commit`] for a change made by `author` (an agent's client key). + pub fn commit_by(&mut self, doc: &Document, rec: Recording, coalesce_key: Option, author: Option) -> bool { let entries: Vec = rec .order .iter() @@ -584,11 +591,13 @@ impl History { annotations, next_id: (rec.next_id_before, doc.next_id), coalesce_key, + author, }; self.generation += 1; if let (Some(key), Some(top)) = (&change.coalesce_key, self.undo.last_mut()) && self.redo.is_empty() && top.coalesce_key.as_ref() == Some(key) + && top.author == change.author { merge(top, change); return true; @@ -619,6 +628,34 @@ impl History { Some(u) } + /// Undoes `author`'s newest step only, leaving later steps by others in place, when none + /// of them touched what it touched (the same layers, the top-level order, variables, the + /// design system, modes or notes). Blocked: the labels of the later steps in the way. + pub fn undo_by(&mut self, doc: &mut Document, author: &str) -> Result, Vec> { + let Some(i) = self.undo.iter().rposition(|c| c.author.as_deref() == Some(author)) else { + return Ok(None); + }; + let mine = &self.undo[i]; + let blocked: Vec = self.undo[i + 1..].iter().filter(|l| overlaps(mine, l)).map(|l| l.label.clone()).collect(); + if !blocked.is_empty() { + return Err(blocked); + } + let c = self.undo.remove(i); + // Later steps may have made layers since: new ids keep counting up from there. + let next = doc.next_id; + c.apply(doc, false); + doc.next_id = doc.next_id.max(next); + self.generation += 1; + let u = c.undone(false); + self.redo.push(c); + Ok(Some(u)) + } + + /// Who made the step `redo` would bring back (None: the person, or nothing to redo). + pub fn redo_author(&self) -> Option<&str> { + self.redo.last().and_then(|c| c.author.as_deref()) + } + pub fn generation(&self) -> u64 { self.generation } @@ -666,6 +703,16 @@ impl History { } } +/// Two steps touched something in common, so one cannot be undone under the other. +fn overlaps(a: &Change, b: &Change) -> bool { + a.entries.iter().any(|e| b.entries.iter().any(|x| x.id == e.id)) + || (a.root.is_some() && b.root.is_some()) + || (a.variables.is_some() && b.variables.is_some()) + || (a.design.is_some() && b.design.is_some()) + || (a.page_modes.is_some() && b.page_modes.is_some()) + || (a.annotations.is_some() && b.annotations.is_some()) +} + /// Fold `newer` into `older`, keeping the oldest before-state of each node. fn merge(older: &mut Change, newer: Change) { for e in newer.entries { diff --git a/src/mcp/delegate.rs b/src/mcp/delegate.rs index e60126c..0ad7b27 100644 --- a/src/mcp/delegate.rs +++ b/src/mcp/delegate.rs @@ -34,7 +34,9 @@ pub fn stop(chat: &str) { /// Runs the helper `command` (`{prompt}` is passed as `$PAPERCRAFT_PROMPT`) for one task. /// `chat`: the chat that asked, so its Stop button ends the helper too. -pub fn run(command: &str, chat: Option<&str>, args: &serde_json::Value) -> ToolResult { +/// `agent`: the helper's name and the session of the agent that delegated it, passed to its +/// `papercraft-cli` calls so they are attributed to it and its claims. +pub fn run(command: &str, chat: Option<&str>, agent: (&str, &str), args: &serde_json::Value) -> ToolResult { if command.trim().is_empty() { return Err("No helper agent is set up. The user can choose one in Settings, Chat, Helper; do the work yourself.".into()); } @@ -56,6 +58,8 @@ pub fn run(command: &str, chat: Option<&str>, args: &serde_json::Value) -> ToolR std::os::unix::process::CommandExt::process_group(&mut cmd, 0); let mut child = cmd .env("PAPERCRAFT_PROMPT", &prompt) + .env("PAPERCRAFT_AGENT", agent.0) + .env("PAPERCRAFT_PARENT", agent.1) // An app opened from Finder has a bare PATH: give the helper the user's shell PATH. .env("PATH", crate::chat::path::resolved_path()) .current_dir(std::env::temp_dir()) @@ -147,16 +151,19 @@ mod tests { } // The task tries to run a command; it must arrive as plain text. let task = "check Home\"; touch pwned; echo \""; - let out = run("printf '%s' {prompt} | tail -c 40", None, &json!({"role": "qa", "task": task})).unwrap(); + let out = run("printf '%s' {prompt} | tail -c 40", None, ("Helper", ""), &json!({"role": "qa", "task": task})).unwrap(); let Out::Text(t) = &out[0] else { panic!() }; assert!(t.ends_with(task), "{t}"); assert!(!std::env::temp_dir().join("pwned").exists()); - assert!(run("", None, &json!({"role": "qa", "task": "x"})).unwrap_err().contains("Settings")); - assert!(run("echo", None, &json!({"role": "design", "task": "x"})).is_err()); - assert!(run("exit 3", None, &json!({"role": "read", "task": "x"})).is_err()); + assert!(run("", None, ("Helper", ""), &json!({"role": "qa", "task": "x"})).unwrap_err().contains("Settings")); + assert!(run("echo", None, ("Helper", ""), &json!({"role": "design", "task": "x"})).is_err()); + assert!(run("exit 3", None, ("Helper", ""), &json!({"role": "read", "task": "x"})).is_err()); + let who = run("printf '%s|%s' \"$PAPERCRAFT_AGENT\" \"$PAPERCRAFT_PARENT\"", None, ("Chat / build 1", "s1"), &json!({"role": "qa", "task": "x"})).unwrap(); + let Out::Text(t) = &who[0] else { panic!() }; + assert_eq!(t, "Chat / build 1|s1", "the helper's CLI calls carry its identity"); assert_eq!(tail("héllo", 4), "llo"); // Stop ends a running helper, and the process it started, at once. - let t = std::thread::spawn(|| run("sleep 30", Some("chat-stop-test"), &json!({"role": "qa", "task": "x"}))); + let t = std::thread::spawn(|| run("sleep 30", Some("chat-stop-test"), ("Helper", ""), &json!({"role": "qa", "task": "x"}))); std::thread::sleep(Duration::from_millis(500)); let begun = Instant::now(); stop("chat-stop-test"); diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 44ba9e0..8b3e79d 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -111,9 +111,13 @@ impl ServerHandler for PaperServer { /// backends connect that way), else the MCP client's title or name and its session id. fn client_of(context: &RequestContext) -> Client { let parts = context.extensions.get::(); - if let Some(id) = parts.and_then(|p| p.uri.query()).and_then(chat_of) { + let query = parts.and_then(|p| p.uri.query()); + if let Some(id) = query.and_then(chat_of) { return Client::chat(id); } + if let Some((name, parent)) = query.and_then(agent_of) { + return Client::agent(&name, parent); + } let name = context .client_info() .map(|i| i.title.filter(|t| !t.is_empty()).unwrap_or(i.name)) @@ -122,7 +126,7 @@ fn client_of(context: &RequestContext) -> Client { .and_then(|p| p.headers.get("mcp-session-id")) .and_then(|v| v.to_str().ok()) .unwrap_or_default(); - Client { name, session: session.into() } + Client { name, session: session.into(), parent: None } } /// The chat id in an MCP URL query (`chat=`). @@ -130,6 +134,47 @@ pub fn chat_of(query: &str) -> Option<&str> { query.split('&').find_map(|kv| kv.strip_prefix("chat=")) } +/// Names a person or the app itself uses, which an agent cannot take. +const RESERVED: [&str; 3] = ["You", super::CHAT_CLIENT, super::ACTIONS_CLIENT]; +/// Longest agent name (`--as`). +const MAX_AGENT_NAME: usize = 40; + +/// The agent name and parent session in an MCP URL query (`agent=&parent=`, from +/// `papercraft-cli --as`). A name outside `[A-Za-z0-9 ._/-]{1,40}` or a reserved one is ignored. +pub fn agent_of(query: &str) -> Option<(String, Option)> { + let get = |k: &str| query.split('&').find_map(|kv| kv.strip_prefix(k)).map(decode); + let name = get("agent=")?; + let ok = (1..=MAX_AGENT_NAME).contains(&name.chars().count()) + && name.chars().all(|c| c.is_ascii_alphanumeric() || " ._/-".contains(c)) + && !RESERVED.iter().any(|r| r.eq_ignore_ascii_case(name.trim())); + ok.then(|| (name, get("parent=").filter(|p| !p.is_empty()))) +} + +/// Percent-decoding of a query value (`+` is a space). +fn decode(s: &str) -> String { + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + let hex = b.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok()); + match (b[i], hex) { + (b'%', Some(v)) => { + out.push(v); + i += 3; + } + (b'+', _) => { + out.push(b' '); + i += 1; + } + (c, _) => { + out.push(c); + i += 1; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + /// Add the `Accept` header the streamable HTTP transport requires when a client omits it. /// Shared with the relay, which serves the same transport to remote agents. pub(crate) async fn fix_accept( @@ -260,6 +305,15 @@ pub fn spawn(port: u16, dispatch: Dispatch) -> Result { mod tests { use super::*; + #[test] + fn a_named_cli_agent_is_read_from_the_query() { + assert_eq!(agent_of("agent=Codex%20B&parent=s1"), Some(("Codex B".into(), Some("s1".into())))); + assert_eq!(agent_of("agent=Chat+%2F+build+1"), Some(("Chat / build 1".into(), None))); + assert_eq!(agent_of("agent=You"), None, "a reserved name"); + assert_eq!(agent_of("agent=a%3Bb"), None, "a character outside the set"); + assert_eq!(agent_of("chat=7"), None); + } + #[test] fn tools_deserialize_and_names_are_unique() { let tools = tool_list(); diff --git a/src/mcp/steer.rs b/src/mcp/steer.rs index 2fc8fd1..e566428 100644 --- a/src/mcp/steer.rs +++ b/src/mcp/steer.rs @@ -46,7 +46,7 @@ mod tests { add("steer-a", "make it blue"); let other = attach(&Client::chat("steer-b"), Ok(vec![])).unwrap(); assert!(other.is_empty()); - let mcp = Client { name: "Claude Code".into(), session: "steer-a".into() }; + let mcp = Client { name: "Claude Code".into(), session: "steer-a".into(), parent: None }; assert!(attach(&mcp, Ok(vec![])).unwrap().is_empty(), "only chat turns"); let outs = attach(&Client::chat("steer-a"), Ok(vec![])).unwrap(); assert!(matches!(&outs[..], [Out::Text(t)] if t.ends_with("make it blue"))); diff --git a/src/mcp/swarm/claims.rs b/src/mcp/swarm/claims.rs new file mode 100644 index 0000000..88f8e1f --- /dev/null +++ b/src/mcp/swarm/claims.rs @@ -0,0 +1,275 @@ +//! Claims: an agent's short lease on layers (each with everything under it) or on a shared +//! resource, so other agents work elsewhere (`specs/swarm-safety/spec.md`, section 3). +//! +//! Pure logic over ids and a clock. A claim is all-or-nothing, never waited on, and expires +//! `ttl` after its holder's last call, so no agent can hold the canvas for good and no two can +//! deadlock. The person at the canvas is never checked. + +use crate::model::NodeId; + +/// Live claims one agent may hold, and all agents together: a runaway agent cannot fence off +/// the canvas. +pub const MAX_PER_CLIENT: usize = 16; +pub const MAX_CLAIMS: usize = 64; +pub const DEFAULT_TTL_SECS: u64 = 300; +pub const MIN_TTL_SECS: u64 = 10; +pub const MAX_TTL_SECS: u64 = 1800; +/// Shared things that cannot be split by layer. +pub const RESOURCES: [&str; 3] = ["variables", "components", "pages"]; + +/// Who holds a claim. +#[derive(Debug, Clone, PartialEq)] +pub struct Holder { + /// `Client::key`: name and session. + pub key: String, + /// What the person and other agents see ("Codex", "Chat / build 1"). + pub label: String, + pub session: String, +} + +/// One layer of one page. +pub type Layer = (String, NodeId); + +#[derive(Debug, Clone)] +pub struct Claim { + pub id: String, + pub holder: Holder, + pub layers: Vec, + pub resources: Vec, + pub note: String, + pub ttl_ms: u64, + pub expires: u64, +} + +impl Claim { + pub fn expires_in_secs(&self, now: u64) -> u64 { + self.expires.saturating_sub(now).div_ceil(1000) + } +} + +/// What an agent asks to claim. +pub struct Request { + pub holder: Holder, + pub layers: Vec, + pub resources: Vec, + pub note: String, + pub ttl_secs: u64, +} + +/// A request that met someone else's claim. +#[derive(Debug, Clone, PartialEq)] +pub struct Denied { + /// The requested layer (or resource) that overlaps. + pub what: String, + /// `self`, `ancestor` (the claim covers a parent of it), `descendant` or `resource`. + pub relation: &'static str, + pub claim: String, +} + +/// How a requested layer relates to a claimed one: the same, inside it, or around it. +fn relation(want: &Layer, held: &Layer, ancestors: &dyn Fn(&Layer) -> Vec) -> Option<&'static str> { + if want.0 != held.0 { + return None; + } + if want.1 == held.1 { + Some("self") + } else if ancestors(want).contains(&held.1) { + Some("ancestor") + } else if ancestors(held).contains(&want.1) { + Some("descendant") + } else { + None + } +} + +#[derive(Debug, Default)] +pub struct Claims { + pub live: Vec, + next: u32, +} + +impl Claims { + /// Drops the claims whose time is up. + pub fn expire(&mut self, now: u64) { + self.live.retain(|c| c.expires > now); + } + + /// Any call by the holder keeps its claims alive. + pub fn renew(&mut self, key: &str, now: u64) { + for c in self.live.iter_mut().filter(|c| c.holder.key == key) { + c.expires = now + c.ttl_ms; + } + } + + /// Other agents' claims that `layers` or `resources` overlap; `exempt` holders (an agent's + /// own, or one it delegated to) are skipped. + pub fn overlaps( + &self, + layers: &[Layer], + resources: &[String], + exempt: &dyn Fn(&Holder) -> bool, + ancestors: &dyn Fn(&Layer) -> Vec, + ) -> Vec { + let mut out = Vec::new(); + for c in self.live.iter().filter(|c| !exempt(&c.holder)) { + for want in layers { + if let Some(rel) = c.layers.iter().find_map(|held| relation(want, held, ancestors)) { + out.push(Denied { what: format!("{}:{}", want.0, want.1.0), relation: rel, claim: c.id.clone() }); + } + } + for r in resources.iter().filter(|r| c.resources.contains(r)) { + out.push(Denied { what: r.clone(), relation: "resource", claim: c.id.clone() }); + } + } + out + } + + /// Claims the request's layers and resources for its holder, all or nothing. Overlap with + /// the holder's own claim merges into it; overlap with anyone else's (but `exempt` + /// holders') denies the whole request. + pub fn claim(&mut self, req: Request, now: u64, exempt: &dyn Fn(&Holder) -> bool, ancestors: &dyn Fn(&Layer) -> Vec) -> Result> { + let Request { holder, layers, resources, note, ttl_secs } = req; + self.expire(now); + let mine = |h: &Holder| h.key == holder.key || exempt(h); + let denied = self.overlaps(&layers, &resources, &mine, ancestors); + if !denied.is_empty() { + return Err(denied); + } + let ttl_ms = ttl_secs.clamp(MIN_TTL_SECS, MAX_TTL_SECS) * 1000; + // Merge into the holder's claim that overlaps (or its only one), so a lease grows + // instead of piling up. + let own = self.live.iter().position(|c| { + c.holder.key == holder.key + && (layers.iter().any(|w| c.layers.iter().any(|h| relation(w, h, ancestors).is_some())) + || resources.iter().any(|r| c.resources.contains(r))) + }); + if let Some(i) = own { + let c = &mut self.live[i]; + for l in layers { + if !c.layers.contains(&l) { + c.layers.push(l); + } + } + for r in resources { + if !c.resources.contains(&r) { + c.resources.push(r); + } + } + if !note.is_empty() { + c.note = note; + } + c.ttl_ms = c.ttl_ms.max(ttl_ms); + c.expires = now + c.ttl_ms; + return Ok(c.id.clone()); + } + if self.live.iter().filter(|c| c.holder.key == holder.key).count() >= MAX_PER_CLIENT { + return Err(vec![Denied { what: format!("{MAX_PER_CLIENT} claims"), relation: "limit", claim: String::new() }]); + } + if self.live.len() >= MAX_CLAIMS { + return Err(vec![Denied { what: format!("{MAX_CLAIMS} claims on the project"), relation: "limit", claim: String::new() }]); + } + self.next += 1; + let id = format!("c{}", self.next); + self.live.push(Claim { id: id.clone(), holder, layers, resources, note, ttl_ms, expires: now + ttl_ms }); + Ok(id) + } + + /// Releases the holder's claims: the one with `id`, those covering `layers`, or all of them. + /// Returns the released ids. + pub fn release(&mut self, key: &str, id: Option<&str>, layers: &[Layer]) -> Vec { + let hit = |c: &Claim| { + c.holder.key == key && id.is_none_or(|i| c.id == i) && (layers.is_empty() || c.layers.iter().any(|l| layers.contains(l))) + }; + let gone: Vec = self.live.iter().filter(|c| hit(c)).map(|c| c.id.clone()).collect(); + self.live.retain(|c| !hit(c)); + gone + } + + /// Releases every claim of the holders `drop` picks (a finished chat turn, a project switch). + pub fn release_where(&mut self, drop: impl Fn(&Holder) -> bool) { + self.live.retain(|c| !drop(&c.holder)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn who(k: &str) -> Holder { + Holder { key: k.into(), label: k.into(), session: k.into() } + } + + /// A page "p" where 2 sits in 1 and 3 in 2. + fn ancestors(l: &Layer) -> Vec { + match l.1.0 { + 3 => vec![NodeId(2), NodeId(1)], + 2 => vec![NodeId(1)], + _ => vec![], + } + } + + fn at(n: u64) -> Layer { + ("p".into(), NodeId(n)) + } + + fn req(k: &str, layers: Vec, resources: Vec) -> Request { + Request { holder: who(k), layers, resources, note: String::new(), ttl_secs: 60 } + } + + fn none(_: &Holder) -> bool { + false + } + + #[test] + fn a_claim_covers_its_subtree_and_denies_around_and_inside_it() { + let mut c = Claims::default(); + c.claim(req("a", vec![at(2)], vec![]), 0, &none, &ancestors).unwrap(); + for (n, rel) in [(2, "self"), (3, "ancestor"), (1, "descendant")] { + let d = c.claim(req("b", vec![at(n)], vec![]), 0, &none, &ancestors).unwrap_err(); + assert_eq!(d[0].relation, rel, "layer {n}"); + } + assert!(c.claim(req("b", vec![at(9)], vec![]), 0, &none, &ancestors).is_ok()); + } + + #[test] + fn a_denied_request_claims_nothing() { + let mut c = Claims::default(); + c.claim(req("a", vec![at(2)], vec![]), 0, &none, &ancestors).unwrap(); + assert!(c.claim(req("b", vec![at(9), at(3)], vec![]), 0, &none, &ancestors).is_err()); + assert!(c.live.iter().all(|x| x.holder.key == "a"), "9 was not claimed either"); + } + + #[test] + fn own_claims_merge_and_leases_slide_until_the_holder_goes_quiet() { + let mut c = Claims::default(); + let first = c.claim(req("a", vec![at(1)], vec![]), 0, &none, &ancestors).unwrap(); + let again = c.claim(req("a", vec![at(3)], vec![]), 1_000, &none, &ancestors).unwrap(); + assert_eq!((first.clone(), c.live.len()), (again, 1)); + c.renew("a", 50_000); + c.expire(100_000); + assert_eq!(c.live.len(), 1, "renewed at 50 s, lives to 110 s"); + c.expire(110_001); + assert!(c.live.is_empty()); + } + + #[test] + fn resources_and_release() { + let mut c = Claims::default(); + c.claim(req("a", vec![], vec!["variables".into()]), 0, &none, &ancestors).unwrap(); + let d = c.overlaps(&[], &["variables".into()], &none, &ancestors); + assert_eq!(d[0].relation, "resource"); + assert!(c.overlaps(&[], &["variables".into()], &|h| h.key == "a", &ancestors).is_empty(), "exempt holder"); + assert_eq!(c.release("b", None, &[]), Vec::::new(), "only the holder releases"); + assert_eq!(c.release("a", None, &[]).len(), 1); + } + + #[test] + fn limits_stop_a_runaway_agent() { + let mut c = Claims::default(); + for n in 0..MAX_PER_CLIENT as u64 { + c.claim(req("a", vec![at(100 + n)], vec![]), 0, &none, &ancestors).unwrap(); + } + let d = c.claim(req("a", vec![at(500)], vec![]), 0, &none, &ancestors).unwrap_err(); + assert_eq!(d[0].relation, "limit"); + } +} diff --git a/src/mcp/swarm/mod.rs b/src/mcp/swarm/mod.rs new file mode 100644 index 0000000..783886a --- /dev/null +++ b/src/mcp/swarm/mod.rs @@ -0,0 +1,362 @@ +//! Several agents on one project (`specs/swarm-safety/spec.md`). Every call from the app's MCP +//! server and chat goes through [`Swarm::call`]: a write is checked against other agents' +//! claims (blocked) and against the layers others changed since this agent last looked at that +//! artboard (reported, or refused when the caller passed `ifRev`), then runs through the run +//! tracker, and what the check found rides back in a trailing `{"swarm": ...}` block. +//! +//! Nothing here waits: a check answers at once, so agents cannot deadlock. The person at the +//! canvas is never checked; their edits reach agents as conflicts by `You`. + +pub mod claims; + +use super::exec::{self, Host}; +use super::{Client, Out, ToolResult}; +use crate::editor::Editor; +use crate::editor::changes::Filter; +use crate::model::NodeId; +use crate::runs::Tracker; +use claims::{Claims, Denied, Holder, Layer, Request}; +use serde_json::{Value, json}; +use std::collections::HashMap; +use std::path::PathBuf; + +/// Conflicts listed per call; the rest are counted. +const MAX_CONFLICTS: usize = 20; +/// Parent links followed when deciding whether a claim belongs to an agent's own helper. +const MAX_DELEGATION_DEPTH: usize = 8; +/// A delegated helper's claim lasts as long as the helper may run. +pub const HELPER_TTL_SECS: u64 = 900; + +/// How far a write reaches below the layers it names. +#[derive(Debug, Clone, Copy, PartialEq)] +enum Reach { + /// The layer's own fields. + Node, + /// Adds layers under it: two agents appending to one frame do not conflict. + Children, + /// Rebuilds, moves or deletes it with everything under it. + Subtree, +} + +fn reach(name: &str, args: &Value) -> Reach { + match name { + "write_html" if args["mode"] == "replace" => Reach::Subtree, + "write_html" | "create_shape" | "duplicate_nodes" | "create_instance" | "create_artboard" | "annotate" => Reach::Children, + "delete_nodes" | "move_nodes" | "detach_instance" | "swap_instance" => Reach::Subtree, + _ => Reach::Node, + } +} + +/// The shared resource a tool changes, which a claim can hold for one agent. +fn resource_of(name: &str) -> Option<&'static str> { + match name { + "set_variables" | "delete_variables" | "create_collection" | "set_variable_mode" | "set_tokens" | "import_design_system" + | "enable_library" | "update_library" | "disable_library" => Some("variables"), + "create_component" | "import_components" | "merge_variants" | "set_component_props" | "apply_ui_kit" => Some("components"), + "create_page" | "rename_pages" | "delete_pages" => Some("pages"), + _ => None, + } +} + +#[derive(Debug, Default)] +pub struct Swarm { + pub claims: Claims, + /// Revision each client last read or wrote each artboard at: what its picture of it is from. + seen: HashMap<(String, String, NodeId), u64>, + /// Revision of each client's first call, for artboards it never looked at. + first: HashMap, + /// Session of the agent that delegated each session. + parents: HashMap, + project: Option, +} + +/// The page id and the layer of each (page index, node) pair. +fn layers(ed: &Editor, named: &[(usize, NodeId)]) -> Vec { + named.iter().map(|(p, id)| (ed.pages[*p].id.clone(), *id)).collect() +} + +fn ancestors_in(ed: &Editor) -> impl Fn(&Layer) -> Vec + '_ { + |l: &Layer| ed.pages.iter().position(|p| p.id == l.0).map_or_else(Vec::new, |i| ed.page_doc(i).ancestors(l.1)) +} + +fn holder(tracker: &mut Tracker, client: &Client) -> Holder { + Holder { key: client.key(), label: tracker.label(client), session: client.session.clone() } +} + +fn denied_json(claims: &Claims, denied: &[Denied], now: u64) -> Vec { + denied + .iter() + .map(|d| { + let c = claims.live.iter().find(|c| c.id == d.claim); + json!({ + "id": d.what, "relation": d.relation, "claimId": d.claim, + "heldBy": c.map(|c| c.holder.label.clone()), + "note": c.map(|c| c.note.clone()), + "expiresInSeconds": c.map(|c| c.expires_in_secs(now)), + }) + }) + .collect() +} + +impl Swarm { + /// Another project means other layers: claims and what agents saw no longer apply. + fn sync_project(&mut self, dir: Option) { + if dir != self.project { + *self = Swarm { project: dir, parents: std::mem::take(&mut self.parents), ..Swarm::default() }; + } + } + + /// `session` was delegated (directly or through helpers) by `ancestor`. + fn delegated_by(&self, session: &str, ancestor: &str) -> bool { + let mut cur = session; + for _ in 0..MAX_DELEGATION_DEPTH { + match self.parents.get(cur) { + Some(p) if p == ancestor => return true, + Some(p) => cur = p, + None => return false, + } + } + false + } + + /// Run one call for `client`: the claim tools here, everything else checked, then through + /// the run tracker. + pub fn call(&mut self, tracker: &mut Tracker, host: &mut dyn Host, client: &Client, name: &str, args: &Value, now: u64) -> ToolResult { + self.sync_project(host.project().map(|p| p.dir.clone())); + if let Some(p) = &client.parent { + self.parents.insert(client.session.clone(), p.clone()); + } + self.claims.expire(now); + self.claims.renew(&client.key(), now); + let key = client.key(); + let start = host.editor().changes.rev(); + self.first.entry(key.clone()).or_insert(start); + match name { + "claim_nodes" => return self.claim_tool(tracker, host, client, args, now), + "release_claims" => return self.release_tool(host, client, args), + "list_claims" => return text(self.list(host.editor(), now)), + _ => {} + } + let ed = host.editor(); + ed.finish_loading(); + let named = exec::named_layers(ed, args); + let mut notes = serde_json::Map::new(); + if crate::mcp::tools::edits_doc(name, args) { + let wanted = layers(ed, &named); + let resources: Vec = resource_of(name).map(String::from).into_iter().collect(); + let found = self.claims.overlaps(&wanted, &resources, &|h| h.key == key, &ancestors_in(ed)); + // An agent may work inside its own helpers' claims; it is told, not stopped. + let (warn, block): (Vec, Vec) = found.into_iter().partition(|d| { + self.claims.live.iter().find(|c| c.id == d.claim).is_some_and(|c| self.delegated_by(&c.holder.session, &client.session)) + }); + if !block.is_empty() { + return Err(json!({ + "error": "claimed", "applied": false, + "claims": denied_json(&self.claims, &block, now), + "hint": "Another agent is working there. Work elsewhere, or retry after expiresInSeconds; never wait in a loop.", + }) + .to_string()); + } + if !warn.is_empty() { + notes.insert("claimWarnings".into(), json!(denied_json(&self.claims, &warn, now))); + } + let if_rev = args["ifRev"].as_u64(); + let reference = if_rev.unwrap_or_else(|| self.reference(&key, ed, &named)); + let strict = if_rev.is_some() && args["onConflict"] != "warn" + || std::env::var("PAPERCRAFT_CONFLICTS").is_ok_and(|v| v == "strict") && args["onConflict"] != "warn"; + let conflicts = conflicts(ed, &key, reference, &named, reach(name, args)); + if strict && !conflicts.is_empty() { + return Err(json!({ + "error": "conflict", "applied": false, "rev": ed.changes.rev(), "refRev": reference, + "conflicts": conflicts, + "hint": format!("Re-read the layers above (get_changes {{since: {reference}, exclude: \"self\"}}), then retry with ifRev {}.", ed.changes.rev()), + }) + .to_string()); + } + if !conflicts.is_empty() { + notes.insert("refRev".into(), json!(reference)); + notes.insert( + "hint".into(), + json!(format!("Others changed layers you just wrote. get_changes {{since: {reference}, exclude: \"self\"}}, re-read those layers, adjust; do not retry blindly.")), + ); + notes.insert("conflicts".into(), json!(conflicts)); + } + } + let artboards: Vec<(String, NodeId)> = named.iter().map(|(p, id)| (ed.pages[*p].id.clone(), artboard(ed, *p, *id))).collect(); + let r = tracker.call(host, client, name, args, now); + if r.is_ok() && name == "finish_working_on_nodes" { + let mine = layers(host.editor(), &named); + self.claims.release(&key, None, &mine); + } + let rev = host.editor().changes.rev(); + for (page, a) in artboards { + self.seen.insert((key.clone(), page, a), rev); + } + if notes.is_empty() { + return r; + } + notes.insert("rev".into(), json!(rev)); + notes.insert("applied".into(), json!(r.is_ok())); + r.map(|mut out| { + out.push(Out::Text(json!({"swarm": notes}).to_string())); + out + }) + } + + /// The oldest revision the client last saw any of these layers' artboards at (its first + /// call's for an artboard it never looked at): coarse, so it over-reports rather than misses. + fn reference(&self, key: &str, ed: &Editor, named: &[(usize, NodeId)]) -> u64 { + let first = self.first.get(key).copied().unwrap_or(0); + named + .iter() + .map(|(p, id)| self.seen.get(&(key.to_string(), ed.pages[*p].id.clone(), artboard(ed, *p, *id))).copied().unwrap_or(first)) + .min() + .unwrap_or(first) + } + + fn claim_tool(&mut self, tracker: &mut Tracker, host: &mut dyn Host, client: &Client, args: &Value, now: u64) -> ToolResult { + let ed = host.editor(); + ed.finish_loading(); + let keys: Vec = args["ids"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()).unwrap_or_default(); + let named = exec::named_layers(ed, &json!({"nodeIds": keys, "pageId": args["pageId"]})); + if named.len() < keys.len() { + return Err("some ids are not layers of this project (ids look like \"3fa9c1:14\")".into()); + } + let resources: Vec = args["resources"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()).unwrap_or_default(); + if let Some(bad) = resources.iter().find(|r| !claims::RESOURCES.contains(&r.as_str())) { + return Err(format!("no resource `{bad}` (resources: {})", claims::RESOURCES.join(", "))); + } + if named.is_empty() && resources.is_empty() { + return Err("claim ids, resources or both".into()); + } + let ttl = args["ttlSeconds"].as_u64().unwrap_or(claims::DEFAULT_TTL_SECS); + let note: String = args["note"].as_str().unwrap_or("").chars().take(120).collect(); + let req = Request { holder: holder(tracker, client), layers: layers(ed, &named), resources: resources.clone(), note, ttl_secs: ttl }; + let me = client.session.clone(); + let mine = |h: &Holder| self.delegated_by(&h.session, &me); + let exempt: Vec = self.claims.live.iter().filter(|c| mine(&c.holder)).map(|c| c.holder.key.clone()).collect(); + match self.claims.claim(req, now, &|h| exempt.contains(&h.key), &ancestors_in(ed)) { + Ok(id) => { + let c = self.claims.live.iter().find(|c| c.id == id).ok_or("claim vanished")?; + text(json!({ + "claimId": id, + "claimed": named.iter().map(|(p, n)| exec::node_key(ed, *p, *n)).collect::>(), + "resources": resources, + "ttlSeconds": c.ttl_ms / 1000, + "rev": ed.changes.rev(), + })) + } + Err(denied) => text(json!({"claimed": [], "denied": denied_json(&self.claims, &denied, now), + "hint": "Nothing was claimed. Do other work first or report back; never wait in a loop."})), + } + } + + fn release_tool(&mut self, host: &mut dyn Host, client: &Client, args: &Value) -> ToolResult { + let ed = host.editor(); + let named = exec::named_layers(ed, &json!({"nodeIds": args["ids"], "pageId": args["pageId"]})); + let mut released = self.claims.release(&client.key(), args["claimId"].as_str(), &layers(ed, &named)); + if let Some(rs) = args["resources"].as_array() { + for c in self.claims.live.iter_mut().filter(|c| c.holder.key == client.key()) { + c.resources.retain(|r| !rs.iter().any(|x| x == r.as_str())); + } + let empty: Vec = self.claims.live.iter().filter(|c| c.layers.is_empty() && c.resources.is_empty()).map(|c| c.id.clone()).collect(); + self.claims.live.retain(|c| !empty.contains(&c.id)); + released.extend(empty); + } + text(json!({"released": released})) + } + + fn list(&self, ed: &Editor, now: u64) -> Value { + let key = |l: &Layer| ed.pages.iter().position(|p| p.id == l.0).map(|p| exec::node_key(ed, p, l.1)); + json!({"claims": self.claims.live.iter().map(|c| json!({ + "claimId": c.id, "holder": c.holder.label, "ids": c.layers.iter().filter_map(key).collect::>(), + "resources": c.resources, "note": c.note, "expiresInSeconds": c.expires_in_secs(now), + })).collect::>()}) + } + + /// Claims a delegated helper's `scope` for it before it starts, so its work is fenced off + /// from the start. Returns the helper's identity, or the denial to give the caller. + pub fn claim_for_helper(&mut self, tracker: &mut Tracker, ed: &mut Editor, caller: &Client, role: &str, scope: &Value, now: u64) -> Result { + ed.finish_loading(); + let n = self.parents.values().filter(|p| **p == caller.session).count() + 1; + let helper = Client::agent(&format!("{} / {role} {n}", tracker.label(caller)), Some(caller.session.clone())); + self.parents.insert(helper.session.clone(), caller.session.clone()); + if role != "build" { + return Ok(helper); + } + let keys = scope["ids"].as_array().filter(|a| !a.is_empty()); + let resources: Vec = scope["resources"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()).unwrap_or_default(); + if keys.is_none() && resources.is_empty() { + return Err("a build helper needs `scope` {ids, resources}: the layers it may change, so other agents keep out".into()); + } + let named = exec::named_layers(ed, &json!({"nodeIds": keys})); + let req = Request { holder: holder(tracker, &helper), layers: layers(ed, &named), resources, note: "delegated build".into(), ttl_secs: HELPER_TTL_SECS }; + let me = caller.key(); + self.claims + .claim(req, now, &|h| h.key == me, &ancestors_in(ed)) + .map(|_| helper) + .map_err(|d| json!({"error": "claimed", "denied": denied_json(&self.claims, &d, now), "hint": "Give the helper layers no one else holds."}).to_string()) + } + + /// A helper ended, a chat turn finished: its claims go. + pub fn release_client(&mut self, client: &Client) { + let key = client.key(); + self.claims.release_where(|h| h.key == key); + } + + /// Every chat's claims go (the chat turn is over). + pub fn release_chats(&mut self) { + self.claims.release_where(|h| h.key.starts_with(&format!("{}#", super::CHAT_CLIENT))); + } +} + +/// The top-level layer `id` sits in (itself when top-level). +fn artboard(ed: &Editor, page: usize, id: NodeId) -> NodeId { + ed.page_doc(page).ancestors(id).last().copied().unwrap_or(id) +} + +/// Layers named by a write that others (anyone but `me`, the person included) changed after +/// `since`: the layer itself, or for a subtree write anything under it. +fn conflicts(ed: &Editor, me: &str, since: u64, named: &[(usize, NodeId)], reach: Reach) -> Vec { + if reach == Reach::Children { + return Vec::new(); + } + let mut out: Vec = Vec::new(); + let mut more = 0; + for (p, id) in named { + let page = &ed.pages[*p].id; + let doc = ed.page_doc(*p); + let changed = ed.changes.since(since, Some(page), Filter::Exclude(me)); + if changed.truncated { + return vec![json!({"relation": "history_reset", "hint": "the change log does not reach back that far; re-read what you are about to change"})]; + } + for g in changed.changes { + let hit = g.ids.iter().find_map(|c| { + if c == id { + Some("self") + } else if reach == Reach::Subtree && doc.ancestors(*c).contains(id) { + Some("descendant") + } else { + None + } + }); + let Some(relation) = hit else { continue }; + if out.len() >= MAX_CONFLICTS { + more += 1; + continue; + } + out.push(json!({ + "node": exec::node_key(ed, *p, *id), "name": doc.get(*id).map(|n| n.name.clone()), + "relation": relation, "by": g.by.unwrap_or_else(|| "You".into()), "rev": g.rev, + })); + } + } + if more > 0 { + out.push(json!({"more": more})); + } + out +} + +fn text(v: Value) -> ToolResult { + Ok(vec![Out::Text(v.to_string())]) +} diff --git a/src/runs.rs b/src/runs.rs index 9382db2..d659d89 100644 --- a/src/runs.rs +++ b/src/runs.rs @@ -529,7 +529,7 @@ impl Tracker { now: u64, ) -> ToolResult { let who = crate::editor::changes::Who { - key: format!("{}#{}", client.name, client.session), + key: client.key(), label: self.label(client), }; host.editor().changes.client = Some(std::sync::Arc::new(who)); @@ -1649,7 +1649,7 @@ mod tests { } fn who(name: &str, session: &str) -> Client { - Client { name: name.into(), session: session.into() } + Client { name: name.into(), session: session.into(), parent: None } } fn frame_as(t: &mut Tracker, h: &mut SessionHost, c: &Client, now: u64) -> NodeId { diff --git a/src/ui/actions.rs b/src/ui/actions.rs index b7d0a1f..a379b4b 100644 --- a/src/ui/actions.rs +++ b/src/ui/actions.rs @@ -123,7 +123,7 @@ pub fn dispatch(ed: &mut Editor, st: &mut UiState, act: Act) { /// The run's title is the summary the toast shows; nothing changed (or an error) goes to the /// status line. pub fn run(host: &mut dyn Host, runs: &mut Tracker, act: Act, keys: &[String], now: u64) { - let client = Client { name: ACTIONS_CLIENT.into(), session: String::new() }; + let client = Client { name: ACTIONS_CLIENT.into(), session: String::new(), parent: None }; let undo_from = host.editor().history.undo_len(); let mut tool = |host: &mut dyn Host, name: &str, args: Value| -> Result { let out = runs.call(host, &client, name, &args, now)?; diff --git a/tests/swarm.rs b/tests/swarm.rs new file mode 100644 index 0000000..facbf0e --- /dev/null +++ b/tests/swarm.rs @@ -0,0 +1,174 @@ +//! Several agents on one project through the entry the app uses (`Swarm::call` over the run +//! tracker): conflicts are reported or refused, claims fence layers off, and agents working +//! apart never hear of each other. + +use papercraft::mcp::swarm::Swarm; +use papercraft::mcp::{Client, Out, exec}; +use papercraft::project::Store; +use papercraft::runs::Tracker; +use serde_json::{Value, json}; + +struct Sim { + host: exec::SessionHost, + tracker: Tracker, + swarm: Swarm, + now: u64, +} + +fn sim() -> Sim { + let store = Store::open(std::env::temp_dir().join(format!("papercraft-swarm-{:016x}", fastrand::u64(..)))).unwrap(); + let mut host = exec::SessionHost::new(store); + host.session.create(&mut host.ed, "Swarm test").unwrap(); + Sim { host, tracker: Tracker::default(), swarm: Swarm::default(), now: 1_000 } +} + +fn agent(name: &str) -> Client { + Client::agent(name, None) +} + +impl Sim { + /// The tool's own JSON and the trailing swarm block, if any. + fn call(&mut self, who: &Client, name: &str, args: Value) -> Result<(Value, Value), String> { + self.now += 1_000; + let outs = self.swarm.call(&mut self.tracker, &mut self.host, who, name, &args, self.now)?; + let texts: Vec = outs + .iter() + .filter_map(|o| match o { + Out::Text(t) => serde_json::from_str(t).ok(), + Out::Image { .. } => None, + }) + .collect(); + let swarm = texts.iter().find_map(|v| v.get("swarm").cloned()).unwrap_or(Value::Null); + Ok((texts.into_iter().next().unwrap_or(Value::Null), swarm)) + } + + fn ok(&mut self, who: &Client, name: &str, args: Value) -> (Value, Value) { + self.call(who, name, args).unwrap_or_else(|e| panic!("{name}: {e}")) + } + + fn board(&mut self, who: &Client, name: &str, x: u32) -> Value { + let ab = self.ok(who, "create_artboard", json!({"name": name, "styles": {"width": "390px", "height": "844px", "left": format!("{x}px"), "top": "0px"}})).0; + ab["id"].clone() + } +} + +#[test] +fn agents_on_separate_artboards_never_hear_of_each_other() { + let mut s = sim(); + let (a, b) = (agent("A"), agent("B")); + let x = s.board(&a, "X", 0); + let y = s.board(&b, "Y", 600); + for _ in 0..3 { + assert!(s.ok(&a, "update_styles", json!({"updates": [{"nodeIds": [x], "styles": {"opacity": 0.9}}]})).1.is_null()); + assert!(s.ok(&b, "update_styles", json!({"updates": [{"nodeIds": [y], "styles": {"opacity": 0.8}}]})).1.is_null()); + } +} + +#[test] +fn a_write_over_someone_elses_newer_change_is_reported_or_refused() { + let mut s = sim(); + let (a, b) = (agent("A"), agent("B")); + let x = s.board(&a, "X", 0); + let rev = s.ok(&a, "get_basic_info", json!({})).0["rev"].as_u64().unwrap(); + let after_b = s.ok(&b, "update_styles", json!({"updates": [{"nodeIds": [x], "styles": {"opacity": 0.5}}]})).0["rev"].clone(); + // Strict: A says what it last saw; nothing is applied. + let err = s.call(&a, "update_styles", json!({"updates": [{"nodeIds": [x], "styles": {"opacity": 0.7}}], "ifRev": rev})).unwrap_err(); + let err: Value = serde_json::from_str(&err).unwrap(); + assert_eq!(err["error"], "conflict"); + assert_eq!(err["conflicts"][0]["by"], "B"); + assert_eq!(s.ok(&a, "get_basic_info", json!({})).0["rev"], after_b, "the refused write changed nothing"); + // Warn (no ifRev): applied, and the conflict comes back with it. + let (_, swarm) = s.ok(&a, "update_styles", json!({"updates": [{"nodeIds": [x], "styles": {"opacity": 0.7}}]})); + assert_eq!(swarm["conflicts"][0]["relation"], "self", "{swarm}"); + // Having written it, A is up to date: the next write is quiet. + assert!(s.ok(&a, "update_styles", json!({"updates": [{"nodeIds": [x], "styles": {"opacity": 0.6}}]})).1.is_null()); +} + +#[test] +fn appending_to_the_same_frame_does_not_conflict() { + let mut s = sim(); + let (a, b) = (agent("A"), agent("B")); + let x = s.board(&a, "X", 0); + s.ok(&b, "get_children", json!({"nodeId": x})); + s.ok(&a, "write_html", json!({"targetNodeId": x, "html": "
from A
"})); + let (_, swarm) = s.ok(&b, "write_html", json!({"targetNodeId": x, "html": "
from B
"})); + assert!(swarm.is_null(), "{swarm}"); +} + +#[test] +fn a_claim_fences_off_a_subtree_until_it_is_released() { + let mut s = sim(); + let (a, b) = (agent("A"), agent("B")); + let x = s.board(&a, "X", 0); + let child = s.ok(&a, "write_html", json!({"targetNodeId": x, "html": "
Header
"})).0["createdNodeIds"][0].clone(); + let claimed = s.ok(&a, "claim_nodes", json!({"ids": [x], "note": "rebuilding X"})).0; + assert!(claimed["claimId"].is_string(), "{claimed}"); + let err = s.call(&b, "update_styles", json!({"updates": [{"nodeIds": [child], "styles": {"opacity": 0.5}}]})).unwrap_err(); + assert!(err.contains("\"claimed\"") && err.contains("rebuilding X"), "{err}"); + let denied = s.ok(&b, "claim_nodes", json!({"ids": [child]})).0; + assert_eq!(denied["denied"][0]["relation"], "ancestor", "{denied}"); + assert_eq!(s.ok(&b, "list_claims", json!({})).0["claims"][0]["holder"], "A"); + s.ok(&a, "update_styles", json!({"updates": [{"nodeIds": [child], "styles": {"opacity": 0.9}}]})); + s.ok(&a, "finish_working_on_nodes", json!({})); + assert!(s.call(&b, "update_styles", json!({"updates": [{"nodeIds": [child], "styles": {"opacity": 0.5}}]})).is_ok(), "released"); +} + +#[test] +fn a_claim_expires_when_its_holder_goes_quiet() { + let mut s = sim(); + let (a, b) = (agent("A"), agent("B")); + let x = s.board(&a, "X", 0); + s.ok(&a, "claim_nodes", json!({"ids": [x], "ttlSeconds": 10})); + s.now += 11_000; + assert!(s.call(&b, "update_styles", json!({"updates": [{"nodeIds": [x], "styles": {"opacity": 0.5}}]})).is_ok()); +} + +#[test] +fn a_build_helper_gets_its_scope_and_its_caller_may_still_step_in() { + let mut s = sim(); + let (lead, other) = (agent("Lead"), agent("Other")); + let x = s.board(&lead, "X", 0); + let ed = &mut s.host.ed; + assert!(s.swarm.claim_for_helper(&mut s.tracker, ed, &lead, "build", &json!(null), s.now).is_err(), "a build needs a scope"); + let helper = s.swarm.claim_for_helper(&mut s.tracker, &mut s.host.ed, &lead, "build", &json!({"ids": [x]}), s.now).unwrap(); + assert_eq!(helper.parent.as_deref(), Some("agent:Lead")); + assert!(s.call(&other, "update_styles", json!({"updates": [{"nodeIds": [x], "styles": {"opacity": 0.5}}]})).is_err(), "others keep out"); + let (_, swarm) = s.ok(&lead, "update_styles", json!({"updates": [{"nodeIds": [x], "styles": {"opacity": 0.5}}]})); + assert!(swarm["claimWarnings"].is_array(), "the caller is told, not stopped: {swarm}"); + s.ok(&helper, "update_styles", json!({"updates": [{"nodeIds": [x], "styles": {"opacity": 0.4}}]})); + s.swarm.release_client(&helper); + assert!(s.call(&other, "update_styles", json!({"updates": [{"nodeIds": [x], "styles": {"opacity": 0.5}}]})).is_ok()); +} + +#[test] +fn an_agent_undoes_its_own_step_and_leaves_the_others() { + let mut s = sim(); + let (a, b) = (agent("A"), agent("B")); + let x = s.board(&a, "X", 0); + let y = s.board(&b, "Y", 600); + s.ok(&a, "update_styles", json!({"updates": [{"nodeIds": [x], "styles": {"opacity": 0.5}}]})); + s.ok(&b, "update_styles", json!({"updates": [{"nodeIds": [y], "styles": {"opacity": 0.4}}]})); + let undone = s.ok(&a, "undo", json!({})).0; + assert_eq!(undone["done"], true, "{undone}"); + let opacity = |s: &mut Sim, who: &Client, id: &Value| s.ok(who, "get_computed_styles", json!({"nodeIds": [id]})).0.to_string(); + assert!(!opacity(&mut s, &a, &x).contains("0.5"), "A's step is gone"); + assert!(opacity(&mut s, &b, &y).contains("0.4"), "B's later step stays"); + // B never undid anything, so B has nothing to redo; A gets its step back. + assert_eq!(s.ok(&b, "redo", json!({})).0["done"], false); + assert_eq!(s.ok(&a, "redo", json!({})).0["done"], true); + assert!(opacity(&mut s, &a, &x).contains("0.5")); +} + +#[test] +fn an_agent_cannot_undo_under_a_later_step_on_the_same_layer() { + let mut s = sim(); + let (a, b) = (agent("A"), agent("B")); + let x = s.board(&a, "X", 0); + s.ok(&a, "update_styles", json!({"updates": [{"nodeIds": [x], "styles": {"opacity": 0.5}}]})); + s.ok(&b, "update_styles", json!({"updates": [{"nodeIds": [x], "styles": {"opacity": 0.4}}]})); + let refused = s.ok(&a, "undo", json!({})).0; + assert_eq!(refused["reason"], "undo_blocked", "{refused}"); + assert!(refused["blockedBy"][0].is_string()); + // The page's undo still takes back the newest step, whoever made it. + assert_eq!(s.ok(&a, "undo", json!({"scope": "page"})).0["done"], true); +}