feat(mcp): let several agents share one project safely

Named CLI agents (--as, --parent), conflict reports and ifRev on writes,
claims that fence layers off, delegate build scopes and per-agent undo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Marcos Paulo
2026-10-28 20:47:02 -03:00
parent 264629ed47
commit e4639054e7
12 changed files with 964 additions and 23 deletions
+9 -9
View File
@@ -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<String>`; `agent_of(query) -> Option<AgentQuery{name,parent}>` next to `chat_of`; `client_of` builds `Client{name, session:"agent:<name>", 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<String>`; `agent_of(query) -> Option<AgentQuery{name,parent}>` next to `chat_of`; `client_of` builds `Client{name, session:"agent:<name>", 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.
+23 -1
View File
@@ -306,8 +306,26 @@ fn runtime() -> Result<tokio::runtime::Runtime, String> {
.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<String>, Option<String>)> = 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<Client, String> {
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<String> = 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");
+1 -1
View File
@@ -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;
}
}
+47
View File
@@ -33,6 +33,8 @@ pub struct Change {
annotations: Option<(Vec<Annotation>, Vec<Annotation>)>,
next_id: (u64, u64),
coalesce_key: Option<String>,
/// The agent that made it (its client key); None for the person.
author: Option<String>,
}
/// 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<String>) -> 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<String>, author: Option<String>) -> bool {
let entries: Vec<Entry> = 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<Option<Undone>, Vec<String>> {
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<String> = 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 {
+13 -6
View File
@@ -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");
+56 -2
View File
@@ -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<RoleServer>) -> Client {
let parts = context.extensions.get::<axum::http::request::Parts>();
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<RoleServer>) -> 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=<id>`).
@@ -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=<name>&parent=<session>`, 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<String>)> {
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<Running, String> {
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();
+1 -1
View File
@@ -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")));
+275
View File
@@ -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<Layer>,
pub resources: Vec<String>,
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<Layer>,
pub resources: Vec<String>,
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<NodeId>) -> 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<Claim>,
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<NodeId>,
) -> Vec<Denied> {
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<NodeId>) -> Result<String, Vec<Denied>> {
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<String> {
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<String> = 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<NodeId> {
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<Layer>, resources: Vec<String>) -> 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::<String>::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");
}
}
+362
View File
@@ -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<String, u64>,
/// Session of the agent that delegated each session.
parents: HashMap<String, String>,
project: Option<PathBuf>,
}
/// The page id and the layer of each (page index, node) pair.
fn layers(ed: &Editor, named: &[(usize, NodeId)]) -> Vec<Layer> {
named.iter().map(|(p, id)| (ed.pages[*p].id.clone(), *id)).collect()
}
fn ancestors_in(ed: &Editor) -> impl Fn(&Layer) -> Vec<NodeId> + '_ {
|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<Value> {
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<PathBuf>) {
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<String> = 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<Denied>, Vec<Denied>) = 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<String> = 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<String> = 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<String> = 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::<Vec<_>>(),
"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<String> = 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::<Vec<_>>(),
"resources": c.resources, "note": c.note, "expiresInSeconds": c.expires_in_secs(now),
})).collect::<Vec<_>>()})
}
/// 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<Client, String> {
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<String> = 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<Value> {
if reach == Reach::Children {
return Vec::new();
}
let mut out: Vec<Value> = 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())])
}
+2 -2
View File
@@ -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 {
+1 -1
View File
@@ -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<Value, String> {
let out = runs.call(host, &client, name, &args, now)?;
+174
View File
@@ -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<Value> = 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": "<div>from A</div>"}));
let (_, swarm) = s.ok(&b, "write_html", json!({"targetNodeId": x, "html": "<div>from B</div>"}));
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": "<div>Header</div>"})).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);
}