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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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")));
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user